@docyrus/animaguru 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +54 -3
  2. package/dist/animaguru.js +1025 -591
  3. package/package.json +1 -1
  4. package/skills/animaguru/SKILL.md +19 -89
  5. package/skills/animaguru/references/avatars.md +22 -0
  6. package/skills/animaguru/references/cloud.md +19 -0
  7. package/skills/animaguru/references/sharing.md +33 -0
  8. package/skills/animaguru/references/sprites.md +29 -0
  9. package/skills/animaguru-advertising/SKILL.md +2 -2
  10. package/skills/animaguru-analog-film-tape/SKILL.md +1 -0
  11. package/skills/animaguru-animated-data-visualization/SKILL.md +1 -0
  12. package/skills/animaguru-art-deco-motion/SKILL.md +1 -0
  13. package/skills/animaguru-ascii-crt/SKILL.md +1 -0
  14. package/skills/animaguru-blueprint-drafting/SKILL.md +1 -0
  15. package/skills/animaguru-brand-kit/SKILL.md +1 -1
  16. package/skills/animaguru-cartoon-character-animation/SKILL.md +1 -0
  17. package/skills/animaguru-cutout-animation/SKILL.md +1 -0
  18. package/skills/animaguru-dark-tech-stage/SKILL.md +2 -1
  19. package/skills/animaguru-dark-tech-stage/references/recipe.md +10 -0
  20. package/skills/animaguru-diagram-formula-motion/SKILL.md +1 -0
  21. package/skills/animaguru-engraved-relief-print/SKILL.md +1 -0
  22. package/skills/animaguru-flat-vector-motion/SKILL.md +1 -0
  23. package/skills/animaguru-generative-light-signal/SKILL.md +1 -0
  24. package/skills/animaguru-glass-mosaic/SKILL.md +1 -0
  25. package/skills/animaguru-halftone-editorial/SKILL.md +1 -0
  26. package/skills/animaguru-holographic-scan/SKILL.md +1 -0
  27. package/skills/animaguru-interface-motion/SKILL.md +1 -0
  28. package/skills/animaguru-isometric-worlds/SKILL.md +1 -0
  29. package/skills/animaguru-kinetic-typography/SKILL.md +1 -0
  30. package/skills/animaguru-miniature-diorama/SKILL.md +1 -0
  31. package/skills/animaguru-painterly-depth-animation/SKILL.md +1 -0
  32. package/skills/animaguru-paper-popup/SKILL.md +1 -0
  33. package/skills/animaguru-photo-footage-editorial/SKILL.md +1 -0
  34. package/skills/animaguru-pixel-art-animation/SKILL.md +1 -0
  35. package/skills/animaguru-portal-zoom/SKILL.md +1 -0
  36. package/skills/animaguru-risograph-print/SKILL.md +1 -0
  37. package/skills/animaguru-sand-drawing/SKILL.md +1 -0
  38. package/skills/animaguru-shadow-theatre/SKILL.md +1 -0
  39. package/skills/animaguru-silkscreen-poster/SKILL.md +1 -0
  40. package/skills/animaguru-stop-motion-objects/SKILL.md +1 -0
  41. package/skills/animaguru-studio-product-reveal/SKILL.md +1 -0
  42. package/skills/animaguru-video/SKILL.md +37 -5
  43. package/skills/animaguru-video/references/short-form-ad.md +57 -0
  44. package/skills/animaguru-whiteboard-line-drawing/SKILL.md +1 -0
  45. package/templates/video/AGENTS.md +2 -1
  46. package/templates/video/WORKSPACE-AGENTS.md +6 -2
  47. package/templates/video/package.json +7 -1
  48. package/templates/video/scripts/audio-mix.mjs +101 -0
  49. package/templates/video/scripts/audio-music.mjs +175 -0
  50. package/templates/video/scripts/audio-tools.mjs +241 -0
  51. package/templates/video/scripts/audio-vo.mjs +158 -0
  52. package/templates/video/scripts/export-mp4.mjs +5 -3
  53. package/templates/video/scripts/frames.mjs +81 -0
  54. package/templates/video/scripts/headless-browser.mjs +83 -10
  55. package/templates/video/scripts/review-analysis.mjs +71 -1
  56. package/templates/video/scripts/review-audio.mjs +2 -1
  57. package/templates/video/scripts/review.mjs +8 -5
  58. package/templates/video/scripts/site-inspect.mjs +128 -0
  59. package/templates/video/scripts/starter-film.sha256 +1 -1
  60. package/templates/video/scripts/workspace-plugin.mjs +2 -1
  61. package/templates/video/shared/safe-areas.json +7 -0
  62. package/templates/video/src/film.ts +97 -125
  63. package/templates/video/src/kit/components.ts +95 -0
  64. package/templates/video/src/kit/dark-stage.ts +55 -0
  65. package/templates/video/src/kit/film-base.ts +108 -0
  66. package/templates/video/src/kit/fonts.ts +29 -0
  67. package/templates/video/src/kit/index.ts +11 -0
  68. package/templates/video/src/kit/layout.ts +40 -0
  69. package/templates/video/src/kit/motion.ts +37 -0
  70. package/templates/video/src/kit/shapes.ts +68 -0
  71. package/templates/video/src/kit/text.ts +34 -0
  72. package/templates/video/src/kit/textures.ts +42 -0
  73. package/templates/video/src/kit/timing.ts +48 -0
  74. package/templates/video/src/player-types.ts +7 -1
  75. package/templates/video/src/player.ts +3 -5
  76. package/templates/video/src/review.ts +2 -0
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: animaguru-shadow-theatre
3
3
  description: Build Shadow Theatre visuals and motion in an AnimaGuru PixiJS video workspace. Use when this visual style is chosen for an explainer, advertisement or general video.
4
+ disable-model-invocation: true
4
5
  ---
5
6
 
6
7
  # Shadow Theatre
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: animaguru-silkscreen-poster
3
3
  description: Build Silkscreen Poster visuals and motion in an AnimaGuru PixiJS video workspace. Use when this visual style is chosen for an explainer, advertisement or general video.
4
+ disable-model-invocation: true
4
5
  ---
5
6
 
6
7
  # Silkscreen Poster
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: animaguru-stop-motion-objects
3
3
  description: Build Stop Motion Objects visuals and motion in an AnimaGuru PixiJS video workspace. Use when this visual style is chosen for an explainer, advertisement or general video.
4
+ disable-model-invocation: true
4
5
  ---
5
6
 
6
7
  # Stop Motion Objects
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: animaguru-studio-product-reveal
3
3
  description: Build Studio Product Reveal visuals and motion in an AnimaGuru PixiJS video workspace. Use when this visual style is chosen for an explainer, advertisement or general video.
4
+ disable-model-invocation: true
4
5
  ---
5
6
 
6
7
  # Studio Product Reveal
@@ -17,7 +17,7 @@ Read the sibling `animaguru` skill for authentication, model discovery, credit a
17
17
 
18
18
  1. If `category` is unset, first ask which category the user wants, offering **Explainer** (explain an idea, product, service or process), **Advertisement** (product, launch, offer, creator or editorial advertising), and **General Video** (films, music visuals, titles, loops or visual essays). Wait for the answer, then save `category` as `explainer`, `advertisement` or `general-video` and `setupStage` as `style`.
19
19
  2. With the category established, present its available visual styles from `animaguru.templates.json`. Share the [AnimaGuru animation-style gallery](https://animaguru.com/animation-styles), or open it in the internal browser when available, so the user can watch previews. Ask them to choose a style and wait. Present the installed styles as concise names and visual descriptions. With many options, offer the gallery and a brief shortlist suited to the user's stated preferences while keeping the full catalog available. Even if a category has only one style, ask for an explicit choice. Do not invent unavailable styles. The gallery is a visual aid; if it is unavailable, use the installed catalog and skill descriptions to continue the choice with the user.
20
- 3. Save the chosen `template` ID and its `skill` name in `animaguru.project.json`, set `setupStage` to `brief`, and update the player's `shared/locales/en/player.json` template label to the selected category/style. Read that style's local `SKILL.md`, then run the quick interview below with its relevant visual questions. After the brief is complete, set `interview` to `complete` and `setupStage` to `ready`.
20
+ 3. Save the chosen `template` ID and its `skill` name in `animaguru.project.json`, set `setupStage` to `brief`, and update the player's `shared/locales/en/player.json` template label to the selected category/style. Read that style's local `SKILL.md` by path (`.claude/skills/SKILL/SKILL.md` or `.agents/skills/SKILL/SKILL.md`; style skills are not auto-loaded), then run the quick interview below with its relevant visual questions. After the brief is complete, set `interview` to `complete` and `setupStage` to `ready`.
21
21
 
22
22
  Keep the gallery URL in `galleryUrl`. All available style skills are already installed for both agents; selection does not require another CLI command, download, account or paid generation.
23
23
 
@@ -42,7 +42,7 @@ Cover the missing essentials, grouping related questions:
42
42
  4. **Visual and motion direction:** Apply the selected style skill's questions to the user's brand, references, preferred energy, subjects and asset constraints.
43
43
  5. **Production scope:** Existing assets/project, preferred models and authorized credit budget. Discover current estimates before spending. If generation is not authorized, prepare a concrete plan and request only the missing authorization.
44
44
 
45
- Save the answers in `production/brief.md`. Develop the story, sequence and pacing from that brief and the selected category. The visual template must not impose a family audience, emotional arc, fixed scene sequence, film length, narration tone or CTA. Once the brief is established, proceed with routine creative decisions without repeated approvals.
45
+ Save the answers in `production/brief.md`. For a short vertical social ad (Reels, Shorts, TikTok, about 15–60 s), read `references/short-form-ad.md` next: it is the end-to-end command sequence, so the CLI, catalog and older films need no exploration. Develop the story, sequence and pacing from that brief and the selected category. The visual template must not impose a family audience, emotional arc, fixed scene sequence, film length, narration tone or CTA. Once the brief is established, proceed with routine creative decisions without repeated approvals.
46
46
 
47
47
  ## Production contract
48
48
 
@@ -56,7 +56,37 @@ Save the answers in `production/brief.md`. Develop the story, sequence and pacin
56
56
  - Motion must depend on absolute timeline time. Seed procedural variation once. Keep `window.animaguru.seek(seconds)` and `renderFrame(frame, fps)` working and asynchronous. When adding video, await its decoded frame and upload the texture before resolving a render. Do not use independent uncontrolled media clocks for exported scenes.
57
57
  - Update `src/film-resources.ts` with the real resources and start/end times whenever the film changes. This metadata drives the authoring timeline and `window.animaguru.resources`. Register image/video/audio files with public-relative `source` paths, and group repeated uses of the same resource into clips on its row. Keep the timeline in sync with scene/caption/audio timing and outside exported embed/capture layouts.
58
58
  - Write user-facing copy in `shared/locales/`. If adding languages, switch scene copy, captions, transcript and narration together; export an explicitly selected matching soundtrack. Load fonts covering the required glyphs.
59
- - Fit the real narration to cue timings. Mix the music underneath speech and save a full-length master locally; `config.audio` connects it to both preview and export. Do not report narration as complete if only transcript text exists.
59
+ - Fit the real narration to cue timings. Mix the music underneath speech and save a full-length master locally; `config.audio` connects it to both preview and export. Do not report narration as complete if only transcript text exists. Use the audio tools below rather than writing your own cutting, beat-analysis or mixing scripts.
60
+
61
+ ## Film code: kit and scene modules
62
+
63
+ Build on the shared kit instead of re-implementing helpers: `src/kit/` in a standalone project, root `runtime/kit/` in a workspace (import `../../../runtime/kit` from `animations/NAME/src/`). Read `kit/index.ts` and the starter `src/film.ts` for the API. It provides:
64
+
65
+ - `FilmBase`: the player contract (stage, resources, captions toggle, review `texts` and `cues`, async `render`). A film passes `{ config, chapters, design?, captions?, captionStyle? }`, adds scenes in `build()` and registers per-frame updates with `film.on(t => …)`. Captions given to it become a burned-in `CaptionBar` placed inside the safe area and hidden in clean exports.
66
+ - `cueSheet(timing)` and `joinCaptions()` over `production/audio/timing.json`: `cue.at("vo-03-turn", 1, 0.1)` is the second caption of that segment plus 0.1 s, so scenes follow the voiceover when it is re-cut. Never copy cue numbers into the code by hand.
67
+ - Motion: `win`, `inOut`, `easeOut`/`easeIn`/`easeInOut`/`easeBack`, `bump`, `lerp`, `mixColor`, `hex`, seeded `rng`, `show`, `setText`.
68
+ - Text and fonts: `txt(value, size, { family: "heading" | "body" | "mono", weight, fill, … })`, `useBrandFonts(branding, monoFamily?)`, `loadFonts([{ family, url }])` (variable fonts get their weight range), `gradientFill(colors)`.
69
+ - Drawing: `panel`, `partialLine`, `bezier`, `pointAt`, `check`, `cross`, `person`, `glowTexture`, `vignetteTexture`, `rasterSvg` (exact-width logo textures), `Badge`, `Pill`.
70
+ - Layout: `safeArea(config)` and `stack(nodes, { x, y, gap })`. Set `"safeArea": "reels"` (or `{ top, right, bottom, left }` pixels) in `film.config.json` for vertical social films; review checks text against it.
71
+
72
+ Keep `src/film.ts` to setup and the scene list, and put each scene in `src/scenes/NAME.ts` as `export function hook(film: FilmBase, cue: CueSheet) { … }` that adds to `film.world` and calls `film.on`. Write and fix one scene file at a time with targeted edits rather than rewriting the whole film. Claude Code's Write tool refuses to replace a file that has not been opened with Read in this session (`cat` does not count), so Read the starter `src/film.ts` before replacing it.
73
+
74
+ ## Motion library
75
+
76
+ Before choreographing a scene from scratch, search the AnimaGuru motion library for a finished example to start from: `animaguru motion search DESCRIPTION [--category ID] [--labels mobile,dark]` (natural language and keywords both work; `animaguru motion categories` lists the categories, such as `kinetic-typography` and `application-ui`). Each result describes what it looks like, its labels, techniques and duration. `animaguru motion get ID` prints its timed beats, options and code; `animaguru motion get ID --write src/scenes` (or `animations/NAME/src/scenes` in a workspace, where the kit import is rewritten for `runtime/kit`) writes the scene and its shared modules. Call its entry function from `build()` with `{ at }`, an area (`x, y, width, height`) and your copy, colors and fonts, then adapt the choreography to the brief rather than shipping the sample copy. The MCP tools `search_motion_examples` and `get_motion_example` return the same data. Search needs `animaguru auth login`; it spends no credits.
77
+
78
+ ## Asset library
79
+
80
+ Pro and Team accounts can take ready-made music, sound effects, voice-overs, clips, images and sprites from the AnimaGuru asset library instead of generating them: `animaguru library search DESCRIPTION --type music|sfx|speech|video|image|sprite` (natural language and keywords). `animaguru library download ID --output public/assets/` saves a file into the film (sprites save their sheets with `animation.json` frame rectangles and timing); `animaguru library import ID --project PROJECT_ID` adds it to a cloud project instead. Prefer a library whoosh or background track over generating one; it spends no credits.
81
+
82
+ ## Voiceover, music and captions
83
+
84
+ 1. Generate the lines in one call: a batch file with shared speech defaults and one `{ name, prompt }` per segment, `animaguru assets batch PROJECT_ID --file production/generation/vo-lines.json --dry-run`, then `--yes --download production/audio/vo/`. Generate music with `assets generate … --type music --yes --download production/audio/music/`. See the `animaguru` skill for authorization.
85
+ 2. Write `production/audio/vo.json`: `{ "tempo": 1.12, "start": 0.25, "gap": 0.35, "maxPause": 0.26, "segments": [{ "id": "vo-01-hook", "captions": ["Four channel reports.", "Three versions of margin."] }] }`. Each segment's file defaults to `production/audio/vo/ID.wav`; `captions` are the on-screen lines (or give `text` to split at sentence ends), and `gapBefore` overrides the gap. Run `npm run audio:vo -- --animation NAME`. It trims silences, caps long pauses, speeds up the read, places the segments and writes `production/audio/work/vo-track.wav` and `production/audio/timing.json`; it warns when the voice runs into the end-card hold (`endHold`, default 3 s).
86
+ 3. Run `npm run audio:music -- --animation NAME --file production/audio/music/BED.mp3 --length DURATION [--drop SECONDS] [--end-hit SECONDS]`. It finds tempo, bars, sections and the final hit, and writes a bar-aligned edit to `production/audio/music-edit.json`, so the drop lands on `--drop` and the bed's own ending near `--end-hit`.
87
+ 4. Run `npm run audio:mix -- --animation NAME` (optional `production/audio/mix.json` for `musicDb`, `duck`, `loudness`, `output`). It ducks the music under the voice, masters two-pass to −14 LUFS and −2 dBTP, writes AAC to `public/assets/SLUG-mix.m4a` and sets `audio` in `film.config.json`.
88
+
89
+ Drive scenes from `cueSheet(timing)` and captions from `joinCaptions(timing.captions)`; the `captions` resource track should list the same lines for `.srt`/`.vtt` export. These tools measure but do not listen: say so in delivery unless someone listened.
60
90
 
61
91
  ## Review and delivery
62
92
 
@@ -64,7 +94,7 @@ Run `npm run build`. Review the actual browser at desktop and mobile sizes, play
64
94
 
65
95
  Deliver the playable HTML film first unless the user asked for MP4 too. For an embeddable HTML5 deliverable, use the download menu or `npm run export:html`. Unzip and test the static package over HTTP(S), including its iframe example; it should play without the authoring workspace or an AnimaGuru account. `npm run build` includes the same download in the static player. Preserve embed mode and hide authoring controls there.
66
96
 
67
- For MP4, run `npm run export:mp4`, open the printed local capture URL in an available browser, and keep it open until completion. The script verifies H.264/AAC, dimensions, duration and exact frame count and writes a JSON report next to the MP4. Captions stay out of the picture; the `captions` resource track becomes `.srt` (YouTube, desktop players) and `.vtt` (HTML5) subtitle files with the MP4's name, so keep that track's clips equal to the spoken lines. For social feeds that need open captions, add `-- --burn-captions`; it saves `SLUG-LANGUAGE-captioned.mp4` with captions in the picture and no subtitle files. Set `"poster"` in `film.config.json` to the seconds of the film's most representative frame (title, product or end card, never a fade or a blank moment); the export saves it beside the MP4 as a 1280-pixel JPEG thumbnail, which `animaguru youtube upload` and video pages can use. Exporting existing local assets spends no generation credits. If browser control is unavailable, give the user the printed URL and explain the one manual step.
97
+ For MP4, run `npm run export:mp4 -- --headless`; it captures in a local headless Chrome (installed Chrome or Edge, `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`, or `npx playwright install chromium`). Without `--headless`, open the printed local capture URL in a browser and keep it open until completion. The script verifies H.264/AAC, dimensions, duration and exact frame count and writes a JSON report next to the MP4. Captions stay out of the picture; the `captions` resource track becomes `.srt` (YouTube, desktop players) and `.vtt` (HTML5) subtitle files with the MP4's name, so keep that track's clips equal to the spoken lines. For social feeds that need open captions, add `-- --burn-captions`; it saves `SLUG-LANGUAGE-captioned.mp4` with captions in the picture and no subtitle files. Set `"poster"` in `film.config.json` to the seconds of the film's most representative frame (title, product or end card, never a fade or a blank moment); the export saves it beside the MP4 as a 1280-pixel JPEG thumbnail, which `animaguru youtube upload` and video pages can use. Exporting existing local assets spends no generation credits. If browser control is unavailable, give the user the printed URL and explain the one manual step.
68
98
 
69
99
  `npm run dev` starts the preview. The capture API and file map are documented in the generated project README. Publishing a website, package or campaign is separate from creating the local video.
70
100
 
@@ -74,7 +104,9 @@ The expanded styles include original 8-second procedural studies in `src/studies
74
104
 
75
105
  Use `src/studies/timing.ts` for analytic easing, measured stroke reveals and cue comparisons; `src/studies/media.ts` for optional source-time/trim mapping. Derive graphics, captions and audio events from shared cue data when synchronizing them. Keep procedural sound optional; generated narration/music and supplied tracks remain supported.
76
106
 
77
- `npm run review` prints a local browser URL. Open it to collect actual text bounds, representative frames and repeated cold-seek comparisons into `production/review/`. Optional cues with `audioTime` are checked to an output-frame tolerance. Reading-time notes are configurable design guidance, not a substitute for watching/listening; partial type reveals and occlusion still need visual review. Do not report a check as passed when metadata or media was unavailable.
107
+ `npm run review -- --headless` collects actual text bounds every 0.2 s, representative frames and repeated cold-seek comparisons into `production/review/` and prints one line per finding (without `--headless`, open the printed URL). Besides seek, frame-bound, reading-time and cue checks, it flags overlapping text, text outside `safeArea`, text below `minTextSize` (default 2.2% of the shorter side) and film text under a burned-in caption, with time ranges. Fix those before looking at frames. Optional cues with `audioTime` are checked to an output-frame tolerance. Reading-time notes are design guidance, not a substitute for watching/listening; partial type reveals and occlusion still need visual review. Do not report a check as passed when metadata or media was unavailable.
108
+
109
+ For visual review, run `npm run frames -- --animation NAME --at 1.3,4.9,…` (or `--every 3`, `--count 12`; `--no-captions`, `--full` for full-size JPEGs). It saves labelled contact sheets of up to 12 frames to `production/review/frames/` and prints the layout notes for those times. Look at one sheet instead of many single frames, and re-run only the times you changed.
78
110
 
79
111
  ## Sharing a finished film
80
112
 
@@ -0,0 +1,57 @@
1
+ # Short-form vertical ad (Reels, Shorts, TikTok)
2
+
3
+ A tested sequence for a 15–60 s 9:16 ad with voiceover, music and burned-in captions, first used for a 45 s B2B product reel. It replaces exploring the CLI, the catalog and older films. Adapt the story to the brief: the beats below are a starting point, not a template.
4
+
5
+ ## 1. Brand and claims
6
+
7
+ - If the shared kit is a placeholder, run `npm run inspect:site -- https://SITE --pages /product,/about` and follow `animaguru-brand-kit`. Look at `hero.png` and the saved logo files before using them.
8
+ - Take product claims from the saved `text.md` pages and record them in `production/sources.json`. Do not invent customers, figures, testimonials or prices; label example values in interface mock-ups as illustrative.
9
+
10
+ ## 2. Film setup
11
+
12
+ - `animaguru create animation NAME`, then save category, template and skill in its `animaguru.project.json`.
13
+ - `film.config.json`: `"width": 1080, "height": 1920, "fps": 30, "duration": 45, "safeArea": "reels", "poster": END_CARD_SECONDS`.
14
+ - One remote asset project for the film's media: `animaguru projects create --name "BRAND — reel"`.
15
+
16
+ ## 3. Script and beats
17
+
18
+ A read at about 2 words per second, sped up 1.12×, fits 85–95 words into 45 s with a 3–4 s end-card hold. One voiceover segment per beat (`vo-01-hook`, `vo-02-problem`, …). This beat pattern worked for a 45 s ad:
19
+
20
+ | Beat | Time | Job |
21
+ | --- | --- | --- |
22
+ | Hook | 0–5 s | One concrete, visual problem |
23
+ | Problem | 5–11 s | Why it happens |
24
+ | Turn | 11–15 s | The reframing line |
25
+ | Product | 15–19 s | The product enters on the music drop |
26
+ | Proof | 19–33 s | Two or three capabilities as interface moments |
27
+ | Outcome | 33–37 s | What the viewer gets |
28
+ | End card | 37–45 s | Logo, tagline, call to action, URL; voice ends by about 41 s |
29
+
30
+ ## 4. Generate the audio
31
+
32
+ - `production/generation/vo-lines.json`: `{"defaults": {"type": "speech", "model": "gemini-3.8-flash-tts", "voice": "Kore", "delivery": "Confident, crisp diction, medium-brisk pace, short pauses at full stops."}, "assets": [{"name": "vo-01-hook", "prompt": "…"}]}`. Check current models and voices with `animaguru catalog --type speech`.
33
+ - `animaguru assets batch PROJECT_ID --file production/generation/vo-lines.json --dry-run`, then `--yes --download production/audio/vo/`.
34
+ - `animaguru assets generate PROJECT_ID --type music --name music-bed --prompt "STYLE, instrumental, tense intro, a clear drop, strong final hit" --yes --download production/audio/music/`. The bed comes back longer than the film; step 5 fits it.
35
+ - Eight speech lines and one music bed cost about 10 credits for the first reel; always dry-run first and stay within the authorized budget. Keep the JSON receipts in `production/generation/`.
36
+
37
+ ## 5. Cut, fit and mix
38
+
39
+ 1. `production/audio/vo.json` with each segment's on-screen `captions`, then `npm run audio:vo -- --animation NAME`.
40
+ 2. `npm run audio:music -- --animation NAME --file production/audio/music/music-bed.mp3 --length 45 --drop PRODUCT_BEAT_START --end-hit END_CARD_START+5.5`.
41
+ 3. `npm run audio:mix -- --animation NAME`.
42
+
43
+ ## 6. Build the film
44
+
45
+ - `src/film.ts`: `FilmBase` with `captions: joinCaptions(timing.captions)`; `cueSheet(timing)` times every scene; `film.mark(DROP, "Music drop")` records music hits for review. Scenes go in `src/scenes/*.ts`, one per beat.
46
+ - Layout inside the Reels safe area: headlines at 72–96 px in the top third (y 260–600), interface cards in the middle (y 650–1350), labels at 26 px or more, and nothing but the caption below y 1440. The caption bar sits at about y 1500 by default.
47
+ - `npm run build -- --animation NAME` typechecks.
48
+
49
+ ## 7. Review in two or three rounds
50
+
51
+ - `npm run review -- --animation NAME --headless`, then fix every overlap, safe-area, small-text and caption line it prints.
52
+ - `npm run frames -- --animation NAME --at TIMES`, with each beat's midpoint and each transition, gives one contact sheet per 12 frames. Fix what it shows, then re-run only the changed times.
53
+
54
+ ## 8. Export
55
+
56
+ - `npm run export:mp4 -- --animation NAME --headless --burn-captions` for the feed upload (muted autoplay needs captions in the picture), and the same without `--burn-captions` for a clean MP4 with `.srt`/`.vtt`.
57
+ - Report what was measured (frames, duration, codecs, loudness) separately from what was watched or listened to.
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  name: animaguru-whiteboard-line-drawing
3
3
  description: Build stroke-by-stroke animated illustrations and annotations in marker, ink or chalk treatments. Use for the Whiteboard and Line Drawing visual style in an AnimaGuru video workspace.
4
+ disable-model-invocation: true
4
5
  ---
5
6
 
6
7
  # Whiteboard & Line Drawing
@@ -17,7 +17,8 @@ Selections and setup progress are recorded in `animaguru.project.json`. Availabl
17
17
  - Keep `src/film-resources.ts` aligned with the real film: add every image/video/audio/graphic resource and text layer, its actual cue times and public-relative source path. `Film.resources` powers the full-width timeline below the player. Do not leave demo resources in a finished film or invent audio tracks when none exist. The timeline belongs to the authoring player, outside embed/capture mode.
18
18
  - `shared/locales/`: all user-facing copy. `public/assets/`: local media. `production/`: brief, sources, storyboard, narration, requests and receipts.
19
19
  - Use `publicAsset("assets/file.ext")` from `src/assets.ts` for public media. Root-absolute paths break an HTML5 export hosted in a subfolder. `npm run export:html` produces a portable static ZIP with an iframe example; the download menu also supports this format. `npm run build` includes a ready-made HTML5 download for static hosting.
20
- - `npm run dev`: preview. `npm run build`: TypeScript check and production build. `npm run export:mp4`: local MP4 capture; open its printed URL and wait for completion.
20
+ - `src/kit/`: shared film kit (`FilmBase`, cue lookup, text, shapes, captions, safe areas); the starter film shows its use. Put each scene of a real film in `src/scenes/*.ts`.
21
+ - `npm run dev`: preview. `npm run build`: TypeScript check and production build. `npm run export:mp4 -- --headless`: local MP4 capture in headless Chrome (without `--headless`, open its printed URL and wait). `npm run review -- --headless` and `npm run frames -- --at 1.2,4.8`: layout checks and contact sheets. `npm run audio:vo|audio:music|audio:mix`: voiceover timing, music fit and mastering.
21
22
 
22
23
  Use strict TypeScript, two-space indentation and double quotes. Preserve the `window.animaguru` async seeking/frame API. Keep scene content and captions in the canvas. Load local assets and await video decoding for capture. Set the final duration and soundtrack in the shared config. Do not embed credentials or reuse another client's media.
23
24
 
@@ -6,6 +6,7 @@ This root owns shared branding, installed skills, dependencies, the PixiJS playe
6
6
  - Read `branding/brand.json`, branding files/guides in `branding/`, and any relevant branding files the user placed at the root. If the kit is still the scaffold placeholder or incomplete, read `.agents/skills/animaguru-brand-kit/SKILL.md` (or `.claude/skills/animaguru-brand-kit/SKILL.md` in Claude Code). Use an official website or brand guide in the first prompt; if none was supplied, ask for one before researching the brand. Populate `branding/` and record sources in `branding/brand-guide.md`. Every animation inherits these shared settings. Keep guides local; exports include registered web assets, rather than the whole branding folder.
7
7
  - Read the installed `animaguru-video` skill and the selected film's `animaguru.project.json`. After establishing the shared brand source, first ask for **Explainer**, **Advertisement**, or **General Video**; then ask for a visual style using `animaguru.templates.json` and https://animaguru.com/animation-styles; then run the compact creative interview. Reuse explicit choices and supplied/completed answers. Save the brief inside that film's `production/` folder. Purpose and visual style are separate decisions.
8
8
  - Production paths such as `film.config.json`, `src/film.ts`, `src/film-resources.ts`, `public/assets/`, `shared/locales/en/film.json` and `production/` in style recipes are relative to the selected animation. Shared runtime examples are in root `runtime/studies/`. Do not copy the player, scripts, skills or node_modules into each animation.
9
+ - Build films on the shared kit in `runtime/kit/` (`FilmBase`, cue lookup from `production/audio/timing.json`, text, shapes, captions, safe areas); keep each scene in its own `src/scenes/*.ts` file. The `animaguru-video` skill lists the kit and the audio tools.
9
10
  - Use the shared `runtime/assets.ts` helper for portable public media paths. A film constructor may receive a `FilmContext` with the shared branding, logo texture and registered asset paths. Imported artwork and audio belong in the selected film's public/assets/. Set its soundtrack in film.config.json; use null for an intentionally silent film. Narration in production/ is not automatically a web asset.
10
11
 
11
12
  Run commands from the workspace root:
@@ -13,8 +14,11 @@ Run commands from the workspace root:
13
14
  - `npm run dev`: the workspace hub and separate `/animations/NAME/` players.
14
15
  - `npm run build -- --animation NAME`: typecheck and build that film.
15
16
  - `npm run export:html -- --animation NAME`: portable HTML5 ZIP with local media and common branding.
16
- - `npm run export:mp4 -- --animation NAME`: verified MP4 without burned-in captions, plus `.srt`/`.vtt` subtitle files from the captions track (`--burn-captions` draws them into the picture instead, as `NAME-LANG-captioned.mp4`), and a `.jpg` thumbnail of the `poster` frame set in film.config.json; open the printed capture page.
17
- - `npm run review -- --animation NAME`: frame, text, seeking and soundtrack checks.
17
+ - `npm run export:mp4 -- --animation NAME`: verified MP4 without burned-in captions, plus `.srt`/`.vtt` subtitle files from the captions track (`--burn-captions` draws them into the picture instead, as `NAME-LANG-captioned.mp4`), and a `.jpg` thumbnail of the `poster` frame set in film.config.json. Add `--headless` to capture in local headless Chrome; otherwise open the printed capture page.
18
+ - `npm run review -- --animation NAME --headless`: frame, text, layout (overlap, safe area, text size), seeking and soundtrack checks, one line per finding.
19
+ - `npm run frames -- --animation NAME --at 1.2,4.8`: labelled contact sheets in `production/review/frames/` plus layout notes for those times.
20
+ - `npm run audio:vo|audio:music|audio:mix -- --animation NAME`: cut and place the voiceover (`timing.json`), fit the music bed, and master the soundtrack.
21
+ - `npm run inspect:site -- URL`: brand evidence (screenshot, colors, fonts, logos, text) in `branding/source/HOST/`.
18
22
  - `npm run example -- STYLE_ID --animation NAME`: install a study only in an untouched selected film.
19
23
  - `animaguru submit animations/NAME --yes`: private hosting; choose recipients/public sharing from the user's request.
20
24
  - `animaguru cloud push -m "MESSAGE"`: in a linked workspace, save every film and the shared kit as a cloud version; `animaguru cloud pull` brings in teammates' versions.
@@ -13,7 +13,12 @@
13
13
  "export:mp4": "node scripts/export-mp4.mjs",
14
14
  "export:html": "node scripts/export-html.mjs",
15
15
  "example": "node scripts/install-example.mjs",
16
- "review": "node scripts/review.mjs"
16
+ "review": "node scripts/review.mjs",
17
+ "frames": "node scripts/frames.mjs",
18
+ "audio:vo": "node scripts/audio-vo.mjs",
19
+ "audio:music": "node scripts/audio-music.mjs",
20
+ "audio:mix": "node scripts/audio-mix.mjs",
21
+ "inspect:site": "node scripts/site-inspect.mjs"
17
22
  },
18
23
  "dependencies": {
19
24
  "pixi.js": "8.22.0"
@@ -22,6 +27,7 @@
22
27
  "@ffmpeg-installer/ffmpeg": "1.1.0",
23
28
  "@ffprobe-installer/ffprobe": "2.1.2",
24
29
  "fflate": "0.8.3",
30
+ "playwright-core": "1.64.0",
25
31
  "typescript": "5.9.3",
26
32
  "vite": "8.3.3"
27
33
  }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * npm run audio:mix -- [--animation NAME] [--config production/audio/mix.json]
3
+ * Edits the music bed (production/audio/music-edit.json), ducks it under the voiceover, normalises the mix in two
4
+ * loudnorm passes (−14 LUFS, −2 dBTP by default) and encodes AAC for the film; then points film.config.json at it.
5
+ */
6
+ import { mkdir, readFile, writeFile, access } from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { projectArguments, projectContext } from "./project-context.mjs";
10
+ import { ffmpeg, measureLoudness, parseFlags, projectPath, readJson, round } from "./audio-tools.mjs";
11
+
12
+ const RATE = 48000;
13
+ export const MIX_DEFAULTS = { musicDb: -9, duck: { threshold: 0.03, ratio: 6, attack: 20, release: 350 }, loudness: { i: -14, tp: -2, lra: 9 } };
14
+
15
+ /** The music part of the graph for input `input`, as edited by audio:music (a trim or a bar-aligned cut). */
16
+ export function musicGraph(edit, { input = 0, duration, musicDb = MIX_DEFAULTS.musicDb, label = "music" }) {
17
+ const headIn = Math.max(0, Number(edit?.headIn ?? 0)), fadeIn = Number(edit?.fadeIn ?? (headIn > 0.05 ? 0.25 : 0.02)), fadeOut = Number(edit?.fadeOut ?? 0.6);
18
+ const ending = `atrim=0:${duration},apad=whole_dur=${duration},afade=t=out:st=${round(Math.max(0, duration - fadeOut))}:d=${fadeOut},volume=${musicDb}dB,aformat=sample_fmts=fltp:sample_rates=${RATE}:channel_layouts=stereo[${label}]`;
19
+ if (edit?.type === "cut") {
20
+ const crossfade = Number(edit.crossfade ?? 0.02), cutA = Number(edit.cutA), cutB = Number(edit.cutB);
21
+ if (!(cutA > headIn) || !(cutB >= 0)) throw new Error("A music cut needs cutA after headIn and a non-negative cutB.");
22
+ return [
23
+ `[${input}:a]asplit=2[${label}A][${label}B]`,
24
+ `[${label}A]atrim=${headIn}:${round(cutA + crossfade, 4)},asetpts=PTS-STARTPTS,aresample=${RATE},afade=t=in:d=${fadeIn}[${label}Head]`,
25
+ `[${label}B]atrim=${cutB},asetpts=PTS-STARTPTS,aresample=${RATE}[${label}Tail]`,
26
+ `[${label}Head][${label}Tail]acrossfade=d=${crossfade}:c1=tri:c2=tri,${ending}`,
27
+ ];
28
+ }
29
+ return [`[${input}:a]atrim=${headIn},asetpts=PTS-STARTPTS,aresample=${RATE},afade=t=in:d=${fadeIn},${ending}`];
30
+ }
31
+
32
+ /** The whole pre-loudness graph: optional music (input 0) and optional voiceover (next input), ending at [mix]. */
33
+ export function mixGraph({ music, vo, edit, duration, musicDb = MIX_DEFAULTS.musicDb, duck = MIX_DEFAULTS.duck }) {
34
+ if (!music && !vo) throw new Error("Nothing to mix: set a voiceover track, a music bed, or both.");
35
+ const filters = [];
36
+ const voInput = music ? 1 : 0;
37
+ if (music) filters.push(...musicGraph(edit, { input: 0, duration, musicDb }));
38
+ if (vo) {
39
+ // Mono VO, a little presence and gentle compression, centred in stereo.
40
+ filters.push(`[${voInput}:a]aresample=${RATE},aformat=channel_layouts=mono,highpass=f=80,equalizer=f=3200:t=q:w=1.2:g=2,acompressor=threshold=-20dB:ratio=3:attack=5:release=120:makeup=3dB,pan=stereo|c0=c0|c1=c0,aformat=sample_fmts=fltp:channel_layouts=stereo,apad=whole_dur=${duration},atrim=0:${duration}${music ? ",asplit=2[vo][key]" : "[mix]"}`);
41
+ }
42
+ if (music && vo) {
43
+ filters.push(`[music][key]sidechaincompress=threshold=${duck.threshold}:ratio=${duck.ratio}:attack=${duck.attack}:release=${duck.release}:makeup=1[ducked]`);
44
+ filters.push(`[ducked][vo]amix=inputs=2:normalize=0,atrim=0:${duration}[mix]`);
45
+ } else if (music) filters.push(`[music]aformat=channel_layouts=stereo[mix]`);
46
+ return filters.join(";");
47
+ }
48
+
49
+ async function exists(file) { try { await access(file); return true; } catch { return false; } }
50
+
51
+ async function main() {
52
+ const root = fileURLToPath(new URL("../", import.meta.url));
53
+ const selection = projectArguments(process.argv.slice(2));
54
+ const flags = parseFlags(selection.rest, { config: "string" });
55
+ const context = await projectContext(root, selection.animation);
56
+ const base = context.projectRoot, config = context.config, duration = Number(config.duration);
57
+ const mixFile = projectPath(base, flags.config ?? "production/audio/mix.json", "--config");
58
+ const mix = await readJson(mixFile, {});
59
+ const editFile = path.join(base, "production/audio/music-edit.json");
60
+ const plan = await readJson(editFile, null);
61
+ const voPath = mix.vo === null ? null : projectPath(base, mix.vo ?? "production/audio/work/vo-track.wav", "vo");
62
+ const vo = voPath && await exists(voPath) ? voPath : null;
63
+ if (mix.vo && !vo) throw new Error(`Voiceover not found: ${mix.vo}. Run npm run audio:vo first.`);
64
+ const musicRelative = mix.music === null ? null : mix.music ?? plan?.file ?? null;
65
+ const music = musicRelative ? projectPath(base, musicRelative, "music") : null;
66
+ if (music && !await exists(music)) throw new Error(`Music bed not found: ${musicRelative}.`);
67
+ const edit = mix.edit ?? plan?.edit ?? null;
68
+ if (music && !edit) console.log("No music edit found; the bed plays from its start and fades out. Run npm run audio:music for a fitted edit.");
69
+ const loudness = { ...MIX_DEFAULTS.loudness, ...mix.loudness };
70
+ const output = projectPath(base, mix.output ?? `public/assets/${config.slug}-mix.m4a`, "output");
71
+ const publicRoot = path.resolve(base, "public");
72
+ if (!output.startsWith(publicRoot + path.sep)) throw new Error("The mix output must be inside the animation's public/ folder so the player and exporter can load it.");
73
+ if (!output.endsWith(".m4a")) throw new Error("Write the mix as an .m4a file (AAC).");
74
+
75
+ const work = path.join(base, "production/audio/work");
76
+ await mkdir(work, { recursive: true });
77
+ await mkdir(path.dirname(output), { recursive: true });
78
+ const premaster = path.join(work, "premaster.wav"), master = path.join(work, "master.wav");
79
+ const graph = mixGraph({ music, vo, edit, duration, musicDb: mix.musicDb ?? MIX_DEFAULTS.musicDb, duck: { ...MIX_DEFAULTS.duck, ...mix.duck } });
80
+ await ffmpeg(["-y", "-loglevel", "error", ...(music ? ["-i", music] : []), ...(vo ? ["-i", vo] : []), "-filter_complex", graph, "-map", "[mix]", "-ar", String(RATE), "-ac", "2", "-c:a", "pcm_s24le", "-t", String(duration), premaster]);
81
+ // Two-pass loudnorm: measure, then normalise linearly with the measured values.
82
+ const first = await measureLoudness(premaster, loudness);
83
+ const second = `loudnorm=I=${loudness.i}:TP=${loudness.tp}:LRA=${loudness.lra}:measured_I=${first.integrated}:measured_TP=${first.truePeak}:measured_LRA=${first.range}:measured_thresh=${first.threshold}:offset=${first.offset}:linear=true`;
84
+ await ffmpeg(["-y", "-loglevel", "error", "-i", premaster, "-af", second, "-ar", String(RATE), "-ac", "2", "-c:a", "pcm_s24le", "-t", String(duration), master]);
85
+ await ffmpeg(["-y", "-loglevel", "error", "-i", master, "-c:a", "aac", "-b:a", "192k", "-ar", String(RATE), "-ac", "2", "-movflags", "+faststart", output]);
86
+ const result = await measureLoudness(output, loudness);
87
+
88
+ const configPath = path.join(base, "film.config.json");
89
+ const filmConfig = JSON.parse(await readFile(configPath, "utf8"));
90
+ const audio = path.relative(publicRoot, output).split(path.sep).join("/");
91
+ if (filmConfig.audio !== audio) { filmConfig.audio = audio; await writeFile(configPath, JSON.stringify(filmConfig, null, 2) + "\n"); }
92
+
93
+ const parts = [vo ? `voiceover ${path.relative(base, vo)}` : null, music ? `music ${path.relative(base, music)}${edit?.type === "cut" ? ` (${edit.headIn}–${edit.cutA} s → ${edit.cutB} s)` : edit ? ` (from ${edit.headIn} s)` : ""}` : null].filter(Boolean);
94
+ console.log(`Mixed ${parts.join(" + ")}${music && vo ? `, music at ${mix.musicDb ?? MIX_DEFAULTS.musicDb} dB, ducked under the voice` : ""}.`);
95
+ console.log(`Before normalising: ${round(first.integrated, 1)} LUFS, ${round(first.truePeak, 1)} dBTP. Encoded AAC: ${round(result.integrated, 1)} LUFS, ${round(result.truePeak, 1)} dBTP (target ${loudness.i} LUFS, ${loudness.tp} dBTP).`);
96
+ if (result.truePeak > -1) console.log("Warning: the encoded true peak is above -1 dBTP; lower loudness.tp in production/audio/mix.json and rerun.");
97
+ if (Math.abs(result.integrated - loudness.i) > 1) console.log("Warning: the encoded loudness is more than 1 LU off target; check for long silences or a very dynamic bed.");
98
+ console.log(`Wrote ${path.relative(base, output)}; film.config.json audio is "${audio}". Listen once with npm run dev before exporting.`);
99
+ }
100
+
101
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
@@ -0,0 +1,175 @@
1
+ /**
2
+ * npm run audio:music -- [--animation NAME] --file production/audio/music/BED.mp3 [--length 45] [--drop 17.1] [--end-hit 43]
3
+ * Finds the bed's tempo, bars, sections and final hit, then suggests a bar-aligned edit that fits the film:
4
+ * a downbeat at --drop, and the bed's own ending near --end-hit. Writes production/audio/music-edit.json for audio:mix.
5
+ */
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { projectArguments, projectContext } from "./project-context.mjs";
9
+ import { chromaFrames, cosine, decodePcm, detectBeats, onsetEnvelope, parseFlags, projectPath, round, writeJson } from "./audio-tools.mjs";
10
+
11
+ const SAMPLE_RATE = 22050;
12
+
13
+ function spanDb(pcm, sampleRate, from, to) {
14
+ let energy = 0, count = 0;
15
+ for (let index = Math.max(0, Math.floor(from * sampleRate)), end = Math.min(pcm.length, Math.floor(to * sampleRate)); index < end; index++) { energy += pcm[index] * pcm[index]; count++; }
16
+ return count ? 10 * Math.log10(energy / count + 1e-12) : -120;
17
+ }
18
+
19
+ /**
20
+ * Downbeat phase: a section entry is a rise in energy, sharpest at its true bar line. Each local maximum of the
21
+ * 8-beat after/before energy difference votes for its beat position within the bar. Falls (breakdowns, the final
22
+ * decay and its reverb tail) blur across bar lines, so they only vote when the bed has no rises.
23
+ */
24
+ export function downbeatOffset(beatDb, { beatsPerBar = 4, window = 8, minStep = 3, audibleBeats = beatDb.length } = {}) {
25
+ const mean = (from, to) => { let sum = 0, count = 0; for (let index = Math.max(0, from); index < Math.min(beatDb.length, to); index++) { sum += 10 ** (beatDb[index] / 10); count++; } return count ? 10 * Math.log10(sum / count + 1e-12) : -120; };
26
+ const step = beatDb.map((_, index) => mean(index, index + window) - mean(index - window, index));
27
+ const tally = (sign) => {
28
+ const votes = new Array(beatsPerBar).fill(0);
29
+ for (let index = window; index < Math.min(step.length - 2, audibleBeats - window); index++) {
30
+ const value = sign * step[index];
31
+ if (value < minStep) continue;
32
+ let peak = true;
33
+ for (let other = Math.max(0, index - 2); other <= index + 2; other++) if (other !== index && sign * step[other] > value) peak = false;
34
+ if (peak) votes[index % beatsPerBar] += Math.min(value, 8);
35
+ }
36
+ return votes;
37
+ };
38
+ let votes = tally(1);
39
+ if (!votes.some(Boolean)) votes = tally(-1);
40
+ const best = votes.indexOf(Math.max(...votes));
41
+ return { offset: votes[best] > 0 ? best : 0, votes: votes.map((value) => round(value, 1)) };
42
+ }
43
+
44
+ /** Tempo, bars with energy and chroma, sections, lead-in, the final hit and where the sound ends. */
45
+ export function analyzeMusic(pcm, sampleRate = SAMPLE_RATE) {
46
+ const duration = pcm.length / sampleRate;
47
+ const envelope = onsetEnvelope(pcm, sampleRate);
48
+ const beat = detectBeats(envelope.flux, envelope.fps, envelope.offset);
49
+ const beats = [];
50
+ for (let time = beat.phase; time + beat.period * 0.5 < duration; time += beat.period) beats.push(time);
51
+ const levels = Array.from(envelope.rms, (value) => 20 * Math.log10(value + 1e-9)), peak = Math.max(...levels);
52
+ const frameTime = (index) => index / envelope.fps + envelope.offset;
53
+ const audible = levels.map((value, index) => value > peak - 35 ? index : -1).filter((index) => index >= 0);
54
+ const leadIn = audible.length ? Math.max(0, frameTime(audible[0]) - 0.05) : 0;
55
+ const soundEnd = audible.length ? Math.min(duration, frameTime(audible.at(-1))) : duration;
56
+ const beatDb = beats.map((time) => spanDb(pcm, sampleRate, time, time + beat.period));
57
+ const { offset } = downbeatOffset(beatDb, { audibleBeats: beats.filter((time) => time < soundEnd).length });
58
+ const downbeats = beats.filter((_, index) => index % 4 === offset);
59
+ if (offset > 0 && beats[offset] - beat.period * 4 >= -beat.period * 0.5) downbeats.unshift(beats[offset] - beat.period * 4);
60
+ const chroma = chromaFrames(pcm, sampleRate);
61
+ const bars = downbeats.map((start, index) => {
62
+ const end = downbeats[index + 1] ?? Math.min(duration, start + beat.period * 4), profile = new Float64Array(12);
63
+ for (const frame of chroma) if (frame.time >= start && frame.time < end) for (let bin = 0; bin < 12; bin++) profile[bin] += frame.chroma[bin];
64
+ return { start, end, db: spanDb(pcm, sampleRate, start, end), chroma: profile };
65
+ });
66
+ // Sections start where the bar energy steps by 3 dB or more (or the harmony changes clearly).
67
+ const sections = [];
68
+ for (const [index, bar] of bars.entries()) {
69
+ const previous = bars[index - 1];
70
+ if (!previous || Math.abs(bar.db - previous.db) >= 3 || cosine(bar.chroma, previous.chroma) < 0.8) sections.push({ start: bar.start, end: bar.end, bars: 1, db: bar.db });
71
+ else { const section = sections.at(-1); section.end = bar.end; section.db = 10 * Math.log10((10 ** (section.db / 10) * section.bars + 10 ** (bar.db / 10)) / (section.bars + 1)); section.bars++; }
72
+ }
73
+ // The final hit: the first bar line after which the energy falls 6 dB or more and stays down to the end.
74
+ let finalHit = null;
75
+ for (let index = 1; index < bars.length; index++) {
76
+ if (bars[index].start > soundEnd) break;
77
+ if (bars[index - 1].db - bars[index].db >= 6 && bars.slice(index).every((bar) => bar.db < bars[index - 1].db - 4)) { finalHit = bars[index].start; break; }
78
+ }
79
+ if (finalHit === null) finalHit = (bars.filter((bar) => bar.start <= soundEnd - 1).at(-1) ?? bars.at(-1) ?? { start: soundEnd }).start;
80
+ return { duration, bpm: beat.bpm, beat: beat.period, phase: beat.phase, downbeatOffset: offset, bars, sections, leadIn, finalHit, soundEnd };
81
+ }
82
+
83
+ const similarity = (a, b) => (a && b ? cosine(a.chroma, b.chroma) - Math.abs(a.db - b.db) / 12 : 0);
84
+
85
+ /**
86
+ * Bar-aligned edits that fit the film. With `drop`, a downbeat (cutA) lands at that film time and the tail resumes at
87
+ * a matching downbeat (cutB) chosen so the bed's final hit lands near `endHit`. Without `drop`, the cut may fall
88
+ * anywhere between 20% and 75% of the film. Short beds are trimmed and faded instead.
89
+ */
90
+ export function planMusicEdit(analysis, { length, drop = null, endHit = null, preRoll = 0.04, crossfade = 0.02, fadeOut = 0.6 } = {}) {
91
+ if (!(length > 0)) throw new Error("Pass the film length in seconds.");
92
+ const decay = Math.max(0, analysis.soundEnd - analysis.finalHit);
93
+ const target = endHit ?? Math.max(length - 4, length - decay - 0.3);
94
+ const { bars } = analysis, candidates = [];
95
+ const tail = (filmAtCut, cutBar) => {
96
+ const finalFilm = filmAtCut + analysis.finalHit - cutBar.start, endFilm = filmAtCut + analysis.soundEnd - cutBar.start;
97
+ return { finalFilm, endFilm, landing: finalFilm - target, shortfall: Math.max(0, length - 0.3 - endFilm) };
98
+ };
99
+ // 1. Trim: one continuous stretch of the bed (no cut), so its continuity is perfect.
100
+ const trim = (headIn, note, rise = 0, whole = false) => {
101
+ const landing = analysis.finalHit - headIn - target, musicEnd = analysis.soundEnd - headIn;
102
+ const late = headIn > analysis.leadIn + 0.5 ? 0.1 : 0;
103
+ candidates.push({ type: "trim", headIn, score: (whole ? 2 : 1.5) + 0.08 * Math.min(rise, 8) - Math.abs(landing) - Math.max(0, length - 0.3 - musicEnd) - 0.01 * headIn - late,
104
+ film: { drop, finalHit: analysis.finalHit - headIn, musicEnd }, landing, note });
105
+ };
106
+ if (analysis.soundEnd - analysis.leadIn <= length + 0.25) trim(analysis.leadIn, "The bed fits the film; it plays from its start and fades at the end.", 0, true);
107
+ for (const [index, bar] of bars.entries()) {
108
+ const headIn = drop === null ? bar.start : bar.start - drop;
109
+ if (headIn < 0 || Math.abs(analysis.finalHit - headIn - target) > analysis.beat * 4) continue;
110
+ trim(headIn, drop === null ? `Starts at bar ${index + 1}.` : `Starts ${round(headIn, 2)} s in so bar ${index + 1} lands on the drop.`, drop === null ? 0 : Math.max(0, bar.db - (bars[index - 1]?.db ?? bar.db)));
111
+ }
112
+ // 2. Cut: head from headIn to bar A, then jump to bar B whose music matches what bar A would have played.
113
+ // A forward jump shortens the bed; a backward jump repeats bars to lengthen it.
114
+ const barLength = analysis.beat * 4;
115
+ for (const [a, barA] of bars.entries()) {
116
+ if (barA.start > analysis.finalHit) break;
117
+ let headIn, filmAtCut;
118
+ if (drop !== null) { headIn = barA.start - drop; filmAtCut = drop; if (headIn < 0 || headIn > 30) continue; }
119
+ else { headIn = analysis.leadIn; filmAtCut = barA.start - headIn; if (filmAtCut < 0.2 * length || filmAtCut > 0.75 * length) continue; }
120
+ const wanted = analysis.finalHit - (target - filmAtCut);
121
+ for (const [b, barB] of bars.entries()) {
122
+ if (Math.abs(b - a) <= 2 || barB.start > analysis.finalHit || Math.abs(barB.start - wanted) > barLength * 1.5) continue;
123
+ let start = headIn, at = filmAtCut, fit = tail(at, barB);
124
+ // Without a fixed drop, starting up to a bar later into the intro removes a late landing exactly.
125
+ if (drop === null && fit.landing > 0 && fit.landing < barLength) { start = headIn + fit.landing; at = barA.start - start; fit = tail(at, barB); }
126
+ const entry = Math.max(0, barA.db - (bars[a - 1]?.db ?? barA.db)), rise = drop === null ? 0 : Math.max(0, barB.db - (bars[a - 1]?.db ?? barB.db));
127
+ const score = similarity(barA, barB) + 0.5 * similarity(bars[a - 1], bars[b - 1]) + 0.06 * Math.min(entry, 8) + 0.08 * Math.min(rise, 8)
128
+ - Math.abs(fit.landing) - fit.shortfall - 0.01 * start - (start > analysis.leadIn + 0.05 && drop === null ? 0.05 : 0);
129
+ candidates.push({ type: "cut", headIn: start, cutA: barA.start - preRoll, cutB: barB.start - preRoll, score, film: { drop: at, finalHit: fit.finalFilm, musicEnd: fit.endFilm }, landing: fit.landing,
130
+ note: `Bar ${a + 1} → bar ${b + 1}${b < a ? ", repeating bars" : ""}.` });
131
+ }
132
+ }
133
+ if (!candidates.length) candidates.push({ type: "trim", headIn: analysis.leadIn, score: 0, film: { drop: null, finalHit: analysis.finalHit - analysis.leadIn, musicEnd: analysis.soundEnd - analysis.leadIn }, landing: analysis.finalHit - analysis.leadIn - target, note: "No bar-aligned edit fits; trimmed with a fade-out." });
134
+ candidates.sort((x, y) => y.score - x.score);
135
+ const tidy = (candidate) => ({ type: candidate.type, headIn: round(Math.max(0, candidate.headIn)), ...(candidate.type === "cut" ? { cutA: round(candidate.cutA), cutB: round(candidate.cutB), crossfade } : {}),
136
+ fadeIn: candidate.headIn > 0.05 ? 0.25 : 0.02, fadeOut, score: round(candidate.score), landingError: round(candidate.landing),
137
+ film: { drop: candidate.film.drop === null ? null : round(candidate.film.drop), finalHit: round(candidate.film.finalHit), musicEnd: round(candidate.film.musicEnd) }, note: candidate.note });
138
+ const unique = [];
139
+ for (const candidate of candidates) if (!unique.some((item) => item.type === candidate.type && Math.abs(item.headIn - candidate.headIn) < 1e-3 && item.cutB === candidate.cutB)) unique.push(candidate);
140
+ return { target: round(target), edit: tidy(unique[0]), candidates: unique.slice(1, 4).map(tidy) };
141
+ }
142
+
143
+ async function main() {
144
+ const root = fileURLToPath(new URL("../", import.meta.url));
145
+ const selection = projectArguments(process.argv.slice(2));
146
+ const flags = parseFlags(selection.rest, { file: "string", length: "number", drop: "number", "end-hit": "number", "pre-roll": "number" });
147
+ const context = await projectContext(root, selection.animation);
148
+ const base = context.projectRoot;
149
+ if (!flags.file) throw new Error("Usage: npm run audio:music -- [--animation NAME] --file production/audio/music/BED.mp3 [--length 45] [--drop 17.1] [--end-hit 43]");
150
+ const file = projectPath(base, flags.file, "--file");
151
+ const length = flags.length ?? Number(context.config.duration);
152
+ const pcm = await decodePcm(file, SAMPLE_RATE);
153
+ const analysis = analyzeMusic(pcm);
154
+ const plan = planMusicEdit(analysis, { length, drop: flags.drop ?? null, endHit: flags["end-hit"] ?? null, preRoll: flags["pre-roll"] ?? 0.04 });
155
+ const output = path.join(base, "production/audio/music-edit.json");
156
+ await writeJson(output, {
157
+ file: path.relative(base, file).split(path.sep).join("/"),
158
+ request: { length, drop: flags.drop ?? null, endHit: flags["end-hit"] ?? null, targetEndHit: plan.target },
159
+ analysis: { duration: round(analysis.duration), bpm: round(analysis.bpm, 2), beat: round(analysis.beat, 4), phase: round(analysis.phase), bars: analysis.bars.length,
160
+ downbeats: analysis.bars.map((bar) => round(bar.start)), leadIn: round(analysis.leadIn), finalHit: round(analysis.finalHit), soundEnd: round(analysis.soundEnd),
161
+ sections: analysis.sections.map((section) => ({ start: round(section.start, 2), end: round(section.end, 2), bars: section.bars, db: round(section.db, 1) })) },
162
+ edit: plan.edit, candidates: plan.candidates,
163
+ });
164
+ const describe = (edit) => edit.type === "cut"
165
+ ? `play ${edit.headIn}–${edit.cutA} s, cut to ${edit.cutB} s (${edit.note}); drop at film ${edit.film.drop} s, final hit at ${edit.film.finalHit} s (${edit.landingError >= 0 ? "+" : ""}${edit.landingError} s), sound to ${edit.film.musicEnd} s; score ${edit.score}`
166
+ : `play from ${edit.headIn} s without a cut (${edit.note}); final hit at film ${edit.film.finalHit} s (${edit.landingError >= 0 ? "+" : ""}${edit.landingError} s), sound to ${edit.film.musicEnd} s; score ${edit.score}`;
167
+ console.log(`Music: ${round(analysis.duration, 2)} s, ${round(analysis.bpm, 1)} BPM (beat ${round(analysis.beat, 4)} s), ${analysis.bars.length} bars, lead-in ${round(analysis.leadIn, 2)} s, final hit ${round(analysis.finalHit, 2)} s, sound ends ${round(analysis.soundEnd, 2)} s.`);
168
+ console.log(`Sections: ${analysis.sections.map((section) => `${round(section.start, 1)}–${round(section.end, 1)} (${section.bars} bars, ${round(section.db, 0)} dB)`).join(", ")}`);
169
+ console.log(`Target: final hit at film ${plan.target} s of ${length} s${flags.drop !== undefined ? `, drop at ${flags.drop} s` : ""}.`);
170
+ console.log(`Chosen: ${describe(plan.edit)}`);
171
+ for (const candidate of plan.candidates) console.log(` alt: ${describe(candidate)}`);
172
+ console.log(`Wrote ${path.relative(base, output)}. Run npm run audio:mix to build the soundtrack (edit the "edit" block to pick an alternative).`);
173
+ }
174
+
175
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();