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.
- package/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +598 -0
- package/dist/assembly/manifest-builder.d.ts +8 -0
- package/dist/assembly/manifest-builder.d.ts.map +1 -0
- package/dist/assembly/manifest-builder.js +196 -0
- package/dist/assembly/manifest-builder.js.map +1 -0
- package/dist/assembly/remotion-scaffold.d.ts +14 -0
- package/dist/assembly/remotion-scaffold.d.ts.map +1 -0
- package/dist/assembly/remotion-scaffold.js +292 -0
- package/dist/assembly/remotion-scaffold.js.map +1 -0
- package/dist/assembly/types.d.ts +67 -0
- package/dist/assembly/types.d.ts.map +1 -0
- package/dist/assembly/types.js +5 -0
- package/dist/assembly/types.js.map +1 -0
- package/dist/characters/describer.d.ts +32 -0
- package/dist/characters/describer.d.ts.map +1 -0
- package/dist/characters/describer.js +309 -0
- package/dist/characters/describer.js.map +1 -0
- package/dist/characters/extractor.d.ts +27 -0
- package/dist/characters/extractor.d.ts.map +1 -0
- package/dist/characters/extractor.js +274 -0
- package/dist/characters/extractor.js.map +1 -0
- package/dist/characters/reference-manager.d.ts +95 -0
- package/dist/characters/reference-manager.d.ts.map +1 -0
- package/dist/characters/reference-manager.js +346 -0
- package/dist/characters/reference-manager.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +310 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +66 -0
- package/dist/config.js.map +1 -0
- package/dist/editing/aligner.d.ts +44 -0
- package/dist/editing/aligner.d.ts.map +1 -0
- package/dist/editing/aligner.js +201 -0
- package/dist/editing/aligner.js.map +1 -0
- package/dist/editing/edl.d.ts +45 -0
- package/dist/editing/edl.d.ts.map +1 -0
- package/dist/editing/edl.js +133 -0
- package/dist/editing/edl.js.map +1 -0
- package/dist/editing/overlays.d.ts +97 -0
- package/dist/editing/overlays.d.ts.map +1 -0
- package/dist/editing/overlays.js +41 -0
- package/dist/editing/overlays.js.map +1 -0
- package/dist/editing/packer.d.ts +52 -0
- package/dist/editing/packer.d.ts.map +1 -0
- package/dist/editing/packer.js +170 -0
- package/dist/editing/packer.js.map +1 -0
- package/dist/editing/providers/whisper-cpp.d.ts +32 -0
- package/dist/editing/providers/whisper-cpp.d.ts.map +1 -0
- package/dist/editing/providers/whisper-cpp.js +239 -0
- package/dist/editing/providers/whisper-cpp.js.map +1 -0
- package/dist/editing/render.d.ts +36 -0
- package/dist/editing/render.d.ts.map +1 -0
- package/dist/editing/render.js +227 -0
- package/dist/editing/render.js.map +1 -0
- package/dist/editing/self-eval.d.ts +48 -0
- package/dist/editing/self-eval.d.ts.map +1 -0
- package/dist/editing/self-eval.js +293 -0
- package/dist/editing/self-eval.js.map +1 -0
- package/dist/editing/silence.d.ts +76 -0
- package/dist/editing/silence.d.ts.map +1 -0
- package/dist/editing/silence.js +189 -0
- package/dist/editing/silence.js.map +1 -0
- package/dist/editing/types.d.ts +178 -0
- package/dist/editing/types.d.ts.map +1 -0
- package/dist/editing/types.js +13 -0
- package/dist/editing/types.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -0
- package/dist/interactive.d.ts +12 -0
- package/dist/interactive.d.ts.map +1 -0
- package/dist/interactive.js +78 -0
- package/dist/interactive.js.map +1 -0
- package/dist/mini-drama/assembler.d.ts +61 -0
- package/dist/mini-drama/assembler.d.ts.map +1 -0
- package/dist/mini-drama/assembler.js +466 -0
- package/dist/mini-drama/assembler.js.map +1 -0
- package/dist/mini-drama/audio-mix.d.ts +65 -0
- package/dist/mini-drama/audio-mix.d.ts.map +1 -0
- package/dist/mini-drama/audio-mix.js +118 -0
- package/dist/mini-drama/audio-mix.js.map +1 -0
- package/dist/mini-drama/cli.d.ts +3 -0
- package/dist/mini-drama/cli.d.ts.map +1 -0
- package/dist/mini-drama/cli.js +2869 -0
- package/dist/mini-drama/cli.js.map +1 -0
- package/dist/mini-drama/fcpxml-export.d.ts +2 -0
- package/dist/mini-drama/fcpxml-export.d.ts.map +1 -0
- package/dist/mini-drama/fcpxml-export.js +5 -0
- package/dist/mini-drama/fcpxml-export.js.map +1 -0
- package/dist/mini-drama/generation-planner.d.ts +24 -0
- package/dist/mini-drama/generation-planner.d.ts.map +1 -0
- package/dist/mini-drama/generation-planner.js +304 -0
- package/dist/mini-drama/generation-planner.js.map +1 -0
- package/dist/mini-drama/location-generator.d.ts +23 -0
- package/dist/mini-drama/location-generator.d.ts.map +1 -0
- package/dist/mini-drama/location-generator.js +144 -0
- package/dist/mini-drama/location-generator.js.map +1 -0
- package/dist/mini-drama/music-cues.d.ts +96 -0
- package/dist/mini-drama/music-cues.d.ts.map +1 -0
- package/dist/mini-drama/music-cues.js +332 -0
- package/dist/mini-drama/music-cues.js.map +1 -0
- package/dist/mini-drama/panel-fixer.d.ts +12 -0
- package/dist/mini-drama/panel-fixer.d.ts.map +1 -0
- package/dist/mini-drama/panel-fixer.js +319 -0
- package/dist/mini-drama/panel-fixer.js.map +1 -0
- package/dist/mini-drama/prompt-builder.d.ts +149 -0
- package/dist/mini-drama/prompt-builder.d.ts.map +1 -0
- package/dist/mini-drama/prompt-builder.js +722 -0
- package/dist/mini-drama/prompt-builder.js.map +1 -0
- package/dist/mini-drama/reference-slots.d.ts +31 -0
- package/dist/mini-drama/reference-slots.d.ts.map +1 -0
- package/dist/mini-drama/reference-slots.js +161 -0
- package/dist/mini-drama/reference-slots.js.map +1 -0
- package/dist/mini-drama/shot-paths.d.ts +77 -0
- package/dist/mini-drama/shot-paths.d.ts.map +1 -0
- package/dist/mini-drama/shot-paths.js +101 -0
- package/dist/mini-drama/shot-paths.js.map +1 -0
- package/dist/mini-drama/storyboard-reference-generator.d.ts +47 -0
- package/dist/mini-drama/storyboard-reference-generator.d.ts.map +1 -0
- package/dist/mini-drama/storyboard-reference-generator.js +293 -0
- package/dist/mini-drama/storyboard-reference-generator.js.map +1 -0
- package/dist/mini-drama/subtitle-generator.d.ts +12 -0
- package/dist/mini-drama/subtitle-generator.d.ts.map +1 -0
- package/dist/mini-drama/subtitle-generator.js +139 -0
- package/dist/mini-drama/subtitle-generator.js.map +1 -0
- package/dist/mini-drama/timeline-export/davinci-fcpxml.d.ts +3 -0
- package/dist/mini-drama/timeline-export/davinci-fcpxml.d.ts.map +1 -0
- package/dist/mini-drama/timeline-export/davinci-fcpxml.js +150 -0
- package/dist/mini-drama/timeline-export/davinci-fcpxml.js.map +1 -0
- package/dist/mini-drama/timeline-export/fcpxml.d.ts +11 -0
- package/dist/mini-drama/timeline-export/fcpxml.d.ts.map +1 -0
- package/dist/mini-drama/timeline-export/fcpxml.js +140 -0
- package/dist/mini-drama/timeline-export/fcpxml.js.map +1 -0
- package/dist/mini-drama/timeline-export/index.d.ts +13 -0
- package/dist/mini-drama/timeline-export/index.d.ts.map +1 -0
- package/dist/mini-drama/timeline-export/index.js +26 -0
- package/dist/mini-drama/timeline-export/index.js.map +1 -0
- package/dist/mini-drama/timeline-export/premiere-xmeml.d.ts +3 -0
- package/dist/mini-drama/timeline-export/premiere-xmeml.d.ts.map +1 -0
- package/dist/mini-drama/timeline-export/premiere-xmeml.js +211 -0
- package/dist/mini-drama/timeline-export/premiere-xmeml.js.map +1 -0
- package/dist/mini-drama/timeline-export/probe.d.ts +53 -0
- package/dist/mini-drama/timeline-export/probe.d.ts.map +1 -0
- package/dist/mini-drama/timeline-export/probe.js +89 -0
- package/dist/mini-drama/timeline-export/probe.js.map +1 -0
- package/dist/mini-drama/timeline-export/types.d.ts +57 -0
- package/dist/mini-drama/timeline-export/types.d.ts.map +1 -0
- package/dist/mini-drama/timeline-export/types.js +14 -0
- package/dist/mini-drama/timeline-export/types.js.map +1 -0
- package/dist/mini-drama/video-generator.d.ts +22 -0
- package/dist/mini-drama/video-generator.d.ts.map +1 -0
- package/dist/mini-drama/video-generator.js +1305 -0
- package/dist/mini-drama/video-generator.js.map +1 -0
- package/dist/mini-drama/voice-reference.d.ts +42 -0
- package/dist/mini-drama/voice-reference.d.ts.map +1 -0
- package/dist/mini-drama/voice-reference.js +170 -0
- package/dist/mini-drama/voice-reference.js.map +1 -0
- package/dist/output/html-renderer.d.ts +2 -0
- package/dist/output/html-renderer.d.ts.map +1 -0
- package/dist/output/html-renderer.js +37 -0
- package/dist/output/html-renderer.js.map +1 -0
- package/dist/parsers/fountain-parser.d.ts +34 -0
- package/dist/parsers/fountain-parser.d.ts.map +1 -0
- package/dist/parsers/fountain-parser.js +71 -0
- package/dist/parsers/fountain-parser.js.map +1 -0
- package/dist/parsers/pdf-parser.d.ts +24 -0
- package/dist/parsers/pdf-parser.d.ts.map +1 -0
- package/dist/parsers/pdf-parser.js +237 -0
- package/dist/parsers/pdf-parser.js.map +1 -0
- package/dist/parsers/scene-extractor.d.ts +41 -0
- package/dist/parsers/scene-extractor.d.ts.map +1 -0
- package/dist/parsers/scene-extractor.js +400 -0
- package/dist/parsers/scene-extractor.js.map +1 -0
- package/dist/series/manager.d.ts +64 -0
- package/dist/series/manager.d.ts.map +1 -0
- package/dist/series/manager.js +282 -0
- package/dist/series/manager.js.map +1 -0
- package/dist/series/types.d.ts +641 -0
- package/dist/series/types.d.ts.map +1 -0
- package/dist/series/types.js +304 -0
- package/dist/series/types.js.map +1 -0
- package/dist/storyboard/assembler.d.ts +134 -0
- package/dist/storyboard/assembler.d.ts.map +1 -0
- package/dist/storyboard/assembler.js +319 -0
- package/dist/storyboard/assembler.js.map +1 -0
- package/dist/storyboard/prompt-builder.d.ts +113 -0
- package/dist/storyboard/prompt-builder.d.ts.map +1 -0
- package/dist/storyboard/prompt-builder.js +578 -0
- package/dist/storyboard/prompt-builder.js.map +1 -0
- package/dist/storyboard/shot-planner.d.ts +34 -0
- package/dist/storyboard/shot-planner.d.ts.map +1 -0
- package/dist/storyboard/shot-planner.js +446 -0
- package/dist/storyboard/shot-planner.js.map +1 -0
- package/dist/user-config.d.ts +15 -0
- package/dist/user-config.d.ts.map +1 -0
- package/dist/user-config.js +103 -0
- package/dist/user-config.js.map +1 -0
- package/dist/venice/audio-preflight.d.ts +80 -0
- package/dist/venice/audio-preflight.d.ts.map +1 -0
- package/dist/venice/audio-preflight.js +142 -0
- package/dist/venice/audio-preflight.js.map +1 -0
- package/dist/venice/audio.d.ts +88 -0
- package/dist/venice/audio.d.ts.map +1 -0
- package/dist/venice/audio.js +234 -0
- package/dist/venice/audio.js.map +1 -0
- package/dist/venice/client.d.ts +86 -0
- package/dist/venice/client.d.ts.map +1 -0
- package/dist/venice/client.js +375 -0
- package/dist/venice/client.js.map +1 -0
- package/dist/venice/edit.d.ts +57 -0
- package/dist/venice/edit.d.ts.map +1 -0
- package/dist/venice/edit.js +100 -0
- package/dist/venice/edit.js.map +1 -0
- package/dist/venice/generate.d.ts +47 -0
- package/dist/venice/generate.d.ts.map +1 -0
- package/dist/venice/generate.js +150 -0
- package/dist/venice/generate.js.map +1 -0
- package/dist/venice/image-bytes.d.ts +26 -0
- package/dist/venice/image-bytes.d.ts.map +1 -0
- package/dist/venice/image-bytes.js +101 -0
- package/dist/venice/image-bytes.js.map +1 -0
- package/dist/venice/models.d.ts +143 -0
- package/dist/venice/models.d.ts.map +1 -0
- package/dist/venice/models.js +984 -0
- package/dist/venice/models.js.map +1 -0
- package/dist/venice/multi-edit.d.ts +40 -0
- package/dist/venice/multi-edit.d.ts.map +1 -0
- package/dist/venice/multi-edit.js +97 -0
- package/dist/venice/multi-edit.js.map +1 -0
- package/dist/venice/provenance.d.ts +66 -0
- package/dist/venice/provenance.d.ts.map +1 -0
- package/dist/venice/provenance.js +151 -0
- package/dist/venice/provenance.js.map +1 -0
- package/dist/venice/recipe.d.ts +69 -0
- package/dist/venice/recipe.d.ts.map +1 -0
- package/dist/venice/recipe.js +103 -0
- package/dist/venice/recipe.js.map +1 -0
- package/dist/venice/rejection.d.ts +63 -0
- package/dist/venice/rejection.d.ts.map +1 -0
- package/dist/venice/rejection.js +111 -0
- package/dist/venice/rejection.js.map +1 -0
- package/dist/venice/seedance-preflight.d.ts +38 -0
- package/dist/venice/seedance-preflight.d.ts.map +1 -0
- package/dist/venice/seedance-preflight.js +29 -0
- package/dist/venice/seedance-preflight.js.map +1 -0
- package/dist/venice/types.d.ts +212 -0
- package/dist/venice/types.d.ts.map +1 -0
- package/dist/venice/types.js +9 -0
- package/dist/venice/types.js.map +1 -0
- package/dist/venice/video.d.ts +77 -0
- package/dist/venice/video.d.ts.map +1 -0
- package/dist/venice/video.js +163 -0
- package/dist/venice/video.js.map +1 -0
- package/dist/venice/voices.d.ts +40 -0
- package/dist/venice/voices.d.ts.map +1 -0
- package/dist/venice/voices.js +194 -0
- package/dist/venice/voices.js.map +1 -0
- 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
|