Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
2a327cc
feat(aidd-pm): plan and render the release video locally
blafourcade Oct 19, 2026
0aca4c6
chore: keep the release video skill in the repository's own skills
blafourcade Oct 19, 2026
13b4dd9
chore: drop removed surfaces from the release video check and plan th…
blafourcade Oct 19, 2026
d94a6e3
chore: type the release video version from an empty intro and show a …
blafourcade Oct 19, 2026
28e06ab
chore: plan the fixes and contributors scenes of the release video
blafourcade Oct 19, 2026
0016ba9
ci: refuse a promotion whose release video misses a shipped feature
blafourcade Oct 19, 2026
e252291
chore: say what the release holds in the video intro and in each scen…
blafourcade Oct 20, 2026
58f7ba8
chore: fit the release video music to the video and keep it under the…
blafourcade Oct 20, 2026
aeb9c11
chore: shape the release video music to what is on screen and write a…
blafourcade Oct 21, 2026
71c9a82
chore: refuse a release video intro or lead too long to read at a gla…
blafourcade Oct 21, 2026
41bd89f
chore: write the release video in everyday words and refuse internal …
blafourcade Oct 21, 2026
e99586e
chore: move the release video into its skill and give it 8-bit sound …
blafourcade Oct 21, 2026
56fb2aa
chore: publish the release video and recap from the release run
blafourcade Oct 22, 2026
c2cfac1
chore: check the release recap link by substring, not a pattern
blafourcade Oct 22, 2026
572d89a
chore: leave the release Slack message to n8n and only print the CI r…
blafourcade Oct 22, 2026
a10bb84
docs: lean the deployment memory and draw the release flow
blafourcade Oct 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/release-video
33 changes: 33 additions & 0 deletions .claude/skills/release-video/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
name: release-video
description: Plans and renders the release video, one scene per feature shipped since the last release, with its music and interface sounds. Use when the user wants to prepare the release announcement video, change its music, or the release video check reports missing scenes. Not for publishing the video or writing release notes.
argument-hint: release version | music
---

# Release video

```mermaid
flowchart LR
source([release]) --> plan --> render --> done([release-video.mp4])
render -->|"scene to fix"| plan
track([music]) --> compose-music --> render
```

## Actions

Run the flow above. Read only the next action file.

| Action | Does |
| --- | --- |
| plan | cover every shipped feature with a scene, or a reason to skip it |
| render | turn the plan into the MP4 and check it on screen |
| compose-music | write a new track when the user asks for other music |

## 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.
- 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.
- Stop at the MP4: attaching it to a release and announcing it are the user's.
42 changes: 42 additions & 0 deletions .claude/skills/release-video/actions/01-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 01 - Plan

Cover every feature shipped since the last release with one scene, or one written reason.

## Input

The repository at the release candidate.

## Output

`aidd_docs/release-video/scenes.json` that `node .claude/skills/release-video/assets/check-release-video.js` accepts; the next step is [render](02-render.md).

## Process

1. **List.** Run the check. 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`.
- 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/<last-tag>...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.
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.

## Test

| Case | Pass |
| --- | --- |
| Uncovered feature | the check names it; the plan gains a scene or a skip |
| 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 |
| `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 |
| 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 |
| Plan read against the judge list | every rule answered, a failing scene rewritten or removed |
33 changes: 33 additions & 0 deletions .claude/skills/release-video/actions/02-render.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 02 - Render

Turn the accepted plan into the release video and look at it.

## Input

`aidd_docs/release-video/scenes.json` that the check accepts, and the version the release will carry, such as `5.13`: the next minor when a `feat:` shipped since the last tag, never a placeholder, because it is typed on screen.

## Output

`release-video.mp4` in the working directory.

## Process

1. **Check.** Run `node .claude/skills/release-video/assets/check-release-video.js`. On failure, return to [plan](01-plan.md).
2. **Render.** Run `node .claude/skills/release-video/assets/render-release-video.cjs --version <version> --out release-video.mp4`. It needs `npx`, a network, ffmpeg and Chrome, and adds the music and the interface sounds of [sound.md](../references/sound.md).
3. **Look.** Extract one frame near the end of each scene with `ffmpeg -ss <seconds> -i release-video.mp4 -frames:v 1` and read them.
- Fix overflow, cut text and wrong values in the plan, then render again.
- Judge each frame as a viewer with the sound off, who never read the release notes: the picture alone must say what the release brings.
4. **Measure.** Cut a second of the intro, of a scene and of the outro with `ffmpeg -ss <s> -t 1 -i release-video.mp4 -af volumedetect -vn -f null -` and compare with the levels in [sound.md](../references/sound.md). You cannot listen: say it was measured, not heard.
5. **Report.** Give the path, the duration, and any avatar the script replaced by the logo.

## Test

| Case | Pass |
| --- | --- |
| Plan the check refuses | no render; back to plan |
| Account that no longer exists | video renders; the replaced avatar is reported |
| 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 |
| Finished render | MP4 present, duration reported, nothing published |
28 changes: 28 additions & 0 deletions .claude/skills/release-video/actions/03-compose-music.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# 03 - Compose music

Write a new original 8-bit track when the current one tires or does not fit.

## Input

What to change, in the user's words: slower, calmer, another mood.

## Output

`assets/music.mp3` in this skill, which every render then uses; the next step is [render](02-render.md).

## Process

1. **Shape.** Edit `assets/compose-music.py`: tempo, chords and levels, keeping the three parts of [sound.md](../references/sound.md).
2. **Compose.** Run `python3 .claude/skills/release-video/assets/compose-music.py <out.wav>`; it needs numpy.
3. **Encode.** Run `ffmpeg -i <out.wav> -b:a 128k .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.

## Test

| Case | Pass |
| --- | --- |
| Script run | a wav of about 44 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 |
192 changes: 192 additions & 0 deletions .claude/skills/release-video/assets/check-release-video.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
#!/usr/bin/env node
/**
* Fails when a feature shipped since the last release has no scene in the release video, and
* no written reason for being left out. A release with no `feat:` commit needs no video.
*
* A surface is something a user can run: a plugin skill (`aidd-dev:10-batch`) or a CLI command
* (`aidd telemetry`). It is read from the files a `feat:` commit adds or modifies, so a
* rename shows the new name only.
*/

const { execFileSync } = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const { youtubeProblems } = require("./youtube-metadata.js");

const ROOT = path.resolve(__dirname, "../../../..");
const PLAN_PATH = path.join(ROOT, "aidd_docs/release-video/scenes.json");

/** Files in the commands folder that are plumbing, not a command a user types. */
const NOT_COMMANDS = new Set([
"global-options",
"menu",
"spawn-cli-command",
"sync-native-activation",
]);

const SKILL = /^plugins\/([^/]+)\/skills\/(\d{2}-[^/]+)\//;
const COMMAND = /^cli\/src\/presentation\/commands\/([^/]+)\.ts$/;

function surfaceOf(file) {
const skill = SKILL.exec(file);
if (skill) return `${skill[1]}:${skill[2]}`;
const command = COMMAND.exec(file);
if (command && !NOT_COMMANDS.has(command[1])) return `aidd ${command[1]}`;
return null;
}

function surfacesOf(files) {
const found = new Set(files.map(surfaceOf).filter(Boolean));
return [...found].sort();
}

/** Where a surface lives, so a surface added then removed or renamed in the same cycle is dropped. */
function pathOf(surface) {
if (surface.startsWith("aidd ")) {
return `cli/src/presentation/commands/${surface.slice(5)}.ts`;
}
const [plugin, skill] = surface.split(":");
return `plugins/${plugin}/skills/${skill}`;
}

function shown(plan) {
return new Set(plan.scenes.flatMap((scene) => scene.surfaces ?? []));
}

function missingScenes(surfaces, plan) {
const covered = shown(plan);
const skipped = new Set(plan.skipped.map((skip) => skip.surface));
return surfaces.filter((s) => !covered.has(s) && !skipped.has(s));
}

function invalidSkips(plan) {
return plan.skipped
.filter((skip) => !String(skip.reason ?? "").trim())
.map((skip) => skip.surface);
}

/** Without GIT_DIR and the like: a hook sets them, and they would point git at the repository
* being committed instead of `cwd`. */
const withoutGitEnv = () =>
Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith("GIT_")));

const MAX_TAGLINE_WORDS = 6;
const MAX_HIGHLIGHTS = 3;
const MAX_HIGHLIGHT_WORDS = 5;
const MAX_LEAD_WORDS = 14;

const wordCount = (text) => String(text ?? "").trim().split(/\s+/).filter(Boolean).length;

/** The first slide and each lead are read in a glance: a long one is refused, not trimmed. */
function overlongText(plan) {
const found = [];
const { tagline, highlights = [] } = plan.intro ?? {};
if (wordCount(tagline) > MAX_TAGLINE_WORDS) {
found.push(`intro tagline: ${wordCount(tagline)} words, ${MAX_TAGLINE_WORDS} at most`);
}
if (highlights.length > MAX_HIGHLIGHTS) {
found.push(`intro highlights: ${highlights.length}, ${MAX_HIGHLIGHTS} at most`);
}
highlights.forEach((text, i) => {
if (wordCount(text) > MAX_HIGHLIGHT_WORDS) {
found.push(`intro highlight ${i + 1}: ${wordCount(text)} words, ${MAX_HIGHLIGHT_WORDS} at most`);
}
});
plan.scenes.forEach((scene, i) => {
if (wordCount(scene.lead) > MAX_LEAD_WORDS) {
found.push(`scene ${i + 1} lead: ${wordCount(scene.lead)} words, ${MAX_LEAD_WORDS} at most`);
}
});
return found;
}

/** Words only the people who build AIDD know. A viewer reads the outcome in plain words; the demo
* window may show the real commands. */
const JARGON = /\b(tokens?|envelopes?|axis|transcripts?|payloads?|hooks?)\b/gi;

function jargonIn(plan) {
const found = [];
const scan = (where, text) => {
for (const [word] of String(text ?? "").matchAll(JARGON)) found.push(`${where}: "${word.toLowerCase()}"`);
};
const { tagline, highlights = [] } = plan.intro ?? {};
scan("intro tagline", tagline);
highlights.forEach((text, i) => scan(`intro highlight ${i + 1}`, text));
plan.scenes.forEach((scene, i) => {
for (const field of ["title", "accent", "lead"]) scan(`scene ${i + 1} ${field}`, scene[field]);
});
return found;
}

function git(cwd, ...args) {
return execFileSync("git", args, { cwd, encoding: "utf8", env: withoutGitEnv() }).trim();
}

/** Surfaces touched by `feat:` commits since the last release tag and still present at HEAD. */
function featSurfaces(cwd = ROOT) {
const tag = git(cwd, "describe", "--tags", "--abbrev=0", "--match", "v[0-9]*");
const log = git(cwd, "log", `${tag}..HEAD`, "--no-merges", "--format=%H%x09%s");
const files = log
.split("\n")
.filter(Boolean)
.map((line) => line.split("\t"))
.filter(([, subject]) => /^feat(\([^)]*\))?!?:/.test(subject))
.flatMap(([sha]) =>
git(cwd, "show", "--name-only", "--diff-filter=AM", "--format=", sha).split("\n")
);
return surfacesOf(files).filter((surface) => fs.existsSync(path.join(cwd, pathOf(surface))));
}

/** A theme (the pill, top left) groups what touches the same domain: its scenes follow each other. */
function splitThemes(plan) {
const seen = new Set();
const split = new Set();
let current;
for (const { pill } of plan.scenes) {
if (pill !== current && seen.has(pill)) split.add(pill);
seen.add(pill);
current = pill;
}
return [...split];
}

function readPlan() {
if (!fs.existsSync(PLAN_PATH)) return { scenes: [], skipped: [] };
const plan = JSON.parse(fs.readFileSync(PLAN_PATH, "utf8"));
return { intro: plan.intro, youtube: plan.youtube, scenes: plan.scenes ?? [], skipped: plan.skipped ?? [] };
}

function run({
surfaces = featSurfaces(),
plan = readPlan(),
logger = console.error,
successLogger = console.log,
} = {}) {
const missing = missingScenes(surfaces, plan);
const unexplained = invalidSkips(plan);
const tooLong = overlongText(plan);
const technical = jargonIn(plan);
const split = splitThemes(plan);
const youtube = plan.scenes.length > 0 ? youtubeProblems(plan) : [];

if (missing.length === 0 && unexplained.length === 0 && tooLong.length === 0 && technical.length === 0 && split.length === 0 && youtube.length === 0) {
successLogger(`✅ Release video: ${surfaces.length} surface(s) covered`);
return 0;
}

if (missing.length > 0) logger(`❌ Scènes manquantes : ${missing.join(", ")}`);
if (unexplained.length > 0) {
logger(`❌ Surfaces ignorées sans raison : ${unexplained.join(", ")}`);
}
if (tooLong.length > 0) logger(`❌ Textes trop longs : ${tooLong.join(", ")}`);
if (technical.length > 0) logger(`❌ Mots techniques : ${technical.join(", ")}`);
if (split.length > 0) logger(`❌ Thèmes dispersés : ${split.join(", ")}`);
if (youtube.length > 0) logger(`❌ Texte YouTube : ${youtube.join(", ")}`);
return 1;
}

module.exports = { featSurfaces, invalidSkips, jargonIn, missingScenes, overlongText, readPlan, run, splitThemes, surfacesOf };

if (require.main === module) {
process.exit(run());
}
60 changes: 60 additions & 0 deletions .claude/skills/release-video/assets/compose-music.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""Compose the release video's 8-bit track: pulse waves and a triangle bass, no noise, no drums.

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 <out.wav>
Needs numpy. Turn the wav into the track with: ffmpeg -i <out.wav> -b:a 128k 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
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(ph<duty,1.0,-1.0)
def tri(f,dur):
n=int(dur*SR); ph=(np.arange(n)*f/SR)%1.0
return 4*np.abs(ph-0.5)-1
def env(n,decay):
t=np.arange(n)/SR; e=np.exp(-t*decay); e[:40]*=np.linspace(0,1,40); e[-80:]*=np.linspace(1,0,80); return e
M=np.zeros(N)
def put(sig,start):
i=int(start*SR); n=min(len(sig),N-i)
if n>0: 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())
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file added .claude/skills/release-video/assets/logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .claude/skills/release-video/assets/music.mp3
Binary file not shown.
Loading
Loading