@x12i/youtube-video-uploader-cli 1.2.0 → 1.4.0

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.
@@ -0,0 +1,192 @@
1
+ import ffmpeg from 'fluent-ffmpeg';
2
+ import ffmpegStatic from 'ffmpeg-static';
3
+ import ffprobeStatic from 'ffprobe-static';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import {
7
+ searchMedia,
8
+ selectBest,
9
+ downloadAsset,
10
+ composeVideo,
11
+ deriveSearchQuery,
12
+ getMediaDuration,
13
+ } from '../index.js';
14
+
15
+ if (ffmpegStatic && fs.existsSync(ffmpegStatic)) {
16
+ ffmpeg.setFfmpegPath(ffmpegStatic);
17
+ }
18
+ if (ffprobeStatic?.path && fs.existsSync(ffprobeStatic.path)) {
19
+ ffmpeg.setFfprobePath(ffprobeStatic.path);
20
+ }
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
+
47
+ /**
48
+ * Extract audio track from a video file.
49
+ *
50
+ * @param {string} inputVideoPath
51
+ * @param {string} outputAudioPath
52
+ * @returns {Promise<string>}
53
+ */
54
+ export function extractAudio(inputVideoPath, outputAudioPath) {
55
+ if (!fs.existsSync(inputVideoPath)) {
56
+ throw new Error(`Input video file not found: ${inputVideoPath}`);
57
+ }
58
+
59
+ fs.mkdirSync(path.dirname(path.resolve(outputAudioPath)), { recursive: true });
60
+
61
+ return new Promise((resolve, reject) => {
62
+ ffmpeg(inputVideoPath)
63
+ .noVideo()
64
+ .audioCodec('copy')
65
+ .outputOptions(['-movflags', '+faststart'])
66
+ .output(outputAudioPath)
67
+ .on('end', () => resolve(outputAudioPath))
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
+ })
80
+ .run();
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Replace the background visuals of a video while preserving its audio.
86
+ *
87
+ * @param {object} params
88
+ * @param {string} params.inputPath - Source video path
89
+ * @param {string} params.outputPath - Output video path
90
+ * @param {object} params.action - Action definition
91
+ * @param {object} [params.entry] - Entry metadata object
92
+ * @param {object} [params.context] - Pipeline context (keys, cacheDir, tempDir, etc.)
93
+ * @param {boolean} [params.dryRun=false]
94
+ * @returns {Promise<{ outputPath: string, duration?: number, dryRun?: boolean, command?: string, asset?: object }>}
95
+ */
96
+ export async function replaceBackground(params) {
97
+ const {
98
+ inputPath,
99
+ outputPath,
100
+ action = {},
101
+ entry = {},
102
+ context = {},
103
+ dryRun = false,
104
+ } = params;
105
+
106
+ const query = action.query || entry.media?.query || entry.mediaQuery || deriveSearchQuery(entry);
107
+ const mediaType = action.mediaType || action.assetType || entry.media?.type || 'video';
108
+ const provider = action.provider || entry.media?.provider || 'auto';
109
+ const orientation = action.orientation || entry.media?.orientation || 'landscape';
110
+ const mode = action.mode || entry.media?.mode || 'video-loop';
111
+ const assPath = action.assPath || entry.assPath || findCompanionFile(entry.sourcePath || inputPath, 'ass');
112
+
113
+ if (dryRun) {
114
+ const subsNote = assPath ? ` + subtitles (${path.basename(assPath)})` : '';
115
+ const plan = `Replace background: query="${query}" (${provider}, ${orientation}, mode: ${mode}) + audio from ${inputPath}${subsNote} -> ${outputPath}`;
116
+ return {
117
+ outputPath,
118
+ dryRun: true,
119
+ command: plan,
120
+ };
121
+ }
122
+
123
+ const tempDir = context.tempDir || path.dirname(path.resolve(outputPath));
124
+ fs.mkdirSync(tempDir, { recursive: true });
125
+
126
+ const audioExt = '.m4a';
127
+ const extractedAudioPath = path.join(
128
+ tempDir,
129
+ `extracted_audio_${path.basename(inputPath, path.extname(inputPath))}_${Date.now()}${audioExt}`
130
+ );
131
+
132
+ // 1. Extract audio
133
+ await extractAudio(inputPath, extractedAudioPath);
134
+ const audioDuration = await getMediaDuration(extractedAudioPath);
135
+
136
+ // 2. Search stock media
137
+ const searchResults = await searchMedia({
138
+ query,
139
+ type: mediaType,
140
+ provider,
141
+ orientation,
142
+ safeSearch: action.safeSearch !== false,
143
+ pexelsKey: context.pexelsKey,
144
+ pixabayKey: context.pixabayKey,
145
+ cacheDir: context.cacheDir,
146
+ fetchFn: context.fetchFn,
147
+ });
148
+
149
+ // 3. Select best candidate
150
+ const winningAsset = selectBest(searchResults, {
151
+ targetDuration: audioDuration,
152
+ orientation,
153
+ avoidReuse: action.avoidReuse !== false,
154
+ cacheDir: context.cacheDir,
155
+ });
156
+
157
+ if (!winningAsset) {
158
+ throw new Error(`No suitable stock media found for query: "${query}" (provider: ${provider})`);
159
+ }
160
+
161
+ // 4. Download asset
162
+ const downloaded = await downloadAsset(winningAsset, {
163
+ cacheDir: context.cacheDir,
164
+ fetchFn: context.fetchFn,
165
+ });
166
+
167
+ // 5. Compose video
168
+ const composeResult = await composeVideo({
169
+ backgroundPath: downloaded.localPath,
170
+ audioPath: extractedAudioPath,
171
+ outputPath,
172
+ mode,
173
+ targetWidth: action.width || 1920,
174
+ targetHeight: action.height || 1080,
175
+ fps: action.fps || 30,
176
+ assPath,
177
+ dryRun: false,
178
+ });
179
+
180
+ // Clean up temporary extracted audio file
181
+ if (fs.existsSync(extractedAudioPath)) {
182
+ try {
183
+ fs.unlinkSync(extractedAudioPath);
184
+ } catch {}
185
+ }
186
+
187
+ return {
188
+ outputPath,
189
+ duration: composeResult.duration,
190
+ asset: winningAsset,
191
+ };
192
+ }
@@ -0,0 +1,310 @@
1
+ import ffmpeg from 'fluent-ffmpeg';
2
+ import ffmpegStatic from 'ffmpeg-static';
3
+ import ffprobeStatic from 'ffprobe-static';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ if (ffmpegStatic && fs.existsSync(ffmpegStatic)) {
8
+ ffmpeg.setFfmpegPath(ffmpegStatic);
9
+ }
10
+ if (ffprobeStatic?.path && fs.existsSync(ffprobeStatic.path)) {
11
+ ffmpeg.setFfprobePath(ffprobeStatic.path);
12
+ }
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
+
39
+ /**
40
+ * Parse time expression to seconds.
41
+ * Supports: 'mm:ss', 'hh:mm:ss', seconds as string or number.
42
+ *
43
+ * @param {string | number} time
44
+ * @returns {number}
45
+ */
46
+ export function parseTimeSeconds(time) {
47
+ if (typeof time === 'number') {
48
+ if (isNaN(time) || time < 0) {
49
+ throw new Error(`Invalid time duration: ${time}`);
50
+ }
51
+ return time;
52
+ }
53
+
54
+ if (typeof time !== 'string') {
55
+ throw new Error(`Invalid time format: ${time}`);
56
+ }
57
+
58
+ const trimmed = time.trim();
59
+ if (!trimmed) {
60
+ throw new Error('Empty time value provided.');
61
+ }
62
+
63
+ if (trimmed.includes(':')) {
64
+ const parts = trimmed.split(':').map(p => parseFloat(p.trim()));
65
+ if (parts.some(p => isNaN(p) || p < 0)) {
66
+ throw new Error(`Invalid time format: "${time}"`);
67
+ }
68
+ if (parts.length === 2) {
69
+ // mm:ss
70
+ return parts[0] * 60 + parts[1];
71
+ }
72
+ if (parts.length === 3) {
73
+ // hh:mm:ss
74
+ return parts[0] * 3600 + parts[1] * 60 + parts[2];
75
+ }
76
+ throw new Error(`Invalid time format: "${time}"`);
77
+ }
78
+
79
+ const parsed = parseFloat(trimmed);
80
+ if (isNaN(parsed) || parsed < 0) {
81
+ throw new Error(`Invalid time format: "${time}"`);
82
+ }
83
+ return parsed;
84
+ }
85
+
86
+ /**
87
+ * Probe video metadata using ffprobe.
88
+ *
89
+ * @param {string} filePath
90
+ * @returns {Promise<{ duration: number, width: number, height: number, fps: number, sampleRate: number }>}
91
+ */
92
+ export function getVideoMetadata(filePath) {
93
+ return new Promise((resolve, reject) => {
94
+ if (!fs.existsSync(filePath)) {
95
+ return reject(new Error(`File not found: ${filePath}`));
96
+ }
97
+
98
+ ffmpeg.ffprobe(filePath, (err, metadata) => {
99
+ if (err) {
100
+ return reject(new Error(`FFprobe error for ${filePath}: ${err.message}`));
101
+ }
102
+
103
+ const videoStream = metadata.streams?.find(s => s.codec_type === 'video');
104
+ const audioStream = metadata.streams?.find(s => s.codec_type === 'audio');
105
+
106
+ let duration = metadata.format?.duration;
107
+ if (typeof duration === 'string') duration = parseFloat(duration);
108
+ if (!duration && videoStream?.duration) {
109
+ duration = parseFloat(videoStream.duration);
110
+ }
111
+ if (!duration && audioStream?.duration) {
112
+ duration = parseFloat(audioStream.duration);
113
+ }
114
+
115
+ let fps = 30;
116
+ if (videoStream?.r_frame_rate) {
117
+ const [num, den] = videoStream.r_frame_rate.split('/').map(Number);
118
+ if (num && den) {
119
+ fps = Math.round(num / den);
120
+ }
121
+ }
122
+
123
+ resolve({
124
+ duration: duration || 0,
125
+ width: videoStream?.width || 1920,
126
+ height: videoStream?.height || 1080,
127
+ fps: fps || 30,
128
+ sampleRate: parseInt(audioStream?.sample_rate, 10) || 44100,
129
+ });
130
+ });
131
+ });
132
+ }
133
+
134
+ /**
135
+ * Shorten a video by cutting at cutPoint, applying a fade-out before the cutPoint,
136
+ * and appending silent padding buffer after the cut.
137
+ *
138
+ * @param {object} params
139
+ * @param {string} params.inputPath - Source video path
140
+ * @param {string} params.outputPath - Output video path
141
+ * @param {string | number} params.cutPoint - Cut timestamp/seconds
142
+ * @param {number} [params.fadeDuration=3] - Duration of fade out in seconds
143
+ * @param {number} [params.paddingDuration=2] - Duration of silent buffer in seconds
144
+ * @param {'black' | 'freeze-frame'} [params.paddingFill='black']
145
+ * @param {Array<'audio' | 'video'>} [params.fadeTargets=['audio', 'video']]
146
+ * @param {boolean} [params.dryRun=false]
147
+ * @param {(msg: string) => void} [params.onWarn]
148
+ * @returns {Promise<{ outputPath: string, duration: number, dryRun?: boolean, command?: string }>}
149
+ */
150
+ export async function shortenVideo(params) {
151
+ const {
152
+ inputPath,
153
+ outputPath,
154
+ cutPoint,
155
+ fadeDuration = 3,
156
+ paddingDuration = 2,
157
+ paddingFill = 'black',
158
+ fadeTargets = ['audio', 'video'],
159
+ dryRun = false,
160
+ entry = {},
161
+ onWarn,
162
+ } = params;
163
+
164
+ if (cutPoint === undefined || cutPoint === null) {
165
+ throw new Error('Action "shorten" requires "cutPoint" parameter.');
166
+ }
167
+
168
+ const cutSeconds = parseTimeSeconds(cutPoint);
169
+ if (cutSeconds <= 0) {
170
+ throw new Error(`Action "shorten" cutPoint must be greater than 0 (received: ${cutPoint}).`);
171
+ }
172
+
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 };
177
+ if (fs.existsSync(inputPath)) {
178
+ try {
179
+ videoMeta = await getVideoMetadata(inputPath);
180
+ } catch (err) {
181
+ if (!dryRun) {
182
+ throw err;
183
+ }
184
+ }
185
+ }
186
+
187
+ if (videoMeta.duration > 0 && cutSeconds >= videoMeta.duration) {
188
+ throw new Error(
189
+ `cutPoint (${cutSeconds}s) is greater than or equal to source video duration (${videoMeta.duration.toFixed(1)}s).`
190
+ );
191
+ }
192
+
193
+ let effectiveFadeDuration = Math.max(0, fadeDuration);
194
+ let fadeStart = cutSeconds - effectiveFadeDuration;
195
+ if (fadeStart < 0) {
196
+ if (onWarn) {
197
+ onWarn(`fadeDuration (${fadeDuration}s) exceeds cutPoint (${cutSeconds}s); clamping fade start to 0s.`);
198
+ }
199
+ fadeStart = 0;
200
+ effectiveFadeDuration = cutSeconds;
201
+ }
202
+
203
+ const totalDuration = cutSeconds + paddingDuration;
204
+ const width = videoMeta.width || 1920;
205
+ const height = videoMeta.height || 1080;
206
+ const fps = videoMeta.fps || 30;
207
+ const sampleRate = hasSeparateAudio ? 48000 : (videoMeta.sampleRate || 48000);
208
+
209
+ const shouldFadeVideo = fadeTargets.includes('video');
210
+ const shouldFadeAudio = fadeTargets.includes('audio');
211
+
212
+ if (dryRun) {
213
+ const videoFadePart = shouldFadeVideo ? `, fade out at ${fadeStart.toFixed(1)}s for ${effectiveFadeDuration.toFixed(1)}s` : '';
214
+ const audioFadePart = shouldFadeAudio ? `, audio fade out at ${fadeStart.toFixed(1)}s for ${effectiveFadeDuration.toFixed(1)}s` : '';
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}`;
217
+ return {
218
+ outputPath,
219
+ duration: totalDuration,
220
+ dryRun: true,
221
+ command: plan,
222
+ };
223
+ }
224
+
225
+ fs.mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true });
226
+
227
+ return new Promise((resolve, reject) => {
228
+ const command = ffmpeg();
229
+
230
+ // Input 0: source video
231
+ command.input(inputPath);
232
+
233
+ let audioInputIndex = 0;
234
+ if (hasSeparateAudio) {
235
+ command.input(audioSourcePath);
236
+ audioInputIndex = 1;
237
+ }
238
+
239
+ const complexFilters = [];
240
+
241
+ // Filter video 0
242
+ let v0Filter = `trim=0:${cutSeconds},setpts=PTS-STARTPTS`;
243
+ if (shouldFadeVideo && effectiveFadeDuration > 0) {
244
+ v0Filter += `,fade=t=out:st=${fadeStart}:d=${effectiveFadeDuration}`;
245
+ }
246
+ complexFilters.push(`[0:v]${v0Filter}[v0]`);
247
+
248
+ // Filter audio
249
+ let a0Filter = `atrim=0:${cutSeconds},asetpts=PTS-STARTPTS`;
250
+ if (shouldFadeAudio && effectiveFadeDuration > 0) {
251
+ a0Filter += `,afade=t=out:st=${fadeStart}:d=${effectiveFadeDuration}`;
252
+ }
253
+ complexFilters.push(`[${audioInputIndex}:a]${a0Filter}[a0]`);
254
+
255
+ if (paddingDuration > 0) {
256
+ // Audio silence input
257
+ const silenceIndex = audioInputIndex + 1;
258
+ command.input(`anullsrc=r=${sampleRate}:cl=stereo`).inputOptions(['-f', 'lavfi', '-t', `${paddingDuration}`]);
259
+
260
+ if (paddingFill === 'freeze-frame') {
261
+ // Use the last frame of the trimmed portion as padding
262
+ complexFilters.push(`[v0]split=2[v0_out][v0_last]`);
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]`);
264
+ complexFilters.push(`[v0_out][a0][vpad][${silenceIndex}:a]concat=n=2:v=1:a=1[outv][outa]`);
265
+ } else {
266
+ // Plain black padding via lavfi color, forced to match the trimmed video
267
+ const colorIndex = silenceIndex + 1;
268
+ command.input(`color=c=black:s=${width}x${height}:r=${fps}:d=${paddingDuration}`).inputOptions(['-f', 'lavfi']);
269
+ complexFilters.push(`[v0]format=yuv420p,setsar=1[v0f]`);
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]`);
272
+ }
273
+ } else {
274
+ complexFilters.push(`[v0]copy[outv]`);
275
+ complexFilters.push(`[a0]copy[outa]`);
276
+ }
277
+
278
+ command
279
+ .complexFilter(complexFilters)
280
+ .outputOptions([
281
+ '-map', '[outv]',
282
+ '-map', '[outa]',
283
+ '-c:v', 'libx264',
284
+ '-crf', '18',
285
+ '-preset', 'veryfast',
286
+ '-c:a', 'aac',
287
+ '-b:a', '320k',
288
+ '-ar', '48000',
289
+ '-pix_fmt', 'yuv420p',
290
+ '-movflags', '+faststart',
291
+ `-t`, `${totalDuration}`,
292
+ ])
293
+ .output(outputPath)
294
+ .on('end', () => {
295
+ resolve({
296
+ outputPath,
297
+ duration: totalDuration,
298
+ });
299
+ })
300
+ .on('error', (err) => {
301
+ if (fs.existsSync(outputPath)) {
302
+ try {
303
+ fs.unlinkSync(outputPath);
304
+ } catch {}
305
+ }
306
+ reject(new Error(`Shorten video failed: ${err.message}`));
307
+ })
308
+ .run();
309
+ });
310
+ }
@@ -0,0 +1,51 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ /**
5
+ * Deep clone and update metadata for the output directory.
6
+ *
7
+ * @param {object} rawMetadata - Original raw metadata object
8
+ * @param {string} outputDir - Path to output directory
9
+ * @param {Array<object>} results - Array of action pipeline results per entry
10
+ * @returns {object} Cloned metadata object that was written
11
+ */
12
+ export function cloneMetadataForOutput(rawMetadata, outputDir, results = []) {
13
+ if (!outputDir) {
14
+ throw new Error('outputDir is required to clone metadata.');
15
+ }
16
+
17
+ const cloned = JSON.parse(JSON.stringify(rawMetadata || {}));
18
+ const resultMap = new Map(results.map(r => [r.songKey || r.filename, r]));
19
+
20
+ if (cloned.songs && typeof cloned.songs === 'object' && !Array.isArray(cloned.songs)) {
21
+ for (const [songKey, songData] of Object.entries(cloned.songs)) {
22
+ const res = resultMap.get(songKey);
23
+ if (res) {
24
+ if (res.actionsApplied && res.actionsApplied.length > 0) {
25
+ songData.actionsApplied = res.actionsApplied;
26
+ }
27
+ if (res.generatedFilename) {
28
+ songData.filename = res.generatedFilename;
29
+ }
30
+ }
31
+ }
32
+ } else if (Array.isArray(cloned.videos)) {
33
+ for (const video of cloned.videos) {
34
+ const res = resultMap.get(video.filename);
35
+ if (res) {
36
+ if (res.actionsApplied && res.actionsApplied.length > 0) {
37
+ video.actionsApplied = res.actionsApplied;
38
+ }
39
+ if (res.generatedFilename) {
40
+ video.filename = res.generatedFilename;
41
+ }
42
+ }
43
+ }
44
+ }
45
+
46
+ fs.mkdirSync(outputDir, { recursive: true });
47
+ const outputMetadataPath = path.join(outputDir, 'metadata.json');
48
+ fs.writeFileSync(outputMetadataPath, JSON.stringify(cloned, null, 2) + '\n', 'utf-8');
49
+
50
+ return cloned;
51
+ }