716 lines
24 KiB
TypeScript
716 lines
24 KiB
TypeScript
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<HTMLCanvasElement>(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<number, string>();
|
||
|
|
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 (
|
||
|
|
<AbsoluteFill
|
||
|
|
style={{
|
||
|
|
background: settings.background,
|
||
|
|
color: '#e6ded2',
|
||
|
|
justifyContent: 'center',
|
||
|
|
alignItems: 'center',
|
||
|
|
fontSize: 38,
|
||
|
|
fontFamily: settings.titleFont,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
请先在网页中上传 MusicXML 并生成预览
|
||
|
|
</AbsoluteFill>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<AbsoluteFill style={{background: settings.background, overflow: 'hidden'}}>
|
||
|
|
<AbsoluteFill
|
||
|
|
style={{
|
||
|
|
background:
|
||
|
|
'radial-gradient(ellipse at 50% 40%, rgba(255,255,255,0.045), transparent 58%)',
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Tilted score plane: paper, notation and its light spill share one 3D transform */}
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
inset: 0,
|
||
|
|
perspective: `${perspective}px`,
|
||
|
|
perspectiveOrigin: '50% 50%',
|
||
|
|
overflow: 'hidden',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
inset: 0,
|
||
|
|
transformOrigin: '50% 50%',
|
||
|
|
transformStyle: 'preserve-3d',
|
||
|
|
transform: planeTransform,
|
||
|
|
willChange: 'transform',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{/* Paper band, wider than the frame so tilted edges stay off-screen */}
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
left: -paperOverhang,
|
||
|
|
width: width + paperOverhang * 2,
|
||
|
|
top: scoreTop - paperPad,
|
||
|
|
height: displayHeight + paperPad * 2,
|
||
|
|
opacity: paperOpacity * paperAlpha,
|
||
|
|
backgroundColor: settings.paperColor,
|
||
|
|
backgroundImage: [
|
||
|
|
paperTexture ? `url(${paperTexture})` : '',
|
||
|
|
'linear-gradient(180deg, rgba(255,255,255,0.22), rgba(255,255,255,0.02) 16%, rgba(0,0,0,0.025) 78%, rgba(0,0,0,0.2))',
|
||
|
|
'radial-gradient(ellipse at 50% 50%, rgba(255,255,255,0.12), transparent 72%)',
|
||
|
|
]
|
||
|
|
.filter(Boolean)
|
||
|
|
.join(', '),
|
||
|
|
backgroundSize: paperTexture
|
||
|
|
? '96px 96px, 100% 100%, 100% 100%'
|
||
|
|
: '100% 100%, 100% 100%',
|
||
|
|
boxShadow:
|
||
|
|
'0 32px 90px rgba(0,0,0,0.6), 0 2px 0 rgba(255,255,255,0.12) inset, 0 -2px 0 rgba(0,0,0,0.18) inset',
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Score: the opaque SVG becomes a mask so its ink colour is live-editable */}
|
||
|
|
<Img
|
||
|
|
src={data.svgUrl}
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
left: 0,
|
||
|
|
top: 0,
|
||
|
|
width: 1,
|
||
|
|
height: 1,
|
||
|
|
opacity: 0,
|
||
|
|
pointerEvents: 'none',
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
left: scoreLeft,
|
||
|
|
top: scoreTop,
|
||
|
|
width: totalWidth,
|
||
|
|
height: displayHeight,
|
||
|
|
opacity: paperOpacity,
|
||
|
|
backgroundColor: scoreColor,
|
||
|
|
WebkitMaskImage: `url("${data.svgUrl}")`,
|
||
|
|
WebkitMaskRepeat: 'no-repeat',
|
||
|
|
WebkitMaskSize: '100% 100%',
|
||
|
|
WebkitMaskPosition: '0 0',
|
||
|
|
maskImage: `url("${data.svgUrl}")`,
|
||
|
|
maskRepeat: 'no-repeat',
|
||
|
|
maskSize: '100% 100%',
|
||
|
|
maskPosition: '0 0',
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* 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 (
|
||
|
|
<div
|
||
|
|
key={`light-${ball.staff}`}
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
inset: 0,
|
||
|
|
pointerEvents: 'none',
|
||
|
|
opacity: paperOpacity,
|
||
|
|
mixBlendMode: 'screen',
|
||
|
|
background: `radial-gradient(circle ${radius}px at ${x}px ${y}px, ${withAlpha(
|
||
|
|
ball.color,
|
||
|
|
0.34 * settings.glow * ball.motion.opacity,
|
||
|
|
)} 0%, ${withAlpha(ball.color, 0.1 * settings.glow * ball.motion.opacity)} 34%, transparent 70%)`,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Orbs live above the tilted plane and are drawn in screen space */}
|
||
|
|
<canvas
|
||
|
|
ref={canvasRef}
|
||
|
|
style={{position: 'absolute', inset: 0, width: '100%', height: '100%'}}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Vignette keeps the eye on the score */}
|
||
|
|
<AbsoluteFill
|
||
|
|
style={{
|
||
|
|
pointerEvents: 'none',
|
||
|
|
background:
|
||
|
|
'radial-gradient(ellipse at 50% 52%, transparent 34%, rgba(7,5,12,0.18) 76%, rgba(3,2,7,0.5) 112%)',
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{/* Header scrim keeps the title readable over a tilted sheet */}
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
left: 0,
|
||
|
|
right: 0,
|
||
|
|
top: 0,
|
||
|
|
height: height * 0.24,
|
||
|
|
pointerEvents: 'none',
|
||
|
|
background: `linear-gradient(180deg, ${withAlpha(settings.background, 0.82)} 0%, ${withAlpha(
|
||
|
|
settings.background,
|
||
|
|
0.4,
|
||
|
|
)} 55%, transparent 100%)`,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
|
||
|
|
{settings.showTitle && (
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
top: headerTop,
|
||
|
|
left: headerPadding,
|
||
|
|
right: headerPadding,
|
||
|
|
display: 'flex',
|
||
|
|
justifyContent: 'space-between',
|
||
|
|
alignItems: 'flex-end',
|
||
|
|
gap: width * 0.04,
|
||
|
|
opacity: titleOpacity,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<div style={{minWidth: 0, flex: 1}}>
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
fontFamily: settings.titleFont,
|
||
|
|
fontSize: titleSize,
|
||
|
|
lineHeight: 1.05,
|
||
|
|
color: '#f4ede2',
|
||
|
|
letterSpacing: '0.012em',
|
||
|
|
whiteSpace: 'nowrap',
|
||
|
|
overflow: 'hidden',
|
||
|
|
textOverflow: 'ellipsis',
|
||
|
|
textShadow: '0 2px 20px rgba(0,0,0,0.35)',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{meta.title || 'Untitled'}
|
||
|
|
</div>
|
||
|
|
{meta.subtitle ? (
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
marginTop: height * 0.008,
|
||
|
|
fontFamily: settings.titleFont,
|
||
|
|
fontSize: subtitleSize,
|
||
|
|
color: 'rgba(244,237,226,0.52)',
|
||
|
|
letterSpacing: '0.05em',
|
||
|
|
whiteSpace: 'nowrap',
|
||
|
|
overflow: 'hidden',
|
||
|
|
textOverflow: 'ellipsis',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{meta.subtitle}
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
{meta.composer ? (
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
fontFamily: settings.titleFont,
|
||
|
|
fontSize: composerSize,
|
||
|
|
color: 'rgba(244,237,226,0.55)',
|
||
|
|
letterSpacing: '0.08em',
|
||
|
|
whiteSpace: 'nowrap',
|
||
|
|
paddingBottom: height * 0.006,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{meta.composer}
|
||
|
|
</div>
|
||
|
|
) : null}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Thin rule under header */}
|
||
|
|
{settings.showTitle && (
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
top: headerTop + titleSize * 1.35 + (meta.subtitle ? subtitleSize * 1.35 : 0),
|
||
|
|
left: headerPadding,
|
||
|
|
right: headerPadding,
|
||
|
|
height: 1,
|
||
|
|
opacity: titleOpacity * 0.5,
|
||
|
|
background: `linear-gradient(90deg, ${withAlpha(
|
||
|
|
settings.staffColors[0] ?? '#bda0ff',
|
||
|
|
0.75,
|
||
|
|
)}, rgba(255,255,255,0.08) 55%, transparent)`,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Progress */}
|
||
|
|
{settings.showProgress && (
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
position: 'absolute',
|
||
|
|
left: headerPadding,
|
||
|
|
right: headerPadding,
|
||
|
|
bottom: progressBottom,
|
||
|
|
height: 2,
|
||
|
|
opacity: progressOpacity,
|
||
|
|
backgroundColor: 'rgba(255,255,255,0.12)',
|
||
|
|
borderRadius: 2,
|
||
|
|
overflow: 'hidden',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
width: `${progress * 100}%`,
|
||
|
|
height: '100%',
|
||
|
|
background: `linear-gradient(90deg, ${withAlpha(
|
||
|
|
settings.staffColors[0] ?? '#bda0ff',
|
||
|
|
0.9,
|
||
|
|
)}, ${withAlpha(settings.staffColors[1] ?? '#ffbc6d', 0.9)})`,
|
||
|
|
borderRadius: 2,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{data.audioUrl ? (
|
||
|
|
<Sequence from={introFrames + delayFrames}>
|
||
|
|
<Audio src={data.audioUrl} startFrom={trimFrames} />
|
||
|
|
</Sequence>
|
||
|
|
) : null}
|
||
|
|
</AbsoluteFill>
|
||
|
|
);
|
||
|
|
};
|