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/LICENSE +106 -0
- package/LICENSE-APACHE +202 -0
- package/README.md +84 -0
- package/dist/chunk-NCNYMPIQ.js +12763 -0
- package/dist/chunk-NCNYMPIQ.js.map +1 -0
- package/dist/cinema.d.ts +580 -0
- package/dist/cinema.js +1687 -0
- package/dist/cinema.js.map +1 -0
- package/dist/components-manifest.d.ts +2 -0
- package/dist/components-manifest.js +3 -0
- package/dist/components-manifest.js.map +1 -0
- package/dist/components.d.ts +3480 -0
- package/dist/components.js +11486 -0
- package/dist/components.js.map +1 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest-7N3yu9tB.d.ts +131 -0
- package/dist/player.d.ts +177 -0
- package/dist/player.js +1749 -0
- package/dist/player.js.map +1 -0
- package/dist/react.d.ts +2141 -0
- package/dist/react.js +2052 -0
- package/dist/react.js.map +1 -0
- package/dist/render.d.ts +42 -0
- package/dist/render.js +113 -0
- package/dist/render.js.map +1 -0
- package/dist/wasm/pkg/onda_wasm.js +598 -0
- package/dist/wasm/pkg/onda_wasm_bg.wasm +0 -0
- package/dist/wasm-audio/pkg/onda_wasm_audio.js +417 -0
- package/dist/wasm-audio/pkg/onda_wasm_audio_bg.wasm +0 -0
- package/dist/wasm-vello/index.js +32 -0
- package/dist/wasm-vello/pkg/onda_wasm_vello.js +1325 -0
- package/dist/wasm-vello/pkg/onda_wasm_vello_bg.wasm +0 -0
- package/package.json +112 -0
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
|