@real-music-packages/web-core 0.9.7 → 0.11.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/audio.d.ts +9 -2
- package/dist/audio.js +1 -1
- package/dist/chunk-HXTRNE74.js +325 -0
- package/dist/chunk-HXTRNE74.js.map +1 -0
- package/dist/{chunk-BPL5LLQH.js → chunk-JVGAABTK.js} +4 -2
- package/dist/chunk-JVGAABTK.js.map +1 -0
- package/dist/promo.d.ts +17 -2
- package/dist/promo.js +66 -6
- package/dist/promo.js.map +1 -1
- package/dist/scene/index.d.ts +950 -0
- package/dist/scene/index.js +2008 -0
- package/dist/scene/index.js.map +1 -0
- package/dist/video.d.ts +9 -1
- package/dist/video.js +17 -244
- package/dist/video.js.map +1 -1
- package/package.json +10 -1
- package/dist/chunk-BPL5LLQH.js.map +0 -1
|
@@ -0,0 +1,2008 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ctaScene,
|
|
3
|
+
drawSafeGuides,
|
|
4
|
+
hookScene,
|
|
5
|
+
loadPortrait,
|
|
6
|
+
recordScenes,
|
|
7
|
+
revealScene,
|
|
8
|
+
safeBox
|
|
9
|
+
} from "../chunk-HXTRNE74.js";
|
|
10
|
+
|
|
11
|
+
// src/scene/score.ts
|
|
12
|
+
var BEATS_PER_WHOLE_NOTE = 4;
|
|
13
|
+
var MAX_ITERATOR_STEPS = 2e5;
|
|
14
|
+
async function scoreFromMusicXML(xml, opts = {}) {
|
|
15
|
+
const osmd = opts.osmdFactory ? opts.osmdFactory() : await defaultOsmd();
|
|
16
|
+
let loadError = null;
|
|
17
|
+
try {
|
|
18
|
+
await osmd.load(xml);
|
|
19
|
+
} catch (e) {
|
|
20
|
+
loadError = e;
|
|
21
|
+
}
|
|
22
|
+
const sheet = osmd.sheet;
|
|
23
|
+
if (!sheet || !sheet.SourceMeasures?.length) {
|
|
24
|
+
throw new Error(
|
|
25
|
+
"scoreFromMusicXML: OSMD produced no source model" + (loadError ? `: ${loadError.message}` : "")
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
const tempoMap = resolveTempo(sheet, opts);
|
|
29
|
+
const wholeNoteToMs = makeWholeNoteToMs(tempoMap.segments[0].bpm);
|
|
30
|
+
const it = sheet.MusicPartManager.getIterator();
|
|
31
|
+
const notes = [];
|
|
32
|
+
let steps = 0;
|
|
33
|
+
while (!it.EndReached && steps++ < MAX_ITERATOR_STEPS) {
|
|
34
|
+
const enrolled = it.CurrentEnrolledTimestamp?.RealValue ?? 0;
|
|
35
|
+
const onsetMs = wholeNoteToMs(enrolled);
|
|
36
|
+
const voiceEntries = it.CurrentAudibleVoiceEntries?.() ?? [];
|
|
37
|
+
for (const ve of voiceEntries) {
|
|
38
|
+
const voiceId = ve.ParentVoice?.VoiceId ?? 1;
|
|
39
|
+
const sse = ve.ParentSourceStaffEntry;
|
|
40
|
+
const staffIdx = sse?.ParentStaff?.idInMusicSheet ?? 0;
|
|
41
|
+
const instrument = sse?.ParentStaff?.ParentInstrument;
|
|
42
|
+
const hand = inferHand(instrument, sse?.ParentStaff);
|
|
43
|
+
for (const n of ve.Notes ?? []) {
|
|
44
|
+
if (n.isRestFlag || n.IsRest) continue;
|
|
45
|
+
const pitch = n.Pitch;
|
|
46
|
+
if (!pitch) continue;
|
|
47
|
+
const tie = n.NoteTie;
|
|
48
|
+
if (tie && tie.StartNote && tie.StartNote !== n) continue;
|
|
49
|
+
const wholeNoteLen = tie && tie.StartNote === n && tie.Duration ? tie.Duration.RealValue : n.Length?.RealValue ?? 0;
|
|
50
|
+
const halfTone = pitch.getHalfTone?.();
|
|
51
|
+
const pitchMidi = halfTone == null ? NaN : halfTone + 12;
|
|
52
|
+
const note = {
|
|
53
|
+
pitchMidi,
|
|
54
|
+
step: fundamentalToStep(pitch.FundamentalNote),
|
|
55
|
+
alter: pitch.AccidentalHalfTones ?? 0,
|
|
56
|
+
octave: octaveFromMidi(pitchMidi),
|
|
57
|
+
onsetMs: Math.round(onsetMs),
|
|
58
|
+
durMs: Math.round(wholeNoteToMs(wholeNoteLen)),
|
|
59
|
+
staff: staffIdx,
|
|
60
|
+
voice: voiceId,
|
|
61
|
+
hand
|
|
62
|
+
};
|
|
63
|
+
const lyric = extractLyric(ve);
|
|
64
|
+
if (lyric != null) note.lyric = lyric;
|
|
65
|
+
const fingering = extractFingering(n);
|
|
66
|
+
if (fingering != null) note.fingering = fingering;
|
|
67
|
+
notes.push(note);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
it.moveToNext();
|
|
71
|
+
}
|
|
72
|
+
notes.sort((a, b) => a.onsetMs - b.onsetMs || a.pitchMidi - b.pitchMidi);
|
|
73
|
+
const durationMs = notes.reduce((mx, n) => Math.max(mx, n.onsetMs + n.durMs), 0);
|
|
74
|
+
return {
|
|
75
|
+
notes,
|
|
76
|
+
tempoMap,
|
|
77
|
+
durationMs,
|
|
78
|
+
key: readKey(sheet),
|
|
79
|
+
timeSig: readTimeSig(sheet),
|
|
80
|
+
title: sheet.TitleString || void 0,
|
|
81
|
+
composer: sheet.ComposerString || void 0
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function resolveTempo(sheet, opts) {
|
|
85
|
+
if (opts.tempoOverride && opts.tempoOverride > 0) {
|
|
86
|
+
return { source: "override", segments: [{ atMs: 0, bpm: opts.tempoOverride }] };
|
|
87
|
+
}
|
|
88
|
+
const xmlTempo = sheet.DefaultStartTempoInBpm;
|
|
89
|
+
if (xmlTempo && xmlTempo > 0) {
|
|
90
|
+
return { source: "xml", segments: [{ atMs: 0, bpm: xmlTempo }] };
|
|
91
|
+
}
|
|
92
|
+
return { source: "fallback", segments: [{ atMs: 0, bpm: opts.tempoFallback ?? 100 }] };
|
|
93
|
+
}
|
|
94
|
+
function makeWholeNoteToMs(bpm) {
|
|
95
|
+
const msPerBeat = 6e4 / bpm;
|
|
96
|
+
return (wholeNotes) => wholeNotes * BEATS_PER_WHOLE_NOTE * msPerBeat;
|
|
97
|
+
}
|
|
98
|
+
function inferHand(instrument, staff) {
|
|
99
|
+
const staves = instrument?.Staves;
|
|
100
|
+
if (Array.isArray(staves) && staves.length >= 2 && staff) {
|
|
101
|
+
const local = staves.indexOf(staff);
|
|
102
|
+
if (local >= 0) return local === 0 ? "R" : "L";
|
|
103
|
+
}
|
|
104
|
+
return "R";
|
|
105
|
+
}
|
|
106
|
+
function fundamentalToStep(f) {
|
|
107
|
+
return { 0: "C", 2: "D", 4: "E", 5: "F", 7: "G", 9: "A", 11: "B" }[f] ?? "?";
|
|
108
|
+
}
|
|
109
|
+
function octaveFromMidi(midi) {
|
|
110
|
+
if (!Number.isFinite(midi)) return 0;
|
|
111
|
+
return Math.floor(midi / 12) - 1;
|
|
112
|
+
}
|
|
113
|
+
function extractLyric(ve) {
|
|
114
|
+
const dict = ve.LyricsEntries;
|
|
115
|
+
if (!dict || !dict.size) return void 0;
|
|
116
|
+
const first = [...dict.values()][0];
|
|
117
|
+
const text = first?.Text?.text ?? first?.Text;
|
|
118
|
+
return typeof text === "string" && text.length ? text : void 0;
|
|
119
|
+
}
|
|
120
|
+
function extractFingering(note) {
|
|
121
|
+
const raw = note?.Fingering?.value ?? note?.Fingering?.Value ?? note?.Fingering;
|
|
122
|
+
const n = typeof raw === "string" ? parseInt(raw, 10) : typeof raw === "number" ? raw : NaN;
|
|
123
|
+
return Number.isFinite(n) ? n : void 0;
|
|
124
|
+
}
|
|
125
|
+
var MAJOR_KEYS = ["Cb", "Gb", "Db", "Ab", "Eb", "Bb", "F", "C", "G", "D", "A", "E", "B", "F#", "C#"];
|
|
126
|
+
var MINOR_KEYS = ["Ab", "Eb", "Bb", "F", "C", "G", "D", "A", "E", "B", "F#", "C#", "G#", "D#", "A#"];
|
|
127
|
+
function readKey(sheet) {
|
|
128
|
+
for (const entry of sheet?.SourceMeasures?.[0]?.FirstInstructionsStaffEntries ?? []) {
|
|
129
|
+
for (const ins of entry?.Instructions ?? []) {
|
|
130
|
+
if (typeof ins?.keyType === "number") {
|
|
131
|
+
const fifths = ins.keyType;
|
|
132
|
+
const idx = fifths + 7;
|
|
133
|
+
if (idx < 0 || idx > 14) return void 0;
|
|
134
|
+
const isMinor = ins.mode === 2;
|
|
135
|
+
const name = (isMinor ? MINOR_KEYS : MAJOR_KEYS)[idx];
|
|
136
|
+
return name ? `${name} ${isMinor ? "minor" : "major"}` : void 0;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return void 0;
|
|
141
|
+
}
|
|
142
|
+
function readTimeSig(sheet) {
|
|
143
|
+
const ts = sheet?.SourceMeasures?.[0]?.ActiveTimeSignature;
|
|
144
|
+
if (ts && Number.isFinite(ts.Numerator) && Number.isFinite(ts.Denominator)) {
|
|
145
|
+
return `${ts.Numerator}/${ts.Denominator}`;
|
|
146
|
+
}
|
|
147
|
+
return void 0;
|
|
148
|
+
}
|
|
149
|
+
async function defaultOsmd() {
|
|
150
|
+
if (typeof document === "undefined") {
|
|
151
|
+
throw new Error(
|
|
152
|
+
"scoreFromMusicXML: no DOM. Call setupHeadlessDom() first (Node), or pass opts.osmdFactory."
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
const mod = await import("opensheetmusicdisplay");
|
|
156
|
+
const OpenSheetMusicDisplay = mod.OpenSheetMusicDisplay ?? mod.default?.OpenSheetMusicDisplay;
|
|
157
|
+
return new OpenSheetMusicDisplay(document.createElement("div"), {
|
|
158
|
+
backend: "svg",
|
|
159
|
+
autoResize: false
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/scene/headless.ts
|
|
164
|
+
var installed = false;
|
|
165
|
+
async function setupHeadlessDom() {
|
|
166
|
+
if (installed) return;
|
|
167
|
+
const g = globalThis;
|
|
168
|
+
if (typeof g.document !== "undefined" && typeof g.window !== "undefined") {
|
|
169
|
+
ensureFakeContext(g.window);
|
|
170
|
+
installed = true;
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const { JSDOM } = await import("jsdom");
|
|
174
|
+
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
|
|
175
|
+
pretendToBeVisual: true
|
|
176
|
+
});
|
|
177
|
+
const { window } = dom;
|
|
178
|
+
ensureFakeContext(window);
|
|
179
|
+
g.window = window;
|
|
180
|
+
g.document = window.document;
|
|
181
|
+
try {
|
|
182
|
+
g.navigator = window.navigator;
|
|
183
|
+
} catch {
|
|
184
|
+
}
|
|
185
|
+
g.HTMLElement = window.HTMLElement;
|
|
186
|
+
g.Node = window.Node;
|
|
187
|
+
g.DOMParser = window.DOMParser;
|
|
188
|
+
g.XMLSerializer = window.XMLSerializer;
|
|
189
|
+
g.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0);
|
|
190
|
+
g.cancelAnimationFrame = () => {
|
|
191
|
+
};
|
|
192
|
+
installed = true;
|
|
193
|
+
}
|
|
194
|
+
function ensureFakeContext(window) {
|
|
195
|
+
const proto = window.HTMLCanvasElement?.prototype;
|
|
196
|
+
if (!proto) return;
|
|
197
|
+
const fakeCtx = makeFakeContext();
|
|
198
|
+
proto.getContext = function() {
|
|
199
|
+
return fakeCtx;
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
function makeFakeContext() {
|
|
203
|
+
return {
|
|
204
|
+
font: "10px Arial",
|
|
205
|
+
fillStyle: "#000",
|
|
206
|
+
strokeStyle: "#000",
|
|
207
|
+
lineWidth: 1,
|
|
208
|
+
textAlign: "left",
|
|
209
|
+
textBaseline: "alphabetic",
|
|
210
|
+
globalAlpha: 1,
|
|
211
|
+
measureText: (s) => ({
|
|
212
|
+
width: (s ? s.length : 0) * 6,
|
|
213
|
+
actualBoundingBoxAscent: 8,
|
|
214
|
+
actualBoundingBoxDescent: 2
|
|
215
|
+
}),
|
|
216
|
+
save() {
|
|
217
|
+
},
|
|
218
|
+
restore() {
|
|
219
|
+
},
|
|
220
|
+
beginPath() {
|
|
221
|
+
},
|
|
222
|
+
closePath() {
|
|
223
|
+
},
|
|
224
|
+
moveTo() {
|
|
225
|
+
},
|
|
226
|
+
lineTo() {
|
|
227
|
+
},
|
|
228
|
+
bezierCurveTo() {
|
|
229
|
+
},
|
|
230
|
+
quadraticCurveTo() {
|
|
231
|
+
},
|
|
232
|
+
arc() {
|
|
233
|
+
},
|
|
234
|
+
rect() {
|
|
235
|
+
},
|
|
236
|
+
fill() {
|
|
237
|
+
},
|
|
238
|
+
stroke() {
|
|
239
|
+
},
|
|
240
|
+
fillRect() {
|
|
241
|
+
},
|
|
242
|
+
clearRect() {
|
|
243
|
+
},
|
|
244
|
+
fillText() {
|
|
245
|
+
},
|
|
246
|
+
strokeText() {
|
|
247
|
+
},
|
|
248
|
+
translate() {
|
|
249
|
+
},
|
|
250
|
+
rotate() {
|
|
251
|
+
},
|
|
252
|
+
scale() {
|
|
253
|
+
},
|
|
254
|
+
setTransform() {
|
|
255
|
+
},
|
|
256
|
+
transform() {
|
|
257
|
+
},
|
|
258
|
+
drawImage() {
|
|
259
|
+
},
|
|
260
|
+
clip() {
|
|
261
|
+
},
|
|
262
|
+
createLinearGradient: () => ({ addColorStop() {
|
|
263
|
+
} }),
|
|
264
|
+
getImageData: () => ({ data: new Uint8ClampedArray(4) })
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// src/scene/math.ts
|
|
269
|
+
var clamp = (x, lo, hi) => x < lo ? lo : x > hi ? hi : x;
|
|
270
|
+
var lerp = (a, b, t) => a + (b - a) * t;
|
|
271
|
+
var invLerp = (a, b, x) => a === b ? 0 : clamp((x - a) / (b - a), 0, 1);
|
|
272
|
+
var linear = (t) => t;
|
|
273
|
+
var easeInOut = (t) => {
|
|
274
|
+
const c = clamp(t, 0, 1);
|
|
275
|
+
return c * c * (3 - 2 * c);
|
|
276
|
+
};
|
|
277
|
+
var easeIn = (t) => {
|
|
278
|
+
const c = clamp(t, 0, 1);
|
|
279
|
+
return c * c;
|
|
280
|
+
};
|
|
281
|
+
var easeOut = (t) => {
|
|
282
|
+
const c = clamp(t, 0, 1);
|
|
283
|
+
return 1 - (1 - c) * (1 - c);
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// src/scene/caption.ts
|
|
287
|
+
function activeCue(script, tMs) {
|
|
288
|
+
let found = null;
|
|
289
|
+
for (const cue of script) {
|
|
290
|
+
if (tMs >= cue.inMs && tMs < cue.outMs) found = cue;
|
|
291
|
+
}
|
|
292
|
+
return found;
|
|
293
|
+
}
|
|
294
|
+
function cueOpacity(cue, tMs, fadeMs = 150) {
|
|
295
|
+
if (tMs < cue.inMs || tMs >= cue.outMs) return 0;
|
|
296
|
+
const inA = invLerp(cue.inMs, cue.inMs + fadeMs, tMs);
|
|
297
|
+
const outA = 1 - invLerp(cue.outMs - fadeMs, cue.outMs, tMs);
|
|
298
|
+
return clamp(Math.min(inA, outA), 0, 1);
|
|
299
|
+
}
|
|
300
|
+
function wrap(ctx, text, maxW, maxLines) {
|
|
301
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
302
|
+
const lines = [];
|
|
303
|
+
let cur = "";
|
|
304
|
+
for (const w of words) {
|
|
305
|
+
const next = cur ? `${cur} ${w}` : w;
|
|
306
|
+
if (ctx.measureText(next).width > maxW && cur) {
|
|
307
|
+
lines.push(cur);
|
|
308
|
+
cur = w;
|
|
309
|
+
if (lines.length === maxLines) break;
|
|
310
|
+
} else {
|
|
311
|
+
cur = next;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (cur && lines.length < maxLines) lines.push(cur);
|
|
315
|
+
return lines;
|
|
316
|
+
}
|
|
317
|
+
function drawCaption(ctx, script, tMs, safe, theme, style = {}) {
|
|
318
|
+
const cue = activeCue(script, tMs);
|
|
319
|
+
if (!cue) return;
|
|
320
|
+
const alpha = cueOpacity(cue, tMs);
|
|
321
|
+
if (alpha <= 0) return;
|
|
322
|
+
const size = style.size ?? 44;
|
|
323
|
+
const yFrac = style.yFrac ?? 0.92;
|
|
324
|
+
const baseY = safe.top + safe.h * yFrac;
|
|
325
|
+
ctx.save();
|
|
326
|
+
ctx.globalAlpha = alpha;
|
|
327
|
+
ctx.textAlign = "center";
|
|
328
|
+
ctx.textBaseline = "middle";
|
|
329
|
+
ctx.font = `bold ${size}px ${theme.fontBody}`;
|
|
330
|
+
const lines = wrap(ctx, cue.text, safe.w * 0.92, 3);
|
|
331
|
+
const lineH = size * 1.2;
|
|
332
|
+
const blockH = lines.length * lineH;
|
|
333
|
+
const top = baseY - blockH;
|
|
334
|
+
if (style.pill !== false) {
|
|
335
|
+
let maxW = 0;
|
|
336
|
+
for (const ln of lines) maxW = Math.max(maxW, ctx.measureText(ln).width);
|
|
337
|
+
const padX = size * 0.5;
|
|
338
|
+
const padY = size * 0.3;
|
|
339
|
+
ctx.fillStyle = "rgba(0,0,0,0.55)";
|
|
340
|
+
const pillW = Math.min(safe.w, maxW + padX * 2);
|
|
341
|
+
ctx.fillRect(safe.cx - pillW / 2, top - padY, pillW, blockH + padY * 2);
|
|
342
|
+
}
|
|
343
|
+
ctx.fillStyle = "#ffffff";
|
|
344
|
+
lines.forEach((ln, i) => {
|
|
345
|
+
ctx.fillText(ln, safe.cx, top + lineH * (i + 0.5));
|
|
346
|
+
});
|
|
347
|
+
ctx.restore();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// src/scene/layers/demo.ts
|
|
351
|
+
function backgroundLayer() {
|
|
352
|
+
let fill = "#000000";
|
|
353
|
+
return {
|
|
354
|
+
key: "background",
|
|
355
|
+
init(ctx, props) {
|
|
356
|
+
const s = props.style ?? "paper";
|
|
357
|
+
fill = s === "paper" ? ctx.theme.paper : s === "ink" ? ctx.theme.ink : s;
|
|
358
|
+
},
|
|
359
|
+
draw(ctx) {
|
|
360
|
+
const c = ctx.ctx2d;
|
|
361
|
+
c.save();
|
|
362
|
+
c.fillStyle = fill;
|
|
363
|
+
c.fillRect(0, 0, ctx.W, ctx.H);
|
|
364
|
+
c.restore();
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
var backgroundFactory = {
|
|
369
|
+
key: "background",
|
|
370
|
+
create: backgroundLayer,
|
|
371
|
+
validateProps(props) {
|
|
372
|
+
const errs = [];
|
|
373
|
+
if (props == null || typeof props !== "object") return ["background: props must be an object"];
|
|
374
|
+
const p = props;
|
|
375
|
+
if (p.style != null && typeof p.style !== "string") errs.push("background.style must be a string");
|
|
376
|
+
return errs;
|
|
377
|
+
}
|
|
378
|
+
};
|
|
379
|
+
function captionLayer() {
|
|
380
|
+
let script = [];
|
|
381
|
+
let size;
|
|
382
|
+
let yFrac;
|
|
383
|
+
return {
|
|
384
|
+
key: "caption",
|
|
385
|
+
init(_ctx, props) {
|
|
386
|
+
script = props.script ?? [];
|
|
387
|
+
size = props.size;
|
|
388
|
+
yFrac = props.yFrac;
|
|
389
|
+
},
|
|
390
|
+
draw(ctx, tMs) {
|
|
391
|
+
drawCaption(ctx.ctx2d, script, tMs, ctx.safeBox, ctx.theme, { size, yFrac });
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
var captionFactory = {
|
|
396
|
+
key: "caption",
|
|
397
|
+
create: captionLayer,
|
|
398
|
+
validateProps(props) {
|
|
399
|
+
const errs = [];
|
|
400
|
+
if (props == null || typeof props !== "object") return ["caption: props must be an object"];
|
|
401
|
+
const p = props;
|
|
402
|
+
if (!Array.isArray(p.script)) {
|
|
403
|
+
errs.push("caption.script must be an array of cues");
|
|
404
|
+
} else {
|
|
405
|
+
p.script.forEach((cue, i) => {
|
|
406
|
+
const c = cue;
|
|
407
|
+
if (typeof c?.text !== "string") errs.push(`caption.script[${i}].text must be a string`);
|
|
408
|
+
if (typeof c?.inMs !== "number" || typeof c?.outMs !== "number")
|
|
409
|
+
errs.push(`caption.script[${i}] needs numeric inMs/outMs`);
|
|
410
|
+
else if (c.outMs <= c.inMs) errs.push(`caption.script[${i}] outMs must be > inMs`);
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
if (p.size != null && typeof p.size !== "number") errs.push("caption.size must be a number");
|
|
414
|
+
if (p.yFrac != null && typeof p.yFrac !== "number") errs.push("caption.yFrac must be a number");
|
|
415
|
+
return errs;
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
// src/scene/notationGeometry.ts
|
|
420
|
+
var FOLLOW_BARS = 2;
|
|
421
|
+
var FOLLOW_PAD = 1.06;
|
|
422
|
+
function cubicEaseInOut(t) {
|
|
423
|
+
if (t < 0.5) return 4 * t * t * t;
|
|
424
|
+
const f = 2 * t - 2;
|
|
425
|
+
return 0.5 * f * f * f + 1;
|
|
426
|
+
}
|
|
427
|
+
function lerpBox(a, b, e) {
|
|
428
|
+
return {
|
|
429
|
+
x: a.x + (b.x - a.x) * e,
|
|
430
|
+
y: a.y + (b.y - a.y) * e,
|
|
431
|
+
w: a.w + (b.w - a.w) * e,
|
|
432
|
+
h: a.h + (b.h - a.h) * e
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
function cropAroundBox(box, aspect, pad, cw, ch) {
|
|
436
|
+
const bw = box.w * pad;
|
|
437
|
+
const bh = box.h * pad;
|
|
438
|
+
let w = Math.max(bw, bh * aspect);
|
|
439
|
+
let h = w / aspect;
|
|
440
|
+
w = Math.min(w, cw);
|
|
441
|
+
h = Math.min(h, ch);
|
|
442
|
+
const ccx = box.x + box.w / 2;
|
|
443
|
+
const ccy = box.y + box.h / 2;
|
|
444
|
+
let x = ccx - w / 2;
|
|
445
|
+
let y = ccy - h / 2;
|
|
446
|
+
x = Math.max(0, Math.min(cw - w, x));
|
|
447
|
+
y = Math.max(0, Math.min(ch - h, y));
|
|
448
|
+
return { x, y, w, h };
|
|
449
|
+
}
|
|
450
|
+
function measureSpanBox(rn, lo, hi) {
|
|
451
|
+
const ms = (rn.measures ?? []).filter((m) => m.index >= lo && m.index < hi).map((m) => m.box);
|
|
452
|
+
if (!ms.length) return null;
|
|
453
|
+
const x0 = Math.min(...ms.map((b) => b.x));
|
|
454
|
+
const y0 = Math.min(...ms.map((b) => b.y));
|
|
455
|
+
const x1 = Math.max(...ms.map((b) => b.x + b.w));
|
|
456
|
+
const y1 = Math.max(...ms.map((b) => b.y + b.h));
|
|
457
|
+
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
|
|
458
|
+
}
|
|
459
|
+
function firstMeasureBox(rn) {
|
|
460
|
+
const first = (rn.measures ?? []).filter((m) => m.index === 0).map((m) => m.box);
|
|
461
|
+
if (!first.length) return rn.systems?.[0] ?? null;
|
|
462
|
+
const x0 = Math.min(...first.map((b) => b.x));
|
|
463
|
+
const y0 = Math.min(...first.map((b) => b.y));
|
|
464
|
+
const x1 = Math.max(...first.map((b) => b.x + b.w));
|
|
465
|
+
const y1 = Math.max(...first.map((b) => b.y + b.h));
|
|
466
|
+
return { x: x0, y: y0, w: x1 - x0, h: y1 - y0 };
|
|
467
|
+
}
|
|
468
|
+
function measureCount(rn) {
|
|
469
|
+
const idx = (rn.measures ?? []).map((m) => m.index);
|
|
470
|
+
return idx.length ? Math.max(...idx) + 1 : 0;
|
|
471
|
+
}
|
|
472
|
+
function followBoxAt(rn, posMeasures) {
|
|
473
|
+
const cur = Math.floor(posMeasures);
|
|
474
|
+
const frac = posMeasures - cur;
|
|
475
|
+
const a = measureSpanBox(rn, cur, cur + FOLLOW_BARS);
|
|
476
|
+
const b = measureSpanBox(rn, cur + 1, cur + 1 + FOLLOW_BARS) ?? a;
|
|
477
|
+
if (!a) return b;
|
|
478
|
+
if (!b) return a;
|
|
479
|
+
return lerpBox(a, b, frac);
|
|
480
|
+
}
|
|
481
|
+
function followWindowStart(nBars, camProgress01) {
|
|
482
|
+
const posBars = camProgress01 * nBars;
|
|
483
|
+
const curBar = Math.floor(posBars);
|
|
484
|
+
const frac = posBars - curBar;
|
|
485
|
+
const scrollFrac = cubicEaseInOut(Math.min(1, Math.max(0, (frac - 0.66) / 0.34)));
|
|
486
|
+
const maxStart = Math.max(0, nBars - FOLLOW_BARS);
|
|
487
|
+
return Math.max(0, Math.min(maxStart, curBar + scrollFrac));
|
|
488
|
+
}
|
|
489
|
+
function notationLayout(rn, W, H, boxTop, boxH, opts = {}) {
|
|
490
|
+
const { zoom01 = 1, focusBox = null } = opts;
|
|
491
|
+
const sb = safeBox(W, H);
|
|
492
|
+
const maxW = sb.centeredW;
|
|
493
|
+
const c = rn.content && rn.content.w > 0 && rn.content.h > 0 ? rn.content : { x: 0, y: 0, w: rn.canvas.width || 1400, h: rn.canvas.height || 300 };
|
|
494
|
+
let src;
|
|
495
|
+
if (focusBox) {
|
|
496
|
+
const px = focusBox.w * (FOLLOW_PAD - 1) / 2;
|
|
497
|
+
const py = focusBox.h * (FOLLOW_PAD - 1) / 2;
|
|
498
|
+
src = {
|
|
499
|
+
x: Math.max(0, focusBox.x - px),
|
|
500
|
+
y: Math.max(0, focusBox.y - py),
|
|
501
|
+
w: focusBox.w + 2 * px,
|
|
502
|
+
h: focusBox.h + 2 * py
|
|
503
|
+
};
|
|
504
|
+
src.w = Math.min(src.w, rn.canvas.width - src.x);
|
|
505
|
+
src.h = Math.min(src.h, rn.canvas.height - src.y);
|
|
506
|
+
} else {
|
|
507
|
+
src = c;
|
|
508
|
+
const focal = firstMeasureBox(rn);
|
|
509
|
+
if (zoom01 < 1 && focal) {
|
|
510
|
+
const start = cropAroundBox(focal, c.w / c.h, 1.25, rn.canvas.width, rn.canvas.height);
|
|
511
|
+
src = lerpBox(start, c, cubicEaseInOut(zoom01));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
const srcAspect = src.w / src.h;
|
|
515
|
+
let dw = maxW;
|
|
516
|
+
let dh = dw / srcAspect;
|
|
517
|
+
if (dh > boxH) {
|
|
518
|
+
dh = boxH;
|
|
519
|
+
dw = dh * srcAspect;
|
|
520
|
+
}
|
|
521
|
+
const dx = (W - dw) / 2;
|
|
522
|
+
const dy = boxTop + (boxH - dh) / 2;
|
|
523
|
+
const fx = dw / src.w;
|
|
524
|
+
const fy = dh / src.h;
|
|
525
|
+
const map = (b) => ({
|
|
526
|
+
x: dx + (b.x - src.x) * fx,
|
|
527
|
+
y: dy + (b.y - src.y) * fy,
|
|
528
|
+
w: b.w * fx,
|
|
529
|
+
h: b.h * fy
|
|
530
|
+
});
|
|
531
|
+
const systems = (rn.systems ?? []).map(map);
|
|
532
|
+
const measures = (rn.measures ?? []).map((m) => ({
|
|
533
|
+
...m,
|
|
534
|
+
box: map(m.box),
|
|
535
|
+
noteStartX: dx + (m.noteStartX - src.x) * fx
|
|
536
|
+
}));
|
|
537
|
+
return { src, rect: { dx, dy, dw, dh }, systems, measures };
|
|
538
|
+
}
|
|
539
|
+
function measureColumnsFromLayout(measures) {
|
|
540
|
+
const byIndex = /* @__PURE__ */ new Map();
|
|
541
|
+
for (const m of measures) {
|
|
542
|
+
const cur = byIndex.get(m.index);
|
|
543
|
+
if (!cur) {
|
|
544
|
+
byIndex.set(m.index, { ...m.box, noteStartX: m.noteStartX });
|
|
545
|
+
} else {
|
|
546
|
+
const x0 = Math.min(cur.x, m.box.x);
|
|
547
|
+
const y0 = Math.min(cur.y, m.box.y);
|
|
548
|
+
const x1 = Math.max(cur.x + cur.w, m.box.x + m.box.w);
|
|
549
|
+
const y1 = Math.max(cur.y + cur.h, m.box.y + m.box.h);
|
|
550
|
+
byIndex.set(m.index, {
|
|
551
|
+
x: x0,
|
|
552
|
+
y: y0,
|
|
553
|
+
w: x1 - x0,
|
|
554
|
+
h: y1 - y0,
|
|
555
|
+
noteStartX: Math.min(cur.noteStartX, m.noteStartX)
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return [...byIndex.keys()].sort((a, b) => a - b).map((k) => byIndex.get(k));
|
|
560
|
+
}
|
|
561
|
+
function playheadLine(layout, t01) {
|
|
562
|
+
const tt = Math.max(0, Math.min(1, t01));
|
|
563
|
+
let alpha = 0.85;
|
|
564
|
+
if (tt < 0.03) alpha *= tt / 0.03;
|
|
565
|
+
if (tt > 0.94) alpha *= Math.max(0, (1 - tt) / 0.06);
|
|
566
|
+
if (alpha <= 0.02) return null;
|
|
567
|
+
const padV = 10;
|
|
568
|
+
let x, y0, y1;
|
|
569
|
+
const cols = measureColumnsFromLayout(layout.measures);
|
|
570
|
+
if (cols.length) {
|
|
571
|
+
const pos = tt * cols.length;
|
|
572
|
+
const i = Math.min(cols.length - 1, Math.floor(pos));
|
|
573
|
+
const m = cols[i];
|
|
574
|
+
const startX = Math.min(m.noteStartX, m.x + m.w);
|
|
575
|
+
x = startX + (pos - i) * (m.x + m.w - startX);
|
|
576
|
+
y0 = m.y - padV;
|
|
577
|
+
y1 = m.y + m.h + padV;
|
|
578
|
+
} else if (layout.systems.length) {
|
|
579
|
+
const pos = tt * layout.systems.length;
|
|
580
|
+
const row = Math.min(layout.systems.length - 1, Math.floor(pos));
|
|
581
|
+
const s = layout.systems[row];
|
|
582
|
+
x = s.x + (pos - row) * s.w;
|
|
583
|
+
y0 = s.y - padV;
|
|
584
|
+
y1 = s.y + s.h + padV;
|
|
585
|
+
} else {
|
|
586
|
+
const r = layout.rect;
|
|
587
|
+
x = r.dx + tt * r.dw;
|
|
588
|
+
y0 = r.dy - padV;
|
|
589
|
+
y1 = r.dy + r.dh + padV;
|
|
590
|
+
}
|
|
591
|
+
return { x, y0, y1, alpha };
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// src/scene/engravingStore.ts
|
|
595
|
+
var STORE = /* @__PURE__ */ new WeakMap();
|
|
596
|
+
function keyFor(ctx) {
|
|
597
|
+
return ctx.audioClock;
|
|
598
|
+
}
|
|
599
|
+
function setNotationEngraving(ctx, eng) {
|
|
600
|
+
STORE.set(keyFor(ctx), eng);
|
|
601
|
+
}
|
|
602
|
+
function getNotationEngraving(ctx) {
|
|
603
|
+
return STORE.get(keyFor(ctx));
|
|
604
|
+
}
|
|
605
|
+
function setFollowLayoutProvider(ctx, fn) {
|
|
606
|
+
const e = STORE.get(keyFor(ctx));
|
|
607
|
+
if (e) e.followLayoutAt = fn;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/scene/layers/notation.ts
|
|
611
|
+
function notationLayer() {
|
|
612
|
+
let rn = null;
|
|
613
|
+
let base = null;
|
|
614
|
+
let propScale = 1;
|
|
615
|
+
let propBandTop;
|
|
616
|
+
let propBandHeight;
|
|
617
|
+
function bandTop(_ctx, sb) {
|
|
618
|
+
return propBandTop ?? sb.top;
|
|
619
|
+
}
|
|
620
|
+
function bandHeight(ctx, sb) {
|
|
621
|
+
const top = bandTop(ctx, sb);
|
|
622
|
+
return (propBandHeight ?? sb.bottom - top) * propScale;
|
|
623
|
+
}
|
|
624
|
+
return {
|
|
625
|
+
key: "notation",
|
|
626
|
+
async init(ctx, props) {
|
|
627
|
+
propScale = props.scale ?? 1;
|
|
628
|
+
propBandTop = props.bandTop;
|
|
629
|
+
propBandHeight = props.bandHeight;
|
|
630
|
+
if (props.rendered) {
|
|
631
|
+
rn = props.rendered;
|
|
632
|
+
} else if (props.xml) {
|
|
633
|
+
const { renderNotation } = await import("../promo.js");
|
|
634
|
+
rn = await renderNotation(props.xml, { bars: props.bars, paper: ctx.theme.paper });
|
|
635
|
+
} else {
|
|
636
|
+
throw new Error("notation layer: provide `rendered` or `xml`");
|
|
637
|
+
}
|
|
638
|
+
const sb = safeBox(ctx.W, ctx.H);
|
|
639
|
+
const top = bandTop(ctx, sb);
|
|
640
|
+
const height = bandHeight(ctx, sb);
|
|
641
|
+
base = notationLayout(rn, ctx.W, ctx.H, top, height, {});
|
|
642
|
+
setNotationEngraving(ctx, { rendered: rn, base, bandTop: top, bandHeight: height });
|
|
643
|
+
},
|
|
644
|
+
draw(ctx, tMs) {
|
|
645
|
+
if (!rn || !base) return;
|
|
646
|
+
const eng = getNotationEngraving(ctx);
|
|
647
|
+
const l = eng?.followLayoutAt ? eng.followLayoutAt(ctx, tMs) : base;
|
|
648
|
+
const c = ctx.ctx2d;
|
|
649
|
+
c.drawImage(
|
|
650
|
+
rn.canvas,
|
|
651
|
+
l.src.x,
|
|
652
|
+
l.src.y,
|
|
653
|
+
l.src.w,
|
|
654
|
+
l.src.h,
|
|
655
|
+
l.rect.dx,
|
|
656
|
+
l.rect.dy,
|
|
657
|
+
l.rect.dw,
|
|
658
|
+
l.rect.dh
|
|
659
|
+
);
|
|
660
|
+
},
|
|
661
|
+
dispose() {
|
|
662
|
+
rn = null;
|
|
663
|
+
base = null;
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
var notationFactory = {
|
|
668
|
+
key: "notation",
|
|
669
|
+
create: notationLayer,
|
|
670
|
+
validateProps(props) {
|
|
671
|
+
const errs = [];
|
|
672
|
+
if (props == null || typeof props !== "object") return ["notation: props must be an object"];
|
|
673
|
+
const p = props;
|
|
674
|
+
if (p.system != null && p.system !== "grand" && p.system !== "single")
|
|
675
|
+
errs.push('notation.system must be "grand" | "single"');
|
|
676
|
+
if (p.scale != null && (typeof p.scale !== "number" || p.scale <= 0))
|
|
677
|
+
errs.push("notation.scale must be a positive number");
|
|
678
|
+
if (p.rendered == null && typeof p.xml !== "string")
|
|
679
|
+
errs.push("notation: provide `rendered` (RenderedNotation) or `xml` (string)");
|
|
680
|
+
if (p.bars != null && (!Array.isArray(p.bars) || p.bars.length !== 2))
|
|
681
|
+
errs.push("notation.bars must be [from,to]");
|
|
682
|
+
return errs;
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
// src/scene/layers/scrollCursor.ts
|
|
687
|
+
function followLayoutFor(ctx, camProgress01) {
|
|
688
|
+
const eng = getNotationEngraving(ctx);
|
|
689
|
+
if (!eng) return null;
|
|
690
|
+
const nBars = measureCount(eng.rendered);
|
|
691
|
+
if (nBars <= 0) return eng.base;
|
|
692
|
+
const windowStart = followWindowStart(nBars, camProgress01);
|
|
693
|
+
const focusBox = followBoxAt(eng.rendered, windowStart);
|
|
694
|
+
return notationLayout(eng.rendered, ctx.W, ctx.H, eng.bandTop, eng.bandHeight, { focusBox });
|
|
695
|
+
}
|
|
696
|
+
function scrollCursorLayer() {
|
|
697
|
+
let musicMs = 0;
|
|
698
|
+
let openingZoomMs = 900;
|
|
699
|
+
let color;
|
|
700
|
+
function camProgress(tMs) {
|
|
701
|
+
const phMs = tMs - openingZoomMs;
|
|
702
|
+
if (phMs < 0 || musicMs <= 0) return 0;
|
|
703
|
+
return Math.min(1, phMs / musicMs);
|
|
704
|
+
}
|
|
705
|
+
function layoutAt(ctx, tMs) {
|
|
706
|
+
const eng = getNotationEngraving(ctx);
|
|
707
|
+
return followLayoutFor(ctx, camProgress(tMs)) ?? eng.base;
|
|
708
|
+
}
|
|
709
|
+
return {
|
|
710
|
+
key: "scroll-cursor",
|
|
711
|
+
init(ctx, props) {
|
|
712
|
+
musicMs = props.musicMs;
|
|
713
|
+
openingZoomMs = props.openingZoomMs ?? 900;
|
|
714
|
+
color = props.color;
|
|
715
|
+
setFollowLayoutProvider(ctx, layoutAt);
|
|
716
|
+
},
|
|
717
|
+
draw(ctx, tMs) {
|
|
718
|
+
const eng = getNotationEngraving(ctx);
|
|
719
|
+
if (!eng) return;
|
|
720
|
+
const p = camProgress(tMs);
|
|
721
|
+
const layout = layoutAt(ctx, tMs);
|
|
722
|
+
const line = playheadLine(layout, p);
|
|
723
|
+
if (!line) return;
|
|
724
|
+
const c = ctx.ctx2d;
|
|
725
|
+
c.save();
|
|
726
|
+
c.strokeStyle = color ?? ctx.theme.accent;
|
|
727
|
+
c.globalAlpha = line.alpha;
|
|
728
|
+
c.lineWidth = 4;
|
|
729
|
+
c.beginPath();
|
|
730
|
+
c.moveTo(line.x, line.y0);
|
|
731
|
+
c.lineTo(line.x, line.y1);
|
|
732
|
+
c.stroke();
|
|
733
|
+
c.restore();
|
|
734
|
+
}
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
var scrollCursorFactory = {
|
|
738
|
+
key: "scroll-cursor",
|
|
739
|
+
create: scrollCursorLayer,
|
|
740
|
+
validateProps(props) {
|
|
741
|
+
const errs = [];
|
|
742
|
+
if (props == null || typeof props !== "object") return ["scroll-cursor: props must be an object"];
|
|
743
|
+
const p = props;
|
|
744
|
+
if (typeof p.musicMs !== "number" || !(p.musicMs > 0))
|
|
745
|
+
errs.push("scroll-cursor.musicMs must be a positive number (total music length ms)");
|
|
746
|
+
if (p.followBars != null && (typeof p.followBars !== "number" || p.followBars < 1))
|
|
747
|
+
errs.push("scroll-cursor.followBars must be a number >= 1");
|
|
748
|
+
if (p.openingZoomMs != null && (typeof p.openingZoomMs !== "number" || p.openingZoomMs < 0))
|
|
749
|
+
errs.push("scroll-cursor.openingZoomMs must be a number >= 0");
|
|
750
|
+
if (p.color != null && typeof p.color !== "string") errs.push("scroll-cursor.color must be a string");
|
|
751
|
+
return errs;
|
|
752
|
+
}
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
// src/scene/keyboardGeometry.ts
|
|
756
|
+
var BLACK_PC = /* @__PURE__ */ new Set([1, 3, 6, 8, 10]);
|
|
757
|
+
function whiteIndexAtOrBelow(midi) {
|
|
758
|
+
const oct = Math.floor(midi / 12);
|
|
759
|
+
const pc = midi - oct * 12;
|
|
760
|
+
const WHITES_BELOW = [0, 1, 1, 2, 2, 3, 4, 4, 5, 5, 6, 6];
|
|
761
|
+
return oct * 7 + WHITES_BELOW[pc];
|
|
762
|
+
}
|
|
763
|
+
function isBlackKey(midi) {
|
|
764
|
+
return BLACK_PC.has((midi % 12 + 12) % 12);
|
|
765
|
+
}
|
|
766
|
+
var PIANO_LOW = 21;
|
|
767
|
+
var PIANO_HIGH = 108;
|
|
768
|
+
function snapDownToWhite(midi) {
|
|
769
|
+
let m = midi;
|
|
770
|
+
while (isBlackKey(m)) m--;
|
|
771
|
+
return m;
|
|
772
|
+
}
|
|
773
|
+
function snapUpToWhite(midi) {
|
|
774
|
+
let m = midi;
|
|
775
|
+
while (isBlackKey(m)) m++;
|
|
776
|
+
return m;
|
|
777
|
+
}
|
|
778
|
+
function keyboardLayout(opts) {
|
|
779
|
+
let low;
|
|
780
|
+
let high;
|
|
781
|
+
if (opts.range === "auto" && opts.span) {
|
|
782
|
+
low = snapDownToWhite(opts.span[0]);
|
|
783
|
+
high = snapUpToWhite(opts.span[1]);
|
|
784
|
+
low = snapDownToWhite(low - 1);
|
|
785
|
+
high = snapUpToWhite(high + 1);
|
|
786
|
+
} else {
|
|
787
|
+
low = PIANO_LOW;
|
|
788
|
+
high = PIANO_HIGH;
|
|
789
|
+
}
|
|
790
|
+
const firstWhiteIndex = whiteIndexAtOrBelow(low);
|
|
791
|
+
const lastWhiteIndex = whiteIndexAtOrBelow(high);
|
|
792
|
+
const whiteCount = lastWhiteIndex - firstWhiteIndex + 1;
|
|
793
|
+
const whiteW = opts.w / whiteCount;
|
|
794
|
+
return {
|
|
795
|
+
lowMidi: low,
|
|
796
|
+
highMidi: high,
|
|
797
|
+
x: opts.x,
|
|
798
|
+
w: opts.w,
|
|
799
|
+
top: opts.top,
|
|
800
|
+
height: opts.height,
|
|
801
|
+
whiteW,
|
|
802
|
+
firstWhiteIndex,
|
|
803
|
+
whiteCount
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
function scorePitchSpan(midis) {
|
|
807
|
+
if (!midis.length) return [PIANO_LOW, PIANO_HIGH];
|
|
808
|
+
return [Math.min(...midis), Math.max(...midis)];
|
|
809
|
+
}
|
|
810
|
+
function resolveKeyboardLayout(args) {
|
|
811
|
+
return keyboardLayout({
|
|
812
|
+
range: args.range,
|
|
813
|
+
span: args.range === "auto" ? scorePitchSpan(args.pitchMidis) : void 0,
|
|
814
|
+
x: args.left,
|
|
815
|
+
w: args.width,
|
|
816
|
+
top: args.bottomY - args.height,
|
|
817
|
+
height: args.height
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
function keyCenterX(layout, midi) {
|
|
821
|
+
const wIdx = whiteIndexAtOrBelow(midi) - layout.firstWhiteIndex;
|
|
822
|
+
const whiteCenter = layout.x + (wIdx + 0.5) * layout.whiteW;
|
|
823
|
+
if (!isBlackKey(midi)) return whiteCenter;
|
|
824
|
+
return whiteCenter + layout.whiteW / 2;
|
|
825
|
+
}
|
|
826
|
+
function keyColumnWidth(layout, midi) {
|
|
827
|
+
return isBlackKey(midi) ? layout.whiteW * 0.6 : layout.whiteW * 0.9;
|
|
828
|
+
}
|
|
829
|
+
function keyRect(layout, midi) {
|
|
830
|
+
const cx = keyCenterX(layout, midi);
|
|
831
|
+
if (isBlackKey(midi)) {
|
|
832
|
+
const w2 = layout.whiteW * 0.6;
|
|
833
|
+
return { x: cx - w2 / 2, y: layout.top, w: w2, h: layout.height * 0.62, black: true };
|
|
834
|
+
}
|
|
835
|
+
const w = layout.whiteW;
|
|
836
|
+
return { x: cx - w / 2, y: layout.top, w, h: layout.height, black: false };
|
|
837
|
+
}
|
|
838
|
+
function inRange(layout, midi) {
|
|
839
|
+
return midi >= layout.lowMidi && midi <= layout.highMidi;
|
|
840
|
+
}
|
|
841
|
+
function whiteKeys(layout) {
|
|
842
|
+
const out = [];
|
|
843
|
+
for (let m = layout.lowMidi; m <= layout.highMidi; m++) if (!isBlackKey(m)) out.push(m);
|
|
844
|
+
return out;
|
|
845
|
+
}
|
|
846
|
+
function blackKeys(layout) {
|
|
847
|
+
const out = [];
|
|
848
|
+
for (let m = layout.lowMidi; m <= layout.highMidi; m++) if (isBlackKey(m)) out.push(m);
|
|
849
|
+
return out;
|
|
850
|
+
}
|
|
851
|
+
var PC_COLORS = [
|
|
852
|
+
"#e64545",
|
|
853
|
+
"#e6803a",
|
|
854
|
+
"#e6c23a",
|
|
855
|
+
"#9bcf3a",
|
|
856
|
+
"#3acf6e",
|
|
857
|
+
"#3acfb0",
|
|
858
|
+
"#3aa6e6",
|
|
859
|
+
"#3a5fe6",
|
|
860
|
+
"#7a3ae6",
|
|
861
|
+
"#b03ae6",
|
|
862
|
+
"#e63ab0",
|
|
863
|
+
"#e63a6e"
|
|
864
|
+
];
|
|
865
|
+
function noteColor(midi, hand, colorBy, hands) {
|
|
866
|
+
if (colorBy === "pitch-class") return PC_COLORS[(midi % 12 + 12) % 12];
|
|
867
|
+
return hand === "L" ? hands.L : hands.R;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// src/scene/keyboardStore.ts
|
|
871
|
+
var STORE2 = /* @__PURE__ */ new WeakMap();
|
|
872
|
+
function keyFor2(ctx) {
|
|
873
|
+
return ctx.audioClock;
|
|
874
|
+
}
|
|
875
|
+
function setKeyboardLayout(ctx, layout) {
|
|
876
|
+
STORE2.set(keyFor2(ctx), layout);
|
|
877
|
+
}
|
|
878
|
+
function getKeyboardLayout(ctx) {
|
|
879
|
+
return STORE2.get(keyFor2(ctx));
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// src/scene/layers/keyboard.ts
|
|
883
|
+
function keyboardLayer() {
|
|
884
|
+
let layout = null;
|
|
885
|
+
let colorBy = "hand";
|
|
886
|
+
let hands = { R: "#7b2436", L: "#c8a55b" };
|
|
887
|
+
return {
|
|
888
|
+
key: "keyboard",
|
|
889
|
+
init(ctx, props) {
|
|
890
|
+
colorBy = props.colorBy ?? "hand";
|
|
891
|
+
hands = props.handColors ?? { R: ctx.theme.accent, L: ctx.theme.gold };
|
|
892
|
+
const sb = ctx.safeBox;
|
|
893
|
+
const height = props.height ?? 220;
|
|
894
|
+
const bottomY = props.bottomY ?? sb.bottom;
|
|
895
|
+
const midis = (ctx.score?.notes ?? []).map((n) => n.pitchMidi);
|
|
896
|
+
layout = resolveKeyboardLayout({
|
|
897
|
+
range: props.range ?? "auto",
|
|
898
|
+
pitchMidis: midis,
|
|
899
|
+
left: sb.left,
|
|
900
|
+
width: sb.w,
|
|
901
|
+
bottomY,
|
|
902
|
+
height
|
|
903
|
+
});
|
|
904
|
+
setKeyboardLayout(ctx, layout);
|
|
905
|
+
},
|
|
906
|
+
draw(ctx, tMs) {
|
|
907
|
+
if (!layout) return;
|
|
908
|
+
const c = ctx.ctx2d;
|
|
909
|
+
const L = layout;
|
|
910
|
+
const lit = /* @__PURE__ */ new Map();
|
|
911
|
+
for (const n of ctx.score?.notes ?? []) {
|
|
912
|
+
if (tMs >= n.onsetMs && tMs < n.onsetMs + n.durMs) lit.set(n.pitchMidi, n.hand);
|
|
913
|
+
}
|
|
914
|
+
c.save();
|
|
915
|
+
for (const m of whiteKeys(L)) {
|
|
916
|
+
const r = keyRect(L, m);
|
|
917
|
+
const onHand = lit.get(m);
|
|
918
|
+
c.fillStyle = onHand ? noteColor(m, onHand, colorBy, hands) : "#fbfbfb";
|
|
919
|
+
c.fillRect(r.x, r.y, r.w, r.h);
|
|
920
|
+
c.strokeStyle = "#b8b0a4";
|
|
921
|
+
c.lineWidth = 1;
|
|
922
|
+
c.strokeRect(r.x, r.y, r.w, r.h);
|
|
923
|
+
}
|
|
924
|
+
for (const m of blackKeys(L)) {
|
|
925
|
+
const r = keyRect(L, m);
|
|
926
|
+
const onHand = lit.get(m);
|
|
927
|
+
c.fillStyle = onHand ? noteColor(m, onHand, colorBy, hands) : "#1a1614";
|
|
928
|
+
c.fillRect(r.x, r.y, r.w, r.h);
|
|
929
|
+
}
|
|
930
|
+
c.restore();
|
|
931
|
+
},
|
|
932
|
+
dispose() {
|
|
933
|
+
layout = null;
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
}
|
|
937
|
+
var keyboardFactory = {
|
|
938
|
+
key: "keyboard",
|
|
939
|
+
create: keyboardLayer,
|
|
940
|
+
validateProps(props) {
|
|
941
|
+
const errs = [];
|
|
942
|
+
if (props == null || typeof props !== "object") return ["keyboard: props must be an object"];
|
|
943
|
+
const p = props;
|
|
944
|
+
if (p.range != null && p.range !== "88" && p.range !== "auto")
|
|
945
|
+
errs.push('keyboard.range must be "88" | "auto"');
|
|
946
|
+
if (p.colorBy != null && p.colorBy !== "hand" && p.colorBy !== "pitch-class")
|
|
947
|
+
errs.push('keyboard.colorBy must be "hand" | "pitch-class"');
|
|
948
|
+
if (p.height != null && (typeof p.height !== "number" || p.height <= 0))
|
|
949
|
+
errs.push("keyboard.height must be a positive number");
|
|
950
|
+
if (p.bottomY != null && typeof p.bottomY !== "number")
|
|
951
|
+
errs.push("keyboard.bottomY must be a number");
|
|
952
|
+
return errs;
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
|
|
956
|
+
// src/scene/layers/fallingNotes.ts
|
|
957
|
+
var DEFAULT_LEAD_MS = 2e3;
|
|
958
|
+
function fallingNotesLayer() {
|
|
959
|
+
let paired = true;
|
|
960
|
+
let colorBy = "hand";
|
|
961
|
+
let hands = { R: "#7b2436", L: "#c8a55b" };
|
|
962
|
+
let hitGlow = true;
|
|
963
|
+
let ownLayout = null;
|
|
964
|
+
let topY = 0;
|
|
965
|
+
let leadMsProp;
|
|
966
|
+
let speedProp;
|
|
967
|
+
function layoutFor(ctx) {
|
|
968
|
+
if (paired) return getKeyboardLayout(ctx) ?? ownLayout;
|
|
969
|
+
return ownLayout;
|
|
970
|
+
}
|
|
971
|
+
function hitLineY(layout) {
|
|
972
|
+
return layout.top;
|
|
973
|
+
}
|
|
974
|
+
function pxPerMs(fallHeight) {
|
|
975
|
+
if (leadMsProp != null) return fallHeight / leadMsProp;
|
|
976
|
+
if (speedProp != null) return speedProp / 1e3;
|
|
977
|
+
return fallHeight / DEFAULT_LEAD_MS;
|
|
978
|
+
}
|
|
979
|
+
return {
|
|
980
|
+
key: "falling-notes",
|
|
981
|
+
init(ctx, props) {
|
|
982
|
+
paired = props.keyboard ?? true;
|
|
983
|
+
colorBy = props.colorBy ?? "hand";
|
|
984
|
+
hands = props.handColors ?? { R: ctx.theme.accent, L: ctx.theme.gold };
|
|
985
|
+
hitGlow = props.hitGlow ?? true;
|
|
986
|
+
leadMsProp = props.leadMs;
|
|
987
|
+
speedProp = props.speed;
|
|
988
|
+
const sb = ctx.safeBox;
|
|
989
|
+
topY = props.topY ?? sb.top;
|
|
990
|
+
if (!paired) {
|
|
991
|
+
const hitLine = props.hitLineY ?? sb.bottom - 220;
|
|
992
|
+
const midis = (ctx.score?.notes ?? []).map((n) => n.pitchMidi);
|
|
993
|
+
ownLayout = resolveKeyboardLayout({
|
|
994
|
+
range: props.range ?? "auto",
|
|
995
|
+
pitchMidis: midis,
|
|
996
|
+
left: sb.left,
|
|
997
|
+
width: sb.w,
|
|
998
|
+
// top of the keyboard == hit-line; height below it is irrelevant here.
|
|
999
|
+
bottomY: hitLine + 1,
|
|
1000
|
+
height: 1
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
},
|
|
1004
|
+
draw(ctx, tMs) {
|
|
1005
|
+
const layout = layoutFor(ctx);
|
|
1006
|
+
if (!layout) return;
|
|
1007
|
+
const c = ctx.ctx2d;
|
|
1008
|
+
const hit = hitLineY(layout);
|
|
1009
|
+
const fallH = Math.max(1, hit - topY);
|
|
1010
|
+
const v = pxPerMs(fallH);
|
|
1011
|
+
for (const n of ctx.score?.notes ?? []) {
|
|
1012
|
+
if (!inRange(layout, n.pitchMidi)) continue;
|
|
1013
|
+
const bottomY = hit - (n.onsetMs - tMs) * v;
|
|
1014
|
+
const lenPx = Math.max(2, n.durMs * v);
|
|
1015
|
+
const topEdge = bottomY - lenPx;
|
|
1016
|
+
if (bottomY < topY) continue;
|
|
1017
|
+
if (topEdge > hit) continue;
|
|
1018
|
+
const cx = keyCenterX(layout, n.pitchMidi);
|
|
1019
|
+
const w = keyColumnWidth(layout, n.pitchMidi);
|
|
1020
|
+
const drawTop = Math.max(topY, topEdge);
|
|
1021
|
+
const drawBottom = Math.min(hit, bottomY);
|
|
1022
|
+
const fill = noteColor(n.pitchMidi, n.hand, colorBy, hands);
|
|
1023
|
+
c.save();
|
|
1024
|
+
c.fillStyle = fill;
|
|
1025
|
+
c.globalAlpha = 0.92;
|
|
1026
|
+
c.fillRect(cx - w / 2, drawTop, w, Math.max(1, drawBottom - drawTop));
|
|
1027
|
+
c.restore();
|
|
1028
|
+
}
|
|
1029
|
+
if (hitGlow) {
|
|
1030
|
+
for (const n of ctx.score?.notes ?? []) {
|
|
1031
|
+
if (!(tMs >= n.onsetMs && tMs < n.onsetMs + n.durMs)) continue;
|
|
1032
|
+
if (!inRange(layout, n.pitchMidi)) continue;
|
|
1033
|
+
const cx = keyCenterX(layout, n.pitchMidi);
|
|
1034
|
+
const w = keyColumnWidth(layout, n.pitchMidi);
|
|
1035
|
+
c.save();
|
|
1036
|
+
c.globalAlpha = 0.5;
|
|
1037
|
+
c.fillStyle = noteColor(n.pitchMidi, n.hand, colorBy, hands);
|
|
1038
|
+
c.fillRect(cx - w / 2, hit - 8, w, 8);
|
|
1039
|
+
c.restore();
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
},
|
|
1043
|
+
dispose() {
|
|
1044
|
+
ownLayout = null;
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
var fallingNotesFactory = {
|
|
1049
|
+
key: "falling-notes",
|
|
1050
|
+
create: fallingNotesLayer,
|
|
1051
|
+
validateProps(props) {
|
|
1052
|
+
const errs = [];
|
|
1053
|
+
if (props == null || typeof props !== "object") return ["falling-notes: props must be an object"];
|
|
1054
|
+
const p = props;
|
|
1055
|
+
if (p.keyboard != null && typeof p.keyboard !== "boolean")
|
|
1056
|
+
errs.push("falling-notes.keyboard must be a boolean");
|
|
1057
|
+
if (p.colorBy != null && p.colorBy !== "hand" && p.colorBy !== "pitch-class")
|
|
1058
|
+
errs.push('falling-notes.colorBy must be "hand" | "pitch-class"');
|
|
1059
|
+
if (p.leadMs != null && (typeof p.leadMs !== "number" || p.leadMs <= 0))
|
|
1060
|
+
errs.push("falling-notes.leadMs must be a positive number");
|
|
1061
|
+
if (p.speed != null && (typeof p.speed !== "number" || p.speed <= 0))
|
|
1062
|
+
errs.push("falling-notes.speed must be a positive number");
|
|
1063
|
+
if (p.hitGlow != null && typeof p.hitGlow !== "boolean")
|
|
1064
|
+
errs.push("falling-notes.hitGlow must be a boolean");
|
|
1065
|
+
if (p.range != null && p.range !== "88" && p.range !== "auto")
|
|
1066
|
+
errs.push('falling-notes.range must be "88" | "auto"');
|
|
1067
|
+
return errs;
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
|
|
1071
|
+
// src/scene/layers/promoCards.ts
|
|
1072
|
+
function localT01(tMs, startMs, durationMs) {
|
|
1073
|
+
if (durationMs <= 0) return 0;
|
|
1074
|
+
const t = (tMs - startMs) / durationMs;
|
|
1075
|
+
return t < 0 ? 0 : t > 1 ? 1 : t;
|
|
1076
|
+
}
|
|
1077
|
+
function validateWindow(p, key) {
|
|
1078
|
+
const errs = [];
|
|
1079
|
+
if (p.startMs != null && (typeof p.startMs !== "number" || p.startMs < 0))
|
|
1080
|
+
errs.push(`${key}.startMs must be a number >= 0`);
|
|
1081
|
+
if (p.durationMs != null && (typeof p.durationMs !== "number" || !(p.durationMs > 0)))
|
|
1082
|
+
errs.push(`${key}.durationMs must be a positive number`);
|
|
1083
|
+
return errs;
|
|
1084
|
+
}
|
|
1085
|
+
function hookLayer() {
|
|
1086
|
+
let scene = null;
|
|
1087
|
+
let startMs = 0;
|
|
1088
|
+
let durationMs = 0;
|
|
1089
|
+
return {
|
|
1090
|
+
key: "hook",
|
|
1091
|
+
init(ctx, props) {
|
|
1092
|
+
const opts = { lines: props.lines, brand: props.brand };
|
|
1093
|
+
scene = hookScene(ctx.theme, opts);
|
|
1094
|
+
startMs = props.startMs ?? 0;
|
|
1095
|
+
durationMs = props.durationMs ?? scene.durationMs;
|
|
1096
|
+
},
|
|
1097
|
+
draw(ctx, tMs) {
|
|
1098
|
+
if (!scene) return;
|
|
1099
|
+
scene.draw(ctx.ctx2d, localT01(tMs, startMs, durationMs));
|
|
1100
|
+
}
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
var hookFactory = {
|
|
1104
|
+
key: "hook",
|
|
1105
|
+
create: hookLayer,
|
|
1106
|
+
validateProps(props) {
|
|
1107
|
+
if (props == null || typeof props !== "object") return ["hook: props must be an object"];
|
|
1108
|
+
const p = props;
|
|
1109
|
+
const errs = [];
|
|
1110
|
+
if (!Array.isArray(p.lines) || !p.lines.every((l) => typeof l === "string"))
|
|
1111
|
+
errs.push("hook.lines must be an array of strings");
|
|
1112
|
+
if (p.brand != null && typeof p.brand !== "string") errs.push("hook.brand must be a string");
|
|
1113
|
+
return [...errs, ...validateWindow(p, "hook")];
|
|
1114
|
+
}
|
|
1115
|
+
};
|
|
1116
|
+
function revealLayer() {
|
|
1117
|
+
let scene = null;
|
|
1118
|
+
let startMs = 0;
|
|
1119
|
+
let durationMs = 0;
|
|
1120
|
+
return {
|
|
1121
|
+
key: "reveal",
|
|
1122
|
+
init(ctx, props) {
|
|
1123
|
+
const opts = {
|
|
1124
|
+
title: props.title,
|
|
1125
|
+
subtitle: props.subtitle,
|
|
1126
|
+
initials: props.initials,
|
|
1127
|
+
funFact: props.funFact,
|
|
1128
|
+
portrait: null
|
|
1129
|
+
// the `portrait` layer carries the medallion image; reveal
|
|
1130
|
+
// here is the initials-fallback look. See portrait.ts.
|
|
1131
|
+
};
|
|
1132
|
+
scene = revealScene(ctx.theme, opts);
|
|
1133
|
+
startMs = props.startMs ?? 0;
|
|
1134
|
+
durationMs = props.durationMs ?? scene.durationMs;
|
|
1135
|
+
},
|
|
1136
|
+
draw(ctx, tMs) {
|
|
1137
|
+
if (!scene) return;
|
|
1138
|
+
scene.draw(ctx.ctx2d, localT01(tMs, startMs, durationMs));
|
|
1139
|
+
}
|
|
1140
|
+
};
|
|
1141
|
+
}
|
|
1142
|
+
var revealFactory = {
|
|
1143
|
+
key: "reveal",
|
|
1144
|
+
create: revealLayer,
|
|
1145
|
+
validateProps(props) {
|
|
1146
|
+
if (props == null || typeof props !== "object") return ["reveal: props must be an object"];
|
|
1147
|
+
const p = props;
|
|
1148
|
+
const errs = [];
|
|
1149
|
+
if (typeof p.title !== "string") errs.push("reveal.title must be a string");
|
|
1150
|
+
if (typeof p.subtitle !== "string") errs.push("reveal.subtitle must be a string");
|
|
1151
|
+
if (p.initials != null && typeof p.initials !== "string") errs.push("reveal.initials must be a string");
|
|
1152
|
+
if (p.funFact != null && typeof p.funFact !== "string") errs.push("reveal.funFact must be a string");
|
|
1153
|
+
return [...errs, ...validateWindow(p, "reveal")];
|
|
1154
|
+
}
|
|
1155
|
+
};
|
|
1156
|
+
function ctaLayer() {
|
|
1157
|
+
let scene = null;
|
|
1158
|
+
let startMs = 0;
|
|
1159
|
+
let durationMs = 0;
|
|
1160
|
+
return {
|
|
1161
|
+
key: "cta",
|
|
1162
|
+
init(ctx, props) {
|
|
1163
|
+
const opts = { lines: props.lines };
|
|
1164
|
+
scene = ctaScene(ctx.theme, opts);
|
|
1165
|
+
startMs = props.startMs ?? 0;
|
|
1166
|
+
durationMs = props.durationMs ?? scene.durationMs;
|
|
1167
|
+
},
|
|
1168
|
+
draw(ctx, tMs) {
|
|
1169
|
+
if (!scene) return;
|
|
1170
|
+
scene.draw(ctx.ctx2d, localT01(tMs, startMs, durationMs));
|
|
1171
|
+
}
|
|
1172
|
+
};
|
|
1173
|
+
}
|
|
1174
|
+
var ctaFactory = {
|
|
1175
|
+
key: "cta",
|
|
1176
|
+
create: ctaLayer,
|
|
1177
|
+
validateProps(props) {
|
|
1178
|
+
if (props == null || typeof props !== "object") return ["cta: props must be an object"];
|
|
1179
|
+
const p = props;
|
|
1180
|
+
const errs = [];
|
|
1181
|
+
if (!Array.isArray(p.lines) || !p.lines.every((l) => typeof l === "string"))
|
|
1182
|
+
errs.push("cta.lines must be an array of strings");
|
|
1183
|
+
return [...errs, ...validateWindow(p, "cta")];
|
|
1184
|
+
}
|
|
1185
|
+
};
|
|
1186
|
+
function portraitLayer() {
|
|
1187
|
+
let scene = null;
|
|
1188
|
+
let startMs = 0;
|
|
1189
|
+
let durationMs = 0;
|
|
1190
|
+
return {
|
|
1191
|
+
key: "portrait",
|
|
1192
|
+
async init(ctx, props) {
|
|
1193
|
+
const portrait = await loadPortrait(props.url ?? null);
|
|
1194
|
+
const opts = {
|
|
1195
|
+
title: props.title,
|
|
1196
|
+
subtitle: props.subtitle,
|
|
1197
|
+
initials: props.initials,
|
|
1198
|
+
funFact: props.funFact,
|
|
1199
|
+
portrait
|
|
1200
|
+
};
|
|
1201
|
+
scene = revealScene(ctx.theme, opts);
|
|
1202
|
+
startMs = props.startMs ?? 0;
|
|
1203
|
+
durationMs = props.durationMs ?? scene.durationMs;
|
|
1204
|
+
},
|
|
1205
|
+
draw(ctx, tMs) {
|
|
1206
|
+
if (!scene) return;
|
|
1207
|
+
scene.draw(ctx.ctx2d, localT01(tMs, startMs, durationMs));
|
|
1208
|
+
}
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
var portraitFactory = {
|
|
1212
|
+
key: "portrait",
|
|
1213
|
+
create: portraitLayer,
|
|
1214
|
+
validateProps(props) {
|
|
1215
|
+
if (props == null || typeof props !== "object") return ["portrait: props must be an object"];
|
|
1216
|
+
const p = props;
|
|
1217
|
+
const errs = [];
|
|
1218
|
+
if (typeof p.title !== "string") errs.push("portrait.title must be a string");
|
|
1219
|
+
if (typeof p.subtitle !== "string") errs.push("portrait.subtitle must be a string");
|
|
1220
|
+
if (p.url != null && typeof p.url !== "string") errs.push("portrait.url must be a string or null");
|
|
1221
|
+
if (p.initials != null && typeof p.initials !== "string") errs.push("portrait.initials must be a string");
|
|
1222
|
+
if (p.funFact != null && typeof p.funFact !== "string") errs.push("portrait.funFact must be a string");
|
|
1223
|
+
return [...errs, ...validateWindow(p, "portrait")];
|
|
1224
|
+
}
|
|
1225
|
+
};
|
|
1226
|
+
|
|
1227
|
+
// src/scene/layers/spectrum.ts
|
|
1228
|
+
var SPEC_BARS = 44;
|
|
1229
|
+
function lerpHex(a, b, f) {
|
|
1230
|
+
const pa = parseInt(a.slice(1), 16);
|
|
1231
|
+
const pb = parseInt(b.slice(1), 16);
|
|
1232
|
+
const r = Math.round((pa >> 16 & 255) + ((pb >> 16 & 255) - (pa >> 16 & 255)) * f);
|
|
1233
|
+
const g = Math.round((pa >> 8 & 255) + ((pb >> 8 & 255) - (pa >> 8 & 255)) * f);
|
|
1234
|
+
const bl = Math.round((pa & 255) + ((pb & 255) - (pa & 255)) * f);
|
|
1235
|
+
return `rgb(${r},${g},${bl})`;
|
|
1236
|
+
}
|
|
1237
|
+
function syntheticMagnitude(tSec, b, n) {
|
|
1238
|
+
const phase = tSec * 5 + b * 0.45;
|
|
1239
|
+
let m = 0.22 + 0.16 * Math.sin(phase) + 0.1 * Math.sin(phase * 1.7 + 1.2);
|
|
1240
|
+
m *= 0.5 + 0.5 * Math.sin(b / (n - 1) * Math.PI);
|
|
1241
|
+
return m;
|
|
1242
|
+
}
|
|
1243
|
+
function binByteFreq(freq, n) {
|
|
1244
|
+
let peak = 0;
|
|
1245
|
+
for (let i = 0; i < freq.length; i++) if (freq[i] > peak) peak = freq[i];
|
|
1246
|
+
if (peak <= 4) return null;
|
|
1247
|
+
const lo = 2, hi = 440;
|
|
1248
|
+
const out = [];
|
|
1249
|
+
for (let b = 0; b < n; b++) {
|
|
1250
|
+
const f0 = lo * Math.pow(hi / lo, b / n);
|
|
1251
|
+
const f1 = lo * Math.pow(hi / lo, (b + 1) / n);
|
|
1252
|
+
const i0 = Math.floor(f0);
|
|
1253
|
+
const i1 = Math.max(i0 + 1, Math.floor(f1));
|
|
1254
|
+
let sum = 0, c = 0;
|
|
1255
|
+
for (let i = i0; i < i1 && i < freq.length; i++) {
|
|
1256
|
+
sum += freq[i];
|
|
1257
|
+
c++;
|
|
1258
|
+
}
|
|
1259
|
+
let m = c ? sum / c / 255 : 0;
|
|
1260
|
+
m = Math.pow(m, 0.78);
|
|
1261
|
+
out.push(m);
|
|
1262
|
+
}
|
|
1263
|
+
return out;
|
|
1264
|
+
}
|
|
1265
|
+
function spectrumLayer() {
|
|
1266
|
+
let n = SPEC_BARS;
|
|
1267
|
+
let centerFrac = 0.46;
|
|
1268
|
+
let maxHeightFrac = 0.135;
|
|
1269
|
+
let colorLow;
|
|
1270
|
+
let colorHigh;
|
|
1271
|
+
let levelsFn;
|
|
1272
|
+
function magnitudesAt(ctx, tMs) {
|
|
1273
|
+
const fromProp = levelsFn?.(tMs, n);
|
|
1274
|
+
const src = fromProp ?? resolveFromCtx(ctx, tMs);
|
|
1275
|
+
if (src && src.length) {
|
|
1276
|
+
const out = new Array(n);
|
|
1277
|
+
for (let b = 0; b < n; b++) out[b] = clamp01(src[Math.min(src.length - 1, b)] ?? 0);
|
|
1278
|
+
return out;
|
|
1279
|
+
}
|
|
1280
|
+
const tSec = tMs / 1e3;
|
|
1281
|
+
return Array.from({ length: n }, (_, b) => clamp01(syntheticMagnitude(tSec, b, n)));
|
|
1282
|
+
}
|
|
1283
|
+
function resolveFromCtx(ctx, tMs) {
|
|
1284
|
+
const sp = ctx.spectrum;
|
|
1285
|
+
if (!sp) return null;
|
|
1286
|
+
const lv = sp.levels?.(tMs, n);
|
|
1287
|
+
if (lv && lv.length) return Array.from(lv);
|
|
1288
|
+
const bf = sp.byteFreq?.(tMs);
|
|
1289
|
+
if (bf && bf.length) return binByteFreq(bf, n);
|
|
1290
|
+
return null;
|
|
1291
|
+
}
|
|
1292
|
+
return {
|
|
1293
|
+
key: "spectrum",
|
|
1294
|
+
init(_ctx, props) {
|
|
1295
|
+
n = props.bars ?? SPEC_BARS;
|
|
1296
|
+
centerFrac = props.centerFrac ?? 0.46;
|
|
1297
|
+
maxHeightFrac = props.maxHeightFrac ?? 0.135;
|
|
1298
|
+
colorLow = props.colorLow;
|
|
1299
|
+
colorHigh = props.colorHigh;
|
|
1300
|
+
levelsFn = props.levelsFn;
|
|
1301
|
+
},
|
|
1302
|
+
draw(ctx, tMs) {
|
|
1303
|
+
const c = ctx.ctx2d;
|
|
1304
|
+
const W = ctx.W, H = ctx.H;
|
|
1305
|
+
const sb = ctx.safeBox;
|
|
1306
|
+
const cy = H * centerFrac;
|
|
1307
|
+
const left = Math.max(W * 0.1, sb.left);
|
|
1308
|
+
const right = sb.right;
|
|
1309
|
+
const span = right - left;
|
|
1310
|
+
const slot = span / n;
|
|
1311
|
+
const gap = slot * 0.34;
|
|
1312
|
+
const barW = slot - gap;
|
|
1313
|
+
const maxH = H * maxHeightFrac;
|
|
1314
|
+
const lo = colorLow ?? ctx.theme.accent;
|
|
1315
|
+
const hi = colorHigh ?? ctx.theme.gold;
|
|
1316
|
+
c.save();
|
|
1317
|
+
c.strokeStyle = lerpHex(ctx.theme.paper, ctx.theme.sepia, 0.28);
|
|
1318
|
+
c.lineWidth = 2;
|
|
1319
|
+
c.beginPath();
|
|
1320
|
+
c.moveTo(left, cy);
|
|
1321
|
+
c.lineTo(right, cy);
|
|
1322
|
+
c.stroke();
|
|
1323
|
+
const mags = magnitudesAt(ctx, tMs);
|
|
1324
|
+
const useRound = typeof c.roundRect === "function";
|
|
1325
|
+
for (let b = 0; b < n; b++) {
|
|
1326
|
+
const m = mags[b];
|
|
1327
|
+
const half = Math.max(barW * 0.5, m * maxH);
|
|
1328
|
+
const x0 = left + b * slot + gap / 2;
|
|
1329
|
+
c.fillStyle = lerpHex(lo, hi, m);
|
|
1330
|
+
if (useRound) {
|
|
1331
|
+
const r = Math.min(barW / 2, half);
|
|
1332
|
+
c.beginPath();
|
|
1333
|
+
c.roundRect(x0, cy - half, barW, half * 2, r);
|
|
1334
|
+
c.fill();
|
|
1335
|
+
} else {
|
|
1336
|
+
c.fillRect(x0, cy - half, barW, half * 2);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
c.restore();
|
|
1340
|
+
}
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
function clamp01(x) {
|
|
1344
|
+
return x < 0 ? 0 : x > 1 ? 1 : x;
|
|
1345
|
+
}
|
|
1346
|
+
var spectrumFactory = {
|
|
1347
|
+
key: "spectrum",
|
|
1348
|
+
create: spectrumLayer,
|
|
1349
|
+
validateProps(props) {
|
|
1350
|
+
if (props == null || typeof props !== "object") return ["spectrum: props must be an object"];
|
|
1351
|
+
const p = props;
|
|
1352
|
+
const errs = [];
|
|
1353
|
+
if (p.bars != null && (typeof p.bars !== "number" || p.bars < 2)) errs.push("spectrum.bars must be a number >= 2");
|
|
1354
|
+
if (p.centerFrac != null && (typeof p.centerFrac !== "number" || p.centerFrac < 0 || p.centerFrac > 1))
|
|
1355
|
+
errs.push("spectrum.centerFrac must be in [0,1]");
|
|
1356
|
+
if (p.maxHeightFrac != null && (typeof p.maxHeightFrac !== "number" || p.maxHeightFrac <= 0))
|
|
1357
|
+
errs.push("spectrum.maxHeightFrac must be a positive number");
|
|
1358
|
+
if (p.colorLow != null && typeof p.colorLow !== "string") errs.push("spectrum.colorLow must be a string");
|
|
1359
|
+
if (p.colorHigh != null && typeof p.colorHigh !== "string") errs.push("spectrum.colorHigh must be a string");
|
|
1360
|
+
if (p.levelsFn != null && typeof p.levelsFn !== "function") errs.push("spectrum.levelsFn must be a function");
|
|
1361
|
+
return errs;
|
|
1362
|
+
}
|
|
1363
|
+
};
|
|
1364
|
+
|
|
1365
|
+
// src/scene/layers/branding.ts
|
|
1366
|
+
function brandingLayer() {
|
|
1367
|
+
let logo;
|
|
1368
|
+
let safezone = false;
|
|
1369
|
+
let yFrac = 1;
|
|
1370
|
+
let size = 34;
|
|
1371
|
+
let color;
|
|
1372
|
+
return {
|
|
1373
|
+
key: "branding",
|
|
1374
|
+
init(_ctx, props) {
|
|
1375
|
+
logo = props.logo;
|
|
1376
|
+
safezone = props.safezone ?? false;
|
|
1377
|
+
yFrac = props.yFrac ?? 1;
|
|
1378
|
+
size = props.size ?? 34;
|
|
1379
|
+
color = props.color;
|
|
1380
|
+
},
|
|
1381
|
+
draw(ctx) {
|
|
1382
|
+
const c = ctx.ctx2d;
|
|
1383
|
+
const sb = ctx.safeBox;
|
|
1384
|
+
const text = logo ?? ctx.theme.brand;
|
|
1385
|
+
c.save();
|
|
1386
|
+
c.textAlign = "center";
|
|
1387
|
+
c.font = `italic ${size}px ${ctx.theme.fontBody}`;
|
|
1388
|
+
c.fillStyle = color ?? ctx.theme.sepia;
|
|
1389
|
+
const y = sb.top + sb.h * yFrac - (yFrac >= 1 ? size * 0.3 : 0);
|
|
1390
|
+
c.fillText(text, sb.cx, y);
|
|
1391
|
+
c.restore();
|
|
1392
|
+
if (safezone) drawSafeGuides(c);
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
var brandingFactory = {
|
|
1397
|
+
key: "branding",
|
|
1398
|
+
create: brandingLayer,
|
|
1399
|
+
validateProps(props) {
|
|
1400
|
+
if (props == null || typeof props !== "object") return ["branding: props must be an object"];
|
|
1401
|
+
const p = props;
|
|
1402
|
+
const errs = [];
|
|
1403
|
+
if (p.logo != null && typeof p.logo !== "string") errs.push("branding.logo must be a string");
|
|
1404
|
+
if (p.safezone != null && typeof p.safezone !== "boolean") errs.push("branding.safezone must be a boolean");
|
|
1405
|
+
if (p.yFrac != null && typeof p.yFrac !== "number") errs.push("branding.yFrac must be a number");
|
|
1406
|
+
if (p.size != null && (typeof p.size !== "number" || p.size <= 0)) errs.push("branding.size must be a positive number");
|
|
1407
|
+
if (p.color != null && typeof p.color !== "string") errs.push("branding.color must be a string");
|
|
1408
|
+
return errs;
|
|
1409
|
+
}
|
|
1410
|
+
};
|
|
1411
|
+
function safeGuidesLayer() {
|
|
1412
|
+
return {
|
|
1413
|
+
key: "safe-guides",
|
|
1414
|
+
init() {
|
|
1415
|
+
},
|
|
1416
|
+
draw(ctx) {
|
|
1417
|
+
drawSafeGuides(ctx.ctx2d);
|
|
1418
|
+
}
|
|
1419
|
+
};
|
|
1420
|
+
}
|
|
1421
|
+
var safeGuidesFactory = {
|
|
1422
|
+
key: "safe-guides",
|
|
1423
|
+
create: safeGuidesLayer,
|
|
1424
|
+
validateProps(props) {
|
|
1425
|
+
if (props != null && typeof props !== "object") return ["safe-guides: props must be an object"];
|
|
1426
|
+
return [];
|
|
1427
|
+
}
|
|
1428
|
+
};
|
|
1429
|
+
|
|
1430
|
+
// src/scene/registry.ts
|
|
1431
|
+
var REGISTRY = /* @__PURE__ */ new Map();
|
|
1432
|
+
function registerLayer(factory) {
|
|
1433
|
+
REGISTRY.set(factory.key, factory);
|
|
1434
|
+
}
|
|
1435
|
+
function getLayerFactory(key) {
|
|
1436
|
+
return REGISTRY.get(key);
|
|
1437
|
+
}
|
|
1438
|
+
function registeredKeys() {
|
|
1439
|
+
return [...REGISTRY.keys()];
|
|
1440
|
+
}
|
|
1441
|
+
registerLayer(backgroundFactory);
|
|
1442
|
+
registerLayer(captionFactory);
|
|
1443
|
+
registerLayer(notationFactory);
|
|
1444
|
+
registerLayer(scrollCursorFactory);
|
|
1445
|
+
registerLayer(keyboardFactory);
|
|
1446
|
+
registerLayer(fallingNotesFactory);
|
|
1447
|
+
registerLayer(hookFactory);
|
|
1448
|
+
registerLayer(revealFactory);
|
|
1449
|
+
registerLayer(ctaFactory);
|
|
1450
|
+
registerLayer(portraitFactory);
|
|
1451
|
+
registerLayer(spectrumFactory);
|
|
1452
|
+
registerLayer(brandingFactory);
|
|
1453
|
+
registerLayer(safeGuidesFactory);
|
|
1454
|
+
|
|
1455
|
+
// src/scene/camera.ts
|
|
1456
|
+
function cameraTransform(cam, W, H) {
|
|
1457
|
+
const s = cam.zoom;
|
|
1458
|
+
return [s, 0, 0, s, W / 2 - cam.cx * s, H / 2 - cam.cy * s];
|
|
1459
|
+
}
|
|
1460
|
+
function worldToViewport(cam, W, H, x, y) {
|
|
1461
|
+
const [a, , , d, e, f] = cameraTransform(cam, W, H);
|
|
1462
|
+
return { x: a * x + e, y: d * y + f };
|
|
1463
|
+
}
|
|
1464
|
+
function frameRect(rect, W, H, pad = 0) {
|
|
1465
|
+
const padded = 1 + Math.max(0, pad) * 2;
|
|
1466
|
+
const zoom = Math.min(W / (rect.w * padded), H / (rect.h * padded));
|
|
1467
|
+
return { cx: rect.x + rect.w / 2, cy: rect.y + rect.h / 2, zoom };
|
|
1468
|
+
}
|
|
1469
|
+
function lerpCamera(a, b, t01, ease = easeInOut) {
|
|
1470
|
+
const k = ease(clamp(t01, 0, 1));
|
|
1471
|
+
return { cx: lerp(a.cx, b.cx, k), cy: lerp(a.cy, b.cy, k), zoom: lerp(a.zoom, b.zoom, k) };
|
|
1472
|
+
}
|
|
1473
|
+
function kenBurns(from, to, at01) {
|
|
1474
|
+
return lerpCamera(from, to, at01, easeInOut);
|
|
1475
|
+
}
|
|
1476
|
+
function applyToContext(ctx, cam, W, H) {
|
|
1477
|
+
const m = cameraTransform(cam, W, H);
|
|
1478
|
+
ctx.setTransform(m[0], m[1], m[2], m[3], m[4], m[5]);
|
|
1479
|
+
}
|
|
1480
|
+
function identityCamera(W, H) {
|
|
1481
|
+
return { cx: W / 2, cy: H / 2, zoom: 1 };
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// src/scene/runner.ts
|
|
1485
|
+
function resolveAnchor(anchor, totalSec) {
|
|
1486
|
+
if (typeof anchor === "number") return anchor;
|
|
1487
|
+
if (anchor === "end") return totalSec;
|
|
1488
|
+
const m = /^end-(\d+(?:\.\d+)?)$/.exec(anchor);
|
|
1489
|
+
if (m) return totalSec - parseFloat(m[1]);
|
|
1490
|
+
throw new Error(`resolveAnchor: bad anchor "${anchor}"`);
|
|
1491
|
+
}
|
|
1492
|
+
function resolveTimeline(spec, totalSec) {
|
|
1493
|
+
return spec.timeline.map((seg) => {
|
|
1494
|
+
const startSec = resolveAnchor(seg.at[0], totalSec);
|
|
1495
|
+
const endSec = resolveAnchor(seg.at[1], totalSec);
|
|
1496
|
+
return { startMs: startSec * 1e3, endMs: endSec * 1e3, layers: seg.layers };
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
function visualTimelineMs(resolved) {
|
|
1500
|
+
return resolved.reduce((mx, s) => Math.max(mx, s.endMs), 0);
|
|
1501
|
+
}
|
|
1502
|
+
var SCREEN_PINNED_KEYS = /* @__PURE__ */ new Set([
|
|
1503
|
+
"caption",
|
|
1504
|
+
"branding",
|
|
1505
|
+
"cta",
|
|
1506
|
+
"hook",
|
|
1507
|
+
"reveal",
|
|
1508
|
+
"portrait",
|
|
1509
|
+
"safe-guides"
|
|
1510
|
+
]);
|
|
1511
|
+
async function buildScene(opts) {
|
|
1512
|
+
const { spec, theme, score } = opts;
|
|
1513
|
+
const [W, H] = spec.size;
|
|
1514
|
+
const fps = spec.fps ?? 30;
|
|
1515
|
+
const resolved = resolveTimeline(spec, opts.totalSec);
|
|
1516
|
+
const safe = safeBox(W, H);
|
|
1517
|
+
const camera = opts.camera ?? (() => identityCamera(W, H));
|
|
1518
|
+
const clock = { nowMs: () => 0 };
|
|
1519
|
+
const baseCtx = {
|
|
1520
|
+
W,
|
|
1521
|
+
H,
|
|
1522
|
+
score,
|
|
1523
|
+
audioClock: clock,
|
|
1524
|
+
theme,
|
|
1525
|
+
safeBox: safe,
|
|
1526
|
+
fps
|
|
1527
|
+
};
|
|
1528
|
+
const bound = [];
|
|
1529
|
+
for (const seg of resolved) {
|
|
1530
|
+
for (const sl of seg.layers) {
|
|
1531
|
+
const factory = getLayerFactory(sl.k);
|
|
1532
|
+
if (!factory) {
|
|
1533
|
+
throw new Error(`buildScene: unknown layer "${sl.k}" (registered: ${registeredKeys().join(", ")})`);
|
|
1534
|
+
}
|
|
1535
|
+
const errs = factory.validateProps(sl.p ?? {});
|
|
1536
|
+
if (errs.length) {
|
|
1537
|
+
throw new Error(`buildScene: invalid props for "${sl.k}": ${errs.join("; ")}`);
|
|
1538
|
+
}
|
|
1539
|
+
const layer = factory.create();
|
|
1540
|
+
await layer.init({ ...baseCtx, ctx2d: null }, sl.p ?? {});
|
|
1541
|
+
bound.push({
|
|
1542
|
+
layer,
|
|
1543
|
+
startMs: seg.startMs,
|
|
1544
|
+
endMs: seg.endMs,
|
|
1545
|
+
screenPinned: SCREEN_PINNED_KEYS.has(sl.k)
|
|
1546
|
+
});
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
const durationMs = visualTimelineMs(resolved);
|
|
1550
|
+
function renderFrame(ctx2d, tMs) {
|
|
1551
|
+
clock.nowMs = () => tMs;
|
|
1552
|
+
const ctx = { ...baseCtx, ctx2d };
|
|
1553
|
+
const cam = camera(tMs);
|
|
1554
|
+
ctx2d.save();
|
|
1555
|
+
applyToContext(ctx2d, cam, W, H);
|
|
1556
|
+
for (const b of bound) {
|
|
1557
|
+
if (b.screenPinned) continue;
|
|
1558
|
+
if (tMs < b.startMs || tMs >= b.endMs) continue;
|
|
1559
|
+
b.layer.draw(ctx, tMs);
|
|
1560
|
+
}
|
|
1561
|
+
ctx2d.restore();
|
|
1562
|
+
ctx2d.save();
|
|
1563
|
+
ctx2d.setTransform(1, 0, 0, 1, 0, 0);
|
|
1564
|
+
for (const b of bound) {
|
|
1565
|
+
if (!b.screenPinned) continue;
|
|
1566
|
+
if (tMs < b.startMs || tMs >= b.endMs) continue;
|
|
1567
|
+
b.layer.draw(ctx, tMs);
|
|
1568
|
+
}
|
|
1569
|
+
ctx2d.restore();
|
|
1570
|
+
}
|
|
1571
|
+
return {
|
|
1572
|
+
W,
|
|
1573
|
+
H,
|
|
1574
|
+
fps,
|
|
1575
|
+
durationMs,
|
|
1576
|
+
resolved,
|
|
1577
|
+
renderFrame,
|
|
1578
|
+
dispose() {
|
|
1579
|
+
for (const b of bound) b.layer.dispose?.();
|
|
1580
|
+
}
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
async function recordSceneSpec(opts) {
|
|
1584
|
+
const { built } = opts;
|
|
1585
|
+
const record = opts.record ?? recordScenes;
|
|
1586
|
+
const composite = {
|
|
1587
|
+
durationMs: built.durationMs,
|
|
1588
|
+
draw: (ctx, t01) => built.renderFrame(ctx, t01 * built.durationMs)
|
|
1589
|
+
};
|
|
1590
|
+
return record([composite], {
|
|
1591
|
+
audioStream: opts.audioStream,
|
|
1592
|
+
width: built.W,
|
|
1593
|
+
height: built.H,
|
|
1594
|
+
fps: built.fps,
|
|
1595
|
+
background: opts.background,
|
|
1596
|
+
onProgress: opts.onProgress
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
// src/scene/highlight.ts
|
|
1601
|
+
function highlightIntensity(region, tMs) {
|
|
1602
|
+
const fade = region.fadeMs ?? 120;
|
|
1603
|
+
if (tMs <= region.inMs - fade || tMs >= region.outMs + fade) return 0;
|
|
1604
|
+
const rampIn = invLerp(region.inMs - fade, region.inMs, tMs);
|
|
1605
|
+
const rampOut = 1 - invLerp(region.outMs, region.outMs + fade, tMs);
|
|
1606
|
+
return clamp(Math.min(rampIn, rampOut), 0, 1);
|
|
1607
|
+
}
|
|
1608
|
+
function noteSetXRange(onsetsMs, windowMs, timeToX) {
|
|
1609
|
+
let lo = Infinity;
|
|
1610
|
+
let hi = -Infinity;
|
|
1611
|
+
for (const on of onsetsMs) {
|
|
1612
|
+
if (on < windowMs[0] || on > windowMs[1]) continue;
|
|
1613
|
+
const x = timeToX(on);
|
|
1614
|
+
if (x < lo) lo = x;
|
|
1615
|
+
if (x > hi) hi = x;
|
|
1616
|
+
}
|
|
1617
|
+
if (lo === Infinity) return null;
|
|
1618
|
+
return { x: lo, w: Math.max(0, hi - lo) };
|
|
1619
|
+
}
|
|
1620
|
+
function drawHighlight(ctx, region, tMs, accent) {
|
|
1621
|
+
const a = highlightIntensity(region, tMs);
|
|
1622
|
+
if (a <= 0) return;
|
|
1623
|
+
ctx.save();
|
|
1624
|
+
ctx.globalAlpha = a * 0.35;
|
|
1625
|
+
ctx.fillStyle = region.color ?? accent;
|
|
1626
|
+
ctx.fillRect(region.x, region.y, region.w, region.h);
|
|
1627
|
+
ctx.restore();
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
// src/scene/audioLayers.ts
|
|
1631
|
+
function countInSchedule(opts) {
|
|
1632
|
+
const beats = opts.beats ?? 4;
|
|
1633
|
+
const accent = opts.accentDownbeat ?? true;
|
|
1634
|
+
const beatSec = 60 / opts.bpm;
|
|
1635
|
+
const events = [];
|
|
1636
|
+
for (let i = 0; i < beats; i++) {
|
|
1637
|
+
const isDownbeat = accent && i === 0;
|
|
1638
|
+
events.push({
|
|
1639
|
+
atSec: i * beatSec,
|
|
1640
|
+
note: isDownbeat ? "C6" : "C5",
|
|
1641
|
+
durSec: 0.05,
|
|
1642
|
+
gain: isDownbeat ? 1 : 0.7,
|
|
1643
|
+
kind: "count"
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
return events;
|
|
1647
|
+
}
|
|
1648
|
+
function countInLeadSec(opts) {
|
|
1649
|
+
return (opts.beats ?? 4) * (60 / opts.bpm);
|
|
1650
|
+
}
|
|
1651
|
+
function clickTrackSchedule(opts) {
|
|
1652
|
+
const beatSec = 60 / opts.bpm;
|
|
1653
|
+
const bpb = opts.beatsPerBar ?? 4;
|
|
1654
|
+
const start = opts.startSec ?? 0;
|
|
1655
|
+
const events = [];
|
|
1656
|
+
const n = Math.floor(opts.durationSec / beatSec);
|
|
1657
|
+
for (let i = 0; i < n; i++) {
|
|
1658
|
+
const isDownbeat = i % bpb === 0;
|
|
1659
|
+
events.push({
|
|
1660
|
+
atSec: start + i * beatSec,
|
|
1661
|
+
note: isDownbeat ? "C6" : "C5",
|
|
1662
|
+
durSec: 0.03,
|
|
1663
|
+
gain: isDownbeat ? 0.6 : 0.4,
|
|
1664
|
+
kind: "click"
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
return events;
|
|
1668
|
+
}
|
|
1669
|
+
function droneSchedule(opts) {
|
|
1670
|
+
const gain = opts.gain ?? 0.15;
|
|
1671
|
+
const events = [
|
|
1672
|
+
{ atSec: 0, note: opts.root, durSec: opts.durationSec, gain, kind: "drone" }
|
|
1673
|
+
];
|
|
1674
|
+
if (opts.fifth ?? true) {
|
|
1675
|
+
events.push({ atSec: 0, note: transposeFifth(opts.root), durSec: opts.durationSec, gain, kind: "drone" });
|
|
1676
|
+
}
|
|
1677
|
+
return events;
|
|
1678
|
+
}
|
|
1679
|
+
function transposeFifth(note) {
|
|
1680
|
+
const m = /^([A-G])(#|b)?(\d)$/.exec(note);
|
|
1681
|
+
if (!m) return note;
|
|
1682
|
+
const order = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
|
1683
|
+
const pcMap = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 };
|
|
1684
|
+
let pc = pcMap[m[1]] + (m[2] === "#" ? 1 : m[2] === "b" ? -1 : 0);
|
|
1685
|
+
let oct = parseInt(m[3], 10);
|
|
1686
|
+
pc += 7;
|
|
1687
|
+
if (pc >= 12) {
|
|
1688
|
+
pc -= 12;
|
|
1689
|
+
oct += 1;
|
|
1690
|
+
}
|
|
1691
|
+
return `${order[pc]}${oct}`;
|
|
1692
|
+
}
|
|
1693
|
+
function duckGainAt(windows, tSec, floor = 0.25, rampSec = 0.2) {
|
|
1694
|
+
for (const w of windows) {
|
|
1695
|
+
if (tSec >= w.startSec - rampSec && tSec <= w.endSec + rampSec) {
|
|
1696
|
+
if (tSec < w.startSec) return lerpGain(1, floor, (tSec - (w.startSec - rampSec)) / rampSec);
|
|
1697
|
+
if (tSec > w.endSec) return lerpGain(floor, 1, (tSec - w.endSec) / rampSec);
|
|
1698
|
+
return floor;
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
return 1;
|
|
1702
|
+
}
|
|
1703
|
+
function lerpGain(a, b, t) {
|
|
1704
|
+
const k = t < 0 ? 0 : t > 1 ? 1 : t;
|
|
1705
|
+
return a + (b - a) * k;
|
|
1706
|
+
}
|
|
1707
|
+
function applySchedule(instrument, schedule, startTime) {
|
|
1708
|
+
for (const ev of schedule) {
|
|
1709
|
+
if (ev.note == null) continue;
|
|
1710
|
+
instrument.triggerAttackRelease(
|
|
1711
|
+
ev.note,
|
|
1712
|
+
ev.durSec ?? 0.05,
|
|
1713
|
+
startTime + ev.atSec,
|
|
1714
|
+
ev.gain ?? 1
|
|
1715
|
+
);
|
|
1716
|
+
}
|
|
1717
|
+
return schedule.length;
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// src/scene/gate.ts
|
|
1721
|
+
var AV_TOLERANCE_MS = 60;
|
|
1722
|
+
async function runGate(input) {
|
|
1723
|
+
const errors = [];
|
|
1724
|
+
let resolved = [];
|
|
1725
|
+
for (const seg of input.spec.timeline) {
|
|
1726
|
+
for (const sl of seg.layers) {
|
|
1727
|
+
const factory = getLayerFactory(sl.k);
|
|
1728
|
+
if (!factory) {
|
|
1729
|
+
errors.push({ check: "spec", message: `unknown layer "${sl.k}"` });
|
|
1730
|
+
continue;
|
|
1731
|
+
}
|
|
1732
|
+
const errs = factory.validateProps(sl.p ?? {});
|
|
1733
|
+
for (const e of errs) errors.push({ check: "spec", message: e });
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
try {
|
|
1737
|
+
resolved = resolveTimeline(input.spec, input.totalSec);
|
|
1738
|
+
} catch (e) {
|
|
1739
|
+
errors.push({ check: "spec", message: `timeline: ${e.message}` });
|
|
1740
|
+
}
|
|
1741
|
+
if (resolved.length) {
|
|
1742
|
+
const vis = visualTimelineMs(resolved);
|
|
1743
|
+
if (!Number.isFinite(vis) || !Number.isFinite(input.audioMs)) {
|
|
1744
|
+
errors.push({ check: "av-duration", message: "non-finite visual/audio duration" });
|
|
1745
|
+
} else if (Math.abs(vis - input.audioMs) > AV_TOLERANCE_MS) {
|
|
1746
|
+
errors.push({
|
|
1747
|
+
check: "av-duration",
|
|
1748
|
+
message: `|visual ${Math.round(vis)}ms \u2212 audio ${Math.round(input.audioMs)}ms| = ${Math.round(
|
|
1749
|
+
Math.abs(vis - input.audioMs)
|
|
1750
|
+
)}ms > ${AV_TOLERANCE_MS}ms`
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
for (const p of input.placements ?? []) {
|
|
1755
|
+
if (!Number.isFinite(p.x) || !Number.isFinite(p.y)) {
|
|
1756
|
+
errors.push({ check: "placement", message: `${p.label}: NaN/\u221E position` });
|
|
1757
|
+
continue;
|
|
1758
|
+
}
|
|
1759
|
+
if (p.x < 0 || p.x > input.W || p.y < 0 || p.y > input.H) {
|
|
1760
|
+
errors.push({ check: "placement", message: `${p.label}: (${p.x},${p.y}) outside ${input.W}\xD7${input.H}` });
|
|
1761
|
+
}
|
|
1762
|
+
if (input.safezone && p.mustBeSafe) {
|
|
1763
|
+
const b = input.safeBox;
|
|
1764
|
+
if (p.x < b.left || p.x > b.right || p.y < b.top || p.y > b.bottom) {
|
|
1765
|
+
errors.push({ check: "placement", message: `${p.label}: outside safe box` });
|
|
1766
|
+
}
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
const ready = input.fontsReady ? await input.fontsReady() : true;
|
|
1770
|
+
if (!ready) errors.push({ check: "fonts", message: "fonts not loaded before first frame" });
|
|
1771
|
+
if (input.output) {
|
|
1772
|
+
const expectedFrames = Math.floor((input.fps ?? 30) * (input.audioMs / 1e3) * 0.5);
|
|
1773
|
+
if (input.output.frames < expectedFrames) {
|
|
1774
|
+
errors.push({
|
|
1775
|
+
check: "output",
|
|
1776
|
+
message: `only ${input.output.frames} frames (expected \u2265 ${expectedFrames})`
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
if (input.output.audioTracks < 1) {
|
|
1780
|
+
errors.push({ check: "output", message: "no audio track in output" });
|
|
1781
|
+
}
|
|
1782
|
+
if (input.output.durationMs != null && Math.abs(input.output.durationMs - input.audioMs) > AV_TOLERANCE_MS) {
|
|
1783
|
+
errors.push({
|
|
1784
|
+
check: "output",
|
|
1785
|
+
message: `output duration ${Math.round(input.output.durationMs)}ms vs audio ${Math.round(
|
|
1786
|
+
input.audioMs
|
|
1787
|
+
)}ms exceeds ${AV_TOLERANCE_MS}ms`
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
return errors;
|
|
1792
|
+
}
|
|
1793
|
+
async function assertGate(input) {
|
|
1794
|
+
const errors = await runGate(input);
|
|
1795
|
+
if (errors.length) {
|
|
1796
|
+
throw new Error(
|
|
1797
|
+
`pre-render gate failed (${errors.length}):
|
|
1798
|
+
` + errors.map((e) => ` [${e.check}] ${e.message}`).join("\n")
|
|
1799
|
+
);
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
// src/scene/notationCamera.ts
|
|
1804
|
+
function mapBoxThroughLayout(base, b) {
|
|
1805
|
+
const fx = base.rect.dw / base.src.w;
|
|
1806
|
+
const fy = base.rect.dh / base.src.h;
|
|
1807
|
+
return {
|
|
1808
|
+
x: base.rect.dx + (b.x - base.src.x) * fx,
|
|
1809
|
+
y: base.rect.dy + (b.y - base.src.y) * fy,
|
|
1810
|
+
w: b.w * fx,
|
|
1811
|
+
h: b.h * fy
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
function followSrcBox(rn, focusBoxCanvas) {
|
|
1815
|
+
const px = focusBoxCanvas.w * (FOLLOW_PAD - 1) / 2;
|
|
1816
|
+
const py = focusBoxCanvas.h * (FOLLOW_PAD - 1) / 2;
|
|
1817
|
+
const src = {
|
|
1818
|
+
x: Math.max(0, focusBoxCanvas.x - px),
|
|
1819
|
+
y: Math.max(0, focusBoxCanvas.y - py),
|
|
1820
|
+
w: focusBoxCanvas.w + 2 * px,
|
|
1821
|
+
h: focusBoxCanvas.h + 2 * py
|
|
1822
|
+
};
|
|
1823
|
+
src.w = Math.min(src.w, rn.canvas.width - src.x);
|
|
1824
|
+
src.h = Math.min(src.h, rn.canvas.height - src.y);
|
|
1825
|
+
return src;
|
|
1826
|
+
}
|
|
1827
|
+
function cameraForFollow(rn, base, focusBoxCanvas, viewW, viewH) {
|
|
1828
|
+
const src = followSrcBox(rn, focusBoxCanvas);
|
|
1829
|
+
const world = mapBoxThroughLayout(base, src);
|
|
1830
|
+
return frameRect(world, viewW, viewH, 0);
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// src/scene/demos/fallingKeyboardDemo.ts
|
|
1834
|
+
function fallingKeyboardDemoSpec(opts = {}) {
|
|
1835
|
+
const size = opts.size ?? [1080, 1920];
|
|
1836
|
+
const leadMs = opts.leadMs ?? 2200;
|
|
1837
|
+
const colorBy = opts.colorBy ?? "hand";
|
|
1838
|
+
const range = opts.range ?? "auto";
|
|
1839
|
+
return {
|
|
1840
|
+
size,
|
|
1841
|
+
theme: opts.theme ?? "rsr",
|
|
1842
|
+
durationMode: "audio",
|
|
1843
|
+
timeline: [
|
|
1844
|
+
{
|
|
1845
|
+
at: [0, "end"],
|
|
1846
|
+
layers: [
|
|
1847
|
+
{ k: "background", p: { style: "ink" } },
|
|
1848
|
+
// keyboard first so it publishes its layout before falling-notes inits;
|
|
1849
|
+
// draw order: keyboard bed under the falling blocks would hide them, so
|
|
1850
|
+
// we draw falling-notes ON TOP of the keyboard bed (falling listed last).
|
|
1851
|
+
{ k: "keyboard", p: { range, colorBy } },
|
|
1852
|
+
{ k: "falling-notes", p: { keyboard: true, colorBy, leadMs } }
|
|
1853
|
+
]
|
|
1854
|
+
}
|
|
1855
|
+
]
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
async function fallingKeyboardDemoScore(xml, opts = {}) {
|
|
1859
|
+
return scoreFromMusicXML(xml, opts.scoreOpts);
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// src/scene/demos/promoCardsDemo.ts
|
|
1863
|
+
function promoCardsDemoSpec(opts = {}) {
|
|
1864
|
+
const size = opts.size ?? [1080, 1920];
|
|
1865
|
+
const totalSec = opts.totalSec ?? 12;
|
|
1866
|
+
const hookSec = opts.hookSec ?? 2.2;
|
|
1867
|
+
const revealSec = opts.revealSec ?? 4;
|
|
1868
|
+
const ctaSec = opts.ctaSec ?? 3;
|
|
1869
|
+
const hookLines = opts.hookLines ?? ["Can you", "name this?"];
|
|
1870
|
+
const title = opts.title ?? "Claude Debussy";
|
|
1871
|
+
const subtitle = opts.subtitle ?? "1862\u20131918";
|
|
1872
|
+
const ctaLines = opts.ctaLines ?? ["Train your ear", "realeartrainer.com"];
|
|
1873
|
+
const brand = opts.brand;
|
|
1874
|
+
const ms = (s) => Math.round(s * 1e3);
|
|
1875
|
+
const hookStart = 0;
|
|
1876
|
+
const revealStart = hookSec;
|
|
1877
|
+
const ctaStart = totalSec - ctaSec;
|
|
1878
|
+
return {
|
|
1879
|
+
size,
|
|
1880
|
+
theme: opts.theme ?? "rsr",
|
|
1881
|
+
durationMode: "fixed",
|
|
1882
|
+
durationSec: totalSec,
|
|
1883
|
+
timeline: [
|
|
1884
|
+
// background spans the whole clip.
|
|
1885
|
+
{ at: [0, "end"], layers: [{ k: "background", p: { style: "paper" } }] },
|
|
1886
|
+
// hook card.
|
|
1887
|
+
{
|
|
1888
|
+
at: [hookStart, hookSec],
|
|
1889
|
+
layers: [{ k: "hook", p: { lines: hookLines, brand, startMs: ms(hookStart), durationMs: ms(hookSec) } }]
|
|
1890
|
+
},
|
|
1891
|
+
// reveal phase: audio-reactive spectrum + the portrait medallion.
|
|
1892
|
+
{
|
|
1893
|
+
at: [revealStart, revealStart + revealSec],
|
|
1894
|
+
layers: [
|
|
1895
|
+
{ k: "spectrum", p: {} },
|
|
1896
|
+
{
|
|
1897
|
+
k: "portrait",
|
|
1898
|
+
p: {
|
|
1899
|
+
title,
|
|
1900
|
+
subtitle,
|
|
1901
|
+
url: opts.portraitUrl ?? null,
|
|
1902
|
+
funFact: opts.funFact,
|
|
1903
|
+
startMs: ms(revealStart),
|
|
1904
|
+
durationMs: ms(revealSec)
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
]
|
|
1908
|
+
},
|
|
1909
|
+
// end-card CTA.
|
|
1910
|
+
{
|
|
1911
|
+
at: [ctaStart, "end"],
|
|
1912
|
+
layers: [{ k: "cta", p: { lines: ctaLines, startMs: ms(ctaStart), durationMs: ms(ctaSec) } }]
|
|
1913
|
+
},
|
|
1914
|
+
// persistent branding across the whole clip.
|
|
1915
|
+
{ at: [0, "end"], layers: [{ k: "branding", p: { logo: brand } }] }
|
|
1916
|
+
],
|
|
1917
|
+
audio: { voicing: "reading" }
|
|
1918
|
+
};
|
|
1919
|
+
}
|
|
1920
|
+
export {
|
|
1921
|
+
FOLLOW_BARS,
|
|
1922
|
+
FOLLOW_PAD,
|
|
1923
|
+
PIANO_HIGH,
|
|
1924
|
+
PIANO_LOW,
|
|
1925
|
+
activeCue,
|
|
1926
|
+
applySchedule,
|
|
1927
|
+
applyToContext,
|
|
1928
|
+
assertGate,
|
|
1929
|
+
blackKeys,
|
|
1930
|
+
brandingFactory,
|
|
1931
|
+
buildScene,
|
|
1932
|
+
cameraForFollow,
|
|
1933
|
+
cameraTransform,
|
|
1934
|
+
clamp,
|
|
1935
|
+
clickTrackSchedule,
|
|
1936
|
+
countInLeadSec,
|
|
1937
|
+
countInSchedule,
|
|
1938
|
+
cropAroundBox,
|
|
1939
|
+
ctaFactory,
|
|
1940
|
+
cubicEaseInOut,
|
|
1941
|
+
cueOpacity,
|
|
1942
|
+
drawCaption,
|
|
1943
|
+
drawHighlight,
|
|
1944
|
+
droneSchedule,
|
|
1945
|
+
duckGainAt,
|
|
1946
|
+
easeIn,
|
|
1947
|
+
easeInOut,
|
|
1948
|
+
easeOut,
|
|
1949
|
+
fallingKeyboardDemoScore,
|
|
1950
|
+
fallingKeyboardDemoSpec,
|
|
1951
|
+
fallingNotesFactory,
|
|
1952
|
+
firstMeasureBox,
|
|
1953
|
+
followBoxAt,
|
|
1954
|
+
followSrcBox,
|
|
1955
|
+
followWindowStart,
|
|
1956
|
+
frameRect,
|
|
1957
|
+
getKeyboardLayout,
|
|
1958
|
+
getLayerFactory,
|
|
1959
|
+
getNotationEngraving,
|
|
1960
|
+
highlightIntensity,
|
|
1961
|
+
hookFactory,
|
|
1962
|
+
identityCamera,
|
|
1963
|
+
inRange,
|
|
1964
|
+
invLerp,
|
|
1965
|
+
isBlackKey,
|
|
1966
|
+
kenBurns,
|
|
1967
|
+
keyCenterX,
|
|
1968
|
+
keyColumnWidth,
|
|
1969
|
+
keyRect,
|
|
1970
|
+
keyboardFactory,
|
|
1971
|
+
keyboardLayout,
|
|
1972
|
+
lerp,
|
|
1973
|
+
lerpBox,
|
|
1974
|
+
lerpCamera,
|
|
1975
|
+
linear,
|
|
1976
|
+
mapBoxThroughLayout,
|
|
1977
|
+
measureColumnsFromLayout,
|
|
1978
|
+
measureCount,
|
|
1979
|
+
measureSpanBox,
|
|
1980
|
+
notationFactory,
|
|
1981
|
+
notationLayout,
|
|
1982
|
+
noteColor,
|
|
1983
|
+
noteSetXRange,
|
|
1984
|
+
playheadLine,
|
|
1985
|
+
portraitFactory,
|
|
1986
|
+
promoCardsDemoSpec,
|
|
1987
|
+
recordSceneSpec,
|
|
1988
|
+
registerLayer,
|
|
1989
|
+
registeredKeys,
|
|
1990
|
+
resolveAnchor,
|
|
1991
|
+
resolveKeyboardLayout,
|
|
1992
|
+
resolveTimeline,
|
|
1993
|
+
revealFactory,
|
|
1994
|
+
runGate,
|
|
1995
|
+
safeGuidesFactory,
|
|
1996
|
+
scoreFromMusicXML,
|
|
1997
|
+
scorePitchSpan,
|
|
1998
|
+
scrollCursorFactory,
|
|
1999
|
+
setFollowLayoutProvider,
|
|
2000
|
+
setKeyboardLayout,
|
|
2001
|
+
setNotationEngraving,
|
|
2002
|
+
setupHeadlessDom,
|
|
2003
|
+
spectrumFactory,
|
|
2004
|
+
visualTimelineMs,
|
|
2005
|
+
whiteKeys,
|
|
2006
|
+
worldToViewport
|
|
2007
|
+
};
|
|
2008
|
+
//# sourceMappingURL=index.js.map
|