feat(cli,producer): add gif output format with two-pass palette encode (#1333)

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
This commit is contained in:
Matt Van Horn
2026-06-10 21:17:55 -04:00
committed by GitHub
co-authored by Matt Van Horn
parent e0ecd4d2d1
commit e6b8d66c2d
13 changed files with 385 additions and 29 deletions
@@ -200,6 +200,20 @@ describe("buildDockerRunArgs", () => {
expect(args[formatIdx + 1]).toBe("png-sequence");
});
it("forwards --format gif and --gif-loop to the container", () => {
const args = buildDockerRunArgs({
...FIXED_INPUT,
outputFilename: "demo.gif",
options: { ...BASE, format: "gif", gifLoop: 0 },
});
const formatIdx = args.indexOf("--format");
const loopIdx = args.indexOf("--gif-loop");
expect(formatIdx).toBeGreaterThanOrEqual(0);
expect(args[formatIdx + 1]).toBe("gif");
expect(loopIdx).toBeGreaterThanOrEqual(0);
expect(args[loopIdx + 1]).toBe("0");
});
it("forwards --video-bitrate to the container when set", () => {
const args = buildDockerRunArgs({
...FIXED_INPUT,
+3 -1
View File
@@ -39,7 +39,8 @@ export interface DockerRenderOptions {
*/
fps: Fps;
quality: "draft" | "standard" | "high";
format: "mp4" | "webm" | "mov" | "png-sequence";
format: "mp4" | "webm" | "mov" | "png-sequence" | "gif";
gifLoop?: number;
workers?: number;
gpu: boolean;
browserGpu: boolean;
@@ -116,6 +117,7 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
options.quality,
"--format",
options.format,
...(options.gifLoop != null ? ["--gif-loop", String(options.gifLoop)] : []),
...(options.workers != null ? ["--workers", String(options.workers)] : []),
...(options.crf != null ? ["--crf", String(options.crf)] : []),
...(options.videoBitrate ? ["--video-bitrate", options.videoBitrate] : []),
+16
View File
@@ -5,6 +5,7 @@ import {
MAX_PAGE_NAVIGATION_TIMEOUT_SECONDS,
parseBrowserTimeoutMsArg,
parseCompositionEntryArg,
parseGifLoopArg,
type BrowserTimeoutParseResult,
type CompositionEntryParseResult,
} from "./renderArgs.js";
@@ -171,3 +172,18 @@ describe("parseCompositionEntryArg", () => {
expect(err.kind).toBe("outside-project");
});
});
describe("parseGifLoopArg", () => {
it("accepts absent flag, bounds, and integers", () => {
expect(parseGifLoopArg(undefined)).toEqual({ ok: true, value: undefined });
expect(parseGifLoopArg("0")).toEqual({ ok: true, value: 0 });
expect(parseGifLoopArg("65535")).toEqual({ ok: true, value: 65535 });
});
it("rejects out-of-range, non-integer, and empty inputs", () => {
expect(parseGifLoopArg("-1").ok).toBe(false);
expect(parseGifLoopArg("65536").ok).toBe(false);
expect(parseGifLoopArg("1.5").ok).toBe(false);
expect(parseGifLoopArg(" ").ok).toBe(false);
});
});
+25
View File
@@ -219,3 +219,28 @@ export function resolveCompositionEntryArg(
}
return result.value;
}
export type GifLoopParseResult =
| { ok: true; value: number | undefined }
| { ok: false; message: string };
/**
* Parse and validate `--gif-loop <count>` (GIF Netscape loop count).
* Returns `{ ok: true, value: undefined }` when the flag is absent so the
* caller can apply the format-dependent default (0 = infinite for gif).
*/
export function parseGifLoopArg(raw: string | undefined): GifLoopParseResult {
if (raw === undefined) return { ok: true, value: undefined };
const trimmed = raw.trim();
if (trimmed.length === 0) {
return { ok: false, message: "GIF loop count must not be empty." };
}
const parsed = Number(trimmed);
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65_535) {
return {
ok: false,
message: `Got "${raw}". GIF loop count must be an integer between 0 and 65535.`,
};
}
return { ok: true, value: parsed };
}