@1agh/maude 0.43.0 → 0.44.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 (122) 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/lib/gitignore-block.mjs +1 -0
  117. package/cli/lib/pkg-root.mjs +107 -0
  118. package/cli/lib/pkg-root.test.mjs +79 -0
  119. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  120. package/cli/lib/update-check.mjs +8 -0
  121. package/package.json +12 -10
  122. package/plugins/design/dependencies.json +18 -0
@@ -150,6 +150,40 @@ export function oklchToHex(o: Oklch): string {
150
150
  return toHex(oklchToRgb(o));
151
151
  }
152
152
 
153
+ function srgb8ToLinear(c8: number): number {
154
+ const c = c8 / 255;
155
+ return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
156
+ }
157
+
158
+ /**
159
+ * Convert clamped sRGB 0–255 to OKLCH — the exact inverse of {@link oklchToRgb}
160
+ * (same Björn Ottosson constants). Used by DDR-172's token importer to
161
+ * normalize hex/rgb() input colors into a DS's declared `oklch` colorSpace.
162
+ */
163
+ export function rgbToOklch({ r, g, b }: Rgb): Oklch {
164
+ const rLin = srgb8ToLinear(r);
165
+ const gLin = srgb8ToLinear(g);
166
+ const bLin = srgb8ToLinear(b);
167
+
168
+ const lc = 0.4122214708 * rLin + 0.5363325363 * gLin + 0.0514459929 * bLin;
169
+ const mc = 0.2119034982 * rLin + 0.6806995451 * gLin + 0.1073969566 * bLin;
170
+ const sc = 0.0883024619 * rLin + 0.2817188376 * gLin + 0.6299787005 * bLin;
171
+
172
+ const l_ = Math.cbrt(lc);
173
+ const m_ = Math.cbrt(mc);
174
+ const s_ = Math.cbrt(sc);
175
+
176
+ const l = 0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_;
177
+ const a = 1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_;
178
+ const bComp = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_;
179
+
180
+ const c = Math.sqrt(a * a + bComp * bComp);
181
+ let h = (Math.atan2(bComp, a) * 180) / Math.PI;
182
+ if (h < 0) h += 360;
183
+
184
+ return { l, c, h };
185
+ }
186
+
153
187
  // ─────────────────────────────────────────────────────────────────────────────
154
188
  // Ramp generation
155
189
  // ─────────────────────────────────────────────────────────────────────────────
@@ -7,11 +7,13 @@
7
7
  import { describe, expect, test } from 'bun:test';
8
8
 
9
9
  import {
10
+ AI_GENERATED_TAG,
10
11
  EDL_VERSION,
11
12
  type Edl,
12
13
  emptyFootageAnalysis,
13
14
  FOOTAGE_ANALYSIS_VERSION,
14
15
  type FootageAnalysis,
16
+ generatedClipAnalysis,
15
17
  isEmptyAnalysis,
16
18
  validateEdl,
17
19
  validateFootageAnalysis,
@@ -192,4 +194,94 @@ describe('validateEdl', () => {
192
194
  test('rejects a music bed with a non-relative asset', () => {
193
195
  expect(validateEdl({ ...GOOD_EDL, music: { asset: 'http://x/y.mp3' } }).ok).toBe(false);
194
196
  });
197
+
198
+ // feature-ai-media-generation Phase 2 — layered audio tracks + captions.
199
+ test('accepts layered audioTracks (music/VO/SFX)', () => {
200
+ const r = validateEdl({
201
+ ...GOOD_EDL,
202
+ audioTracks: [
203
+ { asset: 'assets/aaaaaaaa.mp3', kind: 'music', gainDb: -12, fadeOutFrames: 30 },
204
+ { asset: 'assets/bbbbbbbb.mp3', kind: 'voiceover', startFrame: 15, durationFrames: 90 },
205
+ { asset: 'assets/cccccccc.mp3', kind: 'sfx', startFrame: 40, name: 'whoosh' },
206
+ ],
207
+ });
208
+ expect(r).toEqual({ ok: true, errors: [] });
209
+ });
210
+
211
+ test('rejects an audio track with a bad kind / non-relative asset / out-of-range gain', () => {
212
+ expect(
213
+ validateEdl({ ...GOOD_EDL, audioTracks: [{ asset: 'assets/a.mp3', kind: 'bass' }] }).ok
214
+ ).toBe(false);
215
+ expect(validateEdl({ ...GOOD_EDL, audioTracks: [{ asset: 'http://x/a.mp3' }] }).ok).toBe(false);
216
+ expect(
217
+ validateEdl({ ...GOOD_EDL, audioTracks: [{ asset: 'assets/a.mp3', gainDb: 999 }] }).ok
218
+ ).toBe(false);
219
+ expect(validateEdl({ ...GOOD_EDL, audioTracks: [{ kind: 'music' }] }).ok).toBe(false); // asset required
220
+ });
221
+
222
+ test('accepts a captions track', () => {
223
+ const r = validateEdl({
224
+ ...GOOD_EDL,
225
+ captions: {
226
+ style: 'lower-third',
227
+ cues: [
228
+ { startSec: 0, endSec: 0.8, text: 'Hello there.' },
229
+ { startSec: 1.0, endSec: 1.8, text: 'This is Maude.' },
230
+ ],
231
+ },
232
+ });
233
+ expect(r).toEqual({ ok: true, errors: [] });
234
+ });
235
+
236
+ test('rejects captions with a bad style, missing text, or non-array cues', () => {
237
+ expect(validateEdl({ ...GOOD_EDL, captions: { style: 'sideways', cues: [] } }).ok).toBe(false);
238
+ expect(validateEdl({ ...GOOD_EDL, captions: { cues: [{ startSec: 0, endSec: 1 }] } }).ok).toBe(
239
+ false
240
+ ); // text required
241
+ expect(validateEdl({ ...GOOD_EDL, captions: { cues: 'nope' } }).ok).toBe(false);
242
+ });
243
+
244
+ test('rejects an unknown top-level key (audioTracks typo)', () => {
245
+ expect(validateEdl({ ...GOOD_EDL, audioTrack: [] }).ok).toBe(false);
246
+ });
247
+ });
248
+
249
+ describe('generatedClipAnalysis (Task 3.2 — AI-clip provenance stub)', () => {
250
+ test('produces a VALID, empty-shots, ai-generated-tagged analysis', () => {
251
+ const a = generatedClipAnalysis('assets/deadbeef.mp4', {
252
+ provider: 'gemini',
253
+ model: 'veo-3.1-generate-preview',
254
+ prompt: 'a drone push over a mountain lake at dawn',
255
+ });
256
+ expect(validateFootageAnalysis(a)).toEqual({ ok: true, errors: [] });
257
+ expect(a.asset).toBe('assets/deadbeef.mp4');
258
+ // Empty shots ⇒ the reel's shot-aware cache still runs the analyst on it.
259
+ expect(isEmptyAnalysis(a)).toBe(true);
260
+ expect(a.tags).toContain(AI_GENERATED_TAG);
261
+ expect(a.tags).toContain('gemini');
262
+ expect(a.summary).toContain('mountain lake');
263
+ });
264
+
265
+ test('tolerates a missing prompt/provider and stays valid', () => {
266
+ const a = generatedClipAnalysis('assets/abcd1234.webm');
267
+ expect(validateFootageAnalysis(a)).toEqual({ ok: true, errors: [] });
268
+ expect(a.tags).toEqual([AI_GENERATED_TAG]);
269
+ });
270
+
271
+ test('maps C0/C1 control chars and caps an oversized prompt under the summary limit', () => {
272
+ const a = generatedClipAnalysis('assets/beadfeed.mp4', {
273
+ provider: 'gemini',
274
+ // C0 newline + C1 NEL (\u0085) must both collapse to a space — no fabricated lines.
275
+ prompt: `injected line\n\u0085next${'x'.repeat(8000)}`,
276
+ });
277
+ expect(validateFootageAnalysis(a).ok).toBe(true);
278
+ expect(a.summary?.length ?? 0).toBeLessThanOrEqual(3901);
279
+ // No C0 (0x00–0x1f) or C1 (0x7f–0x9f) control byte survives into the sidecar.
280
+ expect(
281
+ [...(a.summary ?? '')].some((c) => {
282
+ const n = c.charCodeAt(0);
283
+ return n <= 0x1f || (n >= 0x7f && n <= 0x9f);
284
+ })
285
+ ).toBe(false);
286
+ });
195
287
  });
@@ -197,6 +197,52 @@ export interface EdlMusic {
197
197
  fadeOutFrames?: number;
198
198
  }
199
199
 
200
+ /** feature-ai-media-generation Phase 2 — the role an audio track plays in the cut. */
201
+ export type EdlAudioKind = 'music' | 'voiceover' | 'sfx';
202
+
203
+ /**
204
+ * One audio track in the cut (DDR-164 Phase 2). Generalizes the single `music`
205
+ * bed into a LIST so a reel can layer generated music + voiceover + per-beat
206
+ * SFX. `startFrame`/`durationFrames` place + trim it on the OUTPUT timeline (at
207
+ * the Edl's `fps`); omit both for a whole-reel bed. The codegen emits an
208
+ * `<Audio src="assets/…">` inside a `<Sequence from={startFrame}>`.
209
+ */
210
+ export interface EdlAudioTrack {
211
+ /** `assets/<sha8>.<ext>` audio asset. */
212
+ asset: string;
213
+ /** Role — drives default gain/ducking guidance in the codegen. Default 'music'. */
214
+ kind?: EdlAudioKind;
215
+ /** Where the track enters, OUTPUT frames from the start. Default 0. */
216
+ startFrame?: number;
217
+ /** Trim length in OUTPUT frames. Omit ⇒ play to the end of the source. */
218
+ durationFrames?: number;
219
+ /** Volume adjustment in dB (negative = quieter; e.g. a −12 dB music bed under VO). */
220
+ gainDb?: number;
221
+ fadeInFrames?: number;
222
+ fadeOutFrames?: number;
223
+ /** Stable Timeline identity ("vo", "bed", "whoosh-3"). */
224
+ name?: string;
225
+ }
226
+
227
+ /** One rendered caption cue on the OUTPUT timeline — SECONDS (matches captions.ts). */
228
+ export interface EdlCaptionCue {
229
+ startSec: number;
230
+ endSec: number;
231
+ text: string;
232
+ }
233
+
234
+ /**
235
+ * The caption/subtitle track (DDR-164 Phase 2), fed by `generation/captions.ts`
236
+ * (whisper.cpp local or ElevenLabs Scribe / Groq cloud). Stored as parsed cues
237
+ * (seconds) rather than a raw SRT file so the codegen can render them as overlays
238
+ * without re-parsing; the codegen converts seconds → frames at the Edl's `fps`.
239
+ */
240
+ export interface EdlCaptions {
241
+ cues: EdlCaptionCue[];
242
+ /** Where the caption sits on screen. Default 'lower-third'. */
243
+ style?: 'lower-third' | 'centered' | 'top';
244
+ }
245
+
200
246
  /**
201
247
  * The edit decision list — the director's output, one per cut. `beats` is the
202
248
  * ordered timeline; the composition's total length = sum(durationFrames) −
@@ -211,7 +257,12 @@ export interface Edl {
211
257
  width?: number;
212
258
  height?: number;
213
259
  beats?: EdlBeat[];
260
+ /** A single whole-reel music bed (kept for backwards compatibility). */
214
261
  music?: EdlMusic | null;
262
+ /** feature-ai-media-generation Phase 2 — layered audio (music/VO/SFX). */
263
+ audioTracks?: EdlAudioTrack[];
264
+ /** feature-ai-media-generation Phase 2 — the caption/subtitle track. */
265
+ captions?: EdlCaptions | null;
215
266
  }
216
267
 
217
268
  // ── shared helpers ───────────────────────────────────────────────────────────
@@ -231,6 +282,60 @@ export function emptyFootageAnalysis(asset?: string): FootageAnalysis {
231
282
  return { version: FOOTAGE_ANALYSIS_VERSION, ...(asset ? { asset } : {}), shots: [] };
232
283
  }
233
284
 
285
+ /** The provenance tag every AI-generated clip's sidecar carries. The
286
+ * footage-analyst preserves it when it enriches the stub with real shots. It is
287
+ * an ADVISORY hint, not a trust boundary — the sidecar is versioned + peer-
288
+ * editable (DDR-054), so a peer can add/strip it; nothing security-relevant keys
289
+ * off it (it only labels a beat in the UI). */
290
+ export const AI_GENERATED_TAG = 'ai-generated';
291
+
292
+ /**
293
+ * A provenance `FootageAnalysis` stub for an AI-GENERATED clip (DDR-164 Phase 3,
294
+ * Task 3.2). Written by the `/_api/generate-jobs` route the moment a video job
295
+ * lands, so the generated clip is immediately KNOWN to the footage pipeline —
296
+ * `getAnalysis` returns non-null and the reel director can see the clip is
297
+ * synthetic (the `ai-generated` tag) and read what it was generated FOR (the
298
+ * prompt, as the summary) without re-watching.
299
+ *
300
+ * It carries NO shots on purpose: the `footage-analyst` still probes the
301
+ * keyframes and fills the real shot list (so `isEmptyAnalysis` is true and the
302
+ * reel's shot-aware cache re-analyzes it), but the provenance tag + summary
303
+ * survive. Duration/dimensions are unknown until that probe, so they are omitted.
304
+ */
305
+ export function generatedClipAnalysis(
306
+ asset: string,
307
+ provenance: { provider?: string; model?: string; prompt?: string } = {}
308
+ ): FootageAnalysis {
309
+ const tags = [AI_GENERATED_TAG];
310
+ if (provenance.provider) tags.push(provenance.provider);
311
+ const label = [provenance.provider, provenance.model].filter(Boolean).join(' · ');
312
+ // This is the LOCAL generation request's prompt (not a peer file), but its text
313
+ // becomes the sidecar `summary`, which IS versioned + peer-syncable (DDR-054)
314
+ // and read by the footage agents downstream. Map C0/C1 control chars to spaces
315
+ // (matching generation's sanitizeReuseText) so it can't fabricate multi-line
316
+ // instructions, then collapse whitespace; bounded to the summary cap below. The
317
+ // agents' own "treat sidecar text as data" posture is the mitigation for
318
+ // arbitrary PEER-authored summaries — this only cleans what we write.
319
+ const prompt = [...(provenance.prompt ?? '')]
320
+ .map((ch) => {
321
+ const c = ch.charCodeAt(0);
322
+ return (c >= 0x00 && c <= 0x1f) || (c >= 0x7f && c <= 0x9f) ? ' ' : ch;
323
+ })
324
+ .join('')
325
+ .replace(/\s+/g, ' ')
326
+ .trim();
327
+ let summary = `AI-generated clip${label ? ` (${label})` : ''}${prompt ? ` — ${prompt}` : ''}`;
328
+ if (summary.length > 3900) summary = `${summary.slice(0, 3900)}…`;
329
+ return {
330
+ version: FOOTAGE_ANALYSIS_VERSION,
331
+ asset,
332
+ keyframes: 0,
333
+ shots: [],
334
+ summary,
335
+ tags,
336
+ };
337
+ }
338
+
234
339
  // ── structural validation (dependency-free — no Ajv) ─────────────────────────
235
340
  // Mirrors photo/schema.ts: the `/_api/footage` route validates untrusted JSON
236
341
  // with these before persisting. Unknown keys / wrong types / out-of-range
@@ -432,7 +537,7 @@ export function validateEdl(input: unknown): ValidationResult {
432
537
  assertKeys(
433
538
  errors,
434
539
  input,
435
- ['version', 'title', 'fps', 'width', 'height', 'beats', 'music'],
540
+ ['version', 'title', 'fps', 'width', 'height', 'beats', 'music', 'audioTracks', 'captions'],
436
541
  'root'
437
542
  );
438
543
  if ('version' in input && input.version != null && typeof input.version !== 'number')
@@ -480,5 +585,78 @@ export function validateEdl(input: unknown): ValidationResult {
480
585
  }
481
586
  }
482
587
 
588
+ // feature-ai-media-generation Phase 2 — layered audio tracks (music/VO/SFX).
589
+ if ('audioTracks' in input && input.audioTracks != null) {
590
+ if (!Array.isArray(input.audioTracks)) errors.push('audioTracks: must be an array');
591
+ else if (input.audioTracks.length > 64) errors.push('audioTracks: more than 64 items');
592
+ else
593
+ input.audioTracks.forEach((raw, i) => {
594
+ const where = `audioTracks[${i}]`;
595
+ if (!isPlainObject(raw)) {
596
+ errors.push(`${where}: must be an object`);
597
+ return;
598
+ }
599
+ assertKeys(
600
+ errors,
601
+ raw,
602
+ [
603
+ 'asset',
604
+ 'kind',
605
+ 'startFrame',
606
+ 'durationFrames',
607
+ 'gainDb',
608
+ 'fadeInFrames',
609
+ 'fadeOutFrames',
610
+ 'name',
611
+ ],
612
+ where
613
+ );
614
+ assetRel(errors, raw, 'asset', where);
615
+ if (!('asset' in raw) || raw.asset == null) errors.push(`${where}.asset: required`);
616
+ if (
617
+ 'kind' in raw &&
618
+ raw.kind != null &&
619
+ !['music', 'voiceover', 'sfx'].includes(raw.kind as string)
620
+ )
621
+ errors.push(`${where}.kind: must be music|voiceover|sfx`);
622
+ intGe(errors, raw, 'startFrame', 0, where);
623
+ intGe(errors, raw, 'durationFrames', 1, where);
624
+ num(errors, raw, 'gainDb', -60, 24, where);
625
+ intGe(errors, raw, 'fadeInFrames', 0, where);
626
+ intGe(errors, raw, 'fadeOutFrames', 0, where);
627
+ str(errors, raw, 'name', 120, where);
628
+ });
629
+ }
630
+
631
+ // feature-ai-media-generation Phase 2 — the caption/subtitle track.
632
+ if ('captions' in input && input.captions != null) {
633
+ const c = input.captions;
634
+ if (!isPlainObject(c)) errors.push('captions: must be an object');
635
+ else {
636
+ assertKeys(errors, c, ['cues', 'style'], 'captions');
637
+ if (
638
+ 'style' in c &&
639
+ c.style != null &&
640
+ !['lower-third', 'centered', 'top'].includes(c.style as string)
641
+ )
642
+ errors.push('captions.style: must be lower-third|centered|top');
643
+ if (!('cues' in c) || !Array.isArray(c.cues)) errors.push('captions.cues: must be an array');
644
+ else if (c.cues.length > 5000) errors.push('captions.cues: more than 5000 items');
645
+ else
646
+ c.cues.forEach((raw, i) => {
647
+ const where = `captions.cues[${i}]`;
648
+ if (!isPlainObject(raw)) {
649
+ errors.push(`${where}: must be an object`);
650
+ return;
651
+ }
652
+ assertKeys(errors, raw, ['startSec', 'endSec', 'text'], where);
653
+ num(errors, raw, 'startSec', 0, 24 * 3600, where);
654
+ num(errors, raw, 'endSec', 0, 24 * 3600, where);
655
+ str(errors, raw, 'text', 2000, where);
656
+ if (!('text' in raw) || raw.text == null) errors.push(`${where}.text: required`);
657
+ });
658
+ }
659
+ }
660
+
483
661
  return { ok: errors.length === 0, errors };
484
662
  }