1- import { describe , it , expect , vi } from "vitest" ;
1+ import { EventEmitter } from "node:events" ;
2+ import { mkdirSync , mkdtempSync , rmSync , writeFileSync } from "node:fs" ;
3+ import { tmpdir } from "node:os" ;
4+ import { join } from "node:path" ;
5+ import { afterEach , describe , it , expect , vi } from "vitest" ;
26import { ENCODER_PRESETS , getEncoderPreset , buildEncoderArgs } from "./chunkEncoder.js" ;
37
8+ const TINY_PNG = Buffer . from (
9+ "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAACXBIWXMAAAABAAAAAQBPJcTWAAAAEElEQVR4nGP8wwACLGCSAQANBAECv1AVswAAAABJRU5ErkJggg==" ,
10+ "base64" ,
11+ ) ;
12+
13+ const tempDirs : string [ ] = [ ] ;
14+
15+ afterEach ( ( ) => {
16+ for ( const dir of tempDirs . splice ( 0 ) ) {
17+ rmSync ( dir , { recursive : true , force : true } ) ;
18+ }
19+ vi . resetModules ( ) ;
20+ vi . doUnmock ( "child_process" ) ;
21+ vi . useRealTimers ( ) ;
22+ } ) ;
23+
24+ function createFrameFixture ( ) : { root : string ; framesDir : string } {
25+ const root = mkdtempSync ( join ( tmpdir ( ) , "hf-chunk-encoder-" ) ) ;
26+ tempDirs . push ( root ) ;
27+ const framesDir = join ( root , "frames" ) ;
28+ mkdirSync ( framesDir ) ;
29+ for ( let i = 1 ; i <= 2 ; i ++ ) {
30+ writeFileSync ( join ( framesDir , `frame_${ String ( i ) . padStart ( 6 , "0" ) } .png` ) , TINY_PNG ) ;
31+ }
32+ return { root, framesDir } ;
33+ }
34+
35+ const tinyEncodeOptions = {
36+ fps : { num : 30 , den : 1 } ,
37+ width : 2 ,
38+ height : 2 ,
39+ codec : "h264" as const ,
40+ preset : "ultrafast" ,
41+ quality : 28 ,
42+ pixelFormat : "yuv420p" ,
43+ useGpu : false ,
44+ } ;
45+
46+ function encodeTimeoutMessage ( timeoutMs : number ) : string {
47+ return `FFmpeg killed after exceeding ffmpegEncodeTimeout (${ timeoutMs } ms)` ;
48+ }
49+
50+ type FakeProc = EventEmitter & {
51+ stderr : EventEmitter ;
52+ kill : ReturnType < typeof vi . fn > ;
53+ killed : boolean ;
54+ } ;
55+
56+ type SpawnCall = {
57+ command : string ;
58+ args : readonly string [ ] ;
59+ proc : FakeProc ;
60+ } ;
61+
62+ function createFakeProc ( ) : FakeProc {
63+ const proc = new EventEmitter ( ) as FakeProc ;
64+ proc . stderr = new EventEmitter ( ) ;
65+ proc . kill = vi . fn ( ( ) => {
66+ proc . killed = true ;
67+ return true ;
68+ } ) ;
69+ proc . killed = false ;
70+ return proc ;
71+ }
72+
73+ function createSpawnSpy ( ) : {
74+ spawn : ( command : string , args : readonly string [ ] ) => FakeProc ;
75+ calls : SpawnCall [ ] ;
76+ } {
77+ const calls : SpawnCall [ ] = [ ] ;
78+ const spawn = ( command : string , args : readonly string [ ] ) : FakeProc => {
79+ const proc = createFakeProc ( ) ;
80+ calls . push ( { command, args, proc } ) ;
81+ return proc ;
82+ } ;
83+ return { spawn, calls } ;
84+ }
85+
86+ function emitClose ( proc : FakeProc , code : number ) : void {
87+ proc . emit ( "exit" , code ) ;
88+ proc . emit ( "close" , code ) ;
89+ }
90+
491describe ( "ENCODER_PRESETS" , ( ) => {
592 it ( "has draft, standard, and high presets" , ( ) => {
693 expect ( ENCODER_PRESETS ) . toHaveProperty ( "draft" ) ;
@@ -26,6 +113,248 @@ describe("ENCODER_PRESETS", () => {
26113 } ) ;
27114} ) ;
28115
116+ describe ( "encodeFramesFromDir ffmpegEncodeTimeout" , ( ) => {
117+ it ( "kills ffmpeg when config timeout elapses" , async ( ) => {
118+ vi . useFakeTimers ( ) ;
119+ const { spawn, calls } = createSpawnSpy ( ) ;
120+ vi . resetModules ( ) ;
121+ vi . doMock ( "child_process" , ( ) => ( { spawn } ) ) ;
122+
123+ const { encodeFramesFromDir } = await import ( "./chunkEncoder.js" ) ;
124+ const { root, framesDir } = createFrameFixture ( ) ;
125+
126+ const encodePromise = encodeFramesFromDir (
127+ framesDir ,
128+ "frame_%06d.png" ,
129+ join ( root , "timeout.mp4" ) ,
130+ tinyEncodeOptions ,
131+ undefined ,
132+ { ffmpegEncodeTimeout : 1000 } ,
133+ ) ;
134+
135+ expect ( calls ) . toHaveLength ( 1 ) ;
136+ const proc = calls [ 0 ] ! . proc ;
137+ vi . advanceTimersByTime ( 999 ) ;
138+ expect ( proc . kill ) . not . toHaveBeenCalled ( ) ;
139+
140+ vi . advanceTimersByTime ( 1 ) ;
141+ expect ( proc . kill ) . toHaveBeenCalledWith ( "SIGTERM" ) ;
142+
143+ proc . stderr . emit ( "data" , Buffer . from ( "terminated by timeout\n" ) ) ;
144+ emitClose ( proc , 143 ) ;
145+
146+ const result = await encodePromise ;
147+ expect ( result . success ) . toBe ( false ) ;
148+ expect ( result . error ) . toContain ( "FFmpeg exited with code 143" ) ;
149+ expect ( result . error ) . toContain ( "terminated by timeout" ) ;
150+ expect ( result . error ) . toContain ( encodeTimeoutMessage ( 1000 ) ) ;
151+ } ) ;
152+
153+ it ( "keeps non-timeout ffmpeg failures unchanged" , async ( ) => {
154+ vi . useFakeTimers ( ) ;
155+ const { spawn, calls } = createSpawnSpy ( ) ;
156+ vi . resetModules ( ) ;
157+ vi . doMock ( "child_process" , ( ) => ( { spawn } ) ) ;
158+
159+ const { encodeFramesFromDir } = await import ( "./chunkEncoder.js" ) ;
160+ const { root, framesDir } = createFrameFixture ( ) ;
161+
162+ const encodePromise = encodeFramesFromDir (
163+ framesDir ,
164+ "frame_%06d.png" ,
165+ join ( root , "failure.mp4" ) ,
166+ tinyEncodeOptions ,
167+ undefined ,
168+ { ffmpegEncodeTimeout : 1000 } ,
169+ ) ;
170+
171+ expect ( calls ) . toHaveLength ( 1 ) ;
172+ const proc = calls [ 0 ] ! . proc ;
173+ proc . stderr . emit ( "data" , Buffer . from ( "encoder failed\n" ) ) ;
174+ emitClose ( proc , 1 ) ;
175+
176+ const result = await encodePromise ;
177+ expect ( result . success ) . toBe ( false ) ;
178+ expect ( result . error ) . toContain ( "FFmpeg exited with code 1" ) ;
179+ expect ( result . error ) . toContain ( "encoder failed" ) ;
180+ expect ( result . error ) . not . toContain ( "ffmpegEncodeTimeout" ) ;
181+ } ) ;
182+
183+ it ( "uses the default timeout when config is omitted" , async ( ) => {
184+ vi . useFakeTimers ( ) ;
185+ const { spawn, calls } = createSpawnSpy ( ) ;
186+ vi . resetModules ( ) ;
187+ vi . doMock ( "child_process" , ( ) => ( { spawn } ) ) ;
188+
189+ const { encodeFramesFromDir } = await import ( "./chunkEncoder.js" ) ;
190+ const { root, framesDir } = createFrameFixture ( ) ;
191+
192+ const encodePromise = encodeFramesFromDir (
193+ framesDir ,
194+ "frame_%06d.png" ,
195+ join ( root , "default.mp4" ) ,
196+ tinyEncodeOptions ,
197+ ) ;
198+
199+ expect ( calls ) . toHaveLength ( 1 ) ;
200+ const proc = calls [ 0 ] ! . proc ;
201+ vi . advanceTimersByTime ( 599_999 ) ;
202+ expect ( proc . kill ) . not . toHaveBeenCalled ( ) ;
203+
204+ emitClose ( proc , 0 ) ;
205+
206+ const result = await encodePromise ;
207+ expect ( result . success ) . toBe ( true ) ;
208+ expect ( result . framesEncoded ) . toBe ( 2 ) ;
209+ expect ( result . fileSize ) . toBe ( 0 ) ;
210+ } ) ;
211+ } ) ;
212+
213+ describe ( "encodeFramesChunkedConcat ffmpegEncodeTimeout" , ( ) => {
214+ it ( "passes config timeout to per-chunk encodes" , async ( ) => {
215+ vi . useFakeTimers ( ) ;
216+ const { spawn, calls } = createSpawnSpy ( ) ;
217+ vi . resetModules ( ) ;
218+ vi . doMock ( "child_process" , ( ) => ( { spawn } ) ) ;
219+
220+ const { encodeFramesChunkedConcat } = await import ( "./chunkEncoder.js" ) ;
221+ const { root, framesDir } = createFrameFixture ( ) ;
222+
223+ const encodePromise = encodeFramesChunkedConcat (
224+ framesDir ,
225+ "frame_%06d.png" ,
226+ join ( root , "chunked.mp4" ) ,
227+ tinyEncodeOptions ,
228+ 30 ,
229+ undefined ,
230+ { ffmpegEncodeTimeout : 1000 } ,
231+ ) ;
232+
233+ expect ( calls ) . toHaveLength ( 1 ) ;
234+ const proc = calls [ 0 ] ! . proc ;
235+ vi . advanceTimersByTime ( 999 ) ;
236+ expect ( proc . kill ) . not . toHaveBeenCalled ( ) ;
237+
238+ vi . advanceTimersByTime ( 1 ) ;
239+ expect ( proc . kill ) . toHaveBeenCalledWith ( "SIGTERM" ) ;
240+
241+ proc . stderr . emit ( "data" , Buffer . from ( "chunk timeout\n" ) ) ;
242+ emitClose ( proc , 143 ) ;
243+
244+ const result = await encodePromise ;
245+ expect ( result . success ) . toBe ( false ) ;
246+ expect ( result . error ) . toContain ( "Chunk 0 encode failed" ) ;
247+ expect ( result . error ) . toContain ( "chunk timeout" ) ;
248+ expect ( result . error ) . toContain ( encodeTimeoutMessage ( 1000 ) ) ;
249+ } ) ;
250+
251+ it ( "keeps non-timeout chunk failures unchanged" , async ( ) => {
252+ vi . useFakeTimers ( ) ;
253+ const { spawn, calls } = createSpawnSpy ( ) ;
254+ vi . resetModules ( ) ;
255+ vi . doMock ( "child_process" , ( ) => ( { spawn } ) ) ;
256+
257+ const { encodeFramesChunkedConcat } = await import ( "./chunkEncoder.js" ) ;
258+ const { root, framesDir } = createFrameFixture ( ) ;
259+
260+ const encodePromise = encodeFramesChunkedConcat (
261+ framesDir ,
262+ "frame_%06d.png" ,
263+ join ( root , "chunked-failure.mp4" ) ,
264+ tinyEncodeOptions ,
265+ 30 ,
266+ undefined ,
267+ { ffmpegEncodeTimeout : 1000 } ,
268+ ) ;
269+
270+ expect ( calls ) . toHaveLength ( 1 ) ;
271+ const proc = calls [ 0 ] ! . proc ;
272+ proc . stderr . emit ( "data" , Buffer . from ( "chunk failed\n" ) ) ;
273+ emitClose ( proc , 1 ) ;
274+
275+ const result = await encodePromise ;
276+ expect ( result . success ) . toBe ( false ) ;
277+ expect ( result . error ) . toBe ( "Chunk 0 encode failed: chunk failed\n" ) ;
278+ expect ( result . error ) . not . toContain ( "ffmpegEncodeTimeout" ) ;
279+ } ) ;
280+
281+ it ( "kills concat ffmpeg when config timeout elapses" , async ( ) => {
282+ vi . useFakeTimers ( ) ;
283+ const { spawn, calls } = createSpawnSpy ( ) ;
284+ vi . resetModules ( ) ;
285+ vi . doMock ( "child_process" , ( ) => ( { spawn } ) ) ;
286+
287+ const { encodeFramesChunkedConcat } = await import ( "./chunkEncoder.js" ) ;
288+ const { root, framesDir } = createFrameFixture ( ) ;
289+
290+ const encodePromise = encodeFramesChunkedConcat (
291+ framesDir ,
292+ "frame_%06d.png" ,
293+ join ( root , "concat-timeout.mp4" ) ,
294+ tinyEncodeOptions ,
295+ 30 ,
296+ undefined ,
297+ { ffmpegEncodeTimeout : 1000 } ,
298+ ) ;
299+
300+ expect ( calls ) . toHaveLength ( 1 ) ;
301+ emitClose ( calls [ 0 ] ! . proc , 0 ) ;
302+ await Promise . resolve ( ) ;
303+
304+ expect ( calls ) . toHaveLength ( 2 ) ;
305+ const concatProc = calls [ 1 ] ! . proc ;
306+ vi . advanceTimersByTime ( 999 ) ;
307+ expect ( concatProc . kill ) . not . toHaveBeenCalled ( ) ;
308+
309+ vi . advanceTimersByTime ( 1 ) ;
310+ expect ( concatProc . kill ) . toHaveBeenCalledWith ( "SIGTERM" ) ;
311+
312+ concatProc . stderr . emit ( "data" , Buffer . from ( "concat timeout\n" ) ) ;
313+ emitClose ( concatProc , 143 ) ;
314+
315+ const result = await encodePromise ;
316+ expect ( result . success ) . toBe ( false ) ;
317+ expect ( result . error ) . toContain ( "Chunk concat failed" ) ;
318+ expect ( result . error ) . toContain ( "concat timeout" ) ;
319+ expect ( result . error ) . toContain ( encodeTimeoutMessage ( 1000 ) ) ;
320+ } ) ;
321+
322+ it ( "uses the default timeout for per-chunk encodes when config is omitted" , async ( ) => {
323+ vi . useFakeTimers ( ) ;
324+ const { spawn, calls } = createSpawnSpy ( ) ;
325+ vi . resetModules ( ) ;
326+ vi . doMock ( "child_process" , ( ) => ( { spawn } ) ) ;
327+
328+ const { encodeFramesChunkedConcat } = await import ( "./chunkEncoder.js" ) ;
329+ const { root, framesDir } = createFrameFixture ( ) ;
330+
331+ const encodePromise = encodeFramesChunkedConcat (
332+ framesDir ,
333+ "frame_%06d.png" ,
334+ join ( root , "chunked-default.mp4" ) ,
335+ tinyEncodeOptions ,
336+ 30 ,
337+ ) ;
338+
339+ expect ( calls ) . toHaveLength ( 1 ) ;
340+ const chunkProc = calls [ 0 ] ! . proc ;
341+ vi . advanceTimersByTime ( 599_999 ) ;
342+ expect ( chunkProc . kill ) . not . toHaveBeenCalled ( ) ;
343+
344+ emitClose ( chunkProc , 0 ) ;
345+ await Promise . resolve ( ) ;
346+
347+ expect ( calls ) . toHaveLength ( 2 ) ;
348+ const concatProc = calls [ 1 ] ! . proc ;
349+ emitClose ( concatProc , 0 ) ;
350+
351+ const result = await encodePromise ;
352+ expect ( result . success ) . toBe ( true ) ;
353+ expect ( result . framesEncoded ) . toBe ( 2 ) ;
354+ expect ( result . fileSize ) . toBe ( 0 ) ;
355+ } ) ;
356+ } ) ;
357+
29358describe ( "getEncoderPreset" , ( ) => {
30359 it ( "returns h264 with yuv420p for mp4 format" , ( ) => {
31360 const preset = getEncoderPreset ( "standard" , "mp4" ) ;
0 commit comments