@x12i/youtube-video-uploader-cli 1.1.0 → 1.3.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.
@@ -0,0 +1,567 @@
1
+ import ffmpeg from 'fluent-ffmpeg';
2
+ import ffmpegStatic from 'ffmpeg-static';
3
+ import ffprobeStatic from 'ffprobe-static';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import os from 'node:os';
7
+ import { Resvg } from '@resvg/resvg-js';
8
+ import { getVideoMetadata } from './shorten.js';
9
+
10
+ if (ffmpegStatic && fs.existsSync(ffmpegStatic)) {
11
+ ffmpeg.setFfmpegPath(ffmpegStatic);
12
+ }
13
+ if (ffprobeStatic?.path && fs.existsSync(ffprobeStatic.path)) {
14
+ ffmpeg.setFfprobePath(ffprobeStatic.path);
15
+ }
16
+
17
+ /**
18
+ * Escape XML special characters for safe SVG embedding.
19
+ *
20
+ * @param {string} text
21
+ * @returns {string}
22
+ */
23
+ export function escapeXml(text) {
24
+ if (!text) return '';
25
+ return String(text)
26
+ .replace(/&/g, '&')
27
+ .replace(/</g, '&lt;')
28
+ .replace(/>/g, '&gt;')
29
+ .replace(/"/g, '&quot;')
30
+ .replace(/'/g, '&apos;');
31
+ }
32
+
33
+ /**
34
+ * Truncate a string and add an ellipsis if it exceeds maxLength.
35
+ *
36
+ * @param {string} str
37
+ * @param {number} maxLength
38
+ * @returns {string}
39
+ */
40
+ export function truncate(str, maxLength) {
41
+ if (!str || str.length <= maxLength) return str;
42
+ return str.slice(0, Math.max(0, maxLength - 1)) + '…';
43
+ }
44
+
45
+ /**
46
+ * Build tracks list for the sidebar from raw metadata / songs.
47
+ *
48
+ * @param {object} params
49
+ * @param {string} params.currentSongKey
50
+ * @param {object} [params.rawMetadata]
51
+ * @param {string} [params.mode='before-current']
52
+ * @param {number} [params.maxVisibleRows=8]
53
+ * @param {'title' | 'stage' | 'both'} [params.sidebarLabel='title']
54
+ * @returns {Array<{ index: number, title: string, isCurrent: boolean, isUpcoming: boolean }>}
55
+ */
56
+ export function resolveJourneyTracks({ currentSongKey, rawMetadata, mode = 'before-current', maxVisibleRows = 8, sidebarLabel = 'title' }) {
57
+ let allTracks = [];
58
+
59
+ const formatTrackLabel = (rawTitle, stage) => {
60
+ if (sidebarLabel === 'stage') {
61
+ return stage || rawTitle;
62
+ }
63
+ if (sidebarLabel === 'both') {
64
+ return stage ? `${stage}: ${rawTitle}` : rawTitle;
65
+ }
66
+ return rawTitle;
67
+ };
68
+
69
+ if (rawMetadata?.songs && typeof rawMetadata.songs === 'object' && !Array.isArray(rawMetadata.songs)) {
70
+ const orderedKeys = Array.isArray(rawMetadata.playlist?.orderedSongKeys)
71
+ ? rawMetadata.playlist.orderedSongKeys
72
+ : Object.keys(rawMetadata.songs);
73
+
74
+ allTracks = orderedKeys.map((key, idx) => {
75
+ const songData = rawMetadata.songs[key] || {};
76
+ const rawTitle = songData.youtube?.snippet?.title || path.basename(key, path.extname(key));
77
+ const stage = songData.journeyStage;
78
+ return {
79
+ key,
80
+ title: formatTrackLabel(rawTitle, stage),
81
+ journeyStage: stage,
82
+ trackNumber: songData.trackNumber ?? idx + 1,
83
+ };
84
+ });
85
+ } else if (Array.isArray(rawMetadata?.videos)) {
86
+ allTracks = rawMetadata.videos.map((v, idx) => {
87
+ const rawTitle = v.title || path.basename(v.filename, path.extname(v.filename));
88
+ const stage = v.journeyStage;
89
+ return {
90
+ key: v.filename,
91
+ title: formatTrackLabel(rawTitle, stage),
92
+ journeyStage: stage,
93
+ trackNumber: v.trackNumber ?? idx + 1,
94
+ };
95
+ });
96
+ }
97
+
98
+ const currentIndex = allTracks.findIndex(t => t.key === currentSongKey || path.basename(t.key) === path.basename(currentSongKey));
99
+
100
+ if (currentIndex === -1) {
101
+ // Fallback: show only the current track
102
+ const songData = rawMetadata?.songs?.[currentSongKey] || {};
103
+ const singleRawTitle = songData.youtube?.snippet?.title || (currentSongKey ? path.basename(currentSongKey, path.extname(currentSongKey)) : 'Current Track');
104
+ const stage = songData.journeyStage;
105
+ return [{
106
+ index: 1,
107
+ title: formatTrackLabel(singleRawTitle, stage),
108
+ isCurrent: true,
109
+ isUpcoming: false,
110
+ }];
111
+ }
112
+
113
+ let visibleList = [];
114
+ if (mode === 'all') {
115
+ visibleList = allTracks.map((t, i) => ({
116
+ index: i + 1,
117
+ title: t.title,
118
+ isCurrent: i === currentIndex,
119
+ isUpcoming: i > currentIndex,
120
+ }));
121
+ } else {
122
+ // 'before-current'
123
+ visibleList = allTracks.slice(0, currentIndex + 1).map((t, i) => ({
124
+ index: i + 1,
125
+ title: t.title,
126
+ isCurrent: i === currentIndex,
127
+ isUpcoming: false,
128
+ }));
129
+ }
130
+
131
+ if (visibleList.length > maxVisibleRows) {
132
+ // Cap visible rows and add +N more
133
+ const overflowCount = visibleList.length - maxVisibleRows + 1;
134
+ const capped = visibleList.slice(0, maxVisibleRows - 1);
135
+ capped.push({
136
+ index: maxVisibleRows,
137
+ title: `+${overflowCount} more`,
138
+ isCurrent: false,
139
+ isUpcoming: false,
140
+ isOverflow: true,
141
+ });
142
+ return capped;
143
+ }
144
+
145
+ return visibleList;
146
+ }
147
+
148
+ /**
149
+ * Generate SVG string for the journey overlay.
150
+ *
151
+ * @param {object} params
152
+ * @param {number} [params.width=1920]
153
+ * @param {number} [params.height=1080]
154
+ * @param {Array<object>} [params.tracks=[]]
155
+ * @param {string} [params.centerText]
156
+ * @param {boolean} [params.showSidebar=true]
157
+ * @param {'dark' | 'light'} [params.theme='dark']
158
+ * @param {'left'} [params.position='left']
159
+ * @returns {string} SVG xml content
160
+ */
161
+ export function generateJourneySvg(params) {
162
+ const {
163
+ width = 1920,
164
+ height = 1080,
165
+ tracks = [],
166
+ centerText = '',
167
+ showSidebar = true,
168
+ theme = 'dark',
169
+ position = 'left',
170
+ } = params;
171
+
172
+ const isDark = theme === 'dark';
173
+ const sidebarBg = isDark ? 'rgba(15, 23, 42, 0.85)' : 'rgba(255, 255, 255, 0.90)';
174
+ const sidebarBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.10)';
175
+ const textPrimary = isDark ? '#f8fafc' : '#0f172a';
176
+ const textSecondary = isDark ? '#94a3b8' : '#64748b';
177
+ const textHighlight = isDark ? '#38bdf8' : '#0284c7';
178
+ const highlightBadgeBg = isDark ? 'rgba(56, 189, 248, 0.18)' : 'rgba(2, 132, 199, 0.12)';
179
+ const highlightBadgeBorder = isDark ? 'rgba(56, 189, 248, 0.40)' : 'rgba(2, 132, 199, 0.30)';
180
+
181
+ let sidebarSvg = '';
182
+ if (showSidebar && tracks.length > 0) {
183
+ const panelWidth = 440;
184
+ const rowHeight = 44;
185
+ const panelPadding = 24;
186
+ const panelHeight = panelPadding * 2 + tracks.length * rowHeight;
187
+ const panelX = position === 'left' ? 60 : width - panelWidth - 60;
188
+ const panelY = 60;
189
+
190
+ let rowsSvg = '';
191
+ tracks.forEach((track, idx) => {
192
+ const rowY = panelY + panelPadding + idx * rowHeight;
193
+ const truncatedTitle = truncate(track.title, 34);
194
+
195
+ if (track.isCurrent) {
196
+ rowsSvg += `
197
+ <rect x="${panelX + 12}" y="${rowY}" width="${panelWidth - 24}" height="${rowHeight - 6}" rx="8" fill="${highlightBadgeBg}" stroke="${highlightBadgeBorder}" stroke-width="1.5" />
198
+ <circle cx="${panelX + 32}" cy="${rowY + (rowHeight - 6) / 2}" r="5" fill="${textHighlight}" />
199
+ <text x="${panelX + 48}" y="${rowY + (rowHeight - 6) / 2 + 5}" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="16" font-weight="700" fill="${textHighlight}">${escapeXml(truncatedTitle)}</text>
200
+ `;
201
+ } else if (track.isUpcoming) {
202
+ rowsSvg += `
203
+ <circle cx="${panelX + 32}" cy="${rowY + (rowHeight - 6) / 2}" r="3" fill="${textSecondary}" opacity="0.5" />
204
+ <text x="${panelX + 48}" y="${rowY + (rowHeight - 6) / 2 + 5}" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="15" font-weight="400" fill="${textSecondary}" opacity="0.6">${escapeXml(truncatedTitle)}</text>
205
+ `;
206
+ } else if (track.isOverflow) {
207
+ rowsSvg += `
208
+ <text x="${panelX + 48}" y="${rowY + (rowHeight - 6) / 2 + 5}" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="14" font-weight="500" fill="${textSecondary}" font-style="italic">${escapeXml(truncatedTitle)}</text>
209
+ `;
210
+ } else {
211
+ // Past tracks
212
+ rowsSvg += `
213
+ <circle cx="${panelX + 32}" cy="${rowY + (rowHeight - 6) / 2}" r="3.5" fill="${textSecondary}" />
214
+ <text x="${panelX + 48}" y="${rowY + (rowHeight - 6) / 2 + 5}" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="15" font-weight="500" fill="${textPrimary}">${escapeXml(truncatedTitle)}</text>
215
+ `;
216
+ }
217
+ });
218
+
219
+ sidebarSvg = `
220
+ <g id="sidebar">
221
+ <rect x="${panelX}" y="${panelY}" width="${panelWidth}" height="${panelHeight}" rx="16" fill="${sidebarBg}" stroke="${sidebarBorder}" stroke-width="1.5" />
222
+ ${rowsSvg}
223
+ </g>
224
+ `;
225
+ }
226
+
227
+ let centerTextSvg = '';
228
+ if (centerText && centerText.trim()) {
229
+ const textStr = escapeXml(centerText.trim());
230
+ const approxCharWidth = 14;
231
+ const boxWidth = Math.min(width - 200, Math.max(320, textStr.length * approxCharWidth + 60));
232
+ const boxHeight = 64;
233
+ const boxX = (width - boxWidth) / 2;
234
+ const boxY = height - 140;
235
+
236
+ centerTextSvg = `
237
+ <g id="center-text">
238
+ <rect x="${boxX}" y="${boxY}" width="${boxWidth}" height="${boxHeight}" rx="32" fill="${sidebarBg}" stroke="${sidebarBorder}" stroke-width="1.5" />
239
+ <text x="${width / 2}" y="${boxY + boxHeight / 2 + 6}" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="20" font-weight="600" fill="${textPrimary}" text-anchor="middle">${textStr}</text>
240
+ </g>
241
+ `;
242
+ }
243
+
244
+ return `
245
+ <svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
246
+ ${sidebarSvg}
247
+ ${centerTextSvg}
248
+ </svg>
249
+ `.trim();
250
+ }
251
+
252
+ /**
253
+ * Render SVG string to a PNG buffer using @resvg/resvg-js.
254
+ *
255
+ * @param {string} svgContent
256
+ * @param {number} [width=1920]
257
+ * @param {number} [height=1080]
258
+ * @returns {Buffer}
259
+ */
260
+ export function renderSvgToPng(svgContent, width = 1920, height = 1080) {
261
+ const resvg = new Resvg(svgContent, {
262
+ fitTo: {
263
+ mode: 'width',
264
+ value: width,
265
+ },
266
+ });
267
+ const pngData = resvg.render();
268
+ return pngData.asPng();
269
+ }
270
+
271
+ /**
272
+ * Apply journey overlay to video.
273
+ *
274
+ * @param {object} params
275
+ * @param {string} params.inputPath
276
+ * @param {string} params.outputPath
277
+ * @param {object} params.action
278
+ * @param {object} [params.entry]
279
+ * @param {object} [params.rawMetadata]
280
+ * @param {object} [params.journeyConfig]
281
+ * @param {boolean} [params.dryRun=false]
282
+ * @param {(msg: string) => void} [params.onWarn]
283
+ * @returns {Promise<{ outputPath: string, duration?: number, dryRun?: boolean, command?: string }>}
284
+ */
285
+ export async function applyJourneyOverlay(params) {
286
+ const {
287
+ inputPath,
288
+ outputPath,
289
+ action = {},
290
+ entry = {},
291
+ rawMetadata = {},
292
+ journeyConfig = {},
293
+ context = {},
294
+ dryRun = false,
295
+ onWarn,
296
+ } = params;
297
+
298
+ const currentSongKey = entry.filename || entry.songKey || path.basename(inputPath);
299
+ const maxCenterChars = journeyConfig.maxCenterChars || 60;
300
+
301
+ // Extract center concepts or legacy center text
302
+ let rawConcepts = [];
303
+ if (Array.isArray(action.centerConcepts) && action.centerConcepts.length > 0) {
304
+ rawConcepts = action.centerConcepts;
305
+ } else if (action.centerText !== undefined && String(action.centerText).trim()) {
306
+ rawConcepts = [String(action.centerText).trim()];
307
+ }
308
+
309
+ const concepts = rawConcepts.map((text, idx) => {
310
+ const str = String(text).trim();
311
+ if (str.length > maxCenterChars) {
312
+ if (onWarn) {
313
+ onWarn(`Center concept [${idx}] for "${currentSongKey}" exceeds maxCenterChars (${maxCenterChars}); truncating.`);
314
+ }
315
+ return truncate(str, maxCenterChars);
316
+ }
317
+ return str;
318
+ }).filter(Boolean);
319
+
320
+ const conceptsDisplayMode = action.conceptsDisplayMode || (action.centerConcepts ? 'full' : 'intro');
321
+ const conceptDisplaySeconds = typeof action.conceptDisplaySeconds === 'number' && action.conceptDisplaySeconds > 0
322
+ ? action.conceptDisplaySeconds
323
+ : 7;
324
+ const conceptFadeSeconds = typeof action.conceptFadeSeconds === 'number' && action.conceptFadeSeconds >= 0
325
+ ? action.conceptFadeSeconds
326
+ : 1;
327
+
328
+ const isJourneyEnabled = journeyConfig.enabled !== false;
329
+ const showSidebar = isJourneyEnabled && action.showSidebar !== false;
330
+ const sidebarLabel = journeyConfig.sidebarLabel || 'title';
331
+ const sidebarDisplayDuration = journeyConfig.sidebarDisplayDuration !== undefined
332
+ ? journeyConfig.sidebarDisplayDuration
333
+ : (journeyConfig.displayDuration ?? 5);
334
+
335
+ const tracks = showSidebar
336
+ ? resolveJourneyTracks({
337
+ currentSongKey,
338
+ rawMetadata,
339
+ mode: journeyConfig.mode || 'before-current',
340
+ maxVisibleRows: journeyConfig.maxVisibleRows || 8,
341
+ sidebarLabel,
342
+ })
343
+ : [];
344
+
345
+ let videoMeta = { duration: 0, width: 1920, height: 1080, fps: 30 };
346
+ if (fs.existsSync(inputPath)) {
347
+ try {
348
+ videoMeta = await getVideoMetadata(inputPath);
349
+ } catch (err) {
350
+ if (!dryRun) throw err;
351
+ }
352
+ }
353
+
354
+ const width = videoMeta.width || 1920;
355
+ const height = videoMeta.height || 1080;
356
+ const videoDuration = videoMeta.duration || 0;
357
+
358
+ const sidebarDurationSec = sidebarDisplayDuration === 'full'
359
+ ? (videoDuration > 0 ? videoDuration : 300)
360
+ : (typeof sidebarDisplayDuration === 'number' ? sidebarDisplayDuration : 5);
361
+
362
+ const conceptsTotalDuration = conceptsDisplayMode === 'intro'
363
+ ? sidebarDurationSec
364
+ : (videoDuration > 0 ? videoDuration : 300);
365
+
366
+ if (dryRun) {
367
+ const conceptsSummary = concepts.length > 0
368
+ ? `concepts=${concepts.length} [mode=${conceptsDisplayMode}, ${conceptDisplaySeconds}s cycle, ${conceptFadeSeconds}s fade]`
369
+ : `concepts=none`;
370
+ const plan = `Journey overlay: sidebar=${showSidebar} (${tracks.length} tracks, label=${sidebarLabel}, duration=${sidebarDisplayDuration}s), ${conceptsSummary} -> ${outputPath}`;
371
+ return {
372
+ outputPath,
373
+ duration: videoMeta.duration,
374
+ dryRun: true,
375
+ command: plan,
376
+ };
377
+ }
378
+
379
+ const tempFilesToClean = [];
380
+
381
+ try {
382
+ const tempDir = path.join(os.tmpdir(), `journey_overlay_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`);
383
+ fs.mkdirSync(tempDir, { recursive: true });
384
+ tempFilesToClean.push(tempDir);
385
+
386
+ let tempSidebarPath = null;
387
+ if (showSidebar && tracks.length > 0) {
388
+ const sidebarSvg = generateJourneySvg({
389
+ width,
390
+ height,
391
+ tracks,
392
+ centerText: '',
393
+ showSidebar: true,
394
+ theme: journeyConfig.theme || 'dark',
395
+ position: journeyConfig.position || 'left',
396
+ });
397
+ const sidebarPngBuffer = renderSvgToPng(sidebarSvg, width, height);
398
+ tempSidebarPath = path.join(
399
+ tempDir,
400
+ `journey_sidebar_${Date.now()}_${Math.random().toString(36).slice(2, 6)}.png`
401
+ );
402
+ fs.writeFileSync(tempSidebarPath, sidebarPngBuffer);
403
+ }
404
+
405
+ // Schedule concept slots
406
+ const uniqueConcepts = Array.from(new Set(concepts));
407
+ const uniqueConceptPaths = [];
408
+
409
+ for (let u = 0; u < uniqueConcepts.length; u++) {
410
+ const conceptSvg = generateJourneySvg({
411
+ width,
412
+ height,
413
+ tracks: [],
414
+ centerText: uniqueConcepts[u],
415
+ showSidebar: false,
416
+ theme: journeyConfig.theme || 'dark',
417
+ position: journeyConfig.position || 'left',
418
+ });
419
+ const conceptPngBuffer = renderSvgToPng(conceptSvg, width, height);
420
+ const tempConceptPath = path.join(
421
+ tempDir,
422
+ `journey_concept_${u}_${Date.now()}_${Math.random().toString(36).slice(2, 6)}.png`
423
+ );
424
+ fs.writeFileSync(tempConceptPath, conceptPngBuffer);
425
+ uniqueConceptPaths.push(tempConceptPath);
426
+ }
427
+
428
+ const slots = [];
429
+ if (concepts.length > 0 && conceptsTotalDuration > 0) {
430
+ const maxSlots = Math.max(1, Math.ceil(conceptsTotalDuration / conceptDisplaySeconds));
431
+ for (let s = 0; s < maxSlots; s++) {
432
+ const st = s * conceptDisplaySeconds;
433
+ const et = Math.min(conceptsTotalDuration, (s + 1) * conceptDisplaySeconds);
434
+ if (st >= conceptsTotalDuration || et <= st) break;
435
+ const phrase = concepts[s % concepts.length];
436
+ const uniqueIndex = uniqueConcepts.indexOf(phrase);
437
+ slots.push({ slotIndex: s, st, et, phrase, uniqueIndex });
438
+ }
439
+ }
440
+
441
+ const tmpOutputPath = path.join(tempDir, 'overlay_output.mp4');
442
+
443
+ return await new Promise((resolve, reject) => {
444
+ const command = ffmpeg();
445
+ command.input(inputPath);
446
+
447
+ let nextInputIndex = 1;
448
+ let currentV = '[0:v]';
449
+ const filterLines = [];
450
+
451
+ if (tempSidebarPath) {
452
+ const sbInputIdx = nextInputIndex++;
453
+ const sbLoopSeconds = Math.max(sidebarDurationSec, 1);
454
+ command.input(tempSidebarPath).inputOptions(['-loop', '1', '-t', `${sbLoopSeconds}`]);
455
+
456
+ const isSbFull = sidebarDisplayDuration === 'full' || (videoDuration > 0 && sidebarDurationSec >= videoDuration);
457
+ const sbFadeIn = 0.5;
458
+ const sbFadeOut = 0.5;
459
+ const nextV = (slots.length === 0) ? '[outv]' : '[v_after_sb]';
460
+
461
+ if (isSbFull) {
462
+ filterLines.push(`[${sbInputIdx}:v]format=rgba,fade=in:st=0:d=${sbFadeIn}:alpha=1[sb_faded]`);
463
+ filterLines.push(`${currentV}[sb_faded]overlay=0:0${nextV}`);
464
+ } else {
465
+ const sbFadeOutStart = Math.max(sbFadeIn, sidebarDurationSec - sbFadeOut);
466
+ filterLines.push(`[${sbInputIdx}:v]format=rgba,fade=in:st=0:d=${sbFadeIn}:alpha=1,fade=out:st=${sbFadeOutStart}:d=${sbFadeOut}:alpha=1[sb_faded]`);
467
+ filterLines.push(`${currentV}[sb_faded]overlay=0:0:enable='between(t,0,${sidebarDurationSec})'${nextV}`);
468
+ }
469
+ currentV = nextV;
470
+ }
471
+
472
+ if (slots.length > 0) {
473
+ const concatInputs = [];
474
+ slots.forEach((slot, idx) => {
475
+ const inputIdx = nextInputIndex++;
476
+ const dur = slot.et - slot.st;
477
+ command.input(uniqueConceptPaths[slot.uniqueIndex]).inputOptions([
478
+ '-loop', '1',
479
+ '-t', `${dur}`,
480
+ ]);
481
+
482
+ const fIn = Math.min(conceptFadeSeconds, dur / 2);
483
+ const fOut = Math.min(conceptFadeSeconds, dur / 2);
484
+ const fadeOutStart = Math.max(0, dur - fOut);
485
+ const fadeParts = [];
486
+ if (fIn > 0) fadeParts.push(`fade=in:st=0:d=${fIn}:alpha=1`);
487
+ if (fOut > 0) fadeParts.push(`fade=out:st=${fadeOutStart}:d=${fOut}:alpha=1`);
488
+ const fadeFilter = fadeParts.length
489
+ ? `format=rgba,${fadeParts.join(',')}`
490
+ : 'format=rgba';
491
+ filterLines.push(`[${inputIdx}:v]trim=0:${dur},setpts=PTS-STARTPTS,${fadeFilter}[c_slot_${idx}]`);
492
+ concatInputs.push(`[c_slot_${idx}]`);
493
+ });
494
+
495
+ if (slots.length === 1) {
496
+ filterLines.push(`${concatInputs[0]}copy[concepts]`);
497
+ } else {
498
+ filterLines.push(`${concatInputs.join('')}concat=n=${slots.length}:v=1:a=0[concepts]`);
499
+ }
500
+ filterLines.push(`${currentV}[concepts]overlay=0:0:eof_action=pass[outv]`);
501
+ currentV = '[outv]';
502
+ }
503
+
504
+ if (filterLines.length === 0) {
505
+ filterLines.push('[0:v]null[outv]');
506
+ }
507
+
508
+ const outputOpts = [
509
+ '-map', '[outv]',
510
+ '-map', '0:a?',
511
+ '-c:v', 'libx264',
512
+ '-preset', 'veryfast',
513
+ '-crf', '18',
514
+ '-c:a', 'aac',
515
+ '-b:a', '192k',
516
+ '-pix_fmt', 'yuv420p',
517
+ '-movflags', '+faststart',
518
+ '-shortest',
519
+ ];
520
+ if (videoDuration > 0) {
521
+ outputOpts.push('-t', `${videoDuration}`);
522
+ }
523
+
524
+ const cleanup = (removeOutput = false) => {
525
+ for (const f of tempFilesToClean) {
526
+ try { fs.rmSync(f, { recursive: true, force: true }); } catch {}
527
+ }
528
+ if (fs.existsSync(tmpOutputPath)) {
529
+ try { fs.unlinkSync(tmpOutputPath); } catch {}
530
+ }
531
+ if (removeOutput && fs.existsSync(outputPath)) {
532
+ try { fs.unlinkSync(outputPath); } catch {}
533
+ }
534
+ };
535
+
536
+ command
537
+ .complexFilter(filterLines)
538
+ .outputOptions(outputOpts)
539
+ .output(tmpOutputPath)
540
+ .on('end', () => {
541
+ try {
542
+ fs.mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true });
543
+ fs.copyFileSync(tmpOutputPath, outputPath);
544
+ } catch (err) {
545
+ cleanup(true);
546
+ reject(new Error(`Journey overlay compositing failed: ${err.message}`));
547
+ return;
548
+ }
549
+ cleanup(false);
550
+ resolve({
551
+ outputPath,
552
+ duration: videoMeta.duration,
553
+ });
554
+ })
555
+ .on('error', (err) => {
556
+ cleanup(true);
557
+ reject(new Error(`Journey overlay compositing failed: ${err.message}`));
558
+ })
559
+ .run();
560
+ });
561
+ } catch (err) {
562
+ for (const f of tempFilesToClean) {
563
+ try { fs.rmSync(f, { recursive: true, force: true }); } catch {}
564
+ }
565
+ throw err;
566
+ }
567
+ }