Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions packages/core/src/storyboard/transitionsContract.test.ts
Original file line number Diff line number Diff line change
@@ -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"),
`<div data-composition-id="01" data-start="0" data-duration="${rootDuration}">
<div id="ground" data-start="0" data-duration="2"></div>
<div id="content" data-start="0.5" data-duration="1.5"></div>
<audio id="voice" data-start="0" data-duration="2"></audio>
</div>`,
);
writeFileSync(
join(project, "compositions", "02.html"),
`<div data-composition-id="02" data-start="0" data-duration="2"></div>`,
);
writeFileSync(
join(project, "index.html"),
`<div data-composition-id="main" data-start="0" data-duration="4">
<div id="el-01" data-composition-id="01" data-composition-src="compositions/01.html" data-start="0" data-duration="2" data-track-index="1"></div>
<div id="el-02" data-composition-id="02" data-composition-src="compositions/02.html" data-start="2" data-duration="2" data-track-index="1"></div>
</div>
<script>window.__timelines["main"] = gsap.timeline({ paused: true });</script>`,
);
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"/);
},
);
});
6 changes: 3 additions & 3 deletions skills-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"files": 144
},
"faceless-explainer": {
"hash": "a09191a97564b114",
"hash": "b57c98179677e7a0",
"files": 18
},
"figma": {
Expand Down Expand Up @@ -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": {
Expand Down
55 changes: 54 additions & 1 deletion skills/faceless-explainer/scripts/transitions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
55 changes: 54 additions & 1 deletion skills/pr-to-video/scripts/transitions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
57 changes: 55 additions & 2 deletions skills/product-launch-video/scripts/transitions.mjs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
Loading