@real-music-packages/web-core 0.10.0 → 0.12.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/README.md CHANGED
@@ -36,6 +36,31 @@ peers; OSMD is only loaded via a dynamic import). Pure helpers (`parseMidi`,
36
36
  - **`renderNotation(xml, opts?)`** — renders a MusicXML string via OSMD to a detached canvas; returns per-staff measure boxes (RSR geometry), per-measure column union boxes (RMT geometry), system rows, and content bounds. Parameterisable via `RenderNotationOpts` (paper, inkSumThreshold, hostWidth, bars).
37
37
  - **`createPromoSampler(opts?)`** — creates a Tone.js Salamander sampler wired to a `MediaStreamDestination`. `keepAlive` option feeds a silent ConstantSource so the recorder never drops silent intro scenes (default false; RMT passes true).
38
38
 
39
+ ### `./scene`
40
+ Render-components: the Score model (`scoreFromMusicXML`), the Layer contract +
41
+ SceneSpec runner, and the built-in layers (notation, scroll-cursor, keyboard,
42
+ falling-notes, promo cards, spectrum, branding, and the S5 extended catalog).
43
+
44
+ This barrel is **browser-safe** — it pulls in no Node-only dependencies, so
45
+ Vite/rolldown consumers need **no aliases**. `scoreFromMusicXML` runs unchanged in
46
+ the browser (native canvas handles OSMD's lyric layout).
47
+
48
+ ### `./scene/headless` (Node-only)
49
+ `setupHeadlessDom()` — installs `jsdom` globals + a fake 2D canvas context so OSMD
50
+ can `load()` a score outside a browser (CI, batch, audio-only paths). Import it
51
+ **only in Node**, and call it once before `scoreFromMusicXML` (or pass
52
+ `opts.osmdFactory`):
53
+
54
+ ```ts
55
+ import { setupHeadlessDom } from '@real-music-packages/web-core/scene/headless'; // Node only
56
+ import { scoreFromMusicXML } from '@real-music-packages/web-core/scene';
57
+ await setupHeadlessDom();
58
+ const score = scoreFromMusicXML(xml);
59
+ ```
60
+
61
+ This subpath references `jsdom` and must never be imported from browser code. It
62
+ lives here (not in `./scene`) precisely so the `./scene` barrel stays bundler-safe.
63
+
39
64
  ## ⚠️ Octave-base gotcha (`scales.getMidiNote` / `getScaleDegree`)
40
65
  These are ported verbatim from RealEarTrainer and use **RET's non-standard octave
41
66
  base**: `getMidiNote(1, 'C', 4) === 48`, i.e. one octave below the General-MIDI
@@ -0,0 +1,325 @@
1
+ // src/video.ts
2
+ var SAFE_ZONE = { top: 0.15, bottom: 0.28, left: 0.06, right: 0.12 };
3
+ function safeBox(W, H) {
4
+ const left = W * SAFE_ZONE.left;
5
+ const right = W * (1 - SAFE_ZONE.right);
6
+ const top = H * SAFE_ZONE.top;
7
+ const bottom = H * (1 - SAFE_ZONE.bottom);
8
+ return {
9
+ left,
10
+ right,
11
+ top,
12
+ bottom,
13
+ w: right - left,
14
+ h: bottom - top,
15
+ cx: (left + right) / 2,
16
+ cy: (top + bottom) / 2,
17
+ centeredW: 2 * Math.min(W / 2 - left, right - W / 2)
18
+ };
19
+ }
20
+ function safeTitleBaseline(size, H) {
21
+ return H * SAFE_ZONE.top + size * 0.8 + 4;
22
+ }
23
+ function drawSafeGuides(ctx) {
24
+ const W = ctx.canvas.width, H = ctx.canvas.height;
25
+ const b = safeBox(W, H);
26
+ ctx.save();
27
+ ctx.globalAlpha = 1;
28
+ ctx.fillStyle = "rgba(220,40,40,0.35)";
29
+ ctx.fillRect(0, 0, W, b.top);
30
+ ctx.fillRect(0, b.bottom, W, H - b.bottom);
31
+ ctx.fillRect(0, b.top, b.left, b.h);
32
+ ctx.fillRect(b.right, b.top, W - b.right, b.h);
33
+ ctx.strokeStyle = "rgba(40,200,90,1)";
34
+ ctx.lineWidth = 6;
35
+ ctx.strokeRect(b.left, b.top, b.w, b.h);
36
+ ctx.restore();
37
+ }
38
+ function pickMimeType() {
39
+ const candidates = [
40
+ "video/webm;codecs=vp9,opus",
41
+ "video/webm;codecs=vp8,opus",
42
+ "video/webm"
43
+ ];
44
+ for (const t of candidates) {
45
+ if (typeof MediaRecorder !== "undefined" && MediaRecorder.isTypeSupported(t)) return t;
46
+ }
47
+ return "video/webm";
48
+ }
49
+ async function recordScenes(scenes, opts) {
50
+ const { audioStream, width, height, fps = 30, background = "#000000", onProgress } = opts;
51
+ const canvas = document.createElement("canvas");
52
+ canvas.width = width;
53
+ canvas.height = height;
54
+ const maybeCtx = canvas.getContext("2d");
55
+ if (!maybeCtx) throw new Error("Failed to get 2D context");
56
+ const ctx = maybeCtx;
57
+ const videoStream = canvas.captureStream(fps);
58
+ const combined = new MediaStream([
59
+ ...videoStream.getVideoTracks(),
60
+ ...audioStream.getAudioTracks()
61
+ ]);
62
+ const mimeType = pickMimeType();
63
+ const recorder = new MediaRecorder(combined, {
64
+ mimeType,
65
+ videoBitsPerSecond: 4e6,
66
+ audioBitsPerSecond: 128e3
67
+ });
68
+ const chunks = [];
69
+ recorder.ondataavailable = (e) => {
70
+ if (e.data.size > 0) chunks.push(e.data);
71
+ };
72
+ const stopped = new Promise((res) => {
73
+ recorder.onstop = () => res();
74
+ });
75
+ recorder.start();
76
+ const totalMs = scenes.reduce((s, sc) => s + sc.durationMs, 0);
77
+ const t0 = performance.now();
78
+ onProgress?.({ phase: "capturing", progress01: 0 });
79
+ let sceneStart = 0;
80
+ let sceneIdx = 0;
81
+ let enteredScene = -1;
82
+ ctx.fillStyle = background;
83
+ ctx.fillRect(0, 0, width, height);
84
+ scenes[0].draw(ctx, 0);
85
+ await new Promise((resolve) => {
86
+ function tick() {
87
+ const now = performance.now() - t0;
88
+ if (now >= totalMs) {
89
+ setTimeout(resolve, 100);
90
+ return;
91
+ }
92
+ while (sceneIdx < scenes.length - 1 && now - sceneStart >= scenes[sceneIdx].durationMs) {
93
+ sceneStart += scenes[sceneIdx].durationMs;
94
+ sceneIdx += 1;
95
+ }
96
+ const scene = scenes[sceneIdx];
97
+ if (enteredScene !== sceneIdx) {
98
+ enteredScene = sceneIdx;
99
+ scene.onEnter?.();
100
+ }
101
+ const tInScene = (now - sceneStart) / scene.durationMs;
102
+ ctx.fillStyle = background;
103
+ ctx.fillRect(0, 0, width, height);
104
+ scene.draw(ctx, Math.min(1, Math.max(0, tInScene)));
105
+ onProgress?.({
106
+ phase: "capturing",
107
+ progress01: Math.min(0.95, now / totalMs)
108
+ });
109
+ requestAnimationFrame(tick);
110
+ }
111
+ requestAnimationFrame(tick);
112
+ });
113
+ onProgress?.({ phase: "finalizing", progress01: 0.96 });
114
+ recorder.stop();
115
+ await stopped;
116
+ return new Blob(chunks, { type: mimeType });
117
+ }
118
+ function truncate(s, max) {
119
+ return s.length > max ? s.slice(0, max - 1).trimEnd() + "\u2026" : s;
120
+ }
121
+ function initials(name) {
122
+ return name.split(/\s+/).filter(Boolean).slice(0, 3).map((w) => w[0]).join("").toUpperCase();
123
+ }
124
+ function drawBottomGradient(ctx, opts = {}) {
125
+ const W = ctx.canvas.width, H = ctx.canvas.height;
126
+ const heightFrac = opts.heightFrac ?? 0.3;
127
+ const maxAlpha = opts.maxAlpha ?? 0.45;
128
+ const top = H * (1 - heightFrac);
129
+ const g = ctx.createLinearGradient(0, top, 0, H);
130
+ g.addColorStop(0, "rgba(0,0,0,0)");
131
+ g.addColorStop(1, `rgba(0,0,0,${maxAlpha})`);
132
+ ctx.save();
133
+ ctx.fillStyle = g;
134
+ ctx.fillRect(0, top, W, H - top);
135
+ ctx.restore();
136
+ }
137
+ function hookScene(theme, opts) {
138
+ return {
139
+ durationMs: 2e3,
140
+ draw(ctx, _t01) {
141
+ const W = ctx.canvas.width;
142
+ const H = ctx.canvas.height;
143
+ drawBottomGradient(ctx);
144
+ ctx.textAlign = "center";
145
+ ctx.fillStyle = theme.ink;
146
+ ctx.font = `bold 88px ${theme.fontDisplay}`;
147
+ opts.lines.forEach((line, i) => {
148
+ ctx.fillText(line, W / 2, H * 0.42 + i * 104);
149
+ });
150
+ const brand = opts.brand ?? theme.brand;
151
+ ctx.font = `italic 38px ${theme.fontBody}`;
152
+ ctx.fillStyle = theme.sepia;
153
+ ctx.fillText(brand, W / 2, H * 0.42 + opts.lines.length * 104 + 12);
154
+ }
155
+ };
156
+ }
157
+ var _portraitCache = /* @__PURE__ */ new Map();
158
+ function loadPortrait(url) {
159
+ if (!url) return Promise.resolve(null);
160
+ const cached = _portraitCache.get(url);
161
+ if (cached) return cached;
162
+ const p = new Promise((resolve) => {
163
+ if (typeof Image === "undefined") {
164
+ resolve(null);
165
+ return;
166
+ }
167
+ const img = new Image();
168
+ img.crossOrigin = "anonymous";
169
+ img.onload = () => resolve(img);
170
+ img.onerror = () => resolve(null);
171
+ img.src = url;
172
+ });
173
+ _portraitCache.set(url, p);
174
+ return p;
175
+ }
176
+ function wrapLines(ctx, text, maxW, maxLines) {
177
+ const words = text.split(/\s+/).filter(Boolean);
178
+ const lines = [];
179
+ let cur = "";
180
+ for (const w of words) {
181
+ const next = cur ? `${cur} ${w}` : w;
182
+ if (ctx.measureText(next).width > maxW && cur) {
183
+ lines.push(cur);
184
+ cur = w;
185
+ if (lines.length === maxLines - 1) break;
186
+ } else {
187
+ cur = next;
188
+ }
189
+ }
190
+ if (cur && lines.length < maxLines) lines.push(cur);
191
+ const used = lines.join(" ");
192
+ if (used.length < text.length && lines.length) {
193
+ lines[lines.length - 1] = truncate(lines[lines.length - 1] + "\u2026", 999);
194
+ }
195
+ return lines;
196
+ }
197
+ function revealScene(theme, opts) {
198
+ return {
199
+ durationMs: 2200,
200
+ draw(ctx, t01) {
201
+ const W = ctx.canvas.width;
202
+ const H = ctx.canvas.height;
203
+ const b = safeBox(W, H);
204
+ const cx = W / 2;
205
+ const cy = b.top + b.h * 0.4286;
206
+ const r = 92;
207
+ const k = Math.min(1, t01 * 1.4);
208
+ ctx.globalAlpha = k;
209
+ ctx.textAlign = "center";
210
+ ctx.beginPath();
211
+ ctx.arc(cx, cy, r, 0, Math.PI * 2);
212
+ ctx.fillStyle = theme.paper;
213
+ ctx.fill();
214
+ if (opts.portrait) {
215
+ ctx.save();
216
+ ctx.beginPath();
217
+ ctx.arc(cx, cy, r - 3, 0, Math.PI * 2);
218
+ ctx.clip();
219
+ const img = opts.portrait;
220
+ const iw = img.naturalWidth || img.width || 1;
221
+ const ih = img.naturalHeight || img.height || 1;
222
+ const scale = Math.max(2 * (r - 3) / iw, 2 * (r - 3) / ih);
223
+ const dw = iw * scale;
224
+ const dh = ih * scale;
225
+ ctx.drawImage(img, cx - dw / 2, cy - dh / 2, dw, dh);
226
+ ctx.restore();
227
+ } else {
228
+ ctx.fillStyle = theme.ink;
229
+ ctx.font = `bold 76px ${theme.fontDisplay}`;
230
+ const badge = opts.initials ?? initials(opts.title);
231
+ ctx.fillText(badge, cx, cy + 27);
232
+ }
233
+ ctx.lineWidth = 5;
234
+ ctx.strokeStyle = theme.gold;
235
+ ctx.stroke();
236
+ const truncated = truncate(opts.title, 24);
237
+ let fontSize = 80;
238
+ ctx.font = `bold ${fontSize}px ${theme.fontDisplay}`;
239
+ if (ctx.measureText(truncated).width > W * 0.86) {
240
+ fontSize = 68;
241
+ ctx.font = `bold ${fontSize}px ${theme.fontDisplay}`;
242
+ }
243
+ ctx.fillStyle = theme.ink;
244
+ ctx.fillText(truncated, cx, cy + r + 110);
245
+ ctx.font = `italic 50px ${theme.fontBody}`;
246
+ ctx.fillStyle = theme.sepia;
247
+ ctx.fillText(opts.subtitle, cx, cy + r + 176);
248
+ if (opts.funFact) {
249
+ ctx.font = `34px ${theme.fontBody}`;
250
+ ctx.fillStyle = theme.sepia;
251
+ const lines = wrapLines(ctx, opts.funFact, b.w * 0.92, 2);
252
+ lines.forEach((line, i) => {
253
+ ctx.fillText(line, cx, cy + r + 246 + i * 44);
254
+ });
255
+ }
256
+ ctx.globalAlpha = 1;
257
+ }
258
+ };
259
+ }
260
+ function ctaScene(theme, opts) {
261
+ return {
262
+ durationMs: 2500,
263
+ draw(ctx, _t01) {
264
+ const W = ctx.canvas.width;
265
+ const H = ctx.canvas.height;
266
+ const b = safeBox(W, H);
267
+ const baseY = b.cy - 40;
268
+ ctx.textAlign = "center";
269
+ if (opts.lines.length > 0) {
270
+ ctx.fillStyle = theme.ink;
271
+ ctx.font = `bold 66px ${theme.fontDisplay}`;
272
+ ctx.fillText(opts.lines[0], W / 2, baseY);
273
+ }
274
+ opts.lines.slice(1).forEach((line, i) => {
275
+ ctx.fillStyle = theme.accent;
276
+ ctx.font = `bold 72px ${theme.fontDisplay}`;
277
+ ctx.fillText(line, W / 2, baseY + 100 * (i + 1));
278
+ });
279
+ }
280
+ };
281
+ }
282
+ async function runPromoCapture(opts, record = recordScenes) {
283
+ const w = window;
284
+ w.__promoMeta = opts.meta;
285
+ try {
286
+ const blob = await record(opts.buildScenes(), {
287
+ audioStream: opts.audioStream,
288
+ width: opts.width ?? 1080,
289
+ height: opts.height ?? 1920,
290
+ fps: opts.fps,
291
+ background: opts.background
292
+ });
293
+ const url = URL.createObjectURL(blob);
294
+ w.__promoBlobUrl = url;
295
+ if (opts.download !== false) {
296
+ const a = document.createElement("a");
297
+ a.href = url;
298
+ a.download = `promo-${opts.meta.id ?? "clip"}.webm`;
299
+ document.body.appendChild(a);
300
+ a.click();
301
+ }
302
+ w.__promoReady = true;
303
+ } catch (e) {
304
+ w.__promoError = String(e);
305
+ throw e;
306
+ }
307
+ }
308
+
309
+ export {
310
+ SAFE_ZONE,
311
+ safeBox,
312
+ safeTitleBaseline,
313
+ drawSafeGuides,
314
+ pickMimeType,
315
+ recordScenes,
316
+ truncate,
317
+ initials,
318
+ drawBottomGradient,
319
+ hookScene,
320
+ loadPortrait,
321
+ revealScene,
322
+ ctaScene,
323
+ runPromoCapture
324
+ };
325
+ //# sourceMappingURL=chunk-HXTRNE74.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/video.ts"],"sourcesContent":["/**\n * Framework-agnostic promo video module.\n *\n * Provides:\n * - PromoTheme — colour/font/brand tokens\n * - Scene — {durationMs, draw, onEnter?}\n * - recordScenes — rAF-based canvas capture → Blob (browser only)\n * - hookScene / revealScene / ctaScene — scene builders (pure, unit-testable)\n * - runPromoCapture — orchestrator with dependency-injected recorder\n *\n * Visual style ported from whozart/src/lib/promo.ts + video.ts.\n */\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface PromoTheme {\n paper: string;\n ink: string;\n accent: string;\n sepia: string;\n gold: string;\n fontDisplay: string;\n fontBody: string;\n brand: string;\n}\n\nexport interface Scene {\n durationMs: number;\n draw: (ctx: CanvasRenderingContext2D, t01: number) => void;\n onEnter?: () => void;\n}\n\nexport interface RecordOpts {\n audioStream: MediaStream;\n width: number;\n height: number;\n fps?: number;\n background?: string;\n onProgress?: (p: { phase: string; progress01: number }) => void;\n}\n\nexport interface PromoMeta {\n id?: string;\n title?: string;\n composer?: string;\n [k: string]: unknown;\n}\n\nexport interface PromoCaptureOpts {\n buildScenes: () => Scene[];\n audioStream: MediaStream;\n meta: PromoMeta;\n width?: number;\n height?: number;\n fps?: number;\n background?: string;\n download?: boolean;\n}\n\n// ─── Safe zone (phone short-form UI) ─────────────────────────────────────────\n\n/**\n * Insets (fractions of frame) kept clear of TikTok/Reels/Shorts UI overlays.\n * `bottom: 0.28` puts the safe-box bottom at 0.72·H — TikTok's caption + right\n * action rail reach ~25–28% up, tighter than the old 0.20, and a posted RET clip\n * had its keyboard clipped at 0.20. `top: 0.15` clears TikTok's top tabs/search\n * row — measured from a real screenshot, the \"Find related content\" search bar\n * bottom sits at ~0.114·H, so 0.10 clashed and 0.13 cleared by only ~31px; 0.15\n * gives a comfortable ~73px. This is the SINGLE source of truth for both safe lines:\n * consumers anchor bottom content to `safeBox().bottom` and top titles to\n * `safeTitleBaseline()` instead of hardcoding. Verify any layout with `?safe=1`.\n */\nexport const SAFE_ZONE = { top: 0.15, bottom: 0.28, left: 0.06, right: 0.12 } as const;\n\nexport interface SafeBox {\n left: number; right: number; top: number; bottom: number;\n w: number; h: number; cx: number; cy: number;\n /** Widest block centered at frame W/2 that still fits inside the box. */\n centeredW: number;\n}\n\n/** The usable content rectangle for a W×H frame, in pixels. */\nexport function safeBox(W: number, H: number): SafeBox {\n const left = W * SAFE_ZONE.left;\n const right = W * (1 - SAFE_ZONE.right);\n const top = H * SAFE_ZONE.top;\n const bottom = H * (1 - SAFE_ZONE.bottom);\n return {\n left, right, top, bottom,\n w: right - left, h: bottom - top,\n cx: (left + right) / 2, cy: (top + bottom) / 2,\n centeredW: 2 * Math.min(W / 2 - left, right - W / 2),\n };\n}\n\n/**\n * Lowest first-line baseline (for an ALPHABETIC-baseline title of `size` px) that\n * keeps the title's cap-top inside the safe box top. Phone top chrome (TikTok/Reels\n * \"For You\" tabs + search) sits above `safeBox().top`, and an alphabetic baseline\n * draws glyphs ~0.8*size ABOVE the baseline — so a small `topY` silently breaches\n * the top. Apps clamp their top titles: `y = Math.max(topY, safeTitleBaseline(size, H))`.\n * Single source of truth for the top-safe line, mirroring `safeBox().bottom`.\n */\nexport function safeTitleBaseline(size: number, H: number): number {\n return H * SAFE_ZONE.top + size * 0.8 + 4;\n}\n\n/** Debug overlay: dim the four unsafe margins + outline the safe box. */\nexport function drawSafeGuides(ctx: CanvasRenderingContext2D): void {\n const W = ctx.canvas.width, H = ctx.canvas.height;\n const b = safeBox(W, H);\n ctx.save();\n ctx.globalAlpha = 1;\n ctx.fillStyle = 'rgba(220,40,40,0.35)';\n ctx.fillRect(0, 0, W, b.top); // top\n ctx.fillRect(0, b.bottom, W, H - b.bottom); // bottom\n ctx.fillRect(0, b.top, b.left, b.h); // left\n ctx.fillRect(b.right, b.top, W - b.right, b.h); // right (action rail)\n ctx.strokeStyle = 'rgba(40,200,90,1)';\n ctx.lineWidth = 6;\n ctx.strokeRect(b.left, b.top, b.w, b.h);\n ctx.restore();\n}\n\n// ─── MIME type picker ─────────────────────────────────────────────────────────\n\nexport function pickMimeType(): string {\n const candidates = [\n 'video/webm;codecs=vp9,opus',\n 'video/webm;codecs=vp8,opus',\n 'video/webm',\n ];\n for (const t of candidates) {\n if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(t)) return t;\n }\n return 'video/webm';\n}\n\n// ─── recordScenes ─────────────────────────────────────────────────────────────\n\nexport async function recordScenes(scenes: Scene[], opts: RecordOpts): Promise<Blob> {\n const { audioStream, width, height, fps = 30, background = '#000000', onProgress } = opts;\n\n const canvas = document.createElement('canvas');\n canvas.width = width;\n canvas.height = height;\n\n const maybeCtx = canvas.getContext('2d');\n if (!maybeCtx) throw new Error('Failed to get 2D context');\n const ctx: CanvasRenderingContext2D = maybeCtx;\n\n const videoStream = canvas.captureStream(fps);\n const combined = new MediaStream([\n ...videoStream.getVideoTracks(),\n ...audioStream.getAudioTracks(),\n ]);\n\n const mimeType = pickMimeType();\n const recorder = new MediaRecorder(combined, {\n mimeType,\n videoBitsPerSecond: 4_000_000,\n audioBitsPerSecond: 128_000,\n });\n\n const chunks: Blob[] = [];\n recorder.ondataavailable = (e) => {\n if (e.data.size > 0) chunks.push(e.data);\n };\n const stopped = new Promise<void>((res) => {\n recorder.onstop = () => res();\n });\n\n recorder.start();\n\n const totalMs = scenes.reduce((s, sc) => s + sc.durationMs, 0);\n const t0 = performance.now();\n\n onProgress?.({ phase: 'capturing', progress01: 0 });\n\n let sceneStart = 0;\n let sceneIdx = 0;\n let enteredScene = -1;\n\n // Draw first frame immediately\n ctx.fillStyle = background;\n ctx.fillRect(0, 0, width, height);\n scenes[0].draw(ctx, 0);\n\n await new Promise<void>((resolve) => {\n function tick() {\n const now = performance.now() - t0;\n if (now >= totalMs) {\n setTimeout(resolve, 100);\n return;\n }\n\n // Advance scene index\n while (\n sceneIdx < scenes.length - 1 &&\n now - sceneStart >= scenes[sceneIdx].durationMs\n ) {\n sceneStart += scenes[sceneIdx].durationMs;\n sceneIdx += 1;\n }\n\n const scene = scenes[sceneIdx];\n\n // Fire onEnter once per scene\n if (enteredScene !== sceneIdx) {\n enteredScene = sceneIdx;\n scene.onEnter?.();\n }\n\n const tInScene = (now - sceneStart) / scene.durationMs;\n\n // Clear to background\n ctx.fillStyle = background;\n ctx.fillRect(0, 0, width, height);\n\n scene.draw(ctx, Math.min(1, Math.max(0, tInScene)));\n\n onProgress?.({\n phase: 'capturing',\n progress01: Math.min(0.95, now / totalMs),\n });\n\n requestAnimationFrame(tick);\n }\n\n requestAnimationFrame(tick);\n });\n\n onProgress?.({ phase: 'finalizing', progress01: 0.96 });\n recorder.stop();\n await stopped;\n\n return new Blob(chunks, { type: mimeType });\n}\n\n// ─── Helpers ─────────────────────────────────────────────────────────────────\n\nexport function truncate(s: string, max: number): string {\n return s.length > max ? s.slice(0, max - 1).trimEnd() + '…' : s;\n}\n\nexport function initials(name: string): string {\n return name\n .split(/\\s+/)\n .filter(Boolean)\n .slice(0, 3)\n .map((w) => w[0])\n .join('')\n .toUpperCase();\n}\n\n// ─── Scene builders ───────────────────────────────────────────────────────────\n\n/**\n * Dark gradient rising from the frame bottom (opaque-ish at the very bottom,\n * fading out by `heightFrac` up). TikTok overlays the view count bottom-left of\n * every profile-grid thumbnail in WHITE — on our light start screens it was\n * unreadable. Draw this after the background, before content.\n */\nexport function drawBottomGradient(\n ctx: CanvasRenderingContext2D,\n opts: { heightFrac?: number; maxAlpha?: number } = {},\n): void {\n const W = ctx.canvas.width, H = ctx.canvas.height;\n const heightFrac = opts.heightFrac ?? 0.30;\n const maxAlpha = opts.maxAlpha ?? 0.45;\n const top = H * (1 - heightFrac);\n const g = ctx.createLinearGradient(0, top, 0, H);\n g.addColorStop(0, \"rgba(0,0,0,0)\");\n g.addColorStop(1, `rgba(0,0,0,${maxAlpha})`);\n ctx.save();\n ctx.fillStyle = g;\n ctx.fillRect(0, top, W, H - top);\n ctx.restore();\n}\n\nexport interface HookSceneOpts {\n lines: string[];\n brand?: string;\n}\n\nexport function hookScene(theme: PromoTheme, opts: HookSceneOpts): Scene {\n return {\n durationMs: 2000,\n draw(ctx, _t01) {\n const W = ctx.canvas.width;\n const H = ctx.canvas.height;\n // The hook is the profile-grid thumbnail; the bottom gradient keeps\n // TikTok's white view-count overlay readable on light backdrops.\n drawBottomGradient(ctx);\n ctx.textAlign = 'center';\n ctx.fillStyle = theme.ink;\n ctx.font = `bold 88px ${theme.fontDisplay}`;\n opts.lines.forEach((line, i) => {\n ctx.fillText(line, W / 2, H * 0.42 + i * 104);\n });\n const brand = opts.brand ?? theme.brand;\n ctx.font = `italic 38px ${theme.fontBody}`;\n ctx.fillStyle = theme.sepia;\n ctx.fillText(brand, W / 2, H * 0.42 + opts.lines.length * 104 + 12);\n },\n };\n}\n\nexport interface RevealSceneOpts {\n title: string;\n subtitle: string;\n initials?: string;\n /** Composer portrait (already decoded) drawn clipped into the medallion. If\n * omitted/null, falls back to the initials badge. Load it with `loadPortrait`\n * so a cross-origin image (e.g. Wikimedia) doesn't taint the capture canvas. */\n portrait?: HTMLImageElement | null;\n /** Optional one-line fun fact wrapped below the subtitle (e.g. corpus\n * `fun_fact`). Trimmed to two lines. */\n funFact?: string;\n}\n\n/** Load an image for canvas capture: `crossOrigin='anonymous'` so a CORS-enabled\n * cross-origin source (Wikimedia portraits send `Access-Control-Allow-Origin: *`)\n * can be drawn onto a captured canvas WITHOUT tainting it. Fail-soft: resolves\n * null on error/missing url (callers fall back to initials). Cached per-url. */\nconst _portraitCache = new Map<string, Promise<HTMLImageElement | null>>();\nexport function loadPortrait(url?: string | null): Promise<HTMLImageElement | null> {\n if (!url) return Promise.resolve(null);\n const cached = _portraitCache.get(url);\n if (cached) return cached;\n const p = new Promise<HTMLImageElement | null>((resolve) => {\n if (typeof Image === 'undefined') { resolve(null); return; }\n const img = new Image();\n img.crossOrigin = 'anonymous';\n img.onload = () => resolve(img);\n img.onerror = () => resolve(null);\n img.src = url;\n });\n _portraitCache.set(url, p);\n return p;\n}\n\n/** Greedy word-wrap for the current ctx.font into at most `maxLines` lines. */\nfunction wrapLines(ctx: CanvasRenderingContext2D, text: string, maxW: number, maxLines: number): string[] {\n const words = text.split(/\\s+/).filter(Boolean);\n const lines: string[] = [];\n let cur = '';\n for (const w of words) {\n const next = cur ? `${cur} ${w}` : w;\n if (ctx.measureText(next).width > maxW && cur) {\n lines.push(cur);\n cur = w;\n if (lines.length === maxLines - 1) break;\n } else {\n cur = next;\n }\n }\n if (cur && lines.length < maxLines) lines.push(cur);\n // If we ran out of line budget mid-text, ellipsize the last line.\n const used = lines.join(' ');\n if (used.length < text.length && lines.length) {\n lines[lines.length - 1] = truncate(lines[lines.length - 1] + '…', 999);\n }\n return lines;\n}\n\nexport function revealScene(theme: PromoTheme, opts: RevealSceneOpts): Scene {\n return {\n durationMs: 2200,\n draw(ctx, t01) {\n const W = ctx.canvas.width;\n const H = ctx.canvas.height;\n const b = safeBox(W, H);\n const cx = W / 2;\n // Anchor through the safe box; ~0.4286 of box height ≈ original H*0.40 (no visible shift at 1080×1920).\n const cy = b.top + b.h * 0.4286;\n const r = 92;\n const k = Math.min(1, t01 * 1.4);\n ctx.globalAlpha = k;\n ctx.textAlign = 'center';\n\n // Medallion circle (faint surface) + gold ring.\n ctx.beginPath();\n ctx.arc(cx, cy, r, 0, Math.PI * 2);\n ctx.fillStyle = theme.paper;\n ctx.fill();\n\n if (opts.portrait) {\n // Portrait clipped into the medallion, cover-fit (centred crop).\n ctx.save();\n ctx.beginPath();\n ctx.arc(cx, cy, r - 3, 0, Math.PI * 2);\n ctx.clip();\n const img = opts.portrait;\n const iw = img.naturalWidth || img.width || 1;\n const ih = img.naturalHeight || img.height || 1;\n const scale = Math.max((2 * (r - 3)) / iw, (2 * (r - 3)) / ih);\n const dw = iw * scale;\n const dh = ih * scale;\n ctx.drawImage(img, cx - dw / 2, cy - dh / 2, dw, dh);\n ctx.restore();\n } else {\n // Initials inside medallion (fallback).\n ctx.fillStyle = theme.ink;\n ctx.font = `bold 76px ${theme.fontDisplay}`;\n const badge = opts.initials ?? initials(opts.title);\n ctx.fillText(badge, cx, cy + 27);\n }\n\n ctx.lineWidth = 5;\n ctx.strokeStyle = theme.gold;\n ctx.stroke();\n\n // Title (with font-size fallback if too wide)\n const truncated = truncate(opts.title, 24);\n let fontSize = 80;\n ctx.font = `bold ${fontSize}px ${theme.fontDisplay}`;\n if (ctx.measureText(truncated).width > W * 0.86) {\n fontSize = 68;\n ctx.font = `bold ${fontSize}px ${theme.fontDisplay}`;\n }\n ctx.fillStyle = theme.ink;\n ctx.fillText(truncated, cx, cy + r + 110);\n\n // Subtitle\n ctx.font = `italic 50px ${theme.fontBody}`;\n ctx.fillStyle = theme.sepia;\n ctx.fillText(opts.subtitle, cx, cy + r + 176);\n\n // Optional fun fact, wrapped to two lines below the subtitle.\n if (opts.funFact) {\n ctx.font = `34px ${theme.fontBody}`;\n ctx.fillStyle = theme.sepia;\n const lines = wrapLines(ctx, opts.funFact, b.w * 0.92, 2);\n lines.forEach((line, i) => {\n ctx.fillText(line, cx, cy + r + 246 + i * 44);\n });\n }\n\n ctx.globalAlpha = 1;\n },\n };\n}\n\nexport interface CtaSceneOpts {\n lines: string[];\n}\n\nexport function ctaScene(theme: PromoTheme, opts: CtaSceneOpts): Scene {\n return {\n durationMs: 2500,\n draw(ctx, _t01) {\n const W = ctx.canvas.width;\n const H = ctx.canvas.height;\n const b = safeBox(W, H);\n // Safe-box-relative base ≈ original H*0.42 (no visible shift at 1080×1920).\n const baseY = b.cy - 40;\n ctx.textAlign = 'center';\n if (opts.lines.length > 0) {\n ctx.fillStyle = theme.ink;\n ctx.font = `bold 66px ${theme.fontDisplay}`;\n ctx.fillText(opts.lines[0], W / 2, baseY);\n }\n opts.lines.slice(1).forEach((line, i) => {\n ctx.fillStyle = theme.accent;\n ctx.font = `bold 72px ${theme.fontDisplay}`;\n ctx.fillText(line, W / 2, baseY + 100 * (i + 1));\n });\n },\n };\n}\n\n// ─── runPromoCapture ──────────────────────────────────────────────────────────\n\nexport async function runPromoCapture(\n opts: PromoCaptureOpts,\n record: (scenes: Scene[], o: RecordOpts) => Promise<Blob> = recordScenes,\n): Promise<void> {\n const w = window as unknown as Record<string, unknown>;\n w.__promoMeta = opts.meta;\n try {\n const blob = await record(opts.buildScenes(), {\n audioStream: opts.audioStream,\n width: opts.width ?? 1080,\n height: opts.height ?? 1920,\n fps: opts.fps,\n background: opts.background,\n });\n const url = URL.createObjectURL(blob);\n w.__promoBlobUrl = url;\n if (opts.download !== false) {\n const a = document.createElement('a');\n a.href = url;\n a.download = `promo-${opts.meta.id ?? 'clip'}.webm`;\n document.body.appendChild(a);\n a.click();\n }\n w.__promoReady = true;\n } catch (e) {\n w.__promoError = String(e);\n throw e;\n }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Install jsdom globals + a fake 2D canvas context so OSMD can `load()` a score
3
+ * headlessly. Idempotent. Call once before constructing an OSMD instance in Node.
4
+ *
5
+ * No-ops if `window`/`document` already exist (e.g. a real browser, or a vitest
6
+ * jsdom environment), so it is safe to call unconditionally.
7
+ */
8
+ declare function setupHeadlessDom(): Promise<void>;
9
+
10
+ export { setupHeadlessDom };
@@ -0,0 +1,108 @@
1
+ // src/scene/headless.ts
2
+ var installed = false;
3
+ async function setupHeadlessDom() {
4
+ if (installed) return;
5
+ const g = globalThis;
6
+ if (typeof g.document !== "undefined" && typeof g.window !== "undefined") {
7
+ ensureFakeContext(g.window);
8
+ installed = true;
9
+ return;
10
+ }
11
+ const { JSDOM } = await import("jsdom");
12
+ const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>", {
13
+ pretendToBeVisual: true
14
+ });
15
+ const { window } = dom;
16
+ ensureFakeContext(window);
17
+ g.window = window;
18
+ g.document = window.document;
19
+ try {
20
+ g.navigator = window.navigator;
21
+ } catch {
22
+ }
23
+ g.HTMLElement = window.HTMLElement;
24
+ g.Node = window.Node;
25
+ g.DOMParser = window.DOMParser;
26
+ g.XMLSerializer = window.XMLSerializer;
27
+ g.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0);
28
+ g.cancelAnimationFrame = () => {
29
+ };
30
+ installed = true;
31
+ }
32
+ function ensureFakeContext(window) {
33
+ const proto = window.HTMLCanvasElement?.prototype;
34
+ if (!proto) return;
35
+ const fakeCtx = makeFakeContext();
36
+ proto.getContext = function() {
37
+ return fakeCtx;
38
+ };
39
+ }
40
+ function makeFakeContext() {
41
+ return {
42
+ font: "10px Arial",
43
+ fillStyle: "#000",
44
+ strokeStyle: "#000",
45
+ lineWidth: 1,
46
+ textAlign: "left",
47
+ textBaseline: "alphabetic",
48
+ globalAlpha: 1,
49
+ measureText: (s) => ({
50
+ width: (s ? s.length : 0) * 6,
51
+ actualBoundingBoxAscent: 8,
52
+ actualBoundingBoxDescent: 2
53
+ }),
54
+ save() {
55
+ },
56
+ restore() {
57
+ },
58
+ beginPath() {
59
+ },
60
+ closePath() {
61
+ },
62
+ moveTo() {
63
+ },
64
+ lineTo() {
65
+ },
66
+ bezierCurveTo() {
67
+ },
68
+ quadraticCurveTo() {
69
+ },
70
+ arc() {
71
+ },
72
+ rect() {
73
+ },
74
+ fill() {
75
+ },
76
+ stroke() {
77
+ },
78
+ fillRect() {
79
+ },
80
+ clearRect() {
81
+ },
82
+ fillText() {
83
+ },
84
+ strokeText() {
85
+ },
86
+ translate() {
87
+ },
88
+ rotate() {
89
+ },
90
+ scale() {
91
+ },
92
+ setTransform() {
93
+ },
94
+ transform() {
95
+ },
96
+ drawImage() {
97
+ },
98
+ clip() {
99
+ },
100
+ createLinearGradient: () => ({ addColorStop() {
101
+ } }),
102
+ getImageData: () => ({ data: new Uint8ClampedArray(4) })
103
+ };
104
+ }
105
+ export {
106
+ setupHeadlessDom
107
+ };
108
+ //# sourceMappingURL=headless.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/scene/headless.ts"],"sourcesContent":["// Headless DOM setup for running OSMD outside a browser (Node — CI, batch,\n// whozart's notation-less audio path).\n//\n// OSMD's load() runs graphical layout, which calls canvas 2D text metrics.\n// Bare jsdom returns null for getContext('2d'), so layout throws on scores\n// with lyrics. We install a minimal fake 2D context so layout completes; we\n// never read the pixels — only osmd.sheet (the source model) is consumed by\n// scoreFromMusicXML, so a no-op context is sufficient.\n//\n// The SAME extraction code (scoreFromMusicXML) runs unchanged in a real\n// browser: there the native 2D context handles lyric layout and this helper is\n// never called. This module is the only Node-specific seam.\n//\n// jsdom is a devDependency (used by tests + headless callers); it is imported\n// dynamically so bundling for the browser never pulls it in.\n\n/* eslint-disable @typescript-eslint/no-explicit-any */\n\nlet installed = false;\n\n/**\n * Install jsdom globals + a fake 2D canvas context so OSMD can `load()` a score\n * headlessly. Idempotent. Call once before constructing an OSMD instance in Node.\n *\n * No-ops if `window`/`document` already exist (e.g. a real browser, or a vitest\n * jsdom environment), so it is safe to call unconditionally.\n */\nexport async function setupHeadlessDom(): Promise<void> {\n if (installed) return;\n const g = globalThis as any;\n if (typeof g.document !== 'undefined' && typeof g.window !== 'undefined') {\n // A DOM is already present (browser or test env). Still ensure a usable 2D\n // context for OSMD's lyric layout if jsdom didn't provide one.\n ensureFakeContext(g.window);\n installed = true;\n return;\n }\n\n // Literal specifier so bundlers can tree-shake it out of browser builds.\n const { JSDOM } = await import('jsdom');\n const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {\n pretendToBeVisual: true,\n });\n const { window } = dom;\n\n ensureFakeContext(window);\n\n g.window = window;\n g.document = window.document;\n // Node 26: globalThis.navigator is read-only — tolerate the failure.\n try {\n g.navigator = window.navigator;\n } catch {\n /* read-only on some Node versions; OSMD doesn't require it */\n }\n g.HTMLElement = window.HTMLElement;\n g.Node = window.Node;\n g.DOMParser = window.DOMParser;\n g.XMLSerializer = window.XMLSerializer;\n g.requestAnimationFrame = (cb: (t: number) => void) => setTimeout(() => cb(Date.now()), 0);\n g.cancelAnimationFrame = () => {};\n\n installed = true;\n}\n\n/** Patch HTMLCanvasElement.getContext to return a no-op 2D context. */\nfunction ensureFakeContext(window: any): void {\n const proto = window.HTMLCanvasElement?.prototype;\n if (!proto) return;\n const fakeCtx = makeFakeContext();\n proto.getContext = function () {\n return fakeCtx;\n };\n}\n\n/** A minimal 2D context: text metrics return a rough width; everything else is a no-op. */\nfunction makeFakeContext(): any {\n return {\n font: '10px Arial',\n fillStyle: '#000',\n strokeStyle: '#000',\n lineWidth: 1,\n textAlign: 'left',\n textBaseline: 'alphabetic',\n globalAlpha: 1,\n measureText: (s: string) => ({\n width: (s ? s.length : 0) * 6,\n actualBoundingBoxAscent: 8,\n actualBoundingBoxDescent: 2,\n }),\n save() {},\n restore() {},\n beginPath() {},\n closePath() {},\n moveTo() {},\n lineTo() {},\n bezierCurveTo() {},\n quadraticCurveTo() {},\n arc() {},\n rect() {},\n fill() {},\n stroke() {},\n fillRect() {},\n clearRect() {},\n fillText() {},\n strokeText() {},\n translate() {},\n rotate() {},\n scale() {},\n setTransform() {},\n transform() {},\n drawImage() {},\n clip() {},\n createLinearGradient: () => ({ addColorStop() {} }),\n getImageData: () => ({ data: new Uint8ClampedArray(4) }),\n };\n}\n"],"mappings":";AAkBA,IAAI,YAAY;AAShB,eAAsB,mBAAkC;AACtD,MAAI,UAAW;AACf,QAAM,IAAI;AACV,MAAI,OAAO,EAAE,aAAa,eAAe,OAAO,EAAE,WAAW,aAAa;AAGxE,sBAAkB,EAAE,MAAM;AAC1B,gBAAY;AACZ;AAAA,EACF;AAGA,QAAM,EAAE,MAAM,IAAI,MAAM,OAAO,OAAO;AACtC,QAAM,MAAM,IAAI,MAAM,6CAA6C;AAAA,IACjE,mBAAmB;AAAA,EACrB,CAAC;AACD,QAAM,EAAE,OAAO,IAAI;AAEnB,oBAAkB,MAAM;AAExB,IAAE,SAAS;AACX,IAAE,WAAW,OAAO;AAEpB,MAAI;AACF,MAAE,YAAY,OAAO;AAAA,EACvB,QAAQ;AAAA,EAER;AACA,IAAE,cAAc,OAAO;AACvB,IAAE,OAAO,OAAO;AAChB,IAAE,YAAY,OAAO;AACrB,IAAE,gBAAgB,OAAO;AACzB,IAAE,wBAAwB,CAAC,OAA4B,WAAW,MAAM,GAAG,KAAK,IAAI,CAAC,GAAG,CAAC;AACzF,IAAE,uBAAuB,MAAM;AAAA,EAAC;AAEhC,cAAY;AACd;AAGA,SAAS,kBAAkB,QAAmB;AAC5C,QAAM,QAAQ,OAAO,mBAAmB;AACxC,MAAI,CAAC,MAAO;AACZ,QAAM,UAAU,gBAAgB;AAChC,QAAM,aAAa,WAAY;AAC7B,WAAO;AAAA,EACT;AACF;AAGA,SAAS,kBAAuB;AAC9B,SAAO;AAAA,IACL,MAAM;AAAA,IACN,WAAW;AAAA,IACX,aAAa;AAAA,IACb,WAAW;AAAA,IACX,WAAW;AAAA,IACX,cAAc;AAAA,IACd,aAAa;AAAA,IACb,aAAa,CAAC,OAAe;AAAA,MAC3B,QAAQ,IAAI,EAAE,SAAS,KAAK;AAAA,MAC5B,yBAAyB;AAAA,MACzB,0BAA0B;AAAA,IAC5B;AAAA,IACA,OAAO;AAAA,IAAC;AAAA,IACR,UAAU;AAAA,IAAC;AAAA,IACX,YAAY;AAAA,IAAC;AAAA,IACb,YAAY;AAAA,IAAC;AAAA,IACb,SAAS;AAAA,IAAC;AAAA,IACV,SAAS;AAAA,IAAC;AAAA,IACV,gBAAgB;AAAA,IAAC;AAAA,IACjB,mBAAmB;AAAA,IAAC;AAAA,IACpB,MAAM;AAAA,IAAC;AAAA,IACP,OAAO;AAAA,IAAC;AAAA,IACR,OAAO;AAAA,IAAC;AAAA,IACR,SAAS;AAAA,IAAC;AAAA,IACV,WAAW;AAAA,IAAC;AAAA,IACZ,YAAY;AAAA,IAAC;AAAA,IACb,WAAW;AAAA,IAAC;AAAA,IACZ,aAAa;AAAA,IAAC;AAAA,IACd,YAAY;AAAA,IAAC;AAAA,IACb,SAAS;AAAA,IAAC;AAAA,IACV,QAAQ;AAAA,IAAC;AAAA,IACT,eAAe;AAAA,IAAC;AAAA,IAChB,YAAY;AAAA,IAAC;AAAA,IACb,YAAY;AAAA,IAAC;AAAA,IACb,OAAO;AAAA,IAAC;AAAA,IACR,sBAAsB,OAAO,EAAE,eAAe;AAAA,IAAC,EAAE;AAAA,IACjD,cAAc,OAAO,EAAE,MAAM,IAAI,kBAAkB,CAAC,EAAE;AAAA,EACxD;AACF;","names":[]}