@slatesvideo/shared 0.6.11 → 0.7.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/auth.js +2 -2
- package/dist/clients/cloud.js +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/manual/content.d.ts +1 -1
- package/dist/manual/content.js +1 -1
- package/dist/operations/index.d.ts +817 -16
- package/dist/operations/index.js +1413 -360
- package/dist/operations/surface.d.ts +4 -1
- package/dist/operations/surface.js +41 -10
- package/dist/prompts/ad-presets.d.ts +77 -0
- package/dist/prompts/ad-presets.js +43 -0
- package/dist/prompts/agent-doctrine.js +27 -5
- package/dist/prompts/banned-tokens.d.ts +4 -29
- package/dist/prompts/banned-tokens.js +29 -204
- package/dist/prompts/craft-cards.js +2 -2
- package/dist/prompts/generation-policy.d.ts +41 -0
- package/dist/prompts/generation-policy.js +53 -0
- package/dist/prompts/guide-retrieval.d.ts +9 -0
- package/dist/prompts/guide-retrieval.js +53 -0
- package/dist/prompts/index.d.ts +1 -0
- package/dist/prompts/index.js +1 -0
- package/dist/prompts/model-capabilities.d.ts +18 -1
- package/dist/prompts/model-capabilities.js +72 -19
- package/dist/prompts/model-facts.d.ts +34 -2
- package/dist/prompts/model-facts.js +66 -5
- package/dist/prompts/partials.generated.js +8 -2
- package/dist/prompts/prompting-tips.d.ts +1 -1
- package/dist/prompts/prompting-tips.js +61 -16
- package/dist/prompts/reference-composer.d.ts +2 -0
- package/dist/prompts/reference-composer.js +51 -50
- package/dist/prompts/script-document.d.ts +165 -0
- package/dist/prompts/script-document.js +11 -0
- package/dist/prompts/shot-grammar.d.ts +4 -4
- package/dist/prompts/shot-grammar.js +3 -3
- package/dist/prompts/shot-spec.d.ts +13 -0
- package/dist/prompts/shot-spec.js +23 -5
- package/dist/skills/content.js +27 -24
- package/exports/slates-chatgpt-images/generated/SKILL.md +107 -0
- package/exports/slates-chatgpt-images/generated/slates-chatgpt-images.skill +0 -0
- package/exports/slates-prompt-builder/generated/SKILL.md +1 -1
- package/exports/slates-prompt-builder/generated/reference-character.md +9 -1
- package/exports/slates-prompt-builder/generated/reference-kling.md +3 -3
- package/exports/slates-prompt-builder/generated/reference-nano-banana.md +22 -10
- package/exports/slates-prompt-builder/generated/reference-seedance.md +4 -4
- package/exports/slates-prompt-builder/generated/slates-prompt-builder-manifest.json +17 -17
- package/exports/slates-prompt-builder/generated/slates-prompt-builder.skill +0 -0
- package/package.json +9 -3
- package/skills/_partials/cinematic-card.md +8 -0
- package/skills/_partials/cinematic-routes-short.md +2 -0
- package/skills/_partials/cinematic-tips-short.md +2 -0
- package/skills/_partials/decision-log.md +1 -13
- package/skills/_partials/image-defaults.md +11 -0
- package/skills/_partials/lens-video-split.md +1 -0
- package/skills/_partials/reference-rules-core.md +1 -1
- package/skills/_partials/sheet-tool-defaults.md +6 -0
- package/skills/slates-character-identity.md +9 -1
- package/skills/slates-chatgpt-images.md +107 -0
- package/skills/slates-cinematic-look.md +237 -0
- package/skills/slates-cost-discipline.md +18 -12
- package/skills/slates-direct-response-ad.md +13 -53
- package/skills/slates-edit-and-iterate.md +1 -1
- package/skills/slates-model-selection.md +20 -14
- package/skills/slates-one-prompt-film.md +19 -77
- package/skills/slates-project-organization.md +7 -3
- package/skills/slates-prompting-flux-2-max.md +15 -4
- package/skills/slates-prompting-gpt-image-2-5.md +41 -28
- package/skills/slates-prompting-inworld-tts.md +174 -174
- package/skills/slates-prompting-kling-v3.md +3 -3
- package/skills/slates-prompting-lip-sync.md +1 -1
- package/skills/slates-prompting-minimax-h3.md +30 -17
- package/skills/slates-prompting-motion-transfer.md +1 -1
- package/skills/slates-prompting-nano-banana-2.md +24 -11
- package/skills/slates-prompting-seedance-2-5.md +7 -6
- package/skills/slates-prompting-seedance.md +5 -5
- package/skills/slates-prompting-seedream-5-lite.md +14 -3
- package/skills/slates-prompting-veo-3.md +1 -1
- package/skills/slates-script-craft.md +45 -0
- package/skills/slates-shot-variety.md +11 -40
- package/skills/slates-storyboard-from-script.md +14 -66
- package/skills/slates-style-prompting.md +4 -4
- package/skills/slates-ugc-influencer-ad.md +32 -309
- package/skills/slates-vision-feedback-loop.md +2 -1
package/dist/operations/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { MAX_IMAGE_VARIATIONS } from '../prompts/generation-policy.js';
|
|
2
|
+
import { retrieveGuide, guideSections } from '../prompts/guide-retrieval.js';
|
|
3
|
+
import { MINIMAX_MAX_REFERENCE, minimaxMaxReferenceTokens, MODEL_CAPABILITIES, GPT_QUALITY_TIERS, DEFAULT_GPT_QUALITY, GPT_BACKGROUNDS } from '../prompts/model-capabilities.js';
|
|
2
4
|
// Operations layer — the ONE place every Slates agent tool is defined.
|
|
3
5
|
// Both the MCP server and the CLI register these as their tool / command
|
|
4
6
|
// surface. Every operation:
|
|
@@ -23,7 +25,11 @@ import { multimodalRefSummary, multimodalRefModels, seedanceTaskIntentWords,
|
|
|
23
25
|
// slates-mcp/CLAUDE.md forbids restating it in an op description, and this
|
|
24
26
|
// file did it anyway for 1,282 characters that repeated MODEL_FACTS phrase
|
|
25
27
|
// for phrase. Edit model-facts.ts; both surfaces follow.
|
|
26
|
-
describeRouting,
|
|
28
|
+
describeRouting,
|
|
29
|
+
// The default seat per kind, READ from `tier` — never a literal model id.
|
|
30
|
+
defaultModelFor,
|
|
31
|
+
// The seat a built-in tool renders on, READ from TOOL_SEAT (generation-policy.ts).
|
|
32
|
+
toolModelFor, CHATGPT_FRAMING_RATIOS, } from '../prompts/model-facts.js';
|
|
27
33
|
// 🚨 MODEL CAPABILITY SSOT. Aspect ratios, video resolutions and durations are
|
|
28
34
|
// VALIDATED against and DESCRIBED from `MODEL_CAPABILITIES` — this file must
|
|
29
35
|
// never re-state one of those constraints as a literal enum or a sentence
|
|
@@ -33,16 +39,12 @@ describeRouting, } from '../prompts/model-facts.js';
|
|
|
33
39
|
// `videoResolution` line that never mentioned Kling, and 4s quoted for Veo at
|
|
34
40
|
// 1080p. A customer burned a round trip on `4:5` + Seedance: accepted here,
|
|
35
41
|
// queued, credits reserved, rejected by the provider asynchronously.
|
|
36
|
-
import { AGENT_ROUTE_PROVIDER, aspectRatioUnion, videoResolutionUnion, durationBounds, aspectRatiosFor, getModelCapability, defaultVideoResolutionFor, checkAspectRatio, checkVideoResolution, checkDuration, describeAspectRatios, describeVideoResolutions, describeDurations, describeReferenceImageCaps, } from '../prompts/model-capabilities.js';
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
// generate ops' descriptions, which are always in context on both surfaces —
|
|
40
|
-
// no call to skip, no discretion. `bannedTokenWarning` then reports what the
|
|
41
|
-
// submitted prompt actually contained, in the result, without blocking it.
|
|
42
|
-
// Never hand-type one of these tokens here; edit the skill.
|
|
42
|
+
import { AGENT_ROUTE_PROVIDER, defaultImageResolutionFor, aspectRatioUnion, videoResolutionUnion, durationBounds, aspectRatiosFor, getModelCapability, defaultVideoResolutionFor, checkAspectRatio, checkVideoResolution, checkDuration, describeAspectRatios, describeVideoResolutions, describeDurations, describeReferenceImageCaps, } from '../prompts/model-capabilities.js';
|
|
43
|
+
// Per-model warnings are extracted from skill @banned blocks. They ride the
|
|
44
|
+
// selected model's estimate and submitted-prompt result, never another model's schema.
|
|
43
45
|
import { describeBannedTokens, bannedTokenWarning, describeBannedTokensForSkill, } from '../prompts/banned-tokens.js';
|
|
44
46
|
// 🚨 THE OTHER HALF OF THE SAME LESSON. The banned list is the NEGATIVE half and
|
|
45
|
-
// it rides
|
|
47
|
+
// it rides the selected estimate. The CRAFT CARD is the POSITIVE half — the levers
|
|
46
48
|
// that make a shot good rather than merely un-bad — and it rides the estimate
|
|
47
49
|
// RESULT, which the doctrine already makes the agent call before generating.
|
|
48
50
|
// Zero prefix bytes, present at the moment the model has just been named.
|
|
@@ -161,13 +163,14 @@ export const ELEVEN_SFX_DEFAULT_SECONDS = 4;
|
|
|
161
163
|
// bucket of CHARACTERS instead, and the bucket exists because of the minimum
|
|
162
164
|
// billable floor rather than for tidiness.
|
|
163
165
|
//
|
|
164
|
-
// At $
|
|
165
|
-
//
|
|
166
|
-
// The floor stops biting at
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
// this feature is mostly for. 250 splits
|
|
170
|
-
// exactly, giving eight buckets and no ragged
|
|
166
|
+
// At $25/M characters (Inworld On-Demand, read 2026-09-28) and a 1.5× markup,
|
|
167
|
+
// a 200-character line is $0.0075 of basis — under `MIN_AUDIO_BILLABLE_DOLLARS`
|
|
168
|
+
// ($0.01), so it bills the floor. The floor stops biting at 267 characters, so
|
|
169
|
+
// the 250 bucket is floor-bound. A narrower bucket would put several buckets on
|
|
170
|
+
// the one floor price, and the displayed rate would stop tracking cost; a much
|
|
171
|
+
// WIDER one over-bills the short lines this feature is mostly for. 250 splits
|
|
172
|
+
// that difference and divides 2,000 exactly, giving eight buckets and no ragged
|
|
173
|
+
// last one.
|
|
171
174
|
//
|
|
172
175
|
// 🚨 THE CAP IS READ FROM THE CAPABILITY SSOT, NEVER TYPED. 2,000 is the
|
|
173
176
|
// vendor's MEASURED limit (the API rejects 2,001 by name), and this module
|
|
@@ -254,7 +257,7 @@ function backgroundSubmitted(kind, ids, extra, note) {
|
|
|
254
257
|
// ── Workspace + identity ────────────────────────────────────────
|
|
255
258
|
export const getWorkspaceState = {
|
|
256
259
|
id: 'slates_get_workspace_state',
|
|
257
|
-
description: 'Snapshot of the user\'s Slates workspace: the project list (most recent first) plus the active project in full when you name one.
|
|
260
|
+
description: 'Snapshot of the user\'s Slates workspace: the project list (most recent first) plus the active project in full when you name one.',
|
|
258
261
|
input: z.object({
|
|
259
262
|
projectId: z.string().optional(),
|
|
260
263
|
limit: z.number().int().min(1).max(200).optional().describe('How many projects to list, newest first. Default 40.'),
|
|
@@ -287,9 +290,195 @@ export const getWorkspaceState = {
|
|
|
287
290
|
? { truncated: `${rows.length - compact.length} more — raise limit or call slates_list_projects.` }
|
|
288
291
|
: {}),
|
|
289
292
|
activeProject,
|
|
293
|
+
generationDefaults: {
|
|
294
|
+
imageModel: defaultModelFor('image'),
|
|
295
|
+
imageResolution: defaultImageResolutionFor(defaultModelFor('image')),
|
|
296
|
+
gptQuality: DEFAULT_GPT_QUALITY,
|
|
297
|
+
videoModel: defaultModelFor('video'),
|
|
298
|
+
},
|
|
290
299
|
});
|
|
291
300
|
},
|
|
292
301
|
};
|
|
302
|
+
/**
|
|
303
|
+
* What the user has selected in the app RIGHT NOW. The selection band in every
|
|
304
|
+
* grid (images, clips, audio, storyboard shots) reports its ids to the desktop,
|
|
305
|
+
* and the image viewer reports what is open; the desktop looks codes and labels
|
|
306
|
+
* up at request time. Exists so "make these into videos" needs no codes typed.
|
|
307
|
+
*/
|
|
308
|
+
export const getSelection = {
|
|
309
|
+
id: 'slates_get_selection',
|
|
310
|
+
description:
|
|
311
|
+
// Lean on purpose: WHEN to call it is the doctrine's ASSET CODES line, and
|
|
312
|
+
// this description is paid on every desktop turn (lockstep § 7).
|
|
313
|
+
'The user\'s live selection in the Slates app: ticked cards (with codes) and the image open in the viewer.',
|
|
314
|
+
input: z.object({}).strict(),
|
|
315
|
+
async run(_input, ctx) {
|
|
316
|
+
await ctx.desktop().requireCapability('selection', 'reading the live selection');
|
|
317
|
+
const r = await ctx.desktop().get('/agent/selection');
|
|
318
|
+
const describe = (row) => row.code ? (row.label ? `${row.code} — ${row.label}` : String(row.code)) : String(row.id);
|
|
319
|
+
const selected = r.selection
|
|
320
|
+
? r.selection.items.map((i) => {
|
|
321
|
+
const row = i;
|
|
322
|
+
return row.type === 'shot'
|
|
323
|
+
? { id: row.id, code: row.code ?? null, label: row.label ?? null, type: 'shot' }
|
|
324
|
+
: compactAsset(row);
|
|
325
|
+
})
|
|
326
|
+
: [];
|
|
327
|
+
const viewed = r.viewing ? compactAsset(r.viewing.item) : null;
|
|
328
|
+
const lines = [];
|
|
329
|
+
if (r.selection) {
|
|
330
|
+
const where = r.selection.projectName ? ` in project "${r.selection.projectName}"` : '';
|
|
331
|
+
lines.push(`${selected.length} selected on the ${r.selection.surface} tab${where} (projectId ${r.selection.projectId}): ` +
|
|
332
|
+
selected.map(describe).join(', ') +
|
|
333
|
+
'.');
|
|
334
|
+
}
|
|
335
|
+
if (viewed)
|
|
336
|
+
lines.push(`Open in the viewer: ${describe(viewed)}.`);
|
|
337
|
+
if (lines.length === 0) {
|
|
338
|
+
lines.push('Nothing is selected and no image is open in the viewer. Ask the user to select the cards in Slates (click or drag), or to name the codes.');
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
text: lines.join(' '),
|
|
342
|
+
data: {
|
|
343
|
+
selection: r.selection
|
|
344
|
+
? {
|
|
345
|
+
surface: r.selection.surface,
|
|
346
|
+
project_id: r.selection.projectId,
|
|
347
|
+
project_name: r.selection.projectName,
|
|
348
|
+
items: selected,
|
|
349
|
+
updated_at: r.selection.updatedAt,
|
|
350
|
+
}
|
|
351
|
+
: null,
|
|
352
|
+
viewing: viewed
|
|
353
|
+
? { project_id: r.viewing.projectId, item: viewed, updated_at: r.viewing.updatedAt }
|
|
354
|
+
: null,
|
|
355
|
+
},
|
|
356
|
+
};
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
// The view's three lists, mirrored from the desktop's `@shared/types/view`
|
|
360
|
+
// (`LENSES`, `CUT_SIDES`, `DOCK_SECTIONS`), which this package cannot import.
|
|
361
|
+
// Lockstep check 12 fails when they differ.
|
|
362
|
+
const VIEW_LENSES = ['board', 'media', 'script'];
|
|
363
|
+
const VIEW_CUT_SIDES = ['bottom', 'left', 'right'];
|
|
364
|
+
const VIEW_DOCK_SECTIONS = ['storyboards', 'library', 'folders', 'pinned'];
|
|
365
|
+
/** What each dock section is called on screen. */
|
|
366
|
+
const DOCK_SECTION_NAMES = { storyboards: 'Boards', library: 'Library', folders: 'Folders', pinned: 'Pinned' };
|
|
367
|
+
const describeView = (v) => {
|
|
368
|
+
const where = v.cut.full
|
|
369
|
+
? 'filling the workspace'
|
|
370
|
+
: v.cut.open
|
|
371
|
+
? v.cut.side === 'bottom'
|
|
372
|
+
? `a ${v.cut.height}px band under the ${v.lens} tab`
|
|
373
|
+
: `a ${v.cut.width}px column on the ${v.cut.side} of the ${v.lens} tab`
|
|
374
|
+
: 'closed, resting as one line at the bottom';
|
|
375
|
+
const agent = v.studioAgent.enabled
|
|
376
|
+
? v.studioAgent.open
|
|
377
|
+
? `the Studio Agent panel is open (${v.studioAgent.width}px)`
|
|
378
|
+
: 'the Studio Agent panel is closed'
|
|
379
|
+
: 'the Studio Agent is switched off in Settings';
|
|
380
|
+
return (`The ${v.lens} tab is showing. The timeline is ${where}. ` +
|
|
381
|
+
`The project navigator is ${v.leftDock.open ? `open (${v.leftDock.width}px)` : 'closed'}, and ${agent}.` +
|
|
382
|
+
(v.leftDock.folded?.length ? ` Folded in the navigator: ${v.leftDock.folded.map((f) => DOCK_SECTION_NAMES[f] ?? f).join(', ')}.` : '') +
|
|
383
|
+
(v.script ? ` The Script page shows ${v.script.details ? 'Words + shots (each shot\'s picture beside its words)' : 'Words (the words alone)'}.` : ''));
|
|
384
|
+
};
|
|
385
|
+
/**
|
|
386
|
+
* How the Slates window is ARRANGED right now — which lens is showing, where
|
|
387
|
+
* the timeline sits, which side panels are open. Read it before rearranging
|
|
388
|
+
* anything, so you change one thing and leave the rest as the user had it.
|
|
389
|
+
*/
|
|
390
|
+
export const getView = {
|
|
391
|
+
id: 'slates_get_view',
|
|
392
|
+
description: "How the Slates window is arranged: the tab showing (Media, Script or Board), where the timeline sits, which side panels are open, and whether the Script page shows Words or Words + shots.",
|
|
393
|
+
input: z.object({}).strict(),
|
|
394
|
+
async run(_input, ctx) {
|
|
395
|
+
await ctx.desktop().requireCapability('view', 'the window layout');
|
|
396
|
+
const r = await ctx.desktop().get('/agent/view');
|
|
397
|
+
if (!r.view) {
|
|
398
|
+
return {
|
|
399
|
+
text: r.reason
|
|
400
|
+
? `Slates has not reported a window layout yet (${r.reason}).`
|
|
401
|
+
: 'Slates has not reported a window layout yet.',
|
|
402
|
+
data: { view: null },
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
return { text: describeView(r.view), data: { view: r.view } };
|
|
406
|
+
},
|
|
407
|
+
};
|
|
408
|
+
/**
|
|
409
|
+
* Rearrange the window. Every field is optional and only what you name moves,
|
|
410
|
+
* so parking the timeline does not also change the lens.
|
|
411
|
+
*
|
|
412
|
+
* 🚨 THE APP ANSWERS, NOT THE REQUEST. Sizes are clamped by the app's own
|
|
413
|
+
* rules and the reply reports what it settled on — ask for a 10px timeline and
|
|
414
|
+
* you are told 420, because that is what is on the user's screen. A window too
|
|
415
|
+
* small to show the timeline beside the board will not split at all; it shows
|
|
416
|
+
* the timeline full-screen instead, and says so.
|
|
417
|
+
*/
|
|
418
|
+
export const setView = {
|
|
419
|
+
id: 'slates_set_view',
|
|
420
|
+
description: "Rearrange the Slates window: switch tab (Media, Script or Board), open/close the timeline or park it along the bottom or as a left/right column, open/close or resize the side panels, fold the navigator's sections, and switch the Script page between Words and Words + shots. Only the fields you name change; sizes are clamped by the app and the reply says what it settled on.",
|
|
421
|
+
input: z
|
|
422
|
+
.object({
|
|
423
|
+
lens: z.enum(VIEW_LENSES).optional().describe('Which tab the centre shows.'),
|
|
424
|
+
cut: z
|
|
425
|
+
.object({
|
|
426
|
+
open: z.boolean().optional().describe('Show or hide the timeline.'),
|
|
427
|
+
full: z.boolean().optional().describe('Give the timeline the whole workspace.'),
|
|
428
|
+
side: z
|
|
429
|
+
.enum(VIEW_CUT_SIDES)
|
|
430
|
+
.optional()
|
|
431
|
+
.describe('Where the timeline is parked. A column suits a wide monitor; picking a side opens the timeline.'),
|
|
432
|
+
height: z.number().optional().describe('The bottom band\'s height in px.'),
|
|
433
|
+
width: z.number().optional().describe('The side column\'s width in px.'),
|
|
434
|
+
})
|
|
435
|
+
.strict()
|
|
436
|
+
.optional(),
|
|
437
|
+
leftDock: z
|
|
438
|
+
.object({
|
|
439
|
+
open: z.boolean().optional(),
|
|
440
|
+
width: z.number().optional().describe('Width in px.'),
|
|
441
|
+
folded: z
|
|
442
|
+
.array(z.enum(VIEW_DOCK_SECTIONS))
|
|
443
|
+
.optional()
|
|
444
|
+
.describe('Every section to fold to its title (storyboards is Boards); a section not named unfolds. Applies to the open project.'),
|
|
445
|
+
})
|
|
446
|
+
.strict()
|
|
447
|
+
.optional()
|
|
448
|
+
.describe('The project navigator on the left.'),
|
|
449
|
+
studioAgent: z
|
|
450
|
+
.object({
|
|
451
|
+
open: z.boolean().optional(),
|
|
452
|
+
width: z.number().optional().describe('Width in px.'),
|
|
453
|
+
})
|
|
454
|
+
.strict()
|
|
455
|
+
.optional()
|
|
456
|
+
.describe('The Studio Agent panel on the right. It cannot be opened while the agent is off in Settings.'),
|
|
457
|
+
script: z
|
|
458
|
+
.object({
|
|
459
|
+
details: z.boolean().optional().describe('Words + shots (true): each shot\'s picture beside its words on the Script page. Words (false): the words alone.'),
|
|
460
|
+
})
|
|
461
|
+
.strict()
|
|
462
|
+
.optional()
|
|
463
|
+
.describe('The Script page.'),
|
|
464
|
+
})
|
|
465
|
+
.strict(),
|
|
466
|
+
async run(input, ctx) {
|
|
467
|
+
await ctx.desktop().requireCapability('view', 'the window layout');
|
|
468
|
+
if (Object.keys(input).length === 0) {
|
|
469
|
+
throw new Error('Name at least one of lens, cut, leftDock, studioAgent or script — there is nothing to change otherwise.');
|
|
470
|
+
}
|
|
471
|
+
const r = await ctx.desktop().post('/agent/view', input);
|
|
472
|
+
if (!r.view) {
|
|
473
|
+
return { text: r.reason ? `Nothing was rearranged (${r.reason}).` : 'Nothing was rearranged.', data: { view: null } };
|
|
474
|
+
}
|
|
475
|
+
// A desktop whose view predates folding takes the patch and ignores `folded`.
|
|
476
|
+
const noFolds = input.leftDock?.folded !== undefined && r.view.leftDock.folded === undefined
|
|
477
|
+
? " This Slates cannot fold the navigator's sections; update Slates."
|
|
478
|
+
: '';
|
|
479
|
+
return { text: describeView(r.view) + noFolds, data: { view: r.view } };
|
|
480
|
+
},
|
|
481
|
+
};
|
|
293
482
|
export const getMe = {
|
|
294
483
|
id: 'slates_get_me',
|
|
295
484
|
description: 'Identity, license tier, and credit balance for the connected Slates account.',
|
|
@@ -360,18 +549,21 @@ export const VIDEO_MODELS = [
|
|
|
360
549
|
// Flash ones.
|
|
361
550
|
'seedance-2.5',
|
|
362
551
|
'omni-flash',
|
|
363
|
-
// MiniMax H3,
|
|
364
|
-
// AUDIO seat — three directable sound layers in one pass, declared
|
|
365
|
-
// relationships, 480p to 4K
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
//
|
|
552
|
+
// MiniMax H3, three seats in one family. Base H3 (2026-08-27) is the
|
|
553
|
+
// AUTHORED-AUDIO seat — three directable sound layers in one pass, declared
|
|
554
|
+
// reference relationships, 480p to 4K. H3 Max (2026-08-27) is fal's
|
|
555
|
+
// self-hosted post-train: faster, 480p to a 1080p refinement, the same
|
|
556
|
+
// omni-reference set, and dearer than base H3 at the tier they share. H3 Max
|
|
557
|
+
// Turbo (2026-09-29) is a second fal post-train at half Max's rate, with NO
|
|
558
|
+
// reference endpoint.
|
|
369
559
|
//
|
|
370
|
-
// NEVER PREFIX-MATCH: 'minimax-h3-max' starts with 'minimax-h3'
|
|
371
|
-
// branch keyed on these ids matches
|
|
372
|
-
//
|
|
560
|
+
// NEVER PREFIX-MATCH: 'minimax-h3-max-turbo' starts with 'minimax-h3-max',
|
|
561
|
+
// which starts with 'minimax-h3'. Every branch keyed on these ids matches
|
|
562
|
+
// EXACTLY; a prefix test bills one row at another's rates and offers it a
|
|
563
|
+
// ladder it cannot render.
|
|
373
564
|
'minimax-h3',
|
|
374
565
|
'minimax-h3-max',
|
|
566
|
+
'minimax-h3-max-turbo',
|
|
375
567
|
// LTX-2.5, two seats in one family (2026-08-29). Base is the VOLUME seat —
|
|
376
568
|
// cheapest native 1080p second we sell, free native audio at every tier, the
|
|
377
569
|
// only row reaching 1440p, and the longest clips in the catalogue (20s).
|
|
@@ -425,7 +617,7 @@ const VIDEO_RESOLUTION_VOCAB = VIDEO_RESOLUTIONS;
|
|
|
425
617
|
// $0.080 x 1.5 x 100 = 12 cents, and 12 is divisible by CENTS_PER_CREDIT (3),
|
|
426
618
|
// so every extra image is 4 credits at every resolution and every duration with
|
|
427
619
|
// zero drift. A rate that is not a multiple of 2 cents breaks that property.
|
|
428
|
-
const MINIMAX_MODELS = new Set(['minimax-h3', 'minimax-h3-max']);
|
|
620
|
+
const MINIMAX_MODELS = new Set(['minimax-h3', 'minimax-h3-max', 'minimax-h3-max-turbo']);
|
|
429
621
|
/** Reference images fal does not charge for. */
|
|
430
622
|
const MINIMAX_FREE_REF_IMAGES = 5;
|
|
431
623
|
/** Per-row free allowance. The Max row's is FOUR — fal prices its references by
|
|
@@ -512,8 +704,8 @@ export const estimateGenerationCost = {
|
|
|
512
704
|
duration: z.number().int().min(1).max(360).optional().describe(`Seconds; cost scales linearly. Required with a video or PER-SECOND audio base id. Per-model windows: see slates_generate_video's duration. Audio: seed-audio ${SEED_AUDIO_MIN_SECONDS}-${SEED_AUDIO_MAX_SECONDS} (⚠️ the requested duration IS the bill), eleven-sfx ${ELEVEN_SFX_MIN_SECONDS}-${ELEVEN_SFX_MAX_SECONDS}. ⛔ NOT for ${TTS_MODEL} — pass \`characters\`.`),
|
|
513
705
|
characters: z.number().int().min(1).max(TTS_MAX_CHARACTERS).optional().describe(`${TTS_MODEL} only — the LENGTH OF THE TEXT to speak (${TTS_BUCKET_CHARS}-char buckets).`),
|
|
514
706
|
videoResolution: zEnum(VIDEO_RESOLUTIONS).optional().describe('Video only. Omitted, each model quotes at its own default. Per-model ladders: see slates_generate_video\'s videoResolution.'),
|
|
515
|
-
resolution: z.enum(['1k', '2k', '3k', '4k']).optional().describe('Image only
|
|
516
|
-
quality: z.enum(GPT_QUALITY_TIERS).optional().describe(
|
|
707
|
+
resolution: z.enum(['1k', '2k', '3k', '4k']).optional().describe('Image only. Omit for the model default; pass the same value to generation.'),
|
|
708
|
+
quality: z.enum(GPT_QUALITY_TIERS).optional().describe(`GPT Image tier; default ${DEFAULT_GPT_QUALITY}.`),
|
|
517
709
|
aspectRatio: z.string().optional().describe('Image only. 1:1/4:3/3:4 cost more than 16:9.'),
|
|
518
710
|
sound: z.boolean().optional().describe('Veo only — audio flag changes the cost key.'),
|
|
519
711
|
seedanceFace: z.boolean().optional().describe('Seedance AI-face route (pricier key).'),
|
|
@@ -541,7 +733,7 @@ export const estimateGenerationCost = {
|
|
|
541
733
|
// generation quoted 1 cr for a 2 cr job. Four separate copies of one
|
|
542
734
|
// default is what made that possible; there are now none.
|
|
543
735
|
if (img)
|
|
544
|
-
key = imageCostKey(img, input.resolution ?? (img
|
|
736
|
+
key = imageCostKey(img, input.resolution ?? defaultImageResolutionFor(img), input.quality, input.aspectRatio);
|
|
545
737
|
}
|
|
546
738
|
// 2a) audio base id → seconds. Both surfaces bill per second, so a
|
|
547
739
|
// duration is always required. Runs BEFORE the video resolver: it is
|
|
@@ -652,7 +844,7 @@ export const estimateGenerationCost = {
|
|
|
652
844
|
defaultVideoResolutionFor(resolved.model),
|
|
653
845
|
sound: input.sound ?? resolved.sound,
|
|
654
846
|
seedanceFace: input.seedanceFace ?? resolved.seedanceFace,
|
|
655
|
-
seedanceRealFace: input.seedanceRealFace,
|
|
847
|
+
seedanceRealFace: input.seedanceRealFace ?? resolved.seedanceRealFace,
|
|
656
848
|
referenceImages: input.referenceImages ?? resolved.referenceImages,
|
|
657
849
|
videoRefSeconds: input.videoRefSeconds, audioRefSeconds: input.audioRefSeconds,
|
|
658
850
|
});
|
|
@@ -739,6 +931,9 @@ function compactAsset(a) {
|
|
|
739
931
|
prompt: typeof r.prompt === 'string' ? r.prompt : null,
|
|
740
932
|
}),
|
|
741
933
|
type: r.type,
|
|
934
|
+
// Only when it IS one. A compact row pays for every key on every asset,
|
|
935
|
+
// and "not a favorite" is the default the app writes — absent says it.
|
|
936
|
+
...(r.isFavorite === true ? { isFavorite: true } : {}),
|
|
742
937
|
created_at: r.createdAt ?? r.created_at ?? undefined,
|
|
743
938
|
};
|
|
744
939
|
}
|
|
@@ -807,7 +1002,7 @@ function describeResolvedRefs(refInputs, resolved) {
|
|
|
807
1002
|
}
|
|
808
1003
|
export const listAssets = {
|
|
809
1004
|
id: 'slates_list_assets',
|
|
810
|
-
description: 'List assets in a Slates project as COMPACT rows (id, code, label, type) — newest first, default limit 50. Each asset carries its short code (IMG-A12 / VID-V3 / AUD-S1 — the badge the user sees on the gallery card) and label. When the user names an asset by code ("use IMG-A36 as the reference"), pass it as `search` to resolve the assetId. Always speak about assets by code + label, never by UUID. NOTE: generate_* results already return the new asset ids — do NOT call this to find an asset you just created.',
|
|
1005
|
+
description: 'List assets in a Slates project as COMPACT rows (id, code, label, type) — newest first, default limit 50. Each asset carries its short code (IMG-A12 / VID-V3 / AUD-S1 — the badge the user sees on the gallery card) and label. When the user names an asset by code ("use IMG-A36 as the reference"), pass it as `search` to resolve the assetId. Always speak about assets by code + label, never by UUID. A favorited asset carries `isFavorite: true` (absent means not favorited). NOTE: generate_* results already return the new asset ids — do NOT call this to find an asset you just created.',
|
|
811
1006
|
input: z.object({
|
|
812
1007
|
projectId: z.string().uuid(),
|
|
813
1008
|
type: z.enum(['image', 'video', 'audio']).optional().describe('Only this asset type'),
|
|
@@ -868,7 +1063,7 @@ export const getAssetImage = {
|
|
|
868
1063
|
};
|
|
869
1064
|
export const getAssetsBatch = {
|
|
870
1065
|
id: 'slates_get_assets_batch',
|
|
871
|
-
description: 'Fetch up to 8 image-asset thumbnails inline in a single call. Use this when picking the right reference from a project gallery — one round trip beats N. Each returned image carries its short code (IMG-A12) and label
|
|
1066
|
+
description: 'Fetch up to 8 image-asset thumbnails inline in a single call. Use this when picking the right reference from a project gallery — one round trip beats N. Each returned image carries its short code (IMG-A12) and label. Video assets are not supported here — call slates_get_asset_video_frames for those.',
|
|
872
1067
|
input: z.object({
|
|
873
1068
|
ids: z.array(z.string().uuid()).min(1).max(8).describe('1-8 image-asset ids. Order is preserved in the response.'),
|
|
874
1069
|
}),
|
|
@@ -931,6 +1126,71 @@ export const getAssetVideoFrames = {
|
|
|
931
1126
|
};
|
|
932
1127
|
},
|
|
933
1128
|
};
|
|
1129
|
+
export const getChatGptStatus = {
|
|
1130
|
+
id: 'slates_get_chatgpt_status',
|
|
1131
|
+
description: 'Check whether the local Codex host is installed, signed in with ChatGPT, and supports built-in image generation. Does not generate or charge Slates credits.',
|
|
1132
|
+
input: z.object({}),
|
|
1133
|
+
async run(_input, ctx) {
|
|
1134
|
+
await ctx.desktop().requireCapability('chatgpt-image-generation', 'ChatGPT image generation');
|
|
1135
|
+
return ok(await ctx.desktop().get('/agent/chatgpt/status'));
|
|
1136
|
+
},
|
|
1137
|
+
};
|
|
1138
|
+
export const connectChatGpt = {
|
|
1139
|
+
id: 'slates_connect_chatgpt',
|
|
1140
|
+
description: 'Start Codex-managed ChatGPT sign-in and return a browser sign-in URL for the user. Call only when the user requests connecting their account. Never ask for credentials or tokens.',
|
|
1141
|
+
input: z.object({}),
|
|
1142
|
+
async run(_input, ctx) {
|
|
1143
|
+
await ctx.desktop().requireCapability('chatgpt-image-generation', 'ChatGPT image generation');
|
|
1144
|
+
return ok(await ctx.desktop().post('/agent/chatgpt/connect', {}));
|
|
1145
|
+
},
|
|
1146
|
+
};
|
|
1147
|
+
export const generateChatGptImage = {
|
|
1148
|
+
id: 'slates_generate_chatgpt_image',
|
|
1149
|
+
description: 'Generate an image through the local Codex host using the connected ChatGPT account, then save it in Slates with its exact submitted prompt, reference lineage and measured dimensions. Uses ChatGPT account limits, never Slates credits or a paid API fallback. Check slates_get_chatgpt_status first. Supply a new UUID requestId once per intended generation; reuse it for retries to avoid duplicate generation/import. No explicit image model, quality or size controls are exposed. Prompt may request visual properties without guaranteeing them. Background returns a generationId for slates_get_generation_status.',
|
|
1150
|
+
input: z.object({ projectId: z.string().uuid(), requestId: z.string().uuid(), prompt: z.string().min(1),
|
|
1151
|
+
aspectRatio: z.enum(CHATGPT_FRAMING_RATIOS).optional().describe('Optional framing request appended verbally to the prompt, not an exact output-size guarantee.'),
|
|
1152
|
+
referenceAssetIds: z.array(z.string().min(1)).optional(), background: z.boolean().optional() }),
|
|
1153
|
+
async run(input, ctx) {
|
|
1154
|
+
const desktop = ctx.desktop();
|
|
1155
|
+
await desktop.requireCapability('chatgpt-image-generation', 'ChatGPT image generation');
|
|
1156
|
+
const refs = await resolveAssetRefs(ctx, input.projectId, input.referenceAssetIds ?? []);
|
|
1157
|
+
const result = await desktop.post('/agent/generation/chatgpt-image', {
|
|
1158
|
+
...input, referenceAssetIds: (input.referenceAssetIds ?? []).map(id => refs.get(id).id),
|
|
1159
|
+
});
|
|
1160
|
+
return ok(result, JSON.stringify(result) + '\n' + describeResolvedRefs((input.referenceAssetIds ?? []).map(ref => ({ ref, role: 'reference image' })), refs));
|
|
1161
|
+
},
|
|
1162
|
+
};
|
|
1163
|
+
export const saveExternalImage = {
|
|
1164
|
+
id: 'slates_save_external_image',
|
|
1165
|
+
description: 'Save an image generated by an external host with its exact prompt, generator, reference images and measured dimensions. This does not generate or spend Slates credits. Pass filePath or dataUrl for a new result, or assetId to annotate an existing upload in place. Ordinary pasted or dragged files belong to slates_upload_reference_image. Only name a model if the host reported it; requestedSettings records requests, not verified output settings.',
|
|
1166
|
+
input: z.object({
|
|
1167
|
+
projectId: z.string().uuid(),
|
|
1168
|
+
filePath: z.string().min(1).optional(),
|
|
1169
|
+
dataUrl: z.string().min(1).optional(),
|
|
1170
|
+
assetId: z.string().min(1).optional(),
|
|
1171
|
+
prompt: z.string().min(1),
|
|
1172
|
+
generator: z.string().min(1),
|
|
1173
|
+
model: z.string().min(1).optional(),
|
|
1174
|
+
negativePrompt: z.string().min(1).optional(),
|
|
1175
|
+
referenceAssetIds: z.array(z.string().min(1)).optional(),
|
|
1176
|
+
requestedSettings: z.record(z.union([z.string(), z.number().finite(), z.boolean()])).optional(),
|
|
1177
|
+
}).refine(d => [d.filePath, d.dataUrl, d.assetId].filter(Boolean).length === 1, {
|
|
1178
|
+
message: 'Pass exactly one of filePath, dataUrl or assetId',
|
|
1179
|
+
}),
|
|
1180
|
+
async run(input, ctx) {
|
|
1181
|
+
const desktop = ctx.desktop();
|
|
1182
|
+
await desktop.requireCapability('external-image-metadata', 'external image generation metadata');
|
|
1183
|
+
const refs = await resolveAssetRefs(ctx, input.projectId, [
|
|
1184
|
+
...(input.referenceAssetIds ?? []), ...(input.assetId ? [input.assetId] : []),
|
|
1185
|
+
]);
|
|
1186
|
+
const result = await desktop.post('/agent/assets/external-image', {
|
|
1187
|
+
...input,
|
|
1188
|
+
assetId: input.assetId ? refs.get(input.assetId).id : undefined,
|
|
1189
|
+
referenceAssetIds: (input.referenceAssetIds ?? []).map(id => refs.get(id).id),
|
|
1190
|
+
});
|
|
1191
|
+
return ok(result, JSON.stringify(result) + '\n' + describeResolvedRefs((input.referenceAssetIds ?? []).map(ref => ({ ref, role: 'reference image' })), refs));
|
|
1192
|
+
},
|
|
1193
|
+
};
|
|
934
1194
|
export const uploadReferenceImage = {
|
|
935
1195
|
id: 'slates_upload_reference_image',
|
|
936
1196
|
description: 'Add a reference image, video clip, or audio file to a Slates project from disk. Pass either filePath (absolute path to a local file) or dataUrl (base64 data: URL) — exactly one. Set type:"video" to bring in a clip (the user\'s own footage to edit/relocate/trim) or type:"audio" for music/VO/SFX they already have; both are probed on ingest, so duration (and for video, dimensions) are available immediately, and audio gets its waveform. Default type is "image". dataUrl is image-only.',
|
|
@@ -1000,6 +1260,62 @@ export const moveAssetsToFolder = {
|
|
|
1000
1260
|
return ok(await ctx.desktop().post('/agent/folders/move-assets', input));
|
|
1001
1261
|
},
|
|
1002
1262
|
};
|
|
1263
|
+
export const setAssetFavorite = {
|
|
1264
|
+
id: 'slates_set_asset_favorite',
|
|
1265
|
+
description: 'Mark or unmark one asset as a favorite. The heart persists on the card in the app, and slates_list_assets reports it as isFavorite.',
|
|
1266
|
+
input: z.object({
|
|
1267
|
+
assetId: z.string().uuid(),
|
|
1268
|
+
favorite: z.boolean(),
|
|
1269
|
+
}),
|
|
1270
|
+
async run(input, ctx) {
|
|
1271
|
+
await ctx.desktop().requireCapability('asset-favorite', 'favorites');
|
|
1272
|
+
return ok(await ctx.desktop().post('/agent/assets/favorite', input));
|
|
1273
|
+
},
|
|
1274
|
+
};
|
|
1275
|
+
export const exportAssets = {
|
|
1276
|
+
id: 'slates_export_assets',
|
|
1277
|
+
description: 'Copy the ORIGINAL files of one or more assets (images, clips or audio) into a directory on this machine, named by their codes (IMG-A12.png). Never overwrites: a clash gets -2, -3. The project is untouched. Works for an image-only project with no shot, video or timeline. Returns each exported path and any asset whose file is missing.',
|
|
1278
|
+
input: z.object({
|
|
1279
|
+
assetIds: z.array(z.string().uuid()).min(1),
|
|
1280
|
+
directory: z.string().min(1).describe('Absolute path; created if it does not exist.'),
|
|
1281
|
+
}),
|
|
1282
|
+
async run(input, ctx) {
|
|
1283
|
+
await ctx.desktop().requireCapability('asset-export', 'exporting media');
|
|
1284
|
+
return ok(await ctx.desktop().post('/agent/assets/export', input));
|
|
1285
|
+
},
|
|
1286
|
+
};
|
|
1287
|
+
export const listPins = {
|
|
1288
|
+
id: 'slates_list_pins',
|
|
1289
|
+
description: "List a project's pinned references: the images kept in the dock's Pinned section, one click from the prompt. A pin attaches nothing to a generation by itself.",
|
|
1290
|
+
input: z.object({ projectId: z.string().uuid() }),
|
|
1291
|
+
async run(input, ctx) {
|
|
1292
|
+
await ctx.desktop().requireCapability('pins', 'pinned references');
|
|
1293
|
+
return ok(await ctx.desktop().get('/agent/pins', { projectId: input.projectId }));
|
|
1294
|
+
},
|
|
1295
|
+
};
|
|
1296
|
+
export const pinReferences = {
|
|
1297
|
+
id: 'slates_pin_references',
|
|
1298
|
+
description: 'Pin one or more images to the dock\'s Pinned section (UUIDs or badge codes like IMG-A8). Already-pinned ones are reported, not doubled. Pinning keeps a picture at hand; pass it as a reference to a generation to actually use it.',
|
|
1299
|
+
input: z.object({
|
|
1300
|
+
projectId: z.string().uuid(),
|
|
1301
|
+
assetIds: z.array(z.string().min(1)).min(1).describe('Image assets, UUIDs or badge codes.'),
|
|
1302
|
+
}),
|
|
1303
|
+
async run(input, ctx) {
|
|
1304
|
+
await ctx.desktop().requireCapability('pins', 'pinned references');
|
|
1305
|
+
const resolved = await resolveAssetRefs(ctx, input.projectId, input.assetIds);
|
|
1306
|
+
return ok(await ctx.desktop().post('/agent/pins', { projectId: input.projectId, assetIds: input.assetIds.map((r) => resolved.get(r).id) }));
|
|
1307
|
+
},
|
|
1308
|
+
};
|
|
1309
|
+
export const unpinReference = {
|
|
1310
|
+
id: 'slates_unpin_reference',
|
|
1311
|
+
description: 'Take one image off the dock\'s Pinned section. The image stays in the project.',
|
|
1312
|
+
input: z.object({ projectId: z.string().uuid(), assetId: z.string().min(1).describe('UUID or badge code.') }),
|
|
1313
|
+
async run(input, ctx) {
|
|
1314
|
+
await ctx.desktop().requireCapability('pins', 'pinned references');
|
|
1315
|
+
const resolved = await resolveAssetRefs(ctx, input.projectId, [input.assetId]);
|
|
1316
|
+
return ok(await ctx.desktop().post('/agent/pins/remove', { projectId: input.projectId, assetId: resolved.get(input.assetId).id }));
|
|
1317
|
+
},
|
|
1318
|
+
};
|
|
1003
1319
|
export const moveAssetsToProject = {
|
|
1004
1320
|
id: 'slates_move_assets_to_project',
|
|
1005
1321
|
description: 'Move assets (images, videos, audio) out of one project into another. The media files move on disk into the destination project folder — this is a real re-home, not a copy. Moved assets leave whatever gallery folder they were in and are issued fresh badge codes in the destination.',
|
|
@@ -1041,7 +1357,7 @@ export const moveAssetsToProject = {
|
|
|
1041
1357
|
};
|
|
1042
1358
|
export const copyAssetsToProject = {
|
|
1043
1359
|
id: 'slates_copy_assets_to_project',
|
|
1044
|
-
description: 'Copy assets (images, videos, audio) from one project into another. The originals stay exactly where they are — new files, new rows, new badge codes in the destination. Use this instead of slates_move_assets_to_project when the asset is already in use where it lives: an image that is a character
|
|
1360
|
+
description: 'Copy assets (images, videos, audio) from one project into another. The originals stay exactly where they are — new files, new rows, new badge codes in the destination. Use this instead of slates_move_assets_to_project when the asset is already in use where it lives: an image that is a character, location or look picture, or sits in a board frame, CANNOT be moved out (that would leave the other project pointing at a file it no longer owns), but it can always be copied. Lineage is not copied; the copy starts clean.',
|
|
1045
1361
|
input: z.object({
|
|
1046
1362
|
sourceProjectId: z.string().uuid(),
|
|
1047
1363
|
// Badge codes ("IMG-A8") resolve against sourceProjectId at call time.
|
|
@@ -1066,13 +1382,20 @@ export const copyAssetsToProject = {
|
|
|
1066
1382
|
};
|
|
1067
1383
|
export const moveEntityToProject = {
|
|
1068
1384
|
id: 'slates_move_entity_to_project',
|
|
1069
|
-
description: "Move a character,
|
|
1385
|
+
description: "Move a Library item (a character, location, product, look…) into another project, taking every image it references with it; it lands in the target's category of the same name. This is the fix when slates_move_assets_to_project refuses an asset because a Library item still uses it: the image can't leave on its own, but the whole item can. Refuses (rather than cascading) if one of its images is ALSO used by something else — copy the images instead in that case. mentioningShots lists this project's Shots that mention it: they keep the words and fire without it until it comes back. Board frames are not movable this way; moving a frame's image out would empty the shot.",
|
|
1070
1386
|
input: z.object({
|
|
1071
|
-
kind: z
|
|
1387
|
+
kind: z
|
|
1388
|
+
.enum(['library', 'character', 'environment', 'style'])
|
|
1389
|
+
.optional()
|
|
1390
|
+
.describe('Optional and ignored: every value means a Library item. Kept for older callers.'),
|
|
1072
1391
|
entityId: z.string().uuid(),
|
|
1073
1392
|
targetProjectId: z.string().uuid(),
|
|
1074
1393
|
}),
|
|
1075
1394
|
async run(input, ctx) {
|
|
1395
|
+
// 1.5.8 refuses an omitted or 'library' kind; only a legacy kind reaches it.
|
|
1396
|
+
if (input.kind === undefined || input.kind === 'library') {
|
|
1397
|
+
await ctx.desktop().requireCapability('library', 'the Library');
|
|
1398
|
+
}
|
|
1076
1399
|
return ok(await ctx.desktop().post('/agent/entities/move-to-project', input));
|
|
1077
1400
|
},
|
|
1078
1401
|
};
|
|
@@ -1115,7 +1438,7 @@ export const setCharacterIdentity = {
|
|
|
1115
1438
|
// ── Environments ────────────────────────────────────────────────
|
|
1116
1439
|
export const listEnvironments = {
|
|
1117
1440
|
id: 'slates_list_environments',
|
|
1118
|
-
description: 'List environments in a Slates project.',
|
|
1441
|
+
description: 'List the locations (environments) in a Slates project.',
|
|
1119
1442
|
input: z.object({ projectId: z.string().uuid() }),
|
|
1120
1443
|
async run(input, ctx) {
|
|
1121
1444
|
return ok(await ctx.desktop().get('/agent/environments', { projectId: input.projectId }));
|
|
@@ -1123,7 +1446,7 @@ export const listEnvironments = {
|
|
|
1123
1446
|
};
|
|
1124
1447
|
export const createEnvironment = {
|
|
1125
1448
|
id: 'slates_create_environment',
|
|
1126
|
-
description: 'Create a new environment in a Slates project.',
|
|
1449
|
+
description: 'Create a new location (environment) in a Slates project.',
|
|
1127
1450
|
input: z.object({
|
|
1128
1451
|
projectId: z.string().uuid(),
|
|
1129
1452
|
name: z.string().min(1).max(120),
|
|
@@ -1139,14 +1462,14 @@ export const generateCharacterIdentity = {
|
|
|
1139
1462
|
billable: true,
|
|
1140
1463
|
description: "Generate one character identity sheet from a base portrait asset and bind it as the character's canonical reference. Call after slates_create_character. Read slates-character-identity before calling and quote the cost from slates_estimate_generation_cost.",
|
|
1141
1464
|
input: z.object({
|
|
1142
|
-
characterId: z.string().uuid(),
|
|
1465
|
+
characterId: z.string().uuid().describe('A character, or the id of any Library thing: both sheet tools run on any thing, and the result binds as its image.'),
|
|
1143
1466
|
projectId: z.string().uuid(),
|
|
1144
1467
|
baseAssetId: z.string().uuid().describe('The base portrait asset the identity is generated from.'),
|
|
1145
1468
|
userNotes: z.string().optional().describe('Extra instruction, e.g. "use the woman on the left".'),
|
|
1146
1469
|
model: z
|
|
1147
1470
|
.enum(['nano-banana-2', 'nano-banana-2-lite', 'nano-banana-pro', 'gpt-image-2-5-flare', 'gpt-image-2-5-sunburst'])
|
|
1148
1471
|
.optional()
|
|
1149
|
-
.describe(
|
|
1472
|
+
.describe(`Image model for the sheet. Omit for the sheet tool's seat (${toolModelFor('character-sheet')}), which is what the app's own button uses. Pass another only to compare seats side by side.`),
|
|
1150
1473
|
}),
|
|
1151
1474
|
async run(input, ctx) {
|
|
1152
1475
|
// 🚨 THE ROSTER GATE, WHICH THIS OP NEVER HAD. Its `model` enum offers
|
|
@@ -1161,6 +1484,11 @@ export const generateCharacterIdentity = {
|
|
|
1161
1484
|
else if (input.model === 'nano-banana-pro' || input.model === 'nano-banana-2-lite') {
|
|
1162
1485
|
await ctx.desktop().requireCapability('image-models-v2', `${input.model} character identity`);
|
|
1163
1486
|
}
|
|
1487
|
+
// 1.5.8 renders an unnamed model as Nano Banana 2 and every model at 2k, so
|
|
1488
|
+
// only an explicit Nano Banana model runs there as quoted.
|
|
1489
|
+
if (!input.model || isGptImageModel(input.model)) {
|
|
1490
|
+
await ctx.desktop().requireCapability('sheet-tool-seats', 'sheet tools on the default image seat');
|
|
1491
|
+
}
|
|
1164
1492
|
return ok(await ctx.desktop().post('/agent/characters/generate-identity', {
|
|
1165
1493
|
characterId: input.characterId,
|
|
1166
1494
|
projectId: input.projectId,
|
|
@@ -1173,9 +1501,9 @@ export const generateCharacterIdentity = {
|
|
|
1173
1501
|
export const generateEnvironmentPlate = {
|
|
1174
1502
|
id: 'slates_generate_environment_plate',
|
|
1175
1503
|
billable: true,
|
|
1176
|
-
description: "Generate one clean establishing image from an optional base image and bind it as the
|
|
1504
|
+
description: "Generate one clean establishing image from an optional base image and bind it as the location's canonical reference. Call after slates_create_environment and quote the cost from slates_estimate_generation_cost.",
|
|
1177
1505
|
input: z.object({
|
|
1178
|
-
environmentId: z.string().uuid(),
|
|
1506
|
+
environmentId: z.string().uuid().describe('A location, or the id of any Library thing: both sheet tools run on any thing, and the result binds as its image.'),
|
|
1179
1507
|
projectId: z.string().uuid(),
|
|
1180
1508
|
baseAssetId: z
|
|
1181
1509
|
.string()
|
|
@@ -1185,6 +1513,8 @@ export const generateEnvironmentPlate = {
|
|
|
1185
1513
|
userNotes: z.string().optional(),
|
|
1186
1514
|
}),
|
|
1187
1515
|
async run(input, ctx) {
|
|
1516
|
+
// 1.5.8 always renders the plate on Nano Banana 2 at 2k, never the seat quoted.
|
|
1517
|
+
await ctx.desktop().requireCapability('sheet-tool-seats', 'sheet tools on the default image seat');
|
|
1188
1518
|
return ok(await ctx.desktop().post('/agent/environments/generate-plate', {
|
|
1189
1519
|
environmentId: input.environmentId,
|
|
1190
1520
|
projectId: input.projectId,
|
|
@@ -1196,7 +1526,7 @@ export const generateEnvironmentPlate = {
|
|
|
1196
1526
|
// ── Storyboards ─────────────────────────────────────────────────
|
|
1197
1527
|
export const listStoryboards = {
|
|
1198
1528
|
id: 'slates_list_storyboards',
|
|
1199
|
-
description: 'List storyboards in a Slates project.',
|
|
1529
|
+
description: 'List the boards (storyboards) in a Slates project.',
|
|
1200
1530
|
input: z.object({ projectId: z.string().uuid() }),
|
|
1201
1531
|
async run(input, ctx) {
|
|
1202
1532
|
return ok(await ctx.desktop().get('/agent/storyboards', { projectId: input.projectId }));
|
|
@@ -1204,7 +1534,7 @@ export const listStoryboards = {
|
|
|
1204
1534
|
};
|
|
1205
1535
|
export const createStoryboard = {
|
|
1206
1536
|
id: 'slates_create_storyboard',
|
|
1207
|
-
description: 'Create a new storyboard with a default first scene. Returns the
|
|
1537
|
+
description: 'Create a new board (storyboard) with a default first scene. Returns the board record.',
|
|
1208
1538
|
input: z.object({
|
|
1209
1539
|
projectId: z.string().uuid(),
|
|
1210
1540
|
name: z.string().min(1).max(120),
|
|
@@ -1220,7 +1550,7 @@ export const getStoryboardWithFrames = {
|
|
|
1220
1550
|
// in the same order, that the user is looking at. Before this the agent got
|
|
1221
1551
|
// scenes and frames with no Shots, so the user read an arranged board while
|
|
1222
1552
|
// the agent read an unordered pile.
|
|
1223
|
-
description: "Deep-fetch a
|
|
1553
|
+
description: "Deep-fetch a board: every scene, every slot, and the SHOT in each slot — its script line, references, model, params and takes — plus the piece's variety distribution. This is the same board, in the same order, that the user is reading.",
|
|
1224
1554
|
input: z.object({ storyboardId: z.string().uuid() }),
|
|
1225
1555
|
async run(input, ctx) {
|
|
1226
1556
|
const desktop = ctx.desktop();
|
|
@@ -1233,7 +1563,7 @@ export const getStoryboardWithFrames = {
|
|
|
1233
1563
|
};
|
|
1234
1564
|
export const addScene = {
|
|
1235
1565
|
id: 'slates_add_scene',
|
|
1236
|
-
description: 'Add a new scene to a
|
|
1566
|
+
description: 'Add a new scene to a board.',
|
|
1237
1567
|
input: z.object({
|
|
1238
1568
|
storyboardId: z.string().uuid(),
|
|
1239
1569
|
name: z.string().min(1).max(120),
|
|
@@ -1368,7 +1698,7 @@ export function isGptImageModel(model) {
|
|
|
1368
1698
|
* ten and the other five would be accepted here. The image param surface has
|
|
1369
1699
|
* not been audited (aspect ratios, resolution classes, per-model reference
|
|
1370
1700
|
* caps) — that audit is the named follow-up in
|
|
1371
|
-
* `
|
|
1701
|
+
* `second-brain/plans/2026-08-16-slates-model-capability-ssot.md` §5. The machinery
|
|
1372
1702
|
* to close it already exists: call `checkAspectRatio(model, ratio)` the way
|
|
1373
1703
|
* `assertVideoCapabilities` does below.
|
|
1374
1704
|
*/
|
|
@@ -1404,7 +1734,7 @@ function gptKeyAspect(aspectRatio) {
|
|
|
1404
1734
|
// imports it and asserts, key by key, that it equals the desktop's
|
|
1405
1735
|
// `imageCreditKey`. Before that check existed the byte-match was a comment and
|
|
1406
1736
|
// a hope — the two are in different repos and nothing compared them.
|
|
1407
|
-
export function imageCostKey(model, resolution, quality =
|
|
1737
|
+
export function imageCostKey(model, resolution, quality = DEFAULT_GPT_QUALITY, aspectRatio) {
|
|
1408
1738
|
if (model === 'flux-2-max')
|
|
1409
1739
|
return resolution === '1k' ? 'flux-2-max' : `flux-2-max-${resolution}`;
|
|
1410
1740
|
if (model === 'seedream-5-lite')
|
|
@@ -1417,6 +1747,17 @@ export function imageCostKey(model, resolution, quality = 'high', aspectRatio) {
|
|
|
1417
1747
|
return `${model}-${gptKeyTier(quality)}-${resolution}${gptKeyAspect(aspectRatio)}`;
|
|
1418
1748
|
return `nano-banana-2-${resolution}`;
|
|
1419
1749
|
}
|
|
1750
|
+
// The default image seat with a project to route through, READ from MODEL_FACTS
|
|
1751
|
+
// `tier`. Asserted at load so a default moved to a seat this op cannot send
|
|
1752
|
+
// fails the build instead of every omitted-model call.
|
|
1753
|
+
const DEFAULT_IMAGE_MODEL = defaultModelFor('image');
|
|
1754
|
+
if (!IMAGE_MODELS.includes(DEFAULT_IMAGE_MODEL)) {
|
|
1755
|
+
throw new Error(`slates_generate_image: default image seat ${DEFAULT_IMAGE_MODEL} is not in IMAGE_MODELS`);
|
|
1756
|
+
}
|
|
1757
|
+
/** The headless path's one fal batch: `maxBatchImages` owns it (nano-banana-2 is the only batching model). */
|
|
1758
|
+
const HEADLESS_BATCH_CAP = MODEL_CAPABILITIES['nano-banana-2']?.maxBatchImages ?? 1;
|
|
1759
|
+
/** A 1.5.8 desktop's own clamp on a project batch, frozen with that build. */
|
|
1760
|
+
const LEGACY_DESKTOP_IMAGE_BATCH = 4;
|
|
1420
1761
|
export const generateImage = {
|
|
1421
1762
|
id: 'slates_generate_image',
|
|
1422
1763
|
billable: true,
|
|
@@ -1435,13 +1776,13 @@ export const generateImage = {
|
|
|
1435
1776
|
describeBannedTokens('image'),
|
|
1436
1777
|
input: z.object({
|
|
1437
1778
|
prompt: z.string().min(1).max(4000),
|
|
1438
|
-
model: zEnum(IMAGE_MODELS).optional().describe(
|
|
1779
|
+
model: zEnum(IMAGE_MODELS).optional().describe(`Image model. Omitted: ${DEFAULT_IMAGE_MODEL} with projectId, nano-banana-2 (the only headless seat) without. Routing: slates-model-selection skill.`),
|
|
1439
1780
|
projectId: z.string().uuid().optional().describe('Save into this Slates project. Renderer refreshes live. Required for every model except nano-banana-2.'),
|
|
1440
|
-
resolution: z.enum(['1k', '2k', '3k', '4k']).optional().describe('
|
|
1441
|
-
quality: z.enum(GPT_QUALITY_TIERS).optional().describe(
|
|
1781
|
+
resolution: z.enum(['1k', '2k', '3k', '4k']).optional().describe('Omit for the selected model default. Override for a specific delivery size; use the same setting when estimating.'),
|
|
1782
|
+
quality: z.enum(GPT_QUALITY_TIERS).optional().describe(`GPT Image only. Default ${DEFAULT_GPT_QUALITY}; estimate the chosen tier before generation.`),
|
|
1442
1783
|
backgroundMode: z.enum(GPT_BACKGROUNDS).optional().describe('GPT Image only. transparent = alpha channel. Free.'),
|
|
1443
1784
|
aspectRatio: zEnum(IMAGE_ASPECT_RATIOS).optional().describe(`Pick from the use case: cinematic 16:9 · TikTok/Reels 9:16 · IG square 1:1 · ultra-wide 21:9. 1:1 costs most on GPT Image. Per model: ${describeAspectRatios(IMAGE_MODELS)}`),
|
|
1444
|
-
count: z.number().int().min(1).max(
|
|
1785
|
+
count: z.number().int().min(1).max(MAX_IMAGE_VARIATIONS).optional().describe(`Up to ${MAX_IMAGE_VARIATIONS} with projectId; headless caps at ${HEADLESS_BATCH_CAP}.`),
|
|
1445
1786
|
referenceImageUrls: z.array(z.string().url()).max(14).optional().describe('Headless (no projectId) nano-banana-2 only. With a projectId, upload via slates_upload_reference_image. Label every image role in the prompt.'),
|
|
1446
1787
|
referenceAssetIds: z.array(z.string()).max(16).optional().describe("Project assets as references — UUIDs or badge codes (\"IMG-A8\"), resolved at call time. Requires projectId. Caps: GPT Image 16, nano-banana-2 14, FLUX/Seedream lower. Label every reference role in the prompt."),
|
|
1447
1788
|
background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
|
|
@@ -1455,7 +1796,13 @@ export const generateImage = {
|
|
|
1455
1796
|
// Non-blocking prompt hygiene. Computed once, reported on every exit path
|
|
1456
1797
|
// that echoes a prompt -- the clarification and confirm gates are PRE-spend,
|
|
1457
1798
|
// which is where a rewrite is still free.
|
|
1458
|
-
|
|
1799
|
+
// Omitted model: the default seat when there is a project to route through,
|
|
1800
|
+
// nano-banana-2 without one, because it is the only headless seat. The
|
|
1801
|
+
// desktop's pre-flight guard (`estimateOpCostCents`, src/main/studio-agent/
|
|
1802
|
+
// ops.ts) picks the same seat: change both together.
|
|
1803
|
+
const imageModel = input.model ?? (input.projectId ? DEFAULT_IMAGE_MODEL : 'nano-banana-2');
|
|
1804
|
+
const promptWarning = bannedTokenWarning(input.prompt, 'image', promptingSkillFor(imageModel));
|
|
1805
|
+
input = { ...input, resolution: input.resolution ?? defaultImageResolutionFor(imageModel) };
|
|
1459
1806
|
if (!input.aspectRatio || !input.resolution) {
|
|
1460
1807
|
const missing = [];
|
|
1461
1808
|
if (!input.aspectRatio)
|
|
@@ -1468,14 +1815,13 @@ export const generateImage = {
|
|
|
1468
1815
|
...(promptWarning ? { prompt_warning: promptWarning } : {}),
|
|
1469
1816
|
message: (promptWarning ? `${promptWarning}\n\n` : '') +
|
|
1470
1817
|
`Missing required field(s): ${missing.join(', ')}. ` +
|
|
1471
|
-
`Read the
|
|
1472
|
-
// Generated from MODEL_CAPABILITIES — never retype a ratio list.
|
|
1818
|
+
`Read the ${promptingSkillFor(imageModel)} + slates-cost-discipline skills, ` +
|
|
1819
|
+
// Generated from MODEL_CAPABILITIES — never retype a ratio or resolution list.
|
|
1473
1820
|
`or ask the user. Aspect ratio by model: ${describeAspectRatios(IMAGE_MODELS)}. ` +
|
|
1474
|
-
`Resolution options
|
|
1821
|
+
`Resolution options for ${imageModel}: ${(MODEL_CAPABILITIES[imageModel]?.imageResolutions ?? []).join(' ')}.`,
|
|
1475
1822
|
});
|
|
1476
1823
|
}
|
|
1477
1824
|
const resolution = input.resolution;
|
|
1478
|
-
const imageModel = input.model ?? 'nano-banana-2';
|
|
1479
1825
|
const resolutions = MODEL_CAPABILITIES[imageModel]?.imageResolutions ?? [];
|
|
1480
1826
|
if (resolution && !resolutions.includes(resolution)) {
|
|
1481
1827
|
return ok({ requires_clarification: true, missing: ['resolution'],
|
|
@@ -1514,11 +1860,11 @@ export const generateImage = {
|
|
|
1514
1860
|
// `num_images` maximum is 4 (fal schema, 2026-09-09). Refused rather than
|
|
1515
1861
|
// clamped: a silent clamp would make four images against a request for ten
|
|
1516
1862
|
// and read to the caller as a partial failure it should retry.
|
|
1517
|
-
if (!input.projectId && (input.count ?? 1) >
|
|
1863
|
+
if (!input.projectId && (input.count ?? 1) > HEADLESS_BATCH_CAP) {
|
|
1518
1864
|
return ok({
|
|
1519
1865
|
requires_clarification: true,
|
|
1520
1866
|
missing: ['projectId'],
|
|
1521
|
-
message:
|
|
1867
|
+
message: `count above ${HEADLESS_BATCH_CAP} needs a projectId. The headless path asks fal for one batch and nano-banana-2 caps a batch at ${HEADLESS_BATCH_CAP}; with a projectId the desktop fires them as separate generations and the limit is ${MAX_IMAGE_VARIATIONS}.`,
|
|
1522
1868
|
});
|
|
1523
1869
|
}
|
|
1524
1870
|
let refEcho = '';
|
|
@@ -1548,6 +1894,10 @@ export const generateImage = {
|
|
|
1548
1894
|
(imageModel === 'nano-banana-pro' || imageModel === 'nano-banana-2-lite')) {
|
|
1549
1895
|
await ctx.desktop().requireCapability('image-models-v2', `${imageModel} generation`);
|
|
1550
1896
|
}
|
|
1897
|
+
// 1.5.8 clamps a project batch to its own 4 and bills 4 against this op's quote for all of them.
|
|
1898
|
+
if (input.projectId && (input.count ?? 1) > LEGACY_DESKTOP_IMAGE_BATCH) {
|
|
1899
|
+
await ctx.desktop().requireCapability('image-variations', 'more than 4 images per call');
|
|
1900
|
+
}
|
|
1551
1901
|
const costKey = imageCostKey(imageModel, resolution, input.quality, input.aspectRatio ?? '1:1');
|
|
1552
1902
|
const cloud = ctx.cloud();
|
|
1553
1903
|
const registry = await cloud.get('/api/agent/models');
|
|
@@ -1618,7 +1968,7 @@ export const generateImage = {
|
|
|
1618
1968
|
resolution,
|
|
1619
1969
|
aspectRatio: input.aspectRatio ?? '1:1',
|
|
1620
1970
|
count: input.count ?? 1,
|
|
1621
|
-
...(isGptImageModel(imageModel) ? { gptQuality: input.quality, gptBackground: input.backgroundMode } : {}),
|
|
1971
|
+
...(isGptImageModel(imageModel) ? { gptQuality: input.quality ?? DEFAULT_GPT_QUALITY, gptBackground: input.backgroundMode } : {}),
|
|
1622
1972
|
...(referenceAssetIds.length > 0 ? { referenceAssetIds } : {}),
|
|
1623
1973
|
background: input.background,
|
|
1624
1974
|
});
|
|
@@ -1799,18 +2149,22 @@ async function pollProxyJob(cloud, jobId, options = {}) {
|
|
|
1799
2149
|
throw new Error(`Generation timed out after ${Math.round(timeoutMs / 1000)}s.`);
|
|
1800
2150
|
}
|
|
1801
2151
|
// ── Edit image ──────────────────────────────────────────────────
|
|
2152
|
+
/** References an edit carries beside the source, which is image 1 of the same request. */
|
|
2153
|
+
const editReferenceCap = (model) => Math.max(0, (MODEL_CAPABILITIES[model]?.maxRefImages ?? 1) - 1);
|
|
2154
|
+
/** The models a 1.5.8 desktop sends edit references on, frozen with that build: on every other model it drops them and bills the edit in full. */
|
|
2155
|
+
const LEGACY_EDIT_REFERENCE_MODELS = ['nano-banana-2', 'nano-banana-2-lite', 'nano-banana-pro'];
|
|
1802
2156
|
export const editImage = {
|
|
1803
2157
|
id: 'slates_edit_image',
|
|
1804
2158
|
billable: true,
|
|
1805
|
-
description: 'Surgically edit an image asset with a text instruction (e.g. \'make the jacket red\') instead of regenerating from scratch — use when ~90% of the image is already right. The result is a NEW asset (prompt prefixed \'[Edit]\'); the source is untouched. Default model
|
|
2159
|
+
description: 'Surgically edit an image asset with a text instruction (e.g. \'make the jacket red\') instead of regenerating from scratch — use when ~90% of the image is already right. The result is a NEW asset (prompt prefixed \'[Edit]\'); the source is untouched. Default model ' + toolModelFor('image-edit') + ' (the image-edit tool seat, the model the app\'s Edit box opens on). Every model also takes referenceAssetIds, up to its reference cap less one: the source is image 1 of the request. Before first use call slates_get_prompting_guide with topic \'slates-edit-and-iterate\'.',
|
|
1806
2160
|
input: z.object({
|
|
1807
2161
|
projectId: z.string().uuid(),
|
|
1808
2162
|
sourceAssetId: z.string().uuid().describe('Image asset to edit. Must exist in the project.'),
|
|
1809
2163
|
prompt: z.string().min(1).max(4000).describe('The change, not the whole image.'),
|
|
1810
|
-
editModel:
|
|
1811
|
-
referenceAssetIds: z.array(z.string().uuid()).max(
|
|
2164
|
+
editModel: zEnum(IMAGE_MODELS).optional(),
|
|
2165
|
+
referenceAssetIds: z.array(z.string().uuid()).max(Math.max(...IMAGE_MODELS.map(editReferenceCap))).optional().describe(`Images beside the source, which is image 1. Each model's own cap: ${IMAGE_MODELS.map((m) => `${m} ${editReferenceCap(m)}`).join(', ')}.`),
|
|
1812
2166
|
resolution: z.enum(['1k', '2k', '3k', '4k']).optional().describe('3k = GPT Image/seedream-5-lite; nano-banana-2-lite is 1k only.'),
|
|
1813
|
-
quality: z.enum(GPT_QUALITY_TIERS).optional().describe(
|
|
2167
|
+
quality: z.enum(GPT_QUALITY_TIERS).optional().describe(`GPT Image tier; default ${DEFAULT_GPT_QUALITY}.`),
|
|
1814
2168
|
backgroundMode: z.enum(GPT_BACKGROUNDS).optional().describe('GPT Image only. transparent = alpha channel. Free.'),
|
|
1815
2169
|
aspectRatio: z.string().optional(),
|
|
1816
2170
|
confirm: z.boolean().optional().describe('Set true to bypass the confirm gate.'),
|
|
@@ -1822,8 +2176,9 @@ export const editImage = {
|
|
|
1822
2176
|
if (input.background) {
|
|
1823
2177
|
await desktop.requireCapability('background-generation', 'background generation');
|
|
1824
2178
|
}
|
|
1825
|
-
|
|
1826
|
-
const
|
|
2179
|
+
// The image-edit tool's seat (TOOL_SEAT), the model the app's own Edit uses.
|
|
2180
|
+
const editModel = input.editModel ?? toolModelFor('image-edit');
|
|
2181
|
+
const resolution = input.resolution ?? defaultImageResolutionFor(editModel);
|
|
1827
2182
|
if (isGptImageModel(editModel)) {
|
|
1828
2183
|
// 🚨 v3, LIKE generateImage — this site was missed once already.
|
|
1829
2184
|
// A pre-2.5 desktop advertises v2, so gating the 2.5 seats on v2 lets it
|
|
@@ -1835,8 +2190,15 @@ export const editImage = {
|
|
|
1835
2190
|
else if (editModel === 'nano-banana-pro' || editModel === 'nano-banana-2-lite') {
|
|
1836
2191
|
await desktop.requireCapability('image-models-v2', `${editModel} editing`);
|
|
1837
2192
|
}
|
|
2193
|
+
if (input.referenceAssetIds?.length && !LEGACY_EDIT_REFERENCE_MODELS.includes(editModel)) {
|
|
2194
|
+
await desktop.requireCapability('edit-references-all-models', `references on a ${editModel} edit`);
|
|
2195
|
+
}
|
|
2196
|
+
// Past the model's cap the desktop sends the first ones and says so in its reply (`note`).
|
|
2197
|
+
const referencesOver = (input.referenceAssetIds?.length ?? 0) - editReferenceCap(editModel);
|
|
1838
2198
|
// Nano-Banana family + GPT Image 2.5 edits charge the same key as gen;
|
|
1839
2199
|
// FLUX / Seedream route to dedicated edit endpoints priced under '-edit' keys.
|
|
2200
|
+
// Mirrors `editCreditKey` in the desktop's src/shared/imageEdit.ts, which
|
|
2201
|
+
// this package cannot import: change both together.
|
|
1840
2202
|
const costKey = editModel === 'flux-2-max' || editModel === 'seedream-5-lite'
|
|
1841
2203
|
? `${imageCostKey(editModel, resolution)}-edit`
|
|
1842
2204
|
: imageCostKey(editModel, resolution, input.quality, input.aspectRatio);
|
|
@@ -1856,6 +2218,7 @@ export const editImage = {
|
|
|
1856
2218
|
estimated_credits: totalCents,
|
|
1857
2219
|
source_ref: sourceRef,
|
|
1858
2220
|
message: `Cost: ${fmtCredits(totalCents)} to edit ${sourceRef} with ${editModel} (${costKey}). ` +
|
|
2221
|
+
(referencesOver > 0 ? `${editModel} takes ${editReferenceCap(editModel)} references beside the source; ${referencesOver} would not be sent. ` : '') +
|
|
1859
2222
|
`Re-call with confirm=true after the user explicitly OKs the spend. ` +
|
|
1860
2223
|
`When discussing with the user, refer to the source by its code (matches the gallery badge).`,
|
|
1861
2224
|
});
|
|
@@ -1867,7 +2230,7 @@ export const editImage = {
|
|
|
1867
2230
|
editModel,
|
|
1868
2231
|
referenceAssetIds: input.referenceAssetIds,
|
|
1869
2232
|
resolution,
|
|
1870
|
-
...(isGptImageModel(editModel) ? { gptQuality: input.quality, gptBackground: input.backgroundMode } : {}),
|
|
2233
|
+
...(isGptImageModel(editModel) ? { gptQuality: input.quality ?? DEFAULT_GPT_QUALITY, gptBackground: input.backgroundMode } : {}),
|
|
1871
2234
|
aspectRatio: input.aspectRatio,
|
|
1872
2235
|
background: input.background,
|
|
1873
2236
|
});
|
|
@@ -1882,7 +2245,8 @@ export const editImage = {
|
|
|
1882
2245
|
sourceAssetId: input.sourceAssetId,
|
|
1883
2246
|
cost_cents: totalCents,
|
|
1884
2247
|
cost_credits: totalCents,
|
|
1885
|
-
|
|
2248
|
+
...(result.note ? { note: result.note } : {}),
|
|
2249
|
+
}, result.note ? `${result.note}.` : undefined);
|
|
1886
2250
|
}
|
|
1887
2251
|
// Inline the edited result so the LLM sees whether the surgery landed —
|
|
1888
2252
|
// same best-effort pattern as slates_generate_image.
|
|
@@ -1900,7 +2264,8 @@ export const editImage = {
|
|
|
1900
2264
|
return {
|
|
1901
2265
|
text: `Edited image saved as a new asset in project ${input.projectId} ` +
|
|
1902
2266
|
`for ${fmtCredits(totalCents)} via ${editModel}. ` +
|
|
1903
|
-
`Edit: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"
|
|
2267
|
+
`Edit: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"` +
|
|
2268
|
+
(result.note ? ` ${result.note}.` : ''),
|
|
1904
2269
|
images,
|
|
1905
2270
|
data: {
|
|
1906
2271
|
editModel,
|
|
@@ -1912,6 +2277,7 @@ export const editImage = {
|
|
|
1912
2277
|
cost_credits: totalCents,
|
|
1913
2278
|
asset: result.asset,
|
|
1914
2279
|
generationId: result.generationId,
|
|
2280
|
+
...(result.note ? { note: result.note } : {}),
|
|
1915
2281
|
},
|
|
1916
2282
|
};
|
|
1917
2283
|
},
|
|
@@ -2010,7 +2376,7 @@ export function videoCostKey(input) {
|
|
|
2010
2376
|
// × vref × res × duration). AI-face route bills the `-face-` key (~45% over
|
|
2011
2377
|
// faceless); consented real-person route bills the premium `-realface-` key
|
|
2012
2378
|
// (fal partner endpoint). A reference video flips to `-vref-{res}-{T}s`,
|
|
2013
|
-
// T = in + out.
|
|
2379
|
+
// T = in + out, or max(in, out) + out on the AI-face route.
|
|
2014
2380
|
//
|
|
2015
2381
|
// ⚠️ EVERY BOUND HERE IS VERSION-SCOPED. 2.5 runs 480p/720p/1080p (no 4K),
|
|
2016
2382
|
// reaches 30s, and takes references to 30s combined — so its vref total
|
|
@@ -2024,7 +2390,11 @@ export function videoCostKey(input) {
|
|
|
2024
2390
|
if (vrefSecs > 0) {
|
|
2025
2391
|
// ceil(x - 0.05) matches the server's probe rounding — quote = bill.
|
|
2026
2392
|
const maxTotal = v25 ? SEEDANCE_25_VREF_MAX_TOTAL : SEEDANCE_20_VREF_MAX_TOTAL;
|
|
2027
|
-
|
|
2393
|
+
// EvoLink (the AI-face rail) bills a reference on max(input, output) +
|
|
2394
|
+
// output on 2.0 and 2.5. Mirrors seedanceBilledRefSeconds in slate pricing.ts.
|
|
2395
|
+
const inSecs = Math.ceil(vrefSecs - 0.05);
|
|
2396
|
+
const billedIn = face === '-face' ? Math.max(inSecs, input.duration) : inSecs;
|
|
2397
|
+
const total = Math.min(maxTotal, Math.max(6, billedIn + input.duration));
|
|
2028
2398
|
return `${input.model}${face}-vref-${res}-${total}s`;
|
|
2029
2399
|
}
|
|
2030
2400
|
return `${input.model}${face}-${res}-${input.duration}s`;
|
|
@@ -2195,9 +2565,15 @@ function resolveVideoModel(raw) {
|
|
|
2195
2565
|
out.sound = true;
|
|
2196
2566
|
s = s.replace(/-audio\b/, '');
|
|
2197
2567
|
}
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2568
|
+
// Two routes, two keys: `-realface` is the real-person route (still refused
|
|
2569
|
+
// without realFaceConsent), `-face` the AI-face one.
|
|
2570
|
+
const face = /-(realface|face)\b/.exec(s);
|
|
2571
|
+
if (face) {
|
|
2572
|
+
if (face[1] === 'realface')
|
|
2573
|
+
out.seedanceRealFace = true;
|
|
2574
|
+
else
|
|
2575
|
+
out.seedanceFace = true;
|
|
2576
|
+
s = s.replace(face[0], '');
|
|
2201
2577
|
}
|
|
2202
2578
|
const direct = VIDEO_MODELS.find((m) => m === s);
|
|
2203
2579
|
if (direct) {
|
|
@@ -2211,8 +2587,8 @@ function resolveVideoModel(raw) {
|
|
|
2211
2587
|
'kling-v3': 'kling-v3.0-std',
|
|
2212
2588
|
'kling-v3-pro': 'kling-v3.0-pro',
|
|
2213
2589
|
'kling-v3-omni': 'kling-v3.0-omni',
|
|
2214
|
-
|
|
2215
|
-
|
|
2590
|
+
// No Omni Pro spelling here: Omni Pro is not a seat on this surface, and
|
|
2591
|
+
// aliasing it to Omni ran a cheaper model after a Pro quote.
|
|
2216
2592
|
'seedance-2.0': 'seedance-2',
|
|
2217
2593
|
'seedance-2-0': 'seedance-2',
|
|
2218
2594
|
// ⚠️ The 2.5 spellings must resolve to 2.5, and the BARE `seedance` keeps
|
|
@@ -2229,9 +2605,14 @@ function resolveVideoModel(raw) {
|
|
|
2229
2605
|
'gemini-omni-flash': 'omni-flash',
|
|
2230
2606
|
'gemini-omni-flash-preview': 'omni-flash',
|
|
2231
2607
|
'omni-flash-preview': 'omni-flash',
|
|
2232
|
-
// The MAX spellings must come out as
|
|
2233
|
-
// `hailuo-3` all mean the BASE row — it holds the full ladder and
|
|
2234
|
-
// references, and it is cheaper at the tier they share.
|
|
2608
|
+
// The MAX and TURBO spellings must come out as themselves. Bare `minimax`,
|
|
2609
|
+
// `h3` and `hailuo-3` all mean the BASE row — it holds the full ladder and
|
|
2610
|
+
// the references, and it is cheaper than Max at the tier they share.
|
|
2611
|
+
'minimax-h3-max-turbo': 'minimax-h3-max-turbo',
|
|
2612
|
+
'minimax-h3-turbo': 'minimax-h3-max-turbo',
|
|
2613
|
+
'h3-max-turbo': 'minimax-h3-max-turbo',
|
|
2614
|
+
'h3-turbo': 'minimax-h3-max-turbo',
|
|
2615
|
+
'hailuo-3-max-turbo': 'minimax-h3-max-turbo',
|
|
2235
2616
|
'minimax-h3-max': 'minimax-h3-max',
|
|
2236
2617
|
'minimax-h3max': 'minimax-h3-max',
|
|
2237
2618
|
'h3-max': 'minimax-h3-max',
|
|
@@ -2348,7 +2729,7 @@ export const generateVideo = {
|
|
|
2348
2729
|
// kling-v3.0-omni-pro entirely and read as if 7 were an Omni Flash-only rule.
|
|
2349
2730
|
`Visual reference / ingredient assets. Cap per model, combined across the ingredient/character/environment/style params: ${describeReferenceImageCaps(VIDEO_MODELS)}. 2-4 strong references beat both extremes.`),
|
|
2350
2731
|
characterAssetIds: z.array(z.string()).optional().describe('Character sheet assets — keeps a character consistent.'),
|
|
2351
|
-
environmentAssetIds: z.array(z.string()).optional().describe('
|
|
2732
|
+
environmentAssetIds: z.array(z.string()).optional().describe('Location references — keeps a location consistent.'),
|
|
2352
2733
|
styleAssetIds: z.array(z.string()).optional().describe('Style references — locks the look.'),
|
|
2353
2734
|
videoReferenceAssetId: z.string().optional().describe('DEPRECATED — use videoReferenceAssetIds. Kept working: shipped CLI/MCP builds send this shape.'),
|
|
2354
2735
|
videoReferenceSeconds: z.number().optional().describe('DEPRECATED — the singular partner of videoReferenceSecondsEach.'),
|
|
@@ -2361,11 +2742,11 @@ export const generateVideo = {
|
|
|
2361
2742
|
videoReferenceSecondsEach: z.array(z.number()).optional().describe('REQUIRED with videoReferenceAssetIds, same order and length: each clip\'s duration in seconds. Feeds the vref cost key; the server re-probes and corrects an understated value upward.'),
|
|
2362
2743
|
audioReferenceAssetIds: z.array(z.string()).optional().describe(`Reference AUDIO, cited as "audio 1", "audio 2"… in the order given. ${multimodalRefModels().join(' / ')} only. No billing surcharge. ${multimodalRefModels().map(multimodalRefSummary).join(' ')}`),
|
|
2363
2744
|
audioReferenceSpokenText: z.array(z.string()).optional().describe('The exact words in each reference clip — same order and length as audioReferenceAssetIds, "" for a clip with no speech. The model RE-TRANSCRIBES a take rather than using it verbatim, so audio decides voice/accent/timing and only this decides the WORDS. Omit it and the words are a guess.'),
|
|
2364
|
-
sound: z.boolean().optional().describe('Kling Omni / Veo / Seedance:
|
|
2745
|
+
sound: z.boolean().optional().describe('Kling Omni / Veo / Seedance: Sound on (true) or Silent (false). Default true.'),
|
|
2365
2746
|
audioLanguage: z.enum(['EN', 'ZH', 'JA', 'KO', 'ES']).optional().describe('Kling Omni only — language for dialogue.'),
|
|
2366
2747
|
generateMusic: z.boolean().optional().describe('Kling Omni only — auto-generate background music.'),
|
|
2367
2748
|
seedanceFace: z.boolean().optional().describe('Seedance ONLY: a reference shows an AI CHARACTER\'s face. Faces are blocked on the default route, so this reroutes to a face-capable provider at ~45% more. A REAL person fails here with [REAL_FACE_DETECTED] — see seedanceRealFace.'),
|
|
2368
|
-
seedanceRealFace: z.boolean().optional().describe('Seedance ONLY: a reference shows a REAL person.
|
|
2749
|
+
seedanceRealFace: z.boolean().optional().describe('Seedance ONLY: a reference shows a REAL person. Real-person route, roughly 2x the AI-face price — quote it first. REQUIRES realFaceConsent=true.'),
|
|
2369
2750
|
realFaceConsent: z.boolean().optional().describe('MANDATORY with seedanceRealFace: true ONLY after the user has explicitly confirmed they hold rights/consent to this likeness and it does not impersonate or misrepresent them. Refused without it; public figures fail on every route.'),
|
|
2370
2751
|
negativePrompt: z.string().optional(),
|
|
2371
2752
|
background: z.boolean().optional().describe(BACKGROUND_DESCRIBE),
|
|
@@ -2391,13 +2772,15 @@ export const generateVideo = {
|
|
|
2391
2772
|
input.sound = resolved.sound;
|
|
2392
2773
|
if (input.seedanceFace == null && resolved.seedanceFace != null)
|
|
2393
2774
|
input.seedanceFace = resolved.seedanceFace;
|
|
2775
|
+
if (input.seedanceRealFace == null && resolved.seedanceRealFace != null)
|
|
2776
|
+
input.seedanceRealFace = resolved.seedanceRealFace;
|
|
2394
2777
|
// projectId is required for video — without it there's no UI feedback,
|
|
2395
2778
|
// no asset to reference later, and a failed gen leaves the user with
|
|
2396
2779
|
// nothing. The MCP-only headless path that exists for image gen is
|
|
2397
2780
|
// not reasonable for video given the cost.
|
|
2398
2781
|
// Non-blocking prompt hygiene, computed once. Reported on the gates that
|
|
2399
2782
|
// fire BEFORE any spend, where a rewrite is still free.
|
|
2400
|
-
const promptWarning = bannedTokenWarning(input.prompt, 'video');
|
|
2783
|
+
const promptWarning = bannedTokenWarning(input.prompt, 'video', promptingSkillFor(resolved.model));
|
|
2401
2784
|
if (!input.projectId) {
|
|
2402
2785
|
return ok({
|
|
2403
2786
|
requires_clarification: true,
|
|
@@ -2497,7 +2880,9 @@ export const generateVideo = {
|
|
|
2497
2880
|
// not a number the registry models: fal publishes text-to-video,
|
|
2498
2881
|
// image-to-video and reference-to-video for `minimax/h3`, and only the
|
|
2499
2882
|
// all three for `minimax/h3-max` as well (corrected 2026-09-09 — its
|
|
2500
|
-
// reference-to-video was wrongly believed to 404).
|
|
2883
|
+
// reference-to-video was wrongly believed to 404). `minimax/h3-max-turbo`
|
|
2884
|
+
// has text-to-video and image-to-video only; its caps declare no references,
|
|
2885
|
+
// so the first branch below refuses them. The
|
|
2501
2886
|
// reference endpoint has no frame parameters at all, so frames and
|
|
2502
2887
|
// references are mutually exclusive — a shape mismatch, not a preference.
|
|
2503
2888
|
if (MINIMAX_MODELS.has(input.model)) {
|
|
@@ -2669,7 +3054,7 @@ export const generateVideo = {
|
|
|
2669
3054
|
return ok({
|
|
2670
3055
|
requires_clarification: true,
|
|
2671
3056
|
missing: ['videoReferenceSeconds'],
|
|
2672
|
-
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.',
|
|
3057
|
+
message: 'A Seedance video reference bills on combined input+output seconds (on 2.5 AI-face, max(input, output) + output). Pass videoReferenceSeconds (the reference clip\'s duration, shown in slates_list_assets) so the pre-flight quote matches the bill.',
|
|
2673
3058
|
});
|
|
2674
3059
|
}
|
|
2675
3060
|
const pluralRefCount = input.videoReferenceAssetIds?.length ?? 0;
|
|
@@ -2678,7 +3063,7 @@ export const generateVideo = {
|
|
|
2678
3063
|
return ok({
|
|
2679
3064
|
requires_clarification: true,
|
|
2680
3065
|
missing: ['videoReferenceSecondsEach'],
|
|
2681
|
-
message: `A Seedance video reference bills on combined input+output seconds. Pass videoReferenceSecondsEach with exactly ${pluralRefCount} duration${pluralRefCount === 1 ? '' : 's'}, in the same order as videoReferenceAssetIds (durations are shown in slates_list_assets), so the pre-flight quote matches the bill.`,
|
|
3066
|
+
message: `A Seedance video reference bills on combined input+output seconds (on 2.5 AI-face, max(input, output) + output). Pass videoReferenceSecondsEach with exactly ${pluralRefCount} duration${pluralRefCount === 1 ? '' : 's'}, in the same order as videoReferenceAssetIds (durations are shown in slates_list_assets), so the pre-flight quote matches the bill.`,
|
|
2682
3067
|
});
|
|
2683
3068
|
}
|
|
2684
3069
|
const cloud = ctx.cloud();
|
|
@@ -3139,11 +3524,42 @@ export const generateAudio = {
|
|
|
3139
3524
|
};
|
|
3140
3525
|
},
|
|
3141
3526
|
};
|
|
3527
|
+
// ── The Kling tools' quote (lip-sync, motion transfer) ─────────
|
|
3528
|
+
/** The server bills the tools in blocks of this many seconds (slates-api `TOOL_BLOCK_SECONDS`). */
|
|
3529
|
+
const TOOL_BLOCK_SECONDS = 5;
|
|
3530
|
+
/** A project asset's recorded length in seconds, or null when its row has none. */
|
|
3531
|
+
async function recordedSeconds(desktop, projectId, assetId) {
|
|
3532
|
+
const { assets } = await desktop.get('/agent/assets', { projectId });
|
|
3533
|
+
const row = (assets ?? []).find((a) => String(a.id).toLowerCase() === assetId.toLowerCase());
|
|
3534
|
+
const seconds = Number(row?.duration);
|
|
3535
|
+
return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
|
|
3536
|
+
}
|
|
3537
|
+
/**
|
|
3538
|
+
* Whole blocks of the media the output follows, priced as the server bills
|
|
3539
|
+
* them: `${stem}-${blocks × 5}s` measured from that media (slates-api
|
|
3540
|
+
* `tool-keys.ts`), plus one flat block for a voice step. An unknown length
|
|
3541
|
+
* quotes one block, the floor. A flat one-block quote approved a 30s Motion
|
|
3542
|
+
* Control Pro at 42 credits that billed 252.
|
|
3543
|
+
*/
|
|
3544
|
+
async function quoteToolBlocks(ctx, stem, seconds, voiceStep) {
|
|
3545
|
+
const blocks = Math.max(1, Math.ceil((seconds ?? 0) / TOOL_BLOCK_SECONDS));
|
|
3546
|
+
const costKey = `${stem}-${blocks * TOOL_BLOCK_SECONDS}s`;
|
|
3547
|
+
const byKey = new Map();
|
|
3548
|
+
const price = async (key) => {
|
|
3549
|
+
await loadDynamicPrice(ctx, byKey, key);
|
|
3550
|
+
const credits = byKey.get(key);
|
|
3551
|
+
if (credits == null)
|
|
3552
|
+
throw new Error(`Model variant not in registry: ${key}`);
|
|
3553
|
+
return credits;
|
|
3554
|
+
};
|
|
3555
|
+
const totalCents = (await price(costKey)) + (voiceStep ? await price(`${stem}-${TOOL_BLOCK_SECONDS}s`) : 0);
|
|
3556
|
+
return { costKey, totalCents, blocks };
|
|
3557
|
+
}
|
|
3142
3558
|
// ── Generate lip-sync ───────────────────────────────────────────
|
|
3143
3559
|
export const generateLipSync = {
|
|
3144
3560
|
id: 'slates_generate_lip_sync',
|
|
3145
3561
|
billable: true,
|
|
3146
|
-
description: 'Lip-sync a still image (avatar) or a video clip to audio. KLING-ONLY — this tool wraps Kling\'s dedicated lip-sync endpoints and nothing else: sourceType=video re-syncs a clip, sourceType=image animates a still avatar (avatar-standard, or avatar-pro for the premium seat). Quote each with slates_estimate_generation_cost rather than from memory. Audio from TTS (ttsText + ttsVoice) or an uploaded file.
|
|
3562
|
+
description: 'Lip-sync a still image (avatar) or a video clip to audio. KLING-ONLY — this tool wraps Kling\'s dedicated lip-sync endpoints and nothing else: sourceType=video re-syncs a clip, sourceType=image animates a still avatar (avatar-standard, or avatar-pro for the premium seat). Quote each with slates_estimate_generation_cost rather than from memory. Audio from TTS (ttsText + ttsVoice) or an uploaded file. Billed per 5s of the output (the clip, or on a still the voice track). For a Seedance version, do NOT look for an engine switch here — run a normal slates_generate_video on seedance-2 with the clip attached as a video reference and the dialogue written into the prompt; that is the same call, with the prompt visible and editable. REQUIRED before calling: slates-cost-discipline + slates-prompting-lip-sync skills. projectId is REQUIRED.',
|
|
3147
3563
|
input: z.object({
|
|
3148
3564
|
projectId: z.string().uuid().describe('Slates project the source asset lives in. The new lip-synced video lands here.'),
|
|
3149
3565
|
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.'),
|
|
@@ -3174,27 +3590,28 @@ export const generateLipSync = {
|
|
|
3174
3590
|
message: 'audioMethod=upload requires audioFilePath. Pass an absolute path to the audio file on the user\'s machine.',
|
|
3175
3591
|
});
|
|
3176
3592
|
}
|
|
3177
|
-
|
|
3178
|
-
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
const
|
|
3593
|
+
const stem = input.sourceType === 'video'
|
|
3594
|
+
? 'kling-lip-sync-video'
|
|
3595
|
+
: input.avatarModel === 'avatar-pro' ? 'kling-lip-sync-avatar-pro' : 'kling-lip-sync-avatar';
|
|
3596
|
+
// Billed per 5s block of the output: the clip's length on a video source;
|
|
3597
|
+
// on a still, the voice track's — typed words at the desktop's reading pace
|
|
3598
|
+
// (`lipSyncEstimate`, 13 characters a second, so this quote is the Generate
|
|
3599
|
+
// button's), an uploaded file unknown until the desktop measures it. Typed
|
|
3600
|
+
// words on a still are two billed calls: the voice step adds one flat block.
|
|
3601
|
+
const seconds = input.sourceType === 'video'
|
|
3602
|
+
? await recordedSeconds(ctx.desktop(), input.projectId, input.sourceAssetId)
|
|
3603
|
+
: input.audioMethod === 'tts'
|
|
3604
|
+
? (input.ttsText ?? '').trim().length / (13 * (input.ttsSpeed ?? 1))
|
|
3605
|
+
: null;
|
|
3606
|
+
const voiceStep = input.sourceType === 'image' && input.audioMethod === 'tts';
|
|
3607
|
+
const { costKey, totalCents, blocks } = await quoteToolBlocks(ctx, stem, seconds, voiceStep);
|
|
3608
|
+
const length = `${blocks * TOOL_BLOCK_SECONDS}s${voiceStep ? ' + voice' : ''}`;
|
|
3193
3609
|
// Cost confirm gate. Lip-sync is mechanical — the model re-syncs the
|
|
3194
3610
|
// user-chosen source to the user-chosen audio. The agent doesn't
|
|
3195
3611
|
// write a prompt that depends on what the source looks like, so we
|
|
3196
3612
|
// skip the inline preview and just announce the source code in text.
|
|
3197
|
-
|
|
3613
|
+
// An unknown length quotes one block, a floor, so it always asks.
|
|
3614
|
+
if ((totalCents > CONFIRM_CREDITS || seconds == null) && !input.confirm) {
|
|
3198
3615
|
const sourceRef = await lookupAssetRef(ctx.desktop(), input.sourceAssetId);
|
|
3199
3616
|
const audioPreview = input.audioMethod === 'tts'
|
|
3200
3617
|
? `Audio: TTS — "${(input.ttsText ?? '').slice(0, 120)}"`
|
|
@@ -3205,7 +3622,7 @@ export const generateLipSync = {
|
|
|
3205
3622
|
estimated_cents: totalCents,
|
|
3206
3623
|
estimated_credits: totalCents,
|
|
3207
3624
|
source_ref: sourceRef,
|
|
3208
|
-
message: `Cost: ${fmtCredits(totalCents)} for
|
|
3625
|
+
message: `Cost: ${fmtCredits(totalCents)}${seconds == null ? ' or more' : ''} for ${length} lip-sync (${costKey}). ` +
|
|
3209
3626
|
`Source: ${sourceRef}. ${audioPreview}. ` +
|
|
3210
3627
|
`Re-call with confirm=true after the user explicitly OKs the spend. ` +
|
|
3211
3628
|
`When discussing with the user, refer to the source by its code (matches the gallery badge).`,
|
|
@@ -3226,14 +3643,15 @@ export const generateLipSync = {
|
|
|
3226
3643
|
audioFilePath: input.audioFilePath,
|
|
3227
3644
|
avatarModel: input.avatarModel,
|
|
3228
3645
|
klingProvider: input.klingProvider,
|
|
3229
|
-
estimatedCost:
|
|
3646
|
+
// No estimatedCost: the desktop prices the media itself, in dollars.
|
|
3647
|
+
// This op's figure is CREDITS, which a 1.5.8 desktop recorded as dollars.
|
|
3230
3648
|
background: input.background,
|
|
3231
3649
|
});
|
|
3232
3650
|
if (!result.success)
|
|
3233
3651
|
throw new Error(result.error ?? 'Lip-sync generation failed');
|
|
3234
3652
|
if (result.background) {
|
|
3235
3653
|
const ids = result.generationIds ?? (result.generationId ? [result.generationId] : []);
|
|
3236
|
-
return backgroundSubmitted(
|
|
3654
|
+
return backgroundSubmitted(`${length} lip-sync (${costKey})`, ids, {
|
|
3237
3655
|
variant: costKey,
|
|
3238
3656
|
projectId: input.projectId,
|
|
3239
3657
|
sourceAssetId: input.sourceAssetId,
|
|
@@ -3242,7 +3660,7 @@ export const generateLipSync = {
|
|
|
3242
3660
|
});
|
|
3243
3661
|
}
|
|
3244
3662
|
return {
|
|
3245
|
-
text: `Generated
|
|
3663
|
+
text: `Generated ${length} lip-sync (${costKey}) into project ${input.projectId} ` +
|
|
3246
3664
|
`for ${fmtCredits(totalCents)}. ` +
|
|
3247
3665
|
(input.audioMethod === 'tts'
|
|
3248
3666
|
? `Spoken: "${(input.ttsText ?? '').slice(0, 60)}${(input.ttsText ?? '').length > 60 ? '...' : ''}"`
|
|
@@ -3264,7 +3682,7 @@ export const generateLipSync = {
|
|
|
3264
3682
|
export const generateMotionTransfer = {
|
|
3265
3683
|
id: 'slates_generate_motion_transfer',
|
|
3266
3684
|
billable: true,
|
|
3267
|
-
description: 'Transfer the motion from a reference video onto a target image character. KLING-ONLY — this tool wraps Kling Motion Control and nothing else: kling-mc-std or kling-mc-pro, structured skeleton/depth retargeting,
|
|
3685
|
+
description: 'Transfer the motion from a reference video onto a target image character. KLING-ONLY — this tool wraps Kling Motion Control and nothing else: kling-mc-std or kling-mc-pro, structured skeleton/depth retargeting, billed per 5s of the driving clip. For a Seedance version, do NOT look for an engine switch here — run a normal slates_generate_video on seedance-2 with the driving clip attached as a video reference and the motion described in the prompt ("the character from image 1 performs the exact motion from video 1"); that is the same call, with the prompt visible and editable. REQUIRED before calling: slates-cost-discipline + slates-prompting-motion-transfer skills. projectId is REQUIRED — both assets must exist in the project. ' +
|
|
3268
3686
|
CONFIRM_GATE_SENTENCE,
|
|
3269
3687
|
input: z.object({
|
|
3270
3688
|
projectId: z.string().uuid().describe('Slates project. Both source and target assets must live here.'),
|
|
@@ -3279,19 +3697,17 @@ export const generateMotionTransfer = {
|
|
|
3279
3697
|
}),
|
|
3280
3698
|
async run(input, ctx) {
|
|
3281
3699
|
const motionModel = input.motionModel ?? 'kling-mc-pro';
|
|
3282
|
-
|
|
3283
|
-
|
|
3284
|
-
const
|
|
3285
|
-
const
|
|
3286
|
-
|
|
3287
|
-
if (!entry)
|
|
3288
|
-
throw new Error(`Model variant not in registry: ${costKey}`);
|
|
3289
|
-
const totalCents = creditCost(entry);
|
|
3700
|
+
// Billed per 5s block of the driving clip, which the output follows, up to
|
|
3701
|
+
// the orientation's maximum (the desktop's `motionTransferEstimate`).
|
|
3702
|
+
const clipSeconds = await recordedSeconds(ctx.desktop(), input.projectId, input.sourceVideoAssetId);
|
|
3703
|
+
const seconds = clipSeconds == null ? null : Math.min(clipSeconds, (input.characterOrientation ?? 'video') === 'video' ? 30 : 10);
|
|
3704
|
+
const { costKey, totalCents, blocks } = await quoteToolBlocks(ctx, motionModel, seconds, false);
|
|
3290
3705
|
// Cost confirm gate. Motion transfer is mechanical — the model
|
|
3291
3706
|
// applies source motion to target image deterministically. We don't
|
|
3292
3707
|
// burn tokens previewing assets the user already chose; codes in the
|
|
3293
|
-
// text are enough to keep the chat unambiguous.
|
|
3294
|
-
|
|
3708
|
+
// text are enough to keep the chat unambiguous. A clip with no recorded
|
|
3709
|
+
// length quotes one block, a floor, so it always asks.
|
|
3710
|
+
if ((totalCents > CONFIRM_CREDITS || seconds == null) && !input.confirm) {
|
|
3295
3711
|
const desktop = ctx.desktop();
|
|
3296
3712
|
const [source, target] = await Promise.all([
|
|
3297
3713
|
lookupAssetRef(desktop, input.sourceVideoAssetId),
|
|
@@ -3304,14 +3720,14 @@ export const generateMotionTransfer = {
|
|
|
3304
3720
|
estimated_credits: totalCents,
|
|
3305
3721
|
source_ref: source,
|
|
3306
3722
|
target_ref: target,
|
|
3307
|
-
message: `Cost: ${fmtCredits(totalCents)} for
|
|
3723
|
+
message: `Cost: ${fmtCredits(totalCents)}${seconds == null ? ' or more' : ''} for ${blocks * TOOL_BLOCK_SECONDS}s ${motionModel} (${costKey}). ` +
|
|
3308
3724
|
`Transferring motion from ${source} onto ${target}. ` +
|
|
3309
3725
|
// The saving is READ from the registry, never guessed: "~10 credits"
|
|
3310
3726
|
// was hand-typed and is a rate change away from being a lie.
|
|
3311
3727
|
`Re-call with confirm=true after the user explicitly OKs the spend${motionModel === 'kling-mc-pro'
|
|
3312
|
-
? (() => {
|
|
3313
|
-
const std =
|
|
3314
|
-
const saving =
|
|
3728
|
+
? await (async () => {
|
|
3729
|
+
const std = await quoteToolBlocks(ctx, 'kling-mc-std', seconds, false);
|
|
3730
|
+
const saving = totalCents - std.totalCents;
|
|
3315
3731
|
return saving > 0 ? `, or pick kling-mc-std to save ${fmtCredits(saving)}` : '';
|
|
3316
3732
|
})()
|
|
3317
3733
|
: ''}. ` +
|
|
@@ -3330,14 +3746,15 @@ export const generateMotionTransfer = {
|
|
|
3330
3746
|
characterOrientation: input.characterOrientation ?? 'video',
|
|
3331
3747
|
prompt: input.prompt,
|
|
3332
3748
|
klingProvider: input.klingProvider,
|
|
3333
|
-
estimatedCost:
|
|
3749
|
+
// No estimatedCost: the desktop prices the media itself, in dollars.
|
|
3750
|
+
// This op's figure is CREDITS, which a 1.5.8 desktop recorded as dollars.
|
|
3334
3751
|
background: input.background,
|
|
3335
3752
|
});
|
|
3336
3753
|
if (!result.success)
|
|
3337
3754
|
throw new Error(result.error ?? 'Motion transfer generation failed');
|
|
3338
3755
|
if (result.background) {
|
|
3339
3756
|
const ids = result.generationIds ?? (result.generationId ? [result.generationId] : []);
|
|
3340
|
-
return backgroundSubmitted(`
|
|
3757
|
+
return backgroundSubmitted(`motion transfer (${motionModel})`, ids, {
|
|
3341
3758
|
variant: costKey,
|
|
3342
3759
|
motionModel,
|
|
3343
3760
|
projectId: input.projectId,
|
|
@@ -3348,7 +3765,7 @@ export const generateMotionTransfer = {
|
|
|
3348
3765
|
});
|
|
3349
3766
|
}
|
|
3350
3767
|
return {
|
|
3351
|
-
text: `Generated
|
|
3768
|
+
text: `Generated motion transfer (${motionModel}) into project ${input.projectId} ` +
|
|
3352
3769
|
`for ${fmtCredits(totalCents)}.` +
|
|
3353
3770
|
(input.prompt ? ` Prompt: "${input.prompt.slice(0, 60)}${input.prompt.length > 60 ? '...' : ''}"` : ''),
|
|
3354
3771
|
data: {
|
|
@@ -3633,15 +4050,53 @@ export const listGenerations = {
|
|
|
3633
4050
|
}));
|
|
3634
4051
|
},
|
|
3635
4052
|
};
|
|
4053
|
+
const exportCutsInput = z.object({ projectId: z.string().uuid(), directory: z.string(), manifestId: z.string().min(1),
|
|
4054
|
+
action: z.enum(['start', 'status', 'cancel']).optional().describe('start (default) begins, or re-attaches to a running export; status reads it; cancel stops after the output being rendered.'),
|
|
4055
|
+
items: z.array(z.object({ timelineId: z.string().uuid(), format: z.enum(['mp4', 'xml']) })).min(1).optional().describe('Required to start.') });
|
|
4056
|
+
export const exportCuts = {
|
|
4057
|
+
id: 'slates_export_cuts', description: 'Export explicit named cuts to distinct local files with a frozen manifest of timeline settings, media, script revisions and generation provenance. Returns at once while rendering continues; poll with action status. Starting again with the same manifestId and selection retries unfinished outputs under the same filenames and skips finished ones. Existing outputs are never overwritten, and export never generates media. Uses current video/XML fidelity limits.',
|
|
4058
|
+
input: exportCutsInput,
|
|
4059
|
+
async run(input, ctx) {
|
|
4060
|
+
await ctx.desktop().requireCapability('named-cuts', 'named cuts');
|
|
4061
|
+
return ok(await ctx.desktop().post('/agent/timeline/export-cuts', input));
|
|
4062
|
+
},
|
|
4063
|
+
};
|
|
4064
|
+
export const listTimelines = {
|
|
4065
|
+
id: 'slates_list_timelines', description: 'List the independent named cuts in a project. Use the selected timelineId for edits, builds and exports.',
|
|
4066
|
+
input: z.object({ projectId: z.string().uuid() }),
|
|
4067
|
+
async run(input, ctx) {
|
|
4068
|
+
await ctx.desktop().requireCapability('named-cuts', 'named cuts');
|
|
4069
|
+
return ok(await ctx.desktop().get('/agent/timelines', input));
|
|
4070
|
+
},
|
|
4071
|
+
};
|
|
4072
|
+
export const saveTimeline = {
|
|
4073
|
+
id: 'slates_save_timeline', description: 'Create a named cut, or rename the explicit timelineId. Existing cuts and the legacy default stay intact.',
|
|
4074
|
+
input: z.object({ projectId: z.string().uuid(), timelineId: z.string().uuid().optional(), name: z.string().min(1) }),
|
|
4075
|
+
async run(input, ctx) {
|
|
4076
|
+
await ctx.desktop().requireCapability('named-cuts', 'named cuts');
|
|
4077
|
+
return ok(await ctx.desktop().post('/agent/timelines', input));
|
|
4078
|
+
},
|
|
4079
|
+
};
|
|
4080
|
+
/**
|
|
4081
|
+
* A `timelineId` names a cut only on a desktop with named cuts. Its read, clip,
|
|
4082
|
+
* track and settings routes predate them and ignore the field, so a 1.5.8
|
|
4083
|
+
* desktop would act on the default timeline instead of the one named. The
|
|
4084
|
+
* exports resolved a timeline id before named cuts and need no gate.
|
|
4085
|
+
*/
|
|
4086
|
+
async function requireNamedCut(desktop, timelineId) {
|
|
4087
|
+
if (timelineId)
|
|
4088
|
+
await desktop.requireCapability('named-cuts', 'named cuts');
|
|
4089
|
+
}
|
|
3636
4090
|
export const getTimeline = {
|
|
3637
4091
|
id: 'slates_get_timeline',
|
|
3638
|
-
description: 'Get (or lazily create)
|
|
3639
|
-
input: z.object({ projectId: z.string().uuid() }),
|
|
4092
|
+
description: 'Get (or lazily create) a named editing timeline, or the stable legacy default when timelineId is omitted, with all tracks, clips, markers, and a flat clipIndex mapping every clip back to its source asset (assetId + code + label). Frames are the unit of time; durationSec is provided. Call this before adding, reordering, or removing clips, and before exporting — it tells you the timeline id, frame rate, resolution, and current end frame.',
|
|
4093
|
+
input: z.object({ projectId: z.string().uuid(), timelineId: z.string().uuid().optional() }),
|
|
3640
4094
|
async run(input, ctx) {
|
|
3641
4095
|
const desktop = ctx.desktop();
|
|
3642
4096
|
await desktop.requireCapability('timeline', 'timeline editing');
|
|
4097
|
+
await requireNamedCut(desktop, input.timelineId);
|
|
3643
4098
|
const r = await desktop.get('/agent/timeline', {
|
|
3644
|
-
projectId: input.projectId,
|
|
4099
|
+
projectId: input.projectId, timelineId: input.timelineId,
|
|
3645
4100
|
});
|
|
3646
4101
|
const t = r.timeline ?? {};
|
|
3647
4102
|
const clipCount = r.clipIndex?.length ??
|
|
@@ -3657,7 +4112,7 @@ export const addClipToTimeline = {
|
|
|
3657
4112
|
id: 'slates_add_clip_to_timeline',
|
|
3658
4113
|
description: "Append a video or audio asset from the project to the project's timeline (or place it at an explicit startFrame). Defaults match the desktop UI: video clips go to the end of the first video track; audio clips (music, voiceover, AI audio) go after the last clip on the first AUDIO track and are mixed under the video on export. An empty timeline auto-adopts the first video clip's resolution and frame rate; later higher-resolution clips raise the canvas. Overlapping video clips resolve top-track-wins. Optionally trim with sourceInFrame/sourceOutFrame (frames at the SOURCE fps). Use slates_get_timeline first to see current clips and pick positions.",
|
|
3659
4114
|
input: z.object({
|
|
3660
|
-
projectId: z.string().uuid(),
|
|
4115
|
+
projectId: z.string().uuid(), timelineId: z.string().uuid().optional(),
|
|
3661
4116
|
assetId: z.string().uuid().describe('Video or audio asset already in the project.'),
|
|
3662
4117
|
trackId: z.string().uuid().optional().describe('Target track (type must match the asset: video asset → video track, audio asset → audio track). Default: the first track of the matching type.'),
|
|
3663
4118
|
startFrame: z.number().int().min(0).optional().describe('Timeline frame to place the clip at. Default: append after the last clip.'),
|
|
@@ -3667,8 +4122,9 @@ export const addClipToTimeline = {
|
|
|
3667
4122
|
async run(input, ctx) {
|
|
3668
4123
|
const desktop = ctx.desktop();
|
|
3669
4124
|
await desktop.requireCapability('timeline', 'timeline editing');
|
|
4125
|
+
await requireNamedCut(desktop, input.timelineId);
|
|
3670
4126
|
const r = await desktop.post('/agent/timeline/add-clip', {
|
|
3671
|
-
projectId: input.projectId,
|
|
4127
|
+
projectId: input.projectId, timelineId: input.timelineId,
|
|
3672
4128
|
assetId: input.assetId,
|
|
3673
4129
|
trackId: input.trackId,
|
|
3674
4130
|
startFrame: input.startFrame,
|
|
@@ -3710,17 +4166,40 @@ export const removeClip = {
|
|
|
3710
4166
|
return ok(await desktop.post('/agent/timeline/remove-clip', input));
|
|
3711
4167
|
},
|
|
3712
4168
|
};
|
|
4169
|
+
export const manageTimelineMarker = {
|
|
4170
|
+
id: 'slates_manage_timeline_marker',
|
|
4171
|
+
description: "Add, change or delete a marker on the timeline, as the Cut's ruler and marker menus do (slates_get_timeline lists them under timeline.markers). create: frame, optional name and color. update: markerId plus any of frame, name, color. delete: markerId. The color is one of the app's marker colours by name (e.g. Red); any other is refused with the list. The open Cut shows the change.",
|
|
4172
|
+
input: z.object({
|
|
4173
|
+
action: z.enum(['create', 'update', 'delete']),
|
|
4174
|
+
projectId: z.string().uuid(),
|
|
4175
|
+
timelineId: z.string().uuid().optional().describe("A named cut; omit for the project's timeline."),
|
|
4176
|
+
markerId: z.string().uuid().optional(),
|
|
4177
|
+
frame: z.number().int().min(0).optional().describe('Where the marker sits, in timeline frames.'),
|
|
4178
|
+
name: z.string().max(120).optional(),
|
|
4179
|
+
color: z.string().optional(),
|
|
4180
|
+
}),
|
|
4181
|
+
async run(input, ctx) {
|
|
4182
|
+
const desktop = ctx.desktop();
|
|
4183
|
+
await desktop.requireCapability('timeline-markers', 'timeline markers');
|
|
4184
|
+
if (input.action === 'create' && input.frame === undefined)
|
|
4185
|
+
throw new Error('create needs a frame.');
|
|
4186
|
+
if (input.action !== 'create' && !input.markerId)
|
|
4187
|
+
throw new Error(`${input.action} needs a markerId (slates_get_timeline lists them).`);
|
|
4188
|
+
return ok(await desktop.post('/agent/timeline/markers', input));
|
|
4189
|
+
},
|
|
4190
|
+
};
|
|
3713
4191
|
export const addTimelineTrack = {
|
|
3714
4192
|
id: 'slates_add_timeline_track',
|
|
3715
4193
|
description: "Add a track to the project's timeline (default: an audio track, for layering voiceover + music + AI audio). The new track is appended below existing tracks. Returns the new track and the full timeline.",
|
|
3716
4194
|
input: z.object({
|
|
3717
|
-
projectId: z.string().uuid(),
|
|
4195
|
+
projectId: z.string().uuid(), timelineId: z.string().uuid().optional(),
|
|
3718
4196
|
type: z.enum(['video', 'audio']).optional().describe('Default: audio.'),
|
|
3719
4197
|
name: z.string().optional().describe("Default: 'Audio N' / 'Video N'."),
|
|
3720
4198
|
}),
|
|
3721
4199
|
async run(input, ctx) {
|
|
3722
4200
|
const desktop = ctx.desktop();
|
|
3723
4201
|
await desktop.requireCapability('timeline-tracks', 'timeline tracks + audio mixing');
|
|
4202
|
+
await requireNamedCut(desktop, input.timelineId);
|
|
3724
4203
|
return ok(await desktop.post('/agent/timeline/add-track', input));
|
|
3725
4204
|
},
|
|
3726
4205
|
};
|
|
@@ -3758,7 +4237,7 @@ export const updateTimelineSettings = {
|
|
|
3758
4237
|
id: 'slates_update_timeline_settings',
|
|
3759
4238
|
description: "Update the project timeline's output settings: resolution, frame rate (24/30/60 — all clips are conformed to it on export), and masterVolume, the output fader (linear gain, -∞ to +12 dB) applied to the final mix in both preview and MP4 export (use it to prevent clipping when stacking loud tracks). Note these are normally auto-managed: the first video clip sets fps + resolution, and higher-res clips raise the canvas. Changing frameRate after clips are placed retimes them — avoid unless the timeline is empty.",
|
|
3760
4239
|
input: z.object({
|
|
3761
|
-
projectId: z.string().uuid(),
|
|
4240
|
+
projectId: z.string().uuid(), timelineId: z.string().uuid().optional(),
|
|
3762
4241
|
width: z.number().int().min(16).optional(),
|
|
3763
4242
|
height: z.number().int().min(16).optional(),
|
|
3764
4243
|
frameRate: z.union([z.literal(24), z.literal(30), z.literal(60)]).optional(),
|
|
@@ -3767,6 +4246,7 @@ export const updateTimelineSettings = {
|
|
|
3767
4246
|
async run(input, ctx) {
|
|
3768
4247
|
const desktop = ctx.desktop();
|
|
3769
4248
|
await desktop.requireCapability('timeline-tracks', 'timeline tracks + audio mixing');
|
|
4249
|
+
await requireNamedCut(desktop, input.timelineId);
|
|
3770
4250
|
return ok(await desktop.post('/agent/timeline/update-settings', input));
|
|
3771
4251
|
},
|
|
3772
4252
|
};
|
|
@@ -3804,7 +4284,7 @@ export const exportVideo = {
|
|
|
3804
4284
|
};
|
|
3805
4285
|
export const exportTimelineXml = {
|
|
3806
4286
|
id: 'slates_export_timeline_xml',
|
|
3807
|
-
description: "Export the project's timeline as FCP7/XMEML XML — the file DaVinci Resolve imports directly (File → Import → Timeline) to recreate the edit with references to the original clip media on disk. This is the '
|
|
4287
|
+
description: "Export the project's timeline as FCP7/XMEML XML — the file DaVinci Resolve imports directly (File → Import → Timeline) to recreate the edit with references to the original clip media on disk. This is the 'Export for DaVinci, Premiere or Final Cut' path. Pass an absolute outputPath ending in .xml; fails if the file exists unless overwrite=true.",
|
|
3808
4288
|
input: z
|
|
3809
4289
|
.object({
|
|
3810
4290
|
projectId: z.string().uuid().optional(),
|
|
@@ -3859,7 +4339,7 @@ export const updateProject = {
|
|
|
3859
4339
|
};
|
|
3860
4340
|
export const deleteProject = {
|
|
3861
4341
|
id: 'slates_delete_project',
|
|
3862
|
-
description: 'Delete a Slates project. DESTRUCTIVE — permanently removes the project with all its assets,
|
|
4342
|
+
description: 'Delete a Slates project. DESTRUCTIVE — permanently removes the project with all its assets, boards, and media files. Requires confirm=true after explicit user OK.',
|
|
3863
4343
|
input: z.object({
|
|
3864
4344
|
id: z.string().uuid(),
|
|
3865
4345
|
confirm: z.boolean().optional().describe('Set true only after the user explicitly OKs the deletion.'),
|
|
@@ -3868,7 +4348,7 @@ export const deleteProject = {
|
|
|
3868
4348
|
if (!input.confirm) {
|
|
3869
4349
|
return ok({
|
|
3870
4350
|
requires_confirm: true,
|
|
3871
|
-
message: 'Deleting a project permanently removes all its assets,
|
|
4351
|
+
message: 'Deleting a project permanently removes all its assets, boards, and media files. Re-call with confirm=true after explicit user OK.',
|
|
3872
4352
|
});
|
|
3873
4353
|
}
|
|
3874
4354
|
return ok(await ctx.desktop().post('/agent/projects/delete', { id: input.id }));
|
|
@@ -3882,6 +4362,36 @@ export const getProjectDirectory = {
|
|
|
3882
4362
|
return ok(await ctx.desktop().get('/agent/projects/location', { id: input.id }));
|
|
3883
4363
|
},
|
|
3884
4364
|
};
|
|
4365
|
+
/** What both relocation routes answer: the folder the project now uses, and
|
|
4366
|
+
* the one its files could not all be removed from, when that happened. */
|
|
4367
|
+
function relocated(r, done) {
|
|
4368
|
+
const text = r.leftBehind
|
|
4369
|
+
? `${done} to ${r.directory}. Some files could not be removed from ${r.leftBehind}, so both folders exist and the project uses the new one; tell the user, and delete the old folder once nothing holds it open. There is no undo for this move.`
|
|
4370
|
+
: `${done} to ${r.directory}.`;
|
|
4371
|
+
return ok(r, text);
|
|
4372
|
+
}
|
|
4373
|
+
export const relocateProject = {
|
|
4374
|
+
id: 'slates_relocate_project',
|
|
4375
|
+
description: "Move a project stored in an older folder into the current projects folder (Settings › Storage's move): its files are copied, every path is pointed at the copy, then the old folder is removed. slates_get_project_directory says where it is now. slates_undo_relocate_project puts it back while Slates stays open. Refused while the project is generating.",
|
|
4376
|
+
input: z.object({ id: z.string().uuid() }),
|
|
4377
|
+
async run(input, ctx) {
|
|
4378
|
+
const desktop = ctx.desktop();
|
|
4379
|
+
await desktop.requireCapability('project-relocate', 'moving a project into the current projects folder');
|
|
4380
|
+
const r = await desktop.post('/agent/projects/relocate', { id: input.id });
|
|
4381
|
+
const moved = relocated(r, 'Moved the project');
|
|
4382
|
+
return r.leftBehind ? moved : { ...moved, text: `${moved.text} slates_undo_relocate_project puts it back until Slates quits.` };
|
|
4383
|
+
},
|
|
4384
|
+
};
|
|
4385
|
+
export const undoRelocateProject = {
|
|
4386
|
+
id: 'slates_undo_relocate_project',
|
|
4387
|
+
description: "Put a project moved by slates_relocate_project (or Settings › Storage) back in the folder it came from. Works only for the last move, in the same run of Slates, and only while nothing has moved it since.",
|
|
4388
|
+
input: z.object({ id: z.string().uuid() }),
|
|
4389
|
+
async run(input, ctx) {
|
|
4390
|
+
const desktop = ctx.desktop();
|
|
4391
|
+
await desktop.requireCapability('project-relocate', 'moving a project back');
|
|
4392
|
+
return relocated(await desktop.post('/agent/projects/relocate-undo', { id: input.id }), 'Moved the project back');
|
|
4393
|
+
},
|
|
4394
|
+
};
|
|
3885
4395
|
export const deleteAsset = {
|
|
3886
4396
|
id: 'slates_delete_asset',
|
|
3887
4397
|
description: 'Delete an asset from its project. Permanent — also deletes the media file from disk.',
|
|
@@ -3962,7 +4472,7 @@ export const deleteCharacter = {
|
|
|
3962
4472
|
};
|
|
3963
4473
|
export const updateEnvironment = {
|
|
3964
4474
|
id: 'slates_update_environment',
|
|
3965
|
-
description: 'Update
|
|
4475
|
+
description: 'Update a location\'s name, description, style, or bound reference image (referenceAssetId=null clears it).',
|
|
3966
4476
|
input: z.object({
|
|
3967
4477
|
environmentId: z.string().uuid(),
|
|
3968
4478
|
name: z.string().min(1).max(120).optional(),
|
|
@@ -3984,7 +4494,7 @@ export const updateEnvironment = {
|
|
|
3984
4494
|
};
|
|
3985
4495
|
export const deleteEnvironment = {
|
|
3986
4496
|
id: 'slates_delete_environment',
|
|
3987
|
-
description: 'Delete
|
|
4497
|
+
description: 'Delete a location from its project.',
|
|
3988
4498
|
input: z.object({ environmentId: z.string().uuid() }),
|
|
3989
4499
|
async run(input, ctx) {
|
|
3990
4500
|
return ok(await ctx.desktop().post('/agent/environments/delete', { id: input.environmentId }));
|
|
@@ -3992,7 +4502,7 @@ export const deleteEnvironment = {
|
|
|
3992
4502
|
};
|
|
3993
4503
|
export const listStyles = {
|
|
3994
4504
|
id: 'slates_list_styles',
|
|
3995
|
-
description: 'List
|
|
4505
|
+
description: 'List the looks (styles) in a Slates project.',
|
|
3996
4506
|
input: z.object({ projectId: z.string().uuid() }),
|
|
3997
4507
|
async run(input, ctx) {
|
|
3998
4508
|
return ok(await ctx.desktop().get('/agent/styles', { projectId: input.projectId }));
|
|
@@ -4000,7 +4510,7 @@ export const listStyles = {
|
|
|
4000
4510
|
};
|
|
4001
4511
|
export const createStyle = {
|
|
4002
4512
|
id: 'slates_create_style',
|
|
4003
|
-
description: 'Create a new
|
|
4513
|
+
description: 'Create a new look (style) in a Slates project.',
|
|
4004
4514
|
input: z.object({
|
|
4005
4515
|
projectId: z.string().uuid(),
|
|
4006
4516
|
name: z.string().min(1).max(120),
|
|
@@ -4012,7 +4522,7 @@ export const createStyle = {
|
|
|
4012
4522
|
};
|
|
4013
4523
|
export const updateStyle = {
|
|
4014
4524
|
id: 'slates_update_style',
|
|
4015
|
-
description: 'Update a
|
|
4525
|
+
description: 'Update a look\'s name, description, or bound reference image (imageAssetId=null clears it).',
|
|
4016
4526
|
input: z.object({
|
|
4017
4527
|
styleId: z.string().uuid(),
|
|
4018
4528
|
name: z.string().min(1).max(120).optional(),
|
|
@@ -4028,15 +4538,199 @@ export const updateStyle = {
|
|
|
4028
4538
|
};
|
|
4029
4539
|
export const deleteStyle = {
|
|
4030
4540
|
id: 'slates_delete_style',
|
|
4031
|
-
description: 'Delete a
|
|
4541
|
+
description: 'Delete a look from its project.',
|
|
4032
4542
|
input: z.object({ styleId: z.string().uuid() }),
|
|
4033
4543
|
async run(input, ctx) {
|
|
4034
4544
|
return ok(await ctx.desktop().post('/agent/styles/delete', { id: input.styleId }));
|
|
4035
4545
|
},
|
|
4036
4546
|
};
|
|
4547
|
+
// ── The Library ─────────────────────────────────────────────────
|
|
4548
|
+
// One saved reference in a category the USER names (characters, locations,
|
|
4549
|
+
// products, looks…). The character / environment / style ops above read and
|
|
4550
|
+
// write the same rows and stay for published clients; new work uses these.
|
|
4551
|
+
export const listLibrary = {
|
|
4552
|
+
id: 'slates_list_library',
|
|
4553
|
+
description: "List a project's Library: its categories (user-named; each is kind 'thing' = used as @name in prompts, or 'look' = used as #name) and every saved reference in them. Each item carries `mention` — exactly what to type in a prompt to attach it (a bare name is prose and attaches nothing) — plus its image, source and voice asset ids.",
|
|
4554
|
+
input: z.object({ projectId: z.string().uuid() }),
|
|
4555
|
+
async run(input, ctx) {
|
|
4556
|
+
await ctx.desktop().requireCapability('library', 'the Library');
|
|
4557
|
+
return ok(await ctx.desktop().get('/agent/library', { projectId: input.projectId }));
|
|
4558
|
+
},
|
|
4559
|
+
};
|
|
4560
|
+
export const createLibraryItem = {
|
|
4561
|
+
id: 'slates_create_library_item',
|
|
4562
|
+
description: 'Save a reference to the Library in one of the project\'s categories (ids from slates_list_library) — a person, a location, a product, a prop, a look. Names share ONE namespace per sigil: a name whose handle is already taken comes back suffixed ("Candle 2"), so read `mention` from the result instead of assuming it. Pass imageAssetId (UUID or badge code) to bind its picture in the same call.',
|
|
4563
|
+
input: z.object({
|
|
4564
|
+
projectId: z.string().uuid(),
|
|
4565
|
+
categoryId: z.string().uuid(),
|
|
4566
|
+
name: z.string().min(1).max(120),
|
|
4567
|
+
description: z.string().optional(),
|
|
4568
|
+
style: z.string().max(200).optional().describe('Optional free-text style instruction the sheet tools read.'),
|
|
4569
|
+
imageAssetId: z.string().min(1).optional().describe('The one image a mention attaches. UUID or badge code ("IMG-A8").'),
|
|
4570
|
+
}),
|
|
4571
|
+
async run(input, ctx) {
|
|
4572
|
+
await ctx.desktop().requireCapability('library', 'the Library');
|
|
4573
|
+
const { imageAssetId, ...body } = input;
|
|
4574
|
+
// Resolved BEFORE the create, so an unknown code fails without leaving an
|
|
4575
|
+
// item behind.
|
|
4576
|
+
const resolved = imageAssetId ? await resolveAssetRefs(ctx, input.projectId, [imageAssetId]) : null;
|
|
4577
|
+
const created = await ctx.desktop().post('/agent/library/items', body);
|
|
4578
|
+
if (!imageAssetId || !resolved)
|
|
4579
|
+
return ok(created);
|
|
4580
|
+
return ok(await ctx.desktop().post('/agent/library/items/update', {
|
|
4581
|
+
id: created.item.id,
|
|
4582
|
+
data: { imageAssetId: resolved.get(imageAssetId).id },
|
|
4583
|
+
}));
|
|
4584
|
+
},
|
|
4585
|
+
};
|
|
4586
|
+
export const updateLibraryItem = {
|
|
4587
|
+
id: 'slates_update_library_item',
|
|
4588
|
+
description: "Update a Library item: rename it, move it to another category (categoryId), bind its picture (imageAssetId) or its voice (voiceAssetId, an audio asset; a thing in a location category composes as an environment and carries none). null clears an asset; the asset itself stays. Moving between a 'thing' and a 'look' category flips the sigil (@ ↔ #), and moving a thing into or out of a 'location'-template category changes what it composes as. A rename changes its handle. In every case each saved Shot that mentions the item is rewritten to match (the mention in its prompt, and its mention id), and the result's `move` reports `shotsMentioning`, `shotsRetagged` and the old and new `mention`: tell the user what changed. Pass retagShots:false to leave Shots alone; they then show the item as missing until it is mentioned again. Moving it back, or renaming it back, undoes the rewrite.",
|
|
4589
|
+
input: z.object({
|
|
4590
|
+
projectId: z.string().uuid().describe('The project the item is in — asset codes resolve against it.'),
|
|
4591
|
+
itemId: z.string().uuid(),
|
|
4592
|
+
name: z.string().min(1).max(120).optional(),
|
|
4593
|
+
description: z.string().optional(),
|
|
4594
|
+
style: z.string().max(200).optional(),
|
|
4595
|
+
categoryId: z.string().uuid().optional(),
|
|
4596
|
+
retagShots: z.boolean().optional().describe('Only with categoryId or name. Default true: Shots that mention the item follow a move or a rename that changes how it is mentioned.'),
|
|
4597
|
+
imageAssetId: z.string().min(1).nullable().optional().describe('UUID or badge code; null clears.'),
|
|
4598
|
+
voiceAssetId: z.string().min(1).nullable().optional().describe('An AUDIO asset, UUID or badge code; null detaches.'),
|
|
4599
|
+
}),
|
|
4600
|
+
async run(input, ctx) {
|
|
4601
|
+
await ctx.desktop().requireCapability('library', 'the Library');
|
|
4602
|
+
const refs = [input.imageAssetId, input.voiceAssetId].filter((r) => typeof r === 'string');
|
|
4603
|
+
const resolved = await resolveAssetRefs(ctx, input.projectId, refs);
|
|
4604
|
+
const asset = (ref) => typeof ref === 'string' ? resolved.get(ref).id : ref;
|
|
4605
|
+
return ok(await ctx.desktop().post('/agent/library/items/update', {
|
|
4606
|
+
id: input.itemId,
|
|
4607
|
+
retagShots: input.retagShots,
|
|
4608
|
+
data: {
|
|
4609
|
+
name: input.name,
|
|
4610
|
+
description: input.description,
|
|
4611
|
+
style: input.style,
|
|
4612
|
+
categoryId: input.categoryId,
|
|
4613
|
+
// Sent only when given: the route treats presence as intent, and an
|
|
4614
|
+
// explicit null is the clear.
|
|
4615
|
+
...(input.imageAssetId !== undefined ? { imageAssetId: asset(input.imageAssetId) } : {}),
|
|
4616
|
+
...(input.voiceAssetId !== undefined ? { voiceAssetId: asset(input.voiceAssetId) } : {}),
|
|
4617
|
+
},
|
|
4618
|
+
}));
|
|
4619
|
+
},
|
|
4620
|
+
};
|
|
4621
|
+
export const deleteLibraryItem = {
|
|
4622
|
+
id: 'slates_delete_library_item',
|
|
4623
|
+
description: 'Delete a Library item. Its images and voice clip stay in the project as ordinary assets.',
|
|
4624
|
+
input: z.object({ itemId: z.string().uuid() }),
|
|
4625
|
+
async run(input, ctx) {
|
|
4626
|
+
await ctx.desktop().requireCapability('library', 'the Library');
|
|
4627
|
+
return ok(await ctx.desktop().post('/agent/library/items/delete', { id: input.itemId }));
|
|
4628
|
+
},
|
|
4629
|
+
};
|
|
4630
|
+
export const manageLibraryCategory = {
|
|
4631
|
+
id: 'slates_manage_library_category',
|
|
4632
|
+
description: "Create, rename, change what it is (set-behaviour), reorder or delete a Library category. Categories are the USER's labels (\"Products\", \"Mascots\"); never invent ones they did not ask for. create: projectId + name + kind ('thing' = used as @name in prompts, 'look' = used as #name), optional template ('person' | 'location'; the app asks \"Is this a subject, a place, or a look?\" — a 'location' category's things compose as environments and carry no voice; every other thing composes as a character). set-behaviour: categoryId + kind + template (null for no template); retagShots defaults to true and rewrites affected shot mentions to match. The result reports affected items and rewritten shots. rename: categoryId + name. reorder: projectId + orderedIds (every category id, in the new order). delete: categoryId — refused while the category holds items.",
|
|
4633
|
+
input: z.object({
|
|
4634
|
+
action: z.enum(['create', 'rename', 'set-behaviour', 'reorder', 'delete']),
|
|
4635
|
+
projectId: z.string().uuid().optional(),
|
|
4636
|
+
categoryId: z.string().uuid().optional(),
|
|
4637
|
+
name: z.string().min(1).max(80).optional(),
|
|
4638
|
+
kind: z.enum(['thing', 'look']).optional(),
|
|
4639
|
+
template: z.enum(['person', 'location']).nullable().optional(),
|
|
4640
|
+
retagShots: z.boolean().optional(),
|
|
4641
|
+
orderedIds: z.array(z.string().uuid()).optional(),
|
|
4642
|
+
}),
|
|
4643
|
+
async run(input, ctx) {
|
|
4644
|
+
await ctx.desktop().requireCapability('library', 'the Library');
|
|
4645
|
+
switch (input.action) {
|
|
4646
|
+
case 'create':
|
|
4647
|
+
return ok(await ctx.desktop().post('/agent/library/categories', { projectId: input.projectId, name: input.name, kind: input.kind, template: input.template }));
|
|
4648
|
+
case 'set-behaviour':
|
|
4649
|
+
if (!input.categoryId || !input.kind || input.template === undefined)
|
|
4650
|
+
throw new Error('set-behaviour requires categoryId, kind and template (null for none)');
|
|
4651
|
+
return ok(await ctx.desktop().post('/agent/library/categories/update', { id: input.categoryId, kind: input.kind, template: input.template, retagShots: input.retagShots }));
|
|
4652
|
+
case 'reorder':
|
|
4653
|
+
return ok(await ctx.desktop().post('/agent/library/categories/update', { projectId: input.projectId, orderedIds: input.orderedIds }));
|
|
4654
|
+
case 'delete':
|
|
4655
|
+
return ok(await ctx.desktop().post('/agent/library/categories/delete', { id: input.categoryId }));
|
|
4656
|
+
default:
|
|
4657
|
+
return ok(await ctx.desktop().post('/agent/library/categories/update', { id: input.categoryId, name: input.name }));
|
|
4658
|
+
}
|
|
4659
|
+
},
|
|
4660
|
+
};
|
|
4661
|
+
export const copyLibraryItemToProject = {
|
|
4662
|
+
id: 'slates_copy_library_item_to_project',
|
|
4663
|
+
description: "Copy a Library item into another project: the item AND duplicates of its picture, source and voice, filed in the target's category of the same name (made when missing). The original is untouched. Use this to reuse a character or product across projects; use slates_move_entity_to_project to take it out of this one instead.",
|
|
4664
|
+
input: z.object({ itemId: z.string().uuid(), targetProjectId: z.string().uuid() }),
|
|
4665
|
+
async run(input, ctx) {
|
|
4666
|
+
await ctx.desktop().requireCapability('library', 'the Library');
|
|
4667
|
+
return ok(await ctx.desktop().post('/agent/library/items/copy-to-project', { id: input.itemId, targetProjectId: input.targetProjectId }));
|
|
4668
|
+
},
|
|
4669
|
+
};
|
|
4670
|
+
// ── Templates ───────────────────────────────────────────────────────────
|
|
4671
|
+
// A `.slatestemplate` file is a portable storyboard, scene or single Shot: the
|
|
4672
|
+
// recipes, their script words, their references and the Library items they
|
|
4673
|
+
// mention. It holds no takes, and importing one generates and spends nothing.
|
|
4674
|
+
export const getTemplate = {
|
|
4675
|
+
id: 'slates_get_template',
|
|
4676
|
+
description: "Read a Slates template file without changing anything. With `path`: what it holds (scenes, shots, library items, the models its shots are set to) and its SWAP SLOTS — every Library item (`item:i1`, labelled with its mention, e.g. @candle) and every directly attached reference (`asset:a3`, with its roles) that slates_import_template can point at one of the project's own assets instead. Without `path`: the saved templates on this machine (the app's starter set and the user's own folder).",
|
|
4677
|
+
input: z.object({ path: z.string().min(1).optional().describe('Absolute path of a .slatestemplate file. Omit to list saved templates.') }),
|
|
4678
|
+
async run(input, ctx) {
|
|
4679
|
+
await ctx.desktop().requireCapability('templates', 'templates');
|
|
4680
|
+
if (!input.path)
|
|
4681
|
+
return ok(await ctx.desktop().get('/agent/templates'));
|
|
4682
|
+
return ok(await ctx.desktop().get('/agent/templates/inspect', { path: input.path }));
|
|
4683
|
+
},
|
|
4684
|
+
};
|
|
4685
|
+
export const exportTemplate = {
|
|
4686
|
+
id: 'slates_export_template',
|
|
4687
|
+
description: "Save a board, a scene or one Shot as a template file someone else can start from. Give exactly one of storyboardId, sceneId or shotId. The file carries each Shot's full recipe (prompt, model, settings, reference roles, script words) plus the reference files and the Library items the Shots mention; it carries no takes. The project is untouched. Returns the path, the counts, and a warning for any reference whose file is missing.",
|
|
4688
|
+
input: z.object({
|
|
4689
|
+
projectId: z.string().uuid(),
|
|
4690
|
+
path: z.string().min(1).describe('Absolute path to write, ending in .slatestemplate. Its folder is created if needed.'),
|
|
4691
|
+
storyboardId: z.string().uuid().optional(),
|
|
4692
|
+
sceneId: z.string().uuid().optional(),
|
|
4693
|
+
shotId: z.string().min(1).optional().describe('A Shot id or code (SHOT-A3).'),
|
|
4694
|
+
name: z.string().optional().describe("The template's name. Defaults to the board's, scene's or Shot's."),
|
|
4695
|
+
description: z.string().optional(),
|
|
4696
|
+
}),
|
|
4697
|
+
async run(input, ctx) {
|
|
4698
|
+
await ctx.desktop().requireCapability('templates', 'templates');
|
|
4699
|
+
const named = [input.storyboardId, input.sceneId, input.shotId].filter(Boolean).length;
|
|
4700
|
+
if (named !== 1)
|
|
4701
|
+
throw new Error('Give exactly one of storyboardId, sceneId or shotId.');
|
|
4702
|
+
return ok(await ctx.desktop().post('/agent/templates/export', input));
|
|
4703
|
+
},
|
|
4704
|
+
};
|
|
4705
|
+
export const importTemplate = {
|
|
4706
|
+
id: 'slates_import_template',
|
|
4707
|
+
description: "Add a template to a project. A board template becomes a new board; a scene template becomes a new scene of `storyboardId` (default: the most recently edited board); a single-Shot template is filed like any new Shot (into `sceneId` when given). Its reference files become new assets of this project and its Library items are created here (a taken name is suffixed and the imported prompts are rewritten to match; see `renamed`). `swaps` maps a slot key from slates_get_template to an asset of THIS project (id or code) of the slot's type: the item takes that picture, or every Shot that attached the template's reference attaches it instead, and the template's own file for that slot is not imported. Generates nothing and spends nothing: price and fire the returned shotIds with slates_generate_from_shots, after the user approves the prices.",
|
|
4708
|
+
input: z.object({
|
|
4709
|
+
projectId: z.string().uuid(),
|
|
4710
|
+
path: z.string().min(1).describe('Absolute path of the .slatestemplate file.'),
|
|
4711
|
+
sceneIndices: z.array(z.number().int().min(0)).min(1).optional().describe('Import only these scene indices from the inspected parts, preserving their order. Omit for the whole template.'),
|
|
4712
|
+
storyboardId: z.string().uuid().optional(),
|
|
4713
|
+
sceneId: z.string().uuid().optional(),
|
|
4714
|
+
swaps: z.record(z.string().min(1)).optional().describe('{ "item:i1": "IMG-A12", "asset:a3": "<asset id>" }'),
|
|
4715
|
+
useTemplateCategories: z
|
|
4716
|
+
.boolean()
|
|
4717
|
+
.optional()
|
|
4718
|
+
.describe("Remove the project's untouched default Library categories the template does not use. Only acts on a project whose Library is empty; defaults to true on a project with no board."),
|
|
4719
|
+
}),
|
|
4720
|
+
async run(input, ctx) {
|
|
4721
|
+
await ctx.desktop().requireCapability('templates', 'templates');
|
|
4722
|
+
const swaps = {};
|
|
4723
|
+
if (input.swaps) {
|
|
4724
|
+
const resolved = await resolveAssetRefs(ctx, input.projectId, Object.values(input.swaps));
|
|
4725
|
+
for (const [slot, ref] of Object.entries(input.swaps))
|
|
4726
|
+
swaps[slot] = resolved.get(ref)?.id ?? ref;
|
|
4727
|
+
}
|
|
4728
|
+
return ok(await ctx.desktop().post('/agent/templates/import', { ...input, swaps }));
|
|
4729
|
+
},
|
|
4730
|
+
};
|
|
4037
4731
|
export const updateStoryboard = {
|
|
4038
4732
|
id: 'slates_update_storyboard',
|
|
4039
|
-
description: 'Update a
|
|
4733
|
+
description: 'Update a board\'s name and/or description.',
|
|
4040
4734
|
input: z.object({
|
|
4041
4735
|
storyboardId: z.string().uuid(),
|
|
4042
4736
|
name: z.string().min(1).max(120).optional(),
|
|
@@ -4051,7 +4745,7 @@ export const updateStoryboard = {
|
|
|
4051
4745
|
};
|
|
4052
4746
|
export const deleteStoryboard = {
|
|
4053
4747
|
id: 'slates_delete_storyboard',
|
|
4054
|
-
description: 'Delete a
|
|
4748
|
+
description: 'Delete a board with all its scenes and frames (the referenced assets are untouched).',
|
|
4055
4749
|
input: z.object({ storyboardId: z.string().uuid() }),
|
|
4056
4750
|
async run(input, ctx) {
|
|
4057
4751
|
return ok(await ctx.desktop().post('/agent/storyboards/delete', { id: input.storyboardId }));
|
|
@@ -4059,7 +4753,7 @@ export const deleteStoryboard = {
|
|
|
4059
4753
|
};
|
|
4060
4754
|
export const updateScene = {
|
|
4061
4755
|
id: 'slates_update_scene',
|
|
4062
|
-
description: 'Update a scene\'s name and/or position within its
|
|
4756
|
+
description: 'Update a scene\'s name and/or position within its board.',
|
|
4063
4757
|
input: z.object({
|
|
4064
4758
|
sceneId: z.string().uuid(),
|
|
4065
4759
|
name: z.string().min(1).max(120).optional(),
|
|
@@ -4074,7 +4768,7 @@ export const updateScene = {
|
|
|
4074
4768
|
};
|
|
4075
4769
|
export const deleteScene = {
|
|
4076
4770
|
id: 'slates_delete_scene',
|
|
4077
|
-
description: 'Delete a scene (and its frames) from a
|
|
4771
|
+
description: 'Delete a scene (and its frames) from a board.',
|
|
4078
4772
|
input: z.object({ sceneId: z.string().uuid() }),
|
|
4079
4773
|
async run(input, ctx) {
|
|
4080
4774
|
return ok(await ctx.desktop().post('/agent/scenes/delete', { id: input.sceneId }));
|
|
@@ -4082,7 +4776,7 @@ export const deleteScene = {
|
|
|
4082
4776
|
};
|
|
4083
4777
|
export const reorderScenes = {
|
|
4084
4778
|
id: 'slates_reorder_scenes',
|
|
4085
|
-
description: 'Reorder the scenes of a
|
|
4779
|
+
description: 'Reorder the scenes of a board. Pass the COMPLETE list of the board\'s scene ids in the desired order.',
|
|
4086
4780
|
input: z.object({
|
|
4087
4781
|
storyboardId: z.string().uuid(),
|
|
4088
4782
|
sceneIds: z.array(z.string().uuid()).min(1),
|
|
@@ -4098,13 +4792,13 @@ export const updateFrame = {
|
|
|
4098
4792
|
// of what the Shot in this slot already encodes — the image's role and the
|
|
4099
4793
|
// beat's words — and they were backfilled into Shots on 2026-08-31. Use
|
|
4100
4794
|
// slates_update_shot for either.
|
|
4101
|
-
'Update a slot: its shot label, notes, bound asset (assetId=null unbinds), scene or position. The BEAT — its line, references, model, prompt and framing — lives on the Shot in this slot; use slates_update_shot for that.',
|
|
4795
|
+
'Update a slot: its shot label, notes, bound asset (assetId=null unbinds), scene or position. A scene or position change carries the words in this slot with it on the Script page. The BEAT — its line, references, model, prompt and framing — lives on the Shot in this slot; use slates_update_shot for that.',
|
|
4102
4796
|
input: z.object({
|
|
4103
4797
|
frameId: z.string().uuid(),
|
|
4104
4798
|
shotLabel: z.string().optional(),
|
|
4105
4799
|
notes: z.string().optional(),
|
|
4106
4800
|
assetId: z.string().uuid().nullable().optional(),
|
|
4107
|
-
sceneId: z.string().uuid().
|
|
4801
|
+
sceneId: z.string().uuid().optional(),
|
|
4108
4802
|
position: z.number().int().min(0).optional(),
|
|
4109
4803
|
}),
|
|
4110
4804
|
async run(input, ctx) {
|
|
@@ -4135,7 +4829,7 @@ export const updateFrame = {
|
|
|
4135
4829
|
*/
|
|
4136
4830
|
export const batchUpdateFrames = {
|
|
4137
4831
|
id: 'slates_batch_update_frames',
|
|
4138
|
-
description: 'Update MANY slots in one call — shot labels, notes, asset binding, scene/position. Prefer this over repeated slates_update_frame when re-arranging a scene: it is one round-trip and one UI refresh, and every id is validated before anything is written, so the batch never lands half-applied. To write the BEATS themselves, use slates_create_shot / slates_update_shot.',
|
|
4832
|
+
description: 'Update MANY slots in one call — shot labels, notes, asset binding, scene/position. Prefer this over repeated slates_update_frame when re-arranging a scene: it is one round-trip and one UI refresh, and every id is validated before anything is written, so the batch never lands half-applied. A scene or position change carries the words in each slot with it on the Script page. To write the BEATS themselves, use slates_create_shot / slates_update_shot.',
|
|
4139
4833
|
input: z.object({
|
|
4140
4834
|
updates: z
|
|
4141
4835
|
.array(z.object({
|
|
@@ -4143,7 +4837,7 @@ export const batchUpdateFrames = {
|
|
|
4143
4837
|
shotLabel: z.string().optional(),
|
|
4144
4838
|
notes: z.string().optional(),
|
|
4145
4839
|
assetId: z.string().uuid().nullable().optional(),
|
|
4146
|
-
sceneId: z.string().uuid().
|
|
4840
|
+
sceneId: z.string().uuid().optional(),
|
|
4147
4841
|
position: z.number().int().min(0).optional(),
|
|
4148
4842
|
}))
|
|
4149
4843
|
.min(1),
|
|
@@ -4231,6 +4925,10 @@ const SHOT_ASPECT_RATIOS = [...new Set([...VIDEO_ASPECT_RATIOS, ...IMAGE_ASPECT_
|
|
|
4231
4925
|
// The vocabulary is still ENFORCED (a Zod enum built from MODEL_CAPABILITIES),
|
|
4232
4926
|
// and the per-model narrowing is enforced by `assertShotCapabilities` at save
|
|
4233
4927
|
// time — which is stronger than prose, not weaker.
|
|
4928
|
+
//
|
|
4929
|
+
// 🚨 EVERY `ShotParams` FIELD, OR A COMPILE ERROR. A plain `z.object` strips
|
|
4930
|
+
// what it does not declare, so a field missing here was dropped on save while
|
|
4931
|
+
// the op reported success; `satisfies` makes the next new param fail to build.
|
|
4234
4932
|
function shotParamsShape(described) {
|
|
4235
4933
|
const d = (node, text) => (described ? node.describe(text) : node);
|
|
4236
4934
|
return {
|
|
@@ -4251,6 +4949,22 @@ function shotParamsShape(described) {
|
|
|
4251
4949
|
voiceId: d(z.string().optional(), `${TTS_MODEL}: preset voiceId (slates_list_voices).`),
|
|
4252
4950
|
voiceReferenceAssetId: d(z.string().optional(), `${TTS_MODEL}: audio asset to clone.`),
|
|
4253
4951
|
voiceDescription: d(z.string().optional(), `${TTS_MODEL}: the voice in words.`),
|
|
4952
|
+
quality: z.string().optional(),
|
|
4953
|
+
gridMode: d(z.enum(['off', '2x2', '3x3']).optional(), 'Image models: a grid of takes in one picture.'),
|
|
4954
|
+
audioLanguage: d(z.enum(['en', 'zh', 'ja', 'ko', 'es']).optional(), 'Kling with sound: dialogue language.'),
|
|
4955
|
+
audioAccent: d(z.enum(['american', 'british', 'indian']).optional(), 'Kling with sound: English accent.'),
|
|
4956
|
+
generateMusic: d(z.boolean().optional(), 'Kling Omni: background music.'),
|
|
4957
|
+
multiShot: d(z.boolean().optional(), 'Several cuts in one take (multiShotSegments).'),
|
|
4958
|
+
multiShotSegments: z
|
|
4959
|
+
.array(z.object({ prompt: z.string(), duration: z.number(), camera: z.string(), shotSize: z.string() }))
|
|
4960
|
+
.nullable()
|
|
4961
|
+
.optional(),
|
|
4962
|
+
cameraControls: z
|
|
4963
|
+
.object({ horizontal: z.number(), vertical: z.number(), pan: z.number(), tilt: z.number(), roll: z.number(), zoom: z.number() })
|
|
4964
|
+
.optional(),
|
|
4965
|
+
audioLoop: d(z.boolean().optional(), 'eleven-sfx: seamless loop.'),
|
|
4966
|
+
audioPromptInfluence: d(z.number().min(0).max(1).optional(), 'eleven-sfx: how literally the prompt is followed.'),
|
|
4967
|
+
audioMultilingual: d(z.boolean().optional(), 'seed-audio: mixed-language casting.'),
|
|
4254
4968
|
};
|
|
4255
4969
|
}
|
|
4256
4970
|
const shotParamsSchema = z.object(shotParamsShape(true)).optional();
|
|
@@ -4278,6 +4992,8 @@ function shotScriptShape(described) {
|
|
|
4278
4992
|
]));
|
|
4279
4993
|
return {
|
|
4280
4994
|
...text,
|
|
4995
|
+
recipeMode: z.enum(['script', 'custom']).optional().describe('Script mode compiles active words with production fields. Custom keeps the authored prompt exactly.'),
|
|
4996
|
+
promptScriptLine: z.string().nullable().optional().describe('The exact script line reviewed when authoring this custom prompt.'),
|
|
4281
4997
|
continues: described
|
|
4282
4998
|
? z.boolean().optional().describe(SCRIPT_FIELD_DESCRIPTION.continues)
|
|
4283
4999
|
: z.boolean().optional(),
|
|
@@ -4299,6 +5015,10 @@ const FRAMING_NOTE = `shotSize and camera are FREE TEXT and are never rejected o
|
|
|
4299
5015
|
function shotScriptPatch(input) {
|
|
4300
5016
|
const out = {};
|
|
4301
5017
|
const raw = input;
|
|
5018
|
+
if (input.recipeMode === 'script' || input.recipeMode === 'custom')
|
|
5019
|
+
out.recipeMode = input.recipeMode;
|
|
5020
|
+
if (typeof input.promptScriptLine === 'string' || input.promptScriptLine === null)
|
|
5021
|
+
out.promptScriptLine = input.promptScriptLine;
|
|
4302
5022
|
for (const field of SCRIPT_TEXT_FIELDS) {
|
|
4303
5023
|
if (raw[field] !== undefined)
|
|
4304
5024
|
out[field] = raw[field];
|
|
@@ -4444,73 +5164,7 @@ function assertShotCapabilities(model, params) {
|
|
|
4444
5164
|
});
|
|
4445
5165
|
return err ? ok(err) : null;
|
|
4446
5166
|
}
|
|
4447
|
-
/**
|
|
4448
|
-
* The registry cost key for a saved Shot, through the SAME builders every quote
|
|
4449
|
-
* in this file uses (`videoCostKey` / `imageCostKey` / `audioCostKey`).
|
|
4450
|
-
*
|
|
4451
|
-
* Returns null when the Shot cannot be priced — no model, or a model this
|
|
4452
|
-
* surface does not carry. The caller REPORTS that rather than quoting zero: a
|
|
4453
|
-
* missing price displayed as free is the failure mode the whole pricing
|
|
4454
|
-
* contract exists to prevent.
|
|
4455
|
-
*/
|
|
4456
|
-
function shotCostKey(detail) {
|
|
4457
|
-
const model = detail.model;
|
|
4458
|
-
if (!model)
|
|
4459
|
-
return null;
|
|
4460
|
-
const p = detail.params;
|
|
4461
|
-
// Prefer the CLAMPED values the desktop will actually fire with; a listing row
|
|
4462
|
-
// has none, so it falls back to the raw ones and is announced as a floor.
|
|
4463
|
-
const fires = detail.firesWith;
|
|
4464
|
-
if (AUDIO_MODELS.includes(model)) {
|
|
4465
|
-
if (model === TTS_MODEL) {
|
|
4466
|
-
const text = detail.composedPrompt ?? (detail.rawPrompt.trim() || detail.line?.trim() || '');
|
|
4467
|
-
if (!text || text.length > TTS_MAX_CHARACTERS)
|
|
4468
|
-
return null;
|
|
4469
|
-
return audioCostKey({ model, characters: text.length });
|
|
4470
|
-
}
|
|
4471
|
-
const seconds = fires?.audioDurationSeconds ?? p.audioDurationSeconds;
|
|
4472
|
-
if (!seconds)
|
|
4473
|
-
return null;
|
|
4474
|
-
return audioCostKey({ model: model, durationSeconds: seconds });
|
|
4475
|
-
}
|
|
4476
|
-
if (VIDEO_MODELS.includes(model)) {
|
|
4477
|
-
const duration = fires?.duration ?? p.duration;
|
|
4478
|
-
if (!duration)
|
|
4479
|
-
return null;
|
|
4480
|
-
const billed = (d) => model === 'minimax-h3-max' ? Math.max(0, d) : (d > 0 ? Math.ceil(d - 0.05) : 0);
|
|
4481
|
-
return videoCostKey({
|
|
4482
|
-
model: model,
|
|
4483
|
-
duration,
|
|
4484
|
-
videoResolution: fires?.videoResolution ??
|
|
4485
|
-
p.videoResolution ??
|
|
4486
|
-
defaultVideoResolutionFor(model),
|
|
4487
|
-
sound: p.sound,
|
|
4488
|
-
seedanceFace: p.seedanceFace,
|
|
4489
|
-
// `references` is absent on a LISTING row (it does not compose), so both
|
|
4490
|
-
// of these read 0 there. That is why a listing quote is announced as a
|
|
4491
|
-
// floor and `slates_get_shot` is the exact one.
|
|
4492
|
-
referenceImages: (detail.references ?? []).filter((r) => r.kind === 'image').length,
|
|
4493
|
-
audioRefSeconds: (detail.references ?? []).filter((r) => r.kind === 'audio').reduce((n, r) => n + (r.durationSeconds ?? 0), 0),
|
|
4494
|
-
videoRefSeconds: (detail.references ?? [])
|
|
4495
|
-
.filter((r) => r.kind === 'video')
|
|
4496
|
-
.reduce((n, r) => n + billed(r.durationSeconds ?? 0), 0),
|
|
4497
|
-
});
|
|
4498
|
-
}
|
|
4499
|
-
if (IMAGE_MODELS.includes(model)) {
|
|
4500
|
-
return imageCostKey(model, (fires?.imageResolution ?? p.imageResolution) ??
|
|
4501
|
-
(model === 'nano-banana-2-lite' ? '1k' : '2k'),
|
|
4502
|
-
// No inline default — see the note at the estimate op. Firing a Shot with
|
|
4503
|
-
// no stored tier lands on `high` via the desktop's `normalizeGptQuality`,
|
|
4504
|
-
// and `imageCostKey`'s parameter default is pinned to match it.
|
|
4505
|
-
p.gptQuality,
|
|
4506
|
-
// The aspect the Shot will fire at — on GPT Image it moves the key, so
|
|
4507
|
-
// quoting without it under-prices every square Shot. `fires` carries
|
|
4508
|
-
// only the clamped resolution/duration axes, never the aspect.
|
|
4509
|
-
p.aspectRatio);
|
|
4510
|
-
}
|
|
4511
|
-
return null;
|
|
4512
|
-
}
|
|
4513
|
-
/** Dynamic reference keys are priced by the same server calculator that debits them. */
|
|
5167
|
+
/** Fetch a dynamic cloud cost key for the standalone generation estimate. */
|
|
4514
5168
|
async function loadDynamicPrice(ctx, byKey, key) {
|
|
4515
5169
|
if (!key || byKey.has(key))
|
|
4516
5170
|
return;
|
|
@@ -4518,22 +5172,9 @@ async function loadDynamicPrice(ctx, byKey, key) {
|
|
|
4518
5172
|
for (const row of response.models)
|
|
4519
5173
|
byKey.set(row.model, creditCost(row));
|
|
4520
5174
|
}
|
|
4521
|
-
/** Credits for one Shot, and how many generations it fires.
|
|
4522
|
-
*
|
|
4523
|
-
* `imageQuantity` multiplies IMAGE models only — the same condition the
|
|
4524
|
-
* desktop's `estimateCostFor` applies and the only lane `/agent/shots/*` sends
|
|
4525
|
-
* a `count` for. Multiplying it blindly would quote a video Shot 3× for a
|
|
4526
|
-
* param its request never carries, and the card beside it would say ×1. */
|
|
4527
|
-
function shotQuote(detail, byKey) {
|
|
4528
|
-
const key = shotCostKey(detail);
|
|
4529
|
-
const isImage = !!detail.model && IMAGE_MODELS.includes(detail.model);
|
|
4530
|
-
const quantity = isImage ? (detail.params.imageQuantity ?? 1) || 1 : 1;
|
|
4531
|
-
const per = key != null ? byKey.get(key) : undefined;
|
|
4532
|
-
return { key, credits: (per ?? 0) * quantity, quantity };
|
|
4533
|
-
}
|
|
4534
5175
|
export const createShot = {
|
|
4535
5176
|
id: 'slates_create_shot',
|
|
4536
|
-
description: 'Write one beat of the piece — a Shot: its script line, its references with their roles, its model and params, and the prompt that fires. It needs NO image to exist, so a whole film can be written, arranged and priced before anything is generated. It lands in the
|
|
5177
|
+
description: 'Write one beat of the piece — a Shot: its script line, its references with their roles, its model and params, and the prompt that fires. It needs NO image to exist, so a whole film can be written, arranged and priced before anything is generated. It lands in the board automatically (the open scene, else the most recent board) — never unfiled. ' +
|
|
4537
5178
|
FRAMING_NOTE,
|
|
4538
5179
|
input: z.object({
|
|
4539
5180
|
projectId: z.string().uuid(),
|
|
@@ -4548,8 +5189,10 @@ export const createShot = {
|
|
|
4548
5189
|
characterIds: z.array(z.string().uuid()).optional().describe('Characters the prompt @mentions — stored as ENTITY ids, so updating the character updates every Shot that names it.'),
|
|
4549
5190
|
environmentIds: z.array(z.string().uuid()).optional(),
|
|
4550
5191
|
styleIds: z.array(z.string().uuid()).optional(),
|
|
4551
|
-
frameId: z.string().uuid().optional().describe('Put it in this exact frame. Optional — omit it and the Shot files itself into a scene, creating a
|
|
5192
|
+
frameId: z.string().uuid().optional().describe('Put it in this exact frame. Optional — omit it and the Shot files itself into a scene, creating a board if there is none.'),
|
|
4552
5193
|
sceneId: z.string().uuid().optional().describe('File it into this scene. Optional; ignored when frameId is given.'),
|
|
5194
|
+
storyboardId: z.string().uuid().optional().describe('File it into this board (its last scene, or a new one). Optional; ignored when frameId or sceneId is given.'),
|
|
5195
|
+
position: z.number().int().min(0).optional().describe('Slot in the scene it files into, 0 = first. Omit to file it last.'),
|
|
4553
5196
|
...shotScriptSchema,
|
|
4554
5197
|
}),
|
|
4555
5198
|
async run(input, ctx) {
|
|
@@ -4561,6 +5204,9 @@ export const createShot = {
|
|
|
4561
5204
|
return alignErr;
|
|
4562
5205
|
const desktop = ctx.desktop();
|
|
4563
5206
|
await desktop.requireCapability('shots', 'saved Shots');
|
|
5207
|
+
// 1.5.8 ignores `position` and files the Shot last.
|
|
5208
|
+
if (input.position !== undefined)
|
|
5209
|
+
await desktop.requireCapability('shot-position', 'placing a new Shot at a slot');
|
|
4564
5210
|
const { spec, refEcho } = await buildShotSpecInput(ctx, input.projectId, input);
|
|
4565
5211
|
const r = await desktop.post('/agent/shots', {
|
|
4566
5212
|
projectId: input.projectId,
|
|
@@ -4568,6 +5214,8 @@ export const createShot = {
|
|
|
4568
5214
|
spec: { ...spec, ...shotScriptPatch(input) },
|
|
4569
5215
|
frameId: input.frameId ?? null,
|
|
4570
5216
|
sceneId: input.sceneId ?? null,
|
|
5217
|
+
storyboardId: input.storyboardId ?? null,
|
|
5218
|
+
position: input.position,
|
|
4571
5219
|
});
|
|
4572
5220
|
// The CODE is the address the user sees on the row — say it back so the
|
|
4573
5221
|
// next call, and the next sentence to the user, can point at it.
|
|
@@ -4576,7 +5224,7 @@ export const createShot = {
|
|
|
4576
5224
|
};
|
|
4577
5225
|
export const updateShot = {
|
|
4578
5226
|
id: 'slates_update_shot',
|
|
4579
|
-
description: 'Change part of a Shot, or attach/detach it from a
|
|
5227
|
+
description: 'Change part of a Shot, or attach/detach it from a board frame — anything you omit is left exactly as it was. ' +
|
|
4580
5228
|
FRAMING_NOTE,
|
|
4581
5229
|
input: z.object({
|
|
4582
5230
|
shotId: z.string().describe('The Shot id, or its SHOT-A code as shown on the row.'),
|
|
@@ -4592,8 +5240,8 @@ export const updateShot = {
|
|
|
4592
5240
|
characterIds: z.array(z.string().uuid()).optional(),
|
|
4593
5241
|
environmentIds: z.array(z.string().uuid()).optional(),
|
|
4594
5242
|
styleIds: z.array(z.string().uuid()).optional(),
|
|
4595
|
-
attachFrameId: z.string().uuid().optional().describe('Attach this Shot to a
|
|
4596
|
-
detachFrameId: z.string().uuid().optional().describe('Detach it from a frame.
|
|
5243
|
+
attachFrameId: z.string().uuid().optional().describe('Attach this Shot to a board frame.'),
|
|
5244
|
+
detachFrameId: z.string().uuid().optional().describe('Detach it from a frame. Not its last one: pass attachFrameId too, or delete the Shot.'),
|
|
4597
5245
|
posterAssetId: z.string().nullable().optional().describe('Which reference represents this Shot as a thumbnail. Defaulted automatically (first frame, else the first image reference, else the newest take) — only set it to OVERRIDE, and pass null to go back to the default.'),
|
|
4598
5246
|
...shotScriptSchemaTerse,
|
|
4599
5247
|
}),
|
|
@@ -4759,35 +5407,41 @@ function describeVarietyReport(v) {
|
|
|
4759
5407
|
}
|
|
4760
5408
|
export const listShots = {
|
|
4761
5409
|
id: 'slates_list_shots',
|
|
4762
|
-
description: "Read the shot list — every Shot as a compact row IN BOARD ORDER (scene, then position), with the piece's cut count, runtime,
|
|
5410
|
+
description: "Read the shot list — every Shot as a compact row IN BOARD ORDER (scene, then position), with the piece's cut count, runtime, saved-recipe price and its variety distribution. Read this before firing a set: if one shot size is the plurality or three cuts in a row share a camera move, the batch is wrong before a credit is spent.",
|
|
4763
5411
|
input: z.object({
|
|
4764
5412
|
projectId: z.string().uuid(),
|
|
4765
|
-
storyboardId: z.string().uuid().optional().describe('Only Shots attached to a frame in this
|
|
5413
|
+
storyboardId: z.string().uuid().optional().describe('Only Shots attached to a frame in this board.'),
|
|
4766
5414
|
frameId: z.string().uuid().optional().describe('Only Shots attached to this frame.'),
|
|
4767
5415
|
}),
|
|
4768
5416
|
async run(input, ctx) {
|
|
4769
5417
|
const desktop = ctx.desktop();
|
|
4770
5418
|
await desktop.requireCapability('shots', 'saved Shots');
|
|
5419
|
+
// Prices come only from the desktop's quote; a 1.5.8 desktop has none.
|
|
5420
|
+
await desktop.requireCapability('board-quote', 'Shot prices');
|
|
4771
5421
|
const r = await desktop.get('/agent/shots', {
|
|
4772
5422
|
projectId: input.projectId,
|
|
4773
5423
|
storyboardId: input.storyboardId,
|
|
4774
5424
|
frameId: input.frameId,
|
|
4775
5425
|
});
|
|
4776
5426
|
const rows = r.shots ?? [];
|
|
4777
|
-
|
|
4778
|
-
//
|
|
4779
|
-
//
|
|
4780
|
-
|
|
4781
|
-
const
|
|
4782
|
-
|
|
4783
|
-
|
|
5427
|
+
const projectId = input.projectId;
|
|
5428
|
+
// The quote is a GET with every id in its query string, and Node refuses a
|
|
5429
|
+
// request head over 16 KiB (about 345 ids), so a long board is quoted in
|
|
5430
|
+
// chunks. Each Shot's price is its own; only this listing reads them.
|
|
5431
|
+
const QUOTE_CHUNK = 150;
|
|
5432
|
+
const chunks = [];
|
|
5433
|
+
for (let i = 0; i < rows.length; i += QUOTE_CHUNK)
|
|
5434
|
+
chunks.push(rows.slice(i, i + QUOTE_CHUNK).map(r => r.id));
|
|
5435
|
+
const quote = projectId && rows.length
|
|
5436
|
+
? { items: (await Promise.all(chunks.map((shotIds) => desktop.get('/agent/shots/quote', { input: JSON.stringify({ projectId, shotIds }) })))).flatMap((q) => q.items) }
|
|
5437
|
+
: null;
|
|
4784
5438
|
let total = 0;
|
|
4785
5439
|
let unpriced = 0;
|
|
4786
5440
|
const shots = rows.map((s) => {
|
|
4787
|
-
const q =
|
|
4788
|
-
if (q
|
|
5441
|
+
const q = quote?.items.find(i => i.shotId === s.id);
|
|
5442
|
+
if (q?.credits == null)
|
|
4789
5443
|
unpriced += 1;
|
|
4790
|
-
total += q
|
|
5444
|
+
total += q?.credits ?? 0;
|
|
4791
5445
|
return {
|
|
4792
5446
|
id: s.id,
|
|
4793
5447
|
code: s.code,
|
|
@@ -4806,15 +5460,11 @@ export const listShots = {
|
|
|
4806
5460
|
shot_size: s.shotSize,
|
|
4807
5461
|
camera: s.camera,
|
|
4808
5462
|
continues: s.continues,
|
|
4809
|
-
credits: q
|
|
5463
|
+
credits: q?.credits ?? null,
|
|
4810
5464
|
};
|
|
4811
5465
|
});
|
|
4812
5466
|
return ok({ shots, total_credits: total, unpriced, variety: r.variety }, `${shots.length} shot(s), at least ${fmtCredits(total)} to fire them all` +
|
|
4813
5467
|
(unpriced > 0 ? ` (${unpriced} could not be priced — no model or no duration set).` : '.') +
|
|
4814
|
-
' 🚨 That is a FLOOR, not the bill: a listing does not compose, so the two dimensions that' +
|
|
4815
|
-
' depend on the reference set — Seedance reference-clip seconds and MiniMax reference images' +
|
|
4816
|
-
' past the free five — are missing from it. slates_get_shot prices one exactly, and' +
|
|
4817
|
-
' slates_generate_from_shots quotes the set exactly before it fires anything.' +
|
|
4818
5468
|
(describeVarietyReport(r.variety) ? `
|
|
4819
5469
|
|
|
4820
5470
|
${describeVarietyReport(r.variety)}` : ''));
|
|
@@ -4829,14 +5479,14 @@ export const getShot = {
|
|
|
4829
5479
|
async run(input, ctx) {
|
|
4830
5480
|
const desktop = ctx.desktop();
|
|
4831
5481
|
await desktop.requireCapability('shots', 'saved Shots');
|
|
5482
|
+
// Prices come only from the desktop's quote; a 1.5.8 desktop has none.
|
|
5483
|
+
await desktop.requireCapability('board-quote', 'Shot prices');
|
|
4832
5484
|
const r = await desktop.get('/agent/shots/get', { id: input.shotId });
|
|
4833
|
-
const
|
|
4834
|
-
const
|
|
4835
|
-
|
|
4836
|
-
|
|
4837
|
-
|
|
4838
|
-
(q.key && !r.shot.blocked
|
|
4839
|
-
? `${fmtCredits(q.credits)} (${q.key}).`
|
|
5485
|
+
const quote = await desktop.get('/agent/shots/quote', { input: JSON.stringify({ projectId: r.shot.projectId, shotIds: [r.shot.id] }) });
|
|
5486
|
+
const q = quote.items[0];
|
|
5487
|
+
return ok({ ...r.shot, credits: q.credits, quoteFingerprint: quote.fingerprint }, `"${r.shot.name || 'Untitled'}" — ${r.shot.model ?? 'no model set'}, ` +
|
|
5488
|
+
(q.credits != null && !r.shot.blocked
|
|
5489
|
+
? `${fmtCredits(q.credits ?? 0)}.`
|
|
4840
5490
|
: `CANNOT FIRE YET: ${r.shot.blocked ?? 'not priceable — set a model and a duration.'}`) +
|
|
4841
5491
|
(r.shot.blocked ? '' : ` Fires with ${JSON.stringify(r.shot.firesWith)}.`) +
|
|
4842
5492
|
`\nCOMPOSED PROMPT (what the model is told): ${r.shot.composedPrompt}`);
|
|
@@ -4878,6 +5528,43 @@ export const splitShot = {
|
|
|
4878
5528
|
`set it on each if the chop changed how long they run.`);
|
|
4879
5529
|
},
|
|
4880
5530
|
};
|
|
5531
|
+
export const splitTake = {
|
|
5532
|
+
id: 'slates_split_take',
|
|
5533
|
+
description: "A take founds its own Shot. The result (a take under `shotId`) moves to a NEW Shot whose recipe is the take's RECORDED one — the prompt, model, params and references it was actually made from, which may differ from what the source Shot says now. The new Shot lands directly after the source by default; the source keeps its other takes and its recipe untouched, and is never deleted when emptied. Script fields (line, speaker) start empty on the new row.",
|
|
5534
|
+
input: z.object({
|
|
5535
|
+
shotId: z.string().describe('The source Shot id, or its SHOT-A code.'),
|
|
5536
|
+
assetId: z.string().describe('The take to move — an asset id or its IMG-A / VID-A code. Must be a take of `shotId`.'),
|
|
5537
|
+
sceneId: z.string().uuid().optional().describe("Scene for the new Shot. Omitted = the source's scene."),
|
|
5538
|
+
position: z.number().int().min(0).optional().describe('Frame position in that scene. Omitted = directly after the source.'),
|
|
5539
|
+
name: z.string().optional().describe("The new Shot's name. Omitted = the take's label or the first 60 characters of its prompt."),
|
|
5540
|
+
}),
|
|
5541
|
+
async run(input, ctx) {
|
|
5542
|
+
const desktop = ctx.desktop();
|
|
5543
|
+
await desktop.requireCapability('takes', 'moving takes between Shots');
|
|
5544
|
+
const r = await desktop.post('/agent/shots/split-take', input);
|
|
5545
|
+
const unsaved = r.unsavedPaths?.length
|
|
5546
|
+
? ` ${r.unsavedPaths.length} recorded attachment(s) are not project assets and were not carried.`
|
|
5547
|
+
: '';
|
|
5548
|
+
return ok(r, `${r.shot?.code || 'A new Shot'} founded from that take, after ${r.source?.code || 'the source'}, with the take's recorded recipe.${unsaved}`);
|
|
5549
|
+
},
|
|
5550
|
+
};
|
|
5551
|
+
export const refileTake = {
|
|
5552
|
+
id: 'slates_refile_take',
|
|
5553
|
+
description: 'Move a take (a result) from one Shot to another in the same project. Only the attribution moves: neither Shot\'s recipe changes, the asset stays where it is in Media, and a clip that a frame prefers keeps that role. `fromShotId` is a guard — the take moves only if it still hangs under that Shot (omit it to claim an unattributed result). An emptied Shot is kept, never deleted.',
|
|
5554
|
+
input: z.object({
|
|
5555
|
+
assetId: z.string().describe('The take — an asset id or its IMG-A / VID-A code.'),
|
|
5556
|
+
fromShotId: z.string().nullable().optional().describe('The Shot it hangs under now (id or SHOT-A code). Omitted or null = an unattributed result.'),
|
|
5557
|
+
toShotId: z.string().describe('The Shot to move it to (id or SHOT-A code).'),
|
|
5558
|
+
}),
|
|
5559
|
+
async run(input, ctx) {
|
|
5560
|
+
const desktop = ctx.desktop();
|
|
5561
|
+
await desktop.requireCapability('takes', 'moving takes between Shots');
|
|
5562
|
+
const r = await desktop.post('/agent/shots/refile-take', input);
|
|
5563
|
+
return ok(r, r.moved > 0
|
|
5564
|
+
? `Moved to ${r.shot?.code || 'that Shot'}.`
|
|
5565
|
+
: `Nothing moved — that result is not under the Shot you named (or is already under ${r.shot?.code || 'the target'}).`);
|
|
5566
|
+
},
|
|
5567
|
+
};
|
|
4881
5568
|
export const mergeShots = {
|
|
4882
5569
|
id: 'slates_merge_shots',
|
|
4883
5570
|
description: "Merge two adjacent Shots into one. Texts join, references union, the FIRST Shot's model and params win, and the durations SUM — which may exceed the model's window, in which case it is shown and never blocked. Lossy in one direction: the second Shot's model and params are discarded, and there is no undo (the takes are the history).",
|
|
@@ -4896,81 +5583,366 @@ export const mergeShots = {
|
|
|
4896
5583
|
`${r.shot?.cuts ?? 1} cut(s), ${r.shot?.runtimeSeconds ?? 'no'} second(s).`);
|
|
4897
5584
|
},
|
|
4898
5585
|
};
|
|
5586
|
+
// ── The script (overhaul §4.9 "The script IS the shots", P2.4a) ───────
|
|
5587
|
+
// A scene owns ONE continuous text and a Shot with words is a RANGE of it;
|
|
5588
|
+
// the Shot's `line` mirrors the range. The agent edits the same document
|
|
5589
|
+
// through the same operations the Script page uses (rule 8). Offsets are
|
|
5590
|
+
// character positions into the scene's script as `slates_get_script` returns
|
|
5591
|
+
// it; read before you write, because every edit moves what follows it.
|
|
5592
|
+
export const getScript = {
|
|
5593
|
+
id: 'slates_get_script',
|
|
5594
|
+
description: "A scene's script — the ONE text its Shots' lines are ranges of — with every ranged Shot's [start, end) offsets and code. Read this before slates_edit_script or slates_make_shot_from_script: offsets are character positions into exactly this text. Text no Shot holds is unshot; a Shot listed by slates_list_shots but absent here has no words on the page yet. `rev` names this exact text and `revision` the whole script document: pass either to slates_edit_script so an edit measured on it is refused, not misplaced, if the words changed in between (1.6.0 desktops; older ones return neither).",
|
|
5595
|
+
input: z.object({
|
|
5596
|
+
sceneId: z.string().uuid().describe('The scene (slates_get_storyboard_with_frames lists them, each with its script).'),
|
|
5597
|
+
}),
|
|
5598
|
+
async run(input, ctx) {
|
|
5599
|
+
const desktop = ctx.desktop();
|
|
5600
|
+
await desktop.requireCapability('script', 'the Script page');
|
|
5601
|
+
const r = await desktop.get('/agent/script', { sceneId: input.sceneId });
|
|
5602
|
+
return ok(r, `${r.script.length} characters, ${r.shots.length} ranged Shot(s).`);
|
|
5603
|
+
},
|
|
5604
|
+
};
|
|
5605
|
+
const variationSchema = z.object({
|
|
5606
|
+
storyboardId: z.string().uuid(), expectedRevision: z.number().int().nonnegative(), name: z.string().min(1),
|
|
5607
|
+
choices: z.array(z.object({ sectionId: z.string().uuid(), alternativeId: z.string().uuid() })),
|
|
5608
|
+
arrangement: z.array(z.object({ sectionId: z.string().uuid(), alternativeId: z.string().uuid() })).optional(),
|
|
5609
|
+
itemOverrides: z.array(z.object({ from: z.string().uuid(), to: z.string().uuid(), voice: z.enum(['keep', 'replace']) })).optional(),
|
|
5610
|
+
assetOverrides: z.array(z.object({ from: z.string().min(1), to: z.string().min(1) })).optional(),
|
|
5611
|
+
idempotencyKey: z.string().min(1).optional(),
|
|
5612
|
+
});
|
|
5613
|
+
export const previewScriptVariation = {
|
|
5614
|
+
id: 'slates_preview_script_variation', description: 'Preview explicit version choices (one saved version per section) and independent ID-based reference replacements without writes or generation. An optional ordered arrangement supports repetition and omission. Returns composed requests, custom-prompt/reference warnings, a quote, and a plan marking each shot reuse (a finished take with matching inputs exists) or new. Inspect only the combinations you need.',
|
|
5615
|
+
input: variationSchema,
|
|
5616
|
+
async run(input, ctx) {
|
|
5617
|
+
await ctx.desktop().requireCapability('script-documents', 'script documents and variations');
|
|
5618
|
+
return ok(await ctx.desktop().post('/agent/script/variation-preview', await resolveVariationRefs(input, ctx)));
|
|
5619
|
+
},
|
|
5620
|
+
};
|
|
5621
|
+
export const createScriptVariation = {
|
|
5622
|
+
id: 'slates_create_script_variation', description: 'Materialize one reviewed variation as an independent Board with local shot IDs and lineage. Requires idempotencyKey; retry returns the same Board. Returns a quote through the normal request service. No media generation or implicit take reuse.',
|
|
5623
|
+
input: variationSchema,
|
|
5624
|
+
async run(input, ctx) {
|
|
5625
|
+
await ctx.desktop().requireCapability('script-documents', 'script documents and variations');
|
|
5626
|
+
return ok(await ctx.desktop().post('/agent/script/variation-create', await resolveVariationRefs(input, ctx)));
|
|
5627
|
+
},
|
|
5628
|
+
};
|
|
5629
|
+
async function resolveVariationRefs(input, ctx) {
|
|
5630
|
+
if (!input.assetOverrides?.length)
|
|
5631
|
+
return input;
|
|
5632
|
+
const { storyboard } = await ctx.desktop().get('/agent/storyboards/get', { id: input.storyboardId });
|
|
5633
|
+
const assets = await resolveAssetRefs(ctx, storyboard.projectId, input.assetOverrides.flatMap(o => [o.from, o.to]));
|
|
5634
|
+
return { ...input, assetOverrides: input.assetOverrides.map(o => ({ from: assets.get(o.from).id, to: assets.get(o.to).id })) };
|
|
5635
|
+
}
|
|
5636
|
+
export const getScriptUses = {
|
|
5637
|
+
id: 'slates_get_script_uses',
|
|
5638
|
+
description: 'Review each reused passage before updating it: current and proposed words, destination revision, and local-edit conflicts. Pass only explicitly selected non-conflicting uses to slates_update_script_section.',
|
|
5639
|
+
input: z.object({ sectionId: z.string().uuid() }),
|
|
5640
|
+
async run(input, ctx) {
|
|
5641
|
+
await ctx.desktop().requireCapability('script-documents', 'script documents');
|
|
5642
|
+
return ok(await ctx.desktop().get('/agent/script/uses', input));
|
|
5643
|
+
},
|
|
5644
|
+
};
|
|
5645
|
+
export const getShotInputs = {
|
|
5646
|
+
id: 'slates_get_shot_inputs',
|
|
5647
|
+
description: 'Read take input history and compatible reusable media. Earlier and unknown inputs remain playable; they are never treated as a fresh match. No generation.',
|
|
5648
|
+
input: z.object({ projectId: z.string().uuid(), shotId: z.string() }),
|
|
5649
|
+
async run(input, ctx) {
|
|
5650
|
+
await ctx.desktop().requireCapability('script-documents', 'script documents');
|
|
5651
|
+
return ok(await ctx.desktop().get('/agent/shots/inputs', input));
|
|
5652
|
+
},
|
|
5653
|
+
};
|
|
5654
|
+
export const reuseShotTake = {
|
|
5655
|
+
id: 'slates_reuse_shot_take',
|
|
5656
|
+
description: 'Explicitly link a take with matching saved request and recipe inputs to this shot. Keeps the original generation and independent shot recipe. Input changes are rechecked before reuse; costs no credits.',
|
|
5657
|
+
input: z.object({ projectId: z.string().uuid(), shotId: z.string(), assetId: z.string().describe('Asset UUID or badge code from compatibleTakes.') }),
|
|
5658
|
+
async run(input, ctx) {
|
|
5659
|
+
await ctx.desktop().requireCapability('script-documents', 'script documents');
|
|
5660
|
+
const assets = await resolveAssetRefs(ctx, input.projectId, [input.assetId]);
|
|
5661
|
+
return ok(await ctx.desktop().post('/agent/shots/reuse-take', { ...input, assetId: assets.get(input.assetId).id }));
|
|
5662
|
+
},
|
|
5663
|
+
};
|
|
5664
|
+
export const getScriptSections = {
|
|
5665
|
+
id: 'slates_get_script_sections', description: 'Read free-named script sections, anchored fragments, saved versions and local-change state.',
|
|
5666
|
+
input: z.object({ storyboardId: z.string().uuid() }),
|
|
5667
|
+
async run(input, ctx) {
|
|
5668
|
+
await ctx.desktop().requireCapability('script-documents', 'script documents');
|
|
5669
|
+
const data = await ctx.desktop().get('/agent/script/sections', input);
|
|
5670
|
+
return ok(data, 'Script sections read.');
|
|
5671
|
+
},
|
|
5672
|
+
};
|
|
5673
|
+
const sectionInput = z.object({
|
|
5674
|
+
storyboardId: z.string().uuid(), expectedRevision: z.number().int().min(0), action: z.enum(['create', 'alternative', 'choose', 'save', 'reuse', 'updateUses', 'rename', 'archive', 'tags']),
|
|
5675
|
+
sectionId: z.string().uuid().optional(), alternativeId: z.string().uuid().optional(), parentId: z.string().uuid().optional(), label: z.string().optional(),
|
|
5676
|
+
tags: z.array(z.string()).max(20).optional().describe('Free tags for action tags; replaces the list.'),
|
|
5677
|
+
target: z.object({ sceneId: z.string().uuid(), at: z.number().int().min(0), expectedRevision: z.number().int().min(0) }).optional(),
|
|
5678
|
+
uses: z.array(z.object({ sectionId: z.string().uuid(), expectedRevision: z.number().int().min(0) })).optional(),
|
|
5679
|
+
fragments: z.array(z.object({ sceneId: z.string().uuid(), start: z.number().int().min(0), end: z.number().int().min(0) })).optional(),
|
|
5680
|
+
});
|
|
5681
|
+
export const changeScriptSection = {
|
|
5682
|
+
id: 'slates_update_script_section', description: 'Create a free-named section from a selected passage, save the words now on the page as a new version (action alternative), save changes to the shown version (save) or choose another (choose), insert an editable copy (reuse), update unedited copies (updateUses), rename or archive a section or one version (alternativeId), or set its free tags. Choosing, archiving a section, reuse and updateUses first save the page\'s words and shot bindings into the version that was showing (the page is never unsaved; save remains for an explicit save), and only alternative starts a new version; archiving a section keeps its words, shots and media on the page. Uses the current document revision and never generates media. Partial crossing sections are refused.',
|
|
5683
|
+
input: sectionInput,
|
|
5684
|
+
async run(input, ctx) {
|
|
5685
|
+
await ctx.desktop().requireCapability('script-documents', 'script documents');
|
|
5686
|
+
const data = await ctx.desktop().post('/agent/script/sections', input);
|
|
5687
|
+
return ok(data, 'Script section updated.');
|
|
5688
|
+
},
|
|
5689
|
+
};
|
|
5690
|
+
export const getScriptSuggestions = {
|
|
5691
|
+
id: 'slates_get_script_suggestions', description: 'Read suggested replacements on a script: pending ones first (stale when their words changed since), then recent accepted or dismissed ones.',
|
|
5692
|
+
input: z.object({ storyboardId: z.string().uuid() }),
|
|
5693
|
+
async run(input, ctx) {
|
|
5694
|
+
await ctx.desktop().requireCapability('script-documents', 'script documents');
|
|
5695
|
+
return ok(await ctx.desktop().get('/agent/script/suggestions', input), 'Script suggestions read.');
|
|
5696
|
+
},
|
|
5697
|
+
};
|
|
5698
|
+
const suggestionInput = z.object({
|
|
5699
|
+
storyboardId: z.string().uuid(), expectedRevision: z.number().int().min(0), action: z.enum(['create', 'accept', 'dismiss']),
|
|
5700
|
+
suggestions: z.array(z.object({ sceneId: z.string().uuid(), start: z.number().int().min(0), end: z.number().int().min(0),
|
|
5701
|
+
original: z.string().describe('The exact words now at start to end.'), replacement: z.string(), note: z.string().max(500).optional().describe('One line on why.') })).min(1).max(20).optional(),
|
|
5702
|
+
suggestionId: z.string().uuid().optional(),
|
|
5703
|
+
});
|
|
5704
|
+
export const changeScriptSuggestions = {
|
|
5705
|
+
id: 'slates_update_script_suggestions', description: 'Propose replacements for exact passages without editing the script (create), or accept or dismiss one. Use create when asked to suggest or improve a passage; the creator reviews and accepts it in the document. Each quotes the words it replaces; one whose words change later is stale and will not apply. Accepting is one undoable write. Never generates media.',
|
|
5706
|
+
input: suggestionInput,
|
|
5707
|
+
async run(input, ctx) {
|
|
5708
|
+
await ctx.desktop().requireCapability('script-documents', 'script documents');
|
|
5709
|
+
return ok(await ctx.desktop().post('/agent/script/suggestions', input), 'Script suggestions updated.');
|
|
5710
|
+
},
|
|
5711
|
+
};
|
|
5712
|
+
const documentBlocks = z.array(z.object({
|
|
5713
|
+
id: z.string().min(1), kind: z.enum(['paragraph', 'heading', 'direction']),
|
|
5714
|
+
start: z.number().int().min(0), end: z.number().int().min(0), label: z.string().optional(), level: z.union([z.literal(2), z.literal(3)]).optional(),
|
|
5715
|
+
marks: z.array(z.object({ start: z.number().int().min(0), end: z.number().int().min(0), type: z.enum(['strong', 'em']) })),
|
|
5716
|
+
}));
|
|
5717
|
+
export const getScriptDocument = {
|
|
5718
|
+
id: 'slates_get_script_document',
|
|
5719
|
+
description: 'Read the whole script document: scene strings, UTF-16 block/shot anchors, non-spoken headings/directions and a revision for safe writes. Formatting metadata contains no second copy of paragraph text.',
|
|
5720
|
+
input: z.object({ storyboardId: z.string().uuid() }),
|
|
5721
|
+
async run(input, ctx) {
|
|
5722
|
+
await ctx.desktop().requireCapability('script-documents', 'script documents');
|
|
5723
|
+
const data = await ctx.desktop().get('/agent/script/document', input);
|
|
5724
|
+
return ok(data, 'Script document read.');
|
|
5725
|
+
},
|
|
5726
|
+
};
|
|
5727
|
+
const writeScriptDocumentInput = z.object({
|
|
5728
|
+
storyboardId: z.string().uuid(), expectedRevision: z.number().int().min(0),
|
|
5729
|
+
edits: z.array(z.object({ sceneId: z.string().uuid(), at: z.number().int().min(0), removed: z.number().int().min(0), text: z.string() })),
|
|
5730
|
+
structures: z.array(z.object({ sceneId: z.string().uuid(), blocks: documentBlocks })).optional(),
|
|
5731
|
+
move: z.object({ sceneId: z.string().uuid(), blockId: z.string(), delta: z.union([z.literal(-1), z.literal(1)]) }).optional(),
|
|
5732
|
+
makeShots: z.array(z.object({ sceneId: z.string().uuid(), start: z.number().int().min(0), end: z.number().int().min(0), waitingShotId: z.string().uuid().optional() })).optional(),
|
|
5733
|
+
restoreRevision: z.number().int().min(0).optional(), ensureScene: z.boolean().optional(),
|
|
5734
|
+
sceneAction: z.discriminatedUnion('kind', [z.object({ kind: z.literal('split'), sceneId: z.string().uuid(), at: z.number().int().min(0) }), z.object({ kind: z.literal('merge'), sceneId: z.string().uuid() })]).optional(),
|
|
5735
|
+
});
|
|
5736
|
+
export const writeScriptDocument = {
|
|
5737
|
+
id: 'slates_update_script_document',
|
|
5738
|
+
description: 'Apply ordered script edits and optional formatting in one revision-checked transaction. Read the document first. Stale writes are refused without replacing either author’s work. A move carries contained shot anchors; restoreRevision restores an existing text/structure revision. No generation side effect. ensureScene creates an empty scene only if needed.',
|
|
5739
|
+
input: writeScriptDocumentInput,
|
|
5740
|
+
async run(input, ctx) {
|
|
5741
|
+
await ctx.desktop().requireCapability('script-documents', 'script documents');
|
|
5742
|
+
const data = await ctx.desktop().post('/agent/script/document', input);
|
|
5743
|
+
return ok(data, 'Script document updated.');
|
|
5744
|
+
},
|
|
5745
|
+
};
|
|
5746
|
+
export const editScript = {
|
|
5747
|
+
id: 'slates_edit_script',
|
|
5748
|
+
description: "Edit a scene's script: ONE contiguous replacement — `removed` characters at `at` become `text` (insert: removed 0; delete: text ''). Every Shot's range follows the way comment anchors follow a document: text before a Shot moves it, text after leaves it, an edit inside grows or shrinks it, typing at its end extends it unless the text starts a new line, and deleting all of a Shot's words leaves it with no words and no place on the page. Every affected Shot's `line` changes at once — this IS editing the Shots. Read slates_get_script first; offsets are into that text. Pass its `rev` (or the document `revision` as `expectedRevision`): when the Script page or anything else changed the words since, the edit is refused ('the script changed since you read it', or SCRIPT_CONFLICT for a stale revision), and you re-read and measure again. The result carries the new `rev` and `revision` for a following edit.",
|
|
5749
|
+
input: z.object({
|
|
5750
|
+
sceneId: z.string().uuid(),
|
|
5751
|
+
expectedRevision: z.number().int().nonnegative().optional().describe('The document `revision` slates_get_script or slates_get_script_document returned. The edit is refused if the document changed since.'),
|
|
5752
|
+
at: z.number().int().min(0).describe('Character offset the replacement starts at.'),
|
|
5753
|
+
removed: z.number().int().min(0).describe('How many characters to remove there (0 to insert).'),
|
|
5754
|
+
text: z.string().describe("What goes in their place ('' to delete). A blank line (\\n\\n) separates paragraphs."),
|
|
5755
|
+
rev: z.string().optional().describe('The `rev` slates_get_script (or the previous edit) returned. The edit is refused if the text changed since.'),
|
|
5756
|
+
}),
|
|
5757
|
+
async run(input, ctx) {
|
|
5758
|
+
const desktop = ctx.desktop();
|
|
5759
|
+
await desktop.requireCapability('script', 'the Script page');
|
|
5760
|
+
const r = await desktop.post('/agent/script/edit', input);
|
|
5761
|
+
return ok(r, `Script is now ${r.script.length} characters; every Shot's line follows.`);
|
|
5762
|
+
},
|
|
5763
|
+
};
|
|
5764
|
+
export const makeShotFromScript = {
|
|
5765
|
+
id: 'slates_make_shot_from_script',
|
|
5766
|
+
description: "Do the ONE thing a selection of the script can mean, exactly as the Script page's popover does: Make shot when the selection touches no Shot; Split when it lies inside one Shot (the Shot keeps its first piece and its takes, every other piece becomes a new Shot after it); Extend when it crosses one Shot's edge; Merge when it spans several (the first takes the span — unshot text between them included, nothing deleted — the others give up their words: one holding a take (finished or generating), a prompt, a reference or a picture stays and waits for text; only a completely blank one is removed). Pass `waitingShotId` to give a Shot that has no words yet (made on the Board, split off a take) the selected text instead. `perParagraph: true` over several unshot paragraphs makes one Shot per paragraph. A new Shot is filed after every ranged Shot whose words start before it.",
|
|
5767
|
+
input: z.object({
|
|
5768
|
+
sceneId: z.string().uuid(),
|
|
5769
|
+
start: z.number().int().min(0),
|
|
5770
|
+
end: z.number().int().min(0),
|
|
5771
|
+
waitingShotId: z.string().nullable().optional().describe('A Shot with no words (id or SHOT-A code) that should take the selection.'),
|
|
5772
|
+
perParagraph: z.boolean().optional().describe('Over unshot text spanning several paragraphs: one Shot per paragraph.'),
|
|
5773
|
+
}),
|
|
5774
|
+
async run(input, ctx) {
|
|
5775
|
+
const desktop = ctx.desktop();
|
|
5776
|
+
await desktop.requireCapability('script', 'the Script page');
|
|
5777
|
+
const r = await desktop.post('/agent/script/make-shot', input);
|
|
5778
|
+
const kept = r.kept?.length ? ` ${r.kept.join(', ')} kept ${r.kept.length === 1 ? 'its takes and waits' : 'their takes and wait'} for text.` : '';
|
|
5779
|
+
const removed = r.removed?.length ? ` ${r.removed.length} empty Shot(s) removed.` : '';
|
|
5780
|
+
const made = r.shotIds ? `${r.shotIds.length} Shot(s) made, one per paragraph.` : `${r.action.label}: ${r.shot?.code || r.shotId || 'done'}.`;
|
|
5781
|
+
return ok(r, `${made}${kept}${removed}`);
|
|
5782
|
+
},
|
|
5783
|
+
};
|
|
5784
|
+
export const breakScriptIntoShots = {
|
|
5785
|
+
id: 'slates_break_script_into_shots',
|
|
5786
|
+
description: '"Break the script into shots": a Shot for every piece of text no Shot holds — one per paragraph — across a whole board, or one scene. Shots that already exist keep their words. This is the cut after the script is right; slates_paste_script is the step before it.',
|
|
5787
|
+
input: z.object({
|
|
5788
|
+
storyboardId: z.string().uuid().optional().describe('Every scene of this board.'),
|
|
5789
|
+
sceneId: z.string().uuid().optional().describe('Only this scene.'),
|
|
5790
|
+
}),
|
|
5791
|
+
async run(input, ctx) {
|
|
5792
|
+
const desktop = ctx.desktop();
|
|
5793
|
+
await desktop.requireCapability('script', 'the Script page');
|
|
5794
|
+
const r = await desktop.post('/agent/script/break', input);
|
|
5795
|
+
return ok(r, `${r.shotIds.length} Shot(s) made from the unshot text.`);
|
|
5796
|
+
},
|
|
5797
|
+
};
|
|
5798
|
+
export const splitScene = {
|
|
5799
|
+
id: 'slates_split_scene',
|
|
5800
|
+
description: "Open a scene at a heading line, the Notion way: the text from `lineEnd` on moves into a NEW scene directly after this one, with its Shots and their slots; the heading line [lineStart, lineEnd) itself is consumed and `name` becomes the new scene's name. Use slates_paste_script to do this for every SCENE / INT. / EXT. / # line of a pasted script at once.",
|
|
5801
|
+
input: z.object({
|
|
5802
|
+
sceneId: z.string().uuid(),
|
|
5803
|
+
lineStart: z.number().int().min(0).describe('Offset where the heading line starts.'),
|
|
5804
|
+
lineEnd: z.number().int().min(0).describe('Offset just past the heading line (the newline after it is consumed too).'),
|
|
5805
|
+
name: z.string().nullable().optional().describe("The new scene's name. Omitted = 'Scene N'."),
|
|
5806
|
+
}),
|
|
5807
|
+
async run(input, ctx) {
|
|
5808
|
+
const desktop = ctx.desktop();
|
|
5809
|
+
await desktop.requireCapability('script', 'the Script page');
|
|
5810
|
+
const r = await desktop.post('/agent/script/split-scene', input);
|
|
5811
|
+
return ok(r, `Opened "${r.scene?.name ?? 'a scene'}" there; the text below moved into it.`);
|
|
5812
|
+
},
|
|
5813
|
+
};
|
|
5814
|
+
export const mergeScene = {
|
|
5815
|
+
id: 'slates_merge_scene',
|
|
5816
|
+
description: "Join a scene to the one before it (Backspace in an emptied heading): the texts join with a blank line, every Shot keeps its words and its slot appends after the previous scene's. Only the scene row goes — no Shot does. Returns the offset in the joined script where this scene's text now starts.",
|
|
5817
|
+
input: z.object({ sceneId: z.string().uuid() }),
|
|
5818
|
+
async run(input, ctx) {
|
|
5819
|
+
const desktop = ctx.desktop();
|
|
5820
|
+
await desktop.requireCapability('script', 'the Script page');
|
|
5821
|
+
const r = await desktop.post('/agent/script/merge-scene', input);
|
|
5822
|
+
return ok(r, `Joined into "${r.scene?.name ?? 'the previous scene'}" at offset ${r.at}.`);
|
|
5823
|
+
},
|
|
5824
|
+
};
|
|
5825
|
+
export const pasteScript = {
|
|
5826
|
+
id: 'slates_paste_script',
|
|
5827
|
+
description: "Dump a script in as TEXT, not Shots: the text lands in the scene's script (at its end by default), and lines reading `SCENE …`, `INT.` / `EXT.` / `I/E.` or `# Name` open scenes. No Shot is made — cutting is the writer's act or yours: slates_break_script_into_shots for one per paragraph, or slates_make_shot_from_script per selection. With no sceneId, a new scene at the end of the board takes the text (dropped again if the script opens with its own heading). Prefer this over slates_create_shot per paragraph: see the whole script, get it right, then cut it.",
|
|
5828
|
+
input: z.object({
|
|
5829
|
+
storyboardId: z.string().uuid().optional().describe('The board, when no scene is named.'),
|
|
5830
|
+
sceneId: z.string().uuid().nullable().optional().describe('The scene to paste into. Omitted = a new scene at the end.'),
|
|
5831
|
+
text: z.string().describe('The script. Blank lines separate paragraphs.'),
|
|
5832
|
+
at: z.number().int().min(0).optional().describe("Offset in the scene's script to paste at. Omitted = the end."),
|
|
5833
|
+
}),
|
|
5834
|
+
async run(input, ctx) {
|
|
5835
|
+
const desktop = ctx.desktop();
|
|
5836
|
+
await desktop.requireCapability('script', 'the Script page');
|
|
5837
|
+
const r = await desktop.post('/agent/script/paste', input);
|
|
5838
|
+
return ok(r, `Pasted as text; the board now has ${r.scenes.length} scene(s). No Shot was made — break it into shots when it reads right.`);
|
|
5839
|
+
},
|
|
5840
|
+
};
|
|
5841
|
+
/** The draft mode both quote ops accept: one picture per Shot in scope that has none. */
|
|
5842
|
+
const DRAFT_INPUT = z
|
|
5843
|
+
.object({ model: z.enum(IMAGE_MODELS).optional().describe('The image model to draft on. Omit it for the default image model.') })
|
|
5844
|
+
.optional();
|
|
4899
5845
|
export const generateFromShots = {
|
|
4900
5846
|
id: 'slates_generate_from_shots',
|
|
4901
5847
|
billable: true,
|
|
4902
|
-
description: '
|
|
5848
|
+
description: 'Preview the itemized prices the Generate panel shows for saved Shots. After explicit approval, pass confirm and the returned fingerprint. Changed recipes require a fresh price. Runs sequentially; after a timeout inspect generations instead of re-firing. Pass draft to generate previews instead: one picture for each Shot that has none, never saved to the Shot.',
|
|
4903
5849
|
input: z.object({
|
|
4904
|
-
shotIds: z.array(z.string()).min(1).max(20)
|
|
4905
|
-
confirm: z.boolean().optional()
|
|
5850
|
+
shotIds: z.array(z.string()).min(1).max(20),
|
|
5851
|
+
confirm: z.boolean().optional(),
|
|
5852
|
+
fingerprint: z.string().optional(),
|
|
5853
|
+
draft: DRAFT_INPUT.describe('Generate previews: pass {} to price ONE picture for each Shot in scope that has no picture, instead of each Shot recipe, at the chosen image model’s default settings. Works on a Shot with no model. No Shot row changes; the picture becomes a take of that Shot. Pass the same value when confirming.'),
|
|
4906
5854
|
}),
|
|
4907
5855
|
async run(input, ctx) {
|
|
4908
5856
|
const desktop = ctx.desktop();
|
|
4909
5857
|
await desktop.requireCapability('shots', 'saved Shots');
|
|
4910
|
-
//
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
details.
|
|
4916
|
-
}
|
|
4917
|
-
const
|
|
4918
|
-
const
|
|
4919
|
-
|
|
4920
|
-
|
|
4921
|
-
|
|
4922
|
-
|
|
4923
|
-
|
|
4924
|
-
|
|
4925
|
-
|
|
4926
|
-
|
|
4927
|
-
// friction the batch exists to remove; the safety is the STATED TOTAL,
|
|
4928
|
-
// prominent — count, total, and the largest single Shot.
|
|
4929
|
-
const lines = quotes.map((q) => ` - ${q.detail.name || 'Untitled'} · ${q.detail.model ?? 'no model'} · ` +
|
|
4930
|
-
(q.key ? fmtCredits(q.credits) : 'NOT PRICEABLE'));
|
|
4931
|
-
const blockedLines = blockedShots.map((d) => ` ✖ ${d.name || 'Untitled'} WILL NOT FIRE: ${d.blocked}`);
|
|
4932
|
-
const warnings = details
|
|
4933
|
-
.filter((d) => d.missing || d.unresolvedTokens?.length)
|
|
4934
|
-
.map((d) => ` ! ${d.name || 'Untitled'}: ${d.missing ? 'references something that no longer exists' : ''}` +
|
|
4935
|
-
`${d.unresolvedTokens?.length ? ` ${d.unresolvedTokens.join(', ')} match nothing saved (sent as written, no reference attached)` : ''}`);
|
|
4936
|
-
return ok({
|
|
4937
|
-
requires_confirm: true,
|
|
4938
|
-
count: quotes.length,
|
|
4939
|
-
total_credits: total,
|
|
4940
|
-
largest_single_credits: largest,
|
|
4941
|
-
blocked_count: blockedShots.length,
|
|
4942
|
-
shots: quotes.map((q) => ({
|
|
4943
|
-
id: q.detail.id,
|
|
4944
|
-
name: q.detail.name,
|
|
4945
|
-
model: q.detail.model,
|
|
4946
|
-
cost_key: q.key,
|
|
4947
|
-
credits: q.credits,
|
|
4948
|
-
blocked: q.detail.blocked,
|
|
4949
|
-
})),
|
|
4950
|
-
}, `Firing ${quotes.length} Shot(s) SEQUENTIALLY.\n` +
|
|
4951
|
-
`TOTAL ${fmtCredits(total)} · largest single ${fmtCredits(largest)}\n` +
|
|
4952
|
-
lines.join('\n') +
|
|
4953
|
-
(blockedLines.length > 0 ? `\n${blockedLines.join('\n')}` : '') +
|
|
4954
|
-
(unpriced.length > 0
|
|
4955
|
-
? `\n ! ${unpriced.length} shot(s) could not be priced — they will still be attempted and may fail.`
|
|
4956
|
-
: '') +
|
|
4957
|
-
(warnings.length > 0 ? `\n${warnings.join('\n')}` : '') +
|
|
4958
|
-
`\n\nRe-call with confirm: true after explicit user OK on that total.`);
|
|
5858
|
+
// Prices come only from the desktop's quote; a 1.5.8 desktop has none.
|
|
5859
|
+
await desktop.requireCapability('board-quote', 'Shot prices');
|
|
5860
|
+
// Resolved first so a SHOT-A code fails here, not halfway through a billed run.
|
|
5861
|
+
const details = await Promise.all(input.shotIds.map((id) => desktop.get('/agent/shots/get', { id })));
|
|
5862
|
+
const quote = await desktop.get('/agent/shots/quote', {
|
|
5863
|
+
input: JSON.stringify({ projectId: details[0].shot.projectId, shotIds: details.map((d) => d.shot.id), draft: input.draft }),
|
|
5864
|
+
});
|
|
5865
|
+
const total = quote.items.reduce((n, i) => n + (i.credits ?? 0), 0);
|
|
5866
|
+
const largest = Math.max(0, ...quote.items.map((i) => i.credits ?? 0));
|
|
5867
|
+
// ONE approval for the set, on the STATED total; a stale fingerprint is a
|
|
5868
|
+
// changed recipe and never reaches generation.
|
|
5869
|
+
if (!input.confirm || input.fingerprint !== quote.fingerprint) {
|
|
5870
|
+
return ok({ ...quote, requires_confirm: true, total_credits: total, largest_single_credits: largest }, `TOTAL ${fmtCredits(total)} · largest ${fmtCredits(largest)}.\n` +
|
|
5871
|
+
quote.items
|
|
5872
|
+
.map((i) => `${i.code ?? i.name}: ${i.credits == null ? 'unpriced' : fmtCredits(i.credits)}${i.blocked ? ` — ${i.blocked}` : ''}`)
|
|
5873
|
+
.join('\n') +
|
|
5874
|
+
'\nGet approval for these prices, then pass their fingerprint with confirm: true.');
|
|
4959
5875
|
}
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
4967
|
-
|
|
4968
|
-
|
|
4969
|
-
|
|
4970
|
-
|
|
4971
|
-
|
|
4972
|
-
|
|
4973
|
-
|
|
5876
|
+
return ok(await desktop.post('/agent/shots/batch-generate', {
|
|
5877
|
+
shotIds: quote.items.map((i) => i.shotId),
|
|
5878
|
+
fingerprint: quote.fingerprint,
|
|
5879
|
+
draft: input.draft,
|
|
5880
|
+
}));
|
|
5881
|
+
},
|
|
5882
|
+
};
|
|
5883
|
+
export const quoteBoard = {
|
|
5884
|
+
id: 'slates_get_board_quote',
|
|
5885
|
+
description: 'Read the itemized prices the Generate panel shows for a board, scene or shot selection, optionally only missing results. No generation. Uses the desktop composer pricing source and returns a fingerprint for slates_generate_from_shots. Pass draft to price previews (one picture per Shot that has none).',
|
|
5886
|
+
input: z.object({
|
|
5887
|
+
projectId: z.string().uuid(),
|
|
5888
|
+
storyboardId: z.string().uuid().optional(),
|
|
5889
|
+
sceneId: z.string().uuid().optional(),
|
|
5890
|
+
shotIds: z.array(z.string()).optional(),
|
|
5891
|
+
missingOnly: z.boolean().optional(),
|
|
5892
|
+
draft: DRAFT_INPUT.describe('Generate previews: pass {} to price one picture for each Shot in scope with no picture, instead of each Shot recipe.'),
|
|
5893
|
+
}),
|
|
5894
|
+
async run(input, ctx) {
|
|
5895
|
+
await ctx.desktop().requireCapability('board-quote', 'the board prices');
|
|
5896
|
+
return ok(await ctx.desktop().get('/agent/shots/quote', { input: JSON.stringify(input) }));
|
|
5897
|
+
},
|
|
5898
|
+
};
|
|
5899
|
+
export const getBoardProgress = {
|
|
5900
|
+
id: 'slates_get_board_progress',
|
|
5901
|
+
description: 'Read per-shot spend from generation history, surviving take counts, running/failed counts and recorded-round progress. Deleted takes do not reduce spend.',
|
|
5902
|
+
input: z.object({ projectId: z.string().uuid() }),
|
|
5903
|
+
async run(input, ctx) {
|
|
5904
|
+
await ctx.desktop().requireCapability('board-progress', 'board progress');
|
|
5905
|
+
return ok(await ctx.desktop().get('/agent/shots/progress', input));
|
|
5906
|
+
},
|
|
5907
|
+
};
|
|
5908
|
+
export const editCut = {
|
|
5909
|
+
id: 'slates_edit_cut',
|
|
5910
|
+
description: 'Read pending preferred-take changes, explicitly replace one clip, sync a board’s preferred clips, or build a cut in board order. Read changes first for the count. Swaps keep timeline starts and source timing when possible; shorter takes are visibly marked. Never changes a shot recipe or poster. Undo swaps with restore and the returned before snapshots; undo a build with undo-build and its clipIds/markerIds.',
|
|
5911
|
+
input: z.object({
|
|
5912
|
+
projectId: z.string().uuid(), timelineId: z.string().uuid().optional(),
|
|
5913
|
+
action: z.enum(['changes', 'replace', 'sync', 'build', 'restore', 'undo-build']),
|
|
5914
|
+
storyboardId: z.string().uuid().optional(),
|
|
5915
|
+
clipId: z.string().uuid().optional(),
|
|
5916
|
+
assetId: z.string().optional(),
|
|
5917
|
+
before: z
|
|
5918
|
+
.array(z
|
|
5919
|
+
.object({
|
|
5920
|
+
id: z.string().uuid(),
|
|
5921
|
+
assetPath: z.string(),
|
|
5922
|
+
assetId: z.string().nullable().optional(),
|
|
5923
|
+
shotId: z.string().nullable().optional(),
|
|
5924
|
+
thumbnailPath: z.string().optional(),
|
|
5925
|
+
sourceDuration: z.number().positive(),
|
|
5926
|
+
sourceFps: z.number().positive(),
|
|
5927
|
+
sourceInFrame: z.number().int().min(0),
|
|
5928
|
+
sourceOutFrame: z.number().int().positive(),
|
|
5929
|
+
endFrame: z.number().int().positive(),
|
|
5930
|
+
shortened: z.boolean().optional(),
|
|
5931
|
+
})
|
|
5932
|
+
.passthrough())
|
|
5933
|
+
.optional()
|
|
5934
|
+
.describe('Exact before snapshots returned by replace/sync, for restore.'),
|
|
5935
|
+
clipIds: z.array(z.string().uuid()).optional(),
|
|
5936
|
+
markerIds: z.array(z.string().uuid()).optional(),
|
|
5937
|
+
skipPresent: z.boolean().optional().describe('build: add only Shots this cut does not already hold, so a repeated build appends nothing twice. Default appends the whole board.'),
|
|
5938
|
+
}),
|
|
5939
|
+
async run(input, ctx) {
|
|
5940
|
+
await ctx.desktop().requireCapability('cut-edit', 'editing a cut');
|
|
5941
|
+
const refs = input.assetId ? await resolveAssetRefs(ctx, input.projectId, [input.assetId]) : new Map();
|
|
5942
|
+
return ok(await ctx.desktop().post('/agent/timeline/cut', { ...input, assetId: input.assetId ? refs.get(input.assetId)?.id : undefined }));
|
|
5943
|
+
},
|
|
5944
|
+
};
|
|
5945
|
+
export function resolveGuideTopic(topic) {
|
|
4974
5946
|
const t = topic.trim().toLowerCase();
|
|
4975
5947
|
if (SKILLS[t])
|
|
4976
5948
|
return t;
|
|
@@ -4995,6 +5967,32 @@ function resolveGuideTopic(topic) {
|
|
|
4995
5967
|
if (t === 'camera' || t === 'camera-moves' || t === 'camera moves' || t === 'shot-list' || t === 'shot list') {
|
|
4996
5968
|
return 'slates-camera-language';
|
|
4997
5969
|
}
|
|
5970
|
+
// The cinematic-look catalogue: light, exposure, grade, what a lens does to the
|
|
5971
|
+
// picture, imperfection. Above the model prefixes like the blocks before it;
|
|
5972
|
+
// `camera` stays with camera-language, `lens` and `lighting` are look words.
|
|
5973
|
+
if (t === 'cinematic' ||
|
|
5974
|
+
t === 'cinematic-look' ||
|
|
5975
|
+
t === 'cinematic look' ||
|
|
5976
|
+
t === 'film-look' ||
|
|
5977
|
+
t === 'film look' ||
|
|
5978
|
+
t === 'look' ||
|
|
5979
|
+
t === 'lighting' ||
|
|
5980
|
+
t === 'light' ||
|
|
5981
|
+
t === 'exposure' ||
|
|
5982
|
+
t === 'grade' ||
|
|
5983
|
+
t === 'grading' ||
|
|
5984
|
+
t === 'color-grade' ||
|
|
5985
|
+
t === 'colour-grade' ||
|
|
5986
|
+
t === 'silhouette' ||
|
|
5987
|
+
t === 'lens' ||
|
|
5988
|
+
t === 'lenses' ||
|
|
5989
|
+
t === 'realism' ||
|
|
5990
|
+
t === 'natural-light' ||
|
|
5991
|
+
t === 'natural light' ||
|
|
5992
|
+
t === 'too-perfect' ||
|
|
5993
|
+
t === 'imperfection') {
|
|
5994
|
+
return 'slates-cinematic-look';
|
|
5995
|
+
}
|
|
4998
5996
|
if (t === 'blocking-to-prompt' || t === 'previs-prompt' || t === 'reference-video' || t === 'video-to-video' || t === 'v2v') {
|
|
4999
5997
|
return 'slates-blocking-to-prompt';
|
|
5000
5998
|
}
|
|
@@ -5014,7 +6012,7 @@ function resolveGuideTopic(topic) {
|
|
|
5014
6012
|
}
|
|
5015
6013
|
if (t.startsWith('nano-banana'))
|
|
5016
6014
|
return 'slates-prompting-nano-banana-2';
|
|
5017
|
-
if (t.startsWith('gpt-image') || t.startsWith('gpt image'))
|
|
6015
|
+
if (t === 'sunburst' || t === 'flare' || t.startsWith('gpt-image') || t.startsWith('gpt image'))
|
|
5018
6016
|
return 'slates-prompting-gpt-image-2-5';
|
|
5019
6017
|
if (t.startsWith('flux'))
|
|
5020
6018
|
return 'slates-prompting-flux-2-max';
|
|
@@ -5135,61 +6133,64 @@ export const getPromptingGuide = {
|
|
|
5135
6133
|
// not this op is ever called.
|
|
5136
6134
|
"Return a bundled Slates prompting/workflow guide. MCP-only clients (Claude Desktop, Smithery) don't get the CLI-installed skill files — call this instead. Accepts a guide name or a model id ('veo-3.1-fast', 'kling-v3.0-pro', 'seedance-2', 'nano-banana-2'), which maps to the right guide. Reach for it when a card is not enough: the failure modes, the worked examples and the sources are only in the full text.",
|
|
5137
6135
|
input: z.object({
|
|
5138
|
-
query: z.string().max(200).optional().describe('
|
|
6136
|
+
query: z.string().max(200).optional().describe('Keywords, section heading, or exact cinematic technique ID. Returns only the matching section or technique.'),
|
|
5139
6137
|
topic: z
|
|
5140
6138
|
.string()
|
|
5141
6139
|
.min(1)
|
|
5142
6140
|
.describe(`Guide name, model id, or style name. ${describeGuideTopics()}`),
|
|
5143
|
-
depth: z.enum(['card', 'full']).optional().describe('"card"
|
|
6141
|
+
depth: z.enum(['card', 'index', 'section', 'full']).optional().describe('Default "card" is a short overview. "index" lists sections; query selects one section or technique; "full" explicitly returns the complete guide.'),
|
|
5144
6142
|
}),
|
|
5145
6143
|
async run(input) {
|
|
5146
6144
|
if (input.topic.trim().toLowerCase() === 'app-manual') {
|
|
5147
|
-
const content =
|
|
5148
|
-
|
|
6145
|
+
const content = input.query || input.depth === 'full'
|
|
6146
|
+
? appManualSections(input.query)
|
|
6147
|
+
: 'App manual sections. Pass query to read a section, or depth "full" for the complete manual.\n\n' +
|
|
6148
|
+
guideSections(appManualSections()).map((s) => `- ${s.title}`).join('\n');
|
|
6149
|
+
return { text: content, data: { topic: 'app-manual', bytes: Buffer.byteLength(content, 'utf8'), guide: content } };
|
|
5149
6150
|
}
|
|
5150
6151
|
const resolved = resolveGuideTopic(input.topic);
|
|
5151
6152
|
const content = resolved ? SKILLS[resolved] : undefined;
|
|
5152
6153
|
if (!resolved || content === undefined) {
|
|
5153
6154
|
throw new Error(`Unknown guide topic: ${input.topic}. Valid topics: ${Object.keys(SKILLS).sort().join(', ')}`);
|
|
5154
6155
|
}
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
|
|
5159
|
-
}
|
|
5160
|
-
// No card on this guide — returning nothing would read as "no guidance",
|
|
5161
|
-
// which is worse than a fall-through the result names.
|
|
5162
|
-
}
|
|
5163
|
-
return {
|
|
5164
|
-
text: content,
|
|
5165
|
-
data: { topic: resolved, depth: 'full', bytes: Buffer.byteLength(content, 'utf8') },
|
|
5166
|
-
};
|
|
6156
|
+
const depth = input.depth ?? 'card';
|
|
6157
|
+
const guide = retrieveGuide(resolved, content, depth, input.query);
|
|
6158
|
+
// Both fields carry the body: some native clients expose structured data only.
|
|
6159
|
+
return { text: guide, data: { topic: resolved, depth, bytes: Buffer.byteLength(guide, 'utf8'), guide } };
|
|
5167
6160
|
},
|
|
5168
6161
|
};
|
|
5169
6162
|
/**
|
|
5170
6163
|
* The one op that changes what OTHER ops are visible.
|
|
5171
6164
|
*
|
|
5172
6165
|
* 🚨 IT EXISTS BECAUSE THE SURFACE IS 112 KB AND EVERY TURN PAYS FOR ALL OF IT.
|
|
5173
|
-
*
|
|
5174
|
-
*
|
|
5175
|
-
* is registered up front (a stdio server has no run to append to), so this
|
|
5176
|
-
* returns the same definitions as a plain listing — useful either way, since
|
|
5177
|
-
* it is also how an agent asks "what else can you do".
|
|
6166
|
+
* Both surfaces start with the shared core set. Search returns compact metadata;
|
|
6167
|
+
* names/group returns exact schemas and replaces the optional selection.
|
|
5178
6168
|
*/
|
|
5179
6169
|
export const loadTools = {
|
|
5180
6170
|
id: 'slates_load_tools',
|
|
5181
|
-
|
|
5182
|
-
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
'. Call it the moment the work needs one of those — the tools arrive in the same turn\'s result and stay loaded. On MCP clients every tool is already registered and this just lists the group.',
|
|
6171
|
+
// Worded to be true on both surfaces: the desktop agent must load an
|
|
6172
|
+
// extended tool before calling it, while the MCP server lists every tool, so
|
|
6173
|
+
// "missing from your list" never happens there by default.
|
|
6174
|
+
description: 'Find Slates tools by task. query searches tool names and descriptions and returns a compact list. names returns up to five exact tools with their schemas; group returns a whole task group. Call the tools by their own names. If a tool you need is missing from your tool list, a names or group load adds it; a load replaces the previous optional selection, and query alone does not change it. Groups: ' + Object.entries(GROUP_SUMMARY).map(([g, s]) => `${g}: ${s}`).join('; '),
|
|
5186
6175
|
input: z.object({
|
|
5187
|
-
group: z.enum(['library', 'timeline', 'admin', 'blender']).
|
|
5188
|
-
|
|
6176
|
+
group: z.enum(['script', 'library', 'timeline', 'admin', 'blender']).optional(),
|
|
6177
|
+
query: z.string().min(1).max(160).optional().describe('Search for a task such as generate image, create shot, or edit video.'),
|
|
6178
|
+
names: z.array(z.string()).min(1).max(5).optional().describe('Exact operation names to load after discovery. Their permission annotations remain separate.'),
|
|
6179
|
+
}).refine((v) => [v.group, v.query, v.names].filter(Boolean).length === 1, 'Pass exactly one of query, names, or group.'),
|
|
5189
6180
|
async run(input) {
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
|
|
6181
|
+
if (input.query) {
|
|
6182
|
+
const words = input.query.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
6183
|
+
const ranked = ALL_OPERATIONS.map((op) => ({ op, score: words.reduce((n, w) => n + (op.id.includes(w) ? 4 : op.description.toLowerCase().includes(w) ? 1 : 0), 0) }))
|
|
6184
|
+
.filter((x) => x.score > 0).sort((a, b) => b.score - a.score).slice(0, 10);
|
|
6185
|
+
const matches = ranked.map(({ op }) => ({ name: op.id, description: op.description.split(/(?<=\.)\s/)[0], billable: !!op.billable, annotations: op.annotations }));
|
|
6186
|
+
return ok({ matches }, matches.map((o) => `${o.name}: ${o.description}`).join('\n') || 'No matching tools. Try another task description.');
|
|
6187
|
+
}
|
|
6188
|
+
const names = input.names ?? OPERATION_GROUPS[input.group];
|
|
6189
|
+
const unknown = names.filter((id) => !ALL_OPERATIONS.some((op) => op.id === id));
|
|
6190
|
+
if (unknown.length)
|
|
6191
|
+
throw new Error(`Unknown operation(s): ${unknown.join(', ')}`);
|
|
6192
|
+
const defs = toolDefinitions(ALL_OPERATIONS.filter((op) => names.includes(op.id)), { surface: 'mcp' });
|
|
6193
|
+
return ok({ group: input.group, tools: defs }, `Loaded tools (call by name):\n${JSON.stringify(defs)}`);
|
|
5193
6194
|
},
|
|
5194
6195
|
};
|
|
5195
6196
|
// ── Blender previs ──────────────────────────────────────────────
|
|
@@ -5348,6 +6349,9 @@ export const blenderRenderBlocking = {
|
|
|
5348
6349
|
// ── Aggregation ─────────────────────────────────────────────────
|
|
5349
6350
|
export const ALL_OPERATIONS = [
|
|
5350
6351
|
getWorkspaceState,
|
|
6352
|
+
getSelection,
|
|
6353
|
+
getView,
|
|
6354
|
+
setView,
|
|
5351
6355
|
getMe,
|
|
5352
6356
|
getCreditBalance,
|
|
5353
6357
|
listAvailableModels,
|
|
@@ -5360,9 +6364,18 @@ export const ALL_OPERATIONS = [
|
|
|
5360
6364
|
getAssetsBatch,
|
|
5361
6365
|
getAssetVideoFrames,
|
|
5362
6366
|
uploadReferenceImage,
|
|
6367
|
+
saveExternalImage,
|
|
6368
|
+
getChatGptStatus,
|
|
6369
|
+
connectChatGpt,
|
|
6370
|
+
generateChatGptImage,
|
|
5363
6371
|
listFolders,
|
|
5364
6372
|
createFolder,
|
|
5365
6373
|
moveAssetsToFolder,
|
|
6374
|
+
setAssetFavorite,
|
|
6375
|
+
exportAssets,
|
|
6376
|
+
listPins,
|
|
6377
|
+
pinReferences,
|
|
6378
|
+
unpinReference,
|
|
5366
6379
|
moveAssetsToProject,
|
|
5367
6380
|
copyAssetsToProject,
|
|
5368
6381
|
moveEntityToProject,
|
|
@@ -5389,10 +6402,14 @@ export const ALL_OPERATIONS = [
|
|
|
5389
6402
|
editImage,
|
|
5390
6403
|
getGenerationStatus,
|
|
5391
6404
|
listGenerations,
|
|
6405
|
+
listTimelines,
|
|
6406
|
+
saveTimeline,
|
|
6407
|
+
exportCuts,
|
|
5392
6408
|
getTimeline,
|
|
5393
6409
|
addClipToTimeline,
|
|
5394
6410
|
reorderClips,
|
|
5395
6411
|
removeClip,
|
|
6412
|
+
manageTimelineMarker,
|
|
5396
6413
|
addTimelineTrack,
|
|
5397
6414
|
updateTimelineTrack,
|
|
5398
6415
|
removeTimelineTrack,
|
|
@@ -5403,6 +6420,8 @@ export const ALL_OPERATIONS = [
|
|
|
5403
6420
|
updateProject,
|
|
5404
6421
|
deleteProject,
|
|
5405
6422
|
getProjectDirectory,
|
|
6423
|
+
relocateProject,
|
|
6424
|
+
undoRelocateProject,
|
|
5406
6425
|
deleteAsset,
|
|
5407
6426
|
renameFolder,
|
|
5408
6427
|
deleteFolder,
|
|
@@ -5415,6 +6434,15 @@ export const ALL_OPERATIONS = [
|
|
|
5415
6434
|
createStyle,
|
|
5416
6435
|
updateStyle,
|
|
5417
6436
|
deleteStyle,
|
|
6437
|
+
listLibrary,
|
|
6438
|
+
createLibraryItem,
|
|
6439
|
+
updateLibraryItem,
|
|
6440
|
+
deleteLibraryItem,
|
|
6441
|
+
manageLibraryCategory,
|
|
6442
|
+
copyLibraryItemToProject,
|
|
6443
|
+
getTemplate,
|
|
6444
|
+
exportTemplate,
|
|
6445
|
+
importTemplate,
|
|
5418
6446
|
updateStoryboard,
|
|
5419
6447
|
deleteStoryboard,
|
|
5420
6448
|
updateScene,
|
|
@@ -5433,9 +6461,34 @@ export const ALL_OPERATIONS = [
|
|
|
5433
6461
|
// actually decided. They sit beside the writers, not with the spender.
|
|
5434
6462
|
splitShot,
|
|
5435
6463
|
mergeShots,
|
|
6464
|
+
// A take founding its own Shot, or moving between Shots (P2.3).
|
|
6465
|
+
splitTake,
|
|
6466
|
+
refileTake,
|
|
6467
|
+
// The script IS the shots (P2.4a): one text per scene, Shots as ranges of it.
|
|
6468
|
+
getScript,
|
|
6469
|
+
getScriptUses,
|
|
6470
|
+
getShotInputs,
|
|
6471
|
+
reuseShotTake,
|
|
6472
|
+
previewScriptVariation,
|
|
6473
|
+
createScriptVariation,
|
|
6474
|
+
getScriptSections,
|
|
6475
|
+
changeScriptSection,
|
|
6476
|
+
getScriptSuggestions,
|
|
6477
|
+
changeScriptSuggestions,
|
|
6478
|
+
getScriptDocument,
|
|
6479
|
+
writeScriptDocument,
|
|
6480
|
+
editScript,
|
|
6481
|
+
makeShotFromScript,
|
|
6482
|
+
breakScriptIntoShots,
|
|
6483
|
+
splitScene,
|
|
6484
|
+
mergeScene,
|
|
6485
|
+
pasteScript,
|
|
5436
6486
|
listShots,
|
|
5437
6487
|
getShot,
|
|
5438
6488
|
generateFromShots,
|
|
6489
|
+
quoteBoard,
|
|
6490
|
+
getBoardProgress,
|
|
6491
|
+
editCut,
|
|
5439
6492
|
getPromptingGuide,
|
|
5440
6493
|
loadTools,
|
|
5441
6494
|
// ── Blender previs, LAST and deliberately ────────────────────────────
|
|
@@ -5482,5 +6535,5 @@ for (const op of ALL_OPERATIONS) {
|
|
|
5482
6535
|
}
|
|
5483
6536
|
}
|
|
5484
6537
|
}
|
|
5485
|
-
export { toolDefinitions, toolDefinition, groupFor, tierFor, OPERATION_GROUPS, GROUP_SUMMARY } from './surface.js';
|
|
6538
|
+
export { toolDefinitions, toolDefinition, groupFor, tierFor, OPERATION_GROUPS, GROUP_SUMMARY, STARTUP_TOOL_IDS } from './surface.js';
|
|
5486
6539
|
//# sourceMappingURL=index.js.map
|