Skip to content
Closed
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
6 changes: 3 additions & 3 deletions skills-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
"files": 140
},
"faceless-explainer": {
"hash": "b772a9b6c8118c2c",
"files": 22
"hash": "5e64350379329147",
"files": 23
},
"figma": {
"hash": "517e4dc53c13ea05",
Expand All @@ -30,7 +30,7 @@
"files": 11
},
"hyperframes-core": {
"hash": "a270c70ced8cf952",
"hash": "5d3b652de1b652a4",
"files": 19
},
"hyperframes-creative": {
Expand Down
28 changes: 18 additions & 10 deletions skills/faceless-explainer/scripts/assemble-index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@
// [--audio-meta audio_meta.json]. On disk: each built frame's src html,
// capture/{assets,assets/videos,screenshots}/<basename> for staging, compositions/captions.html.
// Writes: <project>/index.html + stages assets/<basename> + (guard ① below)
// repairs a frame file in place when its root is missing data-width/height.
// repairs a frame file in place when its root is missing
// data-width/data-height/data-duration.
//
// Pre-assembly frame guards (run in the same pass that reads each frame, so common
// `lint` failures surface HERE instead of after assembly + a wasted render):
// ① AUTO-REPAIR — a sub-comp root missing data-width/data-height: inject the canvas
// dims (the renderer needs them on the cloned root; else lint root_missing_dimensions).
// ① AUTO-REPAIR — a sub-comp root missing data-width/data-height/data-duration:
// inject the canvas dims and storyboard duration. The transition injector
// extends that static duration when it pads the outgoing frame tail.
// ② HARD FAIL — a timed element (data-start+duration+track-index) that is not the root
// and lacks class="clip" (shows the whole frame), or two same-track clips that overlap.
// (Media inside a sub-comp is NOT a violation: the runtime seeks + decodes nested
Expand Down Expand Up @@ -162,7 +164,7 @@ function findRootTag(html) {
}

// Returns { errors: string[], repairedHtml: string|null, repairNote: string|null }.
function guardFrame(html, label) {
function guardFrame(html, label, durationSeconds) {
const errors = [];
// Scan a copy with comments + <script>/<style> bodies blanked, so a tag-like string
// in a comment (e.g. "<!-- match the host <video> coords -->") or in GSAP code can't
Expand Down Expand Up @@ -219,19 +221,25 @@ function guardFrame(html, label) {
}
}

// ① auto-repair: ensure the root carries data-width / data-height.
// ① auto-repair: ensure the root carries data-width / data-height / data-duration.
let repairedHtml = null;
let repairNote = null;
const root = findRootTag(html);
if (root) {
const needW = !attrPresent(root.attrs, "data-width");
const needH = !attrPresent(root.attrs, "data-height");
if (needW || needH) {
const needDuration = !attrPresent(root.attrs, "data-duration");
if (needW || needH || needDuration) {
const inject =
(needW ? ` data-width="${WIDTH}"` : "") + (needH ? ` data-height="${HEIGHT}"` : "");
(needW ? ` data-width="${WIDTH}"` : "") +
(needH ? ` data-height="${HEIGHT}"` : "") +
(needDuration ? ` data-duration="${durationSeconds}"` : "");
const newTag = root.full.replace(/(\/?>)$/, `${inject}$1`);
repairedHtml = html.slice(0, root.start) + newTag + html.slice(root.end);
repairNote = `${label}: injected${needW ? " data-width" : ""}${needH ? " data-height" : ""} (${WIDTH}×${HEIGHT}) on the root — was missing (would lint root_missing_dimensions)`;
repairNote =
`${label}: injected${needW ? " data-width" : ""}${needH ? " data-height" : ""}` +
`${needDuration ? " data-duration" : ""} on the root` +
`${needW || needH ? ` (${WIDTH}×${HEIGHT})` : ""}`;
}
}

Expand Down Expand Up @@ -281,8 +289,8 @@ for (const f of manifest.frames) {
`${label}: ${f.src} is empty or has no HTML — the worker wrote a blank/partial file. Re-dispatch that worker before assembling.`,
);
}
// pre-assembly guards: ① repair missing root dims in place, ② collects fatal violations.
const guard = guardFrame(html, label);
// pre-assembly guards: ① repair missing root metadata in place, ② collects violations.
const guard = guardFrame(html, label, r3(f.durationSeconds));
if (guard.repairedHtml) {
writeFileSync(compAbs, guard.repairedHtml);
html = guard.repairedHtml;
Expand Down
94 changes: 94 additions & 0 deletions skills/faceless-explainer/scripts/assemble-index.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import test from "node:test";

const scriptDir = dirname(new URL(import.meta.url).pathname);
const assembleScript = join(scriptDir, "assemble-index.mjs");
const transitionsScript = join(scriptDir, "transitions.mjs");

function write(path, contents) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, contents);
}

function frameWithoutRootDuration(id, duration) {
return `<template>
<div data-composition-id="${id}" data-width="1920" data-height="1080">
<div class="clip" data-start="0" data-duration="${duration}" data-track-index="0"></div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["${id}"] = gsap.timeline({ paused: true }).to({}, { duration: ${duration} });
</script>
</div>
</template>`;
}

test("assembly materializes frame root duration before transition injection", () => {
const project = mkdtempSync(join(tmpdir(), "frame-root-duration-"));
try {
write(
join(project, "STORYBOARD.md"),
`---
format: 1920x1080
---

## Frame 1 — First

- duration: 2s
- transition_in: cut
- status: animated
- src: compositions/frames/01-first.html

## Frame 2 — Second

- duration: 2s
- transition_in: crossfade 0.5s
- status: animated
- src: compositions/frames/02-second.html
`,
);
const firstPath = join(project, "compositions", "frames", "01-first.html");
const secondPath = join(project, "compositions", "frames", "02-second.html");
write(firstPath, frameWithoutRootDuration("01-first", 2));
write(secondPath, frameWithoutRootDuration("02-second", 2));

execFileSync(
process.execPath,
[assembleScript, "--storyboard", join(project, "STORYBOARD.md"), "--hyperframes", project],
{ encoding: "utf8" },
);

assert.match(
readFileSync(firstPath, "utf8"),
/data-composition-id="01-first"[^>]*data-duration="2"/,
);
assert.match(
readFileSync(secondPath, "utf8"),
/data-composition-id="02-second"[^>]*data-duration="2"/,
);

assert.doesNotThrow(() =>
execFileSync(
process.execPath,
[
transitionsScript,
"inject",
"--storyboard",
join(project, "STORYBOARD.md"),
"--hyperframes",
project,
],
{ encoding: "utf8", stdio: "pipe" },
),
);
assert.match(
readFileSync(firstPath, "utf8"),
/data-composition-id="01-first"[^>]*data-duration="2.5"/,
);
} finally {
rmSync(project, { recursive: true, force: true });
}
});
4 changes: 2 additions & 2 deletions skills/hyperframes-core/references/frame-worker-core.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,15 @@ Shared law for every narrative frame, each load-bearing; your workflow's delta a

1. **Read** — your packet top to bottom (your frame block, the inlined blueprint, the inlined rule recipes), then `frame.md` (the look). Internalize the self-check codes below before you write — most lethal is **template transport**: every `<style>` + `<script>` (including the gsap load) must live INSIDE `<template>`, because the runtime only clones template contents and the assembled-project `lint` / `check` gate can miss an unwired blank sub-composition.
2. **Design** — turn the time-coded shot sequence into a timeline using `frame.md`'s components and type ramp: each Scene window becomes a phase revealed on its `voiceover` cue, each named motion built from the recipe in your packet, the blueprint's signature move kept recognizable. Find a visual idea that reinforces the beat, not a literal restyle of the words.
3. **Author** — write the full sub-composition to `compositions/frames/<frame_id>.html` (rewrite to iterate; last write wins). `<template>`-wrapped root carrying `data-composition-id="<frame_id>"` and styled via `#root` (not a class on that element — see the self-check below), exactly one `gsap.timeline({ paused: true })` registered at `window.__timelines["<frame_id>"]`, built synchronously.
3. **Author** — write the full sub-composition to `compositions/frames/<frame_id>.html` (rewrite to iterate; last write wins). `<template>`-wrapped root carrying `data-composition-id="<frame_id>"` and the packet's positive `data-duration`, styled via `#root` (not a class on that element — see the self-check below), exactly one `gsap.timeline({ paused: true })` registered at `window.__timelines["<frame_id>"]`, built synchronously.
Prefix authored ids and globally reusable class names with `<frame_id>-` so sibling frames assembled from parallel workers cannot collide. Contract selectors such as `#root` and `.clip` are the only exceptions.
4. **Self-check, then finish** — re-read your file against the checklist below and fix in place; then continue to your next assigned packet, if any. You do **not** run the CLI.

## Self-check before finishing (you do NOT run the CLI)

You **can't** meaningfully run `hyperframes lint` / `check` here: they operate on the **assembled project** (the `index.html` graph / bundle), and your frame isn't wired in yet — so they report on _other_ files, not yours (a false green). The **orchestrator** runs them after assembly (the correct unit), and **re-dispatches you with the finding** if your frame fails (see **Retry** above). So get it right on write: re-read your file against this checklist before finishing — the codes in parens are `hyperframes lint`'s and what the orchestrator may cite back:

- `missing_template_wrapper` / `missing_composition_id` — the entire file is exactly one bare `<template>…</template>` fragment (no DOCTYPE / full document); root carries `data-composition-id="<frame_id>"`.
- `missing_template_wrapper` / `missing_composition_id` — the entire file is exactly one bare `<template>…</template>` fragment (no DOCTYPE / full document); root carries `data-composition-id="<frame_id>"` and a positive `data-duration` matching the packet.
- **Template transport** — every `<style>` and `<script>` block, including the GSAP load, lives inside `<template>`.
- `subcomposition_root_styled_by_class` — **style the frame root via `#root`, never a class on the `data-composition-id` element**: at render a class on the root gets scoped to a descendant selector that can't match it, so the **whole scene renders unstyled** (Studio preview still looks right — trust this rule, not the preview). Descendants use plain selectors.
- **Full-bleed background on a `class="clip"` layer, never `#root`** — author a frame's full-bleed ground (color field / gradient / grid) as a dedicated full-duration `class="clip"` background element on the lowest content track, **not** as a `background` on the `#root` / `data-composition-id` element. At assembly the frame root is clip-gated to its scene window, so a background painted on the root is not a dependable full-frame ground — dark content can end up over the host `body` (black) and render invisible. The video's base ground is painted separately by the assembler from `frame.md`'s `canvas` color onto the index `#root`; your full-bleed clip rides on top of it.
Expand Down
Loading