@odori/cli 0.0.4 → 0.0.5
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/dist/{chunk-NYXWEZU2.js → chunk-RHG23EWW.js} +78 -35
- 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-JEVXYGS2.js} +13 -12
- 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/update.ts +58 -7
- package/src/jobs.ts +1 -1
- package/src/registry-snapshot.json +13 -13
- package/src/render.ts +8 -1
- package/studio/src/Studio.tsx +6 -22
- package/studio/src/components/ExportPanel.tsx +90 -6
- package/studio/src/components/Navigator.tsx +5 -1
- package/studio/src/components/Settings.tsx +109 -0
- package/studio/src/components/Transport.tsx +98 -55
- package/studio/src/components/ui.tsx +16 -1
- package/studio/src/settings.ts +87 -0
- package/studio/src/studio.css +96 -1
- package/studio/src/views/VideosView.tsx +12 -5
|
@@ -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
|
+
};
|
package/studio/src/studio.css
CHANGED
|
@@ -306,6 +306,66 @@ a.button {
|
|
|
306
306
|
font-size: 11px;
|
|
307
307
|
}
|
|
308
308
|
|
|
309
|
+
/* ---------- settings ---------- */
|
|
310
|
+
|
|
311
|
+
.settings {
|
|
312
|
+
position: relative;
|
|
313
|
+
}
|
|
314
|
+
/* Right-aligned: the trigger is the last thing in the header, so a menu
|
|
315
|
+
growing rightward from its left edge would leave the window. */
|
|
316
|
+
.settings-menu {
|
|
317
|
+
left: auto;
|
|
318
|
+
right: 0;
|
|
319
|
+
}
|
|
320
|
+
.menu-heading {
|
|
321
|
+
color: var(--subtle);
|
|
322
|
+
font-size: 11px;
|
|
323
|
+
letter-spacing: 0.04em;
|
|
324
|
+
margin: 6px 0 2px;
|
|
325
|
+
padding: 0 9px;
|
|
326
|
+
text-transform: uppercase;
|
|
327
|
+
}
|
|
328
|
+
.menu-heading:first-child {
|
|
329
|
+
margin-top: 2px;
|
|
330
|
+
}
|
|
331
|
+
.menu-hint {
|
|
332
|
+
color: var(--subtle);
|
|
333
|
+
font-size: 11px;
|
|
334
|
+
}
|
|
335
|
+
/* A tick in a reserved column, so choosing an option never reflows the row
|
|
336
|
+
or nudges the one under the cursor. */
|
|
337
|
+
.menu-item {
|
|
338
|
+
display: grid;
|
|
339
|
+
grid-template-columns: 1fr 14px;
|
|
340
|
+
}
|
|
341
|
+
.menu-item > span:first-child {
|
|
342
|
+
grid-column: 1;
|
|
343
|
+
}
|
|
344
|
+
.menu-hint {
|
|
345
|
+
grid-column: 1;
|
|
346
|
+
}
|
|
347
|
+
.menu-item[data-selected="true"]::after {
|
|
348
|
+
align-self: center;
|
|
349
|
+
color: var(--foreground);
|
|
350
|
+
content: "✓";
|
|
351
|
+
font-size: 11px;
|
|
352
|
+
grid-column: 2;
|
|
353
|
+
grid-row: 1 / span 2;
|
|
354
|
+
justify-self: end;
|
|
355
|
+
}
|
|
356
|
+
/* A stalled bar stops looking like a working one. */
|
|
357
|
+
.progress[data-stalled="true"] > span {
|
|
358
|
+
opacity: 0.5;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
.menu-note {
|
|
362
|
+
border-top: 1px solid var(--border);
|
|
363
|
+
color: var(--subtle);
|
|
364
|
+
font-size: 11px;
|
|
365
|
+
margin: 4px 0 0;
|
|
366
|
+
padding: 7px 9px 3px;
|
|
367
|
+
}
|
|
368
|
+
|
|
309
369
|
/* Matches the transport's icon buttons rather than the host select. */
|
|
310
370
|
.rate {
|
|
311
371
|
appearance: none;
|
|
@@ -951,6 +1011,13 @@ select.input {
|
|
|
951
1011
|
|
|
952
1012
|
.stage {
|
|
953
1013
|
display: grid;
|
|
1014
|
+
/* The column has to be named. An implicit grid column is auto, which means
|
|
1015
|
+
max-content, so the transport's row of controls set the width and the
|
|
1016
|
+
timeline was laid out at its natural size and then clipped: in a pane this
|
|
1017
|
+
narrow the right half of every video sat under the inspector, unclickable,
|
|
1018
|
+
and the playhead walked off the edge on its way there. min-width on .stage
|
|
1019
|
+
does not help, because the overflow is the track inside it. */
|
|
1020
|
+
grid-template-columns: minmax(0, 1fr);
|
|
954
1021
|
grid-template-rows: minmax(0, 1fr) auto;
|
|
955
1022
|
min-width: 0;
|
|
956
1023
|
overflow: hidden;
|
|
@@ -993,13 +1060,19 @@ select.input {
|
|
|
993
1060
|
border-top: 1px solid var(--border);
|
|
994
1061
|
display: grid;
|
|
995
1062
|
gap: 10px;
|
|
1063
|
+
min-width: 0;
|
|
996
1064
|
padding: 10px 14px 14px;
|
|
997
1065
|
}
|
|
998
1066
|
|
|
999
1067
|
.transport-row {
|
|
1000
1068
|
align-items: center;
|
|
1001
1069
|
display: flex;
|
|
1070
|
+
/* Wrapping is what lets the transport be narrower than its controls. Without
|
|
1071
|
+
it the row is a single min-content block and it is the row, not the
|
|
1072
|
+
timeline, that decides how wide the stage has to be. */
|
|
1073
|
+
flex-wrap: wrap;
|
|
1002
1074
|
gap: 8px;
|
|
1075
|
+
min-width: 0;
|
|
1003
1076
|
}
|
|
1004
1077
|
|
|
1005
1078
|
.timecode {
|
|
@@ -1014,8 +1087,13 @@ select.input {
|
|
|
1014
1087
|
}
|
|
1015
1088
|
|
|
1016
1089
|
.timeline {
|
|
1017
|
-
|
|
1090
|
+
/* Only the track is clickable, so only the track shows a pointer. */
|
|
1018
1091
|
display: grid;
|
|
1092
|
+
/* Named for the same reason .stage is: an implicit auto column would take
|
|
1093
|
+
its width from the ruler and the scene labels rather than from the space
|
|
1094
|
+
available, and push the track out from under the cursor. */
|
|
1095
|
+
grid-template-columns: minmax(0, 1fr);
|
|
1096
|
+
min-width: 0;
|
|
1019
1097
|
gap: 4px;
|
|
1020
1098
|
position: relative;
|
|
1021
1099
|
user-select: none;
|
|
@@ -1029,6 +1107,8 @@ select.input {
|
|
|
1029
1107
|
justify-content: space-between;
|
|
1030
1108
|
}
|
|
1031
1109
|
.track {
|
|
1110
|
+
/* The track is a click target, not something to drag. */
|
|
1111
|
+
cursor: pointer;
|
|
1032
1112
|
background: var(--field);
|
|
1033
1113
|
border: 1px solid var(--border);
|
|
1034
1114
|
border-radius: var(--radius-sm);
|
|
@@ -1140,6 +1220,21 @@ select.input {
|
|
|
1140
1220
|
width: 7.5px;
|
|
1141
1221
|
}
|
|
1142
1222
|
|
|
1223
|
+
/* The frame under the cursor, which is a suggestion until it is clicked. It
|
|
1224
|
+
reads as the same instrument as the playhead, turned down. */
|
|
1225
|
+
.playhead[data-preview="true"],
|
|
1226
|
+
.playhead[data-preview="true"]::before {
|
|
1227
|
+
background: var(--subtle);
|
|
1228
|
+
}
|
|
1229
|
+
.playhead[data-preview="true"] {
|
|
1230
|
+
opacity: 0.75;
|
|
1231
|
+
}
|
|
1232
|
+
/* Muted rather than moved: the readout must not change width when it starts
|
|
1233
|
+
reporting the hovered frame, or the whole row shifts under the cursor. */
|
|
1234
|
+
.timecode[data-preview="true"] {
|
|
1235
|
+
color: var(--subtle);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1143
1238
|
/* ---------- inspector ---------- */
|
|
1144
1239
|
|
|
1145
1240
|
.facts {
|
|
@@ -23,6 +23,7 @@ import {Inspector} from "../components/Inspector";
|
|
|
23
23
|
import {Navigator} from "../components/Navigator";
|
|
24
24
|
import {Badge, Empty, Fact, SectionTitle, Separator} from "../components/ui";
|
|
25
25
|
import {measureTrack} from "../lib/mix-loudness";
|
|
26
|
+
import {readStartSound} from "../settings";
|
|
26
27
|
import {useShortcuts} from "../shortcuts";
|
|
27
28
|
|
|
28
29
|
/**
|
|
@@ -51,9 +52,15 @@ export const VideosView = ({
|
|
|
51
52
|
const [timeline, setTimeline] = useState<CompiledTimeline | null>(null);
|
|
52
53
|
const [track, setTrack] = useState<AudioTrack | null>(null);
|
|
53
54
|
const [loop, setLoop] = useState(true);
|
|
54
|
-
|
|
55
|
+
/* Read once per mount rather than watched: flipping the default while a
|
|
56
|
+
video is open would mute the thing being listened to, and a default is a
|
|
57
|
+
statement about what happens next. */
|
|
58
|
+
const [muted, setMuted] = useState(() => readStartSound() === "off");
|
|
55
59
|
const [soloCue, setSoloCue] = useState<string | null>(null);
|
|
56
|
-
|
|
60
|
+
/* The frame the cursor is over, which the stage shows instead of the
|
|
61
|
+
playhead frame. Preview never moves the playhead and never sounds: it
|
|
62
|
+
answers "what is here" and nothing else. */
|
|
63
|
+
const [preview, setPreview] = useState<number | null>(null);
|
|
57
64
|
const [rate, setRate] = useState(1);
|
|
58
65
|
const [loudness, setLoudness] = useState<number | null>(null);
|
|
59
66
|
const [audioBlocked, setAudioBlocked] = useState(false);
|
|
@@ -108,7 +115,7 @@ export const VideosView = ({
|
|
|
108
115
|
playing: playback.playing,
|
|
109
116
|
muted,
|
|
110
117
|
soloCue,
|
|
111
|
-
scrubbing,
|
|
118
|
+
scrubbing: preview !== null,
|
|
112
119
|
rate,
|
|
113
120
|
onBlocked: setAudioBlocked,
|
|
114
121
|
onFailed: setAudioFailed,
|
|
@@ -238,7 +245,7 @@ export const VideosView = ({
|
|
|
238
245
|
<PreviewBoundary resetKey={selected.metadata.id}>
|
|
239
246
|
<OdoriRuntime
|
|
240
247
|
entry={selected}
|
|
241
|
-
frame={playback.frame}
|
|
248
|
+
frame={preview ?? playback.frame}
|
|
242
249
|
input={input}
|
|
243
250
|
assets={project.assets}
|
|
244
251
|
onTimeline={handleTimeline}
|
|
@@ -258,7 +265,7 @@ export const VideosView = ({
|
|
|
258
265
|
audioBlocked={audioBlocked}
|
|
259
266
|
onEnableAudio={() => setMuted(false)}
|
|
260
267
|
onSoloCue={setSoloCue}
|
|
261
|
-
|
|
268
|
+
onPreview={setPreview}
|
|
262
269
|
rate={rate}
|
|
263
270
|
onRate={setRate}
|
|
264
271
|
onToggleLoop={() => setLoop((value) => !value)}
|