#!/usr/bin/env bun /** * Encodes a source video into an immutable HLS (fMP4) package plus scrub sprite sheets: * bun scripts/video/prepare.ts --source in.mov --id intro --version v1 [--out dir] * Needs ffmpeg/ffprobe (libx264, aac) and cwebp. Upload the result with scripts/video/upload.ts. */ import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { parseArgs } from "node:util"; import { SCRUB_MANIFEST, type ScrubManifest } from "../../src/lib/video"; const LADDER = [ { height: 240, bitrate: 400_000, audio: "64k" }, { height: 360, bitrate: 1_000_000, audio: "96k" }, { height: 720, bitrate: 3_000_000, audio: "128k" }, { height: 1080, bitrate: 6_000_000, audio: "128k" }, { height: 1440, bitrate: 10_000_000, audio: "160k" }, { height: 2160, bitrate: 20_000_000, audio: "160k" }, ]; const SEGMENT_SECONDS = 4; const SCRUB = { width: 256, height: 144, columns: 10, rows: 10, maxFrames: 1000 }; const MAX_FRAME_RATE = 60; const QUARTER_TURN = 90; const HALF_TURN = 180; const ID = /^[a-z0-9][a-z0-9-]{0,63}$/; async function run(argv: string[]): Promise { const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe" }); const [out, err, code] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); if (code !== 0) throw new Error(`${argv[0]} exited ${code}: ${err.trim()}`); return out; } interface Probe { width: number; height: number; frameRate: number; duration: number; hasAudio: boolean; } async function probe(source: string): Promise { const json = JSON.parse( await run(["ffprobe", "-v", "error", "-show_streams", "-show_format", "-of", "json", source]), ) as { streams: Array<{ codec_type: string; width?: number; height?: number; avg_frame_rate?: string; side_data_list?: Array<{ rotation?: number }>; }>; format: { duration: string }; }; const video = json.streams.find((stream) => stream.codec_type === "video"); if (!video?.width || !video.height || !video.avg_frame_rate) throw new Error("Source has no usable video stream"); const [num, den] = video.avg_frame_rate.split("/").map(Number); const rotated = Math.abs(video.side_data_list?.find((d) => d.rotation !== undefined)?.rotation ?? 0) % HALF_TURN === QUARTER_TURN; return { width: rotated ? video.height : video.width, height: rotated ? video.width : video.height, frameRate: Math.min(MAX_FRAME_RATE, num / (den || 1)), duration: Number(json.format.duration), hasAudio: json.streams.some((stream) => stream.codec_type === "audio"), }; } const even = (value: number) => Math.max(2, Math.floor(value / 2) * 2); async function encodeRendition(source: string, dir: string, info: Probe, rung: (typeof LADDER)[number]) { const height = Math.min(rung.height, even(info.height)); const width = even((info.width * height) / info.height); const out = join(dir, String(height)); await mkdir(out); console.info(`Encoding ${width}x${height} @ ${rung.bitrate / 1e6} Mbps`); const gop = String(Math.round(info.frameRate * 2)); await run([ "ffmpeg", "-hide_banner", "-loglevel", "error", "-i", source, "-map", "0:v:0", "-map", "0:a:0?", "-map_metadata", "-1", "-vf", `scale=${width}:${height},setsar=1,fps=${info.frameRate}`, "-c:v", "libx264", "-preset", "medium", "-profile:v", "high", "-pix_fmt", "yuv420p", "-b:v", String(rung.bitrate), "-maxrate", String(rung.bitrate * 1.25), "-bufsize", String(rung.bitrate * 2), "-g", gop, "-keyint_min", gop, "-sc_threshold", "0", "-force_key_frames", "expr:gte(t,n_forced*2)", "-c:a", "aac", "-b:a", rung.audio, "-ac", "2", "-ar", "48000", "-f", "hls", "-hls_time", String(SEGMENT_SECONDS), "-hls_playlist_type", "vod", "-hls_segment_type", "fmp4", "-hls_flags", "independent_segments", "-hls_fmp4_init_filename", "init.mp4", "-master_pl_name", "master.m3u8", "-hls_segment_filename", join(out, "%05d.m4s"), join(out, "index.m3u8"), ]); const variantMaster = await readFile(join(out, "master.m3u8"), "utf8"); await rm(join(out, "master.m3u8")); const inf = variantMaster.split("\n").find((line) => line.startsWith("#EXT-X-STREAM-INF")); if (!inf) throw new Error(`ffmpeg wrote no stream info for ${height}p`); return { height, line: inf, playlist: `${height}/index.m3u8` }; } async function prepareScrub(source: string, dir: string, duration: number): Promise { const interval = Math.max(1, Math.ceil(duration / SCRUB.maxFrames)); const scrubDir = join(dir, "scrub"); await mkdir(scrubDir); const { width, height, columns, rows } = SCRUB; await run([ "ffmpeg", "-hide_banner", "-loglevel", "error", "-i", source, "-an", "-vf", `fps=1/${interval},scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,tile=${columns}x${rows}`, "-start_number", "0", join(scrubDir, "sheet-%03d.png"), ]); const pngs = (await readdir(scrubDir)).filter((name) => name.endsWith(".png")).sort(); const sheets: string[] = []; for (const png of pngs) { const webp = png.replace(/\.png$/, ".webp"); await run(["cwebp", "-quiet", "-q", "60", "-m", "6", join(scrubDir, png), "-o", join(scrubDir, webp)]); await rm(join(scrubDir, png)); sheets.push(`scrub/${webp}`); } const count = Math.min(Math.max(1, Math.floor(duration / interval)), sheets.length * columns * rows); const manifest: ScrubManifest = { interval, count, frameWidth: width, frameHeight: height, columns, rows, sheets }; await writeFile(join(dir, SCRUB_MANIFEST), `${JSON.stringify(manifest)}\n`); return manifest; } const LANG = /^[a-z]{2}$/; const POSTER_SECONDS = 5; export async function prepareVideo(options: { source: string; id: string; version: string; out?: string; captions?: string; lang?: string; }) { const lang = options.lang ?? "en"; if (!LANG.test(lang)) throw new Error("lang must be a two-letter code"); if (!ID.test(options.id) || !ID.test(options.version)) throw new Error("id and version must match [a-z0-9-]"); const source = resolve(options.source); const dir = resolve(options.out ?? join(".artifacts/videos", options.id, options.version)); const info = await probe(source); await mkdir(resolve(dir, ".."), { recursive: true }); await mkdir(dir); // fails on an existing directory: versions are immutable const posterHeight = Math.min(720, even(info.height)); await run([ "ffmpeg", "-hide_banner", "-loglevel", "error", "-ss", String(Math.min(POSTER_SECONDS, info.duration / 2)), "-i", source, "-frames:v", "1", "-vf", `scale=-2:${posterHeight},setsar=1`, "-q:v", "3", join(dir, "poster.jpg"), ]); const top = even(info.height); const rungs = LADDER.filter((rung) => rung.height < top); const topRung = LADDER.find((rung) => rung.height >= top) ?? LADDER[LADDER.length - 1]; rungs.push({ ...topRung, height: Math.min(top, topRung.height) }); const variants = []; for (const rung of rungs) variants.push(await encodeRendition(source, dir, info, rung)); const master = ["#EXTM3U", "#EXT-X-VERSION:7", "#EXT-X-INDEPENDENT-SEGMENTS"]; for (const variant of variants) master.push(variant.line, variant.playlist); await writeFile(join(dir, "master.m3u8"), `${master.join("\n")}\n`); const scrub = await prepareScrub(source, dir, info.duration); if (options.captions) { await mkdir(join(dir, "captions")); await run([ "ffmpeg", "-hide_banner", "-loglevel", "error", "-i", resolve(options.captions), join(dir, "captions", `${lang}.vtt`), ]); } const prepared = { id: options.id, version: options.version, duration: info.duration, width: info.width, height: info.height, renditions: variants.map((variant) => variant.height), scrubFrames: scrub.count, captions: options.captions ? [lang] : [], }; await writeFile(join(dir, "prepared.json"), `${JSON.stringify(prepared, null, 2)}\n`); console.info(`Prepared ${options.id}/${options.version} in ${dir}`); return prepared; } if (import.meta.main) { const { values } = parseArgs({ options: { source: { type: "string" }, id: { type: "string" }, version: { type: "string" }, out: { type: "string" }, captions: { type: "string" }, lang: { type: "string" }, }, }); if (!values.source || !values.id || !values.version) { console.error( "prepare --source --id --version [--out ] [--captions --lang en]", ); process.exit(1); } await prepareVideo({ ...values, source: values.source, id: values.id, version: values.version }); }