import React, {useLayoutEffect, useMemo, useRef} from 'react'; import { AbsoluteFill, Audio, Img, Sequence, useCurrentFrame, useVideoConfig, } from 'remotion'; import type {RenderData} from '../shared/types'; import {scrollTrack} from '../shared/scroll'; import { activeRingsAt, arcPoint, groupLandings, liftDepth, performanceAt, REST_DEPTH, type Motion, } from './animation'; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); /** * Accepts hex and rgb()/rgba() colours. Canvas silently keeps its previous * strokeStyle for invalid colours (default black), so this must cover whatever * `mix()` hands back. */ function parseColor(color: string): [number, number, number] { const value = color.trim(); const rgb = value.match(/^rgba?\(([^)]+)\)$/i); if (rgb) { const parts = rgb[1]!.split(',').map((part) => Number.parseFloat(part.trim())); if (parts.length >= 3 && parts.slice(0, 3).every(Number.isFinite)) { return [parts[0]!, parts[1]!, parts[2]!]; } } const hex = value.startsWith('#') ? value.slice(1) : value; if (/^[0-9a-f]{3}$/i.test(hex)) { return [ Number.parseInt(hex[0]! + hex[0]!, 16), Number.parseInt(hex[1]! + hex[1]!, 16), Number.parseInt(hex[2]! + hex[2]!, 16), ]; } if (/^[0-9a-f]{6}$/i.test(hex)) { return [ Number.parseInt(hex.slice(0, 2), 16), Number.parseInt(hex.slice(2, 4), 16), Number.parseInt(hex.slice(4, 6), 16), ]; } return [255, 255, 255]; } function withAlpha(hex: string, alpha: number): string { const [r, g, b] = parseColor(hex); return `rgba(${r},${g},${b},${clamp(alpha, 0, 1)})`; } function mix(hexA: string, hexB: string, amount: number): string { const a = parseColor(hexA); const b = parseColor(hexB); const t = clamp(amount, 0, 1); return `rgb(${Math.round(a[0] + (b[0] - a[0]) * t)},${Math.round( a[1] + (b[1] - a[1]) * t, )},${Math.round(a[2] + (b[2] - a[2]) * t)})`; } function makePaperTexture(): string | undefined { if (typeof document === 'undefined') return undefined; const size = 96; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); if (!ctx) return undefined; const image = ctx.createImageData(size, size); let seed = 0x1a2b3c4d; for (let i = 0; i < image.data.length; i += 4) { seed = (seed * 1664525 + 1013904223) >>> 0; const grain = seed % 18; image.data[i] = 0; image.data[i + 1] = 0; image.data[i + 2] = 0; image.data[i + 3] = grain; } ctx.putImageData(image, 0, 0); return canvas.toDataURL('image/png'); } type BallState = { staff: number; color: string; motion: Motion; }; export const MusicScroll: React.FC<{data: RenderData}> = ({data}) => { const frame = useCurrentFrame(); const {width, height, fps} = useVideoConfig(); const canvasRef = useRef(null); const paperTexture = useMemo(makePaperTexture, []); const settings = data.settings; const meta = data.meta; const uniqueLanes = useMemo( () => [...new Set(data.activations.map((note) => note.staff))].sort((a, b) => a - b), [data.activations], ); const landings = useMemo( () => new Map(uniqueLanes.map((staff) => [staff, groupLandings(data.activations, staff)])), [data.activations, uniqueLanes], ); const colorForStaff = useMemo(() => { const map = new Map(); uniqueLanes.forEach((staff, index) => { map.set(staff, settings.staffColors[index % settings.staffColors.length] ?? '#bda0ff'); }); return map; }, [uniqueLanes, settings.staffColors]); const introSeconds = settings.introSeconds; const t = frame / fps - introSeconds; const paperOpacity = clamp((t + introSeconds) / Math.max(0.2, Math.min(0.55, introSeconds)), 0, 1); const contentOpacity = clamp((t + 0.2) / 0.28, 0, 1); const titleOpacity = clamp((t + introSeconds) / Math.max(0.3, Math.min(0.7, introSeconds)), 0, 1); const displayHeight = Math.max(120, height * settings.scoreHeightRatio); const displayScale = displayHeight / Math.max(1, meta.svgHeight); const totalWidth = meta.svgWidth * displayScale; const centerOffset = totalWidth < width ? (width - totalWidth) / 2 : 0; const scrollX = scrollTrack(data.scroll, t); const scoreLeft = centerOffset - scrollX * displayScale; const scoreTop = (height - displayHeight) / 2 + height * 0.018; const paperPad = Math.max(16, displayHeight * 0.07); /** * The sheet is much wider than the frame: once the camera tilts, a * viewport-wide band would show its left/right edges inside the picture. */ const paperOverhang = width * 2; const balls: BallState[] = []; for (const staff of uniqueLanes) { const motion = performanceAt(landings.get(staff) ?? [], t); if (!motion) continue; if (motion.opacity <= 0.001) continue; balls.push({staff, color: colorForStaff.get(staff) ?? '#bda0ff', motion}); } const rings = activeRingsAt(data.activations, t); const noteAlpha = paperOpacity; const isPortrait = height > width; const headerTop = height * (isPortrait ? 0.05 : 0.055); const headerPadding = width * (isPortrait ? 0.065 : 0.055); const titleSize = Math.round(height * (isPortrait ? 0.026 : 0.04)); const subtitleSize = Math.round(height * (isPortrait ? 0.014 : 0.019)); const composerSize = Math.round(height * (isPortrait ? 0.013 : 0.017)); const progressBottom = height * (isPortrait ? 0.045 : 0.042); const progress = clamp(t / Math.max(0.1, meta.musicDuration), 0, 1); const progressOpacity = titleOpacity * (settings.showProgress ? 0.6 : 0); const tiltX = settings.tiltX ?? 0; const tiltY = settings.tiltY ?? 0; const tiltZ = settings.tiltZ ?? 0; const perspective = Math.max(500, settings.perspective ?? 1800); const tiltZoom = Math.max(0.5, settings.tiltZoom ?? 1); const paperAlpha = settings.paperAlpha ?? 1; const scoreColor = settings.scoreColor ?? '#14110d'; const planeTransform = `rotateX(${tiltX}deg) rotateY(${tiltY}deg) rotateZ(${tiltZ}deg) scale(${tiltZoom})`; const introFrames = Math.round(settings.introSeconds * fps); const offsetMs = settings.audioOffsetMs; const delayFrames = offsetMs > 0 ? Math.round((offsetMs / 1000) * fps) : 0; const trimFrames = offsetMs < 0 ? Math.round((-offsetMs / 1000) * fps) : 0; useLayoutEffect(() => { const canvas = canvasRef.current; if (!canvas) return; if (canvas.width !== width) canvas.width = width; if (canvas.height !== height) canvas.height = height; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.clearRect(0, 0, width, height); const unit = displayScale; /** Score coordinates -> the plane's own pixel canvas (before the 3D transform). */ const toPaper = (x: number, y: number): [number, number] => [ (x - scrollX) * displayScale + centerOffset, scoreTop + y * displayScale, ]; // Mirror of the CSS transform on the score plane, evaluated here so the orbs // can be drawn in screen space: they keep a round silhouette at any camera // angle instead of being squashed into ellipses by the paper's perspective. const rad = (deg: number) => (deg * Math.PI) / 180; const cosX = Math.cos(rad(tiltX)); const sinX = Math.sin(rad(tiltX)); const cosY = Math.cos(rad(tiltY)); const sinY = Math.sin(rad(tiltY)); const cosZ = Math.cos(rad(tiltZ)); const sinZ = Math.sin(rad(tiltZ)); const midX = width / 2; const midY = height / 2; /** Score units above the paper -> plane-local pixels, before the 3D transform. */ const lift = Math.max(0, settings.ballLift ?? 1); const depthToPixels = (depth: number) => liftDepth(depth, lift) * displayScale; const orbRadius = (depth: number) => 2.5 * unit * settings.ballScale * depth; const project = (x: number, y: number, z = 0) => { const lx = (x - midX) * tiltZoom; const ly = (y - midY) * tiltZoom; // CSS applies rotateX * rotateY * rotateZ * scale to the local point. const rzX = lx * cosZ - ly * sinZ; const rzY = lx * sinZ + ly * cosZ; const ryX = rzX * cosY + z * sinY; const ryZ = -rzX * sinY + z * cosY; const rxY = rzY * cosX - ryZ * sinX; const rxZ = rzY * sinX + ryZ * cosX; const w = perspective / Math.max(1, perspective - rxZ); return {x: midX + ryX * w, y: midY + rxY * w, w, depth: rxZ}; }; /** Draw in paper-plane units, so shadows and rings lie flat on the sheet. */ const onPaper = (x: number, y: number, draw: () => void) => { const origin = project(x, y, 0); const alongX = project(x + 1, y, 0); const alongY = project(x, y + 1, 0); ctx.save(); ctx.setTransform( alongX.x - origin.x, alongX.y - origin.y, alongY.x - origin.x, alongY.y - origin.y, origin.x, origin.y, ); draw(); ctx.restore(); }; // Contact shadows, flattened onto the sheet so each hop reads as height. for (const ball of balls) { const motion = ball.motion; const [px, py] = toPaper(motion.position[0], motion.position[1]); const r = 2.5 * unit * settings.ballScale; const height = Math.max(0, motion.depth - REST_DEPTH) * lift; const spread = r * (1.6 + Math.min(2.2, height * 0.08)); const strength = 0.36 / (1 + height / 9); onPaper(px, py, () => { const shadow = ctx.createRadialGradient(0, 0, r * 0.1, 0, 0, spread); shadow.addColorStop(0, `rgba(0,0,0,${strength})`); shadow.addColorStop(0.5, `rgba(0,0,0,${strength * 0.4})`); shadow.addColorStop(1, 'rgba(0,0,0,0)'); ctx.globalAlpha = motion.opacity * 0.9 * contentOpacity; ctx.fillStyle = shadow; ctx.beginPath(); ctx.arc(0, 0, spread, 0, Math.PI * 2); ctx.fill(); }); } // Hit markers stay on the sheet and carry the orb's own lane colour, so a // played note reads as a trace of that ball instead of a dark ring. for (const note of rings) { const age = t - note.time; if (age < 0 || age > 3.6) continue; const sustain = note.kind === 'sustain'; const flash = Math.exp(-age / 0.14) * (sustain ? 0.55 : 1); const fade = Math.max(0, 1 - age / 3.6) ** 1.15; const expansion = 1 + 0.23 * (1 - Math.exp(-age / 0.055)); const color = colorForStaff.get(note.staff) ?? '#bda0ff'; const [nx, ny] = toPaper(note.x, note.y); const ringR = 4.2 * unit * expansion; onPaper(nx, ny, () => { ctx.globalAlpha = noteAlpha; const halo = ctx.createRadialGradient(0, 0, ringR * 0.45, 0, 0, ringR * 2.3); halo.addColorStop(0, withAlpha(color, (0.3 + flash * 0.25) * fade)); halo.addColorStop(1, withAlpha(color, 0)); ctx.fillStyle = halo; ctx.beginPath(); ctx.arc(0, 0, ringR * 2.3, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = withAlpha(color, (sustain ? 0.6 : 0.82) * fade); ctx.lineWidth = Math.max(1.2, 0.55 * unit); ctx.beginPath(); ctx.arc(0, 0, ringR, 0, Math.PI * 2); ctx.stroke(); ctx.strokeStyle = withAlpha(mix(color, '#ffffff', 0.4 + flash * 0.4), 0.6 * fade); ctx.lineWidth = Math.max(0.9, 0.28 * unit); ctx.beginPath(); ctx.arc(0, 0, ringR * 0.84, 0, Math.PI * 2); ctx.stroke(); }); } // Trails: a 3D arc in the same space as the orb, drawn only along the part // of the hop already travelled so it reads as a tail behind the ball. for (const ball of balls) { const motion = ball.motion; if (motion.trailOpacity <= 0.001) continue; const steps = 30; const trailEnd = motion.departing ? Math.max(0.04, motion.u) : 1; const samples = Array.from({length: steps}, (_, i) => { const [x, y, z] = arcPoint(motion.from, motion.to, (i / (steps - 1)) * trailEnd); const [px, py] = toPaper(x, y); const point = project(px, py, depthToPixels(z)); return {x: point.x, y: point.y, w: point.w}; }); ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; const stroke = (widthScale: number, alphaScale: number) => { for (let i = 1; i < samples.length; i += 1) { const a = samples[i - 1]!; const b = samples[i]!; const f = i / (samples.length - 1); const head = f * f; ctx.globalAlpha = noteAlpha * motion.trailOpacity * (0.04 + head * 0.72) * alphaScale; ctx.strokeStyle = withAlpha(ball.color, 0.85); ctx.lineWidth = Math.max( 0.7, (0.3 + 0.62 * head) * unit * b.w * widthScale, ); ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); } }; stroke(3.6, 0.16); stroke(1, 1); ctx.restore(); } // Orbs: real spheres in screen space, so they never collapse into ellipses. for (const ball of balls) { const motion = ball.motion; const opacity = motion.opacity * contentOpacity; if (opacity <= 0.001) continue; const [px, py] = toPaper(motion.position[0], motion.position[1]); const head = project(px, py, depthToPixels(motion.depth)); const r = orbRadius(head.w); const pulse = motion.pulse; const radius = r * (1 + pulse * 0.1); const sx = head.x; const sy = head.y; ctx.save(); ctx.globalCompositeOperation = 'lighter'; ctx.globalAlpha = opacity; const glowRadius = radius * 9.5; const glow = ctx.createRadialGradient(sx, sy, radius * 0.2, sx, sy, glowRadius); glow.addColorStop(0, withAlpha(ball.color, 0.5 * settings.glow)); glow.addColorStop(0.22, withAlpha(ball.color, 0.16 * settings.glow)); glow.addColorStop(0.6, withAlpha(ball.color, 0.04 * settings.glow)); glow.addColorStop(1, withAlpha(ball.color, 0)); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(sx, sy, glowRadius, 0, Math.PI * 2); ctx.fill(); ctx.restore(); ctx.save(); ctx.globalAlpha = opacity; const body = ctx.createRadialGradient( sx - radius * 0.34, sy - radius * 0.38, radius * 0.04, sx + radius * 0.1, sy + radius * 0.14, radius * 1.1, ); body.addColorStop(0, 'rgba(255,255,255,1)'); body.addColorStop(0.16, mix(ball.color, '#ffffff', 0.9)); body.addColorStop(0.42, mix(ball.color, '#ffffff', 0.52)); body.addColorStop(0.7, mix(ball.color, '#ffffff', 0.18)); body.addColorStop(0.9, ball.color); body.addColorStop(1, mix(ball.color, '#000000', 0.12)); ctx.fillStyle = body; ctx.beginPath(); ctx.arc(sx, sy, radius, 0, Math.PI * 2); ctx.fill(); const specular = ctx.createRadialGradient( sx - radius * 0.36, sy - radius * 0.4, 0, sx - radius * 0.36, sy - radius * 0.4, radius * 0.46, ); specular.addColorStop(0, 'rgba(255,255,255,0.92)'); specular.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = specular; ctx.beginPath(); ctx.arc(sx, sy, radius, 0, Math.PI * 2); ctx.fill(); ctx.globalCompositeOperation = 'lighter'; ctx.strokeStyle = withAlpha(mix(ball.color, '#ffffff', 0.85), 0.2 * opacity); ctx.lineWidth = Math.max(0.8, radius * 0.07); ctx.beginPath(); ctx.arc(sx, sy, radius * 0.94, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } }, [ width, height, t, scrollX, displayScale, displayHeight, centerOffset, scoreTop, balls, rings, noteAlpha, contentOpacity, settings.ballScale, settings.ballLift, settings.glow, colorForStaff, tiltX, tiltY, tiltZ, perspective, tiltZoom, ]); if (!data.events.length || !data.svgUrl) { return ( 请先在网页中上传 MusicXML 并生成预览 ); } return ( {/* Tilted score plane: paper, notation and its light spill share one 3D transform */}
{/* Paper band, wider than the frame so tilted edges stay off-screen */}
{/* Score: the opaque SVG becomes a mask so its ink colour is live-editable */}
{/* Paper lights */} {balls.map((ball) => { const [x, y] = [ (ball.motion.position[0] - scrollX) * displayScale + centerOffset, scoreTop + ball.motion.position[1] * displayScale, ]; const radius = Math.max(150, displayHeight * 0.48) * (0.85 + 0.2 * settings.glow); return (
); })}
{/* Orbs live above the tilted plane and are drawn in screen space */} {/* Vignette keeps the eye on the score */} {/* Header scrim keeps the title readable over a tilted sheet */}
{settings.showTitle && (
{meta.title || 'Untitled'}
{meta.subtitle ? (
{meta.subtitle}
) : null}
{meta.composer ? (
{meta.composer}
) : null}
)} {/* Thin rule under header */} {settings.showTitle && (
)} {/* Progress */} {settings.showProgress && (
)} {data.audioUrl ? ( ) : null} ); };