From e57d8178c438a7667c4d68d70acc094acdd01157 Mon Sep 17 00:00:00 2001 From: "Abdullah M. Nazif" Date: Thu, 8 Oct 2026 20:20:44 +0000 Subject: [PATCH] fix(core): associate GIF delays with their image frames (#5272) --- packages/core/src/media/gif.test.ts | 39 +++++++++++++++++++ packages/core/src/media/gif.ts | 10 ++++- .../src/services/animatedGifPrep.test.ts | 37 +++++++++++++++++- 3 files changed, 83 insertions(+), 3 deletions(-) diff --git a/packages/core/src/media/gif.test.ts b/packages/core/src/media/gif.test.ts index 2abbca9dae..f23869d6eb 100644 --- a/packages/core/src/media/gif.test.ts +++ b/packages/core/src/media/gif.test.ts @@ -84,6 +84,45 @@ describe("parseAnimatedGifMetadata", () => { expect(metadata?.durationSeconds).toBe(1); }); + it("gives every image without a control block the browser's minimum delay", () => { + const metadata = parseAnimatedGifMetadata(gif([...frame(5).slice(8), ...frame(15).slice(8)])); + expect(metadata?.delaysCentiseconds).toEqual([10, 10]); + expect(metadata?.durationSeconds).toBe(0.2); + }); + + it("keeps delays aligned when an image has no control block", () => { + const metadata = parseAnimatedGifMetadata( + gif([...frame(5), ...frame(20).slice(8), ...frame(15)]), + ); + expect(metadata?.delaysCentiseconds).toEqual([5, 10, 15]); + expect(metadata?.frameCount).toBe(3); + expect(metadata?.durationSeconds).toBe(0.3); + }); + + it("excludes a trailing control block without an image", () => { + const metadata = parseAnimatedGifMetadata( + gif([...frame(5), ...frame(15), ...frame(200).slice(0, 8)]), + ); + expect(metadata?.delaysCentiseconds).toEqual([5, 15]); + expect(metadata?.durationSeconds).toBe(0.2); + }); + + it("consumes the plain-text control without assigning it to an image", () => { + const plainText = [0x21, 0x01, 12, 0, 0, 0, 0, 1, 0, 1, 0, 1, 1, 1, 0, 1, 65, 0]; + const text = [...frame(200).slice(0, 8), ...plainText]; + const metadata = parseAnimatedGifMetadata(gif([...text, ...frame(5).slice(8), ...frame(15)])); + expect(metadata?.delaysCentiseconds).toEqual([10, 15]); + expect(metadata?.durationSeconds).toBe(0.25); + }); + + it("keeps an image's control through comment extensions", () => { + const comment = [0x21, 0xfe, 1, 65, 0]; + const image = [...frame(5).slice(0, 8), ...comment, ...frame(5).slice(8)]; + const metadata = parseAnimatedGifMetadata(gif([...image, ...frame(15)])); + expect(metadata?.delaysCentiseconds).toEqual([5, 15]); + expect(metadata?.durationSeconds).toBe(0.2); + }); + it("reads Netscape loop metadata", () => { const metadata = parseAnimatedGifMetadata(gif([...frame(8), ...frame(8)], 0)); diff --git a/packages/core/src/media/gif.ts b/packages/core/src/media/gif.ts index 57b89546be..7d465497d4 100644 --- a/packages/core/src/media/gif.ts +++ b/packages/core/src/media/gif.ts @@ -103,6 +103,7 @@ export function parseAnimatedGifMetadata( let frameCount = 0; let controlPackedOffset: number | null = null; + let delayCentiseconds = 0; const delaysCentiseconds: number[] = []; let loopCount: number | null = null; @@ -123,7 +124,7 @@ export function parseAnimatedGifMetadata( if (blockSize !== 4 || pos + 6 > bytes.length) return null; const delay = readU16LE(bytes, pos + 2); if (delay == null) return null; - delaysCentiseconds.push(normalizeDelayCentiseconds(delay)); + delayCentiseconds = delay; controlPackedOffset = pos + 1; pos += 1 + blockSize; if (bytes[pos] !== 0) return null; @@ -139,7 +140,10 @@ export function parseAnimatedGifMetadata( continue; } - if (label === PLAIN_TEXT_LABEL) controlPackedOffset = null; + if (label === PLAIN_TEXT_LABEL) { + controlPackedOffset = null; + delayCentiseconds = 0; + } const next = skipSubBlocks(bytes, pos); if (next == null) return null; pos = next; @@ -161,6 +165,8 @@ export function parseAnimatedGifMetadata( const next = skipSubBlocks(bytes, pos); if (next == null) return null; pos = next; + delaysCentiseconds.push(normalizeDelayCentiseconds(delayCentiseconds)); + delayCentiseconds = 0; frameCount += 1; continue; } diff --git a/packages/producer/src/services/animatedGifPrep.test.ts b/packages/producer/src/services/animatedGifPrep.test.ts index 69f68a01d7..3548851722 100644 --- a/packages/producer/src/services/animatedGifPrep.test.ts +++ b/packages/producer/src/services/animatedGifPrep.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseHTML } from "linkedom"; @@ -132,6 +132,41 @@ describe("prepareAnimatedGifInputs", () => { expect(calls[0]?.args.join(" ")).toContain("-stream_loop 19"); }); + for (const { name, loopCount, expectedLoops, expectedPad } of [ + { name: "looping", loopCount: 0, expectedLoops: 10, expectedPad: 0 }, + { name: "held", loopCount: undefined, expectedLoops: 1, expectedPad: 1.8 }, + ]) { + it(`plans ${name} playback from image delays rather than trailing controls`, async () => { + const projectDir = makeProject(); + try { + writeFileSync( + join(projectDir, "reaction.gif"), + gif([...frame(5), ...frame(15), ...frame(200).slice(0, 8)], loopCount), + ); + const calls: AnimatedGifTranscodeRequest[] = []; + const result = await prepareAnimatedGifInputs( + '', + { + projectDir, + downloadDir: projectDir, + transcode: async (request) => { + calls.push(request); + writeFileSync(request.outputPath, "webm"); + }, + }, + ); + const prepared = result.preparedGifs[0]; + expect(prepared?.metadata.durationSeconds).toBe(0.2); + expect(prepared?.loopIterations).toBe(expectedLoops); + expect(prepared?.padSeconds).toBe(expectedPad); + if (loopCount === 0) expect(calls[0]?.args.join(" ")).toContain("-stream_loop 9"); + else expect(calls[0]?.args.join(" ")).toContain("tpad=stop_mode=clone:stop_duration=1.8"); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + } + it("leaves single-frame GIF images unchanged", async () => { const projectDir = makeProject(); writeFileSync(join(projectDir, "still.gif"), gif(frame(10)));