@odori/cli 0.0.4 → 0.0.6

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,10 +1,20 @@
1
- import {useEffect, useRef} from "react";
1
+ import {useEffect, useRef, useState} from "react";
2
2
  import {formatTimecode, type AudioTrack, type CompiledTimeline, type Playback} from "odori";
3
3
  import {Button, Icon} from "./ui";
4
4
 
5
5
  /**
6
6
  * Transport plus a scene-aware timeline. The track is the scene structure the
7
7
  * runtime compiled, not a decorative bar.
8
+ *
9
+ * The playhead is not draggable. Dragging one is a commitment: the pointer is
10
+ * captured, so a release outside the window can be missed and leave the
11
+ * transport stuck mid-scrub, and every intermediate frame is a real seek, which
12
+ * is what made scrubbing sound like a stuck record. Hovering costs nothing and
13
+ * answers the same question - what is at this moment - so the track previews
14
+ * under the cursor and a click is what actually moves the playhead.
15
+ *
16
+ * Preview is a paused-only affordance. A second marker chasing the cursor while
17
+ * the video plays is two playheads disagreeing about where you are.
8
18
  */
9
19
  export const RATES = [0.25, 0.5, 1, 1.5, 2, 4];
10
20
 
@@ -22,7 +32,7 @@ export const Transport = ({
22
32
  onToggleLoop,
23
33
  onToggleMuted,
24
34
  onSoloCue,
25
- onScrubbing,
35
+ onPreview,
26
36
  rate = 1,
27
37
  onRate,
28
38
  }: {
@@ -41,65 +51,87 @@ export const Transport = ({
41
51
  onToggleLoop: () => void;
42
52
  onToggleMuted?: () => void;
43
53
  onSoloCue?: (id: string | null) => void;
44
- onScrubbing?: (scrubbing: boolean) => void;
54
+ /**
55
+ * The frame under the cursor, or null when the cursor is off the track. The
56
+ * stage renders this instead of the playhead frame, so hovering shows the
57
+ * moment without committing to it.
58
+ */
59
+ onPreview?: (frame: number | null) => void;
45
60
  rate?: number;
46
61
  onRate?: (rate: number) => void;
47
62
  }) => {
48
63
  const trackRef = useRef<HTMLDivElement>(null);
49
- const scrubbing = useRef(false);
50
- // Scrubbing pauses the clock so the drag owns the playhead, then hands
51
- // playback back in the state it was in. Picking a frame is not a request to
52
- // stop.
53
- const resumeAfterScrub = useRef(false);
54
- // A click picks a frame; only a drag auditions. Audio waits for movement so
55
- // choosing a frame while paused stays silent.
56
- const dragOrigin = useRef(0);
57
- const auditioning = useRef(false);
64
+ const [preview, setPreview] = useState<number | null>(null);
65
+ /* How much room the track has, so the ruler can be drawn for it rather than
66
+ for an assumed window. Studio is used docked beside an agent as often as
67
+ it is used full width. */
68
+ const [width, setWidth] = useState(0);
58
69
  const {frame} = playback;
59
70
 
60
- const scrubTo = (clientX: number) => {
71
+ /** The frame under a given client x, or null if the track has no box yet. */
72
+ const frameAt = (clientX: number): number | null => {
61
73
  const element = trackRef.current;
62
- if (!element) return;
74
+ if (!element) return null;
63
75
  const bounds = element.getBoundingClientRect();
76
+ if (bounds.width === 0) return null;
64
77
  const ratio = Math.max(0, Math.min(1, (clientX - bounds.left) / bounds.width));
65
- playback.seek(Math.round(ratio * (durationInFrames - 1)));
78
+ return Math.round(ratio * (durationInFrames - 1));
66
79
  };
67
80
 
68
81
  useEffect(() => {
69
- const move = (event: PointerEvent) => {
70
- if (!scrubbing.current) return;
71
- if (!auditioning.current && Math.abs(event.clientX - dragOrigin.current) > 3) {
72
- auditioning.current = true;
73
- onScrubbing?.(true);
74
- }
75
- scrubTo(event.clientX);
76
- };
77
- const up = () => {
78
- if (!scrubbing.current) return;
79
- scrubbing.current = false;
80
- if (auditioning.current) {
81
- auditioning.current = false;
82
- onScrubbing?.(false);
83
- }
84
- if (resumeAfterScrub.current) {
85
- resumeAfterScrub.current = false;
86
- playback.play();
87
- }
88
- };
89
- window.addEventListener("pointermove", move);
90
- window.addEventListener("pointerup", up);
91
- return () => {
92
- window.removeEventListener("pointermove", move);
93
- window.removeEventListener("pointerup", up);
94
- };
95
- });
82
+ const element = trackRef.current;
83
+ if (!element) return undefined;
84
+ const observer = new ResizeObserver(([entry]) => setWidth(entry?.contentRect.width ?? 0));
85
+ observer.observe(element);
86
+ return () => observer.disconnect();
87
+ }, []);
88
+
89
+ const showPreview = (next: number | null) => {
90
+ setPreview(next);
91
+ onPreview?.(next);
92
+ };
96
93
 
94
+ /* Playing is the one state with no preview, so starting playback while the
95
+ cursor rests on the track clears the marker it left behind. In an effect,
96
+ not in render: the parent owns the previewed frame and telling it during
97
+ our own render would be a write into somebody else's component. */
98
+ const playing = playback.playing;
99
+ useEffect(() => {
100
+ if (playing) {
101
+ setPreview(null);
102
+ onPreview?.(null);
103
+ }
104
+ // eslint-disable-next-line react-hooks/exhaustive-deps
105
+ }, [playing]);
106
+
107
+ /** What the stage is showing: the previewed frame wins while hovering. */
108
+ const shown = preview ?? frame;
97
109
  const scenes = timeline?.scenes ?? [];
98
- const active = scenes.find((scene) => frame >= scene.start && frame < scene.start + scene.durationInFrames);
110
+ const active = scenes.find((scene) => shown >= scene.start && shown < scene.start + scene.durationInFrames);
111
+
112
+ /* The widest each readout will get, in characters. The transport is set in
113
+ the mono face, so a `ch` is exactly one column and this is a measurement
114
+ rather than an estimate. */
115
+ const digits = String(Math.max(0, durationInFrames - 1)).length;
116
+ const frameChars = "frame of ".length + digits * 2;
117
+ const sceneChars =
118
+ scenes.length > 0
119
+ ? "scene ".length + Math.max(...scenes.map((scene) => (scene.name ?? scene.id).length))
120
+ : 0;
121
+
122
+ /*
123
+ * The ruler is drawn to the width it actually has. A fixed twelve marks is
124
+ * fine in a wide window and unreadable in a narrow one, where "10.0s" next
125
+ * to "11.0s" in eighteen pixels is a smear rather than a scale - and where
126
+ * two marks could round to the same frame, which React counted as a
127
+ * duplicate key. Roughly fifty pixels is what one label needs to stand
128
+ * alone.
129
+ */
99
130
  const seconds = Math.max(1, Math.round(durationInFrames / fps));
100
- const ticks = Array.from({length: Math.min(seconds, 12) + 1}, (_, index) =>
101
- Math.round((index * durationInFrames) / Math.min(seconds, 12)),
102
- );
131
+ const divisions = Math.max(1, Math.min(seconds, Math.floor(width / 52) || 1, 12));
132
+ const ticks = [...new Set(
133
+ Array.from({length: divisions + 1}, (_, index) => Math.round((index * durationInFrames) / divisions)),
134
+ )];
103
135
 
104
136
  return (
105
137
  <div className="transport">
@@ -114,12 +146,23 @@ export const Transport = ({
114
146
  <div
115
147
  className="track"
116
148
  ref={trackRef}
117
- onPointerDown={(event) => {
118
- scrubbing.current = true;
119
- dragOrigin.current = event.clientX;
120
- resumeAfterScrub.current = playback.playing;
121
- playback.pause();
122
- scrubTo(event.clientX);
149
+ data-preview={preview !== null ? "true" : undefined}
150
+ onPointerMove={(event) => {
151
+ // Touch reports a move before its tap, and a marker that appears
152
+ // under a finger and stays there is worse than none.
153
+ if (playing || event.pointerType === "touch") return;
154
+ showPreview(frameAt(event.clientX));
155
+ }}
156
+ onPointerLeave={() => showPreview(null)}
157
+ onClick={(event) => {
158
+ const next = frameAt(event.clientX);
159
+ if (next === null) return;
160
+ /* Picking a frame is not a request to stop. A cut that is running
161
+ keeps running from where you pointed, which is what every player
162
+ does and what the audio guide already promised; a paused one
163
+ stays where you put it. */
164
+ playback.seek(next);
165
+ showPreview(null);
123
166
  }}
124
167
  >
125
168
  {scenes.length > 0 ? (
@@ -139,9 +182,22 @@ export const Transport = ({
139
182
  continuous timeline
140
183
  </div>
141
184
  )}
185
+ {preview !== null && preview !== frame ? (
186
+ <div
187
+ className="playhead"
188
+ data-preview="true"
189
+ aria-hidden="true"
190
+ style={{left: `${(preview / Math.max(1, durationInFrames - 1)) * 100}%`}}
191
+ />
192
+ ) : null}
142
193
  <div className="playhead" style={{left: `${(frame / Math.max(1, durationInFrames - 1)) * 100}%`}} />
143
194
  </div>
144
195
 
196
+ {/* Always present, the way the scene track always is.
197
+ It used to appear only once the runtime reported cues, which is
198
+ after the first paint, so the transport grew 26px and took them off
199
+ the stage on every load. A lane that is there and empty also says
200
+ something a missing one cannot: this video is silent. */}
145
201
  {track && track.cues.length > 0 ? (
146
202
  <div className="audio-track">
147
203
  {track.cues.map((cue) => {
@@ -171,7 +227,11 @@ export const Transport = ({
171
227
  );
172
228
  })}
173
229
  </div>
174
- ) : null}
230
+ ) : (
231
+ <div className="audio-track" data-empty="true">
232
+ <span>{track ? "no audio" : "\u00a0"}</span>
233
+ </div>
234
+ )}
175
235
  </div>
176
236
 
177
237
  <div className="transport-row">
@@ -224,13 +284,31 @@ export const Transport = ({
224
284
  </Button>
225
285
  ) : null}
226
286
 
227
- <span className="timecode" style={{marginLeft: 8}}>
228
- <b>{formatTimecode(frame, fps)}</b> / {formatTimecode(durationInFrames - 1, fps)}
287
+ {/* The readout follows the picture, not the playhead: while hovering,
288
+ the stage is showing the previewed frame and a counter reporting
289
+ some other number is just wrong. */}
290
+ <span className="timecode" style={{marginLeft: 8}} data-preview={preview !== null ? "true" : undefined}>
291
+ <b>{formatTimecode(shown, fps)}</b> / {formatTimecode(durationInFrames - 1, fps)}
292
+ </span>
293
+ {/* Both of these change width as the cursor sweeps: the frame number
294
+ grows a digit at a time, and a scene called Resolution is half
295
+ again as wide as one called Proof. Tabular figures fix the digits
296
+ and do nothing for the count or the word, so each reserves the
297
+ widest it will ever need. Without that the row reflows under the
298
+ cursor and, in a pane narrow enough for it to wrap, takes a line
299
+ back from the stage while you are looking at it. */}
300
+ <span
301
+ className="timecode"
302
+ style={{minWidth: `${frameChars}ch`}}
303
+ data-preview={preview !== null ? "true" : undefined}
304
+ >
305
+ frame <b>{shown}</b> of {durationInFrames - 1}
229
306
  </span>
230
- <span className="timecode">
231
- frame <b>{frame}</b> of {durationInFrames - 1}
307
+ {/* Rendered even with no scene under the cursor, so a gap in the
308
+ timeline does not collapse the box and shuffle the row. */}
309
+ <span className="timecode" style={{minWidth: `${sceneChars}ch`}}>
310
+ {active ? <>scene <b>{active.name ?? active.id}</b></> : null}
232
311
  </span>
233
- {active ? <span className="timecode">scene <b>{active.name ?? active.id}</b></span> : null}
234
312
  </div>
235
313
  </div>
236
314
  );
@@ -1,4 +1,4 @@
1
- import type {ButtonHTMLAttributes, ReactNode} from "react";
1
+ import type {ButtonHTMLAttributes, ReactNode, Ref} from "react";
2
2
 
3
3
  export const Button = ({
4
4
  variant = "ghost",
@@ -10,6 +10,8 @@ export const Button = ({
10
10
  variant?: "ghost" | "outline" | "primary";
11
11
  active?: boolean;
12
12
  icon?: boolean;
13
+ /** React 19 passes ref as an ordinary prop; the type has to say so. */
14
+ ref?: Ref<HTMLButtonElement>;
13
15
  }) => (
14
16
  <button
15
17
  type="button"
@@ -69,10 +71,27 @@ export const Icon = ({
69
71
  | "display"
70
72
  | "external"
71
73
  | "sidebar"
74
+ | "settings"
72
75
  | "search";
73
76
  }) => {
74
77
  const paths: Record<string, ReactNode> = {
75
78
  play: <path d="M4.5 2.8v8.4l7-4.2z" fill="currentColor" />,
79
+ /* The ordinary gear, on Lucide's 24 grid scaled to this 14 one. Drawing a
80
+ new one by eye is how you get a gear that reads as almost right; the
81
+ stroke is doubled so it comes out at the 1.2 everything else uses. */
82
+ settings: (
83
+ <g
84
+ transform="scale(0.5833)"
85
+ fill="none"
86
+ stroke="currentColor"
87
+ strokeWidth="2"
88
+ strokeLinecap="round"
89
+ strokeLinejoin="round"
90
+ >
91
+ <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
92
+ <circle cx="12" cy="12" r="3" />
93
+ </g>
94
+ ),
76
95
  sun: (
77
96
  <>
78
97
  <circle cx="7" cy="7" r="2.5" fill="none" stroke="currentColor" strokeWidth="1.2" />
@@ -0,0 +1,87 @@
1
+ import {useCallback, useEffect, useState} from "react";
2
+
3
+ /**
4
+ * Workspace preferences: the choices that belong to the person using Studio
5
+ * rather than to the project.
6
+ *
7
+ * They are deliberately not in `odori.config.ts`. A config file is checked in
8
+ * and shared, and "start muted" is a fact about someone's desk and how loud
9
+ * the room is, not about the video. Everything here is per browser, best
10
+ * effort, and safe to lose.
11
+ */
12
+ export const STORAGE_PREFIX = "odori-studio-";
13
+
14
+ /**
15
+ * Whether the transport starts with sound.
16
+ *
17
+ * Sound belongs on by default: a video with a score is not the same video
18
+ * without one, and a preview that silently drops half the work teaches you
19
+ * the wrong thing about it. But somebody working in an open office, or with
20
+ * Studio parked beside an agent all day, wants the opposite, and that is a
21
+ * setting rather than an argument.
22
+ */
23
+ export type StartSound = "on" | "off";
24
+
25
+ export const SETTINGS = {
26
+ sound: {key: "sound", values: ["on", "off"] as const, fallback: "on" as StartSound},
27
+ } as const;
28
+
29
+ const read = <T extends string>(key: string, values: readonly T[], fallback: T): T => {
30
+ try {
31
+ const stored = window.localStorage.getItem(`${STORAGE_PREFIX}${key}`);
32
+ return values.includes(stored as T) ? (stored as T) : fallback;
33
+ } catch {
34
+ // Private browsing and hardened profiles refuse storage. A preference is
35
+ // not worth failing a render over.
36
+ return fallback;
37
+ }
38
+ };
39
+
40
+ /** A single preference, persisted per browser. */
41
+ export const usePreference = <T extends string>(
42
+ key: string,
43
+ values: readonly T[],
44
+ fallback: T,
45
+ ): [T, (next: T) => void] => {
46
+ const [value, setValue] = useState<T>(() =>
47
+ typeof window === "undefined" ? fallback : read(key, values, fallback),
48
+ );
49
+
50
+ const update = useCallback(
51
+ (next: T) => {
52
+ setValue(next);
53
+ try {
54
+ window.localStorage.setItem(`${STORAGE_PREFIX}${key}`, next);
55
+ } catch {
56
+ // Applied for this session either way.
57
+ }
58
+ },
59
+ [key],
60
+ );
61
+
62
+ return [value, update];
63
+ };
64
+
65
+ /** Read once, outside React, for state that has to be right on first render. */
66
+ export const readStartSound = (): StartSound =>
67
+ typeof window === "undefined"
68
+ ? SETTINGS.sound.fallback
69
+ : read(SETTINGS.sound.key, SETTINGS.sound.values, SETTINGS.sound.fallback);
70
+
71
+ export const useStartSound = () =>
72
+ usePreference<StartSound>(SETTINGS.sound.key, SETTINGS.sound.values, SETTINGS.sound.fallback);
73
+
74
+ /**
75
+ * Other tabs of the same Studio should not disagree about a preference. The
76
+ * storage event fires only in the tabs that did not make the change, which is
77
+ * exactly the set that needs telling.
78
+ */
79
+ export const useSettingsSync = (onChange: () => void) => {
80
+ useEffect(() => {
81
+ const listener = (event: StorageEvent) => {
82
+ if (event.key?.startsWith(STORAGE_PREFIX)) onChange();
83
+ };
84
+ window.addEventListener("storage", listener);
85
+ return () => window.removeEventListener("storage", listener);
86
+ }, [onChange]);
87
+ };