odori 0.0.1 → 0.0.2

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.
@@ -0,0 +1,953 @@
1
+ "use client";
2
+ import {
3
+ framesFromDuration,
4
+ hashValue
5
+ } from "./chunk-PXG45HYV.js";
6
+
7
+ // src/context.ts
8
+ import { createContext, useContext } from "react";
9
+ var FrameContext = createContext(null);
10
+ var SceneContext = createContext(null);
11
+ var BrandContext = createContext(null);
12
+ var LayoutContext = createContext(null);
13
+ var AssetContext = createContext(null);
14
+ var AudioSinkContext = createContext(null);
15
+ var ForceMountContext = createContext(false);
16
+ var ReadinessContext = createContext(null);
17
+ var useReadiness = () => {
18
+ const readiness = useContext(ReadinessContext);
19
+ return readiness ?? { hold: () => () => void 0 };
20
+ };
21
+ var required = (value, hook) => {
22
+ if (value === null) throw new Error(`${hook} must be called inside a <Video> tree.`);
23
+ return value;
24
+ };
25
+ var useFrame = () => required(useContext(FrameContext), "useFrame()").frame;
26
+ var useVideo = () => {
27
+ const state = required(useContext(FrameContext), "useVideo()");
28
+ return {
29
+ fps: state.fps,
30
+ width: state.width,
31
+ height: state.height,
32
+ durationInFrames: state.durationInFrames
33
+ };
34
+ };
35
+ var useDesignScale = (reference = 1080) => {
36
+ const state = required(useContext(FrameContext), "useDesignScale()");
37
+ return Math.min(state.width, state.height) / reference;
38
+ };
39
+ var useBrand = () => required(useContext(BrandContext), "useBrand()");
40
+ var useLayout = () => required(useContext(LayoutContext), "useLayout()");
41
+ var useScene = () => required(useContext(SceneContext), "useScene()");
42
+ var useAssets = () => {
43
+ const registry = useContext(AssetContext);
44
+ return registry ?? { resolve: (reference) => reference, list: () => [], ready: true };
45
+ };
46
+ var createAssetRegistry = (entries) => {
47
+ const table = new Map(entries.map((entry) => [entry.reference, entry.url]));
48
+ return {
49
+ ready: true,
50
+ list: () => entries,
51
+ resolve(reference) {
52
+ const url = table.get(reference);
53
+ if (!url) throw new Error(`Unknown asset reference: ${reference}. Declare it in prepare.ts or odori.config.ts.`);
54
+ return url;
55
+ }
56
+ };
57
+ };
58
+
59
+ // src/audio.ts
60
+ var cueId = (src, fromFrame) => `${src}@${fromFrame}`;
61
+ var isAssetReference = (src) => !/^(https?:\/\/|\/|\.\/|\.\.\/|data:|blob:)/.test(src);
62
+ var sortCues = (cues) => [...cues].sort((left, right) => left.fromFrame - right.fromFrame || left.id.localeCompare(right.id));
63
+ var trackDuration = (cues) => cues.reduce((total, cue) => Math.max(total, cue.fromFrame + cue.durationInFrames), 0);
64
+ var gainAtFrame = (cue, frame) => {
65
+ const local = frame - cue.fromFrame;
66
+ if (local < 0 || local >= cue.durationInFrames) return 0;
67
+ const fadeIn = cue.fadeInFrames > 0 ? Math.min(1, local / cue.fadeInFrames) : 1;
68
+ const remaining = cue.durationInFrames - local;
69
+ const fadeOut = cue.fadeOutFrames > 0 ? Math.min(1, remaining / cue.fadeOutFrames) : 1;
70
+ const authored = cue.gainPoints ? envelopeAtFrame(cue.gainPoints, frame) : 1;
71
+ return cue.gain * authored * fadeIn * fadeOut;
72
+ };
73
+ var sampleGainCurve = (curve, fromFrame, durationInFrames, tolerance = 0.01) => {
74
+ const raw = [];
75
+ for (let frame = fromFrame; frame < fromFrame + durationInFrames; frame += 1) {
76
+ const value = curve(frame - fromFrame);
77
+ raw.push({ frame, value: Number.isFinite(value) ? value : 1 });
78
+ }
79
+ if (raw.length <= 2) return raw;
80
+ const kept = [raw[0]];
81
+ for (let index = 1; index < raw.length - 1; index += 1) {
82
+ const previous = kept[kept.length - 1];
83
+ const next = raw[index + 1];
84
+ const span = next.frame - previous.frame;
85
+ const predicted = span === 0 ? previous.value : previous.value + (next.value - previous.value) * (raw[index].frame - previous.frame) / span;
86
+ if (Math.abs(raw[index].value - predicted) > tolerance) kept.push(raw[index]);
87
+ }
88
+ kept.push(raw[raw.length - 1]);
89
+ return kept;
90
+ };
91
+ var DUCK_GAIN = 0.35;
92
+ var DUCK_RAMP_FRAMES = 6;
93
+ var clampFrame = (frame, durationInFrames) => Math.max(0, Math.min(durationInFrames, frame));
94
+ var duckEnvelope = (cue, cues) => {
95
+ const end = cue.fromFrame + cue.durationInFrames;
96
+ if (!cue.duckUnder) return [{ frame: cue.fromFrame, value: 1 }, { frame: end, value: 1 }];
97
+ const windows = cues.filter((other) => other.id !== cue.id && !other.duckUnder).map((other) => ({
98
+ start: Math.max(cue.fromFrame, other.fromFrame),
99
+ end: Math.min(end, other.fromFrame + other.durationInFrames)
100
+ })).filter((window2) => window2.end > window2.start).sort((left, right) => left.start - right.start);
101
+ const merged = [];
102
+ for (const window2 of windows) {
103
+ const last = merged[merged.length - 1];
104
+ if (last && window2.start <= last.end) last.end = Math.max(last.end, window2.end);
105
+ else merged.push({ ...window2 });
106
+ }
107
+ const points = [{ frame: cue.fromFrame, value: 1 }];
108
+ for (const window2 of merged) {
109
+ points.push(
110
+ { frame: clampFrame(window2.start - DUCK_RAMP_FRAMES, end), value: 1 },
111
+ { frame: clampFrame(window2.start, end), value: DUCK_GAIN },
112
+ { frame: clampFrame(window2.end, end), value: DUCK_GAIN },
113
+ { frame: clampFrame(window2.end + DUCK_RAMP_FRAMES, end), value: 1 }
114
+ );
115
+ }
116
+ points.push({ frame: end, value: 1 });
117
+ return simplify(points.sort((left, right) => left.frame - right.frame));
118
+ };
119
+ var simplify = (points) => {
120
+ const stepped = [];
121
+ for (const point of points) {
122
+ const last = stepped[stepped.length - 1];
123
+ if (last && last.frame === point.frame) stepped[stepped.length - 1] = point;
124
+ else stepped.push(point);
125
+ }
126
+ return stepped.filter((point, index) => {
127
+ const previous = stepped[index - 1];
128
+ const next = stepped[index + 1];
129
+ return !previous || !next || previous.value !== point.value || next.value !== point.value;
130
+ });
131
+ };
132
+ var envelopeAtFrame = (points, frame) => {
133
+ if (points.length === 0) return 1;
134
+ if (frame <= points[0].frame) return points[0].value;
135
+ for (let index = 1; index < points.length; index += 1) {
136
+ const previous = points[index - 1];
137
+ const current = points[index];
138
+ if (frame > current.frame) continue;
139
+ const span = current.frame - previous.frame;
140
+ if (span <= 0) return current.value;
141
+ return previous.value + (current.value - previous.value) * (frame - previous.frame) / span;
142
+ }
143
+ return points[points.length - 1].value;
144
+ };
145
+ var trackGainAtFrame = (cue, cues, frame) => gainAtFrame(cue, frame) * envelopeAtFrame(duckEnvelope(cue, cues), frame);
146
+
147
+ // src/synth.ts
148
+ var SAMPLE_RATE = 48e3;
149
+ var clamp = (value, low, high) => Math.max(low, Math.min(high, value));
150
+ var seeded = (seed) => {
151
+ let state = seed >>> 0;
152
+ return () => {
153
+ state = state + 1831565813 >>> 0;
154
+ let t = Math.imul(state ^ state >>> 15, 1 | state);
155
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
156
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
157
+ };
158
+ };
159
+ var silence = (samples, channels = 1) => ({
160
+ channels: Array.from({ length: channels }, () => new Float32Array(samples)),
161
+ sampleRate: SAMPLE_RATE
162
+ });
163
+ var sine = (samples, frequency, phase = 0) => {
164
+ const data = new Float32Array(samples);
165
+ const increment = Math.PI * 2 * frequency / SAMPLE_RATE;
166
+ for (let index = 0; index < samples; index += 1) data[index] = Math.sin(phase + increment * index);
167
+ return { channels: [data], sampleRate: SAMPLE_RATE };
168
+ };
169
+ var triangle = (samples, frequency) => {
170
+ const data = new Float32Array(samples);
171
+ const period = SAMPLE_RATE / frequency;
172
+ for (let index = 0; index < samples; index += 1) {
173
+ const position = index % period / period;
174
+ data[index] = 4 * Math.abs(position - 0.5) - 1;
175
+ }
176
+ return { channels: [data], sampleRate: SAMPLE_RATE };
177
+ };
178
+ var noise = (samples, seed = 1) => {
179
+ const random = seeded(seed);
180
+ const data = new Float32Array(samples);
181
+ for (let index = 0; index < samples; index += 1) data[index] = random() * 2 - 1;
182
+ return { channels: [data], sampleRate: SAMPLE_RATE };
183
+ };
184
+ var sweep = (samples, from, to) => {
185
+ const data = new Float32Array(samples);
186
+ let phase = 0;
187
+ for (let index = 0; index < samples; index += 1) {
188
+ const position = samples <= 1 ? 1 : index / (samples - 1);
189
+ const frequency = from + (to - from) * position;
190
+ phase += Math.PI * 2 * frequency / SAMPLE_RATE;
191
+ data[index] = Math.sin(phase);
192
+ }
193
+ return { channels: [data], sampleRate: SAMPLE_RATE };
194
+ };
195
+ var shape = (signal, envelope) => {
196
+ const { attack = 0, decay = 0, sustain = 1, release = 0 } = envelope;
197
+ const length = signal.channels[0]?.length ?? 0;
198
+ const attackEnd = Math.round(attack * SAMPLE_RATE);
199
+ const decayEnd = attackEnd + Math.round(decay * SAMPLE_RATE);
200
+ const releaseStart = Math.max(decayEnd, length - Math.round(release * SAMPLE_RATE));
201
+ const level = (index) => {
202
+ if (index < attackEnd) return attackEnd === 0 ? 1 : index / attackEnd;
203
+ if (index < decayEnd) {
204
+ const position = (index - attackEnd) / Math.max(1, decayEnd - attackEnd);
205
+ return 1 + (sustain - 1) * position;
206
+ }
207
+ if (index < releaseStart) return sustain;
208
+ const remaining = length - releaseStart;
209
+ return remaining <= 0 ? 0 : sustain * (1 - (index - releaseStart) / remaining);
210
+ };
211
+ return {
212
+ sampleRate: signal.sampleRate,
213
+ channels: signal.channels.map((channel) => {
214
+ const out = new Float32Array(channel.length);
215
+ for (let index = 0; index < channel.length; index += 1) out[index] = channel[index] * level(index);
216
+ return out;
217
+ })
218
+ };
219
+ };
220
+ var gain = (signal, amount) => ({
221
+ sampleRate: signal.sampleRate,
222
+ channels: signal.channels.map((channel) => {
223
+ const out = new Float32Array(channel.length);
224
+ for (let index = 0; index < channel.length; index += 1) out[index] = channel[index] * amount;
225
+ return out;
226
+ })
227
+ });
228
+ var mix = (...signals) => {
229
+ const length = Math.max(0, ...signals.map((signal) => signal.channels[0]?.length ?? 0));
230
+ const width = Math.max(1, ...signals.map((signal) => signal.channels.length));
231
+ const channels = Array.from({ length: width }, () => new Float32Array(length));
232
+ for (const signal of signals) {
233
+ for (let channel = 0; channel < width; channel += 1) {
234
+ const source = signal.channels[channel] ?? signal.channels[0];
235
+ if (!source) continue;
236
+ const target = channels[channel];
237
+ for (let index = 0; index < source.length; index += 1) target[index] += source[index];
238
+ }
239
+ }
240
+ return { channels, sampleRate: SAMPLE_RATE };
241
+ };
242
+ var lowPass = (signal, frequency) => {
243
+ const coefficient = clamp(1 - Math.exp(-2 * Math.PI * frequency / SAMPLE_RATE), 0, 1);
244
+ return {
245
+ sampleRate: signal.sampleRate,
246
+ channels: signal.channels.map((channel) => {
247
+ const out = new Float32Array(channel.length);
248
+ let previous = 0;
249
+ for (let index = 0; index < channel.length; index += 1) {
250
+ previous += coefficient * (channel[index] - previous);
251
+ out[index] = previous;
252
+ }
253
+ return out;
254
+ })
255
+ };
256
+ };
257
+ var highPass = (signal, frequency) => {
258
+ const low = lowPass(signal, frequency);
259
+ return {
260
+ sampleRate: signal.sampleRate,
261
+ channels: signal.channels.map((channel, index) => {
262
+ const out = new Float32Array(channel.length);
263
+ const removed = low.channels[index];
264
+ for (let sample = 0; sample < channel.length; sample += 1) out[sample] = channel[sample] - removed[sample];
265
+ return out;
266
+ })
267
+ };
268
+ };
269
+ var declick = (signal, milliseconds = 3) => {
270
+ const ramp = Math.max(1, Math.round(milliseconds / 1e3 * SAMPLE_RATE));
271
+ return {
272
+ sampleRate: signal.sampleRate,
273
+ channels: signal.channels.map((channel) => {
274
+ const out = Float32Array.from(channel);
275
+ const span = Math.min(ramp, Math.floor(out.length / 2));
276
+ for (let index = 0; index < span; index += 1) {
277
+ const level = index / span;
278
+ out[index] *= level;
279
+ out[out.length - 1 - index] *= level;
280
+ }
281
+ return out;
282
+ })
283
+ };
284
+ };
285
+ var seamless = (signal, milliseconds = 12) => {
286
+ const fade = Math.max(1, Math.round(milliseconds / 1e3 * SAMPLE_RATE));
287
+ return {
288
+ sampleRate: signal.sampleRate,
289
+ channels: signal.channels.map((channel) => {
290
+ const span = Math.min(fade, Math.floor(channel.length / 2));
291
+ if (span <= 1) return Float32Array.from(channel);
292
+ const out = Float32Array.from(channel.subarray(0, channel.length - span));
293
+ for (let index = 0; index < span; index += 1) {
294
+ const level = index / span;
295
+ out[index] = out[index] * level + channel[channel.length - span + index] * (1 - level);
296
+ }
297
+ return out;
298
+ })
299
+ };
300
+ };
301
+ var normalize = (signal, peak = 0.9) => {
302
+ let highest = 0;
303
+ for (const channel of signal.channels) {
304
+ for (let index = 0; index < channel.length; index += 1) {
305
+ const value = Math.abs(channel[index]);
306
+ if (value > highest) highest = value;
307
+ }
308
+ }
309
+ return highest === 0 ? signal : gain(signal, peak / highest);
310
+ };
311
+ var loop = (signal, samples) => ({
312
+ sampleRate: signal.sampleRate,
313
+ channels: signal.channels.map((channel) => {
314
+ const out = new Float32Array(samples);
315
+ if (channel.length === 0) return out;
316
+ for (let index = 0; index < samples; index += 1) out[index] = channel[index % channel.length];
317
+ return out;
318
+ })
319
+ });
320
+ var seconds = (value) => Math.max(0, Math.round(value * SAMPLE_RATE));
321
+ var SEMITONES = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };
322
+ var note = (name) => {
323
+ const match = /^([a-gA-G])([#b]?)(-?\d+)$/.exec(name.trim());
324
+ if (!match) throw new Error(`Not a note name: "${name}". Use scientific pitch, for example "a4" or "c#3".`);
325
+ const [, letter, accidental, octave] = match;
326
+ const semitone = SEMITONES[letter.toLowerCase()] + (accidental === "#" ? 1 : accidental === "b" ? -1 : 0) + (Number(octave) + 1) * 12;
327
+ return 440 * 2 ** ((semitone - 69) / 12);
328
+ };
329
+ var step = (bpm, division = 4) => Math.max(1, Math.round(60 / bpm / (division / 4) * SAMPLE_RATE));
330
+ var sequence = (steps, options) => {
331
+ const { bpm, division = 4, swing = 0, samples } = options;
332
+ const width = step(bpm, division);
333
+ const placed = steps.map((item) => {
334
+ const length = Math.max(1, Math.round((item.length ?? 1) * width));
335
+ const shuffle = swing > 0 && Math.round(item.at) % 2 === 1 ? swing * width : 0;
336
+ const offset = Math.round(item.at * width + shuffle);
337
+ return { offset, signal: item.gain === void 0 ? item.play(length) : gain(item.play(length), item.gain) };
338
+ });
339
+ const total = samples ?? Math.max(0, ...placed.map(({ offset, signal }) => offset + (signal.channels[0]?.length ?? 0)));
340
+ const channelCount = Math.max(1, ...placed.map(({ signal }) => signal.channels.length));
341
+ const channels = Array.from({ length: channelCount }, () => new Float32Array(total));
342
+ for (const { offset, signal } of placed) {
343
+ for (let channel = 0; channel < channelCount; channel += 1) {
344
+ const source = signal.channels[channel] ?? signal.channels[0];
345
+ if (!source) continue;
346
+ const target = channels[channel];
347
+ for (let index = 0; index < source.length && offset + index < total; index += 1) {
348
+ target[offset + index] += source[index];
349
+ }
350
+ }
351
+ }
352
+ return { channels, sampleRate: SAMPLE_RATE };
353
+ };
354
+ var QUALITIES = {
355
+ major: [0, 4, 7],
356
+ minor: [0, 3, 7],
357
+ sus2: [0, 2, 7],
358
+ sus4: [0, 5, 7],
359
+ major7: [0, 4, 7, 11],
360
+ minor7: [0, 3, 7, 10],
361
+ add9: [0, 4, 7, 14]
362
+ };
363
+ var chord = (root, quality = "major") => {
364
+ const base = typeof root === "number" ? root : note(root);
365
+ return QUALITIES[quality].map((interval) => base * 2 ** (interval / 12));
366
+ };
367
+ var pad = (frequencies, samples, options = {}) => {
368
+ const { envelope = { attack: 0.4, sustain: 1, release: 0.6 }, detuneCents = 4, level = 1 / Math.max(1, frequencies.length) } = options;
369
+ const voices = frequencies.flatMap((frequency, index) => {
370
+ const detune = 2 ** (detuneCents * (index % 2 === 0 ? 1 : -1) / 1200);
371
+ return [gain(sine(samples, frequency), level), gain(sine(samples, frequency * detune), level * 0.6)];
372
+ });
373
+ return shape(mix(...voices), envelope);
374
+ };
375
+
376
+ // src/cue.ts
377
+ var defineCue = (input) => ({
378
+ kind: "odori-cue",
379
+ loops: false,
380
+ params: {},
381
+ ...input,
382
+ // A score is about the sound, not about remembering what a buffer does at
383
+ // its edges. A one shot is declicked, because a buffer that stops
384
+ // mid-waveform is a click. A loop is made seamless instead, because
385
+ // declicking it would put that same silence at every seam.
386
+ render: (context) => input.loops ? seamless(input.render(context)) : declick(input.render(context))
387
+ });
388
+ var isCueDefinition = (value) => typeof value === "object" && value !== null && value.kind === "odori-cue";
389
+ var cueSignature = (cue) => hashValue({
390
+ name: cue.name,
391
+ durationInFrames: cue.durationInFrames,
392
+ loops: cue.loops,
393
+ params: cue.params
394
+ }).slice(0, 16);
395
+ var cueSamples = (cue, fps) => Math.max(1, Math.round(cue.durationInFrames / fps * SAMPLE_RATE));
396
+ var cueUrl = (cue) => `/__odori/cue/${cue.name}-${cueSignature(cue)}.wav`;
397
+
398
+ // src/easing.ts
399
+ var solveBezierCoordinate = (time, a1, a2) => {
400
+ const inverse = 1 - time;
401
+ return 3 * inverse * inverse * time * a1 + 3 * inverse * time * time * a2 + time ** 3;
402
+ };
403
+ var solveBezierSlope = (time, a1, a2) => 3 * (1 - time) ** 2 * a1 + 6 * (1 - time) * time * (a2 - a1) + 3 * time ** 2 * (1 - a2);
404
+ var cubicBezier = (x1, y1, x2, y2) => (value) => {
405
+ if (value <= 0 || value >= 1) return value;
406
+ let time = value;
407
+ for (let index = 0; index < 8; index += 1) {
408
+ const error = solveBezierCoordinate(time, x1, x2) - value;
409
+ const slope = solveBezierSlope(time, x1, x2);
410
+ if (Math.abs(error) < 1e-6 || Math.abs(slope) < 1e-6) break;
411
+ time -= error / slope;
412
+ }
413
+ return solveBezierCoordinate(Math.min(1, Math.max(0, time)), y1, y2);
414
+ };
415
+ var Easing = {
416
+ linear: (value) => value,
417
+ bezier: cubicBezier,
418
+ standard: cubicBezier(0.16, 1, 0.3, 1),
419
+ quad: (value) => value * value,
420
+ cubic: (value) => value ** 3,
421
+ in: (easing) => easing,
422
+ out: (easing) => (value) => 1 - easing(1 - value),
423
+ inOut: (easing) => (value) => value < 0.5 ? easing(value * 2) / 2 : 1 - easing((1 - value) * 2) / 2
424
+ };
425
+ function interpolate(value, input, output, options = {}) {
426
+ if (input.length !== output.length || input.length < 2) {
427
+ throw new Error("interpolate() requires matching input and output ranges.");
428
+ }
429
+ let resolved = value;
430
+ if (options.extrapolateLeft !== "extend") resolved = Math.max(input[0], resolved);
431
+ if (options.extrapolateRight !== "extend") resolved = Math.min(input[input.length - 1], resolved);
432
+ let rangeIndex = input.length - 2;
433
+ for (let index = 0; index < input.length - 1; index += 1) {
434
+ if (resolved <= input[index + 1]) {
435
+ rangeIndex = index;
436
+ break;
437
+ }
438
+ }
439
+ const inputStart = input[rangeIndex];
440
+ const inputEnd = input[rangeIndex + 1];
441
+ const progress = inputEnd === inputStart ? 0 : (resolved - inputStart) / (inputEnd - inputStart);
442
+ const eased = (options.easing ?? Easing.linear)(progress);
443
+ const startValue = output[rangeIndex];
444
+ const endValue = output[rangeIndex + 1];
445
+ if (typeof startValue === "number" && typeof endValue === "number") {
446
+ return startValue + (endValue - startValue) * eased;
447
+ }
448
+ if (typeof startValue === "string" && typeof endValue === "string") {
449
+ const startNumbers = startValue.match(/-?\d*\.?\d+/g)?.map(Number) ?? [];
450
+ const endNumbers = endValue.match(/-?\d*\.?\d+/g)?.map(Number) ?? [];
451
+ if (startNumbers.length !== endNumbers.length) {
452
+ throw new Error("String interpolation requires matching numeric segments.");
453
+ }
454
+ let segment = 0;
455
+ return startValue.replace(/-?\d*\.?\d+/g, () => {
456
+ const result = startNumbers[segment] + (endNumbers[segment] - startNumbers[segment]) * eased;
457
+ segment += 1;
458
+ return Number(result.toFixed(4)).toString();
459
+ });
460
+ }
461
+ throw new Error("interpolate() output values must share a type.");
462
+ }
463
+ var spring = ({
464
+ frame,
465
+ fps,
466
+ from = 0,
467
+ to = 1,
468
+ stiffness = 120,
469
+ damping = 20,
470
+ mass = 1,
471
+ delayInFrames = 0
472
+ }) => {
473
+ const elapsed = Math.max(0, frame - delayInFrames);
474
+ const step2 = 1 / fps;
475
+ let position = 0;
476
+ let velocity = 0;
477
+ for (let index = 0; index < elapsed; index += 1) {
478
+ const force = -stiffness * (position - 1) - damping * velocity;
479
+ velocity += force / mass * step2;
480
+ position += velocity * step2;
481
+ }
482
+ return from + (to - from) * position;
483
+ };
484
+
485
+ // src/brand.ts
486
+ var defaultBrand = {
487
+ kind: "odori-brand",
488
+ name: "odori",
489
+ colors: {
490
+ background: "#000000",
491
+ surface: "#0a0a0a",
492
+ foreground: "#ededed",
493
+ muted: "#a1a1a1",
494
+ accent: "#ffffff",
495
+ border: "#1f1f1f"
496
+ },
497
+ /**
498
+ * System faces, deliberately.
499
+ *
500
+ * A default that leads with a licensed family it does not ship is a default
501
+ * that renders through a fallback on every machine while claiming not to —
502
+ * and `odori test` is right to fail it, which meant a freshly scaffolded
503
+ * project failed its own contract check the first time it installed a
504
+ * component. A brand that wants Geist names it and adds the file, and the
505
+ * check then protects that choice instead of punishing the default.
506
+ */
507
+ typography: {
508
+ sans: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
509
+ mono: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace'
510
+ },
511
+ fonts: [],
512
+ logos: {},
513
+ motion: { standard: [0.16, 1, 0.3, 1], staggerFrames: 4 },
514
+ audio: { cues: {}, targetLufs: -14 }
515
+ };
516
+ var defineBrand = (input) => ({
517
+ kind: "odori-brand",
518
+ name: input.name ?? defaultBrand.name,
519
+ colors: { ...defaultBrand.colors, ...input.colors },
520
+ typography: { ...defaultBrand.typography, ...input.typography },
521
+ fonts: input.fonts ?? [],
522
+ logos: { ...input.logos },
523
+ motion: { ...defaultBrand.motion, ...input.motion },
524
+ audio: { ...defaultBrand.audio, ...input.audio, cues: { ...defaultBrand.audio.cues, ...input.audio?.cues } }
525
+ });
526
+ var brandCssVariables = (brand) => ({
527
+ "--odori-background": brand.colors.background,
528
+ "--odori-surface": brand.colors.surface,
529
+ "--odori-foreground": brand.colors.foreground,
530
+ "--odori-muted": brand.colors.muted,
531
+ "--odori-accent": brand.colors.accent,
532
+ "--odori-border": brand.colors.border,
533
+ "--odori-font-sans": brand.typography.sans,
534
+ "--odori-font-mono": brand.typography.mono
535
+ });
536
+
537
+ // src/layout.ts
538
+ var defaultLayout = {
539
+ kind: "odori-layout",
540
+ format: { width: 1920, height: 1080, fps: 30 },
541
+ brand: defaultBrand,
542
+ safeArea: { x: 96, y: 72 },
543
+ motion: { enter: [0.16, 1, 0.3, 1], staggerFrames: 4 },
544
+ audio: { palette: "product-cinematic", targetLufs: -14 },
545
+ transition: { crossfadeFrames: 8 }
546
+ };
547
+ var defineVideoLayout = (input = {}) => {
548
+ const base = input.extends ?? defaultLayout;
549
+ return {
550
+ kind: "odori-layout",
551
+ format: { ...base.format, ...input.format },
552
+ brand: input.brand ?? base.brand,
553
+ safeArea: input.safeArea ?? base.safeArea,
554
+ motion: { ...base.motion, ...input.motion },
555
+ audio: { ...base.audio, ...input.audio },
556
+ transition: input.transition ?? base.transition
557
+ };
558
+ };
559
+
560
+ // src/runtime.tsx
561
+ import {
562
+ Children,
563
+ cloneElement,
564
+ createContext as createContext2,
565
+ isValidElement,
566
+ useContext as useContext2,
567
+ useEffect,
568
+ useMemo,
569
+ useRef,
570
+ useState
571
+ } from "react";
572
+ import { jsx, jsxs } from "react/jsx-runtime";
573
+ var TimelineContext = createContext2(null);
574
+ var Fill = ({ style, ...props }) => /* @__PURE__ */ jsx(
575
+ "div",
576
+ {
577
+ ...props,
578
+ style: {
579
+ position: "absolute",
580
+ inset: 0,
581
+ width: "100%",
582
+ height: "100%",
583
+ display: "flex",
584
+ flexDirection: "column",
585
+ overflow: "hidden",
586
+ ...style
587
+ }
588
+ }
589
+ );
590
+ var SafeArea = ({ children, style }) => {
591
+ const layout = useContext2(LayoutContext) ?? defaultLayout;
592
+ return /* @__PURE__ */ jsx(Fill, { style: { padding: `${layout.safeArea.y}px ${layout.safeArea.x}px`, ...style }, children });
593
+ };
594
+ var SceneTransitionContext = createContext2({
595
+ entering: 1,
596
+ leaving: 0,
597
+ enterFrames: 0,
598
+ exitFrames: 0
599
+ });
600
+ var useSceneTransition = () => useContext2(SceneTransitionContext);
601
+ var SceneEnvelope = ({ children, durationInFrames }) => {
602
+ const frame = useFrame();
603
+ const edge = Math.max(1, Math.min(10, Math.floor(durationInFrames / 6)));
604
+ const opacity = interpolate(
605
+ frame,
606
+ [0, edge, Math.max(edge + 1, durationInFrames - edge), Math.max(edge + 2, durationInFrames - 1)],
607
+ [0, 1, 1, 0],
608
+ { easing: Easing.standard }
609
+ );
610
+ return /* @__PURE__ */ jsx(Fill, { style: { opacity }, children });
611
+ };
612
+ var Scene = ({
613
+ children,
614
+ id,
615
+ name,
616
+ __start = 0,
617
+ __index = 0,
618
+ __durationInFrames = 1,
619
+ __enterOverlap = 0,
620
+ __exitOverlap = 0,
621
+ __videoFrame = 0
622
+ }) => {
623
+ const config = useVideo();
624
+ const forceMount = useContext2(ForceMountContext);
625
+ const sceneId = id ?? `scene-${__index + 1}`;
626
+ const localFrame = __videoFrame - __start;
627
+ const active = localFrame >= 0 && localFrame < __durationInFrames;
628
+ if (!active && !forceMount) return null;
629
+ const joined = __enterOverlap > 0 || __exitOverlap > 0;
630
+ const transition = {
631
+ entering: __enterOverlap > 0 ? Math.min(1, Math.max(0, localFrame / __enterOverlap)) : 1,
632
+ leaving: __exitOverlap > 0 ? Math.min(1, Math.max(0, (localFrame - (__durationInFrames - __exitOverlap)) / __exitOverlap)) : 0,
633
+ enterFrames: __enterOverlap,
634
+ exitFrames: __exitOverlap
635
+ };
636
+ return /* @__PURE__ */ jsx(SceneContext.Provider, { value: { id: sceneId, name, index: __index, start: __start, durationInFrames: __durationInFrames }, children: /* @__PURE__ */ jsx(
637
+ FrameContext.Provider,
638
+ {
639
+ value: {
640
+ frame: Math.max(0, Math.min(__durationInFrames - 1, localFrame)),
641
+ fps: config.fps,
642
+ width: config.width,
643
+ height: config.height,
644
+ durationInFrames: __durationInFrames
645
+ },
646
+ children: /* @__PURE__ */ jsx(SceneTransitionContext.Provider, { value: transition, children: /* @__PURE__ */ jsx(Fill, { "data-odori-scene": sceneId, children: joined ? /* @__PURE__ */ jsx(Fill, { children }) : /* @__PURE__ */ jsx(SceneEnvelope, { durationInFrames: __durationInFrames, children }) }) })
647
+ }
648
+ ) });
649
+ };
650
+ Scene.__odoriScene = true;
651
+ var Stagger = ({ children, from = 0, duration }) => {
652
+ const config = useVideo();
653
+ const frame = useFrame();
654
+ const start = typeof from === "number" && Number.isInteger(from) && from > 1 ? from : framesFromDuration(from, config.fps);
655
+ const window2 = duration === void 0 ? config.durationInFrames : framesFromDuration(duration, config.fps);
656
+ const local = frame - start;
657
+ if (local < 0 || local >= window2) return null;
658
+ return /* @__PURE__ */ jsx(FrameContext.Provider, { value: { ...config, frame: local, durationInFrames: window2 }, children: /* @__PURE__ */ jsx(Fill, { children }) });
659
+ };
660
+ var Loop = ({
661
+ children,
662
+ duration,
663
+ times
664
+ }) => {
665
+ const config = useVideo();
666
+ const frame = useFrame();
667
+ const window2 = Math.max(1, framesFromDuration(duration, config.fps));
668
+ const pass = Math.floor(frame / window2);
669
+ if (times !== void 0 && pass >= times) return null;
670
+ return /* @__PURE__ */ jsx(FrameContext.Provider, { value: { ...config, frame: frame % window2, durationInFrames: window2 }, children: /* @__PURE__ */ jsx(Fill, { children }) });
671
+ };
672
+ var Freeze = ({ children, at = 0 }) => {
673
+ const config = useVideo();
674
+ const held = framesFromDuration(at, config.fps);
675
+ return /* @__PURE__ */ jsx(FrameContext.Provider, { value: { ...config, frame: Math.max(0, held) }, children: /* @__PURE__ */ jsx(Fill, { children }) });
676
+ };
677
+ var Audio = ({
678
+ src,
679
+ from = 0,
680
+ duration,
681
+ gain: gain2 = 1,
682
+ fadeIn = 0,
683
+ fadeOut = 0,
684
+ trimStart = 0,
685
+ loop: loop2,
686
+ duckUnder = false
687
+ }) => {
688
+ const config = useVideo();
689
+ const sink = useContext2(AudioSinkContext);
690
+ const scene = useContext2(SceneContext);
691
+ const assets = useAssets();
692
+ const brand = useContext2(BrandContext) ?? defaultBrand;
693
+ const frames = (value) => framesFromDuration(value, config.fps);
694
+ const named = brand.audio.cues[src];
695
+ const source = isCueDefinition(named) ? cueUrl(named) : named ?? src;
696
+ const url = isAssetReference(source) ? assets.resolve(source) : source;
697
+ const repeats = loop2 ?? (isCueDefinition(named) ? named.loops : false);
698
+ if (sink) {
699
+ const offset = (from === 0 ? 0 : frames(from)) + (scene?.start ?? 0);
700
+ const window2 = duration === void 0 ? scene?.durationInFrames ?? config.durationInFrames : frames(duration);
701
+ sink.register({
702
+ id: cueId(url, offset),
703
+ src: url,
704
+ fromFrame: offset,
705
+ durationInFrames: window2,
706
+ // A curve is sampled here, once, where the window is known. Downstream
707
+ // only ever sees points.
708
+ gain: typeof gain2 === "function" ? 1 : gain2,
709
+ gainPoints: typeof gain2 === "function" ? sampleGainCurve(gain2, offset, window2) : void 0,
710
+ fadeInFrames: fadeIn === 0 ? 0 : frames(fadeIn),
711
+ fadeOutFrames: fadeOut === 0 ? 0 : frames(fadeOut),
712
+ trimStartSeconds: trimStart === 0 ? 0 : frames(trimStart) / config.fps,
713
+ loop: repeats,
714
+ duckUnder
715
+ });
716
+ }
717
+ return null;
718
+ };
719
+ Audio.__odoriAudio = true;
720
+ var isSceneElement = (node) => isValidElement(node) && node.type?.__odoriScene === true;
721
+ var Video = ({ children, style }) => {
722
+ const config = useVideo();
723
+ const frame = useFrame();
724
+ const sink = useContext2(TimelineContext);
725
+ const reported = useRef("");
726
+ const nodes = Children.toArray(children);
727
+ const { laidOut, timeline } = useMemo(() => {
728
+ let cursor = 0;
729
+ let sceneIndex = 0;
730
+ const scenes = [];
731
+ const overlaps = [];
732
+ const placed = nodes.map((node) => {
733
+ if (!isSceneElement(node)) return null;
734
+ const durationInFrames = Math.max(1, framesFromDuration(node.props.duration, config.fps));
735
+ const requested = node.props.overlap === void 0 ? 0 : framesFromDuration(node.props.overlap, config.fps);
736
+ const previous = scenes[scenes.length - 1];
737
+ const overlap = sceneIndex === 0 ? 0 : Math.max(0, Math.min(requested, durationInFrames, previous?.durationInFrames ?? 0));
738
+ const start = Math.max(0, cursor - overlap);
739
+ const index = sceneIndex;
740
+ scenes.push({ id: node.props.id ?? `scene-${index + 1}`, name: node.props.name, index, start, durationInFrames, overlap });
741
+ overlaps.push(overlap);
742
+ cursor = start + durationInFrames;
743
+ sceneIndex += 1;
744
+ return { start, index, durationInFrames };
745
+ });
746
+ let placedIndex = 0;
747
+ const output = nodes.map((node, key) => {
748
+ if (!isSceneElement(node)) return node;
749
+ const entry = placed[key] ?? placed.find((item) => item !== null);
750
+ const index = placedIndex;
751
+ placedIndex += 1;
752
+ if (!entry) return node;
753
+ return cloneElement(node, {
754
+ key: `odori-scene-${key}`,
755
+ __start: entry.start,
756
+ __index: entry.index,
757
+ __durationInFrames: entry.durationInFrames,
758
+ __enterOverlap: overlaps[index] ?? 0,
759
+ __exitOverlap: overlaps[index + 1] ?? 0
760
+ });
761
+ });
762
+ return { laidOut: output, timeline: { scenes, durationInFrames: cursor } };
763
+ }, [children, config.fps]);
764
+ const publish = () => {
765
+ const signature = JSON.stringify(timeline);
766
+ if (signature === reported.current) return;
767
+ reported.current = signature;
768
+ sink?.report(timeline);
769
+ };
770
+ if (typeof window === "undefined") publish();
771
+ useEffect(publish);
772
+ return /* @__PURE__ */ jsx(Fill, { "data-odori-video": true, style, children: laidOut.map(
773
+ (node) => isSceneElement(node) ? cloneElement(node, { __videoFrame: frame }) : node
774
+ ) });
775
+ };
776
+ var resolveEntryLayout = (entry, override) => override ?? entry.metadata.layout ?? defaultLayout;
777
+ var entryDurationInFrames = (entry, layout) => entry.metadata.duration === void 0 ? 0 : framesFromDuration(entry.metadata.duration, layout.format.fps);
778
+ var fontFaceCss = (fonts) => fonts.map(
779
+ (font) => `@font-face{font-family:"${font.family}";src:url("${font.url}") format("woff2");font-weight:${font.weight ?? "100 900"};font-display:block;font-style:normal;}`
780
+ ).join("");
781
+ var installedFonts = /* @__PURE__ */ new Set();
782
+ var FontFaces = ({ fonts }) => {
783
+ const css = useMemo(() => fontFaceCss(fonts), [fonts]);
784
+ useEffect(() => {
785
+ if (!css || installedFonts.has(css)) return;
786
+ installedFonts.add(css);
787
+ const style = document.createElement("style");
788
+ style.dataset.odoriFonts = "";
789
+ style.textContent = css;
790
+ document.head.append(style);
791
+ }, [css]);
792
+ return typeof window === "undefined" && css ? /* @__PURE__ */ jsx("style", { "data-odori-fonts": true, children: css }) : null;
793
+ };
794
+ var AudioPass = ({
795
+ children,
796
+ onAudio,
797
+ durationInFrames
798
+ }) => {
799
+ const collected = useRef(/* @__PURE__ */ new Map());
800
+ const reported = useRef("");
801
+ const sink = useMemo(
802
+ () => ({
803
+ register: (cue) => {
804
+ collected.current.set(cue.id, cue);
805
+ }
806
+ }),
807
+ []
808
+ );
809
+ collected.current.clear();
810
+ const tree = /* @__PURE__ */ jsx(AudioSinkContext.Provider, { value: sink, children: /* @__PURE__ */ jsx(ForceMountContext.Provider, { value: true, children }) });
811
+ const publish = () => {
812
+ const cues = sortCues([...collected.current.values()]);
813
+ const signature = JSON.stringify(cues);
814
+ if (signature === reported.current) return;
815
+ reported.current = signature;
816
+ onAudio({ cues, durationInFrames: Math.max(durationInFrames, trackDuration(cues)) });
817
+ };
818
+ useEffect(publish);
819
+ return /* @__PURE__ */ jsxs("div", { "aria-hidden": true, "data-odori-audio-pass": true, style: { display: "none" }, children: [
820
+ tree,
821
+ /* @__PURE__ */ jsx(Publisher, { publish })
822
+ ] });
823
+ };
824
+ var Publisher = ({ publish }) => {
825
+ if (typeof window === "undefined") publish();
826
+ return null;
827
+ };
828
+ var OdoriRuntime = ({ entry, frame, input, prepared, assets, layout, onTimeline, onAudio }) => {
829
+ const resolvedLayout = resolveEntryLayout(entry, layout);
830
+ const { format, brand } = resolvedLayout;
831
+ const registry = useMemo(() => createAssetRegistry(assets ?? []), [assets]);
832
+ const sink = useMemo(() => ({ report: (timeline) => onTimeline?.(timeline) }), [onTimeline]);
833
+ const props = useMemo(() => {
834
+ const merged = { ...entry.metadata.defaultProps, ...input };
835
+ return entry.metadata.schema ? entry.metadata.schema.parse(merged) : merged;
836
+ }, [entry, input]);
837
+ const durationInFrames = entryDurationInFrames(entry, resolvedLayout);
838
+ const Composition = entry.component;
839
+ const [pending, setPending] = useState(0);
840
+ const readiness = useMemo(
841
+ () => ({
842
+ hold: () => {
843
+ setPending((count) => count + 1);
844
+ let released = false;
845
+ return () => {
846
+ if (released) return;
847
+ released = true;
848
+ setPending((count) => Math.max(0, count - 1));
849
+ };
850
+ }
851
+ }),
852
+ []
853
+ );
854
+ return /* @__PURE__ */ jsx(LayoutContext.Provider, { value: resolvedLayout, children: /* @__PURE__ */ jsx(BrandContext.Provider, { value: brand, children: /* @__PURE__ */ jsx(AssetContext.Provider, { value: registry, children: /* @__PURE__ */ jsx(ReadinessContext.Provider, { value: readiness, children: /* @__PURE__ */ jsx(TimelineContext.Provider, { value: sink, children: /* @__PURE__ */ jsx(
855
+ FrameContext.Provider,
856
+ {
857
+ value: {
858
+ frame,
859
+ fps: format.fps,
860
+ width: format.width,
861
+ height: format.height,
862
+ durationInFrames: durationInFrames || 1
863
+ },
864
+ children: /* @__PURE__ */ jsxs(
865
+ Fill,
866
+ {
867
+ "data-odori-frame": pending === 0 ? frame : void 0,
868
+ style: {
869
+ backgroundColor: brand.colors.background,
870
+ color: brand.colors.foreground,
871
+ fontFamily: brand.typography.sans,
872
+ ...brandCssVariables(brand)
873
+ },
874
+ children: [
875
+ /* @__PURE__ */ jsx(FontFaces, { fonts: brand.fonts }),
876
+ /* @__PURE__ */ jsx(Composition, { ...props, prepared }),
877
+ onAudio ? /* @__PURE__ */ jsx(AudioPass, { onAudio, durationInFrames, children: /* @__PURE__ */ jsx(Composition, { ...props, prepared }) }, entry.metadata.id) : null
878
+ ]
879
+ }
880
+ )
881
+ }
882
+ ) }) }) }) }) });
883
+ };
884
+
885
+ export {
886
+ useReadiness,
887
+ useFrame,
888
+ useVideo,
889
+ useDesignScale,
890
+ useBrand,
891
+ useLayout,
892
+ useScene,
893
+ useAssets,
894
+ createAssetRegistry,
895
+ cueId,
896
+ isAssetReference,
897
+ sortCues,
898
+ trackDuration,
899
+ gainAtFrame,
900
+ sampleGainCurve,
901
+ DUCK_GAIN,
902
+ DUCK_RAMP_FRAMES,
903
+ duckEnvelope,
904
+ envelopeAtFrame,
905
+ trackGainAtFrame,
906
+ SAMPLE_RATE,
907
+ seeded,
908
+ silence,
909
+ sine,
910
+ triangle,
911
+ noise,
912
+ sweep,
913
+ shape,
914
+ gain,
915
+ mix,
916
+ lowPass,
917
+ highPass,
918
+ seamless,
919
+ normalize,
920
+ loop,
921
+ seconds,
922
+ note,
923
+ step,
924
+ sequence,
925
+ chord,
926
+ pad,
927
+ defineCue,
928
+ isCueDefinition,
929
+ cueSignature,
930
+ cueSamples,
931
+ cueUrl,
932
+ Easing,
933
+ interpolate,
934
+ spring,
935
+ defaultBrand,
936
+ defineBrand,
937
+ brandCssVariables,
938
+ defaultLayout,
939
+ defineVideoLayout,
940
+ Fill,
941
+ SafeArea,
942
+ useSceneTransition,
943
+ Scene,
944
+ Stagger,
945
+ Loop,
946
+ Freeze,
947
+ Audio,
948
+ Video,
949
+ resolveEntryLayout,
950
+ entryDurationInFrames,
951
+ OdoriRuntime
952
+ };
953
+ //# sourceMappingURL=chunk-2SROZSZM.js.map