From a301bb8b577c178023f818dd9b86153efcaba64b Mon Sep 17 00:00:00 2001 From: kapelame <168134658+kapelame@users.noreply.github.com> Date: Mon, 14 Sep 2026 11:29:46 -0400 Subject: [PATCH] fix(producer): honor disabled looping for finite-loop GIF inputs --- .../producer/src/services/animatedGifPrep.test.ts | 9 +++++++-- packages/producer/src/services/animatedGifPrep.ts | 15 ++++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/producer/src/services/animatedGifPrep.test.ts b/packages/producer/src/services/animatedGifPrep.test.ts index 69f68a01d7..d3725e7498 100644 --- a/packages/producer/src/services/animatedGifPrep.test.ts +++ b/packages/producer/src/services/animatedGifPrep.test.ts @@ -147,15 +147,17 @@ describe("prepareAnimatedGifInputs", () => { expect(result.preparedAssets.size).toBe(0); }); - it("lets data-loop override infinite GIF metadata", async () => { + it.each([0, 3])("lets data-loop=false override GIF loop count %i", async (loopCount) => { const projectDir = makeProject(); - writeFileSync(join(projectDir, "reaction.gif"), gif([...frame(10), ...frame(10)], 0)); + writeFileSync(join(projectDir, "reaction.gif"), gif([...frame(10), ...frame(10)], loopCount)); + const calls: AnimatedGifTranscodeRequest[] = []; const result = await prepareAnimatedGifInputs( ``, { projectDir, downloadDir: projectDir, transcode: async (request) => { + calls.push(request); writeFileSync(request.outputPath, "webm"); }, }, @@ -163,6 +165,9 @@ describe("prepareAnimatedGifInputs", () => { const { document } = parseHTML(result.html); expect(document.querySelector("video")?.hasAttribute("loop")).toBe(false); + expect(result.preparedGifs[0]?.loopIterations).toBe(1); + expect(result.preparedGifs[0]?.padSeconds).toBe(1.8); + expect(calls[0]?.args).not.toContain("-stream_loop"); }); it("expands finite loop metadata into the transcoded source", async () => { diff --git a/packages/producer/src/services/animatedGifPrep.ts b/packages/producer/src/services/animatedGifPrep.ts index e8ef90de33..fdaf7b4fa6 100644 --- a/packages/producer/src/services/animatedGifPrep.ts +++ b/packages/producer/src/services/animatedGifPrep.ts @@ -177,18 +177,18 @@ function resolveClipWindowSeconds(img: Element, compositionDuration: number | nu function resolvePreparedPlayback( metadata: AnimatedGifMetadata, - loop: boolean, + loopOverride: boolean | null, windowSeconds: number | null, ): { loopIterations: number; padSeconds: number } { const gifDuration = metadata.durationSeconds > 0 ? metadata.durationSeconds : null; let loopIterations = 1; - if (loop) { + if (resolveLoop(metadata, loopOverride)) { loopIterations = windowSeconds != null && gifDuration != null ? Math.min(MAX_LOOP_ITERATIONS, Math.max(1, Math.ceil(windowSeconds / gifDuration))) : 1; - } else if (metadata.loopCount != null && metadata.loopCount > 1) { + } else if (loopOverride == null && metadata.loopCount != null && metadata.loopCount > 1) { loopIterations = Math.min(MAX_LOOP_ITERATIONS, metadata.loopCount); } @@ -395,9 +395,14 @@ export async function prepareAnimatedGifInputs( const metadata = parseAnimatedGifMetadata(bytes); if (!metadata?.animated) continue; - const loop = resolveLoop(metadata, readLoopOverride(img)); + const loopOverride = readLoopOverride(img); + const loop = resolveLoop(metadata, loopOverride); const windowSeconds = resolveClipWindowSeconds(img, compositionDuration); - const { loopIterations, padSeconds } = resolvePreparedPlayback(metadata, loop, windowSeconds); + const { loopIterations, padSeconds } = resolvePreparedPlayback( + metadata, + loopOverride, + windowSeconds, + ); const hash = computePreparedGifHash(bytes, loopIterations, padSeconds); const filename = `${CACHE_SCHEMA}-${hash.slice(0, 24)}.webm`; const cachePath = join(cacheDir, filename);