@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.
@@ -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-04.1';
8
- export const SEEDANCE_STORYBOARD_REFERENCE_PROMPT = 'Turn the video storyboard in @Image1 into a video by following the thumbnails and script for each segment in the image.';
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
- : 'No spoken dialogue unless exact quoted words are present in the visual prompt.';
510
- return [
879
+ : '';
880
+ const sections = [
511
881
  '[VISUAL]',
512
882
  prompt.trim(),
513
883
  '',
514
884
  '[SPEECH]',
515
- speechLines,
516
- '',
517
- '[SOUNDS]',
518
- 'Use natural ambient sound that matches the scene unless the prompt specifies silence.'
519
- ].join('\n');
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 LTX23_WORKFLOW_MODELS.ia2v;
974
+ return resolveLtx23WorkflowModelForQuality('ia2v', opts.quality);
604
975
  if (workflow === 'a2v')
605
- return LTX23_WORKFLOW_MODELS.a2v;
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 LTX23_WORKFLOW_MODELS.t2v;
980
+ return resolveLtx23WorkflowModelForQuality('t2v', opts.quality);
610
981
  if (workflow === 'i2v' && (opts.referenceAudioIdentity || promptNeedsLtxNativeAudio(opts.prompt) || opts.quality === 'hq' || opts.quality === 'pro')) {
611
- return LTX23_WORKFLOW_MODELS.i2v;
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|reels?|tiktok|shorts?|story)\b/i.test(text);
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
- if (/\b(?:ratio|aspect|format|portrait|landscape|vertical|horizontal|widescreen|frame|video|output)\b/i.test(context)) {
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
- return /\b(?:story\s*board|storyboard|video\s+sequence|sequence\s+sheet|shot\s+sheet|thumbnail\s+sequence|panel\s+sequence)\b/i.test(text);
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.trim();
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*(?:-|to)\s*\d{1,2}(?:[:.]\d{2})?\s*\]/i.test(normalized))
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 mentionsTransition = /\b(?:transition|transitions|transitioning|link|links|linking|connect|connecting|morph)\b/.test(lower)
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
- if (!seedanceStoryboardFallbackAllowedForText(userIntentText))
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 = textMentionsStoryboardReference(userIntentText)
1581
+ const reason = storyboardReferenceMentioned
909
1582
  ? 'text_mentions_storyboard'
910
- : input.storyboardDetected
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 intendedDuration = requestedDuration !== null && requestedDuration !== undefined
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: SEEDANCE_STORYBOARD_REFERENCE_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 = inferNamedVideoResolutionShortSideFromText(text);
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(/&nbsp;/gi, ' ')
2714
+ .replace(/&amp;/gi, '&')
2715
+ .replace(/&quot;/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))