mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
fix(core): associate GIF delays with their image frames (#5272)
This commit is contained in:
@@ -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));
|
||||
|
||||
|
||||
@@ -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)));
|
||||
|
||||
Reference in New Issue
Block a user