venice-video-harness 2.4.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.
Files changed (264) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +598 -0
  4. package/dist/assembly/manifest-builder.d.ts +8 -0
  5. package/dist/assembly/manifest-builder.d.ts.map +1 -0
  6. package/dist/assembly/manifest-builder.js +196 -0
  7. package/dist/assembly/manifest-builder.js.map +1 -0
  8. package/dist/assembly/remotion-scaffold.d.ts +14 -0
  9. package/dist/assembly/remotion-scaffold.d.ts.map +1 -0
  10. package/dist/assembly/remotion-scaffold.js +292 -0
  11. package/dist/assembly/remotion-scaffold.js.map +1 -0
  12. package/dist/assembly/types.d.ts +67 -0
  13. package/dist/assembly/types.d.ts.map +1 -0
  14. package/dist/assembly/types.js +5 -0
  15. package/dist/assembly/types.js.map +1 -0
  16. package/dist/characters/describer.d.ts +32 -0
  17. package/dist/characters/describer.d.ts.map +1 -0
  18. package/dist/characters/describer.js +309 -0
  19. package/dist/characters/describer.js.map +1 -0
  20. package/dist/characters/extractor.d.ts +27 -0
  21. package/dist/characters/extractor.d.ts.map +1 -0
  22. package/dist/characters/extractor.js +274 -0
  23. package/dist/characters/extractor.js.map +1 -0
  24. package/dist/characters/reference-manager.d.ts +95 -0
  25. package/dist/characters/reference-manager.d.ts.map +1 -0
  26. package/dist/characters/reference-manager.js +346 -0
  27. package/dist/characters/reference-manager.js.map +1 -0
  28. package/dist/cli.d.ts +3 -0
  29. package/dist/cli.d.ts.map +1 -0
  30. package/dist/cli.js +310 -0
  31. package/dist/cli.js.map +1 -0
  32. package/dist/config.d.ts +22 -0
  33. package/dist/config.d.ts.map +1 -0
  34. package/dist/config.js +66 -0
  35. package/dist/config.js.map +1 -0
  36. package/dist/editing/aligner.d.ts +44 -0
  37. package/dist/editing/aligner.d.ts.map +1 -0
  38. package/dist/editing/aligner.js +201 -0
  39. package/dist/editing/aligner.js.map +1 -0
  40. package/dist/editing/edl.d.ts +45 -0
  41. package/dist/editing/edl.d.ts.map +1 -0
  42. package/dist/editing/edl.js +133 -0
  43. package/dist/editing/edl.js.map +1 -0
  44. package/dist/editing/overlays.d.ts +97 -0
  45. package/dist/editing/overlays.d.ts.map +1 -0
  46. package/dist/editing/overlays.js +41 -0
  47. package/dist/editing/overlays.js.map +1 -0
  48. package/dist/editing/packer.d.ts +52 -0
  49. package/dist/editing/packer.d.ts.map +1 -0
  50. package/dist/editing/packer.js +170 -0
  51. package/dist/editing/packer.js.map +1 -0
  52. package/dist/editing/providers/whisper-cpp.d.ts +32 -0
  53. package/dist/editing/providers/whisper-cpp.d.ts.map +1 -0
  54. package/dist/editing/providers/whisper-cpp.js +239 -0
  55. package/dist/editing/providers/whisper-cpp.js.map +1 -0
  56. package/dist/editing/render.d.ts +36 -0
  57. package/dist/editing/render.d.ts.map +1 -0
  58. package/dist/editing/render.js +227 -0
  59. package/dist/editing/render.js.map +1 -0
  60. package/dist/editing/self-eval.d.ts +48 -0
  61. package/dist/editing/self-eval.d.ts.map +1 -0
  62. package/dist/editing/self-eval.js +293 -0
  63. package/dist/editing/self-eval.js.map +1 -0
  64. package/dist/editing/silence.d.ts +76 -0
  65. package/dist/editing/silence.d.ts.map +1 -0
  66. package/dist/editing/silence.js +189 -0
  67. package/dist/editing/silence.js.map +1 -0
  68. package/dist/editing/types.d.ts +178 -0
  69. package/dist/editing/types.d.ts.map +1 -0
  70. package/dist/editing/types.js +13 -0
  71. package/dist/editing/types.js.map +1 -0
  72. package/dist/index.d.ts +6 -0
  73. package/dist/index.d.ts.map +1 -0
  74. package/dist/index.js +5 -0
  75. package/dist/index.js.map +1 -0
  76. package/dist/interactive.d.ts +12 -0
  77. package/dist/interactive.d.ts.map +1 -0
  78. package/dist/interactive.js +78 -0
  79. package/dist/interactive.js.map +1 -0
  80. package/dist/mini-drama/assembler.d.ts +61 -0
  81. package/dist/mini-drama/assembler.d.ts.map +1 -0
  82. package/dist/mini-drama/assembler.js +466 -0
  83. package/dist/mini-drama/assembler.js.map +1 -0
  84. package/dist/mini-drama/audio-mix.d.ts +65 -0
  85. package/dist/mini-drama/audio-mix.d.ts.map +1 -0
  86. package/dist/mini-drama/audio-mix.js +118 -0
  87. package/dist/mini-drama/audio-mix.js.map +1 -0
  88. package/dist/mini-drama/cli.d.ts +3 -0
  89. package/dist/mini-drama/cli.d.ts.map +1 -0
  90. package/dist/mini-drama/cli.js +2869 -0
  91. package/dist/mini-drama/cli.js.map +1 -0
  92. package/dist/mini-drama/fcpxml-export.d.ts +2 -0
  93. package/dist/mini-drama/fcpxml-export.d.ts.map +1 -0
  94. package/dist/mini-drama/fcpxml-export.js +5 -0
  95. package/dist/mini-drama/fcpxml-export.js.map +1 -0
  96. package/dist/mini-drama/generation-planner.d.ts +24 -0
  97. package/dist/mini-drama/generation-planner.d.ts.map +1 -0
  98. package/dist/mini-drama/generation-planner.js +304 -0
  99. package/dist/mini-drama/generation-planner.js.map +1 -0
  100. package/dist/mini-drama/location-generator.d.ts +23 -0
  101. package/dist/mini-drama/location-generator.d.ts.map +1 -0
  102. package/dist/mini-drama/location-generator.js +144 -0
  103. package/dist/mini-drama/location-generator.js.map +1 -0
  104. package/dist/mini-drama/music-cues.d.ts +96 -0
  105. package/dist/mini-drama/music-cues.d.ts.map +1 -0
  106. package/dist/mini-drama/music-cues.js +332 -0
  107. package/dist/mini-drama/music-cues.js.map +1 -0
  108. package/dist/mini-drama/panel-fixer.d.ts +12 -0
  109. package/dist/mini-drama/panel-fixer.d.ts.map +1 -0
  110. package/dist/mini-drama/panel-fixer.js +319 -0
  111. package/dist/mini-drama/panel-fixer.js.map +1 -0
  112. package/dist/mini-drama/prompt-builder.d.ts +149 -0
  113. package/dist/mini-drama/prompt-builder.d.ts.map +1 -0
  114. package/dist/mini-drama/prompt-builder.js +722 -0
  115. package/dist/mini-drama/prompt-builder.js.map +1 -0
  116. package/dist/mini-drama/reference-slots.d.ts +31 -0
  117. package/dist/mini-drama/reference-slots.d.ts.map +1 -0
  118. package/dist/mini-drama/reference-slots.js +161 -0
  119. package/dist/mini-drama/reference-slots.js.map +1 -0
  120. package/dist/mini-drama/shot-paths.d.ts +77 -0
  121. package/dist/mini-drama/shot-paths.d.ts.map +1 -0
  122. package/dist/mini-drama/shot-paths.js +101 -0
  123. package/dist/mini-drama/shot-paths.js.map +1 -0
  124. package/dist/mini-drama/storyboard-reference-generator.d.ts +47 -0
  125. package/dist/mini-drama/storyboard-reference-generator.d.ts.map +1 -0
  126. package/dist/mini-drama/storyboard-reference-generator.js +293 -0
  127. package/dist/mini-drama/storyboard-reference-generator.js.map +1 -0
  128. package/dist/mini-drama/subtitle-generator.d.ts +12 -0
  129. package/dist/mini-drama/subtitle-generator.d.ts.map +1 -0
  130. package/dist/mini-drama/subtitle-generator.js +139 -0
  131. package/dist/mini-drama/subtitle-generator.js.map +1 -0
  132. package/dist/mini-drama/timeline-export/davinci-fcpxml.d.ts +3 -0
  133. package/dist/mini-drama/timeline-export/davinci-fcpxml.d.ts.map +1 -0
  134. package/dist/mini-drama/timeline-export/davinci-fcpxml.js +150 -0
  135. package/dist/mini-drama/timeline-export/davinci-fcpxml.js.map +1 -0
  136. package/dist/mini-drama/timeline-export/fcpxml.d.ts +11 -0
  137. package/dist/mini-drama/timeline-export/fcpxml.d.ts.map +1 -0
  138. package/dist/mini-drama/timeline-export/fcpxml.js +140 -0
  139. package/dist/mini-drama/timeline-export/fcpxml.js.map +1 -0
  140. package/dist/mini-drama/timeline-export/index.d.ts +13 -0
  141. package/dist/mini-drama/timeline-export/index.d.ts.map +1 -0
  142. package/dist/mini-drama/timeline-export/index.js +26 -0
  143. package/dist/mini-drama/timeline-export/index.js.map +1 -0
  144. package/dist/mini-drama/timeline-export/premiere-xmeml.d.ts +3 -0
  145. package/dist/mini-drama/timeline-export/premiere-xmeml.d.ts.map +1 -0
  146. package/dist/mini-drama/timeline-export/premiere-xmeml.js +211 -0
  147. package/dist/mini-drama/timeline-export/premiere-xmeml.js.map +1 -0
  148. package/dist/mini-drama/timeline-export/probe.d.ts +53 -0
  149. package/dist/mini-drama/timeline-export/probe.d.ts.map +1 -0
  150. package/dist/mini-drama/timeline-export/probe.js +89 -0
  151. package/dist/mini-drama/timeline-export/probe.js.map +1 -0
  152. package/dist/mini-drama/timeline-export/types.d.ts +57 -0
  153. package/dist/mini-drama/timeline-export/types.d.ts.map +1 -0
  154. package/dist/mini-drama/timeline-export/types.js +14 -0
  155. package/dist/mini-drama/timeline-export/types.js.map +1 -0
  156. package/dist/mini-drama/video-generator.d.ts +22 -0
  157. package/dist/mini-drama/video-generator.d.ts.map +1 -0
  158. package/dist/mini-drama/video-generator.js +1305 -0
  159. package/dist/mini-drama/video-generator.js.map +1 -0
  160. package/dist/mini-drama/voice-reference.d.ts +42 -0
  161. package/dist/mini-drama/voice-reference.d.ts.map +1 -0
  162. package/dist/mini-drama/voice-reference.js +170 -0
  163. package/dist/mini-drama/voice-reference.js.map +1 -0
  164. package/dist/output/html-renderer.d.ts +2 -0
  165. package/dist/output/html-renderer.d.ts.map +1 -0
  166. package/dist/output/html-renderer.js +37 -0
  167. package/dist/output/html-renderer.js.map +1 -0
  168. package/dist/parsers/fountain-parser.d.ts +34 -0
  169. package/dist/parsers/fountain-parser.d.ts.map +1 -0
  170. package/dist/parsers/fountain-parser.js +71 -0
  171. package/dist/parsers/fountain-parser.js.map +1 -0
  172. package/dist/parsers/pdf-parser.d.ts +24 -0
  173. package/dist/parsers/pdf-parser.d.ts.map +1 -0
  174. package/dist/parsers/pdf-parser.js +237 -0
  175. package/dist/parsers/pdf-parser.js.map +1 -0
  176. package/dist/parsers/scene-extractor.d.ts +41 -0
  177. package/dist/parsers/scene-extractor.d.ts.map +1 -0
  178. package/dist/parsers/scene-extractor.js +400 -0
  179. package/dist/parsers/scene-extractor.js.map +1 -0
  180. package/dist/series/manager.d.ts +64 -0
  181. package/dist/series/manager.d.ts.map +1 -0
  182. package/dist/series/manager.js +282 -0
  183. package/dist/series/manager.js.map +1 -0
  184. package/dist/series/types.d.ts +641 -0
  185. package/dist/series/types.d.ts.map +1 -0
  186. package/dist/series/types.js +304 -0
  187. package/dist/series/types.js.map +1 -0
  188. package/dist/storyboard/assembler.d.ts +134 -0
  189. package/dist/storyboard/assembler.d.ts.map +1 -0
  190. package/dist/storyboard/assembler.js +319 -0
  191. package/dist/storyboard/assembler.js.map +1 -0
  192. package/dist/storyboard/prompt-builder.d.ts +113 -0
  193. package/dist/storyboard/prompt-builder.d.ts.map +1 -0
  194. package/dist/storyboard/prompt-builder.js +578 -0
  195. package/dist/storyboard/prompt-builder.js.map +1 -0
  196. package/dist/storyboard/shot-planner.d.ts +34 -0
  197. package/dist/storyboard/shot-planner.d.ts.map +1 -0
  198. package/dist/storyboard/shot-planner.js +446 -0
  199. package/dist/storyboard/shot-planner.js.map +1 -0
  200. package/dist/user-config.d.ts +15 -0
  201. package/dist/user-config.d.ts.map +1 -0
  202. package/dist/user-config.js +103 -0
  203. package/dist/user-config.js.map +1 -0
  204. package/dist/venice/audio-preflight.d.ts +80 -0
  205. package/dist/venice/audio-preflight.d.ts.map +1 -0
  206. package/dist/venice/audio-preflight.js +142 -0
  207. package/dist/venice/audio-preflight.js.map +1 -0
  208. package/dist/venice/audio.d.ts +88 -0
  209. package/dist/venice/audio.d.ts.map +1 -0
  210. package/dist/venice/audio.js +234 -0
  211. package/dist/venice/audio.js.map +1 -0
  212. package/dist/venice/client.d.ts +86 -0
  213. package/dist/venice/client.d.ts.map +1 -0
  214. package/dist/venice/client.js +375 -0
  215. package/dist/venice/client.js.map +1 -0
  216. package/dist/venice/edit.d.ts +57 -0
  217. package/dist/venice/edit.d.ts.map +1 -0
  218. package/dist/venice/edit.js +100 -0
  219. package/dist/venice/edit.js.map +1 -0
  220. package/dist/venice/generate.d.ts +47 -0
  221. package/dist/venice/generate.d.ts.map +1 -0
  222. package/dist/venice/generate.js +150 -0
  223. package/dist/venice/generate.js.map +1 -0
  224. package/dist/venice/image-bytes.d.ts +26 -0
  225. package/dist/venice/image-bytes.d.ts.map +1 -0
  226. package/dist/venice/image-bytes.js +101 -0
  227. package/dist/venice/image-bytes.js.map +1 -0
  228. package/dist/venice/models.d.ts +143 -0
  229. package/dist/venice/models.d.ts.map +1 -0
  230. package/dist/venice/models.js +984 -0
  231. package/dist/venice/models.js.map +1 -0
  232. package/dist/venice/multi-edit.d.ts +40 -0
  233. package/dist/venice/multi-edit.d.ts.map +1 -0
  234. package/dist/venice/multi-edit.js +97 -0
  235. package/dist/venice/multi-edit.js.map +1 -0
  236. package/dist/venice/provenance.d.ts +66 -0
  237. package/dist/venice/provenance.d.ts.map +1 -0
  238. package/dist/venice/provenance.js +151 -0
  239. package/dist/venice/provenance.js.map +1 -0
  240. package/dist/venice/recipe.d.ts +69 -0
  241. package/dist/venice/recipe.d.ts.map +1 -0
  242. package/dist/venice/recipe.js +103 -0
  243. package/dist/venice/recipe.js.map +1 -0
  244. package/dist/venice/rejection.d.ts +63 -0
  245. package/dist/venice/rejection.d.ts.map +1 -0
  246. package/dist/venice/rejection.js +111 -0
  247. package/dist/venice/rejection.js.map +1 -0
  248. package/dist/venice/seedance-preflight.d.ts +38 -0
  249. package/dist/venice/seedance-preflight.d.ts.map +1 -0
  250. package/dist/venice/seedance-preflight.js +29 -0
  251. package/dist/venice/seedance-preflight.js.map +1 -0
  252. package/dist/venice/types.d.ts +212 -0
  253. package/dist/venice/types.d.ts.map +1 -0
  254. package/dist/venice/types.js +9 -0
  255. package/dist/venice/types.js.map +1 -0
  256. package/dist/venice/video.d.ts +77 -0
  257. package/dist/venice/video.d.ts.map +1 -0
  258. package/dist/venice/video.js +163 -0
  259. package/dist/venice/video.js.map +1 -0
  260. package/dist/venice/voices.d.ts +40 -0
  261. package/dist/venice/voices.d.ts.map +1 -0
  262. package/dist/venice/voices.js +194 -0
  263. package/dist/venice/voices.js.map +1 -0
  264. package/package.json +79 -0
@@ -0,0 +1,2869 @@
1
+ #!/usr/bin/env node
2
+ import 'dotenv/config';
3
+ import { Command } from 'commander';
4
+ import { resolve, join, basename } from 'node:path';
5
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
6
+ import { mkdir, readFile, writeFile, copyFile } from 'node:fs/promises';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { stdin } from 'node:process';
9
+ import { createSeries, saveSeries, loadSeries, listSeries, addCharacter, getCharacter, addEpisode, getEpisodeDir, getCharacterDir, getLocationDir, getLocation, addLocation, locationSlugify, saveEpisodeScript, loadEpisodeScript, } from '../series/manager.js';
10
+ import { FEMALE_BASE_TRAITS, MALE_BASE_TRAITS, DEFAULT_CHARACTER_CONSISTENCY_MODEL, DEFAULT_IMAGE_GENERATION_MODEL, DEFAULT_IMAGE_EDIT_MODEL, } from '../series/types.js';
11
+ import { VeniceClient } from '../venice/client.js';
12
+ import { generateImage, generateWithReferences } from '../venice/generate.js';
13
+ import { writeImageBytesSmart } from '../venice/image-bytes.js';
14
+ import { appendRecipePass } from '../venice/recipe.js';
15
+ import { getVeniceApiKey } from '../config.js';
16
+ import { promptChoice, promptText } from '../interactive.js';
17
+ import { getConfigPath, getDefaultSetupWorkspace, getWorkspaceDir, maskApiKey, readUserConfig, updateUserConfig, validateVeniceApiKey, } from '../user-config.js';
18
+ import { listVoices, filterVoices, auditionVoices } from '../venice/voices.js';
19
+ import { generateDialogueForShots, generateSoundEffect, generateMusic, generateSeedAudio, DEFAULT_VENICE_MUSIC_MODEL, DEFAULT_VENICE_SEED_AUDIO_MODEL, } from '../venice/audio.js';
20
+ import { getMusicModel } from '../venice/models.js';
21
+ import { buildImagePrompt, buildCharacterReferencePromptParts } from './prompt-builder.js';
22
+ import { generateEpisodeVideos } from './video-generator.js';
23
+ import { generateVoiceReference } from './voice-reference.js';
24
+ import { generateLocationReferences } from './location-generator.js';
25
+ import { ensureEpisodeStoryboardReferences, generateStoryboardReference, } from './storyboard-reference-generator.js';
26
+ import { generateSubtitles, saveSrt } from './subtitle-generator.js';
27
+ import { fixPanel, refineWithReferences, refineStyleConsistency } from './panel-fixer.js';
28
+ import { multiEditImage, loadImageAsDataUri } from '../venice/multi-edit.js';
29
+ import { assembleEpisode, collectShotVideos } from './assembler.js';
30
+ import { buildGenerationPlan, saveGenerationPlan } from './generation-planner.js';
31
+ const program = new Command();
32
+ program
33
+ .name('venice-video')
34
+ .description('Standalone consistency-first video production with Venice AI')
35
+ .version('2.4.0')
36
+ .option('--workspace <dir>', 'Workspace containing Venice Video projects');
37
+ function runCommand(command, args) {
38
+ const result = spawnSync(command, args, {
39
+ encoding: 'utf-8',
40
+ stdio: 'pipe',
41
+ });
42
+ if (result.error)
43
+ throw result.error;
44
+ if (result.status !== 0) {
45
+ const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '';
46
+ const stdout = typeof result.stdout === 'string' ? result.stdout.trim() : '';
47
+ const detail = stderr || stdout || `exit code ${result.status}`;
48
+ throw new Error(`${command} failed: ${detail}`);
49
+ }
50
+ return typeof result.stdout === 'string' ? result.stdout : '';
51
+ }
52
+ /**
53
+ * Merge the locations an episode script introduced into the series, and
54
+ * generate reference images for any location that doesn't have them yet.
55
+ * Locations referenced by a shot's `location` slug but absent from the
56
+ * script's `locations[]` are synthesized as description-only stubs (from the
57
+ * shot's description) so every tagged slug resolves. Logs generation cost.
58
+ */
59
+ async function mergeAndGenerateEpisodeLocations(client, series, script) {
60
+ const scriptLocations = script.locations ?? [];
61
+ // Synthesize stubs for slugs tagged on shots but not declared in locations[].
62
+ const declaredSlugs = new Set(scriptLocations.map(l => l.slug));
63
+ const taggedSlugs = new Set(script.shots.map(s => s.location).filter((s) => Boolean(s)));
64
+ for (const slug of taggedSlugs) {
65
+ if (declaredSlugs.has(slug))
66
+ continue;
67
+ if (getLocation(series, slug))
68
+ continue; // already a series location
69
+ const firstShot = script.shots.find(s => s.location === slug);
70
+ scriptLocations.push({
71
+ name: slug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
72
+ slug,
73
+ description: firstShot?.description ?? `The ${slug.replace(/-/g, ' ')} location.`,
74
+ });
75
+ }
76
+ if (scriptLocations.length === 0)
77
+ return;
78
+ const toGenerate = [];
79
+ for (const loc of scriptLocations) {
80
+ const slug = loc.slug || locationSlugify(loc.name);
81
+ const existing = getLocation(series, slug);
82
+ const merged = existing
83
+ ? { ...existing, description: loc.description || existing.description, lightingNotes: loc.lightingNotes ?? existing.lightingNotes }
84
+ : {
85
+ name: loc.name,
86
+ slug,
87
+ description: loc.description,
88
+ ...(loc.lightingNotes ? { lightingNotes: loc.lightingNotes } : {}),
89
+ seed: Math.abs([...slug].reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0)) % 999_999_999,
90
+ };
91
+ addLocation(series, merged);
92
+ const dir = getLocationDir(series, slug);
93
+ const hasRefs = ['wide.png', 'medium.png', 'detail.png'].some(f => existsSync(join(dir, f)));
94
+ if (!hasRefs)
95
+ toGenerate.push(merged);
96
+ }
97
+ if (toGenerate.length === 0)
98
+ return;
99
+ if (!series.aesthetic) {
100
+ console.warn(' ⚠ Locations introduced but series aesthetic is not set — skipping reference generation.');
101
+ return;
102
+ }
103
+ console.log(`\nGenerating reference images for ${toGenerate.length} new location(s) (~$${(toGenerate.length * 3 * 0.04).toFixed(2)} est. — 3 angles each)...`);
104
+ for (const loc of toGenerate) {
105
+ console.log(` Location: ${loc.name} (${loc.slug})`);
106
+ try {
107
+ await generateLocationReferences(client, series, loc);
108
+ }
109
+ catch (err) {
110
+ console.warn(` ⚠ Location reference generation failed for ${loc.name}: ${err.message}`);
111
+ }
112
+ }
113
+ }
114
+ /**
115
+ * Resolve the best on-disk location reference image for a shot, plus a prompt
116
+ * note carrying the location's locked description + lighting. Closer shot
117
+ * types prefer the medium angle; everything else prefers the wide establishing
118
+ * angle. Returns undefined refPath when the shot has no location or no images.
119
+ */
120
+ function resolveLocationRefForShot(series, shot) {
121
+ if (!shot.location)
122
+ return { note: '' };
123
+ const location = getLocation(series, shot.location);
124
+ if (!location)
125
+ return { note: '' };
126
+ const dir = getLocationDir(series, location.slug);
127
+ const closer = shot.type === 'close-up' || shot.type === 'reaction' || shot.type === 'insert';
128
+ const order = closer
129
+ ? ['medium.png', 'wide.png', 'detail.png']
130
+ : ['wide.png', 'medium.png', 'detail.png'];
131
+ const refPath = order.map(f => join(dir, f)).find(p => existsSync(p));
132
+ const note = ` Location: ${location.description}${location.lightingNotes ? ` Lighting: ${location.lightingNotes}.` : ''}`;
133
+ return { location, refPath, note };
134
+ }
135
+ // ── setup / config / doctor / new ─────────────────────────────────────
136
+ program
137
+ .command('setup')
138
+ .description('Configure the Venice API key and default project workspace')
139
+ .option('--api-key <key>', 'Venice API key (prefer the hidden prompt in an interactive terminal)')
140
+ .option('--workspace <dir>', 'Default directory for Venice Video projects')
141
+ .option('--skip-validation', 'Store the API key without contacting Venice', false)
142
+ .action(async (opts) => {
143
+ const current = await readUserConfig();
144
+ const apiKey = opts.apiKey
145
+ ?? process.env.VENICE_API_KEY
146
+ ?? await promptText('Venice API key', { hidden: true, required: true });
147
+ const workspace = opts.workspace
148
+ ?? program.opts().workspace
149
+ ?? await promptText('Default project workspace', {
150
+ defaultValue: current.workspace ?? getDefaultSetupWorkspace(),
151
+ required: true,
152
+ });
153
+ if (!opts.skipValidation) {
154
+ process.stdout.write('Validating API key... ');
155
+ await validateVeniceApiKey(apiKey);
156
+ console.log('ok');
157
+ }
158
+ const resolvedWorkspace = await getWorkspaceDir(workspace);
159
+ await mkdir(resolvedWorkspace, { recursive: true });
160
+ await updateUserConfig({ apiKey, workspace: resolvedWorkspace });
161
+ process.env.VENICE_API_KEY = apiKey;
162
+ console.log('Venice Video is configured.');
163
+ console.log(` API key: ${maskApiKey(apiKey)}`);
164
+ console.log(` Workspace: ${resolvedWorkspace}`);
165
+ console.log(` Config: ${getConfigPath()}`);
166
+ console.log('\nNext: run `venice-video new`.');
167
+ });
168
+ const configCommand = program.command('config').description('Show or change standalone CLI configuration');
169
+ configCommand
170
+ .command('show')
171
+ .description('Show configuration with the API key masked')
172
+ .action(async () => {
173
+ const config = await readUserConfig();
174
+ console.log(`API key: ${maskApiKey(process.env.VENICE_API_KEY || config.apiKey)}`);
175
+ console.log(`Workspace: ${await getWorkspaceDir()}`);
176
+ console.log(`Config file: ${getConfigPath()}`);
177
+ });
178
+ configCommand
179
+ .command('set-workspace <dir>')
180
+ .description('Set the default project workspace')
181
+ .action(async (dir) => {
182
+ const workspace = await getWorkspaceDir(dir);
183
+ await mkdir(workspace, { recursive: true });
184
+ await updateUserConfig({ workspace });
185
+ console.log(`Workspace set to: ${workspace}`);
186
+ });
187
+ configCommand
188
+ .command('unset-api-key')
189
+ .description('Remove the stored API key')
190
+ .action(async () => {
191
+ await updateUserConfig({ apiKey: undefined });
192
+ console.log('Stored API key removed.');
193
+ });
194
+ program
195
+ .command('doctor')
196
+ .description('Check API credentials and local media dependencies')
197
+ .action(async () => {
198
+ let failed = false;
199
+ const checkCommand = (name, args = ['-version']) => {
200
+ const result = spawnSync(name, args, { stdio: 'ignore' });
201
+ const ok = result.status === 0;
202
+ console.log(`${ok ? '✓' : '✗'} ${name}`);
203
+ if (!ok)
204
+ failed = true;
205
+ };
206
+ console.log(`✓ Node ${process.versions.node}`);
207
+ checkCommand('ffmpeg');
208
+ checkCommand('ffprobe');
209
+ try {
210
+ const apiKey = await getVeniceApiKey();
211
+ await validateVeniceApiKey(apiKey);
212
+ console.log(`✓ Venice API key ${maskApiKey(apiKey)}`);
213
+ }
214
+ catch (error) {
215
+ failed = true;
216
+ console.log(`✗ Venice API key: ${error.message}`);
217
+ }
218
+ console.log(`✓ Workspace ${await getWorkspaceDir(program.opts().workspace)}`);
219
+ if (failed)
220
+ process.exitCode = 1;
221
+ });
222
+ program
223
+ .command('new')
224
+ .description('Create a project with an interactive wizard; Film is the general-purpose option')
225
+ .option('--type <type>', 'film | series | product-video | music-video | screenplay')
226
+ .option('-n, --name <name>', 'Project name')
227
+ .option('--concept <concept>', 'Project concept or premise')
228
+ .option('-g, --genre <genre>', 'Genre')
229
+ .option('--setting <setting>', 'General setting description')
230
+ .option('--audio-strategy <strategy>', 'native | lip-sync | narrator-vo')
231
+ .option('--video-family <family>', 'auto | seedance | happyhorse | grok-imagine | kling-o3')
232
+ .action(async (opts) => {
233
+ if (!stdin.isTTY && (!opts.type || !opts.name || !opts.concept)) {
234
+ throw new Error('`venice-video new` needs an interactive terminal, or pass --type, --name, and --concept.');
235
+ }
236
+ const types = ['film', 'series', 'product-video', 'music-video', 'screenplay'];
237
+ const type = (opts.type ?? await promptChoice('What are you making?', [
238
+ { label: 'Film', value: 'film', description: 'A film of any length; multi-shot and continuity-first' },
239
+ { label: 'Series', value: 'series', description: 'Recurring episodes, characters, and locations' },
240
+ { label: 'Product video', value: 'product-video', description: 'Branded launch, demo, or campaign film' },
241
+ { label: 'Music video', value: 'music-video', description: 'Music-led visual production' },
242
+ { label: 'Screenplay', value: 'screenplay', description: 'Start from a Fountain or PDF screenplay' },
243
+ ]));
244
+ if (!types.includes(type))
245
+ throw new Error(`--type must be one of: ${types.join(', ')}`);
246
+ const name = opts.name ?? await promptText('Project name', { required: true });
247
+ const concept = opts.concept ?? await promptText('Concept or premise', { required: true });
248
+ const genre = opts.genre ?? (stdin.isTTY
249
+ ? await promptText('Genre', { defaultValue: 'drama', required: true })
250
+ : 'drama');
251
+ const setting = opts.setting ?? (stdin.isTTY
252
+ ? await promptText('Setting', { defaultValue: '', required: false })
253
+ : '');
254
+ const audioStrategy = (opts.audioStrategy ?? (stdin.isTTY ? await promptChoice('Audio strategy', [
255
+ { label: 'Native model audio', value: 'native', description: 'Generate dialogue and ambience in the video model' },
256
+ { label: 'Lip-sync', value: 'lip-sync', description: 'Use Venice speech plus Wan 2.7 lip-sync' },
257
+ { label: 'Narrator voice-over', value: 'narrator-vo', description: 'Mute model narration and own the VO lane' },
258
+ ]) : 'native'));
259
+ const videoFamily = (opts.videoFamily ?? (stdin.isTTY ? await promptChoice('Video model family', [
260
+ { label: 'Automatic', value: 'auto', description: 'Current reference-first Seedance Enhanced defaults' },
261
+ { label: 'Seedance', value: 'seedance' },
262
+ { label: 'HappyHorse', value: 'happyhorse' },
263
+ { label: 'Grok Imagine', value: 'grok-imagine' },
264
+ { label: 'Kling O3', value: 'kling-o3' },
265
+ ]) : 'auto'));
266
+ const workspace = await getWorkspaceDir(program.opts().workspace);
267
+ await mkdir(workspace, { recursive: true });
268
+ const series = createSeries(name, concept, genre, setting, {
269
+ audioStrategy,
270
+ videoFamilyPreference: videoFamily,
271
+ workspace,
272
+ projectType: type,
273
+ });
274
+ await saveSeries(series);
275
+ console.log(`\n${type === 'film' ? 'Film' : 'Project'} created: ${series.outputDir}`);
276
+ console.log(`Reference-first defaults: ${series.videoDefaults.characterConsistencyModel}`);
277
+ console.log(`Next: venice-video new-episode -p "${series.outputDir}" --title "First episode"`);
278
+ });
279
+ // ── new-series ────────────────────────────────────────────────────────
280
+ program
281
+ .command('new-series')
282
+ .description('Create a new mini-drama series')
283
+ .requiredOption('-n, --name <name>', 'Series name')
284
+ .requiredOption('--concept <concept>', 'Series concept/premise')
285
+ .option('-g, --genre <genre>', 'Genre', 'drama')
286
+ .option('--setting <setting>', 'General setting description', '')
287
+ // ── Upfront questionnaire (production-audit follow-up) ──
288
+ // These two flags let the operator answer the "what kind of show is this?"
289
+ // questions at series-creation time, eliminating three classes of bugs we
290
+ // hit producing the PNW field-guide. The MCP's pipeline skill prompts for
291
+ // them before calling `series.new`.
292
+ .option('--audio-strategy <strategy>', 'How dialogue reaches the final mix: ' +
293
+ '"native" (model speaks in-frame; default; best for 1-2 lines per character), ' +
294
+ '"lip-sync" (Venice TTS + Wan 2.7 lip-sync; best when characters talk often), ' +
295
+ '"narrator-vo" (NARRATOR voice-over only; auto-mutes the model audio so a competing AI narrator can\'t fight the TTS).')
296
+ .option('--video-family <family>', 'Preferred video model family: ' +
297
+ 'auto (default Seedance 2.0), seedance, happyhorse, grok-imagine, kling-o3. ' +
298
+ 'Swaps actionModel/atmosphereModel/characterConsistencyModel to that family. ' +
299
+ 'lipSyncModel stays on Wan 2.7 regardless.')
300
+ .action(async (opts) => {
301
+ const allowedAudio = new Set(['native', 'lip-sync', 'narrator-vo']);
302
+ if (opts.audioStrategy && !allowedAudio.has(opts.audioStrategy)) {
303
+ console.error(`--audio-strategy must be one of: ${[...allowedAudio].join(', ')}`);
304
+ process.exit(2);
305
+ }
306
+ const allowedFamily = new Set(['auto', 'seedance', 'happyhorse', 'grok-imagine', 'kling-o3']);
307
+ if (opts.videoFamily && !allowedFamily.has(opts.videoFamily)) {
308
+ console.error(`--video-family must be one of: ${[...allowedFamily].join(', ')}`);
309
+ process.exit(2);
310
+ }
311
+ const series = createSeries(opts.name, opts.concept, opts.genre, opts.setting, {
312
+ workspace: await getWorkspaceDir(program.opts().workspace),
313
+ audioStrategy: opts.audioStrategy,
314
+ videoFamilyPreference: opts.videoFamily,
315
+ });
316
+ await saveSeries(series);
317
+ console.log(`\nSeries created: ${series.name}`);
318
+ console.log(` Slug: ${series.slug}`);
319
+ console.log(` Genre: ${series.genre}`);
320
+ console.log(` Concept: ${series.concept}`);
321
+ console.log(` Output: ${series.outputDir}`);
322
+ if (series.videoDefaults.audioStrategy) {
323
+ console.log(` Audio strategy: ${series.videoDefaults.audioStrategy}`);
324
+ }
325
+ if (series.videoDefaults.videoFamilyPreference) {
326
+ console.log(` Video family: ${series.videoDefaults.videoFamilyPreference}`);
327
+ console.log(` actionModel: ${series.videoDefaults.actionModel}`);
328
+ console.log(` characterConsistencyModel: ${series.videoDefaults.characterConsistencyModel}`);
329
+ }
330
+ console.log(`\nNext: explore-aesthetic -p ${series.outputDir}`);
331
+ });
332
+ // ── new-episode ──────────────────────────────────────────────────────
333
+ program
334
+ .command('new-episode')
335
+ .description('Scaffold a new episode directory and register it in series.json')
336
+ .requiredOption('-p, --project <dir>', 'Series output directory')
337
+ .requiredOption('-t, --title <title>', 'Episode title')
338
+ .action(async (opts) => {
339
+ const series = await loadSeries(resolve(opts.project));
340
+ if (!series) {
341
+ console.error('Series not found.');
342
+ process.exit(1);
343
+ }
344
+ const episode = addEpisode(series, opts.title);
345
+ const episodeDir = getEpisodeDir(series, episode.number);
346
+ const sceneDir = join(episodeDir, 'scene-001');
347
+ const audioDir = join(episodeDir, 'audio');
348
+ await mkdir(sceneDir, { recursive: true });
349
+ await mkdir(audioDir, { recursive: true });
350
+ const templateScript = {
351
+ episode: episode.number,
352
+ title: opts.title,
353
+ seriesName: series.name,
354
+ totalDuration: '60s',
355
+ shots: [],
356
+ };
357
+ await writeFile(join(episodeDir, 'script.json'), JSON.stringify(templateScript, null, 2), 'utf-8');
358
+ await saveSeries(series);
359
+ console.log(`\nEpisode ${episode.number} created: "${opts.title}"`);
360
+ console.log(` Directory: ${episodeDir}`);
361
+ console.log(` Script: ${join(episodeDir, 'script.json')} (empty template -- workshop your shots)`);
362
+ console.log(`\nNext: workshop your shot-by-shot script, then storyboard-episode -p ${series.outputDir} -e ${episode.number}`);
363
+ });
364
+ // ── list-series ───────────────────────────────────────────────────────
365
+ program
366
+ .command('list-series')
367
+ .description('List all mini-drama series')
368
+ .action(async () => {
369
+ const all = await listSeries(await getWorkspaceDir(program.opts().workspace));
370
+ if (all.length === 0) {
371
+ console.log('No projects found. Create one with: venice-video new');
372
+ return;
373
+ }
374
+ console.log('Venice Video projects:');
375
+ for (const s of all) {
376
+ console.log(` ${s.name} (${s.slug}) -> ${s.dir}`);
377
+ }
378
+ });
379
+ // ── explore-aesthetic ─────────────────────────────────────────────────
380
+ program
381
+ .command('explore-aesthetic')
382
+ .description('Generate aesthetic comparison samples for a series')
383
+ .requiredOption('-p, --project <dir>', 'Series output directory')
384
+ .option('--count <n>', 'Number of aesthetic variants', '5')
385
+ .action(async (opts) => {
386
+ const series = await loadSeries(resolve(opts.project));
387
+ if (!series) {
388
+ console.error('Series not found.');
389
+ process.exit(1);
390
+ }
391
+ const apiKey = await getVeniceApiKey();
392
+ const client = new VeniceClient(apiKey);
393
+ const count = parseInt(opts.count);
394
+ const samplesDir = join(series.outputDir, 'aesthetic-samples');
395
+ await mkdir(samplesDir, { recursive: true });
396
+ const sceneDescription = series.setting || series.concept;
397
+ const aestheticStyles = [
398
+ { name: 'anime-noir', style: 'Dark anime noir', palette: 'high contrast shadows with neon accents', lighting: 'dramatic rim lighting, hard shadows', lens: 'wide-angle with depth', film: 'digital anime rendering with grain' },
399
+ { name: 'manhwa-realism', style: 'Korean manhwa semi-realism', palette: 'rich saturated colors, warm skin tones', lighting: 'soft cinematic lighting with bokeh', lens: 'portrait lens shallow depth of field', film: 'digital illustration with painterly finish' },
400
+ { name: 'retro-anime', style: '90s anime cel-shaded', palette: 'vintage warm tones, sunset palette', lighting: 'flat cel-shading with dramatic highlights', lens: 'standard composition', film: '35mm anime film grain' },
401
+ { name: 'hyper-stylized', style: 'Hyper-stylized digital illustration', palette: 'vibrant pop colors with dark contrasts', lighting: 'dramatic chiaroscuro with color splashes', lens: 'dynamic angles and foreshortening', film: 'clean digital with subtle texture' },
402
+ { name: 'webtoon-drama', style: 'Webtoon drama illustration', palette: 'moody desaturated with selective color', lighting: 'atmospheric with volumetric light', lens: 'cinematic wide and close alternation', film: 'soft digital brushwork' },
403
+ { name: 'neo-baroque', style: 'Neo-baroque dramatic illustration', palette: 'deep golds, crimsons, and midnight blues', lighting: 'Caravaggio-inspired chiaroscuro', lens: 'classical composition', film: 'oil painting texture overlay' },
404
+ { name: 'cyberpunk-anime', style: 'Cyberpunk anime', palette: 'electric blue, magenta, toxic green on black', lighting: 'neon glow with rain reflections', lens: 'dutch angles, extreme perspective', film: 'digital with chromatic aberration' },
405
+ ];
406
+ const selected = aestheticStyles.slice(0, count);
407
+ console.log(`Generating ${selected.length} aesthetic samples...`);
408
+ console.log(`Scene: ${sceneDescription}\n`);
409
+ for (const aes of selected) {
410
+ const prompt = `${sceneDescription}. ${aes.style}, ${aes.palette}, ${aes.lighting}, ${aes.lens}, ${aes.film}. Beautiful elegant woman with hourglass figure and handsome man, dramatic scene.`;
411
+ try {
412
+ const response = await generateImage(client, {
413
+ prompt,
414
+ negative_prompt: 'deformed, blurry, bad anatomy, low quality, text, watermark',
415
+ resolution: '1K',
416
+ aspect_ratio: '9:16',
417
+ steps: 30,
418
+ cfg_scale: 7,
419
+ safe_mode: false,
420
+ hide_watermark: true,
421
+ });
422
+ if (response.images?.[0]) {
423
+ const imgBuffer = Buffer.from(response.images[0].b64_json, 'base64');
424
+ const imgPath = await writeImageBytesSmart(imgBuffer, join(samplesDir, `${aes.name}.png`));
425
+ console.log(` ${aes.name}: ${imgPath}`);
426
+ }
427
+ }
428
+ catch (err) {
429
+ console.warn(` Failed: ${aes.name} - ${err}`);
430
+ }
431
+ }
432
+ const html = generateCompareHtml(selected, series.name);
433
+ const htmlPath = join(samplesDir, 'compare.html');
434
+ await writeFile(htmlPath, html, 'utf-8');
435
+ console.log(`\nComparison page: ${htmlPath}`);
436
+ console.log(`Pick a style and run: set-aesthetic -p ${series.outputDir} --style "..." --palette "..." ...`);
437
+ });
438
+ // ── set-aesthetic ─────────────────────────────────────────────────────
439
+ program
440
+ .command('set-aesthetic')
441
+ .description('Lock the visual aesthetic for the series')
442
+ .requiredOption('-p, --project <dir>', 'Series output directory')
443
+ .requiredOption('--style <style>', 'Visual style')
444
+ .requiredOption('--palette <palette>', 'Color palette')
445
+ .requiredOption('--lighting <lighting>', 'Lighting approach')
446
+ .option('--lens <lens>', 'Lens characteristics', 'cinematic depth of field')
447
+ .option('--film <film>', 'Film stock/texture', 'digital illustration')
448
+ .action(async (opts) => {
449
+ const series = await loadSeries(resolve(opts.project));
450
+ if (!series) {
451
+ console.error('Series not found.');
452
+ process.exit(1);
453
+ }
454
+ const aesthetic = {
455
+ style: opts.style,
456
+ palette: opts.palette,
457
+ lighting: opts.lighting,
458
+ lensCharacteristics: opts.lens,
459
+ filmStock: opts.film,
460
+ };
461
+ series.aesthetic = aesthetic;
462
+ await saveSeries(series);
463
+ console.log('Aesthetic locked:');
464
+ Object.entries(aesthetic).forEach(([k, v]) => console.log(` ${k}: ${v}`));
465
+ console.log(`\nNext: add-character -p ${series.outputDir} --name "CHARACTER" --gender female`);
466
+ });
467
+ // ── add-character ─────────────────────────────────────────────────────
468
+ program
469
+ .command('add-character')
470
+ .description('Add and generate reference images for a character')
471
+ .requiredOption('-p, --project <dir>', 'Series output directory')
472
+ .requiredOption('--name <name>', 'Character name')
473
+ .requiredOption('--gender <gender>', 'Gender (male/female/other)')
474
+ .option('--age <age>', 'Age description', 'mid 20s')
475
+ .option('--description <desc>', 'Physical description')
476
+ .option('--wardrobe <wardrobe>', 'Default wardrobe', 'stylish contextual attire')
477
+ .option('--voice-desc <voiceDesc>', 'Voice description (pitch, timbre, accent, cadence)')
478
+ .option('--base-traits <traits>', 'Custom base traits override (e.g. "tabby cat, feline, four legs")')
479
+ .option('--skip-images', 'Skip reference image generation', false)
480
+ // Override-prompt path: read positive / negative prompts (and optionally
481
+ // model / cfg / aspect) from a JSON file and use them verbatim, bypassing
482
+ // buildCharacterReferencePromptParts entirely. Lets operators rescue a
483
+ // character whose default-prompt outputs keep failing (the LEGISLATOR-as-bird
484
+ // and FOUNDER-identity-drift episodes during the PNW field-guide all needed
485
+ // this). When the file omits a field, the default builder fills it.
486
+ // File shape (any subset valid):
487
+ // {
488
+ // "angles": {
489
+ // "front": { "positive": "...", "negative": "..." },
490
+ // "three-quarter": { ... },
491
+ // "profile": { ... },
492
+ // "full-body": { ... }
493
+ // },
494
+ // "shared": { "model": "seedream-v5-lite", "cfg_scale": 9,
495
+ // "aspect_ratio": "1:1", "resolution": "1K", "seed": 42 }
496
+ // }
497
+ .option('--override-prompt <path>', 'Path to a JSON file containing per-angle prompt overrides (see header comment in cli.ts add-character)')
498
+ .action(async (opts) => {
499
+ const series = await loadSeries(resolve(opts.project));
500
+ if (!series) {
501
+ console.error('Series not found.');
502
+ process.exit(1);
503
+ }
504
+ const baseTraits = opts.baseTraits ?? (opts.gender === 'female' ? FEMALE_BASE_TRAITS : MALE_BASE_TRAITS);
505
+ const physicalDesc = opts.description || `${opts.age}, ${baseTraits}`;
506
+ const defaultVoice = opts.gender === 'female'
507
+ ? 'smooth, confident feminine voice, medium pitch, clear diction, measured pacing'
508
+ : 'deep, resonant masculine voice, low pitch, authoritative tone, steady cadence';
509
+ const voiceDescription = opts.voiceDesc || defaultVoice;
510
+ const seed = Math.abs([...opts.name].reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0)) % 999_999_999;
511
+ const character = {
512
+ name: opts.name.toUpperCase(),
513
+ gender: opts.gender,
514
+ age: opts.age,
515
+ description: physicalDesc,
516
+ fullDescription: `${opts.name}, ${opts.age}, ${physicalDesc}`,
517
+ wardrobe: opts.wardrobe,
518
+ voiceDescription,
519
+ ...(opts.baseTraits ? { baseTraits: opts.baseTraits } : {}),
520
+ locked: false,
521
+ seed,
522
+ };
523
+ addCharacter(series, character);
524
+ if (!opts.skipImages && series.aesthetic) {
525
+ const apiKey = await getVeniceApiKey();
526
+ const client = new VeniceClient(apiKey);
527
+ const charDir = getCharacterDir(series, character.name);
528
+ await mkdir(charDir, { recursive: true });
529
+ let override = {};
530
+ if (opts.overridePrompt) {
531
+ try {
532
+ const raw = await readFile(resolve(opts.overridePrompt), 'utf-8');
533
+ override = JSON.parse(raw);
534
+ console.log(` Using prompt overrides from ${opts.overridePrompt}`);
535
+ }
536
+ catch (err) {
537
+ console.warn(` Failed to read --override-prompt ${opts.overridePrompt}: ${err.message}`);
538
+ }
539
+ }
540
+ const sharedModel = override.shared?.model ?? DEFAULT_IMAGE_GENERATION_MODEL;
541
+ const sharedCfg = override.shared?.cfg_scale ?? 10;
542
+ const sharedAspect = override.shared?.aspect_ratio ?? '1:1';
543
+ const sharedResolution = override.shared?.resolution ?? '1K';
544
+ const sharedSeed = override.shared?.seed ?? seed;
545
+ const angles = ['front', 'three-quarter', 'profile', 'full-body'];
546
+ const filenames = ['front.png', 'three-quarter.png', 'profile.png', 'full-body.png'];
547
+ console.log(`Generating reference images for ${character.name}...`);
548
+ for (let i = 0; i < angles.length; i++) {
549
+ const angle = angles[i];
550
+ const angleOverride = override.angles?.[angle];
551
+ // Default prompt build (still used when override.angles[angle].positive
552
+ // is missing). structured prompt keeps the positive prompt under the
553
+ // model's silent-reject ceiling and pushes style-reminder cues
554
+ // into negative_prompt.
555
+ const { positive: defaultPositive, negativeAdditions } = buildCharacterReferencePromptParts(character, series.aesthetic, angle, {
556
+ model: sharedModel,
557
+ negativePromptStrategy: series.videoDefaults.imageDefaults?.negativePromptStrategy ?? 'auto',
558
+ });
559
+ const prompt = angleOverride?.positive ?? defaultPositive;
560
+ const baseNegatives = [
561
+ 'deformed', 'blurry', 'bad anatomy', 'low quality',
562
+ 'multiple people', 'watermark',
563
+ 'character reference sheet', 'comic panels', 'panel borders',
564
+ ];
565
+ const negativePrompt = angleOverride?.negative
566
+ ?? [...baseNegatives, ...negativeAdditions].join(', ');
567
+ try {
568
+ const response = await generateImage(client, {
569
+ model: sharedModel,
570
+ prompt,
571
+ negative_prompt: negativePrompt,
572
+ resolution: sharedResolution,
573
+ aspect_ratio: sharedAspect,
574
+ steps: 30,
575
+ cfg_scale: sharedCfg,
576
+ seed: sharedSeed,
577
+ safe_mode: false,
578
+ hide_watermark: true,
579
+ });
580
+ if (response.images?.[0]) {
581
+ const imgBuffer = Buffer.from(response.images[0].b64_json, 'base64');
582
+ const finalPath = await writeImageBytesSmart(imgBuffer, join(charDir, filenames[i]));
583
+ console.log(` ${angle}: saved -> ${basename(finalPath)}`);
584
+ // Sidecar: capture the resolved prompt + params for this angle so
585
+ // operators can hand-edit and re-run with --override-prompt.
586
+ const returnedSeed = response.images[0].seed;
587
+ const sidecarPath = join(charDir, `${angle}.prompt.json`);
588
+ const sidecar = {
589
+ character: character.name,
590
+ angle,
591
+ model: sharedModel,
592
+ prompt,
593
+ negative_prompt: negativePrompt,
594
+ cfg_scale: sharedCfg,
595
+ aspect_ratio: sharedAspect,
596
+ resolution: sharedResolution,
597
+ seed: sharedSeed,
598
+ returnedSeed,
599
+ overrideSource: opts.overridePrompt ?? null,
600
+ generatedAt: new Date().toISOString(),
601
+ };
602
+ await writeFile(sidecarPath, JSON.stringify(sidecar, null, 2), 'utf-8');
603
+ // Recipe sidecar (same info, unified format used by every asset
604
+ // in the project — panels, videos, and refs all carry one).
605
+ await appendRecipePass(finalPath, {
606
+ kind: 'generate',
607
+ role: 'identity',
608
+ model: sharedModel,
609
+ label: `character reference (${character.name}, ${angle})`,
610
+ prompt,
611
+ negativePrompt,
612
+ seed: sharedSeed,
613
+ cfgScale: sharedCfg,
614
+ aspectRatio: sharedAspect,
615
+ resolution: sharedResolution,
616
+ extra: returnedSeed !== undefined ? { returnedSeed } : undefined,
617
+ }, { provenance: 'generate', hasFace: true });
618
+ }
619
+ }
620
+ catch (err) {
621
+ console.warn(` ${angle}: failed - ${err}`);
622
+ }
623
+ }
624
+ character.locked = true;
625
+ await writeFile(join(charDir, 'character.json'), JSON.stringify(character, null, 2), 'utf-8');
626
+ }
627
+ await saveSeries(series);
628
+ console.log(`\nCharacter added: ${character.name}`);
629
+ console.log(`Next: audition-voices -p ${series.outputDir} --character "${character.name}"`);
630
+ });
631
+ // ── audition-voices ───────────────────────────────────────────────────
632
+ program
633
+ .command('audition-voices')
634
+ .description('Generate Venice TTS voice samples for a character')
635
+ .requiredOption('-p, --project <dir>', 'Series output directory')
636
+ .requiredOption('-c, --character <name>', 'Character name')
637
+ .option('--sample-text <text>', 'Sample line for audition')
638
+ .option('--count <n>', 'Number of voice candidates', '5')
639
+ .action(async (opts) => {
640
+ const series = await loadSeries(resolve(opts.project));
641
+ if (!series) {
642
+ console.error('Series not found.');
643
+ process.exit(1);
644
+ }
645
+ const char = getCharacter(series, opts.character);
646
+ if (!char) {
647
+ console.error(`Character "${opts.character}" not found.`);
648
+ process.exit(1);
649
+ }
650
+ const apiKey = await getVeniceApiKey();
651
+ const client = new VeniceClient(apiKey);
652
+ const sampleText = opts.sampleText || `You crossed the line tonight. I expected better from you.`;
653
+ console.log(`Loading Venice voice catalog...`);
654
+ const allVoices = await listVoices();
655
+ const gender = char.gender === 'other' ? undefined : char.gender;
656
+ const filtered = filterVoices(allVoices, gender);
657
+ const candidates = filtered.slice(0, parseInt(opts.count));
658
+ console.log(`Found ${filtered.length} matching voices, auditioning ${candidates.length}...`);
659
+ const charDir = getCharacterDir(series, char.name);
660
+ const samplesDir = join(charDir, 'voice-samples');
661
+ const results = await auditionVoices(client, candidates, sampleText, samplesDir);
662
+ console.log(`\nVoice samples saved to: ${samplesDir}`);
663
+ console.log('Listen and pick a voice, then run:');
664
+ console.log(` lock-character -p ${series.outputDir} -c "${char.name}" --voice-id <VOICE_ID>`);
665
+ console.log('\nAvailable voices:');
666
+ for (const r of results) {
667
+ console.log(` ${r.voiceName}: ${r.voiceId}`);
668
+ }
669
+ });
670
+ // ── lock-character ────────────────────────────────────────────────────
671
+ program
672
+ .command('lock-character')
673
+ .description('Finalize a character with selected voice')
674
+ .requiredOption('-p, --project <dir>', 'Series output directory')
675
+ .requiredOption('-c, --character <name>', 'Character name')
676
+ .requiredOption('--voice-id <id>', 'Venice voice ID')
677
+ .option('--voice-name <name>', 'Display name for the voice')
678
+ .option('--voice-reference <file>', 'Path to an operator-supplied voice-donor clip (wav/mp3, normalized to 2-15s) used as reference_audio_urls on Seedance/HappyHorse R2V shots')
679
+ .action(async (opts) => {
680
+ const series = await loadSeries(resolve(opts.project));
681
+ if (!series) {
682
+ console.error('Series not found.');
683
+ process.exit(1);
684
+ }
685
+ const char = getCharacter(series, opts.character);
686
+ if (!char) {
687
+ console.error(`Character "${opts.character}" not found.`);
688
+ process.exit(1);
689
+ }
690
+ char.voiceId = opts.voiceId;
691
+ char.voiceName = opts.voiceName || opts.voiceId;
692
+ char.locked = true;
693
+ if (opts.voiceReference) {
694
+ const apiKey = await getVeniceApiKey();
695
+ const client = new VeniceClient(apiKey);
696
+ const { relPath, model } = await generateVoiceReference(client, series, char, { file: opts.voiceReference });
697
+ char.voiceReferencePath = relPath;
698
+ char.voiceReferenceModel = model;
699
+ console.log(` Voice reference imported: ${relPath} (${model})`);
700
+ }
701
+ const charDir = getCharacterDir(series, char.name);
702
+ if (existsSync(charDir)) {
703
+ await writeFile(join(charDir, 'character.json'), JSON.stringify(char, null, 2), 'utf-8');
704
+ }
705
+ await saveSeries(series);
706
+ console.log(`Character locked: ${char.name}`);
707
+ console.log(` Voice: ${char.voiceName} (${char.voiceId})`);
708
+ });
709
+ // ── generate-voice-reference ──────────────────────────────────────────
710
+ program
711
+ .command('generate-voice-reference')
712
+ .description('Generate (or import) a voice-donor reference clip for a character, used as reference_audio_urls (@AudioN) on Seedance/HappyHorse R2V shots')
713
+ .requiredOption('-p, --project <dir>', 'Series output directory')
714
+ .requiredOption('-c, --character <name>', 'Character name')
715
+ .option('--text <text>', 'Spoken text to render (defaults to a neutral sample steered by voiceDescription)')
716
+ .option('--voice <voice>', 'Named seed-audio voice (defaults to describe-in-prompt steering via voiceDescription)')
717
+ .option('--speed <speed>', 'Playback speed 0.5-2', parseFloat)
718
+ .option('--file <file>', 'Import an operator-supplied clip verbatim instead of generating')
719
+ .option('--model <model>', 'Override the seed-audio model id')
720
+ .action(async (opts) => {
721
+ const series = await loadSeries(resolve(opts.project));
722
+ if (!series) {
723
+ console.error('Series not found.');
724
+ process.exit(1);
725
+ }
726
+ const char = getCharacter(series, opts.character);
727
+ if (!char) {
728
+ console.error(`Character "${opts.character}" not found.`);
729
+ process.exit(1);
730
+ }
731
+ const apiKey = await getVeniceApiKey();
732
+ const client = new VeniceClient(apiKey);
733
+ const { relPath, model } = await generateVoiceReference(client, series, char, {
734
+ text: opts.text,
735
+ voice: opts.voice,
736
+ speed: opts.speed,
737
+ file: opts.file,
738
+ model: opts.model,
739
+ });
740
+ char.voiceReferencePath = relPath;
741
+ char.voiceReferenceModel = model;
742
+ const charDir = getCharacterDir(series, char.name);
743
+ if (existsSync(charDir)) {
744
+ await writeFile(join(charDir, 'character.json'), JSON.stringify(char, null, 2), 'utf-8');
745
+ }
746
+ await saveSeries(series);
747
+ console.log(`\nVoice reference set for ${char.name}: ${relPath} (${model})`);
748
+ });
749
+ // ── add-location ──────────────────────────────────────────────────────
750
+ program
751
+ .command('add-location')
752
+ .description('Add and generate reference images for a location (wide / medium / detail)')
753
+ .requiredOption('-p, --project <dir>', 'Series output directory')
754
+ .requiredOption('--name <name>', 'Location name')
755
+ .requiredOption('--description <desc>', 'Locked prose description of the environment')
756
+ .option('--lighting <notes>', 'Lighting notes carried into every panel prompt for this location')
757
+ .option('--model <model>', 'Image-generation model for the reference angles (default nano-banana-pro)')
758
+ .option('--skip-images', 'Skip reference image generation', false)
759
+ .action(async (opts) => {
760
+ const series = await loadSeries(resolve(opts.project));
761
+ if (!series) {
762
+ console.error('Series not found.');
763
+ process.exit(1);
764
+ }
765
+ const slug = locationSlugify(opts.name);
766
+ const seed = Math.abs([...opts.name].reduce((h, c) => ((h << 5) - h + c.charCodeAt(0)) | 0, 0)) % 999_999_999;
767
+ const location = {
768
+ name: opts.name,
769
+ slug,
770
+ description: opts.description,
771
+ ...(opts.lighting ? { lightingNotes: opts.lighting } : {}),
772
+ seed,
773
+ ...(opts.model ? { referenceModel: opts.model } : {}),
774
+ };
775
+ addLocation(series, location);
776
+ if (!opts.skipImages && series.aesthetic) {
777
+ const apiKey = await getVeniceApiKey();
778
+ const client = new VeniceClient(apiKey);
779
+ console.log(`Generating reference images for location "${location.name}"...`);
780
+ const { generated, skipped } = await generateLocationReferences(client, series, location, {
781
+ model: opts.model,
782
+ });
783
+ console.log(` Generated ${generated.length} angle(s)${skipped.length ? `, skipped ${skipped.length} existing` : ''}`);
784
+ }
785
+ await saveSeries(series);
786
+ console.log(`\nLocation added: ${location.name} (${slug})`);
787
+ });
788
+ // ── generate-location-references ──────────────────────────────────────
789
+ program
790
+ .command('generate-location-references')
791
+ .description('Generate (or regenerate) reference images for an existing location')
792
+ .requiredOption('-p, --project <dir>', 'Series output directory')
793
+ .requiredOption('-l, --location <slugOrName>', 'Location slug or name')
794
+ .option('--model <model>', 'Override the image-generation model')
795
+ .option('--force', 'Regenerate angles that already exist (archives prior versions)', false)
796
+ .action(async (opts) => {
797
+ const series = await loadSeries(resolve(opts.project));
798
+ if (!series) {
799
+ console.error('Series not found.');
800
+ process.exit(1);
801
+ }
802
+ const location = getLocation(series, opts.location);
803
+ if (!location) {
804
+ console.error(`Location "${opts.location}" not found.`);
805
+ process.exit(1);
806
+ }
807
+ const apiKey = await getVeniceApiKey();
808
+ const client = new VeniceClient(apiKey);
809
+ const { generated, skipped } = await generateLocationReferences(client, series, location, {
810
+ model: opts.model,
811
+ force: opts.force,
812
+ });
813
+ console.log(`\nLocation references for ${location.name}: generated ${generated.length}, skipped ${skipped.length}`);
814
+ });
815
+ // ── generate-storyboard-refs ──────────────────────────────────────────
816
+ program
817
+ .command('generate-storyboard-refs')
818
+ .description('Plan + generate composed storyboard blocking plates (multi-character beats) for an episode')
819
+ .requiredOption('-p, --project <dir>', 'Series output directory')
820
+ .requiredOption('-e, --episode <number>', 'Episode number', parseInt)
821
+ .option('--slug <slug>', 'Regenerate only this plate (must already be planned in script.json)')
822
+ .option('--model <model>', 'Override the image-generation model')
823
+ .option('--force', 'Regenerate plates that already exist (archives prior versions)', false)
824
+ .action(async (opts) => {
825
+ const series = await loadSeries(resolve(opts.project));
826
+ if (!series) {
827
+ console.error('Series not found.');
828
+ process.exit(1);
829
+ }
830
+ const script = await loadEpisodeScript(series, opts.episode);
831
+ if (!script) {
832
+ console.error(`Episode ${opts.episode} script not found.`);
833
+ process.exit(1);
834
+ }
835
+ const apiKey = await getVeniceApiKey();
836
+ const client = new VeniceClient(apiKey);
837
+ if (opts.slug) {
838
+ const ref = (script.storyboardRefs ?? []).find(r => r.slug === opts.slug);
839
+ if (!ref) {
840
+ console.error(`Storyboard ref "${opts.slug}" not planned in this episode.`);
841
+ process.exit(1);
842
+ }
843
+ const result = await generateStoryboardReference(client, series, ref, {
844
+ model: opts.model, force: opts.force,
845
+ });
846
+ console.log(`\nStoryboard plate ${result.skipped ? 'reused' : 'generated'}: ${result.path}`);
847
+ }
848
+ else {
849
+ const { generated, skipped } = await ensureEpisodeStoryboardReferences(client, series, script, {
850
+ model: opts.model, force: opts.force,
851
+ });
852
+ console.log(`\nStoryboard plates: generated ${generated.length}, reused ${skipped.length}`);
853
+ for (const ref of script.storyboardRefs ?? []) {
854
+ console.log(` ${ref.slug}: shots ${ref.shotIds.join(', ')} — ${ref.characters.join(' + ')}${ref.location ? ` @ ${ref.location}` : ''}`);
855
+ }
856
+ }
857
+ await saveEpisodeScript(series, script);
858
+ await saveSeries(series);
859
+ });
860
+ // ── workshop-episode ──────────────────────────────────────────────────
861
+ program
862
+ .command('workshop-episode')
863
+ .description('Generate an episode script draft using Venice LLM with full series context')
864
+ .requiredOption('-p, --project <dir>', 'Series output directory')
865
+ .requiredOption('-e, --episode <number>', 'Episode number', parseInt)
866
+ .requiredOption('--concept <text>', 'Episode concept/premise')
867
+ .option('--model <model>', 'Venice chat model', 'llama-3.3-70b')
868
+ .action(async (opts) => {
869
+ const series = await loadSeries(resolve(opts.project));
870
+ if (!series) {
871
+ console.error('Series not found.');
872
+ process.exit(1);
873
+ }
874
+ const apiKey = await getVeniceApiKey();
875
+ const client = new VeniceClient(apiKey);
876
+ // Gather series-level reference docs (*.md in series root)
877
+ const seriesDir = series.outputDir;
878
+ const mdFiles = readdirSync(seriesDir)
879
+ .filter((f) => f.endsWith('.md'))
880
+ .sort();
881
+ let referenceContext = '';
882
+ for (const f of mdFiles) {
883
+ const content = await readFile(join(seriesDir, f), 'utf-8');
884
+ referenceContext += `\n--- ${f} ---\n${content}\n`;
885
+ }
886
+ // Gather prior episode summaries for continuity
887
+ let priorEpisodes = '';
888
+ for (const ep of series.episodes) {
889
+ const script = await loadEpisodeScript(series, ep.number);
890
+ if (script && script.shots.length > 0) {
891
+ const dialogueLines = script.shots
892
+ .filter(s => s.dialogue)
893
+ .map(s => ` ${s.dialogue.character}: "${s.dialogue.line}"`)
894
+ .join('\n');
895
+ priorEpisodes += `\nEpisode ${ep.number} ("${ep.title}"): ${script.shots.length} shots, ${script.totalDuration}\n`;
896
+ if (dialogueLines)
897
+ priorEpisodes += `Key dialogue:\n${dialogueLines}\n`;
898
+ }
899
+ }
900
+ // Build character summaries
901
+ const charSummaries = series.characters.map(c => {
902
+ const baseTraits = c.baseTraits ?? (c.gender === 'female' ? FEMALE_BASE_TRAITS : MALE_BASE_TRAITS);
903
+ return `${c.name} (${c.gender}, ${c.age}): ${baseTraits}. ${c.fullDescription}. Wardrobe: ${c.wardrobe}. Voice: ${c.voiceDescription}`;
904
+ }).join('\n');
905
+ // Build aesthetic summary
906
+ const aestheticStr = series.aesthetic
907
+ ? `Style: ${series.aesthetic.style}\nPalette: ${series.aesthetic.palette}\nLighting: ${series.aesthetic.lighting}\nLens: ${series.aesthetic.lensCharacteristics}\nFilm: ${series.aesthetic.filmStock}`
908
+ : 'No aesthetic locked yet.';
909
+ // Build location summary (existing first-class Location entities). The LLM
910
+ // is asked to reuse these slugs and only introduce new ones when needed.
911
+ const locationSummaries = (series.locations ?? []).length > 0
912
+ ? (series.locations ?? []).map(l => `${l.slug}: ${l.name} — ${l.description}${l.lightingNotes ? ` (lighting: ${l.lightingNotes})` : ''}`).join('\n')
913
+ : 'None defined yet.';
914
+ const systemPrompt = `You are a scriptwriter for the mini-drama series "${series.name}".
915
+
916
+ SERIES CONCEPT: ${series.concept}
917
+ GENRE: ${series.genre}
918
+ SETTING: ${series.setting}
919
+
920
+ AESTHETIC:
921
+ ${aestheticStr}
922
+
923
+ CHARACTERS:
924
+ ${charSummaries}
925
+
926
+ LOCATIONS (existing, reuse these slugs):
927
+ ${locationSummaries}
928
+
929
+ PRIOR EPISODES:
930
+ ${priorEpisodes || 'None yet.'}
931
+
932
+ SERIES REFERENCE DOCUMENTS:
933
+ ${referenceContext || 'None available.'}
934
+
935
+ DIRECT THE SCENE, DON'T DECORATE IT (CRITICAL):
936
+ For every shot, first decide what the beat is DOING — the turn, the point of view, the power, the subtext — and name ONE intention. Then derive camera, lens, light, blocking, performance, and sound from that single intention. Do NOT stack empty "cinematic / epic / beautiful / dramatic / masterpiece / 4k" adjectives — they give the model nothing to serve. A reveal is not framed, lit, blocked, or performed like a goodbye; write the specific answer, not the generic one. Hold ONE directorial voice across every shot of the episode.
937
+ - Decorated (reject): "epic cinematic close-up of a woman reading a letter, emotional, beautiful lighting".
938
+ - Directed (write like this): "Medium close-up, eye-level; she lowers the letter and her hands go still as a slow push-in arrives; soft window light keeps her face plain; near-silence with one chair scrape — the realization lands in the stilled hands, not a word."
939
+ Direct INTENTION / CAMERA / LIGHT / BLOCKING / PERFORMANCE / SOUND only. Do NOT write exhaustive physical character descriptions or reference-image tags into "description" — identity is locked downstream by R2V character references. Name the character and direct what they DO.
940
+ When a take is close but wrong, the fix is one variable at a time (camera OR light OR motion OR framing), not a fresh pile of adjectives. When continuing a story, direct the next beat from what actually ended on screen, not from the original plan.
941
+
942
+ Your task is to write a complete episode script as a JSON object. Follow the exact format below. The script must:
943
+ - Target 58-75 seconds total duration
944
+ - Open with a visual hook in the first 3 seconds
945
+ - End on a beat that makes viewers want the next episode
946
+ - Use one scene, one location, one emotional note
947
+ - Give each shot ONE intention and derive its craft from it (see DIRECT THE SCENE above)
948
+ - Include specific delivery cues for all dialogue (see VOICE DIRECTION below)
949
+ - Use the correct videoModel ("action" for movement/dialogue, "atmosphere" for establishing/static)
950
+ - End with a title card shot (3s, type "insert", FADE transition)
951
+
952
+ SHOT DURATION — PREFER FEWER, LONGER SHOTS (CRITICAL):
953
+ The video models (Seedance 2.0, HappyHorse 1.1, Wan 2.7) all support up to 15 seconds in a single generation, and 15s is the strong default. For a 60-second episode, prefer 4 shots at ~15s each over 10 shots at ~6s. Reasons:
954
+ 1. Identity stays locked longer — every new shot is a fresh generation where character likeness can drift.
955
+ 2. Motion has room to breathe — short shots cut before gestures/expressions complete, which is one of the main "AI video looks twitchy" tells.
956
+ 3. Cost is lower — fewer generations per episode.
957
+ 4. Fewer transitions to police for continuity.
958
+ Only use shorts (3-8s) for *deliberate* short beats: hard cuts, sight gags, single-frame reactions, the closing title card. Default everything else to 12-15s.
959
+ - duration must be one of: "3s","4s","5s","6s","7s","8s","9s","10s","11s","12s","13s","14s","15s" (HappyHorse goes down to 3s; Seedance from 4s).
960
+ - Aim for the episode to contain roughly (target_seconds / 13) shots ± 1.
961
+
962
+ VOICE DIRECTION — NATIVE MODEL DIALOGUE IS PREFERRED:
963
+ The recommended pipeline uses the video model's own native dialogue (Seedance / Wan 2.7 / HappyHorse all generate in-character speech). Venice TTS is an exception path, not the default. Therefore every dialogue shot's "delivery" cue must be RICH — direct the voice like you're talking to a voice actor: timbre, accent, pacing, emotional register, breath placement, signature delivery quirks. Two-word "delivery": "angry" cues produce flat results; "delivery": "deliberate, half-volume drawl with a beat before the punchline; warm not bitter; breath audible before 'audacity'" produces in-character results.
964
+
965
+ NO MUSIC / NO SFX FROM THE VIDEO MODEL:
966
+ Every shot "description" MUST end with the literal phrase: "No background music, no sound effects, no soundtrack, dry recording." The harness adds music and ambient/SFX in post via separate Venice audio calls; baked-in music or SFX from the video model fights the assembler's mix. The "sfx" field in the schema below describes what the harness should generate in post — it does NOT instruct the video model to produce sound effects.
967
+
968
+ LOCATIONS — TAG EVERY SHOT WITH A LOCATION:
969
+ Define the physical place(s) this episode happens in as first-class locations, and tag every shot with the location it plays in. Locations anchor the environment across shots (consistent architecture, set dressing, and lighting) the same way character references anchor identity.
970
+ - Emit a top-level "locations" array. Each entry: {"name": "<Display Name>", "slug": "<kebab-case-slug>", "description": "<locked prose description of the environment — architecture, materials, set dressing, scale>", "lightingNotes": "<the established lighting for this place>"}.
971
+ - REUSE the existing location slugs listed above when the scene is in a place already defined; only introduce a new location entry when the place is genuinely new.
972
+ - Give every shot a "location" field set to the slug of the location it plays in.
973
+ - Since an episode uses "one scene, one location" by default, you will usually define exactly ONE location and tag all shots with its slug.
974
+
975
+ IMPORTANT: Every shot MUST include an "environment" field. This controls whether the pipeline uses the series' dark/rainy aesthetic or adapts it for bright daytime scenes. Values:
976
+ - "DAY_INTERIOR" -- bright indoor scene (café, office, apartment in daylight)
977
+ - "DAY_EXTERIOR" -- bright outdoor scene (street, park in daylight)
978
+ - "NIGHT_INTERIOR" -- indoor scene at night (club, bar, dimly lit room)
979
+ - "NIGHT_EXTERIOR" -- outdoor nighttime scene (street at night, rooftop at night)
980
+
981
+ Respond with ONLY valid JSON matching this exact schema (no markdown, no code fences, no explanation):
982
+ {
983
+ "episode": <number>,
984
+ "title": "<title>",
985
+ "seriesName": "${series.name}",
986
+ "totalDuration": "<estimated total>",
987
+ "status": "draft",
988
+ "locations": [
989
+ {"name": "<Display Name>", "slug": "<kebab-case-slug>", "description": "<locked environment description>", "lightingNotes": "<established lighting>"}
990
+ ],
991
+ "shots": [
992
+ {
993
+ "shotNumber": 1,
994
+ "type": "establishing|dialogue|action|reaction|close-up|insert",
995
+ "environment": "DAY_INTERIOR|DAY_EXTERIOR|NIGHT_INTERIOR|NIGHT_EXTERIOR",
996
+ "location": "<slug of a location defined in the top-level locations array>",
997
+ "duration": "3s|4s|...|15s (PREFER 15s; use shorts only for deliberate quick beats)",
998
+ "videoModel": "action|atmosphere",
999
+ "description": "<full visual description, ending with 'No background music, no sound effects, no soundtrack, dry recording.'>",
1000
+ "panelDescription": "<optional single-frame description if description has sequential action>",
1001
+ "characters": ["<CHARACTER_NAME>"],
1002
+ "dialogue": {"character": "<NAME>", "line": "<text>", "delivery": "<rich voice-director cue: timbre, accent, pacing, emotion, breath, signature quirks>"} or null,
1003
+ "sfx": "<sound effects to GENERATE IN POST via Venice SFX>" or null,
1004
+ "cameraMovement": "<camera direction>",
1005
+ "transition": "CUT|FADE|DISSOLVE|MATCH CUT|SMASH CUT"
1006
+ }
1007
+ ]
1008
+ }`;
1009
+ const userPrompt = `Write Episode ${opts.episode} with this concept: ${opts.concept}`;
1010
+ console.log(`Workshop: Generating script draft for Episode ${opts.episode}...`);
1011
+ console.log(` Concept: ${opts.concept}`);
1012
+ console.log(` Model: ${opts.model}`);
1013
+ console.log(` Reference docs: ${mdFiles.length} (${mdFiles.join(', ') || 'none'})`);
1014
+ console.log(` Prior episodes: ${series.episodes.length}\n`);
1015
+ try {
1016
+ const response = await client.post('/api/v1/chat/completions', {
1017
+ model: opts.model,
1018
+ messages: [
1019
+ { role: 'system', content: systemPrompt },
1020
+ { role: 'user', content: userPrompt },
1021
+ ],
1022
+ max_tokens: 8000,
1023
+ temperature: 0.7,
1024
+ });
1025
+ const raw = response.choices?.[0]?.message?.content ?? '';
1026
+ const jsonStr = raw.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
1027
+ let script;
1028
+ try {
1029
+ script = JSON.parse(jsonStr);
1030
+ }
1031
+ catch (parseErr) {
1032
+ console.error('Failed to parse LLM response as JSON.');
1033
+ console.error('Raw response (first 2000 chars):');
1034
+ console.error(jsonStr.slice(0, 2000));
1035
+ const dumpPath = join(getEpisodeDir(series, opts.episode), 'workshop-raw-response.txt');
1036
+ await mkdir(getEpisodeDir(series, opts.episode), { recursive: true });
1037
+ await writeFile(dumpPath, raw, 'utf-8');
1038
+ console.error(`\nFull response saved to: ${dumpPath}`);
1039
+ process.exit(1);
1040
+ }
1041
+ script.episode = opts.episode;
1042
+ script.seriesName = series.name;
1043
+ script.status = 'draft';
1044
+ if (!script.shots || script.shots.length === 0) {
1045
+ console.error('LLM returned a script with no shots. Try again or adjust the concept.');
1046
+ process.exit(1);
1047
+ }
1048
+ // Ensure episode exists in series.json
1049
+ if (!series.episodes.find(ep => ep.number === opts.episode)) {
1050
+ addEpisode(series, script.title || `Episode ${opts.episode}`);
1051
+ }
1052
+ // Merge any locations the LLM introduced into the series, then generate
1053
+ // reference images for locations that don't have them yet. Locations
1054
+ // tagged on shots but missing from the script's locations[] are also
1055
+ // synthesized as stubs so every referenced slug resolves.
1056
+ await mergeAndGenerateEpisodeLocations(client, series, script);
1057
+ // Plan storyboard blocking plates per scene beat (multi-character runs
1058
+ // in the same location) and generate them now so the operator can QA
1059
+ // the blocking alongside the script draft. Assignments land on
1060
+ // shot.storyboardRef; plates go to storyboards/<slug>.png.
1061
+ try {
1062
+ const { generated, skipped } = await ensureEpisodeStoryboardReferences(client, series, script);
1063
+ if (generated.length > 0 || skipped.length > 0) {
1064
+ console.log(` Storyboard blocking plates: ${generated.length} generated, ${skipped.length} reused.`);
1065
+ }
1066
+ }
1067
+ catch (err) {
1068
+ console.warn(` ⚠ Storyboard plate pass failed: ${err.message}`);
1069
+ }
1070
+ const savedPath = await saveEpisodeScript(series, script);
1071
+ await saveSeries(series);
1072
+ const totalDurationSec = script.shots.reduce((sum, s) => {
1073
+ const match = s.duration?.match(/(\d+)/);
1074
+ return sum + (match ? parseInt(match[1], 10) : 5);
1075
+ }, 0);
1076
+ console.log(`Draft saved: ${savedPath}`);
1077
+ console.log(` Title: "${script.title}"`);
1078
+ console.log(` Shots: ${script.shots.length}`);
1079
+ console.log(` Duration: ~${totalDurationSec}s`);
1080
+ console.log(` Status: draft`);
1081
+ // Post-condition advisory: the new system prompt asks for 15s-leaning
1082
+ // shots, but LLMs sometimes ignore that and produce many shorts. We
1083
+ // surface it so the user (and the MCP) sees the warning instead of
1084
+ // silently shipping a draft with 10x 6s beats.
1085
+ const shotsUnder8s = script.shots.filter((s) => {
1086
+ const m = s.duration?.match(/(\d+)\s*s/);
1087
+ return m ? parseInt(m[1], 10) < 8 : false;
1088
+ });
1089
+ if (script.shots.length > Math.max(6, Math.ceil(totalDurationSec / 13)) && shotsUnder8s.length >= 3) {
1090
+ console.warn(` ⚠ The draft has ${script.shots.length} shots for ~${totalDurationSec}s ` +
1091
+ `(${shotsUnder8s.length} are under 8s). Recommended target: ~${Math.max(2, Math.round(totalDurationSec / 13))} shots ` +
1092
+ `with most at 12-15s. Edit script.json before approving or re-run workshop with stronger guidance in the concept.`);
1093
+ }
1094
+ const shotsWithoutAudioNegative = script.shots.filter((s) => !s.description || !/no\s+(music|background music|soundtrack|sound effects|sfx)/i.test(s.description));
1095
+ if (shotsWithoutAudioNegative.length > 0) {
1096
+ console.warn(` ⚠ ${shotsWithoutAudioNegative.length} shot(s) are missing the no-music/no-SFX negative in their description. ` +
1097
+ `The video model may bake music or sound effects into the dialogue track. ` +
1098
+ `The harness will still suppress these via its NEGATIVE_PROMPT, but the script LLM should also include them per the workshop instructions.`);
1099
+ }
1100
+ const dialogueShots = script.shots.filter(s => s.dialogue);
1101
+ if (dialogueShots.length > 0) {
1102
+ console.log(`\nDialogue preview:`);
1103
+ for (const s of dialogueShots) {
1104
+ console.log(` Shot ${s.shotNumber}: ${s.dialogue.character}: "${s.dialogue.line}"`);
1105
+ }
1106
+ }
1107
+ console.log(`\nReview the script, iterate as needed, then approve:`);
1108
+ console.log(` approve-script -p ${series.outputDir} -e ${opts.episode}`);
1109
+ }
1110
+ catch (err) {
1111
+ console.error(`Workshop failed: ${err}`);
1112
+ process.exit(1);
1113
+ }
1114
+ });
1115
+ // ── storyboard-episode ────────────────────────────────────────────────
1116
+ program
1117
+ .command('storyboard-episode')
1118
+ .description('Generate storyboard panel images from an episode script')
1119
+ .requiredOption('-p, --project <dir>', 'Series output directory')
1120
+ .requiredOption('-e, --episode <number>', 'Episode number', parseInt)
1121
+ .option('--no-refine', 'Skip the multi-edit refinement pass (refinement is ON by default)')
1122
+ .option('--edit-model <model>', 'Model for multi-edit refinement (default: nano-banana-2-edit)', DEFAULT_IMAGE_EDIT_MODEL)
1123
+ .option('--cfg-scale <number>', 'Prompt adherence (1-10, higher = stricter)', parseFloat)
1124
+ .option('--debug', 'Save prompt payloads as shot-NNN.prompt.json for debugging', false)
1125
+ .option('--skip-approval', 'Skip script approval check', false)
1126
+ .option('--force', 'Regenerate all panels, ignoring any that already exist', false)
1127
+ .action(async (opts) => {
1128
+ const series = await loadSeries(resolve(opts.project));
1129
+ if (!series) {
1130
+ console.error('Series not found.');
1131
+ process.exit(1);
1132
+ }
1133
+ const script = await loadEpisodeScript(series, opts.episode);
1134
+ if (!script) {
1135
+ console.error(`Episode ${opts.episode} script not found.`);
1136
+ process.exit(1);
1137
+ }
1138
+ if (!series.aesthetic) {
1139
+ console.error('Set aesthetic first.');
1140
+ process.exit(1);
1141
+ }
1142
+ const episodeDir = getEpisodeDir(series, opts.episode);
1143
+ const scriptApprovedPath = join(episodeDir, 'script-approved.json');
1144
+ if (!opts.skipApproval && script.status !== 'approved' && !existsSync(scriptApprovedPath)) {
1145
+ console.error('Script must be approved before storyboarding.');
1146
+ console.error('Review the script, then run: approve-script -p <project> -e <episode>');
1147
+ console.error('Or bypass with: storyboard-episode ... --skip-approval');
1148
+ process.exit(1);
1149
+ }
1150
+ const cfgScale = opts.cfgScale ?? 10;
1151
+ const apiKey = await getVeniceApiKey();
1152
+ const client = new VeniceClient(apiKey);
1153
+ const sceneDir = join(episodeDir, 'scene-001');
1154
+ await mkdir(sceneDir, { recursive: true });
1155
+ console.log(`Generating storyboard for Episode ${opts.episode}: ${script.title}`);
1156
+ console.log(`${script.shots.length} shots to generate`);
1157
+ console.log(` cfg_scale: ${cfgScale} | seed: ${series.aestheticSeed ?? 'random'} | refine: ${opts.refine}\n`);
1158
+ // ── Pass 1: Generate base panels ──────────────────────────────────
1159
+ console.log('Pass 1: Generating base panels...\n');
1160
+ const newlyGenerated = new Set();
1161
+ const totalShots = script.shots.length;
1162
+ let generatedCount = 0;
1163
+ let skippedCount = 0;
1164
+ const pass1Start = Date.now();
1165
+ const shotTimes = [];
1166
+ for (let shotIdx = 0; shotIdx < totalShots; shotIdx++) {
1167
+ const shot = script.shots[shotIdx];
1168
+ const shotNum = String(shot.shotNumber).padStart(3, '0');
1169
+ const imgPath = join(sceneDir, `shot-${shotNum}.png`);
1170
+ const progress = `[${shotIdx + 1}/${totalShots}]`;
1171
+ if (existsSync(imgPath) && !opts.force) {
1172
+ skippedCount++;
1173
+ console.log(` ${progress} Shot ${shotNum}: already exists, skipping`);
1174
+ continue;
1175
+ }
1176
+ // Archive existing panel before overwriting (--force mode)
1177
+ if (existsSync(imgPath) && opts.force) {
1178
+ const archivePath = imgPath.replace(/\.png$/, `-force-archive-${Date.now()}.png`);
1179
+ const { rename: renameFile } = await import('node:fs/promises');
1180
+ await renameFile(imgPath, archivePath);
1181
+ }
1182
+ const imagePrompt = buildImagePrompt(shot, series);
1183
+ // Fold in the shot's location: inject its locked description + lighting
1184
+ // into the panel prompt (anti-pattern 7) and use its reference image as
1185
+ // an environment anchor alongside character faces.
1186
+ const locInfo = resolveLocationRefForShot(series, shot);
1187
+ const effectivePrompt = imagePrompt.prompt + locInfo.note;
1188
+ if (opts.debug) {
1189
+ const debugPath = join(sceneDir, `shot-${shotNum}.prompt.json`);
1190
+ await writeFile(debugPath, JSON.stringify({
1191
+ shotNumber: shot.shotNumber,
1192
+ type: shot.type,
1193
+ characters: shot.characters,
1194
+ location: shot.location ?? null,
1195
+ locationRef: locInfo.refPath ?? null,
1196
+ prompt: effectivePrompt,
1197
+ negativePrompt: imagePrompt.negativePrompt,
1198
+ seed: imagePrompt.seed,
1199
+ cfgScale,
1200
+ generatedAt: new Date().toISOString(),
1201
+ }, null, 2), 'utf-8');
1202
+ }
1203
+ const shotStart = Date.now();
1204
+ try {
1205
+ const storyboardAR = series.storyboardAspectRatio ?? '16:9';
1206
+ let imgBuffer;
1207
+ // For character shots, use generateWithReferences for identity anchoring.
1208
+ // Panels used to force seedream-v5-lite whenever a character was present,
1209
+ // because Seedance 2.0 rejected face-bearing images from other families.
1210
+ // Venice removed that restriction (2026-07), so ALL panels — character
1211
+ // and faceless alike — use the operator's imageDefaults.generationModel
1212
+ // (default nano-banana-2), the higher-quality general default.
1213
+ const hasChars = shot.characters && shot.characters.length > 0;
1214
+ const panelModel = series.videoDefaults.imageDefaults?.generationModel
1215
+ ?? DEFAULT_IMAGE_GENERATION_MODEL;
1216
+ const charRefPaths = [];
1217
+ if (hasChars) {
1218
+ const charRefs = shot.characters
1219
+ .map(name => {
1220
+ const char = series.characters.find(c => c.name.toUpperCase() === name.toUpperCase());
1221
+ if (!char)
1222
+ return null;
1223
+ const charDir = getCharacterDir(series, char.name);
1224
+ const frontPath = join(charDir, 'front.png');
1225
+ if (!existsSync(frontPath))
1226
+ return null;
1227
+ charRefPaths.push(frontPath);
1228
+ return {
1229
+ name: char.name,
1230
+ role: char.description.slice(0, 80),
1231
+ base64Image: readFileSync(frontPath).toString('base64'),
1232
+ };
1233
+ })
1234
+ .filter(Boolean);
1235
+ // Location environment reference: appended AFTER the face refs so it
1236
+ // never consumes a face slot (faceSlots stays = character count).
1237
+ // generateWithReferences concatenates all refs; the extra one is used
1238
+ // as a general environment/style anchor.
1239
+ const charRefsWithLocation = [...charRefs];
1240
+ let locationPromptSuffix = '';
1241
+ if (locInfo.refPath) {
1242
+ charRefsWithLocation.push({
1243
+ name: 'LOCATION',
1244
+ role: 'environment reference — setting, architecture, lighting',
1245
+ base64Image: readFileSync(locInfo.refPath).toString('base64'),
1246
+ });
1247
+ charRefPaths.push(locInfo.refPath);
1248
+ locationPromptSuffix = ` The final reference image is the location environment — match its setting, architecture, and lighting; it is not a character.`;
1249
+ }
1250
+ if (charRefs.length > 0) {
1251
+ const result = await generateWithReferences(client, {
1252
+ model: panelModel,
1253
+ prompt: effectivePrompt + locationPromptSuffix,
1254
+ negative_prompt: imagePrompt.negativePrompt,
1255
+ resolution: '1K',
1256
+ aspect_ratio: storyboardAR,
1257
+ steps: 30,
1258
+ cfg_scale: cfgScale,
1259
+ seed: imagePrompt.seed,
1260
+ safe_mode: false,
1261
+ hide_watermark: true,
1262
+ referenceImages: charRefsWithLocation,
1263
+ faceSlots: Math.min(charRefs.length, 2),
1264
+ });
1265
+ imgBuffer = Buffer.from(result.base64, 'base64');
1266
+ }
1267
+ else {
1268
+ const response = await generateImage(client, {
1269
+ model: panelModel,
1270
+ prompt: effectivePrompt,
1271
+ negative_prompt: imagePrompt.negativePrompt,
1272
+ resolution: '1K',
1273
+ aspect_ratio: storyboardAR,
1274
+ steps: 30,
1275
+ cfg_scale: cfgScale,
1276
+ seed: imagePrompt.seed,
1277
+ safe_mode: false,
1278
+ hide_watermark: true,
1279
+ });
1280
+ imgBuffer = Buffer.from(response.images[0].b64_json, 'base64');
1281
+ }
1282
+ }
1283
+ else if (locInfo.refPath) {
1284
+ // No characters, but a location ref exists — anchor the establishing
1285
+ // panel to the location environment via generateWithReferences
1286
+ // (faceSlots 0 → the ref is a pure environment/style anchor).
1287
+ charRefPaths.push(locInfo.refPath);
1288
+ const result = await generateWithReferences(client, {
1289
+ model: panelModel,
1290
+ prompt: effectivePrompt + ` This reference image is the location environment — match its setting, architecture, and lighting.`,
1291
+ negative_prompt: imagePrompt.negativePrompt,
1292
+ resolution: '1K',
1293
+ aspect_ratio: storyboardAR,
1294
+ steps: 30,
1295
+ cfg_scale: cfgScale,
1296
+ seed: imagePrompt.seed,
1297
+ safe_mode: false,
1298
+ hide_watermark: true,
1299
+ referenceImages: [{
1300
+ name: 'LOCATION',
1301
+ role: 'environment reference',
1302
+ base64Image: readFileSync(locInfo.refPath).toString('base64'),
1303
+ }],
1304
+ faceSlots: 0,
1305
+ });
1306
+ imgBuffer = Buffer.from(result.base64, 'base64');
1307
+ }
1308
+ else {
1309
+ const response = await generateImage(client, {
1310
+ model: panelModel,
1311
+ prompt: effectivePrompt,
1312
+ negative_prompt: imagePrompt.negativePrompt,
1313
+ resolution: '1K',
1314
+ aspect_ratio: storyboardAR,
1315
+ steps: 30,
1316
+ cfg_scale: cfgScale,
1317
+ seed: imagePrompt.seed,
1318
+ safe_mode: false,
1319
+ hide_watermark: true,
1320
+ });
1321
+ imgBuffer = Buffer.from(response.images[0].b64_json, 'base64');
1322
+ }
1323
+ if (imgBuffer) {
1324
+ await writeFile(imgPath, imgBuffer);
1325
+ // Venice can return WebP internally disguised as PNG -- convert immediately.
1326
+ try {
1327
+ const isWebp = imgBuffer.length >= 12 &&
1328
+ imgBuffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
1329
+ imgBuffer.subarray(8, 12).toString('ascii') === 'WEBP';
1330
+ if (isWebp) {
1331
+ const tmpPath = imgPath.replace(/\.png$/, '-webp-conv.png');
1332
+ runCommand('ffmpeg', ['-i', imgPath, '-y', tmpPath]);
1333
+ const { renameSync } = await import('node:fs');
1334
+ renameSync(tmpPath, imgPath);
1335
+ }
1336
+ }
1337
+ catch { /* conversion is best-effort */ }
1338
+ // Record provenance + recipe — `panelModel` was chosen above based
1339
+ // on whether this shot has characters. `hasFace` is true when the
1340
+ // shot has named (non-silhouette) characters; Seedance only
1341
+ // gates face-bearing images. The recipe entry makes this pass
1342
+ // replayable by a finishing agent (model/prompt/seed/cfg/refs).
1343
+ await appendRecipePass(imgPath, {
1344
+ kind: 'generate',
1345
+ role: 'content',
1346
+ model: panelModel,
1347
+ label: 'base panel',
1348
+ prompt: effectivePrompt,
1349
+ negativePrompt: imagePrompt.negativePrompt,
1350
+ seed: imagePrompt.seed,
1351
+ cfgScale,
1352
+ aspectRatio: storyboardAR,
1353
+ resolution: '1K',
1354
+ referenceImagePaths: charRefPaths.length > 0 ? charRefPaths : undefined,
1355
+ }, { provenance: 'generate', hasFace: hasChars });
1356
+ newlyGenerated.add(shot.shotNumber);
1357
+ generatedCount++;
1358
+ const elapsed = ((Date.now() - shotStart) / 1000).toFixed(1);
1359
+ shotTimes.push(Date.now() - shotStart);
1360
+ const avgTime = shotTimes.reduce((a, b) => a + b, 0) / shotTimes.length;
1361
+ const remaining = totalShots - shotIdx - 1 - skippedCount;
1362
+ const eta = remaining > 0 ? ` | ETA ~${Math.ceil((avgTime * remaining) / 60000)}min` : '';
1363
+ console.log(` ${progress} Shot ${shotNum}: saved (${elapsed}s${eta})`);
1364
+ }
1365
+ }
1366
+ catch (err) {
1367
+ console.warn(` ${progress} Shot ${shotNum}: FAILED - ${err}`);
1368
+ }
1369
+ }
1370
+ const pass1Elapsed = ((Date.now() - pass1Start) / 1000).toFixed(0);
1371
+ console.log(`\nPass 1 complete: ${generatedCount} generated, ${skippedCount} skipped (${pass1Elapsed}s total)`);
1372
+ // ── Pass 2: Refine with multi-edit ────────────────────────────────
1373
+ if (opts.refine) {
1374
+ const editModel = opts.editModel;
1375
+ console.log(`\nPass 2: Refining with multi-edit (${editModel})...`);
1376
+ // Save a snapshot of the first character shot BEFORE refinement to use as style anchor.
1377
+ // Post-refinement panels can inherit layout artifacts from character reference sheets,
1378
+ // which would contaminate non-character shots during style-matching.
1379
+ const firstCharShot = script.shots.find(s => s.characters.length > 0);
1380
+ let styleAnchorPath;
1381
+ if (firstCharShot) {
1382
+ const firstCharShotPath = join(sceneDir, `shot-${String(firstCharShot.shotNumber).padStart(3, '0')}.png`);
1383
+ if (existsSync(firstCharShotPath)) {
1384
+ styleAnchorPath = join(sceneDir, '.style-anchor.png');
1385
+ await copyFile(firstCharShotPath, styleAnchorPath);
1386
+ }
1387
+ }
1388
+ const charShots = script.shots.filter(s => s.characters.length > 0);
1389
+ const nonCharShots = script.shots.filter(s => s.characters.length === 0);
1390
+ const refinableShots = [...charShots, ...nonCharShots];
1391
+ const totalRefinable = refinableShots.length;
1392
+ let refineIdx = 0;
1393
+ const pass2Start = Date.now();
1394
+ for (const shot of charShots) {
1395
+ refineIdx++;
1396
+ const shotNum = String(shot.shotNumber).padStart(3, '0');
1397
+ const imgPath = join(sceneDir, `shot-${shotNum}.png`);
1398
+ const progress = `[${refineIdx}/${totalRefinable}]`;
1399
+ if (!existsSync(imgPath))
1400
+ continue;
1401
+ if (shot.skipRefine) {
1402
+ console.log(` ${progress} Shot ${shotNum}: refinement disabled (skipRefine), skipping`);
1403
+ continue;
1404
+ }
1405
+ const preFixPath = join(sceneDir, `shot-${shotNum}-pre-fix.png`);
1406
+ if (existsSync(preFixPath) && !newlyGenerated.has(shot.shotNumber)) {
1407
+ console.log(` ${progress} Shot ${shotNum}: already refined, skipping`);
1408
+ continue;
1409
+ }
1410
+ const refStart = Date.now();
1411
+ try {
1412
+ const locRef = resolveLocationRefForShot(series, shot).refPath;
1413
+ await refineWithReferences(client, series, imgPath, shot, editModel, locRef);
1414
+ const elapsed = ((Date.now() - refStart) / 1000).toFixed(1);
1415
+ console.log(` ${progress} Shot ${shotNum}: character-refined (${elapsed}s)`);
1416
+ }
1417
+ catch (err) {
1418
+ console.warn(` ${progress} Shot ${shotNum}: refinement FAILED - ${err}`);
1419
+ }
1420
+ }
1421
+ for (const shot of nonCharShots) {
1422
+ refineIdx++;
1423
+ const shotNum = String(shot.shotNumber).padStart(3, '0');
1424
+ const imgPath = join(sceneDir, `shot-${shotNum}.png`);
1425
+ const progress = `[${refineIdx}/${totalRefinable}]`;
1426
+ if (!existsSync(imgPath))
1427
+ continue;
1428
+ if (shot.skipRefine) {
1429
+ console.log(` ${progress} Shot ${shotNum}: refinement disabled (skipRefine), skipping`);
1430
+ continue;
1431
+ }
1432
+ const preStylePath = join(sceneDir, `shot-${shotNum}-pre-style.png`);
1433
+ if (existsSync(preStylePath) && !newlyGenerated.has(shot.shotNumber)) {
1434
+ console.log(` ${progress} Shot ${shotNum}: already style-matched, skipping`);
1435
+ continue;
1436
+ }
1437
+ // Prefer the shot's location reference as the environment/style anchor
1438
+ // (keeps every shot in a place looking like that place); fall back to
1439
+ // the episode's character-shot style anchor when there's no location.
1440
+ const locRef = resolveLocationRefForShot(series, shot).refPath;
1441
+ const anchorForShot = locRef ?? styleAnchorPath;
1442
+ if (anchorForShot && existsSync(anchorForShot)) {
1443
+ const refStart = Date.now();
1444
+ try {
1445
+ const aestheticStr = [
1446
+ series.aesthetic.style,
1447
+ series.aesthetic.palette,
1448
+ series.aesthetic.lighting,
1449
+ ].join(', ');
1450
+ await refineStyleConsistency(client, imgPath, anchorForShot, aestheticStr, editModel, shot.environment);
1451
+ const elapsed = ((Date.now() - refStart) / 1000).toFixed(1);
1452
+ console.log(` ${progress} Shot ${shotNum}: style-refined (${elapsed}s${locRef ? ', location anchor' : ''})`);
1453
+ }
1454
+ catch (err) {
1455
+ console.warn(` ${progress} Shot ${shotNum}: refinement FAILED - ${err}`);
1456
+ }
1457
+ }
1458
+ }
1459
+ const pass2Elapsed = ((Date.now() - pass2Start) / 1000).toFixed(0);
1460
+ console.log(`\nPass 2 complete (${pass2Elapsed}s total)`);
1461
+ // NOTE: the style anchor (.style-anchor.png) is intentionally KEPT on
1462
+ // disk. Recipe sidecars reference it as the style-match input, and any
1463
+ // finishing pass that needs to match the episode look (new shots,
1464
+ // regens, polish edits) should anchor against the same file.
1465
+ }
1466
+ // ── Pass 3: Scene-ref injection for shots with sceneImagePaths ────────
1467
+ // buildImagePrompt() is text-only and cannot embed reference images.
1468
+ // For shots that have sceneImagePaths (e.g. Venice logo on the monolith),
1469
+ // we run a dedicated multi-edit pass that injects the logo/scene image
1470
+ // as a reference so the model visually integrates it into the panel.
1471
+ const scenePropShots = script.shots.filter(s => s.sceneImagePaths && s.sceneImagePaths.length > 0);
1472
+ if (scenePropShots.length > 0) {
1473
+ const sceneEditModel = opts.editModel;
1474
+ console.log(`\nPass 3: Injecting scene references into ${scenePropShots.length} shots...`);
1475
+ const pass3Start = Date.now();
1476
+ for (let idx = 0; idx < scenePropShots.length; idx++) {
1477
+ const shot = scenePropShots[idx];
1478
+ const shotNum = String(shot.shotNumber).padStart(3, '0');
1479
+ const imgPath = join(sceneDir, `shot-${shotNum}.png`);
1480
+ const progress = `[${idx + 1}/${scenePropShots.length}]`;
1481
+ if (!existsSync(imgPath)) {
1482
+ console.log(` ${progress} Shot ${shotNum}: panel missing, skipping`);
1483
+ continue;
1484
+ }
1485
+ // Load all scene ref images that actually exist on disk
1486
+ const sceneRefUris = [];
1487
+ const sceneRefPaths = [];
1488
+ for (const refPath of shot.sceneImagePaths.slice(0, 2)) {
1489
+ if (existsSync(refPath)) {
1490
+ sceneRefUris.push(await loadImageAsDataUri(refPath));
1491
+ sceneRefPaths.push(refPath);
1492
+ }
1493
+ else {
1494
+ console.warn(` ${progress} Shot ${shotNum}: scene ref not found: ${refPath}`);
1495
+ }
1496
+ }
1497
+ if (sceneRefUris.length === 0) {
1498
+ console.log(` ${progress} Shot ${shotNum}: no valid scene refs, skipping`);
1499
+ continue;
1500
+ }
1501
+ const panelDataUri = await loadImageAsDataUri(imgPath);
1502
+ const defaultSceneRefPrompt = `Integrate the visual elements from the reference image(s) into this scene. ` +
1503
+ `Preserve the scene composition, characters, lighting, and cinematic framing exactly. ` +
1504
+ `Do not change the overall image. Do not add text, speech bubbles, or panel borders.`;
1505
+ const sceneRefPrompt = shot.sceneRefDescription
1506
+ ? `${shot.sceneRefDescription} Preserve the scene composition, characters, lighting, and cinematic framing exactly. Do not add text, speech bubbles, or panel borders.`
1507
+ : defaultSceneRefPrompt;
1508
+ const refStart = Date.now();
1509
+ try {
1510
+ const resultBuffer = await multiEditImage(client, {
1511
+ model: sceneEditModel,
1512
+ prompt: sceneRefPrompt,
1513
+ baseImage: panelDataUri,
1514
+ referenceImages: sceneRefUris,
1515
+ });
1516
+ // Archive original before overwriting
1517
+ const archivePath = imgPath.replace(/\.png$/, '-pre-scene-ref.png');
1518
+ if (existsSync(imgPath)) {
1519
+ const { rename } = await import('node:fs/promises');
1520
+ await rename(imgPath, archivePath);
1521
+ }
1522
+ await writeFile(imgPath, resultBuffer);
1523
+ await appendRecipePass(imgPath, {
1524
+ kind: 'multi-edit',
1525
+ role: 'content',
1526
+ model: sceneEditModel,
1527
+ label: 'scene-ref injection',
1528
+ prompt: sceneRefPrompt,
1529
+ referenceImagePaths: sceneRefPaths,
1530
+ archivedPrevious: archivePath,
1531
+ }, { provenance: 'edit', hasFace: shot.characters.length > 0 });
1532
+ const elapsed = ((Date.now() - refStart) / 1000).toFixed(1);
1533
+ console.log(` ${progress} Shot ${shotNum}: scene-ref injected (${elapsed}s)`);
1534
+ }
1535
+ catch (err) {
1536
+ console.warn(` ${progress} Shot ${shotNum}: scene-ref injection FAILED - ${err}`);
1537
+ }
1538
+ }
1539
+ const pass3Elapsed = ((Date.now() - pass3Start) / 1000).toFixed(0);
1540
+ console.log(`\nPass 3 complete (${pass3Elapsed}s total)`);
1541
+ }
1542
+ const ep = series.episodes.find(e => e.number === opts.episode);
1543
+ if (ep)
1544
+ ep.status = 'storyboarded';
1545
+ await saveSeries(series);
1546
+ console.log(`\nStoryboard complete. ${script.shots.length} panels in: ${sceneDir}`);
1547
+ console.log(`\n>> QA REVIEW NEEDED: Run /qa-storyboard to check character/setting consistency before proceeding.`);
1548
+ console.log(` The agent will compare each panel against character references and flag issues.`);
1549
+ console.log(`\nAfter QA approval: generate-videos -p ${series.outputDir} -e ${opts.episode}`);
1550
+ });
1551
+ // ── fix-panel ─────────────────────────────────────────────────────────
1552
+ program
1553
+ .command('fix-panel')
1554
+ .description('Fix character appearance in a panel using multi-edit with character references')
1555
+ .requiredOption('-p, --project <dir>', 'Series output directory')
1556
+ .requiredOption('-e, --episode <number>', 'Episode number', parseInt)
1557
+ .requiredOption('-s, --shot <number>', 'Shot number to fix', parseInt)
1558
+ .option('-c, --characters <names>', 'Character names to fix (comma-separated)')
1559
+ .option('--edit-model <model>', 'Multi-edit model (default: nano-banana-2-edit)', DEFAULT_IMAGE_EDIT_MODEL)
1560
+ .option('--prompt <prompt>', 'Custom edit prompt (overrides auto-generated)')
1561
+ .action(async (opts) => {
1562
+ const series = await loadSeries(resolve(opts.project));
1563
+ if (!series) {
1564
+ console.error('Series not found.');
1565
+ process.exit(1);
1566
+ }
1567
+ const script = await loadEpisodeScript(series, opts.episode);
1568
+ if (!script) {
1569
+ console.error(`Episode ${opts.episode} script not found.`);
1570
+ process.exit(1);
1571
+ }
1572
+ const shot = script.shots.find(s => s.shotNumber === opts.shot);
1573
+ if (!shot) {
1574
+ console.error(`Shot ${opts.shot} not found in script.`);
1575
+ process.exit(1);
1576
+ }
1577
+ const episodeDir = getEpisodeDir(series, opts.episode);
1578
+ const shotNum = String(opts.shot).padStart(3, '0');
1579
+ const panelPath = join(episodeDir, 'scene-001', `shot-${shotNum}.png`);
1580
+ if (!existsSync(panelPath)) {
1581
+ console.error(`Panel not found: ${panelPath}`);
1582
+ process.exit(1);
1583
+ }
1584
+ const charNames = opts.characters
1585
+ ? opts.characters.split(',').map(s => s.trim())
1586
+ : shot.characters;
1587
+ if (charNames.length === 0) {
1588
+ console.error('No characters specified and shot has no characters.');
1589
+ process.exit(1);
1590
+ }
1591
+ const apiKey = await getVeniceApiKey();
1592
+ const client = new VeniceClient(apiKey);
1593
+ console.log(`Fixing shot ${shotNum} with character references: ${charNames.join(', ')}`);
1594
+ await fixPanel(client, series, panelPath, charNames, opts.editModel, opts.prompt, shot.episodeWardrobe, shot.environment);
1595
+ console.log(`\nPanel fixed. Review: ${panelPath}`);
1596
+ console.log(`Original archived as: shot-${shotNum}-pre-fix.png`);
1597
+ });
1598
+ // ── insert-shot ─────────────────────────────────────────────
1599
+ // Splices a new shot into an existing script with a suffix-letter id
1600
+ // (3 -> 3b -> 3c) so the order of the original numeric shotNumbers is
1601
+ // preserved. Writes the new script and prints the next steps for the
1602
+ // user to run.
1603
+ //
1604
+ // Doing this in one go would require re-rendering panels + videos +
1605
+ // re-deriving the assembly's filter graph + re-emitting the FCPXML.
1606
+ // Each of those is a separate existing command. Following the harness'
1607
+ // "natural-language interface; commands compose" pattern, this command
1608
+ // only does the script-editing work and tells the user what to run next.
1609
+ program
1610
+ .command('insert-shot')
1611
+ .description('Insert a new shot into an episode script with a suffix-letter id')
1612
+ .requiredOption('-p, --project <dir>', 'Series output directory')
1613
+ .requiredOption('-e, --episode <number>', 'Episode number', parseInt)
1614
+ .requiredOption('--after <shotId>', 'Shot id (number or suffixed string) to insert after')
1615
+ .requiredOption('--description <text>', 'Description for the new shot (drives panel + video prompt)')
1616
+ .option('--type <type>', 'Shot type', 'action')
1617
+ .option('--duration <duration>', 'Shot duration (e.g. 15s). DEFAULT is 15s — the native max on Seedance 2.0 (4-15s) and HappyHorse 1.1 (3-15s). Prefer 15s and stitch fewer long clips (2x15s for a 30s beat) over many short clips: identity stays locked longer, transitions are fewer, cost is lower, and motion has room to breathe. Only drop below 15s for genuine quick beats (sight gag, hard cut, deliberate stinger).', '15s')
1618
+ .option('--motion <motion>', 'Motion intensity: low | medium | high', 'medium')
1619
+ .option('--characters <names>', 'Character names (comma-separated)', '')
1620
+ .option('--dialogue <line>', 'Dialogue line (omit for action/insert shots)')
1621
+ .option('--speaker <name>', 'Dialogue speaker name')
1622
+ .option('--transition <name>', 'Transition into the next shot', 'CUT')
1623
+ .action(async (opts) => {
1624
+ const series = await loadSeries(resolve(opts.project));
1625
+ if (!series) {
1626
+ console.error('Series not found.');
1627
+ process.exit(1);
1628
+ }
1629
+ const script = await loadEpisodeScript(series, opts.episode);
1630
+ if (!script) {
1631
+ console.error(`Episode ${opts.episode} script not found.`);
1632
+ process.exit(1);
1633
+ }
1634
+ // Resolve the insertion anchor. `--after 3` and `--after "3b"` both work.
1635
+ const afterNumeric = parseInt(opts.after, 10);
1636
+ const afterSuffix = opts.after.slice(String(afterNumeric).length);
1637
+ const anchorIdx = script.shots.findIndex(s => s.shotNumber === afterNumeric && (s.shotIdSuffix ?? '') === afterSuffix);
1638
+ if (anchorIdx < 0) {
1639
+ console.error(`Shot ${opts.after} not found in episode ${opts.episode}.`);
1640
+ process.exit(1);
1641
+ }
1642
+ // Find the next free suffix letter for this shotNumber. The first
1643
+ // insert after shot N gets suffix "b" (shot N stays unsuffixed = "a").
1644
+ const usedSuffixes = new Set(script.shots
1645
+ .filter(s => s.shotNumber === afterNumeric)
1646
+ .map(s => s.shotIdSuffix ?? ''));
1647
+ let candidate = '';
1648
+ for (const letter of 'bcdefghijklmnopqrstuvwxyz') {
1649
+ if (!usedSuffixes.has(letter)) {
1650
+ candidate = letter;
1651
+ break;
1652
+ }
1653
+ }
1654
+ if (!candidate) {
1655
+ console.error(`No free suffix letters left for shotNumber ${afterNumeric}.`);
1656
+ process.exit(1);
1657
+ }
1658
+ const characters = opts.characters
1659
+ ? opts.characters.split(',').map(s => s.trim()).filter(Boolean)
1660
+ : [];
1661
+ const dialogue = opts.dialogue
1662
+ ? { character: opts.speaker ?? characters[0] ?? 'NARRATOR', line: opts.dialogue }
1663
+ : null;
1664
+ const motion = opts.motion;
1665
+ const validMotions = new Set(['low', 'medium', 'high']);
1666
+ if (!validMotions.has(motion)) {
1667
+ console.error(`--motion must be one of: low | medium | high`);
1668
+ process.exit(1);
1669
+ }
1670
+ const newShot = {
1671
+ shotNumber: afterNumeric,
1672
+ shotIdSuffix: candidate,
1673
+ type: opts.type,
1674
+ duration: opts.duration,
1675
+ videoModel: 'action',
1676
+ description: opts.description,
1677
+ characters,
1678
+ dialogue,
1679
+ sfx: null,
1680
+ cameraMovement: 'static',
1681
+ transition: opts.transition,
1682
+ motion,
1683
+ };
1684
+ // Archive the pre-insert script so we can rollback.
1685
+ const episodeDir = getEpisodeDir(series, opts.episode);
1686
+ const archivePath = join(episodeDir, `script-pre-insert-${Date.now()}.json`);
1687
+ await writeFile(archivePath, JSON.stringify(script, null, 2), 'utf-8');
1688
+ // Splice the new shot in directly after the anchor.
1689
+ script.shots.splice(anchorIdx + 1, 0, newShot);
1690
+ await saveEpisodeScript(series, script);
1691
+ const newId = `${afterNumeric}${candidate}`;
1692
+ console.log(`Inserted shot ${newId} after shot ${opts.after} in episode ${opts.episode}.`);
1693
+ console.log(` Archived previous script: ${basename(archivePath)}`);
1694
+ console.log(` Shots now: ${script.shots.length}`);
1695
+ console.log(`\nNext steps:`);
1696
+ console.log(` 1. Generate panel for the new shot:`);
1697
+ console.log(` storyboard-episode -p ${series.outputDir} -e ${opts.episode}`);
1698
+ console.log(` (existing panels are skipped unless --force; only the new shot will render)`);
1699
+ console.log(` 2. Re-render the video for the new shot:`);
1700
+ console.log(` generate-videos -p ${series.outputDir} -e ${opts.episode}`);
1701
+ console.log(` 3. Re-assemble the episode:`);
1702
+ console.log(` assemble-episode -p ${series.outputDir} -e ${opts.episode}`);
1703
+ if (dialogue) {
1704
+ console.log(` 4. (Dialogue shot) generate the TTS line for shot ${newId} before re-assembling.`);
1705
+ }
1706
+ });
1707
+ // ── approve-script ───────────────────────────────────────────────────
1708
+ program
1709
+ .command('approve-script')
1710
+ .description('Mark an episode script as approved, unblocking storyboard generation')
1711
+ .requiredOption('-p, --project <dir>', 'Series output directory')
1712
+ .requiredOption('-e, --episode <number>', 'Episode number', parseInt)
1713
+ .option('--notes <notes>', 'Approval notes')
1714
+ .action(async (opts) => {
1715
+ const series = await loadSeries(resolve(opts.project));
1716
+ if (!series) {
1717
+ console.error('Series not found.');
1718
+ process.exit(1);
1719
+ }
1720
+ const script = await loadEpisodeScript(series, opts.episode);
1721
+ if (!script) {
1722
+ console.error(`Episode ${opts.episode} script not found.`);
1723
+ process.exit(1);
1724
+ }
1725
+ script.status = 'approved';
1726
+ await saveEpisodeScript(series, script);
1727
+ const episodeDir = getEpisodeDir(series, opts.episode);
1728
+ const artifactPath = join(episodeDir, 'script-approved.json');
1729
+ const artifact = {
1730
+ episode: opts.episode,
1731
+ approvedAt: new Date().toISOString(),
1732
+ notes: opts.notes || 'Script reviewed and approved.',
1733
+ shotCount: script.shots.length,
1734
+ totalDuration: script.totalDuration,
1735
+ };
1736
+ await writeFile(artifactPath, JSON.stringify(artifact, null, 2), 'utf-8');
1737
+ const ep = series.episodes.find(e => e.number === opts.episode);
1738
+ if (ep)
1739
+ ep.status = 'scripted';
1740
+ await saveSeries(series);
1741
+ console.log(`Script approved for Episode ${opts.episode}: "${script.title}"`);
1742
+ console.log(` Artifact: ${artifactPath}`);
1743
+ console.log(` Shots: ${script.shots.length} | Duration: ${script.totalDuration}`);
1744
+ console.log(`\nStoryboard generation is now unblocked.`);
1745
+ console.log(` Run: storyboard-episode -p ${series.outputDir} -e ${opts.episode}`);
1746
+ });
1747
+ // ── qa-storyboard ─────────────────────────────────────────────────────
1748
+ program
1749
+ .command('qa-storyboard')
1750
+ .description('Analyze storyboard panels for character/setting consistency using vision')
1751
+ .requiredOption('-p, --project <dir>', 'Series output directory')
1752
+ .requiredOption('-e, --episode <number>', 'Episode number', parseInt)
1753
+ .option('--model <model>', 'Vision model for QA analysis', 'qwen-2.5-vl')
1754
+ .option('--shots <range>', 'Specific shots to check (e.g. "3,5,7" or "3-7")')
1755
+ .action(async (opts) => {
1756
+ const series = await loadSeries(resolve(opts.project));
1757
+ if (!series) {
1758
+ console.error('Series not found.');
1759
+ process.exit(1);
1760
+ }
1761
+ const script = await loadEpisodeScript(series, opts.episode);
1762
+ if (!script) {
1763
+ console.error(`Episode ${opts.episode} script not found.`);
1764
+ process.exit(1);
1765
+ }
1766
+ const apiKey = await getVeniceApiKey();
1767
+ const client = new VeniceClient(apiKey);
1768
+ const episodeDir = getEpisodeDir(series, opts.episode);
1769
+ const sceneDir = join(episodeDir, 'scene-001');
1770
+ let shotsToCheck = script.shots;
1771
+ if (opts.shots) {
1772
+ const nums = new Set();
1773
+ for (const part of opts.shots.split(',')) {
1774
+ if (part.includes('-')) {
1775
+ const [a, b] = part.split('-').map(Number);
1776
+ for (let i = a; i <= b; i++)
1777
+ nums.add(i);
1778
+ }
1779
+ else {
1780
+ nums.add(Number(part));
1781
+ }
1782
+ }
1783
+ shotsToCheck = script.shots.filter(s => nums.has(s.shotNumber));
1784
+ }
1785
+ console.log(`QA Storyboard: Episode ${opts.episode} (${shotsToCheck.length} shots, model: ${opts.model})\n`);
1786
+ const results = [];
1787
+ const { readFileSync: readFs } = await import('node:fs');
1788
+ const toDataUri = (p) => `data:image/png;base64,${readFs(p).toString('base64')}`;
1789
+ const systemPrompt = `You are a visual QA analyst for an animated mini-drama series. Your job is to compare storyboard panels against character reference images and the series aesthetic to check for consistency issues.
1790
+
1791
+ For each panel, evaluate:
1792
+ 1. CHARACTER CONSISTENCY: Do characters match their reference images? Check hair color/style, facial features, body type, wardrobe, skin tone.
1793
+ 2. SETTING CONTINUITY: Does the environment match the shot description? Time of day, weather, location details.
1794
+ 3. COMPOSITION: Does the framing match the intended shot type and camera description?
1795
+
1796
+ Respond ONLY in this exact JSON format (no markdown, no code fences):
1797
+ {"verdict":"PASS|FLAG-CRITICAL|FLAG-MODERATE|FLAG-LOW","issues":["issue 1","issue 2"],"notes":"brief overall assessment"}
1798
+
1799
+ Verdict rules:
1800
+ - PASS: Panel matches references and description well
1801
+ - FLAG-CRITICAL: Major character identity mismatch (wrong hair color, wrong gender presentation, missing character)
1802
+ - FLAG-MODERATE: Noticeable wardrobe or feature deviation, wrong composition
1803
+ - FLAG-LOW: Minor stylistic drift, acceptable for production`;
1804
+ for (let i = 0; i < shotsToCheck.length; i++) {
1805
+ const shot = shotsToCheck[i];
1806
+ const shotNum = String(shot.shotNumber).padStart(3, '0');
1807
+ const panelPath = join(sceneDir, `shot-${shotNum}.png`);
1808
+ if (!existsSync(panelPath)) {
1809
+ results.push({
1810
+ shotNumber: shot.shotNumber, type: shot.type, characters: shot.characters,
1811
+ verdict: 'FLAG-CRITICAL', issues: ['Panel file missing'], notes: 'No panel generated',
1812
+ });
1813
+ console.log(` [${i + 1}/${shotsToCheck.length}] Shot ${shotNum}: MISSING`);
1814
+ continue;
1815
+ }
1816
+ const images = [toDataUri(panelPath)];
1817
+ for (const charName of shot.characters.slice(0, 2)) {
1818
+ const charDir = getCharacterDir(series, charName);
1819
+ const frontPath = join(charDir, 'front.png');
1820
+ if (existsSync(frontPath)) {
1821
+ images.push(toDataUri(frontPath));
1822
+ }
1823
+ }
1824
+ const charDescs = shot.characters.map(name => {
1825
+ const char = series.characters.find(c => c.name.toUpperCase() === name.toUpperCase());
1826
+ return char ? `${char.name}: ${char.description}, wearing ${shot.episodeWardrobe?.[name.toUpperCase()] ?? char.wardrobe}` : name;
1827
+ });
1828
+ const userPrompt = [
1829
+ `Analyze this storyboard panel (image 1) for shot ${shot.shotNumber}.`,
1830
+ `Shot type: ${shot.type}. Camera: ${shot.cameraMovement}.`,
1831
+ `Description: ${shot.panelDescription ?? shot.description}`,
1832
+ shot.characters.length > 0
1833
+ ? `Characters in shot: ${charDescs.join('; ')}. Reference images follow the panel.`
1834
+ : 'No characters expected in this shot. Verify the scene is empty of people.',
1835
+ ].join('\n');
1836
+ try {
1837
+ const raw = await client.chatWithVision(opts.model, systemPrompt, images, userPrompt);
1838
+ const jsonStr = raw.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
1839
+ const parsed = JSON.parse(jsonStr);
1840
+ results.push({
1841
+ shotNumber: shot.shotNumber, type: shot.type, characters: shot.characters,
1842
+ ...parsed,
1843
+ });
1844
+ const icon = parsed.verdict === 'PASS' ? '✓' : parsed.verdict === 'FLAG-CRITICAL' ? '✗' : '⚠';
1845
+ console.log(` [${i + 1}/${shotsToCheck.length}] Shot ${shotNum}: ${icon} ${parsed.verdict}${parsed.issues.length > 0 ? ' -- ' + parsed.issues[0] : ''}`);
1846
+ }
1847
+ catch (err) {
1848
+ results.push({
1849
+ shotNumber: shot.shotNumber, type: shot.type, characters: shot.characters,
1850
+ verdict: 'FLAG-LOW', issues: [`QA analysis failed: ${err}`], notes: 'Vision API error',
1851
+ });
1852
+ console.warn(` [${i + 1}/${shotsToCheck.length}] Shot ${shotNum}: QA failed - ${err}`);
1853
+ }
1854
+ }
1855
+ // Persist QA report
1856
+ const report = {
1857
+ episode: opts.episode,
1858
+ model: opts.model,
1859
+ analyzedAt: new Date().toISOString(),
1860
+ summary: {
1861
+ total: results.length,
1862
+ pass: results.filter(r => r.verdict === 'PASS').length,
1863
+ flagCritical: results.filter(r => r.verdict === 'FLAG-CRITICAL').length,
1864
+ flagModerate: results.filter(r => r.verdict === 'FLAG-MODERATE').length,
1865
+ flagLow: results.filter(r => r.verdict === 'FLAG-LOW').length,
1866
+ },
1867
+ results,
1868
+ };
1869
+ const reportPath = join(episodeDir, 'qa-report.json');
1870
+ await writeFile(reportPath, JSON.stringify(report, null, 2), 'utf-8');
1871
+ console.log(`\n${'─'.repeat(50)}`);
1872
+ console.log(`QA Report: ${report.summary.pass} PASS, ${report.summary.flagCritical} CRITICAL, ${report.summary.flagModerate} MODERATE, ${report.summary.flagLow} LOW`);
1873
+ console.log(`Report saved: ${reportPath}`);
1874
+ if (report.summary.flagCritical > 0) {
1875
+ console.log(`\n${report.summary.flagCritical} critical issue(s) found. Fix panels before proceeding.`);
1876
+ const criticalShots = results.filter(r => r.verdict === 'FLAG-CRITICAL');
1877
+ for (const r of criticalShots) {
1878
+ console.log(` Shot ${String(r.shotNumber).padStart(3, '0')}: ${r.issues.join(', ')}`);
1879
+ }
1880
+ }
1881
+ else {
1882
+ console.log(`\nNo critical issues. Run: qa-approve -p ${series.outputDir} -e ${opts.episode}`);
1883
+ }
1884
+ });
1885
+ // ── qa-approve ────────────────────────────────────────────────────────
1886
+ program
1887
+ .command('qa-approve')
1888
+ .description('Mark storyboard panels as QA-approved, unblocking video generation')
1889
+ .requiredOption('-p, --project <dir>', 'Series output directory')
1890
+ .requiredOption('-e, --episode <number>', 'Episode number', parseInt)
1891
+ .option('--notes <notes>', 'QA approval notes')
1892
+ .action(async (opts) => {
1893
+ const series = await loadSeries(resolve(opts.project));
1894
+ if (!series) {
1895
+ console.error('Series not found.');
1896
+ process.exit(1);
1897
+ }
1898
+ const episodeDir = getEpisodeDir(series, opts.episode);
1899
+ const qaPath = join(episodeDir, 'qa-approved.json');
1900
+ const artifact = {
1901
+ episode: opts.episode,
1902
+ approvedAt: new Date().toISOString(),
1903
+ notes: opts.notes || 'Panels reviewed and approved.',
1904
+ };
1905
+ await writeFile(qaPath, JSON.stringify(artifact, null, 2), 'utf-8');
1906
+ console.log(`QA approved for Episode ${opts.episode}.`);
1907
+ console.log(` Artifact: ${qaPath}`);
1908
+ console.log(`\nVideo generation is now unblocked. Run: generate-videos -p ${series.outputDir} -e ${opts.episode}`);
1909
+ });
1910
+ // ── generate-videos ───────────────────────────────────────────────────
1911
+ program
1912
+ .command('generate-videos')
1913
+ .description('Generate video clips from storyboard panels (with native audio)')
1914
+ .requiredOption('-p, --project <dir>', 'Series output directory')
1915
+ .requiredOption('-e, --episode <number>', 'Episode number', parseInt)
1916
+ .option('--skip-qa', 'Skip QA approval check', false)
1917
+ .option('--no-seedance-keyframe', 'Disable the automatic Seedance R2V → Wan 2.7 keyframe pipeline for this run (see AGENTS.md rule 32).')
1918
+ .action(async (opts) => {
1919
+ const series = await loadSeries(resolve(opts.project));
1920
+ if (!series) {
1921
+ console.error('Series not found.');
1922
+ process.exit(1);
1923
+ }
1924
+ const script = await loadEpisodeScript(series, opts.episode);
1925
+ if (!script) {
1926
+ console.error(`Episode ${opts.episode} script not found.`);
1927
+ process.exit(1);
1928
+ }
1929
+ const episodeDir = getEpisodeDir(series, opts.episode);
1930
+ const qaPath = join(episodeDir, 'qa-approved.json');
1931
+ if (!opts.skipQa && !existsSync(qaPath)) {
1932
+ console.error('QA approval required before video generation.');
1933
+ console.error('Run qa-storyboard to review panels, then: qa-approve -p <project> -e <episode>');
1934
+ console.error('Or bypass with: generate-videos ... --skip-qa');
1935
+ process.exit(1);
1936
+ }
1937
+ // commander's --no-<flag> negates the camelCase option. When the user
1938
+ // passes --no-seedance-keyframe we flip the series-level default for
1939
+ // this run only (no persisted change to series.json).
1940
+ if (opts.seedanceKeyframe === false) {
1941
+ series.videoDefaults = { ...series.videoDefaults, seedanceKeyframeForWan: false };
1942
+ console.log('Seedance R2V → Wan 2.7 keyframe pipeline DISABLED for this run.\n');
1943
+ }
1944
+ const apiKey = await getVeniceApiKey();
1945
+ const client = new VeniceClient(apiKey);
1946
+ const sceneDir = join(episodeDir, 'scene-001');
1947
+ const generationPlan = buildGenerationPlan(script, series);
1948
+ console.log(`Generating videos for Episode ${opts.episode}: ${script.title}`);
1949
+ const ccModel = series.videoDefaults.characterConsistencyModel ?? DEFAULT_CHARACTER_CONSISTENCY_MODEL;
1950
+ console.log(`Models: action=${series.videoDefaults.actionModel}, atmosphere=${series.videoDefaults.atmosphereModel}, character-consistency=${ccModel}\n`);
1951
+ console.log(`Generation units: ${generationPlan.units.length}`);
1952
+ const multiUnitCount = generationPlan.units.filter(unit => unit.unitType === 'kling-multishot').length;
1953
+ if (multiUnitCount > 0) {
1954
+ console.log(`Kling multi-shot units: ${multiUnitCount}`);
1955
+ }
1956
+ const seedanceKeyframeCount = generationPlan.units.filter(unit => unit.useSeedanceKeyframe).length;
1957
+ if (seedanceKeyframeCount > 0) {
1958
+ console.log(`Seedance R2V → Wan 2.7 keyframe units: ${seedanceKeyframeCount} (~$0.85 each; AGENTS.md rule 32)`);
1959
+ }
1960
+ console.log('');
1961
+ // Fold the series-level audioStrategy into the episode's audioMix when
1962
+ // the user hasn't explicitly set suppressModelNarration. 'narrator-vo'
1963
+ // tells Seedance `audio: false` for every dialogue shot at queue time so
1964
+ // the model can't generate a competing narrator under the Venice TTS.
1965
+ const effectiveAudioMix = (() => {
1966
+ const mix = { ...(script.audioMix ?? {}) };
1967
+ if (mix.suppressModelNarration === undefined && series.videoDefaults.audioStrategy === 'narrator-vo') {
1968
+ mix.suppressModelNarration = true;
1969
+ }
1970
+ return mix;
1971
+ })();
1972
+ // Storyboard blocking plates (per scene beat): plan beats for the
1973
+ // episode, generate any plates missing on disk, and persist the
1974
+ // storyboardRef assignments so the video prompts can bind them as
1975
+ // @ImageN composition references. Best-effort — a failure just means
1976
+ // the shots render without a blocking plate.
1977
+ try {
1978
+ const { generated, skipped } = await ensureEpisodeStoryboardReferences(client, series, script);
1979
+ if (generated.length > 0 || skipped.length > 0) {
1980
+ console.log(`Storyboard blocking plates: ${generated.length} generated, ${skipped.length} reused.\n`);
1981
+ await saveEpisodeScript(series, script);
1982
+ }
1983
+ }
1984
+ catch (err) {
1985
+ console.warn(`⚠ Storyboard plate pass failed: ${err.message}\n`);
1986
+ }
1987
+ const { videoPaths, plan } = await generateEpisodeVideos(client, series, script.shots, sceneDir, generationPlan, effectiveAudioMix);
1988
+ await saveGenerationPlan(episodeDir, plan);
1989
+ const ep = series.episodes.find(e => e.number === opts.episode);
1990
+ if (ep)
1991
+ ep.status = 'produced';
1992
+ await saveSeries(series);
1993
+ console.log(`\nGenerated ${videoPaths.length} video clips.`);
1994
+ console.log(`Generation plan saved to: ${join(episodeDir, 'generation-plan.json')}`);
1995
+ console.log(`Next: assemble-episode -p ${series.outputDir} -e ${opts.episode}`);
1996
+ });
1997
+ // ── override-audio ────────────────────────────────────────────────────
1998
+ program
1999
+ .command('override-audio')
2000
+ .description('Replace dialogue/SFX with Venice audio models (optional, post video-gen)')
2001
+ .requiredOption('-p, --project <dir>', 'Series output directory')
2002
+ .requiredOption('-e, --episode <number>', 'Episode number', parseInt)
2003
+ .option('--dialogue', 'Override dialogue with Venice TTS', false)
2004
+ .option('--sfx', 'Generate SFX overrides', false)
2005
+ .action(async (opts) => {
2006
+ const series = await loadSeries(resolve(opts.project));
2007
+ if (!series) {
2008
+ console.error('Series not found.');
2009
+ process.exit(1);
2010
+ }
2011
+ const script = await loadEpisodeScript(series, opts.episode);
2012
+ if (!script) {
2013
+ console.error(`Episode ${opts.episode} script not found.`);
2014
+ process.exit(1);
2015
+ }
2016
+ const apiKey = await getVeniceApiKey();
2017
+ const client = new VeniceClient(apiKey);
2018
+ const episodeDir = getEpisodeDir(series, opts.episode);
2019
+ const audioDir = join(episodeDir, 'audio');
2020
+ await mkdir(audioDir, { recursive: true });
2021
+ if (opts.dialogue) {
2022
+ console.log('Generating dialogue with locked character voices...');
2023
+ const lines = script.shots
2024
+ .filter(s => s.dialogue)
2025
+ .map(s => {
2026
+ const char = getCharacter(series, s.dialogue.character);
2027
+ return {
2028
+ shotNumber: s.shotNumber,
2029
+ character: s.dialogue.character,
2030
+ voiceId: char?.voiceId || '',
2031
+ text: s.dialogue.line,
2032
+ voicePrompt: char?.voiceDescription,
2033
+ };
2034
+ })
2035
+ .filter(l => l.voiceId);
2036
+ if (lines.length === 0) {
2037
+ console.warn(' No characters have locked voices. Run audition-voices first.');
2038
+ }
2039
+ else {
2040
+ await generateDialogueForShots(client, lines, audioDir);
2041
+ console.log(` Generated ${lines.length} dialogue lines (mapped to shot numbers).`);
2042
+ }
2043
+ }
2044
+ if (opts.sfx) {
2045
+ console.log('Generating SFX overrides...');
2046
+ const sfxShots = script.shots.filter(s => s.sfx);
2047
+ for (let i = 0; i < sfxShots.length; i++) {
2048
+ const shot = sfxShots[i];
2049
+ const outputPath = join(audioDir, `sfx-${String(i + 1).padStart(3, '0')}.mp3`);
2050
+ try {
2051
+ await generateSoundEffect(client, {
2052
+ text: shot.sfx,
2053
+ durationSeconds: parseShotDurationSeconds(shot.duration),
2054
+ }, outputPath);
2055
+ console.log(` SFX: "${shot.sfx.slice(0, 40)}" -> ${outputPath}`);
2056
+ }
2057
+ catch (err) {
2058
+ console.warn(` SFX failed: ${err}`);
2059
+ }
2060
+ }
2061
+ }
2062
+ console.log(`\nAudio overrides saved to: ${audioDir}`);
2063
+ });
2064
+ // ── generate-music ────────────────────────────────────────────────────
2065
+ program
2066
+ .command('generate-music')
2067
+ .description('Generate background music track via Venice audio')
2068
+ .requiredOption('-p, --project <dir>', 'Series output directory')
2069
+ .requiredOption('-e, --episode <number>', 'Episode number', parseInt)
2070
+ .option('--prompt <prompt>', 'Music style/mood description')
2071
+ .option('--duration <value>', 'Duration in seconds, or milliseconds for backward compatibility', '60')
2072
+ .option('--model <id>', `Venice music model (default ${DEFAULT_VENICE_MUSIC_MODEL})`)
2073
+ .option('--voice <voice>', 'Voice id for voice-enabled models (e.g. seed-audio-1-0)')
2074
+ .option('--speed <value>', 'Playback speed for speed-enabled models (e.g. 0.5-2 for seed-audio-1-0)', parseFloat)
2075
+ .action(async (opts) => {
2076
+ const series = await loadSeries(resolve(opts.project));
2077
+ if (!series) {
2078
+ console.error('Series not found.');
2079
+ process.exit(1);
2080
+ }
2081
+ if (opts.model && !getMusicModel(opts.model)) {
2082
+ console.error(`Unknown music model: ${opts.model}. Run \`inspect models --category music\` for options.`);
2083
+ process.exit(1);
2084
+ }
2085
+ const apiKey = await getVeniceApiKey();
2086
+ const client = new VeniceClient(apiKey);
2087
+ const episodeDir = getEpisodeDir(series, opts.episode);
2088
+ const audioDir = join(episodeDir, 'audio');
2089
+ await mkdir(audioDir, { recursive: true });
2090
+ const musicPrompt = opts.prompt || `Dramatic ${series.genre} background music, tension and emotion, cinematic`;
2091
+ const outputPath = join(audioDir, 'music.mp3');
2092
+ const durationSeconds = normalizeAudioDurationSeconds(opts.duration, 60);
2093
+ console.log(`Generating music: "${musicPrompt}" (${durationSeconds}s)${opts.model ? ` via ${opts.model}` : ''}`);
2094
+ await generateMusic(client, {
2095
+ prompt: musicPrompt,
2096
+ durationSeconds,
2097
+ modelId: opts.model,
2098
+ voice: opts.voice,
2099
+ speed: opts.speed,
2100
+ }, outputPath);
2101
+ console.log(`Music saved: ${outputPath}`);
2102
+ });
2103
+ // ── generate-audio (Seed Audio 1.0 expressive speech / prompt-driven audio) ──
2104
+ program
2105
+ .command('generate-audio')
2106
+ .description('Generate expressive speech / prompt-driven audio via Venice (default: Seed Audio 1.0)')
2107
+ .requiredOption('-p, --project <dir>', 'Series output directory')
2108
+ .requiredOption('-e, --episode <number>', 'Episode number', parseInt)
2109
+ .requiredOption('--prompt <prompt>', 'Text/script to speak, or an audio description (<=2048 chars for seed-audio-1-0)')
2110
+ .option('--out <filename>', 'Output filename written under the episode audio dir', 'seed-audio.mp3')
2111
+ .option('--model <id>', `Venice audio model (default ${DEFAULT_VENICE_SEED_AUDIO_MODEL})`, DEFAULT_VENICE_SEED_AUDIO_MODEL)
2112
+ .option('--voice <voice>', 'Voice id (default: model default; seed-audio uses "Describe in prompt")')
2113
+ .option('--speed <value>', 'Playback speed 0.5-2 (default 1)', parseFloat)
2114
+ .option('--duration <value>', 'Duration in seconds (default: model default)', parseFloat)
2115
+ .action(async (opts) => {
2116
+ const series = await loadSeries(resolve(opts.project));
2117
+ if (!series) {
2118
+ console.error('Series not found.');
2119
+ process.exit(1);
2120
+ }
2121
+ const spec = getMusicModel(opts.model);
2122
+ if (!spec) {
2123
+ console.error(`Unknown audio model: ${opts.model}. Run \`inspect models --category music\` for options.`);
2124
+ process.exit(1);
2125
+ }
2126
+ const apiKey = await getVeniceApiKey();
2127
+ const client = new VeniceClient(apiKey);
2128
+ const episodeDir = getEpisodeDir(series, opts.episode);
2129
+ const audioDir = join(episodeDir, 'audio');
2130
+ await mkdir(audioDir, { recursive: true });
2131
+ const outputPath = join(audioDir, opts.out);
2132
+ const voiceLabel = opts.voice ?? spec.defaultVoice ?? 'model default';
2133
+ console.log(`Generating audio via ${opts.model} [voice: ${voiceLabel}]: "${opts.prompt.slice(0, 60)}${opts.prompt.length > 60 ? '…' : ''}"`);
2134
+ await generateSeedAudio(client, {
2135
+ prompt: opts.prompt,
2136
+ modelId: opts.model,
2137
+ voice: opts.voice,
2138
+ speed: opts.speed,
2139
+ durationSeconds: opts.duration,
2140
+ }, outputPath);
2141
+ console.log(`Audio saved: ${outputPath}`);
2142
+ });
2143
+ // ── validate-episode ─────────────────────────────────────────────────
2144
+ program
2145
+ .command('validate-episode')
2146
+ .description('Check shot numbering, file integrity, and generation plan consistency')
2147
+ .requiredOption('-p, --project <dir>', 'Series output directory')
2148
+ .requiredOption('-e, --episode <number>', 'Episode number', parseInt)
2149
+ .action(async (opts) => {
2150
+ const series = await loadSeries(resolve(opts.project));
2151
+ if (!series) {
2152
+ console.error('Series not found.');
2153
+ process.exit(1);
2154
+ }
2155
+ const script = await loadEpisodeScript(series, opts.episode);
2156
+ if (!script) {
2157
+ console.error(`Episode ${opts.episode} script not found.`);
2158
+ process.exit(1);
2159
+ }
2160
+ const episodeDir = getEpisodeDir(series, opts.episode);
2161
+ const sceneDir = join(episodeDir, 'scene-001');
2162
+ let issues = 0;
2163
+ const warn = (msg) => { issues++; console.log(` ⚠ ${msg}`); };
2164
+ const ok = (msg) => { console.log(` ✓ ${msg}`); };
2165
+ console.log(`Validating Episode ${opts.episode}: ${script.title}\n`);
2166
+ // 1. Check shot numbering is sequential starting from 1
2167
+ console.log('Shot numbering:');
2168
+ const shotNumbers = script.shots.map(s => s.shotNumber);
2169
+ const expectedNumbers = script.shots.map((_, i) => i + 1);
2170
+ const numberingOk = shotNumbers.every((n, i) => n === expectedNumbers[i]);
2171
+ if (numberingOk) {
2172
+ ok(`Sequential 1-${shotNumbers.length}`);
2173
+ }
2174
+ else {
2175
+ warn(`Non-sequential: [${shotNumbers.join(',')}] (expected [${expectedNumbers.join(',')}])`);
2176
+ }
2177
+ const dupes = shotNumbers.filter((n, i) => shotNumbers.indexOf(n) !== i);
2178
+ if (dupes.length > 0) {
2179
+ warn(`Duplicate shot numbers: ${dupes.join(', ')}`);
2180
+ }
2181
+ // 2. Check panel files exist for every shot
2182
+ console.log('\nPanel files:');
2183
+ let missingPanels = 0;
2184
+ let orphanPanels = 0;
2185
+ for (const shot of script.shots) {
2186
+ const panelPath = join(sceneDir, `shot-${String(shot.shotNumber).padStart(3, '0')}.png`);
2187
+ if (!existsSync(panelPath)) {
2188
+ warn(`Missing panel: shot-${String(shot.shotNumber).padStart(3, '0')}.png`);
2189
+ missingPanels++;
2190
+ }
2191
+ }
2192
+ if (missingPanels === 0)
2193
+ ok(`All ${script.shots.length} panels present`);
2194
+ // Check for orphan panel files (panels with no matching shot in script)
2195
+ if (existsSync(sceneDir)) {
2196
+ const panelFiles = readdirSync(sceneDir).filter((f) => /^shot-\d{3}\.png$/.test(f));
2197
+ for (const f of panelFiles) {
2198
+ const num = parseInt(f.match(/shot-(\d{3})\.png/)[1], 10);
2199
+ if (!shotNumbers.includes(num)) {
2200
+ warn(`Orphan panel: ${f} (not in script)`);
2201
+ orphanPanels++;
2202
+ }
2203
+ }
2204
+ if (orphanPanels === 0 && panelFiles.length > 0)
2205
+ ok('No orphan panels');
2206
+ }
2207
+ // 3. Check video files
2208
+ console.log('\nVideo files:');
2209
+ let missingVideos = 0;
2210
+ let orphanVideos = 0;
2211
+ for (const shot of script.shots) {
2212
+ const videoPath = join(sceneDir, `shot-${String(shot.shotNumber).padStart(3, '0')}.mp4`);
2213
+ if (!existsSync(videoPath)) {
2214
+ missingVideos++;
2215
+ }
2216
+ }
2217
+ if (missingVideos === 0) {
2218
+ ok(`All ${script.shots.length} videos present`);
2219
+ }
2220
+ else if (missingVideos === script.shots.length) {
2221
+ ok(`No videos generated yet (expected pre-video-gen)`);
2222
+ }
2223
+ else {
2224
+ warn(`${missingVideos}/${script.shots.length} videos missing`);
2225
+ }
2226
+ if (existsSync(sceneDir)) {
2227
+ const videoFiles = readdirSync(sceneDir).filter((f) => /^shot-\d{3}\.mp4$/.test(f));
2228
+ for (const f of videoFiles) {
2229
+ const num = parseInt(f.match(/shot-(\d{3})\.mp4/)[1], 10);
2230
+ if (!shotNumbers.includes(num)) {
2231
+ warn(`Orphan video: ${f} (not in script)`);
2232
+ orphanVideos++;
2233
+ }
2234
+ }
2235
+ if (orphanVideos === 0 && videoFiles.length > 0)
2236
+ ok('No orphan videos');
2237
+ }
2238
+ // 4. Check generation plan consistency
2239
+ console.log('\nGeneration plan:');
2240
+ const { loadGenerationPlan } = await import('./generation-planner.js');
2241
+ const plan = await loadGenerationPlan(episodeDir);
2242
+ if (!plan) {
2243
+ ok('No generation plan yet (expected pre-video-gen)');
2244
+ }
2245
+ else {
2246
+ const planShotNumbers = plan.units.flatMap(u => u.shotNumbers).sort((a, b) => a - b);
2247
+ const scriptShotNumbers = [...shotNumbers].sort((a, b) => a - b);
2248
+ const planCoversAll = JSON.stringify(planShotNumbers) === JSON.stringify(scriptShotNumbers);
2249
+ if (planCoversAll) {
2250
+ ok(`Plan covers all ${scriptShotNumbers.length} shots (${plan.units.length} units)`);
2251
+ }
2252
+ else {
2253
+ const inPlanNotScript = planShotNumbers.filter(n => !scriptShotNumbers.includes(n));
2254
+ const inScriptNotPlan = scriptShotNumbers.filter(n => !planShotNumbers.includes(n));
2255
+ if (inPlanNotScript.length > 0)
2256
+ warn(`In plan but not script: [${inPlanNotScript.join(',')}]`);
2257
+ if (inScriptNotPlan.length > 0)
2258
+ warn(`In script but not plan: [${inScriptNotPlan.join(',')}]`);
2259
+ }
2260
+ }
2261
+ // 5. Check script field completeness
2262
+ console.log('\nScript completeness:');
2263
+ const missingDuration = script.shots.filter(s => !s.duration);
2264
+ const missingType = script.shots.filter(s => !s.type);
2265
+ const missingDesc = script.shots.filter(s => !s.description);
2266
+ const missingTransition = script.shots.filter(s => !s.transition);
2267
+ const missingCamera = script.shots.filter(s => !s.cameraMovement);
2268
+ if (missingDuration.length > 0)
2269
+ warn(`${missingDuration.length} shots missing duration`);
2270
+ if (missingType.length > 0)
2271
+ warn(`${missingType.length} shots missing type`);
2272
+ if (missingDesc.length > 0)
2273
+ warn(`${missingDesc.length} shots missing description`);
2274
+ if (missingTransition.length > 0)
2275
+ warn(`${missingTransition.length} shots missing transition`);
2276
+ if (missingCamera.length > 0)
2277
+ warn(`${missingCamera.length} shots missing cameraMovement`);
2278
+ if (missingDuration.length + missingType.length + missingDesc.length + missingTransition.length + missingCamera.length === 0) {
2279
+ ok('All required fields present');
2280
+ }
2281
+ // 6. Check character references
2282
+ console.log('\nCharacter references:');
2283
+ const allCharNames = Array.from(new Set(script.shots.flatMap(s => s.characters.map(c => c.toUpperCase()))));
2284
+ for (const name of allCharNames) {
2285
+ const char = series.characters.find(c => c.name.toUpperCase() === name);
2286
+ if (!char) {
2287
+ warn(`Character "${name}" in script but not in series.json`);
2288
+ }
2289
+ else if (!char.locked) {
2290
+ warn(`Character "${name}" not locked`);
2291
+ }
2292
+ }
2293
+ if (allCharNames.every(name => {
2294
+ const char = series.characters.find(c => c.name.toUpperCase() === name);
2295
+ return char && char.locked;
2296
+ })) {
2297
+ ok(`All ${allCharNames.length} characters locked`);
2298
+ }
2299
+ console.log(`\n${'─'.repeat(50)}`);
2300
+ if (issues === 0) {
2301
+ console.log(`Validation PASSED — no issues found.`);
2302
+ }
2303
+ else {
2304
+ console.log(`Validation found ${issues} issue(s). Fix before proceeding.`);
2305
+ }
2306
+ });
2307
+ // ── validate-video-outputs ────────────────────────────────────────────
2308
+ program
2309
+ .command('validate-video-outputs')
2310
+ .description('Post-generation QA: check aspect ratios, R2V usage, and durations')
2311
+ .requiredOption('-p, --project <dir>', 'Series output directory')
2312
+ .requiredOption('-e, --episode <number>', 'Episode number', parseInt)
2313
+ .action(async (opts) => {
2314
+ const series = await loadSeries(resolve(opts.project));
2315
+ if (!series) {
2316
+ console.error('Series not found.');
2317
+ process.exit(1);
2318
+ }
2319
+ const script = await loadEpisodeScript(series, opts.episode);
2320
+ if (!script) {
2321
+ console.error(`Episode ${opts.episode} script not found.`);
2322
+ process.exit(1);
2323
+ }
2324
+ const episodeDir = getEpisodeDir(series, opts.episode);
2325
+ const sceneDir = join(episodeDir, 'scene-001');
2326
+ const expectedAR = series.storyboardAspectRatio ?? '16:9';
2327
+ let issues = 0;
2328
+ const warn = (msg) => { issues++; console.log(` ⚠ ${msg}`); };
2329
+ const ok = (msg) => { console.log(` ✓ ${msg}`); };
2330
+ console.log(`Validating Video Outputs — Episode ${opts.episode}: ${script.title}`);
2331
+ console.log(`Expected aspect ratio: ${expectedAR}\n`);
2332
+ // 1. Aspect ratio check via ffprobe
2333
+ console.log('Aspect ratio:');
2334
+ let arIssues = 0;
2335
+ for (const shot of script.shots) {
2336
+ const videoPath = join(sceneDir, `shot-${String(shot.shotNumber).padStart(3, '0')}.mp4`);
2337
+ if (!existsSync(videoPath))
2338
+ continue;
2339
+ try {
2340
+ const dims = runCommand('ffprobe', [
2341
+ '-v',
2342
+ 'quiet',
2343
+ '-show_entries',
2344
+ 'stream=width,height',
2345
+ '-of',
2346
+ 'csv=p=0',
2347
+ videoPath,
2348
+ ]).trim().split('\n')[0];
2349
+ const [w, h] = dims.split(',').map(Number);
2350
+ if (expectedAR === '16:9' && h > w) {
2351
+ warn(`Shot ${shot.shotNumber}: ${w}x${h} is PORTRAIT but series expects landscape (16:9)`);
2352
+ arIssues++;
2353
+ }
2354
+ else if (expectedAR === '9:16' && w > h) {
2355
+ warn(`Shot ${shot.shotNumber}: ${w}x${h} is LANDSCAPE but series expects portrait (9:16)`);
2356
+ arIssues++;
2357
+ }
2358
+ }
2359
+ catch {
2360
+ warn(`Shot ${shot.shotNumber}: failed to probe dimensions`);
2361
+ arIssues++;
2362
+ }
2363
+ }
2364
+ if (arIssues === 0)
2365
+ ok('All video aspect ratios match series setting');
2366
+ // 2. R2V model usage — flag character shots that used non-R2V models
2367
+ console.log('\nR2V model enforcement:');
2368
+ let r2vIssues = 0;
2369
+ for (const shot of script.shots) {
2370
+ if (shot.characters.length === 0)
2371
+ continue;
2372
+ const metaPath = join(sceneDir, `shot-${String(shot.shotNumber).padStart(3, '0')}.video.json`);
2373
+ if (!existsSync(metaPath))
2374
+ continue;
2375
+ try {
2376
+ const meta = JSON.parse(readFileSync(metaPath, 'utf-8'));
2377
+ const model = meta.video?.model || meta.model || '';
2378
+ if (!model.includes('reference-to-video')) {
2379
+ warn(`Shot ${shot.shotNumber}: has characters [${shot.characters.join(', ')}] but used model "${model}" (not R2V)`);
2380
+ r2vIssues++;
2381
+ }
2382
+ }
2383
+ catch {
2384
+ warn(`Shot ${shot.shotNumber}: could not parse video metadata`);
2385
+ r2vIssues++;
2386
+ }
2387
+ }
2388
+ if (r2vIssues === 0)
2389
+ ok('All character shots use R2V models');
2390
+ // 3. Duration check
2391
+ console.log('\nDuration accuracy:');
2392
+ let durIssues = 0;
2393
+ const DURATION_TOLERANCE_SEC = 3;
2394
+ for (const shot of script.shots) {
2395
+ const videoPath = join(sceneDir, `shot-${String(shot.shotNumber).padStart(3, '0')}.mp4`);
2396
+ if (!existsSync(videoPath))
2397
+ continue;
2398
+ try {
2399
+ const actualDur = parseFloat(runCommand('ffprobe', [
2400
+ '-v',
2401
+ 'quiet',
2402
+ '-show_entries',
2403
+ 'format=duration',
2404
+ '-of',
2405
+ 'csv=p=0',
2406
+ videoPath,
2407
+ ]).trim());
2408
+ const requestedDur = parseInt(shot.duration, 10);
2409
+ const diff = Math.abs(actualDur - requestedDur);
2410
+ if (diff > DURATION_TOLERANCE_SEC) {
2411
+ warn(`Shot ${shot.shotNumber}: actual ${actualDur.toFixed(1)}s vs requested ${requestedDur}s (diff ${diff.toFixed(1)}s)`);
2412
+ durIssues++;
2413
+ }
2414
+ }
2415
+ catch {
2416
+ warn(`Shot ${shot.shotNumber}: failed to probe duration`);
2417
+ durIssues++;
2418
+ }
2419
+ }
2420
+ if (durIssues === 0)
2421
+ ok(`All durations within ${DURATION_TOLERANCE_SEC}s tolerance`);
2422
+ console.log(`\n${'─'.repeat(50)}`);
2423
+ if (issues === 0) {
2424
+ console.log('Video output validation PASSED — no issues found.');
2425
+ }
2426
+ else {
2427
+ console.log(`Video output validation found ${issues} issue(s). Review and re-render affected shots.`);
2428
+ }
2429
+ });
2430
+ // ── assemble-episode ──────────────────────────────────────────────────
2431
+ program
2432
+ .command('assemble-episode')
2433
+ .description('Stitch video clips + (optional) Venice dialogue replacement + music + subtitles')
2434
+ .requiredOption('-p, --project <dir>', 'Series output directory')
2435
+ .requiredOption('-e, --episode <number>', 'Episode number', parseInt)
2436
+ .option('--no-subtitles', 'Skip subtitle burn-in')
2437
+ .option('--no-music', 'Skip background music mixing')
2438
+ .option('--no-ambient', 'Skip ambient bed mixing')
2439
+ .option('--ambient-volume <vol>', 'Ambient bed volume (0-1)', '0.3')
2440
+ // Default is OFF. Native model dialogue (Seedance / Wan 2.7 / HappyHorse)
2441
+ // is preferred until Venice ships better TTS voice options. Pass
2442
+ // --dialogue-replace to opt in (typically when you've also run
2443
+ // `override-audio --dialogue` to produce dialogue-shot-NNN.mp3 files).
2444
+ // --no-dialogue-replace is kept as an explicit alias so scripts that
2445
+ // already pass it don't break (it's now a no-op since the default is
2446
+ // already false).
2447
+ .option('--dialogue-replace', 'Replace native model dialogue with Venice TTS (off by default; flip on only when override-audio --dialogue has produced dialogue-shot-NNN.mp3 files)', false)
2448
+ .option('--no-dialogue-replace', 'Explicitly disable Venice TTS dialogue replacement (now the default — kept for backward compatibility with older scripts)')
2449
+ // Default is intentionally unset; resolved below to `0` when
2450
+ // --dialogue-replace is on (so model-generated narration / dialogue can't
2451
+ // fight the Venice TTS) and `1.0` otherwise. Pass the flag explicitly to
2452
+ // override either default — for example, --native-volume 0.2 keeps a soft
2453
+ // ambient bed under the TTS for shots whose model audio is just room tone.
2454
+ // Per-shot `shot.nativeAudio: 'mute' | 'duck' | 'keep'` always wins.
2455
+ .option('--native-volume <vol>', 'Native audio volume in the final mix (0-1). Default: 0 with --dialogue-replace, 1.0 otherwise. Per-shot shot.nativeAudio overrides this default.')
2456
+ .action(async (opts) => {
2457
+ const series = await loadSeries(resolve(opts.project));
2458
+ if (!series) {
2459
+ console.error('Series not found.');
2460
+ process.exit(1);
2461
+ }
2462
+ const script = await loadEpisodeScript(series, opts.episode);
2463
+ if (!script) {
2464
+ console.error(`Episode ${opts.episode} script not found.`);
2465
+ process.exit(1);
2466
+ }
2467
+ const episodeDir = getEpisodeDir(series, opts.episode);
2468
+ const sceneDir = join(episodeDir, 'scene-001');
2469
+ const audioDir = join(episodeDir, 'audio');
2470
+ const videoFiles = await collectShotVideos(sceneDir);
2471
+ if (videoFiles.length === 0) {
2472
+ console.error('No video clips found. Run generate-videos first.');
2473
+ process.exit(1);
2474
+ }
2475
+ console.log(`Assembling Episode ${opts.episode}: ${script.title}`);
2476
+ console.log(` ${videoFiles.length} video clips`);
2477
+ const hasDialogueFiles = existsSync(audioDir) &&
2478
+ readdirSync(audioDir).some((f) => f.startsWith('dialogue-shot-'));
2479
+ // Default is now OFF — native model dialogue is the recommended path.
2480
+ // Only replace when the user explicitly opts in via --dialogue-replace
2481
+ // AND the TTS files exist.
2482
+ // Series-level audio strategy (set at series-creation time via the
2483
+ // upfront questionnaire) sets the default for --dialogue-replace:
2484
+ // - 'native' : false (model audio plays as-is)
2485
+ // - 'lip-sync' : true (Venice TTS replaces native; lip-sync is
2486
+ // handled by routing dialogue shots to Wan 2.7
2487
+ // via videoDefaults.lipSyncModel)
2488
+ // - 'narrator-vo' : true (Venice TTS owns the dialogue lane; Seedance
2489
+ // was already told audio:false at queue time
2490
+ // via audioMix.suppressModelNarration, so this
2491
+ // ensures the final mix actually plays the TTS)
2492
+ // Operator flag (--dialogue-replace / --no-dialogue-replace) always wins.
2493
+ const seriesAudioStrategy = series.videoDefaults.audioStrategy;
2494
+ const strategyImpliesReplace = seriesAudioStrategy === 'lip-sync' || seriesAudioStrategy === 'narrator-vo';
2495
+ const dialogueReplaceFlagPassed = opts.dialogueReplace === true;
2496
+ const effectiveDialogueReplace = dialogueReplaceFlagPassed || strategyImpliesReplace;
2497
+ const useDialogueReplace = effectiveDialogueReplace && hasDialogueFiles;
2498
+ // Resolve --native-volume default: 0 with --dialogue-replace (Venice TTS
2499
+ // owns the dialogue lane; let nothing compete), 1.0 otherwise. An
2500
+ // explicit operator flag always wins.
2501
+ const nativeVolumeDefault = useDialogueReplace ? 0 : 1.0;
2502
+ const nativeVolume = opts.nativeVolume !== undefined
2503
+ ? parseFloat(opts.nativeVolume)
2504
+ : nativeVolumeDefault;
2505
+ if (useDialogueReplace) {
2506
+ const explicit = opts.nativeVolume !== undefined ? ' (operator override)' : ' (default)';
2507
+ console.log(` Dialogue replacement: ON (Venice TTS; native audio at ${Math.round(nativeVolume * 100)}%${explicit})`);
2508
+ }
2509
+ else if (opts.dialogueReplace === true && !hasDialogueFiles) {
2510
+ console.log(` Dialogue replacement: OFF (--dialogue-replace was set but no dialogue-shot-NNN.mp3 files exist -- run override-audio --dialogue first)`);
2511
+ }
2512
+ else {
2513
+ console.log(` Dialogue replacement: OFF (default — using native model dialogue at ${Math.round(nativeVolume * 100)}% volume)`);
2514
+ }
2515
+ // Collect per-shot trim/flip metadata from script
2516
+ const shotTrims = script.shots
2517
+ .filter(s => s.trimStart || s.trimEnd || s.flip)
2518
+ .map(s => ({ shotNumber: s.shotNumber, trimStart: s.trimStart, trimEnd: s.trimEnd, flip: s.flip }));
2519
+ if (shotTrims.length > 0) {
2520
+ console.log(` Trim/flip metadata: ${shotTrims.length} shots`);
2521
+ }
2522
+ const endingTitleShot = [...script.shots].reverse().find(s => s.titleOverlay?.text?.trim());
2523
+ if (endingTitleShot?.titleOverlay?.text) {
2524
+ console.log(` Ending title overlay: "${endingTitleShot.titleOverlay.text}"`);
2525
+ }
2526
+ let srtPath;
2527
+ if (opts.subtitles !== false) {
2528
+ const subtitles = generateSubtitles(script.shots, sceneDir);
2529
+ if (subtitles.length > 0) {
2530
+ srtPath = join(episodeDir, 'subtitles.srt');
2531
+ await saveSrt(subtitles, srtPath);
2532
+ console.log(` Generated ${subtitles.length} subtitle entries`);
2533
+ }
2534
+ }
2535
+ const musicPath = join(audioDir, 'music.mp3');
2536
+ const hasMusic = opts.music !== false && existsSync(musicPath);
2537
+ const ambientLayerNames = [
2538
+ 'ambient-rain-heavy.mp3',
2539
+ 'ambient-rain.mp3',
2540
+ 'ambient-crowd.mp3',
2541
+ 'ambient-quiet-night.mp3',
2542
+ ];
2543
+ const ambientPaths = ambientLayerNames
2544
+ .map(name => join(audioDir, name))
2545
+ .filter(p => existsSync(p));
2546
+ const ambientPath = ambientPaths[0];
2547
+ const hasAmbient = opts.ambient !== false && ambientPaths.length > 0;
2548
+ if (hasAmbient) {
2549
+ console.log(` Ambient beds: ${ambientPaths.length} layer(s) found (${Math.round(parseFloat(opts.ambientVolume) * 100)}% volume)`);
2550
+ for (const p of ambientPaths) {
2551
+ console.log(` - ${p.split('/').pop()}`);
2552
+ }
2553
+ if (ambientPaths.length > 1) {
2554
+ console.log(` Note: assemble-episode uses only the first ambient layer. For multi-layer mixing, use: npx tsx scripts/mix-episode-audio.ts`);
2555
+ }
2556
+ }
2557
+ else if (opts.ambient !== false) {
2558
+ console.log(` Ambient bed: OFF (no ambient bed found in audio/)`);
2559
+ }
2560
+ const epNum = String(opts.episode).padStart(3, '0');
2561
+ const outputPath = join(episodeDir, `episode-${epNum}-final.mp4`);
2562
+ // Resolve audio paths for each music cue so the assembler picks up
2563
+ // either spec.audioPath (script-provided) or the canonical
2564
+ // audio/music-cue-NNN.mp3 next to the episode. When a cue has no
2565
+ // resolvable audio, the assembler falls back to the single-bed musicPath.
2566
+ const cueAudioPathFor = (spec) => {
2567
+ if (spec.audioPath)
2568
+ return resolve(opts.project, spec.audioPath);
2569
+ const shotId = typeof spec.startShot === 'number'
2570
+ ? String(spec.startShot).padStart(3, '0')
2571
+ : spec.startShot;
2572
+ const candidates = [
2573
+ join(audioDir, `music-cue-${shotId}.mp3`),
2574
+ join(audioDir, `music-shot-${shotId}.mp3`),
2575
+ ];
2576
+ for (const c of candidates) {
2577
+ if (existsSync(c))
2578
+ return c;
2579
+ }
2580
+ return undefined;
2581
+ };
2582
+ // Hydrate musicCues with resolved audio paths so renderMusicCuesTrack
2583
+ // can render directly without re-resolving inside the assembler.
2584
+ const hydratedCues = script.musicCues?.map(spec => ({
2585
+ ...spec,
2586
+ audioPath: spec.audioPath ? resolve(opts.project, spec.audioPath) : cueAudioPathFor(spec),
2587
+ }));
2588
+ await assembleEpisode({
2589
+ videoFiles,
2590
+ outputPath,
2591
+ srtPath,
2592
+ musicPath: hasMusic ? musicPath : undefined,
2593
+ musicVolume: 0.15,
2594
+ musicCues: hydratedCues,
2595
+ shots: script.shots,
2596
+ ambientBedPath: hasAmbient ? ambientPath : undefined,
2597
+ ambientBedVolume: parseFloat(opts.ambientVolume),
2598
+ dialogueDir: useDialogueReplace ? audioDir : undefined,
2599
+ nativeAudioVolume: nativeVolume,
2600
+ shotTrims,
2601
+ endingTitleOverlay: endingTitleShot?.titleOverlay,
2602
+ audioMix: script.audioMix,
2603
+ });
2604
+ const ep = series.episodes.find(e => e.number === opts.episode);
2605
+ if (ep)
2606
+ ep.status = 'assembled';
2607
+ await saveSeries(series);
2608
+ console.log(`\nFinal episode: ${outputPath}`);
2609
+ });
2610
+ const EXPORT_FORMAT_EXTENSIONS = {
2611
+ fcpxml: '.fcpxml',
2612
+ premiere: '.premiere.xml',
2613
+ davinci: '.resolve.fcpxml',
2614
+ };
2615
+ const EXPORT_IMPORT_HINTS = {
2616
+ fcpxml: 'In FCP X: File > Import > XML… and select this file.',
2617
+ premiere: 'In Premiere Pro: File > Import… and select this file.',
2618
+ davinci: 'In DaVinci Resolve: File > Import > Timeline… and select this file.',
2619
+ };
2620
+ async function runTimelineExport(opts) {
2621
+ const series = await loadSeries(resolve(opts.project));
2622
+ if (!series) {
2623
+ console.error('Series not found.');
2624
+ process.exit(1);
2625
+ }
2626
+ const script = await loadEpisodeScript(series, opts.episode);
2627
+ if (!script) {
2628
+ console.error(`Episode ${opts.episode} script not found.`);
2629
+ process.exit(1);
2630
+ }
2631
+ const episodeDir = getEpisodeDir(series, opts.episode);
2632
+ const sceneDir = join(episodeDir, 'scene-001');
2633
+ const audioDir = join(episodeDir, 'audio');
2634
+ const sfxDir = join(audioDir, 'sfx');
2635
+ const videoFiles = await collectShotVideos(sceneDir);
2636
+ if (videoFiles.length === 0) {
2637
+ console.error('No video clips found. Run generate-videos first.');
2638
+ process.exit(1);
2639
+ }
2640
+ const { exportTimeline } = await import('./timeline-export/index.js');
2641
+ const { spawnSync: spawnSyncLocal } = await import('node:child_process');
2642
+ function probeDur(path) {
2643
+ const r = spawnSyncLocal('ffprobe', [
2644
+ '-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', path,
2645
+ ], { encoding: 'utf-8' });
2646
+ return parseFloat(String(r.stdout).trim());
2647
+ }
2648
+ let cursor = 0;
2649
+ const segments = [];
2650
+ for (const path of videoFiles) {
2651
+ const dur = probeDur(path);
2652
+ const filename = basename(path, '.mp4');
2653
+ segments.push({ path, label: filename, durSec: dur, startSec: cursor });
2654
+ cursor += dur;
2655
+ }
2656
+ const masterDur = cursor;
2657
+ // Placement map keyed by shot id ( zero-padded form).
2658
+ const placementMap = {};
2659
+ for (const seg of segments) {
2660
+ const m = seg.label.match(/^shot-(\d+)([a-zA-Z]*)/);
2661
+ if (!m)
2662
+ continue;
2663
+ const key = String(m[1]).padStart(3, '0') + m[2];
2664
+ placementMap[key] = { startSec: seg.startSec, endSec: seg.startSec + seg.durSec };
2665
+ }
2666
+ const audio = [];
2667
+ if (existsSync(audioDir)) {
2668
+ for (const shot of script.shots) {
2669
+ const key = String(shot.shotNumber).padStart(3, '0')
2670
+ + (shot.shotIdSuffix ?? '');
2671
+ const place = placementMap[key];
2672
+ if (!place)
2673
+ continue;
2674
+ const path = join(audioDir, `dialogue-shot-${key}.mp3`);
2675
+ if (!existsSync(path))
2676
+ continue;
2677
+ audio.push({
2678
+ path,
2679
+ label: `${key} ${shot.dialogue?.character ?? 'NARRATOR'}`,
2680
+ startSec: place.startSec + 0.2,
2681
+ audioDur: probeDur(path),
2682
+ lane: -1,
2683
+ role: 'dialogue.dialogue',
2684
+ });
2685
+ }
2686
+ if (existsSync(sfxDir)) {
2687
+ const sfxFiles = readdirSync(sfxDir).filter((f) => f.endsWith('.mp3'));
2688
+ for (const f of sfxFiles) {
2689
+ const m = f.match(/shot-(\d+)([a-zA-Z]*)/);
2690
+ if (!m)
2691
+ continue;
2692
+ const key = String(m[1]).padStart(3, '0') + m[2];
2693
+ const place = placementMap[key];
2694
+ if (!place)
2695
+ continue;
2696
+ const fullPath = join(sfxDir, f);
2697
+ audio.push({
2698
+ path: fullPath,
2699
+ label: f.replace(/\.mp3$/, ''),
2700
+ startSec: place.startSec,
2701
+ audioDur: probeDur(fullPath),
2702
+ lane: -2,
2703
+ role: 'effects.effects',
2704
+ });
2705
+ }
2706
+ }
2707
+ const musicPath = join(audioDir, 'music.mp3');
2708
+ if (existsSync(musicPath)) {
2709
+ audio.push({
2710
+ path: musicPath,
2711
+ label: 'music',
2712
+ startSec: 0,
2713
+ audioDur: Math.min(probeDur(musicPath), masterDur),
2714
+ lane: -3,
2715
+ role: 'music.music',
2716
+ });
2717
+ }
2718
+ }
2719
+ const epNum = String(opts.episode).padStart(3, '0');
2720
+ const ext = EXPORT_FORMAT_EXTENSIONS[opts.format];
2721
+ const outPath = join(episodeDir, `episode-${epNum}${ext}`);
2722
+ const finalPath = await exportTimeline(opts.format, {
2723
+ outputPath: outPath,
2724
+ segments,
2725
+ audio,
2726
+ totalDurationSec: masterDur,
2727
+ fps: parseInt(opts.fps, 10),
2728
+ width: parseInt(opts.width, 10),
2729
+ height: parseInt(opts.height, 10),
2730
+ eventName: script.title,
2731
+ });
2732
+ console.log(`Timeline (${opts.format}): ${finalPath}`);
2733
+ console.log(` Segments: ${segments.length}`);
2734
+ console.log(` Connected clips: ${audio.length}`);
2735
+ console.log(` dialogue (lane -1): ${audio.filter(a => a.lane === -1).length}`);
2736
+ console.log(` SFX (lane -2): ${audio.filter(a => a.lane === -2).length}`);
2737
+ console.log(` music (lane -3): ${audio.filter(a => a.lane === -3).length}`);
2738
+ console.log(`\n${EXPORT_IMPORT_HINTS[opts.format]}`);
2739
+ }
2740
+ program
2741
+ .command('export-timeline')
2742
+ .description('Export an XML timeline for FCP X / Premiere / DaVinci Resolve')
2743
+ .requiredOption('-p, --project <dir>', 'Series output directory')
2744
+ .requiredOption('-e, --episode <number>', 'Episode number', parseInt)
2745
+ .option('--format <fmt>', 'fcpxml | premiere | davinci', 'fcpxml')
2746
+ .option('--fps <fps>', 'Frames per second', '24')
2747
+ .option('--width <px>', 'Sequence width', '1920')
2748
+ .option('--height <px>', 'Sequence height', '1080')
2749
+ .action(async (opts) => {
2750
+ const validFormats = ['fcpxml', 'premiere', 'davinci'];
2751
+ if (!validFormats.includes(opts.format)) {
2752
+ console.error(`--format must be one of: ${validFormats.join(' | ')}`);
2753
+ process.exit(1);
2754
+ }
2755
+ await runTimelineExport({ ...opts, format: opts.format });
2756
+ });
2757
+ // Back-compat alias kept so anyone scripted against 's command name
2758
+ // still works. Forwards to runTimelineExport with format=fcpxml.
2759
+ program
2760
+ .command('export-fcpxml')
2761
+ .description('[Alias of export-timeline --format fcpxml]')
2762
+ .requiredOption('-p, --project <dir>', 'Series output directory')
2763
+ .requiredOption('-e, --episode <number>', 'Episode number', parseInt)
2764
+ .option('--fps <fps>', 'Frames per second', '24')
2765
+ .option('--width <px>', 'Sequence width', '1920')
2766
+ .option('--height <px>', 'Sequence height', '1080')
2767
+ .action(async (opts) => {
2768
+ await runTimelineExport({ ...opts, format: 'fcpxml' });
2769
+ });
2770
+ // ── produce-episode ───────────────────────────────────────────────────
2771
+ program
2772
+ .command('produce-episode')
2773
+ .description('Full pipeline: storyboard -> video -> music -> assembly')
2774
+ .requiredOption('-p, --project <dir>', 'Series output directory')
2775
+ .requiredOption('-e, --episode <number>', 'Episode number', parseInt)
2776
+ .option('--with-tts', 'Add Venice dialogue replacement for voice consistency across episodes', false)
2777
+ .option('--skip-music', 'Skip background music generation', false)
2778
+ .option('--resume-after-qa', 'Continue only after qa-approved.json exists', false)
2779
+ .action(async (opts) => {
2780
+ console.log('=== Full Episode Production Pipeline ===\n');
2781
+ const projectDir = resolve(opts.project);
2782
+ const series = await loadSeries(projectDir);
2783
+ if (!series)
2784
+ throw new Error(`Series not found: ${projectDir}`);
2785
+ const episodeDir = getEpisodeDir(series, opts.episode);
2786
+ const qaApprovedPath = join(episodeDir, 'qa-approved.json');
2787
+ if (!opts.resumeAfterQa) {
2788
+ console.log('Step 1: Generating storyboard panels...');
2789
+ await program.parseAsync(['', '', 'storyboard-episode', '-p', opts.project, '-e', String(opts.episode)]);
2790
+ console.log('\nStep 2: Running standalone vision QA...');
2791
+ await program.parseAsync(['', '', 'qa-storyboard', '-p', opts.project, '-e', String(opts.episode)]);
2792
+ console.log('\nProduction paused at the QA gate. Review qa-report.json and the panels.');
2793
+ console.log(`Approve with: venice-video qa-approve -p "${projectDir}" -e ${opts.episode}`);
2794
+ console.log(`Then resume: venice-video produce-episode -p "${projectDir}" -e ${opts.episode} --resume-after-qa`);
2795
+ return;
2796
+ }
2797
+ if (!existsSync(qaApprovedPath)) {
2798
+ throw new Error(`QA approval is missing: ${qaApprovedPath}. Run qa-approve before --resume-after-qa.`);
2799
+ }
2800
+ console.log('Step 3: Generating video clips (dialogue + SFX + ambient via native model audio)...');
2801
+ await program.parseAsync(['', '', 'generate-videos', '-p', opts.project, '-e', String(opts.episode)]);
2802
+ if (opts.withTts) {
2803
+ console.log('\nStep 4: Replacing dialogue with Venice TTS (voice consistency mode)...');
2804
+ await program.parseAsync(['', '', 'override-audio', '-p', opts.project, '-e', String(opts.episode), '--dialogue']);
2805
+ }
2806
+ if (!opts.skipMusic) {
2807
+ const stepNum = opts.withTts ? 5 : 4;
2808
+ console.log(`\nStep ${stepNum}: Generating background music...`);
2809
+ await program.parseAsync(['', '', 'generate-music', '-p', opts.project, '-e', String(opts.episode)]);
2810
+ }
2811
+ const finalStep = opts.withTts ? (opts.skipMusic ? 5 : 6) : (opts.skipMusic ? 4 : 5);
2812
+ console.log(`\nStep ${finalStep}: Assembling final episode (music + subtitles)...`);
2813
+ await program.parseAsync(['', '', 'assemble-episode', '-p', opts.project, '-e', String(opts.episode)]);
2814
+ console.log('\n=== Production Complete ===');
2815
+ });
2816
+ await program.parseAsync();
2817
+ // ── Helpers ───────────────────────────────────────────────────────────
2818
+ function generateCompareHtml(styles, seriesName) {
2819
+ const cards = styles.map(s => `
2820
+ <div class="card">
2821
+ <img src="${s.name}.png" alt="${s.name}" />
2822
+ <h3>${s.name}</h3>
2823
+ <p>${s.style}</p>
2824
+ <p class="sub">${s.palette}</p>
2825
+ </div>
2826
+ `).join('');
2827
+ return `<!DOCTYPE html>
2828
+ <html>
2829
+ <head>
2830
+ <title>Aesthetic Comparison - ${seriesName}</title>
2831
+ <style>
2832
+ body { background: #111; color: #eee; font-family: system-ui; padding: 2rem; }
2833
+ h1 { text-align: center; }
2834
+ .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1.5rem; }
2835
+ .card { background: #222; border-radius: 12px; overflow: hidden; }
2836
+ .card img { width: 100%; display: block; }
2837
+ .card h3 { padding: 0.5rem 1rem 0; margin: 0; }
2838
+ .card p { padding: 0 1rem 0.5rem; margin: 0; color: #aaa; font-size: 0.9rem; }
2839
+ .card .sub { color: #777; font-size: 0.8rem; padding-bottom: 1rem; }
2840
+ </style>
2841
+ </head>
2842
+ <body>
2843
+ <h1>${seriesName} - Aesthetic Options</h1>
2844
+ <div class="grid">${cards}</div>
2845
+ </body>
2846
+ </html>`;
2847
+ }
2848
+ function normalizeAudioDurationSeconds(rawValue, fallbackSeconds) {
2849
+ const parsed = parseInt(rawValue, 10);
2850
+ if (!Number.isFinite(parsed) || parsed <= 0) {
2851
+ return fallbackSeconds;
2852
+ }
2853
+ if (parsed > 1_000) {
2854
+ return Math.max(1, Math.round(parsed / 1_000));
2855
+ }
2856
+ return parsed;
2857
+ }
2858
+ function parseShotDurationSeconds(duration) {
2859
+ const match = duration.match(/(\d+(?:\.\d+)?)\s*s/i);
2860
+ if (match) {
2861
+ return Math.max(1, Math.round(parseFloat(match[1])));
2862
+ }
2863
+ const numeric = parseFloat(duration);
2864
+ if (Number.isFinite(numeric) && numeric > 0) {
2865
+ return Math.max(1, Math.round(numeric));
2866
+ }
2867
+ return 5;
2868
+ }
2869
+ //# sourceMappingURL=cli.js.map