@agentmedia/schema 0.3.0 → 0.4.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 (54) hide show
  1. package/.memory/cursor.json +3 -0
  2. package/.memory/memories.json +1 -0
  3. package/.memory/project.json +5 -0
  4. package/CLAUDE.md +7 -0
  5. package/dist/__tests__/character-pipeline.test.d.ts +2 -0
  6. package/dist/__tests__/character-pipeline.test.d.ts.map +1 -0
  7. package/dist/__tests__/character-pipeline.test.js +296 -0
  8. package/dist/__tests__/character-pipeline.test.js.map +1 -0
  9. package/dist/__tests__/text-to-video.test.d.ts +2 -0
  10. package/dist/__tests__/text-to-video.test.d.ts.map +1 -0
  11. package/dist/__tests__/text-to-video.test.js +67 -0
  12. package/dist/__tests__/text-to-video.test.js.map +1 -0
  13. package/dist/generators.d.ts +67 -0
  14. package/dist/generators.d.ts.map +1 -1
  15. package/dist/generators.js +11 -1
  16. package/dist/generators.js.map +1 -1
  17. package/dist/v2/character.d.ts +32 -0
  18. package/dist/v2/character.d.ts.map +1 -0
  19. package/dist/v2/character.js +29 -0
  20. package/dist/v2/character.js.map +1 -0
  21. package/dist/v2/generators.d.ts +69 -0
  22. package/dist/v2/generators.d.ts.map +1 -0
  23. package/dist/v2/generators.js +105 -0
  24. package/dist/v2/generators.js.map +1 -0
  25. package/dist/v2/index.d.ts +13 -0
  26. package/dist/v2/index.d.ts.map +1 -0
  27. package/dist/v2/index.js +14 -0
  28. package/dist/v2/index.js.map +1 -0
  29. package/dist/v2/selfie.d.ts +78 -0
  30. package/dist/v2/selfie.d.ts.map +1 -0
  31. package/dist/v2/selfie.js +87 -0
  32. package/dist/v2/selfie.js.map +1 -0
  33. package/dist/v2/subtitle.d.ts +31 -0
  34. package/dist/v2/subtitle.d.ts.map +1 -0
  35. package/dist/v2/subtitle.js +53 -0
  36. package/dist/v2/subtitle.js.map +1 -0
  37. package/dist/video.d.ts +171 -0
  38. package/dist/video.d.ts.map +1 -1
  39. package/dist/video.js +89 -0
  40. package/dist/video.js.map +1 -1
  41. package/package.json +6 -1
  42. package/scripts/generate-v2-docs.ts +328 -0
  43. package/src/__tests__/character-pipeline.test.ts +356 -0
  44. package/src/__tests__/text-to-video.test.ts +79 -0
  45. package/src/generators.ts +12 -0
  46. package/src/v2/character.ts +39 -0
  47. package/src/v2/generators.ts +186 -0
  48. package/src/v2/index.ts +15 -0
  49. package/src/v2/selfie.ts +103 -0
  50. package/src/v2/subtitle.ts +62 -0
  51. package/src/video.ts +164 -0
  52. package/.turbo/turbo-build.log +0 -4
  53. package/.turbo/turbo-test.log +0 -14
  54. package/.turbo/turbo-typecheck.log +0 -4
@@ -0,0 +1,328 @@
1
+ #!/usr/bin/env tsx
2
+ // Copyright 2026 agent-media contributors. Apache-2.0 license.
3
+
4
+ /**
5
+ * Generate v2 docs + SKILL.md from V2_GENERATORS.
6
+ *
7
+ * Reads packages/schema/src/v2/generators.ts and emits:
8
+ * - docs/v2/api-reference.md — Markdown reference for the v2 REST surface
9
+ * - skills/agent-media-v2/SKILL.md — Claude skill file pointing only at v2
10
+ *
11
+ * Both files are derived. Hand edits get blown away on the next run — the
12
+ * single source of truth is the registry.
13
+ */
14
+
15
+ import { writeFileSync, mkdirSync } from 'node:fs';
16
+ import { resolve, dirname } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { zodToJsonSchema } from 'zod-to-json-schema';
19
+ import {
20
+ V2_GENERATORS,
21
+ V2_SHOT_PRESETS,
22
+ V2_VIBES,
23
+ quoteV2Credits,
24
+ type V2GeneratorRecord,
25
+ } from '../src/v2/index.js';
26
+
27
+ const __dirname = dirname(fileURLToPath(import.meta.url));
28
+ const repoRoot = resolve(__dirname, '../../..');
29
+
30
+ const DOCS_OUT = resolve(repoRoot, 'docs/v2/api-reference.md');
31
+ const SKILL_OUT = resolve(repoRoot, 'skills/agent-media-v2/SKILL.md');
32
+
33
+ const GENERATED_NOTE = `<!--
34
+ AUTO-GENERATED — do not hand-edit.
35
+ Source: packages/schema/src/v2/generators.ts
36
+ Regenerate: pnpm --filter @agentmedia/schema gen:v2-docs
37
+ -->`;
38
+
39
+ // ── Markdown helpers ───────────────────────────────────────────────────────
40
+
41
+ function fmtInputSchema(def: V2GeneratorRecord): string {
42
+ const schema = zodToJsonSchema(def.inputSchema, {
43
+ name: `${def.id}_input`,
44
+ $refStrategy: 'none',
45
+ }) as {
46
+ definitions?: Record<string, { properties?: Record<string, unknown>; required?: string[] }>;
47
+ };
48
+ const body = schema.definitions?.[`${def.id}_input`] ?? (schema as any);
49
+ return '```json\n' + JSON.stringify(body, null, 2) + '\n```';
50
+ }
51
+
52
+ function fmtPricing(def: V2GeneratorRecord): string {
53
+ if (!def.pricing) return '_Pricing not declared on this generator._';
54
+ if (def.pricing.basis === 'one_shot') {
55
+ const c = def.pricing.baseCredits;
56
+ return `One-shot: **${c} credits** ($${(c / 100).toFixed(2)})`;
57
+ }
58
+ // per_clip — show 5/8/12/15
59
+ const rows = [5, 8, 12, 15].map((s) => {
60
+ const c = quoteV2Credits(def.id as any, { durationSeconds: s });
61
+ return `| ${s}s | ${c} | $${(c / 100).toFixed(2)} |`;
62
+ });
63
+ return `Per-clip (base ${def.pricing.baseCredits} + ${def.pricing.perSecondCredits}/sec):\n\n| Duration | Credits | USD |\n|---|---:|---:|\n${rows.join('\n')}`;
64
+ }
65
+
66
+ // ── docs/v2/api-reference.md ──────────────────────────────────────────────
67
+
68
+ function renderApiReference(): string {
69
+ const generators = Object.values(V2_GENERATORS);
70
+
71
+ const toc = generators
72
+ .map((g) => `- [\`${g.rest?.method ?? ''} ${g.rest?.path ?? '(internal)'}\` — ${g.summary}](#${g.id})`)
73
+ .join('\n');
74
+
75
+ const sections = generators
76
+ .map((g) => {
77
+ const status = g.status === 'beta' ? ' · _beta_' : '';
78
+ return [
79
+ `## ${g.id}${status}`,
80
+ '',
81
+ `\`${g.rest?.method ?? ''} ${g.rest?.path ?? '(internal)'}\``,
82
+ '',
83
+ g.description,
84
+ '',
85
+ '### Pricing',
86
+ '',
87
+ fmtPricing(g),
88
+ '',
89
+ '### Request body',
90
+ '',
91
+ fmtInputSchema(g),
92
+ '',
93
+ '### Response',
94
+ '',
95
+ g.output === 'video_url'
96
+ ? 'Returns a job submission. Poll `GET /v1/videos/{job_id}` until `status: "completed"`; the final row carries `video_url`.'
97
+ : 'Returns a job submission. Poll `GET /v1/videos/{job_id}` until `status: "completed"`; the final row carries the new `character_id` (`char_xxxxxxxxxx`).',
98
+ '',
99
+ '#### Submission (201)',
100
+ '',
101
+ '```json',
102
+ JSON.stringify(
103
+ {
104
+ job_id: '<uuid>',
105
+ status: 'submitted',
106
+ credits_deducted: g.pricing
107
+ ? g.pricing.basis === 'one_shot'
108
+ ? g.pricing.baseCredits
109
+ : g.pricing.baseCredits + g.pricing.perSecondCredits * 8
110
+ : 0,
111
+ generator: g.id,
112
+ },
113
+ null,
114
+ 2,
115
+ ),
116
+ '```',
117
+ '',
118
+ ...(g.cli?.examples?.length
119
+ ? [
120
+ '### CLI examples',
121
+ '',
122
+ '```bash',
123
+ ...g.cli.examples,
124
+ '```',
125
+ '',
126
+ ]
127
+ : []),
128
+ ].join('\n');
129
+ })
130
+ .join('\n---\n\n');
131
+
132
+ return [
133
+ GENERATED_NOTE,
134
+ '',
135
+ '# agent-media v2 — API reference',
136
+ '',
137
+ '_Public REST surface for v2 generators (Selfie, Character). Auth is a Bearer API key (`ma_xxx`)._',
138
+ '',
139
+ '**Base URL:** `https://api.agent-media.ai`',
140
+ '',
141
+ '## Endpoints',
142
+ '',
143
+ toc,
144
+ '',
145
+ '---',
146
+ '',
147
+ sections,
148
+ '## Shared',
149
+ '',
150
+ '### Authentication',
151
+ '',
152
+ 'Every v2 request sends `Authorization: Bearer ma_xxx`. Get a key via `agent-media login` (CLI) or the dashboard.',
153
+ '',
154
+ '### Polling',
155
+ '',
156
+ '`GET /v1/videos/{job_id}` returns the same shape for v1 and v2 jobs. v2-specific fields:',
157
+ '',
158
+ '- `character_id` — present on jobs that create a v2 character (`char_xxxxxxxxxx`).',
159
+ '- `video_url` — present on completed video jobs.',
160
+ '',
161
+ '### Shot grammar (Selfie)',
162
+ '',
163
+ `Selfie's \`preset\` field accepts one of:`,
164
+ '',
165
+ V2_SHOT_PRESETS.map((p) => `- \`${p}\``).join('\n'),
166
+ '',
167
+ `Or \`custom-scene:<text>\` to compose a new shot ad-hoc.`,
168
+ '',
169
+ '### Vibes (Selfie)',
170
+ '',
171
+ V2_VIBES.map((v) => `- \`${v}\``).join('\n'),
172
+ '',
173
+ ].join('\n');
174
+ }
175
+
176
+ // ── skills/agent-media-v2/SKILL.md ─────────────────────────────────────────
177
+
178
+ function renderSkill(): string {
179
+ const generators = Object.values(V2_GENERATORS);
180
+ const generatorBlocks = generators
181
+ .map((g) => {
182
+ const cliExample = g.cli?.examples?.[0] ?? `agent-media ${g.cli?.command ?? g.id} …`;
183
+ const pricingLine = g.pricing
184
+ ? g.pricing.basis === 'one_shot'
185
+ ? `Cost: **${g.pricing.baseCredits} credits** ($${(g.pricing.baseCredits / 100).toFixed(2)}).`
186
+ : `Cost: **${g.pricing.baseCredits} + ${g.pricing.perSecondCredits}/sec** (8s ≈ ${quoteV2Credits(g.id as any, { durationSeconds: 8 })} credits ≈ $${(quoteV2Credits(g.id as any, { durationSeconds: 8 }) / 100).toFixed(2)}).`
187
+ : '';
188
+ return [
189
+ `### ${g.id}`,
190
+ '',
191
+ g.description,
192
+ '',
193
+ pricingLine,
194
+ '',
195
+ '**CLI:**',
196
+ '',
197
+ '```bash',
198
+ cliExample,
199
+ '```',
200
+ '',
201
+ '**MCP tool:** `' + (g.mcp?.toolName ?? '(none)') + '`',
202
+ '',
203
+ '**REST:** `' + (g.rest?.method ?? '') + ' ' + (g.rest?.path ?? '(internal)') + '`',
204
+ '',
205
+ ].join('\n');
206
+ })
207
+ .join('---\n\n');
208
+
209
+ return [
210
+ '---',
211
+ 'name: agent-media-v2',
212
+ 'description: AI UGC video production via agent-media v2 — Selfie videos and reusable Characters. Use this skill when the user wants to make TikTok-style "AI person talking to camera" clips or save a character for reuse across multiple generations.',
213
+ 'homepage: https://agent-media.ai',
214
+ `version: 1.0.0`,
215
+ '---',
216
+ '',
217
+ GENERATED_NOTE,
218
+ '',
219
+ '# agent-media v2 — Selfie + Characters',
220
+ '',
221
+ 'The v2 surface ships two generators today: **Selfie** (a 9:16 TikTok-style video of an AI person talking to camera) and **character_create** (persist an AI character so subsequent Selfies stay on-model). When the next v2 product lands (Product-in-hands), it appears here automatically — this file is generated from `packages/schema/src/v2/generators.ts`.',
222
+ '',
223
+ '## When to use this skill',
224
+ '',
225
+ 'Trigger phrases:',
226
+ '- "make me a TikTok / Selfie / UGC video"',
227
+ '- "have an AI person say …"',
228
+ '- "create a character / actor / persona for reuse"',
229
+ '- "use the same person across multiple videos"',
230
+ '',
231
+ '## Setup',
232
+ '',
233
+ '```bash',
234
+ 'npm install -g agent-media-cli',
235
+ 'agent-media login # opens browser; pastes ma_xxx into ~/.agent-media',
236
+ '```',
237
+ '',
238
+ 'Or use the SDK directly:',
239
+ '',
240
+ '```ts',
241
+ "import { AgentMedia } from '@agentmedia/sdk';",
242
+ "const client = new AgentMedia({ apiKey: process.env.AGENT_MEDIA_API_KEY! });",
243
+ "const job = await client.v2.createCharacter({ photo_url: '…', display_name: 'sofia', description: '…' });",
244
+ "const done = await client.v2.runUntilDone(Promise.resolve(job));",
245
+ '```',
246
+ '',
247
+ '## Generators',
248
+ '',
249
+ generatorBlocks,
250
+ '## Recommended flow (multi-clip from one character)',
251
+ '',
252
+ '1. **Create the character once** with `character create`. Returns `char_xxxxxxxxxx`.',
253
+ '2. **Reuse that id** for every subsequent Selfie — same face, same voice, same seed.',
254
+ '3. If the user wants a different *look*, make a new character; don\'t mutate the existing one.',
255
+ '',
256
+ '```bash',
257
+ "ID=$(agent-media character create --photo me.png --name sofia \\",
258
+ ' --description "25, asian, long wavy dark hair, casual confident" --quiet)',
259
+ '',
260
+ 'agent-media selfie --character $ID --script "Got my first 100 customers in 30 days." --preset desk-wfh-quick-pitch',
261
+ 'agent-media selfie --character $ID --script "Here\'s how I did it." --preset bedroom-morning-ritual',
262
+ '```',
263
+ '',
264
+ '## Shot grammar (Selfie)',
265
+ '',
266
+ '`--preset` accepts one of:',
267
+ '',
268
+ V2_SHOT_PRESETS.map((p) => `- \`${p}\``).join('\n'),
269
+ '',
270
+ 'Or pass `--preset custom-scene:"<your scene description>"` for an ad-hoc setup.',
271
+ '',
272
+ '## Vibes',
273
+ '',
274
+ V2_VIBES.map((v) => `- \`${v}\``).join('\n'),
275
+ '',
276
+ '## Realism rubric',
277
+ '',
278
+ 'Every Selfie clip is composed with these constraints baked into the prompt:',
279
+ '',
280
+ '1. Skin micro-detail visible — pores, freckles, oil sheen, baby hairs at hairline.',
281
+ '2. Hands always doing something — hair-touch, strap-fix, product hold.',
282
+ '3. Mouth caught mid-syllable when talking, not closed.',
283
+ '4. Eyes slightly off-center to camera, not a dead stare.',
284
+ '5. Single mixed light source (daylight + warm bulb).',
285
+ '6. Real setting (bedroom / kitchen / car / dresser-corner) — never plain wall / studio.',
286
+ '7. Outfit plain + matte or satin — never patterned or logo\'d.',
287
+ '8. Hair long, brushed, in motion.',
288
+ '9. Product (if any) held mid-chest, ~25° tilt.',
289
+ '',
290
+ 'You don\'t need to repeat these in the script — they\'re always applied.',
291
+ '',
292
+ '## Error handling',
293
+ '',
294
+ '| Error code | What it means |',
295
+ '|---|---|',
296
+ '| `VALIDATION_ERROR` | Inputs failed Zod validation. Look at `error.issues`. |',
297
+ '| `INSUFFICIENT_CREDITS` | User\'s balance is below the quote. Tell them the exact amount needed. |',
298
+ '| `MISSING_CHARACTER_INPUT` | Selfie was called without either `--character` or `--photo + --description`. |',
299
+ '| `AMBIGUOUS_CHARACTER_INPUT` | Both `--character` and `--photo` were passed. Pick one. |',
300
+ '| `JOB_FAILED` | Worker reported failure. `error_message` carries the reason. |',
301
+ '| `POLL_TIMEOUT` | Job didn\'t complete within 30 minutes. Surface the job id; it may still finish. |',
302
+ '',
303
+ '## When NOT to use this skill',
304
+ '',
305
+ '- The user has an EXISTING legacy job they want to check or modify — use the v1 commands (`agent-media status`, `agent-media ugc`, etc.).',
306
+ '- The user wants a Show-Your-App / Product-Acting / Laptop-UGC clip — those products live in the v1 surface (separate generators, separate skill).',
307
+ '',
308
+ ].join('\n');
309
+ }
310
+
311
+ // ── Run ────────────────────────────────────────────────────────────────────
312
+
313
+ function main() {
314
+ mkdirSync(dirname(DOCS_OUT), { recursive: true });
315
+ mkdirSync(dirname(SKILL_OUT), { recursive: true });
316
+
317
+ const docs = renderApiReference();
318
+ writeFileSync(DOCS_OUT, docs, 'utf8');
319
+
320
+ const skill = renderSkill();
321
+ writeFileSync(SKILL_OUT, skill, 'utf8');
322
+
323
+ console.log(`✓ wrote ${DOCS_OUT} (${docs.length} bytes)`);
324
+ console.log(`✓ wrote ${SKILL_OUT} (${skill.length} bytes)`);
325
+ console.log(` generators emitted: ${Object.keys(V2_GENERATORS).length}`);
326
+ }
327
+
328
+ main();
@@ -0,0 +1,356 @@
1
+ // Copyright 2026 agent-media contributors. Apache-2.0 license.
2
+
3
+ /**
4
+ * Validation parity tests for the 3-step Content Machine character
5
+ * pipeline schemas:
6
+ * - CharacterSheetSchema (POST /v1/character/sheet-generate)
7
+ * - CharacterStoryboardSchema (POST /v1/character/storyboard-generate)
8
+ * - StoryboardSuggestSchema (POST /v1/character/storyboard-suggest)
9
+ * - CharacterVideoSchema (POST /v1/generate/character_video)
10
+ *
11
+ * These exercise mutually-exclusive sources, length bounds, enum and
12
+ * UUID validation, and default application — exactly the constraints
13
+ * the api-v2 routes also enforce. If a route's schema diverges from
14
+ * server-side validation in the future, this is where it gets caught
15
+ * before shipping.
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest';
19
+ import {
20
+ CharacterSheetSchema,
21
+ CharacterStoryboardSchema,
22
+ StoryboardSuggestSchema,
23
+ CharacterVideoSchema,
24
+ } from '../video.js';
25
+
26
+ const SHEET_URL = 'https://pub-16e2ed8f6be84691845e91436920ce0a.r2.dev/x/character-sheet.png';
27
+ const STORYBOARD_URL = 'https://pub-16e2ed8f6be84691845e91436920ce0a.r2.dev/x/storyboard.png';
28
+ const REF_URL = 'https://example.com/portrait.png';
29
+ const SESSION = '00000000-0000-4000-8000-000000000000';
30
+
31
+ // ── CharacterSheetSchema ─────────────────────────────────────────────────────
32
+
33
+ describe('CharacterSheetSchema', () => {
34
+ it('accepts description-only', () => {
35
+ const r = CharacterSheetSchema.safeParse({ description: 'Marco the chef' });
36
+ expect(r.success).toBe(true);
37
+ });
38
+
39
+ it('accepts actor_slug-only', () => {
40
+ const r = CharacterSheetSchema.safeParse({ actor_slug: 'mei' });
41
+ expect(r.success).toBe(true);
42
+ });
43
+
44
+ it('accepts reference_image_url-only', () => {
45
+ const r = CharacterSheetSchema.safeParse({ reference_image_url: REF_URL });
46
+ expect(r.success).toBe(true);
47
+ });
48
+
49
+ it('accepts description + actor_slug (description adds context)', () => {
50
+ const r = CharacterSheetSchema.safeParse({
51
+ description: 'Marco the chef',
52
+ actor_slug: 'mei',
53
+ });
54
+ expect(r.success).toBe(true);
55
+ });
56
+
57
+ it('accepts description + reference_image_url', () => {
58
+ const r = CharacterSheetSchema.safeParse({
59
+ description: 'Marco the chef',
60
+ reference_image_url: REF_URL,
61
+ });
62
+ expect(r.success).toBe(true);
63
+ });
64
+
65
+ it('accepts session_id alongside any source', () => {
66
+ const r = CharacterSheetSchema.safeParse({
67
+ description: 'Marco',
68
+ session_id: SESSION,
69
+ });
70
+ expect(r.success).toBe(true);
71
+ });
72
+
73
+ it('rejects empty body (no source)', () => {
74
+ const r = CharacterSheetSchema.safeParse({});
75
+ expect(r.success).toBe(false);
76
+ });
77
+
78
+ it('rejects actor_slug + reference_image_url (mutex)', () => {
79
+ const r = CharacterSheetSchema.safeParse({
80
+ actor_slug: 'mei',
81
+ reference_image_url: REF_URL,
82
+ });
83
+ expect(r.success).toBe(false);
84
+ if (!r.success) {
85
+ expect(JSON.stringify(r.error.issues)).toMatch(/mutually exclusive/i);
86
+ }
87
+ });
88
+
89
+ it('rejects description shorter than 3 chars', () => {
90
+ const r = CharacterSheetSchema.safeParse({ description: 'ab' });
91
+ expect(r.success).toBe(false);
92
+ });
93
+
94
+ it('rejects description longer than 400 chars', () => {
95
+ const r = CharacterSheetSchema.safeParse({ description: 'x'.repeat(401) });
96
+ expect(r.success).toBe(false);
97
+ });
98
+
99
+ it('rejects non-URL reference_image_url', () => {
100
+ const r = CharacterSheetSchema.safeParse({ reference_image_url: 'not-a-url' });
101
+ expect(r.success).toBe(false);
102
+ });
103
+
104
+ it('rejects non-uuid session_id', () => {
105
+ const r = CharacterSheetSchema.safeParse({ description: 'Marco', session_id: 'nope' });
106
+ expect(r.success).toBe(false);
107
+ });
108
+ });
109
+
110
+ // ── CharacterStoryboardSchema ────────────────────────────────────────────────
111
+
112
+ describe('CharacterStoryboardSchema', () => {
113
+ const ok = (extra: Record<string, unknown>) => ({
114
+ character_sheet_url: SHEET_URL,
115
+ ...extra,
116
+ });
117
+
118
+ it('accepts beats[]', () => {
119
+ const r = CharacterStoryboardSchema.safeParse(ok({
120
+ beats: ['walks in', 'takes a bite', 'thumbs up'],
121
+ }));
122
+ expect(r.success).toBe(true);
123
+ });
124
+
125
+ it('accepts script', () => {
126
+ const r = CharacterStoryboardSchema.safeParse(ok({
127
+ script: 'Marco walks in, takes a bite, gives a thumbs up.',
128
+ }));
129
+ expect(r.success).toBe(true);
130
+ });
131
+
132
+ it('applies default ratio 9:16', () => {
133
+ const r = CharacterStoryboardSchema.parse(ok({
134
+ beats: ['a', 'bb', 'ccc'].map((s) => s.repeat(3)),
135
+ }));
136
+ expect(r.ratio).toBe('9:16');
137
+ });
138
+
139
+ it('accepts ratio overrides', () => {
140
+ for (const ratio of ['9:16', '16:9', '1:1'] as const) {
141
+ const r = CharacterStoryboardSchema.safeParse(ok({
142
+ script: 'enough chars to pass min length',
143
+ ratio,
144
+ }));
145
+ expect(r.success).toBe(true);
146
+ }
147
+ });
148
+
149
+ it('rejects body with both beats and script (mutex)', () => {
150
+ const r = CharacterStoryboardSchema.safeParse(ok({
151
+ beats: ['walks in', 'takes a bite', 'thumbs up'],
152
+ script: 'a script over ten chars',
153
+ }));
154
+ expect(r.success).toBe(false);
155
+ if (!r.success) {
156
+ expect(JSON.stringify(r.error.issues)).toMatch(/exactly one/i);
157
+ }
158
+ });
159
+
160
+ it('rejects body with neither beats nor script', () => {
161
+ const r = CharacterStoryboardSchema.safeParse(ok({}));
162
+ expect(r.success).toBe(false);
163
+ });
164
+
165
+ it('rejects beats with fewer than 3 entries', () => {
166
+ const r = CharacterStoryboardSchema.safeParse(ok({ beats: ['a', 'bb'] }));
167
+ expect(r.success).toBe(false);
168
+ });
169
+
170
+ it('rejects beats with more than 10 entries', () => {
171
+ const r = CharacterStoryboardSchema.safeParse(ok({
172
+ beats: Array.from({ length: 11 }, (_, i) => `beat ${i}`),
173
+ }));
174
+ expect(r.success).toBe(false);
175
+ });
176
+
177
+ it('rejects beat shorter than 3 chars', () => {
178
+ const r = CharacterStoryboardSchema.safeParse(ok({
179
+ beats: ['ab', 'walks in', 'thumbs up'],
180
+ }));
181
+ expect(r.success).toBe(false);
182
+ });
183
+
184
+ it('rejects script shorter than 10 chars', () => {
185
+ const r = CharacterStoryboardSchema.safeParse(ok({ script: 'too short' }));
186
+ expect(r.success).toBe(false);
187
+ });
188
+
189
+ it('rejects script longer than 1500 chars', () => {
190
+ const r = CharacterStoryboardSchema.safeParse(ok({ script: 'x'.repeat(1501) }));
191
+ expect(r.success).toBe(false);
192
+ });
193
+
194
+ it('rejects bad ratio', () => {
195
+ const r = CharacterStoryboardSchema.safeParse(ok({
196
+ script: 'a script over ten chars',
197
+ ratio: '4:3',
198
+ }));
199
+ expect(r.success).toBe(false);
200
+ });
201
+
202
+ it('rejects missing character_sheet_url', () => {
203
+ const r = CharacterStoryboardSchema.safeParse({
204
+ script: 'a script over ten chars',
205
+ });
206
+ expect(r.success).toBe(false);
207
+ });
208
+
209
+ it('rejects non-uuid session_id', () => {
210
+ const r = CharacterStoryboardSchema.safeParse(ok({
211
+ script: 'a script over ten chars',
212
+ session_id: 'nope',
213
+ }));
214
+ expect(r.success).toBe(false);
215
+ });
216
+ });
217
+
218
+ // ── StoryboardSuggestSchema ──────────────────────────────────────────────────
219
+
220
+ describe('StoryboardSuggestSchema', () => {
221
+ it('accepts actor_slug only', () => {
222
+ const r = StoryboardSuggestSchema.safeParse({ actor_slug: 'mei' });
223
+ expect(r.success).toBe(true);
224
+ });
225
+
226
+ it('accepts character_description only', () => {
227
+ const r = StoryboardSuggestSchema.safeParse({
228
+ character_description: 'Marco the Italian chef',
229
+ });
230
+ expect(r.success).toBe(true);
231
+ });
232
+
233
+ it('applies default duration=10 and n_panels=6', () => {
234
+ const r = StoryboardSuggestSchema.parse({ actor_slug: 'mei' });
235
+ expect(r.duration).toBe(10);
236
+ expect(r.n_panels).toBe(6);
237
+ });
238
+
239
+ it('accepts vibe + duration + n_panels overrides', () => {
240
+ const r = StoryboardSuggestSchema.safeParse({
241
+ actor_slug: 'mei', vibe: 'wholesome', duration: 5, n_panels: 4,
242
+ });
243
+ expect(r.success).toBe(true);
244
+ });
245
+
246
+ it('rejects both actor_slug and character_description (mutex)', () => {
247
+ const r = StoryboardSuggestSchema.safeParse({
248
+ actor_slug: 'mei', character_description: 'Marco',
249
+ });
250
+ expect(r.success).toBe(false);
251
+ });
252
+
253
+ it('rejects neither', () => {
254
+ const r = StoryboardSuggestSchema.safeParse({});
255
+ expect(r.success).toBe(false);
256
+ });
257
+
258
+ it('rejects character_description longer than 200 chars', () => {
259
+ const r = StoryboardSuggestSchema.safeParse({
260
+ character_description: 'x'.repeat(201),
261
+ });
262
+ expect(r.success).toBe(false);
263
+ });
264
+
265
+ it('rejects duration not in {5, 10}', () => {
266
+ const r = StoryboardSuggestSchema.safeParse({ actor_slug: 'mei', duration: 7 });
267
+ expect(r.success).toBe(false);
268
+ });
269
+
270
+ it('rejects n_panels < 4', () => {
271
+ const r = StoryboardSuggestSchema.safeParse({ actor_slug: 'mei', n_panels: 3 });
272
+ expect(r.success).toBe(false);
273
+ });
274
+
275
+ it('rejects n_panels > 10', () => {
276
+ const r = StoryboardSuggestSchema.safeParse({ actor_slug: 'mei', n_panels: 11 });
277
+ expect(r.success).toBe(false);
278
+ });
279
+ });
280
+
281
+ // ── CharacterVideoSchema ─────────────────────────────────────────────────────
282
+
283
+ describe('CharacterVideoSchema', () => {
284
+ const minimal = {
285
+ character_sheet_url: SHEET_URL,
286
+ storyboard_url: STORYBOARD_URL,
287
+ };
288
+
289
+ it('accepts minimal body and applies defaults', () => {
290
+ const r = CharacterVideoSchema.parse(minimal);
291
+ expect(r.duration).toBe(10);
292
+ expect(r.aspect_ratio).toBe('9:16');
293
+ expect(r.generate_audio).toBe(true);
294
+ });
295
+
296
+ it('accepts full body', () => {
297
+ const r = CharacterVideoSchema.safeParse({
298
+ ...minimal,
299
+ action_prompt: 'Scene: Marco in his Brooklyn kitchen at golden hour',
300
+ duration: 5,
301
+ aspect_ratio: '16:9',
302
+ generate_audio: false,
303
+ session_id: SESSION,
304
+ webhook_url: 'https://hooks.example.com/cb',
305
+ });
306
+ expect(r.success).toBe(true);
307
+ });
308
+
309
+ it('rejects missing character_sheet_url', () => {
310
+ const r = CharacterVideoSchema.safeParse({ storyboard_url: STORYBOARD_URL });
311
+ expect(r.success).toBe(false);
312
+ });
313
+
314
+ it('rejects missing storyboard_url', () => {
315
+ const r = CharacterVideoSchema.safeParse({ character_sheet_url: SHEET_URL });
316
+ expect(r.success).toBe(false);
317
+ });
318
+
319
+ it('rejects non-URL character_sheet_url', () => {
320
+ const r = CharacterVideoSchema.safeParse({
321
+ character_sheet_url: 'not a url',
322
+ storyboard_url: STORYBOARD_URL,
323
+ });
324
+ expect(r.success).toBe(false);
325
+ });
326
+
327
+ it('rejects empty action_prompt', () => {
328
+ const r = CharacterVideoSchema.safeParse({ ...minimal, action_prompt: '' });
329
+ expect(r.success).toBe(false);
330
+ });
331
+
332
+ it('rejects action_prompt longer than 2000 chars', () => {
333
+ const r = CharacterVideoSchema.safeParse({ ...minimal, action_prompt: 'x'.repeat(2001) });
334
+ expect(r.success).toBe(false);
335
+ });
336
+
337
+ it('rejects duration not in {5, 10}', () => {
338
+ const r = CharacterVideoSchema.safeParse({ ...minimal, duration: 7 });
339
+ expect(r.success).toBe(false);
340
+ });
341
+
342
+ it('rejects bad aspect_ratio', () => {
343
+ const r = CharacterVideoSchema.safeParse({ ...minimal, aspect_ratio: '4:3' });
344
+ expect(r.success).toBe(false);
345
+ });
346
+
347
+ it('rejects non-uuid session_id', () => {
348
+ const r = CharacterVideoSchema.safeParse({ ...minimal, session_id: 'not-a-uuid' });
349
+ expect(r.success).toBe(false);
350
+ });
351
+
352
+ it('rejects non-https webhook_url style invalid URL', () => {
353
+ const r = CharacterVideoSchema.safeParse({ ...minimal, webhook_url: 'not a url' });
354
+ expect(r.success).toBe(false);
355
+ });
356
+ });