@odori/cli 0.0.2 → 0.0.4
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-7XJL2BYO.js → chunk-NYXWEZU2.js} +717 -348
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +63 -8
- package/dist/index.js +3 -3
- package/dist/registry-snapshot-MSH2EA36.js +4867 -0
- package/package.json +3 -3
- package/src/assets.ts +90 -0
- package/src/brand-file.ts +16 -4
- package/src/chunk-cache.ts +6 -0
- package/src/cli.ts +22 -12
- package/src/commands/add.ts +33 -8
- package/src/commands/dev.ts +114 -12
- package/src/commands/doctor.ts +47 -2
- package/src/commands/exportVideo.ts +39 -5
- package/src/commands/{still.ts → frame.ts} +23 -9
- package/src/commands/init.ts +1 -1
- package/src/commands/new.ts +1 -1
- package/src/cues.ts +34 -21
- package/src/discovery.ts +85 -5
- package/src/formats.ts +67 -8
- package/src/index.ts +1 -1
- package/src/jobs.ts +6 -2
- package/src/registry-snapshot.json +1530 -328
- package/src/registry-source.ts +87 -5
- package/src/render.ts +48 -13
- package/src/server.ts +55 -13
- package/studio/src/Studio.tsx +19 -22
- package/studio/src/components/ExportPanel.tsx +124 -90
- package/studio/src/components/Inspector.tsx +221 -0
- package/studio/src/components/Navigator.tsx +145 -0
- package/studio/src/components/Thumbnail.tsx +65 -23
- package/studio/src/components/ui.tsx +9 -2
- package/studio/src/lib/highlight.ts +85 -0
- package/studio/src/studio.css +435 -20
- package/studio/src/views/AssetsView.tsx +14 -1
- package/studio/src/views/BrandsView.tsx +74 -26
- package/studio/src/views/ComponentsView.tsx +206 -55
- package/studio/src/views/HomeView.tsx +44 -19
- package/studio/src/views/VideosView.tsx +53 -48
- package/studio/src/virtual.d.ts +4 -1
- package/dist/registry-snapshot-NIH2JMQ6.js +0 -3559
|
@@ -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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
19
|
+
audioBadge = false,
|
|
17
20
|
}: {
|
|
18
21
|
entry: VideoEntry;
|
|
19
22
|
frame?: number;
|
|
20
23
|
durationInFrames?: number;
|
|
21
|
-
|
|
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 [
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
|
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" />
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small TypeScript and TSX tokenizer, for reading source in the inspector.
|
|
3
|
+
*
|
|
4
|
+
* Studio ships inside `@odori/cli`, so anything it imports is something every
|
|
5
|
+
* consumer downloads. A real highlighter is megabytes of grammars to colour a
|
|
6
|
+
* read-only panel, which is a bad trade for a dev tool; this is one pass of
|
|
7
|
+
* one regex over files we already know the shape of. It is deliberately not a
|
|
8
|
+
* parser: it does not resolve types, it will not know a word is a variable
|
|
9
|
+
* rather than a call, and it does not need to. What it has to get right is
|
|
10
|
+
* that a keyword inside a string stays a string, and a slash that opens a
|
|
11
|
+
* comment is not division, which ordering the alternation handles.
|
|
12
|
+
*/
|
|
13
|
+
export type Token = {text: string; kind: TokenKind};
|
|
14
|
+
|
|
15
|
+
export type TokenKind =
|
|
16
|
+
| "plain"
|
|
17
|
+
| "comment"
|
|
18
|
+
| "string"
|
|
19
|
+
| "keyword"
|
|
20
|
+
| "number"
|
|
21
|
+
| "tag"
|
|
22
|
+
| "attr"
|
|
23
|
+
| "fn"
|
|
24
|
+
| "punct";
|
|
25
|
+
|
|
26
|
+
const KEYWORDS = new Set([
|
|
27
|
+
"as", "async", "await", "break", "case", "catch", "class", "const", "continue", "declare", "default", "delete",
|
|
28
|
+
"do", "else", "enum", "export", "extends", "false", "finally", "for", "from", "function", "if", "implements",
|
|
29
|
+
"import", "in", "instanceof", "interface", "keyof", "let", "new", "null", "of", "readonly", "return", "satisfies",
|
|
30
|
+
"static", "super", "switch", "this", "throw", "true", "try", "type", "typeof", "undefined", "var", "void", "while",
|
|
31
|
+
"yield",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
/*
|
|
35
|
+
* Order is the whole design. Comments and strings come first so their contents
|
|
36
|
+
* are never read as code; the JSX tag rule follows, because `<Scene` is a tag
|
|
37
|
+
* and `a < b` is not; words and numbers come last.
|
|
38
|
+
*/
|
|
39
|
+
const PATTERN = new RegExp(
|
|
40
|
+
[
|
|
41
|
+
"(?<comment>//[^\\n]*|/\\*[\\s\\S]*?\\*/)",
|
|
42
|
+
"(?<string>`(?:\\\\.|[^`\\\\])*`|\"(?:\\\\.|[^\"\\\\\\n])*\"|'(?:\\\\.|[^'\\\\\\n])*')",
|
|
43
|
+
"(?<tag></?[A-Za-z][\\w.]*(?=[\\s/>]))",
|
|
44
|
+
"(?<number>\\b\\d[\\w.]*\\b)",
|
|
45
|
+
"(?<word>[A-Za-z_$][\\w$]*)",
|
|
46
|
+
"(?<punct>[{}()[\\].,;:=+\\-*/<>!?&|%^~]+)",
|
|
47
|
+
].join("|"),
|
|
48
|
+
"g",
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
export const tokenize = (source: string): Token[] => {
|
|
52
|
+
const tokens: Token[] = [];
|
|
53
|
+
let last = 0;
|
|
54
|
+
|
|
55
|
+
const push = (text: string, kind: TokenKind) => {
|
|
56
|
+
if (text) tokens.push({text, kind});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
for (const match of source.matchAll(PATTERN)) {
|
|
60
|
+
const groups = match.groups ?? {};
|
|
61
|
+
const index = match.index ?? 0;
|
|
62
|
+
push(source.slice(last, index), "plain");
|
|
63
|
+
last = index + match[0].length;
|
|
64
|
+
|
|
65
|
+
if (groups.comment) push(match[0], "comment");
|
|
66
|
+
else if (groups.string) push(match[0], "string");
|
|
67
|
+
else if (groups.tag) push(match[0], "tag");
|
|
68
|
+
else if (groups.number) push(match[0], "number");
|
|
69
|
+
else if (groups.punct) push(match[0], "punct");
|
|
70
|
+
else if (groups.word) {
|
|
71
|
+
const word = match[0];
|
|
72
|
+
// A word followed by `(` is being called; one followed by `=` inside a
|
|
73
|
+
// tag is an attribute. Both are guesses from the next character, which
|
|
74
|
+
// is as far as a tokenizer can honestly go.
|
|
75
|
+
const next = source[last];
|
|
76
|
+
if (KEYWORDS.has(word)) push(word, "keyword");
|
|
77
|
+
else if (next === "(") push(word, "fn");
|
|
78
|
+
else if (next === "=" && source[last + 1] !== "=") push(word, "attr");
|
|
79
|
+
else push(word, "plain");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
push(source.slice(last), "plain");
|
|
84
|
+
return tokens;
|
|
85
|
+
};
|