diff --git a/.claude/skills/release-video/SKILL.md b/.claude/skills/release-video/SKILL.md index 3a90ed705..a68ebab71 100644 --- a/.claude/skills/release-video/SKILL.md +++ b/.claude/skills/release-video/SKILL.md @@ -26,7 +26,7 @@ Run the flow above. Read only the next action file. ## Transversal rules - The plan lives in `aidd_docs/release-video/scenes.json`; the check and the renderer are in `assets/`, their tests in `scripts/__tests__/`. -- Show only what the product does; mark every invented demo value as an example on screen. +- Show only what the product does; mark every invented figure as an example on screen; a demo of text alone, plainly a session, needs no mark. - A viewer may watch with the sound off and never read the release notes: the picture carries the message, the sound only makes it lively. - Never reuse the audio of a published video: it carries that video's own sounds, which then play at moments that mean nothing here. - Keep product choices with the user, music included: it is judged by listening, which is the user's. diff --git a/.claude/skills/release-video/actions/01-plan.md b/.claude/skills/release-video/actions/01-plan.md index 91641523d..49a4047b0 100644 --- a/.claude/skills/release-video/actions/01-plan.md +++ b/.claude/skills/release-video/actions/01-plan.md @@ -4,7 +4,7 @@ Cover every feature shipped since the last release with one scene, or one writte ## Input -The repository at the release candidate. +The repository at the release candidate, read again from the last release tag, never from the previous plan. ## Output @@ -12,15 +12,17 @@ The repository at the release candidate. ## Process -1. **List.** Run the check. Each surface under "Scènes manquantes" needs a scene or a skip. +1. **List.** Read every commit since the last release tag, whatever its type: `git log ..HEAD --no-merges`. Keep what a user would notice, a `refactor:` or `docs:` that changes how a skill behaves included. Then run the check: it sees only `feat:` commits, so it proves nothing is missing from those, not that the plan is complete. Each surface under "Scènes manquantes" needs a scene or a skip. 2. **Read.** Open the skill or command each surface names; take its real behavior, never a guess. 3. **Draft.** Fill the [plan template](../assets/scenes-template.json), one scene per surface, with the templates, rows and fields of [scenes.md](../references/scenes.md). - - Write every title, lead, tag and highlight in everyday words, outcome first: what changes for the user, not how it works. `Count tokens in Codex too` becomes `See what your AI costs, in Codex too`. + - Write every title, tag and highlight in everyday words, outcome first: what the user gains, not what the feature is or how it works. `Count tokens in Codex too` becomes `See what your AI costs, in Codex too`. A title that names a feature without saying what it brings is rewritten. + - A measured gain goes in the title as one figure computed from the scene's own before and after (`38% leaner Brainstorm for small models`); the scene carries no row of numbers beside it. + - Leave `lead` out: the picture must say it, a subtitle under the title competes with it. Add one only when a scene cannot be understood without it. - Only the demo window shows real commands. 4. **Skip.** Move a surface to `skipped` with a reason only when a user would not notice it. 5. **Fixes.** List the `fix:` commits since the last release tag. A fix is worth a scene only when its before and after can be shown on screen; a fix that only makes figures or states more reliable has none. Group the ones that qualify into one `bullet-grid` scene; the usual outcome is no scene. -6. **Contributors.** Name who shipped the release with `gh api repos/{owner}/{repo}/compare/...next --jq '[.commits[].author.login] | unique'`. Drop bots, keep every human author whatever the type of the commit (a docs commit counts). Put the logins in one `contributors` scene, last. -7. **Intro.** Fill `intro.highlights`, the tags that name the release's big features, so that someone who never used AIDD sees at a glance what it brings: up to three, each a short clear sentence of five words at most, starting from what the user gets (`Telemetry now covers Codex`, not `Report by tool`). Leave `tagline` out: a phrase above the tags says less than they do. Take the facts from the release announcement, so the video and the message say the same thing. +6. **Contributors.** Name who shipped the release with `gh api repos/{owner}/{repo}/compare/...next --jq '[.commits[].author.login] | unique'`. Drop bots, keep every human author whatever the type of the commit (a docs commit counts). Add whoever opened an issue closed since the last tag: `gh api "repos/{owner}/{repo}/issues?state=closed&since=" --paginate --jq '[.[] | select(.pull_request == null) | .user.login] | unique'`, because someone who reported what a developer then fixed shipped it too. Put the logins in one `contributors` scene, last. +7. **Intro.** Fill `intro.highlights`, the tags that name the release's big features, so that someone who never used AIDD sees at a glance what it brings: up to five, each a short clear sentence of five words at most, starting from what the user gets (`Telemetry now covers Codex`, not `Report by tool`). Leave `tagline` out: a phrase above the tags says less than they do. Take the facts from the release announcement, so the video and the message say the same thing. 8. **YouTube text.** Fill `youtube` in French, in the words of the published videos: `themes` name the release's themes, `programme` has one line per covered surface saying what it does, as in `compter ce que coûte votre travail, par tâche`. `assets/youtube-metadata.js` assembles the title and the description in their published layout. 9. **Judge.** Read the plan against [judge.md](../references/judge.md), which the check cannot do. 10. **Check.** Rerun the check until it passes, then show the plan and fold corrections. @@ -30,12 +32,17 @@ The repository at the release candidate. | Case | Pass | | --- | --- | | Uncovered feature | the check names it; the plan gains a scene or a skip | +| Skill changed by a `refactor:` or `docs:` commit | read from the log, not the check; a scene or a written reason | +| Plan left from the previous release | scenes rebuilt from this release's log; none kept because they were there | | Skip without reason | the check fails; a reason is added | | Release with no `feat:` commit | no video; plan left empty | -| Invented demo value | marked as an example in the scene | +| Invented figure | marked as an example in the scene | +| Demo of text alone | no mark: a session whose lines are plainly illustrative | | `fix:` commits a user would notice | one grouped scene; internal ones left out | -| Contributor list | bots dropped; one `contributors` scene, last | -| Scene without a lead | refused on review: a viewer could not tell what it brings | +| Contributor list | bots dropped; commit authors and openers of closed issues; one `contributors` scene, last | +| Scene with a measured gain | one title carrying the figure, computed from the scene's own numbers; no figures row | +| Scene that needs a lead to be understood | the picture is redrawn until it says it alone; a lead is the last resort | +| Title that names a feature, not what it brings | refused on review; rewritten from the user's gain | | Internal word or text over the limits | the check fails naming the word or the field and where it is | | Theme split by another one | the check fails naming the theme; its scenes are brought together | | Surface with no `programme` line, or a title over 100 characters | the check fails naming it | diff --git a/.claude/skills/release-video/actions/02-render.md b/.claude/skills/release-video/actions/02-render.md index e7dbee1a6..711a4a0b7 100644 --- a/.claude/skills/release-video/actions/02-render.md +++ b/.claude/skills/release-video/actions/02-render.md @@ -29,5 +29,5 @@ Turn the accepted plan into the release video and look at it. | Text that does not fit a window | seen on a frame, fixed in the plan, rendered again | | Version typed on screen | the real release version, not a placeholder | | Frame read with the sound off | the picture alone says what the release brings | -| Levels measured | the hook is loud, the bed about 10 dB lower under the scenes and the outro, the ending no louder than the bed, the interface sounds stand out of the bed | +| Levels measured | the hook is the loudest, the bed about 4 dB lower under the scenes and the outro, the ending no louder than the bed, the interface sounds stand out of the bed | | Finished render | MP4 present, duration reported, nothing published | diff --git a/.claude/skills/release-video/actions/03-compose-music.md b/.claude/skills/release-video/actions/03-compose-music.md index 58a8fba59..59203a347 100644 --- a/.claude/skills/release-video/actions/03-compose-music.md +++ b/.claude/skills/release-video/actions/03-compose-music.md @@ -1,6 +1,6 @@ # 03 - Compose music -Write a new original 8-bit track when the current one tires or does not fit. +Write a new original track when the current one tires or does not fit. ## Input @@ -12,9 +12,9 @@ What to change, in the user's words: slower, calmer, another mood. ## Process -1. **Shape.** Edit `assets/compose-music.py`: tempo, chords and levels, keeping the three parts of [sound.md](../references/sound.md). +1. **Shape.** Edit `assets/compose-music.py`: tempo, chords and levels, keeping the three parts and the bar lengths of [sound.md](../references/sound.md). 2. **Compose.** Run `python3 .claude/skills/release-video/assets/compose-music.py `; it needs numpy. -3. **Encode.** Run `ffmpeg -i -b:a 128k .claude/skills/release-video/assets/music.mp3`. +3. **Encode.** Run `ffmpeg -i -b:a 160k .claude/skills/release-video/assets/music.mp3`. 4. **Hear.** Render per [render](02-render.md) and give the user the MP4 and the track to listen to. - Keep the track only on the user's word. @@ -22,7 +22,7 @@ What to change, in the user's words: slower, calmer, another mood. | Case | Pass | | --- | --- | -| Script run | a wav of about 44 seconds, with hook, bed and ending levels printed | +| Script run | a stereo wav of about 27 seconds, with hook, bed and ending levels printed | | Track used by a render | the measured levels keep the loud, calm, loud shape | | Track from another video | refused: only an original composition is kept | | User has not listened | the track is not declared good, only measured | diff --git a/.claude/skills/release-video/assets/check-release-video.js b/.claude/skills/release-video/assets/check-release-video.js index e2f9d20c0..26b6a1099 100644 --- a/.claude/skills/release-video/assets/check-release-video.js +++ b/.claude/skills/release-video/assets/check-release-video.js @@ -71,7 +71,7 @@ const withoutGitEnv = () => Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_"))); const MAX_TAGLINE_WORDS = 6; -const MAX_HIGHLIGHTS = 3; +const MAX_HIGHLIGHTS = 5; const MAX_HIGHLIGHT_WORDS = 5; const MAX_LEAD_WORDS = 14; diff --git a/.claude/skills/release-video/assets/compose-music.py b/.claude/skills/release-video/assets/compose-music.py index bbb193bb2..38f03b68c 100644 --- a/.claude/skills/release-video/assets/compose-music.py +++ b/.claude/skills/release-video/assets/compose-music.py @@ -1,60 +1,183 @@ -"""Compose the release video's 8-bit track: pulse waves and a triangle bass, no noise, no drums. +"""Compose the release video's track: felt piano and soft pads over a gentle pulse, in stereo. -Three parts the renderer relies on: a loud hook (3 bars), a calm bed (16 bars, a 4-bar loop) -that plays under the whole video, and a small ending of two soft bars that opens the outro slide: never a loud finale. Usage: python3 compose-music.py -Needs numpy. Turn the wav into the track with: ffmpeg -i -b:a 128k music.mp3 +Three parts the renderer relies on: a hook as long as the intro (6.5 s, 13 beats), a bed of 8 bars that +loops under the scenes, and an ending of 4 s that opens the outro. A four-note motif opens the hook and +the ending: it is the signature. Usage: python3 compose-music.py +Needs numpy. Turn the wav into the track with: ffmpeg -i -b:a 160k music.mp3 """ import sys -import numpy as np, wave -SR=44100; BPM=120; BEAT=60/BPM; BAR=4*BEAT; S16=BEAT/4 -HOOK_BARS, BED_BARS, END_BARS = 3, 16, 2 -total_s=(HOOK_BARS+BED_BARS+END_BARS)*BAR # the ending is the last 4 s: it opens the outro -N=int(total_s*SR)+SR//2 # half a second of true silence after the music, so its end is exact +import wave +import numpy as np + +SR = 44100 +BPM = 120 +BEAT = 60 / BPM +BAR = 4 * BEAT +HOOK = 13 * BEAT # the intro with tags: three bars and the pickup beat of the next +BED = 8 * BAR +ENDING = 4.0 +TOTAL = HOOK + BED + ENDING +N = int((TOTAL + 0.5) * SR) # half a second of true silence after the music +BED_STEP_DB = -4.0 # the bed sits a little under the hook, never a drop +MOTIF = ["A4", "C5", "E5", "A5"] +# A minor, D, E minor, D: the notes of each chord and its root +CHORDS = [(["A4", "C5", "E5"], "A1"), (["A4", "D5", "F#5"], "D2"), (["G4", "B4", "E5"], "E2"), (["A4", "D5", "F#5"], "D2")] +BASS = [1, 0, 1, 1, 1, 0, 1, 2] # eighth notes: rest, root, or the octave above +LINE = [(0.0, 2), (1.5, 1), (2.5, 3), (3.0, 2)] # beat and chord tone of the felt piano +rng = np.random.default_rng(11) + + def hz(name): - n={"C":0,"D":2,"E":4,"F":5,"G":7,"A":9,"B":11}[name[0]]; o=int(name[-1]) - return 440*2**((n+12*(o+1)-69)/12) -def pulse(f,dur,duty): - n=int(dur*SR); ph=(np.arange(n)*f/SR)%1.0 - return np.where(ph0: M[i:i+n]+=sig[:n] -CH=[("A2",["A4","C5","E5"]),("F2",["A4","C5","F5"]),("C3",["G4","C5","E5"]),("G2",["G4","B4","D5"])] -def bar(b,chord,lead,bass,arp,jump=False): - root,notes=chord; s=b*BAR - if bass>0: # triangle bass, eighth notes - for i in range(8): - put(tri(hz(root)*(2 if i%4==3 else 1),BEAT/2)*env(int(BEAT/2*SR),3)*bass,s+i*BEAT/2) - if arp>0: # fast pulse arpeggio, sixteenth notes - pat=[0,1,2,1]*4 if not jump else [0,1,2,1,0,2,1,2]*2 - for i,k in enumerate(pat): - f=hz(notes[k])*(2 if jump and i%4==3 else 1) - put(pulse(f,S16*1.6,0.25)*env(int(S16*1.6*SR),9)*arp,s+i*S16) - if lead>0: # simple tune on the 50% pulse, quarter notes - tune=[notes[2],notes[1],notes[2],notes[0]] - for i,nm in enumerate(tune): - put(pulse(hz(nm)/2*2,BEAT*0.9,0.5)*env(int(BEAT*0.9*SR),2.5)*lead,s+i*BEAT) -b=0 -for i in range(HOOK_BARS): bar(b,CH[i%4],0.22,0.30,0.20); b+=1 -for i in range(BED_BARS): bar(b,CH[i%4],0.0,0.12,0.07); b+=1 -# the ending, as long as the outro slide: four rising notes the moment it opens, then A minor held -# and left to die away -for i,nm in enumerate(["A4","C5","E5","A5"]): - put(pulse(hz(nm),S16*3,0.25)*env(int(S16*3*SR),6)*0.13,b*BAR+i*S16*2) -for nm in ("A3","E4"): - put(tri(hz(nm),BAR*1.8)*env(int(BAR*1.8*SR),1.6)*0.16,b*BAR+S16*8) -b+=END_BARS -end=N-SR//2; r=int(1.2*SR); fade=np.ones(N); fade[end-r:end]=np.linspace(1,0,r); fade[end:]=0; M*=fade -M/=np.abs(M).max() -M=np.round(M*31)/31 # 5-bit steps: the 8-bit grain -rms=lambda a,b: 20*np.log10(np.sqrt(np.mean(M[int(a*SR):int(b*SR)]**2))+1e-9) -he=HOOK_BARS*BAR; be=he+BED_BARS*BAR -print("seconds",round(total_s,1),"hook",round(rms(0,he),1),"bed",round(rms(he+2,be-1),1),"ending",round(rms(be,be+2*BAR),1)) -pcm=(np.stack([M,M],1)*0.89*32767).astype(np.int16) -with wave.open(sys.argv[1],"wb") as w: w.setnchannels(2); w.setsampwidth(2); w.setframerate(SR); w.writeframes(pcm.tobytes()) + n = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}[name[0]] + return 440 * 2 ** ((n + (name[1] == "#") + 12 * (int(name[-1]) + 1) - 69) / 12) + + +def times(seconds): + return np.arange(int(seconds * SR)) / SR + + +def lowpass(x, cutoff, order=2): + f = np.fft.rfftfreq(len(x), 1 / SR) + return np.fft.irfft(np.fft.rfft(x) / (1 + (f / cutoff) ** (2 * order)), len(x)) + + +def bandpass(x, low, high): + return lowpass(x, high) - lowpass(x, low, 1) + + +def envelope(n, attack, decay, release=0.03): + t = np.arange(n) / SR + e = np.exp(-t * decay) + k = max(1, int(attack * SR)) + e[:k] *= np.linspace(0, 1, k) + r = min(n, int(release * SR)) + e[-r:] *= np.linspace(1, 0, r) + return e + + +def put(bus, sound, start, pan=0.0): + i = int(round(start * SR)) + if i >= bus.shape[1] or i + len(sound) <= 0: + return + if i < 0: + sound, i = sound[-i:], 0 + n = min(len(sound), bus.shape[1] - i) + angle = (pan + 1) * np.pi / 4 + bus[0, i:i + n] += sound[:n] * np.cos(angle) + bus[1, i:i + n] += sound[:n] * np.sin(angle) + + +def felt_piano(freq, seconds, velocity=1.0): + t = times(seconds) + tone = sum( + (1 / n ** 1.15) * np.sin(2 * np.pi * freq * n * np.sqrt(1 + 0.0004 * n * n) * t + n) * np.exp(-t * (1.4 + 0.9 * n)) + for n in range(1, 9) if freq * n < 9000 + ) + hammer = lowpass(rng.standard_normal(len(t)), 900) * np.exp(-t * 90) * 0.35 + return lowpass((tone + hammer) * velocity, 2600 + 2500 * velocity, 1) * envelope(len(t), 0.003, 0.0, 0.08) + + +def pad(freq, seconds, cutoff): + """Seven saws a few cents apart, odd ones left and even ones right.""" + t = times(seconds) + sides = [np.zeros_like(t), np.zeros_like(t)] + for v in range(7): + sides[v % 2] += 2 * (((t * freq * (1 + (v - 3) * 0.0035)) + rng.random()) % 1.0) - 1 + e = envelope(len(t), 0.5, 0.0, 0.9) + return [lowpass(side / 7 * 2, cutoff) * e for side in sides] + + +def sub(freq, seconds): + t = times(seconds) + return np.tanh(1.6 * np.sin(2 * np.pi * freq * t)) * 0.8 * envelope(len(t), 0.008, 3.2, 0.04) + + +def kick(): + t = times(0.32) + sweep = 46 + 95 * np.exp(-t * 32) + return np.sin(2 * np.pi * np.cumsum(sweep) / SR) * np.exp(-t * 9.5) + 0.12 * lowpass(rng.standard_normal(len(t)), 1800) * np.exp(-t * 80) + + +def shaker(): + t = times(0.07) + return bandpass(rng.standard_normal(len(t)), 5500, 12000) * np.exp(-t * 55) * np.minimum(1, t * 600) + + +def clap(): + t = times(0.25) + return bandpass(rng.standard_normal(len(t)), 900, 3800) * np.exp(-t * 18) * np.minimum(1, t * 400) + + +def room(bus, mix, seconds=1.9): + """A reverb: the bus through a decaying burst of noise, a different one for each ear.""" + n = bus.shape[1] + int(seconds * SR) + size = 1 << (n - 1).bit_length() + out = np.zeros_like(bus) + for ear in (0, 1): + t = np.arange(int(seconds * SR)) / SR + tail = np.random.default_rng(40 + ear).standard_normal(len(t)) * np.exp(-t * (6.9 / seconds)) + tail = lowpass(tail, 5200, 1) * 0.5 + lowpass(tail, 1400, 1) * 0.3 + tail[:int(0.018 * SR)] *= np.linspace(0, 1, int(0.018 * SR)) + tail /= np.sqrt((tail ** 2).sum()) + out[ear] = np.fft.irfft(np.fft.rfft(bus[ear], size) * np.fft.rfft(tail, size), size)[:bus.shape[1]] + return bus * (1 - mix * 0.25) + out * mix + + +def compose(): + ducked, drums, voice = (np.zeros((2, N)) for _ in range(3)) + kicks = [] + bars = int(np.ceil((HOOK + BED) / BAR)) + for b in range(bars): + s = b * BAR + hook = s < HOOK - 0.01 + notes, root = CHORDS[b % 4] + for name in notes: + left, right = pad(hz(name) / 2, BAR + 0.9, 2200) + put(ducked, left * 0.075, s, -1) + put(ducked, right * 0.075, s, 1) + for i, step in enumerate(BASS): + if step: + put(ducked, sub(hz(root) * (2 if step == 2 else 1), BEAT * 0.45) * 0.30, s + i * BEAT / 2) + for k in range(4): + put(drums, kick() * 0.55, s + k * BEAT) + kicks.append(s + k * BEAT) + for k in (1, 3): + put(drums, clap() * (0.22 if hook else 0.16), s + k * BEAT, 0.1) + for k in range(8): + put(drums, shaker() * (0.10 if hook else 0.06), s + k * BEAT / 2 + BEAT / 4, -0.2 if k % 2 else 0.2) + for i, (beat, tone) in enumerate(LINE): + if hook or i % 2 == 0: + put(voice, felt_piano(hz(notes[tone % 3]) * (2 if tone >= 3 else 1), 1.6, 0.8) * 0.30, s + beat * BEAT, 0.15 * (1 if i % 2 else -1)) + # the pads and the bass breathe with the kick + duck = np.ones(N) + for k in kicks: + i = int(k * SR) + n = min(int(0.8 * SR), N - i) + if n > 0: + duck[i:i + n] = np.minimum(duck[i:i + n], 1 - 0.55 * np.exp(-np.arange(n) / SR / 0.2)) + # the motif: the signature, in the hook and again in the ending + ending = HOOK + BED + for i, name in enumerate(MOTIF): + put(voice, felt_piano(hz(name), 1.8, 1.0) * 0.30, 0.15 + i * 0.28, -0.1) + put(voice, felt_piano(hz(name), 2.2, 0.9) * 0.20, ending + i * 0.22) + for name in CHORDS[0][0]: + left, right = pad(hz(name) / 2, ENDING + 1.5, 1100) + put(ducked, left * 0.08, ending + 0.5, -1) + put(ducked, right * 0.08, ending + 0.5, 1) + mix = room(ducked * duck + drums + voice, 0.4) + t = np.arange(N) / SR + mix *= 10 ** (np.interp(t, [HOOK, HOOK, TOTAL], [0, BED_STEP_DB, BED_STEP_DB]) / 20) + mix *= np.interp(t, [0, 0.3, TOTAL - 1.6, TOTAL], [0, 1, 1, 0]) + return mix / np.abs(mix).max() + + +if __name__ == "__main__": + music = compose() + rms = lambda a, b: round(20 * np.log10(np.sqrt(np.mean(music[:, int(a * SR):int(b * SR)] ** 2)) + 1e-9), 1) + print("seconds", round(TOTAL, 1), "hook", rms(0.5, HOOK - 0.3), "bed", rms(HOOK + 2, HOOK + BED - 1), "ending", rms(HOOK + BED, TOTAL - 1)) + pcm = (music.T * 0.89 * 32767).astype(np.int16) + with wave.open(sys.argv[1], "wb") as w: + w.setnchannels(2) + w.setsampwidth(2) + w.setframerate(SR) + w.writeframes(pcm.tobytes()) diff --git a/.claude/skills/release-video/assets/music.mp3 b/.claude/skills/release-video/assets/music.mp3 index 3f9675b71..30ab1a1c1 100644 Binary files a/.claude/skills/release-video/assets/music.mp3 and b/.claude/skills/release-video/assets/music.mp3 differ diff --git a/.claude/skills/release-video/assets/render-release-video.cjs b/.claude/skills/release-video/assets/render-release-video.cjs index 56766ff08..a7446ba26 100644 --- a/.claude/skills/release-video/assets/render-release-video.cjs +++ b/.claude/skills/release-video/assets/render-release-video.cjs @@ -42,6 +42,14 @@ h2 { font-size: 78px; line-height: 1.08; letter-spacing: -0.02em; font-weight: 8 .bar i { width: 18px; height: 18px; border-radius: 50%; display: block; } .body { padding: 30px 36px; display: flex; flex-direction: column; gap: 18px; font-family: "JetBrains Mono", monospace; font-weight: 500; font-size: 32px; line-height: 1.4; } .dim { color: #8a8fc0; } .grn { color: #5ee6a8; } .blu { color: #8aa6ff; } .pnk { color: #ff8fa8; } .amb { color: #ffd37a; } +.row.del { color: #ff9aa8; background: rgba(255,107,107,.14); border-left: 4px solid #ff6b6b; padding-left: 14px; } +.row.add { color: #5ee6a8; background: rgba(94,230,168,.12); border-left: 4px solid #5ee6a8; padding-left: 14px; } +.diff .row { font-size: 27px; line-height: 1.35; } +.figs { display: flex; gap: 24px; margin-top: 18px; } +.fig { flex: 1; background: #0b0b36; border: 2px solid #26265f; border-radius: 16px; padding: 18px 28px; display: flex; flex-direction: column; gap: 6px; } +.fig b { font-size: 72px; font-weight: 800; line-height: 1; } +.fig .from { color: #ff9aa8; } .fig .arr { color: #8a8fc0; padding: 0 14px; } .fig .to { color: #5ee6a8; } +.fig small { font-family: "JetBrains Mono", monospace; font-size: 26px; color: #8a8fc0; } .row.red { color: #ff9aa8; background: rgba(255,107,107,.14); border-left: 4px solid #ff6b6b; padding-left: 14px; } .note { font-family: "JetBrains Mono", monospace; font-size: 22px; color: #8a8fc0; } .chips { display: flex; flex-wrap: wrap; gap: 18px; } @@ -81,8 +89,8 @@ td { padding: 14px 14px; border-bottom: 2px solid #15153f; vertical-align: top; #ver .dot { color: #5ee6a8; } .tagline { font-family: "JetBrains Mono", monospace; font-size: 40px; color: #a6abe0; } .tagline b { color: #fff; font-weight: 700; } -.hls { display: flex; flex-direction: column; align-items: flex-start; gap: 14px; margin-top: 10px; } -.hl { font-family: "JetBrains Mono", monospace; font-size: 28px; padding: 10px 22px; border-radius: 12px; border: 2px solid #3b4c99; color: #8aa6ff; } +.hls { display: flex; flex-direction: column; align-items: flex-start; gap: 16px; margin-top: 10px; } +.hl { font-family: "JetBrains Mono", monospace; font-size: 42px; padding: 12px 28px; border-radius: 12px; border: 2px solid #3b4c99; color: #8aa6ff; } .lead { font-size: 36px; line-height: 1.3; color: #a6abe0; max-width: 1500px; } #outro .inner { align-items: center; justify-content: center; gap: 26px; } .logo { width: 280px; height: 280px; border-radius: 50%; } @@ -115,6 +123,8 @@ const row = { `${r.table.head.map((h) => ``).join("")}` + r.table.rows.map((cells) => `${cells.map(cell).join("")}`).join("") + `
${esc(h)}
`, + del: (r) => `
- ${esc(r.del)}
`, + add: (r) => `
+ ${esc(r.add)}
`, code: (r) => `
${esc(r.code)}
`, check: (r) => `
${esc(r.mark ?? "✓")}${esc(r.check)}
`, @@ -152,13 +162,27 @@ const rowsIn = (rows = []) => rows.reduce((n, r) => n + (r.table ? 1 + r.table.r /** Rows are revealed one by one; the note comes after the last. */ const rowElements = (s) => - (s.window ? [s.window] : s.panels ?? []).reduce((n, w) => n + (w.rows ?? []).length, 0); + (s.window ? [s.window] : s.panels ?? [s.before, s.after].filter(Boolean)).reduce((n, w) => n + (w.rows ?? []).length, 0); const scene = { "terminal-demo": { items: (s) => rowsIn(s.window?.rows) + (s.flow ? 2 : 0), body: (s) => windowHtml(s.window) + (s.flow ? flowHtml(s.flow) : "") + noteHtml(s), }, + "before-after": { + items: (s) => (s.figures ?? []).length + rowsIn(s.before?.rows) + rowsIn(s.after?.rows), + body: (s) => + `
` + + (s.figures ?? []) + .map( + (f) => + `
${esc(f.from)}→${esc(f.to)}${esc(f.label)}
` + ) + .join("") + + `
` + + `
${windowHtml({ ...s.before, flex: 1 })}${windowHtml({ ...s.after, flex: 1 })}
` + + noteHtml(s), + }, "two-panels": { items: (s) => (s.panels ?? []).reduce((n, p) => n + rowsIn(p.rows), 0), body: (s) => `
${s.panels.map(windowHtml).join("")}
${noteHtml(s)}`, @@ -189,7 +213,7 @@ const scene = { /** Head (2.5 s), a second to read the lead, a beat per item, and a closing pause. */ function sceneDuration(s) { if (!scene[s.template]) throw new Error(`Unknown template: ${s.template}`); - return half(4.5 + (s.lead ? 1 : 0) + 0.7 * scene[s.template].items(s)); + return half(3.5 + (s.lead ? 1 : 0) + 0.7 * scene[s.template].items(s)); } const headHtml = (s, id) => @@ -248,12 +272,15 @@ const round2 = (x) => Math.round(x * 100) / 100; const itemsIn = (html) => (html.match(/class="[^"]*\bit\b/g) ?? []).length; /** One sound per thing that appears, at the time the timeline shows it: a whoosh as a scene - * opens, a pop as a window or item slides in, a click per row, a key tick per typed character. */ + * opens, a tick for its pill and lead, a pop for its title, window, items and note, a click per row, + * a key tick per typed character. */ function soundCues(plan, { version }) { const cues = []; const cue = (kind, t) => cues.push({ kind, t: round2(t) }); const chars = String(version).length + 1; cue("pop", 0.1); + cue("tick", 0.4); + cue("tick", 0.55); for (let k = 0; k < chars; k++) cue("key", 0.8 + (1.1 * k) / chars); if (hasTagline(plan.intro)) cue("pop", 2.0); (plan.intro?.highlights ?? []).forEach((_, k) => cue("pop", (hasTagline(plan.intro) ? 2.6 : 2) + 0.3 * k)); @@ -261,33 +288,78 @@ function soundCues(plan, { version }) { let t = introSeconds(plan); for (const s of plan.scenes) { cue("whoosh", t); + cue("tick", t + 0.14); + cue("pop", t + 0.28); + if (s.lead) cue("tick", t + 0.5); for (let k = 0; k < itemsIn(scene[s.template].body(s)); k++) cue("pop", t + 0.63 + 0.3 * k); for (let k = 0; k < rowElements(s); k++) cue("click", t + 1.3 + 0.45 * k); + if (s.note) cue("pop", t + 1.3 + 0.45 * rowElements(s) + 0.3); t += sceneDuration(s); } + cue("pop", t + 0.07); + cue("pop", t + 0.28); for (let k = 0; k < 2; k++) cue("pop", t + 0.63 + 0.2 * k); return cues.sort((a, b) => a.t - b.t); } const SFX_RATE = 44100; -const square = (phase, duty = 0.5) => (phase % 1 < duty ? 1 : -1); +const frames = (seconds) => Math.round(seconds * SFX_RATE); + +/** Deterministic noise, so two renders of one plan sound the same. */ +const noise = (seed) => { + let x = seed; + return () => { + x = (x * 1664525 + 1013904223) >>> 0; + return x / 2147483648 - 1; + }; +}; + +const onePole = (samples, hz) => { + const a = 1 - Math.exp((-2 * Math.PI * hz) / SFX_RATE); + let y = 0; + return samples.map((v) => (y += a * (v - y))); +}; + +/** A soft struck tone: a sine and a short inharmonic partial, like glass. */ +const ping = (hz, seconds, decay) => + Float64Array.from({ length: frames(seconds) }, (_, i) => { + const x = i / SFX_RATE; + const tone = Math.sin(2 * Math.PI * hz * x) + 0.35 * Math.sin(2 * Math.PI * hz * 2.76 * x) * Math.exp(-x * 30); + return 0.5 * tone * Math.exp(-x * decay) * Math.min(1, x * 1000, (seconds - x) * 100); + }); + +/** Air that turns from dark to bright while it swells and fades. */ +const whoosh = () => { + const seconds = 0.45; + const random = noise(7); + const air = Array.from({ length: frames(seconds) }, random); + const dark = onePole(air, 900); + const low = onePole(air, 1500); + const bright = onePole(air.map((v, i) => v - low[i]), 7000); + return Float64Array.from(air, (_, i) => { + const u = i / air.length; + return 0.9 * (dark[i] * (1 - u) + bright[i] * u) * Math.sin(Math.PI * u) ** 2; + }); +}; -/** Each sound is a short 8-bit blip: a square wave with a fast decay, no noise. */ +/** Each sound is a short soft tone or a breath of air, never a chip-tune blip. */ const SOUNDS = { - key: { seconds: 0.025, wave: (x) => square(1400 * x) * Math.exp(-x * 120) }, - click: { seconds: 0.05, wave: (x) => square(2000 * x) * Math.exp(-x * 80) }, - pop: { seconds: 0.12, wave: (x) => square((480 + 2000 * x) * x) * Math.exp(-x * 25) }, - whoosh: { seconds: 0.26, wave: (x) => square((150 + 2400 * x) * x, 0.25) * Math.sin((Math.PI * x) / 0.26) * 0.6 }, + key: () => ping(2600, 0.06, 60), + tick: () => ping(2000, 0.09, 60), + click: () => ping(1500, 0.08, 40), + pop: () => ping(880, 0.3, 11), + whoosh, }; /** The sounds of the cues, laid on a silent mono 16-bit wav as long as the video. */ function soundTrack(plan, { version }) { const total = Math.round(totalDuration(plan) * SFX_RATE); const mix = new Float64Array(total); + const made = {}; for (const { kind, t } of soundCues(plan, { version })) { - const { seconds, wave } = SOUNDS[kind]; + const sound = (made[kind] ??= SOUNDS[kind]()); const from = Math.round(t * SFX_RATE); - for (let i = 0; i < Math.round(seconds * SFX_RATE) && from + i < total; i++) mix[from + i] += wave(i / SFX_RATE); + for (let i = 0; i < sound.length && from + i < total; i++) mix[from + i] += sound[i]; } const wav = Buffer.alloc(44 + 2 * total); wav.write("RIFF", 0, "latin1"); @@ -303,8 +375,7 @@ function soundTrack(plan, { version }) { wav.write("data", 36, "latin1"); wav.writeUInt32LE(2 * total, 40); for (let i = 0; i < total; i++) { - const steps = Math.round(Math.max(-1, Math.min(1, mix[i] * 0.8)) * 31) / 31; // 5-bit grain - wav.writeInt16LE(Math.round(steps * 32767), 44 + 2 * i); + wav.writeInt16LE(Math.round(Math.max(-1, Math.min(1, mix[i] * 0.8)) * 32767), 44 + 2 * i); } return wav; } @@ -394,7 +465,7 @@ tl.seek(0); const STEP = 0.5; const HOOK_STARTS_WITHIN = 2; -const LOUD_ABOVE_MEDIAN = 8; +const LOUD_ABOVE_MEDIAN = 3; /** The ending is the last stretch of a track before it falls silent, as long as the outro it opens: small, never louder than a hook. */ const ENDING_SECONDS = 4; @@ -528,7 +599,7 @@ async function main() { console.log(`✅ ${out}`); } -module.exports = { buildHtml, musicSections, musicSegments, sceneDuration, soundCues, soundTrack, totalDuration }; +module.exports = { buildHtml, measureRms, musicSections, musicSegments, sceneDuration, soundCues, soundTrack, totalDuration }; if (require.main === module) { main().catch((error) => { diff --git a/.claude/skills/release-video/assets/scenes-template.json b/.claude/skills/release-video/assets/scenes-template.json index 72aa8297c..335f409a8 100644 --- a/.claude/skills/release-video/assets/scenes-template.json +++ b/.claude/skills/release-video/assets/scenes-template.json @@ -28,7 +28,7 @@ ] }, "note": "Example run.", - "lead": "One plain sentence: what it does for the user, and why it matters." + "lead": "Optional, left out by default: only when the picture alone cannot say it." } ], "skipped": [ diff --git a/.claude/skills/release-video/references/judge.md b/.claude/skills/release-video/references/judge.md index 324c1f3e6..ccd41749f 100644 --- a/.claude/skills/release-video/references/judge.md +++ b/.claude/skills/release-video/references/judge.md @@ -9,6 +9,8 @@ What the check cannot read; each row is answered on a plan before it is accepted | Is the feature shown in the tool the user works in? | a Codex feature appears in a terminal: put it in a Codex window | | Is a story told in the order lived? | do it, then see the report by tool, in one scene each | | Is a safety feature shown as it unfolds? | it only states the result: show the warning in red, then the question put to the user | +| Does the title say what the user gains? | it names the feature or the mechanism: say what changes for the user | +| Does the picture say it without a subtitle? | a scene needs its lead to be understood: redraw the window, a lead stays the exception | | Does the picture say it with the sound off? | a scene needs the music or a read of the notes to be understood | -| Does the demo look believable? | an invented value is not marked as an example, or the command is not a real one | +| Does the demo look believable? | an invented figure is not marked as an example, or the command is not a real one | | Do the intro tags name this release's big features? | they could open another release, or need a phrase to be understood: rewrite them | diff --git a/.claude/skills/release-video/references/scenes.md b/.claude/skills/release-video/references/scenes.md index a800b0a6c..92dbffd0f 100644 --- a/.claude/skills/release-video/references/scenes.md +++ b/.claude/skills/release-video/references/scenes.md @@ -7,6 +7,7 @@ What a plan holds and how each part is drawn. | `terminal-demo` | one command and its output | `window`, optional `flow` | | `two-panels` | a request beside its result | `panels`, each with `label`, `flex`, `rows` | | `bullet-grid` | several small changes | `cells`, each with `title`, optional `detail`, `wide` | +| `before-after` | a change shown as an old card and a new one, such as a leaner skill | `before` and `after`, two windows whose rows are `del` and `add` | | `contributors` | who shipped the release | `logins` | | Plan field | Holds | @@ -18,7 +19,7 @@ What a plan holds and how each part is drawn. | --- | --- | | `pill` | the theme, top left: `Telemetry`, `Rules`, never a command | | `title`, `accent` | a short title; the accent takes the gradient | -| `lead` | one plain sentence under the title: what it does for the user | +| `lead` | optional and left out by default: the picture says it. One plain sentence under the title when a scene cannot be understood without it | | `surfaces` | the skills and commands the scene covers, for the check | | `window` | `label` and `rows` | | `note` | a line shown after the last row, such as `Example figures.` | @@ -31,6 +32,7 @@ What a plan holds and how each part is drawn. | table | `{"table": {"head": [], "rows": []}}`, a cell may be `{"status": "…"}` or `{"dim": "…"}` | | code | `{"code": "…"}` | | check | `{"check": "…", "mark": "!"}` | +| diff | `{"del": "…"}` drawn red with `-`, `{"add": "…"}` drawn green with `+` | | Tone | Means | | --- | --- | @@ -43,10 +45,10 @@ What a plan holds and how each part is drawn. | Limit | Value | | --- | --- | | intro tagline | optional and left out by default; 6 words when used | -| intro highlights | 3, of 5 words each, drawn one per line: clear sentences, not labels | -| scene lead | 14 words | +| intro highlights | 5, of 5 words each, drawn one per line in large type: clear sentences, not labels | +| scene lead | 14 words, when there is one | | intro | 5 seconds, 6.5 with highlights | -| scene | 4.5 seconds, plus 1 with a lead, plus 0.7 per item, rounded up to a half second | +| scene | 3.5 seconds, plus 1 with a lead, plus 0.7 per item, rounded up to a half second | | outro | 4 seconds | | refused words | tokens, envelope, axis, transcript, payload, hook; `telemetry` stays; the demo window is not read, it shows real commands and flags | | themes | scenes of one theme follow each other | diff --git a/.claude/skills/release-video/references/sound.md b/.claude/skills/release-video/references/sound.md index c4e4db157..85cd34d40 100644 --- a/.claude/skills/release-video/references/sound.md +++ b/.claude/skills/release-video/references/sound.md @@ -4,13 +4,16 @@ The renderer adds two layers, both original. | Layer | What | Where | | --- | --- | --- | -| Music | an 8-bit track in three parts: a loud hook, a calm bed, a small ending | `assets/music.mp3`, used by every render; `--music ` replaces it for one | -| Interface sounds | short 8-bit blips from square and triangle waves, no noise | `soundCues` and `soundTrack` in `assets/render-release-video.cjs` | +| Music | a stereo track in three parts: a hook, a bed a few dB under it, a small ending; felt piano and soft pads over a gentle pulse | `assets/music.mp3`, used by every render; `--music ` replaces it for one | +| Interface sounds | short soft tones, like struck glass, and a breath of air; no chip-tune waves | `soundCues` and `soundTrack` in `assets/render-release-video.cjs` | + +Everything that appears has a sound, at the time the timeline shows it. | Moment | Sound | | --- | --- | | the version typed in the intro | a key tick per character | -| a window or item slides in | a pop | +| a pill, the release label or a lead appears | a tick | +| a title, a window, an item, a note, the outro logo or title appears | a pop | | a row appears | a click | | a scene opens | a whoosh | @@ -18,13 +21,16 @@ The sounds take their times from the animation: changing the plan moves them, no | Part of the track | Plays under | Level in the video | | --- | --- | --- | -| hook | the intro | about -19 dB | -| bed | the scenes and the outro, looped | about -29 dB, 10 dB under the hook | -| ending | the outro slide: four rising notes as it opens, then a held chord that dies away | about -26 dB, never louder than a few dB above the bed | -| interface sounds | everything | peaks about -12 dB, above the bed | +| hook | the intro, which ends on a bar line | about -22 dB | +| bed | the scenes, looped, and the outro | about -26 dB, 4 dB under the hook: a step, never a drop | +| ending | the outro slide: the four-note motif as it opens, then a held chord that dies away | about the bed's level, never louder | + +The levels are the mean volume of one second of the whole video, music and sounds together. | Fact | Detail | | --- | --- | -| the shape | a track with no loud hook is only looped under everything; its ending is its last 4 seconds before silence, as long as the outro it opens, and stays quiet | -| the generator | `assets/compose-music.py`, numpy, 120 BPM, an A minor, F, C, G loop | +| the shape | a hook of 6.5 s (13 beats, the intro with tags), a bed of 8 bars that loops, an ending of 4 s. A track with no hook at least 3 dB over its median is only looped | +| the motif | four rising notes, A C E A, open the hook and the ending: the signature of the brand | +| the generator | `assets/compose-music.py`, numpy, 120 BPM, A minor, D, E minor, D, stereo | +| the limit | an intro without tags lasts 5 s: the hook is cut before its last beats and the bed joins off the bar line | | taste | a track that tires is replaced by another original one; nothing else changes | diff --git a/aidd_docs/release-video/scenes.json b/aidd_docs/release-video/scenes.json index 48f32ffc5..4b2bd1254 100644 --- a/aidd_docs/release-video/scenes.json +++ b/aidd_docs/release-video/scenes.json @@ -3,9 +3,8 @@ { "template": "terminal-demo", "pill": "Telemetry", - "title": "Your Codex work,", - "accent": "measured too.", - "lead": "Ask Codex what your work cost and get the full report.", + "title": "See what your AI costs,", + "accent": "in Codex too.", "surfaces": [ "aidd telemetry" ], @@ -52,9 +51,8 @@ { "template": "terminal-demo", "pill": "Telemetry", - "title": "Every tool,", - "accent": "told apart.", - "lead": "See Claude and Codex side by side, each with its own total.", + "title": "Know which tool", + "accent": "costs what.", "surfaces": [ "aidd-telemetry:00-init" ], @@ -89,11 +87,210 @@ }, "note": "Example figures." }, + { + "template": "before-after", + "pill": "Brainstorm", + "title": "38% leaner Brainstorm", + "accent": "for small models.", + "before": { + "label": "Before · 01-capture.md", + "rows": [ + { + "del": "Require. If no idea is present, ask for one and wait." + }, + { + "del": "Restate. Capture the idea in short bullets, preserving uncertainty." + }, + { + "del": "Scan. Apply interview depth and discovery map silently." + }, + { + "del": "Open. Hand the highest-impact open point to probe." + } + ] + }, + "after": { + "label": "After · 01-capture.md", + "rows": [ + { + "add": "With no idea, ask for one and stop." + }, + { + "add": "Say the idea back briefly, in the user's words, keeping what is still uncertain." + }, + { + "add": "Go to explore." + } + ] + } + }, + { + "template": "terminal-demo", + "pill": "Brainstorm", + "title": "See what your AI", + "accent": "only guessed.", + "surfaces": [ + "aidd-refine:01-brainstorm" + ], + "window": { + "label": "Claude Code · ~/acme-app", + "rows": [ + { + "prompt": "/aidd-refine:01-brainstorm a way to share reports" + }, + { + "text": "Who reads these reports, a teammate or a client?", + "tone": "amb" + }, + { + "text": "Clear: clients read the reports", + "tone": "grn" + }, + { + "text": "Assumed: a weekly report is enough", + "tone": "amb" + }, + { + "text": "Open: who owns the data?", + "tone": "blu" + }, + { + "text": "Approve, correct, or explore more?", + "tone": "amb" + } + ] + } + }, + { + "template": "before-after", + "pill": "Planning", + "title": "51% leaner Planning", + "accent": "for small models.", + "before": { + "label": "Before · 01-gather.md", + "rows": [ + { + "del": "Find. Identify what the request points to: a file path, a ticket URL or id, or raw text." + }, + { + "del": "Pull. Read the file, fetch the ticket, or take the text as given." + }, + { + "del": "Restate. Summarize the source in a few bullets: what is asked, and the hard constraints it states." + } + ] + }, + "after": { + "label": "After · 01-capture.md", + "rows": [ + { + "add": "With no source to plan from (file, ticket, or text), ask for one and stop." + }, + { + "add": "Read it, noting what it asks and the constraints it states." + }, + { + "add": "Read explore." + } + ] + } + }, + { + "template": "two-panels", + "pill": "Planning", + "title": "Know what a plan", + "accent": "will change.", + "panels": [ + { + "label": "plan.md · Phases", + "flex": 1, + "rows": [ + { + "table": { + "head": [ + "Phase", + "Depends on" + ], + "rows": [ + [ + "1 safety net", + "none" + ], + [ + "2 theme switch", + "1" + ], + [ + "3 settings page", + "2" + ] + ] + } + } + ] + }, + { + "label": "plan.md · Impact", + "flex": 1, + "rows": [ + { + "text": "Runtime: the theme is read at startup", + "tone": "grn" + }, + { + "text": "Regression: print pages stay light", + "tone": "amb" + }, + { + "text": "Architecture: one theme provider", + "tone": "blu" + }, + { + "text": "Documentation: a settings guide section", + "tone": "pnk" + } + ] + } + ] + }, + { + "template": "terminal-demo", + "pill": "Planning", + "title": "Catch plan gaps", + "accent": "before you build.", + "surfaces": [ + "aidd-dev:01-plan" + ], + "window": { + "label": "Claude Code · ~/acme-app", + "rows": [ + { + "prompt": "/aidd-dev:01-plan add dark mode" + }, + { + "text": "plan written, an independent reader checks it…", + "tone": "dim" + }, + { + "text": "✗ Reading: the login page was not read", + "tone": "amb" + }, + { + "text": "✗ Evidence: the settings page impact was assumed", + "tone": "amb" + }, + { + "text": "Approve, correct, or explore more?", + "tone": "amb" + } + ] + } + }, { "template": "terminal-demo", "pill": "Rules", - "title": "No more rules", - "accent": "that clash.", + "title": "Rules that never", + "accent": "contradict each other.", "surfaces": [ "aidd-context:05-rule-generate" ], @@ -120,18 +317,17 @@ "tone": "grn" } ] - }, - "note": "Example run.", - "lead": "Before saving a new rule, your AI checks it against the others." + } }, { "template": "contributors", "pill": "Team", "title": "Shipped by", - "accent": "2 contributors.", + "accent": "3 contributors.", "logins": [ "alexsoyes", - "blafourcade" + "blafourcade", + "waewoo" ] } ], @@ -143,15 +339,18 @@ ], "intro": { "highlights": [ - "Telemetry now covers Codex", - "Telemetry report split by tool", - "Rules checked for conflicts" + "See what Codex costs you", + "Lean skills small models follow", + "See what your AI guessed", + "Catch plan gaps before building", + "No more contradicting rules" ] }, "youtube": { "themes": [ "Télémétrie", - "Codex", + "Plan", + "Brainstorm", "Règles" ], "programme": [ @@ -163,6 +362,14 @@ "surface": "aidd-telemetry:00-init", "text": "activer la mesure et distinguer le résultat par outil, Claude Code ou Codex." }, + { + "surface": "aidd-dev:01-plan", + "text": "alléger le plan de moitié, y lister l'ordre des phases et l'impact, et le faire relire par un juge indépendant." + }, + { + "surface": "aidd-refine:01-brainstorm", + "text": "alléger le brainstorm et distinguer ce qui est établi, supposé ou resté ouvert." + }, { "surface": "aidd-context:05-rule-generate", "text": "vérifier qu'une règle n'en contredit pas une autre, et décider laquelle garder." diff --git a/scripts/__tests__/check-release-video.test.js b/scripts/__tests__/check-release-video.test.js index eb0230efe..a5b91c771 100644 --- a/scripts/__tests__/check-release-video.test.js +++ b/scripts/__tests__/check-release-video.test.js @@ -208,7 +208,7 @@ const words = (n) => Array.from({ length: n }, (_, i) => `w${i}`).join(" "); test("overlongText accepts a short intro and a short lead", () => { const plan = { - intro: { tagline: words(6), highlights: [words(5), words(5), words(5)] }, + intro: { tagline: words(6), highlights: [words(5), words(5), words(5), words(5), words(5)] }, scenes: [{ lead: words(14) }], skipped: [], }; @@ -223,9 +223,9 @@ test("overlongText names the intro tagline that is too long", () => { }); test("overlongText names a highlight that is too long and too many highlights", () => { - const plan = { intro: { highlights: [words(6), "a", "b", "c"] }, scenes: [], skipped: [] }; + const plan = { intro: { highlights: [words(6), "a", "b", "c", "d", "e"] }, scenes: [], skipped: [] }; - assert.deepEqual(overlongText(plan), ["intro highlights: 4, 3 at most", "intro highlight 1: 6 words, 5 at most"]); + assert.deepEqual(overlongText(plan), ["intro highlights: 6, 5 at most", "intro highlight 1: 6 words, 5 at most"]); }); test("overlongText names the scene whose lead is too long", () => { diff --git a/scripts/__tests__/render-release-video.test.js b/scripts/__tests__/render-release-video.test.js index b52966c1e..017772f3b 100644 --- a/scripts/__tests__/render-release-video.test.js +++ b/scripts/__tests__/render-release-video.test.js @@ -1,7 +1,7 @@ const assert = require("node:assert/strict"); const test = require("node:test"); -const { buildHtml, musicSegments, musicSections, sceneDuration, soundCues, soundTrack, totalDuration } = require("../../.claude/skills/release-video/assets/render-release-video.cjs"); +const { buildHtml, measureRms, musicSegments, musicSections, sceneDuration, soundCues, soundTrack, totalDuration } = require("../../.claude/skills/release-video/assets/render-release-video.cjs"); const window = (rows) => ({ label: "Claude Code · ~/acme-app", rows }); @@ -143,6 +143,13 @@ test("the root duration is intro, scenes and outro added up", () => { assert.match(html, new RegExp(`id="root"[^>]*data-duration="${5 + sum(plan.scenes) + 4}"`)); }); +test("a scene lasts 3.5 seconds, plus 1 with a lead, plus 0.7 per row, rounded up to a half second", () => { + const one = { template: "terminal-demo", window: window([{ text: "a" }]) }; + + assert.equal(sceneDuration(one), 4.5); + assert.equal(sceneDuration({ ...one, lead: "Ask what a task cost." }), 5.5); +}); + test("a scene with more rows lasts longer", () => { const few = { template: "terminal-demo", window: window([{ text: "a" }]) }; const many = { template: "terminal-demo", window: window([{ text: "a" }, { text: "b" }, { text: "c" }]) }; @@ -305,7 +312,7 @@ test("a pop sounds as each window or item slides in", () => { test("the outro has no sound of its own: the ending of the music opens it", () => { assert.equal(soundCues(plan, { version: "5.13" }).filter((c) => c.kind === "chime").length, 0); - assert.equal(cuesOf("pop", totalDuration(plan) - 4, totalDuration(plan)).length, 2); + assert.equal(cuesOf("pop", totalDuration(plan) - 4, totalDuration(plan)).length, 4, "logo, title and two items"); }); test("the sound track is a wav as long as the video, loud on a cue and silent between", () => { @@ -317,7 +324,7 @@ test("the sound track is a wav as long as the video, loud on a cue and silent be assert.equal(wav.toString("latin1", 0, 4), "RIFF"); assert.equal(wav.length, 44 + 2 * Math.round(totalDuration(plan) * rate)); assert.ok(peakNear(5 + 1.3) > 1000, "silent on a click"); - assert.equal(peakNear(5 + 0.3), 0, "noise between cues"); + assert.equal(peakNear(4.5), 0, "noise between cues"); }); test("the sound track plays under the whole video, quieter than the picture would", () => { @@ -358,9 +365,102 @@ test("an intro with tags and no phrase shows the tags alone, from the start of t assert.match(html, /enter\("#intro \.hl", 2,/); }); +test("the intro tags are drawn large enough to be read first", () => { + const html = buildHtml(plan, { version: "5.13" }); + const size = Number(html.match(/\.hl \{[^}]*font-size: (\d+)px/)?.[1]); + + assert.ok(size >= 40, `tags at ${size}px`); +}); + +const beforeAfter = { + template: "before-after", + pill: "Brainstorm", + title: "Brainstorm, lean", + figures: [ + { from: "13", to: "8", label: "files" }, + { from: "4", to: "3", label: "steps" }, + ], + before: { label: "Before", rows: [{ del: "probe" }, { del: "integrate" }] }, + after: { label: "After", rows: [{ add: "explore" }] }, +}; + +test("a before-after scene puts its figures above the old and the new side by side", () => { + const html = buildHtml({ scenes: [beforeAfter], skipped: [] }, { version: "5.13" }); + + assert.ok(html.indexOf('class="fig it"') < html.indexOf('class="split diff"'), "figures come after the cards"); + assert.match(html, /13<\/span>[^]*8<\/span>[^]*files/); + assert.ok(html.indexOf("Before") < html.indexOf("After"), "the new comes first"); + assert.match(html, /
- probe<\/div>/); + assert.match(html, /
\+ explore<\/div>/); +}); + +test("a before-after scene lasts for its figures and the lines of both cards", () => { + const bare = { ...beforeAfter, figures: [], before: { label: "Before", rows: [] }, after: { label: "After", rows: [] } }; + + assert.equal(sceneDuration(beforeAfter) - sceneDuration(bare), 3.5); +}); + test("no pop is cued for a phrase the intro does not draw", () => { const withTags = { ...plan, intro: { highlights: ["a", "b"] } }; const pops = soundCues(withTags, { version: "5.13" }).filter((c) => c.kind === "pop" && c.t < 4).map((c) => c.t); assert.deepEqual(pops, [0.1, 2, 2.3]); }); + +const full = { + scenes: [{ template: "terminal-demo", pill: "p", title: "t", lead: "l", window: window([{ prompt: "a" }, { text: "b" }]), note: "n" }], + skipped: [], +}; +const inRange = (p, from, to) => soundCues(p, { version: "5.13" }).filter((c) => c.t >= from && c.t < to); + +test("everything that appears in a scene has a sound at its own time", () => { + assert.deepEqual(inRange(full, 5, 8), [ + { kind: "whoosh", t: 5 }, + { kind: "tick", t: 5.14 }, + { kind: "pop", t: 5.28 }, + { kind: "tick", t: 5.5 }, + { kind: "pop", t: 5.63 }, + { kind: "click", t: 6.3 }, + { kind: "click", t: 6.75 }, + { kind: "pop", t: 7.5 }, + ]); +}); + +test("a scene without a lead or a note has no sound for them", () => { + const bare = { scenes: [{ ...full.scenes[0], lead: undefined, note: undefined }], skipped: [] }; + + assert.deepEqual(inRange(bare, 5, 8).map((c) => c.t), [5, 5.14, 5.28, 5.63, 6.3, 6.75]); +}); + +test("the intro pill and release label, the outro logo and title each have a sound", () => { + const end = totalDuration(full) - 4; + + assert.deepEqual(inRange(full, 0.3, 0.7).map((c) => [c.kind, c.t]), [["tick", 0.4], ["tick", 0.55]]); + assert.deepEqual(inRange(full, end, end + 0.5).map((c) => [c.kind, c.t]), [["pop", end + 0.07], ["pop", end + 0.28]]); +}); + +test("the sounds are smooth, not the 5-bit steps of a chip tune", () => { + const wav = soundTrack(full, { version: "5.13" }); + const levels = new Set(); + for (let i = 44; i < wav.length; i += 2) levels.add(wav.readInt16LE(i)); + + assert.ok(levels.size > 500, `only ${levels.size} sample values`); +}); + +test("a hook only a few dB above the bed is still found", () => { + const close = [-30, ...Array(13).fill(-21), ...Array(32).fill(-25.5), ...Array(8).fill(-24), -61]; + + assert.deepEqual(musicSections(close), { headStart: 0.5, headEnd: 7, tailStart: 23, tailEnd: 27 }); +}); + +const kit = require("node:path").join(__dirname, "../../.claude/skills/release-video/assets/music.mp3"); +const hasFfmpeg = require("node:child_process").spawnSync("ffmpeg", ["-version"]).status === 0; + +test("the shipped track has a hook as long as the intro with tags, a bed of whole bars and a 4 s ending", { skip: !hasFfmpeg }, () => { + const sections = musicSections(measureRms(kit)); + + assert.ok(sections, "no hook found"); + assert.equal(sections.headEnd, 6.5); + assert.equal(sections.tailEnd - sections.tailStart, 4); + assert.equal((sections.tailStart - sections.headEnd) % 2, 0, "the bed is not a whole number of bars"); +});