@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.
@@ -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 scaleCropFilter = `scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=increase,crop=${targetWidth}:${targetHeight},format=yuv420p`;
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
- `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}[v]`,
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
- '-c:a', 'aac',
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
- '-c:a', 'aac',
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
- '-c:a', 'aac',
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
- '-c:a', 'aac',
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 {
@@ -0,0 +1,50 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Find local MP4 files that changed after their last YouTube upload timestamp.
6
+ *
7
+ * @param {string} targetDir
8
+ * @param {import('../state.js').UploadState} state
9
+ * @param {object} [options]
10
+ * @param {number} [options.toleranceMs=1000] - Ignore sub-second clock skew
11
+ * @param {string[]} [options.onlyFilenames] - Restrict to these basenames
12
+ * @returns {Array<{ filename: string, filePath: string, videoId: string, uploadedAt: string, modifiedAt: string }>}
13
+ */
14
+ export function detectOutdatedUploads(targetDir, state, options = {}) {
15
+ const toleranceMs = options.toleranceMs ?? 1000;
16
+ const only = options.onlyFilenames
17
+ ? new Set(options.onlyFilenames.map((f) => path.basename(f)))
18
+ : null;
19
+
20
+ const outdated = [];
21
+ const uploads = state.data.uploads || {};
22
+
23
+ for (const [filename, record] of Object.entries(uploads)) {
24
+ if (only && !only.has(filename)) continue;
25
+ if (!record?.videoId || !record?.uploadedAt) continue;
26
+
27
+ const filePath = path.join(targetDir, filename);
28
+ if (!fs.existsSync(filePath)) continue;
29
+
30
+ const stat = fs.statSync(filePath);
31
+ if (!stat.isFile() || stat.size === 0) continue;
32
+
33
+ const modifiedMs = stat.mtimeMs;
34
+ const uploadedMs = new Date(record.uploadedAt).getTime();
35
+ if (Number.isNaN(uploadedMs)) continue;
36
+
37
+ if (modifiedMs > uploadedMs + toleranceMs) {
38
+ outdated.push({
39
+ filename,
40
+ filePath,
41
+ videoId: record.videoId,
42
+ uploadedAt: record.uploadedAt,
43
+ modifiedAt: new Date(modifiedMs).toISOString(),
44
+ });
45
+ }
46
+ }
47
+
48
+ outdated.sort((a, b) => a.filename.localeCompare(b.filename));
49
+ return outdated;
50
+ }
@@ -0,0 +1,57 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { loadMetadata } from '../metadata.js';
4
+
5
+ /**
6
+ * Resolve desired playlist video IDs from upload histories + metadata song order.
7
+ *
8
+ * @param {object} options
9
+ * @param {string} options.albumDir
10
+ * @param {import('../state.js').UploadState} options.albumState
11
+ * @param {string} [options.overviewHistoryPath]
12
+ * @param {string[]} [options.overviewKeys]
13
+ * @param {string[]} [options.songKeys]
14
+ * @returns {string[]}
15
+ */
16
+ export function resolveDesiredPlaylistOrder(options = {}) {
17
+ const {
18
+ albumDir,
19
+ albumState,
20
+ overviewHistoryPath,
21
+ overviewKeys = [],
22
+ songKeys,
23
+ } = options;
24
+
25
+ const ids = [];
26
+
27
+ if (overviewHistoryPath && overviewKeys.length > 0) {
28
+ const abs = path.resolve(overviewHistoryPath);
29
+ if (!fs.existsSync(abs)) {
30
+ throw new Error(`Overview upload history not found: ${abs}`);
31
+ }
32
+ const overviewHistory = JSON.parse(fs.readFileSync(abs, 'utf-8'));
33
+ for (const key of overviewKeys) {
34
+ const rec = overviewHistory.uploads?.[key];
35
+ if (!rec?.videoId) {
36
+ throw new Error(`Overview upload history missing videoId for ${key}`);
37
+ }
38
+ ids.push(rec.videoId);
39
+ }
40
+ }
41
+
42
+ let orderedSongs = songKeys;
43
+ if (!orderedSongs || orderedSongs.length === 0) {
44
+ const metadata = loadMetadata(albumDir);
45
+ orderedSongs = metadata.videos.map((v) => v.filename);
46
+ }
47
+
48
+ for (const filename of orderedSongs) {
49
+ const rec = albumState.getUpload(filename);
50
+ if (!rec?.videoId) {
51
+ throw new Error(`Album upload history missing videoId for ${filename}`);
52
+ }
53
+ ids.push(rec.videoId);
54
+ }
55
+
56
+ return ids;
57
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * List all playlist items with snippet data.
3
+ *
4
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
5
+ * @param {string} playlistId
6
+ * @returns {Promise<Array<{ id: string, videoId: string, position: number, title: string }>>}
7
+ */
8
+ export async function listPlaylistItems(youtube, playlistId) {
9
+ const items = [];
10
+ let pageToken;
11
+ do {
12
+ const { data } = await youtube.playlistItems.list({
13
+ part: ['snippet'],
14
+ playlistId,
15
+ maxResults: 50,
16
+ pageToken,
17
+ });
18
+ for (const item of data.items || []) {
19
+ items.push({
20
+ id: item.id,
21
+ videoId: item.snippet?.resourceId?.videoId,
22
+ position: item.snippet?.position ?? 0,
23
+ title: item.snippet?.title || '',
24
+ });
25
+ }
26
+ pageToken = data.nextPageToken;
27
+ } while (pageToken);
28
+
29
+ items.sort((a, b) => a.position - b.position);
30
+ return items;
31
+ }
32
+
33
+ /**
34
+ * Reorder a playlist to match an explicit video ID sequence.
35
+ * Updates positions from end to start to avoid index drift.
36
+ *
37
+ * @param {import('googleapis').youtube_v3.Youtube} youtube
38
+ * @param {string} playlistId
39
+ * @param {string[]} desiredVideoIds
40
+ * @returns {Promise<{ changed: number, finalOrder: string[] }>}
41
+ */
42
+ export async function reorderPlaylist(youtube, playlistId, desiredVideoIds) {
43
+ if (!desiredVideoIds.length) {
44
+ throw new Error('desiredVideoIds must not be empty');
45
+ }
46
+
47
+ const items = await listPlaylistItems(youtube, playlistId);
48
+ const byVideo = new Map(items.map((item) => [item.videoId, item]));
49
+
50
+ for (const videoId of desiredVideoIds) {
51
+ if (!byVideo.has(videoId)) {
52
+ throw new Error(`Video ${videoId} is not in playlist ${playlistId}`);
53
+ }
54
+ }
55
+
56
+ let changed = 0;
57
+ for (let pos = desiredVideoIds.length - 1; pos >= 0; pos -= 1) {
58
+ const videoId = desiredVideoIds[pos];
59
+ const item = byVideo.get(videoId);
60
+ if (item.position === pos) continue;
61
+
62
+ await youtube.playlistItems.update({
63
+ part: ['snippet'],
64
+ requestBody: {
65
+ id: item.id,
66
+ snippet: {
67
+ playlistId,
68
+ resourceId: { kind: 'youtube#video', videoId },
69
+ position: pos,
70
+ },
71
+ },
72
+ });
73
+ item.position = pos;
74
+ changed += 1;
75
+ }
76
+
77
+ return { changed, finalOrder: desiredVideoIds };
78
+ }
79
+
80
+ /**
81
+ * Compare current playlist order to desired video IDs.
82
+ *
83
+ * @param {string[]} currentVideoIds
84
+ * @param {string[]} desiredVideoIds
85
+ * @returns {boolean}
86
+ */
87
+ export function playlistOrderMatches(currentVideoIds, desiredVideoIds) {
88
+ if (currentVideoIds.length !== desiredVideoIds.length) return false;
89
+ return currentVideoIds.every((id, i) => id === desiredVideoIds[i]);
90
+ }
@@ -0,0 +1,192 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { UploadState } from '../state.js';
4
+ import { swapSong } from '../swap/swapSong.js';
5
+ import { detectOutdatedUploads } from './detect-outdated.js';
6
+ import { resolveDesiredPlaylistOrder } from './order.js';
7
+ import {
8
+ listPlaylistItems,
9
+ playlistOrderMatches,
10
+ reorderPlaylist,
11
+ } from './reorder.js';
12
+
13
+ /**
14
+ * Sync a YouTube playlist with local files: push outdated uploads via swap,
15
+ * then realign playlist order. Keeps videos private by default.
16
+ *
17
+ * @param {object} options
18
+ * @param {import('googleapis').youtube_v3.Youtube} options.youtube
19
+ * @param {string} options.albumDir
20
+ * @param {string} options.playlistId
21
+ * @param {string} [options.overviewHistoryPath]
22
+ * @param {string[]} [options.overviewKeys]
23
+ * @param {string[]} [options.songKeys]
24
+ * @param {string[]} [options.onlyFilenames]
25
+ * @param {'preserve' | 'private' | 'public'} [options.privacyMode='private']
26
+ * @param {boolean} [options.dryRun=false]
27
+ * @param {boolean} [options.skipReorder=false]
28
+ * @param {boolean} [options.freshSwap=false]
29
+ * @param {string} [options.stateFile]
30
+ * @param {(event: object) => void} [options.onEvent]
31
+ * @returns {Promise<object>}
32
+ */
33
+ export async function syncPlaylist(options = {}) {
34
+ const {
35
+ youtube,
36
+ albumDir,
37
+ playlistId,
38
+ overviewHistoryPath,
39
+ overviewKeys = [],
40
+ songKeys,
41
+ onlyFilenames,
42
+ privacyMode = 'private',
43
+ dryRun = false,
44
+ skipReorder = false,
45
+ freshSwap = false,
46
+ stateFile,
47
+ onEvent = () => {},
48
+ } = options;
49
+
50
+ if (!albumDir || !playlistId) {
51
+ throw new Error('syncPlaylist requires albumDir and playlistId');
52
+ }
53
+
54
+ const resolvedAlbumDir = path.resolve(albumDir);
55
+ const albumState = new UploadState(resolvedAlbumDir, stateFile);
56
+
57
+ const outdated = detectOutdatedUploads(resolvedAlbumDir, albumState, { onlyFilenames });
58
+ const desiredOrder = resolveDesiredPlaylistOrder({
59
+ albumDir: resolvedAlbumDir,
60
+ albumState,
61
+ overviewHistoryPath,
62
+ overviewKeys,
63
+ songKeys,
64
+ });
65
+
66
+ let currentOrder = [];
67
+ if (!dryRun && youtube) {
68
+ const currentItems = await listPlaylistItems(youtube, playlistId);
69
+ currentOrder = currentItems.map((item) => item.videoId);
70
+ }
71
+
72
+ const plan = {
73
+ playlistId,
74
+ albumDir: resolvedAlbumDir,
75
+ privacyMode,
76
+ outdated,
77
+ desiredOrder,
78
+ currentOrder,
79
+ orderAligned: currentOrder.length
80
+ ? playlistOrderMatches(currentOrder, desiredOrder)
81
+ : null,
82
+ swaps: outdated.map((item) => ({
83
+ filename: item.filename,
84
+ oldVideoId: item.videoId,
85
+ modifiedAt: item.modifiedAt,
86
+ uploadedAt: item.uploadedAt,
87
+ })),
88
+ };
89
+
90
+ onEvent({ type: 'sync_plan', plan });
91
+
92
+ if (dryRun) {
93
+ return {
94
+ dryRun: true,
95
+ ...plan,
96
+ pushed: [],
97
+ reordered: false,
98
+ };
99
+ }
100
+
101
+ if (!youtube) {
102
+ throw new Error('syncPlaylist requires an authorized youtube client when dryRun is false');
103
+ }
104
+
105
+ const pushed = [];
106
+ const failed = [];
107
+
108
+ for (const item of outdated) {
109
+ onEvent({ type: 'sync_push_start', filename: item.filename, oldVideoId: item.videoId });
110
+ try {
111
+ const { record } = await swapSong({
112
+ youtube,
113
+ songKey: item.filename,
114
+ newVideoPath: item.filePath,
115
+ playlistId,
116
+ oldVideoId: item.videoId,
117
+ stateDir: resolvedAlbumDir,
118
+ fresh: freshSwap,
119
+ privacyMode,
120
+ onEvent: (event) => onEvent({ ...event, filename: item.filename }),
121
+ });
122
+
123
+ albumState.data.uploads[item.filename] = {
124
+ ...albumState.data.uploads[item.filename],
125
+ videoId: record.newVideoId,
126
+ uploadedAt: new Date().toISOString(),
127
+ playlistId,
128
+ previousVideoId: item.videoId,
129
+ };
130
+ albumState.save();
131
+
132
+ pushed.push({
133
+ filename: item.filename,
134
+ oldVideoId: item.videoId,
135
+ newVideoId: record.newVideoId,
136
+ url: `https://youtu.be/${record.newVideoId}`,
137
+ });
138
+ onEvent({ type: 'sync_push_success', filename: item.filename, record });
139
+ } catch (err) {
140
+ failed.push({ filename: item.filename, error: err });
141
+ onEvent({ type: 'sync_push_error', filename: item.filename, error: err });
142
+ throw err;
143
+ }
144
+ }
145
+
146
+ const refreshedDesiredOrder = resolveDesiredPlaylistOrder({
147
+ albumDir: resolvedAlbumDir,
148
+ albumState,
149
+ overviewHistoryPath,
150
+ overviewKeys,
151
+ songKeys,
152
+ });
153
+
154
+ let reorderResult = { changed: 0, finalOrder: refreshedDesiredOrder };
155
+ if (!skipReorder) {
156
+ const latestItems = await listPlaylistItems(youtube, playlistId);
157
+ const latestOrder = latestItems.map((item) => item.videoId);
158
+ if (!playlistOrderMatches(latestOrder, refreshedDesiredOrder)) {
159
+ onEvent({ type: 'sync_reorder_start', desiredOrder: refreshedDesiredOrder });
160
+ reorderResult = await reorderPlaylist(youtube, playlistId, refreshedDesiredOrder);
161
+ onEvent({ type: 'sync_reorder_success', ...reorderResult });
162
+ }
163
+ }
164
+
165
+ if (overviewHistoryPath && overviewKeys.length > 0) {
166
+ const absOverviewHistory = path.resolve(overviewHistoryPath);
167
+ if (fs.existsSync(absOverviewHistory)) {
168
+ const overviewHistory = JSON.parse(fs.readFileSync(absOverviewHistory, 'utf-8'));
169
+ overviewHistory.playlistId = playlistId;
170
+ overviewHistory.playlistOrder = refreshedDesiredOrder;
171
+ overviewHistory.updatedAt = new Date().toISOString();
172
+ fs.writeFileSync(absOverviewHistory, JSON.stringify(overviewHistory, null, 2) + '\n');
173
+ }
174
+ }
175
+
176
+ albumState.data.playlistId = playlistId;
177
+ albumState.data.playlistOrder = refreshedDesiredOrder;
178
+ albumState.data.updatedAt = new Date().toISOString();
179
+ albumState.save();
180
+
181
+ return {
182
+ dryRun: false,
183
+ playlistId,
184
+ pushed,
185
+ failed,
186
+ outdated,
187
+ desiredOrder: refreshedDesiredOrder,
188
+ reordered: reorderResult.changed > 0,
189
+ reorderChanges: reorderResult.changed,
190
+ orderAligned: true,
191
+ };
192
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Watermark-remover provider registry.
3
+ *
4
+ * Only register tools with permissive open-source licenses:
5
+ * MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, or ISC.
6
+ *
7
+ * To add another MIT/Apache/BSD/ISC npm CLI later:
8
+ * 1. Create `src/providers/watermark/<id>.js` with a `run*Remove` entry point
9
+ * 2. Register it in PROVIDERS below
10
+ * 3. No pipeline changes required
11
+ */
12
+
13
+ import {
14
+ PILIO_PROVIDER_ID,
15
+ PILIO_LICENSE,
16
+ runPilioRemove,
17
+ formatPilioCommand,
18
+ buildPilioArgs,
19
+ resolvePilioBin,
20
+ } from './pilio.js';
21
+
22
+ /** @typedef {'MIT' | 'Apache-2.0' | 'BSD-2-Clause' | 'BSD-3-Clause' | 'ISC'} PermissiveLicense */
23
+
24
+ /**
25
+ * @typedef {object} WatermarkProvider
26
+ * @property {string} id
27
+ * @property {PermissiveLicense} license
28
+ * @property {string} packageName
29
+ * @property {(options: object) => Promise<{ outputPath?: string, outDir?: string, dryRun?: boolean, command: string, exitCode?: number, stdout?: string }>} remove
30
+ */
31
+
32
+ /** @type {Record<string, WatermarkProvider>} */
33
+ const PROVIDERS = {
34
+ [PILIO_PROVIDER_ID]: {
35
+ id: PILIO_PROVIDER_ID,
36
+ license: PILIO_LICENSE,
37
+ packageName: '@pilio/gemini-watermark-remover',
38
+ remove: runPilioRemove,
39
+ },
40
+ };
41
+
42
+ export const DEFAULT_WATERMARK_PROVIDER = PILIO_PROVIDER_ID;
43
+
44
+ export const PERMISSIVE_LICENSES = Object.freeze([
45
+ 'MIT',
46
+ 'Apache-2.0',
47
+ 'BSD-2-Clause',
48
+ 'BSD-3-Clause',
49
+ 'ISC',
50
+ ]);
51
+
52
+ /**
53
+ * List registered watermark providers.
54
+ * @returns {Array<{ id: string, license: string, packageName: string }>}
55
+ */
56
+ export function listWatermarkProviders() {
57
+ return Object.values(PROVIDERS).map(({ id, license, packageName }) => ({
58
+ id,
59
+ license,
60
+ packageName,
61
+ }));
62
+ }
63
+
64
+ /**
65
+ * Resolve a watermark provider by id (default: pilio).
66
+ * @param {string} [providerId]
67
+ * @returns {WatermarkProvider}
68
+ */
69
+ export function getWatermarkProvider(providerId = DEFAULT_WATERMARK_PROVIDER) {
70
+ const id = String(providerId || DEFAULT_WATERMARK_PROVIDER).trim().toLowerCase();
71
+ const provider = PROVIDERS[id];
72
+ if (!provider) {
73
+ const known = Object.keys(PROVIDERS).join(', ');
74
+ throw new Error(
75
+ `Unknown watermark provider "${providerId}". Known providers: ${known}. ` +
76
+ `Only permissive-license tools (${PERMISSIVE_LICENSES.join(', ')}) may be registered.`
77
+ );
78
+ }
79
+ if (!PERMISSIVE_LICENSES.includes(provider.license)) {
80
+ throw new Error(
81
+ `Watermark provider "${provider.id}" has disallowed license "${provider.license}". ` +
82
+ `Allowed: ${PERMISSIVE_LICENSES.join(', ')}.`
83
+ );
84
+ }
85
+ return provider;
86
+ }
87
+
88
+ /**
89
+ * Remove a Gemini/Veo visible watermark via the selected provider.
90
+ *
91
+ * @param {object} options
92
+ * @param {string} options.inputPath
93
+ * @param {string} [options.outputPath]
94
+ * @param {string} [options.outDir]
95
+ * @param {string} [options.provider]
96
+ * @param {boolean} [options.overwrite]
97
+ * @param {boolean} [options.json]
98
+ * @param {number} [options.videoBitrateMbps]
99
+ * @param {boolean} [options.dryRun]
100
+ * @param {(chunk: string) => void} [options.onStderr]
101
+ */
102
+ export async function removeWatermarkWithProvider(options = {}) {
103
+ const provider = getWatermarkProvider(options.provider);
104
+ return provider.remove(options);
105
+ }
106
+
107
+ export {
108
+ runPilioRemove,
109
+ formatPilioCommand,
110
+ buildPilioArgs,
111
+ resolvePilioBin,
112
+ PILIO_PROVIDER_ID,
113
+ };