fix(skills): reject empty/partial scene files at assembly, not at render (#1629)

A scene worker that errors or is interrupted mid-write leaves an empty (or
markup-less) compositions/<scene>.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.
This commit is contained in:
Miguel Ángel
2026-06-21 20:16:22 -04:00
committed by GitHub
parent e385d213f3
commit a843b2acb7
3 changed files with 33 additions and 3 deletions
@@ -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`,
+11 -1
View File
@@ -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`,
@@ -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`,