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
This commit is contained in:
blumia committed 2026-11-05 11:38:25 +08:00
commit a237a7c604
38 files changed
+13958

No files matched your search

+716
View File
@@ -0,0 +1,716 @@
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>
);
};