mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
co-authored by
Matt Van Horn
parent
e0ecd4d2d1
commit
e6b8d66c2d
+2
-1
@@ -112,8 +112,9 @@ This is the same pipeline that handles compositions where, for example, an HDR d
|
||||
| `mp4` | Yes — H.265 10-bit BT.2020, HDR10 metadata |
|
||||
| `mov` | No — falls back to SDR |
|
||||
| `webm` | No — falls back to SDR |
|
||||
| `gif` | No — falls back to SDR |
|
||||
|
||||
If HDR is enabled and you also pass `--format mov` or `--format webm`, Hyperframes logs a message and produces the equivalent SDR render. There is no error — the render still completes — so check the logs (or your verification step) to confirm you got HDR.
|
||||
If HDR is enabled and you also pass `--format mov`, `--format webm`, or `--format gif`, Hyperframes logs a message and produces the equivalent SDR render. There is no error — the render still completes — so check the logs (or your verification step) to confirm you got HDR.
|
||||
|
||||
## Verifying HDR Output
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
title: Rendering
|
||||
description: "Render compositions to MP4, MOV, or WebM locally or in Docker."
|
||||
description: "Render compositions to MP4, MOV, WebM, GIF, or PNG sequences locally or in Docker."
|
||||
---
|
||||
|
||||
Render your Hyperframes [compositions](/concepts/compositions) to MP4, MOV, or WebM with the [CLI](/packages/cli). The rendering pipeline is frame-by-frame and seek-driven — see [Deterministic Rendering](/concepts/determinism) for how this works under the hood.
|
||||
Render your Hyperframes [compositions](/concepts/compositions) to MP4, MOV, WebM, GIF, or PNG sequences with the [CLI](/packages/cli). The rendering pipeline is frame-by-frame and seek-driven — see [Deterministic Rendering](/concepts/determinism) for how this works under the hood.
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -116,8 +116,9 @@ Render your Hyperframes [compositions](/concepts/compositions) to MP4, MOV, or W
|
||||
| Flag | Values | Default | Description |
|
||||
|------|--------|---------|-------------|
|
||||
| `--output` | path | `renders/<name>.mp4` | Output file path |
|
||||
| `--format` | mp4, mov, webm, png-sequence | mp4 | Output format (see [Transparent Video](#transparent-video) below) |
|
||||
| `--format` | mp4, mov, webm, gif, png-sequence | mp4 | Output format (see [Transparent Video](#transparent-video) below) |
|
||||
| `--fps` | 24, 30, 60 | 30 | Frames per second |
|
||||
| `--gif-loop` | 0-65535 | 0 | GIF loop count. `0` loops forever |
|
||||
| `--quality` | draft, standard, high | standard | Encoding quality preset |
|
||||
| `--crf` | 0–51 | — | Override CRF (lower = higher quality). Cannot combine with `--video-bitrate` |
|
||||
| `--video-bitrate` | e.g. `10M`, `5000k` | — | Target bitrate encoding. Cannot combine with `--crf` |
|
||||
@@ -152,6 +153,18 @@ npx hyperframes render --video-bitrate 10M --output controlled.mp4
|
||||
|
||||
**Tip**: The default `standard` preset (CRF 18) is visually lossless at 1080p — most people cannot distinguish it from the source. Use `--quality draft` for faster iteration, or `--quality high` / `--crf 10` when file size is no concern.
|
||||
|
||||
## Animated GIF
|
||||
|
||||
Use GIF when the output needs to autoplay inline in GitHub PRs, READMEs, issue reports, and docs pages:
|
||||
|
||||
```bash Terminal
|
||||
npx hyperframes render --format gif --fps 15 --gif-loop 0 --output demo.gif
|
||||
```
|
||||
|
||||
GIF output uses a two-pass FFmpeg palette encode (`palettegen` with diff statistics, then `paletteuse` with Sierra dithering) for better gradients and text edges than a single-pass conversion. GIFs are still much larger than MP4/WebM at the same dimensions, so prefer `--fps 15` and short compositions. Hyperframes caps GIF renders at 30fps.
|
||||
|
||||
GIF does not carry audio and only has 1-bit transparency. For transparent overlays, use `--format webm`, `--format mov`, or `--format png-sequence` instead.
|
||||
|
||||
## GPU Acceleration
|
||||
|
||||
Hyperframes has two separate GPU acceleration surfaces:
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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] : []),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user