@odori/cli 0.0.2 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-7XJL2BYO.js → chunk-NYXWEZU2.js} +717 -348
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +63 -8
- package/dist/index.js +3 -3
- package/dist/registry-snapshot-MSH2EA36.js +4867 -0
- package/package.json +3 -3
- package/src/assets.ts +90 -0
- package/src/brand-file.ts +16 -4
- package/src/chunk-cache.ts +6 -0
- package/src/cli.ts +22 -12
- package/src/commands/add.ts +33 -8
- package/src/commands/dev.ts +114 -12
- package/src/commands/doctor.ts +47 -2
- package/src/commands/exportVideo.ts +39 -5
- package/src/commands/{still.ts → frame.ts} +23 -9
- package/src/commands/init.ts +1 -1
- package/src/commands/new.ts +1 -1
- package/src/cues.ts +34 -21
- package/src/discovery.ts +85 -5
- package/src/formats.ts +67 -8
- package/src/index.ts +1 -1
- package/src/jobs.ts +6 -2
- package/src/registry-snapshot.json +1530 -328
- package/src/registry-source.ts +87 -5
- package/src/render.ts +48 -13
- package/src/server.ts +55 -13
- package/studio/src/Studio.tsx +19 -22
- package/studio/src/components/ExportPanel.tsx +124 -90
- package/studio/src/components/Inspector.tsx +221 -0
- package/studio/src/components/Navigator.tsx +145 -0
- package/studio/src/components/Thumbnail.tsx +65 -23
- package/studio/src/components/ui.tsx +9 -2
- package/studio/src/lib/highlight.ts +85 -0
- package/studio/src/studio.css +435 -20
- package/studio/src/views/AssetsView.tsx +14 -1
- package/studio/src/views/BrandsView.tsx +74 -26
- package/studio/src/views/ComponentsView.tsx +206 -55
- package/studio/src/views/HomeView.tsx +44 -19
- package/studio/src/views/VideosView.tsx +53 -48
- package/studio/src/virtual.d.ts +4 -1
- package/dist/registry-snapshot-NIH2JMQ6.js +0 -3559
|
@@ -10,13 +10,15 @@ import {
|
|
|
10
10
|
type Brand,
|
|
11
11
|
type VideoEntry,
|
|
12
12
|
} from "odori";
|
|
13
|
-
import {PreviewBoundary} from "odori/preview";
|
|
13
|
+
import {PreviewBoundary, categoryFromPath, categoryPath, familyOf, groupOf} from "odori/preview";
|
|
14
14
|
import {componentPreviews, project, videos} from "virtual:odori-project";
|
|
15
15
|
import {CanvasStage} from "../components/CanvasStage";
|
|
16
16
|
import {Transport} from "../components/Transport";
|
|
17
17
|
import {Thumbnail} from "../components/Thumbnail";
|
|
18
18
|
import {InputControls} from "../components/InputControls";
|
|
19
|
-
import {
|
|
19
|
+
import {Button, Empty, Fact, SectionTitle, Separator} from "../components/ui";
|
|
20
|
+
import {Inspector} from "../components/Inspector";
|
|
21
|
+
import {Navigator} from "../components/Navigator";
|
|
20
22
|
import {useShortcuts} from "../shortcuts";
|
|
21
23
|
|
|
22
24
|
type PreviewEntry = (typeof componentPreviews)[number];
|
|
@@ -31,11 +33,93 @@ const projectBrands = (): Brand[] => {
|
|
|
31
33
|
return [...seen.values()];
|
|
32
34
|
};
|
|
33
35
|
|
|
34
|
-
|
|
36
|
+
// The reading order of the catalog: what you author, then what you show, then
|
|
37
|
+
// what carries it. A family missing here sorts last, alphabetically.
|
|
38
|
+
/**
|
|
39
|
+
* The reading order of the catalog. A category nests, so this lists families
|
|
40
|
+
* and the groups inside them sort alphabetically underneath: the order that
|
|
41
|
+
* matters is which family you meet first, not which of its drawers.
|
|
42
|
+
*/
|
|
43
|
+
const FAMILY_ORDER = [
|
|
44
|
+
"Typography",
|
|
45
|
+
"Developer proof",
|
|
46
|
+
"Interface",
|
|
47
|
+
"Agents",
|
|
48
|
+
"Products",
|
|
49
|
+
"Narrative",
|
|
50
|
+
"Data",
|
|
51
|
+
"Media",
|
|
52
|
+
"Motion",
|
|
53
|
+
"Brand",
|
|
54
|
+
"Foundation",
|
|
55
|
+
"Sound",
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Where a family sorts.
|
|
60
|
+
*
|
|
61
|
+
* The listed ones are the registry's, in reading order. A project's own
|
|
62
|
+
* families are not in that list and must not all collapse to the same rank:
|
|
63
|
+
* they sort alphabetically after, so somebody who writes their own categories
|
|
64
|
+
* gets a stable order rather than whatever the file walk happened to produce.
|
|
65
|
+
*/
|
|
66
|
+
/**
|
|
67
|
+
* Where a component belongs.
|
|
68
|
+
*
|
|
69
|
+
* The fixture wins when it says, because a registry component is copied into
|
|
70
|
+
* somebody's tree, lands flat, and has to keep the family it was written for.
|
|
71
|
+
* Otherwise the directory answers, which is how a project organises its own
|
|
72
|
+
* work: move the folder and it moves in Studio, with nothing to keep in sync
|
|
73
|
+
* and no group that exists only because somebody mistyped one. A directory
|
|
74
|
+
* names itself in a `category.json` beside its components, which is the one
|
|
75
|
+
* thing a filesystem cannot say.
|
|
76
|
+
*/
|
|
77
|
+
/** What a directory calls itself, from its own `category.json`. */
|
|
78
|
+
const declared = new Map(project.categories.map((entry) => [entry.path, entry]));
|
|
79
|
+
|
|
80
|
+
export const categoryOf = (entry: PreviewEntry): string => {
|
|
81
|
+
if (entry.preview.category) return entry.preview.category;
|
|
82
|
+
const file = project.files.previews.find((item) => item.id === entry.id)?.file;
|
|
83
|
+
const derived = file
|
|
84
|
+
? categoryFromPath(file, project.componentsDir.split("/").pop(), (path) => declared.get(path)?.name)
|
|
85
|
+
: "";
|
|
86
|
+
return derived || "Uncategorised";
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Where a family sorts, when a project has said so.
|
|
91
|
+
*
|
|
92
|
+
* `category.json` carries an order, which is the only way a project can put
|
|
93
|
+
* its own families in a sensible sequence: the list above is ours, and a
|
|
94
|
+
* hardcoded list of somebody else's families is no use to them.
|
|
95
|
+
*/
|
|
96
|
+
const declaredOrder = (family: string): number | undefined => {
|
|
97
|
+
for (const entry of project.categories) {
|
|
98
|
+
if (entry.order === undefined || entry.path.includes("/")) continue;
|
|
99
|
+
const label = entry.name ?? entry.path.replace(/-/g, " ").replace(/^./, (letter) => letter.toUpperCase());
|
|
100
|
+
if (label === family) return entry.order;
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const familyRank = (family: string): number => {
|
|
106
|
+
// A project's own order leads, then ours, then whatever is left, which
|
|
107
|
+
// sorts alphabetically. Declared orders go negative so they rank ahead.
|
|
108
|
+
const own = declaredOrder(family);
|
|
109
|
+
if (own !== undefined) return own - 1000;
|
|
110
|
+
const known = FAMILY_ORDER.indexOf(family);
|
|
111
|
+
return known === -1 ? FAMILY_ORDER.length : known;
|
|
112
|
+
};
|
|
35
113
|
|
|
36
114
|
const byFamily = (left: PreviewEntry, right: PreviewEntry) => {
|
|
37
|
-
const
|
|
38
|
-
|
|
115
|
+
const leftFamily = familyOf(categoryOf(left));
|
|
116
|
+
const rightFamily = familyOf(categoryOf(right));
|
|
117
|
+
const delta = familyRank(leftFamily) - familyRank(rightFamily);
|
|
118
|
+
if (delta !== 0) return delta;
|
|
119
|
+
const family = leftFamily.localeCompare(rightFamily);
|
|
120
|
+
if (family !== 0) return family;
|
|
121
|
+
const group = groupOf(categoryOf(left)).localeCompare(groupOf(categoryOf(right)));
|
|
122
|
+
return group !== 0 ? group : left.preview.title.localeCompare(right.preview.title);
|
|
39
123
|
};
|
|
40
124
|
|
|
41
125
|
const FORMATS = [
|
|
@@ -69,15 +153,33 @@ export const entryFor = (
|
|
|
69
153
|
export const ComponentsView = ({
|
|
70
154
|
selection,
|
|
71
155
|
onSelect,
|
|
72
|
-
mode,
|
|
73
156
|
}: {
|
|
74
157
|
selection: string | null;
|
|
75
158
|
onSelect: (id: string) => void;
|
|
76
|
-
mode: "player" | "gallery";
|
|
77
159
|
}) => {
|
|
78
|
-
// Finding one by name is ⌘K;
|
|
79
|
-
|
|
80
|
-
const
|
|
160
|
+
// Finding one by name is ⌘K; the gallery is for browsing them all. A route
|
|
161
|
+
// with no selection is the gallery; a selection is that component's player.
|
|
162
|
+
const [usedIn, setUsedIn] = useState<string | null>(null);
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Which video a component appears in, derived from the imports rather than
|
|
166
|
+
* declared, so it cannot disagree with the composition. A project of a
|
|
167
|
+
* hundred components is otherwise a wall with no way in.
|
|
168
|
+
*/
|
|
169
|
+
const usage = useMemo(
|
|
170
|
+
() => Object.fromEntries(project.files.previews.map((entry) => [entry.id, entry.usedBy])),
|
|
171
|
+
[],
|
|
172
|
+
);
|
|
173
|
+
const videosUsing = useMemo(
|
|
174
|
+
() => [...new Set(Object.values(usage).flat())].sort(),
|
|
175
|
+
[usage],
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const filtered = useMemo(() => {
|
|
179
|
+
const all = [...componentPreviews].sort(byFamily);
|
|
180
|
+
return usedIn ? all.filter((item) => (usage[item.id] ?? []).includes(usedIn)) : all;
|
|
181
|
+
}, [usedIn, usage]);
|
|
182
|
+
const selected = selection ? (filtered.find((entry) => entry.id === selection) ?? null) : null;
|
|
81
183
|
const brands = projectBrands();
|
|
82
184
|
const [brandName, setBrandName] = useState(brands[0]?.name ?? defaultBrand.name);
|
|
83
185
|
const [formatLabel, setFormatLabel] = useState(FORMATS[0].label);
|
|
@@ -110,34 +212,71 @@ export const ComponentsView = ({
|
|
|
110
212
|
l: () => setLoop((value) => !value),
|
|
111
213
|
});
|
|
112
214
|
|
|
113
|
-
if (
|
|
215
|
+
if (filtered.length === 0) {
|
|
114
216
|
return (
|
|
115
217
|
<Empty title="No component previews">
|
|
116
|
-
Add a sibling <code>*.preview.tsx</code> file, or run <code>odori add
|
|
218
|
+
Add a sibling <code>*.preview.tsx</code> file, or run <code>odori add title-reveal</code>.
|
|
117
219
|
</Empty>
|
|
118
220
|
);
|
|
119
221
|
}
|
|
120
222
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
223
|
+
if (!selected) {
|
|
224
|
+
/*
|
|
225
|
+
* Two levels, because a category is a path. Rendering one section per
|
|
226
|
+
* full path printed the family again for every drawer inside it -
|
|
227
|
+
* Interface, Interface, Interface - which is a list pretending to be an
|
|
228
|
+
* outline. `filtered` is already sorted by family, then group, then
|
|
229
|
+
* title, so walking it in order is enough to build the tree.
|
|
230
|
+
*/
|
|
231
|
+
const families: Array<{name: string; groups: Array<{name: string; entries: PreviewEntry[]}>}> = [];
|
|
232
|
+
for (const item of filtered) {
|
|
233
|
+
const familyName = familyOf(categoryOf(item));
|
|
234
|
+
const groupName = groupOf(categoryOf(item));
|
|
235
|
+
let family = families.at(-1);
|
|
236
|
+
if (!family || family.name !== familyName) {
|
|
237
|
+
family = {name: familyName, groups: []};
|
|
238
|
+
families.push(family);
|
|
239
|
+
}
|
|
240
|
+
let group = family.groups.at(-1);
|
|
241
|
+
if (!group || group.name !== groupName) {
|
|
242
|
+
group = {name: groupName, entries: []};
|
|
243
|
+
family.groups.push(group);
|
|
244
|
+
}
|
|
245
|
+
group.entries.push(item);
|
|
246
|
+
}
|
|
134
247
|
return (
|
|
135
248
|
<div style={{overflowY: "auto", width: "100%"}}>
|
|
136
|
-
{
|
|
137
|
-
<
|
|
138
|
-
<
|
|
139
|
-
|
|
140
|
-
|
|
249
|
+
{videosUsing.length > 0 ? (
|
|
250
|
+
<div className="catalog-filter">
|
|
251
|
+
<button type="button" data-active={usedIn === null ? "true" : undefined} onClick={() => setUsedIn(null)}>
|
|
252
|
+
All
|
|
253
|
+
</button>
|
|
254
|
+
{videosUsing.map((video) => (
|
|
255
|
+
<button
|
|
256
|
+
key={video}
|
|
257
|
+
type="button"
|
|
258
|
+
data-active={usedIn === video ? "true" : undefined}
|
|
259
|
+
onClick={() => setUsedIn(video)}
|
|
260
|
+
>
|
|
261
|
+
{video}
|
|
262
|
+
</button>
|
|
263
|
+
))}
|
|
264
|
+
</div>
|
|
265
|
+
) : null}
|
|
266
|
+
{families.map((family) => (
|
|
267
|
+
<section key={family.name} style={{padding: "16px 20px 0"}}>
|
|
268
|
+
<SectionTitle>{family.name}</SectionTitle>
|
|
269
|
+
{family.groups.map((group) => (
|
|
270
|
+
<div key={group.name || "."}>
|
|
271
|
+
{/* A family whose components sit at its top level has one
|
|
272
|
+
unnamed group, and no heading to draw for it. */}
|
|
273
|
+
{/* Any depth renders: the family is the heading, and whatever
|
|
274
|
+
a project nests below it reads as one path. Two levels is
|
|
275
|
+
our own editorial rule for our registry, not a limit the
|
|
276
|
+
framework puts on anybody else's. */}
|
|
277
|
+
{group.name ? <h3 className="group-title">{group.name.split("/").join(" · ")}</h3> : null}
|
|
278
|
+
<div className="gallery" style={{padding: "0 0 8px"}}>
|
|
279
|
+
{group.entries.map((item) => (
|
|
141
280
|
<button key={item.id} type="button" className="card" onClick={() => onSelect(item.id)}>
|
|
142
281
|
<Thumbnail
|
|
143
282
|
entry={entryFor(
|
|
@@ -159,35 +298,47 @@ export const ComponentsView = ({
|
|
|
159
298
|
<span>{item.id}</span>
|
|
160
299
|
</div>
|
|
161
300
|
</button>
|
|
162
|
-
|
|
163
|
-
|
|
301
|
+
))}
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
))}
|
|
164
305
|
</section>
|
|
165
306
|
))}
|
|
166
307
|
</div>
|
|
167
308
|
);
|
|
168
309
|
}
|
|
169
310
|
|
|
311
|
+
const preview = selected.preview;
|
|
312
|
+
const example = preview.examples.find((item) => item.name === exampleName) ?? preview.examples[0];
|
|
313
|
+
const controlDefaults = Object.fromEntries(
|
|
314
|
+
Object.entries(preview.controls ?? {}).map(([name, field]) => [name, field.defaultValue]),
|
|
315
|
+
);
|
|
316
|
+
const props = {...controlDefaults, ...(example?.props ?? {}), ...overrides};
|
|
317
|
+
const entry = entryFor(selected, props, brand, format);
|
|
318
|
+
|
|
319
|
+
const previewFile =
|
|
320
|
+
project.files.previews.find((entry) => entry.id === selected.id)?.file ??
|
|
321
|
+
`videos/components/${selected.id}/${selected.id}.preview.tsx`;
|
|
322
|
+
// What it draws comes before the fixture that plays it: the fixture is the
|
|
323
|
+
// harness, and reading only the harness answers the wrong question.
|
|
324
|
+
const componentFile = previewFile.replace(/\.preview\.tsx$/, ".tsx");
|
|
325
|
+
const sources = componentFile === previewFile ? [previewFile] : [componentFile, previewFile];
|
|
326
|
+
|
|
170
327
|
return (
|
|
171
328
|
<>
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
</li>
|
|
186
|
-
))}
|
|
187
|
-
</ul>
|
|
188
|
-
|
|
189
|
-
</div>
|
|
190
|
-
</aside>
|
|
329
|
+
{/* `filtered` is already sorted family, group, title, so the list reads
|
|
330
|
+
in the same order the gallery does. */}
|
|
331
|
+
<Navigator
|
|
332
|
+
label="Components"
|
|
333
|
+
selected={selected.id}
|
|
334
|
+
onSelect={onSelect}
|
|
335
|
+
items={filtered.map((item) => ({
|
|
336
|
+
id: item.id,
|
|
337
|
+
title: item.preview.title,
|
|
338
|
+
detail: item.id,
|
|
339
|
+
group: categoryPath(categoryOf(item)).join(" · "),
|
|
340
|
+
}))}
|
|
341
|
+
/>
|
|
191
342
|
|
|
192
343
|
<section className="stage">
|
|
193
344
|
<CanvasStage width={format.width} height={format.height}>
|
|
@@ -210,7 +361,7 @@ export const ComponentsView = ({
|
|
|
210
361
|
/>
|
|
211
362
|
</section>
|
|
212
363
|
|
|
213
|
-
<
|
|
364
|
+
<Inspector title={preview.title} hint={componentFile} source={sources}>
|
|
214
365
|
<SectionTitle>Examples</SectionTitle>
|
|
215
366
|
<ul className="list">
|
|
216
367
|
{preview.examples.map((item) => (
|
|
@@ -233,7 +384,7 @@ export const ComponentsView = ({
|
|
|
233
384
|
|
|
234
385
|
<SectionTitle>Component</SectionTitle>
|
|
235
386
|
<dl className="facts">
|
|
236
|
-
<Fact label="category">{
|
|
387
|
+
<Fact label="category">{categoryPath(categoryOf(selected)).join(" · ")}</Fact>
|
|
237
388
|
<Fact label="canvas">
|
|
238
389
|
{preview.canvas.width}x{preview.canvas.height} · {String(preview.canvas.duration)}
|
|
239
390
|
</Fact>
|
|
@@ -269,7 +420,7 @@ export const ComponentsView = ({
|
|
|
269
420
|
{item.name}
|
|
270
421
|
</Button>
|
|
271
422
|
))}
|
|
272
|
-
|
|
423
|
+
|
|
273
424
|
</div>
|
|
274
425
|
|
|
275
426
|
<Separator />
|
|
@@ -279,7 +430,7 @@ export const ComponentsView = ({
|
|
|
279
430
|
) : (
|
|
280
431
|
<p className="hint">This preview declares no controls. Add them to vary props in Studio.</p>
|
|
281
432
|
)}
|
|
282
|
-
</
|
|
433
|
+
</Inspector>
|
|
283
434
|
</>
|
|
284
435
|
);
|
|
285
436
|
};
|
|
@@ -4,7 +4,8 @@ import {Thumbnail} from "../components/Thumbnail";
|
|
|
4
4
|
import {AudioClip} from "../components/AudioClip";
|
|
5
5
|
import {SectionTitle} from "../components/ui";
|
|
6
6
|
import {collectBrands} from "./BrandsView";
|
|
7
|
-
import {entryFor} from "./ComponentsView";
|
|
7
|
+
import {categoryOf, entryFor} from "./ComponentsView";
|
|
8
|
+
import {familyOf} from "odori/preview";
|
|
8
9
|
import type {StudioView} from "../Studio";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -14,19 +15,26 @@ import type {StudioView} from "../Studio";
|
|
|
14
15
|
* is in here", which is the question you have on arrival, so each card plays a
|
|
15
16
|
* frame of the real composition and opens the view that owns it.
|
|
16
17
|
*/
|
|
18
|
+
/** Cards per section here. Home is an overview; each tab shows everything. */
|
|
19
|
+
const SHOWN = 6;
|
|
20
|
+
|
|
17
21
|
export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: string) => void}) => {
|
|
18
22
|
const brands = collectBrands();
|
|
19
23
|
const brand = brands[0];
|
|
20
|
-
const fonts = [
|
|
21
|
-
...new Map(videos.flatMap((video) => resolveEntryLayout(video).brand.fonts).map((font) => [font.url, font])).values(),
|
|
22
|
-
];
|
|
23
24
|
|
|
24
25
|
return (
|
|
25
26
|
<div className="home">
|
|
26
27
|
<section>
|
|
27
|
-
<
|
|
28
|
+
<div className="section-head">
|
|
29
|
+
<SectionTitle>Videos</SectionTitle>
|
|
30
|
+
{videos.length > SHOWN ? (
|
|
31
|
+
<button type="button" className="view-all" onClick={() => onOpen("videos")}>
|
|
32
|
+
View all →
|
|
33
|
+
</button>
|
|
34
|
+
) : null}
|
|
35
|
+
</div>
|
|
28
36
|
<div className="gallery">
|
|
29
|
-
{videos.map((video) => {
|
|
37
|
+
{videos.slice(0, SHOWN).map((video) => {
|
|
30
38
|
const layout = resolveEntryLayout(video);
|
|
31
39
|
return (
|
|
32
40
|
<button key={video.metadata.id} type="button" className="card" onClick={() => onOpen("videos", video.metadata.id)}>
|
|
@@ -34,12 +42,11 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
|
|
|
34
42
|
entry={video}
|
|
35
43
|
frame={video.metadata.thumbnailFrame ?? Math.round(entryDurationInFrames(video, layout) * 0.4)}
|
|
36
44
|
durationInFrames={entryDurationInFrames(video, layout)}
|
|
45
|
+
audioBadge
|
|
37
46
|
/>
|
|
38
47
|
<div className="card-meta">
|
|
39
48
|
<strong>{video.metadata.title}</strong>
|
|
40
|
-
<span>
|
|
41
|
-
{layout.format.width}x{layout.format.height} · {video.metadata.id}
|
|
42
|
-
</span>
|
|
49
|
+
<span>{video.metadata.id}</span>
|
|
43
50
|
</div>
|
|
44
51
|
</button>
|
|
45
52
|
);
|
|
@@ -48,9 +55,16 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
|
|
|
48
55
|
</section>
|
|
49
56
|
|
|
50
57
|
<section>
|
|
51
|
-
<
|
|
58
|
+
<div className="section-head">
|
|
59
|
+
<SectionTitle>Components</SectionTitle>
|
|
60
|
+
{componentPreviews.length > SHOWN ? (
|
|
61
|
+
<button type="button" className="view-all" onClick={() => onOpen("components")}>
|
|
62
|
+
View all →
|
|
63
|
+
</button>
|
|
64
|
+
) : null}
|
|
65
|
+
</div>
|
|
52
66
|
<div className="gallery">
|
|
53
|
-
{componentPreviews.map((item) => {
|
|
67
|
+
{componentPreviews.slice(0, SHOWN).map((item) => {
|
|
54
68
|
const defaults = Object.fromEntries(
|
|
55
69
|
Object.entries(item.preview.controls ?? {}).map(([name, field]) => [name, field.defaultValue]),
|
|
56
70
|
);
|
|
@@ -69,7 +83,9 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
|
|
|
69
83
|
/>
|
|
70
84
|
<div className="card-meta">
|
|
71
85
|
<strong>{item.preview.title}</strong>
|
|
72
|
-
|
|
86
|
+
{/* The home card names the family, not the whole path: it
|
|
87
|
+
is a glance, not a filing cabinet. */}
|
|
88
|
+
<span>{familyOf(categoryOf(item)).toLowerCase() || "component"}</span>
|
|
73
89
|
</div>
|
|
74
90
|
</button>
|
|
75
91
|
);
|
|
@@ -78,14 +94,21 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
|
|
|
78
94
|
</section>
|
|
79
95
|
|
|
80
96
|
<section>
|
|
81
|
-
<
|
|
97
|
+
<div className="section-head">
|
|
98
|
+
<SectionTitle>Brands</SectionTitle>
|
|
99
|
+
<button type="button" className="view-all" onClick={() => onOpen("brands")}>
|
|
100
|
+
View all →
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
82
103
|
<div className="home-swatches">
|
|
83
104
|
{brands.map((item) => (
|
|
84
105
|
<button key={item.name} type="button" className="brand-card" onClick={() => onOpen("brands", item.name)}>
|
|
85
106
|
<div className="swatch-colors">
|
|
107
|
+
{/* Keyed by position: two tokens can resolve to one color,
|
|
108
|
+
and a color used twice is not one child twice. */}
|
|
86
109
|
{[item.colors.background, item.colors.surface, item.colors.foreground, item.colors.accent, item.colors.border].map(
|
|
87
|
-
(color) => (
|
|
88
|
-
<span key={
|
|
110
|
+
(color, position) => (
|
|
111
|
+
<span key={position} style={{background: color}} />
|
|
89
112
|
),
|
|
90
113
|
)}
|
|
91
114
|
</div>
|
|
@@ -101,7 +124,12 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
|
|
|
101
124
|
</section>
|
|
102
125
|
|
|
103
126
|
<section>
|
|
104
|
-
<
|
|
127
|
+
<div className="section-head">
|
|
128
|
+
<SectionTitle>Assets</SectionTitle>
|
|
129
|
+
<button type="button" className="view-all" onClick={() => onOpen("assets")}>
|
|
130
|
+
View all →
|
|
131
|
+
</button>
|
|
132
|
+
</div>
|
|
105
133
|
<ul className="asset-list">
|
|
106
134
|
{project.audio.slice(0, 4).map((entry) => (
|
|
107
135
|
<li key={entry.url}>
|
|
@@ -113,9 +141,6 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
|
|
|
113
141
|
</li>
|
|
114
142
|
))}
|
|
115
143
|
</ul>
|
|
116
|
-
<button type="button" className="home-more" onClick={() => onOpen("assets")}>
|
|
117
|
-
{project.assets.length} declared assets · {project.audio.length} sounds · {fonts.length} fonts
|
|
118
|
-
</button>
|
|
119
144
|
</section>
|
|
120
145
|
</div>
|
|
121
146
|
);
|
|
@@ -19,22 +19,34 @@ import {Thumbnail} from "../components/Thumbnail";
|
|
|
19
19
|
import {InputControls} from "../components/InputControls";
|
|
20
20
|
import {ExportPanel} from "../components/ExportPanel";
|
|
21
21
|
import {Diagnostics} from "../components/Diagnostics";
|
|
22
|
-
import {
|
|
22
|
+
import {Inspector} from "../components/Inspector";
|
|
23
|
+
import {Navigator} from "../components/Navigator";
|
|
24
|
+
import {Badge, Empty, Fact, SectionTitle, Separator} from "../components/ui";
|
|
23
25
|
import {measureTrack} from "../lib/mix-loudness";
|
|
24
26
|
import {useShortcuts} from "../shortcuts";
|
|
25
27
|
|
|
28
|
+
/**
|
|
29
|
+
* What a cue is called in the mix, not what its file is called on disk. A
|
|
30
|
+
* generated cue's filename carries a 16-character content hash that means
|
|
31
|
+
* nothing while mixing and is wide enough to collide with the column beside
|
|
32
|
+
* it; the full filename stays one hover away.
|
|
33
|
+
*/
|
|
34
|
+
const cueLabel = (src: string): string => {
|
|
35
|
+
const file = src.split("/").pop() ?? src;
|
|
36
|
+
return file.replace(/-[0-9a-f]{16}\.wav$/, "");
|
|
37
|
+
};
|
|
38
|
+
|
|
26
39
|
export const VideosView = ({
|
|
27
40
|
selection,
|
|
28
41
|
onSelect,
|
|
29
|
-
mode,
|
|
30
42
|
}: {
|
|
31
43
|
selection: string | null;
|
|
32
44
|
onSelect: (id: string) => void;
|
|
33
|
-
mode: "player" | "gallery";
|
|
34
45
|
}) => {
|
|
35
|
-
// Finding one by name is ⌘K;
|
|
46
|
+
// Finding one by name is ⌘K; the gallery is for browsing them all. A route
|
|
47
|
+
// with no selection is the gallery; a selection is that video's player.
|
|
36
48
|
const filtered = videos;
|
|
37
|
-
const selected = filtered.find((video) => video.metadata.id === selection) ??
|
|
49
|
+
const selected = selection ? (filtered.find((video) => video.metadata.id === selection) ?? null) : null;
|
|
38
50
|
const [input, setInput] = useState<Record<string, unknown>>({});
|
|
39
51
|
const [timeline, setTimeline] = useState<CompiledTimeline | null>(null);
|
|
40
52
|
const [track, setTrack] = useState<AudioTrack | null>(null);
|
|
@@ -150,7 +162,7 @@ export const VideosView = ({
|
|
|
150
162
|
s: () => setShowSafeArea((value) => !value),
|
|
151
163
|
});
|
|
152
164
|
|
|
153
|
-
if (
|
|
165
|
+
if (videos.length === 0) {
|
|
154
166
|
return (
|
|
155
167
|
<Empty title="No videos yet">
|
|
156
168
|
Create <code>{project.videosDir}/launch/video.tsx</code> or run <code>odori new launch</code>. A video exists
|
|
@@ -159,7 +171,7 @@ export const VideosView = ({
|
|
|
159
171
|
);
|
|
160
172
|
}
|
|
161
173
|
|
|
162
|
-
if (
|
|
174
|
+
if (!selected || !layout) {
|
|
163
175
|
return (
|
|
164
176
|
<div className="gallery">
|
|
165
177
|
{filtered.map((video) => {
|
|
@@ -169,19 +181,17 @@ export const VideosView = ({
|
|
|
169
181
|
key={video.metadata.id}
|
|
170
182
|
type="button"
|
|
171
183
|
className="card"
|
|
172
|
-
data-active={video.metadata.id === selected.metadata.id ? "true" : undefined}
|
|
173
184
|
onClick={() => onSelect(video.metadata.id)}
|
|
174
185
|
>
|
|
175
186
|
<Thumbnail
|
|
176
187
|
entry={video}
|
|
177
188
|
frame={video.metadata.thumbnailFrame ?? Math.round(entryDurationInFrames(video, videoLayout) * 0.4)}
|
|
178
189
|
durationInFrames={entryDurationInFrames(video, videoLayout)}
|
|
190
|
+
audioBadge
|
|
179
191
|
/>
|
|
180
192
|
<div className="card-meta">
|
|
181
193
|
<strong>{video.metadata.title}</strong>
|
|
182
|
-
<span>
|
|
183
|
-
{videoLayout.format.width}x{videoLayout.format.height} · {String(video.metadata.duration ?? "auto")}
|
|
184
|
-
</span>
|
|
194
|
+
<span>{String(video.metadata.duration ?? "auto")}</span>
|
|
185
195
|
</div>
|
|
186
196
|
</button>
|
|
187
197
|
);
|
|
@@ -196,27 +206,24 @@ export const VideosView = ({
|
|
|
196
206
|
|
|
197
207
|
return (
|
|
198
208
|
<>
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
</div>
|
|
219
|
-
</aside>
|
|
209
|
+
{/* A video id is a path under videos/, so the folder is the grouping a
|
|
210
|
+
project already chose: social/announcement sits under social. */}
|
|
211
|
+
<Navigator
|
|
212
|
+
label="Videos"
|
|
213
|
+
selected={selected.metadata.id}
|
|
214
|
+
onSelect={onSelect}
|
|
215
|
+
items={[...filtered]
|
|
216
|
+
.sort((left, right) => left.metadata.id.localeCompare(right.metadata.id))
|
|
217
|
+
.map((video) => {
|
|
218
|
+
const parts = video.metadata.id.split("/");
|
|
219
|
+
return {
|
|
220
|
+
id: video.metadata.id,
|
|
221
|
+
title: video.metadata.title,
|
|
222
|
+
detail: String(video.metadata.duration ?? "auto"),
|
|
223
|
+
group: parts.length > 1 ? parts.slice(0, -1).join(" · ") : undefined,
|
|
224
|
+
};
|
|
225
|
+
})}
|
|
226
|
+
/>
|
|
220
227
|
|
|
221
228
|
<section className="stage">
|
|
222
229
|
<CanvasStage
|
|
@@ -259,7 +266,18 @@ export const VideosView = ({
|
|
|
259
266
|
/>
|
|
260
267
|
</section>
|
|
261
268
|
|
|
262
|
-
<
|
|
269
|
+
<Inspector title={selected.metadata.title} hint={file} source={file}>
|
|
270
|
+
{/* Export sits first: it is the pane's one action, and the header
|
|
271
|
+
names what is being worked on right above it. */}
|
|
272
|
+
<ExportPanel
|
|
273
|
+
videoId={selected.metadata.id}
|
|
274
|
+
input={input}
|
|
275
|
+
frame={playback.frame}
|
|
276
|
+
width={layout.format.width}
|
|
277
|
+
height={layout.format.height}
|
|
278
|
+
/>
|
|
279
|
+
<Separator />
|
|
280
|
+
|
|
263
281
|
<SectionTitle>Video</SectionTitle>
|
|
264
282
|
<dl className="facts">
|
|
265
283
|
<Fact label="id">{selected.metadata.id}</Fact>
|
|
@@ -282,11 +300,6 @@ export const VideosView = ({
|
|
|
282
300
|
) : null}
|
|
283
301
|
</dl>
|
|
284
302
|
{selected.metadata.description ? <p className="hint">{selected.metadata.description}</p> : null}
|
|
285
|
-
<div style={{marginTop: 8}}>
|
|
286
|
-
<Button variant="outline" active={showSafeArea} onClick={() => setShowSafeArea((value) => !value)}>
|
|
287
|
-
Safe area
|
|
288
|
-
</Button>
|
|
289
|
-
</div>
|
|
290
303
|
|
|
291
304
|
{Object.keys(fields).length > 0 ? (
|
|
292
305
|
<>
|
|
@@ -304,7 +317,7 @@ export const VideosView = ({
|
|
|
304
317
|
<SectionTitle>Audio</SectionTitle>
|
|
305
318
|
<dl className="facts">
|
|
306
319
|
{track.cues.map((cue) => (
|
|
307
|
-
<Fact key={cue.id} label={cue.src.split("/").pop()
|
|
320
|
+
<Fact key={cue.id} label={cueLabel(cue.src)} title={cue.src.split("/").pop()}>
|
|
308
321
|
{cue.fromFrame}-{cue.fromFrame + cue.durationInFrames - 1} · gain {cue.gain}
|
|
309
322
|
{cue.duckUnder ? ` · ducked to ${DUCK_GAIN}` : ""}
|
|
310
323
|
{soloCue === cue.id ? " · solo" : ""}
|
|
@@ -329,15 +342,7 @@ export const VideosView = ({
|
|
|
329
342
|
) : null}
|
|
330
343
|
|
|
331
344
|
<Diagnostics entry={selected} timeline={timeline} track={track} />
|
|
332
|
-
|
|
333
|
-
<Separator />
|
|
334
|
-
<ExportPanel
|
|
335
|
-
videoId={selected.metadata.id}
|
|
336
|
-
input={input}
|
|
337
|
-
frame={playback.frame}
|
|
338
|
-
exportDir={project.exportDir}
|
|
339
|
-
/>
|
|
340
|
-
</aside>
|
|
345
|
+
</Inspector>
|
|
341
346
|
</>
|
|
342
347
|
);
|
|
343
348
|
};
|