@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,122 @@
|
|
|
1
|
+
import {entryDurationInFrames, framesFromDuration, resolveEntryLayout} from "odori";
|
|
2
|
+
import {componentPreviews, project, videos} from "virtual:odori-project";
|
|
3
|
+
import {Thumbnail} from "../components/Thumbnail";
|
|
4
|
+
import {AudioClip} from "../components/AudioClip";
|
|
5
|
+
import {SectionTitle} from "../components/ui";
|
|
6
|
+
import {collectBrands} from "./BrandsView";
|
|
7
|
+
import {entryFor} from "./ComponentsView";
|
|
8
|
+
import type {StudioView} from "../Studio";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What the project contains, shown rather than listed.
|
|
12
|
+
*
|
|
13
|
+
* Every other view answers a question about one thing. This one answers "what
|
|
14
|
+
* is in here", which is the question you have on arrival, so each card plays a
|
|
15
|
+
* frame of the real composition and opens the view that owns it.
|
|
16
|
+
*/
|
|
17
|
+
export const HomeView = ({onOpen}: {onOpen: (view: StudioView, selection?: string) => void}) => {
|
|
18
|
+
const brands = collectBrands();
|
|
19
|
+
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
|
+
return (
|
|
25
|
+
<div className="home">
|
|
26
|
+
<section>
|
|
27
|
+
<SectionTitle>Videos</SectionTitle>
|
|
28
|
+
<div className="gallery">
|
|
29
|
+
{videos.map((video) => {
|
|
30
|
+
const layout = resolveEntryLayout(video);
|
|
31
|
+
return (
|
|
32
|
+
<button key={video.metadata.id} type="button" className="card" onClick={() => onOpen("videos", video.metadata.id)}>
|
|
33
|
+
<Thumbnail
|
|
34
|
+
entry={video}
|
|
35
|
+
frame={video.metadata.thumbnailFrame ?? Math.round(entryDurationInFrames(video, layout) * 0.4)}
|
|
36
|
+
durationInFrames={entryDurationInFrames(video, layout)}
|
|
37
|
+
/>
|
|
38
|
+
<div className="card-meta">
|
|
39
|
+
<strong>{video.metadata.title}</strong>
|
|
40
|
+
<span>
|
|
41
|
+
{layout.format.width}x{layout.format.height} · {video.metadata.id}
|
|
42
|
+
</span>
|
|
43
|
+
</div>
|
|
44
|
+
</button>
|
|
45
|
+
);
|
|
46
|
+
})}
|
|
47
|
+
</div>
|
|
48
|
+
</section>
|
|
49
|
+
|
|
50
|
+
<section>
|
|
51
|
+
<SectionTitle>Components</SectionTitle>
|
|
52
|
+
<div className="gallery">
|
|
53
|
+
{componentPreviews.map((item) => {
|
|
54
|
+
const defaults = Object.fromEntries(
|
|
55
|
+
Object.entries(item.preview.controls ?? {}).map(([name, field]) => [name, field.defaultValue]),
|
|
56
|
+
);
|
|
57
|
+
const total = framesFromDuration(item.preview.canvas.duration, 30);
|
|
58
|
+
return (
|
|
59
|
+
<button key={item.id} type="button" className="card" onClick={() => onOpen("components", item.id)}>
|
|
60
|
+
<Thumbnail
|
|
61
|
+
entry={entryFor(
|
|
62
|
+
item,
|
|
63
|
+
{...defaults, ...(item.preview.examples[0]?.props ?? {})},
|
|
64
|
+
brand ?? resolveEntryLayout(videos[0]).brand,
|
|
65
|
+
{width: 1920, height: 1080},
|
|
66
|
+
)}
|
|
67
|
+
frame={Math.round(total * 0.6)}
|
|
68
|
+
durationInFrames={total}
|
|
69
|
+
/>
|
|
70
|
+
<div className="card-meta">
|
|
71
|
+
<strong>{item.preview.title}</strong>
|
|
72
|
+
<span>{item.preview.category.toLowerCase()}</span>
|
|
73
|
+
</div>
|
|
74
|
+
</button>
|
|
75
|
+
);
|
|
76
|
+
})}
|
|
77
|
+
</div>
|
|
78
|
+
</section>
|
|
79
|
+
|
|
80
|
+
<section>
|
|
81
|
+
<SectionTitle>Brands</SectionTitle>
|
|
82
|
+
<div className="home-swatches">
|
|
83
|
+
{brands.map((item) => (
|
|
84
|
+
<button key={item.name} type="button" className="brand-card" onClick={() => onOpen("brands", item.name)}>
|
|
85
|
+
<div className="swatch-colors">
|
|
86
|
+
{[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}} />
|
|
89
|
+
),
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
<div className="card-meta">
|
|
93
|
+
<strong>{item.name}</strong>
|
|
94
|
+
<span>
|
|
95
|
+
{item.typography.sans.split(",")[0].replace(/"/g, "")} · {item.audio.targetLufs} LUFS
|
|
96
|
+
</span>
|
|
97
|
+
</div>
|
|
98
|
+
</button>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
</section>
|
|
102
|
+
|
|
103
|
+
<section>
|
|
104
|
+
<SectionTitle>Assets</SectionTitle>
|
|
105
|
+
<ul className="asset-list">
|
|
106
|
+
{project.audio.slice(0, 4).map((entry) => (
|
|
107
|
+
<li key={entry.url}>
|
|
108
|
+
<AudioClip url={entry.url} label={entry.name} shape />
|
|
109
|
+
<div style={{display: "flex", flexDirection: "column", gap: 2, minWidth: 0}}>
|
|
110
|
+
<strong>{entry.name}</strong>
|
|
111
|
+
<code>{entry.url}</code>
|
|
112
|
+
</div>
|
|
113
|
+
</li>
|
|
114
|
+
))}
|
|
115
|
+
</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
|
+
</section>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
};
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import {useCallback, useEffect, useMemo, useRef, useState} from "react";
|
|
2
|
+
import {
|
|
3
|
+
DUCK_GAIN,
|
|
4
|
+
OdoriRuntime,
|
|
5
|
+
entryDurationInFrames,
|
|
6
|
+
isOdoriSchema,
|
|
7
|
+
resolveEntryLayout,
|
|
8
|
+
usePlayback,
|
|
9
|
+
useAudioPlayback,
|
|
10
|
+
type AudioTrack,
|
|
11
|
+
type CompiledTimeline,
|
|
12
|
+
type FieldDescriptor,
|
|
13
|
+
} from "odori";
|
|
14
|
+
import {PreviewBoundary} from "odori/preview";
|
|
15
|
+
import {project, videos} from "virtual:odori-project";
|
|
16
|
+
import {CanvasStage} from "../components/CanvasStage";
|
|
17
|
+
import {RATES, Transport} from "../components/Transport";
|
|
18
|
+
import {Thumbnail} from "../components/Thumbnail";
|
|
19
|
+
import {InputControls} from "../components/InputControls";
|
|
20
|
+
import {ExportPanel} from "../components/ExportPanel";
|
|
21
|
+
import {Diagnostics} from "../components/Diagnostics";
|
|
22
|
+
import {Badge, Button, Empty, Fact, SectionTitle, Separator} from "../components/ui";
|
|
23
|
+
import {measureTrack} from "../lib/mix-loudness";
|
|
24
|
+
import {useShortcuts} from "../shortcuts";
|
|
25
|
+
|
|
26
|
+
export const VideosView = ({
|
|
27
|
+
selection,
|
|
28
|
+
onSelect,
|
|
29
|
+
mode,
|
|
30
|
+
}: {
|
|
31
|
+
selection: string | null;
|
|
32
|
+
onSelect: (id: string) => void;
|
|
33
|
+
mode: "player" | "gallery";
|
|
34
|
+
}) => {
|
|
35
|
+
// Finding one by name is ⌘K; this list is for browsing them all.
|
|
36
|
+
const filtered = videos;
|
|
37
|
+
const selected = filtered.find((video) => video.metadata.id === selection) ?? filtered[0] ?? videos[0];
|
|
38
|
+
const [input, setInput] = useState<Record<string, unknown>>({});
|
|
39
|
+
const [timeline, setTimeline] = useState<CompiledTimeline | null>(null);
|
|
40
|
+
const [track, setTrack] = useState<AudioTrack | null>(null);
|
|
41
|
+
const [loop, setLoop] = useState(true);
|
|
42
|
+
const [muted, setMuted] = useState(false);
|
|
43
|
+
const [soloCue, setSoloCue] = useState<string | null>(null);
|
|
44
|
+
const [scrubbing, setScrubbing] = useState(false);
|
|
45
|
+
const [rate, setRate] = useState(1);
|
|
46
|
+
const [loudness, setLoudness] = useState<number | null>(null);
|
|
47
|
+
const [audioBlocked, setAudioBlocked] = useState(false);
|
|
48
|
+
const [audioFailed, setAudioFailed] = useState<string[]>([]);
|
|
49
|
+
const [showSafeArea, setShowSafeArea] = useState(false);
|
|
50
|
+
|
|
51
|
+
const layout = selected ? resolveEntryLayout(selected) : null;
|
|
52
|
+
const declared = selected && layout ? entryDurationInFrames(selected, layout) : 0;
|
|
53
|
+
const durationInFrames = Math.max(1, declared || timeline?.durationInFrames || 30);
|
|
54
|
+
const playback = usePlayback({
|
|
55
|
+
fps: layout?.format.fps ?? 30,
|
|
56
|
+
durationInFrames,
|
|
57
|
+
rate,
|
|
58
|
+
autoPlay: true,
|
|
59
|
+
loop,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Store compiled output only when it changes, so a runtime report can never
|
|
63
|
+
// feed a render loop.
|
|
64
|
+
const handleTimeline = useCallback(
|
|
65
|
+
(next: CompiledTimeline) =>
|
|
66
|
+
setTimeline((current) => (JSON.stringify(current) === JSON.stringify(next) ? current : next)),
|
|
67
|
+
[],
|
|
68
|
+
);
|
|
69
|
+
// Measuring decodes and renders the whole track, so it runs when the track
|
|
70
|
+
// changes rather than on every frame.
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const fps = layout?.format.fps;
|
|
73
|
+
if (!track || track.cues.length === 0 || !fps) {
|
|
74
|
+
setLoudness(null);
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
let live = true;
|
|
78
|
+
setLoudness(null);
|
|
79
|
+
void measureTrack(track, fps, durationInFrames).then((value) => {
|
|
80
|
+
if (live) setLoudness(value);
|
|
81
|
+
});
|
|
82
|
+
return () => {
|
|
83
|
+
live = false;
|
|
84
|
+
};
|
|
85
|
+
}, [durationInFrames, layout?.format.fps, track]);
|
|
86
|
+
|
|
87
|
+
const handleAudio = useCallback(
|
|
88
|
+
(next: AudioTrack) => setTrack((current) => (JSON.stringify(current) === JSON.stringify(next) ? current : next)),
|
|
89
|
+
[],
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
useAudioPlayback({
|
|
93
|
+
track,
|
|
94
|
+
frame: playback.frame,
|
|
95
|
+
fps: layout?.format.fps ?? 30,
|
|
96
|
+
playing: playback.playing,
|
|
97
|
+
muted,
|
|
98
|
+
soloCue,
|
|
99
|
+
scrubbing,
|
|
100
|
+
rate,
|
|
101
|
+
onBlocked: setAudioBlocked,
|
|
102
|
+
onFailed: setAudioFailed,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// The runtime publishes the timeline and the audio track from its own
|
|
106
|
+
// effects, which commit before this component's. Clearing them in an effect
|
|
107
|
+
// therefore discarded what the newly selected video had just published, and
|
|
108
|
+
// the track never came back. Resetting during render instead puts the clear
|
|
109
|
+
// ahead of the publish, which is the order the data actually flows in.
|
|
110
|
+
const [renderedId, setRenderedId] = useState(selected?.metadata.id);
|
|
111
|
+
if (selected?.metadata.id !== renderedId) {
|
|
112
|
+
setRenderedId(selected?.metadata.id);
|
|
113
|
+
setInput({});
|
|
114
|
+
setTimeline(null);
|
|
115
|
+
setTrack(null);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const previousId = useRef(selected?.metadata.id);
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
const id = selected?.metadata.id;
|
|
121
|
+
if (previousId.current === id) return;
|
|
122
|
+
previousId.current = id;
|
|
123
|
+
playback.seek(0);
|
|
124
|
+
playback.play();
|
|
125
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
126
|
+
}, [selected?.metadata.id]);
|
|
127
|
+
|
|
128
|
+
const scenes = timeline?.scenes ?? [];
|
|
129
|
+
const sceneJump = (direction: 1 | -1) => {
|
|
130
|
+
const boundaries = [0, ...scenes.map((scene) => scene.start), durationInFrames - 1];
|
|
131
|
+
const next =
|
|
132
|
+
direction === 1
|
|
133
|
+
? boundaries.find((boundary) => boundary > playback.frame)
|
|
134
|
+
: [...boundaries].reverse().find((boundary) => boundary < playback.frame);
|
|
135
|
+
playback.seek(next ?? playback.frame);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
useShortcuts({
|
|
139
|
+
" ": () => playback.toggle(),
|
|
140
|
+
ArrowRight: (event) => playback.step(event.shiftKey ? (layout?.format.fps ?? 30) : 1),
|
|
141
|
+
ArrowLeft: (event) => playback.step(event.shiftKey ? -(layout?.format.fps ?? 30) : -1),
|
|
142
|
+
"-": () => setRate((value) => RATES[Math.max(0, RATES.indexOf(value) - 1)] ?? value),
|
|
143
|
+
"=": () => setRate((value) => RATES[Math.min(RATES.length - 1, RATES.indexOf(value) + 1)] ?? value),
|
|
144
|
+
Home: () => playback.seek(0),
|
|
145
|
+
End: () => playback.seek(durationInFrames - 1),
|
|
146
|
+
"]": () => sceneJump(1),
|
|
147
|
+
"[": () => sceneJump(-1),
|
|
148
|
+
l: () => setLoop((value) => !value),
|
|
149
|
+
m: () => setMuted((value) => !value),
|
|
150
|
+
s: () => setShowSafeArea((value) => !value),
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (!selected || !layout) {
|
|
154
|
+
return (
|
|
155
|
+
<Empty title="No videos yet">
|
|
156
|
+
Create <code>{project.videosDir}/launch/video.tsx</code> or run <code>odori new launch</code>. A video exists
|
|
157
|
+
because its file exists.
|
|
158
|
+
</Empty>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (mode === "gallery") {
|
|
163
|
+
return (
|
|
164
|
+
<div className="gallery">
|
|
165
|
+
{filtered.map((video) => {
|
|
166
|
+
const videoLayout = resolveEntryLayout(video);
|
|
167
|
+
return (
|
|
168
|
+
<button
|
|
169
|
+
key={video.metadata.id}
|
|
170
|
+
type="button"
|
|
171
|
+
className="card"
|
|
172
|
+
data-active={video.metadata.id === selected.metadata.id ? "true" : undefined}
|
|
173
|
+
onClick={() => onSelect(video.metadata.id)}
|
|
174
|
+
>
|
|
175
|
+
<Thumbnail
|
|
176
|
+
entry={video}
|
|
177
|
+
frame={video.metadata.thumbnailFrame ?? Math.round(entryDurationInFrames(video, videoLayout) * 0.4)}
|
|
178
|
+
durationInFrames={entryDurationInFrames(video, videoLayout)}
|
|
179
|
+
/>
|
|
180
|
+
<div className="card-meta">
|
|
181
|
+
<strong>{video.metadata.title}</strong>
|
|
182
|
+
<span>
|
|
183
|
+
{videoLayout.format.width}x{videoLayout.format.height} · {String(video.metadata.duration ?? "auto")}
|
|
184
|
+
</span>
|
|
185
|
+
</div>
|
|
186
|
+
</button>
|
|
187
|
+
);
|
|
188
|
+
})}
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const schema = selected.metadata.schema;
|
|
194
|
+
const fields: Record<string, FieldDescriptor> = isOdoriSchema(schema) ? schema.describe() : {};
|
|
195
|
+
const file = project.files.videos.find((entry) => entry.id === selected.metadata.id)?.file;
|
|
196
|
+
|
|
197
|
+
return (
|
|
198
|
+
<>
|
|
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>
|
|
220
|
+
|
|
221
|
+
<section className="stage">
|
|
222
|
+
<CanvasStage
|
|
223
|
+
width={layout.format.width}
|
|
224
|
+
height={layout.format.height}
|
|
225
|
+
safeArea={layout.safeArea}
|
|
226
|
+
showSafeArea={showSafeArea}
|
|
227
|
+
>
|
|
228
|
+
{/* A composition that throws costs the stage, not Studio: the
|
|
229
|
+
transport, the inspector, and the file list stay usable while
|
|
230
|
+
the error is read and fixed. */}
|
|
231
|
+
<PreviewBoundary resetKey={selected.metadata.id}>
|
|
232
|
+
<OdoriRuntime
|
|
233
|
+
entry={selected}
|
|
234
|
+
frame={playback.frame}
|
|
235
|
+
input={input}
|
|
236
|
+
assets={project.assets}
|
|
237
|
+
onTimeline={handleTimeline}
|
|
238
|
+
onAudio={handleAudio}
|
|
239
|
+
/>
|
|
240
|
+
</PreviewBoundary>
|
|
241
|
+
</CanvasStage>
|
|
242
|
+
<Transport
|
|
243
|
+
playback={playback}
|
|
244
|
+
timeline={timeline}
|
|
245
|
+
track={track}
|
|
246
|
+
durationInFrames={durationInFrames}
|
|
247
|
+
fps={layout.format.fps}
|
|
248
|
+
loop={loop}
|
|
249
|
+
muted={muted}
|
|
250
|
+
soloCue={soloCue}
|
|
251
|
+
audioBlocked={audioBlocked}
|
|
252
|
+
onEnableAudio={() => setMuted(false)}
|
|
253
|
+
onSoloCue={setSoloCue}
|
|
254
|
+
onScrubbing={setScrubbing}
|
|
255
|
+
rate={rate}
|
|
256
|
+
onRate={setRate}
|
|
257
|
+
onToggleLoop={() => setLoop((value) => !value)}
|
|
258
|
+
onToggleMuted={() => setMuted((value) => !value)}
|
|
259
|
+
/>
|
|
260
|
+
</section>
|
|
261
|
+
|
|
262
|
+
<aside className="inspector">
|
|
263
|
+
<SectionTitle>Video</SectionTitle>
|
|
264
|
+
<dl className="facts">
|
|
265
|
+
<Fact label="id">{selected.metadata.id}</Fact>
|
|
266
|
+
<Fact label="format">
|
|
267
|
+
{layout.format.width}x{layout.format.height} at {layout.format.fps}fps
|
|
268
|
+
</Fact>
|
|
269
|
+
<Fact label="duration">
|
|
270
|
+
{durationInFrames}f · {(durationInFrames / layout.format.fps).toFixed(2)}s
|
|
271
|
+
</Fact>
|
|
272
|
+
<Fact label="brand">{layout.brand.name}</Fact>
|
|
273
|
+
{file ? <Fact label="source">{file}</Fact> : null}
|
|
274
|
+
{(selected.metadata.tags ?? []).length > 0 ? (
|
|
275
|
+
<Fact label="tags">
|
|
276
|
+
<span style={{display: "flex", flexWrap: "wrap", gap: 6}}>
|
|
277
|
+
{(selected.metadata.tags ?? []).map((tag) => (
|
|
278
|
+
<Badge key={tag}>{tag}</Badge>
|
|
279
|
+
))}
|
|
280
|
+
</span>
|
|
281
|
+
</Fact>
|
|
282
|
+
) : null}
|
|
283
|
+
</dl>
|
|
284
|
+
{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
|
+
|
|
291
|
+
{Object.keys(fields).length > 0 ? (
|
|
292
|
+
<>
|
|
293
|
+
<Separator />
|
|
294
|
+
<SectionTitle>Inputs</SectionTitle>
|
|
295
|
+
<InputControls fields={fields} value={input} onChange={setInput} />
|
|
296
|
+
</>
|
|
297
|
+
) : null}
|
|
298
|
+
|
|
299
|
+
{/* A silent cut is audible as silence, and the cue lane is empty, so
|
|
300
|
+
the section appears once there is a track to describe. */}
|
|
301
|
+
{track && track.cues.length > 0 ? (
|
|
302
|
+
<>
|
|
303
|
+
<Separator />
|
|
304
|
+
<SectionTitle>Audio</SectionTitle>
|
|
305
|
+
<dl className="facts">
|
|
306
|
+
{track.cues.map((cue) => (
|
|
307
|
+
<Fact key={cue.id} label={cue.src.split("/").pop() ?? cue.src}>
|
|
308
|
+
{cue.fromFrame}-{cue.fromFrame + cue.durationInFrames - 1} · gain {cue.gain}
|
|
309
|
+
{cue.duckUnder ? ` · ducked to ${DUCK_GAIN}` : ""}
|
|
310
|
+
{soloCue === cue.id ? " · solo" : ""}
|
|
311
|
+
{/* A cue whose file will not load is silence with no other
|
|
312
|
+
symptom, so it says so where its gain is read. */}
|
|
313
|
+
{audioFailed.includes(cue.src) ? (
|
|
314
|
+
<span style={{color: "var(--warning, #e5a663)"}}> · file will not load</span>
|
|
315
|
+
) : null}
|
|
316
|
+
</Fact>
|
|
317
|
+
))}
|
|
318
|
+
{/* Declared policy, so the number the encoder normalizes to is
|
|
319
|
+
visible while mixing rather than only after an export. */}
|
|
320
|
+
<Fact label="loudness">
|
|
321
|
+
{loudness === null ? "measuring" : `${loudness.toFixed(1)} LUFS`}
|
|
322
|
+
{" · "}
|
|
323
|
+
<span style={{color: "var(--subtle)"}}>
|
|
324
|
+
target {layout.brand.audio.targetLufs}, normalized on export
|
|
325
|
+
</span>
|
|
326
|
+
</Fact>
|
|
327
|
+
</dl>
|
|
328
|
+
</>
|
|
329
|
+
) : null}
|
|
330
|
+
|
|
331
|
+
<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>
|
|
341
|
+
</>
|
|
342
|
+
);
|
|
343
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
declare module "virtual:odori-project" {
|
|
2
|
+
import type {VideoEntry} from "odori";
|
|
3
|
+
import type {ComponentPreview} from "odori/preview";
|
|
4
|
+
|
|
5
|
+
import type {Brand} from "odori";
|
|
6
|
+
|
|
7
|
+
export const videos: VideoEntry[];
|
|
8
|
+
export const brands: Brand[];
|
|
9
|
+
export const componentPreviews: Array<{id: string; preview: ComponentPreview<any>}>;
|
|
10
|
+
export const project: {
|
|
11
|
+
root: string;
|
|
12
|
+
videosDir: string;
|
|
13
|
+
docsUrl: string;
|
|
14
|
+
exportDir: string;
|
|
15
|
+
audioDir: string;
|
|
16
|
+
audio: Array<{name: string; url: string; relativeFile: string; bytes: number}>;
|
|
17
|
+
sourceHash: string;
|
|
18
|
+
assets: Array<{reference: string; url: string}>;
|
|
19
|
+
files: {
|
|
20
|
+
videos: Array<{id: string; file: string}>;
|
|
21
|
+
previews: Array<{id: string; file: string}>;
|
|
22
|
+
brands: Array<{id: string; file: string}>;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
}
|