@x12i/youtube-video-uploader-cli 1.2.0 → 1.4.0

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