@odori/cli 0.0.8 → 0.0.9
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-UPYHTDXP.js → chunk-TDM65HRW.js} +123 -10
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/{registry-snapshot-GV6FFZXV.js → registry-snapshot-TKH2KAC3.js} +1 -1
- package/package.json +2 -2
- package/src/cli.ts +16 -0
- package/src/commands/narrate.ts +74 -0
- package/src/providers.ts +84 -1
- package/src/registry-snapshot.json +1 -1
- package/src/server.ts +10 -0
- package/studio/src/Studio.tsx +2 -4
- package/studio/src/components/GenerateBed.tsx +148 -0
- package/studio/src/components/Settings.tsx +266 -50
- package/studio/src/components/ui.tsx +11 -1
- package/studio/src/integrations.ts +29 -0
- package/studio/src/studio.css +170 -3
- package/studio/src/views/AssetsView.tsx +6 -0
- package/studio/src/virtual.d.ts +1 -0
- package/studio/src/views/IntegrationsView.tsx +0 -270
|
@@ -317,7 +317,7 @@ var toComponent = (item) => ({
|
|
|
317
317
|
});
|
|
318
318
|
var snapshotItems = async () => {
|
|
319
319
|
try {
|
|
320
|
-
const loaded = await import("./registry-snapshot-
|
|
320
|
+
const loaded = await import("./registry-snapshot-TKH2KAC3.js");
|
|
321
321
|
return loaded.default.items;
|
|
322
322
|
} catch {
|
|
323
323
|
throw new Error(
|
|
@@ -893,6 +893,54 @@ Is ${elevenlabs.keyVariable} a current key?` : "")
|
|
|
893
893
|
return { bytes: new Uint8Array(await response.arrayBuffer()), extension };
|
|
894
894
|
}
|
|
895
895
|
};
|
|
896
|
+
var elevenlabsVoice = {
|
|
897
|
+
name: "elevenlabs",
|
|
898
|
+
title: "ElevenLabs Speech",
|
|
899
|
+
docsUrl: "https://elevenlabs.io/docs/api-reference/text-to-speech",
|
|
900
|
+
keyVariable: "ELEVENLABS_API_KEY",
|
|
901
|
+
// Rachel, the provider's most neutral narrator. --voice overrides.
|
|
902
|
+
defaultVoice: "21m00Tcm4TlvDq8ikWAM",
|
|
903
|
+
verifyKey: (apiKey) => elevenlabs.verifyKey(apiKey),
|
|
904
|
+
async speak({ script, voice, apiKey }) {
|
|
905
|
+
const response = await fetch(
|
|
906
|
+
`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}/with-timestamps?output_format=mp3_44100_128`,
|
|
907
|
+
{
|
|
908
|
+
method: "POST",
|
|
909
|
+
headers: { "xi-api-key": apiKey, "content-type": "application/json" },
|
|
910
|
+
body: JSON.stringify({ text: script, model_id: "eleven_multilingual_v2" }),
|
|
911
|
+
signal: AbortSignal.timeout(3e5)
|
|
912
|
+
}
|
|
913
|
+
);
|
|
914
|
+
if (!response.ok) {
|
|
915
|
+
const detail = await response.text().catch(() => "");
|
|
916
|
+
throw new Error(
|
|
917
|
+
`ElevenLabs returned ${response.status} ${response.statusText}.` + (detail ? `
|
|
918
|
+
${detail.slice(0, 400)}` : "") + (response.status === 401 ? `
|
|
919
|
+
Is ${elevenlabsVoice.keyVariable} a current key?` : "")
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
const payload = await response.json();
|
|
923
|
+
return {
|
|
924
|
+
bytes: Uint8Array.from(Buffer.from(payload.audio_base64, "base64")),
|
|
925
|
+
extension: "mp3",
|
|
926
|
+
alignment: {
|
|
927
|
+
characters: payload.alignment.characters,
|
|
928
|
+
startSeconds: payload.alignment.character_start_times_seconds,
|
|
929
|
+
endSeconds: payload.alignment.character_end_times_seconds
|
|
930
|
+
}
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
};
|
|
934
|
+
var voiceProviders = { elevenlabs: elevenlabsVoice };
|
|
935
|
+
var resolveVoiceProvider = (name) => {
|
|
936
|
+
const provider = voiceProviders[name];
|
|
937
|
+
if (!provider) {
|
|
938
|
+
throw new Error(
|
|
939
|
+
`No voice provider named "${name}". Available: ${Object.keys(voiceProviders).join(", ")}.`
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
return provider;
|
|
943
|
+
};
|
|
896
944
|
var musicProviders = { elevenlabs };
|
|
897
945
|
var resolveMusicProvider = (name) => {
|
|
898
946
|
const provider = musicProviders[name];
|
|
@@ -2445,6 +2493,13 @@ var studioEntry = resolve18(studioRoot, "index.html");
|
|
|
2445
2493
|
var installRoot = resolve18(cliRoot, "..", "..");
|
|
2446
2494
|
var VIRTUAL_ID = "virtual:odori-project";
|
|
2447
2495
|
var RESOLVED_ID = `\0${VIRTUAL_ID}`;
|
|
2496
|
+
var cliVersion = () => {
|
|
2497
|
+
try {
|
|
2498
|
+
return createRequire2(import.meta.url)("../package.json").version;
|
|
2499
|
+
} catch {
|
|
2500
|
+
return "dev";
|
|
2501
|
+
}
|
|
2502
|
+
};
|
|
2448
2503
|
var runtimeSource = (root) => {
|
|
2449
2504
|
for (const from of [resolve18(root, "package.json"), import.meta.url]) {
|
|
2450
2505
|
try {
|
|
@@ -2495,6 +2550,7 @@ var odoriProjectPlugin = (config, getGraph) => ({
|
|
|
2495
2550
|
audioDir: config.audioDir,
|
|
2496
2551
|
docsUrl: config.docsUrl,
|
|
2497
2552
|
audio: graph.audio,
|
|
2553
|
+
version: cliVersion(),
|
|
2498
2554
|
sourceHash: graph.sourceHash,
|
|
2499
2555
|
assets: config.assets ?? [],
|
|
2500
2556
|
files: {
|
|
@@ -3561,8 +3617,50 @@ var listCommand = async () => {
|
|
|
3561
3617
|
}
|
|
3562
3618
|
};
|
|
3563
3619
|
|
|
3620
|
+
// src/commands/narrate.ts
|
|
3621
|
+
import { mkdir as mkdir18, writeFile as writeFile19 } from "fs/promises";
|
|
3622
|
+
import { basename as basename3, dirname as dirname9, extname as extname2, join as join9, resolve as resolve24 } from "path";
|
|
3623
|
+
import { wordsFromCharacters } from "odori";
|
|
3624
|
+
var narrateCommand = async (script, options = {}) => {
|
|
3625
|
+
if (!script.trim()) throw new Error('Give the script to read, for example: odori narrate "One definition. Every render."');
|
|
3626
|
+
const config = await loadConfig(process.cwd());
|
|
3627
|
+
const provider = resolveVoiceProvider(options.provider ?? "elevenlabs");
|
|
3628
|
+
const apiKey = await resolveKey(provider);
|
|
3629
|
+
if (!apiKey) {
|
|
3630
|
+
throw new Error(
|
|
3631
|
+
`Narrating with ${provider.name} needs a key: set ${provider.keyVariable} in the environment,
|
|
3632
|
+
or paste one once into Studio\u2019s integrations page ("odori dev", then Integrations).
|
|
3633
|
+
Either way it is sent only to the provider and never stored in the project.`
|
|
3634
|
+
);
|
|
3635
|
+
}
|
|
3636
|
+
const voice = options.voice ?? provider.defaultVoice;
|
|
3637
|
+
log.detail(`Recording ${script.split(/\s+/).length} words with ${provider.title}`);
|
|
3638
|
+
const { bytes, extension, alignment } = await provider.speak({ script, voice, apiKey });
|
|
3639
|
+
const words = wordsFromCharacters(alignment);
|
|
3640
|
+
if (words.length === 0) throw new Error("The provider returned no word timings, so captions cannot be derived. Nothing was written.");
|
|
3641
|
+
const stem = options.output ? basename3(options.output, extname2(options.output)) : script.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "narration";
|
|
3642
|
+
const directory2 = options.output ? dirname9(resolve24(config.root, options.output)) : join9(config.root, "public", "audio");
|
|
3643
|
+
await mkdir18(directory2, { recursive: true });
|
|
3644
|
+
const audioFile = join9(directory2, `${stem}.${extension}`);
|
|
3645
|
+
await writeFile19(audioFile, bytes);
|
|
3646
|
+
const role = options.role ?? "voice.narration";
|
|
3647
|
+
const url = `/audio/${basename3(audioFile)}`;
|
|
3648
|
+
const narration = { script, provider: provider.name, voice, audio: role, words };
|
|
3649
|
+
const timingFile = join9(directory2, `${stem}.narration.json`);
|
|
3650
|
+
await writeFile19(timingFile, `${JSON.stringify(narration, null, 2)}
|
|
3651
|
+
`, "utf8");
|
|
3652
|
+
const registered = await registerCueInBrand(config, { name: role, url }, stem);
|
|
3653
|
+
const seconds = words[words.length - 1].endSeconds;
|
|
3654
|
+
log.success(`Recorded ${seconds.toFixed(1)}s to ${basename3(audioFile)} (${(bytes.length / 1024).toFixed(0)} KB)`);
|
|
3655
|
+
log.success(`Word timings in ${basename3(timingFile)}`);
|
|
3656
|
+
if (registered) log.detail(`Registered "${role}" in the brand`);
|
|
3657
|
+
log.detail("In a video:");
|
|
3658
|
+
log.detail(` <Audio src="${role}" />`);
|
|
3659
|
+
log.detail(` <Captions cues={captionCues(narration, fps)} /> // import narration from the json`);
|
|
3660
|
+
};
|
|
3661
|
+
|
|
3564
3662
|
// src/commands/frame.ts
|
|
3565
|
-
import { resolve as
|
|
3663
|
+
import { resolve as resolve25 } from "path";
|
|
3566
3664
|
import { framesFromOffset, resolveEntryLayout as resolveEntryLayout7 } from "odori";
|
|
3567
3665
|
var frameCommand = async (id, options = {}) => {
|
|
3568
3666
|
const at = options.at ?? 0;
|
|
@@ -3584,7 +3682,7 @@ var frameCommand = async (id, options = {}) => {
|
|
|
3584
3682
|
throw new Error(`Frame ${frame} is past the last frame (${manifest.format.durationInFrames - 1}).`);
|
|
3585
3683
|
}
|
|
3586
3684
|
const target = targetFor({ ...video, durationInFrames: manifest.format.durationInFrames }, input, prepared);
|
|
3587
|
-
const file =
|
|
3685
|
+
const file = resolve25(config.root, options.output ?? `${config.exportDir}/${outputName(id)}-${frame}.png`);
|
|
3588
3686
|
return renderStill(server.url, target, frame, file, config);
|
|
3589
3687
|
});
|
|
3590
3688
|
log.success(`Frame ${frame} written to ${output}`);
|
|
@@ -3597,7 +3695,7 @@ import { isOdoriSchema as isOdoriSchema2, resolveEntryLayout as resolveEntryLayo
|
|
|
3597
3695
|
// src/contracts.ts
|
|
3598
3696
|
import { existsSync as existsSync21 } from "fs";
|
|
3599
3697
|
import { readdir as readdir7 } from "fs/promises";
|
|
3600
|
-
import { resolve as
|
|
3698
|
+
import { resolve as resolve26 } from "path";
|
|
3601
3699
|
import { cueUrl as cueUrl2, isCueDefinition as isCueDefinition2, resolveEntryLayout as resolveEntryLayout8 } from "odori";
|
|
3602
3700
|
var primaryFamily = (stack) => (stack.split(",")[0] ?? "").trim().replace(/^["']|["']$/g, "");
|
|
3603
3701
|
var SYSTEM_FAMILIES = /* @__PURE__ */ new Set([
|
|
@@ -3664,7 +3762,7 @@ var checkAudioWindows = (cues, brand, videoId) => {
|
|
|
3664
3762
|
return failures;
|
|
3665
3763
|
};
|
|
3666
3764
|
var checkInstalledContracts = async (config, videos) => {
|
|
3667
|
-
const componentsDir =
|
|
3765
|
+
const componentsDir = resolve26(config.root, config.componentsDir);
|
|
3668
3766
|
const onDisk = existsSync21(componentsDir) ? (await readdir7(componentsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name) : [];
|
|
3669
3767
|
const names = /* @__PURE__ */ new Set([...Object.keys(await readProvenance(config)), ...onDisk]);
|
|
3670
3768
|
if (names.size === 0) return [];
|
|
@@ -3688,7 +3786,7 @@ var checkInstalledContracts = async (config, videos) => {
|
|
|
3688
3786
|
// src/determinism.ts
|
|
3689
3787
|
import { readdir as readdir8, readFile as readFile17 } from "fs/promises";
|
|
3690
3788
|
import { existsSync as existsSync22 } from "fs";
|
|
3691
|
-
import { join as
|
|
3789
|
+
import { join as join10, relative as relative12, resolve as resolve27 } from "path";
|
|
3692
3790
|
var FORBIDDEN = [
|
|
3693
3791
|
{
|
|
3694
3792
|
pattern: /\bMath\.random\s*\(/,
|
|
@@ -3722,14 +3820,14 @@ var scanSource = (source, file) => {
|
|
|
3722
3820
|
var walk2 = async (directory2, files = []) => {
|
|
3723
3821
|
for (const entry of await readdir8(directory2, { withFileTypes: true })) {
|
|
3724
3822
|
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
3725
|
-
const full =
|
|
3823
|
+
const full = join10(directory2, entry.name);
|
|
3726
3824
|
if (entry.isDirectory()) await walk2(full, files);
|
|
3727
3825
|
else if (/\.(tsx|ts|jsx|js)$/.test(entry.name) && !/\.preview\.(tsx|jsx)$/.test(entry.name)) files.push(full);
|
|
3728
3826
|
}
|
|
3729
3827
|
return files;
|
|
3730
3828
|
};
|
|
3731
3829
|
var checkDeterminism = async (config) => {
|
|
3732
|
-
const root =
|
|
3830
|
+
const root = resolve27(config.root, config.videosDir);
|
|
3733
3831
|
if (!existsSync22(root)) return [];
|
|
3734
3832
|
const files = await walk2(root);
|
|
3735
3833
|
const findings = await Promise.all(
|
|
@@ -4084,6 +4182,7 @@ var COMMAND_FLAGS = {
|
|
|
4084
4182
|
inspect: ["json", "input"],
|
|
4085
4183
|
frame: ["at", "output", "input"],
|
|
4086
4184
|
bed: ["role", "output", "target", "generate", "provider", "seconds"],
|
|
4185
|
+
narrate: ["output", "voice", "role", "provider"],
|
|
4087
4186
|
test: ["json"],
|
|
4088
4187
|
export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "fast", "no-frame-skip", "retry"],
|
|
4089
4188
|
jobs: [],
|
|
@@ -4130,6 +4229,11 @@ var USAGE = {
|
|
|
4130
4229
|
With --generate the positional is a prompt instead of a path: the track is
|
|
4131
4230
|
generated with a provider (--provider, default elevenlabs, key from
|
|
4132
4231
|
ELEVENLABS_API_KEY), then prepared identically. --seconds sets its length.`,
|
|
4232
|
+
narrate: `odori narrate <script> [--output <path>] [--voice <id>] [--role <cue>]
|
|
4233
|
+
Record the script as narration. Writes the audio and a .narration.json with
|
|
4234
|
+
the time every word starts and ends, and registers the cue role (default
|
|
4235
|
+
voice.narration) in the brand. Captions derive from the timings at compose
|
|
4236
|
+
time, so they cannot drift from the voice.`,
|
|
4133
4237
|
integrations: `odori integrations
|
|
4134
4238
|
List generation providers and whether each is connected. Configuration
|
|
4135
4239
|
lives in the environment or Studio's integrations page; generation happens
|
|
@@ -4194,6 +4298,7 @@ Usage
|
|
|
4194
4298
|
odori frame <id> --at 4s Render one deterministic frame to a PNG
|
|
4195
4299
|
odori test [id] [--json] Validate contracts and representative frames
|
|
4196
4300
|
odori export <id> [--output f] Render and encode a distributable file
|
|
4301
|
+
odori narrate <script> Record narration with word timings
|
|
4197
4302
|
odori jobs List export jobs and their status
|
|
4198
4303
|
|
|
4199
4304
|
Options
|
|
@@ -4212,7 +4317,7 @@ Options
|
|
|
4212
4317
|
|
|
4213
4318
|
Run "odori <command> --help" for one command, or "odori doctor" to check setup.
|
|
4214
4319
|
`;
|
|
4215
|
-
var
|
|
4320
|
+
var cliVersion2 = () => {
|
|
4216
4321
|
try {
|
|
4217
4322
|
const require2 = createRequire4(import.meta.url);
|
|
4218
4323
|
return require2("../package.json").version;
|
|
@@ -4224,7 +4329,7 @@ var run3 = async (argv) => {
|
|
|
4224
4329
|
const { command: command2, positionals, flags } = parseArgs(argv);
|
|
4225
4330
|
try {
|
|
4226
4331
|
if (command2 === "--version" || command2 === "-v" || command2 === "version") {
|
|
4227
|
-
log.info(`odori ${
|
|
4332
|
+
log.info(`odori ${cliVersion2()} (node ${process.version})`);
|
|
4228
4333
|
return 0;
|
|
4229
4334
|
}
|
|
4230
4335
|
if (flags.help === true && USAGE[command2]) {
|
|
@@ -4289,6 +4394,14 @@ var run3 = async (argv) => {
|
|
|
4289
4394
|
seconds: numberFlag(flags, "seconds")
|
|
4290
4395
|
});
|
|
4291
4396
|
return 0;
|
|
4397
|
+
case "narrate":
|
|
4398
|
+
await narrateCommand(positionals.join(" "), {
|
|
4399
|
+
output: typeof flags.output === "string" ? flags.output : void 0,
|
|
4400
|
+
voice: typeof flags.voice === "string" ? flags.voice : void 0,
|
|
4401
|
+
role: typeof flags.role === "string" ? flags.role : void 0,
|
|
4402
|
+
provider: typeof flags.provider === "string" ? flags.provider : void 0
|
|
4403
|
+
});
|
|
4404
|
+
return 0;
|
|
4292
4405
|
case "frame":
|
|
4293
4406
|
await frameCommand(positionals[0] ?? "", {
|
|
4294
4407
|
// A duration, so "4s" and "120f" both work; a bare number is
|
package/dist/cli.js
CHANGED
package/dist/index.js
CHANGED
|
@@ -4267,7 +4267,7 @@ export default defineComponentPreview({
|
|
|
4267
4267
|
files: [
|
|
4268
4268
|
{
|
|
4269
4269
|
path: "components/statement/statement.tsx",
|
|
4270
|
-
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type StatementChip = {\n /** The chip\'s text. */\n label: string;\n /** A glyph drawn before the label: a logo, a symbol, a single letter. */\n icon?: string;\n /** Which surface the chip is drawn on. */\n tone?: "neutral" | "dark" | "accent";\n};\n\nexport type StatementProps = {\n /** The sentence, in order. A string is a word; an object is a chip. */\n parts: Array<string | StatementChip>;\n /**\n * What arrives at a time. Characters read as someone typing, words as\n * someone talking, and `none` puts the whole line up at once.\n */\n reveal?: "characters" | "words" | "none";\n /**\n * How a part arrives. `rise` lifts and fades it in; `cut` switches it on.\n * A cut is not a lesser rise: a video built entirely of hard cuts has a\n * rhythm that any easing softens away.\n */\n motion?: "rise" | "cut";\n /** Frames between one unit and the next. */\n stagger?: number;\n /** Where the sentence sits. */\n align?: "left" | "center";\n /** Light page or dark page. Chips and text follow it. */\n theme?: "light" | "dark";\n /** A chip that replaces the last chip in place. */\n swap?: StatementChip;\n /** The frame the swap happens on. */\n swapAt?: number;\n};\n\nconst isChip = (part: string | StatementChip): part is StatementChip => typeof part !== "string";\n\n/**\n * A sentence, arriving under its own discipline, with product controls set\n * inline where the prose needs one.\n *\n * Real product videos disagree about how type should arrive, and they are all\n * right. One types character by character because it is imitating a person at\n * a keyboard. One cuts word by word with no easing anywhere, because every\n * other transition in it is a cut and a single eased rise would sound a wrong\n * note. One lifts whole phrases because it is narrating. So the discipline is\n * a choice here rather than a house opinion baked into three near-identical\n * components.\n *\n * Characters reveal inside a layout that is already the whole sentence: each\n * glyph switches on where it will finally sit, so nothing reflows and no caret\n * is needed to explain a ragged edge, because there is no ragged edge.\n *\n * A chip is drawn the way the interface draws it, so a sentence can name a\n * control and the next scene can cut to that control at scale without the two\n * disagreeing about what the product looks like. `swap` exchanges the last\n * chip in place, so one vendor becoming another is a substitution rather than\n * a reflow of the line.\n */\nexport const Statement = ({\n parts,\n reveal = "words",\n motion = "rise",\n stagger,\n align = "left",\n theme = "light",\n swap,\n swapAt,\n}: StatementProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const dark = theme === "dark";\n const ink = dark ? brand.colors.background : brand.colors.foreground;\n const paper = dark ? brand.colors.foreground : brand.colors.background;\n\n // One frame per character reads as typing; a couple of stagger units per\n // word reads as speech.\n const step = stagger ?? (reveal === "characters" ? 1 : brand.motion.staggerFrames * 2);\n const start = 6;\n const lastChip = parts.map(isChip).lastIndexOf(true);\n const swapFrame = swapAt ?? 0;\n\n // Where each part begins, counted in whatever unit is being revealed.\n const offsets: number[] = [];\n let units = 0;\n for (const part of parts) {\n offsets.push(units);\n units += reveal === "characters" && !isChip(part) ? part.length + 1 : 1;\n }\n\n const shown = (unit: number) => {\n if (reveal === "none") return frame >= start ? 1 : 0;\n const at = start + unit * step;\n if (motion === "cut") return frame >= at ? 1 : 0;\n return interpolate(frame, [at, at + 20], [0, 1], {easing: Easing.standard});\n };\n\n const enter = (progress: number) =>\n motion === "cut"\n ? {display: "inline-block", opacity: progress}\n : {\n display: "inline-block",\n opacity: progress,\n transform: `translateY(${(1 - progress) * 26 * scale}px)`,\n };\n\n const chipSurface = (tone: StatementChip["tone"]) => {\n if (tone === "accent") return {background: brand.colors.accent, color: paper, border: "transparent"};\n if (tone === "dark") return {background: ink, color: paper, border: "transparent"};\n // Mixed from the ink rather than named, so a neutral chip is legible on a\n // light brand, a dark brand, and the inverted scenes without being told\n // which it is standing on.\n return {\n background: `color-mix(in srgb, ${ink} 8%, transparent)`,\n color: ink,\n border: `color-mix(in srgb, ${ink} 20%, transparent)`,\n };\n };\n\n const chipBody = (chip: StatementChip) => {\n const surface = chipSurface(chip.tone);\n return (\n <span\n style={{\n alignItems: "center",\n background: surface.background,\n border: `${Math.max(1, scale)}px solid ${surface.border}`,\n borderRadius: 14 * scale,\n color: surface.color,\n display: "inline-flex",\n gap: 10 * scale,\n padding: `${6 * scale}px ${16 * scale}px`,\n whiteSpace: "nowrap",\n }}\n >\n {chip.icon ? <span style={{fontSize: 46 * scale, lineHeight: 1}}>{chip.icon}</span> : null}\n {chip.label}\n </span>\n );\n };\n\n return (\n <Fill\n style={{\n alignItems: align === "center" ? "center" : "flex-start",\n background: dark ?
|
|
4270
|
+
content: 'import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from "odori";\n\nexport type StatementChip = {\n /** The chip\'s text. */\n label: string;\n /** A glyph drawn before the label: a logo, a symbol, a single letter. */\n icon?: string;\n /** Which surface the chip is drawn on. */\n tone?: "neutral" | "dark" | "accent";\n};\n\nexport type StatementProps = {\n /** The sentence, in order. A string is a word; an object is a chip. */\n parts: Array<string | StatementChip>;\n /**\n * What arrives at a time. Characters read as someone typing, words as\n * someone talking, and `none` puts the whole line up at once.\n */\n reveal?: "characters" | "words" | "none";\n /**\n * How a part arrives. `rise` lifts and fades it in; `cut` switches it on.\n * A cut is not a lesser rise: a video built entirely of hard cuts has a\n * rhythm that any easing softens away.\n */\n motion?: "rise" | "cut";\n /** Frames between one unit and the next. */\n stagger?: number;\n /** Where the sentence sits. */\n align?: "left" | "center";\n /** Light page or dark page. Chips and text follow it. */\n theme?: "light" | "dark";\n /** A chip that replaces the last chip in place. */\n swap?: StatementChip;\n /** The frame the swap happens on. */\n swapAt?: number;\n};\n\nconst isChip = (part: string | StatementChip): part is StatementChip => typeof part !== "string";\n\n/**\n * A sentence, arriving under its own discipline, with product controls set\n * inline where the prose needs one.\n *\n * Real product videos disagree about how type should arrive, and they are all\n * right. One types character by character because it is imitating a person at\n * a keyboard. One cuts word by word with no easing anywhere, because every\n * other transition in it is a cut and a single eased rise would sound a wrong\n * note. One lifts whole phrases because it is narrating. So the discipline is\n * a choice here rather than a house opinion baked into three near-identical\n * components.\n *\n * Characters reveal inside a layout that is already the whole sentence: each\n * glyph switches on where it will finally sit, so nothing reflows and no caret\n * is needed to explain a ragged edge, because there is no ragged edge.\n *\n * A chip is drawn the way the interface draws it, so a sentence can name a\n * control and the next scene can cut to that control at scale without the two\n * disagreeing about what the product looks like. `swap` exchanges the last\n * chip in place, so one vendor becoming another is a substitution rather than\n * a reflow of the line.\n */\nexport const Statement = ({\n parts,\n reveal = "words",\n motion = "rise",\n stagger,\n align = "left",\n theme = "light",\n swap,\n swapAt,\n}: StatementProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const dark = theme === "dark";\n const ink = dark ? brand.colors.background : brand.colors.foreground;\n const paper = dark ? brand.colors.foreground : brand.colors.background;\n\n // One frame per character reads as typing; a couple of stagger units per\n // word reads as speech.\n const step = stagger ?? (reveal === "characters" ? 1 : brand.motion.staggerFrames * 2);\n const start = 6;\n const lastChip = parts.map(isChip).lastIndexOf(true);\n const swapFrame = swapAt ?? 0;\n\n // Where each part begins, counted in whatever unit is being revealed.\n const offsets: number[] = [];\n let units = 0;\n for (const part of parts) {\n offsets.push(units);\n units += reveal === "characters" && !isChip(part) ? part.length + 1 : 1;\n }\n\n const shown = (unit: number) => {\n if (reveal === "none") return frame >= start ? 1 : 0;\n const at = start + unit * step;\n if (motion === "cut") return frame >= at ? 1 : 0;\n return interpolate(frame, [at, at + 20], [0, 1], {easing: Easing.standard});\n };\n\n const enter = (progress: number) =>\n motion === "cut"\n ? {display: "inline-block", opacity: progress}\n : {\n display: "inline-block",\n opacity: progress,\n transform: `translateY(${(1 - progress) * 26 * scale}px)`,\n };\n\n const chipSurface = (tone: StatementChip["tone"]) => {\n if (tone === "accent") return {background: brand.colors.accent, color: paper, border: "transparent"};\n if (tone === "dark") return {background: ink, color: paper, border: "transparent"};\n // Mixed from the ink rather than named, so a neutral chip is legible on a\n // light brand, a dark brand, and the inverted scenes without being told\n // which it is standing on.\n return {\n background: `color-mix(in srgb, ${ink} 8%, transparent)`,\n color: ink,\n border: `color-mix(in srgb, ${ink} 20%, transparent)`,\n };\n };\n\n const chipBody = (chip: StatementChip) => {\n const surface = chipSurface(chip.tone);\n return (\n <span\n style={{\n alignItems: "center",\n background: surface.background,\n border: `${Math.max(1, scale)}px solid ${surface.border}`,\n borderRadius: 14 * scale,\n color: surface.color,\n display: "inline-flex",\n gap: 10 * scale,\n padding: `${6 * scale}px ${16 * scale}px`,\n whiteSpace: "nowrap",\n }}\n >\n {chip.icon ? <span style={{fontSize: 46 * scale, lineHeight: 1}}>{chip.icon}</span> : null}\n {chip.label}\n </span>\n );\n };\n\n return (\n <Fill\n style={{\n alignItems: align === "center" ? "center" : "flex-start",\n // paper, not ink. ink is the type colour, and painting the ground\n // with it put light text on a light ground: the dark theme rendered\n // the sentence invisible, including in this component\'s own fixture.\n background: dark ? paper : "transparent",\n justifyContent: "center",\n padding: `${120 * scale}px ${150 * scale}px`,\n }}\n >\n <div\n style={{\n alignItems: "center",\n color: ink,\n columnGap: 16 * scale,\n display: "flex",\n flexWrap: "wrap",\n fontSize: 64 * scale,\n fontWeight: 500,\n justifyContent: align === "center" ? "center" : "flex-start",\n letterSpacing: "-0.03em",\n lineHeight: 1.35,\n rowGap: 8 * scale,\n textAlign: align,\n }}\n >\n {parts.map((part, index) => {\n const swapping = swap !== undefined && index === lastChip;\n const swapped = swapping\n ? interpolate(frame, [swapFrame, swapFrame + (motion === "cut" ? 1 : 16)], [0, 1], {\n easing: Easing.standard,\n })\n : 0;\n\n if (!isChip(part)) {\n // In a pre-measured layout every glyph already occupies its final\n // place, so revealing one cannot move the ones after it.\n if (reveal === "characters") {\n return (\n <span key={`${part}-${index}`} style={{display: "inline-block", whiteSpace: "pre"}}>\n {[...part].map((character, position) => (\n <span key={position} style={{display: "inline-block", opacity: shown(offsets[index] + position)}}>\n {character}\n </span>\n ))}\n </span>\n );\n }\n return (\n <span key={`${part}-${index}`} style={enter(shown(offsets[index]))}>\n {part}\n </span>\n );\n }\n\n const entrance = shown(offsets[index]);\n return (\n <span\n key={`chip-${index}`}\n style={{\n ...enter(entrance),\n // A chip settles from slightly small, the way a control that\n // just appeared under the cursor does. A cut skips the settle.\n ...(motion === "cut"\n ? {}\n : {transform: `translateY(${(1 - entrance) * 26 * scale}px) scale(${0.92 + entrance * 0.08})`}),\n }}\n >\n {swapping ? (\n <span style={{display: "inline-grid"}}>\n <span\n style={{\n gridArea: "1 / 1",\n opacity: 1 - swapped,\n transform: motion === "cut" ? undefined : `translateY(${swapped * -18 * scale}px)`,\n }}\n >\n {chipBody(part)}\n </span>\n <span\n style={{\n gridArea: "1 / 1",\n opacity: swapped,\n transform: motion === "cut" ? undefined : `translateY(${(1 - swapped) * 18 * scale}px)`,\n }}\n >\n {chipBody(swap)}\n </span>\n </span>\n ) : (\n chipBody(part)\n )}\n </span>\n );\n })}\n </div>\n </Fill>\n );\n};\n',
|
|
4271
4271
|
target: "videos/components/statement/statement.tsx"
|
|
4272
4272
|
},
|
|
4273
4273
|
{
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@odori/cli",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
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",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"playwright-core": "1.55.0",
|
|
29
29
|
"tsx": "4.20.5",
|
|
30
30
|
"vite": "7.3.0",
|
|
31
|
-
"odori": "0.0.
|
|
31
|
+
"odori": "0.0.9"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/node": "22.19.0",
|
package/src/cli.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {inspectCommand} from "./commands/inspect";
|
|
|
12
12
|
import {listCommand} from "./commands/list";
|
|
13
13
|
import {newCommand} from "./commands/new";
|
|
14
14
|
import {bedCommand} from "./commands/bed";
|
|
15
|
+
import {narrateCommand} from "./commands/narrate";
|
|
15
16
|
import {frameCommand} from "./commands/frame";
|
|
16
17
|
import {testCommand} from "./commands/test";
|
|
17
18
|
|
|
@@ -102,6 +103,7 @@ const COMMAND_FLAGS: Record<string, string[]> = {
|
|
|
102
103
|
inspect: ["json", "input"],
|
|
103
104
|
frame: ["at", "output", "input"],
|
|
104
105
|
bed: ["role", "output", "target", "generate", "provider", "seconds"],
|
|
106
|
+
narrate: ["output", "voice", "role", "provider"],
|
|
105
107
|
test: ["json"],
|
|
106
108
|
export: ["output", "input", "concurrency", "preset", "format", "quality", "scale", "no-audio", "fast", "no-frame-skip", "retry"],
|
|
107
109
|
jobs: [],
|
|
@@ -161,6 +163,11 @@ const USAGE: Record<string, string> = {
|
|
|
161
163
|
With --generate the positional is a prompt instead of a path: the track is
|
|
162
164
|
generated with a provider (--provider, default elevenlabs, key from
|
|
163
165
|
ELEVENLABS_API_KEY), then prepared identically. --seconds sets its length.`,
|
|
166
|
+
narrate: `odori narrate <script> [--output <path>] [--voice <id>] [--role <cue>]
|
|
167
|
+
Record the script as narration. Writes the audio and a .narration.json with
|
|
168
|
+
the time every word starts and ends, and registers the cue role (default
|
|
169
|
+
voice.narration) in the brand. Captions derive from the timings at compose
|
|
170
|
+
time, so they cannot drift from the voice.`,
|
|
164
171
|
integrations: `odori integrations
|
|
165
172
|
List generation providers and whether each is connected. Configuration
|
|
166
173
|
lives in the environment or Studio's integrations page; generation happens
|
|
@@ -226,6 +233,7 @@ Usage
|
|
|
226
233
|
odori frame <id> --at 4s Render one deterministic frame to a PNG
|
|
227
234
|
odori test [id] [--json] Validate contracts and representative frames
|
|
228
235
|
odori export <id> [--output f] Render and encode a distributable file
|
|
236
|
+
odori narrate <script> Record narration with word timings
|
|
229
237
|
odori jobs List export jobs and their status
|
|
230
238
|
|
|
231
239
|
Options
|
|
@@ -327,6 +335,14 @@ export const run = async (argv: string[]): Promise<number> => {
|
|
|
327
335
|
seconds: numberFlag(flags, "seconds"),
|
|
328
336
|
});
|
|
329
337
|
return 0;
|
|
338
|
+
case "narrate":
|
|
339
|
+
await narrateCommand(positionals.join(" "), {
|
|
340
|
+
output: typeof flags.output === "string" ? flags.output : undefined,
|
|
341
|
+
voice: typeof flags.voice === "string" ? flags.voice : undefined,
|
|
342
|
+
role: typeof flags.role === "string" ? flags.role : undefined,
|
|
343
|
+
provider: typeof flags.provider === "string" ? flags.provider : undefined,
|
|
344
|
+
});
|
|
345
|
+
return 0;
|
|
330
346
|
case "frame":
|
|
331
347
|
await frameCommand(positionals[0] ?? "", {
|
|
332
348
|
// A duration, so "4s" and "120f" both work; a bare number is
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {mkdir, writeFile} from "node:fs/promises";
|
|
2
|
+
import {basename, dirname, extname, join, resolve} from "node:path";
|
|
3
|
+
import {wordsFromCharacters, type Narration} from "odori";
|
|
4
|
+
import {registerCueInBrand} from "../brand-file";
|
|
5
|
+
import {loadConfig} from "../config";
|
|
6
|
+
import {log} from "../log";
|
|
7
|
+
import {resolveKey, resolveVoiceProvider} from "../providers";
|
|
8
|
+
|
|
9
|
+
export type NarrateOptions = {
|
|
10
|
+
output?: string;
|
|
11
|
+
voice?: string;
|
|
12
|
+
provider?: string;
|
|
13
|
+
/** The brand cue role the audio registers under. */
|
|
14
|
+
role?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Record a narration and keep it as source.
|
|
19
|
+
*
|
|
20
|
+
* One call produces the audio and the time every word starts and ends, and
|
|
21
|
+
* both land in the project: the file under public/audio, the timings beside
|
|
22
|
+
* it as `<name>.narration.json`. Captions derive from the timings at compose
|
|
23
|
+
* time, so they cannot drift from the voice, and a scene can be sized to a
|
|
24
|
+
* sentence because the sentence's end is data.
|
|
25
|
+
*
|
|
26
|
+
* Like every generation in Odori this is an authoring step. The provider is
|
|
27
|
+
* called once, here, and the render never knows a network was involved.
|
|
28
|
+
*/
|
|
29
|
+
export const narrateCommand = async (script: string, options: NarrateOptions = {}) => {
|
|
30
|
+
if (!script.trim()) throw new Error('Give the script to read, for example: odori narrate "One definition. Every render."');
|
|
31
|
+
const config = await loadConfig(process.cwd());
|
|
32
|
+
const provider = resolveVoiceProvider(options.provider ?? "elevenlabs");
|
|
33
|
+
const apiKey = await resolveKey(provider);
|
|
34
|
+
if (!apiKey) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Narrating with ${provider.name} needs a key: set ${provider.keyVariable} in the environment,\n` +
|
|
37
|
+
'or paste one once into Studio’s integrations page ("odori dev", then Integrations).\n' +
|
|
38
|
+
"Either way it is sent only to the provider and never stored in the project.",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const voice = options.voice ?? provider.defaultVoice;
|
|
43
|
+
log.detail(`Recording ${script.split(/\s+/).length} words with ${provider.title}`);
|
|
44
|
+
const {bytes, extension, alignment} = await provider.speak({script, voice, apiKey});
|
|
45
|
+
const words = wordsFromCharacters(alignment);
|
|
46
|
+
if (words.length === 0) throw new Error("The provider returned no word timings, so captions cannot be derived. Nothing was written.");
|
|
47
|
+
|
|
48
|
+
const stem = options.output
|
|
49
|
+
? basename(options.output, extname(options.output))
|
|
50
|
+
: script.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 40) || "narration";
|
|
51
|
+
const directory = options.output
|
|
52
|
+
? dirname(resolve(config.root, options.output))
|
|
53
|
+
: join(config.root, "public", "audio");
|
|
54
|
+
await mkdir(directory, {recursive: true});
|
|
55
|
+
|
|
56
|
+
const audioFile = join(directory, `${stem}.${extension}`);
|
|
57
|
+
await writeFile(audioFile, bytes);
|
|
58
|
+
|
|
59
|
+
const role = options.role ?? "voice.narration";
|
|
60
|
+
const url = `/audio/${basename(audioFile)}`;
|
|
61
|
+
const narration: Narration = {script, provider: provider.name, voice, audio: role, words};
|
|
62
|
+
const timingFile = join(directory, `${stem}.narration.json`);
|
|
63
|
+
await writeFile(timingFile, `${JSON.stringify(narration, null, 2)}\n`, "utf8");
|
|
64
|
+
|
|
65
|
+
const registered = await registerCueInBrand(config, {name: role, url}, stem);
|
|
66
|
+
const seconds = words[words.length - 1].endSeconds;
|
|
67
|
+
|
|
68
|
+
log.success(`Recorded ${seconds.toFixed(1)}s to ${basename(audioFile)} (${(bytes.length / 1024).toFixed(0)} KB)`);
|
|
69
|
+
log.success(`Word timings in ${basename(timingFile)}`);
|
|
70
|
+
if (registered) log.detail(`Registered "${role}" in the brand`);
|
|
71
|
+
log.detail("In a video:");
|
|
72
|
+
log.detail(` <Audio src="${role}" />`);
|
|
73
|
+
log.detail(` <Captions cues={captionCues(narration, fps)} /> // import narration from the json`);
|
|
74
|
+
};
|
package/src/providers.ts
CHANGED
|
@@ -69,6 +69,89 @@ const elevenlabs: MusicProvider = {
|
|
|
69
69
|
},
|
|
70
70
|
};
|
|
71
71
|
|
|
72
|
+
export type GeneratedSpeech = {
|
|
73
|
+
bytes: Uint8Array;
|
|
74
|
+
extension: string;
|
|
75
|
+
/** Character-level timing, parallel arrays over the spoken text. */
|
|
76
|
+
alignment: {characters: string[]; startSeconds: number[]; endSeconds: number[]};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export type VoiceProvider = {
|
|
80
|
+
name: string;
|
|
81
|
+
title: string;
|
|
82
|
+
docsUrl: string;
|
|
83
|
+
keyVariable: string;
|
|
84
|
+
defaultVoice: string;
|
|
85
|
+
speak(options: {script: string; voice: string; apiKey: string}): Promise<GeneratedSpeech>;
|
|
86
|
+
verifyKey(apiKey: string): Promise<boolean>;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/*
|
|
90
|
+
* Speech comes back with its own word timing, in the same call as the audio.
|
|
91
|
+
* That is the whole design: the recording and its alignment are one artifact,
|
|
92
|
+
* so captions derived from it cannot drift from the voice, and nothing ever
|
|
93
|
+
* has to transcribe audio after the fact to find out when a word happened.
|
|
94
|
+
*/
|
|
95
|
+
const elevenlabsVoice: VoiceProvider = {
|
|
96
|
+
name: "elevenlabs",
|
|
97
|
+
title: "ElevenLabs Speech",
|
|
98
|
+
docsUrl: "https://elevenlabs.io/docs/api-reference/text-to-speech",
|
|
99
|
+
keyVariable: "ELEVENLABS_API_KEY",
|
|
100
|
+
// Rachel, the provider's most neutral narrator. --voice overrides.
|
|
101
|
+
defaultVoice: "21m00Tcm4TlvDq8ikWAM",
|
|
102
|
+
verifyKey: (apiKey) => elevenlabs.verifyKey(apiKey),
|
|
103
|
+
async speak({script, voice, apiKey}) {
|
|
104
|
+
const response = await fetch(
|
|
105
|
+
`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}/with-timestamps?output_format=mp3_44100_128`,
|
|
106
|
+
{
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: {"xi-api-key": apiKey, "content-type": "application/json"},
|
|
109
|
+
body: JSON.stringify({text: script, model_id: "eleven_multilingual_v2"}),
|
|
110
|
+
signal: AbortSignal.timeout(300_000),
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
const detail = await response.text().catch(() => "");
|
|
115
|
+
throw new Error(
|
|
116
|
+
`ElevenLabs returned ${response.status} ${response.statusText}.` +
|
|
117
|
+
(detail ? `
|
|
118
|
+
${detail.slice(0, 400)}` : "") +
|
|
119
|
+
(response.status === 401 ? `
|
|
120
|
+
Is ${elevenlabsVoice.keyVariable} a current key?` : ""),
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
const payload = (await response.json()) as {
|
|
124
|
+
audio_base64: string;
|
|
125
|
+
alignment: {
|
|
126
|
+
characters: string[];
|
|
127
|
+
character_start_times_seconds: number[];
|
|
128
|
+
character_end_times_seconds: number[];
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
return {
|
|
132
|
+
bytes: Uint8Array.from(Buffer.from(payload.audio_base64, "base64")),
|
|
133
|
+
extension: "mp3",
|
|
134
|
+
alignment: {
|
|
135
|
+
characters: payload.alignment.characters,
|
|
136
|
+
startSeconds: payload.alignment.character_start_times_seconds,
|
|
137
|
+
endSeconds: payload.alignment.character_end_times_seconds,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const voiceProviders: Record<string, VoiceProvider> = {elevenlabs: elevenlabsVoice};
|
|
144
|
+
|
|
145
|
+
export const resolveVoiceProvider = (name: string): VoiceProvider => {
|
|
146
|
+
const provider = voiceProviders[name];
|
|
147
|
+
if (!provider) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`No voice provider named "${name}". Available: ${Object.keys(voiceProviders).join(", ")}.`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return provider;
|
|
153
|
+
};
|
|
154
|
+
|
|
72
155
|
export const musicProviders: Record<string, MusicProvider> = {elevenlabs};
|
|
73
156
|
|
|
74
157
|
export const resolveMusicProvider = (name: string): MusicProvider => {
|
|
@@ -86,5 +169,5 @@ export const resolveMusicProvider = (name: string): MusicProvider => {
|
|
|
86
169
|
* Studio integrations page writes. Both are read here and nowhere else, so
|
|
87
170
|
* "where do keys come from" has one answer.
|
|
88
171
|
*/
|
|
89
|
-
export const resolveKey = async (provider:
|
|
172
|
+
export const resolveKey = async (provider: {keyVariable: string}): Promise<string | undefined> =>
|
|
90
173
|
process.env[provider.keyVariable] ?? (await storedKey(provider.keyVariable));
|
|
@@ -4035,7 +4035,7 @@
|
|
|
4035
4035
|
"files": [
|
|
4036
4036
|
{
|
|
4037
4037
|
"path": "components/statement/statement.tsx",
|
|
4038
|
-
"content": "import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type StatementChip = {\n /** The chip's text. */\n label: string;\n /** A glyph drawn before the label: a logo, a symbol, a single letter. */\n icon?: string;\n /** Which surface the chip is drawn on. */\n tone?: \"neutral\" | \"dark\" | \"accent\";\n};\n\nexport type StatementProps = {\n /** The sentence, in order. A string is a word; an object is a chip. */\n parts: Array<string | StatementChip>;\n /**\n * What arrives at a time. Characters read as someone typing, words as\n * someone talking, and `none` puts the whole line up at once.\n */\n reveal?: \"characters\" | \"words\" | \"none\";\n /**\n * How a part arrives. `rise` lifts and fades it in; `cut` switches it on.\n * A cut is not a lesser rise: a video built entirely of hard cuts has a\n * rhythm that any easing softens away.\n */\n motion?: \"rise\" | \"cut\";\n /** Frames between one unit and the next. */\n stagger?: number;\n /** Where the sentence sits. */\n align?: \"left\" | \"center\";\n /** Light page or dark page. Chips and text follow it. */\n theme?: \"light\" | \"dark\";\n /** A chip that replaces the last chip in place. */\n swap?: StatementChip;\n /** The frame the swap happens on. */\n swapAt?: number;\n};\n\nconst isChip = (part: string | StatementChip): part is StatementChip => typeof part !== \"string\";\n\n/**\n * A sentence, arriving under its own discipline, with product controls set\n * inline where the prose needs one.\n *\n * Real product videos disagree about how type should arrive, and they are all\n * right. One types character by character because it is imitating a person at\n * a keyboard. One cuts word by word with no easing anywhere, because every\n * other transition in it is a cut and a single eased rise would sound a wrong\n * note. One lifts whole phrases because it is narrating. So the discipline is\n * a choice here rather than a house opinion baked into three near-identical\n * components.\n *\n * Characters reveal inside a layout that is already the whole sentence: each\n * glyph switches on where it will finally sit, so nothing reflows and no caret\n * is needed to explain a ragged edge, because there is no ragged edge.\n *\n * A chip is drawn the way the interface draws it, so a sentence can name a\n * control and the next scene can cut to that control at scale without the two\n * disagreeing about what the product looks like. `swap` exchanges the last\n * chip in place, so one vendor becoming another is a substitution rather than\n * a reflow of the line.\n */\nexport const Statement = ({\n parts,\n reveal = \"words\",\n motion = \"rise\",\n stagger,\n align = \"left\",\n theme = \"light\",\n swap,\n swapAt,\n}: StatementProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const dark = theme === \"dark\";\n const ink = dark ? brand.colors.background : brand.colors.foreground;\n const paper = dark ? brand.colors.foreground : brand.colors.background;\n\n // One frame per character reads as typing; a couple of stagger units per\n // word reads as speech.\n const step = stagger ?? (reveal === \"characters\" ? 1 : brand.motion.staggerFrames * 2);\n const start = 6;\n const lastChip = parts.map(isChip).lastIndexOf(true);\n const swapFrame = swapAt ?? 0;\n\n // Where each part begins, counted in whatever unit is being revealed.\n const offsets: number[] = [];\n let units = 0;\n for (const part of parts) {\n offsets.push(units);\n units += reveal === \"characters\" && !isChip(part) ? part.length + 1 : 1;\n }\n\n const shown = (unit: number) => {\n if (reveal === \"none\") return frame >= start ? 1 : 0;\n const at = start + unit * step;\n if (motion === \"cut\") return frame >= at ? 1 : 0;\n return interpolate(frame, [at, at + 20], [0, 1], {easing: Easing.standard});\n };\n\n const enter = (progress: number) =>\n motion === \"cut\"\n ? {display: \"inline-block\", opacity: progress}\n : {\n display: \"inline-block\",\n opacity: progress,\n transform: `translateY(${(1 - progress) * 26 * scale}px)`,\n };\n\n const chipSurface = (tone: StatementChip[\"tone\"]) => {\n if (tone === \"accent\") return {background: brand.colors.accent, color: paper, border: \"transparent\"};\n if (tone === \"dark\") return {background: ink, color: paper, border: \"transparent\"};\n // Mixed from the ink rather than named, so a neutral chip is legible on a\n // light brand, a dark brand, and the inverted scenes without being told\n // which it is standing on.\n return {\n background: `color-mix(in srgb, ${ink} 8%, transparent)`,\n color: ink,\n border: `color-mix(in srgb, ${ink} 20%, transparent)`,\n };\n };\n\n const chipBody = (chip: StatementChip) => {\n const surface = chipSurface(chip.tone);\n return (\n <span\n style={{\n alignItems: \"center\",\n background: surface.background,\n border: `${Math.max(1, scale)}px solid ${surface.border}`,\n borderRadius: 14 * scale,\n color: surface.color,\n display: \"inline-flex\",\n gap: 10 * scale,\n padding: `${6 * scale}px ${16 * scale}px`,\n whiteSpace: \"nowrap\",\n }}\n >\n {chip.icon ? <span style={{fontSize: 46 * scale, lineHeight: 1}}>{chip.icon}</span> : null}\n {chip.label}\n </span>\n );\n };\n\n return (\n <Fill\n style={{\n alignItems: align === \"center\" ? \"center\" : \"flex-start\",\n background: dark ?
|
|
4038
|
+
"content": "import {Fill, Easing, interpolate, useBrand, useFrame, useDesignScale} from \"odori\";\n\nexport type StatementChip = {\n /** The chip's text. */\n label: string;\n /** A glyph drawn before the label: a logo, a symbol, a single letter. */\n icon?: string;\n /** Which surface the chip is drawn on. */\n tone?: \"neutral\" | \"dark\" | \"accent\";\n};\n\nexport type StatementProps = {\n /** The sentence, in order. A string is a word; an object is a chip. */\n parts: Array<string | StatementChip>;\n /**\n * What arrives at a time. Characters read as someone typing, words as\n * someone talking, and `none` puts the whole line up at once.\n */\n reveal?: \"characters\" | \"words\" | \"none\";\n /**\n * How a part arrives. `rise` lifts and fades it in; `cut` switches it on.\n * A cut is not a lesser rise: a video built entirely of hard cuts has a\n * rhythm that any easing softens away.\n */\n motion?: \"rise\" | \"cut\";\n /** Frames between one unit and the next. */\n stagger?: number;\n /** Where the sentence sits. */\n align?: \"left\" | \"center\";\n /** Light page or dark page. Chips and text follow it. */\n theme?: \"light\" | \"dark\";\n /** A chip that replaces the last chip in place. */\n swap?: StatementChip;\n /** The frame the swap happens on. */\n swapAt?: number;\n};\n\nconst isChip = (part: string | StatementChip): part is StatementChip => typeof part !== \"string\";\n\n/**\n * A sentence, arriving under its own discipline, with product controls set\n * inline where the prose needs one.\n *\n * Real product videos disagree about how type should arrive, and they are all\n * right. One types character by character because it is imitating a person at\n * a keyboard. One cuts word by word with no easing anywhere, because every\n * other transition in it is a cut and a single eased rise would sound a wrong\n * note. One lifts whole phrases because it is narrating. So the discipline is\n * a choice here rather than a house opinion baked into three near-identical\n * components.\n *\n * Characters reveal inside a layout that is already the whole sentence: each\n * glyph switches on where it will finally sit, so nothing reflows and no caret\n * is needed to explain a ragged edge, because there is no ragged edge.\n *\n * A chip is drawn the way the interface draws it, so a sentence can name a\n * control and the next scene can cut to that control at scale without the two\n * disagreeing about what the product looks like. `swap` exchanges the last\n * chip in place, so one vendor becoming another is a substitution rather than\n * a reflow of the line.\n */\nexport const Statement = ({\n parts,\n reveal = \"words\",\n motion = \"rise\",\n stagger,\n align = \"left\",\n theme = \"light\",\n swap,\n swapAt,\n}: StatementProps) => {\n const frame = useFrame();\n const brand = useBrand();\n const scale = useDesignScale();\n const dark = theme === \"dark\";\n const ink = dark ? brand.colors.background : brand.colors.foreground;\n const paper = dark ? brand.colors.foreground : brand.colors.background;\n\n // One frame per character reads as typing; a couple of stagger units per\n // word reads as speech.\n const step = stagger ?? (reveal === \"characters\" ? 1 : brand.motion.staggerFrames * 2);\n const start = 6;\n const lastChip = parts.map(isChip).lastIndexOf(true);\n const swapFrame = swapAt ?? 0;\n\n // Where each part begins, counted in whatever unit is being revealed.\n const offsets: number[] = [];\n let units = 0;\n for (const part of parts) {\n offsets.push(units);\n units += reveal === \"characters\" && !isChip(part) ? part.length + 1 : 1;\n }\n\n const shown = (unit: number) => {\n if (reveal === \"none\") return frame >= start ? 1 : 0;\n const at = start + unit * step;\n if (motion === \"cut\") return frame >= at ? 1 : 0;\n return interpolate(frame, [at, at + 20], [0, 1], {easing: Easing.standard});\n };\n\n const enter = (progress: number) =>\n motion === \"cut\"\n ? {display: \"inline-block\", opacity: progress}\n : {\n display: \"inline-block\",\n opacity: progress,\n transform: `translateY(${(1 - progress) * 26 * scale}px)`,\n };\n\n const chipSurface = (tone: StatementChip[\"tone\"]) => {\n if (tone === \"accent\") return {background: brand.colors.accent, color: paper, border: \"transparent\"};\n if (tone === \"dark\") return {background: ink, color: paper, border: \"transparent\"};\n // Mixed from the ink rather than named, so a neutral chip is legible on a\n // light brand, a dark brand, and the inverted scenes without being told\n // which it is standing on.\n return {\n background: `color-mix(in srgb, ${ink} 8%, transparent)`,\n color: ink,\n border: `color-mix(in srgb, ${ink} 20%, transparent)`,\n };\n };\n\n const chipBody = (chip: StatementChip) => {\n const surface = chipSurface(chip.tone);\n return (\n <span\n style={{\n alignItems: \"center\",\n background: surface.background,\n border: `${Math.max(1, scale)}px solid ${surface.border}`,\n borderRadius: 14 * scale,\n color: surface.color,\n display: \"inline-flex\",\n gap: 10 * scale,\n padding: `${6 * scale}px ${16 * scale}px`,\n whiteSpace: \"nowrap\",\n }}\n >\n {chip.icon ? <span style={{fontSize: 46 * scale, lineHeight: 1}}>{chip.icon}</span> : null}\n {chip.label}\n </span>\n );\n };\n\n return (\n <Fill\n style={{\n alignItems: align === \"center\" ? \"center\" : \"flex-start\",\n // paper, not ink. ink is the type colour, and painting the ground\n // with it put light text on a light ground: the dark theme rendered\n // the sentence invisible, including in this component's own fixture.\n background: dark ? paper : \"transparent\",\n justifyContent: \"center\",\n padding: `${120 * scale}px ${150 * scale}px`,\n }}\n >\n <div\n style={{\n alignItems: \"center\",\n color: ink,\n columnGap: 16 * scale,\n display: \"flex\",\n flexWrap: \"wrap\",\n fontSize: 64 * scale,\n fontWeight: 500,\n justifyContent: align === \"center\" ? \"center\" : \"flex-start\",\n letterSpacing: \"-0.03em\",\n lineHeight: 1.35,\n rowGap: 8 * scale,\n textAlign: align,\n }}\n >\n {parts.map((part, index) => {\n const swapping = swap !== undefined && index === lastChip;\n const swapped = swapping\n ? interpolate(frame, [swapFrame, swapFrame + (motion === \"cut\" ? 1 : 16)], [0, 1], {\n easing: Easing.standard,\n })\n : 0;\n\n if (!isChip(part)) {\n // In a pre-measured layout every glyph already occupies its final\n // place, so revealing one cannot move the ones after it.\n if (reveal === \"characters\") {\n return (\n <span key={`${part}-${index}`} style={{display: \"inline-block\", whiteSpace: \"pre\"}}>\n {[...part].map((character, position) => (\n <span key={position} style={{display: \"inline-block\", opacity: shown(offsets[index] + position)}}>\n {character}\n </span>\n ))}\n </span>\n );\n }\n return (\n <span key={`${part}-${index}`} style={enter(shown(offsets[index]))}>\n {part}\n </span>\n );\n }\n\n const entrance = shown(offsets[index]);\n return (\n <span\n key={`chip-${index}`}\n style={{\n ...enter(entrance),\n // A chip settles from slightly small, the way a control that\n // just appeared under the cursor does. A cut skips the settle.\n ...(motion === \"cut\"\n ? {}\n : {transform: `translateY(${(1 - entrance) * 26 * scale}px) scale(${0.92 + entrance * 0.08})`}),\n }}\n >\n {swapping ? (\n <span style={{display: \"inline-grid\"}}>\n <span\n style={{\n gridArea: \"1 / 1\",\n opacity: 1 - swapped,\n transform: motion === \"cut\" ? undefined : `translateY(${swapped * -18 * scale}px)`,\n }}\n >\n {chipBody(part)}\n </span>\n <span\n style={{\n gridArea: \"1 / 1\",\n opacity: swapped,\n transform: motion === \"cut\" ? undefined : `translateY(${(1 - swapped) * 18 * scale}px)`,\n }}\n >\n {chipBody(swap)}\n </span>\n </span>\n ) : (\n chipBody(part)\n )}\n </span>\n );\n })}\n </div>\n </Fill>\n );\n};\n",
|
|
4039
4039
|
"target": "videos/components/statement/statement.tsx"
|
|
4040
4040
|
},
|
|
4041
4041
|
{
|
package/src/server.ts
CHANGED
|
@@ -42,6 +42,15 @@ const RESOLVED_ID = `\0${VIRTUAL_ID}`;
|
|
|
42
42
|
* the aliases below are simply not installed — Vite then resolves `odori`
|
|
43
43
|
* through its own exports map, which is what should happen.
|
|
44
44
|
*/
|
|
45
|
+
/** The CLI's own published version, for the status bar. */
|
|
46
|
+
const cliVersion = (): string => {
|
|
47
|
+
try {
|
|
48
|
+
return (createRequire(import.meta.url)("../package.json") as {version: string}).version;
|
|
49
|
+
} catch {
|
|
50
|
+
return "dev";
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
45
54
|
const runtimeSource = (root: string): string | null => {
|
|
46
55
|
for (const from of [resolve(root, "package.json"), import.meta.url]) {
|
|
47
56
|
try {
|
|
@@ -99,6 +108,7 @@ const odoriProjectPlugin = (config: ResolvedConfig, getGraph: () => ProjectGraph
|
|
|
99
108
|
audioDir: config.audioDir,
|
|
100
109
|
docsUrl: config.docsUrl,
|
|
101
110
|
audio: graph.audio,
|
|
111
|
+
version: cliVersion(),
|
|
102
112
|
sourceHash: graph.sourceHash,
|
|
103
113
|
assets: config.assets ?? [],
|
|
104
114
|
files: {
|