@agentmedia/schema 0.3.0 → 0.5.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 +31 -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 +98 -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 +548 -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 +41 -0
  47. package/src/v2/generators.ts +186 -0
  48. package/src/v2/index.ts +15 -0
  49. package/src/v2/selfie.ts +115 -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,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
+ });
@@ -0,0 +1,79 @@
1
+ // Copyright 2026 agent-media contributors. Apache-2.0 license.
2
+
3
+ /**
4
+ * Validation tests for TextToVideoSchema — the pure-prompt generator
5
+ * used by the "Use prompts" batch mode and the one-shot
6
+ * POST /v1/generate/text_to_video endpoint.
7
+ *
8
+ * The schema is intentionally simple: prompt (20-1000 chars), duration
9
+ * (5/10/15), aspect_ratio (9:16/16:9/1:1), generate_audio (default true).
10
+ */
11
+
12
+ import { describe, it, expect } from 'vitest';
13
+ import { TextToVideoSchema, TEXT_TO_VIDEO_DURATIONS, TEXT_TO_VIDEO_RATIOS } from '../video.js';
14
+
15
+ const VALID_PROMPT = 'Handcrafted stylized stop-motion aesthetic with miniature practical sets, animation on 2s, warm magical lighting.';
16
+
17
+ describe('TextToVideoSchema', () => {
18
+ it('accepts minimum valid input (prompt only — defaults fill in)', () => {
19
+ const r = TextToVideoSchema.safeParse({ prompt: VALID_PROMPT });
20
+ expect(r.success).toBe(true);
21
+ if (r.success) {
22
+ expect(r.data.duration).toBe(10);
23
+ expect(r.data.aspect_ratio).toBe('9:16');
24
+ expect(r.data.generate_audio).toBe(true);
25
+ }
26
+ });
27
+
28
+ it('rejects prompt shorter than 20 characters', () => {
29
+ const r = TextToVideoSchema.safeParse({ prompt: 'too short' });
30
+ expect(r.success).toBe(false);
31
+ });
32
+
33
+ it('rejects prompt longer than 1000 characters', () => {
34
+ const r = TextToVideoSchema.safeParse({ prompt: 'A'.repeat(1001) });
35
+ expect(r.success).toBe(false);
36
+ });
37
+
38
+ it('accepts each supported duration', () => {
39
+ for (const d of TEXT_TO_VIDEO_DURATIONS) {
40
+ const r = TextToVideoSchema.safeParse({ prompt: VALID_PROMPT, duration: d });
41
+ expect(r.success).toBe(true);
42
+ }
43
+ });
44
+
45
+ it('rejects duration outside the allowed set', () => {
46
+ const r = TextToVideoSchema.safeParse({ prompt: VALID_PROMPT, duration: 7 });
47
+ expect(r.success).toBe(false);
48
+ });
49
+
50
+ it('accepts each supported aspect_ratio', () => {
51
+ for (const a of TEXT_TO_VIDEO_RATIOS) {
52
+ const r = TextToVideoSchema.safeParse({ prompt: VALID_PROMPT, aspect_ratio: a });
53
+ expect(r.success).toBe(true);
54
+ }
55
+ });
56
+
57
+ it('rejects unsupported aspect_ratio', () => {
58
+ const r = TextToVideoSchema.safeParse({ prompt: VALID_PROMPT, aspect_ratio: '21:9' });
59
+ expect(r.success).toBe(false);
60
+ });
61
+
62
+ it('accepts generate_audio=false', () => {
63
+ const r = TextToVideoSchema.safeParse({ prompt: VALID_PROMPT, generate_audio: false });
64
+ expect(r.success).toBe(true);
65
+ });
66
+
67
+ it('accepts a webhook_url that is HTTPS', () => {
68
+ const r = TextToVideoSchema.safeParse({
69
+ prompt: VALID_PROMPT,
70
+ webhook_url: 'https://example.com/hook',
71
+ });
72
+ expect(r.success).toBe(true);
73
+ });
74
+
75
+ it('rejects an invalid webhook_url', () => {
76
+ const r = TextToVideoSchema.safeParse({ prompt: VALID_PROMPT, webhook_url: 'not-a-url' });
77
+ expect(r.success).toBe(false);
78
+ });
79
+ });
package/src/generators.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import {
12
+ CharacterVideoSchema,
12
13
  CreateVideoSchema,
13
14
  LaptopUgcSchema,
14
15
  ProductActingSchema,
@@ -16,6 +17,7 @@ import {
16
17
  SaasReviewSchema,
17
18
  ShowYourAppSchema,
18
19
  SubtitleSchema,
20
+ TextToVideoSchema,
19
21
  } from './video.js';
20
22
 
21
23
  export const GENERATORS = {
@@ -55,6 +57,16 @@ export const GENERATORS = {
55
57
  inputSchema: LaptopUgcSchema,
56
58
  output: 'video_url' as const,
57
59
  },
60
+ character_video: {
61
+ description: 'Generate a video of a character. Pick an actor by slug, OR pass a short description and we generate a character sheet via gpt-image-2 behind the scenes. Then Seedance 2.0 animates the reference image with your storyboard text.',
62
+ inputSchema: CharacterVideoSchema,
63
+ output: 'video_url' as const,
64
+ },
65
+ text_to_video: {
66
+ description: 'Pure text-to-video via Seedance 2.0 — no character, no storyboard, no actor. The prompt IS the whole creative (style, subject, mood, composition). Best for stylistic / scene-driven content where you want the model to invent everything from the prompt text alone.',
67
+ inputSchema: TextToVideoSchema,
68
+ output: 'video_url' as const,
69
+ },
58
70
  } as const;
59
71
 
60
72
  export type GeneratorId = keyof typeof GENERATORS;
@@ -0,0 +1,41 @@
1
+ // Copyright 2026 agent-media contributors. Apache-2.0 license.
2
+
3
+ /**
4
+ * v2 · Character create input schema.
5
+ *
6
+ * Persists a reusable AI character. The user uploads a single photo;
7
+ * we generate a portrait + multi-pose character sheet (gpt-image-2),
8
+ * pin a Seedance seed, store everything in `user_characters`.
9
+ *
10
+ * Returns: { character_id: "char_xxxxxxxxxx" } that the user can pass
11
+ * to any v2 video generator (Selfie, then Product-in-hands, etc).
12
+ */
13
+
14
+ import { z } from 'zod';
15
+ import { V2_SHOT_PRESETS } from './selfie.js';
16
+
17
+ export const CharacterCreateSchema = z.object({
18
+ // Optional source photo — if absent, agent-media generates the
19
+ // portrait from `description` alone. Pass a real person's photo
20
+ // ONLY when you want that exact person's likeness.
21
+ photo_url: z.string().url().optional(),
22
+
23
+ // Required identity
24
+ display_name: z
25
+ .string()
26
+ .trim()
27
+ .min(2)
28
+ .max(40)
29
+ .regex(
30
+ /^[A-Za-z0-9 _-]+$/,
31
+ 'display_name may only contain letters, digits, spaces, underscores, or hyphens',
32
+ ),
33
+
34
+ description: z.string().min(8).max(400),
35
+
36
+ // Optional — defaults applied per-character if missing
37
+ voice_brief: z.string().min(4).max(240).optional(),
38
+ preset_default: z.enum(V2_SHOT_PRESETS).optional(),
39
+ });
40
+
41
+ export type CharacterCreateInput = z.infer<typeof CharacterCreateSchema>;
@@ -0,0 +1,186 @@
1
+ // Copyright 2026 agent-media contributors. Apache-2.0 license.
2
+
3
+ /**
4
+ * v2 · Generator registry.
5
+ *
6
+ * Greenfield registry for the v2 product line. Lives alongside the
7
+ * legacy `GENERATORS` export in ../generators.ts but in its own file
8
+ * with a richer record shape — CLI / MCP / REST / pricing metadata
9
+ * baked in so the SDK, CLI, MCP server, docs and SKILL.md can all
10
+ * derive themselves from this one source.
11
+ *
12
+ * Old code keeps reading `GENERATORS` from ../generators.ts. New code
13
+ * imports `V2_GENERATORS` from here. Two surfaces, zero shared state.
14
+ *
15
+ * The new website + docs + SKILL.md only see v2.
16
+ */
17
+
18
+ import type { z } from 'zod';
19
+ import { SelfieSchema } from './selfie.js';
20
+ import { CharacterCreateSchema } from './character.js';
21
+ import { SubtitleSchema } from './subtitle.js';
22
+
23
+ // ── Record shape ──────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Lifecycle. Two states only — by design.
27
+ * `stable`: fully exposed, no warnings.
28
+ * `beta`: exposed, SDK/CLI print a one-time warning, MCP tool
29
+ * description starts with "[beta]".
30
+ *
31
+ * Retired ops are deleted from the registry, not deprecated. There's
32
+ * no `deprecated` tier because old code stays in its own place.
33
+ */
34
+ export type V2Status = 'stable' | 'beta';
35
+
36
+ export interface V2GeneratorRecord {
37
+ id: string;
38
+ status: V2Status;
39
+
40
+ summary: string; // single line, used in CLI help and tool listings
41
+ description: string; // multi-line, used in docs and MCP tool registration
42
+
43
+ /** Zod schema. The contract. */
44
+ inputSchema: z.ZodTypeAny;
45
+
46
+ /** What the generator returns. Affects how the SDK types the response. */
47
+ output: 'video_url' | 'character_id' | 'subtitled_video_url';
48
+
49
+ /** CLI surface. Omit to hide from the CLI. */
50
+ cli?: {
51
+ command: string; // e.g. "selfie", "character create"
52
+ fileFields?: string[]; // input fields that take --foo file.png and need upload coercion
53
+ examples?: string[];
54
+ };
55
+
56
+ /** MCP surface. Omit to hide from the MCP server. */
57
+ mcp?: {
58
+ toolName: string; // e.g. "create_selfie"
59
+ };
60
+
61
+ /** REST surface. Omit to hide from the public API. */
62
+ rest?: {
63
+ method: 'POST' | 'GET';
64
+ path: string; // e.g. "/v2/selfie"
65
+ };
66
+
67
+ /** Pricing. Single source so dashboard + CLI quoting + webhook agree. */
68
+ pricing?:
69
+ | { basis: 'per_clip'; baseCredits: number; perSecondCredits: number }
70
+ | { basis: 'one_shot'; baseCredits: number };
71
+ }
72
+
73
+ // ── The registry ──────────────────────────────────────────────────────────
74
+
75
+ export const V2_GENERATORS: Record<string, V2GeneratorRecord> = {
76
+ selfie: {
77
+ id: 'selfie',
78
+ status: 'stable',
79
+ summary: 'AI person talking to camera, handheld iPhone-style.',
80
+ description:
81
+ 'Generate a 9:16 vertical TikTok-style selfie clip. Pick a saved character (--character) ' +
82
+ 'OR pass a photo + description inline. The pipeline composes a portrait → multi-pose ' +
83
+ 'character sheet → per-scene wireframe, then Seedance 2.0 animates the scene with native ' +
84
+ 'audio. Output: an mp4 hosted on R2.',
85
+ inputSchema: SelfieSchema,
86
+ output: 'video_url',
87
+
88
+ cli: {
89
+ command: 'selfie',
90
+ fileFields: ['photo_url'],
91
+ examples: [
92
+ 'agent-media selfie --character char_8x2vqp --script "..."',
93
+ 'agent-media selfie --photo me.png --description "25, ..." --script "..."',
94
+ ],
95
+ },
96
+ mcp: { toolName: 'create_selfie' },
97
+ rest: { method: 'POST', path: '/v2/selfie' },
98
+
99
+ // Cost math (list price, 70% margin floor):
100
+ // 5s = $0.63 cost → 210 credits ($2.10)
101
+ // 8s = $0.93 cost → 310 credits ($3.10)
102
+ // 12s = $1.33 cost → 445 credits ($4.45)
103
+ // 15s = $1.63 cost → 545 credits ($5.45)
104
+ // Linear in duration after a fixed prelude (~$0.13 of gpt-image-2 + R2)
105
+ // so we express as base + per-second.
106
+ pricing: { basis: 'per_clip', baseCredits: 75, perSecondCredits: 30 },
107
+ },
108
+
109
+ character_create: {
110
+ id: 'character_create',
111
+ status: 'stable',
112
+ summary: 'Create a reusable AI character from a single photo.',
113
+ description:
114
+ 'Persists a character so subsequent video calls can reference it by id. Two gpt-image-2 ' +
115
+ 'calls (portrait + multi-pose character sheet) are made at create time and cached in R2. ' +
116
+ 'A pinned Seedance seed is stored on the row. Returns: { character_id }.',
117
+ inputSchema: CharacterCreateSchema,
118
+ output: 'character_id',
119
+
120
+ cli: {
121
+ command: 'character create',
122
+ fileFields: ['photo_url'],
123
+ examples: [
124
+ 'agent-media character create --photo X.png --name "sofia" --description "..."',
125
+ ],
126
+ },
127
+ mcp: { toolName: 'create_character' },
128
+ rest: { method: 'POST', path: '/v2/characters' },
129
+
130
+ // ~$0.08 our cost → 27 credits at 70% margin.
131
+ pricing: { basis: 'one_shot', baseCredits: 27 },
132
+ },
133
+
134
+ subtitle: {
135
+ id: 'subtitle',
136
+ status: 'stable',
137
+ summary: 'Burn styled subtitles onto an existing video.',
138
+ description:
139
+ 'Downloads the source video, transcribes it with Whisper (or accepts a caller-supplied ' +
140
+ 'transcript), generates an ASS subtitle file in the chosen style (Hormozi by default; ' +
141
+ '17 styles available), and burns the subs into a new mp4 via ffmpeg. Output: a new ' +
142
+ 'mp4 URL on R2. Source video is fetched once and discarded.',
143
+ inputSchema: SubtitleSchema,
144
+ output: 'video_url',
145
+
146
+ cli: {
147
+ // Command is `subs` (not `subtitle`) because `agent-media subtitle`
148
+ // is already registered by the legacy CLI surface. Both routes
149
+ // live in parallel: the legacy `subtitle` command calls the v1
150
+ // worker via /v1/generate/subtitle, the new `subs` command calls
151
+ // the v2 worker via /v2/subtitle.
152
+ command: 'subs',
153
+ examples: [
154
+ 'agent-media subs --video https://r2/clip.mp4 --style hormozi',
155
+ 'agent-media subs --video https://r2/clip.mp4 --transcript "exact script text" --style neon',
156
+ ],
157
+ },
158
+ mcp: { toolName: 'create_subtitle' },
159
+ rest: { method: 'POST', path: '/v2/subtitle' },
160
+
161
+ // Cost basis:
162
+ // Whisper: $0.006 / minute of audio → trivial at < 60s
163
+ // ffmpeg compute: a few cents at most for a short clip
164
+ // R2 download + re-upload: rounding error
165
+ // Realistic our-cost for an 8s clip: < $0.01. For a 60s clip: ~$0.02.
166
+ // 70% margin floor → priced generously at 3 credits/sec so a casual
167
+ // 8s subtitle is 24 credits ($0.24) — small enough that users don't
168
+ // think twice.
169
+ pricing: { basis: 'per_clip', baseCredits: 0, perSecondCredits: 3 },
170
+ },
171
+ } as const;
172
+
173
+ export type V2GeneratorId = keyof typeof V2_GENERATORS;
174
+
175
+ export const V2_GENERATOR_IDS = Object.keys(V2_GENERATORS) as V2GeneratorId[];
176
+
177
+ /**
178
+ * Pricing helper. Single source for CLI quoting, webhook, dashboard.
179
+ */
180
+ export function quoteV2Credits(id: V2GeneratorId, opts: { durationSeconds?: number } = {}): number {
181
+ const def = V2_GENERATORS[id];
182
+ if (!def?.pricing) return 0;
183
+ if (def.pricing.basis === 'one_shot') return def.pricing.baseCredits;
184
+ const seconds = opts.durationSeconds ?? 8;
185
+ return def.pricing.baseCredits + def.pricing.perSecondCredits * seconds;
186
+ }