@odori/cli 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/bin/odori.mjs +39 -0
- package/dist/chunk-7XJL2BYO.js +3552 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +10 -0
- package/dist/index.d.ts +622 -0
- package/dist/index.js +156 -0
- package/dist/registry-snapshot-NIH2JMQ6.js +3559 -0
- package/package.json +50 -0
- package/src/audio-mix.ts +133 -0
- package/src/binaries.ts +241 -0
- package/src/brand-file.ts +94 -0
- package/src/chunk-cache.ts +85 -0
- package/src/chunks.ts +78 -0
- package/src/cli.ts +319 -0
- package/src/commands/add.ts +151 -0
- package/src/commands/dev.ts +160 -0
- package/src/commands/doctor.ts +162 -0
- package/src/commands/exportVideo.ts +198 -0
- package/src/commands/init.ts +56 -0
- package/src/commands/inspect.ts +72 -0
- package/src/commands/list.ts +22 -0
- package/src/commands/new.ts +126 -0
- package/src/commands/shared.ts +96 -0
- package/src/commands/still.ts +40 -0
- package/src/commands/test.ts +265 -0
- package/src/commands/update.ts +183 -0
- package/src/config.ts +84 -0
- package/src/contracts.ts +159 -0
- package/src/cues.ts +141 -0
- package/src/determinism.ts +82 -0
- package/src/diff.ts +71 -0
- package/src/discovery.ts +216 -0
- package/src/formats.ts +119 -0
- package/src/index.ts +58 -0
- package/src/integrity.ts +101 -0
- package/src/jobs.ts +151 -0
- package/src/log.ts +17 -0
- package/src/open.ts +32 -0
- package/src/paths.ts +12 -0
- package/src/prepare-cache.ts +58 -0
- package/src/project.ts +196 -0
- package/src/registry-snapshot.json +3431 -0
- package/src/registry-source.ts +269 -0
- package/src/render.ts +627 -0
- package/src/server.ts +307 -0
- package/studio/index.html +41 -0
- package/studio/src/Studio.tsx +192 -0
- package/studio/src/components/AudioClip.tsx +64 -0
- package/studio/src/components/CanvasStage.tsx +79 -0
- package/studio/src/components/CommandPalette.tsx +129 -0
- package/studio/src/components/Diagnostics.tsx +93 -0
- package/studio/src/components/ExportPanel.tsx +234 -0
- package/studio/src/components/InputControls.tsx +110 -0
- package/studio/src/components/Thumbnail.tsx +71 -0
- package/studio/src/components/Transport.tsx +237 -0
- package/studio/src/components/Waveform.tsx +114 -0
- package/studio/src/components/Wordmark.tsx +449 -0
- package/studio/src/components/ui.tsx +138 -0
- package/studio/src/lib/mix-loudness.ts +52 -0
- package/studio/src/main.tsx +34 -0
- package/studio/src/shortcuts.ts +27 -0
- package/studio/src/studio.css +1232 -0
- package/studio/src/theme.ts +61 -0
- package/studio/src/views/AssetsView.tsx +111 -0
- package/studio/src/views/BrandsView.tsx +139 -0
- package/studio/src/views/ComponentsView.tsx +285 -0
- package/studio/src/views/HomeView.tsx +122 -0
- package/studio/src/views/VideosView.tsx +343 -0
- package/studio/src/virtual.d.ts +25 -0
|
@@ -0,0 +1,3559 @@
|
|
|
1
|
+
// src/registry-snapshot.json
|
|
2
|
+
var registry_snapshot_default = {
|
|
3
|
+
items: [
|
|
4
|
+
{
|
|
5
|
+
name: "absolute-stage",
|
|
6
|
+
description: "Full-frame surface with stable clipping and positioning.",
|
|
7
|
+
registryDependencies: [],
|
|
8
|
+
files: [
|
|
9
|
+
{
|
|
10
|
+
path: "components/absolute-stage/absolute-stage.tsx",
|
|
11
|
+
content: 'import type {CSSProperties, ReactNode} from "react";\nimport {Fill, useBrand, useDesignScale, useVideo} from "odori";\n\nexport type AbsoluteStageProps = {\n children?: ReactNode;\n /** Where the content sits inside the frame. */\n align?: "center" | "start" | "end";\n /** Inset from the frame edge, in design pixels. */\n padding?: number;\n /** Draw the frame edge, useful while composing. */\n showBounds?: boolean;\n style?: CSSProperties;\n};\n\n/**\n * The plainest full-frame surface: predictable clipping, one alignment\n * decision, and padding that scales with the format. Reach for this when a\n * scene needs structure without the grid and glow of the branded stage.\n */\nexport const AbsoluteStage = ({children, align = "center", padding = 120, showBounds = false, style}: AbsoluteStageProps) => {\n const brand = useBrand();\n const scale = useDesignScale();\n const {width, height} = useVideo();\n\n return (\n <Fill style={{background: brand.colors.background, overflow: "hidden", ...style}}>\n <Fill\n style={{\n alignItems: "center",\n display: "flex",\n justifyContent: align === "center" ? "center" : align === "start" ? "flex-start" : "flex-end",\n flexDirection: "column",\n padding: padding * scale,\n }}\n >\n {children}\n </Fill>\n {showBounds ? (\n <Fill\n style={{\n border: `${2 * scale}px dashed ${brand.colors.border}`,\n inset: padding * scale,\n pointerEvents: "none",\n }}\n >\n <span\n style={{\n color: brand.colors.muted,\n fontFamily: brand.typography.mono,\n fontSize: 22 * scale,\n padding: 12 * scale,\n }}\n >\n {width} x {height}\n </span>\n </Fill>\n ) : null}\n </Fill>\n );\n};\n',
|
|
12
|
+
target: "videos/components/absolute-stage/absolute-stage.tsx"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
path: "components/absolute-stage/absolute-stage.preview.tsx",
|
|
16
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {AbsoluteStage} from "./absolute-stage";\n\nexport default defineComponentPreview({\n title: "Absolute stage",\n category: "Foundation",\n description: "Full-frame surface with stable clipping and positioning.",\n component: AbsoluteStage,\n canvas: {width: 1920, height: 1080, duration: "3s"},\n controls: {\n align: {type: "select", defaultValue: "center", options: ["center", "start", "end"]},\n padding: {type: "number", defaultValue: 120, min: 0, max: 320},\n showBounds: {type: "boolean", defaultValue: true},\n },\n examples: [\n {name: "Bounds", props: {showBounds: true, children: "Content sits here"}},\n {name: "Clean", props: {showBounds: false, children: "Content sits here"}},\n ],\n});\n',
|
|
17
|
+
target: "videos/components/absolute-stage/absolute-stage.preview.tsx"
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
meta: {
|
|
21
|
+
kind: "component",
|
|
22
|
+
family: "Foundation",
|
|
23
|
+
namespaced: "@odori/absolute-stage",
|
|
24
|
+
contract: {
|
|
25
|
+
aspectRatios: [
|
|
26
|
+
"16:9",
|
|
27
|
+
"9:16",
|
|
28
|
+
"1:1"
|
|
29
|
+
],
|
|
30
|
+
recommendedDurationInFrames: 90,
|
|
31
|
+
minimumDurationInFrames: 24,
|
|
32
|
+
entranceFrames: 0,
|
|
33
|
+
exitFrames: 0,
|
|
34
|
+
contentLimits: {},
|
|
35
|
+
reducedMotion: "no motion of its own",
|
|
36
|
+
requires: {
|
|
37
|
+
fonts: [
|
|
38
|
+
"sans"
|
|
39
|
+
],
|
|
40
|
+
audio: []
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: "area-chart",
|
|
47
|
+
description: "Filled trend reveal for volume and cumulative change.",
|
|
48
|
+
registryDependencies: [],
|
|
49
|
+
files: [
|
|
50
|
+
{
|
|
51
|
+
path: "components/area-chart/area-chart.tsx",
|
|
52
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type AreaChartProps = {\n values: number[];\n title?: string;\n detail?: string;\n};\n\n/**\n * A filled trend for volume and cumulative change. The fill wipes in with the\n * path, so the area never appears ahead of the line that bounds it.\n */\nexport const AreaChart = ({values, title, detail}: AreaChartProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const max = Math.max(...values, 1);\n const draw = interpolate(frame, [12, 62], [0, 1], {easing: Easing.standard});\n const points = values.map((value, index) => ({\n x: (index / Math.max(1, values.length - 1)) * 100,\n y: 100 - (value / max) * 92,\n }));\n const line = points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");\n const area = `${line} L 100 100 L 0 100 Z`;\n\n return (\n <Fill style={{gap: 30 * scale, justifyContent: "center", padding: `${110 * scale}px ${130 * scale}px`}}>\n {title ? (\n <div style={{fontSize: 58 * scale, fontWeight: 560, letterSpacing: "-0.04em", opacity: interpolate(frame, [0, 16], [0, 1])}}>\n {title}\n </div>\n ) : null}\n <div style={{height: 460 * scale, width: "100%"}}>\n <svg preserveAspectRatio="none" style={{clipPath: `inset(0 ${(1 - draw) * 100}% 0 0)`, height: "100%", width: "100%"}} viewBox="0 0 100 100">\n <path d={area} fill={brand.colors.accent} opacity={0.18} />\n <path d={line} fill="none" stroke={brand.colors.accent} strokeWidth={9 * scale} vectorEffect="non-scaling-stroke" />\n </svg>\n </div>\n {detail ? (\n <div style={{color: brand.colors.muted, fontSize: 32 * scale, opacity: interpolate(frame, [56, 74], [0, 1])}}>{detail}</div>\n ) : null}\n </Fill>\n );\n};\n',
|
|
53
|
+
target: "videos/components/area-chart/area-chart.tsx"
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
path: "components/area-chart/area-chart.preview.tsx",
|
|
57
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {AreaChart} from "./area-chart";\n\nexport default defineComponentPreview({\n title: "Area chart",\n category: "Data",\n description: "Filled trend reveal for volume and cumulative change.",\n component: AreaChart,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n title: {type: "text", defaultValue: "Frames rendered"},\n detail: {type: "text", defaultValue: "Cumulative, across every format."},\n },\n examples: [{name: "Adoption", props: {values: [2, 5, 9, 14, 22, 31, 44, 60]}}],\n});\n',
|
|
58
|
+
target: "videos/components/area-chart/area-chart.preview.tsx"
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
meta: {
|
|
62
|
+
kind: "component",
|
|
63
|
+
family: "Data",
|
|
64
|
+
namespaced: "@odori/area-chart",
|
|
65
|
+
contract: {
|
|
66
|
+
aspectRatios: [
|
|
67
|
+
"16:9",
|
|
68
|
+
"1:1"
|
|
69
|
+
],
|
|
70
|
+
recommendedDurationInFrames: 120,
|
|
71
|
+
minimumDurationInFrames: 78,
|
|
72
|
+
entranceFrames: 62,
|
|
73
|
+
exitFrames: 0,
|
|
74
|
+
contentLimits: {
|
|
75
|
+
points: 16
|
|
76
|
+
},
|
|
77
|
+
reducedMotion: "the filled area appears at once",
|
|
78
|
+
requires: {
|
|
79
|
+
fonts: [
|
|
80
|
+
"sans"
|
|
81
|
+
],
|
|
82
|
+
audio: []
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
name: "b-roll-window",
|
|
89
|
+
description: "Supporting footage framed beside the main narrative.",
|
|
90
|
+
registryDependencies: [],
|
|
91
|
+
files: [
|
|
92
|
+
{
|
|
93
|
+
path: "components/b-roll-window/b-roll-window.tsx",
|
|
94
|
+
content: 'import type {ReactNode} from "react";\nimport {Easing, Fill, interpolate, placeholderImage, useBrand, useDesignScale, useFrame} from "odori";\n\nexport type BRollWindowProps = {\n /** The main narrative, filling the frame. */\n children?: ReactNode;\n /** Supporting footage. A string is an image URL. */\n media?: string | ReactNode;\n /** Which corner the window sits in. */\n corner?: "bottom-right" | "bottom-left" | "top-right" | "top-left";\n /** Frame it enters and frame it leaves. */\n at?: number;\n until?: number;\n label?: string;\n};\n\n/** Which two edges each corner hugs, so the offsets below stay declarative. */\nconst CORNERS: Record<string, {top?: boolean; bottom?: boolean; left?: boolean; right?: boolean}> = {\n "bottom-right": {bottom: true, right: true},\n "bottom-left": {bottom: true, left: true},\n "top-right": {top: true, right: true},\n "top-left": {top: true, left: true},\n};\n\n/**\n * Supporting footage beside the main narrative, in and out on cue.\n *\n * The window enters from the edge it sits against, which is what makes it read\n * as arriving from off-screen rather than fading up out of nowhere \u2014 and it\n * leaves the same way, so it never has to be cut around.\n */\nexport const BRollWindow = ({\n children,\n media,\n corner = "bottom-right",\n at = 24,\n until = 140,\n label,\n}: BRollWindowProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const place = CORNERS[corner];\n const fromRight = corner.endsWith("right");\n\n const enter = interpolate(frame, [at, at + 14], [0, 1], {easing: Easing.standard});\n const leave = interpolate(frame, [until, until + 14], [1, 0], {easing: Easing.standard});\n const visible = Math.min(enter, leave);\n const source = media ?? placeholderImage({label: "b-roll", seed: "broll", width: 800, height: 500});\n\n return (\n <Fill>\n {children}\n {visible > 0.001 ? (\n <div\n style={{\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 16 * scale,\n bottom: place.bottom ? 64 * scale : undefined,\n boxShadow: `0 ${24 * scale}px ${60 * scale}px rgba(0,0,0,0.5)`,\n height: 340 * scale,\n left: place.left ? 64 * scale : undefined,\n opacity: visible,\n overflow: "hidden",\n position: "absolute",\n right: place.right ? 64 * scale : undefined,\n top: place.top ? 64 * scale : undefined,\n transform: `translateX(${(1 - visible) * (fromRight ? 60 : -60) * scale}px)`,\n width: 560 * scale,\n }}\n >\n {typeof source === "string" ? (\n <img src={source} alt="" style={{height: "100%", objectFit: "cover", width: "100%"}} />\n ) : (\n source\n )}\n {label ? (\n <span\n style={{\n background: "rgba(0,0,0,0.6)",\n bottom: 0,\n color: "#ffffff",\n fontFamily: brand.typography.mono,\n fontSize: 20 * scale,\n left: 0,\n padding: `${10 * scale}px ${16 * scale}px`,\n position: "absolute",\n right: 0,\n }}\n >\n {label}\n </span>\n ) : null}\n </div>\n ) : null}\n </Fill>\n );\n};\n',
|
|
95
|
+
target: "videos/components/b-roll-window/b-roll-window.tsx"
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
path: "components/b-roll-window/b-roll-window.preview.tsx",
|
|
99
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {BRollWindow} from "./b-roll-window";\n\nconst Main = () => (\n <div\n style={{\n alignItems: "center",\n background: "#080808",\n color: "#f5f5f5",\n display: "flex",\n fontFamily: "ui-sans-serif, system-ui",\n fontSize: 82,\n inset: 0,\n justifyContent: "center",\n letterSpacing: "-0.04em",\n position: "absolute",\n }}\n >\n The main narrative\n </div>\n);\n\nexport default defineComponentPreview({\n title: "B-roll window",\n category: "Media and canvas",\n description: "Supporting footage framed beside the main narrative.",\n component: BRollWindow,\n canvas: {width: 1920, height: 1080, duration: "6s"},\n controls: {\n corner: {\n type: "select",\n defaultValue: "bottom-right",\n options: ["bottom-right", "bottom-left", "top-right", "top-left"],\n },\n at: {type: "number", defaultValue: 24, min: 0, max: 90},\n until: {type: "number", defaultValue: 140, min: 30, max: 200},\n label: {type: "text", defaultValue: "odori export launch"},\n },\n examples: [\n {name: "Default", props: {children: <Main />}},\n {name: "Top left", props: {children: <Main />, corner: "top-left"}},\n ],\n});\n',
|
|
100
|
+
target: "videos/components/b-roll-window/b-roll-window.preview.tsx"
|
|
101
|
+
}
|
|
102
|
+
],
|
|
103
|
+
meta: {
|
|
104
|
+
kind: "component",
|
|
105
|
+
family: "Media and canvas",
|
|
106
|
+
namespaced: "@odori/b-roll-window",
|
|
107
|
+
contract: {
|
|
108
|
+
aspectRatios: [
|
|
109
|
+
"16:9",
|
|
110
|
+
"9:16",
|
|
111
|
+
"1:1"
|
|
112
|
+
],
|
|
113
|
+
recommendedDurationInFrames: 180,
|
|
114
|
+
minimumDurationInFrames: 45,
|
|
115
|
+
entranceFrames: 14,
|
|
116
|
+
exitFrames: 14,
|
|
117
|
+
contentLimits: {
|
|
118
|
+
label: 48
|
|
119
|
+
},
|
|
120
|
+
reducedMotion: "the window is present without sliding in",
|
|
121
|
+
requires: {
|
|
122
|
+
fonts: [
|
|
123
|
+
"mono"
|
|
124
|
+
],
|
|
125
|
+
audio: []
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: "bar-chart",
|
|
132
|
+
description: "Ranked bars with labels, values, and staged growth.",
|
|
133
|
+
registryDependencies: [],
|
|
134
|
+
files: [
|
|
135
|
+
{
|
|
136
|
+
path: "components/bar-chart/bar-chart.tsx",
|
|
137
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type BarDatum = {label: string; value: number};\n\nexport type BarChartProps = {\n data: BarDatum[];\n title?: string;\n /** Appended to every printed value. */\n unit?: string;\n /** Index drawn in the accent color. */\n highlight?: number;\n};\n\n/**\n * Ranked bars that grow from a shared baseline. The scale comes from the data,\n * never from a rounded guess, so two charts in one video stay comparable.\n */\nexport const BarChart = ({data, title, unit = "", highlight}: BarChartProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const max = Math.max(...data.map((item) => item.value), 1);\n\n return (\n <Fill style={{gap: 40 * scale, justifyContent: "center", padding: `${110 * scale}px ${130 * scale}px`}}>\n {title ? (\n <div\n style={{\n fontSize: 58 * scale,\n fontWeight: 560,\n letterSpacing: "-0.04em",\n opacity: interpolate(frame, [0, 16], [0, 1], {easing: Easing.standard}),\n }}\n >\n {title}\n </div>\n ) : null}\n <div style={{display: "grid", gap: 22 * scale}}>\n {data.map((item, index) => {\n const delay = 10 + index * 6;\n const grow = interpolate(frame, [delay, delay + 30], [0, 1], {easing: Easing.standard});\n const accent = index === highlight;\n return (\n <div key={item.label} style={{alignItems: "center", display: "flex", gap: 24 * scale}}>\n <span style={{color: brand.colors.muted, fontSize: 30 * scale, width: 320 * scale}}>{item.label}</span>\n <div style={{background: brand.colors.border, borderRadius: 999, flex: 1, height: 34 * scale}}>\n <div\n style={{\n background: accent ? brand.colors.accent : brand.colors.foreground,\n borderRadius: 999,\n height: "100%",\n width: `${(item.value / max) * 100 * grow}%`,\n }}\n />\n </div>\n <span\n style={{\n fontSize: 30 * scale,\n fontVariantNumeric: "tabular-nums",\n textAlign: "right",\n width: 180 * scale,\n }}\n >\n {(item.value * grow).toFixed(item.value % 1 === 0 ? 0 : 1)}\n {unit}\n </span>\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n',
|
|
138
|
+
target: "videos/components/bar-chart/bar-chart.tsx"
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
path: "components/bar-chart/bar-chart.preview.tsx",
|
|
142
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {BarChart} from "./bar-chart";\n\nexport default defineComponentPreview({\n title: "Bar chart",\n category: "Data",\n description: "Ranked bars with labels, values, and staged growth.",\n component: BarChart,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n title: {type: "text", defaultValue: "Time to first preview"},\n unit: {type: "text", defaultValue: "s"},\n highlight: {type: "number", defaultValue: 2, min: 0, max: 5},\n },\n examples: [\n {\n name: "Comparison",\n props: {\n data: [\n {label: "Manual edit", value: 42},\n {label: "Template tool", value: 18},\n {label: "Odori", value: 4},\n ],\n highlight: 2,\n },\n },\n ],\n});\n',
|
|
143
|
+
target: "videos/components/bar-chart/bar-chart.preview.tsx"
|
|
144
|
+
}
|
|
145
|
+
],
|
|
146
|
+
meta: {
|
|
147
|
+
kind: "component",
|
|
148
|
+
family: "Data",
|
|
149
|
+
namespaced: "@odori/bar-chart",
|
|
150
|
+
contract: {
|
|
151
|
+
aspectRatios: [
|
|
152
|
+
"16:9",
|
|
153
|
+
"9:16",
|
|
154
|
+
"1:1"
|
|
155
|
+
],
|
|
156
|
+
recommendedDurationInFrames: 120,
|
|
157
|
+
minimumDurationInFrames: 60,
|
|
158
|
+
entranceFrames: 50,
|
|
159
|
+
exitFrames: 0,
|
|
160
|
+
contentLimits: {
|
|
161
|
+
bars: 6,
|
|
162
|
+
labelLength: 28
|
|
163
|
+
},
|
|
164
|
+
reducedMotion: "bars appear at full length",
|
|
165
|
+
requires: {
|
|
166
|
+
fonts: [
|
|
167
|
+
"sans"
|
|
168
|
+
],
|
|
169
|
+
audio: []
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: "bed-build",
|
|
176
|
+
description: "A rising bed that lands on the reveal it is building to.",
|
|
177
|
+
registryDependencies: [],
|
|
178
|
+
files: [
|
|
179
|
+
{
|
|
180
|
+
path: "components/bed-build/bed-build.tsx",
|
|
181
|
+
content: 'import {\n chord,\n defineCue,\n gain,\n highPass,\n lowPass,\n mix,\n noise,\n normalize,\n note,\n pad,\n sequence,\n shape,\n sine,\n sweep,\n type CueDefinition,\n} from "odori";\n\nexport type BedBuildOptions = {\n /** Root of the chord underneath, as a note name. */\n root?: string;\n /** Seconds the build runs before it lands. */\n seconds?: number;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * A bed that goes somewhere: a chord that opens up, a noise sweep rising\n * through it, and a tick pattern that doubles halfway. For a reveal, where the\n * sound has to arrive at the same moment the picture does.\n *\n * It does not loop. A build that repeats is a build that never landed, so this\n * one is placed once, against the thing it is building to.\n */\nexport const bedBuild = ({root = "d2", seconds = 4, peak = 0.7}: BedBuildOptions = {}): CueDefinition => {\n const samples = Math.round(48000 * seconds);\n const bpm = 120;\n\n return defineCue({\n name: "bed.build",\n durationInFrames: Math.round(seconds * 30),\n params: {root, seconds, peak},\n render: () =>\n normalize(\n mix(\n // The chord, opening as the filter does.\n gain(lowPass(pad(chord(root, "minor"), samples, {envelope: {attack: 1.2, sustain: 1}}), 1200), 0.8),\n // The riser: filtered noise climbing, plus a tone sweeping under it.\n gain(\n shape(highPass(noise(samples, 11), 700), {attack: seconds * 0.92, sustain: 1, release: 0.08}),\n 0.22,\n ),\n gain(shape(sweep(samples, note(root), note(root) * 4), {attack: seconds * 0.8, sustain: 1}), 0.3),\n // Ticks that double in the second half, which is what makes a build\n // feel like it is accelerating without changing tempo.\n sequence(\n [\n ...Array.from({length: 8}, (_, index) => ({at: index, gain: 0.18 + index * 0.02})),\n ...Array.from({length: 16}, (_, index) => ({at: 8 + index * 0.5, gain: 0.2 + index * 0.02})),\n ].map(({at, gain: level}) => ({\n at,\n gain: level,\n play: (length: number) => shape(sine(length, note(root) * 8), {attack: 0.002, decay: 0.06, sustain: 0}),\n })),\n {bpm, samples},\n ),\n ),\n peak,\n ),\n });\n};\n',
|
|
182
|
+
target: "videos/components/bed-build/bed-build.tsx"
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
path: "components/bed-build/bed-build.preview.tsx",
|
|
186
|
+
content: 'import {CueWave, defineComponentPreview} from "odori/preview";\nimport {bedBuild, type BedBuildOptions} from "./bed-build";\n\nconst Wave = ({root, seconds, peak}: BedBuildOptions) => <CueWave cue={bedBuild({root, seconds, peak})} />;\n\nexport default defineComponentPreview({\n title: "Build bed",\n category: "Sound",\n description: "A rising bed that lands on the reveal it is building to.",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {\n root: {type: "select", defaultValue: "d2", options: ["c2", "d2", "f2", "g2"]},\n seconds: {type: "number", defaultValue: 4, min: 2, max: 8},\n peak: {type: "number", defaultValue: 0.7, min: 0.1, max: 1, step: 0.05},\n },\n examples: [\n {name: "Default", props: {}},\n {name: "Short", props: {seconds: 2}},\n ],\n});\n',
|
|
187
|
+
target: "videos/components/bed-build/bed-build.preview.tsx"
|
|
188
|
+
}
|
|
189
|
+
],
|
|
190
|
+
meta: {
|
|
191
|
+
kind: "cue",
|
|
192
|
+
family: "Sound",
|
|
193
|
+
namespaced: "@odori/bed-build",
|
|
194
|
+
contract: {
|
|
195
|
+
aspectRatios: [
|
|
196
|
+
"16:9",
|
|
197
|
+
"9:16",
|
|
198
|
+
"1:1"
|
|
199
|
+
],
|
|
200
|
+
recommendedDurationInFrames: 120,
|
|
201
|
+
minimumDurationInFrames: 120,
|
|
202
|
+
entranceFrames: 0,
|
|
203
|
+
exitFrames: 0,
|
|
204
|
+
contentLimits: {},
|
|
205
|
+
reducedMotion: "the waveform renders without a playhead",
|
|
206
|
+
requires: {
|
|
207
|
+
fonts: [
|
|
208
|
+
"mono"
|
|
209
|
+
],
|
|
210
|
+
audio: []
|
|
211
|
+
},
|
|
212
|
+
loops: false
|
|
213
|
+
},
|
|
214
|
+
cue: {
|
|
215
|
+
name: "bed.build",
|
|
216
|
+
export: "bedBuild"
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
name: "bed-drift",
|
|
222
|
+
description: "Sustained pads with no rhythm, for slow product shots.",
|
|
223
|
+
registryDependencies: [],
|
|
224
|
+
files: [
|
|
225
|
+
{
|
|
226
|
+
path: "components/bed-drift/bed-drift.tsx",
|
|
227
|
+
content: 'import {\n chord,\n defineCue,\n gain,\n lowPass,\n mix,\n normalize,\n pad,\n type CueDefinition,\n} from "odori";\n\nexport type BedDriftOptions = {\n /** Root of the chord the pad holds, as a note name. */\n root?: string;\n /** Detune between voices, in cents. More beats faster. */\n detuneCents?: number;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * Sustained pads and nothing else: no rhythm, no onset to count against the\n * cut. For slow product shots, where a pulse would impose a tempo the picture\n * is not keeping.\n *\n * Two stacked voicings a fifth apart beat slowly against each other, which is\n * what stops a held chord from sounding like a test tone. Eight seconds, and\n * it loops.\n */\nexport const bedDrift = ({root = "f2", detuneCents = 7, peak = 0.5}: BedDriftOptions = {}): CueDefinition => {\n const samples = 48000 * 8;\n\n return defineCue({\n name: "bed.drift",\n durationInFrames: 240,\n loops: true,\n params: {root, detuneCents, peak},\n render: () =>\n normalize(\n lowPass(\n mix(\n pad(chord(root, "add9"), samples, {\n detuneCents,\n envelope: {attack: 2.4, sustain: 1, release: 2.6},\n }),\n // The upper voicing enters later and leaves earlier, so the two\n // never turn at the same moment and the pad never sounds gated.\n gain(\n pad(chord(root, "sus2").map((frequency) => frequency * 2), samples, {\n detuneCents: detuneCents * 1.5,\n envelope: {attack: 3.6, sustain: 1, release: 1.8},\n }),\n 0.55,\n ),\n ),\n 1400,\n ),\n peak,\n ),\n });\n};\n',
|
|
228
|
+
target: "videos/components/bed-drift/bed-drift.tsx"
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
path: "components/bed-drift/bed-drift.preview.tsx",
|
|
232
|
+
content: 'import {CueWave, defineComponentPreview} from "odori/preview";\nimport {bedDrift, type BedDriftOptions} from "./bed-drift";\n\nconst Wave = ({root, detuneCents, peak}: BedDriftOptions) => <CueWave cue={bedDrift({root, detuneCents, peak})} />;\n\nexport default defineComponentPreview({\n title: "Drift bed",\n category: "Sound",\n description: "Sustained pads with no rhythm, for slow product shots.",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: "6s"},\n controls: {\n root: {type: "select", defaultValue: "f2", options: ["c2", "d2", "f2", "a1"]},\n detuneCents: {type: "number", defaultValue: 7, min: 0, max: 24},\n peak: {type: "number", defaultValue: 0.5, min: 0.1, max: 1, step: 0.05},\n },\n examples: [\n {name: "Default", props: {}},\n {name: "Wider", props: {detuneCents: 16}},\n ],\n});\n',
|
|
233
|
+
target: "videos/components/bed-drift/bed-drift.preview.tsx"
|
|
234
|
+
}
|
|
235
|
+
],
|
|
236
|
+
meta: {
|
|
237
|
+
kind: "cue",
|
|
238
|
+
family: "Sound",
|
|
239
|
+
namespaced: "@odori/bed-drift",
|
|
240
|
+
contract: {
|
|
241
|
+
aspectRatios: [
|
|
242
|
+
"16:9",
|
|
243
|
+
"9:16",
|
|
244
|
+
"1:1"
|
|
245
|
+
],
|
|
246
|
+
recommendedDurationInFrames: 240,
|
|
247
|
+
minimumDurationInFrames: 240,
|
|
248
|
+
entranceFrames: 0,
|
|
249
|
+
exitFrames: 0,
|
|
250
|
+
contentLimits: {},
|
|
251
|
+
reducedMotion: "the waveform renders without a playhead",
|
|
252
|
+
requires: {
|
|
253
|
+
fonts: [
|
|
254
|
+
"mono"
|
|
255
|
+
],
|
|
256
|
+
audio: []
|
|
257
|
+
},
|
|
258
|
+
loops: true
|
|
259
|
+
},
|
|
260
|
+
cue: {
|
|
261
|
+
name: "bed.drift",
|
|
262
|
+
export: "bedDrift"
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
},
|
|
266
|
+
{
|
|
267
|
+
name: "bed-pulse",
|
|
268
|
+
description: "A soft repeating pulse over a held chord, for narration.",
|
|
269
|
+
registryDependencies: [],
|
|
270
|
+
files: [
|
|
271
|
+
{
|
|
272
|
+
path: "components/bed-pulse/bed-pulse.tsx",
|
|
273
|
+
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 type CueDefinition,\n} from "odori";\n\nexport type BedPulseOptions = {\n /** Tempo of the pulse. Slower reads calmer under narration. */\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 * Two bars of soft repeating pulse: a held minor seventh underneath, and a\n * muted low tone on each beat to give the bed a heartbeat without giving it a\n * groove. Written to sit under a voice, so nothing in it competes for the\n * frequencies a voice uses.\n *\n * It loops, so the placement repeats it for as long as the scene runs and the\n * score is rendered once.\n */\nexport const bedPulse = ({bpm = 84, root = "c2", peak = 0.55}: BedPulseOptions = {}): CueDefinition => {\n const beat = step(bpm, 4);\n const bars = 2;\n const samples = beat * 4 * bars;\n\n return defineCue({\n name: "bed.pulse",\n // Two bars at this tempo, in frames at 30fps.\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, low passed so it stays under everything else.\n gain(lowPass(pad(chord(root, "minor7"), samples, {envelope: {attack: 0.9, sustain: 1, release: 0.9}}), 900), 0.9),\n // The pulse itself: one soft tone per beat, decaying immediately.\n sequence(\n Array.from({length: 4 * bars}, (_, index) => ({\n at: index,\n play: (length: number) =>\n shape(sine(length, note(root) * 2), {attack: 0.01, decay: 0.35, sustain: 0, release: 0.1}),\n // The downbeat is louder, which is what makes four beats read as a bar.\n gain: index % 4 === 0 ? 0.5 : 0.28,\n })),\n {bpm, samples},\n ),\n ),\n peak,\n ),\n });\n};\n',
|
|
274
|
+
target: "videos/components/bed-pulse/bed-pulse.tsx"
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
path: "components/bed-pulse/bed-pulse.preview.tsx",
|
|
278
|
+
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",\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',
|
|
279
|
+
target: "videos/components/bed-pulse/bed-pulse.preview.tsx"
|
|
280
|
+
}
|
|
281
|
+
],
|
|
282
|
+
meta: {
|
|
283
|
+
kind: "cue",
|
|
284
|
+
family: "Sound",
|
|
285
|
+
namespaced: "@odori/bed-pulse",
|
|
286
|
+
contract: {
|
|
287
|
+
aspectRatios: [
|
|
288
|
+
"16:9",
|
|
289
|
+
"9:16",
|
|
290
|
+
"1:1"
|
|
291
|
+
],
|
|
292
|
+
recommendedDurationInFrames: 171,
|
|
293
|
+
minimumDurationInFrames: 171,
|
|
294
|
+
entranceFrames: 0,
|
|
295
|
+
exitFrames: 0,
|
|
296
|
+
contentLimits: {},
|
|
297
|
+
reducedMotion: "the waveform renders without a playhead",
|
|
298
|
+
requires: {
|
|
299
|
+
fonts: [
|
|
300
|
+
"mono"
|
|
301
|
+
],
|
|
302
|
+
audio: []
|
|
303
|
+
},
|
|
304
|
+
loops: true
|
|
305
|
+
},
|
|
306
|
+
cue: {
|
|
307
|
+
name: "bed.pulse",
|
|
308
|
+
export: "bedPulse"
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
name: "bed-tick",
|
|
314
|
+
description: "Minimal rhythm for changelogs and lists.",
|
|
315
|
+
registryDependencies: [],
|
|
316
|
+
files: [
|
|
317
|
+
{
|
|
318
|
+
path: "components/bed-tick/bed-tick.tsx",
|
|
319
|
+
content: 'import {\n defineCue,\n gain,\n highPass,\n lowPass,\n mix,\n noise,\n normalize,\n note,\n sequence,\n shape,\n sine,\n step,\n triangle,\n type CueDefinition,\n} from "odori";\n\nexport type BedTickOptions = {\n /** Tempo of the pattern. */\n bpm?: number;\n /** Root of the bass note, as a note name. */\n root?: string;\n /** Shuffle on the offbeats, 0 to 0.3. */\n swing?: number;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * Minimal rhythm: a closed hat on every eighth, a soft kick on one and three,\n * and a bass note under the bar. For changelogs and lists, where the cut moves\n * item to item and the sound should keep time with it.\n *\n * One bar, looping. Everything is pitched away from the middle of the spectrum\n * so a voice or a UI sound still has room.\n */\nexport const bedTick = ({bpm = 104, root = "e2", swing = 0.08, peak = 0.6}: BedTickOptions = {}): CueDefinition => {\n const beat = step(bpm, 4);\n const samples = beat * 4;\n\n const hat = (length: number) =>\n shape(highPass(noise(length, 3), 6000), {attack: 0.001, decay: 0.045, sustain: 0});\n const kick = (length: number) =>\n shape(lowPass(sine(length, note(root) / 2), 220), {attack: 0.004, decay: 0.22, sustain: 0});\n\n return defineCue({\n name: "bed.tick",\n durationInFrames: Math.round((samples / 48000) * 30),\n loops: true,\n params: {bpm, root, swing, peak},\n render: () =>\n normalize(\n mix(\n sequence(\n Array.from({length: 8}, (_, index) => ({at: index, play: hat, gain: index % 2 === 0 ? 0.3 : 0.16})),\n {bpm, division: 8, swing, samples},\n ),\n sequence([{at: 0, play: kick, gain: 0.7}, {at: 2, play: kick, gain: 0.5}], {bpm, samples}),\n // The bass holds the bar together under both.\n gain(shape(triangle(samples, note(root)), {attack: 0.05, sustain: 0.8, release: 0.4}), 0.18),\n ),\n peak,\n ),\n });\n};\n',
|
|
320
|
+
target: "videos/components/bed-tick/bed-tick.tsx"
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
path: "components/bed-tick/bed-tick.preview.tsx",
|
|
324
|
+
content: 'import {CueWave, defineComponentPreview} from "odori/preview";\nimport {bedTick, type BedTickOptions} from "./bed-tick";\n\nconst Wave = ({bpm, root, swing, peak}: BedTickOptions) => <CueWave cue={bedTick({bpm, root, swing, peak})} />;\n\nexport default defineComponentPreview({\n title: "Tick bed",\n category: "Sound",\n description: "Minimal rhythm for changelogs and lists.",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {\n bpm: {type: "number", defaultValue: 104, min: 70, max: 160},\n root: {type: "select", defaultValue: "e2", options: ["c2", "d2", "e2", "g2"]},\n swing: {type: "number", defaultValue: 0.08, min: 0, max: 0.3, step: 0.02},\n peak: {type: "number", defaultValue: 0.6, min: 0.1, max: 1, step: 0.05},\n },\n examples: [\n {name: "Default", props: {}},\n {name: "Straight", props: {swing: 0}},\n ],\n});\n',
|
|
325
|
+
target: "videos/components/bed-tick/bed-tick.preview.tsx"
|
|
326
|
+
}
|
|
327
|
+
],
|
|
328
|
+
meta: {
|
|
329
|
+
kind: "cue",
|
|
330
|
+
family: "Sound",
|
|
331
|
+
namespaced: "@odori/bed-tick",
|
|
332
|
+
contract: {
|
|
333
|
+
aspectRatios: [
|
|
334
|
+
"16:9",
|
|
335
|
+
"9:16",
|
|
336
|
+
"1:1"
|
|
337
|
+
],
|
|
338
|
+
recommendedDurationInFrames: 69,
|
|
339
|
+
minimumDurationInFrames: 69,
|
|
340
|
+
entranceFrames: 0,
|
|
341
|
+
exitFrames: 0,
|
|
342
|
+
contentLimits: {},
|
|
343
|
+
reducedMotion: "the waveform renders without a playhead",
|
|
344
|
+
requires: {
|
|
345
|
+
fonts: [
|
|
346
|
+
"mono"
|
|
347
|
+
],
|
|
348
|
+
audio: []
|
|
349
|
+
},
|
|
350
|
+
loops: true
|
|
351
|
+
},
|
|
352
|
+
cue: {
|
|
353
|
+
name: "bed.tick",
|
|
354
|
+
export: "bedTick"
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
name: "before-after",
|
|
360
|
+
description: "Direct visual comparison with a controlled divider.",
|
|
361
|
+
registryDependencies: [],
|
|
362
|
+
files: [
|
|
363
|
+
{
|
|
364
|
+
path: "components/before-after/before-after.tsx",
|
|
365
|
+
content: 'import type {ReactNode} from "react";\nimport {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type BeforeAfterProps = {\n before: ReactNode;\n after: ReactNode;\n beforeLabel?: string;\n afterLabel?: string;\n /** Where the divider ends up, 0 to 1. */\n settleAt?: number;\n};\n\n/**\n * One frame, two states, a divider that travels across it. Both sides are\n * always rendered at full size, so nothing reflows as the divider moves.\n */\nexport const BeforeAfter = ({before, after, beforeLabel = "Before", afterLabel = "After", settleAt = 0.5}: BeforeAfterProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const position = interpolate(frame, [12, 60], [1, settleAt], {\n easing: Easing.standard,\n extrapolateLeft: "clamp",\n extrapolateRight: "clamp",\n });\n\n const label = (text: string, side: "left" | "right") => (\n <span\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 999,\n bottom: 44 * scale,\n color: brand.colors.muted,\n fontFamily: brand.typography.mono,\n fontSize: 24 * scale,\n left: side === "left" ? 44 * scale : undefined,\n padding: `${10 * scale}px ${20 * scale}px`,\n position: "absolute",\n right: side === "right" ? 44 * scale : undefined,\n }}\n >\n {text}\n </span>\n );\n\n return (\n <Fill>\n <Fill>{after}</Fill>\n <Fill style={{clipPath: `inset(0 ${(1 - position) * 100}% 0 0)`}}>{before}</Fill>\n <Fill style={{pointerEvents: "none"}}>\n <div\n style={{\n background: brand.colors.accent,\n bottom: 0,\n left: `${position * 100}%`,\n position: "absolute",\n top: 0,\n width: 3 * scale,\n }}\n />\n {label(beforeLabel, "left")}\n {label(afterLabel, "right")}\n </Fill>\n </Fill>\n );\n};\n',
|
|
366
|
+
target: "videos/components/before-after/before-after.tsx"
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
path: "components/before-after/before-after.preview.tsx",
|
|
370
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {BeforeAfter} from "./before-after";\n\nexport default defineComponentPreview({\n title: "Before and after",\n category: "Narrative",\n description: "Direct visual comparison with a controlled divider.",\n component: BeforeAfter,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n beforeLabel: {type: "text", defaultValue: "Before"},\n afterLabel: {type: "text", defaultValue: "After"},\n settleAt: {type: "number", defaultValue: 0.5, min: 0.1, max: 0.9, step: 0.05},\n },\n examples: [\n {\n name: "Timeline",\n props: {\n before: "Hand-numbered frames",\n after: "Ordered scenes",\n },\n },\n ],\n});\n',
|
|
371
|
+
target: "videos/components/before-after/before-after.preview.tsx"
|
|
372
|
+
}
|
|
373
|
+
],
|
|
374
|
+
meta: {
|
|
375
|
+
kind: "component",
|
|
376
|
+
family: "Narrative",
|
|
377
|
+
namespaced: "@odori/before-after",
|
|
378
|
+
contract: {
|
|
379
|
+
aspectRatios: [
|
|
380
|
+
"16:9",
|
|
381
|
+
"1:1"
|
|
382
|
+
],
|
|
383
|
+
recommendedDurationInFrames: 120,
|
|
384
|
+
minimumDurationInFrames: 72,
|
|
385
|
+
entranceFrames: 60,
|
|
386
|
+
exitFrames: 0,
|
|
387
|
+
contentLimits: {
|
|
388
|
+
beforeLabel: 18,
|
|
389
|
+
afterLabel: 18
|
|
390
|
+
},
|
|
391
|
+
reducedMotion: "the divider starts at its resting position",
|
|
392
|
+
requires: {
|
|
393
|
+
fonts: [
|
|
394
|
+
"sans",
|
|
395
|
+
"mono"
|
|
396
|
+
],
|
|
397
|
+
audio: []
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
name: "brand-provider",
|
|
404
|
+
description: "Typed color, type, spacing, radius, and motion tokens.",
|
|
405
|
+
registryDependencies: [],
|
|
406
|
+
files: [
|
|
407
|
+
{
|
|
408
|
+
path: "components/brand-provider/brand-provider.tsx",
|
|
409
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type BrandProviderProps = {\n /** Which token groups to show. */\n show?: Array<"colors" | "type" | "motion">;\n title?: string;\n};\n\n/**\n * Reads the resolved brand and renders it. Every token on screen is the value\n * the surrounding video actually uses, so this doubles as a brand sheet and as\n * a check that a layout inherited what you expected.\n */\nexport const BrandProvider = ({show = ["colors", "type", "motion"], title = "Brand tokens"}: BrandProviderProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const swatches = Object.entries(brand.colors);\n\n const row = (index: number) => {\n const delay = 8 + index * 4;\n const progress = interpolate(frame, [delay, delay + 20], [0, 1], {easing: Easing.standard});\n return {opacity: progress, transform: `translateY(${(1 - progress) * 18 * scale}px)`};\n };\n\n return (\n <Fill\n style={{\n gap: 40 * scale,\n justifyContent: "center",\n padding: `${110 * scale}px ${130 * scale}px`,\n }}\n >\n <div style={{fontSize: 62 * scale, fontWeight: 570, letterSpacing: "-0.04em", ...row(0)}}>{title}</div>\n\n {show.includes("colors") ? (\n <div style={{display: "flex", flexWrap: "wrap", gap: 18 * scale}}>\n {swatches.map(([name, value], index) => (\n <div key={name} style={{alignItems: "center", display: "flex", gap: 12 * scale, ...row(index + 1)}}>\n <span\n style={{\n background: value,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 12 * scale,\n height: 56 * scale,\n width: 56 * scale,\n }}\n />\n <span style={{fontFamily: brand.typography.mono, fontSize: 24 * scale}}>\n {name}\n <span style={{color: brand.colors.muted, display: "block"}}>{value}</span>\n </span>\n </div>\n ))}\n </div>\n ) : null}\n\n {show.includes("type") ? (\n <div style={{display: "grid", gap: 8 * scale, ...row(swatches.length + 1)}}>\n <span style={{fontFamily: brand.typography.sans, fontSize: 44 * scale}}>Sans, for statements</span>\n <span style={{color: brand.colors.muted, fontFamily: brand.typography.mono, fontSize: 30 * scale}}>\n Mono, for evidence\n </span>\n </div>\n ) : null}\n\n {show.includes("motion") ? (\n <div\n style={{\n color: brand.colors.muted,\n fontFamily: brand.typography.mono,\n fontSize: 26 * scale,\n ...row(swatches.length + 2),\n }}\n >\n standard [{brand.motion.standard.join(", ")}] \xB7 stagger {brand.motion.staggerFrames}f\n </div>\n ) : null}\n </Fill>\n );\n};\n',
|
|
410
|
+
target: "videos/components/brand-provider/brand-provider.tsx"
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
path: "components/brand-provider/brand-provider.preview.tsx",
|
|
414
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {BrandProvider} from "./brand-provider";\n\nexport default defineComponentPreview({\n title: "Brand provider",\n category: "Foundation",\n description: "Typed color, type, spacing, radius, and motion tokens.",\n component: BrandProvider,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n title: {type: "text", defaultValue: "Brand tokens"},\n },\n examples: [\n {name: "Everything", props: {}},\n {name: "Colors only", props: {show: ["colors"], title: "Palette"}},\n ],\n});\n',
|
|
415
|
+
target: "videos/components/brand-provider/brand-provider.preview.tsx"
|
|
416
|
+
}
|
|
417
|
+
],
|
|
418
|
+
meta: {
|
|
419
|
+
kind: "component",
|
|
420
|
+
family: "Foundation",
|
|
421
|
+
namespaced: "@odori/brand-provider",
|
|
422
|
+
contract: {
|
|
423
|
+
aspectRatios: [
|
|
424
|
+
"16:9",
|
|
425
|
+
"9:16",
|
|
426
|
+
"1:1"
|
|
427
|
+
],
|
|
428
|
+
recommendedDurationInFrames: 120,
|
|
429
|
+
minimumDurationInFrames: 60,
|
|
430
|
+
entranceFrames: 40,
|
|
431
|
+
exitFrames: 0,
|
|
432
|
+
contentLimits: {},
|
|
433
|
+
reducedMotion: "rows appear together",
|
|
434
|
+
requires: {
|
|
435
|
+
fonts: [
|
|
436
|
+
"sans",
|
|
437
|
+
"mono"
|
|
438
|
+
],
|
|
439
|
+
audio: []
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
name: "brand-resolve",
|
|
446
|
+
description: "The closing sting: a fifth, a root, and a sub under the landing.",
|
|
447
|
+
registryDependencies: [],
|
|
448
|
+
files: [
|
|
449
|
+
{
|
|
450
|
+
path: "components/brand-resolve/brand-resolve.tsx",
|
|
451
|
+
content: 'import {\n defineCue,\n mix,\n normalize,\n shape,\n sine,\n type CueDefinition,\n} from "odori";\n\nexport type BrandResolveOptions = {\n /** The note the sting lands on, in hertz. */\n root?: number;\n /** Sub weight under the landing, 0 to 1. */\n weight?: number;\n peak?: number;\n};\n\n/**\n * The closing sting: a fifth above the root arriving first, the root landing\n * under it, and a sub giving the landing weight. Thirty six frames, so an end\n * card can breathe before the cut.\n */\nexport const brandResolve = ({root = 196, weight = 0.5, peak = 0.8}: BrandResolveOptions = {}): CueDefinition =>\n defineCue({\n name: "brand.resolve",\n durationInFrames: 36,\n params: {root, weight, peak},\n render: ({samples}) =>\n normalize(\n mix(\n shape(sine(samples, root * 1.5), {attack: 0.01, decay: 0.3, sustain: 0.18, release: 0.5}),\n shape(sine(samples, root), {attack: 0.04, decay: 0.35, sustain: 0.4, release: 0.6}),\n shape(sine(samples, root / 2), {attack: 0.02, decay: 0.45, sustain: weight * 0.6, release: 0.5}),\n ),\n peak,\n ),\n });\n',
|
|
452
|
+
target: "videos/components/brand-resolve/brand-resolve.tsx"
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
path: "components/brand-resolve/brand-resolve.preview.tsx",
|
|
456
|
+
content: 'import {CueWave, defineComponentPreview} from "odori/preview";\nimport {brandResolve, type BrandResolveOptions} from "./brand-resolve";\n\nconst Wave = ({root, weight, peak}: BrandResolveOptions) => <CueWave cue={brandResolve({root, weight, peak})} />;\n\nexport default defineComponentPreview({\n title: "Brand resolve",\n category: "Sound",\n description: "The closing sting: a fifth, a root, and a sub under the landing.",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: "2s"},\n controls: {\n root: {type: "number", defaultValue: 196, min: 80, max: 400},\n weight: {type: "number", defaultValue: 0.5, min: 0, max: 1, step: 0.05},\n },\n examples: [\n {name: "Default", props: {}},\n {name: "Lighter", props: {root: 262, weight: 0.2}},\n ],\n});\n',
|
|
457
|
+
target: "videos/components/brand-resolve/brand-resolve.preview.tsx"
|
|
458
|
+
}
|
|
459
|
+
],
|
|
460
|
+
meta: {
|
|
461
|
+
kind: "cue",
|
|
462
|
+
family: "Sound",
|
|
463
|
+
namespaced: "@odori/brand-resolve",
|
|
464
|
+
contract: {
|
|
465
|
+
aspectRatios: [
|
|
466
|
+
"16:9",
|
|
467
|
+
"9:16",
|
|
468
|
+
"1:1"
|
|
469
|
+
],
|
|
470
|
+
recommendedDurationInFrames: 36,
|
|
471
|
+
minimumDurationInFrames: 36,
|
|
472
|
+
entranceFrames: 0,
|
|
473
|
+
exitFrames: 0,
|
|
474
|
+
contentLimits: {},
|
|
475
|
+
reducedMotion: "the waveform renders without a playhead",
|
|
476
|
+
requires: {
|
|
477
|
+
fonts: [
|
|
478
|
+
"mono"
|
|
479
|
+
],
|
|
480
|
+
audio: []
|
|
481
|
+
},
|
|
482
|
+
loops: false
|
|
483
|
+
},
|
|
484
|
+
cue: {
|
|
485
|
+
name: "brand.resolve",
|
|
486
|
+
export: "brandResolve"
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
{
|
|
491
|
+
name: "browser-demo",
|
|
492
|
+
description: "Browser chrome for deterministic product interface frames.",
|
|
493
|
+
registryDependencies: [],
|
|
494
|
+
files: [
|
|
495
|
+
{
|
|
496
|
+
path: "components/browser-demo/browser-demo.tsx",
|
|
497
|
+
content: 'import type {ReactNode} from "react";\nimport {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type BrowserDemoProps = {\n url: string;\n children?: ReactNode;\n /** Frame-driven cursor path in canvas coordinates. */\n cursor?: Array<{frame: number; x: number; y: number}>;\n};\n\n/**\n * A browser chrome frame for product UI. Application components can be passed\n * as children when they render deterministically from frozen props.\n */\nexport const BrowserDemo = ({url, children, cursor = []}: BrowserDemoProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const appear = interpolate(frame, [0, 18], [0, 1], {easing: Easing.standard});\n\n const position =\n cursor.length >= 2\n ? {\n x: interpolate(frame, cursor.map((point) => point.frame), cursor.map((point) => point.x)),\n y: interpolate(frame, cursor.map((point) => point.frame), cursor.map((point) => point.y)),\n }\n : null;\n\n return (\n <Fill style={{alignItems: "center", justifyContent: "center", padding: 100 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 20 * scale,\n boxShadow: `0 ${40 * scale}px ${80 * scale}px rgba(0,0,0,0.45)`,\n height: "100%",\n maxHeight: 860 * scale,\n maxWidth: 1500 * scale,\n opacity: appear,\n overflow: "hidden",\n position: "relative",\n transform: `translateY(${(1 - appear) * 30 * scale}px) scale(${0.98 + appear * 0.02})`,\n width: "100%",\n }}\n >\n <div\n style={{\n alignItems: "center",\n borderBottom: `1px solid ${brand.colors.border}`,\n background: "rgba(255,255,255,0.02)",\n display: "flex",\n gap: 9 * scale,\n padding: `${16 * scale}px ${22 * scale}px`,\n }}\n >\n {[0, 1, 2].map((index) => (\n <span key={index} style={{background: "#2a2a2a", borderRadius: 999, height: 12 * scale, width: 12 * scale}} />\n ))}\n <div\n style={{\n background: brand.colors.background,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 999,\n color: brand.colors.muted,\n flex: 1,\n fontFamily: brand.typography.mono,\n fontSize: 20 * scale,\n marginLeft: 12 * scale,\n padding: `${8 * scale}px ${18 * scale}px`,\n textAlign: "center",\n }}\n >\n {url}\n </div>\n </div>\n <div style={{flex: 1, position: "relative"}}>{children}</div>\n {position ? (\n <div\n style={{\n borderBottom: `${14 * scale}px solid ${brand.colors.foreground}`,\n borderLeft: `${9 * scale}px solid transparent`,\n borderRight: `${9 * scale}px solid transparent`,\n height: 0,\n left: position.x * scale,\n position: "absolute",\n top: position.y * scale,\n transform: "rotate(-30deg)",\n width: 0,\n }}\n />\n ) : null}\n </div>\n </Fill>\n );\n};\n',
|
|
498
|
+
target: "videos/components/browser-demo/browser-demo.tsx"
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
path: "components/browser-demo/browser-demo.preview.tsx",
|
|
502
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {BrowserDemo} from "./browser-demo";\n\nexport default defineComponentPreview({\n title: "Browser demo",\n category: "Product UI",\n description: "Browser chrome for deterministic product interface frames.",\n component: BrowserDemo,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {url: {type: "text", defaultValue: "odori.dev/docs"}},\n examples: [\n {name: "Empty frame", props: {url: "odori.dev/docs"}},\n {\n name: "With cursor",\n props: {\n url: "odori.dev/components",\n cursor: [\n {frame: 0, x: 200, y: 200},\n {frame: 60, x: 900, y: 520},\n ],\n },\n },\n ],\n});\n',
|
|
503
|
+
target: "videos/components/browser-demo/browser-demo.preview.tsx"
|
|
504
|
+
}
|
|
505
|
+
],
|
|
506
|
+
meta: {
|
|
507
|
+
kind: "component",
|
|
508
|
+
family: "Product UI",
|
|
509
|
+
namespaced: "@odori/browser-demo",
|
|
510
|
+
contract: {
|
|
511
|
+
aspectRatios: [
|
|
512
|
+
"16:9"
|
|
513
|
+
],
|
|
514
|
+
recommendedDurationInFrames: 240,
|
|
515
|
+
minimumDurationInFrames: 90,
|
|
516
|
+
entranceFrames: 18,
|
|
517
|
+
exitFrames: 12,
|
|
518
|
+
contentLimits: {
|
|
519
|
+
url: 48
|
|
520
|
+
},
|
|
521
|
+
reducedMotion: "no cursor motion",
|
|
522
|
+
requires: {
|
|
523
|
+
fonts: [
|
|
524
|
+
"mono",
|
|
525
|
+
"sans"
|
|
526
|
+
],
|
|
527
|
+
audio: []
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
{
|
|
533
|
+
name: "canvas-magnifier",
|
|
534
|
+
description: "A true enlarged detail lens over authored content.",
|
|
535
|
+
registryDependencies: [],
|
|
536
|
+
files: [
|
|
537
|
+
{
|
|
538
|
+
path: "components/canvas-magnifier/canvas-magnifier.tsx",
|
|
539
|
+
content: 'import type {ReactNode} from "react";\nimport {Easing, Fill, cursorAt, interpolate, useBrand, useFrame, useDesignScale, type CursorStop} from "odori";\n\nexport type CanvasMagnifierProps = {\n /** What is being magnified. Rendered once, at full size, underneath. */\n children?: ReactNode;\n /** Where the lens sits, in composition pixels. Accepts an authored path. */\n path?: CursorStop[];\n /** Static position, when the lens does not move. */\n x?: number;\n y?: number;\n /** Lens diameter in composition pixels. */\n size?: number;\n /** How much larger the detail appears. */\n zoom?: number;\n /** Frames the lens takes to open. */\n entranceFrames?: number;\n};\n\n/**\n * A lens that shows the content underneath it, larger.\n *\n * True enlargement, not a copy: the same subtree is rendered a second time\n * inside a clipped circle, scaled about the lens centre, so what the lens\n * shows is the real thing and cannot drift from it. A screenshot pasted into a\n * circle would be a second asset to keep in step, and it would be wrong the\n * first time the content changed.\n *\n * The cost is that children render twice per frame. That is the honest price\n * of the detail being real, and it is why the lens takes a subtree rather than\n * the whole scene.\n */\nexport const CanvasMagnifier = ({\n children,\n path,\n x = 960,\n y = 540,\n size = 460,\n zoom = 2.4,\n entranceFrames = 14,\n}: CanvasMagnifierProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n const cursor = path && path.length > 0 ? cursorAt(path, frame) : null;\n const centreX = cursor ? cursor.x : x;\n const centreY = cursor ? cursor.y : y;\n const open = interpolate(frame, [0, entranceFrames], [0, 1], {easing: Easing.standard}) * (cursor?.visible ?? 1);\n\n const diameter = size * scale;\n const left = centreX * scale - diameter / 2;\n const top = centreY * scale - diameter / 2;\n\n return (\n <Fill>\n {children}\n <div\n aria-hidden\n style={{\n border: `${Math.max(1, 3 * scale)}px solid ${brand.colors.border}`,\n borderRadius: "50%",\n boxShadow: `0 ${20 * scale}px ${50 * scale}px rgba(0,0,0,0.5)`,\n height: diameter,\n left,\n opacity: open,\n overflow: "hidden",\n position: "absolute",\n top,\n transform: `scale(${0.9 + open * 0.1})`,\n width: diameter,\n }}\n >\n {/* The same subtree, scaled about the point under the lens, so the\n detail is the content rather than a picture of it. */}\n <div\n style={{\n height: "100%",\n left: 0,\n position: "absolute",\n top: 0,\n transform: `scale(${zoom}) translate(${(diameter / 2 - centreX * scale) / zoom}px, ${\n (diameter / 2 - centreY * scale) / zoom\n }px)`,\n transformOrigin: "top left",\n width: "100%",\n }}\n >\n {children}\n </div>\n </div>\n </Fill>\n );\n};\n',
|
|
540
|
+
target: "videos/components/canvas-magnifier/canvas-magnifier.tsx"
|
|
541
|
+
},
|
|
542
|
+
{
|
|
543
|
+
path: "components/canvas-magnifier/canvas-magnifier.preview.tsx",
|
|
544
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {CanvasMagnifier} from "./canvas-magnifier";\n\nconst Detail = () => (\n <div style={{background: "#080808", inset: 0, position: "absolute"}}>\n {Array.from({length: 7}, (_, row) => (\n <div\n key={row}\n style={{\n color: row === 3 ? "#f5f5f5" : "#5a5a5a",\n fontFamily: "ui-monospace, monospace",\n fontSize: 34,\n left: 200,\n position: "absolute",\n top: 260 + row * 70,\n }}\n >\n {row === 3 ? "duration: \\"12s\\"," : `line ${row + 1}: const value = ${row * 7};`}\n </div>\n ))}\n </div>\n);\n\nexport default defineComponentPreview({\n title: "Canvas magnifier",\n category: "Media and canvas",\n description: "A true enlarged detail lens over authored content.",\n component: CanvasMagnifier,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n size: {type: "number", defaultValue: 460, min: 200, max: 900},\n zoom: {type: "number", defaultValue: 2.4, min: 1.2, max: 5, step: 0.1},\n },\n examples: [\n {name: "Default", props: {children: <Detail />, x: 520, y: 470}},\n {\n name: "Travelling",\n props: {\n children: <Detail />,\n path: [\n {frame: 0, x: 400, y: 300},\n {frame: 45, x: 520, y: 470, hold: 25},\n {frame: 80, x: 700, y: 640},\n ],\n },\n },\n ],\n});\n',
|
|
545
|
+
target: "videos/components/canvas-magnifier/canvas-magnifier.preview.tsx"
|
|
546
|
+
}
|
|
547
|
+
],
|
|
548
|
+
meta: {
|
|
549
|
+
kind: "component",
|
|
550
|
+
family: "Media and canvas",
|
|
551
|
+
namespaced: "@odori/canvas-magnifier",
|
|
552
|
+
contract: {
|
|
553
|
+
aspectRatios: [
|
|
554
|
+
"16:9",
|
|
555
|
+
"9:16",
|
|
556
|
+
"1:1"
|
|
557
|
+
],
|
|
558
|
+
recommendedDurationInFrames: 120,
|
|
559
|
+
minimumDurationInFrames: 30,
|
|
560
|
+
entranceFrames: 14,
|
|
561
|
+
exitFrames: 0,
|
|
562
|
+
contentLimits: {},
|
|
563
|
+
reducedMotion: "the lens is open in place from the first frame",
|
|
564
|
+
requires: {
|
|
565
|
+
fonts: [],
|
|
566
|
+
audio: []
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
name: "canvas-stage",
|
|
573
|
+
description: "Frame-driven canvas drawing with export parity.",
|
|
574
|
+
registryDependencies: [],
|
|
575
|
+
files: [
|
|
576
|
+
{
|
|
577
|
+
path: "components/canvas-stage/canvas-stage.tsx",
|
|
578
|
+
content: 'import {Fill, random, useBrand, useCanvas, useDesignScale, useVideo, type CanvasDraw} from "odori";\n\nexport type CanvasStageProps = {\n /** The draw call. Receives the 2D context and the frame it is drawing. */\n draw?: CanvasDraw;\n /** Canvas size in composition pixels. Defaults to the whole frame. */\n width?: number;\n height?: number;\n /** Particles drawn by the built-in field, when no draw is supplied. */\n count?: number;\n /** Seed for the built-in field, so two videos can differ on purpose. */\n seed?: string;\n};\n\n/**\n * A canvas that is a function of the frame.\n *\n * Everything a composition can do with markup, it can do with a draw call \u2014\n * and a draw call can do the things markup cannot: sample pixels, warp,\n * accumulate a trail without a thousand elements. What it must not do is\n * animate itself. `requestAnimationFrame` is wall time, and wall time means an\n * export samples wherever the loop happened to be, so two chunks disagree at\n * their seam.\n *\n * The default draw is a field of particles positioned by seeded randomness and\n * moved by the frame. It exists to make the contract legible: no state, no\n * timers, no accumulation \u2014 the same number in, the same pixels out.\n */\nexport const CanvasStage = ({draw, width, height, count = 120, seed = "field"}: CanvasStageProps) => {\n const brand = useBrand();\n const scale = useDesignScale();\n const video = useVideo();\n\n const canvasWidth = width ?? video.width;\n const canvasHeight = height ?? video.height;\n\n const field: CanvasDraw = (context, {frame, width: w, height: h}) => {\n context.fillStyle = brand.colors.accent;\n for (let index = 0; index < count; index += 1) {\n // Position comes from the seed; motion comes from the frame. Neither\n // reads a clock, so frame 200 is the same picture on every run.\n const x = random([seed, "x", index]) * w;\n const drift = random([seed, "speed", index]) * 0.6 + 0.2;\n const y = (random([seed, "y", index]) * h + frame * drift * scale * 2) % h;\n const size = (random([seed, "size", index]) * 3 + 1) * scale;\n context.globalAlpha = 0.15 + random([seed, "alpha", index]) * 0.5;\n context.beginPath();\n context.arc(x, y, size, 0, Math.PI * 2);\n context.fill();\n }\n context.globalAlpha = 1;\n };\n\n const canvas = useCanvas(draw ?? field, [draw, brand.colors.accent, count, seed, scale]);\n\n return (\n <Fill style={{alignItems: "center", background: brand.colors.background, justifyContent: "center"}}>\n <canvas ref={canvas} width={canvasWidth} height={canvasHeight} style={{height: "100%", width: "100%"}} />\n </Fill>\n );\n};\n',
|
|
579
|
+
target: "videos/components/canvas-stage/canvas-stage.tsx"
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
path: "components/canvas-stage/canvas-stage.preview.tsx",
|
|
583
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {CanvasStage} from "./canvas-stage";\n\nexport default defineComponentPreview({\n title: "Canvas stage",\n category: "Media and canvas",\n description: "Frame-driven canvas drawing with export parity.",\n component: CanvasStage,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {\n count: {type: "number", defaultValue: 120, min: 10, max: 600},\n seed: {type: "text", defaultValue: "field"},\n },\n examples: [\n {name: "Default", props: {}},\n {name: "Dense", props: {count: 400}},\n {name: "Another seed", props: {seed: "launch"}},\n ],\n});\n',
|
|
584
|
+
target: "videos/components/canvas-stage/canvas-stage.preview.tsx"
|
|
585
|
+
}
|
|
586
|
+
],
|
|
587
|
+
meta: {
|
|
588
|
+
kind: "component",
|
|
589
|
+
family: "Media and canvas",
|
|
590
|
+
namespaced: "@odori/canvas-stage",
|
|
591
|
+
contract: {
|
|
592
|
+
aspectRatios: [
|
|
593
|
+
"16:9",
|
|
594
|
+
"9:16",
|
|
595
|
+
"1:1"
|
|
596
|
+
],
|
|
597
|
+
recommendedDurationInFrames: 150,
|
|
598
|
+
minimumDurationInFrames: 30,
|
|
599
|
+
entranceFrames: 0,
|
|
600
|
+
exitFrames: 0,
|
|
601
|
+
contentLimits: {
|
|
602
|
+
count: 600
|
|
603
|
+
},
|
|
604
|
+
reducedMotion: "the field holds its first frame",
|
|
605
|
+
requires: {
|
|
606
|
+
fonts: [],
|
|
607
|
+
audio: []
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
name: "captions",
|
|
614
|
+
description: "Frame-accurate caption cues for sound-off playback.",
|
|
615
|
+
registryDependencies: [],
|
|
616
|
+
files: [
|
|
617
|
+
{
|
|
618
|
+
path: "components/captions/captions.tsx",
|
|
619
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type CaptionCue = {text: string; fromFrame: number; durationInFrames: number};\n\nexport type CaptionsProps = {\n cues: CaptionCue[];\n position?: "bottom" | "center";\n};\n\n/** Frame-accurate captions, safe-area aware. */\nexport const Captions = ({cues, position = "bottom"}: CaptionsProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const active = cues.find((cue) => frame >= cue.fromFrame && frame < cue.fromFrame + cue.durationInFrames);\n if (!active) return null;\n\n const local = frame - active.fromFrame;\n const opacity = interpolate(\n local,\n [0, 6, Math.max(7, active.durationInFrames - 6), active.durationInFrames],\n [0, 1, 1, 0],\n {easing: Easing.standard},\n );\n\n return (\n <Fill\n style={{\n alignItems: "center",\n justifyContent: position === "bottom" ? "flex-end" : "center",\n padding: `${72 * scale}px ${96 * scale}px`,\n pointerEvents: "none",\n }}\n >\n <div\n style={{\n background: "rgba(0,0,0,0.55)",\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 14 * scale,\n fontSize: 40 * scale,\n fontWeight: 520,\n maxWidth: 1400 * scale,\n opacity,\n padding: `${16 * scale}px ${28 * scale}px`,\n textAlign: "center",\n }}\n >\n {active.text}\n </div>\n </Fill>\n );\n};\n',
|
|
620
|
+
target: "videos/components/captions/captions.tsx"
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
path: "components/captions/captions.preview.tsx",
|
|
624
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {Captions} from "./captions";\n\nexport default defineComponentPreview({\n title: "Captions",\n category: "Typography",\n description: "Frame-accurate caption cues for sound-off playback.",\n component: Captions,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n examples: [\n {\n name: "Two cues",\n props: {\n cues: [\n {text: "Preview needs no encode.", fromFrame: 0, durationInFrames: 60},\n {text: "Export freezes the approved cut.", fromFrame: 62, durationInFrames: 70},\n ],\n },\n },\n ],\n});\n',
|
|
625
|
+
target: "videos/components/captions/captions.preview.tsx"
|
|
626
|
+
}
|
|
627
|
+
],
|
|
628
|
+
meta: {
|
|
629
|
+
kind: "component",
|
|
630
|
+
family: "Typography",
|
|
631
|
+
namespaced: "@odori/captions",
|
|
632
|
+
contract: {
|
|
633
|
+
aspectRatios: [
|
|
634
|
+
"16:9",
|
|
635
|
+
"9:16",
|
|
636
|
+
"1:1"
|
|
637
|
+
],
|
|
638
|
+
recommendedDurationInFrames: 60,
|
|
639
|
+
minimumDurationInFrames: 20,
|
|
640
|
+
entranceFrames: 6,
|
|
641
|
+
exitFrames: 6,
|
|
642
|
+
contentLimits: {
|
|
643
|
+
text: 84
|
|
644
|
+
},
|
|
645
|
+
reducedMotion: "cuts instead of fading",
|
|
646
|
+
requires: {
|
|
647
|
+
fonts: [
|
|
648
|
+
"sans"
|
|
649
|
+
],
|
|
650
|
+
audio: []
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
{
|
|
656
|
+
name: "carousel",
|
|
657
|
+
description: "A paced media sequence with stable aspect handling.",
|
|
658
|
+
registryDependencies: [],
|
|
659
|
+
files: [
|
|
660
|
+
{
|
|
661
|
+
path: "components/carousel/carousel.tsx",
|
|
662
|
+
content: 'import type {ReactNode} from "react";\nimport {Easing, Fill, interpolate, placeholderImage, useBrand, useDesignScale, useFrame, useVideo} from "odori";\n\nexport type CarouselProps = {\n /** Items to page through. Strings are treated as image URLs. */\n items?: Array<string | ReactNode>;\n /** Frames each item holds before the next arrives. */\n holdInFrames?: number;\n /** Frames the change takes. */\n changeInFrames?: number;\n /** Dots showing position. */\n indicator?: boolean;\n};\n\n/**\n * A paced sequence with a stable frame around it.\n *\n * Two things make a carousel read as considered rather than as a slideshow:\n * the frame never changes size, so nothing reflows between items, and the\n * outgoing item leaves while the incoming one arrives rather than after it.\n * Both are handled here so a caller only supplies the items and the pace.\n */\nexport const Carousel = ({items, holdInFrames = 45, changeInFrames = 14, indicator = true}: CarouselProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const {width, height} = useVideo();\n\n const list =\n items && items.length > 0\n ? items\n : [1, 2, 3].map((index) => placeholderImage({label: `slide ${index}`, seed: `slide-${index}`}));\n\n const cycle = holdInFrames + changeInFrames;\n const index = Math.floor(frame / cycle) % list.length;\n const next = (index + 1) % list.length;\n const local = frame % cycle;\n const changing = interpolate(local, [holdInFrames, cycle], [0, 1], {easing: Easing.standard});\n\n const render = (item: string | ReactNode, key: number, offset: number, opacity: number) => (\n <div\n key={key}\n style={{\n alignItems: "center",\n display: "flex",\n height: "100%",\n inset: 0,\n justifyContent: "center",\n opacity,\n position: "absolute",\n transform: `translateX(${offset}%)`,\n width: "100%",\n }}\n >\n {typeof item === "string" ? (\n <img src={item} alt="" style={{height: "100%", objectFit: "cover", width: "100%"}} />\n ) : (\n item\n )}\n </div>\n );\n\n return (\n <Fill style={{alignItems: "center", justifyContent: "center", overflow: "hidden"}}>\n <div\n style={{\n borderRadius: 20 * scale,\n height: Math.min(height * 0.72, width * 0.5),\n overflow: "hidden",\n position: "relative",\n width: width * 0.78,\n }}\n >\n {render(list[index], index, changing * -12, 1 - changing)}\n {changing > 0 ? render(list[next], next + list.length, (1 - changing) * 12, changing) : null}\n </div>\n\n {indicator ? (\n <div style={{bottom: 60 * scale, display: "flex", gap: 10 * scale, position: "absolute"}}>\n {list.map((_, dot) => (\n <span\n key={dot}\n style={{\n background: dot === index ? brand.colors.foreground : brand.colors.border,\n borderRadius: 999,\n height: 10 * scale,\n width: dot === index ? 28 * scale : 10 * scale,\n }}\n />\n ))}\n </div>\n ) : null}\n </Fill>\n );\n};\n',
|
|
663
|
+
target: "videos/components/carousel/carousel.tsx"
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
path: "components/carousel/carousel.preview.tsx",
|
|
667
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {Carousel} from "./carousel";\n\nexport default defineComponentPreview({\n title: "Carousel",\n category: "Media and canvas",\n description: "A paced media sequence with stable aspect handling.",\n component: Carousel,\n canvas: {width: 1920, height: 1080, duration: "8s"},\n controls: {\n holdInFrames: {type: "number", defaultValue: 45, min: 10, max: 150},\n changeInFrames: {type: "number", defaultValue: 14, min: 4, max: 60},\n indicator: {type: "boolean", defaultValue: true},\n },\n examples: [\n {name: "Default", props: {}},\n {name: "Fast", props: {holdInFrames: 20, changeInFrames: 8}},\n ],\n});\n',
|
|
668
|
+
target: "videos/components/carousel/carousel.preview.tsx"
|
|
669
|
+
}
|
|
670
|
+
],
|
|
671
|
+
meta: {
|
|
672
|
+
kind: "component",
|
|
673
|
+
family: "Media and canvas",
|
|
674
|
+
namespaced: "@odori/carousel",
|
|
675
|
+
contract: {
|
|
676
|
+
aspectRatios: [
|
|
677
|
+
"16:9",
|
|
678
|
+
"9:16",
|
|
679
|
+
"1:1"
|
|
680
|
+
],
|
|
681
|
+
recommendedDurationInFrames: 240,
|
|
682
|
+
minimumDurationInFrames: 60,
|
|
683
|
+
entranceFrames: 14,
|
|
684
|
+
exitFrames: 14,
|
|
685
|
+
contentLimits: {
|
|
686
|
+
items: 8
|
|
687
|
+
},
|
|
688
|
+
reducedMotion: "the first item is shown without paging",
|
|
689
|
+
requires: {
|
|
690
|
+
fonts: [],
|
|
691
|
+
audio: []
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
name: "character-rise",
|
|
698
|
+
description: "Characters rise with a crisp per-letter stagger.",
|
|
699
|
+
registryDependencies: [],
|
|
700
|
+
files: [
|
|
701
|
+
{
|
|
702
|
+
path: "components/character-rise/character-rise.tsx",
|
|
703
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type CharacterRiseProps = {\n text: string;\n detail?: string;\n /** Frames between one character and the next. */\n stagger?: number;\n};\n\n/**\n * Characters rise out of a clipped baseline one after another. Spaces hold\n * their slot so the line never reflows while it assembles.\n */\nexport const CharacterRise = ({text, detail, stagger = 2}: CharacterRiseProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const characters = [...text];\n const settled = 8 + characters.length * stagger + 22;\n\n return (\n <Fill\n style={{\n alignItems: "center",\n gap: 30 * scale,\n justifyContent: "center",\n padding: `${120 * scale}px ${140 * scale}px`,\n }}\n >\n <div style={{display: "flex", overflow: "hidden", paddingBottom: 14 * scale}}>\n {characters.map((character, index) => {\n const delay = 8 + index * stagger;\n const progress = interpolate(frame, [delay, delay + 22], [0, 1], {easing: Easing.standard});\n return (\n <span\n key={`${character}-${index}`}\n style={{\n display: "inline-block",\n fontSize: 132 * scale,\n fontWeight: 600,\n letterSpacing: "-0.04em",\n lineHeight: 1.02,\n opacity: progress,\n transform: `translateY(${(1 - progress) * 84 * scale}px)`,\n whiteSpace: "pre",\n }}\n >\n {character}\n </span>\n );\n })}\n </div>\n {detail ? (\n <div\n style={{\n color: brand.colors.muted,\n fontSize: 34 * scale,\n opacity: interpolate(frame, [settled, settled + 18], [0, 1], {easing: Easing.standard}),\n }}\n >\n {detail}\n </div>\n ) : null}\n </Fill>\n );\n};\n',
|
|
704
|
+
target: "videos/components/character-rise/character-rise.tsx"
|
|
705
|
+
},
|
|
706
|
+
{
|
|
707
|
+
path: "components/character-rise/character-rise.preview.tsx",
|
|
708
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {CharacterRise} from "./character-rise";\n\nexport default defineComponentPreview({\n title: "Character rise",\n category: "Typography",\n description: "Characters rise with a crisp per-letter stagger.",\n component: CharacterRise,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n text: {type: "text", defaultValue: "Momentum", maxLength: 22},\n detail: {type: "text", defaultValue: "Motion with a purpose."},\n stagger: {type: "number", defaultValue: 2, min: 1, max: 8},\n },\n examples: [\n {name: "Default", props: {text: "Momentum", detail: "Motion with a purpose."}},\n {name: "Slow", props: {text: "Deliberate", stagger: 5}},\n ],\n});\n',
|
|
709
|
+
target: "videos/components/character-rise/character-rise.preview.tsx"
|
|
710
|
+
}
|
|
711
|
+
],
|
|
712
|
+
meta: {
|
|
713
|
+
kind: "component",
|
|
714
|
+
family: "Typography",
|
|
715
|
+
namespaced: "@odori/character-rise",
|
|
716
|
+
contract: {
|
|
717
|
+
aspectRatios: [
|
|
718
|
+
"16:9",
|
|
719
|
+
"9:16",
|
|
720
|
+
"1:1"
|
|
721
|
+
],
|
|
722
|
+
recommendedDurationInFrames: 120,
|
|
723
|
+
minimumDurationInFrames: 45,
|
|
724
|
+
entranceFrames: 34,
|
|
725
|
+
exitFrames: 10,
|
|
726
|
+
contentLimits: {
|
|
727
|
+
text: 22
|
|
728
|
+
},
|
|
729
|
+
reducedMotion: "the word fades without per-letter motion",
|
|
730
|
+
requires: {
|
|
731
|
+
fonts: [
|
|
732
|
+
"sans"
|
|
733
|
+
],
|
|
734
|
+
audio: []
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
},
|
|
739
|
+
{
|
|
740
|
+
name: "code-proof",
|
|
741
|
+
description: "A framed source excerpt that reveals line by line.",
|
|
742
|
+
registryDependencies: [],
|
|
743
|
+
files: [
|
|
744
|
+
{
|
|
745
|
+
path: "components/code-proof/code-proof.tsx",
|
|
746
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type CodeProofProps = {\n code: string;\n language?: string;\n title?: string;\n /** One-based line numbers to emphasize. Other lines dim. */\n focus?: number[];\n caption?: string;\n};\n\ntype Token = {pattern: RegExp; role: "comment" | "keyword" | "string" | "number" | "type" | "punctuation"};\n\nconst TOKENS: Token[] = [\n {pattern: /(\\/\\/[^\\n]*)/g, role: "comment"},\n {pattern: /("[^"]*"|\'[^\']*\'|`[^`]*`)/g, role: "string"},\n {\n pattern: /\\b(import|export|from|const|let|var|return|function|default|await|async|type|interface|new|class)\\b/g,\n role: "keyword",\n },\n {pattern: /\\b([A-Z][A-Za-z0-9]*)\\b/g, role: "type"},\n {pattern: /\\b(\\d+(?:\\.\\d+)?)\\b/g, role: "number"},\n];\n\nconst PALETTE: Record<Token["role"], string> = {\n comment: "#6f6f6f",\n keyword: "#c586ff",\n string: "#7ee787",\n number: "#ffc86b",\n type: "#79c0ff",\n punctuation: "#8b8b8b",\n};\n\nconst highlight = (line: string) => {\n const marks: Array<{start: number; end: number; color: string}> = [];\n for (const token of TOKENS) {\n for (const match of line.matchAll(token.pattern)) {\n const start = match.index ?? 0;\n const end = start + match[0].length;\n if (marks.some((mark) => start < mark.end && end > mark.start)) continue;\n marks.push({start, end, color: PALETTE[token.role]});\n }\n }\n marks.sort((left, right) => left.start - right.start);\n\n const parts: Array<{text: string; color?: string}> = [];\n let cursor = 0;\n for (const mark of marks) {\n if (mark.start > cursor) parts.push({text: line.slice(cursor, mark.start)});\n parts.push({text: line.slice(mark.start, mark.end), color: mark.color});\n cursor = mark.end;\n }\n if (cursor < line.length) parts.push({text: line.slice(cursor)});\n return parts;\n};\n\n/**\n * A source excerpt in a product-grade window. Lines resolve from a small blur\n * so the eye lands on the code rather than on the motion.\n */\nexport const CodeProof = ({code, language = "tsx", title, focus = [], caption}: CodeProofProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const lines = code.replace(/\\n+$/, "").split("\\n");\n const enter = interpolate(frame, [0, 22], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: "center", gap: 30 * scale, justifyContent: "center", padding: 110 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 16 * scale,\n boxShadow: `0 ${30 * scale}px ${90 * scale}px rgba(0,0,0,0.6)`,\n maxWidth: 1440 * scale,\n opacity: enter,\n overflow: "hidden",\n transform: `translateY(${(1 - enter) * 28 * scale}px)`,\n width: "100%",\n }}\n >\n <div\n style={{\n alignItems: "center",\n background: "rgba(255,255,255,0.02)",\n borderBottom: `1px solid ${brand.colors.border}`,\n display: "flex",\n gap: 14 * scale,\n padding: `${18 * scale}px ${24 * scale}px`,\n }}\n >\n <div style={{display: "flex", gap: 9 * scale}}>\n {["#2a2a2a", "#2a2a2a", "#2a2a2a"].map((color, index) => (\n <span key={index} style={{background: color, borderRadius: 999, height: 12 * scale, width: 12 * scale}} />\n ))}\n </div>\n {title ? (\n <span\n style={{\n color: brand.colors.muted,\n fontFamily: brand.typography.mono,\n fontSize: 21 * scale,\n letterSpacing: "-0.01em",\n marginLeft: 8 * scale,\n }}\n >\n {title}\n </span>\n ) : null}\n <span\n style={{\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 6 * scale,\n color: brand.colors.muted,\n fontFamily: brand.typography.mono,\n fontSize: 21 * scale,\n marginLeft: "auto",\n padding: `${4 * scale}px ${12 * scale}px`,\n textTransform: "uppercase",\n }}\n >\n {language}\n </span>\n </div>\n\n <pre\n style={{\n color: brand.colors.foreground,\n fontFamily: brand.typography.mono,\n fontSize: 28 * scale,\n lineHeight: 1.65,\n margin: 0,\n padding: `${30 * scale}px ${28 * scale}px`,\n }}\n >\n {lines.map((line, index) => {\n const delay = 10 + index * 3;\n const appear = interpolate(frame, [delay, delay + 14], [0, 1], {easing: Easing.standard});\n const focused = focus.length === 0 || focus.includes(index + 1);\n return (\n <div\n key={`${line}-${index}`}\n style={{\n display: "flex",\n filter: `blur(${(1 - appear) * 6}px)`,\n gap: 24 * scale,\n opacity: appear * (focused ? 1 : 0.32),\n transform: `translateY(${(1 - appear) * 8 * scale}px)`,\n }}\n >\n <span style={{color: "#3a3a3a", minWidth: 32 * scale, textAlign: "right", userSelect: "none"}}>\n {index + 1}\n </span>\n <span style={{whiteSpace: "pre"}}>\n {highlight(line).map((part, partIndex) => (\n <span key={partIndex} style={{color: part.color}}>\n {part.text}\n </span>\n ))}\n </span>\n </div>\n );\n })}\n </pre>\n </div>\n\n {caption ? (\n <div\n style={{\n color: brand.colors.muted,\n fontSize: 30 * scale,\n letterSpacing: "-0.01em",\n opacity: interpolate(frame, [24, 40], [0, 1], {easing: Easing.standard}),\n }}\n >\n {caption}\n </div>\n ) : null}\n </Fill>\n );\n};\n',
|
|
747
|
+
target: "videos/components/code-proof/code-proof.tsx"
|
|
748
|
+
},
|
|
749
|
+
{
|
|
750
|
+
path: "components/code-proof/code-proof.preview.tsx",
|
|
751
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {CodeProof} from "./code-proof";\n\nconst SAMPLE = `export const metadata = defineVideoMetadata({\n id: "launch",\n title: "Product launch",\n duration: "12s",\n});`;\n\nexport default defineComponentPreview({\n title: "Code proof",\n category: "Developer proof",\n description: "A framed source excerpt that reveals line by line.",\n component: CodeProof,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {\n code: {type: "text", defaultValue: SAMPLE, multiline: true},\n language: {type: "select", defaultValue: "tsx", options: ["tsx", "ts", "shell", "json"]},\n title: {type: "text", defaultValue: "videos/launch/video.tsx"},\n caption: {type: "text", defaultValue: "Static metadata, ordinary JSX."},\n },\n examples: [\n {name: "Default", props: {code: SAMPLE, title: "videos/launch/video.tsx"}},\n {name: "Focused line", props: {code: SAMPLE, focus: [3]}},\n ],\n});\n',
|
|
752
|
+
target: "videos/components/code-proof/code-proof.preview.tsx"
|
|
753
|
+
}
|
|
754
|
+
],
|
|
755
|
+
meta: {
|
|
756
|
+
kind: "component",
|
|
757
|
+
family: "Developer proof",
|
|
758
|
+
namespaced: "@odori/code-proof",
|
|
759
|
+
contract: {
|
|
760
|
+
aspectRatios: [
|
|
761
|
+
"16:9",
|
|
762
|
+
"1:1"
|
|
763
|
+
],
|
|
764
|
+
recommendedDurationInFrames: 180,
|
|
765
|
+
minimumDurationInFrames: 90,
|
|
766
|
+
entranceFrames: 18,
|
|
767
|
+
exitFrames: 12,
|
|
768
|
+
contentLimits: {
|
|
769
|
+
lines: 14,
|
|
770
|
+
lineLength: 62
|
|
771
|
+
},
|
|
772
|
+
reducedMotion: "reveals all lines immediately",
|
|
773
|
+
requires: {
|
|
774
|
+
fonts: [
|
|
775
|
+
"mono"
|
|
776
|
+
],
|
|
777
|
+
audio: []
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
},
|
|
782
|
+
{
|
|
783
|
+
name: "code-walkthrough",
|
|
784
|
+
description: "Progressive code reveal with authored focal regions.",
|
|
785
|
+
registryDependencies: [],
|
|
786
|
+
files: [
|
|
787
|
+
{
|
|
788
|
+
path: "components/code-walkthrough/code-walkthrough.tsx",
|
|
789
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type WalkthroughStep = {\n /** Inclusive line range to focus, one-based. */\n from: number;\n to: number;\n note?: string;\n};\n\nexport type CodeWalkthroughProps = {\n code: string;\n steps: WalkthroughStep[];\n title?: string;\n /** Frames each step holds before the next takes focus. */\n hold?: number;\n};\n\n/**\n * Source read in authored order. Every line stays on screen the whole time, so\n * the reader keeps their place; focus moves by dimming everything the current\n * step is not about.\n */\nexport const CodeWalkthrough = ({code, steps, title = "videos/launch/video.tsx", hold = 60}: CodeWalkthroughProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const lines = code.split("\\n");\n const index = Math.min(steps.length - 1, Math.floor(Math.max(0, frame - 12) / hold));\n const step = steps[index];\n const stepStart = 12 + index * hold;\n const focus = interpolate(frame, [stepStart, stepStart + 16], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: "center", justifyContent: "center", padding: 90 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 26 * scale,\n minWidth: 1180 * scale,\n overflow: "hidden",\n }}\n >\n <div\n style={{\n borderBottom: `1px solid ${brand.colors.border}`,\n color: brand.colors.muted,\n fontFamily: brand.typography.mono,\n fontSize: 24 * scale,\n padding: `${20 * scale}px ${28 * scale}px`,\n }}\n >\n {title}\n </div>\n <div style={{padding: `${20 * scale}px 0`}}>\n {lines.map((line, lineIndex) => {\n const number = lineIndex + 1;\n const inFocus = step && number >= step.from && number <= step.to;\n const dim = inFocus ? 1 : 1 - 0.68 * focus;\n return (\n <div\n key={`${line}-${lineIndex}`}\n style={{\n alignItems: "center",\n background: inFocus\n ? `color-mix(in srgb, ${brand.colors.accent} ${focus * 10}%, transparent)`\n : "transparent",\n display: "flex",\n gap: 24 * scale,\n opacity: dim,\n padding: `${6 * scale}px ${28 * scale}px`,\n }}\n >\n <span style={{color: brand.colors.muted, fontFamily: brand.typography.mono, fontSize: 24 * scale, width: 44 * scale}}>\n {number}\n </span>\n <span style={{fontFamily: brand.typography.mono, fontSize: 30 * scale, whiteSpace: "pre"}}>{line}</span>\n </div>\n );\n })}\n </div>\n {step?.note ? (\n <div\n style={{\n borderTop: `1px solid ${brand.colors.border}`,\n color: brand.colors.muted,\n fontSize: 30 * scale,\n opacity: focus,\n padding: `${22 * scale}px ${28 * scale}px`,\n }}\n >\n {step.note}\n </div>\n ) : null}\n </div>\n </Fill>\n );\n};\n',
|
|
790
|
+
target: "videos/components/code-walkthrough/code-walkthrough.tsx"
|
|
791
|
+
},
|
|
792
|
+
{
|
|
793
|
+
path: "components/code-walkthrough/code-walkthrough.preview.tsx",
|
|
794
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {CodeWalkthrough} from "./code-walkthrough";\n\nexport default defineComponentPreview({\n title: "Code walkthrough",\n category: "Developer proof",\n description: "Progressive code reveal with authored focal regions.",\n component: CodeWalkthrough,\n canvas: {width: 1920, height: 1080, duration: "8s"},\n controls: {\n title: {type: "text", defaultValue: "videos/launch/video.tsx"},\n hold: {type: "number", defaultValue: 60, min: 24, max: 120},\n },\n examples: [\n {\n name: "Metadata to timeline",\n props: {\n code: `export const metadata = defineVideoMetadata({\n id: "launch",\n duration: "8s",\n});\n\nexport default function LaunchVideo() {\n return (\n <Video>\n <Scene duration="5s">\n <TitleReveal title="Ship the story." />\n </Scene>\n </Video>\n );\n}`,\n steps: [\n {from: 1, to: 4, note: "Static metadata, so discovery never runs your component."},\n {from: 6, to: 13, note: "Ordinary JSX for the timeline."},\n {from: 8, to: 10, note: "Scenes are ordered, not numbered."},\n ],\n },\n },\n ],\n});\n',
|
|
795
|
+
target: "videos/components/code-walkthrough/code-walkthrough.preview.tsx"
|
|
796
|
+
}
|
|
797
|
+
],
|
|
798
|
+
meta: {
|
|
799
|
+
kind: "component",
|
|
800
|
+
family: "Developer proof",
|
|
801
|
+
namespaced: "@odori/code-walkthrough",
|
|
802
|
+
contract: {
|
|
803
|
+
aspectRatios: [
|
|
804
|
+
"16:9",
|
|
805
|
+
"1:1"
|
|
806
|
+
],
|
|
807
|
+
recommendedDurationInFrames: 240,
|
|
808
|
+
minimumDurationInFrames: 120,
|
|
809
|
+
entranceFrames: 28,
|
|
810
|
+
exitFrames: 0,
|
|
811
|
+
contentLimits: {
|
|
812
|
+
lines: 16,
|
|
813
|
+
lineLength: 62,
|
|
814
|
+
steps: 4
|
|
815
|
+
},
|
|
816
|
+
reducedMotion: "all steps show without dimming",
|
|
817
|
+
requires: {
|
|
818
|
+
fonts: [
|
|
819
|
+
"mono"
|
|
820
|
+
],
|
|
821
|
+
audio: []
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
name: "command-menu",
|
|
828
|
+
description: "Keyboard-first action search and selection flow.",
|
|
829
|
+
registryDependencies: [],
|
|
830
|
+
files: [
|
|
831
|
+
{
|
|
832
|
+
path: "components/command-menu/command-menu.tsx",
|
|
833
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type CommandMenuProps = {\n /** What the operator types, revealed character by character. */\n query: string;\n items: string[];\n /** Index the selection lands on. */\n selectedIndex?: number;\n placeholder?: string;\n};\n\n/**\n * A keyboard-first palette: the query types itself, the list narrows to what\n * matches, and the selection settles on one action. Filtering is computed from\n * the typed prefix, so the list on screen is the list the query produces.\n */\nexport const CommandMenu = ({query, items, selectedIndex = 0, placeholder = "Search actions"}: CommandMenuProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const open = interpolate(frame, [0, 18], [0, 1], {easing: Easing.standard});\n const typed = query.slice(0, Math.max(0, Math.floor((frame - 14) / 2)));\n const matches = items.filter((item) => item.toLowerCase().includes(typed.toLowerCase()));\n const selectAt = 16 + query.length * 2 + 12;\n const selection = interpolate(frame, [selectAt, selectAt + 12], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: "center", justifyContent: "center", padding: 90 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 26 * scale,\n boxShadow: `0 ${40 * scale}px ${120 * scale}px rgba(0,0,0,0.35)`,\n opacity: open,\n overflow: "hidden",\n transform: `translateY(${(1 - open) * 26 * scale}px) scale(${0.97 + open * 0.03})`,\n width: 1080 * scale,\n }}\n >\n <div\n style={{\n alignItems: "center",\n borderBottom: `1px solid ${brand.colors.border}`,\n display: "flex",\n gap: 14 * scale,\n padding: `${26 * scale}px ${30 * scale}px`,\n }}\n >\n <span style={{color: brand.colors.muted, fontFamily: brand.typography.mono, fontSize: 32 * scale}}>{">"}</span>\n <span style={{fontSize: 36 * scale}}>\n {typed || <span style={{color: brand.colors.muted}}>{placeholder}</span>}\n </span>\n <span\n style={{\n background: brand.colors.accent,\n height: 36 * scale,\n opacity: frame % 30 < 15 ? 1 : 0,\n width: 3 * scale,\n }}\n />\n </div>\n <div style={{padding: `${14 * scale}px ${12 * scale}px`}}>\n {matches.map((item, index) => {\n const active = index === Math.min(selectedIndex, matches.length - 1);\n return (\n <div\n key={item}\n style={{\n background: active\n ? `color-mix(in srgb, ${brand.colors.accent} ${selection * 14}%, transparent)`\n : "transparent",\n borderRadius: 14 * scale,\n color: active ? brand.colors.foreground : brand.colors.muted,\n fontSize: 32 * scale,\n padding: `${16 * scale}px ${20 * scale}px`,\n }}\n >\n {item}\n </div>\n );\n })}\n </div>\n </div>\n </Fill>\n );\n};\n',
|
|
834
|
+
target: "videos/components/command-menu/command-menu.tsx"
|
|
835
|
+
},
|
|
836
|
+
{
|
|
837
|
+
path: "components/command-menu/command-menu.preview.tsx",
|
|
838
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {CommandMenu} from "./command-menu";\n\nexport default defineComponentPreview({\n title: "Command menu",\n category: "Developer proof",\n description: "Keyboard-first action search and selection flow.",\n component: CommandMenu,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {\n query: {type: "text", defaultValue: "exp", maxLength: 24},\n selectedIndex: {type: "number", defaultValue: 0, min: 0, max: 5},\n },\n examples: [\n {\n name: "Export",\n props: {\n query: "exp",\n items: ["Export video", "Export still", "Open Studio", "Run contract tests"],\n },\n },\n ],\n});\n',
|
|
839
|
+
target: "videos/components/command-menu/command-menu.preview.tsx"
|
|
840
|
+
}
|
|
841
|
+
],
|
|
842
|
+
meta: {
|
|
843
|
+
kind: "component",
|
|
844
|
+
family: "Developer proof",
|
|
845
|
+
namespaced: "@odori/command-menu",
|
|
846
|
+
contract: {
|
|
847
|
+
aspectRatios: [
|
|
848
|
+
"16:9",
|
|
849
|
+
"1:1"
|
|
850
|
+
],
|
|
851
|
+
recommendedDurationInFrames: 150,
|
|
852
|
+
minimumDurationInFrames: 75,
|
|
853
|
+
entranceFrames: 18,
|
|
854
|
+
exitFrames: 0,
|
|
855
|
+
contentLimits: {
|
|
856
|
+
items: 6,
|
|
857
|
+
itemLength: 32,
|
|
858
|
+
query: 24
|
|
859
|
+
},
|
|
860
|
+
reducedMotion: "the query and selection are shown resolved",
|
|
861
|
+
requires: {
|
|
862
|
+
fonts: [
|
|
863
|
+
"mono"
|
|
864
|
+
],
|
|
865
|
+
audio: []
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
},
|
|
870
|
+
{
|
|
871
|
+
name: "comparison",
|
|
872
|
+
description: "Two options contrasted through matched structure.",
|
|
873
|
+
registryDependencies: [],
|
|
874
|
+
files: [
|
|
875
|
+
{
|
|
876
|
+
path: "components/comparison/comparison.tsx",
|
|
877
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale, useVideo} from "odori";\n\nexport type ComparisonColumn = {\n title: string;\n points: string[];\n preferred?: boolean;\n};\n\nexport type ComparisonProps = {\n columns: ComparisonColumn[];\n headline?: string;\n};\n\n/**\n * Two options in matched structure, so the difference is the content rather\n * than the layout. The preferred column is marked once, at the top, instead of\n * being styled louder throughout.\n */\nexport const Comparison = ({columns, headline}: ComparisonProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const {width, height} = useVideo();\n const stacked = height >= width;\n\n return (\n <Fill style={{gap: 40 * scale, justifyContent: "center", padding: `${100 * scale}px ${110 * scale}px`}}>\n {headline ? (\n <div style={{fontSize: 58 * scale, fontWeight: 560, letterSpacing: "-0.04em", opacity: interpolate(frame, [0, 16], [0, 1])}}>\n {headline}\n </div>\n ) : null}\n <div style={{display: "flex", flexDirection: stacked ? "column" : "row", gap: 24 * scale}}>\n {columns.map((column, columnIndex) => {\n const enter = interpolate(frame, [10 + columnIndex * 10, 10 + columnIndex * 10 + 22], [0, 1], {\n easing: Easing.standard,\n });\n return (\n <div\n key={column.title}\n style={{\n background: brand.colors.surface,\n border: `1px solid ${column.preferred ? brand.colors.accent : brand.colors.border}`,\n borderRadius: 26 * scale,\n display: "grid",\n flex: 1,\n gap: 20 * scale,\n opacity: enter,\n padding: `${34 * scale}px ${34 * scale}px`,\n transform: `translateY(${(1 - enter) * 22 * scale}px)`,\n }}\n >\n <div style={{alignItems: "center", display: "flex", gap: 14 * scale}}>\n <span style={{fontSize: 44 * scale, fontWeight: 570, letterSpacing: "-0.03em"}}>{column.title}</span>\n {column.preferred ? (\n <span\n style={{\n background: brand.colors.accent,\n borderRadius: 999,\n color: brand.colors.background,\n fontFamily: brand.typography.mono,\n fontSize: 20 * scale,\n padding: `${6 * scale}px ${14 * scale}px`,\n }}\n >\n this one\n </span>\n ) : null}\n </div>\n {column.points.map((point, pointIndex) => {\n const at = 18 + columnIndex * 10 + pointIndex * 7;\n const show = interpolate(frame, [at, at + 16], [0, 1], {easing: Easing.standard});\n return (\n <div\n key={point}\n style={{\n color: brand.colors.muted,\n fontSize: 30 * scale,\n lineHeight: 1.4,\n opacity: show,\n transform: `translateX(${(1 - show) * 12 * scale}px)`,\n }}\n >\n {point}\n </div>\n );\n })}\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n',
|
|
878
|
+
target: "videos/components/comparison/comparison.tsx"
|
|
879
|
+
},
|
|
880
|
+
{
|
|
881
|
+
path: "components/comparison/comparison.preview.tsx",
|
|
882
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {Comparison} from "./comparison";\n\nexport default defineComponentPreview({\n title: "Comparison",\n category: "Narrative",\n description: "Two options contrasted through matched structure.",\n component: Comparison,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {headline: {type: "text", defaultValue: "Two ways to ship a launch video"}},\n examples: [\n {\n name: "Manual and Odori",\n props: {\n columns: [\n {title: "Manual edit", points: ["Re-cut for every format", "Copy changes by hand", "Export to see the change"]},\n {\n title: "Odori",\n preferred: true,\n points: ["One layout per format", "Typed inputs per variant", "Preview needs no encode"],\n },\n ],\n },\n },\n ],\n});\n',
|
|
883
|
+
target: "videos/components/comparison/comparison.preview.tsx"
|
|
884
|
+
}
|
|
885
|
+
],
|
|
886
|
+
meta: {
|
|
887
|
+
kind: "component",
|
|
888
|
+
family: "Narrative",
|
|
889
|
+
namespaced: "@odori/comparison",
|
|
890
|
+
contract: {
|
|
891
|
+
aspectRatios: [
|
|
892
|
+
"16:9",
|
|
893
|
+
"9:16",
|
|
894
|
+
"1:1"
|
|
895
|
+
],
|
|
896
|
+
recommendedDurationInFrames: 150,
|
|
897
|
+
minimumDurationInFrames: 75,
|
|
898
|
+
entranceFrames: 60,
|
|
899
|
+
exitFrames: 0,
|
|
900
|
+
contentLimits: {
|
|
901
|
+
columns: 2,
|
|
902
|
+
points: 4,
|
|
903
|
+
pointLength: 42
|
|
904
|
+
},
|
|
905
|
+
reducedMotion: "columns and points appear together",
|
|
906
|
+
requires: {
|
|
907
|
+
fonts: [
|
|
908
|
+
"sans",
|
|
909
|
+
"mono"
|
|
910
|
+
],
|
|
911
|
+
audio: []
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
},
|
|
916
|
+
{
|
|
917
|
+
name: "connection-story",
|
|
918
|
+
description: "A left-to-right pipeline of connected stages.",
|
|
919
|
+
registryDependencies: [],
|
|
920
|
+
files: [
|
|
921
|
+
{
|
|
922
|
+
path: "components/connection-story/connection-story.tsx",
|
|
923
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type ConnectionNode = {label: string; detail?: string};\n\nexport type ConnectionStoryProps = {\n nodes: ConnectionNode[];\n headline?: string;\n};\n\n/** Stages connect left to right as the frame advances. */\nexport const ConnectionStory = ({nodes, headline}: ConnectionStoryProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n return (\n <Fill style={{alignItems: "center", gap: 76 * scale, justifyContent: "center", padding: 110 * scale}}>\n {headline ? (\n <div\n style={{\n fontSize: 58 * scale,\n fontWeight: 540,\n letterSpacing: "-0.04em",\n opacity: interpolate(frame, [0, 16], [0, 1], {easing: Easing.standard}),\n textAlign: "center",\n transform: `translateY(${interpolate(frame, [0, 20], [16 * scale, 0], {easing: Easing.standard})}px)`,\n }}\n >\n {headline}\n </div>\n ) : null}\n\n <div style={{alignItems: "stretch", display: "flex", justifyContent: "center"}}>\n {nodes.map((node, index) => {\n const delay = 14 + index * 16;\n const appear = interpolate(frame, [delay, delay + 18], [0, 1], {easing: Easing.standard});\n const sweep = interpolate(frame, [delay - 8, delay + 12], [0, 100], {easing: Easing.standard});\n return (\n <div key={node.label} style={{alignItems: "center", display: "flex"}}>\n {index > 0 ? (\n <div style={{background: brand.colors.border, height: 1, position: "relative", width: 116 * scale}}>\n <div\n style={{\n background: `linear-gradient(90deg, ${brand.colors.border}, ${brand.colors.foreground})`,\n height: 1,\n width: `${sweep}%`,\n }}\n />\n <span\n style={{\n background: brand.colors.foreground,\n borderRadius: 999,\n height: 7 * scale,\n left: `${sweep}%`,\n opacity: sweep > 2 && sweep < 99 ? 1 : 0,\n position: "absolute",\n top: -3 * scale,\n width: 7 * scale,\n }}\n />\n </div>\n ) : null}\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 14 * scale,\n display: "grid",\n gap: 10 * scale,\n minWidth: 280 * scale,\n opacity: appear,\n padding: `${30 * scale}px ${32 * scale}px`,\n transform: `translateY(${(1 - appear) * 22 * scale}px)`,\n }}\n >\n <span\n style={{\n fontFamily: brand.typography.mono,\n fontSize: 34 * scale,\n fontWeight: 500,\n letterSpacing: "-0.02em",\n }}\n >\n {node.label}\n </span>\n {node.detail ? (\n <span style={{color: brand.colors.muted, fontSize: 25 * scale, letterSpacing: "-0.01em"}}>\n {node.detail}\n </span>\n ) : null}\n </div>\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n',
|
|
924
|
+
target: "videos/components/connection-story/connection-story.tsx"
|
|
925
|
+
},
|
|
926
|
+
{
|
|
927
|
+
path: "components/connection-story/connection-story.preview.tsx",
|
|
928
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {ConnectionStory} from "./connection-story";\n\nexport default defineComponentPreview({\n title: "Connection story",\n category: "Narrative",\n description: "A left-to-right pipeline of connected stages.",\n component: ConnectionStory,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {headline: {type: "text", defaultValue: "One definition, every render path."}},\n examples: [\n {\n name: "Pipeline",\n props: {\n headline: "One definition, every render path.",\n nodes: [\n {label: "videos/", detail: "source of truth"},\n {label: "Studio", detail: "instant preview"},\n {label: "Export", detail: "frozen manifest"},\n ],\n },\n },\n ],\n});\n',
|
|
929
|
+
target: "videos/components/connection-story/connection-story.preview.tsx"
|
|
930
|
+
}
|
|
931
|
+
],
|
|
932
|
+
meta: {
|
|
933
|
+
kind: "component",
|
|
934
|
+
family: "Narrative",
|
|
935
|
+
namespaced: "@odori/connection-story",
|
|
936
|
+
contract: {
|
|
937
|
+
aspectRatios: [
|
|
938
|
+
"16:9"
|
|
939
|
+
],
|
|
940
|
+
recommendedDurationInFrames: 150,
|
|
941
|
+
minimumDurationInFrames: 75,
|
|
942
|
+
entranceFrames: 26,
|
|
943
|
+
exitFrames: 10,
|
|
944
|
+
contentLimits: {
|
|
945
|
+
nodes: 4,
|
|
946
|
+
label: 18
|
|
947
|
+
},
|
|
948
|
+
reducedMotion: "connects without sweeping links",
|
|
949
|
+
requires: {
|
|
950
|
+
fonts: [
|
|
951
|
+
"sans"
|
|
952
|
+
],
|
|
953
|
+
audio: []
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
},
|
|
958
|
+
{
|
|
959
|
+
name: "cursor-focus",
|
|
960
|
+
description: "An authored pointer that moves, holds, and clicks over a surface.",
|
|
961
|
+
registryDependencies: [],
|
|
962
|
+
files: [
|
|
963
|
+
{
|
|
964
|
+
path: "components/cursor-focus/cursor-focus.tsx",
|
|
965
|
+
content: 'import type {ReactNode} from "react";\nimport {Fill, cursorAt, useBrand, useFrame, useDesignScale, type CursorStop} from "odori";\n\nexport type CursorFocusProps = {\n /** The authored path. Coordinates are the composition\'s own pixels. */\n path: CursorStop[];\n /** The surface the pointer moves over. */\n children?: ReactNode;\n /** Pointer size in canvas pixels, before the design scale. */\n size?: number;\n /** Draw a soft spotlight under the pointer, dimming the rest of the frame. */\n spotlight?: boolean;\n};\n\n/**\n * An authored pointer over a product surface.\n *\n * A surface on its own reads as a screenshot. A pointer moving across it and\n * clicking is what makes the same pixels read as someone using the product,\n * which is why every other component in this family composes with this one.\n *\n * The path is source, not a recording: the same frames come out on every\n * machine, and a change to the layout is a change to two numbers rather than a\n * re-record.\n */\nexport const CursorFocus = ({path, children, size = 28, spotlight = false}: CursorFocusProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const cursor = cursorAt(path, frame);\n\n const width = size * scale;\n\n return (\n <Fill>\n {children}\n {spotlight && cursor.visible > 0 ? (\n <div\n aria-hidden\n style={{\n background: `radial-gradient(circle ${420 * scale}px at ${cursor.x * scale}px ${cursor.y * scale}px, rgba(0,0,0,0) 0%, rgba(0,0,0,0.55) 100%)`,\n inset: 0,\n opacity: cursor.visible,\n position: "absolute",\n pointerEvents: "none",\n }}\n />\n ) : null}\n <div\n aria-hidden\n style={{\n height: width,\n left: cursor.x * scale,\n opacity: cursor.visible,\n position: "absolute",\n top: cursor.y * scale,\n // The tip is the anchor, the way a real pointer\'s hotspot is.\n transform: "translate(-2%, -2%)",\n width: width,\n }}\n >\n {/* The press ring, drawn under the arrow so it reads as the surface\n responding rather than as part of the pointer. */}\n {cursor.pressed > 0 ? (\n <span\n style={{\n background: "transparent",\n border: `${Math.max(1, 2 * scale)}px solid ${brand.colors.accent}`,\n borderRadius: 999,\n height: width * 2.4,\n left: "50%",\n opacity: cursor.pressed * 0.8,\n position: "absolute",\n top: "50%",\n transform: `translate(-50%, -50%) scale(${0.4 + (1 - cursor.pressed) * 0.9})`,\n width: width * 2.4,\n }}\n />\n ) : null}\n <svg\n viewBox="0 0 24 24"\n style={{\n display: "block",\n filter: `drop-shadow(0 ${2 * scale}px ${6 * scale}px rgba(0,0,0,0.5))`,\n height: "100%",\n transform: `scale(${1 - cursor.pressed * 0.12})`,\n transformOrigin: "top left",\n width: "100%",\n }}\n >\n <path\n d="M5 2.5 L5 19 L9.2 15.1 L11.9 21.2 L14.8 19.9 L12.1 13.9 L18 13.6 Z"\n fill={brand.colors.foreground}\n stroke={brand.colors.background}\n strokeWidth={1.4}\n strokeLinejoin="round"\n />\n </svg>\n </div>\n </Fill>\n );\n};\n',
|
|
966
|
+
target: "videos/components/cursor-focus/cursor-focus.tsx"
|
|
967
|
+
},
|
|
968
|
+
{
|
|
969
|
+
path: "components/cursor-focus/cursor-focus.preview.tsx",
|
|
970
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {CursorFocus} from "./cursor-focus";\n\n/** A plain surface, so the fixture shows the pointer rather than a product. */\nconst Surface = () => (\n <div style={{background: "#0b0b0b", inset: 0, position: "absolute"}}>\n {[\n {label: "Overview", top: 220},\n {label: "Deployments", top: 340},\n {label: "Settings", top: 460},\n ].map((row) => (\n <div\n key={row.label}\n style={{\n border: "1px solid #1f1f1f",\n borderRadius: 16,\n color: "#d4d4d4",\n fontFamily: "ui-sans-serif, system-ui",\n fontSize: 40,\n left: 260,\n padding: "28px 40px",\n position: "absolute",\n top: row.top,\n width: 900,\n }}\n >\n {row.label}\n </div>\n ))}\n </div>\n);\n\nexport default defineComponentPreview({\n title: "Cursor focus",\n category: "Product UI",\n description: "An authored pointer that moves, holds, and clicks over a surface.",\n component: CursorFocus,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {\n size: {type: "number", defaultValue: 28, min: 16, max: 64},\n spotlight: {type: "boolean", defaultValue: false},\n },\n examples: [\n {\n name: "Default",\n props: {\n children: <Surface />,\n path: [\n {frame: 0, x: 1500, y: 900},\n {frame: 30, x: 640, y: 380, click: true, hold: 20},\n {frame: 60, x: 700, y: 500, click: true, hold: 15},\n ],\n },\n },\n {\n name: "Spotlight",\n props: {\n children: <Surface />,\n spotlight: true,\n path: [\n {frame: 0, x: 1600, y: 300},\n {frame: 36, x: 700, y: 620, click: true, hold: 30},\n ],\n },\n },\n ],\n});\n',
|
|
971
|
+
target: "videos/components/cursor-focus/cursor-focus.preview.tsx"
|
|
972
|
+
}
|
|
973
|
+
],
|
|
974
|
+
meta: {
|
|
975
|
+
kind: "component",
|
|
976
|
+
family: "Product UI",
|
|
977
|
+
namespaced: "@odori/cursor-focus",
|
|
978
|
+
contract: {
|
|
979
|
+
aspectRatios: [
|
|
980
|
+
"16:9",
|
|
981
|
+
"9:16",
|
|
982
|
+
"1:1"
|
|
983
|
+
],
|
|
984
|
+
recommendedDurationInFrames: 150,
|
|
985
|
+
minimumDurationInFrames: 40,
|
|
986
|
+
entranceFrames: 6,
|
|
987
|
+
exitFrames: 20,
|
|
988
|
+
contentLimits: {},
|
|
989
|
+
reducedMotion: "the pointer holds its first stop without travelling",
|
|
990
|
+
requires: {
|
|
991
|
+
fonts: [
|
|
992
|
+
"sans"
|
|
993
|
+
],
|
|
994
|
+
audio: []
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
},
|
|
999
|
+
{
|
|
1000
|
+
name: "dashboard-frame",
|
|
1001
|
+
description: "Navigation, content, and status regions for a SaaS surface.",
|
|
1002
|
+
registryDependencies: [],
|
|
1003
|
+
files: [
|
|
1004
|
+
{
|
|
1005
|
+
path: "components/dashboard-frame/dashboard-frame.tsx",
|
|
1006
|
+
content: 'import type {ReactNode} from "react";\nimport {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type DashboardFrameProps = {\n /** Product name in the sidebar head. */\n product?: string;\n /** Sidebar entries. The active one is the section the content belongs to. */\n sections?: string[];\n /** Which section is current, by index. */\n active?: number;\n /** Shown along the top of the content region. */\n title?: string;\n /** A short status, drawn as a pill beside the title. */\n status?: string;\n children?: ReactNode;\n};\n\n/**\n * The navigation, content, and status regions every SaaS interface has, drawn\n * as a real state rather than a wireframe.\n *\n * The rule this family lives by: a fake dashboard that no user could reach is\n * worse than no dashboard. So there is one active section, one title, and one\n * status \u2014 the things a real screen commits to \u2014 and the content region is a\n * slot, because that is where the truthful part of the demonstration goes.\n */\nexport const DashboardFrame = ({\n product = "Acme",\n sections = ["Overview", "Deployments", "Analytics", "Settings"],\n active = 1,\n title = "Deployments",\n status,\n children,\n}: DashboardFrameProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const appear = interpolate(frame, [0, 16], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{background: brand.colors.background, flexDirection: "row", opacity: appear}}>\n <aside\n style={{\n borderRight: `1px solid ${brand.colors.border}`,\n display: "flex",\n flexDirection: "column",\n gap: 8 * scale,\n padding: `${36 * scale}px ${24 * scale}px`,\n width: 320 * scale,\n }}\n >\n <div\n style={{\n color: brand.colors.foreground,\n fontFamily: brand.typography.sans,\n fontSize: 30 * scale,\n fontWeight: 600,\n letterSpacing: "-0.02em",\n marginBottom: 22 * scale,\n }}\n >\n {product}\n </div>\n {sections.map((section, index) => (\n <div\n key={section}\n style={{\n background: index === active ? brand.colors.surface : "transparent",\n borderRadius: 12 * scale,\n color: index === active ? brand.colors.foreground : brand.colors.muted,\n fontFamily: brand.typography.sans,\n fontSize: 25 * scale,\n padding: `${14 * scale}px ${18 * scale}px`,\n }}\n >\n {section}\n </div>\n ))}\n </aside>\n\n <section style={{display: "flex", flex: 1, flexDirection: "column", minWidth: 0}}>\n <header\n style={{\n alignItems: "center",\n borderBottom: `1px solid ${brand.colors.border}`,\n display: "flex",\n gap: 18 * scale,\n padding: `${34 * scale}px ${44 * scale}px`,\n }}\n >\n <span\n style={{\n color: brand.colors.foreground,\n fontFamily: brand.typography.sans,\n fontSize: 40 * scale,\n fontWeight: 600,\n letterSpacing: "-0.025em",\n }}\n >\n {title}\n </span>\n {status ? (\n <span\n style={{\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 999,\n color: brand.colors.muted,\n fontFamily: brand.typography.mono,\n fontSize: 20 * scale,\n padding: `${7 * scale}px ${16 * scale}px`,\n }}\n >\n {status}\n </span>\n ) : null}\n </header>\n <div style={{flex: 1, minHeight: 0, padding: 44 * scale, position: "relative"}}>{children}</div>\n </section>\n </Fill>\n );\n};\n',
|
|
1007
|
+
target: "videos/components/dashboard-frame/dashboard-frame.tsx"
|
|
1008
|
+
},
|
|
1009
|
+
{
|
|
1010
|
+
path: "components/dashboard-frame/dashboard-frame.preview.tsx",
|
|
1011
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {DashboardFrame} from "./dashboard-frame";\n\nconst Rows = () => (\n <div style={{display: "flex", flexDirection: "column", gap: 16}}>\n {[\n ["odori.dev", "Production", "12s ago"],\n ["docs-site", "Preview", "4m ago"],\n ["studio", "Production", "1h ago"],\n ].map(([name, environment, when]) => (\n <div\n key={name}\n style={{\n alignItems: "center",\n border: "1px solid #1f1f1f",\n borderRadius: 14,\n color: "#e5e5e5",\n display: "flex",\n fontFamily: "ui-sans-serif, system-ui",\n fontSize: 26,\n justifyContent: "space-between",\n padding: "22px 26px",\n }}\n >\n <span>{name}</span>\n <span style={{color: "#8f8f8f"}}>{environment}</span>\n <span style={{color: "#8f8f8f", fontFamily: "ui-monospace, monospace"}}>{when}</span>\n </div>\n ))}\n </div>\n);\n\nexport default defineComponentPreview({\n title: "Dashboard frame",\n category: "Product UI",\n description: "Navigation, content, and status regions for a SaaS surface.",\n component: DashboardFrame,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {\n product: {type: "text", defaultValue: "Acme"},\n title: {type: "text", defaultValue: "Deployments"},\n status: {type: "text", defaultValue: "All systems normal"},\n active: {type: "number", defaultValue: 1, min: 0, max: 3},\n },\n examples: [\n {name: "Default", props: {children: <Rows />}},\n {name: "Settings", props: {active: 3, title: "Settings", status: undefined, children: <Rows />}},\n ],\n});\n',
|
|
1012
|
+
target: "videos/components/dashboard-frame/dashboard-frame.preview.tsx"
|
|
1013
|
+
}
|
|
1014
|
+
],
|
|
1015
|
+
meta: {
|
|
1016
|
+
kind: "component",
|
|
1017
|
+
family: "Product UI",
|
|
1018
|
+
namespaced: "@odori/dashboard-frame",
|
|
1019
|
+
contract: {
|
|
1020
|
+
aspectRatios: [
|
|
1021
|
+
"16:9",
|
|
1022
|
+
"9:16",
|
|
1023
|
+
"1:1"
|
|
1024
|
+
],
|
|
1025
|
+
recommendedDurationInFrames: 150,
|
|
1026
|
+
minimumDurationInFrames: 40,
|
|
1027
|
+
entranceFrames: 16,
|
|
1028
|
+
exitFrames: 0,
|
|
1029
|
+
contentLimits: {
|
|
1030
|
+
product: 24,
|
|
1031
|
+
title: 40,
|
|
1032
|
+
status: 48
|
|
1033
|
+
},
|
|
1034
|
+
reducedMotion: "the frame appears without fading in",
|
|
1035
|
+
requires: {
|
|
1036
|
+
fonts: [
|
|
1037
|
+
"sans"
|
|
1038
|
+
],
|
|
1039
|
+
audio: []
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
},
|
|
1044
|
+
{
|
|
1045
|
+
name: "data-table",
|
|
1046
|
+
description: "Sortable rows with an authored selection story.",
|
|
1047
|
+
registryDependencies: [],
|
|
1048
|
+
files: [
|
|
1049
|
+
{
|
|
1050
|
+
path: "components/data-table/data-table.tsx",
|
|
1051
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type DataTableProps = {\n columns: string[];\n rows: string[][];\n /** Row that ends up selected, zero based. */\n selectedIndex?: number;\n title?: string;\n};\n\n/**\n * Rows arriving in order, ending on one authored selection. The selected row\n * fills rather than recolors its text, so the story survives a brand where\n * accent and foreground sit close together.\n */\nexport const DataTable = ({columns, rows, selectedIndex, title}: DataTableProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const selectAt = 16 + rows.length * 7;\n\n return (\n <Fill style={{alignItems: "center", justifyContent: "center", padding: 90 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 26 * scale,\n minWidth: 1200 * scale,\n overflow: "hidden",\n }}\n >\n {title ? (\n <div\n style={{\n borderBottom: `1px solid ${brand.colors.border}`,\n fontSize: 40 * scale,\n fontWeight: 560,\n padding: `${24 * scale}px ${30 * scale}px`,\n }}\n >\n {title}\n </div>\n ) : null}\n <div\n style={{\n color: brand.colors.muted,\n display: "grid",\n fontSize: 26 * scale,\n gap: 20 * scale,\n gridTemplateColumns: `repeat(${columns.length}, 1fr)`,\n padding: `${20 * scale}px ${30 * scale}px`,\n }}\n >\n {columns.map((column) => (\n <span key={column}>{column}</span>\n ))}\n </div>\n {rows.map((row, index) => {\n const delay = 14 + index * 7;\n const enter = interpolate(frame, [delay, delay + 16], [0, 1], {easing: Easing.standard});\n const selected = index === selectedIndex;\n const fill = selected ? interpolate(frame, [selectAt, selectAt + 16], [0, 1], {easing: Easing.standard}) : 0;\n return (\n <div\n key={index}\n style={{\n background: `color-mix(in srgb, ${brand.colors.accent} ${fill * 14}%, transparent)`,\n borderTop: `1px solid ${brand.colors.border}`,\n display: "grid",\n fontSize: 30 * scale,\n gap: 20 * scale,\n gridTemplateColumns: `repeat(${columns.length}, 1fr)`,\n opacity: enter,\n padding: `${20 * scale}px ${30 * scale}px`,\n transform: `translateY(${(1 - enter) * 10 * scale}px)`,\n }}\n >\n {row.map((cell, cellIndex) => (\n <span key={cellIndex} style={{fontFamily: cellIndex === 0 ? brand.typography.sans : brand.typography.mono}}>\n {cell}\n </span>\n ))}\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n',
|
|
1052
|
+
target: "videos/components/data-table/data-table.tsx"
|
|
1053
|
+
},
|
|
1054
|
+
{
|
|
1055
|
+
path: "components/data-table/data-table.preview.tsx",
|
|
1056
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {DataTable} from "./data-table";\n\nexport default defineComponentPreview({\n title: "Data table",\n category: "Data",\n description: "Sortable rows with an authored selection story.",\n component: DataTable,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {\n title: {type: "text", defaultValue: "Export jobs"},\n selectedIndex: {type: "number", defaultValue: 1, min: 0, max: 5},\n },\n examples: [\n {\n name: "Jobs",\n props: {\n columns: ["Video", "Format", "Duration", "State"],\n rows: [\n ["launch", "1920x1080", "12s", "queued"],\n ["launch", "1080x1920", "12s", "rendering"],\n ["announcement", "1080x1080", "8s", "done"],\n ],\n selectedIndex: 1,\n },\n },\n ],\n});\n',
|
|
1057
|
+
target: "videos/components/data-table/data-table.preview.tsx"
|
|
1058
|
+
}
|
|
1059
|
+
],
|
|
1060
|
+
meta: {
|
|
1061
|
+
kind: "component",
|
|
1062
|
+
family: "Data",
|
|
1063
|
+
namespaced: "@odori/data-table",
|
|
1064
|
+
contract: {
|
|
1065
|
+
aspectRatios: [
|
|
1066
|
+
"16:9",
|
|
1067
|
+
"1:1"
|
|
1068
|
+
],
|
|
1069
|
+
recommendedDurationInFrames: 150,
|
|
1070
|
+
minimumDurationInFrames: 75,
|
|
1071
|
+
entranceFrames: 60,
|
|
1072
|
+
exitFrames: 0,
|
|
1073
|
+
contentLimits: {
|
|
1074
|
+
rows: 6,
|
|
1075
|
+
columns: 5
|
|
1076
|
+
},
|
|
1077
|
+
reducedMotion: "rows and the selection appear at once",
|
|
1078
|
+
requires: {
|
|
1079
|
+
fonts: [
|
|
1080
|
+
"sans",
|
|
1081
|
+
"mono"
|
|
1082
|
+
],
|
|
1083
|
+
audio: []
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
},
|
|
1088
|
+
{
|
|
1089
|
+
name: "dependency-graph",
|
|
1090
|
+
description: "Packages and modules connected as a readable graph.",
|
|
1091
|
+
registryDependencies: [],
|
|
1092
|
+
files: [
|
|
1093
|
+
{
|
|
1094
|
+
path: "components/dependency-graph/dependency-graph.tsx",
|
|
1095
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type GraphNode = {\n label: string;\n /** Position in the frame, 0 to 1 on each axis. */\n x: number;\n y: number;\n};\n\nexport type DependencyGraphProps = {\n nodes: GraphNode[];\n /** Pairs of node indexes, drawn in order. */\n edges: Array<[number, number]>;\n headline?: string;\n};\n\n/**\n * Packages connected as a readable graph. Edges draw before the node they\n * point at appears, so the eye follows the dependency rather than hunting for\n * what moved.\n */\nexport const DependencyGraph = ({nodes, edges, headline}: DependencyGraphProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n return (\n <Fill style={{padding: 100 * scale}}>\n {headline ? (\n <div\n style={{\n fontSize: 56 * scale,\n fontWeight: 560,\n letterSpacing: "-0.04em",\n opacity: interpolate(frame, [0, 18], [0, 1], {easing: Easing.standard}),\n }}\n >\n {headline}\n </div>\n ) : null}\n\n <Fill>\n <svg style={{height: "100%", width: "100%"}}>\n {edges.map(([from, to], index) => {\n const delay = 14 + index * 8;\n const draw = interpolate(frame, [delay, delay + 20], [0, 1], {easing: Easing.standard});\n const a = nodes[from];\n const b = nodes[to];\n if (!a || !b) return null;\n return (\n <line\n key={`${from}-${to}`}\n stroke={brand.colors.border}\n strokeWidth={2 * scale}\n x1={`${a.x * 100}%`}\n x2={`${(a.x + (b.x - a.x) * draw) * 100}%`}\n y1={`${a.y * 100}%`}\n y2={`${(a.y + (b.y - a.y) * draw) * 100}%`}\n />\n );\n })}\n </svg>\n </Fill>\n\n {nodes.map((node, index) => {\n const delay = 10 + index * 8;\n const enter = interpolate(frame, [delay, delay + 18], [0, 1], {easing: Easing.standard});\n return (\n <div\n key={node.label}\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 999,\n fontFamily: brand.typography.mono,\n fontSize: 28 * scale,\n left: `${node.x * 100}%`,\n opacity: enter,\n padding: `${14 * scale}px ${26 * scale}px`,\n position: "absolute",\n top: `${node.y * 100}%`,\n transform: `translate(-50%, -50%) scale(${0.9 + enter * 0.1})`,\n }}\n >\n {node.label}\n </div>\n );\n })}\n </Fill>\n );\n};\n',
|
|
1096
|
+
target: "videos/components/dependency-graph/dependency-graph.tsx"
|
|
1097
|
+
},
|
|
1098
|
+
{
|
|
1099
|
+
path: "components/dependency-graph/dependency-graph.preview.tsx",
|
|
1100
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {DependencyGraph} from "./dependency-graph";\n\nexport default defineComponentPreview({\n title: "Dependency graph",\n category: "Developer proof",\n description: "Packages and modules connected as a readable graph.",\n component: DependencyGraph,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {\n headline: {type: "text", defaultValue: "One graph, one render path."},\n },\n examples: [\n {\n name: "Project",\n props: {\n nodes: [\n {label: "videos/launch", x: 0.24, y: 0.4},\n {label: "videos/components", x: 0.56, y: 0.26},\n {label: "odori", x: 0.78, y: 0.56},\n {label: "public/audio", x: 0.36, y: 0.74},\n ],\n edges: [\n [0, 1],\n [1, 2],\n [0, 3],\n ],\n },\n },\n ],\n});\n',
|
|
1101
|
+
target: "videos/components/dependency-graph/dependency-graph.preview.tsx"
|
|
1102
|
+
}
|
|
1103
|
+
],
|
|
1104
|
+
meta: {
|
|
1105
|
+
kind: "component",
|
|
1106
|
+
family: "Developer proof",
|
|
1107
|
+
namespaced: "@odori/dependency-graph",
|
|
1108
|
+
contract: {
|
|
1109
|
+
aspectRatios: [
|
|
1110
|
+
"16:9",
|
|
1111
|
+
"1:1"
|
|
1112
|
+
],
|
|
1113
|
+
recommendedDurationInFrames: 150,
|
|
1114
|
+
minimumDurationInFrames: 75,
|
|
1115
|
+
entranceFrames: 60,
|
|
1116
|
+
exitFrames: 0,
|
|
1117
|
+
contentLimits: {
|
|
1118
|
+
nodes: 6,
|
|
1119
|
+
labelLength: 24
|
|
1120
|
+
},
|
|
1121
|
+
reducedMotion: "edges and nodes appear without drawing",
|
|
1122
|
+
requires: {
|
|
1123
|
+
fonts: [
|
|
1124
|
+
"sans",
|
|
1125
|
+
"mono"
|
|
1126
|
+
],
|
|
1127
|
+
audio: []
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
},
|
|
1132
|
+
{
|
|
1133
|
+
name: "device-frame",
|
|
1134
|
+
description: "A phone, tablet, or desktop body around a product surface.",
|
|
1135
|
+
registryDependencies: [],
|
|
1136
|
+
files: [
|
|
1137
|
+
{
|
|
1138
|
+
path: "components/device-frame/device-frame.tsx",
|
|
1139
|
+
content: 'import type {ReactNode} from "react";\nimport {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type DeviceFrameProps = {\n /** Which body to draw around the content. */\n device?: "phone" | "tablet" | "desktop";\n children?: ReactNode;\n /** Shown in the desktop chrome\'s address bar. Ignored by the other two. */\n url?: string;\n /** Frames the device takes to settle into place. */\n entranceFrames?: number;\n};\n\nconst BODIES = {\n phone: {width: 480, height: 980, radius: 62, bezel: 14},\n tablet: {width: 900, height: 1180, radius: 40, bezel: 18},\n desktop: {width: 1560, height: 940, radius: 22, bezel: 0},\n} as const;\n\n/**\n * A product stage with a body around it: the same content reads as an app on a\n * phone and as a site on a desktop, and the frame is what tells the viewer\n * which claim is being made.\n *\n * Proportions are the real ones rather than a stylised outline, because a\n * device that is the wrong shape quietly undermines the screenshot inside it.\n * Nothing here is a photograph, so it takes the brand\'s colours and stays\n * honest at any format.\n */\nexport const DeviceFrame = ({device = "desktop", children, url, entranceFrames = 20}: DeviceFrameProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const body = BODIES[device];\n\n const appear = interpolate(frame, [0, entranceFrames], [0, 1], {easing: Easing.standard});\n const bezel = body.bezel * scale;\n\n return (\n <Fill style={{alignItems: "center", justifyContent: "center", padding: 60 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `${Math.max(1, 2 * scale)}px solid ${brand.colors.border}`,\n borderRadius: body.radius * scale,\n boxShadow: `0 ${44 * scale}px ${90 * scale}px rgba(0,0,0,0.5)`,\n // A column, so the screen below the chrome can take the remaining\n // height. Without this the body\'s `flex: 1` means nothing and the\n // content collapses to zero \u2014 the frame renders, and it is empty.\n display: "flex",\n flexDirection: "column",\n height: body.height * scale,\n maxHeight: "100%",\n maxWidth: "100%",\n opacity: appear,\n overflow: "hidden",\n padding: bezel,\n position: "relative",\n transform: `translateY(${(1 - appear) * 26 * scale}px) scale(${0.985 + appear * 0.015})`,\n width: body.width * scale,\n }}\n >\n {device === "desktop" ? (\n <div\n style={{\n alignItems: "center",\n background: "rgba(255,255,255,0.02)",\n borderBottom: `1px solid ${brand.colors.border}`,\n display: "flex",\n gap: 9 * scale,\n padding: `${14 * scale}px ${20 * scale}px`,\n }}\n >\n {[0, 1, 2].map((index) => (\n <span\n key={index}\n style={{background: brand.colors.border, borderRadius: 999, height: 11 * scale, width: 11 * scale}}\n />\n ))}\n {url ? (\n <span\n style={{\n background: brand.colors.background,\n borderRadius: 999,\n color: brand.colors.muted,\n fontFamily: brand.typography.mono,\n // 17px is what a browser really shows and what `odori test`\n // rejects: at 1080p it is a grey smudge. A video is read\n // from further away than a screen is.\n fontSize: 24 * scale,\n marginLeft: 14 * scale,\n padding: `${8 * scale}px ${20 * scale}px`,\n }}\n >\n {url}\n </span>\n ) : null}\n </div>\n ) : null}\n\n <div\n style={{\n background: brand.colors.background,\n borderRadius: Math.max(0, (body.radius - body.bezel) * scale),\n flex: 1,\n overflow: "hidden",\n position: "relative",\n }}\n >\n {children}\n </div>\n\n {/* The one physical detail worth drawing: a phone\'s status notch and a\n tablet\'s home line are what the eye uses to tell them apart. */}\n {device === "phone" ? (\n <span\n style={{\n background: brand.colors.background,\n borderRadius: 999,\n height: 26 * scale,\n left: "50%",\n position: "absolute",\n top: bezel + 10 * scale,\n transform: "translateX(-50%)",\n width: 150 * scale,\n }}\n />\n ) : null}\n </div>\n </Fill>\n );\n};\n',
|
|
1140
|
+
target: "videos/components/device-frame/device-frame.tsx"
|
|
1141
|
+
},
|
|
1142
|
+
{
|
|
1143
|
+
path: "components/device-frame/device-frame.preview.tsx",
|
|
1144
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {DeviceFrame} from "./device-frame";\n\nconst Screen = () => (\n <div\n style={{\n alignItems: "center",\n color: "#f5f5f5",\n display: "flex",\n flexDirection: "column",\n fontFamily: "ui-sans-serif, system-ui",\n gap: 18,\n inset: 0,\n justifyContent: "center",\n position: "absolute",\n textAlign: "center",\n }}\n >\n <strong style={{fontSize: 54, letterSpacing: "-0.03em"}}>Deployments</strong>\n <span style={{color: "#8f8f8f", fontSize: 28}}>All systems normal</span>\n </div>\n);\n\nexport default defineComponentPreview({\n title: "Device frame",\n category: "Product UI",\n description: "A phone, tablet, or desktop body around a product surface.",\n component: DeviceFrame,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n device: {type: "select", defaultValue: "desktop", options: ["desktop", "tablet", "phone"]},\n url: {type: "text", defaultValue: "app.example.com/deployments"},\n entranceFrames: {type: "number", defaultValue: 20, min: 0, max: 60},\n },\n examples: [\n {name: "Desktop", props: {children: <Screen />}},\n {name: "Phone", props: {device: "phone", children: <Screen />}},\n {name: "Tablet", props: {device: "tablet", children: <Screen />}},\n ],\n});\n',
|
|
1145
|
+
target: "videos/components/device-frame/device-frame.preview.tsx"
|
|
1146
|
+
}
|
|
1147
|
+
],
|
|
1148
|
+
meta: {
|
|
1149
|
+
kind: "component",
|
|
1150
|
+
family: "Product UI",
|
|
1151
|
+
namespaced: "@odori/device-frame",
|
|
1152
|
+
contract: {
|
|
1153
|
+
aspectRatios: [
|
|
1154
|
+
"16:9",
|
|
1155
|
+
"9:16",
|
|
1156
|
+
"1:1"
|
|
1157
|
+
],
|
|
1158
|
+
recommendedDurationInFrames: 120,
|
|
1159
|
+
minimumDurationInFrames: 30,
|
|
1160
|
+
entranceFrames: 20,
|
|
1161
|
+
exitFrames: 0,
|
|
1162
|
+
contentLimits: {
|
|
1163
|
+
url: 64
|
|
1164
|
+
},
|
|
1165
|
+
reducedMotion: "the body appears in place without rising",
|
|
1166
|
+
requires: {
|
|
1167
|
+
fonts: [
|
|
1168
|
+
"sans"
|
|
1169
|
+
],
|
|
1170
|
+
audio: []
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
},
|
|
1175
|
+
{
|
|
1176
|
+
name: "diff-proof",
|
|
1177
|
+
description: "Before and after source changes with semantic emphasis.",
|
|
1178
|
+
registryDependencies: [],
|
|
1179
|
+
files: [
|
|
1180
|
+
{
|
|
1181
|
+
path: "components/diff-proof/diff-proof.tsx",
|
|
1182
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type DiffLine = {\n text: string;\n kind?: "added" | "removed" | "context";\n};\n\nexport type DiffProofProps = {\n lines: DiffLine[];\n title?: string;\n /** Frames between one changed line and the next. */\n stagger?: number;\n};\n\n/**\n * A patch that reveals its changed lines in order. Context lines are present\n * from the first frame, so the eye reads the change rather than the file.\n */\nexport const DiffProof = ({lines, title = "videos/launch/video.tsx", stagger = 8}: DiffProofProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n let changed = 0;\n\n return (\n <Fill style={{alignItems: "center", justifyContent: "center", padding: 90 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 24 * scale,\n minWidth: 1100 * scale,\n overflow: "hidden",\n }}\n >\n <div\n style={{\n borderBottom: `1px solid ${brand.colors.border}`,\n color: brand.colors.muted,\n fontFamily: brand.typography.mono,\n fontSize: 24 * scale,\n padding: `${20 * scale}px ${26 * scale}px`,\n }}\n >\n {title}\n </div>\n <div style={{padding: `${18 * scale}px 0`}}>\n {lines.map((line, index) => {\n const kind = line.kind ?? "context";\n const context = kind === "context";\n const delay = context ? 0 : 12 + changed * stagger;\n if (!context) changed += 1;\n const enter = context ? 1 : interpolate(frame, [delay, delay + 14], [0, 1], {easing: Easing.standard});\n const tint =\n kind === "added" ? brand.colors.accent : kind === "removed" ? brand.colors.muted : "transparent";\n return (\n <div\n key={`${line.text}-${index}`}\n style={{\n background: context ? "transparent" : `color-mix(in srgb, ${tint} ${enter * 14}%, transparent)`,\n borderLeft: `${4 * scale}px solid ${context ? "transparent" : tint}`,\n color: context ? brand.colors.muted : brand.colors.foreground,\n fontFamily: brand.typography.mono,\n fontSize: 30 * scale,\n opacity: context ? 0.7 : enter,\n padding: `${8 * scale}px ${26 * scale}px`,\n textDecoration: kind === "removed" ? "line-through" : "none",\n whiteSpace: "pre",\n }}\n >\n {kind === "added" ? "+ " : kind === "removed" ? "- " : " "}\n {line.text}\n </div>\n );\n })}\n </div>\n </div>\n </Fill>\n );\n};\n',
|
|
1183
|
+
target: "videos/components/diff-proof/diff-proof.tsx"
|
|
1184
|
+
},
|
|
1185
|
+
{
|
|
1186
|
+
path: "components/diff-proof/diff-proof.preview.tsx",
|
|
1187
|
+
content: `import {defineComponentPreview} from "odori/preview";
|
|
1188
|
+
import {DiffProof} from "./diff-proof";
|
|
1189
|
+
|
|
1190
|
+
export default defineComponentPreview({
|
|
1191
|
+
title: "Diff proof",
|
|
1192
|
+
category: "Developer proof",
|
|
1193
|
+
description: "Before and after source changes with semantic emphasis.",
|
|
1194
|
+
component: DiffProof,
|
|
1195
|
+
canvas: {width: 1920, height: 1080, duration: "5s"},
|
|
1196
|
+
controls: {
|
|
1197
|
+
title: {type: "text", defaultValue: "videos/launch/video.tsx"},
|
|
1198
|
+
stagger: {type: "number", defaultValue: 8, min: 2, max: 24},
|
|
1199
|
+
},
|
|
1200
|
+
examples: [
|
|
1201
|
+
{
|
|
1202
|
+
name: "Scene retimed",
|
|
1203
|
+
props: {
|
|
1204
|
+
lines: [
|
|
1205
|
+
{text: "<Video>", kind: "context"},
|
|
1206
|
+
{text: ' <Scene duration="4s">', kind: "removed"},
|
|
1207
|
+
{text: ' <Scene duration="5s">', kind: "added"},
|
|
1208
|
+
{text: " <TitleReveal title={headline} />", kind: "context"},
|
|
1209
|
+
{text: " </Scene>", kind: "context"},
|
|
1210
|
+
{text: "</Video>", kind: "context"},
|
|
1211
|
+
],
|
|
1212
|
+
},
|
|
1213
|
+
},
|
|
1214
|
+
],
|
|
1215
|
+
});
|
|
1216
|
+
`,
|
|
1217
|
+
target: "videos/components/diff-proof/diff-proof.preview.tsx"
|
|
1218
|
+
}
|
|
1219
|
+
],
|
|
1220
|
+
meta: {
|
|
1221
|
+
kind: "component",
|
|
1222
|
+
family: "Developer proof",
|
|
1223
|
+
namespaced: "@odori/diff-proof",
|
|
1224
|
+
contract: {
|
|
1225
|
+
aspectRatios: [
|
|
1226
|
+
"16:9",
|
|
1227
|
+
"1:1"
|
|
1228
|
+
],
|
|
1229
|
+
recommendedDurationInFrames: 150,
|
|
1230
|
+
minimumDurationInFrames: 72,
|
|
1231
|
+
entranceFrames: 40,
|
|
1232
|
+
exitFrames: 0,
|
|
1233
|
+
contentLimits: {
|
|
1234
|
+
lines: 12,
|
|
1235
|
+
lineLength: 58
|
|
1236
|
+
},
|
|
1237
|
+
reducedMotion: "changed lines appear without staggering",
|
|
1238
|
+
requires: {
|
|
1239
|
+
fonts: [
|
|
1240
|
+
"mono"
|
|
1241
|
+
],
|
|
1242
|
+
audio: []
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
},
|
|
1247
|
+
{
|
|
1248
|
+
name: "donut-chart",
|
|
1249
|
+
description: "Part-to-whole display with a stable center metric.",
|
|
1250
|
+
registryDependencies: [],
|
|
1251
|
+
files: [
|
|
1252
|
+
{
|
|
1253
|
+
path: "components/donut-chart/donut-chart.tsx",
|
|
1254
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type DonutSlice = {label: string; value: number};\n\nexport type DonutChartProps = {\n slices: DonutSlice[];\n /** Held in the middle of the ring. */\n centerLabel?: string;\n title?: string;\n};\n\n/**\n * Part to whole with a stable center. Slices sweep in order around one ring,\n * and the center metric never moves, so the eye has somewhere to rest.\n */\nexport const DonutChart = ({slices, centerLabel, title}: DonutChartProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const total = slices.reduce((sum, slice) => sum + slice.value, 0) || 1;\n const sweep = interpolate(frame, [10, 62], [0, 1], {easing: Easing.standard});\n const radius = 40;\n const circumference = 2 * Math.PI * radius;\n let offset = 0;\n\n return (\n <Fill style={{alignItems: "center", gap: 34 * scale, justifyContent: "center", padding: 100 * scale}}>\n {title ? (\n <div style={{fontSize: 54 * scale, fontWeight: 560, letterSpacing: "-0.04em", opacity: interpolate(frame, [0, 16], [0, 1])}}>\n {title}\n </div>\n ) : null}\n <div style={{height: 460 * scale, position: "relative", width: 460 * scale}}>\n <svg style={{height: "100%", transform: "rotate(-90deg)", width: "100%"}} viewBox="0 0 100 100">\n <circle cx={50} cy={50} fill="none" r={radius} stroke={brand.colors.border} strokeWidth={12} />\n {slices.map((slice, index) => {\n const share = (slice.value / total) * sweep;\n const dash = share * circumference;\n const element = (\n <circle\n key={slice.label}\n cx={50}\n cy={50}\n fill="none"\n r={radius}\n stroke={index === 0 ? brand.colors.accent : brand.colors.muted}\n strokeDasharray={`${dash} ${circumference}`}\n strokeDashoffset={-offset * circumference}\n strokeWidth={12}\n opacity={index === 0 ? 1 : 0.45 + index * 0.1}\n />\n );\n offset += slice.value / total;\n return element;\n })}\n </svg>\n {centerLabel ? (\n <div\n style={{\n alignItems: "center",\n display: "flex",\n fontSize: 92 * scale,\n fontWeight: 600,\n inset: 0,\n justifyContent: "center",\n letterSpacing: "-0.05em",\n position: "absolute",\n }}\n >\n {centerLabel}\n </div>\n ) : null}\n </div>\n <div style={{color: brand.colors.muted, display: "flex", fontSize: 28 * scale, gap: 28 * scale}}>\n {slices.map((slice) => (\n <span key={slice.label}>\n {slice.label} {Math.round((slice.value / total) * 100)}%\n </span>\n ))}\n </div>\n </Fill>\n );\n};\n',
|
|
1255
|
+
target: "videos/components/donut-chart/donut-chart.tsx"
|
|
1256
|
+
},
|
|
1257
|
+
{
|
|
1258
|
+
path: "components/donut-chart/donut-chart.preview.tsx",
|
|
1259
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {DonutChart} from "./donut-chart";\n\nexport default defineComponentPreview({\n title: "Donut chart",\n category: "Data",\n description: "Part-to-whole display with a stable center metric.",\n component: DonutChart,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n title: {type: "text", defaultValue: "Where render time goes"},\n centerLabel: {type: "text", defaultValue: "72%"},\n },\n examples: [\n {\n name: "Split",\n props: {\n slices: [\n {label: "encode", value: 72},\n {label: "paint", value: 18},\n {label: "audio", value: 10},\n ],\n centerLabel: "72%",\n },\n },\n ],\n});\n',
|
|
1260
|
+
target: "videos/components/donut-chart/donut-chart.preview.tsx"
|
|
1261
|
+
}
|
|
1262
|
+
],
|
|
1263
|
+
meta: {
|
|
1264
|
+
kind: "component",
|
|
1265
|
+
family: "Data",
|
|
1266
|
+
namespaced: "@odori/donut-chart",
|
|
1267
|
+
contract: {
|
|
1268
|
+
aspectRatios: [
|
|
1269
|
+
"16:9",
|
|
1270
|
+
"9:16",
|
|
1271
|
+
"1:1"
|
|
1272
|
+
],
|
|
1273
|
+
recommendedDurationInFrames: 120,
|
|
1274
|
+
minimumDurationInFrames: 72,
|
|
1275
|
+
entranceFrames: 62,
|
|
1276
|
+
exitFrames: 0,
|
|
1277
|
+
contentLimits: {
|
|
1278
|
+
slices: 5,
|
|
1279
|
+
labelLength: 18
|
|
1280
|
+
},
|
|
1281
|
+
reducedMotion: "slices appear without sweeping",
|
|
1282
|
+
requires: {
|
|
1283
|
+
fonts: [
|
|
1284
|
+
"sans"
|
|
1285
|
+
],
|
|
1286
|
+
audio: []
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
},
|
|
1291
|
+
{
|
|
1292
|
+
name: "end-card",
|
|
1293
|
+
description: "The closing frame: title, supporting line, and a call to action.",
|
|
1294
|
+
registryDependencies: [],
|
|
1295
|
+
files: [
|
|
1296
|
+
{
|
|
1297
|
+
path: "components/end-card/end-card.tsx",
|
|
1298
|
+
content: 'import {Fill, Easing, interpolate, spring, useBrand, useFrame, useDesignScale, useVideo} from "odori";\n\nexport type EndCardProps = {\n title: string;\n detail?: string;\n callToAction?: string;\n};\n\n/** The closing lockup: the statement, and one command to run. */\nexport const EndCard = ({title, detail, callToAction}: EndCardProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const {fps} = useVideo();\n const scale = useDesignScale();\n const rise = spring({frame, fps, from: 0.96, to: 1, damping: 20});\n const line = interpolate(frame, [8, 34], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: "center", gap: 30 * scale, justifyContent: "center", padding: 110 * scale}}>\n <div\n style={{\n fontSize: 108 * scale,\n fontWeight: 560,\n letterSpacing: "-0.05em",\n lineHeight: 1.02,\n maxWidth: 1400 * scale,\n opacity: interpolate(frame, [0, 18], [0, 1], {easing: Easing.standard}),\n textAlign: "center",\n transform: `scale(${rise})`,\n whiteSpace: "pre-line",\n }}\n >\n {title}\n </div>\n\n <div\n style={{\n background: `linear-gradient(90deg, transparent, ${brand.colors.border}, transparent)`,\n height: 1,\n opacity: line,\n width: `${line * 46}%`,\n }}\n />\n\n {detail ? (\n <div\n style={{\n color: brand.colors.muted,\n fontSize: 34 * scale,\n letterSpacing: "-0.015em",\n opacity: interpolate(frame, [14, 30], [0, 1], {easing: Easing.standard}),\n textAlign: "center",\n }}\n >\n {detail}\n </div>\n ) : null}\n\n {callToAction ? (\n <div\n style={{\n alignItems: "center",\n background: "rgba(255,255,255,0.04)",\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 10 * scale,\n color: brand.colors.foreground,\n display: "flex",\n fontFamily: brand.typography.mono,\n fontSize: 28 * scale,\n gap: 14 * scale,\n marginTop: 8 * scale,\n opacity: interpolate(frame, [22, 38], [0, 1], {easing: Easing.standard}),\n padding: `${16 * scale}px ${28 * scale}px`,\n transform: `translateY(${interpolate(frame, [22, 38], [12 * scale, 0], {easing: Easing.standard})}px)`,\n }}\n >\n <span style={{color: brand.colors.muted}}>{"$"}</span>\n {callToAction}\n </div>\n ) : null}\n </Fill>\n );\n};\n',
|
|
1299
|
+
target: "videos/components/end-card/end-card.tsx"
|
|
1300
|
+
},
|
|
1301
|
+
{
|
|
1302
|
+
path: "components/end-card/end-card.preview.tsx",
|
|
1303
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {EndCard} from "./end-card";\n\nexport default defineComponentPreview({\n title: "End card",\n category: "Brand",\n description: "The closing frame: title, supporting line, and a call to action.",\n component: EndCard,\n canvas: {width: 1920, height: 1080, duration: "3s"},\n controls: {\n title: {type: "text", defaultValue: "Author. Preview. Ship.", maxLength: 48},\n detail: {type: "text", defaultValue: "An independent React video framework."},\n callToAction: {type: "text", defaultValue: "npm i odori"},\n },\n examples: [\n {name: "Default", props: {title: "Author. Preview. Ship.", callToAction: "npm i odori"}},\n {name: "Title only", props: {title: "Available today.", detail: undefined, callToAction: undefined}},\n ],\n});\n',
|
|
1304
|
+
target: "videos/components/end-card/end-card.preview.tsx"
|
|
1305
|
+
}
|
|
1306
|
+
],
|
|
1307
|
+
meta: {
|
|
1308
|
+
kind: "component",
|
|
1309
|
+
family: "Brand",
|
|
1310
|
+
namespaced: "@odori/end-card",
|
|
1311
|
+
contract: {
|
|
1312
|
+
aspectRatios: [
|
|
1313
|
+
"16:9",
|
|
1314
|
+
"9:16",
|
|
1315
|
+
"1:1"
|
|
1316
|
+
],
|
|
1317
|
+
recommendedDurationInFrames: 90,
|
|
1318
|
+
minimumDurationInFrames: 45,
|
|
1319
|
+
entranceFrames: 24,
|
|
1320
|
+
exitFrames: 12,
|
|
1321
|
+
contentLimits: {
|
|
1322
|
+
title: 48,
|
|
1323
|
+
callToAction: 28
|
|
1324
|
+
},
|
|
1325
|
+
reducedMotion: "fades without scaling",
|
|
1326
|
+
requires: {
|
|
1327
|
+
fonts: [
|
|
1328
|
+
"sans",
|
|
1329
|
+
"mono"
|
|
1330
|
+
],
|
|
1331
|
+
audio: []
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
},
|
|
1336
|
+
{
|
|
1337
|
+
name: "error",
|
|
1338
|
+
description: "Two notes falling a fourth, for a failure.",
|
|
1339
|
+
registryDependencies: [],
|
|
1340
|
+
files: [
|
|
1341
|
+
{
|
|
1342
|
+
path: "components/error/error.tsx",
|
|
1343
|
+
content: 'import {defineCue, gain, lowPass, mix, normalize, note, sequence, shape, sine, triangle, type CueDefinition} from "odori";\n\nexport type ErrorOptions = {\n /** The note the sound starts from before it falls. */\n root?: string;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * Two notes falling a fourth, with a little edge under them: the inverse of\n * `success`, which is how an interface says the thing did not work.\n *\n * Deliberately not harsh. A buzzer punishes the viewer for watching; a falling\n * interval reports the failure and gets out of the way.\n */\nexport const error = ({root = "c5", peak = 0.55}: ErrorOptions = {}): CueDefinition => {\n const samples = Math.round(48000 * 0.55);\n const base = note(root);\n\n return defineCue({\n name: "ui.error",\n durationInFrames: 17,\n params: {root, peak},\n render: () =>\n normalize(\n sequence(\n [base, base * 2 ** (-5 / 12)].map((frequency, index) => ({\n at: index,\n play: (length: number) =>\n mix(\n shape(sine(length, frequency), {attack: 0.004, decay: 0.26, sustain: 0.05, release: 0.14}),\n // A triangle under the second note gives it the edge that\n // reads as "wrong" without reaching for distortion.\n gain(lowPass(shape(triangle(length, frequency / 2), {attack: 0.006, decay: 0.3, sustain: 0}), 1800), 0.35),\n ),\n gain: index === 0 ? 0.75 : 0.9,\n })),\n {bpm: 132, division: 16, samples},\n ),\n peak,\n ),\n });\n};\n',
|
|
1344
|
+
target: "videos/components/error/error.tsx"
|
|
1345
|
+
},
|
|
1346
|
+
{
|
|
1347
|
+
path: "components/error/error.preview.tsx",
|
|
1348
|
+
content: 'import {CueWave, defineComponentPreview} from "odori/preview";\nimport {error, type ErrorOptions} from "./error";\n\nconst Wave = ({root, peak}: ErrorOptions) => <CueWave cue={error({root, peak})} />;\n\nexport default defineComponentPreview({\n title: "Error",\n category: "Sound",\n description: "Two notes falling a fourth, for a failure.",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: "2s"},\n controls: {\n root: {type: "select", defaultValue: "c5", options: ["a4", "c5", "d5", "e5"]},\n peak: {type: "number", defaultValue: 0.55, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: "Default", props: {}}, {name: "Lower", props: {root: "a4"}}],\n});\n',
|
|
1349
|
+
target: "videos/components/error/error.preview.tsx"
|
|
1350
|
+
}
|
|
1351
|
+
],
|
|
1352
|
+
meta: {
|
|
1353
|
+
kind: "cue",
|
|
1354
|
+
family: "Sound",
|
|
1355
|
+
namespaced: "@odori/error",
|
|
1356
|
+
contract: {
|
|
1357
|
+
aspectRatios: [
|
|
1358
|
+
"16:9",
|
|
1359
|
+
"9:16",
|
|
1360
|
+
"1:1"
|
|
1361
|
+
],
|
|
1362
|
+
recommendedDurationInFrames: 17,
|
|
1363
|
+
minimumDurationInFrames: 17,
|
|
1364
|
+
entranceFrames: 0,
|
|
1365
|
+
exitFrames: 0,
|
|
1366
|
+
contentLimits: {},
|
|
1367
|
+
reducedMotion: "the waveform renders without a playhead",
|
|
1368
|
+
requires: {
|
|
1369
|
+
fonts: [
|
|
1370
|
+
"mono"
|
|
1371
|
+
],
|
|
1372
|
+
audio: []
|
|
1373
|
+
},
|
|
1374
|
+
loops: false
|
|
1375
|
+
},
|
|
1376
|
+
cue: {
|
|
1377
|
+
name: "ui.error",
|
|
1378
|
+
export: "error"
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
},
|
|
1382
|
+
{
|
|
1383
|
+
name: "feature-stack",
|
|
1384
|
+
description: "Benefits progress through one stable visual container.",
|
|
1385
|
+
registryDependencies: [],
|
|
1386
|
+
files: [
|
|
1387
|
+
{
|
|
1388
|
+
path: "components/feature-stack/feature-stack.tsx",
|
|
1389
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type Feature = {title: string; detail?: string};\n\nexport type FeatureStackProps = {\n features: Feature[];\n headline?: string;\n /** Frames each feature holds before the next replaces it. */\n hold?: number;\n};\n\n/**\n * Benefits progressing through one stable container. The card never moves or\n * resizes; only its contents change, so a viewer can read three claims without\n * their eye being dragged around the frame.\n */\nexport const FeatureStack = ({features, headline, hold = 45}: FeatureStackProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const index = Math.min(features.length - 1, Math.floor(Math.max(0, frame - 12) / hold));\n const feature = features[index];\n const at = 12 + index * hold;\n const enter = interpolate(frame, [at, at + 16], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: "center", gap: 40 * scale, justifyContent: "center", padding: 110 * scale}}>\n {headline ? (\n <div style={{color: brand.colors.muted, fontSize: 34 * scale, opacity: interpolate(frame, [0, 16], [0, 1])}}>\n {headline}\n </div>\n ) : null}\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 30 * scale,\n display: "grid",\n gap: 18 * scale,\n minHeight: 320 * scale,\n padding: `${52 * scale}px ${56 * scale}px`,\n placeContent: "center",\n textAlign: "center",\n width: 1200 * scale,\n }}\n >\n <div\n style={{\n fontSize: 76 * scale,\n fontWeight: 570,\n letterSpacing: "-0.04em",\n opacity: enter,\n transform: `translateY(${(1 - enter) * 18 * scale}px)`,\n }}\n >\n {feature?.title}\n </div>\n {feature?.detail ? (\n <div style={{color: brand.colors.muted, fontSize: 32 * scale, opacity: enter}}>{feature.detail}</div>\n ) : null}\n </div>\n <div style={{display: "flex", gap: 10 * scale}}>\n {features.map((item, dot) => (\n <span\n key={item.title}\n style={{\n background: dot === index ? brand.colors.accent : brand.colors.border,\n borderRadius: 999,\n height: 10 * scale,\n width: dot === index ? 34 * scale : 10 * scale,\n }}\n />\n ))}\n </div>\n </Fill>\n );\n};\n',
|
|
1390
|
+
target: "videos/components/feature-stack/feature-stack.tsx"
|
|
1391
|
+
},
|
|
1392
|
+
{
|
|
1393
|
+
path: "components/feature-stack/feature-stack.preview.tsx",
|
|
1394
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {FeatureStack} from "./feature-stack";\n\nexport default defineComponentPreview({\n title: "Feature stack",\n category: "Narrative",\n description: "Benefits progress through one stable visual container.",\n component: FeatureStack,\n canvas: {width: 1920, height: 1080, duration: "6s"},\n controls: {\n headline: {type: "text", defaultValue: "What you get"},\n hold: {type: "number", defaultValue: 45, min: 20, max: 90},\n },\n examples: [\n {\n name: "Three claims",\n props: {\n features: [\n {title: "Fast", detail: "Preview needs no encode."},\n {title: "Typed", detail: "Inputs validated before a render starts."},\n {title: "Reliable", detail: "The approved cut is the rendered cut."},\n ],\n },\n },\n ],\n});\n',
|
|
1395
|
+
target: "videos/components/feature-stack/feature-stack.preview.tsx"
|
|
1396
|
+
}
|
|
1397
|
+
],
|
|
1398
|
+
meta: {
|
|
1399
|
+
kind: "component",
|
|
1400
|
+
family: "Narrative",
|
|
1401
|
+
namespaced: "@odori/feature-stack",
|
|
1402
|
+
contract: {
|
|
1403
|
+
aspectRatios: [
|
|
1404
|
+
"16:9",
|
|
1405
|
+
"9:16",
|
|
1406
|
+
"1:1"
|
|
1407
|
+
],
|
|
1408
|
+
recommendedDurationInFrames: 180,
|
|
1409
|
+
minimumDurationInFrames: 90,
|
|
1410
|
+
entranceFrames: 28,
|
|
1411
|
+
exitFrames: 0,
|
|
1412
|
+
contentLimits: {
|
|
1413
|
+
features: 4,
|
|
1414
|
+
titleLength: 24,
|
|
1415
|
+
detailLength: 56
|
|
1416
|
+
},
|
|
1417
|
+
reducedMotion: "features cross-fade without travel",
|
|
1418
|
+
requires: {
|
|
1419
|
+
fonts: [
|
|
1420
|
+
"sans"
|
|
1421
|
+
],
|
|
1422
|
+
audio: []
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
},
|
|
1427
|
+
{
|
|
1428
|
+
name: "file-tree",
|
|
1429
|
+
description: "Progressive repository navigation with active file state.",
|
|
1430
|
+
registryDependencies: [],
|
|
1431
|
+
files: [
|
|
1432
|
+
{
|
|
1433
|
+
path: "components/file-tree/file-tree.tsx",
|
|
1434
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type FileTreeNode = {\n /** Path segment shown on the row. */\n name: string;\n /** Depth from the root, starting at zero. */\n depth?: number;\n kind?: "folder" | "file";\n};\n\nexport type FileTreeProps = {\n nodes: FileTreeNode[];\n /** Row index that ends up highlighted. */\n activeIndex?: number;\n title?: string;\n};\n\n/**\n * A repository revealing itself row by row, ending on one active file. The\n * highlight is a filled row rather than a color change, so it survives a\n * light brand as well as a dark one.\n */\nexport const FileTree = ({nodes, activeIndex = 0, title = "videos/"}: FileTreeProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const settle = 10 + nodes.length * 5;\n\n return (\n <Fill style={{alignItems: "center", justifyContent: "center", padding: 90 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 26 * scale,\n minWidth: 900 * scale,\n overflow: "hidden",\n padding: `${28 * scale}px ${20 * scale}px`,\n }}\n >\n <div\n style={{\n color: brand.colors.muted,\n fontFamily: brand.typography.mono,\n fontSize: 24 * scale,\n padding: `0 ${18 * scale}px ${18 * scale}px`,\n }}\n >\n {title}\n </div>\n {nodes.map((node, index) => {\n const delay = 10 + index * 5;\n const enter = interpolate(frame, [delay, delay + 16], [0, 1], {easing: Easing.standard});\n const active = index === activeIndex;\n const highlight = active\n ? interpolate(frame, [settle, settle + 16], [0, 1], {easing: Easing.standard})\n : 0;\n return (\n <div\n key={`${node.name}-${index}`}\n style={{\n alignItems: "center",\n background: active ? `color-mix(in srgb, ${brand.colors.accent} ${highlight * 16}%, transparent)` : "transparent",\n borderRadius: 12 * scale,\n display: "flex",\n gap: 14 * scale,\n opacity: enter,\n padding: `${10 * scale}px ${18 * scale}px`,\n paddingLeft: (18 + (node.depth ?? 0) * 28) * scale,\n transform: `translateX(${(1 - enter) * 16 * scale}px)`,\n }}\n >\n <span\n style={{\n background: node.kind === "folder" ? brand.colors.muted : "transparent",\n border: `${2 * scale}px solid ${node.kind === "folder" ? brand.colors.muted : brand.colors.border}`,\n borderRadius: 5 * scale,\n height: 18 * scale,\n opacity: 0.8,\n width: 18 * scale,\n }}\n />\n <span\n style={{\n color: active ? brand.colors.foreground : brand.colors.muted,\n fontFamily: brand.typography.mono,\n fontSize: 30 * scale,\n }}\n >\n {node.name}\n </span>\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n',
|
|
1435
|
+
target: "videos/components/file-tree/file-tree.tsx"
|
|
1436
|
+
},
|
|
1437
|
+
{
|
|
1438
|
+
path: "components/file-tree/file-tree.preview.tsx",
|
|
1439
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {FileTree} from "./file-tree";\n\nexport default defineComponentPreview({\n title: "File tree",\n category: "Developer proof",\n description: "Progressive repository navigation with active file state.",\n component: FileTree,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {\n title: {type: "text", defaultValue: "videos/"},\n activeIndex: {type: "number", defaultValue: 4, min: 0, max: 10},\n },\n examples: [\n {\n name: "Project",\n props: {\n nodes: [\n {name: "layout.tsx", kind: "file"},\n {name: "components/", kind: "folder"},\n {name: "title-reveal/", depth: 1, kind: "folder"},\n {name: "launch/", kind: "folder"},\n {name: "video.tsx", depth: 1, kind: "file"},\n {name: "schema.ts", depth: 1, kind: "file"},\n ],\n activeIndex: 4,\n },\n },\n ],\n});\n',
|
|
1440
|
+
target: "videos/components/file-tree/file-tree.preview.tsx"
|
|
1441
|
+
}
|
|
1442
|
+
],
|
|
1443
|
+
meta: {
|
|
1444
|
+
kind: "component",
|
|
1445
|
+
family: "Developer proof",
|
|
1446
|
+
namespaced: "@odori/file-tree",
|
|
1447
|
+
contract: {
|
|
1448
|
+
aspectRatios: [
|
|
1449
|
+
"16:9",
|
|
1450
|
+
"1:1"
|
|
1451
|
+
],
|
|
1452
|
+
recommendedDurationInFrames: 150,
|
|
1453
|
+
minimumDurationInFrames: 60,
|
|
1454
|
+
entranceFrames: 50,
|
|
1455
|
+
exitFrames: 0,
|
|
1456
|
+
contentLimits: {
|
|
1457
|
+
nodes: 10,
|
|
1458
|
+
nameLength: 28
|
|
1459
|
+
},
|
|
1460
|
+
reducedMotion: "rows appear together",
|
|
1461
|
+
requires: {
|
|
1462
|
+
fonts: [
|
|
1463
|
+
"mono"
|
|
1464
|
+
],
|
|
1465
|
+
audio: []
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
},
|
|
1470
|
+
{
|
|
1471
|
+
name: "form-flow",
|
|
1472
|
+
description: "Fields, validation, submit, and the success it resolves to.",
|
|
1473
|
+
registryDependencies: [],
|
|
1474
|
+
files: [
|
|
1475
|
+
{
|
|
1476
|
+
path: "components/form-flow/form-flow.tsx",
|
|
1477
|
+
content: 'import {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type FormField = {\n label: string;\n /** What gets typed into it. */\n value: string;\n /** Frame the typing starts. */\n at: number;\n /** A validation message that appears, then clears when the value is fixed. */\n error?: string;\n};\n\nexport type FormFlowProps = {\n title?: string;\n fields?: FormField[];\n submit?: string;\n /** Frame the submit is pressed. */\n submitAt?: number;\n /** What replaces the form once it succeeds. */\n success?: string;\n};\n\n/**\n * Fields, validation, submit, success \u2014 the whole progression, driven by\n * frames.\n *\n * Typing is drawn character by character from the frame clock rather than\n * animated with a CSS trick, so the same characters appear on the same frames\n * in preview and in the export, and a scrub backwards untypes them.\n */\nexport const FormFlow = ({\n title = "Create a project",\n fields = [],\n submit = "Create",\n submitAt = 100,\n success = "Project created",\n}: FormFlowProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n const appear = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const pressed = interpolate(frame, [submitAt - 3, submitAt, submitAt + 6], [0, 1, 0]);\n const done = interpolate(frame, [submitAt + 6, submitAt + 20], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: "center", justifyContent: "center", opacity: appear, padding: 80 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 24 * scale,\n display: "flex",\n flexDirection: "column",\n gap: 26 * scale,\n padding: 52 * scale,\n position: "relative",\n width: 880 * scale,\n }}\n >\n <strong\n style={{\n color: brand.colors.foreground,\n fontFamily: brand.typography.sans,\n fontSize: 40 * scale,\n fontWeight: 600,\n letterSpacing: "-0.025em",\n opacity: 1 - done,\n }}\n >\n {title}\n </strong>\n\n {fields.map((field) => {\n // Six frames a character: fast enough to read as typing, slow\n // enough that the viewer can follow what is being written.\n const typed = Math.max(0, Math.min(field.value.length, Math.floor((frame - field.at) / 3)));\n const text = field.value.slice(0, typed);\n const complete = typed >= field.value.length;\n const focused = frame >= field.at && !complete;\n const showError = Boolean(field.error) && typed > 0 && !complete;\n\n return (\n <label key={field.label} style={{display: "flex", flexDirection: "column", gap: 10 * scale, opacity: 1 - done}}>\n <span style={{color: brand.colors.muted, fontFamily: brand.typography.sans, fontSize: 22 * scale}}>\n {field.label}\n </span>\n <span\n style={{\n background: brand.colors.background,\n border: `1px solid ${showError ? "#e5484d" : focused ? brand.colors.accent : brand.colors.border}`,\n borderRadius: 12 * scale,\n color: brand.colors.foreground,\n fontFamily: brand.typography.mono,\n fontSize: 26 * scale,\n minHeight: 34 * scale,\n padding: `${18 * scale}px ${22 * scale}px`,\n }}\n >\n {text}\n {focused ? (\n // The caret blinks on the frame clock, not on wall time.\n <span style={{opacity: Math.floor(frame / 8) % 2 === 0 ? 1 : 0.15}}>\u2502</span>\n ) : null}\n </span>\n {showError ? (\n <span style={{color: "#e5484d", fontFamily: brand.typography.sans, fontSize: 20 * scale}}>\n {field.error}\n </span>\n ) : null}\n </label>\n );\n })}\n\n <span\n style={{\n background: brand.colors.accent,\n borderRadius: 12 * scale,\n color: brand.colors.background,\n fontFamily: brand.typography.sans,\n fontSize: 27 * scale,\n fontWeight: 560,\n opacity: 1 - done,\n padding: `${18 * scale}px ${30 * scale}px`,\n textAlign: "center",\n transform: `scale(${1 - pressed * 0.03})`,\n }}\n >\n {submit}\n </span>\n\n {success ? (\n <div\n style={{\n alignItems: "center",\n display: "flex",\n flexDirection: "column",\n gap: 16 * scale,\n inset: 0,\n justifyContent: "center",\n opacity: done,\n position: "absolute",\n }}\n >\n <span\n style={{\n alignItems: "center",\n background: brand.colors.accent,\n borderRadius: 999,\n color: brand.colors.background,\n display: "flex",\n fontSize: 34 * scale,\n height: 76 * scale,\n justifyContent: "center",\n transform: `scale(${0.8 + done * 0.2})`,\n width: 76 * scale,\n }}\n >\n \u2713\n </span>\n <strong\n style={{color: brand.colors.foreground, fontFamily: brand.typography.sans, fontSize: 34 * scale}}\n >\n {success}\n </strong>\n </div>\n ) : null}\n </div>\n </Fill>\n );\n};\n',
|
|
1478
|
+
target: "videos/components/form-flow/form-flow.tsx"
|
|
1479
|
+
},
|
|
1480
|
+
{
|
|
1481
|
+
path: "components/form-flow/form-flow.preview.tsx",
|
|
1482
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {FormFlow} from "./form-flow";\n\nexport default defineComponentPreview({\n title: "Form flow",\n category: "Product UI",\n description: "Fields, validation, submit, and the success it resolves to.",\n component: FormFlow,\n canvas: {width: 1920, height: 1080, duration: "6s"},\n controls: {\n title: {type: "text", defaultValue: "Create a project"},\n submit: {type: "text", defaultValue: "Create"},\n submitAt: {type: "number", defaultValue: 100, min: 30, max: 170},\n success: {type: "text", defaultValue: "Project created"},\n },\n examples: [\n {\n name: "Default",\n props: {\n fields: [\n {label: "Name", value: "launch-video", at: 12},\n {label: "Source root", value: "videos/", at: 60},\n ],\n },\n },\n {\n name: "With validation",\n props: {\n fields: [{label: "Name", value: "Launch Video", at: 12, error: "Use a lowercase, dash separated name."}],\n submitAt: 90,\n },\n },\n ],\n});\n',
|
|
1483
|
+
target: "videos/components/form-flow/form-flow.preview.tsx"
|
|
1484
|
+
}
|
|
1485
|
+
],
|
|
1486
|
+
meta: {
|
|
1487
|
+
kind: "component",
|
|
1488
|
+
family: "Product UI",
|
|
1489
|
+
namespaced: "@odori/form-flow",
|
|
1490
|
+
contract: {
|
|
1491
|
+
aspectRatios: [
|
|
1492
|
+
"16:9",
|
|
1493
|
+
"9:16",
|
|
1494
|
+
"1:1"
|
|
1495
|
+
],
|
|
1496
|
+
recommendedDurationInFrames: 180,
|
|
1497
|
+
minimumDurationInFrames: 60,
|
|
1498
|
+
entranceFrames: 14,
|
|
1499
|
+
exitFrames: 20,
|
|
1500
|
+
contentLimits: {
|
|
1501
|
+
title: 48,
|
|
1502
|
+
fields: 4,
|
|
1503
|
+
submit: 24
|
|
1504
|
+
},
|
|
1505
|
+
reducedMotion: "values appear complete rather than typing in",
|
|
1506
|
+
requires: {
|
|
1507
|
+
fonts: [
|
|
1508
|
+
"sans"
|
|
1509
|
+
],
|
|
1510
|
+
audio: []
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
},
|
|
1515
|
+
{
|
|
1516
|
+
name: "gradient-field",
|
|
1517
|
+
description: "Subtle brand-aware background motion that loops seamlessly.",
|
|
1518
|
+
registryDependencies: [],
|
|
1519
|
+
files: [
|
|
1520
|
+
{
|
|
1521
|
+
path: "components/gradient-field/gradient-field.tsx",
|
|
1522
|
+
content: 'import {Fill, useBrand, useFrame, useVideo} from "odori";\n\nexport type GradientFieldProps = {\n /** Colours to drift. Defaults to the brand\'s accent over its background. */\n colors?: string[];\n /** How far the blobs travel, as a fraction of the frame. */\n travel?: number;\n /** Frames for one full cycle. The motion repeats seamlessly across it. */\n cycleInFrames?: number;\n /** Blur radius in canvas pixels. Large is the point: this is atmosphere. */\n softness?: number;\n /** Overall strength, 0 to 1. */\n intensity?: number;\n};\n\n/**\n * Slow brand-aware colour behind everything else.\n *\n * The motion is driven by sine and cosine of the frame over one cycle, which\n * makes it exactly periodic: the last frame of a cycle matches the first, so\n * a background under a looping section never jumps. Deriving position from the\n * frame rather than from a CSS animation is also what keeps preview and export\n * showing the same pixels \u2014 a keyframe animation runs on wall time and would\n * be somewhere else in every render.\n */\nexport const GradientField = ({\n colors,\n travel = 0.18,\n cycleInFrames = 240,\n softness = 260,\n intensity = 0.55,\n}: GradientFieldProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const {width, height} = useVideo();\n const palette = colors ?? [brand.colors.accent, brand.colors.surface, brand.colors.accent];\n\n const turn = (frame / cycleInFrames) * Math.PI * 2;\n\n return (\n <Fill style={{background: brand.colors.background, overflow: "hidden"}}>\n {palette.map((color, index) => {\n // Each blob runs the same cycle at a different phase and a different\n // ratio, so they never line up and never drift out of period.\n const phase = turn + (index * Math.PI * 2) / palette.length;\n const x = 50 + Math.sin(phase) * travel * 100;\n const y = 50 + Math.cos(phase * (index % 2 === 0 ? 1 : -1.5)) * travel * 100;\n\n return (\n <div\n key={index}\n aria-hidden\n style={{\n background: `radial-gradient(circle at ${x}% ${y}%, ${color} 0%, transparent 60%)`,\n filter: `blur(${softness}px)`,\n inset: `-${softness}px`,\n opacity: intensity / palette.length + intensity * 0.35,\n position: "absolute",\n }}\n />\n );\n })}\n {/* A vignette keeps the corners from lifting, which is where a soft\n gradient otherwise starts to look like a rendering artefact. */}\n <div\n aria-hidden\n style={{\n background: `radial-gradient(circle at 50% 50%, transparent 40%, ${brand.colors.background} 100%)`,\n inset: 0,\n position: "absolute",\n }}\n />\n <div aria-hidden style={{height, position: "absolute", width, pointerEvents: "none"}} />\n </Fill>\n );\n};\n',
|
|
1523
|
+
target: "videos/components/gradient-field/gradient-field.tsx"
|
|
1524
|
+
},
|
|
1525
|
+
{
|
|
1526
|
+
path: "components/gradient-field/gradient-field.preview.tsx",
|
|
1527
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {GradientField} from "./gradient-field";\n\nexport default defineComponentPreview({\n title: "Gradient field",\n category: "Motion",\n description: "Subtle brand-aware background motion that loops seamlessly.",\n component: GradientField,\n canvas: {width: 1920, height: 1080, duration: "8s"},\n controls: {\n travel: {type: "number", defaultValue: 0.18, min: 0, max: 0.6, step: 0.02},\n cycleInFrames: {type: "number", defaultValue: 240, min: 60, max: 900},\n softness: {type: "number", defaultValue: 260, min: 40, max: 500},\n intensity: {type: "number", defaultValue: 0.55, min: 0.1, max: 1, step: 0.05},\n },\n examples: [\n {name: "Default", props: {}},\n {name: "Still", props: {travel: 0}},\n {name: "Strong", props: {intensity: 0.9, travel: 0.32}},\n ],\n});\n',
|
|
1528
|
+
target: "videos/components/gradient-field/gradient-field.preview.tsx"
|
|
1529
|
+
}
|
|
1530
|
+
],
|
|
1531
|
+
meta: {
|
|
1532
|
+
kind: "component",
|
|
1533
|
+
family: "Motion",
|
|
1534
|
+
namespaced: "@odori/gradient-field",
|
|
1535
|
+
contract: {
|
|
1536
|
+
aspectRatios: [
|
|
1537
|
+
"16:9",
|
|
1538
|
+
"9:16",
|
|
1539
|
+
"1:1"
|
|
1540
|
+
],
|
|
1541
|
+
recommendedDurationInFrames: 240,
|
|
1542
|
+
minimumDurationInFrames: 30,
|
|
1543
|
+
entranceFrames: 0,
|
|
1544
|
+
exitFrames: 0,
|
|
1545
|
+
contentLimits: {
|
|
1546
|
+
colors: 5
|
|
1547
|
+
},
|
|
1548
|
+
reducedMotion: "the field holds one still position",
|
|
1549
|
+
requires: {
|
|
1550
|
+
fonts: [],
|
|
1551
|
+
audio: []
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
},
|
|
1556
|
+
{
|
|
1557
|
+
name: "heatmap",
|
|
1558
|
+
description: "Grid intensity reveal for activity and distribution.",
|
|
1559
|
+
registryDependencies: [],
|
|
1560
|
+
files: [
|
|
1561
|
+
{
|
|
1562
|
+
path: "components/heatmap/heatmap.tsx",
|
|
1563
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type HeatmapProps = {\n /** Rows of intensity values, 0 to 1. */\n rows: number[][];\n title?: string;\n rowLabels?: string[];\n};\n\n/**\n * Grid intensity revealed cell by cell, in reading order. Intensity is carried\n * by opacity of one accent color rather than a rainbow, so the scale stays\n * legible at video distance.\n */\nexport const Heatmap = ({rows, title, rowLabels}: HeatmapProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const columns = Math.max(...rows.map((row) => row.length), 1);\n let cellIndex = 0;\n\n return (\n <Fill style={{gap: 34 * scale, justifyContent: "center", padding: `${110 * scale}px ${130 * scale}px`}}>\n {title ? (\n <div style={{fontSize: 56 * scale, fontWeight: 560, letterSpacing: "-0.04em", opacity: interpolate(frame, [0, 16], [0, 1])}}>\n {title}\n </div>\n ) : null}\n <div style={{display: "grid", gap: 12 * scale}}>\n {rows.map((row, rowNumber) => (\n <div key={rowNumber} style={{alignItems: "center", display: "flex", gap: 18 * scale}}>\n {rowLabels ? (\n <span style={{color: brand.colors.muted, fontSize: 26 * scale, width: 200 * scale}}>\n {rowLabels[rowNumber]}\n </span>\n ) : null}\n <div style={{display: "grid", flex: 1, gap: 12 * scale, gridTemplateColumns: `repeat(${columns}, 1fr)`}}>\n {row.map((intensity, columnNumber) => {\n const delay = 10 + cellIndex * 1.5;\n cellIndex += 1;\n const enter = interpolate(frame, [delay, delay + 14], [0, 1], {easing: Easing.standard});\n return (\n <span\n key={columnNumber}\n style={{\n background: `color-mix(in srgb, ${brand.colors.accent} ${Math.round(intensity * 100)}%, ${brand.colors.surface})`,\n borderRadius: 10 * scale,\n height: 62 * scale,\n opacity: enter,\n transform: `scale(${0.86 + enter * 0.14})`,\n }}\n />\n );\n })}\n </div>\n </div>\n ))}\n </div>\n </Fill>\n );\n};\n',
|
|
1564
|
+
target: "videos/components/heatmap/heatmap.tsx"
|
|
1565
|
+
},
|
|
1566
|
+
{
|
|
1567
|
+
path: "components/heatmap/heatmap.preview.tsx",
|
|
1568
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {Heatmap} from "./heatmap";\n\nexport default defineComponentPreview({\n title: "Heatmap",\n category: "Data",\n description: "Grid intensity reveal for activity and distribution.",\n component: Heatmap,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {title: {type: "text", defaultValue: "Renders per day"}},\n examples: [\n {\n name: "Activity",\n props: {\n rowLabels: ["week 1", "week 2", "week 3"],\n rows: [\n [0.1, 0.3, 0.2, 0.6, 0.4, 0.1, 0.05],\n [0.2, 0.5, 0.7, 0.8, 0.6, 0.2, 0.1],\n [0.4, 0.7, 0.9, 1, 0.8, 0.3, 0.15],\n ],\n },\n },\n ],\n});\n',
|
|
1569
|
+
target: "videos/components/heatmap/heatmap.preview.tsx"
|
|
1570
|
+
}
|
|
1571
|
+
],
|
|
1572
|
+
meta: {
|
|
1573
|
+
kind: "component",
|
|
1574
|
+
family: "Data",
|
|
1575
|
+
namespaced: "@odori/heatmap",
|
|
1576
|
+
contract: {
|
|
1577
|
+
aspectRatios: [
|
|
1578
|
+
"16:9",
|
|
1579
|
+
"9:16",
|
|
1580
|
+
"1:1"
|
|
1581
|
+
],
|
|
1582
|
+
recommendedDurationInFrames: 120,
|
|
1583
|
+
minimumDurationInFrames: 60,
|
|
1584
|
+
entranceFrames: 50,
|
|
1585
|
+
exitFrames: 0,
|
|
1586
|
+
contentLimits: {
|
|
1587
|
+
rows: 6,
|
|
1588
|
+
columns: 10
|
|
1589
|
+
},
|
|
1590
|
+
reducedMotion: "cells appear together",
|
|
1591
|
+
requires: {
|
|
1592
|
+
fonts: [
|
|
1593
|
+
"sans"
|
|
1594
|
+
],
|
|
1595
|
+
audio: []
|
|
1596
|
+
}
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
},
|
|
1600
|
+
{
|
|
1601
|
+
name: "html-canvas",
|
|
1602
|
+
description: "HTML authored into a canvas-safe rendered surface.",
|
|
1603
|
+
registryDependencies: [],
|
|
1604
|
+
files: [
|
|
1605
|
+
{
|
|
1606
|
+
path: "components/html-canvas/html-canvas.tsx",
|
|
1607
|
+
content: 'import {useEffect, useRef, useState} from "react";\nimport {Easing, Fill, drawHtml, interpolate, useBrand, useFrame, useReadiness, useVideo} from "odori";\n\nexport type HtmlCanvasProps = {\n /** Markup to rasterize. Every element must carry inline styles. */\n html: string;\n /** Style applied to the wrapper inside the SVG. */\n style?: string;\n /** Canvas size in composition pixels. Defaults to the whole frame. */\n width?: number;\n height?: number;\n /** Frames the result takes to fade up. */\n entranceFrames?: number;\n};\n\n/**\n * HTML drawn into the frame as pixels rather than as live DOM.\n *\n * The reason to want this is compositing: once markup is a canvas it can be\n * warped, masked, blurred, sampled, or fed to another draw call, none of which\n * a live DOM subtree can do. The reason it needs a component rather than a\n * snippet is that getting it right means holding the frame until the raster\n * lands and waiting for fonts first, and both are easy to forget once and hard\n * to notice afterwards.\n *\n * The markup is self-contained by necessity. An SVG `foreignObject` cannot see\n * the page\'s stylesheets, so styles are inline and images are data URLs; there\n * is no way to make that ergonomic, so it is at least stated plainly and the\n * failure names it.\n */\nexport const HtmlCanvas = ({html, style, width, height, entranceFrames = 12}: HtmlCanvasProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const video = useVideo();\n const readiness = useReadiness();\n const canvas = useRef<HTMLCanvasElement | null>(null);\n const [failed, setFailed] = useState<string | null>(null);\n\n const canvasWidth = width ?? video.width;\n const canvasHeight = height ?? video.height;\n\n // The raster is asynchronous, so it cannot use the synchronous useCanvas\n // path: the frame is held by hand until the image has been drawn.\n useEffect(() => {\n const element = canvas.current;\n if (!element) return;\n const context = element.getContext("2d");\n if (!context) return;\n\n let cancelled = false;\n const release = readiness.hold();\n context.clearRect(0, 0, element.width, element.height);\n drawHtml(context, html, {\n width: canvasWidth,\n height: canvasHeight,\n style: style ?? `color:${brand.colors.foreground};font-family:${brand.typography.sans};`,\n })\n .catch((error: Error) => {\n if (!cancelled) setFailed(error.message);\n })\n .finally(() => {\n if (!cancelled) release();\n });\n\n return () => {\n cancelled = true;\n release();\n };\n }, [brand.colors.foreground, brand.typography.sans, canvasHeight, canvasWidth, html, readiness, style]);\n\n const appear = interpolate(frame, [0, entranceFrames], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: "center", justifyContent: "center"}}>\n <canvas\n ref={canvas}\n width={canvasWidth}\n height={canvasHeight}\n style={{height: canvasHeight, opacity: appear, width: canvasWidth}}\n />\n {failed ? (\n <span\n style={{\n color: "#ff9b91",\n fontFamily: brand.typography.mono,\n fontSize: 24,\n maxWidth: "70%",\n position: "absolute",\n textAlign: "center",\n }}\n >\n {failed}\n </span>\n ) : null}\n </Fill>\n );\n};\n',
|
|
1608
|
+
target: "videos/components/html-canvas/html-canvas.tsx"
|
|
1609
|
+
},
|
|
1610
|
+
{
|
|
1611
|
+
path: "components/html-canvas/html-canvas.preview.tsx",
|
|
1612
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {HtmlCanvas} from "./html-canvas";\n\nconst CARD = `\n <div style="display:flex;flex-direction:column;gap:18px;padding:64px;border:1px solid #232323;border-radius:24px;background:#0c0c0c;">\n <div style="font-size:26px;letter-spacing:0.08em;text-transform:uppercase;color:#8f8f8f;">Deployment</div>\n <div style="font-size:76px;font-weight:600;letter-spacing:-0.03em;color:#f5f5f5;">odori.dev</div>\n <div style="font-size:30px;color:#8f8f8f;">Production \xB7 48s \xB7 ready</div>\n </div>\n`;\n\nexport default defineComponentPreview({\n title: "HTML canvas",\n category: "Media and canvas",\n description: "HTML authored into a canvas-safe rendered surface.",\n component: HtmlCanvas,\n canvas: {width: 1920, height: 1080, duration: "3s"},\n controls: {\n entranceFrames: {type: "number", defaultValue: 12, min: 0, max: 60},\n },\n examples: [\n {name: "Default", props: {html: CARD, width: 1100, height: 460}},\n {\n name: "Full frame",\n props: {\n html: `<div style="display:flex;align-items:center;justify-content:center;height:100%;font-size:96px;color:#f5f5f5;">Rasterized</div>`,\n },\n },\n ],\n});\n',
|
|
1613
|
+
target: "videos/components/html-canvas/html-canvas.preview.tsx"
|
|
1614
|
+
}
|
|
1615
|
+
],
|
|
1616
|
+
meta: {
|
|
1617
|
+
kind: "component",
|
|
1618
|
+
family: "Media and canvas",
|
|
1619
|
+
namespaced: "@odori/html-canvas",
|
|
1620
|
+
contract: {
|
|
1621
|
+
aspectRatios: [
|
|
1622
|
+
"16:9",
|
|
1623
|
+
"9:16",
|
|
1624
|
+
"1:1"
|
|
1625
|
+
],
|
|
1626
|
+
recommendedDurationInFrames: 90,
|
|
1627
|
+
minimumDurationInFrames: 24,
|
|
1628
|
+
entranceFrames: 12,
|
|
1629
|
+
exitFrames: 0,
|
|
1630
|
+
contentLimits: {
|
|
1631
|
+
html: 4e3
|
|
1632
|
+
},
|
|
1633
|
+
reducedMotion: "the raster appears without fading in",
|
|
1634
|
+
requires: {
|
|
1635
|
+
fonts: [
|
|
1636
|
+
"sans"
|
|
1637
|
+
],
|
|
1638
|
+
audio: []
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
},
|
|
1643
|
+
{
|
|
1644
|
+
name: "identity-reveal",
|
|
1645
|
+
description: "Product mark and name entrance with visual restraint.",
|
|
1646
|
+
registryDependencies: [],
|
|
1647
|
+
files: [
|
|
1648
|
+
{
|
|
1649
|
+
path: "components/identity-reveal/identity-reveal.tsx",
|
|
1650
|
+
content: 'import type {ReactNode} from "react";\nimport {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type IdentityRevealProps = {\n /** Defaults to the brand name, so a project never hardcodes its own mark. */\n wordmark?: string;\n /**\n * Your own mark, as JSX. Inline SVG scales with the design, takes brand\n * colors, and can animate per frame, which a file can never do.\n */\n mark?: ReactNode;\n /** A key in `brand.logos`, or a URL. Used when no `mark` is given. */\n logo?: string;\n detail?: string;\n};\n\n/**\n * The product mark and name entering together: the mark settles first, the\n * wordmark follows out of a clipped box, and a rule draws under both. Restraint\n * is the point, so nothing spins or bounces.\n */\nexport const IdentityReveal = ({wordmark, mark: markNode, logo, detail}: IdentityRevealProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const name = wordmark ?? brand.name;\n const source = logo ? brand.logos[logo] ?? logo : undefined;\n\n const mark = interpolate(frame, [0, 24], [0, 1], {easing: Easing.standard});\n const word = interpolate(frame, [12, 38], [0, 1], {easing: Easing.standard});\n const rule = interpolate(frame, [28, 54], [0, 1], {easing: Easing.standard});\n const note = interpolate(frame, [38, 60], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: "center", gap: 26 * scale, justifyContent: "center"}}>\n <div style={{alignItems: "center", display: "flex", gap: 26 * scale}}>\n {markNode ? (\n <div\n style={{\n alignItems: "center",\n display: "flex",\n height: 88 * scale,\n opacity: mark,\n transform: `scale(${0.92 + mark * 0.08})`,\n }}\n >\n {markNode}\n </div>\n ) : source ? (\n <img alt="" src={source} style={{height: 88 * scale, opacity: mark, transform: `scale(${0.92 + mark * 0.08})`}} />\n ) : (\n <span\n style={{\n background: brand.colors.accent,\n borderRadius: 18 * scale,\n display: "block",\n height: 72 * scale,\n opacity: mark,\n transform: `scale(${0.9 + mark * 0.1})`,\n width: 72 * scale,\n }}\n />\n )}\n <div style={{overflow: "hidden", paddingBottom: 8 * scale}}>\n <span\n style={{\n display: "inline-block",\n fontSize: 96 * scale,\n fontWeight: 600,\n letterSpacing: "-0.05em",\n opacity: word,\n transform: `translateY(${(1 - word) * 66 * scale}px)`,\n }}\n >\n {name}\n </span>\n </div>\n </div>\n\n <div style={{background: brand.colors.border, height: Math.max(1, 1 * scale), transform: `scaleX(${rule})`, width: 420 * scale}} />\n\n {detail ? <div style={{color: brand.colors.muted, fontSize: 32 * scale, opacity: note}}>{detail}</div> : null}\n </Fill>\n );\n};\n',
|
|
1651
|
+
target: "videos/components/identity-reveal/identity-reveal.tsx"
|
|
1652
|
+
},
|
|
1653
|
+
{
|
|
1654
|
+
path: "components/identity-reveal/identity-reveal.preview.tsx",
|
|
1655
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {IdentityReveal} from "./identity-reveal";\n\nexport default defineComponentPreview({\n title: "Identity reveal",\n category: "Foundation",\n description: "Product mark and name entrance with visual restraint.",\n component: IdentityReveal,\n canvas: {width: 1920, height: 1080, duration: "3s"},\n controls: {\n wordmark: {type: "text", defaultValue: "odori", maxLength: 24},\n detail: {type: "text", defaultValue: "An independent React video framework."},\n },\n examples: [\n {name: "Mark and name", props: {}},\n {name: "Name only", props: {wordmark: "odori", detail: ""}},\n ],\n});\n',
|
|
1656
|
+
target: "videos/components/identity-reveal/identity-reveal.preview.tsx"
|
|
1657
|
+
}
|
|
1658
|
+
],
|
|
1659
|
+
meta: {
|
|
1660
|
+
kind: "component",
|
|
1661
|
+
family: "Foundation",
|
|
1662
|
+
namespaced: "@odori/identity-reveal",
|
|
1663
|
+
contract: {
|
|
1664
|
+
aspectRatios: [
|
|
1665
|
+
"16:9",
|
|
1666
|
+
"9:16",
|
|
1667
|
+
"1:1"
|
|
1668
|
+
],
|
|
1669
|
+
recommendedDurationInFrames: 90,
|
|
1670
|
+
minimumDurationInFrames: 60,
|
|
1671
|
+
entranceFrames: 60,
|
|
1672
|
+
exitFrames: 0,
|
|
1673
|
+
contentLimits: {
|
|
1674
|
+
wordmark: 24,
|
|
1675
|
+
detail: 72
|
|
1676
|
+
},
|
|
1677
|
+
reducedMotion: "mark and name fade in together",
|
|
1678
|
+
requires: {
|
|
1679
|
+
fonts: [
|
|
1680
|
+
"sans"
|
|
1681
|
+
],
|
|
1682
|
+
audio: []
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
},
|
|
1687
|
+
{
|
|
1688
|
+
name: "image-stage",
|
|
1689
|
+
description: "A still with a slow push, held until it has decoded.",
|
|
1690
|
+
registryDependencies: [],
|
|
1691
|
+
files: [
|
|
1692
|
+
{
|
|
1693
|
+
path: "components/image-stage/image-stage.tsx",
|
|
1694
|
+
content: 'import {Easing, Fill, interpolate, placeholderImage, useDesignScale, useFrame, useReadiness} from "odori";\nimport {useEffect, useState} from "react";\n\nexport type ImageStageProps = {\n /** Image URL. Defaults to a generated placeholder, so a fixture needs no file. */\n src?: string;\n alt?: string;\n /** How the image meets the frame. */\n fit?: "contain" | "cover";\n /** Slow push in or out across the scene, as a scale delta. */\n zoom?: number;\n /** Pan across the scene, as a fraction of the frame. */\n pan?: {x?: number; y?: number};\n entranceFrames?: number;\n};\n\n/**\n * A still, with the slow move that keeps it from reading as a slide.\n *\n * The move is the whole component. A static image in a video looks like the\n * player has frozen, and a fraction of a percent of drift per frame is what\n * tells the eye it has not \u2014 so `zoom` and `pan` are small by default and are\n * derived from the frame rather than from a CSS transition.\n *\n * The frame is held until the image decodes. Without that the capture takes a\n * screenshot of a gap, which is the classic way a rendered video ends up with\n * the first second missing its picture.\n */\nexport const ImageStage = ({src, alt = "", fit = "cover", zoom = 0.06, pan, entranceFrames = 16}: ImageStageProps) => {\n const frame = useFrame();\n const scale = useDesignScale();\n const readiness = useReadiness();\n const [ready, setReady] = useState(false);\n const url = src ?? placeholderImage({label: "image-stage", seed: "image"});\n\n useEffect(() => {\n setReady(false);\n const release = readiness.hold();\n const image = new Image();\n let cancelled = false;\n const done = () => {\n if (cancelled) return;\n setReady(true);\n release();\n };\n image.onload = done;\n // A failed decode still releases: a missing file should show as a missing\n // picture, not as a render that hangs until the driver times out.\n image.onerror = done;\n image.src = url;\n return () => {\n cancelled = true;\n release();\n };\n }, [readiness, url]);\n\n const progress = frame / 300;\n const appear = interpolate(frame, [0, entranceFrames], [0, 1], {easing: Easing.standard});\n const offsetX = (pan?.x ?? 0) * progress * 100;\n const offsetY = (pan?.y ?? 0) * progress * 100;\n\n return (\n <Fill style={{overflow: "hidden"}}>\n <img\n src={url}\n alt={alt}\n style={{\n height: "100%",\n objectFit: fit,\n opacity: ready ? appear : 0,\n transform: `scale(${1 + zoom * progress}) translate(${offsetX}%, ${offsetY}%)`,\n transformOrigin: "center",\n width: "100%",\n }}\n />\n <span style={{display: "none"}}>{scale}</span>\n </Fill>\n );\n};\n',
|
|
1695
|
+
target: "videos/components/image-stage/image-stage.tsx"
|
|
1696
|
+
},
|
|
1697
|
+
{
|
|
1698
|
+
path: "components/image-stage/image-stage.preview.tsx",
|
|
1699
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {ImageStage} from "./image-stage";\n\nexport default defineComponentPreview({\n title: "Image stage",\n category: "Media and canvas",\n description: "A still with a slow push, held until it has decoded.",\n component: ImageStage,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {\n fit: {type: "select", defaultValue: "cover", options: ["cover", "contain"]},\n zoom: {type: "number", defaultValue: 0.06, min: -0.2, max: 0.4, step: 0.02},\n entranceFrames: {type: "number", defaultValue: 16, min: 0, max: 60},\n },\n examples: [\n {name: "Default", props: {}},\n {name: "Pan right", props: {pan: {x: -0.04}, zoom: 0.1}},\n {name: "Contain", props: {fit: "contain", zoom: 0}},\n ],\n});\n',
|
|
1700
|
+
target: "videos/components/image-stage/image-stage.preview.tsx"
|
|
1701
|
+
}
|
|
1702
|
+
],
|
|
1703
|
+
meta: {
|
|
1704
|
+
kind: "component",
|
|
1705
|
+
family: "Media and canvas",
|
|
1706
|
+
namespaced: "@odori/image-stage",
|
|
1707
|
+
contract: {
|
|
1708
|
+
aspectRatios: [
|
|
1709
|
+
"16:9",
|
|
1710
|
+
"9:16",
|
|
1711
|
+
"1:1"
|
|
1712
|
+
],
|
|
1713
|
+
recommendedDurationInFrames: 150,
|
|
1714
|
+
minimumDurationInFrames: 30,
|
|
1715
|
+
entranceFrames: 16,
|
|
1716
|
+
exitFrames: 0,
|
|
1717
|
+
contentLimits: {
|
|
1718
|
+
alt: 120
|
|
1719
|
+
},
|
|
1720
|
+
reducedMotion: "the still holds without drifting",
|
|
1721
|
+
requires: {
|
|
1722
|
+
fonts: [],
|
|
1723
|
+
audio: []
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
},
|
|
1728
|
+
{
|
|
1729
|
+
name: "kinetic-center",
|
|
1730
|
+
description: "A centered statement assembled from timed word groups.",
|
|
1731
|
+
registryDependencies: [],
|
|
1732
|
+
files: [
|
|
1733
|
+
{
|
|
1734
|
+
path: "components/kinetic-center/kinetic-center.tsx",
|
|
1735
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type KineticCenterProps = {\n /** Word groups that assemble into one centered statement. */\n groups: string[];\n accentIndex?: number;\n};\n\n/**\n * A centered statement assembled from timed word groups. Each group scales in\n * from slightly under its final size, so the line tightens as it completes.\n */\nexport const KineticCenter = ({groups, accentIndex}: KineticCenterProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n return (\n <Fill\n style={{\n alignItems: "center",\n justifyContent: "center",\n padding: `${120 * scale}px ${140 * scale}px`,\n }}\n >\n <div\n style={{\n display: "flex",\n flexWrap: "wrap",\n gap: `${10 * scale}px ${24 * scale}px`,\n justifyContent: "center",\n maxWidth: 1560 * scale,\n textAlign: "center",\n }}\n >\n {groups.map((group, index) => {\n const delay = 6 + index * 9;\n const progress = interpolate(frame, [delay, delay + 24], [0, 1], {easing: Easing.standard});\n const accent = index === accentIndex;\n return (\n <span\n key={`${group}-${index}`}\n style={{\n color: accent ? brand.colors.accent : brand.colors.foreground,\n display: "inline-block",\n fontSize: 118 * scale,\n fontWeight: accent ? 600 : 540,\n letterSpacing: "-0.05em",\n lineHeight: 1.04,\n opacity: progress,\n transform: `scale(${0.9 + progress * 0.1}) translateY(${(1 - progress) * 22 * scale}px)`,\n }}\n >\n {group}\n </span>\n );\n })}\n </div>\n </Fill>\n );\n};\n',
|
|
1736
|
+
target: "videos/components/kinetic-center/kinetic-center.tsx"
|
|
1737
|
+
},
|
|
1738
|
+
{
|
|
1739
|
+
path: "components/kinetic-center/kinetic-center.preview.tsx",
|
|
1740
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {KineticCenter} from "./kinetic-center";\n\nexport default defineComponentPreview({\n title: "Kinetic center",\n category: "Typography",\n description: "A centered statement assembled from timed word groups.",\n component: KineticCenter,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n accentIndex: {type: "number", defaultValue: 2, min: 0, max: 5},\n },\n examples: [\n {name: "Default", props: {groups: ["Make", "motion", "useful."], accentIndex: 2}},\n {name: "Longer", props: {groups: ["Every", "frame", "is", "a", "decision."], accentIndex: 4}},\n ],\n});\n',
|
|
1741
|
+
target: "videos/components/kinetic-center/kinetic-center.preview.tsx"
|
|
1742
|
+
}
|
|
1743
|
+
],
|
|
1744
|
+
meta: {
|
|
1745
|
+
kind: "component",
|
|
1746
|
+
family: "Typography",
|
|
1747
|
+
namespaced: "@odori/kinetic-center",
|
|
1748
|
+
contract: {
|
|
1749
|
+
aspectRatios: [
|
|
1750
|
+
"16:9",
|
|
1751
|
+
"9:16",
|
|
1752
|
+
"1:1"
|
|
1753
|
+
],
|
|
1754
|
+
recommendedDurationInFrames: 120,
|
|
1755
|
+
minimumDurationInFrames: 45,
|
|
1756
|
+
entranceFrames: 33,
|
|
1757
|
+
exitFrames: 10,
|
|
1758
|
+
contentLimits: {
|
|
1759
|
+
groups: 6,
|
|
1760
|
+
groupLength: 16
|
|
1761
|
+
},
|
|
1762
|
+
reducedMotion: "groups fade without scaling",
|
|
1763
|
+
requires: {
|
|
1764
|
+
fonts: [
|
|
1765
|
+
"sans"
|
|
1766
|
+
],
|
|
1767
|
+
audio: []
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
},
|
|
1772
|
+
{
|
|
1773
|
+
name: "line-chart",
|
|
1774
|
+
description: "Time-series path draw with optional focal annotation.",
|
|
1775
|
+
registryDependencies: [],
|
|
1776
|
+
files: [
|
|
1777
|
+
{
|
|
1778
|
+
path: "components/line-chart/line-chart.tsx",
|
|
1779
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type LineChartProps = {\n /** One value per step, drawn left to right. */\n values: number[];\n title?: string;\n /** Index to annotate once the path finishes drawing. */\n annotateIndex?: number;\n annotation?: string;\n};\n\n/**\n * A time series drawn as one continuous path. The line reveals by clipping\n * from the left rather than by animating point positions, so the shape of the\n * data never lies during the reveal.\n */\nexport const LineChart = ({values, title, annotateIndex, annotation}: LineChartProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const max = Math.max(...values, 1);\n const min = Math.min(...values, 0);\n const draw = interpolate(frame, [12, 60], [0, 1], {easing: Easing.standard});\n const point = (value: number, index: number) => ({\n x: (index / Math.max(1, values.length - 1)) * 100,\n y: 100 - ((value - min) / Math.max(1, max - min)) * 100,\n });\n const path = values\n .map((value, index) => {\n const {x, y} = point(value, index);\n return `${index === 0 ? "M" : "L"} ${x} ${y}`;\n })\n .join(" ");\n const marked = annotateIndex !== undefined ? point(values[annotateIndex] ?? 0, annotateIndex) : null;\n const markOpacity = interpolate(frame, [58, 76], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{gap: 34 * scale, justifyContent: "center", padding: `${110 * scale}px ${130 * scale}px`}}>\n {title ? (\n <div style={{fontSize: 58 * scale, fontWeight: 560, letterSpacing: "-0.04em", opacity: interpolate(frame, [0, 16], [0, 1])}}>\n {title}\n </div>\n ) : null}\n <div style={{height: 460 * scale, position: "relative", width: "100%"}}>\n <svg preserveAspectRatio="none" style={{height: "100%", width: "100%"}} viewBox="0 0 100 100">\n <path\n d={path}\n fill="none"\n stroke={brand.colors.foreground}\n strokeLinecap="round"\n strokeWidth={9 * scale}\n style={{clipPath: `inset(0 ${(1 - draw) * 100}% 0 0)`}}\n vectorEffect="non-scaling-stroke"\n />\n </svg>\n {marked ? (\n <div\n style={{\n left: `${marked.x}%`,\n opacity: markOpacity,\n position: "absolute",\n top: `${marked.y}%`,\n transform: "translate(-50%, -50%)",\n }}\n >\n <span\n style={{\n background: brand.colors.accent,\n borderRadius: 999,\n display: "block",\n height: 18 * scale,\n width: 18 * scale,\n }}\n />\n </div>\n ) : null}\n </div>\n {annotation ? (\n <div style={{color: brand.colors.muted, fontSize: 32 * scale, opacity: markOpacity}}>{annotation}</div>\n ) : null}\n </Fill>\n );\n};\n',
|
|
1780
|
+
target: "videos/components/line-chart/line-chart.tsx"
|
|
1781
|
+
},
|
|
1782
|
+
{
|
|
1783
|
+
path: "components/line-chart/line-chart.preview.tsx",
|
|
1784
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {LineChart} from "./line-chart";\n\nexport default defineComponentPreview({\n title: "Line chart",\n category: "Data",\n description: "Time-series path draw with optional focal annotation.",\n component: LineChart,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n title: {type: "text", defaultValue: "Videos shipped per week"},\n annotation: {type: "text", defaultValue: "The week the registry landed."},\n annotateIndex: {type: "number", defaultValue: 6, min: 0, max: 11},\n },\n examples: [\n {name: "Growth", props: {values: [3, 4, 6, 5, 9, 12, 18, 21], annotateIndex: 6}},\n ],\n});\n',
|
|
1785
|
+
target: "videos/components/line-chart/line-chart.preview.tsx"
|
|
1786
|
+
}
|
|
1787
|
+
],
|
|
1788
|
+
meta: {
|
|
1789
|
+
kind: "component",
|
|
1790
|
+
family: "Data",
|
|
1791
|
+
namespaced: "@odori/line-chart",
|
|
1792
|
+
contract: {
|
|
1793
|
+
aspectRatios: [
|
|
1794
|
+
"16:9",
|
|
1795
|
+
"1:1"
|
|
1796
|
+
],
|
|
1797
|
+
recommendedDurationInFrames: 120,
|
|
1798
|
+
minimumDurationInFrames: 78,
|
|
1799
|
+
entranceFrames: 64,
|
|
1800
|
+
exitFrames: 0,
|
|
1801
|
+
contentLimits: {
|
|
1802
|
+
points: 16
|
|
1803
|
+
},
|
|
1804
|
+
reducedMotion: "the full path is drawn immediately",
|
|
1805
|
+
requires: {
|
|
1806
|
+
fonts: [
|
|
1807
|
+
"sans"
|
|
1808
|
+
],
|
|
1809
|
+
audio: []
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
},
|
|
1814
|
+
{
|
|
1815
|
+
name: "log-stream",
|
|
1816
|
+
description: "Paced runtime logs with levels and bounded scrolling.",
|
|
1817
|
+
registryDependencies: [],
|
|
1818
|
+
files: [
|
|
1819
|
+
{
|
|
1820
|
+
path: "components/log-stream/log-stream.tsx",
|
|
1821
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type LogEntry = {\n message: string;\n level?: "info" | "warn" | "error" | "done";\n /** Frame the line appears on. Derived from the index when omitted. */\n atFrame?: number;\n};\n\nexport type LogStreamProps = {\n entries: LogEntry[];\n title?: string;\n /** Lines visible at once before the stream scrolls. */\n window?: number;\n};\n\n/**\n * Runtime logs arriving on a schedule, bounded so the panel never grows past\n * the frame. Older lines scroll out of the window rather than shrinking the\n * type to fit.\n */\nexport const LogStream = ({entries, title = "odori export launch", window: visible = 6}: LogStreamProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n const arrived = entries.filter((entry, index) => frame >= (entry.atFrame ?? 14 + index * 12));\n const shown = arrived.slice(Math.max(0, arrived.length - visible));\n const color = (level: LogEntry["level"]) =>\n level === "error" || level === "warn" ? brand.colors.accent : level === "done" ? brand.colors.foreground : brand.colors.muted;\n\n return (\n <Fill style={{alignItems: "center", justifyContent: "center", padding: 90 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 24 * scale,\n minHeight: visible * 56 * scale,\n minWidth: 1180 * scale,\n overflow: "hidden",\n padding: `${24 * scale}px 0`,\n }}\n >\n <div\n style={{\n color: brand.colors.muted,\n fontFamily: brand.typography.mono,\n fontSize: 24 * scale,\n padding: `0 ${28 * scale}px ${16 * scale}px`,\n }}\n >\n {title}\n </div>\n {shown.map((entry, index) => {\n const at = entry.atFrame ?? 14 + entries.indexOf(entry) * 12;\n const enter = interpolate(frame, [at, at + 10], [0, 1], {easing: Easing.standard});\n return (\n <div\n key={`${entry.message}-${index}`}\n style={{\n alignItems: "center",\n color: color(entry.level),\n display: "flex",\n fontFamily: brand.typography.mono,\n fontSize: 28 * scale,\n gap: 16 * scale,\n opacity: enter,\n padding: `${8 * scale}px ${28 * scale}px`,\n transform: `translateY(${(1 - enter) * 10 * scale}px)`,\n }}\n >\n <span style={{opacity: 0.55, width: 90 * scale}}>{(entry.level ?? "info").toUpperCase()}</span>\n <span>{entry.message}</span>\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n',
|
|
1822
|
+
target: "videos/components/log-stream/log-stream.tsx"
|
|
1823
|
+
},
|
|
1824
|
+
{
|
|
1825
|
+
path: "components/log-stream/log-stream.preview.tsx",
|
|
1826
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {LogStream} from "./log-stream";\n\nexport default defineComponentPreview({\n title: "Log stream",\n category: "Developer proof",\n description: "Paced runtime logs with levels and bounded scrolling.",\n component: LogStream,\n canvas: {width: 1920, height: 1080, duration: "6s"},\n controls: {\n title: {type: "text", defaultValue: "odori export launch"},\n window: {type: "number", defaultValue: 6, min: 3, max: 10},\n },\n examples: [\n {\n name: "Export",\n props: {\n entries: [\n {message: "resolved manifest launch@2f9c", level: "info"},\n {message: "sampling 12 representative frames", level: "info"},\n {message: "scene proof runs 18f under its contract", level: "warn"},\n {message: "rendering 360 frames", level: "info"},\n {message: "mixing audio to -14 LUFS", level: "info"},\n {message: "wrote out/launch.mp4", level: "done"},\n ],\n },\n },\n ],\n});\n',
|
|
1827
|
+
target: "videos/components/log-stream/log-stream.preview.tsx"
|
|
1828
|
+
}
|
|
1829
|
+
],
|
|
1830
|
+
meta: {
|
|
1831
|
+
kind: "component",
|
|
1832
|
+
family: "Developer proof",
|
|
1833
|
+
namespaced: "@odori/log-stream",
|
|
1834
|
+
contract: {
|
|
1835
|
+
aspectRatios: [
|
|
1836
|
+
"16:9",
|
|
1837
|
+
"1:1"
|
|
1838
|
+
],
|
|
1839
|
+
recommendedDurationInFrames: 180,
|
|
1840
|
+
minimumDurationInFrames: 90,
|
|
1841
|
+
entranceFrames: 14,
|
|
1842
|
+
exitFrames: 0,
|
|
1843
|
+
contentLimits: {
|
|
1844
|
+
entries: 10,
|
|
1845
|
+
messageLength: 56
|
|
1846
|
+
},
|
|
1847
|
+
reducedMotion: "lines appear without travel",
|
|
1848
|
+
requires: {
|
|
1849
|
+
fonts: [
|
|
1850
|
+
"mono"
|
|
1851
|
+
],
|
|
1852
|
+
audio: []
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
},
|
|
1857
|
+
{
|
|
1858
|
+
name: "logo-orbit",
|
|
1859
|
+
description: "Product identity surrounded by connected integrations.",
|
|
1860
|
+
registryDependencies: [],
|
|
1861
|
+
files: [
|
|
1862
|
+
{
|
|
1863
|
+
path: "components/logo-orbit/logo-orbit.tsx",
|
|
1864
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type LogoOrbitProps = {\n /** Center wordmark. Defaults to the brand name. */\n center?: string;\n /** Integration names placed evenly around the ring. */\n around: string[];\n detail?: string;\n};\n\n/**\n * One identity surrounded by what connects to it. The ring is static and the\n * marks arrive in order, because a spinning orbit is unreadable at any\n * distance a viewer will actually watch from.\n */\nexport const LogoOrbit = ({center, around, detail}: LogoOrbitProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const name = center ?? brand.name;\n const ring = interpolate(frame, [6, 40], [0, 1], {easing: Easing.standard});\n const radius = 300 * scale;\n\n return (\n <Fill style={{alignItems: "center", justifyContent: "center"}}>\n <div style={{height: radius * 2, position: "relative", width: radius * 2}}>\n <div\n style={{\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 999,\n inset: 0,\n opacity: ring,\n position: "absolute",\n transform: `scale(${0.9 + ring * 0.1})`,\n }}\n />\n <div\n style={{\n alignItems: "center",\n display: "flex",\n flexDirection: "column",\n gap: 10 * scale,\n inset: 0,\n justifyContent: "center",\n position: "absolute",\n }}\n >\n <span style={{fontSize: 72 * scale, fontWeight: 600, letterSpacing: "-0.05em", opacity: ring}}>{name}</span>\n {detail ? <span style={{color: brand.colors.muted, fontSize: 26 * scale, opacity: ring}}>{detail}</span> : null}\n </div>\n {around.map((label, index) => {\n const angle = (index / around.length) * Math.PI * 2 - Math.PI / 2;\n const at = 20 + index * 8;\n const enter = interpolate(frame, [at, at + 18], [0, 1], {easing: Easing.standard});\n return (\n <div\n key={label}\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 999,\n fontFamily: brand.typography.mono,\n fontSize: 24 * scale,\n left: radius + Math.cos(angle) * radius,\n opacity: enter,\n padding: `${12 * scale}px ${20 * scale}px`,\n position: "absolute",\n top: radius + Math.sin(angle) * radius,\n transform: `translate(-50%, -50%) scale(${0.85 + enter * 0.15})`,\n }}\n >\n {label}\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n',
|
|
1865
|
+
target: "videos/components/logo-orbit/logo-orbit.tsx"
|
|
1866
|
+
},
|
|
1867
|
+
{
|
|
1868
|
+
path: "components/logo-orbit/logo-orbit.preview.tsx",
|
|
1869
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {LogoOrbit} from "./logo-orbit";\n\nexport default defineComponentPreview({\n title: "Logo orbit",\n category: "Narrative",\n description: "Product identity surrounded by connected integrations.",\n component: LogoOrbit,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {\n center: {type: "text", defaultValue: "odori", maxLength: 18},\n detail: {type: "text", defaultValue: "one ecosystem"},\n },\n examples: [\n {name: "Ecosystem", props: {around: ["Next.js", "React", "Studio", "FFmpeg", "CI", "Slack"]}},\n ],\n});\n',
|
|
1870
|
+
target: "videos/components/logo-orbit/logo-orbit.preview.tsx"
|
|
1871
|
+
}
|
|
1872
|
+
],
|
|
1873
|
+
meta: {
|
|
1874
|
+
kind: "component",
|
|
1875
|
+
family: "Narrative",
|
|
1876
|
+
namespaced: "@odori/logo-orbit",
|
|
1877
|
+
contract: {
|
|
1878
|
+
aspectRatios: [
|
|
1879
|
+
"16:9",
|
|
1880
|
+
"9:16",
|
|
1881
|
+
"1:1"
|
|
1882
|
+
],
|
|
1883
|
+
recommendedDurationInFrames: 150,
|
|
1884
|
+
minimumDurationInFrames: 75,
|
|
1885
|
+
entranceFrames: 68,
|
|
1886
|
+
exitFrames: 0,
|
|
1887
|
+
contentLimits: {
|
|
1888
|
+
around: 8,
|
|
1889
|
+
labelLength: 16
|
|
1890
|
+
},
|
|
1891
|
+
reducedMotion: "marks appear without scaling",
|
|
1892
|
+
requires: {
|
|
1893
|
+
fonts: [
|
|
1894
|
+
"sans",
|
|
1895
|
+
"mono"
|
|
1896
|
+
],
|
|
1897
|
+
audio: []
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
},
|
|
1902
|
+
{
|
|
1903
|
+
name: "marks-starter",
|
|
1904
|
+
description: "Placeholder marks that draw themselves on, as editable SVG source.",
|
|
1905
|
+
registryDependencies: [],
|
|
1906
|
+
files: [
|
|
1907
|
+
{
|
|
1908
|
+
path: "components/marks-starter/marks-starter.tsx",
|
|
1909
|
+
content: `import type {CSSProperties} from "react";
|
|
1910
|
+
import {useBrand, useFrame, useDesignScale, Easing, interpolate} from "odori";
|
|
1911
|
+
|
|
1912
|
+
export type MarkProps = {
|
|
1913
|
+
/** Defaults to the brand accent, so a mark inherits the palette. */
|
|
1914
|
+
color?: string;
|
|
1915
|
+
/** Size in design pixels, scaled for the format. */
|
|
1916
|
+
size?: number;
|
|
1917
|
+
/** Draw the mark on across this many frames. Zero renders it complete. */
|
|
1918
|
+
drawFrames?: number;
|
|
1919
|
+
style?: CSSProperties;
|
|
1920
|
+
};
|
|
1921
|
+
|
|
1922
|
+
/**
|
|
1923
|
+
* Placeholder marks, as source. Replace the paths with your own and the motion
|
|
1924
|
+
* stays: a stroked mark draws itself on from the frame clock, which is the one
|
|
1925
|
+
* thing an image file can never do.
|
|
1926
|
+
*
|
|
1927
|
+
* These are deliberately generic. A registry that shipped other companies'
|
|
1928
|
+
* logos would be making a trademark decision on your behalf.
|
|
1929
|
+
*/
|
|
1930
|
+
const useMark = (drawFrames: number, color?: string) => {
|
|
1931
|
+
const frame = useFrame();
|
|
1932
|
+
const brand = useBrand();
|
|
1933
|
+
const progress =
|
|
1934
|
+
drawFrames <= 0 ? 1 : interpolate(frame, [0, drawFrames], [0, 1], {easing: Easing.standard});
|
|
1935
|
+
return {stroke: color ?? brand.colors.accent, progress};
|
|
1936
|
+
};
|
|
1937
|
+
|
|
1938
|
+
/** A closed shape that draws its outline, then fills. */
|
|
1939
|
+
export const MarkAperture = ({color, size = 72, drawFrames = 24, style}: MarkProps) => {
|
|
1940
|
+
const scale = useDesignScale();
|
|
1941
|
+
const {stroke, progress} = useMark(drawFrames, color);
|
|
1942
|
+
const length = 240;
|
|
1943
|
+
|
|
1944
|
+
return (
|
|
1945
|
+
<svg
|
|
1946
|
+
viewBox="0 0 100 100"
|
|
1947
|
+
style={{height: size * scale, width: size * scale, ...style}}
|
|
1948
|
+
aria-hidden="true"
|
|
1949
|
+
>
|
|
1950
|
+
<circle
|
|
1951
|
+
cx="50"
|
|
1952
|
+
cy="50"
|
|
1953
|
+
r="38"
|
|
1954
|
+
fill={stroke}
|
|
1955
|
+
fillOpacity={Math.max(0, (progress - 0.7) / 0.3) * 0.16}
|
|
1956
|
+
stroke={stroke}
|
|
1957
|
+
strokeWidth="8"
|
|
1958
|
+
strokeDasharray={length}
|
|
1959
|
+
strokeDashoffset={length * (1 - progress)}
|
|
1960
|
+
strokeLinecap="round"
|
|
1961
|
+
/>
|
|
1962
|
+
</svg>
|
|
1963
|
+
);
|
|
1964
|
+
};
|
|
1965
|
+
|
|
1966
|
+
/** Three ascending bars, staggered so the mark builds rather than appears. */
|
|
1967
|
+
export const MarkStack = ({color, size = 72, drawFrames = 24, style}: MarkProps) => {
|
|
1968
|
+
const scale = useDesignScale();
|
|
1969
|
+
const frame = useFrame();
|
|
1970
|
+
const {stroke} = useMark(drawFrames, color);
|
|
1971
|
+
const bars = [
|
|
1972
|
+
{x: 18, height: 30},
|
|
1973
|
+
{x: 42, height: 52},
|
|
1974
|
+
{x: 66, height: 74},
|
|
1975
|
+
];
|
|
1976
|
+
|
|
1977
|
+
return (
|
|
1978
|
+
<svg
|
|
1979
|
+
viewBox="0 0 100 100"
|
|
1980
|
+
style={{height: size * scale, width: size * scale, ...style}}
|
|
1981
|
+
aria-hidden="true"
|
|
1982
|
+
>
|
|
1983
|
+
{bars.map((bar, index) => {
|
|
1984
|
+
const start = index * Math.max(1, drawFrames / 4);
|
|
1985
|
+
const grow =
|
|
1986
|
+
drawFrames <= 0
|
|
1987
|
+
? 1
|
|
1988
|
+
: interpolate(frame, [start, start + drawFrames], [0, 1], {easing: Easing.standard});
|
|
1989
|
+
return (
|
|
1990
|
+
<rect
|
|
1991
|
+
key={bar.x}
|
|
1992
|
+
x={bar.x}
|
|
1993
|
+
y={88 - bar.height * grow}
|
|
1994
|
+
width="16"
|
|
1995
|
+
height={bar.height * grow}
|
|
1996
|
+
rx="4"
|
|
1997
|
+
fill={stroke}
|
|
1998
|
+
/>
|
|
1999
|
+
);
|
|
2000
|
+
})}
|
|
2001
|
+
</svg>
|
|
2002
|
+
);
|
|
2003
|
+
};
|
|
2004
|
+
`,
|
|
2005
|
+
target: "videos/components/marks-starter/marks-starter.tsx"
|
|
2006
|
+
},
|
|
2007
|
+
{
|
|
2008
|
+
path: "components/marks-starter/marks-starter.preview.tsx",
|
|
2009
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {MarkAperture} from "./marks-starter";\n\nexport default defineComponentPreview({\n title: "Marks starter",\n category: "Foundation",\n description: "Placeholder marks that draw themselves on, as editable SVG source.",\n component: MarkAperture,\n canvas: {width: 1920, height: 1080, duration: "2s"},\n controls: {\n size: {type: "number", defaultValue: 220, min: 48, max: 400},\n drawFrames: {type: "number", defaultValue: 24, min: 0, max: 60},\n },\n examples: [\n {name: "Aperture", props: {size: 220}},\n {name: "Instant", props: {size: 220, drawFrames: 0}},\n ],\n});\n',
|
|
2010
|
+
target: "videos/components/marks-starter/marks-starter.preview.tsx"
|
|
2011
|
+
}
|
|
2012
|
+
],
|
|
2013
|
+
meta: {
|
|
2014
|
+
kind: "component",
|
|
2015
|
+
family: "Foundation",
|
|
2016
|
+
namespaced: "@odori/marks-starter",
|
|
2017
|
+
contract: {
|
|
2018
|
+
aspectRatios: [
|
|
2019
|
+
"16:9",
|
|
2020
|
+
"9:16",
|
|
2021
|
+
"1:1"
|
|
2022
|
+
],
|
|
2023
|
+
recommendedDurationInFrames: 60,
|
|
2024
|
+
minimumDurationInFrames: 24,
|
|
2025
|
+
entranceFrames: 24,
|
|
2026
|
+
exitFrames: 0,
|
|
2027
|
+
contentLimits: {},
|
|
2028
|
+
reducedMotion: "the mark renders complete, with no draw on",
|
|
2029
|
+
requires: {
|
|
2030
|
+
fonts: [],
|
|
2031
|
+
audio: []
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
},
|
|
2036
|
+
{
|
|
2037
|
+
name: "mask-reveal",
|
|
2038
|
+
description: "Clipped media and typography, revealed at full strength.",
|
|
2039
|
+
registryDependencies: [],
|
|
2040
|
+
files: [
|
|
2041
|
+
{
|
|
2042
|
+
path: "components/mask-reveal/mask-reveal.tsx",
|
|
2043
|
+
content: 'import type {ReactNode} from "react";\nimport {Easing, Fill, interpolate, useFrame} from "odori";\n\nexport type MaskRevealProps = {\n children?: ReactNode;\n /** Which edge the mask opens from. */\n from?: "left" | "right" | "top" | "bottom" | "center";\n /** Frame the reveal begins. */\n at?: number;\n durationInFrames?: number;\n /** Soft edge on the mask, in percent of the frame. Zero is a hard wipe. */\n feather?: number;\n};\n\n/**\n * Content clipped in rather than faded in.\n *\n * A fade changes what the content *is* \u2014 half-opacity type is a different\n * colour, and a half-opacity screenshot is a lie about the product. A mask\n * leaves every visible pixel at full strength and only changes how much of it\n * you can see, which is why typography and UI should arrive this way.\n *\n * Built on a gradient mask rather than a clip path so the edge can be soft,\n * and both `mask` and `WebkitMask` are set because the export browser and the\n * preview browser are the same engine but the property is not yet unprefixed\n * everywhere it renders.\n */\nexport const MaskReveal = ({children, from = "left", at = 0, durationInFrames = 24, feather = 6}: MaskRevealProps) => {\n const frame = useFrame();\n const progress = interpolate(frame, [at, at + durationInFrames], [0, 1], {easing: Easing.standard});\n\n const angle = {left: "to right", right: "to left", top: "to bottom", bottom: "to top", center: "to right"}[from];\n // The visible edge travels past 100 so the feather clears the frame too.\n const edge = progress * (100 + feather * 2);\n const mask =\n from === "center"\n ? `radial-gradient(circle at 50% 50%, #000 ${edge}%, transparent ${edge + feather}%)`\n : `linear-gradient(${angle}, #000 ${Math.max(0, edge - feather)}%, transparent ${edge}%)`;\n\n return (\n <Fill\n style={{\n mask,\n WebkitMask: mask,\n }}\n >\n {children}\n </Fill>\n );\n};\n',
|
|
2044
|
+
target: "videos/components/mask-reveal/mask-reveal.tsx"
|
|
2045
|
+
},
|
|
2046
|
+
{
|
|
2047
|
+
path: "components/mask-reveal/mask-reveal.preview.tsx",
|
|
2048
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {MaskReveal} from "./mask-reveal";\n\nconst Content = () => (\n <div\n style={{\n alignItems: "center",\n color: "#f5f5f5",\n display: "flex",\n flexDirection: "column",\n fontFamily: "ui-sans-serif, system-ui",\n gap: 24,\n inset: 0,\n justifyContent: "center",\n position: "absolute",\n textAlign: "center",\n }}\n >\n <strong style={{fontSize: 104, letterSpacing: "-0.045em"}}>Definitions</strong>\n <span style={{color: "#8f8f8f", fontSize: 38}}>not timelines</span>\n </div>\n);\n\nexport default defineComponentPreview({\n title: "Mask reveal",\n category: "Motion",\n description: "Clipped media and typography, revealed at full strength.",\n component: MaskReveal,\n canvas: {width: 1920, height: 1080, duration: "3s"},\n controls: {\n from: {type: "select", defaultValue: "left", options: ["left", "right", "top", "bottom", "center"]},\n at: {type: "number", defaultValue: 0, min: 0, max: 60},\n durationInFrames: {type: "number", defaultValue: 24, min: 8, max: 90},\n feather: {type: "number", defaultValue: 6, min: 0, max: 40},\n },\n examples: [\n {name: "Default", props: {children: <Content />}},\n {name: "From below", props: {from: "bottom", children: <Content />}},\n {name: "Hard edge", props: {feather: 0, children: <Content />}},\n ],\n});\n',
|
|
2049
|
+
target: "videos/components/mask-reveal/mask-reveal.preview.tsx"
|
|
2050
|
+
}
|
|
2051
|
+
],
|
|
2052
|
+
meta: {
|
|
2053
|
+
kind: "component",
|
|
2054
|
+
family: "Motion",
|
|
2055
|
+
namespaced: "@odori/mask-reveal",
|
|
2056
|
+
contract: {
|
|
2057
|
+
aspectRatios: [
|
|
2058
|
+
"16:9",
|
|
2059
|
+
"9:16",
|
|
2060
|
+
"1:1"
|
|
2061
|
+
],
|
|
2062
|
+
recommendedDurationInFrames: 60,
|
|
2063
|
+
minimumDurationInFrames: 24,
|
|
2064
|
+
entranceFrames: 24,
|
|
2065
|
+
exitFrames: 0,
|
|
2066
|
+
contentLimits: {},
|
|
2067
|
+
reducedMotion: "the content is fully unmasked from the first frame",
|
|
2068
|
+
requires: {
|
|
2069
|
+
fonts: [],
|
|
2070
|
+
audio: []
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
},
|
|
2075
|
+
{
|
|
2076
|
+
name: "metric-callout",
|
|
2077
|
+
description: "A single counted number with a supporting label.",
|
|
2078
|
+
registryDependencies: [],
|
|
2079
|
+
files: [
|
|
2080
|
+
{
|
|
2081
|
+
path: "components/metric-callout/metric-callout.tsx",
|
|
2082
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type MetricCalloutProps = {\n value: number;\n label: string;\n suffix?: string;\n prefix?: string;\n decimals?: number;\n detail?: string;\n};\n\n/** A counted metric. The count is a pure function of the frame. */\nexport const MetricCallout = ({value, label, suffix = "", prefix = "", decimals = 0, detail}: MetricCalloutProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const counted = interpolate(frame, [0, 44], [0, value], {easing: Easing.out(Easing.cubic)});\n const rise = interpolate(frame, [0, 22], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{alignItems: "center", gap: 20 * scale, justifyContent: "center", padding: 120 * scale}}>\n <div\n style={{\n backgroundImage: `linear-gradient(180deg, ${brand.colors.foreground} 30%, ${brand.colors.muted} 100%)`,\n backgroundClip: "text",\n color: "transparent",\n fontFamily: brand.typography.sans,\n fontSize: 216 * scale,\n fontWeight: 560,\n letterSpacing: "-0.06em",\n lineHeight: 1,\n opacity: rise,\n transform: `translateY(${(1 - rise) * 40 * scale}px)`,\n WebkitBackgroundClip: "text",\n }}\n >\n {prefix}\n {counted.toFixed(decimals)}\n {suffix}\n </div>\n <div\n style={{\n color: brand.colors.foreground,\n fontSize: 42 * scale,\n letterSpacing: "-0.02em",\n opacity: interpolate(frame, [14, 30], [0, 1], {easing: Easing.standard}),\n textAlign: "center",\n }}\n >\n {label}\n </div>\n {detail ? (\n <div\n style={{\n color: brand.colors.muted,\n fontSize: 30 * scale,\n opacity: interpolate(frame, [22, 38], [0, 1], {easing: Easing.standard}),\n textAlign: "center",\n }}\n >\n {detail}\n </div>\n ) : null}\n </Fill>\n );\n};\n',
|
|
2083
|
+
target: "videos/components/metric-callout/metric-callout.tsx"
|
|
2084
|
+
},
|
|
2085
|
+
{
|
|
2086
|
+
path: "components/metric-callout/metric-callout.preview.tsx",
|
|
2087
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {MetricCallout} from "./metric-callout";\n\nexport default defineComponentPreview({\n title: "Metric callout",\n category: "Typography",\n description: "A single counted number with a supporting label.",\n component: MetricCallout,\n canvas: {width: 1920, height: 1080, duration: "3s"},\n controls: {\n value: {type: "number", defaultValue: 100, min: 0, max: 100000},\n label: {type: "text", defaultValue: "frames rendered deterministically"},\n suffix: {type: "text", defaultValue: "%"},\n },\n examples: [\n {name: "Percentage", props: {value: 100, suffix: "%", label: "deterministic frames"}},\n {name: "Duration", props: {value: 0.4, decimals: 1, suffix: "s", label: "preview startup"}},\n ],\n});\n',
|
|
2088
|
+
target: "videos/components/metric-callout/metric-callout.preview.tsx"
|
|
2089
|
+
}
|
|
2090
|
+
],
|
|
2091
|
+
meta: {
|
|
2092
|
+
kind: "component",
|
|
2093
|
+
family: "Typography",
|
|
2094
|
+
namespaced: "@odori/metric-callout",
|
|
2095
|
+
contract: {
|
|
2096
|
+
aspectRatios: [
|
|
2097
|
+
"16:9",
|
|
2098
|
+
"9:16",
|
|
2099
|
+
"1:1"
|
|
2100
|
+
],
|
|
2101
|
+
recommendedDurationInFrames: 90,
|
|
2102
|
+
minimumDurationInFrames: 60,
|
|
2103
|
+
entranceFrames: 40,
|
|
2104
|
+
exitFrames: 8,
|
|
2105
|
+
contentLimits: {
|
|
2106
|
+
label: 48
|
|
2107
|
+
},
|
|
2108
|
+
reducedMotion: "shows the final value immediately",
|
|
2109
|
+
requires: {
|
|
2110
|
+
fonts: [
|
|
2111
|
+
"mono",
|
|
2112
|
+
"sans"
|
|
2113
|
+
],
|
|
2114
|
+
audio: []
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
}
|
|
2118
|
+
},
|
|
2119
|
+
{
|
|
2120
|
+
name: "modal-flow",
|
|
2121
|
+
description: "Trigger, dialog, confirmation, and the state it resolves to.",
|
|
2122
|
+
registryDependencies: [],
|
|
2123
|
+
files: [
|
|
2124
|
+
{
|
|
2125
|
+
path: "components/modal-flow/modal-flow.tsx",
|
|
2126
|
+
content: 'import type {ReactNode} from "react";\nimport {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type ModalFlowProps = {\n /** What sits behind the dialog. */\n children?: ReactNode;\n title?: string;\n body?: string;\n /** The primary button\'s label. */\n confirm?: string;\n cancel?: string;\n /** What the surface says once the dialog has resolved. */\n resolved?: string;\n /** Frame the dialog opens on. */\n openAt?: number;\n /** Frame the confirm button is pressed. */\n confirmAt?: number;\n};\n\n/**\n * The four states a dialog actually has: closed, open, confirming, resolved.\n *\n * They are driven by two frame numbers rather than by props a caller flips,\n * because the point of a video is the transition between states \u2014 a component\n * that only renders one of them makes the author animate it from outside, and\n * then two videos of the same dialog disagree about how it opens.\n */\nexport const ModalFlow = ({\n children,\n title = "Deploy to production?",\n body = "This will promote the current preview build. It can be rolled back.",\n confirm = "Deploy",\n cancel = "Cancel",\n resolved = "Deployed",\n openAt = 18,\n confirmAt = 66,\n}: ModalFlowProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n const open = interpolate(frame, [openAt, openAt + 12], [0, 1], {easing: Easing.standard});\n const close = interpolate(frame, [confirmAt, confirmAt + 10], [1, 0], {easing: Easing.standard});\n const visible = Math.min(open, close);\n const pressed = interpolate(frame, [confirmAt - 3, confirmAt, confirmAt + 6], [0, 1, 0]);\n const settled = interpolate(frame, [confirmAt + 8, confirmAt + 20], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill>\n {children}\n <div\n aria-hidden\n style={{background: "rgba(0,0,0,0.62)", inset: 0, opacity: visible, position: "absolute"}}\n />\n <div\n style={{\n alignItems: "center",\n inset: 0,\n justifyContent: "center",\n display: "flex",\n opacity: visible,\n position: "absolute",\n }}\n >\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 24 * scale,\n boxShadow: `0 ${30 * scale}px ${70 * scale}px rgba(0,0,0,0.55)`,\n display: "flex",\n flexDirection: "column",\n gap: 20 * scale,\n padding: 48 * scale,\n transform: `translateY(${(1 - visible) * 24 * scale}px) scale(${0.97 + visible * 0.03})`,\n width: 900 * scale,\n }}\n >\n <strong\n style={{\n color: brand.colors.foreground,\n fontFamily: brand.typography.sans,\n fontSize: 44 * scale,\n fontWeight: 600,\n letterSpacing: "-0.025em",\n }}\n >\n {title}\n </strong>\n <p\n style={{\n color: brand.colors.muted,\n fontFamily: brand.typography.sans,\n fontSize: 27 * scale,\n lineHeight: 1.5,\n margin: 0,\n }}\n >\n {body}\n </p>\n <div style={{display: "flex", gap: 14 * scale, justifyContent: "flex-end", marginTop: 12 * scale}}>\n <span\n style={{\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 12 * scale,\n color: brand.colors.muted,\n fontFamily: brand.typography.sans,\n fontSize: 26 * scale,\n padding: `${16 * scale}px ${28 * scale}px`,\n }}\n >\n {cancel}\n </span>\n <span\n style={{\n background: brand.colors.accent,\n borderRadius: 12 * scale,\n color: brand.colors.background,\n fontFamily: brand.typography.sans,\n fontSize: 26 * scale,\n fontWeight: 560,\n padding: `${16 * scale}px ${28 * scale}px`,\n // The press is the only motion on the button, and it is what\n // makes the dialog close read as caused rather than scheduled.\n transform: `scale(${1 - pressed * 0.04})`,\n }}\n >\n {confirm}\n </span>\n </div>\n </div>\n </div>\n\n {resolved ? (\n <div\n style={{\n alignItems: "center",\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 14 * scale,\n bottom: 60 * scale,\n color: brand.colors.foreground,\n display: "flex",\n fontFamily: brand.typography.sans,\n fontSize: 26 * scale,\n gap: 12 * scale,\n left: "50%",\n opacity: settled,\n padding: `${18 * scale}px ${28 * scale}px`,\n position: "absolute",\n transform: `translate(-50%, ${(1 - settled) * 16 * scale}px)`,\n }}\n >\n <span style={{background: brand.colors.accent, borderRadius: 999, height: 10 * scale, width: 10 * scale}} />\n {resolved}\n </div>\n ) : null}\n </Fill>\n );\n};\n',
|
|
2127
|
+
target: "videos/components/modal-flow/modal-flow.tsx"
|
|
2128
|
+
},
|
|
2129
|
+
{
|
|
2130
|
+
path: "components/modal-flow/modal-flow.preview.tsx",
|
|
2131
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {ModalFlow} from "./modal-flow";\n\nconst Behind = () => (\n <div style={{background: "#0b0b0b", inset: 0, position: "absolute"}}>\n <div\n style={{\n color: "#e5e5e5",\n fontFamily: "ui-sans-serif, system-ui",\n fontSize: 44,\n left: 90,\n position: "absolute",\n top: 80,\n }}\n >\n Deployments\n </div>\n </div>\n);\n\nexport default defineComponentPreview({\n title: "Modal flow",\n category: "Product UI",\n description: "Trigger, dialog, confirmation, and the state it resolves to.",\n component: ModalFlow,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n title: {type: "text", defaultValue: "Deploy to production?"},\n confirm: {type: "text", defaultValue: "Deploy"},\n resolved: {type: "text", defaultValue: "Deployed"},\n openAt: {type: "number", defaultValue: 18, min: 0, max: 60},\n confirmAt: {type: "number", defaultValue: 66, min: 20, max: 110},\n },\n examples: [\n {name: "Default", props: {children: <Behind />}},\n {name: "Destructive", props: {children: <Behind />, title: "Delete project?", confirm: "Delete", resolved: "Deleted"}},\n ],\n});\n',
|
|
2132
|
+
target: "videos/components/modal-flow/modal-flow.preview.tsx"
|
|
2133
|
+
}
|
|
2134
|
+
],
|
|
2135
|
+
meta: {
|
|
2136
|
+
kind: "component",
|
|
2137
|
+
family: "Product UI",
|
|
2138
|
+
namespaced: "@odori/modal-flow",
|
|
2139
|
+
contract: {
|
|
2140
|
+
aspectRatios: [
|
|
2141
|
+
"16:9",
|
|
2142
|
+
"9:16",
|
|
2143
|
+
"1:1"
|
|
2144
|
+
],
|
|
2145
|
+
recommendedDurationInFrames: 120,
|
|
2146
|
+
minimumDurationInFrames: 60,
|
|
2147
|
+
entranceFrames: 12,
|
|
2148
|
+
exitFrames: 10,
|
|
2149
|
+
contentLimits: {
|
|
2150
|
+
title: 64,
|
|
2151
|
+
body: 160,
|
|
2152
|
+
confirm: 24,
|
|
2153
|
+
cancel: 24,
|
|
2154
|
+
resolved: 40
|
|
2155
|
+
},
|
|
2156
|
+
reducedMotion: "the dialog cuts between states without scaling",
|
|
2157
|
+
requires: {
|
|
2158
|
+
fonts: [
|
|
2159
|
+
"sans"
|
|
2160
|
+
],
|
|
2161
|
+
audio: []
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
},
|
|
2166
|
+
{
|
|
2167
|
+
name: "notify",
|
|
2168
|
+
description: "A soft bell for a toast or a message.",
|
|
2169
|
+
registryDependencies: [],
|
|
2170
|
+
files: [
|
|
2171
|
+
{
|
|
2172
|
+
path: "components/notify/notify.tsx",
|
|
2173
|
+
content: 'import {defineCue, gain, mix, normalize, note, shape, sine, type CueDefinition} from "odori";\n\nexport type NotifyOptions = {\n /** The note the bell rings on. */\n pitch?: string;\n /** How much of the metallic partial is kept, 0 to 1. */\n shimmer?: number;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * A soft bell: a fundamental with two inharmonic partials over it, decaying\n * together. For a toast arriving or a message landing.\n *\n * The partials are what make it a bell rather than a beep \u2014 they are tuned to\n * ratios a struck object actually produces, not to the harmonic series.\n */\nexport const notify = ({pitch = "a5", shimmer = 0.5, peak = 0.55}: NotifyOptions = {}): CueDefinition => {\n const samples = Math.round(48000 * 0.9);\n const base = note(pitch);\n\n return defineCue({\n name: "ui.notify",\n durationInFrames: 27,\n params: {pitch, shimmer, peak},\n render: () =>\n normalize(\n mix(\n shape(sine(samples, base), {attack: 0.003, decay: 0.7, sustain: 0.02, release: 0.18}),\n gain(shape(sine(samples, base * 2.76), {attack: 0.002, decay: 0.35, sustain: 0}), 0.34 * shimmer),\n gain(shape(sine(samples, base * 5.4), {attack: 0.002, decay: 0.16, sustain: 0}), 0.18 * shimmer),\n ),\n peak,\n ),\n });\n};\n',
|
|
2174
|
+
target: "videos/components/notify/notify.tsx"
|
|
2175
|
+
},
|
|
2176
|
+
{
|
|
2177
|
+
path: "components/notify/notify.preview.tsx",
|
|
2178
|
+
content: 'import {CueWave, defineComponentPreview} from "odori/preview";\nimport {notify, type NotifyOptions} from "./notify";\n\nconst Wave = ({pitch, shimmer, peak}: NotifyOptions) => <CueWave cue={notify({pitch, shimmer, peak})} />;\n\nexport default defineComponentPreview({\n title: "Notify",\n category: "Sound",\n description: "A soft bell for a toast or a message.",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: "2s"},\n controls: {\n pitch: {type: "select", defaultValue: "a5", options: ["f5", "a5", "c6", "e6"]},\n shimmer: {type: "number", defaultValue: 0.5, min: 0, max: 1, step: 0.05},\n peak: {type: "number", defaultValue: 0.55, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: "Default", props: {}}, {name: "Pure", props: {shimmer: 0}}],\n});\n',
|
|
2179
|
+
target: "videos/components/notify/notify.preview.tsx"
|
|
2180
|
+
}
|
|
2181
|
+
],
|
|
2182
|
+
meta: {
|
|
2183
|
+
kind: "cue",
|
|
2184
|
+
family: "Sound",
|
|
2185
|
+
namespaced: "@odori/notify",
|
|
2186
|
+
contract: {
|
|
2187
|
+
aspectRatios: [
|
|
2188
|
+
"16:9",
|
|
2189
|
+
"9:16",
|
|
2190
|
+
"1:1"
|
|
2191
|
+
],
|
|
2192
|
+
recommendedDurationInFrames: 27,
|
|
2193
|
+
minimumDurationInFrames: 27,
|
|
2194
|
+
entranceFrames: 0,
|
|
2195
|
+
exitFrames: 0,
|
|
2196
|
+
contentLimits: {},
|
|
2197
|
+
reducedMotion: "the waveform renders without a playhead",
|
|
2198
|
+
requires: {
|
|
2199
|
+
fonts: [
|
|
2200
|
+
"mono"
|
|
2201
|
+
],
|
|
2202
|
+
audio: []
|
|
2203
|
+
},
|
|
2204
|
+
loops: false
|
|
2205
|
+
},
|
|
2206
|
+
cue: {
|
|
2207
|
+
name: "ui.notify",
|
|
2208
|
+
export: "notify"
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
},
|
|
2212
|
+
{
|
|
2213
|
+
name: "pop",
|
|
2214
|
+
description: "A four frame bubble for something appearing.",
|
|
2215
|
+
registryDependencies: [],
|
|
2216
|
+
files: [
|
|
2217
|
+
{
|
|
2218
|
+
path: "components/pop/pop.tsx",
|
|
2219
|
+
content: 'import {defineCue, gain, mix, normalize, note, shape, sine, sweep, type CueDefinition} from "odori";\n\nexport type PopOptions = {\n /** The note the pop lands on. */\n pitch?: string;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * A small bubble of air: a tone that snaps up in pitch and dies immediately.\n * Four frames, for a chip appearing, a card landing, an item entering a list.\n *\n * The upward sweep is the whole trick. A flat tone this short is a beep; a\n * tone that rises through it is a pop.\n */\nexport const pop = ({pitch = "e5", peak = 0.7}: PopOptions = {}): CueDefinition => {\n const samples = Math.round(48000 * 0.13);\n const target = note(pitch);\n\n return defineCue({\n name: "ui.pop",\n durationInFrames: 4,\n params: {pitch, peak},\n render: () =>\n normalize(\n mix(\n gain(shape(sweep(samples, target * 0.55, target), {attack: 0.002, decay: 0.09, sustain: 0}), 0.9),\n gain(shape(sine(samples, target * 2), {attack: 0.001, decay: 0.04, sustain: 0}), 0.25),\n ),\n peak,\n ),\n });\n};\n',
|
|
2220
|
+
target: "videos/components/pop/pop.tsx"
|
|
2221
|
+
},
|
|
2222
|
+
{
|
|
2223
|
+
path: "components/pop/pop.preview.tsx",
|
|
2224
|
+
content: 'import {CueWave, defineComponentPreview} from "odori/preview";\nimport {pop, type PopOptions} from "./pop";\n\nconst Wave = ({pitch, peak}: PopOptions) => <CueWave cue={pop({pitch, peak})} />;\n\nexport default defineComponentPreview({\n title: "Pop",\n category: "Sound",\n description: "A four frame bubble for something appearing.",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: "1s"},\n controls: {\n pitch: {type: "select", defaultValue: "e5", options: ["c5", "e5", "g5", "c6"]},\n peak: {type: "number", defaultValue: 0.7, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: "Default", props: {}}, {name: "Higher", props: {pitch: "c6"}}],\n});\n',
|
|
2225
|
+
target: "videos/components/pop/pop.preview.tsx"
|
|
2226
|
+
}
|
|
2227
|
+
],
|
|
2228
|
+
meta: {
|
|
2229
|
+
kind: "cue",
|
|
2230
|
+
family: "Sound",
|
|
2231
|
+
namespaced: "@odori/pop",
|
|
2232
|
+
contract: {
|
|
2233
|
+
aspectRatios: [
|
|
2234
|
+
"16:9",
|
|
2235
|
+
"9:16",
|
|
2236
|
+
"1:1"
|
|
2237
|
+
],
|
|
2238
|
+
recommendedDurationInFrames: 4,
|
|
2239
|
+
minimumDurationInFrames: 4,
|
|
2240
|
+
entranceFrames: 0,
|
|
2241
|
+
exitFrames: 0,
|
|
2242
|
+
contentLimits: {},
|
|
2243
|
+
reducedMotion: "the waveform renders without a playhead",
|
|
2244
|
+
requires: {
|
|
2245
|
+
fonts: [
|
|
2246
|
+
"mono"
|
|
2247
|
+
],
|
|
2248
|
+
audio: []
|
|
2249
|
+
},
|
|
2250
|
+
loops: false
|
|
2251
|
+
},
|
|
2252
|
+
cue: {
|
|
2253
|
+
name: "ui.pop",
|
|
2254
|
+
export: "pop"
|
|
2255
|
+
}
|
|
2256
|
+
}
|
|
2257
|
+
},
|
|
2258
|
+
{
|
|
2259
|
+
name: "progress-ring",
|
|
2260
|
+
description: "Circular completion display with restrained motion.",
|
|
2261
|
+
registryDependencies: [],
|
|
2262
|
+
files: [
|
|
2263
|
+
{
|
|
2264
|
+
path: "components/progress-ring/progress-ring.tsx",
|
|
2265
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type ProgressRingProps = {\n /** Target completion, 0 to 100. */\n value: number;\n label?: string;\n /** Ring thickness in design pixels. */\n thickness?: number;\n};\n\n/**\n * Circular completion with restrained motion: one sweep, one counted number,\n * no pulsing. The printed value tracks the sweep, so the ring and the text can\n * never disagree.\n */\nexport const ProgressRing = ({value, label, thickness = 12}: ProgressRingProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const progress = interpolate(frame, [10, 64], [0, 1], {easing: Easing.standard});\n const shown = Math.round(value * progress);\n const radius = 42;\n const circumference = 2 * Math.PI * radius;\n\n return (\n <Fill style={{alignItems: "center", gap: 30 * scale, justifyContent: "center"}}>\n <div style={{height: 460 * scale, position: "relative", width: 460 * scale}}>\n <svg style={{height: "100%", transform: "rotate(-90deg)", width: "100%"}} viewBox="0 0 100 100">\n <circle cx={50} cy={50} fill="none" r={radius} stroke={brand.colors.border} strokeWidth={thickness / 2} />\n <circle\n cx={50}\n cy={50}\n fill="none"\n r={radius}\n stroke={brand.colors.accent}\n strokeDasharray={`${(value / 100) * progress * circumference} ${circumference}`}\n strokeLinecap="round"\n strokeWidth={thickness / 2}\n />\n </svg>\n <div\n style={{\n alignItems: "center",\n display: "flex",\n fontSize: 104 * scale,\n fontVariantNumeric: "tabular-nums",\n fontWeight: 600,\n inset: 0,\n justifyContent: "center",\n letterSpacing: "-0.05em",\n position: "absolute",\n }}\n >\n {shown}%\n </div>\n </div>\n {label ? <div style={{color: brand.colors.muted, fontSize: 34 * scale}}>{label}</div> : null}\n </Fill>\n );\n};\n',
|
|
2266
|
+
target: "videos/components/progress-ring/progress-ring.tsx"
|
|
2267
|
+
},
|
|
2268
|
+
{
|
|
2269
|
+
path: "components/progress-ring/progress-ring.preview.tsx",
|
|
2270
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {ProgressRing} from "./progress-ring";\n\nexport default defineComponentPreview({\n title: "Progress ring",\n category: "Data",\n description: "Circular completion display with restrained motion.",\n component: ProgressRing,\n canvas: {width: 1920, height: 1080, duration: "3s"},\n controls: {\n value: {type: "number", defaultValue: 84, min: 0, max: 100},\n label: {type: "text", defaultValue: "of frames validated"},\n thickness: {type: "number", defaultValue: 12, min: 4, max: 24},\n },\n examples: [\n {name: "Coverage", props: {value: 84, label: "of frames validated"}},\n {name: "Complete", props: {value: 100, label: "render complete"}},\n ],\n});\n',
|
|
2271
|
+
target: "videos/components/progress-ring/progress-ring.preview.tsx"
|
|
2272
|
+
}
|
|
2273
|
+
],
|
|
2274
|
+
meta: {
|
|
2275
|
+
kind: "component",
|
|
2276
|
+
family: "Data",
|
|
2277
|
+
namespaced: "@odori/progress-ring",
|
|
2278
|
+
contract: {
|
|
2279
|
+
aspectRatios: [
|
|
2280
|
+
"16:9",
|
|
2281
|
+
"9:16",
|
|
2282
|
+
"1:1"
|
|
2283
|
+
],
|
|
2284
|
+
recommendedDurationInFrames: 90,
|
|
2285
|
+
minimumDurationInFrames: 66,
|
|
2286
|
+
entranceFrames: 64,
|
|
2287
|
+
exitFrames: 0,
|
|
2288
|
+
contentLimits: {
|
|
2289
|
+
label: 40
|
|
2290
|
+
},
|
|
2291
|
+
reducedMotion: "the ring is drawn at its final value",
|
|
2292
|
+
requires: {
|
|
2293
|
+
fonts: [
|
|
2294
|
+
"sans"
|
|
2295
|
+
],
|
|
2296
|
+
audio: []
|
|
2297
|
+
}
|
|
2298
|
+
}
|
|
2299
|
+
}
|
|
2300
|
+
},
|
|
2301
|
+
{
|
|
2302
|
+
name: "push-through",
|
|
2303
|
+
description: "A scene arriving by pushing the last one out of frame.",
|
|
2304
|
+
registryDependencies: [],
|
|
2305
|
+
files: [
|
|
2306
|
+
{
|
|
2307
|
+
path: "components/push-through/push-through.tsx",
|
|
2308
|
+
content: 'import type {ReactNode} from "react";\nimport {Easing, Fill, interpolate, useSceneTransition} from "odori";\n\nexport type PushThroughProps = {\n children?: ReactNode;\n /** Which way the incoming scene travels. */\n direction?: "up" | "down" | "left" | "right";\n /** How far the outgoing scene recedes, as a scale. */\n recede?: number;\n};\n\n/**\n * One scene pushing the last one out of the frame.\n *\n * This is the transition the timeline could not express before: it needs both\n * scenes on screen at once, so the scene it lives in declares an `overlap` and\n * this reads how far through the join it is. The same component handles both\n * halves \u2014 arriving, it slides in and pushes; leaving, it recedes and is\n * pushed \u2014 because a transition written as two components has two places to\n * get the timing wrong.\n *\n * ```tsx\n * <Scene id="proof" duration="4s" overlap="0.5s">\n * <PushThrough>{content}</PushThrough>\n * </Scene>\n * ```\n */\nexport const PushThrough = ({children, direction = "up", recede = 0.94}: PushThroughProps) => {\n const {entering, leaving} = useSceneTransition();\n\n const arriving = interpolate(entering, [0, 1], [0, 1], {easing: Easing.standard});\n const departing = interpolate(leaving, [0, 1], [0, 1], {easing: Easing.standard});\n\n const axis = direction === "up" || direction === "down" ? "Y" : "X";\n const sign = direction === "up" || direction === "left" ? 1 : -1;\n\n // Arriving: travel in from off-frame. Leaving: give way in the same\n // direction, and shrink slightly so the incoming scene reads as in front.\n const offset = arriving < 1 ? (1 - arriving) * 100 * sign : departing * 22 * sign;\n const scale = arriving < 1 ? 1 : 1 - departing * (1 - recede);\n const dim = arriving < 1 ? 0 : departing * 0.35;\n\n return (\n <Fill style={{transform: `translate${axis}(${offset}%) scale(${scale})`, transformOrigin: "center"}}>\n {children}\n {dim > 0 ? (\n <div aria-hidden style={{background: "#000", inset: 0, opacity: dim, position: "absolute"}} />\n ) : null}\n </Fill>\n );\n};\n',
|
|
2309
|
+
target: "videos/components/push-through/push-through.tsx"
|
|
2310
|
+
},
|
|
2311
|
+
{
|
|
2312
|
+
path: "components/push-through/push-through.preview.tsx",
|
|
2313
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {PushThrough} from "./push-through";\n\nconst Panel = ({label}: {label: string}) => (\n <div\n style={{\n alignItems: "center",\n background: "#0b0b0b",\n color: "#f5f5f5",\n display: "flex",\n fontFamily: "ui-sans-serif, system-ui",\n fontSize: 88,\n inset: 0,\n justifyContent: "center",\n letterSpacing: "-0.04em",\n position: "absolute",\n }}\n >\n {label}\n </div>\n);\n\nexport default defineComponentPreview({\n title: "Push through",\n category: "Motion",\n description: "A scene arriving by pushing the last one out of frame.",\n component: PushThrough,\n canvas: {width: 1920, height: 1080, duration: "3s"},\n controls: {\n direction: {type: "select", defaultValue: "up", options: ["up", "down", "left", "right"]},\n recede: {type: "number", defaultValue: 0.94, min: 0.8, max: 1, step: 0.01},\n },\n examples: [\n {name: "Default", props: {children: <Panel label="Arriving" />}},\n {name: "Sideways", props: {direction: "left", children: <Panel label="Arriving" />}},\n ],\n});\n',
|
|
2314
|
+
target: "videos/components/push-through/push-through.preview.tsx"
|
|
2315
|
+
}
|
|
2316
|
+
],
|
|
2317
|
+
meta: {
|
|
2318
|
+
kind: "component",
|
|
2319
|
+
family: "Motion",
|
|
2320
|
+
namespaced: "@odori/push-through",
|
|
2321
|
+
contract: {
|
|
2322
|
+
aspectRatios: [
|
|
2323
|
+
"16:9",
|
|
2324
|
+
"9:16",
|
|
2325
|
+
"1:1"
|
|
2326
|
+
],
|
|
2327
|
+
recommendedDurationInFrames: 90,
|
|
2328
|
+
minimumDurationInFrames: 30,
|
|
2329
|
+
entranceFrames: 15,
|
|
2330
|
+
exitFrames: 15,
|
|
2331
|
+
contentLimits: {},
|
|
2332
|
+
reducedMotion: "the arriving scene appears in place, with no travel",
|
|
2333
|
+
requires: {
|
|
2334
|
+
fonts: [],
|
|
2335
|
+
audio: []
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
},
|
|
2340
|
+
{
|
|
2341
|
+
name: "rolling-number",
|
|
2342
|
+
description: "Metrics roll without width changes or digit jitter.",
|
|
2343
|
+
registryDependencies: [],
|
|
2344
|
+
files: [
|
|
2345
|
+
{
|
|
2346
|
+
path: "components/rolling-number/rolling-number.tsx",
|
|
2347
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type RollingNumberProps = {\n from?: number;\n to: number;\n label?: string;\n prefix?: string;\n suffix?: string;\n decimals?: number;\n};\n\n/**\n * A number that counts without the width jitter proportional digits cause.\n * Tabular figures hold every glyph in the same advance, so the label under it\n * never shifts while the value climbs.\n */\nexport const RollingNumber = ({from = 0, to, label, prefix = "", suffix = "", decimals = 0}: RollingNumberProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const progress = interpolate(frame, [8, 62], [0, 1], {easing: Easing.standard});\n const value = (from + (to - from) * progress).toFixed(decimals);\n\n return (\n <Fill\n style={{\n alignItems: "flex-start",\n gap: 16 * scale,\n justifyContent: "center",\n padding: `${120 * scale}px ${150 * scale}px`,\n }}\n >\n <div\n style={{\n fontSize: 208 * scale,\n fontVariantNumeric: "tabular-nums",\n fontWeight: 600,\n letterSpacing: "-0.06em",\n lineHeight: 1,\n opacity: interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard}),\n }}\n >\n {prefix}\n {value}\n {suffix}\n </div>\n {label ? (\n <div\n style={{\n color: brand.colors.muted,\n fontSize: 40 * scale,\n letterSpacing: "-0.015em",\n opacity: interpolate(frame, [10, 30], [0, 1], {easing: Easing.standard}),\n }}\n >\n {label}\n </div>\n ) : null}\n </Fill>\n );\n};\n',
|
|
2348
|
+
target: "videos/components/rolling-number/rolling-number.tsx"
|
|
2349
|
+
},
|
|
2350
|
+
{
|
|
2351
|
+
path: "components/rolling-number/rolling-number.preview.tsx",
|
|
2352
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {RollingNumber} from "./rolling-number";\n\nexport default defineComponentPreview({\n title: "Rolling number",\n category: "Typography",\n description: "Metrics roll without width changes or digit jitter.",\n component: RollingNumber,\n canvas: {width: 1920, height: 1080, duration: "3s"},\n controls: {\n from: {type: "number", defaultValue: 0, min: 0, max: 10000},\n to: {type: "number", defaultValue: 98.7, min: 0, max: 10000},\n decimals: {type: "number", defaultValue: 1, min: 0, max: 3},\n label: {type: "text", defaultValue: "frames delivered on time"},\n suffix: {type: "text", defaultValue: "%"},\n },\n examples: [\n {name: "Percentage", props: {to: 98.7, decimals: 1, suffix: "%", label: "frames delivered on time"}},\n {name: "Count", props: {to: 12480, decimals: 0, suffix: "", label: "frames rendered"}},\n ],\n});\n',
|
|
2353
|
+
target: "videos/components/rolling-number/rolling-number.preview.tsx"
|
|
2354
|
+
}
|
|
2355
|
+
],
|
|
2356
|
+
meta: {
|
|
2357
|
+
kind: "component",
|
|
2358
|
+
family: "Typography",
|
|
2359
|
+
namespaced: "@odori/rolling-number",
|
|
2360
|
+
contract: {
|
|
2361
|
+
aspectRatios: [
|
|
2362
|
+
"16:9",
|
|
2363
|
+
"9:16",
|
|
2364
|
+
"1:1"
|
|
2365
|
+
],
|
|
2366
|
+
recommendedDurationInFrames: 90,
|
|
2367
|
+
minimumDurationInFrames: 54,
|
|
2368
|
+
entranceFrames: 54,
|
|
2369
|
+
exitFrames: 0,
|
|
2370
|
+
contentLimits: {
|
|
2371
|
+
label: 48
|
|
2372
|
+
},
|
|
2373
|
+
reducedMotion: "the final value is shown immediately",
|
|
2374
|
+
requires: {
|
|
2375
|
+
fonts: [
|
|
2376
|
+
"sans"
|
|
2377
|
+
],
|
|
2378
|
+
audio: []
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
}
|
|
2382
|
+
},
|
|
2383
|
+
{
|
|
2384
|
+
name: "safe-area",
|
|
2385
|
+
description: "Format-aware title and action safety boundaries.",
|
|
2386
|
+
registryDependencies: [],
|
|
2387
|
+
files: [
|
|
2388
|
+
{
|
|
2389
|
+
path: "components/safe-area/safe-area.tsx",
|
|
2390
|
+
content: 'import type {ReactNode} from "react";\nimport {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale, useLayout} from "odori";\n\nexport type SafeAreaGuidesProps = {\n children?: ReactNode;\n /** Draw the title and action boundaries. */\n guides?: boolean;\n /** Extra inset beyond the layout policy, in design pixels. */\n inset?: number;\n label?: string;\n};\n\n/**\n * Keeps content inside the boundaries the layout declares, and can draw them.\n * The numbers come from the resolved layout, so a 9:16 cut gets the safety it\n * asked for rather than a value guessed at authoring time.\n */\nexport const SafeAreaGuides = ({children, guides = true, inset = 0, label = "safe area"}: SafeAreaGuidesProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const layout = useLayout();\n const safe = layout.safeArea ?? {x: 80, y: 80};\n const x = (safe.x + inset) * scale;\n const y = (safe.y + inset) * scale;\n const appear = interpolate(frame, [0, 18], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill>\n <Fill\n style={{\n alignItems: "center",\n justifyContent: "center",\n padding: `${y}px ${x}px`,\n textAlign: "center",\n }}\n >\n {children}\n </Fill>\n {guides ? (\n <Fill style={{opacity: appear, pointerEvents: "none"}}>\n <div\n style={{\n border: `${2 * scale}px solid ${brand.colors.accent}`,\n inset: 0,\n margin: `${y}px ${x}px`,\n opacity: 0.55,\n position: "absolute",\n }}\n />\n <span\n style={{\n color: brand.colors.muted,\n fontFamily: brand.typography.mono,\n fontSize: 22 * scale,\n left: x,\n position: "absolute",\n top: Math.max(0, y - 34 * scale),\n }}\n >\n {label} \xB7 {Math.round(safe.x + inset)} x {Math.round(safe.y + inset)}\n </span>\n </Fill>\n ) : null}\n </Fill>\n );\n};\n',
|
|
2391
|
+
target: "videos/components/safe-area/safe-area.tsx"
|
|
2392
|
+
},
|
|
2393
|
+
{
|
|
2394
|
+
path: "components/safe-area/safe-area.preview.tsx",
|
|
2395
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {SafeAreaGuides} from "./safe-area";\n\nexport default defineComponentPreview({\n title: "Safe area",\n category: "Foundation",\n description: "Format-aware title and action safety boundaries.",\n component: SafeAreaGuides,\n canvas: {width: 1920, height: 1080, duration: "3s"},\n controls: {\n guides: {type: "boolean", defaultValue: true},\n inset: {type: "number", defaultValue: 0, min: 0, max: 160},\n label: {type: "text", defaultValue: "safe area"},\n },\n examples: [\n {name: "Guides", props: {guides: true, children: "Everything readable sits inside"}},\n {name: "Composed", props: {guides: false, children: "Everything readable sits inside"}},\n ],\n});\n',
|
|
2396
|
+
target: "videos/components/safe-area/safe-area.preview.tsx"
|
|
2397
|
+
}
|
|
2398
|
+
],
|
|
2399
|
+
meta: {
|
|
2400
|
+
kind: "component",
|
|
2401
|
+
family: "Foundation",
|
|
2402
|
+
namespaced: "@odori/safe-area",
|
|
2403
|
+
contract: {
|
|
2404
|
+
aspectRatios: [
|
|
2405
|
+
"16:9",
|
|
2406
|
+
"9:16",
|
|
2407
|
+
"1:1"
|
|
2408
|
+
],
|
|
2409
|
+
recommendedDurationInFrames: 90,
|
|
2410
|
+
minimumDurationInFrames: 24,
|
|
2411
|
+
entranceFrames: 18,
|
|
2412
|
+
exitFrames: 0,
|
|
2413
|
+
contentLimits: {},
|
|
2414
|
+
reducedMotion: "guides appear without fading",
|
|
2415
|
+
requires: {
|
|
2416
|
+
fonts: [
|
|
2417
|
+
"sans",
|
|
2418
|
+
"mono"
|
|
2419
|
+
],
|
|
2420
|
+
audio: []
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
},
|
|
2425
|
+
{
|
|
2426
|
+
name: "screenshot-focus",
|
|
2427
|
+
description: "A camera move from the whole interface to one detail.",
|
|
2428
|
+
registryDependencies: [],
|
|
2429
|
+
files: [
|
|
2430
|
+
{
|
|
2431
|
+
path: "components/screenshot-focus/screenshot-focus.tsx",
|
|
2432
|
+
content: 'import {Easing, Fill, interpolate, placeholderImage, useBrand, useDesignScale, useFrame} from "odori";\n\nexport type ScreenshotFocusProps = {\n src?: string;\n /** The detail to travel to, in fractions of the image. */\n focus?: {x: number; y: number};\n /** How close the move ends up. */\n zoom?: number;\n /** Frame the move starts, and how long it takes. */\n at?: number;\n durationInFrames?: number;\n /** A ring drawn around the detail once the move lands. */\n marker?: boolean;\n caption?: string;\n};\n\n/**\n * A move from the whole interface to one part of it.\n *\n * The reason this is a component rather than two props on an image: the shot\n * only works if the wide frame is held long enough to be read, the move is\n * eased rather than linear, and the marker arrives *after* the move rather\n * than travelling with it. Those are three decisions that are easy to get\n * subtly wrong, and wrong here reads as a camera lurching.\n */\nexport const ScreenshotFocus = ({\n src,\n focus = {x: 0.5, y: 0.5},\n zoom = 2.2,\n at = 20,\n durationInFrames = 34,\n marker = true,\n caption,\n}: ScreenshotFocusProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const url = src ?? placeholderImage({label: "screenshot", seed: "shot"});\n\n const progress = interpolate(frame, [at, at + durationInFrames], [0, 1], {easing: Easing.standard});\n const currentZoom = 1 + (zoom - 1) * progress;\n // Translate so the focal point stays under the centre as the scale grows.\n const shiftX = (0.5 - focus.x) * 100 * (currentZoom - 1);\n const shiftY = (0.5 - focus.y) * 100 * (currentZoom - 1);\n const landed = interpolate(frame, [at + durationInFrames, at + durationInFrames + 10], [0, 1], {\n easing: Easing.standard,\n });\n\n return (\n <Fill style={{overflow: "hidden"}}>\n <div\n style={{\n height: "100%",\n transform: `scale(${currentZoom}) translate(${shiftX / currentZoom}%, ${shiftY / currentZoom}%)`,\n transformOrigin: "center",\n width: "100%",\n }}\n >\n <img src={url} alt="" style={{height: "100%", objectFit: "cover", width: "100%"}} />\n </div>\n\n {marker ? (\n <span\n style={{\n border: `${Math.max(1, 3 * scale)}px solid ${brand.colors.accent}`,\n borderRadius: 16 * scale,\n height: 180 * scale,\n left: "50%",\n opacity: landed * 0.9,\n position: "absolute",\n top: "50%",\n transform: `translate(-50%, -50%) scale(${0.9 + landed * 0.1})`,\n width: 300 * scale,\n }}\n />\n ) : null}\n\n {caption ? (\n <span\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 12 * scale,\n bottom: 70 * scale,\n color: brand.colors.foreground,\n fontFamily: brand.typography.sans,\n fontSize: 28 * scale,\n left: "50%",\n opacity: landed,\n padding: `${16 * scale}px ${26 * scale}px`,\n position: "absolute",\n transform: "translateX(-50%)",\n }}\n >\n {caption}\n </span>\n ) : null}\n </Fill>\n );\n};\n',
|
|
2433
|
+
target: "videos/components/screenshot-focus/screenshot-focus.tsx"
|
|
2434
|
+
},
|
|
2435
|
+
{
|
|
2436
|
+
path: "components/screenshot-focus/screenshot-focus.preview.tsx",
|
|
2437
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {ScreenshotFocus} from "./screenshot-focus";\n\nexport default defineComponentPreview({\n title: "Screenshot focus",\n category: "Media and canvas",\n description: "A camera move from the whole interface to one detail.",\n component: ScreenshotFocus,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n zoom: {type: "number", defaultValue: 2.2, min: 1.2, max: 5, step: 0.1},\n at: {type: "number", defaultValue: 20, min: 0, max: 60},\n durationInFrames: {type: "number", defaultValue: 34, min: 10, max: 90},\n marker: {type: "boolean", defaultValue: true},\n caption: {type: "text", defaultValue: "One deployment, 48 seconds"},\n },\n examples: [\n {name: "Default", props: {}},\n {name: "Top left", props: {focus: {x: 0.24, y: 0.3}, zoom: 3}},\n ],\n});\n',
|
|
2438
|
+
target: "videos/components/screenshot-focus/screenshot-focus.preview.tsx"
|
|
2439
|
+
}
|
|
2440
|
+
],
|
|
2441
|
+
meta: {
|
|
2442
|
+
kind: "component",
|
|
2443
|
+
family: "Media and canvas",
|
|
2444
|
+
namespaced: "@odori/screenshot-focus",
|
|
2445
|
+
contract: {
|
|
2446
|
+
aspectRatios: [
|
|
2447
|
+
"16:9",
|
|
2448
|
+
"9:16",
|
|
2449
|
+
"1:1"
|
|
2450
|
+
],
|
|
2451
|
+
recommendedDurationInFrames: 120,
|
|
2452
|
+
minimumDurationInFrames: 45,
|
|
2453
|
+
entranceFrames: 0,
|
|
2454
|
+
exitFrames: 0,
|
|
2455
|
+
contentLimits: {
|
|
2456
|
+
caption: 64
|
|
2457
|
+
},
|
|
2458
|
+
reducedMotion: "the wide frame is held, with the marker in place",
|
|
2459
|
+
requires: {
|
|
2460
|
+
fonts: [
|
|
2461
|
+
"sans"
|
|
2462
|
+
],
|
|
2463
|
+
audio: []
|
|
2464
|
+
}
|
|
2465
|
+
}
|
|
2466
|
+
}
|
|
2467
|
+
},
|
|
2468
|
+
{
|
|
2469
|
+
name: "shared-axis",
|
|
2470
|
+
description: "Related scenes travelling on one spatial axis.",
|
|
2471
|
+
registryDependencies: [],
|
|
2472
|
+
files: [
|
|
2473
|
+
{
|
|
2474
|
+
path: "components/shared-axis/shared-axis.tsx",
|
|
2475
|
+
content: 'import type {ReactNode} from "react";\nimport {Easing, Fill, interpolate, useSceneTransition} from "odori";\n\nexport type SharedAxisProps = {\n children?: ReactNode;\n /** The axis the pair travels on. */\n axis?: "x" | "y" | "z";\n /** How far the move carries, as a fraction of the frame. */\n distance?: number;\n};\n\n/**\n * Two related scenes travelling on one axis.\n *\n * The claim a shared axis makes is that these two scenes are *the same kind of\n * thing*, one after another \u2014 a list and its detail, a before and its after.\n * That reads only if both halves move together on one line and fade across\n * each other, so the move is short, the fade is quick, and neither scene ever\n * translates in a direction the other does not.\n *\n * The `z` axis is the depth variant: forward for arriving, back for leaving,\n * which is the one to use when the second scene is *inside* the first rather\n * than beside it.\n *\n * ```tsx\n * <Scene id="detail" duration="4s" overlap="0.4s">\n * <SharedAxis axis="x">{content}</SharedAxis>\n * </Scene>\n * ```\n */\nexport const SharedAxis = ({children, axis = "x", distance = 0.12}: SharedAxisProps) => {\n const {entering, leaving} = useSceneTransition();\n\n const arriving = interpolate(entering, [0, 1], [0, 1], {easing: Easing.standard});\n const departing = interpolate(leaving, [0, 1], [0, 1], {easing: Easing.standard});\n const travelling = arriving < 1;\n\n // One line, one direction: arriving comes from behind the axis origin,\n // leaving continues past it.\n const shift = travelling ? (1 - arriving) * distance * 100 : -departing * distance * 100;\n const depth = travelling ? 0.94 + arriving * 0.06 : 1 + departing * 0.06;\n\n const transform =\n axis === "z"\n ? `scale(${depth})`\n : axis === "x"\n ? `translateX(${shift}%)`\n : `translateY(${shift}%)`;\n\n // The fade is fast at both ends so the two scenes are never both at half\n // strength, which is what makes a cross-fade look like a mistake.\n const opacity = travelling\n ? interpolate(arriving, [0, 0.45], [0, 1], {easing: Easing.standard})\n : interpolate(departing, [0.35, 1], [1, 0], {easing: Easing.standard});\n\n return <Fill style={{opacity, transform, transformOrigin: "center"}}>{children}</Fill>;\n};\n',
|
|
2476
|
+
target: "videos/components/shared-axis/shared-axis.tsx"
|
|
2477
|
+
},
|
|
2478
|
+
{
|
|
2479
|
+
path: "components/shared-axis/shared-axis.preview.tsx",
|
|
2480
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {SharedAxis} from "./shared-axis";\n\nconst Panel = () => (\n <div\n style={{\n alignItems: "center",\n background: "#0b0b0b",\n color: "#f5f5f5",\n display: "flex",\n flexDirection: "column",\n fontFamily: "ui-sans-serif, system-ui",\n gap: 18,\n inset: 0,\n justifyContent: "center",\n position: "absolute",\n }}\n >\n <strong style={{fontSize: 84, letterSpacing: "-0.04em"}}>Deployment detail</strong>\n <span style={{color: "#8f8f8f", fontSize: 32}}>the same thing, one level in</span>\n </div>\n);\n\nexport default defineComponentPreview({\n title: "Shared axis",\n category: "Motion",\n description: "Related scenes travelling on one spatial axis.",\n component: SharedAxis,\n canvas: {width: 1920, height: 1080, duration: "3s"},\n controls: {\n axis: {type: "select", defaultValue: "x", options: ["x", "y", "z"]},\n distance: {type: "number", defaultValue: 0.12, min: 0.02, max: 0.4, step: 0.02},\n },\n examples: [\n {name: "Default", props: {children: <Panel />}},\n {name: "Depth", props: {axis: "z", children: <Panel />}},\n ],\n});\n',
|
|
2481
|
+
target: "videos/components/shared-axis/shared-axis.preview.tsx"
|
|
2482
|
+
}
|
|
2483
|
+
],
|
|
2484
|
+
meta: {
|
|
2485
|
+
kind: "component",
|
|
2486
|
+
family: "Motion",
|
|
2487
|
+
namespaced: "@odori/shared-axis",
|
|
2488
|
+
contract: {
|
|
2489
|
+
aspectRatios: [
|
|
2490
|
+
"16:9",
|
|
2491
|
+
"9:16",
|
|
2492
|
+
"1:1"
|
|
2493
|
+
],
|
|
2494
|
+
recommendedDurationInFrames: 90,
|
|
2495
|
+
minimumDurationInFrames: 30,
|
|
2496
|
+
entranceFrames: 12,
|
|
2497
|
+
exitFrames: 12,
|
|
2498
|
+
contentLimits: {},
|
|
2499
|
+
reducedMotion: "the scenes cross-fade without moving",
|
|
2500
|
+
requires: {
|
|
2501
|
+
fonts: [],
|
|
2502
|
+
audio: []
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
},
|
|
2507
|
+
{
|
|
2508
|
+
name: "soft-blur-in",
|
|
2509
|
+
description: "A gentle blur and opacity reveal for premium openings.",
|
|
2510
|
+
registryDependencies: [],
|
|
2511
|
+
files: [
|
|
2512
|
+
{
|
|
2513
|
+
path: "components/soft-blur-in/soft-blur-in.tsx",
|
|
2514
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type SoftBlurInProps = {\n title: string;\n detail?: string;\n /** Blur radius at the start of the reveal, in design pixels. */\n blur?: number;\n};\n\n/**\n * A restrained opening: the headline resolves out of a blur while it settles\n * a few pixels upward. Useful when a hard word stagger would feel too busy.\n */\nexport const SoftBlurIn = ({title, detail, blur = 26}: SoftBlurInProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const progress = interpolate(frame, [0, 34], [0, 1], {easing: Easing.standard});\n const detailProgress = interpolate(frame, [18, 46], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill\n style={{\n alignItems: "center",\n gap: 32 * scale,\n justifyContent: "center",\n padding: `${120 * scale}px ${160 * scale}px`,\n textAlign: "center",\n }}\n >\n <div\n style={{\n filter: `blur(${(1 - progress) * blur * scale}px)`,\n fontSize: 124 * scale,\n fontWeight: 560,\n letterSpacing: "-0.05em",\n lineHeight: 1.04,\n maxWidth: 1500 * scale,\n opacity: progress,\n transform: `translateY(${(1 - progress) * 26 * scale}px) scale(${0.98 + progress * 0.02})`,\n }}\n >\n {title}\n </div>\n {detail ? (\n <div\n style={{\n color: brand.colors.muted,\n filter: `blur(${(1 - detailProgress) * 10 * scale}px)`,\n fontSize: 36 * scale,\n maxWidth: 1100 * scale,\n opacity: detailProgress,\n }}\n >\n {detail}\n </div>\n ) : null}\n </Fill>\n );\n};\n',
|
|
2515
|
+
target: "videos/components/soft-blur-in/soft-blur-in.tsx"
|
|
2516
|
+
},
|
|
2517
|
+
{
|
|
2518
|
+
path: "components/soft-blur-in/soft-blur-in.preview.tsx",
|
|
2519
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {SoftBlurIn} from "./soft-blur-in";\n\nexport default defineComponentPreview({\n title: "Soft blur in",\n category: "Typography",\n description: "A gentle blur and opacity reveal for premium openings.",\n component: SoftBlurIn,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n title: {type: "text", defaultValue: "Built for focus.", maxLength: 64},\n detail: {type: "text", defaultValue: "Everything you need, nothing you do not."},\n blur: {type: "number", defaultValue: 26, min: 0, max: 60},\n },\n examples: [\n {name: "Default", props: {title: "Built for focus."}},\n {name: "Subtle", props: {title: "Quietly precise.", blur: 10}},\n ],\n});\n',
|
|
2520
|
+
target: "videos/components/soft-blur-in/soft-blur-in.preview.tsx"
|
|
2521
|
+
}
|
|
2522
|
+
],
|
|
2523
|
+
meta: {
|
|
2524
|
+
kind: "component",
|
|
2525
|
+
family: "Typography",
|
|
2526
|
+
namespaced: "@odori/soft-blur-in",
|
|
2527
|
+
contract: {
|
|
2528
|
+
aspectRatios: [
|
|
2529
|
+
"16:9",
|
|
2530
|
+
"9:16",
|
|
2531
|
+
"1:1"
|
|
2532
|
+
],
|
|
2533
|
+
recommendedDurationInFrames: 120,
|
|
2534
|
+
minimumDurationInFrames: 45,
|
|
2535
|
+
entranceFrames: 34,
|
|
2536
|
+
exitFrames: 10,
|
|
2537
|
+
contentLimits: {
|
|
2538
|
+
title: 64,
|
|
2539
|
+
detail: 96
|
|
2540
|
+
},
|
|
2541
|
+
reducedMotion: "fades without blur",
|
|
2542
|
+
requires: {
|
|
2543
|
+
fonts: [
|
|
2544
|
+
"sans"
|
|
2545
|
+
],
|
|
2546
|
+
audio: []
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
},
|
|
2551
|
+
{
|
|
2552
|
+
name: "split-layout",
|
|
2553
|
+
description: "Responsive two-up composition for proof and explanation.",
|
|
2554
|
+
registryDependencies: [],
|
|
2555
|
+
files: [
|
|
2556
|
+
{
|
|
2557
|
+
path: "components/split-layout/split-layout.tsx",
|
|
2558
|
+
content: 'import type {ReactNode} from "react";\nimport {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale, useVideo} from "odori";\n\nexport type SplitLayoutProps = {\n title: string;\n detail?: string;\n /** The proof side: code, a product frame, a chart. */\n children?: ReactNode;\n /** Which side the words sit on in a wide format. */\n side?: "left" | "right";\n /** Share of the frame the words take. */\n ratio?: number;\n};\n\n/**\n * Explanation beside proof. In a wide format the two sit side by side; in a\n * tall one they stack, because a 9:16 column cannot hold two readable panels.\n */\nexport const SplitLayout = ({title, detail, children, side = "left", ratio = 0.42}: SplitLayoutProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const {width, height} = useVideo();\n const stacked = height >= width;\n const words = interpolate(frame, [0, 26], [0, 1], {easing: Easing.standard});\n const proof = interpolate(frame, [14, 44], [0, 1], {easing: Easing.standard});\n const share = `${Math.min(Math.max(ratio, 0.3), 0.6) * 100}%`;\n\n return (\n <Fill\n style={{\n alignItems: "stretch",\n display: "flex",\n flexDirection: stacked ? "column" : side === "left" ? "row" : "row-reverse",\n gap: 56 * scale,\n padding: `${100 * scale}px ${110 * scale}px`,\n }}\n >\n <div\n style={{\n alignContent: "center",\n display: "grid",\n flex: stacked ? "none" : `0 0 ${share}`,\n gap: 20 * scale,\n opacity: words,\n transform: `translateY(${(1 - words) * 24 * scale}px)`,\n }}\n >\n <div style={{fontSize: 78 * scale, fontWeight: 570, letterSpacing: "-0.045em", lineHeight: 1.06}}>{title}</div>\n {detail ? <div style={{color: brand.colors.muted, fontSize: 32 * scale, lineHeight: 1.45}}>{detail}</div> : null}\n </div>\n\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 26 * scale,\n flex: 1,\n opacity: proof,\n overflow: "hidden",\n position: "relative",\n transform: `translateY(${(1 - proof) * 28 * scale}px)`,\n }}\n >\n {children}\n </div>\n </Fill>\n );\n};\n',
|
|
2559
|
+
target: "videos/components/split-layout/split-layout.tsx"
|
|
2560
|
+
},
|
|
2561
|
+
{
|
|
2562
|
+
path: "components/split-layout/split-layout.preview.tsx",
|
|
2563
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {SplitLayout} from "./split-layout";\n\nexport default defineComponentPreview({\n title: "Split layout",\n category: "Foundation",\n description: "Responsive two-up composition for proof and explanation.",\n component: SplitLayout,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n title: {type: "text", defaultValue: "Story on one side.", maxLength: 48},\n detail: {type: "text", defaultValue: "Proof on the other, in the same frame."},\n side: {type: "select", defaultValue: "left", options: ["left", "right"]},\n ratio: {type: "number", defaultValue: 0.42, min: 0.3, max: 0.6, step: 0.02},\n },\n examples: [\n {name: "Words left", props: {}},\n {name: "Words right", props: {side: "right"}},\n ],\n});\n',
|
|
2564
|
+
target: "videos/components/split-layout/split-layout.preview.tsx"
|
|
2565
|
+
}
|
|
2566
|
+
],
|
|
2567
|
+
meta: {
|
|
2568
|
+
kind: "component",
|
|
2569
|
+
family: "Foundation",
|
|
2570
|
+
namespaced: "@odori/split-layout",
|
|
2571
|
+
contract: {
|
|
2572
|
+
aspectRatios: [
|
|
2573
|
+
"16:9",
|
|
2574
|
+
"9:16",
|
|
2575
|
+
"1:1"
|
|
2576
|
+
],
|
|
2577
|
+
recommendedDurationInFrames: 120,
|
|
2578
|
+
minimumDurationInFrames: 60,
|
|
2579
|
+
entranceFrames: 44,
|
|
2580
|
+
exitFrames: 0,
|
|
2581
|
+
contentLimits: {
|
|
2582
|
+
title: 48,
|
|
2583
|
+
detail: 96
|
|
2584
|
+
},
|
|
2585
|
+
reducedMotion: "both panels fade without travel",
|
|
2586
|
+
requires: {
|
|
2587
|
+
fonts: [
|
|
2588
|
+
"sans"
|
|
2589
|
+
],
|
|
2590
|
+
audio: []
|
|
2591
|
+
}
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
},
|
|
2595
|
+
{
|
|
2596
|
+
name: "split-wipe",
|
|
2597
|
+
description: "Two panels parting to reveal the scene behind them.",
|
|
2598
|
+
registryDependencies: [],
|
|
2599
|
+
files: [
|
|
2600
|
+
{
|
|
2601
|
+
path: "components/split-wipe/split-wipe.tsx",
|
|
2602
|
+
content: 'import type {ReactNode} from "react";\nimport {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type SplitWipeProps = {\n /** What is revealed as the panels part. */\n children?: ReactNode;\n /** Which way the two panels travel. */\n direction?: "horizontal" | "vertical";\n /** Frame the wipe begins. */\n at?: number;\n /** How long the panels take to clear the frame. */\n durationInFrames?: number;\n /** Colour of the panels. Defaults to the brand background. */\n color?: string;\n /** A hairline down the seam, so the split reads before it opens. */\n seam?: boolean;\n};\n\n/**\n * Two panels parting to reveal what is behind them.\n *\n * The panels are the transition, not a fade of the content: content that fades\n * in has no edge and reads as a slideshow, while an edge travelling across the\n * frame reads as a cut with intent. Both halves move together, so the reveal\n * grows from the centre and the eye stays where the next scene begins.\n */\nexport const SplitWipe = ({\n children,\n direction = "horizontal",\n at = 0,\n durationInFrames = 26,\n color,\n seam = true,\n}: SplitWipeProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n const progress = interpolate(frame, [at, at + durationInFrames], [0, 1], {easing: Easing.standard});\n const plate = color ?? brand.colors.background;\n const vertical = direction === "vertical";\n const shift = `${progress * 100}%`;\n\n return (\n <Fill>\n {children}\n {/* Two panels, each covering half and leaving in opposite directions.\n They stay mounted once open, which costs nothing and keeps the\n component\'s output identical on every frame it renders. */}\n {[0, 1].map((index) => (\n <div\n key={index}\n aria-hidden\n style={{\n background: plate,\n height: vertical ? "50%" : "100%",\n left: vertical ? 0 : index === 0 ? 0 : "50%",\n position: "absolute",\n top: vertical ? (index === 0 ? 0 : "50%") : 0,\n transform: vertical\n ? `translateY(${index === 0 ? "-" : ""}${shift})`\n : `translateX(${index === 0 ? "-" : ""}${shift})`,\n width: vertical ? "100%" : "50%",\n }}\n />\n ))}\n {seam && progress < 1 ? (\n <div\n aria-hidden\n style={{\n background: brand.colors.border,\n height: vertical ? Math.max(1, 2 * scale) : "100%",\n left: vertical ? 0 : "50%",\n opacity: 1 - progress,\n position: "absolute",\n top: vertical ? "50%" : 0,\n width: vertical ? "100%" : Math.max(1, 2 * scale),\n }}\n />\n ) : null}\n </Fill>\n );\n};\n',
|
|
2603
|
+
target: "videos/components/split-wipe/split-wipe.tsx"
|
|
2604
|
+
},
|
|
2605
|
+
{
|
|
2606
|
+
path: "components/split-wipe/split-wipe.preview.tsx",
|
|
2607
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {SplitWipe} from "./split-wipe";\n\nconst Behind = () => (\n <div\n style={{\n alignItems: "center",\n background: "#0b0b0b",\n color: "#f5f5f5",\n display: "flex",\n fontFamily: "ui-sans-serif, system-ui",\n fontSize: 96,\n inset: 0,\n justifyContent: "center",\n letterSpacing: "-0.04em",\n position: "absolute",\n }}\n >\n Ship the story.\n </div>\n);\n\nexport default defineComponentPreview({\n title: "Split wipe",\n category: "Motion",\n description: "Two panels parting to reveal the scene behind them.",\n component: SplitWipe,\n canvas: {width: 1920, height: 1080, duration: "3s"},\n controls: {\n direction: {type: "select", defaultValue: "horizontal", options: ["horizontal", "vertical"]},\n at: {type: "number", defaultValue: 0, min: 0, max: 60},\n durationInFrames: {type: "number", defaultValue: 26, min: 8, max: 90},\n seam: {type: "boolean", defaultValue: true},\n },\n examples: [\n {name: "Default", props: {children: <Behind />}},\n {name: "Vertical", props: {direction: "vertical", children: <Behind />}},\n {name: "Slow", props: {durationInFrames: 60, children: <Behind />}},\n ],\n});\n',
|
|
2608
|
+
target: "videos/components/split-wipe/split-wipe.preview.tsx"
|
|
2609
|
+
}
|
|
2610
|
+
],
|
|
2611
|
+
meta: {
|
|
2612
|
+
kind: "component",
|
|
2613
|
+
family: "Motion",
|
|
2614
|
+
namespaced: "@odori/split-wipe",
|
|
2615
|
+
contract: {
|
|
2616
|
+
aspectRatios: [
|
|
2617
|
+
"16:9",
|
|
2618
|
+
"9:16",
|
|
2619
|
+
"1:1"
|
|
2620
|
+
],
|
|
2621
|
+
recommendedDurationInFrames: 60,
|
|
2622
|
+
minimumDurationInFrames: 26,
|
|
2623
|
+
entranceFrames: 26,
|
|
2624
|
+
exitFrames: 0,
|
|
2625
|
+
contentLimits: {},
|
|
2626
|
+
reducedMotion: "the panels are open from the first frame",
|
|
2627
|
+
requires: {
|
|
2628
|
+
fonts: [],
|
|
2629
|
+
audio: []
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
},
|
|
2634
|
+
{
|
|
2635
|
+
name: "stage",
|
|
2636
|
+
description: "The shared backdrop: hairline grid, soft glow, and vignette.",
|
|
2637
|
+
registryDependencies: [],
|
|
2638
|
+
files: [
|
|
2639
|
+
{
|
|
2640
|
+
path: "components/stage/stage.tsx",
|
|
2641
|
+
content: 'import type {CSSProperties, ReactNode} from "react";\nimport {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale, useVideo} from "odori";\n\nexport type StageProps = {\n children?: ReactNode;\n /** A hairline grid keeps large empty frames from feeling flat. */\n grid?: boolean;\n /** A slow radial glow behind the content. */\n glow?: boolean;\n align?: "center" | "start";\n style?: CSSProperties;\n};\n\n/**\n * The shared backdrop. Every scene sits on the same surface so cuts feel like\n * one film rather than a stack of unrelated slides.\n */\n/** Relative luminance, so the backdrop adapts to a light or dark brand. */\nconst isLight = (hex: string) => {\n const value = hex.replace("#", "");\n const full = value.length === 3 ? value.split("").map((part) => part + part).join("") : value;\n const [red, green, blue] = [0, 2, 4].map((offset) => parseInt(full.slice(offset, offset + 2), 16) / 255);\n return 0.2126 * red + 0.7152 * green + 0.0722 * blue > 0.5;\n};\n\nexport const Stage = ({children, grid = true, glow = true, align = "center", style}: StageProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const {width, height} = useVideo();\n const scale = useDesignScale();\n const drift = interpolate(frame, [0, 240], [0, 1], {extrapolateRight: "extend"});\n const cell = 80 * scale;\n const light = isLight(brand.colors.background);\n const glowColor = light ? "rgba(0,0,0,0.05)" : "rgba(255,255,255,0.07)";\n const vignette = light ? "rgba(0,0,0,0.06)" : "rgba(0,0,0,0.55)";\n\n return (\n <Fill style={{background: brand.colors.background, ...style}}>\n {grid ? (\n <Fill\n style={{\n backgroundImage: `linear-gradient(to right, ${brand.colors.border} 1px, transparent 1px), linear-gradient(to bottom, ${brand.colors.border} 1px, transparent 1px)`,\n backgroundSize: `${cell}px ${cell}px`,\n maskImage: `radial-gradient(ellipse ${width * 0.55}px ${height * 0.5}px at 50% 45%, rgba(0,0,0,0.65), transparent 78%)`,\n WebkitMaskImage: `radial-gradient(ellipse ${width * 0.55}px ${height * 0.5}px at 50% 45%, rgba(0,0,0,0.65), transparent 78%)`,\n opacity: interpolate(frame, [0, 24], [0, 0.7], {easing: Easing.standard}),\n transform: `translateY(${drift * -14 * scale}px)`,\n }}\n />\n ) : null}\n {glow ? (\n <Fill\n style={{\n backgroundImage: `radial-gradient(ellipse ${width * 0.45}px ${height * 0.42}px at 50% ${\n 38 + drift * 3\n }%, ${glowColor}, transparent 70%)`,\n }}\n />\n ) : null}\n <Fill\n style={{\n alignItems: "center",\n justifyContent: align === "center" ? "center" : "flex-start",\n }}\n >\n {children}\n </Fill>\n <Fill\n style={{\n boxShadow: `inset 0 0 ${240 * scale}px ${vignette}`,\n pointerEvents: "none",\n }}\n />\n </Fill>\n );\n};\n',
|
|
2642
|
+
target: "videos/components/stage/stage.tsx"
|
|
2643
|
+
},
|
|
2644
|
+
{
|
|
2645
|
+
path: "components/stage/stage.preview.tsx",
|
|
2646
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {Stage} from "./stage";\n\nexport default defineComponentPreview({\n title: "Stage",\n category: "Brand",\n description: "The shared backdrop: hairline grid, soft glow, and vignette.",\n component: Stage,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n grid: {type: "boolean", defaultValue: true},\n glow: {type: "boolean", defaultValue: true},\n },\n examples: [\n {name: "Default", props: {}},\n {name: "Flat", props: {grid: false, glow: false}},\n ],\n});\n',
|
|
2647
|
+
target: "videos/components/stage/stage.preview.tsx"
|
|
2648
|
+
}
|
|
2649
|
+
],
|
|
2650
|
+
meta: {
|
|
2651
|
+
kind: "component",
|
|
2652
|
+
family: "Brand",
|
|
2653
|
+
namespaced: "@odori/stage",
|
|
2654
|
+
contract: {
|
|
2655
|
+
aspectRatios: [
|
|
2656
|
+
"16:9",
|
|
2657
|
+
"9:16",
|
|
2658
|
+
"1:1"
|
|
2659
|
+
],
|
|
2660
|
+
recommendedDurationInFrames: 120,
|
|
2661
|
+
minimumDurationInFrames: 24,
|
|
2662
|
+
entranceFrames: 24,
|
|
2663
|
+
exitFrames: 0,
|
|
2664
|
+
contentLimits: {},
|
|
2665
|
+
reducedMotion: "static grid without drift",
|
|
2666
|
+
requires: {
|
|
2667
|
+
fonts: [],
|
|
2668
|
+
audio: []
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
},
|
|
2673
|
+
{
|
|
2674
|
+
name: "staggered-lines",
|
|
2675
|
+
description: "Two line hierarchy with independent entrance timing.",
|
|
2676
|
+
registryDependencies: [],
|
|
2677
|
+
files: [
|
|
2678
|
+
{
|
|
2679
|
+
path: "components/staggered-lines/staggered-lines.tsx",
|
|
2680
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type StaggeredLinesProps = {\n /** Each line enters on its own beat, in order. */\n lines: string[];\n detail?: string;\n align?: "left" | "center";\n /** Frames between one line and the next. */\n stagger?: number;\n};\n\n/**\n * Two or three lines with independent entrance timing, so a statement builds\n * its own hierarchy: the first line lands, the rest answer it.\n */\nexport const StaggeredLines = ({lines, detail, align = "left", stagger = 10}: StaggeredLinesProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n\n return (\n <Fill\n style={{\n alignItems: align === "center" ? "center" : "flex-start",\n gap: 8 * scale,\n justifyContent: "center",\n padding: `${120 * scale}px ${150 * scale}px`,\n textAlign: align,\n }}\n >\n {lines.map((line, index) => {\n const delay = 6 + index * stagger;\n const progress = interpolate(frame, [delay, delay + 26], [0, 1], {easing: Easing.standard});\n const leading = index === 0;\n return (\n <div key={`${line}-${index}`} style={{overflow: "hidden", paddingBottom: 12 * scale}}>\n <span\n style={{\n color: leading ? brand.colors.foreground : brand.colors.muted,\n display: "inline-block",\n fontSize: (leading ? 112 : 92) * scale,\n fontWeight: leading ? 570 : 460,\n letterSpacing: "-0.045em",\n lineHeight: 1.05,\n opacity: progress,\n transform: `translateY(${(1 - progress) * 70 * scale}px)`,\n }}\n >\n {line}\n </span>\n </div>\n );\n })}\n {detail ? (\n <div\n style={{\n color: brand.colors.muted,\n fontSize: 34 * scale,\n marginTop: 20 * scale,\n opacity: interpolate(frame, [6 + lines.length * stagger, 6 + lines.length * stagger + 20], [0, 1], {\n easing: Easing.standard,\n }),\n }}\n >\n {detail}\n </div>\n ) : null}\n </Fill>\n );\n};\n',
|
|
2681
|
+
target: "videos/components/staggered-lines/staggered-lines.tsx"
|
|
2682
|
+
},
|
|
2683
|
+
{
|
|
2684
|
+
path: "components/staggered-lines/staggered-lines.preview.tsx",
|
|
2685
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {StaggeredLines} from "./staggered-lines";\n\nexport default defineComponentPreview({\n title: "Staggered lines",\n category: "Typography",\n description: "Two line hierarchy with independent entrance timing.",\n component: StaggeredLines,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n align: {type: "select", defaultValue: "left", options: ["left", "center"]},\n stagger: {type: "number", defaultValue: 10, min: 4, max: 30},\n detail: {type: "text", defaultValue: "From first scene to final render."},\n },\n examples: [\n {name: "Default", props: {lines: ["One workflow.", "Every launch."]}},\n {name: "Centered", props: {lines: ["Author the story.", "Render the proof."], align: "center"}},\n ],\n});\n',
|
|
2686
|
+
target: "videos/components/staggered-lines/staggered-lines.preview.tsx"
|
|
2687
|
+
}
|
|
2688
|
+
],
|
|
2689
|
+
meta: {
|
|
2690
|
+
kind: "component",
|
|
2691
|
+
family: "Typography",
|
|
2692
|
+
namespaced: "@odori/staggered-lines",
|
|
2693
|
+
contract: {
|
|
2694
|
+
aspectRatios: [
|
|
2695
|
+
"16:9",
|
|
2696
|
+
"9:16",
|
|
2697
|
+
"1:1"
|
|
2698
|
+
],
|
|
2699
|
+
recommendedDurationInFrames: 120,
|
|
2700
|
+
minimumDurationInFrames: 48,
|
|
2701
|
+
entranceFrames: 32,
|
|
2702
|
+
exitFrames: 10,
|
|
2703
|
+
contentLimits: {
|
|
2704
|
+
lines: 3,
|
|
2705
|
+
lineLength: 42
|
|
2706
|
+
},
|
|
2707
|
+
reducedMotion: "lines fade in together",
|
|
2708
|
+
requires: {
|
|
2709
|
+
fonts: [
|
|
2710
|
+
"sans"
|
|
2711
|
+
],
|
|
2712
|
+
audio: []
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
},
|
|
2717
|
+
{
|
|
2718
|
+
name: "state-transition",
|
|
2719
|
+
description: "Before, processing, and resolved product states.",
|
|
2720
|
+
registryDependencies: [],
|
|
2721
|
+
files: [
|
|
2722
|
+
{
|
|
2723
|
+
path: "components/state-transition/state-transition.tsx",
|
|
2724
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type StateTransitionProps = {\n /** The state the product starts in. */\n from: string;\n /** What it looks like while the work happens. */\n during: string;\n /** Where it resolves. */\n to: string;\n label?: string;\n};\n\n/**\n * Before, working, resolved. The card keeps its size across all three, so the\n * transition reads as one object changing rather than three cards cutting.\n */\nexport const StateTransition = ({from, during, to, label}: StateTransitionProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const stage = frame < 34 ? 0 : frame < 78 ? 1 : 2;\n const text = [from, during, to][stage];\n const enterAt = [0, 34, 78][stage];\n const enter = interpolate(frame, [enterAt, enterAt + 14], [0, 1], {easing: Easing.standard});\n const tint = stage === 2 ? brand.colors.accent : brand.colors.border;\n const spin = (frame % 30) / 30;\n\n return (\n <Fill style={{alignItems: "center", gap: 30 * scale, justifyContent: "center"}}>\n {label ? <div style={{color: brand.colors.muted, fontSize: 32 * scale}}>{label}</div> : null}\n <div\n style={{\n alignItems: "center",\n background: brand.colors.surface,\n border: `${2 * scale}px solid ${tint}`,\n borderRadius: 26 * scale,\n display: "flex",\n gap: 22 * scale,\n minWidth: 720 * scale,\n justifyContent: "center",\n padding: `${44 * scale}px ${52 * scale}px`,\n }}\n >\n {stage === 1 ? (\n <span\n style={{\n border: `${4 * scale}px solid ${brand.colors.border}`,\n borderRadius: 999,\n borderTopColor: brand.colors.accent,\n display: "block",\n height: 46 * scale,\n transform: `rotate(${spin * 360}deg)`,\n width: 46 * scale,\n }}\n />\n ) : null}\n <span\n style={{\n fontSize: 62 * scale,\n fontWeight: 560,\n letterSpacing: "-0.03em",\n opacity: enter,\n transform: `translateY(${(1 - enter) * 14 * scale}px)`,\n }}\n >\n {text}\n </span>\n </div>\n </Fill>\n );\n};\n',
|
|
2725
|
+
target: "videos/components/state-transition/state-transition.tsx"
|
|
2726
|
+
},
|
|
2727
|
+
{
|
|
2728
|
+
path: "components/state-transition/state-transition.preview.tsx",
|
|
2729
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {StateTransition} from "./state-transition";\n\nexport default defineComponentPreview({\n title: "State transition",\n category: "Narrative",\n description: "Before, processing, and resolved product states.",\n component: StateTransition,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {\n label: {type: "text", defaultValue: "Export job"},\n from: {type: "text", defaultValue: "Queued", maxLength: 24},\n during: {type: "text", defaultValue: "Rendering 360 frames", maxLength: 32},\n to: {type: "text", defaultValue: "Ready", maxLength: 24},\n },\n examples: [{name: "Export", props: {}}],\n});\n',
|
|
2730
|
+
target: "videos/components/state-transition/state-transition.preview.tsx"
|
|
2731
|
+
}
|
|
2732
|
+
],
|
|
2733
|
+
meta: {
|
|
2734
|
+
kind: "component",
|
|
2735
|
+
family: "Narrative",
|
|
2736
|
+
namespaced: "@odori/state-transition",
|
|
2737
|
+
contract: {
|
|
2738
|
+
aspectRatios: [
|
|
2739
|
+
"16:9",
|
|
2740
|
+
"9:16",
|
|
2741
|
+
"1:1"
|
|
2742
|
+
],
|
|
2743
|
+
recommendedDurationInFrames: 150,
|
|
2744
|
+
minimumDurationInFrames: 120,
|
|
2745
|
+
entranceFrames: 14,
|
|
2746
|
+
exitFrames: 0,
|
|
2747
|
+
contentLimits: {
|
|
2748
|
+
from: 24,
|
|
2749
|
+
during: 32,
|
|
2750
|
+
to: 24
|
|
2751
|
+
},
|
|
2752
|
+
reducedMotion: "states cut without a spinner",
|
|
2753
|
+
requires: {
|
|
2754
|
+
fonts: [
|
|
2755
|
+
"sans"
|
|
2756
|
+
],
|
|
2757
|
+
audio: []
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
},
|
|
2762
|
+
{
|
|
2763
|
+
name: "sting-close",
|
|
2764
|
+
description: "A resolving fall under an end card.",
|
|
2765
|
+
registryDependencies: [],
|
|
2766
|
+
files: [
|
|
2767
|
+
{
|
|
2768
|
+
path: "components/sting-close/sting-close.tsx",
|
|
2769
|
+
content: 'import {\n chord,\n defineCue,\n gain,\n lowPass,\n mix,\n normalize,\n note,\n sequence,\n shape,\n sine,\n type CueDefinition,\n} from "odori";\n\nexport type StingCloseOptions = {\n /** Root the sting resolves onto, as a note name. */\n root?: string;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * The closing bookend: the fifth first, the root landing under it, and a long\n * tail that lets the end card sit. Two and a half seconds, and it resolves\n * downward, because a video that ends on a rise sounds unfinished.\n */\nexport const stingClose = ({root = "d3", peak = 0.8}: StingCloseOptions = {}): CueDefinition => {\n const samples = 48000 * 2.5;\n const [tonic, , fifth] = chord(root, "minor");\n\n return defineCue({\n name: "sting.close",\n durationInFrames: 75,\n params: {root, peak},\n render: () =>\n normalize(\n mix(\n sequence(\n [\n {\n at: 0,\n play: (length: number) => shape(sine(length, fifth), {attack: 0.01, decay: 1.4, sustain: 0.1, release: 0.6}),\n gain: 0.55,\n },\n {\n at: 1,\n play: (length: number) => shape(sine(length, tonic), {attack: 0.012, decay: 1.6, sustain: 0.12, release: 0.7}),\n gain: 0.8,\n },\n ],\n {bpm: 96, division: 8, samples},\n ),\n // Weight under the landing, low passed so it is felt rather than heard.\n gain(lowPass(shape(sine(samples, note(root) / 2), {attack: 0.03, decay: 1.8, sustain: 0}), 160), 0.5),\n ),\n peak,\n ),\n });\n};\n',
|
|
2770
|
+
target: "videos/components/sting-close/sting-close.tsx"
|
|
2771
|
+
},
|
|
2772
|
+
{
|
|
2773
|
+
path: "components/sting-close/sting-close.preview.tsx",
|
|
2774
|
+
content: 'import {CueWave, defineComponentPreview} from "odori/preview";\nimport {stingClose, type StingCloseOptions} from "./sting-close";\n\nconst Wave = ({root, peak}: StingCloseOptions) => <CueWave cue={stingClose({root, peak})} />;\n\nexport default defineComponentPreview({\n title: "Closing sting",\n category: "Sound",\n description: "A resolving fall under an end card.",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: "3s"},\n controls: {\n root: {type: "select", defaultValue: "d3", options: ["c3", "d3", "f3", "g3"]},\n peak: {type: "number", defaultValue: 0.8, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: "Default", props: {}}, {name: "Lower", props: {root: "c3"}}],\n});\n',
|
|
2775
|
+
target: "videos/components/sting-close/sting-close.preview.tsx"
|
|
2776
|
+
}
|
|
2777
|
+
],
|
|
2778
|
+
meta: {
|
|
2779
|
+
kind: "cue",
|
|
2780
|
+
family: "Sound",
|
|
2781
|
+
namespaced: "@odori/sting-close",
|
|
2782
|
+
contract: {
|
|
2783
|
+
aspectRatios: [
|
|
2784
|
+
"16:9",
|
|
2785
|
+
"9:16",
|
|
2786
|
+
"1:1"
|
|
2787
|
+
],
|
|
2788
|
+
recommendedDurationInFrames: 75,
|
|
2789
|
+
minimumDurationInFrames: 75,
|
|
2790
|
+
entranceFrames: 0,
|
|
2791
|
+
exitFrames: 0,
|
|
2792
|
+
contentLimits: {},
|
|
2793
|
+
reducedMotion: "the waveform renders without a playhead",
|
|
2794
|
+
requires: {
|
|
2795
|
+
fonts: [
|
|
2796
|
+
"mono"
|
|
2797
|
+
],
|
|
2798
|
+
audio: []
|
|
2799
|
+
},
|
|
2800
|
+
loops: false
|
|
2801
|
+
},
|
|
2802
|
+
cue: {
|
|
2803
|
+
name: "sting.close",
|
|
2804
|
+
export: "stingClose"
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
},
|
|
2808
|
+
{
|
|
2809
|
+
name: "sting-open",
|
|
2810
|
+
description: "A breath of air and an arriving chord, for a title.",
|
|
2811
|
+
registryDependencies: [],
|
|
2812
|
+
files: [
|
|
2813
|
+
{
|
|
2814
|
+
path: "components/sting-open/sting-open.tsx",
|
|
2815
|
+
content: 'import {\n chord,\n defineCue,\n gain,\n highPass,\n mix,\n noise,\n normalize,\n note,\n sequence,\n shape,\n sine,\n type CueDefinition,\n} from "odori";\n\nexport type StingOpenOptions = {\n /** Root of the arrival 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 * The opening bookend: a breath of air, then a chord arriving under it. Two\n * seconds, so a title has something to land on without the sound outstaying\n * the first shot.\n *\n * The notes enter as an arpeggio a few frames apart rather than together,\n * which is the difference between a chord and a stab.\n */\nexport const stingOpen = ({root = "a3", peak = 0.85}: StingOpenOptions = {}): CueDefinition => {\n const samples = 48000 * 2;\n const voices = chord(root, "add9");\n\n return defineCue({\n name: "sting.open",\n durationInFrames: 60,\n params: {root, peak},\n render: () =>\n normalize(\n mix(\n // Air: filtered noise opening the moment.\n gain(shape(highPass(noise(samples, 21), 3000), {attack: 0.18, decay: 0.5, sustain: 0.05}), 0.25),\n // The chord, one voice every sixteenth.\n sequence(\n voices.map((frequency, index) => ({\n at: index,\n play: (length: number) =>\n shape(sine(length, frequency), {attack: 0.008, decay: 0.9, sustain: 0.15, release: 0.4}),\n gain: 0.7 - index * 0.08,\n })),\n {bpm: 120, division: 16, samples},\n ),\n // A sub under the root gives the arrival a floor.\n gain(shape(sine(samples, note(root) / 2), {attack: 0.02, decay: 1.1, sustain: 0}), 0.4),\n ),\n peak,\n ),\n });\n};\n',
|
|
2816
|
+
target: "videos/components/sting-open/sting-open.tsx"
|
|
2817
|
+
},
|
|
2818
|
+
{
|
|
2819
|
+
path: "components/sting-open/sting-open.preview.tsx",
|
|
2820
|
+
content: 'import {CueWave, defineComponentPreview} from "odori/preview";\nimport {stingOpen, type StingOpenOptions} from "./sting-open";\n\nconst Wave = ({root, peak}: StingOpenOptions) => <CueWave cue={stingOpen({root, peak})} />;\n\nexport default defineComponentPreview({\n title: "Opening sting",\n category: "Sound",\n description: "A breath of air and an arriving chord, for a title.",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: "3s"},\n controls: {\n root: {type: "select", defaultValue: "a3", options: ["f3", "g3", "a3", "c4"]},\n peak: {type: "number", defaultValue: 0.85, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: "Default", props: {}}, {name: "Higher", props: {root: "c4"}}],\n});\n',
|
|
2821
|
+
target: "videos/components/sting-open/sting-open.preview.tsx"
|
|
2822
|
+
}
|
|
2823
|
+
],
|
|
2824
|
+
meta: {
|
|
2825
|
+
kind: "cue",
|
|
2826
|
+
family: "Sound",
|
|
2827
|
+
namespaced: "@odori/sting-open",
|
|
2828
|
+
contract: {
|
|
2829
|
+
aspectRatios: [
|
|
2830
|
+
"16:9",
|
|
2831
|
+
"9:16",
|
|
2832
|
+
"1:1"
|
|
2833
|
+
],
|
|
2834
|
+
recommendedDurationInFrames: 60,
|
|
2835
|
+
minimumDurationInFrames: 60,
|
|
2836
|
+
entranceFrames: 0,
|
|
2837
|
+
exitFrames: 0,
|
|
2838
|
+
contentLimits: {},
|
|
2839
|
+
reducedMotion: "the waveform renders without a playhead",
|
|
2840
|
+
requires: {
|
|
2841
|
+
fonts: [
|
|
2842
|
+
"mono"
|
|
2843
|
+
],
|
|
2844
|
+
audio: []
|
|
2845
|
+
},
|
|
2846
|
+
loops: false
|
|
2847
|
+
},
|
|
2848
|
+
cue: {
|
|
2849
|
+
name: "sting.open",
|
|
2850
|
+
export: "stingOpen"
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
},
|
|
2854
|
+
{
|
|
2855
|
+
name: "success",
|
|
2856
|
+
description: "Two notes rising a fifth, for a confirmation.",
|
|
2857
|
+
registryDependencies: [],
|
|
2858
|
+
files: [
|
|
2859
|
+
{
|
|
2860
|
+
path: "components/success/success.tsx",
|
|
2861
|
+
content: 'import {defineCue, gain, mix, normalize, note, sequence, shape, sine, type CueDefinition} from "odori";\n\nexport type SuccessOptions = {\n /** The note the confirmation starts from. */\n root?: string;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * Two notes rising a fifth: the shape every interface has agreed means "that\n * worked". Half a second, so it fits inside the frame where the check mark\n * appears.\n *\n * Rising is the entire message. The same two notes in the other order are\n * `error`, which is why both are written from one interval.\n */\nexport const success = ({root = "e5", peak = 0.6}: SuccessOptions = {}): CueDefinition => {\n const samples = Math.round(48000 * 0.5);\n const base = note(root);\n\n return defineCue({\n name: "ui.success",\n durationInFrames: 15,\n params: {root, peak},\n render: () =>\n normalize(\n sequence(\n [base, base * 2 ** (7 / 12)].map((frequency, index) => ({\n at: index,\n play: (length: number) =>\n mix(\n shape(sine(length, frequency), {attack: 0.004, decay: 0.24, sustain: 0.06, release: 0.12}),\n gain(shape(sine(length, frequency * 2), {attack: 0.004, decay: 0.12, sustain: 0}), 0.3),\n ),\n gain: index === 0 ? 0.7 : 0.85,\n })),\n {bpm: 150, division: 16, samples},\n ),\n peak,\n ),\n });\n};\n',
|
|
2862
|
+
target: "videos/components/success/success.tsx"
|
|
2863
|
+
},
|
|
2864
|
+
{
|
|
2865
|
+
path: "components/success/success.preview.tsx",
|
|
2866
|
+
content: 'import {CueWave, defineComponentPreview} from "odori/preview";\nimport {success, type SuccessOptions} from "./success";\n\nconst Wave = ({root, peak}: SuccessOptions) => <CueWave cue={success({root, peak})} />;\n\nexport default defineComponentPreview({\n title: "Success",\n category: "Sound",\n description: "Two notes rising a fifth, for a confirmation.",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: "2s"},\n controls: {\n root: {type: "select", defaultValue: "e5", options: ["c5", "d5", "e5", "g5"]},\n peak: {type: "number", defaultValue: 0.6, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: "Default", props: {}}, {name: "Lower", props: {root: "c5"}}],\n});\n',
|
|
2867
|
+
target: "videos/components/success/success.preview.tsx"
|
|
2868
|
+
}
|
|
2869
|
+
],
|
|
2870
|
+
meta: {
|
|
2871
|
+
kind: "cue",
|
|
2872
|
+
family: "Sound",
|
|
2873
|
+
namespaced: "@odori/success",
|
|
2874
|
+
contract: {
|
|
2875
|
+
aspectRatios: [
|
|
2876
|
+
"16:9",
|
|
2877
|
+
"9:16",
|
|
2878
|
+
"1:1"
|
|
2879
|
+
],
|
|
2880
|
+
recommendedDurationInFrames: 15,
|
|
2881
|
+
minimumDurationInFrames: 15,
|
|
2882
|
+
entranceFrames: 0,
|
|
2883
|
+
exitFrames: 0,
|
|
2884
|
+
contentLimits: {},
|
|
2885
|
+
reducedMotion: "the waveform renders without a playhead",
|
|
2886
|
+
requires: {
|
|
2887
|
+
fonts: [
|
|
2888
|
+
"mono"
|
|
2889
|
+
],
|
|
2890
|
+
audio: []
|
|
2891
|
+
},
|
|
2892
|
+
loops: false
|
|
2893
|
+
},
|
|
2894
|
+
cue: {
|
|
2895
|
+
name: "ui.success",
|
|
2896
|
+
export: "success"
|
|
2897
|
+
}
|
|
2898
|
+
}
|
|
2899
|
+
},
|
|
2900
|
+
{
|
|
2901
|
+
name: "table-focus",
|
|
2902
|
+
description: "Rows and columns with one animated focal selection.",
|
|
2903
|
+
registryDependencies: [],
|
|
2904
|
+
files: [
|
|
2905
|
+
{
|
|
2906
|
+
path: "components/table-focus/table-focus.tsx",
|
|
2907
|
+
content: 'import {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type TableFocusProps = {\n columns?: string[];\n rows?: string[][];\n /** Which row the camera settles on, by index. */\n focus?: number;\n /** Frame the focus lands. */\n focusAt?: number;\n /** Dim the rows that are not the focus. */\n dim?: boolean;\n};\n\n/**\n * Rows and columns with one row that matters.\n *\n * A table in a product video is never about the table: it is about the one row\n * the viewer is supposed to read. So the focus is a first-class prop, the rows\n * around it recede, and the highlight arrives on a frame the author chose\n * rather than fading in with everything else.\n */\nexport const TableFocus = ({\n columns = ["Deployment", "Environment", "Duration", "Status"],\n rows = [],\n focus = 1,\n focusAt = 30,\n dim = true,\n}: TableFocusProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const appear = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});\n const landed = interpolate(frame, [focusAt, focusAt + 14], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{justifyContent: "center", opacity: appear, padding: 90 * scale}}>\n <div style={{display: "flex", flexDirection: "column", gap: 10 * scale}}>\n <div style={{display: "flex", gap: 24 * scale, padding: `0 ${28 * scale}px ${14 * scale}px`}}>\n {columns.map((column) => (\n <span\n key={column}\n style={{\n color: brand.colors.muted,\n flex: 1,\n fontFamily: brand.typography.sans,\n fontSize: 21 * scale,\n letterSpacing: "0.06em",\n textTransform: "uppercase",\n }}\n >\n {column}\n </span>\n ))}\n </div>\n\n {rows.map((row, index) => {\n const isFocus = index === focus;\n // Everything else recedes rather than the focus getting brighter:\n // the row stays the colour it always was, which keeps the table\n // looking like a table and not like a slide.\n const recede = dim && !isFocus ? 1 - landed * 0.65 : 1;\n\n return (\n <div\n key={index}\n style={{\n alignItems: "center",\n background: isFocus ? brand.colors.surface : "transparent",\n border: `1px solid ${isFocus ? brand.colors.border : "transparent"}`,\n borderRadius: 14 * scale,\n display: "flex",\n gap: 24 * scale,\n opacity: recede,\n padding: `${22 * scale}px ${28 * scale}px`,\n transform: isFocus ? `scale(${1 + landed * 0.012})` : undefined,\n }}\n >\n {row.map((cell, cellIndex) => (\n <span\n key={cellIndex}\n style={{\n color: cellIndex === 0 ? brand.colors.foreground : brand.colors.muted,\n flex: 1,\n fontFamily: cellIndex === 0 ? brand.typography.sans : brand.typography.mono,\n fontSize: 26 * scale,\n overflow: "hidden",\n textOverflow: "ellipsis",\n whiteSpace: "nowrap",\n }}\n >\n {cell}\n </span>\n ))}\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n',
|
|
2908
|
+
target: "videos/components/table-focus/table-focus.tsx"
|
|
2909
|
+
},
|
|
2910
|
+
{
|
|
2911
|
+
path: "components/table-focus/table-focus.preview.tsx",
|
|
2912
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {TableFocus} from "./table-focus";\n\nconst ROWS = [\n ["odori.dev", "production", "48s", "ready"],\n ["docs-site", "preview", "31s", "ready"],\n ["studio", "production", "62s", "building"],\n ["registry", "preview", "12s", "ready"],\n];\n\nexport default defineComponentPreview({\n title: "Table focus",\n category: "Product UI",\n description: "Rows and columns with one animated focal selection.",\n component: TableFocus,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n focus: {type: "number", defaultValue: 1, min: 0, max: 3},\n focusAt: {type: "number", defaultValue: 30, min: 0, max: 90},\n dim: {type: "boolean", defaultValue: true},\n },\n examples: [\n {name: "Default", props: {rows: ROWS}},\n {name: "Last row", props: {rows: ROWS, focus: 3}},\n {name: "No dimming", props: {rows: ROWS, dim: false}},\n ],\n});\n',
|
|
2913
|
+
target: "videos/components/table-focus/table-focus.preview.tsx"
|
|
2914
|
+
}
|
|
2915
|
+
],
|
|
2916
|
+
meta: {
|
|
2917
|
+
kind: "component",
|
|
2918
|
+
family: "Product UI",
|
|
2919
|
+
namespaced: "@odori/table-focus",
|
|
2920
|
+
contract: {
|
|
2921
|
+
aspectRatios: [
|
|
2922
|
+
"16:9",
|
|
2923
|
+
"9:16",
|
|
2924
|
+
"1:1"
|
|
2925
|
+
],
|
|
2926
|
+
recommendedDurationInFrames: 120,
|
|
2927
|
+
minimumDurationInFrames: 45,
|
|
2928
|
+
entranceFrames: 14,
|
|
2929
|
+
exitFrames: 0,
|
|
2930
|
+
contentLimits: {
|
|
2931
|
+
columns: 6,
|
|
2932
|
+
rows: 8
|
|
2933
|
+
},
|
|
2934
|
+
reducedMotion: "the focused row is marked without the others dimming",
|
|
2935
|
+
requires: {
|
|
2936
|
+
fonts: [
|
|
2937
|
+
"sans"
|
|
2938
|
+
],
|
|
2939
|
+
audio: []
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
},
|
|
2944
|
+
{
|
|
2945
|
+
name: "terminal",
|
|
2946
|
+
description: "A typed command session with deterministic output reveals.",
|
|
2947
|
+
registryDependencies: [],
|
|
2948
|
+
files: [
|
|
2949
|
+
{
|
|
2950
|
+
path: "components/terminal/terminal.tsx",
|
|
2951
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type TerminalStep = {command: string; output?: string[]; durationInFrames?: number};\n\nexport type TerminalProps = {\n steps: TerminalStep[];\n title?: string;\n prompt?: string;\n};\n\n/**\n * A typed session. Characters, the caret blink, and output all derive from the\n * frame, so scrubbing backwards untypes the command exactly.\n */\nexport const Terminal = ({steps, title = "product-stories", prompt = "$"}: TerminalProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const enter = interpolate(frame, [0, 20], [0, 1], {easing: Easing.standard});\n\n let cursor = 0;\n const rendered = steps.map((step) => {\n const typing = Math.max(14, Math.round(step.command.length * 1.5));\n const hold = step.durationInFrames ?? typing + 40;\n const start = cursor;\n cursor += hold;\n const local = frame - start;\n return {\n step,\n visible: local >= 0,\n typed: Math.max(0, Math.min(step.command.length, Math.round(local / 1.5))),\n typing,\n local,\n outputProgress: interpolate(local, [typing + 6, typing + 20], [0, 1], {easing: Easing.standard}),\n };\n });\n\n return (\n <Fill style={{alignItems: "center", justifyContent: "center", padding: 110 * scale}}>\n <div\n style={{\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 16 * scale,\n boxShadow: `0 ${30 * scale}px ${90 * scale}px rgba(0,0,0,0.6)`,\n fontFamily: brand.typography.mono,\n maxWidth: 1360 * scale,\n minHeight: 540 * scale,\n opacity: enter,\n overflow: "hidden",\n transform: `translateY(${(1 - enter) * 26 * scale}px)`,\n width: "100%",\n }}\n >\n <div\n style={{\n alignItems: "center",\n background: "rgba(255,255,255,0.02)",\n borderBottom: `1px solid ${brand.colors.border}`,\n color: brand.colors.muted,\n display: "flex",\n fontSize: 21 * scale,\n gap: 14 * scale,\n padding: `${18 * scale}px ${24 * scale}px`,\n }}\n >\n <div style={{display: "flex", gap: 9 * scale}}>\n {[0, 1, 2].map((index) => (\n <span key={index} style={{background: "#2a2a2a", borderRadius: 999, height: 12 * scale, width: 12 * scale}} />\n ))}\n </div>\n <span style={{marginLeft: 8 * scale}}>{title}</span>\n </div>\n\n <div style={{display: "grid", fontSize: 29 * scale, gap: 20 * scale, lineHeight: 1.5, padding: 32 * scale}}>\n {rendered.map((item, index) =>\n item.visible ? (\n <div key={index} style={{display: "grid", gap: 8 * scale}}>\n <div style={{display: "flex", gap: 14 * scale}}>\n <span style={{color: brand.colors.muted}}>{prompt}</span>\n <span style={{color: brand.colors.foreground, whiteSpace: "pre"}}>\n {item.step.command.slice(0, item.typed)}\n {item.typed < item.step.command.length || Math.floor(item.local / 15) % 2 === 0 ? (\n <span\n style={{\n background: brand.colors.foreground,\n display: "inline-block",\n height: 30 * scale,\n marginLeft: 3 * scale,\n transform: `translateY(${5 * scale}px)`,\n width: 13 * scale,\n }}\n />\n ) : null}\n </span>\n </div>\n {item.outputProgress > 0\n ? (item.step.output ?? []).map((line, lineIndex) => (\n <div\n key={lineIndex}\n style={{\n color: brand.colors.muted,\n opacity: item.outputProgress,\n paddingLeft: 32 * scale,\n transform: `translateY(${(1 - item.outputProgress) * 6 * scale}px)`,\n }}\n >\n {line}\n </div>\n ))\n : null}\n </div>\n ) : null,\n )}\n </div>\n </div>\n </Fill>\n );\n};\n',
|
|
2952
|
+
target: "videos/components/terminal/terminal.tsx"
|
|
2953
|
+
},
|
|
2954
|
+
{
|
|
2955
|
+
path: "components/terminal/terminal.preview.tsx",
|
|
2956
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {Terminal} from "./terminal";\n\nexport default defineComponentPreview({\n title: "Terminal",\n category: "Developer proof",\n description: "A typed command session with deterministic output reveals.",\n component: Terminal,\n canvas: {width: 1920, height: 1080, duration: "6s"},\n controls: {\n title: {type: "text", defaultValue: "zsh"},\n prompt: {type: "text", defaultValue: "$", maxLength: 4},\n },\n examples: [\n {\n name: "Install and preview",\n props: {\n steps: [\n {command: "pnpm create odori@latest product-stories", output: ["Created videos/launch/video.tsx"]},\n {command: "pnpm odori dev", output: ["Odori Studio ready on http://127.0.0.1:4300"]},\n ],\n },\n },\n {\n name: "Single command",\n props: {steps: [{command: "pnpm odori export launch", output: ["out/launch.mp4"]}]},\n },\n ],\n});\n',
|
|
2957
|
+
target: "videos/components/terminal/terminal.preview.tsx"
|
|
2958
|
+
}
|
|
2959
|
+
],
|
|
2960
|
+
meta: {
|
|
2961
|
+
kind: "component",
|
|
2962
|
+
family: "Developer proof",
|
|
2963
|
+
namespaced: "@odori/terminal",
|
|
2964
|
+
contract: {
|
|
2965
|
+
aspectRatios: [
|
|
2966
|
+
"16:9",
|
|
2967
|
+
"1:1"
|
|
2968
|
+
],
|
|
2969
|
+
recommendedDurationInFrames: 180,
|
|
2970
|
+
minimumDurationInFrames: 60,
|
|
2971
|
+
entranceFrames: 12,
|
|
2972
|
+
exitFrames: 10,
|
|
2973
|
+
contentLimits: {
|
|
2974
|
+
commandLength: 58,
|
|
2975
|
+
steps: 4
|
|
2976
|
+
},
|
|
2977
|
+
reducedMotion: "prints commands without typing",
|
|
2978
|
+
requires: {
|
|
2979
|
+
fonts: [
|
|
2980
|
+
"mono"
|
|
2981
|
+
],
|
|
2982
|
+
audio: []
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
},
|
|
2987
|
+
{
|
|
2988
|
+
name: "timeline",
|
|
2989
|
+
description: "Milestones revealed along a measured temporal axis.",
|
|
2990
|
+
registryDependencies: [],
|
|
2991
|
+
files: [
|
|
2992
|
+
{
|
|
2993
|
+
path: "components/timeline/timeline.tsx",
|
|
2994
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type Milestone = {label: string; detail?: string};\n\nexport type TimelineProps = {\n milestones: Milestone[];\n headline?: string;\n};\n\n/**\n * Milestones along one measured axis. The axis draws first and the markers\n * land on it in order, so the spacing reads as time rather than as a list.\n */\nexport const Timeline = ({milestones, headline}: TimelineProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const axis = interpolate(frame, [8, 44], [0, 1], {easing: Easing.standard});\n\n return (\n <Fill style={{gap: 60 * scale, justifyContent: "center", padding: `${110 * scale}px ${120 * scale}px`}}>\n {headline ? (\n <div style={{fontSize: 58 * scale, fontWeight: 560, letterSpacing: "-0.04em", opacity: interpolate(frame, [0, 16], [0, 1])}}>\n {headline}\n </div>\n ) : null}\n <div style={{position: "relative"}}>\n <div\n style={{\n background: brand.colors.border,\n height: 2 * scale,\n transform: `scaleX(${axis})`,\n transformOrigin: "left",\n width: "100%",\n }}\n />\n <div style={{display: "flex", justifyContent: "space-between", marginTop: -12 * scale}}>\n {milestones.map((milestone, index) => {\n const at = 24 + index * 12;\n const enter = interpolate(frame, [at, at + 18], [0, 1], {easing: Easing.standard});\n return (\n <div key={milestone.label} style={{alignItems: "center", display: "grid", gap: 16 * scale, justifyItems: "center", opacity: enter}}>\n <span\n style={{\n background: index === milestones.length - 1 ? brand.colors.accent : brand.colors.foreground,\n borderRadius: 999,\n height: 22 * scale,\n transform: `scale(${0.6 + enter * 0.4})`,\n width: 22 * scale,\n }}\n />\n <span style={{fontSize: 34 * scale, fontWeight: 540}}>{milestone.label}</span>\n {milestone.detail ? (\n <span style={{color: brand.colors.muted, fontSize: 26 * scale}}>{milestone.detail}</span>\n ) : null}\n </div>\n );\n })}\n </div>\n </div>\n </Fill>\n );\n};\n',
|
|
2995
|
+
target: "videos/components/timeline/timeline.tsx"
|
|
2996
|
+
},
|
|
2997
|
+
{
|
|
2998
|
+
path: "components/timeline/timeline.preview.tsx",
|
|
2999
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {Timeline} from "./timeline";\n\nexport default defineComponentPreview({\n title: "Timeline",\n category: "Narrative",\n description: "Milestones revealed along a measured temporal axis.",\n component: Timeline,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {headline: {type: "text", defaultValue: "From idea to launch"}},\n examples: [\n {\n name: "Launch",\n props: {\n milestones: [\n {label: "Idea", detail: "one sentence"},\n {label: "Draft", detail: "scenes in place"},\n {label: "Review", detail: "contract tests"},\n {label: "Launch", detail: "one export"},\n ],\n },\n },\n ],\n});\n',
|
|
3000
|
+
target: "videos/components/timeline/timeline.preview.tsx"
|
|
3001
|
+
}
|
|
3002
|
+
],
|
|
3003
|
+
meta: {
|
|
3004
|
+
kind: "component",
|
|
3005
|
+
family: "Narrative",
|
|
3006
|
+
namespaced: "@odori/timeline",
|
|
3007
|
+
contract: {
|
|
3008
|
+
aspectRatios: [
|
|
3009
|
+
"16:9",
|
|
3010
|
+
"1:1"
|
|
3011
|
+
],
|
|
3012
|
+
recommendedDurationInFrames: 150,
|
|
3013
|
+
minimumDurationInFrames: 75,
|
|
3014
|
+
entranceFrames: 72,
|
|
3015
|
+
exitFrames: 0,
|
|
3016
|
+
contentLimits: {
|
|
3017
|
+
milestones: 5,
|
|
3018
|
+
labelLength: 18
|
|
3019
|
+
},
|
|
3020
|
+
reducedMotion: "the axis and markers appear at once",
|
|
3021
|
+
requires: {
|
|
3022
|
+
fonts: [
|
|
3023
|
+
"sans"
|
|
3024
|
+
],
|
|
3025
|
+
audio: []
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
},
|
|
3030
|
+
{
|
|
3031
|
+
name: "title-reveal",
|
|
3032
|
+
description: "A staggered, word-masked headline.",
|
|
3033
|
+
registryDependencies: [],
|
|
3034
|
+
files: [
|
|
3035
|
+
{
|
|
3036
|
+
path: "components/title-reveal/title-reveal.tsx",
|
|
3037
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type TitleRevealProps = {\n title: string;\n detail?: string;\n align?: "left" | "center";\n};\n\n/**\n * A masked, word-staggered headline. Each word rises out of a clipped line\n * box, so the reveal reads as typography rather than a fade.\n */\nexport const TitleReveal = ({title, detail, align = "center"}: TitleRevealProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const lines = title.split("\\n");\n let wordIndex = 0;\n\n return (\n <Fill\n style={{\n alignItems: align === "center" ? "center" : "flex-start",\n gap: 34 * scale,\n justifyContent: "center",\n padding: `${120 * scale}px ${140 * scale}px`,\n textAlign: align,\n }}\n >\n <div style={{display: "grid", gap: 6 * scale, maxWidth: 1560 * scale}}>\n {lines.map((line, lineNumber) => (\n <div\n key={`${line}-${lineNumber}`}\n style={{\n display: "flex",\n flexWrap: "wrap",\n gap: `0 ${26 * scale}px`,\n justifyContent: align === "center" ? "center" : "flex-start",\n overflow: "hidden",\n paddingBottom: 12 * scale,\n }}\n >\n {line.split(/\\s+/).map((word) => {\n const delay = 4 + wordIndex * brand.motion.staggerFrames;\n wordIndex += 1;\n const progress = interpolate(frame, [delay, delay + 24], [0, 1], {easing: Easing.standard});\n return (\n <span\n key={`${word}-${wordIndex}`}\n style={{\n display: "inline-block",\n fontSize: 116 * scale,\n fontWeight: 560,\n letterSpacing: "-0.045em",\n lineHeight: 1.04,\n opacity: progress,\n transform: `translateY(${(1 - progress) * 78 * scale}px)`,\n }}\n >\n {word}\n </span>\n );\n })}\n </div>\n ))}\n </div>\n\n {detail ? (\n <div\n style={{\n color: brand.colors.muted,\n fontSize: 36 * scale,\n letterSpacing: "-0.015em",\n maxWidth: 1100 * scale,\n opacity: interpolate(frame, [16, 34], [0, 1], {easing: Easing.standard}),\n transform: `translateY(${interpolate(frame, [16, 34], [14 * scale, 0], {easing: Easing.standard})}px)`,\n }}\n >\n {detail}\n </div>\n ) : null}\n </Fill>\n );\n};\n',
|
|
3038
|
+
target: "videos/components/title-reveal/title-reveal.tsx"
|
|
3039
|
+
},
|
|
3040
|
+
{
|
|
3041
|
+
path: "components/title-reveal/title-reveal.preview.tsx",
|
|
3042
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {TitleReveal} from "./title-reveal";\n\nexport default defineComponentPreview({\n title: "Title reveal",\n category: "Typography",\n description: "A staggered, word-masked headline.",\n component: TitleReveal,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n title: {type: "text", defaultValue: "Ship the story.", maxLength: 64},\n detail: {type: "text", defaultValue: "An independent React video framework."},\n align: {type: "select", defaultValue: "center", options: ["left", "center"]},\n },\n examples: [\n {name: "Default", props: {title: "Ship the story."}},\n {name: "Two lines", props: {title: "Build videos\\nlike applications.", detail: "Author. Preview. Ship."}},\n {name: "Long copy", props: {title: "Build videos like applications, not like exports.", align: "left"}},\n ],\n});\n',
|
|
3043
|
+
target: "videos/components/title-reveal/title-reveal.preview.tsx"
|
|
3044
|
+
}
|
|
3045
|
+
],
|
|
3046
|
+
meta: {
|
|
3047
|
+
kind: "component",
|
|
3048
|
+
family: "Typography",
|
|
3049
|
+
namespaced: "@odori/title-reveal",
|
|
3050
|
+
contract: {
|
|
3051
|
+
aspectRatios: [
|
|
3052
|
+
"16:9",
|
|
3053
|
+
"9:16",
|
|
3054
|
+
"1:1"
|
|
3055
|
+
],
|
|
3056
|
+
recommendedDurationInFrames: 120,
|
|
3057
|
+
minimumDurationInFrames: 45,
|
|
3058
|
+
entranceFrames: 20,
|
|
3059
|
+
exitFrames: 10,
|
|
3060
|
+
contentLimits: {
|
|
3061
|
+
title: 64
|
|
3062
|
+
},
|
|
3063
|
+
reducedMotion: "fades without translation",
|
|
3064
|
+
requires: {
|
|
3065
|
+
fonts: [
|
|
3066
|
+
"sans"
|
|
3067
|
+
],
|
|
3068
|
+
audio: []
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
}
|
|
3072
|
+
},
|
|
3073
|
+
{
|
|
3074
|
+
name: "toast-stack",
|
|
3075
|
+
description: "Timed notifications with entry, hold, and dismissal.",
|
|
3076
|
+
registryDependencies: [],
|
|
3077
|
+
files: [
|
|
3078
|
+
{
|
|
3079
|
+
path: "components/toast-stack/toast-stack.tsx",
|
|
3080
|
+
content: 'import {Easing, Fill, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type Toast = {\n /** Frame it arrives on. */\n at: number;\n title: string;\n detail?: string;\n /** Frames it stays before dismissing. */\n hold?: number;\n tone?: "neutral" | "positive" | "negative";\n};\n\nexport type ToastStackProps = {\n toasts?: Toast[];\n /** Which corner the stack grows from. */\n corner?: "bottom-right" | "top-right" | "bottom-left" | "top-left";\n};\n\nconst CORNERS = {\n "bottom-right": {bottom: 0, right: 0, column: "column-reverse" as const},\n "top-right": {top: 0, right: 0, column: "column" as const},\n "bottom-left": {bottom: 0, left: 0, column: "column-reverse" as const},\n "top-left": {top: 0, left: 0, column: "column" as const},\n};\n\n/**\n * Notifications with the three phases they actually have: entry, hold, and\n * dismissal.\n *\n * Each toast carries its own frames, so the stack is authored as a sequence of\n * events rather than animated by hand. Ones that have dismissed leave the\n * layout, which is what makes the ones below slide up the way a real stack\n * does.\n */\nexport const ToastStack = ({toasts = [], corner = "bottom-right"}: ToastStackProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const place = CORNERS[corner];\n const fromRight = corner.endsWith("right");\n\n const tones = {\n neutral: brand.colors.muted,\n positive: brand.colors.accent,\n negative: "#e5484d",\n };\n\n return (\n <Fill style={{pointerEvents: "none"}}>\n <div\n style={{\n display: "flex",\n flexDirection: place.column,\n gap: 14 * scale,\n padding: 48 * scale,\n position: "absolute",\n ...place,\n }}\n >\n {toasts.map((toast, index) => {\n const hold = toast.hold ?? 70;\n const enter = interpolate(frame, [toast.at, toast.at + 10], [0, 1], {easing: Easing.standard});\n const leave = interpolate(frame, [toast.at + hold, toast.at + hold + 10], [1, 0], {easing: Easing.standard});\n const visible = Math.min(enter, leave);\n if (visible <= 0.001) return null;\n\n return (\n <div\n key={index}\n style={{\n alignItems: "flex-start",\n background: brand.colors.surface,\n border: `1px solid ${brand.colors.border}`,\n borderRadius: 16 * scale,\n boxShadow: `0 ${18 * scale}px ${44 * scale}px rgba(0,0,0,0.45)`,\n display: "flex",\n gap: 14 * scale,\n // Height collapses with the fade so the stack closes up\n // instead of leaving a hole where a toast used to be.\n maxHeight: 200 * scale * visible,\n opacity: visible,\n overflow: "hidden",\n padding: `${20 * scale}px ${24 * scale}px`,\n transform: `translateX(${(1 - visible) * (fromRight ? 40 : -40) * scale}px)`,\n width: 520 * scale,\n }}\n >\n <span\n style={{\n background: tones[toast.tone ?? "neutral"],\n borderRadius: 999,\n flex: "none",\n height: 10 * scale,\n marginTop: 10 * scale,\n width: 10 * scale,\n }}\n />\n <div style={{display: "flex", flexDirection: "column", gap: 6 * scale, minWidth: 0}}>\n <strong\n style={{\n color: brand.colors.foreground,\n fontFamily: brand.typography.sans,\n fontSize: 26 * scale,\n fontWeight: 560,\n }}\n >\n {toast.title}\n </strong>\n {toast.detail ? (\n <span style={{color: brand.colors.muted, fontFamily: brand.typography.sans, fontSize: 22 * scale}}>\n {toast.detail}\n </span>\n ) : null}\n </div>\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n',
|
|
3081
|
+
target: "videos/components/toast-stack/toast-stack.tsx"
|
|
3082
|
+
},
|
|
3083
|
+
{
|
|
3084
|
+
path: "components/toast-stack/toast-stack.preview.tsx",
|
|
3085
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {ToastStack} from "./toast-stack";\n\nexport default defineComponentPreview({\n title: "Toast stack",\n category: "Product UI",\n description: "Timed notifications with entry, hold, and dismissal.",\n component: ToastStack,\n canvas: {width: 1920, height: 1080, duration: "6s"},\n controls: {\n corner: {\n type: "select",\n defaultValue: "bottom-right",\n options: ["bottom-right", "top-right", "bottom-left", "top-left"],\n },\n },\n examples: [\n {\n name: "Default",\n props: {\n toasts: [\n {at: 10, title: "Build started", detail: "odori.dev \xB7 production", tone: "neutral"},\n {at: 45, title: "Tests passed", detail: "142 checks", tone: "positive", hold: 60},\n {at: 90, title: "Deployed", detail: "Live in 12s", tone: "positive", hold: 70},\n ],\n },\n },\n {\n name: "Failure",\n props: {\n toasts: [\n {at: 10, title: "Build started", tone: "neutral"},\n {at: 50, title: "Build failed", detail: "Type error in schema.ts", tone: "negative", hold: 90},\n ],\n },\n },\n ],\n});\n',
|
|
3086
|
+
target: "videos/components/toast-stack/toast-stack.preview.tsx"
|
|
3087
|
+
}
|
|
3088
|
+
],
|
|
3089
|
+
meta: {
|
|
3090
|
+
kind: "component",
|
|
3091
|
+
family: "Product UI",
|
|
3092
|
+
namespaced: "@odori/toast-stack",
|
|
3093
|
+
contract: {
|
|
3094
|
+
aspectRatios: [
|
|
3095
|
+
"16:9",
|
|
3096
|
+
"9:16",
|
|
3097
|
+
"1:1"
|
|
3098
|
+
],
|
|
3099
|
+
recommendedDurationInFrames: 180,
|
|
3100
|
+
minimumDurationInFrames: 40,
|
|
3101
|
+
entranceFrames: 10,
|
|
3102
|
+
exitFrames: 10,
|
|
3103
|
+
contentLimits: {
|
|
3104
|
+
title: 48,
|
|
3105
|
+
detail: 72
|
|
3106
|
+
},
|
|
3107
|
+
reducedMotion: "toasts appear and disappear without sliding",
|
|
3108
|
+
requires: {
|
|
3109
|
+
fonts: [
|
|
3110
|
+
"sans"
|
|
3111
|
+
],
|
|
3112
|
+
audio: []
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
},
|
|
3117
|
+
{
|
|
3118
|
+
name: "typewriter",
|
|
3119
|
+
description: "Deterministic typing with a frame-driven caret.",
|
|
3120
|
+
registryDependencies: [],
|
|
3121
|
+
files: [
|
|
3122
|
+
{
|
|
3123
|
+
path: "components/typewriter/typewriter.tsx",
|
|
3124
|
+
content: 'import {Fill, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type TypewriterProps = {\n text: string;\n label?: string;\n /** Characters revealed per second. */\n charactersPerSecond?: number;\n};\n\n/**\n * Typing driven by the frame counter rather than a timer, so scrubbing to a\n * frame shows exactly the characters that frame will render. The caret blinks\n * on a frame cycle for the same reason.\n */\nexport const Typewriter = ({text, label, charactersPerSecond = 18}: TypewriterProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const visible = Math.min(text.length, Math.max(0, Math.floor(((frame - 8) / 30) * charactersPerSecond)));\n const typed = text.slice(0, visible);\n const done = visible >= text.length;\n // A 30 frame cycle: on for the first half, off for the second.\n const caretOn = done ? frame % 30 < 15 : true;\n\n return (\n <Fill\n style={{\n alignItems: "center",\n gap: 26 * scale,\n justifyContent: "center",\n padding: `${120 * scale}px ${140 * scale}px`,\n }}\n >\n {label ? (\n <div\n style={{\n color: brand.colors.muted,\n fontFamily: brand.typography.mono,\n fontSize: 26 * scale,\n opacity: interpolate(frame, [0, 12], [0, 1]),\n }}\n >\n {label}\n </div>\n ) : null}\n <div\n style={{\n alignItems: "center",\n display: "flex",\n fontFamily: brand.typography.mono,\n fontSize: 76 * scale,\n gap: 6 * scale,\n letterSpacing: "-0.02em",\n }}\n >\n <span style={{whiteSpace: "pre"}}>{typed}</span>\n <span\n style={{\n background: brand.colors.accent,\n display: "inline-block",\n height: 76 * scale,\n opacity: caretOn ? 1 : 0,\n width: 10 * scale,\n }}\n />\n </div>\n </Fill>\n );\n};\n',
|
|
3125
|
+
target: "videos/components/typewriter/typewriter.tsx"
|
|
3126
|
+
},
|
|
3127
|
+
{
|
|
3128
|
+
path: "components/typewriter/typewriter.preview.tsx",
|
|
3129
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {Typewriter} from "./typewriter";\n\nexport default defineComponentPreview({\n title: "Typewriter",\n category: "Typography",\n description: "Deterministic typing with a frame-driven caret.",\n component: Typewriter,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n text: {type: "text", defaultValue: "pnpm odori dev", maxLength: 48},\n label: {type: "text", defaultValue: "one command"},\n charactersPerSecond: {type: "number", defaultValue: 18, min: 4, max: 40},\n },\n examples: [\n {name: "Command", props: {text: "pnpm odori dev", label: "one command"}},\n {name: "Export", props: {text: "pnpm odori export launch", charactersPerSecond: 26}},\n ],\n});\n',
|
|
3130
|
+
target: "videos/components/typewriter/typewriter.preview.tsx"
|
|
3131
|
+
}
|
|
3132
|
+
],
|
|
3133
|
+
meta: {
|
|
3134
|
+
kind: "component",
|
|
3135
|
+
family: "Typography",
|
|
3136
|
+
namespaced: "@odori/typewriter",
|
|
3137
|
+
contract: {
|
|
3138
|
+
aspectRatios: [
|
|
3139
|
+
"16:9",
|
|
3140
|
+
"9:16",
|
|
3141
|
+
"1:1"
|
|
3142
|
+
],
|
|
3143
|
+
recommendedDurationInFrames: 120,
|
|
3144
|
+
minimumDurationInFrames: 60,
|
|
3145
|
+
entranceFrames: 8,
|
|
3146
|
+
exitFrames: 0,
|
|
3147
|
+
contentLimits: {
|
|
3148
|
+
text: 48
|
|
3149
|
+
},
|
|
3150
|
+
reducedMotion: "the full string is shown with a static caret",
|
|
3151
|
+
requires: {
|
|
3152
|
+
fonts: [
|
|
3153
|
+
"mono"
|
|
3154
|
+
],
|
|
3155
|
+
audio: []
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
},
|
|
3160
|
+
{
|
|
3161
|
+
name: "typing-loop",
|
|
3162
|
+
description: "Keystrokes that repeat under a terminal scene.",
|
|
3163
|
+
registryDependencies: [],
|
|
3164
|
+
files: [
|
|
3165
|
+
{
|
|
3166
|
+
path: "components/typing-loop/typing-loop.tsx",
|
|
3167
|
+
content: 'import {defineCue, gain, highPass, mix, noise, normalize, seeded, sequence, shape, sine, type CueDefinition} from "odori";\n\nexport type TypingLoopOptions = {\n /** Keystrokes per minute, as a tempo. */\n bpm?: number;\n /** How irregular the timing is, 0 to 1. Nobody types on a grid. */\n human?: number;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * Someone typing: a bar of keystrokes that repeats for as long as the terminal\n * is on screen. Loops, so a scene of any length is one render.\n *\n * The timing and the tone of each key are jittered from a seeded generator.\n * Perfectly even keystrokes read as a machine, and a machine typing is the one\n * thing this sound must not suggest.\n */\nexport const typingLoop = ({bpm = 300, human = 0.35, peak = 0.5}: TypingLoopOptions = {}): CueDefinition => {\n const random = seeded(19);\n const keys = 16;\n const samples = Math.round((60 / bpm) * keys * 48000);\n\n return defineCue({\n name: "ui.typing",\n durationInFrames: Math.round((samples / 48000) * 30),\n loops: true,\n params: {bpm, human, peak},\n render: () =>\n normalize(\n sequence(\n Array.from({length: keys}, (_, index) => {\n const jitter = (random() - 0.5) * human;\n const tone = 260 + random() * 220;\n return {\n // The jitter is bounded so a key never crosses its neighbour.\n at: index + Math.max(-0.4, Math.min(0.4, jitter)),\n gain: 0.6 + random() * 0.4,\n play: (length: number) =>\n mix(\n shape(highPass(noise(length, index + 2), 2200), {attack: 0.001, decay: 0.035, sustain: 0}),\n gain(shape(sine(length, tone), {attack: 0.001, decay: 0.028, sustain: 0}), 0.5),\n ),\n };\n }),\n {bpm, samples},\n ),\n peak,\n ),\n });\n};\n',
|
|
3168
|
+
target: "videos/components/typing-loop/typing-loop.tsx"
|
|
3169
|
+
},
|
|
3170
|
+
{
|
|
3171
|
+
path: "components/typing-loop/typing-loop.preview.tsx",
|
|
3172
|
+
content: 'import {CueWave, defineComponentPreview} from "odori/preview";\nimport {typingLoop, type TypingLoopOptions} from "./typing-loop";\n\nconst Wave = ({bpm, human, peak}: TypingLoopOptions) => <CueWave cue={typingLoop({bpm, human, peak})} />;\n\nexport default defineComponentPreview({\n title: "Typing",\n category: "Sound",\n description: "Keystrokes that repeat under a terminal scene.",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {\n bpm: {type: "number", defaultValue: 300, min: 120, max: 600},\n human: {type: "number", defaultValue: 0.35, min: 0, max: 1, step: 0.05},\n peak: {type: "number", defaultValue: 0.5, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: "Default", props: {}}, {name: "Mechanical", props: {human: 0}}],\n});\n',
|
|
3173
|
+
target: "videos/components/typing-loop/typing-loop.preview.tsx"
|
|
3174
|
+
}
|
|
3175
|
+
],
|
|
3176
|
+
meta: {
|
|
3177
|
+
kind: "cue",
|
|
3178
|
+
family: "Sound",
|
|
3179
|
+
namespaced: "@odori/typing-loop",
|
|
3180
|
+
contract: {
|
|
3181
|
+
aspectRatios: [
|
|
3182
|
+
"16:9",
|
|
3183
|
+
"9:16",
|
|
3184
|
+
"1:1"
|
|
3185
|
+
],
|
|
3186
|
+
recommendedDurationInFrames: 96,
|
|
3187
|
+
minimumDurationInFrames: 96,
|
|
3188
|
+
entranceFrames: 0,
|
|
3189
|
+
exitFrames: 0,
|
|
3190
|
+
contentLimits: {},
|
|
3191
|
+
reducedMotion: "the waveform renders without a playhead",
|
|
3192
|
+
requires: {
|
|
3193
|
+
fonts: [
|
|
3194
|
+
"mono"
|
|
3195
|
+
],
|
|
3196
|
+
audio: []
|
|
3197
|
+
},
|
|
3198
|
+
loops: true
|
|
3199
|
+
},
|
|
3200
|
+
cue: {
|
|
3201
|
+
name: "ui.typing",
|
|
3202
|
+
export: "typingLoop"
|
|
3203
|
+
}
|
|
3204
|
+
}
|
|
3205
|
+
},
|
|
3206
|
+
{
|
|
3207
|
+
name: "ui-key",
|
|
3208
|
+
description: "A typed key: filtered noise with a short tone under it.",
|
|
3209
|
+
registryDependencies: [],
|
|
3210
|
+
files: [
|
|
3211
|
+
{
|
|
3212
|
+
path: "components/ui-key/ui-key.tsx",
|
|
3213
|
+
content: 'import {\n defineCue,\n mix,\n noise,\n normalize,\n shape,\n sine,\n type CueDefinition,\n} from "odori";\n\nexport type UiKeyOptions = {\n /** How much body the click keeps. Lower is duller, higher is sharper. */\n tone?: number;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * A key press: filtered noise for the click, a short sine underneath for\n * weight. Six frames, so it lands inside a single typed character.\n *\n * A sound as source. Change the numbers, hear it in the next preview frame,\n * and the encoder mixes the samples you approved.\n */\nexport const uiKey = ({tone = 320, peak = 0.7}: UiKeyOptions = {}): CueDefinition =>\n defineCue({\n name: "ui.key",\n durationInFrames: 6,\n params: {tone, peak},\n render: ({samples}) =>\n normalize(\n mix(\n shape(noise(samples, 7), {attack: 0.001, decay: 0.05, sustain: 0, release: 0.02}),\n shape(sine(samples, tone), {attack: 0.001, decay: 0.035, sustain: 0, release: 0.02}),\n ),\n peak,\n ),\n });\n',
|
|
3214
|
+
target: "videos/components/ui-key/ui-key.tsx"
|
|
3215
|
+
},
|
|
3216
|
+
{
|
|
3217
|
+
path: "components/ui-key/ui-key.preview.tsx",
|
|
3218
|
+
content: 'import {CueWave, defineComponentPreview} from "odori/preview";\nimport {uiKey, type UiKeyOptions} from "./ui-key";\n\nconst Wave = ({tone, peak}: UiKeyOptions) => <CueWave cue={uiKey({tone, peak})} />;\n\nexport default defineComponentPreview({\n title: "Key click",\n category: "Sound",\n description: "A typed key: filtered noise with a short tone under it.",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: "2s"},\n controls: {\n tone: {type: "number", defaultValue: 320, min: 80, max: 900},\n peak: {type: "number", defaultValue: 0.7, min: 0.1, max: 1, step: 0.05},\n },\n examples: [\n {name: "Default", props: {}},\n {name: "Softer", props: {tone: 180, peak: 0.5}},\n ],\n});\n',
|
|
3219
|
+
target: "videos/components/ui-key/ui-key.preview.tsx"
|
|
3220
|
+
}
|
|
3221
|
+
],
|
|
3222
|
+
meta: {
|
|
3223
|
+
kind: "cue",
|
|
3224
|
+
family: "Sound",
|
|
3225
|
+
namespaced: "@odori/ui-key",
|
|
3226
|
+
contract: {
|
|
3227
|
+
aspectRatios: [
|
|
3228
|
+
"16:9",
|
|
3229
|
+
"9:16",
|
|
3230
|
+
"1:1"
|
|
3231
|
+
],
|
|
3232
|
+
recommendedDurationInFrames: 6,
|
|
3233
|
+
minimumDurationInFrames: 6,
|
|
3234
|
+
entranceFrames: 0,
|
|
3235
|
+
exitFrames: 0,
|
|
3236
|
+
contentLimits: {},
|
|
3237
|
+
reducedMotion: "the waveform renders without a playhead",
|
|
3238
|
+
requires: {
|
|
3239
|
+
fonts: [
|
|
3240
|
+
"mono"
|
|
3241
|
+
],
|
|
3242
|
+
audio: []
|
|
3243
|
+
},
|
|
3244
|
+
loops: false
|
|
3245
|
+
},
|
|
3246
|
+
cue: {
|
|
3247
|
+
name: "ui.key",
|
|
3248
|
+
export: "uiKey"
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
},
|
|
3252
|
+
{
|
|
3253
|
+
name: "ui-reveal",
|
|
3254
|
+
description: "A short upward sweep that settles, for an entrance.",
|
|
3255
|
+
registryDependencies: [],
|
|
3256
|
+
files: [
|
|
3257
|
+
{
|
|
3258
|
+
path: "components/ui-reveal/ui-reveal.tsx",
|
|
3259
|
+
content: 'import {\n defineCue,\n lowPass,\n mix,\n noise,\n normalize,\n shape,\n sweep,\n type CueDefinition,\n} from "odori";\n\nexport type UiRevealOptions = {\n /** Where the sweep starts, in hertz. */\n from?: number;\n /** Where it lands. The distance is what makes it feel like an arrival. */\n to?: number;\n peak?: number;\n};\n\n/**\n * A reveal: a short sweep upward that settles on a tone, with a breath of\n * filtered noise behind it. Eighteen frames, matched to a typical entrance.\n */\nexport const uiReveal = ({from = 220, to = 660, peak = 0.75}: UiRevealOptions = {}): CueDefinition =>\n defineCue({\n name: "ui.reveal",\n durationInFrames: 18,\n params: {from, to, peak},\n render: ({samples}) =>\n normalize(\n mix(\n shape(sweep(samples, from, to), {attack: 0.02, decay: 0.22, sustain: 0.3, release: 0.2}),\n shape(lowPass(noise(samples, 11), 2400), {attack: 0.05, decay: 0.25, sustain: 0.08, release: 0.25}),\n ),\n peak,\n ),\n });\n',
|
|
3260
|
+
target: "videos/components/ui-reveal/ui-reveal.tsx"
|
|
3261
|
+
},
|
|
3262
|
+
{
|
|
3263
|
+
path: "components/ui-reveal/ui-reveal.preview.tsx",
|
|
3264
|
+
content: 'import {CueWave, defineComponentPreview} from "odori/preview";\nimport {uiReveal, type UiRevealOptions} from "./ui-reveal";\n\nconst Wave = ({from, to, peak}: UiRevealOptions) => <CueWave cue={uiReveal({from, to, peak})} />;\n\nexport default defineComponentPreview({\n title: "Reveal",\n category: "Sound",\n description: "A short upward sweep that settles, for an entrance.",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: "2s"},\n controls: {\n from: {type: "number", defaultValue: 220, min: 80, max: 800},\n to: {type: "number", defaultValue: 660, min: 200, max: 2000},\n },\n examples: [\n {name: "Default", props: {}},\n {name: "Wider", props: {from: 140, to: 1200}},\n ],\n});\n',
|
|
3265
|
+
target: "videos/components/ui-reveal/ui-reveal.preview.tsx"
|
|
3266
|
+
}
|
|
3267
|
+
],
|
|
3268
|
+
meta: {
|
|
3269
|
+
kind: "cue",
|
|
3270
|
+
family: "Sound",
|
|
3271
|
+
namespaced: "@odori/ui-reveal",
|
|
3272
|
+
contract: {
|
|
3273
|
+
aspectRatios: [
|
|
3274
|
+
"16:9",
|
|
3275
|
+
"9:16",
|
|
3276
|
+
"1:1"
|
|
3277
|
+
],
|
|
3278
|
+
recommendedDurationInFrames: 18,
|
|
3279
|
+
minimumDurationInFrames: 18,
|
|
3280
|
+
entranceFrames: 0,
|
|
3281
|
+
exitFrames: 0,
|
|
3282
|
+
contentLimits: {},
|
|
3283
|
+
reducedMotion: "the waveform renders without a playhead",
|
|
3284
|
+
requires: {
|
|
3285
|
+
fonts: [
|
|
3286
|
+
"mono"
|
|
3287
|
+
],
|
|
3288
|
+
audio: []
|
|
3289
|
+
},
|
|
3290
|
+
loops: false
|
|
3291
|
+
},
|
|
3292
|
+
cue: {
|
|
3293
|
+
name: "ui.reveal",
|
|
3294
|
+
export: "uiReveal"
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
},
|
|
3298
|
+
{
|
|
3299
|
+
name: "value-swap",
|
|
3300
|
+
description: "Replace one label or value while preserving alignment.",
|
|
3301
|
+
registryDependencies: [],
|
|
3302
|
+
files: [
|
|
3303
|
+
{
|
|
3304
|
+
path: "components/value-swap/value-swap.tsx",
|
|
3305
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type ValueSwapProps = {\n label?: string;\n from: string;\n to: string;\n /** Frame the swap happens on. */\n swapFrame?: number;\n};\n\n/**\n * One value replaced by another in place. Both values occupy the same grid\n * cell, so the swap cannot shift anything around it, however wide the new\n * value is.\n */\nexport const ValueSwap = ({label, from, to, swapFrame = 45}: ValueSwapProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const out = interpolate(frame, [swapFrame - 10, swapFrame], [0, 1], {\n easing: Easing.standard,\n extrapolateLeft: "clamp",\n extrapolateRight: "clamp",\n });\n const into = interpolate(frame, [swapFrame, swapFrame + 14], [0, 1], {\n easing: Easing.standard,\n extrapolateLeft: "clamp",\n extrapolateRight: "clamp",\n });\n\n const cell = {gridColumn: 1, gridRow: 1} as const;\n\n return (\n <Fill\n style={{\n alignItems: "center",\n gap: 24 * scale,\n justifyContent: "center",\n padding: `${120 * scale}px ${140 * scale}px`,\n }}\n >\n {label ? (\n <div\n style={{\n color: brand.colors.muted,\n fontSize: 34 * scale,\n opacity: interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard}),\n }}\n >\n {label}\n </div>\n ) : null}\n <div style={{display: "grid", placeItems: "center"}}>\n <span\n style={{\n ...cell,\n fontSize: 120 * scale,\n fontWeight: 570,\n letterSpacing: "-0.05em",\n opacity: 1 - out,\n transform: `translateY(${out * -34 * scale}px)`,\n }}\n >\n {from}\n </span>\n <span\n style={{\n ...cell,\n color: brand.colors.accent,\n fontSize: 120 * scale,\n fontWeight: 600,\n letterSpacing: "-0.05em",\n opacity: into,\n transform: `translateY(${(1 - into) * 34 * scale}px)`,\n }}\n >\n {to}\n </span>\n </div>\n </Fill>\n );\n};\n',
|
|
3306
|
+
target: "videos/components/value-swap/value-swap.tsx"
|
|
3307
|
+
},
|
|
3308
|
+
{
|
|
3309
|
+
path: "components/value-swap/value-swap.preview.tsx",
|
|
3310
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {ValueSwap} from "./value-swap";\n\nexport default defineComponentPreview({\n title: "Value swap",\n category: "Typography",\n description: "Replace one label or value while preserving alignment.",\n component: ValueSwap,\n canvas: {width: 1920, height: 1080, duration: "3s"},\n controls: {\n label: {type: "text", defaultValue: "Status"},\n from: {type: "text", defaultValue: "Draft", maxLength: 24},\n to: {type: "text", defaultValue: "Ready", maxLength: 24},\n swapFrame: {type: "number", defaultValue: 45, min: 12, max: 80},\n },\n examples: [\n {name: "Status", props: {label: "Status", from: "Draft", to: "Ready"}},\n {name: "Duration", props: {label: "Render time", from: "8 min", to: "40 s"}},\n ],\n});\n',
|
|
3311
|
+
target: "videos/components/value-swap/value-swap.preview.tsx"
|
|
3312
|
+
}
|
|
3313
|
+
],
|
|
3314
|
+
meta: {
|
|
3315
|
+
kind: "component",
|
|
3316
|
+
family: "Typography",
|
|
3317
|
+
namespaced: "@odori/value-swap",
|
|
3318
|
+
contract: {
|
|
3319
|
+
aspectRatios: [
|
|
3320
|
+
"16:9",
|
|
3321
|
+
"9:16",
|
|
3322
|
+
"1:1"
|
|
3323
|
+
],
|
|
3324
|
+
recommendedDurationInFrames: 90,
|
|
3325
|
+
minimumDurationInFrames: 60,
|
|
3326
|
+
entranceFrames: 14,
|
|
3327
|
+
exitFrames: 0,
|
|
3328
|
+
contentLimits: {
|
|
3329
|
+
from: 24,
|
|
3330
|
+
to: 24
|
|
3331
|
+
},
|
|
3332
|
+
reducedMotion: "the values cross-fade without travel",
|
|
3333
|
+
requires: {
|
|
3334
|
+
fonts: [
|
|
3335
|
+
"sans"
|
|
3336
|
+
],
|
|
3337
|
+
audio: []
|
|
3338
|
+
}
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
},
|
|
3342
|
+
{
|
|
3343
|
+
name: "video-stage",
|
|
3344
|
+
description: "Nested media seeked by frame, never played by clock.",
|
|
3345
|
+
registryDependencies: [],
|
|
3346
|
+
files: [
|
|
3347
|
+
{
|
|
3348
|
+
path: "components/video-stage/video-stage.tsx",
|
|
3349
|
+
content: 'import {Easing, Fill, interpolate, placeholderFrame, useFrame, useReadiness, useVideo} from "odori";\nimport {useEffect, useRef, useState} from "react";\n\nexport type VideoStageProps = {\n /** Clip URL. Without one, a generated placeholder clip plays. */\n src?: string;\n /** Seconds into the clip that frame zero shows. */\n trimStart?: number;\n /** Playback rate relative to the composition\'s clock. */\n rate?: number;\n fit?: "contain" | "cover";\n entranceFrames?: number;\n};\n\n/**\n * Nested media, seeked by frame rather than played by clock.\n *\n * A `<video>` element plays on wall time, which is the one thing a render\n * cannot use: four workers rendering four chunks would each be somewhere\n * different in the clip. So the element is never played. Every frame computes\n * the exact time it wants, seeks there, and holds the composition until the\n * decoder reports it has that picture \u2014 the same handshake an image decode\n * uses, and the reason a nested clip can be scrubbed backwards.\n *\n * With no `src` it draws a generated clip: a placeholder that visibly advances\n * and is still a pure function of the frame, so the component previews with no\n * file in the repository.\n */\nexport const VideoStage = ({src, trimStart = 0, rate = 1, fit = "cover", entranceFrames = 12}: VideoStageProps) => {\n const frame = useFrame();\n const {fps} = useVideo();\n const readiness = useReadiness();\n const element = useRef<HTMLVideoElement | null>(null);\n const [ready, setReady] = useState(false);\n\n const target = trimStart + (frame / fps) * rate;\n\n useEffect(() => {\n const media = element.current;\n if (!media || !src) return;\n\n const release = readiness.hold();\n let cancelled = false;\n const settle = () => {\n if (cancelled) return;\n setReady(true);\n release();\n };\n\n // `seeked` is the only signal that the decoder has the requested picture.\n media.addEventListener("seeked", settle, {once: true});\n media.addEventListener("error", settle, {once: true});\n if (Math.abs(media.currentTime - target) > 0.001) media.currentTime = target;\n else settle();\n\n return () => {\n cancelled = true;\n media.removeEventListener("seeked", settle);\n media.removeEventListener("error", settle);\n release();\n };\n }, [readiness, src, target]);\n\n const appear = interpolate(frame, [0, entranceFrames], [0, 1], {easing: Easing.standard});\n\n if (!src) {\n // The generated clip is a still per frame, which is exactly what a\n // frame-addressed video is anyway.\n return (\n <Fill style={{overflow: "hidden"}}>\n <img\n src={placeholderFrame(frame, {fps, label: "video-stage", seed: "clip"})}\n alt=""\n style={{height: "100%", objectFit: fit, opacity: appear, width: "100%"}}\n />\n </Fill>\n );\n }\n\n return (\n <Fill style={{overflow: "hidden"}}>\n <video\n ref={element}\n src={src}\n muted\n playsInline\n preload="auto"\n style={{height: "100%", objectFit: fit, opacity: ready ? appear : 0, width: "100%"}}\n />\n </Fill>\n );\n};\n',
|
|
3350
|
+
target: "videos/components/video-stage/video-stage.tsx"
|
|
3351
|
+
},
|
|
3352
|
+
{
|
|
3353
|
+
path: "components/video-stage/video-stage.preview.tsx",
|
|
3354
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {VideoStage} from "./video-stage";\n\nexport default defineComponentPreview({\n title: "Video stage",\n category: "Media and canvas",\n description: "Nested media seeked by frame, never played by clock.",\n component: VideoStage,\n canvas: {width: 1920, height: 1080, duration: "5s"},\n controls: {\n trimStart: {type: "number", defaultValue: 0, min: 0, max: 30},\n rate: {type: "number", defaultValue: 1, min: 0.25, max: 3, step: 0.25},\n fit: {type: "select", defaultValue: "cover", options: ["cover", "contain"]},\n },\n examples: [\n {name: "Default", props: {}},\n {name: "Half speed", props: {rate: 0.5}},\n ],\n});\n',
|
|
3355
|
+
target: "videos/components/video-stage/video-stage.preview.tsx"
|
|
3356
|
+
}
|
|
3357
|
+
],
|
|
3358
|
+
meta: {
|
|
3359
|
+
kind: "component",
|
|
3360
|
+
family: "Media and canvas",
|
|
3361
|
+
namespaced: "@odori/video-stage",
|
|
3362
|
+
contract: {
|
|
3363
|
+
aspectRatios: [
|
|
3364
|
+
"16:9",
|
|
3365
|
+
"9:16",
|
|
3366
|
+
"1:1"
|
|
3367
|
+
],
|
|
3368
|
+
recommendedDurationInFrames: 150,
|
|
3369
|
+
minimumDurationInFrames: 30,
|
|
3370
|
+
entranceFrames: 12,
|
|
3371
|
+
exitFrames: 0,
|
|
3372
|
+
contentLimits: {},
|
|
3373
|
+
reducedMotion: "the clip holds its first frame",
|
|
3374
|
+
requires: {
|
|
3375
|
+
fonts: [],
|
|
3376
|
+
audio: []
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
},
|
|
3381
|
+
{
|
|
3382
|
+
name: "whip-pan",
|
|
3383
|
+
description: "A fast directional camera move with a clean settle.",
|
|
3384
|
+
registryDependencies: [],
|
|
3385
|
+
files: [
|
|
3386
|
+
{
|
|
3387
|
+
path: "components/whip-pan/whip-pan.tsx",
|
|
3388
|
+
content: 'import type {ReactNode} from "react";\nimport {Easing, Fill, interpolate, useFrame} from "odori";\n\nexport type WhipPanProps = {\n children?: ReactNode;\n /** Which way the camera travels. */\n direction?: "left" | "right" | "up" | "down";\n /** Frame the pan starts. */\n at?: number;\n /** Frames the whole move takes, including the settle. */\n durationInFrames?: number;\n /** How far the content overshoots, as a fraction of the frame. */\n distance?: number;\n /** Peak motion blur in pixels. Zero renders the move clean. */\n blur?: number;\n};\n\n/**\n * A fast directional move with a clean settle.\n *\n * Two things make a whip pan read as a camera rather than a slide: the speed\n * is not constant \u2014 almost all the distance is covered in the first third \u2014\n * and the frame smears while it travels. The blur is the part people leave\n * out, and without it a fast translate looks like a dropped frame.\n *\n * The settle overshoots slightly and comes back, because a camera stopping\n * dead is the one thing a real one never does.\n */\nexport const WhipPan = ({\n children,\n direction = "left",\n at = 0,\n durationInFrames = 18,\n distance = 1.15,\n blur = 26,\n}: WhipPanProps) => {\n const frame = useFrame();\n const local = frame - at;\n const end = durationInFrames;\n\n // Distance is covered fast, then unwound past zero and settled back.\n const travel = interpolate(local, [0, end * 0.45, end * 0.78, end], [1, 0.12, -0.035, 0], {\n easing: Easing.standard,\n });\n // Blur follows speed, not position: it peaks mid-move and is gone by the\n // settle, which is what keeps the last frame sharp.\n const smear = interpolate(local, [0, end * 0.3, end * 0.7, end], [blur * 0.4, blur, blur * 0.25, 0], {\n easing: Easing.standard,\n });\n\n const horizontal = direction === "left" || direction === "right";\n const sign = direction === "left" || direction === "up" ? 1 : -1;\n const offset = travel * distance * 100 * sign;\n\n return (\n <Fill\n style={{\n filter: smear > 0.5 ? `blur(${horizontal ? smear : 0}px)` : undefined,\n transform: horizontal ? `translateX(${offset}%)` : `translateY(${offset}%)`,\n }}\n >\n {children}\n </Fill>\n );\n};\n',
|
|
3389
|
+
target: "videos/components/whip-pan/whip-pan.tsx"
|
|
3390
|
+
},
|
|
3391
|
+
{
|
|
3392
|
+
path: "components/whip-pan/whip-pan.preview.tsx",
|
|
3393
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {WhipPan} from "./whip-pan";\n\nconst Panel = () => (\n <div\n style={{\n alignItems: "center",\n background: "linear-gradient(135deg, #101010, #050505)",\n color: "#f5f5f5",\n display: "flex",\n flexDirection: "column",\n fontFamily: "ui-sans-serif, system-ui",\n gap: 20,\n inset: 0,\n justifyContent: "center",\n position: "absolute",\n }}\n >\n <strong style={{fontSize: 92, letterSpacing: "-0.04em"}}>Next scene</strong>\n <span style={{color: "#8f8f8f", fontSize: 34}}>arrived on the whip</span>\n </div>\n);\n\nexport default defineComponentPreview({\n title: "Whip pan",\n category: "Motion",\n description: "A fast directional camera move with a clean settle.",\n component: WhipPan,\n canvas: {width: 1920, height: 1080, duration: "2s"},\n controls: {\n direction: {type: "select", defaultValue: "left", options: ["left", "right", "up", "down"]},\n at: {type: "number", defaultValue: 0, min: 0, max: 40},\n durationInFrames: {type: "number", defaultValue: 18, min: 8, max: 45},\n blur: {type: "number", defaultValue: 26, min: 0, max: 60},\n },\n examples: [\n {name: "Default", props: {children: <Panel />}},\n {name: "Upward", props: {direction: "up", children: <Panel />}},\n {name: "No blur", props: {blur: 0, children: <Panel />}},\n ],\n});\n',
|
|
3394
|
+
target: "videos/components/whip-pan/whip-pan.preview.tsx"
|
|
3395
|
+
}
|
|
3396
|
+
],
|
|
3397
|
+
meta: {
|
|
3398
|
+
kind: "component",
|
|
3399
|
+
family: "Motion",
|
|
3400
|
+
namespaced: "@odori/whip-pan",
|
|
3401
|
+
contract: {
|
|
3402
|
+
aspectRatios: [
|
|
3403
|
+
"16:9",
|
|
3404
|
+
"9:16",
|
|
3405
|
+
"1:1"
|
|
3406
|
+
],
|
|
3407
|
+
recommendedDurationInFrames: 45,
|
|
3408
|
+
minimumDurationInFrames: 18,
|
|
3409
|
+
entranceFrames: 18,
|
|
3410
|
+
exitFrames: 0,
|
|
3411
|
+
contentLimits: {},
|
|
3412
|
+
reducedMotion: "the content is settled, with no travel and no blur",
|
|
3413
|
+
requires: {
|
|
3414
|
+
fonts: [],
|
|
3415
|
+
audio: []
|
|
3416
|
+
}
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
3419
|
+
},
|
|
3420
|
+
{
|
|
3421
|
+
name: "whoosh",
|
|
3422
|
+
description: "Swept air across a cut, with a direction.",
|
|
3423
|
+
registryDependencies: [],
|
|
3424
|
+
files: [
|
|
3425
|
+
{
|
|
3426
|
+
path: "components/whoosh/whoosh.tsx",
|
|
3427
|
+
content: 'import {defineCue, gain, highPass, lowPass, mix, noise, normalize, shape, sweep, type CueDefinition} from "odori";\n\nexport type WhooshOptions = {\n /** Where the sweep starts, in hertz. */\n from?: number;\n /** Where it ends. Above `from` rises, below it falls. */\n to?: number;\n /** Peak level before the mix normalizes the whole track. */\n peak?: number;\n};\n\n/**\n * Air moving: filtered noise swept across the cut, with a tone under it for\n * direction. Half a second, which is a transition rather than an event.\n *\n * The noise is band limited on both ends. Unfiltered noise reads as a hiss and\n * sits on top of the picture; taking the extremes off puts it behind.\n */\nexport const whoosh = ({from = 400, to = 5200, peak = 0.6}: WhooshOptions = {}): CueDefinition => {\n const samples = Math.round(48000 * 0.5);\n\n return defineCue({\n name: "ui.whoosh",\n durationInFrames: 15,\n params: {from, to, peak},\n render: () =>\n normalize(\n mix(\n gain(\n shape(lowPass(highPass(noise(samples, 13), 500), 9000), {attack: 0.14, decay: 0.3, sustain: 0}),\n 0.8,\n ),\n // The tone is what gives a whoosh a direction rather than a texture.\n gain(shape(sweep(samples, from, to), {attack: 0.12, decay: 0.34, sustain: 0}), 0.22),\n ),\n peak,\n ),\n });\n};\n',
|
|
3428
|
+
target: "videos/components/whoosh/whoosh.tsx"
|
|
3429
|
+
},
|
|
3430
|
+
{
|
|
3431
|
+
path: "components/whoosh/whoosh.preview.tsx",
|
|
3432
|
+
content: 'import {CueWave, defineComponentPreview} from "odori/preview";\nimport {whoosh, type WhooshOptions} from "./whoosh";\n\nconst Wave = ({from, to, peak}: WhooshOptions) => <CueWave cue={whoosh({from, to, peak})} />;\n\nexport default defineComponentPreview({\n title: "Whoosh",\n category: "Sound",\n description: "Swept air across a cut, with a direction.",\n component: Wave,\n canvas: {width: 1920, height: 1080, duration: "2s"},\n controls: {\n from: {type: "number", defaultValue: 400, min: 100, max: 4000},\n to: {type: "number", defaultValue: 5200, min: 200, max: 12000},\n peak: {type: "number", defaultValue: 0.6, min: 0.1, max: 1, step: 0.05},\n },\n examples: [{name: "Default", props: {}}, {name: "Falling", props: {from: 5200, to: 400}}],\n});\n',
|
|
3433
|
+
target: "videos/components/whoosh/whoosh.preview.tsx"
|
|
3434
|
+
}
|
|
3435
|
+
],
|
|
3436
|
+
meta: {
|
|
3437
|
+
kind: "cue",
|
|
3438
|
+
family: "Sound",
|
|
3439
|
+
namespaced: "@odori/whoosh",
|
|
3440
|
+
contract: {
|
|
3441
|
+
aspectRatios: [
|
|
3442
|
+
"16:9",
|
|
3443
|
+
"9:16",
|
|
3444
|
+
"1:1"
|
|
3445
|
+
],
|
|
3446
|
+
recommendedDurationInFrames: 15,
|
|
3447
|
+
minimumDurationInFrames: 15,
|
|
3448
|
+
entranceFrames: 0,
|
|
3449
|
+
exitFrames: 0,
|
|
3450
|
+
contentLimits: {},
|
|
3451
|
+
reducedMotion: "the waveform renders without a playhead",
|
|
3452
|
+
requires: {
|
|
3453
|
+
fonts: [
|
|
3454
|
+
"mono"
|
|
3455
|
+
],
|
|
3456
|
+
audio: []
|
|
3457
|
+
},
|
|
3458
|
+
loops: false
|
|
3459
|
+
},
|
|
3460
|
+
cue: {
|
|
3461
|
+
name: "ui.whoosh",
|
|
3462
|
+
export: "whoosh"
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
},
|
|
3466
|
+
{
|
|
3467
|
+
name: "word-cascade",
|
|
3468
|
+
description: "Words enter in a paced vertical sequence.",
|
|
3469
|
+
registryDependencies: [],
|
|
3470
|
+
files: [
|
|
3471
|
+
{
|
|
3472
|
+
path: "components/word-cascade/word-cascade.tsx",
|
|
3473
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type WordCascadeProps = {\n /** One word per line, revealed in order. */\n words: string[];\n detail?: string;\n /** Frames between one word and the next. */\n stagger?: number;\n};\n\n/**\n * Words arrive down a vertical column, each one lifting into place while the\n * one above settles. The cascade is derived from the frame, so the rhythm is\n * identical in preview and in the render.\n */\nexport const WordCascade = ({words, detail, stagger}: WordCascadeProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const step = stagger ?? brand.motion.staggerFrames * 4;\n\n return (\n <Fill\n style={{\n alignItems: "flex-start",\n gap: 18 * scale,\n justifyContent: "center",\n padding: `${120 * scale}px ${150 * scale}px`,\n }}\n >\n {words.map((word, index) => {\n const delay = 6 + index * step;\n const progress = interpolate(frame, [delay, delay + 26], [0, 1], {easing: Easing.standard});\n return (\n <div key={`${word}-${index}`} style={{overflow: "hidden", paddingBottom: 10 * scale}}>\n <span\n style={{\n display: "inline-block",\n fontSize: 128 * scale,\n fontWeight: 570,\n letterSpacing: "-0.05em",\n lineHeight: 1,\n opacity: progress,\n transform: `translateY(${(1 - progress) * 96 * scale}px)`,\n }}\n >\n {word}\n </span>\n </div>\n );\n })}\n {detail ? (\n <div\n style={{\n color: brand.colors.muted,\n fontSize: 34 * scale,\n marginTop: 14 * scale,\n opacity: interpolate(frame, [6 + words.length * step, 6 + words.length * step + 20], [0, 1], {\n easing: Easing.standard,\n }),\n }}\n >\n {detail}\n </div>\n ) : null}\n </Fill>\n );\n};\n',
|
|
3474
|
+
target: "videos/components/word-cascade/word-cascade.tsx"
|
|
3475
|
+
},
|
|
3476
|
+
{
|
|
3477
|
+
path: "components/word-cascade/word-cascade.preview.tsx",
|
|
3478
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {WordCascade} from "./word-cascade";\n\nexport default defineComponentPreview({\n title: "Word cascade",\n category: "Typography",\n description: "Words enter in a paced vertical sequence.",\n component: WordCascade,\n canvas: {width: 1920, height: 1080, duration: "4s"},\n controls: {\n detail: {type: "text", defaultValue: "One workflow, every launch."},\n stagger: {type: "number", defaultValue: 12, min: 4, max: 30},\n },\n examples: [\n {name: "Default", props: {words: ["Write.", "Preview.", "Ship."], detail: "One workflow, every launch."}},\n {name: "Two beats", props: {words: ["Author.", "Render."], stagger: 18}},\n ],\n});\n',
|
|
3479
|
+
target: "videos/components/word-cascade/word-cascade.preview.tsx"
|
|
3480
|
+
}
|
|
3481
|
+
],
|
|
3482
|
+
meta: {
|
|
3483
|
+
kind: "component",
|
|
3484
|
+
family: "Typography",
|
|
3485
|
+
namespaced: "@odori/word-cascade",
|
|
3486
|
+
contract: {
|
|
3487
|
+
aspectRatios: [
|
|
3488
|
+
"16:9",
|
|
3489
|
+
"9:16",
|
|
3490
|
+
"1:1"
|
|
3491
|
+
],
|
|
3492
|
+
recommendedDurationInFrames: 120,
|
|
3493
|
+
minimumDurationInFrames: 48,
|
|
3494
|
+
entranceFrames: 30,
|
|
3495
|
+
exitFrames: 10,
|
|
3496
|
+
contentLimits: {
|
|
3497
|
+
words: 5,
|
|
3498
|
+
wordLength: 18
|
|
3499
|
+
},
|
|
3500
|
+
reducedMotion: "all words fade in together",
|
|
3501
|
+
requires: {
|
|
3502
|
+
fonts: [
|
|
3503
|
+
"sans"
|
|
3504
|
+
],
|
|
3505
|
+
audio: []
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
},
|
|
3510
|
+
{
|
|
3511
|
+
name: "workflow-steps",
|
|
3512
|
+
description: "Sequential process with one active focal point.",
|
|
3513
|
+
registryDependencies: [],
|
|
3514
|
+
files: [
|
|
3515
|
+
{
|
|
3516
|
+
path: "components/workflow-steps/workflow-steps.tsx",
|
|
3517
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale, useVideo} from "odori";\n\nexport type WorkflowStepsProps = {\n steps: string[];\n headline?: string;\n /** Frames each step holds the focus. */\n hold?: number;\n};\n\n/**\n * A sequential process with exactly one active step. Completed steps stay on\n * screen at reduced weight, so the viewer keeps the whole path in view while\n * the focus advances.\n */\nexport const WorkflowSteps = ({steps, headline, hold = 40}: WorkflowStepsProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const {width, height} = useVideo();\n const stacked = height >= width;\n const active = Math.min(steps.length - 1, Math.floor(Math.max(0, frame - 14) / hold));\n\n return (\n <Fill style={{gap: 46 * scale, justifyContent: "center", padding: `${110 * scale}px ${120 * scale}px`}}>\n {headline ? (\n <div style={{fontSize: 58 * scale, fontWeight: 560, letterSpacing: "-0.04em", opacity: interpolate(frame, [0, 16], [0, 1])}}>\n {headline}\n </div>\n ) : null}\n <div style={{alignItems: "stretch", display: "flex", flexDirection: stacked ? "column" : "row", gap: 20 * scale}}>\n {steps.map((step, index) => {\n const enter = interpolate(frame, [10 + index * 6, 10 + index * 6 + 18], [0, 1], {easing: Easing.standard});\n const isActive = index === active;\n const done = index < active;\n return (\n <div\n key={step}\n style={{\n alignItems: "center",\n background: isActive ? `color-mix(in srgb, ${brand.colors.accent} 14%, ${brand.colors.surface})` : brand.colors.surface,\n border: `1px solid ${isActive ? brand.colors.accent : brand.colors.border}`,\n borderRadius: 22 * scale,\n display: "flex",\n flex: 1,\n gap: 18 * scale,\n opacity: enter * (done ? 0.6 : 1),\n padding: `${26 * scale}px ${28 * scale}px`,\n transform: `translateY(${(1 - enter) * 16 * scale}px)`,\n }}\n >\n <span\n style={{\n alignItems: "center",\n background: isActive ? brand.colors.accent : "transparent",\n border: `${2 * scale}px solid ${isActive ? brand.colors.accent : brand.colors.border}`,\n borderRadius: 999,\n color: isActive ? brand.colors.background : brand.colors.muted,\n display: "flex",\n fontFamily: brand.typography.mono,\n fontSize: 24 * scale,\n height: 46 * scale,\n justifyContent: "center",\n width: 46 * scale,\n }}\n >\n {index + 1}\n </span>\n <span style={{fontSize: 34 * scale, letterSpacing: "-0.02em"}}>{step}</span>\n </div>\n );\n })}\n </div>\n </Fill>\n );\n};\n',
|
|
3518
|
+
target: "videos/components/workflow-steps/workflow-steps.tsx"
|
|
3519
|
+
},
|
|
3520
|
+
{
|
|
3521
|
+
path: "components/workflow-steps/workflow-steps.preview.tsx",
|
|
3522
|
+
content: 'import {defineComponentPreview} from "odori/preview";\nimport {WorkflowSteps} from "./workflow-steps";\n\nexport default defineComponentPreview({\n title: "Workflow steps",\n category: "Narrative",\n description: "Sequential process with one active focal point.",\n component: WorkflowSteps,\n canvas: {width: 1920, height: 1080, duration: "6s"},\n controls: {\n headline: {type: "text", defaultValue: "From file to finished cut"},\n hold: {type: "number", defaultValue: 40, min: 16, max: 90},\n },\n examples: [\n {name: "Pipeline", props: {steps: ["Author", "Preview", "Test", "Export"]}},\n ],\n});\n',
|
|
3523
|
+
target: "videos/components/workflow-steps/workflow-steps.preview.tsx"
|
|
3524
|
+
}
|
|
3525
|
+
],
|
|
3526
|
+
meta: {
|
|
3527
|
+
kind: "component",
|
|
3528
|
+
family: "Narrative",
|
|
3529
|
+
namespaced: "@odori/workflow-steps",
|
|
3530
|
+
contract: {
|
|
3531
|
+
aspectRatios: [
|
|
3532
|
+
"16:9",
|
|
3533
|
+
"9:16",
|
|
3534
|
+
"1:1"
|
|
3535
|
+
],
|
|
3536
|
+
recommendedDurationInFrames: 180,
|
|
3537
|
+
minimumDurationInFrames: 90,
|
|
3538
|
+
entranceFrames: 30,
|
|
3539
|
+
exitFrames: 0,
|
|
3540
|
+
contentLimits: {
|
|
3541
|
+
steps: 5,
|
|
3542
|
+
stepLength: 26
|
|
3543
|
+
},
|
|
3544
|
+
reducedMotion: "every step shows without a moving focus",
|
|
3545
|
+
requires: {
|
|
3546
|
+
fonts: [
|
|
3547
|
+
"sans",
|
|
3548
|
+
"mono"
|
|
3549
|
+
],
|
|
3550
|
+
audio: []
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
3553
|
+
}
|
|
3554
|
+
}
|
|
3555
|
+
]
|
|
3556
|
+
};
|
|
3557
|
+
export {
|
|
3558
|
+
registry_snapshot_default as default
|
|
3559
|
+
};
|