@slatesvideo/shared 0.4.8 → 0.5.1

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,41 +115,53 @@ 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)'),
112
136
  duration: z.number().int().min(3).max(15).optional().describe('Video only — seconds. Cost scales linearly; required with a video base id.'),
113
137
  videoResolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe('Video only. Seedance defaults to 1080p.'),
114
- resolution: z.enum(['1k', '2k', '4k']).optional().describe('Image only (default 2k).'),
138
+ resolution: z.enum(['1k', '2k', '3k', '4k']).optional().describe('Image only (default 2k; 3k = gpt-image-2 1440p class).'),
139
+ quality: z.enum(['medium', 'high']).optional().describe('gpt-image-2 only — quality tier (default medium).'),
115
140
  sound: z.boolean().optional().describe('Veo only — audio flag changes the cost key.'),
116
141
  seedanceFace: z.boolean().optional().describe('Seedance AI-face route (pricier key).'),
117
142
  seedanceRealFace: z.boolean().optional().describe('Seedance consented real-face route (premium key).'),
118
143
  }),
119
144
  async run(input, ctx) {
120
145
  const registry = await ctx.cloud().get('/api/agent/models');
121
- 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)]));
122
147
  // 1) exact registry cost key
123
148
  let key = byKey.has(input.model) ? input.model : null;
124
- // 2) image base id + resolution
149
+ // 2) image base id + resolution (+ quality for gpt-image-2)
125
150
  if (!key) {
126
- const img = ['nano-banana-2', 'flux-2-max', 'seedream-5-lite'].find((m) => m === input.model);
151
+ const img = ['nano-banana-2', 'nano-banana-2-lite', 'nano-banana-pro', 'gpt-image-2', 'flux-2-max', 'seedream-5-lite'].find((m) => m === input.model);
127
152
  if (img)
128
- key = imageCostKey(img, input.resolution ?? '2k');
153
+ key = imageCostKey(img, input.resolution ?? (img === 'nano-banana-2-lite' ? '1k' : '2k'), input.quality ?? 'medium');
154
+ }
155
+ // 2b) Kling O3 edit base id + duration (ceiled source-clip length)
156
+ if (!key && (input.model === 'kling-v3.0-omni-edit' || input.model === 'kling-v3.0-omni-pro-edit')) {
157
+ if (!input.duration) {
158
+ return ok({
159
+ requires_clarification: true,
160
+ missing: ['duration'],
161
+ message: 'Kling O3 edit bills per second of output (≈ the source clip length, rounded up) — pass duration.',
162
+ });
163
+ }
164
+ key = klingEditCostKey(input.model, input.duration);
129
165
  }
130
166
  // 3) video base id (or cost-key spelling) → the same forgiving resolver
131
167
  // the generate op uses, so the two can never disagree about a model.
@@ -152,21 +188,20 @@ export const estimateGenerationCost = {
152
188
  });
153
189
  }
154
190
  }
155
- const cents = key != null ? byKey.get(key) : undefined;
156
- if (key == null || cents == null) {
191
+ const perCredits = key != null ? byKey.get(key) : undefined;
192
+ if (key == null || perCredits == null) {
157
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.`);
158
194
  }
159
195
  const qty = input.quantity ?? 1;
160
- const totalCents = cents * qty;
196
+ const totalCredits = perCredits * qty;
161
197
  return ok({
162
198
  model: input.model,
163
199
  cost_key: key,
164
200
  quantity: qty,
165
- cost_per_cents: cents,
166
- total_cents: totalCents,
167
- total_dollars: (totalCents / 100).toFixed(2),
168
- requires_confirm: totalCents > 50,
169
- });
201
+ cost_per_credits: perCredits,
202
+ total_credits: totalCredits,
203
+ requires_confirm: totalCredits > CONFIRM_CREDITS,
204
+ }, `${input.model}${qty > 1 ? ` ×${qty}` : ''}: ${fmtCredits(totalCredits)} (${key}).`);
170
205
  },
171
206
  };
172
207
  // ── Projects ────────────────────────────────────────────────────
@@ -561,7 +596,7 @@ export const generateCharacterSheets = {
561
596
  };
562
597
  export const generateEnvironmentPlate = {
563
598
  id: 'slates_generate_environment_plate',
564
- 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.",
599
+ 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.",
565
600
  input: z.object({
566
601
  environmentId: z.string().uuid(),
567
602
  projectId: z.string().uuid(),
@@ -721,31 +756,39 @@ async function previewAssets(ctx, refs) {
721
756
  }
722
757
  return out;
723
758
  }
724
- // ── Generation (cloud-routed, credits-default) ──────────────────
725
759
  // Registry cost-key for an image model+resolution. Mirrors imageCreditKey()
726
- // in slate/src/shared/pricing.ts: NB2 prices per resolution, FLUX.2 Max prices
727
- // per resolution (1k is the bare key), Seedream is flat (one key).
728
- function imageCostKey(model, resolution) {
760
+ // in slate/src/shared/pricing.ts MUST byte-match it (the same hard rule as
761
+ // videoCostKey): NB2/NB Pro price per resolution, FLUX.2 Max prices per
762
+ // resolution (1k is the bare key), NB2 Lite/Seedream are flat, GPT Image 2 is
763
+ // quality × resolution-class (med|high; low deliberately not exposed).
764
+ function imageCostKey(model, resolution, quality = 'medium') {
729
765
  if (model === 'flux-2-max')
730
766
  return resolution === '1k' ? 'flux-2-max' : `flux-2-max-${resolution}`;
731
767
  if (model === 'seedream-5-lite')
732
768
  return 'seedream-5-lite';
769
+ if (model === 'nano-banana-2-lite')
770
+ return 'nano-banana-2-lite';
771
+ if (model === 'nano-banana-pro')
772
+ return `nano-banana-pro-${resolution}`;
773
+ if (model === 'gpt-image-2')
774
+ return `gpt-image-2-${quality === 'high' ? 'high' : 'med'}-${resolution}`;
733
775
  return `nano-banana-2-${resolution}`;
734
776
  }
735
777
  export const generateImage = {
736
778
  id: 'slates_generate_image',
737
- description: 'Generate an image via Slates credits. Three models: nano-banana-2 (Google Gemini 3 Image default, strongest general image model, well-censored), flux-2-max (FLUX.2 Max — photoreal, less censored, up to 4MP), seedream-5-lite (cheapest at ~$0.05 flat, less censored). Pass projectId to save into a Slates project (recommended — asset appears live in the desktop UI). FLUX.2 Max and Seedream 5 Lite REQUIRE projectId (no headless path). REQUIRED before calling: read the slates-cost-discipline skill (and slates-prompting-nano-banana-2 when using nano-banana-2). You MUST pass aspectRatio and resolution explicitly (the server returns requires_clarification when missing — defaults waste credits). Cost > $0.50 returns requires_confirm — pass confirm=true after explicit user OK. MCP/CLI generation always charges credits. No skill files installed? Call slates_get_prompting_guide with the model\'s topic (and \'slates-cost-discipline\') before first use.',
779
+ description: 'Generate an image via Slates credits. Models: nano-banana-2 (default), nano-banana-2-lite (fast/cheap drafts, 1K only), nano-banana-pro (hero-frame/typography premium), gpt-image-2 (sharp text / character sheets / grids; quality medium|high), flux-2-max (photoreal, less censored), seedream-5-lite (cheapest flat, less censored). Which model for which job: read the slates-model-selection skill. Pass projectId to save into a Slates project (recommended — asset appears live in the desktop UI). All models except nano-banana-2 REQUIRE projectId (no headless path). REQUIRED before calling: read the slates-cost-discipline skill (and the model\'s slates-prompting-* skill). You MUST pass aspectRatio and resolution explicitly (the server returns requires_clarification when missing — defaults waste credits). Cost > $0.50 returns requires_confirm — pass confirm=true after explicit user OK. MCP/CLI generation always charges credits. No skill files installed? Call slates_get_prompting_guide with the model\'s topic (and \'slates-cost-discipline\') before first use.',
738
780
  input: z.object({
739
781
  prompt: z.string().min(1).max(4000),
740
- model: z.enum(['nano-banana-2', 'flux-2-max', 'seedream-5-lite']).optional().describe('Image model. Default nano-banana-2. Use flux-2-max for photoreal / less-censored, seedream-5-lite for cheapest. flux-2-max & seedream-5-lite require projectId.'),
741
- projectId: z.string().uuid().optional().describe('Save into this Slates project. Renderer refreshes live. Required for flux-2-max / seedream-5-lite.'),
742
- resolution: z.enum(['1k', '2k', '4k']).optional().describe('Pick deliberately: 1k drafts, 2k hero shots, 4k print/final. (Seedream is flat-priced regardless; FLUX & NB2 price by resolution.) Never default this.'),
782
+ model: z.enum(['nano-banana-2', 'nano-banana-2-lite', 'nano-banana-pro', 'gpt-image-2', 'flux-2-max', 'seedream-5-lite']).optional().describe('Image model. Default nano-banana-2. Routing doctrine: slates-model-selection skill. All except nano-banana-2 require projectId.'),
783
+ projectId: z.string().uuid().optional().describe('Save into this Slates project. Renderer refreshes live. Required for every model except nano-banana-2.'),
784
+ resolution: z.enum(['1k', '2k', '3k', '4k']).optional().describe('Pick deliberately: 1k drafts, 2k hero shots, 4k print/final. nano-banana-2-lite is 1k-only. gpt-image-2 classes: 1k=1024², 2k=1080p, 3k=1440p, 4k=2160p (3k is gpt-image-2 only). Never default this.'),
785
+ quality: z.enum(['medium', 'high']).optional().describe('gpt-image-2 only. medium (default) = sharp text, fast, the value seat; high = max text precision + reasoning at ~4× the price. Ignored by other models.'),
743
786
  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('Pick deliberately from the use case. Cinematic → 16:9. TikTok/Reels/Story → 9:16. IG square → 1:1. Ultra-wide → 21:9. Ask the user when ambiguous.'),
744
787
  count: z.number().int().min(1).max(4).optional(),
745
788
  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.'),
746
789
  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."),
747
790
  background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
748
- confirm: z.boolean().optional().describe('Set true to bypass the >$0.50 cost confirm gate.'),
791
+ confirm: z.boolean().optional().describe('Set true to bypass the confirm gate.'),
749
792
  }),
750
793
  async run(input, ctx) {
751
794
  // Clarification gate: aspectRatio + resolution must be deliberate.
@@ -769,7 +812,16 @@ export const generateImage = {
769
812
  }
770
813
  const resolution = input.resolution;
771
814
  const imageModel = input.model ?? 'nano-banana-2';
772
- // FLUX.2 Max / Seedream 5 Lite have no headless path — they route through
815
+ // The '3k' class exists only on gpt-image-2 (2560×1440); other models
816
+ // would mis-key the registry lookup.
817
+ if (resolution === '3k' && imageModel !== 'gpt-image-2') {
818
+ return ok({
819
+ requires_clarification: true,
820
+ missing: ['resolution'],
821
+ message: `3k (1440p) is a gpt-image-2 resolution class — pick 1k/2k/4k for ${imageModel}.`,
822
+ });
823
+ }
824
+ // Only nano-banana-2 has a headless path — everything else routes through
773
825
  // the desktop generation pipeline, which needs a project.
774
826
  if (imageModel !== 'nano-banana-2' && !input.projectId) {
775
827
  return ok({
@@ -810,32 +862,39 @@ export const generateImage = {
810
862
  if (input.background) {
811
863
  await ctx.desktop().requireCapability('background-generation', 'background generation');
812
864
  }
813
- const costKey = imageCostKey(imageModel, resolution);
865
+ // New-roster models need a desktop that knows them — an older desktop's
866
+ // allowlist would silently fall back to nano-banana-2 while we quote the
867
+ // new model's price.
868
+ if (input.projectId &&
869
+ (imageModel === 'gpt-image-2' || imageModel === 'nano-banana-pro' || imageModel === 'nano-banana-2-lite')) {
870
+ await ctx.desktop().requireCapability('image-models-v2', `${imageModel} generation`);
871
+ }
872
+ const costKey = imageCostKey(imageModel, resolution, input.quality ?? 'medium');
814
873
  const cloud = ctx.cloud();
815
874
  const registry = await cloud.get('/api/agent/models');
816
875
  const entry = registry.models.find((m) => m.model === costKey);
817
876
  if (!entry)
818
877
  throw new Error(`Model not in registry: ${costKey}`);
819
- const totalCents = entry.cost_cents * (input.count ?? 1);
878
+ const totalCents = creditCost(entry) * (input.count ?? 1);
820
879
  // Confirm gate. Fires on cost > $0.50, AND (look-first, mirroring
821
880
  // slates_generate_video) whenever reference assets are involved
822
881
  // regardless of cost — the LLM must see what it's referencing before
823
882
  // committing spend.
824
- if ((totalCents > 50 || referenceAssetIds.length > 0) && !input.confirm) {
883
+ if ((totalCents > CONFIRM_CREDITS || referenceAssetIds.length > 0) && !input.confirm) {
825
884
  if (referenceAssetIds.length === 0) {
826
885
  return ok({
827
886
  requires_confirm: true,
828
887
  model: costKey,
829
888
  estimated_cents: totalCents,
830
- estimated_dollars: (totalCents / 100).toFixed(2),
831
- message: 'Cost exceeds $0.50. Re-call with confirm=true to proceed, or pick a smaller resolution / count.',
889
+ estimated_credits: totalCents,
890
+ message: `Cost exceeds ${CONFIRM_CREDITS} credits. Re-call with confirm=true to proceed, or pick a smaller resolution / count.`,
832
891
  });
833
892
  }
834
893
  const previews = await previewAssets(ctx, referenceAssetIds.map((id) => ({ id, type: 'image', role: 'reference' })));
835
894
  const refLines = previews.map((p) => ` - ${p.role}: ${p.ref}`).join('\n');
836
895
  return {
837
896
  text: `Pre-flight for ${imageModel} (${costKey}): ` +
838
- `$${(totalCents / 100).toFixed(2)} (${totalCents}¢).` +
897
+ `${fmtCredits(totalCents)}.` +
839
898
  `\n\nReference images attached above:\n${refLines}\n\n` +
840
899
  `Review them against your prompt — every reference's role must be labeled in the prompt text. ` +
841
900
  `If the references suggest a different composition / style than the current prompt captures, REVISE the prompt before confirming. ` +
@@ -847,7 +906,7 @@ export const generateImage = {
847
906
  model: imageModel,
848
907
  variant: costKey,
849
908
  estimated_cents: totalCents,
850
- estimated_dollars: (totalCents / 100).toFixed(2),
909
+ estimated_credits: totalCents,
851
910
  references: previews.map((p) => ({
852
911
  role: p.role,
853
912
  ref: p.ref,
@@ -876,6 +935,7 @@ export const generateImage = {
876
935
  resolution,
877
936
  aspectRatio: input.aspectRatio ?? '1:1',
878
937
  count: input.count ?? 1,
938
+ ...(imageModel === 'gpt-image-2' ? { gptQuality: input.quality ?? 'medium' } : {}),
879
939
  ...(referenceAssetIds.length > 0 ? { referenceAssetIds } : {}),
880
940
  background: input.background,
881
941
  });
@@ -894,7 +954,7 @@ export const generateImage = {
894
954
  costKey,
895
955
  projectId: input.projectId,
896
956
  cost_cents: totalCents,
897
- cost_dollars: (totalCents / 100).toFixed(2),
957
+ cost_credits: totalCents,
898
958
  }, refEcho);
899
959
  }
900
960
  const assetList = result.assets
@@ -920,7 +980,7 @@ export const generateImage = {
920
980
  `The saved assets are in data.assets — re-generate only the missing count, don't redo the whole batch. ` +
921
981
  `Prompt: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"`
922
982
  : `Generated ${assetList.length} image(s) into project ${input.projectId} ` +
923
- `for $${(totalCents / 100).toFixed(2)} (${totalCents}¢). ` +
983
+ `for ${fmtCredits(totalCents)}. ` +
924
984
  `Prompt: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"` +
925
985
  (refEcho ? ` ${refEcho}` : ''),
926
986
  images,
@@ -931,7 +991,7 @@ export const generateImage = {
931
991
  aspectRatio: input.aspectRatio,
932
992
  resolution,
933
993
  cost_cents: totalCents,
934
- cost_dollars: (totalCents / 100).toFixed(2),
994
+ cost_credits: totalCents,
935
995
  // Compact refs only — the full rows (prompt/settings/paths) were a
936
996
  // multi-KB leak per generation and everything needed downstream is
937
997
  // the id/code/label.
@@ -990,7 +1050,7 @@ export const generateImage = {
990
1050
  images.push({ data: buf.toString('base64'), mimeType: mt });
991
1051
  }
992
1052
  return {
993
- text: `Generated ${urls.length} image(s) for $${(totalCents / 100).toFixed(2)} (${totalCents}¢). ` +
1053
+ text: `Generated ${urls.length} image(s) for ${fmtCredits(totalCents)}. ` +
994
1054
  `Prompt: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"`,
995
1055
  images,
996
1056
  data: {
@@ -1000,7 +1060,7 @@ export const generateImage = {
1000
1060
  aspectRatio: input.aspectRatio,
1001
1061
  resolution,
1002
1062
  cost_cents: totalCents,
1003
- cost_dollars: (totalCents / 100).toFixed(2),
1063
+ cost_credits: totalCents,
1004
1064
  },
1005
1065
  };
1006
1066
  },
@@ -1034,11 +1094,12 @@ export const editImage = {
1034
1094
  projectId: z.string().uuid(),
1035
1095
  sourceAssetId: z.string().uuid().describe('Image asset to edit. Must already exist in the project.'),
1036
1096
  prompt: z.string().min(1).max(4000).describe('The edit instruction — describe the change, not the whole image.'),
1037
- editModel: z.enum(['nano-banana-2', 'flux-2-max', 'seedream-5-lite']).optional(),
1038
- referenceAssetIds: z.array(z.string().uuid()).max(13).optional().describe('nano-banana-2 only: extra reference images.'),
1039
- resolution: z.enum(['1k', '2k', '4k']).optional(),
1097
+ editModel: z.enum(['nano-banana-2', 'nano-banana-2-lite', 'nano-banana-pro', 'gpt-image-2', 'flux-2-max', 'seedream-5-lite']).optional(),
1098
+ referenceAssetIds: z.array(z.string().uuid()).max(13).optional().describe('Nano-Banana family only: extra reference images (NB Pro takes up to 13, NB2 Lite up to 3).'),
1099
+ resolution: z.enum(['1k', '2k', '3k', '4k']).optional().describe('3k (1440p class) is gpt-image-2 only; nano-banana-2-lite is 1k only.'),
1100
+ quality: z.enum(['medium', 'high']).optional().describe('gpt-image-2 only — quality tier (default medium).'),
1040
1101
  aspectRatio: z.string().optional(),
1041
- confirm: z.boolean().optional().describe('Set true to bypass the >$0.50 cost confirm gate.'),
1102
+ confirm: z.boolean().optional().describe('Set true to bypass the confirm gate.'),
1042
1103
  background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
1043
1104
  }),
1044
1105
  async run(input, ctx) {
@@ -1048,27 +1109,30 @@ export const editImage = {
1048
1109
  await desktop.requireCapability('background-generation', 'background generation');
1049
1110
  }
1050
1111
  const editModel = input.editModel ?? 'nano-banana-2';
1051
- const resolution = input.resolution ?? '2k';
1052
- // NB2 edits charge the normal per-resolution NB2 key; FLUX / Seedream
1053
- // route to their dedicated edit endpoints, priced under '-edit' keys.
1054
- const costKey = editModel === 'nano-banana-2'
1055
- ? imageCostKey('nano-banana-2', resolution)
1056
- : `${imageCostKey(editModel, resolution)}-edit`;
1112
+ const resolution = input.resolution ?? (editModel === 'nano-banana-2-lite' ? '1k' : '2k');
1113
+ if ((editModel === 'gpt-image-2' || editModel === 'nano-banana-pro' || editModel === 'nano-banana-2-lite')) {
1114
+ await desktop.requireCapability('image-models-v2', `${editModel} editing`);
1115
+ }
1116
+ // Nano-Banana family + GPT Image 2 edits charge the same key as gen;
1117
+ // FLUX / Seedream route to dedicated edit endpoints priced under '-edit' keys.
1118
+ const costKey = editModel === 'flux-2-max' || editModel === 'seedream-5-lite'
1119
+ ? `${imageCostKey(editModel, resolution)}-edit`
1120
+ : imageCostKey(editModel, resolution, input.quality ?? 'medium');
1057
1121
  const cloud = ctx.cloud();
1058
1122
  const registry = await cloud.get('/api/agent/models');
1059
1123
  const entry = registry.models.find((m) => m.model === costKey);
1060
1124
  if (!entry)
1061
1125
  throw new Error(`Model not in registry: ${costKey}`);
1062
- const totalCents = entry.cost_cents;
1063
- if (totalCents > 50 && !input.confirm) {
1126
+ const totalCents = creditCost(entry);
1127
+ if (totalCents > CONFIRM_CREDITS && !input.confirm) {
1064
1128
  const sourceRef = await lookupAssetRef(desktop, input.sourceAssetId);
1065
1129
  return ok({
1066
1130
  requires_confirm: true,
1067
1131
  variant: costKey,
1068
1132
  estimated_cents: totalCents,
1069
- estimated_dollars: (totalCents / 100).toFixed(2),
1133
+ estimated_credits: totalCents,
1070
1134
  source_ref: sourceRef,
1071
- message: `Cost: $${(totalCents / 100).toFixed(2)} to edit ${sourceRef} with ${editModel} (${costKey}). ` +
1135
+ message: `Cost: ${fmtCredits(totalCents)} to edit ${sourceRef} with ${editModel} (${costKey}). ` +
1072
1136
  `Re-call with confirm=true after the user explicitly OKs the spend. ` +
1073
1137
  `When discussing with the user, refer to the source by its code (matches the gallery badge).`,
1074
1138
  });
@@ -1080,6 +1144,7 @@ export const editImage = {
1080
1144
  editModel,
1081
1145
  referenceAssetIds: input.referenceAssetIds,
1082
1146
  resolution,
1147
+ ...(editModel === 'gpt-image-2' ? { gptQuality: input.quality ?? 'medium' } : {}),
1083
1148
  aspectRatio: input.aspectRatio,
1084
1149
  background: input.background,
1085
1150
  });
@@ -1093,7 +1158,7 @@ export const editImage = {
1093
1158
  projectId: input.projectId,
1094
1159
  sourceAssetId: input.sourceAssetId,
1095
1160
  cost_cents: totalCents,
1096
- cost_dollars: (totalCents / 100).toFixed(2),
1161
+ cost_credits: totalCents,
1097
1162
  });
1098
1163
  }
1099
1164
  // Inline the edited result so the LLM sees whether the surgery landed —
@@ -1111,7 +1176,7 @@ export const editImage = {
1111
1176
  }
1112
1177
  return {
1113
1178
  text: `Edited image saved as a new asset in project ${input.projectId} ` +
1114
- `for $${(totalCents / 100).toFixed(2)} (${totalCents}¢) via ${editModel}. ` +
1179
+ `for ${fmtCredits(totalCents)} via ${editModel}. ` +
1115
1180
  `Edit: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"`,
1116
1181
  images,
1117
1182
  data: {
@@ -1121,7 +1186,7 @@ export const editImage = {
1121
1186
  sourceAssetId: input.sourceAssetId,
1122
1187
  resolution,
1123
1188
  cost_cents: totalCents,
1124
- cost_dollars: (totalCents / 100).toFixed(2),
1189
+ cost_credits: totalCents,
1125
1190
  asset: result.asset,
1126
1191
  generationId: result.generationId,
1127
1192
  },
@@ -1152,16 +1217,27 @@ const KLING_TIER_MAP = {
1152
1217
  'kling-v3.0-std': 'kling-v3-standard',
1153
1218
  'kling-v3.0-pro': 'kling-v3-pro',
1154
1219
  'kling-v3.0-omni': 'kling-v3-omni',
1220
+ // Missing until 2026-07-05 — the fallthrough quoted a nonexistent
1221
+ // 'kling-v3.0-omni-pro-…' key for O3 Pro (caught by the consistency check
1222
+ // the day omni-pro was added to its coverage).
1223
+ 'kling-v3.0-omni-pro': 'kling-v3-omni-pro',
1155
1224
  };
1156
1225
  // Exported for scripts/pricing-consistency-check.mjs (slates-api repo), which
1157
1226
  // asserts this builder byte-matches the desktop's klingCreditKey/seedanceCreditKey.
1158
1227
  export function videoCostKey(input) {
1159
1228
  if (input.model.startsWith('seedance')) {
1160
- // Mirrors seedanceCreditKey() in slate/src/shared/pricing.ts (face × res × duration).
1161
- // AI-face route bills the `-face-` key (~45% over faceless); consented
1162
- // real-person route bills the premium `-realface-` key (fal partner endpoint).
1229
+ // Mirrors seedanceCreditKey() in slate/src/shared/pricing.ts (face × vref ×
1230
+ // res × duration). AI-face route bills the `-face-` key (~45% over faceless);
1231
+ // consented real-person route bills the premium `-realface-` key (fal partner
1232
+ // endpoint). A reference video flips to `-vref-{res}-{T}s`, T = in + out (6..30).
1163
1233
  const res = input.videoResolution ?? '1080p';
1164
1234
  const face = input.seedanceRealFace ? '-realface' : input.seedanceFace ? '-face' : '';
1235
+ const vrefSecs = input.videoRefSeconds ?? 0;
1236
+ if (vrefSecs > 0) {
1237
+ // ceil(x - 0.05) matches the server's probe rounding — quote = bill.
1238
+ const total = Math.min(30, Math.max(6, Math.ceil(vrefSecs - 0.05) + input.duration));
1239
+ return `${input.model}${face}-vref-${res}-${total}s`;
1240
+ }
1165
1241
  return `${input.model}${face}-${res}-${input.duration}s`;
1166
1242
  }
1167
1243
  if (input.model.startsWith('veo')) {
@@ -1178,16 +1254,26 @@ export function videoCostKey(input) {
1178
1254
  if (input.model.startsWith('kling-v3.0')) {
1179
1255
  // Mirrors klingCreditKey() in slate/src/shared/pricing.ts. Kling native 4K
1180
1256
  // bills flat-rate keys: std/pro/omni all get a `-4k` tier key, and omni-pro
1181
- // shares kling-v3-omni-4k (the o3/4k endpoint has one flat rate).
1257
+ // shares kling-v3-omni-4k (the o3/4k endpoint has one flat rate, audio
1258
+ // included). At 1080p AUDIO IS A KEY DIMENSION (credits = COGS × markup,
1259
+ // locked 2026-07-05): sound → `-audio` variant. Kling sound defaults OFF.
1182
1260
  const tier = KLING_TIER_MAP[input.model] ?? input.model;
1183
1261
  if (input.videoResolution === '4k') {
1184
1262
  const tier4k = tier === 'kling-v3-omni-pro' ? 'kling-v3-omni' : tier;
1185
1263
  return `${tier4k}-4k-${input.duration}s`;
1186
1264
  }
1187
- return `${tier}-${input.duration}s`;
1265
+ return `${tier}-${input.duration}s${input.sound === true ? '-audio' : ''}`;
1188
1266
  }
1189
1267
  throw new Error(`Unknown video model: ${input.model}`);
1190
1268
  }
1269
+ // Kling O3 video-to-video edit cost key — mirrors klingEditCreditKey() in
1270
+ // slate/src/shared/pricing.ts (must byte-match; checked by the slates-api
1271
+ // pricing-consistency script). Duration is the CEILED source-clip length —
1272
+ // billing is per second of output ≈ source length, always rounded up.
1273
+ export function klingEditCostKey(model, duration) {
1274
+ const tier = model === 'kling-v3.0-omni-pro-edit' ? 'kling-v3-omni-pro-edit' : 'kling-v3-omni-edit';
1275
+ return `${tier}-${duration}s`;
1276
+ }
1191
1277
  /**
1192
1278
  * Forgiving model-id resolver. Agents routinely paste registry COST keys
1193
1279
  * ("kling-v3-standard-8s", "seedance-2-1080p-8s") into the `model` param —
@@ -1271,7 +1357,9 @@ export const generateVideo = {
1271
1357
  characterAssetIds: z.array(z.string()).optional().describe('Character sheet assets (UUIDs or badge codes) — keeps a character consistent across the shot.'),
1272
1358
  environmentAssetIds: z.array(z.string()).optional().describe('Environment grid assets (UUIDs or badge codes) — keeps a location/setting consistent across the shot.'),
1273
1359
  styleAssetIds: z.array(z.string()).optional().describe('Style reference assets (UUIDs or badge codes) — locks the visual style of the shot.'),
1274
- videoReferenceAssetId: z.string().optional().describe('Seedance ONLY: an existing VIDEO asset (UUID or badge code) to use as a reference (edit / relocate an existing clip Seedance ref-to-video). 2-15s. If the clip contains a human/AI character, pair with seedanceFace=true so it routes to the face-capable provider (the default Seedance route blocks people). Ignored by Kling/Veo.'),
1360
+ videoReferenceAssetId: z.string().optional().describe('Seedance ONLY: an existing VIDEO asset (UUID or badge code) to use as a reference edit/relocate a clip, or MOTION TRANSFER (pair with a subject in ingredientAssetIds and a prompt like "the character from image 1 performs the motion from video 1"). 2-15s. Billing switches to input+output seconds (the vref key) — pass videoReferenceSeconds so the quote is right. If the clip contains a human/AI character, pair with seedanceFace=true (the default Seedance route blocks people). Ignored by Kling/Veo.'),
1361
+ videoReferenceSeconds: z.number().optional().describe('REQUIRED with videoReferenceAssetId: the reference clip\'s duration in seconds (from the asset listing). Feeds the vref cost key — a video-reference gen bills combined input+output seconds; the server re-derives this by probing the clip, so an understated value just gets corrected upward.'),
1362
+ audioReferenceAssetId: z.string().optional().describe('Seedance ONLY: an AUDIO asset (UUID or badge code), ≤15s, used as a reference — e.g. lip-sync a character to this audio ("the character in image 1 speaks the dialogue from audio 1"). No billing surcharge (Seedance audio is included). Requires at least one image or video reference alongside.'),
1275
1363
  sound: z.boolean().optional().describe('Kling Omni / Veo / Seedance: enable audio generation. Default true.'),
1276
1364
  audioLanguage: z.enum(['EN', 'ZH', 'JA', 'KO', 'ES']).optional().describe('Kling Omni only — language for dialogue.'),
1277
1365
  generateMusic: z.boolean().optional().describe('Kling Omni only — auto-generate background music.'),
@@ -1280,7 +1368,7 @@ export const generateVideo = {
1280
1368
  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.'),
1281
1369
  negativePrompt: z.string().optional(),
1282
1370
  background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
1283
- 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).'),
1371
+ 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).'),
1284
1372
  }),
1285
1373
  async run(input, ctx) {
1286
1374
  // Resolve the model FIRST — forgiving normalization (cost keys, alias
@@ -1310,7 +1398,7 @@ export const generateVideo = {
1310
1398
  return ok({
1311
1399
  requires_clarification: true,
1312
1400
  missing: ['projectId'],
1313
- 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.',
1401
+ 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.',
1314
1402
  });
1315
1403
  }
1316
1404
  if (!input.aspectRatio || !input.duration) {
@@ -1368,17 +1456,30 @@ export const generateVideo = {
1368
1456
  refInputs.push({ ref: r, role: 'style' });
1369
1457
  if (input.videoReferenceAssetId)
1370
1458
  refInputs.push({ ref: input.videoReferenceAssetId, role: 'video reference' });
1459
+ if (input.audioReferenceAssetId)
1460
+ refInputs.push({ ref: input.audioReferenceAssetId, role: 'audio reference' });
1371
1461
  const resolvedRefs = await resolveAssetRefs(ctx, input.projectId, refInputs.map((r) => r.ref));
1372
1462
  const rid = (v) => v ? (resolvedRefs.get(v)?.id ?? v) : v;
1373
1463
  const rids = (a) => a?.map((v) => resolvedRefs.get(v)?.id ?? v);
1374
1464
  input.firstFrameAssetId = rid(input.firstFrameAssetId);
1375
1465
  input.lastFrameAssetId = rid(input.lastFrameAssetId);
1376
1466
  input.videoReferenceAssetId = rid(input.videoReferenceAssetId);
1467
+ input.audioReferenceAssetId = rid(input.audioReferenceAssetId);
1377
1468
  input.ingredientAssetIds = rids(input.ingredientAssetIds);
1378
1469
  input.characterAssetIds = rids(input.characterAssetIds);
1379
1470
  input.environmentAssetIds = rids(input.environmentAssetIds);
1380
1471
  input.styleAssetIds = rids(input.styleAssetIds);
1381
1472
  const refEcho = describeResolvedRefs(refInputs, resolvedRefs);
1473
+ // A video reference bills combined input+output seconds (the vref key) —
1474
+ // the quote needs the clip's length. The server probes the uploaded ref
1475
+ // and corrects the key anyway, so this only gates quote accuracy.
1476
+ if (input.videoReferenceAssetId && input.model.startsWith('seedance') && !input.videoReferenceSeconds) {
1477
+ return ok({
1478
+ requires_clarification: true,
1479
+ missing: ['videoReferenceSeconds'],
1480
+ message: 'A Seedance video reference bills on combined input+output seconds. Pass videoReferenceSeconds (the reference clip\'s duration, shown in slates_list_assets) so the pre-flight quote matches the bill.',
1481
+ });
1482
+ }
1382
1483
  const cloud = ctx.cloud();
1383
1484
  const registry = await cloud.get('/api/agent/models');
1384
1485
  const costKey = videoCostKey({
@@ -1388,6 +1489,7 @@ export const generateVideo = {
1388
1489
  sound: input.sound,
1389
1490
  seedanceFace: input.seedanceFace,
1390
1491
  seedanceRealFace: input.seedanceRealFace,
1492
+ videoRefSeconds: input.videoReferenceAssetId ? input.videoReferenceSeconds : 0,
1391
1493
  });
1392
1494
  // Hard consent gate, checked before any spend: the real-face route is
1393
1495
  // consent-attested by design (the desktop enforces it too).
@@ -1403,7 +1505,7 @@ export const generateVideo = {
1403
1505
  throw new Error(`Model variant not in registry: ${costKey}. ` +
1404
1506
  `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(', ')}`);
1405
1507
  }
1406
- const totalCents = entry.cost_cents;
1508
+ const totalCents = creditCost(entry);
1407
1509
  // Pre-flight confirm gate. Fires when:
1408
1510
  // (a) cost > $0.50 (the cost gate), OR
1409
1511
  // (b) any reference assets are involved (the look-first gate)
@@ -1432,7 +1534,7 @@ export const generateVideo = {
1432
1534
  referenceRefs.push({ id, type: 'image', role: 'style' });
1433
1535
  }
1434
1536
  const hasReferences = referenceRefs.length > 0;
1435
- if ((totalCents > 50 || hasReferences) && !input.confirm) {
1537
+ if ((totalCents > CONFIRM_CREDITS || hasReferences) && !input.confirm) {
1436
1538
  const previews = hasReferences ? await previewAssets(ctx, referenceRefs) : [];
1437
1539
  const refLines = previews.map((p) => ` - ${p.role}: ${p.ref}`).join('\n');
1438
1540
  const refSummary = hasReferences
@@ -1442,7 +1544,7 @@ export const generateVideo = {
1442
1544
  const refImages = previews.flatMap((p) => p.images);
1443
1545
  return {
1444
1546
  text: `Pre-flight for ${input.duration}s ${input.model} (${costKey}): ` +
1445
- `$${(totalCents / 100).toFixed(2)} (${totalCents}¢).` +
1547
+ `${fmtCredits(totalCents)}.` +
1446
1548
  refSummary +
1447
1549
  `\n\nWhen ready, re-call slates_generate_video with confirm=true and the (possibly revised) prompt.`,
1448
1550
  images: refImages,
@@ -1451,7 +1553,7 @@ export const generateVideo = {
1451
1553
  model: input.model,
1452
1554
  variant: costKey,
1453
1555
  estimated_cents: totalCents,
1454
- estimated_dollars: (totalCents / 100).toFixed(2),
1556
+ estimated_credits: totalCents,
1455
1557
  references: previews.map((p) => ({
1456
1558
  role: p.role,
1457
1559
  ref: p.ref,
@@ -1482,6 +1584,7 @@ export const generateVideo = {
1482
1584
  environmentAssetIds: input.environmentAssetIds ?? [],
1483
1585
  styleAssetIds: input.styleAssetIds ?? [],
1484
1586
  videoReferenceAssetId: input.videoReferenceAssetId,
1587
+ audioReferenceAssetId: input.audioReferenceAssetId,
1485
1588
  sound: input.sound,
1486
1589
  audioLanguage: input.audioLanguage,
1487
1590
  generateMusic: input.generateMusic,
@@ -1501,7 +1604,7 @@ export const generateVideo = {
1501
1604
  variant: costKey,
1502
1605
  projectId: input.projectId,
1503
1606
  cost_cents: totalCents,
1504
- cost_dollars: (totalCents / 100).toFixed(2),
1607
+ cost_credits: totalCents,
1505
1608
  references: refInputs.map(({ ref, role }) => ({
1506
1609
  role,
1507
1610
  ...(resolvedRefs.get(ref) ?? { id: ref, code: null, label: null }),
@@ -1510,7 +1613,7 @@ export const generateVideo = {
1510
1613
  }
1511
1614
  return {
1512
1615
  text: `Generated ${input.duration}s ${input.model} video into project ${input.projectId} ` +
1513
- `for $${(totalCents / 100).toFixed(2)} (${totalCents}¢). ` +
1616
+ `for ${fmtCredits(totalCents)}. ` +
1514
1617
  `Prompt: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"` +
1515
1618
  (refEcho ? ` ${refEcho}` : ''),
1516
1619
  data: {
@@ -1520,7 +1623,7 @@ export const generateVideo = {
1520
1623
  aspectRatio: input.aspectRatio,
1521
1624
  duration: input.duration,
1522
1625
  cost_cents: totalCents,
1523
- cost_dollars: (totalCents / 100).toFixed(2),
1626
+ cost_credits: totalCents,
1524
1627
  asset: result.asset,
1525
1628
  generationId: result.generationId,
1526
1629
  },
@@ -1530,21 +1633,29 @@ export const generateVideo = {
1530
1633
  // ── Generate lip-sync ───────────────────────────────────────────
1531
1634
  export const generateLipSync = {
1532
1635
  id: 'slates_generate_lip_sync',
1533
- description: 'Lip-sync a still image (avatar) or a video clip to audio via Kling. REQUIRED before calling: read the slates-cost-discipline + slates-prompting-lip-sync skills. projectId is REQUIRED — the source asset must already exist in the project. Two flows: (1) sourceType=video → lip-syncs an existing talking-head clip to new audio (~$0.11 / 5s, no confirm gate); (2) sourceType=image animates a still portrait into a talking avatar (avatar-standard ~$0.42 / 5s; avatar-pro ~$0.86 / 5s, hits the >$0.50 confirm gate). Audio comes from either TTS (pass ttsText) or an uploaded file (pass audioFilePath). Always 5 seconds — Kling lip-sync does not support other durations. No skill files installed? Call slates_get_prompting_guide with \'slates-prompting-lip-sync\' (and \'slates-cost-discipline\') before first use.',
1636
+ description: 'Lip-sync a still image (avatar) or a video clip to audio. Two engines route per slates-model-selection: (1) engine=kling (default, cheap utility lane): sourceType=video re-syncs a clip (~$0.11 / 5s); sourceType=image animates a still avatar (avatar-standard ~$0.42 / 5s; avatar-pro ~$0.86 / 5s). Audio from TTS (ttsText + ttsVoice) or an uploaded file. Always 5 seconds. (2) engine=seedance-2 (premium single-pass): the speech is generated IN the video itself — natural delivery, a video source keeps its OWN voice (native voice clone), audio included; ttsText becomes the spoken line (no voice/speed params), or an uploaded ≤15s audio file drives the speech as a reference. Seedance sources must be 2-15s videos or images; bills seedance keys (video sources bill input+output seconds — pass sourceSeconds). Faces route via seedanceFace (default true) / seedanceRealFace+realFaceConsent for real people. REQUIRED before calling: slates-cost-discipline + slates-prompting-lip-sync skills. projectId is REQUIRED.',
1534
1637
  input: z.object({
1535
1638
  projectId: z.string().uuid().describe('Slates project the source asset lives in. The new lip-synced video lands here.'),
1536
1639
  sourceAssetId: z.string().uuid().describe('Asset id of the still image (avatar flow) or video clip (lip-sync flow). Must already exist in the project — use slates_upload_reference_image or slates_generate_image / slates_generate_video first if needed.'),
1537
1640
  sourceType: z.enum(['image', 'video']).describe('"image" = animate a still portrait (avatar). "video" = re-sync an existing talking-head clip. Determines pricing — be deliberate.'),
1538
- audioMethod: z.enum(['tts', 'upload']).describe('"tts" = generate speech from ttsText. "upload" = use the file at audioFilePath (absolute path on the user\'s machine).'),
1641
+ audioMethod: z.enum(['tts', 'upload']).describe('"tts" = generate speech from ttsText (on Seedance the line is spoken natively in the generation). "upload" = use the file at audioFilePath (absolute path on the user\'s machine; ≤15s on Seedance).'),
1539
1642
  ttsText: z.string().min(1).max(2000).optional().describe('Required when audioMethod=tts. The exact words the avatar/clip will speak.'),
1540
- ttsVoice: z.string().optional().describe('Kling voice id (e.g. "oversea_male1"). See slates-prompting-lip-sync skill for the voice catalog.'),
1541
- ttsLanguage: z.enum(['EN', 'ZH', 'JA', 'KO', 'ES']).optional().describe('TTS language. Default EN.'),
1542
- ttsSpeed: z.number().min(0.5).max(2).optional().describe('TTS speech rate. Default 1.0. Range 0.5-2.0.'),
1643
+ ttsVoice: z.string().optional().describe('Kling engine only — voice id (e.g. "oversea_male1"). See slates-prompting-lip-sync skill for the voice catalog. Ignored on Seedance (a video source keeps its own voice; otherwise describe the voice in ttsText context).'),
1644
+ ttsLanguage: z.enum(['EN', 'ZH', 'JA', 'KO', 'ES']).optional().describe('Kling engine only — TTS language. Default EN.'),
1645
+ ttsSpeed: z.number().min(0.5).max(2).optional().describe('Kling engine only — TTS speech rate. Default 1.0. Range 0.5-2.0.'),
1543
1646
  audioFilePath: z.string().optional().describe('Required when audioMethod=upload. Absolute path to the audio file on the user\'s machine (mp3, wav, m4a).'),
1544
- avatarModel: z.enum(['avatar-standard', 'avatar-pro']).optional().describe('Image-source only. avatar-standard ($0.42/5s) for general use. avatar-pro ($0.86/5s) for sharper face fidelity. Ignored when sourceType=video.'),
1545
- klingProvider: z.enum(['fal', 'kling']).optional().describe('Provider routing for the Kling call. "fal" (default) uses Slates credits. "kling" uses the user\'s own BYOK Kling key if configured — omit unless the user explicitly wants BYOK.'),
1647
+ 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.'),
1648
+ klingProvider: z.enum(['fal', 'kling']).optional().describe('Kling engine only provider routing. Leave unset: all agent generations bill Slates credits (BYOK is retired).'),
1649
+ 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.'),
1650
+ videoResolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe('Seedance engine only. Default 1080p.'),
1651
+ aspectRatio: z.string().optional().describe('Seedance engine only. Default 16:9.'),
1652
+ seedanceFace: z.boolean().optional().describe('Seedance engine only — a character\'s face is in the source (default TRUE for lip-sync; the faceless route would reject it). Bills the -face key.'),
1653
+ seedanceRealFace: z.boolean().optional().describe('Seedance engine only — the source shows a REAL person. Premium -realface key; REQUIRES realFaceConsent=true.'),
1654
+ realFaceConsent: z.boolean().optional().describe('MANDATORY with seedanceRealFace — set true only after the user explicitly confirms they hold rights/consent to the likeness.'),
1655
+ 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).'),
1656
+ audioSeconds: z.number().optional().describe('Seedance engine + audioMethod=upload: the audio file\'s duration in seconds — sets the output length (4-15s).'),
1546
1657
  background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
1547
- confirm: z.boolean().optional().describe('Set true to bypass the >$0.50 cost confirm gate. Required for avatar-pro.'),
1658
+ confirm: z.boolean().optional().describe('Set true to bypass the confirm gate. Required for avatar-pro.'),
1548
1659
  }),
1549
1660
  async run(input, ctx) {
1550
1661
  if (input.audioMethod === 'tts' && !input.ttsText) {
@@ -1561,8 +1672,41 @@ export const generateLipSync = {
1561
1672
  message: 'audioMethod=upload requires audioFilePath. Pass an absolute path to the audio file on the user\'s machine.',
1562
1673
  });
1563
1674
  }
1675
+ const isSeedance = input.engine === 'seedance-2';
1564
1676
  let costKey;
1565
- if (input.sourceType === 'video') {
1677
+ let seedanceDuration = 0;
1678
+ if (isSeedance) {
1679
+ // Consent gate before any spend, mirroring slates_generate_video.
1680
+ if (input.seedanceRealFace && !input.realFaceConsent) {
1681
+ return ok({
1682
+ requires_clarification: true,
1683
+ missing: ['realFaceConsent'],
1684
+ message: 'Real-person lip-sync needs consent: confirm with the user that they hold the rights/consent to this likeness, then retry with realFaceConsent=true.',
1685
+ });
1686
+ }
1687
+ if (input.sourceType === 'video' && !input.sourceSeconds) {
1688
+ return ok({
1689
+ requires_clarification: true,
1690
+ missing: ['sourceSeconds'],
1691
+ message: 'Seedance lip-sync on a video source bills combined input+output seconds. Pass sourceSeconds (the clip\'s duration from slates_list_assets, must be 2-15s).',
1692
+ });
1693
+ }
1694
+ const clamp = (n) => Math.min(15, Math.max(4, Math.ceil(n)));
1695
+ seedanceDuration = clamp(input.sourceType === 'video' && input.sourceSeconds
1696
+ ? input.sourceSeconds
1697
+ : input.audioSeconds ?? (input.ttsText ? input.ttsText.length / 13 : 5));
1698
+ costKey = videoCostKey({
1699
+ model: 'seedance-2',
1700
+ duration: seedanceDuration,
1701
+ videoResolution: input.videoResolution ?? '1080p',
1702
+ // Lip-sync sources are faces by definition — face route unless
1703
+ // explicitly disabled or escalated to realface.
1704
+ seedanceFace: input.seedanceFace !== false && input.seedanceRealFace !== true,
1705
+ seedanceRealFace: input.seedanceRealFace === true,
1706
+ videoRefSeconds: input.sourceType === 'video' ? input.sourceSeconds ?? 0 : 0,
1707
+ });
1708
+ }
1709
+ else if (input.sourceType === 'video') {
1566
1710
  costKey = 'kling-lip-sync-video-5s';
1567
1711
  }
1568
1712
  else {
@@ -1575,12 +1719,12 @@ export const generateLipSync = {
1575
1719
  const entry = registry.models.find((m) => m.model === costKey);
1576
1720
  if (!entry)
1577
1721
  throw new Error(`Model variant not in registry: ${costKey}`);
1578
- const totalCents = entry.cost_cents;
1722
+ const totalCents = creditCost(entry);
1579
1723
  // Cost confirm gate. Lip-sync is mechanical — the model re-syncs the
1580
1724
  // user-chosen source to the user-chosen audio. The agent doesn't
1581
1725
  // write a prompt that depends on what the source looks like, so we
1582
1726
  // skip the inline preview and just announce the source code in text.
1583
- if (totalCents > 50 && !input.confirm) {
1727
+ if (totalCents > CONFIRM_CREDITS && !input.confirm) {
1584
1728
  const sourceRef = await lookupAssetRef(ctx.desktop(), input.sourceAssetId);
1585
1729
  const audioPreview = input.audioMethod === 'tts'
1586
1730
  ? `Audio: TTS — "${(input.ttsText ?? '').slice(0, 120)}"`
@@ -1589,9 +1733,9 @@ export const generateLipSync = {
1589
1733
  requires_confirm: true,
1590
1734
  variant: costKey,
1591
1735
  estimated_cents: totalCents,
1592
- estimated_dollars: (totalCents / 100).toFixed(2),
1736
+ estimated_credits: totalCents,
1593
1737
  source_ref: sourceRef,
1594
- message: `Cost: $${(totalCents / 100).toFixed(2)} for 5s ${costKey}. ` +
1738
+ message: `Cost: ${fmtCredits(totalCents)} for ${isSeedance ? `${seedanceDuration}s Seedance` : '5s'} lip-sync (${costKey}). ` +
1595
1739
  `Source: ${sourceRef}. ${audioPreview}. ` +
1596
1740
  `Re-call with confirm=true after the user explicitly OKs the spend. ` +
1597
1741
  `When discussing with the user, refer to the source by its code (matches the gallery badge).`,
@@ -1614,22 +1758,38 @@ export const generateLipSync = {
1614
1758
  klingProvider: input.klingProvider,
1615
1759
  estimatedCost: totalCents,
1616
1760
  background: input.background,
1761
+ // Seedance engine passthrough — the desktop delegates to the seedance
1762
+ // ref-to-video path (vref billing, face cascade, consent gate). The
1763
+ // durations ride along so the desktop bills exactly what was quoted.
1764
+ ...(isSeedance
1765
+ ? {
1766
+ lipSyncEngine: 'seedance-2',
1767
+ duration: seedanceDuration,
1768
+ videoResolution: input.videoResolution,
1769
+ aspectRatio: input.aspectRatio,
1770
+ seedanceFace: input.seedanceFace !== false && input.seedanceRealFace !== true,
1771
+ seedanceRealFace: input.seedanceRealFace === true,
1772
+ realFaceConsent: input.realFaceConsent === true,
1773
+ sourceDurationSeconds: input.sourceSeconds,
1774
+ audioDurationSeconds: input.audioSeconds,
1775
+ }
1776
+ : {}),
1617
1777
  });
1618
1778
  if (!result.success)
1619
1779
  throw new Error(result.error ?? 'Lip-sync generation failed');
1620
1780
  if (result.background) {
1621
1781
  const ids = result.generationIds ?? (result.generationId ? [result.generationId] : []);
1622
- return backgroundSubmitted(`5s lip-sync (${costKey})`, ids, {
1782
+ return backgroundSubmitted(`${isSeedance ? `${seedanceDuration}s` : '5s'} lip-sync (${costKey})`, ids, {
1623
1783
  variant: costKey,
1624
1784
  projectId: input.projectId,
1625
1785
  sourceAssetId: input.sourceAssetId,
1626
1786
  cost_cents: totalCents,
1627
- cost_dollars: (totalCents / 100).toFixed(2),
1787
+ cost_credits: totalCents,
1628
1788
  });
1629
1789
  }
1630
1790
  return {
1631
- text: `Generated 5s lip-sync (${costKey}) into project ${input.projectId} ` +
1632
- `for $${(totalCents / 100).toFixed(2)} (${totalCents}¢). ` +
1791
+ text: `Generated ${isSeedance ? `${seedanceDuration}s` : '5s'} lip-sync (${costKey}) into project ${input.projectId} ` +
1792
+ `for ${fmtCredits(totalCents)}. ` +
1633
1793
  (input.audioMethod === 'tts'
1634
1794
  ? `Spoken: "${(input.ttsText ?? '').slice(0, 60)}${(input.ttsText ?? '').length > 60 ? '...' : ''}"`
1635
1795
  : `Audio: ${input.audioFilePath}`),
@@ -1639,7 +1799,7 @@ export const generateLipSync = {
1639
1799
  sourceType: input.sourceType,
1640
1800
  sourceAssetId: input.sourceAssetId,
1641
1801
  cost_cents: totalCents,
1642
- cost_dollars: (totalCents / 100).toFixed(2),
1802
+ cost_credits: totalCents,
1643
1803
  asset: result.asset,
1644
1804
  generationId: result.generationId,
1645
1805
  },
@@ -1649,32 +1809,69 @@ export const generateLipSync = {
1649
1809
  // ── Generate motion transfer ────────────────────────────────────
1650
1810
  export const generateMotionTransfer = {
1651
1811
  id: 'slates_generate_motion_transfer',
1652
- description: 'Transfer the motion from a reference video onto a target image character via Kling Motion Control. REQUIRED before calling: read the slates-cost-discipline + slates-prompting-motion-transfer skills. projectId is REQUIRED both source video and target image must already exist as assets in the project. Two tiers: kling-mc-std ($0.95 / 5s) and kling-mc-pro ($1.26 / 5s) both hit the >$0.50 confirm gate. Always 5 seconds. No skill files installed? Call slates_get_prompting_guide with \'slates-prompting-motion-transfer\' (and \'slates-cost-discipline\') before first use.',
1812
+ description: 'Transfer the motion from a reference video onto a target image character. Two engines route per slates-model-selection: (1) kling-mc-std ($0.95 / 5s) / kling-mc-pro ($1.26 / 5s)the cheap utility lane, structured skeleton/depth retargeting, always 5s. (2) motionModel=seedance-2 the PREMIUM lane: single-pass generation with the driving clip as a native conditioning signal (better motion fidelity + native audio), prompt-driven (write what the character does, e.g. "the character from image 1 performs the exact motion from video 1"), bills input+output seconds on seedance vref keys (pass sourceVideoSeconds; driving clip must be 2-15s). Faces: seedanceFace defaults true; a REAL person needs seedanceRealFace+realFaceConsent (premium route). REQUIRED before calling: slates-cost-discipline + slates-prompting-motion-transfer skills. projectId is REQUIRED — both assets must exist in the project. All tiers hit the >$0.50 confirm gate.',
1653
1813
  input: z.object({
1654
1814
  projectId: z.string().uuid().describe('Slates project. Both source and target assets must live here.'),
1655
- 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.'),
1815
+ 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.'),
1656
1816
  targetImageAssetId: z.string().uuid().describe('Asset id of the target image (the character that will perform the motion). Must already exist in the project.'),
1657
- motionModel: z.enum(['kling-mc-std', 'kling-mc-pro']).optional().describe('std ($0.95) for general motion. pro ($1.26) for cleaner anatomy + identity preservation. Default pro — pick std deliberately for cost savings.'),
1658
- characterOrientation: z.enum(['video', 'image']).optional().describe('"video" = use the source video\'s framing. "image" = use the target image\'s framing. Default video.'),
1659
- prompt: z.string().optional().describe('Optional refinement prompt. Read slates-prompting-motion-transfer for guidance.'),
1660
- klingProvider: z.enum(['fal', 'kling']).optional().describe('Provider routing for the Kling call. "fal" (default) uses Slates credits. "kling" uses the user\'s own BYOK Kling key if configured — omit unless the user explicitly wants BYOK.'),
1817
+ 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.'),
1818
+ 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.'),
1819
+ 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.'),
1820
+ klingProvider: z.enum(['fal', 'kling']).optional().describe('Kling engine only provider routing. "fal" (default) uses Slates credits.'),
1821
+ duration: z.number().int().min(4).max(15).optional().describe('Seedance engine only — output duration in seconds (4-15). Defaults to the driving clip\'s length.'),
1822
+ videoResolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe('Seedance engine only. Default 1080p.'),
1823
+ aspectRatio: z.string().optional().describe('Seedance engine only. Default 16:9.'),
1824
+ seedanceFace: z.boolean().optional().describe('Seedance engine only — a character\'s face is in the clip/image (default TRUE for motion transfer). Bills the -face key.'),
1825
+ seedanceRealFace: z.boolean().optional().describe('Seedance engine only — the driving clip/subject shows a REAL person. Premium -realface key; REQUIRES realFaceConsent=true.'),
1826
+ realFaceConsent: z.boolean().optional().describe('MANDATORY with seedanceRealFace — set true only after the user explicitly confirms they hold rights/consent to the likeness.'),
1827
+ 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).'),
1661
1828
  background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
1662
- confirm: z.boolean().optional().describe('Set true to bypass the >$0.50 confirm gate. Required — both tiers exceed.'),
1829
+ confirm: z.boolean().optional().describe('Set true to bypass the confirm gate. Required — both tiers exceed.'),
1663
1830
  }),
1664
1831
  async run(input, ctx) {
1665
1832
  const motionModel = input.motionModel ?? 'kling-mc-pro';
1666
- const costKey = motionModel === 'kling-mc-std' ? 'kling-mc-std-5s' : 'kling-mc-pro-5s';
1833
+ const isSeedance = motionModel === 'seedance-2';
1834
+ let costKey;
1835
+ let seedanceDuration = 0;
1836
+ if (isSeedance) {
1837
+ if (input.seedanceRealFace && !input.realFaceConsent) {
1838
+ return ok({
1839
+ requires_clarification: true,
1840
+ missing: ['realFaceConsent'],
1841
+ message: 'Real-person motion transfer needs consent: confirm with the user that they hold the rights/consent to this likeness, then retry with realFaceConsent=true.',
1842
+ });
1843
+ }
1844
+ if (!input.sourceVideoSeconds) {
1845
+ return ok({
1846
+ requires_clarification: true,
1847
+ missing: ['sourceVideoSeconds'],
1848
+ message: 'Seedance motion transfer bills combined input+output seconds. Pass sourceVideoSeconds (the driving clip\'s duration from slates_list_assets, must be 2-15s).',
1849
+ });
1850
+ }
1851
+ seedanceDuration = input.duration ?? Math.min(15, Math.max(4, Math.ceil(input.sourceVideoSeconds)));
1852
+ costKey = videoCostKey({
1853
+ model: 'seedance-2',
1854
+ duration: seedanceDuration,
1855
+ videoResolution: input.videoResolution ?? '1080p',
1856
+ seedanceFace: input.seedanceFace !== false && input.seedanceRealFace !== true,
1857
+ seedanceRealFace: input.seedanceRealFace === true,
1858
+ videoRefSeconds: input.sourceVideoSeconds,
1859
+ });
1860
+ }
1861
+ else {
1862
+ costKey = motionModel === 'kling-mc-std' ? 'kling-mc-std-5s' : 'kling-mc-pro-5s';
1863
+ }
1667
1864
  const cloud = ctx.cloud();
1668
1865
  const registry = await cloud.get('/api/agent/models');
1669
1866
  const entry = registry.models.find((m) => m.model === costKey);
1670
1867
  if (!entry)
1671
1868
  throw new Error(`Model variant not in registry: ${costKey}`);
1672
- const totalCents = entry.cost_cents;
1869
+ const totalCents = creditCost(entry);
1673
1870
  // Cost confirm gate. Motion transfer is mechanical — the model
1674
1871
  // applies source motion to target image deterministically. We don't
1675
1872
  // burn tokens previewing assets the user already chose; codes in the
1676
1873
  // text are enough to keep the chat unambiguous.
1677
- if (totalCents > 50 && !input.confirm) {
1874
+ if (totalCents > CONFIRM_CREDITS && !input.confirm) {
1678
1875
  const desktop = ctx.desktop();
1679
1876
  const [source, target] = await Promise.all([
1680
1877
  lookupAssetRef(desktop, input.sourceVideoAssetId),
@@ -1684,12 +1881,12 @@ export const generateMotionTransfer = {
1684
1881
  requires_confirm: true,
1685
1882
  variant: costKey,
1686
1883
  estimated_cents: totalCents,
1687
- estimated_dollars: (totalCents / 100).toFixed(2),
1884
+ estimated_credits: totalCents,
1688
1885
  source_ref: source,
1689
1886
  target_ref: target,
1690
- message: `Cost: $${(totalCents / 100).toFixed(2)} for 5s ${motionModel}. ` +
1887
+ message: `Cost: ${fmtCredits(totalCents)} for ${isSeedance ? `${seedanceDuration}s Seedance motion transfer` : `5s ${motionModel}`} (${costKey}). ` +
1691
1888
  `Transferring motion from ${source} onto ${target}. ` +
1692
- `Re-call with confirm=true after the user explicitly OKs the spend, or pick kling-mc-std to save $0.31. ` +
1889
+ `Re-call with confirm=true after the user explicitly OKs the spend${isSeedance ? '' : ', or pick kling-mc-std to save ~10 credits'}. ` +
1693
1890
  `When discussing with the user, refer to the assets by those codes — they'll match the gallery badges.`,
1694
1891
  });
1695
1892
  }
@@ -1707,24 +1904,39 @@ export const generateMotionTransfer = {
1707
1904
  klingProvider: input.klingProvider,
1708
1905
  estimatedCost: totalCents,
1709
1906
  background: input.background,
1907
+ // Seedance engine passthrough — the desktop delegates to the seedance
1908
+ // ref-to-video path (vref billing, face cascade, consent gate).
1909
+ ...(isSeedance
1910
+ ? {
1911
+ duration: seedanceDuration,
1912
+ videoResolution: input.videoResolution,
1913
+ aspectRatio: input.aspectRatio,
1914
+ seedanceFace: input.seedanceFace !== false && input.seedanceRealFace !== true,
1915
+ seedanceRealFace: input.seedanceRealFace === true,
1916
+ realFaceConsent: input.realFaceConsent === true,
1917
+ // Ride the caller-supplied clip duration through — the asset row's
1918
+ // duration can be null for imported clips.
1919
+ sourceVideoDurationSeconds: input.sourceVideoSeconds,
1920
+ }
1921
+ : {}),
1710
1922
  });
1711
1923
  if (!result.success)
1712
1924
  throw new Error(result.error ?? 'Motion transfer generation failed');
1713
1925
  if (result.background) {
1714
1926
  const ids = result.generationIds ?? (result.generationId ? [result.generationId] : []);
1715
- return backgroundSubmitted(`5s motion transfer (${motionModel})`, ids, {
1927
+ return backgroundSubmitted(`${isSeedance ? `${seedanceDuration}s` : '5s'} motion transfer (${motionModel})`, ids, {
1716
1928
  variant: costKey,
1717
1929
  motionModel,
1718
1930
  projectId: input.projectId,
1719
1931
  sourceVideoAssetId: input.sourceVideoAssetId,
1720
1932
  targetImageAssetId: input.targetImageAssetId,
1721
1933
  cost_cents: totalCents,
1722
- cost_dollars: (totalCents / 100).toFixed(2),
1934
+ cost_credits: totalCents,
1723
1935
  });
1724
1936
  }
1725
1937
  return {
1726
- text: `Generated 5s motion transfer (${motionModel}) into project ${input.projectId} ` +
1727
- `for $${(totalCents / 100).toFixed(2)} (${totalCents}¢).` +
1938
+ text: `Generated ${isSeedance ? `${seedanceDuration}s` : '5s'} motion transfer (${motionModel}) into project ${input.projectId} ` +
1939
+ `for ${fmtCredits(totalCents)}.` +
1728
1940
  (input.prompt ? ` Prompt: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"` : ''),
1729
1941
  data: {
1730
1942
  variant: costKey,
@@ -1733,7 +1945,133 @@ export const generateMotionTransfer = {
1733
1945
  sourceVideoAssetId: input.sourceVideoAssetId,
1734
1946
  targetImageAssetId: input.targetImageAssetId,
1735
1947
  cost_cents: totalCents,
1736
- cost_dollars: (totalCents / 100).toFixed(2),
1948
+ cost_credits: totalCents,
1949
+ asset: result.asset,
1950
+ generationId: result.generationId,
1951
+ },
1952
+ };
1953
+ },
1954
+ };
1955
+ // ── Edit video (Kling O3 video-to-video) ────────────────────────
1956
+ export const editVideo = {
1957
+ id: 'slates_edit_video',
1958
+ 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.',
1959
+ input: z.object({
1960
+ projectId: z.string().uuid().describe('Project the source clip lives in.'),
1961
+ 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.'),
1962
+ 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.'),
1963
+ 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.'),
1964
+ 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).'),
1965
+ styleAssetIds: z.array(z.string()).max(4).optional().describe('Style/appearance reference images (@ImageN). Max 4 combined with characterAssetIds.'),
1966
+ keepAudio: z.boolean().optional().describe('Preserve the original audio track (default true).'),
1967
+ background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
1968
+ confirm: z.boolean().optional().describe('Set true to bypass the cost confirm gate after the user OKs the spend.'),
1969
+ }),
1970
+ async run(input, ctx) {
1971
+ const desktop = ctx.desktop();
1972
+ await desktop.requireCapability('edit-video', 'video editing (Kling O3 edit)');
1973
+ if (input.background) {
1974
+ await desktop.requireCapability('background-generation', 'background generation');
1975
+ }
1976
+ const model = input.model ?? 'kling-v3.0-omni-edit';
1977
+ // Resolve refs (UUIDs or badge codes) against the project AT CALL TIME.
1978
+ const refInputs = [
1979
+ { ref: input.sourceVideoAssetId, role: 'source clip' },
1980
+ ...(input.characterAssetIds ?? []).map((ref) => ({ ref, role: 'subject element' })),
1981
+ ...(input.styleAssetIds ?? []).map((ref) => ({ ref, role: 'style' })),
1982
+ ];
1983
+ const resolved = await resolveAssetRefs(ctx, input.projectId, refInputs.map((r) => r.ref));
1984
+ const rid = (ref) => resolved.get(ref)?.id ?? ref;
1985
+ const sourceId = rid(input.sourceVideoAssetId);
1986
+ const characterAssetIds = (input.characterAssetIds ?? []).map(rid);
1987
+ const styleAssetIds = (input.styleAssetIds ?? []).map(rid);
1988
+ const refEcho = describeResolvedRefs(refInputs, resolved);
1989
+ if (characterAssetIds.length + styleAssetIds.length > 4) {
1990
+ throw new Error('Kling O3 edit takes max 4 combined subject + style references.');
1991
+ }
1992
+ // The billed key needs the clip's duration (ceiled). Read it from the
1993
+ // project's asset records — the desktop route independently re-validates.
1994
+ const { assets } = await desktop.get('/agent/assets', { projectId: input.projectId });
1995
+ const sourceRow = (assets ?? []).find((a) => String(a.id).toLowerCase() === sourceId.toLowerCase());
1996
+ if (!sourceRow)
1997
+ throw new Error(`Source asset not found in project: ${input.sourceVideoAssetId}`);
1998
+ if (sourceRow.type !== 'video')
1999
+ throw new Error('sourceVideoAssetId must reference a VIDEO asset.');
2000
+ const clipSeconds = Number(sourceRow.duration);
2001
+ if (!Number.isFinite(clipSeconds) || clipSeconds <= 0) {
2002
+ throw new Error('Source clip has no recorded duration — cannot quote the edit. Re-import the clip or pick another.');
2003
+ }
2004
+ if (clipSeconds > 15.05 || clipSeconds < 2.95) {
2005
+ 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).`);
2006
+ }
2007
+ const billedSeconds = Math.min(15, Math.max(3, Math.ceil(clipSeconds - 0.05)));
2008
+ const costKey = klingEditCostKey(model, billedSeconds);
2009
+ const cloud = ctx.cloud();
2010
+ const registry = await cloud.get('/api/agent/models');
2011
+ const entry = registry.models.find((m) => m.model === costKey);
2012
+ if (!entry)
2013
+ throw new Error(`Model variant not in registry: ${costKey}`);
2014
+ const totalCents = creditCost(entry);
2015
+ // Confirm gate — look-first: preview the source clip + refs so the LLM
2016
+ // sees what it's editing before committing spend (mirrors generateVideo).
2017
+ if ((totalCents > CONFIRM_CREDITS || refInputs.length > 1) && !input.confirm) {
2018
+ const previews = await previewAssets(ctx, [
2019
+ { id: sourceId, type: 'video', role: 'source clip' },
2020
+ ...characterAssetIds.map((id) => ({ id, type: 'image', role: 'subject element' })),
2021
+ ...styleAssetIds.map((id) => ({ id, type: 'image', role: 'style' })),
2022
+ ]);
2023
+ return {
2024
+ text: `Cost: ${fmtCredits(totalCents)} to edit a ${billedSeconds}s clip with ${model} (${costKey}). ` +
2025
+ `${refEcho} Re-call with confirm=true after the user explicitly OKs the spend.`,
2026
+ images: previews.flatMap((p) => p.images),
2027
+ data: {
2028
+ requires_confirm: true,
2029
+ model,
2030
+ variant: costKey,
2031
+ billed_seconds: billedSeconds,
2032
+ clip_seconds: clipSeconds,
2033
+ estimated_cents: totalCents,
2034
+ estimated_credits: totalCents,
2035
+ references: previews.map((p) => ({ ref: p.ref, role: p.role, ...p.meta })),
2036
+ },
2037
+ };
2038
+ }
2039
+ const result = await desktop.post('/agent/generation/edit-video', {
2040
+ projectId: input.projectId,
2041
+ model,
2042
+ prompt: input.prompt,
2043
+ sourceVideoAssetId: sourceId,
2044
+ characterAssetIds,
2045
+ styleAssetIds,
2046
+ keepAudio: input.keepAudio !== false,
2047
+ background: input.background,
2048
+ });
2049
+ if (!result.success)
2050
+ throw new Error(result.error ?? 'Video edit failed');
2051
+ if (result.background) {
2052
+ const ids = result.generationId ? [result.generationId] : [];
2053
+ return backgroundSubmitted(`${billedSeconds}s video edit (${model})`, ids, {
2054
+ model,
2055
+ variant: costKey,
2056
+ projectId: input.projectId,
2057
+ sourceVideoAssetId: sourceId,
2058
+ cost_cents: totalCents,
2059
+ cost_credits: totalCents,
2060
+ }, refEcho);
2061
+ }
2062
+ return {
2063
+ text: `Edited ${billedSeconds}s clip saved as a new asset in project ${input.projectId} ` +
2064
+ `for ${fmtCredits(totalCents)} via ${model}. ` +
2065
+ `Edit: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"` +
2066
+ (refEcho ? ` ${refEcho}` : ''),
2067
+ data: {
2068
+ model,
2069
+ variant: costKey,
2070
+ projectId: input.projectId,
2071
+ sourceVideoAssetId: sourceId,
2072
+ billed_seconds: billedSeconds,
2073
+ cost_cents: totalCents,
2074
+ cost_credits: totalCents,
1737
2075
  asset: result.asset,
1738
2076
  generationId: result.generationId,
1739
2077
  },
@@ -1771,7 +2109,7 @@ export const getGenerationStatus = {
1771
2109
  // agent acts on, with the EXACT billed cost front and center.
1772
2110
  return ok({
1773
2111
  status,
1774
- cost_cents: g.cost ?? null,
2112
+ cost_credits: g.cost != null ? creditsFromDollars(g.cost) : null,
1775
2113
  error: g.error ?? null,
1776
2114
  model: g.model,
1777
2115
  completed_at: g.completedAt ?? null,
@@ -2238,6 +2576,8 @@ function resolveGuideTopic(topic) {
2238
2576
  }
2239
2577
  if (t.startsWith('nano-banana'))
2240
2578
  return 'slates-prompting-nano-banana-2';
2579
+ if (t.startsWith('gpt-image') || t.startsWith('gpt image'))
2580
+ return 'slates-prompting-gpt-image-2';
2241
2581
  if (t.startsWith('flux'))
2242
2582
  return 'slates-prompting-flux-2-max';
2243
2583
  if (t.startsWith('seedream'))
@@ -2246,6 +2586,8 @@ function resolveGuideTopic(topic) {
2246
2586
  return 'slates-prompting-veo-3';
2247
2587
  if (t.startsWith('kling-mc'))
2248
2588
  return 'slates-prompting-motion-transfer';
2589
+ if (t === 'edit-video' || t === 'video-edit' || t === 'edit video' || t === 'video edit')
2590
+ return 'slates-prompting-kling-v3';
2249
2591
  if (t.startsWith('kling-v3'))
2250
2592
  return 'slates-prompting-kling-v3';
2251
2593
  if (t.startsWith('seedance'))
@@ -2323,6 +2665,7 @@ export const ALL_OPERATIONS = [
2323
2665
  generateVideo,
2324
2666
  generateLipSync,
2325
2667
  generateMotionTransfer,
2668
+ editVideo,
2326
2669
  editImage,
2327
2670
  getGenerationStatus,
2328
2671
  listGenerations,