@real-music-packages/web-core 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -227,6 +227,14 @@ declare const linear: Easing;
227
227
  declare const easeInOut: Easing;
228
228
  declare const easeIn: Easing;
229
229
  declare const easeOut: Easing;
230
+ /**
231
+ * Eased 0..1 progress of a transition at absolute time `tMs`, given the incoming
232
+ * segment's `startMs` and the transition `durationMs`. Returns 0 at/before the
233
+ * start, 1 at/after start+durationMs, eased in between. Pure fn of t. Shared by
234
+ * the runner so the same envelope drives crossfade alpha, push translate, and
235
+ * wipe clip. `durationMs <= 0` → an instant 1 (degenerate hard-cut).
236
+ */
237
+ declare const transitionProgress: (tMs: number, startMs: number, durationMs: number, easing?: Easing) => number;
230
238
 
231
239
  /** A rectangle in WORLD coordinates (the layers' natural pixel space). */
232
240
  interface Rect {
@@ -326,12 +334,52 @@ interface SegmentAudioCtx {
326
334
  interface ScheduleTarget {
327
335
  triggerAttackRelease: (note: unknown, dur: unknown, time?: unknown, velocity?: unknown) => void;
328
336
  }
337
+ /**
338
+ * Optional ENTER transition for a segment — how it comes IN from the PREVIOUS
339
+ * segment (the one immediately before it in the timeline). Purely VISUAL and
340
+ * fully additive: a segment with no `transition` hard-cuts exactly as before
341
+ * (existing specs are byte-for-byte unchanged). The audio timeline / per-segment
342
+ * `audio` hook is unaffected (transitions are a visual envelope only).
343
+ *
344
+ * During the first `durationMs` of the incoming segment, the runner renders BOTH
345
+ * the previous segment (its last drawn state, held) AND the incoming segment,
346
+ * applying an eased envelope (`easeInOut` by default):
347
+ * - `crossfade` — outgoing opacity 1→0, incoming 0→1 (the keystone; always on).
348
+ * - `push` — both slide along `direction`: incoming enters from off-frame
349
+ * while outgoing exits the opposite way (a hard slide, no fade).
350
+ * - `wipe` — incoming is revealed by a moving clip edge along `direction`;
351
+ * the outgoing shows through the un-wiped region (no fade).
352
+ *
353
+ * The overlap does NOT add time — the transition lives INSIDE the incoming
354
+ * segment's own window, so `visualTimelineMs` (and the gate's A/V-duration check)
355
+ * are unchanged. `durationMs` is clamped to the shorter of the two adjoining
356
+ * segment lengths so a transition can't run past either segment.
357
+ */
358
+ interface SegmentTransition {
359
+ /** Transition style. Start with `crossfade`. */
360
+ type: 'crossfade' | 'push' | 'wipe';
361
+ /** Length of the enter window in ms (from the incoming segment's start). */
362
+ durationMs: number;
363
+ /**
364
+ * Slide / wipe direction for `push` and `wipe` (ignored by `crossfade`).
365
+ * Names the edge the INCOMING segment comes FROM. Default `left`.
366
+ */
367
+ direction?: 'left' | 'right' | 'up' | 'down';
368
+ /** Eased envelope, 0..1 → 0..1. Defaults to `easeInOut`. */
369
+ easing?: Easing;
370
+ }
329
371
  interface TimelineSegment {
330
372
  /** [start, end] in SECONDS; end may be "end" / "end-N". */
331
373
  at: [number, TimeAnchor];
332
374
  layers: SpecLayer[];
333
375
  /** Optional sound scheduled at this segment's start (see SegmentAudio). */
334
376
  audio?: SegmentAudio;
377
+ /**
378
+ * Optional ENTER transition from the PREVIOUS segment (see SegmentTransition).
379
+ * Omit for today's hard cut. Has no effect on the first segment (nothing to
380
+ * transition from) unless `loop` is set on the spec.
381
+ */
382
+ transition?: SegmentTransition;
335
383
  }
336
384
  interface SceneSpec {
337
385
  /** [width, height] px. */
@@ -357,6 +405,7 @@ interface ResolvedSegment {
357
405
  endMs: number;
358
406
  layers: SpecLayer[];
359
407
  audio?: SegmentAudio;
408
+ transition?: SegmentTransition;
360
409
  }
361
410
  /** Resolve every segment's [start,end] to ms against the total clip length. */
362
411
  declare function resolveTimeline(spec: SceneSpec, totalSec: number): ResolvedSegment[];
@@ -397,7 +446,25 @@ interface BuildSceneOpts {
397
446
  /** Override the camera pose per absolute tMs (pan/zoom director). Identity by
398
447
  * default. */
399
448
  camera?: (tMs: number) => CameraState;
449
+ /**
450
+ * Offscreen-buffer factory used ONLY by segment `transition`s (a segment's
451
+ * stack is rendered to a buffer, then composited onto the main canvas with the
452
+ * transition envelope — so the envelope is robust even when inner layers set
453
+ * their own `globalAlpha`). Unused when no segment has a transition.
454
+ *
455
+ * Default: `OffscreenCanvas` if available, else `document.createElement
456
+ * ('canvas')`. Node/headless callers (and the node-canvas golden tests) pass a
457
+ * factory backed by `createCanvas`. If no factory is resolvable AND a
458
+ * transition fires, the runner falls back to a direct `globalAlpha` envelope
459
+ * (crossfade only; degrades gracefully) rather than throwing.
460
+ */
461
+ bufferFactory?: BufferFactory;
400
462
  }
463
+ /** Makes an offscreen drawing surface for transition compositing. */
464
+ type BufferFactory = (w: number, h: number) => {
465
+ ctx: CanvasRenderingContext2D;
466
+ image: CanvasImageSource;
467
+ };
401
468
  /**
402
469
  * Instantiate + init every layer in the spec and return a deterministic renderer.
403
470
  * Throws if a referenced layer key isn't registered or its props don't validate
@@ -603,15 +670,25 @@ interface NotationProps {
603
670
  declare const notationFactory: LayerFactory<NotationProps>;
604
671
 
605
672
  interface ScrollCursorProps {
673
+ /**
674
+ * Cursor pacing mode. Default `'audio'` (foolproof): the cursor is driven
675
+ * note-by-note off the audio clock against each note's `onsetMs`, so it lands
676
+ * on every note as it sounds and can never race/desync the full score width —
677
+ * correct by construction regardless of timeline. `'linear'` is the legacy
678
+ * measure-linear sweep paced over `musicMs` (RSR's original behaviour) — kept
679
+ * for back-compat; prefer leaving this unset.
680
+ */
681
+ mode?: 'audio' | 'linear';
606
682
  /** Bars visible in the follow window. Default 2 (RSR FOLLOW_BARS). Informational
607
683
  * for v1 — the geometry uses the module constant unless overridden here. */
608
684
  followBars?: number;
609
685
  /** Opening-zoom duration in ms before the music/cursor start (RSR INTRO_MS=900).
610
- * During this lead-in camProgress is held at 0. Default 900. */
686
+ * During this lead-in the follow window is held at the start. Default 900. */
611
687
  openingZoomMs?: number;
612
- /** Total music length in ms (RSR musicMs). Required to pace camProgress + the
613
- * cursor against the audio clock. */
614
- musicMs: number;
688
+ /** Total music length in ms (RSR musicMs). Required by `'linear'` to pace
689
+ * camProgress; in `'audio'` mode it is optional (the onset clock drives pacing)
690
+ * and used only as a fallback when the score has no notes. */
691
+ musicMs?: number;
615
692
  /** Cursor stroke colour. Defaults to theme.accent. */
616
693
  color?: string;
617
694
  }
@@ -688,6 +765,24 @@ interface PlayheadLine {
688
765
  alpha: number;
689
766
  }
690
767
  declare function playheadLine(layout: NotationLayout, t01: number): PlayheadLine | null;
768
+ /** Distinct, sorted note onsets from a Score's notes. */
769
+ declare function distinctOnsets(notes: {
770
+ onsetMs: number;
771
+ }[]): number[];
772
+ /**
773
+ * The FOOLPROOF audio-driven playhead line for absolute audio time `tMs`.
774
+ *
775
+ * `tMs` is the audio clock; `onsetsMs` are the score's distinct note onsets
776
+ * (sorted). The cursor is the time-lerp of the two anchors bracketing `tMs`:
777
+ * before the first onset it holds on anchor 0; after the last it holds on the
778
+ * last; in a gap it eases between the bracketing onsets. Pure function of
779
+ * (layout, onsetsMs, tMs). Returns null only when there is no geometry to anchor
780
+ * to (falls back to the rect sweep in the layer, same as `playheadLine`).
781
+ *
782
+ * Fade in/out mirrors `playheadLine` but is keyed on position in the ONSET span
783
+ * (first→last onset), not on musicMs — so the fade tracks the notes too.
784
+ */
785
+ declare function audioPlayheadLine(layout: NotationLayout, onsetsMs: number[], tMs: number): PlayheadLine | null;
691
786
 
692
787
  /** Map a canvas-space box through a base notation layout into world/screen coords. */
693
788
  declare function mapBoxThroughLayout(base: NotationLayout, b: Box): Box;
@@ -1465,4 +1560,4 @@ interface SectionMinimapProps {
1465
1560
  }
1466
1561
  declare const sectionMinimapFactory: LayerFactory<SectionMinimapProps>;
1467
1562
 
1468
- export { type Affine, type AudioClock, type AudioEvent, type BackgroundProps, type BeatGridOpts, type BeatTick, type BrandingProps, type BuildSceneOpts, type BuiltScene, type CameraState, type CaptionCue, type CaptionProps, type CaptionScript, type CaptionStyle, type ChordSpan, type CircleOfFifthsProps, type CirclePoint, type ClickTrackOpts, type ColorBy, type ContourPlot, type ContourPoint, type CountInOpts, type CountingTrackProps, type CtaProps, DEFAULT_FUNCTION_COLORS, type DegreeLabel, type DegreeLabelsProps, type DroneOpts, type DuckWindow, type Easing, type ExtendedDemoOpts, FIFTHS_MAJOR, FIFTHS_MINOR, FOLLOW_BARS, FOLLOW_PAD, type FallingKeyboardDemoOpts, type FallingNotesProps, type FunctionColors, type FunctionalHarmonyProps, type GateError, type GateInput, type HandColors, type HarmonicFunction, type HarmonyMode, type HighlightRegion, type HookProps, type KeyRect, type KeyboardLayout, type KeyboardLayoutOpts, type KeyboardProps, type LabelMode, type Layer, type LayerFactory, type McqCardProps, type NotationEngraving, type NotationLayout, type NotationLayoutOpts, type NotationProps, type NotationRect, type OutputProbe, PIANO_HIGH, PIANO_LOW, type PitchContourProps, type Placement, type PlayheadLine, type PortraitProps, type PromoCardsDemoOpts, type Quiz, type QuizOption, type QuizPhase, type RayEndpoints, type RecordSceneSpecOpts, type Rect, type RenderCtx, type ResolvedSegment, type RevealProps, type SafeGuidesProps, type SceneSpec, type ScheduleTarget, type Score, type ScoreFromMusicXMLOpts, type ScoreNote, type ScrollCursorProps, type Section, type SectionMinimapProps, type SegmentAudio, type SegmentAudioCtx, type SpecLayer, type SpectrumInput, type SpectrumProps, type StaffKeyboardRayProps, type TempoMap, type TimeAnchor, type TimelineSegment, activeChord, activeCue, activeSection, animatedSlot, applySchedule, applyToContext, assertGate, ballArc, ballX, beatGrid, beatPhase, blackKeys, bpmOf, brandingFactory, buildScene, cameraForFollow, cameraTransform, circleOfFifthsDemoSpec, circleOfFifthsFactory, clamp, clickTrackSchedule, contourMinimapDemoSpec, contourPoints, contourPolyline, countInLeadSec, countInSchedule, countdownRemaining, countdownSeconds, countingDegreeDemoSpec, countingTrackFactory, cropAroundBox, ctaFactory, cubicEaseInOut, cueOpacity, degreeLabel, degreeLabelsFactory, dotAt, drawCaption, drawHighlight, droneSchedule, duckGainAt, easeIn, easeInOut, easeOut, fallingKeyboardDemoScore, fallingKeyboardDemoSpec, fallingNotesFactory, firstMeasureBox, followBoxAt, followSrcBox, followWindowStart, fracSlotPoint, frameRect, functionColor, functionalHarmonyFactory, getKeyboardLayout, getLayerFactory, getNotationEngraving, harmonyDemoSpec, highlightIntensity, hookFactory, identityCamera, inRange, invLerp, isBlackKey, kenBurns, keyCenterX, keyColumnWidth, keyRect, keySlot, keyboardFactory, keyboardLayout, lerp, lerpBox, lerpCamera, linear, mapBoxThroughLayout, mcqCardFactory, mcqDemoSpec, measureColumnsFromLayout, measureCount, measureSpanBox, measureSpans, timeToX as minimapTimeToX, msPerBeat, notationFactory, notationLayout, noteColor, noteSetXRange, parseKey, parseTimeSig, pcToSlot, pitchAt, pitchContourFactory, pitchRange, playheadLine, portraitFactory, progress01, projectPoint, promoCardsDemoSpec, quizPhase, rayEndpoints, rayPointAt, recordSceneSpec, registerLayer, registeredKeys, resolveAnchor, resolveKeyboardLayout, resolveTimeline, revealFactory, revealProgress, runGate, safeGuidesFactory, scoreFromMusicXML, scorePitchSpan, scrollCursorFactory, sectionMinimapFactory, setFollowLayoutProvider, setKeyboardLayout, setNotationEngraving, slotAngle, slotPc, slotPoint, spectrumFactory, staffAnchor, staffKeyboardRayFactory, staffRayDemoSpec, validateChordTrack, validateQuiz, validateSections, visualTimelineMs, whiteKeys, worldToViewport };
1563
+ export { type Affine, type AudioClock, type AudioEvent, type BackgroundProps, type BeatGridOpts, type BeatTick, type BrandingProps, type BufferFactory, type BuildSceneOpts, type BuiltScene, type CameraState, type CaptionCue, type CaptionProps, type CaptionScript, type CaptionStyle, type ChordSpan, type CircleOfFifthsProps, type CirclePoint, type ClickTrackOpts, type ColorBy, type ContourPlot, type ContourPoint, type CountInOpts, type CountingTrackProps, type CtaProps, DEFAULT_FUNCTION_COLORS, type DegreeLabel, type DegreeLabelsProps, type DroneOpts, type DuckWindow, type Easing, type ExtendedDemoOpts, FIFTHS_MAJOR, FIFTHS_MINOR, FOLLOW_BARS, FOLLOW_PAD, type FallingKeyboardDemoOpts, type FallingNotesProps, type FunctionColors, type FunctionalHarmonyProps, type GateError, type GateInput, type HandColors, type HarmonicFunction, type HarmonyMode, type HighlightRegion, type HookProps, type KeyRect, type KeyboardLayout, type KeyboardLayoutOpts, type KeyboardProps, type LabelMode, type Layer, type LayerFactory, type McqCardProps, type NotationEngraving, type NotationLayout, type NotationLayoutOpts, type NotationProps, type NotationRect, type OutputProbe, PIANO_HIGH, PIANO_LOW, type PitchContourProps, type Placement, type PlayheadLine, type PortraitProps, type PromoCardsDemoOpts, type Quiz, type QuizOption, type QuizPhase, type RayEndpoints, type RecordSceneSpecOpts, type Rect, type RenderCtx, type ResolvedSegment, type RevealProps, type SafeGuidesProps, type SceneSpec, type ScheduleTarget, type Score, type ScoreFromMusicXMLOpts, type ScoreNote, type ScrollCursorProps, type Section, type SectionMinimapProps, type SegmentAudio, type SegmentAudioCtx, type SegmentTransition, type SpecLayer, type SpectrumInput, type SpectrumProps, type StaffKeyboardRayProps, type TempoMap, type TimeAnchor, type TimelineSegment, activeChord, activeCue, activeSection, animatedSlot, applySchedule, applyToContext, assertGate, audioPlayheadLine, ballArc, ballX, beatGrid, beatPhase, blackKeys, bpmOf, brandingFactory, buildScene, cameraForFollow, cameraTransform, circleOfFifthsDemoSpec, circleOfFifthsFactory, clamp, clickTrackSchedule, contourMinimapDemoSpec, contourPoints, contourPolyline, countInLeadSec, countInSchedule, countdownRemaining, countdownSeconds, countingDegreeDemoSpec, countingTrackFactory, cropAroundBox, ctaFactory, cubicEaseInOut, cueOpacity, degreeLabel, degreeLabelsFactory, distinctOnsets, dotAt, drawCaption, drawHighlight, droneSchedule, duckGainAt, easeIn, easeInOut, easeOut, fallingKeyboardDemoScore, fallingKeyboardDemoSpec, fallingNotesFactory, firstMeasureBox, followBoxAt, followSrcBox, followWindowStart, fracSlotPoint, frameRect, functionColor, functionalHarmonyFactory, getKeyboardLayout, getLayerFactory, getNotationEngraving, harmonyDemoSpec, highlightIntensity, hookFactory, identityCamera, inRange, invLerp, isBlackKey, kenBurns, keyCenterX, keyColumnWidth, keyRect, keySlot, keyboardFactory, keyboardLayout, lerp, lerpBox, lerpCamera, linear, mapBoxThroughLayout, mcqCardFactory, mcqDemoSpec, measureColumnsFromLayout, measureCount, measureSpanBox, measureSpans, timeToX as minimapTimeToX, msPerBeat, notationFactory, notationLayout, noteColor, noteSetXRange, parseKey, parseTimeSig, pcToSlot, pitchAt, pitchContourFactory, pitchRange, playheadLine, portraitFactory, progress01, projectPoint, promoCardsDemoSpec, quizPhase, rayEndpoints, rayPointAt, recordSceneSpec, registerLayer, registeredKeys, resolveAnchor, resolveKeyboardLayout, resolveTimeline, revealFactory, revealProgress, runGate, safeGuidesFactory, scoreFromMusicXML, scorePitchSpan, scrollCursorFactory, sectionMinimapFactory, setFollowLayoutProvider, setKeyboardLayout, setNotationEngraving, slotAngle, slotPc, slotPoint, spectrumFactory, staffAnchor, staffKeyboardRayFactory, staffRayDemoSpec, transitionProgress, validateChordTrack, validateQuiz, validateSections, visualTimelineMs, whiteKeys, worldToViewport };
@@ -177,6 +177,10 @@ var easeOut = (t) => {
177
177
  const c = clamp(t, 0, 1);
178
178
  return 1 - (1 - c) * (1 - c);
179
179
  };
180
+ var transitionProgress = (tMs, startMs, durationMs, easing = easeInOut) => {
181
+ if (durationMs <= 0) return 1;
182
+ return easing(invLerp(startMs, startMs + durationMs, tMs));
183
+ };
180
184
 
181
185
  // src/scene/caption.ts
182
186
  function activeCue(script, tMs) {
@@ -485,6 +489,74 @@ function playheadLine(layout, t01) {
485
489
  }
486
490
  return { x, y0, y1, alpha };
487
491
  }
492
+ var clamp01 = (x) => x < 0 ? 0 : x > 1 ? 1 : x;
493
+ function anchorAtColumnPos(cols, pos, padV) {
494
+ const i = Math.min(cols.length - 1, Math.max(0, Math.floor(pos)));
495
+ const m = cols[i];
496
+ const startX = Math.min(m.noteStartX, m.x + m.w);
497
+ const frac = Math.min(1, Math.max(0, pos - i));
498
+ return {
499
+ onsetMs: 0,
500
+ x: startX + frac * (m.x + m.w - startX),
501
+ y0: m.y - padV,
502
+ y1: m.y + m.h + padV
503
+ };
504
+ }
505
+ function noteAnchors(layout, onsetsMs, padV) {
506
+ const cols = measureColumnsFromLayout(layout.measures);
507
+ if (!cols.length || !onsetsMs.length) return [];
508
+ const n = onsetsMs.length;
509
+ return onsetsMs.map((onsetMs, k) => {
510
+ const frac = n === 1 ? 0 : k / (n - 1);
511
+ const a = anchorAtColumnPos(cols, frac * cols.length, padV);
512
+ return { ...a, onsetMs };
513
+ });
514
+ }
515
+ function distinctOnsets(notes) {
516
+ const set = /* @__PURE__ */ new Set();
517
+ for (const n of notes) set.add(n.onsetMs);
518
+ return [...set].sort((a, b) => a - b);
519
+ }
520
+ function audioPlayheadLine(layout, onsetsMs, tMs) {
521
+ const padV = 10;
522
+ const anchors = noteAnchors(layout, onsetsMs, padV);
523
+ if (!anchors.length) return null;
524
+ const first = anchors[0].onsetMs;
525
+ const last = anchors[anchors.length - 1].onsetMs;
526
+ const span = last - first;
527
+ let a, b, frac;
528
+ if (tMs <= first || anchors.length === 1) {
529
+ a = b = anchors[0];
530
+ frac = 0;
531
+ } else if (tMs >= last) {
532
+ a = b = anchors[anchors.length - 1];
533
+ frac = 0;
534
+ } else {
535
+ let lo = 0;
536
+ let hi = anchors.length - 1;
537
+ while (lo < hi) {
538
+ const mid = lo + hi + 1 >> 1;
539
+ if (anchors[mid].onsetMs <= tMs) lo = mid;
540
+ else hi = mid - 1;
541
+ }
542
+ a = anchors[lo];
543
+ b = anchors[lo + 1];
544
+ const dt = b.onsetMs - a.onsetMs;
545
+ frac = dt > 0 ? cubicEaseInOut((tMs - a.onsetMs) / dt) : 0;
546
+ }
547
+ const x = a.x + (b.x - a.x) * frac;
548
+ const y0 = a.y0 + (b.y0 - a.y0) * frac;
549
+ const y1 = a.y1 + (b.y1 - a.y1) * frac;
550
+ let alpha = 0.85;
551
+ if (span > 0) {
552
+ const fadeIn = Math.min(250, span * 0.5);
553
+ const fadeOut = Math.min(400, span * 0.5);
554
+ if (tMs < first + fadeIn) alpha *= clamp01((tMs - (first - fadeIn)) / (2 * fadeIn));
555
+ if (tMs > last - fadeOut) alpha *= clamp01((last + fadeOut - tMs) / (2 * fadeOut));
556
+ }
557
+ if (alpha <= 0.02) return null;
558
+ return { x, y0, y1, alpha };
559
+ }
488
560
 
489
561
  // src/scene/engravingStore.ts
490
562
  var STORE = /* @__PURE__ */ new WeakMap();
@@ -589,6 +661,7 @@ function followLayoutFor(ctx, camProgress01) {
589
661
  return notationLayout(eng.rendered, ctx.W, ctx.H, eng.bandTop, eng.bandHeight, { focusBox });
590
662
  }
591
663
  function scrollCursorLayer() {
664
+ let mode = "audio";
592
665
  let musicMs = 0;
593
666
  let openingZoomMs = 900;
594
667
  let color;
@@ -597,14 +670,41 @@ function scrollCursorLayer() {
597
670
  if (phMs < 0 || musicMs <= 0) return 0;
598
671
  return Math.min(1, phMs / musicMs);
599
672
  }
673
+ function onsetsFor(ctx) {
674
+ const notes = ctx.score?.notes;
675
+ return notes && notes.length ? distinctOnsets(notes) : [];
676
+ }
677
+ function notesProgress(onsetsMs, tMs) {
678
+ if (tMs < openingZoomMs) return 0;
679
+ const n = onsetsMs.length;
680
+ if (n <= 1) return 0;
681
+ const first = onsetsMs[0];
682
+ const last = onsetsMs[n - 1];
683
+ if (tMs <= first) return 0;
684
+ if (tMs >= last || last <= first) return 1;
685
+ let lo = 0, hi = n - 1;
686
+ while (lo < hi) {
687
+ const mid = lo + hi + 1 >> 1;
688
+ if (onsetsMs[mid] <= tMs) lo = mid;
689
+ else hi = mid - 1;
690
+ }
691
+ const segFrac = (tMs - onsetsMs[lo]) / (onsetsMs[lo + 1] - onsetsMs[lo]);
692
+ return (lo + segFrac) / (n - 1);
693
+ }
694
+ function followProgress(ctx, tMs) {
695
+ if (mode === "linear") return camProgress(tMs);
696
+ const onsets = onsetsFor(ctx);
697
+ return onsets.length ? notesProgress(onsets, tMs) : camProgress(tMs);
698
+ }
600
699
  function layoutAt(ctx, tMs) {
601
700
  const eng = getNotationEngraving(ctx);
602
- return followLayoutFor(ctx, camProgress(tMs)) ?? eng.base;
701
+ return followLayoutFor(ctx, followProgress(ctx, tMs)) ?? eng.base;
603
702
  }
604
703
  return {
605
704
  key: "scroll-cursor",
606
705
  init(ctx, props) {
607
- musicMs = props.musicMs;
706
+ mode = props.mode ?? "audio";
707
+ musicMs = props.musicMs ?? 0;
608
708
  openingZoomMs = props.openingZoomMs ?? 900;
609
709
  color = props.color;
610
710
  setFollowLayoutProvider(ctx, layoutAt);
@@ -612,9 +712,14 @@ function scrollCursorLayer() {
612
712
  draw(ctx, tMs) {
613
713
  const eng = getNotationEngraving(ctx);
614
714
  if (!eng) return;
615
- const p = camProgress(tMs);
616
715
  const layout = layoutAt(ctx, tMs);
617
- const line = playheadLine(layout, p);
716
+ let line;
717
+ const onsets = mode === "audio" ? onsetsFor(ctx) : [];
718
+ if (mode === "audio" && onsets.length) {
719
+ line = audioPlayheadLine(layout, onsets, tMs);
720
+ } else {
721
+ line = playheadLine(layout, camProgress(tMs));
722
+ }
618
723
  if (!line) return;
619
724
  const c = ctx.ctx2d;
620
725
  c.save();
@@ -636,8 +741,12 @@ var scrollCursorFactory = {
636
741
  const errs = [];
637
742
  if (props == null || typeof props !== "object") return ["scroll-cursor: props must be an object"];
638
743
  const p = props;
639
- if (typeof p.musicMs !== "number" || !(p.musicMs > 0))
640
- errs.push("scroll-cursor.musicMs must be a positive number (total music length ms)");
744
+ if (p.mode != null && p.mode !== "audio" && p.mode !== "linear")
745
+ errs.push('scroll-cursor.mode must be "audio" | "linear"');
746
+ if (p.mode === "linear" && (typeof p.musicMs !== "number" || !(p.musicMs > 0)))
747
+ errs.push('scroll-cursor.musicMs must be a positive number for mode:"linear"');
748
+ if (p.musicMs != null && (typeof p.musicMs !== "number" || !(p.musicMs > 0)))
749
+ errs.push("scroll-cursor.musicMs must be a positive number");
641
750
  if (p.followBars != null && (typeof p.followBars !== "number" || p.followBars < 1))
642
751
  errs.push("scroll-cursor.followBars must be a number >= 1");
643
752
  if (p.openingZoomMs != null && (typeof p.openingZoomMs !== "number" || p.openingZoomMs < 0))
@@ -1169,11 +1278,11 @@ function spectrumLayer() {
1169
1278
  const src = fromProp ?? resolveFromCtx(ctx, tMs);
1170
1279
  if (src && src.length) {
1171
1280
  const out = new Array(n);
1172
- for (let b = 0; b < n; b++) out[b] = clamp01(src[Math.min(src.length - 1, b)] ?? 0);
1281
+ for (let b = 0; b < n; b++) out[b] = clamp012(src[Math.min(src.length - 1, b)] ?? 0);
1173
1282
  return out;
1174
1283
  }
1175
1284
  const tSec = tMs / 1e3;
1176
- return Array.from({ length: n }, (_, b) => clamp01(syntheticMagnitude(tSec, b, n)));
1285
+ return Array.from({ length: n }, (_, b) => clamp012(syntheticMagnitude(tSec, b, n)));
1177
1286
  }
1178
1287
  function resolveFromCtx(ctx, tMs) {
1179
1288
  const sp = ctx.spectrum;
@@ -1235,7 +1344,7 @@ function spectrumLayer() {
1235
1344
  }
1236
1345
  };
1237
1346
  }
1238
- function clamp01(x) {
1347
+ function clamp012(x) {
1239
1348
  return x < 0 ? 0 : x > 1 ? 1 : x;
1240
1349
  }
1241
1350
  var spectrumFactory = {
@@ -2748,7 +2857,13 @@ function resolveTimeline(spec, totalSec) {
2748
2857
  return spec.timeline.map((seg) => {
2749
2858
  const startSec = resolveAnchor(seg.at[0], totalSec);
2750
2859
  const endSec = resolveAnchor(seg.at[1], totalSec);
2751
- return { startMs: startSec * 1e3, endMs: endSec * 1e3, layers: seg.layers, audio: seg.audio };
2860
+ return {
2861
+ startMs: startSec * 1e3,
2862
+ endMs: endSec * 1e3,
2863
+ layers: seg.layers,
2864
+ audio: seg.audio,
2865
+ transition: seg.transition
2866
+ };
2752
2867
  });
2753
2868
  }
2754
2869
  function visualTimelineMs(resolved) {
@@ -2764,6 +2879,24 @@ var SCREEN_PINNED_KEYS = /* @__PURE__ */ new Set([
2764
2879
  "safe-guides",
2765
2880
  "mcq-card"
2766
2881
  ]);
2882
+ function defaultBufferFactory() {
2883
+ const g = globalThis;
2884
+ if (typeof g.OffscreenCanvas === "function") {
2885
+ return (w, h) => {
2886
+ const c = new g.OffscreenCanvas(w, h);
2887
+ return { ctx: c.getContext("2d"), image: c };
2888
+ };
2889
+ }
2890
+ if (typeof g.document !== "undefined" && g.document?.createElement) {
2891
+ return (w, h) => {
2892
+ const c = g.document.createElement("canvas");
2893
+ c.width = w;
2894
+ c.height = h;
2895
+ return { ctx: c.getContext("2d"), image: c };
2896
+ };
2897
+ }
2898
+ return void 0;
2899
+ }
2767
2900
  async function buildScene(opts) {
2768
2901
  const { spec, theme, score } = opts;
2769
2902
  const [W, H] = spec.size;
@@ -2771,6 +2904,8 @@ async function buildScene(opts) {
2771
2904
  const resolved = resolveTimeline(spec, opts.totalSec);
2772
2905
  const safe = safeBox(W, H);
2773
2906
  const camera = opts.camera ?? (() => identityCamera(W, H));
2907
+ const bufferFactory = opts.bufferFactory ?? defaultBufferFactory();
2908
+ const hasTransitions = resolved.some((s) => s.transition && s.transition.durationMs > 0);
2774
2909
  const clock = { nowMs: () => 0 };
2775
2910
  const baseCtx = {
2776
2911
  W,
@@ -2782,7 +2917,8 @@ async function buildScene(opts) {
2782
2917
  fps
2783
2918
  };
2784
2919
  const bound = [];
2785
- for (const seg of resolved) {
2920
+ for (let segIndex = 0; segIndex < resolved.length; segIndex++) {
2921
+ const seg = resolved[segIndex];
2786
2922
  for (const sl of seg.layers) {
2787
2923
  const factory = getLayerFactory(sl.k);
2788
2924
  if (!factory) {
@@ -2798,32 +2934,160 @@ async function buildScene(opts) {
2798
2934
  layer,
2799
2935
  startMs: seg.startMs,
2800
2936
  endMs: seg.endMs,
2937
+ segIndex,
2801
2938
  screenPinned: SCREEN_PINNED_KEYS.has(sl.k)
2802
2939
  });
2803
2940
  }
2804
2941
  }
2805
2942
  const durationMs = visualTimelineMs(resolved);
2806
- function renderFrame(ctx2d, tMs) {
2807
- clock.nowMs = () => tMs;
2808
- const ctx = { ...baseCtx, ctx2d };
2809
- const cam = camera(tMs);
2810
- ctx2d.save();
2811
- applyToContext(ctx2d, cam, W, H);
2943
+ function dirVec(dir) {
2944
+ switch (dir) {
2945
+ case "right":
2946
+ return { x: 1, y: 0 };
2947
+ case "up":
2948
+ return { x: 0, y: -1 };
2949
+ case "down":
2950
+ return { x: 0, y: 1 };
2951
+ case "left":
2952
+ default:
2953
+ return { x: -1, y: 0 };
2954
+ }
2955
+ }
2956
+ function transitionEnvs(tr, p) {
2957
+ if (tr.type === "push") {
2958
+ const v = dirVec(tr.direction);
2959
+ const span = Math.abs(v.x) ? W : H;
2960
+ return {
2961
+ incoming: { alpha: 1, dx: -v.x * span * (1 - p), dy: -v.y * span * (1 - p) },
2962
+ outgoing: { alpha: 1, dx: v.x * span * p, dy: v.y * span * p }
2963
+ };
2964
+ }
2965
+ if (tr.type === "wipe") {
2966
+ const v = dirVec(tr.direction);
2967
+ let inClip;
2968
+ let outClip;
2969
+ if (v.x !== 0) {
2970
+ const w = W * p;
2971
+ if (v.x < 0) {
2972
+ inClip = { x: 0, y: 0, w, h: H };
2973
+ outClip = { x: w, y: 0, w: W - w, h: H };
2974
+ } else {
2975
+ inClip = { x: W - w, y: 0, w, h: H };
2976
+ outClip = { x: 0, y: 0, w: W - w, h: H };
2977
+ }
2978
+ } else {
2979
+ const h = H * p;
2980
+ if (v.y < 0) {
2981
+ inClip = { x: 0, y: 0, w: W, h };
2982
+ outClip = { x: 0, y: h, w: W, h: H - h };
2983
+ } else {
2984
+ inClip = { x: 0, y: H - h, w: W, h };
2985
+ outClip = { x: 0, y: 0, w: W, h: H - h };
2986
+ }
2987
+ }
2988
+ return {
2989
+ incoming: { alpha: 1, dx: 0, dy: 0, clip: inClip },
2990
+ outgoing: { alpha: 1, dx: 0, dy: 0, clip: outClip }
2991
+ };
2992
+ }
2993
+ return {
2994
+ incoming: { alpha: p, dx: 0, dy: 0 },
2995
+ outgoing: { alpha: 1 - p, dx: 0, dy: 0 }
2996
+ };
2997
+ }
2998
+ function frameplan(tMs) {
2999
+ const plan = [];
3000
+ for (let i = 0; i < resolved.length; i++) {
3001
+ const seg = resolved[i];
3002
+ if (tMs < seg.startMs || tMs >= seg.endMs) continue;
3003
+ const tr = seg.transition;
3004
+ const prev = i > 0 ? resolved[i - 1] : void 0;
3005
+ const enterEnd = tr ? seg.startMs + Math.max(0, tr.durationMs) : seg.startMs;
3006
+ const inEnter = tr && tr.durationMs > 0 && tMs < enterEnd && prev;
3007
+ if (!inEnter) {
3008
+ plan.push({ segIndex: i, drawTMs: tMs, env: null });
3009
+ continue;
3010
+ }
3011
+ const p = transitionProgress(tMs, seg.startMs, tr.durationMs, tr.easing ?? easeInOut);
3012
+ const { incoming, outgoing } = transitionEnvs(tr, p);
3013
+ const heldTMs = clamp(tMs, prev.startMs, prev.endMs - 1);
3014
+ plan.push({ segIndex: i - 1, drawTMs: heldTMs, env: outgoing });
3015
+ plan.push({ segIndex: i, drawTMs: tMs, env: incoming });
3016
+ }
3017
+ return plan;
3018
+ }
3019
+ function drawSegmentInto(target, segIndex, drawTMs) {
3020
+ const ctx = { ...baseCtx, ctx2d: target };
3021
+ const cam = camera(drawTMs);
3022
+ target.save();
3023
+ applyToContext(target, cam, W, H);
2812
3024
  for (const b of bound) {
2813
- if (b.screenPinned) continue;
2814
- if (tMs < b.startMs || tMs >= b.endMs) continue;
2815
- b.layer.draw(ctx, tMs);
3025
+ if (b.segIndex !== segIndex || b.screenPinned) continue;
3026
+ if (drawTMs < b.startMs || drawTMs >= b.endMs) continue;
3027
+ b.layer.draw(ctx, drawTMs);
2816
3028
  }
2817
- ctx2d.restore();
3029
+ target.restore();
3030
+ target.save();
3031
+ target.setTransform(1, 0, 0, 1, 0, 0);
3032
+ for (const b of bound) {
3033
+ if (b.segIndex !== segIndex || !b.screenPinned) continue;
3034
+ if (drawTMs < b.startMs || drawTMs >= b.endMs) continue;
3035
+ b.layer.draw(ctx, drawTMs);
3036
+ }
3037
+ target.restore();
3038
+ }
3039
+ function compositeEntry(ctx2d, e) {
3040
+ if (e.env === null) {
3041
+ drawSegmentInto(ctx2d, e.segIndex, e.drawTMs);
3042
+ return;
3043
+ }
3044
+ const env = e.env;
3045
+ if (!bufferFactory) {
3046
+ ctx2d.save();
3047
+ ctx2d.globalAlpha = ctx2d.globalAlpha * env.alpha;
3048
+ drawSegmentInto(ctx2d, e.segIndex, e.drawTMs);
3049
+ ctx2d.restore();
3050
+ return;
3051
+ }
3052
+ const buf = bufferFactory(W, H);
3053
+ drawSegmentInto(buf.ctx, e.segIndex, e.drawTMs);
2818
3054
  ctx2d.save();
2819
3055
  ctx2d.setTransform(1, 0, 0, 1, 0, 0);
2820
- for (const b of bound) {
2821
- if (!b.screenPinned) continue;
2822
- if (tMs < b.startMs || tMs >= b.endMs) continue;
2823
- b.layer.draw(ctx, tMs);
3056
+ if (env.alpha !== 1) ctx2d.globalAlpha = ctx2d.globalAlpha * env.alpha;
3057
+ if (env.clip) {
3058
+ ctx2d.beginPath();
3059
+ ctx2d.rect(env.clip.x, env.clip.y, env.clip.w, env.clip.h);
3060
+ ctx2d.clip();
2824
3061
  }
3062
+ ctx2d.drawImage(buf.image, env.dx, env.dy, W, H);
2825
3063
  ctx2d.restore();
2826
3064
  }
3065
+ function renderFrame(ctx2d, tMs) {
3066
+ clock.nowMs = () => tMs;
3067
+ const ctx = { ...baseCtx, ctx2d };
3068
+ const cam = camera(tMs);
3069
+ if (!hasTransitions) {
3070
+ ctx2d.save();
3071
+ applyToContext(ctx2d, cam, W, H);
3072
+ for (const b of bound) {
3073
+ if (b.screenPinned) continue;
3074
+ if (tMs < b.startMs || tMs >= b.endMs) continue;
3075
+ b.layer.draw(ctx, tMs);
3076
+ }
3077
+ ctx2d.restore();
3078
+ ctx2d.save();
3079
+ ctx2d.setTransform(1, 0, 0, 1, 0, 0);
3080
+ for (const b of bound) {
3081
+ if (!b.screenPinned) continue;
3082
+ if (tMs < b.startMs || tMs >= b.endMs) continue;
3083
+ b.layer.draw(ctx, tMs);
3084
+ }
3085
+ ctx2d.restore();
3086
+ return;
3087
+ }
3088
+ const plan = frameplan(tMs);
3089
+ for (const e of plan) compositeEntry(ctx2d, e);
3090
+ }
2827
3091
  function scheduleAudio(instrument, baseSec) {
2828
3092
  let fired = 0;
2829
3093
  for (const seg of resolved) {
@@ -3207,6 +3471,7 @@ export {
3207
3471
  applySchedule,
3208
3472
  applyToContext,
3209
3473
  assertGate,
3474
+ audioPlayheadLine,
3210
3475
  ballArc,
3211
3476
  ballX,
3212
3477
  beatGrid,
@@ -3236,6 +3501,7 @@ export {
3236
3501
  cueOpacity,
3237
3502
  degreeLabel,
3238
3503
  degreeLabelsFactory,
3504
+ distinctOnsets,
3239
3505
  dotAt,
3240
3506
  drawCaption,
3241
3507
  drawHighlight,
@@ -3327,6 +3593,7 @@ export {
3327
3593
  staffAnchor,
3328
3594
  staffKeyboardRayFactory,
3329
3595
  staffRayDemoSpec,
3596
+ transitionProgress,
3330
3597
  validateChordTrack,
3331
3598
  validateQuiz,
3332
3599
  validateSections,