@x12i/youtube-video-uploader-cli 1.4.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 CHANGED
@@ -14,8 +14,11 @@
14
14
  - **๐Ÿ›ก๏ธ Safe Quota Batching**: `--safe-quota`, `--limit`, and `--max-quota` defer excess uploads for the next day (~1,600 units/video + 50/playlist item).
15
15
  - **๐Ÿ“Š Quota Estimation**: Warns when estimated units approach the standard 10,000/day limit.
16
16
  - **๐Ÿ” Playlist Video Swap**: `swap` subcommand replaces a playlist entry in place; old video is unlisted, never deleted. Resumable via `.swap-history.json`.
17
+ - **๐Ÿ”„ Sync Playlist**: `sync-playlist` detects local files newer than upload history, swaps them in-place (private by default), and realigns playlist order with overview prefix + album tracks.
18
+ - **๐Ÿ” Set Access**: `set-access` sets privacy for a playlist and all its videos (`private`, `unlisted`, or `public`).
17
19
  - **๐Ÿท๏ธ Metadata & Title Updates**: `update-metadata` / `update-titles` subcommand to batch update video titles and playlist titles on YouTube and in `metadata.json` (e.g. adding postfixes, prefixes, or syncing).
18
20
  - **๐ŸŽฌ Automated Stock Media Sourcing**: Search Pexels and Pixabay for high-res stock videos/photos, rank and auto-select best matches, cache responses, and compose final `.mp4` files from audio tracks via ffmpeg.
21
+ - **๐Ÿงน Gemini Watermark Removal**: Strip visible Gemini/Veo watermarks from local images and videos via MIT-licensed providers (`remove-watermark` command + `remove-watermark` pipeline action; default provider `@pilio/gemini-watermark-remover`).
19
22
  - **๐Ÿ” Dry Run Mode**: Validate metadata and estimate quota before uploading or updating.
20
23
 
21
24
  ---
@@ -237,11 +240,16 @@ Options:
237
240
  --playlist-postfix <postfix> Postfix to append to playlist title (e.g. " (long versions)")
238
241
  --title-prefix <prefix> Prefix to prepend to each video title
239
242
  --playlist-title <title> Explicit new title for the playlist
243
+ --replace-pattern <pattern> Find/replace pattern in titles (and metadata.json when saving)
244
+ --replace-with <replacement> Replacement string for --replace-pattern
245
+ --replace-literal Treat --replace-pattern as a plain substring (not regex)
246
+ --replace-in-snippet Also replace in YouTube descriptions and tags
247
+ --include-playlist <id> Include all playlist videos (e.g. overview prefix videos)
240
248
  --video <videoId> Target a specific YouTube video ID
241
249
  --title <title> Explicit new title for the targeted video
242
250
  --playlist <playlistId> Target a specific YouTube playlist ID
243
251
  --no-update-file Do not update metadata.json on disk
244
- -n, --dry-run Preview changes without making any API calls
252
+ --dry-run Preview changes without making any API calls
245
253
  --token-file <file> OAuth token path (default: token.json in target dir)
246
254
  --client-secrets-file <file> Path to client_secret.json
247
255
  --client-id <id> Google OAuth Client ID
@@ -283,6 +291,23 @@ npx @x12i/youtube-video-uploader-cli ./my-videos
283
291
  npx @x12i/youtube-video-uploader-cli ./my-videos --client-secrets-file ./client_secret.json
284
292
  ```
285
293
 
294
+ ### 6. Rename band/artist text across playlist titles and metadata
295
+ ```bash
296
+ yvu-cli update-metadata ./my-album \
297
+ --replace-pattern "The White Stoic" \
298
+ --replace-with "White Stoic" \
299
+ --replace-literal \
300
+ --include-playlist PLxxxx \
301
+ --dry-run
302
+
303
+ # Apply:
304
+ yvu-cli update-metadata ./my-album \
305
+ --replace-pattern "The White Stoic" \
306
+ --replace-with "White Stoic" \
307
+ --replace-literal \
308
+ --include-playlist PLxxxx
309
+ ```
310
+
286
311
  ---
287
312
 
288
313
  ## ๐Ÿ” Replacing a Video in a Playlist (`swap`)
@@ -481,6 +506,39 @@ yt-uploader preview-journey 01-first.mp4 --dir ./my-videos
481
506
  yt-uploader preview-journey 01-first.mp4 --center-text "Custom prompt question?" -o ./preview.png
482
507
  ```
483
508
 
509
+ #### Remove Watermark (`remove-watermark`)
510
+ Clean visible Gemini/Veo watermarks from a local file or folder using a permissive-license provider (default: `pilio` / `@pilio/gemini-watermark-remover`):
511
+
512
+ ```bash
513
+ # Single file โ†’ writes stem-clean.ext next to the input
514
+ yt-uploader remove-watermark ./clip.mp4
515
+
516
+ # Explicit output path
517
+ yt-uploader remove-watermark ./clip.mp4 -o ./clean.mp4
518
+
519
+ # Batch a folder into a sibling *-clean directory
520
+ yt-uploader remove-watermark ./gemini-exports --out-dir ./gemini-exports-clean
521
+
522
+ # Preview provider commands
523
+ yt-uploader remove-watermark ./clip.mp4 --dry-run
524
+
525
+ # Higher video encode target (provider-specific)
526
+ yt-uploader remove-watermark ./clip.mp4 -o ./clean.mp4 --video-bitrate-mbps 20 --overwrite
527
+ ```
528
+
529
+ Or as a pipeline action in `metadata.json` / `--actions-file`:
530
+
531
+ ```json
532
+ {
533
+ "type": "remove-watermark",
534
+ "provider": "pilio",
535
+ "videoBitrateMbps": 20,
536
+ "overwrite": true
537
+ }
538
+ ```
539
+
540
+ Only MIT / Apache-2.0 / BSD / ISC providers are registered. Additional providers are added under `src/providers/watermark/` without changing the pipeline.
541
+
484
542
  ---
485
543
 
486
544
  ## ๐Ÿ› ๏ธ Programmatic Node.js API
package/bin/cli.js CHANGED
@@ -11,6 +11,8 @@ import {
11
11
  swapSong,
12
12
  SwapStore,
13
13
  QUOTA_PER_SWAP,
14
+ syncPlaylist,
15
+ setPlaylistAccess,
14
16
  batchUpdateMetadata,
15
17
  searchMedia,
16
18
  rankCandidates,
@@ -18,6 +20,9 @@ import {
18
20
  composeVideo,
19
21
  batchApplyActions,
20
22
  previewJourney,
23
+ batchRemoveWatermark,
24
+ listWatermarkProviders,
25
+ DEFAULT_WATERMARK_PROVIDER,
21
26
  } from '../src/index.js';
22
27
  import { authenticate } from '../src/auth.js';
23
28
 
@@ -248,6 +253,7 @@ program
248
253
  .option('--client-id <id>', 'Google OAuth Client ID')
249
254
  .option('--client-secret <secret>', 'Google OAuth Client Secret')
250
255
  .option('--fresh', 'Discard any previous swap state for this key and start over', false)
256
+ .option('--privacy <preserve|private|public>', 'Final privacy for the new video (default: public)', 'public')
251
257
  .option('-n, --dry-run', 'Print the swap plan without any API calls', false)
252
258
  .option('-q, --quiet', 'Minimal output mode', false)
253
259
  .action(async (videoFile, options) => {
@@ -318,6 +324,7 @@ program
318
324
  stateDir,
319
325
  stateFile: options.stateFile,
320
326
  fresh: options.fresh,
327
+ privacyMode: options.privacy || 'public',
321
328
  onEvent: (event) => {
322
329
  if (isQuiet) return;
323
330
  if (event.type === 'swap_step') {
@@ -337,7 +344,8 @@ program
337
344
  console.log();
338
345
  console.log(pc.green(pc.bold(`๐ŸŽ‰ Swap complete!`)));
339
346
  console.log(` โ€ข Playlist entry replaced: ${pc.bold(options.playlist)} (position preserved)`);
340
- console.log(` โ€ข New video is public : ${pc.underline(pc.blue(`https://youtu.be/${record.newVideoId}`))}`);
347
+ console.log(` โ€ข New video privacy : ${pc.bold(options.privacy || 'public')}`);
348
+ console.log(` โ€ข New video : ${pc.underline(pc.blue(`https://youtu.be/${record.newVideoId}`))}`);
341
349
  console.log(` โ€ข Old video unlisted : ${pc.dim(`https://youtu.be/${options.oldVideo} (direct links still work)`)}`);
342
350
  console.log();
343
351
  }
@@ -347,6 +355,202 @@ program
347
355
  }
348
356
  });
349
357
 
358
+ program
359
+ .command('sync-playlist')
360
+ .description('Push local videos that changed since upload (playlist swap) and realign playlist order โ€” keeps videos private by default')
361
+ .argument('[directory]', 'Album folder with metadata.json and .upload-history.json (default: current directory)', '.')
362
+ .option('--album-dir <path>', 'Explicit album directory path (alias for positional directory)')
363
+ .option('-d, --dir <path>', 'Alias for --album-dir')
364
+ .requiredOption('--playlist <id>', 'YouTube playlist ID to sync')
365
+ .option('--overview-history <file>', 'Upload history JSON for overview/prefix videos')
366
+ .option('--overview-key <file>', 'Overview filename keys in order (repeatable)', (value, prev) => [...prev, value], [])
367
+ .option('--only <filename>', 'Restrict push to specific filenames (repeatable)', (value, prev) => [...prev, value], [])
368
+ .option('--privacy <preserve|private|public>', 'Privacy for swapped videos (default: private)', 'private')
369
+ .option('--skip-reorder', 'Push outdated files without reordering the playlist', false)
370
+ .option('--fresh', 'Discard previous swap state and start swaps fresh', false)
371
+ .option('--token-file <file>', 'OAuth token file (default: token.json in target dir)')
372
+ .option('--client-secrets-file <file>', 'Path to client_secret.json')
373
+ .option('--client-id <id>', 'Google OAuth Client ID')
374
+ .option('--client-secret <secret>', 'Google OAuth Client Secret')
375
+ .option('-n, --dry-run', 'Print the sync plan without API calls')
376
+ .option('-q, --quiet', 'Minimal output mode', false)
377
+ .action(async (directoryArg, options) => {
378
+ const isQuiet = options.quiet;
379
+ const albumDir = path.resolve(options.albumDir || options.dir || directoryArg || process.cwd());
380
+ const dryRun = Boolean(options.dryRun);
381
+
382
+ if (!isQuiet) {
383
+ console.log();
384
+ console.log(pc.bold(pc.red(`๐Ÿ”„ Sync Playlist`)) + ` ${pc.dim(`v${pkg.version}`)}`);
385
+ console.log(pc.dim(`โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€`));
386
+ console.log(`${pc.bold('๐Ÿ“‚ Album Dir :')} ${pc.white(albumDir)}`);
387
+ console.log(`${pc.bold('๐Ÿ“‘ Playlist ID :')} ${pc.white(options.playlist)}`);
388
+ if (options.overviewHistory) {
389
+ console.log(`${pc.bold('๐ŸŽฌ Overview History:')} ${pc.white(path.resolve(options.overviewHistory))}`);
390
+ }
391
+ console.log(`${pc.bold('๐Ÿ”’ Privacy Mode :')} ${pc.white(options.privacy || 'private')}`);
392
+ console.log();
393
+ }
394
+
395
+ try {
396
+ const tokenFile = options.tokenFile
397
+ ? path.resolve(options.tokenFile)
398
+ : path.join(albumDir, 'token.json');
399
+
400
+ let youtube = null;
401
+ if (!dryRun) {
402
+ const auth = await authenticate({
403
+ tokenFile,
404
+ clientSecretsFile: options.clientSecretsFile,
405
+ clientId: options.clientId,
406
+ clientSecret: options.clientSecret,
407
+ });
408
+ youtube = google.youtube({ version: 'v3', auth });
409
+ }
410
+
411
+ const result = await syncPlaylist({
412
+ youtube,
413
+ albumDir,
414
+ playlistId: options.playlist,
415
+ overviewHistoryPath: options.overviewHistory ? path.resolve(options.overviewHistory) : undefined,
416
+ overviewKeys: options.overviewKey,
417
+ onlyFilenames: options.only,
418
+ privacyMode: options.privacy || 'private',
419
+ dryRun,
420
+ skipReorder: options.skipReorder,
421
+ freshSwap: options.fresh,
422
+ onEvent: (event) => {
423
+ if (isQuiet) return;
424
+ if (event.type === 'sync_push_start') {
425
+ console.log(pc.cyan(`โฌ†๏ธ Pushing ${event.filename} (replacing ${event.oldVideoId})โ€ฆ`));
426
+ } else if (event.type === 'sync_push_success') {
427
+ console.log(pc.green(` โœ… ${event.filename} โ†’ ${event.record.newVideoId}`));
428
+ } else if (event.type === 'sync_reorder_success') {
429
+ console.log(pc.green(` ๐Ÿ”€ Reordered playlist (${event.changed} position updates)`));
430
+ }
431
+ },
432
+ });
433
+
434
+ if (!isQuiet) {
435
+ if (dryRun || result.dryRun) {
436
+ console.log(pc.yellow('๐Ÿ” DRY RUN plan'));
437
+ console.log(` Outdated local files: ${result.outdated.length}`);
438
+ for (const item of result.outdated) {
439
+ console.log(` โ€ข ${item.filename} (local ${item.modifiedAt} > uploaded ${item.uploadedAt})`);
440
+ }
441
+ console.log(` Desired order (${result.desiredOrder.length} videos):`);
442
+ result.desiredOrder.forEach((id, i) => console.log(` ${i}. ${id}`));
443
+ if (result.currentOrder?.length) {
444
+ console.log(` Current order aligned: ${result.orderAligned ? 'yes' : 'no'}`);
445
+ }
446
+ console.log();
447
+ } else {
448
+ console.log();
449
+ console.log(pc.green(pc.bold('๐ŸŽ‰ Sync complete')));
450
+ console.log(` โ€ข Pushed : ${result.pushed.length} video(s)`);
451
+ console.log(` โ€ข Reordered: ${result.reordered ? 'yes' : 'already aligned'}`);
452
+ console.log(` โ€ข Playlist : https://www.youtube.com/playlist?list=${options.playlist}`);
453
+ console.log();
454
+ }
455
+ }
456
+ } catch (err) {
457
+ console.error(pc.red(`\nโŒ Error: ${err.message}\n`));
458
+ process.exit(1);
459
+ }
460
+ });
461
+
462
+ program
463
+ .command('set-access')
464
+ .description('Set privacy/access for a playlist and its videos (private, unlisted, or public)')
465
+ .argument('[directory]', 'Album folder for metadata/history updates (default: current directory)', '.')
466
+ .option('--album-dir <path>', 'Explicit album directory path')
467
+ .requiredOption('--playlist <id>', 'YouTube playlist ID')
468
+ .requiredOption('--privacy <private|unlisted|public>', 'Target privacy status')
469
+ .option('--videos <playlist|history>', 'Where to resolve member videos (default: playlist)', 'playlist')
470
+ .option('--overview-history <file>', 'Overview upload history when --videos history')
471
+ .option('--overview-key <file>', 'Overview filename keys in order (repeatable)', (value, prev) => [...prev, value], [])
472
+ .option('--update-metadata', 'Write privacy status into metadata.json', false)
473
+ .option('--token-file <file>', 'OAuth token file (default: token.json in album dir)')
474
+ .option('--client-secrets-file <file>', 'Path to client_secret.json')
475
+ .option('--client-id <id>', 'Google OAuth Client ID')
476
+ .option('--client-secret <secret>', 'Google OAuth Client Secret')
477
+ .option('--dry-run', 'Print the access plan without API updates')
478
+ .option('-q, --quiet', 'Minimal output mode', false)
479
+ .action(async (directoryArg, options) => {
480
+ const isQuiet = options.quiet;
481
+ const albumDir = path.resolve(options.albumDir || directoryArg || process.cwd());
482
+ const parentOpts = program.opts();
483
+ const dryRun = Boolean(options.dryRun || parentOpts.dryRun);
484
+
485
+ if (!isQuiet) {
486
+ console.log();
487
+ console.log(pc.bold(pc.red(`๐Ÿ” Set Access`)) + ` ${pc.dim(`v${pkg.version}`)}`);
488
+ console.log(pc.dim(`โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€`));
489
+ console.log(`${pc.bold('๐Ÿ“‚ Album Dir :')} ${pc.white(albumDir)}`);
490
+ console.log(`${pc.bold('๐Ÿ“‘ Playlist ID :')} ${pc.white(options.playlist)}`);
491
+ console.log(`${pc.bold('๐Ÿ”’ Target Privacy :')} ${pc.white(options.privacy)}`);
492
+ console.log(`${pc.bold('๐ŸŽฌ Video Source :')} ${pc.white(options.videos || 'playlist')}`);
493
+ console.log();
494
+ }
495
+
496
+ try {
497
+ const tokenFile = options.tokenFile
498
+ ? path.resolve(options.tokenFile)
499
+ : path.join(albumDir, 'token.json');
500
+
501
+ let youtube = null;
502
+ const auth = await authenticate({
503
+ tokenFile,
504
+ clientSecretsFile: options.clientSecretsFile,
505
+ clientId: options.clientId,
506
+ clientSecret: options.clientSecret,
507
+ });
508
+ youtube = google.youtube({ version: 'v3', auth });
509
+
510
+ const result = await setPlaylistAccess({
511
+ youtube,
512
+ playlistId: options.playlist,
513
+ privacyStatus: options.privacy,
514
+ videoSource: options.videos || 'playlist',
515
+ albumDir,
516
+ overviewHistoryPath: options.overviewHistory ? path.resolve(options.overviewHistory) : undefined,
517
+ overviewKeys: options.overviewKey,
518
+ updateMetadata: Boolean(options.updateMetadata),
519
+ dryRun,
520
+ onEvent: (event) => {
521
+ if (isQuiet) return;
522
+ if (event.type === 'access_video_start') {
523
+ console.log(pc.cyan(` ๐Ÿ”’ ${event.title || event.videoId} โ†’ ${options.privacy}`));
524
+ }
525
+ },
526
+ });
527
+
528
+ if (!isQuiet) {
529
+ if (dryRun || result.dryRun) {
530
+ console.log(pc.yellow('๐Ÿ” DRY RUN plan'));
531
+ console.log(` Playlist ${result.playlist.playlistId}: ${result.playlist.currentPrivacy || '?'} โ†’ ${result.playlist.targetPrivacy}${result.playlist.changed ? '' : ' (no change)'}`);
532
+ console.log(` Videos (${result.videos.length}):`);
533
+ for (const video of result.videos) {
534
+ const label = video.title || video.videoId;
535
+ console.log(` โ€ข ${label}: ${video.currentPrivacy || '?'} โ†’ ${video.targetPrivacy}${video.changed ? '' : ' (no change)'}`);
536
+ }
537
+ console.log(` Estimated quota: ~${result.estimatedQuota} units`);
538
+ console.log();
539
+ } else {
540
+ console.log();
541
+ console.log(pc.green(pc.bold('๐ŸŽ‰ Access updated')));
542
+ console.log(` โ€ข Playlist : ${result.updated.playlist ? options.privacy : 'unchanged'} (${options.playlist})`);
543
+ console.log(` โ€ข Videos : ${result.updated.videos.length} updated to ${options.privacy}`);
544
+ console.log(` โ€ข URL : https://www.youtube.com/playlist?list=${options.playlist}`);
545
+ console.log();
546
+ }
547
+ }
548
+ } catch (err) {
549
+ console.error(pc.red(`\nโŒ Error: ${err.message}\n`));
550
+ process.exit(1);
551
+ }
552
+ });
553
+
350
554
  program
351
555
  .command('update-metadata')
352
556
  .alias('update-titles')
@@ -360,12 +564,15 @@ program
360
564
  .option('--playlist-title <title>', 'Explicit new title for the playlist')
361
565
  .option('--replace-pattern <pattern>', 'Regex/string pattern to replace in titles')
362
566
  .option('--replace-with <replacement>', 'Replacement string for --replace-pattern', '')
567
+ .option('--replace-literal', 'Treat --replace-pattern as a plain substring (not regex)', false)
568
+ .option('--replace-in-snippet', 'Also replace in descriptions and tags on YouTube', false)
569
+ .option('--include-playlist <id>', 'Include all videos in this playlist (covers overview/prefix videos outside metadata.json)')
363
570
  .option('--sync-metadata', 'Sync titles directly from metadata.json to YouTube')
364
571
  .option('--video <videoId>', 'Target a specific YouTube video ID')
365
572
  .option('--title <title>', 'Explicit new title for the targeted video')
366
573
  .option('--playlist <playlistId>', 'Target a specific YouTube playlist ID')
367
574
  .option('--no-update-file', 'Do not update metadata.json on disk')
368
- .option('-n, --dry-run', 'Preview changes without making any API calls')
575
+ .option('--dry-run', 'Preview changes without making any API calls')
369
576
  .option('--token-file <file>', 'Path to store or read OAuth token (default: token.json in target dir)')
370
577
  .option('--client-secrets-file <file>', 'Path to client_secret.json downloaded from Google Cloud')
371
578
  .option('--client-id <id>', 'Google OAuth Client ID')
@@ -374,12 +581,17 @@ program
374
581
  .action(async (directoryArg, options) => {
375
582
  const targetDir = path.resolve(options.dir || directoryArg || process.cwd());
376
583
  const isQuiet = options.quiet;
584
+ const parentOpts = program.opts();
585
+ const dryRun = Boolean(options.dryRun || parentOpts.dryRun);
377
586
 
378
587
  if (!isQuiet) {
379
588
  console.log();
380
589
  console.log(pc.bold(pc.cyan(`๐Ÿ“ YouTube Metadata & Title Updater`)) + ` ${pc.dim(`v${pkg.version}`)}`);
381
590
  console.log(pc.dim(`โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€`));
382
591
  console.log(`${pc.bold('๐Ÿ“‚ Target Folder :')} ${pc.white(targetDir)}`);
592
+ if (options.replacePattern) {
593
+ console.log(`${pc.bold('๐Ÿ” Replace :')} ${pc.green(JSON.stringify(options.replacePattern))} โ†’ ${pc.green(JSON.stringify(options.replaceWith || ''))}`);
594
+ }
383
595
  if (options.titlePostfix) {
384
596
  console.log(`${pc.bold('๐Ÿท๏ธ Title Postfix :')} ${pc.green(JSON.stringify(options.titlePostfix))}`);
385
597
  }
@@ -389,7 +601,10 @@ program
389
601
  if (options.titlePrefix) {
390
602
  console.log(`${pc.bold('๐Ÿท๏ธ Title Prefix :')} ${pc.green(JSON.stringify(options.titlePrefix))}`);
391
603
  }
392
- if (options.dryRun) {
604
+ if (options.includePlaylist) {
605
+ console.log(`${pc.bold('๐Ÿ“‘ Include Playlist:')} ${pc.white(options.includePlaylist)}`);
606
+ }
607
+ if (dryRun) {
393
608
  console.log(`${pc.yellow(pc.bold('๐Ÿ” Mode :'))} ${pc.yellow('DRY RUN (no updates will be performed)')}`);
394
609
  }
395
610
  console.log();
@@ -407,13 +622,16 @@ program
407
622
  playlistTitle: options.playlistTitle,
408
623
  replacePattern: options.replacePattern,
409
624
  replaceWith: options.replaceWith,
625
+ replaceLiteral: options.replaceLiteral,
626
+ replaceInSnippet: options.replaceInSnippet,
627
+ includePlaylistId: options.includePlaylist,
410
628
  syncMetadata: options.syncMetadata,
411
629
  metadataFile: options.metadata,
412
630
  updateLocalMetadata: options.updateFile !== false,
413
631
  singleVideoId: options.video,
414
632
  singleVideoTitle: options.title,
415
633
  singlePlaylistId: options.playlist,
416
- dryRun: options.dryRun,
634
+ dryRun,
417
635
  tokenFile,
418
636
  clientSecretsFile: options.clientSecretsFile,
419
637
  clientId: options.clientId,
@@ -465,7 +683,7 @@ program
465
683
  });
466
684
 
467
685
  if (!isQuiet) {
468
- if (options.dryRun) {
686
+ if (dryRun) {
469
687
  console.log(pc.yellow(`โœจ Dry run complete. No YouTube updates were performed.`));
470
688
  console.log();
471
689
  process.exit(0);
@@ -733,7 +951,7 @@ program
733
951
 
734
952
  program
735
953
  .command('apply-actions')
736
- .description('Run configured video processing actions (shorten, replace-background, journey-overlay) on videos')
954
+ .description('Run configured video processing actions (shorten, replace-background, journey-overlay, remove-watermark) on videos')
737
955
  .argument('[directory]', 'Source folder containing metadata.json and video files (default: current directory)', '.')
738
956
  .option('-d, --dir <path>', 'Explicit target directory path')
739
957
  .option('-m, --metadata <file>', 'Custom path to metadata.json file')
@@ -866,6 +1084,126 @@ program
866
1084
  }
867
1085
  });
868
1086
 
1087
+ program
1088
+ .command('remove-watermark')
1089
+ .description('Remove Gemini/Veo visible watermarks from a local image/video file or folder (MIT providers)')
1090
+ .argument('[input]', 'Input file or folder (default: current directory)', '.')
1091
+ .option('-o, --output <file>', 'Output file path (single-file input)')
1092
+ .option('--out-dir <dir>', 'Output directory (folder input, or folder for a single file basename)')
1093
+ .option('--provider <id>', `Watermark provider id (default: ${DEFAULT_WATERMARK_PROVIDER})`, DEFAULT_WATERMARK_PROVIDER)
1094
+ .option('--overwrite', 'Overwrite existing outputs', false)
1095
+ .option('-f, --force', 'Alias for --overwrite', false)
1096
+ .option('--video-bitrate-mbps <n>', 'Video encode bitrate in Mbps (provider-specific)')
1097
+ .option('--json', 'Ask the provider for JSON logs when supported', false)
1098
+ .option('-n, --dry-run', 'Print planned provider commands without writing files', false)
1099
+ .option('-q, --quiet', 'Minimal output mode', false)
1100
+ .action(async (inputArg, options, command) => {
1101
+ const parentOpts = command?.parent?.opts() || {};
1102
+ const dryRun = Boolean(options.dryRun || parentOpts.dryRun);
1103
+ const overwrite = Boolean(options.overwrite || options.force || parentOpts.force);
1104
+ const isQuiet = Boolean(options.quiet || parentOpts.quiet);
1105
+ const input = path.resolve(inputArg || '.');
1106
+
1107
+ let videoBitrateMbps;
1108
+ if (options.videoBitrateMbps !== undefined) {
1109
+ videoBitrateMbps = parseFloat(options.videoBitrateMbps);
1110
+ if (!Number.isFinite(videoBitrateMbps) || videoBitrateMbps <= 0) {
1111
+ console.error(pc.red(`\nโŒ Error: Invalid --video-bitrate-mbps value\n`));
1112
+ process.exit(1);
1113
+ }
1114
+ }
1115
+
1116
+ if (!isQuiet) {
1117
+ const providers = listWatermarkProviders()
1118
+ .map((p) => `${p.id} (${p.license})`)
1119
+ .join(', ');
1120
+ console.log();
1121
+ console.log(pc.bold(pc.cyan(`๐Ÿงน Remove Watermark`)) + ` ${pc.dim(`v${pkg.version}`)}`);
1122
+ console.log(pc.dim(`โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€`));
1123
+ console.log(`${pc.bold('๐Ÿ“‚ Input :')} ${pc.white(input)}`);
1124
+ console.log(`${pc.bold('๐Ÿ”Œ Provider :')} ${pc.cyan(options.provider)}`);
1125
+ console.log(`${pc.bold('๐Ÿ“œ Known providers :')} ${pc.dim(providers)}`);
1126
+ if (options.output) {
1127
+ console.log(`${pc.bold('๐Ÿ“„ Output File :')} ${pc.white(path.resolve(options.output))}`);
1128
+ }
1129
+ if (options.outDir) {
1130
+ console.log(`${pc.bold('๐Ÿ“ Output Dir :')} ${pc.white(path.resolve(options.outDir))}`);
1131
+ }
1132
+ if (videoBitrateMbps) {
1133
+ console.log(`${pc.bold('๐ŸŽฅ Video bitrate :')} ${pc.cyan(`${videoBitrateMbps} Mbps`)}`);
1134
+ }
1135
+ if (dryRun) {
1136
+ console.log(`${pc.yellow(pc.bold('๐Ÿ” Mode :'))} ${pc.yellow('DRY RUN (no file writes)')}`);
1137
+ }
1138
+ if (overwrite) {
1139
+ console.log(`${pc.magenta(pc.bold('โšก Overwrite :'))} ${pc.magenta('ENABLED')}`);
1140
+ }
1141
+ console.log();
1142
+ }
1143
+
1144
+ try {
1145
+ const result = await batchRemoveWatermark(input, {
1146
+ output: options.output,
1147
+ outDir: options.outDir,
1148
+ provider: options.provider,
1149
+ overwrite,
1150
+ json: Boolean(options.json),
1151
+ videoBitrateMbps,
1152
+ dryRun,
1153
+ onEvent: (event) => {
1154
+ if (isQuiet) return;
1155
+ if (event.type === 'watermark_plan') {
1156
+ console.log(
1157
+ pc.bold('Found ') +
1158
+ pc.green(pc.bold(`${event.total}`)) +
1159
+ pc.bold(` media file(s) to clean.`)
1160
+ );
1161
+ if (event.outDir) {
1162
+ console.log(`${pc.bold('๐Ÿ“ Output Directory:')} ${pc.cyan(event.outDir)}`);
1163
+ }
1164
+ console.log();
1165
+ } else if (event.type === 'entry_start') {
1166
+ console.log(` ๐Ÿงน [${event.index + 1}/${event.total}] ${pc.bold(event.filename)}`);
1167
+ } else if (event.type === 'entry_complete') {
1168
+ if (dryRun) {
1169
+ console.log(` ${pc.dim(event.command)}`);
1170
+ } else {
1171
+ console.log(` ${pc.green('โœ“')} ${pc.underline(event.outputPath)}`);
1172
+ }
1173
+ } else if (event.type === 'entry_skipped') {
1174
+ console.log(` ${pc.yellow('โญ')} skipped: ${event.reason}`);
1175
+ } else if (event.type === 'entry_failed') {
1176
+ console.log(` ${pc.red('โœ—')} ${event.error?.message || event.error}`);
1177
+ }
1178
+ },
1179
+ });
1180
+
1181
+ if (!isQuiet) {
1182
+ console.log();
1183
+ console.log(pc.bold('Summary'));
1184
+ console.log(` โ€ข Processed : ${pc.green(String(result.processed.length))}`);
1185
+ console.log(` โ€ข Skipped : ${pc.yellow(String(result.skipped.length))}`);
1186
+ console.log(` โ€ข Failed : ${pc.red(String(result.failed.length))}`);
1187
+ console.log();
1188
+ if (dryRun) {
1189
+ console.log(pc.yellow(`โœจ Dry run complete. No files were written.`));
1190
+ console.log();
1191
+ } else if (result.failed.length > 0) {
1192
+ console.log(pc.red(`โš ๏ธ Completed with ${result.failed.length} failure(s).`));
1193
+ process.exit(1);
1194
+ } else {
1195
+ console.log(pc.green(pc.bold(`๐ŸŽ‰ Watermark removal complete!`)));
1196
+ console.log();
1197
+ }
1198
+ } else if (result.failed.length > 0) {
1199
+ process.exit(1);
1200
+ }
1201
+ } catch (err) {
1202
+ console.error(pc.red(`\nโŒ Error: ${err.message}\n`));
1203
+ process.exit(1);
1204
+ }
1205
+ });
1206
+
869
1207
  program
870
1208
  .command('preview-journey')
871
1209
  .description('Generate and preview the journey overlay graphic as a standalone PNG image')
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@x12i/youtube-video-uploader-cli",
3
- "version": "1.4.0",
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.",
3
+ "version": "1.6.1",
4
+ "description": "CLI tool to batch upload MP4 videos to YouTube via YouTube Data API v3 with OAuth2, metadata.json config, playlist assignment, swap, and sync-playlist alignment.",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
7
7
  "type": "module",
@@ -44,6 +44,7 @@
44
44
  "author": "",
45
45
  "license": "MIT",
46
46
  "dependencies": {
47
+ "@pilio/gemini-watermark-remover": "^1.0.41",
47
48
  "@resvg/resvg-js": "^2.6.2",
48
49
  "commander": "^13.1.0",
49
50
  "ffmpeg-static": "^5.2.0",
@@ -53,6 +54,7 @@
53
54
  "mime-types": "^2.1.35",
54
55
  "open": "^10.1.0",
55
56
  "p-limit": "^6.1.0",
56
- "picocolors": "^1.1.1"
57
+ "picocolors": "^1.1.1",
58
+ "sharp": "^0.35.4"
57
59
  }
58
60
  }