feat(skills): product-launch-video skill + consolidate motion knowledge into hyperframes-animation (#1745)

* feat(skills): product-launch-video + consolidate motion knowledge into hyperframes-animation

- Add the product-launch-video skill: shot-sequence architecture where each
  visual frame is a time-coded shot sequence picked from a blueprint menu and
  paced to the voiceover (anti-PowerPoint). Includes the frame-worker sub-agent,
  story/visual/motion-design references, and audio/captions/transitions/
  stage-assets/assemble-index scripts.
- Consolidate motion knowledge in hyperframes-animation as the single source of
  truth: promote the updated atomic rules (31 -> 36) and rename product-launch-
  video's archetypes into hyperframes-animation blueprints (13 -> 15, replacing
  the old set). product-launch-video, faceless-explainer, and pr-to-video now
  reference them via ../hyperframes-animation/{rules-index,blueprints-index}.md
  and the rules/blueprints dirs. Fixes the discrete-text-sequence broken links;
  blueprints no longer ship per-id runnable examples, so example references in
  the consumers were dropped.
- Default HeyGen TTS voice to Marcia (deterministic; was the API's first English
  voice, which drifts on catalog re-sort). Override with --voice.
- assemble-index pre-assembly frame guards: auto-repair a sub-comp root missing
  canvas dims; hard-fail on <video>/<audio> inside a sub-comp; hard-fail on a
  timed non-root element missing class="clip" or overlapping same-track clips.
- Lint/CLI: lint media inside sub-compositions as an error; stop false-positive
  caption layout/lint findings; contrast/layout-audit skip elements hidden by an
  invisible ancestor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(skills): clear CodeQL alerts in assemble-index.mjs

- script/style blanking regex now matches closing tags with trailing
  whitespace (</script >, </style >) — js/bad-tag-filter (high).
- drop the existsSync precheck before reading/repairing a frame file; read
  directly and handle ENOENT, removing the check->write TOCTOU window —
  js/file-system-race (high).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
WaterrrForever
2026-06-27 02:51:35 +08:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 413d8187fd
commit 05af482f22
67 changed files with 3838 additions and 3006 deletions
@@ -88,6 +88,25 @@ window.__contrastAudit = async function (imgBase64, time) {
var cs = getComputedStyle(el);
if (cs.visibility === "hidden" || cs.display === "none") continue;
if (parseFloat(cs.opacity) <= 0.01) continue;
// Also skip when an ANCESTOR is effectively invisible (opacity≈0 / hidden / display:none).
// Karaoke captions keep every word at opacity 1 but toggle the GROUP's opacity per beat,
// so an inactive word's OWN opacity is 1 — only an ancestor reveals it's hidden. Without
// this, the hidden caption words flood the audit with false ~1:1 contrast warnings.
var anc = el.parentElement,
ancHidden = false;
while (anc && anc !== document.body) {
var acs = getComputedStyle(anc);
if (
acs.visibility === "hidden" ||
acs.display === "none" ||
parseFloat(acs.opacity) <= 0.01
) {
ancHidden = true;
break;
}
anc = anc.parentElement;
}
if (ancHidden) continue;
var rect = el.getBoundingClientRect();
if (rect.width < 8 || rect.height < 8) continue;
if (rect.right <= 0 || rect.bottom <= 0 || rect.left >= w || rect.top >= h) continue;
@@ -27,14 +27,18 @@
return Math.round(value * 100) / 100;
}
function overflowFor(subject, container, tolerance) {
function overflowFor(subject, container, tolerance, vTolerance) {
// Horizontal axis uses `tolerance`; vertical axis uses `vTolerance` (defaults to the same).
// A separate vertical tolerance lets text overflow checks absorb glyph ink that exceeds a
// snug line-height — see textOverflowIssues.
if (vTolerance == null) vTolerance = tolerance;
const overflow = {};
if (subject.left < container.left - tolerance)
overflow.left = round(container.left - subject.left);
if (subject.right > container.right + tolerance)
overflow.right = round(subject.right - container.right);
if (subject.top < container.top - tolerance) overflow.top = round(container.top - subject.top);
if (subject.bottom > container.bottom + tolerance)
if (subject.top < container.top - vTolerance) overflow.top = round(container.top - subject.top);
if (subject.bottom > container.bottom + vTolerance)
overflow.bottom = round(subject.bottom - container.bottom);
return Object.keys(overflow).length > 0 ? overflow : null;
}
@@ -319,9 +323,22 @@
const container = nearestConstraint(element, root, rootRect);
const containerRect = container === root ? rootRect : toRect(container.getBoundingClientRect());
const containerOverflow = overflowFor(textRect, containerRect, tolerance);
// Glyph ink (ascenders / descenders / accents / heavy display faces) routinely exceeds a
// snug line-height box by a few px, proportional to font size. When the constraining box
// does NOT clip, that vertical spill is normal typography — it shows in the padding, nothing
// is hidden — not a layout defect (it false-flagged caption words). Allow a font-metric
// vertical tolerance there; keep it tight when the box actually clips (a real cut-off) and
// always tight horizontally (too-wide text is a real wrap/legibility issue).
const elementStyle = getComputedStyle(element);
const containerClips = clipsOverflow(
container === root ? getComputedStyle(root) : getComputedStyle(container),
);
const verticalTolerance = containerClips
? tolerance
: Math.max(tolerance, parsePx(elementStyle.fontSize) * 0.2);
const containerOverflow = overflowFor(textRect, containerRect, tolerance, verticalTolerance);
if (containerOverflow && !hasAllowOverflowFlag(element)) {
const style = getComputedStyle(element);
const style = elementStyle;
issues.push({
code: "text_box_overflow",
severity: "error",
@@ -98,6 +98,42 @@ describe("layout-audit.browser", () => {
expect(runAudit()).toEqual([]);
});
it("does not flag glyph-ink vertical spill within the font-metric band on a non-clipping box", () => {
// A painted, non-clipping caption-word-like box whose glyph ink (text rect) exceeds its snug
// line-height box by a few px vertically — normal typography, nothing is clipped. (fontSize
// 36 → vertical tolerance ~7.2px; the ink spills ~5px each side, well within it.)
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="bubble"><div id="headline">crews,</div></div>
</div>
`;
installGeometry({
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
bubble: rect({ left: 80, top: 120, width: 400, height: 80 }),
text: rect({ left: 100, top: 115, width: 300, height: 90 }),
});
installAuditScript();
expect(runAudit().some((issue) => issue.code === "text_box_overflow")).toBe(false);
});
it("still flags vertical text overflow beyond the font-metric band", () => {
// Ink is 40px / 80px beyond the box — far past the ~7px font-metric band: a real overflow.
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="640" data-height="360">
<div id="bubble"><div id="headline">two crammed lines</div></div>
</div>
`;
installGeometry({
root: rect({ left: 0, top: 0, width: 640, height: 360 }),
bubble: rect({ left: 80, top: 120, width: 400, height: 80 }),
text: rect({ left: 100, top: 80, width: 300, height: 200 }),
});
installAuditScript();
expect(runAudit().some((issue) => issue.code === "text_box_overflow")).toBe(true);
});
});
describe("layout-audit.browser content overlap", () => {
@@ -72,6 +72,26 @@ describe("caption rules", () => {
expect(finding).toBeUndefined();
});
it("does not warn on a content frame that only mentions karaoke in a comment", async () => {
const html = `<template id="06-one-platform-template">
<div id="root" data-composition-id="06-one-platform" data-width="1920" data-height="1080">
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
// "Minutes, not weeks" lands with a karaoke-style keyword glow
SCREENS.forEach(function (s, i) {
var el = document.getElementById("screen-" + i);
tl.to(el, { y: -40, opacity: 0, duration: 0.3 }, i * 1.3);
});
window.__timelines["06-one-platform"] = tl;
</script>
</div>
</template>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill");
expect(finding).toBeUndefined();
});
it("warns when caption group has nowrap without max-width", async () => {
const html = `
<html><body>
+10 -1
View File
@@ -30,8 +30,17 @@ function extractArrayLiteral(src: string, varMatch: RegExpExecArray): string | n
export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [
// caption_exit_missing_hard_kill
({ scripts }) => {
({ scripts, styles, options, rootCompositionId }) => {
const findings: HyperframeLintFinding[] = [];
// Only the ACTUAL captions composition. A content frame that merely mentions
// "karaoke" / "caption-*" in a comment (or uses an unrelated forEach + opacity:0
// screen-swap) is NOT captions — gating here prevents the false positive that fired
// on a content frame whose only caption signal was a descriptive comment.
const isCaptionComposition =
Boolean(options.filePath && /caption/i.test(options.filePath)) ||
rootCompositionId === "captions" ||
styles.some((s) => /\.caption[-_]?(?:group|word|line|block)\b|\.cg-/.test(s.content));
if (!isCaptionComposition) return findings;
for (const script of scripts) {
const content = script.content;
const hasExitTween = /\.to\s*\([^,]+,\s*\{[^}]*opacity\s*:\s*0/.test(content);
@@ -220,4 +220,32 @@ describe("media rules", () => {
const finding = result.findings.find((f) => f.code === "imperative_media_control");
expect(finding).toBeUndefined();
});
it("flags <video> inside a sub-composition (media must be a host-root child)", async () => {
const html = `<template id="scene-template">
<div id="root" data-composition-id="scene" data-width="1920" data-height="1080">
<video id="v1" src="clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
<script>window.__timelines = window.__timelines || {}; window.__timelines["scene"] = gsap.timeline({ paused: true });</script>
</div>
</template>`;
const result = await lintHyperframeHtml(html, { isSubComposition: true });
const finding = result.findings.find((f) => f.code === "media_in_subcomposition");
expect(finding).toBeDefined();
expect(finding?.severity).toBe("error");
expect(finding?.elementId).toBe("v1");
expect(finding?.message).toContain("sub-composition");
});
it("does not flag media in a host-root (non-sub) composition", async () => {
const html = `
<html><body>
<div id="root" data-composition-id="c1" data-width="1920" data-height="1080">
<video id="v1" src="clip.mp4" data-start="0" data-duration="5" muted playsinline></video>
</div>
<script>window.__timelines = window.__timelines || {}; window.__timelines["c1"] = gsap.timeline({ paused: true });</script>
</body></html>`;
const result = await lintHyperframeHtml(html);
const finding = result.findings.find((f) => f.code === "media_in_subcomposition");
expect(finding).toBeUndefined();
});
});
+23
View File
@@ -342,6 +342,29 @@ export const mediaRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> =
return findings;
},
// media_in_subcomposition — <video>/<audio> only render as a DIRECT child of the host
// root (index.html). Inside a sub-composition <template> the runtime never seeks/decodes
// them, so they render BLANK/black in preview and renders — and the other lint/validate
// passes otherwise miss it (only a per-frame snapshot reveals the blank panel).
({ tags, options }) => {
const findings: HyperframeLintFinding[] = [];
if (!options.isSubComposition) return findings;
for (const tag of tags) {
if (tag.name !== "video" && tag.name !== "audio") continue;
const elementId = readAttr(tag.raw, "id") || undefined;
findings.push({
code: "media_in_subcomposition",
severity: "error",
message: `<${tag.name}${elementId ? ` id="${elementId}"` : ""}> is inside a sub-composition. The runtime only drives media that is a DIRECT child of the host root (index.html); media inside a sub-comp <template> is never seeked/decoded and renders BLANK/black in preview and renders.`,
elementId,
fixHint:
"Move the media OUT of the sub-composition: place the <video>/<audio> as a direct child of #root in index.html, positioned over the scene, and drive any per-scene motion on the MAIN timeline at global time (a sub-comp timeline cannot reach host elements). See composition-patterns.md archetype B.",
snippet: truncateSnippet(tag.raw),
});
}
return findings;
},
// self_closing_media_tag
({ source }) => {
const findings: HyperframeLintFinding[] = [];