#!/usr/bin/env node // Build the 1080x1920 vertical cut: four boxes, nothing overlapping. // // primary 720x405 at 180,112 // gameplay 1080x972 at 0,541 // secondary 1 480x270 at 48,1537 // secondary 2 480x270 at 552,1537 // // The gameplay is fitted WHOLE rather than cropped to 9:16, because the finales -- // Mortal Kombat's fatality, Pokemon's catch -- play across the full width and are // exactly what a crop would cut away. The game is nearly square (800x720 natively), // so at 1080 wide it is 1080x972 and leaves ~950px of band for the voices. // // Every voice comes from its OWN render over black (solo-voice.mjs + LAYOUT=bg // BG_MAIN_SCALE=1.0 BG_DIM=0). Cropping boxes out of a finished composite cannot // work: a panel is only drawn while its voice sounds, so a rest shows a shrunken // duplicate of the game. An idle box here is simply black. // // node shorts-compose.mjs // COMPOSED= the finished 16:9 build -- supplies the AUDIO, and the picture // during the gimmick windows // GAMEPLAY= the clean background, for the gameplay box // PRIMARY= SEC1= SEC2= the solo voice renders (SEC2 optional) // OFFSET= seconds to delay the solo/gameplay clocks by, so they line up with // COMPOSED. A build with an intro concatenated in front has // composedTime = planTime + introLen - bodyCut; that difference is // this number. Getting it wrong slides every panel against the sound. // GIMMICKS= "a-b,c-d" windows IN COMPOSED TIME where the gameplay box shows the // composed render instead of the clean background, and the voice boxes // stay empty. The Pokemon intro and the catch are gimmicks: they are // the payoff, and they belong on the main screen rather than shrunk // into a box. import { execFileSync } from "node:child_process"; const OUT = process.argv[2]; if (!OUT) throw new Error("usage: shorts-compose.mjs "); const need = (k) => { const v = process.env[k]; if (!v) throw new Error(`need ${k}=`); return v; }; const COMPOSED = need("COMPOSED"), GAMEPLAY = need("GAMEPLAY"), PRIMARY = need("PRIMARY"); const SEC1 = process.env.SEC1 ?? "", SEC2 = process.env.SEC2 ?? ""; const OFFSET = Number(process.env.OFFSET ?? 0); const W = 1080, H = 1920, FPS = Number(process.env.FPS ?? 30); const BOX = { primary: [720, 405, 180, 112], game: [1080, 972, 0, 541], sec1: [480, 270, 48, 1537], sec2: [480, 270, 552, 1537], }; // A tune with only two voices leaves the second secondary box empty for its whole // length, and a lone box at x=48 reads as misaligned rather than as deliberate -- // there is simply 552px of dead space beside it. With nothing to sit next to, it // belongs in the middle. Set CENTRE_LONE=0 to keep the two-box grid. if (!SEC2 && (process.env.CENTRE_LONE ?? "1") === "1") { BOX.sec1[2] = Math.round((W - BOX.sec1[0]) / 2); console.log(`only one secondary voice -> centring its box at x=${BOX.sec1[2]}`); } const dur = Number(process.env.DUR ?? execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", COMPOSED]).toString().trim()); const gim = (process.env.GIMMICKS ?? "").split(",").map((s) => s.trim()).filter(Boolean) .map((s) => s.split("-").map(Number)); // One expression, used positively for the gimmick layer and negatively for everything // it replaces -- so the two can never both be on, or both be off, at a frame. const inGim = gim.length ? gim.map(([a, b]) => `between(t,${a.toFixed(3)},${b.toFixed(3)})`).join("+") : ""; const notGim = inGim ? `not(${inGim})` : ""; console.log(`${dur.toFixed(2)}s, offset ${OFFSET.toFixed(3)}s` + (gim.length ? `, ${gim.length} gimmick window(s): ${gim.map(([a, b]) => `${a}-${b}`).join(" ")}` : "")); // The solo renders and the clean background run on the PLAN clock; the composed build // may carry an intro in front of it. -itsoffset shifts an input's whole timeline, so // one number per input puts them all on the composed clock. const off = OFFSET ? ["-itsoffset", OFFSET.toFixed(3)] : []; const args = ["-nostdin", "-v", "warning", "-y", "-i", COMPOSED, ...off, "-i", GAMEPLAY, ...off, "-i", PRIMARY]; const idx = { composed: 0, game: 1, primary: 2 }; let n = 3; if (SEC1) { args.push(...off, "-i", SEC1); idx.sec1 = n; n += 1; } if (SEC2) { args.push(...off, "-i", SEC2); idx.sec2 = n; n += 1; } const fit = (w, h) => `scale=${w - 2}:${h - 2}:force_original_aspect_ratio=decrease,pad=${w}:${h}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=${FPS}`; const parts = [`color=c=black:s=${W}x${H}:r=${FPS}:d=${dur.toFixed(3)}[bg]`]; // gameplay box: the clean background, except where a gimmick takes it over parts.push(`[${idx.game}:v]${fit(BOX.game[0], BOX.game[1])}[gm]`); let cur = "bg", stage = 0; const over = (src, box, enable) => { const [, , x, y] = box; parts.push(`[${cur}][${src}]overlay=x=${x}:y=${y}:shortest=0:eof_action=pass` + (enable ? `:enable='${enable}'` : "") + `[s${stage}]`); cur = `s${stage}`; stage += 1; }; over("gm", BOX.game, notGim); if (inGim) { // The composed build is 16:9; the gameplay box is nearly square. Fit it whole rather // than filling the box -- filling would crop the sides, which is the one thing this // layout exists to avoid. // // The cost is a SIZE JUMP at the window's edge. A 4:3 capture padded to 16:9 and // then letterboxed into a 1.111 box arrives ~1.6x smaller than the same game fitted // to that box directly. GIMMICK_CROP=W:H:X:Y takes the composed render's own // pillarbox off first, which removes the jump entirely -- but it also cuts anything // drawn in those side bars. Use it where the gimmick is bare game footage (a Mario // RPG transition, a Mortal Kombat fatality) and NOT where panels play across the // full width (the Pokemon alarm and its catch jingle). const crop = process.env.GIMMICK_CROP ? `crop=${process.env.GIMMICK_CROP.split(":").join(":")},` : ""; parts.push(`[${idx.composed}:v]${crop}${fit(BOX.game[0], BOX.game[1])}[gk]`); over("gk", BOX.game, inGim); } parts.push(`[${idx.primary}:v]${fit(BOX.primary[0], BOX.primary[1])}[pv]`); over("pv", BOX.primary, notGim); if (SEC1) { parts.push(`[${idx.sec1}:v]${fit(BOX.sec1[0], BOX.sec1[1])}[s1v]`); over("s1v", BOX.sec1, notGim); } if (SEC2) { parts.push(`[${idx.sec2}:v]${fit(BOX.sec2[0], BOX.sec2[1])}[s2v]`); over("s2v", BOX.sec2, notGim); } parts.push(`[${cur}]null[v]`); args.push("-filter_complex", parts.join(";"), "-map", "[v]", "-map", `${idx.composed}:a?`, "-t", dur.toFixed(3), "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2", "-movflags", "+faststart", OUT); execFileSync("ffmpeg", args, { stdio: ["ignore", "inherit", "inherit"] }); console.log(`wrote ${OUT}`);