From a6ea0777dee21460f8099c73217b512bceb86e9c Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Mon, 26 Oct 2026 17:05:19 +0100 Subject: [PATCH] chore: open the release video on its thumbnail for one frame A chat shows the first frame of a video as its preview, and the intro starts on the bare background, so the Discord announcement showed an empty picture. The video now begins with the thumbnail for one frame, then the intro as before. It is encoded again at its own bitrate, which kept it smaller. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019tw8HkVy69fuukqR971Roz --- .../skills/release-video/actions/02-render.md | 2 +- .../assets/render-release-video.cjs | 28 ++++++++++++++++++- .../__tests__/render-release-video.test.js | 13 ++++++++- 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/.claude/skills/release-video/actions/02-render.md b/.claude/skills/release-video/actions/02-render.md index 1eb3dae7d..72ef94415 100644 --- a/.claude/skills/release-video/actions/02-render.md +++ b/.claude/skills/release-video/actions/02-render.md @@ -8,7 +8,7 @@ Turn the accepted plan into the release video and look at it. ## Output -`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. +`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, and it is also the video's first frame, one frame long, so a chat shows it as the preview. ## Process diff --git a/.claude/skills/release-video/assets/render-release-video.cjs b/.claude/skills/release-video/assets/render-release-video.cjs index f1c11c056..47c08e032 100644 --- a/.claude/skills/release-video/assets/render-release-video.cjs +++ b/.claude/skills/release-video/assets/render-release-video.cjs @@ -617,6 +617,14 @@ async function main() { 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}`); + + const probe = spawnSync("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=bit_rate", "-of", "csv=p=0", out], { encoding: "utf8" }); + const bitrate = Number(probe.stdout.trim()); + if (!bitrate) throw new Error("The bitrate of the video could not be read"); + const posted = out.replace(/\.mp4$/, ".poster.mp4"); + const withPoster = spawnSync("ffmpeg", posterArgs({ video: out, poster: thumbnail, out: posted, bitrate }), { stdio: "inherit" }); + if (withPoster.status !== 0) throw new Error(`The thumbnail could not be put first (${withPoster.status})`); + fs.renameSync(posted, out); } /** The picture a video is announced with, drawn as its own still composition rather than taken from a frame @@ -644,7 +652,25 @@ window.__timelines["main"] = gsap.timeline({ paused: true }); `; } -module.exports = { buildHtml, measureRms, musicSections, musicSegments, sceneDuration, soundCues, soundTrack, thumbnailHtml, totalDuration }; +/** The video opens on its thumbnail for one frame: a chat shows a video's first frame as its preview, and the + * intro starts on the bare background. The video is encoded again at its own bitrate, and its sound is delayed + * by that frame. */ +function posterArgs({ video, poster, out, bitrate }) { + const kbps = Math.round(bitrate / 1000); + return [ + "-y", "-v", "error", + "-loop", "1", "-framerate", "30", "-t", "0.0334", "-i", poster, + "-i", video, + "-filter_complex", + "[0:v]scale=1920:1080,setsar=1,fps=30,format=yuv420p[p];[1:v]setsar=1,fps=30,format=yuv420p[v];[p][v]concat=n=2:v=1:a=0[vo];[1:a]adelay=34|34[ao]", + "-map", "[vo]", "-map", "[ao]", + "-c:v", "libx264", "-profile:v", "high", "-b:v", `${kbps}k`, "-maxrate", `${Math.round(kbps * 1.25)}k`, "-bufsize", `${kbps * 2}k`, "-preset", "slow", + "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", + out, + ]; +} + +module.exports = { buildHtml, measureRms, musicSections, musicSegments, sceneDuration, soundCues, posterArgs, 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 a2dea3f6a..f7d4baffe 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, thumbnailHtml, totalDuration } = require("../../.claude/skills/release-video/assets/render-release-video.cjs"); +const { buildHtml, measureRms, musicSegments, musicSections, sceneDuration, soundCues, posterArgs, soundTrack, thumbnailHtml, totalDuration } = require("../../.claude/skills/release-video/assets/render-release-video.cjs"); const window = (rows) => ({ label: "Claude Code · ~/acme-app", rows }); @@ -82,6 +82,17 @@ test("the thumbnail is its own still composition: the logo, the release and the assert.doesNotMatch(html, /class="hl|typed|enter\(/); }); +test("the video opens on the thumbnail for one frame, so a chat shows it as the preview, and keeps its bitrate", () => { + const args = posterArgs({ video: "v.mp4", poster: "t.png", out: "o.mp4", bitrate: 866446 }); + const joined = args.join(" "); + + assert.ok(args.includes("t.png") && args.includes("v.mp4") && args.at(-1) === "o.mp4"); + assert.match(joined, /concat=n=2:v=1:a=0/); + assert.match(joined, /adelay=34\|34/); + assert.equal(args[args.indexOf("-b:v") + 1], "866k"); + assert.equal(args[args.indexOf("-maxrate") + 1], "1083k"); +}); + test("a thumbnail with no themes draws none", () => { assert.doesNotMatch(thumbnailHtml({ ...plan, youtube: undefined }, { version: "5.13" }), /class="themes"/); });