onda-engine 0.1.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/player.js ADDED
@@ -0,0 +1,1749 @@
1
+ import { renderFrame, registeredFonts } from 'onda-engine/react';
2
+ import { forwardRef, useRef, useId, useState, useMemo, useEffect, useCallback, useImperativeHandle } from 'react';
3
+ import { jsx, jsxs } from 'react/jsx-runtime';
4
+
5
+ // ../player/src/canvas-renderer.ts
6
+ function drawScene(ctx, scene) {
7
+ const { width, height } = scene.composition;
8
+ ctx.clearRect(0, 0, width, height);
9
+ ctx.save();
10
+ ctx.globalAlpha = 1;
11
+ drawNode(ctx, scene.root);
12
+ ctx.restore();
13
+ }
14
+ function drawNode(ctx, node) {
15
+ ctx.save();
16
+ const transform = node.transform;
17
+ if (transform?.translate) ctx.translate(transform.translate.x, transform.translate.y);
18
+ if (transform?.scale) ctx.scale(transform.scale.x, transform.scale.y);
19
+ if (typeof node.opacity === "number") ctx.globalAlpha *= node.opacity;
20
+ if (node.clip) {
21
+ geometryPath(ctx, node.clip);
22
+ ctx.clip();
23
+ }
24
+ const kind = node.kind;
25
+ switch (kind.type) {
26
+ case "group":
27
+ break;
28
+ case "shape":
29
+ drawShape(ctx, kind);
30
+ break;
31
+ case "text":
32
+ drawText(ctx, kind);
33
+ break;
34
+ }
35
+ for (const child of node.children ?? []) drawNode(ctx, child);
36
+ ctx.restore();
37
+ }
38
+ function geometryPath(ctx, geometry) {
39
+ ctx.beginPath();
40
+ switch (geometry.shape) {
41
+ case "rect": {
42
+ const { width, height } = geometry.size;
43
+ if (geometry.corner_radius) ctx.roundRect(0, 0, width, height, geometry.corner_radius);
44
+ else ctx.rect(0, 0, width, height);
45
+ break;
46
+ }
47
+ case "ellipse": {
48
+ const rx = geometry.size.width / 2;
49
+ const ry = geometry.size.height / 2;
50
+ ctx.ellipse(rx, ry, rx, ry, 0, 0, Math.PI * 2);
51
+ break;
52
+ }
53
+ case "path":
54
+ ctx.beginPath();
55
+ tracePath(ctx, geometry.data);
56
+ break;
57
+ }
58
+ }
59
+ function tracePath(ctx, data) {
60
+ if (typeof Path2D === "undefined") return;
61
+ pendingPath = new Path2D(data);
62
+ }
63
+ var pendingPath = null;
64
+ function drawShape(ctx, shape) {
65
+ pendingPath = null;
66
+ geometryPath(ctx, shape.geometry);
67
+ const paint = shape.gradient ? gradientStyle(ctx, shape.geometry, shape.gradient) : shape.fill ? cssColor(shape.fill) : null;
68
+ if (paint) {
69
+ ctx.fillStyle = paint;
70
+ if (pendingPath) ctx.fill(pendingPath);
71
+ else ctx.fill();
72
+ }
73
+ if (shape.stroke) {
74
+ ctx.strokeStyle = cssColor(shape.stroke.color);
75
+ ctx.lineWidth = shape.stroke.width;
76
+ if (pendingPath) ctx.stroke(pendingPath);
77
+ else ctx.stroke();
78
+ }
79
+ pendingPath = null;
80
+ }
81
+ function gradientStyle(ctx, geometry, gradient) {
82
+ let g;
83
+ if (gradient.gradient === "linear") {
84
+ g = ctx.createLinearGradient(gradient.start.x, gradient.start.y, gradient.end.x, gradient.end.y);
85
+ } else if (gradient.gradient === "radial") {
86
+ g = ctx.createRadialGradient(
87
+ gradient.center.x,
88
+ gradient.center.y,
89
+ 0,
90
+ gradient.center.x,
91
+ gradient.center.y,
92
+ gradient.radius
93
+ );
94
+ } else {
95
+ const [w, h] = geometryExtent(geometry);
96
+ g = ctx.createLinearGradient(0, 0, w, h);
97
+ }
98
+ for (const stop of gradient.stops) {
99
+ g.addColorStop(Math.min(1, Math.max(0, stop.offset)), cssColor(stop.color));
100
+ }
101
+ return g;
102
+ }
103
+ function geometryExtent(geometry) {
104
+ if (geometry.shape === "rect" || geometry.shape === "ellipse") {
105
+ return [geometry.size.width, geometry.size.height];
106
+ }
107
+ return [256, 256];
108
+ }
109
+ function drawText(ctx, text) {
110
+ ctx.fillStyle = cssColor(text.color ?? { r: 1, g: 1, b: 1 });
111
+ ctx.textBaseline = "alphabetic";
112
+ const size = text.font_size ?? 48;
113
+ ctx.font = `${size}px sans-serif`;
114
+ ctx.fillText(text.content, 0, size * 0.8);
115
+ }
116
+ function cssColor(color) {
117
+ const to255 = (c) => Math.round(Math.min(1, Math.max(0, c)) * 255);
118
+ return `rgba(${to255(color.r)}, ${to255(color.g)}, ${to255(color.b)}, ${color.a ?? 1})`;
119
+ }
120
+
121
+ // ../player/src/engine-drawer.ts
122
+ function engineDrawer(engine) {
123
+ return (ctx, scene) => {
124
+ const frame = engine.render(JSON.stringify(scene));
125
+ const buffer = frame.pixels instanceof Uint8ClampedArray ? frame.pixels : new Uint8ClampedArray(frame.pixels.buffer, frame.pixels.byteOffset, frame.pixels.length);
126
+ const image = new ImageData(buffer, frame.width, frame.height);
127
+ ctx.putImageData(image, 0, 0);
128
+ };
129
+ }
130
+
131
+ // ../player/src/audio-engine.ts
132
+ var LOOKAHEAD = 2;
133
+ var TOPUP_INTERVAL = 250;
134
+ var GAIN_RAMP = 0.012;
135
+ var PreviewAudio = class {
136
+ ctx = null;
137
+ master = null;
138
+ clips = [];
139
+ /** Decoded buffers keyed by src, reused across `setClips` so the editor's
140
+ * frequent composition edits don't re-fetch/re-decode unchanged audio. */
141
+ bufferCache = /* @__PURE__ */ new Map();
142
+ /** Bumped on every `setClips` so a stale in-flight decode can't apply. */
143
+ clipsToken = 0;
144
+ period = 1;
145
+ // one timeline cycle, seconds (always > 0)
146
+ loop = false;
147
+ rate = 1;
148
+ volume = 1;
149
+ muted = false;
150
+ playing = false;
151
+ anchorCtx = 0;
152
+ // AudioContext time at `anchorComp`
153
+ anchorComp = 0;
154
+ // composition time (s) at the anchor
155
+ nextCycle = [];
156
+ // per clip: next loop cycle index to schedule
157
+ active = [];
158
+ timer = null;
159
+ // ── public API ──────────────────────────────────────────────────────────
160
+ /** Master volume × mute. Applied as a short ramp (click-free). */
161
+ setGain(volume, muted) {
162
+ this.volume = volume;
163
+ this.muted = muted;
164
+ this.applyMaster();
165
+ }
166
+ setLoop(loop) {
167
+ this.loop = loop;
168
+ }
169
+ /** Swap the clip set (composition changed) and the timeline period. Tears down
170
+ * the current schedule, (re)decodes as needed, and reschedules if playing. */
171
+ setClips(clips, periodSeconds) {
172
+ this.period = Math.max(1e-3, periodSeconds);
173
+ this.stopActive();
174
+ const token = ++this.clipsToken;
175
+ this.clips = clips.map((clip) => ({ clip, buffer: null, gain: null, decoding: false }));
176
+ if (this.ctx) {
177
+ this.wireClipGains();
178
+ for (const cs of this.clips) this.ensureDecoded(cs, token);
179
+ if (this.playing) this.reanchor(this.currentComp());
180
+ }
181
+ }
182
+ setRate(rate) {
183
+ if (this.rate === rate) return;
184
+ const comp = this.playing ? this.currentComp() : this.anchorComp;
185
+ this.rate = rate;
186
+ if (this.playing) this.reanchor(comp);
187
+ else this.applyMaster();
188
+ }
189
+ /** Start (or restart) playback anchored at `compTime` (seconds). */
190
+ play(compTime) {
191
+ const ctx = this.ensureCtx();
192
+ void ctx.resume().catch(() => {
193
+ });
194
+ this.playing = true;
195
+ const token = this.clipsToken;
196
+ for (const cs of this.clips) this.ensureDecoded(cs, token);
197
+ this.reanchor(compTime);
198
+ this.startTimer();
199
+ }
200
+ pause() {
201
+ this.playing = false;
202
+ this.stopActive();
203
+ this.stopTimer();
204
+ }
205
+ /** Re-anchor to a new playhead. While playing this restarts the schedule from
206
+ * `compTime`; while paused it just records the position for the next play. */
207
+ seek(compTime) {
208
+ if (this.playing) this.reanchor(compTime);
209
+ else this.anchorComp = compTime;
210
+ }
211
+ dispose() {
212
+ this.stopActive();
213
+ this.stopTimer();
214
+ this.clips = [];
215
+ this.bufferCache.clear();
216
+ if (this.ctx) {
217
+ void this.ctx.close().catch(() => {
218
+ });
219
+ this.ctx = null;
220
+ this.master = null;
221
+ }
222
+ }
223
+ // ── internals ───────────────────────────────────────────────────────────
224
+ ensureCtx() {
225
+ if (!this.ctx) {
226
+ const Ctor = window.AudioContext ?? window.webkitAudioContext;
227
+ this.ctx = new Ctor();
228
+ this.master = this.ctx.createGain();
229
+ this.master.connect(this.ctx.destination);
230
+ this.applyMaster();
231
+ this.wireClipGains();
232
+ }
233
+ return this.ctx;
234
+ }
235
+ /** Give every clip a per-clip gain (its own volume) feeding the master. */
236
+ wireClipGains() {
237
+ if (!this.ctx || !this.master) return;
238
+ for (const cs of this.clips) {
239
+ if (!cs.gain) {
240
+ const g = this.ctx.createGain();
241
+ g.gain.value = Math.max(0, Math.min(1, cs.clip.volume));
242
+ g.connect(this.master);
243
+ cs.gain = g;
244
+ }
245
+ }
246
+ }
247
+ ensureDecoded(cs, token) {
248
+ if (cs.buffer || cs.decoding || !this.ctx) return;
249
+ const cached = this.bufferCache.get(cs.clip.src);
250
+ if (cached) {
251
+ cs.buffer = cached;
252
+ return;
253
+ }
254
+ cs.decoding = true;
255
+ const ctx = this.ctx;
256
+ void (async () => {
257
+ try {
258
+ const res = await fetch(cs.clip.src);
259
+ const bytes = await res.arrayBuffer();
260
+ const decoded = await ctx.decodeAudioData(bytes);
261
+ if (token !== this.clipsToken) return;
262
+ this.bufferCache.set(cs.clip.src, decoded);
263
+ cs.buffer = decoded;
264
+ cs.decoding = false;
265
+ if (this.playing) this.scheduleAhead();
266
+ } catch (err) {
267
+ cs.decoding = false;
268
+ console.warn("[onda] preview audio decode failed:", cs.clip.src, err);
269
+ }
270
+ })();
271
+ }
272
+ /** Composition time (s) currently under the playhead, derived from the anchor. */
273
+ currentComp() {
274
+ if (!this.ctx) return this.anchorComp;
275
+ return this.anchorComp + (this.ctx.currentTime - this.anchorCtx) * this.rate;
276
+ }
277
+ compToCtx(compTime) {
278
+ return this.anchorCtx + (compTime - this.anchorComp) / this.rate;
279
+ }
280
+ reanchor(compTime) {
281
+ this.stopActive();
282
+ const ctx = this.ensureCtx();
283
+ this.anchorCtx = ctx.currentTime;
284
+ this.anchorComp = compTime;
285
+ const startCycle = this.loop ? Math.floor(compTime / this.period) : 0;
286
+ this.nextCycle = this.clips.map(() => startCycle);
287
+ this.applyMaster();
288
+ this.scheduleAhead();
289
+ }
290
+ /** Queue every clip instance whose start falls within the look-ahead window.
291
+ * Idempotent + incremental: `nextCycle[i]` tracks how far each clip is queued,
292
+ * so repeated calls only add the newly-reachable cycles. */
293
+ scheduleAhead() {
294
+ if (!this.playing || !this.ctx || this.rate !== 1) return;
295
+ const ctx = this.ctx;
296
+ const horizon = ctx.currentTime + LOOKAHEAD;
297
+ for (let i = 0; i < this.clips.length; i++) {
298
+ const cs = this.clips[i];
299
+ if (!cs || !cs.buffer || !cs.gain) continue;
300
+ const bufDur = cs.buffer.duration;
301
+ for (let guard = 0; guard < 512; guard++) {
302
+ const k = this.nextCycle[i] ?? 0;
303
+ const compClipStart = k * this.period + cs.clip.start;
304
+ let ctxStart = this.compToCtx(compClipStart);
305
+ if (ctxStart >= horizon) break;
306
+ let offset = cs.clip.startAt;
307
+ let dur = Math.min(bufDur - offset, this.period - cs.clip.start);
308
+ if (ctxStart < ctx.currentTime) {
309
+ const late = (ctx.currentTime - ctxStart) * this.rate;
310
+ offset += late;
311
+ dur -= late;
312
+ ctxStart = ctx.currentTime;
313
+ }
314
+ if (dur > 5e-3 && offset < bufDur) {
315
+ const src = ctx.createBufferSource();
316
+ src.buffer = cs.buffer;
317
+ src.connect(cs.gain);
318
+ src.onended = () => {
319
+ const idx = this.active.indexOf(src);
320
+ if (idx >= 0) this.active.splice(idx, 1);
321
+ try {
322
+ src.disconnect();
323
+ } catch {
324
+ }
325
+ };
326
+ try {
327
+ src.start(ctxStart, offset, dur);
328
+ this.active.push(src);
329
+ } catch {
330
+ }
331
+ }
332
+ this.nextCycle[i] = k + 1;
333
+ if (!this.loop) break;
334
+ }
335
+ }
336
+ }
337
+ applyMaster() {
338
+ if (!this.master || !this.ctx) return;
339
+ const target = this.muted || this.rate !== 1 ? 0 : Math.max(0, Math.min(1, this.volume));
340
+ this.master.gain.setTargetAtTime(target, this.ctx.currentTime, GAIN_RAMP);
341
+ }
342
+ stopActive() {
343
+ for (const src of this.active) {
344
+ src.onended = null;
345
+ try {
346
+ src.stop();
347
+ } catch {
348
+ }
349
+ try {
350
+ src.disconnect();
351
+ } catch {
352
+ }
353
+ }
354
+ this.active = [];
355
+ }
356
+ startTimer() {
357
+ if (this.timer != null) return;
358
+ this.timer = setInterval(() => this.scheduleAhead(), TOPUP_INTERVAL);
359
+ }
360
+ stopTimer() {
361
+ if (this.timer != null) {
362
+ clearInterval(this.timer);
363
+ this.timer = null;
364
+ }
365
+ }
366
+ };
367
+
368
+ // ../player/src/audio.ts
369
+ function collectAudioClips(root) {
370
+ const out = [];
371
+ const walk = (node) => {
372
+ if (!node) return;
373
+ const k = node.kind;
374
+ if (k?.type === "audio" && typeof k.src === "string" && k.src.length > 0) {
375
+ out.push({
376
+ src: k.src,
377
+ start: k.start ?? 0,
378
+ startAt: k.start_at ?? 0,
379
+ volume: k.volume ?? 1
380
+ });
381
+ }
382
+ for (const child of node.children ?? []) walk(child);
383
+ };
384
+ walk(root);
385
+ return out;
386
+ }
387
+
388
+ // ../player/src/images.ts
389
+ var cache = /* @__PURE__ */ new Map();
390
+ var inflight = /* @__PURE__ */ new Map();
391
+ var failed = /* @__PURE__ */ new Set();
392
+ function resolveImageUrl(url) {
393
+ if (cache.has(url) || failed.has(url) || typeof fetch === "undefined") return Promise.resolve();
394
+ const existing = inflight.get(url);
395
+ if (existing) return existing;
396
+ const p = (async () => {
397
+ try {
398
+ const res = await fetch(url);
399
+ if (!res.ok) {
400
+ failed.add(url);
401
+ return;
402
+ }
403
+ const blob = await res.blob();
404
+ const dataUri = await new Promise((resolve, reject) => {
405
+ const reader = new FileReader();
406
+ reader.onload = () => resolve(String(reader.result));
407
+ reader.onerror = () => reject(reader.error);
408
+ reader.readAsDataURL(blob);
409
+ });
410
+ cache.set(url, dataUri);
411
+ } catch {
412
+ failed.add(url);
413
+ } finally {
414
+ inflight.delete(url);
415
+ }
416
+ })();
417
+ inflight.set(url, p);
418
+ return p;
419
+ }
420
+ function imageResolved(url) {
421
+ return url.startsWith("data:") || url.startsWith("onda-noise:") || cache.has(url) || failed.has(url);
422
+ }
423
+ function collectImageUrls(node, out) {
424
+ if (!node) return;
425
+ const src = node.kind?.type === "image" ? node.kind.src : void 0;
426
+ if (typeof src === "string" && src.length > 0 && !src.startsWith("data:") && !src.startsWith("onda-noise:")) {
427
+ out.add(src);
428
+ }
429
+ for (const child of node.children ?? []) collectImageUrls(child, out);
430
+ }
431
+ function applyResolvedImages(node) {
432
+ if (!node) return;
433
+ if (node.kind?.type === "image" && typeof node.kind.src === "string") {
434
+ const dataUri = cache.get(node.kind.src);
435
+ if (dataUri) node.kind.src = dataUri;
436
+ }
437
+ for (const child of node.children ?? []) applyResolvedImages(child);
438
+ }
439
+
440
+ // ../player/src/video.ts
441
+ var BUCKET_FPS = 30;
442
+ var elements = /* @__PURE__ */ new Map();
443
+ var frameCache = /* @__PURE__ */ new Map();
444
+ var inflight2 = /* @__PURE__ */ new Map();
445
+ var lastGood = /* @__PURE__ */ new Map();
446
+ var seekChain = /* @__PURE__ */ new Map();
447
+ var warned = /* @__PURE__ */ new Set();
448
+ function warnUnresolved(src) {
449
+ if (warned.has(src) || typeof console === "undefined") return;
450
+ warned.add(src);
451
+ let crossOrigin = false;
452
+ try {
453
+ crossOrigin = typeof location !== "undefined" && new URL(src, location.href).origin !== location.origin;
454
+ } catch {
455
+ }
456
+ const reason = crossOrigin ? ' \u2022 Cross-origin: the browser only reads frames from a same-origin file or one served with CORS (Access-Control-Allow-Origin). Host it with CORS or copy it into your project. YouTube/Vimeo page links are not media files and never work. Or set previewFallback="element" to play it in preview without compositing.' : " \u2022 The source failed to load (wrong path / not a video file?).";
457
+ console.warn(
458
+ `[onda] couldn't decode video for PREVIEW: ${src}
459
+ ${reason}
460
+ \u2022 This is a preview-only limit \u2014 \`onda export\` (ffmpeg) decodes any direct URL regardless of CORS.`
461
+ );
462
+ }
463
+ var scratch = null;
464
+ function bucketKey(src, time) {
465
+ return `${src}@${Math.round(Math.max(0, time) * BUCKET_FPS)}`;
466
+ }
467
+ function getVideo(src) {
468
+ const existing = elements.get(src);
469
+ if (existing) return existing;
470
+ const p = new Promise((resolve, reject) => {
471
+ const v = document.createElement("video");
472
+ v.muted = true;
473
+ v.crossOrigin = "anonymous";
474
+ v.preload = "auto";
475
+ v.playsInline = true;
476
+ const cleanup = () => {
477
+ v.removeEventListener("loadeddata", onReady);
478
+ v.removeEventListener("error", onError);
479
+ };
480
+ const onReady = () => {
481
+ cleanup();
482
+ resolve(v);
483
+ };
484
+ const onError = () => {
485
+ cleanup();
486
+ reject(new Error(`video failed to load: ${src}`));
487
+ };
488
+ v.addEventListener("loadeddata", onReady);
489
+ v.addEventListener("error", onError);
490
+ v.src = src;
491
+ v.load();
492
+ });
493
+ elements.set(src, p);
494
+ return p;
495
+ }
496
+ function seek(v, t) {
497
+ return new Promise((resolve) => {
498
+ if (v.readyState >= 2 && Math.abs(v.currentTime - t) < 1 / (BUCKET_FPS * 2)) {
499
+ resolve();
500
+ return;
501
+ }
502
+ const onSeeked = () => {
503
+ v.removeEventListener("seeked", onSeeked);
504
+ resolve();
505
+ };
506
+ v.addEventListener("seeked", onSeeked);
507
+ try {
508
+ v.currentTime = t;
509
+ } catch {
510
+ v.removeEventListener("seeked", onSeeked);
511
+ resolve();
512
+ }
513
+ });
514
+ }
515
+ function decodeFrame(src, time) {
516
+ const key = bucketKey(src, time);
517
+ const cached = frameCache.get(key);
518
+ if (cached) return Promise.resolve(cached);
519
+ const pending = inflight2.get(key);
520
+ if (pending) return pending;
521
+ const prior = seekChain.get(src) ?? Promise.resolve();
522
+ const p = prior.then(async () => {
523
+ const again = frameCache.get(key);
524
+ if (again) return again;
525
+ try {
526
+ const v = await getVideo(src);
527
+ const dur = Number.isFinite(v.duration) ? v.duration : 0;
528
+ const t = dur > 0 ? Math.min(time, Math.max(0, dur - 1 / BUCKET_FPS)) : time;
529
+ await seek(v, t);
530
+ const w = v.videoWidth;
531
+ const h = v.videoHeight;
532
+ if (!w || !h) return null;
533
+ if (!scratch) scratch = document.createElement("canvas");
534
+ if (scratch.width !== w) scratch.width = w;
535
+ if (scratch.height !== h) scratch.height = h;
536
+ const ctx = scratch.getContext("2d");
537
+ if (!ctx) return null;
538
+ ctx.drawImage(v, 0, 0, w, h);
539
+ const uri = scratch.toDataURL("image/jpeg", 0.82);
540
+ frameCache.set(key, uri);
541
+ return uri;
542
+ } catch {
543
+ return null;
544
+ } finally {
545
+ inflight2.delete(key);
546
+ }
547
+ });
548
+ inflight2.set(key, p);
549
+ seekChain.set(src, p);
550
+ return p;
551
+ }
552
+ async function resolveVideoFrames(root) {
553
+ if (!root || typeof document === "undefined") return;
554
+ const nodes = [];
555
+ const collect = (n) => {
556
+ if (!n) return;
557
+ const src = n.kind?.type === "video" ? n.kind.src : void 0;
558
+ if (typeof src === "string" && src.length > 0 && !src.startsWith("data:") && n.kind?.previewFallback !== "element") {
559
+ nodes.push(n);
560
+ }
561
+ for (const child of n.children ?? []) collect(child);
562
+ };
563
+ collect(root);
564
+ for (const n of nodes) {
565
+ const src = n.kind?.src;
566
+ if (typeof src !== "string") continue;
567
+ const time = typeof n.kind?.time === "number" ? n.kind.time : 0;
568
+ const uri = await decodeFrame(src, time);
569
+ if (uri && n.kind) {
570
+ n.kind.src = uri;
571
+ lastGood.set(src, uri);
572
+ } else if (!uri && n.kind) {
573
+ const held = lastGood.get(src);
574
+ if (held) n.kind.src = held;
575
+ else warnUnresolved(src);
576
+ }
577
+ }
578
+ }
579
+ function collectVideoOverlays(root, compWidth, compHeight) {
580
+ const out = [];
581
+ if (!root) return out;
582
+ let idx = 0;
583
+ const walk = (node, ax, ay, asx, asy) => {
584
+ const t = node.transform?.translate;
585
+ const s = node.transform?.scale;
586
+ const nx = ax + asx * (t?.x ?? 0);
587
+ const ny = ay + asy * (t?.y ?? 0);
588
+ const nsx = asx * (s?.x ?? 1);
589
+ const nsy = asy * (s?.y ?? 1);
590
+ const k = node.kind;
591
+ if (k?.type === "video" && k.previewFallback === "element" && typeof k.src === "string" && k.src.length > 0) {
592
+ out.push({
593
+ key: `${k.src}#${idx++}`,
594
+ src: k.src,
595
+ x: nx,
596
+ y: ny,
597
+ w: (typeof k.width === "number" ? k.width : compWidth) * nsx,
598
+ h: (typeof k.height === "number" ? k.height : compHeight) * nsy,
599
+ fit: k.fit ?? "cover"
600
+ });
601
+ }
602
+ for (const child of node.children ?? []) walk(child, nx, ny, nsx, nsy);
603
+ };
604
+ walk(root, 0, 0, 1, 1);
605
+ return out;
606
+ }
607
+ var enginesRendering = /* @__PURE__ */ new WeakSet();
608
+ var engineFontCount = /* @__PURE__ */ new WeakMap();
609
+ function ensureFontsLoaded(engine) {
610
+ const e = engine;
611
+ const load = e.loadFont ?? e.load_font;
612
+ if (typeof load !== "function") return;
613
+ const fonts = registeredFonts();
614
+ const loaded = engineFontCount.get(engine) ?? 0;
615
+ for (let i = loaded; i < fonts.length; i++) {
616
+ try {
617
+ load.call(engine, fonts[i]);
618
+ } catch {
619
+ }
620
+ }
621
+ engineFontCount.set(engine, fonts.length);
622
+ }
623
+ var Player = forwardRef(function Player2({
624
+ composition,
625
+ autoPlay = true,
626
+ loop: initialLoop = true,
627
+ draw,
628
+ gpuEngine,
629
+ engine,
630
+ showStatus = true,
631
+ controls = "auto",
632
+ label = "ONDA composition player",
633
+ className,
634
+ initialFrame = 0,
635
+ playbackRate = 1,
636
+ onFrameUpdate,
637
+ onPlay,
638
+ onPause
639
+ }, ref) {
640
+ const canvasRef = useRef(null);
641
+ const stageRef = useRef(null);
642
+ const uid = useId();
643
+ useInjectStyles();
644
+ const [gpuFailed, setGpuFailed] = useState(false);
645
+ const [engineFailed, setEngineFailed] = useState(false);
646
+ const mode = useMemo(() => {
647
+ if (draw) return { kind: "sync", draw, label: "Custom" };
648
+ if (gpuEngine && !gpuFailed) return { kind: "gpu", engine: gpuEngine };
649
+ if (engine && !engineFailed) {
650
+ const real = engineDrawer(engine);
651
+ const safe = (ctx, scene) => {
652
+ try {
653
+ real(ctx, scene);
654
+ } catch {
655
+ setEngineFailed(true);
656
+ drawScene(ctx, scene);
657
+ }
658
+ };
659
+ return { kind: "sync", draw: safe, label: "CPU" };
660
+ }
661
+ return { kind: "sync", draw: drawScene, label: "Canvas2D" };
662
+ }, [draw, gpuEngine, gpuFailed, engine, engineFailed]);
663
+ const backend = mode.kind === "gpu" ? "WebGPU" : mode.label;
664
+ const isExact = mode.kind === "gpu" || backend === "CPU" || backend === "Custom";
665
+ const config = useMemo(() => renderFrame(composition, 0).composition, [composition]);
666
+ const totalFrames = Math.max(1, config.duration_in_frames);
667
+ const lastFrame = totalFrames - 1;
668
+ const videoOverlays = useMemo(
669
+ () => collectVideoOverlays(renderFrame(composition, 0).root, config.width, config.height),
670
+ [composition, config.width, config.height]
671
+ );
672
+ const videoOverlayRef = useRef(null);
673
+ const audioClips = useMemo(
674
+ () => collectAudioClips(renderFrame(composition, 0).root),
675
+ [composition]
676
+ );
677
+ const audioRef = useRef(null);
678
+ if (audioRef.current === null && typeof window !== "undefined") {
679
+ audioRef.current = new PreviewAudio();
680
+ }
681
+ useEffect(
682
+ () => () => {
683
+ audioRef.current?.dispose();
684
+ audioRef.current = null;
685
+ },
686
+ []
687
+ );
688
+ const [volume, setVolume] = useState(1);
689
+ const [muted, setMuted] = useState(false);
690
+ const [frame, setFrame] = useState(
691
+ () => Math.min(lastFrame, Math.max(0, Math.floor(initialFrame)))
692
+ );
693
+ const [imagesReady, setImagesReady] = useState(0);
694
+ useEffect(() => {
695
+ const urls = /* @__PURE__ */ new Set();
696
+ for (const f of /* @__PURE__ */ new Set([0, Math.floor(lastFrame / 2), lastFrame])) {
697
+ collectImageUrls(renderFrame(composition, f).root, urls);
698
+ }
699
+ if (urls.size === 0) return;
700
+ let cancelled = false;
701
+ Promise.all([...urls].map(resolveImageUrl)).then(() => {
702
+ if (!cancelled) setImagesReady((v) => v + 1);
703
+ });
704
+ return () => {
705
+ cancelled = true;
706
+ };
707
+ }, [composition, lastFrame]);
708
+ const [playing, setPlaying] = useState(autoPlay);
709
+ const [loop, setLoop] = useState(initialLoop);
710
+ const [isFullscreen, setIsFullscreen] = useState(false);
711
+ const [seekNonce, setSeekNonce] = useState(0);
712
+ const [rate, setRate] = useState(() => clampRate(playbackRate));
713
+ const [speedOpen, setSpeedOpen] = useState(false);
714
+ const speedRef = useRef(null);
715
+ const targetRef = useRef({ composition, frame, images: imagesReady });
716
+ targetRef.current = { composition, frame, images: imagesReady };
717
+ const pendingPaintRef = useRef(null);
718
+ const blitRafRef = useRef(null);
719
+ const scheduleBlit = useCallback((out) => {
720
+ pendingPaintRef.current = out;
721
+ if (blitRafRef.current != null) return;
722
+ blitRafRef.current = requestAnimationFrame(() => {
723
+ blitRafRef.current = null;
724
+ const p = pendingPaintRef.current;
725
+ const canvas = canvasRef.current;
726
+ const ctx = canvas?.getContext("2d");
727
+ if (!p || !canvas || !ctx) return;
728
+ if (canvas.width !== p.width) canvas.width = p.width;
729
+ if (canvas.height !== p.height) canvas.height = p.height;
730
+ ctx.putImageData(new ImageData(new Uint8ClampedArray(p.pixels), p.width, p.height), 0, 0);
731
+ });
732
+ }, []);
733
+ useEffect(
734
+ () => () => {
735
+ if (blitRafRef.current != null) cancelAnimationFrame(blitRafRef.current);
736
+ },
737
+ []
738
+ );
739
+ const liveRef = useRef({ frame, playing, loop, lastFrame, totalFrames, rate });
740
+ liveRef.current = { frame, playing, loop, lastFrame, totalFrames, rate };
741
+ const onFrameUpdateRef = useRef(onFrameUpdate);
742
+ onFrameUpdateRef.current = onFrameUpdate;
743
+ const onPlayRef = useRef(onPlay);
744
+ onPlayRef.current = onPlay;
745
+ const onPauseRef = useRef(onPause);
746
+ onPauseRef.current = onPause;
747
+ const listenersRef = useRef(/* @__PURE__ */ new Map());
748
+ const emit = useCallback((type, atFrame, playbackRate2) => {
749
+ const set = listenersRef.current.get(type);
750
+ if (!set) return;
751
+ const detail = playbackRate2 === void 0 ? { frame: atFrame } : { frame: atFrame, playbackRate: playbackRate2 };
752
+ for (const listener of set) listener({ type, detail });
753
+ }, []);
754
+ const changeRate = useCallback(
755
+ (next) => {
756
+ const clamped = clampRate(next);
757
+ setRate(clamped);
758
+ emit("ratechange", liveRef.current.frame, clamped);
759
+ },
760
+ [emit]
761
+ );
762
+ useEffect(() => {
763
+ if (!speedOpen) return;
764
+ const onDown = (e) => {
765
+ if (!speedRef.current?.contains(e.target)) setSpeedOpen(false);
766
+ };
767
+ const onKey = (e) => {
768
+ if (e.key === "Escape") setSpeedOpen(false);
769
+ };
770
+ document.addEventListener("pointerdown", onDown);
771
+ document.addEventListener("keydown", onKey);
772
+ return () => {
773
+ document.removeEventListener("pointerdown", onDown);
774
+ document.removeEventListener("keydown", onKey);
775
+ };
776
+ }, [speedOpen]);
777
+ const paintGpu = useCallback(
778
+ (gpu) => {
779
+ if (enginesRendering.has(gpu)) return;
780
+ enginesRendering.add(gpu);
781
+ (async () => {
782
+ try {
783
+ let done = null;
784
+ while (true) {
785
+ const { composition: c, frame: f, images: v } = targetRef.current;
786
+ if (done && done.c === c && done.f === f && done.v === v) break;
787
+ done = { c, f, v };
788
+ const scene = renderFrame(c, f);
789
+ const urls = /* @__PURE__ */ new Set();
790
+ collectImageUrls(scene.root, urls);
791
+ const pending = [...urls].filter((u) => !imageResolved(u));
792
+ if (pending.length > 0) {
793
+ void Promise.all(pending.map(resolveImageUrl)).then(
794
+ () => setImagesReady((n) => n + 1)
795
+ );
796
+ }
797
+ applyResolvedImages(scene.root);
798
+ await resolveVideoFrames(scene.root);
799
+ ensureFontsLoaded(gpu);
800
+ const out = await gpu.render(JSON.stringify(scene));
801
+ scheduleBlit(out);
802
+ }
803
+ } catch {
804
+ setGpuFailed(true);
805
+ } finally {
806
+ enginesRendering.delete(gpu);
807
+ }
808
+ })();
809
+ },
810
+ [scheduleBlit]
811
+ );
812
+ useEffect(() => {
813
+ if (mode.kind === "gpu") {
814
+ paintGpu(mode.engine);
815
+ } else {
816
+ const ctx = canvasRef.current?.getContext("2d");
817
+ if (ctx) {
818
+ const scene = renderFrame(composition, frame);
819
+ const urls = /* @__PURE__ */ new Set();
820
+ collectImageUrls(scene.root, urls);
821
+ const pending = [...urls].filter((u) => !imageResolved(u));
822
+ if (pending.length > 0) {
823
+ void Promise.all(pending.map(resolveImageUrl)).then(() => setImagesReady((n) => n + 1));
824
+ }
825
+ applyResolvedImages(scene.root);
826
+ mode.draw(ctx, scene);
827
+ }
828
+ }
829
+ }, [composition, frame, mode, paintGpu, imagesReady]);
830
+ useEffect(() => {
831
+ const vids = videoOverlayRef.current?.querySelectorAll("video");
832
+ if (!vids) return;
833
+ for (const v of vids) {
834
+ if (playing) void v.play().catch(() => {
835
+ });
836
+ else v.pause();
837
+ }
838
+ }, [playing, videoOverlays]);
839
+ const compSeconds = useCallback((f) => f / Math.max(1, config.fps), [config.fps]);
840
+ useEffect(() => {
841
+ audioRef.current?.setClips(audioClips, totalFrames / Math.max(1, config.fps));
842
+ }, [audioClips, totalFrames, config.fps]);
843
+ useEffect(() => {
844
+ audioRef.current?.setGain(volume, muted);
845
+ }, [volume, muted]);
846
+ useEffect(() => {
847
+ audioRef.current?.setLoop(loop);
848
+ }, [loop]);
849
+ useEffect(() => {
850
+ audioRef.current?.setRate(rate);
851
+ }, [rate]);
852
+ useEffect(() => {
853
+ const audio = audioRef.current;
854
+ if (!audio) return;
855
+ if (playing) audio.play(compSeconds(liveRef.current.frame));
856
+ else audio.pause();
857
+ }, [playing, compSeconds]);
858
+ useEffect(() => {
859
+ if (liveRef.current.playing) audioRef.current?.seek(compSeconds(liveRef.current.frame));
860
+ }, [seekNonce, compSeconds]);
861
+ useEffect(() => {
862
+ if (!playing) return;
863
+ const frameDuration = 1e3 / config.fps;
864
+ let last = null;
865
+ let raf = 0;
866
+ const tick = (now) => {
867
+ if (last === null) last = now;
868
+ const r = liveRef.current.rate;
869
+ const steps = Math.floor((now - last) * r / frameDuration);
870
+ if (steps > 0) {
871
+ last += steps * frameDuration / r;
872
+ setFrame((current) => {
873
+ const next = current + steps;
874
+ if (next <= lastFrame) return next;
875
+ return loop ? next % totalFrames : lastFrame;
876
+ });
877
+ }
878
+ raf = requestAnimationFrame(tick);
879
+ };
880
+ raf = requestAnimationFrame(tick);
881
+ return () => cancelAnimationFrame(raf);
882
+ }, [playing, config.fps, totalFrames, lastFrame, loop]);
883
+ useEffect(() => {
884
+ if (!loop && frame >= lastFrame) {
885
+ setPlaying(false);
886
+ emit("ended", frame);
887
+ }
888
+ }, [frame, lastFrame, loop, emit]);
889
+ useEffect(() => {
890
+ onFrameUpdateRef.current?.(frame);
891
+ emit("frameupdate", frame);
892
+ }, [frame, emit]);
893
+ useEffect(() => {
894
+ if (playing) onPlayRef.current?.();
895
+ else onPauseRef.current?.();
896
+ emit(playing ? "play" : "pause", liveRef.current.frame);
897
+ }, [playing, emit]);
898
+ const togglePlay = useCallback(() => {
899
+ setPlaying((p) => {
900
+ const { frame: f, loop: lp, lastFrame: lf } = liveRef.current;
901
+ if (!p && !lp && f >= lf) setFrame(0);
902
+ return !p;
903
+ });
904
+ }, []);
905
+ const seekTo = useCallback(
906
+ (next) => {
907
+ setPlaying(false);
908
+ setFrame(Math.min(lastFrame, Math.max(0, next)));
909
+ setSeekNonce((n) => n + 1);
910
+ },
911
+ [lastFrame]
912
+ );
913
+ const goToFrame = useCallback(
914
+ (next) => {
915
+ const clamped = Math.min(lastFrame, Math.max(0, Math.floor(next)));
916
+ setFrame(clamped);
917
+ setSeekNonce((n) => n + 1);
918
+ emit("seeked", clamped);
919
+ },
920
+ [lastFrame, emit]
921
+ );
922
+ useImperativeHandle(
923
+ ref,
924
+ () => ({
925
+ seekTo: goToFrame,
926
+ play: () => {
927
+ const { frame: f, loop: lp, lastFrame: lf } = liveRef.current;
928
+ if (!lp && f >= lf) setFrame(0);
929
+ setPlaying(true);
930
+ },
931
+ pause: () => setPlaying(false),
932
+ toggle: togglePlay,
933
+ getCurrentFrame: () => liveRef.current.frame,
934
+ getTotalFrames: () => liveRef.current.totalFrames,
935
+ isPlaying: () => liveRef.current.playing,
936
+ getPlaybackRate: () => liveRef.current.rate,
937
+ setPlaybackRate: changeRate,
938
+ addEventListener: (type, listener) => {
939
+ const map = listenersRef.current;
940
+ if (!map.has(type)) map.set(type, /* @__PURE__ */ new Set());
941
+ map.get(type)?.add(listener);
942
+ },
943
+ removeEventListener: (type, listener) => {
944
+ listenersRef.current.get(type)?.delete(listener);
945
+ }
946
+ }),
947
+ [goToFrame, togglePlay, changeRate]
948
+ );
949
+ const toggleFullscreen = useCallback(() => {
950
+ const el = stageRef.current;
951
+ if (!el) return;
952
+ const doc = document;
953
+ if (document.fullscreenElement ?? doc.webkitFullscreenElement) {
954
+ (document.exitFullscreen ?? doc.webkitExitFullscreen)?.call(document);
955
+ } else {
956
+ (el.requestFullscreen ?? el.webkitRequestFullscreen)?.call(el);
957
+ }
958
+ }, []);
959
+ useEffect(() => {
960
+ const onChange = () => {
961
+ const doc = document;
962
+ setIsFullscreen(Boolean(document.fullscreenElement ?? doc.webkitFullscreenElement));
963
+ };
964
+ document.addEventListener("fullscreenchange", onChange);
965
+ document.addEventListener("webkitfullscreenchange", onChange);
966
+ return () => {
967
+ document.removeEventListener("fullscreenchange", onChange);
968
+ document.removeEventListener("webkitfullscreenchange", onChange);
969
+ };
970
+ }, []);
971
+ const onKeyDown = useCallback(
972
+ (event) => {
973
+ const onButton = event.target?.tagName === "BUTTON";
974
+ const jump = event.shiftKey ? 10 : 1;
975
+ switch (event.key) {
976
+ case " ":
977
+ case "k":
978
+ if (onButton && event.key === " ") return;
979
+ event.preventDefault();
980
+ togglePlay();
981
+ break;
982
+ case "ArrowRight":
983
+ event.preventDefault();
984
+ seekTo(frame + jump);
985
+ break;
986
+ case "ArrowLeft":
987
+ event.preventDefault();
988
+ seekTo(frame - jump);
989
+ break;
990
+ case "Home":
991
+ event.preventDefault();
992
+ seekTo(0);
993
+ break;
994
+ case "End":
995
+ event.preventDefault();
996
+ seekTo(lastFrame);
997
+ break;
998
+ case "l":
999
+ event.preventDefault();
1000
+ setLoop((v) => !v);
1001
+ break;
1002
+ case "f":
1003
+ event.preventDefault();
1004
+ toggleFullscreen();
1005
+ break;
1006
+ }
1007
+ },
1008
+ [frame, lastFrame, togglePlay, seekTo, toggleFullscreen]
1009
+ );
1010
+ const seconds = frame / config.fps;
1011
+ const totalSeconds = lastFrame / config.fps;
1012
+ const statusLabel = backend;
1013
+ const statusText = mode.kind === "gpu" ? "Rendered by Vello on WebGPU \u2014 pixel-identical to `onda export`, no Chromium." : backend === "CPU" ? "Rendered by the ONDA CPU engine in WebAssembly \u2014 no DOM, no Chromium." : backend === "Canvas2D" ? "Canvas2D fallback \u2014 WebGPU and the CPU engine are unavailable here." : "Custom renderer.";
1014
+ return /* @__PURE__ */ jsx(
1015
+ "div",
1016
+ {
1017
+ className: [
1018
+ "onda-player",
1019
+ playing ? "" : "is-paused",
1020
+ controls === "always" ? "is-controls" : "",
1021
+ controls === "none" ? "is-controls-none" : "",
1022
+ className
1023
+ ].filter(Boolean).join(" "),
1024
+ style: styles.root,
1025
+ role: "group",
1026
+ "aria-label": label,
1027
+ "aria-roledescription": "media player",
1028
+ tabIndex: 0,
1029
+ onKeyDown,
1030
+ children: /* @__PURE__ */ jsxs(
1031
+ "div",
1032
+ {
1033
+ ref: stageRef,
1034
+ className: "onda-player__stage",
1035
+ style: { ...styles.stage, aspectRatio: `${config.width} / ${config.height}` },
1036
+ children: [
1037
+ /* @__PURE__ */ jsx(
1038
+ "canvas",
1039
+ {
1040
+ ref: canvasRef,
1041
+ width: config.width,
1042
+ height: config.height,
1043
+ className: "onda-player__canvas",
1044
+ style: styles.canvas,
1045
+ onClick: controls === "none" ? void 0 : togglePlay,
1046
+ "aria-label": `composition preview, ${config.width}\xD7${config.height} at ${config.fps}fps \u2014 click to ${playing ? "pause" : "play"}`
1047
+ }
1048
+ ),
1049
+ videoOverlays.length > 0 && /* @__PURE__ */ jsx("div", { ref: videoOverlayRef, style: styles.videoOverlay, "aria-hidden": "true", children: videoOverlays.map((o) => /* @__PURE__ */ jsx(
1050
+ "video",
1051
+ {
1052
+ src: o.src,
1053
+ muted: true,
1054
+ autoPlay: true,
1055
+ loop: true,
1056
+ playsInline: true,
1057
+ style: {
1058
+ position: "absolute",
1059
+ left: `${o.x / config.width * 100}%`,
1060
+ top: `${o.y / config.height * 100}%`,
1061
+ width: `${o.w / config.width * 100}%`,
1062
+ height: `${o.h / config.height * 100}%`,
1063
+ objectFit: o.fit
1064
+ }
1065
+ },
1066
+ o.key
1067
+ )) }),
1068
+ showStatus && /* @__PURE__ */ jsxs("div", { className: "onda-player__badge", title: statusText, children: [
1069
+ /* @__PURE__ */ jsx(
1070
+ "span",
1071
+ {
1072
+ className: `onda-player__dot onda-player__dot--${isExact ? "ok" : "warn"}`,
1073
+ "aria-hidden": "true"
1074
+ }
1075
+ ),
1076
+ /* @__PURE__ */ jsx("span", { children: statusLabel })
1077
+ ] }),
1078
+ /* @__PURE__ */ jsx(
1079
+ "button",
1080
+ {
1081
+ type: "button",
1082
+ className: "onda-player__fs",
1083
+ onClick: toggleFullscreen,
1084
+ "aria-label": isFullscreen ? "Exit full screen" : "Full screen",
1085
+ "aria-pressed": isFullscreen,
1086
+ title: isFullscreen ? "Exit full screen (f)" : "Full screen (f)",
1087
+ children: isFullscreen ? /* @__PURE__ */ jsx(ExitFullscreenIcon, {}) : /* @__PURE__ */ jsx(FullscreenIcon, {})
1088
+ }
1089
+ ),
1090
+ /* @__PURE__ */ jsxs("div", { className: "onda-player__overlay", children: [
1091
+ /* @__PURE__ */ jsx(
1092
+ "input",
1093
+ {
1094
+ id: `${uid}-scrubber`,
1095
+ type: "range",
1096
+ className: "onda-player__scrubber",
1097
+ style: { "--progress": `${lastFrame ? frame / lastFrame * 100 : 0}%` },
1098
+ min: 0,
1099
+ max: lastFrame,
1100
+ step: 1,
1101
+ value: frame,
1102
+ onChange: (event) => seekTo(Number(event.target.value)),
1103
+ "aria-label": "Seek frame",
1104
+ "aria-valuemin": 0,
1105
+ "aria-valuemax": lastFrame,
1106
+ "aria-valuenow": frame,
1107
+ "aria-valuetext": `Frame ${frame} of ${lastFrame}, ${seconds.toFixed(2)} seconds`
1108
+ }
1109
+ ),
1110
+ /* @__PURE__ */ jsxs("div", { className: "onda-player__row", children: [
1111
+ /* @__PURE__ */ jsx(
1112
+ "button",
1113
+ {
1114
+ type: "button",
1115
+ className: "onda-player__play",
1116
+ onClick: togglePlay,
1117
+ "aria-label": playing ? "Pause" : "Play",
1118
+ "aria-pressed": playing,
1119
+ children: playing ? /* @__PURE__ */ jsx(PauseIcon, {}) : /* @__PURE__ */ jsx(PlayIcon, {})
1120
+ }
1121
+ ),
1122
+ /* @__PURE__ */ jsxs(
1123
+ "output",
1124
+ {
1125
+ className: "onda-player__readout",
1126
+ htmlFor: `${uid}-scrubber`,
1127
+ style: styles.readout,
1128
+ "aria-live": "off",
1129
+ children: [
1130
+ /* @__PURE__ */ jsx("span", { className: "onda-player__time", children: fmtTime(seconds) }),
1131
+ /* @__PURE__ */ jsx("span", { className: "onda-player__sep", "aria-hidden": "true", children: "/" }),
1132
+ /* @__PURE__ */ jsx("span", { className: "onda-player__total", children: fmtTime(totalSeconds) })
1133
+ ]
1134
+ }
1135
+ ),
1136
+ /* @__PURE__ */ jsx("span", { className: "onda-player__spacer" }),
1137
+ audioClips.length > 0 && /* @__PURE__ */ jsxs("div", { className: "onda-player__volume", children: [
1138
+ /* @__PURE__ */ jsx(
1139
+ "button",
1140
+ {
1141
+ type: "button",
1142
+ className: "onda-player__icon",
1143
+ onClick: () => setMuted((v) => !v),
1144
+ "aria-label": muted ? "Unmute" : "Mute",
1145
+ "aria-pressed": muted,
1146
+ title: muted ? "Unmute" : "Mute",
1147
+ children: muted || volume === 0 ? /* @__PURE__ */ jsx(VolumeMuteIcon, {}) : /* @__PURE__ */ jsx(VolumeIcon, {})
1148
+ }
1149
+ ),
1150
+ /* @__PURE__ */ jsx(
1151
+ "input",
1152
+ {
1153
+ type: "range",
1154
+ className: "onda-player__volume-slider",
1155
+ min: 0,
1156
+ max: 1,
1157
+ step: 0.01,
1158
+ value: muted ? 0 : volume,
1159
+ onChange: (event) => {
1160
+ const v = Number(event.target.value);
1161
+ setVolume(v);
1162
+ setMuted(v === 0);
1163
+ },
1164
+ "aria-label": "Volume"
1165
+ }
1166
+ )
1167
+ ] }),
1168
+ /* @__PURE__ */ jsxs("div", { className: "onda-player__speed", ref: speedRef, children: [
1169
+ /* @__PURE__ */ jsx(
1170
+ "button",
1171
+ {
1172
+ type: "button",
1173
+ className: `onda-player__icon onda-player__speed-btn${rate !== 1 ? " is-active" : ""}`,
1174
+ onClick: () => setSpeedOpen((v) => !v),
1175
+ "aria-label": `Playback speed: ${formatRate(rate)}`,
1176
+ "aria-haspopup": "menu",
1177
+ "aria-expanded": speedOpen,
1178
+ title: "Playback speed (preview only)",
1179
+ children: formatRate(rate)
1180
+ }
1181
+ ),
1182
+ speedOpen && /* @__PURE__ */ jsx("div", { className: "onda-player__speed-menu", role: "menu", "aria-label": "Playback speed", children: SPEED_PRESETS.map((r) => /* @__PURE__ */ jsx(
1183
+ "button",
1184
+ {
1185
+ type: "button",
1186
+ role: "menuitemradio",
1187
+ "aria-checked": r === rate,
1188
+ className: `onda-player__speed-item${r === rate ? " is-active" : ""}`,
1189
+ onClick: () => {
1190
+ changeRate(r);
1191
+ setSpeedOpen(false);
1192
+ },
1193
+ children: formatRate(r)
1194
+ },
1195
+ r
1196
+ )) })
1197
+ ] }),
1198
+ /* @__PURE__ */ jsx(
1199
+ "button",
1200
+ {
1201
+ type: "button",
1202
+ className: `onda-player__icon${loop ? " is-active" : ""}`,
1203
+ onClick: () => setLoop((v) => !v),
1204
+ "aria-label": "Loop playback",
1205
+ "aria-pressed": loop,
1206
+ title: "Loop",
1207
+ children: /* @__PURE__ */ jsx(LoopIcon, {})
1208
+ }
1209
+ )
1210
+ ] })
1211
+ ] })
1212
+ ]
1213
+ }
1214
+ )
1215
+ }
1216
+ );
1217
+ });
1218
+ var SPEED_PRESETS = [0.25, 0.5, 1, 1.5, 2];
1219
+ function clampRate(rate) {
1220
+ if (!Number.isFinite(rate) || rate <= 0) return 1;
1221
+ return Math.max(0.1, Math.min(4, rate));
1222
+ }
1223
+ function formatRate(rate) {
1224
+ return `${Math.round(rate * 100) / 100}\xD7`;
1225
+ }
1226
+ function PlayIcon() {
1227
+ return /* @__PURE__ */ jsx("svg", { width: "15", height: "16", viewBox: "0 0 15 16", fill: "currentColor", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M2 1.4v13.2a1 1 0 0 0 1.52.85l11-6.6a1 1 0 0 0 0-1.7l-11-6.6A1 1 0 0 0 2 1.4Z" }) });
1228
+ }
1229
+ function PauseIcon() {
1230
+ return /* @__PURE__ */ jsxs("svg", { width: "14", height: "16", viewBox: "0 0 14 16", fill: "currentColor", "aria-hidden": "true", children: [
1231
+ /* @__PURE__ */ jsx("rect", { x: "2", y: "1.5", width: "3.5", height: "13", rx: "1.2" }),
1232
+ /* @__PURE__ */ jsx("rect", { x: "8.5", y: "1.5", width: "3.5", height: "13", rx: "1.2" })
1233
+ ] });
1234
+ }
1235
+ function LoopIcon() {
1236
+ return /* @__PURE__ */ jsxs(
1237
+ "svg",
1238
+ {
1239
+ width: "17",
1240
+ height: "17",
1241
+ viewBox: "0 0 24 24",
1242
+ fill: "none",
1243
+ stroke: "currentColor",
1244
+ strokeWidth: "2.2",
1245
+ strokeLinecap: "round",
1246
+ strokeLinejoin: "round",
1247
+ "aria-hidden": "true",
1248
+ children: [
1249
+ /* @__PURE__ */ jsx("path", { d: "M17 1.5 21 5.5 17 9.5" }),
1250
+ /* @__PURE__ */ jsx("path", { d: "M3 11V9a4 4 0 0 1 4-4h14" }),
1251
+ /* @__PURE__ */ jsx("path", { d: "M7 22.5 3 18.5 7 14.5" }),
1252
+ /* @__PURE__ */ jsx("path", { d: "M21 13v2a4 4 0 0 1-4 4H3" })
1253
+ ]
1254
+ }
1255
+ );
1256
+ }
1257
+ function VolumeIcon() {
1258
+ return /* @__PURE__ */ jsxs(
1259
+ "svg",
1260
+ {
1261
+ width: "18",
1262
+ height: "18",
1263
+ viewBox: "0 0 24 24",
1264
+ fill: "none",
1265
+ stroke: "currentColor",
1266
+ strokeWidth: "2",
1267
+ strokeLinecap: "round",
1268
+ strokeLinejoin: "round",
1269
+ "aria-hidden": "true",
1270
+ children: [
1271
+ /* @__PURE__ */ jsx("path", { d: "M4 9v6h4l5 4V5L8 9H4Z" }),
1272
+ /* @__PURE__ */ jsx("path", { d: "M16.5 8.5a4 4 0 0 1 0 7" }),
1273
+ /* @__PURE__ */ jsx("path", { d: "M19 6a7 7 0 0 1 0 12" })
1274
+ ]
1275
+ }
1276
+ );
1277
+ }
1278
+ function VolumeMuteIcon() {
1279
+ return /* @__PURE__ */ jsxs(
1280
+ "svg",
1281
+ {
1282
+ width: "18",
1283
+ height: "18",
1284
+ viewBox: "0 0 24 24",
1285
+ fill: "none",
1286
+ stroke: "currentColor",
1287
+ strokeWidth: "2",
1288
+ strokeLinecap: "round",
1289
+ strokeLinejoin: "round",
1290
+ "aria-hidden": "true",
1291
+ children: [
1292
+ /* @__PURE__ */ jsx("path", { d: "M4 9v6h4l5 4V5L8 9H4Z" }),
1293
+ /* @__PURE__ */ jsx("path", { d: "M22 9.5 16.5 15" }),
1294
+ /* @__PURE__ */ jsx("path", { d: "M16.5 9.5 22 15" })
1295
+ ]
1296
+ }
1297
+ );
1298
+ }
1299
+ function FullscreenIcon() {
1300
+ return /* @__PURE__ */ jsxs(
1301
+ "svg",
1302
+ {
1303
+ width: "16",
1304
+ height: "16",
1305
+ viewBox: "0 0 24 24",
1306
+ fill: "none",
1307
+ stroke: "currentColor",
1308
+ strokeWidth: "2.2",
1309
+ strokeLinecap: "round",
1310
+ strokeLinejoin: "round",
1311
+ "aria-hidden": "true",
1312
+ children: [
1313
+ /* @__PURE__ */ jsx("path", { d: "M3 8V4a1 1 0 0 1 1-1h4" }),
1314
+ /* @__PURE__ */ jsx("path", { d: "M21 8V4a1 1 0 0 0-1-1h-4" }),
1315
+ /* @__PURE__ */ jsx("path", { d: "M3 16v4a1 1 0 0 0 1 1h4" }),
1316
+ /* @__PURE__ */ jsx("path", { d: "M21 16v4a1 1 0 0 1-1 1h-4" })
1317
+ ]
1318
+ }
1319
+ );
1320
+ }
1321
+ function ExitFullscreenIcon() {
1322
+ return /* @__PURE__ */ jsxs(
1323
+ "svg",
1324
+ {
1325
+ width: "16",
1326
+ height: "16",
1327
+ viewBox: "0 0 24 24",
1328
+ fill: "none",
1329
+ stroke: "currentColor",
1330
+ strokeWidth: "2.2",
1331
+ strokeLinecap: "round",
1332
+ strokeLinejoin: "round",
1333
+ "aria-hidden": "true",
1334
+ children: [
1335
+ /* @__PURE__ */ jsx("path", { d: "M8 3v4a1 1 0 0 1-1 1H3" }),
1336
+ /* @__PURE__ */ jsx("path", { d: "M16 3v4a1 1 0 0 0 1 1h4" }),
1337
+ /* @__PURE__ */ jsx("path", { d: "M8 21v-4a1 1 0 0 0-1-1H3" }),
1338
+ /* @__PURE__ */ jsx("path", { d: "M16 21v-4a1 1 0 0 1 1-1h4" })
1339
+ ]
1340
+ }
1341
+ );
1342
+ }
1343
+ function fmtTime(s) {
1344
+ const minutes = Math.floor(s / 60);
1345
+ const secs = Math.floor(s % 60);
1346
+ const centis = Math.floor(s * 100 % 100);
1347
+ return `${minutes}:${secs.toString().padStart(2, "0")}.${centis.toString().padStart(2, "0")}`;
1348
+ }
1349
+ var styles = {
1350
+ root: {
1351
+ display: "block",
1352
+ width: "100%",
1353
+ color: "var(--onda-text, #f2f2f4)",
1354
+ fontFamily: 'var(--onda-font-body, "Space Grotesk", ui-sans-serif, system-ui, sans-serif)'
1355
+ },
1356
+ stage: {
1357
+ position: "relative",
1358
+ width: "100%",
1359
+ borderRadius: 14,
1360
+ overflow: "hidden",
1361
+ background: "var(--onda-bg-deep, #08080a)",
1362
+ border: "1px solid var(--onda-border, #26262c)",
1363
+ // Isolate the preview as its own compositing context + contain its paint, so
1364
+ // the heavy, per-frame-updating canvas layer doesn't force the browser to
1365
+ // recomposite the whole PAGE behind it. That recomposite is what flickers the
1366
+ // background on some GPUs (e.g. Arc/Metal) while a video plays and the cursor
1367
+ // moves — a compositor artifact, NOT a content change (the rendered frames are
1368
+ // byte-identical) and NOT present in exported video.
1369
+ isolation: "isolate",
1370
+ contain: "paint",
1371
+ // A query container (inline axis only, so aspect-ratio still drives height) so
1372
+ // the control bar can adapt to narrow widths (e.g. 9:16) — see the @container
1373
+ // rule in the stylesheet.
1374
+ containerType: "inline-size"
1375
+ },
1376
+ // `translateZ(0)` promotes the canvas to its OWN GPU layer, so its per-frame
1377
+ // pixel updates don't invalidate / re-raster sibling layers (see `stage`).
1378
+ canvas: {
1379
+ width: "100%",
1380
+ height: "100%",
1381
+ display: "block",
1382
+ cursor: "pointer",
1383
+ transform: "translateZ(0)"
1384
+ },
1385
+ videoOverlay: { position: "absolute", inset: 0, overflow: "hidden", pointerEvents: "none" },
1386
+ readout: {
1387
+ display: "flex",
1388
+ alignItems: "baseline",
1389
+ gap: 4,
1390
+ fontVariantNumeric: "tabular-nums",
1391
+ fontSize: 13,
1392
+ color: "rgba(255,255,255,.75)",
1393
+ whiteSpace: "nowrap"
1394
+ }
1395
+ };
1396
+ function useInjectStyles() {
1397
+ useEffect(() => {
1398
+ const id = "onda-player-styles";
1399
+ if (typeof document === "undefined" || document.getElementById(id)) return;
1400
+ const style = document.createElement("style");
1401
+ style.id = id;
1402
+ style.textContent = PLAYER_CSS;
1403
+ document.head.appendChild(style);
1404
+ }, []);
1405
+ }
1406
+ var PLAYER_CSS = `
1407
+ .onda-player {
1408
+ /* Brand tokens (onda.video palette) with safe fallbacks. */
1409
+ --onda-bg: var(--bg, #0e0e12);
1410
+ --onda-bg-deep: var(--bg-deep, #08080a);
1411
+ --onda-surface: var(--surface, #121217);
1412
+ --onda-surface-2: var(--surface-2, #18181d);
1413
+ --onda-border: var(--border, #26262c);
1414
+ --onda-text: var(--text, #f2f2f4);
1415
+ --onda-text-muted: var(--text-muted, #8e8e98);
1416
+ /* Player-scoped (NOT the page's generic --accent, which a host app like ONDA
1417
+ Studio sets to its own color) so the controls stay the ONDA rose by default;
1418
+ a host can still theme them via --onda-player-accent. */
1419
+ --onda-accent: var(--onda-player-accent, #d96b82);
1420
+ --onda-accent-600: var(--onda-player-accent-600, #c8576f);
1421
+ --onda-on-accent: var(--on-accent, #0e0e12);
1422
+ --onda-ok: var(--ok, #6bbf8a);
1423
+ --onda-warn: var(--warn, #d9b06b);
1424
+ }
1425
+ /* Controls overlay the stage and auto-hide unless hovering / paused / focused. */
1426
+ .onda-player__overlay {
1427
+ position: absolute; left: 0; right: 0; bottom: 0;
1428
+ display: flex; flex-direction: column; gap: 10px;
1429
+ padding: 32px 14px 14px;
1430
+ background: linear-gradient(to top, rgba(0,0,0,.72), rgba(0,0,0,.32) 55%, transparent);
1431
+ opacity: 0; transform: translateY(8px);
1432
+ transition: opacity 200ms ease-out, transform 200ms ease-out;
1433
+ pointer-events: none;
1434
+ }
1435
+ .onda-player__stage:hover .onda-player__overlay,
1436
+ .onda-player__stage:focus-within .onda-player__overlay,
1437
+ .onda-player.is-paused .onda-player__overlay,
1438
+ .onda-player.is-controls .onda-player__overlay {
1439
+ opacity: 1; transform: none; pointer-events: auto;
1440
+ }
1441
+ /* controls:none \u2014 a chrome-free thumbnail: no overlay, no fullscreen button
1442
+ (overrides the hover/focus/paused reveal above). */
1443
+ .onda-player.is-controls-none .onda-player__overlay,
1444
+ .onda-player.is-controls-none .onda-player__fs {
1445
+ display: none;
1446
+ }
1447
+ .onda-player__row { display: flex; align-items: center; gap: 14px; }
1448
+ .onda-player__spacer { flex: 1 1 auto; }
1449
+ /* Responsive control bar \u2014 the overlay is an inline-size query container, so
1450
+ these adapt to the PLAYER width regardless of canvas aspect (9:16, 4:5, 1:1,
1451
+ 16:9 all just resolve to a width).
1452
+
1453
+ KEY: the widest thing in the bar is the time readout ("0:07.80 / 0:11.06"),
1454
+ NOT the buttons \u2014 so the right move on a narrow player is to compact the TIME,
1455
+ which then lets every button (play \xB7 mute \xB7 speed \xB7 loop) stay in one row. We
1456
+ only start dropping controls at sizes far below any real editor preview.
1457
+
1458
+ Tier 1 (\u2264440px \u2014 most 9:16 / smaller 4:5\xB71:1): readout \u2192 CURRENT time only
1459
+ (the scrubber already shows the end point), tighten gaps. All buttons stay.
1460
+ Tier 2 (\u2264340px \u2014 tight 9:16): collapse volume to a mute TOGGLE (its 56px
1461
+ hover-out slider is the only thing left that would overflow). Muting works.
1462
+ Tier 3 (\u2264280px \u2014 last resort, a very small player): drop the preview-only
1463
+ speed control. */
1464
+ @container (max-width: 440px) {
1465
+ .onda-player__overlay { padding-left: 12px; padding-right: 12px; }
1466
+ .onda-player__row { gap: 9px; }
1467
+ .onda-player__sep, .onda-player__total { display: none; }
1468
+ }
1469
+ @container (max-width: 340px) {
1470
+ .onda-player__overlay { padding-left: 9px; padding-right: 9px; }
1471
+ .onda-player__row { gap: 7px; }
1472
+ .onda-player__volume-slider { display: none; }
1473
+ }
1474
+ @container (max-width: 280px) {
1475
+ .onda-player__speed { display: none; }
1476
+ }
1477
+ /* Engine/preview badge (top-left), fades with the controls. */
1478
+ .onda-player__badge {
1479
+ position: absolute; top: 12px; left: 12px;
1480
+ display: inline-flex; align-items: center; gap: 7px;
1481
+ padding: 5px 10px; border-radius: 999px;
1482
+ background: rgba(8,8,10,.82);
1483
+ border: 1px solid rgba(255,255,255,.1);
1484
+ color: rgba(255,255,255,.85);
1485
+ font-size: 11.5px; font-weight: 500; letter-spacing: 0.02em;
1486
+ opacity: 0; transition: opacity 200ms ease-out; pointer-events: none;
1487
+ }
1488
+ .onda-player__stage:hover .onda-player__badge,
1489
+ .onda-player__stage:focus-within .onda-player__badge,
1490
+ .onda-player.is-paused .onda-player__badge,
1491
+ .onda-player.is-controls .onda-player__badge { opacity: 1; }
1492
+ /* Fullscreen toggle (top-right), fades in with the controls like the badge. */
1493
+ .onda-player__fs {
1494
+ position: absolute; top: 12px; right: 12px;
1495
+ width: 34px; height: 34px;
1496
+ display: grid; place-items: center;
1497
+ border-radius: 9px;
1498
+ background: rgba(8,8,10,.82);
1499
+ border: 1px solid rgba(255,255,255,.1);
1500
+ color: rgba(255,255,255,.85);
1501
+ cursor: pointer;
1502
+ opacity: 0; transform: translateY(-4px);
1503
+ transition: opacity 200ms ease-out, transform 200ms ease-out, background 160ms ease-out, color 160ms ease-out;
1504
+ pointer-events: none;
1505
+ }
1506
+ .onda-player__stage:hover .onda-player__fs,
1507
+ .onda-player__stage:focus-within .onda-player__fs,
1508
+ .onda-player.is-paused .onda-player__fs,
1509
+ .onda-player.is-controls .onda-player__fs { opacity: 1; transform: none; pointer-events: auto; }
1510
+ .onda-player__fs:hover { background: rgba(8,8,10,.85); color: #fff; }
1511
+ .onda-player__fs:focus-visible { outline: 2px solid var(--onda-accent); outline-offset: 2px; }
1512
+ .onda-player__fs svg { display: block; }
1513
+ /* In fullscreen: fill the screen and letterbox the canvas (preserve aspect). */
1514
+ .onda-player__stage:fullscreen,
1515
+ .onda-player__stage:-webkit-full-screen {
1516
+ width: 100vw; height: 100vh; border-radius: 0; aspect-ratio: auto; background: #000;
1517
+ }
1518
+ .onda-player__stage:fullscreen .onda-player__canvas,
1519
+ .onda-player__stage:-webkit-full-screen .onda-player__canvas {
1520
+ width: 100vw; height: 100vh; object-fit: contain;
1521
+ }
1522
+ /* Circular primary play/pause \u2014 the player's focal control. */
1523
+ .onda-player__play {
1524
+ flex: 0 0 auto;
1525
+ width: 42px; height: 42px;
1526
+ display: grid; place-items: center;
1527
+ border: 0; border-radius: 999px;
1528
+ background: var(--onda-accent);
1529
+ color: var(--onda-on-accent);
1530
+ cursor: pointer;
1531
+ transition: background 160ms ease-out, transform 120ms ease-out;
1532
+ }
1533
+ .onda-player__play:hover { background: var(--onda-accent-600); transform: scale(1.06); }
1534
+ .onda-player__play:active { transform: scale(0.96); }
1535
+ .onda-player__play svg { display: block; }
1536
+ /* Ghost icon toggle (loop), over the scrim. */
1537
+ .onda-player__icon {
1538
+ flex: 0 0 auto;
1539
+ width: 36px; height: 36px;
1540
+ display: grid; place-items: center;
1541
+ border: 1px solid rgba(255,255,255,.18); border-radius: 10px;
1542
+ background: transparent; color: rgba(255,255,255,.8);
1543
+ cursor: pointer;
1544
+ transition: background 160ms ease-out, color 160ms ease-out, border-color 160ms ease-out;
1545
+ }
1546
+ .onda-player__icon:hover { background: rgba(255,255,255,.1); color: #fff; }
1547
+ .onda-player__icon.is-active {
1548
+ color: var(--onda-accent);
1549
+ border-color: color-mix(in srgb, var(--onda-accent) 60%, transparent);
1550
+ background: color-mix(in srgb, var(--onda-accent) 16%, transparent);
1551
+ }
1552
+ .onda-player__icon svg { display: block; }
1553
+ /* Speed: a rate button (shows e.g. "1\xD7") that opens a preset menu above it. */
1554
+ .onda-player__speed { position: relative; display: inline-flex; flex: 0 0 auto; }
1555
+ .onda-player__speed-btn {
1556
+ width: auto; min-width: 46px; padding: 0 10px;
1557
+ font: 600 13px/1 ui-monospace, "SF Mono", Menlo, monospace;
1558
+ font-variant-numeric: tabular-nums;
1559
+ }
1560
+ .onda-player__speed-menu {
1561
+ position: absolute; bottom: calc(100% + 8px); right: 0; z-index: 6;
1562
+ display: flex; flex-direction: column; gap: 2px;
1563
+ padding: 4px; border-radius: 10px;
1564
+ background: #16161c; border: 1px solid rgba(255,255,255,.14);
1565
+ box-shadow: 0 10px 30px rgba(0,0,0,.5);
1566
+ }
1567
+ .onda-player__speed-item {
1568
+ appearance: none; border: 0; background: transparent;
1569
+ color: rgba(255,255,255,.82); cursor: pointer;
1570
+ font: 600 13px/1 ui-monospace, "SF Mono", Menlo, monospace;
1571
+ font-variant-numeric: tabular-nums; text-align: right; white-space: nowrap;
1572
+ padding: 7px 12px; border-radius: 7px; min-width: 60px;
1573
+ }
1574
+ .onda-player__speed-item:hover { background: rgba(255,255,255,.1); color: #fff; }
1575
+ .onda-player__speed-item.is-active { color: var(--onda-accent); }
1576
+ .onda-player__speed-btn:focus-visible,
1577
+ .onda-player__speed-item:focus-visible {
1578
+ outline: 2px solid var(--onda-accent); outline-offset: 2px;
1579
+ }
1580
+ /* Volume: a mute toggle + a slider that expands on hover/focus (compact). */
1581
+ .onda-player__volume { display: inline-flex; align-items: center; gap: 4px; }
1582
+ .onda-player__volume-slider {
1583
+ width: 0; opacity: 0; min-width: 0;
1584
+ height: 5px; cursor: pointer; margin: 0;
1585
+ -webkit-appearance: none; appearance: none; background: transparent;
1586
+ transition: width 160ms ease-out, opacity 160ms ease-out;
1587
+ }
1588
+ .onda-player__volume:hover .onda-player__volume-slider,
1589
+ .onda-player__volume:focus-within .onda-player__volume-slider { width: 56px; opacity: 1; }
1590
+ .onda-player__volume-slider::-webkit-slider-runnable-track {
1591
+ height: 4px; border-radius: 999px; background: rgba(255,255,255,.32);
1592
+ }
1593
+ .onda-player__volume-slider::-webkit-slider-thumb {
1594
+ -webkit-appearance: none; appearance: none; width: 11px; height: 11px; margin-top: -3.5px;
1595
+ border-radius: 999px; background: #fff;
1596
+ }
1597
+ .onda-player__volume-slider::-moz-range-track {
1598
+ height: 4px; border-radius: 999px; background: rgba(255,255,255,.32);
1599
+ }
1600
+ .onda-player__volume-slider::-moz-range-thumb {
1601
+ width: 11px; height: 11px; border: 0; border-radius: 999px; background: #fff;
1602
+ }
1603
+ /* Visible focus rings on every interactive control + the player region. */
1604
+ .onda-player:focus-visible,
1605
+ .onda-player__play:focus-visible,
1606
+ .onda-player__icon:focus-visible,
1607
+ .onda-player__scrubber:focus-visible {
1608
+ outline: 2px solid var(--onda-accent);
1609
+ outline-offset: 2px;
1610
+ }
1611
+ /* Progress-aware scrubber over the scrim: rose fill, translucent track. */
1612
+ .onda-player__scrubber {
1613
+ flex: 1 1 auto; min-width: 80px;
1614
+ height: 8px; cursor: pointer; margin: 0;
1615
+ -webkit-appearance: none; appearance: none;
1616
+ background: transparent;
1617
+ }
1618
+ .onda-player__scrubber::-webkit-slider-runnable-track {
1619
+ height: 6px; border-radius: 999px;
1620
+ background: linear-gradient(
1621
+ to right,
1622
+ var(--onda-accent) 0 var(--progress, 0%),
1623
+ rgba(255,255,255,.32) var(--progress, 0%) 100%
1624
+ );
1625
+ }
1626
+ .onda-player__scrubber::-moz-range-track {
1627
+ height: 6px; border-radius: 999px; background: rgba(255,255,255,.32);
1628
+ }
1629
+ .onda-player__scrubber::-moz-range-progress {
1630
+ height: 6px; border-radius: 999px; background: var(--onda-accent);
1631
+ }
1632
+ .onda-player__scrubber::-webkit-slider-thumb {
1633
+ -webkit-appearance: none; appearance: none;
1634
+ width: 14px; height: 14px; margin-top: -4px;
1635
+ border-radius: 999px; background: #fff;
1636
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--onda-accent) 45%, transparent), 0 1px 3px rgba(0,0,0,.6);
1637
+ transition: box-shadow 140ms ease-out;
1638
+ }
1639
+ .onda-player__scrubber:hover::-webkit-slider-thumb {
1640
+ box-shadow: 0 0 0 6px color-mix(in srgb, var(--onda-accent) 50%, transparent), 0 1px 3px rgba(0,0,0,.6);
1641
+ }
1642
+ .onda-player__scrubber::-moz-range-thumb {
1643
+ width: 14px; height: 14px; border: 0;
1644
+ border-radius: 999px; background: #fff;
1645
+ box-shadow: 0 0 0 4px color-mix(in srgb, var(--onda-accent) 45%, transparent), 0 1px 3px rgba(0,0,0,.6);
1646
+ }
1647
+ .onda-player__readout { flex: 0 0 auto; }
1648
+ .onda-player__time { font-weight: 600; color: #fff; }
1649
+ .onda-player__sep, .onda-player__total { color: rgba(255,255,255,.6); }
1650
+ .onda-player__dot {
1651
+ flex: 0 0 auto; width: 7px; height: 7px; border-radius: 999px; display: inline-block;
1652
+ }
1653
+ .onda-player__dot--ok {
1654
+ background: var(--onda-ok);
1655
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--onda-ok) 25%, transparent);
1656
+ }
1657
+ .onda-player__dot--warn {
1658
+ background: var(--onda-warn);
1659
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--onda-warn) 25%, transparent);
1660
+ }
1661
+ @media (prefers-reduced-motion: reduce) {
1662
+ .onda-player__overlay,
1663
+ .onda-player__badge,
1664
+ .onda-player__play,
1665
+ .onda-player__icon,
1666
+ .onda-player__scrubber::-webkit-slider-thumb { transition: none; }
1667
+ .onda-player__play:hover,
1668
+ .onda-player__play:active { transform: none; }
1669
+ }
1670
+ `;
1671
+ //! Canvas2D **preview** renderer (the stopgap path).
1672
+ //!
1673
+ //! Draws an ONDA scene graph to a `CanvasRenderingContext2D` for a fast,
1674
+ //! dependency-free in-browser preview. It interprets the *same* scene graph the
1675
+ //! engine renders, and now covers shapes (rect/ellipse/path), gradients, clips,
1676
+ //! transforms, and opacity — but it is **not** the engine and is **not
1677
+ //! pixel-accurate**:
1678
+ //! - Text uses the browser's font rasterizer (and a heuristic baseline), not
1679
+ //! the engine's cosmic-text + bundled Open Sans, so glyph shapes/metrics
1680
+ //! differ.
1681
+ //! - Anti-aliasing, gradient color-space, and path/stroke geometry follow the
1682
+ //! browser's Canvas2D, not Vello.
1683
+ //! The pixel-exact output is `onda render` / `onda export`, and in the browser
1684
+ //! the {@link @onda-engine/wasm} `OndaEngine` (the real Rust renderer compiled to wasm).
1685
+ //! Prefer that drawer in `<Player>` when it is available; this Canvas2D renderer
1686
+ //! is the graceful fallback. A WebGPU *present* path (see `WEBGPU.md`) is the
1687
+ //! planned way to make in-browser preview == export at real-time speed.
1688
+ //! Bridge the **real** ONDA renderer (the `@onda-engine/wasm` `OndaEngine`) into a
1689
+ //! `<Player>` {@link FrameDrawer}.
1690
+ //! The wasm engine is the same Rust renderer `onda export` uses (cosmic-text +
1691
+ //! the bundled Open Sans, Vello-class shape/path/gradient/clip semantics), so a
1692
+ //! preview drawn through it is **pixel-identical to the native/CLI render** — no
1693
+ //! DOM, no Chromium. This is the charter-true preview path; the Canvas2D
1694
+ //! {@link drawScene} renderer is the dependency-free fallback.
1695
+ //! To keep `@onda-engine/player` free of a hard dependency on `@onda-engine/wasm`, the engine
1696
+ //! is accepted *structurally*: any object exposing `render(json) -> RenderedFrame`
1697
+ //! works. The app owns wasm init and constructs the engine.
1698
+ //! Web Audio transport for the Player's preview.
1699
+ //! The preview's visual frame-clock pegs the main thread (the per-frame wasm/GPU
1700
+ //! render), and an HTML `<audio>` element leans on the main thread for buffering
1701
+ //! and servicing — so under that load it underruns and glitches every second or
1702
+ //! two. This plays each clip through the **Web Audio API** instead: the source is
1703
+ //! decoded ONCE into an in-memory `AudioBuffer` and scheduled on the audio render
1704
+ //! thread, which is immune to main-thread jank. No streaming, no per-frame seeks,
1705
+ //! no loop-seam click.
1706
+ //! Model: the timeline is the master clock. On play we anchor the audio to the
1707
+ //! playhead (a `compTime` ↔ `AudioContext.currentTime` mapping) and schedule clip
1708
+ //! instances cycle-by-cycle with a look-ahead, so a looping timeline is gapless
1709
+ //! (the next cycle is queued before the current one ends — no JS seek at the wrap).
1710
+ //! We re-anchor only on an explicit play/seek/rate change, never to chase drift.
1711
+ //! Export is unaffected — it muxes the same nodes through the native pipeline.
1712
+ //! Audio playback support for the Player.
1713
+ //! Non-visual `<Audio>` nodes ride in the scene graph; the player finds them here
1714
+ //! and plays them via plain `<audio>` elements, synced to play/pause + scrub at
1715
+ //! the player's volume. (Export muxes the same nodes via the native pipeline.)
1716
+ //! In-browser image resolution.
1717
+ //! The wasm engine decodes `data:` URIs but can't fetch URL/path image sources
1718
+ //! (there's no filesystem or fetch inside the render call). So the Player resolves
1719
+ //! image `src` URLs to `data:` URIs in JS — the browser fetches + the engine's
1720
+ //! existing `data:` decode path handles them. Resolved URIs are cached and shared
1721
+ //! across players, so each image is fetched once.
1722
+ //! In-browser video-frame decoding for the Player.
1723
+ //! The wasm engine can't decode video, so the Player decodes the frame the scene
1724
+ //! asks for — an off-screen `<video>` seeked to the node's source `time`, drawn
1725
+ //! to a canvas — and hands it to the engine as that frame's pixels. Today the
1726
+ //! hand-off reuses the engine's image path (a `data:` URI); a raw-buffer path
1727
+ //! (no per-frame base64) layers on top of this same resolver later.
1728
+ //! Decoded frames are cached by `(src, time-bucket)` and shared across players,
1729
+ //! so a looping preview decodes each distinct source frame at most once — the
1730
+ //! first pass seeks, every pass after is a cache hit. Seeking is serialized per
1731
+ //! source (one `<video>` element can only be at one `currentTime`).
1732
+ //! `<Player>` — an interactive, accessible preview of an ONDA composition.
1733
+ //! Renders each visible frame from `@onda-engine/react`'s `renderFrame` (so what you
1734
+ //! scrub is the real per-frame scene graph) and paints it to a canvas. By
1735
+ //! default it paints with the **real ONDA engine** when one is supplied (the
1736
+ //! `@onda-engine/wasm` `OndaEngine`, pixel-identical to `onda export`), and otherwise
1737
+ //! falls back to the dependency-free Canvas2D preview.
1738
+ //! Controls are fully keyboard-accessible (Space = play/pause, ←/→ = step,
1739
+ //! Shift+←/→ = jump, Home/End = ends), ARIA-labelled, and styled with the ONDA
1740
+ //! brand tokens (see `assets/brand/BRAND.md`). All non-essential motion respects
1741
+ //! `prefers-reduced-motion`.
1742
+ //! `@onda-engine/player` — interactive, accessible preview of ONDA compositions.
1743
+ //! `<Player>` previews through the **real** ONDA renderer when given an `engine`
1744
+ //! (`@onda-engine/wasm`'s `OndaEngine`, pixel-identical to `onda export`), and otherwise
1745
+ //! falls back to the dependency-free Canvas2D {@link drawScene} preview.
1746
+
1747
+ export { Player, cssColor, drawScene, engineDrawer };
1748
+ //# sourceMappingURL=player.js.map
1749
+ //# sourceMappingURL=player.js.map