@odori/cli 0.0.7 → 0.0.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-STVORYOF.js → chunk-TDM65HRW.js} +1575 -1074
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +52 -4
- package/dist/index.js +1 -1
- package/dist/{registry-snapshot-ADKSTGDR.js → registry-snapshot-TKH2KAC3.js} +362 -25
- package/package.json +3 -3
- package/src/audio-mix.ts +6 -1
- package/src/cli.ts +53 -4
- package/src/commands/bed.ts +224 -0
- package/src/commands/dev.ts +80 -0
- package/src/commands/doctor.ts +20 -0
- package/src/commands/exportVideo.ts +15 -0
- package/src/commands/integrations.ts +26 -0
- package/src/commands/narrate.ts +74 -0
- package/src/commands/test.ts +55 -16
- package/src/config.ts +5 -0
- package/src/jobs.ts +1 -1
- package/src/keystore.ts +76 -0
- package/src/providers.ts +173 -0
- package/src/registry-snapshot.json +362 -25
- package/src/render.ts +69 -22
- package/src/server.ts +10 -0
- package/studio/src/Studio.tsx +1 -1
- package/studio/src/components/GenerateBed.tsx +148 -0
- package/studio/src/components/Settings.tsx +266 -50
- package/studio/src/components/ui.tsx +11 -1
- package/studio/src/integrations.ts +29 -0
- package/studio/src/studio.css +276 -3
- package/studio/src/views/AssetsView.tsx +6 -0
- package/studio/src/virtual.d.ts +1 -0
|
@@ -257,27 +257,110 @@
|
|
|
257
257
|
}
|
|
258
258
|
},
|
|
259
259
|
{
|
|
260
|
-
"name": "bed-
|
|
261
|
-
"description": "
|
|
260
|
+
"name": "bed-drift",
|
|
261
|
+
"description": "A pad that breathes instead of keeping time, for narration to pace.",
|
|
262
262
|
"registryDependencies": [],
|
|
263
263
|
"files": [
|
|
264
264
|
{
|
|
265
|
-
"path": "components/bed-
|
|
266
|
-
"content": "import {
|
|
267
|
-
"target": "videos/components/bed-
|
|
265
|
+
"path": "components/bed-drift/bed-drift.tsx",
|
|
266
|
+
"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 — no pulse, no beat —\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",
|
|
267
|
+
"target": "videos/components/bed-drift/bed-drift.tsx"
|
|
268
|
+
},
|
|
269
|
+
{
|
|
270
|
+
"path": "components/bed-drift/bed-drift.preview.tsx",
|
|
271
|
+
"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",
|
|
272
|
+
"target": "videos/components/bed-drift/bed-drift.preview.tsx"
|
|
273
|
+
}
|
|
274
|
+
],
|
|
275
|
+
"meta": {
|
|
276
|
+
"kind": "cue",
|
|
277
|
+
"family": "Sound",
|
|
278
|
+
"namespaced": "@odori/bed-drift",
|
|
279
|
+
"contract": {
|
|
280
|
+
"aspectRatios": [
|
|
281
|
+
"16:9",
|
|
282
|
+
"9:16",
|
|
283
|
+
"1:1"
|
|
284
|
+
],
|
|
285
|
+
"recommendedDurationInFrames": 480,
|
|
286
|
+
"minimumDurationInFrames": 30,
|
|
287
|
+
"entranceFrames": 0,
|
|
288
|
+
"exitFrames": 0,
|
|
289
|
+
"contentLimits": {},
|
|
290
|
+
"reducedMotion": "the waveform renders without a playhead",
|
|
291
|
+
"requires": {
|
|
292
|
+
"fonts": [
|
|
293
|
+
"mono"
|
|
294
|
+
],
|
|
295
|
+
"audio": []
|
|
296
|
+
},
|
|
297
|
+
"loops": true
|
|
298
|
+
},
|
|
299
|
+
"cue": {
|
|
300
|
+
"name": "bed.drift",
|
|
301
|
+
"export": "bedDrift"
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
{
|
|
306
|
+
"name": "bed-horizons",
|
|
307
|
+
"description": "Steady and wide, for a cut that wants scale without hurry.",
|
|
308
|
+
"registryDependencies": [],
|
|
309
|
+
"files": [
|
|
310
|
+
{
|
|
311
|
+
"path": "components/bed-horizons/bed-horizons.preview.tsx",
|
|
312
|
+
"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",
|
|
313
|
+
"target": "videos/components/bed-horizons/bed-horizons.preview.tsx"
|
|
314
|
+
}
|
|
315
|
+
],
|
|
316
|
+
"meta": {
|
|
317
|
+
"kind": "asset",
|
|
318
|
+
"family": "Sound",
|
|
319
|
+
"namespaced": "@odori/bed-horizons",
|
|
320
|
+
"contract": {
|
|
321
|
+
"aspectRatios": [
|
|
322
|
+
"16:9",
|
|
323
|
+
"9:16",
|
|
324
|
+
"1:1"
|
|
325
|
+
],
|
|
326
|
+
"recommendedDurationInFrames": 1800,
|
|
327
|
+
"minimumDurationInFrames": 30,
|
|
328
|
+
"entranceFrames": 0,
|
|
329
|
+
"exitFrames": 0,
|
|
330
|
+
"contentLimits": {},
|
|
331
|
+
"reducedMotion": "the waveform renders without a playhead",
|
|
332
|
+
"requires": {
|
|
333
|
+
"fonts": [
|
|
334
|
+
"mono"
|
|
335
|
+
],
|
|
336
|
+
"audio": []
|
|
337
|
+
},
|
|
338
|
+
"loops": false
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
{
|
|
343
|
+
"name": "bed-innovation",
|
|
344
|
+
"description": "Confident and even, for a product walking through its paces.",
|
|
345
|
+
"registryDependencies": [],
|
|
346
|
+
"files": [
|
|
347
|
+
{
|
|
348
|
+
"path": "components/bed-innovation/bed-innovation.preview.tsx",
|
|
349
|
+
"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",
|
|
350
|
+
"target": "videos/components/bed-innovation/bed-innovation.preview.tsx"
|
|
268
351
|
}
|
|
269
352
|
],
|
|
270
353
|
"meta": {
|
|
271
354
|
"kind": "asset",
|
|
272
355
|
"family": "Sound",
|
|
273
|
-
"namespaced": "@odori/bed-
|
|
356
|
+
"namespaced": "@odori/bed-innovation",
|
|
274
357
|
"contract": {
|
|
275
358
|
"aspectRatios": [
|
|
276
359
|
"16:9",
|
|
277
360
|
"9:16",
|
|
278
361
|
"1:1"
|
|
279
362
|
],
|
|
280
|
-
"recommendedDurationInFrames":
|
|
363
|
+
"recommendedDurationInFrames": 1800,
|
|
281
364
|
"minimumDurationInFrames": 30,
|
|
282
365
|
"entranceFrames": 0,
|
|
283
366
|
"exitFrames": 0,
|
|
@@ -293,9 +376,92 @@
|
|
|
293
376
|
}
|
|
294
377
|
}
|
|
295
378
|
},
|
|
379
|
+
{
|
|
380
|
+
"name": "bed-launch",
|
|
381
|
+
"description": "A build with a destination, for the run-up to a reveal.",
|
|
382
|
+
"registryDependencies": [],
|
|
383
|
+
"files": [
|
|
384
|
+
{
|
|
385
|
+
"path": "components/bed-launch/bed-launch.preview.tsx",
|
|
386
|
+
"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",
|
|
387
|
+
"target": "videos/components/bed-launch/bed-launch.preview.tsx"
|
|
388
|
+
}
|
|
389
|
+
],
|
|
390
|
+
"meta": {
|
|
391
|
+
"kind": "asset",
|
|
392
|
+
"family": "Sound",
|
|
393
|
+
"namespaced": "@odori/bed-launch",
|
|
394
|
+
"contract": {
|
|
395
|
+
"aspectRatios": [
|
|
396
|
+
"16:9",
|
|
397
|
+
"9:16",
|
|
398
|
+
"1:1"
|
|
399
|
+
],
|
|
400
|
+
"recommendedDurationInFrames": 1800,
|
|
401
|
+
"minimumDurationInFrames": 30,
|
|
402
|
+
"entranceFrames": 0,
|
|
403
|
+
"exitFrames": 0,
|
|
404
|
+
"contentLimits": {},
|
|
405
|
+
"reducedMotion": "the waveform renders without a playhead",
|
|
406
|
+
"requires": {
|
|
407
|
+
"fonts": [
|
|
408
|
+
"mono"
|
|
409
|
+
],
|
|
410
|
+
"audio": []
|
|
411
|
+
},
|
|
412
|
+
"loops": false
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
"name": "bed-lift",
|
|
418
|
+
"description": "A held chord with a quiet shimmer circling above it, for a cut with somewhere to be.",
|
|
419
|
+
"registryDependencies": [],
|
|
420
|
+
"files": [
|
|
421
|
+
{
|
|
422
|
+
"path": "components/bed-lift/bed-lift.tsx",
|
|
423
|
+
"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 — it\n * gives a cut forward motion without a drum in sight — 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",
|
|
424
|
+
"target": "videos/components/bed-lift/bed-lift.tsx"
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
"path": "components/bed-lift/bed-lift.preview.tsx",
|
|
428
|
+
"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",
|
|
429
|
+
"target": "videos/components/bed-lift/bed-lift.preview.tsx"
|
|
430
|
+
}
|
|
431
|
+
],
|
|
432
|
+
"meta": {
|
|
433
|
+
"kind": "cue",
|
|
434
|
+
"family": "Sound",
|
|
435
|
+
"namespaced": "@odori/bed-lift",
|
|
436
|
+
"contract": {
|
|
437
|
+
"aspectRatios": [
|
|
438
|
+
"16:9",
|
|
439
|
+
"9:16",
|
|
440
|
+
"1:1"
|
|
441
|
+
],
|
|
442
|
+
"recommendedDurationInFrames": 300,
|
|
443
|
+
"minimumDurationInFrames": 30,
|
|
444
|
+
"entranceFrames": 0,
|
|
445
|
+
"exitFrames": 0,
|
|
446
|
+
"contentLimits": {},
|
|
447
|
+
"reducedMotion": "the waveform renders without a playhead",
|
|
448
|
+
"requires": {
|
|
449
|
+
"fonts": [
|
|
450
|
+
"mono"
|
|
451
|
+
],
|
|
452
|
+
"audio": []
|
|
453
|
+
},
|
|
454
|
+
"loops": true
|
|
455
|
+
},
|
|
456
|
+
"cue": {
|
|
457
|
+
"name": "bed.lift",
|
|
458
|
+
"export": "bedLift"
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
},
|
|
296
462
|
{
|
|
297
463
|
"name": "bed-pulse",
|
|
298
|
-
"description": "A
|
|
464
|
+
"description": "A held chord with a heartbeat, for momentum without a groove.",
|
|
299
465
|
"registryDependencies": [],
|
|
300
466
|
"files": [
|
|
301
467
|
{
|
|
@@ -305,7 +471,7 @@
|
|
|
305
471
|
},
|
|
306
472
|
{
|
|
307
473
|
"path": "components/bed-pulse/bed-pulse.preview.tsx",
|
|
308
|
-
"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
|
|
474
|
+
"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",
|
|
309
475
|
"target": "videos/components/bed-pulse/bed-pulse.preview.tsx"
|
|
310
476
|
}
|
|
311
477
|
],
|
|
@@ -320,7 +486,7 @@
|
|
|
320
486
|
"1:1"
|
|
321
487
|
],
|
|
322
488
|
"recommendedDurationInFrames": 171,
|
|
323
|
-
"minimumDurationInFrames":
|
|
489
|
+
"minimumDurationInFrames": 30,
|
|
324
490
|
"entranceFrames": 0,
|
|
325
491
|
"exitFrames": 0,
|
|
326
492
|
"contentLimits": {},
|
|
@@ -340,27 +506,27 @@
|
|
|
340
506
|
}
|
|
341
507
|
},
|
|
342
508
|
{
|
|
343
|
-
"name": "bed-
|
|
344
|
-
"description": "
|
|
509
|
+
"name": "bed-tomorrow",
|
|
510
|
+
"description": "Bright and open, for showing something new without shouting.",
|
|
345
511
|
"registryDependencies": [],
|
|
346
512
|
"files": [
|
|
347
513
|
{
|
|
348
|
-
"path": "components/bed-
|
|
349
|
-
"content": "import {SampleWave, defineComponentPreview} from \"odori/preview\";\n\n/** Peaks measured from the published file, so the card draws the recording. */\nconst PEAKS = [
|
|
350
|
-
"target": "videos/components/bed-
|
|
514
|
+
"path": "components/bed-tomorrow/bed-tomorrow.preview.tsx",
|
|
515
|
+
"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",
|
|
516
|
+
"target": "videos/components/bed-tomorrow/bed-tomorrow.preview.tsx"
|
|
351
517
|
}
|
|
352
518
|
],
|
|
353
519
|
"meta": {
|
|
354
520
|
"kind": "asset",
|
|
355
521
|
"family": "Sound",
|
|
356
|
-
"namespaced": "@odori/bed-
|
|
522
|
+
"namespaced": "@odori/bed-tomorrow",
|
|
357
523
|
"contract": {
|
|
358
524
|
"aspectRatios": [
|
|
359
525
|
"16:9",
|
|
360
526
|
"9:16",
|
|
361
527
|
"1:1"
|
|
362
528
|
],
|
|
363
|
-
"recommendedDurationInFrames":
|
|
529
|
+
"recommendedDurationInFrames": 1800,
|
|
364
530
|
"minimumDurationInFrames": 30,
|
|
365
531
|
"entranceFrames": 0,
|
|
366
532
|
"exitFrames": 0,
|
|
@@ -377,27 +543,27 @@
|
|
|
377
543
|
}
|
|
378
544
|
},
|
|
379
545
|
{
|
|
380
|
-
"name": "bed-
|
|
381
|
-
"description": "
|
|
546
|
+
"name": "bed-velocity",
|
|
547
|
+
"description": "The fast one: forward motion for a cut with momentum to spend.",
|
|
382
548
|
"registryDependencies": [],
|
|
383
549
|
"files": [
|
|
384
550
|
{
|
|
385
|
-
"path": "components/bed-
|
|
386
|
-
"content": "import {SampleWave, defineComponentPreview} from \"odori/preview\";\n\n/** Peaks measured from the published file, so the card draws the recording. */\nconst PEAKS = [
|
|
387
|
-
"target": "videos/components/bed-
|
|
551
|
+
"path": "components/bed-velocity/bed-velocity.preview.tsx",
|
|
552
|
+
"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",
|
|
553
|
+
"target": "videos/components/bed-velocity/bed-velocity.preview.tsx"
|
|
388
554
|
}
|
|
389
555
|
],
|
|
390
556
|
"meta": {
|
|
391
557
|
"kind": "asset",
|
|
392
558
|
"family": "Sound",
|
|
393
|
-
"namespaced": "@odori/bed-
|
|
559
|
+
"namespaced": "@odori/bed-velocity",
|
|
394
560
|
"contract": {
|
|
395
561
|
"aspectRatios": [
|
|
396
562
|
"16:9",
|
|
397
563
|
"9:16",
|
|
398
564
|
"1:1"
|
|
399
565
|
],
|
|
400
|
-
"recommendedDurationInFrames":
|
|
566
|
+
"recommendedDurationInFrames": 1800,
|
|
401
567
|
"minimumDurationInFrames": 30,
|
|
402
568
|
"entranceFrames": 0,
|
|
403
569
|
"exitFrames": 0,
|
|
@@ -1659,6 +1825,48 @@
|
|
|
1659
1825
|
}
|
|
1660
1826
|
}
|
|
1661
1827
|
},
|
|
1828
|
+
{
|
|
1829
|
+
"name": "dissolve-reveal",
|
|
1830
|
+
"description": "A noise threshold sweeps across the frame, so content arrives as a torn edge.",
|
|
1831
|
+
"registryDependencies": [],
|
|
1832
|
+
"files": [
|
|
1833
|
+
{
|
|
1834
|
+
"path": "components/dissolve-reveal/dissolve-reveal.tsx",
|
|
1835
|
+
"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",
|
|
1836
|
+
"target": "videos/components/dissolve-reveal/dissolve-reveal.tsx"
|
|
1837
|
+
},
|
|
1838
|
+
{
|
|
1839
|
+
"path": "components/dissolve-reveal/dissolve-reveal.preview.tsx",
|
|
1840
|
+
"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",
|
|
1841
|
+
"target": "videos/components/dissolve-reveal/dissolve-reveal.preview.tsx"
|
|
1842
|
+
}
|
|
1843
|
+
],
|
|
1844
|
+
"meta": {
|
|
1845
|
+
"kind": "component",
|
|
1846
|
+
"family": "Motion",
|
|
1847
|
+
"namespaced": "@odori/dissolve-reveal",
|
|
1848
|
+
"contract": {
|
|
1849
|
+
"aspectRatios": [
|
|
1850
|
+
"16:9",
|
|
1851
|
+
"9:16",
|
|
1852
|
+
"1:1"
|
|
1853
|
+
],
|
|
1854
|
+
"recommendedDurationInFrames": 90,
|
|
1855
|
+
"minimumDurationInFrames": 24,
|
|
1856
|
+
"entranceFrames": 0,
|
|
1857
|
+
"exitFrames": 0,
|
|
1858
|
+
"contentLimits": {},
|
|
1859
|
+
"reducedMotion": "set hold to a small value so content is present almost at once",
|
|
1860
|
+
"requires": {
|
|
1861
|
+
"fonts": [],
|
|
1862
|
+
"audio": [],
|
|
1863
|
+
"graphics": [
|
|
1864
|
+
"webgl2"
|
|
1865
|
+
]
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
},
|
|
1662
1870
|
{
|
|
1663
1871
|
"name": "donut-chart",
|
|
1664
1872
|
"description": "Part-to-whole display with a stable center metric.",
|
|
@@ -2579,6 +2787,51 @@
|
|
|
2579
2787
|
}
|
|
2580
2788
|
}
|
|
2581
2789
|
},
|
|
2790
|
+
{
|
|
2791
|
+
"name": "liquid-title",
|
|
2792
|
+
"description": "A headline displaced by moving glass that settles as the shot goes on.",
|
|
2793
|
+
"registryDependencies": [],
|
|
2794
|
+
"files": [
|
|
2795
|
+
{
|
|
2796
|
+
"path": "components/liquid-title/liquid-title.tsx",
|
|
2797
|
+
"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",
|
|
2798
|
+
"target": "videos/components/liquid-title/liquid-title.tsx"
|
|
2799
|
+
},
|
|
2800
|
+
{
|
|
2801
|
+
"path": "components/liquid-title/liquid-title.preview.tsx",
|
|
2802
|
+
"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",
|
|
2803
|
+
"target": "videos/components/liquid-title/liquid-title.preview.tsx"
|
|
2804
|
+
}
|
|
2805
|
+
],
|
|
2806
|
+
"meta": {
|
|
2807
|
+
"kind": "component",
|
|
2808
|
+
"family": "Typography",
|
|
2809
|
+
"namespaced": "@odori/liquid-title",
|
|
2810
|
+
"contract": {
|
|
2811
|
+
"aspectRatios": [
|
|
2812
|
+
"16:9",
|
|
2813
|
+
"9:16",
|
|
2814
|
+
"1:1"
|
|
2815
|
+
],
|
|
2816
|
+
"recommendedDurationInFrames": 120,
|
|
2817
|
+
"minimumDurationInFrames": 30,
|
|
2818
|
+
"entranceFrames": 0,
|
|
2819
|
+
"exitFrames": 0,
|
|
2820
|
+
"contentLimits": {},
|
|
2821
|
+
"reducedMotion": "set amount to 0 and the type is undisturbed",
|
|
2822
|
+
"requires": {
|
|
2823
|
+
"fonts": [
|
|
2824
|
+
"display",
|
|
2825
|
+
"sans"
|
|
2826
|
+
],
|
|
2827
|
+
"audio": [],
|
|
2828
|
+
"graphics": [
|
|
2829
|
+
"webgl2"
|
|
2830
|
+
]
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
},
|
|
2582
2835
|
{
|
|
2583
2836
|
"name": "log-stream",
|
|
2584
2837
|
"description": "Paced runtime logs with levels and bounded scrolling.",
|
|
@@ -2745,6 +2998,48 @@
|
|
|
2745
2998
|
}
|
|
2746
2999
|
}
|
|
2747
3000
|
},
|
|
3001
|
+
{
|
|
3002
|
+
"name": "mesh-gradient",
|
|
3003
|
+
"description": "Four colour centres blended by inverse distance, so they pass through each other cleanly.",
|
|
3004
|
+
"registryDependencies": [],
|
|
3005
|
+
"files": [
|
|
3006
|
+
{
|
|
3007
|
+
"path": "components/mesh-gradient/mesh-gradient.tsx",
|
|
3008
|
+
"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",
|
|
3009
|
+
"target": "videos/components/mesh-gradient/mesh-gradient.tsx"
|
|
3010
|
+
},
|
|
3011
|
+
{
|
|
3012
|
+
"path": "components/mesh-gradient/mesh-gradient.preview.tsx",
|
|
3013
|
+
"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",
|
|
3014
|
+
"target": "videos/components/mesh-gradient/mesh-gradient.preview.tsx"
|
|
3015
|
+
}
|
|
3016
|
+
],
|
|
3017
|
+
"meta": {
|
|
3018
|
+
"kind": "component",
|
|
3019
|
+
"family": "Foundation",
|
|
3020
|
+
"namespaced": "@odori/mesh-gradient",
|
|
3021
|
+
"contract": {
|
|
3022
|
+
"aspectRatios": [
|
|
3023
|
+
"16:9",
|
|
3024
|
+
"9:16",
|
|
3025
|
+
"1:1"
|
|
3026
|
+
],
|
|
3027
|
+
"recommendedDurationInFrames": 180,
|
|
3028
|
+
"minimumDurationInFrames": 30,
|
|
3029
|
+
"entranceFrames": 0,
|
|
3030
|
+
"exitFrames": 0,
|
|
3031
|
+
"contentLimits": {},
|
|
3032
|
+
"reducedMotion": "set drift to 0 and the field holds still",
|
|
3033
|
+
"requires": {
|
|
3034
|
+
"fonts": [],
|
|
3035
|
+
"audio": [],
|
|
3036
|
+
"graphics": [
|
|
3037
|
+
"webgl2"
|
|
3038
|
+
]
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
}
|
|
3042
|
+
},
|
|
2748
3043
|
{
|
|
2749
3044
|
"name": "metric-callout",
|
|
2750
3045
|
"description": "A single counted number with a supporting label.",
|
|
@@ -2836,6 +3131,48 @@
|
|
|
2836
3131
|
}
|
|
2837
3132
|
}
|
|
2838
3133
|
},
|
|
3134
|
+
{
|
|
3135
|
+
"name": "noise-field",
|
|
3136
|
+
"description": "Domain-warped fractal noise that folds through itself, from soft cloud to hard contour.",
|
|
3137
|
+
"registryDependencies": [],
|
|
3138
|
+
"files": [
|
|
3139
|
+
{
|
|
3140
|
+
"path": "components/noise-field/noise-field.tsx",
|
|
3141
|
+
"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",
|
|
3142
|
+
"target": "videos/components/noise-field/noise-field.tsx"
|
|
3143
|
+
},
|
|
3144
|
+
{
|
|
3145
|
+
"path": "components/noise-field/noise-field.preview.tsx",
|
|
3146
|
+
"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",
|
|
3147
|
+
"target": "videos/components/noise-field/noise-field.preview.tsx"
|
|
3148
|
+
}
|
|
3149
|
+
],
|
|
3150
|
+
"meta": {
|
|
3151
|
+
"kind": "component",
|
|
3152
|
+
"family": "Foundation",
|
|
3153
|
+
"namespaced": "@odori/noise-field",
|
|
3154
|
+
"contract": {
|
|
3155
|
+
"aspectRatios": [
|
|
3156
|
+
"16:9",
|
|
3157
|
+
"9:16",
|
|
3158
|
+
"1:1"
|
|
3159
|
+
],
|
|
3160
|
+
"recommendedDurationInFrames": 180,
|
|
3161
|
+
"minimumDurationInFrames": 30,
|
|
3162
|
+
"entranceFrames": 0,
|
|
3163
|
+
"exitFrames": 0,
|
|
3164
|
+
"contentLimits": {},
|
|
3165
|
+
"reducedMotion": "set speed to 0 and the field holds still",
|
|
3166
|
+
"requires": {
|
|
3167
|
+
"fonts": [],
|
|
3168
|
+
"audio": [],
|
|
3169
|
+
"graphics": [
|
|
3170
|
+
"webgl2"
|
|
3171
|
+
]
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
}
|
|
3175
|
+
},
|
|
2839
3176
|
{
|
|
2840
3177
|
"name": "notify",
|
|
2841
3178
|
"description": "A soft bell for a toast or a message.",
|
|
@@ -3698,7 +4035,7 @@
|
|
|
3698
4035
|
"files": [
|
|
3699
4036
|
{
|
|
3700
4037
|
"path": "components/statement/statement.tsx",
|
|
3701
|
-
"content": "import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type StatementChip = {\n /** The chip's text. */\n label: string;\n /** A glyph drawn before the label: a logo, a symbol, a single letter. */\n icon?: string;\n /** Which surface the chip is drawn on. */\n tone?: \"neutral\" | \"dark\" | \"accent\";\n};\n\nexport type StatementProps = {\n /** The sentence, in order. A string is a word; an object is a chip. */\n parts: Array<string | StatementChip>;\n /**\n * What arrives at a time. Characters read as someone typing, words as\n * someone talking, and `none` puts the whole line up at once.\n */\n reveal?: \"characters\" | \"words\" | \"none\";\n /**\n * How a part arrives. `rise` lifts and fades it in; `cut` switches it on.\n * A cut is not a lesser rise: a video built entirely of hard cuts has a\n * rhythm that any easing softens away.\n */\n motion?: \"rise\" | \"cut\";\n /** Frames between one unit and the next. */\n stagger?: number;\n /** Where the sentence sits. */\n align?: \"left\" | \"center\";\n /** Light page or dark page. Chips and text follow it. */\n theme?: \"light\" | \"dark\";\n /** A chip that replaces the last chip in place. */\n swap?: StatementChip;\n /** The frame the swap happens on. */\n swapAt?: number;\n};\n\nconst isChip = (part: string | StatementChip): part is StatementChip => typeof part !== \"string\";\n\n/**\n * A sentence, arriving under its own discipline, with product controls set\n * inline where the prose needs one.\n *\n * Real product videos disagree about how type should arrive, and they are all\n * right. One types character by character because it is imitating a person at\n * a keyboard. One cuts word by word with no easing anywhere, because every\n * other transition in it is a cut and a single eased rise would sound a wrong\n * note. One lifts whole phrases because it is narrating. So the discipline is\n * a choice here rather than a house opinion baked into three near-identical\n * components.\n *\n * Characters reveal inside a layout that is already the whole sentence: each\n * glyph switches on where it will finally sit, so nothing reflows and no caret\n * is needed to explain a ragged edge, because there is no ragged edge.\n *\n * A chip is drawn the way the interface draws it, so a sentence can name a\n * control and the next scene can cut to that control at scale without the two\n * disagreeing about what the product looks like. `swap` exchanges the last\n * chip in place, so one vendor becoming another is a substitution rather than\n * a reflow of the line.\n */\nexport const Statement = ({\n parts,\n reveal = \"words\",\n motion = \"rise\",\n stagger,\n align = \"left\",\n theme = \"light\",\n swap,\n swapAt,\n}: StatementProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const dark = theme === \"dark\";\n const ink = dark ? brand.colors.background : brand.colors.foreground;\n const paper = dark ? brand.colors.foreground : brand.colors.background;\n\n // One frame per character reads as typing; a couple of stagger units per\n // word reads as speech.\n const step = stagger ?? (reveal === \"characters\" ? 1 : brand.motion.staggerFrames * 2);\n const start = 6;\n const lastChip = parts.map(isChip).lastIndexOf(true);\n const swapFrame = swapAt ?? 0;\n\n // Where each part begins, counted in whatever unit is being revealed.\n const offsets: number[] = [];\n let units = 0;\n for (const part of parts) {\n offsets.push(units);\n units += reveal === \"characters\" && !isChip(part) ? part.length + 1 : 1;\n }\n\n const shown = (unit: number) => {\n if (reveal === \"none\") return frame >= start ? 1 : 0;\n const at = start + unit * step;\n if (motion === \"cut\") return frame >= at ? 1 : 0;\n return interpolate(frame, [at, at + 20], [0, 1], {easing: Easing.standard});\n };\n\n const enter = (progress: number) =>\n motion === \"cut\"\n ? {display: \"inline-block\", opacity: progress}\n : {\n display: \"inline-block\",\n opacity: progress,\n transform: `translateY(${(1 - progress) * 26 * scale}px)`,\n };\n\n const chipSurface = (tone: StatementChip[\"tone\"]) => {\n if (tone === \"accent\") return {background: brand.colors.accent, color: paper, border: \"transparent\"};\n if (tone === \"dark\") return {background: ink, color: paper, border: \"transparent\"};\n // Mixed from the ink rather than named, so a neutral chip is legible on a\n // light brand, a dark brand, and the inverted scenes without being told\n // which it is standing on.\n return {\n background: `color-mix(in srgb, ${ink} 8%, transparent)`,\n color: ink,\n border: `color-mix(in srgb, ${ink} 20%, transparent)`,\n };\n };\n\n const chipBody = (chip: StatementChip) => {\n const surface = chipSurface(chip.tone);\n return (\n <span\n style={{\n alignItems: \"center\",\n background: surface.background,\n border: `${Math.max(1, scale)}px solid ${surface.border}`,\n borderRadius: 14 * scale,\n color: surface.color,\n display: \"inline-flex\",\n gap: 10 * scale,\n padding: `${6 * scale}px ${16 * scale}px`,\n whiteSpace: \"nowrap\",\n }}\n >\n {chip.icon ? <span style={{fontSize: 46 * scale, lineHeight: 1}}>{chip.icon}</span> : null}\n {chip.label}\n </span>\n );\n };\n\n return (\n <Fill\n style={{\n alignItems: align === \"center\" ? \"center\" : \"flex-start\",\n background: dark ?
|
|
4038
|
+
"content": "import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type StatementChip = {\n /** The chip's text. */\n label: string;\n /** A glyph drawn before the label: a logo, a symbol, a single letter. */\n icon?: string;\n /** Which surface the chip is drawn on. */\n tone?: \"neutral\" | \"dark\" | \"accent\";\n};\n\nexport type StatementProps = {\n /** The sentence, in order. A string is a word; an object is a chip. */\n parts: Array<string | StatementChip>;\n /**\n * What arrives at a time. Characters read as someone typing, words as\n * someone talking, and `none` puts the whole line up at once.\n */\n reveal?: \"characters\" | \"words\" | \"none\";\n /**\n * How a part arrives. `rise` lifts and fades it in; `cut` switches it on.\n * A cut is not a lesser rise: a video built entirely of hard cuts has a\n * rhythm that any easing softens away.\n */\n motion?: \"rise\" | \"cut\";\n /** Frames between one unit and the next. */\n stagger?: number;\n /** Where the sentence sits. */\n align?: \"left\" | \"center\";\n /** Light page or dark page. Chips and text follow it. */\n theme?: \"light\" | \"dark\";\n /** A chip that replaces the last chip in place. */\n swap?: StatementChip;\n /** The frame the swap happens on. */\n swapAt?: number;\n};\n\nconst isChip = (part: string | StatementChip): part is StatementChip => typeof part !== \"string\";\n\n/**\n * A sentence, arriving under its own discipline, with product controls set\n * inline where the prose needs one.\n *\n * Real product videos disagree about how type should arrive, and they are all\n * right. One types character by character because it is imitating a person at\n * a keyboard. One cuts word by word with no easing anywhere, because every\n * other transition in it is a cut and a single eased rise would sound a wrong\n * note. One lifts whole phrases because it is narrating. So the discipline is\n * a choice here rather than a house opinion baked into three near-identical\n * components.\n *\n * Characters reveal inside a layout that is already the whole sentence: each\n * glyph switches on where it will finally sit, so nothing reflows and no caret\n * is needed to explain a ragged edge, because there is no ragged edge.\n *\n * A chip is drawn the way the interface draws it, so a sentence can name a\n * control and the next scene can cut to that control at scale without the two\n * disagreeing about what the product looks like. `swap` exchanges the last\n * chip in place, so one vendor becoming another is a substitution rather than\n * a reflow of the line.\n */\nexport const Statement = ({\n parts,\n reveal = \"words\",\n motion = \"rise\",\n stagger,\n align = \"left\",\n theme = \"light\",\n swap,\n swapAt,\n}: StatementProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const dark = theme === \"dark\";\n const ink = dark ? brand.colors.background : brand.colors.foreground;\n const paper = dark ? brand.colors.foreground : brand.colors.background;\n\n // One frame per character reads as typing; a couple of stagger units per\n // word reads as speech.\n const step = stagger ?? (reveal === \"characters\" ? 1 : brand.motion.staggerFrames * 2);\n const start = 6;\n const lastChip = parts.map(isChip).lastIndexOf(true);\n const swapFrame = swapAt ?? 0;\n\n // Where each part begins, counted in whatever unit is being revealed.\n const offsets: number[] = [];\n let units = 0;\n for (const part of parts) {\n offsets.push(units);\n units += reveal === \"characters\" && !isChip(part) ? part.length + 1 : 1;\n }\n\n const shown = (unit: number) => {\n if (reveal === \"none\") return frame >= start ? 1 : 0;\n const at = start + unit * step;\n if (motion === \"cut\") return frame >= at ? 1 : 0;\n return interpolate(frame, [at, at + 20], [0, 1], {easing: Easing.standard});\n };\n\n const enter = (progress: number) =>\n motion === \"cut\"\n ? {display: \"inline-block\", opacity: progress}\n : {\n display: \"inline-block\",\n opacity: progress,\n transform: `translateY(${(1 - progress) * 26 * scale}px)`,\n };\n\n const chipSurface = (tone: StatementChip[\"tone\"]) => {\n if (tone === \"accent\") return {background: brand.colors.accent, color: paper, border: \"transparent\"};\n if (tone === \"dark\") return {background: ink, color: paper, border: \"transparent\"};\n // Mixed from the ink rather than named, so a neutral chip is legible on a\n // light brand, a dark brand, and the inverted scenes without being told\n // which it is standing on.\n return {\n background: `color-mix(in srgb, ${ink} 8%, transparent)`,\n color: ink,\n border: `color-mix(in srgb, ${ink} 20%, transparent)`,\n };\n };\n\n const chipBody = (chip: StatementChip) => {\n const surface = chipSurface(chip.tone);\n return (\n <span\n style={{\n alignItems: \"center\",\n background: surface.background,\n border: `${Math.max(1, scale)}px solid ${surface.border}`,\n borderRadius: 14 * scale,\n color: surface.color,\n display: \"inline-flex\",\n gap: 10 * scale,\n padding: `${6 * scale}px ${16 * scale}px`,\n whiteSpace: \"nowrap\",\n }}\n >\n {chip.icon ? <span style={{fontSize: 46 * scale, lineHeight: 1}}>{chip.icon}</span> : null}\n {chip.label}\n </span>\n );\n };\n\n return (\n <Fill\n style={{\n alignItems: align === \"center\" ? \"center\" : \"flex-start\",\n // paper, not ink. ink is the type colour, and painting the ground\n // with it put light text on a light ground: the dark theme rendered\n // the sentence invisible, including in this component's own fixture.\n background: dark ? paper : \"transparent\",\n justifyContent: \"center\",\n padding: `${120 * scale}px ${150 * scale}px`,\n }}\n >\n <div\n style={{\n alignItems: \"center\",\n color: ink,\n columnGap: 16 * scale,\n display: \"flex\",\n flexWrap: \"wrap\",\n fontSize: 64 * scale,\n fontWeight: 500,\n justifyContent: align === \"center\" ? \"center\" : \"flex-start\",\n letterSpacing: \"-0.03em\",\n lineHeight: 1.35,\n rowGap: 8 * scale,\n textAlign: align,\n }}\n >\n {parts.map((part, index) => {\n const swapping = swap !== undefined && index === lastChip;\n const swapped = swapping\n ? interpolate(frame, [swapFrame, swapFrame + (motion === \"cut\" ? 1 : 16)], [0, 1], {\n easing: Easing.standard,\n })\n : 0;\n\n if (!isChip(part)) {\n // In a pre-measured layout every glyph already occupies its final\n // place, so revealing one cannot move the ones after it.\n if (reveal === \"characters\") {\n return (\n <span key={`${part}-${index}`} style={{display: \"inline-block\", whiteSpace: \"pre\"}}>\n {[...part].map((character, position) => (\n <span key={position} style={{display: \"inline-block\", opacity: shown(offsets[index] + position)}}>\n {character}\n </span>\n ))}\n </span>\n );\n }\n return (\n <span key={`${part}-${index}`} style={enter(shown(offsets[index]))}>\n {part}\n </span>\n );\n }\n\n const entrance = shown(offsets[index]);\n return (\n <span\n key={`chip-${index}`}\n style={{\n ...enter(entrance),\n // A chip settles from slightly small, the way a control that\n // just appeared under the cursor does. A cut skips the settle.\n ...(motion === \"cut\"\n ? {}\n : {transform: `translateY(${(1 - entrance) * 26 * scale}px) scale(${0.92 + entrance * 0.08})`}),\n }}\n >\n {swapping ? (\n <span style={{display: \"inline-grid\"}}>\n <span\n style={{\n gridArea: \"1 / 1\",\n opacity: 1 - swapped,\n transform: motion === \"cut\" ? undefined : `translateY(${swapped * -18 * scale}px)`,\n }}\n >\n {chipBody(part)}\n </span>\n <span\n style={{\n gridArea: \"1 / 1\",\n opacity: swapped,\n transform: motion === \"cut\" ? undefined : `translateY(${(1 - swapped) * 18 * scale}px)`,\n }}\n >\n {chipBody(swap)}\n </span>\n </span>\n ) : (\n chipBody(part)\n )}\n </span>\n );\n })}\n </div>\n </Fill>\n );\n};\n",
|
|
3702
4039
|
"target": "videos/components/statement/statement.tsx"
|
|
3703
4040
|
},
|
|
3704
4041
|
{
|