@odori/cli 0.0.7 → 0.0.8

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.
@@ -258,27 +258,110 @@ var registry_snapshot_default = {
258
258
  }
259
259
  },
260
260
  {
261
- name: "bed-energetic",
262
- description: "Loud and rhythmic, for a launch. Mixed hot, so give it a lower gain.",
261
+ name: "bed-drift",
262
+ description: "A pad that breathes instead of keeping time, for narration to pace.",
263
263
  registryDependencies: [],
264
264
  files: [
265
265
  {
266
- path: "components/bed-energetic/bed-energetic.preview.tsx",
267
- content: 'import {SampleWave, defineComponentPreview} from "odori/preview";\n\n/** Peaks measured from the published file, so the card draws the recording. */\nconst PEAKS = [\n 0.65, 0.93, 1, 0.94, 0.93, 1, 0.96, 0.93, 1, 0.95, 1, 0.51, 0.97, 1, 0.95, 0.84, 1, 0.94, 1, 0.96, 0.95, 1,\n 0.97, 0.94, 1, 1, 0.66, 1, 0.96, 1, 1, 0.94, 0.91, 0.92, 0.96, 1, 0.96, 1, 0.99, 0.99, 1, 0.95, 1, 0.98, 0.92,\n 0.89, 1, 0.9, 0.99, 0.97, 0.88, 1, 0.91, 1, 0.99, 0.94, 1, 0.95, 0.97, 0.97, 1, 0.98, 1, 0.91, 0.99, 0.94, 0.94,\n 0.99, 0.99, 0.97, 0.97, 0.96, 0.9, 1, 0.67, 1, 0.94, 1, 1, 0.95, 0.88, 1, 0.96, 0.99, 1, 0.98, 1, 0.7, 0.96, 1,\n 0.96, 0.81, 1, 0.95, 1, 0.7, 0.96, 0.94, 0.94, 0.85, 1, 0.96, 1, 0.94, 0.98, 0.98, 0.91, 0.96, 1, 0.96, 0.94, 1,\n 0.97, 1, 0.96, 0.94, 1, 0.81, 0.55, 0.34,\n];\n\nexport default defineComponentPreview({\n title: "Energetic bed",\n category: "Sound/Beds",\n description: "Loud and rhythmic, for a launch. Mixed hot, so give it a lower gain.",\n component: () => <SampleWave peaks={PEAKS} label="bed.energetic" seconds={45} />,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n examples: [{name: "Default", props: {}}],\n});\n',
268
- target: "videos/components/bed-energetic/bed-energetic.preview.tsx"
266
+ path: "components/bed-drift/bed-drift.tsx",
267
+ content: 'import {chord, defineCue, gain, lowPass, mix, noise, normalize, note, pad, sequence, sine, type CueDefinition} from "odori";\n\nexport type BedDriftOptions = {\n /** Root of the home chord, as a note name. */\n root?: string;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * Sixteen seconds of slow breathing: the home chord swells and lets go, and a\n * warmer chord answers it, overlapping so the seam between them is a change of\n * colour rather than an event. Nothing in it marks time \u2014 no pulse, no beat \u2014\n * which is the bed for a cut that narration is already pacing.\n *\n * Everything sits below the voice: the pads are low passed under a kilohertz,\n * the drone is an octave under the root, and the air is closer to room tone\n * than to texture.\n */\nexport const bedDrift = ({root = "a2", peak = 0.5}: BedDriftOptions = {}): CueDefinition => {\n // One breath is four seconds. Two breaths each way is the phrase.\n const breath = 4 * 48000;\n const samples = breath * 4;\n\n // The answering chord, a major third below: the relative major seventh,\n // which agrees with the home chord about every note but one.\n const answer = note(root) * 2 ** (-4 / 12);\n\n return defineCue({\n name: "bed.drift",\n durationInFrames: Math.round((samples / 48000) * 30),\n loops: true,\n params: {root, peak},\n render: () =>\n normalize(\n mix(\n // The two swells, placed so one rises while the other releases.\n sequence(\n [\n {\n at: 0,\n length: 2.5,\n play: (length: number) =>\n lowPass(pad(chord(root, "minor7"), length, {envelope: {attack: 3.5, sustain: 1, release: 4}}), 850),\n gain: 0.85,\n },\n {\n at: 1.5,\n length: 2.5,\n play: (length: number) =>\n lowPass(pad(chord(answer, "major7"), length, {envelope: {attack: 3.5, sustain: 1, release: 4}}), 850),\n gain: 0.7,\n },\n ],\n {bpm: 60, division: 1, samples},\n ),\n // A drone an octave down holds through both, which is what makes the\n // chord change read as weather rather than as a cut.\n gain(sine(samples, note(root) / 2), 0.16),\n // Room tone. Just enough that silence between swells is not digital.\n gain(lowPass(noise(samples, 7), 350), 0.05),\n ),\n peak,\n ),\n });\n};\n',
268
+ target: "videos/components/bed-drift/bed-drift.tsx"
269
+ },
270
+ {
271
+ path: "components/bed-drift/bed-drift.preview.tsx",
272
+ content: 'import {CueWave, defineComponentPreview} from "odori/preview";\nimport {bedDrift, type BedDriftOptions} from "./bed-drift";\n\nconst Wave = ({root, peak}: BedDriftOptions) => <CueWave cue={bedDrift({root, peak})} />;\n\nexport default defineComponentPreview({\n title: "Drift bed",\n category: "Sound/Beds",\n description: "A pad that breathes instead of keeping time, for narration to pace.",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {\n root: {type: "select", defaultValue: "a2", options: ["f2", "g2", "a2", "c3"]},\n peak: {type: "number", defaultValue: 0.5, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: "Default", props: {}}, {name: "Lower", props: {root: "f2"}}],\n});\n',
273
+ target: "videos/components/bed-drift/bed-drift.preview.tsx"
274
+ }
275
+ ],
276
+ meta: {
277
+ kind: "cue",
278
+ family: "Sound",
279
+ namespaced: "@odori/bed-drift",
280
+ contract: {
281
+ aspectRatios: [
282
+ "16:9",
283
+ "9:16",
284
+ "1:1"
285
+ ],
286
+ recommendedDurationInFrames: 480,
287
+ minimumDurationInFrames: 30,
288
+ entranceFrames: 0,
289
+ exitFrames: 0,
290
+ contentLimits: {},
291
+ reducedMotion: "the waveform renders without a playhead",
292
+ requires: {
293
+ fonts: [
294
+ "mono"
295
+ ],
296
+ audio: []
297
+ },
298
+ loops: true
299
+ },
300
+ cue: {
301
+ name: "bed.drift",
302
+ export: "bedDrift"
303
+ }
304
+ }
305
+ },
306
+ {
307
+ name: "bed-horizons",
308
+ description: "Steady and wide, for a cut that wants scale without hurry.",
309
+ registryDependencies: [],
310
+ files: [
311
+ {
312
+ path: "components/bed-horizons/bed-horizons.preview.tsx",
313
+ content: 'import {SampleWave, defineComponentPreview} from "odori/preview";\n\n/** Peaks measured from the published file, so the card draws the recording. */\nconst PEAKS = [0.45, 0.44, 0.42, 0.4, 0.44, 0.42, 0.44, 0.48, 0.44, 0.45, 0.47, 0.42, 0.46, 0.43, 0.45, 0.38, 0.44, 0.43, 0.41, 0.36, 0.34, 0.38, 0.34, 0.37, 0.39, 0.4, 0.4, 0.37, 0.35, 0.24, 0.25, 0.22, 0.38, 0.41, 0.45, 0.39, 0.4, 0.47, 0.39, 0.4, 0.4, 0.43, 0.47, 0.41, 0.4, 0.44, 0.36, 0.4, 0.37, 0.4, 0.43, 0.46, 0.43, 0.42, 0.4, 0.41, 0.44, 0.43, 0.4, 0.38, 0.46, 0.41, 0.46, 0.3, 0.44, 0.49, 0.45, 0.41, 0.41, 0.46, 0.41, 0.47, 0.42, 0.47, 0.45, 0.44, 0.42, 0.41, 0.41, 0.41, 0.39, 0.4, 0.44, 0.4, 0.43, 0.43, 0.44, 0.44, 0.38, 0.44, 0.4, 0.38, 0.37, 0.41, 0.36, 0.32, 0.39, 0.38, 0.39, 0.4, 0.45, 0.39, 0.42, 0.44, 0.4, 0.48, 0.39, 0.43, 0.39, 0.47, 0.44, 0.44, 0.46, 0.25, 0.16, 0.09, 0.06, 0.01, 0.0, 0.0, 0.0];\n\nexport default defineComponentPreview({\n title: "New Horizons",\n category: "Sound/Beds",\n description: "Steady and wide, for a cut that wants scale without hurry.",\n component: () => <SampleWave peaks={PEAKS} label="bed.horizons" seconds={60} />,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n examples: [{name: "Default", props: {}}],\n});\n',
314
+ target: "videos/components/bed-horizons/bed-horizons.preview.tsx"
315
+ }
316
+ ],
317
+ meta: {
318
+ kind: "asset",
319
+ family: "Sound",
320
+ namespaced: "@odori/bed-horizons",
321
+ contract: {
322
+ aspectRatios: [
323
+ "16:9",
324
+ "9:16",
325
+ "1:1"
326
+ ],
327
+ recommendedDurationInFrames: 1800,
328
+ minimumDurationInFrames: 30,
329
+ entranceFrames: 0,
330
+ exitFrames: 0,
331
+ contentLimits: {},
332
+ reducedMotion: "the waveform renders without a playhead",
333
+ requires: {
334
+ fonts: [
335
+ "mono"
336
+ ],
337
+ audio: []
338
+ },
339
+ loops: false
340
+ }
341
+ }
342
+ },
343
+ {
344
+ name: "bed-innovation",
345
+ description: "Confident and even, for a product walking through its paces.",
346
+ registryDependencies: [],
347
+ files: [
348
+ {
349
+ path: "components/bed-innovation/bed-innovation.preview.tsx",
350
+ content: 'import {SampleWave, defineComponentPreview} from "odori/preview";\n\n/** Peaks measured from the published file, so the card draws the recording. */\nconst PEAKS = [0.26, 0.24, 0.18, 0.24, 0.25, 0.35, 0.32, 0.35, 0.26, 0.24, 0.34, 0.21, 0.22, 0.19, 0.26, 0.41, 0.26, 0.32, 0.23, 0.29, 0.35, 0.36, 0.24, 0.3, 0.31, 0.41, 0.34, 0.33, 0.26, 0.3, 0.24, 0.31, 0.25, 0.2, 0.27, 0.31, 0.27, 0.31, 0.24, 0.24, 0.48, 0.35, 0.47, 0.46, 0.51, 0.47, 0.36, 0.45, 0.46, 0.46, 0.52, 0.44, 0.49, 0.45, 0.46, 0.28, 0.48, 0.53, 0.38, 0.45, 0.38, 0.43, 0.45, 0.45, 0.52, 0.46, 0.45, 0.48, 0.43, 0.41, 0.51, 0.43, 0.5, 0.42, 0.48, 0.43, 0.45, 0.48, 0.4, 0.4, 0.47, 0.52, 0.49, 0.4, 0.42, 0.49, 0.47, 0.49, 0.39, 0.43, 0.46, 0.43, 0.51, 0.44, 0.45, 0.48, 0.46, 0.36, 0.47, 0.49, 0.51, 0.5, 0.32, 0.25, 0.2, 0.13, 0.08, 0.07, 0.04, 0.03, 0.02, 0.01, 0.01, 0.01, 0.01, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];\n\nexport default defineComponentPreview({\n title: "Innovation Unveiled",\n category: "Sound/Beds",\n description: "Confident and even, for a product walking through its paces.",\n component: () => <SampleWave peaks={PEAKS} label="bed.innovation" seconds={60} />,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n examples: [{name: "Default", props: {}}],\n});\n',
351
+ target: "videos/components/bed-innovation/bed-innovation.preview.tsx"
269
352
  }
270
353
  ],
271
354
  meta: {
272
355
  kind: "asset",
273
356
  family: "Sound",
274
- namespaced: "@odori/bed-energetic",
357
+ namespaced: "@odori/bed-innovation",
275
358
  contract: {
276
359
  aspectRatios: [
277
360
  "16:9",
278
361
  "9:16",
279
362
  "1:1"
280
363
  ],
281
- recommendedDurationInFrames: 1350,
364
+ recommendedDurationInFrames: 1800,
282
365
  minimumDurationInFrames: 30,
283
366
  entranceFrames: 0,
284
367
  exitFrames: 0,
@@ -294,9 +377,92 @@ var registry_snapshot_default = {
294
377
  }
295
378
  }
296
379
  },
380
+ {
381
+ name: "bed-launch",
382
+ description: "A build with a destination, for the run-up to a reveal.",
383
+ registryDependencies: [],
384
+ files: [
385
+ {
386
+ path: "components/bed-launch/bed-launch.preview.tsx",
387
+ content: 'import {SampleWave, defineComponentPreview} from "odori/preview";\n\n/** Peaks measured from the published file, so the card draws the recording. */\nconst PEAKS = [0.0, 0.01, 0.01, 0.02, 0.02, 0.03, 0.03, 0.03, 0.05, 0.06, 0.04, 0.03, 0.05, 0.04, 0.03, 0.05, 0.04, 0.03, 0.03, 0.03, 0.03, 0.04, 0.06, 0.04, 0.04, 0.04, 0.05, 0.05, 0.06, 0.04, 0.04, 0.46, 0.47, 0.47, 0.48, 0.5, 0.52, 0.52, 0.56, 0.56, 0.64, 0.69, 0.69, 0.67, 0.64, 0.63, 0.61, 0.57, 0.55, 0.56, 0.55, 0.53, 0.53, 0.55, 0.56, 0.52, 0.48, 0.51, 0.49, 0.48, 0.48, 0.37, 0.47, 0.45, 0.42, 0.51, 0.41, 0.47, 0.42, 0.49, 0.46, 0.45, 0.4, 0.47, 0.39, 0.44, 0.4, 0.45, 0.39, 0.44, 0.41, 0.48, 0.39, 0.44, 0.4, 0.44, 0.43, 0.38, 0.43, 0.38, 0.46, 0.44, 0.45, 0.43, 0.43, 0.43, 0.49, 0.49, 0.56, 0.44, 0.56, 0.5, 0.53, 0.47, 0.56, 0.46, 0.51, 0.44, 0.54, 0.21, 0.15, 0.08, 0.08, 0.07, 0.05, 0.04, 0.04, 0.01, 0.01, 0.0, 0.0];\n\nexport default defineComponentPreview({\n title: "Launch Sequence",\n category: "Sound/Beds",\n description: "A build with a destination, for the run-up to a reveal.",\n component: () => <SampleWave peaks={PEAKS} label="bed.launch" seconds={60} />,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n examples: [{name: "Default", props: {}}],\n});\n',
388
+ target: "videos/components/bed-launch/bed-launch.preview.tsx"
389
+ }
390
+ ],
391
+ meta: {
392
+ kind: "asset",
393
+ family: "Sound",
394
+ namespaced: "@odori/bed-launch",
395
+ contract: {
396
+ aspectRatios: [
397
+ "16:9",
398
+ "9:16",
399
+ "1:1"
400
+ ],
401
+ recommendedDurationInFrames: 1800,
402
+ minimumDurationInFrames: 30,
403
+ entranceFrames: 0,
404
+ exitFrames: 0,
405
+ contentLimits: {},
406
+ reducedMotion: "the waveform renders without a playhead",
407
+ requires: {
408
+ fonts: [
409
+ "mono"
410
+ ],
411
+ audio: []
412
+ },
413
+ loops: false
414
+ }
415
+ }
416
+ },
417
+ {
418
+ name: "bed-lift",
419
+ description: "A held chord with a quiet shimmer circling above it, for a cut with somewhere to be.",
420
+ registryDependencies: [],
421
+ files: [
422
+ {
423
+ path: "components/bed-lift/bed-lift.tsx",
424
+ content: 'import {\n chord,\n defineCue,\n gain,\n lowPass,\n mix,\n normalize,\n note,\n pad,\n sequence,\n shape,\n sine,\n step,\n triangle,\n type CueDefinition,\n} from "odori";\n\nexport type BedLiftOptions = {\n /** Tempo of the shimmer. Faster reads brighter, not louder. */\n bpm?: number;\n /** Root of the held chord, as a note name. */\n root?: string;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * Four bars with somewhere to be: a major add9 held underneath and a quiet\n * arpeggio circling above it on the eighths. The shimmer is the point \u2014 it\n * gives a cut forward motion without a drum in sight \u2014 and it stays quiet\n * enough that it colours the bed rather than performing over it.\n *\n * The brightest voice is low passed just above a kilohertz, so the motion is\n * felt more than heard and the voice keeps the range it needs.\n */\nexport const bedLift = ({bpm = 96, root = "c3", peak = 0.55}: BedLiftOptions = {}): CueDefinition => {\n const beat = step(bpm, 4);\n const bars = 4;\n const samples = beat * 4 * bars;\n\n const tones = chord(root, "add9");\n // The circling order: root, fifth, third, ninth. Up and never quite back.\n const circle = [0, 2, 1, 3];\n\n return defineCue({\n name: "bed.lift",\n durationInFrames: Math.round((samples / 48000) * 30),\n loops: true,\n params: {bpm, root, peak},\n render: () =>\n normalize(\n mix(\n // The held chord, warm and below everything.\n gain(lowPass(pad(tones, samples, {envelope: {attack: 1.4, sustain: 1, release: 1.4}}), 1000), 0.85),\n // The shimmer: one soft voice per eighth, an octave up, each gone\n // before the next arrives.\n lowPass(\n sequence(\n Array.from({length: 8 * bars}, (_, index) => ({\n at: index,\n play: (length: number) =>\n shape(triangle(length, tones[circle[index % circle.length]] * 2), {\n attack: 0.015,\n decay: 0.32,\n sustain: 0,\n release: 0.08,\n }),\n // The bar line leans in slightly, which is all the grid a bed needs.\n gain: index % 8 === 0 ? 0.2 : 0.12,\n })),\n {bpm, division: 8, samples},\n ),\n 1600,\n ),\n // A floor under the root.\n gain(sine(samples, note(root) / 2), 0.22),\n ),\n peak,\n ),\n });\n};\n',
425
+ target: "videos/components/bed-lift/bed-lift.tsx"
426
+ },
427
+ {
428
+ path: "components/bed-lift/bed-lift.preview.tsx",
429
+ content: 'import {CueWave, defineComponentPreview} from "odori/preview";\nimport {bedLift, type BedLiftOptions} from "./bed-lift";\n\nconst Wave = ({bpm, root, peak}: BedLiftOptions) => <CueWave cue={bedLift({bpm, root, peak})} />;\n\nexport default defineComponentPreview({\n title: "Lift bed",\n category: "Sound/Beds",\n description: "A held chord with a quiet shimmer circling above it, for a cut with somewhere to be.",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {\n bpm: {type: "number", defaultValue: 96, min: 72, max: 128, step: 4},\n root: {type: "select", defaultValue: "c3", options: ["a2", "c3", "d3", "f3"]},\n peak: {type: "number", defaultValue: 0.55, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: "Default", props: {}}, {name: "Slower", props: {bpm: 80}}],\n});\n',
430
+ target: "videos/components/bed-lift/bed-lift.preview.tsx"
431
+ }
432
+ ],
433
+ meta: {
434
+ kind: "cue",
435
+ family: "Sound",
436
+ namespaced: "@odori/bed-lift",
437
+ contract: {
438
+ aspectRatios: [
439
+ "16:9",
440
+ "9:16",
441
+ "1:1"
442
+ ],
443
+ recommendedDurationInFrames: 300,
444
+ minimumDurationInFrames: 30,
445
+ entranceFrames: 0,
446
+ exitFrames: 0,
447
+ contentLimits: {},
448
+ reducedMotion: "the waveform renders without a playhead",
449
+ requires: {
450
+ fonts: [
451
+ "mono"
452
+ ],
453
+ audio: []
454
+ },
455
+ loops: true
456
+ },
457
+ cue: {
458
+ name: "bed.lift",
459
+ export: "bedLift"
460
+ }
461
+ }
462
+ },
297
463
  {
298
464
  name: "bed-pulse",
299
- description: "A soft repeating pulse over a held chord, for narration.",
465
+ description: "A held chord with a heartbeat, for momentum without a groove.",
300
466
  registryDependencies: [],
301
467
  files: [
302
468
  {
@@ -306,7 +472,7 @@ var registry_snapshot_default = {
306
472
  },
307
473
  {
308
474
  path: "components/bed-pulse/bed-pulse.preview.tsx",
309
- content: 'import {CueWave, defineComponentPreview} from "odori/preview";\nimport {bedPulse, type BedPulseOptions} from "./bed-pulse";\n\nconst Wave = ({bpm, root, peak}: BedPulseOptions) => <CueWave cue={bedPulse({bpm, root, peak})} />;\n\nexport default defineComponentPreview({\n title: "Pulse bed",\n category: "Sound/Beds",\n description: "A soft repeating pulse over a held chord, for narration.",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: "6s"},\n controls: {\n bpm: {type: "number", defaultValue: 84, min: 60, max: 140},\n root: {type: "select", defaultValue: "c2", options: ["a1", "c2", "d2", "f2"]},\n peak: {type: "number", defaultValue: 0.55, min: 0.1, max: 1, step: 0.05},\n },\n examples: [\n {name: "Default", props: {}},\n {name: "Slower", props: {bpm: 68}},\n {name: "Darker", props: {root: "a1"}},\n ],\n});\n',
475
+ content: 'import {CueWave, defineComponentPreview} from "odori/preview";\nimport {bedPulse, type BedPulseOptions} from "./bed-pulse";\n\nconst Wave = ({bpm, root, peak}: BedPulseOptions) => <CueWave cue={bedPulse({bpm, root, peak})} />;\n\nexport default defineComponentPreview({\n title: "Pulse bed",\n category: "Sound/Beds",\n description: "A held chord with a heartbeat, for momentum without a groove.",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {\n bpm: {type: "number", defaultValue: 84, min: 64, max: 112, step: 4},\n root: {type: "select", defaultValue: "c2", options: ["a1", "c2", "d2", "f2"]},\n peak: {type: "number", defaultValue: 0.55, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: "Default", props: {}}, {name: "Slower", props: {bpm: 72}}],\n});\n',
310
476
  target: "videos/components/bed-pulse/bed-pulse.preview.tsx"
311
477
  }
312
478
  ],
@@ -321,7 +487,7 @@ var registry_snapshot_default = {
321
487
  "1:1"
322
488
  ],
323
489
  recommendedDurationInFrames: 171,
324
- minimumDurationInFrames: 171,
490
+ minimumDurationInFrames: 30,
325
491
  entranceFrames: 0,
326
492
  exitFrames: 0,
327
493
  contentLimits: {},
@@ -341,27 +507,27 @@ var registry_snapshot_default = {
341
507
  }
342
508
  },
343
509
  {
344
- name: "bed-soft-pulse",
345
- description: "A pulse without a beat, for a cut that needs momentum but not tempo.",
510
+ name: "bed-tomorrow",
511
+ description: "Bright and open, for showing something new without shouting.",
346
512
  registryDependencies: [],
347
513
  files: [
348
514
  {
349
- path: "components/bed-soft-pulse/bed-soft-pulse.preview.tsx",
350
- content: 'import {SampleWave, defineComponentPreview} from "odori/preview";\n\n/** Peaks measured from the published file, so the card draws the recording. */\nconst PEAKS = [\n 0.05, 0.07, 0.16, 0.17, 0.27, 0.38, 0.15, 0.26, 0.21, 0.31, 0.33, 0.21, 0.38, 0.3, 0.32, 0.36, 0.25, 0.3, 0.24,\n 0.2, 0.26, 0.12, 0.26, 0.21, 0.19, 0.31, 0.22, 0.35, 0.29, 0.22, 0.26, 0.2, 0.33, 0.22, 0.1, 0.24, 0.17, 0.34,\n 0.24, 0.22, 0.28, 0.26, 0.44, 0.33, 0.26, 0.33, 0.22, 0.35, 0.26, 0.18, 0.24, 0.23, 0.33, 0.29, 0.32, 0.32,\n 0.28, 0.37, 0.3, 0.27, 0.34, 0.15, 0.26, 0.17, 0.16, 0.29, 0.22, 0.3, 0.2, 0.2, 0.33, 0.24, 0.32, 0.22, 0.17,\n 0.31, 0.2, 0.35, 0.22, 0.13, 0.25, 0.13, 0.28, 0.23, 0.21, 0.33, 0.24, 0.32, 0.3, 0.29, 0.37, 0.24, 0.33, 0.29,\n 0.27, 0.36, 0.32, 0.33, 0.24, 0.21, 0.33, 0.28, 0.23, 0.13, 0.13, 0.27, 0.25, 0.27, 0.25, 0.2, 0.35, 0.28, 0.3,\n 0.18, 0.19, 0.25, 0.14, 0.15, 0.06, 0.02,\n];\n\nexport default defineComponentPreview({\n title: "Soft pulse bed",\n category: "Sound/Beds",\n description: "A pulse without a beat, for a cut that needs momentum but not tempo.",\n component: () => <SampleWave peaks={PEAKS} label="bed.soft" seconds={48} />,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n examples: [{name: "Default", props: {}}],\n});\n',
351
- target: "videos/components/bed-soft-pulse/bed-soft-pulse.preview.tsx"
515
+ path: "components/bed-tomorrow/bed-tomorrow.preview.tsx",
516
+ content: 'import {SampleWave, defineComponentPreview} from "odori/preview";\n\n/** Peaks measured from the published file, so the card draws the recording. */\nconst PEAKS = [0.21, 0.27, 0.1, 0.24, 0.13, 0.22, 0.21, 0.31, 0.26, 0.23, 0.51, 0.38, 0.36, 0.33, 0.43, 0.5, 0.4, 0.42, 0.31, 0.56, 0.44, 0.42, 0.45, 0.39, 0.37, 0.49, 0.34, 0.36, 0.42, 0.44, 0.5, 0.46, 0.45, 0.37, 0.34, 0.54, 0.41, 0.41, 0.4, 0.52, 0.53, 0.37, 0.39, 0.39, 0.39, 0.44, 0.5, 0.39, 0.33, 0.43, 0.47, 0.49, 0.38, 0.37, 0.4, 0.42, 0.49, 0.46, 0.33, 0.49, 0.37, 0.49, 0.53, 0.36, 0.37, 0.35, 0.47, 0.39, 0.42, 0.28, 0.45, 0.42, 0.4, 0.42, 0.32, 0.45, 0.51, 0.38, 0.36, 0.37, 0.43, 0.52, 0.45, 0.37, 0.4, 0.45, 0.44, 0.52, 0.38, 0.38, 0.3, 0.47, 0.36, 0.39, 0.45, 0.42, 0.44, 0.38, 0.46, 0.41, 0.52, 0.55, 0.55, 0.52, 0.45, 0.58, 0.45, 0.57, 0.56, 0.46, 0.46, 0.34, 0.41, 0.22, 0.1, 0.09, 0.05, 0.02, 0.02, 0.0, 0.0];\n\nexport default defineComponentPreview({\n title: "Unveiling Tomorrow",\n category: "Sound/Beds",\n description: "Bright and open, for showing something new without shouting.",\n component: () => <SampleWave peaks={PEAKS} label="bed.tomorrow" seconds={60} />,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n examples: [{name: "Default", props: {}}],\n});\n',
517
+ target: "videos/components/bed-tomorrow/bed-tomorrow.preview.tsx"
352
518
  }
353
519
  ],
354
520
  meta: {
355
521
  kind: "asset",
356
522
  family: "Sound",
357
- namespaced: "@odori/bed-soft-pulse",
523
+ namespaced: "@odori/bed-tomorrow",
358
524
  contract: {
359
525
  aspectRatios: [
360
526
  "16:9",
361
527
  "9:16",
362
528
  "1:1"
363
529
  ],
364
- recommendedDurationInFrames: 1440,
530
+ recommendedDurationInFrames: 1800,
365
531
  minimumDurationInFrames: 30,
366
532
  entranceFrames: 0,
367
533
  exitFrames: 0,
@@ -378,27 +544,27 @@ var registry_snapshot_default = {
378
544
  }
379
545
  },
380
546
  {
381
- name: "bed-warm-minimal",
382
- description: "Unhurried and low contrast. The default bed under narration.",
547
+ name: "bed-velocity",
548
+ description: "The fast one: forward motion for a cut with momentum to spend.",
383
549
  registryDependencies: [],
384
550
  files: [
385
551
  {
386
- path: "components/bed-warm-minimal/bed-warm-minimal.preview.tsx",
387
- content: 'import {SampleWave, defineComponentPreview} from "odori/preview";\n\n/** Peaks measured from the published file, so the card draws the recording. */\nconst PEAKS = [\n 0.05, 0.1, 0.13, 0.24, 0.29, 0.27, 0.17, 0.17, 0.21, 0.3, 0.22, 0.29, 0.27, 0.33, 0.33, 0.29, 0.24, 0.21, 0.25,\n 0.2, 0.2, 0.17, 0.2, 0.19, 0.24, 0.2, 0.31, 0.22, 0.34, 0.21, 0.23, 0.28, 0.24, 0.22, 0.14, 0.23, 0.18, 0.26,\n 0.2, 0.27, 0.22, 0.32, 0.33, 0.34, 0.24, 0.24, 0.28, 0.25, 0.25, 0.23, 0.21, 0.23, 0.27, 0.27, 0.36, 0.24, 0.32,\n 0.3, 0.35, 0.3, 0.28, 0.2, 0.16, 0.2, 0.17, 0.29, 0.24, 0.2, 0.16, 0.25, 0.24, 0.31, 0.25, 0.26, 0.18, 0.18,\n 0.28, 0.25, 0.2, 0.17, 0.23, 0.15, 0.21, 0.23, 0.24, 0.21, 0.22, 0.24, 0.36, 0.29, 0.29, 0.33, 0.31, 0.32, 0.28,\n 0.45, 0.38, 0.27, 0.19, 0.31, 0.26, 0.34, 0.16, 0.2, 0.13, 0.24, 0.27, 0.24, 0.27, 0.21, 0.3, 0.29, 0.27, 0.21,\n 0.19, 0.16, 0.17, 0.09, 0.08, 0.02,\n];\n\nexport default defineComponentPreview({\n title: "Warm minimal bed",\n category: "Sound/Beds",\n description: "Unhurried and low contrast. The default bed under narration.",\n component: () => <SampleWave peaks={PEAKS} label="bed.warm" seconds={48} />,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n examples: [{name: "Default", props: {}}],\n});\n',
388
- target: "videos/components/bed-warm-minimal/bed-warm-minimal.preview.tsx"
552
+ path: "components/bed-velocity/bed-velocity.preview.tsx",
553
+ content: 'import {SampleWave, defineComponentPreview} from "odori/preview";\n\n/** Peaks measured from the published file, so the card draws the recording. */\nconst PEAKS = [0.15, 0.05, 0.05, 0.05, 0.04, 0.04, 0.05, 0.2, 0.08, 0.06, 0.05, 0.04, 0.06, 0.06, 0.06, 0.19, 0.07, 0.05, 0.08, 0.05, 0.07, 0.07, 0.08, 0.25, 0.09, 0.07, 0.11, 0.06, 0.08, 0.07, 0.09, 0.37, 0.54, 0.39, 0.56, 0.55, 0.64, 0.46, 0.53, 0.48, 0.52, 0.57, 0.55, 0.58, 0.55, 0.66, 0.66, 0.61, 0.56, 0.6, 0.53, 0.51, 0.51, 0.69, 0.52, 0.67, 0.59, 0.49, 0.53, 0.55, 0.61, 0.53, 0.47, 0.59, 0.64, 0.63, 0.53, 0.6, 0.56, 0.57, 0.55, 0.52, 0.62, 0.51, 0.5, 0.43, 0.48, 0.46, 0.48, 0.58, 0.53, 0.45, 0.54, 0.57, 0.46, 0.52, 0.46, 0.52, 0.54, 0.43, 0.43, 0.47, 0.43, 0.48, 0.43, 0.5, 0.44, 0.49, 0.56, 0.46, 0.53, 0.45, 0.5, 0.45, 0.46, 0.41, 0.55, 0.32, 0.39, 0.3, 0.33, 0.18, 0.1, 0.07, 0.07, 0.04, 0.03, 0.01, 0.01, 0.0, 0.0];\n\nexport default defineComponentPreview({\n title: "Launch Velocity",\n category: "Sound/Beds",\n description: "The fast one: forward motion for a cut with momentum to spend.",\n component: () => <SampleWave peaks={PEAKS} label="bed.velocity" seconds={60} />,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n examples: [{name: "Default", props: {}}],\n});\n',
554
+ target: "videos/components/bed-velocity/bed-velocity.preview.tsx"
389
555
  }
390
556
  ],
391
557
  meta: {
392
558
  kind: "asset",
393
559
  family: "Sound",
394
- namespaced: "@odori/bed-warm-minimal",
560
+ namespaced: "@odori/bed-velocity",
395
561
  contract: {
396
562
  aspectRatios: [
397
563
  "16:9",
398
564
  "9:16",
399
565
  "1:1"
400
566
  ],
401
- recommendedDurationInFrames: 1440,
567
+ recommendedDurationInFrames: 1800,
402
568
  minimumDurationInFrames: 30,
403
569
  entranceFrames: 0,
404
570
  exitFrames: 0,
@@ -1710,6 +1876,48 @@ export default defineComponentPreview({
1710
1876
  }
1711
1877
  }
1712
1878
  },
1879
+ {
1880
+ name: "dissolve-reveal",
1881
+ description: "A noise threshold sweeps across the frame, so content arrives as a torn edge.",
1882
+ registryDependencies: [],
1883
+ files: [
1884
+ {
1885
+ path: "components/dissolve-reveal/dissolve-reveal.tsx",
1886
+ content: 'import type {ReactNode} from "react";\nimport {EffectSurface, defineShaderEffect, numberUniform} from "odori/effects";\n\n/**\n * Anything you put inside it, arriving through a dissolve.\n *\n * A cut usually needs its content to appear, and a fade is the answer everyone\n * reaches for because it is the one CSS gives away. This is the other one: a\n * noise threshold sweeps across the frame, so the picture comes in as a torn\n * edge rather than a uniform brightening.\n *\n * The sweep runs on `progress`, so it always fills exactly the scene it is in\n * however long that scene is. `hold` keeps it complete for the rest of the\n * shot once it has arrived.\n */\nexport type DissolveRevealProps = {\n children: ReactNode;\n /** How much of the scene the arrival takes. 0.4 is the first two fifths. */\n hold?: number;\n /** Size of the torn edge. Small is grainy, large is cloudy. */\n grain?: number;\n /** Width of the soft edge between arrived and not. */\n softness?: number;\n /** Direction the sweep travels. */\n direction?: "up" | "down" | "left" | "right";\n};\n\nconst AXIS: Record<string, [number, number]> = {\n up: [0, -1],\n down: [0, 1],\n left: [-1, 0],\n right: [1, 0],\n};\n\nconst dissolve = defineShaderEffect({\n name: "dissolve-reveal",\n uniforms: {\n hold: numberUniform(0.4),\n grain: numberUniform(9),\n softness: numberUniform(0.22),\n axisX: numberUniform(0),\n axisY: numberUniform(1),\n },\n fragmentShader: `\nuniform float hold;\nuniform float grain;\nuniform float softness;\nuniform float axisX;\nuniform float axisY;\n\nfloat value(vec2 p) {\n vec2 cell = floor(p);\n vec2 f = fract(p);\n f = f * f * (3.0 - 2.0 * f);\n return mix(\n mix(hash(cell), hash(cell + vec2(1.0, 0.0)), f.x),\n mix(hash(cell + vec2(0.0, 1.0)), hash(cell + vec2(1.0, 1.0)), f.x),\n f.y);\n}\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy / resolution;\n /* Where along the sweep this pixel sits, 0 at the leading edge. */\n vec2 axis = vec2(axisX, axisY);\n float along = dot(uv - 0.5, axis) * 0.5 + 0.5;\n\n /* The threshold each pixel has to beat: its position plus some noise, so the\n boundary is torn rather than straight. */\n float threshold = along * 0.7 + value(uv * grain) * 0.3;\n\n float swept = clamp(progress / max(hold, 0.0001), 0.0, 1.0);\n /* A pixel is in once the sweep passes the threshold it was given. Padded at\n both ends by the softness so the frame is genuinely empty at 0 and\n genuinely full at 1, rather than starting or finishing part way. */\n float front = swept * (1.0 + softness * 2.0) - softness;\n float arrived = smoothstep(threshold - softness, threshold + softness, front);\n\n odoriColour = tex(uv) * arrived;\n}`,\n});\n\nexport const DissolveReveal = ({\n children,\n hold = 0.4,\n grain = 9,\n softness = 0.22,\n direction = "up",\n}: DissolveRevealProps) => {\n const [axisX, axisY] = AXIS[direction] ?? AXIS.up;\n return (\n <EffectSurface effects={[dissolve({hold, grain, softness, axisX, axisY})]}>{children}</EffectSurface>\n );\n};\n',
1887
+ target: "videos/components/dissolve-reveal/dissolve-reveal.tsx"
1888
+ },
1889
+ {
1890
+ path: "components/dissolve-reveal/dissolve-reveal.preview.tsx",
1891
+ content: 'import {Fill, useBrand} from "odori";\nimport {defineComponentPreview} from "odori/preview";\nimport {DissolveReveal} from "./dissolve-reveal";\n\nconst Card = () => {\n const brand = useBrand();\n return (\n <Fill\n style={{\n alignItems: "center",\n background: brand.colors.surface,\n color: brand.colors.foreground,\n display: "flex",\n fontFamily: brand.typography.display ?? brand.typography.sans,\n fontSize: 150,\n fontWeight: 700,\n justifyContent: "center",\n }}\n >\n Arrived\n </Fill>\n );\n};\n\nexport default defineComponentPreview({\n title: "Dissolve reveal",\n category: "Motion/Transitions",\n description: "A noise threshold sweeps across the frame, so content arrives as a torn edge.",\n component: DissolveReveal,\n canvas: {width: 1920, height: 1080, duration: "3s"},\n controls: {\n hold: {type: "number", defaultValue: 0.4, min: 0.1, max: 1, step: 0.05},\n grain: {type: "number", defaultValue: 9, min: 2, max: 40, step: 1},\n softness: {type: "number", defaultValue: 0.22, min: 0.02, max: 0.6, step: 0.02},\n direction: {type: "select", defaultValue: "up", options: ["up", "down", "left", "right"]},\n },\n examples: [\n {name: "Up", props: {children: <Card />}},\n {name: "Fine grain", props: {children: <Card />, grain: 30, softness: 0.1}},\n {name: "Cloudy", props: {children: <Card />, grain: 3, softness: 0.45, direction: "right"}},\n ],\n});\n',
1892
+ target: "videos/components/dissolve-reveal/dissolve-reveal.preview.tsx"
1893
+ }
1894
+ ],
1895
+ meta: {
1896
+ kind: "component",
1897
+ family: "Motion",
1898
+ namespaced: "@odori/dissolve-reveal",
1899
+ contract: {
1900
+ aspectRatios: [
1901
+ "16:9",
1902
+ "9:16",
1903
+ "1:1"
1904
+ ],
1905
+ recommendedDurationInFrames: 90,
1906
+ minimumDurationInFrames: 24,
1907
+ entranceFrames: 0,
1908
+ exitFrames: 0,
1909
+ contentLimits: {},
1910
+ reducedMotion: "set hold to a small value so content is present almost at once",
1911
+ requires: {
1912
+ fonts: [],
1913
+ audio: [],
1914
+ graphics: [
1915
+ "webgl2"
1916
+ ]
1917
+ }
1918
+ }
1919
+ }
1920
+ },
1713
1921
  {
1714
1922
  name: "donut-chart",
1715
1923
  description: "Part-to-whole display with a stable center metric.",
@@ -2689,6 +2897,51 @@ export default defineComponentPreview({
2689
2897
  }
2690
2898
  }
2691
2899
  },
2900
+ {
2901
+ name: "liquid-title",
2902
+ description: "A headline displaced by moving glass that settles as the shot goes on.",
2903
+ registryDependencies: [],
2904
+ files: [
2905
+ {
2906
+ path: "components/liquid-title/liquid-title.tsx",
2907
+ content: 'import {Fill, useBrand, useVideo} from "odori";\nimport {EffectSurface, defineShaderEffect, numberUniform} from "odori/effects";\n\n/**\n * A headline seen through moving glass.\n *\n * The text is ordinary HTML, set in the brand\'s display face, and the whole\n * frame is displaced by a noise field before it reaches the screen. That is\n * the point of doing it this way rather than as an SVG filter: the type stays\n * type, so it kerns and wraps and can be read by anything inspecting the DOM,\n * and only the last step is pixels.\n *\n * The distortion settles as the shot goes on, so a title arrives unreadable\n * and resolves, which is usually what this is for. Set `settle` to 0 to keep\n * it moving throughout.\n */\nexport type LiquidTitleProps = {\n title: string;\n /** A quieter line under the headline. */\n detail?: string;\n /** How far the glass pushes the picture, in pixels at 1080p. */\n amount?: number;\n /** Size of the ripples. Larger is slower and wider. */\n scale?: number;\n /** 1 resolves to clear by the end of the shot, 0 never settles. */\n settle?: number;\n};\n\nconst liquid = defineShaderEffect({\n name: "liquid-title",\n uniforms: {amount: numberUniform(28), scale: numberUniform(2.2), settle: numberUniform(1)},\n fragmentShader: `\nuniform float amount;\nuniform float scale;\nuniform float settle;\n\nfloat value(vec2 p) {\n vec2 cell = floor(p);\n vec2 f = fract(p);\n f = f * f * (3.0 - 2.0 * f);\n return mix(\n mix(hash(cell), hash(cell + vec2(1.0, 0.0)), f.x),\n mix(hash(cell + vec2(0.0, 1.0)), hash(cell + vec2(1.0, 1.0)), f.x),\n f.y);\n}\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy / resolution;\n float t = seconds * 0.6;\n\n /* Two noise samples a little apart give a direction to push in, rather than\n one sample which would only ever push along one axis. */\n vec2 p = uv * scale + vec2(t, -t * 0.7);\n vec2 push = vec2(value(p) - 0.5, value(p + 31.7) - 0.5);\n\n /* Eased so it lets go quickly and then creeps to nothing, which reads as\n settling rather than as a fade. */\n float strength = mix(1.0, pow(1.0 - progress, 2.2), settle);\n odoriColour = tex(uv + push * (amount / resolution) * strength * 2.0);\n}`,\n});\n\nexport const LiquidTitle = ({title, detail, amount = 28, scale = 2.2, settle = 1}: LiquidTitleProps) => {\n const brand = useBrand();\n const {width, height} = useVideo();\n const unit = Math.min(width, height) / 1080;\n\n return (\n <EffectSurface effects={[liquid({amount, scale, settle})]}>\n <Fill\n style={{\n alignItems: "center",\n background: brand.colors.background,\n display: "flex",\n flexDirection: "column",\n gap: 28 * unit,\n height,\n justifyContent: "center",\n padding: 96 * unit,\n width,\n }}\n >\n <h1\n style={{\n color: brand.colors.foreground,\n fontFamily: brand.typography.display ?? brand.typography.sans,\n fontSize: 168 * unit,\n fontWeight: 700,\n letterSpacing: "-0.035em",\n lineHeight: 1.02,\n margin: 0,\n textAlign: "center",\n }}\n >\n {title}\n </h1>\n {detail ? (\n <p\n style={{\n color: brand.colors.muted,\n fontFamily: brand.typography.sans,\n fontSize: 44 * unit,\n margin: 0,\n textAlign: "center",\n }}\n >\n {detail}\n </p>\n ) : null}\n </Fill>\n </EffectSurface>\n );\n};\n',
2908
+ target: "videos/components/liquid-title/liquid-title.tsx"
2909
+ },
2910
+ {
2911
+ path: "components/liquid-title/liquid-title.preview.tsx",
2912
+ content: 'import {defineComponentPreview} from "odori/preview";\nimport {LiquidTitle} from "./liquid-title";\n\nexport default defineComponentPreview({\n title: "Liquid title",\n category: "Typography/Titles",\n description: "A headline displaced by moving glass that settles as the shot goes on.",\n component: LiquidTitle,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n title: {type: "text", defaultValue: "Author the story.", maxLength: 48},\n detail: {type: "text", defaultValue: "Frame accurate, every render.", maxLength: 64},\n amount: {type: "number", defaultValue: 28, min: 0, max: 90, step: 2},\n scale: {type: "number", defaultValue: 2.2, min: 0.5, max: 8, step: 0.1},\n settle: {type: "number", defaultValue: 1, min: 0, max: 1, step: 0.1},\n },\n examples: [\n {name: "Settles", props: {}},\n {name: "Never settles", props: {settle: 0, amount: 18}},\n {name: "Heat haze", props: {amount: 12, scale: 6}},\n ],\n});\n',
2913
+ target: "videos/components/liquid-title/liquid-title.preview.tsx"
2914
+ }
2915
+ ],
2916
+ meta: {
2917
+ kind: "component",
2918
+ family: "Typography",
2919
+ namespaced: "@odori/liquid-title",
2920
+ contract: {
2921
+ aspectRatios: [
2922
+ "16:9",
2923
+ "9:16",
2924
+ "1:1"
2925
+ ],
2926
+ recommendedDurationInFrames: 120,
2927
+ minimumDurationInFrames: 30,
2928
+ entranceFrames: 0,
2929
+ exitFrames: 0,
2930
+ contentLimits: {},
2931
+ reducedMotion: "set amount to 0 and the type is undisturbed",
2932
+ requires: {
2933
+ fonts: [
2934
+ "display",
2935
+ "sans"
2936
+ ],
2937
+ audio: [],
2938
+ graphics: [
2939
+ "webgl2"
2940
+ ]
2941
+ }
2942
+ }
2943
+ }
2944
+ },
2692
2945
  {
2693
2946
  name: "log-stream",
2694
2947
  description: "Paced runtime logs with levels and bounded scrolling.",
@@ -2950,6 +3203,48 @@ export const MarkStack = ({color, size = 72, drawFrames = 24, style}: MarkProps)
2950
3203
  }
2951
3204
  }
2952
3205
  },
3206
+ {
3207
+ name: "mesh-gradient",
3208
+ description: "Four colour centres blended by inverse distance, so they pass through each other cleanly.",
3209
+ registryDependencies: [],
3210
+ files: [
3211
+ {
3212
+ path: "components/mesh-gradient/mesh-gradient.tsx",
3213
+ content: 'import {Fill, useBrand, useVideo} from "odori";\nimport {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from "odori/effects";\n\n/**\n * Four colour centres drifting past each other, blended on the GPU.\n *\n * The sibling of `gradient-field`, which does a similar job with blurred DOM\n * and repeats exactly across a cycle. This one is a shader: the centres blend\n * by inverse distance rather than by stacking blurs, so they pass through each\n * other without an edge and the falloff is a control rather than a blur\n * radius. Reach for the DOM one when a background has to loop seamlessly, and\n * this one when it has to look like a mesh gradient.\n *\n * Colours default to the brand, so it already looks like the project.\n */\nexport type MeshGradientProps = {\n /** Two to four colours. Beyond four the extra ones are ignored. */\n colors?: string[];\n /** How far the centres travel. 0 holds them still. */\n drift?: number;\n /** How sharply one colour gives way to the next. */\n falloff?: number;\n /** Grain over the blend, which keeps a wide gradient from banding. */\n grain?: number;\n};\n\nconst hexToRgb = (hex: string): [number, number, number] => {\n const clean = hex.replace("#", "");\n const full = clean.length === 3 ? [...clean].map((c) => c + c).join("") : clean;\n const value = Number.parseInt(full.slice(0, 6), 16);\n return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];\n};\n\nconst field = defineShaderEffect({\n name: "mesh-gradient",\n uniforms: {\n drift: numberUniform(1),\n falloff: numberUniform(1.6),\n grain: numberUniform(0.03),\n colourA: vec3Uniform([0.11, 0.13, 0.22]),\n colourB: vec3Uniform([0.35, 0.22, 0.55]),\n colourC: vec3Uniform([0.12, 0.35, 0.45]),\n colourD: vec3Uniform([0.05, 0.06, 0.1]),\n },\n fragmentShader: `\nuniform float drift;\nuniform float falloff;\nuniform float grain;\nuniform vec3 colourA;\nuniform vec3 colourB;\nuniform vec3 colourC;\nuniform vec3 colourD;\n\n/* Inverse-distance blending rather than a gradient stop, so the centres can\n pass through each other without a visible edge. */\nvec3 blend(vec2 uv, vec2 p, vec3 c, inout float total) {\n float w = 1.0 / (pow(distance(uv, p), falloff) + 0.0001);\n total += w;\n return c * w;\n}\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy / resolution;\n /* Corrected for shape, or the centres travel in ovals on a wide frame. */\n vec2 aspect = vec2(resolution.x / resolution.y, 1.0);\n uv *= aspect;\n float t = progress * 6.2831853 * drift;\n\n float total = 0.0;\n vec3 sum = vec3(0.0);\n sum += blend(uv, vec2(0.28, 0.30) * aspect + vec2(cos(t), sin(t * 0.9)) * 0.16 * drift, colourA, total);\n sum += blend(uv, vec2(0.76, 0.26) * aspect + vec2(cos(t * 1.3 + 2.0), sin(t * 0.7)) * 0.14 * drift, colourB, total);\n sum += blend(uv, vec2(0.30, 0.74) * aspect + vec2(sin(t * 0.8), cos(t * 1.1)) * 0.15 * drift, colourC, total);\n sum += blend(uv, vec2(0.74, 0.78) * aspect + vec2(sin(t * 1.2 + 1.0), cos(t * 0.6)) * 0.13 * drift, colourD, total);\n\n vec3 colour = sum / total;\n /* A wide gradient bands across 8-bit output without this. */\n colour += (hash(gl_FragCoord.xy + frame) - 0.5) * grain;\n odoriColour = vec4(colour, 1.0);\n}`,\n});\n\nexport const MeshGradient = ({colors, drift = 1, falloff = 1.6, grain = 0.03}: MeshGradientProps) => {\n const brand = useBrand();\n const {width, height} = useVideo();\n const palette = colors?.length\n ? colors\n : [brand.colors.accent, brand.colors.surface, brand.colors.muted, brand.colors.background];\n const [a, b, c, d] = [0, 1, 2, 3].map((index) => hexToRgb(palette[index % palette.length]));\n\n return (\n <EffectSurface effects={[field({drift, falloff, grain, colourA: a, colourB: b, colourC: c, colourD: d})]}>\n {/* The surface needs something to photograph even where the shader\n ignores it, and a flat fill is the cheapest thing to capture. */}\n <Fill style={{background: brand.colors.background, height, width}} />\n </EffectSurface>\n );\n};\n',
3214
+ target: "videos/components/mesh-gradient/mesh-gradient.tsx"
3215
+ },
3216
+ {
3217
+ path: "components/mesh-gradient/mesh-gradient.preview.tsx",
3218
+ content: 'import {defineComponentPreview} from "odori/preview";\nimport {MeshGradient} from "./mesh-gradient";\n\nexport default defineComponentPreview({\n title: "Mesh gradient",\n category: "Foundation/Backgrounds",\n description: "Four colour centres blended by inverse distance, so they pass through each other cleanly.",\n component: MeshGradient,\n canvas: {width: 1920, height: 1080, duration: "6s"},\n controls: {\n drift: {type: "number", defaultValue: 1, min: 0, max: 3, step: 0.1},\n falloff: {type: "number", defaultValue: 1.6, min: 0.6, max: 4, step: 0.1},\n grain: {type: "number", defaultValue: 0.03, min: 0, max: 0.12, step: 0.01},\n },\n examples: [\n {name: "Brand", props: {}},\n {name: "Still", props: {drift: 0}},\n {name: "Sunset", props: {colors: ["#2b1055", "#7597de", "#ff7e5f", "#120318"]}},\n ],\n});\n',
3219
+ target: "videos/components/mesh-gradient/mesh-gradient.preview.tsx"
3220
+ }
3221
+ ],
3222
+ meta: {
3223
+ kind: "component",
3224
+ family: "Foundation",
3225
+ namespaced: "@odori/mesh-gradient",
3226
+ contract: {
3227
+ aspectRatios: [
3228
+ "16:9",
3229
+ "9:16",
3230
+ "1:1"
3231
+ ],
3232
+ recommendedDurationInFrames: 180,
3233
+ minimumDurationInFrames: 30,
3234
+ entranceFrames: 0,
3235
+ exitFrames: 0,
3236
+ contentLimits: {},
3237
+ reducedMotion: "set drift to 0 and the field holds still",
3238
+ requires: {
3239
+ fonts: [],
3240
+ audio: [],
3241
+ graphics: [
3242
+ "webgl2"
3243
+ ]
3244
+ }
3245
+ }
3246
+ }
3247
+ },
2953
3248
  {
2954
3249
  name: "metric-callout",
2955
3250
  description: "A single counted number with a supporting label.",
@@ -3041,6 +3336,48 @@ export const MarkStack = ({color, size = 72, drawFrames = 24, style}: MarkProps)
3041
3336
  }
3042
3337
  }
3043
3338
  },
3339
+ {
3340
+ name: "noise-field",
3341
+ description: "Domain-warped fractal noise that folds through itself, from soft cloud to hard contour.",
3342
+ registryDependencies: [],
3343
+ files: [
3344
+ {
3345
+ path: "components/noise-field/noise-field.tsx",
3346
+ content: 'import {Fill, useBrand, useVideo} from "odori";\nimport {EffectSurface, defineShaderEffect, numberUniform, vec3Uniform} from "odori/effects";\n\n/**\n * Flowing fractal noise, for a background with structure rather than a blur.\n *\n * Where a gradient field is four soft centres, this is layered value noise:\n * wisps and filaments that fold through each other. It reads as smoke, tide,\n * or contour depending on how hard the contrast is driven, which is the one\n * control worth having.\n *\n * The flow is a function of `seconds`, so a longer cut travels further rather\n * than looping, and the noise itself comes from the prelude\'s integer hash so\n * two machines agree on it.\n */\nexport type NoiseFieldProps = {\n /** Two colours the noise sits between. Defaults to the brand. */\n colors?: [string, string];\n /** How fast the field folds through itself. */\n speed?: number;\n /** Size of the largest features, in screens. */\n scale?: number;\n /** 0 is a soft cloud, 1 is hard contour lines. */\n contrast?: number;\n};\n\nconst hexToRgb = (hex: string): [number, number, number] => {\n const clean = hex.replace("#", "");\n const full = clean.length === 3 ? [...clean].map((c) => c + c).join("") : clean;\n const value = Number.parseInt(full.slice(0, 6), 16);\n return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];\n};\n\nconst field = defineShaderEffect({\n name: "noise-field",\n uniforms: {\n speed: numberUniform(0.25),\n scale: numberUniform(3),\n contrast: numberUniform(0.3),\n low: vec3Uniform([0.04, 0.05, 0.09]),\n high: vec3Uniform([0.45, 0.55, 0.85]),\n },\n fragmentShader: `\nuniform float speed;\nuniform float scale;\nuniform float contrast;\nuniform vec3 low;\nuniform vec3 high;\n\n/* Value noise: the integer hash at four lattice corners, smoothed between.\n Not gradient noise, which needs more taps for a look this will blur anyway. */\nfloat value(vec2 p) {\n vec2 cell = floor(p);\n vec2 f = fract(p);\n f = f * f * (3.0 - 2.0 * f);\n float a = hash(cell);\n float b = hash(cell + vec2(1.0, 0.0));\n float c = hash(cell + vec2(0.0, 1.0));\n float d = hash(cell + vec2(1.0, 1.0));\n return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);\n}\n\n/* Five octaves, each half the size and half the weight of the last. */\nfloat fbm(vec2 p) {\n float sum = 0.0;\n float weight = 0.5;\n for (int i = 0; i < 5; i++) {\n sum += value(p) * weight;\n p = p * 2.03 + 17.3;\n weight *= 0.5;\n }\n return sum;\n}\n\nvoid main() {\n vec2 uv = gl_FragCoord.xy / resolution;\n uv.x *= resolution.x / resolution.y;\n float t = seconds * speed;\n\n /* Domain warping: the field is sampled at a position the field itself\n decides, which is what turns smooth noise into something that curls. */\n vec2 q = vec2(fbm(uv * scale + t), fbm(uv * scale + vec2(5.2, 1.3) - t));\n float n = fbm(uv * scale + q * 1.8);\n\n /* Contrast pushes the midpoint into a hard edge, so the same field reads as\n cloud at 0 and as a contour map at 1. */\n n = mix(n, smoothstep(0.45, 0.55, n), contrast);\n odoriColour = vec4(mix(low, high, clamp(n, 0.0, 1.0)), 1.0);\n}`,\n});\n\nexport const NoiseField = ({colors, speed = 0.25, scale = 3, contrast = 0.3}: NoiseFieldProps) => {\n const brand = useBrand();\n const {width, height} = useVideo();\n const [low, high] = colors ?? [brand.colors.background, brand.colors.accent];\n\n return (\n <EffectSurface effects={[field({speed, scale, contrast, low: hexToRgb(low), high: hexToRgb(high)})]}>\n <Fill style={{background: brand.colors.background, height, width}} />\n </EffectSurface>\n );\n};\n',
3347
+ target: "videos/components/noise-field/noise-field.tsx"
3348
+ },
3349
+ {
3350
+ path: "components/noise-field/noise-field.preview.tsx",
3351
+ content: 'import {defineComponentPreview} from "odori/preview";\nimport {NoiseField} from "./noise-field";\n\nexport default defineComponentPreview({\n title: "Noise field",\n category: "Foundation/Backgrounds",\n description: "Domain-warped fractal noise that folds through itself, from soft cloud to hard contour.",\n component: NoiseField,\n canvas: {width: 1920, height: 1080, duration: "6s"},\n controls: {\n speed: {type: "number", defaultValue: 0.25, min: 0, max: 1.5, step: 0.05},\n scale: {type: "number", defaultValue: 3, min: 1, max: 10, step: 0.5},\n contrast: {type: "number", defaultValue: 0.3, min: 0, max: 1, step: 0.05},\n },\n examples: [\n {name: "Brand", props: {}},\n {name: "Contour", props: {contrast: 1, scale: 4}},\n {name: "Tide", props: {colors: ["#04121a", "#4fd1c5"], scale: 2, speed: 0.15}},\n ],\n});\n',
3352
+ target: "videos/components/noise-field/noise-field.preview.tsx"
3353
+ }
3354
+ ],
3355
+ meta: {
3356
+ kind: "component",
3357
+ family: "Foundation",
3358
+ namespaced: "@odori/noise-field",
3359
+ contract: {
3360
+ aspectRatios: [
3361
+ "16:9",
3362
+ "9:16",
3363
+ "1:1"
3364
+ ],
3365
+ recommendedDurationInFrames: 180,
3366
+ minimumDurationInFrames: 30,
3367
+ entranceFrames: 0,
3368
+ exitFrames: 0,
3369
+ contentLimits: {},
3370
+ reducedMotion: "set speed to 0 and the field holds still",
3371
+ requires: {
3372
+ fonts: [],
3373
+ audio: [],
3374
+ graphics: [
3375
+ "webgl2"
3376
+ ]
3377
+ }
3378
+ }
3379
+ }
3380
+ },
3044
3381
  {
3045
3382
  name: "notify",
3046
3383
  description: "A soft bell for a toast or a message.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@odori/cli",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "type": "module",
5
5
  "description": "The odori command line: discovery, Studio, component installation, stills, tests, and export jobs.",
6
6
  "license": "Apache-2.0",
@@ -28,7 +28,7 @@
28
28
  "playwright-core": "1.55.0",
29
29
  "tsx": "4.20.5",
30
30
  "vite": "7.3.0",
31
- "odori": "0.0.7"
31
+ "odori": "0.0.8"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/node": "22.19.0",
@@ -36,7 +36,7 @@
36
36
  "@types/react-dom": "19.2.3",
37
37
  "tsup": "^8.5.1",
38
38
  "typescript": "5.9.3",
39
- "@odori/registry": "0.0.7"
39
+ "@odori/registry": "0.0.8"
40
40
  },
41
41
  "publishConfig": {
42
42
  "access": "public"
package/src/audio-mix.ts CHANGED
@@ -126,7 +126,12 @@ export const buildAudioFilter = (
126
126
  // loudnorm resamples to its own rate and can drop the layout on the way
127
127
  // out, so the last link states the output format rather than negotiating
128
128
  // it with whatever encoder is downstream.
129
- `[mixed]loudnorm=I=${targetLufs}:TP=-1.5:LRA=11,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[audio]`,
129
+ /* No LRA target: a mix cannot be given a loudness range it does not have,
130
+ so asking for one states an intent the filter cannot honour. Measured
131
+ against the beds here it changes nothing either way, which is the point
132
+ — the range comes from the material, and asking for eleven from a bed
133
+ mastered to two only hides that. */
134
+ `[mixed]loudnorm=I=${targetLufs}:TP=-1.5,aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo[audio]`,
130
135
  );
131
136
 
132
137
  return {filter: parts.join(";"), label: "[audio]"};