Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/producer/src/services/distributed/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
validateNoSystemFonts,
} from "../render/planValidation.js";
import { snapshotRuntimeEnv } from "../render/runtimeEnvSnapshot.js";
import { outputNeedsAlpha } from "../render/renderFormat.js";
import {
buildSyntheticRenderJob,
buildPlanVideosJson,
Expand Down Expand Up @@ -888,7 +889,7 @@ export async function plan(
// move the contents over once the staged work completes.
const finalCompiledDir = join(planDir, "compiled");

// webm + mov + png-sequence carry alpha — flip force-screenshot so
// Alpha-capable distributed formats flip force-screenshot so
// compileStage takes the alpha-aware capture path (BeginFrame doesn't
// preserve alpha on Linux headless-shell). Must match the in-process
// renderer's needsAlpha logic in `renderOrchestrator.ts` so chunked
Expand All @@ -897,8 +898,7 @@ export async function plan(
// into the planDir and every chunk worker captures opaque RGB — the
// libvpx-vp9 alpha sub-stream then encodes either uniform alpha or
// gets downgraded by the encoder, producing un-keyable webm output.
const needsAlpha =
config.format === "png-sequence" || config.format === "mov" || config.format === "webm";
const needsAlpha = outputNeedsAlpha(config.format);

// ── Compile ──
const compileResult = await runCompileStage({
Expand Down
28 changes: 28 additions & 0 deletions packages/producer/src/services/render/renderFormat.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "bun:test";
import { outputNeedsAlpha, outputSupportsPageSideShaderCompositing } from "./renderFormat.js";

describe("outputNeedsAlpha", () => {
it("uses alpha-aware capture for transparent-capable formats", () => {
expect(outputNeedsAlpha("gif")).toBe(true);
expect(outputNeedsAlpha("webm")).toBe(true);
expect(outputNeedsAlpha("mov")).toBe(true);
expect(outputNeedsAlpha("png-sequence")).toBe(true);
});

it("preserves opaque capture for mp4", () => {
expect(outputNeedsAlpha("mp4")).toBe(false);
});
});

describe("outputSupportsPageSideShaderCompositing", () => {
it("supports opaque MP4 capture and RGBA GIF disk frames", () => {
expect(outputSupportsPageSideShaderCompositing("mp4")).toBe(true);
expect(outputSupportsPageSideShaderCompositing("gif")).toBe(true);
});

it("keeps the alpha video and PNG sequence formats on their existing paths", () => {
expect(outputSupportsPageSideShaderCompositing("webm")).toBe(false);
expect(outputSupportsPageSideShaderCompositing("mov")).toBe(false);
expect(outputSupportsPageSideShaderCompositing("png-sequence")).toBe(false);
});
});
9 changes: 9 additions & 0 deletions packages/producer/src/services/render/renderFormat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type RenderOutputFormat = "mp4" | "webm" | "mov" | "png-sequence" | "gif";

export function outputNeedsAlpha(format: RenderOutputFormat): boolean {
return format !== "mp4";
}

export function outputSupportsPageSideShaderCompositing(format: RenderOutputFormat): boolean {
return format === "mp4" || format === "gif";
}
60 changes: 60 additions & 0 deletions packages/producer/src/services/render/stages/encodeStage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ describe("gif encode args", () => {
outputPath: "/tmp/hf/demo.gif",
fps: { num: 15, den: 1 },
loop: 0,
preserveAlpha: false,
};

it("builds the palettegen pass with diff statistics", () => {
Expand Down Expand Up @@ -151,6 +152,21 @@ describe("gif encode args", () => {
"/tmp/hf/demo.gif",
]);
});

it("reserves transparency and applies the GIF alpha threshold for RGBA frames", () => {
const transparentInput = {
...input,
framePattern: "frame_%06d.png",
preserveAlpha: true,
};

expect(buildGifPalettegenArgs(transparentInput)).toContain(
"fps=15,palettegen=stats_mode=diff:reserve_transparent=1",
);
expect(buildGifPaletteuseArgs(transparentInput)).toContain(
"fps=15 [x]; [x][1:v] paletteuse=dither=sierra2_4a:alpha_threshold=128",
);
});
});

describe("runEncodeStage config plumbing", () => {
Expand Down Expand Up @@ -221,4 +237,48 @@ describe("runEncodeStage config plumbing", () => {
resolvedEngineConfig.ffmpegEncodeTimeout,
);
});

it("encodes alpha GIFs from PNG frames with explicit transparency filters", async () => {
const { runEncodeStage } = await import("./encodeStage.js");
const paths = createFramesDir("png");

await runEncodeStage(
makeInput({
framesDir: paths.framesDir,
outputPath: join(paths.root, "out.gif"),
videoOnlyPath: join(paths.root, "video-only.mp4"),
isGif: true,
needsAlpha: true,
}),
);

expect(runFfmpegMock).toHaveBeenCalledTimes(2);
expect(runFfmpegMock.mock.calls[0]?.[0]).toContain(join(paths.framesDir, "frame_%06d.png"));
expect(runFfmpegMock.mock.calls[0]?.[0]).toContain(
"fps=30,palettegen=stats_mode=diff:reserve_transparent=1",
);
expect(runFfmpegMock.mock.calls[1]?.[0]).toContain(join(paths.framesDir, "frame_%06d.png"));
expect(runFfmpegMock.mock.calls[1]?.[0]).toContain(
"fps=30 [x]; [x][1:v] paletteuse=dither=sierra2_4a:alpha_threshold=128",
);
});

it("keeps opaque GIF encoding on JPEG frames without alpha-only filters", async () => {
const { runEncodeStage } = await import("./encodeStage.js");
const paths = createFramesDir("jpg");

await runEncodeStage(
makeInput({
framesDir: paths.framesDir,
outputPath: join(paths.root, "out.gif"),
videoOnlyPath: join(paths.root, "video-only.mp4"),
isGif: true,
needsAlpha: false,
}),
);

expect(runFfmpegMock.mock.calls[0]?.[0]).toContain(join(paths.framesDir, "frame_%06d.jpg"));
expect(runFfmpegMock.mock.calls[0]?.[0]).not.toContain("reserve_transparent");
expect(runFfmpegMock.mock.calls[1]?.[0]).not.toContain("alpha_threshold");
});
});
6 changes: 5 additions & 1 deletion packages/producer/src/services/render/stages/encodeStage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ async function encodeGifFromDir(
fps: Fps;
loop: number;
palettePath: string;
preserveAlpha: boolean;
signal?: AbortSignal;
timeout: number;
},
Expand All @@ -148,6 +149,7 @@ async function encodeGifFromDir(
outputPath,
fps: input.fps,
loop: input.loop,
preserveAlpha: input.preserveAlpha,
};
try {
const paletteResult = await runFfmpeg(buildGifPalettegenArgs(argsInput), {
Expand Down Expand Up @@ -258,12 +260,14 @@ export async function runEncodeStage(input: EncodeStageInput): Promise<EncodeSta
if (hasAudio) {
log.warn("[Render] GIF output does not support audio; audio tracks will be ignored.");
}
const framePattern = "frame_%06d.jpg";
const frameExt = needsAlpha ? "png" : "jpg";
const framePattern = `frame_%06d.${frameExt}`;
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"),
preserveAlpha: needsAlpha,
signal: abortSignal,
timeout: engineCfg.ffmpegEncodeTimeout,
});
Expand Down
7 changes: 5 additions & 2 deletions packages/producer/src/services/render/stages/gifEncodeArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface GifEncodeArgsInput {
outputPath: string;
fps: Fps;
loop: number;
preserveAlpha: boolean;
}

function fpsToFfmpegArg(fps: Fps): string {
Expand All @@ -16,20 +17,22 @@ function fpsToFfmpegArg(fps: Fps): string {

export function buildGifPalettegenArgs(input: GifEncodeArgsInput): string[] {
const fpsArg = fpsToFfmpegArg(input.fps);
const transparency = input.preserveAlpha ? ":reserve_transparent=1" : "";
return [
"-y",
"-framerate",
fpsArg,
"-i",
join(input.framesDir, input.framePattern),
"-vf",
`fps=${fpsArg},palettegen=stats_mode=diff`,
`fps=${fpsArg},palettegen=stats_mode=diff${transparency}`,
input.palettePath,
];
}

export function buildGifPaletteuseArgs(input: GifEncodeArgsInput): string[] {
const fpsArg = fpsToFfmpegArg(input.fps);
const transparency = input.preserveAlpha ? ":alpha_threshold=128" : "";
return [
"-y",
"-framerate",
Expand All @@ -39,7 +42,7 @@ export function buildGifPaletteuseArgs(input: GifEncodeArgsInput): string[] {
"-i",
input.palettePath,
"-lavfi",
`fps=${fpsArg} [x]; [x][1:v] paletteuse=dither=sierra2_4a`,
`fps=${fpsArg} [x]; [x][1:v] paletteuse=dither=sierra2_4a${transparency}`,
"-loop",
String(input.loop),
input.outputPath,
Expand Down
35 changes: 18 additions & 17 deletions packages/producer/src/services/renderOrchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ import {
VIRTUAL_TIME_SHIM,
} from "./fileServer.js";
import { defaultLogger, type ProducerLogger } from "../logger.js";
import {
outputNeedsAlpha,
outputSupportsPageSideShaderCompositing,
type RenderOutputFormat,
} from "./render/renderFormat.js";
import { createMemorySampler, type MemorySampler, updateJobStatus } from "./render/shared.js";
import { buildRenderErrorDetails } from "./render/cleanup.js";
import { publishRenderFailure } from "./render/renderEventPublisher.js";
Expand Down Expand Up @@ -284,10 +289,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
* - `"gif"`: animated GIF encoded from captured RGBA 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.
* stream; transparency is binary because GIF has no partial alpha.
* - `"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
Expand All @@ -296,7 +301,7 @@ export interface RenderConfig {
* encoding. `outputPath` is treated as a directory; it is created if
* it doesn't exist.
*
* Alpha output (`"webm"`, `"mov"`, `"png-sequence"`) automatically
* Alpha output (`"webm"`, `"mov"`, `"png-sequence"`, `"gif"`) automatically
* forces screenshot capture (Chrome's BeginFrame compositor does not
* preserve alpha on Linux headless-shell) and disables HDR — HDR +
* alpha is not a supported combination, a warning is logged and HDR
Expand All @@ -305,7 +310,7 @@ export interface RenderConfig {
* not paint a fullscreen `body` / `#root` background in their
* compositions when targeting alpha output.
*/
format?: "mp4" | "webm" | "mov" | "png-sequence" | "gif";
format?: RenderOutputFormat;
/** GIF Netscape loop count. 0 means infinite looping. Only used with `format: "gif"`. */
gifLoop?: number;
workers?: number;
Expand Down Expand Up @@ -1745,16 +1750,14 @@ async function executeRenderPipeline(input: {
renderJobId: job.id,
});
const outputFormat = job.config.format ?? ("mp4" as const);
const isWebm = outputFormat === "webm";
const isMov = outputFormat === "mov";
const isPngSequence = outputFormat === "png-sequence";
const isGif = outputFormat === "gif";
const artifactTransaction = new ArtifactTransaction(
outputPath,
isPngSequence ? "directory" : "file",
);
const stagedOutputPath = artifactTransaction.stagingPath;
const needsAlpha = isWebm || isMov || isPngSequence;
const needsAlpha = outputNeedsAlpha(outputFormat);
// `forceScreenshot` is resolved exactly once inside `compileStage` (alpha
// output + composition `renderModeHints` are folded together there) and
// returned on `compileResult.forceScreenshot`. The sequencer stores it
Expand Down Expand Up @@ -2812,19 +2815,16 @@ async function executeRenderPipeline(input: {
// Page-side compositing opt-in: when the engine is configured to run the
// shader blend inside Chrome via a page-side WebGL canvas, the layered
// Node-side composite path is unnecessary for SDR shader transitions.
// The streaming path takes ONE opaque RGB screenshot per output frame —
// 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). 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.
// MP4's streaming path takes one opaque RGB screenshot per output frame.
// GIF takes the same page-side composite through its RGBA PNG disk-frame
// path so the palette encoder can preserve transparency. 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).
const usePageSideCompositingForTransitions =
(cfg.enablePageSideCompositing || isGif) &&
compiled.hasShaderTransitions &&
!hasHdrContent &&
!isPngSequence &&
!needsAlpha;
outputSupportsPageSideShaderCompositing(outputFormat);
if (usePageSideCompositingForTransitions) {
activeFileServer.addPreHeadScript(HF_PAGE_SIDE_COMPOSITING_STUB);
if (
Expand All @@ -2848,7 +2848,8 @@ async function executeRenderPipeline(input: {
updateCaptureObservability({ forceScreenshot: captureForceScreenshot });
log.info(
"[Render] Page-side compositing enabled — bypassing Node-side layered " +
"shader-blend path. Engine will capture one opaque RGB screenshot per output frame.",
`shader-blend path. Engine will capture one ${needsAlpha ? "RGBA PNG" : "opaque RGB"} ` +
"screenshot per output frame.",
);
}
const useLayeredComposite =
Expand Down
Loading
Loading