@odori/cli 0.0.10 → 0.0.12
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-AJBK4XRB.js → chunk-FS2BJU5Q.js} +777 -78
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +100 -2
- package/dist/index.js +5 -1
- package/dist/{registry-snapshot-TKH2KAC3.js → registry-snapshot-TSTAA6NT.js} +1815 -245
- package/package.json +5 -4
- package/src/cli.ts +32 -2
- package/src/commands/blocks.ts +188 -0
- package/src/commands/dev.ts +6 -1
- package/src/commands/docs.ts +88 -0
- package/src/commands/doctor.ts +38 -0
- package/src/commands/exportVideo.ts +21 -2
- package/src/commands/graph.ts +141 -0
- package/src/commands/shared.ts +4 -1
- package/src/commands/test.ts +10 -1
- package/src/cues.ts +17 -0
- package/src/discovery.ts +8 -2
- package/src/docs-snapshot.json +130 -0
- package/src/docs.ts +54 -0
- package/src/index.ts +2 -0
- package/src/registry-snapshot.json +1647 -206
- package/src/registry-source.ts +139 -0
- package/src/render.ts +3 -0
- package/src/structure.ts +177 -0
- package/studio/src/components/ExportPanel.tsx +11 -18
- package/studio/src/main.tsx +1 -0
- package/studio/src/studio.css +18 -0
- package/studio/src/views/ComponentsView.tsx +28 -7
- package/studio/src/views/VideosView.tsx +83 -11
package/src/registry-source.ts
CHANGED
|
@@ -348,4 +348,143 @@ export const verifyIntegrity = (item: RegistryItemDocument, origin: RegistryOrig
|
|
|
348
348
|
);
|
|
349
349
|
};
|
|
350
350
|
|
|
351
|
+
/**
|
|
352
|
+
* Blocks: whole videos, published the same way components are.
|
|
353
|
+
*
|
|
354
|
+
* A block is the larger unit — an entry and the files beside it, plus the
|
|
355
|
+
* components it installs — so it reuses every guarantee this module already
|
|
356
|
+
* makes rather than growing a second way in: the same index-then-document
|
|
357
|
+
* split, the same cache, the same integrity hash over the exact bytes a
|
|
358
|
+
* consumer writes, and the same refusal to write outside the project.
|
|
359
|
+
*
|
|
360
|
+
* There is no bundled floor for blocks. The snapshot exists so a locked-down
|
|
361
|
+
* machine can still install a component and so `odori test` never touches the
|
|
362
|
+
* network; taking a whole video is a deliberate act on a machine that has one.
|
|
363
|
+
* A block therefore resolves network → cache and says so when neither answers.
|
|
364
|
+
*/
|
|
365
|
+
export type BlockDocument = {
|
|
366
|
+
id: string;
|
|
367
|
+
title: string;
|
|
368
|
+
description: string;
|
|
369
|
+
tags: string[];
|
|
370
|
+
format: string;
|
|
371
|
+
/** The file to render, relative to the project root. */
|
|
372
|
+
entry: string;
|
|
373
|
+
/** Registry components this block imports, by namespaced name. */
|
|
374
|
+
components: string[];
|
|
375
|
+
files: Array<{path: string; content: string; target: string}>;
|
|
376
|
+
meta?: {integrity?: string};
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/** The index: every block, without the file contents. */
|
|
380
|
+
export type BlockSummary = Omit<BlockDocument, "files"> & {
|
|
381
|
+
files: Array<{path: string; target: string}>;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
export type BlockIndex = {items: BlockSummary[]; origin: RegistryOrigin; detail: string};
|
|
385
|
+
|
|
386
|
+
const blocksUnavailable = (url: string): Error =>
|
|
387
|
+
new Error(
|
|
388
|
+
`No blocks available: ${url}/blocks.json could not be fetched and nothing is cached on this machine. ` +
|
|
389
|
+
"Blocks are published rather than bundled, so this needs a network once. " +
|
|
390
|
+
"Components still install offline.",
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
export const resolveBlocks = async (
|
|
394
|
+
config: ResolvedConfig,
|
|
395
|
+
options: {allowNetwork?: boolean} = {},
|
|
396
|
+
): Promise<BlockIndex> => {
|
|
397
|
+
const url = registryUrl(config);
|
|
398
|
+
const cache = resolve(cacheDir(url), "blocks.json");
|
|
399
|
+
|
|
400
|
+
if (options.allowNetwork !== false) {
|
|
401
|
+
try {
|
|
402
|
+
const index = (await fetchJson(`${url}/blocks.json`)) as {items: BlockSummary[]};
|
|
403
|
+
if (!Array.isArray(index.items)) throw new Error("the index has no items array");
|
|
404
|
+
await mkdir(dirname(cache), {recursive: true});
|
|
405
|
+
await writeFile(cache, JSON.stringify(index), "utf8");
|
|
406
|
+
return {items: index.items, origin: "network", detail: url};
|
|
407
|
+
} catch {
|
|
408
|
+
// As with components: unreachable is not an error until the disk is
|
|
409
|
+
// empty too.
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (existsSync(cache)) {
|
|
414
|
+
try {
|
|
415
|
+
const index = JSON.parse(await readFile(cache, "utf8")) as {items: BlockSummary[]};
|
|
416
|
+
return {items: index.items, origin: "cache", detail: cache};
|
|
417
|
+
} catch {
|
|
418
|
+
// A corrupt cache is a cache miss.
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
throw blocksUnavailable(url);
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
/** One block, with the source of every file it ships. */
|
|
426
|
+
export const resolveBlock = async (
|
|
427
|
+
config: ResolvedConfig,
|
|
428
|
+
id: string,
|
|
429
|
+
options: {allowNetwork?: boolean} = {},
|
|
430
|
+
): Promise<{block: BlockDocument; origin: RegistryOrigin}> => {
|
|
431
|
+
assertSafeName(id);
|
|
432
|
+
const url = registryUrl(config);
|
|
433
|
+
const cache = resolve(cacheDir(url), `block-${id}.json`);
|
|
434
|
+
|
|
435
|
+
if (options.allowNetwork !== false) {
|
|
436
|
+
try {
|
|
437
|
+
const block = (await fetchJson(`${url}/blocks/${id}.json`)) as BlockDocument;
|
|
438
|
+
if (block?.id !== id) throw new Error(`the document at ${url}/blocks/${id}.json is for "${block?.id}"`);
|
|
439
|
+
await mkdir(dirname(cache), {recursive: true});
|
|
440
|
+
await writeFile(cache, JSON.stringify(block), "utf8");
|
|
441
|
+
return {block, origin: "network"};
|
|
442
|
+
} catch {
|
|
443
|
+
// Try the disk before giving up.
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (existsSync(cache)) {
|
|
448
|
+
try {
|
|
449
|
+
return {block: JSON.parse(await readFile(cache, "utf8")) as BlockDocument, origin: "cache"};
|
|
450
|
+
} catch {
|
|
451
|
+
// Corrupt: fall through to the error, which names the whole situation.
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
throw blocksUnavailable(url);
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* The same hash components use, over a block's files.
|
|
460
|
+
*
|
|
461
|
+
* Integrity proves the bytes and not the intent, which is why every target is
|
|
462
|
+
* still resolved against the project root before anything is written.
|
|
463
|
+
*/
|
|
464
|
+
export const verifyBlockIntegrity = (block: BlockDocument): void => {
|
|
465
|
+
const expected = block.meta?.integrity;
|
|
466
|
+
if (!expected) {
|
|
467
|
+
throw new Error(
|
|
468
|
+
`The document for block "${block.id}" carries no integrity hash. ` +
|
|
469
|
+
"A fetched block must be verifiable; refusing to write it. Nothing was written.",
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const hash = createHash("sha256");
|
|
474
|
+
for (const file of [...block.files].sort((left, right) => left.path.localeCompare(right.path))) {
|
|
475
|
+
hash.update(file.path);
|
|
476
|
+
hash.update("\0");
|
|
477
|
+
hash.update(file.content);
|
|
478
|
+
hash.update("\0");
|
|
479
|
+
}
|
|
480
|
+
const actual = `sha256-${hash.digest("base64")}`;
|
|
481
|
+
if (actual === expected) return;
|
|
482
|
+
|
|
483
|
+
throw new Error(
|
|
484
|
+
`The files for block "${block.id}" do not match the hash the registry published.\n` +
|
|
485
|
+
` expected ${expected}\n received ${actual}\n` +
|
|
486
|
+
"Nothing was written. This is a truncated download, a stale proxy, or a tampered document.",
|
|
487
|
+
);
|
|
488
|
+
};
|
|
489
|
+
|
|
351
490
|
export type {RegistryItemDocument};
|
package/src/render.ts
CHANGED
|
@@ -22,6 +22,8 @@ export type RenderTarget = {
|
|
|
22
22
|
input?: Record<string, unknown>;
|
|
23
23
|
prepared?: unknown;
|
|
24
24
|
audio?: ManifestAudioCue[];
|
|
25
|
+
/** A declared audio variant's name, applied when the page compiles cues. */
|
|
26
|
+
audioVariant?: string;
|
|
25
27
|
targetLufs?: number;
|
|
26
28
|
scenes?: Array<{id: string; start: number; durationInFrames: number}>;
|
|
27
29
|
};
|
|
@@ -32,6 +34,7 @@ const renderUrl = (origin: string, target: RenderTarget, frame: number) => {
|
|
|
32
34
|
const params = new URLSearchParams({render: "1", video: target.videoId, frame: String(frame)});
|
|
33
35
|
if (target.input) params.set("input", encodeParam(target.input));
|
|
34
36
|
if (target.prepared !== undefined) params.set("prepared", encodeParam(target.prepared));
|
|
37
|
+
if (target.audioVariant) params.set("audio", target.audioVariant);
|
|
35
38
|
return `${origin}/?${params.toString()}`;
|
|
36
39
|
};
|
|
37
40
|
|
package/src/structure.ts
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import {readFile} from "node:fs/promises";
|
|
2
|
+
import {existsSync} from "node:fs";
|
|
3
|
+
import {relative, resolve, sep} from "node:path";
|
|
4
|
+
import {resolveEntryLayout} from "odori";
|
|
5
|
+
import type {ResolvedConfig} from "./config";
|
|
6
|
+
import {walkSource, type ProjectGraph} from "./discovery";
|
|
7
|
+
import type {LoadedVideo} from "./project";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A fact about the project's shape that discovery itself cannot say.
|
|
11
|
+
*
|
|
12
|
+
* Discovery answers "what exists": it walks the tree and takes what matches.
|
|
13
|
+
* That is the right behaviour for a build — an unrecognized file must never
|
|
14
|
+
* take the project down — and exactly the wrong behaviour for an author,
|
|
15
|
+
* because everything discovery quietly steps over is a file someone wrote on
|
|
16
|
+
* purpose. The filesystem is Odori's contract: `video.tsx` is a video,
|
|
17
|
+
* `*.preview.tsx` is a fixture, a directory under components/ names a
|
|
18
|
+
* component. This module checks the places where that contract can be almost
|
|
19
|
+
* met, which is the one distance discovery cannot see.
|
|
20
|
+
*
|
|
21
|
+
* An `error` is a promise the project makes and breaks at render or play
|
|
22
|
+
* time: a sound that will be silence, an override that will never be heard.
|
|
23
|
+
* A `warn` is a file that works today and will surprise someone later.
|
|
24
|
+
*/
|
|
25
|
+
export type StructureFinding = {
|
|
26
|
+
level: "error" | "warn";
|
|
27
|
+
/** The file an author would open to fix it, relative to the project root. */
|
|
28
|
+
file: string;
|
|
29
|
+
message: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Entry basenames one keystroke away from meaning something. */
|
|
33
|
+
const NEAR_MISSES: Array<{test: (base: string) => boolean; message: string}> = [
|
|
34
|
+
{
|
|
35
|
+
test: (base) => base === "video.ts" || base === "video.jsx",
|
|
36
|
+
message: "Only video.tsx is a video entry. This file is invisible to discovery: rename it to video.tsx.",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
test: (base) => base.toLowerCase() === "video.tsx" && base !== "video.tsx",
|
|
40
|
+
message: "Entry names are exact and lower case. This file is invisible to discovery: rename it to video.tsx.",
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
test: (base) => /\.preview\.(ts|jsx)$/.test(base),
|
|
44
|
+
message:
|
|
45
|
+
"Only *.preview.tsx is a component fixture. This file is invisible to discovery: rename it to end in .preview.tsx.",
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
test: (base) => base.toLowerCase().endsWith(".preview.tsx") && !base.endsWith(".preview.tsx"),
|
|
49
|
+
message:
|
|
50
|
+
"Fixture names are exact and lower case. This file is invisible to discovery: rename it to end in .preview.tsx.",
|
|
51
|
+
},
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
/** String literals that name a file under the served audio library. */
|
|
55
|
+
const AUDIO_REFERENCE = /["'`](\/audio\/[^"'`\s]+\.[a-z0-9]{2,4})["'`]/gi;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Check the filesystem contract and report every place it is almost met.
|
|
59
|
+
*
|
|
60
|
+
* Everything here is answerable in Node from the tree and the loaded
|
|
61
|
+
* metadata: no browser, no network, no render. That is what makes it cheap
|
|
62
|
+
* enough to run inside `odori doctor` and first inside `odori test`, before
|
|
63
|
+
* a browser ever starts.
|
|
64
|
+
*/
|
|
65
|
+
export const checkStructure = async (
|
|
66
|
+
config: ResolvedConfig,
|
|
67
|
+
graph: ProjectGraph,
|
|
68
|
+
videos: LoadedVideo[],
|
|
69
|
+
): Promise<StructureFinding[]> => {
|
|
70
|
+
const findings: StructureFinding[] = [];
|
|
71
|
+
const videosRoot = resolve(config.root, config.videosDir);
|
|
72
|
+
const componentsRoot = resolve(config.root, config.componentsDir);
|
|
73
|
+
const files = existsSync(videosRoot) ? await walkSource(videosRoot) : [];
|
|
74
|
+
|
|
75
|
+
// Files one keystroke away from being entries. Discovery is filename
|
|
76
|
+
// driven, so `video.ts` is not a video with a small problem — it is not a
|
|
77
|
+
// video at all, and nothing else in the pipeline will ever say so.
|
|
78
|
+
for (const file of files) {
|
|
79
|
+
const base = file.split(sep).pop() ?? "";
|
|
80
|
+
const miss = NEAR_MISSES.find((candidate) => candidate.test(base));
|
|
81
|
+
if (miss) findings.push({level: "warn", file: relative(config.root, file), message: miss.message});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A fixture named after something other than its directory. The catalog
|
|
85
|
+
// attributes usage by matching the fixture's name to the directory videos
|
|
86
|
+
// import from, so a mismatched name previews fine and is credited to
|
|
87
|
+
// nothing: its "used by" list stays empty forever.
|
|
88
|
+
for (const preview of graph.previews) {
|
|
89
|
+
if (!preview.file.startsWith(componentsRoot + sep)) continue;
|
|
90
|
+
const directory = resolve(preview.file, "..").split(sep).pop() ?? "";
|
|
91
|
+
if (directory && preview.name !== directory) {
|
|
92
|
+
findings.push({
|
|
93
|
+
level: "warn",
|
|
94
|
+
file: preview.relativeFile,
|
|
95
|
+
message:
|
|
96
|
+
`Fixture "${preview.name}" sits in components/${directory}/, so usage is never attributed to it. ` +
|
|
97
|
+
`Name the fixture after its directory: ${directory}.preview.tsx.`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A literal audio path with no file behind it. The dev server answers 404
|
|
103
|
+
// and plays on; the export hasher shrugs and warns late. The author finds
|
|
104
|
+
// out when the cut is silent where the sound was supposed to be.
|
|
105
|
+
const audioUrls = new Set(graph.audio.map((entry) => entry.url));
|
|
106
|
+
for (const file of files) {
|
|
107
|
+
if (!/\.(tsx|ts)$/.test(file)) continue;
|
|
108
|
+
const contents = await readFile(file, "utf8");
|
|
109
|
+
const reported = new Set<string>();
|
|
110
|
+
for (const match of contents.matchAll(AUDIO_REFERENCE)) {
|
|
111
|
+
const url = match[1];
|
|
112
|
+
// A template literal with an interpolation is a path decided at
|
|
113
|
+
// runtime. Only what is written in full can be answered for here.
|
|
114
|
+
if (url.includes("${") || audioUrls.has(url) || reported.has(url)) continue;
|
|
115
|
+
reported.add(url);
|
|
116
|
+
findings.push({
|
|
117
|
+
level: "error",
|
|
118
|
+
file: relative(config.root, file),
|
|
119
|
+
message: `References ${url}, and no file answers it under ${config.audioDir}/. It will play as silence.`,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Variant overrides that answer to nothing. A variant replaces brand cues
|
|
125
|
+
// by name, so a name the brand never defines is an override nothing asks
|
|
126
|
+
// for, and a file path with no file is a sound that cannot load. Both are
|
|
127
|
+
// authored intent that the player will quietly ignore.
|
|
128
|
+
for (const video of videos) {
|
|
129
|
+
const layout = resolveEntryLayout(video.entry);
|
|
130
|
+
const brandCues = new Set(Object.keys(layout.brand.audio.cues));
|
|
131
|
+
for (const [variant, overrides] of Object.entries(video.entry.metadata.audio?.variants ?? {})) {
|
|
132
|
+
for (const [cue, value] of Object.entries(overrides)) {
|
|
133
|
+
if (!brandCues.has(cue)) {
|
|
134
|
+
findings.push({
|
|
135
|
+
level: "warn",
|
|
136
|
+
file: video.relativeFile,
|
|
137
|
+
message:
|
|
138
|
+
`Variant "${variant}" overrides a cue named "${cue}" that the brand "${layout.brand.name}" does not define. ` +
|
|
139
|
+
`Nothing plays that name, so the override is never heard.`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
if (typeof value === "string" && value.startsWith("/") && !audioUrls.has(value)) {
|
|
143
|
+
findings.push({
|
|
144
|
+
level: "error",
|
|
145
|
+
file: video.relativeFile,
|
|
146
|
+
message: `Variant "${variant}" points cue "${cue}" at ${value}, and no file answers it under ${config.audioDir}/.`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// A compiled catalog that no longer describes the tree. Anything that
|
|
154
|
+
// reads the artifact instead of running discovery — an agent, a CI step,
|
|
155
|
+
// another tool — would be reading yesterday's project as if it were today's.
|
|
156
|
+
const catalogFile = resolve(config.root, config.outDir, "catalog.json");
|
|
157
|
+
if (existsSync(catalogFile)) {
|
|
158
|
+
try {
|
|
159
|
+
const catalog = JSON.parse(await readFile(catalogFile, "utf8")) as {sourceHash?: string};
|
|
160
|
+
if (catalog.sourceHash && catalog.sourceHash !== graph.sourceHash) {
|
|
161
|
+
findings.push({
|
|
162
|
+
level: "warn",
|
|
163
|
+
file: relative(config.root, catalogFile),
|
|
164
|
+
message: "The compiled catalog is older than the source tree. Run odori graph (or odori dev) to refresh it.",
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
findings.push({
|
|
169
|
+
level: "warn",
|
|
170
|
+
file: relative(config.root, catalogFile),
|
|
171
|
+
message: "The compiled catalog is not valid JSON. Run odori graph (or odori dev) to rewrite it.",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return findings;
|
|
177
|
+
};
|
|
@@ -51,12 +51,18 @@ const outputName = (videoId: string) => videoId.split("/").join("-");
|
|
|
51
51
|
* started here can be retried from either surface.
|
|
52
52
|
*/
|
|
53
53
|
export const ExportPanel = ({
|
|
54
|
+
audioVariant = null,
|
|
55
|
+
sound = true,
|
|
54
56
|
videoId,
|
|
55
57
|
input,
|
|
56
58
|
frame,
|
|
57
59
|
width,
|
|
58
60
|
height,
|
|
59
61
|
}: {
|
|
62
|
+
/** The audio choices, made where the audio is described: the inspector's
|
|
63
|
+
Audio section owns the controls, the export ships what is playing. */
|
|
64
|
+
audioVariant?: string | null;
|
|
65
|
+
sound?: boolean;
|
|
60
66
|
videoId: string;
|
|
61
67
|
input: Record<string, unknown>;
|
|
62
68
|
frame: number;
|
|
@@ -70,7 +76,6 @@ export const ExportPanel = ({
|
|
|
70
76
|
/* Sound is part of the video, so it ships unless it is turned off. GIF has
|
|
71
77
|
no audio track at all and a still is one frame, so the choice only exists
|
|
72
78
|
where it means something. */
|
|
73
|
-
const [sound, setSound] = useState(true);
|
|
74
79
|
const [format, setFormat] = useState<Format>("mp4");
|
|
75
80
|
const [quality, setQuality] = useState<Quality>("studio");
|
|
76
81
|
const [scale, setScale] = useState(1);
|
|
@@ -193,7 +198,7 @@ export const ExportPanel = ({
|
|
|
193
198
|
const run = async () => {
|
|
194
199
|
setNotice(null);
|
|
195
200
|
if (format === "frame") return post("/__odori/still", {videoId, input, frame});
|
|
196
|
-
return post("/__odori/exports", {videoId, input, format, quality, scale, audio: hasSound ? sound : false});
|
|
201
|
+
return post("/__odori/exports", {videoId, input, format, quality, scale, audio: hasSound ? sound : false, ...(audioVariant ? {audioVariant} : {})});
|
|
197
202
|
};
|
|
198
203
|
|
|
199
204
|
const file = outputName(videoId);
|
|
@@ -250,7 +255,10 @@ export const ExportPanel = ({
|
|
|
250
255
|
disabled={busy}
|
|
251
256
|
onClick={() => setScale(option)}
|
|
252
257
|
>
|
|
253
|
-
{
|
|
258
|
+
{/* A resolution, not a multiplier: 1080p means something at a
|
|
259
|
+
glance and 1.5x means arithmetic. The height names the
|
|
260
|
+
chip because that is how people say resolutions. */}
|
|
261
|
+
{scaled(height, option)}p
|
|
254
262
|
</Button>
|
|
255
263
|
))}
|
|
256
264
|
</div>
|
|
@@ -280,21 +288,6 @@ export const ExportPanel = ({
|
|
|
280
288
|
</div>
|
|
281
289
|
) : null}
|
|
282
290
|
|
|
283
|
-
{hasSound ? (
|
|
284
|
-
<div className="export-row">
|
|
285
|
-
<span className="export-label">Sound</span>
|
|
286
|
-
<div className="export-options">
|
|
287
|
-
<Button variant="outline" active={sound} disabled={busy} onClick={() => setSound(true)}
|
|
288
|
-
title="Mix the brand's cues into the file">
|
|
289
|
-
On
|
|
290
|
-
</Button>
|
|
291
|
-
<Button variant="outline" active={!sound} disabled={busy} onClick={() => setSound(false)}
|
|
292
|
-
title="Write the picture with no audio track">
|
|
293
|
-
Off
|
|
294
|
-
</Button>
|
|
295
|
-
</div>
|
|
296
|
-
</div>
|
|
297
|
-
) : null}
|
|
298
291
|
|
|
299
292
|
<div className="export-actions">
|
|
300
293
|
<Button
|
package/studio/src/main.tsx
CHANGED
package/studio/src/studio.css
CHANGED
|
@@ -1181,6 +1181,18 @@ html[data-inspector="collapsed"] .main:not([data-single="true"]) {
|
|
|
1181
1181
|
flex-wrap: wrap;
|
|
1182
1182
|
gap: 6px;
|
|
1183
1183
|
}
|
|
1184
|
+
/* The Voice and Sound rows above the cue facts: each row breathes, and the
|
|
1185
|
+
pair stands clear of the list it governs. */
|
|
1186
|
+
.audio-controls {
|
|
1187
|
+
display: grid;
|
|
1188
|
+
gap: 12px;
|
|
1189
|
+
margin: 2px 0 14px;
|
|
1190
|
+
}
|
|
1191
|
+
/* Cue labels are file names, which outgrow the fact grid's 84px column:
|
|
1192
|
+
bed-launch.m4a wrapped onto two lines in the default width. */
|
|
1193
|
+
.facts-audio .fact {
|
|
1194
|
+
grid-template-columns: 122px minmax(0, 1fr);
|
|
1195
|
+
}
|
|
1184
1196
|
.export-dimensions {
|
|
1185
1197
|
color: var(--subtle);
|
|
1186
1198
|
font-family: var(--font-mono);
|
|
@@ -1216,6 +1228,12 @@ html[data-inspector="collapsed"] .main:not([data-single="true"]) {
|
|
|
1216
1228
|
gap: 6px;
|
|
1217
1229
|
padding: 16px 20px 0;
|
|
1218
1230
|
}
|
|
1231
|
+
.catalog-filter-rule {
|
|
1232
|
+
align-self: stretch;
|
|
1233
|
+
background: var(--border);
|
|
1234
|
+
margin: 2px 6px;
|
|
1235
|
+
width: 1px;
|
|
1236
|
+
}
|
|
1219
1237
|
.catalog-filter button {
|
|
1220
1238
|
background: transparent;
|
|
1221
1239
|
border: 1px solid var(--border);
|
|
@@ -159,7 +159,11 @@ export const ComponentsView = ({
|
|
|
159
159
|
}) => {
|
|
160
160
|
// Finding one by name is ⌘K; the gallery is for browsing them all. A route
|
|
161
161
|
// with no selection is the gallery; a selection is that component's player.
|
|
162
|
-
|
|
162
|
+
//
|
|
163
|
+
// One active filter, of either kind: a video from the derived usage, or a
|
|
164
|
+
// tag an author declared. Stacking them multiplies chips into a query
|
|
165
|
+
// builder, and the question is always singular: what belongs to this?
|
|
166
|
+
const [filter, setFilter] = useState<{kind: "video" | "tag"; value: string} | null>(null);
|
|
163
167
|
|
|
164
168
|
/**
|
|
165
169
|
* Which video a component appears in, derived from the imports rather than
|
|
@@ -174,11 +178,17 @@ export const ComponentsView = ({
|
|
|
174
178
|
() => [...new Set(Object.values(usage).flat())].sort(),
|
|
175
179
|
[usage],
|
|
176
180
|
);
|
|
181
|
+
const tags = useMemo(
|
|
182
|
+
() => [...new Set(componentPreviews.flatMap((item) => item.preview.tags ?? []))].sort(),
|
|
183
|
+
[],
|
|
184
|
+
);
|
|
177
185
|
|
|
178
186
|
const filtered = useMemo(() => {
|
|
179
187
|
const all = [...componentPreviews].sort(byFamily);
|
|
180
|
-
|
|
181
|
-
|
|
188
|
+
if (!filter) return all;
|
|
189
|
+
if (filter.kind === "video") return all.filter((item) => (usage[item.id] ?? []).includes(filter.value));
|
|
190
|
+
return all.filter((item) => (item.preview.tags ?? []).includes(filter.value));
|
|
191
|
+
}, [filter, usage]);
|
|
182
192
|
const selected = selection ? (filtered.find((entry) => entry.id === selection) ?? null) : null;
|
|
183
193
|
const brands = projectBrands();
|
|
184
194
|
const [brandName, setBrandName] = useState(brands[0]?.name ?? defaultBrand.name);
|
|
@@ -246,21 +256,32 @@ export const ComponentsView = ({
|
|
|
246
256
|
}
|
|
247
257
|
return (
|
|
248
258
|
<div style={{overflowY: "auto", width: "100%"}}>
|
|
249
|
-
{videosUsing.length > 0 ? (
|
|
259
|
+
{videosUsing.length > 0 || tags.length > 0 ? (
|
|
250
260
|
<div className="catalog-filter">
|
|
251
|
-
<button type="button" data-active={
|
|
261
|
+
<button type="button" data-active={filter === null ? "true" : undefined} onClick={() => setFilter(null)}>
|
|
252
262
|
All
|
|
253
263
|
</button>
|
|
254
264
|
{videosUsing.map((video) => (
|
|
255
265
|
<button
|
|
256
266
|
key={video}
|
|
257
267
|
type="button"
|
|
258
|
-
data-active={
|
|
259
|
-
onClick={() =>
|
|
268
|
+
data-active={filter?.kind === "video" && filter.value === video ? "true" : undefined}
|
|
269
|
+
onClick={() => setFilter({kind: "video", value: video})}
|
|
260
270
|
>
|
|
261
271
|
{video}
|
|
262
272
|
</button>
|
|
263
273
|
))}
|
|
274
|
+
{tags.length > 0 && videosUsing.length > 0 ? <span className="catalog-filter-rule" /> : null}
|
|
275
|
+
{tags.map((tag) => (
|
|
276
|
+
<button
|
|
277
|
+
key={tag}
|
|
278
|
+
type="button"
|
|
279
|
+
data-active={filter?.kind === "tag" && filter.value === tag ? "true" : undefined}
|
|
280
|
+
onClick={() => setFilter({kind: "tag", value: tag})}
|
|
281
|
+
>
|
|
282
|
+
{tag}
|
|
283
|
+
</button>
|
|
284
|
+
))}
|
|
264
285
|
</div>
|
|
265
286
|
) : null}
|
|
266
287
|
{families.map((family) => (
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {useCallback, useEffect, useMemo, useRef, useState} from "react";
|
|
2
2
|
import {
|
|
3
|
+
AudioVariantContext,
|
|
3
4
|
DUCK_GAIN,
|
|
4
5
|
OdoriRuntime,
|
|
5
6
|
entryDurationInFrames,
|
|
@@ -21,7 +22,7 @@ import {ExportPanel} from "../components/ExportPanel";
|
|
|
21
22
|
import {Diagnostics} from "../components/Diagnostics";
|
|
22
23
|
import {Inspector} from "../components/Inspector";
|
|
23
24
|
import {Navigator} from "../components/Navigator";
|
|
24
|
-
import {Badge, Empty, Fact, SectionTitle, Separator} from "../components/ui";
|
|
25
|
+
import {Badge, Button, Empty, Fact, SectionTitle, Separator} from "../components/ui";
|
|
25
26
|
import {measureTrack} from "../lib/mix-loudness";
|
|
26
27
|
import {readStartSound} from "../settings";
|
|
27
28
|
import {useShortcuts} from "../shortcuts";
|
|
@@ -46,7 +47,16 @@ export const VideosView = ({
|
|
|
46
47
|
}) => {
|
|
47
48
|
// Finding one by name is ⌘K; the gallery is for browsing them all. A route
|
|
48
49
|
// with no selection is the gallery; a selection is that video's player.
|
|
49
|
-
|
|
50
|
+
// The same chips the catalog offers: one active tag, or all of them.
|
|
51
|
+
const [tag, setTag] = useState<string | null>(null);
|
|
52
|
+
// Which declared audio variant plays. Lives here rather than in the export
|
|
53
|
+
// panel because the player has to hear the same voice the export will ship.
|
|
54
|
+
const [audioVariant, setAudioVariant] = useState<string | null>(null);
|
|
55
|
+
// Whether the export ships its track. Lives beside the voice choice: both
|
|
56
|
+
// are audio decisions, and the export panel only inherits them.
|
|
57
|
+
const [sound, setSound] = useState(true);
|
|
58
|
+
const tags = useMemo(() => [...new Set(videos.flatMap((video) => video.metadata.tags ?? []))].sort(), []);
|
|
59
|
+
const filtered = tag ? videos.filter((video) => (video.metadata.tags ?? []).includes(tag)) : videos;
|
|
50
60
|
const selected = selection ? (filtered.find((video) => video.metadata.id === selection) ?? null) : null;
|
|
51
61
|
const [input, setInput] = useState<Record<string, unknown>>({});
|
|
52
62
|
const [timeline, setTimeline] = useState<CompiledTimeline | null>(null);
|
|
@@ -180,6 +190,24 @@ export const VideosView = ({
|
|
|
180
190
|
|
|
181
191
|
if (!selected || !layout) {
|
|
182
192
|
return (
|
|
193
|
+
<div style={{overflowY: "auto", width: "100%"}}>
|
|
194
|
+
{tags.length > 0 ? (
|
|
195
|
+
<div className="catalog-filter">
|
|
196
|
+
<button type="button" data-active={tag === null ? "true" : undefined} onClick={() => setTag(null)}>
|
|
197
|
+
All
|
|
198
|
+
</button>
|
|
199
|
+
{tags.map((name) => (
|
|
200
|
+
<button
|
|
201
|
+
key={name}
|
|
202
|
+
type="button"
|
|
203
|
+
data-active={tag === name ? "true" : undefined}
|
|
204
|
+
onClick={() => setTag(name)}
|
|
205
|
+
>
|
|
206
|
+
{name}
|
|
207
|
+
</button>
|
|
208
|
+
))}
|
|
209
|
+
</div>
|
|
210
|
+
) : null}
|
|
183
211
|
<div className="gallery">
|
|
184
212
|
{filtered.map((video) => {
|
|
185
213
|
const videoLayout = resolveEntryLayout(video);
|
|
@@ -204,6 +232,7 @@ export const VideosView = ({
|
|
|
204
232
|
);
|
|
205
233
|
})}
|
|
206
234
|
</div>
|
|
235
|
+
</div>
|
|
207
236
|
);
|
|
208
237
|
}
|
|
209
238
|
|
|
@@ -243,14 +272,18 @@ export const VideosView = ({
|
|
|
243
272
|
transport, the inspector, and the file list stay usable while
|
|
244
273
|
the error is read and fixed. */}
|
|
245
274
|
<PreviewBoundary resetKey={selected.metadata.id}>
|
|
246
|
-
<
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
275
|
+
<AudioVariantContext.Provider
|
|
276
|
+
value={(audioVariant && selected.metadata.audio?.variants?.[audioVariant]) || null}
|
|
277
|
+
>
|
|
278
|
+
<OdoriRuntime
|
|
279
|
+
entry={selected}
|
|
280
|
+
frame={preview ?? playback.frame}
|
|
281
|
+
input={input}
|
|
282
|
+
assets={project.assets}
|
|
283
|
+
onTimeline={handleTimeline}
|
|
284
|
+
onAudio={handleAudio}
|
|
285
|
+
/>
|
|
286
|
+
</AudioVariantContext.Provider>
|
|
254
287
|
</PreviewBoundary>
|
|
255
288
|
</CanvasStage>
|
|
256
289
|
<Transport
|
|
@@ -277,6 +310,8 @@ export const VideosView = ({
|
|
|
277
310
|
{/* Export sits first: it is the pane's one action, and the header
|
|
278
311
|
names what is being worked on right above it. */}
|
|
279
312
|
<ExportPanel
|
|
313
|
+
audioVariant={audioVariant}
|
|
314
|
+
sound={sound}
|
|
280
315
|
videoId={selected.metadata.id}
|
|
281
316
|
input={input}
|
|
282
317
|
frame={playback.frame}
|
|
@@ -322,7 +357,44 @@ export const VideosView = ({
|
|
|
322
357
|
<>
|
|
323
358
|
<Separator />
|
|
324
359
|
<SectionTitle>Audio</SectionTitle>
|
|
325
|
-
<
|
|
360
|
+
<div className="audio-controls">
|
|
361
|
+
{Object.keys(selected.metadata.audio?.variants ?? {}).length > 0 ? (
|
|
362
|
+
<div className="export-row">
|
|
363
|
+
{/* "Variant", not "Voice": the field is audio.variants and a variant
|
|
364
|
+
can re-point any cue, a bed or a language track as readily as a
|
|
365
|
+
narrator. The label matches the metadata and the export flag, so
|
|
366
|
+
the same word names the thing everywhere it appears. */}
|
|
367
|
+
<span className="export-label">Variant</span>
|
|
368
|
+
<div className="export-options">
|
|
369
|
+
<Button variant="outline" active={audioVariant === null} onClick={() => setAudioVariant(null)}>
|
|
370
|
+
Default
|
|
371
|
+
</Button>
|
|
372
|
+
{Object.keys(selected.metadata.audio?.variants ?? {}).map((name) => (
|
|
373
|
+
<Button
|
|
374
|
+
key={name}
|
|
375
|
+
variant="outline"
|
|
376
|
+
active={audioVariant === name}
|
|
377
|
+
onClick={() => setAudioVariant(name)}
|
|
378
|
+
>
|
|
379
|
+
{name}
|
|
380
|
+
</Button>
|
|
381
|
+
))}
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
) : null}
|
|
385
|
+
<div className="export-row">
|
|
386
|
+
<span className="export-label">Sound</span>
|
|
387
|
+
<div className="export-options">
|
|
388
|
+
<Button variant="outline" active={sound} onClick={() => setSound(true)}>
|
|
389
|
+
On
|
|
390
|
+
</Button>
|
|
391
|
+
<Button variant="outline" active={!sound} onClick={() => setSound(false)}>
|
|
392
|
+
Off
|
|
393
|
+
</Button>
|
|
394
|
+
</div>
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
<dl className="facts facts-audio">
|
|
326
398
|
{track.cues.map((cue) => (
|
|
327
399
|
<Fact key={cue.id} label={cueLabel(cue.src)} title={cue.src.split("/").pop()}>
|
|
328
400
|
{cue.fromFrame}-{cue.fromFrame + cue.durationInFrames - 1} · gain {cue.gain}
|