diff --git a/.claude/skills/release-video/actions/02-render.md b/.claude/skills/release-video/actions/02-render.md index 711a4a0b7..1eb3dae7d 100644 --- a/.claude/skills/release-video/actions/02-render.md +++ b/.claude/skills/release-video/actions/02-render.md @@ -8,13 +8,13 @@ Turn the accepted plan into the release video and look at it. ## Output -`release-video.mp4` in the working directory. +`release-video.mp4` in the working directory, and `release-video.png` beside it: the thumbnail, a still composition of the logo, the version and the themes, not a frame of the video. ## 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 --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 -i release-video.mp4 -frames:v 1` and read them. +3. **Look.** Extract one frame near the end of each scene with `ffmpeg -ss -i release-video.mp4 -frames:v 1` and read them, and read `release-video.png`. - 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 -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. diff --git a/.claude/skills/release-video/assets/render-release-video.cjs b/.claude/skills/release-video/assets/render-release-video.cjs index 4f2a0eca9..f1c11c056 100644 --- a/.claude/skills/release-video/assets/render-release-video.cjs +++ b/.claude/skills/release-video/assets/render-release-video.cjs @@ -19,7 +19,6 @@ const HYPERFRAMES = "hyperframes@0.8.162"; const INTRO = 5; const INTRO_WITH_HIGHLIGHTS = 6.5; const VERSION_TYPING_FROM = 0.8, VERSION_TYPING_SECONDS = 1.1; -const VERSION_TYPED_AT = VERSION_TYPING_FROM + VERSION_TYPING_SECONDS; const OUTRO = 4; const CSS = ` @@ -84,6 +83,11 @@ td { padding: 14px 14px; border-bottom: 2px solid #15153f; vertical-align: top; .av span { font-family: "JetBrains Mono", monospace; font-size: 32px; font-weight: 700; } #intro .inner { flex-direction: row; align-items: center; justify-content: center; gap: 90px; } #intro-logo { width: 420px; height: 420px; border-radius: 50%; border: 2px solid #2a2a66; } +#thumb .inner { flex-direction: row; align-items: center; justify-content: center; gap: 110px; } +#thumb #intro-logo { width: 560px; height: 560px; } +#thumb #ver { font-size: 330px; } +#thumb .intro-text { min-width: 0; gap: 30px; } +.themes { font-family: "JetBrains Mono", monospace; font-size: 38px; font-weight: 700; color: #a6abe0; white-space: nowrap; } .intro-text { display: flex; flex-direction: column; gap: 22px; min-width: 720px; } .intro-top { display: flex; align-items: center; gap: 22px; } .rel { font-family: "JetBrains Mono", monospace; font-size: 28px; letter-spacing: 0.12em; color: #8a8fc0; } @@ -539,8 +543,8 @@ function fitMusic(source, target, plan) { ffmpeg(["-i", source, "-filter_complex", filter, "-map", "[a]", "-c:a", "libmp3lame", "-q:a", "2", target]); } -function writeProject(dir, plan, version, musicSource) { - fs.mkdirSync(path.join(dir, "assets"), { recursive: true }); +/** What a Hyperframes project needs around its page: the fonts, the logo and the project files. */ +function writeShell(dir) { fs.mkdirSync(path.join(dir, "assets"), { recursive: true }); fs.cpSync(path.join(__dirname, "fonts"), path.join(dir, "assets/fonts"), { recursive: true }); fs.copyFileSync(path.join(__dirname, "logo.png"), path.join(dir, "assets/logo.png")); @@ -550,6 +554,10 @@ function writeProject(dir, plan, version, musicSource) { ); fs.writeFileSync(path.join(dir, "meta.json"), JSON.stringify({ id: "release-video", name: "release-video" })); fs.writeFileSync(path.join(dir, "hyperframes.json"), JSON.stringify({ paths: { assets: "assets" } })); +} + +function writeProject(dir, plan, version, musicSource) { + writeShell(dir); let music; if (musicSource) { music = "assets/music-fit.mp3"; @@ -600,17 +608,43 @@ async function main() { fs.copyFileSync(path.join(dir, "release-video.mp4"), out); console.log(`✅ ${out}`); + const still = fs.mkdtempSync(path.join(os.tmpdir(), "release-thumbnail-")); + writeShell(still); + fs.writeFileSync(path.join(still, "index.html"), thumbnailHtml(plan, { version })); + const stillRender = spawnSync("npx", ["--yes", HYPERFRAMES, "render", "--output", "thumbnail.mp4"], { cwd: still, stdio: "inherit" }); + if (stillRender.status !== 0) throw new Error(`Hyperframes render of the thumbnail failed (${stillRender.status})`); const thumbnail = out.replace(/\.mp4$/, ".png"); - const frame = spawnSync("ffmpeg", ["-y", "-v", "error", "-ss", String(thumbnailAt(plan)), "-i", out, "-frames:v", "1", "-vf", "scale=1280:720", thumbnail], { stdio: "inherit" }); + const frame = spawnSync("ffmpeg", ["-y", "-v", "error", "-i", path.join(still, "thumbnail.mp4"), "-frames:v", "1", "-vf", "scale=1280:720", thumbnail], { stdio: "inherit" }); if (frame.status !== 0) throw new Error(`The thumbnail could not be taken (${frame.status})`); console.log(`✅ ${thumbnail}`); } -/** The picture a video is announced with: the intro with its logo and its whole version, the moment before - * the first tag comes in. */ -const thumbnailAt = () => Math.round((VERSION_TYPED_AT + 0.05) * 100) / 100; +/** The picture a video is announced with, drawn as its own still composition rather than taken from a frame + * of the video: the logo, the release and the whole version, then the themes. */ +function thumbnailHtml(plan, { version }) { + const [major, minor = ""] = String(version).split("."); + const themes = plan.youtube?.themes ?? []; + const inner = + `
` + + `
AIDD FrameworkNEW RELEASE
` + + `
v${esc(major)}${minor ? "." : ""}${esc(minor)}
` + + (themes.length ? `

${themes.map(esc).join(" · ")}

` : "") + + `
`; + return ` + + + +
+
${inner}
+
+ +`; +} -module.exports = { buildHtml, measureRms, musicSections, musicSegments, sceneDuration, soundCues, soundTrack, thumbnailAt, totalDuration }; +module.exports = { buildHtml, measureRms, musicSections, musicSegments, sceneDuration, soundCues, soundTrack, thumbnailHtml, totalDuration }; if (require.main === module) { main().catch((error) => { diff --git a/scripts/__tests__/render-release-video.test.js b/scripts/__tests__/render-release-video.test.js index 3c323ba91..a2dea3f6a 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, measureRms, musicSegments, musicSections, sceneDuration, soundCues, soundTrack, thumbnailAt, totalDuration } = require("../../.claude/skills/release-video/assets/render-release-video.cjs"); +const { buildHtml, measureRms, musicSegments, musicSections, sceneDuration, soundCues, soundTrack, thumbnailHtml, totalDuration } = require("../../.claude/skills/release-video/assets/render-release-video.cjs"); const window = (rows) => ({ label: "Claude Code · ~/acme-app", rows }); @@ -70,16 +70,20 @@ test("the intro starts with no version on screen and types it in", () => { assert.match(html, /AIDD v5\.13<\/span>/); }); -test("the thumbnail is the intro with its logo and its whole version, before the first tag comes in", () => { - for (const intro of [{ highlights: ["a", "b"] }, {}]) { - const at = thumbnailAt({ ...plan, intro }); - const html = buildHtml({ ...plan, intro }, { version: "5.13" }); - const tagAt = Number(/enter\("#intro (?:\.hl|-tag)", ([\d.]+)/.exec(html)[1]); +test("the thumbnail is its own still composition: the logo, the release and the whole version, then the themes", () => { + const html = thumbnailHtml({ ...plan, youtube: { themes: ["Télémétrie", "Plan"], programme: [] } }, { version: "5.13" }); - assert.equal(at, 1.95); - assert.ok(at < tagAt, `thumbnail at ${at}, first tag at ${tagAt}`); - assert.match(html, /tl\.to\(typed, \{ n: 1, duration: 1\.1,.*\}, 0\.8\)/s); - } + assert.match(html, /v5<\/span>\.<\/span>13<\/span>/); + assert.match(html, /Télémétrie · Plan<\/p>/); + assert.match(html, /data-duration="1"/); + assert.doesNotMatch(html, /class="hl|typed|enter\(/); +}); + +test("a thumbnail with no themes draws none", () => { + assert.doesNotMatch(thumbnailHtml({ ...plan, youtube: undefined }, { version: "5.13" }), /class="themes"/); }); test("a scene's note appears after its last row, not with its window", () => {