@real-music-packages/web-core 0.24.1 → 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,
@@ -4074,6 +4076,485 @@ var scaleHighlightFactory = {
4074
4076
  }
4075
4077
  };
4076
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"');
4554
+ return errs;
4555
+ }
4556
+ };
4557
+
4077
4558
  // src/scene/registry.ts
4078
4559
  var REGISTRY = /* @__PURE__ */ new Map();
4079
4560
  function registerLayer(factory) {
@@ -4116,6 +4597,11 @@ registerLayer(kineticTextFactory);
4116
4597
  registerLayer(countdownFactory);
4117
4598
  registerLayer(waveformFactory);
4118
4599
  registerLayer(scaleHighlightFactory);
4600
+ registerLayer(particleBurstFactory);
4601
+ registerLayer(tensionGraphFactory);
4602
+ registerLayer(textureFactory);
4603
+ registerLayer(statCounterFactory);
4604
+ registerLayer(endCardFactory);
4119
4605
 
4120
4606
  // src/scene/audioLayers.ts
4121
4607
  function countInSchedule(opts) {
@@ -4274,7 +4760,11 @@ var SCREEN_PINNED_KEYS = /* @__PURE__ */ new Set([
4274
4760
  "progress-ring",
4275
4761
  "karaoke-caption",
4276
4762
  "kinetic-text",
4277
- "countdown"
4763
+ "countdown",
4764
+ "particle-burst",
4765
+ "texture",
4766
+ "stat-counter",
4767
+ "end-card"
4278
4768
  ]);
4279
4769
  function defaultBufferFactory() {
4280
4770
  const g = globalThis;
@@ -4912,6 +5402,7 @@ export {
4912
5402
  easeIn,
4913
5403
  easeInOut,
4914
5404
  easeOut,
5405
+ endCardFactory,
4915
5406
  fallingKeyboardDemoScore,
4916
5407
  fallingKeyboardDemoSpec,
4917
5408
  fallingNotesFactory,
@@ -4963,6 +5454,7 @@ export {
4963
5454
  noteSetXRange,
4964
5455
  parseKey,
4965
5456
  parseTimeSig,
5457
+ particleBurstFactory,
4966
5458
  pcToSlot,
4967
5459
  pitchAt,
4968
5460
  pitchContourFactory,
@@ -5003,7 +5495,10 @@ export {
5003
5495
  staffAnchor,
5004
5496
  staffKeyboardRayFactory,
5005
5497
  staffRayDemoSpec,
5498
+ statCounterFactory,
5006
5499
  systemBox,
5500
+ tensionGraphFactory,
5501
+ textureFactory,
5007
5502
  transitionProgress,
5008
5503
  validateChordTrack,
5009
5504
  validateQuiz,