From a34d3dba4d15b3d6830bc546b7b6c423a1304477 Mon Sep 17 00:00:00 2001 From: Miao Yang Date: Tue, 23 Jun 2026 21:33:01 +0800 Subject: [PATCH] feat(skills): unify bgm-to-video flows into music-to-video Replace bgm-to-video, bgm-to-video-new, bgm-to-video-refactor, and the standalone beat-sync/montage skills with a single music-to-video skill. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/core/src/lint/rules/composition.ts | 72 ++++ .../css-var-fonts/output/output.mp4 | Bin 71779 -> 0 bytes skills/bgm-to-video/SKILL.md | 191 ----------- skills/bgm-to-video/agents/builder.md | 72 ---- skills/bgm-to-video/agents/director.md | 121 ------- skills/bgm-to-video/agents/finalize.md | 39 --- skills/bgm-to-video/agents/music-reader.md | 128 ------- .../references/builder-contract.md | 143 -------- .../references/motion-primitives.md | 123 ------- .../references/signature-schema.md | 195 ----------- .../references/template-definition.md | 156 --------- .../references/transition-catalog.md | 54 --- .../references/visual-plan-contract.md | 158 --------- .../templates/card-flyby/README.md | 128 ------- .../templates/card-flyby/card.json | 158 --------- .../held-message-living-field/README.md | 51 --- .../held-message-living-field/card.json | 66 ---- .../held-text-strobe-burst/README.md | 168 --------- .../held-text-strobe-burst/card.json | 99 ------ .../templates/intro-kinetic-cascade/README.md | 96 ------ .../templates/intro-kinetic-cascade/card.json | 164 --------- .../logo-split-lockup-pulse/README.md | 85 ----- .../logo-split-lockup-pulse/card.json | 145 -------- .../templates/poster-tile-mosaic/README.md | 190 ---------- .../templates/poster-tile-mosaic/card.json | 150 -------- .../roll-flipbook-word-cycle/README.md | 150 -------- .../roll-flipbook-word-cycle/card.json | 111 ------ .../split-anchor-word-slot/README.md | 246 ------------- .../split-anchor-word-slot/assets/gsap.min.js | 11 - .../split-anchor-word-slot/card.json | 178 ---------- .../README.md | 186 ---------- .../card.json | 95 ----- .../references/sub-compositions.md | 55 ++- skills/montage/SKILL.md | 324 ------------------ skills/music-to-video/SKILL.md | 189 ++++++++++ .../references/frame-skeleton.md | 94 +++++ skills/music-to-video/references/montage.md | 55 +++ .../references/motion-primitive-catalog.md | 95 +++++ .../3d-card-flip}/index.html | 0 .../bg-flow-field}/index.html | 0 .../binary-decrypt}/index.html | 0 .../blur-resolve}/index.html | 0 .../motion-primitives/braam-punch}/index.html | 0 .../chromatic-split}/index.html | 0 .../chrome-sweep}/index.html | 0 .../counting-punch}/index.html | 0 .../crash-zoom-in}/index.html | 0 .../datamosh-smear}/index.html | 0 .../directional-fill}/index.html | 0 .../motion-primitives/dolly-zoom}/index.html | 0 .../electric-arc}/index.html | 0 .../motion-primitives/flash-cut}/index.html | 0 .../gooey-metaball}/index.html | 0 .../motion-primitives/hard-cut}/index.html | 0 .../hypercut-whip}/index.html | 0 .../motion-primitives/iris-open}/index.html | 0 .../kinetic-letter-in}/index.html | 0 .../liquid-morph}/index.html | 0 .../motion-primitives/mask-reveal}/index.html | 0 .../motion-primitives/mosaic-pack}/index.html | 0 .../neon-flicker}/index.html | 0 .../outline-to-fill}/index.html | 0 .../palette-flip}/index.html | 0 .../particle-burst}/index.html | 0 .../pixel-dissolve}/index.html | 0 .../radial-burst-lines}/index.html | 0 .../screen-shake}/index.html | 0 .../slot-machine-reveal}/index.html | 0 .../spotlight-sweep}/index.html | 0 .../staggered-exit}/index.html | 0 .../text-spectral-rays}/USAGE.md | 6 +- .../text-spectral-rays}/index.html | 0 .../text-wave-distort}/index.html | 0 .../motion-primitives/tile-mosaic}/index.html | 0 .../typewriter-reveal}/index.html | 0 .../word-grid-burst}/index.html | 0 skills/music-to-video/references/planning.md | 100 ++++++ .../references/storyboard-format.md | 91 +++++ .../references/template-catalog.md | 70 ++++ .../templates/card-flyby}/assets/gsap.min.js | 0 .../templates/card-flyby/index.html | 0 .../templates/card-flyby/program.json | 0 .../held-message-living-field/index.html | 0 .../assets/gsap.min.js | 0 .../texture-mask-text/masks/asphalt-031.png | Bin .../texture-mask-text/masks/bark-014.png | Bin .../assets/texture-mask-text/masks/brick.png | Bin .../texture-mask-text/masks/bricks-075-a.png | Bin .../texture-mask-text/masks/bricks-101.png | Bin .../texture-mask-text/masks/bricks-102.png | Bin .../texture-mask-text/masks/bricks-104.png | Bin .../assets/texture-mask-text/masks/carpet.png | Bin .../texture-mask-text/masks/concrete-034.png | Bin .../masks/concrete-042-a.png | Bin .../texture-mask-text/masks/concrete-046.png | Bin .../masks/concrete-047-a.png | Bin .../texture-mask-text/masks/concrete.png | Bin .../masks/diamond-plate-009.png | Bin .../texture-mask-text/masks/fabric-080.png | Bin .../texture-mask-text/masks/fabric-083.png | Bin .../texture-mask-text/masks/grass-001.png | Bin .../texture-mask-text/masks/grass-004.png | Bin .../texture-mask-text/masks/grass-005.png | Bin .../texture-mask-text/masks/ground-037.png | Bin .../texture-mask-text/masks/ground-054.png | Bin .../texture-mask-text/masks/ground-068.png | Bin .../texture-mask-text/masks/ground-080.png | Bin .../texture-mask-text/masks/ground-103.png | Bin .../texture-mask-text/masks/ground-104.png | Bin .../assets/texture-mask-text/masks/lava.png | Bin .../texture-mask-text/masks/leather-037.png | Bin .../texture-mask-text/masks/marble-012.png | Bin .../texture-mask-text/masks/marble-016.png | Bin .../texture-mask-text/masks/metal-032.png | Bin .../texture-mask-text/masks/metal-038.png | Bin .../texture-mask-text/masks/metal-041-a.png | Bin .../texture-mask-text/masks/metal-046-b.png | Bin .../texture-mask-text/masks/metal-048-a.png | Bin .../texture-mask-text/masks/metal-049-a.png | Bin .../texture-mask-text/masks/metal-055-a.png | Bin .../texture-mask-text/masks/metal-061-b.png | Bin .../assets/texture-mask-text/masks/metal.png | Bin .../assets/texture-mask-text/masks/onyx.png | Bin .../masks/painted-plaster-017.png | Bin .../masks/paving-stones-138.png | Bin .../masks/paving-stones-150.png | Bin .../texture-mask-text/masks/plaster-001.png | Bin .../texture-mask-text/masks/road-007.png | Bin .../texture-mask-text/masks/road-008-a.png | Bin .../texture-mask-text/masks/road-009-c.png | Bin .../texture-mask-text/masks/road-012-a.png | Bin .../texture-mask-text/masks/road-012-b.png | Bin .../texture-mask-text/masks/road-013-a.png | Bin .../texture-mask-text/masks/rock-058.png | Bin .../texture-mask-text/masks/rock-063.png | Bin .../assets/texture-mask-text/masks/rock.png | Bin .../texture-mask-text/masks/snow-015.png | Bin .../assets/texture-mask-text/masks/snow.png | Bin .../texture-mask-text/masks/tiles-138.png | Bin .../masks/travertine-009.png | Bin .../texture-mask-text/masks/wood-049.png | Bin .../texture-mask-text/masks/wood-051.png | Bin .../texture-mask-text/masks/wood-058.png | Bin .../texture-mask-text/masks/wood-066.png | Bin .../texture-mask-text/masks/wood-092.png | Bin .../texture-mask-text/masks/wood-094.png | Bin .../masks/wood-floor-051.png | Bin .../masks/wood-floor-064.png | Bin .../masks/wood-floor-070.png | Bin .../assets/texture-mask-text/masks/wood.png | Bin .../held-text-strobe-burst/index.html | 0 .../intro-kinetic-cascade}/assets/gsap.min.js | 0 .../intro-kinetic-cascade/index.html | 0 .../intro-kinetic-cascade/program.json | 0 .../assets/fredoka-700.woff2 | Bin .../assets/gsap.min.js | 0 .../logo-split-lockup-pulse/index.html | 0 .../poster-tile-mosaic}/assets/gsap.min.js | 0 .../templates/poster-tile-mosaic/index.html | 2 +- .../templates/poster-tile-mosaic/program.json | 0 .../roll-flipbook-word-cycle/index.html | 0 .../assets/gsap.min.js | 0 .../split-anchor-word-slot/index.html | 2 +- .../split-anchor-word-slot/program.json | 0 .../index.html | 0 .../scripts/analyze-beatgrid.py | 2 +- .../music-to-video/scripts/assemble-index.mjs | 214 ++++++++++++ .../music-to-video/scripts/lib/storyboard.mjs | 249 ++++++++++++++ .../music-to-video/scripts/stage-assets.mjs | 55 +++ .../music-to-video/scripts/validate-plan.mjs | 144 ++++++++ .../music-to-video/sub-agents/frame-worker.md | 73 ++++ skills/pr-to-video/sub-agents/frame-worker.md | 4 +- 172 files changed, 1555 insertions(+), 4198 deletions(-) delete mode 100644 packages/producer/tests/distributed/css-var-fonts/output/output.mp4 delete mode 100644 skills/bgm-to-video/SKILL.md delete mode 100644 skills/bgm-to-video/agents/builder.md delete mode 100644 skills/bgm-to-video/agents/director.md delete mode 100644 skills/bgm-to-video/agents/finalize.md delete mode 100644 skills/bgm-to-video/agents/music-reader.md delete mode 100644 skills/bgm-to-video/references/builder-contract.md delete mode 100644 skills/bgm-to-video/references/motion-primitives.md delete mode 100644 skills/bgm-to-video/references/signature-schema.md delete mode 100644 skills/bgm-to-video/references/template-definition.md delete mode 100644 skills/bgm-to-video/references/transition-catalog.md delete mode 100644 skills/bgm-to-video/references/visual-plan-contract.md delete mode 100644 skills/bgm-to-video/templates/card-flyby/README.md delete mode 100644 skills/bgm-to-video/templates/card-flyby/card.json delete mode 100644 skills/bgm-to-video/templates/held-message-living-field/README.md delete mode 100644 skills/bgm-to-video/templates/held-message-living-field/card.json delete mode 100644 skills/bgm-to-video/templates/held-text-strobe-burst/README.md delete mode 100644 skills/bgm-to-video/templates/held-text-strobe-burst/card.json delete mode 100644 skills/bgm-to-video/templates/intro-kinetic-cascade/README.md delete mode 100644 skills/bgm-to-video/templates/intro-kinetic-cascade/card.json delete mode 100644 skills/bgm-to-video/templates/logo-split-lockup-pulse/README.md delete mode 100644 skills/bgm-to-video/templates/logo-split-lockup-pulse/card.json delete mode 100644 skills/bgm-to-video/templates/poster-tile-mosaic/README.md delete mode 100644 skills/bgm-to-video/templates/poster-tile-mosaic/card.json delete mode 100644 skills/bgm-to-video/templates/roll-flipbook-word-cycle/README.md delete mode 100644 skills/bgm-to-video/templates/roll-flipbook-word-cycle/card.json delete mode 100644 skills/bgm-to-video/templates/split-anchor-word-slot/README.md delete mode 100644 skills/bgm-to-video/templates/split-anchor-word-slot/assets/gsap.min.js delete mode 100644 skills/bgm-to-video/templates/split-anchor-word-slot/card.json delete mode 100644 skills/bgm-to-video/templates/typewriter-phrase-keyword-shuffle/README.md delete mode 100644 skills/bgm-to-video/templates/typewriter-phrase-keyword-shuffle/card.json delete mode 100644 skills/montage/SKILL.md create mode 100644 skills/music-to-video/SKILL.md create mode 100644 skills/music-to-video/references/frame-skeleton.md create mode 100644 skills/music-to-video/references/montage.md create mode 100644 skills/music-to-video/references/motion-primitive-catalog.md rename skills/{bgm-to-video/references/motion-primitives/18-3d_card_flip => music-to-video/references/motion-primitives/3d-card-flip}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/bg_flow_field => music-to-video/references/motion-primitives/bg-flow-field}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/08-binary_decrypt => music-to-video/references/motion-primitives/binary-decrypt}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/blur_resolve => music-to-video/references/motion-primitives/blur-resolve}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/03-braam_punch => music-to-video/references/motion-primitives/braam-punch}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/04-chromatic_split => music-to-video/references/motion-primitives/chromatic-split}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/13-chrome_sweep => music-to-video/references/motion-primitives/chrome-sweep}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/22-counting_punch => music-to-video/references/motion-primitives/counting-punch}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/19-crash_zoom_in => music-to-video/references/motion-primitives/crash-zoom-in}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/27-datamosh_smear => music-to-video/references/motion-primitives/datamosh-smear}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/directional_fill => music-to-video/references/motion-primitives/directional-fill}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/09-dolly_zoom => music-to-video/references/motion-primitives/dolly-zoom}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/11-electric_arc => music-to-video/references/motion-primitives/electric-arc}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/flash_cut => music-to-video/references/motion-primitives/flash-cut}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/17-gooey_metaball => music-to-video/references/motion-primitives/gooey-metaball}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/hard_cut => music-to-video/references/motion-primitives/hard-cut}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/01-hypercut_whip => music-to-video/references/motion-primitives/hypercut-whip}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/10-iris_open => music-to-video/references/motion-primitives/iris-open}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/02-kinetic_letter_in => music-to-video/references/motion-primitives/kinetic-letter-in}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/16-liquid_morph => music-to-video/references/motion-primitives/liquid-morph}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/05-mask_reveal => music-to-video/references/motion-primitives/mask-reveal}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/mosaic_pack => music-to-video/references/motion-primitives/mosaic-pack}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/12-neon_flicker => music-to-video/references/motion-primitives/neon-flicker}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/21-outline_to_fill => music-to-video/references/motion-primitives/outline-to-fill}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/palette_flip => music-to-video/references/motion-primitives/palette-flip}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/24-particle_burst => music-to-video/references/motion-primitives/particle-burst}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/26-pixel_dissolve => music-to-video/references/motion-primitives/pixel-dissolve}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/25-radial_burst_lines => music-to-video/references/motion-primitives/radial-burst-lines}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/07-screen_shake => music-to-video/references/motion-primitives/screen-shake}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/15-slot_machine_reveal => music-to-video/references/motion-primitives/slot-machine-reveal}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/20-spotlight_sweep => music-to-video/references/motion-primitives/spotlight-sweep}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/staggered_exit => music-to-video/references/motion-primitives/staggered-exit}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/text_spectral_rays => music-to-video/references/motion-primitives/text-spectral-rays}/USAGE.md (92%) rename skills/{bgm-to-video/references/motion-primitives/text_spectral_rays => music-to-video/references/motion-primitives/text-spectral-rays}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/28-text_wave_distort => music-to-video/references/motion-primitives/text-wave-distort}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/tile_mosaic => music-to-video/references/motion-primitives/tile-mosaic}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/typewriter_reveal => music-to-video/references/motion-primitives/typewriter-reveal}/index.html (100%) rename skills/{bgm-to-video/references/motion-primitives/word_grid_burst => music-to-video/references/motion-primitives/word-grid-burst}/index.html (100%) create mode 100644 skills/music-to-video/references/planning.md create mode 100644 skills/music-to-video/references/storyboard-format.md create mode 100644 skills/music-to-video/references/template-catalog.md rename skills/{bgm-to-video/references/motion-primitives => music-to-video/references/templates/card-flyby}/assets/gsap.min.js (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/card-flyby/index.html (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/card-flyby/program.json (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-message-living-field/index.html (100%) rename skills/{bgm-to-video/templates/card-flyby => music-to-video/references/templates/held-text-strobe-burst}/assets/gsap.min.js (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/asphalt-031.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/bark-014.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/brick.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/bricks-075-a.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/bricks-101.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/bricks-102.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/bricks-104.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/carpet.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/concrete-034.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/concrete-042-a.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/concrete-046.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/concrete-047-a.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/concrete.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/diamond-plate-009.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/fabric-080.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/fabric-083.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/grass-001.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/grass-004.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/grass-005.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/ground-037.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/ground-054.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/ground-068.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/ground-080.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/ground-103.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/ground-104.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/lava.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/leather-037.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/marble-012.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/marble-016.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/metal-032.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/metal-038.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/metal-041-a.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/metal-046-b.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/metal-048-a.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/metal-049-a.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/metal-055-a.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/metal-061-b.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/metal.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/onyx.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/painted-plaster-017.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/paving-stones-138.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/paving-stones-150.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/plaster-001.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/road-007.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/road-008-a.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/road-009-c.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/road-012-a.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/road-012-b.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/road-013-a.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/rock-058.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/rock-063.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/rock.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/snow-015.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/snow.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/tiles-138.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/travertine-009.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/wood-049.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/wood-051.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/wood-058.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/wood-066.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/wood-092.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/wood-094.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/wood-floor-051.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/wood-floor-064.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/wood-floor-070.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/assets/texture-mask-text/masks/wood.png (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/held-text-strobe-burst/index.html (100%) rename skills/{bgm-to-video/templates/held-text-strobe-burst => music-to-video/references/templates/intro-kinetic-cascade}/assets/gsap.min.js (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/intro-kinetic-cascade/index.html (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/intro-kinetic-cascade/program.json (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/logo-split-lockup-pulse/assets/fredoka-700.woff2 (100%) rename skills/{bgm-to-video/templates/intro-kinetic-cascade => music-to-video/references/templates/logo-split-lockup-pulse}/assets/gsap.min.js (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/logo-split-lockup-pulse/index.html (100%) rename skills/{bgm-to-video/templates/logo-split-lockup-pulse => music-to-video/references/templates/poster-tile-mosaic}/assets/gsap.min.js (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/poster-tile-mosaic/index.html (99%) rename skills/{bgm-to-video => music-to-video/references}/templates/poster-tile-mosaic/program.json (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/roll-flipbook-word-cycle/index.html (100%) rename skills/{bgm-to-video/templates/poster-tile-mosaic => music-to-video/references/templates/split-anchor-word-slot}/assets/gsap.min.js (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/split-anchor-word-slot/index.html (99%) rename skills/{bgm-to-video => music-to-video/references}/templates/split-anchor-word-slot/program.json (100%) rename skills/{bgm-to-video => music-to-video/references}/templates/typewriter-phrase-keyword-shuffle/index.html (100%) rename skills/{bgm-to-video => music-to-video}/scripts/analyze-beatgrid.py (99%) create mode 100644 skills/music-to-video/scripts/assemble-index.mjs create mode 100644 skills/music-to-video/scripts/lib/storyboard.mjs create mode 100644 skills/music-to-video/scripts/stage-assets.mjs create mode 100644 skills/music-to-video/scripts/validate-plan.mjs create mode 100644 skills/music-to-video/sub-agents/frame-worker.md diff --git a/packages/core/src/lint/rules/composition.ts b/packages/core/src/lint/rules/composition.ts index 3c6039f5b..9c76ab03a 100644 --- a/packages/core/src/lint/rules/composition.ts +++ b/packages/core/src/lint/rules/composition.ts @@ -37,6 +37,32 @@ function isCompositionRootOrMount(rawTag: string): boolean { ); } +// Top-level CSS selectors (comma-split) in a stylesheet, skipping at-rule headers +// (@media/@keyframes/...) and keyframe stops. Heuristic — the lint layer has no +// full CSS parser, and rules elsewhere in this file scan CSS the same way. +function extractCssSelectors(css: string): string[] { + const out: string[] = []; + const noComments = css.replace(/\/\*[\s\S]*?\*\//g, ""); + const ruleHeader = /([^{}]+)\{/g; + let m: RegExpExecArray | null; + while ((m = ruleHeader.exec(noComments)) !== null) { + const header = (m[1] ?? "").trim(); + if (!header || header.startsWith("@")) continue; + for (const sel of header.split(",")) { + const s = sel.trim(); + if (s) out.push(s); + } + } + return out; +} + +// Class tokens in a selector's leftmost compound (before the first descendant / +// child / sibling combinator). `.frame .title` → ["frame"]; `.a.b > .c` → ["a","b"]. +function leftmostCompoundClasses(selector: string): string[] { + const leftmost = selector.trim().split(/[\s>+~]+/)[0] ?? ""; + return (leftmost.match(/\.([\w-]+)/g) ?? []).map((c) => c.slice(1)); +} + export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> = [ // invalid_capture_path — catches ../capture/ in src/href attributes and scripts. // Sub-compositions live in compositions/ but are served relative to the project @@ -576,4 +602,50 @@ export const compositionRules: Array<(ctx: LintContext) => HyperframeLintFinding } return findings; }, + + // subcomposition_root_styled_by_class + // A sub-composition's -
+
@@ -83,23 +85,23 @@ Contrast with **standalone** compositions, which put the root directly in ` - + ``` @@ -133,6 +135,36 @@ Contrast with **standalone** compositions, which put the root directly in `` for every mismatched slot, waits 45s per scene, then captures static initial-state frames (so the video is full-length but no animation plays). +### Pitfall 3 — Styling the root by a class instead of `#root` + +```html + + + + + +``` + +**Why this happens:** when sub-compositions are inlined into one composited render, the compiler **scopes each file's CSS to its own `data-composition-id`** so scenes can't leak styles into each other — every rule `S` becomes `[data-composition-id=""] S` (a _descendant_ selector). A rule whose leftmost selector is the **root's own class** (`.frame`) therefore becomes `[data-composition-id=""] .frame`, which cannot match the root (the root _is_ the scoped element, not a descendant of it), so **every `.frame…` rule silently drops**. `#root` is special-cased by the scoper and keeps matching the root; plain descendant selectors (`.title`) match normally. The per-scene class namespace is also just redundant — the `data-composition-id` scope already isolates each scene's styles. + +**Symptom:** _identical_ to Pitfall 1 — tiny unstyled text in the top-left, images at natural size, inline styles (e.g. a card background) the only thing surviving. The trap: this passes `lint`/`validate`/`inspect` (they evaluate the file in isolation, unscoped) **and looks perfect in `preview`** (Studio mounts each scene in its own iframe, also unscoped) — it only breaks in the composited MP4 render. Lint rule `subcomposition_root_styled_by_class` flags it; the registry blocks (e.g. `apple-money-count`) model the `#root` pattern. + ### Verification checklist before render ```bash @@ -145,9 +177,14 @@ grep -n ".html grep "data-composition-id" index.html grep "data-composition-id\|__timelines\[" compositions/.html # → all three strings must match exactly per scene + +# 3) the root is styled by #root, not by a class on the data-composition-id element +grep -n 'data-composition-id=' compositions/.html +# → that element should NOT also carry a class="…" that the + + +
+${body.join("\n")} +
+ + + + +`; +writeFileSync(outPath, html); + +console.log(`✓ wrote ${outPath}`); +console.log(` canvas: ${WIDTH}×${HEIGHT}`); +console.log(` frames (track 1): ${mounted.length}`); +console.log(` bgm (track 11): ${bgmEmitted ? bgmRel : "MISSING"}`); +console.log(` vo (track 10): ${voiceCount}`); +console.log(` total duration: ${TOTAL}s` + (audioDur != null ? ` (audio ${audioDur}s)` : "")); +if (anomalies.length) { + console.log(`\nanomalies (non-fatal):`); + for (const a of anomalies) console.log(` - ${a}`); +} diff --git a/skills/music-to-video/scripts/lib/storyboard.mjs b/skills/music-to-video/scripts/lib/storyboard.mjs new file mode 100644 index 000000000..7c821dcba --- /dev/null +++ b/skills/music-to-video/scripts/lib/storyboard.mjs @@ -0,0 +1,249 @@ +// storyboard.mjs — vendored lenient parser for STORYBOARD.md. +// +// Faithful plain-JS port of @hyperframes/core/storyboard +// (packages/core/src/storyboard/parseStoryboard.ts). Vendored because skills +// ship standalone: installed via `npx skills add`, a skill's scripts can't reach +// the monorepo's core package, and the core export points at .ts source that +// `node` (which runs these scripts) can't load. CANONICAL contract = the core +// parser + skills/hyperframes-core/references/storyboard-format.md; keep this in +// lockstep. Behavior: never throws, accepts freeform narrative, recognizes +// Frame/Beat/Scene headings at H2/H3, preserves unknown keys verbatim under +// `extra` (keys lowercased). Pure node — no deps. + +export const FRAME_STATUSES = ["outline", "built", "animated"]; +export const DEFAULT_FRAME_STATUS = "outline"; + +// Detection-only frame heading (ends at the keyword); ReDoS-hardened — keep as-is. +const FRAME_HEADING_RE = /^(#{2,3})[ \t]+(?:frame|beat|scene)\b/i; +const FRAME_TITLE_SEP_RE = /^[\s.:—-]+/; +const HEADING_LEVEL_RE = /^(#{1,6})\s+/; +const META_RE = /^\s*[-*]\s+([A-Za-z_][\w-]*)\s*:\s*(.+?)\s*$/; +const LEADING_INT_RE = /^(\d+)/; +const DURATION_NUM_RE = /(\d+(?:\.\d+)?)/; +const TRANSITION_KEYS = new Set(["transition_in", "transitionin", "transition"]); +const SCENE_KEYS = new Set(["scene", "description", "summary", "caption"]); +export const VOICEOVER_ALIASES = ["voiceover", "vo", "voice_over", "narration"]; +const VOICEOVER_KEYS = new Set(VOICEOVER_ALIASES); + +export function parseStoryboard(source) { + const warnings = []; + const { globals, bodyStartLine, body } = parseFrontmatter(source, warnings); + const frames = parseFrames(body, bodyStartLine, warnings); + return { globals, frames, warnings }; +} + +function emptyGlobals() { + return { extra: {} }; +} + +function isFrameStatus(value) { + return FRAME_STATUSES.includes(value); +} + +// ── Frontmatter ───────────────────────────────────────────────────────────── +function findFrontmatterRange(lines, warnings) { + let start = 0; + while (start < lines.length && (lines[start] ?? "").trim() === "") start++; + if ((lines[start] ?? "").trim() !== "---") return null; + for (let i = start + 1; i < lines.length; i++) { + if ((lines[i] ?? "").trim() === "---") return { start, end: i }; + } + warnings.push({ + message: "Frontmatter opening '---' has no closing '---'; treating whole file as body.", + line: start + 1, + }); + return null; +} + +function parseFrontmatterEntries(lines, start, end, warnings) { + const globals = emptyGlobals(); + for (let i = start + 1; i < end; i++) { + const raw = lines[i] ?? ""; + if (raw.trim() === "") continue; + const colon = raw.indexOf(":"); + if (colon === -1) { + warnings.push({ + message: `Ignored non key:value frontmatter line: "${raw.trim()}"`, + line: i + 1, + }); + continue; + } + const key = raw.slice(0, colon).trim().toLowerCase(); + assignGlobal(globals, key, stripQuotes(raw.slice(colon + 1).trim())); + } + return globals; +} + +function parseFrontmatter(source, warnings) { + const lines = source.split(/\r?\n/); + const range = findFrontmatterRange(lines, warnings); + if (!range) return { globals: emptyGlobals(), bodyStartLine: 1, body: source }; + const globals = parseFrontmatterEntries(lines, range.start, range.end, warnings); + const body = lines.slice(range.end + 1).join("\n"); + return { globals, bodyStartLine: range.end + 2, body }; +} + +function assignGlobal(globals, key, value) { + switch (key) { + case "format": + globals.format = value; + break; + case "message": + globals.message = value; + break; + case "arc": + globals.arc = value; + break; + case "audience": + globals.audience = value; + break; + default: + globals.extra[key] = value; + } +} + +// ── Frames ────────────────────────────────────────────────────────────────── +function openFrameSection(line, headingLine) { + const match = FRAME_HEADING_RE.exec(line); + if (!match) return null; + const headingText = line.slice(match[0].length).replace(FRAME_TITLE_SEP_RE, "").trim(); + return { headingText, headingLine, level: (match[1] ?? "##").length, lines: [] }; +} + +function endsFrameSection(line, current) { + if (!current) return false; + const heading = HEADING_LEVEL_RE.exec(line); + return heading !== null && (heading[1] ?? "").length <= current.level; +} + +function parseFrames(body, bodyStartLine, warnings) { + const lines = body.split(/\r?\n/); + const sections = []; + let current = null; + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ""; + const opened = openFrameSection(line, bodyStartLine + i); + if (opened) { + sections.push(opened); + current = opened; + } else if (endsFrameSection(line, current)) { + current = null; + } else if (current) { + current.lines.push(line); + } + } + return sections.map((section, idx) => buildFrame(section, idx + 1, warnings)); +} + +function buildFrame(section, index, warnings) { + const frame = { index, status: DEFAULT_FRAME_STATUS, narrative: "", extra: {} }; + const { number, title } = parseHeading(section.headingText); + if (number !== undefined) frame.number = number; + if (title) frame.title = title; + + const narrativeLines = []; + for (const line of section.lines) { + const meta = META_RE.exec(line); + if (meta) { + applyMeta( + frame, + (meta[1] ?? "").toLowerCase(), + (meta[2] ?? "").trim(), + section.headingLine, + warnings, + ); + } else { + narrativeLines.push(line); + } + } + frame.narrative = narrativeLines.join("\n").trim(); + return frame; +} + +function parseHeading(text) { + if (!text) return {}; + const intMatch = LEADING_INT_RE.exec(text); + if (!intMatch) return { title: text }; + const number = Number.parseInt(intMatch[1] ?? "", 10); + const rest = text + .slice((intMatch[0] ?? "").length) + .replace(/^[\s.:—-]+/, "") + .trim(); + return { number, title: rest || undefined }; +} + +// Dispatch a recognized metadata key to its field, else stash under `extra`. +// Mirrors core's META_SETTERS map exactly (direct keys + alias sets). +function applyMeta(frame, key, value, headingLine, warnings) { + switch (key) { + case "duration": + applyDuration(frame, value, headingLine, warnings); + return; + case "status": + applyStatus(frame, value, headingLine, warnings); + return; + case "poster": + applyPoster(frame, value); + return; + case "src": + frame.src = value; + return; + } + if (TRANSITION_KEYS.has(key)) { + frame.transitionIn = value; + return; + } + if (SCENE_KEYS.has(key)) { + frame.scene = value; + return; + } + if (VOICEOVER_KEYS.has(key)) { + frame.voiceover = stripQuotes(value); + return; + } + frame.extra[key] = value; +} + +function applyPoster(frame, value) { + const num = DURATION_NUM_RE.exec(value); + if (num) frame.poster = Number.parseFloat(num[1] ?? ""); +} + +function applyDuration(frame, value, headingLine, warnings) { + frame.duration = value; + const num = DURATION_NUM_RE.exec(value); + if (num) { + frame.durationSeconds = Number.parseFloat(num[1] ?? ""); + return; + } + warnings.push({ + message: `Frame ${frame.index}: could not parse duration "${value}".`, + line: headingLine, + frameIndex: frame.index, + }); +} + +function applyStatus(frame, value, headingLine, warnings) { + const normalized = value.toLowerCase(); + if (isFrameStatus(normalized)) { + frame.status = normalized; + return; + } + frame.extra.status = value; + warnings.push({ + message: `Frame ${frame.index}: unknown status "${value}"; defaulting to "${DEFAULT_FRAME_STATUS}".`, + line: headingLine, + frameIndex: frame.index, + }); +} + +function stripQuotes(value) { + if (value.length >= 2) { + const first = value[0]; + const last = value[value.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return value.slice(1, -1); + } + } + return value; +} diff --git a/skills/music-to-video/scripts/stage-assets.mjs b/skills/music-to-video/scripts/stage-assets.mjs new file mode 100644 index 000000000..18f92bf9f --- /dev/null +++ b/skills/music-to-video/scripts/stage-assets.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node +// stage-assets.mjs — copy user-supplied media into the project's assets/ so scene +// files (and lint/validate/render) can reference them locally. Only needed when +// the user provides images/videos for asset treatments (montage.md). First-wins, +// idempotent, safe to run twice. Never fetches remote URLs. +// +// Usage: node stage-assets.mjs --from --hyperframes +// [--into public] (subdir under assets/; default copies flat into assets/) +// +// Copies common media extensions only; reports what landed. + +import { existsSync, mkdirSync, readdirSync, copyFileSync, statSync } from "node:fs"; +import { extname, join, resolve, basename } from "node:path"; + +const argv = process.argv.slice(2); +const flag = (n, d) => { + const i = argv.indexOf(`--${n}`); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d; +}; +function die(m) { + console.error(`✗ stage-assets.mjs: ${m}`); + process.exit(1); +} + +const fromDir = flag("from", null); +if (!fromDir) die("missing --from "); +const fromAbs = resolve(fromDir); +if (!existsSync(fromAbs) || !statSync(fromAbs).isDirectory()) die(`--from is not a directory: ${fromAbs}`); +const hyperframesDir = resolve(flag("hyperframes", ".")); +const into = flag("into", ""); +const destDir = join(hyperframesDir, "assets", into); + +const MEDIA = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".mp4", ".mov", ".webm", ".m4v"]); + +mkdirSync(destDir, { recursive: true }); +let staged = 0, + skipped = 0; +const landed = []; +for (const name of readdirSync(fromAbs)) { + const src = join(fromAbs, name); + if (!statSync(src).isFile()) continue; + if (!MEDIA.has(extname(name).toLowerCase())) continue; + const dest = join(destDir, basename(name)); + if (existsSync(dest)) { + skipped++; + continue; + } // first-wins + copyFileSync(src, dest); + staged++; + landed.push(join("assets", into, basename(name))); +} + +console.log(`✓ stage-assets: ${staged} copied, ${skipped} already present → ${join("assets", into)}/`); +for (const l of landed) console.log(` ${l}`); +if (staged === 0 && skipped === 0) console.log(` (no media files found in ${fromAbs})`); diff --git a/skills/music-to-video/scripts/validate-plan.mjs b/skills/music-to-video/scripts/validate-plan.mjs new file mode 100644 index 000000000..9eb497725 --- /dev/null +++ b/skills/music-to-video/scripts/validate-plan.mjs @@ -0,0 +1,144 @@ +#!/usr/bin/env node +// validate-plan.mjs — machine-check STORYBOARD.md against the audiomap + template +// catalog at Step 3, before any frame is built. Runs on the PLAN (frame files do +// not exist yet), so it checks fields, not on-disk html. +// +// HARD (exit 1): frontmatter duration_s == audiomap duration; >=1 frame; each frame +// has src + positive duration; frames tile the track gap-free (sum == duration_s). +// WARN (exit 0): best-effort group checks — each group exactly one of +// template/free_design/asset; a template id exists under the --templates dir; +// phrase_flow frame has no beat_cut asset treatment. +// +// Frame-level checks use the vendored storyboard parser. Group-level checks re-scan +// the RAW source (the parser's META_RE consumes indented `- params:`/`- asset:` lines). +// +// Reads: --storyboard, --audiomap, --hyperframes (for templates/). + +import { existsSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { parseStoryboard } from "./lib/storyboard.mjs"; + +const argv = process.argv.slice(2); +const flag = (n, d) => { + const i = argv.indexOf(`--${n}`); + return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d; +}; +const hyperframesDir = resolve(flag("hyperframes", ".")); +const storyboardPath = resolve(flag("storyboard", join(hyperframesDir, "STORYBOARD.md"))); +const audiomapPath = resolve(flag("audiomap", join(hyperframesDir, "audiomap.json"))); +const templatesDir = resolve(flag("templates", join(hyperframesDir, "templates"))); + +const errors = []; +const warns = []; +const r3 = (x) => Math.round(x * 1000) / 1000; + +if (!existsSync(storyboardPath)) { + console.error(`✗ STORYBOARD.md not found at ${storyboardPath}`); + process.exit(1); +} +const raw = readFileSync(storyboardPath, "utf8"); +const manifest = parseStoryboard(raw); +const G = manifest.globals.extra ?? {}; + +// ---------- audio duration ---------- +let audioDur = null; +if (existsSync(audiomapPath)) { + try { + audioDur = JSON.parse(readFileSync(audiomapPath, "utf8"))?.audio?.duration_sec ?? null; + } catch (e) { + warns.push(`audiomap parse failed: ${e.message}`); + } +} else { + warns.push(`audiomap not found at ${audiomapPath} — skipping duration cross-check`); +} + +// ---------- frontmatter duration_s ---------- +const declaredDur = G.duration_s != null ? Number.parseFloat(G.duration_s) : NaN; +if (!Number.isFinite(declaredDur)) errors.push(`frontmatter \`duration_s\` missing or unparseable`); +else if (audioDur != null && Math.abs(declaredDur - audioDur) > 0.05) + errors.push(`frontmatter duration_s (${declaredDur}) != audiomap duration (${audioDur})`); + +// ---------- frames (hard) ---------- +const frames = manifest.frames; +if (frames.length === 0) errors.push(`no frames (no \`## Frame N — \` headings found)`); + +let sum = 0; +for (const f of frames) { + const label = `frame ${f.number ?? f.index}${f.title ? ` (${f.title})` : ""}`; + if (!f.src) errors.push(`${label}: missing \`- src:\``); + if (!Number.isFinite(f.durationSeconds) || f.durationSeconds <= 0) + errors.push(`${label}: missing/!positive \`- duration:\` (got ${JSON.stringify(f.duration)})`); + else sum += f.durationSeconds; +} +sum = r3(sum); +const tileTarget = Number.isFinite(declaredDur) ? declaredDur : audioDur; +if (tileTarget != null && Math.abs(sum - tileTarget) > 0.1) + errors.push(`frame durations sum to ${sum}s but the track is ${tileTarget}s — frames must tile it gap-free`); + +// ---------- group checks (warns) — parse RAW text ---------- +const FRAME_HEAD = /^##\s+(?:frame|scene|section)\b/i; +const GROUP_HEAD = /^\s*[-*]\s*\*\*\s*(\w+)\s*\*\*\s*[—:-]\s*(template|free_design|asset)\b(.*)$/i; +const templateExistsCache = new Map(); +function templateExists(id) { + if (templateExistsCache.has(id)) return templateExistsCache.get(id); + const ok = existsSync(join(templatesDir, id, "index.html")); + templateExistsCache.set(id, ok); + return ok; +} + +// walk raw lines → group blocks tagged with their frame label + pacing +const blocks = []; +let frameLabel = "?"; +let pacing = ""; +let cur = null; +for (const ln of raw.split(/\r?\n/)) { + if (FRAME_HEAD.test(ln)) { + frameLabel = ln.replace(/^#+\s+/, "").trim(); + pacing = ""; + cur = null; + continue; + } + const pm = ln.match(/^\s*[-*]\s*pacing\s*:\s*([A-Za-z_]+)/i); + if (pm && !cur) { + pacing = pm[1].toLowerCase(); + continue; + } + const h = GROUP_HEAD.exec(ln); + if (h) { + cur = { frameLabel, pacing, name: h[1], kind: h[2].toLowerCase(), rest: h[3] ?? "", lines: [] }; + blocks.push(cur); + continue; + } + if (cur) cur.lines.push(ln); +} + +const framesWithGroups = new Set(blocks.map((b) => b.frameLabel)); +for (const f of frames) { + const lbl = `Frame ${f.number ?? ""} — ${f.title ?? f.index}`.replace(/\s+—\s+$/, ""); + if (![...framesWithGroups].some((s) => s.includes(String(f.title ?? "")))) { + warns.push(`${lbl}: no parseable groups (expected \`- **gN** — template|free_design|asset …\`)`); + } +} + +for (const b of blocks) { + const gid = `${b.frameLabel} / ${b.name}`; + const blockText = b.rest + " " + b.lines.join(" "); + if (b.kind === "template") { + const m = b.rest.match(/`([^`]+)`/) || b.rest.match(/:\s*([\w-]+)/); + const id = m ? m[1].trim() : null; + if (!id) warns.push(`${gid}: template kind but no template id on the head line`); + else if (!templateExists(id)) + warns.push(`${gid}: template \`${id}\` not found at templates/${id}/index.html`); + } + if (b.kind === "asset" && b.pacing === "phrase_flow" && /beat_cut/.test(blockText)) + warns.push(`${gid}: beat_cut asset treatment on a phrase_flow frame — use ken_burns/crossfade instead`); +} + +// ---------- report ---------- +for (const w of warns) console.log(`⚠ ${w}`); +if (errors.length) { + for (const e of errors) console.error(`✗ ${e}`); + console.error(`\nvalidate-plan: ${errors.length} error(s), ${warns.length} warning(s)`); + process.exit(1); +} +console.log(`✓ validate-plan: ${frames.length} frames tile ${sum}s; ${blocks.length} groups; ${warns.length} warning(s)`); diff --git a/skills/music-to-video/sub-agents/frame-worker.md b/skills/music-to-video/sub-agents/frame-worker.md new file mode 100644 index 000000000..df8188e72 --- /dev/null +++ b/skills/music-to-video/sub-agents/frame-worker.md @@ -0,0 +1,73 @@ +# Frame worker — per-frame composition author (music-to-video) + +You build one frame's composition file: `compositions/frames/.html`. Siblings build +the other frames in parallel. The generic HyperFrames law — sub-composition shape, timeline +registration, determinism, layout — lives in `hyperframes-core` (`references/sub-compositions.md` ++ `determinism-rules.md` + `data-attributes.md`); read it first. This file covers the +music-specific part. + +Your job: **follow the manual, fetch the materials, assemble.** The storyboard tells you WHAT +(the frame's groups, each group's template / primitives, content, brand, real beat-anchor +seconds). You decide HOW (fetch the materials, bind them to this frame's audio seconds, +micro-timing, layout, namespacing). + +## Inputs (your dispatch context) + +- `PROJECT_DIR` — project root; all paths relative to it. +- `frame_id` — the frame file's stem, e.g. `02-f2`. Use it verbatim as the composition id, the + `window.__timelines` key, and the filename `compositions/frames/.html` (the + assembler matches on it). +- Your **`## Frame N` block** in `STORYBOARD.md` — its `span_sec`, `pacing`, `mood`, `feel`, and + its **`### Groups`** list. Each group is one of: + - **template** — `template:` + `params` + `role_bindings` (real audiomap anchor seconds) + `copy`. + - **free_design** — `free_design:{dominant_system, primitives, density_topology}` + `anchors[]` + `copy`. + - **asset** — `asset:{treatment, clips, anchors?, overlay_copy?}` (see `montage.md`). +- `audiomap.json` — timing truth; use the seconds you're given. +- `frame.md` — the brand (palette + type). Pull every visual token from here. +- **Materials** — `references/templates//index.html` (its `data-composition-variables` give + the param semantics) for template groups; `references/motion-primitives//index.html` for + free groups; staged `assets/…` for asset groups. +- Canvas `×` and the frame's `pacing`. + +If your dispatch carries lint / validate feedback from a prior pass, address each finding. + +## What comes fixed — realize it as given + +- **The plan** is set in your `## Frame` block: the groups, templates / primitives, copy, brand, + and anchors. Build it as written. If a plan is genuinely wrong (wrong template or copy), stop + and report — the orchestrator re-plans at Step 3. +- **Transitions** are the assembler's: it hard-cuts between frames. You author the frame's + **internal** group→group cuts only. +- **Audio** lives on the root `index.html`; your frame is silent. +- **GSAP** is loaded by the host; use the global `gsap` (your frame carries no gsap `