@slatesvideo/shared 0.5.0 → 0.5.2

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.
@@ -28,6 +28,29 @@ function ok(data, text) {
28
28
  data,
29
29
  };
30
30
  }
31
+ // ── Credits (2026-07-07 re-denomination) ────────────────────────
32
+ // Costs are ABSTRACT CREDITS now, not dollars. The API's model registry
33
+ // returns `cost_credits` (with `cost_cents` kept as a legacy alias carrying
34
+ // the same credit value); balances the same. Read via creditCost(); display
35
+ // via fmtCredits(). The confirm gate fires above CONFIRM_CREDITS (≈ the old
36
+ // $0.50 gate at the 3¢/credit peg).
37
+ const CONFIRM_CREDITS = 17;
38
+ const CENTS_PER_CREDIT = 3; // peg: 1 credit = 3¢ billed = 2¢ COGS (mirror of slates-api)
39
+ function creditCost(m) {
40
+ if (!m)
41
+ return 0;
42
+ return m.cost_credits ?? m.cost_cents ?? 0;
43
+ }
44
+ function fmtCredits(credits) {
45
+ return `${Math.round(credits).toLocaleString('en-US')} credits`;
46
+ }
47
+ /** Convert a legacy desktop dollar estimate (COGS × markup) to billed credits,
48
+ * mirroring the server's toCredits(round(dollars × 100)). The desktop's
49
+ * generations.cost column still stores dollars, so status reads convert. */
50
+ function creditsFromDollars(dollars) {
51
+ const cents = Math.round(dollars * 100);
52
+ return cents <= 0 ? 0 : Math.max(1, Math.ceil(cents / CENTS_PER_CREDIT));
53
+ }
31
54
  // Shared describe-text for the background flag on every generate_* op.
32
55
  const BACKGROUND_DESCRIBE = 'Submit and return immediately with generationId(s) instead of blocking until the file is saved. ' +
33
56
  'Poll with slates_get_generation_status. Recommended for video (1-5 min renders).';
@@ -71,16 +94,17 @@ export const getMe = {
71
94
  };
72
95
  export const getCreditBalance = {
73
96
  id: 'slates_get_credit_balance',
74
- description: 'Current Slates credit balance, in cents and dollars. Call before any generation that costs credits.',
97
+ description: 'Current Slates credit balance (abstract credits — never expire). Call before any generation that costs credits.',
75
98
  input: z.object({}).strict(),
76
99
  async run(_input, ctx) {
77
100
  const r = await ctx.cloud().get('/api/agent/credits/balance');
78
- return ok(r);
101
+ const credits = r.credit_balance ?? r.credit_balance_cents ?? 0;
102
+ return ok({ success: r.success, credit_balance: credits }, `Balance: ${fmtCredits(credits)}.`);
79
103
  },
80
104
  };
81
105
  export const listAvailableModels = {
82
106
  id: 'slates_list_available_models',
83
- description: 'Registry of generation model cost keys with per-call credit cost in cents, as a compact "key cents" table. Optional `filter` substring (e.g. "kling" or "nano-banana") keeps the result small — prefer it. For a single known model, slates_estimate_generation_cost is cheaper still.',
107
+ description: 'Registry of generation model cost keys with per-call credit cost, as a compact "key credits" table. Optional `filter` substring (e.g. "kling" or "nano-banana") keeps the result small — prefer it. For a single known model, slates_estimate_generation_cost is cheaper still.',
84
108
  input: z.object({
85
109
  filter: z.string().optional().describe('Substring match on the model key, e.g. "kling-v3" or "seedance"'),
86
110
  }),
@@ -91,21 +115,21 @@ export const listAvailableModels = {
91
115
  const q = input.filter.toLowerCase();
92
116
  models = models.filter((m) => m.model.toLowerCase().includes(q));
93
117
  }
94
- // Compact text table — "key cents" lines are ~5x denser than the raw
118
+ // Compact text table — "key credits" lines are ~5x denser than the raw
95
119
  // JSON registry (the full pretty-printed dump was an ~8k-token leak).
96
- const table = models.map((m) => `${m.model} ${m.cost_cents}`).join('\n');
120
+ const table = models.map((m) => `${m.model} ${creditCost(m)}`).join('\n');
97
121
  return {
98
- text: `${models.length} COST keys (cents per generation)${input.filter ? ` matching "${input.filter}"` : ''}. ` +
122
+ text: `${models.length} COST keys (credits per generation)${input.filter ? ` matching "${input.filter}"` : ''}. ` +
99
123
  `NOTE: these are billing keys for cost lookup ONLY — the \`model\` param on slates_generate_video takes a BASE id ` +
100
124
  `(kling-v3.0-std | kling-v3.0-pro | kling-v3.0-omni | seedance-2 | veo-3.1-fast | veo-3.1-standard) with duration/videoResolution as separate params:\n` +
101
125
  table,
102
- data: { count: models.length, credit_markup: r.credit_markup },
126
+ data: { count: models.length },
103
127
  };
104
128
  },
105
129
  };
106
130
  export const estimateGenerationCost = {
107
131
  id: 'slates_estimate_generation_cost',
108
- description: 'Pre-flight cost estimate. Call before any generate_* op so the user sees "this will cost N credits" up front. Takes the SAME base model ids as the generate ops (video: "seedance-2" + duration + videoResolution; image: "nano-banana-2" + resolution) — exact registry cost keys also work. Pairs with the >$0.50 confirm gate.',
132
+ description: 'Pre-flight cost estimate. Call before any generate_* op so the user sees "this will cost N credits" up front. Takes the SAME base model ids as the generate ops (video: "seedance-2" + duration + videoResolution; image: "nano-banana-2" + resolution) — exact registry cost keys also work. Pairs with the confirm gate.',
109
133
  input: z.object({
110
134
  model: z.string().describe('Base model id as passed to the generate op (e.g. "seedance-2", "kling-v3.0-std", "nano-banana-2") or an exact registry cost key ("nano-banana-2-2k", "seedance-2-1080p-8s")'),
111
135
  quantity: z.number().int().min(1).max(10).optional().describe('Number of generations (default 1)'),
@@ -119,7 +143,7 @@ export const estimateGenerationCost = {
119
143
  }),
120
144
  async run(input, ctx) {
121
145
  const registry = await ctx.cloud().get('/api/agent/models');
122
- const byKey = new Map(registry.models.map((m) => [m.model, m.cost_cents]));
146
+ const byKey = new Map(registry.models.map((m) => [m.model, creditCost(m)]));
123
147
  // 1) exact registry cost key
124
148
  let key = byKey.has(input.model) ? input.model : null;
125
149
  // 2) image base id + resolution (+ quality for gpt-image-2)
@@ -164,21 +188,20 @@ export const estimateGenerationCost = {
164
188
  });
165
189
  }
166
190
  }
167
- const cents = key != null ? byKey.get(key) : undefined;
168
- if (key == null || cents == null) {
191
+ const perCredits = key != null ? byKey.get(key) : undefined;
192
+ if (key == null || perCredits == null) {
169
193
  throw new Error(`Unknown model: ${input.model}. Pass a base id (${VIDEO_MODELS.join(' | ')} | nano-banana-2 | flux-2-max | seedream-5-lite) plus duration/resolution params, or use slates_list_available_models with a filter.`);
170
194
  }
171
195
  const qty = input.quantity ?? 1;
172
- const totalCents = cents * qty;
196
+ const totalCredits = perCredits * qty;
173
197
  return ok({
174
198
  model: input.model,
175
199
  cost_key: key,
176
200
  quantity: qty,
177
- cost_per_cents: cents,
178
- total_cents: totalCents,
179
- total_dollars: (totalCents / 100).toFixed(2),
180
- requires_confirm: totalCents > 50,
181
- });
201
+ cost_per_credits: perCredits,
202
+ total_credits: totalCredits,
203
+ requires_confirm: totalCredits > CONFIRM_CREDITS,
204
+ }, `${input.model}${qty > 1 ? ` ×${qty}` : ''}: ${fmtCredits(totalCredits)} (${key}).`);
182
205
  },
183
206
  };
184
207
  // ── Projects ────────────────────────────────────────────────────
@@ -416,12 +439,16 @@ export const getAssetVideoFrames = {
416
439
  };
417
440
  export const uploadReferenceImage = {
418
441
  id: 'slates_upload_reference_image',
419
- description: 'Add a reference image to a Slates project. Pass either filePath (absolute path to a local file) or dataUrl (base64 data: URL) — exactly one.',
442
+ description: 'Add a reference image OR video clip to a Slates project. Pass either filePath (absolute path to a local file) or dataUrl (base64 data: URL) — exactly one. Set type:"video" on a filePath import to bring in a clip (the user\'s own footage to edit/relocate/trim); imported videos are probed on ingest, so duration + dimensions are available immediately. Default type is "image". dataUrl is image-only.',
420
443
  input: z
421
444
  .object({
422
445
  projectId: z.string().uuid(),
423
446
  filePath: z.string().optional(),
424
447
  dataUrl: z.string().optional(),
448
+ type: z
449
+ .enum(['image', 'video'])
450
+ .optional()
451
+ .describe('Asset kind for a filePath import — "image" (default) or "video". A dataUrl is always an image.'),
425
452
  })
426
453
  .refine((d) => !!d.filePath !== !!d.dataUrl, {
427
454
  message: 'Pass exactly one of filePath or dataUrl',
@@ -432,10 +459,13 @@ export const uploadReferenceImage = {
432
459
  const r = await desktop.post('/agent/assets/upload', {
433
460
  projectId: input.projectId,
434
461
  filePath: input.filePath,
435
- type: 'image',
462
+ type: input.type ?? 'image',
436
463
  });
437
464
  return ok(r);
438
465
  }
466
+ if (input.type === 'video') {
467
+ throw new Error('dataUrl uploads are image-only — pass a filePath to import a video clip.');
468
+ }
439
469
  const r = await desktop.post('/agent/assets/upload-base64', {
440
470
  projectId: input.projectId,
441
471
  dataUrl: input.dataUrl,
@@ -573,7 +603,7 @@ export const generateCharacterSheets = {
573
603
  };
574
604
  export const generateEnvironmentPlate = {
575
605
  id: 'slates_generate_environment_plate',
576
- description: "Generate an environment's single clean establishing plate (Nano Banana 2, 2K) from an optional base image, and bind it as the environment's reference. THE real environment-building workflow — call right after slates_create_environment. Afterward, pass the plate asset id as environmentAssetIds to slates_generate_video (or referenceAssetIds to slates_generate_image) so the location stays consistent across shots. Cost ~$0.18.",
606
+ description: "Generate an environment's single clean establishing plate (Nano Banana 2, 2K) from an optional base image, and bind it as the environment's reference. THE real environment-building workflow — call right after slates_create_environment. Afterward, pass the plate asset id as environmentAssetIds to slates_generate_video (or referenceAssetIds to slates_generate_image) so the location stays consistent across shots. Cost ~6 credits.",
577
607
  input: z.object({
578
608
  environmentId: z.string().uuid(),
579
609
  projectId: z.string().uuid(),
@@ -765,7 +795,7 @@ export const generateImage = {
765
795
  referenceImageUrls: z.array(z.string().url()).max(14).optional().describe('Headless (no-projectId) nano-banana-2 only: up to 14 ref URLs. For projectId runs, upload refs with slates_upload_reference_image first. Always label each image\'s role in the prompt text.'),
766
796
  referenceAssetIds: z.array(z.string()).max(14).optional().describe("Project assets to use as reference/ingredient images — asset UUIDs or badge codes (\"IMG-A8\"); codes resolve against the project at call time. Requires projectId. For nano-banana-2 up to 14 refs; FLUX/Seedream route to their edit endpoints with lower per-model caps. Label each reference's role in the prompt text."),
767
797
  background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
768
- confirm: z.boolean().optional().describe('Set true to bypass the >$0.50 cost confirm gate.'),
798
+ confirm: z.boolean().optional().describe('Set true to bypass the confirm gate.'),
769
799
  }),
770
800
  async run(input, ctx) {
771
801
  // Clarification gate: aspectRatio + resolution must be deliberate.
@@ -852,26 +882,26 @@ export const generateImage = {
852
882
  const entry = registry.models.find((m) => m.model === costKey);
853
883
  if (!entry)
854
884
  throw new Error(`Model not in registry: ${costKey}`);
855
- const totalCents = entry.cost_cents * (input.count ?? 1);
885
+ const totalCents = creditCost(entry) * (input.count ?? 1);
856
886
  // Confirm gate. Fires on cost > $0.50, AND (look-first, mirroring
857
887
  // slates_generate_video) whenever reference assets are involved
858
888
  // regardless of cost — the LLM must see what it's referencing before
859
889
  // committing spend.
860
- if ((totalCents > 50 || referenceAssetIds.length > 0) && !input.confirm) {
890
+ if ((totalCents > CONFIRM_CREDITS || referenceAssetIds.length > 0) && !input.confirm) {
861
891
  if (referenceAssetIds.length === 0) {
862
892
  return ok({
863
893
  requires_confirm: true,
864
894
  model: costKey,
865
895
  estimated_cents: totalCents,
866
- estimated_dollars: (totalCents / 100).toFixed(2),
867
- message: 'Cost exceeds $0.50. Re-call with confirm=true to proceed, or pick a smaller resolution / count.',
896
+ estimated_credits: totalCents,
897
+ message: `Cost exceeds ${CONFIRM_CREDITS} credits. Re-call with confirm=true to proceed, or pick a smaller resolution / count.`,
868
898
  });
869
899
  }
870
900
  const previews = await previewAssets(ctx, referenceAssetIds.map((id) => ({ id, type: 'image', role: 'reference' })));
871
901
  const refLines = previews.map((p) => ` - ${p.role}: ${p.ref}`).join('\n');
872
902
  return {
873
903
  text: `Pre-flight for ${imageModel} (${costKey}): ` +
874
- `$${(totalCents / 100).toFixed(2)} (${totalCents}¢).` +
904
+ `${fmtCredits(totalCents)}.` +
875
905
  `\n\nReference images attached above:\n${refLines}\n\n` +
876
906
  `Review them against your prompt — every reference's role must be labeled in the prompt text. ` +
877
907
  `If the references suggest a different composition / style than the current prompt captures, REVISE the prompt before confirming. ` +
@@ -883,7 +913,7 @@ export const generateImage = {
883
913
  model: imageModel,
884
914
  variant: costKey,
885
915
  estimated_cents: totalCents,
886
- estimated_dollars: (totalCents / 100).toFixed(2),
916
+ estimated_credits: totalCents,
887
917
  references: previews.map((p) => ({
888
918
  role: p.role,
889
919
  ref: p.ref,
@@ -931,7 +961,7 @@ export const generateImage = {
931
961
  costKey,
932
962
  projectId: input.projectId,
933
963
  cost_cents: totalCents,
934
- cost_dollars: (totalCents / 100).toFixed(2),
964
+ cost_credits: totalCents,
935
965
  }, refEcho);
936
966
  }
937
967
  const assetList = result.assets
@@ -957,7 +987,7 @@ export const generateImage = {
957
987
  `The saved assets are in data.assets — re-generate only the missing count, don't redo the whole batch. ` +
958
988
  `Prompt: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"`
959
989
  : `Generated ${assetList.length} image(s) into project ${input.projectId} ` +
960
- `for $${(totalCents / 100).toFixed(2)} (${totalCents}¢). ` +
990
+ `for ${fmtCredits(totalCents)}. ` +
961
991
  `Prompt: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"` +
962
992
  (refEcho ? ` ${refEcho}` : ''),
963
993
  images,
@@ -968,7 +998,7 @@ export const generateImage = {
968
998
  aspectRatio: input.aspectRatio,
969
999
  resolution,
970
1000
  cost_cents: totalCents,
971
- cost_dollars: (totalCents / 100).toFixed(2),
1001
+ cost_credits: totalCents,
972
1002
  // Compact refs only — the full rows (prompt/settings/paths) were a
973
1003
  // multi-KB leak per generation and everything needed downstream is
974
1004
  // the id/code/label.
@@ -1027,7 +1057,7 @@ export const generateImage = {
1027
1057
  images.push({ data: buf.toString('base64'), mimeType: mt });
1028
1058
  }
1029
1059
  return {
1030
- text: `Generated ${urls.length} image(s) for $${(totalCents / 100).toFixed(2)} (${totalCents}¢). ` +
1060
+ text: `Generated ${urls.length} image(s) for ${fmtCredits(totalCents)}. ` +
1031
1061
  `Prompt: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"`,
1032
1062
  images,
1033
1063
  data: {
@@ -1037,7 +1067,7 @@ export const generateImage = {
1037
1067
  aspectRatio: input.aspectRatio,
1038
1068
  resolution,
1039
1069
  cost_cents: totalCents,
1040
- cost_dollars: (totalCents / 100).toFixed(2),
1070
+ cost_credits: totalCents,
1041
1071
  },
1042
1072
  };
1043
1073
  },
@@ -1076,7 +1106,7 @@ export const editImage = {
1076
1106
  resolution: z.enum(['1k', '2k', '3k', '4k']).optional().describe('3k (1440p class) is gpt-image-2 only; nano-banana-2-lite is 1k only.'),
1077
1107
  quality: z.enum(['medium', 'high']).optional().describe('gpt-image-2 only — quality tier (default medium).'),
1078
1108
  aspectRatio: z.string().optional(),
1079
- confirm: z.boolean().optional().describe('Set true to bypass the >$0.50 cost confirm gate.'),
1109
+ confirm: z.boolean().optional().describe('Set true to bypass the confirm gate.'),
1080
1110
  background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
1081
1111
  }),
1082
1112
  async run(input, ctx) {
@@ -1100,16 +1130,16 @@ export const editImage = {
1100
1130
  const entry = registry.models.find((m) => m.model === costKey);
1101
1131
  if (!entry)
1102
1132
  throw new Error(`Model not in registry: ${costKey}`);
1103
- const totalCents = entry.cost_cents;
1104
- if (totalCents > 50 && !input.confirm) {
1133
+ const totalCents = creditCost(entry);
1134
+ if (totalCents > CONFIRM_CREDITS && !input.confirm) {
1105
1135
  const sourceRef = await lookupAssetRef(desktop, input.sourceAssetId);
1106
1136
  return ok({
1107
1137
  requires_confirm: true,
1108
1138
  variant: costKey,
1109
1139
  estimated_cents: totalCents,
1110
- estimated_dollars: (totalCents / 100).toFixed(2),
1140
+ estimated_credits: totalCents,
1111
1141
  source_ref: sourceRef,
1112
- message: `Cost: $${(totalCents / 100).toFixed(2)} to edit ${sourceRef} with ${editModel} (${costKey}). ` +
1142
+ message: `Cost: ${fmtCredits(totalCents)} to edit ${sourceRef} with ${editModel} (${costKey}). ` +
1113
1143
  `Re-call with confirm=true after the user explicitly OKs the spend. ` +
1114
1144
  `When discussing with the user, refer to the source by its code (matches the gallery badge).`,
1115
1145
  });
@@ -1135,7 +1165,7 @@ export const editImage = {
1135
1165
  projectId: input.projectId,
1136
1166
  sourceAssetId: input.sourceAssetId,
1137
1167
  cost_cents: totalCents,
1138
- cost_dollars: (totalCents / 100).toFixed(2),
1168
+ cost_credits: totalCents,
1139
1169
  });
1140
1170
  }
1141
1171
  // Inline the edited result so the LLM sees whether the surgery landed —
@@ -1153,7 +1183,7 @@ export const editImage = {
1153
1183
  }
1154
1184
  return {
1155
1185
  text: `Edited image saved as a new asset in project ${input.projectId} ` +
1156
- `for $${(totalCents / 100).toFixed(2)} (${totalCents}¢) via ${editModel}. ` +
1186
+ `for ${fmtCredits(totalCents)} via ${editModel}. ` +
1157
1187
  `Edit: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"`,
1158
1188
  images,
1159
1189
  data: {
@@ -1163,7 +1193,7 @@ export const editImage = {
1163
1193
  sourceAssetId: input.sourceAssetId,
1164
1194
  resolution,
1165
1195
  cost_cents: totalCents,
1166
- cost_dollars: (totalCents / 100).toFixed(2),
1196
+ cost_credits: totalCents,
1167
1197
  asset: result.asset,
1168
1198
  generationId: result.generationId,
1169
1199
  },
@@ -1181,6 +1211,7 @@ export const VIDEO_MODELS = [
1181
1211
  'veo-3.1-fast',
1182
1212
  'veo-3.1-standard',
1183
1213
  'seedance-2',
1214
+ 'omni-flash',
1184
1215
  ];
1185
1216
  // Model → registry cost-key. Each provider's keys ship with their own
1186
1217
  // shape (verified against /api/agent/models):
@@ -1241,6 +1272,11 @@ export function videoCostKey(input) {
1241
1272
  }
1242
1273
  return `${tier}-${input.duration}s${input.sound === true ? '-audio' : ''}`;
1243
1274
  }
1275
+ if (input.model === 'omni-flash') {
1276
+ // Mirrors omniFlashCreditKey() in slate/src/shared/pricing.ts — flat 720p
1277
+ // rate, audio native + included, no resolution/audio key dimension.
1278
+ return `omni-flash-${input.duration}s`;
1279
+ }
1244
1280
  throw new Error(`Unknown video model: ${input.model}`);
1245
1281
  }
1246
1282
  // Kling O3 video-to-video edit cost key — mirrors klingEditCreditKey() in
@@ -1251,6 +1287,12 @@ export function klingEditCostKey(model, duration) {
1251
1287
  const tier = model === 'kling-v3.0-omni-pro-edit' ? 'kling-v3-omni-pro-edit' : 'kling-v3-omni-edit';
1252
1288
  return `${tier}-${duration}s`;
1253
1289
  }
1290
+ // Omni Flash video-edit cost key — mirrors omniFlashCreditKey() in
1291
+ // slate/src/shared/pricing.ts (must byte-match; checked by the slates-api
1292
+ // pricing-consistency script). Duration is the CEILED source-clip length.
1293
+ export function omniFlashEditCostKey(duration) {
1294
+ return `omni-flash-edit-${duration}s`;
1295
+ }
1254
1296
  /**
1255
1297
  * Forgiving model-id resolver. Agents routinely paste registry COST keys
1256
1298
  * ("kling-v3-standard-8s", "seedance-2-1080p-8s") into the `model` param —
@@ -1298,6 +1340,9 @@ function resolveVideoModel(raw) {
1298
1340
  seedance: 'seedance-2',
1299
1341
  'veo-3.1': 'veo-3.1-fast',
1300
1342
  'veo-3': 'veo-3.1-fast',
1343
+ 'gemini-omni-flash': 'omni-flash',
1344
+ 'gemini-omni-flash-preview': 'omni-flash',
1345
+ 'omni-flash-preview': 'omni-flash',
1301
1346
  };
1302
1347
  if (aliases[s]) {
1303
1348
  out.model = aliases[s];
@@ -1316,6 +1361,8 @@ function promptingSkillFor(model) {
1316
1361
  return 'slates-prompting-veo-3';
1317
1362
  if (model.startsWith('seedance'))
1318
1363
  return 'slates-prompting-seedance';
1364
+ if (model.startsWith('omni-flash'))
1365
+ return 'slates-prompting-omni-flash';
1319
1366
  return 'slates-cost-discipline';
1320
1367
  }
1321
1368
  export const generateVideo = {
@@ -1323,14 +1370,14 @@ export const generateVideo = {
1323
1370
  description: 'Generate video via Slates credits. REQUIRED before calling: read slates-model-selection (the routing doctrine), slates-cost-discipline, and the matching per-model prompting skill (slates-prompting-seedance / slates-prompting-kling-v3 / slates-prompting-veo-3) — video models prompt very differently; load them via slates_get_prompting_guide if no skill files are installed. Read slates-content-policy when the scene involves conflict, creatures, crowds, destruction, weapons, or young characters. projectId, aspectRatio, and duration are required (requires_clarification otherwise). Cost > $0.50 returns requires_confirm — pass confirm=true after explicit user OK. Image-to-video via firstFrameAssetId; first+last frames = Veo/Seedance only; ingredients via ingredientAssetIds (Kling Omni / Seedance). Asset params take UUIDs or badge codes ("IMG-A8").',
1324
1371
  input: z.object({
1325
1372
  prompt: z.string().min(1).max(4000),
1326
- model: z.string().describe('One of: kling-v3.0-std | kling-v3.0-pro | kling-v3.0-omni | seedance-2 | veo-3.1-fast | veo-3.1-standard. Pass the BASE id — duration and videoResolution are separate params (registry cost keys like "kling-v3-standard-8s" auto-resolve). Route per the slates-model-selection skill: Kling std = general-purpose DEFAULT, Seedance 2 = premium physics/effects/hero tier, Veo = native-synced-audio niche only (16:9, 4/6/8s) — never the default. All are VIDEO-only. For per-call cost, call slates_estimate_generation_cost — never quote prices from memory.'),
1373
+ model: z.string().describe('One of: kling-v3.0-std | kling-v3.0-pro | kling-v3.0-omni | seedance-2 | veo-3.1-fast | veo-3.1-standard | omni-flash. Pass the BASE id — duration and videoResolution are separate params (registry cost keys like "kling-v3-standard-8s" auto-resolve). Route per the slates-model-selection skill: Kling std = general-purpose DEFAULT, Seedance 2 = premium physics/effects/hero tier, Veo = native-synced-audio niche only (16:9, 4/6/8s) — never the default, omni-flash = cheap 720p tier with audio included (3-10s, 16:9/9:16; t2v, single-start-frame i2v, or up to 7 reference images; no last frame / video / audio refs). All are VIDEO-only. For per-call cost, call slates_estimate_generation_cost — never quote prices from memory.'),
1327
1374
  projectId: z.string().uuid().optional().describe('Save into this Slates project. Strongly recommended — the desktop UI shows a progress card live and the asset appears when complete.'),
1328
1375
  aspectRatio: z.enum(['1:1', '16:9', '9:16', '4:3', '3:4', '21:9', '9:21', '4:5', '5:4', '2:3', '3:2']).optional().describe('Veo locks to 16:9 — passing anything else will be ignored or fail. Kling/Seedance support all.'),
1329
- duration: z.number().int().min(4).max(15).optional().describe('Seconds. Kling: 5-15. Veo: 4, 6, or 8 only (4K only at 8s). Seedance: 4-15. Default 5 if omitted but always be explicit (cost scales linearly).'),
1376
+ duration: z.number().int().min(3).max(15).optional().describe('Seconds. Kling: 5-15. Veo: 4, 6, or 8 only (4K only at 8s). Seedance: 4-15. Omni Flash: 3-10. Default 5 if omitted but always be explicit (cost scales linearly).'),
1330
1377
  videoResolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe('Veo + Seedance. Seedance: 480p/720p/1080p/4K (default 1080p; 4K is native, the most expensive). Veo: 720p/1080p same price, 4K more (8s only).'),
1331
1378
  firstFrameAssetId: z.string().optional().describe('Starting frame for image-to-video: asset UUID or badge code ("IMG-A8") — codes resolve against the project at call time, so a code the user just spoke is always safe to pass.'),
1332
1379
  lastFrameAssetId: z.string().optional().describe('Ending frame (UUID or badge code). Veo and Seedance only. Pairs with firstFrameAssetId for guided transitions.'),
1333
- ingredientAssetIds: z.array(z.string()).max(9).optional().describe('Visual reference / ingredient assets (UUIDs or badge codes) for Kling Omni or Seedance. Up to 9 (Seedance) or 4 (Kling).'),
1380
+ ingredientAssetIds: z.array(z.string()).max(9).optional().describe('Visual reference / ingredient assets (UUIDs or badge codes) for Kling Omni, Seedance, or Omni Flash. Up to 9 (Seedance), 4 (Kling), or 7 (Omni Flash, combined across all ref params).'),
1334
1381
  characterAssetIds: z.array(z.string()).optional().describe('Character sheet assets (UUIDs or badge codes) — keeps a character consistent across the shot.'),
1335
1382
  environmentAssetIds: z.array(z.string()).optional().describe('Environment grid assets (UUIDs or badge codes) — keeps a location/setting consistent across the shot.'),
1336
1383
  styleAssetIds: z.array(z.string()).optional().describe('Style reference assets (UUIDs or badge codes) — locks the visual style of the shot.'),
@@ -1345,7 +1392,7 @@ export const generateVideo = {
1345
1392
  realFaceConsent: z.boolean().optional().describe('MANDATORY with seedanceRealFace: set true ONLY after the user has explicitly confirmed they hold the rights/consent to this person\'s likeness and it doesn\'t impersonate or misrepresent them. The generation is refused without it. Public figures/celebrities fail on every route.'),
1346
1393
  negativePrompt: z.string().optional(),
1347
1394
  background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
1348
- confirm: z.boolean().optional().describe('Set true after explicit user OK to bypass the >$0.50 cost confirm gate (which fires for almost every video gen since they\'re expensive).'),
1395
+ confirm: z.boolean().optional().describe('Set true after explicit user OK to bypass the confirm gate (which fires for almost every video gen since they\'re expensive).'),
1349
1396
  }),
1350
1397
  async run(input, ctx) {
1351
1398
  // Resolve the model FIRST — forgiving normalization (cost keys, alias
@@ -1375,7 +1422,7 @@ export const generateVideo = {
1375
1422
  return ok({
1376
1423
  requires_clarification: true,
1377
1424
  missing: ['projectId'],
1378
- message: 'projectId is required for video generation. Use slates_list_projects to find one or slates_create_project to make a new one. Video gens cost $0.50-$2.00+ per call — they need to land in a project so the user sees the progress card and the result.',
1425
+ message: 'projectId is required for video generation. Use slates_list_projects to find one or slates_create_project to make a new one. Video gens cost tens to a few hundred credits per call — they need to land in a project so the user sees the progress card and the result.',
1379
1426
  });
1380
1427
  }
1381
1428
  if (!input.aspectRatio || !input.duration) {
@@ -1389,10 +1436,41 @@ export const generateVideo = {
1389
1436
  missing,
1390
1437
  message: `Missing required field(s): ${missing.join(', ')}. ` +
1391
1438
  `Read the slates-cost-discipline + ${promptingSkillFor(input.model)} skills, ` +
1392
- `or ask the user. Veo locks to 16:9. Kling/Seedance support 1:1 16:9 9:16 4:3 3:4 21:9. ` +
1393
- `Duration: Kling 5-15s, Veo 4/6/8s (4K only at 8s), Seedance 4-15s. Cost scales linearly with duration.`,
1439
+ `or ask the user. Veo locks to 16:9. Kling/Seedance support 1:1 16:9 9:16 4:3 3:4 21:9. Omni Flash: 16:9/9:16 only. ` +
1440
+ `Duration: Kling 5-15s, Veo 4/6/8s (4K only at 8s), Seedance 4-15s, Omni Flash 3-10s. Cost scales linearly with duration.`,
1394
1441
  });
1395
1442
  }
1443
+ // Omni Flash: 3-10s, 720p only — t2v, single-start-frame i2v, or ref2v
1444
+ // with up to 7 reference IMAGES. No last frame, no video/audio refs.
1445
+ // Validate up front so the agent gets an actionable message instead of
1446
+ // a registry throw.
1447
+ if (input.model === 'omni-flash') {
1448
+ if (input.duration < 3 || input.duration > 10) {
1449
+ return ok({
1450
+ requires_clarification: true,
1451
+ missing: ['duration'],
1452
+ message: `Omni Flash supports 3-10 seconds (you passed ${input.duration}s). Pick a duration in that range.`,
1453
+ });
1454
+ }
1455
+ if (input.lastFrameAssetId || input.videoReferenceAssetId || input.audioReferenceAssetId) {
1456
+ return ok({
1457
+ requires_clarification: true,
1458
+ missing: [],
1459
+ message: 'Omni Flash takes a prompt, an optional start frame, and up to 7 reference IMAGES — last frames and video/audio references are not supported. Drop those, or switch to seedance-2 (video/audio refs, last frame) or veo (last frame).',
1460
+ });
1461
+ }
1462
+ const refCount = (input.ingredientAssetIds?.length ?? 0) +
1463
+ (input.characterAssetIds?.length ?? 0) +
1464
+ (input.environmentAssetIds?.length ?? 0) +
1465
+ (input.styleAssetIds?.length ?? 0);
1466
+ if (refCount > 7) {
1467
+ return ok({
1468
+ requires_clarification: true,
1469
+ missing: [],
1470
+ message: `Omni Flash takes at most 7 reference images combined (you passed ${refCount}). Trim the list.`,
1471
+ });
1472
+ }
1473
+ }
1396
1474
  // Veo exists only at discrete durations 4/6/8s, and 4K only at 8s.
1397
1475
  // Validate up front so the agent gets an actionable message instead of
1398
1476
  // a generic "Model variant not in registry" throw from the cost lookup.
@@ -1480,9 +1558,9 @@ export const generateVideo = {
1480
1558
  const entry = registry.models.find((m) => m.model === costKey);
1481
1559
  if (!entry) {
1482
1560
  throw new Error(`Model variant not in registry: ${costKey}. ` +
1483
- `Available video models: ${registry.models.filter((m) => m.model.startsWith('kling') || m.model.startsWith('veo') || m.model.startsWith('seedance')).map((m) => m.model).slice(0, 20).join(', ')}`);
1561
+ `Available video models: ${registry.models.filter((m) => m.model.startsWith('kling') || m.model.startsWith('veo') || m.model.startsWith('seedance') || m.model.startsWith('omni-flash')).map((m) => m.model).slice(0, 20).join(', ')}`);
1484
1562
  }
1485
- const totalCents = entry.cost_cents;
1563
+ const totalCents = creditCost(entry);
1486
1564
  // Pre-flight confirm gate. Fires when:
1487
1565
  // (a) cost > $0.50 (the cost gate), OR
1488
1566
  // (b) any reference assets are involved (the look-first gate)
@@ -1511,7 +1589,7 @@ export const generateVideo = {
1511
1589
  referenceRefs.push({ id, type: 'image', role: 'style' });
1512
1590
  }
1513
1591
  const hasReferences = referenceRefs.length > 0;
1514
- if ((totalCents > 50 || hasReferences) && !input.confirm) {
1592
+ if ((totalCents > CONFIRM_CREDITS || hasReferences) && !input.confirm) {
1515
1593
  const previews = hasReferences ? await previewAssets(ctx, referenceRefs) : [];
1516
1594
  const refLines = previews.map((p) => ` - ${p.role}: ${p.ref}`).join('\n');
1517
1595
  const refSummary = hasReferences
@@ -1521,7 +1599,7 @@ export const generateVideo = {
1521
1599
  const refImages = previews.flatMap((p) => p.images);
1522
1600
  return {
1523
1601
  text: `Pre-flight for ${input.duration}s ${input.model} (${costKey}): ` +
1524
- `$${(totalCents / 100).toFixed(2)} (${totalCents}¢).` +
1602
+ `${fmtCredits(totalCents)}.` +
1525
1603
  refSummary +
1526
1604
  `\n\nWhen ready, re-call slates_generate_video with confirm=true and the (possibly revised) prompt.`,
1527
1605
  images: refImages,
@@ -1530,7 +1608,7 @@ export const generateVideo = {
1530
1608
  model: input.model,
1531
1609
  variant: costKey,
1532
1610
  estimated_cents: totalCents,
1533
- estimated_dollars: (totalCents / 100).toFixed(2),
1611
+ estimated_credits: totalCents,
1534
1612
  references: previews.map((p) => ({
1535
1613
  role: p.role,
1536
1614
  ref: p.ref,
@@ -1581,7 +1659,7 @@ export const generateVideo = {
1581
1659
  variant: costKey,
1582
1660
  projectId: input.projectId,
1583
1661
  cost_cents: totalCents,
1584
- cost_dollars: (totalCents / 100).toFixed(2),
1662
+ cost_credits: totalCents,
1585
1663
  references: refInputs.map(({ ref, role }) => ({
1586
1664
  role,
1587
1665
  ...(resolvedRefs.get(ref) ?? { id: ref, code: null, label: null }),
@@ -1590,7 +1668,7 @@ export const generateVideo = {
1590
1668
  }
1591
1669
  return {
1592
1670
  text: `Generated ${input.duration}s ${input.model} video into project ${input.projectId} ` +
1593
- `for $${(totalCents / 100).toFixed(2)} (${totalCents}¢). ` +
1671
+ `for ${fmtCredits(totalCents)}. ` +
1594
1672
  `Prompt: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"` +
1595
1673
  (refEcho ? ` ${refEcho}` : ''),
1596
1674
  data: {
@@ -1600,7 +1678,7 @@ export const generateVideo = {
1600
1678
  aspectRatio: input.aspectRatio,
1601
1679
  duration: input.duration,
1602
1680
  cost_cents: totalCents,
1603
- cost_dollars: (totalCents / 100).toFixed(2),
1681
+ cost_credits: totalCents,
1604
1682
  asset: result.asset,
1605
1683
  generationId: result.generationId,
1606
1684
  },
@@ -1621,8 +1699,8 @@ export const generateLipSync = {
1621
1699
  ttsLanguage: z.enum(['EN', 'ZH', 'JA', 'KO', 'ES']).optional().describe('Kling engine only — TTS language. Default EN.'),
1622
1700
  ttsSpeed: z.number().min(0.5).max(2).optional().describe('Kling engine only — TTS speech rate. Default 1.0. Range 0.5-2.0.'),
1623
1701
  audioFilePath: z.string().optional().describe('Required when audioMethod=upload. Absolute path to the audio file on the user\'s machine (mp3, wav, m4a).'),
1624
- avatarModel: z.enum(['avatar-standard', 'avatar-pro']).optional().describe('Kling engine, image-source only. avatar-standard ($0.42/5s) for general use. avatar-pro ($0.86/5s) for sharper face fidelity.'),
1625
- klingProvider: z.enum(['fal', 'kling']).optional().describe('Kling engine only — provider routing. "fal" (default) uses Slates credits. "kling" uses the user\'s own BYOK Kling key if configured.'),
1702
+ avatarModel: z.enum(['avatar-standard', 'avatar-pro']).optional().describe('Kling engine, image-source only. avatar-standard (~14 credits/5s) for general use. avatar-pro (~29 credits/5s) for sharper face fidelity.'),
1703
+ klingProvider: z.enum(['fal', 'kling']).optional().describe('Kling engine only — provider routing. Leave unset: all agent generations bill Slates credits (BYOK is retired).'),
1626
1704
  engine: z.enum(['kling', 'seedance-2']).optional().describe('Default kling (cheap utility). seedance-2 = premium single-pass: natural speech generated in the video, voice cloned from a video source, audio included. Credits only.'),
1627
1705
  videoResolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe('Seedance engine only. Default 1080p.'),
1628
1706
  aspectRatio: z.string().optional().describe('Seedance engine only. Default 16:9.'),
@@ -1632,7 +1710,7 @@ export const generateLipSync = {
1632
1710
  sourceSeconds: z.number().optional().describe('Seedance engine + sourceType=video: the source clip\'s duration in seconds (from the asset listing). Feeds the vref cost key (input+output billing).'),
1633
1711
  audioSeconds: z.number().optional().describe('Seedance engine + audioMethod=upload: the audio file\'s duration in seconds — sets the output length (4-15s).'),
1634
1712
  background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
1635
- confirm: z.boolean().optional().describe('Set true to bypass the >$0.50 cost confirm gate. Required for avatar-pro.'),
1713
+ confirm: z.boolean().optional().describe('Set true to bypass the confirm gate. Required for avatar-pro.'),
1636
1714
  }),
1637
1715
  async run(input, ctx) {
1638
1716
  if (input.audioMethod === 'tts' && !input.ttsText) {
@@ -1696,12 +1774,12 @@ export const generateLipSync = {
1696
1774
  const entry = registry.models.find((m) => m.model === costKey);
1697
1775
  if (!entry)
1698
1776
  throw new Error(`Model variant not in registry: ${costKey}`);
1699
- const totalCents = entry.cost_cents;
1777
+ const totalCents = creditCost(entry);
1700
1778
  // Cost confirm gate. Lip-sync is mechanical — the model re-syncs the
1701
1779
  // user-chosen source to the user-chosen audio. The agent doesn't
1702
1780
  // write a prompt that depends on what the source looks like, so we
1703
1781
  // skip the inline preview and just announce the source code in text.
1704
- if (totalCents > 50 && !input.confirm) {
1782
+ if (totalCents > CONFIRM_CREDITS && !input.confirm) {
1705
1783
  const sourceRef = await lookupAssetRef(ctx.desktop(), input.sourceAssetId);
1706
1784
  const audioPreview = input.audioMethod === 'tts'
1707
1785
  ? `Audio: TTS — "${(input.ttsText ?? '').slice(0, 120)}"`
@@ -1710,9 +1788,9 @@ export const generateLipSync = {
1710
1788
  requires_confirm: true,
1711
1789
  variant: costKey,
1712
1790
  estimated_cents: totalCents,
1713
- estimated_dollars: (totalCents / 100).toFixed(2),
1791
+ estimated_credits: totalCents,
1714
1792
  source_ref: sourceRef,
1715
- message: `Cost: $${(totalCents / 100).toFixed(2)} for ${isSeedance ? `${seedanceDuration}s Seedance` : '5s'} lip-sync (${costKey}). ` +
1793
+ message: `Cost: ${fmtCredits(totalCents)} for ${isSeedance ? `${seedanceDuration}s Seedance` : '5s'} lip-sync (${costKey}). ` +
1716
1794
  `Source: ${sourceRef}. ${audioPreview}. ` +
1717
1795
  `Re-call with confirm=true after the user explicitly OKs the spend. ` +
1718
1796
  `When discussing with the user, refer to the source by its code (matches the gallery badge).`,
@@ -1761,12 +1839,12 @@ export const generateLipSync = {
1761
1839
  projectId: input.projectId,
1762
1840
  sourceAssetId: input.sourceAssetId,
1763
1841
  cost_cents: totalCents,
1764
- cost_dollars: (totalCents / 100).toFixed(2),
1842
+ cost_credits: totalCents,
1765
1843
  });
1766
1844
  }
1767
1845
  return {
1768
1846
  text: `Generated ${isSeedance ? `${seedanceDuration}s` : '5s'} lip-sync (${costKey}) into project ${input.projectId} ` +
1769
- `for $${(totalCents / 100).toFixed(2)} (${totalCents}¢). ` +
1847
+ `for ${fmtCredits(totalCents)}. ` +
1770
1848
  (input.audioMethod === 'tts'
1771
1849
  ? `Spoken: "${(input.ttsText ?? '').slice(0, 60)}${(input.ttsText ?? '').length > 60 ? '...' : ''}"`
1772
1850
  : `Audio: ${input.audioFilePath}`),
@@ -1776,7 +1854,7 @@ export const generateLipSync = {
1776
1854
  sourceType: input.sourceType,
1777
1855
  sourceAssetId: input.sourceAssetId,
1778
1856
  cost_cents: totalCents,
1779
- cost_dollars: (totalCents / 100).toFixed(2),
1857
+ cost_credits: totalCents,
1780
1858
  asset: result.asset,
1781
1859
  generationId: result.generationId,
1782
1860
  },
@@ -1791,7 +1869,7 @@ export const generateMotionTransfer = {
1791
1869
  projectId: z.string().uuid().describe('Slates project. Both source and target assets must live here.'),
1792
1870
  sourceVideoAssetId: z.string().uuid().describe('Asset id of the reference video — its motion will be retargeted onto the target image. Must already exist in the project. Seedance engine: 2-15s clips only.'),
1793
1871
  targetImageAssetId: z.string().uuid().describe('Asset id of the target image (the character that will perform the motion). Must already exist in the project.'),
1794
- motionModel: z.enum(['kling-mc-std', 'kling-mc-pro', 'seedance-2']).optional().describe('kling-mc-std ($0.95) general motion; kling-mc-pro ($1.26) cleaner anatomy — default. seedance-2 = premium single-pass lane (prompt-driven, native audio, input+output-second billing) — pick when motion fidelity or audio matters.'),
1872
+ motionModel: z.enum(['kling-mc-std', 'kling-mc-pro', 'seedance-2']).optional().describe('kling-mc-std (~32 credits) general motion; kling-mc-pro (~42 credits) cleaner anatomy — default. seedance-2 = premium single-pass lane (prompt-driven, native audio, input+output-second billing) — pick when motion fidelity or audio matters.'),
1795
1873
  characterOrientation: z.enum(['video', 'image']).optional().describe('Kling only. "video" = use the source video\'s framing. "image" = use the target image\'s framing. Default video.'),
1796
1874
  prompt: z.string().optional().describe('Kling: optional refinement. Seedance: THE driver — describe what the character does with the motion from the clip (ordinal references: "the character from image 1 performs the motion from video 1"). A sensible default recipe is used if omitted. Read slates-prompting-motion-transfer.'),
1797
1875
  klingProvider: z.enum(['fal', 'kling']).optional().describe('Kling engine only — provider routing. "fal" (default) uses Slates credits.'),
@@ -1803,7 +1881,7 @@ export const generateMotionTransfer = {
1803
1881
  realFaceConsent: z.boolean().optional().describe('MANDATORY with seedanceRealFace — set true only after the user explicitly confirms they hold rights/consent to the likeness.'),
1804
1882
  sourceVideoSeconds: z.number().optional().describe('Seedance engine: the driving clip\'s duration in seconds (from the asset listing, 2-15s). Feeds the vref cost key (input+output billing).'),
1805
1883
  background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
1806
- confirm: z.boolean().optional().describe('Set true to bypass the >$0.50 confirm gate. Required — both tiers exceed.'),
1884
+ confirm: z.boolean().optional().describe('Set true to bypass the confirm gate. Required — both tiers exceed.'),
1807
1885
  }),
1808
1886
  async run(input, ctx) {
1809
1887
  const motionModel = input.motionModel ?? 'kling-mc-pro';
@@ -1843,12 +1921,12 @@ export const generateMotionTransfer = {
1843
1921
  const entry = registry.models.find((m) => m.model === costKey);
1844
1922
  if (!entry)
1845
1923
  throw new Error(`Model variant not in registry: ${costKey}`);
1846
- const totalCents = entry.cost_cents;
1924
+ const totalCents = creditCost(entry);
1847
1925
  // Cost confirm gate. Motion transfer is mechanical — the model
1848
1926
  // applies source motion to target image deterministically. We don't
1849
1927
  // burn tokens previewing assets the user already chose; codes in the
1850
1928
  // text are enough to keep the chat unambiguous.
1851
- if (totalCents > 50 && !input.confirm) {
1929
+ if (totalCents > CONFIRM_CREDITS && !input.confirm) {
1852
1930
  const desktop = ctx.desktop();
1853
1931
  const [source, target] = await Promise.all([
1854
1932
  lookupAssetRef(desktop, input.sourceVideoAssetId),
@@ -1858,12 +1936,12 @@ export const generateMotionTransfer = {
1858
1936
  requires_confirm: true,
1859
1937
  variant: costKey,
1860
1938
  estimated_cents: totalCents,
1861
- estimated_dollars: (totalCents / 100).toFixed(2),
1939
+ estimated_credits: totalCents,
1862
1940
  source_ref: source,
1863
1941
  target_ref: target,
1864
- message: `Cost: $${(totalCents / 100).toFixed(2)} for ${isSeedance ? `${seedanceDuration}s Seedance motion transfer` : `5s ${motionModel}`} (${costKey}). ` +
1942
+ message: `Cost: ${fmtCredits(totalCents)} for ${isSeedance ? `${seedanceDuration}s Seedance motion transfer` : `5s ${motionModel}`} (${costKey}). ` +
1865
1943
  `Transferring motion from ${source} onto ${target}. ` +
1866
- `Re-call with confirm=true after the user explicitly OKs the spend${isSeedance ? '' : ', or pick kling-mc-std to save $0.31'}. ` +
1944
+ `Re-call with confirm=true after the user explicitly OKs the spend${isSeedance ? '' : ', or pick kling-mc-std to save ~10 credits'}. ` +
1867
1945
  `When discussing with the user, refer to the assets by those codes — they'll match the gallery badges.`,
1868
1946
  });
1869
1947
  }
@@ -1908,12 +1986,12 @@ export const generateMotionTransfer = {
1908
1986
  sourceVideoAssetId: input.sourceVideoAssetId,
1909
1987
  targetImageAssetId: input.targetImageAssetId,
1910
1988
  cost_cents: totalCents,
1911
- cost_dollars: (totalCents / 100).toFixed(2),
1989
+ cost_credits: totalCents,
1912
1990
  });
1913
1991
  }
1914
1992
  return {
1915
1993
  text: `Generated ${isSeedance ? `${seedanceDuration}s` : '5s'} motion transfer (${motionModel}) into project ${input.projectId} ` +
1916
- `for $${(totalCents / 100).toFixed(2)} (${totalCents}¢).` +
1994
+ `for ${fmtCredits(totalCents)}.` +
1917
1995
  (input.prompt ? ` Prompt: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"` : ''),
1918
1996
  data: {
1919
1997
  variant: costKey,
@@ -1922,7 +2000,7 @@ export const generateMotionTransfer = {
1922
2000
  sourceVideoAssetId: input.sourceVideoAssetId,
1923
2001
  targetImageAssetId: input.targetImageAssetId,
1924
2002
  cost_cents: totalCents,
1925
- cost_dollars: (totalCents / 100).toFixed(2),
2003
+ cost_credits: totalCents,
1926
2004
  asset: result.asset,
1927
2005
  generationId: result.generationId,
1928
2006
  },
@@ -1932,15 +2010,15 @@ export const generateMotionTransfer = {
1932
2010
  // ── Edit video (Kling O3 video-to-video) ────────────────────────
1933
2011
  export const editVideo = {
1934
2012
  id: 'slates_edit_video',
1935
- description: 'Edit an EXISTING video clip with one instruction via Kling O3 video-to-video edit — character swap, environment change, style transfer — in one pass, no masking. Original motion, camera, and audio are preserved; only what the prompt names changes. Use when a clip is ~90% right (fix it, don\'t re-roll it) or to AI-edit the user\'s own footage. Source clip constraints: 3–15s, 720–3840px, MP4/MOV. Cost = per second of OUTPUT (≈ clip length, rounded UP to the next second): kling-v3.0-omni-edit ≈ 19¢/s, kling-v3.0-omni-pro-edit ≈ 25¢/s. Subjects to swap IN go as characterAssetIds (frontal + angle images become Kling elements); style refs as styleAssetIds; max 4 combined. The edited clip saves as a NEW asset linked to its parent (chain edits freely). Routing: Kling edit is the default edit tool (element lock + audio intact); prefer Seedance edit/relocate only for style-transfer-heavy jobs — see slates-model-selection. Prompting: slates-prompting-kling-v3 §Edit.',
2013
+ description: 'Edit an EXISTING video clip with one instruction — character swap, environment change, style transfer — in one pass, no masking. Original motion, camera, and audio are preserved; only what the prompt names changes. Use when a clip is ~90% right (fix it, don\'t re-roll it) or to AI-edit the user\'s own footage. Engines: Kling O3 edit (default; 3–15s clips, 720–3840px, subject/style refs via elements) or omni-flash-edit (Gemini Omni Flash; 3–10s clips, 720p output, PROMPT-ONLY — no refs, cheapest seat). Cost = per second of OUTPUT (≈ clip length, rounded UP to the next second): omni-flash-edit ≈ 19¢/s ≈ kling-v3.0-omni-edit ≈ 19¢/s, kling-v3.0-omni-pro-edit ≈ 25¢/s. Subjects to swap IN go as characterAssetIds (frontal + angle images become Kling elements — Kling models only); style refs as styleAssetIds; max 4 combined. The edited clip saves as a NEW asset linked to its parent (chain edits freely). Routing: Kling edit is the default edit tool (element lock + audio intact); omni-flash-edit for cheap prompt-only footage-synced swaps; prefer Seedance edit/relocate only for style-transfer-heavy jobs — see slates-model-selection. Prompting: slates-prompting-kling-v3 §Edit / slates-prompting-omni-flash.',
1936
2014
  input: z.object({
1937
2015
  projectId: z.string().uuid().describe('Project the source clip lives in.'),
1938
- sourceVideoAssetId: z.string().describe('The VIDEO asset to edit — UUID or badge code ("VID-V3", bare "V3"); codes resolve against the project at call time. 3–15s clips only.'),
1939
- prompt: z.string().min(1).max(2500).describe('The change, not the whole scene — e.g. "replace the man with @marcus", "make it a rainy night", "turn the street into a neon Tokyo alley". Mention subjects with @name; the transport compiles them to Kling\'s @ElementN notation.'),
1940
- model: z.enum(['kling-v3.0-omni-edit', 'kling-v3.0-omni-pro-edit']).optional().describe('Default kling-v3.0-omni-edit. Pro (~25¢/s vs ~19¢/s) only for hero shots where fidelity matters.'),
1941
- characterAssetIds: z.array(z.string()).max(4).optional().describe('Subject/element image assets to swap IN (UUIDs or badge codes). Each becomes a Kling element (@ElementN).'),
1942
- styleAssetIds: z.array(z.string()).max(4).optional().describe('Style/appearance reference images (@ImageN). Max 4 combined with characterAssetIds.'),
1943
- keepAudio: z.boolean().optional().describe('Preserve the original audio track (default true).'),
2016
+ sourceVideoAssetId: z.string().describe('The VIDEO asset to edit — UUID or badge code ("VID-V3", bare "V3"); codes resolve against the project at call time. Kling: 3–15s clips; omni-flash-edit: 3–10s.'),
2017
+ prompt: z.string().min(1).max(2500).describe('The change, not the whole scene — e.g. "replace the man with @marcus", "make it a rainy night", "turn the street into a neon Tokyo alley". Mention subjects with @name; the transport compiles them to Kling\'s @ElementN notation (Kling models). For omni-flash-edit keep it simple and add "Keep everything else the same."'),
2018
+ model: z.enum(['kling-v3.0-omni-edit', 'kling-v3.0-omni-pro-edit', 'omni-flash-edit']).optional().describe('Default kling-v3.0-omni-edit. Pro (~25¢/s vs ~19¢/s) only for hero shots where fidelity matters. omni-flash-edit (~19¢/s, 720p, 3–10s) for prompt-only edits — it takes NO character/style refs.'),
2019
+ characterAssetIds: z.array(z.string()).max(4).optional().describe('Subject/element image assets to swap IN (UUIDs or badge codes). Each becomes a Kling element (@ElementN). KLING MODELS ONLY — rejected on omni-flash-edit.'),
2020
+ styleAssetIds: z.array(z.string()).max(4).optional().describe('Style/appearance reference images (@ImageN). Max 4 combined with characterAssetIds. KLING MODELS ONLY — rejected on omni-flash-edit.'),
2021
+ keepAudio: z.boolean().optional().describe('Preserve the original audio track (default true; Kling models only — omni-flash-edit output carries its own audio).'),
1944
2022
  background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
1945
2023
  confirm: z.boolean().optional().describe('Set true to bypass the cost confirm gate after the user OKs the spend.'),
1946
2024
  }),
@@ -1951,6 +2029,12 @@ export const editVideo = {
1951
2029
  await desktop.requireCapability('background-generation', 'background generation');
1952
2030
  }
1953
2031
  const model = input.model ?? 'kling-v3.0-omni-edit';
2032
+ const isOmniFlashEdit = model === 'omni-flash-edit';
2033
+ // Kling edit: 3–15s source clips; Omni Flash edit: 3–10s.
2034
+ const maxClipSeconds = isOmniFlashEdit ? 10 : 15;
2035
+ if (isOmniFlashEdit && ((input.characterAssetIds?.length ?? 0) > 0 || (input.styleAssetIds?.length ?? 0) > 0)) {
2036
+ throw new Error('omni-flash-edit is prompt-only — it takes no character/style reference images. Drop the refs, or switch to kling-v3.0-omni-edit which supports elements.');
2037
+ }
1954
2038
  // Resolve refs (UUIDs or badge codes) against the project AT CALL TIME.
1955
2039
  const refInputs = [
1956
2040
  { ref: input.sourceVideoAssetId, role: 'source clip' },
@@ -1978,27 +2062,28 @@ export const editVideo = {
1978
2062
  if (!Number.isFinite(clipSeconds) || clipSeconds <= 0) {
1979
2063
  throw new Error('Source clip has no recorded duration — cannot quote the edit. Re-import the clip or pick another.');
1980
2064
  }
1981
- if (clipSeconds > 15.05 || clipSeconds < 2.95) {
1982
- throw new Error(`Source clip is ${clipSeconds.toFixed(1)}s — Kling O3 edit accepts 3–15s. Trim it first (agents can pre-trim on the timeline).`);
2065
+ if (clipSeconds > maxClipSeconds + 0.05 || clipSeconds < 2.95) {
2066
+ throw new Error(`Source clip is ${clipSeconds.toFixed(1)}s — ${model} accepts 3–${maxClipSeconds}s. ` +
2067
+ `Trim it first with slates_trim_video (e.g. inSec 0, outSec ${maxClipSeconds}), then edit the trimmed clip.`);
1983
2068
  }
1984
- const billedSeconds = Math.min(15, Math.max(3, Math.ceil(clipSeconds - 0.05)));
1985
- const costKey = klingEditCostKey(model, billedSeconds);
2069
+ const billedSeconds = Math.min(maxClipSeconds, Math.max(3, Math.ceil(clipSeconds - 0.05)));
2070
+ const costKey = isOmniFlashEdit ? omniFlashEditCostKey(billedSeconds) : klingEditCostKey(model, billedSeconds);
1986
2071
  const cloud = ctx.cloud();
1987
2072
  const registry = await cloud.get('/api/agent/models');
1988
2073
  const entry = registry.models.find((m) => m.model === costKey);
1989
2074
  if (!entry)
1990
2075
  throw new Error(`Model variant not in registry: ${costKey}`);
1991
- const totalCents = entry.cost_cents;
2076
+ const totalCents = creditCost(entry);
1992
2077
  // Confirm gate — look-first: preview the source clip + refs so the LLM
1993
2078
  // sees what it's editing before committing spend (mirrors generateVideo).
1994
- if ((totalCents > 50 || refInputs.length > 1) && !input.confirm) {
2079
+ if ((totalCents > CONFIRM_CREDITS || refInputs.length > 1) && !input.confirm) {
1995
2080
  const previews = await previewAssets(ctx, [
1996
2081
  { id: sourceId, type: 'video', role: 'source clip' },
1997
2082
  ...characterAssetIds.map((id) => ({ id, type: 'image', role: 'subject element' })),
1998
2083
  ...styleAssetIds.map((id) => ({ id, type: 'image', role: 'style' })),
1999
2084
  ]);
2000
2085
  return {
2001
- text: `Cost: $${(totalCents / 100).toFixed(2)} (${totalCents}¢) to edit a ${billedSeconds}s clip with ${model} (${costKey}). ` +
2086
+ text: `Cost: ${fmtCredits(totalCents)} to edit a ${billedSeconds}s clip with ${model} (${costKey}). ` +
2002
2087
  `${refEcho} Re-call with confirm=true after the user explicitly OKs the spend.`,
2003
2088
  images: previews.flatMap((p) => p.images),
2004
2089
  data: {
@@ -2008,7 +2093,7 @@ export const editVideo = {
2008
2093
  billed_seconds: billedSeconds,
2009
2094
  clip_seconds: clipSeconds,
2010
2095
  estimated_cents: totalCents,
2011
- estimated_dollars: (totalCents / 100).toFixed(2),
2096
+ estimated_credits: totalCents,
2012
2097
  references: previews.map((p) => ({ ref: p.ref, role: p.role, ...p.meta })),
2013
2098
  },
2014
2099
  };
@@ -2033,12 +2118,12 @@ export const editVideo = {
2033
2118
  projectId: input.projectId,
2034
2119
  sourceVideoAssetId: sourceId,
2035
2120
  cost_cents: totalCents,
2036
- cost_dollars: (totalCents / 100).toFixed(2),
2121
+ cost_credits: totalCents,
2037
2122
  }, refEcho);
2038
2123
  }
2039
2124
  return {
2040
2125
  text: `Edited ${billedSeconds}s clip saved as a new asset in project ${input.projectId} ` +
2041
- `for $${(totalCents / 100).toFixed(2)} (${totalCents}¢) via ${model}. ` +
2126
+ `for ${fmtCredits(totalCents)} via ${model}. ` +
2042
2127
  `Edit: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"` +
2043
2128
  (refEcho ? ` ${refEcho}` : ''),
2044
2129
  data: {
@@ -2048,13 +2133,48 @@ export const editVideo = {
2048
2133
  sourceVideoAssetId: sourceId,
2049
2134
  billed_seconds: billedSeconds,
2050
2135
  cost_cents: totalCents,
2051
- cost_dollars: (totalCents / 100).toFixed(2),
2136
+ cost_credits: totalCents,
2052
2137
  asset: result.asset,
2053
2138
  generationId: result.generationId,
2054
2139
  },
2055
2140
  };
2056
2141
  },
2057
2142
  };
2143
+ // ── Trim a video to an exact window (fit-to-model primitive) ────
2144
+ export const trimVideo = {
2145
+ id: 'slates_trim_video',
2146
+ description: 'Trim a video asset to an exact [inSec, outSec] window and save the result as a NEW clip linked to the original (the original is untouched). This is the fit-to-model primitive: an 11s clip will not run on omni-flash-edit (3–10s) or Kling edit (3–15s), and a Seedance video reference must be 2–15s — trim it first, then edit/relocate the trimmed clip. EXACT re-encode (not a keyframe-snapped cut) so the result honors hard duration caps to the frame; any phone rotation flag is baked into the pixels in the same pass. The new clip lands with correct duration/width/height immediately. inSec defaults to 0.',
2147
+ input: z.object({
2148
+ projectId: z.string().uuid().describe('Project the clip lives in.'),
2149
+ assetId: z
2150
+ .string()
2151
+ .describe('The VIDEO asset to trim — UUID or badge code ("VID-V3", bare "V3"); resolves against the project at call time.'),
2152
+ inSec: z.number().min(0).optional().describe('Trim start in seconds (default 0).'),
2153
+ outSec: z.number().positive().describe('Trim end in seconds. Must be greater than inSec.'),
2154
+ }),
2155
+ async run(input, ctx) {
2156
+ const desktop = ctx.desktop();
2157
+ const resolved = await resolveAssetRefs(ctx, input.projectId, [input.assetId]);
2158
+ const assetId = resolved.get(input.assetId)?.id ?? input.assetId;
2159
+ const inSec = input.inSec ?? 0;
2160
+ if (input.outSec - inSec < 0.05) {
2161
+ throw new Error('outSec must be at least ~0.1s after inSec.');
2162
+ }
2163
+ const r = await desktop.post('/agent/assets/trim-video', {
2164
+ assetId,
2165
+ inSec,
2166
+ outSec: input.outSec,
2167
+ });
2168
+ const a = r.asset;
2169
+ const name = a?.code ?? a?.id ?? 'new clip';
2170
+ return {
2171
+ text: `Trimmed clip saved as ${name}${a?.label ? ` — ${a.label}` : ''} ` +
2172
+ `(${(input.outSec - inSec).toFixed(1)}s, ${inSec.toFixed(1)}–${input.outSec.toFixed(1)}s). ` +
2173
+ `Edit or generate from it by its new id/code.`,
2174
+ data: { asset: r.asset },
2175
+ };
2176
+ },
2177
+ };
2058
2178
  // ── Generation status (background mode) ─────────────────────────
2059
2179
  export const getGenerationStatus = {
2060
2180
  id: 'slates_get_generation_status',
@@ -2086,7 +2206,7 @@ export const getGenerationStatus = {
2086
2206
  // agent acts on, with the EXACT billed cost front and center.
2087
2207
  return ok({
2088
2208
  status,
2089
- cost_cents: g.cost ?? null,
2209
+ cost_credits: g.cost != null ? creditsFromDollars(g.cost) : null,
2090
2210
  error: g.error ?? null,
2091
2211
  model: g.model,
2092
2212
  completed_at: g.completedAt ?? null,
@@ -2561,6 +2681,8 @@ function resolveGuideTopic(topic) {
2561
2681
  return 'slates-prompting-seedream-5-lite';
2562
2682
  if (t.startsWith('veo'))
2563
2683
  return 'slates-prompting-veo-3';
2684
+ if (t.startsWith('omni-flash') || t.startsWith('gemini-omni') || t === 'omni flash')
2685
+ return 'slates-prompting-omni-flash';
2564
2686
  if (t.startsWith('kling-mc'))
2565
2687
  return 'slates-prompting-motion-transfer';
2566
2688
  if (t === 'edit-video' || t === 'video-edit' || t === 'edit video' || t === 'video edit')
@@ -2643,6 +2765,7 @@ export const ALL_OPERATIONS = [
2643
2765
  generateLipSync,
2644
2766
  generateMotionTransfer,
2645
2767
  editVideo,
2768
+ trimVideo,
2646
2769
  editImage,
2647
2770
  getGenerationStatus,
2648
2771
  listGenerations,