@1agh/maude 0.43.0 → 0.45.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.
Files changed (126) hide show
  1. package/apps/studio/acp/bridge.ts +44 -10
  2. package/apps/studio/acp/env.ts +23 -0
  3. package/apps/studio/acp/login-state.ts +263 -0
  4. package/apps/studio/acp/plugin-bootstrap.ts +31 -25
  5. package/apps/studio/acp/probe.ts +152 -1
  6. package/apps/studio/annotations-layer.tsx +396 -46
  7. package/apps/studio/api.ts +395 -2
  8. package/apps/studio/bin/_audio-search.mjs +140 -0
  9. package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
  10. package/apps/studio/bin/_import-asset.mjs +815 -0
  11. package/apps/studio/bin/_import-brand.mjs +635 -0
  12. package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
  13. package/apps/studio/bin/_import-tokens.mjs +1201 -0
  14. package/apps/studio/bin/_transcribe.mjs +419 -0
  15. package/apps/studio/bin/_transcribe.test.mjs +80 -0
  16. package/apps/studio/bin/audio-search.sh +28 -0
  17. package/apps/studio/bin/generate.sh +154 -0
  18. package/apps/studio/bin/import-asset.sh +34 -0
  19. package/apps/studio/bin/import-brand.sh +29 -0
  20. package/apps/studio/bin/import-tokens.sh +33 -0
  21. package/apps/studio/bin/photo-bg-remove.sh +119 -21
  22. package/apps/studio/bin/transcribe.sh +39 -0
  23. package/apps/studio/canvas-edit.ts +197 -2
  24. package/apps/studio/canvas-lib.tsx +115 -6
  25. package/apps/studio/client/app.jsx +1954 -685
  26. package/apps/studio/client/generate-dialog.jsx +352 -0
  27. package/apps/studio/client/github.js +14 -0
  28. package/apps/studio/client/inspector-controls.jsx +781 -0
  29. package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
  30. package/apps/studio/client/panels/ChatPanel.jsx +15 -33
  31. package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
  32. package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
  33. package/apps/studio/client/panels/ReadinessList.jsx +227 -7
  34. package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
  35. package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
  36. package/apps/studio/client/photo-knobs.jsx +167 -172
  37. package/apps/studio/client/styles/3-shell-maude.css +334 -28
  38. package/apps/studio/client/styles/4-components.css +223 -0
  39. package/apps/studio/client/styles/6-acp-chat.css +86 -0
  40. package/apps/studio/client/tour/quick-setup-tour.js +39 -0
  41. package/apps/studio/commands/annotation-strokes-command.ts +13 -3
  42. package/apps/studio/config.schema.json +70 -0
  43. package/apps/studio/context-menu.tsx +11 -2
  44. package/apps/studio/context.ts +16 -0
  45. package/apps/studio/design-setup-readiness.ts +115 -0
  46. package/apps/studio/dist/client.bundle.js +2097 -2088
  47. package/apps/studio/dist/styles.css +1 -1
  48. package/apps/studio/draw/palette.ts +34 -0
  49. package/apps/studio/footage/schema.test.ts +92 -0
  50. package/apps/studio/footage/schema.ts +179 -1
  51. package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
  52. package/apps/studio/generation/adapters/gemini.ts +542 -0
  53. package/apps/studio/generation/adapters/groq.test.ts +80 -0
  54. package/apps/studio/generation/adapters/groq.ts +247 -0
  55. package/apps/studio/generation/audio-library.test.ts +95 -0
  56. package/apps/studio/generation/audio-library.ts +156 -0
  57. package/apps/studio/generation/captions.test.ts +78 -0
  58. package/apps/studio/generation/captions.ts +132 -0
  59. package/apps/studio/generation/download.ts +76 -0
  60. package/apps/studio/generation/elevenlabs.test.ts +223 -0
  61. package/apps/studio/generation/gemini.test.ts +350 -0
  62. package/apps/studio/generation/jobs.test.ts +128 -0
  63. package/apps/studio/generation/jobs.ts +243 -0
  64. package/apps/studio/generation/keys.test.ts +75 -0
  65. package/apps/studio/generation/keys.ts +162 -0
  66. package/apps/studio/generation/prefs.test.ts +95 -0
  67. package/apps/studio/generation/prefs.ts +82 -0
  68. package/apps/studio/generation/registry.ts +88 -0
  69. package/apps/studio/generation/types.test.ts +123 -0
  70. package/apps/studio/generation/types.ts +266 -0
  71. package/apps/studio/generation/whisper-models.test.ts +152 -0
  72. package/apps/studio/generation/whisper-models.ts +257 -0
  73. package/apps/studio/generation/whisper-spike-results.md +61 -0
  74. package/apps/studio/git/service.ts +3 -1
  75. package/apps/studio/http.ts +867 -9
  76. package/apps/studio/media/intro.mp4 +0 -0
  77. package/apps/studio/media-commit-chain.ts +119 -0
  78. package/apps/studio/paths.ts +10 -0
  79. package/apps/studio/readiness.ts +108 -20
  80. package/apps/studio/scaffold-design.ts +1145 -0
  81. package/apps/studio/server.ts +13 -1
  82. package/apps/studio/test/_helpers.ts +15 -2
  83. package/apps/studio/test/acp-bridge.test.ts +34 -1
  84. package/apps/studio/test/acp-env.test.ts +30 -0
  85. package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
  86. package/apps/studio/test/acp-session-plugins.test.ts +72 -1
  87. package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
  88. package/apps/studio/test/annotations-layer.test.ts +174 -0
  89. package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
  90. package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
  91. package/apps/studio/test/csrf-write-guard.test.ts +23 -1
  92. package/apps/studio/test/design-setup-readiness.test.ts +155 -0
  93. package/apps/studio/test/element-structural-edit.test.ts +108 -0
  94. package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
  95. package/apps/studio/test/generate-route.test.ts +106 -0
  96. package/apps/studio/test/import-asset-browser.test.ts +64 -0
  97. package/apps/studio/test/import-asset-route.test.ts +71 -0
  98. package/apps/studio/test/import-asset.test.ts +399 -0
  99. package/apps/studio/test/import-brand-browser.test.ts +88 -0
  100. package/apps/studio/test/import-brand-route.test.ts +120 -0
  101. package/apps/studio/test/import-brand.test.ts +206 -0
  102. package/apps/studio/test/import-tokens.test.ts +722 -0
  103. package/apps/studio/test/media-commit-chain.test.ts +210 -0
  104. package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
  105. package/apps/studio/test/readiness.test.ts +13 -11
  106. package/apps/studio/test/scaffold-design.test.ts +122 -0
  107. package/apps/studio/test/tour-overlay.test.tsx +16 -0
  108. package/apps/studio/tool-palette.tsx +11 -6
  109. package/apps/studio/ui-prefs.ts +130 -0
  110. package/apps/studio/use-canvas-media-drop.tsx +4 -2
  111. package/apps/studio/use-chrome-visibility.tsx +8 -2
  112. package/apps/studio/whats-new.json +96 -0
  113. package/apps/studio/ws.ts +5 -0
  114. package/cli/bin/maude.mjs +6 -6
  115. package/cli/commands/design.mjs +33 -0
  116. package/cli/commands/init.mjs +7 -1
  117. package/cli/lib/gitignore-block.mjs +1 -0
  118. package/cli/lib/pkg-root.mjs +107 -0
  119. package/cli/lib/pkg-root.test.mjs +79 -0
  120. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  121. package/cli/lib/update-check.mjs +8 -0
  122. package/package.json +12 -10
  123. package/plugins/design/dependencies.json +18 -0
  124. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  125. package/plugins/flow/templates/ai-skeleton/scenario-guide.md +39 -0
  126. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +2 -0
@@ -0,0 +1,419 @@
1
+ #!/usr/bin/env bun
2
+ // _transcribe.mjs — internal shim behind `maude design transcribe` (DDR-062).
3
+ // Local, no-key speech-to-text for automatic subtitles: spawn whisper.cpp
4
+ // (whisper-cli) on an audio/video file, parse its word-level JSON, and emit
5
+ // SRT/VTT through the SHARED captions.ts reflow (the same rules the cloud STT
6
+ // providers — ElevenLabs Scribe, Groq Whisper — run through, so local + cloud
7
+ // subtitles never drift on line-length / timing). Runs under Bun so it can
8
+ // import the `.ts` caption module directly (mirrors _svg-optimize.mjs).
9
+ //
10
+ // whisper.cpp is a SOFT dependency (plugins/design/dependencies.json): absent →
11
+ // this exits non-zero with an actionable message (install it, or use the cloud
12
+ // Scribe path). No dev server, no ffmpeg of our own — mirrors the footage-probe
13
+ // shim shape.
14
+ //
15
+ // Model + binary resolution (whisper.cpp ships neither a default model nor
16
+ // auto-download): `--model <path>` or $MAUDE_WHISPER_MODEL (a ggml .bin);
17
+ // `--whisper <path>` or $MAUDE_WHISPER_CLI, else whisper-cli / whisper / main.
18
+ //
19
+ // Stdout (last line): the produced .srt path. Stderr: progress/diagnostics.
20
+ // Exit: 0 ok / 1 dependency-or-input problem / 3 transcription failed.
21
+
22
+ import { spawnSync } from 'node:child_process';
23
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
24
+ import { tmpdir } from 'node:os';
25
+ import { basename, dirname, extname, join, resolve } from 'node:path';
26
+
27
+ import { wordsToSrt, wordsToVtt } from '../generation/captions.ts';
28
+ import { resolveWhisperModel } from '../generation/whisper-models.ts';
29
+
30
+ /** Read `.design/config.json` → generation.transcription.whisperModel, or null
31
+ * (the preferred managed model id, e.g. "base"). */
32
+ function readConfigWhisperModel(repo) {
33
+ try {
34
+ const cfg = JSON.parse(readFileSync(join(repo, '.design', 'config.json'), 'utf8'));
35
+ const m = cfg?.generation?.transcription?.whisperModel;
36
+ return typeof m === 'string' ? m : null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * whisper.cpp can't decode arbitrary containers (mp4/mov/m4a) unless built with
44
+ * ffmpeg — the owner hit this on a real clip (GOTCHA C). If the input isn't
45
+ * already a WAV and system ffmpeg is present, transcode to a temp 16 kHz mono
46
+ * WAV (whisper's native rate) and transcribe THAT. Best-effort: no ffmpeg → we
47
+ * pass the original through and let whisper try (with the existing hint on
48
+ * failure). Returns { path, cleanup } — cleanup removes the temp file (if any).
49
+ */
50
+ function ensureWav(input) {
51
+ if (/\.wav$/i.test(input)) return { path: input, cleanup: () => {} };
52
+ const ffmpeg = spawnSync('ffmpeg', ['-version'], { stdio: 'ignore' });
53
+ if (ffmpeg.error) return { path: input, cleanup: () => {} }; // no ffmpeg → try as-is
54
+ // Security (ethical-hacker Finding 1): write into a FRESH 0700 private dir
55
+ // (mkdtemp), NOT a predictable `/tmp/maude-transcribe-<name>-<pid>.wav`. On a
56
+ // shared/multi-user host (Linux CI, the hub Docker image) a co-tenant could
57
+ // pre-plant a symlink at a guessable path and ffmpeg's `-y` would follow it
58
+ // and overwrite the target. An unguessable per-run dir removes the primitive.
59
+ const dir = mkdtempSync(join(tmpdir(), 'maude-transcribe-'));
60
+ const wav = join(dir, `${basename(input, extname(input))}.wav`);
61
+ process.stderr.write('transcribe: converting to 16 kHz mono WAV via ffmpeg…\n');
62
+ const conv = spawnSync(
63
+ 'ffmpeg',
64
+ ['-y', '-i', input, '-ar', '16000', '-ac', '1', '-c:a', 'pcm_s16le', wav],
65
+ { stdio: ['ignore', 'ignore', 'inherit'] }
66
+ );
67
+ const cleanup = () => rmSync(dir, { recursive: true, force: true });
68
+ if (conv.status !== 0 || !existsSync(wav)) {
69
+ cleanup();
70
+ return { path: input, cleanup: () => {} }; // conversion failed → fall back
71
+ }
72
+ return { path: wav, cleanup };
73
+ }
74
+
75
+ /**
76
+ * whisper.cpp `-oj` JSON → word timings. With `-ml 1` each `transcription[]`
77
+ * entry is ~one word; `offsets.from/to` are milliseconds from the audio start.
78
+ * Pure + exported so the parsing is unit-testable without whisper installed.
79
+ */
80
+ export function whisperJsonToWords(jsonText) {
81
+ let doc;
82
+ try {
83
+ doc = JSON.parse(jsonText);
84
+ } catch {
85
+ return [];
86
+ }
87
+ const segs = Array.isArray(doc?.transcription) ? doc.transcription : [];
88
+ const words = [];
89
+ for (const s of segs) {
90
+ const text = typeof s?.text === 'string' ? s.text.trim() : '';
91
+ if (!text) continue;
92
+ const from = Number(s?.offsets?.from);
93
+ const to = Number(s?.offsets?.to);
94
+ words.push({
95
+ text,
96
+ start: Number.isFinite(from) ? from / 1000 : 0,
97
+ end: Number.isFinite(to) ? to / 1000 : 0,
98
+ });
99
+ }
100
+ return words;
101
+ }
102
+
103
+ function parseArgs(argv) {
104
+ const out = { format: 'srt', words: true };
105
+ for (let i = 0; i < argv.length; i++) {
106
+ const a = argv[i];
107
+ const next = () => argv[++i];
108
+ if (a === '--source') out.source = next();
109
+ else if (a === '--root') out.root = next();
110
+ else if (a === '--out') out.out = next();
111
+ else if (a === '--format')
112
+ out.format = next(); // srt | vtt | both
113
+ else if (a === '--model') out.model = next();
114
+ else if (a === '--whisper') out.whisper = next();
115
+ else if (a === '--provider')
116
+ out.provider = next(); // whisper | elevenlabs | groq
117
+ else if (a === '--lang') out.lang = next();
118
+ else if (a === '--segments')
119
+ out.words = false; // segment-level, not per-word
120
+ else if (a === '--help' || a === '-h') out.help = true;
121
+ }
122
+ return out;
123
+ }
124
+
125
+ const CLOUD_PROVIDERS = new Set(['elevenlabs', 'groq']);
126
+ const KNOWN_PROVIDERS = new Set(['whisper', 'elevenlabs', 'groq']);
127
+
128
+ /**
129
+ * Resolve the transcription engine as an EXPLICIT choice (Task 2.6): the
130
+ * --provider flag wins; else the project's `generation.transcription.provider`
131
+ * config default; else `whisper` (local, free) — and we SAY which we chose.
132
+ * Maude never silently reaches for a paid cloud engine. Returns { provider,
133
+ * defaulted } where `defaulted` means neither the flag nor config set it.
134
+ */
135
+ export function resolveProvider(flag, repo) {
136
+ if (flag) {
137
+ if (!KNOWN_PROVIDERS.has(flag)) {
138
+ process.stderr.write(
139
+ `transcribe: unknown --provider '${flag}' (use whisper | elevenlabs | groq)\n`
140
+ );
141
+ process.exit(2);
142
+ }
143
+ return { provider: flag, defaulted: false };
144
+ }
145
+ const configured = readConfigProvider(repo);
146
+ if (configured && KNOWN_PROVIDERS.has(configured))
147
+ return { provider: configured, defaulted: false };
148
+ return { provider: 'whisper', defaulted: true };
149
+ }
150
+
151
+ /** Read `.design/config.json` → generation.transcription.provider, or null. */
152
+ function readConfigProvider(repo) {
153
+ try {
154
+ const raw = readFileSync(join(repo, '.design', 'config.json'), 'utf8');
155
+ const cfg = JSON.parse(raw);
156
+ const p = cfg?.generation?.transcription?.provider;
157
+ return typeof p === 'string' ? p : null;
158
+ } catch {
159
+ return null;
160
+ }
161
+ }
162
+
163
+ function resolveWhisper(explicit) {
164
+ // Security (Phase-2 ethical-hacker): do NOT include the bare name `main`
165
+ // (whisper.cpp's legacy binary) in the fallback chain — it is one of the most
166
+ // common executable names in the wild, and the probe below EXECUTES each
167
+ // candidate (`--help`). A user running this inside an untrusted repo with `.`
168
+ // on $PATH could otherwise auto-run a seeded `main`. Only the explicit
169
+ // --whisper / $MAUDE_WHISPER_CLI (owner's own choice) and the specific
170
+ // whisper-cli / whisper names are probed.
171
+ const candidates = [explicit, process.env.MAUDE_WHISPER_CLI, 'whisper-cli', 'whisper'].filter(
172
+ Boolean
173
+ );
174
+ for (const c of candidates) {
175
+ const probe = spawnSync(c, ['--help'], { stdio: 'ignore' });
176
+ if (!probe.error) return c;
177
+ }
178
+ return null;
179
+ }
180
+
181
+ /**
182
+ * Cloud STT path (Task 2.6): the user CHOSE elevenlabs / groq. The transcription
183
+ * runs on the dev server's privileged generate route (the key is resolved
184
+ * server-side — never handed to this CLI), which writes the SRT sidecar next to
185
+ * the source and returns its `assets/<sha8>.srt` path. No-silent-switch: if the
186
+ * server is down or the source isn't an assets/ path, we ERROR clearly naming
187
+ * the fix AND the local alternative — we NEVER quietly fall back to whisper.
188
+ */
189
+ async function runCloud(args, repo, provider) {
190
+ const source = args.source;
191
+ if (!source.startsWith('assets/') || source.includes('..')) {
192
+ process.stderr.write(
193
+ `transcribe: cloud engine '${provider}' needs a content-addressed source (assets/<sha8>.<ext>).\n` +
194
+ ' Ingest the file into the project first, or use --provider whisper for an arbitrary local path.\n'
195
+ );
196
+ process.exit(2);
197
+ }
198
+ const serverPath = join(repo, '.design', '_server.json');
199
+ if (!existsSync(serverPath)) {
200
+ process.stderr.write(
201
+ `transcribe: cloud engine '${provider}' needs the dev server running (it resolves your key server-side).\n` +
202
+ ' Start it: maude design server-up — or use --provider whisper (local, no server, no key).\n'
203
+ );
204
+ process.exit(1);
205
+ }
206
+ let port;
207
+ try {
208
+ port = JSON.parse(readFileSync(serverPath, 'utf8'))?.port;
209
+ } catch {
210
+ /* handled below */
211
+ }
212
+ if (!port) {
213
+ process.stderr.write(`transcribe: could not read a port from ${serverPath}\n`);
214
+ process.exit(1);
215
+ }
216
+
217
+ const base = `http://127.0.0.1:${port}/_api/generate-jobs`;
218
+ const body = { modality: 'transcription', provider, sourceAsset: source };
219
+ if (args.model) body.model = args.model;
220
+ if (args.lang) body.params = { language: args.lang };
221
+
222
+ process.stderr.write(`transcribe: submitting to ${provider} (cloud STT)…\n`);
223
+ let jobId;
224
+ try {
225
+ const res = await fetch(base, {
226
+ method: 'POST',
227
+ headers: { 'content-type': 'application/json' },
228
+ body: JSON.stringify(body),
229
+ });
230
+ const json = await res.json().catch(() => ({}));
231
+ if (!res.ok) {
232
+ process.stderr.write(
233
+ `transcribe: ${provider} enqueue rejected: ${json?.error ?? res.status}\n`
234
+ );
235
+ process.exit(3);
236
+ }
237
+ jobId = json.jobId;
238
+ } catch (err) {
239
+ process.stderr.write(`transcribe: could not reach the dev server: ${err?.message ?? err}\n`);
240
+ process.exit(1);
241
+ }
242
+ if (!jobId) {
243
+ process.stderr.write('transcribe: enqueue returned no job id\n');
244
+ process.exit(3);
245
+ }
246
+
247
+ const deadline = Date.now() + 300_000;
248
+ for (;;) {
249
+ await new Promise((r) => setTimeout(r, 2000));
250
+ let job;
251
+ try {
252
+ const jobs = await (await fetch(base)).json();
253
+ job = Array.isArray(jobs?.jobs) ? jobs.jobs.find((j) => j.id === jobId) : null;
254
+ } catch {
255
+ /* transient — keep polling until the deadline */
256
+ }
257
+ if (job?.status === 'done') {
258
+ const out = job.assets?.[0];
259
+ if (!out) {
260
+ process.stderr.write('transcribe: job done but produced no caption sidecar\n');
261
+ process.exit(3);
262
+ }
263
+ // Chosen engine may only produce SRT; the shared reflow output is SRT. If a
264
+ // VTT was asked for, note that the cloud path currently writes SRT only.
265
+ process.stdout.write(`${out.startsWith('/') ? out.slice(1) : out}\n`);
266
+ return;
267
+ }
268
+ if (job?.status === 'failed') {
269
+ process.stderr.write(
270
+ `transcribe: ${provider} transcription failed: ${job.error ?? 'unknown'}\n`
271
+ );
272
+ process.exit(3);
273
+ }
274
+ if (Date.now() >= deadline) {
275
+ process.stderr.write(`transcribe: timed out waiting for ${provider} (job ${jobId})\n`);
276
+ process.exit(3);
277
+ }
278
+ }
279
+ }
280
+
281
+ async function main() {
282
+ const args = parseArgs(process.argv.slice(2));
283
+ if (args.help) {
284
+ process.stderr.write(
285
+ 'usage: maude design transcribe --source <assets/x.mp4|file> [--root <repo>]\n' +
286
+ ' [--provider whisper|elevenlabs|groq] [--format srt|vtt|both] [--model <id>]\n' +
287
+ ' [--whisper <bin>] [--lang <code>] [--segments]\n' +
288
+ '\n' +
289
+ ' The engine is an EXPLICIT choice: --provider wins, else the project\n' +
290
+ ' generation.transcription.provider, else local whisper (never a silent\n' +
291
+ ' switch to a paid cloud engine). whisper = local/free/no-key;\n' +
292
+ ' elevenlabs|groq = cloud (needs the dev server + a key in Settings).\n'
293
+ );
294
+ process.exit(0);
295
+ }
296
+ if (!args.source) {
297
+ process.stderr.write('transcribe: --source is required\n');
298
+ process.exit(1);
299
+ }
300
+
301
+ const repo = args.root || process.env.CLAUDE_PROJECT_DIR || process.cwd();
302
+
303
+ // Resolve the engine as an explicit choice (Task 2.6). A chosen cloud engine
304
+ // routes through the dev server (key server-side); the local default stays
305
+ // whisper. We NEVER auto-switch between them.
306
+ const { provider, defaulted } = resolveProvider(args.provider, repo);
307
+ if (defaulted)
308
+ process.stderr.write(
309
+ 'transcribe: no engine configured — using local whisper ' +
310
+ '(set generation.transcription.provider or pass --provider to choose)\n'
311
+ );
312
+ if (CLOUD_PROVIDERS.has(provider)) {
313
+ await runCloud(args, repo, provider);
314
+ return;
315
+ }
316
+
317
+ // Resolve the input: an `assets/<name>` under the design root, or a plain path.
318
+ // For the assets/ form, reject `..` so the content-addressed branch can't be
319
+ // used to climb out of <designRoot>/assets/ (parity with api.readAssetBytes —
320
+ // the network Scribe path is fully contained; keep this local path close). The
321
+ // plain-path branch intentionally accepts an owner-named file (transcribing an
322
+ // arbitrary local clip is a legitimate CLI use; there is no untrusted caller).
323
+ if (args.source.startsWith('assets/') && args.source.includes('..')) {
324
+ process.stderr.write('transcribe: invalid assets/ path (no "..")\n');
325
+ process.exit(1);
326
+ }
327
+ const input = args.source.startsWith('assets/')
328
+ ? join(repo, '.design', args.source)
329
+ : resolve(repo, args.source);
330
+ if (!existsSync(input)) {
331
+ process.stderr.write(`transcribe: input not found: ${input}\n`);
332
+ process.exit(1);
333
+ }
334
+
335
+ const whisper = resolveWhisper(args.whisper);
336
+ if (!whisper) {
337
+ process.stderr.write(
338
+ 'transcribe: whisper.cpp not found (whisper-cli / whisper / main).\n' +
339
+ ' Install: brew install whisper-cpp (or build https://github.com/ggml-org/whisper.cpp)\n' +
340
+ ' Or set $MAUDE_WHISPER_CLI. For cloud STT instead, use ElevenLabs Scribe / Groq Whisper.\n'
341
+ );
342
+ process.exit(1);
343
+ }
344
+
345
+ // Model resolution (Task 2.7 — one-click): explicit --model / $MAUDE_WHISPER_MODEL
346
+ // wins; otherwise auto-resolve a model downloaded via Settings ("Download &
347
+ // enable"), preferring the configured `generation.transcription.whisperModel`.
348
+ const model =
349
+ args.model ||
350
+ process.env.MAUDE_WHISPER_MODEL ||
351
+ resolveWhisperModel(readConfigWhisperModel(repo));
352
+ if (!model || !existsSync(model)) {
353
+ process.stderr.write(
354
+ 'transcribe: no whisper.cpp model available.\n' +
355
+ ' One-click: open Settings (⌘,) → Subtitles → "Download model" (base is a good multilingual default).\n' +
356
+ ' Or pass --model <ggml-*.bin> / set $MAUDE_WHISPER_MODEL, or use a cloud engine (--provider elevenlabs|groq).\n'
357
+ );
358
+ process.exit(1);
359
+ }
360
+
361
+ // The owner's Czech-footage gotcha: an English-only `.en` model garbles other
362
+ // languages. Warn (don't block — the user may know their audio is English).
363
+ if (/\.en\.bin$/i.test(model) && args.lang && args.lang.toLowerCase() !== 'en') {
364
+ process.stderr.write(
365
+ `transcribe: WARNING — model ${basename(model)} is English-only but --lang ${args.lang} was given; expect garbled output. Use a multilingual model (e.g. ggml-base.bin).\n`
366
+ );
367
+ }
368
+
369
+ // Transcode to a WAV whisper.cpp can always read (GOTCHA C), when needed.
370
+ const wav = ensureWav(input);
371
+
372
+ const outBase = args.out
373
+ ? args.out.replace(/\.(srt|vtt|json)$/i, '')
374
+ : join(dirname(input), basename(input, extname(input)));
375
+
376
+ // whisper.cpp: JSON output for the shared reflow, `-ml 1` for word-level timings.
377
+ const flags = ['-m', model, '-f', wav.path, '-oj', '-of', outBase];
378
+ if (args.words) flags.push('-ml', '1');
379
+ if (args.lang) flags.push('-l', args.lang);
380
+
381
+ process.stderr.write(`transcribe: running ${whisper} (${basename(model)})…\n`);
382
+ const run = spawnSync(whisper, flags, { stdio: ['ignore', 'inherit', 'inherit'] });
383
+ wav.cleanup();
384
+ if (run.status !== 0) {
385
+ process.stderr.write(
386
+ `transcribe: whisper.cpp exited ${run.status ?? '?'} — if the input is a non-WAV container, install ffmpeg so Maude can auto-convert it (or convert to 16 kHz mono WAV first).\n`
387
+ );
388
+ process.exit(3);
389
+ }
390
+
391
+ const jsonPath = `${outBase}.json`;
392
+ if (!existsSync(jsonPath)) {
393
+ process.stderr.write(`transcribe: whisper produced no JSON at ${jsonPath}\n`);
394
+ process.exit(3);
395
+ }
396
+ const words = whisperJsonToWords(readFileSync(jsonPath, 'utf8'));
397
+ if (words.length === 0) {
398
+ process.stderr.write('transcribe: no words recognized (silent or unsupported audio?)\n');
399
+ process.exit(3);
400
+ }
401
+
402
+ const written = [];
403
+ if (args.format === 'srt' || args.format === 'both') {
404
+ const p = `${outBase}.srt`;
405
+ writeFileSync(p, wordsToSrt(words));
406
+ written.push(p);
407
+ }
408
+ if (args.format === 'vtt' || args.format === 'both') {
409
+ const p = `${outBase}.vtt`;
410
+ writeFileSync(p, wordsToVtt(words));
411
+ written.push(p);
412
+ }
413
+ // Last stdout line = the primary (SRT-preferred) caption path, for $(…) capture.
414
+ process.stdout.write(`${written[0]}\n`);
415
+ }
416
+
417
+ // Only run main when invoked directly (import.meta.main) so the parser can be
418
+ // imported by the test without spawning whisper (mirrors the F3 CLI-guard fix).
419
+ if (import.meta.main) main();
@@ -0,0 +1,80 @@
1
+ // _transcribe.test.mjs — the whisper.cpp JSON → word-timings parser (Phase 2).
2
+ // The spawn/model resolution is a soft-dep live gate; this covers the pure,
3
+ // deterministic parsing that turns whisper.cpp `-oj` output into CaptionWords
4
+ // (then captions.ts renders the SRT — covered in captions.test.ts).
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { wordsToSrt } from '../generation/captions.ts';
11
+ import { resolveProvider, whisperJsonToWords } from './_transcribe.mjs';
12
+
13
+ function tmpRepo(config) {
14
+ const root = mkdtempSync(join(tmpdir(), 'maude-transcribe-'));
15
+ mkdirSync(join(root, '.design'), { recursive: true });
16
+ if (config !== undefined)
17
+ writeFileSync(join(root, '.design', 'config.json'), JSON.stringify(config));
18
+ return root;
19
+ }
20
+
21
+ const WHISPER_JSON = JSON.stringify({
22
+ transcription: [
23
+ { offsets: { from: 0, to: 480 }, text: ' Hello' },
24
+ { offsets: { from: 480, to: 900 }, text: ' there.' },
25
+ { offsets: { from: 1200, to: 1800 }, text: ' Maude' },
26
+ { offsets: { from: 0, to: 0 }, text: ' ' }, // blank → dropped
27
+ ],
28
+ });
29
+
30
+ describe('whisperJsonToWords', () => {
31
+ test('maps offsets (ms) to seconds and trims text', () => {
32
+ const words = whisperJsonToWords(WHISPER_JSON);
33
+ expect(words).toHaveLength(3);
34
+ expect(words[0]).toEqual({ text: 'Hello', start: 0, end: 0.48 });
35
+ expect(words[1]).toEqual({ text: 'there.', start: 0.48, end: 0.9 });
36
+ expect(words[2].text).toBe('Maude');
37
+ });
38
+
39
+ test('feeds captions.ts to produce a valid SRT', () => {
40
+ const srt = wordsToSrt(whisperJsonToWords(WHISPER_JSON));
41
+ expect(srt).toContain('00:00:00,000 --> 00:00:00,900');
42
+ expect(srt).toContain('Hello there.');
43
+ });
44
+
45
+ test('malformed / empty JSON yields no words (never throws)', () => {
46
+ expect(whisperJsonToWords('not json')).toEqual([]);
47
+ expect(whisperJsonToWords('{}')).toEqual([]);
48
+ expect(whisperJsonToWords(JSON.stringify({ transcription: [] }))).toEqual([]);
49
+ });
50
+
51
+ test('tolerates missing offsets', () => {
52
+ const words = whisperJsonToWords(JSON.stringify({ transcription: [{ text: 'x' }] }));
53
+ expect(words).toEqual([{ text: 'x', start: 0, end: 0 }]);
54
+ });
55
+ });
56
+
57
+ describe('resolveProvider (Task 2.6 — explicit engine choice, no silent switch)', () => {
58
+ test('an explicit valid flag wins over config', () => {
59
+ const repo = tmpRepo({ generation: { transcription: { provider: 'groq' } } });
60
+ expect(resolveProvider('elevenlabs', repo)).toEqual({
61
+ provider: 'elevenlabs',
62
+ defaulted: false,
63
+ });
64
+ });
65
+
66
+ test('falls back to the project config default', () => {
67
+ const repo = tmpRepo({ generation: { transcription: { provider: 'groq' } } });
68
+ expect(resolveProvider(undefined, repo)).toEqual({ provider: 'groq', defaulted: false });
69
+ });
70
+
71
+ test('defaults to local whisper (marked defaulted) when nothing is set', () => {
72
+ const repo = tmpRepo(); // no config
73
+ expect(resolveProvider(undefined, repo)).toEqual({ provider: 'whisper', defaulted: true });
74
+ });
75
+
76
+ test('a malformed config provider is ignored → whisper default', () => {
77
+ const repo = tmpRepo({ generation: { transcription: { provider: 'bogus' } } });
78
+ expect(resolveProvider(undefined, repo)).toEqual({ provider: 'whisper', defaulted: true });
79
+ });
80
+ });
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bash
2
+ # audio-search.sh — feature-ai-media-generation (Task 2.5, DDR-164). Reuse-before-
3
+ # you-pay for AUDIO: before generating a new music/SFX/VO track, search the
4
+ # project's own generated audio + the user's ElevenLabs history and prefer an
5
+ # existing suitable track over spending credits. The search + reuse run
6
+ # server-side (the key is resolved by the sidecar), so this is a pure fetch to
7
+ # the running dev server — mirrors generate.sh.
8
+ #
9
+ # Reached via `maude design audio-search` (never a raw bin path — DDR-062).
10
+ #
11
+ # Usage:
12
+ # audio-search.sh --query "<text>" [--root <repo>] [--reuse <historyId>] [--json]
13
+ #
14
+ # Requires a running dev server (caller runs `maude design server-up` first).
15
+ #
16
+ # Stdout (last line, --reuse): the localized /assets/<sha8>.mp3 path.
17
+ # Exit: 0 ok / 1 server problem / 2 bad args / 3 search/reuse failed.
18
+
19
+ set -euo pipefail
20
+
21
+ if ! command -v bun >/dev/null 2>&1; then
22
+ echo "audio-search.sh: bun is required (hard dependency — see plugins/design/dependencies.json)." >&2
23
+ echo " Install: curl -fsSL https://bun.sh/install | bash" >&2
24
+ exit 1
25
+ fi
26
+
27
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
28
+ exec bun run "$SCRIPT_DIR/_audio-search.mjs" "$@"
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env bash
2
+ # generate.sh — feature-ai-media-generation (Phase 0, DDR-16x). Headless BYOK
3
+ # AI-media generation: POST a GenRequest to the running dev server's privileged
4
+ # /_api/generate-jobs route, poll the job to completion, and print the produced
5
+ # content-addressed asset path (`/assets/<sha8>.<ext>`).
6
+ #
7
+ # The thin, non-browser sibling of the studio generate dialog — the provider call
8
+ # happens SERVER-SIDE (the sidecar resolves the key from the OS keychain /
9
+ # ~/.config/maude/keys.json and calls the provider directly), so this verb is a
10
+ # pure JSON curl + poll, exactly like photo-adjust.sh. No client-side inference,
11
+ # so a harness canvas + agent-browser would be pure overhead.
12
+ #
13
+ # Reached via `maude design generate` (never a raw bin path — DDR-062).
14
+ #
15
+ # Usage:
16
+ # generate.sh --prompt "<text>" [--provider gemini|elevenlabs] [--model <id>]
17
+ # [--modality image|audio|video] [--aspect 16:9] [--source assets/<sha8>.<ext>]
18
+ # [--kind music|sfx|tts] [--voice <voice_id>] [--duration <seconds>]
19
+ # [--root <repo>] [--timeout <sec>]
20
+ #
21
+ # --source turns an IMAGE call into a maskless EDIT (Nano Banana): the content-
22
+ # addressed source image is read server-side into the request and the prompt
23
+ # describes the change; a NEW content-addressed asset is produced (the original
24
+ # is never mutated). For --modality video it is the image-to-video SEED (a still
25
+ # → a clip, so a generated clip can match a generated hero's style).
26
+ #
27
+ # --modality audio (provider elevenlabs): --kind music (default) | sfx | tts.
28
+ # tts needs --voice <voice_id>; music/sfx take an optional --duration in seconds.
29
+ # Produces assets/<sha8>.mp3. (Transcription is a separate verb: `maude design
30
+ # transcribe`, which writes SRT/VTT.)
31
+ #
32
+ # --modality video (provider gemini / Veo 3.1): ASYNC — the clip takes ~1–10 min;
33
+ # the poll timeout defaults to 600s for video (180s otherwise). Produces
34
+ # assets/<sha8>.mp4 + a provenance assets/<sha8>.footage.json stub so the clip is
35
+ # reel-ready (see /design:reel Step 1.5).
36
+ #
37
+ # The key is NEVER passed here — it lives server-side. Add it in Settings
38
+ # (⌘,) or drop it into ~/.config/maude/keys.json (mode 0600).
39
+ #
40
+ # Requires a running dev server (caller runs `maude design server-up` first);
41
+ # reads the port from <designRoot>/_server.json.
42
+ #
43
+ # Stdout (last line): the produced asset path `/assets/<sha8>.<ext>` (for $(...)).
44
+ # Stderr: progress / diagnostics.
45
+ # Exit: 0 ok / 1 server problem / 2 bad args / 3 generation failed.
46
+
47
+ set -euo pipefail
48
+
49
+ PROMPT="" PROVIDER="gemini" MODEL="" MODALITY="image" ASPECT="" SOURCE="" REPO="" TIMEOUT=""
50
+ # Audio params (Phase 2) — kind = music|sfx|tts; voice = an ElevenLabs voice_id
51
+ # (required for tts); duration = seconds (music/sfx length).
52
+ KIND="" VOICE="" DURATION=""
53
+
54
+ while [ $# -gt 0 ]; do
55
+ case "$1" in
56
+ --prompt) PROMPT="$2"; shift 2 ;;
57
+ --provider) PROVIDER="$2"; shift 2 ;;
58
+ --model) MODEL="$2"; shift 2 ;;
59
+ --modality) MODALITY="$2"; shift 2 ;;
60
+ --aspect) ASPECT="$2"; shift 2 ;;
61
+ --source) SOURCE="$2"; shift 2 ;;
62
+ --kind) KIND="$2"; shift 2 ;;
63
+ --voice) VOICE="$2"; shift 2 ;;
64
+ --duration) DURATION="$2"; shift 2 ;;
65
+ --root) REPO="$2"; shift 2 ;;
66
+ --timeout) TIMEOUT="$2"; shift 2 ;;
67
+ --help|-h) sed -n '2,41p' "$0" | sed 's/^# \?//'; exit 0 ;;
68
+ *) echo "generate.sh: unknown arg '$1'" >&2; exit 2 ;;
69
+ esac
70
+ done
71
+
72
+ # generate.sh produces MEDIA (image / audio) from a prompt. Transcription is a
73
+ # different shape (audio → captions) reached via `maude design transcribe`, which
74
+ # writes SRT/VTT files — not this verb, whose contract is "print the produced
75
+ # asset path".
76
+ [ -n "$PROMPT" ] || { echo "generate.sh: --prompt is required" >&2; exit 2; }
77
+ command -v jq >/dev/null 2>&1 || { echo "generate.sh: jq is required" >&2; exit 1; }
78
+
79
+ # Async video (Veo) takes minutes — default the poll window wider than the
80
+ # seconds-fast image/audio calls unless the caller pinned --timeout explicitly.
81
+ if [ -z "$TIMEOUT" ]; then
82
+ if [ "$MODALITY" = "video" ]; then TIMEOUT="600"; else TIMEOUT="180"; fi
83
+ fi
84
+
85
+ if [ -z "$REPO" ]; then
86
+ REPO="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
87
+ fi
88
+ DESIGN_ROOT="$REPO/.design"
89
+ [ -d "$DESIGN_ROOT" ] || { echo "generate.sh: no .design/ under $REPO" >&2; exit 1; }
90
+
91
+ STATE="$DESIGN_ROOT/_server.json"
92
+ [ -f "$STATE" ] || { echo "generate.sh: no _server.json (start the dev server first)" >&2; exit 1; }
93
+ PORT=$(jq -r '.port // empty' "$STATE" 2>/dev/null)
94
+ [ -n "$PORT" ] || { echo "generate.sh: could not read port from $STATE" >&2; exit 1; }
95
+
96
+ BASE="http://127.0.0.1:$PORT/_api/generate-jobs"
97
+
98
+ # Normalize a --source to the `assets/<sha8>.<ext>` form the route validates
99
+ # (strip a leading slash the canvas-relative writers emit).
100
+ SOURCE="${SOURCE#/}"
101
+
102
+ # Build the audio params object (only the set keys) — audioKind / voice_id /
103
+ # durationSeconds are opaque to the spine; each adapter validates its own subset.
104
+ PARAMS=$(jq -n --arg kind "$KIND" --arg voice "$VOICE" --arg dur "$DURATION" '
105
+ {}
106
+ + (if $kind != "" then { audioKind: $kind } else {} end)
107
+ + (if $voice != "" then { voice_id: $voice } else {} end)
108
+ + (if $dur != "" then { durationSeconds: ($dur|tonumber) } else {} end)')
109
+
110
+ # Build the request body — jq handles all prompt escaping. Omit empty optionals.
111
+ BODY=$(jq -n \
112
+ --arg modality "$MODALITY" --arg provider "$PROVIDER" --arg model "$MODEL" \
113
+ --arg prompt "$PROMPT" --arg aspect "$ASPECT" --arg source "$SOURCE" \
114
+ --argjson params "$PARAMS" '
115
+ { modality: $modality, provider: $provider }
116
+ + (if $prompt != "" then { prompt: $prompt } else {} end)
117
+ + (if $model != "" then { model: $model } else {} end)
118
+ + (if $aspect != "" then { aspectRatio: $aspect } else {} end)
119
+ + (if $source != "" then { sourceAsset: $source } else {} end)
120
+ + (if ($params|length) > 0 then { params: $params } else {} end)')
121
+
122
+ echo "generate.sh: submitting to $PROVIDER ($MODALITY)…" >&2
123
+ RESP=$(curl -s -X POST -H 'Content-Type: application/json' -d "$BODY" "$BASE")
124
+ JOB_ID=$(printf '%s' "$RESP" | jq -r '.jobId // empty' 2>/dev/null || echo "")
125
+ if [ -z "$JOB_ID" ]; then
126
+ echo "generate.sh: enqueue rejected: $RESP" >&2
127
+ exit 3
128
+ fi
129
+ echo "generate.sh: job $JOB_ID queued — polling…" >&2
130
+
131
+ # Poll the job list until this job is done/failed, or the timeout elapses.
132
+ DEADLINE=$(( $(date +%s) + TIMEOUT ))
133
+ while :; do
134
+ JOBS=$(curl -s "$BASE" 2>/dev/null || echo '{}')
135
+ JOB=$(printf '%s' "$JOBS" | jq -c --arg id "$JOB_ID" '.jobs[]? | select(.id == $id)' 2>/dev/null || echo "")
136
+ STATUS=$(printf '%s' "$JOB" | jq -r '.status // empty' 2>/dev/null || echo "")
137
+ case "$STATUS" in
138
+ done)
139
+ ASSET=$(printf '%s' "$JOB" | jq -r '.assets[0] // empty' 2>/dev/null || echo "")
140
+ [ -n "$ASSET" ] || { echo "generate.sh: job done but produced no asset" >&2; exit 3; }
141
+ # Normalize to the leading-slash canvas-relative form writers use.
142
+ printf '/%s\n' "${ASSET#/}"
143
+ exit 0 ;;
144
+ failed)
145
+ ERR=$(printf '%s' "$JOB" | jq -r '.error // "unknown error"' 2>/dev/null || echo "unknown")
146
+ echo "generate.sh: generation failed: $ERR" >&2
147
+ exit 3 ;;
148
+ esac
149
+ if [ "$(date +%s)" -ge "$DEADLINE" ]; then
150
+ echo "generate.sh: timed out after ${TIMEOUT}s (job still $STATUS)" >&2
151
+ exit 3
152
+ fi
153
+ sleep 2
154
+ done