@x12i/youtube-video-uploader-cli 1.3.0 → 1.6.1
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 +76 -5
- package/bin/cli.js +344 -6
- package/package.json +5 -3
- package/src/access/privacy.js +113 -0
- package/src/access/set-access.js +247 -0
- package/src/actions/index.js +33 -0
- package/src/actions/journey-overlay.js +117 -61
- package/src/actions/remove-watermark.js +109 -0
- 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/cli/remove-watermark.js +252 -0
- package/src/index.d.ts +156 -3
- package/src/index.js +42 -0
- package/src/media/compose.js +85 -12
- package/src/playlist/detect-outdated.js +50 -0
- package/src/playlist/order.js +57 -0
- package/src/playlist/reorder.js +90 -0
- package/src/playlist/sync.js +192 -0
- package/src/providers/watermark/index.js +113 -0
- package/src/providers/watermark/pilio.js +201 -0
- package/src/swap/steps.js +17 -12
- package/src/swap/swapSong.js +40 -2
- package/src/update.js +165 -25
|
@@ -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',
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_WATERMARK_PROVIDER,
|
|
5
|
+
removeWatermarkWithProvider,
|
|
6
|
+
} from '../providers/watermark/index.js';
|
|
7
|
+
|
|
8
|
+
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.tif', '.tiff', '.bmp']);
|
|
9
|
+
const VIDEO_EXTS = new Set(['.mp4', '.webm', '.mov', '.mkv', '.m4v']);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} filePath
|
|
13
|
+
* @returns {boolean}
|
|
14
|
+
*/
|
|
15
|
+
export function isWatermarkSupportedMedia(filePath) {
|
|
16
|
+
const ext = path.extname(filePath || '').toLowerCase();
|
|
17
|
+
return IMAGE_EXTS.has(ext) || VIDEO_EXTS.has(ext);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Default cleaned output path next to the input (stem-clean.ext).
|
|
22
|
+
* @param {string} inputPath
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
export function defaultCleanOutputPath(inputPath) {
|
|
26
|
+
const dir = path.dirname(inputPath);
|
|
27
|
+
const ext = path.extname(inputPath);
|
|
28
|
+
const stem = path.basename(inputPath, ext);
|
|
29
|
+
return path.join(dir, `${stem}-clean${ext}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Default out-dir for batch cleaning a folder (sibling `basename-clean`).
|
|
34
|
+
* @param {string} inputDir
|
|
35
|
+
* @returns {string}
|
|
36
|
+
*/
|
|
37
|
+
export function defaultCleanOutDir(inputDir) {
|
|
38
|
+
const resolved = path.resolve(inputDir);
|
|
39
|
+
return `${resolved}-clean`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Remove Gemini/Veo visible watermarks from a single media file.
|
|
44
|
+
*
|
|
45
|
+
* Matches other action executors: dryRun returns `{ command }` without writing.
|
|
46
|
+
*
|
|
47
|
+
* @param {object} params
|
|
48
|
+
* @param {string} params.inputPath
|
|
49
|
+
* @param {string} params.outputPath
|
|
50
|
+
* @param {object} [params.action]
|
|
51
|
+
* @param {string} [params.action.provider]
|
|
52
|
+
* @param {boolean} [params.action.overwrite]
|
|
53
|
+
* @param {boolean} [params.action.json]
|
|
54
|
+
* @param {number} [params.action.videoBitrateMbps]
|
|
55
|
+
* @param {boolean} [params.dryRun=false]
|
|
56
|
+
* @param {(msg: string) => void} [params.onWarn]
|
|
57
|
+
* @param {(chunk: string) => void} [params.onStderr]
|
|
58
|
+
* @returns {Promise<{ outputPath: string, dryRun?: boolean, command: string, provider: string }>}
|
|
59
|
+
*/
|
|
60
|
+
export async function removeWatermark(params = {}) {
|
|
61
|
+
const {
|
|
62
|
+
inputPath,
|
|
63
|
+
outputPath,
|
|
64
|
+
action = {},
|
|
65
|
+
dryRun = false,
|
|
66
|
+
onWarn,
|
|
67
|
+
onStderr,
|
|
68
|
+
} = params;
|
|
69
|
+
|
|
70
|
+
if (!inputPath) {
|
|
71
|
+
throw new Error('inputPath is required for remove-watermark');
|
|
72
|
+
}
|
|
73
|
+
if (!outputPath) {
|
|
74
|
+
throw new Error('outputPath is required for remove-watermark');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const provider = action.provider || DEFAULT_WATERMARK_PROVIDER;
|
|
78
|
+
const overwrite = action.overwrite !== undefined ? Boolean(action.overwrite) : true;
|
|
79
|
+
const json = Boolean(action.json);
|
|
80
|
+
const videoBitrateMbps = action.videoBitrateMbps;
|
|
81
|
+
|
|
82
|
+
if (!dryRun && !fs.existsSync(inputPath)) {
|
|
83
|
+
throw new Error(`Input file not found: ${inputPath}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!isWatermarkSupportedMedia(inputPath) && onWarn) {
|
|
87
|
+
onWarn(`File extension may be unsupported by watermark remover: ${path.extname(inputPath)}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const result = await removeWatermarkWithProvider({
|
|
91
|
+
inputPath,
|
|
92
|
+
outputPath,
|
|
93
|
+
provider,
|
|
94
|
+
overwrite,
|
|
95
|
+
json,
|
|
96
|
+
videoBitrateMbps,
|
|
97
|
+
dryRun,
|
|
98
|
+
onStderr,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
outputPath,
|
|
103
|
+
dryRun: result.dryRun,
|
|
104
|
+
command: result.command.startsWith('Remove watermark')
|
|
105
|
+
? result.command
|
|
106
|
+
: `Remove watermark (${provider}): ${result.command}`,
|
|
107
|
+
provider,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -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 || {}),
|