@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.
- package/README.md +362 -25
- package/bin/cli.js +735 -2
- package/package.json +7 -1
- package/src/actions/index.js +274 -0
- package/src/actions/journey-overlay.js +567 -0
- package/src/actions/replace-background.js +154 -0
- package/src/actions/shorten.js +271 -0
- package/src/auth.js +1 -1
- package/src/batch/clone-metadata.js +51 -0
- package/src/batch/plan.js +194 -0
- package/src/cli/apply-actions.js +152 -0
- package/src/cli/preview-journey.js +106 -0
- package/src/index.d.ts +596 -1
- package/src/index.js +91 -1
- package/src/media/cache.js +134 -0
- package/src/media/compose.js +227 -0
- package/src/media/download.js +151 -0
- package/src/media/resolve.js +476 -0
- package/src/media/select.js +176 -0
- package/src/providers/index.js +235 -0
- package/src/providers/pexels.js +171 -0
- package/src/providers/pixabay.js +184 -0
- package/src/state.js +11 -0
- package/src/swap/steps.js +161 -0
- package/src/swap/store.js +120 -0
- package/src/swap/swapSong.js +154 -0
- package/src/update.js +392 -0
- package/src/uploader.js +20 -0
|
@@ -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
|
+
}
|