Files
hyperframes/skills/hyperframes-creative/frame-presets/claude/caption-skin.html
T
WaterrrForeverandClaude Opus 4.8 d0f0ec29e7 feat(skills): frame-preset library + shared audio engine (foundation) (#1632)
* feat(hyperframes-creative): add frame-preset library

Add a library of ready-made visual frame presets (claude, biennale-yellow,
blockframe, blue-professional, bold-poster, broadside, capsule, cartesian,
cobalt-grid, coral, creative-mode, daisy-days, editorial-forest, …), each with
a FRAME.md spec, a frame-showcase.html, and a per-preset caption-skin.html.
Registered in the creative design-spec so workflows can remix a preset onto
brand tokens.

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

* feat(hyperframes-media): shared TTS/BGM/SFX audio engine

Add a shared audio engine under hyperframes-media (scripts/audio.mjs + lib/
tts.mjs, bgm.mjs, sfx.mjs, heygen.mjs) plus a bundled SFX pack and manifest.
Workflows resolve this engine by path (../../hyperframes-media/scripts/
audio.mjs) for text-to-speech, background music, and sound effects, so audio
is authored once and reused across skills instead of duplicated per workflow.

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

* feat(skills): gate render on user review; refresh router, core, general-video

- hyperframes-cli: render is now user-gated — preview opens Studio (the timeline
  editor where the user can hand-edit anything, not just watch); never
  auto-render once checks pass, pause at preview and render only after approval.
- hyperframes (router): tighten the entry SKILL.md description + routing.
- hyperframes-core: rewrite SKILL.md and add script-format.md + storyboard-format.md
  references for the script-driven authoring architecture.
- general-video: tidy the fallback-workflow description and routing table.

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

* style(hyperframes-creative): reformat frame-preset showcase HTML

Run the HTML formatter over the frame-showcase.html files (indentation,
self-closing void tags, one CSS declaration per line). Formatting only — no
content or markup changes.

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

* fix(hyperframes-media): correct wait-bgm field mapping and guard credential parse

Two correctness fixes from review (#1632):

- wait-bgm.mjs read audioMeta.bgm_path / audioMeta.bgm_enabled, but audio.mjs
  writes the path nested as bgm.path and the flag as bgm_pending. The detached
  generate path (Lyria/MusicGen) therefore always saw an empty path and exited
  status: disabled, silently dropping the music track even while generation was
  running. Read audioMeta.bgm?.path and gate on bgm_pending.
- heygenCredential() had an unguarded JSON.parse despite documenting that it
  never throws — a malformed ~/.heygen credentials file crashed the engine at
  startup instead of degrading to no-credential. Wrap the parse and return null.

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

* chore(hyperframes): add router tag to entry skill metadata

Fold the router metadata tag into the foundation rewrite of the entry SKILL.md.
This file is owned by this PR (the full router rewrite); keeping the tag tweak
here — instead of a separate edit on the pre-rewrite version in another PR —
avoids a guaranteed merge conflict between the two.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 22:49:10 +08:00

225 lines
8.2 KiB
HTML

<!--
Claude — caption skin (preset-local source).
This is the preset's own lower-third karaoke caption look. product-launch-video's
(and pr-to-video's) Step 2 copies it into the project as caption-skin.html;
captions.mjs then fills the three reserved holes, wraps the fragment in a
<template>, and writes compositions/captions.html:
· the empty GROUPS array → the engine's scene-aware word groups
· the 0 duration + 0 width/height → real total + canvas size
· the empty data-brand-tokens style → :root tokens derived from the project's frame.md
Token contract — reference ONLY this fixed vocab (captions.mjs injects it from
frame.md, so the Step-2 brand overlay flows through; each var has a claude literal
fallback for standalone preview):
--cap-ink · --cap-canvas · --cap-accent · --cap-accent-2 · --font-display ·
--font-body · --cap-band-top · --cap-band-height
Hooks (do not rename): .caption-group / .caption-word + states .is-active / .is-spoken,
root data-composition-id="captions", timeline at window.__timelines["captions"].
State changes use gsap.set({className}) (applied on the engine's frame-by-frame seek);
never tl.call() callbacks (seek does not fire them → wrong state on render).
Visual: a warm cream card lifted off the page on a 1px hairline ink border + one soft
warm shadow — claude's only elevation language, no tilt, editorial and precise.
EB Garamond sentence case, warm-ink glyphs. Upcoming words sit in faint ink; the current
word reads in full ink under a 3px coral "voltage" underline (the brand's inline-link
gesture, border-bottom = underline without layout shift); spoken words settle to solid
ink, underline cleared.
-->
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<!-- captions.mjs injects the project's :root tokens here -->
<style data-brand-tokens></style>
<style>
#captions-root {
position: absolute;
inset: 0;
pointer-events: none;
}
.caption-layer {
position: absolute;
inset: 0;
z-index: 20;
pointer-events: none;
}
/* the keep-out band, from captionBand(H) — frame content stays above it */
.caption-stage {
position: absolute;
left: 0;
right: 0;
top: var(--cap-band-top, 900px);
height: var(--cap-band-height, 180px);
display: flex;
align-items: center;
justify-content: center;
}
.caption-group {
/* every group is absolutely stacked over the SAME band — exactly one shown at a
time via opacity. Must be absolute: in-flow groups would lay out side-by-side. */
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
/* THE lifted cream card — warm cream fill, 1px hairline ink border, one soft warm
shadow (claude's only elevation language), 12px radius, no tilt */
.caption-pill {
max-width: 78%;
padding: 22px 44px 26px;
background: var(--cap-canvas, #faf9f5);
border: 1px solid color-mix(in srgb, var(--cap-ink, #141413) 12%, transparent);
border-radius: 12px;
box-shadow:
0 1px 3px color-mix(in srgb, var(--cap-ink, #141413) 8%, transparent),
0 4px 16px color-mix(in srgb, var(--cap-ink, #141413) 4%, transparent);
}
.caption-line {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.12em 0.3em;
font-family: var(--font-display, "EB Garamond"), Georgia, serif;
font-weight: 500;
font-size: clamp(40px, 4.4vw, 56px);
line-height: 1.24;
letter-spacing: -0.02em;
}
/* upcoming — faint warm ink, no underline */
.caption-word {
display: inline-block;
padding: 0 0.02em 0.06em;
color: color-mix(in srgb, var(--cap-ink, #141413) 40%, var(--cap-canvas, #faf9f5));
border-bottom: 3px solid transparent;
}
/* current word — full ink under the coral voltage underline (the brand's signature
inline-link gesture; border-bottom = underline without layout shift) */
.caption-word.is-active {
color: var(--cap-ink, #141413);
border-bottom: 3px solid var(--cap-accent, #cc785c);
}
/* already spoken — solid warm ink, underline cleared */
.caption-word.is-spoken {
color: var(--cap-ink, #141413);
border-bottom: 3px solid transparent;
}
/* optional per-word accents (dormant until a word-classer tags them) */
/* brand / key term — the expressive EB Garamond italic */
.caption-word.cap-brand {
font-style: italic;
color: var(--cap-ink, #141413);
}
.caption-word.cap-brand.is-active {
border-bottom: 3px solid var(--cap-accent, #cc785c);
}
/* numerals / data — slightly recessed warm ink */
.caption-word.cap-num {
color: color-mix(in srgb, var(--cap-ink, #141413) 72%, var(--cap-canvas, #faf9f5));
}
/* emphasis — a persistent coral voltage underline */
.caption-word.cap-cta {
border-bottom: 3px solid var(--cap-accent-2, #b5654a);
color: var(--cap-ink, #141413);
}
@media (max-aspect-ratio: 9/16) {
.caption-pill {
max-width: 92%;
}
.caption-line {
font-size: clamp(34px, 5.4vw, 46px);
}
}
</style>
<div
id="captions-root"
data-composition-id="captions"
data-timeline-locked
data-start="0"
data-duration="0"
data-fps="30"
data-width="0"
data-height="0"
>
<div class="caption-layer" aria-hidden="true">
<div id="caption-stage" class="caption-stage"></div>
</div>
</div>
<script>
// captions.mjs injects the engine's scene-aware groups + real duration here.
var GROUPS = [];
var DURATION = 0;
(function () {
var stage = document.getElementById("caption-stage");
// build DOM: one .caption-group per group, .caption-word per word
GROUPS.forEach(function (group, g) {
var groupEl = document.createElement("div");
groupEl.className = "caption-group";
groupEl.id = "caption-group-" + g;
var pill = document.createElement("div");
pill.className = "caption-pill";
var line = document.createElement("div");
line.className = "caption-line";
(group.words || []).forEach(function (w, i) {
var span = document.createElement("span");
span.className = "caption-word";
span.id = "caption-word-" + g + "-" + i;
span.textContent = String(w.text);
line.appendChild(span);
});
pill.appendChild(line);
groupEl.appendChild(pill);
stage.appendChild(groupEl);
});
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
GROUPS.forEach(function (group, g) {
var groupEl = document.getElementById("caption-group-" + g);
var words = group.words || [];
var next = GROUPS[g + 1];
var isLast = g === GROUPS.length - 1;
var start = Math.max(0, Number(group.start));
var end = isLast ? DURATION : Math.min(Number(next.start), Number(group.end) + 0.3);
if (end <= start) end = start + 0.01;
// group on/off — exactly one group visible at a time
tl.set(groupEl, { opacity: 1 }, start);
tl.set(groupEl, { opacity: 0 }, end);
// 3-state karaoke via className SETS — seek-safe (gsap.set applies on the
// engine's frame-by-frame seek; .call callbacks would NOT fire).
words.forEach(function (w, i) {
var el = document.getElementById("caption-word-" + g + "-" + i);
var at = Math.max(start, Number(w.start));
tl.set(el, { className: "caption-word" }, start); // upcoming (also resets on reverse-seek)
tl.set(el, { className: "caption-word is-active" }, at); // current
tl.fromTo(el, { scale: 0.98 }, { scale: 1, duration: 0.16, ease: "power1.out" }, at);
if (i + 1 < words.length) {
var nextAt = Math.max(start, Number(words[i + 1].start));
tl.set(el, { className: "caption-word is-spoken" }, nextAt); // demote when next activates
}
});
if (words.length) {
var lastEl = document.getElementById("caption-word-" + g + "-" + (words.length - 1));
var lastSpoken = Math.min(end, Number(words[words.length - 1].end) + 0.1);
tl.set(lastEl, { className: "caption-word is-spoken" }, lastSpoken);
}
});
// full-span anchor so the sub-comp timeline spans the whole video
tl.to({}, { duration: DURATION }, 0);
window.__timelines["captions"] = tl;
})();
</script>