@slatesvideo/shared 0.6.1 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/clients/blender.d.ts +50 -0
- package/dist/clients/blender.js +195 -0
- package/dist/operations/index.d.ts +24 -0
- package/dist/operations/index.js +244 -1
- package/dist/prompts/reference-composer.d.ts +57 -0
- package/dist/prompts/reference-composer.js +70 -1
- package/dist/skills/content.js +5 -0
- package/package.json +1 -1
- package/skills/slates-blocking-to-prompt.md +250 -0
- package/skills/slates-camera-language.md +196 -0
- package/skills/slates-dialogue-blocking.md +134 -0
- package/skills/slates-previs-blocking.md +153 -0
- package/skills/slates-restyle-from-blocking.md +121 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export declare const RENDER_TIMEOUT_MS: number;
|
|
2
|
+
/**
|
|
3
|
+
* Where the add-on comes from, and how to switch it on. ONE string.
|
|
4
|
+
*
|
|
5
|
+
* 🚨 IT WAS TWO, AND THAT IS ALWAYS A DRIFT. The op layer had its own
|
|
6
|
+
* differently-worded copy naming the same URL, so a moved download page or a
|
|
7
|
+
* renamed panel button would have had to be found in two files by someone who
|
|
8
|
+
* remembered both existed. Same fact, same sentence, one home.
|
|
9
|
+
*
|
|
10
|
+
* `slates.video/blender` is a real page as of 2026-08-28 (`slates-web`
|
|
11
|
+
* `src/app/blender/page.tsx`), and it is the ONLY thing that tells a stuck user
|
|
12
|
+
* where the add-on comes from. It is a hard dependency of this string, not a
|
|
13
|
+
* nice-to-have: keep the path or 301 it.
|
|
14
|
+
*/
|
|
15
|
+
export declare const BLENDER_SETUP_HINT: string;
|
|
16
|
+
export declare class BlenderNotRunningError extends Error {
|
|
17
|
+
constructor();
|
|
18
|
+
}
|
|
19
|
+
/** Raised when Blender executed the code and Python threw. Carries the traceback. */
|
|
20
|
+
export declare class BlenderExecError extends Error {
|
|
21
|
+
readonly stdout: string;
|
|
22
|
+
readonly stderr: string;
|
|
23
|
+
constructor(message: string, stdout?: string, stderr?: string);
|
|
24
|
+
}
|
|
25
|
+
export declare class BlenderBridgeClient {
|
|
26
|
+
/**
|
|
27
|
+
* Execute Python in Blender and return whatever the code assigned to
|
|
28
|
+
* `result`. `strict_json` is false so a stray Blender object comes back as
|
|
29
|
+
* its repr instead of failing the whole call — the agent can then correct
|
|
30
|
+
* itself, which it cannot do if the error is about serialization.
|
|
31
|
+
*/
|
|
32
|
+
execute(code: string, opts?: {
|
|
33
|
+
timeoutMs?: number;
|
|
34
|
+
}): Promise<{
|
|
35
|
+
result: unknown;
|
|
36
|
+
stdout: string;
|
|
37
|
+
stderr: string;
|
|
38
|
+
}>;
|
|
39
|
+
/**
|
|
40
|
+
* Execute code with the add-on package bound to `_slates`, so ops can call
|
|
41
|
+
* the shipped helpers (`_slates.previs`, `_slates.scene`, `_slates.docs`)
|
|
42
|
+
* instead of re-implementing them as inline Python string blobs.
|
|
43
|
+
*/
|
|
44
|
+
call(body: string, opts?: {
|
|
45
|
+
timeoutMs?: number;
|
|
46
|
+
}): Promise<unknown>;
|
|
47
|
+
/** True when a Blender with the add-on is reachable. Never throws. */
|
|
48
|
+
isReachable(): Promise<boolean>;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=blender.d.ts.map
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
// Thin client for the Slates Blender add-on's localhost execution bridge.
|
|
3
|
+
//
|
|
4
|
+
// Wire protocol (matches the add-on's `bridge/server.py`, which is Blender
|
|
5
|
+
// Lab's `blender_mcp` protocol):
|
|
6
|
+
//
|
|
7
|
+
// -> {"type":"execute","code":"...","strict_json":false}\0
|
|
8
|
+
// <- {"status":"ok","result":{...},"stdout":"...","stderr":"..."}\0
|
|
9
|
+
// <- {"status":"error","message":"<traceback>"}\0
|
|
10
|
+
//
|
|
11
|
+
// Requests and responses are NUL-delimited JSON over a plain TCP socket. The
|
|
12
|
+
// add-on executes on Blender's main thread via a timer, so a request is
|
|
13
|
+
// serviced within a tick rather than immediately — the socket stays open until
|
|
14
|
+
// the reply lands, and long jobs (renders) hold it open for as long as they
|
|
15
|
+
// take. That is why the timeout here is generous and configurable per call
|
|
16
|
+
// rather than a single global value.
|
|
17
|
+
const HOST = '127.0.0.1';
|
|
18
|
+
// The add-on binds the first free port in this range, so a second Blender (or
|
|
19
|
+
// a stale process holding 9876) shifts it forward instead of failing. We probe
|
|
20
|
+
// the same range in the same order.
|
|
21
|
+
const BASE_PORT = 9876;
|
|
22
|
+
const PORT_FALLBACKS = 3;
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
24
|
+
// Renders are the one call that legitimately runs for minutes.
|
|
25
|
+
export const RENDER_TIMEOUT_MS = 15 * 60_000;
|
|
26
|
+
const CONNECT_TIMEOUT_MS = 1_500;
|
|
27
|
+
/**
|
|
28
|
+
* Where the add-on comes from, and how to switch it on. ONE string.
|
|
29
|
+
*
|
|
30
|
+
* 🚨 IT WAS TWO, AND THAT IS ALWAYS A DRIFT. The op layer had its own
|
|
31
|
+
* differently-worded copy naming the same URL, so a moved download page or a
|
|
32
|
+
* renamed panel button would have had to be found in two files by someone who
|
|
33
|
+
* remembered both existed. Same fact, same sentence, one home.
|
|
34
|
+
*
|
|
35
|
+
* `slates.video/blender` is a real page as of 2026-08-28 (`slates-web`
|
|
36
|
+
* `src/app/blender/page.tsx`), and it is the ONLY thing that tells a stuck user
|
|
37
|
+
* where the add-on comes from. It is a hard dependency of this string, not a
|
|
38
|
+
* nice-to-have: keep the path or 301 it.
|
|
39
|
+
*/
|
|
40
|
+
export const BLENDER_SETUP_HINT = 'Install the Slates add-on from https://slates.video/blender, then in the 3D ' +
|
|
41
|
+
'viewport sidebar (press N) open the Slates tab and click Start Bridge.';
|
|
42
|
+
const NOT_RUNNING_MESSAGE = 'No Blender with the Slates add-on is listening on ' +
|
|
43
|
+
`${HOST}:${BASE_PORT}-${BASE_PORT + PORT_FALLBACKS}. ` +
|
|
44
|
+
BLENDER_SETUP_HINT;
|
|
45
|
+
export class BlenderNotRunningError extends Error {
|
|
46
|
+
constructor() {
|
|
47
|
+
super(NOT_RUNNING_MESSAGE);
|
|
48
|
+
this.name = 'BlenderNotRunningError';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Raised when Blender executed the code and Python threw. Carries the traceback. */
|
|
52
|
+
export class BlenderExecError extends Error {
|
|
53
|
+
stdout;
|
|
54
|
+
stderr;
|
|
55
|
+
constructor(message, stdout = '', stderr = '') {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = 'BlenderExecError';
|
|
58
|
+
this.stdout = stdout;
|
|
59
|
+
this.stderr = stderr;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Resolve the installed add-on package regardless of what Blender named it.
|
|
64
|
+
*
|
|
65
|
+
* Extensions are imported as `bl_ext.<repo>.slates_blender`, and the repo
|
|
66
|
+
* segment depends on where the user installed from — so the name cannot be
|
|
67
|
+
* hardcoded. Scanning `sys.modules` for the root package is the only stable
|
|
68
|
+
* handle. Submodules (`...slates_blender.bridge`) do not match the suffix, so
|
|
69
|
+
* the generator yields exactly the root.
|
|
70
|
+
*/
|
|
71
|
+
const PRELUDE = `
|
|
72
|
+
import sys as _sys
|
|
73
|
+
from importlib import import_module as _import_module
|
|
74
|
+
_slates = next(
|
|
75
|
+
(m for n, m in list(_sys.modules.items())
|
|
76
|
+
if n.endswith('slates_blender') and m is not None),
|
|
77
|
+
None,
|
|
78
|
+
)
|
|
79
|
+
if _slates is None:
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
'The Slates Blender add-on is not loaded in this Blender. '
|
|
82
|
+
'Enable it in Edit > Preferences > Add-ons.'
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def _mod(_name):
|
|
86
|
+
"""Import a submodule of the add-on by short name.
|
|
87
|
+
|
|
88
|
+
The root package only imports \`bridge\` at load time — everything else is
|
|
89
|
+
lazy so enabling the add-on stays cheap — so \`_slates.previs\` is not
|
|
90
|
+
reliably an attribute. Go through importlib rather than assuming it is.
|
|
91
|
+
"""
|
|
92
|
+
return _import_module(_slates.__name__ + '.' + _name)
|
|
93
|
+
`.trim();
|
|
94
|
+
function connect(port) {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const socket = new net.Socket();
|
|
97
|
+
const onError = (err) => {
|
|
98
|
+
socket.destroy();
|
|
99
|
+
reject(err);
|
|
100
|
+
};
|
|
101
|
+
socket.setTimeout(CONNECT_TIMEOUT_MS, () => onError(new Error('connect timeout')));
|
|
102
|
+
socket.once('error', onError);
|
|
103
|
+
socket.connect(port, HOST, () => {
|
|
104
|
+
socket.setTimeout(0);
|
|
105
|
+
socket.removeListener('error', onError);
|
|
106
|
+
resolve(socket);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
async function connectAny() {
|
|
111
|
+
for (let port = BASE_PORT; port <= BASE_PORT + PORT_FALLBACKS; port++) {
|
|
112
|
+
try {
|
|
113
|
+
return await connect(port);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
// Try the next port in the range.
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
throw new BlenderNotRunningError();
|
|
120
|
+
}
|
|
121
|
+
function request(socket, payload, timeoutMs) {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
let buffer = '';
|
|
124
|
+
let settled = false;
|
|
125
|
+
const finish = (fn) => {
|
|
126
|
+
if (settled)
|
|
127
|
+
return;
|
|
128
|
+
settled = true;
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
socket.destroy();
|
|
131
|
+
fn();
|
|
132
|
+
};
|
|
133
|
+
const timer = setTimeout(() => finish(() => reject(new Error(`Blender did not respond within ${Math.round(timeoutMs / 1000)}s. ` +
|
|
134
|
+
'It may be busy with a modal operator — check the Blender window.'))), timeoutMs);
|
|
135
|
+
socket.setEncoding('utf8');
|
|
136
|
+
socket.on('data', (chunk) => {
|
|
137
|
+
buffer += chunk;
|
|
138
|
+
const end = buffer.indexOf('\0');
|
|
139
|
+
if (end === -1)
|
|
140
|
+
return;
|
|
141
|
+
const raw = buffer.slice(0, end);
|
|
142
|
+
finish(() => {
|
|
143
|
+
try {
|
|
144
|
+
resolve(JSON.parse(raw));
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
reject(new Error(`Malformed reply from Blender: ${err.message}`));
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
socket.on('error', (err) => finish(() => reject(err)));
|
|
152
|
+
socket.on('close', () => finish(() => reject(new Error('Blender closed the connection before replying.'))));
|
|
153
|
+
socket.write(payload + '\0');
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
export class BlenderBridgeClient {
|
|
157
|
+
/**
|
|
158
|
+
* Execute Python in Blender and return whatever the code assigned to
|
|
159
|
+
* `result`. `strict_json` is false so a stray Blender object comes back as
|
|
160
|
+
* its repr instead of failing the whole call — the agent can then correct
|
|
161
|
+
* itself, which it cannot do if the error is about serialization.
|
|
162
|
+
*/
|
|
163
|
+
async execute(code, opts) {
|
|
164
|
+
const socket = await connectAny();
|
|
165
|
+
const payload = JSON.stringify({ type: 'execute', code, strict_json: false });
|
|
166
|
+
const res = await request(socket, payload, opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
167
|
+
const stdout = res.stdout ?? '';
|
|
168
|
+
const stderr = res.stderr ?? '';
|
|
169
|
+
if (res.status !== 'ok') {
|
|
170
|
+
throw new BlenderExecError(res.message ?? 'Blender reported an error', stdout, stderr);
|
|
171
|
+
}
|
|
172
|
+
return { result: res.result, stdout, stderr };
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Execute code with the add-on package bound to `_slates`, so ops can call
|
|
176
|
+
* the shipped helpers (`_slates.previs`, `_slates.scene`, `_slates.docs`)
|
|
177
|
+
* instead of re-implementing them as inline Python string blobs.
|
|
178
|
+
*/
|
|
179
|
+
async call(body, opts) {
|
|
180
|
+
const { result } = await this.execute(`${PRELUDE}\n${body}`, opts);
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
/** True when a Blender with the add-on is reachable. Never throws. */
|
|
184
|
+
async isReachable() {
|
|
185
|
+
try {
|
|
186
|
+
const socket = await connectAny();
|
|
187
|
+
socket.destroy();
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
//# sourceMappingURL=blender.js.map
|
|
@@ -277,6 +277,7 @@ export declare const generateVideo: Operation<{
|
|
|
277
277
|
videoReferenceAssetIds?: string[];
|
|
278
278
|
videoReferenceSecondsEach?: number[];
|
|
279
279
|
audioReferenceAssetIds?: string[];
|
|
280
|
+
audioReferenceSpokenText?: string[];
|
|
280
281
|
sound?: boolean;
|
|
281
282
|
audioLanguage?: 'EN' | 'ZH' | 'JA' | 'KO' | 'ES';
|
|
282
283
|
generateMusic?: boolean;
|
|
@@ -538,6 +539,29 @@ export declare const deleteFrame: Operation<{
|
|
|
538
539
|
export declare const getPromptingGuide: Operation<{
|
|
539
540
|
topic: string;
|
|
540
541
|
}>;
|
|
542
|
+
export declare const blenderStatus: Operation<Record<string, never>>;
|
|
543
|
+
export declare const blenderExecute: Operation<{
|
|
544
|
+
code: string;
|
|
545
|
+
timeoutSeconds?: number;
|
|
546
|
+
}>;
|
|
547
|
+
export declare const blenderScene: Operation<Record<string, never>>;
|
|
548
|
+
export declare const blenderDocs: Operation<{
|
|
549
|
+
identifier: string;
|
|
550
|
+
}>;
|
|
551
|
+
export declare const blenderSearchDocs: Operation<{
|
|
552
|
+
query: string;
|
|
553
|
+
scope?: 'api' | 'manual';
|
|
554
|
+
maxResults?: number;
|
|
555
|
+
}>;
|
|
556
|
+
export declare const blenderRenderBlocking: Operation<{
|
|
557
|
+
projectId?: string;
|
|
558
|
+
resolutionX?: number;
|
|
559
|
+
resolutionY?: number;
|
|
560
|
+
fps?: number;
|
|
561
|
+
frameStart?: number;
|
|
562
|
+
frameEnd?: number;
|
|
563
|
+
basename?: string;
|
|
564
|
+
}>;
|
|
541
565
|
export declare const ALL_OPERATIONS: ReadonlyArray<Operation<unknown>>;
|
|
542
566
|
export {};
|
|
543
567
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/operations/index.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import { z } from 'zod';
|
|
13
13
|
import { SlatesCloudClient } from '../clients/cloud.js';
|
|
14
14
|
import { SlatesDesktopClient } from '../clients/desktop.js';
|
|
15
|
+
import { BlenderBridgeClient, BLENDER_SETUP_HINT, RENDER_TIMEOUT_MS } from '../clients/blender.js';
|
|
15
16
|
import { SKILLS } from '../skills/content.js';
|
|
16
17
|
// Reference-capacity prose is DERIVED, never hand-typed — root CLAUDE.md:
|
|
17
18
|
// "never hand-type a fact an LLM will read". These helpers read MODEL_FACTS.
|
|
@@ -1813,6 +1814,33 @@ function resolveVideoModel(raw) {
|
|
|
1813
1814
|
}
|
|
1814
1815
|
return null;
|
|
1815
1816
|
}
|
|
1817
|
+
/**
|
|
1818
|
+
* Pair each reference-audio asset id with the words spoken in it.
|
|
1819
|
+
*
|
|
1820
|
+
* 🚨 THE MODEL RE-TRANSCRIBES A SUPPLIED TAKE. Seedance does not consume
|
|
1821
|
+
* reference audio verbatim — it re-synthesises something close to it, and a
|
|
1822
|
+
* 2026-08-28 field test heard "an app called Slates" come back as "a map called
|
|
1823
|
+
* Slates". The clip carries the voice, the accent and the timing; only text
|
|
1824
|
+
* carries the words. This is the text, and without it every generation with a
|
|
1825
|
+
* voice take has its line guessed.
|
|
1826
|
+
*
|
|
1827
|
+
* Positional in, KEYED out: the desktop route merges its deprecated singular
|
|
1828
|
+
* `audioReferenceAssetId` onto the END of the plural list, so an index would
|
|
1829
|
+
* address different clips depending on which shape the caller used. Blank
|
|
1830
|
+
* entries are dropped rather than sent as empty strings — a clip with no
|
|
1831
|
+
* speech has no line, which is not the same as a line that is empty.
|
|
1832
|
+
*/
|
|
1833
|
+
function spokenTextByAssetId(assetIds, spoken) {
|
|
1834
|
+
if (!assetIds?.length || !spoken?.length)
|
|
1835
|
+
return undefined;
|
|
1836
|
+
const out = {};
|
|
1837
|
+
assetIds.forEach((id, i) => {
|
|
1838
|
+
const text = spoken[i]?.trim();
|
|
1839
|
+
if (id && text)
|
|
1840
|
+
out[id] = text;
|
|
1841
|
+
});
|
|
1842
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
1843
|
+
}
|
|
1816
1844
|
// Maps a video model id to its bundled prompting skill (frontmatter `name:`),
|
|
1817
1845
|
// so guidance text points at a skill that actually exists. Deriving the name
|
|
1818
1846
|
// via model.split('-')[0] produced 'slates-prompting-kling' / '...-veo', which
|
|
@@ -1877,6 +1905,7 @@ export const generateVideo = {
|
|
|
1877
1905
|
videoReferenceAssetIds: z.array(z.string()).optional().describe(`Reference VIDEOS (UUIDs or badge codes) read alongside the images and audio in the same generation — own-footage restyle, MOTION TRANSFER ("the character from image 1 performs the motion from video 1"), or dialogue conditioning. Cited in the prompt as "video 1", "video 2"… in the order given. ${multimodalRefModels().join(' / ')} only; ignored elsewhere. ${multimodalRefSummary('seedance-2')} ${multimodalRefSummary('seedance-2.5')} Billing switches to combined input+output seconds (the vref key) — pass videoReferenceSecondsEach so the quote is right. If any clip contains a human/AI character, pair with seedanceFace=true (the default Seedance route blocks people). Over the cap is REFUSED, never trimmed: a dropped clip would already have been priced in.`),
|
|
1878
1906
|
videoReferenceSecondsEach: z.array(z.number()).optional().describe('REQUIRED with videoReferenceAssetIds, same order and length: each reference clip\'s duration in seconds (from the asset listing). Feeds the vref cost key — the bill is Σceil(each) + output seconds. The server re-derives this by probing every uploaded clip, so an understated value just gets corrected upward.'),
|
|
1879
1907
|
audioReferenceAssetIds: z.array(z.string()).optional().describe(`Reference AUDIO clips (UUIDs or badge codes) read alongside the images and video — e.g. lip-sync a character to a line ("the character in image 1 speaks the dialogue from audio 1"). Cited as "audio 1", "audio 2"… in the order given. No billing surcharge (Seedance audio is included). ${multimodalRefSummary('seedance-2')} ${multimodalRefSummary('seedance-2.5')}`),
|
|
1908
|
+
audioReferenceSpokenText: z.array(z.string()).optional().describe('STRONGLY RECOMMENDED whenever a reference clip contains SPEECH. Same order and length as audioReferenceAssetIds; use "" for a clip with no words (music, ambience, room tone). The model RE-TRANSCRIBES a supplied take rather than using it verbatim — a field test heard "an app called Slates" come back as "a map called Slates" — so the audio decides the VOICE, the ACCENT and the TIMING while only text decides the WORDS. Give the exact line here and it is quoted into the prompt beside the citation. Omit it and the words are a guess. Pairs with the plural audioReferenceAssetIds; the deprecated singular audioReferenceAssetId carries no text.'),
|
|
1880
1909
|
sound: z.boolean().optional().describe('Kling Omni / Veo / Seedance: enable audio generation. Default true.'),
|
|
1881
1910
|
audioLanguage: z.enum(['EN', 'ZH', 'JA', 'KO', 'ES']).optional().describe('Kling Omni only — language for dialogue.'),
|
|
1882
1911
|
generateMusic: z.boolean().optional().describe('Kling Omni only — auto-generate background music.'),
|
|
@@ -2033,6 +2062,20 @@ export const generateVideo = {
|
|
|
2033
2062
|
message: `${input.model} takes at most ${maxTotal} reference files across all modalities (you passed ${refImages + refMedia}).`,
|
|
2034
2063
|
});
|
|
2035
2064
|
}
|
|
2065
|
+
// A misaligned spoken-text array would attach one clip's line to another
|
|
2066
|
+
// and send the model the wrong words with nothing on screen to say so.
|
|
2067
|
+
// Refuse rather than truncate: the whole point of the field is that the
|
|
2068
|
+
// WORDS are exact.
|
|
2069
|
+
if (input.audioReferenceSpokenText &&
|
|
2070
|
+
input.audioReferenceSpokenText.length !== (input.audioReferenceAssetIds?.length ?? 0)) {
|
|
2071
|
+
return ok({
|
|
2072
|
+
requires_clarification: true,
|
|
2073
|
+
missing: [],
|
|
2074
|
+
message: `audioReferenceSpokenText must be the same length as audioReferenceAssetIds ` +
|
|
2075
|
+
`(${input.audioReferenceSpokenText.length} vs ${input.audioReferenceAssetIds?.length ?? 0}) ` +
|
|
2076
|
+
`— it pairs by position. Use "" for a clip with no speech.`,
|
|
2077
|
+
});
|
|
2078
|
+
}
|
|
2036
2079
|
// fal: "Audio cannot be the only reference input; provide at least one
|
|
2037
2080
|
// reference image or video with it."
|
|
2038
2081
|
const audioRefs = (input.audioReferenceAssetIds?.length ?? 0) + (input.audioReferenceAssetId ? 1 : 0);
|
|
@@ -2269,6 +2312,13 @@ export const generateVideo = {
|
|
|
2269
2312
|
audioReferenceAssetId: input.audioReferenceAssetId,
|
|
2270
2313
|
videoReferenceAssetIds: input.videoReferenceAssetIds,
|
|
2271
2314
|
audioReferenceAssetIds: input.audioReferenceAssetIds,
|
|
2315
|
+
// The route keys this by ASSET ID, not by position, because its own
|
|
2316
|
+
// singular/plural merge appends to the END of the list — an index would
|
|
2317
|
+
// mean different clips depending on which shape the caller used. The op
|
|
2318
|
+
// takes the friendlier positional array and re-keys it here, AFTER
|
|
2319
|
+
// `rids()` has resolved badge codes, so the keys are the ids the route
|
|
2320
|
+
// will resolve. A blank entry is dropped rather than sent as "".
|
|
2321
|
+
audioReferenceSpokenText: spokenTextByAssetId(input.audioReferenceAssetIds, input.audioReferenceSpokenText),
|
|
2272
2322
|
sound: input.sound,
|
|
2273
2323
|
audioLanguage: input.audioLanguage,
|
|
2274
2324
|
generateMusic: input.generateMusic,
|
|
@@ -3472,6 +3522,33 @@ function resolveGuideTopic(topic) {
|
|
|
3472
3522
|
if (t === 'slates-character-turnaround' || t === 'character-turnaround') {
|
|
3473
3523
|
return 'slates-character-identity';
|
|
3474
3524
|
}
|
|
3525
|
+
// ⚠️ Previs aliases sit HIGH, before the model-prefix rules below. `dialogue`
|
|
3526
|
+
// already resolves to the Seed Audio guide, and `style`/`camera` words are a
|
|
3527
|
+
// hair away from the style-prompting and model blocks — anchoring these here
|
|
3528
|
+
// keeps a previs ask out of an audio guide.
|
|
3529
|
+
if (t === 'previs' ||
|
|
3530
|
+
t === 'pre-vis' ||
|
|
3531
|
+
t === 'previz' ||
|
|
3532
|
+
t === 'blocking' ||
|
|
3533
|
+
t === 'blockout' ||
|
|
3534
|
+
t === 'greybox' ||
|
|
3535
|
+
t === 'grey-box' ||
|
|
3536
|
+
t === 'graybox' ||
|
|
3537
|
+
t === 'blender') {
|
|
3538
|
+
return 'slates-previs-blocking';
|
|
3539
|
+
}
|
|
3540
|
+
if (t === 'camera' || t === 'camera-moves' || t === 'camera moves' || t === 'shot-list' || t === 'shot list') {
|
|
3541
|
+
return 'slates-camera-language';
|
|
3542
|
+
}
|
|
3543
|
+
if (t === 'blocking-to-prompt' || t === 'previs-prompt' || t === 'reference-video' || t === 'video-to-video' || t === 'v2v') {
|
|
3544
|
+
return 'slates-blocking-to-prompt';
|
|
3545
|
+
}
|
|
3546
|
+
if (t === 'dialogue-blocking' || t === 'dialogue blocking' || t === '180-rule' || t === 'eyelines' || t === 'seating') {
|
|
3547
|
+
return 'slates-dialogue-blocking';
|
|
3548
|
+
}
|
|
3549
|
+
if (t === 'restyle' || t === 're-style' || t === 'style-swap' || t === 'style swap' || t === 'style-variants') {
|
|
3550
|
+
return 'slates-restyle-from-blocking';
|
|
3551
|
+
}
|
|
3475
3552
|
if (t === 'model-selection' ||
|
|
3476
3553
|
t === 'model selection' ||
|
|
3477
3554
|
t === 'which-model' ||
|
|
@@ -3560,7 +3637,7 @@ export const getPromptingGuide = {
|
|
|
3560
3637
|
topic: z
|
|
3561
3638
|
.string()
|
|
3562
3639
|
.min(1)
|
|
3563
|
-
.describe('Guide name, model id, or style name. Guides: slates-model-selection (which model for which job — read before choosing any model), slates-cost-discipline, slates-content-policy, slates-style-prompting, slates-prompting-nano-banana-2, slates-prompting-veo-3, slates-prompting-kling-v3, slates-prompting-seedance, slates-prompting-seed-audio, slates-prompting-elevenlabs, slates-prompting-lip-sync, slates-prompting-motion-transfer, slates-prompting-flux-2-max, slates-prompting-seedream-5-lite, slates-edit-and-iterate, slates-vision-feedback-loop, slates-character-identity, slates-storyboard-from-script, slates-direct-response-ad, slates-one-prompt-film. Style names (photoreal, anime, painterly, 3d-render) resolve to slates-style-prompting.'),
|
|
3640
|
+
.describe('Guide name, model id, or style name. Guides: slates-model-selection (which model for which job — read before choosing any model), slates-cost-discipline, slates-content-policy, slates-style-prompting, slates-prompting-nano-banana-2, slates-prompting-veo-3, slates-prompting-kling-v3, slates-prompting-seedance, slates-prompting-seed-audio, slates-prompting-elevenlabs, slates-prompting-lip-sync, slates-prompting-motion-transfer, slates-prompting-flux-2-max, slates-prompting-seedream-5-lite, slates-edit-and-iterate, slates-vision-feedback-loop, slates-character-identity, slates-storyboard-from-script, slates-direct-response-ad, slates-one-prompt-film. Blender previs (3D-blocked camera control): slates-previs-blocking (start here), slates-camera-language, slates-blocking-to-prompt, slates-dialogue-blocking, slates-restyle-from-blocking. Style names (photoreal, anime, painterly, 3d-render) resolve to slates-style-prompting.'),
|
|
3564
3641
|
}),
|
|
3565
3642
|
async run(input) {
|
|
3566
3643
|
const resolved = resolveGuideTopic(input.topic);
|
|
@@ -3574,6 +3651,159 @@ export const getPromptingGuide = {
|
|
|
3574
3651
|
};
|
|
3575
3652
|
},
|
|
3576
3653
|
};
|
|
3654
|
+
// ── Blender previs ──────────────────────────────────────────────
|
|
3655
|
+
//
|
|
3656
|
+
// The only ops that talk to a third transport: a localhost socket into a
|
|
3657
|
+
// running Blender carrying the Slates add-on. They exist to produce ONE
|
|
3658
|
+
// artifact — a grey-box blocking clip whose asset id goes straight into
|
|
3659
|
+
// `slates_generate_video`'s `videoReferenceAssetIds`, so the model renders a
|
|
3660
|
+
// world around a camera path instead of inventing one.
|
|
3661
|
+
//
|
|
3662
|
+
// 🚨 There is deliberately no camera-move library here. Camera work is written
|
|
3663
|
+
// as `bpy` by the agent through `slates_blender_execute`, against the Blender
|
|
3664
|
+
// API reference the add-on ships. A fixed menu of moves would cap the workflow
|
|
3665
|
+
// at whatever we thought of; code execution plus real docs does not.
|
|
3666
|
+
// The setup sentence and the download URL live in `clients/blender.ts`, beside
|
|
3667
|
+
// the port range they belong to — one home, so a moved page is one edit.
|
|
3668
|
+
const BLENDER_UNAVAILABLE_HINT = `Blender previs needs the Slates Blender add-on running. ${BLENDER_SETUP_HINT}`;
|
|
3669
|
+
export const blenderStatus = {
|
|
3670
|
+
id: 'slates_blender_status',
|
|
3671
|
+
description: 'Check whether a Blender running the Slates add-on is reachable, and if so return its scene summary (timing, camera, collection tree). Call this FIRST in any previs workflow — every other Blender op fails with the same setup message when the bridge is down, and knowing the frame range and fps up front is what keeps the blocking and the prompt timings in agreement.',
|
|
3672
|
+
input: z.object({}),
|
|
3673
|
+
async run() {
|
|
3674
|
+
const client = new BlenderBridgeClient();
|
|
3675
|
+
if (!(await client.isReachable())) {
|
|
3676
|
+
return ok({ connected: false, hint: BLENDER_UNAVAILABLE_HINT });
|
|
3677
|
+
}
|
|
3678
|
+
return ok({ connected: true, scene: await client.call('result = _mod("scene").summary()') });
|
|
3679
|
+
},
|
|
3680
|
+
};
|
|
3681
|
+
export const blenderExecute = {
|
|
3682
|
+
id: 'slates_blender_execute',
|
|
3683
|
+
description: 'Run Python (`bpy`) inside the connected Blender and return whatever the code assigns to a dict named `result`. This is how blocking gets built: primitives, empties, constraints, camera rigs, keyframes, markers. Anything Blender can do, this can do. Assign a dict to `result` to get data back (e.g. `result = {"camera": cam.name}`); print() output comes back separately as stdout. On an exception you get the full traceback — read it, fix the code, retry. Before writing an unfamiliar call, look up its real signature with slates_blender_docs rather than guessing: the add-on ships the Blender 5.1 API reference precisely so you do not have to recall it.',
|
|
3684
|
+
input: z.object({
|
|
3685
|
+
code: z
|
|
3686
|
+
.string()
|
|
3687
|
+
.min(1)
|
|
3688
|
+
.describe('Python source. Assign a JSON-serialisable dict to `result` to return data.'),
|
|
3689
|
+
timeoutSeconds: z
|
|
3690
|
+
.number()
|
|
3691
|
+
.int()
|
|
3692
|
+
.min(5)
|
|
3693
|
+
.max(900)
|
|
3694
|
+
.optional()
|
|
3695
|
+
.describe('How long to wait for Blender (default 60). Raise it for heavy geometry.'),
|
|
3696
|
+
}),
|
|
3697
|
+
async run(input) {
|
|
3698
|
+
const client = new BlenderBridgeClient();
|
|
3699
|
+
const { result, stdout, stderr } = await client.execute(input.code, {
|
|
3700
|
+
timeoutMs: input.timeoutSeconds ? input.timeoutSeconds * 1000 : undefined,
|
|
3701
|
+
});
|
|
3702
|
+
return ok({ result, ...(stdout ? { stdout } : {}), ...(stderr ? { stderr } : {}) });
|
|
3703
|
+
},
|
|
3704
|
+
};
|
|
3705
|
+
export const blenderScene = {
|
|
3706
|
+
id: 'slates_blender_scene',
|
|
3707
|
+
description: 'Scene summary from the connected Blender: frame range, fps, duration, render resolution, the active camera with its keyframe times in both frames and seconds, and the full collection/object tree with transforms and constraints. Cheap — call it freely between edits. The camera keyframe times ARE the cut structure, so read them before writing any shot-by-shot prompt.',
|
|
3708
|
+
input: z.object({}),
|
|
3709
|
+
async run() {
|
|
3710
|
+
return ok(await new BlenderBridgeClient().call('result = _mod("scene").summary()'));
|
|
3711
|
+
},
|
|
3712
|
+
};
|
|
3713
|
+
export const blenderDocs = {
|
|
3714
|
+
id: 'slates_blender_docs',
|
|
3715
|
+
description: 'Look up a dotted Blender Python API identifier in the bundled 5.1 reference — e.g. "bpy.ops.object", "bpy.types.Camera", "bpy.types.FollowPathConstraint". Pass "*" for top-level modules or "bpy.ops.*" to list a namespace. Use this instead of recalling a signature from memory: invented operator names and wrong enum values are the most common way previs code fails, and they fail silently often enough to be worth the lookup.',
|
|
3716
|
+
input: z.object({
|
|
3717
|
+
identifier: z.string().min(1).describe('Dotted identifier, or a namespace wildcard like "bpy.ops.*".'),
|
|
3718
|
+
}),
|
|
3719
|
+
async run(input) {
|
|
3720
|
+
const code = `result = _mod("docs").lookup(${JSON.stringify(input.identifier)})`;
|
|
3721
|
+
return ok(await new BlenderBridgeClient().call(code));
|
|
3722
|
+
},
|
|
3723
|
+
};
|
|
3724
|
+
export const blenderSearchDocs = {
|
|
3725
|
+
id: 'slates_blender_search_docs',
|
|
3726
|
+
description: 'Full-text search of the bundled Blender documentation for when you do not know the identifier yet. scope "api" searches the Python reference; "manual" searches the user manual for concepts and workflow ("how does Follow Path work", "bezier interpolation handles"). Use slates_blender_docs when you know the name and this when you do not.',
|
|
3727
|
+
input: z.object({
|
|
3728
|
+
query: z.string().min(2),
|
|
3729
|
+
scope: z.enum(['api', 'manual']).optional().describe('Default "api".'),
|
|
3730
|
+
maxResults: z.number().int().min(1).max(20).optional().describe('Default 8.'),
|
|
3731
|
+
}),
|
|
3732
|
+
async run(input) {
|
|
3733
|
+
const args = [
|
|
3734
|
+
JSON.stringify(input.query),
|
|
3735
|
+
JSON.stringify(input.scope ?? 'api'),
|
|
3736
|
+
String(input.maxResults ?? 8),
|
|
3737
|
+
].join(', ');
|
|
3738
|
+
return ok(await new BlenderBridgeClient().call(`result = _mod("docs").search(${args})`));
|
|
3739
|
+
},
|
|
3740
|
+
};
|
|
3741
|
+
export const blenderRenderBlocking = {
|
|
3742
|
+
id: 'slates_blender_render_blocking',
|
|
3743
|
+
description: "Render the connected Blender scene camera to a grey-box mp4 — the blocking clip that locks camera motion for generation — and, when projectId is given, import it into that Slates project as a video asset in the same call. The returned asset id goes into slates_generate_video's videoReferenceAssetIds and the returned durationSeconds into videoReferenceSecondsEach. Renders through the SCENE camera using scene render settings, never the user's viewport, so output does not depend on where they left their mouse. Untextured is correct: the clip supplies camera path and timing, the references supply the look. Colour in the blocking is NOTATION, not look — a distinct viewport colour per character is what binds a proxy to its reference image across cuts, and a marked face encodes which way a featureless proxy is facing. State every such mapping in the generation prompt AND state that the colours themselves are not inherited, or the model renders a literally red person. See the slates-previs-blocking and slates-blocking-to-prompt skills. Keep it at or under 30s — seedance-2.5 accepts reference videos up to 30s, the other reference-video models up to 15s.",
|
|
3744
|
+
input: z.object({
|
|
3745
|
+
projectId: z
|
|
3746
|
+
.string()
|
|
3747
|
+
.uuid()
|
|
3748
|
+
.optional()
|
|
3749
|
+
.describe('Import the clip into this project and return its asset. Omit to just get a file path.'),
|
|
3750
|
+
resolutionX: z.number().int().min(256).max(4096).optional().describe('Default 1920.'),
|
|
3751
|
+
resolutionY: z.number().int().min(256).max(4096).optional().describe('Default 1080.'),
|
|
3752
|
+
fps: z.number().int().min(1).max(120).optional().describe('Default 24. Match what the shot list assumes.'),
|
|
3753
|
+
frameStart: z.number().int().optional().describe("Default: the scene's own frame_start."),
|
|
3754
|
+
frameEnd: z.number().int().optional().describe("Default: the scene's own frame_end."),
|
|
3755
|
+
basename: z.string().min(1).max(64).optional().describe('Filename stem. Default "blocking".'),
|
|
3756
|
+
}),
|
|
3757
|
+
async run(input, ctx) {
|
|
3758
|
+
const kwargs = [];
|
|
3759
|
+
if (input.resolutionX !== undefined)
|
|
3760
|
+
kwargs.push(`resolution_x=${input.resolutionX}`);
|
|
3761
|
+
if (input.resolutionY !== undefined)
|
|
3762
|
+
kwargs.push(`resolution_y=${input.resolutionY}`);
|
|
3763
|
+
if (input.fps !== undefined)
|
|
3764
|
+
kwargs.push(`fps=${input.fps}`);
|
|
3765
|
+
if (input.frameStart !== undefined)
|
|
3766
|
+
kwargs.push(`frame_start=${input.frameStart}`);
|
|
3767
|
+
if (input.frameEnd !== undefined)
|
|
3768
|
+
kwargs.push(`frame_end=${input.frameEnd}`);
|
|
3769
|
+
if (input.basename !== undefined)
|
|
3770
|
+
kwargs.push(`basename=${JSON.stringify(input.basename)}`);
|
|
3771
|
+
// 🚨 THE RENDER IS DEFERRED, AND THAT IS WHY THIS IS NOT ONE LINE.
|
|
3772
|
+
// In an interactive Blender `render_blocking` INVOKES the render rather
|
|
3773
|
+
// than executing it — a synchronous animation render driven from the
|
|
3774
|
+
// bridge's own `bpy.app.timers` callback would re-enter the main loop
|
|
3775
|
+
// running it. So it hands back a `check_is_finished` callable instead of
|
|
3776
|
+
// the clip, and assigning that to `check_is_finished` is the bridge's
|
|
3777
|
+
// documented convention for "hold the socket open and answer when the job
|
|
3778
|
+
// lands" (see the add-on's `bridge/deferred.py`). The deferred path wraps
|
|
3779
|
+
// the eventual dict in the SAME `{status, result}` envelope, so everything
|
|
3780
|
+
// downstream of this call is identical either way. Headless Blender has no
|
|
3781
|
+
// job system to poll, renders synchronously, and takes the `else`.
|
|
3782
|
+
const render = (await new BlenderBridgeClient().call(`_previs_result = _mod("previs").render_blocking(${kwargs.join(', ')})\n` +
|
|
3783
|
+
'if callable(_previs_result):\n' +
|
|
3784
|
+
' check_is_finished = _previs_result\n' +
|
|
3785
|
+
'else:\n' +
|
|
3786
|
+
' result = _previs_result\n', { timeoutMs: RENDER_TIMEOUT_MS }));
|
|
3787
|
+
if (!input.projectId) {
|
|
3788
|
+
return ok({
|
|
3789
|
+
...render,
|
|
3790
|
+
next: 'Pass projectId to import this into a Slates project, or call slates_upload_reference_image with type "video".',
|
|
3791
|
+
});
|
|
3792
|
+
}
|
|
3793
|
+
// The same desktop route slates_upload_reference_image uses — the file is
|
|
3794
|
+
// probed on ingest, so duration and dimensions are known immediately.
|
|
3795
|
+
const uploaded = await ctx.desktop().post('/agent/assets/upload', {
|
|
3796
|
+
projectId: input.projectId,
|
|
3797
|
+
filePath: render.filePath,
|
|
3798
|
+
type: 'video',
|
|
3799
|
+
});
|
|
3800
|
+
return ok({
|
|
3801
|
+
...render,
|
|
3802
|
+
asset: uploaded.asset ?? uploaded,
|
|
3803
|
+
next: "Pass the asset's id in slates_generate_video videoReferenceAssetIds, with videoReferenceSecondsEach set to durationSeconds.",
|
|
3804
|
+
});
|
|
3805
|
+
},
|
|
3806
|
+
};
|
|
3577
3807
|
// ── Aggregation ─────────────────────────────────────────────────
|
|
3578
3808
|
export const ALL_OPERATIONS = [
|
|
3579
3809
|
getWorkspaceState,
|
|
@@ -3652,5 +3882,18 @@ export const ALL_OPERATIONS = [
|
|
|
3652
3882
|
batchUpdateFrames,
|
|
3653
3883
|
deleteFrame,
|
|
3654
3884
|
getPromptingGuide,
|
|
3885
|
+
// ── Blender previs, LAST and deliberately ────────────────────────────
|
|
3886
|
+
// This order is not cosmetic: `slate/src/main/studio-agent/ops.ts` maps this
|
|
3887
|
+
// array straight into the Anthropic `tools` array, and that block sits inside
|
|
3888
|
+
// the desktop Studio Agent's PROMPT-CACHED PREFIX. These six landed at the
|
|
3889
|
+
// TOP, which put a third transport nobody without Blender can reach ahead of
|
|
3890
|
+
// `slates_get_workspace_state` in every conversation the app has. They are a
|
|
3891
|
+
// niche lane off the end of the surface, and the list should read that way.
|
|
3892
|
+
blenderStatus,
|
|
3893
|
+
blenderExecute,
|
|
3894
|
+
blenderScene,
|
|
3895
|
+
blenderDocs,
|
|
3896
|
+
blenderSearchDocs,
|
|
3897
|
+
blenderRenderBlocking,
|
|
3655
3898
|
];
|
|
3656
3899
|
//# sourceMappingURL=index.js.map
|
|
@@ -16,6 +16,24 @@ export interface ReferenceGroup {
|
|
|
16
16
|
kind: ReferenceKind;
|
|
17
17
|
/** A group can carry several images for workflows that genuinely need them. */
|
|
18
18
|
media: ReferenceMedia[];
|
|
19
|
+
/**
|
|
20
|
+
* What is SAID in this group's reference audio, typed by the user.
|
|
21
|
+
* `audio-ref` groups only; every other kind ignores it.
|
|
22
|
+
*
|
|
23
|
+
* 🚨 THE MODEL RE-TRANSCRIBES REFERENCE AUDIO AND GUESSES THE WORDS.
|
|
24
|
+
* Seedance does not consume a supplied take verbatim — it re-synthesises
|
|
25
|
+
* something very close to it, and a field test on 2026-08-28 heard "an app
|
|
26
|
+
* called Slates" come back as "a map called Slates". The audio carries the
|
|
27
|
+
* voice, the accent and the timing; only TEXT carries the words. Composing
|
|
28
|
+
* this line is the whole fix, and every user who attached a voice take since
|
|
29
|
+
* v1.5.2 was exposed to silent mistranscription without it.
|
|
30
|
+
*
|
|
31
|
+
* It is USER-AUTHORED and optional. Nothing transcribes the clip and fills
|
|
32
|
+
* this in: a second model in the request path silently rewriting the prompt
|
|
33
|
+
* is exactly what the prompt-transparency invariant forbids. An empty value
|
|
34
|
+
* composes byte-identically to before this field existed.
|
|
35
|
+
*/
|
|
36
|
+
spokenText?: string;
|
|
19
37
|
}
|
|
20
38
|
export interface ComposedReferences {
|
|
21
39
|
/** The composed prompt: user's words lead, references cited inline as "image N". */
|
|
@@ -61,6 +79,45 @@ export interface ComposeOptions {
|
|
|
61
79
|
startAudioNumber?: number;
|
|
62
80
|
}
|
|
63
81
|
export declare function composeReferences(rawPrompt: string, groups: ReferenceGroup[], opts?: ComposeOptions): ComposedReferences;
|
|
82
|
+
/** One character's voice sample, in the order it is attached. */
|
|
83
|
+
export interface VoiceCitation {
|
|
84
|
+
/**
|
|
85
|
+
* The mention this voice answers to — '@sarah', or null when the character
|
|
86
|
+
* is attached without being mentioned.
|
|
87
|
+
*
|
|
88
|
+
* Matched through `normToken`, so '@Big Red', '@big_red' and '@big-red' are
|
|
89
|
+
* the same token. It is therefore NOT guaranteed byte-equal to what the
|
|
90
|
+
* prompt authored: the desktop builds it from the character's NAME while the
|
|
91
|
+
* agent passes the token as typed, and both resolve identically. Do not echo
|
|
92
|
+
* it back to a user as "what they wrote".
|
|
93
|
+
*/
|
|
94
|
+
token: string | null;
|
|
95
|
+
/** 'Sarah' — the character's name, used verbatim. */
|
|
96
|
+
name: string;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Translate `@character` mentions into Seed Audio's OWN `@AudioN` notation.
|
|
100
|
+
*
|
|
101
|
+
* 🚨 AN ADAPTER, NOT A SECOND COMPOSER — the same relationship
|
|
102
|
+
* `composeKlingEdit` has to `composeReferences`. Seed Audio does not read
|
|
103
|
+
* "audio 1"; it reads `@Audio1`-`@Audio3`, and a clip the prompt never cites is
|
|
104
|
+
* a clip the model ignores. Attaching a voice without citing it would be the
|
|
105
|
+
* silent-drop failure this repo catalogues by name: attach the reference, get
|
|
106
|
+
* no error, get no warning, and get a request that does nothing with it.
|
|
107
|
+
*
|
|
108
|
+
* It emits the SAME grammar the image composer uses — the first mention
|
|
109
|
+
* becomes `Sarah (@Audio1)`, later ones stay `Sarah` — so the NAME still
|
|
110
|
+
* carries the identity and the citation carries the slot. A voice that is
|
|
111
|
+
* attached but never mentioned gets one short key line instead, exactly as an
|
|
112
|
+
* unmentioned character's image does.
|
|
113
|
+
*
|
|
114
|
+
* Everything else is left as authored: an `@token` that is not one of these
|
|
115
|
+
* voices is not ours to touch.
|
|
116
|
+
*
|
|
117
|
+
* A `null` slot means "this position is a clip the caller cites itself" — it
|
|
118
|
+
* holds its @AudioN number and is otherwise ignored.
|
|
119
|
+
*/
|
|
120
|
+
export declare function composeVoiceCitations(rawPrompt: string, voices: Array<VoiceCitation | null>): string;
|
|
64
121
|
export interface KlingEditElement {
|
|
65
122
|
/** Frontal image path/URL (element primary view) */
|
|
66
123
|
frontal: string;
|