@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.
- package/README.md +351 -27
- package/bin/cli.js +620 -2
- package/package.json +7 -1
- package/src/actions/index.js +276 -0
- package/src/actions/journey-overlay.js +623 -0
- package/src/actions/replace-background.js +192 -0
- package/src/actions/shorten.js +310 -0
- package/src/batch/clone-metadata.js +51 -0
- package/src/batch/plan.js +195 -0
- package/src/cli/apply-actions.js +152 -0
- package/src/cli/preview-journey.js +111 -0
- package/src/index.d.ts +532 -3
- package/src/index.js +82 -1
- package/src/media/cache.js +134 -0
- package/src/media/compose.js +300 -0
- package/src/media/download.js +151 -0
- package/src/media/resolve.js +476 -0
- package/src/media/select.js +176 -0
- package/src/providers/index.js +235 -0
- package/src/providers/pexels.js +171 -0
- package/src/providers/pixabay.js +184 -0
- package/src/state.js +11 -0
- package/src/update.js +392 -0
- package/src/uploader.js +20 -0
|
@@ -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, '<')
|
|
28
|
+
.replace(/>/g, '>')
|
|
29
|
+
.replace(/"/g, '"')
|
|
30
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|