@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,61 @@
|
|
|
1
|
+
import {useEffect, useState} from "react";
|
|
2
|
+
|
|
3
|
+
export type Theme = "system" | "light" | "dark";
|
|
4
|
+
|
|
5
|
+
export const THEME_STORAGE_KEY = "odori-studio-theme";
|
|
6
|
+
|
|
7
|
+
const isTheme = (value: unknown): value is Theme => value === "system" || value === "light" || value === "dark";
|
|
8
|
+
|
|
9
|
+
export const readStoredTheme = (): Theme => {
|
|
10
|
+
try {
|
|
11
|
+
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
|
12
|
+
return isTheme(stored) ? stored : "system";
|
|
13
|
+
} catch {
|
|
14
|
+
// Private browsing and hardened profiles can refuse storage.
|
|
15
|
+
return "system";
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const resolveTheme = (theme: Theme): "light" | "dark" =>
|
|
20
|
+
theme === "system" ? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") : theme;
|
|
21
|
+
|
|
22
|
+
/** Applied to the document element so CSS tokens and form controls follow. */
|
|
23
|
+
export const applyTheme = (theme: Theme) => {
|
|
24
|
+
const resolved = resolveTheme(theme);
|
|
25
|
+
document.documentElement.dataset.theme = resolved;
|
|
26
|
+
document.documentElement.style.colorScheme = resolved;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Theme state for the workspace chrome.
|
|
31
|
+
*
|
|
32
|
+
* Only Studio follows this. A composition always renders its brand's own
|
|
33
|
+
* colors, because the video is the product, not the surrounding tool.
|
|
34
|
+
*/
|
|
35
|
+
export const useTheme = (): {theme: Theme; resolved: "light" | "dark"; setTheme: (next: Theme) => void} => {
|
|
36
|
+
const [theme, setTheme] = useState<Theme>(() => (typeof window === "undefined" ? "dark" : readStoredTheme()));
|
|
37
|
+
const [resolved, setResolved] = useState<"light" | "dark">(() =>
|
|
38
|
+
typeof window === "undefined" ? "dark" : resolveTheme(readStoredTheme()),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
applyTheme(theme);
|
|
43
|
+
setResolved(resolveTheme(theme));
|
|
44
|
+
try {
|
|
45
|
+
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
46
|
+
} catch {
|
|
47
|
+
// Preference is best effort; the applied theme still holds for this session.
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (theme !== "system") return;
|
|
51
|
+
const query = window.matchMedia("(prefers-color-scheme: dark)");
|
|
52
|
+
const onChange = () => {
|
|
53
|
+
applyTheme("system");
|
|
54
|
+
setResolved(resolveTheme("system"));
|
|
55
|
+
};
|
|
56
|
+
query.addEventListener("change", onChange);
|
|
57
|
+
return () => query.removeEventListener("change", onChange);
|
|
58
|
+
}, [theme]);
|
|
59
|
+
|
|
60
|
+
return {theme, resolved, setTheme};
|
|
61
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {resolveEntryLayout} from "odori";
|
|
2
|
+
import {project, videos} from "virtual:odori-project";
|
|
3
|
+
import {AudioClip} from "../components/AudioClip";
|
|
4
|
+
import {SectionTitle} from "../components/ui";
|
|
5
|
+
|
|
6
|
+
const isImage = (url: string) => /\.(png|jpe?g|gif|svg|webp|avif)$/i.test(url);
|
|
7
|
+
const isAudio = (url: string) => /\.(m4a|mp3|wav|aac|ogg|opus|flac)$/i.test(url);
|
|
8
|
+
|
|
9
|
+
const fileSize = (bytes: number) =>
|
|
10
|
+
bytes > 1024 * 1024 ? `${(bytes / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
11
|
+
|
|
12
|
+
export const AssetsView = () => {
|
|
13
|
+
const audioAssets = project.assets.filter((asset) => isAudio(asset.url));
|
|
14
|
+
const visualAssets = project.assets.filter((asset) => !isAudio(asset.url));
|
|
15
|
+
const referenceFor = (url: string) => audioAssets.find((asset) => asset.url === url)?.reference;
|
|
16
|
+
// A cue can point outside the library, so a declared file that discovery did
|
|
17
|
+
// not walk is still listed rather than silently missing.
|
|
18
|
+
const library = [
|
|
19
|
+
...project.audio.map((entry) => ({url: entry.url, name: entry.name, bytes: entry.bytes})),
|
|
20
|
+
...audioAssets
|
|
21
|
+
.filter((asset) => !project.audio.some((entry) => entry.url === asset.url))
|
|
22
|
+
.map((asset) => ({url: asset.url, name: asset.url.split("/").pop() ?? asset.url, bytes: 0})),
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const fonts = [...new Map(videos.flatMap((video) => resolveEntryLayout(video).brand.fonts).map((font) => [font.url, font])).values()];
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div className="assets-view">
|
|
29
|
+
<section>
|
|
30
|
+
<SectionTitle>Assets</SectionTitle>
|
|
31
|
+
{visualAssets.length === 0 ? (
|
|
32
|
+
<p className="hint">
|
|
33
|
+
Declare assets in <code>odori.config.ts</code> so preview and render workers resolve identical URLs.
|
|
34
|
+
</p>
|
|
35
|
+
) : (
|
|
36
|
+
<ul className="asset-grid">
|
|
37
|
+
{visualAssets.map((asset) => (
|
|
38
|
+
<li key={asset.reference}>
|
|
39
|
+
{isImage(asset.url) ? <img src={asset.url} alt={asset.reference} /> : <div className="asset-file" />}
|
|
40
|
+
<strong>{asset.reference}</strong>
|
|
41
|
+
<code>{asset.url}</code>
|
|
42
|
+
</li>
|
|
43
|
+
))}
|
|
44
|
+
</ul>
|
|
45
|
+
)}
|
|
46
|
+
</section>
|
|
47
|
+
|
|
48
|
+
<section>
|
|
49
|
+
<SectionTitle>Audio</SectionTitle>
|
|
50
|
+
{library.length === 0 ? (
|
|
51
|
+
<p className="hint">
|
|
52
|
+
Drop sounds in <code>{project.audioDir}/</code>. Reference one from a cue with{" "}
|
|
53
|
+
<code>{'<Audio src="bed/main" />'}</code> or by its URL.
|
|
54
|
+
</p>
|
|
55
|
+
) : (
|
|
56
|
+
<ul className="asset-list">
|
|
57
|
+
{library.map((entry) => (
|
|
58
|
+
<li key={entry.url}>
|
|
59
|
+
<AudioClip url={entry.url} label={entry.name} shape />
|
|
60
|
+
<div style={{display: "flex", flexDirection: "column", gap: 2, minWidth: 0}}>
|
|
61
|
+
<strong>
|
|
62
|
+
{entry.name}
|
|
63
|
+
{referenceFor(entry.url) ? (
|
|
64
|
+
<span className="reference">{`<Audio src="${referenceFor(entry.url)}" />`}</span>
|
|
65
|
+
) : null}
|
|
66
|
+
</strong>
|
|
67
|
+
<code>
|
|
68
|
+
{entry.url}
|
|
69
|
+
{entry.bytes > 0 ? ` · ${fileSize(entry.bytes)}` : ""}
|
|
70
|
+
</code>
|
|
71
|
+
</div>
|
|
72
|
+
</li>
|
|
73
|
+
))}
|
|
74
|
+
</ul>
|
|
75
|
+
)}
|
|
76
|
+
</section>
|
|
77
|
+
|
|
78
|
+
<section>
|
|
79
|
+
<SectionTitle>Fonts</SectionTitle>
|
|
80
|
+
{/* Studio's own chrome never loaded these faces, so a name rendered in
|
|
81
|
+
its own family would otherwise fall back to a system font. */}
|
|
82
|
+
<style>
|
|
83
|
+
{fonts
|
|
84
|
+
.map(
|
|
85
|
+
(font) =>
|
|
86
|
+
`@font-face{font-family:"${font.family}";src:url("${font.url}") format("woff2");font-weight:${
|
|
87
|
+
font.weight ?? "100 900"
|
|
88
|
+
};font-display:swap;}`,
|
|
89
|
+
)
|
|
90
|
+
.join("")}
|
|
91
|
+
</style>
|
|
92
|
+
{fonts.length === 0 ? (
|
|
93
|
+
<p className="hint">
|
|
94
|
+
Brands declare fonts with <code>defineBrand({"{fonts: [...]}"})</code>. The runtime loads them before the
|
|
95
|
+
first frame.
|
|
96
|
+
</p>
|
|
97
|
+
) : (
|
|
98
|
+
<ul className="asset-list">
|
|
99
|
+
{fonts.map((font) => (
|
|
100
|
+
<li key={font.url}>
|
|
101
|
+
<strong style={{fontFamily: font.family, fontSize: 20}}>{font.family}</strong>
|
|
102
|
+
<code>{font.url}</code>
|
|
103
|
+
</li>
|
|
104
|
+
))}
|
|
105
|
+
</ul>
|
|
106
|
+
)}
|
|
107
|
+
</section>
|
|
108
|
+
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {useState} from "react";
|
|
2
|
+
import {
|
|
3
|
+
OdoriRuntime,
|
|
4
|
+
defineVideoLayout,
|
|
5
|
+
entryDurationInFrames,
|
|
6
|
+
resolveEntryLayout,
|
|
7
|
+
usePlayback,
|
|
8
|
+
type Brand,
|
|
9
|
+
type CompiledTimeline,
|
|
10
|
+
} from "odori";
|
|
11
|
+
import {brands as discoveredBrands, project, videos} from "virtual:odori-project";
|
|
12
|
+
import {CanvasStage} from "../components/CanvasStage";
|
|
13
|
+
import {Transport} from "../components/Transport";
|
|
14
|
+
import {Empty, Fact, SectionTitle, Separator} from "../components/ui";
|
|
15
|
+
import {useShortcuts} from "../shortcuts";
|
|
16
|
+
|
|
17
|
+
/** Brands come from videos/**\/brands modules and from every resolved layout. */
|
|
18
|
+
export const collectBrands = (): Brand[] => {
|
|
19
|
+
const seen = new Map<string, Brand>();
|
|
20
|
+
for (const brand of discoveredBrands) seen.set(brand.name, brand);
|
|
21
|
+
for (const video of videos) {
|
|
22
|
+
const brand = resolveEntryLayout(video).brand;
|
|
23
|
+
if (!seen.has(brand.name)) seen.set(brand.name, brand);
|
|
24
|
+
}
|
|
25
|
+
return [...seen.values()];
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const BrandsView = () => {
|
|
29
|
+
const brands = collectBrands();
|
|
30
|
+
const [brandName, setBrandName] = useState(brands[0]?.name ?? "");
|
|
31
|
+
const [timeline, setTimeline] = useState<CompiledTimeline | null>(null);
|
|
32
|
+
const [videoId, setVideoId] = useState(videos[0]?.metadata.id ?? "");
|
|
33
|
+
const brand = brands.find((item) => item.name === brandName) ?? brands[0];
|
|
34
|
+
const entry = videos.find((video) => video.metadata.id === videoId) ?? videos[0];
|
|
35
|
+
const base = entry ? resolveEntryLayout(entry) : null;
|
|
36
|
+
const durationInFrames = entry && base ? Math.max(1, entryDurationInFrames(entry, base)) : 1;
|
|
37
|
+
const playback = usePlayback({fps: base?.format.fps ?? 30, durationInFrames, autoPlay: true, loop: true});
|
|
38
|
+
|
|
39
|
+
// A view with a transport answers the transport keys. Anything else makes
|
|
40
|
+
// the same player behave differently depending on which tab it sits in.
|
|
41
|
+
useShortcuts({
|
|
42
|
+
" ": () => playback.toggle(),
|
|
43
|
+
ArrowRight: (event) => playback.step(event.shiftKey ? (base?.format.fps ?? 30) : 1),
|
|
44
|
+
ArrowLeft: (event) => playback.step(event.shiftKey ? -(base?.format.fps ?? 30) : -1),
|
|
45
|
+
Home: () => playback.seek(0),
|
|
46
|
+
End: () => playback.seek(durationInFrames - 1),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!brand || !entry || !base) {
|
|
50
|
+
return <Empty title="No brands resolved">Attach a brand through defineVideoLayout().</Empty>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const layout = defineVideoLayout({extends: base, brand});
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
<aside className="sidebar">
|
|
58
|
+
<div className="sidebar-scroll">
|
|
59
|
+
<ul className="list">
|
|
60
|
+
{brands.map((item) => (
|
|
61
|
+
<li key={item.name}>
|
|
62
|
+
<button
|
|
63
|
+
type="button"
|
|
64
|
+
className="list-item"
|
|
65
|
+
data-active={item.name === brand.name ? "true" : undefined}
|
|
66
|
+
onClick={() => setBrandName(item.name)}
|
|
67
|
+
>
|
|
68
|
+
<strong>{item.name}</strong>
|
|
69
|
+
</button>
|
|
70
|
+
</li>
|
|
71
|
+
))}
|
|
72
|
+
</ul>
|
|
73
|
+
</div>
|
|
74
|
+
</aside>
|
|
75
|
+
|
|
76
|
+
<section className="stage">
|
|
77
|
+
<CanvasStage width={layout.format.width} height={layout.format.height}>
|
|
78
|
+
<OdoriRuntime
|
|
79
|
+
key={`${brand.name}-${entry.metadata.id}`}
|
|
80
|
+
entry={entry}
|
|
81
|
+
frame={playback.frame}
|
|
82
|
+
layout={layout}
|
|
83
|
+
assets={project.assets}
|
|
84
|
+
onTimeline={setTimeline}
|
|
85
|
+
/>
|
|
86
|
+
</CanvasStage>
|
|
87
|
+
<Transport
|
|
88
|
+
playback={playback}
|
|
89
|
+
timeline={timeline}
|
|
90
|
+
durationInFrames={durationInFrames}
|
|
91
|
+
fps={layout.format.fps}
|
|
92
|
+
loop
|
|
93
|
+
onToggleLoop={() => undefined}
|
|
94
|
+
/>
|
|
95
|
+
</section>
|
|
96
|
+
|
|
97
|
+
<aside className="inspector">
|
|
98
|
+
<SectionTitle>Preview with</SectionTitle>
|
|
99
|
+
<ul className="list">
|
|
100
|
+
{videos.map((video) => (
|
|
101
|
+
<li key={video.metadata.id}>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
className="list-item"
|
|
105
|
+
data-active={video.metadata.id === entry.metadata.id ? "true" : undefined}
|
|
106
|
+
onClick={() => setVideoId(video.metadata.id)}
|
|
107
|
+
>
|
|
108
|
+
<strong>{video.metadata.title}</strong>
|
|
109
|
+
</button>
|
|
110
|
+
</li>
|
|
111
|
+
))}
|
|
112
|
+
</ul>
|
|
113
|
+
|
|
114
|
+
<SectionTitle>Tokens</SectionTitle>
|
|
115
|
+
<div className="swatches">
|
|
116
|
+
{Object.entries(brand.colors).map(([token, value]) => (
|
|
117
|
+
<div key={token} className="swatch">
|
|
118
|
+
<span className="chip" style={{background: value}} />
|
|
119
|
+
<span>{token}</span>
|
|
120
|
+
<code>{value}</code>
|
|
121
|
+
</div>
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
<Separator />
|
|
125
|
+
<SectionTitle>Policy</SectionTitle>
|
|
126
|
+
<dl className="facts">
|
|
127
|
+
<Fact label="sans">{brand.typography.sans.split(",")[0].replace(/"/g, "")}</Fact>
|
|
128
|
+
<Fact label="mono">{brand.typography.mono.split(",")[0].replace(/"/g, "")}</Fact>
|
|
129
|
+
<Fact label="motion">cubic-bezier({brand.motion.standard.join(", ")})</Fact>
|
|
130
|
+
<Fact label="stagger">{brand.motion.staggerFrames} frames</Fact>
|
|
131
|
+
<Fact label="audio">
|
|
132
|
+
{Object.keys(brand.audio.cues).join(", ") || "no named cues"} at {brand.audio.targetLufs} LUFS
|
|
133
|
+
</Fact>
|
|
134
|
+
<Fact label="fonts">{brand.fonts.length ? brand.fonts.map((font) => font.family).join(", ") : "system"}</Fact>
|
|
135
|
+
</dl>
|
|
136
|
+
</aside>
|
|
137
|
+
</>
|
|
138
|
+
);
|
|
139
|
+
};
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import {useEffect, useMemo, useRef, useState} from "react";
|
|
2
|
+
import type {ReactElement} from "react";
|
|
3
|
+
import {
|
|
4
|
+
OdoriRuntime,
|
|
5
|
+
defaultBrand,
|
|
6
|
+
defineVideoLayout,
|
|
7
|
+
framesFromDuration,
|
|
8
|
+
resolveEntryLayout,
|
|
9
|
+
usePlayback,
|
|
10
|
+
type Brand,
|
|
11
|
+
type VideoEntry,
|
|
12
|
+
} from "odori";
|
|
13
|
+
import {PreviewBoundary} from "odori/preview";
|
|
14
|
+
import {componentPreviews, project, videos} from "virtual:odori-project";
|
|
15
|
+
import {CanvasStage} from "../components/CanvasStage";
|
|
16
|
+
import {Transport} from "../components/Transport";
|
|
17
|
+
import {Thumbnail} from "../components/Thumbnail";
|
|
18
|
+
import {InputControls} from "../components/InputControls";
|
|
19
|
+
import {Badge, Button, Empty, Fact, SectionTitle, Separator} from "../components/ui";
|
|
20
|
+
import {useShortcuts} from "../shortcuts";
|
|
21
|
+
|
|
22
|
+
type PreviewEntry = (typeof componentPreviews)[number];
|
|
23
|
+
|
|
24
|
+
const projectBrands = (): Brand[] => {
|
|
25
|
+
const seen = new Map<string, Brand>();
|
|
26
|
+
for (const video of videos) {
|
|
27
|
+
const brand = resolveEntryLayout(video).brand;
|
|
28
|
+
if (!seen.has(brand.name)) seen.set(brand.name, brand);
|
|
29
|
+
}
|
|
30
|
+
if (seen.size === 0) seen.set(defaultBrand.name, defaultBrand);
|
|
31
|
+
return [...seen.values()];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const FAMILY_ORDER = ["Typography", "Developer proof", "Product UI", "Narrative", "Brand", "Media", "Audio"];
|
|
35
|
+
|
|
36
|
+
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);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const FORMATS = [
|
|
42
|
+
{label: "16:9", width: 1920, height: 1080},
|
|
43
|
+
{label: "9:16", width: 1080, height: 1920},
|
|
44
|
+
{label: "1:1", width: 1080, height: 1080},
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A component preview plays through the same runtime as a full video, so a
|
|
49
|
+
* fixture and a composition can never disagree.
|
|
50
|
+
*/
|
|
51
|
+
export const entryFor = (
|
|
52
|
+
entry: PreviewEntry,
|
|
53
|
+
props: Record<string, unknown>,
|
|
54
|
+
brand: Brand,
|
|
55
|
+
format: {width: number; height: number},
|
|
56
|
+
): VideoEntry => {
|
|
57
|
+
const Component = entry.preview.component as (componentProps: Record<string, unknown>) => ReactElement;
|
|
58
|
+
return {
|
|
59
|
+
component: () => <Component {...props} />,
|
|
60
|
+
metadata: {
|
|
61
|
+
id: `preview-${entry.id}`,
|
|
62
|
+
title: entry.preview.title,
|
|
63
|
+
duration: entry.preview.canvas.duration,
|
|
64
|
+
layout: defineVideoLayout({format: {...format, fps: 30}, brand}),
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export const ComponentsView = ({
|
|
70
|
+
selection,
|
|
71
|
+
onSelect,
|
|
72
|
+
mode,
|
|
73
|
+
}: {
|
|
74
|
+
selection: string | null;
|
|
75
|
+
onSelect: (id: string) => void;
|
|
76
|
+
mode: "player" | "gallery";
|
|
77
|
+
}) => {
|
|
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];
|
|
81
|
+
const brands = projectBrands();
|
|
82
|
+
const [brandName, setBrandName] = useState(brands[0]?.name ?? defaultBrand.name);
|
|
83
|
+
const [formatLabel, setFormatLabel] = useState(FORMATS[0].label);
|
|
84
|
+
const [exampleName, setExampleName] = useState<string | null>(null);
|
|
85
|
+
const [overrides, setOverrides] = useState<Record<string, unknown>>({});
|
|
86
|
+
const [loop, setLoop] = useState(true);
|
|
87
|
+
|
|
88
|
+
const brand = brands.find((item) => item.name === brandName) ?? defaultBrand;
|
|
89
|
+
const format = FORMATS.find((item) => item.label === formatLabel) ?? FORMATS[0];
|
|
90
|
+
const durationInFrames = selected ? framesFromDuration(selected.preview.canvas.duration, 30) : 30;
|
|
91
|
+
const playback = usePlayback({fps: 30, durationInFrames, autoPlay: true, loop});
|
|
92
|
+
|
|
93
|
+
const previousId = useRef(selected?.id);
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (previousId.current === selected?.id) return;
|
|
96
|
+
previousId.current = selected?.id;
|
|
97
|
+
setExampleName(null);
|
|
98
|
+
setOverrides({});
|
|
99
|
+
playback.seek(0);
|
|
100
|
+
playback.play();
|
|
101
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
102
|
+
}, [selected?.id]);
|
|
103
|
+
|
|
104
|
+
useShortcuts({
|
|
105
|
+
" ": () => playback.toggle(),
|
|
106
|
+
ArrowRight: (event) => playback.step(event.shiftKey ? 30 : 1),
|
|
107
|
+
ArrowLeft: (event) => playback.step(event.shiftKey ? -30 : -1),
|
|
108
|
+
Home: () => playback.seek(0),
|
|
109
|
+
End: () => playback.seek(durationInFrames - 1),
|
|
110
|
+
l: () => setLoop((value) => !value),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
if (!selected) {
|
|
114
|
+
return (
|
|
115
|
+
<Empty title="No component previews">
|
|
116
|
+
Add a sibling <code>*.preview.tsx</code> file, or run <code>odori add @odori/title-reveal</code>.
|
|
117
|
+
</Empty>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
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
|
+
}, {});
|
|
134
|
+
return (
|
|
135
|
+
<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) => (
|
|
141
|
+
<button key={item.id} type="button" className="card" onClick={() => onSelect(item.id)}>
|
|
142
|
+
<Thumbnail
|
|
143
|
+
entry={entryFor(
|
|
144
|
+
item,
|
|
145
|
+
{
|
|
146
|
+
...Object.fromEntries(
|
|
147
|
+
Object.entries(item.preview.controls ?? {}).map(([name, field]) => [name, field.defaultValue]),
|
|
148
|
+
),
|
|
149
|
+
...(item.preview.examples[0]?.props ?? {}),
|
|
150
|
+
},
|
|
151
|
+
brand,
|
|
152
|
+
FORMATS[0],
|
|
153
|
+
)}
|
|
154
|
+
frame={Math.round(framesFromDuration(item.preview.canvas.duration, 30) * 0.6)}
|
|
155
|
+
durationInFrames={framesFromDuration(item.preview.canvas.duration, 30)}
|
|
156
|
+
/>
|
|
157
|
+
<div className="card-meta">
|
|
158
|
+
<strong>{item.preview.title}</strong>
|
|
159
|
+
<span>{item.id}</span>
|
|
160
|
+
</div>
|
|
161
|
+
</button>
|
|
162
|
+
))}
|
|
163
|
+
</div>
|
|
164
|
+
</section>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<>
|
|
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>
|
|
191
|
+
|
|
192
|
+
<section className="stage">
|
|
193
|
+
<CanvasStage width={format.width} height={format.height}>
|
|
194
|
+
<PreviewBoundary resetKey={`${selected.id}-${format.label}-${brand.name}`} label="This component threw">
|
|
195
|
+
<OdoriRuntime
|
|
196
|
+
key={`${selected.id}-${format.label}-${brand.name}`}
|
|
197
|
+
entry={entry}
|
|
198
|
+
frame={playback.frame}
|
|
199
|
+
assets={project.assets}
|
|
200
|
+
/>
|
|
201
|
+
</PreviewBoundary>
|
|
202
|
+
</CanvasStage>
|
|
203
|
+
<Transport
|
|
204
|
+
playback={playback}
|
|
205
|
+
timeline={null}
|
|
206
|
+
durationInFrames={durationInFrames}
|
|
207
|
+
fps={30}
|
|
208
|
+
loop={loop}
|
|
209
|
+
onToggleLoop={() => setLoop((value) => !value)}
|
|
210
|
+
/>
|
|
211
|
+
</section>
|
|
212
|
+
|
|
213
|
+
<aside className="inspector">
|
|
214
|
+
<SectionTitle>Examples</SectionTitle>
|
|
215
|
+
<ul className="list">
|
|
216
|
+
{preview.examples.map((item) => (
|
|
217
|
+
<li key={item.name}>
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
className="list-item"
|
|
221
|
+
data-active={item.name === example?.name ? "true" : undefined}
|
|
222
|
+
onClick={() => {
|
|
223
|
+
setExampleName(item.name);
|
|
224
|
+
setOverrides({});
|
|
225
|
+
playback.seek(0);
|
|
226
|
+
}}
|
|
227
|
+
>
|
|
228
|
+
<strong>{item.name}</strong>
|
|
229
|
+
</button>
|
|
230
|
+
</li>
|
|
231
|
+
))}
|
|
232
|
+
</ul>
|
|
233
|
+
|
|
234
|
+
<SectionTitle>Component</SectionTitle>
|
|
235
|
+
<dl className="facts">
|
|
236
|
+
<Fact label="category">{preview.category}</Fact>
|
|
237
|
+
<Fact label="canvas">
|
|
238
|
+
{preview.canvas.width}x{preview.canvas.height} · {String(preview.canvas.duration)}
|
|
239
|
+
</Fact>
|
|
240
|
+
<Fact label="source">
|
|
241
|
+
{project.files.previews.find((entry) => entry.id === selected.id)?.file ??
|
|
242
|
+
`videos/components/${selected.id}/${selected.id}.preview.tsx`}
|
|
243
|
+
</Fact>
|
|
244
|
+
</dl>
|
|
245
|
+
{preview.description ? <p className="hint">{preview.description}</p> : null}
|
|
246
|
+
|
|
247
|
+
<Separator />
|
|
248
|
+
<SectionTitle>Stress tests</SectionTitle>
|
|
249
|
+
<div style={{display: "flex", flexWrap: "wrap", gap: 6}}>
|
|
250
|
+
{FORMATS.map((item) => (
|
|
251
|
+
<Button
|
|
252
|
+
key={item.label}
|
|
253
|
+
variant="outline"
|
|
254
|
+
active={item.label === formatLabel}
|
|
255
|
+
onClick={() => setFormatLabel(item.label)}
|
|
256
|
+
>
|
|
257
|
+
{item.label}
|
|
258
|
+
</Button>
|
|
259
|
+
))}
|
|
260
|
+
</div>
|
|
261
|
+
<div style={{display: "flex", flexWrap: "wrap", gap: 6, marginTop: 8}}>
|
|
262
|
+
{brands.map((item) => (
|
|
263
|
+
<Button
|
|
264
|
+
key={item.name}
|
|
265
|
+
variant="outline"
|
|
266
|
+
active={item.name === brand.name}
|
|
267
|
+
onClick={() => setBrandName(item.name)}
|
|
268
|
+
>
|
|
269
|
+
{item.name}
|
|
270
|
+
</Button>
|
|
271
|
+
))}
|
|
272
|
+
<Badge>{Object.keys(preview.controls ?? {}).length} controls</Badge>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
<Separator />
|
|
276
|
+
<SectionTitle>Props</SectionTitle>
|
|
277
|
+
{preview.controls ? (
|
|
278
|
+
<InputControls fields={preview.controls} value={props} onChange={setOverrides} />
|
|
279
|
+
) : (
|
|
280
|
+
<p className="hint">This preview declares no controls. Add them to vary props in Studio.</p>
|
|
281
|
+
)}
|
|
282
|
+
</aside>
|
|
283
|
+
</>
|
|
284
|
+
);
|
|
285
|
+
};
|