@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.
- package/dist/clients/cloud.d.ts +7 -7
- package/dist/operations/index.d.ts +43 -6
- package/dist/operations/index.js +456 -113
- package/dist/prompts/model-facts.js +34 -2
- package/dist/prompts/reference-composer.d.ts +35 -0
- package/dist/prompts/reference-composer.js +81 -0
- package/dist/skills/content.js +13 -12
- package/package.json +1 -1
- package/skills/slates-content-policy.md +8 -0
- package/skills/slates-cost-discipline.md +13 -11
- package/skills/slates-direct-response-ad.md +2 -2
- package/skills/slates-edit-and-iterate.md +1 -1
- package/skills/slates-model-selection.md +31 -1
- package/skills/slates-prompting-gpt-image-2.md +40 -0
- package/skills/slates-prompting-kling-v3.md +26 -0
- package/skills/slates-prompting-lip-sync.md +31 -17
- package/skills/slates-prompting-motion-transfer.md +29 -15
- package/skills/slates-prompting-nano-banana-2.md +9 -0
- package/skills/slates-prompting-seedance.md +9 -0
- package/skills/slates-storyboard-from-script.md +1 -1
- package/skills/slates-vision-feedback-loop.md +1 -1
package/dist/operations/index.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
120
|
+
const table = models.map((m) => `${m.model} ${creditCost(m)}`).join('\n');
|
|
97
121
|
return {
|
|
98
|
-
text: `${models.length} COST keys (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
156
|
-
if (key == 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
|
|
196
|
+
const totalCredits = perCredits * qty;
|
|
161
197
|
return ok({
|
|
162
198
|
model: input.model,
|
|
163
199
|
cost_key: key,
|
|
164
200
|
quantity: qty,
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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
|
|
727
|
-
//
|
|
728
|
-
|
|
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.
|
|
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.
|
|
741
|
-
projectId: z.string().uuid().optional().describe('Save into this Slates project. Renderer refreshes live. Required for
|
|
742
|
-
resolution: z.enum(['1k', '2k', '4k']).optional().describe('Pick deliberately: 1k drafts, 2k hero shots, 4k print/final.
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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 >
|
|
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
|
-
|
|
831
|
-
message:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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('
|
|
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
|
|
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
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
|
1063
|
-
if (totalCents >
|
|
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
|
-
|
|
1133
|
+
estimated_credits: totalCents,
|
|
1070
1134
|
source_ref: sourceRef,
|
|
1071
|
-
message: `Cost:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 ×
|
|
1161
|
-
// AI-face route bills the `-face-` key (~45% over faceless);
|
|
1162
|
-
// real-person route bills the premium `-realface-` key (fal partner
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 >
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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('
|
|
1545
|
-
klingProvider: z.enum(['fal', 'kling']).optional().describe('
|
|
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
|
|
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
|
-
|
|
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
|
|
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 >
|
|
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
|
-
|
|
1736
|
+
estimated_credits: totalCents,
|
|
1593
1737
|
source_ref: sourceRef,
|
|
1594
|
-
message: `Cost:
|
|
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
|
-
|
|
1787
|
+
cost_credits: totalCents,
|
|
1628
1788
|
});
|
|
1629
1789
|
}
|
|
1630
1790
|
return {
|
|
1631
|
-
text: `Generated 5s lip-sync (${costKey}) into project ${input.projectId} ` +
|
|
1632
|
-
`for
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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('
|
|
1660
|
-
klingProvider: z.enum(['fal', 'kling']).optional().describe('
|
|
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
|
|
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
|
|
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
|
|
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 >
|
|
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
|
-
|
|
1884
|
+
estimated_credits: totalCents,
|
|
1688
1885
|
source_ref: source,
|
|
1689
1886
|
target_ref: target,
|
|
1690
|
-
message: `Cost:
|
|
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
|
|
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
|
-
|
|
1934
|
+
cost_credits: totalCents,
|
|
1723
1935
|
});
|
|
1724
1936
|
}
|
|
1725
1937
|
return {
|
|
1726
|
-
text: `Generated 5s motion transfer (${motionModel}) into project ${input.projectId} ` +
|
|
1727
|
-
`for
|
|
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
|
-
|
|
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
|
-
|
|
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,
|