@real-music-packages/web-core 0.24.0 → 0.25.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.
@@ -2,9 +2,11 @@ import {
2
2
  intervalBySemitones
3
3
  } from "../chunk-N56UTMWA.js";
4
4
  import {
5
+ getScaleDegree,
6
+ getStability,
5
7
  noteNameToIndex,
6
8
  pitchClass
7
- } from "../chunk-25RUSM2X.js";
9
+ } from "../chunk-GORQ5YMR.js";
8
10
  import {
9
11
  ctaScene,
10
12
  drawSafeGuides,
@@ -4005,6 +4007,7 @@ function scaleHighlightLayer() {
4005
4007
  let alpha = 0.55;
4006
4008
  let label = true;
4007
4009
  let labelTextProp;
4010
+ let labelCenterFrac;
4008
4011
  return {
4009
4012
  key: "scale-highlight",
4010
4013
  init(_ctx, props) {
@@ -4015,6 +4018,7 @@ function scaleHighlightLayer() {
4015
4018
  alpha = props.alpha ?? 0.55;
4016
4019
  label = props.label ?? true;
4017
4020
  labelTextProp = props.labelText;
4021
+ labelCenterFrac = props.labelCenterFrac;
4018
4022
  },
4019
4023
  draw(ctx, _tMs) {
4020
4024
  const c = ctx.ctx2d;
@@ -4045,8 +4049,9 @@ function scaleHighlightLayer() {
4045
4049
  c.fillStyle = ctx.theme.ink;
4046
4050
  c.font = `700 ${Math.round(ctx.H * 0.03)}px ${ctx.theme.fontDisplay}`;
4047
4051
  c.textAlign = "center";
4048
- c.textBaseline = "top";
4049
- c.fillText(text, ctx.W / 2, sb.top + sb.h * 0.04);
4052
+ c.textBaseline = labelCenterFrac != null ? "middle" : "top";
4053
+ const ly = labelCenterFrac != null ? ctx.H * labelCenterFrac : sb.top + sb.h * 0.04;
4054
+ c.fillText(text, ctx.W / 2, ly);
4050
4055
  c.restore();
4051
4056
  }
4052
4057
  }
@@ -4066,6 +4071,486 @@ var scaleHighlightFactory = {
4066
4071
  if (p.alpha != null && (typeof p.alpha !== "number" || p.alpha < 0 || p.alpha > 1)) errs.push("scale-highlight.alpha must be in [0,1]");
4067
4072
  if (p.label != null && typeof p.label !== "boolean") errs.push("scale-highlight.label must be a boolean");
4068
4073
  if (p.labelText != null && typeof p.labelText !== "string") errs.push("scale-highlight.labelText must be a string");
4074
+ if (p.labelCenterFrac != null && (typeof p.labelCenterFrac !== "number" || p.labelCenterFrac < 0 || p.labelCenterFrac > 1)) errs.push("scale-highlight.labelCenterFrac must be in [0,1]");
4075
+ return errs;
4076
+ }
4077
+ };
4078
+
4079
+ // src/scene/layers/particleBurst.ts
4080
+ var DEFAULTS7 = { count: 16, lifeMs: 750, speed: 520, gravity: 900, originYFrac: 0.6, size: 9 };
4081
+ function seeded(a, b) {
4082
+ const s = Math.sin(a * 127.1 + b * 311.7) * 43758.5453;
4083
+ return s - Math.floor(s);
4084
+ }
4085
+ function rgba7(hex, al) {
4086
+ const h = hex.replace("#", "");
4087
+ const n = h.length === 3 ? parseInt(h.split("").map((c) => c + c).join(""), 16) : parseInt(h, 16);
4088
+ return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${al})`;
4089
+ }
4090
+ function particleBurstLayer() {
4091
+ let sources = [];
4092
+ let count = DEFAULTS7.count;
4093
+ let lifeMs = DEFAULTS7.lifeMs;
4094
+ let speed = DEFAULTS7.speed;
4095
+ let gravity = DEFAULTS7.gravity;
4096
+ let originYFrac = DEFAULTS7.originYFrac;
4097
+ let color;
4098
+ let size = DEFAULTS7.size;
4099
+ return {
4100
+ key: "particle-burst",
4101
+ init(ctx, props) {
4102
+ count = props.count ?? DEFAULTS7.count;
4103
+ lifeMs = props.lifeMs ?? DEFAULTS7.lifeMs;
4104
+ speed = props.speed ?? DEFAULTS7.speed;
4105
+ gravity = props.gravity ?? DEFAULTS7.gravity;
4106
+ originYFrac = props.originYFrac ?? DEFAULTS7.originYFrac;
4107
+ color = props.color;
4108
+ size = props.size ?? DEFAULTS7.size;
4109
+ if (props.onsetsMs) {
4110
+ sources = props.onsetsMs.map((tMs) => ({ tMs, x01: 0.5 }));
4111
+ } else if (ctx.score?.notes?.length) {
4112
+ const notes = ctx.score.notes;
4113
+ let lo = Infinity, hi = -Infinity;
4114
+ for (const nn of notes) {
4115
+ if (nn.pitchMidi < lo) lo = nn.pitchMidi;
4116
+ if (nn.pitchMidi > hi) hi = nn.pitchMidi;
4117
+ }
4118
+ const span = Math.max(1, hi - lo);
4119
+ const byOnset = /* @__PURE__ */ new Map();
4120
+ for (const nn of notes) if (!byOnset.has(nn.onsetMs)) byOnset.set(nn.onsetMs, nn.pitchMidi);
4121
+ sources = distinctOnsets(notes).map((tMs) => ({ tMs, x01: 0.15 + 0.7 * (((byOnset.get(tMs) ?? lo) - lo) / span) }));
4122
+ } else {
4123
+ sources = [];
4124
+ }
4125
+ sources.sort((a, b) => a.tMs - b.tMs);
4126
+ },
4127
+ draw(ctx, tMs) {
4128
+ if (!sources.length) return;
4129
+ const c = ctx.ctx2d;
4130
+ const W = ctx.W, H = ctx.H;
4131
+ const oy = H * originYFrac;
4132
+ const col = color ?? ctx.theme.gold;
4133
+ c.save();
4134
+ c.globalCompositeOperation = "lighter";
4135
+ for (let si = 0; si < sources.length; si++) {
4136
+ const src = sources[si];
4137
+ const age = tMs - src.tMs;
4138
+ if (age < 0) break;
4139
+ if (age >= lifeMs) continue;
4140
+ const ageSec = age / 1e3;
4141
+ const lifeFrac = age / lifeMs;
4142
+ const ox = W * src.x01;
4143
+ for (let i = 0; i < count; i++) {
4144
+ const a = seeded(si * 31 + i, 7);
4145
+ const sp = speed * (0.5 + seeded(si, i * 17));
4146
+ const ang = -Math.PI / 2 + (a - 0.5) * Math.PI * 1.2;
4147
+ const vx = Math.cos(ang) * sp;
4148
+ const vy = Math.sin(ang) * sp;
4149
+ const x = ox + vx * ageSec;
4150
+ const y = oy + vy * ageSec + 0.5 * gravity * ageSec * ageSec;
4151
+ const alpha = (1 - lifeFrac) * 0.9;
4152
+ const r = size * (1 - lifeFrac * 0.6);
4153
+ if (r <= 0) continue;
4154
+ c.fillStyle = rgba7(col, alpha);
4155
+ c.beginPath();
4156
+ c.arc(x, y, r, 0, Math.PI * 2);
4157
+ c.fill();
4158
+ }
4159
+ }
4160
+ c.restore();
4161
+ }
4162
+ };
4163
+ }
4164
+ var particleBurstFactory = {
4165
+ key: "particle-burst",
4166
+ create: particleBurstLayer,
4167
+ validateProps(props) {
4168
+ if (props == null || typeof props !== "object") return ["particle-burst: props must be an object"];
4169
+ const p = props;
4170
+ const errs = [];
4171
+ if (p.onsetsMs != null && (!Array.isArray(p.onsetsMs) || p.onsetsMs.some((x) => typeof x !== "number"))) errs.push("particle-burst.onsetsMs must be a number[]");
4172
+ for (const k of ["count", "lifeMs", "speed", "gravity", "size"]) {
4173
+ if (p[k] != null && (typeof p[k] !== "number" || p[k] <= 0)) errs.push(`particle-burst.${k} must be a positive number`);
4174
+ }
4175
+ if (p.originYFrac != null && (typeof p.originYFrac !== "number" || p.originYFrac < 0 || p.originYFrac > 1)) errs.push("particle-burst.originYFrac must be in [0,1]");
4176
+ if (p.color != null && typeof p.color !== "string") errs.push("particle-burst.color must be a string");
4177
+ return errs;
4178
+ }
4179
+ };
4180
+
4181
+ // src/scene/layers/tensionGraph.ts
4182
+ function stabilityTension(midi, key) {
4183
+ const deg = getScaleDegree(midi, key);
4184
+ if (deg == null) return 1;
4185
+ const s = getStability(deg);
4186
+ return s === "stable" ? 0.15 : s === "lessStable" ? 0.5 : s === "unstable" ? 0.85 : 0.5;
4187
+ }
4188
+ function rgba8(hex, a) {
4189
+ const h = hex.replace("#", "");
4190
+ const n = h.length === 3 ? parseInt(h.split("").map((c) => c + c).join(""), 16) : parseInt(h, 16);
4191
+ return `rgba(${n >> 16 & 255},${n >> 8 & 255},${n & 255},${a})`;
4192
+ }
4193
+ function tensionGraphLayer() {
4194
+ let series = [];
4195
+ let durationMs = 1;
4196
+ let topProp;
4197
+ let heightProp;
4198
+ let color;
4199
+ let fill = true;
4200
+ let width = 5;
4201
+ let dot = true;
4202
+ return {
4203
+ key: "tension-graph",
4204
+ init(ctx, props) {
4205
+ topProp = props.top;
4206
+ heightProp = props.height;
4207
+ color = props.color;
4208
+ fill = props.fill ?? true;
4209
+ width = props.width ?? 5;
4210
+ dot = props.dot ?? true;
4211
+ if (props.series?.length) {
4212
+ series = props.series.slice().sort((a, b) => a.tMs - b.tMs);
4213
+ } else if (ctx.score?.notes?.length) {
4214
+ const pts = contourPoints(ctx.score.notes);
4215
+ if (props.key) {
4216
+ series = pts.map((p) => ({ tMs: p.tMs, value: stabilityTension(p.pitchMidi, props.key) }));
4217
+ } else {
4218
+ const r = pitchRange(pts);
4219
+ const span = Math.max(1, r.max - r.min);
4220
+ series = pts.map((p) => ({ tMs: p.tMs, value: (p.pitchMidi - r.min) / span }));
4221
+ }
4222
+ } else {
4223
+ series = [];
4224
+ }
4225
+ durationMs = ctx.score?.durationMs ?? (series.length ? series[series.length - 1].tMs : 1);
4226
+ },
4227
+ draw(ctx, tMs) {
4228
+ if (series.length < 2) return;
4229
+ const c = ctx.ctx2d;
4230
+ const sb = ctx.safeBox;
4231
+ const top = topProp ?? sb.top + sb.h * 0.18;
4232
+ const height = heightProp ?? sb.h * 0.3;
4233
+ const bottom = top + height;
4234
+ const col = color ?? ctx.theme.accent;
4235
+ const x = (ms) => sb.left + (sb.right - sb.left) * Math.min(1, Math.max(0, ms / durationMs));
4236
+ const y = (v) => bottom - Math.min(1, Math.max(0, v)) * height;
4237
+ const playX = x(tMs);
4238
+ c.save();
4239
+ c.lineJoin = "round";
4240
+ c.lineCap = "round";
4241
+ c.globalAlpha = 0.2;
4242
+ c.strokeStyle = ctx.theme.sepia;
4243
+ c.lineWidth = width;
4244
+ c.beginPath();
4245
+ series.forEach((p, i) => i === 0 ? c.moveTo(x(p.tMs), y(p.value)) : c.lineTo(x(p.tMs), y(p.value)));
4246
+ c.stroke();
4247
+ const revealed = [];
4248
+ for (let i = 0; i < series.length; i++) {
4249
+ const px = x(series[i].tMs);
4250
+ if (px <= playX) {
4251
+ revealed.push({ x: px, y: y(series[i].value) });
4252
+ } else {
4253
+ if (i > 0) {
4254
+ const a = series[i - 1], b = series[i];
4255
+ const f = (playX - x(a.tMs)) / Math.max(1, x(b.tMs) - x(a.tMs));
4256
+ revealed.push({ x: playX, y: y(a.value + (b.value - a.value) * f) });
4257
+ }
4258
+ break;
4259
+ }
4260
+ }
4261
+ if (revealed.length) {
4262
+ c.globalAlpha = 1;
4263
+ if (fill) {
4264
+ c.fillStyle = rgba8(col, 0.22);
4265
+ c.beginPath();
4266
+ c.moveTo(revealed[0].x, bottom);
4267
+ for (const p of revealed) c.lineTo(p.x, p.y);
4268
+ c.lineTo(revealed[revealed.length - 1].x, bottom);
4269
+ c.closePath();
4270
+ c.fill();
4271
+ }
4272
+ c.strokeStyle = col;
4273
+ c.lineWidth = width;
4274
+ c.beginPath();
4275
+ revealed.forEach((p, i) => i === 0 ? c.moveTo(p.x, p.y) : c.lineTo(p.x, p.y));
4276
+ c.stroke();
4277
+ if (dot) {
4278
+ const last = revealed[revealed.length - 1];
4279
+ c.fillStyle = col;
4280
+ c.beginPath();
4281
+ c.arc(last.x, last.y, width * 2, 0, Math.PI * 2);
4282
+ c.fill();
4283
+ }
4284
+ }
4285
+ c.restore();
4286
+ }
4287
+ };
4288
+ }
4289
+ var tensionGraphFactory = {
4290
+ key: "tension-graph",
4291
+ create: tensionGraphLayer,
4292
+ validateProps(props) {
4293
+ if (props == null || typeof props !== "object") return ["tension-graph: props must be an object"];
4294
+ const p = props;
4295
+ const errs = [];
4296
+ if (p.series != null) {
4297
+ if (!Array.isArray(p.series)) errs.push("tension-graph.series must be an array of {tMs, value}");
4298
+ else p.series.forEach((raw, i) => {
4299
+ const s = raw;
4300
+ if (typeof s?.tMs !== "number" || typeof s?.value !== "number") errs.push(`tension-graph.series[${i}] must be {tMs:number, value:number}`);
4301
+ });
4302
+ }
4303
+ if (p.key != null && typeof p.key !== "string") errs.push("tension-graph.key must be a string");
4304
+ if (p.top != null && typeof p.top !== "number") errs.push("tension-graph.top must be a number");
4305
+ if (p.height != null && (typeof p.height !== "number" || p.height <= 0)) errs.push("tension-graph.height must be a positive number");
4306
+ if (p.color != null && typeof p.color !== "string") errs.push("tension-graph.color must be a string");
4307
+ if (p.fill != null && typeof p.fill !== "boolean") errs.push("tension-graph.fill must be a boolean");
4308
+ if (p.width != null && (typeof p.width !== "number" || p.width <= 0)) errs.push("tension-graph.width must be a positive number");
4309
+ if (p.dot != null && typeof p.dot !== "boolean") errs.push("tension-graph.dot must be a boolean");
4310
+ return errs;
4311
+ }
4312
+ };
4313
+
4314
+ // src/scene/layers/texture.ts
4315
+ var DEFAULTS8 = { grain: 0.05, grainDensity: 700, vignette: 0.35, letterboxFrac: 0 };
4316
+ function lcg(seed) {
4317
+ let s = seed | 0 || 1;
4318
+ return () => {
4319
+ s = s * 1664525 + 1013904223 & 2147483647;
4320
+ return s / 2147483647;
4321
+ };
4322
+ }
4323
+ function textureLayer() {
4324
+ let grain = DEFAULTS8.grain;
4325
+ let grainDensity = DEFAULTS8.grainDensity;
4326
+ let vignette = DEFAULTS8.vignette;
4327
+ let letterboxFrac = DEFAULTS8.letterboxFrac;
4328
+ return {
4329
+ key: "texture",
4330
+ init(_ctx, props) {
4331
+ grain = props.grain ?? DEFAULTS8.grain;
4332
+ grainDensity = props.grainDensity ?? DEFAULTS8.grainDensity;
4333
+ vignette = props.vignette ?? DEFAULTS8.vignette;
4334
+ letterboxFrac = props.letterboxFrac ?? DEFAULTS8.letterboxFrac;
4335
+ },
4336
+ draw(ctx, tMs) {
4337
+ const c = ctx.ctx2d;
4338
+ const W = ctx.W, H = ctx.H;
4339
+ c.save();
4340
+ if (vignette > 0) {
4341
+ const g = c.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.35, W / 2, H / 2, Math.max(W, H) * 0.72);
4342
+ g.addColorStop(0, "rgba(0,0,0,0)");
4343
+ g.addColorStop(1, `rgba(0,0,0,${vignette})`);
4344
+ c.fillStyle = g;
4345
+ c.fillRect(0, 0, W, H);
4346
+ }
4347
+ if (grain > 0 && grainDensity > 0) {
4348
+ const frame = Math.floor(tMs / 1e3 * (ctx.fps || 30));
4349
+ const rnd = lcg(frame * 2654435761);
4350
+ for (let i = 0; i < grainDensity; i++) {
4351
+ const x = rnd() * W;
4352
+ const y = rnd() * H;
4353
+ const dark = rnd() < 0.5;
4354
+ c.fillStyle = dark ? `rgba(0,0,0,${grain})` : `rgba(255,255,255,${grain})`;
4355
+ c.fillRect(x, y, 2, 2);
4356
+ }
4357
+ }
4358
+ if (letterboxFrac > 0) {
4359
+ const bh = H * letterboxFrac;
4360
+ c.fillStyle = "#000";
4361
+ c.fillRect(0, 0, W, bh);
4362
+ c.fillRect(0, H - bh, W, bh);
4363
+ }
4364
+ c.restore();
4365
+ }
4366
+ };
4367
+ }
4368
+ var textureFactory = {
4369
+ key: "texture",
4370
+ create: textureLayer,
4371
+ validateProps(props) {
4372
+ if (props == null || typeof props !== "object") return ["texture: props must be an object"];
4373
+ const p = props;
4374
+ const errs = [];
4375
+ if (p.grain != null && (typeof p.grain !== "number" || p.grain < 0 || p.grain > 1)) errs.push("texture.grain must be in [0,1]");
4376
+ if (p.grainDensity != null && (typeof p.grainDensity !== "number" || p.grainDensity < 0)) errs.push("texture.grainDensity must be a number >= 0");
4377
+ if (p.vignette != null && (typeof p.vignette !== "number" || p.vignette < 0 || p.vignette > 1)) errs.push("texture.vignette must be in [0,1]");
4378
+ if (p.letterboxFrac != null && (typeof p.letterboxFrac !== "number" || p.letterboxFrac < 0 || p.letterboxFrac > 0.45)) errs.push("texture.letterboxFrac must be in [0,0.45]");
4379
+ return errs;
4380
+ }
4381
+ };
4382
+
4383
+ // src/scene/layers/statCounter.ts
4384
+ var DEFAULTS9 = { startMs: 0, durationMs: 1200, fontPx: 220, centerFrac: 0.46, group: true };
4385
+ function easeOut2(t) {
4386
+ return 1 - Math.pow(1 - t, 3);
4387
+ }
4388
+ function clamp015(x) {
4389
+ return x < 0 ? 0 : x > 1 ? 1 : x;
4390
+ }
4391
+ function statCounterLayer() {
4392
+ let value = 0;
4393
+ let startMs = DEFAULTS9.startMs;
4394
+ let durationMs = DEFAULTS9.durationMs;
4395
+ let prefix = "";
4396
+ let suffix = "";
4397
+ let label = "";
4398
+ let fontPx = DEFAULTS9.fontPx;
4399
+ let centerFrac = DEFAULTS9.centerFrac;
4400
+ let color;
4401
+ let group = DEFAULTS9.group;
4402
+ return {
4403
+ key: "stat-counter",
4404
+ init(_ctx, props) {
4405
+ value = props.value ?? 0;
4406
+ startMs = props.startMs ?? DEFAULTS9.startMs;
4407
+ durationMs = props.durationMs ?? DEFAULTS9.durationMs;
4408
+ prefix = props.prefix ?? "";
4409
+ suffix = props.suffix ?? "";
4410
+ label = props.label ?? "";
4411
+ fontPx = props.fontPx ?? DEFAULTS9.fontPx;
4412
+ centerFrac = props.centerFrac ?? DEFAULTS9.centerFrac;
4413
+ color = props.color;
4414
+ group = props.group ?? DEFAULTS9.group;
4415
+ },
4416
+ draw(ctx, tMs) {
4417
+ const c = ctx.ctx2d;
4418
+ const p = clamp015((tMs - startMs) / Math.max(1, durationMs));
4419
+ const shown = Math.round(easeOut2(p) * value);
4420
+ const numStr = group ? shown.toLocaleString("en-US") : String(shown);
4421
+ const cx = ctx.W / 2;
4422
+ const cy = ctx.H * centerFrac;
4423
+ const col = color ?? ctx.theme.accent;
4424
+ c.save();
4425
+ c.textAlign = "center";
4426
+ c.textBaseline = "middle";
4427
+ c.fillStyle = col;
4428
+ c.font = `800 ${fontPx}px ${ctx.theme.fontDisplay}`;
4429
+ c.fillText(`${prefix}${numStr}${suffix}`, cx, cy);
4430
+ if (label) {
4431
+ c.fillStyle = ctx.theme.ink;
4432
+ c.font = `600 ${Math.round(fontPx * 0.26)}px ${ctx.theme.fontDisplay}`;
4433
+ c.fillText(label, cx, cy + fontPx * 0.62);
4434
+ }
4435
+ c.restore();
4436
+ }
4437
+ };
4438
+ }
4439
+ var statCounterFactory = {
4440
+ key: "stat-counter",
4441
+ create: statCounterLayer,
4442
+ validateProps(props) {
4443
+ if (props == null || typeof props !== "object") return ["stat-counter: props must be an object"];
4444
+ const p = props;
4445
+ const errs = [];
4446
+ if (typeof p.value !== "number") errs.push("stat-counter.value must be a number");
4447
+ if (p.startMs != null && typeof p.startMs !== "number") errs.push("stat-counter.startMs must be a number");
4448
+ if (p.durationMs != null && (typeof p.durationMs !== "number" || p.durationMs <= 0)) errs.push("stat-counter.durationMs must be a positive number");
4449
+ for (const k of ["prefix", "suffix", "label"]) if (p[k] != null && typeof p[k] !== "string") errs.push(`stat-counter.${k} must be a string`);
4450
+ if (p.fontPx != null && (typeof p.fontPx !== "number" || p.fontPx <= 0)) errs.push("stat-counter.fontPx must be a positive number");
4451
+ if (p.centerFrac != null && (typeof p.centerFrac !== "number" || p.centerFrac < 0 || p.centerFrac > 1)) errs.push("stat-counter.centerFrac must be in [0,1]");
4452
+ if (p.color != null && typeof p.color !== "string") errs.push("stat-counter.color must be a string");
4453
+ if (p.group != null && typeof p.group !== "boolean") errs.push("stat-counter.group must be a boolean");
4454
+ return errs;
4455
+ }
4456
+ };
4457
+
4458
+ // src/scene/layers/endCard.ts
4459
+ var DEFAULTS10 = { text: "Follow for daily", startMs: 0, inMs: 400, centerFrac: 0.5, fontPx: 72, arrow: true, arrowDir: "up" };
4460
+ function clamp016(x) {
4461
+ return x < 0 ? 0 : x > 1 ? 1 : x;
4462
+ }
4463
+ function easeOut3(t) {
4464
+ return 1 - Math.pow(1 - t, 3);
4465
+ }
4466
+ function endCardLayer() {
4467
+ let text = DEFAULTS10.text;
4468
+ let handle = "";
4469
+ let startMs = DEFAULTS10.startMs;
4470
+ let inMs = DEFAULTS10.inMs;
4471
+ let color;
4472
+ let centerFrac = DEFAULTS10.centerFrac;
4473
+ let fontPx = DEFAULTS10.fontPx;
4474
+ let arrow = DEFAULTS10.arrow;
4475
+ let arrowDir = DEFAULTS10.arrowDir;
4476
+ return {
4477
+ key: "end-card",
4478
+ init(_ctx, props) {
4479
+ text = props.text ?? DEFAULTS10.text;
4480
+ handle = props.handle ?? "";
4481
+ startMs = props.startMs ?? DEFAULTS10.startMs;
4482
+ inMs = props.inMs ?? DEFAULTS10.inMs;
4483
+ color = props.color;
4484
+ centerFrac = props.centerFrac ?? DEFAULTS10.centerFrac;
4485
+ fontPx = props.fontPx ?? DEFAULTS10.fontPx;
4486
+ arrow = props.arrow ?? DEFAULTS10.arrow;
4487
+ arrowDir = props.arrowDir ?? DEFAULTS10.arrowDir;
4488
+ },
4489
+ draw(ctx, tMs) {
4490
+ if (tMs < startMs) return;
4491
+ const c = ctx.ctx2d;
4492
+ const cx = ctx.W / 2;
4493
+ const cy = ctx.H * centerFrac;
4494
+ const col = color ?? ctx.theme.accent;
4495
+ const p = clamp016((tMs - startMs) / Math.max(1, inMs));
4496
+ const scale = 0.7 + 0.3 * easeOut3(p);
4497
+ const dir = arrowDir === "down" ? 1 : -1;
4498
+ const bounce = Math.sin(tMs / 260) * 14;
4499
+ c.save();
4500
+ c.globalAlpha = p;
4501
+ c.translate(cx, cy);
4502
+ c.scale(scale, scale);
4503
+ c.textAlign = "center";
4504
+ c.textBaseline = "middle";
4505
+ c.fillStyle = col;
4506
+ c.font = `800 ${fontPx}px ${ctx.theme.fontDisplay}`;
4507
+ c.fillText(text, 0, 0);
4508
+ if (handle) {
4509
+ c.fillStyle = ctx.theme.ink;
4510
+ c.font = `600 ${Math.round(fontPx * 0.5)}px ${ctx.theme.fontDisplay}`;
4511
+ c.fillText(handle, 0, fontPx * 0.9);
4512
+ }
4513
+ c.restore();
4514
+ if (arrow) {
4515
+ const ax = cx;
4516
+ const ay = (arrowDir === "down" ? cy + fontPx * 1.6 : cy - fontPx * 1.6) + bounce * dir;
4517
+ const s = fontPx * 0.4;
4518
+ c.save();
4519
+ c.globalAlpha = p;
4520
+ c.strokeStyle = col;
4521
+ c.lineWidth = 10;
4522
+ c.lineCap = "round";
4523
+ c.lineJoin = "round";
4524
+ c.beginPath();
4525
+ if (arrowDir === "down") {
4526
+ c.moveTo(ax - s, ay - s * 0.6);
4527
+ c.lineTo(ax, ay + s * 0.6);
4528
+ c.lineTo(ax + s, ay - s * 0.6);
4529
+ } else {
4530
+ c.moveTo(ax - s, ay + s * 0.6);
4531
+ c.lineTo(ax, ay - s * 0.6);
4532
+ c.lineTo(ax + s, ay + s * 0.6);
4533
+ }
4534
+ c.stroke();
4535
+ c.restore();
4536
+ }
4537
+ }
4538
+ };
4539
+ }
4540
+ var endCardFactory = {
4541
+ key: "end-card",
4542
+ create: endCardLayer,
4543
+ validateProps(props) {
4544
+ if (props == null || typeof props !== "object") return ["end-card: props must be an object"];
4545
+ const p = props;
4546
+ const errs = [];
4547
+ for (const k of ["text", "handle", "color"]) if (p[k] != null && typeof p[k] !== "string") errs.push(`end-card.${k} must be a string`);
4548
+ if (p.startMs != null && typeof p.startMs !== "number") errs.push("end-card.startMs must be a number");
4549
+ if (p.inMs != null && (typeof p.inMs !== "number" || p.inMs <= 0)) errs.push("end-card.inMs must be a positive number");
4550
+ if (p.centerFrac != null && (typeof p.centerFrac !== "number" || p.centerFrac < 0 || p.centerFrac > 1)) errs.push("end-card.centerFrac must be in [0,1]");
4551
+ if (p.fontPx != null && (typeof p.fontPx !== "number" || p.fontPx <= 0)) errs.push("end-card.fontPx must be a positive number");
4552
+ if (p.arrow != null && typeof p.arrow !== "boolean") errs.push("end-card.arrow must be a boolean");
4553
+ if (p.arrowDir != null && p.arrowDir !== "up" && p.arrowDir !== "down") errs.push('end-card.arrowDir must be "up" | "down"');
4069
4554
  return errs;
4070
4555
  }
4071
4556
  };
@@ -4112,6 +4597,11 @@ registerLayer(kineticTextFactory);
4112
4597
  registerLayer(countdownFactory);
4113
4598
  registerLayer(waveformFactory);
4114
4599
  registerLayer(scaleHighlightFactory);
4600
+ registerLayer(particleBurstFactory);
4601
+ registerLayer(tensionGraphFactory);
4602
+ registerLayer(textureFactory);
4603
+ registerLayer(statCounterFactory);
4604
+ registerLayer(endCardFactory);
4115
4605
 
4116
4606
  // src/scene/audioLayers.ts
4117
4607
  function countInSchedule(opts) {
@@ -4270,7 +4760,11 @@ var SCREEN_PINNED_KEYS = /* @__PURE__ */ new Set([
4270
4760
  "progress-ring",
4271
4761
  "karaoke-caption",
4272
4762
  "kinetic-text",
4273
- "countdown"
4763
+ "countdown",
4764
+ "particle-burst",
4765
+ "texture",
4766
+ "stat-counter",
4767
+ "end-card"
4274
4768
  ]);
4275
4769
  function defaultBufferFactory() {
4276
4770
  const g = globalThis;
@@ -4908,6 +5402,7 @@ export {
4908
5402
  easeIn,
4909
5403
  easeInOut,
4910
5404
  easeOut,
5405
+ endCardFactory,
4911
5406
  fallingKeyboardDemoScore,
4912
5407
  fallingKeyboardDemoSpec,
4913
5408
  fallingNotesFactory,
@@ -4959,6 +5454,7 @@ export {
4959
5454
  noteSetXRange,
4960
5455
  parseKey,
4961
5456
  parseTimeSig,
5457
+ particleBurstFactory,
4962
5458
  pcToSlot,
4963
5459
  pitchAt,
4964
5460
  pitchContourFactory,
@@ -4999,7 +5495,10 @@ export {
4999
5495
  staffAnchor,
5000
5496
  staffKeyboardRayFactory,
5001
5497
  staffRayDemoSpec,
5498
+ statCounterFactory,
5002
5499
  systemBox,
5500
+ tensionGraphFactory,
5501
+ textureFactory,
5003
5502
  transitionProgress,
5004
5503
  validateChordTrack,
5005
5504
  validateQuiz,