@tandryukha/aidemo 0.8.0

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/src/util.ts ADDED
@@ -0,0 +1,171 @@
1
+ import { promises as fs, createWriteStream, type WriteStream } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { AsyncLocalStorage } from "node:async_hooks";
4
+
5
+ // Optional native log tee: when a command sets a log file, everything emitted
6
+ // through log/step/ok/fail is mirrored (ANSI-stripped) into logs/<cmd>.log as
7
+ // well as stderr. This makes `aidemo record` self-log without a fragile
8
+ // `| tee record.log` (which also masks the exit code — see the CLI docs).
9
+ let logStream: WriteStream | null = null;
10
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
11
+
12
+ /** Mirror all subsequent log output into `path` (truncating it). Best-effort. */
13
+ export function setLogFile(path: string): void {
14
+ try {
15
+ logStream = createWriteStream(path, { flags: "w" });
16
+ logStream.on("error", () => (logStream = null));
17
+ } catch {
18
+ logStream = null;
19
+ }
20
+ }
21
+
22
+ /** Flush and close the log file, if any. Await before process.exit(). */
23
+ export async function closeLogFile(): Promise<void> {
24
+ const s = logStream;
25
+ logStream = null;
26
+ if (!s) return;
27
+ await new Promise<void>((res) => s.end(res));
28
+ }
29
+
30
+ /** A per-job log sink receiving every ANSI-stripped emitted chunk. */
31
+ export type LogSink = (clean: string) => void;
32
+ const logSinks = new AsyncLocalStorage<LogSink[]>();
33
+
34
+ /**
35
+ * Run `fn` with additional log sinks that receive everything emitted through
36
+ * log/step/ok/fail — scoped to fn's async chain, so concurrent runs (MCP jobs
37
+ * vs. sync tool calls) don't interleave each other's log files. The CLI's
38
+ * setLogFile singleton is unaffected. Composes with any sinks already active
39
+ * (additive, not a replace) so a stage nested inside a composite command
40
+ * (e.g. the `render` job's voice/record/captions/compose sub-stages) still
41
+ * feeds the outer command's log/ring buffer while ALSO getting its own sink.
42
+ * Known blind spot: a line emitted from a detached event-loop callback (not
43
+ * awaited inside fn) misses the sinks and goes to stderr only.
44
+ */
45
+ export function withLogSinks<T>(sinks: LogSink[], fn: () => Promise<T>): Promise<T> {
46
+ const outer = logSinks.getStore();
47
+ return logSinks.run(outer ? [...outer, ...sinks] : sinks, fn);
48
+ }
49
+
50
+ /**
51
+ * Tee subsequent log/step/ok/fail output into a freshly truncated file at
52
+ * `path`, composed with whatever log sinks (or the CLI's global log file) are
53
+ * already active. Used so a stage driven from inside a composite command —
54
+ * CLI `render`; the MCP `render` job — still refreshes its OWN stable
55
+ * logs/<stage>.log, not just the composite command's log. Fixes stale
56
+ * `logs/<command>.log` lines fooling log-watchers into probing half-written
57
+ * output (a poller sees an old `✓ final video → …` line from a previous
58
+ * standalone `compose` run while the current `render` is still composing).
59
+ */
60
+ export async function teeStageLog<T>(path: string, fn: () => Promise<T>): Promise<T> {
61
+ const stream = createWriteStream(path, { flags: "w" });
62
+ stream.on("error", () => {});
63
+ try {
64
+ return await withLogSinks([(s) => void stream.write(s)], fn);
65
+ } finally {
66
+ await new Promise<void>((res) => stream.end(res));
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Per-scene progress hooks, shared by every stage that loops over scenes
72
+ * (voice, captions, compose, and record's own RecordOptions) — the MCP job
73
+ * model wires these into job.currentScene/scenesTotal/scenesDone so pollers
74
+ * get structured progress instead of only a log tail.
75
+ */
76
+ export interface SceneProgress {
77
+ onSceneStart?: (sceneId: string, index: number, total: number) => void;
78
+ onSceneComplete?: (sceneId: string, index: number, total: number) => void;
79
+ }
80
+
81
+ /** Thrown when a run is canceled via an AbortSignal (e.g. MCP job_cancel). */
82
+ export class CanceledError extends Error {
83
+ constructor(msg = "canceled") {
84
+ super(msg);
85
+ this.name = "CanceledError";
86
+ }
87
+ }
88
+
89
+ function emit(s: string): void {
90
+ process.stderr.write(s);
91
+ const clean = s.replace(ANSI_RE, "");
92
+ logStream?.write(clean);
93
+ const extra = logSinks.getStore();
94
+ if (extra) {
95
+ for (const sink of extra) {
96
+ try {
97
+ sink(clean);
98
+ } catch {
99
+ // a broken sink must never break a pipeline stage
100
+ }
101
+ }
102
+ }
103
+ }
104
+
105
+ export function log(msg: string): void {
106
+ emit(` ${msg}\n`);
107
+ }
108
+
109
+ export function step(msg: string): void {
110
+ emit(`\n▶ ${msg}\n`);
111
+ }
112
+
113
+ export function ok(msg: string): void {
114
+ emit(` ✓ ${msg}\n`);
115
+ }
116
+
117
+ /** Emit a failure line (also captured in the log file). */
118
+ export function fail(msg: string): void {
119
+ emit(`\n✗ ${msg}\n`);
120
+ }
121
+
122
+ export const sleep = (ms: number): Promise<void> =>
123
+ new Promise((r) => setTimeout(r, ms));
124
+
125
+ /** easeInOutCubic — smooth acceleration/deceleration for cursor motion. */
126
+ export function easeInOutCubic(t: number): number {
127
+ return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
128
+ }
129
+
130
+ /** easeOutCubic — fast start, soft landing (the "snappy" scroll feel). */
131
+ export function easeOutCubic(t: number): number {
132
+ return 1 - Math.pow(1 - t, 3);
133
+ }
134
+
135
+ /** easeInOutSine — gentle drift for long cinematic scrolls. */
136
+ export function easeInOutSine(t: number): number {
137
+ return -(Math.cos(Math.PI * t) - 1) / 2;
138
+ }
139
+
140
+ export async function ensureDir(path: string): Promise<void> {
141
+ await fs.mkdir(path, { recursive: true });
142
+ }
143
+
144
+ export async function writeJson(path: string, data: unknown): Promise<void> {
145
+ await ensureDir(dirname(path));
146
+ await fs.writeFile(path, JSON.stringify(data, null, 2) + "\n");
147
+ }
148
+
149
+ export async function readJson<T = unknown>(path: string): Promise<T> {
150
+ return JSON.parse(await fs.readFile(path, "utf8")) as T;
151
+ }
152
+
153
+ export async function exists(path: string): Promise<boolean> {
154
+ try {
155
+ await fs.access(path);
156
+ return true;
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+
162
+ /** Format milliseconds as an SRT timestamp: HH:MM:SS,mmm */
163
+ export function srtTime(ms: number): string {
164
+ const clamped = Math.max(0, ms);
165
+ const h = Math.floor(clamped / 3_600_000);
166
+ const m = Math.floor((clamped % 3_600_000) / 60_000);
167
+ const s = Math.floor((clamped % 60_000) / 1000);
168
+ const millis = Math.floor(clamped % 1000);
169
+ const pad = (n: number, w = 2) => String(n).padStart(w, "0");
170
+ return `${pad(h)}:${pad(m)}:${pad(s)},${pad(millis, 3)}`;
171
+ }
@@ -0,0 +1,117 @@
1
+ import { resolve } from "node:path";
2
+ import { promises as fs } from "node:fs";
3
+ import { z } from "zod";
4
+ import { Project } from "./project.js";
5
+ import { generateVoice } from "./voice.js";
6
+ import { record, type RecordOptions } from "./recorder.js";
7
+ import { generateCaptions, generateCaptionsOffline } from "./captions.js";
8
+ import { compose } from "./compose.js";
9
+ import { captionsAutoOffline } from "./config.js";
10
+ import { readJson, log, ok, step } from "./util.js";
11
+
12
+ /**
13
+ * Personalized demo variants — one storyboard, N per-prospect/segment renders.
14
+ *
15
+ * `aidemo render <dir> --variants variants.json` where variants.json is:
16
+ * [ { "name": "acme", "params": { "customer": "Acme" } }, ... ]
17
+ *
18
+ * Each variant renders as an ISOLATED project under
19
+ * <dir>/output/variants/<name>/ — it seeds its own copy of the storyboard and
20
+ * runs the full pipeline (voice → record → captions → compose) with its params,
21
+ * so a param that changes narration/urls/typed text produces a genuinely
22
+ * different take. Sequential and simple; voice hashing dedupes unchanged scenes
23
+ * within a variant.
24
+ */
25
+
26
+ const VariantSchema = z.object({
27
+ name: z
28
+ .string()
29
+ .regex(
30
+ /^[A-Za-z0-9._-]+$/,
31
+ "variant name must be a filesystem-safe dir name (A-Z a-z 0-9 . _ -)"
32
+ ),
33
+ params: z.record(z.string(), z.string()).default({}),
34
+ });
35
+ export type Variant = z.infer<typeof VariantSchema>;
36
+
37
+ const VariantsFileSchema = z.array(VariantSchema).min(1);
38
+
39
+ /** Read + validate a variants matrix file. */
40
+ export async function loadVariants(path: string): Promise<Variant[]> {
41
+ const abs = resolve(path);
42
+ const raw = await readJson<unknown>(abs);
43
+ const parsed = VariantsFileSchema.safeParse(raw);
44
+ if (!parsed.success) {
45
+ throw new Error(
46
+ `Invalid variants file ${abs}:\n${parsed.error.issues
47
+ .map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`)
48
+ .join("\n")}\nExpected: [{ "name": "acme", "params": { "k": "v" } }, ...]`
49
+ );
50
+ }
51
+ const seen = new Set<string>();
52
+ for (const v of parsed.data) {
53
+ if (seen.has(v.name)) {
54
+ throw new Error(`duplicate variant name "${v.name}" in ${abs}`);
55
+ }
56
+ seen.add(v.name);
57
+ }
58
+ return parsed.data;
59
+ }
60
+
61
+ export interface RenderVariantsOptions {
62
+ /** Chrome record options shared by every variant. */
63
+ record?: Pick<RecordOptions, "profileDir" | "headed" | "capture">;
64
+ /** Re-synthesize narration even if unchanged. */
65
+ forceVoice?: boolean;
66
+ }
67
+
68
+ export interface VariantResult {
69
+ name: string;
70
+ dir: string;
71
+ output: string;
72
+ params: Record<string, string>;
73
+ }
74
+
75
+ /**
76
+ * Render every variant. NOTE: a variant renders relative to its own isolated
77
+ * dir, so storyboards that reference LOCAL asset paths (e.g. a music `track`)
78
+ * should use absolute paths — relative ones resolve against the variant dir.
79
+ */
80
+ export async function renderVariants(
81
+ parentDir: string,
82
+ variants: Variant[],
83
+ opts: RenderVariantsOptions = {}
84
+ ): Promise<VariantResult[]> {
85
+ const parent = new Project(parentDir);
86
+ const storyboardRaw = await fs.readFile(parent.storyboardPath, "utf8");
87
+
88
+ const results: VariantResult[] = [];
89
+ for (const [i, v] of variants.entries()) {
90
+ step(`Variant ${i + 1}/${variants.length}: "${v.name}"`);
91
+ const variantDir = parent.p("output", "variants", v.name);
92
+ const project = new Project(variantDir);
93
+ await project.ensureDirs();
94
+ // Seed the storyboard so the variant is a fully self-contained project.
95
+ await fs.writeFile(project.storyboardPath, storyboardRaw);
96
+
97
+ // Explicit variant params win (and are typo-guarded) + get persisted.
98
+ const storyboard = await project.loadStoryboard({ params: v.params });
99
+ await generateVoice(project, storyboard, { force: opts.forceVoice });
100
+ await record(project, storyboard, { ...(opts.record ?? {}) });
101
+ if (captionsAutoOffline()) {
102
+ log("local TTS and no STT endpoint/key — deriving captions offline");
103
+ await generateCaptionsOffline(project, storyboard);
104
+ } else {
105
+ await generateCaptions(project, storyboard);
106
+ }
107
+ await compose(project, storyboard);
108
+ ok(`variant "${v.name}" → ${project.outputPath}`);
109
+ results.push({
110
+ name: v.name,
111
+ dir: variantDir,
112
+ output: project.outputPath,
113
+ params: v.params,
114
+ });
115
+ }
116
+ return results;
117
+ }
@@ -0,0 +1,230 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { tmpdir } from "node:os";
4
+ import { randomBytes } from "node:crypto";
5
+ import { createRequire } from "node:module";
6
+ import { pathToFileURL } from "node:url";
7
+ import type { VoicePlan } from "./types.js";
8
+ import type { VoiceProvider } from "./voice.js";
9
+ import {
10
+ KOKORO_MODEL_ID,
11
+ KOKORO_VOICE,
12
+ kokoroDtype,
13
+ ttsModelCacheDir,
14
+ } from "./config.js";
15
+ import { runFfmpeg } from "./ffmpeg.js";
16
+ import { exists, log } from "./util.js";
17
+
18
+ /**
19
+ * In-process TTS: Kokoro-82M ONNX on CPU via kokoro-js — no server, no key.
20
+ * kokoro-js is deliberately NOT in package.json (it drags in ~400 MB of
21
+ * onnxruntime); it's an opt-in install, imported lazily through non-literal
22
+ * specifiers so typecheck and every non-local run never touch it.
23
+ */
24
+ const KOKORO_PKG: string = "kokoro-js";
25
+ const TRANSFORMERS_PKG: string = "@huggingface/transformers";
26
+
27
+ interface KokoroTtsCtor {
28
+ prototype: unknown;
29
+ from_pretrained(
30
+ modelId: string,
31
+ opts: { dtype: string; device: string }
32
+ ): Promise<KokoroTts>;
33
+ }
34
+
35
+ interface KokoroModules {
36
+ KokoroTTS: KokoroTtsCtor;
37
+ /** transformers.js env — cacheDir/allowRemoteModels knobs. */
38
+ hfEnv: { cacheDir: string; allowRemoteModels: boolean };
39
+ }
40
+
41
+ interface KokoroTts {
42
+ voices: Record<string, { name: string }>;
43
+ model?: { dispose(): Promise<void> };
44
+ generate(
45
+ text: string,
46
+ opts: { voice: string; speed: number }
47
+ ): Promise<{ toWav(): ArrayBuffer }>;
48
+ }
49
+
50
+ /**
51
+ * Resolve kokoro-js from the engine's own node_modules, then from the
52
+ * consumer's cwd (an npx-installed engine lives in the npx cache, so a
53
+ * `npm install kokoro-js` in the consumer repo is only reachable via cwd).
54
+ * @huggingface/transformers is resolved relative to wherever kokoro-js was
55
+ * found — it's kokoro-js's own dependency, needed only to steer its cache.
56
+ */
57
+ async function loadKokoro(): Promise<KokoroModules> {
58
+ const attempts: Array<() => Promise<KokoroModules>> = [
59
+ async () => ({
60
+ KokoroTTS: (await import(KOKORO_PKG)).KokoroTTS,
61
+ hfEnv: (await import(TRANSFORMERS_PKG)).env,
62
+ }),
63
+ async () => {
64
+ const req = createRequire(join(process.cwd(), "package.json"));
65
+ const kokoroPath = req.resolve(KOKORO_PKG);
66
+ const sibling = createRequire(kokoroPath);
67
+ return {
68
+ KokoroTTS: (await import(pathToFileURL(kokoroPath).href)).KokoroTTS,
69
+ hfEnv: (await import(pathToFileURL(sibling.resolve(TRANSFORMERS_PKG)).href)).env,
70
+ };
71
+ },
72
+ ];
73
+ for (const attempt of attempts) {
74
+ try {
75
+ return await attempt();
76
+ } catch {
77
+ // try the next resolution root
78
+ }
79
+ }
80
+ throw new Error(
81
+ `AIDEMO_TTS_PROVIDER=local needs the optional kokoro-js package (not bundled — ` +
82
+ `it installs ~400 MB of onnxruntime). Run: npm install kokoro-js ` +
83
+ `(in the engine checkout, or in the repo you run aidemo from), then retry.`
84
+ );
85
+ }
86
+
87
+ /** True if kokoro-js resolves from the engine or the consumer cwd (no heavy import). */
88
+ export function localTtsInstalled(): boolean {
89
+ for (const from of [import.meta.url, pathToFileURL(join(process.cwd(), "package.json")).href]) {
90
+ try {
91
+ createRequire(from).resolve(KOKORO_PKG);
92
+ return true;
93
+ } catch {
94
+ // not resolvable from this root
95
+ }
96
+ }
97
+ return false;
98
+ }
99
+
100
+ /** Doctor's view of the local provider: installed? model cached where, how big? */
101
+ export async function localTtsStatus(): Promise<{
102
+ installed: boolean;
103
+ dtype: string;
104
+ cacheDir: string;
105
+ modelDir: string;
106
+ modelCached: boolean;
107
+ cacheSizeMB: number | null;
108
+ }> {
109
+ const cacheDir = ttsModelCacheDir();
110
+ const modelDir = join(cacheDir, KOKORO_MODEL_ID);
111
+ const modelCached = await exists(join(modelDir, "onnx"));
112
+ return {
113
+ installed: localTtsInstalled(),
114
+ dtype: kokoroDtype(),
115
+ cacheDir,
116
+ modelDir,
117
+ modelCached,
118
+ cacheSizeMB: modelCached ? await dirSizeMB(modelDir) : null,
119
+ };
120
+ }
121
+
122
+ async function dirSizeMB(dir: string): Promise<number> {
123
+ let bytes = 0;
124
+ const walk = async (d: string): Promise<void> => {
125
+ for (const e of await fs.readdir(d, { withFileTypes: true })) {
126
+ const p = join(d, e.name);
127
+ if (e.isDirectory()) await walk(p);
128
+ else bytes += (await fs.stat(p)).size;
129
+ }
130
+ };
131
+ await walk(dir).catch(() => {});
132
+ return Math.round(bytes / 1e6);
133
+ }
134
+
135
+ /**
136
+ * The voice table is a static getter on the prototype (it doesn't need a
137
+ * loaded model) — reading it there lets a wrong voiceId fail BEFORE the
138
+ * ~90 MB first-run model download. Best-effort: null falls back to the
139
+ * post-load instance check.
140
+ */
141
+ function staticVoices(KokoroTTS: KokoroTtsCtor): Record<string, unknown> | null {
142
+ try {
143
+ const get = Object.getOwnPropertyDescriptor(KokoroTTS.prototype, "voices")?.get;
144
+ const voices = get?.call(Object.create(KokoroTTS.prototype as object));
145
+ return voices && typeof voices === "object" ? voices : null;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ export class LocalVoiceProvider implements VoiceProvider {
152
+ private mods: Promise<KokoroModules> | null = null;
153
+ private tts: Promise<KokoroTts> | null = null;
154
+ private warnedInstructions = false;
155
+
156
+ private modules(): Promise<KokoroModules> {
157
+ return (this.mods ??= loadKokoro());
158
+ }
159
+
160
+ private load(): Promise<KokoroTts> {
161
+ return (this.tts ??= (async () => {
162
+ const { KokoroTTS, hfEnv } = await this.modules();
163
+ hfEnv.cacheDir = ttsModelCacheDir();
164
+ // Same switch the Python HF stack honors — proves air-gapped runs work.
165
+ if (process.env.HF_HUB_OFFLINE === "1") hfEnv.allowRemoteModels = false;
166
+ const dtype = kokoroDtype();
167
+ if (!(await localTtsStatus()).modelCached) {
168
+ log(
169
+ `downloading ${KOKORO_MODEL_ID} (${dtype}) from huggingface.co → ` +
170
+ `${hfEnv.cacheDir} — first run only, cached after`
171
+ );
172
+ }
173
+ return KokoroTTS.from_pretrained(KOKORO_MODEL_ID, { dtype, device: "cpu" });
174
+ })());
175
+ }
176
+
177
+ async synthesize({ text, plan }: { text: string; plan: VoicePlan }): Promise<Buffer> {
178
+ if (plan.instructions && !this.warnedInstructions) {
179
+ this.warnedInstructions = true;
180
+ log(
181
+ "⚠ voice.instructions is a gpt-4o-mini-tts steering prompt — the local Kokoro provider ignores it"
182
+ );
183
+ }
184
+ // "marin" is the schema default (an OpenAI voice name); anything else the
185
+ // author chose deliberately and must be a Kokoro voice id. Validate here,
186
+ // not in kokoro-js — its own validation console.tables to stdout, which
187
+ // the MCP server reserves for JSON-RPC.
188
+ const voiceId = plan.voiceId === "marin" ? KOKORO_VOICE() : plan.voiceId;
189
+ const voices =
190
+ staticVoices((await this.modules()).KokoroTTS) ?? (await this.load()).voices;
191
+ if (!(voiceId in voices)) {
192
+ throw new Error(
193
+ `"${voiceId}" is not a Kokoro voice — the local provider has: ` +
194
+ `${Object.keys(voices).join(", ")}. Set the storyboard's voice.voiceId ` +
195
+ `(e.g. af_heart) or AIDEMO_KOKORO_VOICE.`
196
+ );
197
+ }
198
+ const tts = await this.load();
199
+ const audio = await tts.generate(text, {
200
+ voice: voiceId,
201
+ // Storyboards allow 0.5–2, matching Kokoro's usable range — pass through.
202
+ speed: plan.speed ?? 1,
203
+ });
204
+ return wavToMp3(Buffer.from(await audio.toWav()));
205
+ }
206
+
207
+ /** Release the ONNX session — onnxruntime aborts noisily if the process
208
+ * exits (especially process.exit) while a session is still alive. */
209
+ async dispose(): Promise<void> {
210
+ if (!this.tts) return;
211
+ const tts = await this.tts.catch(() => null);
212
+ await tts?.model?.dispose().catch(() => {});
213
+ this.tts = null;
214
+ }
215
+ }
216
+
217
+ /** Kokoro emits WAV; sceneAudioPath is .mp3 and downstream assumes it — transcode. */
218
+ async function wavToMp3(wav: Buffer): Promise<Buffer> {
219
+ const base = join(tmpdir(), `aidemo-tts-${randomBytes(6).toString("hex")}`);
220
+ const wavPath = `${base}.wav`;
221
+ const mp3Path = `${base}.mp3`;
222
+ try {
223
+ await fs.writeFile(wavPath, wav);
224
+ await runFfmpeg(["-i", wavPath, "-codec:a", "libmp3lame", "-q:a", "2", mp3Path]);
225
+ return await fs.readFile(mp3Path);
226
+ } finally {
227
+ await fs.rm(wavPath, { force: true });
228
+ await fs.rm(mp3Path, { force: true });
229
+ }
230
+ }