mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
fix(render): consolidate duration and timing correctness (#2405)
* fix(producer): pass variables to duration probe
* fix(producer): tolerate rounded frame-boundary durations
* fix(cli): resolve relative data-start references in composition duration
`compositions --json` computed each timed child's start with a bare
parseFloat(data-start ?? "0") in parseCompositions (host duration) and
parseSubComposition (sub-comp duration). A relative reference like
data-start="s1" ("start when clip s1 ends") is not numeric, so parseFloat
returned NaN and that clip's contribution to the max-end was silently
dropped — a host with two 3s clips (2nd data-start="s1") reported duration 3
instead of 6, breaking compositions/inspect/snapshot for composition-clip
relative timing.
Resolve relative references the same way the extractor does (parseStartExpression
from @hyperframes/core + a findReferenceTargetEl/resolveReferencedStart port,
since the engine's referenceResolver isn't a public export across the package
boundary). Verified: host duration now 6; 3 tests pass.
(Implemented via Codex; verified independently.)
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
// the correct forceScreenshot value (regression for #1236 — probe was launched
|
||||
// in beginframe mode even when lowMemoryMode demanded screenshot capture).
|
||||
const capturedCfgs: unknown[] = [];
|
||||
const capturedOptions: unknown[] = [];
|
||||
|
||||
const mockPage = {
|
||||
evaluate: async () => ({
|
||||
@@ -43,12 +44,13 @@ mock.module("@hyperframes/engine", () => ({
|
||||
createCaptureSession: async (
|
||||
_url: string,
|
||||
_dir: string,
|
||||
_opts: unknown,
|
||||
opts: unknown,
|
||||
_nullArg: unknown,
|
||||
cfg: unknown,
|
||||
) => {
|
||||
createSessionCallCount++;
|
||||
capturedCfgs.push(cfg);
|
||||
capturedOptions.push(opts);
|
||||
if (createSessionError && createSessionCallCount <= createSessionFailUntilAttempt) {
|
||||
throw createSessionError;
|
||||
}
|
||||
@@ -312,6 +314,45 @@ describe("runProbeStage — forceScreenshot threading", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runProbeStage — render variable threading", () => {
|
||||
it("passes render variables to the duration-discovery capture session", async () => {
|
||||
capturedOptions.length = 0;
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const input = makeProbeInput({ stageForceScreenshot: false });
|
||||
input.job.config.variables = { short: true, sceneCount: 2 };
|
||||
|
||||
await runProbeStage(input);
|
||||
|
||||
expect(capturedOptions[0]).toMatchObject({
|
||||
variables: { short: true, sceneCount: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runProbeStage — decimal duration frame count", () => {
|
||||
it("does not add a frame for a six-decimal duration rounded from an exact frame boundary", async () => {
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const input = makeProbeInput({});
|
||||
input.composition.duration = 32.866667;
|
||||
input.compiled.staticDuration = 32.866667;
|
||||
|
||||
const result = await runProbeStage(input);
|
||||
|
||||
expect(result.totalFrames).toBe(986);
|
||||
});
|
||||
|
||||
it("still ceilings a duration that genuinely extends into the next frame", async () => {
|
||||
const { runProbeStage } = await import("./probeStage.js");
|
||||
const input = makeProbeInput({});
|
||||
input.composition.duration = 32.867;
|
||||
input.compiled.staticDuration = 32.867;
|
||||
|
||||
const result = await runProbeStage(input);
|
||||
|
||||
expect(result.totalFrames).toBe(987);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runProbeStage — transient browser error retry (#1687)", () => {
|
||||
it("retries once on a transient 'Navigating frame was detached' error and succeeds", async () => {
|
||||
resetRetryMocks();
|
||||
|
||||
@@ -83,6 +83,16 @@ export interface ProbeStageInput {
|
||||
deviceScaleFactor: number;
|
||||
}
|
||||
|
||||
const FRAME_BOUNDARY_EPSILON = 1e-3;
|
||||
|
||||
function durationToFrameCount(duration: number, fps: number): number {
|
||||
const rawFrameCount = duration * fps;
|
||||
const nearestFrame = Math.round(rawFrameCount);
|
||||
return Math.abs(rawFrameCount - nearestFrame) <= FRAME_BOUNDARY_EPSILON
|
||||
? nearestFrame
|
||||
: Math.ceil(rawFrameCount);
|
||||
}
|
||||
|
||||
export interface ProbeStageResult {
|
||||
/** May be reassigned from `recompileWithResolutions`. */
|
||||
compiled: CompiledComposition;
|
||||
@@ -221,6 +231,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
|
||||
fps: job.config.fps,
|
||||
format: needsAlpha ? "png" : "jpeg",
|
||||
quality: needsAlpha ? undefined : 80,
|
||||
variables: job.config.variables,
|
||||
deviceScaleFactor,
|
||||
};
|
||||
|
||||
@@ -558,7 +569,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
|
||||
const browserProbeMs = Date.now() - probeStart;
|
||||
|
||||
const duration = composition.duration;
|
||||
const totalFrames = Math.ceil(duration * fpsToNumber(job.config.fps));
|
||||
const totalFrames = durationToFrameCount(duration, fpsToNumber(job.config.fps));
|
||||
|
||||
if (duration <= 0) {
|
||||
// Gather diagnostics to help users understand why the render would produce a black video.
|
||||
|
||||
Reference in New Issue
Block a user