@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.
- package/bin/odori.mjs +33 -11
- package/dist/{chunk-NYXWEZU2.js → chunk-6CE7U5KB.js} +124 -39
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +11 -0
- package/dist/index.js +1 -1
- package/dist/{registry-snapshot-MSH2EA36.js → registry-snapshot-ADKSTGDR.js} +15 -14
- package/package.json +3 -3
- package/src/cli.ts +26 -4
- package/src/commands/add.ts +12 -1
- package/src/commands/dev.ts +4 -1
- package/src/commands/exportVideo.ts +6 -0
- package/src/commands/init.ts +34 -1
- package/src/commands/test.ts +24 -2
- package/src/commands/update.ts +58 -7
- package/src/jobs.ts +1 -1
- package/src/registry-snapshot.json +15 -15
- package/src/render.ts +8 -1
- package/studio/index.html +26 -0
- package/studio/src/Studio.tsx +6 -22
- package/studio/src/components/CanvasStage.tsx +40 -3
- package/studio/src/components/ExportPanel.tsx +90 -6
- package/studio/src/components/Inspector.tsx +36 -30
- package/studio/src/components/Navigator.tsx +64 -2
- package/studio/src/components/Settings.tsx +109 -0
- package/studio/src/components/Transport.tsx +136 -58
- package/studio/src/components/ui.tsx +20 -1
- package/studio/src/settings.ts +87 -0
- package/studio/src/studio.css +269 -20
- package/studio/src/views/VideosView.tsx +12 -5
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
78
|
+
return Math.round(ratio * (durationInFrames - 1));
|
|
66
79
|
};
|
|
67
80
|
|
|
68
81
|
useEffect(() => {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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) =>
|
|
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
|
|
101
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
) :
|
|
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
|
-
|
|
228
|
-
|
|
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
|
-
|
|
231
|
-
|
|
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
|
+
};
|