@telepat/rilo 0.1.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/LICENSE +21 -0
- package/README.md +209 -0
- package/index.js +1 -0
- package/models/black-forest-labs__flux-2-pro.json +78 -0
- package/models/black-forest-labs__flux-schnell.json +95 -0
- package/models/bytedance__seedream-4.json +71 -0
- package/models/deepseek-ai__deepseek-v3.json +61 -0
- package/models/google__nano-banana-pro.json +92 -0
- package/models/google__veo-3.1-fast.json +93 -0
- package/models/google__veo-3.1.json +93 -0
- package/models/jaaari__kokoro-82m.json +86 -0
- package/models/kwaivgi__kling-v3-video.json +101 -0
- package/models/minimax__speech-02-turbo.json +141 -0
- package/models/pixverse__pixverse-v5.6.json +113 -0
- package/models/prunaai__z-image-turbo.json +107 -0
- package/models/resemble-ai__chatterbox-turbo.json +102 -0
- package/models/wan-video__wan-2.2-i2v-fast.json +139 -0
- package/package.json +67 -0
- package/src/api/firebaseFunction.js +46 -0
- package/src/api/middleware/auth.js +70 -0
- package/src/api/openapi/generateOpenApi.js +21 -0
- package/src/api/openapi/spec.js +831 -0
- package/src/api/routes/jobs.js +45 -0
- package/src/api/routes/projectAssets.js +63 -0
- package/src/api/routes/projects.js +647 -0
- package/src/api/routes/webhooks.js +13 -0
- package/src/api/server.js +88 -0
- package/src/backends/firebaseClient.js +57 -0
- package/src/backends/outputBackend.js +186 -0
- package/src/backends/projectMetadataBackend.js +550 -0
- package/src/cli/commands/openHome.js +70 -0
- package/src/cli/commands/settingsFlow.js +196 -0
- package/src/cli/index.js +192 -0
- package/src/config/env.js +158 -0
- package/src/config/keystore.js +175 -0
- package/src/config/models.js +281 -0
- package/src/config/settingsSchema.js +214 -0
- package/src/media/ffmpeg.js +144 -0
- package/src/media/files.js +77 -0
- package/src/media/subtitles.js +444 -0
- package/src/observability/apiTrace.js +17 -0
- package/src/observability/logger.js +7 -0
- package/src/observability/metrics.js +10 -0
- package/src/pipeline/inputSanitizer.js +6 -0
- package/src/pipeline/orchestrator.js +1669 -0
- package/src/policy/contentGuardrails.js +30 -0
- package/src/providers/predictions.js +188 -0
- package/src/providers/replicateClient.js +12 -0
- package/src/steps/alignSubtitles.js +156 -0
- package/src/steps/burnInSubtitles.js +22 -0
- package/src/steps/composeFinalVideo.js +57 -0
- package/src/steps/generateKeyframes.js +70 -0
- package/src/steps/generateVideoSegments.js +95 -0
- package/src/steps/generateVoiceover.js +128 -0
- package/src/steps/imageToVideoAdapters.js +100 -0
- package/src/steps/script.js +177 -0
- package/src/steps/textToImageAdapters.js +87 -0
- package/src/store/assetStore.js +5 -0
- package/src/store/jobStore.js +102 -0
- package/src/store/projectAnalyticsStore.js +625 -0
- package/src/store/projectStore.js +684 -0
- package/src/store/settingsStore.js +155 -0
- package/src/store/staleAssetStore.js +63 -0
- package/src/types/job.js +28 -0
- package/src/types/media.js +28 -0
- package/src/worker/processor.js +24 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { DEFAULT_VIDEO_CONFIG, MODEL_CATEGORIES, getModelInputOptions, resolveModelForCategory } from '../config/models.js';
|
|
2
|
+
import { runModel, extractOutputUri } from '../providers/predictions.js';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { downloadToFile, ensureDir } from '../media/files.js';
|
|
5
|
+
|
|
6
|
+
const TTS_WORDS_PER_SECOND = 2.6;
|
|
7
|
+
const MIN_TTS_SPEED = 0.75;
|
|
8
|
+
const MAX_TTS_SPEED = 1.25;
|
|
9
|
+
|
|
10
|
+
function countWords(text) {
|
|
11
|
+
return String(text || '')
|
|
12
|
+
.trim()
|
|
13
|
+
.split(/\s+/)
|
|
14
|
+
.filter(Boolean).length;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function clamp(value, min, max) {
|
|
18
|
+
return Math.min(max, Math.max(min, value));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function asModelOptions(candidate) {
|
|
22
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
return candidate;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveTtsSpeed(script, targetDurationSec) {
|
|
29
|
+
const words = countWords(script);
|
|
30
|
+
const safeTarget = Number.isInteger(targetDurationSec) && targetDurationSec > 0
|
|
31
|
+
? targetDurationSec
|
|
32
|
+
: DEFAULT_VIDEO_CONFIG.durationSec;
|
|
33
|
+
|
|
34
|
+
const estimatedAtSpeedOne = words / TTS_WORDS_PER_SECOND;
|
|
35
|
+
const rawSpeed = estimatedAtSpeedOne / safeTarget;
|
|
36
|
+
const speed = clamp(rawSpeed || 1, MIN_TTS_SPEED, MAX_TTS_SPEED);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
speed,
|
|
40
|
+
words,
|
|
41
|
+
estimatedAtSpeedOneSec: Math.round(estimatedAtSpeedOne * 1000) / 1000
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function buildFixedTimeline(shotsCount, segmentDurationSec = DEFAULT_VIDEO_CONFIG.segmentDurationSec) {
|
|
46
|
+
const normalizedShotCount = Number.isInteger(shotsCount) && shotsCount > 0 ? shotsCount : DEFAULT_VIDEO_CONFIG.shots;
|
|
47
|
+
const normalizedSegmentDuration = Number.isInteger(segmentDurationSec) && segmentDurationSec > 0
|
|
48
|
+
? segmentDurationSec
|
|
49
|
+
: DEFAULT_VIDEO_CONFIG.segmentDurationSec;
|
|
50
|
+
|
|
51
|
+
return Array.from({ length: normalizedShotCount }, (_, idx) => ({
|
|
52
|
+
shot: idx + 1,
|
|
53
|
+
durationSec: normalizedSegmentDuration
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function resolveSegmentCountFromAudioDuration(audioDurationSec, segmentDurationSec = DEFAULT_VIDEO_CONFIG.segmentDurationSec) {
|
|
58
|
+
const normalizedSegmentDuration = Number.isInteger(segmentDurationSec) && segmentDurationSec > 0
|
|
59
|
+
? segmentDurationSec
|
|
60
|
+
: DEFAULT_VIDEO_CONFIG.segmentDurationSec;
|
|
61
|
+
|
|
62
|
+
if (!Number.isFinite(audioDurationSec) || audioDurationSec <= 0) {
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return Math.max(1, Math.ceil(audioDurationSec / normalizedSegmentDuration));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function generateVoiceover(script, options = {}, trace = null) {
|
|
70
|
+
const deps = options.deps || {};
|
|
71
|
+
const runModelFn = deps.runModel || runModel;
|
|
72
|
+
const extractOutputUriFn = deps.extractOutputUri || extractOutputUri;
|
|
73
|
+
|
|
74
|
+
const shotsCount = Number.isInteger(options.shotsCount) && options.shotsCount > 0
|
|
75
|
+
? options.shotsCount
|
|
76
|
+
: DEFAULT_VIDEO_CONFIG.shots;
|
|
77
|
+
const segmentDurationSec = Number.isInteger(options.segmentDurationSec) && options.segmentDurationSec > 0
|
|
78
|
+
? options.segmentDurationSec
|
|
79
|
+
: DEFAULT_VIDEO_CONFIG.segmentDurationSec;
|
|
80
|
+
const targetDurationSec = Number.isInteger(options.targetDurationSec) && options.targetDurationSec > 0
|
|
81
|
+
? options.targetDurationSec
|
|
82
|
+
: shotsCount * segmentDurationSec;
|
|
83
|
+
const ttsPlan = resolveTtsSpeed(script, targetDurationSec);
|
|
84
|
+
const modelId = options.modelId || resolveModelForCategory(MODEL_CATEGORIES.textToSpeech);
|
|
85
|
+
const modelOptions = asModelOptions(options.modelOptions);
|
|
86
|
+
const inputOptions = getModelInputOptions(modelId);
|
|
87
|
+
const allowedFields = new Set(inputOptions.userConfigurable || []);
|
|
88
|
+
|
|
89
|
+
const input = {
|
|
90
|
+
...modelOptions,
|
|
91
|
+
text: script
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
if (allowedFields.has('speed') && !Object.prototype.hasOwnProperty.call(modelOptions, 'speed')) {
|
|
95
|
+
input.speed = ttsPlan.speed;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (allowedFields.has('subtitle_enable') && !Object.prototype.hasOwnProperty.call(modelOptions, 'subtitle_enable')) {
|
|
99
|
+
input.subtitle_enable = false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const prediction = await runModelFn({
|
|
103
|
+
model: modelId,
|
|
104
|
+
input,
|
|
105
|
+
trace: trace ? { ...trace, step: 'voiceover' } : null
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
voiceoverUrl: extractOutputUriFn(prediction.output),
|
|
110
|
+
timeline: buildFixedTimeline(shotsCount, segmentDurationSec),
|
|
111
|
+
ttsPlan: {
|
|
112
|
+
...ttsPlan,
|
|
113
|
+
targetDurationSec
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function persistVoiceover(projectDir, voiceoverUrl, options = {}) {
|
|
119
|
+
const deps = options.deps || {};
|
|
120
|
+
const ensureDirFn = deps.ensureDir || ensureDir;
|
|
121
|
+
const downloadToFileFn = deps.downloadToFile || downloadToFile;
|
|
122
|
+
|
|
123
|
+
const audioDir = path.join(projectDir, 'assets', 'audio');
|
|
124
|
+
await ensureDirFn(audioDir);
|
|
125
|
+
const voicePath = path.join(audioDir, 'voiceover.mp3');
|
|
126
|
+
await downloadToFileFn(voiceoverUrl, voicePath);
|
|
127
|
+
return voicePath;
|
|
128
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { DEFAULT_VIDEO_CONFIG, MODELS } from '../config/models.js';
|
|
2
|
+
|
|
3
|
+
function normalizeModelOptions(candidate) {
|
|
4
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
|
|
5
|
+
return {};
|
|
6
|
+
}
|
|
7
|
+
return candidate;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function secondsToFrames(seconds, fps) {
|
|
11
|
+
return Math.max(81, Math.round(seconds * fps));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function buildWanInput({ prompt, startImage, endImage, durationSec, videoResolution, modelOptions }) {
|
|
15
|
+
return {
|
|
16
|
+
...normalizeModelOptions(modelOptions),
|
|
17
|
+
prompt,
|
|
18
|
+
image: startImage,
|
|
19
|
+
last_image: endImage,
|
|
20
|
+
num_frames: secondsToFrames(durationSec, DEFAULT_VIDEO_CONFIG.fps),
|
|
21
|
+
frames_per_second: DEFAULT_VIDEO_CONFIG.fps,
|
|
22
|
+
resolution: videoResolution
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function buildKlingInput({ prompt, startImage, endImage, aspectRatio, modelOptions }) {
|
|
27
|
+
const normalizedOptions = {
|
|
28
|
+
...normalizeModelOptions(modelOptions),
|
|
29
|
+
generate_audio: false
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
...normalizedOptions,
|
|
34
|
+
prompt,
|
|
35
|
+
start_image: startImage,
|
|
36
|
+
end_image: endImage,
|
|
37
|
+
aspect_ratio: aspectRatio,
|
|
38
|
+
duration: 5
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildPixverseInput({ prompt, startImage, endImage, aspectRatio, modelOptions }) {
|
|
43
|
+
const normalizedOptions = {
|
|
44
|
+
...normalizeModelOptions(modelOptions),
|
|
45
|
+
generate_audio_switch: false
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
...normalizedOptions,
|
|
50
|
+
prompt,
|
|
51
|
+
image: startImage,
|
|
52
|
+
last_frame_image: endImage,
|
|
53
|
+
aspect_ratio: aspectRatio,
|
|
54
|
+
duration: 5
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildVeoInput({ prompt, startImage, endImage, aspectRatio, modelOptions }) {
|
|
59
|
+
return {
|
|
60
|
+
...normalizeModelOptions(modelOptions),
|
|
61
|
+
prompt,
|
|
62
|
+
image: startImage,
|
|
63
|
+
last_frame: endImage,
|
|
64
|
+
aspect_ratio: aspectRatio,
|
|
65
|
+
duration: 5,
|
|
66
|
+
generate_audio: false
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const IMAGE_TO_VIDEO_ADAPTERS = {
|
|
71
|
+
[MODELS.video]: {
|
|
72
|
+
modelId: MODELS.video,
|
|
73
|
+
buildInput: buildWanInput
|
|
74
|
+
},
|
|
75
|
+
[MODELS.klingVideo3]: {
|
|
76
|
+
modelId: MODELS.klingVideo3,
|
|
77
|
+
buildInput: buildKlingInput
|
|
78
|
+
},
|
|
79
|
+
[MODELS.pixverseV56]: {
|
|
80
|
+
modelId: MODELS.pixverseV56,
|
|
81
|
+
buildInput: buildPixverseInput
|
|
82
|
+
},
|
|
83
|
+
[MODELS.veo31]: {
|
|
84
|
+
modelId: MODELS.veo31,
|
|
85
|
+
buildInput: buildVeoInput
|
|
86
|
+
},
|
|
87
|
+
[MODELS.veo31Fast]: {
|
|
88
|
+
modelId: MODELS.veo31Fast,
|
|
89
|
+
buildInput: buildVeoInput
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const DEFAULT_IMAGE_TO_VIDEO_ADAPTER = {
|
|
94
|
+
modelId: null,
|
|
95
|
+
buildInput: buildWanInput
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export function resolveImageToVideoAdapter(modelId) {
|
|
99
|
+
return IMAGE_TO_VIDEO_ADAPTERS[modelId] || DEFAULT_IMAGE_TO_VIDEO_ADAPTER;
|
|
100
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { DEFAULT_VIDEO_CONFIG, MODEL_CATEGORIES, resolveModelForCategory } from '../config/models.js';
|
|
2
|
+
import { runModel, extractOutputText } from '../providers/predictions.js';
|
|
3
|
+
|
|
4
|
+
const SCRIPT_WORDS_PER_SECOND = 2.6;
|
|
5
|
+
const SCRIPT_RETRY_LIMIT = 3;
|
|
6
|
+
const SHOTS_RETRY_LIMIT = 3;
|
|
7
|
+
|
|
8
|
+
function extractJsonBlock(text) {
|
|
9
|
+
const start = text.indexOf('{');
|
|
10
|
+
const end = text.lastIndexOf('}');
|
|
11
|
+
if (start === -1 || end === -1 || end <= start) {
|
|
12
|
+
throw new Error('Model response did not include JSON payload');
|
|
13
|
+
}
|
|
14
|
+
return text.slice(start, end + 1);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function countWords(text) {
|
|
18
|
+
return String(text || '')
|
|
19
|
+
.trim()
|
|
20
|
+
.split(/\s+/)
|
|
21
|
+
.filter(Boolean).length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function buildWordBudget(targetDurationSec) {
|
|
25
|
+
const targetWords = Math.max(20, Math.round(targetDurationSec * SCRIPT_WORDS_PER_SECOND));
|
|
26
|
+
return {
|
|
27
|
+
targetWords,
|
|
28
|
+
minWords: Math.max(20, Math.floor(targetWords * 0.9)),
|
|
29
|
+
maxWords: Math.max(30, Math.ceil(targetWords * 1.1))
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildScriptPrompt(story, targetDurationSec, wordBudget, attempt) {
|
|
34
|
+
const retryInstruction =
|
|
35
|
+
attempt > 1
|
|
36
|
+
? `\nIMPORTANT: previous attempt missed the narration length target. Keep script length strictly between ${wordBudget.minWords} and ${wordBudget.maxWords} words.`
|
|
37
|
+
: '';
|
|
38
|
+
|
|
39
|
+
return `You are writing narration for a short video. Return ONLY JSON with keys: script, tone.\nRequirements:\n- script: narration text matching roughly ${targetDurationSec} seconds of voiceover\n- script length: strictly between ${wordBudget.minWords} and ${wordBudget.maxWords} words (target ${wordBudget.targetWords})\n- tone: concise tone label${retryInstruction}\nStory source:\n${story}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildShotsPrompt(script, shotCount, tone, attempt) {
|
|
43
|
+
const retryInstruction =
|
|
44
|
+
attempt > 1
|
|
45
|
+
? `\nIMPORTANT: previous attempt failed shape checks. Return exactly ${shotCount} shots.`
|
|
46
|
+
: '';
|
|
47
|
+
|
|
48
|
+
const toneHint = typeof tone === 'string' && tone.trim() ? tone.trim() : 'neutral';
|
|
49
|
+
return `You are writing visual keyframe prompts for a short video narration. Return ONLY JSON with key: shots.\nRequirements:\n- shots: array of exactly ${shotCount} short visual descriptions\n- each shot: exactly one sentence, concrete and visually descriptive\n- each shot must be fully self-contained because prompts are generated independently (do not rely on context from other shots)\n- in every shot, restate essential visual context like characters, setting, era/time-of-day, and key scene details when relevant\n- keep continuity of characters/setting across shots while still repeating critical details per shot\n- align with narration pacing and tone: ${toneHint}${retryInstruction}\nNarration:\n${script}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function asModelOptions(candidate) {
|
|
53
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
|
|
54
|
+
return {};
|
|
55
|
+
}
|
|
56
|
+
return candidate;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function normalizeShotDescription(shot) {
|
|
60
|
+
if (typeof shot === 'string') {
|
|
61
|
+
const value = shot.trim();
|
|
62
|
+
return value.length > 0 ? value : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (shot && typeof shot === 'object' && !Array.isArray(shot) && typeof shot.description === 'string') {
|
|
66
|
+
const value = shot.description.trim();
|
|
67
|
+
return value.length > 0 ? value : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function generateScript(story, options = {}, trace = null) {
|
|
74
|
+
const deps = options.deps || {};
|
|
75
|
+
const runModelFn = deps.runModel || runModel;
|
|
76
|
+
const extractOutputTextFn = deps.extractOutputText || extractOutputText;
|
|
77
|
+
|
|
78
|
+
const targetDurationSec = Number.isInteger(options.targetDurationSec) && options.targetDurationSec > 0
|
|
79
|
+
? options.targetDurationSec
|
|
80
|
+
: DEFAULT_VIDEO_CONFIG.durationSec;
|
|
81
|
+
const wordBudget = buildWordBudget(targetDurationSec);
|
|
82
|
+
const modelId = options.modelId || resolveModelForCategory(MODEL_CATEGORIES.textToText);
|
|
83
|
+
const modelOptions = asModelOptions(options.modelOptions);
|
|
84
|
+
|
|
85
|
+
let bestCandidate = null;
|
|
86
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
87
|
+
|
|
88
|
+
for (let attempt = 1; attempt <= SCRIPT_RETRY_LIMIT; attempt += 1) {
|
|
89
|
+
const prompt = buildScriptPrompt(story, targetDurationSec, wordBudget, attempt);
|
|
90
|
+
const prediction = await runModelFn({
|
|
91
|
+
model: modelId,
|
|
92
|
+
input: {
|
|
93
|
+
max_tokens: 1800,
|
|
94
|
+
temperature: 0.6,
|
|
95
|
+
...modelOptions,
|
|
96
|
+
prompt
|
|
97
|
+
},
|
|
98
|
+
trace: trace ? { ...trace, step: 'script', attempt } : null
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const text = extractOutputTextFn(prediction.output);
|
|
102
|
+
const parsed = JSON.parse(extractJsonBlock(text));
|
|
103
|
+
|
|
104
|
+
if (!parsed.script || typeof parsed.script !== 'string') {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const words = countWords(parsed.script);
|
|
109
|
+
const distance = Math.abs(words - wordBudget.targetWords);
|
|
110
|
+
|
|
111
|
+
if (distance < bestDistance) {
|
|
112
|
+
bestDistance = distance;
|
|
113
|
+
bestCandidate = {
|
|
114
|
+
script: parsed.script,
|
|
115
|
+
tone: parsed.tone || 'neutral',
|
|
116
|
+
scriptWordCount: words,
|
|
117
|
+
targetWordCount: wordBudget.targetWords
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (words >= wordBudget.minWords && words <= wordBudget.maxWords) {
|
|
122
|
+
return bestCandidate;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!bestCandidate) {
|
|
127
|
+
throw new Error('Invalid script output shape');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return bestCandidate;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function generateShots(script, options = {}, trace = null) {
|
|
134
|
+
const deps = options.deps || {};
|
|
135
|
+
const runModelFn = deps.runModel || runModel;
|
|
136
|
+
const extractOutputTextFn = deps.extractOutputText || extractOutputText;
|
|
137
|
+
|
|
138
|
+
const shotCount = Number.isInteger(options.shotCount) && options.shotCount > 0
|
|
139
|
+
? options.shotCount
|
|
140
|
+
: DEFAULT_VIDEO_CONFIG.shots;
|
|
141
|
+
const tone = typeof options.tone === 'string' ? options.tone : 'neutral';
|
|
142
|
+
const modelId = options.modelId || resolveModelForCategory(MODEL_CATEGORIES.textToText);
|
|
143
|
+
const modelOptions = asModelOptions(options.modelOptions);
|
|
144
|
+
|
|
145
|
+
for (let attempt = 1; attempt <= SHOTS_RETRY_LIMIT; attempt += 1) {
|
|
146
|
+
const prompt = buildShotsPrompt(script, shotCount, tone, attempt);
|
|
147
|
+
const prediction = await runModelFn({
|
|
148
|
+
model: modelId,
|
|
149
|
+
input: {
|
|
150
|
+
max_tokens: 1800,
|
|
151
|
+
temperature: 0.5,
|
|
152
|
+
...modelOptions,
|
|
153
|
+
prompt
|
|
154
|
+
},
|
|
155
|
+
trace: trace ? { ...trace, step: 'shots', attempt } : null
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const text = extractOutputTextFn(prediction.output);
|
|
159
|
+
const parsed = JSON.parse(extractJsonBlock(text));
|
|
160
|
+
const candidateShots = parsed?.shots;
|
|
161
|
+
const normalizedShots = Array.isArray(candidateShots)
|
|
162
|
+
? candidateShots.map((shot) => normalizeShotDescription(shot))
|
|
163
|
+
: null;
|
|
164
|
+
const hasValidShape =
|
|
165
|
+
Array.isArray(normalizedShots) &&
|
|
166
|
+
normalizedShots.length === shotCount &&
|
|
167
|
+
normalizedShots.every((shot) => typeof shot === 'string' && shot.length > 0);
|
|
168
|
+
|
|
169
|
+
if (hasValidShape) {
|
|
170
|
+
return {
|
|
171
|
+
shots: normalizedShots
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
throw new Error('Invalid shots output shape');
|
|
177
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { MODELS } from '../config/models.js';
|
|
2
|
+
|
|
3
|
+
function normalizeModelOptions(candidate) {
|
|
4
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
|
|
5
|
+
return {};
|
|
6
|
+
}
|
|
7
|
+
return candidate;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function buildCommonPrompt(promptText, tone, index) {
|
|
11
|
+
return `Cinematic documentary style, coherent character continuity, shot ${index + 1}, tone ${tone}. ${promptText}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function buildPrunaInput({ promptText, tone, index, width, height, modelOptions }) {
|
|
15
|
+
return {
|
|
16
|
+
...normalizeModelOptions(modelOptions),
|
|
17
|
+
prompt: buildCommonPrompt(promptText, tone, index),
|
|
18
|
+
width,
|
|
19
|
+
height
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildFluxInput({ promptText, tone, index, width, height, modelOptions }) {
|
|
24
|
+
return {
|
|
25
|
+
...normalizeModelOptions(modelOptions),
|
|
26
|
+
prompt: buildCommonPrompt(promptText, tone, index),
|
|
27
|
+
aspect_ratio: 'custom',
|
|
28
|
+
width,
|
|
29
|
+
height
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function buildFluxSchnellInput({ promptText, tone, index, aspectRatio, modelOptions }) {
|
|
34
|
+
return {
|
|
35
|
+
...normalizeModelOptions(modelOptions),
|
|
36
|
+
prompt: buildCommonPrompt(promptText, tone, index),
|
|
37
|
+
aspect_ratio: aspectRatio
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildNanoBananaProInput({ promptText, tone, index, aspectRatio, modelOptions }) {
|
|
42
|
+
return {
|
|
43
|
+
...normalizeModelOptions(modelOptions),
|
|
44
|
+
prompt: buildCommonPrompt(promptText, tone, index),
|
|
45
|
+
aspect_ratio: aspectRatio
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildSeedream4Input({ promptText, tone, index, aspectRatio, modelOptions }) {
|
|
50
|
+
return {
|
|
51
|
+
...normalizeModelOptions(modelOptions),
|
|
52
|
+
prompt: buildCommonPrompt(promptText, tone, index),
|
|
53
|
+
aspect_ratio: aspectRatio
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const TEXT_TO_IMAGE_ADAPTERS = {
|
|
58
|
+
[MODELS.keyframe]: {
|
|
59
|
+
modelId: MODELS.keyframe,
|
|
60
|
+
buildInput: buildPrunaInput
|
|
61
|
+
},
|
|
62
|
+
[MODELS.flux]: {
|
|
63
|
+
modelId: MODELS.flux,
|
|
64
|
+
buildInput: buildFluxInput
|
|
65
|
+
},
|
|
66
|
+
[MODELS.fluxSchnell]: {
|
|
67
|
+
modelId: MODELS.fluxSchnell,
|
|
68
|
+
buildInput: buildFluxSchnellInput
|
|
69
|
+
},
|
|
70
|
+
[MODELS.nanoBananaPro]: {
|
|
71
|
+
modelId: MODELS.nanoBananaPro,
|
|
72
|
+
buildInput: buildNanoBananaProInput
|
|
73
|
+
},
|
|
74
|
+
[MODELS.seedream4]: {
|
|
75
|
+
modelId: MODELS.seedream4,
|
|
76
|
+
buildInput: buildSeedream4Input
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const DEFAULT_TEXT_TO_IMAGE_ADAPTER = {
|
|
81
|
+
modelId: null,
|
|
82
|
+
buildInput: buildPrunaInput
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export function resolveTextToImageAdapter(modelId) {
|
|
86
|
+
return TEXT_TO_IMAGE_ADAPTERS[modelId] || DEFAULT_TEXT_TO_IMAGE_ADAPTER;
|
|
87
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
2
|
+
import { JobStatus } from '../types/job.js';
|
|
3
|
+
import { emptyPipelineArtifacts } from '../types/media.js';
|
|
4
|
+
import { emptyStepState } from '../types/job.js';
|
|
5
|
+
|
|
6
|
+
const jobs = new Map();
|
|
7
|
+
const projectRunLocks = new Map();
|
|
8
|
+
|
|
9
|
+
function normalizeProjectName(project) {
|
|
10
|
+
return String(project || '').trim().toLowerCase();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createJob(payload) {
|
|
14
|
+
const id = uuidv4();
|
|
15
|
+
const job = {
|
|
16
|
+
id,
|
|
17
|
+
status: JobStatus.PENDING,
|
|
18
|
+
createdAt: new Date().toISOString(),
|
|
19
|
+
updatedAt: new Date().toISOString(),
|
|
20
|
+
payload,
|
|
21
|
+
error: null,
|
|
22
|
+
artifacts: emptyPipelineArtifacts(),
|
|
23
|
+
steps: emptyStepState()
|
|
24
|
+
};
|
|
25
|
+
jobs.set(id, job);
|
|
26
|
+
return job;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function updateJob(id, updates) {
|
|
30
|
+
const current = jobs.get(id);
|
|
31
|
+
if (!current) return null;
|
|
32
|
+
const next = {
|
|
33
|
+
...current,
|
|
34
|
+
...updates,
|
|
35
|
+
updatedAt: new Date().toISOString()
|
|
36
|
+
};
|
|
37
|
+
jobs.set(id, next);
|
|
38
|
+
return next;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getJob(id) {
|
|
42
|
+
return jobs.get(id) || null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function findActiveJobByProject(project, options = {}) {
|
|
46
|
+
const targetProject = normalizeProjectName(project);
|
|
47
|
+
if (!targetProject) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const excludeJobId = options.excludeJobId || null;
|
|
52
|
+
return [...jobs.values()].find((job) => {
|
|
53
|
+
if (!job || (excludeJobId && job.id === excludeJobId)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (job.status !== JobStatus.PENDING && job.status !== JobStatus.RUNNING) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return normalizeProjectName(job.payload?.project) === targetProject;
|
|
62
|
+
}) || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function acquireProjectRunLock(project, jobId) {
|
|
66
|
+
const projectName = normalizeProjectName(project);
|
|
67
|
+
if (!projectName || !jobId) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const currentOwner = projectRunLocks.get(projectName);
|
|
72
|
+
if (currentOwner && currentOwner !== jobId) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
projectRunLocks.set(projectName, jobId);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function releaseProjectRunLock(project, jobId) {
|
|
81
|
+
const projectName = normalizeProjectName(project);
|
|
82
|
+
if (!projectName || !jobId) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const currentOwner = projectRunLocks.get(projectName);
|
|
87
|
+
if (currentOwner === jobId) {
|
|
88
|
+
projectRunLocks.delete(projectName);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function getProjectRunLockOwner(project) {
|
|
93
|
+
const projectName = normalizeProjectName(project);
|
|
94
|
+
if (!projectName) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return projectRunLocks.get(projectName) || null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function listPendingJobs() {
|
|
101
|
+
return [...jobs.values()].filter((job) => job.status === JobStatus.PENDING);
|
|
102
|
+
}
|