diff --git a/packages/core/src/storyboard/transitionsContract.test.ts b/packages/core/src/storyboard/transitionsContract.test.ts
new file mode 100644
index 000000000..c81ca1116
--- /dev/null
+++ b/packages/core/src/storyboard/transitionsContract.test.ts
@@ -0,0 +1,71 @@
+// @vitest-environment node
+import { execFileSync } from "node:child_process";
+import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { afterEach, describe, expect, it } from "vitest";
+
+const REPO_ROOT = join(fileURLToPath(new URL(".", import.meta.url)), "..", "..", "..", "..");
+const skillNames = ["faceless-explainer", "pr-to-video", "product-launch-video"];
+const tempDirs: string[] = [];
+
+afterEach(() => {
+ for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
+});
+
+function makeProject({ preInflatedRoot = true } = {}): string {
+ const project = mkdtempSync(join(tmpdir(), "hf-transition-tail-"));
+ tempDirs.push(project);
+ mkdirSync(join(project, "compositions"));
+ writeFileSync(
+ join(project, "STORYBOARD.md"),
+ `---\nformat: 1920x1080\n---\n\n## Frame 1\n- status: built\n- duration: 2s\n- src: compositions/01.html\n\n## Frame 2\n- status: built\n- duration: 2s\n- src: compositions/02.html\n- transition_in: crossfade 0.5s\n`,
+ );
+ const rootDuration = preInflatedRoot ? 2.5 : 2;
+ writeFileSync(
+ join(project, "compositions", "01.html"),
+ `
`,
+ );
+ writeFileSync(
+ join(project, "compositions", "02.html"),
+ ``,
+ );
+ writeFileSync(
+ join(project, "index.html"),
+ `
+ `,
+ );
+ return project;
+}
+
+describe.each(skillNames)("%s transition contract", (skillName) => {
+ it.each([
+ ["pre-inflated worker root", true],
+ ["normal worker root", false],
+ ])(
+ "extends the outgoing frame root and visual tail clips across overlap (%s)",
+ (_, preInflatedRoot) => {
+ const project = makeProject({ preInflatedRoot });
+ const script = join(REPO_ROOT, "skills", skillName, "scripts", "transitions.mjs");
+ execFileSync(process.execPath, [script, "inject", "--hyperframes", project], {
+ stdio: "pipe",
+ });
+
+ const frame = readFileSync(join(project, "compositions", "01.html"), "utf8");
+ const index = readFileSync(join(project, "index.html"), "utf8");
+ expect(frame).toContain('data-composition-id="01" data-start="0" data-duration="2.5"');
+ expect(frame).toContain('id="ground" data-start="0" data-duration="2.5"');
+ expect(frame).toContain('id="content" data-start="0.5" data-duration="2"');
+ expect(frame).toContain('id="voice" data-start="0" data-duration="2"');
+ expect(index).toMatch(/id="el-01"[^>]*data-duration="2.5"/);
+ },
+ );
+});
diff --git a/skills-manifest.json b/skills-manifest.json
index 5a28a54f5..a0384101e 100644
--- a/skills-manifest.json
+++ b/skills-manifest.json
@@ -6,7 +6,7 @@
"files": 144
},
"faceless-explainer": {
- "hash": "a09191a97564b114",
+ "hash": "b57c98179677e7a0",
"files": 18
},
"figma": {
@@ -58,11 +58,11 @@
"files": 132
},
"pr-to-video": {
- "hash": "21dcf8aa94fc1033",
+ "hash": "2132f6f9bd474f52",
"files": 22
},
"product-launch-video": {
- "hash": "cb30ad95ba8863c7",
+ "hash": "b1b41c74a52e28f1",
"files": 21
},
"remotion-to-hyperframes": {
diff --git a/skills/faceless-explainer/scripts/transitions.mjs b/skills/faceless-explainer/scripts/transitions.mjs
index 22ad13ff8..2c2c568ea 100644
--- a/skills/faceless-explainer/scripts/transitions.mjs
+++ b/skills/faceless-explainer/scripts/transitions.mjs
@@ -93,6 +93,57 @@ function parseFrameClips(html, frameIds) {
return clips;
}
+// The host wrapper is extended across an outgoing transition, so the mounted
+// frame must remain visually populated for the same local-time window. Extend
+// the frame root and every non-audio timed element that reached the original
+// storyboard boundary. This also repairs worker files whose root was already
+// inflated while their ground/content clips still ended at the synced duration.
+function extendFrameTail(hyperframesDir, frame, baseDuration, targetDuration, die) {
+ if (!frame?.src || targetDuration <= baseDuration) return;
+ const framePath = join(hyperframesDir, frame.src);
+ let html;
+ try {
+ html = readFileSync(framePath, "utf8");
+ } catch {
+ die(`outgoing frame file not found at ${framePath}`);
+ }
+
+ const compId = frame.src
+ .split("/")
+ .pop()
+ .replace(/\.html?$/i, "");
+ const EPS = 0.011;
+ let foundRoot = false;
+ let extended = 0;
+ const rewritten = html.replace(/<([A-Za-z][\w:-]*)\b([^>]*)>/g, (tag, name, attrs) => {
+ const durationMatch = attrs.match(/\bdata-duration="([\d.]+)"/);
+ if (!durationMatch) return tag;
+ const duration = Number(durationMatch[1]);
+ if (!Number.isFinite(duration)) return tag;
+
+ const compositionMatch = attrs.match(/\bdata-composition-id="([^"]+)"/);
+ if (compositionMatch?.[1] === compId && !foundRoot) {
+ foundRoot = true;
+ return tag.replace(/\bdata-duration="[\d.]+"/, `data-duration="${targetDuration}"`);
+ }
+
+ if (name.toLowerCase() === "audio") return tag;
+ const startMatch = attrs.match(/\bdata-start="([\d.]+)"/);
+ if (!startMatch) return tag;
+ const start = Number(startMatch[1]);
+ const end = start + duration;
+ if (end < baseDuration - EPS || end >= targetDuration - EPS) return tag;
+ extended++;
+ return tag.replace(/\bdata-duration="[\d.]+"/, `data-duration="${r3(targetDuration - start)}"`);
+ });
+
+ if (!foundRoot) die(`${frame.src} has no data-composition-id="${compId}" root`);
+ writeFileSync(framePath, rewritten);
+ console.log(
+ ` ${compId}: extended root + ${extended} tail clip(s) ${baseDuration}s→${targetDuration}s`,
+ );
+}
+
// Resolve a transition_in spec to a registry record (calm default on unknown).
function resolveRecord(spec, byName, reg, warn) {
let rec = byName.get(spec.type);
@@ -183,7 +234,9 @@ function runInject(argv) {
);
const dur = resolveDur(spec, rec, reg);
const T = r3(incoming.start); // cut = incoming start (frames tile)
- outgoing.duration = r3(outgoing.duration + dur); // extend outgoing only
+ const baseDuration = outgoing.duration;
+ outgoing.duration = r3(baseDuration + dur); // extend outgoing only
+ extendFrameTail(hyperframesDir, order[i - 1].frame, baseDuration, outgoing.duration, die);
padFrameInternalDuration(
hyperframesDir,
order[i - 1].frame.src,
diff --git a/skills/pr-to-video/scripts/transitions.mjs b/skills/pr-to-video/scripts/transitions.mjs
index 22ad13ff8..2c2c568ea 100644
--- a/skills/pr-to-video/scripts/transitions.mjs
+++ b/skills/pr-to-video/scripts/transitions.mjs
@@ -93,6 +93,57 @@ function parseFrameClips(html, frameIds) {
return clips;
}
+// The host wrapper is extended across an outgoing transition, so the mounted
+// frame must remain visually populated for the same local-time window. Extend
+// the frame root and every non-audio timed element that reached the original
+// storyboard boundary. This also repairs worker files whose root was already
+// inflated while their ground/content clips still ended at the synced duration.
+function extendFrameTail(hyperframesDir, frame, baseDuration, targetDuration, die) {
+ if (!frame?.src || targetDuration <= baseDuration) return;
+ const framePath = join(hyperframesDir, frame.src);
+ let html;
+ try {
+ html = readFileSync(framePath, "utf8");
+ } catch {
+ die(`outgoing frame file not found at ${framePath}`);
+ }
+
+ const compId = frame.src
+ .split("/")
+ .pop()
+ .replace(/\.html?$/i, "");
+ const EPS = 0.011;
+ let foundRoot = false;
+ let extended = 0;
+ const rewritten = html.replace(/<([A-Za-z][\w:-]*)\b([^>]*)>/g, (tag, name, attrs) => {
+ const durationMatch = attrs.match(/\bdata-duration="([\d.]+)"/);
+ if (!durationMatch) return tag;
+ const duration = Number(durationMatch[1]);
+ if (!Number.isFinite(duration)) return tag;
+
+ const compositionMatch = attrs.match(/\bdata-composition-id="([^"]+)"/);
+ if (compositionMatch?.[1] === compId && !foundRoot) {
+ foundRoot = true;
+ return tag.replace(/\bdata-duration="[\d.]+"/, `data-duration="${targetDuration}"`);
+ }
+
+ if (name.toLowerCase() === "audio") return tag;
+ const startMatch = attrs.match(/\bdata-start="([\d.]+)"/);
+ if (!startMatch) return tag;
+ const start = Number(startMatch[1]);
+ const end = start + duration;
+ if (end < baseDuration - EPS || end >= targetDuration - EPS) return tag;
+ extended++;
+ return tag.replace(/\bdata-duration="[\d.]+"/, `data-duration="${r3(targetDuration - start)}"`);
+ });
+
+ if (!foundRoot) die(`${frame.src} has no data-composition-id="${compId}" root`);
+ writeFileSync(framePath, rewritten);
+ console.log(
+ ` ${compId}: extended root + ${extended} tail clip(s) ${baseDuration}s→${targetDuration}s`,
+ );
+}
+
// Resolve a transition_in spec to a registry record (calm default on unknown).
function resolveRecord(spec, byName, reg, warn) {
let rec = byName.get(spec.type);
@@ -183,7 +234,9 @@ function runInject(argv) {
);
const dur = resolveDur(spec, rec, reg);
const T = r3(incoming.start); // cut = incoming start (frames tile)
- outgoing.duration = r3(outgoing.duration + dur); // extend outgoing only
+ const baseDuration = outgoing.duration;
+ outgoing.duration = r3(baseDuration + dur); // extend outgoing only
+ extendFrameTail(hyperframesDir, order[i - 1].frame, baseDuration, outgoing.duration, die);
padFrameInternalDuration(
hyperframesDir,
order[i - 1].frame.src,
diff --git a/skills/product-launch-video/scripts/transitions.mjs b/skills/product-launch-video/scripts/transitions.mjs
index 66b3b60aa..2c2c568ea 100644
--- a/skills/product-launch-video/scripts/transitions.mjs
+++ b/skills/product-launch-video/scripts/transitions.mjs
@@ -1,5 +1,5 @@
#!/usr/bin/env node
-// transitions.mjs — inter-frame transition injector + verifier for product-launch.
+// transitions.mjs — inter-frame transition injector + verifier for the video workflow.
//
// inject — read STORYBOARD frame order + each frame's transition_in, overlap
// the frame clip wrappers in index.html, and stamp the GSAP template.
@@ -93,6 +93,57 @@ function parseFrameClips(html, frameIds) {
return clips;
}
+// The host wrapper is extended across an outgoing transition, so the mounted
+// frame must remain visually populated for the same local-time window. Extend
+// the frame root and every non-audio timed element that reached the original
+// storyboard boundary. This also repairs worker files whose root was already
+// inflated while their ground/content clips still ended at the synced duration.
+function extendFrameTail(hyperframesDir, frame, baseDuration, targetDuration, die) {
+ if (!frame?.src || targetDuration <= baseDuration) return;
+ const framePath = join(hyperframesDir, frame.src);
+ let html;
+ try {
+ html = readFileSync(framePath, "utf8");
+ } catch {
+ die(`outgoing frame file not found at ${framePath}`);
+ }
+
+ const compId = frame.src
+ .split("/")
+ .pop()
+ .replace(/\.html?$/i, "");
+ const EPS = 0.011;
+ let foundRoot = false;
+ let extended = 0;
+ const rewritten = html.replace(/<([A-Za-z][\w:-]*)\b([^>]*)>/g, (tag, name, attrs) => {
+ const durationMatch = attrs.match(/\bdata-duration="([\d.]+)"/);
+ if (!durationMatch) return tag;
+ const duration = Number(durationMatch[1]);
+ if (!Number.isFinite(duration)) return tag;
+
+ const compositionMatch = attrs.match(/\bdata-composition-id="([^"]+)"/);
+ if (compositionMatch?.[1] === compId && !foundRoot) {
+ foundRoot = true;
+ return tag.replace(/\bdata-duration="[\d.]+"/, `data-duration="${targetDuration}"`);
+ }
+
+ if (name.toLowerCase() === "audio") return tag;
+ const startMatch = attrs.match(/\bdata-start="([\d.]+)"/);
+ if (!startMatch) return tag;
+ const start = Number(startMatch[1]);
+ const end = start + duration;
+ if (end < baseDuration - EPS || end >= targetDuration - EPS) return tag;
+ extended++;
+ return tag.replace(/\bdata-duration="[\d.]+"/, `data-duration="${r3(targetDuration - start)}"`);
+ });
+
+ if (!foundRoot) die(`${frame.src} has no data-composition-id="${compId}" root`);
+ writeFileSync(framePath, rewritten);
+ console.log(
+ ` ${compId}: extended root + ${extended} tail clip(s) ${baseDuration}s→${targetDuration}s`,
+ );
+}
+
// Resolve a transition_in spec to a registry record (calm default on unknown).
function resolveRecord(spec, byName, reg, warn) {
let rec = byName.get(spec.type);
@@ -183,7 +234,9 @@ function runInject(argv) {
);
const dur = resolveDur(spec, rec, reg);
const T = r3(incoming.start); // cut = incoming start (frames tile)
- outgoing.duration = r3(outgoing.duration + dur); // extend outgoing only
+ const baseDuration = outgoing.duration;
+ outgoing.duration = r3(baseDuration + dur); // extend outgoing only
+ extendFrameTail(hyperframesDir, order[i - 1].frame, baseDuration, outgoing.duration, die);
padFrameInternalDuration(
hyperframesDir,
order[i - 1].frame.src,