@real-music-packages/web-core 0.45.2 → 0.47.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.
@@ -39,6 +39,7 @@ import {
39
39
  vstackFollowBox
40
40
  } from "../chunk-DBFGX6OK.js";
41
41
  import {
42
+ SAFE_ZONE,
42
43
  ctaScene,
43
44
  drawSafeGuides,
44
45
  hookScene,
@@ -2528,18 +2529,18 @@ function sectionMinimapLayer() {
2528
2529
  c.fillRect(x0 - 1, barY - round - 3, 2, barHeight + 6);
2529
2530
  });
2530
2531
  }
2531
- const px = timeToX(left, width, dur, tMs);
2532
+ const px2 = timeToX(left, width, dur, tMs);
2532
2533
  c.globalAlpha = 0.85;
2533
2534
  c.fillStyle = accent;
2534
- drawBar(c, left, barY - round, Math.max(0, px - left), barHeight, round);
2535
+ drawBar(c, left, barY - round, Math.max(0, px2 - left), barHeight, round);
2535
2536
  c.globalAlpha = 1;
2536
2537
  c.fillStyle = accent;
2537
2538
  c.beginPath();
2538
- c.arc(px, barY, barHeight * 1.6, 0, Math.PI * 2);
2539
+ c.arc(px2, barY, barHeight * 1.6, 0, Math.PI * 2);
2539
2540
  c.fill();
2540
2541
  c.fillStyle = ctx.theme.paper;
2541
2542
  c.beginPath();
2542
- c.arc(px, barY, barHeight * 0.7, 0, Math.PI * 2);
2543
+ c.arc(px2, barY, barHeight * 0.7, 0, Math.PI * 2);
2543
2544
  c.fill();
2544
2545
  if (showLabel) {
2545
2546
  const act = activeSection(sections, tMs);
@@ -2549,7 +2550,7 @@ function sectionMinimapLayer() {
2549
2550
  c.textAlign = "center";
2550
2551
  c.textBaseline = "bottom";
2551
2552
  c.font = `700 ${Math.round(barHeight * 2.4)}px ${ctx.theme.fontBody}`;
2552
- c.fillText(act.label, px, barY - barHeight * 2.2);
2553
+ c.fillText(act.label, px2, barY - barHeight * 2.2);
2553
2554
  }
2554
2555
  }
2555
2556
  c.restore();
@@ -3836,9 +3837,9 @@ function tensionGraphLayer() {
3836
3837
  c.stroke();
3837
3838
  const revealed = [];
3838
3839
  for (let i = 0; i < series.length; i++) {
3839
- const px = x(series[i].tMs);
3840
- if (px <= playX) {
3841
- revealed.push({ x: px, y: y(series[i].value) });
3840
+ const px2 = x(series[i].tMs);
3841
+ if (px2 <= playX) {
3842
+ revealed.push({ x: px2, y: y(series[i].value) });
3842
3843
  } else {
3843
3844
  if (i > 0) {
3844
3845
  const a = series[i - 1], b = series[i];
@@ -4529,15 +4530,15 @@ function compositeRhythmLayer() {
4529
4530
  c.stroke();
4530
4531
  }
4531
4532
  if (playhead && tMs >= w0 && tMs <= w1) {
4532
- const px = x(tMs);
4533
+ const px2 = x(tMs);
4533
4534
  const topY = grids.length ? lhY - rowGap * grids.length - tickH : lhY - tickH;
4534
4535
  const botY = rhY + tickH;
4535
4536
  c.strokeStyle = mergeColor;
4536
4537
  c.globalAlpha = 0.5;
4537
4538
  c.lineWidth = 2;
4538
4539
  c.beginPath();
4539
- c.moveTo(px, topY);
4540
- c.lineTo(px, botY);
4540
+ c.moveTo(px2, topY);
4541
+ c.lineTo(px2, botY);
4541
4542
  c.stroke();
4542
4543
  c.globalAlpha = 1;
4543
4544
  }
@@ -4585,6 +4586,241 @@ var compositeRhythmFactory = {
4585
4586
  }
4586
4587
  };
4587
4588
 
4589
+ // src/scene/layers/videoSource.ts
4590
+ var DEFAULTS12 = {
4591
+ fit: "cover",
4592
+ focus: { x: 0.5, y: 0.38 },
4593
+ startMs: 0,
4594
+ sourceStartMs: 0,
4595
+ loop: true,
4596
+ rate: 1,
4597
+ mute: true,
4598
+ opacity: 1,
4599
+ clockMode: "play",
4600
+ driftTolMs: 120
4601
+ };
4602
+ function clamp2(x, lo, hi) {
4603
+ return x < lo ? lo : x > hi ? hi : x;
4604
+ }
4605
+ function fitBoxes(iW, iH, bw, bh, mode, focus) {
4606
+ if (iW <= 0 || iH <= 0 || bw <= 0 || bh <= 0) {
4607
+ return { sx: 0, sy: 0, sw: Math.max(0, iW), sh: Math.max(0, iH), dx: 0, dy: 0, dw: 0, dh: 0 };
4608
+ }
4609
+ if (mode === "contain") {
4610
+ const scale2 = Math.min(bw / iW, bh / iH);
4611
+ const dw = iW * scale2;
4612
+ const dh = iH * scale2;
4613
+ return { sx: 0, sy: 0, sw: iW, sh: iH, dx: (bw - dw) / 2, dy: (bh - dh) / 2, dw, dh };
4614
+ }
4615
+ const scale = Math.max(bw / iW, bh / iH);
4616
+ const sw = Math.min(iW, bw / scale);
4617
+ const sh = Math.min(iH, bh / scale);
4618
+ const sx = clamp2(focus.x * iW - sw / 2, 0, iW - sw);
4619
+ const sy = clamp2(focus.y * iH - sh / 2, 0, iH - sh);
4620
+ return { sx, sy, sw, sh, dx: 0, dy: 0, dw: bw, dh: bh };
4621
+ }
4622
+ function sourceTimeMs(tMs, o) {
4623
+ const elapsed = Math.max(0, (tMs - o.startMs) * o.rate);
4624
+ const span = o.durationMs - o.sourceStartMs;
4625
+ if (!o.loop || !(span > 0)) {
4626
+ const end = o.durationMs > 0 ? o.durationMs : Infinity;
4627
+ return Math.min(o.sourceStartMs + elapsed, end);
4628
+ }
4629
+ return o.sourceStartMs + elapsed % span;
4630
+ }
4631
+ function needsSeek(currentMs, wantMs, tolMs) {
4632
+ return Math.abs(currentMs - wantMs) > tolMs;
4633
+ }
4634
+ function isVideoLike(x) {
4635
+ return !!x && typeof x === "object" && typeof x.currentTime === "number" && typeof x.duration === "number";
4636
+ }
4637
+ function sourceSize(src) {
4638
+ const s = src;
4639
+ const w = s?.videoWidth || s?.naturalWidth || s?.width || 0;
4640
+ const h = s?.videoHeight || s?.naturalHeight || s?.height || 0;
4641
+ return { w, h };
4642
+ }
4643
+ function loadVideo(url, mute) {
4644
+ const v = document.createElement("video");
4645
+ v.src = url;
4646
+ v.muted = mute;
4647
+ v.playsInline = true;
4648
+ v.preload = "auto";
4649
+ v.crossOrigin = "anonymous";
4650
+ return new Promise((resolve, reject) => {
4651
+ const ok = () => resolve(v);
4652
+ v.addEventListener("canplaythrough", ok, { once: true });
4653
+ v.addEventListener("error", () => reject(new Error(`video-source: failed to load ${url}`)), { once: true });
4654
+ v.load();
4655
+ });
4656
+ }
4657
+ function videoSourceLayer() {
4658
+ let src = null;
4659
+ let o = { ...DEFAULTS12, durationMs: 0 };
4660
+ let started = false;
4661
+ return {
4662
+ key: "video-source",
4663
+ async init(_ctx, props) {
4664
+ o = {
4665
+ fit: props.fit ?? DEFAULTS12.fit,
4666
+ focus: props.focus ?? DEFAULTS12.focus,
4667
+ startMs: props.startMs ?? DEFAULTS12.startMs,
4668
+ sourceStartMs: props.sourceStartMs ?? DEFAULTS12.sourceStartMs,
4669
+ loop: props.loop ?? DEFAULTS12.loop,
4670
+ rate: props.rate ?? DEFAULTS12.rate,
4671
+ mute: props.mute ?? DEFAULTS12.mute,
4672
+ opacity: props.opacity ?? DEFAULTS12.opacity,
4673
+ clockMode: props.clockMode ?? DEFAULTS12.clockMode,
4674
+ driftTolMs: props.driftTolMs ?? DEFAULTS12.driftTolMs,
4675
+ durationMs: props.durationMs ?? 0
4676
+ };
4677
+ if (o.clockMode === "seek") {
4678
+ throw new Error(
4679
+ 'video-source: clockMode "seek" needs an async draw path the runner does not have yet; use "play"'
4680
+ );
4681
+ }
4682
+ src = typeof props.src === "string" ? await loadVideo(props.src, o.mute) : props.src;
4683
+ if (isVideoLike(src)) {
4684
+ src.muted = o.mute;
4685
+ src.playbackRate = o.rate;
4686
+ if (!o.durationMs && Number.isFinite(src.duration)) o.durationMs = src.duration * 1e3;
4687
+ }
4688
+ },
4689
+ draw(ctx, tMs) {
4690
+ if (!src) return;
4691
+ const { w: iW, h: iH } = sourceSize(src);
4692
+ if (iW <= 0 || iH <= 0) return;
4693
+ if (isVideoLike(src)) {
4694
+ const want = sourceTimeMs(tMs, o);
4695
+ if (!started && tMs >= o.startMs) {
4696
+ started = true;
4697
+ src.currentTime = want / 1e3;
4698
+ void src.play?.();
4699
+ } else if (started && needsSeek(src.currentTime * 1e3, want, o.driftTolMs)) {
4700
+ src.currentTime = want / 1e3;
4701
+ }
4702
+ }
4703
+ const b = fitBoxes(iW, iH, ctx.W, ctx.H, o.fit, o.focus);
4704
+ if (b.dw <= 0 || b.dh <= 0) return;
4705
+ const g = ctx.ctx2d;
4706
+ g.save();
4707
+ if (o.opacity !== 1) g.globalAlpha = g.globalAlpha * o.opacity;
4708
+ g.drawImage(src, b.sx, b.sy, b.sw, b.sh, b.dx, b.dy, b.dw, b.dh);
4709
+ g.restore();
4710
+ }
4711
+ };
4712
+ }
4713
+ var videoSourceFactory = {
4714
+ key: "video-source",
4715
+ create: videoSourceLayer,
4716
+ validateProps(props) {
4717
+ const errs = [];
4718
+ if (props == null || typeof props !== "object") return ["video-source: props must be an object"];
4719
+ const p = props;
4720
+ if (p.src == null) errs.push("video-source.src is required");
4721
+ else if (typeof p.src !== "string" && typeof p.src !== "object") {
4722
+ errs.push("video-source.src must be a URL string or a CanvasImageSource");
4723
+ }
4724
+ if (p.fit != null && p.fit !== "cover" && p.fit !== "contain") {
4725
+ errs.push('video-source.fit must be "cover" or "contain"');
4726
+ }
4727
+ if (p.clockMode != null && p.clockMode !== "play" && p.clockMode !== "seek") {
4728
+ errs.push('video-source.clockMode must be "play" or "seek"');
4729
+ }
4730
+ if (p.focus != null) {
4731
+ const f = p.focus;
4732
+ if (typeof f?.x !== "number" || typeof f?.y !== "number") {
4733
+ errs.push("video-source.focus must be {x, y} numbers");
4734
+ }
4735
+ }
4736
+ for (const k of ["startMs", "sourceStartMs", "rate", "opacity", "driftTolMs", "durationMs"]) {
4737
+ if (p[k] != null && (typeof p[k] !== "number" || !Number.isFinite(p[k]))) {
4738
+ errs.push(`video-source.${k} must be a finite number`);
4739
+ }
4740
+ }
4741
+ if (p.rate != null && p.rate <= 0) errs.push("video-source.rate must be > 0");
4742
+ const looping = p.loop == null ? DEFAULTS12.loop : p.loop === true;
4743
+ if (looping && typeof p.src === "object" && p.durationMs == null) {
4744
+ errs.push("video-source.durationMs is required when looping a non-<video> source");
4745
+ }
4746
+ return errs;
4747
+ }
4748
+ };
4749
+
4750
+ // src/scene/layers/paneSeam.ts
4751
+ var DEFAULTS13 = {
4752
+ orientation: "h",
4753
+ style: "line",
4754
+ thicknessPx: 4,
4755
+ shadowPx: 28,
4756
+ opacity: 0.9
4757
+ };
4758
+ function paneSeamLayer() {
4759
+ let p = { ...DEFAULTS13 };
4760
+ return {
4761
+ key: "pane-seam",
4762
+ init(_ctx, props) {
4763
+ p = {
4764
+ atFrac: props.atFrac,
4765
+ orientation: props.orientation ?? DEFAULTS13.orientation,
4766
+ style: props.style ?? DEFAULTS13.style,
4767
+ thicknessPx: props.thicknessPx ?? DEFAULTS13.thicknessPx,
4768
+ shadowPx: props.shadowPx ?? DEFAULTS13.shadowPx,
4769
+ opacity: props.opacity ?? DEFAULTS13.opacity,
4770
+ color: props.color
4771
+ };
4772
+ },
4773
+ draw(ctx) {
4774
+ const g = ctx.ctx2d;
4775
+ const horiz = p.orientation === "h";
4776
+ const at = horiz ? p.atFrac * ctx.H : p.atFrac * ctx.W;
4777
+ const color = p.color ?? ctx.theme.ink;
4778
+ g.save();
4779
+ if (p.style === "shadow" && p.shadowPx > 0) {
4780
+ const grad = horiz ? g.createLinearGradient(0, at - p.shadowPx, 0, at + p.shadowPx) : g.createLinearGradient(at - p.shadowPx, 0, at + p.shadowPx, 0);
4781
+ grad.addColorStop(0, "rgba(0,0,0,0)");
4782
+ grad.addColorStop(0.5, "rgba(0,0,0,0.35)");
4783
+ grad.addColorStop(1, "rgba(0,0,0,0)");
4784
+ g.fillStyle = grad;
4785
+ if (horiz) g.fillRect(0, at - p.shadowPx, ctx.W, 2 * p.shadowPx);
4786
+ else g.fillRect(at - p.shadowPx, 0, 2 * p.shadowPx, ctx.H);
4787
+ }
4788
+ g.globalAlpha = g.globalAlpha * p.opacity;
4789
+ g.fillStyle = color;
4790
+ const t = p.thicknessPx;
4791
+ if (horiz) g.fillRect(0, at - t / 2, ctx.W, t);
4792
+ else g.fillRect(at - t / 2, 0, t, ctx.H);
4793
+ g.restore();
4794
+ }
4795
+ };
4796
+ }
4797
+ var paneSeamFactory = {
4798
+ key: "pane-seam",
4799
+ create: paneSeamLayer,
4800
+ validateProps(props) {
4801
+ const errs = [];
4802
+ if (props == null || typeof props !== "object") return ["pane-seam: props must be an object"];
4803
+ const p = props;
4804
+ if (typeof p.atFrac !== "number" || !Number.isFinite(p.atFrac)) {
4805
+ errs.push("pane-seam.atFrac must be a finite number");
4806
+ } else if (p.atFrac < 0 || p.atFrac > 1) {
4807
+ errs.push("pane-seam.atFrac must be within 0..1");
4808
+ }
4809
+ if (p.orientation != null && p.orientation !== "h" && p.orientation !== "v") {
4810
+ errs.push('pane-seam.orientation must be "h" or "v"');
4811
+ }
4812
+ if (p.style != null && p.style !== "line" && p.style !== "shadow") {
4813
+ errs.push('pane-seam.style must be "line" or "shadow"');
4814
+ }
4815
+ for (const k of ["thicknessPx", "shadowPx", "opacity"]) {
4816
+ if (p[k] != null && (typeof p[k] !== "number" || !Number.isFinite(p[k]))) {
4817
+ errs.push(`pane-seam.${k} must be a finite number`);
4818
+ }
4819
+ }
4820
+ return errs;
4821
+ }
4822
+ };
4823
+
4588
4824
  // src/scene/layers/handIndicator.ts
4589
4825
  function activeOpacity(notes, tMs, fadeMs) {
4590
4826
  let lastEnd = -Infinity;
@@ -4759,6 +4995,8 @@ registerLayer(endCardFactory);
4759
4995
  registerLayer(imageRevealFactory);
4760
4996
  registerLayer(compositeRhythmFactory);
4761
4997
  registerLayer(handIndicatorFactory);
4998
+ registerLayer(videoSourceFactory);
4999
+ registerLayer(paneSeamFactory);
4762
5000
 
4763
5001
  // src/scene/audioLayers.ts
4764
5002
  function countInSchedule(opts) {
@@ -4879,6 +5117,63 @@ function identityCamera(W, H) {
4879
5117
  return { cx: W / 2, cy: H / 2, zoom: 1 };
4880
5118
  }
4881
5119
 
5120
+ // src/scene/region.ts
5121
+ var FULL_REGION = { x: 0, y: 0, w: 1, h: 1 };
5122
+ function isFullRegion(r) {
5123
+ return r.x === 0 && r.y === 0 && r.w === 1 && r.h === 1;
5124
+ }
5125
+ function regionRect(r, W, H) {
5126
+ return { x: r.x * W, y: r.y * H, w: r.w * W, h: r.h * H };
5127
+ }
5128
+ function validateRegion(r) {
5129
+ const errs = [];
5130
+ if (typeof r !== "object" || r === null) return ["region must be an object"];
5131
+ const o = r;
5132
+ for (const k of ["x", "y", "w", "h"]) {
5133
+ const v = o[k];
5134
+ if (typeof v !== "number" || !Number.isFinite(v)) {
5135
+ errs.push(`region.${k} must be a finite number`);
5136
+ }
5137
+ }
5138
+ if (errs.length) return errs;
5139
+ const { x, y, w, h } = o;
5140
+ if (w <= 0 || h <= 0) errs.push("region.w and region.h must be > 0");
5141
+ if (x < 0 || y < 0) errs.push("region.x and region.y must be >= 0");
5142
+ if (x + w > 1 + 1e-9 || y + h > 1 + 1e-9) errs.push("region must fit inside the frame (x+w <= 1, y+h <= 1)");
5143
+ return errs;
5144
+ }
5145
+ function paneSafeBox(r, W, H) {
5146
+ const frame = safeBox(W, H);
5147
+ const px2 = r.x * W;
5148
+ const py = r.y * H;
5149
+ const pw = r.w * W;
5150
+ const ph = r.h * H;
5151
+ const left = Math.min(Math.max(frame.left, px2), px2 + pw) - px2;
5152
+ const right = Math.max(Math.min(frame.right, px2 + pw), px2) - px2;
5153
+ const top = Math.min(Math.max(frame.top, py), py + ph) - py;
5154
+ const bottom = Math.max(Math.min(frame.bottom, py + ph), py) - py;
5155
+ const w = Math.max(0, right - left);
5156
+ const h = Math.max(0, bottom - top);
5157
+ const r2 = left + w;
5158
+ const b2 = top + h;
5159
+ return {
5160
+ left,
5161
+ right: r2,
5162
+ top,
5163
+ bottom: b2,
5164
+ w,
5165
+ h,
5166
+ // Same expression shape as safeBox() so a full-frame region is bit-identical
5167
+ // to the frame box (left + w/2 differs in the last float ulp).
5168
+ cx: (left + r2) / 2,
5169
+ cy: (top + b2) / 2,
5170
+ // Mirrors the frame version, but centered on the PANE's midline (pw/2) —
5171
+ // a layer centering content inside a pane centers on the pane, not on the
5172
+ // frame, and the pane may be horizontally offset.
5173
+ centeredW: Math.max(0, 2 * Math.min(pw / 2 - left, left + w - pw / 2))
5174
+ };
5175
+ }
5176
+
4882
5177
  // src/scene/runner.ts
4883
5178
  function resolveAnchor(anchor, totalSec) {
4884
5179
  if (typeof anchor === "number") return anchor;
@@ -4921,7 +5216,10 @@ var SCREEN_PINNED_KEYS = /* @__PURE__ */ new Set([
4921
5216
  "particle-burst",
4922
5217
  "texture",
4923
5218
  "stat-counter",
4924
- "end-card"
5219
+ "end-card",
5220
+ // S7c — the seam belongs to the FRAME, between the panes, so it must not pan
5221
+ // with the camera the way a pane's contents do.
5222
+ "pane-seam"
4925
5223
  ]);
4926
5224
  function defaultBufferFactory() {
4927
5225
  const g = globalThis;
@@ -4972,14 +5270,33 @@ async function buildScene(opts) {
4972
5270
  if (errs.length) {
4973
5271
  throw new Error(`buildScene: invalid props for "${sl.k}": ${errs.join("; ")}`);
4974
5272
  }
5273
+ let region;
5274
+ let base = baseCtx;
5275
+ if (sl.region) {
5276
+ const rErrs = validateRegion(sl.region);
5277
+ if (rErrs.length) {
5278
+ throw new Error(`buildScene: invalid region for "${sl.k}": ${rErrs.join("; ")}`);
5279
+ }
5280
+ if (!isFullRegion(sl.region)) {
5281
+ region = sl.region;
5282
+ base = {
5283
+ ...baseCtx,
5284
+ W: region.w * W,
5285
+ H: region.h * H,
5286
+ safeBox: paneSafeBox(region, W, H)
5287
+ };
5288
+ }
5289
+ }
4975
5290
  const layer = factory.create();
4976
- await layer.init({ ...baseCtx, ctx2d: null }, sl.p ?? {});
5291
+ await layer.init({ ...base, ctx2d: null }, sl.p ?? {});
4977
5292
  bound.push({
4978
5293
  layer,
4979
5294
  startMs: seg.startMs,
4980
5295
  endMs: seg.endMs,
4981
5296
  segIndex,
4982
- screenPinned: SCREEN_PINNED_KEYS.has(sl.k)
5297
+ screenPinned: SCREEN_PINNED_KEYS.has(sl.k),
5298
+ region,
5299
+ base
4983
5300
  });
4984
5301
  }
4985
5302
  }
@@ -5060,6 +5377,20 @@ async function buildScene(opts) {
5060
5377
  }
5061
5378
  return plan;
5062
5379
  }
5380
+ function drawBound(target, b, drawTMs, sharedCtx) {
5381
+ if (!b.region) {
5382
+ b.layer.draw(sharedCtx, drawTMs);
5383
+ return;
5384
+ }
5385
+ const r = regionRect(b.region, W, H);
5386
+ target.save();
5387
+ target.beginPath();
5388
+ target.rect(r.x, r.y, r.w, r.h);
5389
+ target.clip();
5390
+ target.translate(r.x, r.y);
5391
+ b.layer.draw({ ...b.base, ctx2d: target }, drawTMs);
5392
+ target.restore();
5393
+ }
5063
5394
  function drawSegmentInto(target, segIndex, drawTMs) {
5064
5395
  const ctx = { ...baseCtx, ctx2d: target };
5065
5396
  const cam = camera(drawTMs);
@@ -5068,7 +5399,7 @@ async function buildScene(opts) {
5068
5399
  for (const b of bound) {
5069
5400
  if (b.segIndex !== segIndex || b.screenPinned) continue;
5070
5401
  if (drawTMs < b.startMs || drawTMs >= b.endMs) continue;
5071
- b.layer.draw(ctx, drawTMs);
5402
+ drawBound(target, b, drawTMs, ctx);
5072
5403
  }
5073
5404
  target.restore();
5074
5405
  target.save();
@@ -5076,7 +5407,7 @@ async function buildScene(opts) {
5076
5407
  for (const b of bound) {
5077
5408
  if (b.segIndex !== segIndex || !b.screenPinned) continue;
5078
5409
  if (drawTMs < b.startMs || drawTMs >= b.endMs) continue;
5079
- b.layer.draw(ctx, drawTMs);
5410
+ drawBound(target, b, drawTMs, ctx);
5080
5411
  }
5081
5412
  target.restore();
5082
5413
  }
@@ -5116,7 +5447,7 @@ async function buildScene(opts) {
5116
5447
  for (const b of bound) {
5117
5448
  if (b.screenPinned) continue;
5118
5449
  if (tMs < b.startMs || tMs >= b.endMs) continue;
5119
- b.layer.draw(ctx, tMs);
5450
+ drawBound(ctx2d, b, tMs, ctx);
5120
5451
  }
5121
5452
  ctx2d.restore();
5122
5453
  ctx2d.save();
@@ -5124,7 +5455,7 @@ async function buildScene(opts) {
5124
5455
  for (const b of bound) {
5125
5456
  if (!b.screenPinned) continue;
5126
5457
  if (tMs < b.startMs || tMs >= b.endMs) continue;
5127
- b.layer.draw(ctx, tMs);
5458
+ drawBound(ctx2d, b, tMs, ctx);
5128
5459
  }
5129
5460
  ctx2d.restore();
5130
5461
  return;
@@ -5186,6 +5517,345 @@ async function recordSceneSpec(opts) {
5186
5517
  });
5187
5518
  }
5188
5519
 
5520
+ // src/scene/presets.ts
5521
+ var DEFAULT_HOOK_CENTER_FRAC = SAFE_ZONE.top + 0.06;
5522
+ var DEFAULTS14 = {
5523
+ split: 0.42,
5524
+ durationSec: 8,
5525
+ seam: "line",
5526
+ size: [1080, 1920],
5527
+ theme: "default",
5528
+ fps: 30,
5529
+ hookFontPx: 84
5530
+ };
5531
+ function reactionSplitSpec(o) {
5532
+ const split = o.split ?? DEFAULTS14.split;
5533
+ if (!(split > 0 && split < 1)) {
5534
+ throw new Error(`reactionSplitSpec: split must be strictly between 0 and 1 (got ${split})`);
5535
+ }
5536
+ if (!o.app.length) {
5537
+ throw new Error("reactionSplitSpec: `app` must contain at least one layer");
5538
+ }
5539
+ const durationSec = o.durationSec ?? DEFAULTS14.durationSec;
5540
+ const seam = o.seam ?? DEFAULTS14.seam;
5541
+ const topPane = { x: 0, y: 0, w: 1, h: split };
5542
+ const bottomPane = { x: 0, y: split, w: 1, h: 1 - split };
5543
+ const layers = [
5544
+ { k: "video-source", region: topPane, p: o.reaction },
5545
+ ...o.app.map((l) => ({ ...l, region: l.region ?? bottomPane }))
5546
+ ];
5547
+ if (seam !== "none") {
5548
+ layers.push({ k: "pane-seam", p: { atFrac: split, orientation: "h", style: seam } });
5549
+ }
5550
+ layers.push({
5551
+ k: "kinetic-text",
5552
+ p: {
5553
+ text: o.hook,
5554
+ mode: "word-pop",
5555
+ startMs: 0,
5556
+ revealMs: 900,
5557
+ fontPx: o.hookFontPx ?? DEFAULTS14.hookFontPx,
5558
+ centerFrac: o.hookCenterFrac ?? DEFAULT_HOOK_CENTER_FRAC,
5559
+ ...o.hookColor ? { color: o.hookColor } : {}
5560
+ }
5561
+ });
5562
+ const timeline = [{ at: [0, "end"], layers }];
5563
+ if (o.endCard) {
5564
+ if (o.endCard.atSec < 0 || o.endCard.atSec >= durationSec) {
5565
+ throw new Error(
5566
+ `reactionSplitSpec: endCard.atSec ${o.endCard.atSec} must be within [0, ${durationSec})`
5567
+ );
5568
+ }
5569
+ timeline.push({
5570
+ at: [o.endCard.atSec, "end"],
5571
+ layers: [{
5572
+ k: "end-card",
5573
+ p: {
5574
+ ...o.endCard.text ? { text: o.endCard.text } : {},
5575
+ ...o.endCard.handle ? { handle: o.endCard.handle } : {}
5576
+ }
5577
+ }]
5578
+ });
5579
+ }
5580
+ return {
5581
+ size: o.size ?? DEFAULTS14.size,
5582
+ theme: o.theme ?? DEFAULTS14.theme,
5583
+ durationMode: "fixed",
5584
+ durationSec,
5585
+ fps: o.fps ?? DEFAULTS14.fps,
5586
+ timeline
5587
+ };
5588
+ }
5589
+
5590
+ // src/scene/reactions.ts
5591
+ var REACTION_EMOTIONS = [
5592
+ "confused",
5593
+ "shocked",
5594
+ "impressed",
5595
+ "delighted",
5596
+ "deadpan",
5597
+ "skeptical"
5598
+ ];
5599
+ var DEFAULT_FOCUS = { x: 0.5, y: 0.38 };
5600
+ function isFrac(v) {
5601
+ return typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 1;
5602
+ }
5603
+ function nonEmptyString(v) {
5604
+ return typeof v === "string" && v.trim().length > 0;
5605
+ }
5606
+ function validateReactionManifest(json) {
5607
+ const errs = [];
5608
+ if (json == null || typeof json !== "object") return ["manifest must be an object"];
5609
+ const m = json;
5610
+ if (!Array.isArray(m.clips)) return ["manifest.clips must be an array"];
5611
+ const seen = /* @__PURE__ */ new Set();
5612
+ m.clips.forEach((raw, i) => {
5613
+ const at = `clips[${i}]`;
5614
+ if (raw == null || typeof raw !== "object") {
5615
+ errs.push(`${at} must be an object`);
5616
+ return;
5617
+ }
5618
+ const c = raw;
5619
+ if (!nonEmptyString(c.id)) errs.push(`${at}.id must be a non-empty string`);
5620
+ else if (seen.has(c.id)) errs.push(`${at}.id "${c.id}" is duplicated`);
5621
+ else seen.add(c.id);
5622
+ if (!nonEmptyString(c.file)) errs.push(`${at}.file must be a non-empty string`);
5623
+ if (!REACTION_EMOTIONS.includes(c.emotion)) {
5624
+ errs.push(`${at}.emotion must be one of ${REACTION_EMOTIONS.join(", ")}`);
5625
+ }
5626
+ if (typeof c.durationMs !== "number" || !Number.isFinite(c.durationMs) || c.durationMs <= 0) {
5627
+ errs.push(`${at}.durationMs must be a positive number`);
5628
+ }
5629
+ if (!nonEmptyString(c.source)) {
5630
+ errs.push(`${at}.source is required \u2014 record where this clip came from (no un-provenanced footage)`);
5631
+ }
5632
+ if (!nonEmptyString(c.licenseId)) {
5633
+ errs.push(`${at}.licenseId is required \u2014 record the licence/receipt proving the right to use it`);
5634
+ }
5635
+ if (c.acquiredAt != null && !/^\d{4}-\d{2}-\d{2}$/.test(String(c.acquiredAt))) {
5636
+ errs.push(`${at}.acquiredAt must be an ISO date (YYYY-MM-DD)`);
5637
+ }
5638
+ if (c.faceBox != null) {
5639
+ const f = c.faceBox;
5640
+ if (!Array.isArray(f) || f.length !== 4 || !f.every(isFrac)) {
5641
+ errs.push(`${at}.faceBox must be [x, y, w, h] fractions within 0..1`);
5642
+ } else {
5643
+ const [x, y, w, h] = f;
5644
+ if (w <= 0 || h <= 0) errs.push(`${at}.faceBox width/height must be > 0`);
5645
+ if (x + w > 1 + 1e-9 || y + h > 1 + 1e-9) errs.push(`${at}.faceBox must fit inside the source`);
5646
+ }
5647
+ }
5648
+ });
5649
+ return errs;
5650
+ }
5651
+ function assertReactionManifest(json) {
5652
+ const errs = validateReactionManifest(json);
5653
+ if (errs.length) throw new Error(`reaction manifest invalid:
5654
+ - ${errs.join("\n - ")}`);
5655
+ return json;
5656
+ }
5657
+ function clipFocus(clip) {
5658
+ if (!clip.faceBox) return { ...DEFAULT_FOCUS };
5659
+ const [x, y, w, h] = clip.faceBox;
5660
+ return { x: x + w / 2, y: y + h / 2 };
5661
+ }
5662
+ function reactionProps(clip, o = {}) {
5663
+ return {
5664
+ src: `${o.baseUrl ?? ""}${clip.file}`,
5665
+ fit: "cover",
5666
+ focus: clipFocus(clip),
5667
+ startMs: o.startMs ?? 0,
5668
+ sourceStartMs: o.sourceStartMs ?? 0,
5669
+ loop: o.loop ?? true,
5670
+ rate: o.rate ?? 1,
5671
+ durationMs: clip.durationMs
5672
+ };
5673
+ }
5674
+ function pickClips(m, emotion) {
5675
+ return emotion ? m.clips.filter((c) => c.emotion === emotion) : [...m.clips];
5676
+ }
5677
+
5678
+ // src/scene/paneChecks.ts
5679
+ var PAPER_LUM_FLOOR = 200;
5680
+ function px(f, x, y) {
5681
+ const i = (y * f.w + x) * 4;
5682
+ return [f.data[i], f.data[i + 1], f.data[i + 2]];
5683
+ }
5684
+ function lum(r, g, b) {
5685
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
5686
+ }
5687
+ function inkFractionInRow(f, y, x0, x1) {
5688
+ const n = x1 - x0;
5689
+ if (n <= 0 || y < 0 || y >= f.h) return 0;
5690
+ let ink = 0;
5691
+ const lo = Math.max(0, x0);
5692
+ const hi = Math.min(f.w, x1);
5693
+ for (let x = lo; x < hi; x++) {
5694
+ const [r, g, b] = px(f, x, y);
5695
+ const chroma = Math.max(r, g, b) - Math.min(r, g, b);
5696
+ if (lum(r, g, b) < PAPER_LUM_FLOOR - 30 || chroma > 40) ink++;
5697
+ }
5698
+ return ink / n;
5699
+ }
5700
+ function checkPaneSafety(frames, o) {
5701
+ const srcW = o.srcW ?? 1080;
5702
+ const srcH = o.srcH ?? 1920;
5703
+ const maxInkFrac = o.maxInkFrac ?? 0.08;
5704
+ const frame = safeBox(srcW, srcH);
5705
+ const pane = regionRect(o.region, srcW, srcH);
5706
+ const y0Src = Math.max(frame.bottom, pane.y);
5707
+ const y1Src = pane.y + pane.h;
5708
+ const name = "PANE SAFE ZONE";
5709
+ if (y1Src <= y0Src) {
5710
+ return { name, pass: true, detail: "pane ends above the frame bottom-safe line (nothing to check)" };
5711
+ }
5712
+ const sy = frames.frameH / srcH;
5713
+ const sx = frames.frameW / srcW;
5714
+ const fy0 = Math.round(y0Src * sy);
5715
+ const fy1 = Math.round(y1Src * sy);
5716
+ const x0 = Math.round(pane.x * sx);
5717
+ const x1 = Math.round((pane.x + pane.w) * sx);
5718
+ const offenders = [];
5719
+ for (const { tSec, f } of frames.show) {
5720
+ let worst = 0;
5721
+ for (let y = fy0; y < Math.min(fy1, f.h); y++) {
5722
+ worst = Math.max(worst, inkFractionInRow(f, y, x0, x1));
5723
+ }
5724
+ if (worst > maxInkFrac) offenders.push({ tSec: +tSec.toFixed(2), inkFrac: +worst.toFixed(3) });
5725
+ }
5726
+ const pass = offenders.length === 0;
5727
+ return {
5728
+ name,
5729
+ pass,
5730
+ detail: pass ? `no pane content below the frame bottom-safe line (y=${Math.round(frame.bottom)}) across ${frames.show.length} frames` : `pane content under the platform caption/action rail in ${offenders.length} samples: ${JSON.stringify(offenders.slice(0, 6))}`
5731
+ };
5732
+ }
5733
+ function checkSeamPresent(frames, o) {
5734
+ const srcH = o.srcH ?? 1920;
5735
+ const tolPx = o.tolPx ?? 6;
5736
+ const minSpan = o.minSpan ?? 0.9;
5737
+ const sy = frames.frameH / srcH;
5738
+ const centre = Math.round(o.splitFrac * srcH * sy);
5739
+ const band = Math.max(1, Math.round(tolPx * sy));
5740
+ const missing = [];
5741
+ for (const { tSec, f } of frames.show) {
5742
+ let best = 0;
5743
+ for (let y = centre - band; y <= centre + band; y++) {
5744
+ best = Math.max(best, inkFractionInRow(f, y, 0, f.w));
5745
+ }
5746
+ if (best < minSpan) missing.push(+tSec.toFixed(2));
5747
+ }
5748
+ const pass = frames.show.length > 0 && missing.length === 0;
5749
+ return {
5750
+ name: "PANE SEAM PRESENT",
5751
+ pass,
5752
+ detail: frames.show.length === 0 ? "no frames sampled (cannot verify)" : pass ? `seam rule found within ${tolPx}px of y=${o.splitFrac * srcH | 0} in all ${frames.show.length} frames` : `seam missing/broken at t=${JSON.stringify(missing.slice(0, 6))} \u2014 did the split collapse?`
5753
+ };
5754
+ }
5755
+ function faceLandsInPane(o) {
5756
+ const frameW = o.frameW ?? 1080;
5757
+ const frameH = o.frameH ?? 1920;
5758
+ const pane = regionRect(o.region, frameW, frameH);
5759
+ const b = fitBoxes(o.srcW, o.srcH, pane.w, pane.h, o.fit ?? "cover", o.focus);
5760
+ const [fx, fy, fw, fh] = o.faceBox;
5761
+ const faceSrc = { x: fx * o.srcW, y: fy * o.srcH, w: fw * o.srcW, h: fh * o.srcH };
5762
+ const ix0 = Math.max(faceSrc.x, b.sx);
5763
+ const iy0 = Math.max(faceSrc.y, b.sy);
5764
+ const ix1 = Math.min(faceSrc.x + faceSrc.w, b.sx + b.sw);
5765
+ const iy1 = Math.min(faceSrc.y + faceSrc.h, b.sy + b.sh);
5766
+ const iw = Math.max(0, ix1 - ix0);
5767
+ const ih = Math.max(0, iy1 - iy0);
5768
+ const visibleFrac = faceSrc.w * faceSrc.h > 0 ? iw * ih / (faceSrc.w * faceSrc.h) : 0;
5769
+ if (iw <= 0 || ih <= 0) {
5770
+ return {
5771
+ ok: false,
5772
+ visibleFrac: 0,
5773
+ frameRect: { x: 0, y: 0, w: 0, h: 0 },
5774
+ reason: "the declared face is entirely outside the cover crop"
5775
+ };
5776
+ }
5777
+ const kx = b.dw / b.sw;
5778
+ const ky = b.dh / b.sh;
5779
+ const frameRect2 = {
5780
+ x: pane.x + b.dx + (ix0 - b.sx) * kx,
5781
+ y: pane.y + b.dy + (iy0 - b.sy) * ky,
5782
+ w: iw * kx,
5783
+ h: ih * ky
5784
+ };
5785
+ const ok = visibleFrac > 0.999;
5786
+ return {
5787
+ ok,
5788
+ visibleFrac,
5789
+ frameRect: frameRect2,
5790
+ ...ok ? {} : { reason: `only ${(visibleFrac * 100).toFixed(1)}% of the declared face survives the crop` }
5791
+ };
5792
+ }
5793
+
5794
+ // src/scene/variants.ts
5795
+ function gcd(a, b) {
5796
+ while (b) {
5797
+ const t = a % b;
5798
+ a = b;
5799
+ b = t;
5800
+ }
5801
+ return a;
5802
+ }
5803
+ function coprimeStride(total) {
5804
+ if (total <= 2) return 1;
5805
+ const start = Math.max(1, Math.round(total * 0.6180339887498949));
5806
+ for (let s = start; s < start + total; s++) {
5807
+ const c = (s - 1) % (total - 1) + 1;
5808
+ if (gcd(c, total) === 1) return c;
5809
+ }
5810
+ return 1;
5811
+ }
5812
+ function variantIndices(n, sizes, seed = 0) {
5813
+ const total = sizes.reduce((a, b) => a * b, 1);
5814
+ if (total <= 0 || n <= 0) return [];
5815
+ const stride = coprimeStride(total);
5816
+ const out = [];
5817
+ for (let k = 0; k < n; k++) {
5818
+ let flat = ((seed + k * stride) % total + total) % total;
5819
+ const tuple = [];
5820
+ for (let a = sizes.length - 1; a >= 0; a--) {
5821
+ tuple[a] = flat % sizes[a];
5822
+ flat = Math.floor(flat / sizes[a]);
5823
+ }
5824
+ out.push(tuple);
5825
+ }
5826
+ return out;
5827
+ }
5828
+ function slugify(s, max = 32) {
5829
+ const base = s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
5830
+ return (base || "hook").slice(0, max).replace(/-+$/, "");
5831
+ }
5832
+ function reactionVariants(o) {
5833
+ if (!o.hooks.length) throw new Error("reactionVariants: `hooks` must not be empty");
5834
+ if (!o.clips.length) throw new Error("reactionVariants: `clips` must not be empty");
5835
+ const endings = o.endings?.length ? o.endings : [null];
5836
+ const sizes = [o.hooks.length, o.clips.length, endings.length];
5837
+ const total = sizes.reduce((a, b) => a * b, 1);
5838
+ const n = o.n ?? total;
5839
+ return variantIndices(n, sizes, o.seed ?? 0).map(([hi, ci, ei]) => {
5840
+ const hook = o.hooks[hi];
5841
+ const clip = o.clips[ci];
5842
+ const ending = endings[ei];
5843
+ const spec = reactionSplitSpec({
5844
+ ...o.base,
5845
+ hook,
5846
+ reaction: reactionProps(clip, { baseUrl: o.baseUrl }),
5847
+ ...ending ? { endCard: ending } : {}
5848
+ });
5849
+ return {
5850
+ id: `${clip.id}--${slugify(hook)}--e${ei}`,
5851
+ hook,
5852
+ clipId: clip.id,
5853
+ endingIndex: ei,
5854
+ spec
5855
+ };
5856
+ });
5857
+ }
5858
+
5189
5859
  // src/scene/gate.ts
5190
5860
  var AV_TOLERANCE_MS = 60;
5191
5861
  async function runGate(input) {
@@ -5200,6 +5870,11 @@ async function runGate(input) {
5200
5870
  }
5201
5871
  const errs = factory.validateProps(sl.p ?? {});
5202
5872
  for (const e of errs) errors.push({ check: "spec", message: e });
5873
+ if (sl.region) {
5874
+ for (const e of validateRegion(sl.region)) {
5875
+ errors.push({ check: "spec", message: `layer "${sl.k}": ${e}` });
5876
+ }
5877
+ }
5203
5878
  }
5204
5879
  }
5205
5880
  try {
@@ -5281,12 +5956,12 @@ function mapBoxThroughLayout(base, b) {
5281
5956
  };
5282
5957
  }
5283
5958
  function followSrcBox(rn, focusBoxCanvas) {
5284
- const px = focusBoxCanvas.w * (FOLLOW_PAD - 1) / 2;
5959
+ const px2 = focusBoxCanvas.w * (FOLLOW_PAD - 1) / 2;
5285
5960
  const py = focusBoxCanvas.h * (FOLLOW_PAD - 1) / 2;
5286
5961
  const src = {
5287
- x: Math.max(0, focusBoxCanvas.x - px),
5962
+ x: Math.max(0, focusBoxCanvas.x - px2),
5288
5963
  y: Math.max(0, focusBoxCanvas.y - py),
5289
- w: focusBoxCanvas.w + 2 * px,
5964
+ w: focusBoxCanvas.w + 2 * px2,
5290
5965
  h: focusBoxCanvas.h + 2 * py
5291
5966
  };
5292
5967
  src.w = Math.min(src.w, rn.canvas.width - src.x);
@@ -5503,13 +6178,18 @@ function contourMinimapDemoSpec(opts = {}) {
5503
6178
  };
5504
6179
  }
5505
6180
  export {
6181
+ DEFAULT_FOCUS,
5506
6182
  DEFAULT_FUNCTION_COLORS,
6183
+ DEFAULT_HOOK_CENTER_FRAC,
5507
6184
  FIFTHS_MAJOR,
5508
6185
  FIFTHS_MINOR,
5509
6186
  FOLLOW_BARS,
5510
6187
  FOLLOW_PAD,
6188
+ FULL_REGION,
6189
+ PAPER_LUM_FLOOR,
5511
6190
  PIANO_HIGH,
5512
6191
  PIANO_LOW,
6192
+ REACTION_EMOTIONS,
5513
6193
  SCALE_INTERVALS,
5514
6194
  activeChord,
5515
6195
  activeCue,
@@ -5518,6 +6198,7 @@ export {
5518
6198
  applySchedule,
5519
6199
  applyToContext,
5520
6200
  assertGate,
6201
+ assertReactionManifest,
5521
6202
  audioPlayheadLine,
5522
6203
  ballArc,
5523
6204
  ballX,
@@ -5530,15 +6211,19 @@ export {
5530
6211
  buildScene,
5531
6212
  cameraForFollow,
5532
6213
  cameraTransform,
6214
+ checkPaneSafety,
6215
+ checkSeamPresent,
5533
6216
  chordRibbonFactory,
5534
6217
  circleOfFifthsDemoSpec,
5535
6218
  circleOfFifthsFactory,
5536
6219
  clamp,
5537
6220
  clickTrackSchedule,
6221
+ clipFocus,
5538
6222
  compositeRhythmFactory,
5539
6223
  contourMinimapDemoSpec,
5540
6224
  contourPoints,
5541
6225
  contourPolyline,
6226
+ coprimeStride,
5542
6227
  countInLeadSec,
5543
6228
  countInSchedule,
5544
6229
  countdownFactory,
@@ -5563,10 +6248,12 @@ export {
5563
6248
  easeInOut,
5564
6249
  easeOut,
5565
6250
  endCardFactory,
6251
+ faceLandsInPane,
5566
6252
  fallingKeyboardDemoScore,
5567
6253
  fallingKeyboardDemoSpec,
5568
6254
  fallingNotesFactory,
5569
6255
  firstMeasureBox,
6256
+ fitBoxes,
5570
6257
  followBoxAt,
5571
6258
  followSrcBox,
5572
6259
  followWindowStart,
@@ -5585,9 +6272,11 @@ export {
5585
6272
  identityCamera,
5586
6273
  imageRevealFactory,
5587
6274
  inRange,
6275
+ inkFractionInRow,
5588
6276
  intervalArcsFactory,
5589
6277
  invLerp,
5590
6278
  isBlackKey,
6279
+ isFullRegion,
5591
6280
  karaokeCaptionFactory,
5592
6281
  kenBurns,
5593
6282
  keyCenterX,
@@ -5611,14 +6300,18 @@ export {
5611
6300
  measureSystemMap,
5612
6301
  timeToX as minimapTimeToX,
5613
6302
  msPerBeat,
6303
+ needsSeek,
5614
6304
  notationFactory,
5615
6305
  notationLayout,
5616
6306
  noteColor,
5617
6307
  noteSetXRange,
6308
+ paneSafeBox,
6309
+ paneSeamFactory,
5618
6310
  parseKey,
5619
6311
  parseTimeSig,
5620
6312
  particleBurstFactory,
5621
6313
  pcToSlot,
6314
+ pickClips,
5622
6315
  pitchAt,
5623
6316
  pitchContourFactory,
5624
6317
  pitchRange,
@@ -5633,7 +6326,11 @@ export {
5633
6326
  radialSpectrumFactory,
5634
6327
  rayEndpoints,
5635
6328
  rayPointAt,
6329
+ reactionProps,
6330
+ reactionSplitSpec,
6331
+ reactionVariants,
5636
6332
  recordSceneSpec,
6333
+ regionRect,
5637
6334
  registerLayer,
5638
6335
  registeredKeys,
5639
6336
  resolveAnchor,
@@ -5654,6 +6351,9 @@ export {
5654
6351
  slotAngle,
5655
6352
  slotPc,
5656
6353
  slotPoint,
6354
+ slugify,
6355
+ sourceSize,
6356
+ sourceTimeMs,
5657
6357
  spectrumFactory,
5658
6358
  staffAnchor,
5659
6359
  staffKeyboardRayFactory,
@@ -5665,7 +6365,11 @@ export {
5665
6365
  transitionProgress,
5666
6366
  validateChordTrack,
5667
6367
  validateQuiz,
6368
+ validateReactionManifest,
6369
+ validateRegion,
5668
6370
  validateSections,
6371
+ variantIndices,
6372
+ videoSourceFactory,
5669
6373
  visualTimelineMs,
5670
6374
  vstackAudioPlayheadLine,
5671
6375
  vstackFollowBox,