fix(skills): hold frame content through transitions (#2235)

* fix(skills): hold frame content through transitions

* test(storyboard): cover normal transition worker roots

* style(skills): format transition injectors

* chore: refresh skills manifest
This commit is contained in:
Miguel Ángel
2026-07-11 18:32:18 -04:00
committed by GitHub
parent c18391b98c
commit 687883124f
5 changed files with 237 additions and 7 deletions
@@ -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"/);
},
);
});
+3 -3
View File
@@ -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": {
@@ -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,
+54 -1
View File
@@ -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,
@@ -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,