@x12i/youtube-video-uploader-cli 1.3.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 +17 -4
- package/package.json +1 -1
- package/src/actions/index.js +2 -0
- package/src/actions/journey-overlay.js +117 -61
- package/src/actions/replace-background.js +42 -4
- package/src/actions/shorten.js +49 -10
- package/src/batch/plan.js +4 -3
- package/src/cli/preview-journey.js +9 -4
- package/src/index.d.ts +9 -2
- package/src/media/compose.js +85 -12
package/README.md
CHANGED
|
@@ -412,7 +412,7 @@ The actions pipeline allows non-destructive video modifications (shortening, bac
|
|
|
412
412
|
|
|
413
413
|
1. **`shorten`**: Cut video at `cutPoint`, smoothly fade out audio and video into the cut, then append a silent padding buffer.
|
|
414
414
|
2. **`replace-background`**: Keep existing audio and swap the visual background with a freshly sourced stock video or photo.
|
|
415
|
-
3. **`journey-overlay`**: Burn in a
|
|
415
|
+
3. **`journey-overlay`**: Burn in a modern journey overlay graphic (either a sleek top-right step badge or playlist panel) and optional cycling center concepts.
|
|
416
416
|
|
|
417
417
|
### `metadata.json` Actions Configuration
|
|
418
418
|
|
|
@@ -423,19 +423,32 @@ The actions pipeline allows non-destructive video modifications (shortening, bac
|
|
|
423
423
|
],
|
|
424
424
|
"journey": {
|
|
425
425
|
"enabled": true,
|
|
426
|
-
"
|
|
426
|
+
"style": "badge",
|
|
427
|
+
"position": "right",
|
|
428
|
+
"sidebarLabel": "stage",
|
|
429
|
+
"sidebarDisplayDuration": 4,
|
|
427
430
|
"theme": "dark",
|
|
428
|
-
"displayDuration": 5,
|
|
429
431
|
"maxCenterChars": 60
|
|
430
432
|
},
|
|
431
433
|
"songs": {
|
|
432
434
|
"01-first.mp4": {
|
|
435
|
+
"journeyStage": "Decision",
|
|
433
436
|
"actions": [
|
|
434
437
|
{ "type": "shorten", "cutPoint": "02:15", "fadeDuration": 3, "paddingDuration": 2 },
|
|
435
|
-
{
|
|
438
|
+
{
|
|
439
|
+
"type": "journey-overlay",
|
|
440
|
+
"conceptsDisplayMode": "full",
|
|
441
|
+
"conceptDisplaySeconds": 7,
|
|
442
|
+
"conceptFadeSeconds": 1,
|
|
443
|
+
"centerConcepts": [
|
|
444
|
+
"You don't feel ready.",
|
|
445
|
+
"Move anyway."
|
|
446
|
+
]
|
|
447
|
+
}
|
|
436
448
|
]
|
|
437
449
|
},
|
|
438
450
|
"02-second.mp4": {
|
|
451
|
+
"journeyStage": "Sensation",
|
|
439
452
|
"actions": [
|
|
440
453
|
{ "type": "replace-background", "query": "empty highway sunrise" }
|
|
441
454
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@x12i/youtube-video-uploader-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "CLI tool to batch upload MP4 videos to YouTube via YouTube Data API v3 with OAuth2, metadata.json config, playlist assignment, and smart upload-state resume.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "src/index.d.ts",
|
package/src/actions/index.js
CHANGED
|
@@ -116,6 +116,7 @@ export async function runActionsPipeline(entry, actions = [], context = {}) {
|
|
|
116
116
|
const res = await shortenVideo({
|
|
117
117
|
inputPath: currentInput,
|
|
118
118
|
outputPath: stepOutput,
|
|
119
|
+
entry,
|
|
119
120
|
...action,
|
|
120
121
|
dryRun: true,
|
|
121
122
|
onWarn,
|
|
@@ -205,6 +206,7 @@ export async function runActionsPipeline(entry, actions = [], context = {}) {
|
|
|
205
206
|
await shortenVideo({
|
|
206
207
|
inputPath: currentInput,
|
|
207
208
|
outputPath: stepOutput,
|
|
209
|
+
entry,
|
|
208
210
|
...action,
|
|
209
211
|
dryRun: false,
|
|
210
212
|
onWarn,
|
|
@@ -74,23 +74,29 @@ export function resolveJourneyTracks({ currentSongKey, rawMetadata, mode = 'befo
|
|
|
74
74
|
allTracks = orderedKeys.map((key, idx) => {
|
|
75
75
|
const songData = rawMetadata.songs[key] || {};
|
|
76
76
|
const rawTitle = songData.youtube?.snippet?.title || path.basename(key, path.extname(key));
|
|
77
|
-
const stage = songData.journeyStage;
|
|
77
|
+
const stage = songData.journeyStage || songData.albumRole;
|
|
78
78
|
return {
|
|
79
79
|
key,
|
|
80
80
|
title: formatTrackLabel(rawTitle, stage),
|
|
81
|
-
|
|
81
|
+
rawTitle,
|
|
82
|
+
stage: stage || '',
|
|
83
|
+
journeyStage: stage || '',
|
|
82
84
|
trackNumber: songData.trackNumber ?? idx + 1,
|
|
85
|
+
totalTracks: orderedKeys.length,
|
|
83
86
|
};
|
|
84
87
|
});
|
|
85
88
|
} else if (Array.isArray(rawMetadata?.videos)) {
|
|
86
89
|
allTracks = rawMetadata.videos.map((v, idx) => {
|
|
87
90
|
const rawTitle = v.title || path.basename(v.filename, path.extname(v.filename));
|
|
88
|
-
const stage = v.journeyStage;
|
|
91
|
+
const stage = v.journeyStage || v.albumRole;
|
|
89
92
|
return {
|
|
90
93
|
key: v.filename,
|
|
91
94
|
title: formatTrackLabel(rawTitle, stage),
|
|
92
|
-
|
|
95
|
+
rawTitle,
|
|
96
|
+
stage: stage || '',
|
|
97
|
+
journeyStage: stage || '',
|
|
93
98
|
trackNumber: v.trackNumber ?? idx + 1,
|
|
99
|
+
totalTracks: rawMetadata.videos.length,
|
|
94
100
|
};
|
|
95
101
|
});
|
|
96
102
|
}
|
|
@@ -101,10 +107,14 @@ export function resolveJourneyTracks({ currentSongKey, rawMetadata, mode = 'befo
|
|
|
101
107
|
// Fallback: show only the current track
|
|
102
108
|
const songData = rawMetadata?.songs?.[currentSongKey] || {};
|
|
103
109
|
const singleRawTitle = songData.youtube?.snippet?.title || (currentSongKey ? path.basename(currentSongKey, path.extname(currentSongKey)) : 'Current Track');
|
|
104
|
-
const stage = songData.journeyStage;
|
|
110
|
+
const stage = songData.journeyStage || songData.albumRole;
|
|
105
111
|
return [{
|
|
106
112
|
index: 1,
|
|
113
|
+
totalTracks: allTracks.length || 1,
|
|
107
114
|
title: formatTrackLabel(singleRawTitle, stage),
|
|
115
|
+
rawTitle: singleRawTitle,
|
|
116
|
+
stage: stage || '',
|
|
117
|
+
journeyStage: stage || '',
|
|
108
118
|
isCurrent: true,
|
|
109
119
|
isUpcoming: false,
|
|
110
120
|
}];
|
|
@@ -114,7 +124,11 @@ export function resolveJourneyTracks({ currentSongKey, rawMetadata, mode = 'befo
|
|
|
114
124
|
if (mode === 'all') {
|
|
115
125
|
visibleList = allTracks.map((t, i) => ({
|
|
116
126
|
index: i + 1,
|
|
127
|
+
totalTracks: allTracks.length,
|
|
117
128
|
title: t.title,
|
|
129
|
+
rawTitle: t.rawTitle,
|
|
130
|
+
stage: t.stage,
|
|
131
|
+
journeyStage: t.journeyStage,
|
|
118
132
|
isCurrent: i === currentIndex,
|
|
119
133
|
isUpcoming: i > currentIndex,
|
|
120
134
|
}));
|
|
@@ -122,19 +136,25 @@ export function resolveJourneyTracks({ currentSongKey, rawMetadata, mode = 'befo
|
|
|
122
136
|
// 'before-current'
|
|
123
137
|
visibleList = allTracks.slice(0, currentIndex + 1).map((t, i) => ({
|
|
124
138
|
index: i + 1,
|
|
139
|
+
totalTracks: allTracks.length,
|
|
125
140
|
title: t.title,
|
|
141
|
+
rawTitle: t.rawTitle,
|
|
142
|
+
stage: t.stage,
|
|
143
|
+
journeyStage: t.journeyStage,
|
|
126
144
|
isCurrent: i === currentIndex,
|
|
127
145
|
isUpcoming: false,
|
|
128
146
|
}));
|
|
129
147
|
}
|
|
130
148
|
|
|
131
|
-
if (visibleList.length > maxVisibleRows) {
|
|
149
|
+
if (maxVisibleRows && visibleList.length > maxVisibleRows) {
|
|
132
150
|
// Cap visible rows and add +N more
|
|
133
151
|
const overflowCount = visibleList.length - maxVisibleRows + 1;
|
|
134
152
|
const capped = visibleList.slice(0, maxVisibleRows - 1);
|
|
135
153
|
capped.push({
|
|
136
154
|
index: maxVisibleRows,
|
|
155
|
+
totalTracks: allTracks.length,
|
|
137
156
|
title: `+${overflowCount} more`,
|
|
157
|
+
stage: '',
|
|
138
158
|
isCurrent: false,
|
|
139
159
|
isUpcoming: false,
|
|
140
160
|
isOverflow: true,
|
|
@@ -155,7 +175,12 @@ export function resolveJourneyTracks({ currentSongKey, rawMetadata, mode = 'befo
|
|
|
155
175
|
* @param {string} [params.centerText]
|
|
156
176
|
* @param {boolean} [params.showSidebar=true]
|
|
157
177
|
* @param {'dark' | 'light'} [params.theme='dark']
|
|
158
|
-
* @param {'left'} [params.position='
|
|
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']
|
|
159
184
|
* @returns {string} SVG xml content
|
|
160
185
|
*/
|
|
161
186
|
export function generateJourneySvg(params) {
|
|
@@ -166,7 +191,12 @@ export function generateJourneySvg(params) {
|
|
|
166
191
|
centerText = '',
|
|
167
192
|
showSidebar = true,
|
|
168
193
|
theme = 'dark',
|
|
169
|
-
position = '
|
|
194
|
+
position = 'right',
|
|
195
|
+
style = 'badge',
|
|
196
|
+
stage = '',
|
|
197
|
+
step = null,
|
|
198
|
+
totalSteps = null,
|
|
199
|
+
badgeText = 'Beyond Art',
|
|
170
200
|
} = params;
|
|
171
201
|
|
|
172
202
|
const isDark = theme === 'dark';
|
|
@@ -179,64 +209,86 @@ export function generateJourneySvg(params) {
|
|
|
179
209
|
const highlightBadgeBorder = isDark ? 'rgba(56, 189, 248, 0.40)' : 'rgba(2, 132, 199, 0.30)';
|
|
180
210
|
|
|
181
211
|
let sidebarSvg = '';
|
|
182
|
-
if (showSidebar && tracks.length > 0) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
+
});
|
|
218
248
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
+
}
|
|
225
277
|
}
|
|
226
278
|
|
|
227
279
|
let centerTextSvg = '';
|
|
228
280
|
if (centerText && centerText.trim()) {
|
|
229
281
|
const textStr = escapeXml(centerText.trim());
|
|
230
|
-
const approxCharWidth =
|
|
231
|
-
const boxWidth = Math.min(width -
|
|
232
|
-
const boxHeight =
|
|
282
|
+
const approxCharWidth = 24;
|
|
283
|
+
const boxWidth = Math.min(width - 240, Math.max(500, textStr.length * approxCharWidth + 80));
|
|
284
|
+
const boxHeight = 84;
|
|
233
285
|
const boxX = (width - boxWidth) / 2;
|
|
234
|
-
const boxY = height -
|
|
286
|
+
const boxY = (height - boxHeight) / 2;
|
|
235
287
|
|
|
236
288
|
centerTextSvg = `
|
|
237
289
|
<g id="center-text">
|
|
238
|
-
<rect x="${boxX}" y="${boxY}" width="${boxWidth}" height="${boxHeight}" rx="
|
|
239
|
-
<text x="${width / 2}" y="${
|
|
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>
|
|
240
292
|
</g>
|
|
241
293
|
`;
|
|
242
294
|
}
|
|
@@ -337,7 +389,7 @@ export async function applyJourneyOverlay(params) {
|
|
|
337
389
|
currentSongKey,
|
|
338
390
|
rawMetadata,
|
|
339
391
|
mode: journeyConfig.mode || 'before-current',
|
|
340
|
-
maxVisibleRows: journeyConfig.maxVisibleRows || 8,
|
|
392
|
+
maxVisibleRows: journeyConfig.style === 'panel' ? (journeyConfig.maxVisibleRows || 8) : null,
|
|
341
393
|
sidebarLabel,
|
|
342
394
|
})
|
|
343
395
|
: [];
|
|
@@ -384,7 +436,7 @@ export async function applyJourneyOverlay(params) {
|
|
|
384
436
|
tempFilesToClean.push(tempDir);
|
|
385
437
|
|
|
386
438
|
let tempSidebarPath = null;
|
|
387
|
-
if (showSidebar && tracks.length > 0) {
|
|
439
|
+
if (showSidebar && (tracks.length > 0 || entry.journeyStage || entry.albumRole)) {
|
|
388
440
|
const sidebarSvg = generateJourneySvg({
|
|
389
441
|
width,
|
|
390
442
|
height,
|
|
@@ -392,7 +444,12 @@ export async function applyJourneyOverlay(params) {
|
|
|
392
444
|
centerText: '',
|
|
393
445
|
showSidebar: true,
|
|
394
446
|
theme: journeyConfig.theme || 'dark',
|
|
395
|
-
position: journeyConfig.position || '
|
|
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',
|
|
396
453
|
});
|
|
397
454
|
const sidebarPngBuffer = renderSvgToPng(sidebarSvg, width, height);
|
|
398
455
|
tempSidebarPath = path.join(
|
|
@@ -511,8 +568,7 @@ export async function applyJourneyOverlay(params) {
|
|
|
511
568
|
'-c:v', 'libx264',
|
|
512
569
|
'-preset', 'veryfast',
|
|
513
570
|
'-crf', '18',
|
|
514
|
-
'-c:a', '
|
|
515
|
-
'-b:a', '192k',
|
|
571
|
+
'-c:a', 'copy',
|
|
516
572
|
'-pix_fmt', 'yuv420p',
|
|
517
573
|
'-movflags', '+faststart',
|
|
518
574
|
'-shortest',
|
|
@@ -19,6 +19,31 @@ if (ffprobeStatic?.path && fs.existsSync(ffprobeStatic.path)) {
|
|
|
19
19
|
ffmpeg.setFfprobePath(ffprobeStatic.path);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Find companion file with matching stem and target extension.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} sourcePath
|
|
26
|
+
* @param {string} ext
|
|
27
|
+
* @returns {string | null}
|
|
28
|
+
*/
|
|
29
|
+
export function findCompanionFile(sourcePath, ext) {
|
|
30
|
+
if (!sourcePath) return null;
|
|
31
|
+
const dir = path.dirname(sourcePath);
|
|
32
|
+
const stem = path.basename(sourcePath, path.extname(sourcePath)).trim().toLowerCase();
|
|
33
|
+
const direct = path.join(dir, `${path.basename(sourcePath, path.extname(sourcePath))}.${ext}`);
|
|
34
|
+
if (fs.existsSync(direct)) return direct;
|
|
35
|
+
if (fs.existsSync(dir)) {
|
|
36
|
+
try {
|
|
37
|
+
const files = fs.readdirSync(dir);
|
|
38
|
+
const match = files.find(f => f.toLowerCase().endsWith('.' + ext.toLowerCase()) && path.basename(f, path.extname(f)).trim().toLowerCase() === stem);
|
|
39
|
+
if (match) return path.join(dir, match);
|
|
40
|
+
} catch {
|
|
41
|
+
// ignore
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
22
47
|
/**
|
|
23
48
|
* Extract audio track from a video file.
|
|
24
49
|
*
|
|
@@ -36,12 +61,22 @@ export function extractAudio(inputVideoPath, outputAudioPath) {
|
|
|
36
61
|
return new Promise((resolve, reject) => {
|
|
37
62
|
ffmpeg(inputVideoPath)
|
|
38
63
|
.noVideo()
|
|
39
|
-
.audioCodec('
|
|
40
|
-
.audioBitrate('192k')
|
|
64
|
+
.audioCodec('copy')
|
|
41
65
|
.outputOptions(['-movflags', '+faststart'])
|
|
42
66
|
.output(outputAudioPath)
|
|
43
67
|
.on('end', () => resolve(outputAudioPath))
|
|
44
|
-
.on('error', (
|
|
68
|
+
.on('error', () => {
|
|
69
|
+
// Fallback to high-bitrate AAC if copy is not container-compatible
|
|
70
|
+
ffmpeg(inputVideoPath)
|
|
71
|
+
.noVideo()
|
|
72
|
+
.audioCodec('aac')
|
|
73
|
+
.audioBitrate('320k')
|
|
74
|
+
.outputOptions(['-ar', '48000', '-movflags', '+faststart'])
|
|
75
|
+
.output(outputAudioPath)
|
|
76
|
+
.on('end', () => resolve(outputAudioPath))
|
|
77
|
+
.on('error', (err) => reject(new Error(`Failed to extract audio from ${inputVideoPath}: ${err.message}`)))
|
|
78
|
+
.run();
|
|
79
|
+
})
|
|
45
80
|
.run();
|
|
46
81
|
});
|
|
47
82
|
}
|
|
@@ -73,9 +108,11 @@ export async function replaceBackground(params) {
|
|
|
73
108
|
const provider = action.provider || entry.media?.provider || 'auto';
|
|
74
109
|
const orientation = action.orientation || entry.media?.orientation || 'landscape';
|
|
75
110
|
const mode = action.mode || entry.media?.mode || 'video-loop';
|
|
111
|
+
const assPath = action.assPath || entry.assPath || findCompanionFile(entry.sourcePath || inputPath, 'ass');
|
|
76
112
|
|
|
77
113
|
if (dryRun) {
|
|
78
|
-
const
|
|
114
|
+
const subsNote = assPath ? ` + subtitles (${path.basename(assPath)})` : '';
|
|
115
|
+
const plan = `Replace background: query="${query}" (${provider}, ${orientation}, mode: ${mode}) + audio from ${inputPath}${subsNote} -> ${outputPath}`;
|
|
79
116
|
return {
|
|
80
117
|
outputPath,
|
|
81
118
|
dryRun: true,
|
|
@@ -136,6 +173,7 @@ export async function replaceBackground(params) {
|
|
|
136
173
|
targetWidth: action.width || 1920,
|
|
137
174
|
targetHeight: action.height || 1080,
|
|
138
175
|
fps: action.fps || 30,
|
|
176
|
+
assPath,
|
|
139
177
|
dryRun: false,
|
|
140
178
|
});
|
|
141
179
|
|
package/src/actions/shorten.js
CHANGED
|
@@ -11,6 +11,31 @@ if (ffprobeStatic?.path && fs.existsSync(ffprobeStatic.path)) {
|
|
|
11
11
|
ffmpeg.setFfprobePath(ffprobeStatic.path);
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Find companion file with matching stem and target extension.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} sourcePath
|
|
18
|
+
* @param {string} ext
|
|
19
|
+
* @returns {string | null}
|
|
20
|
+
*/
|
|
21
|
+
export function findCompanionFile(sourcePath, ext) {
|
|
22
|
+
if (!sourcePath) return null;
|
|
23
|
+
const dir = path.dirname(sourcePath);
|
|
24
|
+
const stem = path.basename(sourcePath, path.extname(sourcePath)).trim().toLowerCase();
|
|
25
|
+
const direct = path.join(dir, `${path.basename(sourcePath, path.extname(sourcePath))}.${ext}`);
|
|
26
|
+
if (fs.existsSync(direct)) return direct;
|
|
27
|
+
if (fs.existsSync(dir)) {
|
|
28
|
+
try {
|
|
29
|
+
const files = fs.readdirSync(dir);
|
|
30
|
+
const match = files.find(f => f.toLowerCase().endsWith('.' + ext.toLowerCase()) && path.basename(f, path.extname(f)).trim().toLowerCase() === stem);
|
|
31
|
+
if (match) return path.join(dir, match);
|
|
32
|
+
} catch {
|
|
33
|
+
// ignore
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
14
39
|
/**
|
|
15
40
|
* Parse time expression to seconds.
|
|
16
41
|
* Supports: 'mm:ss', 'hh:mm:ss', seconds as string or number.
|
|
@@ -132,6 +157,7 @@ export async function shortenVideo(params) {
|
|
|
132
157
|
paddingFill = 'black',
|
|
133
158
|
fadeTargets = ['audio', 'video'],
|
|
134
159
|
dryRun = false,
|
|
160
|
+
entry = {},
|
|
135
161
|
onWarn,
|
|
136
162
|
} = params;
|
|
137
163
|
|
|
@@ -144,7 +170,10 @@ export async function shortenVideo(params) {
|
|
|
144
170
|
throw new Error(`Action "shorten" cutPoint must be greater than 0 (received: ${cutPoint}).`);
|
|
145
171
|
}
|
|
146
172
|
|
|
147
|
-
|
|
173
|
+
const audioSourcePath = params.audioSourcePath || entry.sourceWav || findCompanionFile(entry.sourcePath || inputPath, 'wav');
|
|
174
|
+
const hasSeparateAudio = Boolean(audioSourcePath && fs.existsSync(audioSourcePath) && audioSourcePath !== inputPath);
|
|
175
|
+
|
|
176
|
+
let videoMeta = { duration: 0, width: 1920, height: 1080, fps: 30, sampleRate: 48000 };
|
|
148
177
|
if (fs.existsSync(inputPath)) {
|
|
149
178
|
try {
|
|
150
179
|
videoMeta = await getVideoMetadata(inputPath);
|
|
@@ -175,7 +204,7 @@ export async function shortenVideo(params) {
|
|
|
175
204
|
const width = videoMeta.width || 1920;
|
|
176
205
|
const height = videoMeta.height || 1080;
|
|
177
206
|
const fps = videoMeta.fps || 30;
|
|
178
|
-
const sampleRate = videoMeta.sampleRate ||
|
|
207
|
+
const sampleRate = hasSeparateAudio ? 48000 : (videoMeta.sampleRate || 48000);
|
|
179
208
|
|
|
180
209
|
const shouldFadeVideo = fadeTargets.includes('video');
|
|
181
210
|
const shouldFadeAudio = fadeTargets.includes('audio');
|
|
@@ -183,7 +212,8 @@ export async function shortenVideo(params) {
|
|
|
183
212
|
if (dryRun) {
|
|
184
213
|
const videoFadePart = shouldFadeVideo ? `, fade out at ${fadeStart.toFixed(1)}s for ${effectiveFadeDuration.toFixed(1)}s` : '';
|
|
185
214
|
const audioFadePart = shouldFadeAudio ? `, audio fade out at ${fadeStart.toFixed(1)}s for ${effectiveFadeDuration.toFixed(1)}s` : '';
|
|
186
|
-
const
|
|
215
|
+
const wavNote = hasSeparateAudio ? ` [audio from ${path.basename(audioSourcePath)}]` : '';
|
|
216
|
+
const plan = `Shorten: trim to ${cutSeconds.toFixed(1)}s${videoFadePart}${audioFadePart}${wavNote} + ${paddingDuration}s silent padding (${paddingFill}) -> ${outputPath}`;
|
|
187
217
|
return {
|
|
188
218
|
outputPath,
|
|
189
219
|
duration: totalDuration,
|
|
@@ -200,6 +230,12 @@ export async function shortenVideo(params) {
|
|
|
200
230
|
// Input 0: source video
|
|
201
231
|
command.input(inputPath);
|
|
202
232
|
|
|
233
|
+
let audioInputIndex = 0;
|
|
234
|
+
if (hasSeparateAudio) {
|
|
235
|
+
command.input(audioSourcePath);
|
|
236
|
+
audioInputIndex = 1;
|
|
237
|
+
}
|
|
238
|
+
|
|
203
239
|
const complexFilters = [];
|
|
204
240
|
|
|
205
241
|
// Filter video 0
|
|
@@ -209,28 +245,30 @@ export async function shortenVideo(params) {
|
|
|
209
245
|
}
|
|
210
246
|
complexFilters.push(`[0:v]${v0Filter}[v0]`);
|
|
211
247
|
|
|
212
|
-
// Filter audio
|
|
248
|
+
// Filter audio
|
|
213
249
|
let a0Filter = `atrim=0:${cutSeconds},asetpts=PTS-STARTPTS`;
|
|
214
250
|
if (shouldFadeAudio && effectiveFadeDuration > 0) {
|
|
215
251
|
a0Filter += `,afade=t=out:st=${fadeStart}:d=${effectiveFadeDuration}`;
|
|
216
252
|
}
|
|
217
|
-
complexFilters.push(`[
|
|
253
|
+
complexFilters.push(`[${audioInputIndex}:a]${a0Filter}[a0]`);
|
|
218
254
|
|
|
219
255
|
if (paddingDuration > 0) {
|
|
220
|
-
//
|
|
256
|
+
// Audio silence input
|
|
257
|
+
const silenceIndex = audioInputIndex + 1;
|
|
221
258
|
command.input(`anullsrc=r=${sampleRate}:cl=stereo`).inputOptions(['-f', 'lavfi', '-t', `${paddingDuration}`]);
|
|
222
259
|
|
|
223
260
|
if (paddingFill === 'freeze-frame') {
|
|
224
261
|
// Use the last frame of the trimmed portion as padding
|
|
225
262
|
complexFilters.push(`[v0]split=2[v0_out][v0_last]`);
|
|
226
263
|
complexFilters.push(`[v0_last]trim=${Math.max(0, cutSeconds - 0.04)}:${cutSeconds},setpts=PTS-STARTPTS,loop=loop=-1:size=1:start=0,trim=0:${paddingDuration},setpts=PTS-STARTPTS[vpad]`);
|
|
227
|
-
complexFilters.push(`[v0_out][a0][vpad][
|
|
264
|
+
complexFilters.push(`[v0_out][a0][vpad][${silenceIndex}:a]concat=n=2:v=1:a=1[outv][outa]`);
|
|
228
265
|
} else {
|
|
229
266
|
// Plain black padding via lavfi color, forced to match the trimmed video
|
|
267
|
+
const colorIndex = silenceIndex + 1;
|
|
230
268
|
command.input(`color=c=black:s=${width}x${height}:r=${fps}:d=${paddingDuration}`).inputOptions(['-f', 'lavfi']);
|
|
231
269
|
complexFilters.push(`[v0]format=yuv420p,setsar=1[v0f]`);
|
|
232
|
-
complexFilters.push(`[
|
|
233
|
-
complexFilters.push(`[v0f][a0][vpad][
|
|
270
|
+
complexFilters.push(`[${colorIndex}:v]scale=${width}:${height}:flags=neighbor,setsar=1,fps=${fps},format=yuv420p[vpad]`);
|
|
271
|
+
complexFilters.push(`[v0f][a0][vpad][${silenceIndex}:a]concat=n=2:v=1:a=1[outv][outa]`);
|
|
234
272
|
}
|
|
235
273
|
} else {
|
|
236
274
|
complexFilters.push(`[v0]copy[outv]`);
|
|
@@ -246,7 +284,8 @@ export async function shortenVideo(params) {
|
|
|
246
284
|
'-crf', '18',
|
|
247
285
|
'-preset', 'veryfast',
|
|
248
286
|
'-c:a', 'aac',
|
|
249
|
-
'-b:a', '
|
|
287
|
+
'-b:a', '320k',
|
|
288
|
+
'-ar', '48000',
|
|
250
289
|
'-pix_fmt', 'yuv420p',
|
|
251
290
|
'-movflags', '+faststart',
|
|
252
291
|
`-t`, `${totalDuration}`,
|
package/src/batch/plan.js
CHANGED
|
@@ -116,11 +116,12 @@ export function planBatchActions(sourceDir, options = {}) {
|
|
|
116
116
|
const journeyConfig = {
|
|
117
117
|
enabled: true,
|
|
118
118
|
mode: 'before-current',
|
|
119
|
-
position: '
|
|
120
|
-
displayDuration:
|
|
119
|
+
position: 'right',
|
|
120
|
+
displayDuration: 4,
|
|
121
121
|
sidebarDisplayDuration: undefined,
|
|
122
|
-
sidebarLabel: '
|
|
122
|
+
sidebarLabel: 'stage',
|
|
123
123
|
theme: 'dark',
|
|
124
|
+
style: 'badge',
|
|
124
125
|
maxCenterChars: 60,
|
|
125
126
|
maxVisibleRows: 8,
|
|
126
127
|
...(rawMetadata.journey || {}),
|
|
@@ -38,11 +38,12 @@ export async function previewJourney(songKey, options = {}) {
|
|
|
38
38
|
const journeyConfig = {
|
|
39
39
|
enabled: true,
|
|
40
40
|
mode: 'before-current',
|
|
41
|
-
position: '
|
|
42
|
-
displayDuration:
|
|
41
|
+
position: 'right',
|
|
42
|
+
displayDuration: 4,
|
|
43
43
|
sidebarDisplayDuration: undefined,
|
|
44
|
-
sidebarLabel: '
|
|
44
|
+
sidebarLabel: 'stage',
|
|
45
45
|
theme: 'dark',
|
|
46
|
+
style: 'badge',
|
|
46
47
|
maxCenterChars: 60,
|
|
47
48
|
maxVisibleRows: 8,
|
|
48
49
|
...(rawMetadata.journey || {}),
|
|
@@ -71,7 +72,7 @@ export async function previewJourney(songKey, options = {}) {
|
|
|
71
72
|
currentSongKey: songKey,
|
|
72
73
|
rawMetadata,
|
|
73
74
|
mode: journeyConfig.mode,
|
|
74
|
-
maxVisibleRows: journeyConfig.maxVisibleRows,
|
|
75
|
+
maxVisibleRows: journeyConfig.style === 'panel' ? journeyConfig.maxVisibleRows : null,
|
|
75
76
|
sidebarLabel: journeyConfig.sidebarLabel,
|
|
76
77
|
});
|
|
77
78
|
|
|
@@ -86,6 +87,10 @@ export async function previewJourney(songKey, options = {}) {
|
|
|
86
87
|
showSidebar: journeyConfig.enabled !== false,
|
|
87
88
|
theme: journeyConfig.theme,
|
|
88
89
|
position: journeyConfig.position,
|
|
90
|
+
style: journeyConfig.style,
|
|
91
|
+
stage: songData.journeyStage || songData.albumRole,
|
|
92
|
+
step: typeof songData.trackNumber === 'number' ? songData.trackNumber : (songData.playlistPosition !== undefined ? songData.playlistPosition + 1 : undefined),
|
|
93
|
+
totalSteps: rawMetadata.album?.trackCount || rawMetadata.album?.arc?.length || (tracks[0]?.totalTracks) || 11,
|
|
89
94
|
});
|
|
90
95
|
|
|
91
96
|
const pngBuffer = renderSvgToPng(svgContent, width, height);
|
package/src/index.d.ts
CHANGED
|
@@ -505,7 +505,9 @@ export type VideoAction = ShortenAction | ReplaceBackgroundAction | JourneyOverl
|
|
|
505
505
|
export interface JourneyConfig {
|
|
506
506
|
enabled?: boolean;
|
|
507
507
|
mode?: 'before-current' | 'all';
|
|
508
|
-
position?: 'left';
|
|
508
|
+
position?: 'left' | 'right';
|
|
509
|
+
style?: 'badge' | 'panel';
|
|
510
|
+
badgeText?: string;
|
|
509
511
|
displayDuration?: number | 'full';
|
|
510
512
|
sidebarDisplayDuration?: number | 'full';
|
|
511
513
|
sidebarLabel?: 'title' | 'stage' | 'both';
|
|
@@ -614,7 +616,12 @@ export function generateJourneySvg(params: {
|
|
|
614
616
|
centerText?: string;
|
|
615
617
|
showSidebar?: boolean;
|
|
616
618
|
theme?: 'dark' | 'light';
|
|
617
|
-
position?: 'left';
|
|
619
|
+
position?: 'left' | 'right';
|
|
620
|
+
style?: 'badge' | 'panel';
|
|
621
|
+
stage?: string;
|
|
622
|
+
step?: number;
|
|
623
|
+
totalSteps?: number;
|
|
624
|
+
badgeText?: string;
|
|
618
625
|
}): string;
|
|
619
626
|
|
|
620
627
|
export function renderSvgToPng(svgContent: string, width?: number, height?: number): Buffer;
|
package/src/media/compose.js
CHANGED
|
@@ -3,6 +3,7 @@ import ffmpegStatic from 'ffmpeg-static';
|
|
|
3
3
|
import ffprobeStatic from 'ffprobe-static';
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import os from 'node:os';
|
|
6
7
|
|
|
7
8
|
// Configure ffmpeg and ffprobe paths if available
|
|
8
9
|
if (ffmpegStatic && fs.existsSync(ffmpegStatic)) {
|
|
@@ -12,6 +13,47 @@ if (ffprobeStatic?.path && fs.existsSync(ffprobeStatic.path)) {
|
|
|
12
13
|
ffmpeg.setFfprobePath(ffprobeStatic.path);
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
const ASS_FILTER_UNSAFE = /['\[\]:;\\,]/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Return a safe path to pass to ffmpeg's `ass=` filter.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} assPath
|
|
22
|
+
* @returns {{ filterPath: string | null, cleanupPath?: string }}
|
|
23
|
+
*/
|
|
24
|
+
export function resolveAssFilterPath(assPath) {
|
|
25
|
+
if (!assPath || !fs.existsSync(assPath)) {
|
|
26
|
+
return { filterPath: null };
|
|
27
|
+
}
|
|
28
|
+
if (!ASS_FILTER_UNSAFE.test(assPath)) {
|
|
29
|
+
return { filterPath: assPath };
|
|
30
|
+
}
|
|
31
|
+
const cleanupPath = path.join(
|
|
32
|
+
os.tmpdir(),
|
|
33
|
+
`compose-ass-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.ass`
|
|
34
|
+
);
|
|
35
|
+
try {
|
|
36
|
+
fs.copyFileSync(assPath, cleanupPath);
|
|
37
|
+
return { filterPath: cleanupPath, cleanupPath };
|
|
38
|
+
} catch {
|
|
39
|
+
return { filterPath: assPath };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Quote and escape special characters in path for ffmpeg filter syntax.
|
|
45
|
+
*
|
|
46
|
+
* @param {string} filePath
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function escapeForFfmpeg(filePath) {
|
|
50
|
+
const escaped = String(filePath)
|
|
51
|
+
.replaceAll('\\', '\\\\')
|
|
52
|
+
.replaceAll(':', '\\:')
|
|
53
|
+
.replaceAll("'", "\\'");
|
|
54
|
+
return `'${escaped}'`;
|
|
55
|
+
}
|
|
56
|
+
|
|
15
57
|
/**
|
|
16
58
|
* Get the duration of an audio or video file in seconds using ffprobe.
|
|
17
59
|
*
|
|
@@ -72,6 +114,7 @@ export async function composeVideo(options) {
|
|
|
72
114
|
targetHeight = 1080,
|
|
73
115
|
fps = 30,
|
|
74
116
|
dryRun = false,
|
|
117
|
+
assPath = null,
|
|
75
118
|
onProgress,
|
|
76
119
|
} = options;
|
|
77
120
|
|
|
@@ -98,12 +141,31 @@ export async function composeVideo(options) {
|
|
|
98
141
|
}
|
|
99
142
|
|
|
100
143
|
const isImage = bgDuration === 0 || mode === 'ken-burns';
|
|
101
|
-
const
|
|
144
|
+
const baseScaleFilter = `scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=increase,crop=${targetWidth}:${targetHeight},format=yuv420p`;
|
|
145
|
+
|
|
146
|
+
let assCleanupPath = null;
|
|
147
|
+
let scaleCropFilter = baseScaleFilter;
|
|
148
|
+
if (assPath && fs.existsSync(assPath)) {
|
|
149
|
+
const resolved = resolveAssFilterPath(assPath);
|
|
150
|
+
if (resolved.filterPath) {
|
|
151
|
+
assCleanupPath = resolved.cleanupPath || null;
|
|
152
|
+
scaleCropFilter = `${baseScaleFilter},ass=${escapeForFfmpeg(resolved.filterPath)}`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const cleanup = () => {
|
|
157
|
+
if (assCleanupPath && fs.existsSync(assCleanupPath)) {
|
|
158
|
+
try { fs.unlinkSync(assCleanupPath); } catch {}
|
|
159
|
+
assCleanupPath = null;
|
|
160
|
+
}
|
|
161
|
+
};
|
|
102
162
|
|
|
103
163
|
if (dryRun) {
|
|
164
|
+
cleanup();
|
|
165
|
+
const subsNote = assPath ? ` + subtitles (${path.basename(assPath)})` : '';
|
|
104
166
|
const planDescription = isImage
|
|
105
|
-
? `Image (${backgroundPath}) + Audio (${audioPath}, ${audioDuration.toFixed(1)}s) -> ${outputPath} [${targetWidth}x${targetHeight} @ ${fps}fps, mode: ${mode}]`
|
|
106
|
-
: `Video (${backgroundPath}, ${bgDuration.toFixed(1)}s) + Audio (${audioPath}, ${audioDuration.toFixed(1)}s) -> ${outputPath} [${targetWidth}x${targetHeight} @ ${fps}fps, mode: ${mode}]`;
|
|
167
|
+
? `Image (${backgroundPath}) + Audio (${audioPath}, ${audioDuration.toFixed(1)}s)${subsNote} -> ${outputPath} [${targetWidth}x${targetHeight} @ ${fps}fps, mode: ${mode}]`
|
|
168
|
+
: `Video (${backgroundPath}, ${bgDuration.toFixed(1)}s) + Audio (${audioPath}, ${audioDuration.toFixed(1)}s)${subsNote} -> ${outputPath} [${targetWidth}x${targetHeight} @ ${fps}fps, mode: ${mode}]`;
|
|
107
169
|
|
|
108
170
|
return {
|
|
109
171
|
outputPath,
|
|
@@ -116,15 +178,22 @@ export async function composeVideo(options) {
|
|
|
116
178
|
return new Promise((resolve, reject) => {
|
|
117
179
|
const command = ffmpeg();
|
|
118
180
|
|
|
181
|
+
const audioOpts = [
|
|
182
|
+
'-c:a', 'aac',
|
|
183
|
+
'-b:a', '320k',
|
|
184
|
+
'-ar', '48000',
|
|
185
|
+
];
|
|
186
|
+
|
|
119
187
|
if (isImage) {
|
|
120
188
|
if (mode === 'ken-burns') {
|
|
121
189
|
const totalFrames = Math.max(30, Math.ceil(audioDuration * fps));
|
|
190
|
+
const kenBurnsFilter = `scale=8000:-1,zoompan=z='min(zoom+0.0008,1.25)':d=${totalFrames}:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=${targetWidth}x${targetHeight}:fps=${fps},${scaleCropFilter}`;
|
|
122
191
|
command
|
|
123
192
|
.input(backgroundPath)
|
|
124
193
|
.inputOptions(['-loop', '1'])
|
|
125
194
|
.input(audioPath)
|
|
126
195
|
.complexFilter([
|
|
127
|
-
|
|
196
|
+
`${kenBurnsFilter}[v]`,
|
|
128
197
|
])
|
|
129
198
|
.outputOptions([
|
|
130
199
|
'-map', '[v]',
|
|
@@ -132,8 +201,7 @@ export async function composeVideo(options) {
|
|
|
132
201
|
'-c:v', 'libx264',
|
|
133
202
|
'-preset', 'veryfast',
|
|
134
203
|
'-crf', '20',
|
|
135
|
-
|
|
136
|
-
'-b:a', '192k',
|
|
204
|
+
...audioOpts,
|
|
137
205
|
'-shortest',
|
|
138
206
|
`-t`, `${audioDuration}`,
|
|
139
207
|
'-pix_fmt', 'yuv420p',
|
|
@@ -147,11 +215,12 @@ export async function composeVideo(options) {
|
|
|
147
215
|
.input(audioPath)
|
|
148
216
|
.videoFilters([scaleCropFilter])
|
|
149
217
|
.outputOptions([
|
|
218
|
+
'-map', '0:v',
|
|
219
|
+
'-map', '1:a',
|
|
150
220
|
'-c:v', 'libx264',
|
|
151
221
|
'-preset', 'veryfast',
|
|
152
222
|
'-crf', '20',
|
|
153
|
-
|
|
154
|
-
'-b:a', '192k',
|
|
223
|
+
...audioOpts,
|
|
155
224
|
'-shortest',
|
|
156
225
|
`-t`, `${audioDuration}`,
|
|
157
226
|
`-r`, `${fps}`,
|
|
@@ -169,11 +238,12 @@ export async function composeVideo(options) {
|
|
|
169
238
|
.input(audioPath)
|
|
170
239
|
.videoFilters([scaleCropFilter])
|
|
171
240
|
.outputOptions([
|
|
241
|
+
'-map', '0:v',
|
|
242
|
+
'-map', '1:a',
|
|
172
243
|
'-c:v', 'libx264',
|
|
173
244
|
'-preset', 'veryfast',
|
|
174
245
|
'-crf', '20',
|
|
175
|
-
|
|
176
|
-
'-b:a', '192k',
|
|
246
|
+
...audioOpts,
|
|
177
247
|
'-shortest',
|
|
178
248
|
`-t`, `${audioDuration}`,
|
|
179
249
|
`-r`, `${fps}`,
|
|
@@ -187,11 +257,12 @@ export async function composeVideo(options) {
|
|
|
187
257
|
.input(audioPath)
|
|
188
258
|
.videoFilters([scaleCropFilter])
|
|
189
259
|
.outputOptions([
|
|
260
|
+
'-map', '0:v',
|
|
261
|
+
'-map', '1:a',
|
|
190
262
|
'-c:v', 'libx264',
|
|
191
263
|
'-preset', 'veryfast',
|
|
192
264
|
'-crf', '20',
|
|
193
|
-
|
|
194
|
-
'-b:a', '192k',
|
|
265
|
+
...audioOpts,
|
|
195
266
|
'-shortest',
|
|
196
267
|
`-t`, `${audioDuration}`,
|
|
197
268
|
`-r`, `${fps}`,
|
|
@@ -208,12 +279,14 @@ export async function composeVideo(options) {
|
|
|
208
279
|
command
|
|
209
280
|
.output(outputPath)
|
|
210
281
|
.on('end', () => {
|
|
282
|
+
cleanup();
|
|
211
283
|
resolve({
|
|
212
284
|
outputPath,
|
|
213
285
|
duration: audioDuration,
|
|
214
286
|
});
|
|
215
287
|
})
|
|
216
288
|
.on('error', (err) => {
|
|
289
|
+
cleanup();
|
|
217
290
|
// Clean up partial output if any
|
|
218
291
|
if (fs.existsSync(outputPath)) {
|
|
219
292
|
try {
|