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
@@ -622,6 +622,9 @@ export async function renderChunk(
// AND the mp4 audio mux.
hasAudio: false,
isPngSequence,
// `DistributedFormat` has no "gif" member — distributed chunks are
// always video segments (gif renders in-process only).
isGif: false,
preset,
effectiveQuality,
effectiveBitrate,
@@ -0,0 +1,43 @@
import { describe, expect, it } from "bun:test";
import { buildGifPalettegenArgs, buildGifPaletteuseArgs } from "./gifEncodeArgs.js";
describe("gif encode args", () => {
const input = {
framesDir: "/tmp/hf/captured-frames",
framePattern: "frame_%06d.jpg",
palettePath: "/tmp/hf/gif-palette.png",
outputPath: "/tmp/hf/demo.gif",
fps: { num: 15, den: 1 },
loop: 0,
};
it("builds the palettegen pass with diff statistics", () => {
expect(buildGifPalettegenArgs(input)).toEqual([
"-y",
"-framerate",
"15",
"-i",
"/tmp/hf/captured-frames/frame_%06d.jpg",
"-vf",
"fps=15,palettegen=stats_mode=diff",
"/tmp/hf/gif-palette.png",
]);
});
it("builds the paletteuse pass with Sierra dithering and loop count", () => {
expect(buildGifPaletteuseArgs({ ...input, loop: 3 })).toEqual([
"-y",
"-framerate",
"15",
"-i",
"/tmp/hf/captured-frames/frame_%06d.jpg",
"-i",
"/tmp/hf/gif-palette.png",
"-lavfi",
"fps=15 [x]; [x][1:v] paletteuse=dither=sierra2_4a",
"-loop",
"3",
"/tmp/hf/demo.gif",
]);
});
});
@@ -4,7 +4,9 @@
* 1. png-sequence: no encoder. Captured PNGs are renamed to
* `frame_NNNNNN.png` and copied to `outputPath`. Audio (if any) is
* written as an `audio.aac` sidecar.
* 2. mp4 / webm / mov: invokes `encodeFramesFromDir` (or the chunked-
* 2. gif: runs a two-pass FFmpeg palette encode and writes directly to
* `outputPath`. GIF has no mux/faststart stage and ignores audio.
* 3. mp4 / webm / mov: invokes `encodeFramesFromDir` (or the chunked-
* concat variant when `enableChunkedEncode` is on) to produce
* `videoOnlyPath`. The mux + faststart pass lives in `assembleStage`.
*
@@ -26,15 +28,25 @@
* `success: false`.
*/
import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
import { dirname, join } from "node:path";
import {
DEFAULT_CONFIG,
encodeFramesChunkedConcat,
encodeFramesFromDir,
formatFfmpegError,
getEncoderPreset,
runFfmpeg,
type EncodeResult,
} from "@hyperframes/engine";
import type { Fps } from "@hyperframes/core";
import type { ProducerLogger } from "../../../logger.js";
import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js";
import {
buildGifPalettegenArgs,
buildGifPaletteuseArgs,
type GifEncodeArgsInput,
} from "./gifEncodeArgs.js";
import { updateJobStatus } from "../shared.js";
export interface EncodeStageInput {
@@ -62,6 +74,8 @@ export interface EncodeStageInput {
audioOutputPath?: string;
/** Mp4 vs png-sequence vs … gates the entire stage branch. */
isPngSequence: boolean;
/** GIF writes directly to `outputPath` via a two-pass palette encode. */
isGif: boolean;
/** Encoder preset (codec, preset, pixelFormat, hdr). Only used on the non-png path. */
preset: ReturnType<typeof getEncoderPreset>;
effectiveQuality: number;
@@ -89,6 +103,88 @@ export interface EncodeStageResult {
encodeMs: number;
}
function resolveGifLoop(loop: number | undefined): number {
const resolved = loop ?? 0;
if (!Number.isInteger(resolved) || resolved < 0 || resolved > 65_535) {
throw new Error(`[Render] gifLoop must be an integer between 0 and 65535 (got ${resolved})`);
}
return resolved;
}
async function encodeGifFromDir(
framesDir: string,
framePattern: string,
outputPath: string,
input: {
fps: Fps;
loop: number;
palettePath: string;
signal?: AbortSignal;
timeout: number;
},
): Promise<EncodeResult> {
const startTime = Date.now();
const files = readdirSync(framesDir).filter((file) => file.match(/\.(jpg|jpeg|png)$/i));
const frameCount = files.length;
if (frameCount === 0) {
return {
success: false,
outputPath,
durationMs: Date.now() - startTime,
framesEncoded: 0,
fileSize: 0,
error: "[FFmpeg] No frame files found in directory",
};
}
const argsInput: GifEncodeArgsInput = {
framesDir,
framePattern,
palettePath: input.palettePath,
outputPath,
fps: input.fps,
loop: input.loop,
};
const paletteResult = await runFfmpeg(buildGifPalettegenArgs(argsInput), {
signal: input.signal,
timeout: input.timeout,
});
if (!paletteResult.success) {
return {
success: false,
outputPath,
durationMs: Date.now() - startTime,
framesEncoded: 0,
fileSize: 0,
error: formatFfmpegError(paletteResult.exitCode, paletteResult.stderr),
};
}
const gifResult = await runFfmpeg(buildGifPaletteuseArgs(argsInput), {
signal: input.signal,
timeout: input.timeout,
});
if (!gifResult.success) {
return {
success: false,
outputPath,
durationMs: Date.now() - startTime,
framesEncoded: 0,
fileSize: 0,
error: formatFfmpegError(gifResult.exitCode, gifResult.stderr),
};
}
const fileSize = existsSync(outputPath) ? statSync(outputPath).size : 0;
return {
success: true,
outputPath,
durationMs: Date.now() - startTime,
framesEncoded: frameCount,
fileSize,
};
}
export async function runEncodeStage(input: EncodeStageInput): Promise<EncodeStageResult> {
const {
job,
@@ -102,6 +198,7 @@ export async function runEncodeStage(input: EncodeStageInput): Promise<EncodeSta
hasAudio,
audioOutputPath,
isPngSequence,
isGif,
preset,
effectiveQuality,
effectiveBitrate,
@@ -144,6 +241,28 @@ export async function runEncodeStage(input: EncodeStageInput): Promise<EncodeSta
return { encodeMs: Date.now() - stage5Start };
}
if (isGif) {
// ── Stage 5 (gif): two-pass palette encode ───────────────────────
updateJobStatus(job, "encoding", "Encoding GIF", 75, onProgress);
if (hasAudio) {
log.warn("[Render] GIF output does not support audio; audio tracks will be ignored.");
}
const framePattern = "frame_%06d.jpg";
const loop = resolveGifLoop(job.config.gifLoop);
const encodeResult = await encodeGifFromDir(framesDir, framePattern, outputPath, {
fps: job.config.fps,
loop,
palettePath: join(dirname(videoOnlyPath), "gif-palette.png"),
signal: abortSignal,
timeout: job.config.producerConfig?.ffmpegEncodeTimeout ?? DEFAULT_CONFIG.ffmpegEncodeTimeout,
});
assertNotAborted();
if (!encodeResult.success) {
throw new Error(`Encoding failed: ${encodeResult.error}`);
}
return { encodeMs: Date.now() - stage5Start };
}
// ── Stage 5: Encode ───────────────────────────────────────────────
updateJobStatus(job, "encoding", "Encoding video", 75, onProgress);
@@ -0,0 +1,47 @@
import { join } from "node:path";
import type { Fps } from "@hyperframes/core";
export interface GifEncodeArgsInput {
framesDir: string;
framePattern: string;
palettePath: string;
outputPath: string;
fps: Fps;
loop: number;
}
function fpsToFfmpegArg(fps: Fps): string {
return fps.den === 1 ? String(fps.num) : `${fps.num}/${fps.den}`;
}
export function buildGifPalettegenArgs(input: GifEncodeArgsInput): string[] {
const fpsArg = fpsToFfmpegArg(input.fps);
return [
"-y",
"-framerate",
fpsArg,
"-i",
join(input.framesDir, input.framePattern),
"-vf",
`fps=${fpsArg},palettegen=stats_mode=diff`,
input.palettePath,
];
}
export function buildGifPaletteuseArgs(input: GifEncodeArgsInput): string[] {
const fpsArg = fpsToFfmpegArg(input.fps);
return [
"-y",
"-framerate",
fpsArg,
"-i",
join(input.framesDir, input.framePattern),
"-i",
input.palettePath,
"-lavfi",
`fps=${fpsArg} [x]; [x][1:v] paletteuse=dither=sierra2_4a`,
"-loop",
String(input.loop),
input.outputPath,
];
}
@@ -261,6 +261,10 @@ export interface RenderConfig {
* - `"mov"`: ProRes 4444 + `yuva444p10le` → **true alpha channel +
* 10-bit color**. Sized for editor ingest (Premiere, Final Cut Pro,
* DaVinci Resolve), not direct web playback. Audio is muxed as AAC.
* - `"gif"`: animated GIF encoded from captured frames with a two-pass
* FFmpeg palette (`palettegen` + `paletteuse`). Use for PRs, READMEs,
* and docs where inline autoplay matters more than file size. No audio
* stream and no alpha channel.
* - `"png-sequence"`: a directory of zero-padded RGBA PNGs
* (`frame_000001.png` …). Lossless alpha, largest on disk, no muxed
* audio (an `audio.aac` sidecar is written alongside the PNGs when
@@ -278,7 +282,9 @@ export interface RenderConfig {
* not paint a fullscreen `body` / `#root` background in their
* compositions when targeting alpha output.
*/
format?: "mp4" | "webm" | "mov" | "png-sequence";
format?: "mp4" | "webm" | "mov" | "png-sequence" | "gif";
/** GIF Netscape loop count. 0 means infinite looping. Only used with `format: "gif"`. */
gifLoop?: number;
workers?: number;
useGpu?: boolean;
debug?: boolean;
@@ -1428,6 +1434,7 @@ export function shouldUseStreamingEncode(
): boolean {
if (!cfg.enableStreamingEncode) return false;
if (outputFormat === "png-sequence") return false;
if (outputFormat === "gif") return false;
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) return false;
if (durationSeconds > cfg.streamingEncodeMaxDurationSeconds) return false;
return workerCount === 1;
@@ -1516,6 +1523,7 @@ export async function executeRenderJob(
const isWebm = outputFormat === "webm";
const isMov = outputFormat === "mov";
const isPngSequence = outputFormat === "png-sequence";
const isGif = outputFormat === "gif";
const needsAlpha = isWebm || isMov || isPngSequence;
// `forceScreenshot` is resolved exactly once inside `compileStage` (alpha
// output + composition `renderModeHints` are folded together there) and
@@ -1981,6 +1989,7 @@ export async function executeRenderJob(
webm: ".webm",
mov: ".mov",
"png-sequence": "",
gif: ".gif",
};
const videoExt = FORMAT_EXT[outputFormat] ?? ".mp4";
const videoOnlyPath = join(workDir, `video-only${videoExt}`);
@@ -1997,9 +2006,11 @@ export async function executeRenderJob(
// exactly the single capture the page-side compositor produces. HDR
// content still forces the layered path (HDR layers need per-layer
// alpha + native HDR raw frame compositing in Node; that's out of scope
// for this opt-in).
// for this opt-in). GIF also uses this path for shader transitions
// because its two-pass palette encoder needs disk frames, not the
// layered path's streaming raw-video encoder.
const usePageSideCompositingForTransitions =
cfg.enablePageSideCompositing &&
(cfg.enablePageSideCompositing || isGif) &&
compiled.hasShaderTransitions &&
!hasHdrContent &&
!isPngSequence &&
@@ -2015,7 +2026,7 @@ export async function executeRenderJob(
!usePageSideCompositingForTransitions &&
shouldUseLayeredComposite({
hasHdrContent,
hasShaderTransitions: compiled.hasShaderTransitions,
hasShaderTransitions: compiled.hasShaderTransitions && !isGif,
isPngSequence,
});
updateCaptureObservability({
@@ -2041,7 +2052,8 @@ export async function executeRenderJob(
// reads `preset.quality` for `effectiveQuality` and `preset.codec` for
// unrelated bookkeeping. Fall back to the mp4 preset shape — its values
// are never written to ffmpeg in the png-sequence path.
const presetFormat: "mp4" | "webm" | "mov" = isPngSequence ? "mp4" : outputFormat;
const presetFormat: "mp4" | "webm" | "mov" =
outputFormat === "webm" || outputFormat === "mov" ? outputFormat : "mp4";
const preset = getEncoderPreset(job.config.quality, presetFormat, encoderHdr);
// CLI overrides (--crf, --video-bitrate) flow through job.config and must
@@ -2219,7 +2231,7 @@ export async function executeRenderJob(
const encodeRes = await observeRenderStage(
observability,
"encode",
{ hasAudio, isPngSequence, chunkedEncode: enableChunkedEncode },
{ hasAudio, isPngSequence, isGif, chunkedEncode: enableChunkedEncode },
() =>
runEncodeStage({
job,
@@ -2233,6 +2245,7 @@ export async function executeRenderJob(
hasAudio,
audioOutputPath,
isPngSequence,
isGif,
preset,
effectiveQuality,
effectiveBitrate,
@@ -2261,9 +2274,10 @@ export async function executeRenderJob(
fileServer = null;
// ── Stage 6: Assemble ───────────────────────────────────────────────
// Skipped for png-sequence — there is no encoded video to mux/faststart.
// The frames were copied directly to outputPath in Stage 5.
if (!isPngSequence) {
// Skipped for formats with no mux/faststart step. png-sequence is a
// directory deliverable, and gif is written directly to outputPath by the
// two-pass palette encoder.
if (!isPngSequence && !isGif) {
const assembleRes = await observeRenderStage(observability, "assemble", { hasAudio }, () =>
runAssembleStage({
job,
@@ -2278,7 +2292,7 @@ export async function executeRenderJob(
);
perfStages.assembleMs = assembleRes.assembleMs;
} else {
observability.checkpoint("assemble", "skipped for png-sequence");
observability.checkpoint("assemble", `skipped for ${outputFormat}`);
}
// ── Complete ─────────────────────────────────────────────────────────