feat(274): render at 60 fps, with all timing in seconds

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
This commit is contained in:
2026-09-30 01:29:15 +09:00
parent d7b3824e1e
commit 530238a328
4 changed files with 39 additions and 9 deletions
+1 -1
View File
@@ -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;
+6 -1
View File
@@ -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;
+18 -7
View File
@@ -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 =
+14
View File
@@ -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;