mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-13 15:49:53 +00:00
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:
@@ -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,7 +6,7 @@
|
|||||||
"files": 144
|
"files": 144
|
||||||
},
|
},
|
||||||
"faceless-explainer": {
|
"faceless-explainer": {
|
||||||
"hash": "a09191a97564b114",
|
"hash": "b57c98179677e7a0",
|
||||||
"files": 18
|
"files": 18
|
||||||
},
|
},
|
||||||
"figma": {
|
"figma": {
|
||||||
@@ -58,11 +58,11 @@
|
|||||||
"files": 132
|
"files": 132
|
||||||
},
|
},
|
||||||
"pr-to-video": {
|
"pr-to-video": {
|
||||||
"hash": "21dcf8aa94fc1033",
|
"hash": "2132f6f9bd474f52",
|
||||||
"files": 22
|
"files": 22
|
||||||
},
|
},
|
||||||
"product-launch-video": {
|
"product-launch-video": {
|
||||||
"hash": "cb30ad95ba8863c7",
|
"hash": "b1b41c74a52e28f1",
|
||||||
"files": 21
|
"files": 21
|
||||||
},
|
},
|
||||||
"remotion-to-hyperframes": {
|
"remotion-to-hyperframes": {
|
||||||
|
|||||||
@@ -93,6 +93,57 @@ function parseFrameClips(html, frameIds) {
|
|||||||
return clips;
|
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).
|
// Resolve a transition_in spec to a registry record (calm default on unknown).
|
||||||
function resolveRecord(spec, byName, reg, warn) {
|
function resolveRecord(spec, byName, reg, warn) {
|
||||||
let rec = byName.get(spec.type);
|
let rec = byName.get(spec.type);
|
||||||
@@ -183,7 +234,9 @@ function runInject(argv) {
|
|||||||
);
|
);
|
||||||
const dur = resolveDur(spec, rec, reg);
|
const dur = resolveDur(spec, rec, reg);
|
||||||
const T = r3(incoming.start); // cut = incoming start (frames tile)
|
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(
|
padFrameInternalDuration(
|
||||||
hyperframesDir,
|
hyperframesDir,
|
||||||
order[i - 1].frame.src,
|
order[i - 1].frame.src,
|
||||||
|
|||||||
@@ -93,6 +93,57 @@ function parseFrameClips(html, frameIds) {
|
|||||||
return clips;
|
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).
|
// Resolve a transition_in spec to a registry record (calm default on unknown).
|
||||||
function resolveRecord(spec, byName, reg, warn) {
|
function resolveRecord(spec, byName, reg, warn) {
|
||||||
let rec = byName.get(spec.type);
|
let rec = byName.get(spec.type);
|
||||||
@@ -183,7 +234,9 @@ function runInject(argv) {
|
|||||||
);
|
);
|
||||||
const dur = resolveDur(spec, rec, reg);
|
const dur = resolveDur(spec, rec, reg);
|
||||||
const T = r3(incoming.start); // cut = incoming start (frames tile)
|
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(
|
padFrameInternalDuration(
|
||||||
hyperframesDir,
|
hyperframesDir,
|
||||||
order[i - 1].frame.src,
|
order[i - 1].frame.src,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/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
|
// inject — read STORYBOARD frame order + each frame's transition_in, overlap
|
||||||
// the frame clip wrappers in index.html, and stamp the GSAP template.
|
// the frame clip wrappers in index.html, and stamp the GSAP template.
|
||||||
@@ -93,6 +93,57 @@ function parseFrameClips(html, frameIds) {
|
|||||||
return clips;
|
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).
|
// Resolve a transition_in spec to a registry record (calm default on unknown).
|
||||||
function resolveRecord(spec, byName, reg, warn) {
|
function resolveRecord(spec, byName, reg, warn) {
|
||||||
let rec = byName.get(spec.type);
|
let rec = byName.get(spec.type);
|
||||||
@@ -183,7 +234,9 @@ function runInject(argv) {
|
|||||||
);
|
);
|
||||||
const dur = resolveDur(spec, rec, reg);
|
const dur = resolveDur(spec, rec, reg);
|
||||||
const T = r3(incoming.start); // cut = incoming start (frames tile)
|
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(
|
padFrameInternalDuration(
|
||||||
hyperframesDir,
|
hyperframesDir,
|
||||||
order[i - 1].frame.src,
|
order[i - 1].frame.src,
|
||||||
|
|||||||
Reference in New Issue
Block a user