The 217 catalog preview URLs pointed at static.heygen.ai, which now answers 403 Access Denied. The manifests carry no preview rather than a URL that cannot resolve; scripts/generate-catalog-previews.ts regenerates the files whenever we have somewhere of our own to serve them from. Social-card blocks introduce Hanzo, not HeyGen, and the avatar they ship is the Frames mark instead of the vendor logo. ui-3d-reveal drops the embedded vendor logo, its hg- class prefix, and the app.heygen.com framing. .github/workflows ran on GitHub-hosted runners, published to npm with a repo-held token, and synced skills to clawhub.ai/heygen-com. CI runs from .hanzo/workflows on the git.hanzo.ai fleet, so the tree goes; the issue and patch templates stay, pointed at our forge. The changelog-video mirrors synthesize with `frames tts` and time words with `frames transcribe` — local models, no key, no account — in place of the HeyGen CLI and its pinned voice, and ship Inter and JetBrains Mono in place of two licensed brand faces we have no right to redistribute. Dockerfile.test filtered on the @hyperframes scope and a runtime script name that no longer exist, so it built nothing.
5.2 KiB
Build Spec — square 1080 changelog master
Single-doc index.html: scenes are absolutely-positioned .slide clips on
track 1; the master timeline tl (MUST be named tl — seam-stamp emits
tl.*) is paused, padded to total duration, registered as
window.__timelines["main"]. See examples/master-skeleton.html for the
verbatim scaffold.
Brand tokens
@font-face {
font-family: "Inter";
font-weight: 400;
src: url(assets/fonts/Inter-400.woff2) format("woff2");
}
@font-face {
font-family: "Inter";
font-weight: 500;
src: url(assets/fonts/Inter-500.woff2) format("woff2");
}
@font-face {
font-family: "Inter";
font-weight: 700;
src: url(assets/fonts/Inter-700.woff2) format("woff2");
}
@font-face {
font-family: "Inter";
font-weight: 800;
src: url(assets/fonts/Inter-800.woff2) format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-weight: 400;
src: url(assets/fonts/JetBrainsMono-400.woff2) format("woff2");
}
/* ink #f5f6f4 · ink2 rgba(245,246,244,.72) · dim rgba(245,246,244,.66) —
NOT .45: the contrast gate fails small text under 4.5:1 over the glass.
green #5ef17c (RATIONED: one moment per scene) · bg #0a0c0b ·
chip-bg rgba(10,12,11,.78) · glass-line rgba(190,255,205,.32) */
Copy fonts from <SKILL_DIR>/assets/fonts/ into the project's
assets/fonts/ (local @font-face with format('woff2') embeds at render).
Glass card: chip-bg fill, 1px glass-line border, radius 22,
box-shadow: 0 24px 60px rgba(0,0,0,.5). NO backdrop-filter. Chips: mono
18-22px, radius 12, 1px rgba(255,255,255,.14) border on rgba(255,255,255,.05).
Display type: Inter 800. Body: Inter. Everything
code/UI-label: JetBrains Mono. Safe margins x/y ∈ [76, 1004].
Animated background (the house pattern)
Encode the bundled source to the film's exact duration — the render compiler shortens a video's slot to the media length, so the encode must be ≥ total:
ffmpeg -y -stream_loop 15 -i <SKILL_DIR>/assets/bg-pattern.mp4 -t <TOTAL> \
-vf "scale=1080:1080,fps=30,eq=saturation=0.72,[email protected]:t=fill" \
-an -c:v libx264 -crf 20 -pix_fmt yuv420p assets/bg-pattern-<TOTAL>s.mp4
Keep the darkening crush — the raw pattern is far too loud. Mount as
<video id="bg-video" class="clip" muted> on track 0 + a static
rgba(8,10,9,.25) scrim div. <video> needs the id (silent/black
otherwise) and must stay flat 2D (no 3D ancestors).
Scene anatomy
- Chrome (untimed, z 6): kicker chip top-left
FRAMES WEEKLY · <RANGE>, progress dots top-right (one per theme;tl.setbackgroundColor at each cut — active #f5f6f4, done .45; never tl.call for state). - Title (≤2s): mono kicker date, Inter 800 h1 ~104px, green rule sweep.
- Theme scene: sec-chip
0N · THEME NAME(top 128) + Inter 800 headline ~54px (top 186) + the mock (from the visualization registry) filling y ∈ [288, 944]. - Outro (≤3.5s): kicker FULL DIGEST, "See what shipped." ~96px, green rule, mono URL chip, tag line. Fade all + chrome ~0.5s before end.
- Caption rail per
script-voice.md(top: 990, font-size: 32px, height: 52). Mandatory — populate the master-skeleton'sLINESarray fromcaptions.jsonbefore render; see SKILL.md step 5.
Seams + internal life (doctrine mechanics)
ledger.json: every ordinary seamcut-the-curve LEFT(x, dir −1), exit and entry selectors = the slide wrappers. Outro entrytravel: 8.seam-stamp.mjs --ledger ledger.json --write index.htmlowns ALL wrapper entries/exits — author none yourself. Title (film open) authors its own entry only.- Slides: CSS
opacity: 0base;data-start= exactly the cut time. - Each scene's shell (chip, headline, mock chrome, initial state) is COMPOSED at local t=0 — the wrapper flies it in. Internal reveals start ≥0.4s after the cut and end ≥0.45s before the next cut (stamped exits begin at cut −0.34s).
- Every internal beat lands on a VO word from
transcript.json. Name each scene's sustained-motion route in the plan (sequenced UI life for mocks, staged reveals for checklists). One green moment per scene. - Init states via
gsap.set(...)at build time; animation via sequentialtl.to(...)only (no plain-object keyframes, no repeat:-1, no tweening left/top — set base position in CSS, tween x/y). Counters: object tween with anonUpdatein the tween config (cache DOM refs; nevertl.eventCallback).
Lint/check gotchas (all hit before, all pre-solved)
- Mock containers with intentional stacking:
data-layout-allow-overlapon the slide root; elements a playhead/line crosses:data-layout-allow-occlusion. - Dim text:
rgba(245,246,244,.66)minimum (contrast gate). - Audio: every
<audio>carries anid. BGM: the house track ships at<SKILL_DIR>/assets/bgm.mp3(159s instrumental) — copy it to the project asbgm.mp3and mount on its own track:<audio id="bgm" src="bgm.mp3" data-start="0" data-duration="<TOTAL>" data-track-index="2" data-volume="0.14" data-media-start="0">(0.14 sits under the VO; use a user-supplied track only when given one). - Preview server caches the bundle — RESTART after edits, then verify on the
raw comp page (
/api/projects/<id>/preview/comp/index.html) viawindow.__player.seek(t).