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,