Initial commit: InTono — app KMP per l'apprendimento musicale
App Kotlin Multiplatform (Android/iOS/Desktop) con Compose Multiplatform: controllo intonazione (YIN), pratica guidata, metronomo, ear training, sfide, scansione spartiti via AI, spartiti bundled e pentagramma con glifi tipografici, travature, battute e spaziatura proporzionale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
21ad969b10
245 files changed
+28963
No files matched your search
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
Audio,
|
||||
interpolate,
|
||||
Sequence,
|
||||
staticFile,
|
||||
useCurrentFrame,
|
||||
} from "remotion";
|
||||
import { COLORS, SCENE_TIMING } from "./lib/constants";
|
||||
import { IntroScene } from "./scenes/IntroScene";
|
||||
import { StaffScene } from "./scenes/StaffScene";
|
||||
import { FeaturesScene } from "./scenes/FeaturesScene";
|
||||
import { OutroScene } from "./scenes/OutroScene";
|
||||
|
||||
const AnimatedBackground: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
// Slow gradient rotation
|
||||
const angle = interpolate(frame, [0, 900], [0, 60]);
|
||||
|
||||
// Subtle purple nebula blobs
|
||||
const blob1X = 30 + Math.sin(frame * 0.008) * 10;
|
||||
const blob1Y = 30 + Math.cos(frame * 0.006) * 8;
|
||||
const blob2X = 70 + Math.sin(frame * 0.01 + 2) * 12;
|
||||
const blob2Y = 60 + Math.cos(frame * 0.007 + 1) * 10;
|
||||
|
||||
return (
|
||||
<AbsoluteFill>
|
||||
{/* Base gradient */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: `linear-gradient(${angle}deg, ${COLORS.bgDark} 0%, ${COLORS.bgPurple} 40%, ${COLORS.bgIndigo} 70%, ${COLORS.bgDark} 100%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Nebula blob 1 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${blob1X}%`,
|
||||
top: `${blob1Y}%`,
|
||||
width: 600,
|
||||
height: 600,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(circle, rgba(106,55,185,0.15), transparent 70%)`,
|
||||
filter: "blur(80px)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Nebula blob 2 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${blob2X}%`,
|
||||
top: `${blob2Y}%`,
|
||||
width: 500,
|
||||
height: 500,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(circle, rgba(255,215,0,0.06), transparent 70%)`,
|
||||
filter: "blur(60px)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Subtle vignette */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background:
|
||||
"radial-gradient(ellipse at center, transparent 40%, rgba(0,0,0,0.5) 100%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Subtle grain overlay */}
|
||||
<svg
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
opacity: 0.03,
|
||||
}}
|
||||
>
|
||||
<filter id="grain">
|
||||
<feTurbulence
|
||||
type="fractalNoise"
|
||||
baseFrequency="0.9"
|
||||
numOctaves="4"
|
||||
stitchTiles="stitch"
|
||||
/>
|
||||
</filter>
|
||||
<rect width="100%" height="100%" filter="url(#grain)" />
|
||||
</svg>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
|
||||
export const MyComposition: React.FC = () => {
|
||||
return (
|
||||
<AbsoluteFill style={{ backgroundColor: COLORS.bgDark }}>
|
||||
{/* Background music */}
|
||||
<Audio src={staticFile("music.wav")} volume={0.8} />
|
||||
|
||||
{/* Animated background - always present */}
|
||||
<AnimatedBackground />
|
||||
|
||||
{/* Scene 1: Intro (frames 0-210) */}
|
||||
<Sequence from={SCENE_TIMING.intro.start} durationInFrames={SCENE_TIMING.intro.end}>
|
||||
<IntroScene />
|
||||
</Sequence>
|
||||
|
||||
{/* Scene 2: Musical Staff (frames 180-450) */}
|
||||
<Sequence from={SCENE_TIMING.staff.start} durationInFrames={SCENE_TIMING.staff.end - SCENE_TIMING.staff.start}>
|
||||
<StaffScene />
|
||||
</Sequence>
|
||||
|
||||
{/* Scene 3: Features Showcase (frames 420-720) */}
|
||||
<Sequence from={SCENE_TIMING.features.start} durationInFrames={SCENE_TIMING.features.end - SCENE_TIMING.features.start}>
|
||||
<FeaturesScene />
|
||||
</Sequence>
|
||||
|
||||
{/* Scene 4: Outro (frames 690-900) */}
|
||||
<Sequence from={SCENE_TIMING.outro.start} durationInFrames={SCENE_TIMING.outro.end - SCENE_TIMING.outro.start}>
|
||||
<OutroScene />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import "./index.css";
|
||||
import { Composition } from "remotion";
|
||||
import { MyComposition } from "./Composition";
|
||||
import { VIDEO_CONFIG } from "./lib/constants";
|
||||
|
||||
export const RemotionRoot: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<Composition
|
||||
id="NoteMusicali"
|
||||
component={MyComposition}
|
||||
durationInFrames={VIDEO_CONFIG.durationInFrames}
|
||||
fps={VIDEO_CONFIG.fps}
|
||||
width={VIDEO_CONFIG.width}
|
||||
height={VIDEO_CONFIG.height}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
@import "tailwindcss";
|
||||
@@ -0,0 +1,4 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "./Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
@@ -0,0 +1,84 @@
|
||||
export const COLORS = {
|
||||
bgDark: "#0a0118",
|
||||
bgPurple: "#1a0533",
|
||||
bgIndigo: "#2d1b69",
|
||||
accent: "#FFD700",
|
||||
accentWarm: "#FFA500",
|
||||
accentRose: "#FF6B9D",
|
||||
text: "#FFFFFF",
|
||||
textMuted: "rgba(255,255,255,0.6)",
|
||||
staffLine: "rgba(255,255,255,0.35)",
|
||||
cardBg: "rgba(255,255,255,0.06)",
|
||||
cardBorder: "rgba(255,255,255,0.12)",
|
||||
noteGlow: "rgba(255,215,0,0.4)",
|
||||
};
|
||||
|
||||
export const NOTES_ITALIAN = [
|
||||
{ name: "DO", offset: 6 },
|
||||
{ name: "RE", offset: 5 },
|
||||
{ name: "MI", offset: 4 },
|
||||
{ name: "FA", offset: 3.5 },
|
||||
{ name: "SOL", offset: 2.5 },
|
||||
{ name: "LA", offset: 1.5 },
|
||||
{ name: "SI", offset: 1 },
|
||||
{ name: "DO", offset: 0.5 },
|
||||
] as const;
|
||||
|
||||
export const MUSIC_SYMBOLS = [
|
||||
"\u266A",
|
||||
"\u266B",
|
||||
"\u266C",
|
||||
"\u2669",
|
||||
"\u{1D160}",
|
||||
"\u{1D15E}",
|
||||
];
|
||||
|
||||
export const FEATURES = [
|
||||
{
|
||||
icon: "\uD83C\uDFAF",
|
||||
title: "Accordatore",
|
||||
desc: "Rileva l'intonazione in tempo reale",
|
||||
color: "#4ECDC4",
|
||||
},
|
||||
{
|
||||
icon: "\uD83C\uDFB5",
|
||||
title: "Pratica Guidata",
|
||||
desc: "Segui le sequenze di note passo dopo passo",
|
||||
color: "#FFD93D",
|
||||
},
|
||||
{
|
||||
icon: "\uD83D\uDCC4",
|
||||
title: "Spartiti",
|
||||
desc: "Libreria con oltre 190 brani classici",
|
||||
color: "#6C5CE7",
|
||||
},
|
||||
{
|
||||
icon: "\uD83D\uDCF8",
|
||||
title: "Scansione AI",
|
||||
desc: "Riconosci spartiti con intelligenza artificiale",
|
||||
color: "#FF6B9D",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const INSTRUMENTS = [
|
||||
{ name: "Violino", emoji: "\uD83C\uDFBB", range: "G3-E7" },
|
||||
{ name: "Viola", emoji: "\uD83C\uDFBB", range: "C3-A6" },
|
||||
{ name: "Violoncello", emoji: "\uD83C\uDFBB", range: "C2-C6" },
|
||||
{ name: "Pianoforte", emoji: "\uD83C\uDFB9", range: "A0-C8" },
|
||||
{ name: "Chitarra", emoji: "\uD83C\uDFB8", range: "E2-E6" },
|
||||
{ name: "Voce", emoji: "\uD83C\uDFA4", range: "D2-D6" },
|
||||
] as const;
|
||||
|
||||
export const SCENE_TIMING = {
|
||||
intro: { start: 0, end: 210 },
|
||||
staff: { start: 180, end: 450 },
|
||||
features: { start: 420, end: 720 },
|
||||
outro: { start: 690, end: 900 },
|
||||
} as const;
|
||||
|
||||
export const VIDEO_CONFIG = {
|
||||
durationInFrames: 900,
|
||||
fps: 30,
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
} as const;
|
||||
@@ -0,0 +1,338 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
import { COLORS, FEATURES, INSTRUMENTS } from "../lib/constants";
|
||||
|
||||
const FeatureCard: React.FC<{
|
||||
icon: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
color: string;
|
||||
index: number;
|
||||
totalCards: number;
|
||||
}> = ({ icon, title, desc, color, index, totalCards }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width } = useVideoConfig();
|
||||
|
||||
const entryDelay = 40 + index * 20;
|
||||
|
||||
const cardSpring = spring({
|
||||
frame: Math.max(0, frame - entryDelay),
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 80, mass: 0.8 },
|
||||
});
|
||||
|
||||
const opacity = interpolate(cardSpring, [0, 1], [0, 1]);
|
||||
const translateY = interpolate(cardSpring, [0, 1], [80, 0]);
|
||||
const scale = interpolate(cardSpring, [0, 1], [0.8, 1]);
|
||||
|
||||
const cardWidth = 380;
|
||||
const gap = 30;
|
||||
const totalWidth = totalCards * cardWidth + (totalCards - 1) * gap;
|
||||
const startX = (width - totalWidth) / 2;
|
||||
const x = startX + index * (cardWidth + gap);
|
||||
|
||||
// Subtle floating animation
|
||||
const floatY = Math.sin(frame * 0.04 + index * 1.5) * 5;
|
||||
|
||||
// Hover glow
|
||||
const glowPulse = interpolate(
|
||||
Math.sin(frame * 0.06 + index),
|
||||
[-1, 1],
|
||||
[0.3, 0.6],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: x,
|
||||
top: 340 + translateY + floatY,
|
||||
width: cardWidth,
|
||||
opacity,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
{/* Card background */}
|
||||
<div
|
||||
style={{
|
||||
background: COLORS.cardBg,
|
||||
backdropFilter: "blur(20px)",
|
||||
border: `1px solid ${COLORS.cardBorder}`,
|
||||
borderRadius: 24,
|
||||
padding: "50px 36px",
|
||||
textAlign: "center",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Top accent line */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: "10%",
|
||||
right: "10%",
|
||||
height: 3,
|
||||
background: `linear-gradient(90deg, transparent, ${color}, transparent)`,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Glow effect */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: -50,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(circle, ${color}15, transparent)`,
|
||||
opacity: glowPulse,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Icon */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 64,
|
||||
marginBottom: 20,
|
||||
filter: `drop-shadow(0 0 15px ${color}50)`,
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 30,
|
||||
fontWeight: 700,
|
||||
color: COLORS.text,
|
||||
marginBottom: 12,
|
||||
fontFamily:
|
||||
'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: COLORS.textMuted,
|
||||
lineHeight: 1.5,
|
||||
fontFamily:
|
||||
'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
||||
}}
|
||||
>
|
||||
{desc}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const InstrumentBadge: React.FC<{
|
||||
name: string;
|
||||
emoji: string;
|
||||
index: number;
|
||||
total: number;
|
||||
}> = ({ name, emoji, index, total }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width } = useVideoConfig();
|
||||
|
||||
const entryDelay = 160 + index * 12;
|
||||
|
||||
const badgeSpring = spring({
|
||||
frame: Math.max(0, frame - entryDelay),
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 100, mass: 0.5 },
|
||||
});
|
||||
|
||||
const opacity = interpolate(badgeSpring, [0, 1], [0, 1]);
|
||||
const scale = interpolate(badgeSpring, [0, 1], [0.5, 1]);
|
||||
|
||||
const gap = 20;
|
||||
const badgeWidth = 200;
|
||||
const totalWidth = total * badgeWidth + (total - 1) * gap;
|
||||
const startX = (width - totalWidth) / 2;
|
||||
const x = startX + index * (badgeWidth + gap);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: x,
|
||||
top: 780,
|
||||
width: badgeWidth,
|
||||
opacity,
|
||||
transform: `scale(${scale})`,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
border: `1px solid rgba(255,255,255,0.1)`,
|
||||
borderRadius: 16,
|
||||
padding: "16px 12px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 28 }}>{emoji}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 18,
|
||||
color: COLORS.text,
|
||||
fontWeight: 500,
|
||||
fontFamily:
|
||||
'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FeaturesScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
// Scene fade
|
||||
const fadeIn = interpolate(frame, [0, 30], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const fadeOut = interpolate(frame, [270, 300], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Title
|
||||
const titleOpacity = interpolate(frame, [10, 35], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const titleY = interpolate(frame, [10, 35], [30, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Instruments section title
|
||||
const instrTitleOpacity = interpolate(frame, [140, 165], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ opacity: fadeIn * fadeOut }}>
|
||||
{/* Section title */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: 140,
|
||||
transform: `translate(-50%, ${titleY}px)`,
|
||||
opacity: titleOpacity,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 52,
|
||||
fontWeight: 700,
|
||||
color: COLORS.text,
|
||||
fontFamily:
|
||||
'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
||||
}}
|
||||
>
|
||||
Tutto ciò di cui hai bisogno
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 22,
|
||||
color: COLORS.textMuted,
|
||||
marginTop: 10,
|
||||
fontFamily:
|
||||
'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
||||
}}
|
||||
>
|
||||
Strumenti professionali per ogni musicista
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative line under title */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: 260,
|
||||
transform: "translateX(-50%)",
|
||||
width: interpolate(frame, [30, 60], [0, 120], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
}),
|
||||
height: 3,
|
||||
background: `linear-gradient(90deg, transparent, ${COLORS.accent}, transparent)`,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Feature cards */}
|
||||
{FEATURES.map((feature, i) => (
|
||||
<FeatureCard
|
||||
key={i}
|
||||
icon={feature.icon}
|
||||
title={feature.title}
|
||||
desc={feature.desc}
|
||||
color={feature.color}
|
||||
index={i}
|
||||
totalCards={FEATURES.length}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Instruments section title */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: 720,
|
||||
transform: "translateX(-50%)",
|
||||
opacity: instrTitleOpacity,
|
||||
fontSize: 28,
|
||||
fontWeight: 600,
|
||||
color: COLORS.textMuted,
|
||||
letterSpacing: 4,
|
||||
textTransform: "uppercase",
|
||||
fontFamily:
|
||||
'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
||||
}}
|
||||
>
|
||||
Strumenti Supportati
|
||||
</div>
|
||||
|
||||
{/* Instrument badges */}
|
||||
{INSTRUMENTS.map((instr, i) => (
|
||||
<InstrumentBadge
|
||||
key={i}
|
||||
name={instr.name}
|
||||
emoji={instr.emoji}
|
||||
index={i}
|
||||
total={INSTRUMENTS.length}
|
||||
/>
|
||||
))}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,277 @@
|
||||
import { AbsoluteFill, interpolate, random, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { COLORS, MUSIC_SYMBOLS } from "../lib/constants";
|
||||
|
||||
const FloatingNote: React.FC<{
|
||||
symbol: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
size: number;
|
||||
delay: number;
|
||||
speed: number;
|
||||
drift: number;
|
||||
}> = ({ symbol, startX, startY, size, delay, speed, drift }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const progress = interpolate(frame - delay, [0, 180 / speed], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
const y = interpolate(progress, [0, 1], [startY, startY - 600]);
|
||||
const x = startX + Math.sin(progress * Math.PI * 2 * drift) * 40;
|
||||
const opacity = interpolate(
|
||||
progress,
|
||||
[0, 0.1, 0.7, 1],
|
||||
[0, 0.6, 0.6, 0],
|
||||
);
|
||||
const rotation = interpolate(progress, [0, 1], [0, drift * 30]);
|
||||
|
||||
const scale = spring({
|
||||
frame: Math.max(0, frame - delay),
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 80, mass: 0.8 },
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: x,
|
||||
top: y,
|
||||
fontSize: size,
|
||||
opacity,
|
||||
transform: `scale(${scale}) rotate(${rotation}deg)`,
|
||||
color: COLORS.accent,
|
||||
textShadow: `0 0 20px ${COLORS.noteGlow}, 0 0 40px ${COLORS.noteGlow}`,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{symbol}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Particle: React.FC<{
|
||||
x: number;
|
||||
y: number;
|
||||
delay: number;
|
||||
size: number;
|
||||
}> = ({ x, y, delay, size }) => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
const progress = interpolate(frame - delay, [0, 120], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
const opacity = interpolate(progress, [0, 0.3, 0.7, 1], [0, 0.8, 0.8, 0]);
|
||||
const scale = interpolate(progress, [0, 0.5, 1], [0.5, 1.2, 0.5]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: x,
|
||||
top: y + Math.sin(frame * 0.05 + delay) * 10,
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(circle, ${COLORS.accent}, transparent)`,
|
||||
opacity,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const IntroScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps, width, height } = useVideoConfig();
|
||||
|
||||
// Title animation
|
||||
const titleSpring = spring({
|
||||
frame: Math.max(0, frame - 40),
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 60, mass: 1 },
|
||||
});
|
||||
|
||||
const titleOpacity = interpolate(titleSpring, [0, 1], [0, 1]);
|
||||
const titleScale = interpolate(titleSpring, [0, 1], [0.3, 1]);
|
||||
const titleY = interpolate(titleSpring, [0, 1], [60, 0]);
|
||||
|
||||
// Subtitle animation
|
||||
const subtitleSpring = spring({
|
||||
frame: Math.max(0, frame - 70),
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 50, mass: 1 },
|
||||
});
|
||||
|
||||
const subtitleOpacity = interpolate(subtitleSpring, [0, 1], [0, 1]);
|
||||
const subtitleY = interpolate(subtitleSpring, [0, 1], [30, 0]);
|
||||
|
||||
// Treble clef animation
|
||||
const clefSpring = spring({
|
||||
frame: Math.max(0, frame - 20),
|
||||
fps,
|
||||
config: { damping: 18, stiffness: 40, mass: 1.2 },
|
||||
});
|
||||
|
||||
const clefOpacity = interpolate(clefSpring, [0, 1], [0, 0.15]);
|
||||
const clefScale = interpolate(clefSpring, [0, 1], [0.5, 1]);
|
||||
const clefRotation = interpolate(clefSpring, [0, 1], [-15, 0]);
|
||||
|
||||
// Scene fade out
|
||||
const fadeOut = interpolate(frame, [170, 210], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Generate floating notes
|
||||
const floatingNotes = Array.from({ length: 18 }, (_, i) => ({
|
||||
symbol: MUSIC_SYMBOLS[i % MUSIC_SYMBOLS.length],
|
||||
startX: (width / 18) * i + random(`note-${i}`) * 80 - 40,
|
||||
startY: height + 50 + (i % 3) * 80,
|
||||
size: 28 + (i % 4) * 12,
|
||||
delay: i * 8,
|
||||
speed: 0.8 + (i % 3) * 0.3,
|
||||
drift: 0.5 + (i % 4) * 0.4,
|
||||
}));
|
||||
|
||||
// Generate particles
|
||||
const particles = Array.from({ length: 30 }, (_, i) => ({
|
||||
x: Math.sin(i * 2.39) * width * 0.45 + width / 2,
|
||||
y: Math.cos(i * 1.73) * height * 0.4 + height / 2,
|
||||
delay: i * 4,
|
||||
size: 2 + (i % 4) * 2,
|
||||
}));
|
||||
|
||||
// Pulsing ring
|
||||
const ringProgress = interpolate(frame, [60, 180], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const ringScale = interpolate(ringProgress, [0, 1], [0.5, 2.5]);
|
||||
const ringOpacity = interpolate(ringProgress, [0, 0.3, 1], [0, 0.3, 0]);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ opacity: fadeOut }}>
|
||||
{/* Large treble clef background */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: width * 0.15,
|
||||
top: "50%",
|
||||
transform: `translateY(-50%) scale(${clefScale}) rotate(${clefRotation}deg)`,
|
||||
fontSize: 500,
|
||||
opacity: clefOpacity,
|
||||
color: COLORS.accent,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{"\uD834\uDD1E"}
|
||||
</div>
|
||||
|
||||
{/* Pulsing ring */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "50%",
|
||||
width: 300,
|
||||
height: 300,
|
||||
marginLeft: -150,
|
||||
marginTop: -150,
|
||||
borderRadius: "50%",
|
||||
border: `2px solid ${COLORS.accent}`,
|
||||
opacity: ringOpacity,
|
||||
transform: `scale(${ringScale})`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Floating notes */}
|
||||
{floatingNotes.map((note, i) => (
|
||||
<FloatingNote key={i} {...note} />
|
||||
))}
|
||||
|
||||
{/* Particles */}
|
||||
{particles.map((p, i) => (
|
||||
<Particle key={`p-${i}`} {...p} />
|
||||
))}
|
||||
|
||||
{/* Title */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "42%",
|
||||
transform: `translate(-50%, -50%) scale(${titleScale}) translateY(${titleY}px)`,
|
||||
opacity: titleOpacity,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 140,
|
||||
fontWeight: 800,
|
||||
color: COLORS.text,
|
||||
letterSpacing: -3,
|
||||
textShadow: `0 0 60px ${COLORS.noteGlow}, 0 0 120px rgba(255,215,0,0.2)`,
|
||||
fontFamily:
|
||||
'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
||||
}}
|
||||
>
|
||||
In
|
||||
<span style={{ color: COLORS.accent }}>Tono</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "56%",
|
||||
transform: `translate(-50%, -50%) translateY(${subtitleY}px)`,
|
||||
opacity: subtitleOpacity,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 36,
|
||||
fontWeight: 300,
|
||||
color: COLORS.textMuted,
|
||||
letterSpacing: 8,
|
||||
textTransform: "uppercase",
|
||||
fontFamily:
|
||||
'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
||||
}}
|
||||
>
|
||||
La tua guida musicale
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom decorative line */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 120,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: interpolate(frame, [80, 130], [0, 300], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
}),
|
||||
height: 2,
|
||||
background: `linear-gradient(90deg, transparent, ${COLORS.accent}, transparent)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,289 @@
|
||||
import {
|
||||
AbsoluteFill,
|
||||
interpolate,
|
||||
spring,
|
||||
useCurrentFrame,
|
||||
useVideoConfig,
|
||||
} from "remotion";
|
||||
import { COLORS, MUSIC_SYMBOLS } from "../lib/constants";
|
||||
|
||||
const OrbitNote: React.FC<{
|
||||
index: number;
|
||||
total: number;
|
||||
radius: number;
|
||||
speed: number;
|
||||
size: number;
|
||||
}> = ({ index, total, radius, speed, size }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { width, height } = useVideoConfig();
|
||||
|
||||
const angle =
|
||||
(index / total) * Math.PI * 2 + frame * speed * 0.02;
|
||||
|
||||
const x = width / 2 + Math.cos(angle) * radius - size / 2;
|
||||
const y = height / 2 + Math.sin(angle) * radius * 0.6 - size / 2 - 40;
|
||||
|
||||
const orbitOpacity = interpolate(frame, [30, 60, 170, 210], [0, 0.5, 0.5, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: x,
|
||||
top: y,
|
||||
fontSize: size,
|
||||
color: COLORS.accent,
|
||||
opacity: orbitOpacity,
|
||||
textShadow: `0 0 15px ${COLORS.noteGlow}`,
|
||||
}}
|
||||
>
|
||||
{MUSIC_SYMBOLS[index % MUSIC_SYMBOLS.length]}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ConcentricRing: React.FC<{
|
||||
radius: number;
|
||||
delay: number;
|
||||
}> = ({ radius, delay }) => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
const progress = interpolate(frame - delay, [0, 60], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
const scale = interpolate(progress, [0, 1], [0.5, 1]);
|
||||
const opacity = interpolate(
|
||||
progress,
|
||||
[0, 0.3, 0.7, 1],
|
||||
[0, 0.2, 0.2, 0.08],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "46%",
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
marginLeft: -radius,
|
||||
marginTop: -radius,
|
||||
borderRadius: "50%",
|
||||
border: `1px solid ${COLORS.accent}`,
|
||||
opacity,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const OutroScene: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
// Logo animation
|
||||
const logoSpring = spring({
|
||||
frame: Math.max(0, frame - 20),
|
||||
fps,
|
||||
config: { damping: 15, stiffness: 50, mass: 1.2 },
|
||||
});
|
||||
|
||||
const logoOpacity = interpolate(logoSpring, [0, 1], [0, 1]);
|
||||
const logoScale = interpolate(logoSpring, [0, 1], [0.5, 1]);
|
||||
|
||||
// Tagline animation
|
||||
const taglineSpring = spring({
|
||||
frame: Math.max(0, frame - 55),
|
||||
fps,
|
||||
config: { damping: 14, stiffness: 60, mass: 0.8 },
|
||||
});
|
||||
|
||||
const taglineOpacity = interpolate(taglineSpring, [0, 1], [0, 1]);
|
||||
const taglineY = interpolate(taglineSpring, [0, 1], [20, 0]);
|
||||
|
||||
// CTA animation
|
||||
const ctaOpacity = interpolate(frame, [80, 110], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Final fade to black
|
||||
const finalFade = interpolate(frame, [180, 210], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Pulsing glow behind logo
|
||||
const glowPulse = interpolate(
|
||||
Math.sin(frame * 0.06),
|
||||
[-1, 1],
|
||||
[0.3, 0.6],
|
||||
);
|
||||
|
||||
return (
|
||||
<AbsoluteFill style={{ opacity: finalFade }}>
|
||||
{/* Concentric rings */}
|
||||
{[120, 200, 300, 420].map((r, i) => (
|
||||
<ConcentricRing key={i} radius={r} delay={10 + i * 10} />
|
||||
))}
|
||||
|
||||
{/* Orbiting notes */}
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<OrbitNote
|
||||
key={i}
|
||||
index={i}
|
||||
total={12}
|
||||
radius={280 + (i % 3) * 80}
|
||||
speed={1 + (i % 2) * 0.5}
|
||||
size={24 + (i % 3) * 8}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Center glow */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "42%",
|
||||
width: 400,
|
||||
height: 400,
|
||||
marginLeft: -200,
|
||||
marginTop: -200,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(circle, ${COLORS.accent}15, transparent)`,
|
||||
opacity: glowPulse,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Logo */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "38%",
|
||||
transform: `translate(-50%, -50%) scale(${logoScale})`,
|
||||
opacity: logoOpacity,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 160,
|
||||
fontWeight: 800,
|
||||
color: COLORS.text,
|
||||
letterSpacing: -4,
|
||||
textShadow: `0 0 80px ${COLORS.noteGlow}, 0 0 160px rgba(255,215,0,0.15)`,
|
||||
fontFamily:
|
||||
'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
||||
}}
|
||||
>
|
||||
In
|
||||
<span style={{ color: COLORS.accent }}>Tono</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tagline */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "54%",
|
||||
transform: `translate(-50%) translateY(${taglineY}px)`,
|
||||
opacity: taglineOpacity,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 42,
|
||||
fontWeight: 300,
|
||||
color: COLORS.text,
|
||||
letterSpacing: 6,
|
||||
fontFamily:
|
||||
'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
||||
}}
|
||||
>
|
||||
Impara.{" "}
|
||||
<span style={{ color: COLORS.accent, fontWeight: 600 }}>
|
||||
Pratica.
|
||||
</span>{" "}
|
||||
Suona.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative line */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "62%",
|
||||
transform: "translateX(-50%)",
|
||||
width: interpolate(frame, [70, 100], [0, 200], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
}),
|
||||
height: 2,
|
||||
background: `linear-gradient(90deg, transparent, ${COLORS.accent}80, transparent)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* CTA */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: "70%",
|
||||
transform: "translate(-50%)",
|
||||
opacity: ctaOpacity,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 24,
|
||||
color: COLORS.textMuted,
|
||||
letterSpacing: 3,
|
||||
textTransform: "uppercase",
|
||||
fontFamily:
|
||||
'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
||||
}}
|
||||
>
|
||||
Disponibile su iOS, Android e Desktop
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom musical note decoration */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 60,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
display: "flex",
|
||||
gap: 40,
|
||||
opacity: interpolate(frame, [100, 130], [0, 0.3], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{MUSIC_SYMBOLS.slice(0, 4).map((s, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: 24,
|
||||
color: COLORS.accent,
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,380 @@
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: STAFF_LEFT,
|
||||
top: y,
|
||||
width,
|
||||
height: 2,
|
||||
background: COLORS.staffLine,
|
||||
borderRadius: 1,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: x - 18,
|
||||
top: y + noteY,
|
||||
opacity,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
{/* Ledger line */}
|
||||
{needsLedgerLine && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: -8,
|
||||
top: 17,
|
||||
width: 52,
|
||||
height: 2,
|
||||
background: COLORS.staffLine,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Note glow */}
|
||||
{isPlaying && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: -10,
|
||||
top: -10,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: "50%",
|
||||
background: `radial-gradient(circle, ${COLORS.noteGlow}, transparent)`,
|
||||
opacity: glowIntensity,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Note head (ellipse) */}
|
||||
<div
|
||||
style={{
|
||||
width: 36,
|
||||
height: 28,
|
||||
borderRadius: "50%",
|
||||
background: isPlaying ? COLORS.accent : COLORS.text,
|
||||
transform: "rotate(-15deg)",
|
||||
boxShadow: isPlaying
|
||||
? `0 0 20px ${COLORS.noteGlow}, 0 0 40px ${COLORS.noteGlow}`
|
||||
: "none",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Stem */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: stemUp ? 33 : 0,
|
||||
top: stemUp ? -60 : 18,
|
||||
width: 3,
|
||||
height: 65,
|
||||
background: isPlaying ? COLORS.accent : COLORS.text,
|
||||
borderRadius: 1.5,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Note name label */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: stemUp ? 45 : -35,
|
||||
transform: "translateX(-50%)",
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: isPlaying ? COLORS.accent : COLORS.textMuted,
|
||||
fontFamily:
|
||||
'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
||||
textAlign: "center",
|
||||
letterSpacing: 1,
|
||||
whiteSpace: "nowrap",
|
||||
textShadow: isPlaying
|
||||
? `0 0 10px ${COLORS.noteGlow}`
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
{isHigh && (
|
||||
<span style={{ fontSize: 14, verticalAlign: "super" }}>2</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: STAFF_LEFT + 20,
|
||||
top: STAFF_TOP - 65,
|
||||
fontSize: 200,
|
||||
color: COLORS.accent,
|
||||
opacity,
|
||||
transform: `scale(${scale})`,
|
||||
lineHeight: 1,
|
||||
textShadow: `0 0 30px ${COLORS.noteGlow}`,
|
||||
}}
|
||||
>
|
||||
{"\uD834\uDD1E"}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: x,
|
||||
top: STAFF_TOP - 30,
|
||||
width: 3,
|
||||
height: STAFF_GAP * 4 + 60,
|
||||
background: `linear-gradient(180deg, transparent, ${COLORS.accent}, transparent)`,
|
||||
opacity,
|
||||
boxShadow: `0 0 15px ${COLORS.accent}, 0 0 30px ${COLORS.noteGlow}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<AbsoluteFill style={{ opacity: fadeIn * fadeOut }}>
|
||||
{/* Section title */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "50%",
|
||||
top: 140,
|
||||
transform: `translate(-50%, ${titleY}px)`,
|
||||
opacity: titleOpacity,
|
||||
fontSize: 48,
|
||||
fontWeight: 700,
|
||||
color: COLORS.text,
|
||||
letterSpacing: 2,
|
||||
fontFamily:
|
||||
'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif',
|
||||
}}
|
||||
>
|
||||
La Scala di{" "}
|
||||
<span style={{ color: COLORS.accent }}>DO Maggiore</span>
|
||||
</div>
|
||||
|
||||
{/* Staff lines */}
|
||||
{Array.from({ length: 5 }, (_, i) => (
|
||||
<StaffLine
|
||||
key={i}
|
||||
y={STAFF_TOP + i * STAFF_GAP}
|
||||
delay={5 + i * 4}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Treble clef */}
|
||||
<TrebleClef />
|
||||
|
||||
{/* Playhead */}
|
||||
<Playhead />
|
||||
|
||||
{/* Notes */}
|
||||
{NOTES_ITALIAN.map((note, i) => (
|
||||
<NoteHead
|
||||
key={i}
|
||||
x={NOTE_START_X + i * NOTE_GAP}
|
||||
lineOffset={note.offset}
|
||||
name={note.name}
|
||||
index={i}
|
||||
isPlaying={activeNoteIndex === i && frame >= playStart}
|
||||
isHigh={i === 7}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Bottom frequency bars */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
bottom: 100,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
display: "flex",
|
||||
gap: 6,
|
||||
alignItems: "flex-end",
|
||||
height: 80,
|
||||
}}
|
||||
>
|
||||
{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 (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: 8,
|
||||
height: barHeight,
|
||||
borderRadius: 4,
|
||||
background: `linear-gradient(180deg, ${COLORS.accent}, ${COLORS.accentWarm})`,
|
||||
opacity: barOpacity,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Brace decoration */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: STAFF_LEFT - 30,
|
||||
top: STAFF_TOP - 10,
|
||||
width: 3,
|
||||
height: STAFF_GAP * 4 + 20,
|
||||
background: COLORS.accent,
|
||||
borderRadius: 2,
|
||||
opacity: interpolate(frame, [20, 40], [0, 0.6], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user