@odori/cli 0.0.3 → 0.0.5
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-RXLB2CXH.js → chunk-RHG23EWW.js} +457 -241
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +45 -5
- package/dist/index.js +3 -3
- package/dist/registry-snapshot-JEVXYGS2.js +4868 -0
- package/package.json +3 -3
- package/src/assets.ts +90 -0
- package/src/brand-file.ts +16 -4
- package/src/cli.ts +38 -13
- package/src/commands/add.ts +37 -1
- package/src/commands/dev.ts +32 -2
- package/src/commands/doctor.ts +47 -2
- package/src/commands/exportVideo.ts +6 -0
- package/src/commands/{still.ts → frame.ts} +23 -9
- package/src/commands/update.ts +58 -7
- package/src/discovery.ts +63 -2
- package/src/index.ts +1 -1
- package/src/jobs.ts +1 -1
- package/src/registry-snapshot.json +1529 -327
- package/src/registry-source.ts +37 -2
- package/src/render.ts +8 -1
- package/src/server.ts +7 -1
- package/studio/src/Studio.tsx +6 -22
- package/studio/src/components/ExportPanel.tsx +90 -6
- package/studio/src/components/Inspector.tsx +101 -1
- package/studio/src/components/Navigator.tsx +149 -0
- package/studio/src/components/Settings.tsx +109 -0
- package/studio/src/components/Transport.tsx +98 -55
- package/studio/src/components/ui.tsx +16 -1
- package/studio/src/lib/highlight.ts +85 -0
- package/studio/src/settings.ts +87 -0
- package/studio/src/studio.css +350 -8
- package/studio/src/views/BrandsView.tsx +18 -1
- package/studio/src/views/ComponentsView.tsx +191 -26
- package/studio/src/views/HomeView.tsx +7 -4
- package/studio/src/views/VideosView.tsx +33 -6
- package/studio/src/virtual.d.ts +4 -1
- package/dist/registry-snapshot-BDP6PVYB.js +0 -3559
|
@@ -10,14 +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
20
|
import {Inspector} from "../components/Inspector";
|
|
21
|
+
import {Navigator} from "../components/Navigator";
|
|
21
22
|
import {useShortcuts} from "../shortcuts";
|
|
22
23
|
|
|
23
24
|
type PreviewEntry = (typeof componentPreviews)[number];
|
|
@@ -32,11 +33,93 @@ const projectBrands = (): Brand[] => {
|
|
|
32
33
|
return [...seen.values()];
|
|
33
34
|
};
|
|
34
35
|
|
|
35
|
-
|
|
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
|
+
};
|
|
36
113
|
|
|
37
114
|
const byFamily = (left: PreviewEntry, right: PreviewEntry) => {
|
|
38
|
-
const
|
|
39
|
-
|
|
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);
|
|
40
123
|
};
|
|
41
124
|
|
|
42
125
|
const FORMATS = [
|
|
@@ -76,7 +159,26 @@ export const ComponentsView = ({
|
|
|
76
159
|
}) => {
|
|
77
160
|
// Finding one by name is ⌘K; the gallery is for browsing them all. A route
|
|
78
161
|
// with no selection is the gallery; a selection is that component's player.
|
|
79
|
-
const
|
|
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]);
|
|
80
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);
|
|
@@ -119,17 +221,62 @@ export const ComponentsView = ({
|
|
|
119
221
|
}
|
|
120
222
|
|
|
121
223
|
if (!selected) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
}
|
|
126
247
|
return (
|
|
127
248
|
<div style={{overflowY: "auto", width: "100%"}}>
|
|
128
|
-
{
|
|
129
|
-
<
|
|
130
|
-
<
|
|
131
|
-
|
|
132
|
-
|
|
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) => (
|
|
133
280
|
<button key={item.id} type="button" className="card" onClick={() => onSelect(item.id)}>
|
|
134
281
|
<Thumbnail
|
|
135
282
|
entry={entryFor(
|
|
@@ -151,8 +298,10 @@ export const ComponentsView = ({
|
|
|
151
298
|
<span>{item.id}</span>
|
|
152
299
|
</div>
|
|
153
300
|
</button>
|
|
154
|
-
|
|
155
|
-
|
|
301
|
+
))}
|
|
302
|
+
</div>
|
|
303
|
+
</div>
|
|
304
|
+
))}
|
|
156
305
|
</section>
|
|
157
306
|
))}
|
|
158
307
|
</div>
|
|
@@ -167,8 +316,30 @@ export const ComponentsView = ({
|
|
|
167
316
|
const props = {...controlDefaults, ...(example?.props ?? {}), ...overrides};
|
|
168
317
|
const entry = entryFor(selected, props, brand, format);
|
|
169
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
|
<>
|
|
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
|
+
/>
|
|
342
|
+
|
|
172
343
|
<section className="stage">
|
|
173
344
|
<CanvasStage width={format.width} height={format.height}>
|
|
174
345
|
<PreviewBoundary resetKey={`${selected.id}-${format.label}-${brand.name}`} label="This component threw">
|
|
@@ -190,13 +361,7 @@ export const ComponentsView = ({
|
|
|
190
361
|
/>
|
|
191
362
|
</section>
|
|
192
363
|
|
|
193
|
-
<Inspector
|
|
194
|
-
title={preview.title}
|
|
195
|
-
hint={
|
|
196
|
-
project.files.previews.find((entry) => entry.id === selected.id)?.file ??
|
|
197
|
-
`videos/components/${selected.id}/${selected.id}.preview.tsx`
|
|
198
|
-
}
|
|
199
|
-
>
|
|
364
|
+
<Inspector title={preview.title} hint={componentFile} source={sources}>
|
|
200
365
|
<SectionTitle>Examples</SectionTitle>
|
|
201
366
|
<ul className="list">
|
|
202
367
|
{preview.examples.map((item) => (
|
|
@@ -219,7 +384,7 @@ export const ComponentsView = ({
|
|
|
219
384
|
|
|
220
385
|
<SectionTitle>Component</SectionTitle>
|
|
221
386
|
<dl className="facts">
|
|
222
|
-
<Fact label="category">{
|
|
387
|
+
<Fact label="category">{categoryPath(categoryOf(selected)).join(" · ")}</Fact>
|
|
223
388
|
<Fact label="canvas">
|
|
224
389
|
{preview.canvas.width}x{preview.canvas.height} · {String(preview.canvas.duration)}
|
|
225
390
|
</Fact>
|
|
@@ -255,7 +420,7 @@ export const ComponentsView = ({
|
|
|
255
420
|
{item.name}
|
|
256
421
|
</Button>
|
|
257
422
|
))}
|
|
258
|
-
|
|
423
|
+
|
|
259
424
|
</div>
|
|
260
425
|
|
|
261
426
|
<Separator />
|
|
@@ -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
|
/**
|
|
@@ -28,7 +29,7 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
|
|
|
28
29
|
<SectionTitle>Videos</SectionTitle>
|
|
29
30
|
{videos.length > SHOWN ? (
|
|
30
31
|
<button type="button" className="view-all" onClick={() => onOpen("videos")}>
|
|
31
|
-
View all
|
|
32
|
+
View all →
|
|
32
33
|
</button>
|
|
33
34
|
) : null}
|
|
34
35
|
</div>
|
|
@@ -58,7 +59,7 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
|
|
|
58
59
|
<SectionTitle>Components</SectionTitle>
|
|
59
60
|
{componentPreviews.length > SHOWN ? (
|
|
60
61
|
<button type="button" className="view-all" onClick={() => onOpen("components")}>
|
|
61
|
-
View all
|
|
62
|
+
View all →
|
|
62
63
|
</button>
|
|
63
64
|
) : null}
|
|
64
65
|
</div>
|
|
@@ -82,7 +83,9 @@ export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: strin
|
|
|
82
83
|
/>
|
|
83
84
|
<div className="card-meta">
|
|
84
85
|
<strong>{item.preview.title}</strong>
|
|
85
|
-
|
|
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>
|
|
86
89
|
</div>
|
|
87
90
|
</button>
|
|
88
91
|
);
|
|
@@ -20,8 +20,10 @@ import {InputControls} from "../components/InputControls";
|
|
|
20
20
|
import {ExportPanel} from "../components/ExportPanel";
|
|
21
21
|
import {Diagnostics} from "../components/Diagnostics";
|
|
22
22
|
import {Inspector} from "../components/Inspector";
|
|
23
|
+
import {Navigator} from "../components/Navigator";
|
|
23
24
|
import {Badge, Empty, Fact, SectionTitle, Separator} from "../components/ui";
|
|
24
25
|
import {measureTrack} from "../lib/mix-loudness";
|
|
26
|
+
import {readStartSound} from "../settings";
|
|
25
27
|
import {useShortcuts} from "../shortcuts";
|
|
26
28
|
|
|
27
29
|
/**
|
|
@@ -50,9 +52,15 @@ export const VideosView = ({
|
|
|
50
52
|
const [timeline, setTimeline] = useState<CompiledTimeline | null>(null);
|
|
51
53
|
const [track, setTrack] = useState<AudioTrack | null>(null);
|
|
52
54
|
const [loop, setLoop] = useState(true);
|
|
53
|
-
|
|
55
|
+
/* Read once per mount rather than watched: flipping the default while a
|
|
56
|
+
video is open would mute the thing being listened to, and a default is a
|
|
57
|
+
statement about what happens next. */
|
|
58
|
+
const [muted, setMuted] = useState(() => readStartSound() === "off");
|
|
54
59
|
const [soloCue, setSoloCue] = useState<string | null>(null);
|
|
55
|
-
|
|
60
|
+
/* The frame the cursor is over, which the stage shows instead of the
|
|
61
|
+
playhead frame. Preview never moves the playhead and never sounds: it
|
|
62
|
+
answers "what is here" and nothing else. */
|
|
63
|
+
const [preview, setPreview] = useState<number | null>(null);
|
|
56
64
|
const [rate, setRate] = useState(1);
|
|
57
65
|
const [loudness, setLoudness] = useState<number | null>(null);
|
|
58
66
|
const [audioBlocked, setAudioBlocked] = useState(false);
|
|
@@ -107,7 +115,7 @@ export const VideosView = ({
|
|
|
107
115
|
playing: playback.playing,
|
|
108
116
|
muted,
|
|
109
117
|
soloCue,
|
|
110
|
-
scrubbing,
|
|
118
|
+
scrubbing: preview !== null,
|
|
111
119
|
rate,
|
|
112
120
|
onBlocked: setAudioBlocked,
|
|
113
121
|
onFailed: setAudioFailed,
|
|
@@ -205,6 +213,25 @@ export const VideosView = ({
|
|
|
205
213
|
|
|
206
214
|
return (
|
|
207
215
|
<>
|
|
216
|
+
{/* A video id is a path under videos/, so the folder is the grouping a
|
|
217
|
+
project already chose: social/announcement sits under social. */}
|
|
218
|
+
<Navigator
|
|
219
|
+
label="Videos"
|
|
220
|
+
selected={selected.metadata.id}
|
|
221
|
+
onSelect={onSelect}
|
|
222
|
+
items={[...filtered]
|
|
223
|
+
.sort((left, right) => left.metadata.id.localeCompare(right.metadata.id))
|
|
224
|
+
.map((video) => {
|
|
225
|
+
const parts = video.metadata.id.split("/");
|
|
226
|
+
return {
|
|
227
|
+
id: video.metadata.id,
|
|
228
|
+
title: video.metadata.title,
|
|
229
|
+
detail: String(video.metadata.duration ?? "auto"),
|
|
230
|
+
group: parts.length > 1 ? parts.slice(0, -1).join(" · ") : undefined,
|
|
231
|
+
};
|
|
232
|
+
})}
|
|
233
|
+
/>
|
|
234
|
+
|
|
208
235
|
<section className="stage">
|
|
209
236
|
<CanvasStage
|
|
210
237
|
width={layout.format.width}
|
|
@@ -218,7 +245,7 @@ export const VideosView = ({
|
|
|
218
245
|
<PreviewBoundary resetKey={selected.metadata.id}>
|
|
219
246
|
<OdoriRuntime
|
|
220
247
|
entry={selected}
|
|
221
|
-
frame={playback.frame}
|
|
248
|
+
frame={preview ?? playback.frame}
|
|
222
249
|
input={input}
|
|
223
250
|
assets={project.assets}
|
|
224
251
|
onTimeline={handleTimeline}
|
|
@@ -238,7 +265,7 @@ export const VideosView = ({
|
|
|
238
265
|
audioBlocked={audioBlocked}
|
|
239
266
|
onEnableAudio={() => setMuted(false)}
|
|
240
267
|
onSoloCue={setSoloCue}
|
|
241
|
-
|
|
268
|
+
onPreview={setPreview}
|
|
242
269
|
rate={rate}
|
|
243
270
|
onRate={setRate}
|
|
244
271
|
onToggleLoop={() => setLoop((value) => !value)}
|
|
@@ -246,7 +273,7 @@ export const VideosView = ({
|
|
|
246
273
|
/>
|
|
247
274
|
</section>
|
|
248
275
|
|
|
249
|
-
<Inspector title={selected.metadata.title} hint={file}>
|
|
276
|
+
<Inspector title={selected.metadata.title} hint={file} source={file}>
|
|
250
277
|
{/* Export sits first: it is the pane's one action, and the header
|
|
251
278
|
names what is being worked on right above it. */}
|
|
252
279
|
<ExportPanel
|
package/studio/src/virtual.d.ts
CHANGED
|
@@ -10,6 +10,9 @@ declare module "virtual:odori-project" {
|
|
|
10
10
|
export const project: {
|
|
11
11
|
root: string;
|
|
12
12
|
videosDir: string;
|
|
13
|
+
componentsDir: string;
|
|
14
|
+
/** Directories that named themselves, from `category.json`. */
|
|
15
|
+
categories: Array<{path: string; name?: string; order?: number}>;
|
|
13
16
|
docsUrl: string;
|
|
14
17
|
exportDir: string;
|
|
15
18
|
audioDir: string;
|
|
@@ -18,7 +21,7 @@ declare module "virtual:odori-project" {
|
|
|
18
21
|
assets: Array<{reference: string; url: string}>;
|
|
19
22
|
files: {
|
|
20
23
|
videos: Array<{id: string; file: string}>;
|
|
21
|
-
previews: Array<{id: string; file: string}>;
|
|
24
|
+
previews: Array<{id: string; file: string; usedBy: string[]}>;
|
|
22
25
|
brands: Array<{id: string; file: string}>;
|
|
23
26
|
};
|
|
24
27
|
};
|