Skip to content

Commit e69be30

Browse files
miga-heygenclaude
andauthored
fix(engine): fail render on sub-composition script failures (#3352) (#3528)
When a composition script throws during execution, the GSAP timeline registration never arrives and pollSubCompositionTimelines times out. Previously the render continued with a degenerate 2-frame output and reported success — now it fails loudly. Two changes: 1. Detect composition script runtime errors in the browser console handler and feed them into scriptLoadFailures, triggering the existing fail-fast path (same as script load 404s). 2. Make sub_timeline_script_failure a fatal warning in applyRenderWarningPolicy, alongside audio_processing_failed. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 05275c1 commit e69be30

3 files changed

Lines changed: 61 additions & 3 deletions

File tree

packages/engine/src/services/frameCapture.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1889,13 +1889,16 @@ function recordCaptureWarnings(session: CaptureSession, warnings: readonly Captu
18891889
function recordSubTimelineWarning(session: CaptureSession, timeoutMs: number): void {
18901890
if (session.subTimelineWaitOutcome === "ready" || !session.subTimelineWaitOutcome) return;
18911891
const scriptFailure = session.subTimelineWaitOutcome === "script_failure";
1892+
const hasRuntimeErrors = session.scriptLoadFailures.some((f) => f.startsWith("runtime-error:"));
18921893
recordCaptureWarnings(session, [
18931894
{
18941895
code: scriptFailure ? "sub_timeline_script_failure" : "sub_timeline_readiness_timeout",
18951896
message: scriptFailure
1896-
? "A sub-composition timeline script failed to load"
1897+
? hasRuntimeErrors
1898+
? `A sub-composition script threw during execution — timeline registration never arrived (${session.scriptLoadFailures.join(", ")})`
1899+
: `A sub-composition timeline script failed to load (${session.scriptLoadFailures.join(", ")})`
18971900
: `Sub-composition timelines did not become ready within ${timeoutMs}ms`,
1898-
details: { timeoutMs },
1901+
details: { timeoutMs, sources: [...session.scriptLoadFailures] },
18991902
},
19001903
]);
19011904
}
@@ -2066,6 +2069,16 @@ export async function initializeSession(session: CaptureSession): Promise<void>
20662069
const diagnostic = formatConsoleDiagnostic(type, text, locationUrl);
20672070
if (!diagnostic.suppressHostLog) console.log(diagnostic.text);
20682071
appendBrowserDiagnostic(session, diagnostic.text);
2072+
2073+
// Composition script runtime errors mean the GSAP timeline registration
2074+
// can never arrive — same fail-fast treatment as script load failures.
2075+
// Without this, pollSubCompositionTimelines burns the full timeout and
2076+
// the render silently succeeds with a degenerate 2-frame output (#3352).
2077+
if (type === "error" && text.startsWith("[HyperFrames] composition script error:")) {
2078+
const detail = text.slice("[HyperFrames] composition script error:".length).trim();
2079+
const compId = detail.split(" ")[0] || "unknown";
2080+
recordScriptLoadFailure(session, `runtime-error:${compId}`);
2081+
}
20692082
});
20702083

20712084
page.on("pageerror", (err) => {

packages/producer/src/services/render/renderEventPublisher.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,45 @@ describe("updateJobStatus", () => {
246246
);
247247
});
248248

249+
it("blocks sub-timeline script failures in best-effort mode (#3352)", () => {
250+
const job = createRenderJob({ fps: 30, quality: "high" });
251+
const log = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
252+
expect(() =>
253+
applyRenderWarningPolicy(
254+
job,
255+
[
256+
{
257+
code: "sub_timeline_script_failure",
258+
message: "A sub-composition script threw during execution",
259+
details: {
260+
timeoutMs: 45_000,
261+
sources: ["runtime-error:decision-tree-123"],
262+
},
263+
},
264+
],
265+
log,
266+
),
267+
).toThrow(RenderQualityError);
268+
expect(job.warnings).toHaveLength(1);
269+
});
270+
271+
it("allows sub-timeline readiness timeout in best-effort mode", () => {
272+
const job = createRenderJob({ fps: 30, quality: "high" });
273+
const log = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };
274+
applyRenderWarningPolicy(
275+
job,
276+
[
277+
{
278+
code: "sub_timeline_readiness_timeout",
279+
message: "Sub-composition timelines did not become ready within 45000ms",
280+
details: { timeoutMs: 45_000 },
281+
},
282+
],
283+
log,
284+
);
285+
expect(job.warnings).toHaveLength(1);
286+
});
287+
249288
it("fails explicitly strict renders on correctness warnings", () => {
250289
const job = createRenderJob({ fps: 30, quality: "high", strictness: "strict" });
251290
const log = { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() };

packages/producer/src/services/renderOrchestrator.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -685,7 +685,13 @@ export function applyRenderWarningPolicy(
685685
const hasAudioProcessingFailure = job.warnings.some(
686686
(warning) => warning.code === "audio_processing_failed",
687687
);
688-
if (strictness === "strict" || hasAudioProcessingFailure) {
688+
// A script failure means the composition's GSAP timelines can never
689+
// register — the render produces a degenerate 2-frame output that looks
690+
// like a still image. Fail loudly rather than shipping garbage (#3352).
691+
const hasSubTimelineScriptFailure = job.warnings.some(
692+
(warning) => warning.code === "sub_timeline_script_failure",
693+
);
694+
if (strictness === "strict" || hasAudioProcessingFailure || hasSubTimelineScriptFailure) {
689695
throw new RenderQualityError(job.warnings);
690696
}
691697
}

0 commit comments

Comments
 (0)