feat(producer): renderStretch to re-time short compositions across longer scenes (#2676)

Linear: VA-1859

## Problem

For a `fit_to_scene` B-roll where the composition's intrinsic timeline (e.g. `data-duration=1.0s` → 30 frames) is shorter than the scene it fills (e.g. 4.8s narration), the producer renders only the intrinsic 30 frames and the downstream compositor frame-holds/PTS-stretches that fixed clip to the scene length. Spreading 30 unique frames over 4.8s starves motion to ~6 effective fps → a visibly choppy result. Root cause: the producer welds one `composition.duration` to both the frame count and the 1:1 seek mapping, with no notion of a target output length.

## Fix

Add optional `renderStretch: number` (default `1.0` = no-op), `renderStretch = intrinsic / target`:

- **Frame count** comes from the target: `outputDuration = intrinsic / renderStretch`, `totalFrames = outputDuration × fps` (`probeStage.ts`). `composition.duration` stays intrinsic (drives video/audio windows).
- **Per-frame seek** is scaled: `time = (frameIndex / fps) × renderStretch`, so the N output frames map across `[0, intrinsic]` — a fresh frame per output frame.

All seek sites go through a single shared `outputFrameToTimelineSeconds(frameIndex, fps, renderStretch)` helper (`core.types.ts`), consumed by every capture path so none can silently diverge:
- parallel (`parallelCoordinator.ts`), `sdr_streaming` (`captureStreamingStage.ts` ×3), `sdr_disk` (`captureStage.ts`), HDR loops.
- DrawElement + static self-verify (`frameCapture.ts`) — ground-truth seek uses the same mapping, so PSNR compares like-for-like (no spurious verification failure on stretched comps).
- Distributed path: `renderStretch` threaded through `DistributedRenderConfig` → chunk workers, and **folded into the plan hash only when `!= 1`** so a pre-stretch cached plan is never reused.

With `renderStretch = 1` (or omitted → `?? 1`): every seek is `×1.0` (IEEE-754 identity), frame counts unchanged, and the plan hash is byte-identical — a provable no-op. `player.ts` absolute-seek is untouched.

## Verify

- typecheck (core + engine + producer): pass. lint/format/fallow/commitlint: pass. `planHash` + `renderRequest` unit suites: pass.
- Adversarial self-review found + fixed three capture-path gaps (streaming, self-verify, distributed) before this revision.
- **Not yet runtime-verified** on a real render — needs a fit_to_scene render at `renderStretch < 1` confirming N distinct frames over the target length (draft until then).

Paired with experiment-framework#42766, which computes and forwards `renderStretch = hf intrinsic / scene duration`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Xuanru Li
2026-07-21 13:30:44 -07:00
committed by GitHub
parent 696cbdbbd0
commit e786b78b33
20 changed files with 159 additions and 15 deletions
+12
View File
@@ -38,6 +38,18 @@ export function fpsToNumber(fps: Fps): number {
return fps.num / fps.den;
}
/**
* Timeline seek time for an output frame. `renderStretch` (=intrinsic/target,
* default 1 = no-op) scales the mapping so a short comp spans a longer output.
*/
export function outputFrameToTimelineSeconds(
frameIndex: number,
fps: Fps,
renderStretch = 1,
): number {
return ((frameIndex * fps.den) / fps.num) * renderStretch;
}
/**
* FFmpeg-style fps argument. Returns `"30"` for integer fps and `"30000/1001"`
* for rationals — both forms are accepted verbatim by FFmpeg's `-r` and
+1
View File
@@ -63,6 +63,7 @@ export {
parseFpsWithDefault,
toFps,
fpsToNumber,
outputFrameToTimelineSeconds,
fpsToFfmpegArg,
TIMELINE_COLORS,
DEFAULT_DURATIONS,
@@ -0,0 +1,30 @@
import { describe, it, expect } from "vitest";
import { outputFrameToTimelineSeconds } from "./core.types.js";
describe("outputFrameToTimelineSeconds", () => {
const fps = { num: 30, den: 1 };
it("no-op when renderStretch is omitted (defaults to 1)", () => {
for (const i of [0, 1, 29, 143]) {
expect(outputFrameToTimelineSeconds(i, fps)).toBe((i * fps.den) / fps.num);
}
});
it("renderStretch=1 is byte-identical to the raw frame time", () => {
expect(outputFrameToTimelineSeconds(143, fps, 1)).toBe(143 / 30);
});
it("stretches a 1s comp across a 4.8s output (renderStretch = intrinsic/target)", () => {
const rs = 1 / 4.8;
expect(outputFrameToTimelineSeconds(0, fps, rs)).toBe(0);
// last of 144 output frames lands just under intrinsic 1.0s — never past it
const last = outputFrameToTimelineSeconds(143, fps, rs);
expect(last).toBeGreaterThan(0.98);
expect(last).toBeLessThan(1.0);
});
it("honors an exact rational fps (NTSC 30000/1001)", () => {
const ntsc = { num: 30000, den: 1001 };
expect(outputFrameToTimelineSeconds(30, ntsc, 1)).toBe((30 * 1001) / 30000);
});
});
+8 -3
View File
@@ -2660,8 +2660,9 @@ export async function verifyStaticFramesSafe(
if (last && f === last.b + 1) last.b = f;
else runs.push({ a: f, b: f });
}
const renderStretch = session.options.renderStretch ?? 1;
const seekToFrame = async (frameIdx: number): Promise<void> => {
const t = quantizeTimeToFrame(frameIdx / fps, fps);
const t = quantizeTimeToFrame((frameIdx / fps) * renderStretch, fps);
await page.evaluate((tt: number) => {
const hf = (
window as unknown as {
@@ -3599,11 +3600,14 @@ async function captureDeVerificationFrames(
// their data-duration, and infinite-repeat GSAP reports a huge sentinel —
// and indices derived from it would never be drained, silently disarming
// verification for exactly the comps that need it.
// compositionDurationSeconds is already the output (drained) duration; the raw
// page fallback is intrinsic — divide it by renderStretch to match (1 = no-op).
const renderStretch = session.options.renderStretch ?? 1;
const duration =
session.options.compositionDurationSeconds ??
(await page.evaluate(
() => (window as unknown as { __hf?: { duration?: number } }).__hf?.duration ?? 0,
));
)) / renderStretch;
const totalFrames = Math.floor(duration * fps);
if (totalFrames < 10) return;
if (duration > 3600) {
@@ -3653,7 +3657,8 @@ async function captureDeVerificationFrames(
while (boundary.has(idx) && guard++ < 6) idx = Math.min(totalFrames - 1, idx + 2);
if (boundary.has(idx)) continue;
if (frames.has(idx)) continue;
const t = quantizeTimeToFrame(idx / fps, fps);
// Seek truth with the same ×renderStretch mapping the real capture uses for output frame idx.
const t = quantizeTimeToFrame((idx / fps) * renderStretch, fps);
await seekTo(t);
// Video frame injection (same hook the real capture paths run) — without
// it, <video> elements screenshot black and every video comp would
@@ -23,6 +23,7 @@ import {
type CapturePerfSummary,
type BeforeCaptureHook,
} from "./frameCapture.js";
import { outputFrameToTimelineSeconds } from "@hyperframes/core";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { assertSwiftShader } from "../utils/assertSwiftShader.js";
import { readWebGlVendorInfoFromCanvas } from "../utils/readWebGlVendorInfoFromCanvas.js";
@@ -368,6 +369,10 @@ async function captureFrameRange(
let framesCaptured = 0;
const outputOffset = task.outputFrameOffset ?? 0;
const stride = task.frameStride ?? 1;
// Per-frame seek ×renderStretch maps frames across [0, intrinsic] (1 = no-op).
const renderStretch = captureOptions.renderStretch ?? 1;
const seekTime = (i: number): number =>
outputFrameToTimelineSeconds(i, captureOptions.fps, renderStretch);
// Depth-2 pipelined drawElement produce (HF_DE_PARALLEL_STREAM spike): frame
// k's in-page worker encode overlaps frame k+stride's produce phase — the
// same shape as the sequential worker-encode loop. Only engaged when the
@@ -387,7 +392,7 @@ async function captureFrameRange(
let prev: { idx: number; encodeResult: Promise<Buffer> } | null = null;
for (let i = task.startFrame; i < task.endFrame; i += stride) {
if (signal?.aborted) throw new Error("Parallel worker cancelled");
const time = (i * captureOptions.fps.den) / captureOptions.fps.num;
const time = seekTime(i);
if (dbg && i < task.startFrame + dbgWin) {
console.log(`[par:w${task.workerId}] +${Date.now() - dbgT0}ms produce ${i} start`);
}
@@ -431,7 +436,7 @@ async function captureFrameRange(
}
for (let i = task.startFrame; i < task.endFrame; i += stride) {
if (signal?.aborted) throw new Error("Parallel worker cancelled");
const time = (i * captureOptions.fps.den) / captureOptions.fps.num;
const time = seekTime(i);
const fileFrameIdx = i - outputOffset;
if (onFrameBuffer) {
+2
View File
@@ -114,6 +114,7 @@ export interface CaptureOptions {
* timelines can outrun their declared duration). Consumers that derive
* frame indices meant to be drained by the producer (drawElement
* self-verification) MUST prefer this over `__hf.duration`.
* When renderStretch != 1 this is the OUTPUT (stretched) duration, not intrinsic.
*/
compositionDurationSeconds?: number;
/**
@@ -123,6 +124,7 @@ export interface CaptureOptions {
* rational form verbatim see `fpsToFfmpegArg`.
*/
fps: Fps;
renderStretch?: number;
format?: "jpeg" | "png";
quality?: number;
deviceScaleFactor?: number;
+12
View File
@@ -49,6 +49,8 @@ export interface RenderRequestOptions {
variables?: Record<string, unknown>;
outputResolution?: CanvasResolution;
outputResolutionAspectAgnostic?: boolean;
/** intrinsic/target timeline re-time; 1 = no-op. Audio is silence-padded to output length, not time-stretched. */
renderStretch?: number;
engineConfig: EngineConfig;
distributed?: DistributedRenderOptions;
}
@@ -105,6 +107,13 @@ function assertOptionalInteger(options: Record<string, unknown>, field: string,
}
}
function assertOptionalPositiveNumber(options: Record<string, unknown>, field: string): void {
const value = options[field];
if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value) || value <= 0)) {
throw new Error(`Render request ${field} must be a positive number`);
}
}
function assertOptionalString(options: Record<string, unknown>, field: string): void {
if (options[field] !== undefined && typeof options[field] !== "string") {
throw new Error(`Render request ${field} must be a string`);
@@ -160,6 +169,7 @@ function assertRequestOptionScalars(options: Record<string, unknown>): void {
assertOptionalInteger(options, "gifLoop");
assertOptionalInteger(options, "workers", 1);
assertOptionalInteger(options, "crf");
assertOptionalPositiveNumber(options, "renderStretch");
for (const field of ["useGpu", "debug", "outputResolutionAspectAgnostic"] as const) {
assertOptionalBoolean(options, field);
}
@@ -284,6 +294,7 @@ export function distributedConfigFromRequest(
videoFrameFormat: options.videoFrameFormat,
outputResolution: options.outputResolution,
outputResolutionAspectAgnostic: options.outputResolutionAspectAgnostic,
renderStretch: options.renderStretch,
chunkSize: distributed.chunkSize,
maxParallelChunks: distributed.maxParallelChunks,
targetChunkFrames: distributed.targetChunkFrames,
@@ -339,6 +350,7 @@ export function renderRequestFromDistributedConfig(input: {
...optionalProperty("videoFrameFormat", config.videoFrameFormat),
...optionalProperty("outputResolution", config.outputResolution),
...optionalProperty("outputResolutionAspectAgnostic", config.outputResolutionAspectAgnostic),
...optionalProperty("renderStretch", config.renderStretch),
...optionalProperty("hdrMode", config.hdrMode),
...optionalProperty("strictness", config.strictness),
...optionalProperty("entryFile", config.entryFile),
+7
View File
@@ -110,6 +110,7 @@ interface RenderInput {
* compositions as an aspect-ratio mismatch.
*/
outputResolutionAspectAgnostic?: boolean;
renderStretch?: number;
}
interface PreparedRenderInput {
@@ -167,6 +168,10 @@ export function parseRenderOptions(body: Record<string, unknown>): Omit<RenderIn
const { variables, outputResolution, outputResolutionAspectAgnostic } =
parseRenderOverrides(body);
const renderStretch =
typeof body.renderStretch === "number" && body.renderStretch > 0
? body.renderStretch
: undefined;
return {
outputPath,
@@ -182,6 +187,7 @@ export function parseRenderOptions(body: Record<string, unknown>): Omit<RenderIn
outputResolution,
outputResolutionAspectAgnostic,
videoFrameFormat,
renderStretch,
};
}
@@ -239,6 +245,7 @@ function buildRenderJobConfig(input: RenderInput, outputPath: string, log: Produ
outputResolution: input.outputResolution,
outputResolutionAspectAgnostic: input.outputResolutionAspectAgnostic,
videoFrameFormat: input.videoFrameFormat,
renderStretch: input.renderStretch,
},
});
return renderConfigFromRequest(request, { logger: log });
@@ -135,6 +135,9 @@ export interface DistributedRenderConfig {
*/
outputResolutionAspectAgnostic?: boolean;
/** intrinsic/target timeline re-time; 1 (or omitted) = no-op. Folded into planHash. */
renderStretch?: number;
/**
* Frames per chunk. When explicitly set, that value is used and
* `chunkCount = min(maxParallelChunks, ceil(totalFrames / chunkSize))`
@@ -771,6 +774,7 @@ export async function plan(
videoFrameFormat: config.videoFrameFormat,
outputResolution: config.outputResolution,
outputResolutionAspectAgnostic: config.outputResolutionAspectAgnostic,
renderStretch: config.renderStretch,
// HDR is banned in distributed mode. force-sdr keeps the
// extract / encoder paths off the HDR branches entirely.
hdrMode: config.hdrMode ?? "force-sdr",
@@ -1036,6 +1040,7 @@ export async function plan(
width,
height,
format: config.format,
renderStretch: config.renderStretch,
};
// Clean up the temp work tree BEFORE freezePlan. `.plan-work/` holds
// intermediate compileStage + audio-mix artifacts (downloaded source
@@ -239,6 +239,7 @@ interface PlanJson {
width: number;
height: number;
format: DistributedFormat;
renderStretch?: number;
};
chunkCount: number;
totalFrames: number;
@@ -515,6 +516,8 @@ export async function renderChunk(
width: plan.dimensions.width,
height: plan.dimensions.height,
fps: { num: plan.dimensions.fpsNum, den: plan.dimensions.fpsDen },
// Per-frame seek ×renderStretch maps output frames across [0, intrinsic] (1 = no-op).
renderStretch: plan.dimensions.renderStretch ?? 1,
format: plan.dimensions.format === "mp4" ? "jpeg" : "png",
quality: plan.dimensions.format === "mp4" ? 80 : undefined,
deviceScaleFactor: encoder.deviceScaleFactor,
@@ -187,6 +187,18 @@ export function validateDistributedRenderConfig(
}
}
if (
config.renderStretch !== undefined &&
(typeof config.renderStretch !== "number" ||
!Number.isFinite(config.renderStretch) ||
config.renderStretch <= 0)
) {
throw new InvalidConfigError(
"config.renderStretch",
`must be a positive number; got ${String(config.renderStretch)}`,
);
}
if (config.runtimeCap !== undefined && !ALLOWED_RUNTIME_CAPS.includes(config.runtimeCap)) {
throw new InvalidConfigError(
"config.runtimeCap",
@@ -100,6 +100,7 @@ export interface SyntheticRenderJobInput {
videoFrameFormat?: VideoFrameFormat;
outputResolution?: RenderConfig["outputResolution"];
outputResolutionAspectAgnostic?: RenderConfig["outputResolutionAspectAgnostic"];
renderStretch?: number;
hdrMode: RenderConfig["hdrMode"];
strictness?: RenderConfig["strictness"];
entryFile: string;
@@ -122,6 +123,7 @@ export function buildSyntheticRenderJob(input: SyntheticRenderJobInput): RenderJ
videoFrameFormat: input.videoFrameFormat,
outputResolution: input.outputResolution,
outputResolutionAspectAgnostic: input.outputResolutionAspectAgnostic,
renderStretch: input.renderStretch,
// Distributed mode hard-pins to software GPU. The plan-time validator
// refuses to fan out otherwise.
useGpu: false,
@@ -33,6 +33,7 @@ import {
initTransparentBackground,
initializeSession,
} from "@hyperframes/engine";
import { outputFrameToTimelineSeconds } from "@hyperframes/core";
import type { FileServerHandle } from "../../fileServer.js";
import type { ProducerLogger } from "../../../logger.js";
import {
@@ -216,7 +217,7 @@ export async function runHybridLayeredFrameLoop(input: HybridLoopInput): Promise
let nextRingIdx = 0;
for (let i = range.start; i < range.end; i++) {
assertNotAborted();
const time = (i * job.config.fps.den) / job.config.fps.num;
const time = outputFrameToTimelineSeconds(i, job.config.fps, job.config.renderStretch ?? 1);
const activeTransition = transitionFramesSet.has(i)
? transitionRanges.find((t) => i >= t.startFrame && i <= t.endFrame)
: undefined;
@@ -19,6 +19,7 @@ import {
TRANSITIONS,
crossfade,
} from "@hyperframes/engine";
import { outputFrameToTimelineSeconds } from "@hyperframes/core";
import type { ProducerLogger } from "../../../logger.js";
import {
type HdrCompositeContext,
@@ -106,7 +107,7 @@ export async function runSequentialLayeredFrameLoop(input: SequentialLoopInput):
for (let i = 0; i < totalFrames; i++) {
assertNotAborted();
const time = (i * job.config.fps.den) / job.config.fps.num;
const time = outputFrameToTimelineSeconds(i, job.config.fps, job.config.renderStretch ?? 1);
if (hdrPerf) hdrPerf.frames += 1;
const stackingInfo = await seekInjectAndQueryStacking(
@@ -52,6 +52,7 @@ import {
initializeSession,
prepareCaptureSessionForReuse,
} from "@hyperframes/engine";
import { outputFrameToTimelineSeconds } from "@hyperframes/core";
import type { FileServerHandle } from "../../fileServer.js";
import type { ProducerLogger } from "../../../logger.js";
import {
@@ -316,7 +317,11 @@ export async function runCaptureStage(input: CaptureStageInput): Promise<Capture
for (let i = 0; i < rangeFrames; i++) {
assertNotAborted();
const absoluteIdx = rangeStart + i;
const time = (absoluteIdx * job.config.fps.den) / job.config.fps.num;
const time = outputFrameToTimelineSeconds(
absoluteIdx,
job.config.fps,
job.config.renderStretch ?? 1,
);
const { encodeResult } = await captureFrameToBufferPipelined(session, i, time);
await drainPrev();
prev = { fileIndex: i, encodeResult };
@@ -326,7 +331,11 @@ export async function runCaptureStage(input: CaptureStageInput): Promise<Capture
for (let i = 0; i < rangeFrames; i++) {
assertNotAborted();
const absoluteIdx = rangeStart + i;
const time = (absoluteIdx * job.config.fps.den) / job.config.fps.num;
const time = outputFrameToTimelineSeconds(
absoluteIdx,
job.config.fps,
job.config.renderStretch ?? 1,
);
await captureFrame(session, i, time);
reportFrame(i);
}
@@ -71,6 +71,7 @@ import {
prepareCaptureSessionForReuse,
spawnStreamingEncoder,
} from "@hyperframes/engine";
import { outputFrameToTimelineSeconds } from "@hyperframes/core";
import type { FileServerHandle } from "../../fileServer.js";
import type { ProducerLogger } from "../../../logger.js";
import type { ProgressCallback, RenderJob } from "../../renderOrchestrator.js";
@@ -405,7 +406,8 @@ async function runWorkerEncodePipelineLoop(
abortSignal: AbortSignal | undefined,
): Promise<void> {
let prev: { idx: number; encodeResult: Promise<Buffer> } | null = null;
const frameTime = (i: number) => (i * job.config.fps.den) / job.config.fps.num;
const frameTime = (i: number) =>
outputFrameToTimelineSeconds(i, job.config.fps, job.config.renderStretch ?? 1);
const guard = createDrainFrameGuard({ log, stats, frameTime });
const guardFrame = (idx: number, buf: Buffer): Promise<Buffer> => guard(session, idx, buf);
@@ -640,7 +642,8 @@ export async function runCaptureStreamingStage(
const parallelGuard = createDrainFrameGuard({
log,
stats: parallelStats,
frameTime: (i: number) => (i * job.config.fps.den) / job.config.fps.num,
frameTime: (i: number) =>
outputFrameToTimelineSeconds(i, job.config.fps, job.config.renderStretch ?? 1),
});
let parallelGuardRan = false;
// First guard/write failure aborts the reorder buffer so peer workers
@@ -835,7 +838,11 @@ export async function runCaptureStreamingStage(
let lastProgressAt = Date.now();
for (let i = 0; i < totalFrames; i++) {
assertNotAborted();
const time = (i * job.config.fps.den) / job.config.fps.num;
const time = outputFrameToTimelineSeconds(
i,
job.config.fps,
job.config.renderStretch ?? 1,
);
const { buffer } = await raceAgainstStall(
captureFrameToBuffer(session, i, time),
stallTimeoutMs - (Date.now() - lastProgressAt),
@@ -251,3 +251,23 @@ describe("sha256Hex", () => {
expect(sha256Hex(s)).toBe(sha256Hex(new TextEncoder().encode(s)));
});
});
describe("computePlanHash — renderStretch byte-identity", () => {
const baseDims = { fpsNum: 30, fpsDen: 1, width: 1920, height: 1080, format: "mp4" as const };
it("renderStretch=1 and undefined hash identically to a pre-renderStretch plan", () => {
const omitted = computePlanHash(makeInput({ dimensions: { ...baseDims } }));
const one = computePlanHash(makeInput({ dimensions: { ...baseDims, renderStretch: 1 } }));
const undef = computePlanHash(
makeInput({ dimensions: { ...baseDims, renderStretch: undefined } }),
);
expect(one).toBe(omitted);
expect(undef).toBe(omitted);
});
it("a stretched plan (renderStretch=2) gets a distinct hash", () => {
const base = computePlanHash(makeInput({ dimensions: { ...baseDims } }));
const stretched = computePlanHash(makeInput({ dimensions: { ...baseDims, renderStretch: 2 } }));
expect(stretched).not.toBe(base);
});
});
@@ -73,6 +73,8 @@ export interface PlanDimensions {
width: number;
height: number;
format: DistributedFormat;
/** intrinsic/target timeline re-time; 1 (or omitted) = no-op, hashed identically to today. */
renderStretch?: number;
}
export interface PlanHashInput {
@@ -127,7 +129,10 @@ export function computePlanHash(input: PlanHashInput): string {
hash.update(FIELD_DELIMITER);
const d = input.dimensions;
hash.update(`${d.fpsNum}/${d.fpsDen}x${d.width}x${d.height}x${d.format}`, "utf8");
// Append renderStretch only when it re-times (!= 1) so unstretched plans hash identically to before.
const stretchSuffix =
d.renderStretch !== undefined && d.renderStretch !== 1 ? `x${d.renderStretch}` : "";
hash.update(`${d.fpsNum}/${d.fpsDen}x${d.width}x${d.height}x${d.format}${stretchSuffix}`, "utf8");
return hash.digest("hex");
}
@@ -591,7 +591,10 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
const browserProbeMs = Date.now() - probeStart;
const duration = composition.duration;
const totalFrames = durationToFrameCount(duration, fpsToNumber(job.config.fps));
// Output length = intrinsic / renderStretch (1 = no-op); composition.duration stays intrinsic.
const renderStretch = job.config.renderStretch ?? 1;
const outputDuration = duration / renderStretch;
const totalFrames = durationToFrameCount(outputDuration, fpsToNumber(job.config.fps));
if (duration <= 0) {
// Gather diagnostics to help users understand why the render would produce a black video.
@@ -660,7 +663,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
fileServer,
probeSession,
lastBrowserConsole,
duration,
duration: outputDuration,
totalFrames,
browserProbeMs,
beginFrameStalled,
@@ -364,6 +364,7 @@ export interface RenderConfig {
* alias (`1080p-portrait`). Explicit orientation presets stay strict.
*/
outputResolutionAspectAgnostic?: boolean;
renderStretch?: number;
}
export interface RenderPerfSummary {
@@ -2189,6 +2190,7 @@ async function executeRenderPipeline(input: {
width,
height,
fps: job.config.fps,
renderStretch: job.config.renderStretch ?? 1,
format: needsAlpha ? "png" : "jpeg",
quality: needsAlpha ? undefined : job.config.quality === "draft" ? 80 : 95,
variables: job.config.variables,