@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.
- package/dist/scene/index.d.ts +100 -5
- package/dist/scene/index.js +292 -25
- package/dist/scene/index.js.map +1 -1
- package/package.json +1 -1
package/dist/scene/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
613
|
-
*
|
|
614
|
-
|
|
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 };
|
package/dist/scene/index.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
640
|
-
errs.push(
|
|
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] =
|
|
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) =>
|
|
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
|
|
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 {
|
|
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 (
|
|
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
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
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 (
|
|
2815
|
-
b.layer.draw(ctx,
|
|
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
|
-
|
|
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
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
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,
|