Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 44e778522a4ef0a75f24028ec56800316a172762
parent 1cad6c43041ff4e8aa812694aace18eee55baa75
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 11:58:28 -0400

umtool: the 20 one-off song run-log scripts deleted

backfill-build, mk-fatal-finish{,2,3}, mk-rebuild, ms2-{bg3,drums,full,
jerbg,play-rebuild,triangle}, pk2, pk3, pk-v12, pkmn-{rebuild,video},
rpg-remake-v5, rpg-short, vshort, yoshi-rebuild: each began with a
literal cd into one machine's job dir and was never runnable from the
app. lib/jobs.ts and BuildChain's note say it in the past tense. The
historical mentions in debox-bg, hush-head, pick-take and video-dir stay.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mumtool/components/BuildChain.tsx | 11++++++-----
Mumtool/lib/jobs.ts | 21+++++++++++----------
Dumtool/song/backfill-build.sh | 89-------------------------------------------------------------------------------
Dumtool/song/mk-fatal-finish.sh | 57---------------------------------------------------------
Dumtool/song/mk-fatal-finish2.sh | 64----------------------------------------------------------------
Dumtool/song/mk-fatal-finish3.sh | 164-------------------------------------------------------------------------------
Dumtool/song/mk-rebuild.sh | 55-------------------------------------------------------
Dumtool/song/ms2-bg3.sh | 31-------------------------------
Dumtool/song/ms2-drums.sh | 36------------------------------------
Dumtool/song/ms2-full.sh | 18------------------
Dumtool/song/ms2-jerbg.sh | 110-------------------------------------------------------------------------------
Dumtool/song/ms2-play-rebuild.sh | 113-------------------------------------------------------------------------------
Dumtool/song/ms2-triangle.sh | 94-------------------------------------------------------------------------------
Dumtool/song/pk-v12.sh | 69---------------------------------------------------------------------
Dumtool/song/pk2.sh | 166-------------------------------------------------------------------------------
Dumtool/song/pk3.sh | 375-------------------------------------------------------------------------------
Dumtool/song/pkmn-rebuild.sh | 123-------------------------------------------------------------------------------
Dumtool/song/pkmn-video.sh | 122-------------------------------------------------------------------------------
Dumtool/song/rpg-remake-v5.sh | 277-------------------------------------------------------------------------------
Dumtool/song/rpg-short.sh | 152-------------------------------------------------------------------------------
Dumtool/song/vshort.sh | 52----------------------------------------------------
Dumtool/song/yoshi-rebuild.sh | 81-------------------------------------------------------------------------------
22 files changed, 17 insertions(+), 2263 deletions(-)

diff --git a/umtool/components/BuildChain.tsx b/umtool/components/BuildChain.tsx @@ -141,12 +141,13 @@ export default function BuildChain({ setId }: { setId: string }) { </button> </div> - {/* Nothing here can be re-pointed at the whole-song .sh builds: they - hardcode their own paths, ignore SONG_DIR, and take 20+ minutes. */} + {/* The whole-song .sh builds were never runnable here: they hardcoded + their own paths, ignored SONG_DIR, and took 20+ minutes. They were + deleted in release 12 (lib/jobs.ts). */} <p className="mt-2 text-[11px] text-[var(--color-dim)]"> - The whole-song rebuild scripts are deliberately not runnable from here — they hardcode their - paths, ignore <span className="font-mono">SONG_DIR</span>, and re-arrange - non-deterministically. Run those from a shell. + The whole-song rebuild scripts were one-off shell run logs that hardcoded their paths + and ignored <span className="font-mono">SONG_DIR</span>; they are not in the tree, and + not runnable from here. </p> {error && <p className="mt-2 text-[11px] text-[var(--color-bad)]">{error}</p>} diff --git a/umtool/lib/jobs.ts b/umtool/lib/jobs.ts @@ -5,16 +5,17 @@ import { ENV_DENY, type Step } from "./trim"; // Running a chain of steps, with the guardrails that make it safe to put behind // a button. // -// WHAT IS DELIBERATELY NOT HERE: the whole-song build scripts. +// WHAT WAS DELIBERATELY NEVER HERE: the whole-song build scripts. // // mk-rebuild.sh, pkmn-rebuild.sh, yoshi-rebuild.sh, rpg-remake-v5.sh and the -// ms2-*.sh family all begin with a literal `cd /home/user/.claude/jobs/...` -// and write to a literal reports path. They ignore SONG_DIR and -// SONG_REPORTS_DIR entirely, which means an e2e fixture cannot contain them -- -// a spec that clicked the button would render into the real deliverables tree. -// They also run 20+ minutes, and arrange-poly is non-deterministic across a -// changed palette, so one click can invalidate a set of judgements already made -// by ear. The UI prints their command instead. +// ms2-*.sh family were one-off shell run logs that hardcoded their paths: each +// began with a literal `cd` into the job temp dir the song was mined in and +// wrote to a literal reports path, ignoring SONG_DIR and SONG_REPORTS_DIR, so +// no e2e fixture could contain them -- a spec that clicked a button would have +// rendered into the real deliverables tree. They also ran 20+ minutes, and +// arrange-poly is non-deterministic across a changed palette, so one click +// could invalidate a set of judgements already made by ear. They are not in +// the tree any more (release 12), and nothing here can run them. // // What runs here is the .mjs chain, which honours SONG_DATA through // song/paths.mjs and finishes in seconds to minutes. @@ -68,8 +69,8 @@ export const getJob = (id: string) => jobs.get(id) ?? null; export const recentJobs = (n = 10) => [...jobs.values()].sort((a, b) => b.startedAt - a.startedAt).slice(0, n); -/** The accidental-hour-long-job guard. None of the .sh builds fits under any - * cap worth setting, which is the other reason they stay out. +/** The accidental-hour-long-job guard. None of the old .sh builds fitted under + * any cap worth setting, which was the other reason they were never here. * * A step may ask for more (Step.timeoutMs). A 19-clip crossfaded report build * runs 20 to 40 minutes and would otherwise be SIGKILLed at 15 -- but raising diff --git a/umtool/song/backfill-build.sh b/umtool/song/backfill-build.sh @@ -1,89 +0,0 @@ -#!/usr/bin/env bash -# Backfill build.json for the songs whose recipe can actually be READ OFF a builder. -# -# WHAT IS HERE IS VERIFIED, AND THE GAPS ARE DELIBERATE. -# -# Only two builders write into videos/<song>/ themselves, which is what makes their -# output attributable without guessing: -# -# pkmn-video.sh line 116 OUT=$V/<wide|vertical>[-short].mp4 all 4 cuts -# ms2-jerbg.sh line 102 mux -> "$V/$NAME.mp4" wide, wide-short -# line 104 mux -> "$V/variants/$NAME-umdrums.mp4" -# line 84 ffmpeg -> "$V/variants/$NAME-nokit.mp4" -# -# The other three are NOT recorded, and must not be guessed at: -# -# mortal-kombat mk-fatal-finish3.sh takes OUT as a required env var and names no -# plan at all -- it assembles audio. Which of the five files in -# plan/ produced which cut is not in the script. -# mario-rpg rpg-short.sh defaults PLANF=rpg-plan-short.json, and there is NO -# such file in videos/mario-rpg/plan/ -- it holds rpg-plan-s2/s3 -# (the 2-voice vs 3-voice pair still awaiting a decision) and v4. -# So the shipped cuts came from an invocation this script does not -# describe. -# yoshi yoshi-rebuild.sh defaults PLANF=yoshi-plan-v7.json, and plan/ -# holds the SHORT plans. Same problem. -# -# A build.json that says "probably this plan" is worse than one that says nothing: -# the whole point of the file is that it can be trusted. The browse UI renders an -# unrecorded file as unrecorded and offers the plan picker instead. -# -# Idempotent -- re-running just rewrites the same entries with a fresh `written`. -set -euo pipefail -cd "$(dirname "$0")" - -D=/home/user/reports/quartering-uh-song -V=${VIDEO_ROOT:-$D/videos} -export VIDEO_ROOT=$V - -rec () { node video-dir.mjs "$@"; } - -echo "--- pokemon (pkmn-video.sh: FORMAT x CUT, writes each cut directly)" -JINGLE=pkc-jingle-merged.json -for fmt in wide vertical; do - for cut in long short; do - rel=$fmt$( [ "$cut" = short ] && echo -short || echo "" ).mp4 - [ -f "$V/pokemon/$rel" ] || { echo " skip $rel (not built)"; continue; } - if [ "$cut" = long ]; then BODY=pkmn-v12-body.json; RUN=pkmn-v12-run.json - else BODY=pkmn-short-body.json; RUN=pkmn-short-run.json; fi - rec --song pokemon --rel "$rel" \ - --script pkmn-video.sh \ - --env "FORMAT=$fmt" --env "CUT=$cut" \ - --plan "$BODY" --plan "$RUN" --plan "$JINGLE" \ - --note "body + run + the catch jingle; the shape is the video, see the script header" - done -done - -echo "--- metal-slug (ms2-jerbg.sh: jer's edit, handover on MISSION 1 START)" -BG=$D/jer-metalslug-bg.mp4 -ANN=$D/MS_Mission_1_start.ogg -for name in wide wide-short; do - case $name in - wide) PLAN=ms2-plan-11-v3.json ;; - wide-short) PLAN=ms2-plan-short2.json ;; - esac - # The shipped cut carries the game's own kit; -umdrums swaps it for the um kit; - # -nokit is the same mix with no drums at all. One recipe, three outputs. - for rel in "$name.mp4" "variants/$name-nokit.mp4" "variants/$name-umdrums.mp4"; do - [ -f "$V/metal-slug/$rel" ] || { echo " skip $rel (not built)"; continue; } - case $rel in - *-nokit.mp4) NOTE="the handover mix with no drum kit" ;; - *-umdrums.mp4) NOTE="the um kit in place of the game's" ;; - *) NOTE="shipped: the game's own kit, arrangement shifted +0.669s onto MISSION 1 START" ;; - esac - rec --song metal-slug --rel "$rel" \ - --script ms2-jerbg.sh \ - --env "BG=$BG" --env "ANNOUNCER=$ANN" \ - --plan "$PLAN" \ - --asset "$BG" --asset "$ANN" \ - --note "$NOTE" - done -done - -echo -echo "NOT recorded, and deliberately so -- the builder does not say which plan:" -echo " mortal-kombat/* mk-fatal-finish3.sh names no plan (OUT is a required env var)" -echo " mario-rpg/* rpg-short.sh defaults to rpg-plan-short.json, absent from plan/" -echo " yoshi/* yoshi-rebuild.sh defaults to yoshi-plan-v7.json, absent from plan/" -echo -node video-dir.mjs --list diff --git a/umtool/song/mk-fatal-finish.sh b/umtool/song/mk-fatal-finish.sh @@ -1,57 +0,0 @@ -#!/usr/bin/env bash -# Finish the Mortal Kombat fatality build: put the GAME audio back under the -# rendered song, then hand over to the fatality at full brightness. -# -# The renderer stops its audio at the last note (~144.2s) but leaves ~2.9s of -# SILENT video after it, so the container duration is not the song's end. Cutting -# on the container would put the freeze and the shatter inside the dimmed body. -# The body is therefore cut at the last audible sample, measured here. -# -# Video: body (dimmed by the renderer) ++ background tail (full brightness). -# Audio: the song, plus the game under it -- full through the gong, silent under -# the song, back up as the song ends so the shatter is heard. -set -euo pipefail -cd "$(dirname "$0")" - -BODY=${BODY:-/home/user/.claude/jobs/efbe67a7/tmp/mkfatal.mp4} -BG=${BG:-intro/mk-bg-fatal.mp4} -OUT=${OUT:-/home/user/reports/quartering-uh-song/quartering-mk-fatality.mp4} - -# Where the FATALITY screen gives way to the tournament ladder. Ending here means -# the video finishes ON the payoff instead of drifting into the next screen. -END=${END:-153.10} -# The game's gong is the select-screen sting at 18.086s, after a 0.9s silence -- -# NOT 17s, and an 18s gate would mute it. -GONG_HOLD=${GONG_HOLD:-19.0} # game full until here -GAME_OUT=${GAME_OUT:-20.8} # ...faded out by here, before the 21s cut -FADE_UP=${FADE_UP:-1.4} # game rises over this long, landing on the join -GAME_GAIN=${GAME_GAIN:-0.90} - -# Last audible sample of the body = the song's real end. -T=$(ffmpeg -v error -i "$BODY" -ac 1 -ar 1000 -f s16le - | node -e ' -const c=[];process.stdin.on("data",d=>c.push(d));process.stdin.on("end",()=>{ -const b=Buffer.concat(c);const a=new Int16Array(b.buffer,b.byteOffset,b.length/2); -let last=0; for(let i=0;i<a.length;i++) if(Math.abs(a[i])>8) last=i; -console.log((last/1000+0.04).toFixed(3));});') - -FU=$(node -e "console.log(($T - $FADE_UP).toFixed(3))") -TAILDUR=$(node -e "console.log(($END - $T).toFixed(3))") -echo "song ends ${T}s · game rises ${FU}->${T}s · fatality tail ${T}->${END}s (${TAILDUR}s)" - -ffmpeg -y -v warning \ - -i "$BODY" \ - -i "$BG" \ - -ss "$T" -t "$TAILDUR" -i "$BG" \ - -filter_complex " -[1:a]atrim=0:${END},asetpts=PTS-STARTPTS,volume='${GAME_GAIN}*(if(lt(t,${GONG_HOLD}),1.0, if(lt(t,${GAME_OUT}),1-(t-${GONG_HOLD})/(${GAME_OUT}-${GONG_HOLD}), if(lt(t,${FU}),0.0, min(1.0,(t-${FU})/${FADE_UP})))))':eval=frame[game]; -[0:a]atrim=0:${T},asetpts=PTS-STARTPTS,apad=whole_dur=${END}[song]; -[song][game]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]; -[0:v]trim=0:${T},setpts=PTS-STARTPTS[bodyv]; -[2:v]scale=1280:720,fps=30,setsar=1,format=yuv420p,setpts=PTS-STARTPTS[tailv]; -[bodyv][tailv]concat=n=2:v=1[v]" \ - -map "[v]" -map "[a]" -shortest \ - -c:v libx264 -preset medium -crf 18 -pix_fmt yuv420p \ - -c:a aac -b:a 192k -movflags +faststart "$OUT" - -echo "wrote $OUT" -ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT" diff --git a/umtool/song/mk-fatal-finish2.sh b/umtool/song/mk-fatal-finish2.sh @@ -1,64 +0,0 @@ -#!/usr/bin/env bash -# Assemble the Mortal Kombat fatality build from an already-mixed audio track. -# -# Ending, as asked for: -# song ends -> a beat of DEAD AIR -> the game returns QUIETLY (you can see the -# fatality land, you can only just hear it) -> the game rises to full during -# its OWN natural quiet gap (source 260.3-261.2s) -> the "dun dun dun" fanfare -# at source 261.3s is the first sound back at FULL volume, with FATALITY on -# screen. -# -# Background is mk-bg-fatal2.mp4: source 0->21.0 ++ 128.5->266.0, so beyond the -# cut output = source - 107.5. -set -euo pipefail -cd "$(dirname "$0")" - -BODY=${BODY:-/home/user/.claude/jobs/efbe67a7/tmp/mkfatal2.mp4} # video (+ the un-hooked song, unused here) -AUDIO=${AUDIO:?need the mixed audio wav} -BG=${BG:-intro/mk-bg-fatal2.mp4} -OUT=${OUT:?need an output path} - -DEAD=${DEAD:-2.0} # seconds of silence after the song -LOW=${LOW:-0.25} # how loud the game is during the fatality itself -LOW_IN=${LOW_IN:-0.8} # how long it takes to reach LOW -FULL_AT=${FULL_AT:-152.80} # start of the game's own quiet gap (source 260.30) -FULL_BY=${FULL_BY:-153.70} # ...end of it (source 261.20); fanfare is at 153.80 -END=${END:-156.10} # FATALITY screen gives way to the ladder (source 263.60) -GONG_HOLD=${GONG_HOLD:-19.0} -GAME_OUT=${GAME_OUT:-20.8} -GAME_GAIN=${GAME_GAIN:-0.90} - -# The song's real end = last audible sample of the MIXED audio. -T=$(ffmpeg -v error -i "$AUDIO" -ac 1 -ar 1000 -f s16le - | node -e ' -const c=[];process.stdin.on("data",d=>c.push(d));process.stdin.on("end",()=>{ -const b=Buffer.concat(c);const a=new Int16Array(b.buffer,b.byteOffset,b.length/2); -let last=0; for(let i=0;i<a.length;i++) if(Math.abs(a[i])>8) last=i; -console.log((last/1000+0.04).toFixed(3));});') - -Q1=$(node -e "console.log(($T + $DEAD).toFixed(3))") -Q2=$(node -e "console.log(($T + $DEAD + $LOW_IN).toFixed(3))") -TAILDUR=$(node -e "console.log(($END - $T).toFixed(3))") -echo "song ends ${T}s · dead air to ${Q1}s · game at ${LOW} by ${Q2}s · full ${FULL_AT}->${FULL_BY}s · end ${END}s" - -# volume envelope, in order: gong held full, out before the cut, silent under the -# song, dead air, up to LOW for the fatality, up to FULL for the fanfare. -VOL="${GAME_GAIN}*(if(lt(t,${GONG_HOLD}),1.0, if(lt(t,${GAME_OUT}),1-(t-${GONG_HOLD})/(${GAME_OUT}-${GONG_HOLD}), if(lt(t,${Q1}),0.0, if(lt(t,${Q2}),${LOW}*(t-${Q1})/(${LOW_IN}), if(lt(t,${FULL_AT}),${LOW}, if(lt(t,${FULL_BY}),${LOW}+(1-${LOW})*(t-${FULL_AT})/(${FULL_BY}-${FULL_AT}),1.0)))))))" - -ffmpeg -y -v warning \ - -i "$BODY" \ - -i "$AUDIO" \ - -i "$BG" \ - -ss "$T" -t "$TAILDUR" -i "$BG" \ - -filter_complex " -[2:a]atrim=0:${END},asetpts=PTS-STARTPTS,volume='${VOL}':eval=frame[game]; -[1:a]apad=whole_dur=${END}[song]; -[song][game]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]; -[0:v]trim=0:${T},setpts=PTS-STARTPTS[bodyv]; -[3:v]scale=1280:720,fps=30,setsar=1,format=yuv420p,setpts=PTS-STARTPTS[tailv]; -[bodyv][tailv]concat=n=2:v=1[v]" \ - -map "[v]" -map "[a]" -shortest \ - -c:v libx264 -preset medium -crf 18 -pix_fmt yuv420p \ - -c:a aac -b:a 192k -movflags +faststart "$OUT" - -echo "wrote $OUT" -ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT" diff --git a/umtool/song/mk-fatal-finish3.sh b/umtool/song/mk-fatal-finish3.sh @@ -1,164 +0,0 @@ -#!/usr/bin/env bash -# Assemble a Mortal Kombat fatality finale from an already-mixed audio track. -# -# Supersedes mk-fatal-finish2.sh, whose DEFAULTS DID NOT DESCRIBE THE SHIPPED -# BUILD: several rounds of feedback ("start the finale earlier", "fade the game -# in earlier", "fatality a touch quieter") were applied as one-off env overrides -# and never written back, so rebuilding from that script silently changed the -# gong and the ending as well as the vocals. -# -# The defaults below are MEASURED off the shipped quartering-mk-fatality-full.mp4 -# against the raw background, region by region: -# -# region shipped/raw set by -# attract music 0.410 GAME_GAIN -# the gong (18.09s) 0.410 GONG_HOLD (still full) -# after gong (18.6s) 0.266 fading, GONG_HOLD -> GAME_OUT -# silence (18.95s) 0.000 GAME_OUT -# dead air (145.0s) 0.048 DEAD -# fatality plateau 0.242 LOW -# ramp (149.5s) 1.015 FULL_BY -# fanfare / end 1.00 (full) -# -# New here: FINISH_HOOK lays "Finish him!" over the tail so the finale is led -# into rather than opened cold on the game's own "dun dun dun" fanfare, and the -# game's drum kit is mixed on at the end instead of in a separate script. -set -euo pipefail -# The DATA dir, not the script's own -- intro/, mkvocals/ and the plan files live -# there, and the repo copy of this script would otherwise cd into the repo. See -# pipeline.md: the data directory holds independent, sometimes stale copies, so the -# repo is authoritative and every builder must name the data dir absolutely. -cd /home/user/.claude/jobs/efbe67a7/tmp/song - -BODY=${BODY:?need the rendered body video} -AUDIO=${AUDIO:?need the mixed audio wav} -BG=${BG:-intro/mk-bg-fatal2.mp4} -OUT=${OUT:?need an output path} - -DEAD=${DEAD:-1.06} # seconds of silence after the song -# 0.17, not the 0.24 the shipped file MEASURES against the raw background. -# -# Match the envelope in the regions that are NOT at the limiter ceiling. The -# fatality and the fanfare read ~1.0 of raw in every version regardless, because -# they ARE at the ceiling — so they cannot be used to set anything. The attract -# music and the fatality plateau are the two windows that can, and both came out -# 1.41x hot at the "measured" values, which is the 0.709 whole-envelope factor -# recorded in mortal-kombat.md. 0.24 x 0.709 = 0.17, 0.41 x 0.709 = 0.29. -LOW=${LOW:-0.17} # how loud the game is during the fatality itself -LOW_IN=${LOW_IN:-0.8} # how long it takes to reach LOW -FULL_AT=${FULL_AT:-148.60} # start of the ramp back to full... -FULL_BY=${FULL_BY:-149.50} # ...measured as where the shipped file reaches full -END=${END:-156.10} # FATALITY gives way to the ladder screen -GONG_HOLD=${GONG_HOLD:-18.5} -GAME_OUT=${GAME_OUT:-19.0} -# ATTRACT is the level of the attract music and the gong ONLY. It is not a gain -# on the whole envelope: the shipped file measures 0.410 against the raw -# background at the attract music but 1.009 at the fanfare, and one multiplier -# cannot produce both. Applying it globally (as -finish2.sh's GAME_GAIN does) -# plays the FATALITY -- the payoff the whole cut is built around -- at 41%. -ATTRACT=${ATTRACT:-0.29} -FULL_GAIN=${FULL_GAIN:-1.0} - -# "Finish him!" into the fanfare. The game's fanfare is at 153.80s, so the shout -# is placed to LAND on it rather than to sit anywhere in particular. -FINISH_HOOK=${FINISH_HOOK:-mkvocals/hooks-verb/hook-17.wav} -FANFARE_AT=${FANFARE_AT:-153.80} -# FINISH_GAIN is a PLAIN MULTIPLIER, not a peak target -- and hook-17 peaks at only -# 0.39 of full scale, so the old 0.85 ATTENUATED it to ~0.33 and dropped it under a game -# running at ~0.9. Measured on the shipped build: the shout is genuinely there -# (cross-correlates r=0.27 at exactly its placed time, 152.560s) and simply inaudible. -# 2.2 puts its peak at ~0.86, level with everything else in the finale. -FINISH_GAIN=${FINISH_GAIN:-2.2} -# ...and the game gets out of its way. A shout at the same level as a full-scale fanfare -# is still a shout nobody can pick out, so the game ducks to FIN_DUCK across the hook, -# ramped either side so the drop is not itself an event. -FIN_DUCK=${FIN_DUCK:-0.32} -FIN_DUCK_IN=${FIN_DUCK_IN:-0.15} -FIN_DUCK_OUT=${FIN_DUCK_OUT:-0.25} -FINISH_LEAD=${FINISH_LEAD:-0.05} # how far its END sits before the fanfare - -DRUMS=${DRUMS:-1} -DRUM_GAIN=${DRUM_GAIN:-1.6} - -# The song's real end = last audible sample of the MIXED audio. The renderer -# leaves silent video past the last note, so a container duration would put the -# freeze and the shatter inside the dimmed body instead of the bright tail. -T=$(ffmpeg -v error -i "$AUDIO" -ac 1 -ar 1000 -f s16le - | node -e ' -const c=[];process.stdin.on("data",d=>c.push(d));process.stdin.on("end",()=>{ -const b=Buffer.concat(c);const a=new Int16Array(b.buffer,b.byteOffset,b.length/2); -let last=0; for(let i=0;i<a.length;i++) if(Math.abs(a[i])>8) last=i; -console.log((last/1000+0.04).toFixed(3));});') - -Q1=$(node -e "console.log(($T + $DEAD).toFixed(3))") -Q2=$(node -e "console.log(($T + $DEAD + $LOW_IN).toFixed(3))") -TAILDUR=$(node -e "console.log(($END - $T).toFixed(3))") -echo "song ends ${T}s · dead air to ${Q1}s · game at ${LOW} by ${Q2}s · full ${FULL_AT}->${FULL_BY}s · end ${END}s" - -VOL="if(lt(t,${GONG_HOLD}),${ATTRACT}, if(lt(t,${GAME_OUT}),${ATTRACT}*(1-(t-${GONG_HOLD})/(${GAME_OUT}-${GONG_HOLD})), if(lt(t,${Q1}),0.0, if(lt(t,${Q2}),${LOW}*(t-${Q1})/(${LOW_IN}), if(lt(t,${FULL_AT}),${LOW}, if(lt(t,${FULL_BY}),${LOW}+(${FULL_GAIN}-${LOW})*(t-${FULL_AT})/(${FULL_BY}-${FULL_AT}),${FULL_GAIN}))))))" - -# Place the shout so its SOUNDED part ends just before the fanfare. -FIN_AT=$(node -e " -const {execFileSync}=require('child_process'); -const d=Number(execFileSync('ffprobe',['-v','error','-show_entries','format=duration','-of','csv=p=0','$FINISH_HOOK']).toString().trim()); -console.log(Math.max(0, ${FANFARE_AT} - ${FINISH_LEAD} - d).toFixed(3));") -# String(), not the bare number: node colourises numeric console.log output on a -# TTY, and those ANSI escapes end up INSIDE the filtergraph, where ffmpeg reads -# them as part of a pad name and the graph fails to bind. -FIN_MS=$(node -e "console.log(String(Math.round($FIN_AT*1000)))") -echo "\"Finish him!\" at ${FIN_AT}s, landing on the ${FANFARE_AT}s fanfare" - -# The duck is applied AFTER VOL is built, because it needs FIN_AT. -FIN_END=$(node -e " -const {execFileSync}=require('child_process'); -const d=Number(execFileSync('ffprobe',['-v','error','-show_entries','format=duration','-of','csv=p=0','$FINISH_HOOK']).toString().trim()); -console.log(String(($FIN_AT + d).toFixed(3)));") -D0=$(node -e "console.log(String(($FIN_AT - $FIN_DUCK_IN).toFixed(3)))") -D3=$(node -e "console.log(String(($FIN_END + $FIN_DUCK_OUT).toFixed(3)))") -DUCK="if(lt(t,${D0}),1, if(lt(t,${FIN_AT}), 1-(1-${FIN_DUCK})*(t-${D0})/${FIN_DUCK_IN}, if(lt(t,${FIN_END}), ${FIN_DUCK}, if(lt(t,${D3}), ${FIN_DUCK}+(1-${FIN_DUCK})*(t-${FIN_END})/${FIN_DUCK_OUT}, 1))))" -VOL="(${VOL})*(${DUCK})" -echo " game ducks to ${FIN_DUCK} across ${FIN_AT}-${FIN_END}s so the shout is heard" - -ffmpeg -y -v warning \ - -i "$BODY" \ - -i "$AUDIO" \ - -i "$BG" \ - -ss "$T" -t "$TAILDUR" -i "$BG" \ - -i "$FINISH_HOOK" \ - -filter_complex " -[2:a]atrim=0:${END},asetpts=PTS-STARTPTS,volume='${VOL}':eval=frame[game]; -[1:a]apad=whole_dur=${END}[song]; -[4:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,volume=${FINISH_GAIN},adelay=delays=${FIN_MS}:all=1,apad=whole_dur=${END}[fin]; -[song][game][fin]amix=inputs=3:duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]; -[0:v]trim=0:${T},setpts=PTS-STARTPTS[bodyv]; -[3:v]scale=1280:720,fps=30,setsar=1,format=yuv420p,setpts=PTS-STARTPTS[tailv]; -[bodyv][tailv]concat=n=2:v=1[v]" \ - -map "[v]" -map "[a]" -shortest \ - -c:v libx264 -preset medium -crf 18 -pix_fmt yuv420p \ - -c:a aac -b:a 192k -movflags +faststart "$OUT" - -# ---- the game's own drum kit ------------------------------------------------ -# Track 10 picked by INDEX, not by name: this file's track names spell the -# author's credit, so a /drum/i match finds nothing. It is 100% GM percussion. -# The kit stops at the song's end, leaving the fatality tail untouched. -if [ "$DRUMS" = "1" ]; then - T2=/home/user/.claude/jobs/efbe67a7/tmp - # DRUM_WAV plays a kit that was rendered elsewhere -- um-drums.mjs builds one - # out of chopped ums. Everything downstream is identical, so the two kits are - # directly comparable: same hits, same gain, same mix. - if [ -n "${DRUM_WAV:-}" ]; then - cp "$DRUM_WAV" "$T2/mk-drums4.wav" - else - TEMPO_SCALE=1.05 TRIM=0 SHIFT=17.45 START=19.0 END="$T" \ - node midi-drums.mjs ~/Downloads/mkmtheme.mid "$T2/mk-drums4.mid" 10 >/dev/null - fluidsynth -ni -F "$T2/mk-drums4.wav" -r 48000 -g 0.8 \ - /usr/share/soundfonts/FatBoy.sf2 "$T2/mk-drums4.mid" >/dev/null 2>&1 - fi - ffmpeg -y -v error -i "$OUT" -i "$T2/mk-drums4.wav" \ - -filter_complex "[1:a]volume=${DRUM_GAIN},aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[d];[0:a][d]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]" \ - -map 0:v -map "[a]" -c:v copy -c:a aac -b:a 192k -movflags +faststart "${OUT%.mp4}-tmp.mp4" - mv "${OUT%.mp4}-tmp.mp4" "$OUT" - echo " drums mixed at ${DRUM_GAIN}" -fi - -echo "wrote $OUT" -ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT" diff --git a/umtool/song/mk-rebuild.sh b/umtool/song/mk-rebuild.sh @@ -1,55 +0,0 @@ -#!/usr/bin/env bash -# Mortal Kombat, rebuilt on the 3,959-clip palette. -# -# Renders the BODY ONLY -- no hooks. That is what makes the vocal balance -# iterable: the video takes ~20 minutes and does not depend on the hooks at all, -# so a new balance is a ~10-second re-mux by mk-mix-hooks.mjs instead of another -# render. Round 1 (mk-variants/A-E) was built this way. -# -# The two finales need two BODIES, not just two tails: the background is spliced -# at 21s and runs continuously from there, so where segment B starts changes what -# is on screen DURING the song as well as after it. -# FULL intro/mk-bg-fatal2.mp4 (segment B at source 128.5, fatality 153.6s) -# SHORT intro/mk-bg-fatal3.mp4 (segment B at source 136.5, fatality 145.6s) -set -euo pipefail -# The DATA dir, not the script's own. `cd $(dirname $0)` worked only while this -# script was being run from the data directory's own copy; run from the repo it -# lands where the lead files and poly-plan.json do not exist. rpg-remake-v5.sh -# carries the same note for the same reason. -cd /home/user/.claude/jobs/efbe67a7/tmp/song -T=/home/user/.claude/jobs/efbe67a7/tmp -WHICH="${1:-full}" - -# Voice settings recovered from the SHIPPED plan (mk-plan-fatal2.json), not -# guessed: melody gain 1.0 / bass 0.68, scale 1.05, 488 + 390 notes to bar 70. -# poolFactor 2.6 is measured -- 1.8 gives melody 0.93 / bass 1.43, 3.0 gives -# 1.25 / 0.83, and 2.6 is the knee at 1.01 / 0.94. -export TEMPO_SCALE=1.05 TIME_LIMIT=${TL:-120.88} MAX_IOI=0.70 WINDOW=700 LOCAL=0 -export VOICES='[{"name":"melody","lead":"mkm-lead.json","gain":1.0,"role":"full","unique":true,"reuseGap":20,"contourMax":12,"fill":true},{"name":"bass","lead":"mkm-bass.json","gain":0.68,"role":"pip1","unique":true,"reuseGap":12,"contourMax":4,"poolFactor":2.6,"fill":false}]' - -if [ "${SKIP_ARRANGE:-0}" != "1" ]; then - node arrange-poly.mjs - cp poly-plan.json "${RAWF:-mk-plan-v4.json}" - - # PREROLL 17.45 puts the first um at 19.29s -- after the game's own gong at - # 18.086s (which is the SELECT-SCREEN sting, not an in-game gong) and after the - # 0.9s of digital silence that follows it. NO_GONG because the background - # supplies one; the record's would double it. - PREROLL=17.45 NO_GONG=1 node place-vocals-full.mjs \ - "${RAWF:-mk-plan-v4.json}" mkvocals/hooks-verb/mk-vocals.json mkvocals/hooks-verb \ - "${FPLAN:-mk-plan-fatal4.json}" "${FOVER:-mk-overlays-fatal4.json}" -fi -[ "${ARRANGE_ONLY:-0}" = "1" ] && exit 0 - -# BG and OUT can be overridden, because a cut of a different LENGTH needs its own -# splice: where segment B starts is the only thing that decides how long after the -# last note the fatality lands, so a new TL means a new background. -if [ -n "${BG:-}" ]; then OUT="${OUT:-$T/mkfatal4-custom.mp4}" -elif [ "$WHICH" = "short" ]; then BG=intro/mk-bg-fatal3.mp4; OUT="$T/mkfatal4-short.mp4" -else BG=intro/mk-bg-fatal2.mp4; OUT="$T/mkfatal4.mp4"; fi - -# No OVERLAYS= on purpose: the hooks are mixed on afterwards. -PLAN=${FPLAN:-mk-plan-fatal4.json} BG_VIDEO=$PWD/$BG LAYOUT=bg \ - node render-poly.mjs "$OUT" - -echo "MK_BODY_${WHICH}_DONE -> $OUT" diff --git a/umtool/song/ms2-bg3.sh b/umtool/song/ms2-bg3.sh @@ -1,31 +0,0 @@ -#!/bin/sh -# Metal Slug, cut after the first statement. The tune states its material twice -# (the second pass begins at bar 55 = 79.02s and differs only slightly), so this -# keeps one full statement, lets ~6 bars of the repeat begin, and fades across -# them -- the tune comes round again and dissolves rather than stopping. -cd /home/user/.claude/jobs/efbe67a7/tmp/song || exit 1 -D=/home/user/reports/quartering-uh-song - -export MAX_IOI=0.70 WINDOW=700 LOCAL=0 TEMPO_SCALE=1.05 WOBBLE_W=0.08 TIME_LIMIT=${TL:-87.8} -# At 198 melody notes the melody is in SURPLUS, so the bass pool is free -- the -# poolFactor cliff that constrains the full-length build does not exist here. -export VOICES='[{"name":"melody","lead":"ms2-lead-t.json","gain":1.0,"role":"full","unique":true,"reuseGap":20,"contourMax":12,"fill":true},{"name":"bass","lead":"ms2-bass-t.json","gain":0.68,"role":"pip1","unique":true,"reuseGap":12,"contourMax":4,"poolFactor":5.0,"fill":false}]' -node arrange-poly.mjs | grep -E "melody|bass|total" -cp poly-plan.json ms2-short-plan.json - -TAIL_FADE=6.2 BG_VIDEO=$PWD/intro/ms2-bg.mp4 BG_FIT=pad LAYOUT=bg \ - node render-poly.mjs /tmp/ms2short.mp4 - -LEAD=$(node -e " -const {voices}=require('$PWD/poly-plan.json'); -const first=Math.min(...voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart)); -console.log(Math.max(0,first).toFixed(3));") -echo "trimming ${LEAD}s of lead-in off the body" -ffmpeg -nostdin -v error -y -ss "$LEAD" -i /tmp/ms2short.mp4 -c:v libx264 -preset medium -crf 21 \ - -pix_fmt yuv420p -video_track_timescale 30000 -c:a aac -b:a 192k -ar 48000 -ac 2 /tmp/ms2shortbd.mp4 - -ffmpeg -nostdin -v error -y -i intro/ms2-intro.mp4 -i /tmp/ms2shortbd.mp4 \ - -filter_complex "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]" -map "[v]" -map "[a]" \ - -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p -c:a aac -b:a 192k -ar 48000 -ac 2 \ - -movflags +faststart "$D/quartering-metalslug2-short.mp4" -echo MS2SHORT_DONE diff --git a/umtool/song/ms2-drums.sh b/umtool/song/ms2-drums.sh @@ -1,36 +0,0 @@ -#!/usr/bin/env sh -# Metal Slug, gameplay build, WITH the game's own drum kit under it. -# -# The ums play the pitched voices. The kit is not a pitched voice -- a hi-hat is -# noise, not a note -- so it comes straight out of the MIDI and is played as -# drums. Nothing in the build ever used the three drum tracks before this. -# -# The drums are mixed onto the FINISHED video (video stream copied, audio -# re-encoded), not re-rendered: the ums and the game bed are already balanced in -# that file, and the kit simply goes on top. That is why this runs in seconds. -set -e -cd /home/user/.claude/jobs/efbe67a7/tmp/song -D=/home/user/reports/quartering-uh-song -T=/home/user/.claude/jobs/efbe67a7/tmp - -# The arrangement is the MIDI trimmed by 5.4497s, stretched 1.05, shifted to 11s. -# Verified against ms2-plan-11.json at BOTH ends: first melody slot 11.232s and -# last 101.698s are both predicted exactly, so there is no drift over the 90s. -TEMPO_SCALE=1.05 TRIM=5.4497 SHIFT=11.0 START=11.0 END=102.85 \ - node midi-drums.mjs ~/Downloads/ms2_stage1.mid "$T/ms2-drums.mid" - -# Tracks 1 "Drum(Bass & Snare)", 2 "Drum(Hi-hat)", 3 "Drum(Cymbal)" are already -# GM percussion numbers (36 kick, 38 snare, 42 closed hat, 49 crash), so they -# render as a kit on channel 10 with any GM soundfont. -fluidsynth -ni -F "$T/ms2-drums.wav" -r 48000 -g 0.8 \ - /usr/share/soundfonts/FatBoy.sf2 "$T/ms2-drums.mid" - -# g=1.8 chosen by ear over 1.1 (too subtle) and 2.6 (too prominent): "clearly -# part of the song but not overpowering". Peaks 27.7k of 32768 -- the limiter is -# a backstop, it is not working at this level. -ffmpeg -y -v error -i "$D/quartering-metalslug2-play.mp4" -i "$T/ms2-drums.wav" \ - -filter_complex "[1:a]volume=1.8,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[d];[0:a][d]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]" \ - -map 0:v -map "[a]" -c:v copy -c:a aac -b:a 192k -movflags +faststart \ - "$D/quartering-metalslug2-drums.mp4" - -echo "MS2DRUMS_DONE -> $D/quartering-metalslug2-drums.mp4" diff --git a/umtool/song/ms2-full.sh b/umtool/song/ms2-full.sh @@ -1,18 +0,0 @@ -#!/bin/sh -# Full-length Metal Slug 2, in two flavours. Run once the mine has grown the palette. -# ./ms2-full.sh 2 -> lead + bass (730 notes) -# ./ms2-full.sh 3 -> lead + bass + rhythm (1287 notes) -cd /home/user/.claude/jobs/efbe67a7/tmp/song || exit 1 -D=/home/user/reports/quartering-uh-song -N="${1:-2}" -export MAX_IOI=0.70 WINDOW=700 LOCAL=0 TEMPO_SCALE=1.05 WOBBLE_W=0.08 TIME_LIMIT=999 -LEADV='{"name":"melody","lead":"ms2-lead.json","gain":1.0,"role":"full","unique":true,"reuseGap":20,"contourMax":12,"fill":true}' -BASSV='{"name":"bass","lead":"ms2-bass.json","gain":0.68,"role":"pip1","unique":true,"reuseGap":12,"contourMax":4,"poolFactor":1.05,"fill":false}' -GTRV='{"name":"rhythm","lead":"ms2-gtr.json","gain":0.55,"role":"pip2","unique":true,"reuseGap":10,"contourMax":6,"poolFactor":1.0,"fill":false}' -if [ "$N" = "3" ]; then export VOICES="[$LEADV,$BASSV,$GTRV]"; OUT="$D/quartering-metalslug2-full3.mp4" -else export VOICES="[$LEADV,$BASSV]"; OUT="$D/quartering-metalslug2-full.mp4"; fi -node arrange-poly.mjs | grep -E "melody|bass|rhythm|total" -LAYOUT=pip node render-poly.mjs "$OUT" | tail -1 -ffmpeg -nostdin -v error -y -i "$OUT" -c:v libx264 -preset medium -crf 28 -pix_fmt yuv420p \ - -c:a aac -b:a 160k -movflags +faststart "${OUT%.mp4}-small.mp4" -echo "MS2_FULL_${N}_DONE" diff --git a/umtool/song/ms2-jerbg.sh b/umtool/song/ms2-jerbg.sh @@ -1,110 +0,0 @@ -#!/usr/bin/env bash -# Metal Slug 2 over the USER'S OWN EDIT — the background WITH music, handing over -# on the game's own "MISSION 1 START". -# -# SUPERSEDES the no-music background. `ms2-play-rebuild.sh` is built around -# TheSoldier's music-free gameplay capture, with the game's sounds blended under -# the ums at quarter volume. That capture is RETIRED (user, 2026-08-17): every -# Metal Slug cut now uses jer's edit, which has the game's music in it, and the -# song takes over rather than sitting under it. -# -# The handover, recovered from the lost ms2-triangle.sh and re-verified here: -# * "MISSION 1 START" is at 11.901s in that audio, -# * the background's own sound is CUT DEAD there — not ducked, cut, -# * the CLEAN announcer clip plays in its place, so it is not doubled with the -# background's own copy of it or muddied by the music under it, -# * and the ums fade in over 1.6s underneath, because in the game the music -# starts alongside that voice. -# -# THE ARRANGEMENT IS SHIFTED, NOT RE-ARRANGED. Both shipped plans put their first -# note at 11.232s; landing it on 11.901 is a flat +0.669 on every slot, which -# keeps every clip the user has already judged. The KIT MUST MOVE WITH IT: the -# drum map is songTime = (midiTime - 5.4497) x 1.05 + SHIFT, so SHIFT goes -# 11.0 -> 11.669. Leaving it at 11.0 slides every drum hit 0.669s against the ums, -# which is the same trap ms2-play-rebuild.sh documents at 0.232s. -# -# The background is 640x480, so BG_FIT=pad — cropping a 4:3 source to 16:9 cuts -# the top and bottom of the gameplay. The pips then overhang the pillarbox bars, -# which is what the triangle build looked like and is intended. -set -uo pipefail -cd /home/user/.claude/jobs/efbe67a7/tmp/song -S=/home/user/Projects/yt-dlp-transcript-browser/umtool/song -D=/home/user/reports/quartering-uh-song -T=/home/user/.claude/jobs/efbe67a7/tmp -V=$D/videos/metal-slug - -BG=${BG:-$D/jer-metalslug-bg.mp4} -ANNOUNCER=${ANNOUNCER:-$D/MS_Mission_1_start.ogg} -START_AT=${START_AT:-11.901} -UM_FADE=${UM_FADE:-1.6} -ANN_GAIN=${ANN_GAIN:-1.0} -DRUM_GAIN=${DRUM_GAIN:-1.8} -mkdir -p "$V/variants" - -# $1 name $2 source plan $3 body path -cut () { - local NAME=$1 SRCPLAN=$2 BODY=$3 - local PLANF=ms2-plan-jer-$NAME.json - echo "######## $NAME" - - local SH END DRUMSHIFT - SH=$(node -e " -const {voices}=require('./$SRCPLAN'); -const f=Math.min(...voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart)); -console.log(String((${START_AT}-f).toFixed(4)));") - node -e " -const fs=require('fs'); -const p=JSON.parse(fs.readFileSync('$SRCPLAN','utf8')); -for(const v of p.voices) for(const n of v.plan) n.slotStart=+(n.slotStart+$SH).toFixed(4); -fs.writeFileSync('$PLANF',JSON.stringify(p,null,1)); -console.log(' shifted +$SH s -> first note '+ - Math.min(...p.voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart)).toFixed(3)+'s');" - DRUMSHIFT=$(node -e "console.log(String((11.0+$SH).toFixed(4)))") - - if [ "${REUSE_BODY:-1}" = "1" ] && [ -f "$BODY" ]; then - echo " reusing $BODY" - else - PLAN=$PLANF TAIL_FADE=6.2 BG_VIDEO=$BG BG_FIT=pad LAYOUT=bg BG_DIM=0 \ - node "$S/render-poly.mjs" "$BODY" 2>&1 | tail -2 - fi - - END=$(node -e " -const {voices}=require('./$PLANF'); -console.log((Math.max(...voices.flatMap(v=>v.plan.map(n=>n.slotStart+n.slotDur)))+0.6).toFixed(2));") - echo " game sound full to ${START_AT}s then CUT; announcer + ums from there; ends ${END}s" - - # ---- the handover, onto the drum-less deliverable ------------------------- - ffmpeg -nostdin -v error -y -i "$BODY" -i "$BG" -i "$ANNOUNCER" \ - -filter_complex " -[1:a]atrim=0:${END},asetpts=PTS-STARTPTS,volume='if(lt(t,${START_AT}),1.0,0.0)':eval=frame[game]; -[2:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,volume=${ANN_GAIN},adelay=delays=$(node -e "console.log(String(Math.round(${START_AT}*1000)))"):all=1,apad=whole_dur=${END}[ann]; -[0:a]atrim=0:${END},asetpts=PTS-STARTPTS,afade=t=in:st=${START_AT}:d=${UM_FADE}[song]; -[song][game][ann]amix=inputs=3:duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]" \ - -map 0:v -map "[a]" -t "$END" -c:v copy -c:a aac -b:a 192k -ar 48000 -ac 2 \ - -movflags +faststart "$V/variants/$NAME-nokit.mp4" - - # ---- the kits ------------------------------------------------------------ - TEMPO_SCALE=1.05 TRIM=5.4497 SHIFT="$DRUMSHIFT" START="$DRUMSHIFT" END="$END" \ - node "$S/midi-drums.mjs" ~/Downloads/ms2_stage1.mid "$T/jer-$NAME.mid" 2>&1 | tail -1 - fluidsynth -ni -F "$T/jer-$NAME-kit.wav" -r 48000 -g 0.8 \ - /usr/share/soundfonts/FatBoy.sf2 "$T/jer-$NAME.mid" >/dev/null 2>&1 - - TEMPO_SCALE=1.05 TRIM=5.4497 SHIFT="$DRUMSHIFT" START="$DRUMSHIFT" END="$END" \ - node "$S/midi-drums.mjs" ~/Downloads/ms2_stage1.mid "$T/jer-$NAME-hits.json" 2>&1 | tail -1 - MATCH="$T/jer-$NAME-kit.wav" PLAN_OUT="$T/jer-$NAME-drumplan.json" \ - node "$S/um-drums.mjs" "$T/jer-$NAME-hits.json" "$T/jer-$NAME-umkit.wav" 2>&1 | tail -4 - - mux () { - ffmpeg -nostdin -v error -y -i "$V/variants/$NAME-nokit.mp4" -i "$1" \ - -filter_complex "[1:a]volume=${DRUM_GAIN},aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[d];[0:a][d]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]" \ - -map 0:v -map "[a]" -c:v copy -c:a aac -b:a 192k -movflags +faststart "$2" - echo " -> $2 $(ffprobe -v error -show_entries format=duration -of csv=p=0 "$2")s" - } - mux "$T/jer-$NAME-kit.wav" "$V/$NAME.mp4" - mux "$T/jer-$NAME-umkit.wav" "$V/variants/$NAME-umdrums.mp4" -} - -cut wide ms2-plan-11-v3.json "$T/ms2jer-wide.mp4" -cut wide-short ms2-plan-short2.json "$T/ms2jer-wide-short.mp4" - -echo MS2_JERBG_DONE diff --git a/umtool/song/ms2-play-rebuild.sh b/umtool/song/ms2-play-rebuild.sh @@ -1,113 +0,0 @@ -#!/bin/sh -# Metal Slug 2 -- THE SHIPPED BUILD, rebuilt on the 3,959-clip palette: -# the short cut over TheSoldier's NO-MUSIC gameplay, with the game's own sounds -# blended in, and then the game's own drum kit on top. -# -# The no-music capture is the whole point of this build: it is what lets the -# game's sounds sit under the ums without the game's MUSIC fighting them. Full -# volume until the song starts at 11s, quarter volume under the song. -# -# Three things this rebuild must not lose, all of which are the build: -# * background intro/ms2-play-bg.mp4 (the no-music capture), NOT ms2-bg.mp4 -# * the game audio bed, muxed under the song -# * the drum kit from the MIDI, mixed on at 1.8 by ms2-drums.sh -# -# 11s of run-up is deliberate: the game's own "mission 1 start" voice sounds at -# 19s in the SOURCE and the capture is cut from 8.753s, so 11s of output = 19.99s -# of source -- a second clear of that voice. 10s was tried and is too close. -# -# Note the SLOT TIMES do not depend on the palette at all: they come from the -# MIDI, TEMPO_SCALE and TIME_LIMIT. So the 11s shift and the drum map (verified -# at both ends against the old plan) carry over exactly. -set -e -cd /home/user/.claude/jobs/efbe67a7/tmp/song -D=/home/user/reports/quartering-uh-song -T=/home/user/.claude/jobs/efbe67a7/tmp - -PLANF=${PLANF:-ms2-plan-11-v3.json} -# The UNSHIFTED arrangement, kept beside the shifted one. It is named after the -# build rather than fixed, so a short cut cannot overwrite the record of the long. -RAWF=${RAWF:-ms2-short-plan-v3.json} -# The body render and the kit, likewise named after the build. -BODY=${BODY:-$T/ms2play-v3.mp4} -KIT=${KIT:-$T/ms2-drums-v3} -PLAY=${PLAY:-$D/quartering-metalslug2-play-v3.mp4} -DRUMS=${DRUMS:-$D/quartering-metalslug2-drums-v3.mp4} - -# At TIME_LIMIT 87.8 the melody is only ~198 notes and is in SURPLUS, so the bass -# pool is free and poolFactor 5.0 costs nothing. The 1.3 cliff that constrains -# the FULL-LENGTH cut does not exist at this length. -export MAX_IOI=0.70 WINDOW=700 LOCAL=0 TEMPO_SCALE=1.05 WOBBLE_W=0.08 TIME_LIMIT=${TL:-87.8} -export VOICES='[{"name":"melody","lead":"ms2-lead-t.json","gain":1.0,"role":"full","unique":true,"reuseGap":20,"contourMax":12,"fill":true},{"name":"bass","lead":"ms2-bass-t.json","gain":0.68,"role":"pip1","unique":true,"reuseGap":12,"contourMax":4,"poolFactor":5.0,"fill":false}]' - -if [ "${REUSE_PLAN:-0}" != "1" ]; then - node arrange-poly.mjs - cp poly-plan.json "$RAWF" - # Shift the whole arrangement by a FLAT +11.0s. - # - # NOT "move the first note to 11.0". The drum map is - # songTime = (midiTime - 5.4497) x 1.05 + 11.0 - # and the -t voice files already carry that 5.4497 trim, so the shift term IS - # 11.0 and the first melody slot lands at 11.232 (its trimmed time 0.221 x - # 1.05). Normalising the first note to 11.000 instead would slide every drum - # hit 0.232s against the ums -- and the kit is the one thing in the build that - # is locked to the beat by arithmetic rather than by choice. - node -e " - const fs=require('fs'); - const p=JSON.parse(fs.readFileSync('$RAWF','utf8')); - const SH=11.0; - for(const v of p.voices) for(const n of v.plan) n.slotStart=+(n.slotStart+SH).toFixed(4); - fs.writeFileSync('$PLANF',JSON.stringify(p,null,1)); - for(const v of p.voices) console.log(' '+v.name+' first slot '+v.plan[0].slotStart.toFixed(3)+ - 's, last '+(v.plan.at(-1).slotStart+v.plan.at(-1).slotDur).toFixed(3)+'s');" -fi -[ "${ARRANGE_ONLY:-0}" = "1" ] && exit 0 - -# TAIL_FADE 6.2 lets the tune come round again and dissolve rather than stopping -# dead on the loop point. VIDEO_TAIL_FADE now follows it, so the picture goes -# with the sound. -if [ "${REUSE_BODY:-0}" = "1" ] && [ -f "$BODY" ]; then - echo "reusing $BODY" -else - PLAN=$PLANF TAIL_FADE=6.2 BG_VIDEO=$PWD/intro/ms2-play-bg.mp4 BG_FIT=pad LAYOUT=bg \ - node render-poly.mjs "$BODY" -fi - -# End of the song, measured -- not the container duration. -END=$(node -e " -const {voices}=require('$PWD/$PLANF'); -console.log((Math.max(...voices.flatMap(v=>v.plan.map(n=>n.slotStart+n.slotDur)))+0.6).toFixed(2));") -# The game bed fades with the song and the picture. The song's own tail fade is -# inside the render, and the picture now follows it -- so without this the last -# 6.2s would be a black screen with the game still chattering under it. -GFADE_ST=$(node -e "console.log(($END - 6.2).toFixed(2))") -echo "muxing the game audio under the song, to ${END}s (game bed fades from ${GFADE_ST}s)" - -# The game's own sounds: FULL until the song starts at 11s, then a QUARTER under -# it. normalize=0 so amix does not halve both inputs. -ffmpeg -nostdin -v error -y -i "$BODY" -i intro/ms2-play-bg.mp4 \ - -filter_complex " -[1:a]volume='if(lt(t,11),1.0,0.25)':eval=frame,atrim=0:${END},asetpts=PTS-STARTPTS,afade=t=out:st=${GFADE_ST}:d=6.2[game]; -[0:a]atrim=0:${END},asetpts=PTS-STARTPTS[song]; -[song][game]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]" \ - -map 0:v -map "[a]" -t "$END" -c:v copy -c:a aac -b:a 192k -ar 48000 -ac 2 \ - -movflags +faststart "$PLAY" -echo "MS2PLAY_DONE -> $PLAY" - -# ---- drums ------------------------------------------------------------------ -# songTime = (midiTime - 5.4497) x 1.05 + 11.0, verified at BOTH ends against the -# plan. The three drum tracks already use GM percussion numbers (36 kick, -# 38 snare, 42 closed hat, 49 crash), so they play as a kit on channel 10. -TEMPO_SCALE=1.05 TRIM=5.4497 SHIFT=11.0 START=11.0 END="$END" \ - node midi-drums.mjs ~/Downloads/ms2_stage1.mid "$KIT.mid" -fluidsynth -ni -F "$KIT.wav" -r 48000 -g 0.8 \ - /usr/share/soundfonts/FatBoy.sf2 "$KIT.mid" >/dev/null 2>&1 - -# 1.8 was chosen by ear over 1.1 (too subtle) and 2.6 (too prominent). Mixed onto -# the finished video with -c:v copy, so re-auditioning a level costs seconds. -ffmpeg -nostdin -v error -y -i "$PLAY" -i "$KIT.wav" \ - -filter_complex "[1:a]volume=1.8,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[d];[0:a][d]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]" \ - -map 0:v -map "[a]" -c:v copy -c:a aac -b:a 192k -movflags +faststart "$DRUMS" - -echo "MS2DRUMS_DONE -> $DRUMS" -ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$DRUMS" diff --git a/umtool/song/ms2-triangle.sh b/umtool/song/ms2-triangle.sh @@ -1,94 +0,0 @@ -#!/bin/sh -# Metal Slug 2, 3 voices, over the USER'S OWN EDIT as one continuous background, -# handing over on the game's own "MISSION 1 START". -# -# RECOVERED 2026-08-17 from a session transcript: this file had been lost from the -# repo, and it is the only place the announcer handover was worked out. Everything -# that now uses that handover (ms2-jerbg.sh, which is the builder for the shipped -# cuts) descends from it, so it is restored rather than left as a dead reference -# in metal-slug.md. -# -# Two changes from the earlier bg builds. -# -# 1. ONE background, not intro-plus-body. `intro/ms2-intro.mp4` is jer's edit -# 0->14.47s and `intro/ms2-bg.mp4` is the same edit from 14.4706s on, and -# ms2-bg.mp4 carries NO AUDIO STREAM AT ALL -- which is why that build had no -# game sound under it. Taking jer-metalslug-bg.mp4 whole keeps the footage -# continuous AND keeps its audio, and lets the song start anywhere. -# -# 2. The handover is the announcer. "MISSION 1 START" sits at 11.901s in that -# audio -- located by cross-correlating the clean announcer clip against it, -# and corroborated by the energy dropping to rms 1115 at 11.5s before jumping -# to 5433 at 12.0s. The background's own sound is CUT there and the CLEAN clip -# plays instead, with the ums fading in underneath it, because in the game the -# music starts alongside that voice clip. -# -# The picture is a TRIANGLE: the melody panel is only a little larger than the -# two accompaniment panels and sits top-centre, with those two in the bottom -# corners, so the character at the bottom centre is framed rather than covered. -set -e -cd /home/user/.claude/jobs/efbe67a7/tmp/song -D=/home/user/reports/quartering-uh-song -T=/home/user/.claude/jobs/efbe67a7/tmp - -BG=${BG:-$D/jer-metalslug-bg.mp4} -ANNOUNCER=${ANNOUNCER:-$D/MS_Mission_1_start.ogg} -START_AT=${START_AT:-11.901} # "MISSION 1 START" in the background's audio -UM_FADE=${UM_FADE:-1.6} # the ums swell in under the announcer -ANN_GAIN=${ANN_GAIN:-1.0} -PLANF=${PLANF:-ms2-plan-tri.json} -OUT=${OUT:-$D/quartering-metalslug2-triangle.mp4} -# PAD, not crop. The transcript's copy of this script said crop; the build that -# actually SHIPPED is pillarboxed, and metal-slug.md gives the reason -- the -# source is 640x480 and cropping 4:3 to 16:9 cuts the top and bottom off the -# gameplay (the mistake documented on Mario RPG). The pips then overhang the -# pillarbox bars, which is intended. -BG_FIT=${BG_FIT:-pad} -mkdir -p "$(dirname "$OUT")" - -export MAX_IOI=0.70 WINDOW=700 LOCAL=0 TEMPO_SCALE=1.05 WOBBLE_W=0.08 TIME_LIMIT=999 -export VOICES='[{"name":"melody","lead":"ms2-lead-t.json","gain":1.0,"role":"full","unique":true,"reuseGap":20,"contourMax":12,"fill":true},{"name":"bass","lead":"ms2-bass-t.json","gain":0.68,"role":"pip1","unique":true,"reuseGap":12,"contourMax":4,"poolFactor":1.3,"fill":false},{"name":"rhythm","lead":"ms2-gtr-t.json","gain":0.55,"role":"pip2","unique":true,"reuseGap":10,"contourMax":6,"poolFactor":1.0,"fill":false}]' - -if [ "${REUSE_PLAN:-0}" != "1" ]; then - node arrange-poly.mjs - node -e " - const fs=require('fs'); - const p=JSON.parse(fs.readFileSync('poly-plan.json','utf8')); - const first=Math.min(...p.voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart)); - const SH=$START_AT-first; - for(const v of p.voices) for(const n of v.plan) n.slotStart=+(n.slotStart+SH).toFixed(4); - fs.writeFileSync('$PLANF',JSON.stringify(p,null,1)); - console.log('shifted '+SH.toFixed(3)+'s -> first note '+ - Math.min(...p.voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart)).toFixed(3)+'s, ends '+ - Math.max(...p.voices.flatMap(v=>v.plan.map(n=>n.slotStart+n.slotDur))).toFixed(3)+'s');" -fi -[ "${ARRANGE_ONLY:-0}" = "1" ] && exit 0 - -# BG_MAIN_SCALE 0.36 -> a 460x260 melody panel, 1.20x the 384x216 accompaniment -# panels, and BG_MAIN_TOP 108 keeps the top edge where it already was. -# TAIL_FADE 4.5 covers the loop-back restatement; the picture now fades with it. -if [ "${REUSE_BODY:-0}" = "1" ] && [ -f "$T/ms2tri-body.mp4" ]; then - echo "reusing $T/ms2tri-body.mp4" -else - PLAN=$PLANF TAIL_FADE=4.5 BG_VIDEO=$BG BG_FIT=$BG_FIT LAYOUT=bg \ - BG_MAIN_SCALE=0.36 BG_MAIN_TOP=108 \ - node render-poly.mjs "$T/ms2tri-body.mp4" -fi - -END=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$T/ms2tri-body.mp4") -echo "background sound full to ${START_AT}s then CUT; announcer + ums from there; ends ${END}s" - -# [game] the edit's own sound, cut dead at the announcer -# [ann] the CLEAN announcer clip, in its place -# [song] the ums, swelling in underneath it -ffmpeg -nostdin -v error -y -i "$T/ms2tri-body.mp4" -i "$BG" -i "$ANNOUNCER" \ - -filter_complex " -[1:a]atrim=0:${END},asetpts=PTS-STARTPTS,volume='if(lt(t,${START_AT}),1.0,0.0)':eval=frame[game]; -[2:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,volume=${ANN_GAIN},adelay=delays=$(node -e "console.log(String(Math.round($START_AT*1000)))"):all=1,apad=whole_dur=${END}[ann]; -[0:a]afade=t=in:st=${START_AT}:d=${UM_FADE}[song]; -[song][game][ann]amix=inputs=3:duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]" \ - -map 0:v -map "[a]" -c:v copy -c:a aac -b:a 192k -ar 48000 -ac 2 \ - -movflags +faststart "$OUT" - -echo "MS2TRI_DONE -> $OUT" -ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT" diff --git a/umtool/song/pk-v12.sh b/umtool/song/pk-v12.sh @@ -1,69 +0,0 @@ -#!/usr/bin/env bash -# Pokemon v12 -- everything decided after v11, which needs a different build shape. -# -# v11 mixed all three voices inside one body render. Two decisions since make that -# impossible: the run voice's audio gets its own effects (climb-fx), and its picture is -# the X strokes rather than a fixed panel. Splitting the run out solves both at once -- -# a body rendered from melody+bass has no run panel to suppress. -# -# 1 run rendered alone -> its audio only, picture discarded -# 2 climb-fx on that audio -> loudness/brightness/pan track the pitch -# 3 body from melody+bass, and pk3.sh's spare CHOP input carries the run audio back in -# 4 climb-panel over the result -> the X strokes -# -# Steps 1 and 3 are the expensive ones; everything else is a single encode. -set -euo pipefail -cd /home/user/.claude/jobs/efbe67a7/tmp/song -T=/home/user/.claude/jobs/efbe67a7/tmp -D=/home/user/reports/quartering-uh-song -S=/home/user/Projects/yt-dlp-transcript-browser/umtool/song - -RUN_GAIN=${RUN_GAIN:-0.55} -AMP=${AMP:-0.5} BRI=${BRI:-0.8} PAN=${PAN:-0.7} -TILE_W=${TILE_W:-420} MIN_RUN=${MIN_RUN:-4} TRAIL=${TRAIL:-0.30} -EXTENT=${EXTENT:-0.82} MARGIN=${MARGIN:-8} -OUT=${OUT:-$D/quartering-pokemon-v12.mp4} - -# ---- 1. the run voice, alone ------------------------------------------------ -if [ ! -f "$T/v12-run.mp4" ]; then - echo "=== run voice (516 notes) ===" - PLAN=pkmn-v12-run.json BG_VIDEO=$PWD/intro/pkmn-late.mp4 BG_FIT=pad LAYOUT=bg OUT_END=0 \ - node render-poly.mjs "$T/v12-run.mp4" -fi - -# ---- 2. its effects --------------------------------------------------------- -# Applied to the run ALONE, which is the whole reason it is rendered separately: the -# cues are meant to carry this line's contour, not to tilt the melody and bass with it. -if [ ! -f "$T/v12-run-fx.wav" ]; then - echo "=== climb-fx on the run ===" - AMP=$AMP BRI=$BRI PAN=$PAN node "$S/climb-fx.mjs" pkmn-v12-run.json run "$T/v12-run.mp4" "$T/v12-run-fx.mp4" - ffmpeg -nostdin -v error -y -i "$T/v12-run-fx.mp4" -vn -ar 48000 -ac 2 "$T/v12-run-fx.wav" -fi - -# ---- 3. body + the whole finale --------------------------------------------- -# CHOP is pk3.sh's spare audio input; the run rides back in through it at RUN_GAIN. -echo "=== body (melody + bass) and final assembly ===" -REUSE_BG=1 PLANF=pkmn-v12-body.json \ - JINGLE_VIDEO=1 JINGLE=$T/pk9/jingle-finale.mp4 \ - CHOP="$T/v12-run-fx.wav" CHOP_GAIN=$RUN_GAIN \ - OUT="$T/v12-base.mp4" \ - FILLER='1745-1782:3.0:17.5 2189-2223:3.0:25.0 3710-3740:1.0:24.0' \ - bash "$S/pk3.sh" - -# ---- 4. the run's picture, in two halves ------------------------------------ -# The run is on screen for the whole song, but as two different things, and the split -# is the melody: while the melody sings, the run is an inner voice and sits in the -# ordinary bottom-right pip; while the melody rests, it takes the frame as the X -# strokes. One gate drives both, from opposite sides, so the line is drawn exactly -# once at every instant -- never doubled, never absent. -echo "=== run pip (while the melody sounds) ===" -GATE_PLAN=pkmn-v12-body.json GATE_VOICE=melody \ - node "$S/run-pip.mjs" pkmn-v12-run.json run "$T/v12-run.mp4" "$T/v12-base.mp4" "$T/v12-pip.mp4" - -echo "=== climb-panel X strokes (while the melody rests) ===" -TILE_W=$TILE_W MIN_RUN=$MIN_RUN TRAIL=$TRAIL EXTENT=$EXTENT MARGIN=$MARGIN \ - GATE_PLAN=pkmn-v12-body.json GATE_VOICE=melody \ - node "$S/climb-panel.mjs" pkmn-v12-run.json run "$T/v12-pip.mp4" "$OUT" - -echo "PK12_DONE -> $OUT" -ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT" diff --git a/umtool/song/pk2.sh b/umtool/song/pk2.sh @@ -1,166 +0,0 @@ -#!/bin/sh -# Pokemon FR/LG wild battle, rebuilt on the 3,959-clip palette over real -# gameplay -- the most out-of-date deliverable in the set (its only build was -# made at ~1,050 clips, before the window and renderer fixes). -# -# The background is FCPlaythroughs' Pokemon Red longplay. Timings were derived, -# not guessed, and all three sources were tied to ONE clock first: -# -# * pkmn-ref.mp4 (a combined A+V section) starts at source 620.000s exactly -- -# established by cross-correlating its audio against the complete audio -# track, which matched to the sample. -# * pkmn-long-v.webm (the high-quality video) starts at source 644.134s -- -# established by matching the 1100ms black run, which sits at 36.267s in the -# reference and 12.133s here. -# * the audio is cut from the complete track at 644.134s to match. -# -# That mattered: a first attempt cut the audio at the REQUESTED start rather -# than the actual one, and the two were ~14s apart, so no audio event lined up -# with any picture event. -# -# The splice is on the AUDIO, not the black frames. The encounter's black run -# ends at 13.233s, but the game's own rapid-notes alarm keeps playing to ~13.2s -# and the battle theme's downbeat lands at 13.50s -- which is where the ums come -# in, so the game plays its own intro and hands over on the beat. -# -# FLOURISH=drop the MIDI's own rapid intro is removed; the background's -# alarm is the intro (default) -# FLOURISH=keep the ums play the rapid intro themselves, and the game audio -# ducks BEFORE the alarm so it is not doubled -set -e -cd /home/user/.claude/jobs/efbe67a7/tmp/song -D=/home/user/reports/quartering-uh-song -T=/home/user/.claude/jobs/efbe67a7/tmp - -BG_START=${BG_START:-5.866} # 10:50 in the source, in pkmn-long-v time -SPLICE=${SPLICE:-13.500} # the battle theme's downbeat, same clock -# Where the game's own ALARM starts, same clock. Read off the pitch contour: the -# route music before it is B4/C#5/D5, and the alarm is the G5-F#5-F5-G6 figure -- -# audibly higher and quite different, which is how it is picked out. It runs -# 2.6s, and the MIDI's own alarm is 33 notes over 2.5s, so the two agree. -# -# 10.966 was the ORIGINAL reading and it is 1.14s LATE -- the cut landed after the -# game's alarm instead of on it, so v3 played the game's alarm and then the ums'. -# The corrected onset is 9.826, measured by the high/low band-energy ratio rather -# than eyeballed off a pitch contour. Every shipped build passes 9.826 by env; this -# default now matches them instead of contradicting them. -ALARM_AT=${ALARM_AT:-9.826} -FLOURISH=${FLOURISH:-keep} -# 0 = CUT the game's own sound where the ums take over, rather than leaving a -# bed under them. The battle music would otherwise play its version of the same -# tune underneath ours. -DUCK=${DUCK:-0.0} -PLANF=${PLANF:-pkmn-plan-v2.json} -OUT=${OUT:-$D/quartering-pokemon-v2.mp4} -mkdir -p "$(dirname "$OUT")" - -# The MIDI's own alarm is notes 0..32 of the lead (t=0 -> 2.500s, 78ms apart); -# the tune proper starts at 2.969s. Those are the notes FLOURISH=drop removes. -CUTAT=${CUTAT:-2.95} -SCALE=${SCALE:-1.15} -if [ "$FLOURISH" = "keep" ]; then CUTAT=-1; HANDOVER=$ALARM_AT; else HANDOVER=$SPLICE; fi - -# ---- arrange ---------------------------------------------------------------- -# poolFactor 6.0 on the accompaniment, IN LINE WITH THE OTHER TUNES. The old build ran the -# accompaniment at ~1.0, the starved setting every other tune has since moved off -# -- Metal Slug went 1.05 -> 1.3, Mario RPG to 6.0, Mortal Kombat to 2.6. -if [ "${REUSE_PLAN:-0}" != "1" ]; then - # Voices from PkmRB-Battle1.mid -- Pokemon RED/BLUE, which is what the - # background actually is. Its track names spell the credit (sequenced by - # Joao Buaes), like the Mortal Kombat file, so tracks are picked by INDEX: - # 2 -> melody 144 notes, IOI p50 0.469s, and it carries the alarm - # 3 -> bass 471 notes, IOI p50 0.156s - # 1 -> harm 516 notes, IOI p50 0.078s -- LEFT OUT. At 78ms every note is - # shorter than the palette's 0.14s minimum clip, so it can only - # come out as fragments; Metal Slug's rhythm voice at 0.183s - # already reads as percussion rather than melody. - # CMAX is how far a melody note may be STRETCHED before the arranger folds it - # an octave instead, and it is the only thing measured that moves the - # over-pitching at all. - # - # At 14 a note may be dragged more than an octave rather than folded, and the - # notes that need it are exactly the ones at the edges of the corpus's pitch - # range (the corpus is median 103Hz / p95 127Hz; this melody asks for 73Hz and - # 196Hz). Measured on the Pokemon melody, 14 -> 9: - # - # under 0.5 semitones 59 -> 122 2-3 semitones 51 -> 2 - # 3+ semitones 10 -> 0 down by 2+ 53 -> 2 - # - # 9, 7 and 5 are identical, so it is a stable setting rather than a tuned one. - # It is not free: 25 notes fold an octave instead of being dragged, so the - # melody's contour changes at those notes. That is the trade -- an octave - # displacement in place of an audibly dragged note. - # - # Raising SHIFT_W (what a semitone costs in the score) does NOT work: 12 -> 80 - # leaves the 2-3 cluster at 51/49/50/52. Nor does disabling the 115Hz - # other-speaker gate, which moves exactly one note. - # 9, not the 14 this defaulted to originally: 14 is the setting the measurements - # below argue AGAINST, and every shipped build passes 9 by env. - CMAX=${CMAX:-9} - export TEMPO_SCALE=$SCALE MAX_IOI=0.70 WINDOW=700 LOCAL=0 WOBBLE_W=0.08 - export VOICES='[{"name":"melody","lead":"pkrb-lead.json","gain":1.0,"role":"full","unique":true,"reuseGap":20,"contourMax":'"$CMAX"',"fill":true},{"name":"bass","lead":"pkrb-bass.json","gain":0.62,"role":"pip1","unique":true,"reuseGap":12,"contourMax":5,"poolFactor":4.0,"fill":false}]' - node arrange-poly.mjs - cp poly-plan.json "$PLANF.raw" -fi - -# Drop the flourish if asked, then shift so the first surviving note lands on -# the handover. -# -# SKIP_SHIFT=1 leaves $PLANF exactly as it is. -# -# This block REGENERATES $PLANF from $PLANF.raw, and it runs on every pass -- -# including a REUSE_PLAN=1 render. So anything edited into the plan between the -# arrange and the render is silently thrown away: a run that arranged, applied -# nine swap-clip fixes, verified them with a scan, and then rendered produced a -# video with none of the fixes in it, and the scan output looked perfectly -# correct because it ran before the clobber. Swap first, then render with -# SKIP_SHIFT=1. -if [ "${SKIP_SHIFT:-0}" = "1" ]; then - echo "SKIP_SHIFT=1: using $PLANF as it stands (already shifted)" -else -node -e " -const fs=require('fs'); -const p=JSON.parse(fs.readFileSync('$PLANF.raw','utf8')); -const CUT=$CUTAT; -let dropped=0; -if(CUT>0) for(const v of p.voices){const n0=v.plan.length; v.plan=v.plan.filter(n=>n.slotStart>=CUT*$SCALE-1e-6); dropped+=n0-v.plan.length;} -const first=Math.min(...p.voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart)); -const SH=$HANDOVER-$BG_START-first; -for(const v of p.voices) for(const n of v.plan) n.slotStart=+(n.slotStart+SH).toFixed(4); -fs.writeFileSync('$PLANF',JSON.stringify(p,null,1)); -const f2=Math.min(...p.voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart)); -const l2=Math.max(...p.voices.flatMap(v=>v.plan.map(n=>n.slotStart+n.slotDur))); -console.log('flourish '+('$FLOURISH')+': dropped '+dropped+' notes; shifted '+SH.toFixed(3)+ - 's -> first note '+f2.toFixed(3)+'s, ends '+l2.toFixed(3)+'s');" -fi -[ "${ARRANGE_ONLY:-0}" = "1" ] && exit 0 - -# ---- background ------------------------------------------------------------- -# 10:50 onward, as one file, so the renderer's clock and this script's agree. -if [ ! -f "$T/pkmn-bg.mp4" ]; then - ffmpeg -nostdin -v error -y -ss "$BG_START" -i intro/pkmn-bg-full.mkv \ - -c:v libx264 -preset medium -crf 20 -pix_fmt yuv420p -video_track_timescale 30000 \ - -c:a aac -b:a 192k -ar 48000 -ac 2 "$T/pkmn-bg.mp4" -fi - -PLAN=$PLANF BG_VIDEO=$T/pkmn-bg.mp4 BG_FIT=pad LAYOUT=bg \ - node render-poly.mjs "$T/pkmn-body.mp4" - -# ---- the game's own sound under it ------------------------------------------ -# Full until the handover -- the transition and the alarm ARE the intro -- then -# down under the ums. -END=$(node -e " -const {voices}=require('$PWD/$PLANF'); -console.log((Math.max(...voices.flatMap(v=>v.plan.map(n=>n.slotStart+n.slotDur)))+0.7).toFixed(2));") -H=$(node -e "console.log(($HANDOVER-$BG_START).toFixed(3))") -echo "game audio full to ${H}s, then ${DUCK}; output ends ${END}s" -ffmpeg -nostdin -v error -y -i "$T/pkmn-body.mp4" -i "$T/pkmn-bg.mp4" \ - -filter_complex " -[1:a]volume='if(lt(t,${H}),1.0,${DUCK})':eval=frame,atrim=0:${END},asetpts=PTS-STARTPTS[game]; -[0:a]atrim=0:${END},asetpts=PTS-STARTPTS[song]; -[song][game]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]" \ - -map 0:v -map "[a]" -t "$END" -c:v copy -c:a aac -b:a 192k -ar 48000 -ac 2 \ - -movflags +faststart "$OUT" - -echo "PKMN_DONE -> $OUT" -ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT" diff --git a/umtool/song/pk3.sh b/umtool/song/pk3.sh @@ -1,375 +0,0 @@ -#!/usr/bin/env bash -# Pokemon v9 -- the 35:10 opening and the caught-Pikachu finale. -# -# v8 opened at source 10:50 and simply stopped when the notes ran out. This one -# opens in a GAP between notes of the route music at ~35:10, lets the encounter -# alarm fire into the ums as before, and ends MK-style: right after the song's -# last note the player throws a Poke Ball, catches a Pikachu, and an um-ified -# "caught" jingle plays once the frame stills on "PIKACHU was caught!". -# -# READ specs/pokemon.md FIRST. In particular: -# * every timing here was MEASURED, and the measurements are recorded there; -# * `pk2.sh` clobbers its plan on every pass -- this script never regenerates -# one, it REUSES v8's, which already lands its first note on the handover. -# -# --------------------------------------------------------------------------- -# The one clock -# -# intro/pkmn-late.mp4 is a 302+251 MERGED fetch, and t=0 in it is source -# 2090.036s -- established by cross-correlating its audio against the complete -# local track at two independent windows (t=10 and t=45), which agreed to 0.000s. -# Fetching 302 alone gives no audio to pin with, and the same requested range came -# back a different length (83.415s vs 80.014s), so both streams must come from the -# one file. Every SECTION time below is in that file's clock. -# -# --------------------------------------------------------------------------- -# The shape -# -# [A] section BG_START -> SEG_A_END the entry gap, the alarm, the battle -# [F] filler more battle footage, see FILLER -# [C] section SEG_C_START -> SEG_C_END the ball, the catch, the jingle -# -# A is only ~20.7s and the song is 90.2s, so F exists purely to carry the song -# to its last note with the throw waiting on the other side. F is TRIMMED to -# whatever length makes the throw land GAP seconds after the final note, so the -# ranges given in FILLER only have to be long ENOUGH, not exact. -set -euo pipefail -# The bulk data -- intro/, the plan files, cand2/, the rendered scratch -- lives in -# the job temp dir, not beside this script. Same as pk2.sh. -cd /home/user/.claude/jobs/efbe67a7/tmp/song - -T=/home/user/.claude/jobs/efbe67a7/tmp -D=/home/user/reports/quartering-uh-song -SRC=intro/pkmn-late.mp4 -URL='https://www.youtube.com/watch?v=D1SrSFZrV7A' -SFX=$D/sfx/pkmn-catch.wav - -# ---- the measured section times --------------------------------------------- -# Inside the 20.188->20.468 inter-note trough (source 2110.224->2110.504), not on -# its leading onset, so the entry does not clip a note attack. It also puts the -# alarm at bg 3.960 -- the SAME handover v8 shipped, which is why v8's plan can be -# reused without reshifting a single note. -BG_START=${BG_START:-20.324} -ALARM_AT=${ALARM_AT:-24.284} # source 2114.320, found by pulse REGULARITY (82.4ms) -# 41.000 is where the FIGHT menu returns after the two moves. The user's -# constraint: the loop/filler must not contain any of the catching animation, and -# the catch begins at 42.800 when the ITEM menu opens. Cutting at 41.000 keeps -# every frame of the ball out of the body. -SEG_A_END=${SEG_A_END:-41.000} -SEG_C_START=${SEG_C_START:-41.000} -SEG_C_END=${SEG_C_END:-54.350} -GAP=${GAP:-0.80} # dead air between the last note and the throw - -# Filler: space-separated SOURCE ranges "start-end" in seconds. These are only -# ever seen, never heard -- the game's own audio is cut from the handover on -- -# so they need no pinning, unlike the section above. -FILLER=${FILLER:-} - -PLANF=${PLANF:-pkmn-plan-v8.json} -OUT=${OUT:-$D/quartering-pokemon-v9.mp4} - -# ---- catch SFX event times, from the sidecar -------------------------------- -# The donor recording is a DIFFERENT catch attempt: its wiggle SPACING matches the -# footage exactly (0.933s) but the group sits ~0.27s off and it supplies one wiggle -# fewer than the picture shows. So the asset is NOT slid as a block -- each event is -# cut out and delayed to its own measured picture time, and a wiggle tick is reused -# for the third. That is what pkmn-catch.json's event offsets exist for. -# -# asset event picture (section) what it lands on -A_THROW=0.036; P_THROW=44.860 # the ball leaves the trainer's hand -A_LAND=1.0405; P_LAND=45.683 # Pikachu is absorbed -A_SETTLE=1.6632; P_SETTLE=46.417 # the ball drops and settles -A_W2=2.5937; P_W1=47.150 # wiggle 1 -A_W3=3.5287; P_W2=48.083 # wiggle 2 - P_W3=49.017 # wiggle 3 -- reuses the A_W3 tick -P_JINGLE=50.344 # "PIKACHU was caught!" finishes; frame stills - -JINGLE=${JINGLE:-$T/pkc-jingle.mp4} -SFX_GAIN=${SFX_GAIN:-0.85} -JINGLE_GAIN=${JINGLE_GAIN:-1.0} - -n() { node -e "console.log(String((($1)).toFixed(4)))"; } # String(): node ANSI-colourises bare numbers -ms() { node -e "console.log(String(Math.round(($1)*1000)))"; } - -LEN_A=$(n "$SEG_A_END - $BG_START") -HANDOVER=$(n "$ALARM_AT - $BG_START") - -# The song, straight off v8's plan -- not rearranged, not reshifted. -LAST=$(node -e " -const {voices}=require('$PWD/$PLANF'); -console.log(String(Math.max(...voices.flatMap(v=>v.plan.map(x=>x.slotStart+x.slotDur))).toFixed(4)));") - -# Where segment C must begin, in bg time, for the throw to land GAP after the last note -OFF_C=$(n "$LAST + $GAP - $P_THROW + $SEG_C_START") -NEED=$(n "$OFF_C - $LEN_A") -END=$(n "$SEG_C_END - $SEG_C_START + $OFF_C") - -echo "song: first note 3.960s, last note ends ${LAST}s" -echo "segment A ${BG_START}->${SEG_A_END} (${LEN_A}s), handover at bg ${HANDOVER}s" -echo "filler needed: ${NEED}s segment C starts at bg ${OFF_C}s output ends ${END}s" - -if [ -z "$FILLER" ]; then - echo - echo "FILLER is empty and ${NEED}s of it is needed." - echo "Pass source ranges, e.g. FILLER='3600-3640 7200-7235' (seconds, start-end)." - exit 2 -fi - -# WORK is the scratch directory for the spliced background and its pieces. It is -# configurable so a cut of a different LENGTH -- which needs a different background -- -# does not overwrite the one an existing build was made from. -WORK=${WORK:-$T/pk9} -mkdir -p "$WORK" "$(dirname "$OUT")" - -# ---- normalise every background piece to ONE format -------------------------- -# concat demuxer needs identical streams. 800x720@60 is the source's own geometry, -# so segment A and C are never resampled; only fetched filler is. -norm() { # <in> <ss> <t> <out> - ffmpeg -nostdin -v error -y -ss "$2" -t "$3" -i "$1" \ - -vf "scale=800:720:force_original_aspect_ratio=decrease,pad=800:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=60,format=yuv420p" \ - -c:v libx264 -preset medium -crf 18 -video_track_timescale 60000 \ - -c:a aac -b:a 192k -ar 48000 -ac 2 "$4" -} - -# REUSE_BG=1 keeps an already-spliced background, REUSE_BODY=1 an already-rendered -# body. The body is ~15 minutes of clip decoding, so re-running it to fix something -# downstream of it is pure waste. -if [ "${REUSE_BG:-0}" = "1" ] && [ -f "$WORK/bg.mp4" ]; then - echo "REUSE_BG=1: keeping $WORK/bg.mp4 ($(ffprobe -v error -show_entries format=duration -of csv=p=0 "$WORK/bg.mp4")s)" -else - -echo "--- segment A ---" -norm "$SRC" "$BG_START" "$LEN_A" "$WORK/a.mp4" - -echo "--- filler ---" -i=0; ACC=0; LIST="$WORK/concat.txt" -: > "$LIST" -echo "file '$WORK/a.mp4'" >> "$LIST" -# -# Each entry is FS-FE[:OFF[:LEN]] in SOURCE seconds. -# -# OFF skips into the FETCHED file and is not optional bookkeeping: -# --download-sections does not start where it is asked (630->620, 645->644.134 are -# the recorded cases), so the first seconds of a window are routinely the walk INTO -# the battle rather than the battle. OFF is read off a frame strip of the fetch. -# -# LEN caps how much of the window is used, and it MATTERS here: most wild -# encounters in this longplay are Pokedex-filling CATCHES, so a window run to its -# end shows another Pokemon being caught -- which would undercut the one the whole -# finale is built around. LEN stops each battle before the ball. -for R in $FILLER; do - OIFS=$IFS; IFS=:; set -- $R; IFS=$OIFS - SPEC=$1; OFF=${2:-0}; LEN=${3:-} - FS=${SPEC%%-*}; FE=${SPEC##*-} - i=$((i+1)) - # A window fetched earlier by hand is reused rather than pulled again. - RAW="$WORK/fill-${FS}.mp4" - [ -f "$RAW" ] || RAW="$WORK/f${i}-raw.mp4" - if [ ! -f "$RAW" ]; then - # Fetch merged, as ever: a 302-only fetch has no audio and cuts differently. - yt-dlp -f 302+251 --download-sections "*${FS}-${FE}" -P "$WORK" -o "f${i}-raw.%(ext)s" "$URL" >/dev/null 2>&1 - for E in webm mkv mp4; do - [ -f "$WORK/f${i}-raw.$E" ] && [ "$WORK/f${i}-raw.$E" != "$RAW" ] && mv "$WORK/f${i}-raw.$E" "$RAW" - done - fi - DUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$RAW") - HAVE=$(n "$DUR - $OFF") - [ -n "$LEN" ] && HAVE=$(node -e "console.log(String(Math.min($HAVE,$LEN).toFixed(4)))") - WANT=$(n "$NEED - $ACC") - USE=$(node -e "console.log(String(Math.min($HAVE,$WANT).toFixed(4)))") - if node -e "process.exit(($USE)>0.04?0:1)"; then - norm "$RAW" "$OFF" "$USE" "$WORK/f${i}.mp4" - echo "file '$WORK/f${i}.mp4'" >> "$LIST" - ACC=$(n "$ACC + $USE") - echo " filler $i: source ${FS}-${FE} +${OFF}s in, have ${HAVE}s, used ${USE}s (total ${ACC}/${NEED})" - fi -done -if node -e "process.exit(($NEED-$ACC)>0.05?0:1)"; then - echo "SHORT by $(n "$NEED - $ACC")s of filler -- add another range to FILLER." - exit 3 -fi - -echo "--- segment C ---" -norm "$SRC" "$SEG_C_START" "$(n "$SEG_C_END - $SEG_C_START")" "$WORK/c.mp4" -echo "file '$WORK/c.mp4'" >> "$LIST" - -echo "--- splice ---" -ffmpeg -nostdin -v error -y -f concat -safe 0 -i "$LIST" -c copy "$WORK/bg.mp4" -ffprobe -v error -show_entries format=duration -of default=nw=1 "$WORK/bg.mp4" - -fi - -# ---- re-derive the segment C offset from what was ACTUALLY built ------------- -# OFF_C above is where segment C was PLANNED to start, and it sizes the filler. -# It is not where segment C actually starts: every piece gets quantised to a frame -# when it is encoded, so a.mp4 comes out 20.699s against a requested 20.676s and -# the pieces together run ~70ms long. Placing the catch SFX on the planned figure -# put every tick 67ms AHEAD of the wiggle it belongs to -- measured in the finished -# file, picture wiggles at 93.367/94.300/95.233 against ticks at 93.300/94.233/95.167. -# -# So the events are placed on the REAL offset, taken as (whole background - segment -# C), which needs no bookkeeping of the individual pieces. The song does not move; -# the gap after its last note just absorbs the difference. -OFF_C=$(node -e " -const {execFileSync}=require('child_process'); -const d=f=>Number(execFileSync('ffprobe',['-v','error','-show_entries','format=duration','-of','csv=p=0',f]).toString().trim()); -console.log(String((d('$WORK/bg.mp4')-d('$WORK/c.mp4')).toFixed(4)));") -END=$(n "$SEG_C_END - $SEG_C_START + $OFF_C") -echo "segment C really starts at bg ${OFF_C}s; throw lands $(n "$P_THROW - $SEG_C_START + $OFF_C - $LAST")s after the last note; output ends ${END}s" - -# ---- body: the ums over the spliced background ------------------------------ -# OUT_END=0 stops the renderer TRIMMING at the last note, but it does not make the -# picture run any longer than the song -- see the video assembly below. -if [ "${REUSE_BODY:-0}" = "1" ] && [ -f "$WORK/body.mp4" ]; then - echo "REUSE_BODY=1: keeping $WORK/body.mp4 ($(ffprobe -v error -show_entries format=duration -of csv=p=0 "$WORK/body.mp4")s)" -else -PLAN=$PLANF BG_VIDEO=$WORK/bg.mp4 BG_FIT=pad LAYOUT=bg OUT_END=0 \ - node render-poly.mjs "$WORK/body.mp4" -fi - -# ---- the mix ---------------------------------------------------------------- -# Game audio full until the handover (the route gap and the alarm ARE the intro), -# then CUT -- not ducked: the battle music would otherwise play its own version of -# this very tune underneath ours, and the catch would fight our jingle. -J_MS=$(ms "$P_JINGLE - $SEG_C_START + $OFF_C") -echo "jingle at bg $(n "$P_JINGLE - $SEG_C_START + $OFF_C")s" - -# Each SFX event is a SEPARATE -ss/-t input off the same wav, rather than one input -# split six ways: the six taps need six different source ranges AND six different -# delays, so demuxer-level seeking is both simpler and cheaper than splitting and -# atrimming inside the graph. -# The handover, and why a HARD CUT is what makes the intro and the song feel like two -# separate pieces. -# -# The game runs at full until HANDOVER and is then CUT to zero, because from the -# downbeat onwards it plays its own version of our tune and would double it. But -# between the handover and the downbeat the game is not playing the theme -- it is -# playing the ENCOUNTER ALARM, the same figure the ums take over. Cutting it there -# throws away the one passage where the two could overlap, and the join lands as a -# step: the game stops dead and the ums start alone. -# -# GAME_BED holds the game at a low level across that window and fades it to nothing by -# the downbeat, so the ums' alarm rises out of the game's own rather than replacing it -# between one sample and the next. It is 0 by default -- the previous hard cut -- and -# the fade ends AT the downbeat, so nothing of the game's battle theme is ever heard -# under ours, which is the reason the cut existed in the first place. -GAME_BED=${GAME_BED:-0} -DOWNBEAT=${DOWNBEAT:-7.374} -if [ "$(node -e "console.log(String($GAME_BED>0?1:0))")" = "1" ]; then - GVOL="if(lt(t,${HANDOVER}),1.0, if(lt(t,${DOWNBEAT}), ${GAME_BED}*(1-(t-${HANDOVER})/(${DOWNBEAT}-${HANDOVER})), 0.0))" - echo "game audio: full to ${HANDOVER}s, then a ${GAME_BED} bed fading to 0 by the downbeat at ${DOWNBEAT}s" -else - GVOL="if(lt(t,${HANDOVER}),1.0,0.0)" -fi -FC="[1:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,volume='${GVOL}':eval=frame,atrim=0:${END},asetpts=PTS-STARTPTS,apad=whole_dur=${END}[game];" -FC="$FC[0:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,apad=whole_dur=${END}[song];" -FC="$FC[2:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,volume=${JINGLE_GAIN},adelay=delays=${J_MS}:all=1,apad=whole_dur=${END}[jing];" - -IN_SFX="" -k=0 -MIXIN="[song][game][jing]" -for EV in "$A_THROW 0.492 $P_THROW" "$A_LAND 0.500 $P_LAND" "$A_SETTLE 0.238 $P_SETTLE" \ - "$A_W2 0.243 $P_W1" "$A_W3 0.244 $P_W2" "$A_W3 0.244 $P_W3"; do - set -- $EV - PAD=0.030 - SS=$(n "$1 - $PAD"); DU=$(n "$2 + 2*$PAD"); DLY=$(ms "$3 - $SEG_C_START + $OFF_C - $PAD") - IDX=$((3+k)) - IN_SFX="$IN_SFX -ss $SS -t $DU -i $SFX" - FC="$FC[${IDX}:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,volume=${SFX_GAIN},adelay=delays=${DLY}:all=1,apad=whole_dur=${END}[sfx${k}];" - MIXIN="$MIXIN[sfx${k}]" - echo " sfx $k: asset ${SS}+${DU}s -> bg $(n "$3 - $SEG_C_START + $OFF_C")s" - k=$((k+1)) -done - -# ---- the chopped third voice ------------------------------------------------- -# PkmRB-Battle1's track 1 runs at 78ms a note, under the palette's 0.14s clip floor, -# so every build has left it out -- and it is exactly what covers the melody's rests -# in the game. Those rests are 20.3% of the song: six ~2.1s stretches where only the -# quiet bass sounds, which is what "some stretches are kind of empty" meant. -# -# chop-voice.mjs makes it from slices of a handful of sustained ums instead of a -# clip per note. It is AUDIO ONLY and deliberately has no video panel: there is no -# single clip behind it to show, and it is a texture under the tune rather than a -# voice with a face. That also means it mixes in HERE, costing one encode instead of -# a 15-minute body re-render. -CHOP_IN="" -if [ -n "${CHOP:-}" ] && [ -f "$CHOP" ]; then - CHOP_IN="-i $CHOP" - FC="$FC[$((3+k)):a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,volume=${CHOP_GAIN:-1.0},apad=whole_dur=${END}[chop];" - MIXIN="$MIXIN[chop]" - k=$((k+1)) - echo " chopped third voice: $CHOP at gain ${CHOP_GAIN:-1.0}" -fi - -FC="$FC${MIXIN}amix=inputs=$((3+k)):duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]" - -# shellcheck disable=SC2086 -# ---- video: the body, then the background carrying on past it ----------------- -# OUT_END=0 is NOT enough. render-poly stops its VIDEO where the song's audio -# stops, whatever OUT_END says, so the body came out 93.066s against a 100.587s -# background -- and the whole finale (the ball landing, the wiggles, the caught -# screen) fell off the end. The container still measured 100.5s because the AUDIO -# ran that long, which is exactly the trap mk-fatal-finish3.sh warns about: never -# trust a container duration for where the picture actually ends. -# -# So the picture is assembled the way MK's is: the body up to where it really ends, -# then the background itself from that point on. The tail is padded to 1280x720 the -# same way BG_FIT=pad does inside the renderer, or it would jump at the join. -TBODY=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$WORK/body.mp4") -TBODY=$(node -e "console.log(String((Math.floor($TBODY*30)/30).toFixed(4)))") # to a 30fps frame -echo "body ends ${TBODY}s; background carries the last $(n "$END - $TBODY")s" - -PADV="scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30,format=yuv420p" -# render-poly DIMS the background (BG_DIM, default -0.10) so the um panels read -# against it. Anything taken straight from bg.mp4 has not been through that, so the -# picture visibly BRIGHTENS the moment the body ends -- reported as "you're dimming -# the game during ums and it lightens during the finale". render-poly's own header -# warns about exactly this join. -# -# The fix is to keep it dim the whole way rather than to ramp back: a constant grade -# reads as deliberate, a switch reads as a mistake. The jingle segment needs nothing, -# because render-poly composited it and so it is already dimmed. -# 0, matching render-poly's new default: the background is not dimmed anywhere, so the -# bg-derived tails need no dim to match it. This was -0.10 to hide a brightness step -# that no longer exists. -DIM=${DIM:-0} -PADV_DIM="$PADV,eq=brightness=${DIM}" -FC="$FC;[0:v]trim=0:${TBODY},setpts=PTS-STARTPTS[bodyv];" - -# The finale's um panels. Without this the jingle plays over bare game footage while -# every other note in the video has a face on it, which reads as the ums stopping. -# -# $JINGLE supplies BOTH streams here -- it is rendered by render-poly over the -# finale's own frames (cut straight out of the spliced bg.mp4, so the frames are -# identical rather than merely similar) and its audio is the ums. One input, so the -# SFX input indices below do not have to shift. -JAT=$(n "$P_JINGLE - $SEG_C_START + $OFF_C") -JDUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$JINGLE") -JEND=$(n "$JAT + $JDUR") -if [ "${JINGLE_VIDEO:-0}" = "1" ]; then - echo "jingle panels on screen ${JAT}s -> ${JEND}s" - # split first: a filter graph may consume each input pad only once, and the - # background is needed both before the jingle and after it. - FC="$FC[1:v]split=2[bgA][bgB];" - FC="$FC[bgA]trim=${TBODY}:${JAT},setpts=PTS-STARTPTS,${PADV_DIM}[tail1];" - FC="$FC[2:v]${PADV},setpts=PTS-STARTPTS[jv];" - FC="$FC[bgB]trim=${JEND}:${END},setpts=PTS-STARTPTS,${PADV_DIM}[tail2];" - FC="$FC[bodyv][tail1][jv][tail2]concat=n=4:v=1[v]" -else - FC="$FC[1:v]trim=${TBODY}:${END},setpts=PTS-STARTPTS,${PADV_DIM}[tailv];" - FC="$FC[bodyv][tailv]concat=n=2:v=1[v]" -fi - -ffmpeg -nostdin -v warning -y \ - -i "$WORK/body.mp4" \ - -i "$WORK/bg.mp4" \ - -i "$JINGLE" \ - $IN_SFX \ - $CHOP_IN \ - -filter_complex "$FC" \ - -map "[v]" -map "[a]" -t "$END" \ - -c:v libx264 -preset medium -crf 18 -pix_fmt yuv420p -video_track_timescale 30000 \ - -c:a aac -b:a 192k -ar 48000 -ac 2 -movflags +faststart "$OUT" - -echo "PK9_DONE -> $OUT" -ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT" diff --git a/umtool/song/pkmn-rebuild.sh b/umtool/song/pkmn-rebuild.sh @@ -1,123 +0,0 @@ -#!/bin/sh -# Pokemon FR/LG wild battle, rebuilt on the 3,959-clip palette over real -# gameplay -- the most out-of-date deliverable in the set (its only build was -# made at ~1,050 clips, before the window and renderer fixes). -# -# The background is FCPlaythroughs' Pokemon Red longplay. Timings were derived, -# not guessed, and all three sources were tied to ONE clock first: -# -# * pkmn-ref.mp4 (a combined A+V section) starts at source 620.000s exactly -- -# established by cross-correlating its audio against the complete audio -# track, which matched to the sample. -# * pkmn-long-v.webm (the high-quality video) starts at source 644.134s -- -# established by matching the 1100ms black run, which sits at 36.267s in the -# reference and 12.133s here. -# * the audio is cut from the complete track at 644.134s to match. -# -# That mattered: a first attempt cut the audio at the REQUESTED start rather -# than the actual one, and the two were ~14s apart, so no audio event lined up -# with any picture event. -# -# The splice is on the AUDIO, not the black frames. The encounter's black run -# ends at 13.233s, but the game's own rapid-notes alarm keeps playing to ~13.2s -# and the battle theme's downbeat lands at 13.50s -- which is where the ums come -# in, so the game plays its own intro and hands over on the beat. -# -# FLOURISH=drop the MIDI's own rapid intro is removed; the background's -# alarm is the intro (default) -# FLOURISH=keep the ums play the rapid intro themselves, and the game audio -# ducks BEFORE the alarm so it is not doubled -set -e -cd /home/user/.claude/jobs/efbe67a7/tmp/song -D=/home/user/reports/quartering-uh-song -T=/home/user/.claude/jobs/efbe67a7/tmp - -BG_START=${BG_START:-5.866} # 10:50 in the source, in pkmn-long-v time -SPLICE=${SPLICE:-13.500} # the battle theme's downbeat, same clock -# Where the game's own ALARM starts, same clock. Read off the pitch contour: the -# route music before it is B4/C#5/D5, and the alarm is the G5-F#5-F5-G6 figure -- -# audibly higher and quite different, which is how it is picked out. It runs -# 2.6s, and the MIDI's own alarm is 33 notes over 2.5s, so the two agree. -ALARM_AT=${ALARM_AT:-10.966} -FLOURISH=${FLOURISH:-keep} -# 0 = CUT the game's own sound where the ums take over, rather than leaving a -# bed under them. The battle music would otherwise play its version of the same -# tune underneath ours. -DUCK=${DUCK:-0.0} -PLANF=${PLANF:-pkmn-plan-v2.json} -OUT=${OUT:-$D/quartering-pokemon-v2.mp4} -mkdir -p "$(dirname "$OUT")" - -# The MIDI's own alarm is notes 0..32 of the lead (t=0 -> 2.500s, 78ms apart); -# the tune proper starts at 2.969s. Those are the notes FLOURISH=drop removes. -CUTAT=${CUTAT:-2.95} -SCALE=${SCALE:-1.15} -if [ "$FLOURISH" = "keep" ]; then CUTAT=-1; HANDOVER=$ALARM_AT; else HANDOVER=$SPLICE; fi - -# ---- arrange ---------------------------------------------------------------- -# poolFactor 3.0 on the bass. NOT 6.0: that was calibrated when the bass had 143 -# notes, and this MIDI's bass has 471 -- at 6.0 it claims 2,840 clips and STARVES -# the melody (413 clips, p90 4.64). Measured knee: 3.0 gives melody 2.39 / bass 2.87. -if [ "${REUSE_PLAN:-0}" != "1" ]; then - # Voices from PkmRB-Battle1.mid -- Pokemon RED/BLUE, which is what the - # background actually is. Its track names spell the credit (sequenced by - # Joao Buaes), like the Mortal Kombat file, so tracks are picked by INDEX: - # 2 -> melody 144 notes, IOI p50 0.469s, and it carries the alarm - # 3 -> bass 471 notes, IOI p50 0.156s - # 1 -> harm 516 notes, IOI p50 0.078s -- LEFT OUT. At 78ms every note is - # shorter than the palette's 0.14s minimum clip, so it can only - # come out as fragments; Metal Slug's rhythm voice at 0.183s - # already reads as percussion rather than melody. - export TEMPO_SCALE=$SCALE MAX_IOI=0.70 WINDOW=700 LOCAL=0 WOBBLE_W=0.08 - export VOICES='[{"name":"melody","lead":"pkrb-lead.json","gain":1.0,"role":"full","unique":true,"reuseGap":20,"contourMax":14,"fill":true},{"name":"bass","lead":"pkrb-bass.json","gain":0.62,"role":"pip1","unique":true,"reuseGap":12,"contourMax":5,"poolFactor":3.0,"fill":false}]' - node arrange-poly.mjs - cp poly-plan.json "$PLANF.raw" -fi - -# Drop the flourish if asked, then shift so the first surviving note lands on -# the handover. -node -e " -const fs=require('fs'); -const p=JSON.parse(fs.readFileSync('$PLANF.raw','utf8')); -const CUT=$CUTAT; -let dropped=0; -if(CUT>0) for(const v of p.voices){const n0=v.plan.length; v.plan=v.plan.filter(n=>n.slotStart>=CUT*$SCALE-1e-6); dropped+=n0-v.plan.length;} -const first=Math.min(...p.voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart)); -const SH=$HANDOVER-$BG_START-first; -for(const v of p.voices) for(const n of v.plan) n.slotStart=+(n.slotStart+SH).toFixed(4); -fs.writeFileSync('$PLANF',JSON.stringify(p,null,1)); -const f2=Math.min(...p.voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart)); -const l2=Math.max(...p.voices.flatMap(v=>v.plan.map(n=>n.slotStart+n.slotDur))); -console.log('flourish '+('$FLOURISH')+': dropped '+dropped+' notes; shifted '+SH.toFixed(3)+ - 's -> first note '+f2.toFixed(3)+'s, ends '+l2.toFixed(3)+'s');" -[ "${ARRANGE_ONLY:-0}" = "1" ] && exit 0 - -# ---- background ------------------------------------------------------------- -# 10:50 onward, as one file, so the renderer's clock and this script's agree. -if [ ! -f "$T/pkmn-bg.mp4" ]; then - ffmpeg -nostdin -v error -y -ss "$BG_START" -i intro/pkmn-bg-full.mkv \ - -c:v libx264 -preset medium -crf 20 -pix_fmt yuv420p -video_track_timescale 30000 \ - -c:a aac -b:a 192k -ar 48000 -ac 2 "$T/pkmn-bg.mp4" -fi - -PLAN=$PLANF BG_VIDEO=$T/pkmn-bg.mp4 BG_FIT=pad LAYOUT=bg \ - node render-poly.mjs "$T/pkmn-body.mp4" - -# ---- the game's own sound under it ------------------------------------------ -# Full until the handover -- the transition and the alarm ARE the intro -- then -# down under the ums. -END=$(node -e " -const {voices}=require('$PWD/$PLANF'); -console.log((Math.max(...voices.flatMap(v=>v.plan.map(n=>n.slotStart+n.slotDur)))+0.7).toFixed(2));") -H=$(node -e "console.log(($HANDOVER-$BG_START).toFixed(3))") -echo "game audio full to ${H}s, then ${DUCK}; output ends ${END}s" -ffmpeg -nostdin -v error -y -i "$T/pkmn-body.mp4" -i "$T/pkmn-bg.mp4" \ - -filter_complex " -[1:a]volume='if(lt(t,${H}),1.0,${DUCK})':eval=frame,atrim=0:${END},asetpts=PTS-STARTPTS[game]; -[0:a]atrim=0:${END},asetpts=PTS-STARTPTS[song]; -[song][game]amix=inputs=2:duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]" \ - -map 0:v -map "[a]" -t "$END" -c:v copy -c:a aac -b:a 192k -ar 48000 -ac 2 \ - -movflags +faststart "$OUT" - -echo "PKMN_DONE -> $OUT" -ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT" diff --git a/umtool/song/pkmn-video.sh b/umtool/song/pkmn-video.sh @@ -1,122 +0,0 @@ -#!/usr/bin/env bash -# THE Pokemon video. One script, one shape, both aspect ratios, both lengths. -# -# This exists because the shape kept drifting: a build would get the X strokes but not -# the 2x2, or the 2x2 but not the alarm, or the vertical would get none of them because -# it was composed before the treatment existed. The shape is not a set of optional -# effects -- it is the video -- so it lives in one place and every cut runs the same -# steps in the same order. -# -# --------------------------------------------------------------------------- -# THE SHAPE -# -# melody the main panel (wide) / the primary box (vertical) -# bass the first pip (wide) / secondary box 1 (vertical) -# run the rapid third voice, which is NEVER a static panel: -# melody OUT -> X strokes sweeping the screen -# (vertical: inside the gameplay screen, not the whole frame) -# melody IN -> its own container, and inside that container -# notes <0.15s apart -> a 2x2 grid, one corner per note -# slower notes -> one clip filling the container -# -# the intro alarm (bg 3.960 -> 7.374, which the ums play themselves) -# -> one um per note snaking through whatever space is free: -# the whole screen in wide, the two bands in vertical, -# on a grid sized to travel the path about twice -# -# the catch jingle -# -> plays in the RUN'S container with the run's own visuals, so -# "PIKACHU was caught!" stays readable underneath -# -# --------------------------------------------------------------------------- -# FORMAT=wide|vertical CUT=long|short -# -# Everything else is derived. Set REUSE=1 to keep an existing base and only redo the -# visual layers, which is the cheap loop when the shape is being judged. -set -euo pipefail -cd /home/user/.claude/jobs/efbe67a7/tmp/song -S=/home/user/Projects/yt-dlp-transcript-browser/umtool/song -T=/home/user/.claude/jobs/efbe67a7/tmp -V=/home/user/reports/quartering-uh-song/videos/pokemon - -FORMAT=${FORMAT:-wide} -CUT=${CUT:-long} - -if [ "$CUT" = "short" ]; then - BODY_PLAN=pkmn-short-body.json; RUN_PLAN=pkmn-short-run.json - WORK=$T/pkshort; FILLER='1745-1782:3.0:17.5' - RUNFX=$T/pkshort-run-fx.wav; TAG=short - LAST=39.16; ENDS=49.53 - SOLO=pkshort -else - BODY_PLAN=pkmn-v12-body.json; RUN_PLAN=pkmn-v12-run.json - WORK=$T/pk9; FILLER='1745-1782:3.0:17.5 2189-2223:3.0:25.0 3710-3740:1.0:24.0' - RUNFX=$T/v12-run-fx.wav; TAG=long - LAST=90.21; ENDS=100.60 - SOLO=pkmn -fi -ALARM_A=3.96; ALARM_B=7.374 -JINGLE_AT=$(node -e "console.log(String(($ENDS - 4.03).toFixed(4)))") # jingle onset in output time - -mkdir -p "$V/plan" "$V/variants" -PFX=$T/pkv-$TAG-$FORMAT # working prefix (B is taken: bars-snake reads B as its window end) - -# ---- 1. the audio: lift the run where the melody rests ---------------------- -# The run exists to carry those rests, and it was mixed at one level throughout. -LIFT=${LIFT:-5} RAMP=0.25 GATE_PLAN=$BODY_PLAN GATE_VOICE=melody \ - node "$S/rest-lift.mjs" "$RUNFX" "$PFX-runfx.wav" | tail -2 - -# ---- 2. the base ------------------------------------------------------------ -# GAME_BED=0.30: the game holds a bed across the alarm and fades to nothing BY the -# downbeat, so the ums' alarm rises out of the game's own instead of replacing it -# between one sample and the next. JINGLE_VIDEO is off -- the jingle gets the run's -# visuals in step 4 instead of panels over the payoff text. -if [ "${REUSE:-0}" = "1" ] && [ -f "$PFX-base.mp4" ]; then - echo "REUSE=1: keeping $PFX-base.mp4" -elif [ "$FORMAT" = "wide" ]; then - WORK=$WORK REUSE_BG=1 REUSE_BODY=1 GAME_BED=0.30 PLANF=$BODY_PLAN \ - JINGLE=$T/pk9/jingle-finale.mp4 CHOP="$PFX-runfx.wav" CHOP_GAIN=0.55 \ - OUT="$PFX-base.mp4" FILLER="$FILLER" bash "$S/pk3.sh" | tail -2 -else - # the vertical composes from the WIDE cut: it supplies the audio and the gimmick - # picture, and the voice boxes come from the solo renders. - WIDE=$V/$( [ "$CUT" = short ] && echo wide-short || echo wide ).mp4 - [ -f "$WIDE" ] || { echo "need $WIDE first -- build FORMAT=wide CUT=$CUT"; exit 2; } - COMPOSED=$WIDE GAMEPLAY=$WORK/bg.mp4 \ - PRIMARY=$T/solo-$SOLO-melody.mp4 SEC1=$T/solo-$SOLO-bass.mp4 SEC2= CENTRE_LONE=0 \ - OFFSET=0 GIMMICKS="0-$ALARM_B,$LAST-$ENDS" \ - node "$S/shorts-compose.mjs" "$PFX-base.mp4" | tail -1 -fi - -# ---- 3/4/5. the shape ------------------------------------------------------- -if [ "$FORMAT" = "wide" ]; then - BOXARG=384:216:870:478 ; FW=1280 ; FH=720 - STROKE_BOX="" ; STROKE_TILE=420 - SNAKE_BANDS="0:720" ; SNAKE_W=1280 -else - BOXARG=480:270:552:1537 ; FW=1080 ; FH=1920 - STROKE_BOX=1080:972:0:541 ; STROKE_TILE=300 - SNAKE_BANDS="0:541,1513:1920" ; SNAKE_W=1080 -fi - -echo "--- the run, in its container" -MODE=auto BOX=$BOXARG W=$FW H=$FH GATE_PLAN=$BODY_PLAN GATE_VOICE=melody \ - node "$S/run-line.mjs" "$RUN_PLAN" run "$PFX-base.mp4" "$PFX-run.mp4" 2>&1 | grep -vE "deprecated|Last message" | tail -2 - -echo "--- the jingle, same container, same visuals" -MODE=auto BOX=$BOXARG W=$FW H=$FH OFFSET=-$JINGLE_AT \ - node "$S/run-line.mjs" pkc-jingle-merged.json jingle "$PFX-run.mp4" "$PFX-jing.mp4" 2>&1 | grep -vE "deprecated|Last message" | tail -2 - -echo "--- the X strokes, where the melody is out" -BOX=$STROKE_BOX W=$FW H=$FH TILE_W=$STROKE_TILE MIN_RUN=4 TRAIL=0.30 EXTENT=0.82 MARGIN=8 \ - GATE_PLAN=$BODY_PLAN GATE_VOICE=melody \ - node "$S/climb-panel.mjs" "$RUN_PLAN" run "$PFX-jing.mp4" "$PFX-str.mp4" 2>&1 | grep -vE "deprecated|Last message" | tail -2 - -echo "--- the intro alarm, snaking, about two loops" -OUT=$V/$( [ "$FORMAT" = wide ] && echo wide || echo vertical )$( [ "$CUT" = short ] && echo -short || echo "" ).mp4 -A=$ALARM_A B=$ALARM_B W=$SNAKE_W PASSES=2 BANDS="$SNAKE_BANDS" \ - node "$S/bars-snake.mjs" "$BODY_PLAN" melody "$PFX-str.mp4" "$OUT" 2>&1 | grep -vE "deprecated|Last message" | tail -3 - -cp "$BODY_PLAN" "$RUN_PLAN" "$V/plan/" 2>/dev/null || true -echo "PKMN_${FORMAT}_${CUT}_DONE -> $OUT" -ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT" diff --git a/umtool/song/rpg-remake-v5.sh b/umtool/song/rpg-remake-v5.sh @@ -1,277 +0,0 @@ -#!/usr/bin/env bash -# Super Mario RPG, rebuilt on the 3,959-clip palette, with the two changes asked -# for after v4: -# -# DRUM_GAIN the snare roll and the drum hit are lifted harder. They are very -# quiet in the source (peaks ~500-1600 against the transition's -# ~6000), and at 1.7 the hit still sits under the ums that follow. -# A BUMP, not a doubling -- 2.1 and 2.4 are the values to compare. -# -# UM_DELAY hold the ums back by a fraction of a second. The body is cut at -# -ss LEAD (1.9556s, the second slot) so the first um lands at -# final t=0; cutting at LEAD-UM_DELAY instead gives UM_DELAY -# seconds of background before the first um WITH THE TUNE ITSELF -# UNTOUCHED. The ending is pinned by BG_END, which is not moved, so -# the background's wipe to black still lands on the last note -- -# the video simply becomes UM_DELAY longer at the front. -# -# Everything else is v4's, including the two values that took several passes to -# get right: INTRO_END 10.400 (keeps the drum hit AND its decay; 10.100 cut one -# frame in front of it and kept the snare's tail instead) and BG_FIT=pad (the -# background is 960x720, and cropping it to 16:9 reads as the picture ZOOMING IN -# the moment the song starts). -set -euo pipefail -# The DATA dir, not the script's own -- arrange-poly writes poly-plan.json into -# SONG_DATA, and this script copies it from the working directory. Running the repo -# copy with `cd $(dirname $0)` landed in the repo, where that file does not exist, and -# the rebuild died on `cp: cannot stat 'poly-plan.json'` AFTER arranging successfully. -# yoshi-rebuild.sh and pk3.sh both use the absolute path; this one was the exception. -cd /home/user/.claude/jobs/efbe67a7/tmp/song -D=/home/user/reports/quartering-uh-song -T=/home/user/.claude/jobs/efbe67a7/tmp - -INTRO_END=${INTRO_END:-10.400} -DRUM_FROM=${DRUM_FROM:-8.60} -DRUM_GAIN=${DRUM_GAIN:-2.1} -# UM_DELAY=auto DERIVES the spacing instead of guessing it. -# -# The arrangement's opening note is a lone bass B1 at 1.4666s standing in for the -# drum hit the theme starts on, and the real downbeat is at 1.9556s -- so the -# composer's own spacing between the kick and the first melody note is 0.4890s. -# The intro carries the game's REAL drum hit, whose low-band onset is at 10.115s -# (measured: the band is silent at 10.110 and rises 1214 -> 2183 -> 3599 over the -# next three 5ms frames, peaking 4337 at 10.150). Lining the real hit up with the -# note that stood in for it puts the first um at 10.115 + 0.489 = 10.604s, and -# the body starts at INTRO_END, so the delay is 10.604 - INTRO_END = 0.204s. -KICK_AT=${KICK_AT:-10.115} -UM_DELAY=${UM_DELAY:-auto} -# UM_FADE fades the first um IN, rather than giving it more room. -# -# The body is cut at EXACTLY the slot start with no padding, so the first um's -# attack lands on the body's very first sample -- a hard splice out of the -# intro's decay straight onto a note onset. That can read as jarring rather than -# as early, which is a different complaint with a different fix, so it is a -# separate knob: UM_DELAY buys space, UM_FADE softens the entry. The body's audio -# is the ums ALONE (the background supplies picture only), so this touches -# nothing else. -UM_FADE=${UM_FADE:-0} -XFADE=${XFADE:-0} -# INTRO_GAIN lifts the WHOLE intro, in dB, to meet the body. -# -# The seam was described as "stark", and it is a LEVEL STEP rather than a -# transition problem. Measured on rpg-A-cut with ebur128: the intro runs -# -22.0 LUFS into the seam and the body arrives at -17.0 LUFS. Five LU is a -# plainly audible jump, and no crossfade can fix it -- a fade only smears the -# step over its duration. rpg-D-xfade030 is actually WORSE by this measure: the -# fade pulls the intro's last two seconds to -30.2 LUFS, so the song arrives -# against a 13 LU contrast instead of 5. -# -# Headroom for it: true peak into the seam is -8.1 dBFS, so +5 dB lands at -# -3.1 dBFS with the lifted drum hit still intact. -INTRO_GAIN=${INTRO_GAIN:-0} -# Separate the um kick from the game's kick IN TIME. -# -# At offset 0 the two land on the same instant in the same low band, and the -# louder simply masks the other -- which is why a variant built to have two -# kicks was heard as having one. Around 0.1s reads as two distinct hits; around -# 0.03s reads as a flam (one thickened hit). Note the intro is hard-cut at -# INTRO_END, so a large offset leaves the um kick very little room. -KICK_UM_OFFSET=${KICK_UM_OFFSET:-0} -BG_END=${BG_END:-65.583} -# The body render, named after the build so a short cannot clobber the long. -BODY=${BODY:-$T/r5-body.mp4} -PLANF=${PLANF:-rpg-plan-v4.json} -OUT=${OUT:-$D/quartering-mario-rpg-v5.mp4} -FADE_ST=$(node -e "console.log(($INTRO_END - 0.06).toFixed(3))") -mkdir -p "$(dirname "$OUT")" - -# ---- arrange ---------------------------------------------------------------- -# poolFactor 6.0 is free here and nowhere else: the melody needs only 210 of -# ~2,800 clips, so tripling and doubling the bass pool costs it nothing -# (p90 0.39 -> 0.41) while the bass median more than halves (0.52 -> 0.21). -if [ "${REUSE_PLAN:-0}" != "1" ]; then - export TEMPO_SCALE=1.10 MAX_IOI=0.70 WINDOW=700 LOCAL=0 TIME_LIMIT=${TL:-999} - # VOICES is overridable so a variant can add the third voice (Brass B) or point at - # held-last-note lead files, without a second copy of this script. - export VOICES=${VOICES:-'[{"name":"melody","lead":"rpg-lead.json","gain":1.0,"role":"full","unique":true,"reuseGap":20,"contourMax":12,"fill":true},{"name":"bass","lead":"rpg-bass.json","gain":0.68,"role":"pip1","unique":true,"reuseGap":12,"contourMax":4,"poolFactor":6.0,"fill":false}]'} - node arrange-poly.mjs - cp poly-plan.json "$PLANF" -fi -[ "${ARRANGE_ONLY:-0}" = "1" ] && exit 0 - -# ---- drop the stand-in kick note -------------------------------------------- -# v4 got rid of it by CUTTING at the second slot, which worked only because the -# cut was exactly there. UM_DELAY moves the cut EARLIER, straight back over that -# note -- so it has to leave the plan, or a truncated low blip returns in front -# of the tune and the intro's real drum hit is answered by a fake one. -LEAD=$(node -e " -const {voices}=require('$PWD/$PLANF'); -const t=[...new Set(voices.flatMap(v=>v.plan.map(n=>n.slotStart)))].sort((a,b)=>a-b); -console.log((t[1] ?? t[0]).toFixed(4));") -TRIMF="${PLANF%.json}-trim.json" -node -e " -const fs=require('fs'); -const p=JSON.parse(fs.readFileSync('$PLANF','utf8')); -let dropped=0; -for(const v of p.voices){const n0=v.plan.length; v.plan=v.plan.filter(n=>n.slotStart>=$LEAD-1e-6); dropped+=n0-v.plan.length;} -fs.writeFileSync('$TRIMF',JSON.stringify(p,null,1)); -console.log('dropped '+dropped+' note(s) before the downbeat at ${LEAD}s -> $TRIMF');" - -# ---- how far to hold the ums back ------------------------------------------- -if [ "$UM_DELAY" = "auto" ]; then - UM_DELAY=$(node -e " - const {voices}=require('$PWD/$PLANF'); - const t=[...new Set(voices.flatMap(v=>v.plan.map(n=>n.slotStart)))].sort((a,b)=>a-b); - const gap=(t[1]??t[0])-t[0]; // the MIDI's own kick -> downbeat spacing - console.log(Math.max(0, $KICK_AT + gap - $INTRO_END).toFixed(4));") - echo "UM_DELAY=auto -> ${UM_DELAY}s (real kick at ${KICK_AT}s + the MIDI's own kick-to-downbeat spacing)" -fi - -# ---- body ------------------------------------------------------------------- -# REUSE_BODY=1 skips the ~20-minute render when only the trim or the intro has -# changed. The body does not depend on either -- they are concatenated, not -# mixed -- so every UM_DELAY past the first is nearly free. -if [ "${REUSE_BODY:-0}" = "1" ] && [ -f "$BODY" ]; then - echo "reusing $BODY (REUSE_BODY=1)" -else - # OUT_END=0: do NOT trim the render to the audio's end. The ending here is the - # background's own wipe to black at 65.583s, and the last note is at 64.53s -- - # trimming to the audio throws the ending away (7 trailing black frames where - # v4 had 32). BG_END does the cutting instead, below. - OUT_END=0 PLAN=$TRIMF BG_VIDEO=$PWD/${BG:-intro/smrpg-bg.mp4} BG_FIT=pad LAYOUT=bg \ - node render-poly.mjs "$BODY" | tail -3 -fi - -CUT=$(node -e "console.log(Math.max(0, $LEAD - $UM_DELAY).toFixed(4))") -echo "lead-in ${LEAD}s, holding the ums back ${UM_DELAY}s -> cutting the body at ${CUT}s, ending at ${BG_END}s" -# String(), not a bare number -- node colourises numeric console.log on a TTY, -# so this test would compare against an ANSI-wrapped "1" and never fire. -if [ "$(node -e "console.log(String($UM_FADE>0?1:0))")" = "1" ]; then - # Start the fade AT the um, not at the body's first sample. UM_DELAY puts - # that many seconds of silence in front, so a fade shorter than the delay - # fades nothing at all -- measured: at UM_DELAY 0.204 a 0.18s fade produced a - # byte-identical file. - AF="-af afade=t=in:st=${UM_DELAY}:d=${UM_FADE}" - echo " fading the first um in over ${UM_FADE}s, from ${UM_DELAY}s" -else - AF="" -fi -# shellcheck disable=SC2086 -ffmpeg -nostdin -v error -y -ss "$CUT" -to "$BG_END" -i "$BODY" $AF \ - -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p -video_track_timescale 30000 \ - -c:a aac -b:a 192k -ar 48000 -ac 2 "$T/r5-bd.mp4" - -# ---- the stand-in kick, as an um, ON the real one --------------------------- -# KICK_UM=1 plays the dropped note after all -- but placed on the BACKGROUND's -# own kick rather than after it, so the two land together. -# -# It cannot simply be left in the body: the game's kick is at 10.115s and the -# body does not start until INTRO_END (10.400s), so a note carried in the body -# can only ever arrive 0.285s LATE. Mixing it onto the intro at the kick's own -# time is the only way to make them coincide. Its audio comes from a one-note -# render of the notes the trim dropped, so it is the same clip the arranger -# chose, at the same gain. -KICK_MIX="" -if [ "${KICK_UM:-0}" = "1" ]; then - KICKF="${PLANF%.json}-kick.json" - if [ ! -f "$T/r5-kick.wav" ]; then - # Drop the EMPTY voices and make what is left the `full` one. The stand-in - # kick is a BASS note, so a straight filter leaves the melody with no notes - # at all -- and the renderer looks up role:"full" to build the main panel, - # which then has nothing to build from. - node -e " - const fs=require('fs'); - const p=JSON.parse(fs.readFileSync('$PLANF','utf8')); - for(const v of p.voices) v.plan=v.plan.filter(n=>n.slotStart<$LEAD-1e-6); - p.voices=p.voices.filter(v=>v.plan.length); - if(!p.voices.length) throw new Error('nothing before the downbeat to extract'); - p.voices[0].role='full'; - p.voices=[p.voices[0]]; - fs.writeFileSync('$KICKF',JSON.stringify(p,null,1)); - console.log('kick-only plan: '+p.voices.map(v=>v.name+' '+v.plan.length).join(', '));" - PLAN=$KICKF BG_VIDEO=$PWD/intro/smrpg-bg.mp4 BG_FIT=pad LAYOUT=bg \ - node render-poly.mjs "$T/r5-kickonly.mp4" >/dev/null 2>&1 - KSTART=$(node -e " - const {voices}=require('$PWD/$KICKF'); - console.log(Math.min(...voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart)).toFixed(4));") - ffmpeg -nostdin -v error -y -ss "$KSTART" -t 0.60 -i "$T/r5-kickonly.mp4" -ac 1 -ar 48000 "$T/r5-kick.wav" - echo " extracted the stand-in kick um from ${KSTART}s" - fi - KDELAY=$(node -e "console.log(String(Math.round(($KICK_AT+$KICK_UM_OFFSET)*1000)))") - # The um kick is a 0.60s clip laid at 10.115s, but the intro is cut at - # INTRO_END (10.400s) -- so it was being CHOPPED 285ms in, with no fade, at - # exactly the seam. That truncation is its own contribution to the stark - # transition. Fade it out on the same schedule as the intro instead. - KICK_MIX="[1:a]aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,volume=${KICK_UM_GAIN:-1.0},adelay=delays=${KDELAY}:all=1,afade=t=out:st=${FADE_ST}:d=0.06[k];[main][k]amix=inputs=2:duration=first:normalize=0[aout]" - echo " KICK_UM=1: the um kick is laid on the real kick at ${KICK_AT}s" -fi - -# ---- intro: gameplay, transition, black, silence, SNARE ROLL, DRUM HIT ------- -AFILT="volume='if(lt(t,${DRUM_FROM}),1.0,${DRUM_GAIN})':eval=frame,volume=${INTRO_GAIN}dB,afade=t=out:st=${FADE_ST}:d=0.06" -if [ -n "$KICK_MIX" ]; then - ffmpeg -nostdin -v error -y -t "$INTRO_END" -i intro/smrpg-intro.mp4 -i "$T/r5-kick.wav" \ - -filter_complex "[0:v]scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30[vout];[0:a]${AFILT}[main];${KICK_MIX}" \ - -map "[vout]" -map "[aout]" -t "$INTRO_END" \ - -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p -video_track_timescale 30000 \ - -c:a aac -b:a 192k -ar 48000 -ac 2 "$T/r5-intro.mp4" -else - ffmpeg -nostdin -v error -y -t "$INTRO_END" -i intro/smrpg-intro.mp4 \ - -vf "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30" \ - -af "$AFILT" \ - -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p -video_track_timescale 30000 \ - -c:a aac -b:a 192k -ar 48000 -ac 2 "$T/r5-intro.mp4" -fi - -# The peak of the lifted section, BEFORE the concat -- a gain that clips the -# drum hit is worse than one that leaves it quiet. -node -e " -const {execFileSync}=require('child_process'); -const b=execFileSync('ffmpeg',['-v','error','-ss','${DRUM_FROM}','-i','$T/r5-intro.mp4','-ac','1','-ar','48000','-f','s16le','-'],{maxBuffer:1<<28}); -const a=new Int16Array(b.buffer,b.byteOffset,b.length/2); -let pk=0,s=0; for(let i=0;i<a.length;i++){const v=Math.abs(a[i]); if(v>pk)pk=v; s+=a[i]*a[i];} -console.log(' intro after ${DRUM_FROM}s at gain ${DRUM_GAIN}: peak '+pk+'/32768 ('+(pk/32768*100).toFixed(1)+'%), rms '+Math.sqrt(s/a.length).toFixed(0)+(pk>=32700?' ** CLIPPING **':''));" - -# ---- assemble --------------------------------------------------------------- -# XFADE>0 dissolves the intro into the body instead of cutting, in picture AND -# sound, so the change from the game's own gameplay+audio to the background+ums -# is a transition rather than a splice. It costs XFADE seconds of overlap, so the -# ums arrive that much sooner -- which is why it is a variant and not a default. -# XFADE_AUDIO crossfades the SOUND only and leaves the picture a hard cut. -# -# Judged best: "xfade30 audio but with f-level distance from the kick and static -# visuals". The picture dissolve was never what the seam needed -- the seam was a LEVEL -# step, which INTRO_GAIN fixes -- but a short audio crossfade still softens the arrival -# of the first um without smearing the join visually. -# -# An audio crossfade SHORTENS the result by XFADE while a video concat does not, so the -# picture has to lose the same amount or everything after the join drifts. It comes off -# the INTRO'S TAIL, which is the static battle scene after the drum hit's decay -- the -# one stretch where 0.3s of picture is invisible -- and it puts the cut exactly where -# the body's sound starts fading in. -if [ "$(node -e "console.log(String(${XFADE_AUDIO:-0}>0?1:0))")" = "1" ]; then - ITRIM=$(node -e "console.log(($INTRO_END - $XFADE_AUDIO).toFixed(3))") - echo " audio-only crossfade ${XFADE_AUDIO}s; picture cuts hard at ${ITRIM}s (intro tail trimmed to match)" - ffmpeg -nostdin -v error -y -i "$T/r5-intro.mp4" -i "$T/r5-bd.mp4" \ - -filter_complex "[0:v]trim=0:${ITRIM},setpts=PTS-STARTPTS[iv];[1:v]setpts=PTS-STARTPTS[bv];\ -[iv][bv]concat=n=2:v=1[v];[0:a][1:a]acrossfade=d=${XFADE_AUDIO}:c1=tri:c2=tri[a]" \ - -map "[v]" -map "[a]" \ - -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p -c:a aac -b:a 192k -ar 48000 -ac 2 \ - -movflags +faststart "$OUT" -elif [ "$(node -e "console.log(String($XFADE>0?1:0))")" = "1" ]; then - IDUR=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$T/r5-intro.mp4") - OFF=$(node -e "console.log(Math.max(0,$IDUR-$XFADE).toFixed(3))") - echo " crossfading intro -> body over ${XFADE}s (offset ${OFF}s)" - ffmpeg -nostdin -v error -y -i "$T/r5-intro.mp4" -i "$T/r5-bd.mp4" \ - -filter_complex "[0:v][1:v]xfade=transition=fade:duration=${XFADE}:offset=${OFF}[v];[0:a][1:a]acrossfade=d=${XFADE}:c1=tri:c2=tri[a]" \ - -map "[v]" -map "[a]" \ - -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p -c:a aac -b:a 192k -ar 48000 -ac 2 \ - -movflags +faststart "$OUT" -else - ffmpeg -nostdin -v error -y -i "$T/r5-intro.mp4" -i "$T/r5-bd.mp4" \ - -filter_complex "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]" -map "[v]" -map "[a]" \ - -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p -c:a aac -b:a 192k -ar 48000 -ac 2 \ - -movflags +faststart "$OUT" -fi - -echo "RPG5_DONE -> $OUT" -ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT" diff --git a/umtool/song/rpg-short.sh b/umtool/song/rpg-short.sh @@ -1,152 +0,0 @@ -#!/usr/bin/env bash -# Super Mario RPG, cut at the first repeat, ending on the transition INTO the next -# battle. -# -# The tune is a literal exact double -- find-repeats scores 100% at 28.48s -- so half -# of the v6 build is the same 28.5 seconds played twice. TL=28.48 is therefore not a -# trim but the whole tune, once. -# -# That leaves the ending to be rebuilt, because v6's ending is the background's own -# wipe at bg 64.267 and the short song is over at 31.77. The background's black runs -# are what make the splice free: -# -# 0.000 - 0.850 opening black -# 0.850 - 27.617 THE BATTLE <- the whole short song plays over this -# 27.617 - 29.000 black, the battle ending -# ... -# 57.033 - 58.367 black -# 58.367 - 64.267 overworld: Mario walks into an enemy -# 64.267 - 65.617 THE WIPE INTO THE NEXT BATTLE, and its sting -# -# Cut segment A inside the first black run and start segment B inside the second, and -# the seam is black-to-black -- invisible, rather than a jump cut between two scenes. -# What the viewer sees is one continuous piece: the battle ends, Mario walks, he hits -# an enemy, the screen wipes. -# -# The sting is bg 63.90 -> 64.78 (measured: RMS 1153 -> 2095 -> 2673 -> 2961 across -# 63.85-64.00), followed by TRUE DIGITAL SILENCE 64.80 - 65.45 over the black. So the -# tail ends at 65.48 -- inside that silence, before the next battle's audio starts at -# 65.50 -- and the video simply stops in the black. -set -euo pipefail -cd /home/user/.claude/jobs/efbe67a7/tmp/song -D=/home/user/reports/quartering-uh-song -T=/home/user/.claude/jobs/efbe67a7/tmp -S=/home/user/Projects/yt-dlp-transcript-browser/umtool/song - -A_END=${A_END:-27.90} # inside the black run 27.617-29.000 -B_START=${B_START:-58.10} # inside the black run 57.033-58.367 -B_END=${B_END:-65.48} # inside the digital silence after the sting -SFX_AT=${SFX_AT:-63.88} # the sting's onset in the SOURCE -SFX_GAIN=${SFX_GAIN:-0.9} -OUT=${OUT:-$D/quartering-mario-rpg-short.mp4} -PLANF=${PLANF:-rpg-plan-short.json} -BODY=${BODY:-$T/rpg-short-body.mp4} -BASE=${BASE:-$T/rpg-short-base.mp4} -# BLEND lays the slightest bit of the GAME'S OWN next note under our first um. -# -# Measured on intro/smrpg-intro.mp4: the drum kick is at 10.115, the theme's next beat -# at 10.570 (a low hit, energy 19 -> 32 dB) and its melodic content at 10.630 (the high -# band takes 60-65%, peak ~1.2kHz). Our first um lands at 10.604 -- between the two -- -# because UM_DELAY is derived from the MIDI's own kick-to-downbeat spacing, so the two -# were already in agreement and nothing of the game was audible only because the intro -# is hard-cut at INTRO_END. -# -# So the blend is not a retime: it is letting a little of what was already there through. -# BLEND_GAIN is deliberately low and BLEND_FADE short -- "mostly the um with just the -# slightest bit of the beginning of the note". -BLEND=${BLEND:-0} -BLEND_FROM=${BLEND_FROM:-10.400} -BLEND_LEN=${BLEND_LEN:-0.42} -BLEND_GAIN=${BLEND_GAIN:-0.30} -BLEND_FADE=${BLEND_FADE:-0.26} - -BG=intro/smrpg-bg-short.mp4 -SPLICED=$(node -e "console.log(($A_END + $B_END - $B_START).toFixed(3))") -STING_IN_BG=$(node -e "console.log(($A_END + $SFX_AT - $B_START).toFixed(3))") -SFX_LEN=$(node -e "console.log(($B_END - $SFX_AT).toFixed(3))") -echo "spliced background ${SPLICED}s; the sting lands at ${STING_IN_BG}s, ${SFX_LEN}s long" - -# ---- 1. the spliced background ---------------------------------------------- -# Both halves re-encoded with identical parameters so the concat demuxer can join -# them without a re-wrap surprise. 960x720 and 60fps are the source's own; the -# renderer pads to 1280x720 itself (BG_FIT=pad -- cropping this 4:3 capture reads -# as the picture zooming in, see mario-rpg.md). -if [ ! -f "$BG" ]; then - for seg in "a:0:$A_END" "b:$B_START:$(node -e "console.log(($B_END-$B_START).toFixed(3))")"; do - IFS=: read -r n ss dur <<EOF -$seg -EOF - ffmpeg -nostdin -v error -y -ss "$ss" -i intro/smrpg-bg.mp4 -t "$dur" -an \ - -c:v libx264 -preset medium -crf 18 -pix_fmt yuv420p -video_track_timescale 60000 \ - "$T/rpgshort-$n.mp4" - done - printf "file '%s'\nfile '%s'\n" "$T/rpgshort-a.mp4" "$T/rpgshort-b.mp4" > "$T/rpgshort-concat.txt" - ffmpeg -nostdin -v error -y -f concat -safe 0 -i "$T/rpgshort-concat.txt" -c copy "$BG" - echo " built $BG ($(ffprobe -v error -show_entries format=duration -of csv=p=0 "$BG")s)" -fi - -# ---- 2. the sting, and a silent spacer -------------------------------------- -# The spacer is what makes the picture reach the end of the background. render-poly -# sizes its master buffer from max(last slot, last overlay) + 1s, and the picture -# stops where that buffer does -- which is how the Pokemon v9 body came out 7.4s -# short with a container duration that still read 100.5s. A silent overlay ending at -# the background's end is the smallest thing that holds the picture open. -# -# The sting itself is NOT an overlay: mixed into the master it could only be -# re-levelled by re-rendering, and levels here are judged by ear. Muxed onto the -# finished video with -c:v copy instead, a new balance costs seconds. -mkdir -p sfx -[ -f sfx/rpg-transition.wav ] || ffmpeg -nostdin -v error -y -ss "$SFX_AT" -i intro/smrpg-bg.mp4 \ - -t "$SFX_LEN" -vn -ar 48000 -ac 2 sfx/rpg-transition.wav -[ -f sfx/rpg-spacer.wav ] || ffmpeg -nostdin -v error -y -f lavfi -i anullsrc=r=48000:cl=stereo \ - -t "$SFX_LEN" sfx/rpg-spacer.wav -node -e " -const fs=require('fs'); -fs.writeFileSync('rpg-short-overlays.json', JSON.stringify( - [{file:'sfx/rpg-spacer.wav', at:$STING_IN_BG, gain:0.0, label:'spacer: holds the picture open to the wipe'}], null, 1)); -console.log(' overlay spacer at ${STING_IN_BG}s');" - -# ---- 3. the body, over the spliced background ------------------------------- -# INTRO_GAIN=5 is the MEASURED fix for the seam, not a taste call: the intro runs -# -22.0 LUFS into it and the body arrives at -17.0, and no crossfade can close a -# level step. v6 (the long) was built without it, so short and long differ here. -REUSE_PLAN=1 TL=28.48 PLANF="$PLANF" \ - BG="$BG" BG_END="$SPLICED" BODY="$BODY" \ - OVERLAYS=rpg-short-overlays.json INTRO_GAIN=${INTRO_GAIN:-5} \ - OUT="$BASE" bash "$S/rpg-remake-v5.sh" - -# ---- 4. the sting, onto the finished video ---------------------------------- -# Its time in the OUTPUT: the intro is concatenated in front and the body was cut at -# CUT, so bg time t becomes 10.400 + t - CUT. -CUT=$(node -e " -const {voices}=require('$PWD/$PLANF'); -const t=[...new Set(voices.flatMap(v=>v.plan.map(n=>n.slotStart)))].sort((a,b)=>a-b); -console.log(Math.max(0,(t[1]??t[0])-0.2040).toFixed(4));") -AT=$(node -e "console.log((10.400 + $STING_IN_BG - $CUT).toFixed(3))") -MS=$(node -e "console.log(String(Math.round((10.400 + $STING_IN_BG - $CUT)*1000)))") -echo "muxing the sting at ${AT}s of the output (body cut at ${CUT}s)" - -# The blend rides in on the same mux. The intro occupies 0 -> INTRO_END of the output, -# so a snippet cut from the intro asset at time X sits at output time X -- no offset -# arithmetic, which is the one thing that could quietly put it in the wrong place. -BLEND_IN="" -BLEND_FC="" -BLEND_MIX="[0:a][s]" -NMIX=2 -if [ "$BLEND" = "1" ]; then - [ -f sfx/rpg-firstnote.wav ] || ffmpeg -nostdin -v error -y -ss "$BLEND_FROM" -i intro/smrpg-intro.mp4 \ - -t "$BLEND_LEN" -vn -ar 48000 -ac 2 sfx/rpg-firstnote.wav - BMS=$(node -e "console.log(String(Math.round($BLEND_FROM*1000)))") - BLEND_IN="-i sfx/rpg-firstnote.wav" - BLEND_FC="[2:a]volume=${BLEND_GAIN},aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,afade=t=out:st=0:d=${BLEND_FADE},adelay=delays=${BMS}:all=1[b];" - BLEND_MIX="[0:a][s][b]" - NMIX=3 - echo " blending ${BLEND_LEN}s of the game's own next note at ${BLEND_FROM}s, gain ${BLEND_GAIN}, fading over ${BLEND_FADE}s" -fi -# shellcheck disable=SC2086 -ffmpeg -nostdin -v error -y -i "$BASE" -i sfx/rpg-transition.wav $BLEND_IN \ - -filter_complex "[1:a]volume=${SFX_GAIN},aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo,adelay=delays=${MS}:all=1[s];${BLEND_FC}${BLEND_MIX}amix=inputs=${NMIX}:duration=first:normalize=0,alimiter=limit=0.98:level=disabled[a]" \ - -map 0:v -map "[a]" -c:v copy -c:a aac -b:a 192k -movflags +faststart "$OUT" - -echo "RPG_SHORT_DONE -> $OUT" -ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT" diff --git a/umtool/song/vshort.sh b/umtool/song/vshort.sh @@ -1,52 +0,0 @@ -#!/usr/bin/env bash -# Build one tune's vertical (1080x1920) cut: render every voice alone over black, then -# compose the four boxes. -# -# VOICES="melody bass arp" from PLAN -# VOICES="melody bass run@other.json" a voice whose plan is elsewhere (Pokemon's run -# is rendered separately, so it has its own) -# -# The first voice is the PRIMARY box, the next two the secondaries. A tune with two -# voices simply leaves the second secondary box empty -- which is what an idle box -# looks like anyway, so nothing special is needed for it. -set -euo pipefail -cd /home/user/.claude/jobs/efbe67a7/tmp/song -T=/home/user/.claude/jobs/efbe67a7/tmp -S=/home/user/Projects/yt-dlp-transcript-browser/umtool/song - -TUNE=${TUNE:?need TUNE} -PLAN=${PLAN:?need PLAN} -VOICES=${VOICES:?need VOICES} -COMPOSED=${COMPOSED:?need COMPOSED} -GAMEPLAY=${GAMEPLAY:?need GAMEPLAY} -OUT=${OUT:?need OUT} -OFFSET=${OFFSET:-0} -GIMMICKS=${GIMMICKS:-} - -SOLOS=() -for spec in $VOICES; do - v="${spec%%@*}" - p="${spec#*@}"; [ "$p" = "$spec" ] && p="$PLAN" - solo="solo-$TUNE-$v.json" - vid="$T/solo-$TUNE-$v.mp4" - node "$S/solo-voice.mjs" "$p" "$v" "$solo" - if [ -f "$vid" ]; then - echo " reusing $vid" - else - # BG_MAIN_SCALE=1.0 fills the frame -- the default 0.70 insets the panel to - # 896x504 and the box would carry a black border of its own. BG_DIM=0 because - # there is no background to dim; dimming black just makes the render slower. - PLAN="$solo" BG_VIDEO=$PWD/intro/black1280.mp4 LAYOUT=bg \ - BG_MAIN_SCALE=1.0 BG_DIM=0 OUT_END=0 \ - node render-poly.mjs "$vid" | tail -2 - fi - SOLOS+=("$vid") -done - -COMPOSED="$COMPOSED" GAMEPLAY="$GAMEPLAY" \ - PRIMARY="${SOLOS[0]}" SEC1="${SOLOS[1]:-}" SEC2="${SOLOS[2]:-}" \ - OFFSET="$OFFSET" GIMMICKS="$GIMMICKS" \ - node "$S/shorts-compose.mjs" "$OUT" - -echo "VSHORT_${TUNE}_DONE -> $OUT" -ffprobe -v error -show_entries format=duration:stream=width,height -of csv=p=0 "$OUT" | head -2 diff --git a/umtool/song/yoshi-rebuild.sh b/umtool/song/yoshi-rebuild.sh @@ -1,81 +0,0 @@ -#!/bin/sh -# Yoshi's Island athletic theme, rebuilt on the 3,959-clip palette. -# -# TIME_LIMIT 94.5 reproduces v6 exactly (melody 411 / bass 204 / arp 108). The -# length is the lever on this tune more than any other: at full length the melody -# degrades to p90 3.8-4.5 semitones, and raising poolFactor does NOTHING because -# the melody is claimed LAST and takes whatever the bass and arp leave. -# -# The vowel is a SCORED preference (tokenPenalty), not a filter. A hard filter -# took the melody from p90 1.59 to 4.47 at full length; scoring lets a much -# better pitch match still buy an "uh" while "um" wins every tie. v6 landed at -# 87% "um" that way. -set -e -cd /home/user/.claude/jobs/efbe67a7/tmp/song -D=/home/user/reports/quartering-uh-song -T=/home/user/.claude/jobs/efbe67a7/tmp - -TP=${TP:-6} -PLANF=${PLANF:-yoshi-plan-v7.json} -OUT=${OUT:-$D/quartering-yoshi-v7.mp4} -mkdir -p "$(dirname "$OUT")" - -export TEMPO_SCALE=1.05 TIME_LIMIT=${TL:-94.5} MAX_IOI=0.70 WINDOW=700 LOCAL=0 WOBBLE_W=0.08 -export VOICES='[{"name":"melody","lead":"lead2.json","gain":1.0,"role":"full","unique":true,"reuseGap":20,"contourMax":12,"fill":true,"tokens":["um"],"tokenPenalty":'"$TP"',"noKeeps":true},{"name":"bass","lead":"yoshi-bass.json","gain":0.68,"role":"pip1","unique":true,"reuseGap":12,"contourMax":4,"poolFactor":2.5,"fill":false,"noKeeps":true},{"name":"arp","lead":"yoshi-harm.json","gain":0.46,"role":"pip2","unique":true,"reuseGap":12,"contourMax":5,"poolFactor":2.5,"fill":false,"noKeeps":true}]' - -if [ "${REUSE_PLAN:-0}" != "1" ]; then - node arrange-poly.mjs - cp poly-plan.json "$PLANF" -fi - -# What the vowel preference actually bought, measured on the PLAN rather than -# assumed from the setting. -node -e " -const {readFileSync,readdirSync}=require('fs');const path=require('path'); -const S='$PWD';const cand=new Map(); -for(const f of readdirSync(path.join(S,'cand2')).filter(f=>f.endsWith('.json'))) - for(const c of JSON.parse(readFileSync(path.join(S,'cand2',f),'utf8')).candidates){ - const k=c.video+'@'+(+c.start).toFixed(2); if(!cand.has(k))cand.set(k,c);} -const W=JSON.parse(readFileSync(path.join(S,'words.json'),'utf8')); -const p=JSON.parse(readFileSync('$PLANF','utf8')); -for(const v of p.voices){const t={}; - for(const n of v.plan){const k=n.video+'@'+(+n.srcStart).toFixed(2); - const w=((W[k]&&W[k].word)||(cand.get(k)||{}).token||'?').toLowerCase().trim(); t[w]=(t[w]||0)+1;} - const tot=v.plan.length; - console.log(' vowel '+v.name.padEnd(7)+' '+Object.entries(t).sort((a,b)=>b[1]-a[1]) - .map(([k,n])=>k+' '+(100*n/tot).toFixed(0)+'%').join(' '));} -" -[ "${ARRANGE_ONLY:-0}" = "1" ] && exit 0 - -# TAIL_FADE is NOT set: this cut ends on the reprise, not on a loop point, so it -# has an ending of its own. VIDEO_TAIL_FADE follows TAIL_FADE, so the picture is -# left alone too. -# BG_VIDEO overridable like TP/PLANF/OUT/TL above, so a treated bed -- say one -# debox-bg.mjs has taken the game's own text-box freezes out of -- can be -# rendered against this exact arrangement without editing the recipe. -PLAN=$PLANF BG_VIDEO=${BG_VIDEO:-$PWD/intro/yoshi-bg.mp4} BG_FIT=pad LAYOUT=bg \ - node render-poly.mjs "$T/yoshi-${PLANF%.json}-body.mp4" | tail -3 - -LEAD=$(node -e " -const {voices}=require('$PWD/$PLANF'); -console.log(Math.max(0,Math.min(...voices.filter(v=>v.plan.length).map(v=>v.plan[0].slotStart))).toFixed(3));") -echo "trimming ${LEAD}s of lead-in off the body" -ffmpeg -nostdin -v error -y -ss "$LEAD" -i "$T/yoshi-${PLANF%.json}-body.mp4" \ - -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p -video_track_timescale 30000 \ - -c:a aac -b:a 192k -ar 48000 -ac 2 "$T/yoshi-${PLANF%.json}-bd.mp4" - -# Intro: yoshi-src.mp4 6.24 -> 10.05s. 6.24 is inside the FIRST FULLY BLACK -# frame -- the source is 60fps, and detecting black by MEAN luma finds the wrong -# frames because averaging hides a small lit region. -ffmpeg -nostdin -v error -y -ss 6.24 -to 10.05 -i intro/yoshi-src.mp4 \ - -vf "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30" \ - -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p -video_track_timescale 30000 \ - -c:a aac -b:a 192k -ar 48000 -ac 2 "$T/yoshi-${PLANF%.json}-intro.mp4" - -ffmpeg -nostdin -v error -y -i "$T/yoshi-${PLANF%.json}-intro.mp4" -i "$T/yoshi-${PLANF%.json}-bd.mp4" \ - -filter_complex "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]" -map "[v]" -map "[a]" \ - -c:v libx264 -preset medium -crf 21 -pix_fmt yuv420p -c:a aac -b:a 192k -ar 48000 -ac 2 \ - -movflags +faststart "$OUT" - -echo "YOSHI7_DONE -> $OUT" -ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1 "$OUT"