@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.
- package/dist/{chunk-HIELHWEZ.js → chunk-DBFGX6OK.js} +2 -2
- package/dist/{chunk-F4VYTGJQ.js → chunk-HIYU73EB.js} +2 -2
- package/dist/{chunk-3X2J66MQ.js → chunk-NZFS7ZTK.js} +3 -3
- package/dist/{chunk-HXTRNE74.js → chunk-PVZXLC23.js} +26 -8
- package/dist/chunk-PVZXLC23.js.map +1 -0
- package/dist/notationPlayer.js +3 -3
- package/dist/notationPlayerSvg.js +3 -3
- package/dist/notationPlayerVerovio.d.ts +108 -3
- package/dist/notationPlayerVerovio.js +65 -18
- package/dist/notationPlayerVerovio.js.map +1 -1
- package/dist/scene/index.d.ts +4 -0
- package/dist/scene/index.js +6 -4
- package/dist/scene/index.js.map +1 -1
- package/dist/video.d.ts +23 -0
- package/dist/video.js +1 -1
- package/package.json +1 -1
- package/dist/chunk-HXTRNE74.js.map +0 -1
- /package/dist/{chunk-HIELHWEZ.js.map → chunk-DBFGX6OK.js.map} +0 -0
- /package/dist/{chunk-F4VYTGJQ.js.map → chunk-HIYU73EB.js.map} +0 -0
- /package/dist/{chunk-3X2J66MQ.js.map → chunk-NZFS7ZTK.js.map} +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
safeBox
|
|
3
|
-
} from "./chunk-
|
|
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-
|
|
485
|
+
//# sourceMappingURL=chunk-DBFGX6OK.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
hitTestMeasureAt,
|
|
3
3
|
measureColumnsFromLayout
|
|
4
|
-
} from "./chunk-
|
|
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-
|
|
153
|
+
//# sourceMappingURL=chunk-HIYU73EB.js.map
|
|
@@ -7,10 +7,10 @@ import {
|
|
|
7
7
|
notationLayout,
|
|
8
8
|
vstackAudioPlayheadLine,
|
|
9
9
|
vstackFollowBox
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-DBFGX6OK.js";
|
|
11
11
|
import {
|
|
12
12
|
safeBox
|
|
13
|
-
} from "./chunk-
|
|
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-
|
|
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
|
|
59
|
-
|
|
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(
|
|
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-
|
|
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":[]}
|
package/dist/notationPlayer.js
CHANGED
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
getNotationEngraving,
|
|
3
3
|
notationFactory,
|
|
4
4
|
scrollCursorFactory
|
|
5
|
-
} from "./chunk-
|
|
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-
|
|
13
|
+
} from "./chunk-DBFGX6OK.js";
|
|
14
14
|
import {
|
|
15
15
|
safeBox
|
|
16
|
-
} from "./chunk-
|
|
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-
|
|
7
|
+
} from "./chunk-HIYU73EB.js";
|
|
8
8
|
import {
|
|
9
9
|
distinctOnsets,
|
|
10
10
|
hitTestMeasureAt,
|
|
11
11
|
vstackAudioPlayheadLine
|
|
12
|
-
} from "./chunk-
|
|
13
|
-
import "./chunk-
|
|
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
|
-
|
|
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-
|
|
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-
|
|
12
|
-
import "./chunk-
|
|
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 =
|
|
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
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
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
|
|
527
|
-
|
|
528
|
-
|
|
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
|