@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 +59 -1
- 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 +31 -0
- package/src/actions/remove-watermark.js +109 -0
- package/src/cli/remove-watermark.js +252 -0
- package/src/index.d.ts +147 -1
- package/src/index.js +42 -0
- 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
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { VALID_PRIVACY_STATUSES } from '../metadata.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @typedef {'private' | 'unlisted' | 'public'} PrivacyStatus
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Normalize and validate a privacy status string.
|
|
9
|
+
* @param {string} value
|
|
10
|
+
* @returns {PrivacyStatus}
|
|
11
|
+
*/
|
|
12
|
+
export function normalizePrivacyStatus(value) {
|
|
13
|
+
const status = String(value || '').toLowerCase().trim();
|
|
14
|
+
if (!VALID_PRIVACY_STATUSES.has(status)) {
|
|
15
|
+
throw new Error(`Invalid privacy status: ${value}. Use private, unlisted, or public.`);
|
|
16
|
+
}
|
|
17
|
+
return /** @type {PrivacyStatus} */ (status);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Fetch privacy status for videos (batch up to 50 ids per call).
|
|
22
|
+
*
|
|
23
|
+
* @param {import('googleapis').youtube_v3.Youtube} youtube
|
|
24
|
+
* @param {string[]} videoIds
|
|
25
|
+
* @returns {Promise<Map<string, PrivacyStatus>>}
|
|
26
|
+
*/
|
|
27
|
+
export async function getVideosPrivacy(youtube, videoIds) {
|
|
28
|
+
const map = new Map();
|
|
29
|
+
const ids = [...new Set(videoIds.filter(Boolean))];
|
|
30
|
+
for (let i = 0; i < ids.length; i += 50) {
|
|
31
|
+
const chunk = ids.slice(i, i + 50);
|
|
32
|
+
const { data } = await youtube.videos.list({
|
|
33
|
+
part: ['status', 'snippet'],
|
|
34
|
+
id: chunk,
|
|
35
|
+
});
|
|
36
|
+
for (const item of data.items || []) {
|
|
37
|
+
map.set(item.id, /** @type {PrivacyStatus} */ (item.status?.privacyStatus || 'private'));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return map;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fetch playlist privacy status.
|
|
45
|
+
*
|
|
46
|
+
* @param {import('googleapis').youtube_v3.Youtube} youtube
|
|
47
|
+
* @param {string} playlistId
|
|
48
|
+
* @returns {Promise<PrivacyStatus>}
|
|
49
|
+
*/
|
|
50
|
+
export async function getPlaylistPrivacy(youtube, playlistId) {
|
|
51
|
+
const { data } = await youtube.playlists.list({
|
|
52
|
+
part: ['status'],
|
|
53
|
+
id: [playlistId],
|
|
54
|
+
});
|
|
55
|
+
const item = data.items?.[0];
|
|
56
|
+
if (!item) {
|
|
57
|
+
throw new Error(`Playlist not found or inaccessible: ${playlistId}`);
|
|
58
|
+
}
|
|
59
|
+
return /** @type {PrivacyStatus} */ (item.status?.privacyStatus || 'private');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Update a single video's privacy status.
|
|
64
|
+
*
|
|
65
|
+
* @param {import('googleapis').youtube_v3.Youtube} youtube
|
|
66
|
+
* @param {string} videoId
|
|
67
|
+
* @param {PrivacyStatus} privacyStatus
|
|
68
|
+
* @returns {Promise<{ videoId: string, privacyStatus: PrivacyStatus }>}
|
|
69
|
+
*/
|
|
70
|
+
export async function setVideoPrivacy(youtube, videoId, privacyStatus) {
|
|
71
|
+
const status = normalizePrivacyStatus(privacyStatus);
|
|
72
|
+
await youtube.videos.update({
|
|
73
|
+
part: ['status'],
|
|
74
|
+
requestBody: {
|
|
75
|
+
id: videoId,
|
|
76
|
+
status: { privacyStatus: status },
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
return { videoId, privacyStatus: status };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Update a playlist's privacy status.
|
|
84
|
+
*
|
|
85
|
+
* @param {import('googleapis').youtube_v3.Youtube} youtube
|
|
86
|
+
* @param {string} playlistId
|
|
87
|
+
* @param {PrivacyStatus} privacyStatus
|
|
88
|
+
* @returns {Promise<{ playlistId: string, privacyStatus: PrivacyStatus }>}
|
|
89
|
+
*/
|
|
90
|
+
export async function setPlaylistPrivacy(youtube, playlistId, privacyStatus) {
|
|
91
|
+
const status = normalizePrivacyStatus(privacyStatus);
|
|
92
|
+
|
|
93
|
+
const { data } = await youtube.playlists.list({
|
|
94
|
+
part: ['snippet', 'status'],
|
|
95
|
+
id: [playlistId],
|
|
96
|
+
});
|
|
97
|
+
const existing = data.items?.[0];
|
|
98
|
+
if (!existing?.snippet) {
|
|
99
|
+
throw new Error(`Playlist not found or inaccessible: ${playlistId}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
await youtube.playlists.update({
|
|
103
|
+
part: ['snippet', 'status'],
|
|
104
|
+
requestBody: {
|
|
105
|
+
id: playlistId,
|
|
106
|
+
snippet: existing.snippet,
|
|
107
|
+
status: { privacyStatus: status },
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
return { playlistId, privacyStatus: status };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const QUOTA_PER_PRIVACY_UPDATE = 50;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { UploadState } from '../state.js';
|
|
4
|
+
import { listPlaylistItems } from '../playlist/reorder.js';
|
|
5
|
+
import { resolveDesiredPlaylistOrder } from '../playlist/order.js';
|
|
6
|
+
import {
|
|
7
|
+
getVideosPrivacy,
|
|
8
|
+
getPlaylistPrivacy,
|
|
9
|
+
setVideoPrivacy,
|
|
10
|
+
setPlaylistPrivacy,
|
|
11
|
+
normalizePrivacyStatus,
|
|
12
|
+
QUOTA_PER_PRIVACY_UPDATE,
|
|
13
|
+
} from './privacy.js';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Set privacy/access for a playlist and its member videos.
|
|
17
|
+
*
|
|
18
|
+
* @param {object} options
|
|
19
|
+
* @param {import('googleapis').youtube_v3.Youtube} [options.youtube]
|
|
20
|
+
* @param {string} options.playlistId
|
|
21
|
+
* @param {'private' | 'unlisted' | 'public'} options.privacyStatus
|
|
22
|
+
* @param {string[]} [options.videoIds] - Explicit video IDs (skips playlist listing)
|
|
23
|
+
* @param {'playlist' | 'history'} [options.videoSource='playlist'] - Resolve videos from YouTube playlist or upload history
|
|
24
|
+
* @param {string} [options.albumDir]
|
|
25
|
+
* @param {string} [options.overviewHistoryPath]
|
|
26
|
+
* @param {string[]} [options.overviewKeys]
|
|
27
|
+
* @param {boolean} [options.updateMetadata=false]
|
|
28
|
+
* @param {string} [options.metadataFile]
|
|
29
|
+
* @param {boolean} [options.dryRun=false]
|
|
30
|
+
* @param {(event: object) => void} [options.onEvent]
|
|
31
|
+
* @returns {Promise<object>}
|
|
32
|
+
*/
|
|
33
|
+
export async function setPlaylistAccess(options = {}) {
|
|
34
|
+
const {
|
|
35
|
+
youtube,
|
|
36
|
+
playlistId,
|
|
37
|
+
privacyStatus: rawPrivacy,
|
|
38
|
+
videoSource = 'playlist',
|
|
39
|
+
albumDir,
|
|
40
|
+
overviewHistoryPath,
|
|
41
|
+
overviewKeys = [],
|
|
42
|
+
videoIds: explicitVideoIds,
|
|
43
|
+
updateMetadata = false,
|
|
44
|
+
metadataFile,
|
|
45
|
+
dryRun = false,
|
|
46
|
+
onEvent = () => {},
|
|
47
|
+
} = options;
|
|
48
|
+
|
|
49
|
+
if (!playlistId) {
|
|
50
|
+
throw new Error('setPlaylistAccess requires playlistId');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const privacyStatus = normalizePrivacyStatus(rawPrivacy);
|
|
54
|
+
|
|
55
|
+
/** @type {Array<{ videoId: string, title?: string, position?: number }>} */
|
|
56
|
+
let targets = [];
|
|
57
|
+
|
|
58
|
+
if (Array.isArray(explicitVideoIds) && explicitVideoIds.length > 0) {
|
|
59
|
+
targets = explicitVideoIds.map((videoId, index) => ({ videoId, position: index }));
|
|
60
|
+
} else if (videoSource === 'playlist') {
|
|
61
|
+
if (!youtube && !dryRun) {
|
|
62
|
+
throw new Error('videoSource "playlist" requires a YouTube client');
|
|
63
|
+
}
|
|
64
|
+
if (youtube) {
|
|
65
|
+
const items = await listPlaylistItems(youtube, playlistId);
|
|
66
|
+
targets = items.map((item) => ({
|
|
67
|
+
videoId: item.videoId,
|
|
68
|
+
title: item.title,
|
|
69
|
+
position: item.position,
|
|
70
|
+
}));
|
|
71
|
+
}
|
|
72
|
+
} else if (videoSource === 'history') {
|
|
73
|
+
if (!albumDir) {
|
|
74
|
+
throw new Error('videoSource "history" requires albumDir');
|
|
75
|
+
}
|
|
76
|
+
const albumState = new UploadState(albumDir);
|
|
77
|
+
const videoIds = resolveDesiredPlaylistOrder({
|
|
78
|
+
albumDir,
|
|
79
|
+
albumState,
|
|
80
|
+
overviewHistoryPath,
|
|
81
|
+
overviewKeys,
|
|
82
|
+
});
|
|
83
|
+
targets = videoIds.map((videoId) => ({ videoId }));
|
|
84
|
+
} else {
|
|
85
|
+
throw new Error(`Unsupported videoSource: ${videoSource}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (targets.length === 0 && dryRun && videoSource === 'playlist') {
|
|
89
|
+
// Dry-run without youtube: cannot list playlist — caller should use history
|
|
90
|
+
throw new Error('Dry-run with videoSource "playlist" requires YouTube auth to list playlist items');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let currentPlaylistPrivacy = null;
|
|
94
|
+
let currentVideoPrivacy = new Map();
|
|
95
|
+
|
|
96
|
+
if (youtube) {
|
|
97
|
+
currentPlaylistPrivacy = await getPlaylistPrivacy(youtube, playlistId);
|
|
98
|
+
currentVideoPrivacy = await getVideosPrivacy(youtube, targets.map((t) => t.videoId));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const videoPlans = targets.map((target) => {
|
|
102
|
+
const current = currentVideoPrivacy.get(target.videoId) || null;
|
|
103
|
+
return {
|
|
104
|
+
...target,
|
|
105
|
+
currentPrivacy: current,
|
|
106
|
+
targetPrivacy: privacyStatus,
|
|
107
|
+
changed: current !== null ? current !== privacyStatus : true,
|
|
108
|
+
};
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const playlistChanged = currentPlaylistPrivacy !== null
|
|
112
|
+
? currentPlaylistPrivacy !== privacyStatus
|
|
113
|
+
: true;
|
|
114
|
+
|
|
115
|
+
const changedVideos = videoPlans.filter((v) => v.changed);
|
|
116
|
+
const estimatedQuota = (playlistChanged ? QUOTA_PER_PRIVACY_UPDATE : 0)
|
|
117
|
+
+ (changedVideos.length * QUOTA_PER_PRIVACY_UPDATE);
|
|
118
|
+
|
|
119
|
+
const plan = {
|
|
120
|
+
playlistId,
|
|
121
|
+
privacyStatus,
|
|
122
|
+
videoSource,
|
|
123
|
+
playlist: {
|
|
124
|
+
playlistId,
|
|
125
|
+
currentPrivacy: currentPlaylistPrivacy,
|
|
126
|
+
targetPrivacy: privacyStatus,
|
|
127
|
+
changed: playlistChanged,
|
|
128
|
+
},
|
|
129
|
+
videos: videoPlans,
|
|
130
|
+
changedCount: changedVideos.length + (playlistChanged ? 1 : 0),
|
|
131
|
+
estimatedQuota,
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
onEvent({ type: 'access_plan', plan });
|
|
135
|
+
|
|
136
|
+
if (dryRun) {
|
|
137
|
+
return { dryRun: true, ...plan, updated: { playlist: false, videos: [] } };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (!youtube) {
|
|
141
|
+
throw new Error('setPlaylistAccess requires a YouTube client when dryRun is false');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const updated = { playlist: false, videos: [] };
|
|
145
|
+
|
|
146
|
+
if (playlistChanged) {
|
|
147
|
+
onEvent({ type: 'access_playlist_start', playlistId, privacyStatus });
|
|
148
|
+
await setPlaylistPrivacy(youtube, playlistId, privacyStatus);
|
|
149
|
+
updated.playlist = true;
|
|
150
|
+
onEvent({ type: 'access_playlist_success', playlistId, privacyStatus });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (let i = 0; i < changedVideos.length; i += 1) {
|
|
154
|
+
const video = changedVideos[i];
|
|
155
|
+
onEvent({
|
|
156
|
+
type: 'access_video_start',
|
|
157
|
+
videoId: video.videoId,
|
|
158
|
+
title: video.title,
|
|
159
|
+
index: i,
|
|
160
|
+
total: changedVideos.length,
|
|
161
|
+
});
|
|
162
|
+
await setVideoPrivacy(youtube, video.videoId, privacyStatus);
|
|
163
|
+
updated.videos.push({ videoId: video.videoId, privacyStatus });
|
|
164
|
+
onEvent({ type: 'access_video_success', videoId: video.videoId, privacyStatus });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (updateMetadata && albumDir) {
|
|
168
|
+
const metadataPath = metadataFile
|
|
169
|
+
? path.resolve(metadataFile)
|
|
170
|
+
: path.join(path.resolve(albumDir), 'metadata.json');
|
|
171
|
+
if (fs.existsSync(metadataPath)) {
|
|
172
|
+
try {
|
|
173
|
+
const raw = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
|
|
174
|
+
if (raw.uploadPolicy) {
|
|
175
|
+
raw.uploadPolicy.initialVideoPrivacyStatus = privacyStatus;
|
|
176
|
+
raw.uploadPolicy.initialPlaylistPrivacyStatus = privacyStatus;
|
|
177
|
+
}
|
|
178
|
+
if (raw.playlist?.youtube?.status) {
|
|
179
|
+
raw.playlist.youtube.status.privacyStatus = privacyStatus;
|
|
180
|
+
} else if (raw.playlist?.status) {
|
|
181
|
+
raw.playlist.status.privacyStatus = privacyStatus;
|
|
182
|
+
}
|
|
183
|
+
if (raw.songs && typeof raw.songs === 'object') {
|
|
184
|
+
for (const song of Object.values(raw.songs)) {
|
|
185
|
+
if (song?.youtube?.status) {
|
|
186
|
+
song.youtube.status.privacyStatus = privacyStatus;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
fs.writeFileSync(metadataPath, JSON.stringify(raw, null, 2) + '\n', 'utf-8');
|
|
191
|
+
onEvent({ type: 'access_metadata_saved', metadataPath });
|
|
192
|
+
} catch (err) {
|
|
193
|
+
onEvent({ type: 'access_metadata_error', error: err });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
dryRun: false,
|
|
200
|
+
...plan,
|
|
201
|
+
updated,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Set privacy for explicit video IDs (no playlist update).
|
|
207
|
+
*
|
|
208
|
+
* @param {object} options
|
|
209
|
+
* @param {import('googleapis').youtube_v3.Youtube} options.youtube
|
|
210
|
+
* @param {string[]} options.videoIds
|
|
211
|
+
* @param {'private' | 'unlisted' | 'public'} options.privacyStatus
|
|
212
|
+
* @param {boolean} [options.dryRun=false]
|
|
213
|
+
* @returns {Promise<object>}
|
|
214
|
+
*/
|
|
215
|
+
export async function setVideosAccess(options = {}) {
|
|
216
|
+
const { youtube, videoIds = [], privacyStatus: rawPrivacy, dryRun = false } = options;
|
|
217
|
+
const privacyStatus = normalizePrivacyStatus(rawPrivacy);
|
|
218
|
+
const ids = [...new Set(videoIds.filter(Boolean))];
|
|
219
|
+
|
|
220
|
+
if (ids.length === 0) {
|
|
221
|
+
throw new Error('setVideosAccess requires at least one videoId');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const current = youtube ? await getVideosPrivacy(youtube, ids) : new Map();
|
|
225
|
+
const plans = ids.map((videoId) => ({
|
|
226
|
+
videoId,
|
|
227
|
+
currentPrivacy: current.get(videoId) || null,
|
|
228
|
+
targetPrivacy: privacyStatus,
|
|
229
|
+
changed: current.get(videoId) !== privacyStatus,
|
|
230
|
+
}));
|
|
231
|
+
|
|
232
|
+
if (dryRun) {
|
|
233
|
+
return { dryRun: true, videos: plans, changedCount: plans.filter((p) => p.changed).length };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (!youtube) {
|
|
237
|
+
throw new Error('setVideosAccess requires a YouTube client');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const updated = [];
|
|
241
|
+
for (const plan of plans.filter((p) => p.changed)) {
|
|
242
|
+
await setVideoPrivacy(youtube, plan.videoId, privacyStatus);
|
|
243
|
+
updated.push(plan.videoId);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return { dryRun: false, videos: plans, updated };
|
|
247
|
+
}
|
package/src/actions/index.js
CHANGED
|
@@ -9,6 +9,12 @@ import {
|
|
|
9
9
|
renderSvgToPng,
|
|
10
10
|
resolveJourneyTracks,
|
|
11
11
|
} from './journey-overlay.js';
|
|
12
|
+
import {
|
|
13
|
+
removeWatermark,
|
|
14
|
+
isWatermarkSupportedMedia,
|
|
15
|
+
defaultCleanOutputPath,
|
|
16
|
+
defaultCleanOutDir,
|
|
17
|
+
} from './remove-watermark.js';
|
|
12
18
|
|
|
13
19
|
export {
|
|
14
20
|
shortenVideo,
|
|
@@ -20,6 +26,10 @@ export {
|
|
|
20
26
|
generateJourneySvg,
|
|
21
27
|
renderSvgToPng,
|
|
22
28
|
resolveJourneyTracks,
|
|
29
|
+
removeWatermark,
|
|
30
|
+
isWatermarkSupportedMedia,
|
|
31
|
+
defaultCleanOutputPath,
|
|
32
|
+
defaultCleanOutDir,
|
|
23
33
|
};
|
|
24
34
|
|
|
25
35
|
/**
|
|
@@ -144,6 +154,15 @@ export async function runActionsPipeline(entry, actions = [], context = {}) {
|
|
|
144
154
|
onWarn,
|
|
145
155
|
});
|
|
146
156
|
steps.push(`[${i + 1}/${actions.length}] ${res.command}`);
|
|
157
|
+
} else if (action.type === 'remove-watermark') {
|
|
158
|
+
const res = await removeWatermark({
|
|
159
|
+
inputPath: currentInput,
|
|
160
|
+
outputPath: stepOutput,
|
|
161
|
+
action,
|
|
162
|
+
dryRun: true,
|
|
163
|
+
onWarn,
|
|
164
|
+
});
|
|
165
|
+
steps.push(`[${i + 1}/${actions.length}] ${res.command}`);
|
|
147
166
|
} else {
|
|
148
167
|
throw new Error(`Unknown action type: "${action.type}" on entry ${songKey}`);
|
|
149
168
|
}
|
|
@@ -232,6 +251,18 @@ export async function runActionsPipeline(entry, actions = [], context = {}) {
|
|
|
232
251
|
dryRun: false,
|
|
233
252
|
onWarn,
|
|
234
253
|
});
|
|
254
|
+
} else if (action.type === 'remove-watermark') {
|
|
255
|
+
await removeWatermark({
|
|
256
|
+
inputPath: currentInput,
|
|
257
|
+
outputPath: stepOutput,
|
|
258
|
+
action: {
|
|
259
|
+
...action,
|
|
260
|
+
// Pipeline targets are always freshly planned; allow overwrite of step outputs.
|
|
261
|
+
overwrite: action.overwrite !== undefined ? action.overwrite : true,
|
|
262
|
+
},
|
|
263
|
+
dryRun: false,
|
|
264
|
+
onWarn,
|
|
265
|
+
});
|
|
235
266
|
} else {
|
|
236
267
|
throw new Error(`Unknown action type: "${action.type}" on entry ${songKey}`);
|
|
237
268
|
}
|
|
@@ -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
|
+
}
|