@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.
@@ -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
- journeyStage: stage,
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
- journeyStage: stage,
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='left']
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 = 'left',
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
- const panelWidth = 440;
184
- const rowHeight = 44;
185
- const panelPadding = 24;
186
- const panelHeight = panelPadding * 2 + tracks.length * rowHeight;
187
- const panelX = position === 'left' ? 60 : width - panelWidth - 60;
188
- const panelY = 60;
189
-
190
- let rowsSvg = '';
191
- tracks.forEach((track, idx) => {
192
- const rowY = panelY + panelPadding + idx * rowHeight;
193
- const truncatedTitle = truncate(track.title, 34);
194
-
195
- if (track.isCurrent) {
196
- rowsSvg += `
197
- <rect x="${panelX + 12}" y="${rowY}" width="${panelWidth - 24}" height="${rowHeight - 6}" rx="8" fill="${highlightBadgeBg}" stroke="${highlightBadgeBorder}" stroke-width="1.5" />
198
- <circle cx="${panelX + 32}" cy="${rowY + (rowHeight - 6) / 2}" r="5" fill="${textHighlight}" />
199
- <text x="${panelX + 48}" y="${rowY + (rowHeight - 6) / 2 + 5}" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="16" font-weight="700" fill="${textHighlight}">${escapeXml(truncatedTitle)}</text>
200
- `;
201
- } else if (track.isUpcoming) {
202
- rowsSvg += `
203
- <circle cx="${panelX + 32}" cy="${rowY + (rowHeight - 6) / 2}" r="3" fill="${textSecondary}" opacity="0.5" />
204
- <text x="${panelX + 48}" y="${rowY + (rowHeight - 6) / 2 + 5}" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="15" font-weight="400" fill="${textSecondary}" opacity="0.6">${escapeXml(truncatedTitle)}</text>
205
- `;
206
- } else if (track.isOverflow) {
207
- rowsSvg += `
208
- <text x="${panelX + 48}" y="${rowY + (rowHeight - 6) / 2 + 5}" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="14" font-weight="500" fill="${textSecondary}" font-style="italic">${escapeXml(truncatedTitle)}</text>
209
- `;
210
- } else {
211
- // Past tracks
212
- rowsSvg += `
213
- <circle cx="${panelX + 32}" cy="${rowY + (rowHeight - 6) / 2}" r="3.5" fill="${textSecondary}" />
214
- <text x="${panelX + 48}" y="${rowY + (rowHeight - 6) / 2 + 5}" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="15" font-weight="500" fill="${textPrimary}">${escapeXml(truncatedTitle)}</text>
215
- `;
216
- }
217
- });
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
- sidebarSvg = `
220
- <g id="sidebar">
221
- <rect x="${panelX}" y="${panelY}" width="${panelWidth}" height="${panelHeight}" rx="16" fill="${sidebarBg}" stroke="${sidebarBorder}" stroke-width="1.5" />
222
- ${rowsSvg}
223
- </g>
224
- `;
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 = 14;
231
- const boxWidth = Math.min(width - 200, Math.max(320, textStr.length * approxCharWidth + 60));
232
- const boxHeight = 64;
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 - 140;
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="32" fill="${sidebarBg}" stroke="${sidebarBorder}" stroke-width="1.5" />
239
- <text x="${width / 2}" y="${boxY + boxHeight / 2 + 6}" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" font-size="20" font-weight="600" fill="${textPrimary}" text-anchor="middle">${textStr}</text>
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 || 'left',
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', 'aac',
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('aac')
40
- .audioBitrate('192k')
64
+ .audioCodec('copy')
41
65
  .outputOptions(['-movflags', '+faststart'])
42
66
  .output(outputAudioPath)
43
67
  .on('end', () => resolve(outputAudioPath))
44
- .on('error', (err) => reject(new Error(`Failed to extract audio from ${inputVideoPath}: ${err.message}`)))
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 plan = `Replace background: query="${query}" (${provider}, ${orientation}, mode: ${mode}) + audio from ${inputPath} -> ${outputPath}`;
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
 
@@ -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
- let videoMeta = { duration: 0, width: 1920, height: 1080, fps: 30, sampleRate: 44100 };
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 || 44100;
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 plan = `Shorten: trim to ${cutSeconds.toFixed(1)}s${videoFadePart}${audioFadePart} + ${paddingDuration}s silent padding (${paddingFill}) -> ${outputPath}`;
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 0
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(`[0:a]${a0Filter}[a0]`);
253
+ complexFilters.push(`[${audioInputIndex}:a]${a0Filter}[a0]`);
218
254
 
219
255
  if (paddingDuration > 0) {
220
- // Input 1 for audio padding: anullsrc
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][1:a]concat=n=2:v=1:a=1[outv][outa]`);
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(`[2:v]scale=${width}:${height}:flags=neighbor,setsar=1,fps=${fps},format=yuv420p[vpad]`);
233
- complexFilters.push(`[v0f][a0][vpad][1:a]concat=n=2:v=1:a=1[outv][outa]`);
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', '192k',
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: 'left',
120
- displayDuration: 5,
119
+ position: 'right',
120
+ displayDuration: 4,
121
121
  sidebarDisplayDuration: undefined,
122
- sidebarLabel: 'title',
122
+ sidebarLabel: 'stage',
123
123
  theme: 'dark',
124
+ style: 'badge',
124
125
  maxCenterChars: 60,
125
126
  maxVisibleRows: 8,
126
127
  ...(rawMetadata.journey || {}),