fix(core): associate GIF delays with their image frames (#5272)

This commit is contained in:
Abdullah M. Nazif
2026-10-08 20:20:44 +00:00
committed by GitHub
parent 5a7ada63bf
commit e57d8178c4
3 changed files with 83 additions and 3 deletions
+39
View File
@@ -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));
+8 -2
View File
@@ -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;
}
@@ -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(
'<img data-start="0" data-duration="2" src="reaction.gif" />',
{
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)));