@x12i/youtube-video-uploader-cli 1.1.0 → 1.3.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,154 @@
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
+ * Extract audio track from a video file.
24
+ *
25
+ * @param {string} inputVideoPath
26
+ * @param {string} outputAudioPath
27
+ * @returns {Promise<string>}
28
+ */
29
+ export function extractAudio(inputVideoPath, outputAudioPath) {
30
+ if (!fs.existsSync(inputVideoPath)) {
31
+ throw new Error(`Input video file not found: ${inputVideoPath}`);
32
+ }
33
+
34
+ fs.mkdirSync(path.dirname(path.resolve(outputAudioPath)), { recursive: true });
35
+
36
+ return new Promise((resolve, reject) => {
37
+ ffmpeg(inputVideoPath)
38
+ .noVideo()
39
+ .audioCodec('aac')
40
+ .audioBitrate('192k')
41
+ .outputOptions(['-movflags', '+faststart'])
42
+ .output(outputAudioPath)
43
+ .on('end', () => resolve(outputAudioPath))
44
+ .on('error', (err) => reject(new Error(`Failed to extract audio from ${inputVideoPath}: ${err.message}`)))
45
+ .run();
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Replace the background visuals of a video while preserving its audio.
51
+ *
52
+ * @param {object} params
53
+ * @param {string} params.inputPath - Source video path
54
+ * @param {string} params.outputPath - Output video path
55
+ * @param {object} params.action - Action definition
56
+ * @param {object} [params.entry] - Entry metadata object
57
+ * @param {object} [params.context] - Pipeline context (keys, cacheDir, tempDir, etc.)
58
+ * @param {boolean} [params.dryRun=false]
59
+ * @returns {Promise<{ outputPath: string, duration?: number, dryRun?: boolean, command?: string, asset?: object }>}
60
+ */
61
+ export async function replaceBackground(params) {
62
+ const {
63
+ inputPath,
64
+ outputPath,
65
+ action = {},
66
+ entry = {},
67
+ context = {},
68
+ dryRun = false,
69
+ } = params;
70
+
71
+ const query = action.query || entry.media?.query || entry.mediaQuery || deriveSearchQuery(entry);
72
+ const mediaType = action.mediaType || action.assetType || entry.media?.type || 'video';
73
+ const provider = action.provider || entry.media?.provider || 'auto';
74
+ const orientation = action.orientation || entry.media?.orientation || 'landscape';
75
+ const mode = action.mode || entry.media?.mode || 'video-loop';
76
+
77
+ if (dryRun) {
78
+ const plan = `Replace background: query="${query}" (${provider}, ${orientation}, mode: ${mode}) + audio from ${inputPath} -> ${outputPath}`;
79
+ return {
80
+ outputPath,
81
+ dryRun: true,
82
+ command: plan,
83
+ };
84
+ }
85
+
86
+ const tempDir = context.tempDir || path.dirname(path.resolve(outputPath));
87
+ fs.mkdirSync(tempDir, { recursive: true });
88
+
89
+ const audioExt = '.m4a';
90
+ const extractedAudioPath = path.join(
91
+ tempDir,
92
+ `extracted_audio_${path.basename(inputPath, path.extname(inputPath))}_${Date.now()}${audioExt}`
93
+ );
94
+
95
+ // 1. Extract audio
96
+ await extractAudio(inputPath, extractedAudioPath);
97
+ const audioDuration = await getMediaDuration(extractedAudioPath);
98
+
99
+ // 2. Search stock media
100
+ const searchResults = await searchMedia({
101
+ query,
102
+ type: mediaType,
103
+ provider,
104
+ orientation,
105
+ safeSearch: action.safeSearch !== false,
106
+ pexelsKey: context.pexelsKey,
107
+ pixabayKey: context.pixabayKey,
108
+ cacheDir: context.cacheDir,
109
+ fetchFn: context.fetchFn,
110
+ });
111
+
112
+ // 3. Select best candidate
113
+ const winningAsset = selectBest(searchResults, {
114
+ targetDuration: audioDuration,
115
+ orientation,
116
+ avoidReuse: action.avoidReuse !== false,
117
+ cacheDir: context.cacheDir,
118
+ });
119
+
120
+ if (!winningAsset) {
121
+ throw new Error(`No suitable stock media found for query: "${query}" (provider: ${provider})`);
122
+ }
123
+
124
+ // 4. Download asset
125
+ const downloaded = await downloadAsset(winningAsset, {
126
+ cacheDir: context.cacheDir,
127
+ fetchFn: context.fetchFn,
128
+ });
129
+
130
+ // 5. Compose video
131
+ const composeResult = await composeVideo({
132
+ backgroundPath: downloaded.localPath,
133
+ audioPath: extractedAudioPath,
134
+ outputPath,
135
+ mode,
136
+ targetWidth: action.width || 1920,
137
+ targetHeight: action.height || 1080,
138
+ fps: action.fps || 30,
139
+ dryRun: false,
140
+ });
141
+
142
+ // Clean up temporary extracted audio file
143
+ if (fs.existsSync(extractedAudioPath)) {
144
+ try {
145
+ fs.unlinkSync(extractedAudioPath);
146
+ } catch {}
147
+ }
148
+
149
+ return {
150
+ outputPath,
151
+ duration: composeResult.duration,
152
+ asset: winningAsset,
153
+ };
154
+ }
@@ -0,0 +1,271 @@
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
+ * Parse time expression to seconds.
16
+ * Supports: 'mm:ss', 'hh:mm:ss', seconds as string or number.
17
+ *
18
+ * @param {string | number} time
19
+ * @returns {number}
20
+ */
21
+ export function parseTimeSeconds(time) {
22
+ if (typeof time === 'number') {
23
+ if (isNaN(time) || time < 0) {
24
+ throw new Error(`Invalid time duration: ${time}`);
25
+ }
26
+ return time;
27
+ }
28
+
29
+ if (typeof time !== 'string') {
30
+ throw new Error(`Invalid time format: ${time}`);
31
+ }
32
+
33
+ const trimmed = time.trim();
34
+ if (!trimmed) {
35
+ throw new Error('Empty time value provided.');
36
+ }
37
+
38
+ if (trimmed.includes(':')) {
39
+ const parts = trimmed.split(':').map(p => parseFloat(p.trim()));
40
+ if (parts.some(p => isNaN(p) || p < 0)) {
41
+ throw new Error(`Invalid time format: "${time}"`);
42
+ }
43
+ if (parts.length === 2) {
44
+ // mm:ss
45
+ return parts[0] * 60 + parts[1];
46
+ }
47
+ if (parts.length === 3) {
48
+ // hh:mm:ss
49
+ return parts[0] * 3600 + parts[1] * 60 + parts[2];
50
+ }
51
+ throw new Error(`Invalid time format: "${time}"`);
52
+ }
53
+
54
+ const parsed = parseFloat(trimmed);
55
+ if (isNaN(parsed) || parsed < 0) {
56
+ throw new Error(`Invalid time format: "${time}"`);
57
+ }
58
+ return parsed;
59
+ }
60
+
61
+ /**
62
+ * Probe video metadata using ffprobe.
63
+ *
64
+ * @param {string} filePath
65
+ * @returns {Promise<{ duration: number, width: number, height: number, fps: number, sampleRate: number }>}
66
+ */
67
+ export function getVideoMetadata(filePath) {
68
+ return new Promise((resolve, reject) => {
69
+ if (!fs.existsSync(filePath)) {
70
+ return reject(new Error(`File not found: ${filePath}`));
71
+ }
72
+
73
+ ffmpeg.ffprobe(filePath, (err, metadata) => {
74
+ if (err) {
75
+ return reject(new Error(`FFprobe error for ${filePath}: ${err.message}`));
76
+ }
77
+
78
+ const videoStream = metadata.streams?.find(s => s.codec_type === 'video');
79
+ const audioStream = metadata.streams?.find(s => s.codec_type === 'audio');
80
+
81
+ let duration = metadata.format?.duration;
82
+ if (typeof duration === 'string') duration = parseFloat(duration);
83
+ if (!duration && videoStream?.duration) {
84
+ duration = parseFloat(videoStream.duration);
85
+ }
86
+ if (!duration && audioStream?.duration) {
87
+ duration = parseFloat(audioStream.duration);
88
+ }
89
+
90
+ let fps = 30;
91
+ if (videoStream?.r_frame_rate) {
92
+ const [num, den] = videoStream.r_frame_rate.split('/').map(Number);
93
+ if (num && den) {
94
+ fps = Math.round(num / den);
95
+ }
96
+ }
97
+
98
+ resolve({
99
+ duration: duration || 0,
100
+ width: videoStream?.width || 1920,
101
+ height: videoStream?.height || 1080,
102
+ fps: fps || 30,
103
+ sampleRate: parseInt(audioStream?.sample_rate, 10) || 44100,
104
+ });
105
+ });
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Shorten a video by cutting at cutPoint, applying a fade-out before the cutPoint,
111
+ * and appending silent padding buffer after the cut.
112
+ *
113
+ * @param {object} params
114
+ * @param {string} params.inputPath - Source video path
115
+ * @param {string} params.outputPath - Output video path
116
+ * @param {string | number} params.cutPoint - Cut timestamp/seconds
117
+ * @param {number} [params.fadeDuration=3] - Duration of fade out in seconds
118
+ * @param {number} [params.paddingDuration=2] - Duration of silent buffer in seconds
119
+ * @param {'black' | 'freeze-frame'} [params.paddingFill='black']
120
+ * @param {Array<'audio' | 'video'>} [params.fadeTargets=['audio', 'video']]
121
+ * @param {boolean} [params.dryRun=false]
122
+ * @param {(msg: string) => void} [params.onWarn]
123
+ * @returns {Promise<{ outputPath: string, duration: number, dryRun?: boolean, command?: string }>}
124
+ */
125
+ export async function shortenVideo(params) {
126
+ const {
127
+ inputPath,
128
+ outputPath,
129
+ cutPoint,
130
+ fadeDuration = 3,
131
+ paddingDuration = 2,
132
+ paddingFill = 'black',
133
+ fadeTargets = ['audio', 'video'],
134
+ dryRun = false,
135
+ onWarn,
136
+ } = params;
137
+
138
+ if (cutPoint === undefined || cutPoint === null) {
139
+ throw new Error('Action "shorten" requires "cutPoint" parameter.');
140
+ }
141
+
142
+ const cutSeconds = parseTimeSeconds(cutPoint);
143
+ if (cutSeconds <= 0) {
144
+ throw new Error(`Action "shorten" cutPoint must be greater than 0 (received: ${cutPoint}).`);
145
+ }
146
+
147
+ let videoMeta = { duration: 0, width: 1920, height: 1080, fps: 30, sampleRate: 44100 };
148
+ if (fs.existsSync(inputPath)) {
149
+ try {
150
+ videoMeta = await getVideoMetadata(inputPath);
151
+ } catch (err) {
152
+ if (!dryRun) {
153
+ throw err;
154
+ }
155
+ }
156
+ }
157
+
158
+ if (videoMeta.duration > 0 && cutSeconds >= videoMeta.duration) {
159
+ throw new Error(
160
+ `cutPoint (${cutSeconds}s) is greater than or equal to source video duration (${videoMeta.duration.toFixed(1)}s).`
161
+ );
162
+ }
163
+
164
+ let effectiveFadeDuration = Math.max(0, fadeDuration);
165
+ let fadeStart = cutSeconds - effectiveFadeDuration;
166
+ if (fadeStart < 0) {
167
+ if (onWarn) {
168
+ onWarn(`fadeDuration (${fadeDuration}s) exceeds cutPoint (${cutSeconds}s); clamping fade start to 0s.`);
169
+ }
170
+ fadeStart = 0;
171
+ effectiveFadeDuration = cutSeconds;
172
+ }
173
+
174
+ const totalDuration = cutSeconds + paddingDuration;
175
+ const width = videoMeta.width || 1920;
176
+ const height = videoMeta.height || 1080;
177
+ const fps = videoMeta.fps || 30;
178
+ const sampleRate = videoMeta.sampleRate || 44100;
179
+
180
+ const shouldFadeVideo = fadeTargets.includes('video');
181
+ const shouldFadeAudio = fadeTargets.includes('audio');
182
+
183
+ if (dryRun) {
184
+ const videoFadePart = shouldFadeVideo ? `, fade out at ${fadeStart.toFixed(1)}s for ${effectiveFadeDuration.toFixed(1)}s` : '';
185
+ const audioFadePart = shouldFadeAudio ? `, audio fade out at ${fadeStart.toFixed(1)}s for ${effectiveFadeDuration.toFixed(1)}s` : '';
186
+ const plan = `Shorten: trim to ${cutSeconds.toFixed(1)}s${videoFadePart}${audioFadePart} + ${paddingDuration}s silent padding (${paddingFill}) -> ${outputPath}`;
187
+ return {
188
+ outputPath,
189
+ duration: totalDuration,
190
+ dryRun: true,
191
+ command: plan,
192
+ };
193
+ }
194
+
195
+ fs.mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true });
196
+
197
+ return new Promise((resolve, reject) => {
198
+ const command = ffmpeg();
199
+
200
+ // Input 0: source video
201
+ command.input(inputPath);
202
+
203
+ const complexFilters = [];
204
+
205
+ // Filter video 0
206
+ let v0Filter = `trim=0:${cutSeconds},setpts=PTS-STARTPTS`;
207
+ if (shouldFadeVideo && effectiveFadeDuration > 0) {
208
+ v0Filter += `,fade=t=out:st=${fadeStart}:d=${effectiveFadeDuration}`;
209
+ }
210
+ complexFilters.push(`[0:v]${v0Filter}[v0]`);
211
+
212
+ // Filter audio 0
213
+ let a0Filter = `atrim=0:${cutSeconds},asetpts=PTS-STARTPTS`;
214
+ if (shouldFadeAudio && effectiveFadeDuration > 0) {
215
+ a0Filter += `,afade=t=out:st=${fadeStart}:d=${effectiveFadeDuration}`;
216
+ }
217
+ complexFilters.push(`[0:a]${a0Filter}[a0]`);
218
+
219
+ if (paddingDuration > 0) {
220
+ // Input 1 for audio padding: anullsrc
221
+ command.input(`anullsrc=r=${sampleRate}:cl=stereo`).inputOptions(['-f', 'lavfi', '-t', `${paddingDuration}`]);
222
+
223
+ if (paddingFill === 'freeze-frame') {
224
+ // Use the last frame of the trimmed portion as padding
225
+ complexFilters.push(`[v0]split=2[v0_out][v0_last]`);
226
+ complexFilters.push(`[v0_last]trim=${Math.max(0, cutSeconds - 0.04)}:${cutSeconds},setpts=PTS-STARTPTS,loop=loop=-1:size=1:start=0,trim=0:${paddingDuration},setpts=PTS-STARTPTS[vpad]`);
227
+ complexFilters.push(`[v0_out][a0][vpad][1:a]concat=n=2:v=1:a=1[outv][outa]`);
228
+ } else {
229
+ // Plain black padding via lavfi color, forced to match the trimmed video
230
+ command.input(`color=c=black:s=${width}x${height}:r=${fps}:d=${paddingDuration}`).inputOptions(['-f', 'lavfi']);
231
+ complexFilters.push(`[v0]format=yuv420p,setsar=1[v0f]`);
232
+ complexFilters.push(`[2:v]scale=${width}:${height}:flags=neighbor,setsar=1,fps=${fps},format=yuv420p[vpad]`);
233
+ complexFilters.push(`[v0f][a0][vpad][1:a]concat=n=2:v=1:a=1[outv][outa]`);
234
+ }
235
+ } else {
236
+ complexFilters.push(`[v0]copy[outv]`);
237
+ complexFilters.push(`[a0]copy[outa]`);
238
+ }
239
+
240
+ command
241
+ .complexFilter(complexFilters)
242
+ .outputOptions([
243
+ '-map', '[outv]',
244
+ '-map', '[outa]',
245
+ '-c:v', 'libx264',
246
+ '-crf', '18',
247
+ '-preset', 'veryfast',
248
+ '-c:a', 'aac',
249
+ '-b:a', '192k',
250
+ '-pix_fmt', 'yuv420p',
251
+ '-movflags', '+faststart',
252
+ `-t`, `${totalDuration}`,
253
+ ])
254
+ .output(outputPath)
255
+ .on('end', () => {
256
+ resolve({
257
+ outputPath,
258
+ duration: totalDuration,
259
+ });
260
+ })
261
+ .on('error', (err) => {
262
+ if (fs.existsSync(outputPath)) {
263
+ try {
264
+ fs.unlinkSync(outputPath);
265
+ } catch {}
266
+ }
267
+ reject(new Error(`Shorten video failed: ${err.message}`));
268
+ })
269
+ .run();
270
+ });
271
+ }
package/src/auth.js CHANGED
@@ -8,7 +8,7 @@ import pc from 'picocolors';
8
8
 
9
9
  export const YOUTUBE_SCOPES = [
10
10
  'https://www.googleapis.com/auth/youtube.upload',
11
- 'https://www.googleapis.com/auth/youtube',
11
+ 'https://www.googleapis.com/auth/youtube.force-ssl',
12
12
  ];
13
13
 
14
14
  export const DEFAULT_REDIRECT_URI = 'http://localhost:3000/oauth2callback';
@@ -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
+ }
@@ -0,0 +1,194 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import { loadMetadata } from '../metadata.js';
4
+
5
+ /**
6
+ * Determine the output directory based on source directory and options.
7
+ *
8
+ * @param {string} sourceDir
9
+ * @param {object} [options]
10
+ * @param {string} [options.outputDir]
11
+ * @param {string} [options.outputSuffix]
12
+ * @param {boolean} [options.hasShorten=false]
13
+ * @returns {string}
14
+ */
15
+ export function resolveOutputDirectory(sourceDir, options = {}) {
16
+ const resolvedSource = path.resolve(sourceDir || process.cwd());
17
+ if (options.outputDir) {
18
+ return path.resolve(options.outputDir);
19
+ }
20
+ const suffix = options.outputSuffix !== undefined
21
+ ? options.outputSuffix
22
+ : (options.hasShorten ? ' (short)' : ' (edited)');
23
+ return path.join(path.dirname(resolvedSource), path.basename(resolvedSource) + suffix);
24
+ }
25
+
26
+ /**
27
+ * Resolve actions configuration for a specific entry.
28
+ *
29
+ * @param {object} params
30
+ * @param {string} params.songKey
31
+ * @param {string} params.filename
32
+ * @param {object} [params.rawEntry]
33
+ * @param {object} [params.rawMetadata]
34
+ * @param {object} [params.actionsFileConfig]
35
+ * @returns {Array<object>}
36
+ */
37
+ export function resolveEntryActions({ songKey, filename, rawEntry, rawMetadata, actionsFileConfig }) {
38
+ // If actions file is provided, check it first
39
+ if (actionsFileConfig) {
40
+ if (actionsFileConfig.songs && actionsFileConfig.songs[songKey]?.actions) {
41
+ return actionsFileConfig.songs[songKey].actions;
42
+ }
43
+ if (Array.isArray(actionsFileConfig.videos)) {
44
+ const match = actionsFileConfig.videos.find(v => v.filename === filename || v.songKey === songKey);
45
+ if (match?.actions) return match.actions;
46
+ }
47
+ if (Array.isArray(actionsFileConfig.defaultActions)) {
48
+ return actionsFileConfig.defaultActions;
49
+ }
50
+ if (Array.isArray(actionsFileConfig.actions)) {
51
+ return actionsFileConfig.actions;
52
+ }
53
+ }
54
+
55
+ // Next check entry specific actions
56
+ if (Array.isArray(rawEntry?.actions)) {
57
+ return rawEntry.actions;
58
+ }
59
+
60
+ // Check raw metadata songs[songKey].actions
61
+ if (rawMetadata?.songs && rawMetadata.songs[songKey]?.actions) {
62
+ return rawMetadata.songs[songKey].actions;
63
+ }
64
+
65
+ // Check raw metadata defaultActions
66
+ if (Array.isArray(rawMetadata?.defaultActions)) {
67
+ return rawMetadata.defaultActions;
68
+ }
69
+
70
+ return [];
71
+ }
72
+
73
+ /**
74
+ * Plan batch actions for a directory.
75
+ *
76
+ * @param {string} sourceDir
77
+ * @param {object} [options]
78
+ * @param {string} [options.metadataFile]
79
+ * @param {string} [options.actionsFile]
80
+ * @param {string} [options.outputDir]
81
+ * @param {string} [options.outputSuffix]
82
+ * @param {string} [options.only]
83
+ * @param {boolean} [options.force=false]
84
+ * @param {boolean} [options.dryRun=false]
85
+ * @returns {object}
86
+ */
87
+ export function planBatchActions(sourceDir, options = {}) {
88
+ const resolvedSource = path.resolve(sourceDir || process.cwd());
89
+ const metadataPath = options.metadataFile
90
+ ? path.resolve(options.metadataFile)
91
+ : path.join(resolvedSource, 'metadata.json');
92
+
93
+ let rawMetadata = {};
94
+ if (fs.existsSync(metadataPath)) {
95
+ try {
96
+ rawMetadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
97
+ } catch (err) {
98
+ throw new Error(`Failed to parse metadata file (${metadataPath}): ${err.message}`);
99
+ }
100
+ }
101
+
102
+ let actionsFileConfig = null;
103
+ if (options.actionsFile) {
104
+ const actionsPath = path.resolve(options.actionsFile);
105
+ if (!fs.existsSync(actionsPath)) {
106
+ throw new Error(`Actions file not found: ${actionsPath}`);
107
+ }
108
+ try {
109
+ actionsFileConfig = JSON.parse(fs.readFileSync(actionsPath, 'utf-8'));
110
+ } catch (err) {
111
+ throw new Error(`Failed to parse actions file (${actionsPath}): ${err.message}`);
112
+ }
113
+ }
114
+
115
+ const normalized = loadMetadata(resolvedSource, options.metadataFile);
116
+ const journeyConfig = {
117
+ enabled: true,
118
+ mode: 'before-current',
119
+ position: 'left',
120
+ displayDuration: 5,
121
+ sidebarDisplayDuration: undefined,
122
+ sidebarLabel: 'title',
123
+ theme: 'dark',
124
+ maxCenterChars: 60,
125
+ maxVisibleRows: 8,
126
+ ...(rawMetadata.journey || {}),
127
+ ...(actionsFileConfig?.journey || {}),
128
+ };
129
+
130
+ const rawSongs = rawMetadata.songs || {};
131
+ let entries = normalized.videos.map((v, index) => {
132
+ const songKey = v.filename;
133
+ const rawEntry = rawSongs[songKey] || v;
134
+ const actions = resolveEntryActions({
135
+ songKey,
136
+ filename: v.filename,
137
+ rawEntry,
138
+ rawMetadata,
139
+ actionsFileConfig,
140
+ });
141
+
142
+ return {
143
+ index,
144
+ songKey,
145
+ filename: v.filename,
146
+ title: v.title,
147
+ sourcePath: v.filePath,
148
+ actions,
149
+ rawEntry,
150
+ };
151
+ });
152
+
153
+ if (options.only) {
154
+ const onlyTarget = String(options.only).trim();
155
+ entries = entries.filter(
156
+ e => e.songKey === onlyTarget || e.filename === onlyTarget || path.basename(e.filename, path.extname(e.filename)) === onlyTarget
157
+ );
158
+ if (entries.length === 0) {
159
+ throw new Error(`No video matching --only "${options.only}" was found.`);
160
+ }
161
+ }
162
+
163
+ const hasShorten = entries.some(e => e.actions.some(a => a.type === 'shorten'));
164
+ const outputDir = resolveOutputDirectory(resolvedSource, {
165
+ outputDir: options.outputDir,
166
+ outputSuffix: options.outputSuffix,
167
+ hasShorten,
168
+ });
169
+
170
+ if (fs.existsSync(outputDir) && !options.force && !options.outputDir) {
171
+ const files = fs.readdirSync(outputDir).filter(f => !f.startsWith('.'));
172
+ if (files.length > 0) {
173
+ throw new Error(
174
+ `Output directory already exists and is not empty: ${outputDir}. Use --force to overwrite or specify --output-dir.`
175
+ );
176
+ }
177
+ }
178
+
179
+ const plannedEntries = entries.map(e => ({
180
+ ...e,
181
+ targetPath: path.join(outputDir, e.filename),
182
+ }));
183
+
184
+ return {
185
+ sourceDir: resolvedSource,
186
+ outputDir,
187
+ hasShorten,
188
+ rawMetadata,
189
+ journeyConfig,
190
+ entries: plannedEntries,
191
+ dryRun: Boolean(options.dryRun),
192
+ force: Boolean(options.force),
193
+ };
194
+ }