Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions .claude/skills/release-video/actions/02-render.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.
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, 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 <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.
Expand Down
50 changes: 42 additions & 8 deletions .claude/skills/release-video/assets/render-release-video.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = `
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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"));
Expand All @@ -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";
Expand Down Expand Up @@ -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 =
`<img id="intro-logo" src="assets/logo.png" alt="" /><div class="intro-text">` +
`<div class="intro-top"><span class="pill">AIDD Framework</span><span class="rel">NEW RELEASE</span></div>` +
`<div id="ver"><span id="ver-t">v${esc(major)}</span><span class="dot">${minor ? "." : ""}</span><span id="ver-n">${esc(minor)}</span></div>` +
(themes.length ? `<p class="themes">${themes.map(esc).join(" · ")}</p>` : "") +
`</div>`;
return `<!doctype html>
<html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=1920, height=1080" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>${CSS}</style></head>
<body><div id="root" data-composition-id="main" data-start="0" data-duration="1" data-width="1920" data-height="1080">
<section id="thumb" class="scene clip" data-start="0" data-duration="1" data-track-index="0"><div class="inner">${inner}</div></section>
</div>
<script>
window.__timelines = window.__timelines || {};
window.__timelines["main"] = gsap.timeline({ paused: true });
</script></body></html>
`;
}

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) => {
Expand Down
24 changes: 14 additions & 10 deletions scripts/__tests__/render-release-video.test.js
Original file line number Diff line number Diff line change
@@ -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 });

Expand Down Expand Up @@ -70,16 +70,20 @@ test("the intro starts with no version on screen and types it in", () => {
assert.match(html, /AIDD <span class="g">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, /<span id="ver-t">v5<\/span><span class="dot">\.<\/span><span id="ver-n">13<\/span>/);
assert.match(html, /<img id="intro-logo"/);
assert.match(html, /AIDD Framework/);
assert.match(html, /NEW RELEASE/);
assert.match(html, /<p class="themes">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", () => {
Expand Down
Loading