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
12 changes: 12 additions & 0 deletions packages/core/src/core.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit — zero unit tests for outputFrameToTimelineSeconds, the heart of the change. The PR body notes planHash + renderRequest unit suites: pass — those exercise the plan-hash byte-identity claim and request-validation guards, but not the seek arithmetic itself. This helper is called from 8+ capture sites and its correctness under renderStretch != 1 is what the runtime verification will empirically confirm. A handful of unit tests would lock the math in place BEFORE the runtime pass finds a subtle IEEE-754 issue at rational fps ({num: 30000, den: 1001} × renderStretch = 0.2083, etc.): boundary frame (i=0), last output frame lands at ≈ intrinsic, no-op case (renderStretch=1 returns exactly (i * den) / num). Cheap insurance, fits the PR's adversarial-self-review posture. — Rames D Jusso

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
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export {
parseFpsWithDefault,
toFps,
fpsToNumber,
outputFrameToTimelineSeconds,
fpsToFfmpegArg,
TIMELINE_COLORS,
DEFAULT_DURATIONS,
Expand Down
30 changes: 30 additions & 0 deletions packages/core/src/outputFrameToTimelineSeconds.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
11 changes: 8 additions & 3 deletions packages/engine/src/services/frameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions packages/engine/src/services/parallelCoordinator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -342,6 +343,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
Expand All @@ -361,7 +366,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`);
}
Expand Down Expand Up @@ -405,7 +410,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) {
Expand Down
2 changes: 2 additions & 0 deletions packages/engine/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand All @@ -123,6 +124,7 @@ export interface CaptureOptions {
* rational form verbatim — see `fpsToFfmpegArg`.
*/
fps: Fps;
renderStretch?: number;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Concern — field name and semantic have drifted. Post-PR, CaptureOptions.compositionDurationSeconds no longer means «the composition's intrinsic duration» — it means «the OUTPUT (drained) duration = intrinsic / renderStretch», per frameCapture.ts:3603 inline comment. The type declaration here doesn't document that. A future author looking at compositionDurationSeconds and assuming intrinsic will do wrong math (e.g. computing intrinsic frame counts for verification vs the real intrinsic duration from composition.duration). Two ways forward: (a) JSDoc on this field body saying «post-stretch output duration; intrinsic is only in composition.duration», or (b) rename to outputDurationSeconds and audit every setter. Option (a) is the smaller diff. This is subtle enough that it will silently drift again the next time someone touches this surface. — Rames D Jusso

format?: "jpeg" | "png";
quality?: number;
deviceScaleFactor?: number;
Expand Down
12 changes: 12 additions & 0 deletions packages/producer/src/renderRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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`);
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
Expand Down
7 changes: 7 additions & 0 deletions packages/producer/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ interface RenderInput {
* compositions as an aspect-ratio mismatch.
*/
outputResolutionAspectAgnostic?: boolean;
renderStretch?: number;
}

interface PreparedRenderInput {
Expand Down Expand Up @@ -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,
Expand All @@ -182,6 +187,7 @@ export function parseRenderOptions(body: Record<string, unknown>): Omit<RenderIn
outputResolution,
outputResolutionAspectAgnostic,
videoFrameFormat,
renderStretch,
};
}

Expand Down Expand Up @@ -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 });
Expand Down
5 changes: 5 additions & 0 deletions packages/producer/src/services/distributed/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))`
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/producer/src/services/distributed/renderChunk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ interface PlanJson {
width: number;
height: number;
format: DistributedFormat;
renderStretch?: number;
};
chunkCount: number;
totalFrames: number;
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question — any upper bound needed on renderStretch beyond > 0? Validation accepts any finite positive number. A 10s intrinsic with renderStretch = 0.0001 → ~10⁵ s ≈ 27.8 hr output → correctly rejected by validateRenderDuration (24hr ceiling) that landed in #2671. Good, transitive bound. But renderStretch = 0.5 on a 60,000-second (huge) intrinsic works out to a 120,000s output → also rejected. Bound only fires via the intrinsic × stretch product, which is arithmetic the author had to derive. Would an explicit renderStretch ≥ 0.01 (or similar) at request-validation-time surface a clearer error («renderStretch too aggressive» vs «output duration too long» — different diagnostic path)? Not a blocker; feels like the transitive bound holds. Curious whether you considered an explicit ceiling. — Rames D Jusso

`must be a positive number; got ${String(config.renderStretch)}`,
);
}

if (config.runtimeCap !== undefined && !ALLOWED_RUNTIME_CAPS.includes(config.runtimeCap)) {
throw new InvalidConfigError(
"config.runtimeCap",
Expand Down
2 changes: 2 additions & 0 deletions packages/producer/src/services/distributed/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
TRANSITIONS,
crossfade,
} from "@hyperframes/engine";
import { outputFrameToTimelineSeconds } from "@hyperframes/core";
import type { ProducerLogger } from "../../../logger.js";
import {
type HdrCompositeContext,
Expand Down Expand Up @@ -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(
Expand Down
13 changes: 11 additions & 2 deletions packages/producer/src/services/render/stages/captureStage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 };
Expand All @@ -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);
}
Expand Down
Loading
Loading