3 Commits

Author SHA1 Message Date
mikkeli e3c843d65c feat(274): name the モトツ pair with a slash in the transect subtitles
モトツ橋にモトツシェルター -> モトツ橋 / モトツシェルター, matching the two nodes the
visualiser lights side by side on the road. The rest of the diff is Aegisub's
saved cursor position.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-30 01:29:15 +09:00
mikkeli 530238a328 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
2026-09-30 01:29:15 +09:00
mikkeli d7b3824e1e fix(274): end the fade-out where the black hold starts
FADE_OUT_POST holds full black over the last stretch of the video, but the ramp
still aimed to finish at lastFrameS - FADE_OUT_S, so it completed inside the
hold and the two overlapped. The ramp now runs from
lastFrameS - FADE_OUT_POST - FADE_OUT_S to lastFrameS - FADE_OUT_POST, giving
FADE_OUT_S of fade followed by FADE_OUT_POST of black.

Verified: with 0.4 / 0.3 / 0.2 / 0.4 the picture is clear from 0.70 s to
618.67 s, and the rendered file is pure black for the first and last 13 frames
at 30 fps (25 at 60).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PLt25nCVMsoroKALYUBu6u
2026-09-30 01:29:13 +09:00
6 changed files with 48 additions and 14 deletions
+4 -4
View File
@@ -15,9 +15,9 @@ Video File: 274_transect.mp4
Video AR Mode: 4
Video AR Value: 1.777778
Video Zoom Percent: 0.500000
Scroll Position: 107
Active Line: 124
Video Position: 18574
Scroll Position: 74
Active Line: 82
Video Position: 8587
[V4+ Styles]
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
@@ -123,7 +123,7 @@ Dialogue: 0,0:04:38.70,0:04:41.02,274-place,,0,0,0,,福山大橋
Dialogue: 0,0:04:41.02,0:04:43.29,274-place,,0,0,0,,千坂橋
Dialogue: 0,0:04:43.29,0:04:45.12,274-place,,0,0,0,,オコタン橋
Dialogue: 0,0:04:45.12,0:04:46.22,274-place,,0,0,0,,モトツトンネル
Dialogue: 0,0:04:46.22,0:04:48.98,274-place,,0,0,0,,モトツ橋にモトツシェルター
Dialogue: 0,0:04:46.22,0:04:48.98,274-place,,0,0,0,,モトツ橋 / モトツシェルター
Dialogue: 0,0:04:48.98,0:04:51.39,274-place,,0,0,0,,福山トンネル
Dialogue: 0,0:04:51.39,0:04:53.61,274-place,,0,0,0,,福穂橋
Dialogue: 0,0:04:53.61,0:04:55.52,274-place,,0,0,0,,穂高トンネル
+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;
+5 -1
View File
@@ -5,6 +5,8 @@
/** Seconds of complete black at start */
export const FADE_IN_PRE = 0.4;
/** Seconds of complete black at end */
export const FADE_OUT_POST = 0.4;
/** Seconds from black to the picture at the start. 0 disables the fade-in. */
export const FADE_IN_S = 0.3;
/** Seconds from the picture to black at the end. 0 disables the fade-out. */
@@ -19,6 +21,8 @@ const clamp01 = (v: number) => Math.min(1, Math.max(0, v));
*/
export const blackOpacity = (t: number, lastFrameS: number): number => {
const fadeIn = (t < FADE_IN_PRE) ? 1 : (FADE_IN_S > 0 ? 1 - clamp01((t - FADE_IN_PRE) / FADE_IN_S) : 0);
const fadeOut = FADE_OUT_S > 0 ? clamp01((t - (lastFrameS - FADE_OUT_S)) / FADE_OUT_S) : 0;
// Black from here to the last frame; the fade-out ramp ends exactly here.
const blackFromS = lastFrameS - FADE_OUT_POST;
const fadeOut = (t >= blackFromS) ? 1 : (FADE_OUT_S > 0 ? clamp01((t - (blackFromS - FADE_OUT_S)) / FADE_OUT_S) : 0);
return Math.max(fadeIn, fadeOut);
};
+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;