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
2 changes: 1 addition & 1 deletion .claude/skills/release-video/actions/02-render.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 27 additions & 1 deletion .claude/skills/release-video/assets/render-release-video.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) => {
Expand Down
13 changes: 12 additions & 1 deletion 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, 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 });

Expand Down Expand Up @@ -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"/);
});
Expand Down
Loading