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,1305 @@
|
|
|
1
|
+
import { writeFile, mkdir, appendFile } from 'node:fs/promises';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { existsSync, readFileSync, renameSync } from 'node:fs';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { VeniceRequestError } from '../venice/client.js';
|
|
6
|
+
import { MODELS_SUPPORTING_ELEMENTS, MODELS_SUPPORTING_REFERENCE_IMAGES, MODELS_SUPPORTING_SCENE_IMAGES, MODELS_SUPPORTING_END_IMAGE, MODELS_SUPPORTING_AUDIO_INPUT, MODELS_SUPPORTING_PER_REFERENCE_AUDIO, MODELS_SUPPORTING_REFERENCE_AUDIO, MODELS_USING_IMAGE_TAGS, isSeedanceVideoModel, DEFAULT_CHARACTER_CONSISTENCY_MODEL, getMaxReferenceImages, } from '../series/types.js';
|
|
7
|
+
import { padAudioForModel, probeAudioDurationSec } from '../venice/audio-preflight.js';
|
|
8
|
+
import { generateSpeech } from '../venice/audio.js';
|
|
9
|
+
import { getCharacterDir, getLocationDir, getLocation } from '../series/manager.js';
|
|
10
|
+
import { buildKlingMultiShotPrompt, buildVideoPrompt, resolveVideoModel, } from './prompt-builder.js';
|
|
11
|
+
import { generateVoiceReference, resolveVoiceReferenceAbsPath, VOICE_REF_MIN_SEC, VOICE_REF_MAX_SEC, } from './voice-reference.js';
|
|
12
|
+
import { parseShotDuration } from './generation-planner.js';
|
|
13
|
+
import { dialogueFileForShot, shotKey } from './shot-paths.js';
|
|
14
|
+
import { getVideoModel, modelSupportsDuration } from '../venice/models.js';
|
|
15
|
+
import { appendRecipePass } from '../venice/recipe.js';
|
|
16
|
+
const VIDEO_QUEUE_PATH = '/api/v1/video/queue';
|
|
17
|
+
const VIDEO_RETRIEVE_PATH = '/api/v1/video/retrieve';
|
|
18
|
+
const VIDEO_COMPLETE_PATH = '/api/v1/video/complete';
|
|
19
|
+
const POLL_INTERVAL_MS = 10_000;
|
|
20
|
+
const MULTISHOT_RETRY_DELAY_MS = 15_000;
|
|
21
|
+
function runCommand(command, args) {
|
|
22
|
+
const result = spawnSync(command, args, {
|
|
23
|
+
encoding: 'utf-8',
|
|
24
|
+
stdio: 'pipe',
|
|
25
|
+
});
|
|
26
|
+
if (result.error)
|
|
27
|
+
throw result.error;
|
|
28
|
+
if (result.status !== 0) {
|
|
29
|
+
const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '';
|
|
30
|
+
const stdout = typeof result.stdout === 'string' ? result.stdout.trim() : '';
|
|
31
|
+
const detail = stderr || stdout || `exit code ${result.status}`;
|
|
32
|
+
throw new Error(`${command} failed: ${detail}`);
|
|
33
|
+
}
|
|
34
|
+
return typeof result.stdout === 'string' ? result.stdout : '';
|
|
35
|
+
}
|
|
36
|
+
function sleep(ms) {
|
|
37
|
+
return new Promise(r => setTimeout(r, ms));
|
|
38
|
+
}
|
|
39
|
+
function extractLastFrame(videoPath, outputPath) {
|
|
40
|
+
const durationStr = runCommand('ffprobe', [
|
|
41
|
+
'-v',
|
|
42
|
+
'error',
|
|
43
|
+
'-show_entries',
|
|
44
|
+
'format=duration',
|
|
45
|
+
'-of',
|
|
46
|
+
'csv=p=0',
|
|
47
|
+
videoPath,
|
|
48
|
+
]).trim();
|
|
49
|
+
const duration = parseFloat(durationStr);
|
|
50
|
+
const seekTo = Math.max(0, duration - 0.05);
|
|
51
|
+
runCommand('ffmpeg', [
|
|
52
|
+
'-y',
|
|
53
|
+
'-ss',
|
|
54
|
+
String(seekTo),
|
|
55
|
+
'-i',
|
|
56
|
+
videoPath,
|
|
57
|
+
'-frames:v',
|
|
58
|
+
'1',
|
|
59
|
+
outputPath,
|
|
60
|
+
]);
|
|
61
|
+
}
|
|
62
|
+
function extractFirstFrame(videoPath, outputPath) {
|
|
63
|
+
runCommand('ffmpeg', [
|
|
64
|
+
'-y',
|
|
65
|
+
'-i',
|
|
66
|
+
videoPath,
|
|
67
|
+
'-frames:v',
|
|
68
|
+
'1',
|
|
69
|
+
'-q:v',
|
|
70
|
+
'2',
|
|
71
|
+
outputPath,
|
|
72
|
+
]);
|
|
73
|
+
}
|
|
74
|
+
function resolveDialogueShotId(shot) {
|
|
75
|
+
return shot.shotIdSuffix ? `${shot.shotNumber}${shot.shotIdSuffix}` : shot.shotNumber;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Resolves the path to the dialogue MP3 for a shot, generating it inline
|
|
79
|
+
* via Venice TTS if the shot has a locked voice and the file does not yet
|
|
80
|
+
* exist. Returns undefined when the shot has no usable voice and no
|
|
81
|
+
* pre-existing audio — the caller should fall back to letting Wan 2.7
|
|
82
|
+
* synthesize audio from the prompt's dialogue block.
|
|
83
|
+
*/
|
|
84
|
+
async function ensureDialogueAudio(client, series, shot, audioDir) {
|
|
85
|
+
if (!shot.dialogue)
|
|
86
|
+
return undefined;
|
|
87
|
+
const shotId = resolveDialogueShotId(shot);
|
|
88
|
+
const target = dialogueFileForShot(audioDir, shotId);
|
|
89
|
+
if (existsSync(target))
|
|
90
|
+
return target;
|
|
91
|
+
const character = series.characters.find(c => c.name.toUpperCase() === shot.dialogue.character.toUpperCase());
|
|
92
|
+
if (!character?.voiceId) {
|
|
93
|
+
console.warn(` No locked voice for ${shot.dialogue.character}; skipping inline TTS — Wan 2.7 will synthesize from the prompt.`);
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
await mkdir(audioDir, { recursive: true });
|
|
97
|
+
console.log(` Inline TTS: shot ${shotKey(shotId)} [${character.name}, voice=${character.voiceName ?? character.voiceId}]`);
|
|
98
|
+
try {
|
|
99
|
+
await generateSpeech(client, {
|
|
100
|
+
voiceId: character.voiceId,
|
|
101
|
+
text: shot.dialogue.line,
|
|
102
|
+
prompt: character.voiceDescription,
|
|
103
|
+
}, target);
|
|
104
|
+
return target;
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
console.warn(` Inline TTS failed (${err.message}); falling back to model-synthesized audio.`);
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Stage A + Stage B of AGENTS.md rule 32: render a Seedance R2V identity-
|
|
113
|
+
* lock pass (no audio, all character refs) and extract frame 1 as a PNG.
|
|
114
|
+
* Returns paths for both the intermediate video and the keyframe so the
|
|
115
|
+
* caller can wire them into the Wan 2.7 i2v stage and the saved metadata.
|
|
116
|
+
*
|
|
117
|
+
* Throws on any failure; the caller is responsible for falling back to
|
|
118
|
+
* the panel-anchored single-pass render.
|
|
119
|
+
*/
|
|
120
|
+
async function renderSeedanceKeyframe(client, series, shot, sceneDir, outputVideoPath, previousShot) {
|
|
121
|
+
const stageAVideoPath = outputVideoPath.replace(/\.mp4$/, '-r2v-keyframe.mp4');
|
|
122
|
+
const keyframePngPath = outputVideoPath.replace(/\.mp4$/, '-r2v-keyframe.png');
|
|
123
|
+
if (existsSync(keyframePngPath) && existsSync(stageAVideoPath)) {
|
|
124
|
+
console.log(` Stage A: reusing existing keyframe ${keyframePngPath}`);
|
|
125
|
+
const refs = collectReferenceImagePathsForShot(series, shot);
|
|
126
|
+
return {
|
|
127
|
+
keyframePngPath,
|
|
128
|
+
stageAVideoPath,
|
|
129
|
+
referenceImagePaths: refs,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// Re-route the shot to Seedance R2V by cloning it with no dialogue and
|
|
133
|
+
// forcing motion to 'high' (the planner skips Wan 2.7 routing on both
|
|
134
|
+
// signals). This re-uses the entire prompt-builder pipeline including
|
|
135
|
+
// the Seedance compatibility pre-flight and image-tag handling.
|
|
136
|
+
const stageAShot = {
|
|
137
|
+
...shot,
|
|
138
|
+
dialogue: null,
|
|
139
|
+
motion: 'high',
|
|
140
|
+
useReferenceImages: true,
|
|
141
|
+
};
|
|
142
|
+
const stageAPrompt = buildVideoPrompt(stageAShot, series, previousShot);
|
|
143
|
+
// Wan 2.7's audio + R2V's audio metadata don't mix — force off explicitly.
|
|
144
|
+
stageAPrompt.audio = false;
|
|
145
|
+
if (!isSeedanceVideoModel(stageAPrompt.model)) {
|
|
146
|
+
// Defensive: if the series's character consistency model isn't a Seedance
|
|
147
|
+
// family member, we still skip lip-sync at this stage but warn the user
|
|
148
|
+
// since the keyframe may inherit drift from the chosen model.
|
|
149
|
+
console.warn(` Stage A: characterConsistencyModel=${stageAPrompt.model} is not Seedance — keyframe will inherit that model's identity behavior.`);
|
|
150
|
+
}
|
|
151
|
+
const panelPath = getShotPanelPath(sceneDir, resolveDialogueShotId(shot));
|
|
152
|
+
const { elements, referenceImagePaths } = resolveCharacterElements(series, stageAShot, stageAPrompt);
|
|
153
|
+
console.log(` Stage A/3: ${stageAPrompt.model} keyframe render (identity lock, no audio)`);
|
|
154
|
+
await renderVideoFile(client, {
|
|
155
|
+
prompt: stageAPrompt,
|
|
156
|
+
anchorImagePath: panelPath,
|
|
157
|
+
outputPath: stageAVideoPath,
|
|
158
|
+
elements,
|
|
159
|
+
referenceImagePaths,
|
|
160
|
+
aspectRatio: series.storyboardAspectRatio ?? '16:9',
|
|
161
|
+
seedanceCompatibility: series.videoDefaults.seedanceCompatibility,
|
|
162
|
+
});
|
|
163
|
+
console.log(` Stage B/3: extracting first frame -> ${keyframePngPath}`);
|
|
164
|
+
extractFirstFrame(stageAVideoPath, keyframePngPath);
|
|
165
|
+
if (!existsSync(keyframePngPath)) {
|
|
166
|
+
throw new Error(`Stage B keyframe extraction produced no file at ${keyframePngPath}`);
|
|
167
|
+
}
|
|
168
|
+
await appendRecipePass(keyframePngPath, {
|
|
169
|
+
kind: 'mechanical',
|
|
170
|
+
role: 'mechanical',
|
|
171
|
+
model: 'ffmpeg',
|
|
172
|
+
label: 'keyframe extraction (frame 1 of Seedance R2V identity-lock pass)',
|
|
173
|
+
anchorImagePath: stageAVideoPath,
|
|
174
|
+
});
|
|
175
|
+
return {
|
|
176
|
+
keyframePngPath,
|
|
177
|
+
stageAVideoPath,
|
|
178
|
+
referenceImagePaths: referenceImagePaths ?? [],
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function collectReferenceImagePathsForShot(series, shot, modelId = DEFAULT_CHARACTER_CONSISTENCY_MODEL) {
|
|
182
|
+
const budget = getMaxReferenceImages(modelId);
|
|
183
|
+
const resolved = shot.characters
|
|
184
|
+
.map(name => series.characters.find(c => c.name.toUpperCase() === name.toUpperCase()))
|
|
185
|
+
.filter(Boolean);
|
|
186
|
+
if (resolved.length === 0)
|
|
187
|
+
return [];
|
|
188
|
+
return resolved
|
|
189
|
+
.slice(0, budget)
|
|
190
|
+
.flatMap(c => {
|
|
191
|
+
const dir = getCharacterDir(series, c.name);
|
|
192
|
+
return ['front.png', 'three-quarter.png']
|
|
193
|
+
.map(f => join(dir, f))
|
|
194
|
+
.filter(p => existsSync(p));
|
|
195
|
+
})
|
|
196
|
+
.slice(0, budget);
|
|
197
|
+
}
|
|
198
|
+
function imageToDataUri(imagePath, mimeType = 'image/png') {
|
|
199
|
+
const buffer = readFileSync(imagePath);
|
|
200
|
+
return `data:${mimeType};base64,${buffer.toString('base64')}`;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Resolve the best location reference image for a shot. Closer shot types
|
|
204
|
+
* (close-up / reaction / insert) prefer the medium ref; everything else
|
|
205
|
+
* prefers the wide establishing ref. Falls back through the other angles.
|
|
206
|
+
* Returns undefined when the shot has no location or no ref images exist.
|
|
207
|
+
*/
|
|
208
|
+
function getLocationRefPath(series, shot) {
|
|
209
|
+
if (!shot.location)
|
|
210
|
+
return undefined;
|
|
211
|
+
const loc = getLocation(series, shot.location);
|
|
212
|
+
if (!loc)
|
|
213
|
+
return undefined;
|
|
214
|
+
const dir = getLocationDir(series, loc.slug);
|
|
215
|
+
const closer = shot.type === 'close-up' || shot.type === 'reaction' || shot.type === 'insert';
|
|
216
|
+
const order = closer
|
|
217
|
+
? ['medium.png', 'wide.png', 'detail.png']
|
|
218
|
+
: ['wide.png', 'medium.png', 'detail.png'];
|
|
219
|
+
for (const f of order) {
|
|
220
|
+
const p = join(dir, f);
|
|
221
|
+
if (existsSync(p))
|
|
222
|
+
return p;
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Fold a location reference into the character reference_image_urls for a
|
|
228
|
+
* Seedance / HappyHorse R2V shot. Characters come first (one ref per
|
|
229
|
+
* character to keep the @ImageN mapping aligned with the prompt's
|
|
230
|
+
* characterElements), then the location takes the last free slot within the
|
|
231
|
+
* per-model budget (9 on Seedance R2V / HappyHorse 1.1 R2V).
|
|
232
|
+
*
|
|
233
|
+
* LEGACY fallback: shots whose prompt carries a full `referenceSlots` plan
|
|
234
|
+
* (the normal path on @Image-tag models) never reach this — the slot plan
|
|
235
|
+
* already interleaves characters, storyboard plates, and location angles.
|
|
236
|
+
*/
|
|
237
|
+
function foldLocationIntoReferences(series, shot, prompt, locationRefPath, existingCharRefs) {
|
|
238
|
+
const budget = getMaxReferenceImages(prompt.model);
|
|
239
|
+
// One image per character, ordered to match the prompt's @Image1..@ImageN.
|
|
240
|
+
const slotNames = (prompt.characterElements && prompt.characterElements.length > 0)
|
|
241
|
+
? prompt.characterElements.map(s => s.characterName)
|
|
242
|
+
: shot.characters;
|
|
243
|
+
const charRefs = slotNames
|
|
244
|
+
.map(name => {
|
|
245
|
+
const dir = getCharacterDir(series, name);
|
|
246
|
+
return ['front.png', 'three-quarter.png']
|
|
247
|
+
.map(f => join(dir, f))
|
|
248
|
+
.find(p => existsSync(p));
|
|
249
|
+
})
|
|
250
|
+
.filter((p) => Boolean(p));
|
|
251
|
+
if (charRefs.length >= budget) {
|
|
252
|
+
console.warn(` ⚠ Location ref for "${shot.location}" dropped: character refs already fill the ${budget}-image budget.`);
|
|
253
|
+
return existingCharRefs ?? charRefs.slice(0, budget);
|
|
254
|
+
}
|
|
255
|
+
console.log(` Location ref -> reference_image_urls slot @Image${charRefs.length + 1} (${shot.location})`);
|
|
256
|
+
return [...charRefs, locationRefPath].slice(0, budget);
|
|
257
|
+
}
|
|
258
|
+
async function persistCharacterJson(series, character) {
|
|
259
|
+
const dir = getCharacterDir(series, character.name);
|
|
260
|
+
if (!existsSync(dir))
|
|
261
|
+
await mkdir(dir, { recursive: true });
|
|
262
|
+
await writeFile(join(dir, 'character.json'), JSON.stringify(character, null, 2), 'utf-8');
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Ensure the dialogue speaker for a shot has a voice-donor reference clip when
|
|
266
|
+
* the shot routes to a reference-audio-capable model and voice references
|
|
267
|
+
* aren't disabled. Auto-generates one via seed-audio-1-0 (mirrors the inline
|
|
268
|
+
* TTS pattern in ensureDialogueAudio) and persists it to character.json so the
|
|
269
|
+
* prompt builder picks it up. Best-effort — a failure just skips the ref.
|
|
270
|
+
*/
|
|
271
|
+
async function ensureVoiceReferenceForShot(client, series, shot, previousShot) {
|
|
272
|
+
if (series.videoDefaults.voiceReferenceForDialogue === false)
|
|
273
|
+
return;
|
|
274
|
+
if (!shot.dialogue)
|
|
275
|
+
return;
|
|
276
|
+
const speaker = shot.dialogue.character.toUpperCase();
|
|
277
|
+
if (speaker === 'NARRATOR' || speaker === 'V.O.' || speaker === 'VO')
|
|
278
|
+
return;
|
|
279
|
+
const resolution = resolveVideoModel(shot, series, previousShot);
|
|
280
|
+
if (!MODELS_SUPPORTING_REFERENCE_AUDIO.has(resolution.modelId))
|
|
281
|
+
return;
|
|
282
|
+
const character = series.characters.find(c => c.name.toUpperCase() === speaker);
|
|
283
|
+
if (!character)
|
|
284
|
+
return;
|
|
285
|
+
const abs = resolveVoiceReferenceAbsPath(series, character);
|
|
286
|
+
if (character.voiceReferencePath && abs && existsSync(abs))
|
|
287
|
+
return; // already present
|
|
288
|
+
try {
|
|
289
|
+
const { relPath, model } = await generateVoiceReference(client, series, character);
|
|
290
|
+
character.voiceReferencePath = relPath;
|
|
291
|
+
character.voiceReferenceModel = model;
|
|
292
|
+
await persistCharacterJson(series, character);
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
console.warn(` ⚠ Voice reference generation failed for ${character.name} (${err.message}); shot will fall back to the [Char, voiceDesc] text.`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function resolveVoiceReferencePaths(series, prompt) {
|
|
299
|
+
if (!prompt.voiceReferenceSlots || prompt.voiceReferenceSlots.length === 0)
|
|
300
|
+
return [];
|
|
301
|
+
return [...prompt.voiceReferenceSlots]
|
|
302
|
+
.sort((a, b) => a.audioIndex - b.audioIndex)
|
|
303
|
+
.map(slot => {
|
|
304
|
+
const char = series.characters.find(c => c.name.toUpperCase() === slot.characterName.toUpperCase());
|
|
305
|
+
const abs = char ? resolveVoiceReferenceAbsPath(series, char) : undefined;
|
|
306
|
+
return abs && existsSync(abs) ? abs : undefined;
|
|
307
|
+
})
|
|
308
|
+
.filter((p) => Boolean(p));
|
|
309
|
+
}
|
|
310
|
+
function getVideoDuration(path) {
|
|
311
|
+
const out = runCommand('ffprobe', [
|
|
312
|
+
'-v',
|
|
313
|
+
'error',
|
|
314
|
+
'-show_entries',
|
|
315
|
+
'format=duration',
|
|
316
|
+
'-of',
|
|
317
|
+
'csv=p=0',
|
|
318
|
+
path,
|
|
319
|
+
]).trim();
|
|
320
|
+
return parseFloat(out);
|
|
321
|
+
}
|
|
322
|
+
function archiveExisting(outputPath) {
|
|
323
|
+
if (!existsSync(outputPath))
|
|
324
|
+
return;
|
|
325
|
+
let version = 1;
|
|
326
|
+
let archivePath = outputPath.replace(/\.mp4$/, `-v${version}.mp4`);
|
|
327
|
+
while (existsSync(archivePath)) {
|
|
328
|
+
version += 1;
|
|
329
|
+
archivePath = outputPath.replace(/\.mp4$/, `-v${version}.mp4`);
|
|
330
|
+
}
|
|
331
|
+
renameSync(outputPath, archivePath);
|
|
332
|
+
console.log(` Archived previous: ${archivePath}`);
|
|
333
|
+
}
|
|
334
|
+
function saveJson(path, data) {
|
|
335
|
+
return writeFile(path, JSON.stringify(data, null, 2), 'utf-8');
|
|
336
|
+
}
|
|
337
|
+
async function logFailedRequest(outputPath, body, error) {
|
|
338
|
+
const logDir = dirname(outputPath);
|
|
339
|
+
const logFile = join(logDir, 'failed-requests.log');
|
|
340
|
+
const timestamp = new Date().toISOString();
|
|
341
|
+
const sanitizedBody = { ...body };
|
|
342
|
+
if (sanitizedBody.image_url && typeof sanitizedBody.image_url === 'string' && sanitizedBody.image_url.length > 200) {
|
|
343
|
+
sanitizedBody.image_url = `${sanitizedBody.image_url.slice(0, 80)}...[${sanitizedBody.image_url.length} chars]`;
|
|
344
|
+
}
|
|
345
|
+
if (sanitizedBody.end_image_url && typeof sanitizedBody.end_image_url === 'string' && sanitizedBody.end_image_url.length > 200) {
|
|
346
|
+
sanitizedBody.end_image_url = `${sanitizedBody.end_image_url.slice(0, 80)}...[${sanitizedBody.end_image_url.length} chars]`;
|
|
347
|
+
}
|
|
348
|
+
let errorDetail;
|
|
349
|
+
if (error instanceof VeniceRequestError) {
|
|
350
|
+
errorDetail = { status: error.status, message: error.message, body: error.body };
|
|
351
|
+
}
|
|
352
|
+
else if (error instanceof Error) {
|
|
353
|
+
errorDetail = { message: error.message };
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
errorDetail = { raw: String(error) };
|
|
357
|
+
}
|
|
358
|
+
const entry = {
|
|
359
|
+
timestamp,
|
|
360
|
+
targetOutput: outputPath,
|
|
361
|
+
promptLength: body.prompt?.length,
|
|
362
|
+
request: sanitizedBody,
|
|
363
|
+
error: errorDetail,
|
|
364
|
+
};
|
|
365
|
+
await appendFile(logFile, JSON.stringify(entry, null, 2) + '\n---\n', 'utf-8');
|
|
366
|
+
console.warn(` Failed request logged to: ${logFile}`);
|
|
367
|
+
}
|
|
368
|
+
function fileToDataUri(filePath, mimeType = 'image/png') {
|
|
369
|
+
if (!filePath || !existsSync(filePath))
|
|
370
|
+
return undefined;
|
|
371
|
+
const buffer = readFileSync(filePath);
|
|
372
|
+
return `data:${mimeType};base64,${buffer.toString('base64')}`;
|
|
373
|
+
}
|
|
374
|
+
async function renderVideoFile(client, options) {
|
|
375
|
+
const { prompt, anchorImagePath, outputPath, endFrameImagePath, elements, referenceImagePaths, sceneImagePaths, negativePrompt, audioUrl, audioPath, videoUrl, voiceReferencePaths } = options;
|
|
376
|
+
await mkdir(dirname(outputPath), { recursive: true });
|
|
377
|
+
// NOTE: the former Seedance seedream-provenance pre-flight gate was removed
|
|
378
|
+
// (2026-07). Venice dropped the restriction that Seedance 2.0 only accepts
|
|
379
|
+
// face-bearing input images produced by seedream-v5-lite / -edit — it now
|
|
380
|
+
// accepts face-bearing images from any image family — so there is nothing to
|
|
381
|
+
// check, reroute, or launder before building the body. The Seedance face
|
|
382
|
+
// *consent* attestation (409 needs_consent) is a separate mechanism and is
|
|
383
|
+
// still handled at queue time below.
|
|
384
|
+
const effectiveModel = prompt.model;
|
|
385
|
+
// Pure reference mode (2026-07-30): on @Image-tag R2V models with a full
|
|
386
|
+
// slot plan, the references carry ALL consistency — character sheets,
|
|
387
|
+
// storyboard blocking plates, and location angles. No start image is sent;
|
|
388
|
+
// a start frame would fight the blocking plate for compositional authority
|
|
389
|
+
// and re-introduce the panel-drift problem R2V exists to solve. Shots with
|
|
390
|
+
// no references at all (rare: no characters, no location, no storyboard)
|
|
391
|
+
// still anchor on the panel.
|
|
392
|
+
const hasSlotPlan = (prompt.referenceSlots?.length ?? 0) > 0;
|
|
393
|
+
const refsOnly = hasSlotPlan
|
|
394
|
+
&& MODELS_USING_IMAGE_TAGS.has(effectiveModel)
|
|
395
|
+
&& Boolean(referenceImagePaths && referenceImagePaths.length > 0);
|
|
396
|
+
const body = {
|
|
397
|
+
model: effectiveModel,
|
|
398
|
+
prompt: prompt.prompt,
|
|
399
|
+
duration: prompt.duration,
|
|
400
|
+
audio: prompt.audio,
|
|
401
|
+
};
|
|
402
|
+
// Models with audioConfigurable:false (e.g. HappyHorse 1.1) return HTTP 400
|
|
403
|
+
// when the `audio` field is present with a non-default value — probed
|
|
404
|
+
// 2026-07-30 (`audio: false` 400'd on happyhorse-1-1-reference-to-video).
|
|
405
|
+
// Omit the field entirely for those models.
|
|
406
|
+
const modelSpecForAudio = getVideoModel(effectiveModel);
|
|
407
|
+
if (modelSpecForAudio && modelSpecForAudio.audioConfigurable === false) {
|
|
408
|
+
if (prompt.audio === false) {
|
|
409
|
+
console.warn(` ⚠ ${effectiveModel} does not support audio toggling; omitting audio:false (model output will include native audio).`);
|
|
410
|
+
}
|
|
411
|
+
delete body.audio;
|
|
412
|
+
}
|
|
413
|
+
if (refsOnly) {
|
|
414
|
+
console.log(' Start frame: none (pure reference mode — refs carry consistency)');
|
|
415
|
+
}
|
|
416
|
+
else if (anchorImagePath && existsSync(anchorImagePath)) {
|
|
417
|
+
body.image_url = imageToDataUri(anchorImagePath);
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
console.warn(` ⚠ No start image available (${anchorImagePath ?? 'none'}) and not in reference mode — request may fail on i2v models.`);
|
|
421
|
+
}
|
|
422
|
+
if (negativePrompt) {
|
|
423
|
+
body.negative_prompt = negativePrompt;
|
|
424
|
+
}
|
|
425
|
+
if (endFrameImagePath && existsSync(endFrameImagePath) && MODELS_SUPPORTING_END_IMAGE.has(effectiveModel)) {
|
|
426
|
+
body.end_image_url = imageToDataUri(endFrameImagePath);
|
|
427
|
+
}
|
|
428
|
+
if (effectiveModel.includes('seedance')) {
|
|
429
|
+
body.resolution = '720p';
|
|
430
|
+
}
|
|
431
|
+
else if (effectiveModel.includes('veo')) {
|
|
432
|
+
body.resolution = '720p';
|
|
433
|
+
}
|
|
434
|
+
else if (effectiveModel.includes('wan-2.6') || effectiveModel.includes('wan-2.5')) {
|
|
435
|
+
body.resolution = '1080p';
|
|
436
|
+
}
|
|
437
|
+
else if (effectiveModel.includes('ltx-2')) {
|
|
438
|
+
body.resolution = '1080p';
|
|
439
|
+
}
|
|
440
|
+
else if (effectiveModel.includes('sora-2-pro')) {
|
|
441
|
+
body.resolution = '1080p';
|
|
442
|
+
}
|
|
443
|
+
else if (effectiveModel.includes('sora-2')) {
|
|
444
|
+
body.resolution = '720p';
|
|
445
|
+
}
|
|
446
|
+
// Seedance image-to-video inherits aspect from the start image and
|
|
447
|
+
// returns HTTP 400 if aspect_ratio is provided. Reference-to-video and
|
|
448
|
+
// text-to-video Seedance variants do accept aspect_ratio.
|
|
449
|
+
if (effectiveModel.includes('reference-to-video')
|
|
450
|
+
|| (effectiveModel.includes('seedance') && !effectiveModel.includes('image-to-video'))) {
|
|
451
|
+
body.aspect_ratio = options.aspectRatio ?? '16:9';
|
|
452
|
+
}
|
|
453
|
+
// audio_url attach with Wan 2.7 minimum-duration pre-flight.
|
|
454
|
+
// - `audioPath` (preferred for new callers) runs ffprobe + ffmpeg apad to
|
|
455
|
+
// pad short dialogue to the model's minAudioInputSec before encoding.
|
|
456
|
+
// - `audioUrl` (legacy) is taken at face value; the silent-reject guard
|
|
457
|
+
// on the rendered video will surface a downstream failure if the audio
|
|
458
|
+
// was too short. Migrate callers to `audioPath` when possible.
|
|
459
|
+
// - `wan-2-7-reference-to-video` uses per_reference_audio inside elements
|
|
460
|
+
// instead of a global audio_url — see element loop below.
|
|
461
|
+
if (MODELS_SUPPORTING_AUDIO_INPUT.has(effectiveModel)) {
|
|
462
|
+
if (audioPath) {
|
|
463
|
+
try {
|
|
464
|
+
const result = await padAudioForModel({ model: effectiveModel, audioPath });
|
|
465
|
+
if (result.padded) {
|
|
466
|
+
console.log(` Padded ${audioPath} -> ${result.outputPath} (${result.durationSec.toFixed(2)}s) for ${effectiveModel}.`);
|
|
467
|
+
}
|
|
468
|
+
body.audio_url = fileToDataUri(result.outputPath, 'audio/mpeg') ?? audioUrl;
|
|
469
|
+
}
|
|
470
|
+
catch (err) {
|
|
471
|
+
console.warn(` Wan audio pre-flight failed (${err.message}). Falling back to raw audioUrl.`);
|
|
472
|
+
if (audioUrl)
|
|
473
|
+
body.audio_url = audioUrl;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
else if (audioUrl) {
|
|
477
|
+
body.audio_url = audioUrl;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
else if (audioPath || audioUrl) {
|
|
481
|
+
// Model doesn't accept audio_url — drop quietly rather than 400.
|
|
482
|
+
// For per-reference-audio R2V models, the audio attaches per element below.
|
|
483
|
+
if (!MODELS_SUPPORTING_PER_REFERENCE_AUDIO.has(effectiveModel)) {
|
|
484
|
+
console.warn(` Model ${effectiveModel} does not accept audio_url; dropping audio attach.`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
if (videoUrl) {
|
|
488
|
+
body.video_url = videoUrl;
|
|
489
|
+
}
|
|
490
|
+
if (elements && elements.length > 0 && MODELS_SUPPORTING_ELEMENTS.has(effectiveModel)) {
|
|
491
|
+
const supportsPerRefAudio = MODELS_SUPPORTING_PER_REFERENCE_AUDIO.has(effectiveModel);
|
|
492
|
+
const apiElements = await Promise.all(elements.map(async (el) => {
|
|
493
|
+
const out = {};
|
|
494
|
+
if (el.frontalImageUrl) {
|
|
495
|
+
out.frontal_image_url = el.frontalImageUrl.startsWith('data:')
|
|
496
|
+
? el.frontalImageUrl
|
|
497
|
+
: fileToDataUri(el.frontalImageUrl) ?? el.frontalImageUrl;
|
|
498
|
+
}
|
|
499
|
+
if (el.referenceImageUrls && el.referenceImageUrls.length > 0) {
|
|
500
|
+
out.reference_image_urls = el.referenceImageUrls.map(url => url.startsWith('data:') ? url : (fileToDataUri(url) ?? url));
|
|
501
|
+
}
|
|
502
|
+
if (el.videoUrl)
|
|
503
|
+
out.video_url = el.videoUrl;
|
|
504
|
+
// per-reference audio for Wan 2.7 R2V — each element drives
|
|
505
|
+
// a different character's lip-sync. Run the same pad pre-flight here.
|
|
506
|
+
if (supportsPerRefAudio) {
|
|
507
|
+
if (el.audioPath) {
|
|
508
|
+
try {
|
|
509
|
+
const result = await padAudioForModel({ model: effectiveModel, audioPath: el.audioPath });
|
|
510
|
+
if (result.padded) {
|
|
511
|
+
console.log(` [per-ref] Padded ${el.audioPath} -> ${result.outputPath} for ${effectiveModel}.`);
|
|
512
|
+
}
|
|
513
|
+
const uri = fileToDataUri(result.outputPath, 'audio/mpeg');
|
|
514
|
+
if (uri)
|
|
515
|
+
out.audio_url = uri;
|
|
516
|
+
else if (el.audioUrl)
|
|
517
|
+
out.audio_url = el.audioUrl;
|
|
518
|
+
}
|
|
519
|
+
catch (err) {
|
|
520
|
+
console.warn(` [per-ref] audio pre-flight failed (${err.message}); using raw audioUrl.`);
|
|
521
|
+
if (el.audioUrl)
|
|
522
|
+
out.audio_url = el.audioUrl;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
else if (el.audioUrl) {
|
|
526
|
+
out.audio_url = el.audioUrl;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return out;
|
|
530
|
+
}));
|
|
531
|
+
body.elements = apiElements;
|
|
532
|
+
console.log(` Elements: ${apiElements.length} character/object reference(s)`);
|
|
533
|
+
}
|
|
534
|
+
if (referenceImagePaths && referenceImagePaths.length > 0
|
|
535
|
+
&& MODELS_SUPPORTING_REFERENCE_IMAGES.has(effectiveModel)) {
|
|
536
|
+
const refBudget = getMaxReferenceImages(effectiveModel);
|
|
537
|
+
if (referenceImagePaths.length > refBudget) {
|
|
538
|
+
console.warn(` ⚠ ${referenceImagePaths.length} reference images exceed ${effectiveModel}'s ${refBudget}-image budget; truncating (check the slot allocator).`);
|
|
539
|
+
}
|
|
540
|
+
body.reference_image_urls = referenceImagePaths
|
|
541
|
+
.slice(0, refBudget)
|
|
542
|
+
.map(p => p.startsWith('data:') ? p : (fileToDataUri(p) ?? p))
|
|
543
|
+
.filter(Boolean);
|
|
544
|
+
console.log(` Reference images (@Image1..@Image${body.reference_image_urls.length}): ${body.reference_image_urls.length}`);
|
|
545
|
+
}
|
|
546
|
+
if (sceneImagePaths && sceneImagePaths.length > 0
|
|
547
|
+
&& MODELS_SUPPORTING_SCENE_IMAGES.has(effectiveModel)) {
|
|
548
|
+
body.scene_image_urls = sceneImagePaths
|
|
549
|
+
.slice(0, 4)
|
|
550
|
+
.map(p => p.startsWith('data:') ? p : (fileToDataUri(p) ?? p))
|
|
551
|
+
.filter(Boolean);
|
|
552
|
+
console.log(` Scene images: ${body.scene_image_urls.length}`);
|
|
553
|
+
}
|
|
554
|
+
// Voice-donor reference audio (@Audio1, @Audio2, …). Gated on model support
|
|
555
|
+
// AND the presence of ≥1 reference image (Venice rejects audio-only). Each
|
|
556
|
+
// clip must be 2-15s with an aggregate ≤15s across ≤3 clips; out-of-budget
|
|
557
|
+
// clips are dropped with a warning so the render still proceeds.
|
|
558
|
+
if (voiceReferencePaths && voiceReferencePaths.length > 0
|
|
559
|
+
&& MODELS_SUPPORTING_REFERENCE_AUDIO.has(effectiveModel)) {
|
|
560
|
+
const hasReferenceImage = Array.isArray(body.reference_image_urls)
|
|
561
|
+
&& body.reference_image_urls.length > 0;
|
|
562
|
+
if (!hasReferenceImage) {
|
|
563
|
+
console.warn(' ⚠ Voice references present but no reference image — dropping (Venice rejects audio-only reference audio).');
|
|
564
|
+
}
|
|
565
|
+
else {
|
|
566
|
+
const accepted = [];
|
|
567
|
+
let aggregateSec = 0;
|
|
568
|
+
for (const p of voiceReferencePaths) {
|
|
569
|
+
if (accepted.length >= 3) {
|
|
570
|
+
console.warn(` ⚠ Voice reference budget: >3 clips, dropping extras.`);
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
if (p.startsWith('data:')) {
|
|
574
|
+
accepted.push(p);
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (!existsSync(p)) {
|
|
578
|
+
console.warn(` ⚠ Voice reference missing on disk, skipping: ${p}`);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
let durSec;
|
|
582
|
+
try {
|
|
583
|
+
durSec = await probeAudioDurationSec(p);
|
|
584
|
+
}
|
|
585
|
+
catch (err) {
|
|
586
|
+
console.warn(` ⚠ Could not probe voice reference (${err.message}); skipping ${p}`);
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
if (durSec < VOICE_REF_MIN_SEC || durSec > VOICE_REF_MAX_SEC) {
|
|
590
|
+
console.warn(` ⚠ Voice reference ${p} is ${durSec.toFixed(2)}s (must be ${VOICE_REF_MIN_SEC}-${VOICE_REF_MAX_SEC}s); skipping.`);
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
if (aggregateSec + durSec > VOICE_REF_MAX_SEC) {
|
|
594
|
+
console.warn(` ⚠ Voice reference aggregate would exceed ${VOICE_REF_MAX_SEC}s; skipping ${p}.`);
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
const mime = p.toLowerCase().endsWith('.wav') ? 'audio/wav' : 'audio/mpeg';
|
|
598
|
+
const uri = fileToDataUri(p, mime);
|
|
599
|
+
if (uri) {
|
|
600
|
+
accepted.push(uri);
|
|
601
|
+
aggregateSec += durSec;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
if (accepted.length > 0) {
|
|
605
|
+
body.reference_audio_urls = accepted;
|
|
606
|
+
console.log(` Reference audio (@Audio1..@Audio${accepted.length}): ${accepted.length} voice clip(s), ${aggregateSec.toFixed(2)}s total`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
else if (voiceReferencePaths && voiceReferencePaths.length > 0) {
|
|
611
|
+
console.warn(` ⚠ Model ${effectiveModel} does not support reference_audio_urls; dropping ${voiceReferencePaths.length} voice reference(s).`);
|
|
612
|
+
}
|
|
613
|
+
if (options.aspectRatio && body.aspect_ratio && body.aspect_ratio !== options.aspectRatio) {
|
|
614
|
+
console.warn(` ⚠ Aspect ratio mismatch: sending ${body.aspect_ratio} but series expects ${options.aspectRatio}`);
|
|
615
|
+
}
|
|
616
|
+
if (prompt.characterElements && prompt.characterElements.length > 0
|
|
617
|
+
&& !effectiveModel.includes('reference-to-video')) {
|
|
618
|
+
console.warn(` ⚠ Shot has characters but model ${effectiveModel} is NOT R2V — character identity may drift`);
|
|
619
|
+
}
|
|
620
|
+
console.log(` Queueing video: model=${effectiveModel}, duration=${prompt.duration}, aspect=${body.aspect_ratio ?? 'default'}, prompt=${(prompt.prompt).length} chars`);
|
|
621
|
+
let queueResponse;
|
|
622
|
+
try {
|
|
623
|
+
queueResponse = await client.post(VIDEO_QUEUE_PATH, body);
|
|
624
|
+
}
|
|
625
|
+
catch (err) {
|
|
626
|
+
// Seedance face-media consent flow (two-call attestation).
|
|
627
|
+
// A 409 needs_consent is non-charging; resubmitting the identical body
|
|
628
|
+
// with consents.seedance (all three booleans true) accepts the
|
|
629
|
+
// policy_text returned in the 409. See
|
|
630
|
+
// https://docs.venice.ai/guides/media/seedance-face-consent
|
|
631
|
+
const isNeedsConsent = err instanceof VeniceRequestError
|
|
632
|
+
&& err.status === 409
|
|
633
|
+
&& err.body?.error?.code === 'needs_consent';
|
|
634
|
+
if (isNeedsConsent) {
|
|
635
|
+
console.log(' Seedance face consent requested (409 needs_consent) — resubmitting with attestation.');
|
|
636
|
+
const consentBody = {
|
|
637
|
+
...body,
|
|
638
|
+
consents: {
|
|
639
|
+
seedance: {
|
|
640
|
+
confirmed_terms_and_privacy: true,
|
|
641
|
+
confirmed_legal_right: true,
|
|
642
|
+
confirmed_screening_acknowledged: true,
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
};
|
|
646
|
+
try {
|
|
647
|
+
queueResponse = await client.post(VIDEO_QUEUE_PATH, consentBody);
|
|
648
|
+
}
|
|
649
|
+
catch (consentErr) {
|
|
650
|
+
if (consentErr instanceof VeniceRequestError) {
|
|
651
|
+
console.error(` Venice queue error after consent (HTTP ${consentErr.status}): ${consentErr.message}`);
|
|
652
|
+
console.error(` Error body: ${JSON.stringify(consentErr.body, null, 2)}`);
|
|
653
|
+
}
|
|
654
|
+
await logFailedRequest(outputPath, consentBody, consentErr);
|
|
655
|
+
throw consentErr;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
if (err instanceof VeniceRequestError) {
|
|
660
|
+
console.error(` Venice queue error (HTTP ${err.status}): ${err.message}`);
|
|
661
|
+
console.error(` Error body: ${JSON.stringify(err.body, null, 2)}`);
|
|
662
|
+
}
|
|
663
|
+
await logFailedRequest(outputPath, body, err);
|
|
664
|
+
throw err;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
const { queue_id, model } = queueResponse;
|
|
668
|
+
console.log(` Queue ID: ${queue_id}`);
|
|
669
|
+
let elapsed = 0;
|
|
670
|
+
while (true) {
|
|
671
|
+
await sleep(POLL_INTERVAL_MS);
|
|
672
|
+
elapsed += POLL_INTERVAL_MS;
|
|
673
|
+
try {
|
|
674
|
+
const result = await client.postBinaryOrJson(VIDEO_RETRIEVE_PATH, { model, queue_id });
|
|
675
|
+
if (Buffer.isBuffer(result.value)) {
|
|
676
|
+
const videoBuffer = result.value;
|
|
677
|
+
archiveExisting(outputPath);
|
|
678
|
+
await writeFile(outputPath, videoBuffer);
|
|
679
|
+
console.log(` Video saved: ${outputPath} (${(videoBuffer.length / 1024 / 1024).toFixed(1)} MB, ${(elapsed / 1000).toFixed(0)}s)`);
|
|
680
|
+
try {
|
|
681
|
+
await client.post(VIDEO_COMPLETE_PATH, { model, queue_id });
|
|
682
|
+
}
|
|
683
|
+
catch { /* cleanup is optional */ }
|
|
684
|
+
// Recipe sidecar: log the resolved video call with stable on-disk
|
|
685
|
+
// paths (never data: URIs) so a finishing agent can replay or
|
|
686
|
+
// continue this shot. `elements` mapping goes in `extra` since it
|
|
687
|
+
// carries per-character frontal/reference structure.
|
|
688
|
+
const isPath = (p) => !!p && !p.startsWith('data:');
|
|
689
|
+
await appendRecipePass(outputPath, {
|
|
690
|
+
kind: 'video-generate',
|
|
691
|
+
role: (prompt.characterElements && prompt.characterElements.length > 0)
|
|
692
|
+
|| (referenceImagePaths && referenceImagePaths.length > 0)
|
|
693
|
+
|| (elements && elements.length > 0)
|
|
694
|
+
? 'identity' : 'content',
|
|
695
|
+
model: effectiveModel,
|
|
696
|
+
label: effectiveModel !== prompt.model
|
|
697
|
+
? `video render (fallback from ${prompt.model})`
|
|
698
|
+
: 'video render',
|
|
699
|
+
prompt: prompt.prompt,
|
|
700
|
+
negativePrompt,
|
|
701
|
+
duration: prompt.duration,
|
|
702
|
+
aspectRatio: body.aspect_ratio ?? options.aspectRatio,
|
|
703
|
+
resolution: body.resolution,
|
|
704
|
+
anchorImagePath: isPath(anchorImagePath) ? anchorImagePath : undefined,
|
|
705
|
+
endImagePath: isPath(endFrameImagePath) ? endFrameImagePath : undefined,
|
|
706
|
+
audioPath: isPath(audioPath) ? audioPath : undefined,
|
|
707
|
+
referenceImagePaths: referenceImagePaths?.filter(isPath),
|
|
708
|
+
extra: {
|
|
709
|
+
audio: prompt.audio,
|
|
710
|
+
...(voiceReferencePaths && voiceReferencePaths.length > 0
|
|
711
|
+
? { voiceReferencePaths: voiceReferencePaths.filter(isPath) } : {}),
|
|
712
|
+
...(sceneImagePaths && sceneImagePaths.length > 0
|
|
713
|
+
? { sceneImagePaths: sceneImagePaths.filter(isPath) } : {}),
|
|
714
|
+
...(elements && elements.length > 0
|
|
715
|
+
? {
|
|
716
|
+
elements: elements.map(el => ({
|
|
717
|
+
frontalImageUrl: isPath(el.frontalImageUrl) ? el.frontalImageUrl : undefined,
|
|
718
|
+
referenceImageUrls: el.referenceImageUrls?.filter(isPath),
|
|
719
|
+
audioPath: isPath(el.audioPath) ? el.audioPath : undefined,
|
|
720
|
+
})),
|
|
721
|
+
} : {}),
|
|
722
|
+
},
|
|
723
|
+
});
|
|
724
|
+
return outputPath;
|
|
725
|
+
}
|
|
726
|
+
const status = result.value;
|
|
727
|
+
const pct = status.execution_duration
|
|
728
|
+
? `${(status.execution_duration / 1000).toFixed(0)}s elapsed`
|
|
729
|
+
: '';
|
|
730
|
+
process.stdout.write(`\r Polling... ${status.status} ${pct} `);
|
|
731
|
+
}
|
|
732
|
+
catch (err) {
|
|
733
|
+
console.warn(` Poll error (will retry): ${err}`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
function resolveCharacterElements(series, shot, prompt) {
|
|
738
|
+
// @Image-tag models with a slot plan: the plan IS the reference array.
|
|
739
|
+
// Push in exactly the slot order so the prompt's @ImageN bindings match
|
|
740
|
+
// (characters, storyboard blocking plate, location angles, extra angles).
|
|
741
|
+
if (prompt.referenceSlots && prompt.referenceSlots.length > 0
|
|
742
|
+
&& MODELS_SUPPORTING_REFERENCE_IMAGES.has(prompt.model)) {
|
|
743
|
+
const paths = prompt.referenceSlots
|
|
744
|
+
.map(slot => slot.path)
|
|
745
|
+
.filter(p => existsSync(p));
|
|
746
|
+
if (paths.length !== prompt.referenceSlots.length) {
|
|
747
|
+
console.warn(' ⚠ Reference slot images missing on disk — @ImageN bindings may misalign; regenerate refs.');
|
|
748
|
+
}
|
|
749
|
+
return { referenceImagePaths: paths.length > 0 ? paths : undefined };
|
|
750
|
+
}
|
|
751
|
+
if (!shot.characters || shot.characters.length === 0)
|
|
752
|
+
return {};
|
|
753
|
+
const resolvedChars = shot.characters
|
|
754
|
+
.map(name => series.characters.find(c => c.name.toUpperCase() === name.toUpperCase()))
|
|
755
|
+
.filter(Boolean);
|
|
756
|
+
if (resolvedChars.length === 0)
|
|
757
|
+
return {};
|
|
758
|
+
const charDirFn = (name) => getCharacterDir(series, name);
|
|
759
|
+
const autoElements = prompt.modelResolution?.autoUseElements ?? false;
|
|
760
|
+
const autoRefs = prompt.modelResolution?.autoUseReferenceImages ?? false;
|
|
761
|
+
if ((prompt.characterElements && prompt.characterElements.length > 0 || autoElements)
|
|
762
|
+
&& MODELS_SUPPORTING_ELEMENTS.has(prompt.model)) {
|
|
763
|
+
const slots = prompt.characterElements && prompt.characterElements.length > 0
|
|
764
|
+
? prompt.characterElements
|
|
765
|
+
: resolvedChars.slice(0, 2).map((char, index) => ({
|
|
766
|
+
characterName: char.name,
|
|
767
|
+
elementIndex: index + 1,
|
|
768
|
+
}));
|
|
769
|
+
const elements = slots.map(slot => {
|
|
770
|
+
const dir = charDirFn(slot.characterName);
|
|
771
|
+
const frontal = join(dir, 'front.png');
|
|
772
|
+
const refs = ['three-quarter.png', 'profile.png', 'back.png']
|
|
773
|
+
.map(f => join(dir, f))
|
|
774
|
+
.filter(p => existsSync(p))
|
|
775
|
+
.slice(0, 3);
|
|
776
|
+
return {
|
|
777
|
+
frontalImageUrl: existsSync(frontal) ? frontal : undefined,
|
|
778
|
+
referenceImageUrls: refs.length > 0 ? refs : undefined,
|
|
779
|
+
};
|
|
780
|
+
});
|
|
781
|
+
return { elements };
|
|
782
|
+
}
|
|
783
|
+
if ((shot.useReferenceImages || autoRefs)
|
|
784
|
+
&& MODELS_SUPPORTING_REFERENCE_IMAGES.has(prompt.model)) {
|
|
785
|
+
const budget = getMaxReferenceImages(prompt.model);
|
|
786
|
+
const paths = resolvedChars
|
|
787
|
+
.slice(0, budget)
|
|
788
|
+
.flatMap(c => {
|
|
789
|
+
const dir = charDirFn(c.name);
|
|
790
|
+
return ['front.png', 'three-quarter.png']
|
|
791
|
+
.map(f => join(dir, f))
|
|
792
|
+
.filter(p => existsSync(p));
|
|
793
|
+
})
|
|
794
|
+
.slice(0, budget);
|
|
795
|
+
return { referenceImagePaths: paths.length > 0 ? paths : undefined };
|
|
796
|
+
}
|
|
797
|
+
return {};
|
|
798
|
+
}
|
|
799
|
+
function getShotPanelPath(sceneDir, shotId) {
|
|
800
|
+
return join(sceneDir, `shot-${shotKey(shotId)}.png`);
|
|
801
|
+
}
|
|
802
|
+
function getShotVideoPath(sceneDir, shotId) {
|
|
803
|
+
return join(sceneDir, `shot-${shotKey(shotId)}.mp4`);
|
|
804
|
+
}
|
|
805
|
+
function chooseAnchorImagePath(unit, sceneDir, unitOutputPath, previousRenderedShotPath, explicitPanelPath) {
|
|
806
|
+
const firstShotNumber = unit.shotNumbers[0];
|
|
807
|
+
const panelPath = explicitPanelPath ?? getShotPanelPath(sceneDir, firstShotNumber);
|
|
808
|
+
if (unit.startFrameStrategy === 'previous-last-frame'
|
|
809
|
+
&& previousRenderedShotPath
|
|
810
|
+
&& existsSync(previousRenderedShotPath)) {
|
|
811
|
+
const lastFramePath = unitOutputPath.replace(/\.mp4$/, '-lastframe.png');
|
|
812
|
+
extractLastFrame(previousRenderedShotPath, lastFramePath);
|
|
813
|
+
console.log(' Start frame: chained from previous rendered shot');
|
|
814
|
+
return lastFramePath;
|
|
815
|
+
}
|
|
816
|
+
console.log(' Start frame: panel image');
|
|
817
|
+
return panelPath;
|
|
818
|
+
}
|
|
819
|
+
function chooseEndFrameImagePath(unit, sceneDir, nextShotNumber) {
|
|
820
|
+
if (unit.endFrameStrategy !== 'next-panel-target' || nextShotNumber === undefined) {
|
|
821
|
+
console.log(' End frame: natural');
|
|
822
|
+
return undefined;
|
|
823
|
+
}
|
|
824
|
+
const nextPanelPath = getShotPanelPath(sceneDir, nextShotNumber);
|
|
825
|
+
if (!existsSync(nextPanelPath)) {
|
|
826
|
+
console.log(' End frame: natural (next panel missing)');
|
|
827
|
+
return undefined;
|
|
828
|
+
}
|
|
829
|
+
console.log(` End frame: targeting shot-${String(nextShotNumber).padStart(3, '0')}`);
|
|
830
|
+
return nextPanelPath;
|
|
831
|
+
}
|
|
832
|
+
async function saveSingleShotMetadata(series, shot, videoPath, videoPrompt, extraMetadata = {}) {
|
|
833
|
+
const videoJsonPath = videoPath.replace(/\.mp4$/, '.video.json');
|
|
834
|
+
await saveJson(videoJsonPath, {
|
|
835
|
+
panelId: `E${series.episodes.length}-S${shot.shotNumber}`,
|
|
836
|
+
shotNumber: shot.shotNumber,
|
|
837
|
+
video: {
|
|
838
|
+
model: videoPrompt.model,
|
|
839
|
+
prompt: videoPrompt.prompt,
|
|
840
|
+
duration: videoPrompt.duration,
|
|
841
|
+
audio: videoPrompt.audio,
|
|
842
|
+
},
|
|
843
|
+
metadata: {
|
|
844
|
+
characters: shot.characters,
|
|
845
|
+
dialogue: shot.dialogue,
|
|
846
|
+
sfx: shot.sfx,
|
|
847
|
+
transition: shot.transition,
|
|
848
|
+
cameraMovement: shot.cameraMovement,
|
|
849
|
+
...extraMetadata,
|
|
850
|
+
},
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
function splitRenderedUnitIntoShots(unitOutputPath, unit, shotsByNumber, sceneDir) {
|
|
854
|
+
const renderedDuration = getVideoDuration(unitOutputPath);
|
|
855
|
+
const plannedTotal = unit.shotNumbers.reduce((sum, shotNumber) => {
|
|
856
|
+
const shot = shotsByNumber.get(shotNumber);
|
|
857
|
+
return sum + (shot ? parseShotDuration(shot.duration) : 0);
|
|
858
|
+
}, 0);
|
|
859
|
+
let offset = 0;
|
|
860
|
+
const segments = [];
|
|
861
|
+
for (let index = 0; index < unit.shotNumbers.length; index++) {
|
|
862
|
+
const shotNumber = unit.shotNumbers[index];
|
|
863
|
+
const shot = shotsByNumber.get(shotNumber);
|
|
864
|
+
if (!shot)
|
|
865
|
+
continue;
|
|
866
|
+
const outputPath = getShotVideoPath(sceneDir, shotNumber);
|
|
867
|
+
const isLast = index === unit.shotNumbers.length - 1;
|
|
868
|
+
const durationSec = isLast
|
|
869
|
+
? Math.max(0.1, renderedDuration - offset)
|
|
870
|
+
: Math.max(0.1, renderedDuration * (parseShotDuration(shot.duration) / plannedTotal));
|
|
871
|
+
archiveExisting(outputPath);
|
|
872
|
+
runCommand('ffmpeg', [
|
|
873
|
+
'-y',
|
|
874
|
+
'-ss',
|
|
875
|
+
String(offset),
|
|
876
|
+
'-i',
|
|
877
|
+
unitOutputPath,
|
|
878
|
+
'-t',
|
|
879
|
+
String(durationSec),
|
|
880
|
+
'-c:v',
|
|
881
|
+
'libx264',
|
|
882
|
+
'-preset',
|
|
883
|
+
'fast',
|
|
884
|
+
'-crf',
|
|
885
|
+
'18',
|
|
886
|
+
'-c:a',
|
|
887
|
+
'aac',
|
|
888
|
+
'-ar',
|
|
889
|
+
'44100',
|
|
890
|
+
'-ac',
|
|
891
|
+
'2',
|
|
892
|
+
'-b:a',
|
|
893
|
+
'192k',
|
|
894
|
+
outputPath,
|
|
895
|
+
]);
|
|
896
|
+
segments.push({
|
|
897
|
+
shotNumber,
|
|
898
|
+
startOffsetSec: Number(offset.toFixed(3)),
|
|
899
|
+
durationSec: Number(durationSec.toFixed(3)),
|
|
900
|
+
outputFile: `shot-${String(shotNumber).padStart(3, '0')}.mp4`,
|
|
901
|
+
});
|
|
902
|
+
offset += durationSec;
|
|
903
|
+
}
|
|
904
|
+
return segments;
|
|
905
|
+
}
|
|
906
|
+
async function renderSingleShotUnit(client, series, shot, unit, sceneDir, previousRenderedShotPath, nextShotNumber, previousShot, episodeAudioMix) {
|
|
907
|
+
// Suffixed inserts ("3b") must key their own panel/video files — using the
|
|
908
|
+
// bare shotNumber here made every suffixed shot collide with its base shot
|
|
909
|
+
// (path resolved to shot-003.*), so inserts were silently skipped as
|
|
910
|
+
// "video exists".
|
|
911
|
+
const shotId = resolveDialogueShotId(shot);
|
|
912
|
+
const panelPath = getShotPanelPath(sceneDir, shotId);
|
|
913
|
+
// A missing panel is only fatal for shots that will actually anchor on it.
|
|
914
|
+
// Refs-only shots (Seedance R2V slot plan) don't send a start image, so the
|
|
915
|
+
// decision to skip is deferred until after the prompt/references resolve.
|
|
916
|
+
const panelExists = existsSync(panelPath);
|
|
917
|
+
const videoPath = getShotVideoPath(sceneDir, shotId);
|
|
918
|
+
if (existsSync(videoPath)) {
|
|
919
|
+
console.log(` Shot ${shotKey(shotId)}: video exists, skipping`);
|
|
920
|
+
unit.renderedDurationSec = getVideoDuration(videoPath);
|
|
921
|
+
unit.segments = [{
|
|
922
|
+
shotNumber: shot.shotNumber,
|
|
923
|
+
startOffsetSec: 0,
|
|
924
|
+
durationSec: unit.renderedDurationSec,
|
|
925
|
+
outputFile: `shot-${shotKey(shotId)}.mp4`,
|
|
926
|
+
}];
|
|
927
|
+
return [videoPath];
|
|
928
|
+
}
|
|
929
|
+
// Ensure the dialogue speaker has a voice-donor reference clip before the
|
|
930
|
+
// prompt is built, so buildVideoPrompt can emit the @AudioN binding (A2/A3).
|
|
931
|
+
await ensureVoiceReferenceForShot(client, series, shot, previousShot);
|
|
932
|
+
const videoPrompt = buildVideoPrompt(shot, series, previousShot, episodeAudioMix);
|
|
933
|
+
if (!videoPrompt.audio && shot.dialogue) {
|
|
934
|
+
const reason = shot.nativeAudio === 'mute'
|
|
935
|
+
? 'shot.nativeAudio=mute'
|
|
936
|
+
: episodeAudioMix?.suppressModelNarration
|
|
937
|
+
? 'episode.audioMix.suppressModelNarration'
|
|
938
|
+
: shot.dialogue.character?.toUpperCase() === 'NARRATOR'
|
|
939
|
+
? 'NARRATOR shot (auto)'
|
|
940
|
+
: 'unknown';
|
|
941
|
+
console.log(` Audio: model-native disabled (${reason})`);
|
|
942
|
+
}
|
|
943
|
+
unit.model = videoPrompt.model;
|
|
944
|
+
if (videoPrompt.modelResolution) {
|
|
945
|
+
const res = videoPrompt.modelResolution;
|
|
946
|
+
console.log(` Model: ${res.modelId}${res.upgraded ? ` (upgraded: ${res.reason})` : ''}`);
|
|
947
|
+
if (res.autoUseElements)
|
|
948
|
+
console.log(' Auto-enabled: elements (character identity anchoring)');
|
|
949
|
+
if (res.autoUseReferenceImages)
|
|
950
|
+
console.log(' Auto-enabled: reference images');
|
|
951
|
+
}
|
|
952
|
+
let { elements, referenceImagePaths } = resolveCharacterElements(series, shot, videoPrompt);
|
|
953
|
+
let sceneImagePaths = shot.sceneImagePaths?.filter(p => existsSync(p));
|
|
954
|
+
const hasSlotPlan = (videoPrompt.referenceSlots?.length ?? 0) > 0
|
|
955
|
+
&& (referenceImagePaths?.length ?? 0) > 0;
|
|
956
|
+
// Refs-only shots (Seedance R2V slot plan) don't anchor on the panel, so a
|
|
957
|
+
// missing panel is fine there. Everything else still requires it.
|
|
958
|
+
if (!panelExists && !hasSlotPlan) {
|
|
959
|
+
console.warn(` Panel not found: ${panelPath}, skipping shot ${shotId}`);
|
|
960
|
+
return [];
|
|
961
|
+
}
|
|
962
|
+
let anchorImagePath = chooseAnchorImagePath(unit, sceneDir, videoPath, previousRenderedShotPath, panelPath);
|
|
963
|
+
const endFramePath = chooseEndFrameImagePath(unit, sceneDir, nextShotNumber);
|
|
964
|
+
// --- Location environment references (B5) ---
|
|
965
|
+
// The slot plan (referenceSlots) already interleaves location angles for
|
|
966
|
+
// @Image-tag models; this legacy fold only runs when no plan exists.
|
|
967
|
+
// Kling O3 R2V takes environment refs via scene_image_urls. Hand-set
|
|
968
|
+
// sceneImagePaths always win as an override.
|
|
969
|
+
const locationRefPath = getLocationRefPath(series, shot);
|
|
970
|
+
if (locationRefPath) {
|
|
971
|
+
if (MODELS_SUPPORTING_SCENE_IMAGES.has(videoPrompt.model)) {
|
|
972
|
+
if (!sceneImagePaths || sceneImagePaths.length === 0) {
|
|
973
|
+
sceneImagePaths = [locationRefPath];
|
|
974
|
+
console.log(` Location ref -> scene_image_urls (${shot.location})`);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
else if (!hasSlotPlan && videoPrompt.locationEnvSlot) {
|
|
978
|
+
referenceImagePaths = foldLocationIntoReferences(series, shot, videoPrompt, locationRefPath, referenceImagePaths);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
// Resolve voice-donor clips in the exact order the prompt's @AudioN slots
|
|
982
|
+
// expect (A3). Only used by reference-audio-capable models with ≥1 ref image.
|
|
983
|
+
const voiceReferencePaths = resolveVoiceReferencePaths(series, videoPrompt);
|
|
984
|
+
// --- AGENTS.md rule 32: Seedance R2V → Wan 2.7 keyframe pipeline ---
|
|
985
|
+
// Wan 2.7 i2v has no `reference_image_urls`; its only identity anchor is
|
|
986
|
+
// the single `image_url` keyframe. We render a Seedance R2V identity-lock
|
|
987
|
+
// pass first, extract frame 1, and use that frame for the Wan 2.7 i2v
|
|
988
|
+
// render. The dialogue MP3 is wired into `audio_url` so Wan 2.7 lip-syncs
|
|
989
|
+
// against the real voice instead of synthesizing.
|
|
990
|
+
let keyframeArtifacts;
|
|
991
|
+
let dialogueAudioPath;
|
|
992
|
+
let stageAFailed = false;
|
|
993
|
+
let stageAFailureReason;
|
|
994
|
+
if (unit.useSeedanceKeyframe === true) {
|
|
995
|
+
try {
|
|
996
|
+
keyframeArtifacts = await renderSeedanceKeyframe(client, series, shot, sceneDir, videoPath, previousShot);
|
|
997
|
+
anchorImagePath = keyframeArtifacts.keyframePngPath;
|
|
998
|
+
const audioDir = join(dirname(sceneDir), 'audio');
|
|
999
|
+
console.log(' Stage C/3: locating dialogue audio for Wan 2.7 lip-sync');
|
|
1000
|
+
dialogueAudioPath = await ensureDialogueAudio(client, series, shot, audioDir);
|
|
1001
|
+
}
|
|
1002
|
+
catch (err) {
|
|
1003
|
+
stageAFailed = true;
|
|
1004
|
+
stageAFailureReason = err instanceof Error ? err.message : String(err);
|
|
1005
|
+
console.warn(` ⚠ Seedance R2V keyframe pipeline failed (${stageAFailureReason}); falling back to panel-anchored single-pass render.`);
|
|
1006
|
+
keyframeArtifacts = undefined;
|
|
1007
|
+
dialogueAudioPath = undefined;
|
|
1008
|
+
anchorImagePath = chooseAnchorImagePath(unit, sceneDir, videoPath, previousRenderedShotPath, panelPath);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
const savedPath = await renderVideoFile(client, {
|
|
1012
|
+
prompt: videoPrompt,
|
|
1013
|
+
anchorImagePath,
|
|
1014
|
+
outputPath: videoPath,
|
|
1015
|
+
endFrameImagePath: endFramePath,
|
|
1016
|
+
elements,
|
|
1017
|
+
referenceImagePaths,
|
|
1018
|
+
sceneImagePaths,
|
|
1019
|
+
audioPath: dialogueAudioPath,
|
|
1020
|
+
voiceReferencePaths,
|
|
1021
|
+
aspectRatio: series.storyboardAspectRatio ?? '16:9',
|
|
1022
|
+
seedanceCompatibility: series.videoDefaults.seedanceCompatibility,
|
|
1023
|
+
});
|
|
1024
|
+
const durationSec = getVideoDuration(savedPath);
|
|
1025
|
+
unit.renderedDurationSec = durationSec;
|
|
1026
|
+
unit.segments = [{
|
|
1027
|
+
shotNumber: shot.shotNumber,
|
|
1028
|
+
startOffsetSec: 0,
|
|
1029
|
+
durationSec,
|
|
1030
|
+
outputFile: `shot-${shotKey(shotId)}.mp4`,
|
|
1031
|
+
}];
|
|
1032
|
+
const extraMetadata = { generationUnit: unit.unitId };
|
|
1033
|
+
if (keyframeArtifacts) {
|
|
1034
|
+
extraMetadata.seedanceKeyframe = {
|
|
1035
|
+
stageAVideo: relativeForMetadata(savedPath, keyframeArtifacts.stageAVideoPath),
|
|
1036
|
+
keyframePng: relativeForMetadata(savedPath, keyframeArtifacts.keyframePngPath),
|
|
1037
|
+
keyframeModel: unit.keyframeModel,
|
|
1038
|
+
dialogueAudio: dialogueAudioPath
|
|
1039
|
+
? relativeForMetadata(savedPath, dialogueAudioPath)
|
|
1040
|
+
: null,
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
else if (unit.useSeedanceKeyframe === true && stageAFailed) {
|
|
1044
|
+
extraMetadata.seedanceKeyframe = {
|
|
1045
|
+
attempted: true,
|
|
1046
|
+
success: false,
|
|
1047
|
+
reason: stageAFailureReason,
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
await saveSingleShotMetadata(series, shot, savedPath, videoPrompt, extraMetadata);
|
|
1051
|
+
return [savedPath];
|
|
1052
|
+
}
|
|
1053
|
+
function relativeForMetadata(anchorPath, target) {
|
|
1054
|
+
// Both anchorPath and target live in the same scene dir; return the
|
|
1055
|
+
// basename for compactness while preserving uniqueness within the dir.
|
|
1056
|
+
if (dirname(anchorPath) === dirname(target)) {
|
|
1057
|
+
return target.slice(dirname(target).length + 1);
|
|
1058
|
+
}
|
|
1059
|
+
return target;
|
|
1060
|
+
}
|
|
1061
|
+
async function renderMultiShotUnit(client, series, shots, unit, sceneDir, previousRenderedShotPath, nextShotNumber) {
|
|
1062
|
+
const shotOutputPaths = shots.map(shot => getShotVideoPath(sceneDir, shot.shotNumber));
|
|
1063
|
+
if (shotOutputPaths.every(path => existsSync(path))) {
|
|
1064
|
+
console.log(` ${unit.unitId}: shot outputs exist, skipping`);
|
|
1065
|
+
let offset = 0;
|
|
1066
|
+
unit.segments = shotOutputPaths.map((path, index) => {
|
|
1067
|
+
const durationSec = getVideoDuration(path);
|
|
1068
|
+
const segment = {
|
|
1069
|
+
shotNumber: shots[index].shotNumber,
|
|
1070
|
+
startOffsetSec: Number(offset.toFixed(3)),
|
|
1071
|
+
durationSec: Number(durationSec.toFixed(3)),
|
|
1072
|
+
outputFile: `shot-${String(shots[index].shotNumber).padStart(3, '0')}.mp4`,
|
|
1073
|
+
};
|
|
1074
|
+
offset += durationSec;
|
|
1075
|
+
return segment;
|
|
1076
|
+
});
|
|
1077
|
+
unit.renderedDurationSec = offset;
|
|
1078
|
+
return shotOutputPaths;
|
|
1079
|
+
}
|
|
1080
|
+
const firstPanelPath = getShotPanelPath(sceneDir, shots[0].shotNumber);
|
|
1081
|
+
if (!existsSync(firstPanelPath)) {
|
|
1082
|
+
console.warn(` Panel not found: ${firstPanelPath}, skipping unit ${unit.unitId}`);
|
|
1083
|
+
return [];
|
|
1084
|
+
}
|
|
1085
|
+
const unitOutputPath = join(sceneDir, unit.outputFile);
|
|
1086
|
+
const prompt = buildKlingMultiShotPrompt(shots, unit, series);
|
|
1087
|
+
const anchorImagePath = chooseAnchorImagePath(unit, sceneDir, unitOutputPath, previousRenderedShotPath);
|
|
1088
|
+
const endFramePath = chooseEndFrameImagePath(unit, sceneDir, nextShotNumber);
|
|
1089
|
+
// Resolve elements and references for multi-shot — same identity anchoring as single shots
|
|
1090
|
+
const allCharNames = Array.from(new Set(shots.flatMap(s => s.characters)));
|
|
1091
|
+
const resolvedChars = allCharNames
|
|
1092
|
+
.map(name => series.characters.find(c => c.name.toUpperCase() === name.toUpperCase()))
|
|
1093
|
+
.filter(Boolean);
|
|
1094
|
+
const charDirFn2 = (name) => getCharacterDir(series, name);
|
|
1095
|
+
let elements;
|
|
1096
|
+
let referenceImagePaths;
|
|
1097
|
+
if (prompt.characterElements && prompt.characterElements.length > 0
|
|
1098
|
+
&& MODELS_SUPPORTING_ELEMENTS.has(prompt.model)) {
|
|
1099
|
+
elements = prompt.characterElements.map(slot => {
|
|
1100
|
+
const dir = charDirFn2(slot.characterName);
|
|
1101
|
+
const frontal = join(dir, 'front.png');
|
|
1102
|
+
const refs = ['three-quarter.png', 'profile.png', 'back.png']
|
|
1103
|
+
.map(f => join(dir, f))
|
|
1104
|
+
.filter(p => existsSync(p))
|
|
1105
|
+
.slice(0, 3);
|
|
1106
|
+
return {
|
|
1107
|
+
frontalImageUrl: existsSync(frontal) ? frontal : undefined,
|
|
1108
|
+
referenceImageUrls: refs.length > 0 ? refs : undefined,
|
|
1109
|
+
};
|
|
1110
|
+
});
|
|
1111
|
+
console.log(` ${unit.unitId}: elements enabled for ${prompt.characterElements.map(s => s.characterName).join(', ')}`);
|
|
1112
|
+
}
|
|
1113
|
+
else if (MODELS_SUPPORTING_REFERENCE_IMAGES.has(prompt.model) && resolvedChars.length > 0) {
|
|
1114
|
+
referenceImagePaths = resolvedChars
|
|
1115
|
+
.flatMap(c => {
|
|
1116
|
+
const dir = charDirFn2(c.name);
|
|
1117
|
+
return ['front.png', 'three-quarter.png']
|
|
1118
|
+
.map(f => join(dir, f))
|
|
1119
|
+
.filter(p => existsSync(p));
|
|
1120
|
+
})
|
|
1121
|
+
.slice(0, 4);
|
|
1122
|
+
if (referenceImagePaths.length === 0)
|
|
1123
|
+
referenceImagePaths = undefined;
|
|
1124
|
+
}
|
|
1125
|
+
const savedUnitPath = await renderVideoFile(client, {
|
|
1126
|
+
prompt,
|
|
1127
|
+
anchorImagePath,
|
|
1128
|
+
outputPath: unitOutputPath,
|
|
1129
|
+
endFrameImagePath: endFramePath,
|
|
1130
|
+
elements,
|
|
1131
|
+
referenceImagePaths,
|
|
1132
|
+
aspectRatio: series.storyboardAspectRatio ?? '16:9',
|
|
1133
|
+
seedanceCompatibility: series.videoDefaults.seedanceCompatibility,
|
|
1134
|
+
});
|
|
1135
|
+
const segments = splitRenderedUnitIntoShots(savedUnitPath, unit, new Map(shots.map(shot => [shot.shotNumber, shot])), sceneDir);
|
|
1136
|
+
const shotPaths = [];
|
|
1137
|
+
for (const segment of segments) {
|
|
1138
|
+
const shot = shots.find(item => item.shotNumber === segment.shotNumber);
|
|
1139
|
+
if (!shot)
|
|
1140
|
+
continue;
|
|
1141
|
+
const shotPath = join(sceneDir, segment.outputFile);
|
|
1142
|
+
shotPaths.push(shotPath);
|
|
1143
|
+
await saveSingleShotMetadata(series, shot, shotPath, {
|
|
1144
|
+
...prompt,
|
|
1145
|
+
duration: shot.duration,
|
|
1146
|
+
}, {
|
|
1147
|
+
generationUnit: unit.unitId,
|
|
1148
|
+
generatedFromUnit: unit.outputFile,
|
|
1149
|
+
unitStartOffsetSec: segment.startOffsetSec,
|
|
1150
|
+
unitDurationSec: segment.durationSec,
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
unit.renderedDurationSec = Number(getVideoDuration(savedUnitPath).toFixed(3));
|
|
1154
|
+
unit.segments = segments;
|
|
1155
|
+
await saveJson(savedUnitPath.replace(/\.mp4$/, '.video.json'), {
|
|
1156
|
+
unitId: unit.unitId,
|
|
1157
|
+
shotNumbers: unit.shotNumbers,
|
|
1158
|
+
video: prompt,
|
|
1159
|
+
metadata: {
|
|
1160
|
+
unitType: unit.unitType,
|
|
1161
|
+
segments,
|
|
1162
|
+
decisionReasons: unit.decisionReasons,
|
|
1163
|
+
},
|
|
1164
|
+
});
|
|
1165
|
+
return shotPaths;
|
|
1166
|
+
}
|
|
1167
|
+
async function renderMultiShotUnitUntilSuccess(client, series, shots, unit, sceneDir, previousRenderedShotPath, nextShotNumber) {
|
|
1168
|
+
let attempt = 1;
|
|
1169
|
+
while (true) {
|
|
1170
|
+
try {
|
|
1171
|
+
if (attempt > 1) {
|
|
1172
|
+
console.log(` ${unit.unitId}: retrying multi-shot render (attempt ${attempt})`);
|
|
1173
|
+
}
|
|
1174
|
+
return await renderMultiShotUnit(client, series, shots, unit, sceneDir, previousRenderedShotPath, nextShotNumber);
|
|
1175
|
+
}
|
|
1176
|
+
catch (err) {
|
|
1177
|
+
if (err instanceof VeniceRequestError) {
|
|
1178
|
+
console.warn(` ${unit.unitId}: multi-shot attempt ${attempt} failed (HTTP ${err.status}): ${err.message}`);
|
|
1179
|
+
console.warn(` Error body: ${JSON.stringify(err.body, null, 2)}`);
|
|
1180
|
+
}
|
|
1181
|
+
else {
|
|
1182
|
+
console.warn(` ${unit.unitId}: multi-shot attempt ${attempt} failed - ${err}`);
|
|
1183
|
+
}
|
|
1184
|
+
console.warn(` ${unit.unitId}: keeping multi-shot strategy, retrying in ${(MULTISHOT_RETRY_DELAY_MS / 1000).toFixed(0)}s`);
|
|
1185
|
+
attempt += 1;
|
|
1186
|
+
await sleep(MULTISHOT_RETRY_DELAY_MS);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
/**
|
|
1191
|
+
* Pre-flight check that every shot's requested duration is renderable on the
|
|
1192
|
+
* model it was routed to. Throws a single error listing all violations so the
|
|
1193
|
+
* operator can fix the script in one pass rather than fail-and-retry against
|
|
1194
|
+
* Venice (which returns HTTP 422 deep into the queue call with a generic
|
|
1195
|
+
* message).
|
|
1196
|
+
*
|
|
1197
|
+
* Examples this catches:
|
|
1198
|
+
* - duration: "16s" routed to seedance-2-0-* (max 15s)
|
|
1199
|
+
* - duration: "12s" routed to veo3.1-fast-image-to-video (max 8s)
|
|
1200
|
+
* - duration: "8s" routed to wan-2-7-reference-to-video (max 10s, but step
|
|
1201
|
+
* restriction — 8s not in [5s, 10s])
|
|
1202
|
+
*/
|
|
1203
|
+
export function assertShotDurationsValid(shots, plan) {
|
|
1204
|
+
const violations = [];
|
|
1205
|
+
const shotById = new Map(shots.map(s => [s.shotNumber, s]));
|
|
1206
|
+
for (const unit of plan.units) {
|
|
1207
|
+
const model = unit.model;
|
|
1208
|
+
const modelSpec = getVideoModel(model);
|
|
1209
|
+
if (!modelSpec) {
|
|
1210
|
+
// Unknown model — registry may be stale. Don't block; the API will
|
|
1211
|
+
// surface the real error if the model is actually missing.
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
for (const shotNum of unit.shotNumbers) {
|
|
1215
|
+
const shot = shotById.get(shotNum);
|
|
1216
|
+
if (!shot)
|
|
1217
|
+
continue;
|
|
1218
|
+
const requestedSec = parseShotDuration(shot.duration);
|
|
1219
|
+
if (!Number.isFinite(requestedSec) || requestedSec <= 0)
|
|
1220
|
+
continue;
|
|
1221
|
+
if (requestedSec > modelSpec.maxDurationSec) {
|
|
1222
|
+
violations.push({
|
|
1223
|
+
shotNumber: shotNum,
|
|
1224
|
+
duration: shot.duration,
|
|
1225
|
+
durationSec: requestedSec,
|
|
1226
|
+
model,
|
|
1227
|
+
maxSec: modelSpec.maxDurationSec,
|
|
1228
|
+
allowed: modelSpec.durations,
|
|
1229
|
+
});
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
// The model may have a stepped duration ladder (e.g. Wan 2.7 R2V only
|
|
1233
|
+
// accepts 5s/10s). Use modelSupportsDuration for the loose
|
|
1234
|
+
// (under-the-ceiling) check, then a strict membership check when the
|
|
1235
|
+
// model exposes an explicit ladder. Strict check catches 8s on Wan 2.7
|
|
1236
|
+
// R2V which modelSupportsDuration's lenient fallback would let through.
|
|
1237
|
+
const passesLenient = modelSupportsDuration(model, shot.duration);
|
|
1238
|
+
const passesStrict = modelSpec.durations.length === 0
|
|
1239
|
+
|| modelSpec.durations.includes(shot.duration);
|
|
1240
|
+
if (!passesLenient || !passesStrict) {
|
|
1241
|
+
violations.push({
|
|
1242
|
+
shotNumber: shotNum,
|
|
1243
|
+
duration: shot.duration,
|
|
1244
|
+
durationSec: requestedSec,
|
|
1245
|
+
model,
|
|
1246
|
+
maxSec: modelSpec.maxDurationSec,
|
|
1247
|
+
allowed: modelSpec.durations,
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
if (violations.length === 0)
|
|
1253
|
+
return;
|
|
1254
|
+
const lines = violations.map(v => ` shot ${v.shotNumber}: duration "${v.duration}" (${v.durationSec}s) exceeds model "${v.model}" ceiling ${v.maxSec}s (allowed: ${v.allowed.join(', ') || '<none>'})`);
|
|
1255
|
+
throw new Error(`Shot duration preflight failed for ${violations.length} shot(s):\n${lines.join('\n')}\n` +
|
|
1256
|
+
`Edit script.json and re-run, or update the model registry in src/venice/models.ts if the ceiling has changed.`);
|
|
1257
|
+
}
|
|
1258
|
+
export async function generateEpisodeVideos(client, series, shots, sceneDir, plan, episodeAudioMix) {
|
|
1259
|
+
// Fail fast on duration / model mismatches before any Venice queue call.
|
|
1260
|
+
assertShotDurationsValid(shots, plan);
|
|
1261
|
+
const videoPaths = [];
|
|
1262
|
+
// Suffixed inserts ("13b") share their base shotNumber, so a Map keyed by
|
|
1263
|
+
// shotNumber collapses "13" and "13b" onto one entry and the base shot is
|
|
1264
|
+
// silently skipped as "video exists" (the insert's video). The plan is
|
|
1265
|
+
// built from `shots` in order with each shot in exactly one unit, so we
|
|
1266
|
+
// resolve unit shots with a sequential cursor instead; the Map remains
|
|
1267
|
+
// only as a fallback for hand-edited plans.
|
|
1268
|
+
const shotsByNumber = new Map(shots.map(shot => [shot.shotNumber, shot]));
|
|
1269
|
+
let shotCursor = 0;
|
|
1270
|
+
let previousRenderedShotPath;
|
|
1271
|
+
let previousShot;
|
|
1272
|
+
for (let unitIndex = 0; unitIndex < plan.units.length; unitIndex++) {
|
|
1273
|
+
const unit = plan.units[unitIndex];
|
|
1274
|
+
const unitShots = unit.shotNumbers
|
|
1275
|
+
.map(shotNumber => {
|
|
1276
|
+
const candidate = shots[shotCursor];
|
|
1277
|
+
if (candidate && candidate.shotNumber === shotNumber) {
|
|
1278
|
+
shotCursor += 1;
|
|
1279
|
+
return candidate;
|
|
1280
|
+
}
|
|
1281
|
+
return shotsByNumber.get(shotNumber);
|
|
1282
|
+
})
|
|
1283
|
+
.filter((shot) => Boolean(shot));
|
|
1284
|
+
const nextUnit = plan.units[unitIndex + 1];
|
|
1285
|
+
const nextShotNumber = nextUnit?.shotNumbers[0];
|
|
1286
|
+
if (unitShots.length === 0)
|
|
1287
|
+
continue;
|
|
1288
|
+
try {
|
|
1289
|
+
const savedPaths = unit.unitType === 'single'
|
|
1290
|
+
? await renderSingleShotUnit(client, series, unitShots[0], unit, sceneDir, previousRenderedShotPath, nextShotNumber, previousShot, episodeAudioMix)
|
|
1291
|
+
: await renderMultiShotUnitUntilSuccess(client, series, unitShots, unit, sceneDir, previousRenderedShotPath, nextShotNumber);
|
|
1292
|
+
if (savedPaths.length > 0) {
|
|
1293
|
+
videoPaths.push(...savedPaths);
|
|
1294
|
+
previousRenderedShotPath = savedPaths[savedPaths.length - 1];
|
|
1295
|
+
}
|
|
1296
|
+
previousShot = unitShots[unitShots.length - 1];
|
|
1297
|
+
console.log('');
|
|
1298
|
+
}
|
|
1299
|
+
catch (err) {
|
|
1300
|
+
throw err;
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
return { videoPaths, plan };
|
|
1304
|
+
}
|
|
1305
|
+
//# sourceMappingURL=video-generator.js.map
|