import {
AbsoluteFill,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from "remotion";
import { COLORS, NOTES_ITALIAN } from "../lib/constants";
const STAFF_TOP = 300;
const STAFF_GAP = 40;
const STAFF_LEFT = 200;
const NOTE_START_X = 380;
const NOTE_GAP = 150;
const StaffLine: React.FC<{ y: number; delay: number }> = ({ y, delay }) => {
const frame = useCurrentFrame();
const localFrame = frame - delay;
const width = interpolate(localFrame, [0, 30], [0, 1520], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
);
};
const NoteHead: React.FC<{
x: number;
lineOffset: number;
name: string;
index: number;
isPlaying: boolean;
isHigh: boolean;
}> = ({ x, lineOffset, name, index, isPlaying, isHigh }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const entryDelay = 50 + index * 18;
const noteSpring = spring({
frame: Math.max(0, frame - entryDelay),
fps,
config: { damping: 10, stiffness: 100, mass: 0.6 },
});
const y = STAFF_TOP + lineOffset * STAFF_GAP - 18;
const opacity = interpolate(noteSpring, [0, 1], [0, 1]);
const scale = interpolate(noteSpring, [0, 1], [0, 1]);
const noteY = interpolate(noteSpring, [0, 1], [-40, 0]);
const glowIntensity = isPlaying
? interpolate(
Math.sin(frame * 0.3),
[-1, 1],
[0.6, 1],
)
: 0;
// Stem direction based on position
const stemUp = lineOffset > 2;
// Ledger line for notes outside the staff
const needsLedgerLine = lineOffset >= 6;
return (
{/* Ledger line */}
{needsLedgerLine && (
)}
{/* Note glow */}
{isPlaying && (
)}
{/* Note head (ellipse) */}
{/* Stem */}
{/* Note name label */}
{name}
{isHigh && (
2
)}
);
};
const TrebleClef: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const clefSpring = spring({
frame: Math.max(0, frame - 25),
fps,
config: { damping: 12, stiffness: 60, mass: 1 },
});
const opacity = interpolate(clefSpring, [0, 1], [0, 0.9]);
const scale = interpolate(clefSpring, [0, 1], [0.3, 1]);
return (
{"\uD834\uDD1E"}
);
};
const Playhead: React.FC = () => {
const frame = useCurrentFrame();
const playStart = 70;
const playEnd = 210;
const progress = interpolate(frame, [playStart, playEnd], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const x = interpolate(progress, [0, 1], [NOTE_START_X - 30, NOTE_START_X + NOTE_GAP * 7 + 30]);
const opacity = interpolate(frame, [playStart - 10, playStart, playEnd, playEnd + 10], [0, 0.8, 0.8, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
);
};
export const StaffScene: React.FC = () => {
const frame = useCurrentFrame();
// Scene fade
const fadeIn = interpolate(frame, [0, 30], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const fadeOut = interpolate(frame, [240, 270], [1, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Section title
const titleOpacity = interpolate(frame, [10, 30], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const titleY = interpolate(frame, [10, 30], [20, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
// Calculate which note is "playing"
const playStart = 70;
const noteDuration = 18;
const activeNoteIndex = Math.floor(
(frame - playStart) / noteDuration
);
// Bottom scale visualization
const scaleBarCount = 24;
return (
{/* Section title */}
La Scala di{" "}
DO Maggiore
{/* Staff lines */}
{Array.from({ length: 5 }, (_, i) => (
))}
{/* Treble clef */}
{/* Playhead */}
{/* Notes */}
{NOTES_ITALIAN.map((note, i) => (
= playStart}
isHigh={i === 7}
/>
))}
{/* Bottom frequency bars */}
{Array.from({ length: scaleBarCount }, (_, i) => {
const barHeight = interpolate(
Math.sin(frame * 0.08 + i * 0.5),
[-1, 1],
[10, 60 + (activeNoteIndex >= 0 && activeNoteIndex < 8 ? 20 : 0)],
);
const barOpacity = interpolate(frame, [60, 80], [0, 0.5], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
);
})}
{/* Brace decoration */}
);
};