From 0f190622b99d9b96ef9ba36b6c1f056de71ed022 Mon Sep 17 00:00:00 2001 From: Scott Milner Date: Sun, 18 Oct 2026 18:06:47 -0700 Subject: [PATCH] ci(skills): lint skill-root paths and skill names in skill docs The cross-reference rule in lint-skills.ts checks only `./` and `../` paths, so the bare paths skills actually use went stale with nothing failing: `references/x.md`, `/scripts/x.mjs`, `hyperframes-core/references/x.md`, `skills//...`. That is the broken-path half of #5025. Add lintSkillPaths, run on every skill markdown file (prose and fenced blocks) in the existing skills CI job: - `/rest` must exist under the referencing skill - `/rest` must exist under skill `name` (`` -> hyperframes-audio) - `/rest` must exist under that skill - `skills//...` is flagged in the distributed set, since it resolves only from the repo root, not from an installed skill; in .claude/skills and .agents/skills `` must be a skill - a bare `references/`-style path must resolve under the referencing skill, a directory between it and the file, or a skill the same paragraph names as `/name` or `name` in backticks; there is no repo-root fallback, since an installed skill cannot see repo files - a backticked `/hyphenated-name` must be a skill in this repo Skill names resolve in the referencing skill's own tree first, so the .claude and .agents mirrors are each checked against their own skills. Project paths, placeholders, URLs and extensionless ids are skipped. A doc can exempt deliberate tokens and `/name` references with one or more `` markers. Run against 2b82e39, the commit #5025 audited, the rule reports every broken-path item in the issue (#11, #12, #19, #21). Fix what it finds on main: - music-to-video frame-worker: determinism-rules.md was missing references/ - media-use tts docs: skills/media-use/... commands -> /... - extract-audio-data.py commands in hyperframes-animation and media-use -> /..., matching the existing convention - prose skills//... mentions -> /... - sine-wave-loop: point the motion doctrine at /faceless-explainer - motion-graphics charts: the logo lives at assets/logo.png in the repo - motion-graphics news: drop the never-committed Layout B reference - embedded-captions direction-catalog: examples/ -> references/example-renders/ - changelog-video: heygen-tts.mjs moved from the retired hyperframes-media skill to media-use/audio/scripts/ - allow= markers for Three.js examples/ paths, the api-map legend, frame-worker-core's spliced paths, the registry contributing guide's repo scripts, and general-video's conditional /motion-doctrine Four references are left failing on purpose for help from reviewers to resolve: guide.md:8 (search-strategy.md, never merged), guide.md:21 (resolve.mjs, a second copy of #5025 item #19), news/module.md:40 (_ref-centered-emphasis.html, never committed), and _anatomy.md:3 and :14 (templates/, which never existed). skills-manifest.json is regenerated for the changed skills. Refs #5025 Co-Authored-By: Claude Opus 5.5 --- .agents/skills/changelog-video/SKILL.md | 2 +- .claude/skills/changelog-video/SKILL.md | 2 +- scripts/lint-skills.test.mjs | 143 ++++++++- scripts/lint-skills.ts | 273 +++++++++++++++++- skills-manifest.json | 20 +- .../references/direction-catalog.md | 2 +- skills/general-video/SKILL.md | 2 + skills/hyperframes-animation/adapters/gsap.md | 2 +- .../adapters/html-in-canvas-patterns.md | 2 + .../rules/gsap-effects.md | 6 +- .../rules/sine-wave-loop.md | 2 +- skills/hyperframes-animation/techniques.md | 6 +- .../references/beat-direction.md | 8 +- .../references/contributing.md | 2 + .../references/frame-worker-core.md | 2 + .../audio/references/captions/motion.md | 6 +- .../audio/references/tts-to-captions.md | 2 +- skills/media-use/audio/references/tts.md | 12 +- .../categories/charts/module.md | 2 +- .../motion-graphics/categories/news/module.md | 2 - .../music-to-video/sub-agents/frame-worker.md | 2 +- .../references/api-map.md | 2 + 22 files changed, 458 insertions(+), 44 deletions(-) diff --git a/.agents/skills/changelog-video/SKILL.md b/.agents/skills/changelog-video/SKILL.md index 6eb6bb73fbf..83afb06342f 100644 --- a/.agents/skills/changelog-video/SKILL.md +++ b/.agents/skills/changelog-video/SKILL.md @@ -82,7 +82,7 @@ Save as `script-tokens.json` in the project. # so it uses the tracked hyperframes-media TTS helper directly (no `npx hyperframes # skills` install step). If you've copied the skill into another repo, swap in # your own path to the media-use / hyperframes-media heygen-tts.mjs. -node skills/hyperframes-media/scripts/heygen-tts.mjs ./vo-spoken.txt \ +node skills/media-use/audio/scripts/heygen-tts.mjs ./vo-spoken.txt \ -o voiceover.mp3 --words vo-words.json \ --voice 330290724a1b470fb63153f34d4c0183 # Annie — lifelike (do not substitute) ``` diff --git a/.claude/skills/changelog-video/SKILL.md b/.claude/skills/changelog-video/SKILL.md index 6eb6bb73fbf..83afb06342f 100644 --- a/.claude/skills/changelog-video/SKILL.md +++ b/.claude/skills/changelog-video/SKILL.md @@ -82,7 +82,7 @@ Save as `script-tokens.json` in the project. # so it uses the tracked hyperframes-media TTS helper directly (no `npx hyperframes # skills` install step). If you've copied the skill into another repo, swap in # your own path to the media-use / hyperframes-media heygen-tts.mjs. -node skills/hyperframes-media/scripts/heygen-tts.mjs ./vo-spoken.txt \ +node skills/media-use/audio/scripts/heygen-tts.mjs ./vo-spoken.txt \ -o voiceover.mp3 --words vo-words.json \ --voice 330290724a1b470fb63153f34d4c0183 # Annie — lifelike (do not substitute) ``` diff --git a/scripts/lint-skills.test.mjs b/scripts/lint-skills.test.mjs index d01595a0497..2fbca0fbbe9 100644 --- a/scripts/lint-skills.test.mjs +++ b/scripts/lint-skills.test.mjs @@ -1,6 +1,6 @@ // Positive / negative fixture tests for the checkers exported by // scripts/lint-skills.ts: SKILL.md frontmatter shape, registry-snapshot item -// refs, and doc cross-references. Each runs against known inputs and asserts +// refs, doc cross-references, and skill-root paths. Each runs against known inputs and asserts // the violation set matches expectation. // // Kept in .mjs (not .ts) so `node --test` can execute it via the same runner @@ -11,7 +11,14 @@ import assert from "node:assert/strict"; import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { headingSlugs, lintDocRefs, lintFrontmatter, lintRegistryItemRefs } from "./lint-skills.ts"; +import { + buildSkillIndex, + headingSlugs, + lintDocRefs, + lintFrontmatter, + lintRegistryItemRefs, + lintSkillPaths, +} from "./lint-skills.ts"; const wrap = (frontmatter) => `---\n${frontmatter}\n---\n\n# body\n`; @@ -271,7 +278,7 @@ test("doc refs: backticked relative .md path to a missing file is a violation", test("doc refs: bare backticked paths are a KNOWN blind spot, not an accident", () => { // A bare `references/.md` without a leading ./ or ../ is skill-root shorthand, // another skill's file, or a runtime artifact more often than a file-relative - // path. Pinned so the tradeoff is visible in code. See the cross-reference note in lint-skills.ts. + // path. Pinned so the tradeoff is visible in code. lintSkillPaths owns bare paths (tests below). const file = refFixture(); assert.deepEqual(lintDocRefs(file, "Read `references/does-not-exist.md`.\n"), []); }); @@ -337,3 +344,133 @@ test("heading slugs: GitHub-style lowercasing, punctuation strip, dedupe", () => ); assert.deepEqual([...slugs], ["hello-world", "code--stuff", "hello-world-1"]); }); + +// Skill-root paths: a two-skill tree. alpha/ ships references/a.md, scripts/run.mjs and a nested +// audio/scripts/tool.mjs; beta/ ships references/b.md. `dist` plays the distributed skills/ dir. + +function skillsFixture() { + const dist = mkdtempSync(join(tmpdir(), "lint-skills-paths-")); + for (const dir of [ + "alpha/references", + "alpha/scripts", + "alpha/audio/references", + "alpha/audio/scripts", + "beta/references", + ]) { + mkdirSync(join(dist, dir), { recursive: true }); + } + for (const name of ["alpha", "beta"]) { + writeFileSync(join(dist, name, "SKILL.md"), `---\nname: ${name}\ndescription: d\n---\n`); + } + writeFileSync(join(dist, "alpha", "references", "a.md"), "# A\n"); + writeFileSync(join(dist, "alpha", "scripts", "run.mjs"), "\n"); + writeFileSync(join(dist, "alpha", "audio", "scripts", "tool.mjs"), "\n"); + writeFileSync(join(dist, "beta", "references", "b.md"), "# B\n"); + const index = buildSkillIndex( + [join(dist, "alpha", "SKILL.md"), join(dist, "beta", "SKILL.md")], + dist, + ); + return { dist, index, skill: join(dist, "alpha", "SKILL.md") }; +} + +test("skill paths: references that resolve pass", () => { + const { dist, index, skill } = skillsFixture(); + const doc = [ + "Read `references/a.md`, run `node /scripts/run.mjs`, format per `beta/references/b.md`.", + "Extract with `python3 /references/b.md`. Skills: `/alpha`, `/beta`, `/animate-text`.", + "", + // A skill mentioned on the previous line of the same paragraph counts (wrapped prose). + "wiring in `/beta`", + "`references/b.md`.", + "", + ].join("\n"); + assert.deepEqual(lintSkillPaths(skill, doc, index), []); + // A nested module resolves from the referencing file upward. + const nested = join(dist, "alpha", "audio", "references", "tts.md"); + assert.deepEqual(lintSkillPaths(nested, "`scripts/tool.mjs` and `scripts/run.mjs`\n", index), []); +}); + +test("skill paths: each broken reference is one violation naming its token", () => { + const { index, skill } = skillsFixture(); + const cases = [ + ["See `references/sub-compositions.md`.", "references/sub-compositions.md"], + ["Per `beta/b.md`.", "beta/b.md"], + ["Per `/beta` (`references/gone.md`).", "references/gone.md"], + ["```bash\nnode /phases/resolve.mjs\n```", "/phases/resolve.mjs"], + ["python3 /scripts/gone.py", "/scripts/gone.py"], + ["node /x.mjs", '"gamma"'], + // A blank line ends the paragraph, so `/beta` no longer counts as a mention. + ["wiring in `/beta`\n\n`references/b.md`.", "references/b.md"], + // A bare word equal to a skill name is prose, not a mention. + ["Run the beta CLI, then read `references/b.md`.", "references/b.md"], + // Exists at the real repo root, but not inside an installed skill. + ["Run `scripts/lint-skills.ts`.", "scripts/lint-skills.ts"], + ]; + for (const [doc, token] of cases) { + const violations = lintSkillPaths(skill, `${doc}\n`, index); + assert.equal(violations.length, 1, doc); + assert.ok(violations[0].message.includes(token), doc); + } + assert.equal(lintSkillPaths(skill, "# Doc\n\n`references/x.md`\n", index)[0].line, 3); +}); + +test("skill paths: skills// is non-portable in the distributed set, checked elsewhere", () => { + const { dist, index, skill } = skillsFixture(); + const doc = "python3 skills/beta/references/b.md\n"; + assert.ok(lintSkillPaths(skill, doc, index)[0].message.includes("installed skill")); + // A repo-native skill runs inside the repo, where the path works if the skill exists. + const native = buildSkillIndex([skill, join(dist, "beta", "SKILL.md")], join(dist, "elsewhere")); + assert.deepEqual(lintSkillPaths(skill, doc, native), []); + const retired = lintSkillPaths(skill, "node skills/retired-skill/x.mjs\n", native); + assert.ok(retired[0].message.includes('"retired-skill"')); +}); + +test("skill paths: mirrored trees are each linted against their own skills", () => { + // .claude/skills and .agents/skills hold the same names; neither may shadow the other. + const root = mkdtempSync(join(tmpdir(), "lint-skills-mirror-")); + const files = ["claude", "agents"].flatMap((tree) => + ["one", "two"].map((name) => { + mkdirSync(join(root, tree, name, "references"), { recursive: true }); + writeFileSync( + join(root, tree, name, "SKILL.md"), + `---\nname: ${name}\ndescription: d\n---\n`, + ); + return join(root, tree, name, "SKILL.md"); + }), + ); + writeFileSync(join(root, "claude", "two", "references", "only-here.md"), "# x\n"); + const index = buildSkillIndex(files, join(root, "distributed")); + const doc = "See `two/references/only-here.md` and `references/nope.md`.\n"; + assert.equal(lintSkillPaths(files[0], doc, index).length, 1); + assert.equal(lintSkillPaths(files[2], doc, index).length, 2); +}); + +test("skill paths: project paths, placeholders, URLs and extensionless ids are ignored", () => { + const { index, skill } = skillsFixture(); + const doc = [ + "Write `compositions/scene.html`, `assets/logo.png`, `.hyperframes/x.json`.", + "Templates: `references/.md`, `scripts/{name}.mjs`, `references/*.md`, `$SKILL_DIR/x/$Y.mjs`.", + "Docs at https://example.com/references/x.md and `references/some-id`.", + "", + ].join("\n"); + assert.deepEqual(lintSkillPaths(skill, doc, index), []); +}); + +test("skill paths: an unknown hyphenated /skill is a violation; hyphenless /words are not", () => { + const { index, skill } = skillsFixture(); + const violations = lintSkillPaths(skill, "Load `/gamma-skill`; divide by `/fps`.\n", index); + assert.equal(violations.length, 1); + assert.ok(violations[0].message.includes("/gamma-skill")); +}); + +test("skill paths: every allow= marker exempts its paths and /name references", () => { + const { index, skill } = skillsFixture(); + const doc = [ + "", + "", + "", + "See `references/X.md`, `references/Y.md` and `/upstream-skill`.", + "", + ].join("\n"); + assert.deepEqual(lintSkillPaths(skill, doc, index), []); +}); diff --git a/scripts/lint-skills.ts b/scripts/lint-skills.ts index 079ae4efd6c..5013d7ebcc5 100644 --- a/scripts/lint-skills.ts +++ b/scripts/lint-skills.ts @@ -10,7 +10,7 @@ */ import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs"; -import { dirname, join, relative, resolve } from "node:path"; +import { basename, dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { parse as parseYaml, YAMLParseError } from "yaml"; import type { RegistryManifest } from "../packages/core/src/index.js"; @@ -329,8 +329,8 @@ export function lintRegistryItemRefs(content: string, known: Set): LineV } // Cross-references between skill docs: relative link targets, and backticked `./` or `../` .md/.html paths, must -// resolve from the referencing file; `#anchor`s into .md files must match a GitHub heading slug. Bare backticked -// paths like `references/.md` are skill-root shorthand and deliberately unchecked. +// resolve from the referencing file; `#anchor`s into .md files must match a GitHub heading slug. Bare paths like +// `references/.md` are skill-root shorthand, checked by lintSkillPaths below. interface DocRef { line: number; @@ -509,6 +509,262 @@ export function lintDocRefs(filePath: string, content: string): LineViolation[] .flatMap((ref) => checkDocRef(ref, filePath, content) ?? []); } +// --------------------------------------------------------------------------- +// Skill-root paths and skill names +// --------------------------------------------------------------------------- +// +// The cross-reference rule above leaves bare paths alone, and bare paths are how +// skills actually point at their own files: `references/bgm.md`, +// `/scripts/carve.mjs`, `hyperframes-core/references/sub-compositions.md`. When one of +// those goes stale the agent gets a read error mid-task (heygen-com/hyperframes#5025 +// lists a batch). This rule checks every path-shaped token, in prose and in fenced +// blocks alike, whose first segment says it lives inside a skill: +// +// /rest rest must exist under the referencing skill's root +// /rest rest must exist under skill `name` (`` +// is hyperframes-audio) +// /rest rest must exist under that skill's root +// skills//rest must be a skill; in the distributed skills/ set it is +// flagged anyway, because it never resolves once a skill is +// installed (repo-native skills run in the repo) +// /rest a directory some skill ships (references/, scripts/, ...): +// rest must exist under the referencing skill, under any +// directory between it and the referencing file, or under a +// skill the same paragraph names as `/name` or `name` +// +// and every backticked `/name` that names a skill must name one that exists. A skill +// name resolves in the referencing skill's own tree first (`.claude/skills` and +// `.agents/skills` mirror each other), then in the distributed set. +// +// Anything else (project paths like `compositions/`, `.hyperframes/`, package +// paths, URLs, `./` and `../` paths, which the rule above owns) is not this rule's +// business. Paths containing placeholders (``, `{slug}`, `*`, `$VAR`) are skipped, +// as are extensionless paths without a trailing slash, which are too often ids or +// npm-style names to be read as files. +// +// A doc that names paths or skills on purpose that do not resolve from an installed +// skill (a shared file spliced into each workflow's own copy, a contributor guide +// naming repo scripts, an upstream skill) lists them in one or more marker lines: +// +// + +export interface SkillIndex { + /** Skills tree (the directory holding skill roots) → skill name → root directory. */ + trees: Map>; + /** Names of the top-level directories any skill ships. */ + contentDirs: Set; + /** The set users install; repo-native skills elsewhere may name repo paths. */ + distributedDir: string; +} + +// Skills that live outside this repo but are legitimately referenced by `/name`. +const EXTERNAL_SKILLS = new Set(["animate-text"]); +// Skill directories whose names are also the conventional project directory, so a +// bare `assets/logo.png` is far more often the user's project than the skill's. +const PROJECT_DIR_NAMES = new Set(["assets"]); +const SKILL_DIR_SEGMENT = /^(?:|\$\{?SKILL_DIR\}?)$/; +const NAMED_SKILL_DIR_SEGMENT = /^<([A-Z][A-Z0-9_]*)_SKILL_DIR>$/; +const PATH_TOKEN = /[\w.@<>${}*-]*\/[\w.@<>${}*/-]*/g; +const PATH_PLACEHOLDER = /[<>{}*$]/; +const SLASH_SKILL_REF = /`\/([a-z][a-z0-9-]*)`/g; +// `/name` anywhere, or a whole backticked `name`; a bare word that happens to equal a +// skill name ("the hyperframes CLI") is not a mention. +const SKILL_MENTION = /`\/?([a-z][a-z0-9-]*)`|(?:^|[^\w/-])\/([a-z][a-z0-9-]*)/g; +const SKILL_PATHS_MARKER = //g; + +export function buildSkillIndex( + skillFiles: string[], + distributedDir = join(REPO_ROOT, "skills"), +): SkillIndex { + const trees = new Map>(); + const contentDirs = new Set(); + for (const file of skillFiles) { + const root = dirname(file); + const tree = trees.get(dirname(root)) ?? new Map(); + tree.set(basename(root), root); + trees.set(dirname(root), tree); + for (const entry of readdirSync(root, { withFileTypes: true })) { + if (entry.isDirectory() && !PROJECT_DIR_NAMES.has(entry.name)) contentDirs.add(entry.name); + } + } + return { trees, contentDirs, distributedDir }; +} + +function skillRootOf(filePath: string, index: SkillIndex): string | null { + let best: string | null = null; + for (const tree of index.trees.values()) { + for (const root of tree.values()) { + const inside = filePath.startsWith(root + "/"); + if (inside && (best === null || root.length > best.length)) best = root; + } + } + return best; +} + +/** The root of skill `name` as seen from `ownRoot`: its own tree first, then the distributed set. */ +function rootNamed(name: string, ownRoot: string, index: SkillIndex): string | undefined { + return ( + index.trees.get(dirname(ownRoot))?.get(name) ?? index.trees.get(index.distributedDir)?.get(name) + ); +} + +function skillsMentionedIn(paragraph: string, ownRoot: string, index: SkillIndex): string[] { + const roots = [...paragraph.matchAll(SKILL_MENTION)] + .map((m) => rootNamed(m[1] ?? m[2] ?? "", ownRoot, index)) + .filter((root): root is string => root !== undefined); + return [...new Set(roots)]; +} + +/** For each line, the skill roots its paragraph (run of non-blank lines) mentions. */ +function paragraphMentionsByLine(lines: string[], ownRoot: string, index: SkillIndex): string[][] { + const out: string[][] = []; + let start = 0; + for (let i = 0; i <= lines.length; i++) { + if (i < lines.length && (lines[i] ?? "").trim() !== "") continue; + const mentioned = skillsMentionedIn(lines.slice(start, i).join("\n"), ownRoot, index); + for (let j = start; j <= i && j < lines.length; j++) out[j] = mentioned; + start = i + 1; + } + return out; +} + +/** The referencing file's directory and each of its ancestors up to the skill root. */ +function dirsUpToRoot(filePath: string, ownRoot: string): string[] { + const dirs: string[] = []; + for (let dir = dirname(filePath); dir.startsWith(ownRoot); dir = dirname(dir)) dirs.push(dir); + return dirs; +} + +function cleanPathToken(raw: string): string { + return raw.replace(/[.]+$/, ""); +} + +function isFileOrDirShaped(rest: string[]): boolean { + const last = rest[rest.length - 1] ?? ""; + return last === "" || /\.[A-Za-z0-9]+$/.test(last); +} + +type PathCandidate = + | { kind: "resolve"; bases: string[]; rest: string } + | { kind: "nonPortable" } + | { kind: "unknownSkill"; name: string }; + +interface PathContext { + filePath: string; + ownRoot: string; + mentioned: string[]; + index: SkillIndex; +} + +function skillCandidate(name: string, rest: string, ctx: PathContext): PathCandidate { + const root = rootNamed(name, ctx.ownRoot, ctx.index); + return root === undefined + ? { kind: "unknownSkill", name } + : { kind: "resolve", bases: [root], rest }; +} + +function classifyPath(token: string, ctx: PathContext): PathCandidate | null { + const segments = token.split("/"); + const [first = "", ...rest] = segments; + if (rest.length === 0 || first === "" || first.startsWith(".")) return null; + if (SKILL_DIR_SEGMENT.test(first)) + return { kind: "resolve", bases: [ctx.ownRoot], rest: rest.join("/") }; + const named = NAMED_SKILL_DIR_SEGMENT.exec(first); + if (named) + return skillCandidate((named[1] ?? "").toLowerCase().replace(/_/g, "-"), rest.join("/"), ctx); + if (first === "skills") { + const [name = "", ...inner] = rest; + if (inner.length === 0 || PATH_PLACEHOLDER.test(name)) return null; + if (ctx.ownRoot.startsWith(ctx.index.distributedDir + "/")) return { kind: "nonPortable" }; + return skillCandidate(name, inner.join("/"), ctx); + } + if (rootNamed(first, ctx.ownRoot, ctx.index) !== undefined) + return skillCandidate(first, rest.join("/"), ctx); + if (!ctx.index.contentDirs.has(first)) return null; + const bases = [...dirsUpToRoot(ctx.filePath, ctx.ownRoot), ...ctx.mentioned]; + return { kind: "resolve", bases: [...new Set(bases)], rest: segments.join("/") }; +} + +function pathViolation( + token: string, + candidate: PathCandidate, + lineNumber: number, + text: string, +): LineViolation | null { + if (candidate.kind === "nonPortable") { + return violation( + lineNumber, + `"${token}" only resolves from the repo root, not from an installed skill. Use /... for this skill's own files, or name the other skill (\`/name\`) and its skill-relative path.`, + text, + ); + } + if (candidate.kind === "unknownSkill") { + return violation( + lineNumber, + `"${token}" names "${candidate.name}", which is not a skill in this repo.`, + text, + ); + } + const rest = candidate.rest.split("/"); + if (rest.some((s) => PATH_PLACEHOLDER.test(s)) || !isFileOrDirShaped(rest)) return null; + const found = candidate.bases.some((base) => + statSync(join(base, candidate.rest), { throwIfNoEntry: false }), + ); + if (found) return null; + const looked = candidate.bases.map((b) => relative(REPO_ROOT, b) || ".").join(", "); + return violation( + lineNumber, + `Skill path "${token}" does not resolve (looked under: ${looked}).`, + text, + ); +} + +function skillNameViolations( + line: string, + lineNumber: number, + ownRoot: string, + index: SkillIndex, + allowed: Set, +): LineViolation[] { + return ( + [...new Set([...line.matchAll(SLASH_SKILL_REF)].map((m) => m[1] ?? ""))] + // A hyphenless `/word` is as often arithmetic (`/fps`) or a caption as a skill. + .filter((name) => name.includes("-") && rootNamed(name, ownRoot, index) === undefined) + .filter((name) => !EXTERNAL_SKILLS.has(name) && !allowed.has(`/${name}`)) + .map((name) => violation(lineNumber, `"/${name}" is not a skill in this repo.`, line.trim())) + ); +} + +function allowedTokens(content: string): Set { + const lists = [...content.matchAll(SKILL_PATHS_MARKER)].map((m) => m[1] ?? ""); + return new Set(lists.flatMap((list) => list.split(",")).filter(Boolean)); +} + +/** Violations for skill-root paths and `/skill` names in `content` (located at `filePath`) that do not resolve. */ +export function lintSkillPaths( + filePath: string, + content: string, + index: SkillIndex, +): LineViolation[] { + const ownRoot = skillRootOf(filePath, index); + if (ownRoot === null) return []; + const allowed = allowedTokens(content); + const lines = content.split("\n"); + const mentions = paragraphMentionsByLine(lines, ownRoot, index); + return lines.flatMap((line, i) => { + const text = line.trim(); + const ctx: PathContext = { filePath, ownRoot, mentioned: mentions[i] ?? [], index }; + const tokens = new Set([...line.matchAll(PATH_TOKEN)].map((m) => cleanPathToken(m[0]))); + const paths = [...tokens] + .filter((token) => !allowed.has(token)) + .flatMap((token) => { + const candidate = classifyPath(token, ctx); + return candidate ? (pathViolation(token, candidate, i + 1, text) ?? []) : []; + }); + return [...paths, ...skillNameViolations(line, i + 1, ownRoot, index, allowed)]; + }); +} + function collectMarkdownFiles(dir: string): string[] { return readdirSync(dir, { withFileTypes: true, recursive: true }) .filter((entry) => entry.isFile() && entry.name.endsWith(".md")) @@ -531,12 +787,18 @@ function lintFile(filePath: string): Violation[] { type Report = (file: string, violations: LineViolation[]) => void; /** Doc cross-references and registry snapshots for every markdown file; returns the snapshot count. */ -function lintMarkdownFiles(paths: string[], knownItems: Set, report: Report): number { +function lintMarkdownFiles( + paths: string[], + knownItems: Set, + skills: SkillIndex, + report: Report, +): number { let snapshotsChecked = 0; for (const path of paths) { const content = readFileSync(path, "utf-8"); const file = relative(process.cwd(), path); report(file, lintDocRefs(path, content)); + report(file, lintSkillPaths(path, content, skills)); const found = lintRegistryItemRefs(content, knownItems); if (found === null) continue; snapshotsChecked++; @@ -570,7 +832,8 @@ function main(): void { const knownItems = registryItemNames(); const markdownFiles = skillsDirs.flatMap(collectMarkdownFiles); - const snapshotsChecked = lintMarkdownFiles(markdownFiles, knownItems, report); + const skills = buildSkillIndex(files); + const snapshotsChecked = lintMarkdownFiles(markdownFiles, knownItems, skills, report); if (totalViolations > 0) { console.error(`\n${totalViolations} skill lint error(s) found.`); diff --git a/skills-manifest.json b/skills-manifest.json index e774301b3f6..f7ac5477309 100644 --- a/skills-manifest.json +++ b/skills-manifest.json @@ -2,7 +2,7 @@ "source": "heygen-com/hyperframes", "skills": { "embedded-captions": { - "hash": "3310b3412bff68dd", + "hash": "77bb7098b239581e", "files": 146 }, "faceless-explainer": { @@ -14,15 +14,15 @@ "files": 2 }, "general-video": { - "hash": "f288a7bc7eb1a1cf", + "hash": "000eeae7e8a72b4a", "files": 6 }, "hyperframes": { - "hash": "6ef04c743e34f0b2", + "hash": "d71e47204cc16c55", "files": 31 }, "hyperframes-animation": { - "hash": "eeadb8a12feaaba8", + "hash": "78ffe13fad92d21e", "files": 122 }, "hyperframes-audio": { @@ -38,7 +38,7 @@ "files": 11 }, "hyperframes-creative": { - "hash": "043c4bc84b16a96f", + "hash": "92f0364e2f0c2864", "files": 79 }, "hyperframes-keyframes": { @@ -46,7 +46,7 @@ "files": 3 }, "hyperframes-registry": { - "hash": "d7c1885fb4cce2cf", + "hash": "5d15c6c461cb7fd7", "files": 12 }, "hyperframes-studio": { @@ -54,15 +54,15 @@ "files": 1 }, "media-use": { - "hash": "5a8f4a204aa1e159", + "hash": "e02fd0d0abf52032", "files": 113 }, "motion-graphics": { - "hash": "67bd36b53df3b7e0", + "hash": "54a0dadc43cfad79", "files": 23 }, "music-to-video": { - "hash": "ac33d950e9a2e1e9", + "hash": "069957d8ccfef4af", "files": 169 }, "pr-to-video": { @@ -74,7 +74,7 @@ "files": 37 }, "remotion-to-hyperframes": { - "hash": "d02be65b7f3f4a63", + "hash": "68ba9bdfad929af8", "files": 77 }, "slideshow": { diff --git a/skills/embedded-captions/references/direction-catalog.md b/skills/embedded-captions/references/direction-catalog.md index a197441401c..3b23feeab9f 100644 --- a/skills/embedded-captions/references/direction-catalog.md +++ b/skills/embedded-captions/references/direction-catalog.md @@ -132,7 +132,7 @@ Priority ship order based on competitive gap: 1. Classify the clip: `tone × shot × platform` (see matrix above) 2. Look up direction in matrix 3. Read that direction's spec in this file -4. Translate spec into plan.json parameters OR clone the canonical HTML from `examples/` +4. Translate spec into plan.json parameters OR clone the canonical HTML from `references/example-renders/` 5. Tune layout per scene (agent judgment, see `aesthetic-principles.md §1–18`) If no direction fits cleanly → **Standard mode**. Don't fake-fit an ill-matched direction. diff --git a/skills/general-video/SKILL.md b/skills/general-video/SKILL.md index 4ee2d13d01a..20eb6198119 100644 --- a/skills/general-video/SKILL.md +++ b/skills/general-video/SKILL.md @@ -12,6 +12,8 @@ description: > # General video + + Run `npx hyperframes usage --json` at the start unless the workflow just read it, then check again at milestones such as after drafting and before rendering. Read the available windows and reset times. If usage is unknown, say so without guessing allowance; keep scope and workflow choices with the user. Before relying on this workflow, run: diff --git a/skills/hyperframes-animation/adapters/gsap.md b/skills/hyperframes-animation/adapters/gsap.md index 1593c3d7d42..94b3dd1f95c 100644 --- a/skills/hyperframes-animation/adapters/gsap.md +++ b/skills/hyperframes-animation/adapters/gsap.md @@ -91,7 +91,7 @@ HyperFrames is stricter than vanilla GSAP. Animate only: - `./gsap-timeline-and-labels.md` — timeline creation, position parameter (`+=`, `<`, `>`), labels, nesting, sub-comp `fromTo` preference, playback control. - `./gsap-easing-and-stagger.md` — easing families, stagger objects, function-based values, `gsap.matchMedia()`, `gsap.defaults()`. - `./gsap-transforms-and-perf.md` — transform aliases, autoAlpha, `quickTo`, `will-change`, performance rules. -- `../rules/gsap-effects.md` — drop-in recipes: typewriter (with cursor / backspace / word rotation) + audio visualizer (uses `skills/hyperframes-creative/scripts/extract-audio-data.py`). +- `../rules/gsap-effects.md` — drop-in recipes: typewriter (with cursor / backspace / word rotation) + audio visualizer (uses `hyperframes-creative/scripts/extract-audio-data.py`). ## Best Practices diff --git a/skills/hyperframes-animation/adapters/html-in-canvas-patterns.md b/skills/hyperframes-animation/adapters/html-in-canvas-patterns.md index dce46619020..723c46e4a4f 100644 --- a/skills/hyperframes-animation/adapters/html-in-canvas-patterns.md +++ b/skills/hyperframes-animation/adapters/html-in-canvas-patterns.md @@ -1,5 +1,7 @@ # HTML-in-Canvas Patterns + + ## Contents - [Core Boilerplate (same in every HTML-in-Canvas composition)](#core-boilerplate-same-in-every-html-in-canvas-composition) diff --git a/skills/hyperframes-animation/rules/gsap-effects.md b/skills/hyperframes-animation/rules/gsap-effects.md index 0dcf6954f25..691d1e56fc6 100644 --- a/skills/hyperframes-animation/rules/gsap-effects.md +++ b/skills/hyperframes-animation/rules/gsap-effects.md @@ -129,10 +129,12 @@ Pre-extract audio data, drive Canvas / DOM rendering from the timeline. **Do not Bundled extractor (requires `ffmpeg` + Python `numpy`): ```bash -python skills/hyperframes-creative/scripts/extract-audio-data.py audio.mp3 -o audio-data.json -python skills/hyperframes-creative/scripts/extract-audio-data.py video.mp4 --fps 30 --bands 16 -o audio-data.json +python /scripts/extract-audio-data.py audio.mp3 -o audio-data.json +python /scripts/extract-audio-data.py video.mp4 --fps 30 --bands 16 -o audio-data.json ``` +`` is the installed `hyperframes-creative` skill directory. + Output: `{ "fps": 30, "totalFrames": 5415, "frames": [{ "time": 0.0, "rms": 0.42, "bands": [0.8, 0.6, 0.3] }] }` — `rms` (0-1) is overall loudness; `bands[]` (0-1) are frequency magnitudes, index 0 = bass, each band normalized independently. ### Loading (Synchronously) diff --git a/skills/hyperframes-animation/rules/sine-wave-loop.md b/skills/hyperframes-animation/rules/sine-wave-loop.md index 6619603b75f..822e3138114 100644 --- a/skills/hyperframes-animation/rules/sine-wave-loop.md +++ b/skills/hyperframes-animation/rules/sine-wave-loop.md @@ -7,7 +7,7 @@ metadata: # Sine Wave Loop (subtle jitter / bounded ambient) -> **Reach for this last.** Per the motion doctrine (`references/motion-language.md`): circular breathing — scaling text/cards up and down to look "alive" — is cheap, the agent's reflexive cheat, and reads weak. "I'd rather have NO motion than BAD motion." First fill the back of a shot with **sequential reveal timed to the VO**; if a frame has genuinely settled and still needs life, the **sanctioned move is subtle jitter** — this rule at the LOW end of its amplitude range. A full breathing loop is the rare last resort on a single held hero, never stamped on every element. +> **Reach for this last.** Per the motion doctrine (`/faceless-explainer` `references/motion-language.md` § 3): circular breathing — scaling text/cards up and down to look "alive" — is cheap, the agent's reflexive cheat, and reads weak. "I'd rather have NO motion than BAD motion." First fill the back of a shot with **sequential reveal timed to the VO**; if a frame has genuinely settled and still needs life, the **sanctioned move is subtle jitter** — this rule at the LOW end of its amplitude range. A full breathing loop is the rare last resort on a single held hero, never stamped on every element. Keeps a settled element from feeling dead using `Math.sin` on the timeline clock. Two forms: diff --git a/skills/hyperframes-animation/techniques.md b/skills/hyperframes-animation/techniques.md index ba6d6a11ac1..352bd94d665 100644 --- a/skills/hyperframes-animation/techniques.md +++ b/skills/hyperframes-animation/techniques.md @@ -383,12 +383,14 @@ for (var f = 0; f < AUDIO_DATA.totalFrames; f++) { Per-frame sampling is required — a single tween will not react. Use the extract script: ```bash -python3 skills/hyperframes-creative/scripts/extract-audio-data.py narration.wav --fps 30 --bands 16 -o audio-data.json +python3 /scripts/extract-audio-data.py narration.wav --fps 30 --bands 16 -o audio-data.json ``` +`` is the installed `hyperframes-creative` skill directory. + Keep text/logo intensity subtle (≤5% scale, ≤30% glow) — audio-reactive motion on tiny elements reads as jitter. Bigger backgrounds can push to 10–30%. -**Never do:** equalizer bars, spectrum analyzers, waveform displays, strobing, rainbow color cycling. The audio provides _timing and intensity_; the visual vocabulary still comes from the brand. See `skills/hyperframes-creative/references/audio-reactive.md` for the full API and anti-patterns. +**Never do:** equalizer bars, spectrum analyzers, waveform displays, strobing, rainbow color cycling. The audio provides _timing and intensity_; the visual vocabulary still comes from the brand. See `hyperframes-creative/references/audio-reactive.md` for the full API and anti-patterns. --- diff --git a/skills/hyperframes-creative/references/beat-direction.md b/skills/hyperframes-creative/references/beat-direction.md index b1d436a5b9b..f136902ee6c 100644 --- a/skills/hyperframes-creative/references/beat-direction.md +++ b/skills/hyperframes-creative/references/beat-direction.md @@ -53,7 +53,7 @@ The vocabulary of motion verbs (organized by physical character, not by energy l Every element gets a verb. If you can't name the verb, the element is not yet designed. The verb should follow from the beat's concept — not from a lookup of what "high energy" or "low energy" beats use. -For text elements specifically, you can name a deterministic, named effect by ID (e.g. `typewriter`, `kinetic-center-build`, `soft-blur-in`) instead of inventing timing from scratch — the 24-effect vocabulary and how to load it live in `skills/hyperframes-animation/adapters/animate-text.md`. +For text elements specifically, you can name a deterministic, named effect by ID (e.g. `typewriter`, `kinetic-center-build`, `soft-blur-in`) instead of inventing timing from scratch — the 24-effect vocabulary and how to load it live in `hyperframes-animation/adapters/animate-text.md`. ### Transition @@ -69,9 +69,9 @@ How this beat hands off to the next. Specify the type and parameters. Rule of thumb: if the beat is the _centerpiece_ of the video, shader-transition into it. If the beat is connective tissue, a CSS crossfade is fine. A brand reel of 5-7 beats usually wants 1-2 shader transitions (the hero reveal + the CTA) — too many flatten their impact. -**Mixing shader and CSS crossfade transitions in one composition is supported.** Omit `shader` on any transition entry to get a smooth opacity crossfade — HyperShader manages all scene visibility regardless. Let HyperShader create the timeline (don't pass a pre-built `timeline:` option) and add all composition tweens to the returned `tl` after `init()`. Config snippet in `skills/hyperframes-animation/transitions/overview.md` → "CSS vs Shader". +**Mixing shader and CSS crossfade transitions in one composition is supported.** Omit `shader` on any transition entry to get a smooth opacity crossfade — HyperShader manages all scene visibility regardless. Let HyperShader create the timeline (don't pass a pre-built `timeline:` option) and add all composition tweens to the returned `tl` after `init()`. Config snippet in `hyperframes-animation/transitions/overview.md` → "CSS vs Shader". -**CSS transitions** — 30+ patterns across 13 categories. Full code in `skills/hyperframes-animation/transitions/` (route via `catalog.md`). Pick based on the energy and feel: +**CSS transitions** — 30+ patterns across 13 categories. Full code in `hyperframes-animation/transitions/` (route via `catalog.md`). Pick based on the energy and feel: | Category | Patterns | Motion character | | ------------------ | ------------------------------------------------------------------------ | -------------------------------------------------------------------------- | @@ -99,7 +99,7 @@ Common quick-picks: Timing presets: snappy (0.2s), smooth (0.4s), gentle (0.6s), dramatic (0.5s), instant (0.15s), luxe (0.7s). -**Shader transitions** — 14 built-in WebGL GPU effects. Install with `npx hyperframes add ` (block name ≠ shader name — see `skills/hyperframes-registry/references/discovery.md`); full API in `packages/shader-transitions/README.md`. +**Shader transitions** — 14 built-in WebGL GPU effects. Install with `npx hyperframes add ` (block name ≠ shader name — see `hyperframes-registry/references/discovery.md`); full API in `packages/shader-transitions/README.md`. | Shader | Visual description | Duration range | | ----------------------- | ---------------------------------------------------------------------------------------------- | -------------- | diff --git a/skills/hyperframes-registry/references/contributing.md b/skills/hyperframes-registry/references/contributing.md index 28eb4957049..078f4dddd0a 100644 --- a/skills/hyperframes-registry/references/contributing.md +++ b/skills/hyperframes-registry/references/contributing.md @@ -1,5 +1,7 @@ # Contributing a Block or Component to the Registry + + Guide the user from idea to merged PR for a new registry block or component. ## Workflow diff --git a/skills/hyperframes/references/frame-worker-core.md b/skills/hyperframes/references/frame-worker-core.md index 862dcfef289..8a4fc30106a 100644 --- a/skills/hyperframes/references/frame-worker-core.md +++ b/skills/hyperframes/references/frame-worker-core.md @@ -1,5 +1,7 @@ # Frame worker — core contract (shared by the narrative video workflows) + + The workflow-agnostic half of every frame worker's role. Each workflow's packet builder (`scripts/frame-packets.mjs`) prepends this file to that workflow's `sub-agents/frame-worker.md` (the **delta**) to form `.hyperframes/frame-packets/_role.md` — a worker reads the two as one role. Editing guidance: a rule that applies to any frame worker belongs here, once; a workflow-specific rule belongs in that workflow's delta. (`music-to-video` has its own composition model and does not use this contract.) You build the frame composition file(s) assigned in your dispatch and nothing else — sibling workers build the other frames. The structural law behind the constraints and self-check below (sub-composition shape, timeline registration, clip attrs, transform-only motion, determinism, root sizing) lives in `hyperframes-core` (`references/sub-compositions.md`, `references/determinism-rules.md`, `references/data-attributes.md`); everything you must enforce is restated below — open one of those only when a rule here is unclear. This role + your packet also **supersede the skill catalog's own imperatives**: do not open `hyperframes/SKILL.md` or `hyperframes-core/SKILL.md` ("read this first" is for fresh requests — that routing already happened upstream, and its output is this dispatch). diff --git a/skills/media-use/audio/references/captions/motion.md b/skills/media-use/audio/references/captions/motion.md index d39be28dce4..d50c79e551f 100644 --- a/skills/media-use/audio/references/captions/motion.md +++ b/skills/media-use/audio/references/captions/motion.md @@ -69,9 +69,11 @@ Keep audio reactivity subtle — 3-6% scale variation and soft glow. Heavy pulsi To generate the audio data file: ```bash -python3 skills/hyperframes-creative/scripts/extract-audio-data.py audio.mp3 --fps 30 --bands 8 -o audio-data.json +python3 /scripts/extract-audio-data.py audio.mp3 --fps 30 --bands 8 -o audio-data.json ``` +`` is the installed `hyperframes-creative` skill directory. + ## Combining Techniques Don't use the same highlight animation on every group — cycle through styles using the group index. Don't combine multiple competing animations on the same word at the same timestamp. Vary techniques across groups to match the content's pace changes. @@ -84,4 +86,4 @@ Caption motion uses standard HyperFrames runtime APIs. Use the canonical sources - **GSAP timeline + tween syntax** — `hyperframes-animation/adapters/gsap.md` (eases, position parameter, performance) - **`window.__hyperframes.fitTextFontSize` / `pretext`** — `hyperframes-core/references/determinism-rules.md` → Layout Contract (overflow prevention, per-frame text measurement) -- **Audio data extraction** — generate via `python3 skills/hyperframes-creative/scripts/extract-audio-data.py audio.mp3 --fps 30 --bands 8 -o audio-data.json`, then load inline as shown in "Audio-Reactive Captions" above +- **Audio data extraction** — generate via `python3 /scripts/extract-audio-data.py audio.mp3 --fps 30 --bands 8 -o audio-data.json`, then load inline as shown in "Audio-Reactive Captions" above diff --git a/skills/media-use/audio/references/tts-to-captions.md b/skills/media-use/audio/references/tts-to-captions.md index 4b99fa0a8cc..a4e27f39a34 100644 --- a/skills/media-use/audio/references/tts-to-captions.md +++ b/skills/media-use/audio/references/tts-to-captions.md @@ -8,7 +8,7 @@ HeyGen returns word timestamps in the same response as the audio. Use the bundled REST helper (the `hyperframes tts` command is Kokoro-only): ```bash -node skills/media-use/audio/scripts/heygen-tts.mjs \ +node /audio/scripts/heygen-tts.mjs \ script.txt --output narration.wav --words narration.words.json ``` diff --git a/skills/media-use/audio/references/tts.md b/skills/media-use/audio/references/tts.md index 231fcc8a5f2..1edfa111dbe 100644 --- a/skills/media-use/audio/references/tts.md +++ b/skills/media-use/audio/references/tts.md @@ -74,11 +74,11 @@ only credential is an expired OAuth token it stops with a hint to run export HEYGEN_API_KEY=... # or put it in a project .env # Synthesize + capture word timestamps in one call (skips a Whisper pass) -node skills/media-use/audio/scripts/heygen-tts.mjs \ +node /audio/scripts/heygen-tts.mjs \ "Welcome to HyperFrames." -o narration.wav --words narration.words.json -node skills/media-use/audio/scripts/heygen-tts.mjs ./script.txt -o narration.wav -node skills/media-use/audio/scripts/heygen-tts.mjs --list # public starfish voices +node /audio/scripts/heygen-tts.mjs ./script.txt -o narration.wav +node /audio/scripts/heygen-tts.mjs --list # public starfish voices ``` - **Voice:** `--voice ` must be a **starfish** voice_id (`--list`, or `GET /v3/voices?engine=starfish`) or one of your cloned voice ids (below). v2-catalog ids are rejected with HTTP 400. Omit `--voice` (English) and it defaults to **Marcia** (`05f19352e8f74b0392a8f411eba40de1`, a fixed default so the choice is deterministic). Non-English with no `--voice` falls back to the first matching catalog voice. @@ -94,9 +94,9 @@ Clone a voice from an mp3 or wav recording, then pass the printed id to message to stderr and exits 1. ```bash -node skills/media-use/audio/scripts/heygen-voice.mjs clone take.mp3 --name "My voice" # → {"voice_id":"..."} -node skills/media-use/audio/scripts/heygen-voice.mjs list --prefix "My" # → [{"voice_id","name","created_at"}] -node skills/media-use/audio/scripts/heygen-voice.mjs delete +node /audio/scripts/heygen-voice.mjs clone take.mp3 --name "My voice" # → {"voice_id":"..."} +node /audio/scripts/heygen-voice.mjs list --prefix "My" # → [{"voice_id","name","created_at"}] +node /audio/scripts/heygen-voice.mjs delete ``` `clone` waits up to 120 s for the clone to finish. `list` reads diff --git a/skills/motion-graphics/categories/charts/module.md b/skills/motion-graphics/categories/charts/module.md index c9b579b58c0..4a9dfb656e8 100644 --- a/skills/motion-graphics/categories/charts/module.md +++ b/skills/motion-graphics/categories/charts/module.md @@ -21,4 +21,4 @@ Reuse `data-chart`: `npx hyperframes add data-chart` → edit the data arrays + ## Dashboard-skeleton variant -For a **product-dashboard** case: lay out a skeleton dashboard — a top bar with a **real test logo** (e.g. the hyperframes logo in `samples/_assets/`) + a title, then a grid of 3–4 **KPI cards** (each a `stat` count-up) + one `data-chart` panel. Reveal order: header/logo in → cards stagger in → the chart animates. Composes the `stat` + `charts` primitives inside a dashboard frame; the logo is a frozen project-local asset. +For a **product-dashboard** case: lay out a skeleton dashboard — a top bar with a **real test logo** (e.g. the hyperframes logo, `assets/logo.png` in the HyperFrames repo) + a title, then a grid of 3–4 **KPI cards** (each a `stat` count-up) + one `data-chart` panel. Reveal order: header/logo in → cards stagger in → the chart animates. Composes the `stat` + `charts` primitives inside a dashboard frame; the logo is a frozen project-local asset. diff --git a/skills/motion-graphics/categories/news/module.md b/skills/motion-graphics/categories/news/module.md index bb47ada1681..172d3ee8709 100644 --- a/skills/motion-graphics/categories/news/module.md +++ b/skills/motion-graphics/categories/news/module.md @@ -49,8 +49,6 @@ The editorial article-card layout (logo top-left + date + big serif headline wit 4. **Person cutout** (`#subject`, the bg-removed PNG, `height≈860`, `right:40; bottom:0`, `drop-shadow`) — **slides up from bottom-right** as the hero beat. 5. **Timeline** (seek-safe, paused): logo `back.out` @0.15 → date @0.4 → **subject slides up** (`set{autoAlpha:0,x:90,y:60}`→`to{autoAlpha:1,x:0,y:0,duration:0.85,ease:"power3.out"}`) @0.45 → headline @0.7 → dek @1.5 → outlet @1.9 → **keyword highlight** (`--hlw 0→100%`, `duration:0.8`) @2.2. duration ~6.5s. -**Reference:** `samples/news/_ref-article-layout.html` (a real article rendered with a real outlet logo + a background-removed person + a real story). - ## Critical Highlight is **grown in place, swept on AFTER the text — never pre-applied**. The text **stays readable — do NOT zoom the keyword to fill the frame** (team feedback 2026-06-09: zoom loses context and reads wrong; the technique never zooms — it presents the text and highlights the word). For Layout B, the person MUST be a `remove-background` cutout (no rectangular photo box). Deterministic; honor `references/builder-contract.md`. diff --git a/skills/music-to-video/sub-agents/frame-worker.md b/skills/music-to-video/sub-agents/frame-worker.md index 2f9b9d8b764..6ae7f1cf10c 100644 --- a/skills/music-to-video/sub-agents/frame-worker.md +++ b/skills/music-to-video/sub-agents/frame-worker.md @@ -44,6 +44,6 @@ If your dispatch carries `lint` / `check` feedback from a prior pass, address ea - Each group shows across its frame-local span and hides outside it; `t=0` renders; group→group cuts are 0ms; ids / uniforms namespaced. - Each group's text / palette match its block's `params` / `copy`, drawn from `frame.md`. - `phrase_flow` frames pace by phrase / energy. -- Seek-safe per `hyperframes-core/determinism-rules.md` (derive variation from indices; swap text / numbers with `tl.set`). +- Seek-safe per `hyperframes-core/references/determinism-rules.md` (derive variation from indices; swap text / numbers with `tl.set`). - Asset clips: muted `