Files
music-visualizer/src/remotion/animation.ts
T
blumia a237a7c604 Add one-line score visualizer with 3D orb and MP4 export
- Verovio single-system engraving, MuseScore audio/MIDI from the same source
- React + Remotion player with live camera, colour and orb controls
- Express pipeline, render queue and local H.264/AAC export
- MIT license plus third-party notices for the reference skill
2026-11-05 11:38:25 +08:00

149 lines
4.6 KiB
TypeScript

import type {NoteEvent} from '../shared/types';
export const BALL_ENTRY_SECONDS = 0.18;
/** Resting height above the paper, in score units: just enough to clear the ink. */
export const REST_DEPTH = 1.6;
/**
* Amplifies only the part of the height that comes from a hop, so the ball
* still rests just above the ink and 0 flattens every jump onto the sheet.
*/
export function liftDepth(depth: number, lift: number): number {
return REST_DEPTH + (depth - REST_DEPTH) * Math.max(0, lift);
}
export type Motion = {
position: [number, number];
/** Height above the paper along its normal, in score units. */
depth: number;
pulse: number;
landed: boolean;
opacity: number;
from: NoteEvent;
to: NoteEvent;
u: number;
trailOpacity: number;
departing: boolean;
};
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
export function groupLandings(notes: NoteEvent[], staff: number): NoteEvent[] {
const groups: NoteEvent[][] = [];
for (const note of notes
.filter((n) => n.staff === staff)
.sort((a, b) => a.time - b.time)) {
const last = groups[groups.length - 1];
if (last && Math.abs(last[0]!.time - note.time) < 1e-6) last.push(note);
else groups.push([note]);
}
const preferHigh = staff % 2 === 1;
return groups.map(
(group) =>
[...group].sort(
(a, b) =>
(a.kind === 'attack' ? 0 : 1) - (b.kind === 'attack' ? 0 : 1) ||
(preferHigh ? b.pitch - a.pitch : a.pitch - b.pitch),
)[0]!,
);
}
/**
* Hop from note `a` to note `b`. X/Y travel across the sheet in a straight line
* while Z arcs above it, so the ball lands back on the paper at every note.
*/
export function arcPoint(
a: NoteEvent,
b: NoteEvent,
u: number,
): [number, number, number] {
const phase = clamp(u, 0, 1);
const distance = Math.hypot(b.x - a.x, b.y - a.y);
const interval = Math.max(0, b.time - a.time);
// Big enough that a hop still reads on a nearly face-on sheet, where a
// movement along the paper normal mostly points at the camera.
const hop =
(22 + Math.min(22, distance * 0.15)) * Math.min(1, Math.max(0.25, interval / 0.24));
return [
a.x + (b.x - a.x) * phase,
a.y + (b.y - a.y) * phase,
REST_DEPTH + 4 * phase * (1 - phase) * hop,
];
}
export function performanceAt(targets: NoteEvent[], t: number): Motion | null {
if (!targets.length) return null;
let i = 0;
while (i + 1 < targets.length && targets[i + 1]!.time <= t) i++;
const current = targets[i]!;
const next = targets[i + 1];
const age = t - current.time;
const landed = age >= 0;
const resting = Boolean(next && next.time - current.time - current.duration > 0.65);
const enteringNext = Boolean(resting && next && t >= next.time - BALL_ENTRY_SECONDS);
const entryTarget = enteringNext ? next! : current;
const entryPhase = clamp((t - entryTarget.time + BALL_ENTRY_SECONDS) / BALL_ENTRY_SECONDS, 0, 1);
const departing = Boolean(next && landed && !resting);
const u = departing ? clamp(age / Math.max(1e-6, next!.time - current.time), 0, 1) : 1;
const [ballX, ballY, ballZ] = departing
? arcPoint(current, next!, u)
: arcPoint(entryTarget, entryTarget, 1);
const position: [number, number] = [ballX, ballY];
let opacity = 1;
if (!landed || enteringNext) {
opacity = Math.min(1, entryPhase * 2.5);
} else if (resting || !next) {
opacity = clamp((current.time + current.duration + 0.18 - t) / 0.18, 0, 1);
}
const pulse = landed && !enteringNext ? Math.exp(-Math.max(0, age) / 0.1) : 0;
const from = departing ? current : targets[Math.max(0, i - 1)] ?? current;
const to = departing ? next! : current;
const trailOpacity = departing
? 0.68
: from.scoreId !== to.scoreId && i > 0
? 0.48 * Math.max(0, 1 - age / 0.3)
: 0;
return {
position,
depth: ballZ,
pulse,
landed,
opacity,
from,
to,
u,
trailOpacity,
departing,
};
}
function lowerBound(notes: NoteEvent[], time: number): number {
let low = 0;
let high = notes.length;
while (low < high) {
const mid = (low + high) >> 1;
if (notes[mid]!.time < time) low = mid + 1;
else high = mid;
}
return low;
}
/** Latest ring per scoreId within the 3.6s window, matching the original behavior. */
export function activeRingsAt(notes: NoteEvent[], t: number): NoteEvent[] {
const start = lowerBound(notes, t - 3.6);
const latest = new Map<string, NoteEvent>();
for (let i = start; i < notes.length; i++) {
const note = notes[i]!;
if (note.time > t) break;
latest.set(note.scoreId, note);
}
return [...latest.values()];
}
export function staffColorIndex(staff: number): number {
return Math.max(0, staff - 1);
}