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
+16
View File
@@ -169,6 +169,22 @@ describe("renderLocal browser GPU config", () => {
expect(producerState.createdJobs[0]?.format).toBe("png-sequence");
});
it("forwards format: gif and gifLoop through to createRenderJob", async () => {
await renderLocal("/tmp/project", "/tmp/demo.gif", {
fps: { num: 15, den: 1 },
quality: "standard",
format: "gif",
gifLoop: 3,
gpu: false,
browserGpuMode: "software",
hdrMode: "auto",
quiet: true,
});
expect(producerState.createdJobs[0]?.format).toBe("gif");
expect(producerState.createdJobs[0]?.gifLoop).toBe(3);
});
it("omits variables from createRenderJob when not provided", async () => {
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: { num: 30, den: 1 },
+54 -11
View File
@@ -6,7 +6,11 @@ import {
resolveVariablesArg,
validateVariablesAgainstProject,
} from "../utils/variables.js";
import { resolveBrowserTimeoutMsArg, resolveCompositionEntryArg } from "../utils/renderArgs.js";
import {
parseGifLoopArg,
resolveBrowserTimeoutMsArg,
resolveCompositionEntryArg,
} from "../utils/renderArgs.js";
export const examples: Example[] = [
["Render to MP4", "hyperframes render --output output.mp4"],
@@ -17,6 +21,10 @@ export const examples: Example[] = [
],
["Render transparent overlay (ProRes)", "hyperframes render --format mov --output overlay.mov"],
["Render transparent WebM overlay", "hyperframes render --format webm --output overlay.webm"],
[
"Render animated GIF for PRs/docs",
"hyperframes render --format gif --fps 15 --gif-loop 0 --output demo.gif",
],
[
"Render PNG sequence (RGBA frames for AE/Nuke/Fusion)",
"hyperframes render --format png-sequence --output frames/",
@@ -97,22 +105,31 @@ function formatFpsParseError(
return `Got "${input}". Decimal frame rates are ambiguous — use the exact rational form instead (e.g. 30000/1001 for 29.97).`;
}
}
const VALID_FORMAT = new Set(["mp4", "webm", "mov", "png-sequence"]);
const RENDER_FORMATS = ["mp4", "webm", "mov", "png-sequence", "gif"] as const;
type RenderFormat = (typeof RENDER_FORMATS)[number];
const VALID_FORMAT = new Set<string>(RENDER_FORMATS);
const RENDER_FORMAT_LABEL = "mp4, webm, mov, png-sequence, or gif";
// `png-sequence` writes a directory of frames rather than a single muxed file,
// so its "extension" is empty — the auto-output path becomes a directory name.
const FORMAT_EXT: Record<string, string> = {
const FORMAT_EXT: Record<RenderFormat, string> = {
mp4: ".mp4",
webm: ".webm",
mov: ".mov",
"png-sequence": "",
gif: ".gif",
};
const CPU_CORE_COUNT = cpus().length;
function parseRenderFormat(input: string): RenderFormat | undefined {
if (!VALID_FORMAT.has(input)) return undefined;
return RENDER_FORMATS.find((format) => format === input);
}
export default defineCommand({
meta: {
name: "render",
description: "Render a composition to MP4, WebM, MOV, or a PNG sequence",
description: "Render a composition to MP4, WebM, MOV, GIF, or a PNG sequence",
},
args: {
dir: {
@@ -151,11 +168,15 @@ export default defineCommand({
format: {
type: "string",
description:
"Output format: mp4, webm, mov, png-sequence " +
"Output format: mp4, webm, mov, gif, png-sequence " +
"(MOV/WebM render with transparency; png-sequence writes RGBA frames " +
"to a directory for AE/Nuke/Fusion ingest)",
"to a directory for AE/Nuke/Fusion ingest; gif is best at 15fps for PRs/docs)",
default: "mp4",
},
"gif-loop": {
type: "string",
description: "GIF loop count, 0 = infinite. Range: 0-65535. Only used with --format gif.",
},
workers: {
type: "string",
alias: "w",
@@ -299,7 +320,7 @@ export default defineCommand({
errorBox("Invalid fps", formatFpsParseError(args.fps ?? "30", fpsParse.reason));
process.exit(1);
}
const fps: Fps = fpsParse.value;
let fps: Fps = fpsParse.value;
// ── Validate quality ───────────────────────────────────────────────────
const qualityRaw = args.quality ?? "standard";
@@ -311,11 +332,24 @@ export default defineCommand({
// ── Validate format ─────────────────────────────────────────────────
const formatRaw = args.format ?? "mp4";
if (!VALID_FORMAT.has(formatRaw)) {
errorBox("Invalid format", `Got "${formatRaw}". Must be mp4, webm, mov, or png-sequence.`);
const format = parseRenderFormat(formatRaw);
if (!format) {
errorBox("Invalid format", `Got "${formatRaw}". Must be ${RENDER_FORMAT_LABEL}.`);
process.exit(1);
}
const format = formatRaw as "mp4" | "webm" | "mov" | "png-sequence";
let gifFpsCapped = false;
if (format === "gif" && fpsToNumber(fps) > 30) {
fps = { num: 30, den: 1 };
gifFpsCapped = true;
}
const gifLoopParse = parseGifLoopArg(args["gif-loop"]);
if (!gifLoopParse.ok) {
errorBox("Invalid gif-loop", gifLoopParse.message);
process.exit(1);
}
const gifLoop = gifLoopParse.value ?? (format === "gif" ? 0 : undefined);
// ── Validate resolution ────────────────────────────────────────────────
let outputResolution: CanvasResolution | undefined;
@@ -462,6 +496,10 @@ export default defineCommand({
process.exit(1);
}
if (!quiet && gifFpsCapped) {
console.log(c.warn(" GIF output is capped at 30fps. Use --fps 15 for smaller files."));
}
// ── Validate browser-timeout (seconds) and composition entry file ────
// Both validators live in `utils/renderArgs.ts` so the parse/reject
// branches are unit-testable without `process.exit`. See issue #1199
@@ -579,6 +617,7 @@ export default defineCommand({
fps,
quality,
format,
gifLoop,
workers,
gpu: useGpu,
browserGpuMode,
@@ -600,6 +639,7 @@ export default defineCommand({
fps,
quality,
format,
gifLoop,
workers,
gpu: useGpu,
browserGpuMode,
@@ -623,7 +663,8 @@ export default defineCommand({
interface RenderOptions {
fps: Fps;
quality: "draft" | "standard" | "high";
format: "mp4" | "webm" | "mov" | "png-sequence";
format: RenderFormat;
gifLoop?: number;
workers?: number;
gpu: boolean;
/**
@@ -866,6 +907,7 @@ async function renderDocker(
fps: options.fps,
quality: options.quality,
format: options.format,
gifLoop: options.gifLoop,
workers: options.workers,
gpu: options.gpu,
browserGpu: options.browserGpuMode === "hardware",
@@ -953,6 +995,7 @@ export async function renderLocal(
fps: options.fps,
quality: options.quality,
format: options.format,
gifLoop: options.gifLoop,
workers: options.workers,
useGpu: options.gpu,
logger,
@@ -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 };
}