@kolbo/mcp 1.63.0 → 1.65.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/package.json +1 -1
- package/skill/SKILL.md +1 -1
- package/skill/references/models/seedance.md +2 -0
- package/skill/references/workflows/marketing-studio.md +1 -1
- package/src/apps/widgets/generation.js +2 -1
- package/src/polling.js +63 -4
- package/src/tools/generate.js +40 -15
- package/src/tools/visual_dna.js +1 -1
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -99,7 +99,7 @@ Each `references/models/*.md` mirrors the matching skill prompt in `kolbo-api/sr
|
|
|
99
99
|
| `generate_video` | Text-to-video. Does **not** support Visual DNA — use `generate_elements` for character-consistent video. |
|
|
100
100
|
| `generate_video_from_image` | Animate a still. Prompt describes motion, not subject. |
|
|
101
101
|
| `generate_video_from_video` | Restyle/transform an existing video. Keeps original motion. |
|
|
102
|
-
| `generate_elements` | Reference-driven video. **Primary route for DNA → video.** |
|
|
102
|
+
| `generate_elements` | Reference-driven video. **Primary route for DNA → video.** Supports `multi_shots` / `multi_shot_count` (badge/metadata/native flag only — never rewrites the prompt) and short `session_name`. Crafted demos: `enhance_prompt:false` + put shot structure in the prompt. |
|
|
103
103
|
| `generate_first_last_frame` | Keyframe interpolation between two frames. |
|
|
104
104
|
| `generate_lipsync` | Lipsync audio to an image or video face. |
|
|
105
105
|
| `generate_music` | Music generation (Suno + variants). |
|
|
@@ -9,6 +9,8 @@ Load this file when the user wants a **Seedance 2 / Seedance 2.0** (ByteDance) v
|
|
|
9
9
|
|
|
10
10
|
**Kolbo MCP routing:** Seedance is a video model — call `generate_video` (text-to-video) or `generate_elements` (when video references / Visual DNA / first-last frames are involved). Run `list_models({ type: "text_to_video" })` and pick a Seedance variant by name.
|
|
11
11
|
|
|
12
|
+
**Multishot (Elements):** Seedance has **no** native provider Multishot switch. Pass `multi_shots: true` on `generate_elements` (optional `multi_shot_count`) for the UI Multishot badge only — it does **not** rewrite or reshape your prompt. **Always send `enhance_prompt: false` for crafted demos** (MCP default) so the prompt stays RAW. Put shot structure in the prompt yourself (`Multishot ON`, `Total: … / N shots`, numbered shots). Optional `session_name` sets a short sidebar title (skips AI auto-name from boilerplate).
|
|
13
|
+
|
|
12
14
|
## Universal Rules (apply to EVERY Seedance prompt)
|
|
13
15
|
|
|
14
16
|
- **First line ALWAYS declares shot structure**: total duration, shot count, aspect ratio. Example: `Total: 15s / 6 shots / 16:9`. Put it at the BOTTOM of the prompt too. For connected narrative sequences the proven phrasing is `N connected cinematic shots, 15 seconds total, 16:9, Multishot ON` — use it and keep `Multishot ON` for any multi-shot story.
|
|
@@ -123,7 +123,7 @@ If the user gives a **product URL** instead of a photo, see `workflows/research-
|
|
|
123
123
|
|
|
124
124
|
## Multi-Slot Board Method (structured shot specs + character consistency)
|
|
125
125
|
|
|
126
|
-
For any multi-shot UGC / review / how-to where the SAME presenter must stay identical across shots, compose the prompt as explicit **slots** and lock identity with a **board-first** pass.
|
|
126
|
+
For any multi-shot UGC / review / how-to where the SAME presenter must stay identical across shots, compose the prompt as explicit **slots** and lock identity with a **board-first** pass. Prefer `generate_elements` with `multi_shots: true` (and a short `session_name`) when animating a single multi-cut Seedance spot; board-first still uses `generate_image` + `generate_elements` / `generate_video_from_image` per slot when you need separate clips.
|
|
127
127
|
|
|
128
128
|
### 1. Structured input slots
|
|
129
129
|
|
|
@@ -293,7 +293,8 @@ function releaseSeen() {
|
|
|
293
293
|
function schedulePoll(sc) {
|
|
294
294
|
if (cancelRequested) return;
|
|
295
295
|
if (!seen) { whenSeenFns.push(function () { schedulePoll(sc); }); return; }
|
|
296
|
-
// The call itself long-waits server-side
|
|
296
|
+
// The call itself long-waits server-side, for one transport-safe window
|
|
297
|
+
// (~45s over the remote connector — see WAIT_WINDOW_MS in tools/generate.js).
|
|
297
298
|
// This short pause only separates successive wait windows — the FIRST call
|
|
298
299
|
// goes out immediately, so a card revealed by scrolling resolves at once.
|
|
299
300
|
var delay = pollStart ? 1500 : 0;
|
package/src/polling.js
CHANGED
|
@@ -100,12 +100,71 @@ async function pollUntilDone(client, generationId, options = {}) {
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
// Still running: put a byte on the wire before going quiet again, so no
|
|
103
|
-
// intermediary mistakes a
|
|
103
|
+
// intermediary mistakes a long wait for a dead connection.
|
|
104
104
|
await progress.tick();
|
|
105
105
|
|
|
106
|
-
// Wait before next poll
|
|
107
|
-
|
|
106
|
+
// Wait before next poll — but never past the deadline. The check at the top
|
|
107
|
+
// of the loop only runs BETWEEN sleeps, so an unclamped sleep let the call
|
|
108
|
+
// overshoot `timeout` by up to a full interval (a 45s window with a 15s
|
|
109
|
+
// cadence could return at 60s). That is the difference between landing
|
|
110
|
+
// inside the caller's transport window and blowing straight through it.
|
|
111
|
+
const remaining = timeout - (Date.now() - startTime);
|
|
112
|
+
await new Promise(resolve => setTimeout(resolve, Math.max(0, Math.min(interval, remaining))));
|
|
108
113
|
}
|
|
109
114
|
}
|
|
110
115
|
|
|
111
|
-
|
|
116
|
+
// ─── Blocking-wait window for the STATUS tools ──────────────────────────────
|
|
117
|
+
// How long get_generation_status / get_creative_director_status may block
|
|
118
|
+
// inside ONE tool call before handing back a non-terminal result the caller
|
|
119
|
+
// re-issues. This is NOT the generation's lifetime — the job keeps running
|
|
120
|
+
// server-side either way.
|
|
121
|
+
//
|
|
122
|
+
// It used to be a flat 180s, which over the remote HTTP connector no caller
|
|
123
|
+
// could ever reach: there the whole tool call has to fit inside a single
|
|
124
|
+
// POST /mcp response, and every hop in front of us has a shorter fuse.
|
|
125
|
+
//
|
|
126
|
+
// • MCP client request timeout — 60s (SDK DEFAULT_REQUEST_TIMEOUT_MSEC), and
|
|
127
|
+
// it only resets on a progress notification when the client opted into
|
|
128
|
+
// resetTimeoutOnProgress, whose SDK default is false. We cannot make that
|
|
129
|
+
// choice on the host's behalf, so this is the ceiling we must respect.
|
|
130
|
+
// • Cloudflare origin read — 100s. api.kolbo.ai is Cloudflare-proxied.
|
|
131
|
+
// • kolbo-api httpServer.timeout — 120s. Measured against the production
|
|
132
|
+
// settings: a SILENT stream is RST at exactly 120.0s, while a 15s write
|
|
133
|
+
// cadence survives 200s. So progress.tick() does defeat this hop — but no
|
|
134
|
+
// amount of ticking defeats a client timeout that does not reset.
|
|
135
|
+
//
|
|
136
|
+
// Net effect of the old 180s: a 185s music generation made wait=true fail with
|
|
137
|
+
// "the connector's server isn't responding" every single time, on a perfectly
|
|
138
|
+
// healthy paid generation. Returning early with state:"processing" is strictly
|
|
139
|
+
// better than erroring — the caller re-issues and nothing is lost.
|
|
140
|
+
//
|
|
141
|
+
// stdio hosts have no hop in between and do reset on our ticks, so they keep
|
|
142
|
+
// the long window. KOLBO_MCP_WAIT_MS overrides both without a release, if a
|
|
143
|
+
// host ever proves tighter still.
|
|
144
|
+
const TRANSPORT_CEILING_MS = 60000;
|
|
145
|
+
// Headroom for everything that happens AFTER the last poll and before the
|
|
146
|
+
// response is on the wire: the final status read, addDisplayNames' catalog
|
|
147
|
+
// lookups, JSON serialization.
|
|
148
|
+
const RESULT_ASSEMBLY_BUDGET_MS = 10000;
|
|
149
|
+
const REMOTE_WAIT_MS = 45000;
|
|
150
|
+
const STDIO_WAIT_MS = 180000;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @param {object} [options] tool options; `apps === true` is the remote-HTTP
|
|
154
|
+
* transport signal (set only by kolbo-api's connector).
|
|
155
|
+
*/
|
|
156
|
+
function waitWindowMs(options = {}) {
|
|
157
|
+
const override = Number(process.env.KOLBO_MCP_WAIT_MS);
|
|
158
|
+
if (Number.isFinite(override) && override > 0) return override;
|
|
159
|
+
return options.apps === true ? REMOTE_WAIT_MS : STDIO_WAIT_MS;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = {
|
|
163
|
+
pollUntilDone,
|
|
164
|
+
PollingTimeoutError,
|
|
165
|
+
GenerationFailedError,
|
|
166
|
+
waitWindowMs,
|
|
167
|
+
TRANSPORT_CEILING_MS,
|
|
168
|
+
RESULT_ASSEMBLY_BUDGET_MS,
|
|
169
|
+
REMOTE_WAIT_MS,
|
|
170
|
+
};
|
package/src/tools/generate.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
const { z } = require('zod');
|
|
7
7
|
const FormData = require('form-data');
|
|
8
|
-
const { pollUntilDone } = require('../polling');
|
|
8
|
+
const { pollUntilDone, waitWindowMs } = require('../polling');
|
|
9
9
|
const { resolveToBuffer, pollOrTimedOut, creditFields, projectIdField, sessionIdField, inlineImageBlocks, buildOpenUrl, uiGenerating, appsEnabled } = require('./_shared');
|
|
10
10
|
const { UI, uiResult, canonicalModelId, modelInfo, voiceInfo } = require('../apps');
|
|
11
11
|
|
|
@@ -126,6 +126,18 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
126
126
|
// "submitted" response + a live ui://kolbo/generation.html widget that keeps
|
|
127
127
|
// one wait=true status call in flight. Text-only hosts never take this branch.
|
|
128
128
|
const ui = () => appsEnabled(server, options);
|
|
129
|
+
|
|
130
|
+
// How long the STATUS tools may block inside one tool call before handing
|
|
131
|
+
// back a non-terminal result the caller re-issues. Bounded by the transport,
|
|
132
|
+
// not by the generation — full reasoning and the measured numbers live next
|
|
133
|
+
// to the constants in ../polling.js.
|
|
134
|
+
const WAIT_WINDOW_MS = waitWindowMs(options);
|
|
135
|
+
const WAIT_WINDOW_S = Math.round(WAIT_WINDOW_MS / 1000);
|
|
136
|
+
// What to tell a caller holding a still-running generation. Never "don't call
|
|
137
|
+
// again" — for anything longer than the window, calling again IS the protocol.
|
|
138
|
+
const stillRunningHint = (idsPhrase) =>
|
|
139
|
+
`Still running — this is NOT a failure and no credits were lost. Each wait=true call blocks for at most ~${WAIT_WINDOW_S}s and then returns whatever the state is, so a long job (music ~3 min, video can be longer) legitimately needs SEVERAL wait=true calls in a row. Call get_generation_status again with wait=true${idsPhrase}. Do not spin with wait=false, and do not re-run the generation tool.`;
|
|
140
|
+
|
|
129
141
|
// ─── generate_image ────────────────────────────────────────
|
|
130
142
|
server.tool(
|
|
131
143
|
'generate_image',
|
|
@@ -367,10 +379,10 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
367
379
|
// blocking poll window) needs this tool to be re-checked until done.
|
|
368
380
|
server.tool(
|
|
369
381
|
'get_creative_director_status',
|
|
370
|
-
'Check the status of a Creative Director batch (from generate_creative_director) by its generation_id. Returns overall state ("processing" until EVERY scene is terminal, then "completed"/"failed") plus each scene\'s number, title, per-scene status, and image_urls/video_urls. Set wait=true to block
|
|
382
|
+
'Check the status of a Creative Director batch (from generate_creative_director) by its generation_id. Returns overall state ("processing" until EVERY scene is terminal, then "completed"/"failed") plus each scene\'s number, title, per-scene status, and image_urls/video_urls. Set wait=true to block until the batch is terminal or the wait window closes, whichever comes first — a batch longer than one window returns state="processing" and you simply call again with wait=true. Prefer this over the generic get_generation_status for Creative Director ids — the generic tool now returns the same scene data (it delegates here), but this one is the direct route.',
|
|
371
383
|
{
|
|
372
384
|
generation_id: z.string().describe('The Creative Director generation_id returned by generate_creative_director.'),
|
|
373
|
-
wait: z.boolean().optional().describe(
|
|
385
|
+
wait: z.boolean().optional().describe(`If true, block until the batch is terminal, for at most ~${WAIT_WINDOW_S}s per call. A batch that outlives one window comes back state="processing" (not an error) — call again with wait=true until it is terminal. Always prefer this over polling with wait=false.`)
|
|
374
386
|
},
|
|
375
387
|
async ({ generation_id, wait }) => {
|
|
376
388
|
const statusUrl = `/v1/generate/creative-director/${encodeURIComponent(generation_id)}/status`;
|
|
@@ -379,7 +391,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
379
391
|
try {
|
|
380
392
|
status = await pollUntilDone(client, generation_id, {
|
|
381
393
|
interval: 15000,
|
|
382
|
-
timeout:
|
|
394
|
+
timeout: WAIT_WINDOW_MS,
|
|
383
395
|
statusUrl
|
|
384
396
|
});
|
|
385
397
|
} catch (err) {
|
|
@@ -409,7 +421,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
409
421
|
completed_scenes: completed,
|
|
410
422
|
_hint: status.state === 'completed'
|
|
411
423
|
? 'All scenes terminal. Every completed scene\'s image_urls/video_urls are final.'
|
|
412
|
-
:
|
|
424
|
+
: `Still running — not a failure. Each wait=true call blocks for at most ~${WAIT_WINDOW_S}s, and a video batch routinely outlasts several windows, so call get_creative_director_status again with wait=true and keep going until state is terminal. Scenes that already carry image_urls/video_urls are done; never re-run generate_creative_director.`
|
|
413
425
|
}, null, 2) }] };
|
|
414
426
|
}
|
|
415
427
|
);
|
|
@@ -817,11 +829,11 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
817
829
|
// ─── get_generation_status ─────────────────────────────────
|
|
818
830
|
server.tool(
|
|
819
831
|
'get_generation_status',
|
|
820
|
-
|
|
832
|
+
`Check the status of one or more generations. Use after a generation tool returned "submitted" (widget hosts) or timed out. Tracking SEVERAL concurrent generations? Pass them ALL in generation_ids — one call returns an all_done summary. Need the final result? Set wait=true and the server blocks until every generation finishes, for at most ~${WAIT_WINDOW_S}s per call. A job that outlives one window (music is ~3 min, video longer) comes back state="processing" — that is a normal result, not an error: call again with wait=true and keep going until every id is terminal. Never poll with wait=false in a loop.`,
|
|
821
833
|
{
|
|
822
834
|
generation_id: z.string().optional().describe('A single generation ID to check'),
|
|
823
835
|
generation_ids: z.array(z.string()).optional().describe('Multiple generation IDs to check in ONE call. Returns { all_done, pending, generations[] } — always prefer this over checking IDs one by one.'),
|
|
824
|
-
wait: z.boolean().optional().describe(
|
|
836
|
+
wait: z.boolean().optional().describe(`If true, block until every generation reaches a terminal state (completed/failed), for at most ~${WAIT_WINDOW_S}s per call, then return whatever state they are in. Anything still processing is reported, not errored — re-issue with wait=true and only the still-pending ids. This is always better than polling with wait=false.`)
|
|
825
837
|
},
|
|
826
838
|
async ({ generation_id, generation_ids, wait }) => {
|
|
827
839
|
const ids = (generation_ids && generation_ids.length > 0)
|
|
@@ -836,15 +848,16 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
836
848
|
if (wait) {
|
|
837
849
|
// Widgets use this long-wait path. A 15s API check cadence keeps
|
|
838
850
|
// completion responsive without multiplying backend traffic for
|
|
839
|
-
// every card left open in a host conversation.
|
|
840
|
-
|
|
851
|
+
// every card left open in a host conversation. The window itself is
|
|
852
|
+
// bounded by the transport — see WAIT_WINDOW_MS above.
|
|
853
|
+
const result = await pollUntilDone(client, id, { interval: 15000, timeout: WAIT_WINDOW_MS });
|
|
841
854
|
return { generation_id: id, ...result };
|
|
842
855
|
}
|
|
843
856
|
const result = await client.get(`/v1/generate/${encodeURIComponent(id)}/status`);
|
|
844
857
|
return { generation_id: id, ...result };
|
|
845
858
|
} catch (err) {
|
|
846
859
|
if (err.timedOut) {
|
|
847
|
-
return { generation_id: id, state: 'processing', _timed_out: true, note:
|
|
860
|
+
return { generation_id: id, state: 'processing', _timed_out: true, note: `Still running after this ~${WAIT_WINDOW_S}s wait window — call get_generation_status again with wait=true.` };
|
|
848
861
|
}
|
|
849
862
|
if (err.name === 'GenerationFailedError') {
|
|
850
863
|
return { generation_id: id, state: 'failed', error: err.message };
|
|
@@ -858,9 +871,15 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
858
871
|
|
|
859
872
|
const pending = results.filter(r => r.state !== 'completed' && r.state !== 'failed' && r.state !== 'cancelled');
|
|
860
873
|
const doneHint = 'ALL generations are in a final state — do NOT poll again. Report the results to the user.';
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
874
|
+
// The old wait=false hint said "call it ONCE with wait=true ... to block
|
|
875
|
+
// until they finish". That is the advice that broke: one wait=true call
|
|
876
|
+
// cannot outlast a 185s music job, and a caller that obeyed it got a
|
|
877
|
+
// transport error instead of a result. Say what actually works.
|
|
878
|
+
const pendingIds = pending.map(r => r.generation_id);
|
|
879
|
+
const idsPhrase = pendingIds.length > 1
|
|
880
|
+
? ` and ONLY the still-pending ids: ${JSON.stringify(pendingIds)}`
|
|
881
|
+
: '';
|
|
882
|
+
const pendingHint = stillRunningHint(idsPhrase);
|
|
864
883
|
|
|
865
884
|
// Single-id calls keep the original flat shape — the generation widget
|
|
866
885
|
// waits on this tool with { generation_id, wait:true } and reads
|
|
@@ -968,10 +987,13 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
968
987
|
image_url: z.string().describe('Public URL of the keyframe image'),
|
|
969
988
|
timestamp_seconds: z.number().describe('Moment on the OUTPUT timeline (seconds, 0 = first frame) where this image is pinned')
|
|
970
989
|
})).optional().describe('Timeline-pinned keyframes for multi-keyframe models (e.g. "flux-3-keyframes", "luma-ray-3-2-storyboard"): the model generates the motion BETWEEN the pinned images. Only models with `supports_keyframes: true` in list_models accept this; cap = `max_keyframes` (FLUX 3: 10). Requires an explicit `duration` — timestamps beyond it are clamped. Ignored by ordinary elements models. OPTIONAL for flux-3-keyframes: if omitted, pass the images via reference_images instead — they are played through IN ORDER, timed from timing language in the prompt or spaced evenly. Pass explicit keyframes only when you need exact control.'),
|
|
990
|
+
multi_shots: z.boolean().optional().describe('Enable Multishot metadata/UI badge (and native provider flag when the model supportsMultiShot). Never rewrites or reshapes the prompt — craft shot structure yourself (`Multishot ON`, Total/SHOT lines). MCP default enhance_prompt=false keeps the prompt raw; multi_shots alone is badge/flag only. For crafted demos: enhance_prompt:false + multi_shots:true.'),
|
|
991
|
+
multi_shot_count: z.number().optional().describe('Explicit shot count when multi_shots=true (e.g. 2). Clamped to ~2 shots/sec of duration.'),
|
|
992
|
+
session_name: z.string().optional().describe('Short sidebar session title (max ~60 chars), e.g. "UGC — Hana bathroom light". When set, skips AI auto-naming from the prompt boilerplate.'),
|
|
971
993
|
project_id: projectIdField,
|
|
972
994
|
session_id: sessionIdField
|
|
973
995
|
},
|
|
974
|
-
async ({ prompt, model, reference_images, reference_videos, reference_audio_urls, audio_url, files, duration, aspect_ratio, motion, preset_id, enhance_prompt = false, visual_dna_ids, resolution, keyframes, project_id, session_id }) => {
|
|
996
|
+
async ({ prompt, model, reference_images, reference_videos, reference_audio_urls, audio_url, files, duration, aspect_ratio, motion, preset_id, enhance_prompt = false, visual_dna_ids, resolution, keyframes, multi_shots, multi_shot_count, session_name, project_id, session_id }) => {
|
|
975
997
|
model = await canonicalModelId(client, model, 'elements'); // lenient id resolution ("z-image" → "z-image/turbo")
|
|
976
998
|
if (!prompt) throw new Error('prompt is required');
|
|
977
999
|
|
|
@@ -994,6 +1016,9 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
994
1016
|
if (audio_url) form.append('audio_url', audio_url);
|
|
995
1017
|
if (resolution) form.append('resolution', resolution);
|
|
996
1018
|
if (keyframes) form.append('keyframes', JSON.stringify(keyframes));
|
|
1019
|
+
if (multi_shots !== undefined) form.append('multi_shots', String(multi_shots));
|
|
1020
|
+
if (multi_shot_count !== undefined) form.append('multi_shot_count', String(multi_shot_count));
|
|
1021
|
+
if (session_name) form.append('session_name', session_name);
|
|
997
1022
|
if (project_id) form.append('project_id', project_id);
|
|
998
1023
|
if (session_id) form.append('session_id', session_id);
|
|
999
1024
|
for (const f of resolved) {
|
|
@@ -1003,7 +1028,7 @@ function registerGenerateTools(server, client, options = {}) {
|
|
|
1003
1028
|
} else {
|
|
1004
1029
|
// URL-only mode: plain JSON.
|
|
1005
1030
|
startResponse = await client.post('/v1/generate/elements', {
|
|
1006
|
-
prompt, model, reference_images, reference_videos, reference_audio_urls, audio_url, duration, aspect_ratio, motion, preset_id, enhance_prompt, visual_dna_ids, resolution, keyframes, project_id, session_id
|
|
1031
|
+
prompt, model, reference_images, reference_videos, reference_audio_urls, audio_url, duration, aspect_ratio, motion, preset_id, enhance_prompt, visual_dna_ids, resolution, keyframes, multi_shots, multi_shot_count, session_name, project_id, session_id
|
|
1007
1032
|
});
|
|
1008
1033
|
}
|
|
1009
1034
|
|
package/src/tools/visual_dna.js
CHANGED
|
@@ -197,7 +197,7 @@ function registerVisualDnaTools(server, client, options = {}) {
|
|
|
197
197
|
'Generate a reference sheet for a Visual DNA from 1+ reference image URLs — the same step the in-app Visual DNA wizard offers, for EVERY DNA type via `sheet_type`: character = multi-angle turnaround, product = angles + branding/material/construction close-ups, environment = location angles + one signature detail, style = a style board (the same look applied to six varied subjects). The sheet is the single strongest consistency booster for a DNA, and it always preserves the reference\'s original art style (2D stays 2D, photo stays photo). CHARGES CREDITS, so when the user is about to create a DNA, OFFER this first ("want me to generate a reference sheet for stronger consistency? it costs a few credits") and only run it on a yes. Returns `character_sheet_url` — pass it as `character_sheet_url` to `create_visual_dna` with the matching `dna_type`.',
|
|
198
198
|
{
|
|
199
199
|
image_urls: z.array(z.string()).min(1).describe('Reference image URLs of the subject (for characters: front/side/varied angles work best). Use generated-image URLs or upload_media output.'),
|
|
200
|
-
sheet_type: z.enum(['character', 'product', 'environment', 'style']).optional().describe('
|
|
200
|
+
sheet_type: z.enum(['character', 'character_headless', 'product', 'environment', 'style']).optional().describe('Sheet layout. character = front/back/face turnaround. character_headless = wardrobe/body refs with a headless front panel (use when clothing must change without fighting the face sheet). product / environment / style = matching DNA types. Defaults to character.')
|
|
201
201
|
},
|
|
202
202
|
async ({ image_urls, sheet_type }) => {
|
|
203
203
|
const result = await client.post('/v1/visual-dna/character-sheet', { image_urls, ...(sheet_type ? { sheet_type } : {}) });
|