@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.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +209 -0
  3. package/index.js +1 -0
  4. package/models/black-forest-labs__flux-2-pro.json +78 -0
  5. package/models/black-forest-labs__flux-schnell.json +95 -0
  6. package/models/bytedance__seedream-4.json +71 -0
  7. package/models/deepseek-ai__deepseek-v3.json +61 -0
  8. package/models/google__nano-banana-pro.json +92 -0
  9. package/models/google__veo-3.1-fast.json +93 -0
  10. package/models/google__veo-3.1.json +93 -0
  11. package/models/jaaari__kokoro-82m.json +86 -0
  12. package/models/kwaivgi__kling-v3-video.json +101 -0
  13. package/models/minimax__speech-02-turbo.json +141 -0
  14. package/models/pixverse__pixverse-v5.6.json +113 -0
  15. package/models/prunaai__z-image-turbo.json +107 -0
  16. package/models/resemble-ai__chatterbox-turbo.json +102 -0
  17. package/models/wan-video__wan-2.2-i2v-fast.json +139 -0
  18. package/package.json +67 -0
  19. package/src/api/firebaseFunction.js +46 -0
  20. package/src/api/middleware/auth.js +70 -0
  21. package/src/api/openapi/generateOpenApi.js +21 -0
  22. package/src/api/openapi/spec.js +831 -0
  23. package/src/api/routes/jobs.js +45 -0
  24. package/src/api/routes/projectAssets.js +63 -0
  25. package/src/api/routes/projects.js +647 -0
  26. package/src/api/routes/webhooks.js +13 -0
  27. package/src/api/server.js +88 -0
  28. package/src/backends/firebaseClient.js +57 -0
  29. package/src/backends/outputBackend.js +186 -0
  30. package/src/backends/projectMetadataBackend.js +550 -0
  31. package/src/cli/commands/openHome.js +70 -0
  32. package/src/cli/commands/settingsFlow.js +196 -0
  33. package/src/cli/index.js +192 -0
  34. package/src/config/env.js +158 -0
  35. package/src/config/keystore.js +175 -0
  36. package/src/config/models.js +281 -0
  37. package/src/config/settingsSchema.js +214 -0
  38. package/src/media/ffmpeg.js +144 -0
  39. package/src/media/files.js +77 -0
  40. package/src/media/subtitles.js +444 -0
  41. package/src/observability/apiTrace.js +17 -0
  42. package/src/observability/logger.js +7 -0
  43. package/src/observability/metrics.js +10 -0
  44. package/src/pipeline/inputSanitizer.js +6 -0
  45. package/src/pipeline/orchestrator.js +1669 -0
  46. package/src/policy/contentGuardrails.js +30 -0
  47. package/src/providers/predictions.js +188 -0
  48. package/src/providers/replicateClient.js +12 -0
  49. package/src/steps/alignSubtitles.js +156 -0
  50. package/src/steps/burnInSubtitles.js +22 -0
  51. package/src/steps/composeFinalVideo.js +57 -0
  52. package/src/steps/generateKeyframes.js +70 -0
  53. package/src/steps/generateVideoSegments.js +95 -0
  54. package/src/steps/generateVoiceover.js +128 -0
  55. package/src/steps/imageToVideoAdapters.js +100 -0
  56. package/src/steps/script.js +177 -0
  57. package/src/steps/textToImageAdapters.js +87 -0
  58. package/src/store/assetStore.js +5 -0
  59. package/src/store/jobStore.js +102 -0
  60. package/src/store/projectAnalyticsStore.js +625 -0
  61. package/src/store/projectStore.js +684 -0
  62. package/src/store/settingsStore.js +155 -0
  63. package/src/store/staleAssetStore.js +63 -0
  64. package/src/types/job.js +28 -0
  65. package/src/types/media.js +28 -0
  66. package/src/worker/processor.js +24 -0
@@ -0,0 +1,144 @@
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 './files.js';
6
+
7
+ function runFfmpeg(args) {
8
+ return new Promise((resolve, reject) => {
9
+ const proc = spawn(env.ffmpegBin, args, { stdio: 'inherit' });
10
+ proc.on('error', reject);
11
+ proc.on('exit', (code) => {
12
+ if (code === 0) resolve();
13
+ else reject(new Error(`ffmpeg exited with code ${code}`));
14
+ });
15
+ });
16
+ }
17
+
18
+ function runCapture(command, args) {
19
+ return new Promise((resolve, reject) => {
20
+ const proc = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
21
+ let stdout = '';
22
+ let stderr = '';
23
+
24
+ proc.stdout.on('data', (chunk) => {
25
+ stdout += chunk.toString();
26
+ });
27
+
28
+ proc.stderr.on('data', (chunk) => {
29
+ stderr += chunk.toString();
30
+ });
31
+
32
+ proc.on('error', reject);
33
+ proc.on('exit', (code) => {
34
+ if (code === 0) {
35
+ resolve(stdout.trim());
36
+ } else {
37
+ reject(new Error((stderr || `Command failed with exit code ${code}`).trim()));
38
+ }
39
+ });
40
+ });
41
+ }
42
+
43
+ export async function concatSegments(segmentPaths, outputPath, options = {}) {
44
+ const runFfmpegFn = options.runFfmpeg || runFfmpeg;
45
+ const ensureDirFn = options.ensureDir || ensureDir;
46
+ const writeFileFn = options.writeFile || fs.writeFile;
47
+
48
+ const listFilePath = path.join(path.dirname(outputPath), 'segments.txt');
49
+ const listContent = segmentPaths.map((segment) => `file '${path.resolve(segment)}'`).join('\n');
50
+ await ensureDirFn(path.dirname(outputPath));
51
+ await writeFileFn(listFilePath, listContent, 'utf8');
52
+
53
+ await runFfmpegFn([
54
+ '-y',
55
+ '-f',
56
+ 'concat',
57
+ '-safe',
58
+ '0',
59
+ '-i',
60
+ listFilePath,
61
+ '-c',
62
+ 'copy',
63
+ outputPath
64
+ ]);
65
+ }
66
+
67
+ export async function muxVoiceover(videoPath, audioPath, outputPath, options = {}) {
68
+ const trimToAudio = options.trimToAudio !== false;
69
+ const runFfmpegFn = options.runFfmpeg || runFfmpeg;
70
+ const args = [
71
+ '-y',
72
+ '-i',
73
+ videoPath,
74
+ '-i',
75
+ audioPath,
76
+ '-map',
77
+ '0:v:0',
78
+ '-map',
79
+ '1:a:0',
80
+ '-c:v',
81
+ 'copy',
82
+ '-c:a',
83
+ 'aac'
84
+ ];
85
+
86
+ if (trimToAudio) {
87
+ args.push('-shortest');
88
+ }
89
+
90
+ args.push(outputPath);
91
+ await runFfmpegFn(args);
92
+ }
93
+
94
+ function escapeFilterPath(filePath) {
95
+ return String(filePath || '')
96
+ .replace(/\\/g, '\\\\')
97
+ .replace(/:/g, '\\:')
98
+ .replace(/'/g, "\\'")
99
+ .replace(/,/g, '\\,');
100
+ }
101
+
102
+ export async function burnInAssSubtitles(videoPath, assPath, outputPath, options = {}) {
103
+ const runFfmpegFn = options.runFfmpeg || runFfmpeg;
104
+ const crf = Number.isFinite(options.crf) ? String(options.crf) : '18';
105
+ const preset = options.preset || 'slow';
106
+ const escapedAssPath = escapeFilterPath(path.resolve(assPath));
107
+
108
+ await runFfmpegFn([
109
+ '-y',
110
+ '-i',
111
+ videoPath,
112
+ '-vf',
113
+ `ass='${escapedAssPath}'`,
114
+ '-c:v',
115
+ 'libx264',
116
+ '-preset',
117
+ preset,
118
+ '-crf',
119
+ crf,
120
+ '-c:a',
121
+ 'copy',
122
+ outputPath
123
+ ]);
124
+ }
125
+
126
+ export async function probeMediaDurationSeconds(mediaPath, options = {}) {
127
+ const runCaptureFn = options.runCapture || runCapture;
128
+ const output = await runCaptureFn(env.ffprobeBin, [
129
+ '-v',
130
+ 'error',
131
+ '-show_entries',
132
+ 'format=duration',
133
+ '-of',
134
+ 'default=noprint_wrappers=1:nokey=1',
135
+ mediaPath
136
+ ]);
137
+
138
+ const parsed = Number.parseFloat(output);
139
+ if (!Number.isFinite(parsed) || parsed <= 0) {
140
+ throw new Error(`Unable to determine media duration for ${mediaPath}`);
141
+ }
142
+
143
+ return parsed;
144
+ }
@@ -0,0 +1,77 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { env } from '../config/env.js';
4
+
5
+ function isAllowedHost(hostname) {
6
+ const normalizedHost = String(hostname || '').toLowerCase();
7
+ return env.downloadAllowedHosts.some((allowedHost) => {
8
+ return normalizedHost === allowedHost || normalizedHost.endsWith(`.${allowedHost}`);
9
+ });
10
+ }
11
+
12
+ function parseDownloadUrl(url) {
13
+ let parsedUrl;
14
+ try {
15
+ parsedUrl = new URL(url);
16
+ } catch {
17
+ throw new Error('Invalid download URL');
18
+ }
19
+
20
+ if (parsedUrl.protocol !== 'https:') {
21
+ throw new Error('Download URL must use https');
22
+ }
23
+
24
+ if (!isAllowedHost(parsedUrl.hostname)) {
25
+ throw new Error(`Download host is not allowed: ${parsedUrl.hostname}`);
26
+ }
27
+
28
+ return parsedUrl;
29
+ }
30
+
31
+ export async function ensureDir(dirPath) {
32
+ await fs.mkdir(dirPath, { recursive: true });
33
+ }
34
+
35
+ export async function writeJson(filePath, data) {
36
+ await ensureDir(path.dirname(filePath));
37
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
38
+ }
39
+
40
+ export async function downloadToFile(url, outputPath) {
41
+ const parsedUrl = parseDownloadUrl(url);
42
+ const response = await fetch(parsedUrl, {
43
+ signal: AbortSignal.timeout(env.downloadTimeoutMs)
44
+ });
45
+
46
+ if (!response.ok) {
47
+ throw new Error(`Failed to download ${parsedUrl}: ${response.status}`);
48
+ }
49
+
50
+ const contentLength = Number(response.headers.get('content-length') || 0);
51
+ if (Number.isFinite(contentLength) && contentLength > env.downloadMaxBytes) {
52
+ throw new Error(`Download too large (${contentLength} bytes > ${env.downloadMaxBytes} bytes)`);
53
+ }
54
+
55
+ const reader = response.body?.getReader();
56
+ if (!reader) {
57
+ throw new Error('Download response body is not readable');
58
+ }
59
+
60
+ const chunks = [];
61
+ let totalBytes = 0;
62
+ while (true) {
63
+ const { done, value } = await reader.read();
64
+ if (done) break;
65
+
66
+ const chunk = Buffer.from(value);
67
+ totalBytes += chunk.length;
68
+ if (totalBytes > env.downloadMaxBytes) {
69
+ throw new Error(`Download exceeded max size (${env.downloadMaxBytes} bytes)`);
70
+ }
71
+ chunks.push(chunk);
72
+ }
73
+
74
+ await ensureDir(path.dirname(outputPath));
75
+ await fs.writeFile(outputPath, Buffer.concat(chunks));
76
+ return outputPath;
77
+ }
@@ -0,0 +1,444 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { ensureDir } from './files.js';
4
+
5
+ const ASS_ALIGNMENT_BY_POSITION = {
6
+ top: 8,
7
+ center: 5,
8
+ bottom: 2
9
+ };
10
+
11
+ const SUBTITLE_HIGHLIGHT_MODE = {
12
+ SPOKEN_UPCOMING: 'spoken_upcoming',
13
+ CURRENT_ONLY: 'current_only'
14
+ };
15
+
16
+ function toMilliseconds(totalSeconds) {
17
+ return Math.max(0, Math.round(Number(totalSeconds || 0) * 1000));
18
+ }
19
+
20
+ function pad(value, size) {
21
+ return String(value).padStart(size, '0');
22
+ }
23
+
24
+ function formatSrtTimestamp(ms) {
25
+ const total = Math.max(0, Math.round(ms));
26
+ const hours = Math.floor(total / 3600000);
27
+ const minutes = Math.floor((total % 3600000) / 60000);
28
+ const seconds = Math.floor((total % 60000) / 1000);
29
+ const millis = total % 1000;
30
+ return `${pad(hours, 2)}:${pad(minutes, 2)}:${pad(seconds, 2)},${pad(millis, 3)}`;
31
+ }
32
+
33
+ function formatAssTimestamp(ms) {
34
+ const total = Math.max(0, Math.round(ms));
35
+ const hours = Math.floor(total / 3600000);
36
+ const minutes = Math.floor((total % 3600000) / 60000);
37
+ const seconds = Math.floor((total % 60000) / 1000);
38
+ const centiseconds = Math.floor((total % 1000) / 10);
39
+ return `${hours}:${pad(minutes, 2)}:${pad(seconds, 2)}.${pad(centiseconds, 2)}`;
40
+ }
41
+
42
+ function normalizeWhitespace(text) {
43
+ return String(text || '').replace(/\s+/g, ' ').trim();
44
+ }
45
+
46
+ function stripTrailingDot(text) {
47
+ return normalizeWhitespace(text).replace(/\.$/, '');
48
+ }
49
+
50
+ function splitIntoSentences(text) {
51
+ const normalized = normalizeWhitespace(text);
52
+ if (!normalized) {
53
+ return [];
54
+ }
55
+
56
+ const sentenceMatches = normalized.match(/[^.!?]+[.!?]+(?:['")\]]+)?|[^.!?]+$/g) || [];
57
+ return sentenceMatches
58
+ .map((sentence) => normalizeWhitespace(sentence))
59
+ .filter(Boolean);
60
+ }
61
+
62
+ function splitScriptIntoLines(script, maxWordsPerLine = 7) {
63
+ const safeMaxWords = Number.isInteger(maxWordsPerLine) && maxWordsPerLine > 0 ? maxWordsPerLine : 7;
64
+ const sentences = splitIntoSentences(script);
65
+ const chunks = [];
66
+
67
+ for (const sentence of sentences) {
68
+ const words = sentence.split(' ').filter(Boolean);
69
+ if (words.length <= safeMaxWords) {
70
+ chunks.push(sentence);
71
+ continue;
72
+ }
73
+
74
+ for (let index = 0; index < words.length; index += safeMaxWords) {
75
+ chunks.push(words.slice(index, index + safeMaxWords).join(' '));
76
+ }
77
+ }
78
+
79
+ return chunks;
80
+ }
81
+
82
+ function estimateLineDurations(lines, totalDurationSec) {
83
+ const totalDurationMs = Math.max(1000, toMilliseconds(totalDurationSec));
84
+ if (!lines.length) {
85
+ return [];
86
+ }
87
+
88
+ const weights = lines.map((line) => Math.max(1, normalizeWhitespace(line).length));
89
+ const weightTotal = weights.reduce((sum, value) => sum + value, 0);
90
+
91
+ let cursor = 0;
92
+ return lines.map((line, idx) => {
93
+ const isLast = idx === lines.length - 1;
94
+ const durationMs = isLast
95
+ ? Math.max(300, totalDurationMs - cursor)
96
+ : Math.max(300, Math.round((weights[idx] / weightTotal) * totalDurationMs));
97
+ const startMs = cursor;
98
+ const endMs = Math.min(totalDurationMs, cursor + durationMs);
99
+ cursor = endMs;
100
+ return {
101
+ index: idx + 1,
102
+ startMs,
103
+ endMs,
104
+ text: stripTrailingDot(line)
105
+ };
106
+ });
107
+ }
108
+
109
+ export async function writeSeedSrtFromScript({
110
+ script,
111
+ totalDurationSec,
112
+ outputPath,
113
+ maxWordsPerLine = 7,
114
+ deps = {}
115
+ }) {
116
+ const ensureDirFn = deps.ensureDir || ensureDir;
117
+ const writeFileFn = deps.writeFile || fs.writeFile;
118
+
119
+ const lines = splitScriptIntoLines(script, maxWordsPerLine);
120
+ const cues = estimateLineDurations(lines, totalDurationSec);
121
+
122
+ const payload = cues.map((cue) => {
123
+ return `${cue.index}\n${formatSrtTimestamp(cue.startMs)} --> ${formatSrtTimestamp(cue.endMs)}\n${cue.text}`;
124
+ }).join('\n\n');
125
+
126
+ await ensureDirFn(path.dirname(outputPath));
127
+ await writeFileFn(outputPath, `${payload}\n`, 'utf8');
128
+
129
+ return {
130
+ cueCount: cues.length,
131
+ outputPath
132
+ };
133
+ }
134
+
135
+ export function parseSrtCues(rawSrt) {
136
+ const normalized = String(rawSrt || '').replace(/\r/g, '');
137
+ const blocks = normalized.split(/\n\n+/).map((block) => block.trim()).filter(Boolean);
138
+ const cues = [];
139
+
140
+ for (const block of blocks) {
141
+ const lines = block.split('\n');
142
+ if (lines.length < 2) {
143
+ continue;
144
+ }
145
+
146
+ const timelineLine = lines.find((line) => line.includes('-->'));
147
+ if (!timelineLine) {
148
+ continue;
149
+ }
150
+
151
+ const [startRaw, endRaw] = timelineLine.split('-->').map((part) => part.trim());
152
+ const textLines = lines.slice(lines.indexOf(timelineLine) + 1);
153
+ const text = normalizeWhitespace(textLines.join(' '));
154
+
155
+ const startMs = parseSrtTimestamp(startRaw);
156
+ const endMs = parseSrtTimestamp(endRaw);
157
+ if (startMs === null || endMs === null || endMs <= startMs || !text) {
158
+ continue;
159
+ }
160
+
161
+ cues.push({ startMs, endMs, text });
162
+ }
163
+
164
+ return cues;
165
+ }
166
+
167
+ function parseSrtTimestamp(value) {
168
+ const match = String(value || '').match(/^(\d{2}):(\d{2}):(\d{2})[,.](\d{3})$/);
169
+ if (!match) {
170
+ return null;
171
+ }
172
+
173
+ const [, hh, mm, ss, ms] = match;
174
+ return (
175
+ Number(hh) * 3600000
176
+ + Number(mm) * 60000
177
+ + Number(ss) * 1000
178
+ + Number(ms)
179
+ );
180
+ }
181
+
182
+ function escapeAssText(text) {
183
+ return String(text || '').replace(/[{}]/g, '').replace(/\n/g, '\\N');
184
+ }
185
+
186
+ function applyTextCase(text, subtitleOptions = {}) {
187
+ const normalized = String(text || '');
188
+ if (subtitleOptions.makeUppercase === true) {
189
+ return normalized.toUpperCase();
190
+ }
191
+ return normalized;
192
+ }
193
+
194
+ function hexToAssBgr(hexColor) {
195
+ return hexToAssAbgr(hexColor, 0);
196
+ }
197
+
198
+ function hexToAssAbgr(hexColor, alpha = 0) {
199
+ const normalized = String(hexColor || '').trim().replace(/^#/, '');
200
+ const safeAlpha = Number.isFinite(alpha) ? Math.max(0, Math.min(255, Math.round(alpha))) : 0;
201
+ if (!/^[0-9a-fA-F]{6}$/.test(normalized)) {
202
+ return '&H00FFFFFF';
203
+ }
204
+
205
+ const r = normalized.slice(0, 2);
206
+ const g = normalized.slice(2, 4);
207
+ const b = normalized.slice(4, 6);
208
+ return `&H${safeAlpha.toString(16).padStart(2, '0')}${b}${g}${r}`.toUpperCase();
209
+ }
210
+
211
+
212
+ function splitWordsIntoDisplayLines(words, maxLines = 2) {
213
+ if (!Array.isArray(words) || words.length === 0) {
214
+ return [];
215
+ }
216
+
217
+ const safeMaxLines = Number.isInteger(maxLines) && maxLines > 0 ? maxLines : 2;
218
+ const lineCount = Math.max(1, Math.min(safeMaxLines, words.length));
219
+ const baseSize = Math.floor(words.length / lineCount);
220
+ let remainder = words.length % lineCount;
221
+
222
+ const lines = [];
223
+ let cursor = 0;
224
+ for (let lineIndex = 0; lineIndex < lineCount; lineIndex += 1) {
225
+ const currentSize = baseSize + (remainder > 0 ? 1 : 0);
226
+ remainder = Math.max(0, remainder - 1);
227
+ lines.push(words.slice(cursor, cursor + currentSize));
228
+ cursor += currentSize;
229
+ }
230
+
231
+ return lines.filter((line) => line.length > 0);
232
+ }
233
+
234
+ function buildKaraokeText(text, cueDurationMs, subtitleOptions = {}) {
235
+ const words = applyTextCase(text, subtitleOptions).split(/\s+/).filter(Boolean);
236
+ if (!words.length) {
237
+ return '';
238
+ }
239
+
240
+ const highlightMode = subtitleOptions.highlightMode || SUBTITLE_HIGHLIGHT_MODE.SPOKEN_UPCOMING;
241
+ const baseColor = hexToAssBgr(subtitleOptions.primaryColor);
242
+ const activeColor = hexToAssBgr(subtitleOptions.activeColor);
243
+
244
+ const weights = words.map((word) => Math.max(1, word.replace(/[^\p{L}\p{N}]/gu, '').length || word.length));
245
+ const totalWeight = weights.reduce((sum, value) => sum + value, 0);
246
+ let allocatedCentis = 0;
247
+ const totalCentis = Math.max(1, Math.round(cueDurationMs / 10));
248
+
249
+ const timedWords = words.map((word, index) => {
250
+ const isLast = index === words.length - 1;
251
+ const portion = isLast
252
+ ? Math.max(1, totalCentis - allocatedCentis)
253
+ : Math.max(1, Math.round((weights[index] / totalWeight) * totalCentis));
254
+ allocatedCentis += portion;
255
+
256
+ if (highlightMode === SUBTITLE_HIGHLIGHT_MODE.CURRENT_ONLY) {
257
+ return `{\\2c${activeColor}\\k${portion}}${escapeAssText(word)}{\\2c${baseColor}}`;
258
+ }
259
+
260
+ return `{\\k${portion}}${escapeAssText(word)}`;
261
+ });
262
+
263
+ const lines = splitWordsIntoDisplayLines(timedWords, subtitleOptions.maxLines);
264
+ return lines.map((lineWords) => lineWords.join(' ')).join('\\N');
265
+ }
266
+
267
+ function calculateWordTimingPortions(words, cueDurationMs) {
268
+ const weights = words.map((word) => Math.max(1, word.replace(/[^\p{L}\p{N}]/gu, '').length || word.length));
269
+ const totalWeight = weights.reduce((sum, value) => sum + value, 0);
270
+ let allocatedCentis = 0;
271
+ const totalCentis = Math.max(1, Math.round(cueDurationMs / 10));
272
+
273
+ return words.map((_, index) => {
274
+ const isLast = index === words.length - 1;
275
+ const portion = isLast
276
+ ? Math.max(1, totalCentis - allocatedCentis)
277
+ : Math.max(1, Math.round((weights[index] / totalWeight) * totalCentis));
278
+ allocatedCentis += portion;
279
+ return portion;
280
+ });
281
+ }
282
+
283
+ function buildCurrentWordOnlyEvents(text, cueDurationMs, subtitleOptions = {}) {
284
+ const words = applyTextCase(text, subtitleOptions).split(/\s+/).filter(Boolean);
285
+ if (!words.length) {
286
+ return [];
287
+ }
288
+
289
+ const portions = calculateWordTimingPortions(words, cueDurationMs);
290
+ const baseColor = hexToAssBgr(subtitleOptions.primaryColor);
291
+ const activeColor = hexToAssBgr(subtitleOptions.activeColor);
292
+ const lines = splitWordsIntoDisplayLines(words, subtitleOptions.maxLines);
293
+
294
+ let globalWordIndex = 0;
295
+ const lineWordIndexes = lines.map((line) => {
296
+ const indexes = line.map(() => {
297
+ const current = globalWordIndex;
298
+ globalWordIndex += 1;
299
+ return current;
300
+ });
301
+ return indexes;
302
+ });
303
+
304
+ const events = [];
305
+ let cursorMs = 0;
306
+
307
+ for (let wordIndex = 0; wordIndex < words.length; wordIndex += 1) {
308
+ const durationMs = Math.max(10, portions[wordIndex] * 10);
309
+ const startMs = cursorMs;
310
+ const endMs = startMs + durationMs;
311
+ cursorMs = endMs;
312
+
313
+ const linesText = lines.map((lineWords, lineIndex) => {
314
+ const wordIndexes = lineWordIndexes[lineIndex];
315
+ return lineWords.map((word, inLineIndex) => {
316
+ const thisWordIndex = wordIndexes[inLineIndex];
317
+ const escaped = escapeAssText(word);
318
+ if (thisWordIndex === wordIndex) {
319
+ return `{\\1c${activeColor}}${escaped}{\\1c${baseColor}}`;
320
+ }
321
+ return escaped;
322
+ }).join(' ');
323
+ });
324
+
325
+ events.push({
326
+ startOffsetMs: startMs,
327
+ endOffsetMs: endMs,
328
+ text: linesText.join('\\N')
329
+ });
330
+ }
331
+
332
+ return events;
333
+ }
334
+
335
+ function toAssStyle(subtitleOptions = {}) {
336
+ const position = subtitleOptions.position || 'center';
337
+ const highlightMode = subtitleOptions.highlightMode || SUBTITLE_HIGHLIGHT_MODE.SPOKEN_UPCOMING;
338
+ const alignment = ASS_ALIGNMENT_BY_POSITION[position] || ASS_ALIGNMENT_BY_POSITION.bottom;
339
+ const primaryColor = hexToAssBgr(subtitleOptions.primaryColor);
340
+ const activeColor = hexToAssBgr(subtitleOptions.activeColor);
341
+ const secondaryColor = highlightMode === SUBTITLE_HIGHLIGHT_MODE.CURRENT_ONLY ? primaryColor : activeColor;
342
+ const backgroundEnabled = subtitleOptions.backgroundEnabled === true;
343
+ const opacity = typeof subtitleOptions.backgroundOpacity === 'number' && Number.isFinite(subtitleOptions.backgroundOpacity)
344
+ ? Math.max(0, Math.min(0.85, subtitleOptions.backgroundOpacity))
345
+ : 0.45;
346
+ const backgroundAlpha = Math.round((1 - opacity) * 255);
347
+ const backgroundAssColor = hexToAssAbgr(subtitleOptions.backgroundColor, backgroundAlpha);
348
+ const requestedOutline = Number.isInteger(subtitleOptions.outline) ? subtitleOptions.outline : 3;
349
+ // BorderStyle=3 uses Outline as box padding; multi-line events can stack boxes and darken overlaps.
350
+ const effectiveOutline = backgroundEnabled && (subtitleOptions.maxLines || 2) > 1
351
+ ? 1
352
+ : requestedOutline;
353
+ const outlineColor = backgroundEnabled
354
+ ? backgroundAssColor
355
+ : hexToAssBgr(subtitleOptions.outlineColor);
356
+ const backColor = backgroundEnabled
357
+ ? backgroundAssColor
358
+ : hexToAssAbgr(subtitleOptions.backgroundColor, backgroundAlpha);
359
+
360
+ return {
361
+ name: 'Karaoke',
362
+ fontName: subtitleOptions.fontName || 'Poppins',
363
+ fontSize: Number.isInteger(subtitleOptions.fontSize) ? subtitleOptions.fontSize : 58,
364
+ primaryColor,
365
+ secondaryColor,
366
+ outlineColor,
367
+ backColor,
368
+ marginV: Number.isInteger(subtitleOptions.marginV) ? subtitleOptions.marginV : 70,
369
+ outline: effectiveOutline,
370
+ shadow: Number.isInteger(subtitleOptions.shadow) ? subtitleOptions.shadow : 0,
371
+ alignment,
372
+ bold: subtitleOptions.bold !== false,
373
+ italic: subtitleOptions.italic === true,
374
+ borderStyle: backgroundEnabled ? 3 : 1
375
+ };
376
+ }
377
+
378
+ export function renderAssFromCues(cues, subtitleOptions = {}) {
379
+ const style = toAssStyle(subtitleOptions);
380
+ const highlightMode = subtitleOptions.highlightMode || SUBTITLE_HIGHLIGHT_MODE.SPOKEN_UPCOMING;
381
+ const assHeader = [
382
+ '[Script Info]',
383
+ 'ScriptType: v4.00+',
384
+ 'Collisions: Normal',
385
+ 'PlayResX: 1080',
386
+ 'PlayResY: 1920',
387
+ 'WrapStyle: 2',
388
+ 'ScaledBorderAndShadow: yes',
389
+ '',
390
+ '[V4+ Styles]',
391
+ 'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding',
392
+ `Style: ${style.name},${style.fontName},${style.fontSize},${style.primaryColor},${style.secondaryColor},${style.outlineColor},${style.backColor},${style.bold ? 1 : 0},${style.italic ? 1 : 0},0,0,100,100,0,0,${style.borderStyle},${style.outline},${style.shadow},${style.alignment},60,60,${style.marginV},1`,
393
+ '',
394
+ '[Events]',
395
+ 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text'
396
+ ];
397
+
398
+ const assEvents = [];
399
+
400
+ for (const cue of cues) {
401
+ if (highlightMode === SUBTITLE_HIGHLIGHT_MODE.CURRENT_ONLY) {
402
+ const timedEvents = buildCurrentWordOnlyEvents(cue.text, cue.endMs - cue.startMs, subtitleOptions);
403
+ for (const event of timedEvents) {
404
+ assEvents.push(
405
+ `Dialogue: 0,${formatAssTimestamp(cue.startMs + event.startOffsetMs)},${formatAssTimestamp(cue.startMs + event.endOffsetMs)},${style.name},,0,0,0,,${event.text}`
406
+ );
407
+ }
408
+ continue;
409
+ }
410
+
411
+ const karaokeText = buildKaraokeText(cue.text, cue.endMs - cue.startMs, subtitleOptions);
412
+ assEvents.push(
413
+ `Dialogue: 0,${formatAssTimestamp(cue.startMs)},${formatAssTimestamp(cue.endMs)},${style.name},,0,0,0,,${karaokeText}`
414
+ );
415
+ }
416
+
417
+ return `${assHeader.join('\n')}\n${assEvents.join('\n')}\n`;
418
+ }
419
+
420
+ export async function writeAssFromSrt({
421
+ sourceSrtPath,
422
+ outputAssPath,
423
+ subtitleOptions,
424
+ deps = {}
425
+ }) {
426
+ const readFileFn = deps.readFile || fs.readFile;
427
+ const writeFileFn = deps.writeFile || fs.writeFile;
428
+ const ensureDirFn = deps.ensureDir || ensureDir;
429
+
430
+ const rawSrt = await readFileFn(sourceSrtPath, 'utf8');
431
+ const cues = parseSrtCues(rawSrt);
432
+ if (!cues.length) {
433
+ throw new Error('Unable to generate ASS: aligned subtitle file has no cues');
434
+ }
435
+
436
+ const payload = renderAssFromCues(cues, subtitleOptions);
437
+ await ensureDirFn(path.dirname(outputAssPath));
438
+ await writeFileFn(outputAssPath, payload, 'utf8');
439
+
440
+ return {
441
+ cueCount: cues.length,
442
+ outputPath: outputAssPath
443
+ };
444
+ }
@@ -0,0 +1,17 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { ensureDir } from '../media/files.js';
4
+
5
+ function getTraceFilePath(projectDir) {
6
+ return path.join(projectDir, 'assets', 'debug', 'api-requests.jsonl');
7
+ }
8
+
9
+ export async function appendApiTrace(projectDir, record) {
10
+ if (!projectDir) {
11
+ return;
12
+ }
13
+
14
+ const filePath = getTraceFilePath(projectDir);
15
+ await ensureDir(path.dirname(filePath));
16
+ await fs.appendFile(filePath, `${JSON.stringify(record)}\n`, 'utf8');
17
+ }
@@ -0,0 +1,7 @@
1
+ export function logInfo(message, data = {}) {
2
+ console.log(JSON.stringify({ level: 'info', message, ...data, ts: new Date().toISOString() }));
3
+ }
4
+
5
+ export function logError(message, data = {}) {
6
+ console.error(JSON.stringify({ level: 'error', message, ...data, ts: new Date().toISOString() }));
7
+ }
@@ -0,0 +1,10 @@
1
+ const counters = new Map();
2
+
3
+ export function incrementMetric(name) {
4
+ const value = counters.get(name) || 0;
5
+ counters.set(name, value + 1);
6
+ }
7
+
8
+ export function readMetrics() {
9
+ return Object.fromEntries(counters.entries());
10
+ }