@sogni-ai/sogni-creative-agent-skill 2.1.3 → 2.2.0
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/README.md +362 -181
- package/SKILL.md +131 -27
- package/generated/creative-agent-runtime.mjs +3759 -27
- package/llm.txt +19 -7
- package/openclaw.plugin.json +36 -4
- package/package.json +5 -3
- package/sogni-agent.mjs +1750 -106
- package/version.mjs +1 -1
|
@@ -4,8 +4,357 @@
|
|
|
4
4
|
function isLtxWorkflow(workflow) {
|
|
5
5
|
return workflow === 't2v' || workflow === 'i2v' || workflow === 'ia2v' || workflow === 'a2v' || workflow === 'v2v';
|
|
6
6
|
}
|
|
7
|
-
export const SKILL_RUNTIME_VERSION = '2026-05-
|
|
8
|
-
|
|
7
|
+
export const SKILL_RUNTIME_VERSION = '2026-05-12.1';
|
|
8
|
+
const SEEDANCE_MODEL_REF_FORMAT = {
|
|
9
|
+
format(index, type) {
|
|
10
|
+
if (type === 'video')
|
|
11
|
+
return `@Video${index}`;
|
|
12
|
+
if (type === 'audio')
|
|
13
|
+
return `@Audio${index}`;
|
|
14
|
+
return `@Image${index}`;
|
|
15
|
+
},
|
|
16
|
+
parse(token) {
|
|
17
|
+
const m = /^@(Image|Video|Audio)(\d+)$/.exec(token.trim());
|
|
18
|
+
if (!m)
|
|
19
|
+
return null;
|
|
20
|
+
const index = Number.parseInt(m[2], 10);
|
|
21
|
+
if (!Number.isFinite(index) || index < 1)
|
|
22
|
+
return null;
|
|
23
|
+
return {
|
|
24
|
+
index,
|
|
25
|
+
type: m[1] === 'Video' ? 'video' : m[1] === 'Audio' ? 'audio' : 'image',
|
|
26
|
+
};
|
|
27
|
+
},
|
|
28
|
+
scanRegex: /@(?:Image|Video|Audio)\d+/g,
|
|
29
|
+
};
|
|
30
|
+
const GPT_IMAGE_MODEL_REF_FORMAT = {
|
|
31
|
+
format(index, type) {
|
|
32
|
+
if (type === 'video')
|
|
33
|
+
return `Video ${index}`;
|
|
34
|
+
if (type === 'audio')
|
|
35
|
+
return `Audio ${index}`;
|
|
36
|
+
return `Image ${index}`;
|
|
37
|
+
},
|
|
38
|
+
parse(token) {
|
|
39
|
+
const m = /^(?:\[(Image|Video|Audio)\s+(\d+)\]|(Image|Video|Audio)\s+(\d+))$/.exec(token.trim());
|
|
40
|
+
if (!m)
|
|
41
|
+
return null;
|
|
42
|
+
const kind = m[1] ?? m[3];
|
|
43
|
+
const index = Number.parseInt(m[2] ?? m[4], 10);
|
|
44
|
+
if (!Number.isFinite(index) || index < 1)
|
|
45
|
+
return null;
|
|
46
|
+
return {
|
|
47
|
+
index,
|
|
48
|
+
type: kind === 'Video' ? 'video' : kind === 'Audio' ? 'audio' : 'image',
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
scanRegex: /(?<!@)(?<!\b[Gg][Pp][Tt]\s)(?:\[(?:Image|Video|Audio)\s+\d+\]|\b(?:Image|Video|Audio)\s+\d+\b)/g,
|
|
52
|
+
};
|
|
53
|
+
const CONTEXT_MODEL_REF_FORMAT = {
|
|
54
|
+
format(index, type) {
|
|
55
|
+
const slot = Math.max(0, index - 1);
|
|
56
|
+
if (type === 'video')
|
|
57
|
+
return `context_video_${slot}`;
|
|
58
|
+
if (type === 'audio')
|
|
59
|
+
return `context_audio_${slot}`;
|
|
60
|
+
return `context_image_${slot}`;
|
|
61
|
+
},
|
|
62
|
+
parse(token) {
|
|
63
|
+
const m = /^context_(image|video|audio)_(\d+)$/.exec(token.trim());
|
|
64
|
+
if (!m)
|
|
65
|
+
return null;
|
|
66
|
+
const slot = Number.parseInt(m[2], 10);
|
|
67
|
+
if (!Number.isFinite(slot) || slot < 0)
|
|
68
|
+
return null;
|
|
69
|
+
return { index: slot + 1, type: m[1] };
|
|
70
|
+
},
|
|
71
|
+
scanRegex: /context_(?:image|video|audio)_\d+/g,
|
|
72
|
+
};
|
|
73
|
+
export function getModelRefFormat(modelId) {
|
|
74
|
+
const trimmed = modelId.trim().toLowerCase();
|
|
75
|
+
if (trimmed.startsWith('seedance'))
|
|
76
|
+
return SEEDANCE_MODEL_REF_FORMAT;
|
|
77
|
+
if (trimmed.startsWith('gpt-image') || trimmed.startsWith('flux'))
|
|
78
|
+
return GPT_IMAGE_MODEL_REF_FORMAT;
|
|
79
|
+
if (trimmed.startsWith('ltx') || trimmed.startsWith('wan') || trimmed.startsWith('qwen-image'))
|
|
80
|
+
return CONTEXT_MODEL_REF_FORMAT;
|
|
81
|
+
console.warn(`[SOGNI RUNTIME] Unknown model_id "${modelId}" fell back to GPT Image model_ref format.`);
|
|
82
|
+
return GPT_IMAGE_MODEL_REF_FORMAT;
|
|
83
|
+
}
|
|
84
|
+
export function formatModelRef(modelId, index, type) {
|
|
85
|
+
return getModelRefFormat(modelId).format(index, type);
|
|
86
|
+
}
|
|
87
|
+
export const SESSION_CONTROL_SKILL = {
|
|
88
|
+
id: 'session_control',
|
|
89
|
+
name: 'Session control',
|
|
90
|
+
description: 'Turn-control markers that end the current turn cleanly.',
|
|
91
|
+
toolNames: ['ask_clarifying_question', 'finalize_response'],
|
|
92
|
+
alwaysLoaded: true,
|
|
93
|
+
constraints: ['ask_clarifying_question and finalize_response both end the turn.'],
|
|
94
|
+
};
|
|
95
|
+
export const ASSET_REFERENCE_MANAGEMENT_SKILL = {
|
|
96
|
+
id: 'asset_reference_management',
|
|
97
|
+
name: 'Asset reference management',
|
|
98
|
+
description: 'Translate between asset_id, user_label, and per-model model_ref tokens.',
|
|
99
|
+
toolNames: ['create_asset_manifest', 'inspect_asset', 'label_asset', 'map_assets_for_model', 'validate_asset_references'],
|
|
100
|
+
alwaysLoaded: true,
|
|
101
|
+
constraints: ['Use formatModelRef/map assets helpers instead of hand-formatting model reference tokens.'],
|
|
102
|
+
};
|
|
103
|
+
export const QUALITY_AUDIT_SKILL = {
|
|
104
|
+
id: 'quality_audit',
|
|
105
|
+
name: 'Quality audit',
|
|
106
|
+
description: 'Pre-dispatch and post-generation audits that catch parameter / asset / model-range / persona-flow issues before burning a worker round. Findings come back as structured fatal/minor issues with a recommended_action (accept | refine | regenerate | ask_user). Always loaded — cannot be unloaded.',
|
|
107
|
+
toolNames: [],
|
|
108
|
+
alwaysLoaded: true,
|
|
109
|
+
constraints: [
|
|
110
|
+
'When the audit returns recommended_action="ask_user", surface the fatal_issues to the user and wait — do not retry the tool call.',
|
|
111
|
+
'When recommended_action="refine", apply the fix_hint(s) on the next attempt rather than repeating the same call.',
|
|
112
|
+
],
|
|
113
|
+
};
|
|
114
|
+
export const IMAGE_GENERATION_SKILL = {
|
|
115
|
+
id: 'image_generation',
|
|
116
|
+
name: 'Image generation',
|
|
117
|
+
description: 'Text-to-image synthesis (Flux). Use when the user wants a new image generated from a prompt with no source asset.',
|
|
118
|
+
toolNames: ['generate_image'],
|
|
119
|
+
constraints: [
|
|
120
|
+
'For persona-driven requests, defer to image_editing — personas must be conditioned on reference photos, never generated from scratch.',
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
export const IMAGE_EDITING_SKILL = {
|
|
124
|
+
id: 'image_editing',
|
|
125
|
+
name: 'Image editing',
|
|
126
|
+
description: 'Edit, restore, restyle, refine, or change the camera angle of an existing image. Includes persona-conditioned edits — persona images must always be produced with edit_image and reference photos, never via text-to-image.',
|
|
127
|
+
toolNames: ['edit_image', 'restore_photo', 'apply_style', 'change_angle', 'refine_result'],
|
|
128
|
+
constraints: [
|
|
129
|
+
'Persona images must always be produced with edit_image and a reference photo — never invoke generate_image for persona output.',
|
|
130
|
+
'refine_result acts on a prior generation in the session; do not call it before any image has been produced or uploaded.',
|
|
131
|
+
],
|
|
132
|
+
};
|
|
133
|
+
export const VIDEO_GENERATION_SKILL = {
|
|
134
|
+
id: 'video_generation',
|
|
135
|
+
name: 'Video generation',
|
|
136
|
+
description: 'Text-to-video synthesis (LTX-2). Use when the user wants a new video clip generated from a prompt with no source image, audio, or clip.',
|
|
137
|
+
toolNames: ['generate_video'],
|
|
138
|
+
constraints: [
|
|
139
|
+
'Persona-driven video requests must always go through image_editing first to produce a conditioned image; never go straight to text-to-video for personas.',
|
|
140
|
+
],
|
|
141
|
+
};
|
|
142
|
+
export const VIDEO_EDITING_SKILL = {
|
|
143
|
+
id: 'video_editing',
|
|
144
|
+
name: 'Video editing',
|
|
145
|
+
description: 'Convert a still image, audio track, or existing clip into video, plus stitching, orbits, dance-montage compositions, segment extend/replace, and pure-ffmpeg post-production (overlay, subtitles).',
|
|
146
|
+
toolNames: [
|
|
147
|
+
'animate_photo',
|
|
148
|
+
'sound_to_video',
|
|
149
|
+
'video_to_video',
|
|
150
|
+
'stitch_video',
|
|
151
|
+
'orbit_video',
|
|
152
|
+
'dance_montage',
|
|
153
|
+
'extend_video',
|
|
154
|
+
'replace_video_segment',
|
|
155
|
+
'overlay_video',
|
|
156
|
+
'add_subtitles',
|
|
157
|
+
],
|
|
158
|
+
constraints: [
|
|
159
|
+
'Per-clip retry and the batch progress contract are sacred — never collapse a multi-clip render down to a single waterfall call.',
|
|
160
|
+
'animate_photo errors with all_failed must surface to the user; do not auto-retry from inside the chat loop.',
|
|
161
|
+
],
|
|
162
|
+
};
|
|
163
|
+
export const MUSIC_GENERATION_SKILL = {
|
|
164
|
+
id: 'music_generation',
|
|
165
|
+
name: 'Music generation',
|
|
166
|
+
description: 'Compose music with optional lyrics, BPM, key, and structural hints (Sonic Logos).',
|
|
167
|
+
toolNames: ['generate_music'],
|
|
168
|
+
};
|
|
169
|
+
export const MEDIA_ANALYSIS_SKILL = {
|
|
170
|
+
id: 'media_analysis',
|
|
171
|
+
name: 'Media analysis',
|
|
172
|
+
description: 'Vision analysis of uploaded images / videos and structured extraction of generation metadata from previously rendered results.',
|
|
173
|
+
toolNames: ['analyze_image', 'analyze_video', 'extract_metadata'],
|
|
174
|
+
};
|
|
175
|
+
export const PERSONA_MANAGEMENT_SKILL = {
|
|
176
|
+
id: 'persona_management',
|
|
177
|
+
name: 'Persona & memory',
|
|
178
|
+
description: "Resolve named personas to their reference photos and read/write the user's long-term creative memory (preferences, named subjects, ongoing projects).",
|
|
179
|
+
toolNames: ['resolve_personas', 'manage_memory'],
|
|
180
|
+
constraints: [
|
|
181
|
+
'A persona-driven request must call resolve_personas before any image_editing or video_editing tool — never assume a name resolves on its own.',
|
|
182
|
+
],
|
|
183
|
+
};
|
|
184
|
+
export const APP_SETTINGS_SKILL = {
|
|
185
|
+
id: 'app_settings',
|
|
186
|
+
name: 'App settings',
|
|
187
|
+
description: 'Toggle user-visible app preferences such as the safe-content filter. Only invoke when the user has explicitly asked to change a setting.',
|
|
188
|
+
toolNames: ['set_content_filter'],
|
|
189
|
+
};
|
|
190
|
+
export const ALL_BUILT_IN_SKILLS = [
|
|
191
|
+
QUALITY_AUDIT_SKILL,
|
|
192
|
+
SESSION_CONTROL_SKILL,
|
|
193
|
+
ASSET_REFERENCE_MANAGEMENT_SKILL,
|
|
194
|
+
IMAGE_GENERATION_SKILL,
|
|
195
|
+
IMAGE_EDITING_SKILL,
|
|
196
|
+
VIDEO_GENERATION_SKILL,
|
|
197
|
+
VIDEO_EDITING_SKILL,
|
|
198
|
+
MUSIC_GENERATION_SKILL,
|
|
199
|
+
MEDIA_ANALYSIS_SKILL,
|
|
200
|
+
PERSONA_MANAGEMENT_SKILL,
|
|
201
|
+
APP_SETTINGS_SKILL,
|
|
202
|
+
];
|
|
203
|
+
export function toolOk(fields) {
|
|
204
|
+
return { ok: true, status: 'completed', output_assets: [], ...fields };
|
|
205
|
+
}
|
|
206
|
+
export function toolErr(fields) {
|
|
207
|
+
return { ok: false, ...fields };
|
|
208
|
+
}
|
|
209
|
+
export function isToolResultOk(result) {
|
|
210
|
+
return result.ok === true;
|
|
211
|
+
}
|
|
212
|
+
export function isToolResultErr(result) {
|
|
213
|
+
return result.ok === false;
|
|
214
|
+
}
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
// Storyboard adapter prompt-guidance composer (wraps the per-adapter strings)
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
/**
|
|
219
|
+
* Returns the concatenated per-adapter system-prompt guidance block.
|
|
220
|
+
* Public-skill consumers append this to their chat system prompt so the
|
|
221
|
+
* model sees the same per-model rules the chat product injects. The
|
|
222
|
+
* adapter `compile()` runtime is intentionally not bundled here — public
|
|
223
|
+
* skill callers that need real per-model prompt compilation should
|
|
224
|
+
* import directly from `@sogni/creative-agent` rather than the
|
|
225
|
+
* single-file runtime bundle.
|
|
226
|
+
*
|
|
227
|
+
* NOTE: The rich adapter `compile()` logic (Seedance / GPT Image 2 /
|
|
228
|
+
* LTX-2.3 / WAN) is not inlined into this bundle yet. Pull it in from
|
|
229
|
+
* `@sogni/creative-agent/storyboard` when a downstream consumer needs
|
|
230
|
+
* `compileForModel()` to do real prompt compilation rather than the
|
|
231
|
+
* registry stub above.
|
|
232
|
+
*/
|
|
233
|
+
export function composeAdapterPromptGuidance() {
|
|
234
|
+
// The hand-curated bundle currently exposes adapter stubs only, so the
|
|
235
|
+
// composer returns an empty block. Real compile() + getSystemPromptGuidance
|
|
236
|
+
// for each adapter lives in `@sogni/creative-agent/storyboard/adapters`.
|
|
237
|
+
return '';
|
|
238
|
+
}
|
|
239
|
+
export class SkillRegistry {
|
|
240
|
+
manifests = new Map();
|
|
241
|
+
loaded = new Set();
|
|
242
|
+
register(manifest) {
|
|
243
|
+
this.manifests.set(manifest.id, manifest);
|
|
244
|
+
if (manifest.alwaysLoaded)
|
|
245
|
+
this.loaded.add(manifest.id);
|
|
246
|
+
}
|
|
247
|
+
load(id) {
|
|
248
|
+
if (!this.manifests.has(id))
|
|
249
|
+
return false;
|
|
250
|
+
this.loaded.add(id);
|
|
251
|
+
return true;
|
|
252
|
+
}
|
|
253
|
+
unload(id) {
|
|
254
|
+
const manifest = this.manifests.get(id);
|
|
255
|
+
if (!manifest || manifest.alwaysLoaded)
|
|
256
|
+
return false;
|
|
257
|
+
return this.loaded.delete(id);
|
|
258
|
+
}
|
|
259
|
+
getActiveToolNames() {
|
|
260
|
+
const names = new Set();
|
|
261
|
+
for (const id of this.loaded) {
|
|
262
|
+
const manifest = this.manifests.get(id);
|
|
263
|
+
if (!manifest)
|
|
264
|
+
continue;
|
|
265
|
+
for (const name of manifest.toolNames)
|
|
266
|
+
names.add(name);
|
|
267
|
+
}
|
|
268
|
+
return [...names];
|
|
269
|
+
}
|
|
270
|
+
getActiveSkills() {
|
|
271
|
+
return [...this.loaded].map((id) => this.manifests.get(id)).filter(Boolean);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
export const storyboardAdapterRegistry = {
|
|
275
|
+
getAdapter(modelId) {
|
|
276
|
+
const trimmed = modelId.trim().toLowerCase();
|
|
277
|
+
if (trimmed.startsWith('seedance'))
|
|
278
|
+
return { modelId: 'seedance' };
|
|
279
|
+
if (trimmed.startsWith('gpt-image'))
|
|
280
|
+
return { modelId: 'gpt-image-2' };
|
|
281
|
+
if (trimmed.startsWith('ltx'))
|
|
282
|
+
return { modelId: 'ltx23' };
|
|
283
|
+
if (trimmed.startsWith('wan'))
|
|
284
|
+
return { modelId: 'wan' };
|
|
285
|
+
return null;
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
export function compileForModel(modelId) {
|
|
289
|
+
const adapter = storyboardAdapterRegistry.getAdapter(modelId);
|
|
290
|
+
if (!adapter)
|
|
291
|
+
throw new Error(`No storyboard adapter registered for model_id "${modelId}".`);
|
|
292
|
+
return { adapterModelId: adapter.modelId };
|
|
293
|
+
}
|
|
294
|
+
const PUBLIC_SEEDANCE_PRIMARY_IMAGE_REF = formatModelRef('seedance', 1, 'image');
|
|
295
|
+
export const SEEDANCE_STORYBOARD_REFERENCE_PROMPT = `Create a full-screen cinematic video from the storyboard in ${PUBLIC_SEEDANCE_PRIMARY_IMAGE_REF}. Treat ${PUBLIC_SEEDANCE_PRIMARY_IMAGE_REF} as the controlling source for shot order and intent, and as a source layout reference: use the thumbnails, timing, Dialogue/VO, Audio/SFX, timecodes, camera/motion notes, transitions, and scene order as instructions, not as a visual board to reproduce. Do not display the storyboard grid, borders, caption bars, storyboard title/footer text, panel numbers, section labels, slide titles, headings, or transcribed narration. Convert the ordered thumbnails into full-screen chronological beats; do not reuse only one or two motifs while skipping panels. When the board has panel titles, captions, section numbers, slide titles, or headings but no formal Dialogue/VO labels, treat those labels as short audio-only narration/voiceover or key-message beats in order unless they are clearly visual-only metadata. Voice each label as its own brief phrase with a pause; do not concatenate labels into run-on sentences and do not speak panel numbers. Show storyboard labels as visible text only when the user explicitly asks for visible text, subtitles, a title card, lower third, signage, or CTA. Preserve the story spine, character/product/reference continuity, and cause-and-effect progression between beats. Treat transitions as motion instructions, not unrelated hard cuts unless the storyboard explicitly asks for hard cuts. Use brand color, lighting, product imagery, and composition instead of invented typography. Keep visible text limited to exact copy the user or storyboard explicitly marks as on-screen text, CTA, signage, or end-card text. Use a music/SFX arc that follows the storyboard audio notes and lands the final brand/CTA hit. Keep unrelated UI, extra logos, microtext, subtitles, and extra scenes out of the frame.`;
|
|
296
|
+
export const SEEDANCE_V2V_REFERENCE_MAX_DURATION_SECONDS = 15;
|
|
297
|
+
function asciiAt(data, start, length) {
|
|
298
|
+
if (data.length < start + length)
|
|
299
|
+
return '';
|
|
300
|
+
let value = '';
|
|
301
|
+
for (let index = start; index < start + length; index += 1) {
|
|
302
|
+
value += String.fromCharCode(data[index]);
|
|
303
|
+
}
|
|
304
|
+
return value;
|
|
305
|
+
}
|
|
306
|
+
export function normalizeReferenceAudioMimeType(mimeType) {
|
|
307
|
+
const trimmed = mimeType?.split(';')[0]?.trim().toLowerCase();
|
|
308
|
+
return trimmed || 'application/octet-stream';
|
|
309
|
+
}
|
|
310
|
+
export function detectReferenceAudioFormat(data, mimeType) {
|
|
311
|
+
const normalizedMimeType = normalizeReferenceAudioMimeType(mimeType);
|
|
312
|
+
if (normalizedMimeType === 'audio/mpeg' || normalizedMimeType === 'audio/mp3') {
|
|
313
|
+
return 'mp3';
|
|
314
|
+
}
|
|
315
|
+
if (normalizedMimeType === 'audio/mp4'
|
|
316
|
+
|| normalizedMimeType === 'audio/m4a'
|
|
317
|
+
|| normalizedMimeType === 'audio/x-m4a') {
|
|
318
|
+
return 'm4a';
|
|
319
|
+
}
|
|
320
|
+
if (normalizedMimeType === 'audio/wav' || normalizedMimeType === 'audio/x-wav') {
|
|
321
|
+
return 'wav';
|
|
322
|
+
}
|
|
323
|
+
if (normalizedMimeType === 'audio/ogg' || normalizedMimeType === 'application/ogg') {
|
|
324
|
+
return 'ogg';
|
|
325
|
+
}
|
|
326
|
+
if (data.length >= 3 && asciiAt(data, 0, 3) === 'ID3')
|
|
327
|
+
return 'mp3';
|
|
328
|
+
if (data.length >= 2 && data[0] === 0xff && (data[1] & 0xe0) === 0xe0)
|
|
329
|
+
return 'mp3';
|
|
330
|
+
if (data.length >= 12 && asciiAt(data, 4, 4) === 'ftyp')
|
|
331
|
+
return 'm4a';
|
|
332
|
+
if (data.length >= 12 && asciiAt(data, 0, 4) === 'RIFF' && asciiAt(data, 8, 4) === 'WAVE')
|
|
333
|
+
return 'wav';
|
|
334
|
+
if (data.length >= 4 && asciiAt(data, 0, 4) === 'OggS')
|
|
335
|
+
return 'ogg';
|
|
336
|
+
return 'unknown';
|
|
337
|
+
}
|
|
338
|
+
function finiteNonNegativeMediaValue(value, fallback = 0) {
|
|
339
|
+
const parsed = Number(value);
|
|
340
|
+
return Number.isFinite(parsed) ? Math.max(0, parsed) : fallback;
|
|
341
|
+
}
|
|
342
|
+
export function shouldTrimSeedanceV2VSourceVideo({ sourceDurationSeconds, requestedDurationSeconds, startOffsetSeconds = 0, maxDurationSeconds = SEEDANCE_V2V_REFERENCE_MAX_DURATION_SECONDS, }) {
|
|
343
|
+
const maxDuration = finiteNonNegativeMediaValue(maxDurationSeconds, SEEDANCE_V2V_REFERENCE_MAX_DURATION_SECONDS)
|
|
344
|
+
|| SEEDANCE_V2V_REFERENCE_MAX_DURATION_SECONDS;
|
|
345
|
+
const sourceDuration = finiteNonNegativeMediaValue(sourceDurationSeconds, Number.NaN);
|
|
346
|
+
const requestedDuration = finiteNonNegativeMediaValue(requestedDurationSeconds, maxDuration);
|
|
347
|
+
const startOffset = finiteNonNegativeMediaValue(startOffsetSeconds, 0);
|
|
348
|
+
if (startOffset > 0)
|
|
349
|
+
return true;
|
|
350
|
+
const effectiveDuration = Math.min(requestedDuration || maxDuration, maxDuration);
|
|
351
|
+
return Number.isFinite(sourceDuration) && sourceDuration > effectiveDuration + 0.15;
|
|
352
|
+
}
|
|
353
|
+
export const GPT_IMAGE_STORYBOARD_DEFAULTS = {
|
|
354
|
+
storyboardLandscape: { width: 2560, height: 1440, aspectRatio: '2560x1440' },
|
|
355
|
+
storyboardPortrait: { width: 1440, height: 2560, aspectRatio: '1440x2560' },
|
|
356
|
+
};
|
|
357
|
+
export const DEFAULT_STORYBOARD_CANVAS_HINT_MARKER = 'DEFAULT STORYBOARD PAGE LAYOUT:';
|
|
9
358
|
export const LTX23_WORKFLOW_MODELS = Object.freeze({
|
|
10
359
|
t2v: 'ltx23-22b-fp8_t2v_distilled',
|
|
11
360
|
i2v: 'ltx23-22b-fp8_i2v_distilled',
|
|
@@ -13,6 +362,18 @@ export const LTX23_WORKFLOW_MODELS = Object.freeze({
|
|
|
13
362
|
a2v: 'ltx23-22b-fp8_a2v_distilled',
|
|
14
363
|
v2v: 'ltx23-22b-fp8_v2v_distilled'
|
|
15
364
|
});
|
|
365
|
+
export const LTX23_DEV_WORKFLOW_MODELS = Object.freeze({
|
|
366
|
+
t2v: 'ltx23-22b-fp8_t2v_dev',
|
|
367
|
+
i2v: 'ltx23-22b-fp8_i2v_dev',
|
|
368
|
+
ia2v: 'ltx23-22b-fp8_ia2v_dev',
|
|
369
|
+
a2v: 'ltx23-22b-fp8_a2v_dev'
|
|
370
|
+
});
|
|
371
|
+
export function resolveLtx23WorkflowModelForQuality(workflow, qualityTier) {
|
|
372
|
+
if (qualityTier === 'pro' && workflow !== 'v2v') {
|
|
373
|
+
return LTX23_DEV_WORKFLOW_MODELS[workflow];
|
|
374
|
+
}
|
|
375
|
+
return LTX23_WORKFLOW_MODELS[workflow];
|
|
376
|
+
}
|
|
16
377
|
export const SEEDANCE_WORKFLOW_MODELS = Object.freeze({
|
|
17
378
|
t2v: 'seedance-2-0',
|
|
18
379
|
t2vFast: 'seedance-2-0-fast',
|
|
@@ -233,6 +594,15 @@ export const VIDEO_MODEL_REGISTRY = Object.freeze({
|
|
|
233
594
|
});
|
|
234
595
|
export const EXPANDED_VIDEO_MODEL_REGISTRY = (() => {
|
|
235
596
|
const registry = { ...VIDEO_MODEL_REGISTRY };
|
|
597
|
+
for (const workflow of ['t2v', 'i2v', 'ia2v', 'a2v']) {
|
|
598
|
+
const base = registry[LTX23_WORKFLOW_MODELS[workflow]];
|
|
599
|
+
if (!base)
|
|
600
|
+
continue;
|
|
601
|
+
registry[LTX23_DEV_WORKFLOW_MODELS[workflow]] = {
|
|
602
|
+
...base,
|
|
603
|
+
steps: 20
|
|
604
|
+
};
|
|
605
|
+
}
|
|
236
606
|
for (const workflow of ['t2v', 'i2v', 'ia2v', 'a2v', 'v2v']) {
|
|
237
607
|
const ltx2Distilled = 'ltx2-19b-fp8_' + workflow + '_distilled';
|
|
238
608
|
const ltx2Quality = 'ltx2-19b-fp8_' + workflow;
|
|
@@ -506,17 +876,18 @@ export function formatAudioIdPrompt(prompt, voiceName) {
|
|
|
506
876
|
const dialogue = extractQuotedDialogueSegments(prompt);
|
|
507
877
|
const speechLines = dialogue.length > 0
|
|
508
878
|
? dialogue.map((line, index) => (voiceName || 'SPEAKER_' + (index + 1)) + ': "' + line + '"').join('\n')
|
|
509
|
-
: '
|
|
510
|
-
|
|
879
|
+
: '';
|
|
880
|
+
const sections = [
|
|
511
881
|
'[VISUAL]',
|
|
512
882
|
prompt.trim(),
|
|
513
883
|
'',
|
|
514
884
|
'[SPEECH]',
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
885
|
+
];
|
|
886
|
+
if (speechLines) {
|
|
887
|
+
sections.push(speechLines);
|
|
888
|
+
}
|
|
889
|
+
sections.push('', '[SOUNDS]', 'Use natural ambient sound that matches the scene unless the prompt specifies silence.');
|
|
890
|
+
return sections.join('\n');
|
|
520
891
|
}
|
|
521
892
|
export function getVideoPromptGuardrailPlan({ prompt, duration, frames, fps, durationExplicit, referenceAudioIdentity, voiceName } = {}) {
|
|
522
893
|
let nextPrompt = prompt || '';
|
|
@@ -600,15 +971,15 @@ export function selectDefaultVideoModel(workflow, opts = {}, config) {
|
|
|
600
971
|
if (configured)
|
|
601
972
|
return resolveVideoModelAlias(configured, workflow) || null;
|
|
602
973
|
if (workflow === 'ia2v')
|
|
603
|
-
return
|
|
974
|
+
return resolveLtx23WorkflowModelForQuality('ia2v', opts.quality);
|
|
604
975
|
if (workflow === 'a2v')
|
|
605
|
-
return
|
|
976
|
+
return resolveLtx23WorkflowModelForQuality('a2v', opts.quality);
|
|
606
977
|
if (workflow === 'v2v')
|
|
607
978
|
return LTX23_WORKFLOW_MODELS.v2v;
|
|
608
979
|
if (workflow === 't2v')
|
|
609
|
-
return
|
|
980
|
+
return resolveLtx23WorkflowModelForQuality('t2v', opts.quality);
|
|
610
981
|
if (workflow === 'i2v' && (opts.referenceAudioIdentity || promptNeedsLtxNativeAudio(opts.prompt) || opts.quality === 'hq' || opts.quality === 'pro')) {
|
|
611
|
-
return
|
|
982
|
+
return resolveLtx23WorkflowModelForQuality('i2v', opts.quality);
|
|
612
983
|
}
|
|
613
984
|
return VIDEO_WORKFLOW_DEFAULT_MODELS[workflow] || null;
|
|
614
985
|
}
|
|
@@ -626,8 +997,11 @@ export function dimensionsWithShortSide(width, height, shortSide) {
|
|
|
626
997
|
height: Math.round(h * scale)
|
|
627
998
|
};
|
|
628
999
|
}
|
|
1000
|
+
function textMentionsPortraitSocialFormat(text) {
|
|
1001
|
+
return /\b(?:tiktok|tik\s*tok|reels?|shorts|story)\b/i.test(text);
|
|
1002
|
+
}
|
|
629
1003
|
function hasPortraitResolutionHint(text) {
|
|
630
|
-
return /\b(portrait|vertical
|
|
1004
|
+
return /\b(?:portrait|vertical)\b/i.test(text) || textMentionsPortraitSocialFormat(text);
|
|
631
1005
|
}
|
|
632
1006
|
function hasLandscapeResolutionHint(text) {
|
|
633
1007
|
return /\b(landscape|horizontal|wide(?:screen)?|cinematic|youtube)\b/i.test(text);
|
|
@@ -649,6 +1023,18 @@ export function inferNamedVideoResolutionShortSideFromText(text) {
|
|
|
649
1023
|
const namedResolution = compact.match(/\b(480|720|1080|1440|2160)\s*p\b/i);
|
|
650
1024
|
return namedResolution ? Number(namedResolution[1]) : null;
|
|
651
1025
|
}
|
|
1026
|
+
export function inferRequestedVideoResolutionShortSideFromText(text) {
|
|
1027
|
+
const standardResolution = inferNamedVideoResolutionShortSideFromText(text);
|
|
1028
|
+
if (standardResolution)
|
|
1029
|
+
return standardResolution;
|
|
1030
|
+
const match = text.match(/\b([1-9]\d{2,3})\s*p\b/i);
|
|
1031
|
+
if (!match)
|
|
1032
|
+
return null;
|
|
1033
|
+
const parsed = Number(match[1]);
|
|
1034
|
+
if (!Number.isInteger(parsed) || parsed < 100 || parsed > 4320)
|
|
1035
|
+
return null;
|
|
1036
|
+
return parsed;
|
|
1037
|
+
}
|
|
652
1038
|
export function inferExplicitPixelDimensionsFromText(text) {
|
|
653
1039
|
const compact = text.replace(/,/g, '');
|
|
654
1040
|
const exactPair = compact.match(/\b(\d{1,5})\s*(x|by)\s*(\d{1,5})\s*(px|pixels?)?\b/i);
|
|
@@ -715,7 +1101,11 @@ export function inferExplicitAspectRatioFromText(text) {
|
|
|
715
1101
|
}
|
|
716
1102
|
const index = match.index ?? 0;
|
|
717
1103
|
const context = text.slice(Math.max(0, index - 48), Math.min(text.length, index + match[0].length + 48));
|
|
718
|
-
|
|
1104
|
+
const hasExplicitRatioContext = /\b(?:aspect|ratio|format|resolution|size|dimensions?|portrait|landscape|widescreen|horizontal|vertical)\b/i.test(context);
|
|
1105
|
+
if (!hasExplicitRatioContext && width <= 3 && height >= 10 && height < 60) {
|
|
1106
|
+
return null;
|
|
1107
|
+
}
|
|
1108
|
+
if (/\b(?:ratio|aspect|format|resolution|size|dimensions?|portrait|landscape|vertical|horizontal|widescreen|frame|image|photo|picture|video|clip|ad|advert|commercial|promo|story\s*board|storyboard|tiktok|tik\s*tok|reels?|shorts?|instagram|output)\b/i.test(context)) {
|
|
719
1109
|
return { width, height, text: match[0] };
|
|
720
1110
|
}
|
|
721
1111
|
return null;
|
|
@@ -723,15 +1113,28 @@ export function inferExplicitAspectRatioFromText(text) {
|
|
|
723
1113
|
export function inferRequestedTotalVideoDurationSeconds(text) {
|
|
724
1114
|
const durations = [];
|
|
725
1115
|
for (const match of text.matchAll(/\b(\d{1,3}(?:\.\d+)?)\s*(?:minutes?|mins?)\b/gi)) {
|
|
1116
|
+
if (match.index !== undefined && text[match.index - 1] === '%')
|
|
1117
|
+
continue;
|
|
726
1118
|
const minutes = Number(match[1]);
|
|
727
1119
|
if (Number.isFinite(minutes) && minutes > 0)
|
|
728
1120
|
durations.push(Math.ceil(minutes * 60));
|
|
729
1121
|
}
|
|
730
|
-
for (const match of text.matchAll(/\b(\d{1,3})\s*(?:s|sec|secs|seconds?)\b/gi)) {
|
|
1122
|
+
for (const match of text.matchAll(/\b(\d{1,3}(?:\.\d+)?)\s*(?:s|sec|secs|seconds?)\b/gi)) {
|
|
1123
|
+
if (match.index !== undefined && text[match.index - 1] === '%')
|
|
1124
|
+
continue;
|
|
731
1125
|
const seconds = Number(match[1]);
|
|
732
1126
|
if (Number.isFinite(seconds) && seconds > 0)
|
|
733
1127
|
durations.push(seconds);
|
|
734
1128
|
}
|
|
1129
|
+
for (const match of text.matchAll(/\b(\d{1,2}):([0-5]\d)(?:\.\d+)?\s*(?:-|–|—|\bto\b)\s*(\d{1,2}):([0-5]\d)(?:\.\d+)?\b/gi)) {
|
|
1130
|
+
if (match.index !== undefined && text[match.index - 1] === '%')
|
|
1131
|
+
continue;
|
|
1132
|
+
const start = (Number(match[1]) * 60) + Number(match[2]);
|
|
1133
|
+
const end = (Number(match[3]) * 60) + Number(match[4]);
|
|
1134
|
+
if (Number.isFinite(start) && Number.isFinite(end) && end > start && end <= 600) {
|
|
1135
|
+
durations.push(end);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
735
1138
|
return durations.length > 0 ? Math.max(...durations) : null;
|
|
736
1139
|
}
|
|
737
1140
|
export function textProvidesLiteralVideoPrompt(text) {
|
|
@@ -789,15 +1192,94 @@ export function extractLiteralVideoPrompt(text) {
|
|
|
789
1192
|
return extracted.length >= 3 ? extracted : null;
|
|
790
1193
|
}
|
|
791
1194
|
export function textMentionsStoryboardReference(text) {
|
|
792
|
-
|
|
1195
|
+
const normalized = text
|
|
1196
|
+
.replace(/[“”]/g, '"')
|
|
1197
|
+
.replace(/[’]/g, "'")
|
|
1198
|
+
.replace(/\s+/g, ' ')
|
|
1199
|
+
.trim();
|
|
1200
|
+
if (!normalized)
|
|
1201
|
+
return false;
|
|
1202
|
+
if (/\b(?:video\s+sequence|sequence\s+sheet|shot\s+sheet|thumbnail\s+sequence|panel\s+sequence)\b/i.test(normalized)) {
|
|
1203
|
+
return true;
|
|
1204
|
+
}
|
|
1205
|
+
const rejectsStoryboardPanelOutput = /\b(?:no|without)\s+(?:extra\s+|random\s+|visible\s+|generated\s+|output\s+)?(?:story\s*board|storyboard)\s+panels?\b/i.test(normalized)
|
|
1206
|
+
|| /\b(?:avoid|exclude|never|don't|do\s+not)\b[\s\S]{0,80}\b(?:story\s*board|storyboard)\s+panels?\b/i.test(normalized)
|
|
1207
|
+
|| /\bnot\s+(?:a\s+|the\s+|as\s+)?(?:story\s*board|storyboard)\s+panels?\b/i.test(normalized)
|
|
1208
|
+
|| /\b(?:story\s*board|storyboard)\s+panels?\b[\s\S]{0,80}\b(?:not\s+(?:needed|required|wanted)|without|avoid|exclude|never)\b/i.test(normalized);
|
|
1209
|
+
const contextualStoryboardReference = /\b(?:uploading|uploaded|attached|provided|reference|source|input)\b[\s\S]{0,80}\b(?:story\s*board|storyboard)\b/i.test(normalized)
|
|
1210
|
+
|| /\b(?:story\s*board|storyboard)\b[\s\S]{0,80}\b(?:uploaded|attached|provided|reference|source|input|image|sheet|grid|layout|page|board|panels?|frames?|sequence|timecodes?)\b/i.test(normalized)
|
|
1211
|
+
|| /\b(?:use|using|with|from|based\s+on|following|follow|turn|convert|transform|animate)\b[\s\S]{0,100}\b(?:story\s*board|storyboard)\b/i.test(normalized)
|
|
1212
|
+
|| /\b(?:story\s*board|storyboard)\b[\s\S]{0,100}\b(?:into|to|as)\s+(?:a\s+|the\s+)?(?:videos?|clips?|animations?|movies?|films?)\b/i.test(normalized);
|
|
1213
|
+
return contextualStoryboardReference && !rejectsStoryboardPanelOutput;
|
|
1214
|
+
}
|
|
1215
|
+
export function textExplicitlyRequestsSeedanceFastModel(text) {
|
|
1216
|
+
const mentionsSeedance = /\b(?:seedance|seeddance)(?:\s*2(?:\.0)?)?\b/i.test(text);
|
|
1217
|
+
return /\b(?:seedance|seeddance)(?:\s*2(?:\.0)?)?\s+fast\b/i.test(text)
|
|
1218
|
+
|| /\bfast\s+(?:seedance|seeddance)(?:\s*2(?:\.0)?)?\b/i.test(text)
|
|
1219
|
+
|| /\b(?:(?:seedance|seeddance)(?:\s*2(?:\.0)?)?\s+)?fast\s+(?:version|variant)\b/i.test(text)
|
|
1220
|
+
|| /\b(?:version|variant)\s+(?:should\s+be\s+|is\s+|as\s+)?(?:(?:seedance|seeddance)(?:\s*2(?:\.0)?)?\s+)?fast\b/i.test(text)
|
|
1221
|
+
|| (mentionsSeedance && /\bdraft\b/i.test(text))
|
|
1222
|
+
|| (mentionsSeedance
|
|
1223
|
+
&& /\b(?:use|using|choose|select|set|switch\s+to|with|via)\b[\s\S]{0,40}\bfast\s+model\b/i.test(text));
|
|
1224
|
+
}
|
|
1225
|
+
export function textExplicitlyRequestsNonSeedanceVideoModel(text) {
|
|
1226
|
+
return /\b(?:ltx(?:\s*2(?:\.3)?)?|wan(?:\s*2(?:\.2)?)?|another\s+video\s+model|different\s+video\s+model|non[-\s]?seedance)\b/i.test(text)
|
|
1227
|
+
&& !/\b(?:seedance|seeddance)(?:\s*2(?:\.0)?)?\b/i.test(text);
|
|
1228
|
+
}
|
|
1229
|
+
export function textTreatsAudioAsLooseReference(text) {
|
|
1230
|
+
return /@?audio\d*\b[\s\S]{0,100}\b(?:loose|rough|mood|vibe|background|ambient|style|play\s+under)\b[\s\S]{0,40}\b(?:references?|guide|under|track|shot|clip)\b/i.test(text)
|
|
1231
|
+
|| /\baudio\b[\s\S]{0,80}\bloose\s+references?\b/i.test(text)
|
|
1232
|
+
|| /\bloose\s+references?\b[\s\S]{0,80}\baudio\b/i.test(text)
|
|
1233
|
+
|| /\breference\s+audio\b[\s\S]{0,100}\b(?:loose|mood|background|play\s+under|under)\b/i.test(text)
|
|
1234
|
+
|| /\baudio\b[\s\S]{0,100}\b(?:mood|background|ambient|style)\s+references?\b/i.test(text)
|
|
1235
|
+
|| /\baudio\b[\s\S]{0,80}\bplay\s+under\b/i.test(text)
|
|
1236
|
+
|| /\bplay\s+under\b[\s\S]{0,80}\baudio\b/i.test(text)
|
|
1237
|
+
|| /\baudio\b[\s\S]{0,80}\b(?:as\s+(?:a\s+)?(?:rough\s+)?(?:reference|guide)|for\s+(?:the\s+)?(?:mood|vibe|tone|feel|inspiration|style)|mood\s+reference|vibe\s+reference)\b/i.test(text)
|
|
1238
|
+
|| /\b(?:use|take|treat)\b[\s\S]{0,80}\b(?:audio|song|track|music)\b[\s\S]{0,80}\b(?:as\s+(?:a\s+)?(?:rough\s+)?(?:reference|guide)|for\s+(?:the\s+)?(?:mood|vibe|tone|feel|inspiration|style))\b/i.test(text)
|
|
1239
|
+
|| /\b(?:mood|vibe|tone|feel|style)\b[\s\S]{0,80}\b(?:from|of)\b[\s\S]{0,80}\b(?:audio|song|track|music)\b/i.test(text)
|
|
1240
|
+
|| /\b(?:inspired\s+by|based\s+on)\b[\s\S]{0,80}\b(?:audio|song|track|music)\b[\s\S]{0,80}\b(?:mood|vibe|tone|feel|style)?\b/i.test(text);
|
|
1241
|
+
}
|
|
1242
|
+
export function textRequestsPrimaryAudioSyncVideo(text) {
|
|
1243
|
+
if (textTreatsAudioAsLooseReference(text)) {
|
|
1244
|
+
return false;
|
|
1245
|
+
}
|
|
1246
|
+
if (/\b(?:do\s+not|don't|dont|no|not|without)\b[\s\S]{0,50}\b(?:sync|synced|synchroni[sz]e|synchronized|match|lip[-\s]*sync)\b[\s\S]{0,50}\b(?:audio|sound|song|track|beat|rhythm|music|voice|dialogue|speech|words)\b/i.test(text)
|
|
1247
|
+
|| /\b(?:audio|sound|song|track|beat|rhythm|music|voice|dialogue|speech|words)\b[\s\S]{0,50}\b(?:do\s+not|don't|dont|no|not|without)\b[\s\S]{0,50}\b(?:sync|synced|synchroni[sz]e|synchronized|match|lip[-\s]*sync)\b/i.test(text)) {
|
|
1248
|
+
return false;
|
|
1249
|
+
}
|
|
1250
|
+
return /\bsound[-_\s]*to[-_\s]*video\b/i.test(text)
|
|
1251
|
+
|| /\baudio[-_\s]*(?:sync|synced|synchronized|synchroni[sz]ed)\b/i.test(text)
|
|
1252
|
+
|| /\b(?:sync|synced|synchroni[sz]e|synchroni[sz]ed|match)\b[\s\S]{0,80}\b(?:audio|sound|song|track|beat|rhythm|music|voice|dialogue|speech|words)\b/i.test(text)
|
|
1253
|
+
|| /\b(?:saying|speaking|lip[-\s]*sync(?:ing)?|mouth(?:ing)?)\b[\s\S]{0,100}\b(?:audio|sound|words|dialogue|speech|voice)\b/i.test(text)
|
|
1254
|
+
|| /\b(?:audio|sound|song|track|beat|rhythm|music|voice|dialogue|speech|words)\b[\s\S]{0,100}\b(?:drive|drives|driving|primary|sync|synced|synchronized|synchroni[sz]ed|lip[-\s]*sync)\b/i.test(text)
|
|
1255
|
+
|| /\b(?:audio\s+(?:voice\s+)?recording|voice\s+(?:recording|clip|track|file)|wav\s+file|uploaded\s+(?:audio|voice|wav)|audio\s+file)\b[\s\S]{0,160}\b(?:image|photo|picture|portrait|face|person|man|woman|character)\b[\s\S]{0,160}\b(?:make|create|generate|render|animate|turn|convert|transform)\b[\s\S]{0,120}\b(?:sing|sinc|since|sync|speak|speaking|say|saying|talk|talking|lip[-\s]*sync|dance|dancing)\b/i.test(text)
|
|
1256
|
+
|| /\b(?:image|photo|picture|portrait|face|person|man|woman|character)\b[\s\S]{0,160}\b(?:audio\s+(?:voice\s+)?recording|voice\s+(?:recording|clip|track|file)|wav\s+file|uploaded\s+(?:audio|voice|wav)|audio\s+file)\b[\s\S]{0,160}\b(?:make|create|generate|render|animate|turn|convert|transform)\b[\s\S]{0,120}\b(?:sing|sinc|since|sync|speak|speaking|say|saying|talk|talking|lip[-\s]*sync|dance|dancing)\b/i.test(text);
|
|
1257
|
+
}
|
|
1258
|
+
export function seedanceRequestUsesStoryboardReferenceForModelDefault(input) {
|
|
1259
|
+
if (input.storyboardDetected === true)
|
|
1260
|
+
return true;
|
|
1261
|
+
const promptText = typeof input.promptText === 'string'
|
|
1262
|
+
? input.promptText.trim()
|
|
1263
|
+
: '';
|
|
1264
|
+
if (promptText === SEEDANCE_STORYBOARD_REFERENCE_PROMPT)
|
|
1265
|
+
return true;
|
|
1266
|
+
if (!input.hasImageReference)
|
|
1267
|
+
return false;
|
|
1268
|
+
const combinedText = `${input.userIntentText}\n${promptText}`;
|
|
1269
|
+
if (!textMentionsStoryboardReference(combinedText))
|
|
1270
|
+
return false;
|
|
1271
|
+
return /\b(?:seedance|videos?|clips?|animations?|movies?|films?|generate|create|make|render|produce|turn|animate|convert|transform)\b/i.test(combinedText);
|
|
793
1272
|
}
|
|
794
1273
|
export function textProvidesVideoScriptOrDetailedPrompt(text) {
|
|
795
|
-
const normalized = text
|
|
1274
|
+
const normalized = text
|
|
1275
|
+
.replace(/[“”]/g, '"')
|
|
1276
|
+
.replace(/[’]/g, "'")
|
|
1277
|
+
.trim();
|
|
796
1278
|
if (!normalized)
|
|
797
1279
|
return false;
|
|
798
1280
|
if (textProvidesLiteralVideoPrompt(normalized))
|
|
799
1281
|
return true;
|
|
800
|
-
if (/\[\s*\d{1,2}(?:[:.]\d{2})?\s*(
|
|
1282
|
+
if (/\[\s*\d{1,2}(?:[:.]\d{2})?\s*(?:-|–|—|to)\s*\d{1,2}(?:[:.]\d{2})?\s*\]/i.test(normalized))
|
|
801
1283
|
return true;
|
|
802
1284
|
if (/^\s*(?:style|shot|scene|segment|camera|motion|audio|vo|v\.o\.|voiceover|sfx|fx|music|dialogue)\s*:/im.test(normalized))
|
|
803
1285
|
return true;
|
|
@@ -821,6 +1303,32 @@ export function textProvidesVideoScriptOrDetailedPrompt(text) {
|
|
|
821
1303
|
export function seedanceStoryboardFallbackAllowedForText(text) {
|
|
822
1304
|
return !textProvidesVideoScriptOrDetailedPrompt(text);
|
|
823
1305
|
}
|
|
1306
|
+
function textProvidesStructuredVideoScriptOrPrompt(text) {
|
|
1307
|
+
const normalized = text
|
|
1308
|
+
.replace(/[“”]/g, '"')
|
|
1309
|
+
.replace(/[’]/g, "'")
|
|
1310
|
+
.trim();
|
|
1311
|
+
if (!normalized)
|
|
1312
|
+
return false;
|
|
1313
|
+
if (textProvidesLiteralVideoPrompt(normalized))
|
|
1314
|
+
return true;
|
|
1315
|
+
if (/\[\s*\d{1,2}(?:[:.]\d{2})?\s*(?:-|–|—|to)\s*\d{1,2}(?:[:.]\d{2})?\s*\]/i.test(normalized))
|
|
1316
|
+
return true;
|
|
1317
|
+
if (/^\s*(?:scene|shot|segment)\s*\d{1,2}\b/im.test(normalized))
|
|
1318
|
+
return true;
|
|
1319
|
+
if (/^\s*(?:dialogue|vo|v\.o\.|voiceover)\s*:/im.test(normalized))
|
|
1320
|
+
return true;
|
|
1321
|
+
if ((normalized.match(/^\s*(?:shot|scene|segment|camera|motion|audio|vo|v\.o\.|voiceover|sfx|fx|music|dialogue)\s*:/gim) || []).length >= 2) {
|
|
1322
|
+
return true;
|
|
1323
|
+
}
|
|
1324
|
+
if ((normalized.match(/\b(?:VO|SFX|camera|motion|audio|dialogue|shot|segment)\s*:/g) || []).length >= 3) {
|
|
1325
|
+
return true;
|
|
1326
|
+
}
|
|
1327
|
+
return (normalized.match(/"[^"]{8,}"/g) || []).length >= 2;
|
|
1328
|
+
}
|
|
1329
|
+
export function seedanceStoryboardReferenceFallbackAllowedForVisualDetection(text) {
|
|
1330
|
+
return !textProvidesStructuredVideoScriptOrPrompt(text);
|
|
1331
|
+
}
|
|
824
1332
|
function normalizeDurationSeconds(value) {
|
|
825
1333
|
const raw = typeof value === 'string'
|
|
826
1334
|
? Number(value.trim().match(/^(\d{1,3}(?:\.\d+)?)\s*(?:s|sec|secs|seconds?)?$/i)?.[1])
|
|
@@ -877,7 +1385,12 @@ function textExplicitlyRequestsGeneratedImageStage(text) {
|
|
|
877
1385
|
}
|
|
878
1386
|
function textRequestsAdjacentImageTransitions(text) {
|
|
879
1387
|
const lower = text.toLowerCase();
|
|
880
|
-
const
|
|
1388
|
+
const sequenceNouns = String.raw `(?:images?|photos?|pictures?|keyframes?|frames?|versions?|variations?|scenes?)`;
|
|
1389
|
+
const transitionVerbs = String.raw `(?:transition|transitions|transitioning|link|links|linking|connect|connecting|morph|morphs|morphing)`;
|
|
1390
|
+
const transitionVerbTargetsSequence = new RegExp(String.raw `\b${transitionVerbs}\b\s+(?:the\s+|all\s+|each\s+|every\s+|these\s+|those\s+)?${sequenceNouns}\b`, 'i').test(lower)
|
|
1391
|
+
|| new RegExp(String.raw `\b${transitionVerbs}\b[\s\S]{0,40}\b(?:between|from|to|into|across)\b[\s\S]{0,40}\b(?:the\s+)?${sequenceNouns}\b`, 'i').test(lower)
|
|
1392
|
+
|| new RegExp(String.raw `\b(?:each|every|all|these|those|generated|uploaded|source|end|first|last|previous|next)\s+(?:story\s*board\s+|storyboard\s+|generated\s+|uploaded\s+)?${sequenceNouns}\b[\s\S]{0,80}\b${transitionVerbs}\b`, 'i').test(lower);
|
|
1393
|
+
const mentionsTransition = transitionVerbTargetsSequence
|
|
881
1394
|
|| /\bbetween\s+(?:the\s+)?(?:images?|photos?|pictures?|keyframes?|frames?|versions?|variations?|scenes?)\b/.test(lower)
|
|
882
1395
|
|| /(?:->)/.test(text);
|
|
883
1396
|
if (!mentionsTransition)
|
|
@@ -887,13 +1400,171 @@ function textRequestsAdjacentImageTransitions(text) {
|
|
|
887
1400
|
const mentionsVideoOutput = /\b(?:video|videos|clips?|segments?|stitch|stitched|stitching|montage)\b/.test(lower);
|
|
888
1401
|
return mentionsGeneratedSequence && mentionsVideoOutput;
|
|
889
1402
|
}
|
|
1403
|
+
export function textRequestsProfessionalCharacterSheetImage(text) {
|
|
1404
|
+
const normalized = text
|
|
1405
|
+
.replace(/[“”]/g, '"')
|
|
1406
|
+
.replace(/[’]/g, "'")
|
|
1407
|
+
.replace(/\s+/g, ' ')
|
|
1408
|
+
.trim();
|
|
1409
|
+
if (!normalized)
|
|
1410
|
+
return false;
|
|
1411
|
+
const generationVerb = String.raw `(?:generate|create|make|render|produce|design|build|develop|draw)`;
|
|
1412
|
+
const characterSubject = String.raw `(?:character|mascot|brand\s+mascot|creature|avatar|persona|toon|cartoon\s+character)`;
|
|
1413
|
+
const sheetArtifact = String.raw `(?:character\s+sheet|mascot\s+sheet|model\s+sheet|reference\s+(?:sheet|board)|design\s+sheet|turnaround(?:\s+(?:sheet|views?|board))?|expression\s+(?:sheet|row|board)|pose\s+(?:sheet|board))`;
|
|
1414
|
+
const directCharacterSheet = new RegExp(String.raw `\b${characterSubject}\b[\s\S]{0,80}\b(?:sheet|reference\s+(?:sheet|board)|model\s+sheet|turnaround(?:\s+(?:sheet|views?|board))?|expression\s+(?:sheet|row|board)|pose\s+(?:sheet|board))\b`, 'i').test(normalized)
|
|
1415
|
+
|| new RegExp(String.raw `\b${sheetArtifact}\b[\s\S]{0,80}\b${characterSubject}\b`, 'i').test(normalized)
|
|
1416
|
+
|| /\breusable\s+character\s+sheet\b/i.test(normalized);
|
|
1417
|
+
if (!directCharacterSheet)
|
|
1418
|
+
return false;
|
|
1419
|
+
return new RegExp(String.raw `\b${generationVerb}\b[\s\S]{0,180}\b${sheetArtifact}\b`, 'i').test(normalized)
|
|
1420
|
+
|| new RegExp(String.raw `\b${sheetArtifact}\b[\s\S]{0,120}\b(?:image|illustration|artwork|render|board|layout)\b`, 'i').test(normalized)
|
|
1421
|
+
|| new RegExp(String.raw `\b(?:need|want|would\s+like|looking\s+for|please|can\s+you|could\s+you)\b[\s\S]{0,120}\b${sheetArtifact}\b`, 'i').test(normalized);
|
|
1422
|
+
}
|
|
1423
|
+
export function textRequestsSingleCompositeImageOutput(text) {
|
|
1424
|
+
const normalized = text
|
|
1425
|
+
.replace(/[“”]/g, '"')
|
|
1426
|
+
.replace(/[’]/g, "'")
|
|
1427
|
+
.replace(/\s+/g, ' ')
|
|
1428
|
+
.trim();
|
|
1429
|
+
if (!normalized)
|
|
1430
|
+
return false;
|
|
1431
|
+
const generationVerbs = String.raw `(?:generate|create|make|render|produce|design|build|develop|draw)`;
|
|
1432
|
+
const videoGenerationVerbs = String.raw `(?:generate|create|make|render|produce|turn|animate|convert|transform)`;
|
|
1433
|
+
const imageOutputNouns = String.raw `(?:images?|photos?|pictures?|portraits?|posters?|artwork|illustrations?)`;
|
|
1434
|
+
const videoOutputNouns = String.raw `(?:videos?|clips?|animations?|movies?|films?)`;
|
|
1435
|
+
const compositeNouns = String.raw `(?:story\s*board|storyboard|collage|contact\s+sheet|mood\s*board|moodboard|grid|board)`;
|
|
1436
|
+
const adCreativeNouns = String.raw `(?:ads?|advertisements?|banners?|flyers?|posters?|social\s+posts?|campaign\s+creative|marketing\s+(?:creative|graphic)|promo\s+graphic|product\s+graphic)`;
|
|
1437
|
+
const characterSheetImageStage = textRequestsProfessionalCharacterSheetImage(normalized);
|
|
1438
|
+
const directSeedanceVersionFromStoryboard = /\b(?:generate|create|make|render|produce|turn|animate|convert|transform)\b[\s\S]{0,120}\b(?:seedance|seeddance)(?:\s*2(?:\.0)?)?(?:\s+\w+){0,3}\s+(?:version|variant)\b[\s\S]{0,120}\b(?:story\s*board|storyboard)\b/i.test(normalized)
|
|
1439
|
+
|| /\b(?:generate|create|make|render|produce|turn|animate|convert|transform)\b[\s\S]{0,120}\b(?:version|variant)(?:\s+\w+){0,3}\s+(?:seedance|seeddance)(?:\s*2(?:\.0)?)?\b[\s\S]{0,120}\b(?:story\s*board|storyboard)\b/i.test(normalized);
|
|
1440
|
+
if (directSeedanceVersionFromStoryboard)
|
|
1441
|
+
return false;
|
|
1442
|
+
const uploadedStoryboardPanelSource = /\b(?:upload(?:ing|ed)?|attach(?:ing|ed)?|provid(?:ing|ed)?)\b[\s\S]{0,140}\b(?:each|all|the)?\s*(?:of\s+)?(?:the\s+)?\d{1,2}\s+(?:story\s*board\s+|storyboard\s+)?(?:panels?|frames?|keyframes?)\b/i.test(normalized)
|
|
1443
|
+
|| /\b(?:these|uploaded|attached|provided)\s+(?:\d{1,2}\s+)?(?:story\s*board\s+|storyboard\s+)?(?:panels?|frames?|keyframes?)\b/i.test(normalized);
|
|
1444
|
+
const directClipPerPanelRequest = /\b(?:generate|create|make|render|animate|produce)\b[\s\S]{0,120}\b(?:each|all|the)?\s*(?:clips?|videos?|segments?|animations?)\b[\s\S]{0,120}\b(?:per|from|using|with|based\s+on)\b[\s\S]{0,80}\b(?:panels?|frames?|keyframes?|story\s*board|storyboard)\b/i.test(normalized);
|
|
1445
|
+
if (uploadedStoryboardPanelSource && directClipPerPanelRequest)
|
|
1446
|
+
return false;
|
|
1447
|
+
const directVideoFromStoryboard = new RegExp(String.raw `\b(?:generate|create|make|render|produce|turn|animate|convert|transform)\b[\s\S]{0,100}\b(?:videos?|clips?|animations?|movies?|films?)\b[\s\S]{0,140}\b(?:using|with|from|based\s+on|as|following)\b[\s\S]{0,100}\b(?:story\s*board|storyboard)(?:\s+(?:image|photo|picture|reference))?\b`, 'i').test(normalized)
|
|
1448
|
+
|| /\b(?:turn|convert|transform|animate)\b[\s\S]{0,120}\b(?:story\s*board|storyboard)\b[\s\S]{0,80}\b(?:into|to|as)\s+(?:a\s+|the\s+)?(?:videos?|clips?|animations?|movies?|films?)\b/i.test(normalized);
|
|
1449
|
+
const noReferenceConnectorBeforeStoryboard = String.raw `(?:(?!\b(?:using|with|from|based\s+on|as|following)\b)[\s\S])`;
|
|
1450
|
+
const explicitStoryboardImageOrSheetRequest = new RegExp(String.raw `\b(?:generate|create|make|render|produce|design|build|develop|draw)\b${noReferenceConnectorBeforeStoryboard}{0,180}\b(?:video\s+)?(?:story\s*board|storyboard)\s+(?:image|sheet|grid|layout|page|board)\b`, 'i').test(normalized)
|
|
1451
|
+
|| /\b(?:story\s*board|storyboard)\s+(?:image|sheet|grid|layout|page|board)\b[\s\S]{0,120}\b(?:for|of)\s+(?:a\s+|the\s+)?(?:videos?|clips?|animations?|movies?|films?|social\s+media\s+video)\b/i.test(normalized);
|
|
1452
|
+
if (directVideoFromStoryboard
|
|
1453
|
+
&& !explicitStoryboardImageOrSheetRequest
|
|
1454
|
+
&& !/\b(?:story\s*board|storyboard)\b[\s\S]{0,60}\bfirst\b/i.test(normalized)) {
|
|
1455
|
+
return false;
|
|
1456
|
+
}
|
|
1457
|
+
const directVideoOutput = new RegExp(String.raw `\b${videoGenerationVerbs}\b[\s\S]{0,140}\b${videoOutputNouns}\b`, 'i').test(normalized)
|
|
1458
|
+
|| new RegExp(String.raw `\b${videoOutputNouns}\b[\s\S]{0,140}\b${videoGenerationVerbs}\b`, 'i').test(normalized);
|
|
1459
|
+
const rejectsStoryboardPanelOutput = /\b(?:no|without)\s+(?:extra\s+|random\s+|visible\s+|generated\s+|output\s+)?(?:story\s*board|storyboard)\s+panels?\b/i.test(normalized)
|
|
1460
|
+
|| /\b(?:avoid|exclude|never|don't|do\s+not)\b[\s\S]{0,80}\b(?:story\s*board|storyboard)\s+panels?\b/i.test(normalized)
|
|
1461
|
+
|| /\bnot\s+(?:a\s+|the\s+|as\s+)?(?:story\s*board|storyboard)\s+panels?\b/i.test(normalized)
|
|
1462
|
+
|| /\b(?:story\s*board|storyboard)\s+panels?\b[\s\S]{0,80}\b(?:not\s+(?:needed|required|wanted)|without|avoid|exclude|never)\b/i.test(normalized);
|
|
1463
|
+
if (directVideoOutput && rejectsStoryboardPanelOutput && !explicitStoryboardImageOrSheetRequest) {
|
|
1464
|
+
return false;
|
|
1465
|
+
}
|
|
1466
|
+
const explicitGeneratedImageOutput = new RegExp(String.raw `\b${generationVerbs}\b[\s\S]{0,180}\b(?:${imageOutputNouns}|${compositeNouns}\s+image|image\s+(?:story\s*board|storyboard|grid|collage|board))\b`, 'i').test(normalized);
|
|
1467
|
+
const standaloneCompositeImageMention = new RegExp(String.raw `\b(?:story\s*board|storyboard|grid|collage|contact\s+sheet|mood\s*board|moodboard)\s+${imageOutputNouns}\b`, 'i').test(normalized);
|
|
1468
|
+
const storyboardImageStage = new RegExp(String.raw `\b${generationVerbs}\b[\s\S]{0,180}\b(?:video\s+)?(?:story\s*board|storyboard)(?:\s+(?:sequence|sheet|layout|panel|panels|board))?\b`, 'i').test(normalized)
|
|
1469
|
+
|| /\b(?:turn|convert|transform)\b[\s\S]{0,80}\binto\b[\s\S]{0,120}\b(?:video\s+)?(?:story\s*board|storyboard)(?:\s+(?:sequence|sheet|layout|panel|panels|board))?\b/i.test(normalized)
|
|
1470
|
+
|| /\b(?:story\s*board|storyboard)\b[\s\S]{0,80}\bfirst\b/i.test(normalized)
|
|
1471
|
+
|| /\bfirst\b[\s\S]{0,80}\b(?:story\s*board|storyboard)\b/i.test(normalized);
|
|
1472
|
+
const referenceGuidedAdCreative = new RegExp(String.raw `\b${generationVerbs}\b[\s\S]{0,140}\b${adCreativeNouns}\b`, 'i').test(normalized)
|
|
1473
|
+
&& /\b(?:referenc(?:e|es|ed|ing)|use|using|include|incorporate|based\s+on|guided\s+by|with)\b[\s\S]{0,120}\b(?:uploaded|attached|provided|reference|source|input|assets?|images?|photos?|pictures?)\b/i.test(normalized)
|
|
1474
|
+
&& !/\b(?:videos?|clips?|animations?|movies?|films?|commercials?)\b/i.test(normalized);
|
|
1475
|
+
const storyboardImageMentionIsCaptionSource = standaloneCompositeImageMention
|
|
1476
|
+
&& /\b(?:voice\s*over|voiceover|captions?|text|copy|dialogue|lines?)\b[\s\S]{0,180}\b(?:read|shown|displayed|under|below|from|in|on)\b[\s\S]{0,120}\b(?:story\s*board|storyboard)\s+images?\b/i.test(normalized);
|
|
1477
|
+
if (directVideoOutput
|
|
1478
|
+
&& storyboardImageMentionIsCaptionSource
|
|
1479
|
+
&& !explicitStoryboardImageOrSheetRequest
|
|
1480
|
+
&& !referenceGuidedAdCreative
|
|
1481
|
+
&& !characterSheetImageStage) {
|
|
1482
|
+
return false;
|
|
1483
|
+
}
|
|
1484
|
+
if (directVideoOutput
|
|
1485
|
+
&& !explicitGeneratedImageOutput
|
|
1486
|
+
&& standaloneCompositeImageMention
|
|
1487
|
+
&& !storyboardImageStage
|
|
1488
|
+
&& !referenceGuidedAdCreative
|
|
1489
|
+
&& !characterSheetImageStage
|
|
1490
|
+
&& !explicitStoryboardImageOrSheetRequest) {
|
|
1491
|
+
return false;
|
|
1492
|
+
}
|
|
1493
|
+
const explicitImageOutput = explicitGeneratedImageOutput || standaloneCompositeImageMention;
|
|
1494
|
+
if (!explicitImageOutput && !storyboardImageStage && !referenceGuidedAdCreative && !characterSheetImageStage)
|
|
1495
|
+
return false;
|
|
1496
|
+
if (!referenceGuidedAdCreative
|
|
1497
|
+
&& !characterSheetImageStage
|
|
1498
|
+
&& !/\b(?:story\s*board|storyboard|panels?|grid|rows?|columns?|collage|contact\s+sheet|mood\s*board|moodboard|layout|board)\b/i.test(normalized)) {
|
|
1499
|
+
return false;
|
|
1500
|
+
}
|
|
1501
|
+
const directVideoStoryboardReference = new RegExp(String.raw `\b${generationVerbs}\b[\s\S]{0,100}\b(?:videos?|clips?|animations?|movies?|films?)\b[\s\S]{0,140}\b(?:using|with|from|based\s+on|as)\b[\s\S]{0,100}\b(?:story\s*board|storyboard)(?:\s+(?:image|photo|picture|reference))?\b`, 'i').test(normalized);
|
|
1502
|
+
if (directVideoStoryboardReference && !explicitStoryboardImageOrSheetRequest) {
|
|
1503
|
+
return false;
|
|
1504
|
+
}
|
|
1505
|
+
const explicitSeparateOutputs = new RegExp(String.raw `\b${generationVerbs}\b[\s\S]{0,180}\b(?:separate|individual|distinct|different|multiple)\s+(?:images|photos|pictures|keyframes|frames|versions|variations|variants)\b`, 'i').test(normalized)
|
|
1506
|
+
|| /\b(?:let\s+me\s+see|show\s+me|give\s+me|i\s+want\s+to\s+see|want\s+to\s+see|need|make|generate|create|render|produce)\b[\s\S]{0,100}\b(?:\d{1,2}|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen)\s+(?:separate\s+|individual\s+|distinct\s+|different\s+|alternate\s+|new\s+)?(?:images|photos|pictures|keyframes|frames|versions|variations|variants|options|takes)\b/i.test(normalized)
|
|
1507
|
+
|| /\b(?:\d{1,2}|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen)\s+(?:separate\s+|individual\s+|distinct\s+|different\s+|alternate\s+|new\s+)?(?:images|photos|pictures|keyframes|frames|versions|variations|variants|options|takes)\b[\s\S]{0,100}\b(?:then|after|before|animate|animation|video|stitch|transition)\b/i.test(normalized);
|
|
1508
|
+
return !explicitSeparateOutputs;
|
|
1509
|
+
}
|
|
1510
|
+
export function textRequestsDirectMediaAfterPreproduction(text) {
|
|
1511
|
+
const normalized = text
|
|
1512
|
+
.replace(/[“”]/g, '"')
|
|
1513
|
+
.replace(/[’]/g, "'")
|
|
1514
|
+
.replace(/\s+/g, ' ')
|
|
1515
|
+
.trim();
|
|
1516
|
+
if (!normalized)
|
|
1517
|
+
return false;
|
|
1518
|
+
return /\b(?:do\s+not|don't|dont|without|no\s+need\s+to)\b[\s\S]{0,80}\b(?:wait|ask|confirm|review|feedback|approval|approve)\b/i.test(normalized)
|
|
1519
|
+
|| /\b(?:skip|bypass)\b[\s\S]{0,80}\b(?:review|approval|confirmation|feedback)\b/i.test(normalized)
|
|
1520
|
+
|| /\b(?:generate|render|make|create|produce|complete|build|run)\b[\s\S]{0,140}\b(?:everything|all\s+stages|all\s+the\s+way|end\s*-?\s*to\s*-?\s*end|full\s+workflow|complete\s+workflow|the\s+(?:entire|whole)\s+(?:workflow|project|pipeline|thing))\b/i.test(normalized)
|
|
1521
|
+
|| /\b(?:go\s+ahead|proceed|run\s+it|do\s+it\s+now|generate\s+directly|render\s+directly|start\s+rendering|send\s+it)\b/i.test(normalized)
|
|
1522
|
+
|| /\b(?:after|then|next)\b[\s\S]{0,120}\b(?:immediately|directly|right\s+away|without\s+waiting|without\s+asking)\b[\s\S]{0,120}\b(?:generate|render|make|create|animate|produce)\b/i.test(normalized)
|
|
1523
|
+
|| /\b(?:generate|render|make|create|animate|produce)\b[\s\S]{0,120}\b(?:immediately|directly|right\s+away|without\s+waiting|without\s+asking)\b/i.test(normalized);
|
|
1524
|
+
}
|
|
1525
|
+
export function textRequestsPreproductionScriptStage(text) {
|
|
1526
|
+
const normalized = text
|
|
1527
|
+
.replace(/[“”]/g, '"')
|
|
1528
|
+
.replace(/[’]/g, "'")
|
|
1529
|
+
.replace(/\s+/g, ' ')
|
|
1530
|
+
.trim();
|
|
1531
|
+
if (!normalized)
|
|
1532
|
+
return false;
|
|
1533
|
+
const planningArtifactNoun = String.raw `(?:script|screenplay|story\s*board|storyboard|shot\s*list|beat\s*sheet|treatment)`;
|
|
1534
|
+
const suppliedScript = new RegExp(String.raw `\b(?:here\s+is|here's|below\s+is|following\s+is)\b[\s\S]{0,80}\b${planningArtifactNoun}\b`, 'i').test(normalized)
|
|
1535
|
+
|| new RegExp(String.raw `\b(?:provided|supplied|included|existing|approved|final|attached|uploaded)\s+${planningArtifactNoun}\b`, 'i').test(normalized)
|
|
1536
|
+
|| new RegExp(String.raw `\b${planningArtifactNoun}\b\s*(?:is|was|has\s+been|already)?\s*(?:provided|supplied|included|attached|uploaded|pasted|below|approved|final)\b`, 'i').test(normalized)
|
|
1537
|
+
|| /\b(?:use|send|turn|convert|animate|generate|render|make|create)\b[\s\S]{0,120}\b(?:this|that|existing|approved|final|provided|supplied|included)\b[\s\S]{0,80}\b(?:script|screenplay|story\s*board|storyboard|shot\s*list|beat\s*sheet|treatment|storyboard\s+image)\b/i.test(normalized);
|
|
1538
|
+
if (suppliedScript)
|
|
1539
|
+
return false;
|
|
1540
|
+
const explicitStoryboardImageOutput = /\b(?:story\s*board|storyboard)\s+(?:image|sheet|grid|layout|poster|board)\b/i.test(normalized)
|
|
1541
|
+
|| /\b(?:image|sheet|grid|layout|poster|board)\s+(?:story\s*board|storyboard)\b/i.test(normalized);
|
|
1542
|
+
const mentionsWrittenPlanning = /\b(?:script|screenplay|shot\s*list|beat\s*sheet|treatment|story\s+beats?|video\s+plan|creative\s+brief)\b/i.test(normalized);
|
|
1543
|
+
if (explicitStoryboardImageOutput && !mentionsWrittenPlanning)
|
|
1544
|
+
return false;
|
|
1545
|
+
const planningNoun = String.raw `(?:script|screenplay|story\s*board|storyboard|shot\s*list|beat\s*sheet|treatment|story\s+beats?|video\s+plan|creative\s+brief)`;
|
|
1546
|
+
const planningVerb = String.raw `(?:write|draft|develop|create|make|generate|build|design|outline|plan|map\s*out|break\s*down)`;
|
|
1547
|
+
const asksForPlanning = new RegExp(String.raw `\b${planningVerb}\b[\s\S]{0,140}\b${planningNoun}\b`, 'i').test(normalized)
|
|
1548
|
+
|| new RegExp(String.raw `\b${planningNoun}\b[\s\S]{0,120}\b(?:to\s+develop|for\s+review|for\s+approval|before|first|then|next|subsequent|subsequently|later)\b`, 'i').test(normalized)
|
|
1549
|
+
|| /\bnew\s+script\s+to\s+develop\b/i.test(normalized);
|
|
1550
|
+
if (!asksForPlanning)
|
|
1551
|
+
return false;
|
|
1552
|
+
const downstreamMediaContext = /\b(?:video|clip|animation|movie|film|seedance|ltx|image|keyframe|storyboard\s+image|storyboard\s+sheet|model|generation|generate|render|animate)\b/i.test(normalized)
|
|
1553
|
+
|| /\b(?:used\s+by|enough\s+details?|production\s+ready|prompt-ready|model-ready)\b/i.test(normalized);
|
|
1554
|
+
return downstreamMediaContext;
|
|
1555
|
+
}
|
|
890
1556
|
export function planSeedanceStoryboardFallback(input) {
|
|
891
1557
|
const userIntentText = input.userIntentText;
|
|
892
1558
|
const providesLiteralPrompt = input.providesLiteralPrompt
|
|
893
1559
|
?? textProvidesLiteralVideoPrompt(userIntentText);
|
|
894
1560
|
if (providesLiteralPrompt)
|
|
895
1561
|
return null;
|
|
896
|
-
|
|
1562
|
+
const storyboardReferenceMentioned = textMentionsStoryboardReference(userIntentText);
|
|
1563
|
+
const storyboardVisuallyDetected = input.storyboardDetected === true;
|
|
1564
|
+
const allowsStoryboardFallback = storyboardReferenceMentioned || storyboardVisuallyDetected
|
|
1565
|
+
? seedanceStoryboardReferenceFallbackAllowedForVisualDetection(userIntentText)
|
|
1566
|
+
: seedanceStoryboardFallbackAllowedForText(userIntentText);
|
|
1567
|
+
if (!allowsStoryboardFallback)
|
|
897
1568
|
return null;
|
|
898
1569
|
if (input.uploadedImageCount !== 1)
|
|
899
1570
|
return null;
|
|
@@ -903,11 +1574,13 @@ export function planSeedanceStoryboardFallback(input) {
|
|
|
903
1574
|
return null;
|
|
904
1575
|
if (textExplicitlyRequestsGeneratedImageStage(userIntentText))
|
|
905
1576
|
return null;
|
|
1577
|
+
if (textRequestsSingleCompositeImageOutput(userIntentText))
|
|
1578
|
+
return null;
|
|
906
1579
|
if (textRequestsAdjacentImageTransitions(userIntentText))
|
|
907
1580
|
return null;
|
|
908
|
-
const reason =
|
|
1581
|
+
const reason = storyboardReferenceMentioned
|
|
909
1582
|
? 'text_mentions_storyboard'
|
|
910
|
-
:
|
|
1583
|
+
: storyboardVisuallyDetected
|
|
911
1584
|
? 'vision_detected_storyboard'
|
|
912
1585
|
: null;
|
|
913
1586
|
if (!reason)
|
|
@@ -919,11 +1592,14 @@ export function planSeedanceStoryboardFallback(input) {
|
|
|
919
1592
|
const defaultDuration = input.defaultDurationSeconds ?? 5;
|
|
920
1593
|
const maxDuration = input.maxDurationSeconds ?? 15;
|
|
921
1594
|
const minDuration = input.minDurationSeconds ?? 4;
|
|
922
|
-
const
|
|
1595
|
+
const userProvidedDuration = requestedDuration !== null && requestedDuration !== undefined;
|
|
1596
|
+
const intendedDuration = userProvidedDuration
|
|
923
1597
|
? requestedDuration
|
|
924
1598
|
: storyboardDuration ?? defaultDuration;
|
|
1599
|
+
if (userProvidedDuration && intendedDuration > maxDuration)
|
|
1600
|
+
return null;
|
|
925
1601
|
return {
|
|
926
|
-
prompt:
|
|
1602
|
+
prompt: buildSeedanceStoryboardReferencePrompt(userIntentText, reason),
|
|
927
1603
|
duration: Math.max(minDuration, Math.min(maxDuration, intendedDuration)),
|
|
928
1604
|
referenceImageIndices: input.referenceImageIndices ?? [-1],
|
|
929
1605
|
skipPromptProcessing: true,
|
|
@@ -932,6 +1608,16 @@ export function planSeedanceStoryboardFallback(input) {
|
|
|
932
1608
|
...(storyboardAspectRatio ? { aspectRatio: storyboardAspectRatio } : {}),
|
|
933
1609
|
};
|
|
934
1610
|
}
|
|
1611
|
+
function buildSeedanceStoryboardReferencePrompt(userIntentText, reason) {
|
|
1612
|
+
if (reason !== 'vision_detected_storyboard')
|
|
1613
|
+
return SEEDANCE_STORYBOARD_REFERENCE_PROMPT;
|
|
1614
|
+
const additionalDirection = userIntentText
|
|
1615
|
+
.replace(/\s+/g, ' ')
|
|
1616
|
+
.trim();
|
|
1617
|
+
if (!additionalDirection)
|
|
1618
|
+
return SEEDANCE_STORYBOARD_REFERENCE_PROMPT;
|
|
1619
|
+
return `${SEEDANCE_STORYBOARD_REFERENCE_PROMPT} Additional user direction to honor while following the storyboard: ${additionalDirection}`;
|
|
1620
|
+
}
|
|
935
1621
|
function valuePresent(value) {
|
|
936
1622
|
return value !== undefined && value !== null && value !== '';
|
|
937
1623
|
}
|
|
@@ -984,6 +1670,7 @@ export function planCliVideoBrain(input) {
|
|
|
984
1670
|
plan.duration = inferredDuration;
|
|
985
1671
|
}
|
|
986
1672
|
if (!cliSet.width && !cliSet.height) {
|
|
1673
|
+
const aspectRatio = inferExplicitAspectRatioFromText(text);
|
|
987
1674
|
const exactDimensions = inferExplicitPixelDimensionsFromText(text);
|
|
988
1675
|
if (exactDimensions) {
|
|
989
1676
|
plan.width = exactDimensions.width;
|
|
@@ -991,12 +1678,11 @@ export function planCliVideoBrain(input) {
|
|
|
991
1678
|
plan.dimensionSource = 'exact';
|
|
992
1679
|
}
|
|
993
1680
|
else if (!cliSet.targetResolution) {
|
|
994
|
-
const shortSide =
|
|
1681
|
+
const shortSide = inferRequestedVideoResolutionShortSideFromText(text);
|
|
995
1682
|
if (shortSide !== null) {
|
|
996
1683
|
plan.targetResolution = shortSide;
|
|
997
1684
|
}
|
|
998
1685
|
else {
|
|
999
|
-
const aspectRatio = inferExplicitAspectRatioFromText(text);
|
|
1000
1686
|
if (aspectRatio) {
|
|
1001
1687
|
const dimensions = dimensionsForAspectRatio(input.width ?? 1920, input.height ?? 1088, aspectRatio.text);
|
|
1002
1688
|
if (dimensions) {
|
|
@@ -1007,6 +1693,9 @@ export function planCliVideoBrain(input) {
|
|
|
1007
1693
|
}
|
|
1008
1694
|
}
|
|
1009
1695
|
}
|
|
1696
|
+
if (aspectRatio && !exactDimensions) {
|
|
1697
|
+
plan.aspectRatio = aspectRatio.text;
|
|
1698
|
+
}
|
|
1010
1699
|
}
|
|
1011
1700
|
const uploadedImageCount = (valuePresent(input.refImage) ? 1 : 0) +
|
|
1012
1701
|
(valuePresent(input.refImageEnd) ? 1 : 0);
|
|
@@ -1045,6 +1734,3049 @@ export function planCliVideoBrain(input) {
|
|
|
1045
1734
|
}
|
|
1046
1735
|
return plan;
|
|
1047
1736
|
}
|
|
1737
|
+
export const STORYBOARD_PLANNING_CONTRACT_SCHEMA_VERSION = 'storyboard-planning-contract/v1';
|
|
1738
|
+
export const STORYBOARD_PLANNING_CONTRACT_JSON_SCHEMA = {
|
|
1739
|
+
type: 'object',
|
|
1740
|
+
additionalProperties: false,
|
|
1741
|
+
properties: {
|
|
1742
|
+
schemaVersion: {
|
|
1743
|
+
type: 'string',
|
|
1744
|
+
const: STORYBOARD_PLANNING_CONTRACT_SCHEMA_VERSION,
|
|
1745
|
+
},
|
|
1746
|
+
source: {
|
|
1747
|
+
type: 'string',
|
|
1748
|
+
enum: ['llm_schema', 'assistant_metadata', 'user_schema', 'fallback_text'],
|
|
1749
|
+
},
|
|
1750
|
+
layout: {
|
|
1751
|
+
type: 'object',
|
|
1752
|
+
additionalProperties: false,
|
|
1753
|
+
properties: {
|
|
1754
|
+
source: {
|
|
1755
|
+
type: 'string',
|
|
1756
|
+
enum: ['llm_schema', 'assistant_metadata', 'user_schema', 'fallback_text'],
|
|
1757
|
+
},
|
|
1758
|
+
storyboardCanvasAspectRatio: { type: 'string' },
|
|
1759
|
+
storyboardCellAspectRatio: { type: 'string' },
|
|
1760
|
+
targetVideoAspectRatio: { type: 'string' },
|
|
1761
|
+
boardDimensions: { type: 'string' },
|
|
1762
|
+
},
|
|
1763
|
+
required: [
|
|
1764
|
+
'source',
|
|
1765
|
+
'storyboardCanvasAspectRatio',
|
|
1766
|
+
'storyboardCellAspectRatio',
|
|
1767
|
+
'targetVideoAspectRatio',
|
|
1768
|
+
'boardDimensions',
|
|
1769
|
+
],
|
|
1770
|
+
},
|
|
1771
|
+
scenes: {
|
|
1772
|
+
type: 'array',
|
|
1773
|
+
items: {
|
|
1774
|
+
type: 'object',
|
|
1775
|
+
additionalProperties: false,
|
|
1776
|
+
properties: {
|
|
1777
|
+
id: { type: 'string' },
|
|
1778
|
+
index: { type: 'integer', minimum: 1, maximum: 24 },
|
|
1779
|
+
visibleText: {
|
|
1780
|
+
type: 'array',
|
|
1781
|
+
items: { type: 'string' },
|
|
1782
|
+
},
|
|
1783
|
+
metadataLabels: {
|
|
1784
|
+
type: 'array',
|
|
1785
|
+
items: { type: 'string' },
|
|
1786
|
+
},
|
|
1787
|
+
referenceUsage: {
|
|
1788
|
+
type: 'array',
|
|
1789
|
+
items: { type: 'string' },
|
|
1790
|
+
},
|
|
1791
|
+
},
|
|
1792
|
+
required: ['id', 'index', 'visibleText', 'metadataLabels', 'referenceUsage'],
|
|
1793
|
+
},
|
|
1794
|
+
},
|
|
1795
|
+
endCard: {
|
|
1796
|
+
type: 'object',
|
|
1797
|
+
additionalProperties: false,
|
|
1798
|
+
properties: {
|
|
1799
|
+
visibleText: {
|
|
1800
|
+
type: 'array',
|
|
1801
|
+
items: { type: 'string' },
|
|
1802
|
+
},
|
|
1803
|
+
metadataLabels: {
|
|
1804
|
+
type: 'array',
|
|
1805
|
+
items: { type: 'string' },
|
|
1806
|
+
},
|
|
1807
|
+
},
|
|
1808
|
+
required: ['visibleText', 'metadataLabels'],
|
|
1809
|
+
},
|
|
1810
|
+
metadataLabels: {
|
|
1811
|
+
type: 'array',
|
|
1812
|
+
items: { type: 'string' },
|
|
1813
|
+
},
|
|
1814
|
+
},
|
|
1815
|
+
required: ['schemaVersion', 'source', 'layout', 'scenes', 'endCard', 'metadataLabels'],
|
|
1816
|
+
};
|
|
1817
|
+
export function buildStoryboardPlanningResponseFormat(name = 'preproduction_storyboard_planning') {
|
|
1818
|
+
return {
|
|
1819
|
+
type: 'json_schema',
|
|
1820
|
+
json_schema: {
|
|
1821
|
+
name,
|
|
1822
|
+
strict: true,
|
|
1823
|
+
schema: {
|
|
1824
|
+
type: 'object',
|
|
1825
|
+
additionalProperties: false,
|
|
1826
|
+
properties: {
|
|
1827
|
+
notes: { type: 'string' },
|
|
1828
|
+
storyboardPlanningContract: STORYBOARD_PLANNING_CONTRACT_JSON_SCHEMA,
|
|
1829
|
+
},
|
|
1830
|
+
required: ['notes', 'storyboardPlanningContract'],
|
|
1831
|
+
},
|
|
1832
|
+
},
|
|
1833
|
+
};
|
|
1834
|
+
}
|
|
1835
|
+
export const STORYBOARD_DEFAULT_MIN_FRAMES = 4;
|
|
1836
|
+
export const STORYBOARD_DEFAULT_MAX_FRAMES = 12;
|
|
1837
|
+
const STORYBOARD_COUNT_WORDS = {
|
|
1838
|
+
one: 1,
|
|
1839
|
+
two: 2,
|
|
1840
|
+
three: 3,
|
|
1841
|
+
four: 4,
|
|
1842
|
+
five: 5,
|
|
1843
|
+
six: 6,
|
|
1844
|
+
seven: 7,
|
|
1845
|
+
eight: 8,
|
|
1846
|
+
nine: 9,
|
|
1847
|
+
ten: 10,
|
|
1848
|
+
eleven: 11,
|
|
1849
|
+
twelve: 12,
|
|
1850
|
+
thirteen: 13,
|
|
1851
|
+
fourteen: 14,
|
|
1852
|
+
fifteen: 15,
|
|
1853
|
+
sixteen: 16,
|
|
1854
|
+
seventeen: 17,
|
|
1855
|
+
eighteen: 18,
|
|
1856
|
+
nineteen: 19,
|
|
1857
|
+
twenty: 20,
|
|
1858
|
+
twentyone: 21,
|
|
1859
|
+
twentytwo: 22,
|
|
1860
|
+
twentythree: 23,
|
|
1861
|
+
twentyfour: 24,
|
|
1862
|
+
};
|
|
1863
|
+
const DEFAULT_STORYBOARD_TIMING_RULES = {
|
|
1864
|
+
normalWordsPerSecondMin: 2.0,
|
|
1865
|
+
normalWordsPerSecondMax: 3.3,
|
|
1866
|
+
fastWordsPerSecondMax: 4.0,
|
|
1867
|
+
minEndCardHoldSec: 2.0,
|
|
1868
|
+
minPunchlineSec: 0.5,
|
|
1869
|
+
toleranceSec: 0.25,
|
|
1870
|
+
};
|
|
1871
|
+
function storyboardGcd(a, b) {
|
|
1872
|
+
let x = Math.abs(a);
|
|
1873
|
+
let y = Math.abs(b);
|
|
1874
|
+
while (y > 0) {
|
|
1875
|
+
const next = x % y;
|
|
1876
|
+
x = y;
|
|
1877
|
+
y = next;
|
|
1878
|
+
}
|
|
1879
|
+
return x || 1;
|
|
1880
|
+
}
|
|
1881
|
+
function formatStoryboardRatio(width, height) {
|
|
1882
|
+
const divisor = storyboardGcd(width, height);
|
|
1883
|
+
return `${width / divisor}:${height / divisor}`;
|
|
1884
|
+
}
|
|
1885
|
+
function ratioFromStoryboardAspectWords(value) {
|
|
1886
|
+
if (/^(?:portrait|vertical|9\s*:\s*16)$/i.test(value.trim()))
|
|
1887
|
+
return '9:16';
|
|
1888
|
+
if (/^(?:landscape|horizontal|widescreen|16\s*:\s*9)$/i.test(value.trim()))
|
|
1889
|
+
return '16:9';
|
|
1890
|
+
const match = value.match(/(\d{1,4})\s*:\s*(\d{1,4})/);
|
|
1891
|
+
if (!match)
|
|
1892
|
+
return value;
|
|
1893
|
+
const width = Number(match[1]);
|
|
1894
|
+
const height = Number(match[2]);
|
|
1895
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
1896
|
+
return '';
|
|
1897
|
+
}
|
|
1898
|
+
return `${width}:${height}`;
|
|
1899
|
+
}
|
|
1900
|
+
function inferStoryboardBoardAspectDirective(text) {
|
|
1901
|
+
const aspectToken = String.raw `(?:portrait|vertical|landscape|horizontal|widescreen|\d{1,4}\s*:\s*\d{1,4})`;
|
|
1902
|
+
const patterns = [
|
|
1903
|
+
/\bStoryboard layout target\s*:\s*board\s+([^\n;,]+)/i,
|
|
1904
|
+
/\bStoryboard layout\s*:[^\n]*\bboard\s+([^\n;,]+)/i,
|
|
1905
|
+
/\bDEFAULT STORYBOARD PAGE LAYOUT\s*:\s*Use a\s+([^\n.]+?)\s+storyboard\s+canvas\/page\b/i,
|
|
1906
|
+
/\bOverall storyboard canvas(?:\s+aspect ratio)?\s*:\s*(?:\d{3,5}\s*x\s*\d{3,5}\s+pixels\s*\()?([^\n.)]+)/i,
|
|
1907
|
+
new RegExp(String.raw `\b(${aspectToken})(?:\s*\([^)]{0,80}\))?[^\n]{0,120}\b(?:video\s+)?(?:story\s*board|storyboard)\s+(?:image|sheet|layout|page|board|canvas)\b`, 'i'),
|
|
1908
|
+
new RegExp(String.raw `\b(?:story\s*board|storyboard)?\s*(?:board|canvas|page|sheet)\b[^\n]{0,80}\b(?:must|should|use|be|is|as|at)\b[^\n]{0,80}\b(${aspectToken})\b`, 'i'),
|
|
1909
|
+
];
|
|
1910
|
+
for (const pattern of patterns) {
|
|
1911
|
+
const match = text.match(pattern);
|
|
1912
|
+
if (!match)
|
|
1913
|
+
continue;
|
|
1914
|
+
const ratio = ratioFromStoryboardAspectWords(match[1]);
|
|
1915
|
+
if (ratio)
|
|
1916
|
+
return ratio;
|
|
1917
|
+
}
|
|
1918
|
+
return null;
|
|
1919
|
+
}
|
|
1920
|
+
function inferStoryboardAspectNearUnit(text, unitPattern, rejectBetweenPattern) {
|
|
1921
|
+
const aspectPattern = /\b(portrait|vertical|landscape|horizontal|widescreen|\d{1,4}\s*:\s*\d{1,4})\b/gi;
|
|
1922
|
+
const unit = new RegExp(String.raw `\b(?:${unitPattern})\b`, 'i');
|
|
1923
|
+
const candidates = [];
|
|
1924
|
+
for (const match of text.matchAll(aspectPattern)) {
|
|
1925
|
+
const aspect = match[1];
|
|
1926
|
+
const ratio = ratioFromStoryboardAspectWords(aspect);
|
|
1927
|
+
if (!ratio)
|
|
1928
|
+
continue;
|
|
1929
|
+
const specificity = /\d/.test(aspect) ? 0 : 1;
|
|
1930
|
+
const start = match.index ?? 0;
|
|
1931
|
+
const end = start + match[0].length;
|
|
1932
|
+
const after = text.slice(end, Math.min(text.length, end + 80));
|
|
1933
|
+
const afterUnit = after.match(unit);
|
|
1934
|
+
if (afterUnit?.index !== undefined) {
|
|
1935
|
+
const between = after.slice(0, afterUnit.index);
|
|
1936
|
+
if (!rejectBetweenPattern.test(between)) {
|
|
1937
|
+
candidates.push({ ratio, distance: between.length, specificity });
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
const before = text.slice(Math.max(0, start - 80), start);
|
|
1941
|
+
const beforeUnitMatches = Array.from(before.matchAll(new RegExp(String.raw `\b(?:${unitPattern})\b`, 'gi')));
|
|
1942
|
+
const beforeUnit = beforeUnitMatches[beforeUnitMatches.length - 1];
|
|
1943
|
+
if (beforeUnit?.index !== undefined) {
|
|
1944
|
+
const between = before.slice(beforeUnit.index + beforeUnit[0].length);
|
|
1945
|
+
if (!rejectBetweenPattern.test(between)) {
|
|
1946
|
+
candidates.push({ ratio, distance: between.length, specificity });
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
candidates.sort((a, b) => a.specificity - b.specificity || a.distance - b.distance);
|
|
1951
|
+
return candidates[0]?.ratio ?? null;
|
|
1952
|
+
}
|
|
1953
|
+
function inferStoryboardBoardAspectRatio(text) {
|
|
1954
|
+
const boardDirective = inferStoryboardBoardAspectDirective(text);
|
|
1955
|
+
if (boardDirective)
|
|
1956
|
+
return boardDirective;
|
|
1957
|
+
const explicitPixels = inferExplicitPixelDimensionsFromText(text);
|
|
1958
|
+
if (explicitPixels) {
|
|
1959
|
+
return formatStoryboardRatio(explicitPixels.width, explicitPixels.height);
|
|
1960
|
+
}
|
|
1961
|
+
const boardAspect = inferStoryboardAspectNearUnit(text, String.raw `board|canvas|image|poster|sheet|layout|story\s*board|storyboard|output`, /\b(?:cell|cells|frame|frames|panel|panels|still|stills|thumbnail|thumbnails|shot|shots|video)\b/i);
|
|
1962
|
+
if (boardAspect)
|
|
1963
|
+
return boardAspect;
|
|
1964
|
+
const explicitRatio = inferExplicitAspectRatioFromText(text);
|
|
1965
|
+
if (explicitRatio)
|
|
1966
|
+
return `${explicitRatio.width}:${explicitRatio.height}`;
|
|
1967
|
+
if (textMentionsPortraitSocialFormat(text))
|
|
1968
|
+
return '9:16';
|
|
1969
|
+
return '16:9';
|
|
1970
|
+
}
|
|
1971
|
+
function inferExplicitStoryboardTargetVideoAspectRatio(text) {
|
|
1972
|
+
const storyboardLayoutTarget = text.match(/\bStoryboard layout(?: target)?\s*:[^\n]*\bvideo\s+(\d{1,4})\s*:\s*(\d{1,4})\b/i);
|
|
1973
|
+
if (storyboardLayoutTarget) {
|
|
1974
|
+
const width = Number(storyboardLayoutTarget[1]);
|
|
1975
|
+
const height = Number(storyboardLayoutTarget[2]);
|
|
1976
|
+
if (width > 0 && height > 0)
|
|
1977
|
+
return `${width}:${height}`;
|
|
1978
|
+
}
|
|
1979
|
+
const videoOutputAspect = inferStoryboardAspectNearUnit(text, String.raw `target\s+video|final\s+video|output\s+video|video\s+output|actual\s+video|seedance\s+video`, /\b(?:story\s*board|storyboard|board|canvas|page|sheet|poster)\b/i);
|
|
1980
|
+
if (videoOutputAspect)
|
|
1981
|
+
return videoOutputAspect;
|
|
1982
|
+
const explicitTargetPattern = /\b(?:target|final|output|actual|seedance|video|clip|film|commercial|promo)\b([\s\S]{0,80}?)\b(\d{1,4})\s*:\s*(\d{1,4})\b/gi;
|
|
1983
|
+
for (const explicitTarget of text.matchAll(explicitTargetPattern)) {
|
|
1984
|
+
const between = explicitTarget[1] ?? '';
|
|
1985
|
+
if (/\b(?:story\s*board|storyboard|board|canvas|page|sheet|poster)\b/i.test(between))
|
|
1986
|
+
continue;
|
|
1987
|
+
const width = Number(explicitTarget[2]);
|
|
1988
|
+
const height = Number(explicitTarget[3]);
|
|
1989
|
+
if (width > 0 && height > 0)
|
|
1990
|
+
return `${width}:${height}`;
|
|
1991
|
+
}
|
|
1992
|
+
return null;
|
|
1993
|
+
}
|
|
1994
|
+
function inferStoryboardTargetVideoAspectRatio(text, boardAspectRatio) {
|
|
1995
|
+
const explicitTarget = inferExplicitStoryboardTargetVideoAspectRatio(text);
|
|
1996
|
+
if (explicitTarget)
|
|
1997
|
+
return explicitTarget;
|
|
1998
|
+
if (/\b(?:portrait|vertical|9\s*:\s*16)\b/i.test(text) || textMentionsPortraitSocialFormat(text)) {
|
|
1999
|
+
return '9:16';
|
|
2000
|
+
}
|
|
2001
|
+
if (/\b(?:landscape|horizontal|widescreen|16\s*:\s*9|youtube)\b/i.test(text)) {
|
|
2002
|
+
return '16:9';
|
|
2003
|
+
}
|
|
2004
|
+
return boardAspectRatio;
|
|
2005
|
+
}
|
|
2006
|
+
function inferStoryboardCellAspectRatio(text, targetVideoAspectRatio) {
|
|
2007
|
+
const explicitCell = inferStoryboardAspectNearUnit(text, String.raw `cell|cells|frame|frames|panel|panels|still|stills|thumbnail|thumbnails|shot|shots`, /\b(?:board|canvas|image|poster|sheet|layout|story\s*board|storyboard|output|format)\b/i);
|
|
2008
|
+
if (explicitCell) {
|
|
2009
|
+
return explicitCell;
|
|
2010
|
+
}
|
|
2011
|
+
if (/\b(?:portrait|vertical|9\s*:\s*16)\b[\s\S]{0,80}\b(?:cell|cells|frame|frames|panel|panels|still|stills|thumbnail|thumbnails|shot|shots)\b/i.test(text)) {
|
|
2012
|
+
return '9:16';
|
|
2013
|
+
}
|
|
2014
|
+
if (/\b(?:cell|cells|frame|frames|panel|panels|still|stills|thumbnail|thumbnails|shot|shots)\b[\s\S]{0,80}\b(?:portrait|vertical|9\s*:\s*16)\b/i.test(text)) {
|
|
2015
|
+
return '9:16';
|
|
2016
|
+
}
|
|
2017
|
+
if (/\b(?:landscape|horizontal|widescreen|letterbox|16\s*:\s*9)\b[\s\S]{0,80}\b(?:cell|cells|frame|frames|panel|panels|still|stills|thumbnail|thumbnails|shot|shots)\b/i.test(text)) {
|
|
2018
|
+
return '16:9';
|
|
2019
|
+
}
|
|
2020
|
+
if (/\b(?:cell|cells|frame|frames|panel|panels|still|stills|thumbnail|thumbnails|shot|shots)\b[\s\S]{0,80}\b(?:landscape|horizontal|widescreen|letterbox|16\s*:\s*9)\b/i.test(text)) {
|
|
2021
|
+
return '16:9';
|
|
2022
|
+
}
|
|
2023
|
+
return targetVideoAspectRatio;
|
|
2024
|
+
}
|
|
2025
|
+
function describeVideoFrameShape(cellAspectRatio) {
|
|
2026
|
+
const cellOrientation = parseAspectRatioOrientation(cellAspectRatio);
|
|
2027
|
+
if (cellOrientation === 'portrait')
|
|
2028
|
+
return `tall ${cellAspectRatio} portrait video-frame rectangle`;
|
|
2029
|
+
if (cellOrientation === 'landscape')
|
|
2030
|
+
return `wide ${cellAspectRatio} landscape video-frame rectangle`;
|
|
2031
|
+
if (cellOrientation === 'square')
|
|
2032
|
+
return `square ${cellAspectRatio} video-frame area`;
|
|
2033
|
+
return `${cellAspectRatio} video-frame rectangle`;
|
|
2034
|
+
}
|
|
2035
|
+
function describePortraitLetterboxCellArrangement(frameCount, cellAspectRatio) {
|
|
2036
|
+
const frameShape = describeVideoFrameShape(cellAspectRatio);
|
|
2037
|
+
if (frameCount <= 4) {
|
|
2038
|
+
return `${frameCount} numbered scene slots, each containing one ${frameShape}, stacked in a portrait sheet with compact labels outside the rectangles`;
|
|
2039
|
+
}
|
|
2040
|
+
const columns = frameCount <= 8 ? 2 : frameCount <= 15 ? 3 : 4;
|
|
2041
|
+
const rows = Math.ceil(frameCount / columns);
|
|
2042
|
+
return [
|
|
2043
|
+
`${frameCount} numbered scene slots arranged as a ${columns}-column x ${rows}-row grid inside a portrait sheet`,
|
|
2044
|
+
`each slot contains one ${frameShape} with compact labels outside the rectangle`,
|
|
2045
|
+
'use unused grid slots as margin/notes space only',
|
|
2046
|
+
].join('; ');
|
|
2047
|
+
}
|
|
2048
|
+
function describeLandscapePortraitCellArrangement(frameCount, cellAspectRatio) {
|
|
2049
|
+
const frameShape = describeVideoFrameShape(cellAspectRatio);
|
|
2050
|
+
if (frameCount <= 4) {
|
|
2051
|
+
return `${frameCount} numbered scene slots, each containing one ${frameShape}, arranged cleanly inside a landscape board with compact labels outside the rectangles`;
|
|
2052
|
+
}
|
|
2053
|
+
const rows = frameCount <= 8 ? 2 : frameCount <= 15 ? 3 : 4;
|
|
2054
|
+
const columns = Math.ceil(frameCount / rows);
|
|
2055
|
+
return [
|
|
2056
|
+
`${frameCount} numbered scene slots arranged as a ${rows}-row x ${columns}-column grid inside a landscape board`,
|
|
2057
|
+
`each slot contains one ${frameShape} with compact labels outside the rectangle`,
|
|
2058
|
+
'use unused grid slots as margin/notes space only',
|
|
2059
|
+
].join('; ');
|
|
2060
|
+
}
|
|
2061
|
+
function describeSingleOrientationStoryboardArrangement(boardAspectRatio, cellAspectRatio, frameCount) {
|
|
2062
|
+
const boardOrientation = parseAspectRatioOrientation(boardAspectRatio);
|
|
2063
|
+
const frameShape = describeVideoFrameShape(cellAspectRatio);
|
|
2064
|
+
if (boardOrientation === 'portrait') {
|
|
2065
|
+
return {
|
|
2066
|
+
layoutKind: 'portrait_grid',
|
|
2067
|
+
layoutDescription: `${frameCount} numbered scene slots arranged in a clean vertical storyboard grid; each slot contains one ${frameShape} with compact labels outside the rectangle`,
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
const boardLabel = boardOrientation === 'square' ? 'square board' : 'landscape board';
|
|
2071
|
+
return {
|
|
2072
|
+
layoutKind: 'landscape_grid',
|
|
2073
|
+
layoutDescription: frameCount === 6 && boardOrientation !== 'square'
|
|
2074
|
+
? `2 rows x 3 columns of numbered scene slots inside a ${boardLabel}; each slot contains one ${frameShape} with compact labels outside the rectangle`
|
|
2075
|
+
: `${frameCount} numbered scene slots in a balanced ${boardLabel} storyboard grid; each slot contains one ${frameShape} with compact labels outside the rectangle`,
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
function describeStoryboardLayout(boardAspectRatio, cellAspectRatio, frameCount) {
|
|
2079
|
+
const boardOrientation = parseAspectRatioOrientation(boardAspectRatio);
|
|
2080
|
+
const cellOrientation = parseAspectRatioOrientation(cellAspectRatio);
|
|
2081
|
+
if (boardOrientation === 'portrait' && cellOrientation === 'landscape') {
|
|
2082
|
+
return {
|
|
2083
|
+
layoutKind: 'portrait_letterbox_cells',
|
|
2084
|
+
layoutDescription: describePortraitLetterboxCellArrangement(frameCount, cellAspectRatio),
|
|
2085
|
+
};
|
|
2086
|
+
}
|
|
2087
|
+
if (boardOrientation === 'landscape' && cellOrientation === 'portrait') {
|
|
2088
|
+
return {
|
|
2089
|
+
layoutKind: 'landscape_portrait_cells',
|
|
2090
|
+
layoutDescription: describeLandscapePortraitCellArrangement(frameCount, cellAspectRatio),
|
|
2091
|
+
};
|
|
2092
|
+
}
|
|
2093
|
+
return describeSingleOrientationStoryboardArrangement(boardAspectRatio, cellAspectRatio, frameCount);
|
|
2094
|
+
}
|
|
2095
|
+
function normalizeStoryboardBoardDimensions(value) {
|
|
2096
|
+
if (typeof value !== 'string')
|
|
2097
|
+
return null;
|
|
2098
|
+
const match = value.trim().match(/\b(\d{3,5})\s*[x:]\s*(\d{3,5})\b/i);
|
|
2099
|
+
if (!match)
|
|
2100
|
+
return null;
|
|
2101
|
+
const width = Number(match[1]);
|
|
2102
|
+
const height = Number(match[2]);
|
|
2103
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0)
|
|
2104
|
+
return null;
|
|
2105
|
+
return `${Math.trunc(width)}x${Math.trunc(height)}`;
|
|
2106
|
+
}
|
|
2107
|
+
function storyboardPlanningSourceFromContract(contract) {
|
|
2108
|
+
return contract?.layout?.source ?? contract?.source ?? 'fallback_text';
|
|
2109
|
+
}
|
|
2110
|
+
function applyStoryboardPlanningLayoutContract(fallback, contract, frameCount) {
|
|
2111
|
+
const layoutContract = contract?.layout;
|
|
2112
|
+
if (!layoutContract)
|
|
2113
|
+
return fallback;
|
|
2114
|
+
const boardAspectRatio = normalizeAspectRatio(layoutContract.storyboardCanvasAspectRatio)
|
|
2115
|
+
?? fallback.boardAspectRatio;
|
|
2116
|
+
const cellAspectRatio = normalizeAspectRatio(layoutContract.storyboardCellAspectRatio)
|
|
2117
|
+
?? fallback.cellAspectRatio;
|
|
2118
|
+
const targetVideoAspectRatio = normalizeAspectRatio(layoutContract.targetVideoAspectRatio)
|
|
2119
|
+
?? cellAspectRatio
|
|
2120
|
+
?? fallback.targetVideoAspectRatio;
|
|
2121
|
+
const layout = describeStoryboardLayout(boardAspectRatio, cellAspectRatio, frameCount);
|
|
2122
|
+
const boardDimensions = normalizeStoryboardBoardDimensions(layoutContract.boardDimensions)
|
|
2123
|
+
?? fallback.boardDimensions;
|
|
2124
|
+
return {
|
|
2125
|
+
boardAspectRatio,
|
|
2126
|
+
cellAspectRatio,
|
|
2127
|
+
targetVideoAspectRatio,
|
|
2128
|
+
...layout,
|
|
2129
|
+
...(boardDimensions ? { boardDimensions } : {}),
|
|
2130
|
+
};
|
|
2131
|
+
}
|
|
2132
|
+
export function inferStoryboardLayoutSpec(userIntentText, frameCount, planningContract) {
|
|
2133
|
+
const explicitPixels = inferExplicitPixelDimensionsFromText(userIntentText);
|
|
2134
|
+
const boardAspectRatio = inferStoryboardBoardAspectRatio(userIntentText);
|
|
2135
|
+
const explicitTargetVideoAspectRatio = inferExplicitStoryboardTargetVideoAspectRatio(userIntentText);
|
|
2136
|
+
const inferredTargetVideoAspectRatio = explicitTargetVideoAspectRatio
|
|
2137
|
+
?? inferStoryboardTargetVideoAspectRatio(userIntentText, boardAspectRatio);
|
|
2138
|
+
const cellAspectRatio = inferStoryboardCellAspectRatio(userIntentText, inferredTargetVideoAspectRatio);
|
|
2139
|
+
const targetVideoAspectRatio = explicitTargetVideoAspectRatio ?? cellAspectRatio;
|
|
2140
|
+
const layout = describeStoryboardLayout(boardAspectRatio, cellAspectRatio, frameCount);
|
|
2141
|
+
const fallback = {
|
|
2142
|
+
boardAspectRatio,
|
|
2143
|
+
cellAspectRatio,
|
|
2144
|
+
targetVideoAspectRatio,
|
|
2145
|
+
...layout,
|
|
2146
|
+
...(explicitPixels ? { boardDimensions: `${explicitPixels.width}x${explicitPixels.height}` } : {}),
|
|
2147
|
+
};
|
|
2148
|
+
return applyStoryboardPlanningLayoutContract(fallback, planningContract, frameCount);
|
|
2149
|
+
}
|
|
2150
|
+
function clampStoryboardDefaultFrameCount(value) {
|
|
2151
|
+
return Math.max(STORYBOARD_DEFAULT_MIN_FRAMES, Math.min(STORYBOARD_DEFAULT_MAX_FRAMES, Math.round(value)));
|
|
2152
|
+
}
|
|
2153
|
+
function normalizeStoryboardCountToken(value) {
|
|
2154
|
+
if (!value)
|
|
2155
|
+
return null;
|
|
2156
|
+
const normalized = value.toLowerCase().replace(/[\s-]/g, '');
|
|
2157
|
+
const parsed = /^\d+$/.test(normalized)
|
|
2158
|
+
? Number(normalized)
|
|
2159
|
+
: STORYBOARD_COUNT_WORDS[normalized];
|
|
2160
|
+
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 24 ? parsed : null;
|
|
2161
|
+
}
|
|
2162
|
+
export function inferExplicitStoryboardFrameCountFromText(text) {
|
|
2163
|
+
const normalized = text
|
|
2164
|
+
.replace(/[“”]/g, '"')
|
|
2165
|
+
.replace(/[’]/g, "'")
|
|
2166
|
+
.trim();
|
|
2167
|
+
if (!normalized)
|
|
2168
|
+
return null;
|
|
2169
|
+
const countToken = String.raw `(?:\d{1,2}|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty(?:[-\s]?(?:one|two|three|four))?)`;
|
|
2170
|
+
const countModifiers = String.raw `(?:(?:timed|timecoded|time-coded|sequential|distinct|separate|individual|video|storyboard|story-board|key|scene|shot|clean|polished|portrait|landscape|widescreen)\s+){0,5}`;
|
|
2171
|
+
const storyboardUnits = String.raw `(?:panels?|frames?|storyboard\s+frames?|shots?|keyframes?|beats?|cells?|stills?|thumbnails?)`;
|
|
2172
|
+
const candidates = [];
|
|
2173
|
+
const patterns = [
|
|
2174
|
+
new RegExp(String.raw `\b(${countToken})\s*[- ]?\s*${countModifiers}${storyboardUnits}\b`, 'gi'),
|
|
2175
|
+
new RegExp(String.raw `\b${storyboardUnits}\s*(?:count|total)?\s*[:=]\s*(${countToken})\b`, 'gi'),
|
|
2176
|
+
];
|
|
2177
|
+
for (const pattern of patterns) {
|
|
2178
|
+
for (const match of normalized.matchAll(pattern)) {
|
|
2179
|
+
const count = normalizeStoryboardCountToken(match[1]);
|
|
2180
|
+
if (count !== null)
|
|
2181
|
+
candidates.push(count);
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
return candidates.length > 0 ? Math.max(...candidates) : null;
|
|
2185
|
+
}
|
|
2186
|
+
function inferMarkdownStoryboardTableFrameCount(text) {
|
|
2187
|
+
const count = text
|
|
2188
|
+
.split(/\r?\n/)
|
|
2189
|
+
.filter(line => {
|
|
2190
|
+
if (!line.trim().startsWith('|'))
|
|
2191
|
+
return false;
|
|
2192
|
+
return /\b(?:\d{1,2}:\d{2}(?:\.\d+)?|\d{1,3}(?:\.\d+)?)\s*(?:s|sec|secs|seconds?)?\s*(?:-|to|\u2013|\u2014)\s*(?:\d{1,2}:\d{2}(?:\.\d+)?|\d{1,3}(?:\.\d+)?)\b/i.test(line);
|
|
2193
|
+
})
|
|
2194
|
+
.length;
|
|
2195
|
+
return count >= 1 && count <= 24 ? count : null;
|
|
2196
|
+
}
|
|
2197
|
+
export function inferStoryboardFrameCountFromScriptText(text) {
|
|
2198
|
+
if (!text)
|
|
2199
|
+
return null;
|
|
2200
|
+
const canonicalText = canonicalStoryboardScriptContext(text) || text;
|
|
2201
|
+
const tableCount = inferMarkdownStoryboardTableFrameCount(canonicalText);
|
|
2202
|
+
if (tableCount)
|
|
2203
|
+
return tableCount;
|
|
2204
|
+
const sections = splitStoryboardSections(canonicalText);
|
|
2205
|
+
if (sections.length >= 1 && sections.length <= 24)
|
|
2206
|
+
return sections.length;
|
|
2207
|
+
return inferExplicitStoryboardFrameCountFromText(canonicalText);
|
|
2208
|
+
}
|
|
2209
|
+
function storyboardComplexityScore(text) {
|
|
2210
|
+
let score = 0;
|
|
2211
|
+
const normalized = text.toLowerCase();
|
|
2212
|
+
const signals = [
|
|
2213
|
+
/\b(?:dialogue|voice\s*over|voiceover|vo|spoken|says?|speaker|character\s+lines?)\b/i,
|
|
2214
|
+
/\b(?:audio|sfx|foley|sound\s*effects?|music|score|ambience)\b/i,
|
|
2215
|
+
/\b(?:brand|logo|product|launch|commercial|ad|campaign|cta|end\s*card|title\s*card)\b/i,
|
|
2216
|
+
/\b(?:cast|characters?|mascot|protagonist|hero|villain|host|customer|crowd|people)\b/i,
|
|
2217
|
+
/\b(?:transform|transformation|journey|arc|storyline|narrative|beginning|middle|ending|reveal|twist)\b/i,
|
|
2218
|
+
/\b(?:uploaded|attached|provided|reference|image\s+\d|asset\s+\d)\b/i,
|
|
2219
|
+
/\b(?:transition|montage|sequence|timecoded|shot\s*list|beat\s*sheet)\b/i,
|
|
2220
|
+
];
|
|
2221
|
+
for (const signal of signals) {
|
|
2222
|
+
if (signal.test(normalized))
|
|
2223
|
+
score += 1;
|
|
2224
|
+
}
|
|
2225
|
+
const sentenceCount = (text.match(/[.!?]\s+|\n{2,}/g) || []).length + 1;
|
|
2226
|
+
if (sentenceCount >= 5)
|
|
2227
|
+
score += 1;
|
|
2228
|
+
if (sentenceCount >= 9)
|
|
2229
|
+
score += 1;
|
|
2230
|
+
return score;
|
|
2231
|
+
}
|
|
2232
|
+
function storyboardWordCount(text) {
|
|
2233
|
+
const words = text.match(/\b[\w']+\b/g);
|
|
2234
|
+
return words ? words.length : 0;
|
|
2235
|
+
}
|
|
2236
|
+
export function inferDefaultStoryboardFrameCountFromText(text) {
|
|
2237
|
+
const canonicalText = canonicalStoryboardScriptContext(text) || text;
|
|
2238
|
+
const scriptCount = inferStoryboardFrameCountFromScriptText(canonicalText);
|
|
2239
|
+
if (scriptCount)
|
|
2240
|
+
return clampStoryboardDefaultFrameCount(scriptCount);
|
|
2241
|
+
const duration = inferRequestedTotalVideoDurationSeconds(canonicalText);
|
|
2242
|
+
const words = storyboardWordCount(canonicalText);
|
|
2243
|
+
const complexity = storyboardComplexityScore(canonicalText);
|
|
2244
|
+
let count = duration !== null
|
|
2245
|
+
? duration <= 8
|
|
2246
|
+
? 4
|
|
2247
|
+
: duration <= 15
|
|
2248
|
+
? 5
|
|
2249
|
+
: duration <= 20
|
|
2250
|
+
? 6
|
|
2251
|
+
: duration <= 30
|
|
2252
|
+
? 8
|
|
2253
|
+
: duration <= 45
|
|
2254
|
+
? 10
|
|
2255
|
+
: 12
|
|
2256
|
+
: words <= 28
|
|
2257
|
+
? 4
|
|
2258
|
+
: words <= 70
|
|
2259
|
+
? 5
|
|
2260
|
+
: words <= 130
|
|
2261
|
+
? 7
|
|
2262
|
+
: 9;
|
|
2263
|
+
if (complexity >= 6) {
|
|
2264
|
+
count += 2;
|
|
2265
|
+
}
|
|
2266
|
+
else if (complexity >= 3) {
|
|
2267
|
+
count += 1;
|
|
2268
|
+
}
|
|
2269
|
+
if (duration === null && words > 180)
|
|
2270
|
+
count += 1;
|
|
2271
|
+
return clampStoryboardDefaultFrameCount(count);
|
|
2272
|
+
}
|
|
2273
|
+
function inferReferencedStoryboardImageCount(text) {
|
|
2274
|
+
let maxIndex = 0;
|
|
2275
|
+
for (const match of text.matchAll(/\b(?:uploaded|attached|provided|reference|source|input)?\s*(?:image|photo|picture|asset)\s*(?:#|number\s*)?(\d{1,2})\b/gi)) {
|
|
2276
|
+
if (match.index !== undefined && isStoryboardModelNameReference(text, match.index, match.index + match[0].length)) {
|
|
2277
|
+
continue;
|
|
2278
|
+
}
|
|
2279
|
+
maxIndex = Math.max(maxIndex, Number(match[1]));
|
|
2280
|
+
}
|
|
2281
|
+
return maxIndex;
|
|
2282
|
+
}
|
|
2283
|
+
function isStoryboardModelNameReference(text, start, end) {
|
|
2284
|
+
const matched = text.slice(start, end).toLowerCase();
|
|
2285
|
+
if (!/\bimage\s*(?:#|number\s*)?2\b/.test(matched))
|
|
2286
|
+
return false;
|
|
2287
|
+
const before = text.slice(Math.max(0, start - 24), start).toLowerCase();
|
|
2288
|
+
const after = text.slice(end, Math.min(text.length, end + 36)).toLowerCase();
|
|
2289
|
+
const compactContext = `${before}${matched}${after}`;
|
|
2290
|
+
return /\bgpt\s+image\s*2\b/.test(compactContext)
|
|
2291
|
+
|| /\b(?:gpt[-\s]*)?image\s*2\s+(?:image\s+)?models?\b/.test(compactContext)
|
|
2292
|
+
|| /\bimage\s*2\s+image[-\s]?to[-\s]?image\b/.test(compactContext);
|
|
2293
|
+
}
|
|
2294
|
+
function contextAroundStoryboardReference(text, index) {
|
|
2295
|
+
const referenceWithSubjectInParens = new RegExp(String.raw `(?:^|[\n,;:*_\-\s])(?:uploaded|attached|provided|reference|source|input)?\s*(?:image|photo|picture|asset)\s*(?:#|number\s*)?${index}\s*\(\s*([^\)\n]{2,120}?)\s*\)\s*[:*_ -]*\s*([^\n]{0,160})`, 'gi');
|
|
2296
|
+
for (const match of text.matchAll(referenceWithSubjectInParens)) {
|
|
2297
|
+
if (match.index === undefined)
|
|
2298
|
+
continue;
|
|
2299
|
+
if (isStoryboardModelNameReference(text, match.index, match.index + match[0].length))
|
|
2300
|
+
continue;
|
|
2301
|
+
const subject = compactStoryboardLine(stripStoryboardMarkup(match[1]));
|
|
2302
|
+
const tail = compactStoryboardLine(stripStoryboardMarkup(match[2] || ''));
|
|
2303
|
+
if (subject)
|
|
2304
|
+
return `${subject} (asset ${index})${tail ? ` ${tail}` : ''}`;
|
|
2305
|
+
}
|
|
2306
|
+
const parentheticalReferencePattern = new RegExp(String.raw `(?:^|[\n,;:])\s*([^,\n;()]{2,120}?)\s*\(\s*(?:uploaded|attached|provided|reference|source|input)?\s*(?:image|photo|picture|asset)\s*(?:#|number\s*)?${index}\s*\)`, 'gi');
|
|
2307
|
+
for (const match of text.matchAll(parentheticalReferencePattern)) {
|
|
2308
|
+
if (match.index === undefined)
|
|
2309
|
+
continue;
|
|
2310
|
+
if (isStoryboardModelNameReference(text, match.index, match.index + match[0].length))
|
|
2311
|
+
continue;
|
|
2312
|
+
const subject = compactStoryboardLine(stripStoryboardMarkup(match[1]))
|
|
2313
|
+
.replace(/^(?:reference\s+assets?|uploaded\s+(?:images?|assets?)|attached\s+(?:images?|assets?)|provided\s+(?:images?|assets?)|assets?)\s*:\s*/i, '')
|
|
2314
|
+
.trim();
|
|
2315
|
+
if (subject)
|
|
2316
|
+
return `${subject} (asset ${index})`;
|
|
2317
|
+
}
|
|
2318
|
+
const patterns = [
|
|
2319
|
+
new RegExp(String.raw `\b(?:uploaded|attached|provided|reference|source|input)?\s*(?:image|photo|picture|asset)\s*(?:#|number\s*)?${index}\b`, 'i'),
|
|
2320
|
+
new RegExp(String.raw `\b${index}\b`, 'i'),
|
|
2321
|
+
];
|
|
2322
|
+
for (const pattern of patterns) {
|
|
2323
|
+
const matches = Array.from(text.matchAll(new RegExp(pattern.source, 'gi')));
|
|
2324
|
+
for (const match of matches) {
|
|
2325
|
+
if (match.index === undefined)
|
|
2326
|
+
continue;
|
|
2327
|
+
if (isStoryboardModelNameReference(text, match.index, match.index + match[0].length))
|
|
2328
|
+
continue;
|
|
2329
|
+
const lineStart = text.lastIndexOf('\n', match.index) + 1;
|
|
2330
|
+
const nextLineBreak = text.indexOf('\n', match.index);
|
|
2331
|
+
const lineEnd = nextLineBreak >= 0 ? nextLineBreak : text.length;
|
|
2332
|
+
const start = Math.max(0, lineStart);
|
|
2333
|
+
let end = Math.min(lineEnd, match.index + match[0].length + 140);
|
|
2334
|
+
const afterMatch = text.slice(match.index + match[0].length, end);
|
|
2335
|
+
const nextReference = afterMatch.search(/\b(?:uploaded|attached|provided|reference|source|input)?\s*(?:image|photo|picture|asset)\s*(?:#|number\s*)?\d{1,2}\b/i);
|
|
2336
|
+
if (nextReference >= 0) {
|
|
2337
|
+
end = match.index + match[0].length + nextReference;
|
|
2338
|
+
}
|
|
2339
|
+
const afterCurrentReference = text.slice(match.index + match[0].length, end);
|
|
2340
|
+
const nextSentence = afterCurrentReference.search(/[.!?]\s+\S/);
|
|
2341
|
+
if (nextSentence >= 0) {
|
|
2342
|
+
end = Math.min(end, match.index + match[0].length + nextSentence + 1);
|
|
2343
|
+
}
|
|
2344
|
+
return text.slice(start, end);
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2347
|
+
return text;
|
|
2348
|
+
}
|
|
2349
|
+
function cleanExplicitStoryboardReferenceSubject(value, index) {
|
|
2350
|
+
const subjectMarkers = Array.from(value.matchAll(/\bSubject:\s*/gi));
|
|
2351
|
+
const lastSubjectMarker = subjectMarkers[subjectMarkers.length - 1];
|
|
2352
|
+
if (!lastSubjectMarker || lastSubjectMarker.index === undefined)
|
|
2353
|
+
return '';
|
|
2354
|
+
const subjectStart = lastSubjectMarker.index + lastSubjectMarker[0].length;
|
|
2355
|
+
const tail = value.slice(subjectStart);
|
|
2356
|
+
const nextField = tail.search(/\s+(?:Usage|Preserve):/i);
|
|
2357
|
+
const rawSubject = nextField >= 0 ? tail.slice(0, nextField) : tail;
|
|
2358
|
+
const cleaned = compactStoryboardLine(stripStoryboardMarkup(rawSubject))
|
|
2359
|
+
.replace(new RegExp(String.raw `\s*\(\s*asset\s*${index}\s*\)\s*`, 'i'), ' ')
|
|
2360
|
+
.replace(/^(?:character\/source subject|logo\/brand|product\/object|style\/environment|reference asset|character|logo|brand|product|style|environment|other)\s+references?\b\.?\s*/i, '')
|
|
2361
|
+
.replace(/[.\s]+$/g, '')
|
|
2362
|
+
.replace(/\s+/g, ' ')
|
|
2363
|
+
.trim();
|
|
2364
|
+
if (!cleaned)
|
|
2365
|
+
return '';
|
|
2366
|
+
if (/^(?:logo|brand|character|mascot|asset|reference|image|photo|picture)$/i.test(cleaned))
|
|
2367
|
+
return '';
|
|
2368
|
+
return cleaned.slice(0, 180).replace(/\s+\S*$/, match => cleaned.length > 180 ? '' : match).trim();
|
|
2369
|
+
}
|
|
2370
|
+
function cleanStoryboardReferenceSubjectHint(context, index) {
|
|
2371
|
+
const source = compactStoryboardLine(stripStoryboardMarkup(context));
|
|
2372
|
+
const explicitSubject = cleanExplicitStoryboardReferenceSubject(source, index);
|
|
2373
|
+
if (explicitSubject)
|
|
2374
|
+
return explicitSubject;
|
|
2375
|
+
const cleaned = source
|
|
2376
|
+
.replace(new RegExp(String.raw `^(?:image|photo|picture|asset)\s*(?:#|number\s*)?${index}\s*(?:\([^)]*\))?\s*:?\s*`, 'i'), '')
|
|
2377
|
+
.replace(new RegExp(String.raw `^(?:uploaded|attached|provided|reference|source|input)\s+(?:image|photo|picture|asset)\s*(?:#|number\s*)?${index}\s*:?\s*`, 'i'), '')
|
|
2378
|
+
.replace(/\b(?:use|using|for|as|with|from|featuring|feature)\b\s*$/i, '')
|
|
2379
|
+
.replace(/[.\s]+$/g, '')
|
|
2380
|
+
.replace(/\s+/g, ' ')
|
|
2381
|
+
.trim();
|
|
2382
|
+
if (!cleaned)
|
|
2383
|
+
return '';
|
|
2384
|
+
if (/^(?:logo|brand|character|mascot|asset|reference|image|photo|picture)$/i.test(cleaned))
|
|
2385
|
+
return '';
|
|
2386
|
+
return cleaned.slice(0, 180).replace(/\s+\S*$/, match => cleaned.length > 180 ? '' : match).trim();
|
|
2387
|
+
}
|
|
2388
|
+
function inferStoryboardReferenceRole(index, text) {
|
|
2389
|
+
const context = contextAroundStoryboardReference(text, index);
|
|
2390
|
+
const lower = context.toLowerCase();
|
|
2391
|
+
const subjectHint = cleanStoryboardReferenceSubjectHint(context, index);
|
|
2392
|
+
if (/\b(?:logo|wordmark|brand|mark|icon)\b/.test(lower)) {
|
|
2393
|
+
return {
|
|
2394
|
+
index,
|
|
2395
|
+
role: 'logo/brand reference',
|
|
2396
|
+
subjectHint,
|
|
2397
|
+
usage: /\b(?:end|final|card|cta|tagline|logo\s+reveal|brand\s+reveal)\b/.test(lower)
|
|
2398
|
+
? 'end card only unless the approved script says otherwise'
|
|
2399
|
+
: 'brand moments and any explicitly assigned scenes',
|
|
2400
|
+
preserve: 'preserve the visible logo shape, typography, spacing, color relationships, and spelling as closely as possible',
|
|
2401
|
+
};
|
|
2402
|
+
}
|
|
2403
|
+
if (/\b(?:character|mascot|person|people|face|actor|host|protagonist|subject|hero|doll|toy|figure|avatar|girl|boy|woman|man)\b/.test(lower)) {
|
|
2404
|
+
return {
|
|
2405
|
+
index,
|
|
2406
|
+
role: 'character/source subject reference',
|
|
2407
|
+
subjectHint,
|
|
2408
|
+
usage: 'all scenes where that subject appears',
|
|
2409
|
+
preserve: 'preserve the visible identity, proportions, colors, outfit cues, expression, and recognizable silhouette',
|
|
2410
|
+
};
|
|
2411
|
+
}
|
|
2412
|
+
if (/\b(?:product|package|device|object|item)\b/.test(lower)) {
|
|
2413
|
+
return {
|
|
2414
|
+
index,
|
|
2415
|
+
role: 'product/object reference',
|
|
2416
|
+
subjectHint,
|
|
2417
|
+
usage: 'all scenes where that product or object appears',
|
|
2418
|
+
preserve: 'preserve the visible shape, materials, markings, proportions, and recognizable details',
|
|
2419
|
+
};
|
|
2420
|
+
}
|
|
2421
|
+
if (/\b(?:style|mood|look|palette|lighting|texture|background|environment|setting)\b/.test(lower)) {
|
|
2422
|
+
return {
|
|
2423
|
+
index,
|
|
2424
|
+
role: 'style/environment reference',
|
|
2425
|
+
subjectHint,
|
|
2426
|
+
usage: 'style, lighting, palette, or environment guidance where the approved brief calls for it',
|
|
2427
|
+
preserve: 'preserve the requested visual direction without copying unrelated content into every scene',
|
|
2428
|
+
};
|
|
2429
|
+
}
|
|
2430
|
+
return {
|
|
2431
|
+
index,
|
|
2432
|
+
role: 'reference asset',
|
|
2433
|
+
subjectHint,
|
|
2434
|
+
usage: 'use only where assigned by the approved brief',
|
|
2435
|
+
preserve: 'preserve visible details that the brief identifies as important',
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2438
|
+
function storyboardReferenceKindFromRole(role) {
|
|
2439
|
+
const lower = role.role.toLowerCase();
|
|
2440
|
+
if (lower.includes('character') || lower.includes('subject'))
|
|
2441
|
+
return 'character';
|
|
2442
|
+
if (lower.includes('logo') || lower.includes('brand'))
|
|
2443
|
+
return 'logo';
|
|
2444
|
+
if (lower.includes('product') || lower.includes('object'))
|
|
2445
|
+
return 'product';
|
|
2446
|
+
if (lower.includes('style'))
|
|
2447
|
+
return 'style';
|
|
2448
|
+
if (lower.includes('environment') || lower.includes('background'))
|
|
2449
|
+
return 'background';
|
|
2450
|
+
return 'other';
|
|
2451
|
+
}
|
|
2452
|
+
function storyboardUsageScopeFromRole(role) {
|
|
2453
|
+
const usage = role.usage.toLowerCase();
|
|
2454
|
+
if (/\b(?:end|final|card|cta)\b/.test(usage))
|
|
2455
|
+
return 'end_card_only';
|
|
2456
|
+
if (/\b(?:assigned|specific|explicit)\b/.test(usage))
|
|
2457
|
+
return 'specific_scenes';
|
|
2458
|
+
return 'global';
|
|
2459
|
+
}
|
|
2460
|
+
function storyboardPreservePriorityFromRole(role) {
|
|
2461
|
+
const kind = storyboardReferenceKindFromRole(role);
|
|
2462
|
+
if (kind === 'logo' || kind === 'character' || kind === 'product')
|
|
2463
|
+
return 'critical';
|
|
2464
|
+
if (kind === 'style' || kind === 'background')
|
|
2465
|
+
return 'high';
|
|
2466
|
+
return 'medium';
|
|
2467
|
+
}
|
|
2468
|
+
function inferStoryboardReferenceRoles(text) {
|
|
2469
|
+
const count = inferReferencedStoryboardImageCount(text);
|
|
2470
|
+
return Array.from({ length: count }, (_, index) => inferStoryboardReferenceRole(index + 1, text));
|
|
2471
|
+
}
|
|
2472
|
+
function buildStoryboardReferenceAssets(userIntentText, prompt) {
|
|
2473
|
+
return inferStoryboardReferenceRoles(`${userIntentText}\n${prompt}`).map(role => ({
|
|
2474
|
+
id: `image_${role.index}`,
|
|
2475
|
+
index: role.index,
|
|
2476
|
+
kind: storyboardReferenceKindFromRole(role),
|
|
2477
|
+
description: `${role.role}. ${role.subjectHint ? `Subject: ${role.subjectHint}. ` : ''}Usage: ${role.usage}. Preserve: ${role.preserve}.`,
|
|
2478
|
+
usageScope: storyboardUsageScopeFromRole(role),
|
|
2479
|
+
preservePriority: storyboardPreservePriorityFromRole(role),
|
|
2480
|
+
}));
|
|
2481
|
+
}
|
|
2482
|
+
function compileStoryboardReferenceSection(project) {
|
|
2483
|
+
const refs = project.references;
|
|
2484
|
+
if (refs.length <= 0) {
|
|
2485
|
+
return [
|
|
2486
|
+
'REFERENCE IMAGES:',
|
|
2487
|
+
'Uploaded or supplied references: preserve any provided subject, product, logo, style, or background roles from the approved brief. If no reference assets are attached, ignore this section.',
|
|
2488
|
+
];
|
|
2489
|
+
}
|
|
2490
|
+
return [
|
|
2491
|
+
'REFERENCE IMAGES:',
|
|
2492
|
+
...refs.map((ref, index) => {
|
|
2493
|
+
const modelRef = formatModelRef('gpt-image-2', ref.index ?? index + 1, 'image');
|
|
2494
|
+
return `${modelRef}: ${ref.description} Usage scope: ${ref.usageScope}. Preserve priority: ${ref.preservePriority}.`;
|
|
2495
|
+
}),
|
|
2496
|
+
];
|
|
2497
|
+
}
|
|
2498
|
+
function extractStoryboardAvoidConstraints(text) {
|
|
2499
|
+
const constraints = [];
|
|
2500
|
+
for (const match of text.matchAll(/\b(?:avoid|do not include|don't include|without|less)\b[\s\S]{0,220}(?:\.|$)/gi)) {
|
|
2501
|
+
const value = match[0].trim();
|
|
2502
|
+
if (value && !constraints.includes(value))
|
|
2503
|
+
constraints.push(value);
|
|
2504
|
+
}
|
|
2505
|
+
return constraints;
|
|
2506
|
+
}
|
|
2507
|
+
function extractStoryboardRequiredText(text) {
|
|
2508
|
+
const required = new Set();
|
|
2509
|
+
const inlineProductionLabelPattern = /\b(?:SFX|FX|Audio(?:\s*\/\s*SFX)?|Sound(?:s)?|Music|Foley|Camera(?:\s*\/\s*Motion)?|Motion|Lighting(?:\s*\/\s*Style)?|Style|Transition|Action(?:\s*\/\s*Motion)?|Performance|Beat)\s*:\s*[^a-z0-9]{0,4}$/i;
|
|
2510
|
+
const visibleTextContextPattern = /\b(?:visible|on[-\s]?screen|in[-\s]?frame|text|copy|cta|tagline|headline|title\s+card|caption|subtitle|super|wordmark|spell(?:ed)?|read(?:s)?|slogan)\b/i;
|
|
2511
|
+
const shouldIgnoreRequiredText = (value, matchIndex, precedingText) => {
|
|
2512
|
+
const lineStart = text.lastIndexOf('\n', matchIndex) + 1;
|
|
2513
|
+
const nextLineBreak = text.indexOf('\n', matchIndex);
|
|
2514
|
+
const lineEnd = nextLineBreak >= 0 ? nextLineBreak : text.length;
|
|
2515
|
+
const line = text.slice(lineStart, lineEnd);
|
|
2516
|
+
const looksLikeAssetHandle = /\.\.\.|(?:^|[./_-])(?:png|jpe?g|webp|gif|svg)$|[a-f0-9]{8}-[a-f0-9-]{8,}/i.test(value);
|
|
2517
|
+
const fieldLabel = line.match(/^\s*(?:[-*+]\s*)?(?:[*_]{1,3})?\s*([^:\n]{1,60})\s*:/)?.[1] ?? '';
|
|
2518
|
+
const isProductionDirectionField = /\b(?:action|motion|camera|transition|audio|sfx|fx|foley|sound|music|lighting|style|performance|beat)\b/i.test(fieldLabel)
|
|
2519
|
+
&& !visibleTextContextPattern.test(fieldLabel);
|
|
2520
|
+
const hasExplicitVisibleTextContext = visibleTextContextPattern.test(line);
|
|
2521
|
+
const looksLikeActionOrSfxCallout = /^[a-z][a-z-]{1,24}[!?.]?$/i.test(value.trim())
|
|
2522
|
+
&& /\b(?:action|motion|transition|audio|sfx|fx|foley|sound|music|camera|performance|beat|pop(?:s|ped|ping)?|snap(?:s|ped|ping)?|whoosh(?:es)?|thud(?:s|ded|ding)?|ding(?:s|ed|ing)?|boom(?:s|ed|ing)?|impact(?:s|ed|ing)?|hit(?:s|ting)?|slam(?:s|med|ming)?|wipe(?:s|d|ing)?|glitch(?:es|ed|ing)?|morph(?:s|ed|ing)?|bounce(?:s|d|ing)?|zoom(?:s|ed|ing)?)\b/i.test(line);
|
|
2523
|
+
const precedingTail = (precedingText ?? '').slice(-80);
|
|
2524
|
+
const hasInlineProductionLabel = !!precedingTail
|
|
2525
|
+
&& inlineProductionLabelPattern.test(precedingTail)
|
|
2526
|
+
&& !visibleTextContextPattern.test(precedingTail);
|
|
2527
|
+
if (/\b(?:working\s+title|project\s+title)\b/i.test(line)
|
|
2528
|
+
&& !/\b(?:title\s+card|on[-\s]?screen|visible|text|copy|cta|headline|tagline)\b/i.test(line)) {
|
|
2529
|
+
return true;
|
|
2530
|
+
}
|
|
2531
|
+
if (storyboardTextCandidateLooksLikeGenericProductionLabel(value)) {
|
|
2532
|
+
return true;
|
|
2533
|
+
}
|
|
2534
|
+
if (hasInlineProductionLabel) {
|
|
2535
|
+
return true;
|
|
2536
|
+
}
|
|
2537
|
+
if ((isProductionDirectionField || looksLikeActionOrSfxCallout) && !hasExplicitVisibleTextContext) {
|
|
2538
|
+
return true;
|
|
2539
|
+
}
|
|
2540
|
+
if (looksLikeAssetHandle
|
|
2541
|
+
&& /\b(?:asset|reference|image|photo|upload|file|filename|logo|brand)\b/i.test(line)) {
|
|
2542
|
+
return true;
|
|
2543
|
+
}
|
|
2544
|
+
return false;
|
|
2545
|
+
};
|
|
2546
|
+
const addRequiredText = (rawValue, matchIndex = 0, options = {}) => {
|
|
2547
|
+
const value = compactStoryboardLine(stripStoryboardMarkup(rawValue || ''))
|
|
2548
|
+
.replace(/\\"/g, '"')
|
|
2549
|
+
.replace(/^\|+|\|+$/g, '')
|
|
2550
|
+
.replace(/^[*_]+|[*_]+$/g, '')
|
|
2551
|
+
.replace(/^["“”'`]+|["“”'`]+$/g, '')
|
|
2552
|
+
.trim();
|
|
2553
|
+
if (value && shouldIgnoreRequiredText(value, matchIndex, options.precedingText))
|
|
2554
|
+
return;
|
|
2555
|
+
if (/^visible\s+text\b/i.test(value) && (extractStoryboardTiming(value) || /^visible\s+text\.?$/i.test(value)))
|
|
2556
|
+
return;
|
|
2557
|
+
if (options.splitUnquotedList && value.includes(';')) {
|
|
2558
|
+
const parts = value
|
|
2559
|
+
.split(/\s*;\s*/)
|
|
2560
|
+
.map(part => part.trim())
|
|
2561
|
+
.filter(Boolean);
|
|
2562
|
+
if (parts.length > 1 && parts.every(part => part.length <= 160)) {
|
|
2563
|
+
for (const part of parts)
|
|
2564
|
+
addRequiredText(part, matchIndex, { precedingText: options.precedingText });
|
|
2565
|
+
return;
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2568
|
+
if (value)
|
|
2569
|
+
required.add(value);
|
|
2570
|
+
};
|
|
2571
|
+
const exactTextPattern = /\b(?:render|show|include|text|copy|cta|tagline|headline|title|brand|logo|wordmark|words?|say(?:s)?|spell(?:ed)?)\b[^"“`\n]{0,120}(?:"([^"]{1,160})"|“([^”]{1,160})”|`([^`]{1,160})`)/gi;
|
|
2572
|
+
for (const match of text.matchAll(exactTextPattern)) {
|
|
2573
|
+
const fullMatch = match[0];
|
|
2574
|
+
const quoteOpenInMatch = fullMatch.search(/["“`]/);
|
|
2575
|
+
const precedingInMatch = quoteOpenInMatch >= 0 ? fullMatch.slice(0, quoteOpenInMatch) : fullMatch;
|
|
2576
|
+
addRequiredText(match[1] ?? match[2] ?? match[3], match.index ?? 0, {
|
|
2577
|
+
precedingText: precedingInMatch,
|
|
2578
|
+
});
|
|
2579
|
+
}
|
|
2580
|
+
const labeledTextPattern = /\b(?:on[-\s]?screen\s+text|visible\s+text|text\s+only|text|end\s+card\s+text|final\s+text|tagline|cta|headline|title\s+card|copy)\s*:\s*([^\n]{1,260})/gi;
|
|
2581
|
+
for (const match of text.matchAll(labeledTextPattern)) {
|
|
2582
|
+
const value = match[1] || '';
|
|
2583
|
+
const matchStartInText = match.index ?? 0;
|
|
2584
|
+
const valueStartInMatch = match[0].indexOf(value);
|
|
2585
|
+
const valueStartInText = valueStartInMatch >= 0 ? matchStartInText + valueStartInMatch : matchStartInText;
|
|
2586
|
+
let addedQuotedText = false;
|
|
2587
|
+
for (const quote of value.matchAll(/"([^"]{1,160})"|“([^”]{1,160})”|`([^`]{1,160})`/g)) {
|
|
2588
|
+
const innerStartInValue = quote.index ?? 0;
|
|
2589
|
+
const precedingInValue = value.slice(0, innerStartInValue);
|
|
2590
|
+
const quoteAbsIndex = valueStartInText + innerStartInValue;
|
|
2591
|
+
addRequiredText(quote[1] ?? quote[2] ?? quote[3], quoteAbsIndex, {
|
|
2592
|
+
precedingText: precedingInValue,
|
|
2593
|
+
});
|
|
2594
|
+
addedQuotedText = true;
|
|
2595
|
+
}
|
|
2596
|
+
if (!addedQuotedText) {
|
|
2597
|
+
const unquoted = value
|
|
2598
|
+
.split('|')[0]
|
|
2599
|
+
.replace(/\s+\b(?:Dialogue\/VO|VO\/Dialogue|V\.O\.|VO|Voiceover|Voice-over|Speech|Narration|Audio\/SFX|Audio|SFX|FX|Foley|Sound|Sounds|Music|Camera\/Motion|Camera|Lighting\/Style|Lighting|Style|Look|Action\/Motion|Action|Motion)\s*:[\s\S]*$/i, '')
|
|
2600
|
+
.replace(/\b(?:none|no\s+(?:visible\s+)?text|n\/a|not\s+specified)\b\.?$/i, '')
|
|
2601
|
+
.trim();
|
|
2602
|
+
addRequiredText(unquoted, matchStartInText, { splitUnquotedList: true });
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
const renderThesePattern = /\b(?:exact words|exact text|required text|final cta|end card text)\b[\s\S]{0,320}/gi;
|
|
2606
|
+
for (const block of text.matchAll(renderThesePattern)) {
|
|
2607
|
+
for (const quote of extractQuotedDialogueSegments(block[0])) {
|
|
2608
|
+
addRequiredText(quote, block.index ?? 0);
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
return [...required];
|
|
2612
|
+
}
|
|
2613
|
+
function inferStoryboardTitle(text) {
|
|
2614
|
+
const cleanTitle = (rawValue) => compactStoryboardLine(stripStoryboardMarkup(rawValue)
|
|
2615
|
+
.replace(/^["“”'`*_\s]+|["“”'`*_\s.]+$/g, ''));
|
|
2616
|
+
const titleMatch = text.match(/\b(?:title|working title)\s*:\s*([^\n]{1,120})/i);
|
|
2617
|
+
if (titleMatch?.[1]?.trim())
|
|
2618
|
+
return cleanTitle(titleMatch[1]);
|
|
2619
|
+
const quotedTitle = text.match(/\b(?:titled|called)\s+"([^"]{1,120})"/i);
|
|
2620
|
+
if (quotedTitle?.[1]?.trim())
|
|
2621
|
+
return cleanTitle(quotedTitle[1]);
|
|
2622
|
+
return 'Video Storyboard';
|
|
2623
|
+
}
|
|
2624
|
+
function compactStoryboardLine(value, fallback = '') {
|
|
2625
|
+
return String(value || fallback).replace(/\s+/g, ' ').trim();
|
|
2626
|
+
}
|
|
2627
|
+
function stripStoryboardExpansionInstruction(value) {
|
|
2628
|
+
return value
|
|
2629
|
+
.replace(/\s*If the source text contains fewer (?:panel|frame) descriptions, expand those beats into exactly \d{1,2} timecoded frames; do not create (?:a\s+)?(?:\d{1,2}|four|six)[^.]*?or split the storyboard into separate images\./gi, '')
|
|
2630
|
+
.replace(/\s*If the source text contains fewer (?:panel|frame) descriptions, expand those beats into exactly \d{1,2} timecoded frames; keep all frames in one composite storyboard image and do not split the storyboard into separate images\./gi, '')
|
|
2631
|
+
.replace(/[ \t]+/g, ' ')
|
|
2632
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
2633
|
+
.trim();
|
|
2634
|
+
}
|
|
2635
|
+
function normalizeStoryboardBriefKey(value) {
|
|
2636
|
+
return stripStoryboardExpansionInstruction(value)
|
|
2637
|
+
.toLowerCase()
|
|
2638
|
+
.replace(/[^\p{L}\p{N}:]+/gu, ' ')
|
|
2639
|
+
.replace(/\s+/g, ' ')
|
|
2640
|
+
.trim();
|
|
2641
|
+
}
|
|
2642
|
+
function textHasStoryboardBriefSubstance(value) {
|
|
2643
|
+
const text = stripStoryboardExpansionInstruction(value);
|
|
2644
|
+
if (text.length < 40)
|
|
2645
|
+
return false;
|
|
2646
|
+
return /\b(?:story\s*board|storyboard|video|seedance|commercial|promo|teaser|ad|scene|shot|beat|panel|frame|duration|logo|reference|audio|sfx|foley)\b/i.test(text)
|
|
2647
|
+
|| extractStoryboardTiming(text) !== null;
|
|
2648
|
+
}
|
|
2649
|
+
function isTerseStoryboardRetryInstruction(value) {
|
|
2650
|
+
const text = stripStoryboardExpansionInstruction(value)
|
|
2651
|
+
.toLowerCase()
|
|
2652
|
+
.replace(/[.!?]+$/g, '')
|
|
2653
|
+
.replace(/\s+/g, ' ')
|
|
2654
|
+
.trim();
|
|
2655
|
+
if (!text || text.length > 90)
|
|
2656
|
+
return false;
|
|
2657
|
+
return /^(?:try again|retry|redo|rerun|run it again|do it again|go ahead|do it|continue|looks good|approved|approve|yes|yeah|yep|ok|okay|sure|please do|make it)$/.test(text)
|
|
2658
|
+
|| /\b(?:try again|retry|redo|do it again)\b/i.test(text);
|
|
2659
|
+
}
|
|
2660
|
+
function latestSubstantiveStoryboardUserBrief(userIntentText, promptCore) {
|
|
2661
|
+
const promptKey = normalizeStoryboardBriefKey(promptCore);
|
|
2662
|
+
const chunks = userIntentText
|
|
2663
|
+
.split(/\n{1,}/)
|
|
2664
|
+
.map(chunk => chunk.trim())
|
|
2665
|
+
.filter(Boolean)
|
|
2666
|
+
.filter(chunk => normalizeStoryboardBriefKey(chunk) !== promptKey)
|
|
2667
|
+
.filter(textHasStoryboardBriefSubstance);
|
|
2668
|
+
return chunks[chunks.length - 1] || '';
|
|
2669
|
+
}
|
|
2670
|
+
function selectStoryboardSourceBrief(prompt, userIntentText) {
|
|
2671
|
+
const promptCore = stripStoryboardExpansionInstruction(prompt);
|
|
2672
|
+
const priorBrief = latestSubstantiveStoryboardUserBrief(userIntentText, promptCore);
|
|
2673
|
+
if (priorBrief && (!textHasStoryboardBriefSubstance(promptCore) || isTerseStoryboardRetryInstruction(promptCore))) {
|
|
2674
|
+
return priorBrief;
|
|
2675
|
+
}
|
|
2676
|
+
return promptCore || priorBrief || userIntentText.trim();
|
|
2677
|
+
}
|
|
2678
|
+
function storyboardBriefContains(haystack, needle) {
|
|
2679
|
+
const haystackKey = normalizeStoryboardBriefKey(haystack);
|
|
2680
|
+
const needleKey = normalizeStoryboardBriefKey(needle);
|
|
2681
|
+
return !!needleKey && haystackKey.includes(needleKey);
|
|
2682
|
+
}
|
|
2683
|
+
function buildStoryboardSourceBriefForPrompt(prompt, userIntentText, approvedScriptContext, promptAuthorship) {
|
|
2684
|
+
const selectedBrief = selectStoryboardSourceBrief(prompt, userIntentText);
|
|
2685
|
+
const originalBrief = latestSubstantiveStoryboardUserBrief(userIntentText, selectedBrief);
|
|
2686
|
+
const canonicalApprovedScriptContext = canonicalStoryboardScriptContext(approvedScriptContext);
|
|
2687
|
+
const includeSelectedBrief = selectedBrief && !(promptAuthorship === 'assistant' && canonicalApprovedScriptContext);
|
|
2688
|
+
const includeOriginalBrief = originalBrief && !(promptAuthorship === 'assistant' && canonicalApprovedScriptContext);
|
|
2689
|
+
const parts = [];
|
|
2690
|
+
if (includeOriginalBrief && !storyboardBriefContains(selectedBrief, originalBrief)) {
|
|
2691
|
+
parts.push(`ORIGINAL USER INTENT:\n${originalBrief}`);
|
|
2692
|
+
}
|
|
2693
|
+
if (includeSelectedBrief) {
|
|
2694
|
+
parts.push(parts.length > 0 ? `STORYBOARD BRIEF:\n${selectedBrief}` : selectedBrief);
|
|
2695
|
+
}
|
|
2696
|
+
if (canonicalApprovedScriptContext) {
|
|
2697
|
+
parts.push(`APPROVED STORYBOARD SCRIPT CONTEXT TO PRESERVE:\n${canonicalApprovedScriptContext}`);
|
|
2698
|
+
}
|
|
2699
|
+
return parts.filter(Boolean).join('\n\n');
|
|
2700
|
+
}
|
|
2701
|
+
function buildStoryboardUserConstraintSource(userIntentText, primarySourceBrief, options) {
|
|
2702
|
+
const canonicalApprovedScriptContext = canonicalStoryboardScriptContext(options.approvedScriptContext);
|
|
2703
|
+
return [
|
|
2704
|
+
userIntentText,
|
|
2705
|
+
canonicalApprovedScriptContext,
|
|
2706
|
+
options.promptAuthorship === 'assistant' ? '' : primarySourceBrief,
|
|
2707
|
+
].filter(Boolean).join('\n\n');
|
|
2708
|
+
}
|
|
2709
|
+
function stripStoryboardMarkup(value) {
|
|
2710
|
+
return value
|
|
2711
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
2712
|
+
.replace(/<\/?[^>]+>/g, ' ')
|
|
2713
|
+
.replace(/ /gi, ' ')
|
|
2714
|
+
.replace(/&/gi, '&')
|
|
2715
|
+
.replace(/"/gi, '"')
|
|
2716
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
2717
|
+
.replace(/__([^_]+)__/g, '$1')
|
|
2718
|
+
.replace(/\*([^*]+)\*/g, '$1')
|
|
2719
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
2720
|
+
.split(/\r?\n/)
|
|
2721
|
+
.map(line => line.replace(/^\s*(?:[-*+]|\d+[.)])\s+/, '').trim())
|
|
2722
|
+
.filter(Boolean)
|
|
2723
|
+
.join('\n')
|
|
2724
|
+
.trim();
|
|
2725
|
+
}
|
|
2726
|
+
function storyboardFieldLabelPattern(label) {
|
|
2727
|
+
return label
|
|
2728
|
+
.trim()
|
|
2729
|
+
.split('/')
|
|
2730
|
+
.map(part => part.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
2731
|
+
.join(String.raw `\s*/\s*`);
|
|
2732
|
+
}
|
|
2733
|
+
function extractStoryboardField(section, labels) {
|
|
2734
|
+
const normalizedSection = stripStoryboardMarkup(section)
|
|
2735
|
+
.split(/\r?\n/)
|
|
2736
|
+
.map(line => line.replace(/^\s*#{1,6}\s*/, '').trim())
|
|
2737
|
+
.filter(Boolean)
|
|
2738
|
+
.join('\n');
|
|
2739
|
+
const labelPattern = labels.map(storyboardFieldLabelPattern).join('|');
|
|
2740
|
+
const match = normalizedSection.match(new RegExp(String.raw `^\s*(?:${labelPattern})(?:\s*\([^)\n]{0,80}\))?\s*:\s*(.+)$`, 'im'));
|
|
2741
|
+
return compactStoryboardLine(match?.[1]);
|
|
2742
|
+
}
|
|
2743
|
+
function removeStoryboardTimingText(value) {
|
|
2744
|
+
return value
|
|
2745
|
+
.replace(/\b\d{1,2}:\d{2}(?:\.\d+)?\s*(?:-|to|\u2013|\u2014)\s*\d{1,2}:\d{2}(?:\.\d+)?\b/gi, ' ')
|
|
2746
|
+
.replace(/\b\d{1,3}(?:\.\d+)?\s*(?:s|sec|secs|seconds?)?\s*(?:-|to|\u2013|\u2014)\s*\d{1,3}(?:\.\d+)?\s*(?:s|sec|secs|seconds?)?\b/gi, ' ')
|
|
2747
|
+
.replace(/\(\s*\)|\[\s*\]/g, ' ')
|
|
2748
|
+
.replace(/\s*[|]\s*/g, ' ')
|
|
2749
|
+
.replace(/^[-:.\s|]+|[-:.\s|]+$/g, ' ')
|
|
2750
|
+
.replace(/\s+/g, ' ')
|
|
2751
|
+
.trim();
|
|
2752
|
+
}
|
|
2753
|
+
function storyboardTextCandidateLooksLikeGenericProductionLabel(value) {
|
|
2754
|
+
const withoutTiming = removeStoryboardTimingText(value)
|
|
2755
|
+
.replace(/^\(|\)$/g, '')
|
|
2756
|
+
.replace(/^[-:.\s|]+|[-:.\s|]+$/g, '')
|
|
2757
|
+
.trim();
|
|
2758
|
+
return /^(?:visible\s+text|on[-\s]?screen\s+text|onscreen\s+text|text\s+overlay|title\s+card|caption|subtitle|super|copy|cta|tagline|headline)$/i.test(withoutTiming);
|
|
2759
|
+
}
|
|
2760
|
+
function normalizeStoryboardDialogue(value) {
|
|
2761
|
+
const compact = compactStoryboardLine(value);
|
|
2762
|
+
if (!compact)
|
|
2763
|
+
return '';
|
|
2764
|
+
if (/^[-\u2013\u2014]\.?$/.test(compact))
|
|
2765
|
+
return '';
|
|
2766
|
+
if (/^(?:[\[(]\s*)?(?:none|no\s+(?:spoken\s+)?(?:dialogue|vo|voiceover|voice-over|speech)|n\/a|not\s+specified|text\s+only|silence(?:\s*\/\s*beat)?|silent beat)(?:\s*[\])])?\.?$/i.test(compact)) {
|
|
2767
|
+
return '';
|
|
2768
|
+
}
|
|
2769
|
+
const visibleTextField = compact.match(/^(?:visible\s+text|on[-\s]?screen\s+text|onscreen\s+text|text|cta|tagline|headline|title\s+card|copy)\s*:\s*(.+)$/i)?.[1];
|
|
2770
|
+
if (visibleTextField !== undefined)
|
|
2771
|
+
return '';
|
|
2772
|
+
if (/^text\s+only\s*:/i.test(compact))
|
|
2773
|
+
return '';
|
|
2774
|
+
const quoted = extractQuotedDialogueSegments(compact)
|
|
2775
|
+
.map(line => compactStoryboardLine(line))
|
|
2776
|
+
.filter(Boolean);
|
|
2777
|
+
if (quoted.length > 0)
|
|
2778
|
+
return quoted.join(' ');
|
|
2779
|
+
const nestedVo = compact.match(/\b(?:VO|V\.O\.|Voiceover|Voice-over|Dialogue|Speech|Narration)\b(?:\s*\([^)]*\))?\s*:\s*(.+)$/i)?.[1];
|
|
2780
|
+
if (nestedVo && !/^(?:[\[(]\s*)?(?:none|no\s+(?:spoken\s+)?(?:dialogue|vo|voiceover|voice-over|speech)|n\/a|not\s+specified|text\s+only)(?:\s*[\])])?\.?$/i.test(nestedVo.trim())) {
|
|
2781
|
+
return compactStoryboardLine(nestedVo);
|
|
2782
|
+
}
|
|
2783
|
+
return compact;
|
|
2784
|
+
}
|
|
2785
|
+
function parseStoryboardTimeValue(value) {
|
|
2786
|
+
const trimmed = value.trim();
|
|
2787
|
+
const timecode = trimmed.match(/^(\d{1,2}):(\d{2})(?:\.(\d+))?$/);
|
|
2788
|
+
if (timecode) {
|
|
2789
|
+
const minutes = Number(timecode[1]);
|
|
2790
|
+
const seconds = Number(timecode[2]);
|
|
2791
|
+
const fraction = timecode[3] ? Number(`0.${timecode[3]}`) : 0;
|
|
2792
|
+
if (!Number.isFinite(minutes) || !Number.isFinite(seconds) || seconds >= 60)
|
|
2793
|
+
return null;
|
|
2794
|
+
return Math.round((minutes * 60 + seconds + fraction) * 100) / 100;
|
|
2795
|
+
}
|
|
2796
|
+
const numeric = Number(trimmed);
|
|
2797
|
+
return Number.isFinite(numeric) ? numeric : null;
|
|
2798
|
+
}
|
|
2799
|
+
function extractStoryboardTiming(text) {
|
|
2800
|
+
const match = text.match(/(\d{1,2}:\d{2}(?:\.\d+)?|\d{1,3}(?:\.\d+)?)\s*(?:s|sec|secs|seconds?)?\s*(?:-|to|\u2013|\u2014)\s*(\d{1,2}:\d{2}(?:\.\d+)?|\d{1,3}(?:\.\d+)?)\s*(?:s|sec|secs|seconds?)?/i);
|
|
2801
|
+
if (!match)
|
|
2802
|
+
return null;
|
|
2803
|
+
const startSec = parseStoryboardTimeValue(match[1]);
|
|
2804
|
+
const endSec = parseStoryboardTimeValue(match[2]);
|
|
2805
|
+
if (startSec === null || endSec === null)
|
|
2806
|
+
return null;
|
|
2807
|
+
if (!Number.isFinite(startSec) || !Number.isFinite(endSec) || endSec <= startSec)
|
|
2808
|
+
return null;
|
|
2809
|
+
return {
|
|
2810
|
+
startSec,
|
|
2811
|
+
endSec,
|
|
2812
|
+
durationSec: Math.round((endSec - startSec) * 100) / 100,
|
|
2813
|
+
};
|
|
2814
|
+
}
|
|
2815
|
+
function extractStoryboardTimingMarker(text) {
|
|
2816
|
+
const match = text.match(/(\d{1,2}:\d{2}(?:\.\d+)?|\d{1,3}(?:\.\d+)?)\s*(?:s|sec|secs|seconds?)?\s*(?:-|to|\u2013|\u2014)\s*(\d{1,2}:\d{2}(?:\.\d+)?|\d{1,3}(?:\.\d+)?)\s*(?:s|sec|secs|seconds?)?/i);
|
|
2817
|
+
if (!match)
|
|
2818
|
+
return null;
|
|
2819
|
+
const startSec = parseStoryboardTimeValue(match[1]);
|
|
2820
|
+
const endSec = parseStoryboardTimeValue(match[2]);
|
|
2821
|
+
if (startSec === null || endSec === null)
|
|
2822
|
+
return null;
|
|
2823
|
+
if (!Number.isFinite(startSec) || !Number.isFinite(endSec) || endSec < startSec)
|
|
2824
|
+
return null;
|
|
2825
|
+
return {
|
|
2826
|
+
startSec,
|
|
2827
|
+
endSec,
|
|
2828
|
+
durationSec: Math.round((endSec - startSec) * 100) / 100,
|
|
2829
|
+
};
|
|
2830
|
+
}
|
|
2831
|
+
function storyboardMarkdownTableCells(line) {
|
|
2832
|
+
const trimmed = line.trim();
|
|
2833
|
+
if (!trimmed.startsWith('|'))
|
|
2834
|
+
return [];
|
|
2835
|
+
const cells = trimmed
|
|
2836
|
+
.replace(/^\|/, '')
|
|
2837
|
+
.replace(/\|$/, '')
|
|
2838
|
+
.split('|')
|
|
2839
|
+
.map(cell => stripStoryboardMarkup(cell))
|
|
2840
|
+
.map(cell => compactStoryboardLine(cell))
|
|
2841
|
+
.filter(cell => cell.length > 0);
|
|
2842
|
+
if (cells.length < 2)
|
|
2843
|
+
return [];
|
|
2844
|
+
if (cells.every(cell => /^:?-{3,}:?$/.test(cell.replace(/\s+/g, ''))))
|
|
2845
|
+
return [];
|
|
2846
|
+
return cells;
|
|
2847
|
+
}
|
|
2848
|
+
function splitStoryboardTitleDescription(value) {
|
|
2849
|
+
const cleaned = stripStoryboardMarkup(value);
|
|
2850
|
+
const fallbackTitleFromDescription = (description) => {
|
|
2851
|
+
const withoutLeadingLabel = description.replace(/^(?:Visual|Visual Frame|Frame|Shot|Image|Action|Action\/Motion)\s*:\s*/i, '');
|
|
2852
|
+
const titleSafe = withoutLeadingLabel
|
|
2853
|
+
.replace(/"[^"]{1,240}"|“[^”]{1,240}”|`[^`]{1,240}`/g, '')
|
|
2854
|
+
.replace(/\s+\b(?:Dialogue\/VO|VO\/Dialogue|V\.O\.|VO|Voiceover|Voice-over|Speech|Narration|Audio\/SFX|Audio|SFX|FX|Foley|Sound|Sounds|Music|Visible text|On-screen text|Onscreen text|Text|CTA|Camera\/Motion|Camera|Lighting\/Style|Lighting|Style|Look|Action\/Motion|Action|Motion)\s*:[\s\S]*$/i, '')
|
|
2855
|
+
.replace(/\s*:\s*$/, '');
|
|
2856
|
+
const firstClause = titleSafe.split(/(?<=[.!?])\s+|;\s+|\n/)[0] || titleSafe || withoutLeadingLabel;
|
|
2857
|
+
return compactStoryboardLine(firstClause.slice(0, 80), 'Storyboard Beat');
|
|
2858
|
+
};
|
|
2859
|
+
const match = cleaned.match(/^([^:\n]{1,100})\s*:\s*([\s\S]+)$/);
|
|
2860
|
+
if (!match) {
|
|
2861
|
+
return {
|
|
2862
|
+
title: fallbackTitleFromDescription(cleaned),
|
|
2863
|
+
description: compactStoryboardLine(cleaned),
|
|
2864
|
+
};
|
|
2865
|
+
}
|
|
2866
|
+
const rawPrefix = match[1];
|
|
2867
|
+
const prefixContainsSentenceEnd = /[.!?]\s/.test(rawPrefix);
|
|
2868
|
+
if (prefixContainsSentenceEnd) {
|
|
2869
|
+
return {
|
|
2870
|
+
title: fallbackTitleFromDescription(cleaned),
|
|
2871
|
+
description: compactStoryboardLine(cleaned),
|
|
2872
|
+
};
|
|
2873
|
+
}
|
|
2874
|
+
const title = compactStoryboardLine(rawPrefix, 'Storyboard Beat');
|
|
2875
|
+
const description = compactStoryboardLine(match[2], cleaned);
|
|
2876
|
+
if (/^(?:visual|visual frame|frame|shot|image|action|action\/motion|camera|audio|sfx|dialogue|vo)$/i.test(title)) {
|
|
2877
|
+
return {
|
|
2878
|
+
title: fallbackTitleFromDescription(description),
|
|
2879
|
+
description,
|
|
2880
|
+
};
|
|
2881
|
+
}
|
|
2882
|
+
return {
|
|
2883
|
+
title,
|
|
2884
|
+
description,
|
|
2885
|
+
};
|
|
2886
|
+
}
|
|
2887
|
+
function storyboardTableCellWithFieldBreaks(value) {
|
|
2888
|
+
return stripStoryboardMarkup(value)
|
|
2889
|
+
.replace(/\s+\b(Dialogue\/VO|VO\/Dialogue|V\.O\.|VO|Voiceover|Voice-over|Speech|Narration|Audio\/SFX|Audio|SFX|FX|Foley|Sound|Sounds|Music|Visible text|On-screen text|Onscreen text|Text|CTA|Camera\/Motion|Camera|Lighting\/Style|Lighting|Style|Look|Action\/Motion|Action|Motion)\s*:/gi, '\n$1:')
|
|
2890
|
+
.trim();
|
|
2891
|
+
}
|
|
2892
|
+
function storyboardTableHeaderMatches(header, pattern) {
|
|
2893
|
+
return !!header && pattern.test(header);
|
|
2894
|
+
}
|
|
2895
|
+
function storyboardTableHeaderIndex(headers, pattern) {
|
|
2896
|
+
if (!headers)
|
|
2897
|
+
return -1;
|
|
2898
|
+
return headers.findIndex(header => storyboardTableHeaderMatches(header, pattern));
|
|
2899
|
+
}
|
|
2900
|
+
function uniqueStoryboardTableIndices(indices) {
|
|
2901
|
+
const seen = new Set();
|
|
2902
|
+
return indices.filter(index => {
|
|
2903
|
+
if (index < 0 || seen.has(index))
|
|
2904
|
+
return false;
|
|
2905
|
+
seen.add(index);
|
|
2906
|
+
return true;
|
|
2907
|
+
});
|
|
2908
|
+
}
|
|
2909
|
+
function looksLikeStoryboardTableHeader(cells) {
|
|
2910
|
+
if (cells.some(cell => extractStoryboardTiming(cell)))
|
|
2911
|
+
return false;
|
|
2912
|
+
const joined = cells.join(' ');
|
|
2913
|
+
return /\b(?:time|timecode|timing|duration)\b/i.test(joined)
|
|
2914
|
+
&& /\b(?:visual|action|frame|shot|camera|audio|dialogue|vo|voiceover|sfx)\b/i.test(joined);
|
|
2915
|
+
}
|
|
2916
|
+
function storyboardTableCellWithoutDialogue(cell, dialogue) {
|
|
2917
|
+
const normalizedDialogue = compactStoryboardLine(dialogue).toLowerCase();
|
|
2918
|
+
return cell
|
|
2919
|
+
.split(/\r?\n/)
|
|
2920
|
+
.map(line => {
|
|
2921
|
+
const compact = compactStoryboardLine(dialogue ? line.replace(/"[^"]{1,800}"/g, ' ') : line);
|
|
2922
|
+
if (normalizedDialogue && compact.toLowerCase() === normalizedDialogue) {
|
|
2923
|
+
return '';
|
|
2924
|
+
}
|
|
2925
|
+
if (/^\s*(?:Dialogue\/VO|VO\/Dialogue|V\.O\.|VO|Voiceover|Voice-over|Speech|Narration)\s*:/i.test(compact)) {
|
|
2926
|
+
return '';
|
|
2927
|
+
}
|
|
2928
|
+
if (/^\s*(?:Visible text|On-screen text|Onscreen text|Text|CTA)\s*:/i.test(compact)) {
|
|
2929
|
+
return '';
|
|
2930
|
+
}
|
|
2931
|
+
return compact;
|
|
2932
|
+
})
|
|
2933
|
+
.filter(Boolean)
|
|
2934
|
+
.join(' ')
|
|
2935
|
+
.replace(/\b(?:none|no\s+(?:spoken\s+)?(?:dialogue|vo|voiceover|voice-over|speech)|n\/a|not\s+specified|text\s+only)\b\.?/gi, ' ')
|
|
2936
|
+
.replace(/\s+/g, ' ')
|
|
2937
|
+
.trim();
|
|
2938
|
+
}
|
|
2939
|
+
function storyboardTableCellHasExplicitVoiceField(cell) {
|
|
2940
|
+
return cell
|
|
2941
|
+
.split(/\r?\n/)
|
|
2942
|
+
.some(line => /^\s*(?:Dialogue\/VO|VO\/Dialogue|Dialogue|VO|V\.O\.|Voiceover|Voice-over|Speech|Narration)\s*:/i.test(compactStoryboardLine(line)));
|
|
2943
|
+
}
|
|
2944
|
+
function storyboardTableCellIsAudioOnly(cell) {
|
|
2945
|
+
const lines = cell
|
|
2946
|
+
.split(/\r?\n/)
|
|
2947
|
+
.map(line => compactStoryboardLine(line))
|
|
2948
|
+
.filter(Boolean);
|
|
2949
|
+
if (lines.length === 0)
|
|
2950
|
+
return false;
|
|
2951
|
+
if (storyboardTableCellHasExplicitVoiceField(cell))
|
|
2952
|
+
return false;
|
|
2953
|
+
return lines.every(line => /^\s*(?:Audio\/SFX|Audio|SFX|FX|Foley|Sound|Sounds|Music)\s*:/i.test(line));
|
|
2954
|
+
}
|
|
2955
|
+
function splitStoryboardTableSections(text) {
|
|
2956
|
+
const sections = [];
|
|
2957
|
+
let headers = null;
|
|
2958
|
+
for (const line of text.split(/\r?\n/)) {
|
|
2959
|
+
const cells = storyboardMarkdownTableCells(line);
|
|
2960
|
+
if (cells.length < 3)
|
|
2961
|
+
continue;
|
|
2962
|
+
if (looksLikeStoryboardTableHeader(cells)) {
|
|
2963
|
+
headers = cells;
|
|
2964
|
+
continue;
|
|
2965
|
+
}
|
|
2966
|
+
const timingCellIndex = cells.findIndex(cell => extractStoryboardTimingMarker(cell) !== null);
|
|
2967
|
+
if (timingCellIndex < 0)
|
|
2968
|
+
continue;
|
|
2969
|
+
const timing = extractStoryboardTimingMarker(cells[timingCellIndex]);
|
|
2970
|
+
if (!timing)
|
|
2971
|
+
continue;
|
|
2972
|
+
const visualHeaderIndex = storyboardTableHeaderIndex(headers, /\b(?:visual|frame|shot|image|action)\b/i);
|
|
2973
|
+
const cameraHeaderIndex = storyboardTableHeaderIndex(headers, /\b(?:camera|motion|fx|effect|lighting|style|look)\b/i);
|
|
2974
|
+
const dialogueHeaderIndex = storyboardTableHeaderIndex(headers, /\b(?:dialogue|vo|v\.o\.|voiceover|speech|narration)\b/i);
|
|
2975
|
+
const soundHeaderIndex = storyboardTableHeaderIndex(headers, /\b(?:audio|sfx|sound|foley|music)\b/i);
|
|
2976
|
+
const visibleTextHeaderIndex = storyboardTableHeaderIndex(headers, /\b(?:visible\s+text|on[-\s]?screen\s+text|onscreen\s+text|text|copy|cta|tagline|headline|title\s+card|subtitle|caption)\b/i);
|
|
2977
|
+
const beatHeaderIndex = headers
|
|
2978
|
+
? headers.findIndex(header => /\b(?:beat|scene|shot|panel|frame)\b/i.test(header)
|
|
2979
|
+
&& !/\b(?:visual|action|camera|audio|dialogue|vo|sfx)\b/i.test(header))
|
|
2980
|
+
: -1;
|
|
2981
|
+
const purposeHeaderIndex = headers
|
|
2982
|
+
? headers.findIndex(header => /\b(?:purpose|story\s*beat|story\s*purpose|beat\s*name|beat\s*title|narrative|name|title)\b/i.test(header)
|
|
2983
|
+
&& !/\b(?:visual|action|camera|audio|dialogue|vo|sfx|time|transition|scene)\b/i.test(header))
|
|
2984
|
+
: -1;
|
|
2985
|
+
const visualIndex = visualHeaderIndex >= 0
|
|
2986
|
+
? visualHeaderIndex
|
|
2987
|
+
: timingCellIndex === 0
|
|
2988
|
+
? 1
|
|
2989
|
+
: Math.min(timingCellIndex + 1, cells.length - 1);
|
|
2990
|
+
if (visualIndex === timingCellIndex)
|
|
2991
|
+
continue;
|
|
2992
|
+
const visualHeader = headers?.[visualIndex] || '';
|
|
2993
|
+
const visualCell = cells[visualIndex] || '';
|
|
2994
|
+
const visualCellWithFieldBreaks = storyboardTableCellWithFieldBreaks(visualCell);
|
|
2995
|
+
const visual = splitStoryboardTitleDescription(visualCell);
|
|
2996
|
+
const explicitAction = extractStoryboardField(visualCellWithFieldBreaks, ['Action/Motion', 'Action', 'Motion', 'Performance']);
|
|
2997
|
+
const action = explicitAction || (storyboardTableHeaderMatches(visualHeader, /\b(?:action|motion|performance)\b/i)
|
|
2998
|
+
&& !storyboardTableHeaderMatches(visualHeader, /\b(?:visual|frame|shot|image)\b/i)
|
|
2999
|
+
? visual.description || visual.title
|
|
3000
|
+
: '');
|
|
3001
|
+
const cameraLighting = cameraHeaderIndex >= 0
|
|
3002
|
+
? storyboardTableCellWithFieldBreaks(cells[cameraHeaderIndex] || '')
|
|
3003
|
+
: '';
|
|
3004
|
+
const dialogueCell = dialogueHeaderIndex >= 0
|
|
3005
|
+
? storyboardTableCellWithFieldBreaks(cells[dialogueHeaderIndex] || '')
|
|
3006
|
+
: '';
|
|
3007
|
+
const soundCell = soundHeaderIndex >= 0
|
|
3008
|
+
? storyboardTableCellWithFieldBreaks(cells[soundHeaderIndex] || '')
|
|
3009
|
+
: '';
|
|
3010
|
+
const visibleTextCell = visibleTextHeaderIndex >= 0
|
|
3011
|
+
? storyboardTableCellWithFieldBreaks(cells[visibleTextHeaderIndex] || '')
|
|
3012
|
+
: '';
|
|
3013
|
+
const audioCell = uniqueStoryboardTableIndices([dialogueHeaderIndex, soundHeaderIndex, visibleTextHeaderIndex])
|
|
3014
|
+
.map(index => storyboardTableCellWithFieldBreaks(cells[index] || ''))
|
|
3015
|
+
.filter(Boolean)
|
|
3016
|
+
.join('\n');
|
|
3017
|
+
const dialogueHeader = headers?.[dialogueHeaderIndex] || headers?.[soundHeaderIndex] || '';
|
|
3018
|
+
const camera = extractStoryboardField(cameraLighting, ['Camera', 'Camera/Motion', 'Framing', 'Shot type']);
|
|
3019
|
+
const lighting = extractStoryboardField(cameraLighting, ['Lighting', 'Style', 'Lighting/Style', 'Look']);
|
|
3020
|
+
const explicitDialogue = extractStoryboardField(audioCell, [
|
|
3021
|
+
'Dialogue/VO',
|
|
3022
|
+
'VO/Dialogue',
|
|
3023
|
+
'Dialogue',
|
|
3024
|
+
'VO',
|
|
3025
|
+
'V.O.',
|
|
3026
|
+
'Voiceover',
|
|
3027
|
+
'Voice-over',
|
|
3028
|
+
'Speech',
|
|
3029
|
+
'Narration',
|
|
3030
|
+
]);
|
|
3031
|
+
const dialogue = explicitDialogue
|
|
3032
|
+
? normalizeStoryboardDialogue(explicitDialogue)
|
|
3033
|
+
: storyboardTableCellIsAudioOnly(dialogueCell || audioCell)
|
|
3034
|
+
? ''
|
|
3035
|
+
: storyboardTableHeaderMatches(dialogueHeader, /\b(?:dialogue|vo|v\.o\.|voiceover|speech|narration)\b/i)
|
|
3036
|
+
? normalizeStoryboardDialogue(extractQuotedDialogueSegments(dialogueCell || audioCell)[0] || dialogueCell || audioCell)
|
|
3037
|
+
: '';
|
|
3038
|
+
const visibleText = extractStoryboardField(audioCell, ['Visible text', 'On-screen text', 'Onscreen text', 'Text', 'CTA'])
|
|
3039
|
+
|| (visibleTextHeaderIndex >= 0 ? compactStoryboardLine(visibleTextCell) : '');
|
|
3040
|
+
const audio = extractStoryboardField(audioCell, ['Audio/SFX', 'Audio', 'SFX', 'FX', 'Foley', 'Sound', 'Sounds'])
|
|
3041
|
+
|| storyboardTableCellWithoutDialogue(soundCell || audioCell, dialogue);
|
|
3042
|
+
const number = sections.length + 1;
|
|
3043
|
+
const beatLabel = beatHeaderIndex >= 0 && beatHeaderIndex !== timingCellIndex
|
|
3044
|
+
? compactStoryboardLine(cells[beatHeaderIndex])
|
|
3045
|
+
: '';
|
|
3046
|
+
const purposeLabel = purposeHeaderIndex >= 0
|
|
3047
|
+
&& purposeHeaderIndex !== timingCellIndex
|
|
3048
|
+
&& purposeHeaderIndex !== visualIndex
|
|
3049
|
+
&& purposeHeaderIndex !== beatHeaderIndex
|
|
3050
|
+
&& purposeHeaderIndex !== dialogueHeaderIndex
|
|
3051
|
+
&& purposeHeaderIndex !== soundHeaderIndex
|
|
3052
|
+
&& purposeHeaderIndex !== cameraHeaderIndex
|
|
3053
|
+
&& purposeHeaderIndex !== visibleTextHeaderIndex
|
|
3054
|
+
? compactStoryboardLine(cells[purposeHeaderIndex])
|
|
3055
|
+
: '';
|
|
3056
|
+
const beatTitleSource = purposeLabel && !/^\d{1,2}$/.test(purposeLabel)
|
|
3057
|
+
? purposeLabel
|
|
3058
|
+
: beatLabel && !/^\d{1,2}$/.test(beatLabel)
|
|
3059
|
+
? beatLabel
|
|
3060
|
+
: '';
|
|
3061
|
+
const sceneTitle = beatTitleSource
|
|
3062
|
+
? `${beatTitleSource}: ${visual.title}`
|
|
3063
|
+
: visual.title;
|
|
3064
|
+
sections.push({
|
|
3065
|
+
number,
|
|
3066
|
+
heading: `Scene ${number} - ${sceneTitle} - ${formatStoryboardSeconds(timing.startSec)}-${formatStoryboardSeconds(timing.endSec)}`,
|
|
3067
|
+
body: [
|
|
3068
|
+
`Visual: ${visual.description || visual.title}`,
|
|
3069
|
+
action ? `Action: ${action}` : '',
|
|
3070
|
+
camera ? `Camera: ${camera}` : cameraLighting ? `Camera: ${compactStoryboardLine(cameraLighting)}` : '',
|
|
3071
|
+
lighting ? `Lighting: ${lighting}` : '',
|
|
3072
|
+
dialogue ? `Dialogue/VO: ${dialogue}` : '',
|
|
3073
|
+
visibleText ? `Visible text: ${visibleText}` : '',
|
|
3074
|
+
audio ? `Audio/SFX: ${audio}` : '',
|
|
3075
|
+
].filter(Boolean).join('\n'),
|
|
3076
|
+
});
|
|
3077
|
+
}
|
|
3078
|
+
return sections;
|
|
3079
|
+
}
|
|
3080
|
+
function splitStoryboardSceneSections(text) {
|
|
3081
|
+
const matches = Array.from(text.matchAll(/^\s*(?:[-*+]\s*)?(?:#{1,6}\s*)?(?:[*_]{1,3})?\s*(?:Scene|Shot|Beat|Panel|Frame)\s*(\d{1,2})\b\s*(?:[-:.)|]\s*)?([^\n]*)/gim));
|
|
3082
|
+
if (matches.length === 0)
|
|
3083
|
+
return [];
|
|
3084
|
+
return matches.map((match, index) => {
|
|
3085
|
+
const start = match.index ?? 0;
|
|
3086
|
+
const nextStart = index + 1 < matches.length ? matches[index + 1].index ?? text.length : text.length;
|
|
3087
|
+
return {
|
|
3088
|
+
number: Number(match[1]),
|
|
3089
|
+
heading: compactStoryboardLine(match[0]),
|
|
3090
|
+
body: text.slice(start, nextStart).trim(),
|
|
3091
|
+
};
|
|
3092
|
+
}).filter(section => Number.isInteger(section.number) && section.number > 0);
|
|
3093
|
+
}
|
|
3094
|
+
function splitStoryboardSections(text) {
|
|
3095
|
+
const sectionHeadings = splitStoryboardSceneSections(text);
|
|
3096
|
+
const tableSections = splitStoryboardTableSections(text);
|
|
3097
|
+
if (tableSections.length > 0 && tableSections.length >= sectionHeadings.length) {
|
|
3098
|
+
return tableSections;
|
|
3099
|
+
}
|
|
3100
|
+
return sectionHeadings.length > 0 ? sectionHeadings : tableSections;
|
|
3101
|
+
}
|
|
3102
|
+
function storyboardSectionsHavePreservableExplicitTiming(sections) {
|
|
3103
|
+
return sections.length > 1
|
|
3104
|
+
&& sections.some(section => extractStoryboardTiming(`${section.heading}\n${section.body}`) !== null);
|
|
3105
|
+
}
|
|
3106
|
+
function canonicalStoryboardScriptContext(text) {
|
|
3107
|
+
const trimmed = String(text || '').trim();
|
|
3108
|
+
if (!trimmed)
|
|
3109
|
+
return '';
|
|
3110
|
+
const cutoffPattern = /^\s*(?:[-*+]\s*)?(?:#{1,6}\s*)?(?:[*_]{1,3})?\s*(?:[^\w\n]{0,12}\s*)?(?:storyboard\s+image\s+brief|subsequent\s+video\s+brief|video\s+generation\s+stage|next\s+steps?)\b[^\n]*$/gim;
|
|
3111
|
+
for (const match of trimmed.matchAll(cutoffPattern)) {
|
|
3112
|
+
const before = trimmed.slice(0, match.index ?? 0).trim();
|
|
3113
|
+
if (before && splitStoryboardSections(before).length > 0) {
|
|
3114
|
+
return before;
|
|
3115
|
+
}
|
|
3116
|
+
}
|
|
3117
|
+
return trimmed;
|
|
3118
|
+
}
|
|
3119
|
+
function escapeStoryboardRegExp(value) {
|
|
3120
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
3121
|
+
}
|
|
3122
|
+
function uniqueStoryboardStrings(values) {
|
|
3123
|
+
const seen = new Set();
|
|
3124
|
+
const result = [];
|
|
3125
|
+
for (const value of values.map(item => compactStoryboardLine(item)).filter(Boolean)) {
|
|
3126
|
+
const key = value.toLowerCase();
|
|
3127
|
+
if (seen.has(key))
|
|
3128
|
+
continue;
|
|
3129
|
+
seen.add(key);
|
|
3130
|
+
result.push(value);
|
|
3131
|
+
}
|
|
3132
|
+
return result;
|
|
3133
|
+
}
|
|
3134
|
+
function normalizeStoryboardContractTextArray(value) {
|
|
3135
|
+
if (!Array.isArray(value))
|
|
3136
|
+
return [];
|
|
3137
|
+
return uniqueStoryboardStrings(value.filter((item) => typeof item === 'string'));
|
|
3138
|
+
}
|
|
3139
|
+
function storyboardScenePlanningContractForIndex(contract, sceneNumber) {
|
|
3140
|
+
const scenes = Array.isArray(contract?.scenes) ? contract.scenes : [];
|
|
3141
|
+
const id = `scene_${String(sceneNumber).padStart(2, '0')}`.toLowerCase();
|
|
3142
|
+
return scenes.find(scene => typeof scene.id === 'string' && scene.id.toLowerCase() === id)
|
|
3143
|
+
?? scenes.find(scene => scene.index === sceneNumber)
|
|
3144
|
+
?? null;
|
|
3145
|
+
}
|
|
3146
|
+
function removeStoryboardMetadataLabelsFromVisibleText(visibleText, metadataLabels) {
|
|
3147
|
+
if (metadataLabels.length === 0)
|
|
3148
|
+
return visibleText;
|
|
3149
|
+
const metadataKeys = new Set(metadataLabels.map(item => compactStoryboardLine(item).toLowerCase()));
|
|
3150
|
+
return visibleText.filter(item => !metadataKeys.has(compactStoryboardLine(item).toLowerCase()));
|
|
3151
|
+
}
|
|
3152
|
+
function metadataLabelsFromPlanningContract(contract) {
|
|
3153
|
+
return uniqueStoryboardStrings([
|
|
3154
|
+
...normalizeStoryboardContractTextArray(contract?.metadataLabels),
|
|
3155
|
+
...normalizeStoryboardContractTextArray(contract?.endCard?.metadataLabels),
|
|
3156
|
+
...(Array.isArray(contract?.scenes)
|
|
3157
|
+
? contract.scenes.flatMap(scene => normalizeStoryboardContractTextArray(scene.metadataLabels))
|
|
3158
|
+
: []),
|
|
3159
|
+
]);
|
|
3160
|
+
}
|
|
3161
|
+
function storyboardReferenceSubjectTokens(ref) {
|
|
3162
|
+
const subject = ref.description.match(/\bSubject:\s*([\s\S]*?)\s+Usage:/i)?.[1] || '';
|
|
3163
|
+
if (!subject)
|
|
3164
|
+
return [];
|
|
3165
|
+
const stopWords = new Set([
|
|
3166
|
+
'asset',
|
|
3167
|
+
'image',
|
|
3168
|
+
'photo',
|
|
3169
|
+
'picture',
|
|
3170
|
+
'reference',
|
|
3171
|
+
'source',
|
|
3172
|
+
'input',
|
|
3173
|
+
'with',
|
|
3174
|
+
'from',
|
|
3175
|
+
'that',
|
|
3176
|
+
'this',
|
|
3177
|
+
'used',
|
|
3178
|
+
'using',
|
|
3179
|
+
'where',
|
|
3180
|
+
'scene',
|
|
3181
|
+
'scenes',
|
|
3182
|
+
'final',
|
|
3183
|
+
'card',
|
|
3184
|
+
'only',
|
|
3185
|
+
'unless',
|
|
3186
|
+
]);
|
|
3187
|
+
return uniqueStoryboardStrings(subject
|
|
3188
|
+
.toLowerCase()
|
|
3189
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
3190
|
+
.split(/\s+/)
|
|
3191
|
+
.map(token => token.trim())
|
|
3192
|
+
.filter(token => token.length >= 4 && !stopWords.has(token)));
|
|
3193
|
+
}
|
|
3194
|
+
function storyboardReferenceSubjectMatchesSection(ref, lowerSection) {
|
|
3195
|
+
return storyboardReferenceSubjectTokens(ref).some(token => new RegExp(String.raw `\b${escapeStoryboardRegExp(token)}\b`, 'i').test(lowerSection));
|
|
3196
|
+
}
|
|
3197
|
+
function sceneReferencesFromSection(section, references) {
|
|
3198
|
+
const used = references
|
|
3199
|
+
.filter(ref => ref.index && new RegExp(String.raw `\b(?:image|photo|picture|asset)\s*(?:#|number\s*)?${ref.index}\b`, 'i').test(section))
|
|
3200
|
+
.map(ref => ref.id);
|
|
3201
|
+
if (used.length > 0)
|
|
3202
|
+
return used;
|
|
3203
|
+
const lower = section.toLowerCase();
|
|
3204
|
+
const subjectMatches = references
|
|
3205
|
+
.filter(ref => ref.kind !== 'logo')
|
|
3206
|
+
.filter(ref => storyboardReferenceSubjectMatchesSection(ref, lower))
|
|
3207
|
+
.map(ref => ref.id);
|
|
3208
|
+
if (subjectMatches.length > 0)
|
|
3209
|
+
return uniqueStoryboardStrings(subjectMatches);
|
|
3210
|
+
const roleMatches = references
|
|
3211
|
+
.filter(ref => {
|
|
3212
|
+
if (ref.kind === 'logo')
|
|
3213
|
+
return /\b(?:logo|wordmark|brand|brand\s+mark|end\s+card|cta|tagline)\b/i.test(lower);
|
|
3214
|
+
if (ref.kind === 'style' || ref.kind === 'background')
|
|
3215
|
+
return /\b(?:style|look|lighting|palette|background|environment|setting)\b/i.test(lower);
|
|
3216
|
+
return false;
|
|
3217
|
+
})
|
|
3218
|
+
.map(ref => ref.id);
|
|
3219
|
+
if (roleMatches.length > 0)
|
|
3220
|
+
return uniqueStoryboardStrings(roleMatches);
|
|
3221
|
+
return references
|
|
3222
|
+
.filter(ref => ref.usageScope === 'global')
|
|
3223
|
+
.filter(ref => {
|
|
3224
|
+
if (ref.kind === 'character') {
|
|
3225
|
+
return /\b(?:character|mascot|person|people|face|actor|host|protagonist|subject|hero|doll|toy|figure|avatar|girl|boy|woman|man|she|he|they|her|him)\b/i.test(lower);
|
|
3226
|
+
}
|
|
3227
|
+
if (ref.kind === 'product') {
|
|
3228
|
+
return /\b(?:product|package|device|object|item|prototype|model|tool|app|platform|integration|feature)\b/i.test(lower);
|
|
3229
|
+
}
|
|
3230
|
+
return false;
|
|
3231
|
+
})
|
|
3232
|
+
.map(ref => ref.id);
|
|
3233
|
+
}
|
|
3234
|
+
function sceneTextRequirementsFromSection(section) {
|
|
3235
|
+
const required = extractStoryboardRequiredText(section);
|
|
3236
|
+
const visualCueContext = [
|
|
3237
|
+
section.split(/\r?\n/, 1)[0] || '',
|
|
3238
|
+
extractStoryboardField(section, ['Visual/Action', 'Visual Frame', 'Visual', 'Frame', 'Image', 'Shot']),
|
|
3239
|
+
extractStoryboardField(section, ['Action/Motion', 'Action', 'Motion', 'Performance']),
|
|
3240
|
+
extractStoryboardField(section, ['Product/Feature', 'Product feature', 'Feature mapping', 'Product meaning', 'Feature', 'Capability']),
|
|
3241
|
+
].filter(Boolean).join('\n');
|
|
3242
|
+
const hasVisibleTextCue = /\b(?:visible\s+text|on[-\s]?screen\s+text|onscreen\s+text|text\s+overlay|title\s+card|end\s+card|cta|tagline|headline|slogan|copy|wordmark|text\s+(?:appears|changes|updates|fades|reads|below)|reads\s*:)\b/i.test(visualCueContext);
|
|
3243
|
+
if (!hasVisibleTextCue) {
|
|
3244
|
+
return required;
|
|
3245
|
+
}
|
|
3246
|
+
const quotedVisualText = extractQuotedDialogueSegments(visualCueContext)
|
|
3247
|
+
.map(text => compactStoryboardLine(text))
|
|
3248
|
+
.filter(Boolean)
|
|
3249
|
+
.filter(text => !/^[-\u2013\u2014]\.?$/.test(text))
|
|
3250
|
+
.filter(text => !/^(?:none|no\s+(?:visible\s+)?text|n\/a|not\s+specified)$/i.test(text));
|
|
3251
|
+
return uniqueStoryboardStrings([...required, ...quotedVisualText]);
|
|
3252
|
+
}
|
|
3253
|
+
function isNoAudioPlaceholder(value) {
|
|
3254
|
+
return /^(?:no\s+(?:audio|sound|sfx|audio\/sfx)(?:\s+specified)?|none|n\/a|not\s+specified)\.?$/i.test(value.trim());
|
|
3255
|
+
}
|
|
3256
|
+
function sanitizeStoryboardExternalAudioReferences(value) {
|
|
3257
|
+
let sanitized = value;
|
|
3258
|
+
sanitized = sanitized
|
|
3259
|
+
.replace(/\b(?:trending|viral|popular)\s+(?:TikTok\s+)?(?:audio|sound|song|track)\b/gi, 'video-model-generated social-video music bed')
|
|
3260
|
+
.replace(/\b(?:a\s+)?(?:trending|viral|popular)\s+(?:upbeat\s+)?(?:audio|sound|song|track)\b/gi, 'a video-model-generated upbeat social-video music bed')
|
|
3261
|
+
.replace(/\b(?:existing|external|licensed|commercial)\s+(?:audio|sound|song|track|music)\b/gi, 'generated audio');
|
|
3262
|
+
sanitized = sanitized.replace(/\b(?:all\s+)?(?:cuts?|edits?|transitions?)\s+(?:must\s+|should\s+)?(?:align|sync|cut)\s+(?:perfectly\s+|exactly\s+)?(?:with|to)\s+([^.!?\n]*(?:beat|beats|drop|drops|snare|hi[-/\s]?hat|kick|percussion)[^.!?\n]*)[.!?]?/gi, 'Cuts should follow a generated music bed, with percussion hits, beat drops, whooshes, and foley cues described for the video model.');
|
|
3263
|
+
if (/\b(?:snare|hi-hat|beat drops?|cuts?)\b/i.test(sanitized) && /\bvideo-model-generated\b/i.test(sanitized)) {
|
|
3264
|
+
sanitized = sanitized.replace(/\bperfectly\b/gi, 'clearly');
|
|
3265
|
+
}
|
|
3266
|
+
return sanitized;
|
|
3267
|
+
}
|
|
3268
|
+
function normalizeStoryboardAudioSfx(value) {
|
|
3269
|
+
const compact = sanitizeStoryboardExternalAudioReferences(compactStoryboardLine(value));
|
|
3270
|
+
if (!compact || isNoAudioPlaceholder(compact))
|
|
3271
|
+
return [];
|
|
3272
|
+
return compact
|
|
3273
|
+
.split(/\s*,\s*|\s*;\s*/)
|
|
3274
|
+
.map(item => item.trim())
|
|
3275
|
+
.filter(Boolean);
|
|
3276
|
+
}
|
|
3277
|
+
function buildSceneFromSection(section, references, fallbackTiming, planningContract) {
|
|
3278
|
+
const combined = `${section.heading}\n${section.body}`;
|
|
3279
|
+
const timing = extractStoryboardTiming(combined) ?? fallbackTiming;
|
|
3280
|
+
const normalizedHeading = stripStoryboardMarkup(section.heading)
|
|
3281
|
+
.replace(/^\s*#{1,6}\s*/, '');
|
|
3282
|
+
const title = compactStoryboardLine(removeStoryboardTimingText(normalizedHeading
|
|
3283
|
+
.replace(/^\s*(?:Scene|Shot|Beat|Panel|Frame)\s*\d{1,2}\b\s*(?:[-:.)|]\s*)?/i, '')), `Scene ${String(section.number).padStart(2, '0')}`);
|
|
3284
|
+
const dialogueField = extractStoryboardField(combined, [
|
|
3285
|
+
'Dialogue/VO',
|
|
3286
|
+
'VO/Dialogue',
|
|
3287
|
+
'Dialogue',
|
|
3288
|
+
'VO',
|
|
3289
|
+
'V.O.',
|
|
3290
|
+
'Voiceover',
|
|
3291
|
+
'Voice-over',
|
|
3292
|
+
'Speech',
|
|
3293
|
+
'Narration',
|
|
3294
|
+
]);
|
|
3295
|
+
const hasExplicitNoDialogueField = /\b(?:Dialogue\/VO|VO\/Dialogue|Dialogue|VO|V\.O\.|Voiceover|Voice-over|Speech|Narration)\s*:\s*(?:none|no\s+(?:spoken\s+)?(?:dialogue|vo|voiceover|voice-over|speech)|n\/a|not\s+specified|text\s+only)\b/i.test(combined);
|
|
3296
|
+
const dialogue = dialogueField
|
|
3297
|
+
? normalizeStoryboardDialogue(dialogueField)
|
|
3298
|
+
: hasExplicitNoDialogueField
|
|
3299
|
+
? ''
|
|
3300
|
+
: /\b(?:VO|V\.O\.|voiceover|voice-over|dialogue|speech|narration|says?|speaks?|whispers?|shouts?)\b/i.test(combined)
|
|
3301
|
+
? extractQuotedDialogueSegments(combined)[0] || ''
|
|
3302
|
+
: '';
|
|
3303
|
+
const audio = extractStoryboardField(combined, ['Audio/SFX', 'Audio/Foley', 'Foley/SFX', 'Audio', 'SFX', 'FX', 'Foley', 'Sound', 'Sounds']);
|
|
3304
|
+
const audioSfx = normalizeStoryboardAudioSfx(audio);
|
|
3305
|
+
const metadataLabels = normalizeStoryboardContractTextArray(planningContract?.metadataLabels);
|
|
3306
|
+
const hasTypedVisibleText = Array.isArray(planningContract?.visibleText);
|
|
3307
|
+
const visibleText = hasTypedVisibleText
|
|
3308
|
+
? normalizeStoryboardContractTextArray(planningContract?.visibleText)
|
|
3309
|
+
: sceneTextRequirementsFromSection(combined);
|
|
3310
|
+
const referenceUsage = Array.isArray(planningContract?.referenceUsage)
|
|
3311
|
+
? uniqueStoryboardStrings(planningContract.referenceUsage)
|
|
3312
|
+
: sceneReferencesFromSection(combined, references);
|
|
3313
|
+
return {
|
|
3314
|
+
id: `scene_${String(section.number).padStart(2, '0')}`,
|
|
3315
|
+
title,
|
|
3316
|
+
startSec: timing?.startSec ?? null,
|
|
3317
|
+
endSec: timing?.endSec ?? null,
|
|
3318
|
+
durationSec: timing?.durationSec ?? null,
|
|
3319
|
+
purpose: extractStoryboardField(combined, ['Purpose', 'Story purpose', 'Narrative purpose', 'Beat purpose', 'Scene purpose', 'Why this beat exists']),
|
|
3320
|
+
productFeature: extractStoryboardField(combined, ['Product/Feature', 'Product feature', 'Feature mapping', 'Product meaning', 'Product idea', 'Feature', 'Capability']),
|
|
3321
|
+
visual: extractStoryboardField(combined, ['Visual/Action', 'Visual Frame', 'Visual', 'Frame', 'Image', 'Shot']) || compactStoryboardLine(section.body.slice(0, 240)),
|
|
3322
|
+
action: extractStoryboardField(combined, ['Action/Motion', 'Action', 'Motion', 'Performance', 'Beat']),
|
|
3323
|
+
camera: extractStoryboardField(combined, ['Camera/Motion', 'Camera', 'Framing', 'Shot type']),
|
|
3324
|
+
lighting: extractStoryboardField(combined, ['Lighting/Style', 'Lighting', 'Style', 'Look']),
|
|
3325
|
+
transitionIn: extractStoryboardField(combined, ['Transition in', 'Transition-in', 'In']),
|
|
3326
|
+
transitionOut: extractStoryboardField(combined, ['Transition', 'Transition out', 'Transition-out', 'Edit']),
|
|
3327
|
+
dialogue,
|
|
3328
|
+
audioSfx,
|
|
3329
|
+
music: sanitizeStoryboardExternalAudioReferences(extractStoryboardField(combined, ['Music', 'Score', 'Underscore'])),
|
|
3330
|
+
referenceUsage,
|
|
3331
|
+
textInImage: removeStoryboardMetadataLabelsFromVisibleText(visibleText, metadataLabels),
|
|
3332
|
+
metadataLabels,
|
|
3333
|
+
mustAvoid: extractStoryboardAvoidConstraints(combined),
|
|
3334
|
+
};
|
|
3335
|
+
}
|
|
3336
|
+
function applyStoryboardScenePlanningContract(scene, planningContract) {
|
|
3337
|
+
if (!planningContract)
|
|
3338
|
+
return scene;
|
|
3339
|
+
const metadataLabels = normalizeStoryboardContractTextArray(planningContract.metadataLabels);
|
|
3340
|
+
const hasTypedVisibleText = Array.isArray(planningContract.visibleText);
|
|
3341
|
+
const visibleText = hasTypedVisibleText
|
|
3342
|
+
? normalizeStoryboardContractTextArray(planningContract.visibleText)
|
|
3343
|
+
: scene.textInImage;
|
|
3344
|
+
const referenceUsage = Array.isArray(planningContract.referenceUsage)
|
|
3345
|
+
? uniqueStoryboardStrings(planningContract.referenceUsage)
|
|
3346
|
+
: scene.referenceUsage;
|
|
3347
|
+
return {
|
|
3348
|
+
...scene,
|
|
3349
|
+
referenceUsage,
|
|
3350
|
+
textInImage: removeStoryboardMetadataLabelsFromVisibleText(visibleText, metadataLabels),
|
|
3351
|
+
metadataLabels,
|
|
3352
|
+
};
|
|
3353
|
+
}
|
|
3354
|
+
function buildFallbackScenes(frameCount, durationSec, sourceText) {
|
|
3355
|
+
const sceneDuration = durationSec && frameCount > 0
|
|
3356
|
+
? Math.round((durationSec / frameCount) * 100) / 100
|
|
3357
|
+
: null;
|
|
3358
|
+
return Array.from({ length: frameCount }, (_, index) => {
|
|
3359
|
+
const startSec = sceneDuration !== null ? Math.round(index * sceneDuration * 100) / 100 : null;
|
|
3360
|
+
const endSec = sceneDuration !== null
|
|
3361
|
+
? index === frameCount - 1 && durationSec !== null
|
|
3362
|
+
? durationSec
|
|
3363
|
+
: Math.round(((index + 1) * sceneDuration) * 100) / 100
|
|
3364
|
+
: null;
|
|
3365
|
+
return {
|
|
3366
|
+
id: `scene_${String(index + 1).padStart(2, '0')}`,
|
|
3367
|
+
title: `Frame ${String(index + 1).padStart(2, '0')}`,
|
|
3368
|
+
startSec,
|
|
3369
|
+
endSec,
|
|
3370
|
+
durationSec: startSec !== null && endSec !== null ? Math.round((endSec - startSec) * 100) / 100 : null,
|
|
3371
|
+
purpose: index === 0
|
|
3372
|
+
? 'Establish the hook and the first clear story beat from the source brief.'
|
|
3373
|
+
: 'Advance the same story spine with a distinct sequential beat.',
|
|
3374
|
+
productFeature: '',
|
|
3375
|
+
visual: index === 0 ? compactStoryboardLine(sourceText.slice(0, 260)) : 'Follow the approved source brief for this sequential storyboard beat.',
|
|
3376
|
+
action: '',
|
|
3377
|
+
camera: '',
|
|
3378
|
+
lighting: '',
|
|
3379
|
+
transitionIn: '',
|
|
3380
|
+
transitionOut: '',
|
|
3381
|
+
dialogue: '',
|
|
3382
|
+
audioSfx: [],
|
|
3383
|
+
music: '',
|
|
3384
|
+
referenceUsage: [],
|
|
3385
|
+
textInImage: [],
|
|
3386
|
+
metadataLabels: [],
|
|
3387
|
+
mustAvoid: [],
|
|
3388
|
+
};
|
|
3389
|
+
});
|
|
3390
|
+
}
|
|
3391
|
+
function normalizeAssistantStoryboardSceneTiming(scenes, targetDurationSec, promptAuthorship) {
|
|
3392
|
+
if (promptAuthorship !== 'assistant' || targetDurationSec === null || scenes.length === 0) {
|
|
3393
|
+
return scenes;
|
|
3394
|
+
}
|
|
3395
|
+
const timedScenes = scenes.filter(scene => scene.startSec !== null
|
|
3396
|
+
&& scene.endSec !== null
|
|
3397
|
+
&& scene.durationSec !== null
|
|
3398
|
+
&& scene.endSec > scene.startSec
|
|
3399
|
+
&& scene.durationSec > 0);
|
|
3400
|
+
if (timedScenes.length !== scenes.length)
|
|
3401
|
+
return scenes;
|
|
3402
|
+
const totalSceneDurationSec = Math.round(scenes.reduce((sum, scene) => sum + (scene.durationSec ?? 0), 0) * 100) / 100;
|
|
3403
|
+
if (totalSceneDurationSec <= 0)
|
|
3404
|
+
return scenes;
|
|
3405
|
+
const diff = Math.abs(totalSceneDurationSec - targetDurationSec);
|
|
3406
|
+
if (diff <= DEFAULT_STORYBOARD_TIMING_RULES.toleranceSec)
|
|
3407
|
+
return scenes;
|
|
3408
|
+
const scale = targetDurationSec / totalSceneDurationSec;
|
|
3409
|
+
const timelineStartSec = scenes[0].startSec ?? 0;
|
|
3410
|
+
const timelineEndSec = Math.round((timelineStartSec + targetDurationSec) * 100) / 100;
|
|
3411
|
+
let cursor = timelineStartSec;
|
|
3412
|
+
return scenes.map((scene, index) => {
|
|
3413
|
+
const isLast = index === scenes.length - 1;
|
|
3414
|
+
const startLimit = isLast ? timelineEndSec - 0.01 : cursor;
|
|
3415
|
+
const startSec = Math.round(Math.min(cursor, startLimit) * 100) / 100;
|
|
3416
|
+
const endSec = isLast
|
|
3417
|
+
? timelineEndSec
|
|
3418
|
+
: Math.round((startSec + Math.max(0.01, (scene.durationSec ?? 0) * scale)) * 100) / 100;
|
|
3419
|
+
const durationSec = Math.round((endSec - startSec) * 100) / 100;
|
|
3420
|
+
cursor = endSec;
|
|
3421
|
+
return {
|
|
3422
|
+
...scene,
|
|
3423
|
+
startSec,
|
|
3424
|
+
endSec,
|
|
3425
|
+
durationSec,
|
|
3426
|
+
};
|
|
3427
|
+
});
|
|
3428
|
+
}
|
|
3429
|
+
function normalizeStoryboardDialogueToken(value) {
|
|
3430
|
+
return value
|
|
3431
|
+
.toLowerCase()
|
|
3432
|
+
.replace(/[‘’]/g, "'")
|
|
3433
|
+
.replace(/^'+|'+$/g, '');
|
|
3434
|
+
}
|
|
3435
|
+
function storyboardDialogueWordSpans(text) {
|
|
3436
|
+
const spans = [];
|
|
3437
|
+
const pattern = /[\p{L}\p{N}]+(?:[’'][\p{L}\p{N}]+)?/gu;
|
|
3438
|
+
let match;
|
|
3439
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
3440
|
+
spans.push({
|
|
3441
|
+
token: normalizeStoryboardDialogueToken(match[0]),
|
|
3442
|
+
start: match.index,
|
|
3443
|
+
end: match.index + match[0].length,
|
|
3444
|
+
});
|
|
3445
|
+
}
|
|
3446
|
+
return spans;
|
|
3447
|
+
}
|
|
3448
|
+
function storyboardDialogueCoverage(sourceDialogue, currentDialogue) {
|
|
3449
|
+
const sourceTokens = storyboardDialogueWordSpans(sourceDialogue).map(span => span.token);
|
|
3450
|
+
const currentTokens = storyboardDialogueWordSpans(currentDialogue).map(span => span.token);
|
|
3451
|
+
if (sourceTokens.length === 0)
|
|
3452
|
+
return 1;
|
|
3453
|
+
if (currentTokens.length === 0)
|
|
3454
|
+
return 0;
|
|
3455
|
+
let cursor = 0;
|
|
3456
|
+
let matched = 0;
|
|
3457
|
+
for (const token of sourceTokens) {
|
|
3458
|
+
const foundIndex = currentTokens.indexOf(token, cursor);
|
|
3459
|
+
if (foundIndex < 0)
|
|
3460
|
+
continue;
|
|
3461
|
+
matched += 1;
|
|
3462
|
+
cursor = foundIndex + 1;
|
|
3463
|
+
}
|
|
3464
|
+
return matched / sourceTokens.length;
|
|
3465
|
+
}
|
|
3466
|
+
function sourceQuotedStoryboardDialogueSegments(userIntentText) {
|
|
3467
|
+
const text = normalizeScreenplayDialogueQuotes(userIntentText.replace(/[“”]/g, '"')) || '';
|
|
3468
|
+
const segments = [];
|
|
3469
|
+
const pattern = /"([^"]{1,800})"/g;
|
|
3470
|
+
let match;
|
|
3471
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
3472
|
+
const index = match.index;
|
|
3473
|
+
const before = text.slice(Math.max(0, index - 180), index);
|
|
3474
|
+
const after = text.slice(pattern.lastIndex, Math.min(text.length, pattern.lastIndex + 120));
|
|
3475
|
+
const context = `${before} ${after}`;
|
|
3476
|
+
const lineStart = text.lastIndexOf('\n', Math.max(0, index - 1)) + 1;
|
|
3477
|
+
const nextLineBreak = text.indexOf('\n', pattern.lastIndex);
|
|
3478
|
+
const lineEnd = nextLineBreak >= 0 ? nextLineBreak : text.length;
|
|
3479
|
+
const lineContext = text.slice(lineStart, lineEnd);
|
|
3480
|
+
const speechContext = /\b(?:dialogue|vo|v\.o\.|voiceover|voice-over|speech|spoken|monologue|narration|line|lines|say|says|said|speak|speaks|speaking|script)\b/i.test(context);
|
|
3481
|
+
const lineSpeechContext = /\b(?:dialogue|vo|v\.o\.|voiceover|voice-over|speech|spoken|monologue|narration|say|says|said|speak|speaks|speaking)\b/i.test(lineContext);
|
|
3482
|
+
const lineVisibleTextOnly = /\b(?:visible|on[-\s]?screen|onscreen|text|copy|cta|tagline|headline|title\s+card|logo|wordmark|spell(?:ed)?|read(?:s)?|end\s+card|slogan)\b/i.test(lineContext)
|
|
3483
|
+
&& !lineSpeechContext;
|
|
3484
|
+
const lineNonVoiceMetadata = /\b(?:working\s+title|title|format|duration|aspect(?:\s+ratio)?|reference\s+assets?)\s*:/i.test(lineContext)
|
|
3485
|
+
&& !lineSpeechContext;
|
|
3486
|
+
const value = compactStoryboardLine(match[1]);
|
|
3487
|
+
if (value && speechContext && !lineVisibleTextOnly && !lineNonVoiceMetadata)
|
|
3488
|
+
segments.push(value);
|
|
3489
|
+
}
|
|
3490
|
+
return segments;
|
|
3491
|
+
}
|
|
3492
|
+
function findStoryboardDialogueStartIndex(sourceSpans, dialogue, cursor) {
|
|
3493
|
+
const dialogueTokens = storyboardDialogueWordSpans(dialogue).map(span => span.token);
|
|
3494
|
+
if (dialogueTokens.length === 0)
|
|
3495
|
+
return null;
|
|
3496
|
+
const maxNeedleLength = Math.min(3, dialogueTokens.length);
|
|
3497
|
+
for (let needleLength = maxNeedleLength; needleLength >= 1; needleLength -= 1) {
|
|
3498
|
+
const needle = dialogueTokens.slice(0, needleLength);
|
|
3499
|
+
for (let index = cursor; index <= sourceSpans.length - needleLength; index += 1) {
|
|
3500
|
+
const matches = needle.every((token, offset) => sourceSpans[index + offset]?.token === token);
|
|
3501
|
+
if (matches)
|
|
3502
|
+
return index;
|
|
3503
|
+
}
|
|
3504
|
+
}
|
|
3505
|
+
return null;
|
|
3506
|
+
}
|
|
3507
|
+
function sourceDialogueSliceForWordRange(sourceDialogue, sourceSpans, startIndex, endExclusive) {
|
|
3508
|
+
const start = sourceSpans[startIndex]?.start ?? 0;
|
|
3509
|
+
const lastSpan = sourceSpans[Math.max(startIndex, endExclusive - 1)];
|
|
3510
|
+
if (!lastSpan)
|
|
3511
|
+
return '';
|
|
3512
|
+
const nextStart = sourceSpans[endExclusive]?.start ?? sourceDialogue.length;
|
|
3513
|
+
const trailing = sourceDialogue.slice(lastSpan.end, nextStart).match(/^[\s.,!?;:…'"’”)}\]-]*/)?.[0] ?? '';
|
|
3514
|
+
return sourceDialogue.slice(start, lastSpan.end + trailing.length).trim();
|
|
3515
|
+
}
|
|
3516
|
+
function matchSourceDialogueSceneStarts(scenes, sourceSpans) {
|
|
3517
|
+
const dialogueScenes = scenes
|
|
3518
|
+
.map((scene, index) => ({ scene, index }))
|
|
3519
|
+
.filter(({ scene }) => scene.dialogue.trim().length > 0);
|
|
3520
|
+
if (sourceSpans.length === 0 || dialogueScenes.length === 0)
|
|
3521
|
+
return null;
|
|
3522
|
+
const matches = [];
|
|
3523
|
+
let cursor = 0;
|
|
3524
|
+
for (const { scene, index } of dialogueScenes) {
|
|
3525
|
+
const start = findStoryboardDialogueStartIndex(sourceSpans, scene.dialogue, cursor);
|
|
3526
|
+
if (start === null)
|
|
3527
|
+
return null;
|
|
3528
|
+
matches.push({ sceneIndex: index, startIndex: start });
|
|
3529
|
+
cursor = start + 1;
|
|
3530
|
+
}
|
|
3531
|
+
return matches;
|
|
3532
|
+
}
|
|
3533
|
+
function redistributeSourceDialogueAcrossMatchedScenes(scenes, sourceDialogue, matches) {
|
|
3534
|
+
const sourceSpans = storyboardDialogueWordSpans(sourceDialogue);
|
|
3535
|
+
if (sourceSpans.length === 0 || matches.length === 0)
|
|
3536
|
+
return null;
|
|
3537
|
+
const repaired = scenes.slice();
|
|
3538
|
+
for (let index = 0; index < matches.length; index += 1) {
|
|
3539
|
+
const sceneIndex = matches[index].sceneIndex;
|
|
3540
|
+
const start = matches[index].startIndex;
|
|
3541
|
+
const end = matches[index + 1]?.startIndex ?? sourceSpans.length;
|
|
3542
|
+
if (end <= start)
|
|
3543
|
+
return null;
|
|
3544
|
+
const dialogue = sourceDialogueSliceForWordRange(sourceDialogue, sourceSpans, start, end);
|
|
3545
|
+
if (!dialogue)
|
|
3546
|
+
return null;
|
|
3547
|
+
repaired[sceneIndex] = {
|
|
3548
|
+
...repaired[sceneIndex],
|
|
3549
|
+
dialogue,
|
|
3550
|
+
};
|
|
3551
|
+
}
|
|
3552
|
+
return repaired;
|
|
3553
|
+
}
|
|
3554
|
+
function alignAssistantStoryboardDialogueWithUserSource(scenes, userIntentText, promptAuthorship) {
|
|
3555
|
+
if (promptAuthorship !== 'assistant')
|
|
3556
|
+
return { scenes, shouldRetime: false };
|
|
3557
|
+
const sourceDialogue = sourceQuotedStoryboardDialogueSegments(userIntentText).join(' ');
|
|
3558
|
+
if (!sourceDialogue)
|
|
3559
|
+
return { scenes, shouldRetime: false };
|
|
3560
|
+
const sourceSpans = storyboardDialogueWordSpans(sourceDialogue);
|
|
3561
|
+
const matches = matchSourceDialogueSceneStarts(scenes, sourceSpans);
|
|
3562
|
+
if (!matches)
|
|
3563
|
+
return { scenes, shouldRetime: false };
|
|
3564
|
+
const currentDialogue = scenes.map(scene => scene.dialogue).filter(Boolean).join(' ');
|
|
3565
|
+
if (storyboardDialogueCoverage(sourceDialogue, currentDialogue) >= 1) {
|
|
3566
|
+
return { scenes, shouldRetime: true };
|
|
3567
|
+
}
|
|
3568
|
+
const repaired = redistributeSourceDialogueAcrossMatchedScenes(scenes, sourceDialogue, matches);
|
|
3569
|
+
if (!repaired)
|
|
3570
|
+
return { scenes, shouldRetime: false };
|
|
3571
|
+
return { scenes: repaired, shouldRetime: true };
|
|
3572
|
+
}
|
|
3573
|
+
function minimumStoryboardSceneDurationForDialogue(scene, rules) {
|
|
3574
|
+
const dialogueWords = countWords(scene.dialogue);
|
|
3575
|
+
let minDuration = dialogueWords > 0
|
|
3576
|
+
? dialogueWords / rules.normalWordsPerSecondMax
|
|
3577
|
+
: 0.5;
|
|
3578
|
+
const sceneContext = `${scene.title}\n${scene.visual}\n${scene.textInImage.join(' ')}`;
|
|
3579
|
+
const hasReadableText = scene.textInImage.length > 0;
|
|
3580
|
+
const isEndCard = /\b(?:end card|outro|cta|final|logo|brand resolve)\b/i.test(sceneContext);
|
|
3581
|
+
if (isEndCard) {
|
|
3582
|
+
minDuration = Math.max(minDuration, rules.minEndCardHoldSec);
|
|
3583
|
+
}
|
|
3584
|
+
else if (hasReadableText) {
|
|
3585
|
+
minDuration = Math.max(minDuration, 1.2);
|
|
3586
|
+
}
|
|
3587
|
+
return Math.round(minDuration * 100) / 100;
|
|
3588
|
+
}
|
|
3589
|
+
function retimeStoryboardScenesForDialogue(scenes, targetDurationSec, rules = DEFAULT_STORYBOARD_TIMING_RULES) {
|
|
3590
|
+
if (targetDurationSec === null || scenes.length === 0)
|
|
3591
|
+
return scenes;
|
|
3592
|
+
if (!scenes.every(scene => scene.startSec !== null && scene.endSec !== null && scene.durationSec !== null && scene.durationSec > 0)) {
|
|
3593
|
+
return scenes;
|
|
3594
|
+
}
|
|
3595
|
+
const minimumDurations = scenes.map(scene => minimumStoryboardSceneDurationForDialogue(scene, rules));
|
|
3596
|
+
const needsRetiming = scenes.some((scene, index) => (scene.durationSec ?? 0) + rules.toleranceSec < minimumDurations[index]);
|
|
3597
|
+
if (!needsRetiming)
|
|
3598
|
+
return scenes;
|
|
3599
|
+
const minimumTotal = Math.round(minimumDurations.reduce((sum, duration) => sum + duration, 0) * 100) / 100;
|
|
3600
|
+
if (minimumTotal > targetDurationSec + rules.toleranceSec)
|
|
3601
|
+
return scenes;
|
|
3602
|
+
const extraDuration = Math.max(0, targetDurationSec - minimumTotal);
|
|
3603
|
+
const originalDurations = scenes.map(scene => scene.durationSec ?? 0);
|
|
3604
|
+
const weightTotal = originalDurations.reduce((sum, duration) => sum + Math.max(0.01, duration), 0);
|
|
3605
|
+
const unroundedDurations = minimumDurations.map((minimum, index) => (minimum + (extraDuration * Math.max(0.01, originalDurations[index]) / weightTotal)));
|
|
3606
|
+
const timelineStartSec = scenes[0].startSec ?? 0;
|
|
3607
|
+
const timelineEndSec = Math.round((timelineStartSec + targetDurationSec) * 100) / 100;
|
|
3608
|
+
let cursor = timelineStartSec;
|
|
3609
|
+
return scenes.map((scene, index) => {
|
|
3610
|
+
const isLast = index === scenes.length - 1;
|
|
3611
|
+
const startSec = Math.round(cursor * 100) / 100;
|
|
3612
|
+
const endSec = isLast
|
|
3613
|
+
? timelineEndSec
|
|
3614
|
+
: Math.round((startSec + unroundedDurations[index]) * 100) / 100;
|
|
3615
|
+
const durationSec = Math.round((endSec - startSec) * 100) / 100;
|
|
3616
|
+
cursor = endSec;
|
|
3617
|
+
return {
|
|
3618
|
+
...scene,
|
|
3619
|
+
startSec,
|
|
3620
|
+
endSec,
|
|
3621
|
+
durationSec,
|
|
3622
|
+
};
|
|
3623
|
+
});
|
|
3624
|
+
}
|
|
3625
|
+
function quotedStoryboardVoiceLinesFromText(text) {
|
|
3626
|
+
const lines = [];
|
|
3627
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
3628
|
+
const line = compactStoryboardLine(stripStoryboardMarkup(rawLine));
|
|
3629
|
+
if (!line || !/"[^"]{1,800}"/.test(line))
|
|
3630
|
+
continue;
|
|
3631
|
+
const fieldLabel = line.match(/^\s*(?:[-*+]\s*)?(?:[*_]{1,3})?\s*([^:\n]{1,60})\s*:/)?.[1] ?? '';
|
|
3632
|
+
const isExplicitVoiceField = /\b(?:dialogue|vo|v\.o\.|voiceover|voice-over|speech|narration|spoken)\b/i.test(fieldLabel);
|
|
3633
|
+
const isNonVoiceProductionField = /\b(?:audio|sfx|fx|foley|sound|music|action|motion|transition|camera|lighting|style|visual|visible|text|copy|cta|tagline|headline|title\s+card|caption|subtitle|super)\b/i.test(fieldLabel)
|
|
3634
|
+
&& !isExplicitVoiceField;
|
|
3635
|
+
const hasSpeechVerb = /\b(?:says?|speaks?|speaking|asks?|replies?|responds?|whispers?|shouts?|yells?|sings?|narrates?|voiceover|voice-over)\b/i.test(line);
|
|
3636
|
+
if (isNonVoiceProductionField && !hasSpeechVerb)
|
|
3637
|
+
continue;
|
|
3638
|
+
if (!isExplicitVoiceField && !hasSpeechVerb)
|
|
3639
|
+
continue;
|
|
3640
|
+
lines.push(...extractQuotedDialogueSegments(line).map(item => compactStoryboardLine(item)).filter(Boolean));
|
|
3641
|
+
}
|
|
3642
|
+
return lines;
|
|
3643
|
+
}
|
|
3644
|
+
function assignVoiceLinesToScenes(scenes, sourceText) {
|
|
3645
|
+
const sceneLines = scenes.flatMap(scene => {
|
|
3646
|
+
const dialogue = scene.dialogue.trim();
|
|
3647
|
+
if (!dialogue)
|
|
3648
|
+
return [];
|
|
3649
|
+
const quoted = extractQuotedDialogueSegments(dialogue);
|
|
3650
|
+
const lines = quoted.length > 0 ? quoted : [dialogue];
|
|
3651
|
+
return lines.map((text) => ({
|
|
3652
|
+
text,
|
|
3653
|
+
sceneId: scene.id,
|
|
3654
|
+
startSec: scene.startSec,
|
|
3655
|
+
endSec: scene.endSec,
|
|
3656
|
+
delivery: '',
|
|
3657
|
+
priority: 'required',
|
|
3658
|
+
}));
|
|
3659
|
+
});
|
|
3660
|
+
if (sceneLines.length > 0)
|
|
3661
|
+
return sceneLines;
|
|
3662
|
+
const quoted = quotedStoryboardVoiceLinesFromText(sourceText);
|
|
3663
|
+
return quoted.map((text, index) => {
|
|
3664
|
+
const scene = scenes[Math.min(index, Math.max(0, scenes.length - 1))];
|
|
3665
|
+
return {
|
|
3666
|
+
text,
|
|
3667
|
+
sceneId: scene?.id ?? 'scene_01',
|
|
3668
|
+
startSec: scene?.startSec ?? null,
|
|
3669
|
+
endSec: scene?.endSec ?? null,
|
|
3670
|
+
delivery: '',
|
|
3671
|
+
priority: 'required',
|
|
3672
|
+
};
|
|
3673
|
+
});
|
|
3674
|
+
}
|
|
3675
|
+
function inferStoryboardToneProgression(text) {
|
|
3676
|
+
const progression = text.match(/\b(?:tone progression|progression|arc)\s*:\s*([^\n]{1,240})/i)?.[1];
|
|
3677
|
+
if (!progression)
|
|
3678
|
+
return [];
|
|
3679
|
+
return progression.split(/\s*(?:->|,|;|\|)\s*/).map(item => item.trim()).filter(Boolean);
|
|
3680
|
+
}
|
|
3681
|
+
function cleanStoryboardStorySpine(value) {
|
|
3682
|
+
const cleaned = sanitizeStoryboardExternalAudioReferences(compactStoryboardLine(stripStoryboardMarkup(value)))
|
|
3683
|
+
.replace(/^[:*_\-\s]+|[:*_\-\s]+$/g, '')
|
|
3684
|
+
.trim();
|
|
3685
|
+
if (!cleaned || /^[*_\-:.\s]+$/.test(cleaned))
|
|
3686
|
+
return '';
|
|
3687
|
+
return cleaned;
|
|
3688
|
+
}
|
|
3689
|
+
function inferStoryboardStorySpineFromHeading(text) {
|
|
3690
|
+
const lines = stripStoryboardMarkup(text)
|
|
3691
|
+
.split(/\r?\n/)
|
|
3692
|
+
.map(line => line.trim())
|
|
3693
|
+
.filter(Boolean);
|
|
3694
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
3695
|
+
const line = lines[index];
|
|
3696
|
+
const inline = line.match(/^(?:story\s+spine|narrative\s+spine|throughline|story\s+arc|creative\s+intent)\s*:\s*(.+)$/i)?.[1];
|
|
3697
|
+
const cleanedInline = cleanStoryboardStorySpine(inline || '');
|
|
3698
|
+
if (cleanedInline)
|
|
3699
|
+
return cleanedInline;
|
|
3700
|
+
if (!/^(?:story\s+spine|narrative\s+spine|throughline|story\s+arc|creative\s+intent)\s*:?\s*$/i.test(line)) {
|
|
3701
|
+
continue;
|
|
3702
|
+
}
|
|
3703
|
+
const collected = [];
|
|
3704
|
+
for (let cursor = index + 1; cursor < lines.length && collected.length < 4; cursor += 1) {
|
|
3705
|
+
const candidate = lines[cursor];
|
|
3706
|
+
if (/^(?:storyboard|video|production|reference|timecoded|beat\s+\d|scene\s+\d|format|duration|aspect|does this|please confirm)\b/i.test(candidate)) {
|
|
3707
|
+
break;
|
|
3708
|
+
}
|
|
3709
|
+
const cleaned = cleanStoryboardStorySpine(candidate);
|
|
3710
|
+
if (cleaned)
|
|
3711
|
+
collected.push(cleaned);
|
|
3712
|
+
}
|
|
3713
|
+
const combined = cleanStoryboardStorySpine(collected.join(' '));
|
|
3714
|
+
if (combined)
|
|
3715
|
+
return combined;
|
|
3716
|
+
}
|
|
3717
|
+
return '';
|
|
3718
|
+
}
|
|
3719
|
+
function inferStoryboardStorySpine(text, fallbackBrief) {
|
|
3720
|
+
const explicit = cleanStoryboardStorySpine(text.match(/\b(?:story\s+spine|narrative\s+spine|throughline|story\s+arc|creative\s+intent)\s*:\s*([^\n]{1,360})/i)?.[1] || '');
|
|
3721
|
+
if (explicit)
|
|
3722
|
+
return explicit;
|
|
3723
|
+
const fromHeading = inferStoryboardStorySpineFromHeading(text);
|
|
3724
|
+
if (fromHeading)
|
|
3725
|
+
return fromHeading;
|
|
3726
|
+
const compactFallback = sanitizeStoryboardExternalAudioReferences(compactStoryboardLine(fallbackBrief || text));
|
|
3727
|
+
if (compactFallback) {
|
|
3728
|
+
return `One continuous progression from the source brief: ${compactFallback.slice(0, 260)}`;
|
|
3729
|
+
}
|
|
3730
|
+
return 'A coherent sequence where every scene follows from the previous beat and supports the requested final video outcome.';
|
|
3731
|
+
}
|
|
3732
|
+
function inferStoryboardProductFeatureMap(text, scenes) {
|
|
3733
|
+
const explicitLines = text
|
|
3734
|
+
.split(/\r?\n/)
|
|
3735
|
+
.map(line => compactStoryboardLine(stripStoryboardMarkup(line)))
|
|
3736
|
+
.filter(line => /\b(?:product\/feature|product feature|feature mapping|product meaning|capability)\b\s*:/i.test(line))
|
|
3737
|
+
.map(line => line.replace(/^[^:]{1,80}:\s*/, '').trim())
|
|
3738
|
+
.filter(Boolean);
|
|
3739
|
+
const sceneFeatures = scenes
|
|
3740
|
+
.map(scene => scene.productFeature)
|
|
3741
|
+
.filter(Boolean);
|
|
3742
|
+
return [...new Set([...explicitLines, ...sceneFeatures])];
|
|
3743
|
+
}
|
|
3744
|
+
function inferEndCard(projectText, references, requiredText) {
|
|
3745
|
+
const logo = references.find(ref => ref.kind === 'logo');
|
|
3746
|
+
const endBlock = projectText.match(/\b(?:end card|cta|final card|brand resolve|logo reveal)\b[\s\S]{0,420}/i)?.[0] ?? '';
|
|
3747
|
+
return {
|
|
3748
|
+
requiredText,
|
|
3749
|
+
logoUsage: logo
|
|
3750
|
+
? `${logo.id} should be used according to its ${logo.usageScope} usage scope.`
|
|
3751
|
+
: 'Use any approved logo or brand reference only where the source brief assigns it.',
|
|
3752
|
+
backgroundStyle: extractStoryboardField(endBlock, ['Background', 'Background style', 'Visual', 'Style']),
|
|
3753
|
+
composition: extractStoryboardField(endBlock, ['Composition', 'Layout', 'Camera']) || compactStoryboardLine(endBlock.slice(0, 180)),
|
|
3754
|
+
};
|
|
3755
|
+
}
|
|
3756
|
+
export function buildStoryboardProject(options) {
|
|
3757
|
+
const prompt = options.prompt.trim();
|
|
3758
|
+
const rawUserIntentText = options.userIntentText.trim();
|
|
3759
|
+
const userIntentText = canonicalStoryboardScriptContext(rawUserIntentText) || rawUserIntentText;
|
|
3760
|
+
const approvedScriptContext = canonicalStoryboardScriptContext(options.approvedScriptContext);
|
|
3761
|
+
const primarySourceBrief = selectStoryboardSourceBrief(prompt, userIntentText);
|
|
3762
|
+
const sourceText = sanitizeStoryboardExternalAudioReferences([
|
|
3763
|
+
primarySourceBrief,
|
|
3764
|
+
approvedScriptContext
|
|
3765
|
+
? `APPROVED STORYBOARD SCRIPT CONTEXT TO PRESERVE:\n${approvedScriptContext}`
|
|
3766
|
+
: '',
|
|
3767
|
+
].filter(Boolean).join('\n\n'));
|
|
3768
|
+
const allText = `${userIntentText}\n${sourceText}`;
|
|
3769
|
+
const layoutTextParts = [userIntentText].filter(Boolean);
|
|
3770
|
+
if (primarySourceBrief
|
|
3771
|
+
&& !storyboardBriefContains(userIntentText, primarySourceBrief)
|
|
3772
|
+
&& !storyboardBriefContains(primarySourceBrief, userIntentText)) {
|
|
3773
|
+
layoutTextParts.push(primarySourceBrief);
|
|
3774
|
+
}
|
|
3775
|
+
if (approvedScriptContext
|
|
3776
|
+
&& !storyboardBriefContains(layoutTextParts.join('\n'), approvedScriptContext)) {
|
|
3777
|
+
layoutTextParts.push(approvedScriptContext);
|
|
3778
|
+
}
|
|
3779
|
+
const layoutText = layoutTextParts.join('\n\n') || sourceText;
|
|
3780
|
+
const layout = inferStoryboardLayoutSpec(layoutText, options.frameCount, options.planningContract);
|
|
3781
|
+
const requestedDurationSec = inferRequestedTotalVideoDurationSeconds(userIntentText);
|
|
3782
|
+
const durationSec = requestedDurationSec ?? inferRequestedTotalVideoDurationSeconds(sourceText);
|
|
3783
|
+
const references = buildStoryboardReferenceAssets(userIntentText, [
|
|
3784
|
+
prompt,
|
|
3785
|
+
approvedScriptContext,
|
|
3786
|
+
].filter(Boolean).join('\n\n'));
|
|
3787
|
+
const approvedSections = approvedScriptContext
|
|
3788
|
+
? splitStoryboardSections(approvedScriptContext)
|
|
3789
|
+
: [];
|
|
3790
|
+
const sourceSections = splitStoryboardSections(sourceText);
|
|
3791
|
+
const approvedSectionsHaveExplicitTiming = storyboardSectionsHavePreservableExplicitTiming(approvedSections);
|
|
3792
|
+
const sourceSectionsHaveExplicitTiming = storyboardSectionsHavePreservableExplicitTiming(sourceSections);
|
|
3793
|
+
const assistantApprovedDraftUndercounted = options.promptAuthorship === 'assistant'
|
|
3794
|
+
&& approvedSections.length > 0
|
|
3795
|
+
&& approvedSections.length < options.frameCount
|
|
3796
|
+
&& !approvedSectionsHaveExplicitTiming;
|
|
3797
|
+
const assistantDraftUndercounted = options.promptAuthorship === 'assistant'
|
|
3798
|
+
&& !approvedScriptContext
|
|
3799
|
+
&& sourceSections.length > 0
|
|
3800
|
+
&& sourceSections.length < options.frameCount
|
|
3801
|
+
&& !sourceSectionsHaveExplicitTiming;
|
|
3802
|
+
const sections = approvedSections.length > 0 && !assistantApprovedDraftUndercounted
|
|
3803
|
+
? approvedSections
|
|
3804
|
+
: assistantDraftUndercounted || assistantApprovedDraftUndercounted
|
|
3805
|
+
? []
|
|
3806
|
+
: sourceSections;
|
|
3807
|
+
const sceneCountForTiming = sections.length > 0 ? sections.length : options.frameCount;
|
|
3808
|
+
const equalDuration = durationSec && sceneCountForTiming > 0
|
|
3809
|
+
? Math.round((durationSec / sceneCountForTiming) * 100) / 100
|
|
3810
|
+
: null;
|
|
3811
|
+
const scenes = sections.length > 0
|
|
3812
|
+
? sections.map((section, index) => {
|
|
3813
|
+
const fallbackTiming = equalDuration !== null
|
|
3814
|
+
? {
|
|
3815
|
+
startSec: Math.round(index * equalDuration * 100) / 100,
|
|
3816
|
+
endSec: index === sceneCountForTiming - 1 && durationSec !== null
|
|
3817
|
+
? durationSec
|
|
3818
|
+
: Math.round((index + 1) * equalDuration * 100) / 100,
|
|
3819
|
+
durationSec: index === sceneCountForTiming - 1 && durationSec !== null
|
|
3820
|
+
? Math.round((durationSec - index * equalDuration) * 100) / 100
|
|
3821
|
+
: equalDuration,
|
|
3822
|
+
}
|
|
3823
|
+
: null;
|
|
3824
|
+
return buildSceneFromSection(section, references, fallbackTiming, storyboardScenePlanningContractForIndex(options.planningContract, section.number));
|
|
3825
|
+
})
|
|
3826
|
+
: buildFallbackScenes(options.frameCount, durationSec, sourceText)
|
|
3827
|
+
.map((scene, index) => applyStoryboardScenePlanningContract(scene, storyboardScenePlanningContractForIndex(options.planningContract, index + 1)));
|
|
3828
|
+
const timingNormalizedScenes = normalizeAssistantStoryboardSceneTiming(scenes, durationSec, options.promptAuthorship);
|
|
3829
|
+
const dialogueAlignment = alignAssistantStoryboardDialogueWithUserSource(timingNormalizedScenes, userIntentText, options.promptAuthorship);
|
|
3830
|
+
const normalizedScenes = dialogueAlignment.shouldRetime
|
|
3831
|
+
? retimeStoryboardScenesForDialogue(dialogueAlignment.scenes, durationSec)
|
|
3832
|
+
: dialogueAlignment.scenes;
|
|
3833
|
+
const userConstraintSource = buildStoryboardUserConstraintSource(userIntentText, primarySourceBrief, options);
|
|
3834
|
+
const requiredText = uniqueStoryboardStrings([
|
|
3835
|
+
...extractStoryboardRequiredText(userConstraintSource),
|
|
3836
|
+
...normalizeStoryboardContractTextArray(options.planningContract?.endCard?.visibleText),
|
|
3837
|
+
]);
|
|
3838
|
+
const voiceLines = assignVoiceLinesToScenes(normalizedScenes, sourceText);
|
|
3839
|
+
const storySpineFallback = approvedScriptContext
|
|
3840
|
+
|| (options.promptAuthorship === 'assistant' ? userIntentText : primarySourceBrief)
|
|
3841
|
+
|| prompt
|
|
3842
|
+
|| userIntentText;
|
|
3843
|
+
const storySpine = inferStoryboardStorySpine(allText, storySpineFallback);
|
|
3844
|
+
const productFeatureMap = inferStoryboardProductFeatureMap(allText, scenes);
|
|
3845
|
+
return {
|
|
3846
|
+
title: inferStoryboardTitle(allText),
|
|
3847
|
+
durationSec,
|
|
3848
|
+
outputAspectRatio: layout.boardAspectRatio,
|
|
3849
|
+
frameAspectRatio: layout.cellAspectRatio,
|
|
3850
|
+
targetVideoAspectRatio: layout.targetVideoAspectRatio,
|
|
3851
|
+
...(layout.boardDimensions ? { boardDimensions: layout.boardDimensions } : {}),
|
|
3852
|
+
boardLayout: layout.layoutKind,
|
|
3853
|
+
layoutSource: storyboardPlanningSourceFromContract(options.planningContract),
|
|
3854
|
+
...(options.planningContract ? { planningContract: options.planningContract } : {}),
|
|
3855
|
+
metadataLabels: metadataLabelsFromPlanningContract(options.planningContract),
|
|
3856
|
+
intendedUse: /commercial|ad|promo|launch/i.test(allText) ? 'commercial storyboard' : 'video storyboard',
|
|
3857
|
+
references,
|
|
3858
|
+
creativeBrief: {
|
|
3859
|
+
concept: compactStoryboardLine(primarySourceBrief || prompt || userIntentText),
|
|
3860
|
+
storySpine,
|
|
3861
|
+
toneProgression: inferStoryboardToneProgression(allText),
|
|
3862
|
+
productFeatureMap,
|
|
3863
|
+
mustInclude: requiredText,
|
|
3864
|
+
mustAvoid: extractStoryboardAvoidConstraints(userConstraintSource),
|
|
3865
|
+
brandRules: requiredText.length > 0 ? requiredText.map(text => `Preserve exact visible text: "${text}"`) : [],
|
|
3866
|
+
visualQualityBar: /production[-\s]?ready|premium|commercial|cinematic|high[-\s]?end/i.test(allText)
|
|
3867
|
+
? 'production-ready commercial storyboard sheet'
|
|
3868
|
+
: 'clean readable storyboard sheet',
|
|
3869
|
+
},
|
|
3870
|
+
voiceover: {
|
|
3871
|
+
fullScript: voiceLines.map(line => line.text).join('\n'),
|
|
3872
|
+
lines: voiceLines,
|
|
3873
|
+
},
|
|
3874
|
+
scenes: normalizedScenes,
|
|
3875
|
+
endCard: inferEndCard(allText, references, requiredText),
|
|
3876
|
+
};
|
|
3877
|
+
}
|
|
3878
|
+
export function validateStoryboardProjectTiming(project, rules = DEFAULT_STORYBOARD_TIMING_RULES) {
|
|
3879
|
+
const issues = [];
|
|
3880
|
+
const timedScenes = project.scenes
|
|
3881
|
+
.filter(scene => scene.startSec !== null && scene.endSec !== null && scene.durationSec !== null)
|
|
3882
|
+
.slice()
|
|
3883
|
+
.sort((a, b) => (a.startSec ?? 0) - (b.startSec ?? 0));
|
|
3884
|
+
if (project.durationSec === null) {
|
|
3885
|
+
issues.push({
|
|
3886
|
+
severity: 'warning',
|
|
3887
|
+
code: 'missing_target_duration',
|
|
3888
|
+
message: 'No target video duration was detected; timing checks are limited to scene-local ranges.',
|
|
3889
|
+
repair: 'Add an explicit total duration such as 15 seconds when this storyboard is meant to drive a video.',
|
|
3890
|
+
});
|
|
3891
|
+
}
|
|
3892
|
+
if (timedScenes.length < project.scenes.length) {
|
|
3893
|
+
issues.push({
|
|
3894
|
+
severity: 'warning',
|
|
3895
|
+
code: 'missing_scene_timing',
|
|
3896
|
+
message: `${project.scenes.length - timedScenes.length} scene(s) do not have explicit start/end/duration timing.`,
|
|
3897
|
+
repair: 'Add start and end seconds for every storyboard scene before video generation.',
|
|
3898
|
+
});
|
|
3899
|
+
}
|
|
3900
|
+
for (const scene of timedScenes) {
|
|
3901
|
+
const start = scene.startSec ?? 0;
|
|
3902
|
+
const end = scene.endSec ?? 0;
|
|
3903
|
+
const duration = scene.durationSec ?? 0;
|
|
3904
|
+
if (end <= start || duration <= 0) {
|
|
3905
|
+
issues.push({
|
|
3906
|
+
severity: 'error',
|
|
3907
|
+
code: 'invalid_scene_range',
|
|
3908
|
+
sceneId: scene.id,
|
|
3909
|
+
message: `${scene.id} has an invalid timing range.`,
|
|
3910
|
+
repair: 'Set startSec < endSec and durationSec to the range length.',
|
|
3911
|
+
});
|
|
3912
|
+
continue;
|
|
3913
|
+
}
|
|
3914
|
+
const dialogueWords = countWords(scene.dialogue);
|
|
3915
|
+
if (dialogueWords > 0) {
|
|
3916
|
+
const wordsPerSecond = dialogueWords / duration;
|
|
3917
|
+
if (wordsPerSecond > rules.fastWordsPerSecondMax) {
|
|
3918
|
+
issues.push({
|
|
3919
|
+
severity: 'warning',
|
|
3920
|
+
code: 'dialogue_too_dense',
|
|
3921
|
+
sceneId: scene.id,
|
|
3922
|
+
message: `${scene.id} has about ${dialogueWords} spoken words in ${duration}s (${wordsPerSecond.toFixed(1)} words/sec).`,
|
|
3923
|
+
repair: 'Increase this scene duration, reallocate time from non-dialogue beats, or split the dialogue across adjacent scenes before changing any supplied words.',
|
|
3924
|
+
});
|
|
3925
|
+
}
|
|
3926
|
+
else if (wordsPerSecond > rules.normalWordsPerSecondMax) {
|
|
3927
|
+
issues.push({
|
|
3928
|
+
severity: 'warning',
|
|
3929
|
+
code: 'dialogue_fast',
|
|
3930
|
+
sceneId: scene.id,
|
|
3931
|
+
message: `${scene.id} dialogue is fast at ${wordsPerSecond.toFixed(1)} words/sec.`,
|
|
3932
|
+
repair: 'Prefer more time or a split across adjacent scenes for cleaner delivery; only shorten dialogue that was not supplied by the user.',
|
|
3933
|
+
});
|
|
3934
|
+
}
|
|
3935
|
+
}
|
|
3936
|
+
const isPunchline = dialogueWords > 0
|
|
3937
|
+
&& dialogueWords <= 3
|
|
3938
|
+
&& /\b(?:punchline|reveal|joke|twist|tag)\b/i.test(`${scene.title}\n${scene.dialogue}`);
|
|
3939
|
+
if (isPunchline && duration < rules.minPunchlineSec) {
|
|
3940
|
+
issues.push({
|
|
3941
|
+
severity: 'warning',
|
|
3942
|
+
code: 'punchline_hold_too_short',
|
|
3943
|
+
sceneId: scene.id,
|
|
3944
|
+
message: `${scene.id} punchline/reveal hold is only ${duration}s.`,
|
|
3945
|
+
repair: `Hold punchline/reveal scenes for at least ${rules.minPunchlineSec}s.`,
|
|
3946
|
+
});
|
|
3947
|
+
}
|
|
3948
|
+
const isEndCard = /\b(?:end card|cta|final|logo|brand resolve)\b/i.test(`${scene.title}\n${scene.visual}`);
|
|
3949
|
+
if (isEndCard && duration < rules.minEndCardHoldSec) {
|
|
3950
|
+
issues.push({
|
|
3951
|
+
severity: 'warning',
|
|
3952
|
+
code: 'end_card_hold_too_short',
|
|
3953
|
+
sceneId: scene.id,
|
|
3954
|
+
message: `${scene.id} end-card hold is only ${duration}s.`,
|
|
3955
|
+
repair: `Hold the CTA/end card for at least ${rules.minEndCardHoldSec}s when the user requested brand text or logo readability.`,
|
|
3956
|
+
});
|
|
3957
|
+
}
|
|
3958
|
+
}
|
|
3959
|
+
for (let index = 1; index < timedScenes.length; index += 1) {
|
|
3960
|
+
const previous = timedScenes[index - 1];
|
|
3961
|
+
const current = timedScenes[index];
|
|
3962
|
+
const previousEnd = previous.endSec ?? 0;
|
|
3963
|
+
const currentStart = current.startSec ?? 0;
|
|
3964
|
+
const delta = Math.round((currentStart - previousEnd) * 100) / 100;
|
|
3965
|
+
if (delta < -rules.toleranceSec) {
|
|
3966
|
+
issues.push({
|
|
3967
|
+
severity: 'error',
|
|
3968
|
+
code: 'overlapping_scene_ranges',
|
|
3969
|
+
sceneId: current.id,
|
|
3970
|
+
message: `${current.id} overlaps the previous scene by ${Math.abs(delta).toFixed(2)}s.`,
|
|
3971
|
+
repair: 'Adjust scene start/end ranges so they do not overlap.',
|
|
3972
|
+
});
|
|
3973
|
+
}
|
|
3974
|
+
else if (delta > rules.toleranceSec) {
|
|
3975
|
+
issues.push({
|
|
3976
|
+
severity: 'warning',
|
|
3977
|
+
code: 'scene_timing_gap',
|
|
3978
|
+
sceneId: current.id,
|
|
3979
|
+
message: `${current.id} starts ${delta.toFixed(2)}s after the previous scene ends.`,
|
|
3980
|
+
repair: 'Remove unintentional gaps or mark them as intentional holds.',
|
|
3981
|
+
});
|
|
3982
|
+
}
|
|
3983
|
+
}
|
|
3984
|
+
const totalSceneDurationSec = timedScenes.length > 0
|
|
3985
|
+
? Math.round(timedScenes.reduce((sum, scene) => sum + (scene.durationSec ?? 0), 0) * 100) / 100
|
|
3986
|
+
: null;
|
|
3987
|
+
if (project.durationSec !== null && totalSceneDurationSec !== null) {
|
|
3988
|
+
const diff = Math.abs(totalSceneDurationSec - project.durationSec);
|
|
3989
|
+
if (diff > rules.toleranceSec) {
|
|
3990
|
+
issues.push({
|
|
3991
|
+
severity: 'error',
|
|
3992
|
+
code: 'scene_total_duration_mismatch',
|
|
3993
|
+
message: `Timed scenes add up to ${totalSceneDurationSec}s but the target duration is ${project.durationSec}s.`,
|
|
3994
|
+
repair: 'Repair scene durations so their total matches the requested video duration.',
|
|
3995
|
+
});
|
|
3996
|
+
}
|
|
3997
|
+
}
|
|
3998
|
+
return {
|
|
3999
|
+
ok: issues.every(issue => issue.severity !== 'error'),
|
|
4000
|
+
issues,
|
|
4001
|
+
totalSceneDurationSec,
|
|
4002
|
+
timedSceneCount: timedScenes.length,
|
|
4003
|
+
};
|
|
4004
|
+
}
|
|
4005
|
+
function compileStoryboardCriticalRequirements() {
|
|
4006
|
+
return [
|
|
4007
|
+
'This must read as production logic, not a concept collage or mood board. Every panel needs a narrative job and a clear reason to exist in sequence.',
|
|
4008
|
+
'Preserve user-provided jokes, slogans, dialogue, brand copy, timings, and scene order unless the source brief explicitly asks for a rewrite.',
|
|
4009
|
+
'Preserve the story spine across the full board. Each scene should visibly cause, motivate, reveal, or set up the next scene.',
|
|
4010
|
+
'When a product, feature, brand, capability, or CTA is part of the brief, keep its scene-level purpose legible in the non-frame notes without inventing unsupported product claims.',
|
|
4011
|
+
'Use explicit transition logic between adjacent beats: object motion, light/color handoff, match cut, camera move, wipe, reaction, or another concrete edit idea.',
|
|
4012
|
+
'Use shaped pacing when timings are flexible: fast hook, escalating middle, readable reveal, and a final CTA/end card held long enough for critical text/logo recognition.',
|
|
4013
|
+
'Divide each scene cell into a clean video-frame artwork area plus a clearly associated note/header/footer area. Put Time, scene/frame numbers, Visual/Action, Camera/Motion, Lighting/Style, Dialogue/VO, and Audio/SFX outside the video frame artwork, never overlaid on top of the cinematic frame.',
|
|
4014
|
+
'Every cinematic video-frame artwork area must preserve the requested Individual scene-cell/frame aspect ratio. Keep all frame artwork areas locked to the same W:H unless the source explicitly requests mixed ratios; do not make individual stills square or let one or two cells drift to a different crop.',
|
|
4015
|
+
'Use concise readable storyboard labels in the non-frame note areas. Do not place long paragraphs of production notes inside every cell.',
|
|
4016
|
+
'Every scene cell must include compact fields for Time, Visual/Action, Camera/Motion, Lighting/Style, Dialogue/VO, and Audio/SFX, attached to the correct frame but outside the video-frame artwork.',
|
|
4017
|
+
'When a scene has no spoken dialogue or voiceover, write exactly [no dialogue] in the Dialogue/VO field; never use "-", None, N/A, or a blank field.',
|
|
4018
|
+
'Keep generated SFX/action words such as whoosh, boom, impact, thud, slash, crack, pop, and similar motion callouts in Visual/Action or Audio/SFX note areas only. Do not render them inside the video-frame artwork unless the source explicitly requires that exact word as visible on-screen text.',
|
|
4019
|
+
'When audio is not explicitly supplied, propose scene-appropriate generated audio, ambience, music bed, and foley/SFX generically. Do not label a scene silent, muted, or "no audio" unless the source brief explicitly requests silence.',
|
|
4020
|
+
'Do not reference unattached songs, trending tracks, stock sound libraries, or external media. Treat Audio/SFX/Music notes as instructions for the downstream video model to generate the sound unless an audio reference asset is explicitly listed.',
|
|
4021
|
+
'Give the audio direction an arc when the final asset is video: build, peak, resolve, transition accents, and final logo/CTA hit.',
|
|
4022
|
+
'Keep character, product, logo, and style references bound to their assigned scenes. Do not replace referenced assets with invented substitutes.',
|
|
4023
|
+
];
|
|
4024
|
+
}
|
|
4025
|
+
function compileStoryboardAvoidSection(userIntentText) {
|
|
4026
|
+
const avoidLines = [
|
|
4027
|
+
'Avoid malformed text, misspelled brand words, inconsistent reference identities, missing scene cells, wrong timings, and mismatched board/cell aspect ratios.',
|
|
4028
|
+
'Avoid scene numbers, timing badges, timecodes, production tables, Dialogue/VO labels, Audio/SFX labels, or other production notes overlaid inside the video frame artwork.',
|
|
4029
|
+
'Avoid in-frame comic-book SFX/action text such as Whoosh!, Impact!, Boom!, Thud!, Slash!, Crack!, or Pop! unless the source explicitly marks that word as required visible text.',
|
|
4030
|
+
];
|
|
4031
|
+
for (const constraint of extractStoryboardAvoidConstraints(userIntentText)) {
|
|
4032
|
+
avoidLines.push(`Preserve this user avoid-list constraint: ${constraint}`);
|
|
4033
|
+
}
|
|
4034
|
+
return avoidLines;
|
|
4035
|
+
}
|
|
4036
|
+
function formatStoryboardSeconds(value) {
|
|
4037
|
+
return value === null ? 'unspecified' : `${Number(value.toFixed(2))}s`;
|
|
4038
|
+
}
|
|
4039
|
+
function defaultStoryboardAudioSfxLine() {
|
|
4040
|
+
return 'Scene-appropriate generated audio bed, ambience, and foley/SFX matching the action; do not mark silent/no audio unless the source brief explicitly requests silence.';
|
|
4041
|
+
}
|
|
4042
|
+
function compileStoryboardStoryContinuitySection(project) {
|
|
4043
|
+
return [
|
|
4044
|
+
'STORY / CONTINUITY:',
|
|
4045
|
+
`Story spine: ${project.creativeBrief.storySpine}`,
|
|
4046
|
+
'Every panel must feel like the next beat in one continuous sequence, not an unrelated feature card, contact sheet, or mood board.',
|
|
4047
|
+
'Use visible cause-and-effect between beats through action, object motion, eyeline, lighting/color handoff, match cut, wipe, camera movement, or another concrete transition idea.',
|
|
4048
|
+
'Keep product/feature/brand meaning in the associated notes. Do not force long product explanations or the whole script into tiny in-frame text.',
|
|
4049
|
+
...(project.creativeBrief.productFeatureMap.length > 0
|
|
4050
|
+
? [
|
|
4051
|
+
'Product/feature mapping to preserve:',
|
|
4052
|
+
...project.creativeBrief.productFeatureMap.map(item => `- ${item}`),
|
|
4053
|
+
]
|
|
4054
|
+
: []),
|
|
4055
|
+
];
|
|
4056
|
+
}
|
|
4057
|
+
function compileStoryboardCountContractSection(project, layout) {
|
|
4058
|
+
if (project.scenes.length === 0)
|
|
4059
|
+
return [];
|
|
4060
|
+
const sceneSlots = project.scenes
|
|
4061
|
+
.map((scene, index) => `[${index + 1}] ${scene.id.toUpperCase()}`)
|
|
4062
|
+
.join(', ');
|
|
4063
|
+
return [
|
|
4064
|
+
'COUNT / GRID CONTRACT:',
|
|
4065
|
+
`Required scene count: exactly ${project.scenes.length} numbered storyboard scene slots; do not render fewer slots and do not add extra scene slots.`,
|
|
4066
|
+
`Allocate the full ${project.scenes.length}-slot storyboard grid before drawing details. Fill slots in reading order, left-to-right then top-to-bottom: ${sceneSlots}.`,
|
|
4067
|
+
`Each allocated scene slot gets one distinct ${layout.cellAspectRatio} cinematic video-frame rectangle plus its own compact notes outside that rectangle. Do not merge adjacent scenes, combine two beats into one slot, duplicate slots, or place thumbnail/inset panels inside a slot.`,
|
|
4068
|
+
`Use the layout preset exactly: ${layout.layoutKind} - ${layout.layoutDescription}. The final sheet should be visibly countable as ${project.scenes.length} numbered scene slots at a glance.`,
|
|
4069
|
+
];
|
|
4070
|
+
}
|
|
4071
|
+
function compileStoryboardFrameGeometrySection(layout) {
|
|
4072
|
+
const cellOrientation = parseAspectRatioOrientation(layout.cellAspectRatio);
|
|
4073
|
+
if (cellOrientation === 'portrait') {
|
|
4074
|
+
return [
|
|
4075
|
+
'PORTRAIT FRAME GEOMETRY:',
|
|
4076
|
+
'Treat each numbered scene slot as a container, not as the artwork shape.',
|
|
4077
|
+
`Inside every numbered scene slot, draw one identical upright ${layout.cellAspectRatio} video-frame rectangle whose height is visibly greater than its width.`,
|
|
4078
|
+
`Keep all scene numbers, timing, titles, notes, headers, footers, and production labels outside the ${layout.cellAspectRatio} video-frame rectangles.`,
|
|
4079
|
+
`Do not make the numbered scene slots, frame artwork areas, or thumbnails square. Square cells violate the requested ${layout.cellAspectRatio} final video format.`,
|
|
4080
|
+
'Unused grid slots must remain blank margin/notes space only; do not stretch or square off the portrait frame rectangles to fill the grid.',
|
|
4081
|
+
];
|
|
4082
|
+
}
|
|
4083
|
+
if (cellOrientation === 'landscape') {
|
|
4084
|
+
return [
|
|
4085
|
+
'LANDSCAPE FRAME GEOMETRY:',
|
|
4086
|
+
'Treat each numbered scene slot as a container, not as the artwork shape.',
|
|
4087
|
+
`Inside every numbered scene slot, draw one identical wide ${layout.cellAspectRatio} video-frame rectangle whose width is visibly greater than its height.`,
|
|
4088
|
+
`Keep all scene numbers, timing, titles, notes, headers, footers, and production labels outside the ${layout.cellAspectRatio} video-frame rectangles.`,
|
|
4089
|
+
`Do not make the numbered scene slots, frame artwork areas, or thumbnails square. Square cells violate the requested ${layout.cellAspectRatio} final video format.`,
|
|
4090
|
+
'Unused grid slots must remain blank margin/notes space only; do not stretch or square off the landscape frame rectangles to fill the grid.',
|
|
4091
|
+
];
|
|
4092
|
+
}
|
|
4093
|
+
if (cellOrientation === 'square') {
|
|
4094
|
+
return [
|
|
4095
|
+
'SQUARE FRAME GEOMETRY:',
|
|
4096
|
+
'Treat each numbered scene slot as a container, not as the artwork shape.',
|
|
4097
|
+
`Inside every numbered scene slot, draw one identical square ${layout.cellAspectRatio} video-frame area plus compact notes outside that square.`,
|
|
4098
|
+
`Do not stretch square frame artwork areas into portrait, landscape, full-board, or any aspect ratio other than ${layout.cellAspectRatio}.`,
|
|
4099
|
+
];
|
|
4100
|
+
}
|
|
4101
|
+
return [
|
|
4102
|
+
'FRAME GEOMETRY:',
|
|
4103
|
+
`Inside every numbered scene slot, draw one identical ${layout.cellAspectRatio} video-frame rectangle plus compact notes outside that rectangle.`,
|
|
4104
|
+
`Do not let frame artwork areas drift to square, full-board, or any aspect ratio other than ${layout.cellAspectRatio}.`,
|
|
4105
|
+
];
|
|
4106
|
+
}
|
|
4107
|
+
function promptReferenceUsageForScene(referenceUsage, references, modelId) {
|
|
4108
|
+
if (referenceUsage.length === 0)
|
|
4109
|
+
return '';
|
|
4110
|
+
const referenceById = new Map(references.map((ref, index) => [ref.id, { ref, index }]));
|
|
4111
|
+
return uniqueStoryboardStrings(referenceUsage.map(value => {
|
|
4112
|
+
const found = referenceById.get(value);
|
|
4113
|
+
if (!found)
|
|
4114
|
+
return value;
|
|
4115
|
+
return formatModelRef(modelId, found.ref.index ?? found.index + 1, 'image');
|
|
4116
|
+
})).join(', ');
|
|
4117
|
+
}
|
|
4118
|
+
function storyboardMetadataLabelAliases(labels) {
|
|
4119
|
+
return uniqueStoryboardStrings(labels.flatMap(label => [
|
|
4120
|
+
label,
|
|
4121
|
+
removeStoryboardTimingText(label),
|
|
4122
|
+
]));
|
|
4123
|
+
}
|
|
4124
|
+
function removeStoryboardMetadataLabelsFromPromptText(value, metadataLabels) {
|
|
4125
|
+
if (metadataLabels.length === 0)
|
|
4126
|
+
return compactStoryboardLine(value);
|
|
4127
|
+
let cleaned = value
|
|
4128
|
+
.replace(/\b(?:visible\s+text|metadata\s+labels?)\s*:\s*/gi, '')
|
|
4129
|
+
.replace(/\btext\s+overlay\s*:\s*/gi, '');
|
|
4130
|
+
const aliases = storyboardMetadataLabelAliases(metadataLabels)
|
|
4131
|
+
.filter(Boolean)
|
|
4132
|
+
.sort((a, b) => b.length - a.length);
|
|
4133
|
+
for (const label of aliases) {
|
|
4134
|
+
cleaned = cleaned.replace(new RegExp(escapeStoryboardRegExp(label), 'gi'), '');
|
|
4135
|
+
}
|
|
4136
|
+
return compactStoryboardLine(cleaned)
|
|
4137
|
+
.replace(/\s+([,.;:])/g, '$1')
|
|
4138
|
+
.replace(/^[:;,\s]+|[:;,\s]+$/g, '')
|
|
4139
|
+
.trim();
|
|
4140
|
+
}
|
|
4141
|
+
function compileStoryboardScenesSection(project) {
|
|
4142
|
+
if (project.scenes.length === 0)
|
|
4143
|
+
return [];
|
|
4144
|
+
const lines = ['SCENES:'];
|
|
4145
|
+
for (const scene of project.scenes) {
|
|
4146
|
+
const timing = scene.startSec !== null && scene.endSec !== null
|
|
4147
|
+
? `${formatStoryboardSeconds(scene.startSec)}-${formatStoryboardSeconds(scene.endSec)}`
|
|
4148
|
+
: 'timing unspecified';
|
|
4149
|
+
lines.push(`${scene.id.toUpperCase()} - ${scene.title} - ${timing}`);
|
|
4150
|
+
if (scene.purpose)
|
|
4151
|
+
lines.push(`Scene purpose: ${scene.purpose}`);
|
|
4152
|
+
if (scene.productFeature)
|
|
4153
|
+
lines.push(`Product/feature mapping: ${scene.productFeature}`);
|
|
4154
|
+
lines.push(`Visual/Action: ${[scene.visual, scene.action].filter(Boolean).join(' ') || 'Follow the approved storyboard beat.'}`);
|
|
4155
|
+
lines.push(`Camera/Motion: ${scene.camera || 'Use the approved storyboard framing and motion notes.'}`);
|
|
4156
|
+
lines.push(`Lighting/Style: ${scene.lighting || 'Use the approved storyboard lighting and style notes.'}`);
|
|
4157
|
+
if (scene.transitionIn || scene.transitionOut) {
|
|
4158
|
+
lines.push(`Transition: ${[scene.transitionIn, scene.transitionOut].filter(Boolean).join(' / ')}`);
|
|
4159
|
+
}
|
|
4160
|
+
lines.push(`Dialogue/VO: ${scene.dialogue || '[no dialogue]'}`);
|
|
4161
|
+
lines.push(`Audio/SFX: ${scene.audioSfx.length > 0 ? scene.audioSfx.join(', ') : defaultStoryboardAudioSfxLine()}`);
|
|
4162
|
+
if (scene.music)
|
|
4163
|
+
lines.push(`Music: ${scene.music}`);
|
|
4164
|
+
const referenceUsage = promptReferenceUsageForScene(scene.referenceUsage, project.references, 'gpt-image-2');
|
|
4165
|
+
if (referenceUsage)
|
|
4166
|
+
lines.push(`Reference usage: ${referenceUsage}`);
|
|
4167
|
+
if ((scene.metadataLabels ?? []).length > 0) {
|
|
4168
|
+
lines.push(`Metadata labels (outside frame only): ${(scene.metadataLabels ?? []).join('; ')}`);
|
|
4169
|
+
}
|
|
4170
|
+
if (scene.textInImage.length > 0)
|
|
4171
|
+
lines.push(`Visible text: ${scene.textInImage.map(text => `"${text}"`).join(', ')}`);
|
|
4172
|
+
if (scene.mustAvoid.length > 0)
|
|
4173
|
+
lines.push(`Avoid: ${scene.mustAvoid.join('; ')}`);
|
|
4174
|
+
lines.push('');
|
|
4175
|
+
}
|
|
4176
|
+
return lines;
|
|
4177
|
+
}
|
|
4178
|
+
function storyboardLayoutSpecFromProject(project, frameCount) {
|
|
4179
|
+
const layout = describeStoryboardLayout(project.outputAspectRatio, project.frameAspectRatio, frameCount);
|
|
4180
|
+
return {
|
|
4181
|
+
boardAspectRatio: project.outputAspectRatio,
|
|
4182
|
+
cellAspectRatio: project.frameAspectRatio,
|
|
4183
|
+
targetVideoAspectRatio: project.targetVideoAspectRatio,
|
|
4184
|
+
...layout,
|
|
4185
|
+
...(project.boardDimensions ? { boardDimensions: project.boardDimensions } : {}),
|
|
4186
|
+
};
|
|
4187
|
+
}
|
|
4188
|
+
function compileStoryboardTimingValidationSection(project) {
|
|
4189
|
+
const validation = validateStoryboardProjectTiming(project);
|
|
4190
|
+
const errors = validation.issues.filter(issue => issue.severity === 'error');
|
|
4191
|
+
if (errors.length === 0)
|
|
4192
|
+
return [];
|
|
4193
|
+
return [
|
|
4194
|
+
'TIMING VALIDATION:',
|
|
4195
|
+
...errors.map(issue => {
|
|
4196
|
+
const prefix = 'ERROR';
|
|
4197
|
+
const repair = issue.repair ? ` Repair: ${issue.repair}` : '';
|
|
4198
|
+
return `${prefix} ${issue.code}: ${issue.message}${repair}`;
|
|
4199
|
+
}),
|
|
4200
|
+
'Resolve these ERROR items before treating this as video-model-ready. Do not add validation, warning, or repair-status text inside any storyboard frame.',
|
|
4201
|
+
];
|
|
4202
|
+
}
|
|
4203
|
+
export function compileVideoStoryboardImagePrompt(options) {
|
|
4204
|
+
const rawUserIntentText = options.userIntentText.trim();
|
|
4205
|
+
const userIntentText = canonicalStoryboardScriptContext(rawUserIntentText) || rawUserIntentText;
|
|
4206
|
+
const project = buildStoryboardProject(options);
|
|
4207
|
+
const compiledFrameCount = project.scenes.length || options.frameCount;
|
|
4208
|
+
const layout = storyboardLayoutSpecFromProject(project, compiledFrameCount);
|
|
4209
|
+
const sourceBrief = sanitizeStoryboardExternalAudioReferences(buildStoryboardSourceBriefForPrompt(options.prompt.trim(), userIntentText, options.approvedScriptContext, options.promptAuthorship));
|
|
4210
|
+
const selectedBrief = selectStoryboardSourceBrief(options.prompt.trim(), userIntentText);
|
|
4211
|
+
const avoidSource = buildStoryboardUserConstraintSource(userIntentText, selectedBrief, options);
|
|
4212
|
+
const boardSizeLine = layout.boardDimensions
|
|
4213
|
+
? `Overall storyboard canvas: ${layout.boardDimensions} pixels (${layout.boardAspectRatio}).`
|
|
4214
|
+
: `Overall storyboard canvas aspect ratio: ${layout.boardAspectRatio}.`;
|
|
4215
|
+
return [
|
|
4216
|
+
'CREATE:',
|
|
4217
|
+
`Create exactly ${compiledFrameCount} sequential video storyboard frames as one composite storyboard image.`,
|
|
4218
|
+
`Project title: ${project.title}.`,
|
|
4219
|
+
project.durationSec !== null ? `Target duration: ${project.durationSec} seconds.` : 'Target duration: unspecified in source brief.',
|
|
4220
|
+
'',
|
|
4221
|
+
...compileStoryboardCountContractSection(project, layout),
|
|
4222
|
+
'',
|
|
4223
|
+
...compileStoryboardReferenceSection(project),
|
|
4224
|
+
'',
|
|
4225
|
+
'CANVAS / LAYOUT:',
|
|
4226
|
+
boardSizeLine,
|
|
4227
|
+
`Individual scene-cell/frame aspect ratio: ${layout.cellAspectRatio}.`,
|
|
4228
|
+
`Target final video aspect ratio: ${layout.targetVideoAspectRatio}.`,
|
|
4229
|
+
`Layout preset: ${layout.layoutKind} - ${layout.layoutDescription}.`,
|
|
4230
|
+
`Every cinematic frame artwork area inside every scene cell must preserve ${layout.cellAspectRatio}; do not let any individual frame drift to square, full-board, or a different portrait/landscape ratio.`,
|
|
4231
|
+
'Keep margins, gutters, outside-frame numbering, note strips, and typography consistent across the full board.',
|
|
4232
|
+
'Each scene cell must make the frame-to-notes relationship clear while keeping production labels outside the actual video-frame artwork.',
|
|
4233
|
+
'',
|
|
4234
|
+
...compileStoryboardFrameGeometrySection(layout),
|
|
4235
|
+
'',
|
|
4236
|
+
...compileStoryboardStoryContinuitySection(project),
|
|
4237
|
+
'',
|
|
4238
|
+
'GLOBAL STYLE:',
|
|
4239
|
+
`${project.creativeBrief.visualQualityBar} with cinematic shot language, coherent art direction, readable labels, and consistent reference usage.`,
|
|
4240
|
+
'',
|
|
4241
|
+
'CRITICAL REQUIREMENTS:',
|
|
4242
|
+
...compileStoryboardCriticalRequirements().map((item, index) => `${index + 1}. ${item}`),
|
|
4243
|
+
...project.references
|
|
4244
|
+
.filter(ref => ref.preservePriority === 'critical')
|
|
4245
|
+
.map((ref, index) => `${compileStoryboardCriticalRequirements().length + index + 1}. Critical reference lock: ${ref.id} (${ref.kind}) must remain bound to its assigned usage scope: ${ref.usageScope}.`),
|
|
4246
|
+
'',
|
|
4247
|
+
...compileStoryboardTimingValidationSection(project),
|
|
4248
|
+
'',
|
|
4249
|
+
...compileStoryboardScenesSection(project),
|
|
4250
|
+
'TEXT RENDERING:',
|
|
4251
|
+
'Place scene number, timing, scene title, beat title, and compact production labels outside each video frame in a clearly associated header, footer strip, side rail, or table. Do not overlay scene numbers, timecodes, production notes, Dialogue/VO labels, Audio/SFX text, or SFX/action callout words such as Whoosh!, Impact!, Boom!, Thud!, Slash!, Crack!, or Pop! on top of the video-frame artwork. Also do not overlay scene/beat titles on top of the video-frame artwork. Project titles are metadata, not in-frame text, unless the source explicitly marks them as visible on-screen text. Only user-required diegetic or brand text belongs inside a frame. Quote and spell any required visible text exactly.',
|
|
4252
|
+
...project.endCard.requiredText.map(text => `Required exact visible text: "${text}".`),
|
|
4253
|
+
project.endCard.logoUsage ? `Logo usage: ${project.endCard.logoUsage}` : '',
|
|
4254
|
+
'',
|
|
4255
|
+
'SOURCE BRIEF TO FOLLOW:',
|
|
4256
|
+
sourceBrief,
|
|
4257
|
+
'',
|
|
4258
|
+
'NEGATIVE / AVOID:',
|
|
4259
|
+
...compileStoryboardAvoidSection(avoidSource).map(item => `- ${item}`),
|
|
4260
|
+
].join('\n');
|
|
4261
|
+
}
|
|
4262
|
+
export function ensureCompiledVideoStoryboardPromptPreservesSourceBrief(compiledPrompt, userIntentText, approvedScriptContext) {
|
|
4263
|
+
const prompt = compiledPrompt.trim();
|
|
4264
|
+
const rawUserIntentText = userIntentText.trim();
|
|
4265
|
+
if (!prompt || !rawUserIntentText)
|
|
4266
|
+
return prompt;
|
|
4267
|
+
const canonicalUserIntentText = canonicalStoryboardScriptContext(rawUserIntentText) || rawUserIntentText;
|
|
4268
|
+
const selectedSourceBrief = sanitizeStoryboardExternalAudioReferences(buildStoryboardSourceBriefForPrompt('', canonicalUserIntentText, approvedScriptContext)).trim();
|
|
4269
|
+
const sourceBrief = selectedSourceBrief && storyboardBriefContains(selectedSourceBrief, canonicalUserIntentText)
|
|
4270
|
+
? selectedSourceBrief
|
|
4271
|
+
: [
|
|
4272
|
+
`ORIGINAL USER INTENT:\n${canonicalUserIntentText}`,
|
|
4273
|
+
selectedSourceBrief && !storyboardBriefContains(canonicalUserIntentText, selectedSourceBrief)
|
|
4274
|
+
? `SELECTED STORYBOARD BRIEF:\n${selectedSourceBrief}`
|
|
4275
|
+
: '',
|
|
4276
|
+
].filter(Boolean).join('\n\n');
|
|
4277
|
+
if (!sourceBrief || storyboardBriefContains(prompt, sourceBrief))
|
|
4278
|
+
return prompt;
|
|
4279
|
+
return [
|
|
4280
|
+
prompt,
|
|
4281
|
+
'',
|
|
4282
|
+
'ORIGINAL SOURCE BRIEF TO PRESERVE:',
|
|
4283
|
+
sourceBrief,
|
|
4284
|
+
'Preserve these user-provided brands, names, dialogue, CTA text, reference assignments, timings, and scene order when rendering the storyboard image.',
|
|
4285
|
+
].join('\n');
|
|
4286
|
+
}
|
|
4287
|
+
export function lintStoryboardImagePrompt(prompt, layout, project) {
|
|
4288
|
+
const errors = [];
|
|
4289
|
+
const warnings = [];
|
|
4290
|
+
if (!/CREATE:/i.test(prompt))
|
|
4291
|
+
errors.push('missing CREATE section');
|
|
4292
|
+
if (!/COUNT \/ GRID CONTRACT:/i.test(prompt))
|
|
4293
|
+
errors.push('missing COUNT / GRID CONTRACT section');
|
|
4294
|
+
if (!/REFERENCE IMAGES:/i.test(prompt))
|
|
4295
|
+
errors.push('missing REFERENCE IMAGES section');
|
|
4296
|
+
if (!/CANVAS \/ LAYOUT:/i.test(prompt))
|
|
4297
|
+
errors.push('missing CANVAS / LAYOUT section');
|
|
4298
|
+
if (!/FRAME GEOMETRY:/i.test(prompt))
|
|
4299
|
+
errors.push('missing FRAME GEOMETRY section');
|
|
4300
|
+
const cellOrientation = parseAspectRatioOrientation(layout.cellAspectRatio);
|
|
4301
|
+
if (cellOrientation === 'portrait' && !/PORTRAIT FRAME GEOMETRY:/i.test(prompt)) {
|
|
4302
|
+
errors.push('missing PORTRAIT FRAME GEOMETRY section');
|
|
4303
|
+
}
|
|
4304
|
+
if (cellOrientation === 'landscape' && !/LANDSCAPE FRAME GEOMETRY:/i.test(prompt)) {
|
|
4305
|
+
errors.push('missing LANDSCAPE FRAME GEOMETRY section');
|
|
4306
|
+
}
|
|
4307
|
+
if (cellOrientation === 'square' && !/SQUARE FRAME GEOMETRY:/i.test(prompt)) {
|
|
4308
|
+
errors.push('missing SQUARE FRAME GEOMETRY section');
|
|
4309
|
+
}
|
|
4310
|
+
if (!/TEXT RENDERING:/i.test(prompt))
|
|
4311
|
+
errors.push('missing TEXT RENDERING section');
|
|
4312
|
+
if (!prompt.includes(`Overall storyboard canvas aspect ratio: ${layout.boardAspectRatio}`)
|
|
4313
|
+
&& !prompt.includes(`(${layout.boardAspectRatio})`)) {
|
|
4314
|
+
errors.push(`missing board aspect ratio ${layout.boardAspectRatio}`);
|
|
4315
|
+
}
|
|
4316
|
+
if (!prompt.includes(`Individual scene-cell/frame aspect ratio: ${layout.cellAspectRatio}`)) {
|
|
4317
|
+
errors.push(`missing cell aspect ratio ${layout.cellAspectRatio}`);
|
|
4318
|
+
}
|
|
4319
|
+
if (!prompt.includes(`Target final video aspect ratio: ${layout.targetVideoAspectRatio}`)) {
|
|
4320
|
+
errors.push(`missing target video aspect ratio ${layout.targetVideoAspectRatio}`);
|
|
4321
|
+
}
|
|
4322
|
+
if (!/\bDialogue\/VO\b/i.test(prompt))
|
|
4323
|
+
warnings.push('missing explicit Dialogue/VO field');
|
|
4324
|
+
if (!/\bAudio\/SFX\b/i.test(prompt))
|
|
4325
|
+
warnings.push('missing explicit Audio/SFX field');
|
|
4326
|
+
if (/\bparagraphs?\s+inside\s+each\s+(?:cell|frame|panel)\b/i.test(prompt)) {
|
|
4327
|
+
warnings.push('prompt may encourage dense paragraphs inside storyboard cells');
|
|
4328
|
+
}
|
|
4329
|
+
if (project) {
|
|
4330
|
+
const sceneCount = project.scenes.length;
|
|
4331
|
+
if (sceneCount > 0 && !new RegExp(String.raw `Create exactly\s+${sceneCount}\s+sequential video storyboard frames`, 'i').test(prompt)) {
|
|
4332
|
+
errors.push(`missing exact scene count ${sceneCount}`);
|
|
4333
|
+
}
|
|
4334
|
+
for (const ref of project.references.filter(item => item.preservePriority === 'critical')) {
|
|
4335
|
+
const refPattern = ref.index
|
|
4336
|
+
? new RegExp(String.raw `\bImage\s+${ref.index}\b`, 'i')
|
|
4337
|
+
: new RegExp(String.raw `\b${ref.id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\b`, 'i');
|
|
4338
|
+
if (!refPattern.test(prompt)) {
|
|
4339
|
+
errors.push(`missing critical reference ${ref.id}`);
|
|
4340
|
+
}
|
|
4341
|
+
}
|
|
4342
|
+
for (const requiredText of [
|
|
4343
|
+
...project.creativeBrief.mustInclude,
|
|
4344
|
+
...project.endCard.requiredText,
|
|
4345
|
+
]) {
|
|
4346
|
+
if (requiredText && !prompt.includes(requiredText)) {
|
|
4347
|
+
errors.push(`missing required text "${requiredText}"`);
|
|
4348
|
+
}
|
|
4349
|
+
}
|
|
4350
|
+
const timing = validateStoryboardProjectTiming(project);
|
|
4351
|
+
for (const issue of timing.issues) {
|
|
4352
|
+
const target = issue.severity === 'error' ? errors : warnings;
|
|
4353
|
+
target.push(`timing ${issue.code}: ${issue.message}`);
|
|
4354
|
+
}
|
|
4355
|
+
}
|
|
4356
|
+
return {
|
|
4357
|
+
ok: errors.length === 0,
|
|
4358
|
+
errors,
|
|
4359
|
+
warnings,
|
|
4360
|
+
};
|
|
4361
|
+
}
|
|
4362
|
+
function parseStoryboardDimensionText(value) {
|
|
4363
|
+
if (!value)
|
|
4364
|
+
return null;
|
|
4365
|
+
const match = value.match(/\b(\d{2,5})\s*x\s*(\d{2,5})\b/i);
|
|
4366
|
+
if (!match)
|
|
4367
|
+
return null;
|
|
4368
|
+
const width = Number(match[1]);
|
|
4369
|
+
const height = Number(match[2]);
|
|
4370
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0)
|
|
4371
|
+
return null;
|
|
4372
|
+
return { width, height };
|
|
4373
|
+
}
|
|
4374
|
+
function exactPixelAspectDescription(width, height) {
|
|
4375
|
+
const ratio = formatAspectRatio(width, height) ?? '16:9';
|
|
4376
|
+
const orientation = width > height ? 'landscape' : height > width ? 'portrait' : 'square';
|
|
4377
|
+
return `${ratio} ${orientation} video`;
|
|
4378
|
+
}
|
|
4379
|
+
function storyboardCanvasContextMentionsPage(context) {
|
|
4380
|
+
return /\b(?:board|canvas|page|sheet|poster|story\s*board\s+(?:image|sheet|canvas|board|page)|storyboard\s+(?:image|sheet|canvas|board|page)|(?:image|sheet|canvas|board|page)\s+(?:story\s*board|storyboard))\b/i.test(context);
|
|
4381
|
+
}
|
|
4382
|
+
export function stripGeneratedStoryboardLayoutHints(text) {
|
|
4383
|
+
return text
|
|
4384
|
+
.replace(new RegExp(`${DEFAULT_STORYBOARD_CANVAS_HINT_MARKER}[^\\n]*`, 'gi'), '')
|
|
4385
|
+
.replace(/^Storyboard layout target:[^\n]*$/gmi, '')
|
|
4386
|
+
.replace(/^Storyboard layout:[^\n]*$/gmi, '');
|
|
4387
|
+
}
|
|
4388
|
+
export function inferExplicitStoryboardCanvasPixelDimensions(text) {
|
|
4389
|
+
const source = stripGeneratedStoryboardLayoutHints(text);
|
|
4390
|
+
const matcher = /\b(\d{3,5})\s*x\s*(\d{3,5})\b/gi;
|
|
4391
|
+
for (const match of source.matchAll(matcher)) {
|
|
4392
|
+
const index = match.index ?? 0;
|
|
4393
|
+
const context = source.slice(Math.max(0, index - 80), Math.min(source.length, index + match[0].length + 80));
|
|
4394
|
+
if (!storyboardCanvasContextMentionsPage(context))
|
|
4395
|
+
continue;
|
|
4396
|
+
const width = Number(match[1]);
|
|
4397
|
+
const height = Number(match[2]);
|
|
4398
|
+
if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
|
|
4399
|
+
return { width, height };
|
|
4400
|
+
}
|
|
4401
|
+
}
|
|
4402
|
+
return null;
|
|
4403
|
+
}
|
|
4404
|
+
export function userDefinedStoryboardCanvas(text) {
|
|
4405
|
+
const source = stripGeneratedStoryboardLayoutHints(text);
|
|
4406
|
+
if (inferStoryboardBoardAspectDirective(source))
|
|
4407
|
+
return true;
|
|
4408
|
+
if (inferExplicitStoryboardCanvasPixelDimensions(source))
|
|
4409
|
+
return true;
|
|
4410
|
+
const pageUnit = String.raw `(?:board|canvas|page|sheet|poster|story\s*board\s+(?:image|sheet|canvas|board|page)|storyboard\s+(?:image|sheet|canvas|board|page)|(?:image|sheet|canvas|board|page)\s+(?:story\s*board|storyboard))`;
|
|
4411
|
+
const aspectToken = String.raw `(?:\d{1,4}\s*:\s*\d{1,4}|\d{3,5}\s*x\s*\d{3,5}|portrait|vertical|landscape|horizontal|widescreen)`;
|
|
4412
|
+
return new RegExp(String.raw `\b${pageUnit}\b[\s\S]{0,80}\b${aspectToken}\b`, 'i').test(source)
|
|
4413
|
+
|| new RegExp(String.raw `\b${aspectToken}\b[\s\S]{0,80}\b${pageUnit}\b`, 'i').test(source);
|
|
4414
|
+
}
|
|
4415
|
+
export function maskNonCanvasExactPixelDimensionsForStoryboard(text) {
|
|
4416
|
+
return text.replace(/\b(\d{3,5})\s*x\s*(\d{3,5})\b/gi, (match, rawWidth, rawHeight, offset, fullText) => {
|
|
4417
|
+
const source = String(fullText);
|
|
4418
|
+
const context = source.slice(Math.max(0, offset - 80), Math.min(source.length, offset + match.length + 80));
|
|
4419
|
+
if (storyboardCanvasContextMentionsPage(context))
|
|
4420
|
+
return match;
|
|
4421
|
+
const width = Number(rawWidth);
|
|
4422
|
+
const height = Number(rawHeight);
|
|
4423
|
+
return exactPixelAspectDescription(width, height);
|
|
4424
|
+
});
|
|
4425
|
+
}
|
|
4426
|
+
function parseAspectRatioOrientation(aspectRatio) {
|
|
4427
|
+
const normalized = normalizeAspectRatio(aspectRatio);
|
|
4428
|
+
if (!normalized)
|
|
4429
|
+
return null;
|
|
4430
|
+
const [widthText, heightText] = normalized.split(':');
|
|
4431
|
+
const width = Number(widthText);
|
|
4432
|
+
const height = Number(heightText);
|
|
4433
|
+
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
|
4434
|
+
return null;
|
|
4435
|
+
}
|
|
4436
|
+
if (width > height)
|
|
4437
|
+
return 'landscape';
|
|
4438
|
+
if (height > width)
|
|
4439
|
+
return 'portrait';
|
|
4440
|
+
return 'square';
|
|
4441
|
+
}
|
|
4442
|
+
function defaultStoryboardCanvasForVideoAspectRatio(targetVideoAspectRatio) {
|
|
4443
|
+
const orientation = parseAspectRatioOrientation(targetVideoAspectRatio);
|
|
4444
|
+
if (orientation === 'portrait')
|
|
4445
|
+
return GPT_IMAGE_STORYBOARD_DEFAULTS.storyboardLandscape;
|
|
4446
|
+
if (orientation === 'landscape')
|
|
4447
|
+
return GPT_IMAGE_STORYBOARD_DEFAULTS.storyboardPortrait;
|
|
4448
|
+
return null;
|
|
4449
|
+
}
|
|
4450
|
+
function storyboardCanvasHintText(canvas, targetVideoAspectRatio) {
|
|
4451
|
+
const boardAspect = canvas === GPT_IMAGE_STORYBOARD_DEFAULTS.storyboardLandscape
|
|
4452
|
+
? '16:9 landscape'
|
|
4453
|
+
: '9:16 portrait';
|
|
4454
|
+
return [
|
|
4455
|
+
`${DEFAULT_STORYBOARD_CANVAS_HINT_MARKER} Use a ${boardAspect} storyboard canvas/page (${canvas.aspectRatio}) for the composite storyboard sheet unless the user explicitly specifies another storyboard page size.`,
|
|
4456
|
+
`Keep individual scene-cell/frame aspect ratio ${targetVideoAspectRatio}; target final video aspect ratio ${targetVideoAspectRatio}.`,
|
|
4457
|
+
].join(' ');
|
|
4458
|
+
}
|
|
4459
|
+
function insertDefaultStoryboardCanvasHint(text, hint) {
|
|
4460
|
+
const generatedBriefMatch = text.match(/^\s*(?:[-*+]\s*)?(?:#{1,6}\s*)?(?:[*_]{1,3})?\s*(?:storyboard\s+image\s+brief|subsequent\s+video\s+brief|video\s+generation\s+stage|next\s+steps?)\b[^\n]*$/im);
|
|
4461
|
+
if (!generatedBriefMatch || generatedBriefMatch.index === undefined) {
|
|
4462
|
+
return `${hint}\n${text}`;
|
|
4463
|
+
}
|
|
4464
|
+
const before = text.slice(0, generatedBriefMatch.index).trimEnd();
|
|
4465
|
+
const after = text.slice(generatedBriefMatch.index).trimStart();
|
|
4466
|
+
return `${before}\n${hint}\n\n${after}`;
|
|
4467
|
+
}
|
|
4468
|
+
export function applyDefaultStoryboardCanvasHint(text, frameCount) {
|
|
4469
|
+
if (text.includes(DEFAULT_STORYBOARD_CANVAS_HINT_MARKER))
|
|
4470
|
+
return text;
|
|
4471
|
+
if (userDefinedStoryboardCanvas(text))
|
|
4472
|
+
return text;
|
|
4473
|
+
const maskedText = maskNonCanvasExactPixelDimensionsForStoryboard(text);
|
|
4474
|
+
const layout = inferStoryboardLayoutSpec(maskedText, frameCount);
|
|
4475
|
+
const explicitRatio = inferExplicitAspectRatioFromText(maskedText);
|
|
4476
|
+
const explicitTargetAspectRatio = explicitRatio ? `${explicitRatio.width}:${explicitRatio.height}` : null;
|
|
4477
|
+
const targetVideoAspectRatio = explicitTargetAspectRatio ?? layout.targetVideoAspectRatio;
|
|
4478
|
+
const canvas = defaultStoryboardCanvasForVideoAspectRatio(targetVideoAspectRatio);
|
|
4479
|
+
if (!canvas)
|
|
4480
|
+
return maskedText;
|
|
4481
|
+
return insertDefaultStoryboardCanvasHint(maskedText, storyboardCanvasHintText(canvas, targetVideoAspectRatio));
|
|
4482
|
+
}
|
|
4483
|
+
export function buildStoryboardCanvasArgs(boardAspectRatio, isGptImage2) {
|
|
4484
|
+
const normalizedBoardAspectRatio = normalizeAspectRatio(boardAspectRatio) ?? boardAspectRatio;
|
|
4485
|
+
const orientation = parseAspectRatioOrientation(normalizedBoardAspectRatio);
|
|
4486
|
+
const canvas = orientation === 'landscape'
|
|
4487
|
+
? GPT_IMAGE_STORYBOARD_DEFAULTS.storyboardLandscape
|
|
4488
|
+
: orientation === 'portrait'
|
|
4489
|
+
? GPT_IMAGE_STORYBOARD_DEFAULTS.storyboardPortrait
|
|
4490
|
+
: null;
|
|
4491
|
+
if (!canvas) {
|
|
4492
|
+
return { aspectRatio: normalizedBoardAspectRatio };
|
|
4493
|
+
}
|
|
4494
|
+
if (isGptImage2) {
|
|
4495
|
+
return {
|
|
4496
|
+
width: canvas.width,
|
|
4497
|
+
height: canvas.height,
|
|
4498
|
+
aspectRatio: canvas.aspectRatio,
|
|
4499
|
+
};
|
|
4500
|
+
}
|
|
4501
|
+
const isLandscapeCanvas = canvas === GPT_IMAGE_STORYBOARD_DEFAULTS.storyboardLandscape;
|
|
4502
|
+
const width = isLandscapeCanvas ? 1920 : 1080;
|
|
4503
|
+
const height = isLandscapeCanvas ? 1080 : 1920;
|
|
4504
|
+
return { width, height, aspectRatio: `${width}x${height}` };
|
|
4505
|
+
}
|
|
4506
|
+
function storyboardIntentWithDefaultCanvasHint(userIntentText, frameCount) {
|
|
4507
|
+
return applyDefaultStoryboardCanvasHint(userIntentText, frameCount);
|
|
4508
|
+
}
|
|
4509
|
+
export function defaultStoryboardImageDimensions(layout) {
|
|
4510
|
+
const explicit = parseStoryboardDimensionText(layout.boardDimensions);
|
|
4511
|
+
if (explicit)
|
|
4512
|
+
return explicit;
|
|
4513
|
+
const normalized = normalizeAspectRatio(layout.boardAspectRatio);
|
|
4514
|
+
if (normalized === '9:16')
|
|
4515
|
+
return { width: 1440, height: 2560 };
|
|
4516
|
+
if (normalized === '1:1')
|
|
4517
|
+
return { width: 2048, height: 2048 };
|
|
4518
|
+
if (normalized === '4:3')
|
|
4519
|
+
return { width: 2048, height: 1536 };
|
|
4520
|
+
if (normalized === '3:4')
|
|
4521
|
+
return { width: 1536, height: 2048 };
|
|
4522
|
+
return { width: 2560, height: 1440 };
|
|
4523
|
+
}
|
|
4524
|
+
function dimensionsForShortSideAspectRatio(aspectRatio, shortSide) {
|
|
4525
|
+
const normalized = normalizeAspectRatio(aspectRatio) ?? '16:9';
|
|
4526
|
+
const [widthText, heightText] = normalized.split(':');
|
|
4527
|
+
const ratioWidth = Number(widthText);
|
|
4528
|
+
const ratioHeight = Number(heightText);
|
|
4529
|
+
if (!Number.isFinite(ratioWidth)
|
|
4530
|
+
|| !Number.isFinite(ratioHeight)
|
|
4531
|
+
|| ratioWidth <= 0
|
|
4532
|
+
|| ratioHeight <= 0) {
|
|
4533
|
+
return { width: 1280, height: 720 };
|
|
4534
|
+
}
|
|
4535
|
+
const multiple = 16;
|
|
4536
|
+
const side = Math.max(multiple, Math.round(shortSide / multiple) * multiple);
|
|
4537
|
+
const roundLongSide = (value) => Math.max(multiple, Math.round(value / multiple) * multiple);
|
|
4538
|
+
if (ratioWidth >= ratioHeight) {
|
|
4539
|
+
return {
|
|
4540
|
+
width: roundLongSide(side * ratioWidth / ratioHeight),
|
|
4541
|
+
height: side,
|
|
4542
|
+
};
|
|
4543
|
+
}
|
|
4544
|
+
return {
|
|
4545
|
+
width: side,
|
|
4546
|
+
height: roundLongSide(side * ratioHeight / ratioWidth),
|
|
4547
|
+
};
|
|
4548
|
+
}
|
|
4549
|
+
function clampSeedanceStoryboardDuration(value) {
|
|
4550
|
+
const raw = typeof value === 'number' && Number.isFinite(value) ? value : 5;
|
|
4551
|
+
return Math.max(4, Math.min(15, Math.round(raw)));
|
|
4552
|
+
}
|
|
4553
|
+
function sceneLineForSeedanceStoryboardProject(scene, index) {
|
|
4554
|
+
const timing = scene.startSec !== null && scene.endSec !== null
|
|
4555
|
+
? `${formatStoryboardSeconds(scene.startSec)}-${formatStoryboardSeconds(scene.endSec)}`
|
|
4556
|
+
: 'untimed';
|
|
4557
|
+
const voice = scene.dialogue || '[no dialogue]';
|
|
4558
|
+
const metadataLabels = scene.metadataLabels ?? [];
|
|
4559
|
+
return [
|
|
4560
|
+
`SCENE ${String(index + 1).padStart(2, '0')} - ${removeStoryboardMetadataLabelsFromPromptText(scene.title, metadataLabels) || scene.title}`,
|
|
4561
|
+
`TIME: ${timing}`,
|
|
4562
|
+
`PURPOSE: ${scene.purpose || 'Advance the approved story spine.'}`,
|
|
4563
|
+
`VISUAL: ${removeStoryboardMetadataLabelsFromPromptText(scene.visual, metadataLabels) || 'Follow the approved storyboard visual.'}`,
|
|
4564
|
+
`ACTION: ${removeStoryboardMetadataLabelsFromPromptText(scene.action, metadataLabels) || 'Use clear motion that connects to the next beat.'}`,
|
|
4565
|
+
`CAMERA: ${scene.camera || 'Use the approved shot language.'}`,
|
|
4566
|
+
`LIGHTING/STYLE: ${scene.lighting || 'Preserve the storyboard style and lighting.'}`,
|
|
4567
|
+
`TRANSITION: ${[scene.transitionIn, scene.transitionOut].filter(Boolean).join('; ') || 'Use a clean motivated continuity transition.'}`,
|
|
4568
|
+
`VOICE/DIALOGUE: ${voice}`,
|
|
4569
|
+
`AUDIO/SFX: ${scene.audioSfx.join(', ') || defaultStoryboardAudioSfxLine()}`,
|
|
4570
|
+
`MUSIC: ${scene.music || 'Follow the global generated music arc.'}`,
|
|
4571
|
+
`REFERENCE USAGE: ${scene.referenceUsage.join('; ') || `Use ${PUBLIC_SEEDANCE_PRIMARY_IMAGE_REF} storyboard only.`}`,
|
|
4572
|
+
`METADATA LABELS (do not render): ${metadataLabels.join('; ') || 'none'}`,
|
|
4573
|
+
`VISIBLE TEXT: ${scene.textInImage.join('; ') || 'none'}`,
|
|
4574
|
+
].join('\n');
|
|
4575
|
+
}
|
|
4576
|
+
export function compileSeedanceStoryboardPromptFromProject(project, options = {}) {
|
|
4577
|
+
const storyboardImageTag = options.storyboardImageTag ?? PUBLIC_SEEDANCE_PRIMARY_IMAGE_REF;
|
|
4578
|
+
const durationSec = clampSeedanceStoryboardDuration(options.durationSec ?? project.durationSec);
|
|
4579
|
+
const aspectRatio = normalizeAspectRatio(options.aspectRatio ?? project.targetVideoAspectRatio)
|
|
4580
|
+
?? project.targetVideoAspectRatio
|
|
4581
|
+
?? '16:9';
|
|
4582
|
+
const avoidList = [
|
|
4583
|
+
...project.creativeBrief.mustAvoid,
|
|
4584
|
+
'Do not render the storyboard board, grid, captions, panel dividers, thumbnails, or collage layout as the video.',
|
|
4585
|
+
'Do not add extra readable text beyond required brand/CTA text.',
|
|
4586
|
+
'Do not drift reference assets, product design, logo, recurring character identity, shot order, or scene timing.',
|
|
4587
|
+
];
|
|
4588
|
+
const requiredText = project.endCard.requiredText.length > 0
|
|
4589
|
+
? project.endCard.requiredText.join('; ')
|
|
4590
|
+
: 'none';
|
|
4591
|
+
const metadataLabels = uniqueStoryboardStrings([
|
|
4592
|
+
...(project.metadataLabels ?? []),
|
|
4593
|
+
...project.scenes.flatMap(scene => scene.metadataLabels ?? []),
|
|
4594
|
+
]);
|
|
4595
|
+
const storySpine = removeStoryboardMetadataLabelsFromPromptText(project.creativeBrief.storySpine, metadataLabels) || project.creativeBrief.storySpine;
|
|
4596
|
+
return [
|
|
4597
|
+
'PROJECT:',
|
|
4598
|
+
`Title: ${project.title}`,
|
|
4599
|
+
`Duration: ${durationSec} seconds total.`,
|
|
4600
|
+
`Aspect ratio: ${aspectRatio}.`,
|
|
4601
|
+
`Story spine: ${storySpine}`,
|
|
4602
|
+
'',
|
|
4603
|
+
'INPUT ASSETS:',
|
|
4604
|
+
`${storyboardImageTag}: approved GPT Image 2 storyboard board. Treat it as an ordered shot guide and timing reference only, not as a collage, split-screen, grid, or picture-in-picture layout to reproduce.`,
|
|
4605
|
+
'',
|
|
4606
|
+
'GLOBAL VIDEO INSTRUCTIONS:',
|
|
4607
|
+
`Render one continuous cinematic video in ${aspectRatio}. Follow the storyboard scene order, timing ranges, transitions, audio plan, and CTA hold exactly where specified.`,
|
|
4608
|
+
'Use the storyboard as the controlling source for shot order and intent while converting each panel into full-screen motion.',
|
|
4609
|
+
'Keep required visible text minimal and exact. All scene numbers, timecodes, labels, and production notes remain metadata only and must not appear in the video.',
|
|
4610
|
+
metadataLabels.length > 0 ? `Do not render these metadata labels as video text: ${metadataLabels.join('; ')}.` : '',
|
|
4611
|
+
`Visual style: ${project.creativeBrief.visualQualityBar}`,
|
|
4612
|
+
`Tone progression: ${project.creativeBrief.toneProgression.join(' -> ') || 'preserve the approved tone progression.'}`,
|
|
4613
|
+
`Music arc: ${project.scenes.map(scene => scene.music).filter(Boolean).join(' -> ') || 'support the approved story arc without overpowering dialogue.'}`,
|
|
4614
|
+
'',
|
|
4615
|
+
'TIMECODED SCENES:',
|
|
4616
|
+
...project.scenes.map(sceneLineForSeedanceStoryboardProject),
|
|
4617
|
+
'',
|
|
4618
|
+
'END CARD / CTA HOLD:',
|
|
4619
|
+
`Required visible text: ${requiredText}`,
|
|
4620
|
+
'Hold readable CTA or brand text long enough to read.',
|
|
4621
|
+
`Treatment: ${[project.endCard.backgroundStyle, project.endCard.composition, project.endCard.logoUsage].filter(Boolean).join(' ') || 'clean branded end frame that remains readable.'}`,
|
|
4622
|
+
'',
|
|
4623
|
+
'NEGATIVE / AVOID:',
|
|
4624
|
+
...Array.from(new Set(avoidList.map(item => compactStoryboardLine(item)).filter(Boolean))).map(item => `- ${item}`),
|
|
4625
|
+
].join('\n');
|
|
4626
|
+
}
|
|
4627
|
+
export function lintSeedanceStoryboardPromptFromProject(prompt, project) {
|
|
4628
|
+
const errors = [];
|
|
4629
|
+
const warnings = [];
|
|
4630
|
+
if (!/\bPROJECT:/i.test(prompt))
|
|
4631
|
+
errors.push('missing PROJECT section');
|
|
4632
|
+
if (!/\bINPUT ASSETS:/i.test(prompt))
|
|
4633
|
+
errors.push('missing INPUT ASSETS section');
|
|
4634
|
+
if (!/\bGLOBAL VIDEO INSTRUCTIONS:/i.test(prompt))
|
|
4635
|
+
errors.push('missing GLOBAL VIDEO INSTRUCTIONS section');
|
|
4636
|
+
if (!new RegExp(PUBLIC_SEEDANCE_PRIMARY_IMAGE_REF, 'i').test(prompt)) {
|
|
4637
|
+
errors.push(`missing ${PUBLIC_SEEDANCE_PRIMARY_IMAGE_REF} storyboard reference`);
|
|
4638
|
+
}
|
|
4639
|
+
if (!/\bshot guide\b/i.test(prompt))
|
|
4640
|
+
errors.push('missing shot-guide instruction');
|
|
4641
|
+
if (!/\bnot\s+as\s+a\s+(?:collage|split-screen|grid)/i.test(prompt)) {
|
|
4642
|
+
errors.push('missing anti-collage/grid instruction');
|
|
4643
|
+
}
|
|
4644
|
+
if (project.scenes.length === 0)
|
|
4645
|
+
errors.push('missing storyboard scenes');
|
|
4646
|
+
project.scenes.forEach((scene, index) => {
|
|
4647
|
+
const sceneNumber = String(index + 1).padStart(2, '0');
|
|
4648
|
+
if (!new RegExp(`SCENE\\s+${sceneNumber}\\b`, 'i').test(prompt)) {
|
|
4649
|
+
errors.push(`missing scene ${index + 1}`);
|
|
4650
|
+
}
|
|
4651
|
+
if (scene.startSec !== null && scene.endSec !== null) {
|
|
4652
|
+
const timeText = `${formatStoryboardSeconds(scene.startSec)}-${formatStoryboardSeconds(scene.endSec)}`;
|
|
4653
|
+
if (!prompt.includes(timeText))
|
|
4654
|
+
warnings.push(`missing exact time range for scene ${index + 1}`);
|
|
4655
|
+
}
|
|
4656
|
+
});
|
|
4657
|
+
for (const text of project.endCard.requiredText) {
|
|
4658
|
+
if (text && !prompt.includes(text))
|
|
4659
|
+
errors.push(`missing required text "${text}"`);
|
|
4660
|
+
}
|
|
4661
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
4662
|
+
}
|
|
4663
|
+
export function buildStoryboardVideoHostedToolSequenceInput(options) {
|
|
4664
|
+
const storyline = options.storyline.trim();
|
|
4665
|
+
const userIntentText = options.userIntentText.trim();
|
|
4666
|
+
if (!storyline) {
|
|
4667
|
+
throw new Error('Storyboard video workflow requires a generated storyline or approved storyboard script.');
|
|
4668
|
+
}
|
|
4669
|
+
if (!userIntentText) {
|
|
4670
|
+
throw new Error('Storyboard video workflow requires the original user intent text.');
|
|
4671
|
+
}
|
|
4672
|
+
const initialFrameCount = Math.max(1, Math.min(24, options.frameCount
|
|
4673
|
+
?? inferExplicitStoryboardFrameCountFromText(`${userIntentText}\n${storyline}`)
|
|
4674
|
+
?? inferDefaultStoryboardFrameCountFromText(`${userIntentText}\n${storyline}`)));
|
|
4675
|
+
const workflowUserIntentText = storyboardIntentWithDefaultCanvasHint(userIntentText, initialFrameCount);
|
|
4676
|
+
const frameCount = Math.max(1, Math.min(24, options.frameCount
|
|
4677
|
+
?? inferExplicitStoryboardFrameCountFromText(`${workflowUserIntentText}\n${storyline}`)
|
|
4678
|
+
?? inferDefaultStoryboardFrameCountFromText(`${workflowUserIntentText}\n${storyline}`)));
|
|
4679
|
+
const compileOptions = {
|
|
4680
|
+
prompt: storyline,
|
|
4681
|
+
userIntentText: workflowUserIntentText,
|
|
4682
|
+
approvedScriptContext: storyline,
|
|
4683
|
+
frameCount,
|
|
4684
|
+
promptAuthorship: 'assistant',
|
|
4685
|
+
};
|
|
4686
|
+
const project = buildStoryboardProject(compileOptions);
|
|
4687
|
+
const layout = storyboardLayoutSpecFromProject(project, project.scenes.length || frameCount);
|
|
4688
|
+
const defaultImageDimensions = defaultStoryboardImageDimensions(layout);
|
|
4689
|
+
const imageWidth = options.imageWidth ?? defaultImageDimensions.width;
|
|
4690
|
+
const imageHeight = options.imageHeight ?? defaultImageDimensions.height;
|
|
4691
|
+
const videoDuration = clampSeedanceStoryboardDuration(options.videoDurationSec ?? project.durationSec);
|
|
4692
|
+
const videoDimensions = dimensionsForShortSideAspectRatio(project.targetVideoAspectRatio, options.videoTargetResolution ?? 720);
|
|
4693
|
+
const storyboardImagePrompt = compileVideoStoryboardImagePrompt(compileOptions);
|
|
4694
|
+
const seedanceVideoPrompt = compileSeedanceStoryboardPromptFromProject(project, {
|
|
4695
|
+
storyboardImageTag: PUBLIC_SEEDANCE_PRIMARY_IMAGE_REF,
|
|
4696
|
+
durationSec: videoDuration,
|
|
4697
|
+
aspectRatio: project.targetVideoAspectRatio,
|
|
4698
|
+
});
|
|
4699
|
+
const storyboardLint = lintStoryboardImagePrompt(storyboardImagePrompt, layout, project);
|
|
4700
|
+
const seedanceLint = lintSeedanceStoryboardPromptFromProject(seedanceVideoPrompt, project);
|
|
4701
|
+
const title = options.title || `${project.title} storyboard video`;
|
|
4702
|
+
const imageModel = options.imageModel ?? 'gpt-image-2';
|
|
4703
|
+
const imageQuality = options.imageQuality ?? 'high';
|
|
4704
|
+
const imageOutputFormat = options.imageOutputFormat ?? 'png';
|
|
4705
|
+
const videoModel = options.videoModel ?? 'seedance2';
|
|
4706
|
+
const generateAudio = options.generateAudio ?? true;
|
|
4707
|
+
const input = {
|
|
4708
|
+
title,
|
|
4709
|
+
steps: [
|
|
4710
|
+
{
|
|
4711
|
+
id: 'storyboard_image',
|
|
4712
|
+
toolName: 'sogni_generate_image',
|
|
4713
|
+
arguments: {
|
|
4714
|
+
prompt: storyboardImagePrompt,
|
|
4715
|
+
model: imageModel,
|
|
4716
|
+
width: imageWidth,
|
|
4717
|
+
height: imageHeight,
|
|
4718
|
+
number_of_variations: 1,
|
|
4719
|
+
gpt_image_quality: imageQuality,
|
|
4720
|
+
output_format: imageOutputFormat,
|
|
4721
|
+
},
|
|
4722
|
+
},
|
|
4723
|
+
{
|
|
4724
|
+
id: 'seedance_video',
|
|
4725
|
+
toolName: 'sogni_generate_video',
|
|
4726
|
+
arguments: {
|
|
4727
|
+
prompt: seedanceVideoPrompt,
|
|
4728
|
+
model: videoModel,
|
|
4729
|
+
width: videoDimensions.width,
|
|
4730
|
+
height: videoDimensions.height,
|
|
4731
|
+
duration: videoDuration,
|
|
4732
|
+
fps: 24,
|
|
4733
|
+
number_of_variations: 1,
|
|
4734
|
+
generate_audio: generateAudio,
|
|
4735
|
+
expand_prompt: false,
|
|
4736
|
+
},
|
|
4737
|
+
dependsOn: [
|
|
4738
|
+
{
|
|
4739
|
+
sourceStepId: 'storyboard_image',
|
|
4740
|
+
sourceArtifactIndex: 0,
|
|
4741
|
+
targetArgument: 'reference_image_url',
|
|
4742
|
+
mediaType: 'image',
|
|
4743
|
+
transform: 'artifact_url',
|
|
4744
|
+
required: true,
|
|
4745
|
+
},
|
|
4746
|
+
],
|
|
4747
|
+
},
|
|
4748
|
+
],
|
|
4749
|
+
};
|
|
4750
|
+
return {
|
|
4751
|
+
title,
|
|
4752
|
+
frameCount,
|
|
4753
|
+
storyline,
|
|
4754
|
+
storyboardProject: project,
|
|
4755
|
+
storyboardImagePrompt,
|
|
4756
|
+
seedanceVideoPrompt,
|
|
4757
|
+
image: {
|
|
4758
|
+
width: imageWidth,
|
|
4759
|
+
height: imageHeight,
|
|
4760
|
+
model: imageModel,
|
|
4761
|
+
quality: imageQuality,
|
|
4762
|
+
outputFormat: imageOutputFormat,
|
|
4763
|
+
},
|
|
4764
|
+
video: {
|
|
4765
|
+
width: videoDimensions.width,
|
|
4766
|
+
height: videoDimensions.height,
|
|
4767
|
+
duration: videoDuration,
|
|
4768
|
+
model: videoModel,
|
|
4769
|
+
generateAudio,
|
|
4770
|
+
},
|
|
4771
|
+
input,
|
|
4772
|
+
warnings: [
|
|
4773
|
+
...storyboardLint.warnings.map(warning => `storyboard image: ${warning}`),
|
|
4774
|
+
...storyboardLint.errors.map(error => `storyboard image error: ${error}`),
|
|
4775
|
+
...seedanceLint.warnings.map(warning => `seedance prompt: ${warning}`),
|
|
4776
|
+
...seedanceLint.errors.map(error => `seedance prompt error: ${error}`),
|
|
4777
|
+
],
|
|
4778
|
+
};
|
|
4779
|
+
}
|
|
1048
4780
|
export function inferDefaultVideoSteps(modelId) {
|
|
1049
4781
|
const id = (modelId || '').toLowerCase();
|
|
1050
4782
|
if (isSeedanceModel(id))
|