@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.
Files changed (41) hide show
  1. package/dist/{chunk-7XJL2BYO.js → chunk-NYXWEZU2.js} +717 -348
  2. package/dist/cli.js +1 -1
  3. package/dist/index.d.ts +63 -8
  4. package/dist/index.js +3 -3
  5. package/dist/registry-snapshot-MSH2EA36.js +4867 -0
  6. package/package.json +3 -3
  7. package/src/assets.ts +90 -0
  8. package/src/brand-file.ts +16 -4
  9. package/src/chunk-cache.ts +6 -0
  10. package/src/cli.ts +22 -12
  11. package/src/commands/add.ts +33 -8
  12. package/src/commands/dev.ts +114 -12
  13. package/src/commands/doctor.ts +47 -2
  14. package/src/commands/exportVideo.ts +39 -5
  15. package/src/commands/{still.ts → frame.ts} +23 -9
  16. package/src/commands/init.ts +1 -1
  17. package/src/commands/new.ts +1 -1
  18. package/src/cues.ts +34 -21
  19. package/src/discovery.ts +85 -5
  20. package/src/formats.ts +67 -8
  21. package/src/index.ts +1 -1
  22. package/src/jobs.ts +6 -2
  23. package/src/registry-snapshot.json +1530 -328
  24. package/src/registry-source.ts +87 -5
  25. package/src/render.ts +48 -13
  26. package/src/server.ts +55 -13
  27. package/studio/src/Studio.tsx +19 -22
  28. package/studio/src/components/ExportPanel.tsx +124 -90
  29. package/studio/src/components/Inspector.tsx +221 -0
  30. package/studio/src/components/Navigator.tsx +145 -0
  31. package/studio/src/components/Thumbnail.tsx +65 -23
  32. package/studio/src/components/ui.tsx +9 -2
  33. package/studio/src/lib/highlight.ts +85 -0
  34. package/studio/src/studio.css +435 -20
  35. package/studio/src/views/AssetsView.tsx +14 -1
  36. package/studio/src/views/BrandsView.tsx +74 -26
  37. package/studio/src/views/ComponentsView.tsx +206 -55
  38. package/studio/src/views/HomeView.tsx +44 -19
  39. package/studio/src/views/VideosView.tsx +53 -48
  40. package/studio/src/virtual.d.ts +4 -1
  41. 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 {Badge, Button, Empty, Fact, SectionTitle, Separator} from "../components/ui";
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
- const FAMILY_ORDER = ["Typography", "Developer proof", "Product UI", "Narrative", "Brand", "Media", "Audio"];
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 delta = FAMILY_ORDER.indexOf(left.preview.category) - FAMILY_ORDER.indexOf(right.preview.category);
38
- return delta !== 0 ? delta : left.preview.title.localeCompare(right.preview.title);
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; this list is for browsing them all.
79
- const filtered = useMemo(() => [...componentPreviews].sort(byFamily), []);
80
- const selected = filtered.find((entry) => entry.id === selection) ?? filtered[0];
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 (!selected) {
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 @odori/title-reveal</code>.
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
- const preview = selected.preview;
122
- const example = preview.examples.find((item) => item.name === exampleName) ?? preview.examples[0];
123
- const controlDefaults = Object.fromEntries(
124
- Object.entries(preview.controls ?? {}).map(([name, field]) => [name, field.defaultValue]),
125
- );
126
- const props = {...controlDefaults, ...(example?.props ?? {}), ...overrides};
127
- const entry = entryFor(selected, props, brand, format);
128
-
129
- if (mode === "gallery") {
130
- const grouped = filtered.reduce<Record<string, PreviewEntry[]>>((groups, item) => {
131
- groups[item.preview.category] = [...(groups[item.preview.category] ?? []), item];
132
- return groups;
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
- {Object.entries(grouped).map(([category, entries]) => (
137
- <section key={category} style={{padding: "16px 20px 0"}}>
138
- <SectionTitle>{category}</SectionTitle>
139
- <div className="gallery" style={{padding: "0 0 8px"}}>
140
- {entries.map((item) => (
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
- </div>
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
- <aside className="sidebar">
173
- <div className="sidebar-scroll">
174
- <ul className="list">
175
- {filtered.map((item) => (
176
- <li key={item.id}>
177
- <button
178
- type="button"
179
- className="list-item"
180
- data-active={item.id === selected.id ? "true" : undefined}
181
- onClick={() => onSelect(item.id)}
182
- >
183
- <strong>{item.preview.title}</strong>
184
- </button>
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
- <aside className="inspector">
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">{preview.category}</Fact>
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
- <Badge>{Object.keys(preview.controls ?? {}).length} controls</Badge>
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
- </aside>
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
- <SectionTitle>Videos</SectionTitle>
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
- <SectionTitle>Components</SectionTitle>
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
- <span>{item.preview.category.toLowerCase()}</span>
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
- <SectionTitle>Brands</SectionTitle>
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={color} style={{background: color}} />
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
- <SectionTitle>Assets</SectionTitle>
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 {Badge, Button, Empty, Fact, SectionTitle, Separator} from "../components/ui";
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; this list is for browsing them all.
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) ?? filtered[0] ?? videos[0];
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 (!selected || !layout) {
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 (mode === "gallery") {
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
- <aside className="sidebar">
200
- <div className="sidebar-scroll">
201
- <ul className="list">
202
- {filtered.map((video) => (
203
- <li key={video.metadata.id}>
204
- <button
205
- type="button"
206
- className="list-item"
207
- data-active={video.metadata.id === selected.metadata.id ? "true" : undefined}
208
- onClick={() => onSelect(video.metadata.id)}
209
- >
210
- {/* Format and duration are facts about the selection, and the
211
- inspector states both. The list is for choosing. */}
212
- <strong>{video.metadata.title}</strong>
213
- </button>
214
- </li>
215
- ))}
216
- </ul>
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
- <aside className="inspector">
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() ?? cue.src}>
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
  };