@odori/cli 0.0.2 → 0.0.3

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.
@@ -1,5 +1,5 @@
1
- import {useCallback, useEffect, useRef, useState} from "react";
2
- import {Badge, Button, Icon, SectionTitle} from "./ui";
1
+ import {useState} from "react";
2
+ import {Badge, Button, SectionTitle} from "./ui";
3
3
 
4
4
  type JobState = {
5
5
  id: string;
@@ -17,13 +17,30 @@ const tone = (status: string) => (status === "failed" ? "danger" : status === "r
17
17
  /** Jobs are identified by what they wrote, not by their manifest hash. */
18
18
  const fileName = (path?: string) => path?.split(/[\\/]/).pop();
19
19
 
20
- const clockTime = (value?: string) => {
21
- if (!value) return undefined;
22
- const parsed = new Date(value);
23
- return Number.isNaN(parsed.getTime()) ? undefined : parsed.toLocaleTimeString();
24
- };
20
+ /** What the file is: a codec choice, or one still frame. */
21
+ type Format = "mp4" | "webm" | "prores" | "gif" | "frame";
22
+ type Quality = "studio" | "social" | "web";
23
+
24
+ const EXTENSIONS: Record<Format, string> = {mp4: ".mp4", webm: ".webm", prores: ".mov", gif: ".gif", frame: ".png"};
25
+
26
+ const FORMAT_CHOICES: Array<{id: Format; label: string; hint: string}> = [
27
+ {id: "mp4", label: "MP4", hint: "H.264. Plays everywhere; the default."},
28
+ {id: "webm", label: "WebM", hint: "VP9 with alpha, for the web and overlays."},
29
+ {id: "prores", label: "ProRes", hint: "ProRes 4444 with alpha, for an editor."},
30
+ {id: "gif", label: "GIF", hint: "Palette-optimised animation. Silent, by the format."},
31
+ {id: "frame", label: "Frame", hint: "The current frame as a PNG."},
32
+ ];
33
+
34
+ const SCALES = [0.5, 1, 2];
35
+
36
+ const QUALITY_CHOICES: Array<{id: Quality; label: string; hint: string}> = [
37
+ {id: "studio", label: "Studio", hint: "Highest quality, for further editing."},
38
+ {id: "social", label: "Social", hint: "Tuned for upload pipelines that re-encode anyway."},
39
+ {id: "web", label: "Web", hint: "Smallest file that still looks intentional."},
40
+ ];
25
41
 
26
- type Mode = "video" | "frame" | "clipboard";
42
+ /** Even dimensions, the way the encoder's scale filter lands them. */
43
+ const scaled = (value: number, scale: number) => Math.trunc((value * scale) / 2) * 2;
27
44
 
28
45
  /** Ids are paths under videos/, output files are flat. */
29
46
  const outputName = (videoId: string) => videoId.split("/").join("-");
@@ -37,36 +54,21 @@ export const ExportPanel = ({
37
54
  videoId,
38
55
  input,
39
56
  frame,
40
- exportDir,
57
+ width,
58
+ height,
41
59
  }: {
42
60
  videoId: string;
43
61
  input: Record<string, unknown>;
44
62
  frame: number;
45
- exportDir: string;
63
+ width: number;
64
+ height: number;
46
65
  }) => {
47
66
  const [job, setJob] = useState<JobState | null>(null);
48
67
  const [busy, setBusy] = useState(false);
49
- const [mode, setMode] = useState<Mode>("video");
50
- const [menuOpen, setMenuOpen] = useState(false);
51
- const [dropUp, setDropUp] = useState(false);
68
+ const [format, setFormat] = useState<Format>("mp4");
69
+ const [quality, setQuality] = useState<Quality>("studio");
70
+ const [scale, setScale] = useState(1);
52
71
  const [notice, setNotice] = useState<string | null>(null);
53
- const menuRef = useRef<HTMLDivElement | null>(null);
54
-
55
- useEffect(() => {
56
- if (!menuOpen) return undefined;
57
- const dismiss = (event: MouseEvent) => {
58
- if (!menuRef.current?.contains(event.target as Node)) setMenuOpen(false);
59
- };
60
- const onKey = (event: KeyboardEvent) => {
61
- if (event.key === "Escape") setMenuOpen(false);
62
- };
63
- document.addEventListener("mousedown", dismiss);
64
- document.addEventListener("keydown", onKey);
65
- return () => {
66
- document.removeEventListener("mousedown", dismiss);
67
- document.removeEventListener("keydown", onKey);
68
- };
69
- }, [menuOpen]);
70
72
 
71
73
  const poll = async (id: string) => {
72
74
  for (let attempt = 0; attempt < 900; attempt += 1) {
@@ -130,74 +132,110 @@ export const ExportPanel = ({
130
132
  }
131
133
  };
132
134
 
133
- const run = async (next: Mode) => {
134
- setMenuOpen(false);
135
- setMode(next);
135
+ const run = async () => {
136
136
  setNotice(null);
137
- if (next === "clipboard") return copyFrame();
138
- if (next === "frame") return post("/__odori/still", {videoId, input, frame});
139
- return post("/__odori/exports", {videoId, input});
137
+ if (format === "frame") return post("/__odori/still", {videoId, input, frame});
138
+ return post("/__odori/exports", {videoId, input, format, quality, scale});
140
139
  };
141
140
 
142
141
  const file = outputName(videoId);
143
- // Destinations name a pattern, never the live frame: the transport already
144
- // reports the playhead, and a number that moves 30 times a second here reads
145
- // as activity rather than as a label.
146
- const modes: Array<{id: Mode; label: string; hint: string}> = [
147
- {id: "video", label: "MP4 video", hint: `Encode the timeline to ${exportDir}/${file}.mp4`},
148
- {id: "frame", label: "PNG frame", hint: `Write the current frame to ${exportDir}/${file}-<frame>.png`},
149
- {id: "clipboard", label: "Frame to clipboard", hint: "Copy the current frame as an image, writing no file"},
150
- ];
151
-
152
- const actionLabel = mode === "video" ? "Export MP4" : mode === "clipboard" ? "Copy frame" : "Export frame";
142
+ // Frame rate is deliberately not an option here: fps is authored in the
143
+ // video's layout and every frame number in the project depends on it.
144
+ const still = format === "frame";
145
+ const hasQuality = format === "mp4" || format === "webm";
146
+ const inFlight = job && job.status !== "ready" && job.status !== "failed";
147
+ // The button names the file it will write - extension implied by the type
148
+ // above it - and reports its own progress while writing it.
149
+ const actionLabel = inFlight
150
+ ? `${job.status === "queued" ? "Queued" : job.status === "encoding" ? "Encoding" : "Rendering"} ${Math.round((job.progress ?? 0) * 100)}%`
151
+ : busy
152
+ ? "Working"
153
+ : still
154
+ ? `Export ${file}-${frame}`
155
+ : `Export ${file}`;
153
156
 
154
157
  return (
155
- <div>
158
+ <div className="export-panel">
156
159
  <SectionTitle>Export</SectionTitle>
157
- <div className="split" ref={menuRef}>
160
+
161
+ <div className="export-row">
162
+ <span className="export-label">Type</span>
163
+ <div className="export-options">
164
+ {FORMAT_CHOICES.map((item) => (
165
+ <Button
166
+ key={item.id}
167
+ variant="outline"
168
+ active={item.id === format}
169
+ disabled={busy}
170
+ title={item.hint}
171
+ onClick={() => setFormat(item.id)}
172
+ >
173
+ {item.label}
174
+ </Button>
175
+ ))}
176
+ </div>
177
+ </div>
178
+
179
+ {/* A still is a browser screenshot at the composition's own size, so
180
+ the encoder options do not apply to it. */}
181
+ {still ? null : (
182
+ <div className="export-row">
183
+ <span className="export-label">Size</span>
184
+ <div className="export-options">
185
+ {SCALES.map((option) => (
186
+ <Button
187
+ key={option}
188
+ variant="outline"
189
+ active={option === scale}
190
+ disabled={busy}
191
+ onClick={() => setScale(option)}
192
+ >
193
+ {option}x
194
+ </Button>
195
+ ))}
196
+ </div>
197
+ <span className="export-dimensions">
198
+ {scaled(width, scale)} × {scaled(height, scale)}
199
+ </span>
200
+ </div>
201
+ )}
202
+
203
+ {hasQuality ? (
204
+ <div className="export-row">
205
+ <span className="export-label">Quality</span>
206
+ <div className="export-options">
207
+ {QUALITY_CHOICES.map((item) => (
208
+ <Button
209
+ key={item.id}
210
+ variant="outline"
211
+ active={item.id === quality}
212
+ disabled={busy}
213
+ title={item.hint}
214
+ onClick={() => setQuality(item.id)}
215
+ >
216
+ {item.label}
217
+ </Button>
218
+ ))}
219
+ </div>
220
+ </div>
221
+ ) : null}
222
+
223
+ <div className="export-actions">
158
224
  <Button
159
225
  variant="primary"
160
226
  disabled={busy}
161
- onClick={() => void run(mode)}
162
- title={modes.find((item) => item.id === mode)?.hint}
227
+ onClick={() => void run()}
228
+ title={
229
+ still
230
+ ? `Write ${file}-${frame}.png to your Downloads folder`
231
+ : `Encode the timeline to ${file}${EXTENSIONS[format]} in your Downloads folder`
232
+ }
163
233
  >
164
- {actionLabel}
234
+ <span>{actionLabel}</span>
165
235
  </Button>
166
- <Button
167
- variant="primary"
168
- disabled={busy}
169
- icon
170
- aria-haspopup="menu"
171
- aria-expanded={menuOpen}
172
- aria-label="Choose what to export"
173
- onClick={() => {
174
- // The panel sits low in the sidebar, so the menu opens toward
175
- // whichever edge has room for it.
176
- const bounds = menuRef.current?.getBoundingClientRect();
177
- if (bounds) setDropUp(window.innerHeight - bounds.bottom < 180);
178
- setMenuOpen((open) => !open);
179
- }}
180
- >
181
- <Icon name="caret" />
236
+ <Button variant="outline" disabled={busy} onClick={() => void copyFrame()} title="Copy the current frame as an image, writing no file">
237
+ <span>Copy frame</span>
182
238
  </Button>
183
- {menuOpen ? (
184
- <div className="menu" role="menu" data-placement={dropUp ? "up" : "down"}>
185
- {modes.map((item) => (
186
- <button
187
- key={item.id}
188
- type="button"
189
- role="menuitemradio"
190
- aria-checked={item.id === mode}
191
- className="menu-item"
192
- data-active={item.id === mode ? "true" : undefined}
193
- onClick={() => void run(item.id)}
194
- >
195
- <span>{item.label}</span>
196
- <span className="hint">{item.hint}</span>
197
- </button>
198
- ))}
199
- </div>
200
- ) : null}
201
239
  </div>
202
240
 
203
241
  {notice ? (
@@ -223,11 +261,7 @@ export const ExportPanel = ({
223
261
  </div>
224
262
  ) : null}
225
263
  </>
226
- ) : (
227
- <p className="hint" style={{marginTop: 8}}>
228
- Preview needs no encode. Export writes {exportDir}/{file}.mp4 from the frozen manifest.
229
- </p>
230
- )}
264
+ ) : null}
231
265
 
232
266
  </div>
233
267
  );
@@ -0,0 +1,121 @@
1
+ import {useEffect, useState} from "react";
2
+ import type {ReactNode} from "react";
3
+ import {Icon} from "./ui";
4
+
5
+ /**
6
+ * The right-hand pane, with its width and its presence under the reader's
7
+ * control.
8
+ *
9
+ * Studio lives beside an editor or an agent pane at least as often as it owns
10
+ * the window, and at those widths a fixed inspector is what makes the whole
11
+ * app feel unusable: the stage keeps the leftovers. The width is a CSS
12
+ * variable on the root so the grid in `.main` can read it, and both the width
13
+ * and the collapsed state persist per browser, because a pane that snaps back
14
+ * on reload was never adjustable.
15
+ */
16
+ const WIDTH_VAR = "--inspector-width";
17
+ const STORAGE_KEY = "odori-inspector-width";
18
+ const COLLAPSED_KEY = "odori-inspector-collapsed";
19
+ const MIN = 240;
20
+ const MAX = 560;
21
+
22
+ const clampWidth = (value: number): number =>
23
+ Math.round(Math.min(Math.max(value, MIN), Math.min(MAX, window.innerWidth * 0.5)));
24
+
25
+ const applyWidth = (value: number) => {
26
+ document.documentElement.style.setProperty(WIDTH_VAR, `${clampWidth(value)}px`);
27
+ };
28
+
29
+ export const Inspector = ({
30
+ title,
31
+ hint,
32
+ children,
33
+ }: {
34
+ /** What this pane is about, as a person names it. */
35
+ title?: string;
36
+ /** The longer form, one hover away - usually the source path. */
37
+ hint?: string;
38
+ children: ReactNode;
39
+ }) => {
40
+ const [collapsed, setCollapsed] = useState(() => window.localStorage.getItem(COLLAPSED_KEY) === "true");
41
+
42
+ useEffect(() => {
43
+ const stored = Number(window.localStorage.getItem(STORAGE_KEY));
44
+ if (Number.isFinite(stored) && stored > 0) applyWidth(stored);
45
+ }, []);
46
+
47
+ // The grid in .main reads this, so collapsing costs no React re-layout.
48
+ useEffect(() => {
49
+ document.documentElement.dataset.inspector = collapsed ? "collapsed" : "open";
50
+ window.localStorage.setItem(COLLAPSED_KEY, String(collapsed));
51
+ return () => {
52
+ delete document.documentElement.dataset.inspector;
53
+ };
54
+ }, [collapsed]);
55
+
56
+ if (collapsed) {
57
+ return (
58
+ <button
59
+ type="button"
60
+ className="inspector-reopen"
61
+ aria-label="Show inspector"
62
+ title="Show inspector"
63
+ onClick={() => setCollapsed(false)}
64
+ >
65
+ <Icon name="sidebar" />
66
+ </button>
67
+ );
68
+ }
69
+
70
+ return (
71
+ <aside className="inspector">
72
+ <div
73
+ className="inspector-resize"
74
+ role="separator"
75
+ aria-orientation="vertical"
76
+ aria-label="Resize inspector"
77
+ onPointerDown={(event) => {
78
+ // The handle is eight pixels wide; a fast drag leaves it between
79
+ // events. The window sees every move, so the listeners go there
80
+ // for the duration of the drag rather than relying on capture.
81
+ event.preventDefault();
82
+ document.body.style.userSelect = "none";
83
+ document.body.style.cursor = "col-resize";
84
+ const onMove = (move: PointerEvent) => applyWidth(window.innerWidth - move.clientX);
85
+ const onUp = () => {
86
+ window.removeEventListener("pointermove", onMove);
87
+ window.removeEventListener("pointerup", onUp);
88
+ document.body.style.userSelect = "";
89
+ document.body.style.cursor = "";
90
+ const width = getComputedStyle(document.documentElement).getPropertyValue(WIDTH_VAR).trim();
91
+ if (width) window.localStorage.setItem(STORAGE_KEY, String(Number.parseInt(width, 10)));
92
+ };
93
+ window.addEventListener("pointermove", onMove);
94
+ window.addEventListener("pointerup", onUp);
95
+ }}
96
+ onDoubleClick={() => {
97
+ // Back to the default, the way a draggable divider usually resets.
98
+ document.documentElement.style.removeProperty(WIDTH_VAR);
99
+ window.localStorage.removeItem(STORAGE_KEY);
100
+ }}
101
+ />
102
+ <div className="inspector-scroll">
103
+ <div className="inspector-head">
104
+ <span className="inspector-title" title={hint ?? title}>
105
+ {title}
106
+ </span>
107
+ <button
108
+ type="button"
109
+ className="inspector-toggle"
110
+ aria-label="Hide inspector"
111
+ title="Hide inspector"
112
+ onClick={() => setCollapsed(true)}
113
+ >
114
+ <Icon name="sidebar" />
115
+ </button>
116
+ </div>
117
+ {children}
118
+ </div>
119
+ </aside>
120
+ );
121
+ };
@@ -1,29 +1,49 @@
1
- import {useState} from "react";
2
- import {OdoriRuntime, resolveEntryLayout, usePlayback, type VideoEntry} from "odori";
1
+ import {useEffect, useRef, useState} from "react";
2
+ import {OdoriRuntime, resolveEntryLayout, usePlayback, type AudioTrack, type VideoEntry} from "odori";
3
3
  import {PreviewBoundary} from "odori/preview";
4
4
  import {project} from "virtual:odori-project";
5
5
  import {useFitScale} from "./CanvasStage";
6
+ import {Icon} from "./ui";
6
7
 
7
8
  /**
8
- * A gallery thumbnail is the real composition at a chosen frame, letterboxed
9
- * into a uniform card. Hovering plays it through the same runtime, so the
10
- * catalog can never drift from the render.
9
+ * A gallery thumbnail is the real composition, playing, letterboxed into a
10
+ * uniform card. It plays while it is on screen and rests while it is not —
11
+ * motion is what these cards are for, and asking for a hover to see any of it
12
+ * made the gallery read as a wall of stills. Offscreen cards pause, so a
13
+ * library of eighty costs what the visible rows cost.
11
14
  */
12
15
  export const Thumbnail = ({
13
16
  entry,
14
17
  frame = 0,
15
18
  durationInFrames,
16
- playOnHover = true,
19
+ audioBadge = false,
17
20
  }: {
18
21
  entry: VideoEntry;
19
22
  frame?: number;
20
23
  durationInFrames?: number;
21
- playOnHover?: boolean;
24
+ /** Mark the card when the composition declares sound. Videos only; a cue
25
+ card is nothing but sound and the chip would restate every one. */
26
+ audioBadge?: boolean;
22
27
  }) => {
23
28
  const layout = resolveEntryLayout(entry);
24
29
  const {width, height, fps} = layout.format;
25
30
  const [container, scale] = useFitScale(width, height);
26
- const [hovering, setHovering] = useState(false);
31
+ const [visible, setVisible] = useState(false);
32
+ const [hasAudio, setHasAudio] = useState(false);
33
+ const [fontsReady, setFontsReady] = useState(false);
34
+
35
+ // Brand fonts load after the composition mounts, and text set in a face
36
+ // that arrives late repaints as a visible shift across every card. The
37
+ // 16:9 box holds the layout; the pixels wait for the faces.
38
+ useEffect(() => {
39
+ let live = true;
40
+ void document.fonts.ready.then(() => {
41
+ if (live) setFontsReady(true);
42
+ });
43
+ return () => {
44
+ live = false;
45
+ };
46
+ }, []);
27
47
  const playback = usePlayback({
28
48
  fps,
29
49
  durationInFrames: Math.max(1, durationInFrames ?? fps * 8),
@@ -32,23 +52,39 @@ export const Thumbnail = ({
32
52
  loop: true,
33
53
  });
34
54
 
35
- const hover = (next: boolean) => {
36
- if (!playOnHover) return;
37
- setHovering(next);
38
- if (next) playback.play();
39
- else {
40
- playback.pause();
41
- playback.seek(frame);
55
+ // usePlayback's play/pause identities follow its internal state; keeping
56
+ // them out of the dependencies makes visibility the only trigger.
57
+ const playbackRef = useRef(playback);
58
+ playbackRef.current = playback;
59
+
60
+ useEffect(() => {
61
+ const element = container.current;
62
+ if (!element) return undefined;
63
+ const observer = new IntersectionObserver(
64
+ ([intersection]) => setVisible(intersection?.isIntersecting ?? false),
65
+ {rootMargin: "64px"},
66
+ );
67
+ observer.observe(element);
68
+ return () => observer.disconnect();
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, []);
71
+
72
+ useEffect(() => {
73
+ if (visible) {
74
+ playbackRef.current.play();
75
+ return;
42
76
  }
43
- };
77
+ playbackRef.current.pause();
78
+ playbackRef.current.seek(frame);
79
+ }, [visible, frame]);
44
80
 
45
81
  return (
46
- <div
47
- className="card-canvas"
48
- ref={container}
49
- onPointerEnter={() => hover(true)}
50
- onPointerLeave={() => hover(false)}
51
- >
82
+ <div className="card-canvas" ref={container}>
83
+ {audioBadge && hasAudio ? (
84
+ <span className="card-audio" title="Has audio">
85
+ <Icon name="sound" />
86
+ </span>
87
+ ) : null}
52
88
  {/* Absolutely positioned so the composition never sizes the card. */}
53
89
  <div
54
90
  style={{
@@ -57,13 +93,19 @@ export const Thumbnail = ({
57
93
  position: "absolute",
58
94
  top: "50%",
59
95
  transform: `translate(-50%, -50%) scale(${scale})`,
96
+ visibility: fontsReady ? undefined : "hidden",
60
97
  width,
61
98
  }}
62
99
  >
63
100
  {/* A card in a grid of forty: one broken component must not blank
64
101
  the library it is listed in. */}
65
102
  <PreviewBoundary resetKey={entry.metadata.id} label="Threw">
66
- <OdoriRuntime entry={entry} frame={hovering ? playback.frame : frame} assets={project.assets} />
103
+ <OdoriRuntime
104
+ entry={entry}
105
+ frame={visible ? playback.frame : frame}
106
+ assets={project.assets}
107
+ onAudio={audioBadge ? (track: AudioTrack) => setHasAudio(track.cues.length > 0) : undefined}
108
+ />
67
109
  </PreviewBoundary>
68
110
  </div>
69
111
  </div>
@@ -35,9 +35,9 @@ export const Separator = () => <div className="separator" />;
35
35
 
36
36
  export const SectionTitle = ({children}: {children: ReactNode}) => <h2 className="section-title">{children}</h2>;
37
37
 
38
- export const Fact = ({label, children}: {label: string; children: ReactNode}) => (
38
+ export const Fact = ({label, title, children}: {label: string; title?: string; children: ReactNode}) => (
39
39
  <div className="fact">
40
- <dt>{label}</dt>
40
+ <dt title={title}>{label}</dt>
41
41
  <dd>{children}</dd>
42
42
  </div>
43
43
  );
@@ -68,6 +68,7 @@ export const Icon = ({
68
68
  | "moon"
69
69
  | "display"
70
70
  | "external"
71
+ | "sidebar"
71
72
  | "search";
72
73
  }) => {
73
74
  const paths: Record<string, ReactNode> = {
@@ -92,6 +93,12 @@ export const Icon = ({
92
93
  </>
93
94
  ),
94
95
  caret: <path d="M3.6 5.6 7 9l3.4-3.4" fill="none" stroke="currentColor" strokeWidth="1.4" />,
96
+ sidebar: (
97
+ <>
98
+ <rect x="1.9" y="2.4" width="10.2" height="9.2" rx="1.2" fill="none" stroke="currentColor" strokeWidth="1.2" />
99
+ <path d="M8.9 2.4v9.2" fill="none" stroke="currentColor" strokeWidth="1.2" />
100
+ </>
101
+ ),
95
102
  search: (
96
103
  <>
97
104
  <circle cx="6.2" cy="6.2" r="3.4" fill="none" stroke="currentColor" strokeWidth="1.3" />