@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,79 @@
|
|
|
1
|
+
import {useEffect, useRef, useState, type ReactNode, type RefObject} from "react";
|
|
2
|
+
|
|
3
|
+
/** Fit a fixed composition size into whatever space the container has. */
|
|
4
|
+
export const useFitScale = (width: number, height: number): [RefObject<HTMLDivElement | null>, number] => {
|
|
5
|
+
const container = useRef<HTMLDivElement>(null);
|
|
6
|
+
const [scale, setScale] = useState(0.25);
|
|
7
|
+
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
const element = container.current;
|
|
10
|
+
if (!element) return;
|
|
11
|
+
const fit = () => {
|
|
12
|
+
const bounds = element.getBoundingClientRect();
|
|
13
|
+
if (bounds.width === 0 || bounds.height === 0) return;
|
|
14
|
+
setScale(Math.min(bounds.width / width, bounds.height / height));
|
|
15
|
+
};
|
|
16
|
+
fit();
|
|
17
|
+
const observer = new ResizeObserver(fit);
|
|
18
|
+
observer.observe(element);
|
|
19
|
+
return () => observer.disconnect();
|
|
20
|
+
}, [height, width]);
|
|
21
|
+
|
|
22
|
+
return [container, scale];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Scales the composition to fit the viewport without changing its pixel
|
|
27
|
+
* dimensions, so what Studio shows is the render resolution scaled, not a
|
|
28
|
+
* different layout.
|
|
29
|
+
*/
|
|
30
|
+
export const CanvasStage = ({
|
|
31
|
+
width,
|
|
32
|
+
height,
|
|
33
|
+
safeArea,
|
|
34
|
+
showSafeArea,
|
|
35
|
+
children,
|
|
36
|
+
}: {
|
|
37
|
+
width: number;
|
|
38
|
+
height: number;
|
|
39
|
+
safeArea?: {x: number; y: number};
|
|
40
|
+
showSafeArea?: boolean;
|
|
41
|
+
children: ReactNode;
|
|
42
|
+
}) => {
|
|
43
|
+
const [container, scale] = useFitScale(width, height);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="viewport">
|
|
47
|
+
{/* The measured box is absolutely positioned, so the composition it
|
|
48
|
+
contains can never feed its own size back into the fit calculation. */}
|
|
49
|
+
<div className="viewport-inner" ref={container}>
|
|
50
|
+
<div
|
|
51
|
+
className="frame"
|
|
52
|
+
data-safe={showSafeArea ? "true" : undefined}
|
|
53
|
+
style={
|
|
54
|
+
{
|
|
55
|
+
height: Math.round(height * scale),
|
|
56
|
+
width: Math.round(width * scale),
|
|
57
|
+
"--safe-x": `${(safeArea?.x ?? 0) * scale}px`,
|
|
58
|
+
"--safe-y": `${(safeArea?.y ?? 0) * scale}px`,
|
|
59
|
+
} as React.CSSProperties
|
|
60
|
+
}
|
|
61
|
+
>
|
|
62
|
+
<div
|
|
63
|
+
style={{
|
|
64
|
+
height,
|
|
65
|
+
left: 0,
|
|
66
|
+
position: "absolute",
|
|
67
|
+
top: 0,
|
|
68
|
+
transform: `scale(${scale})`,
|
|
69
|
+
transformOrigin: "top left",
|
|
70
|
+
width,
|
|
71
|
+
}}
|
|
72
|
+
>
|
|
73
|
+
{children}
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import {useEffect, useMemo, useState} from "react";
|
|
2
|
+
import {componentPreviews, videos} from "virtual:odori-project";
|
|
3
|
+
|
|
4
|
+
export type Command = {id: string; label: string; type: string; keywords?: string; run: () => void};
|
|
5
|
+
|
|
6
|
+
/** One keystroke to anything in the project, like a code editor. */
|
|
7
|
+
export const CommandPalette = ({
|
|
8
|
+
open,
|
|
9
|
+
initialQuery = "",
|
|
10
|
+
onClose,
|
|
11
|
+
onOpenVideo,
|
|
12
|
+
onOpenComponent,
|
|
13
|
+
onView,
|
|
14
|
+
}: {
|
|
15
|
+
open: boolean;
|
|
16
|
+
/** What was typed in the top bar before the palette took over. */
|
|
17
|
+
initialQuery?: string;
|
|
18
|
+
onClose: () => void;
|
|
19
|
+
onOpenVideo: (id: string) => void;
|
|
20
|
+
onOpenComponent: (id: string) => void;
|
|
21
|
+
onView: (view: string) => void;
|
|
22
|
+
}) => {
|
|
23
|
+
const [query, setQuery] = useState(initialQuery);
|
|
24
|
+
const [index, setIndex] = useState(0);
|
|
25
|
+
|
|
26
|
+
const commands = useMemo<Command[]>(
|
|
27
|
+
() => [
|
|
28
|
+
...videos.map((video) => ({
|
|
29
|
+
id: `video:${video.metadata.id}`,
|
|
30
|
+
label: video.metadata.title,
|
|
31
|
+
keywords: `${video.metadata.id} ${(video.metadata.tags ?? []).join(" ")}`,
|
|
32
|
+
type: "video",
|
|
33
|
+
run: () => onOpenVideo(video.metadata.id),
|
|
34
|
+
})),
|
|
35
|
+
...componentPreviews.map((preview) => ({
|
|
36
|
+
id: `component:${preview.id}`,
|
|
37
|
+
label: preview.preview.title,
|
|
38
|
+
keywords: `${preview.id} ${preview.preview.category}`,
|
|
39
|
+
type: "component",
|
|
40
|
+
run: () => onOpenComponent(preview.id),
|
|
41
|
+
})),
|
|
42
|
+
...["videos", "components", "brands", "assets"].map((view) => ({
|
|
43
|
+
id: `view:${view}`,
|
|
44
|
+
label: `Go to ${view}`,
|
|
45
|
+
type: "view",
|
|
46
|
+
run: () => onView(view),
|
|
47
|
+
})),
|
|
48
|
+
],
|
|
49
|
+
[onOpenComponent, onOpenVideo, onView],
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const needle = query.trim().toLowerCase();
|
|
53
|
+
const matches = commands.filter((command) =>
|
|
54
|
+
`${command.label} ${command.keywords ?? ""}`.toLowerCase().includes(needle),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (open) {
|
|
59
|
+
setQuery(initialQuery);
|
|
60
|
+
setIndex(0);
|
|
61
|
+
}
|
|
62
|
+
}, [initialQuery, open]);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!open) return;
|
|
66
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
67
|
+
if (event.key === "Escape") onClose();
|
|
68
|
+
if (event.key === "ArrowDown") {
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
setIndex((value) => Math.min(value + 1, matches.length - 1));
|
|
71
|
+
}
|
|
72
|
+
if (event.key === "ArrowUp") {
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
setIndex((value) => Math.max(value - 1, 0));
|
|
75
|
+
}
|
|
76
|
+
if (event.key === "Enter") {
|
|
77
|
+
event.preventDefault();
|
|
78
|
+
matches[index]?.run();
|
|
79
|
+
onClose();
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
window.addEventListener("keydown", onKeyDown);
|
|
83
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
84
|
+
}, [index, matches, onClose, open]);
|
|
85
|
+
|
|
86
|
+
if (!open) return null;
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<div className="overlay" onPointerDown={onClose}>
|
|
90
|
+
<div className="palette" onPointerDown={(event) => event.stopPropagation()}>
|
|
91
|
+
<input
|
|
92
|
+
className="input"
|
|
93
|
+
autoFocus
|
|
94
|
+
placeholder="Search videos, components, and views"
|
|
95
|
+
value={query}
|
|
96
|
+
onChange={(event) => {
|
|
97
|
+
setQuery(event.currentTarget.value);
|
|
98
|
+
setIndex(0);
|
|
99
|
+
}}
|
|
100
|
+
/>
|
|
101
|
+
<ul>
|
|
102
|
+
{matches.map((command, position) => (
|
|
103
|
+
<li key={command.id}>
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
data-active={position === index ? "true" : undefined}
|
|
107
|
+
onPointerEnter={() => setIndex(position)}
|
|
108
|
+
onClick={() => {
|
|
109
|
+
command.run();
|
|
110
|
+
onClose();
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
{command.label}
|
|
114
|
+
<span className="type">{command.type}</span>
|
|
115
|
+
</button>
|
|
116
|
+
</li>
|
|
117
|
+
))}
|
|
118
|
+
{matches.length === 0 ? (
|
|
119
|
+
<li>
|
|
120
|
+
<button type="button" disabled>
|
|
121
|
+
No matches
|
|
122
|
+
</button>
|
|
123
|
+
</li>
|
|
124
|
+
) : null}
|
|
125
|
+
</ul>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {
|
|
2
|
+
entryDurationInFrames,
|
|
3
|
+
resolveEntryLayout,
|
|
4
|
+
type AudioTrack,
|
|
5
|
+
type CompiledTimeline,
|
|
6
|
+
type VideoEntry,
|
|
7
|
+
} from "odori";
|
|
8
|
+
import {SectionTitle, Separator} from "./ui";
|
|
9
|
+
|
|
10
|
+
export type Issue = {level: "error" | "warning"; message: string};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Validation the framework promises: a declared duration is authoritative, and
|
|
14
|
+
* compiled scene totals must agree with it.
|
|
15
|
+
*/
|
|
16
|
+
export const collectDiagnostics = (
|
|
17
|
+
entry: VideoEntry,
|
|
18
|
+
timeline: CompiledTimeline | null,
|
|
19
|
+
track?: AudioTrack | null,
|
|
20
|
+
): Issue[] => {
|
|
21
|
+
const layout = resolveEntryLayout(entry);
|
|
22
|
+
const declared = entryDurationInFrames(entry, layout);
|
|
23
|
+
const issues: Issue[] = [];
|
|
24
|
+
|
|
25
|
+
if (!declared && !timeline?.durationInFrames) {
|
|
26
|
+
issues.push({level: "error", message: "No duration. Set metadata.duration or add scenes."});
|
|
27
|
+
}
|
|
28
|
+
if (declared && timeline && timeline.durationInFrames && timeline.durationInFrames !== declared) {
|
|
29
|
+
issues.push({
|
|
30
|
+
level: "warning",
|
|
31
|
+
message: `Scenes total ${timeline.durationInFrames} frames but metadata.duration is ${declared} frames.`,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const seen = new Set<string>();
|
|
36
|
+
for (const scene of timeline?.scenes ?? []) {
|
|
37
|
+
if (seen.has(scene.id)) issues.push({level: "error", message: `Duplicate scene id: ${scene.id}`});
|
|
38
|
+
seen.add(scene.id);
|
|
39
|
+
if (scene.durationInFrames < layout.format.fps / 2) {
|
|
40
|
+
issues.push({level: "warning", message: `Scene ${scene.id} is shorter than half a second.`});
|
|
41
|
+
}
|
|
42
|
+
// An overlap that eats most of a scene is a scene nobody sees on its own,
|
|
43
|
+
// which is almost always a duration that was meant to be longer.
|
|
44
|
+
if (scene.overlap > 0 && scene.overlap > scene.durationInFrames / 2) {
|
|
45
|
+
issues.push({
|
|
46
|
+
level: "warning",
|
|
47
|
+
message: `Scene ${scene.id} overlaps ${scene.overlap} of its ${scene.durationInFrames} frames, so it is mostly transition.`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const declaredTotal = declared || timeline?.durationInFrames || 0;
|
|
52
|
+
for (const cue of track?.cues ?? []) {
|
|
53
|
+
if (declaredTotal && cue.fromFrame + cue.durationInFrames > declaredTotal) {
|
|
54
|
+
issues.push({
|
|
55
|
+
level: "warning",
|
|
56
|
+
message: `Audio ${cue.src.split("/").pop()} runs ${cue.fromFrame + cue.durationInFrames - declaredTotal} frames past the end.`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
if (cue.gain > 1) {
|
|
60
|
+
issues.push({level: "warning", message: `Audio ${cue.src.split("/").pop()} has gain above 1 and may clip.`});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return issues;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const Diagnostics = ({
|
|
67
|
+
entry,
|
|
68
|
+
timeline,
|
|
69
|
+
track,
|
|
70
|
+
}: {
|
|
71
|
+
entry: VideoEntry;
|
|
72
|
+
timeline: CompiledTimeline | null;
|
|
73
|
+
track?: AudioTrack | null;
|
|
74
|
+
}) => {
|
|
75
|
+
const issues = collectDiagnostics(entry, timeline, track);
|
|
76
|
+
// A permanent green badge is a section that never says anything. The panel
|
|
77
|
+
// appears when a contract is actually broken, so its absence is the pass.
|
|
78
|
+
if (issues.length === 0) return null;
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div>
|
|
82
|
+
<Separator />
|
|
83
|
+
<SectionTitle>Diagnostics</SectionTitle>
|
|
84
|
+
<ul className="issues">
|
|
85
|
+
{issues.map((issue) => (
|
|
86
|
+
<li key={issue.message} className="issue" data-level={issue.level}>
|
|
87
|
+
{issue.message}
|
|
88
|
+
</li>
|
|
89
|
+
))}
|
|
90
|
+
</ul>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
};
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import {useCallback, useEffect, useRef, useState} from "react";
|
|
2
|
+
import {Badge, Button, Icon, SectionTitle} from "./ui";
|
|
3
|
+
|
|
4
|
+
type JobState = {
|
|
5
|
+
id: string;
|
|
6
|
+
videoId?: string;
|
|
7
|
+
status: string;
|
|
8
|
+
progress: number;
|
|
9
|
+
attempts?: number;
|
|
10
|
+
output?: string;
|
|
11
|
+
error?: string;
|
|
12
|
+
createdAt?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const tone = (status: string) => (status === "failed" ? "danger" : status === "ready" ? "success" : undefined);
|
|
16
|
+
|
|
17
|
+
/** Jobs are identified by what they wrote, not by their manifest hash. */
|
|
18
|
+
const fileName = (path?: string) => path?.split(/[\\/]/).pop();
|
|
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
|
+
};
|
|
25
|
+
|
|
26
|
+
type Mode = "video" | "frame" | "clipboard";
|
|
27
|
+
|
|
28
|
+
/** Ids are paths under videos/, output files are flat. */
|
|
29
|
+
const outputName = (videoId: string) => videoId.split("/").join("-");
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Studio never encodes to preview. Export is an explicit action that hands a
|
|
33
|
+
* frozen manifest to the same queue and render worker the CLI uses, so a job
|
|
34
|
+
* started here can be retried from either surface.
|
|
35
|
+
*/
|
|
36
|
+
export const ExportPanel = ({
|
|
37
|
+
videoId,
|
|
38
|
+
input,
|
|
39
|
+
frame,
|
|
40
|
+
exportDir,
|
|
41
|
+
}: {
|
|
42
|
+
videoId: string;
|
|
43
|
+
input: Record<string, unknown>;
|
|
44
|
+
frame: number;
|
|
45
|
+
exportDir: string;
|
|
46
|
+
}) => {
|
|
47
|
+
const [job, setJob] = useState<JobState | null>(null);
|
|
48
|
+
const [busy, setBusy] = useState(false);
|
|
49
|
+
const [mode, setMode] = useState<Mode>("video");
|
|
50
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
51
|
+
const [dropUp, setDropUp] = useState(false);
|
|
52
|
+
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
|
+
|
|
71
|
+
const poll = async (id: string) => {
|
|
72
|
+
for (let attempt = 0; attempt < 900; attempt += 1) {
|
|
73
|
+
await new Promise((wait) => setTimeout(wait, 1000));
|
|
74
|
+
const response = await fetch(`/__odori/jobs/${id}`);
|
|
75
|
+
if (!response.ok) continue;
|
|
76
|
+
const payload = (await response.json()) as JobState;
|
|
77
|
+
setJob(payload);
|
|
78
|
+
if (payload.status === "ready" || payload.status === "failed") return;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const post = async (path: string, body?: unknown) => {
|
|
83
|
+
setBusy(true);
|
|
84
|
+
setJob({id: "pending", status: "queued", progress: 0});
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetch(path, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: {"content-type": "application/json"},
|
|
89
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
90
|
+
});
|
|
91
|
+
const payload = (await response.json()) as JobState;
|
|
92
|
+
setJob(payload);
|
|
93
|
+
if (payload.id && payload.status !== "ready" && payload.status !== "failed") await poll(payload.id);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
setJob({id: "error", status: "failed", progress: 0, error: String(error)});
|
|
96
|
+
} finally {
|
|
97
|
+
setBusy(false);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The write is handed a promise rather than a resolved blob so the copy stays
|
|
103
|
+
* inside the click that started it. Rendering a frame takes longer than the
|
|
104
|
+
* user activation a bare `await` would spend.
|
|
105
|
+
*/
|
|
106
|
+
const copyFrame = async () => {
|
|
107
|
+
setBusy(true);
|
|
108
|
+
setNotice(null);
|
|
109
|
+
setJob(null);
|
|
110
|
+
const png = fetch("/__odori/still", {
|
|
111
|
+
method: "POST",
|
|
112
|
+
headers: {"content-type": "application/json"},
|
|
113
|
+
body: JSON.stringify({videoId, input, frame, inline: true}),
|
|
114
|
+
}).then(async (response) => {
|
|
115
|
+
if (!response.ok) throw new Error(await response.text());
|
|
116
|
+
return response.blob();
|
|
117
|
+
});
|
|
118
|
+
try {
|
|
119
|
+
await navigator.clipboard.write([new ClipboardItem({"image/png": png})]);
|
|
120
|
+
setNotice(`Frame ${frame} copied to the clipboard.`);
|
|
121
|
+
} catch {
|
|
122
|
+
try {
|
|
123
|
+
await navigator.clipboard.write([new ClipboardItem({"image/png": await png})]);
|
|
124
|
+
setNotice(`Frame ${frame} copied to the clipboard.`);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
setNotice(`Could not copy frame ${frame}: ${error instanceof Error ? error.message : String(error)}`);
|
|
127
|
+
}
|
|
128
|
+
} finally {
|
|
129
|
+
setBusy(false);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const run = async (next: Mode) => {
|
|
134
|
+
setMenuOpen(false);
|
|
135
|
+
setMode(next);
|
|
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});
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
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";
|
|
153
|
+
|
|
154
|
+
return (
|
|
155
|
+
<div>
|
|
156
|
+
<SectionTitle>Export</SectionTitle>
|
|
157
|
+
<div className="split" ref={menuRef}>
|
|
158
|
+
<Button
|
|
159
|
+
variant="primary"
|
|
160
|
+
disabled={busy}
|
|
161
|
+
onClick={() => void run(mode)}
|
|
162
|
+
title={modes.find((item) => item.id === mode)?.hint}
|
|
163
|
+
>
|
|
164
|
+
{actionLabel}
|
|
165
|
+
</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" />
|
|
182
|
+
</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
|
+
</div>
|
|
202
|
+
|
|
203
|
+
{notice ? (
|
|
204
|
+
<p className="hint" style={{marginTop: 8}}>
|
|
205
|
+
{notice}
|
|
206
|
+
</p>
|
|
207
|
+
) : null}
|
|
208
|
+
|
|
209
|
+
{job ? (
|
|
210
|
+
<>
|
|
211
|
+
<div className="status">
|
|
212
|
+
<Badge tone={tone(job.status)}>{job.status}</Badge>
|
|
213
|
+
<span
|
|
214
|
+
style={{color: "var(--subtle)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap"}}
|
|
215
|
+
title={job.output ?? job.error}
|
|
216
|
+
>
|
|
217
|
+
{fileName(job.output) ?? job.error ?? `${Math.round((job.progress ?? 0) * 100)}%`}
|
|
218
|
+
</span>
|
|
219
|
+
</div>
|
|
220
|
+
{job.status === "rendering" || job.status === "encoding" ? (
|
|
221
|
+
<div className="progress">
|
|
222
|
+
<span style={{width: `${Math.round((job.progress ?? 0) * 100)}%`}} />
|
|
223
|
+
</div>
|
|
224
|
+
) : null}
|
|
225
|
+
</>
|
|
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
|
+
)}
|
|
231
|
+
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type {FieldDescriptor} from "odori";
|
|
2
|
+
|
|
3
|
+
export const InputControls = ({
|
|
4
|
+
fields,
|
|
5
|
+
value,
|
|
6
|
+
onChange,
|
|
7
|
+
}: {
|
|
8
|
+
fields: Record<string, FieldDescriptor>;
|
|
9
|
+
value: Record<string, unknown>;
|
|
10
|
+
onChange: (next: Record<string, unknown>) => void;
|
|
11
|
+
}) => {
|
|
12
|
+
const set = (name: string, next: unknown) => onChange({...value, [name]: next});
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<div>
|
|
16
|
+
{Object.entries(fields).map(([name, field]) => {
|
|
17
|
+
const current = value[name] ?? field.defaultValue;
|
|
18
|
+
return (
|
|
19
|
+
<div key={name} className="field">
|
|
20
|
+
<label htmlFor={`control-${name}`}>
|
|
21
|
+
{name}
|
|
22
|
+
<span className="hint">
|
|
23
|
+
{field.type}
|
|
24
|
+
{field.type === "text" && field.maxLength ? ` · ${String(current).length}/${field.maxLength}` : ""}
|
|
25
|
+
</span>
|
|
26
|
+
</label>
|
|
27
|
+
{field.type === "text" ? (
|
|
28
|
+
field.multiline ? (
|
|
29
|
+
<textarea
|
|
30
|
+
id={`control-${name}`}
|
|
31
|
+
className="input"
|
|
32
|
+
rows={3}
|
|
33
|
+
value={String(current)}
|
|
34
|
+
onChange={(event) => set(name, event.currentTarget.value)}
|
|
35
|
+
/>
|
|
36
|
+
) : (
|
|
37
|
+
<input
|
|
38
|
+
id={`control-${name}`}
|
|
39
|
+
className="input"
|
|
40
|
+
type="text"
|
|
41
|
+
maxLength={field.maxLength}
|
|
42
|
+
value={String(current)}
|
|
43
|
+
onChange={(event) => set(name, event.currentTarget.value)}
|
|
44
|
+
/>
|
|
45
|
+
)
|
|
46
|
+
) : null}
|
|
47
|
+
{field.type === "number" ? (
|
|
48
|
+
<input
|
|
49
|
+
id={`control-${name}`}
|
|
50
|
+
className="input"
|
|
51
|
+
type="number"
|
|
52
|
+
min={field.min}
|
|
53
|
+
max={field.max}
|
|
54
|
+
step={field.step ?? 1}
|
|
55
|
+
value={Number(current)}
|
|
56
|
+
onChange={(event) => set(name, Number(event.currentTarget.value))}
|
|
57
|
+
/>
|
|
58
|
+
) : null}
|
|
59
|
+
{field.type === "boolean" ? (
|
|
60
|
+
<input
|
|
61
|
+
id={`control-${name}`}
|
|
62
|
+
type="checkbox"
|
|
63
|
+
checked={Boolean(current)}
|
|
64
|
+
onChange={(event) => set(name, event.currentTarget.checked)}
|
|
65
|
+
/>
|
|
66
|
+
) : null}
|
|
67
|
+
{field.type === "color" ? (
|
|
68
|
+
<input
|
|
69
|
+
id={`control-${name}`}
|
|
70
|
+
className="input"
|
|
71
|
+
type="color"
|
|
72
|
+
value={String(current)}
|
|
73
|
+
onChange={(event) => set(name, event.currentTarget.value)}
|
|
74
|
+
/>
|
|
75
|
+
) : null}
|
|
76
|
+
{field.type === "select" ? (
|
|
77
|
+
<select
|
|
78
|
+
id={`control-${name}`}
|
|
79
|
+
className="input"
|
|
80
|
+
value={String(current)}
|
|
81
|
+
onChange={(event) => set(name, event.currentTarget.value)}
|
|
82
|
+
>
|
|
83
|
+
{field.options.map((option) => (
|
|
84
|
+
<option key={option} value={option}>
|
|
85
|
+
{option}
|
|
86
|
+
</option>
|
|
87
|
+
))}
|
|
88
|
+
</select>
|
|
89
|
+
) : null}
|
|
90
|
+
{field.type === "json" ? (
|
|
91
|
+
<textarea
|
|
92
|
+
id={`control-${name}`}
|
|
93
|
+
className="input"
|
|
94
|
+
rows={4}
|
|
95
|
+
defaultValue={JSON.stringify(current, null, 2)}
|
|
96
|
+
onBlur={(event) => {
|
|
97
|
+
try {
|
|
98
|
+
set(name, JSON.parse(event.currentTarget.value));
|
|
99
|
+
} catch {
|
|
100
|
+
// Keep the previous value until the JSON parses.
|
|
101
|
+
}
|
|
102
|
+
}}
|
|
103
|
+
/>
|
|
104
|
+
) : null}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
})}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
};
|