1- import { mkdirSync , mkdtempSync , rmSync , writeFileSync } from "node:fs" ;
1+ import { mkdirSync , mkdtempSync , readFileSync , rmSync , writeFileSync } from "node:fs" ;
22import { tmpdir } from "node:os" ;
33import { join } from "node:path" ;
44import { afterEach , describe , expect , it , mock } from "bun:test" ;
@@ -131,6 +131,8 @@ describe("gif encode args", () => {
131131 "-y" ,
132132 "-framerate" ,
133133 "15" ,
134+ "-reinit_filter" ,
135+ "0" ,
134136 "-i" ,
135137 "/tmp/hf/captured-frames/frame_%06d.jpg" ,
136138 "-vf" ,
@@ -144,6 +146,8 @@ describe("gif encode args", () => {
144146 "-y" ,
145147 "-framerate" ,
146148 "15" ,
149+ "-reinit_filter" ,
150+ "0" ,
147151 "-i" ,
148152 "/tmp/hf/captured-frames/frame_%06d.jpg" ,
149153 "-i" ,
@@ -402,20 +406,31 @@ describe("runEncodeStage config plumbing", () => {
402406 ) ;
403407 } ) ;
404408
405- it ( "encodes alpha GIFs from PNG frames with explicit transparency filters" , async ( ) => {
409+ async function encodeAlphaGif ( paths : { framesDir : string ; root : string } , outputPath : string ) {
406410 const { runEncodeStage } = await import ( "./encodeStage.js" ) ;
407- const paths = createFramesDir ( "png" ) ;
408-
409- await runEncodeStage (
411+ return runEncodeStage (
410412 makeInput ( {
411413 framesDir : paths . framesDir ,
412- outputPath : join ( paths . root , "out.gif" ) ,
414+ outputPath,
413415 videoOnlyPath : join ( paths . root , "video-only.mp4" ) ,
414416 isGif : true ,
415417 needsAlpha : true ,
416418 captureImageFormat : "png" ,
417419 } ) ,
418420 ) ;
421+ }
422+
423+ it ( "encodes alpha GIFs from PNG frames with explicit transparency filters" , async ( ) => {
424+ const paths = createFramesDir ( "png" ) ;
425+ // One 1x1 frame whose graphic control block says "leave in place" (disposal 1).
426+ const oneFrameGif = Buffer . from (
427+ "47494638396101000100800000000000ffffff21f90405000000002c00000000010001000002024401003b" ,
428+ "hex" ,
429+ ) ;
430+ const outputPath = join ( paths . root , "out.gif" ) ;
431+ writeFileSync ( outputPath , oneFrameGif ) ;
432+
433+ await encodeAlphaGif ( paths , outputPath ) ;
419434
420435 expect ( runFfmpegMock ) . toHaveBeenCalledTimes ( 2 ) ;
421436 expect ( runFfmpegMock . mock . calls [ 0 ] ?. [ 0 ] ) . toContain ( join ( paths . framesDir , "frame_%06d.png" ) ) ;
@@ -426,6 +441,40 @@ describe("runEncodeStage config plumbing", () => {
426441 expect ( runFfmpegMock . mock . calls [ 1 ] ?. [ 0 ] ) . toContain (
427442 "fps=30 [x]; [x][1:v] paletteuse=dither=sierra2_4a:alpha_threshold=128" ,
428443 ) ;
444+ // No frame turns translucent after an opaque one, so the plain encode is left untouched.
445+ expect ( readFileSync ( outputPath ) . equals ( oneFrameGif ) ) . toBe ( true ) ;
446+ } ) ;
447+
448+ it ( "re-encodes whole frames and clears them when a translucent frame follows an opaque one" , async ( ) => {
449+ const paths = createFramesDir ( "png" ) ;
450+ const image = "2c0000000001000100000202440100" ;
451+ // Frame 1 is left in place (opaque); frame 2 clears to background with transparent index 0.
452+ const staleProne = Buffer . from (
453+ `47494638396101000100800000000000ffffff21f9040400000000${ image } 21f9040900000000${ image } 3b` ,
454+ "hex" ,
455+ ) ;
456+ const outputPath = join ( paths . root , "out.gif" ) ;
457+ writeFileSync ( outputPath , staleProne ) ;
458+
459+ await encodeAlphaGif ( paths , outputPath ) ;
460+
461+ expect ( runFfmpegMock ) . toHaveBeenCalledTimes ( 3 ) ;
462+ expect ( runFfmpegMock . mock . calls [ 1 ] ?. [ 0 ] ) . not . toContain ( "-gifflags" ) ;
463+ expect ( runFfmpegMock . mock . calls [ 2 ] ?. [ 0 ] ) . toContain ( "-gifflags" ) ;
464+ const out = readFileSync ( outputPath ) ;
465+ const packed = [ ...out . keys ( ) ]
466+ . filter ( ( i ) => out [ i ] === 0x21 && out [ i + 1 ] === 0xf9 && out [ i + 2 ] === 0x04 )
467+ . map ( ( i ) => out [ i + 3 ] ) ;
468+ expect ( packed ) . toEqual ( [ 0b0000_1001 , 0b0000_1001 ] ) ;
469+ } ) ;
470+
471+ it ( "fails the encode when the GIF it wrote cannot be read back" , async ( ) => {
472+ const paths = createFramesDir ( "png" ) ;
473+ const outputPath = join ( paths . root , "out.gif" ) ;
474+ writeFileSync ( outputPath , "not a gif" ) ;
475+
476+ await expect ( encodeAlphaGif ( paths , outputPath ) ) . rejects . toThrow ( "could not be parsed" ) ;
477+ expect ( runFfmpegMock ) . toHaveBeenCalledTimes ( 3 ) ;
429478 } ) ;
430479
431480 it ( "keeps opaque GIF encoding on JPEG frames without alpha-only filters" , async ( ) => {
@@ -442,6 +491,7 @@ describe("runEncodeStage config plumbing", () => {
442491 } ) ,
443492 ) ;
444493
494+ expect ( runFfmpegMock . mock . calls [ 1 ] ?. [ 0 ] ) . not . toContain ( "-gifflags" ) ;
445495 expect ( runFfmpegMock . mock . calls [ 0 ] ?. [ 0 ] ) . toContain ( join ( paths . framesDir , "frame_%06d.jpg" ) ) ;
446496 expect ( runFfmpegMock . mock . calls [ 0 ] ?. [ 0 ] ) . not . toContain ( "reserve_transparent" ) ;
447497 expect ( runFfmpegMock . mock . calls [ 1 ] ?. [ 0 ] ) . not . toContain ( "alpha_threshold" ) ;
0 commit comments