@slatesvideo/shared 0.5.9 → 0.5.10

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/index.d.ts CHANGED
@@ -5,5 +5,6 @@ export { SKILLS } from './skills/content.js';
5
5
  export * as operations from './operations/index.js';
6
6
  export { ALL_OPERATIONS, VIDEO_MODELS, AUDIO_MODELS, defaultContext, type Operation, type OperationContext, type OperationResult } from './operations/index.js';
7
7
  export { MODEL_FACTS, getModelFact, multimodalRefSummary, multimodalRefModels, SEEDANCE_TASK_INTENT_WORDS, seedanceTaskIntentWords, type ModelFact, } from './prompts/model-facts.js';
8
+ export { MODEL_CAPABILITIES, ALL_ASPECT_RATIOS, AGENT_ROUTE_PROVIDER, getModelCapability, aspectRatiosFor, videoResolutionsFor, defaultVideoResolutionFor, durationsFor, durationValuesFor, aspectRatioUnion, videoResolutionUnion, durationBounds, checkAspectRatio, checkVideoResolution, checkDuration, describeAspectRatios, describeVideoResolutions, describeDurations, describeReferenceImageCaps, type AspectRatio, type VideoResolution, type ModelCapability, type DurationCapability, type VideoResolutionCapability, } from './prompts/model-capabilities.js';
8
9
  export { PROMPTING_TIPS, getPromptingTips, type PromptingTipsEntry, type PromptingTipCard, type PromptingTipsKey } from './prompts/prompting-tips.js';
9
10
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -11,6 +11,12 @@ export { MODEL_FACTS, getModelFact, multimodalRefSummary, multimodalRefModels,
11
11
  // Mirrored in slate/src/shared/pricing.ts — see the constant's own header for
12
12
  // why the mirror exists and why it must never drive a prompt rewrite.
13
13
  SEEDANCE_TASK_INTENT_WORDS, seedanceTaskIntentWords, } from './prompts/model-facts.js';
14
+ // Model CAPABILITY SSOT — what each model will ACCEPT (aspect ratios incl.
15
+ // per-provider overrides, video resolutions, duration windows, reference caps).
16
+ // `slate/src/shared/pricing.ts` spreads these into every MODEL_REGISTRY entry;
17
+ // the op surface validates against them and GENERATES its `.describe()` prose
18
+ // from them. Never hand-type a capability fact an LLM will read.
19
+ export { MODEL_CAPABILITIES, ALL_ASPECT_RATIOS, AGENT_ROUTE_PROVIDER, getModelCapability, aspectRatiosFor, videoResolutionsFor, defaultVideoResolutionFor, durationsFor, durationValuesFor, aspectRatioUnion, videoResolutionUnion, durationBounds, checkAspectRatio, checkVideoResolution, checkDuration, describeAspectRatios, describeVideoResolutions, describeDurations, describeReferenceImageCaps, } from './prompts/model-capabilities.js';
14
20
  // Per-model prompting tips — the SSOT for the desktop "See prompting tips"
15
21
  // modals. The desktop renders these; it never hand-writes tips content.
16
22
  export { PROMPTING_TIPS, getPromptingTips } from './prompts/prompting-tips.js';
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { SlatesCloudClient } from '../clients/cloud.js';
3
3
  import { SlatesDesktopClient } from '../clients/desktop.js';
4
+ import { type AspectRatio, type VideoResolution } from '../prompts/model-capabilities.js';
4
5
  export interface OperationContext {
5
6
  cloud: () => SlatesCloudClient;
6
7
  desktop: () => SlatesDesktopClient;
@@ -28,6 +29,12 @@ export declare const getCreditBalance: Operation<Record<string, never>>;
28
29
  export declare const listAvailableModels: Operation<{
29
30
  filter?: string;
30
31
  }>;
32
+ export declare const VIDEO_MODELS: readonly ["kling-v3.0-std", "kling-v3.0-pro", "kling-v3.0-omni", "veo-3.1-fast", "veo-3.1-standard", "seedance-2", "seedance-2.5", "omni-flash"];
33
+ type VideoModel = (typeof VIDEO_MODELS)[number];
34
+ /** The exact `model` ids `slates_edit_video` accepts. Edit rows are deliberately
35
+ * NOT in VIDEO_MODELS — they take a source clip, not frames. */
36
+ export declare const EDIT_VIDEO_MODELS: readonly ["kling-v3.0-omni-edit", "kling-v3.0-omni-pro-edit", "omni-flash-edit", "seedance-2.5-edit"];
37
+ type EditVideoModel = (typeof EDIT_VIDEO_MODELS)[number];
31
38
  export declare const estimateGenerationCost: Operation<{
32
39
  model: string;
33
40
  quantity?: number;
@@ -156,12 +163,14 @@ export declare const addFrame: Operation<{
156
163
  position?: number;
157
164
  }>;
158
165
  export type ImageModelId = 'nano-banana-2' | 'nano-banana-2-lite' | 'nano-banana-pro' | 'gpt-image-2' | 'flux-2-max' | 'seedream-5-lite';
166
+ /** The exact `model` ids `slates_generate_image` accepts. */
167
+ export declare const IMAGE_MODELS: readonly ["nano-banana-2", "nano-banana-2-lite", "nano-banana-pro", "gpt-image-2", "flux-2-max", "seedream-5-lite"];
159
168
  export declare const generateImage: Operation<{
160
169
  prompt: string;
161
170
  model?: ImageModelId;
162
171
  projectId?: string;
163
172
  resolution?: '1k' | '2k' | '3k' | '4k';
164
- aspectRatio?: '1:1' | '16:9' | '9:16' | '4:3' | '3:4' | '21:9' | '9:21' | '4:5' | '5:4' | '2:3' | '3:2';
173
+ aspectRatio?: AspectRatio;
165
174
  quality?: 'medium' | 'high';
166
175
  count?: number;
167
176
  referenceImageUrls?: string[];
@@ -181,8 +190,6 @@ export declare const editImage: Operation<{
181
190
  confirm?: boolean;
182
191
  background?: boolean;
183
192
  }>;
184
- export declare const VIDEO_MODELS: readonly ["kling-v3.0-std", "kling-v3.0-pro", "kling-v3.0-omni", "veo-3.1-fast", "veo-3.1-standard", "seedance-2", "seedance-2.5", "omni-flash"];
185
- type VideoModel = (typeof VIDEO_MODELS)[number];
186
193
  export declare function videoCostKey(input: {
187
194
  model: VideoModel;
188
195
  duration: number;
@@ -197,9 +204,11 @@ export declare function videoCostKey(input: {
197
204
  }): string;
198
205
  export declare function klingEditCostKey(model: 'kling-v3.0-omni-edit' | 'kling-v3.0-omni-pro-edit', duration: number): string;
199
206
  export declare function omniFlashEditCostKey(duration: number): string;
200
- /** Seedance 2.5 source-clip bounds for the edit task type. */
201
- export declare const SEEDANCE_25_EDIT_MIN_SECONDS = 4;
202
- export declare const SEEDANCE_25_EDIT_MAX_SECONDS = 30;
207
+ /** Seedance 2.5 source-clip bounds for the edit task type — READ from the
208
+ * capability SSOT, not typed. Kept as named exports because published builds
209
+ * import them. */
210
+ export declare const SEEDANCE_25_EDIT_MIN_SECONDS: number;
211
+ export declare const SEEDANCE_25_EDIT_MAX_SECONDS: number;
203
212
  export declare function seedanceEditCostKey(input: {
204
213
  duration: number;
205
214
  videoResolution?: '480p' | '720p';
@@ -241,9 +250,9 @@ export declare const generateVideo: Operation<{
241
250
  prompt: string;
242
251
  model: string;
243
252
  projectId?: string;
244
- aspectRatio?: '1:1' | '16:9' | '9:16' | '4:3' | '3:4' | '21:9' | '9:21' | '4:5' | '5:4' | '2:3' | '3:2';
253
+ aspectRatio?: AspectRatio;
245
254
  duration?: number;
246
- videoResolution?: '480p' | '720p' | '1080p' | '4k';
255
+ videoResolution?: VideoResolution;
247
256
  firstFrameAssetId?: string;
248
257
  lastFrameAssetId?: string;
249
258
  ingredientAssetIds?: string[];
@@ -313,11 +322,11 @@ export declare const editVideo: Operation<{
313
322
  projectId: string;
314
323
  sourceVideoAssetId: string;
315
324
  prompt: string;
316
- model?: 'kling-v3.0-omni-edit' | 'kling-v3.0-omni-pro-edit' | 'omni-flash-edit' | 'seedance-2.5-edit';
325
+ model?: EditVideoModel;
317
326
  characterAssetIds?: string[];
318
327
  styleAssetIds?: string[];
319
328
  keepAudio?: boolean;
320
- videoResolution?: '480p' | '720p';
329
+ videoResolution?: VideoResolution;
321
330
  seedanceFace?: boolean;
322
331
  background?: boolean;
323
332
  confirm?: boolean;
@@ -16,6 +16,16 @@ import { SKILLS } from '../skills/content.js';
16
16
  // Reference-capacity prose is DERIVED, never hand-typed — root CLAUDE.md:
17
17
  // "never hand-type a fact an LLM will read". These helpers read MODEL_FACTS.
18
18
  import { multimodalRefSummary, multimodalRefModels, seedanceTaskIntentWords } from '../prompts/model-facts.js';
19
+ // 🚨 MODEL CAPABILITY SSOT. Aspect ratios, video resolutions and durations are
20
+ // VALIDATED against and DESCRIBED from `MODEL_CAPABILITIES` — this file must
21
+ // never re-state one of those constraints as a literal enum or a sentence
22
+ // again. It did until 2026-08-16, and every axis had drifted: an invented
23
+ // `9:21`, "Kling/Seedance support all" (Seedance takes 6 of 11 and has no
24
+ // `4:5`), "Veo locks to 16:9" (two on fal), "Kling: 5-15" (min is 3), a
25
+ // `videoResolution` line that never mentioned Kling, and 4s quoted for Veo at
26
+ // 1080p. A customer burned a round trip on `4:5` + Seedance: accepted here,
27
+ // queued, credits reserved, rejected by the provider asynchronously.
28
+ import { AGENT_ROUTE_PROVIDER, aspectRatioUnion, videoResolutionUnion, durationBounds, aspectRatiosFor, getModelCapability, defaultVideoResolutionFor, checkAspectRatio, checkVideoResolution, checkDuration, describeAspectRatios, describeVideoResolutions, describeDurations, describeReferenceImageCaps, } from '../prompts/model-capabilities.js';
19
29
  export function defaultContext() {
20
30
  return {
21
31
  cloud: () => new SlatesCloudClient(),
@@ -23,6 +33,18 @@ export function defaultContext() {
23
33
  };
24
34
  }
25
35
  // ── Helpers ─────────────────────────────────────────────────────
36
+ /**
37
+ * `z.enum` over a GENERATED list. Zod's signature wants a non-empty tuple
38
+ * literal, which is exactly what you can't write when the values come from the
39
+ * capability SSOT — and writing them out is how the enums drifted in the first
40
+ * place. Callers must pass a non-empty array; every call site derives from
41
+ * `MODEL_CAPABILITIES`, which never yields an empty union for a real model set.
42
+ */
43
+ function zEnum(values) {
44
+ if (values.length === 0)
45
+ throw new Error('zEnum: empty value list');
46
+ return z.enum(values);
47
+ }
26
48
  function ok(data, text) {
27
49
  return {
28
50
  // Compact JSON — pretty-printing (indent 2) cost ~10-15% extra tokens
@@ -133,14 +155,82 @@ export const listAvailableModels = {
133
155
  };
134
156
  },
135
157
  };
158
+ // ── Video model vocabulary ──────────────────────────────────────
159
+ //
160
+ // Declared HERE, above every op, because both `slates_estimate_generation_cost`
161
+ // and `slates_generate_video` build their Zod schemas from it at module load —
162
+ // and a schema is evaluated in source order, so a list declared below the first
163
+ // op that reads it is a temporal-dead-zone crash, not a lint nit.
164
+ // Exported: the exact `model` ids slates_generate_video accepts — consumed
165
+ // by the desktop Studio Agent system prompt (SSOT; never restate these ids
166
+ // in prose that can drift).
167
+ export const VIDEO_MODELS = [
168
+ 'kling-v3.0-std',
169
+ 'kling-v3.0-pro',
170
+ 'kling-v3.0-omni',
171
+ 'veo-3.1-fast',
172
+ 'veo-3.1-standard',
173
+ 'seedance-2',
174
+ // Seedance 2.5 is a SECOND SEAT, not a replacement: 30s takes, 30 image
175
+ // references, audio-only references — and 480p/720p ONLY. 2.0 keeps the
176
+ // ladder to native 4K and stays the default. Its EDIT row is not here; edit
177
+ // models live on slates_edit_video, same as the Kling and Omni Flash ones.
178
+ 'seedance-2.5',
179
+ 'omni-flash',
180
+ ];
181
+ // ── Capability-derived param vocabulary + guard ─────────────────
182
+ //
183
+ // 🚨 EVERYTHING BELOW IS GENERATED FROM `MODEL_CAPABILITIES`. Not one aspect
184
+ // ratio, resolution or duration is typed into this file any more, and none may
185
+ // be again. The enums are the UNION of what the video models accept (so the
186
+ // JSON schema still advertises the legal universe to the LLM) and
187
+ // `assertVideoCapabilities` does the PER-MODEL narrowing.
188
+ /** Union of the ratios every video model accepts on the agent's route. */
189
+ const VIDEO_ASPECT_RATIOS = aspectRatioUnion(VIDEO_MODELS, AGENT_ROUTE_PROVIDER);
190
+ /** Union of the resolutions every video model renders at. */
191
+ const VIDEO_RESOLUTIONS = videoResolutionUnion(VIDEO_MODELS);
192
+ /** Widest legal duration window across every video model. */
193
+ const VIDEO_DURATION_BOUNDS = durationBounds(VIDEO_MODELS);
194
+ /** Omni Flash's combined reference-image cap, read from the SSOT (7). */
195
+ const omniFlashRefCap = getModelCapability('omni-flash')?.maxIngredientImages ?? 0;
196
+ /** The exact `model` ids `slates_edit_video` accepts. Edit rows are deliberately
197
+ * NOT in VIDEO_MODELS — they take a source clip, not frames. */
198
+ export const EDIT_VIDEO_MODELS = [
199
+ 'kling-v3.0-omni-edit',
200
+ 'kling-v3.0-omni-pro-edit',
201
+ 'omni-flash-edit',
202
+ 'seedance-2.5-edit',
203
+ ];
204
+ /**
205
+ * Source-clip length an edit engine accepts, read from the SSOT.
206
+ *
207
+ * On an edit row the output length FOLLOWS the source clip, so the model's
208
+ * declared duration window is exactly the legal clip window — Kling 3-15s,
209
+ * Omni Flash 3-10s, Seedance 2.5 4-30s. These three pairs used to be typed
210
+ * inline as `isSeedanceEdit ? 4 : 3` / `isSeedanceEdit ? 30 : isOmniFlashEdit ?
211
+ * 10 : 15`, i.e. a fourth copy of registry data keyed on model-id comparisons.
212
+ */
213
+ function editClipBounds(model) {
214
+ const d = getModelCapability(model)?.duration;
215
+ if (!d)
216
+ throw new Error(`editClipBounds: no duration capability for "${model}"`);
217
+ return { min: d.min, max: d.max };
218
+ }
219
+ /** Resolutions any edit engine exposes as a PARAM. Only Seedance's edit price
220
+ * moves with resolution; the Kling and Omni Flash rows are fixed to the source. */
221
+ const EDIT_VIDEO_RESOLUTIONS = videoResolutionUnion(['seedance-2.5-edit']);
136
222
  export const estimateGenerationCost = {
137
223
  id: 'slates_estimate_generation_cost',
138
224
  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.',
139
225
  input: z.object({
140
226
  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")'),
141
227
  quantity: z.number().int().min(1).max(10).optional().describe('Number of generations (default 1)'),
142
- duration: z.number().int().min(1).max(360).optional().describe('Seconds. Video 3-15 (cost scales linearly; required with a video base id). Audio: seed-audio 3-120 (⚠️ the requested duration IS the bill), eleven-sfx 1-22 — required with either audio base id.'),
143
- videoResolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe('Video only. Seedance defaults to 1080p.'),
228
+ // Video windows and resolutions GENERATED from MODEL_CAPABILITIES. The
229
+ // hand-typed "Video 3-15" here was wrong the day seedance-2.5 (4-30s)
230
+ // shipped, and "Seedance defaults to 1080p" was wrong for 2.5, which has no
231
+ // 1080p at all.
232
+ duration: z.number().int().min(1).max(360).optional().describe(`Seconds; cost scales linearly. Required with a video or audio base id. Video per model: ${describeDurations(VIDEO_MODELS)}. Audio: seed-audio 3-120 (⚠️ the requested duration IS the bill), eleven-sfx 1-22.`),
233
+ videoResolution: zEnum(VIDEO_RESOLUTIONS).optional().describe(`Video only. Omitted, each model quotes at its own default. Per model: ${describeVideoResolutions(VIDEO_MODELS)}`),
144
234
  resolution: z.enum(['1k', '2k', '3k', '4k']).optional().describe('Image only (default 2k; 3k = gpt-image-2 1440p class).'),
145
235
  quality: z.enum(['medium', 'high']).optional().describe('gpt-image-2 only — quality tier (default medium).'),
146
236
  sound: z.boolean().optional().describe('Veo only — audio flag changes the cost key.'),
@@ -218,6 +308,16 @@ export const estimateGenerationCost = {
218
308
  message: `"${resolved.model}" cost scales with duration — pass duration (seconds) to estimate.`,
219
309
  });
220
310
  }
311
+ // Refuse an out-of-range combination rather than quoting a key that does
312
+ // not exist — the generate op gates the same way, from the same data, so
313
+ // a quote can never promise something generation then refuses.
314
+ const capErr = assertVideoCapabilities({
315
+ model: resolved.model,
316
+ videoResolution: input.videoResolution ?? resolved.videoResolution,
317
+ duration,
318
+ });
319
+ if (capErr)
320
+ return ok(capErr);
221
321
  key = videoCostKey({
222
322
  model: resolved.model,
223
323
  duration,
@@ -225,10 +325,9 @@ export const estimateGenerationCost = {
225
325
  resolved.videoResolution ??
226
326
  // Seedance quotes are resolution-scaled, so a missing resolution has
227
327
  // to fall back to the model's OWN default — 2.5 has no 1080p at all,
228
- // and a blanket '1080p' here quoted a key that does not exist.
229
- (resolved.model === 'seedance-2.5' ? '720p'
230
- : resolved.model.startsWith('seedance') ? '1080p'
231
- : undefined),
328
+ // and a blanket '1080p' here quoted a key that does not exist. Read
329
+ // from the SSOT; this used to be a hand-typed model-id ternary.
330
+ defaultVideoResolutionFor(resolved.model),
232
331
  sound: input.sound ?? resolved.sound,
233
332
  seedanceFace: input.seedanceFace ?? resolved.seedanceFace,
234
333
  seedanceRealFace: input.seedanceRealFace,
@@ -872,6 +971,30 @@ async function previewAssets(ctx, refs) {
872
971
  }
873
972
  return out;
874
973
  }
974
+ /** The exact `model` ids `slates_generate_image` accepts. */
975
+ export const IMAGE_MODELS = [
976
+ 'nano-banana-2',
977
+ 'nano-banana-2-lite',
978
+ 'nano-banana-pro',
979
+ 'gpt-image-2',
980
+ 'flux-2-max',
981
+ 'seedream-5-lite',
982
+ ];
983
+ /**
984
+ * The aspect ratios an image generation can carry — the UNION over what these
985
+ * models declare in `MODEL_CAPABILITIES`, generated so the enum cannot hold a
986
+ * value no model accepts. It used to be a hand-typed eleven-value list whose
987
+ * `9:21` exists in ZERO models; that phantom is gone by construction.
988
+ *
989
+ * ⚠️ STILL A UNION, NOT A PER-MODEL CHECK. `gpt-image-2` takes five of these
990
+ * ten and the other five would be accepted here. The image param surface has
991
+ * not been audited (aspect ratios, resolution classes, per-model reference
992
+ * caps) — that audit is the named follow-up in
993
+ * `slate/docs/plan-docs/2026-08-16-MODEL-CAPABILITY-SSOT.md` §5. The machinery
994
+ * to close it already exists: call `checkAspectRatio(model, ratio)` the way
995
+ * `assertVideoCapabilities` does below.
996
+ */
997
+ const IMAGE_ASPECT_RATIOS = aspectRatioUnion(IMAGE_MODELS);
875
998
  // Registry cost-key for an image model+resolution. Mirrors imageCreditKey()
876
999
  // in slate/src/shared/pricing.ts — MUST byte-match it (the same hard rule as
877
1000
  // videoCostKey): NB2/NB Pro price per resolution, FLUX.2 Max prices per
@@ -895,11 +1018,11 @@ export const generateImage = {
895
1018
  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.',
896
1019
  input: z.object({
897
1020
  prompt: z.string().min(1).max(4000),
898
- 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.'),
1021
+ model: zEnum(IMAGE_MODELS).optional().describe('Image model. Default nano-banana-2. Routing doctrine: slates-model-selection skill. All except nano-banana-2 require projectId.'),
899
1022
  projectId: z.string().uuid().optional().describe('Save into this Slates project. Renderer refreshes live. Required for every model except nano-banana-2.'),
900
1023
  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.'),
901
1024
  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.'),
902
- 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.'),
1025
+ aspectRatio: zEnum(IMAGE_ASPECT_RATIOS).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. Per model: ${describeAspectRatios(IMAGE_MODELS)}`),
903
1026
  count: z.number().int().min(1).max(4).optional(),
904
1027
  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.'),
905
1028
  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."),
@@ -922,7 +1045,8 @@ export const generateImage = {
922
1045
  missing,
923
1046
  message: `Missing required field(s): ${missing.join(', ')}. ` +
924
1047
  `Read the slates-prompting-nano-banana-2 + slates-cost-discipline skills, ` +
925
- `or ask the user. Aspect ratio options: 1:1 16:9 9:16 4:3 3:4 21:9 9:21 4:5 5:4 2:3 3:2. ` +
1048
+ // Generated from MODEL_CAPABILITIES — never retype a ratio list.
1049
+ `or ask the user. Aspect ratio by model: ${describeAspectRatios(IMAGE_MODELS)}. ` +
926
1050
  `Resolution options: 1k 2k 4k (same price band — pick by need, not cost).`,
927
1051
  });
928
1052
  }
@@ -1310,23 +1434,52 @@ export const editImage = {
1310
1434
  },
1311
1435
  };
1312
1436
  // ── Generate video ──────────────────────────────────────────────
1313
- // Exported: the exact `model` ids slates_generate_video accepts — consumed
1314
- // by the desktop Studio Agent system prompt (SSOT; never restate these ids
1315
- // in prose that can drift).
1316
- export const VIDEO_MODELS = [
1317
- 'kling-v3.0-std',
1318
- 'kling-v3.0-pro',
1319
- 'kling-v3.0-omni',
1320
- 'veo-3.1-fast',
1321
- 'veo-3.1-standard',
1322
- 'seedance-2',
1323
- // Seedance 2.5 is a SECOND SEAT, not a replacement: 30s takes, 30 image
1324
- // references, audio-only references — and 480p/720p ONLY. 2.0 keeps the
1325
- // ladder to native 4K and stays the default. Its EDIT row is not here; edit
1326
- // models live on slates_edit_video, same as the Kling and Omni Flash ones.
1327
- 'seedance-2.5',
1328
- 'omni-flash',
1329
- ];
1437
+ //
1438
+ // VIDEO_MODELS and the capability-derived param vocabulary are declared near the
1439
+ // top of this file: `slates_estimate_generation_cost`'s schema is evaluated at
1440
+ // module load and reads them, so they have to initialise before it.
1441
+ /**
1442
+ * Reject an illegal aspectRatio / videoResolution / duration BEFORE submit,
1443
+ * naming the legal set. Returns a `requires_clarification` payload or null.
1444
+ *
1445
+ * ⚠️ WHY THIS IS A RUN()-TIME GUARD AND NOT A SCHEMA `superRefine`. The op
1446
+ * accepts registry COST-KEY spellings as `model` ("seedance-2-1080p-8s") and
1447
+ * `resolveVideoModel()` unpacks them into base id + duration + resolution at
1448
+ * the top of `run()`. A schema-level refinement runs BEFORE that, so it would
1449
+ * see an unnormalised model id and a `duration` that is still undefined — it
1450
+ * would reject legal calls and wave illegal ones through. The check has to sit
1451
+ * after normalisation, which is here. It also lets the failure arrive as the
1452
+ * same teaching `requires_clarification` shape as every other gate in this op,
1453
+ * rather than a raw Zod error the agent has to guess its way out of.
1454
+ *
1455
+ * `promptMode` matters: Veo's reference-to-video endpoint is 8s only, declared
1456
+ * as `duration.modeOverrides.ingredients`. Free reference images with no
1457
+ * first/last frame IS ingredients mode — the same condition
1458
+ * `buildFalVeoRequest` uses to pick the ref2v endpoint.
1459
+ */
1460
+ function assertVideoCapabilities(input) {
1461
+ const freeRefs = (input.ingredientAssetIds?.length ?? 0) +
1462
+ (input.characterAssetIds?.length ?? 0) +
1463
+ (input.environmentAssetIds?.length ?? 0) +
1464
+ (input.styleAssetIds?.length ?? 0);
1465
+ const promptMode = freeRefs > 0 && !input.firstFrameAssetId && !input.lastFrameAssetId ? 'ingredients' : undefined;
1466
+ const ratioErr = checkAspectRatio(input.model, input.aspectRatio, AGENT_ROUTE_PROVIDER);
1467
+ if (ratioErr)
1468
+ return { requires_clarification: true, missing: ['aspectRatio'], message: ratioErr };
1469
+ const resErr = checkVideoResolution(input.model, input.videoResolution);
1470
+ if (resErr)
1471
+ return { requires_clarification: true, missing: ['videoResolution'], message: resErr };
1472
+ // Duration LAST: its legal window depends on the resolution, so a bad
1473
+ // resolution must be reported first or the duration message names a window
1474
+ // derived from a value the model never had.
1475
+ const durErr = checkDuration(input.model, input.duration, {
1476
+ videoResolution: input.videoResolution,
1477
+ promptMode,
1478
+ });
1479
+ if (durErr)
1480
+ return { requires_clarification: true, missing: ['duration'], message: durErr };
1481
+ return null;
1482
+ }
1330
1483
  // Model → registry cost-key. Each provider's keys ship with their own
1331
1484
  // shape (verified against /api/agent/models):
1332
1485
  // Kling: kling-v3-{standard|pro|omni}-{N}s — note the user-facing
@@ -1419,9 +1572,11 @@ export function omniFlashEditCostKey(duration) {
1419
1572
  * 2.0: refs 2–15s + output 4–15s. 2.5: refs to 30s + output to 30s. */
1420
1573
  const SEEDANCE_20_VREF_MAX_TOTAL = 30;
1421
1574
  const SEEDANCE_25_VREF_MAX_TOTAL = 60;
1422
- /** Seedance 2.5 source-clip bounds for the edit task type. */
1423
- export const SEEDANCE_25_EDIT_MIN_SECONDS = 4;
1424
- export const SEEDANCE_25_EDIT_MAX_SECONDS = 30;
1575
+ /** Seedance 2.5 source-clip bounds for the edit task type — READ from the
1576
+ * capability SSOT, not typed. Kept as named exports because published builds
1577
+ * import them. */
1578
+ export const SEEDANCE_25_EDIT_MIN_SECONDS = editClipBounds('seedance-2.5-edit').min;
1579
+ export const SEEDANCE_25_EDIT_MAX_SECONDS = editClipBounds('seedance-2.5-edit').max;
1425
1580
  // Seedance 2.5 video-edit cost key — mirrors seedanceCreditKey() in
1426
1581
  // slate/src/shared/pricing.ts for the edit row (must byte-match; checked by the
1427
1582
  // slates-api pricing-consistency script). Unlike the Kling and Omni Flash edit
@@ -1589,14 +1744,27 @@ export const generateVideo = {
1589
1744
  description: 'Generate video via Slates credits. REQUIRED before calling: read slates-model-selection (the routing doctrine), slates-cost-discipline, and the matching per-model prompting skill (slates-prompting-seedance / slates-prompting-seedance-2-5 / slates-prompting-kling-v3 / slates-prompting-veo-3) — video models prompt very differently; load them via slates_get_prompting_guide if no skill files are installed. Read slates-content-policy when the scene involves conflict, creatures, crowds, destruction, weapons, or young characters. projectId, aspectRatio, and duration are required (requires_clarification otherwise). Cost > $0.50 returns requires_confirm — pass confirm=true after explicit user OK. Image-to-video via firstFrameAssetId; first+last frames = Veo/Seedance only; ingredients via ingredientAssetIds (Kling Omni / Seedance). Asset params take UUIDs or badge codes ("IMG-A8").',
1590
1745
  input: z.object({
1591
1746
  prompt: z.string().min(1).max(4000),
1592
- model: z.string().describe('One of: kling-v3.0-std | kling-v3.0-pro | kling-v3.0-omni | seedance-2 | seedance-2.5 | veo-3.1-fast | veo-3.1-standard | omni-flash. Pass the BASE id — duration and videoResolution are separate params (registry cost keys like "kling-v3-standard-8s" auto-resolve). Route per the slates-model-selection skill: Kling std = general-purpose DEFAULT, Seedance 2 = premium physics/effects/hero tier, seedance-2.5 = a SECOND SEAT beside it (4-30s takes, 30 image refs, audio-only refs — but 480p/720p ONLY, so stay on seedance-2 whenever resolution matters), Veo = native-synced-audio niche only (16:9, 4/6/8s) — never the default, omni-flash = cheap 720p tier with audio included (3-10s, 16:9/9:16; t2v, single-start-frame i2v, or up to 7 reference images; no last frame / video / audio refs). All are VIDEO-only. For per-call cost, call slates_estimate_generation_cost — never quote prices from memory.'),
1747
+ // ROUTING doctrine only. Every capability number was stripped on 2026-08-16
1748
+ // and now lives in the aspectRatio / duration / videoResolution descriptions
1749
+ // below, generated from MODEL_CAPABILITIES. "Veo = 16:9 only" and
1750
+ // "seedance-2.5 480p/720p" were both stated here AND there, and the two
1751
+ // copies disagreed.
1752
+ model: z.string().describe(`One of: ${VIDEO_MODELS.join(' | ')}. Pass the BASE id — duration and videoResolution are separate params (registry cost keys like "kling-v3-standard-8s" auto-resolve). Route per the slates-model-selection skill: Kling std = general-purpose DEFAULT, Seedance 2 = premium physics/effects/hero tier, seedance-2.5 = a SECOND SEAT beside it (longer takes, far more references, audio-only refs — but no 1080p or 4K, so stay on seedance-2 whenever resolution matters), Veo = native-synced-audio niche only, never the default, omni-flash = cheap tier with audio included (t2v, single-start-frame i2v, or reference images; no last frame, no video/audio refs). All are VIDEO-only. Each model's legal aspect ratios, durations and resolutions are in those params' own descriptions — read them there, not from memory. For per-call cost, call slates_estimate_generation_cost.`),
1593
1753
  projectId: z.string().uuid().optional().describe('Save into this Slates project. Strongly recommended — the desktop UI shows a progress card live and the asset appears when complete.'),
1594
- aspectRatio: z.enum(['1:1', '16:9', '9:16', '4:3', '3:4', '21:9', '9:21', '4:5', '5:4', '2:3', '3:2']).optional().describe('Veo locks to 16:9 — passing anything else will be ignored or fail. Kling/Seedance support all.'),
1595
- duration: z.number().int().min(3).max(30).optional().describe('Seconds. Kling: 5-15. Veo: 4, 6, or 8 only (4K only at 8s). Seedance 2: 4-15. Seedance 2.5: 4-30 (the only model that reaches 30). Omni Flash: 3-10. Default 5 if omitted but always be explicit — cost scales linearly, and a 30s seedance-2.5 take is several hundred credits.'),
1596
- videoResolution: z.enum(['480p', '720p', '1080p', '4k']).optional().describe('Veo + Seedance. Seedance 2: 480p/720p/1080p/4K (default 1080p; 4K is native, the most expensive, and Pro-only). Seedance 2.5: 480p/720p ONLY (default 720p) — no 1080p and no 4K on any route Slates offers, so asking for one is rejected, not downgraded. Veo: 720p/1080p same price, 4K more (8s only).'),
1754
+ // 🚨 THESE THREE DESCRIPTIONS ARE GENERATED FROM `MODEL_CAPABILITIES`.
1755
+ // Never hand-write a ratio, resolution or duration into them again — every
1756
+ // one of the hand-written claims that stood here had drifted, and an
1757
+ // out-of-set value is accepted by the client and rejected by the provider
1758
+ // ASYNCHRONOUSLY, after the job queues and credits reserve.
1759
+ aspectRatio: zEnum(VIDEO_ASPECT_RATIOS).optional().describe(`NOT every model takes every value — an out-of-set ratio is REFUSED before submit, not silently ignored. Per model: ${describeAspectRatios(VIDEO_MODELS, AGENT_ROUTE_PROVIDER)}`),
1760
+ duration: z.number().int().min(VIDEO_DURATION_BOUNDS.min).max(VIDEO_DURATION_BOUNDS.max).optional().describe(`Seconds. Cost scales linearly — a 30s seedance-2.5 take is several hundred credits, so be explicit rather than defaulting. Per model: ${describeDurations(VIDEO_MODELS)}`),
1761
+ videoResolution: zEnum(VIDEO_RESOLUTIONS).optional().describe(`An unsupported resolution is REJECTED, never downgraded. Per model: ${describeVideoResolutions(VIDEO_MODELS)}. 4K video is Pro-only (the server returns PRO_REQUIRED for a base-tier account).`),
1597
1762
  firstFrameAssetId: z.string().optional().describe('Starting frame for image-to-video: asset UUID or badge code ("IMG-A8") — codes resolve against the project at call time, so a code the user just spoke is always safe to pass.'),
1598
1763
  lastFrameAssetId: z.string().optional().describe('Ending frame (UUID or badge code). Veo and Seedance only. Pairs with firstFrameAssetId for guided transitions.'),
1599
- ingredientAssetIds: z.array(z.string()).max(30).optional().describe('Visual reference / ingredient assets (UUIDs or badge codes) for Kling Omni, Seedance, or Omni Flash. Up to 30 (Seedance 2.5), 9 (Seedance 2), 4 (Kling), or 7 (Omni Flash, combined across all ref params). More is not better: 2-4 strong references beat both extremes, and past 4 reference PEOPLE output stability drops on Seedance regardless of the cap.'),
1764
+ ingredientAssetIds: z.array(z.string()).max(30).optional().describe(
1765
+ // Caps DERIVED from MODEL_CAPABILITIES — the hand-typed list omitted
1766
+ // kling-v3.0-omni-pro entirely and read as if 7 were an Omni Flash-only rule.
1767
+ `Visual reference / ingredient assets (UUIDs or badge codes). Cap per model (combined across ingredient/character/environment/style params): ${describeReferenceImageCaps(VIDEO_MODELS)}. More is not better: 2-4 strong references beat both extremes, and past 4 reference PEOPLE output stability drops on Seedance regardless of the cap.`),
1600
1768
  characterAssetIds: z.array(z.string()).optional().describe('Character sheet assets (UUIDs or badge codes) — keeps a character consistent across the shot.'),
1601
1769
  environmentAssetIds: z.array(z.string()).optional().describe('Environment reference assets (UUIDs or badge codes) — keeps a location/setting consistent across the shot.'),
1602
1770
  styleAssetIds: z.array(z.string()).optional().describe('Style reference assets (UUIDs or badge codes) — locks the visual style of the shot.'),
@@ -1662,22 +1830,28 @@ export const generateVideo = {
1662
1830
  missing,
1663
1831
  message: `Missing required field(s): ${missing.join(', ')}. ` +
1664
1832
  `Read the slates-cost-discipline + ${promptingSkillFor(input.model)} skills, ` +
1665
- `or ask the user. Veo locks to 16:9. Kling/Seedance support 1:1 16:9 9:16 4:3 3:4 21:9. Omni Flash: 16:9/9:16 only. ` +
1666
- `Duration: Kling 5-15s, Veo 4/6/8s (4K only at 8s), Seedance 4-15s, Omni Flash 3-10s. Cost scales linearly with duration.`,
1833
+ // Generated from MODEL_CAPABILITIES. The prose that stood here claimed
1834
+ // "Veo locks to 16:9", "Kling/Seedance support 1:1 16:9 9:16 4:3 3:4
1835
+ // 21:9" and "Kling 5-15s" — three wrong claims in one sentence.
1836
+ `or ask the user. ` +
1837
+ `Aspect ratio for ${input.model}: ${aspectRatiosFor(input.model, AGENT_ROUTE_PROVIDER).join(', ')}. ` +
1838
+ `Duration: ${describeDurations([input.model])}. ` +
1839
+ `Cost scales linearly with duration.`,
1667
1840
  });
1668
1841
  }
1669
- // Omni Flash: 3-10s, 720p only — t2v, single-start-frame i2v, or ref2v
1670
- // with up to 7 reference IMAGES. No last frame, no video/audio refs.
1671
- // Validate up front so the agent gets an actionable message instead of
1672
- // a registry throw.
1842
+ // 🚨 CAPABILITY GATE — the one that stops a client-side accept of a
1843
+ // server-side reject. Registry-driven and model-keyed: an aspect ratio,
1844
+ // resolution or duration the chosen model does not take is refused HERE,
1845
+ // before the job queues and credits reserve. Runs after normalisation so it
1846
+ // sees the resolved model and any params a cost key back-filled.
1847
+ const capErr = assertVideoCapabilities(input);
1848
+ if (capErr)
1849
+ return ok(capErr);
1850
+ // Omni Flash's SHAPE constraints — t2v, single-start-frame i2v, or ref2v
1851
+ // with reference IMAGES. No last frame, no video/audio refs. (Its duration
1852
+ // and resolution are covered by the capability gate above; the 3-10s check
1853
+ // that used to live here was a hand-typed duplicate of registry data.)
1673
1854
  if (input.model === 'omni-flash') {
1674
- if (input.duration < 3 || input.duration > 10) {
1675
- return ok({
1676
- requires_clarification: true,
1677
- missing: ['duration'],
1678
- message: `Omni Flash supports 3-10 seconds (you passed ${input.duration}s). Pick a duration in that range.`,
1679
- });
1680
- }
1681
1855
  if (input.lastFrameAssetId ||
1682
1856
  input.videoReferenceAssetId || input.audioReferenceAssetId ||
1683
1857
  (input.videoReferenceAssetIds?.length ?? 0) > 0 ||
@@ -1685,40 +1859,26 @@ export const generateVideo = {
1685
1859
  return ok({
1686
1860
  requires_clarification: true,
1687
1861
  missing: [],
1688
- message: 'Omni Flash takes a prompt, an optional start frame, and up to 7 reference IMAGES — last frames and video/audio references are not supported. Drop those, or switch to seedance-2 (video/audio refs, last frame) or veo (last frame).',
1862
+ message: `Omni Flash takes a prompt, an optional start frame, and up to ${omniFlashRefCap} reference IMAGES — last frames and video/audio references are not supported. Drop those, or switch to seedance-2 (video/audio refs, last frame) or veo (last frame).`,
1689
1863
  });
1690
1864
  }
1691
1865
  const refCount = (input.ingredientAssetIds?.length ?? 0) +
1692
1866
  (input.characterAssetIds?.length ?? 0) +
1693
1867
  (input.environmentAssetIds?.length ?? 0) +
1694
1868
  (input.styleAssetIds?.length ?? 0);
1695
- if (refCount > 7) {
1869
+ if (refCount > omniFlashRefCap) {
1696
1870
  return ok({
1697
1871
  requires_clarification: true,
1698
1872
  missing: [],
1699
- message: `Omni Flash takes at most 7 reference images combined (you passed ${refCount}). Trim the list.`,
1700
- });
1701
- }
1702
- }
1703
- // Veo exists only at discrete durations 4/6/8s, and 4K only at 8s.
1704
- // Validate up front so the agent gets an actionable message instead of
1705
- // a generic "Model variant not in registry" throw from the cost lookup.
1706
- if (input.model.startsWith('veo')) {
1707
- if (![4, 6, 8].includes(input.duration)) {
1708
- return ok({
1709
- requires_clarification: true,
1710
- missing: ['duration'],
1711
- message: `Veo 3.1 supports only 4s, 6s, or 8s (you passed ${input.duration}s). Pick one of those.`,
1712
- });
1713
- }
1714
- if (input.videoResolution === '4k' && input.duration !== 8) {
1715
- return ok({
1716
- requires_clarification: true,
1717
- missing: ['duration'],
1718
- message: `Veo 3.1 4K renders only at 8s (you passed ${input.duration}s at 4k). Use duration=8 for 4K, or drop to 720p/1080p for 4s/6s.`,
1873
+ message: `Omni Flash takes at most ${omniFlashRefCap} reference images combined (you passed ${refCount}). Trim the list.`,
1719
1874
  });
1720
1875
  }
1721
1876
  }
1877
+ // ⛔ The hand-written Veo duration block that stood here is GONE. It read
1878
+ // `![4,6,8].includes(duration)` plus "4K only at 8s" — and the registry
1879
+ // forces 8s at 1080p TOO, so it happily quoted a 4s 1080p Veo the provider
1880
+ // rejects. `assertVideoCapabilities` above covers both, plus the
1881
+ // reference-to-video 8s rule it never knew about, from the SSOT.
1722
1882
  // Resolve every asset reference (UUID or badge code) against the
1723
1883
  // project AT CALL TIME. Codes the user just spoke ("a8 is the one")
1724
1884
  // resolve to whatever exists NOW — including assets created in the UI
@@ -2355,11 +2515,12 @@ export const editVideo = {
2355
2515
  projectId: z.string().uuid().describe('Project the source clip lives in.'),
2356
2516
  sourceVideoAssetId: z.string().describe('The VIDEO asset to edit — UUID or badge code ("VID-V3", bare "V3"); codes resolve against the project at call time. Kling: 3–15s clips; omni-flash-edit: 3–10s.'),
2357
2517
  prompt: z.string().min(1).max(2500).describe('The change, not the whole scene — e.g. "replace the man with @marcus", "make it a rainy night", "turn the street into a neon Tokyo alley". Mention subjects with @name; the transport compiles them to Kling\'s @ElementN notation (Kling models). For omni-flash-edit keep it simple and add "Keep everything else the same."'),
2358
- model: z.enum(['kling-v3.0-omni-edit', 'kling-v3.0-omni-pro-edit', 'omni-flash-edit', 'seedance-2.5-edit']).optional().describe('Default kling-v3.0-omni-edit. Pro (~25¢/s vs ~19¢/s) only for hero shots where fidelity matters. omni-flash-edit (~19¢/s, 720p, 3–10s) for prompt-only edits — it takes NO character/style refs. seedance-2.5-edit (480p/720p, 4–30s) is the ONLY engine that accepts a clip longer than 15s; it also takes NO character/style refs on this op.'),
2518
+ // Clip windows and resolutions GENERATED from MODEL_CAPABILITIES.
2519
+ model: zEnum(EDIT_VIDEO_MODELS).optional().describe(`Default kling-v3.0-omni-edit. Pro (~25¢/s vs ~19¢/s) only for hero shots where fidelity matters. omni-flash-edit (~19¢/s) for prompt-only edits — it takes NO character/style refs. seedance-2.5-edit is the ONLY engine that accepts a clip longer than 15s; it also takes NO character/style refs on this op. Source-clip length per engine: ${describeDurations(EDIT_VIDEO_MODELS)}. Output resolution: ${describeVideoResolutions(EDIT_VIDEO_MODELS)}`),
2359
2520
  characterAssetIds: z.array(z.string()).max(4).optional().describe('Subject/element image assets to swap IN (UUIDs or badge codes). Each becomes a Kling element (@ElementN). KLING MODELS ONLY — rejected on omni-flash-edit.'),
2360
2521
  styleAssetIds: z.array(z.string()).max(4).optional().describe('Style/appearance reference images (@ImageN). Max 4 combined with characterAssetIds. KLING MODELS ONLY — rejected on omni-flash-edit.'),
2361
2522
  keepAudio: z.boolean().optional().describe('Preserve the original audio track (default true; Kling models only — omni-flash-edit and seedance-2.5-edit output carry their own audio).'),
2362
- videoResolution: z.enum(['480p', '720p']).optional().describe('seedance-2.5-edit ONLY (default 720p). Ignored by the Kling and Omni Flash engines, whose output follows the source clip.'),
2523
+ videoResolution: zEnum(EDIT_VIDEO_RESOLUTIONS).optional().describe(`seedance-2.5-edit ONLY (default ${defaultVideoResolutionFor('seedance-2.5-edit')}). Ignored by the Kling and Omni Flash engines, whose output follows the source clip.`),
2363
2524
  seedanceFace: z.boolean().optional().describe('seedance-2.5-edit ONLY: set true when a CHARACTER FACE is visible in the clip. Faceless edits run on BytePlus; faces are blocked there and must route to the relaxed provider, which costs ~35% more. A face edit submitted without this flag is rejected by the provider, not silently downgraded.'),
2364
2525
  background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
2365
2526
  confirm: z.boolean().optional().describe('Set true to bypass the cost confirm gate after the user OKs the spend.'),
@@ -2373,10 +2534,9 @@ export const editVideo = {
2373
2534
  const model = input.model ?? 'kling-v3.0-omni-edit';
2374
2535
  const isOmniFlashEdit = model === 'omni-flash-edit';
2375
2536
  const isSeedanceEdit = model === 'seedance-2.5-edit';
2376
- // Kling edit: 3–15s source clips; Omni Flash edit: 3–10s; Seedance 2.5
2377
- // edit: 4–30s — the only engine that takes a clip over 15s.
2378
- const minClipSeconds = isSeedanceEdit ? SEEDANCE_25_EDIT_MIN_SECONDS : 3;
2379
- const maxClipSeconds = isSeedanceEdit ? SEEDANCE_25_EDIT_MAX_SECONDS : isOmniFlashEdit ? 10 : 15;
2537
+ // Source-clip window, read from the capability SSOT per engine — never a
2538
+ // model-id ternary. (Kling 3–15s, Omni Flash 3–10s, Seedance 2.5 4–30s.)
2539
+ const { min: minClipSeconds, max: maxClipSeconds } = editClipBounds(model);
2380
2540
  if ((isOmniFlashEdit || isSeedanceEdit) && ((input.characterAssetIds?.length ?? 0) > 0 || (input.styleAssetIds?.length ?? 0) > 0)) {
2381
2541
  throw new Error(`${model} takes the prompt and the source clip only on this op — no character/style reference images. Drop the refs, or switch to kling-v3.0-omni-edit which supports elements.`);
2382
2542
  }
@@ -2419,7 +2579,7 @@ export const editVideo = {
2419
2579
  const costKey = isSeedanceEdit
2420
2580
  ? seedanceEditCostKey({
2421
2581
  duration: billedSeconds,
2422
- videoResolution: input.videoResolution ?? '720p',
2582
+ videoResolution: (input.videoResolution ?? defaultVideoResolutionFor('seedance-2.5-edit')),
2423
2583
  seedanceFace: input.seedanceFace === true,
2424
2584
  })
2425
2585
  : isOmniFlashEdit
@@ -2,6 +2,7 @@ export * from './reference-rules.js';
2
2
  export * from './reference-composer.js';
3
3
  export * from './content-policy.js';
4
4
  export * from './style-library.js';
5
+ export * from './model-capabilities.js';
5
6
  export * from './model-facts.js';
6
7
  export * from './character-sheet.js';
7
8
  export * from './environment-sheet.js';
@@ -13,6 +13,10 @@ export * from './reference-rules.js';
13
13
  export * from './reference-composer.js';
14
14
  export * from './content-policy.js';
15
15
  export * from './style-library.js';
16
+ // Model CAPABILITY SSOT (aspect ratios, video resolutions, durations, reference
17
+ // caps). The desktop's MODEL_REGISTRY spreads these into every entry and the op
18
+ // surface validates + generates its descriptions from them — never hand-typed.
19
+ export * from './model-capabilities.js';
16
20
  export * from './model-facts.js';
17
21
  export * from './character-sheet.js';
18
22
  export * from './environment-sheet.js';