From df37eb3832b330a9bf716c5c6a9e6cb1b5905323 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 21 Jun 2026 20:04:54 -0400 Subject: [PATCH] fix(skills): reject empty/partial scene files at assembly, not at render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scene worker that errors or is interrupted mid-write leaves an empty (or markup-less) compositions/.html. existsSync passed, so assemble-index emitted a data-composition-src pointing at it and the failure surfaced much later as the render-compile error "Composition HTML is empty or could not be parsed: compositions/scene-*.html" — the #1 render_error, ~4.7k users/day and climbing. All three assemblers (product-launch-video, faceless-explainer, pr-to-video) now validate scene-file content (non-empty + contains markup) right where they already read it for the duration cross-check, and die with an actionable "re-dispatch that scene worker" message before the broken project can reach a user's render. --- skills/faceless-explainer/scripts/assemble-index.mjs | 12 +++++++++++- skills/pr-to-video/scripts/assemble-index.mjs | 12 +++++++++++- .../product-launch-video/scripts/assemble-index.mjs | 12 +++++++++++- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/skills/faceless-explainer/scripts/assemble-index.mjs b/skills/faceless-explainer/scripts/assemble-index.mjs index 88dc7a9965..e48907066a 100644 --- a/skills/faceless-explainer/scripts/assemble-index.mjs +++ b/skills/faceless-explainer/scripts/assemble-index.mjs @@ -171,7 +171,17 @@ for (const visual of visualClips) { const start = Number(visual.start_s); if (!isFinite(start) || start < 0) die(`${visual.id}: visual start_s invalid (${visual.start_s})`); - const rootDur = rootDataDuration(readFileSync(compAbs, "utf8")); + // Guard against blank/partial scene files: a worker that errors or is + // interrupted mid-write leaves an empty (or markup-less) file that exists but + // fails at render with "Composition HTML is empty or could not be parsed". + // Catch it here — before emitting data-composition-src — and re-dispatch. + const compHtml = readFileSync(compAbs, "utf8"); + if (!compHtml.trim() || !/<\w/.test(compHtml)) { + die( + `visual file ${compRel} is empty or has no HTML — the worker for ${visual.id} wrote a blank/partial file. Re-dispatch that scene worker before assembling.`, + ); + } + const rootDur = rootDataDuration(compHtml); if (rootDur == null) { anomalies.push( `${visual.id}: could not read root data-duration from ${compRel} — skipped duration cross-check`, diff --git a/skills/pr-to-video/scripts/assemble-index.mjs b/skills/pr-to-video/scripts/assemble-index.mjs index 4368197ae5..01599835ca 100644 --- a/skills/pr-to-video/scripts/assemble-index.mjs +++ b/skills/pr-to-video/scripts/assemble-index.mjs @@ -171,7 +171,17 @@ for (const visual of visualClips) { const start = Number(visual.start_s); if (!isFinite(start) || start < 0) die(`${visual.id}: visual start_s invalid (${visual.start_s})`); - const rootDur = rootDataDuration(readFileSync(compAbs, "utf8")); + // Guard against blank/partial scene files: a worker that errors or is + // interrupted mid-write leaves an empty (or markup-less) file that exists but + // fails at render with "Composition HTML is empty or could not be parsed". + // Catch it here — before emitting data-composition-src — and re-dispatch. + const compHtml = readFileSync(compAbs, "utf8"); + if (!compHtml.trim() || !/<\w/.test(compHtml)) { + die( + `visual file ${compRel} is empty or has no HTML — the worker for ${visual.id} wrote a blank/partial file. Re-dispatch that scene worker before assembling.`, + ); + } + const rootDur = rootDataDuration(compHtml); if (rootDur == null) { anomalies.push( `${visual.id}: could not read root data-duration from ${compRel} — skipped duration cross-check`, diff --git a/skills/product-launch-video/scripts/assemble-index.mjs b/skills/product-launch-video/scripts/assemble-index.mjs index c86e5d5db5..2187d6a3fb 100644 --- a/skills/product-launch-video/scripts/assemble-index.mjs +++ b/skills/product-launch-video/scripts/assemble-index.mjs @@ -125,7 +125,17 @@ for (const { sid, scene } of playOrder) { `${sid}: group_spec estimatedDuration_s missing or non-positive (${scene.estimatedDuration_s})`, ); } - const rootDur = rootDataDuration(readFileSync(compAbs, "utf8")); + // Guard against blank/partial scene files: a worker that errors or is + // interrupted mid-write leaves an empty (or markup-less) file that exists but + // fails at render with "Composition HTML is empty or could not be parsed". + // Catch it here — before emitting data-composition-src — and re-dispatch. + const compHtml = readFileSync(compAbs, "utf8"); + if (!compHtml.trim() || !/<\w/.test(compHtml)) { + die( + `scene file ${compRel} is empty or has no HTML — the worker for ${sid} wrote a blank/partial file. Re-dispatch that scene worker before assembling.`, + ); + } + const rootDur = rootDataDuration(compHtml); if (rootDur == null) { anomalies.push( `${sid}: could not read root data-duration from ${compRel} — skipped duration cross-check`,