diff --git a/skills-manifest.json b/skills-manifest.json
index 2b1142879..5d48f0d0f 100644
--- a/skills-manifest.json
+++ b/skills-manifest.json
@@ -6,8 +6,8 @@
"files": 144
},
"faceless-explainer": {
- "hash": "844c54a06fd16d3c",
- "files": 17
+ "hash": "6dac80491c3db278",
+ "files": 18
},
"figma": {
"hash": "0adc2a1e01767db7",
@@ -62,12 +62,12 @@
"files": 132
},
"pr-to-video": {
- "hash": "132b44ddda774fef",
- "files": 21
+ "hash": "d40ba25aa5af30e1",
+ "files": 22
},
"product-launch-video": {
- "hash": "32cab842cc7a6b76",
- "files": 18
+ "hash": "b1895d518ec04da5",
+ "files": 20
},
"remotion-to-hyperframes": {
"hash": "9d959b31fa0fc9d0",
diff --git a/skills/faceless-explainer/scripts/lib/pad-frame-duration.mjs b/skills/faceless-explainer/scripts/lib/pad-frame-duration.mjs
new file mode 100644
index 000000000..91471151f
--- /dev/null
+++ b/skills/faceless-explainer/scripts/lib/pad-frame-duration.mjs
@@ -0,0 +1,36 @@
+// pad-frame-duration.mjs — keeps a frame's own #root/clip data-duration in
+// sync with the padded index.html wrapper duration transitions.mjs computes.
+//
+// The frame's OWN internal file declares its #root/clip data-duration to the
+// STORYBOARD's content-only length (frame-worker.md: duration is "fixed
+// upstream"). When an outgoing transition pads the index.html WRAPPER's
+// data-duration to cover the transition tail, the frame's own internal
+// duration is left short — the render engine clip-gates the sub-composition's
+// visible content at that shorter value, so content vanishes abruptly at
+// content-end instead of fading gracefully through the wrapper's extended
+// fade-out tween. Pad the frame's own file to match so both durations agree.
+
+import { readFileSync, writeFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+export function padFrameInternalDuration(hyperframesDir, frameSrc, frameId, newDuration) {
+ const framePath = resolve(hyperframesDir, frameSrc);
+ let html;
+ try {
+ html = readFileSync(framePath, "utf8");
+ } catch (err) {
+ if (err?.code === "ENOENT") return;
+ throw err;
+ }
+ const tagRe = /<[a-z][\w:-]*\s[^<>]*?>/gi;
+ let m;
+ while ((m = tagRe.exec(html)) !== null) {
+ const tag = m[0];
+ if (!tag.includes(`data-composition-id="${frameId}"`)) continue;
+ if (!/data-duration="[\d.]+"/.test(tag)) continue;
+ const newTag = tag.replace(/data-duration="[\d.]+"/, `data-duration="${newDuration}"`);
+ if (newTag === tag) return;
+ writeFileSync(framePath, html.slice(0, m.index) + newTag + html.slice(m.index + tag.length));
+ return;
+ }
+}
diff --git a/skills/faceless-explainer/scripts/transitions.mjs b/skills/faceless-explainer/scripts/transitions.mjs
index d30a9fae8..22ad13ff8 100644
--- a/skills/faceless-explainer/scripts/transitions.mjs
+++ b/skills/faceless-explainer/scripts/transitions.mjs
@@ -28,6 +28,7 @@ import { join, resolve } from "node:path";
import { parseStoryboard } from "./lib/storyboard.mjs";
import { parseFormat } from "./lib/dimensions.mjs";
import { loadTransitionRegistry, transitionsByName } from "./lib/transition-registry.mjs";
+import { padFrameInternalDuration } from "./lib/pad-frame-duration.mjs";
const flag = (argv, name, def) => {
const i = argv.indexOf(`--${name}`);
@@ -183,6 +184,12 @@ 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
+ padFrameInternalDuration(
+ hyperframesDir,
+ order[i - 1].frame.src,
+ outgoing.id,
+ outgoing.duration,
+ );
gsapLines.push(
...buildGsap(rec, outgoing.id, incoming.id, dur, T, spec.direction, CW, CH, die),
);
diff --git a/skills/pr-to-video/scripts/lib/pad-frame-duration.mjs b/skills/pr-to-video/scripts/lib/pad-frame-duration.mjs
new file mode 100644
index 000000000..91471151f
--- /dev/null
+++ b/skills/pr-to-video/scripts/lib/pad-frame-duration.mjs
@@ -0,0 +1,36 @@
+// pad-frame-duration.mjs — keeps a frame's own #root/clip data-duration in
+// sync with the padded index.html wrapper duration transitions.mjs computes.
+//
+// The frame's OWN internal file declares its #root/clip data-duration to the
+// STORYBOARD's content-only length (frame-worker.md: duration is "fixed
+// upstream"). When an outgoing transition pads the index.html WRAPPER's
+// data-duration to cover the transition tail, the frame's own internal
+// duration is left short — the render engine clip-gates the sub-composition's
+// visible content at that shorter value, so content vanishes abruptly at
+// content-end instead of fading gracefully through the wrapper's extended
+// fade-out tween. Pad the frame's own file to match so both durations agree.
+
+import { readFileSync, writeFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+export function padFrameInternalDuration(hyperframesDir, frameSrc, frameId, newDuration) {
+ const framePath = resolve(hyperframesDir, frameSrc);
+ let html;
+ try {
+ html = readFileSync(framePath, "utf8");
+ } catch (err) {
+ if (err?.code === "ENOENT") return;
+ throw err;
+ }
+ const tagRe = /<[a-z][\w:-]*\s[^<>]*?>/gi;
+ let m;
+ while ((m = tagRe.exec(html)) !== null) {
+ const tag = m[0];
+ if (!tag.includes(`data-composition-id="${frameId}"`)) continue;
+ if (!/data-duration="[\d.]+"/.test(tag)) continue;
+ const newTag = tag.replace(/data-duration="[\d.]+"/, `data-duration="${newDuration}"`);
+ if (newTag === tag) return;
+ writeFileSync(framePath, html.slice(0, m.index) + newTag + html.slice(m.index + tag.length));
+ return;
+ }
+}
diff --git a/skills/pr-to-video/scripts/transitions.mjs b/skills/pr-to-video/scripts/transitions.mjs
index d30a9fae8..22ad13ff8 100644
--- a/skills/pr-to-video/scripts/transitions.mjs
+++ b/skills/pr-to-video/scripts/transitions.mjs
@@ -28,6 +28,7 @@ import { join, resolve } from "node:path";
import { parseStoryboard } from "./lib/storyboard.mjs";
import { parseFormat } from "./lib/dimensions.mjs";
import { loadTransitionRegistry, transitionsByName } from "./lib/transition-registry.mjs";
+import { padFrameInternalDuration } from "./lib/pad-frame-duration.mjs";
const flag = (argv, name, def) => {
const i = argv.indexOf(`--${name}`);
@@ -183,6 +184,12 @@ 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
+ padFrameInternalDuration(
+ hyperframesDir,
+ order[i - 1].frame.src,
+ outgoing.id,
+ outgoing.duration,
+ );
gsapLines.push(
...buildGsap(rec, outgoing.id, incoming.id, dur, T, spec.direction, CW, CH, die),
);
diff --git a/skills/product-launch-video/scripts/lib/pad-frame-duration.mjs b/skills/product-launch-video/scripts/lib/pad-frame-duration.mjs
new file mode 100644
index 000000000..91471151f
--- /dev/null
+++ b/skills/product-launch-video/scripts/lib/pad-frame-duration.mjs
@@ -0,0 +1,36 @@
+// pad-frame-duration.mjs — keeps a frame's own #root/clip data-duration in
+// sync with the padded index.html wrapper duration transitions.mjs computes.
+//
+// The frame's OWN internal file declares its #root/clip data-duration to the
+// STORYBOARD's content-only length (frame-worker.md: duration is "fixed
+// upstream"). When an outgoing transition pads the index.html WRAPPER's
+// data-duration to cover the transition tail, the frame's own internal
+// duration is left short — the render engine clip-gates the sub-composition's
+// visible content at that shorter value, so content vanishes abruptly at
+// content-end instead of fading gracefully through the wrapper's extended
+// fade-out tween. Pad the frame's own file to match so both durations agree.
+
+import { readFileSync, writeFileSync } from "node:fs";
+import { resolve } from "node:path";
+
+export function padFrameInternalDuration(hyperframesDir, frameSrc, frameId, newDuration) {
+ const framePath = resolve(hyperframesDir, frameSrc);
+ let html;
+ try {
+ html = readFileSync(framePath, "utf8");
+ } catch (err) {
+ if (err?.code === "ENOENT") return;
+ throw err;
+ }
+ const tagRe = /<[a-z][\w:-]*\s[^<>]*?>/gi;
+ let m;
+ while ((m = tagRe.exec(html)) !== null) {
+ const tag = m[0];
+ if (!tag.includes(`data-composition-id="${frameId}"`)) continue;
+ if (!/data-duration="[\d.]+"/.test(tag)) continue;
+ const newTag = tag.replace(/data-duration="[\d.]+"/, `data-duration="${newDuration}"`);
+ if (newTag === tag) return;
+ writeFileSync(framePath, html.slice(0, m.index) + newTag + html.slice(m.index + tag.length));
+ return;
+ }
+}
diff --git a/skills/product-launch-video/scripts/lib/pad-frame-duration.test.mjs b/skills/product-launch-video/scripts/lib/pad-frame-duration.test.mjs
new file mode 100644
index 000000000..5bb395c2b
--- /dev/null
+++ b/skills/product-launch-video/scripts/lib/pad-frame-duration.test.mjs
@@ -0,0 +1,76 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { padFrameInternalDuration } from "./pad-frame-duration.mjs";
+
+// Regression: an outgoing transition pads the index.html WRAPPER's
+// data-duration to cover the transition tail, but the frame's own internal
+// file kept its shorter content-only duration, so the render engine
+// clip-gated the sub-composition's visible content at the shorter value —
+// content vanished abruptly instead of fading through the wrapper's
+// extended fade-out tween. A user diagnosed and verified this fix
+// themselves: pad the frame's own #root/clip data-duration to match.
+test("padFrameInternalDuration pads the matching frame's own data-duration", () => {
+ const dir = mkdtempSync(join(tmpdir(), "transitions-pad-"));
+ const framesDir = join(dir, "compositions", "frames");
+ mkdirSync(framesDir, { recursive: true });
+ const frameSrc = "compositions/frames/scene-1.html";
+ const framePath = join(dir, frameSrc);
+ writeFileSync(
+ framePath,
+ `
+
+`,
+ );
+
+ try {
+ padFrameInternalDuration(dir, frameSrc, "scene-1", 4.7);
+ const updated = readFileSync(framePath, "utf8");
+ assert.match(updated, /data-duration="4\.7"/);
+ assert.doesNotMatch(updated, /data-duration="4\.2"/);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test("padFrameInternalDuration only touches the tag matching the given frame id", () => {
+ const dir = mkdtempSync(join(tmpdir(), "transitions-pad-scope-"));
+ const framesDir = join(dir, "compositions", "frames");
+ mkdirSync(framesDir, { recursive: true });
+ const frameSrc = "compositions/frames/scene-2.html";
+ const framePath = join(dir, frameSrc);
+ const original = `
+
+`;
+ writeFileSync(framePath, original);
+
+ try {
+ padFrameInternalDuration(dir, frameSrc, "scene-2", 3.5);
+ const updated = readFileSync(framePath, "utf8");
+ assert.match(updated, /data-composition-id="scene-2" data-duration="3\.5"/);
+ assert.match(updated, /data-composition-id="unrelated-child" data-duration="1\.0"/);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
+
+test("padFrameInternalDuration is a no-op when the frame file does not exist", () => {
+ const dir = mkdtempSync(join(tmpdir(), "transitions-pad-missing-"));
+ try {
+ assert.doesNotThrow(() =>
+ padFrameInternalDuration(dir, "compositions/frames/missing.html", "missing", 5),
+ );
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+});
diff --git a/skills/product-launch-video/scripts/transitions.mjs b/skills/product-launch-video/scripts/transitions.mjs
index 00b1246aa..66b3b60aa 100644
--- a/skills/product-launch-video/scripts/transitions.mjs
+++ b/skills/product-launch-video/scripts/transitions.mjs
@@ -28,6 +28,7 @@ import { join, resolve } from "node:path";
import { parseStoryboard } from "./lib/storyboard.mjs";
import { parseFormat } from "./lib/dimensions.mjs";
import { loadTransitionRegistry, transitionsByName } from "./lib/transition-registry.mjs";
+import { padFrameInternalDuration } from "./lib/pad-frame-duration.mjs";
const flag = (argv, name, def) => {
const i = argv.indexOf(`--${name}`);
@@ -183,6 +184,12 @@ 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
+ padFrameInternalDuration(
+ hyperframesDir,
+ order[i - 1].frame.src,
+ outgoing.id,
+ outgoing.duration,
+ );
gsapLines.push(
...buildGsap(rec, outgoing.id, incoming.id, dur, T, spec.direction, CW, CH, die),
);