@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,30 @@
|
|
|
1
|
+
const NAME_PATTERNS = [
|
|
2
|
+
/\bMr\.\s+[A-Z][a-z]+\b/g,
|
|
3
|
+
/\bMrs\.\s+[A-Z][a-z]+\b/g,
|
|
4
|
+
/\bMs\.\s+[A-Z][a-z]+\b/g,
|
|
5
|
+
/\bJudge\s+[A-Z][a-z]+\b/g,
|
|
6
|
+
/\b[A-Z][a-z]+\s+[A-Z][a-z]+\b/g
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
const SENSITIVE_PATTERNS = [
|
|
10
|
+
/\b\d{3}-\d{2}-\d{4}\b/g,
|
|
11
|
+
/\b\d{10,}\b/g,
|
|
12
|
+
/\b\d{1,5}\s+[A-Za-z]+\s+(Street|St|Avenue|Ave|Road|Rd|Boulevard|Blvd)\b/gi
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export function sanitizeStoryInput(story) {
|
|
16
|
+
let sanitized = story;
|
|
17
|
+
for (const pattern of NAME_PATTERNS) {
|
|
18
|
+
sanitized = sanitized.replace(pattern, 'a person');
|
|
19
|
+
}
|
|
20
|
+
for (const pattern of SENSITIVE_PATTERNS) {
|
|
21
|
+
sanitized = sanitized.replace(pattern, '[redacted]');
|
|
22
|
+
}
|
|
23
|
+
return sanitized;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function assertSafeStory(story) {
|
|
27
|
+
if (!story || story.trim().length < 50) {
|
|
28
|
+
throw new Error('Story input must be at least 50 characters');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { env } from '../config/env.js';
|
|
2
|
+
import { getReplicateClient } from './replicateClient.js';
|
|
3
|
+
import { logError, logInfo } from '../observability/logger.js';
|
|
4
|
+
import { appendApiTrace } from '../observability/apiTrace.js';
|
|
5
|
+
|
|
6
|
+
function pickPredictionFields(prediction) {
|
|
7
|
+
if (!prediction || typeof prediction !== 'object') {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
id: prediction.id || null,
|
|
13
|
+
status: prediction.status || null,
|
|
14
|
+
createdAt: prediction.created_at || null,
|
|
15
|
+
startedAt: prediction.started_at || null,
|
|
16
|
+
completedAt: prediction.completed_at || null,
|
|
17
|
+
error: prediction.error || null,
|
|
18
|
+
metrics: prediction.metrics || null
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function sleep(ms) {
|
|
23
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function pickErrorFields(error) {
|
|
27
|
+
if (!error || typeof error !== 'object') {
|
|
28
|
+
return {
|
|
29
|
+
name: 'Error',
|
|
30
|
+
message: String(error || ''),
|
|
31
|
+
nonRetryable: false
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
name: error.name || 'Error',
|
|
37
|
+
message: error.message || '',
|
|
38
|
+
code: error.code || null,
|
|
39
|
+
status: error.status || error.statusCode || null,
|
|
40
|
+
nonRetryable: Boolean(error.nonRetryable)
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function withRetries(fn, label, { logErrorFn = logError, sleepFn = sleep } = {}) {
|
|
45
|
+
let lastError;
|
|
46
|
+
const maxAttempts = env.maxRetries + 1;
|
|
47
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
48
|
+
try {
|
|
49
|
+
return await fn();
|
|
50
|
+
} catch (error) {
|
|
51
|
+
lastError = error;
|
|
52
|
+
const shouldRetry = !error?.nonRetryable && attempt < maxAttempts;
|
|
53
|
+
const retryDelayMs = shouldRetry ? env.retryDelayMs * attempt : 0;
|
|
54
|
+
logErrorFn('prediction_attempt_failed', {
|
|
55
|
+
label,
|
|
56
|
+
attempt,
|
|
57
|
+
maxAttempts,
|
|
58
|
+
willRetry: shouldRetry,
|
|
59
|
+
retryDelayMs,
|
|
60
|
+
error: pickErrorFields(error)
|
|
61
|
+
});
|
|
62
|
+
if (error?.nonRetryable) {
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
if (shouldRetry) {
|
|
66
|
+
await sleepFn(retryDelayMs);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
throw lastError;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function runModel({ model, input, trace = null, deps = {} }) {
|
|
74
|
+
const getReplicateClientFn = deps.getReplicateClient || getReplicateClient;
|
|
75
|
+
const appendApiTraceFn = deps.appendApiTrace || appendApiTrace;
|
|
76
|
+
const logInfoFn = deps.logInfo || logInfo;
|
|
77
|
+
const sleepFn = deps.sleep || sleep;
|
|
78
|
+
const nowFn = deps.now || (() => Date.now());
|
|
79
|
+
const logErrorFn = deps.logError || logError;
|
|
80
|
+
|
|
81
|
+
if (env.useWebhooks) {
|
|
82
|
+
const error = new Error('USE_WEBHOOKS=true is currently disabled until webhook verification and queue reconciliation are implemented');
|
|
83
|
+
error.nonRetryable = true;
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const replicate = getReplicateClientFn();
|
|
88
|
+
|
|
89
|
+
return withRetries(async () => {
|
|
90
|
+
const startedAt = new Date().toISOString();
|
|
91
|
+
logInfoFn('prediction_start', { model });
|
|
92
|
+
await appendApiTraceFn(trace?.projectDir, {
|
|
93
|
+
type: 'request_start',
|
|
94
|
+
ts: startedAt,
|
|
95
|
+
model,
|
|
96
|
+
input,
|
|
97
|
+
trace
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const prediction = await replicate.predictions.create({
|
|
101
|
+
model,
|
|
102
|
+
input,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await appendApiTraceFn(trace?.projectDir, {
|
|
106
|
+
type: 'request_created',
|
|
107
|
+
ts: new Date().toISOString(),
|
|
108
|
+
model,
|
|
109
|
+
predictionId: prediction.id,
|
|
110
|
+
status: prediction.status,
|
|
111
|
+
prediction: pickPredictionFields(prediction),
|
|
112
|
+
trace
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
let current = prediction;
|
|
116
|
+
const pollStartedAt = nowFn();
|
|
117
|
+
while (current.status !== 'succeeded' && current.status !== 'failed' && current.status !== 'canceled') {
|
|
118
|
+
if (nowFn() - pollStartedAt > env.predictionMaxWaitMs) {
|
|
119
|
+
await appendApiTraceFn(trace?.projectDir, {
|
|
120
|
+
type: 'request_timeout',
|
|
121
|
+
ts: new Date().toISOString(),
|
|
122
|
+
model,
|
|
123
|
+
predictionId: current.id,
|
|
124
|
+
status: current.status,
|
|
125
|
+
prediction: pickPredictionFields(current),
|
|
126
|
+
trace
|
|
127
|
+
});
|
|
128
|
+
const timeoutError = new Error(`Prediction timed out after ${env.predictionMaxWaitMs}ms`);
|
|
129
|
+
timeoutError.nonRetryable = true;
|
|
130
|
+
throw timeoutError;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
await sleepFn(env.predictionPollIntervalMs);
|
|
134
|
+
current = await replicate.predictions.get(current.id);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (current.status !== 'succeeded') {
|
|
138
|
+
await appendApiTraceFn(trace?.projectDir, {
|
|
139
|
+
type: 'request_failed',
|
|
140
|
+
ts: new Date().toISOString(),
|
|
141
|
+
model,
|
|
142
|
+
input,
|
|
143
|
+
predictionId: current.id,
|
|
144
|
+
status: current.status,
|
|
145
|
+
prediction: pickPredictionFields(current),
|
|
146
|
+
trace
|
|
147
|
+
});
|
|
148
|
+
const statusError = new Error(`Prediction failed with status ${current.status}`);
|
|
149
|
+
statusError.nonRetryable = true;
|
|
150
|
+
throw statusError;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
logInfoFn('prediction_succeeded', { model, predictionId: current.id });
|
|
154
|
+
await appendApiTraceFn(trace?.projectDir, {
|
|
155
|
+
type: 'request_succeeded',
|
|
156
|
+
ts: new Date().toISOString(),
|
|
157
|
+
model,
|
|
158
|
+
input,
|
|
159
|
+
predictionId: current.id,
|
|
160
|
+
status: current.status,
|
|
161
|
+
prediction: pickPredictionFields(current),
|
|
162
|
+
output: current.output,
|
|
163
|
+
trace
|
|
164
|
+
});
|
|
165
|
+
return current;
|
|
166
|
+
}, model, { logErrorFn, sleepFn });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function extractOutputText(output) {
|
|
170
|
+
if (Array.isArray(output)) {
|
|
171
|
+
return output.join('').trim();
|
|
172
|
+
}
|
|
173
|
+
if (typeof output === 'string') {
|
|
174
|
+
return output.trim();
|
|
175
|
+
}
|
|
176
|
+
return JSON.stringify(output || '');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function extractOutputUri(output) {
|
|
180
|
+
if (typeof output === 'string') return output;
|
|
181
|
+
if (Array.isArray(output) && output.length > 0) {
|
|
182
|
+
return typeof output[0] === 'string' ? output[0] : '';
|
|
183
|
+
}
|
|
184
|
+
if (output && typeof output === 'object' && output.url) {
|
|
185
|
+
return output.url;
|
|
186
|
+
}
|
|
187
|
+
return '';
|
|
188
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import Replicate from 'replicate';
|
|
2
|
+
import { env, assertRequiredEnv } from '../config/env.js';
|
|
3
|
+
|
|
4
|
+
let client;
|
|
5
|
+
|
|
6
|
+
export function getReplicateClient() {
|
|
7
|
+
if (!client) {
|
|
8
|
+
assertRequiredEnv();
|
|
9
|
+
client = new Replicate({ auth: env.replicateApiToken });
|
|
10
|
+
}
|
|
11
|
+
return client;
|
|
12
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { env } from '../config/env.js';
|
|
5
|
+
import { ensureDir } from '../media/files.js';
|
|
6
|
+
import { parseSrtCues, writeSeedSrtFromScript, writeAssFromSrt } from '../media/subtitles.js';
|
|
7
|
+
|
|
8
|
+
function runCommand(command, args) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
11
|
+
let stderr = '';
|
|
12
|
+
|
|
13
|
+
proc.stderr.on('data', (chunk) => {
|
|
14
|
+
stderr += chunk.toString();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
proc.on('error', reject);
|
|
18
|
+
proc.on('exit', (code) => {
|
|
19
|
+
if (code === 0) {
|
|
20
|
+
resolve();
|
|
21
|
+
} else {
|
|
22
|
+
reject(new Error((stderr || `${command} exited with code ${code}`).trim()));
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeCueText(text) {
|
|
29
|
+
return String(text || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function formatSrtTimestamp(ms) {
|
|
33
|
+
const total = Math.max(0, Math.round(ms));
|
|
34
|
+
const hours = Math.floor(total / 3600000);
|
|
35
|
+
const minutes = Math.floor((total % 3600000) / 60000);
|
|
36
|
+
const seconds = Math.floor((total % 60000) / 1000);
|
|
37
|
+
const millis = total % 1000;
|
|
38
|
+
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')},${String(millis).padStart(3, '0')}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function serializeSrtCues(cues) {
|
|
42
|
+
return cues.map((cue, index) => {
|
|
43
|
+
return `${index + 1}\n${formatSrtTimestamp(cue.startMs)} --> ${formatSrtTimestamp(cue.endMs)}\n${cue.text}`;
|
|
44
|
+
}).join('\n\n');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function restoreDroppedLeadingCues(seedCues, alignedCues) {
|
|
48
|
+
if (!Array.isArray(seedCues) || !Array.isArray(alignedCues) || !alignedCues.length || alignedCues.length >= seedCues.length) {
|
|
49
|
+
return alignedCues;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const firstAligned = alignedCues[0];
|
|
53
|
+
const firstAlignedText = normalizeCueText(firstAligned.text);
|
|
54
|
+
if (!firstAlignedText) {
|
|
55
|
+
return alignedCues;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const firstSeedMatchIndex = seedCues.findIndex((cue) => normalizeCueText(cue.text) === firstAlignedText);
|
|
59
|
+
if (firstSeedMatchIndex <= 0) {
|
|
60
|
+
return alignedCues;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const shiftMs = firstAligned.startMs - seedCues[firstSeedMatchIndex].startMs;
|
|
64
|
+
const recoveredLeading = seedCues.slice(0, firstSeedMatchIndex).map((cue) => {
|
|
65
|
+
const shiftedStartMs = cue.startMs + shiftMs;
|
|
66
|
+
const shiftedEndMs = cue.endMs + shiftMs;
|
|
67
|
+
const startMs = Math.max(0, shiftedStartMs);
|
|
68
|
+
const endMs = Math.max(startMs + 120, shiftedEndMs);
|
|
69
|
+
return {
|
|
70
|
+
startMs,
|
|
71
|
+
endMs,
|
|
72
|
+
text: cue.text
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return [...recoveredLeading, ...alignedCues];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function reconcileAlignedSrt(seedSrtPath, alignedSrtPath, deps = {}) {
|
|
80
|
+
const readFileFn = deps.readFile || fs.readFile;
|
|
81
|
+
const writeFileFn = deps.writeFile || fs.writeFile;
|
|
82
|
+
|
|
83
|
+
const [seedRaw, alignedRaw] = await Promise.all([
|
|
84
|
+
readFileFn(seedSrtPath, 'utf8'),
|
|
85
|
+
readFileFn(alignedSrtPath, 'utf8')
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const seedCues = parseSrtCues(seedRaw);
|
|
89
|
+
const alignedCues = parseSrtCues(alignedRaw);
|
|
90
|
+
|
|
91
|
+
if (!seedCues.length || !alignedCues.length) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const reconciled = restoreDroppedLeadingCues(seedCues, alignedCues);
|
|
96
|
+
if (reconciled.length === alignedCues.length) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await writeFileFn(alignedSrtPath, `${serializeSrtCues(reconciled)}\n`, 'utf8');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function alignSubtitlesToVideo({
|
|
104
|
+
projectDir,
|
|
105
|
+
videoPath,
|
|
106
|
+
script,
|
|
107
|
+
totalDurationSec,
|
|
108
|
+
subtitleOptions,
|
|
109
|
+
deps = {}
|
|
110
|
+
}) {
|
|
111
|
+
const ensureDirFn = deps.ensureDir || ensureDir;
|
|
112
|
+
const runCommandFn = deps.runCommand || runCommand;
|
|
113
|
+
const reconcileAlignedSrtFn = deps.reconcileAlignedSrt || reconcileAlignedSrt;
|
|
114
|
+
const writeSeedSrtFn = deps.writeSeedSrtFromScript || writeSeedSrtFromScript;
|
|
115
|
+
const writeAssFromSrtFn = deps.writeAssFromSrt || writeAssFromSrt;
|
|
116
|
+
|
|
117
|
+
if (!script || !String(script).trim()) {
|
|
118
|
+
throw new Error('Cannot align subtitles: script is empty');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const subtitlesDir = path.join(projectDir, 'assets', 'subtitles');
|
|
122
|
+
await ensureDirFn(subtitlesDir);
|
|
123
|
+
|
|
124
|
+
const seedSrtPath = path.join(subtitlesDir, 'seed.srt');
|
|
125
|
+
const alignedSrtPath = path.join(subtitlesDir, 'aligned.srt');
|
|
126
|
+
const alignedAssPath = path.join(subtitlesDir, 'aligned.ass');
|
|
127
|
+
|
|
128
|
+
await writeSeedSrtFn({
|
|
129
|
+
script,
|
|
130
|
+
totalDurationSec,
|
|
131
|
+
outputPath: seedSrtPath,
|
|
132
|
+
maxWordsPerLine: subtitleOptions?.maxWordsPerLine
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await runCommandFn(env.ffsubsyncBin, [
|
|
136
|
+
videoPath,
|
|
137
|
+
'-i',
|
|
138
|
+
seedSrtPath,
|
|
139
|
+
'-o',
|
|
140
|
+
alignedSrtPath
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
await reconcileAlignedSrtFn(seedSrtPath, alignedSrtPath, deps);
|
|
144
|
+
|
|
145
|
+
await writeAssFromSrtFn({
|
|
146
|
+
sourceSrtPath: alignedSrtPath,
|
|
147
|
+
outputAssPath: alignedAssPath,
|
|
148
|
+
subtitleOptions
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
subtitleSeedPath: seedSrtPath,
|
|
153
|
+
subtitleAlignedSrtPath: alignedSrtPath,
|
|
154
|
+
subtitleAssPath: alignedAssPath
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { burnInAssSubtitles } from '../media/ffmpeg.js';
|
|
3
|
+
|
|
4
|
+
export async function burnInSubtitles({
|
|
5
|
+
projectDir,
|
|
6
|
+
videoPath,
|
|
7
|
+
subtitleAssPath,
|
|
8
|
+
deps = {}
|
|
9
|
+
}) {
|
|
10
|
+
const burnInAssSubtitlesFn = deps.burnInAssSubtitles || burnInAssSubtitles;
|
|
11
|
+
|
|
12
|
+
if (!subtitleAssPath) {
|
|
13
|
+
throw new Error('Cannot burn subtitles: subtitle ASS path is missing');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const outputPath = path.join(projectDir, 'final_captioned.mp4');
|
|
17
|
+
await burnInAssSubtitlesFn(videoPath, subtitleAssPath, outputPath);
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
finalCaptionedVideoPath: outputPath
|
|
21
|
+
};
|
|
22
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { concatSegments, muxVoiceover } from '../media/ffmpeg.js';
|
|
3
|
+
import { downloadToFile, ensureDir } from '../media/files.js';
|
|
4
|
+
|
|
5
|
+
export async function composeFinalVideo({
|
|
6
|
+
projectDir,
|
|
7
|
+
segmentUrls,
|
|
8
|
+
segmentPaths = [],
|
|
9
|
+
voiceoverPath = '',
|
|
10
|
+
voiceoverUrl,
|
|
11
|
+
keyframePaths = [],
|
|
12
|
+
finalDurationMode = 'match_audio',
|
|
13
|
+
deps = {}
|
|
14
|
+
}) {
|
|
15
|
+
const ensureDirFn = deps.ensureDir || ensureDir;
|
|
16
|
+
const downloadToFileFn = deps.downloadToFile || downloadToFile;
|
|
17
|
+
const concatSegmentsFn = deps.concatSegments || concatSegments;
|
|
18
|
+
const muxVoiceoverFn = deps.muxVoiceover || muxVoiceover;
|
|
19
|
+
|
|
20
|
+
const assetsDir = path.join(projectDir, 'assets');
|
|
21
|
+
const segmentsDir = path.join(assetsDir, 'segments');
|
|
22
|
+
const audioDir = path.join(assetsDir, 'audio');
|
|
23
|
+
await ensureDirFn(segmentsDir);
|
|
24
|
+
await ensureDirFn(audioDir);
|
|
25
|
+
|
|
26
|
+
const localSegmentPaths = [];
|
|
27
|
+
if (segmentPaths.length === segmentUrls.length && segmentPaths.length > 0) {
|
|
28
|
+
localSegmentPaths.push(...segmentPaths);
|
|
29
|
+
} else {
|
|
30
|
+
for (let i = 0; i < segmentUrls.length; i += 1) {
|
|
31
|
+
const segmentPath = path.join(segmentsDir, `segment_${String(i + 1).padStart(2, '0')}.mp4`);
|
|
32
|
+
await downloadToFileFn(segmentUrls[i], segmentPath);
|
|
33
|
+
localSegmentPaths.push(segmentPath);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let voicePath = voiceoverPath;
|
|
38
|
+
if (!voicePath) {
|
|
39
|
+
voicePath = path.join(audioDir, 'voiceover.mp3');
|
|
40
|
+
await downloadToFileFn(voiceoverUrl, voicePath);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const concatPath = path.join(assetsDir, 'video_concat.mp4');
|
|
44
|
+
const finalPath = path.join(projectDir, 'final.mp4');
|
|
45
|
+
|
|
46
|
+
await concatSegmentsFn(localSegmentPaths, concatPath);
|
|
47
|
+
await muxVoiceoverFn(concatPath, voicePath, finalPath, {
|
|
48
|
+
trimToAudio: finalDurationMode !== 'match_visual'
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
finalVideoPath: finalPath,
|
|
53
|
+
keyframePaths,
|
|
54
|
+
segmentPaths: localSegmentPaths,
|
|
55
|
+
voiceoverPath: voicePath
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ASPECT_RATIO_PRESETS, MODEL_CATEGORIES, 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
|
+
import { resolveTextToImageAdapter } from './textToImageAdapters.js';
|
|
6
|
+
|
|
7
|
+
export async function generateKeyframe(
|
|
8
|
+
promptText,
|
|
9
|
+
tone,
|
|
10
|
+
aspectRatio = '9:16',
|
|
11
|
+
index = 0,
|
|
12
|
+
trace = null,
|
|
13
|
+
sizeOverride = null,
|
|
14
|
+
options = {}
|
|
15
|
+
) {
|
|
16
|
+
const deps = options.deps || {};
|
|
17
|
+
const runModelFn = deps.runModel || runModel;
|
|
18
|
+
const extractOutputUriFn = deps.extractOutputUri || extractOutputUri;
|
|
19
|
+
|
|
20
|
+
const preset = ASPECT_RATIO_PRESETS[aspectRatio] || ASPECT_RATIO_PRESETS['9:16'];
|
|
21
|
+
const width = sizeOverride?.width || preset.keyframeWidth || ASPECT_RATIO_PRESETS['9:16'].keyframeWidth;
|
|
22
|
+
const height = sizeOverride?.height || preset.keyframeHeight || ASPECT_RATIO_PRESETS['9:16'].keyframeHeight;
|
|
23
|
+
const modelId = options.modelId || resolveModelForCategory(MODEL_CATEGORIES.textToImage);
|
|
24
|
+
const modelOptions = options.modelOptions;
|
|
25
|
+
const adapter = resolveTextToImageAdapter(modelId);
|
|
26
|
+
|
|
27
|
+
const prediction = await runModelFn({
|
|
28
|
+
model: modelId,
|
|
29
|
+
input: adapter.buildInput({ promptText, tone, index, aspectRatio, width, height, modelOptions }),
|
|
30
|
+
trace: trace ? { ...trace, step: 'keyframe', index } : null
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const imageUrl = extractOutputUriFn(prediction.output);
|
|
34
|
+
if (!imageUrl) {
|
|
35
|
+
throw new Error(`Missing keyframe output for shot ${index + 1}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return imageUrl;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function generateKeyframes(shots, tone, aspectRatio = '9:16', trace = null, options = {}) {
|
|
42
|
+
const urls = [];
|
|
43
|
+
for (let i = 0; i < shots.length; i += 1) {
|
|
44
|
+
const imageUrl = await generateKeyframe(shots[i], tone, aspectRatio, i, trace, null, options);
|
|
45
|
+
urls.push(imageUrl);
|
|
46
|
+
}
|
|
47
|
+
return urls;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function persistKeyframe(projectDir, keyframeUrl, index, options = {}) {
|
|
51
|
+
const deps = options.deps || {};
|
|
52
|
+
const ensureDirFn = deps.ensureDir || ensureDir;
|
|
53
|
+
const downloadToFileFn = deps.downloadToFile || downloadToFile;
|
|
54
|
+
|
|
55
|
+
const keyframesDir = path.join(projectDir, 'assets', 'keyframes');
|
|
56
|
+
await ensureDirFn(keyframesDir);
|
|
57
|
+
const keyframePath = path.join(keyframesDir, `keyframe_${String(index + 1).padStart(2, '0')}.png`);
|
|
58
|
+
await downloadToFileFn(keyframeUrl, keyframePath);
|
|
59
|
+
return keyframePath;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function persistKeyframes(projectDir, keyframeUrls, options = {}) {
|
|
63
|
+
const keyframePaths = [];
|
|
64
|
+
for (let i = 0; i < keyframeUrls.length; i += 1) {
|
|
65
|
+
const keyframePath = await persistKeyframe(projectDir, keyframeUrls[i], i, options);
|
|
66
|
+
keyframePaths.push(keyframePath);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return keyframePaths;
|
|
70
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ASPECT_RATIO_PRESETS,
|
|
3
|
+
MODEL_CATEGORIES,
|
|
4
|
+
resolveModelForCategory
|
|
5
|
+
} from '../config/models.js';
|
|
6
|
+
import { runModel, extractOutputUri } from '../providers/predictions.js';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { downloadToFile, ensureDir } from '../media/files.js';
|
|
9
|
+
import { resolveImageToVideoAdapter } from './imageToVideoAdapters.js';
|
|
10
|
+
|
|
11
|
+
function asModelOptions(candidate) {
|
|
12
|
+
if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
return candidate;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function generateVideoSegmentAtIndex(
|
|
19
|
+
segmentIndex,
|
|
20
|
+
keyframeUrls,
|
|
21
|
+
timeline,
|
|
22
|
+
shots = [],
|
|
23
|
+
aspectRatio = '9:16',
|
|
24
|
+
trace = null,
|
|
25
|
+
options = {}
|
|
26
|
+
) {
|
|
27
|
+
const deps = options.deps || {};
|
|
28
|
+
const runModelFn = deps.runModel || runModel;
|
|
29
|
+
const extractOutputUriFn = deps.extractOutputUri || extractOutputUri;
|
|
30
|
+
|
|
31
|
+
const preset = ASPECT_RATIO_PRESETS[aspectRatio] || ASPECT_RATIO_PRESETS['9:16'];
|
|
32
|
+
const totalKeyframes = keyframeUrls.length;
|
|
33
|
+
const durationSec = timeline[segmentIndex]?.durationSec || 5;
|
|
34
|
+
const modelId = options.modelId || resolveModelForCategory(MODEL_CATEGORIES.imageTextToVideo);
|
|
35
|
+
const modelOptions = asModelOptions(options.modelOptions);
|
|
36
|
+
const adapter = resolveImageToVideoAdapter(modelId);
|
|
37
|
+
|
|
38
|
+
if (segmentIndex < 0 || segmentIndex >= totalKeyframes - 1) {
|
|
39
|
+
throw new Error(`segment index ${segmentIndex} out of range for ${Math.max(0, totalKeyframes - 1)} segments`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const prompt = shots[segmentIndex] || `Cinematic continuity shot ${segmentIndex + 1}`;
|
|
43
|
+
const prediction = await runModelFn({
|
|
44
|
+
model: modelId,
|
|
45
|
+
input: adapter.buildInput({
|
|
46
|
+
prompt,
|
|
47
|
+
startImage: keyframeUrls[segmentIndex],
|
|
48
|
+
endImage: keyframeUrls[segmentIndex + 1],
|
|
49
|
+
durationSec,
|
|
50
|
+
videoResolution: preset.videoResolution,
|
|
51
|
+
aspectRatio,
|
|
52
|
+
modelOptions
|
|
53
|
+
}),
|
|
54
|
+
trace: trace ? { ...trace, step: 'segment', index: segmentIndex } : null
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const url = extractOutputUriFn(prediction.output);
|
|
58
|
+
if (!url) {
|
|
59
|
+
throw new Error(`Missing segment output for segment ${segmentIndex + 1}`);
|
|
60
|
+
}
|
|
61
|
+
return url;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function generateVideoSegments(keyframeUrls, timeline, shots = [], aspectRatio = '9:16', trace = null, options = {}) {
|
|
65
|
+
const segmentUrls = [];
|
|
66
|
+
|
|
67
|
+
for (let i = 0; i < Math.max(0, keyframeUrls.length - 1); i += 1) {
|
|
68
|
+
const url = await generateVideoSegmentAtIndex(i, keyframeUrls, timeline, shots, aspectRatio, trace, options);
|
|
69
|
+
segmentUrls.push(url);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return segmentUrls;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function persistSegment(projectDir, segmentUrl, index, options = {}) {
|
|
76
|
+
const deps = options.deps || {};
|
|
77
|
+
const ensureDirFn = deps.ensureDir || ensureDir;
|
|
78
|
+
const downloadToFileFn = deps.downloadToFile || downloadToFile;
|
|
79
|
+
|
|
80
|
+
const segmentsDir = path.join(projectDir, 'assets', 'segments');
|
|
81
|
+
await ensureDirFn(segmentsDir);
|
|
82
|
+
const segmentPath = path.join(segmentsDir, `segment_${String(index + 1).padStart(2, '0')}.mp4`);
|
|
83
|
+
await downloadToFileFn(segmentUrl, segmentPath);
|
|
84
|
+
return segmentPath;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function persistSegments(projectDir, segmentUrls, options = {}) {
|
|
88
|
+
const segmentPaths = [];
|
|
89
|
+
for (let i = 0; i < segmentUrls.length; i += 1) {
|
|
90
|
+
const segmentPath = await persistSegment(projectDir, segmentUrls[i], i, options);
|
|
91
|
+
segmentPaths.push(segmentPath);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return segmentPaths;
|
|
95
|
+
}
|