@real-music-packages/web-core 0.44.1 → 0.45.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.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  safeBox
3
- } from "./chunk-HXTRNE74.js";
3
+ } from "./chunk-PVZXLC23.js";
4
4
 
5
5
  // src/scene/notationGeometry.ts
6
6
  var FOLLOW_BARS = 2;
@@ -482,4 +482,4 @@ export {
482
482
  audioPlayheadLine,
483
483
  vstackAudioPlayheadLine
484
484
  };
485
- //# sourceMappingURL=chunk-HIELHWEZ.js.map
485
+ //# sourceMappingURL=chunk-DBFGX6OK.js.map
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  hitTestMeasureAt,
3
3
  measureColumnsFromLayout
4
- } from "./chunk-HIELHWEZ.js";
4
+ } from "./chunk-DBFGX6OK.js";
5
5
 
6
6
  // src/notationCommon.ts
7
7
  function computeReflowScrollDelta(oldLayout, newLayout, anchorX, anchorY) {
@@ -150,4 +150,4 @@ export {
150
150
  hasReachedProgrammaticTarget,
151
151
  createFollowController
152
152
  };
153
- //# sourceMappingURL=chunk-F4VYTGJQ.js.map
153
+ //# sourceMappingURL=chunk-HIYU73EB.js.map
@@ -7,10 +7,10 @@ import {
7
7
  notationLayout,
8
8
  vstackAudioPlayheadLine,
9
9
  vstackFollowBox
10
- } from "./chunk-HIELHWEZ.js";
10
+ } from "./chunk-DBFGX6OK.js";
11
11
  import {
12
12
  safeBox
13
- } from "./chunk-HXTRNE74.js";
13
+ } from "./chunk-PVZXLC23.js";
14
14
 
15
15
  // src/scene/engravingStore.ts
16
16
  var STORE = /* @__PURE__ */ new WeakMap();
@@ -275,4 +275,4 @@ export {
275
275
  notationFactory,
276
276
  scrollCursorFactory
277
277
  };
278
- //# sourceMappingURL=chunk-3X2J66MQ.js.map
278
+ //# sourceMappingURL=chunk-NZFS7ZTK.js.map
@@ -47,7 +47,7 @@ function pickMimeType() {
47
47
  return "video/webm";
48
48
  }
49
49
  async function recordScenes(scenes, opts) {
50
- const { audioStream, width, height, fps = 30, background = "#000000", onProgress } = opts;
50
+ const { audioStream, width, height, fps = 30, background = "#000000", onProgress, splitAudioTrack = false, onAudioBlob } = opts;
51
51
  const canvas = document.createElement("canvas");
52
52
  canvas.width = width;
53
53
  canvas.height = height;
@@ -55,15 +55,13 @@ async function recordScenes(scenes, opts) {
55
55
  if (!maybeCtx) throw new Error("Failed to get 2D context");
56
56
  const ctx = maybeCtx;
57
57
  const videoStream = canvas.captureStream(fps);
58
- const combined = new MediaStream([
59
- ...videoStream.getVideoTracks(),
60
- ...audioStream.getAudioTracks()
61
- ]);
58
+ const videoOnly = splitAudioTrack;
59
+ const recordedStream = videoOnly ? new MediaStream([...videoStream.getVideoTracks()]) : new MediaStream([...videoStream.getVideoTracks(), ...audioStream.getAudioTracks()]);
62
60
  const mimeType = pickMimeType();
63
- const recorder = new MediaRecorder(combined, {
61
+ const recorder = new MediaRecorder(recordedStream, {
64
62
  mimeType,
65
63
  videoBitsPerSecond: 4e6,
66
- audioBitsPerSecond: 128e3
64
+ ...videoOnly ? {} : { audioBitsPerSecond: 128e3 }
67
65
  });
68
66
  const chunks = [];
69
67
  recorder.ondataavailable = (e) => {
@@ -72,7 +70,25 @@ async function recordScenes(scenes, opts) {
72
70
  const stopped = new Promise((res) => {
73
71
  recorder.onstop = () => res();
74
72
  });
73
+ let audioRecorder = null;
74
+ let audioStopped = Promise.resolve();
75
+ if (videoOnly && audioStream.getAudioTracks().length > 0) {
76
+ const audioOnlyStream = new MediaStream([...audioStream.getAudioTracks()]);
77
+ const audioMime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus") ? "audio/webm;codecs=opus" : "audio/webm";
78
+ audioRecorder = new MediaRecorder(audioOnlyStream, { mimeType: audioMime, audioBitsPerSecond: 128e3 });
79
+ const audioChunks = [];
80
+ audioRecorder.ondataavailable = (e) => {
81
+ if (e.data.size > 0) audioChunks.push(e.data);
82
+ };
83
+ audioStopped = new Promise((res) => {
84
+ audioRecorder.onstop = () => {
85
+ onAudioBlob?.(new Blob(audioChunks, { type: audioMime }));
86
+ res();
87
+ };
88
+ });
89
+ }
75
90
  recorder.start();
91
+ audioRecorder?.start();
76
92
  const totalMs = scenes.reduce((s, sc) => s + sc.durationMs, 0);
77
93
  const t0 = performance.now();
78
94
  onProgress?.({ phase: "capturing", progress01: 0 });
@@ -112,7 +128,9 @@ async function recordScenes(scenes, opts) {
112
128
  });
113
129
  onProgress?.({ phase: "finalizing", progress01: 0.96 });
114
130
  recorder.stop();
131
+ audioRecorder?.stop();
115
132
  await stopped;
133
+ await audioStopped;
116
134
  return new Blob(chunks, { type: mimeType });
117
135
  }
118
136
  function truncate(s, max) {
@@ -322,4 +340,4 @@ export {
322
340
  ctaScene,
323
341
  runPromoCapture
324
342
  };
325
- //# sourceMappingURL=chunk-HXTRNE74.js.map
343
+ //# sourceMappingURL=chunk-PVZXLC23.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 * Record video and audio as TWO SEPARATE MediaRecorders instead of one\n * combined MediaStream. Default false (preserves the original combined\n * behavior for existing callers).\n *\n * Why this exists: combining a canvas `captureStream()` video track with a\n * live Web Audio (`createMediaStreamDestination`) audio track into one\n * MediaStream and feeding THAT to a single MediaRecorder produces an\n * essentially empty (~110-byte, header-only) WebM in the Chromium build\n * this fleet's headless render harness uses (Playwright's bundled Chrome\n * for Testing as of chromium-1228) — reproduced with vp8/vp9/default\n * codecs, with/without bitrate options, with/without a timeslice, headed\n * AND headless. Recording each track with its OWN MediaRecorder (proven\n * to work reliably alone) and muxing the two resulting blobs afterward\n * (see tools/promo/render.mjs's muxIfSeparateAudio) sidesteps it entirely.\n * Opt-in (not the default) so existing callers/routes are unaffected until\n * they're verified against this path too.\n */\n splitAudioTrack?: boolean;\n /** Only meaningful with `splitAudioTrack: true` — called once with the\n * audio-only Blob when its recorder stops (same start/stop window as the\n * returned video-only Blob, so the two are time-aligned for muxing). */\n onAudioBlob?: (blob: Blob) => 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, splitAudioTrack = false, onAudioBlob } = 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\n // See RecordOpts.splitAudioTrack: combining video+audio tracks into one\n // MediaStream can produce an empty recording in this fleet's headless\n // Chromium. When opted in, record video and audio with their OWN\n // MediaRecorders (each combination proven to work alone) instead of one\n // combined recorder.\n const videoOnly = splitAudioTrack;\n const recordedStream = videoOnly\n ? new MediaStream([...videoStream.getVideoTracks()])\n : new MediaStream([...videoStream.getVideoTracks(), ...audioStream.getAudioTracks()]);\n\n const mimeType = pickMimeType();\n const recorder = new MediaRecorder(recordedStream, {\n mimeType,\n videoBitsPerSecond: 4_000_000,\n ...(videoOnly ? {} : { 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 // Audio-only recorder, started/stopped in lockstep with the video recorder\n // so the two blobs cover the SAME real-time window (muxed back together\n // downstream — see tools/promo/render.mjs).\n let audioRecorder: MediaRecorder | null = null;\n let audioStopped: Promise<void> = Promise.resolve();\n if (videoOnly && audioStream.getAudioTracks().length > 0) {\n const audioOnlyStream = new MediaStream([...audioStream.getAudioTracks()]);\n const audioMime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';\n audioRecorder = new MediaRecorder(audioOnlyStream, { mimeType: audioMime, audioBitsPerSecond: 128_000 });\n const audioChunks: Blob[] = [];\n audioRecorder.ondataavailable = (e) => {\n if (e.data.size > 0) audioChunks.push(e.data);\n };\n audioStopped = new Promise<void>((res) => {\n audioRecorder!.onstop = () => {\n onAudioBlob?.(new Blob(audioChunks, { type: audioMime }));\n res();\n };\n });\n }\n\n recorder.start();\n audioRecorder?.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 audioRecorder?.stop();\n await stopped;\n await audioStopped;\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":[]}
@@ -2,7 +2,7 @@ import {
2
2
  getNotationEngraving,
3
3
  notationFactory,
4
4
  scrollCursorFactory
5
- } from "./chunk-3X2J66MQ.js";
5
+ } from "./chunk-NZFS7ZTK.js";
6
6
  import {
7
7
  audioPlayheadLine,
8
8
  distinctOnsets,
@@ -10,10 +10,10 @@ import {
10
10
  measureCount,
11
11
  notationLayout,
12
12
  vstackAudioPlayheadLine
13
- } from "./chunk-HIELHWEZ.js";
13
+ } from "./chunk-DBFGX6OK.js";
14
14
  import {
15
15
  safeBox
16
- } from "./chunk-HXTRNE74.js";
16
+ } from "./chunk-PVZXLC23.js";
17
17
 
18
18
  // src/notationPlayer.ts
19
19
  var DEFAULT_THEME = {
@@ -4,13 +4,13 @@ import {
4
4
  createFollowController,
5
5
  hasReachedProgrammaticTarget,
6
6
  isWithinProgrammaticScroll
7
- } from "./chunk-F4VYTGJQ.js";
7
+ } from "./chunk-HIYU73EB.js";
8
8
  import {
9
9
  distinctOnsets,
10
10
  hitTestMeasureAt,
11
11
  vstackAudioPlayheadLine
12
- } from "./chunk-HIELHWEZ.js";
13
- import "./chunk-HXTRNE74.js";
12
+ } from "./chunk-DBFGX6OK.js";
13
+ import "./chunk-PVZXLC23.js";
14
14
 
15
15
  // src/notationPlayerSvg.ts
16
16
  function graphicalNotesAtColumn(osmd, col) {
@@ -135,6 +135,19 @@ interface CreateVerovioNotationPlayerOpts {
135
135
  osmdOptions?: {
136
136
  autoBeam?: boolean;
137
137
  };
138
+ /**
139
+ * Passthrough for Verovio's own layout/line-breaking options — merged
140
+ * UNDER `VEROVIO_LAYOUT_DEFAULTS` and OVER `scale`/`pageWidth`/
141
+ * `adjustPageHeight` (see `verovioRenderOptions`'s own doc for the exact
142
+ * merge order and why: a caller's `breaks` must be able to override the
143
+ * default, but a caller can never smuggle in `scale`/`pageWidth` — those
144
+ * stay derived from `zoom`/host width, not caller-suppliable). Stave's own
145
+ * use case: inject `<print new-system="yes"/>` into MusicXML and pass
146
+ * `breaks: 'line'` to get exact N-bars-per-line, rather than Verovio's own
147
+ * automatic line-breaking (see `VEROVIO_LAYOUT_DEFAULTS`'s doc for the
148
+ * "why" behind the defaults themselves).
149
+ */
150
+ verovioOptions?: VerovioLayoutOptions;
138
151
  }
139
152
  interface VerovioNotationPlayer {
140
153
  /** Resolves once the engraving is in the DOM and ready to draw. `setTime`/
@@ -300,6 +313,95 @@ interface VerovioRenderOptions {
300
313
  scale: number;
301
314
  pageWidth: number;
302
315
  }
316
+ /**
317
+ * Verovio layout/line-breaking options a caller may override — see
318
+ * `CreateVerovioNotationPlayerOpts.verovioOptions`'s doc for the merge
319
+ * contract and Stave's own motivating use case (encoded `<print
320
+ * new-system="yes"/>` breaks + `breaks: 'line'`, exact N-bars-per-line).
321
+ * Deliberately NARROWER than Verovio's full option surface — only the knobs
322
+ * this integration has an actual caller for; add more here (not a raw
323
+ * `Record<string, unknown>` passthrough) as real needs arise, so a typo in a
324
+ * caller's options object is a compile error, not a silently-ignored no-op.
325
+ */
326
+ interface VerovioLayoutOptions {
327
+ breaks?: 'none' | 'auto' | 'line' | 'smart' | 'encoded';
328
+ breaksSmartSb?: number;
329
+ breaksNoWidow?: boolean;
330
+ minLastJustification?: number;
331
+ spacingSystem?: number;
332
+ spacingStaff?: number;
333
+ }
334
+ /**
335
+ * Layout defaults applied to EVERY engrave (initial + every reflow) unless a
336
+ * caller's own `verovioOptions` overrides a given key — see
337
+ * `verovioRenderOptions`'s doc for the merge order.
338
+ *
339
+ * WHY: Verovio only justifies a page's LAST system when its unstretched
340
+ * width is already ≥ `minLastJustification` (Verovio's own default: 0.8) of
341
+ * the page width — confirmed empirically (a 4-bar-of-whole-notes fixture at
342
+ * pageWidth 2400 renders its single system at ~41% of the page width with
343
+ * Verovio's own default, ~96% with `minLastJustification: 0`; see this
344
+ * module's real-Verovio justification test). A short excerpt — the common
345
+ * case for both this player's normal usage (a few bars at a time) AND
346
+ * Stave's per-line-break usage (section below) — is ALWAYS a "last system"
347
+ * (it's the only one), so Verovio's default leaves it looking
348
+ * left-justified/shrunk rather than filling the available width. Overriding
349
+ * to 0 makes every system justify unconditionally, matching this player's
350
+ * own "fill the host width" semantic (`verovioZoomOptions`'s own doc).
351
+ * `breaksNoWidow: true` prevents Verovio leaving a single trailing bar
352
+ * orphaned on its own system (a related "short excerpt" artifact, same
353
+ * spirit as the justification fix). `breaks: 'auto'` (Verovio's own default
354
+ * line-breaking algorithm) is the base default; Stave overrides it to
355
+ * `'line'` when it has ALREADY encoded exact break points (see
356
+ * `VerovioLayoutOptions`'s doc) — see the "caller's `breaks` wins" case in
357
+ * `verovioRenderOptions`'s own tests.
358
+ */
359
+ declare const VEROVIO_LAYOUT_DEFAULTS: {
360
+ readonly breaks: "auto";
361
+ readonly minLastJustification: 0;
362
+ readonly breaksNoWidow: true;
363
+ };
364
+ /**
365
+ * The full `toolkit.setOptions(...)` argument for one engrave: layout
366
+ * defaults, overridden by the caller's own `layout` (a caller's `breaks`
367
+ * WINS over `VEROVIO_LAYOUT_DEFAULTS.breaks` — this is the whole point of
368
+ * exposing the override), overridden AGAIN by `scale`/`pageWidth` (derived
369
+ * from `hostWidthPx`/`zoom` via `verovioZoomOptions` — a caller's
370
+ * `verovioOptions` has no `scale`/`pageWidth` keys per `VerovioLayoutOptions`
371
+ * own (narrower) type, so this last spread is really just making the
372
+ * derived-vs-defaulted precedence explicit, not fighting a real collision)
373
+ * and `adjustPageHeight: true` (always on — this player's own "whole score,
374
+ * page-flow, no pagination" framing, see the module doc's "PAGES" section).
375
+ * Pure — exists so the merge itself is unit-testable without a DOM or a real
376
+ * Verovio toolkit (`tests/notationPlayerVerovio.test.ts`'s "options merge"
377
+ * tests call this directly).
378
+ */
379
+ declare function verovioRenderOptions(hostWidthPx: number, zoom: number, layout?: VerovioLayoutOptions): Record<string, unknown>;
380
+ /**
381
+ * 1 when every system already fits within `engraveWidthPx` (widest ≤ width
382
+ * × 1.01 — a small tolerance for sub-pixel rounding in the DOM geometry, not
383
+ * a real "close enough" fudge); otherwise `engraveWidthPx / widest` (< 1) —
384
+ * the zoom-scaling factor that would bring the widest system back down to
385
+ * exactly `engraveWidthPx`. Never NaN/Infinity: an empty `systemWidthsPx`,
386
+ * a non-finite/non-positive widest width, or a non-finite/non-positive
387
+ * `engraveWidthPx` all return 1 (the safe "don't touch the zoom" default —
388
+ * see the module doc's "Fit-to-width" section for how the caller uses this:
389
+ * a factor < 1 triggers exactly ONE re-engrave at `requestedZoom * factor`,
390
+ * never a loop).
391
+ *
392
+ * WHY THIS IS NEEDED (see `VEROVIO_LAYOUT_DEFAULTS`'s doc for the
393
+ * complementary justification fix): with ENCODED breaks (`breaks: 'line'` +
394
+ * Stave's own `<print new-system="yes"/>` markers), Verovio must put
395
+ * whatever notes fall between two encoded break points onto one system —
396
+ * unlike `breaks: 'auto'`, it cannot relieve overcrowding by moving a
397
+ * measure to the next line. If that system is too dense to fit
398
+ * `engraveWidthPx` even at Verovio's own minimum inter-note spacing, Verovio
399
+ * renders it WIDER than the requested page width instead of silently
400
+ * clipping it. Shrinking the effective zoom (smaller glyphs, smaller
401
+ * spacing) is the only lever left to bring it back within the host's
402
+ * available width.
403
+ */
404
+ declare function fitZoomFactor(systemWidthsPx: readonly number[], engraveWidthPx: number): number;
303
405
  /**
304
406
  * Semantic zoom → Verovio options. The migration spike's `zoom-test*.mjs`
305
407
  * proved `pageWidth` (Verovio's line-breaking width, in ITS OWN units)
@@ -336,11 +438,14 @@ declare function verovioZoomOptions(hostWidthPx: number, zoom: number): VerovioR
336
438
  /** Engrave-width ceiling, CSS px — same policy as notationPlayerSvg.ts's
337
439
  * `MAX_ENGRAVE_WIDTH_SVG` (design doc §"Render": "cap ~1200px"), restated
338
440
  * independently since the two players' constants are independently
339
- * tunable. */
340
- declare const MAX_ENGRAVE_WIDTH_VRV = 1200;
441
+ * tunable. Raised 1200 -> 1400: Stave's encoded-break usage
442
+ * (`verovioOptions.breaks: 'line'`, exact N-bars-per-line) wants more
443
+ * breathing room per system than the original OSMD-parity cap allowed
444
+ * before glyphs get cramped at typical desktop widths. */
445
+ declare const MAX_ENGRAVE_WIDTH_VRV = 1400;
341
446
  /** Build a live, interactive Verovio (vector) notation player. See the
342
447
  * module doc + `docs/superpowers/specs/2026-08-11-verovio-player-design.md`
343
448
  * (in stave-web-sightread) §2 for the full design. */
344
449
  declare function createVerovioNotationPlayer(opts: CreateVerovioNotationPlayerOpts): VerovioNotationPlayer;
345
450
 
346
- export { type CreateVerovioNotationPlayerOpts, EngravedNote, MARKED_NOTE_CLASS, MAX_ENGRAVE_WIDTH_VRV, VEROVIO_BASE_SCALE, VEROVIO_MAX_SCALE, VEROVIO_MIN_SCALE, type VerovioNotationPlayer, type VerovioOnset, type VerovioRenderOptions, applyPrintObjectHiding, createVerovioNotationPlayer, verovioEngravedNotes, verovioNotationLayout, verovioNotesAtColumn, verovioOnsetColumns, verovioZoomOptions };
451
+ export { type CreateVerovioNotationPlayerOpts, EngravedNote, MARKED_NOTE_CLASS, MAX_ENGRAVE_WIDTH_VRV, VEROVIO_BASE_SCALE, VEROVIO_LAYOUT_DEFAULTS, VEROVIO_MAX_SCALE, VEROVIO_MIN_SCALE, type VerovioLayoutOptions, type VerovioNotationPlayer, type VerovioOnset, type VerovioRenderOptions, applyPrintObjectHiding, createVerovioNotationPlayer, fitZoomFactor, verovioEngravedNotes, verovioNotationLayout, verovioNotesAtColumn, verovioOnsetColumns, verovioRenderOptions, verovioZoomOptions };
@@ -1,15 +1,15 @@
1
1
  import {
2
2
  computeReflowScrollDelta,
3
3
  createFollowController
4
- } from "./chunk-F4VYTGJQ.js";
4
+ } from "./chunk-HIYU73EB.js";
5
5
  import {
6
6
  distinctOnsets,
7
7
  hitTestMeasureAt,
8
8
  measureColumnsFromLayout,
9
9
  systemIndexOfBox,
10
10
  vstackAudioPlayheadLine
11
- } from "./chunk-HIELHWEZ.js";
12
- import "./chunk-HXTRNE74.js";
11
+ } from "./chunk-DBFGX6OK.js";
12
+ import "./chunk-PVZXLC23.js";
13
13
 
14
14
  // src/notationXml.ts
15
15
  function stampNoteIds(xml) {
@@ -356,6 +356,24 @@ function applyPrintObjectHiding(root, noteModel) {
356
356
  var VEROVIO_BASE_SCALE = 40;
357
357
  var VEROVIO_MIN_SCALE = 20;
358
358
  var VEROVIO_MAX_SCALE = 120;
359
+ var VEROVIO_LAYOUT_DEFAULTS = {
360
+ breaks: "auto",
361
+ minLastJustification: 0,
362
+ breaksNoWidow: true
363
+ };
364
+ function verovioRenderOptions(hostWidthPx, zoom, layout) {
365
+ const { scale, pageWidth } = verovioZoomOptions(hostWidthPx, zoom);
366
+ return { ...VEROVIO_LAYOUT_DEFAULTS, ...layout, scale, pageWidth, adjustPageHeight: true };
367
+ }
368
+ function fitZoomFactor(systemWidthsPx, engraveWidthPx) {
369
+ if (!systemWidthsPx.length) return 1;
370
+ if (!(engraveWidthPx > 0) || !Number.isFinite(engraveWidthPx)) return 1;
371
+ const widest = Math.max(...systemWidthsPx);
372
+ if (!(widest > 0) || !Number.isFinite(widest)) return 1;
373
+ if (widest <= engraveWidthPx * 1.01) return 1;
374
+ const factor = engraveWidthPx / widest;
375
+ return Number.isFinite(factor) && factor > 0 ? factor : 1;
376
+ }
359
377
  function verovioZoomOptions(hostWidthPx, zoom) {
360
378
  const z = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
361
379
  const scale = Math.max(VEROVIO_MIN_SCALE, Math.min(VEROVIO_MAX_SCALE, VEROVIO_BASE_SCALE * z));
@@ -377,9 +395,18 @@ function getVerovioToolkit() {
377
395
  }
378
396
  return toolkitPromise;
379
397
  }
398
+ var toolkitQueue = Promise.resolve();
399
+ function withToolkit(fn) {
400
+ const run = toolkitQueue.then(() => getVerovioToolkit()).then((toolkit) => fn(toolkit));
401
+ toolkitQueue = run.then(
402
+ () => void 0,
403
+ () => void 0
404
+ );
405
+ return run;
406
+ }
380
407
  var loggedAutoBeamIgnored = false;
381
408
  var DEFAULT_PLAYHEAD_COLOR = "#2f6f4f";
382
- var MAX_ENGRAVE_WIDTH_VRV = 1200;
409
+ var MAX_ENGRAVE_WIDTH_VRV = 1400;
383
410
  var MIN_ENGRAVE_WIDTH_VRV = 280;
384
411
  function createVerovioNotationPlayer(opts) {
385
412
  const { host, musicXml, onsets: rawOnsets } = opts;
@@ -483,6 +510,28 @@ function createVerovioNotationPlayer(opts) {
483
510
  }
484
511
  }
485
512
  }
513
+ function engraveOnce(toolkit, widthPx, zoom) {
514
+ toolkit.setOptions(verovioRenderOptions(widthPx, zoom, opts.verovioOptions));
515
+ const ok = !!toolkit.loadData(stampedXml);
516
+ if (ok) renderAllPages(toolkit);
517
+ rebuildLayoutFromDom();
518
+ if (ok && currentLayout) {
519
+ const factor = fitZoomFactor(
520
+ currentLayout.systems.map((s) => s.w),
521
+ widthPx
522
+ );
523
+ if (factor < 1) {
524
+ const effectiveZoom = zoom * factor;
525
+ toolkit.setOptions(verovioRenderOptions(widthPx, effectiveZoom, opts.verovioOptions));
526
+ const ok2 = !!toolkit.loadData(stampedXml);
527
+ if (ok2) {
528
+ renderAllPages(toolkit);
529
+ rebuildLayoutFromDom();
530
+ }
531
+ }
532
+ }
533
+ return ok;
534
+ }
486
535
  async function initialEngrave() {
487
536
  if (opts.rendered) {
488
537
  currentLayout = opts.rendered;
@@ -490,16 +539,14 @@ function createVerovioNotationPlayer(opts) {
490
539
  renderPlayhead(lastTMs);
491
540
  return;
492
541
  }
493
- const toolkit = await getVerovioToolkit();
494
- if (destroyed) return;
495
542
  const widthPx = desiredEngraveWidthPx();
496
- const { scale, pageWidth } = verovioZoomOptions(widthPx, currentZoom);
497
- toolkit.setOptions({ scale, pageWidth, breaks: "auto", adjustPageHeight: true });
498
- loaded = !!toolkit.loadData(stampedXml);
543
+ const ok = await withToolkit((toolkit) => {
544
+ if (destroyed) return false;
545
+ return engraveOnce(toolkit, widthPx, currentZoom);
546
+ });
499
547
  if (destroyed) return;
500
548
  lastEngravedWidthPx = widthPx;
501
- if (loaded) renderAllPages(toolkit);
502
- rebuildLayoutFromDom();
549
+ loaded = ok;
503
550
  applyMarks();
504
551
  renderPlayhead(lastTMs);
505
552
  }
@@ -512,8 +559,6 @@ function createVerovioNotationPlayer(opts) {
512
559
  currentZoom = newZoom;
513
560
  return;
514
561
  }
515
- const toolkit = await getVerovioToolkit();
516
- if (destroyed) return;
517
562
  const myToken = ++rebuildToken;
518
563
  const oldLayout = currentLayout;
519
564
  const hasWin = typeof window !== "undefined";
@@ -523,9 +568,10 @@ function createVerovioNotationPlayer(opts) {
523
568
  const r = root.getBoundingClientRect();
524
569
  anchorY = window.innerHeight / 2 - r.top;
525
570
  }
526
- const { scale, pageWidth } = verovioZoomOptions(widthPx, newZoom);
527
- toolkit.setOptions({ scale, pageWidth, breaks: "auto", adjustPageHeight: true });
528
- const ok = toolkit.loadData(stampedXml);
571
+ const ok = await withToolkit((toolkit) => {
572
+ if (destroyed || myToken !== rebuildToken) return false;
573
+ return engraveOnce(toolkit, widthPx, newZoom);
574
+ });
529
575
  if (destroyed || myToken !== rebuildToken) return;
530
576
  if (!ok) {
531
577
  loaded = false;
@@ -533,8 +579,6 @@ function createVerovioNotationPlayer(opts) {
533
579
  }
534
580
  lastEngravedWidthPx = widthPx;
535
581
  currentZoom = newZoom;
536
- renderAllPages(toolkit);
537
- rebuildLayoutFromDom();
538
582
  applyMarks();
539
583
  if (oldLayout && currentLayout && anchorY != null && hasWin) {
540
584
  const delta = computeReflowScrollDelta(oldLayout, currentLayout, anchorX, anchorY);
@@ -600,14 +644,17 @@ export {
600
644
  MARKED_NOTE_CLASS,
601
645
  MAX_ENGRAVE_WIDTH_VRV,
602
646
  VEROVIO_BASE_SCALE,
647
+ VEROVIO_LAYOUT_DEFAULTS,
603
648
  VEROVIO_MAX_SCALE,
604
649
  VEROVIO_MIN_SCALE,
605
650
  applyPrintObjectHiding,
606
651
  createVerovioNotationPlayer,
652
+ fitZoomFactor,
607
653
  verovioEngravedNotes,
608
654
  verovioNotationLayout,
609
655
  verovioNotesAtColumn,
610
656
  verovioOnsetColumns,
657
+ verovioRenderOptions,
611
658
  verovioZoomOptions
612
659
  };
613
660
  //# sourceMappingURL=notationPlayerVerovio.js.map