From 530238a3282f3b72f546501e8210bfe2e38481d8 Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Wed, 30 Sep 2026 01:29:15 +0900 Subject: [PATCH] feat(274): render at 60 fps, with all timing in seconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composition is now 60 fps (37,157 frames). Everything was already written against time in seconds except the spectrum's release, which counted frames and so would have shortened from 0.2 s to 0.1 s at 60 fps. - spectrum.ts: RELEASE_FRAMES -> RELEASE_WINDOW_S (0.2 s) sampled every RELEASE_STEP_S (1/30 s), RELEASE_DECAY per step. At 30, 60 and 120 fps this reads the same instants (0, 33.3, 66.7, 100, 133.3, 166.7 ms back) with the same weights, so the tail keeps its length and the work per frame stays constant instead of growing with the frame rate. - time.ts: secondsToFrames / framesToSeconds, the one place the two meet — only Remotion's API counts frames. - camera.ts: the smoothing window's hardcoded 1/30 is now FOLLOW_SAMPLE_S, a sampling interval in seconds rather than a frame count. - Composition.tsx: FPS 30 -> 60; the duration still follows the audio length. Changing FPS is now the only edit needed to try another frame rate. Verified: eslint + tsc clean; the 60 fps export reports 60/1 and 37,157 frames, with audio 0.00 ms off at 1:45 and 9:00. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u --- src/song-274/Composition.tsx | 2 +- src/song-274/camera.ts | 7 ++++++- src/song-274/spectrum.ts | 25 ++++++++++++++++++------- src/song-274/time.ts | 14 ++++++++++++++ 4 files changed, 39 insertions(+), 9 deletions(-) create mode 100644 src/song-274/time.ts diff --git a/src/song-274/Composition.tsx b/src/song-274/Composition.tsx index 373149c..4321ac7 100644 --- a/src/song-274/Composition.tsx +++ b/src/song-274/Composition.tsx @@ -4,7 +4,7 @@ import { loadTransectData } from "./data"; import { darkPalette } from "./palette"; import { Transect, type TransectProps } from "./Transect"; -const FPS = 30; +const FPS = 60; /** Length of song-274/audio/274_release2.wav (see BOM.md). */ const AUDIO_DURATION_S = 619.270083; diff --git a/src/song-274/camera.ts b/src/song-274/camera.ts index f56b9e3..d3bba09 100644 --- a/src/song-274/camera.ts +++ b/src/song-274/camera.ts @@ -27,6 +27,11 @@ export const ZOOM_IN_S = 3; * This is the standard deviation, in seconds; the window spans ±3 of these. */ export const FOLLOW_SMOOTH_S = 0.6; +/** + * How often that window is sampled, in seconds. Independent of fps: finer + * sampling costs more per frame and changes the average only slightly. + */ +export const FOLLOW_SAMPLE_S = 1 / 30; /** * An overview frames the covered road plus some road ahead of the head, so the * head does not sit against the frame edge: this fraction of the distance @@ -95,7 +100,7 @@ export const overviewWeight = (parts: Span[], t: number): number => { /** The head's position averaged over a Gaussian window around `t`. */ export const smoothedFocus = (focusAt: (t: number) => Point, t: number): Point => { - const step = 1 / 30; + const step = FOLLOW_SAMPLE_S; const reach = 3 * FOLLOW_SMOOTH_S; let sumX = 0; let sumY = 0; diff --git a/src/song-274/spectrum.ts b/src/song-274/spectrum.ts index bee856a..d8fa918 100644 --- a/src/song-274/spectrum.ts +++ b/src/song-274/spectrum.ts @@ -1,5 +1,6 @@ import { visualizeAudio, type MediaUtilsAudioData } from "@remotion/media-utils"; import { random } from "remotion"; +import { secondsToFrames } from "./time"; /** * The outro spectrum: from SPECTRUM_START_S the nodes stop following the lyric @@ -43,11 +44,16 @@ export const TREBLE_TILT = 0.5; export const CURVE = 0.6; /** - * Release: a node's level is the loudest of its last RELEASE_FRAMES frames, each - * frame back multiplied by RELEASE_DECAY once more. Hits jump up at once and - * fall back smoothly, without any state carried between frames. + * Release: a node's level is the loudest reading over the RELEASE_WINDOW_S + * before it, sampled every RELEASE_STEP_S and multiplied by RELEASE_DECAY once + * more per step back. Hits jump up at once and fall back smoothly, without any + * state carried between frames. + * + * Sampling by time rather than by frame keeps the tail the same length, and the + * work per frame the same, whatever the fps. */ -export const RELEASE_FRAMES = 6; +export const RELEASE_WINDOW_S = 0.2; +export const RELEASE_STEP_S = 1 / 30; export const RELEASE_DECAY = 0.75; /** FFT size for visualizeAudio: bins are (sample rate / 2) / FFT_SAMPLES wide (23.4 Hz). */ @@ -69,7 +75,7 @@ export const spectrumWeight = (t: number): number => * otherwise the spectrum's start, so nothing is fetched for the rest of the song. */ export const audioWindowFrame = (frame: number, fps: number): number => - Math.max(frame, Math.floor((SPECTRUM_START_S - SPECTRUM_FADE_S) * fps)); + Math.max(frame, secondsToFrames(SPECTRUM_START_S - SPECTRUM_FADE_S, fps)); /** `count` log-spaced band centres from FREQ_LOW_HZ to FREQ_HIGH_HZ. */ export const bandFrequencies = (count: number): number[] => @@ -131,7 +137,12 @@ export const bandLevels = (args: { frequencies.length > 1 ? Math.pow(FREQ_HIGH_HZ / FREQ_LOW_HZ, 1 / (frequencies.length - 1)) : 2; const levels = frequencies.map(() => 0); - for (let back = 0; back < RELEASE_FRAMES && frame - back >= 0; back++) { + const steps = Math.max(1, Math.round(RELEASE_WINDOW_S / RELEASE_STEP_S)); + for (let step = 0; step < steps; step++) { + const back = secondsToFrames(step * RELEASE_STEP_S, fps); + if (frame - back < 0) { + break; + } const spectrum = visualizeAudio({ audioData, frame: frame - back, @@ -140,7 +151,7 @@ export const bandLevels = (args: { dataOffsetInSeconds, optimizeFor: "accuracy", }); - const decay = Math.pow(RELEASE_DECAY, back); + const decay = Math.pow(RELEASE_DECAY, step); frequencies.forEach((hz, i) => { const raw = bandValue(spectrum, hz, ratio, binHz); const level = diff --git a/src/song-274/time.ts b/src/song-274/time.ts new file mode 100644 index 0000000..815e514 --- /dev/null +++ b/src/song-274/time.ts @@ -0,0 +1,14 @@ +/** + * Frame-rate conversions. + * + * Every timing constant in this composition is in seconds, so the animation is + * identical at any fps. Only Remotion's own API counts frames, so convert here + * rather than writing frame counts into the constants. + */ + +/** Seconds to a whole number of frames, rounded to the nearest. */ +export const secondsToFrames = (seconds: number, fps: number): number => + Math.round(seconds * fps); + +/** A frame number back to its time in seconds. */ +export const framesToSeconds = (frames: number, fps: number): number => frames / fps;