veodl 1.12.1 → 2.0.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/CHANGELOG.md +15 -0
- package/LICENSE +21 -21
- package/README.md +178 -97
- package/bin/veo.js +47 -2
- package/docs/AGENT_GUIDE.md +1 -1
- package/native/android-arm64/veo +0 -0
- package/native/darwin-arm64/veo +0 -0
- package/native/darwin-x64/veo +0 -0
- package/native/linux-arm64/veo +0 -0
- package/native/linux-x64/veo +0 -0
- package/native/win32-arm64/veo.exe +0 -0
- package/native/win32-x64/veo.exe +0 -0
- package/package.json +53 -57
- package/src/alias.js +0 -258
- package/src/backend-update.js +0 -191
- package/src/backend.js +0 -572
- package/src/changes.js +0 -278
- package/src/cli.js +0 -785
- package/src/compatibility.js +0 -39
- package/src/config-clipboard.js +0 -67
- package/src/config-diagnostics.js +0 -155
- package/src/config-editor.js +0 -404
- package/src/config-errors.js +0 -64
- package/src/config-reset.js +0 -39
- package/src/config-template.js +0 -227
- package/src/config.js +0 -307
- package/src/disk-space.js +0 -68
- package/src/doctor.js +0 -306
- package/src/download-cache.js +0 -31
- package/src/downloader.js +0 -704
- package/src/execution.js +0 -68
- package/src/experimental-fragments.js +0 -29
- package/src/flush.js +0 -66
- package/src/history.js +0 -173
- package/src/inline-region.js +0 -72
- package/src/inspect-media.js +0 -179
- package/src/interactive.js +0 -120
- package/src/jobs.js +0 -218
- package/src/legacy-config-comments.js +0 -106
- package/src/media-assets.js +0 -272
- package/src/naming.js +0 -54
- package/src/open-file.js +0 -15
- package/src/option-suggestions.js +0 -43
- package/src/output.js +0 -81
- package/src/path-links.js +0 -53
- package/src/paths.js +0 -26
- package/src/playlist.js +0 -31
- package/src/progress.js +0 -341
- package/src/run-archive.js +0 -148
- package/src/runs.js +0 -346
- package/src/source-discovery.js +0 -305
- package/src/state.js +0 -27
- package/src/stats.js +0 -63
- package/src/terminal-size.js +0 -8
- package/src/terminal-title.js +0 -19
- package/src/tool-setup.js +0 -289
- package/src/uninstall.js +0 -222
- package/src/updater.js +0 -229
- package/src/utils.js +0 -208
- package/src/version.js +0 -38
package/src/backend.js
DELETED
|
@@ -1,572 +0,0 @@
|
|
|
1
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { createReadStream, createWriteStream, constants } from 'node:fs';
|
|
3
|
-
import { access, chmod, copyFile, lstat, mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import { Readable, Transform } from 'node:stream';
|
|
6
|
-
import { pipeline } from 'node:stream/promises';
|
|
7
|
-
import { cacheBase } from './paths.js';
|
|
8
|
-
import { compareVersions } from './version.js';
|
|
9
|
-
import { hasTermuxEjs, installTermuxTools, installMediaTools, verifiedMediaTools, runSetup } from './tool-setup.js';
|
|
10
|
-
|
|
11
|
-
export const TERMUX_SETUP = 'pkg install python-yt-dlp yt-dlp-ejs ffmpeg';
|
|
12
|
-
export const RELEASE = '2026.08.19';
|
|
13
|
-
const RELEASE_HOST = 'https://github.com/yt-dlp/yt-dlp/releases/download';
|
|
14
|
-
const MAX_BYTES = 256 * 1024 * 1024;
|
|
15
|
-
const DOWNLOAD_TIMEOUT = 180_000;
|
|
16
|
-
const RELEASE_PATTERN = /^\d{4}\.\d{2}\.\d{2}(?:\.\d+)?$/;
|
|
17
|
-
// Official hashes fetched at development time; never trust a runtime checksum download.
|
|
18
|
-
// https://github.com/yt-dlp/yt-dlp/releases/download/2026.08.19/SHA2-256SUMS
|
|
19
|
-
const HASHES = Object.freeze({
|
|
20
|
-
'yt-dlp.exe': '66674953fe251b89f4d08c5f0e35e0728679bd67ab3d7d05c0562af101dd3e7a',
|
|
21
|
-
'yt-dlp_arm64.exe': '05b438997bafc3affdfda9d041353c9d73e04dc842207254b655b0887c4445b0',
|
|
22
|
-
'yt-dlp_x86.exe': 'a8f91bd41452506bc81ebd2f369b186fea0ee7075413ba00cef9fd346a0a5d0c',
|
|
23
|
-
'yt-dlp_macos': '0f192b7ec147ab6288885d6351d9ab67367640029b4377576ef46dd79cf7b202',
|
|
24
|
-
'yt-dlp_linux': '58162f9bfdc27458ea47bfcb311cf47028f17d8154a8bf7d689861d46399230a',
|
|
25
|
-
'yt-dlp_linux_aarch64': 'b16e4dab368a816cd05d477d698a605a6ae87ccee1c8ffd38fa21d7254141fcc',
|
|
26
|
-
'yt-dlp_musllinux': 'f3dec9cfeaf304cec98290fe41c6ad465d4b747d302473559643e7af24929722',
|
|
27
|
-
'yt-dlp_musllinux_aarch64': '17b164c4d258be92bb1ad146cb7c336b783aedb380814aabbcb7d52937f77e57',
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
export function selectAsset(platform = process.platform, arch = process.arch, musl = false) {
|
|
31
|
-
if (platform === 'win32') return { x64: 'yt-dlp.exe', arm64: 'yt-dlp_arm64.exe', ia32: 'yt-dlp_x86.exe' }[arch];
|
|
32
|
-
if (platform === 'darwin' && ['x64', 'arm64'].includes(arch)) return 'yt-dlp_macos';
|
|
33
|
-
if (platform === 'linux' && ['x64', 'arm64'].includes(arch)) return `yt-dlp_${musl ? 'musl' : ''}linux${arch === 'arm64' ? '_aarch64' : ''}`;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function cacheDirectory(release = RELEASE) {
|
|
37
|
-
return path.join(cacheBase(), 'backends', release, `${process.platform}-${process.arch}`);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export { cacheDirectory as backendCacheDirectory };
|
|
41
|
-
|
|
42
|
-
export function releaseUrl(release, asset) {
|
|
43
|
-
return `${RELEASE_HOST}/${release}/${asset}`;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
export function isValidRelease(value) {
|
|
47
|
-
return typeof value === 'string' && RELEASE_PATTERN.test(value);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Path of the state file that records an explicitly installed newer backend. */
|
|
51
|
-
export function backendStateFile({ env = process.env } = {}) {
|
|
52
|
-
return path.join(cacheBase({ env }), 'backend-override.json');
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export async function readBackendOverride(stateFile = backendStateFile()) {
|
|
56
|
-
try {
|
|
57
|
-
const state = JSON.parse(await readFile(stateFile, 'utf8'));
|
|
58
|
-
return state && typeof state === 'object' && !Array.isArray(state) ? state : null;
|
|
59
|
-
} catch {
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export async function writeBackendOverride(state, stateFile = backendStateFile()) {
|
|
65
|
-
await mkdir(path.dirname(stateFile), { recursive: true, mode: 0o700 });
|
|
66
|
-
const temp = `${stateFile}.${randomUUID()}.tmp`;
|
|
67
|
-
try {
|
|
68
|
-
await writeFile(temp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
|
|
69
|
-
await rename(temp, stateFile);
|
|
70
|
-
} finally {
|
|
71
|
-
await rm(temp, { force: true });
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** Returns true only when a recorded override was actually removed. */
|
|
76
|
-
export async function clearBackendOverride(stateFile = backendStateFile()) {
|
|
77
|
-
try {
|
|
78
|
-
await rm(stateFile);
|
|
79
|
-
return true;
|
|
80
|
-
} catch (error) {
|
|
81
|
-
if (error.code === 'ENOENT') return false;
|
|
82
|
-
throw error;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* The explicitly installed backend only wins when it is newer than the pinned
|
|
88
|
-
* release, matches this platform's asset, and its bytes still hash to the value
|
|
89
|
-
* recorded at install time. Anything else silently falls back to the pinned
|
|
90
|
-
* release, so a tampered or truncated cache never becomes the default.
|
|
91
|
-
*/
|
|
92
|
-
export async function activeOverride(asset, { signal, stateFile = backendStateFile(), matchesImpl = matches } = {}) {
|
|
93
|
-
if (!asset) return null;
|
|
94
|
-
const state = await readBackendOverride(stateFile);
|
|
95
|
-
if (!state || state.asset !== asset) return null;
|
|
96
|
-
if (!isValidRelease(state.release) || !/^[a-f0-9]{64}$/.test(String(state.sha256))) return null;
|
|
97
|
-
if (compareVersions(state.release, RELEASE) <= 0) return null;
|
|
98
|
-
const file = path.join(cacheDirectory(state.release), `yt-dlp${exeSuffix()}`);
|
|
99
|
-
if (!await matchesImpl(file, state.sha256, signal)) return null;
|
|
100
|
-
return { release: state.release, asset, sha256: state.sha256, path: file };
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Install a backend release that is newer than the pinned one. The expected
|
|
105
|
-
* hash must come from that release's own checksum list; the caller is
|
|
106
|
-
* responsible for the trust decision and for telling the user about it.
|
|
107
|
-
*/
|
|
108
|
-
export async function installBackend({ release, asset, sha256, signal, status = () => {} }) {
|
|
109
|
-
if (!isValidRelease(release)) throw new Error(`Invalid yt-dlp release: ${release}`);
|
|
110
|
-
if (!asset) throw new Error(`No standalone yt-dlp is available for ${process.platform}/${process.arch}.`);
|
|
111
|
-
if (!/^[a-f0-9]{64}$/.test(String(sha256))) throw new Error('The release checksum is missing or malformed.');
|
|
112
|
-
const directory = cacheDirectory(release);
|
|
113
|
-
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
114
|
-
const destination = await downloadBackend({ asset, release, expected: sha256, directory, signal, status, replace: true });
|
|
115
|
-
return { release, asset, sha256, path: destination };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export function exeSuffix(platform = process.platform) {
|
|
119
|
-
return platform === 'win32' ? '.exe' : '';
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Retained for callers that inspect the desktop media-tool platform matrix.
|
|
123
|
-
export function staticToolsSupported(platform = process.platform, arch = process.arch) {
|
|
124
|
-
if (platform === 'win32') return ['x64', 'ia32', 'arm64'].includes(arch);
|
|
125
|
-
if (platform === 'darwin') return ['x64', 'arm64'].includes(arch);
|
|
126
|
-
if (platform === 'linux') return ['x64', 'ia32', 'arm', 'arm64'].includes(arch);
|
|
127
|
-
return false;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
async function isExecutable(file) {
|
|
131
|
-
if (typeof file !== 'string' || !file) return false;
|
|
132
|
-
try {
|
|
133
|
-
if (!(await stat(file)).isFile()) return false;
|
|
134
|
-
await access(file, process.platform === 'win32' ? constants.R_OK : constants.X_OK);
|
|
135
|
-
return true;
|
|
136
|
-
} catch {
|
|
137
|
-
return false;
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Locate the first matching executable on PATH. Native pinned media downloads
|
|
143
|
-
* support Windows ARM64 through x64 emulation. A trusted system ffmpeg remains
|
|
144
|
-
* a legitimate third source after the override and the verified managed cache.
|
|
145
|
-
* Git for Windows and WinGet ship ffmpeg in directories such as these but do
|
|
146
|
-
* not always extend the PATH that a Node process inherits.
|
|
147
|
-
*/
|
|
148
|
-
export function wellKnownMediaDirectories({ platform = process.platform, env = process.env } = {}) {
|
|
149
|
-
if (platform === 'android') return env.PREFIX ? [path.join(env.PREFIX, 'bin')] : [];
|
|
150
|
-
if (platform !== 'win32') return ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'];
|
|
151
|
-
const local = env.LOCALAPPDATA || '';
|
|
152
|
-
const programFiles = env.ProgramFiles || 'C:\\Program Files';
|
|
153
|
-
return [
|
|
154
|
-
local && path.join(local, 'Microsoft', 'WinGet', 'Links'),
|
|
155
|
-
local && path.join(local, 'Microsoft', 'WindowsApps'),
|
|
156
|
-
path.join(programFiles, 'ffmpeg', 'bin'),
|
|
157
|
-
'C:\\ffmpeg\\bin',
|
|
158
|
-
'C:\\ProgramData\\chocolatey\\bin',
|
|
159
|
-
].filter(Boolean);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function normalizedPath(file) {
|
|
163
|
-
const resolved = path.resolve(file);
|
|
164
|
-
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
async function excludedPath(file, excludeFiles) {
|
|
168
|
-
if (!file || !excludeFiles.length) return false;
|
|
169
|
-
const resolved = normalizedPath(file);
|
|
170
|
-
if (excludeFiles.some(excluded => normalizedPath(excluded) === resolved)) return true;
|
|
171
|
-
const canonical = await realpath(file).catch(() => undefined);
|
|
172
|
-
const parent = await realpath(path.dirname(file)).catch(() => undefined);
|
|
173
|
-
const entry = parent && path.join(parent, path.basename(file));
|
|
174
|
-
for (const excluded of excludeFiles) {
|
|
175
|
-
const directory = await realpath(path.dirname(excluded)).catch(() => undefined);
|
|
176
|
-
if (!directory) continue;
|
|
177
|
-
// Resolve directory aliases, but do not follow a rejected cache file's
|
|
178
|
-
// outward symlink and accidentally blacklist its genuine system target.
|
|
179
|
-
const info = await lstat(excluded).catch(() => undefined);
|
|
180
|
-
const regular = info?.isFile() ? await realpath(excluded).catch(() => undefined) : undefined;
|
|
181
|
-
const target = normalizedPath(regular || path.join(directory, path.basename(excluded)));
|
|
182
|
-
if (entry && normalizedPath(entry) === target || canonical && normalizedPath(canonical) === target) return true;
|
|
183
|
-
}
|
|
184
|
-
return false;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
export async function findOnPath(names, { platform = process.platform, env = process.env, directories, excludeFiles = [] } = {}) {
|
|
188
|
-
const raw = platform === 'win32' ? env.PATH || env.Path || '' : env.PATH || '';
|
|
189
|
-
const candidates = directories || [
|
|
190
|
-
...raw.split(platform === 'win32' ? ';' : ':').map(entry => entry.trim().replace(/^"(.*)"$/, '$1')),
|
|
191
|
-
...wellKnownMediaDirectories({ platform, env }),
|
|
192
|
-
];
|
|
193
|
-
const suffixes = platform === 'win32' ? ['', '.exe', '.cmd'] : [''];
|
|
194
|
-
for (const directory of candidates) {
|
|
195
|
-
if (!path.isAbsolute(directory)) continue;
|
|
196
|
-
for (const name of names) {
|
|
197
|
-
for (const suffix of suffixes) {
|
|
198
|
-
const candidate = path.join(directory, `${name}${suffix}`);
|
|
199
|
-
if (await isExecutable(candidate) && !await excludedPath(candidate, excludeFiles)) return candidate;
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
return undefined;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function envPath(name) {
|
|
207
|
-
if (!(name in process.env)) return undefined;
|
|
208
|
-
const value = process.env[name];
|
|
209
|
-
if (!value?.trim() || value.includes('\0')) throw new Error(`${name} must be a non-empty filesystem path.`);
|
|
210
|
-
return path.resolve(value);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
async function executable(file, label) {
|
|
214
|
-
if (typeof file !== 'string' || !file) throw new Error(`${label} is unavailable on ${process.platform}/${process.arch}.`);
|
|
215
|
-
try {
|
|
216
|
-
if (!(await stat(file)).isFile()) throw new Error('not a regular file');
|
|
217
|
-
await access(file, process.platform === 'win32' ? constants.R_OK : constants.X_OK);
|
|
218
|
-
} catch (cause) {
|
|
219
|
-
throw new Error(`${label} is missing or not executable: ${file}`, { cause });
|
|
220
|
-
}
|
|
221
|
-
return path.resolve(file);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
async function sha256(file, signal) {
|
|
225
|
-
signal?.throwIfAborted();
|
|
226
|
-
const hash = createHash('sha256');
|
|
227
|
-
let bytes = 0;
|
|
228
|
-
for await (const chunk of createReadStream(file, { signal })) {
|
|
229
|
-
bytes += chunk.length;
|
|
230
|
-
if (bytes > MAX_BYTES) throw new Error(`Backend file exceeds ${MAX_BYTES / 1024 / 1024} MiB: ${file}`);
|
|
231
|
-
hash.update(chunk);
|
|
232
|
-
}
|
|
233
|
-
return hash.digest('hex');
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
async function matches(file, expected, signal) {
|
|
237
|
-
try {
|
|
238
|
-
const info = await lstat(file);
|
|
239
|
-
// Do not accept cached symlinks or special files.
|
|
240
|
-
if (!info.isFile() || info.size > MAX_BYTES) return false;
|
|
241
|
-
return await sha256(file, signal) === expected;
|
|
242
|
-
} catch (error) {
|
|
243
|
-
if (error.code === 'ENOENT') return false;
|
|
244
|
-
throw error;
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
async function publish(temp, destination, expected, signal) {
|
|
249
|
-
signal?.throwIfAborted();
|
|
250
|
-
if (process.platform !== 'win32') await chmod(temp, 0o755);
|
|
251
|
-
try {
|
|
252
|
-
await rename(temp, destination);
|
|
253
|
-
} catch (error) {
|
|
254
|
-
// Another process may have finished the same acquisition on Windows.
|
|
255
|
-
if (['EEXIST', 'EPERM', 'EACCES'].includes(error.code)
|
|
256
|
-
&& await matches(destination, expected, signal)) return;
|
|
257
|
-
// A directory at the destination is stale cache from an older layout.
|
|
258
|
-
if (['EEXIST', 'EPERM', 'EACCES', 'ENOTEMPTY', 'EISDIR'].includes(error.code)) {
|
|
259
|
-
const info = await lstat(destination).catch(() => undefined);
|
|
260
|
-
if (info?.isDirectory()) {
|
|
261
|
-
await rm(destination, { recursive: true, force: true });
|
|
262
|
-
try { await rename(temp, destination); return; } catch { /* Fall through. */ }
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
throw error;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* Download one yt-dlp asset from its release and publish it only after the
|
|
271
|
-
* SHA-256 of the received bytes matched `expected`. With `replace`, an already
|
|
272
|
-
* present file is removed first, which is safe because the temp copy is fully
|
|
273
|
-
* verified before it is published.
|
|
274
|
-
*/
|
|
275
|
-
async function downloadBackend({ asset, release, expected, directory, signal, status, replace = false }) {
|
|
276
|
-
const destination = path.join(directory, `yt-dlp${exeSuffix()}`);
|
|
277
|
-
const temp = `${destination}.${randomUUID()}.tmp`;
|
|
278
|
-
const timeout = AbortSignal.timeout(DOWNLOAD_TIMEOUT);
|
|
279
|
-
const downloadSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
280
|
-
status(`Downloading yt-dlp ${release}…`);
|
|
281
|
-
try {
|
|
282
|
-
const response = await fetch(`${RELEASE_HOST}/${release}/${asset}`, { signal: downloadSignal });
|
|
283
|
-
if (!response.ok || !response.body) {
|
|
284
|
-
await response.body?.cancel();
|
|
285
|
-
throw new Error(`yt-dlp download failed: HTTP ${response.status}.`);
|
|
286
|
-
}
|
|
287
|
-
if (Number(response.headers.get('content-length')) > MAX_BYTES) {
|
|
288
|
-
await response.body.cancel();
|
|
289
|
-
throw new Error('yt-dlp download exceeds the size limit.');
|
|
290
|
-
}
|
|
291
|
-
let bytes = 0;
|
|
292
|
-
const hash = createHash('sha256');
|
|
293
|
-
const verifier = new Transform({
|
|
294
|
-
transform(chunk, encoding, callback) {
|
|
295
|
-
bytes += chunk.length;
|
|
296
|
-
if (bytes > MAX_BYTES) return callback(new Error('yt-dlp download exceeds the size limit.'));
|
|
297
|
-
hash.update(chunk);
|
|
298
|
-
callback(null, chunk);
|
|
299
|
-
},
|
|
300
|
-
});
|
|
301
|
-
await pipeline(Readable.fromWeb(response.body), verifier,
|
|
302
|
-
createWriteStream(temp, { flags: 'wx', mode: 0o600 }), { signal: downloadSignal });
|
|
303
|
-
if (hash.digest('hex') !== expected) throw new Error('yt-dlp SHA-256 verification failed. The download was discarded.');
|
|
304
|
-
if (replace) await rm(destination, { recursive: true, force: true });
|
|
305
|
-
await publish(temp, destination, expected, downloadSignal);
|
|
306
|
-
return destination;
|
|
307
|
-
} catch (cause) {
|
|
308
|
-
signal?.throwIfAborted();
|
|
309
|
-
if (timeout.aborted) throw new Error('yt-dlp download timed out after 3 minutes. Please retry.', { cause });
|
|
310
|
-
throw new Error(`Unable to prepare yt-dlp: ${cause.message} You can set VEO_YT_DLP_PATH to a trusted local executable.`, { cause });
|
|
311
|
-
} finally {
|
|
312
|
-
await rm(temp, { force: true });
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
async function acquire(asset, directory, signal, status, statusDone = () => {}) {
|
|
317
|
-
const destination = path.join(directory, `yt-dlp${exeSuffix()}`);
|
|
318
|
-
const expected = HASHES[asset];
|
|
319
|
-
status('Checking yt-dlp…');
|
|
320
|
-
let cached;
|
|
321
|
-
try {
|
|
322
|
-
cached = await matches(destination, expected, signal);
|
|
323
|
-
statusDone('Checking yt-dlp…', 'done');
|
|
324
|
-
} catch (error) {
|
|
325
|
-
if (!signal?.aborted && error?.name !== 'AbortError') statusDone('Checking yt-dlp…', 'failed');
|
|
326
|
-
throw error;
|
|
327
|
-
}
|
|
328
|
-
if (cached) {
|
|
329
|
-
if (process.platform !== 'win32') await chmod(destination, 0o755);
|
|
330
|
-
return destination;
|
|
331
|
-
}
|
|
332
|
-
status(`Downloading yt-dlp ${RELEASE} (first use)…`);
|
|
333
|
-
return downloadBackend({ asset, release: RELEASE, expected, directory, signal, status });
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
async function stage(source, destination, signal) {
|
|
337
|
-
const expected = await sha256(source, signal);
|
|
338
|
-
if (await matches(destination, expected, signal)) {
|
|
339
|
-
if (process.platform !== 'win32') await chmod(destination, 0o755);
|
|
340
|
-
return;
|
|
341
|
-
}
|
|
342
|
-
const temp = `${destination}.${randomUUID()}.tmp`;
|
|
343
|
-
try {
|
|
344
|
-
signal?.throwIfAborted();
|
|
345
|
-
await copyFile(source, temp, constants.COPYFILE_EXCL);
|
|
346
|
-
if (!await matches(temp, expected, signal)) throw new Error(`Backend copy verification failed: ${source}`);
|
|
347
|
-
await publish(temp, destination, expected, signal);
|
|
348
|
-
} finally {
|
|
349
|
-
await rm(temp, { force: true });
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
async function managedMediaTools({ signal, directory }) {
|
|
354
|
-
if (!await verifiedMediaTools({ signal, directory })) {
|
|
355
|
-
return 'the managed media tools are missing or failed SHA-256 verification';
|
|
356
|
-
}
|
|
357
|
-
if (process.platform !== 'win32') {
|
|
358
|
-
for (const name of ['ffmpeg', 'ffprobe']) await chmod(path.join(directory, name), 0o755);
|
|
359
|
-
}
|
|
360
|
-
return null;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/**
|
|
364
|
-
* Resolve a location that contains both ffmpeg and ffprobe for
|
|
365
|
-
* --ffmpeg-location. Precedence: explicit override, verified managed cache,
|
|
366
|
-
* then a system installation. Missing desktop tools use pinned downloads.
|
|
367
|
-
*/
|
|
368
|
-
export async function resolveMediaTools({ signal, status = () => {}, statusDone = () => {}, directory = cacheDirectory(), offline = false,
|
|
369
|
-
find = findOnPath, managed = managedMediaTools, install = installMediaTools } = {}) {
|
|
370
|
-
const preparing = 'Preparing ffmpeg and ffprobe…';
|
|
371
|
-
let currentStatus;
|
|
372
|
-
const report = message => { currentStatus = message; status(message); };
|
|
373
|
-
const finish = outcome => {
|
|
374
|
-
if (currentStatus !== preparing) report(preparing);
|
|
375
|
-
statusDone(preparing, outcome);
|
|
376
|
-
};
|
|
377
|
-
report(preparing);
|
|
378
|
-
try {
|
|
379
|
-
const suffix = exeSuffix();
|
|
380
|
-
const override = envPath('VEO_FFMPEG_PATH');
|
|
381
|
-
if (override) {
|
|
382
|
-
await executable(path.join(override, `ffmpeg${suffix}`), 'VEO_FFMPEG_PATH ffmpeg');
|
|
383
|
-
await executable(path.join(override, `ffprobe${suffix}`), 'VEO_FFMPEG_PATH ffprobe');
|
|
384
|
-
finish('done');
|
|
385
|
-
report('Using VEO_FFMPEG_PATH override.');
|
|
386
|
-
return override;
|
|
387
|
-
}
|
|
388
|
-
const problem = await managed({ signal, status: report, directory });
|
|
389
|
-
if (!problem) { finish('done'); return directory; }
|
|
390
|
-
const excludeFiles = ['ffmpeg', 'ffprobe'].map(name => path.join(directory, `${name}${suffix}`));
|
|
391
|
-
const systemTool = async name => {
|
|
392
|
-
const found = await find([name], { excludeFiles });
|
|
393
|
-
return await excludedPath(found, excludeFiles) ? undefined : found;
|
|
394
|
-
};
|
|
395
|
-
const ffmpeg = await systemTool('ffmpeg');
|
|
396
|
-
const ffprobe = await systemTool('ffprobe');
|
|
397
|
-
if (!ffmpeg || !ffprobe) {
|
|
398
|
-
if (!offline) {
|
|
399
|
-
const location = await install({ signal, status: report, directory, find, stage });
|
|
400
|
-
finish('done');
|
|
401
|
-
return location;
|
|
402
|
-
}
|
|
403
|
-
const missing = [!ffmpeg && 'ffmpeg', !ffprobe && 'ffprobe'].filter(Boolean).join(' and ');
|
|
404
|
-
throw new Error(`Cannot load media tools: ${problem}, and no ${missing} was found on PATH. `
|
|
405
|
-
+ 'Run veo doctor fix without --offline to install them automatically, or set VEO_FFMPEG_PATH to a directory containing both binaries.');
|
|
406
|
-
}
|
|
407
|
-
// Same directory: yt-dlp can use it in place instead of duplicating ~150 MB.
|
|
408
|
-
if (path.dirname(ffmpeg) === path.dirname(ffprobe)) {
|
|
409
|
-
finish('done');
|
|
410
|
-
report(`Using the system ffmpeg in ${path.dirname(ffmpeg)}.`);
|
|
411
|
-
return path.dirname(ffmpeg);
|
|
412
|
-
}
|
|
413
|
-
report('Copying system ffmpeg and ffprobe into the backend cache…');
|
|
414
|
-
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
415
|
-
await stage(ffmpeg, path.join(directory, `ffmpeg${suffix}`), signal);
|
|
416
|
-
await stage(ffprobe, path.join(directory, `ffprobe${suffix}`), signal);
|
|
417
|
-
finish('done');
|
|
418
|
-
return directory;
|
|
419
|
-
} catch (error) {
|
|
420
|
-
if (!signal?.aborted && error?.name !== 'AbortError') finish('failed');
|
|
421
|
-
throw error;
|
|
422
|
-
}
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
/**
|
|
426
|
-
* Report the state of every backend component without downloading, executing,
|
|
427
|
-
* or changing anything. Used by `veo doctor`.
|
|
428
|
-
*/
|
|
429
|
-
export async function inspectBackend({ signal, platform = process.platform, arch = process.arch, find = findOnPath } = {}) {
|
|
430
|
-
const suffix = exeSuffix();
|
|
431
|
-
const directory = cacheDirectory();
|
|
432
|
-
const musl = process.platform === 'linux' && !process.report.getReport().header.glibcVersionRuntime;
|
|
433
|
-
const report = {
|
|
434
|
-
release: RELEASE,
|
|
435
|
-
directory,
|
|
436
|
-
platform: `${platform}/${arch}`,
|
|
437
|
-
asset: undefined,
|
|
438
|
-
override: null,
|
|
439
|
-
ytDlp: { source: 'none', present: false, verified: false },
|
|
440
|
-
ffmpeg: { source: 'missing', present: false },
|
|
441
|
-
ffprobe: { source: 'missing', present: false },
|
|
442
|
-
errors: [],
|
|
443
|
-
};
|
|
444
|
-
try {
|
|
445
|
-
const override = envPath('VEO_YT_DLP_PATH');
|
|
446
|
-
if (override) {
|
|
447
|
-
report.ytDlp = { source: 'override', path: override, present: await isExecutable(override), verified: false };
|
|
448
|
-
} else if (platform === 'android') {
|
|
449
|
-
const file = await find(['yt-dlp']);
|
|
450
|
-
report.ytDlp = { source: 'system', path: file, present: Boolean(file), verified: false };
|
|
451
|
-
} else {
|
|
452
|
-
const asset = selectAsset(platform, arch, musl);
|
|
453
|
-
report.asset = asset;
|
|
454
|
-
if (!asset) {
|
|
455
|
-
report.errors.push(`No standalone yt-dlp is published for ${report.platform}.`);
|
|
456
|
-
} else {
|
|
457
|
-
report.override = await activeOverride(asset, { signal });
|
|
458
|
-
const file = report.override?.path || path.join(directory, `yt-dlp${suffix}`);
|
|
459
|
-
const present = await isExecutable(file);
|
|
460
|
-
report.ytDlp = {
|
|
461
|
-
source: report.override ? 'installed' : 'managed',
|
|
462
|
-
path: file,
|
|
463
|
-
present,
|
|
464
|
-
verified: present && await matches(file, report.override?.sha256 || HASHES[asset], signal),
|
|
465
|
-
};
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
} catch (error) {
|
|
469
|
-
report.errors.push(error.message);
|
|
470
|
-
report.ytDlp = { source: 'invalid', present: false, verified: false };
|
|
471
|
-
}
|
|
472
|
-
let override;
|
|
473
|
-
try {
|
|
474
|
-
override = envPath('VEO_FFMPEG_PATH');
|
|
475
|
-
} catch (error) {
|
|
476
|
-
report.errors.push(error.message);
|
|
477
|
-
}
|
|
478
|
-
const cacheVerified = platform !== 'android' && !override
|
|
479
|
-
&& await verifiedMediaTools({ directory, platform, arch, signal });
|
|
480
|
-
const excludeFiles = platform === 'android' ? []
|
|
481
|
-
: ['ffmpeg', 'ffprobe'].map(name => path.join(directory, `${name}${suffix}`));
|
|
482
|
-
for (const name of ['ffmpeg', 'ffprobe']) {
|
|
483
|
-
const cached = path.join(directory, `${name}${suffix}`);
|
|
484
|
-
let entry;
|
|
485
|
-
if (override) {
|
|
486
|
-
const file = path.join(override, `${name}${suffix}`);
|
|
487
|
-
entry = { source: 'override', path: file, present: await isExecutable(file) };
|
|
488
|
-
} else if (cacheVerified) {
|
|
489
|
-
entry = { source: 'cache', path: cached, present: await isExecutable(cached), verified: true };
|
|
490
|
-
} else {
|
|
491
|
-
const candidate = await find([name], { excludeFiles });
|
|
492
|
-
const found = await excludedPath(candidate, excludeFiles) ? undefined : candidate;
|
|
493
|
-
entry = found ? { source: 'path', path: found, present: true, verified: false }
|
|
494
|
-
: platform !== 'android' && await lstat(cached).then(() => true, () => false)
|
|
495
|
-
? { source: 'cache', path: cached, present: false, verified: false }
|
|
496
|
-
: { source: 'missing', path: undefined, present: false, verified: false };
|
|
497
|
-
}
|
|
498
|
-
report[name] = entry;
|
|
499
|
-
}
|
|
500
|
-
return report;
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
/**
|
|
504
|
-
* Resolve native tools, installing missing tools on first use (unless offline).
|
|
505
|
-
* VEO_YT_DLP_PATH: trusted executable file; bypasses acquisition/pinned hash checks.
|
|
506
|
-
* VEO_FFMPEG_PATH: directory containing both ffmpeg[.exe] and ffprobe[.exe].
|
|
507
|
-
* Relative overrides resolve against cwd. Trusted system installations remain
|
|
508
|
-
* available when the managed cache is absent or cannot serve this platform.
|
|
509
|
-
* onStatus receives plain strings. Throws on cancellation or acquisition failure.
|
|
510
|
-
*/
|
|
511
|
-
export async function resolveBackend({ signal, onStatus, onStatusDone, offline = false, platform = process.platform, find = findOnPath,
|
|
512
|
-
setupTermux = installTermuxTools, hasEjs = hasTermuxEjs, run = runSetup } = {}) {
|
|
513
|
-
if (signal !== undefined && !(signal instanceof AbortSignal)) throw new TypeError('signal must be an AbortSignal.');
|
|
514
|
-
if (onStatus !== undefined && typeof onStatus !== 'function') throw new TypeError('onStatus must be a function.');
|
|
515
|
-
signal?.throwIfAborted();
|
|
516
|
-
const status = onStatus || (() => {});
|
|
517
|
-
const statusDone = onStatusDone || (() => {});
|
|
518
|
-
const ytOverride = envPath('VEO_YT_DLP_PATH');
|
|
519
|
-
if (platform === 'android') {
|
|
520
|
-
const mediaOverride = envPath('VEO_FFMPEG_PATH');
|
|
521
|
-
// Invalid explicit paths must fail before a package-manager mutation.
|
|
522
|
-
if (ytOverride) await executable(ytOverride, 'VEO_YT_DLP_PATH');
|
|
523
|
-
if (mediaOverride) {
|
|
524
|
-
await executable(path.join(mediaOverride, 'ffmpeg'), 'VEO_FFMPEG_PATH ffmpeg');
|
|
525
|
-
await executable(path.join(mediaOverride, 'ffprobe'), 'VEO_FFMPEG_PATH ffprobe');
|
|
526
|
-
}
|
|
527
|
-
const discover = async () => ({
|
|
528
|
-
ytDlp: ytOverride || await find(['yt-dlp']),
|
|
529
|
-
ffmpeg: mediaOverride ? path.join(mediaOverride, 'ffmpeg') : await find(['ffmpeg']),
|
|
530
|
-
ffprobe: mediaOverride ? path.join(mediaOverride, 'ffprobe') : await find(['ffprobe']),
|
|
531
|
-
ejs: Boolean(ytOverride) || await hasEjs({ find, signal, run }),
|
|
532
|
-
});
|
|
533
|
-
let tools = await discover();
|
|
534
|
-
const ready = value => value.ytDlp && value.ffmpeg && value.ffprobe && value.ejs;
|
|
535
|
-
if (!ready(tools)) {
|
|
536
|
-
if (offline) throw new Error(`Termux tools are missing (yt-dlp, FFmpeg/FFprobe or JavaScript support). Run veo doctor fix without --offline, or: ${TERMUX_SETUP}`);
|
|
537
|
-
await setupTermux({ find, signal, status, run });
|
|
538
|
-
tools = await discover();
|
|
539
|
-
if (!ready(tools)) throw new Error(`Termux setup finished but tools are still missing. Check PATH and run: ${TERMUX_SETUP}`);
|
|
540
|
-
for (const [file, args] of [[tools.ytDlp, ['--version']], [tools.ffmpeg, ['-version']], [tools.ffprobe, ['-version']]]) {
|
|
541
|
-
await run(file, args, { signal, timeoutMs: 15_000 });
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
const { ytDlp, ffmpeg, ffprobe } = tools;
|
|
545
|
-
await executable(ffmpeg, 'ffmpeg');
|
|
546
|
-
await executable(ffprobe, 'ffprobe');
|
|
547
|
-
if (path.dirname(ffmpeg) !== path.dirname(ffprobe)) throw new Error('Set VEO_FFMPEG_PATH to a directory containing both ffmpeg and ffprobe.');
|
|
548
|
-
status(ytOverride ? 'Using VEO_YT_DLP_PATH override.' : 'Using system yt-dlp (Termux package; not pinned by veo).');
|
|
549
|
-
signal?.throwIfAborted();
|
|
550
|
-
return { ytDlp, ffmpegLocation: path.dirname(ffmpeg) };
|
|
551
|
-
}
|
|
552
|
-
// Diagnostic reports expose glibc when linked against it; no shell probe needed.
|
|
553
|
-
const musl = process.platform === 'linux' && !process.report.getReport().header.glibcVersionRuntime;
|
|
554
|
-
const asset = ytOverride ? undefined : selectAsset(process.platform, process.arch, musl);
|
|
555
|
-
if (!ytOverride && !asset) throw new Error(`No standalone yt-dlp is available for ${process.platform}/${process.arch}. Set VEO_YT_DLP_PATH to a trusted executable.`);
|
|
556
|
-
// Validate overrides and dependencies before doing any network work.
|
|
557
|
-
const ytDlp = ytOverride ? await executable(ytOverride, 'VEO_YT_DLP_PATH') : undefined;
|
|
558
|
-
// An explicitly installed newer backend takes precedence over the pinned one.
|
|
559
|
-
// Media tools stay in the pinned release directory so they are never duplicated.
|
|
560
|
-
const installed = ytDlp ? null : await activeOverride(asset, { signal });
|
|
561
|
-
const directory = cacheDirectory();
|
|
562
|
-
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
563
|
-
const ffmpegLocation = await resolveMediaTools({ signal, status, statusDone, directory, offline });
|
|
564
|
-
if (ytDlp) status('Using VEO_YT_DLP_PATH override.');
|
|
565
|
-
else if (installed) status(`Using the installed yt-dlp ${installed.release}.`);
|
|
566
|
-
if (offline && !ytDlp && !installed && !await matches(path.join(directory, `yt-dlp${exeSuffix()}`), HASHES[asset], signal)) {
|
|
567
|
-
throw new Error('yt-dlp is missing or damaged. Run veo doctor fix without --offline to download it.');
|
|
568
|
-
}
|
|
569
|
-
const backend = ytDlp || installed?.path || await acquire(asset, directory, signal, status, statusDone);
|
|
570
|
-
signal?.throwIfAborted();
|
|
571
|
-
return { ytDlp: backend, ffmpegLocation };
|
|
572
|
-
}
|