@odori/cli 0.0.9 → 0.0.11
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-TDM65HRW.js → chunk-77R3UC27.js} +41 -7
- package/dist/cli.js +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +1 -1
- package/package.json +20 -16
- package/src/cli.ts +3 -2
- package/src/commands/dev.ts +8 -1
- package/src/commands/exportVideo.ts +21 -2
- package/src/commands/shared.ts +4 -1
- package/src/cues.ts +17 -0
- package/src/providers.ts +14 -1
- package/src/render.ts +3 -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/LICENSE +0 -22
|
@@ -870,7 +870,11 @@ var elevenlabs = {
|
|
|
870
870
|
signal: AbortSignal.timeout(1e4)
|
|
871
871
|
});
|
|
872
872
|
if (response.ok) return true;
|
|
873
|
-
if (response.status === 401 || response.status === 403)
|
|
873
|
+
if (response.status === 401 || response.status === 403) {
|
|
874
|
+
const detail = await response.text().catch(() => "");
|
|
875
|
+
if (/missing_permissions|permission/i.test(detail)) return true;
|
|
876
|
+
return false;
|
|
877
|
+
}
|
|
874
878
|
throw new Error(`ElevenLabs answered ${response.status} to a key check.`);
|
|
875
879
|
},
|
|
876
880
|
async generate({ prompt, seconds, apiKey }) {
|
|
@@ -1019,6 +1023,13 @@ var registerCues = (brands, fps) => {
|
|
|
1019
1023
|
}
|
|
1020
1024
|
return known.size;
|
|
1021
1025
|
};
|
|
1026
|
+
var registerVariantCues = (variants, fps) => {
|
|
1027
|
+
for (const overrides of Object.values(variants ?? {})) {
|
|
1028
|
+
for (const value of Object.values(overrides)) {
|
|
1029
|
+
if (isCueDefinition(value)) known.set(cueUrl(value), { cue: value, fps });
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1022
1033
|
var renderedCue = (url) => {
|
|
1023
1034
|
const cached = rendered.get(url);
|
|
1024
1035
|
if (cached) return cached;
|
|
@@ -1049,6 +1060,7 @@ var registerProjectCues = async (graph, load = importFresh) => {
|
|
|
1049
1060
|
const entry = { component: module.default, metadata: module.metadata };
|
|
1050
1061
|
const layout = resolveEntryLayout(entry);
|
|
1051
1062
|
registerCues([layout.brand], layout.format.fps);
|
|
1063
|
+
registerVariantCues(entry.metadata.audio?.variants, layout.format.fps);
|
|
1052
1064
|
} catch (error) {
|
|
1053
1065
|
log.warn(`[odori] generated cues in ${video.relativeFile} may use the default frame rate: ${message(error)}`);
|
|
1054
1066
|
}
|
|
@@ -1361,6 +1373,7 @@ var renderUrl = (origin, target, frame) => {
|
|
|
1361
1373
|
const params = new URLSearchParams({ render: "1", video: target.videoId, frame: String(frame) });
|
|
1362
1374
|
if (target.input) params.set("input", encodeParam(target.input));
|
|
1363
1375
|
if (target.prepared !== void 0) params.set("prepared", encodeParam(target.prepared));
|
|
1376
|
+
if (target.audioVariant) params.set("audio", target.audioVariant);
|
|
1364
1377
|
return `${origin}/?${params.toString()}`;
|
|
1365
1378
|
};
|
|
1366
1379
|
var DEFAULT_GRAPHICS = "software";
|
|
@@ -2753,10 +2766,11 @@ var createContext = async (root = process.cwd()) => {
|
|
|
2753
2766
|
const layout = resolveEntryLayout3(video.entry);
|
|
2754
2767
|
brands.set(layout.brand.name, layout.brand);
|
|
2755
2768
|
registerCues([layout.brand], layout.format.fps);
|
|
2769
|
+
registerVariantCues(video.entry.metadata.audio?.variants, layout.format.fps);
|
|
2756
2770
|
}
|
|
2757
2771
|
return { config, graph, videos };
|
|
2758
2772
|
};
|
|
2759
|
-
var targetFor = (video, input, prepared, audio, scenes) => {
|
|
2773
|
+
var targetFor = (video, input, prepared, audio, scenes, audioVariant) => {
|
|
2760
2774
|
const layout = resolveEntryLayout3(video.entry);
|
|
2761
2775
|
return {
|
|
2762
2776
|
videoId: video.entry.metadata.id,
|
|
@@ -2768,6 +2782,7 @@ var targetFor = (video, input, prepared, audio, scenes) => {
|
|
|
2768
2782
|
prepared,
|
|
2769
2783
|
audio,
|
|
2770
2784
|
scenes,
|
|
2785
|
+
audioVariant,
|
|
2771
2786
|
targetLufs: layout.audio.targetLufs
|
|
2772
2787
|
};
|
|
2773
2788
|
};
|
|
@@ -2902,7 +2917,19 @@ var exportCommand = async (id, options = {}) => {
|
|
|
2902
2917
|
return withServer(config, async (server) => {
|
|
2903
2918
|
const record = options.retry ? await readJob(config, options.retry) : await (async () => {
|
|
2904
2919
|
const video2 = findVideo(videos, id);
|
|
2905
|
-
|
|
2920
|
+
if (options.audioVariant) {
|
|
2921
|
+
const declared = Object.keys(video2.entry.metadata.audio?.variants ?? {});
|
|
2922
|
+
if (!declared.includes(options.audioVariant)) {
|
|
2923
|
+
throw new Error(
|
|
2924
|
+
declared.length ? `No audio variant named "${options.audioVariant}". Declared: ${declared.join(", ")}.` : `"${id}" declares no audio variants; drop --audio-variant or add metadata.audio.variants.`
|
|
2925
|
+
);
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
const compiled = await compileInBrowser(
|
|
2929
|
+
server.url,
|
|
2930
|
+
targetFor(video2, options.input, void 0, void 0, void 0, options.audioVariant),
|
|
2931
|
+
config
|
|
2932
|
+
);
|
|
2906
2933
|
const { manifest } = await freezeManifest(
|
|
2907
2934
|
{ ...video2, durationInFrames: compiled.durationInFrames },
|
|
2908
2935
|
graph,
|
|
@@ -2910,9 +2937,10 @@ var exportCommand = async (id, options = {}) => {
|
|
|
2910
2937
|
options.input ?? {},
|
|
2911
2938
|
{ scenes: compiled.scenes, audio: compiled.audio }
|
|
2912
2939
|
);
|
|
2940
|
+
const suffix = options.audioVariant ? `-${options.audioVariant}` : "";
|
|
2913
2941
|
const output = resolve19(
|
|
2914
2942
|
config.root,
|
|
2915
|
-
options.output ?? `${config.exportDir}/${outputName(id)}${format.extension}`
|
|
2943
|
+
options.output ?? `${config.exportDir}/${outputName(id)}${suffix}${format.extension}`
|
|
2916
2944
|
);
|
|
2917
2945
|
return createJob(config, manifest, output, {
|
|
2918
2946
|
format: format.name,
|
|
@@ -3074,7 +3102,12 @@ var devCommand = async (options = {}) => {
|
|
|
3074
3102
|
const { graph, videos } = await context();
|
|
3075
3103
|
const video = findVideo(videos, String(body.videoId));
|
|
3076
3104
|
const input = body.input ?? {};
|
|
3077
|
-
const
|
|
3105
|
+
const audioVariant = typeof body.audioVariant === "string" ? body.audioVariant : void 0;
|
|
3106
|
+
const compiled = await compileInBrowser(
|
|
3107
|
+
origin,
|
|
3108
|
+
targetFor(video, input, void 0, void 0, void 0, audioVariant),
|
|
3109
|
+
config
|
|
3110
|
+
);
|
|
3078
3111
|
const { manifest } = await freezeManifest(
|
|
3079
3112
|
{ ...video, durationInFrames: compiled.durationInFrames },
|
|
3080
3113
|
graph,
|
|
@@ -4184,7 +4217,7 @@ var COMMAND_FLAGS = {
|
|
|
4184
4217
|
bed: ["role", "output", "target", "generate", "provider", "seconds"],
|
|
4185
4218
|
narrate: ["output", "voice", "role", "provider"],
|
|
4186
4219
|
test: ["json"],
|
|
4187
|
-
export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "fast", "no-frame-skip", "retry"],
|
|
4220
|
+
export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "audio-variant", "fast", "no-frame-skip", "retry"],
|
|
4188
4221
|
jobs: [],
|
|
4189
4222
|
help: []
|
|
4190
4223
|
};
|
|
@@ -4268,7 +4301,7 @@ var USAGE = {
|
|
|
4268
4301
|
check, for CI.`,
|
|
4269
4302
|
export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
|
|
4270
4303
|
[--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
|
|
4271
|
-
[--no-audio] [--fast] [--no-frame-skip] [--retry <job>]
|
|
4304
|
+
[--no-audio] [--audio-variant <name>] [--fast] [--no-frame-skip] [--retry <job>]
|
|
4272
4305
|
Render and encode a distributable file. --format is mp4, webm, prores, gif,
|
|
4273
4306
|
or png; without it the output's extension decides, and mp4 is the default.
|
|
4274
4307
|
--quality is studio, social, or web. --scale multiplies the output size,
|
|
@@ -4424,6 +4457,7 @@ var run3 = async (argv) => {
|
|
|
4424
4457
|
scale: numberFlag(flags, "scale"),
|
|
4425
4458
|
format: typeof flags.format === "string" ? flags.format : void 0,
|
|
4426
4459
|
audio: flags["no-audio"] === true ? false : void 0,
|
|
4460
|
+
audioVariant: typeof flags["audio-variant"] === "string" ? flags["audio-variant"] : void 0,
|
|
4427
4461
|
fast: flags.fast === true,
|
|
4428
4462
|
skipUnchangedFrames: flags["no-frame-skip"] === true ? false : void 0,
|
|
4429
4463
|
retry: typeof flags.retry === "string" ? flags.retry : void 0
|
package/dist/cli.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -251,6 +251,8 @@ type RenderTarget = {
|
|
|
251
251
|
input?: Record<string, unknown>;
|
|
252
252
|
prepared?: unknown;
|
|
253
253
|
audio?: ManifestAudioCue[];
|
|
254
|
+
/** A declared audio variant's name, applied when the page compiles cues. */
|
|
255
|
+
audioVariant?: string;
|
|
254
256
|
targetLufs?: number;
|
|
255
257
|
scenes?: Array<{
|
|
256
258
|
id: string;
|
|
@@ -360,7 +362,7 @@ declare const targetFor: (video: LoadedVideo, input?: Record<string, unknown>, p
|
|
|
360
362
|
id: string;
|
|
361
363
|
start: number;
|
|
362
364
|
durationInFrames: number;
|
|
363
|
-
}
|
|
365
|
+
}>, audioVariant?: string) => RenderTarget;
|
|
364
366
|
/**
|
|
365
367
|
* `metadata.duration` is authoritative. When it is absent, the compiled
|
|
366
368
|
* timeline reported by the runtime is used instead.
|
|
@@ -607,6 +609,8 @@ declare const exportCommand: (id: string, options?: {
|
|
|
607
609
|
format?: string;
|
|
608
610
|
/** False writes the picture with no audio track. */
|
|
609
611
|
audio?: boolean;
|
|
612
|
+
/** A declared audio variant to voice this export with. */
|
|
613
|
+
audioVariant?: string;
|
|
610
614
|
/** True draws on the machine's GPU instead of the reproducible backend. */
|
|
611
615
|
fast?: boolean;
|
|
612
616
|
skipUnchangedFrames?: boolean;
|
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@odori/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.11",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The odori command line: discovery, Studio, component installation, stills, tests, and export jobs.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -8,10 +8,7 @@
|
|
|
8
8
|
"odori": "./bin/odori.mjs"
|
|
9
9
|
},
|
|
10
10
|
"exports": {
|
|
11
|
-
".":
|
|
12
|
-
"types": "./dist/index.d.ts",
|
|
13
|
-
"import": "./dist/index.js"
|
|
14
|
-
}
|
|
11
|
+
".": "./src/index.ts"
|
|
15
12
|
},
|
|
16
13
|
"files": [
|
|
17
14
|
"bin",
|
|
@@ -19,16 +16,23 @@
|
|
|
19
16
|
"src",
|
|
20
17
|
"studio"
|
|
21
18
|
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.studio.json",
|
|
21
|
+
"build": "tsup",
|
|
22
|
+
"snapshot": "tsx scripts/snapshot.ts",
|
|
23
|
+
"verify:package": "tsx scripts/verify-package.ts",
|
|
24
|
+
"prepack": "pnpm snapshot && pnpm build && pnpm verify:package"
|
|
25
|
+
},
|
|
22
26
|
"dependencies": {
|
|
23
27
|
"@puppeteer/browsers": "3.2.0",
|
|
24
28
|
"@vitejs/plugin-react": "5.1.2",
|
|
25
29
|
"ffmpeg-static": "5.3.0",
|
|
30
|
+
"odori": "workspace:*",
|
|
26
31
|
"react": "19.2.3",
|
|
27
32
|
"react-dom": "19.2.3",
|
|
28
33
|
"playwright-core": "1.55.0",
|
|
29
34
|
"tsx": "4.20.5",
|
|
30
|
-
"vite": "7.3.0"
|
|
31
|
-
"odori": "0.0.9"
|
|
35
|
+
"vite": "7.3.0"
|
|
32
36
|
},
|
|
33
37
|
"devDependencies": {
|
|
34
38
|
"@types/node": "22.19.0",
|
|
@@ -36,15 +40,15 @@
|
|
|
36
40
|
"@types/react-dom": "19.2.3",
|
|
37
41
|
"tsup": "^8.5.1",
|
|
38
42
|
"typescript": "5.9.3",
|
|
39
|
-
"@odori/registry": "
|
|
43
|
+
"@odori/registry": "workspace:*"
|
|
40
44
|
},
|
|
41
45
|
"publishConfig": {
|
|
42
|
-
"access": "public"
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
"access": "public",
|
|
47
|
+
"exports": {
|
|
48
|
+
".": {
|
|
49
|
+
"types": "./dist/index.d.ts",
|
|
50
|
+
"import": "./dist/index.js"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
49
53
|
}
|
|
50
|
-
}
|
|
54
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -105,7 +105,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
105
105
|
bed: ["role", "output", "target", "generate", "provider", "seconds"],
|
|
106
106
|
narrate: ["output", "voice", "role", "provider"],
|
|
107
107
|
test: ["json"],
|
|
108
|
-
export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "fast", "no-frame-skip", "retry"],
|
|
108
|
+
export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "audio-variant", "fast", "no-frame-skip", "retry"],
|
|
109
109
|
jobs: [],
|
|
110
110
|
help: [],
|
|
111
111
|
};
|
|
@@ -202,7 +202,7 @@ const USAGE: Record<string, string> = {
|
|
|
202
202
|
check, for CI.`,
|
|
203
203
|
export: `odori export <id> [--output <path>] [--input <json>] [--concurrency <n>]
|
|
204
204
|
[--preset <name>] [--format <name>] [--quality <tier>] [--scale <n>]
|
|
205
|
-
[--no-audio] [--fast] [--no-frame-skip] [--retry <job>]
|
|
205
|
+
[--no-audio] [--audio-variant <name>] [--fast] [--no-frame-skip] [--retry <job>]
|
|
206
206
|
Render and encode a distributable file. --format is mp4, webm, prores, gif,
|
|
207
207
|
or png; without it the output's extension decides, and mp4 is the default.
|
|
208
208
|
--quality is studio, social, or web. --scale multiplies the output size,
|
|
@@ -365,6 +365,7 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
365
365
|
scale: numberFlag(flags, "scale"),
|
|
366
366
|
format: typeof flags.format === "string" ? flags.format : undefined,
|
|
367
367
|
audio: flags["no-audio"] === true ? false : undefined,
|
|
368
|
+
audioVariant: typeof flags["audio-variant"] === "string" ? flags["audio-variant"] : undefined,
|
|
368
369
|
fast: flags.fast === true,
|
|
369
370
|
skipUnchangedFrames: flags["no-frame-skip"] === true ? false : undefined,
|
|
370
371
|
retry: typeof flags.retry === "string" ? flags.retry : undefined,
|
package/src/commands/dev.ts
CHANGED
|
@@ -165,7 +165,12 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
165
165
|
const {graph, videos} = await context();
|
|
166
166
|
const video = findVideo(videos, String(body.videoId));
|
|
167
167
|
const input = (body.input ?? {}) as Record<string, unknown>;
|
|
168
|
-
const
|
|
168
|
+
const audioVariant = typeof body.audioVariant === "string" ? body.audioVariant : undefined;
|
|
169
|
+
const compiled = await compileInBrowser(
|
|
170
|
+
origin,
|
|
171
|
+
targetFor(video, input, undefined, undefined, undefined, audioVariant),
|
|
172
|
+
config,
|
|
173
|
+
);
|
|
169
174
|
const {manifest} = await freezeManifest(
|
|
170
175
|
{...video, durationInFrames: compiled.durationInFrames},
|
|
171
176
|
graph,
|
|
@@ -309,6 +314,8 @@ export const devCommand = async (options: {port?: number; root?: string; open?:
|
|
|
309
314
|
verified = null;
|
|
310
315
|
}
|
|
311
316
|
if (verified === false) {
|
|
317
|
+
// The provider's check accepts a valid key regardless of its
|
|
318
|
+
// scoping, so a rejection here means the key itself.
|
|
312
319
|
json(response, 400, {error: `${provider.title} rejected that key.`});
|
|
313
320
|
return;
|
|
314
321
|
}
|
|
@@ -158,6 +158,8 @@ export const exportCommand = async (
|
|
|
158
158
|
format?: string;
|
|
159
159
|
/** False writes the picture with no audio track. */
|
|
160
160
|
audio?: boolean;
|
|
161
|
+
/** A declared audio variant to voice this export with. */
|
|
162
|
+
audioVariant?: string;
|
|
161
163
|
/** True draws on the machine's GPU instead of the reproducible backend. */
|
|
162
164
|
fast?: boolean;
|
|
163
165
|
skipUnchangedFrames?: boolean;
|
|
@@ -176,7 +178,21 @@ export const exportCommand = async (
|
|
|
176
178
|
? await readJob(config, options.retry)
|
|
177
179
|
: await (async () => {
|
|
178
180
|
const video = findVideo(videos, id);
|
|
179
|
-
|
|
181
|
+
if (options.audioVariant) {
|
|
182
|
+
const declared = Object.keys(video.entry.metadata.audio?.variants ?? {});
|
|
183
|
+
if (!declared.includes(options.audioVariant)) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
declared.length
|
|
186
|
+
? `No audio variant named "${options.audioVariant}". Declared: ${declared.join(", ")}.`
|
|
187
|
+
: `"${id}" declares no audio variants; drop --audio-variant or add metadata.audio.variants.`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const compiled = await compileInBrowser(
|
|
192
|
+
server.url,
|
|
193
|
+
targetFor(video, options.input, undefined, undefined, undefined, options.audioVariant),
|
|
194
|
+
config,
|
|
195
|
+
);
|
|
180
196
|
const {manifest} = await freezeManifest(
|
|
181
197
|
{...video, durationInFrames: compiled.durationInFrames},
|
|
182
198
|
graph,
|
|
@@ -184,9 +200,12 @@ export const exportCommand = async (
|
|
|
184
200
|
options.input ?? {},
|
|
185
201
|
{scenes: compiled.scenes, audio: compiled.audio},
|
|
186
202
|
);
|
|
203
|
+
// The variant names the file, so two voices cannot overwrite each
|
|
204
|
+
// other's exports.
|
|
205
|
+
const suffix = options.audioVariant ? `-${options.audioVariant}` : "";
|
|
187
206
|
const output = resolve(
|
|
188
207
|
config.root,
|
|
189
|
-
options.output ?? `${config.exportDir}/${outputName(id)}${format.extension}`,
|
|
208
|
+
options.output ?? `${config.exportDir}/${outputName(id)}${suffix}${format.extension}`,
|
|
190
209
|
);
|
|
191
210
|
return createJob(config, manifest, output, {
|
|
192
211
|
format: format.name,
|
package/src/commands/shared.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import {resolveEntryLayout, type AudioCue, type ManifestAudioCue, type Brand} from "odori";
|
|
2
2
|
import {loadConfig, type ResolvedConfig} from "../config";
|
|
3
|
-
import {registerCues} from "../cues";
|
|
3
|
+
import {registerCues, registerVariantCues} from "../cues";
|
|
4
4
|
import {discoverProject, type ProjectGraph} from "../discovery";
|
|
5
5
|
import {loadVideos, type LoadedVideo} from "../project";
|
|
6
6
|
import {openRenderPage, readAudio, readTimeline, type RenderTarget} from "../render";
|
|
@@ -19,6 +19,7 @@ export const createContext = async (root = process.cwd()): Promise<Context> => {
|
|
|
19
19
|
const layout = resolveEntryLayout(video.entry);
|
|
20
20
|
brands.set(layout.brand.name, layout.brand);
|
|
21
21
|
registerCues([layout.brand], layout.format.fps);
|
|
22
|
+
registerVariantCues(video.entry.metadata.audio?.variants, layout.format.fps);
|
|
22
23
|
}
|
|
23
24
|
return {config, graph, videos};
|
|
24
25
|
};
|
|
@@ -29,6 +30,7 @@ export const targetFor = (
|
|
|
29
30
|
prepared?: unknown,
|
|
30
31
|
audio?: ManifestAudioCue[],
|
|
31
32
|
scenes?: Array<{id: string; start: number; durationInFrames: number}>,
|
|
33
|
+
audioVariant?: string,
|
|
32
34
|
): RenderTarget => {
|
|
33
35
|
const layout = resolveEntryLayout(video.entry);
|
|
34
36
|
return {
|
|
@@ -41,6 +43,7 @@ export const targetFor = (
|
|
|
41
43
|
prepared,
|
|
42
44
|
audio,
|
|
43
45
|
scenes,
|
|
46
|
+
audioVariant,
|
|
44
47
|
targetLufs: layout.audio.targetLufs,
|
|
45
48
|
};
|
|
46
49
|
};
|
package/src/cues.ts
CHANGED
|
@@ -80,6 +80,22 @@ export const registerCues = (brands: Brand[], fps: number): number => {
|
|
|
80
80
|
return known.size;
|
|
81
81
|
};
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Variant cues live on a video's metadata rather than on a brand, and a
|
|
85
|
+
* synthesized one still has to be answerable by URL: an audition that plays
|
|
86
|
+
* the default voice but 404s the alternatives is not an audition.
|
|
87
|
+
*/
|
|
88
|
+
export const registerVariantCues = (
|
|
89
|
+
variants: Record<string, Record<string, string | CueDefinition>> | undefined,
|
|
90
|
+
fps: number,
|
|
91
|
+
): void => {
|
|
92
|
+
for (const overrides of Object.values(variants ?? {})) {
|
|
93
|
+
for (const value of Object.values(overrides)) {
|
|
94
|
+
if (isCueDefinition(value)) known.set(cueUrl(value), {cue: value, fps});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
83
99
|
export const renderedCue = (url: string): Uint8Array | null => {
|
|
84
100
|
const cached = rendered.get(url);
|
|
85
101
|
if (cached) return cached;
|
|
@@ -143,6 +159,7 @@ export const registerProjectCues = async (graph: ProjectGraph, load: ModuleLoade
|
|
|
143
159
|
const entry = {component: module.default, metadata: module.metadata} as VideoEntry;
|
|
144
160
|
const layout = resolveEntryLayout(entry);
|
|
145
161
|
registerCues([layout.brand], layout.format.fps);
|
|
162
|
+
registerVariantCues(entry.metadata.audio?.variants, layout.format.fps);
|
|
146
163
|
} catch (error) {
|
|
147
164
|
log.warn(`[odori] generated cues in ${video.relativeFile} may use the default frame rate: ${message(error)}`);
|
|
148
165
|
}
|
package/src/providers.ts
CHANGED
|
@@ -45,7 +45,20 @@ const elevenlabs: MusicProvider = {
|
|
|
45
45
|
signal: AbortSignal.timeout(10_000),
|
|
46
46
|
});
|
|
47
47
|
if (response.ok) return true;
|
|
48
|
-
if (response.status === 401 || response.status === 403)
|
|
48
|
+
if (response.status === 401 || response.status === 403) {
|
|
49
|
+
/*
|
|
50
|
+
* A restricted key opts in to endpoints one by one, and this probe's
|
|
51
|
+
* endpoint is one of them: a real key scoped to something else answers
|
|
52
|
+
* 401 here exactly like a wrong key does. The error body tells them
|
|
53
|
+
* apart, an invalid key names itself invalid, a valid one names the
|
|
54
|
+
* permission it lacks, and a key that is real but scoped elsewhere is
|
|
55
|
+
* a pass. Whether it can reach the integration's own endpoint is the
|
|
56
|
+
* generate call's problem, which reports it properly.
|
|
57
|
+
*/
|
|
58
|
+
const detail = await response.text().catch(() => "");
|
|
59
|
+
if (/missing_permissions|permission/i.test(detail)) return true;
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
49
62
|
throw new Error(`ElevenLabs answered ${response.status} to a key check.`);
|
|
50
63
|
},
|
|
51
64
|
async generate({prompt, seconds, apiKey}) {
|
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
|
|
|
@@ -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}
|
package/LICENSE
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 Allen Zhou
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
|
22
|
-
|