veodl 1.9.0 → 1.10.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/src/config.js CHANGED
@@ -4,13 +4,13 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { configBase } from './paths.js';
6
6
  import { configSyntaxError } from './config-errors.js';
7
- import { CONFIG_TEMPLATE, stripConfigComments, withConfigTemplate } from './config-template.js';
7
+ import { CONFIG_TEMPLATE, CONFIG_GUIDE, stripConfigComments } from './config-template.js';
8
8
 
9
9
  /**
10
10
  * Keys accepted in the config file, with the CLI flag they act as a default for.
11
11
  * Everything here is a default only: an explicit command-line flag always wins.
12
12
  */
13
- export const CONFIG_KEYS = Object.freeze({
13
+ export const CONFIG_KEYS = Object.freeze({
14
14
  concurrentDownloads: 'number',
15
15
  adaptiveConcurrency: 'boolean',
16
16
  filenameTemplate: 'string',
@@ -47,7 +47,9 @@ export const CONFIG_KEYS = Object.freeze({
47
47
  timeout: 'string',
48
48
  listSources: 'boolean',
49
49
  autoListSources: 'boolean',
50
- });
50
+ });
51
+
52
+ export const RESERVED_PROFILE_NAMES = Object.freeze(['list', 'reset']);
51
53
 
52
54
  export function configFile({ env = process.env, platform = process.platform } = {}) {
53
55
  const override = env.VEO_CONFIG;
@@ -94,13 +96,19 @@ export function parseConfigText(text, target = 'config') {
94
96
  }
95
97
  const warnings = [];
96
98
  const config = {};
97
- for (const [key, value] of Object.entries(data)) {
98
- if (key === '$schema') continue;
99
+ for (const [key, value] of Object.entries(data)) {
100
+ if (key === '$schema') continue;
101
+ if (key === 'activeProfile') {
102
+ if (typeOf(value) !== 'string' || !value) throw new Error(`Config key "activeProfile" must be a non-empty string (${target}).`);
103
+ config.activeProfile = value;
104
+ continue;
105
+ }
99
106
  if (key === 'profiles') {
100
107
  if (!value || typeOf(value) !== 'object') throw new Error('Config profiles must be an object.');
101
- config.profiles = Object.create(null);
102
- for (const [name, profile] of Object.entries(value)) {
103
- if (!profile || typeOf(profile) !== 'object') throw new Error(`Profile "${name}" must be an object.`);
108
+ config.profiles = Object.create(null);
109
+ for (const [name, profile] of Object.entries(value)) {
110
+ if (RESERVED_PROFILE_NAMES.includes(name)) throw new Error(`Profile name "${name}" is reserved for veo profile ${name}. Rename it in ${target}.`);
111
+ if (!profile || typeOf(profile) !== 'object') throw new Error(`Profile "${name}" must be an object.`);
104
112
  const checked = {};
105
113
  for (const [setting, item] of Object.entries(profile)) {
106
114
  if (!Object.hasOwn(CONFIG_KEYS, setting)) throw new Error(`Unknown setting "${setting}" in profile "${name}".`);
@@ -119,20 +127,95 @@ export function parseConfigText(text, target = 'config') {
119
127
  if (typeOf(value) !== expected) {
120
128
  throw new Error(`Config key "${key}" must be a ${expected}, not a ${typeOf(value)} (${target}).`);
121
129
  }
122
- config[key] = value;
123
- }
124
- return { file: target, exists: true, config, warnings };
125
- }
126
-
127
- export function applyProfile(config, name) {
128
- const { profiles, ...defaults } = config;
129
- if (!name) {
130
- if (!profiles || !Object.hasOwn(profiles, 'default')) return defaults;
131
- name = 'default';
132
- }
133
- if (!profiles || !Object.hasOwn(profiles, name)) throw new Error(`Unknown profile "${name}". Available: ${Object.keys(profiles || {}).join(', ') || 'none'}. Use veo config edit.`);
134
- return { ...defaults, ...profiles[name] };
135
- }
130
+ config[key] = value;
131
+ }
132
+ if (config.activeProfile && !Object.hasOwn(config.profiles || {}, config.activeProfile)) {
133
+ throw new Error(`Unknown active profile "${config.activeProfile}" in ${target}. Available: ${Object.keys(config.profiles || {}).join(', ') || 'none'}.`);
134
+ }
135
+ return { file: target, exists: true, config, warnings };
136
+ }
137
+
138
+ export function selectedProfileName(config, name) {
139
+ return name || config.activeProfile || (Object.hasOwn(config.profiles || {}, 'default') ? 'default' : undefined);
140
+ }
141
+
142
+ export function applyProfile(config, name) {
143
+ const { profiles, activeProfile, ...defaults } = config;
144
+ name = selectedProfileName(config, name);
145
+ if (!name) return defaults;
146
+ if (!profiles || !Object.hasOwn(profiles, name)) throw new Error(`Unknown profile "${name}". Available: ${Object.keys(profiles || {}).join(', ') || 'none'}. Use veo config edit.`);
147
+ return { ...defaults, ...profiles[name] };
148
+ }
149
+
150
+ export async function profileMain(args, { file = configFile(), output = process.stdout } = {}) {
151
+ if (args.length > 1 || args[0]?.startsWith('-')) throw new Error('Usage: veo profile [list|reset|NAME]');
152
+ const loaded = await loadConfig({ file });
153
+ if (!args.length) {
154
+ output.write(`Active profile: ${selectedProfileName(loaded.config) || 'global (no profile)'}\n`);
155
+ output.write(`Available: ${Object.keys(loaded.config.profiles || {}).join(', ') || 'none'}\n`);
156
+ return 0;
157
+ }
158
+ if (args[0] === 'list' && args.length === 1) {
159
+ const names = Object.keys(loaded.config.profiles || {});
160
+ if (!names.length) output.write('No profiles configured. Use veo config edit.\n');
161
+ else {
162
+ const active = selectedProfileName(loaded.config);
163
+ output.write(`Profiles:\n${active ? '' : '* global (no profile)\n'}${names.map(name => `${name === active ? '* ' : ' '}${name}${name === active ? ' (active)' : ''}`).join('\n')}\n`);
164
+ }
165
+ return 0;
166
+ }
167
+ const reset = args[0] === 'reset' && args.length === 1;
168
+ const name = args[0];
169
+ if (!reset) {
170
+ applyProfile(loaded.config, name);
171
+ await effectiveConfig(loaded.config, name);
172
+ }
173
+ if (!loaded.exists) {
174
+ output.write('Active profile: global (no profile)\n');
175
+ return 0;
176
+ }
177
+ const original = await readFile(file, 'utf8');
178
+ // Token locations come from the comment-aware parser, preserving hand-written notes.
179
+ const { configSource } = await import('./config-diagnostics.js');
180
+ const { nodes } = configSource(original);
181
+ const property = nodes.get(JSON.stringify(['activeProfile']));
182
+ let updated;
183
+ if (reset) {
184
+ if (!property) updated = original;
185
+ else {
186
+ const clean = (original.startsWith('\uFEFF') ? ' ' : '') + stripConfigComments(original);
187
+ let start = property.keyStart, end = property.end;
188
+ while (/\s/.test(clean[end] || '')) end++;
189
+ if (clean[end] === ',') end++;
190
+ else {
191
+ let before = start - 1;
192
+ while (before >= 0 && /\s/.test(clean[before])) before--;
193
+ if (clean[before] === ',') start = before;
194
+ end = property.end;
195
+ }
196
+ updated = original.slice(0, start) + original.slice(end);
197
+ }
198
+ } else if (property) updated = original.slice(0, property.start) + JSON.stringify(name) + original.slice(property.end);
199
+ else {
200
+ const root = nodes.get('[]');
201
+ const clean = (original.startsWith('\uFEFF') ? ' ' : '') + stripConfigComments(original);
202
+ const first = root.start + 1 + clean.slice(root.start + 1, root.end - 1).search(/\S/);
203
+ const empty = first === root.start;
204
+ const newline = original.includes('\r\n') ? '\r\n' : '\n';
205
+ const position = empty ? root.end - 1 : first;
206
+ const indent = empty ? ' ' : /^[ \t]*$/.test(original.slice(original.lastIndexOf('\n', position - 1) + 1, position))
207
+ ? original.slice(original.lastIndexOf('\n', position - 1) + 1, position) : '';
208
+ updated = original.slice(0, position) + (empty ? `${newline}${indent}` : '') +
209
+ `"activeProfile": ${JSON.stringify(name)}${empty ? `${newline}` : `,${newline}${indent}`}` + original.slice(position);
210
+ }
211
+ parseConfigText(updated, file);
212
+ if (updated !== original) {
213
+ if (await readFile(file, 'utf8') !== original) throw new Error('Config changed while switching profiles. Try again.');
214
+ await writeFile(file, updated, 'utf8');
215
+ }
216
+ output.write(`Active profile: ${reset ? selectedProfileName(parseConfigText(updated, file).config) || 'global (no profile)' : name}\n`);
217
+ return 0;
218
+ }
136
219
 
137
220
  export async function prepareConfigEdit(file) {
138
221
  await mkdir(path.dirname(file), { recursive: true });
@@ -140,8 +223,7 @@ export async function prepareConfigEdit(file) {
140
223
  catch (error) {
141
224
  if (error.code !== 'EEXIST') throw error;
142
225
  const original = await readFile(file, 'utf8');
143
- const annotated = withConfigTemplate(original);
144
- if (annotated !== original) await writeFile(file, annotated, 'utf8');
226
+ if (!original.replace(/^\uFEFF/, '').trim()) await writeFile(file, CONFIG_TEMPLATE, 'utf8');
145
227
  }
146
228
  }
147
229
 
@@ -149,7 +231,7 @@ export async function configMain(args) {
149
231
  const editorColor = !args.includes('--no-color') && !Object.hasOwn(process.env, 'NO_COLOR');
150
232
  let stdout;
151
233
  [args, stdout] = commandOutput(args, process.stdout);
152
- if (args[0] === 'show') stdout = process.stdout;
234
+ if (args[0] === 'show' || args[0] === 'guide') stdout = process.stdout;
153
235
  const file = configFile();
154
236
  if (args[0] === 'reset') {
155
237
  if (args.length !== 1) throw new Error('Usage: veo config reset');
@@ -179,8 +261,9 @@ export async function configMain(args) {
179
261
  if (args.length > 2 || (args[1] && !['--external', '--terminal'].includes(args[1]))) throw new Error('Usage: veo config edit [--external|--terminal]');
180
262
  editorMode = args[1]?.slice(2) || process.env.VEO_CONFIG_EDITOR || 'auto';
181
263
  if (!['auto', 'external', 'terminal'].includes(editorMode)) throw new Error('VEO_CONFIG_EDITOR must be auto, external or terminal.');
182
- } else if (args.length !== 1 || !['path', 'profiles'].includes(args[0])) throw new Error('Usage: veo config edit|path|profiles|check|show|reset');
183
- if (args[0] === 'path') { stdout.write(`${file}\n`); return 0; }
264
+ } else if (args.length !== 1 || !['path', 'profiles', 'guide'].includes(args[0])) throw new Error('Usage: veo config edit|path|profiles|guide|check|show|reset');
265
+ if (args[0] === 'path') { stdout.write(`${file}\n`); return 0; }
266
+ if (args[0] === 'guide') { stdout.write(CONFIG_GUIDE); return 0; }
184
267
  if (args[0] === 'profiles') {
185
268
  const loaded = await loadConfig();
186
269
  stdout.write(`${Object.keys(loaded.config.profiles || {}).join('\n') || 'No profiles configured. Use veo config edit.'}\n`);
package/src/downloader.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { ensureCompatibility } from './compatibility.js';
2
- import { adaptiveRun, reducedLimit, phaseTimer, formatTimings } from './execution.js';
2
+ import { adaptiveRun, reducedLimit, phaseTimer } from './execution.js';
3
3
  import { mediaDestination, prepareDestination } from './naming.js';
4
4
  import { estimateMediaBytes, reserveSpace } from './disk-space.js';
5
5
  import { spawn } from 'node:child_process';
@@ -288,20 +288,26 @@ export async function fetchMetadata(options, { signal, backend, runner, reporter
288
288
  const args = [...common, '--dump-single-json', '--skip-download'];
289
289
  if (options.playlist && !options._entryIndex) args.push('--flat-playlist');
290
290
  args.push('--', options.mediaUrl || options.url);
291
- let metadata = JSON.parse(await runner(backend.ytDlp, args, { signal }));
292
- if (options._entryIndex && metadata.entries) metadata = metadata.entries.find(Boolean);
293
- if (!metadata) throw new Error('The selected playlist entry is unavailable.');
294
- if (!options.playlist && (metadata._type === 'playlist' || metadata.entries)) {
295
- throw new Error('This URL is a collection. Add --playlist to download every entry.');
296
- }
297
- if (metadata.is_live) throw new Error('Live streams are not supported. Please use a finished video.');
298
- if (metadata.has_drm) throw new Error('This content is DRM-protected.');
299
- return metadata;
300
- }
301
-
302
- export async function prepareBackend(options, { signal, backendResolver = resolveBackend, reporter } = {}) {
303
- return backendResolver({ signal, onStatus: message => reporter?.status(message) });
304
- }
291
+ try {
292
+ let metadata = JSON.parse(await runner(backend.ytDlp, args, { signal }));
293
+ if (options._entryIndex && metadata.entries) metadata = metadata.entries.find(Boolean);
294
+ if (!metadata) throw new Error('The selected playlist entry is unavailable.');
295
+ if (!options.playlist && (metadata._type === 'playlist' || metadata.entries)) {
296
+ throw new Error('This URL is a collection. Add --playlist to download every entry.');
297
+ }
298
+ if (metadata.is_live) throw new Error('Live streams are not supported. Please use a finished video.');
299
+ if (metadata.has_drm) throw new Error('This content is DRM-protected.');
300
+ reporter?.finishStatus?.('Reading video…', 'done');
301
+ return metadata;
302
+ } catch (error) {
303
+ if (!signal?.aborted && error?.name !== 'AbortError') reporter?.finishStatus?.('Reading video…', 'failed');
304
+ throw error;
305
+ }
306
+ }
307
+
308
+ export async function prepareBackend(options, { signal, backendResolver = resolveBackend, reporter } = {}) {
309
+ return backendResolver({ signal, onStatus: message => reporter?.status(message), onStatusDone: (message, outcome) => reporter?.finishStatus?.(message, outcome) });
310
+ }
305
311
 
306
312
  /** The extension the finished file will most likely have, for previews. */
307
313
  export function predictedExtension(options) {
@@ -411,10 +417,10 @@ async function downloadCollection(options, metadata, dependencies) {
411
417
  */
412
418
  export async function download(options, { signal, reporter, backendResolver = resolveBackend, runner = runBackend, onEntry, localRoot = downloadCacheRoot(), skipCacheCleanup = false, adaptiveState = { divisor: 1 }, wait, diskChecker = reserveSpace, now, compatibilityRunner = runBackend } = {}) {
413
419
  const timer = phaseTimer(now);
414
- const timingResult = () => {
415
- if (options.timings === false) return {};
416
- const timings = timer.result(); reporter?.status(formatTimings(timings)); return { timings };
417
- };
420
+ const timingResult = () => {
421
+ if (options.timings === false) return {};
422
+ return { timings: timer.result() };
423
+ };
418
424
  let directory = path.resolve(options.output);
419
425
  localRoot = path.resolve(localRoot);
420
426
  if (!skipCacheCleanup) await cleanupDownloadCache(localRoot);
@@ -548,8 +554,9 @@ export async function download(options, { signal, reporter, backendResolver = re
548
554
  }
549
555
  await writeJson(historyFile, { version: 1, source: sourceKey(metadata, options.url), settings: downloadSettings(options), files });
550
556
  return { url: options.url, title: metadata.title || metadata.id || 'video', files, status: 'saved', saved: 1, skipped: 0, ...timingResult() };
551
- } catch (error) {
552
- // A kept staging directory is the whole point of --resume, and cancellation
557
+ } catch (error) {
558
+ if (!signal?.aborted) reporter?.failStep?.();
559
+ // A kept staging directory is the whole point of --resume, and cancellation
553
560
  // is the most common reason to want one.
554
561
  keepPartial = Boolean(options.resume || manifest?.ready?.length || unconfirmedOutput);
555
562
  if (manifest && unconfirmedOutput) manifest.unconfirmedOutput = true;
@@ -1,7 +1,7 @@
1
1
  import { outputStream, formatOutput } from './output.js';
2
2
  import { createInterface } from 'node:readline/promises';
3
3
  import { availableHeights, cleanText, validateUrl } from './utils.js';
4
- import { applyProfile } from './config.js';
4
+ import { applyProfile, selectedProfileName } from './config.js';
5
5
  import { fetchMetadata, prepareBackend, runBackend } from './downloader.js';
6
6
  import { describeEstimate, selectedEntries, sizeEstimate, validateItems } from './playlist.js';
7
7
 
@@ -25,9 +25,9 @@ export async function interactiveArgs(config, { signal, input = process.stdin, o
25
25
  const profiles = Object.keys(config.profiles || {});
26
26
  let defaults = applyProfile(config);
27
27
  if (profiles.length) {
28
- const hasDefault = profiles.includes('default');
29
- const choices = hasDefault ? profiles : [...profiles, 'none'];
30
- const fallback = hasDefault ? 'default' : 'none';
28
+ const selected = selectedProfileName(config);
29
+ const choices = selected ? profiles : [...profiles, 'none'];
30
+ const fallback = selected || 'none';
31
31
  const profile = await choose(`Profile (${choices.join(', ')}) [${fallback}]: `, choices, fallback);
32
32
  if (profile !== 'none') { args.push('--profile', profile); defaults = applyProfile(config, profile); }
33
33
  }
@@ -53,24 +53,24 @@ export async function interactiveArgs(config, { signal, input = process.stdin, o
53
53
  } catch (originalError) {
54
54
  const { discoverSources, formatSource, parseSourceTimeout, shouldOfferSourceDiscovery } = await import('./source-discovery.js');
55
55
  if (!shouldOfferSourceDiscovery(originalError)) throw originalError;
56
- if (!defaults.autoListSources) {
57
- const answer = (await question('No downloadable video found. Search this page for other sources? (y/N): ')).trim().toLowerCase();
58
- if (!['y', 'yes'].includes(answer)) throw originalError;
59
- }
56
+ if (defaults.autoListSources === false) throw originalError;
60
57
  const inspectSource = (mediaUrl, _page, { signal: scanSignal = signal } = {}) => inspect
61
58
  ? inspect({ ...inspectionOptions, playlist: false, mediaUrl }, scanSignal)
62
59
  : fetchMetadata({ ...inspectionOptions, playlist: false, mediaUrl }, { signal: scanSignal, backend, runner: runBackend });
63
- const sources = await (discover || discoverSources)(url, { signal, inspect: inspectSource,
64
- deepScan: defaults.deepScan, timeoutMs: defaults.timeout ? parseSourceTimeout(defaults.timeout) : undefined }).catch(() => []);
65
- if (!sources.length) throw originalError;
66
- for (const source of sources) output.write(`${formatSource(source)}\n`);
67
- let selection;
68
- while (!selection) {
69
- const answer = (await question('Source number: ')).trim();
70
- const index = Number(answer);
71
- if (/^[1-9]\d*$/.test(answer) && sources[index - 1]) selection = sources[index - 1];
72
- else output.write(`Choose a number from 1 to ${sources.length}.\n`);
73
- }
60
+ const sources = await (discover || discoverSources)(url, { signal, inspect: inspectSource,
61
+ deepScan: defaults.deepScan, timeoutMs: defaults.timeout ? parseSourceTimeout(defaults.timeout) : undefined }).catch(() => []);
62
+ if (!sources.length) throw originalError;
63
+ let selection = sources[0];
64
+ if (sources.length > 1) {
65
+ for (const source of sources) output.write(`${formatSource(source)}\n`);
66
+ selection = null;
67
+ while (!selection) {
68
+ const answer = (await question('Source number: ')).trim();
69
+ const index = Number(answer);
70
+ if (/^[1-9]\d*$/.test(answer) && sources[index - 1]) selection = sources[index - 1];
71
+ else output.write(`Choose a number from 1 to ${sources.length}.\n`);
72
+ }
73
+ } else output.write(`Found video source: ${formatSource(selection)}\n`);
74
74
  args.push('--source', String(selection.index));
75
75
  metadata = await inspectSource(selection.url);
76
76
  }
package/src/jobs.js CHANGED
@@ -1,4 +1,4 @@
1
- import { runPool, serialQueue, reducedLimit } from './execution.js';
1
+ import { runPool, serialQueue, reducedLimit, formatTimings } from './execution.js';
2
2
  import { styleText } from './progress.js';
3
3
  import path from 'node:path';
4
4
  import { lstat, readdir } from 'node:fs/promises';
@@ -126,14 +126,14 @@ export async function runJob(options, { download, reporter, signal, openFile, st
126
126
  skipped += result.skipped ?? 0;
127
127
  failed += result.failures?.length ?? (item.status === 'failed' ? 1 : 0);
128
128
  }
129
- if (options.json) stdout.write(`${JSON.stringify({ ...result, status: item.status, ...(runId ? { runId } : {}) })}\n`);
129
+ if (options.json) stdout.write(`${JSON.stringify({ ...result, status: item.status, profile: request.profile || null, ...(runId ? { runId } : {}) })}\n`);
130
130
  await publish(result.files);
131
131
  } catch (error) {
132
132
  item.status = signal?.aborted ? 'cancelled' : 'failed';
133
133
  item.error = readableError(error);
134
134
  if (!signal?.aborted) failed++;
135
135
  item.files = item.entries.flatMap(entry => entry.files || []);
136
- if (options.json) stdout.write(`${JSON.stringify({ url: request.url, status: item.status, error: item.error, files: item.files, ...(runId ? { runId } : {}) })}\n`);
136
+ if (options.json) stdout.write(`${JSON.stringify({ url: request.url, status: item.status, profile: request.profile || null, error: item.error, files: item.files, ...(runId ? { runId } : {}) })}\n`);
137
137
  else stderr.write(styleText(stderr, `veo: ${cleanText(request.url)}: ${item.error}`, 'error', options.color !== false) + '\n');
138
138
  }
139
139
  if (recordStats) {
@@ -165,7 +165,16 @@ export async function runJob(options, { download, reporter, signal, openFile, st
165
165
  stderr.write(`Run ID: ${runId}\n`);
166
166
  }
167
167
  const unfinished = job.items.some(item => !['saved', 'skipped'].includes(item.status));
168
- stderr.write(styleText(stderr, `Summary: ${saved} saved, ${skipped} skipped, ${failed} failed${signal?.aborted ? ', cancelled' : ''}.`, unfinished ? 'error' : 'success', options.color !== false && !options.json) + '\n');
168
+ const downloadCount = job.items.reduce((count, item) => count + Math.max(1, item.entries?.length || 0), 0);
169
+ if (downloadCount > 1) stderr.write(styleText(stderr, `Summary: ${saved} saved, ${skipped} skipped, ${failed} failed${signal?.aborted ? ', cancelled' : ''}.`, unfinished ? 'error' : 'success', options.color !== false && !options.json) + '\n');
170
+ if (options.timings !== false) {
171
+ const totals = {};
172
+ for (const item of job.items) for (const timings of [item.timings, ...(item.entryTimings || []).map(entry => entry.timings)]) {
173
+ if (!timings) continue;
174
+ for (const [phase, ms] of Object.entries(timings)) totals[phase] = (totals[phase] || 0) + ms;
175
+ }
176
+ if (Object.keys(totals).length) stderr.write(styleText(stderr, formatTimings(totals), 'muted', options.color !== false && !options.json) + '\n');
177
+ }
169
178
  if (unfinished) stderr.write(`Retry failed/unfinished downloads: veo --retry-failed "${file}"\n`);
170
179
  if (unfinished) reporter.fail(Boolean(signal?.aborted)); else reporter.complete();
171
180
  return signal?.aborted ? 130 : unfinished ? 1 : 0;
@@ -0,0 +1,43 @@
1
+ // Match misspelled flags without silently accepting them. Ignore punctuation
2
+ // differences first, then allow a small number of insertions or transpositions.
3
+ function editDistance(left, right) {
4
+ const rows = Array.from({ length: left.length + 1 }, () => Array(right.length + 1).fill(0));
5
+ for (let i = 0; i <= left.length; i++) rows[i][0] = i;
6
+ for (let j = 0; j <= right.length; j++) rows[0][j] = j;
7
+ for (let i = 1; i <= left.length; i++) {
8
+ for (let j = 1; j <= right.length; j++) {
9
+ rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1,
10
+ rows[i - 1][j - 1] + (left[i - 1] === right[j - 1] ? 0 : 1));
11
+ if (i > 1 && j > 1 && left[i - 1] === right[j - 2] && left[i - 2] === right[j - 1]) {
12
+ rows[i][j] = Math.min(rows[i][j], rows[i - 2][j - 2] + 1);
13
+ }
14
+ }
15
+ }
16
+ return rows[left.length][right.length];
17
+ }
18
+
19
+ export function suggestOption(unknown, options) {
20
+ const normalized = value => value.toLowerCase().replace(/-/g, '');
21
+ const target = normalized(unknown);
22
+ const prefix = unknown.startsWith('--') ? '--' : unknown.startsWith('-') ? '-' : null;
23
+ const candidates = [...new Set(options)].filter(option => prefix ? option.startsWith(prefix) : !option.startsWith('-'));
24
+ const ranked = candidates.map(option => ({ option, distance: editDistance(target, normalized(option)) }))
25
+ .sort((a, b) => a.distance - b.distance || a.option.length - b.option.length);
26
+ const best = ranked[0];
27
+ return best && best.distance <= Math.min(3, Math.max(1, Math.floor(target.length / 3))) ? best.option : null;
28
+ }
29
+
30
+ export function optionSpellings(schema) {
31
+ return Object.entries(schema).flatMap(([name, details]) => [
32
+ `--${name}`, ...(details.short ? [`-${details.short}`] : []),
33
+ ...(details.type === 'boolean' ? [`--no-${name}`] : []),
34
+ ]);
35
+ }
36
+
37
+ export function explainUnknownOption(error, schema) {
38
+ if (error?.code !== 'ERR_PARSE_ARGS_UNKNOWN_OPTION') return error;
39
+ const unknown = /Unknown option '([^']+)'/.exec(error.message)?.[1];
40
+ if (!unknown) return error;
41
+ const suggestion = suggestOption(unknown, optionSpellings(schema));
42
+ return new Error(`Unknown option "${unknown}".${suggestion ? ` Did you mean "${suggestion}"?` : ' Run veo --help to see available options.'}`, { cause: error });
43
+ }
package/src/progress.js CHANGED
@@ -5,7 +5,7 @@ const PROCESSING_LABELS = { Merger: 'Merging audio/video', VideoRemuxer: 'Changi
5
5
 
6
6
  export function styleText(stream, text, role = 'muted', enabled = true, env = process.env) {
7
7
  if (!enabled || !stream.isTTY || Object.hasOwn(env, 'NO_COLOR') || env.TERM === 'dumb') return text;
8
- const codes = { muted: 90, title: 1, success: 32, error: 31 };
8
+ const codes = { muted: 90, title: 1, profile: 97, success: 32, error: 31 };
9
9
  return '\x1b[' + (codes[role] || 90) + 'm' + text + '\x1b[0m';
10
10
  }
11
11
 
@@ -56,10 +56,10 @@ export function formatProgress(data, { columns = Infinity, prefix = '' } = {}) {
56
56
  return fit(variants.at(-1), columns);
57
57
  }
58
58
 
59
- export function createReporter(stream = process.stderr, { setTitle = createTerminalTitle(stream) } = {}) {
59
+ export function createReporter(stream = process.stderr, { setTitle = createTerminalTitle(stream), env = process.env } = {}) {
60
60
  let color = true;
61
- const muted = text => styleText(stream, text, 'muted', color);
62
- const heading = text => styleText(stream, text, 'title', color);
61
+ const muted = text => styleText(stream, text, 'muted', color, env);
62
+ const heading = text => styleText(stream, text, 'title', color, env);
63
63
  let active = false;
64
64
  let lastLog = 0;
65
65
  let name = '';
@@ -68,27 +68,64 @@ export function createReporter(stream = process.stderr, { setTitle = createTermi
68
68
  let position = '';
69
69
  let hasItem = false;
70
70
  let streamName = '';
71
+ let animation;
72
+ let animationLabel = '';
73
+ let animationOwner;
74
+ let animationFrame = 0;
75
+ const stopAnimation = () => { if (animation) clearInterval(animation); animation = undefined; };
76
+ const display = value => fit(value, stream.isTTY ? Math.max(1, (stream.columns || 80) - 1) : Infinity);
77
+ const animate = (label, owner) => {
78
+ clear();
79
+ if (!stream.isTTY) { stream.write(muted(label + '…') + '\n'); return; }
80
+ animationLabel = label;
81
+ animationOwner = owner;
82
+ animationFrame = 0;
83
+ const tick = () => { stream.write(`\r\x1b[2K${muted(display(animationLabel + '.'.repeat(animationFrame % 3 + 1)))}`); active = true; animationFrame++; };
84
+ tick();
85
+ animation = setInterval(tick, 350);
86
+ animation.unref?.();
87
+ };
88
+ const processingResult = (label, status) => {
89
+ clear();
90
+ const failed = status === 'failed' || status === 'error';
91
+ const suffix = failed ? 'failed' : 'done';
92
+ const width = stream.isTTY ? Math.max(1, (stream.columns || 80) - 1) : Infinity;
93
+ const result = `${fit(label, Math.max(0, width - suffix.length - 2))}: `;
94
+ stream.write(muted(result) + styleText(stream, suffix, failed ? 'error' : 'success', color, env) + '\n');
95
+ };
96
+ const finishStatus = (message, owner, prefix = '', outcome) => {
97
+ const label = prefix + cleanText(message);
98
+ if (!animation || animationOwner !== owner || animationLabel !== label.slice(0, -1)) return;
99
+ if (outcome) { processingResult(label.slice(0, -1), outcome); return; }
100
+ clear();
101
+ stream.write(muted(display(label)) + '\n');
102
+ };
71
103
  const line = (data, prefix) => formatProgress(data, { prefix, columns: stream.isTTY ? Math.max(1, (stream.columns || 80) - 1) : Infinity });
72
104
  const draw = (data, prefix) => { stream.write(`\r\x1b[2K${line(data, prefix)}`); active = true; };
73
105
  const updateTitle = () => setTitle(`veo | ${phase}${name ? ` | ${name}` : ''}`);
74
106
  function clear() {
107
+ stopAnimation();
75
108
  if (active && stream.isTTY) stream.write('\r\x1b[2K');
76
109
  active = false;
110
+ animationLabel = '';
111
+ animationOwner = undefined;
77
112
  }
78
113
  const scoped = (index, total, title, parent = '') => {
79
114
  let childName = cleanText(title), lastProgress = 0;
115
+ const owner = Symbol('scoped reporter');
80
116
  const prefix = parent + '[' + index + '/' + total + '] ';
81
117
  const log = (message, quiet = true) => {
82
- clear();
83
- const line = prefix + childName + ': ' + cleanText(message);
84
- stream.write((quiet ? muted(line) : line) + '\n');
85
118
  phase = cleanText(message); name = childName;
119
+ const line = prefix + childName + ': ' + phase;
120
+ if (phase.endsWith('…')) animate(line.slice(0, -1), owner);
121
+ else { clear(); stream.write((quiet ? muted(line) : line) + '\n'); }
86
122
  if (started) updateTitle();
87
123
  };
88
124
  return {
89
125
  scoped: (index, total, title) => scoped(index, total, title, prefix),
90
126
  name(value) { childName = cleanText(value); },
91
127
  status: log,
128
+ finishStatus(message, outcome) { finishStatus(message, owner, prefix + childName + ': ', outcome); },
92
129
  progress(data) {
93
130
  if (stream.isTTY) {
94
131
  draw(data, prefix + childName + ': ' + (data.stream || 'Media'));
@@ -97,8 +134,15 @@ export function createReporter(stream = process.stderr, { setTitle = createTermi
97
134
  lastProgress = Date.now();
98
135
  }
99
136
  },
100
- processing(data) { log((PROCESSING_LABELS[data.postprocessor] || 'Processing media') + (data.status === 'finished' ? ': done' : '…')); },
137
+ processing(data) {
138
+ const label = prefix + childName + ': ' + (PROCESSING_LABELS[data.postprocessor] || 'Processing media');
139
+ if (['finished', 'failed', 'error'].includes(data.status)) processingResult(label, data.status);
140
+ else animate(label, owner);
141
+ phase = `${PROCESSING_LABELS[data.postprocessor] || 'Processing media'}${data.status === 'finished' ? ': done' : '…'}`;
142
+ name = childName; if (started) updateTitle();
143
+ },
101
144
  finish() {},
145
+ failStep() { if (animation && animationOwner === owner) processingResult(animationLabel, 'failed'); },
102
146
  };
103
147
  };
104
148
  return {
@@ -112,9 +156,10 @@ export function createReporter(stream = process.stderr, { setTitle = createTermi
112
156
  processing(data) {
113
157
  const processor = cleanText(data.postprocessor || 'Processing');
114
158
  const label = PROCESSING_LABELS[processor] || 'Processing media';
115
- clear(); phase = `${label}${data.status === 'finished' ? ': done' : '…'}`;
159
+ phase = `${label}${data.status === 'finished' ? ': done' : '…'}`;
116
160
  if (started) updateTitle();
117
- stream.write(muted(`${position}${phase}`) + '\n');
161
+ if (['finished', 'failed', 'error'].includes(data.status)) processingResult(`${position}${label}`, data.status);
162
+ else animate(`${position}${label}`);
118
163
  },
119
164
  start(title = '') { started = true; name = cleanText(title); phase = 'Starting…'; updateTitle(); },
120
165
  name(title) {
@@ -123,11 +168,22 @@ export function createReporter(stream = process.stderr, { setTitle = createTermi
123
168
  name = next; if (started) updateTitle();
124
169
  },
125
170
  status(message) {
126
- clear();
127
171
  phase = cleanText(message);
128
172
  if (started) updateTitle();
129
- stream.write(muted(phase) + '\n');
173
+ if (phase.endsWith('…')) animate(phase.slice(0, -1));
174
+ else { clear(); stream.write(muted(phase) + '\n'); }
175
+ },
176
+ profile(value) {
177
+ const selected = value ? cleanText(value) : 'global (no profile)';
178
+ phase = `Profile: ${selected}`;
179
+ if (started) updateTitle();
180
+ clear();
181
+ stream.write(value && value !== 'default'
182
+ ? muted('Profile: ') + styleText(stream, selected, 'profile', color, env) + '\n'
183
+ : muted(phase) + '\n');
130
184
  },
185
+ finishStatus(message, outcome) { finishStatus(message, undefined, '', outcome); },
186
+ failStep() { if (animation && animationOwner === undefined) processingResult(animationLabel, 'failed'); },
131
187
  complete() { clear(); phase = 'Done'; if (started) updateTitle(); },
132
188
  fail(cancelled = false) { clear(); phase = cancelled ? 'Cancelled' : 'Failed'; if (started) updateTitle(); },
133
189
  progress(data) {
package/src/runs.js CHANGED
@@ -119,7 +119,7 @@ export async function registerRun(cancel, root = cacheBase()) {
119
119
  const file = path.join(directory, `${randomUUID()}.json`);
120
120
  const request = `${file}.cancel`;
121
121
  const record = { version: 1, id, pid: process.pid, startedAt: new Date().toISOString(),
122
- urls: [], output: null, media: null, quality: null, format: null, playlist: false, job: null };
122
+ urls: [], output: null, media: null, quality: null, format: null, profile: null, playlist: false, job: null };
123
123
  await writeJson(file, record);
124
124
  // A flush that started while this run registered must still win.
125
125
  if (await exists(gate)) {
@@ -146,8 +146,9 @@ export async function registerRun(cancel, root = cacheBase()) {
146
146
  urls: (Array.isArray(details.urls) ? details.urls : []).filter(url => typeof url === 'string').slice(0, 200).map(cleanText),
147
147
  output: text(details.output) || null,
148
148
  media: details.audio ? 'audio' : 'video',
149
- quality: details.audio ? null : text(details.quality) || null,
150
- format: text(details.format) || null,
149
+ quality: details.audio ? null : text(details.quality) || null,
150
+ format: text(details.format) || null,
151
+ profile: text(details.profile) || null,
151
152
  playlist: Boolean(details.playlist),
152
153
  job: text(details.job) || null,
153
154
  });
@@ -232,7 +233,8 @@ export function formatRunDetails(run) {
232
233
  const running = uptime(run);
233
234
  lines.push(`State: ${run.alive ? `running (PID ${run.pid})` : `stale (PID ${run.pid} is gone)`}`);
234
235
  lines.push(`Started: ${localStamp(run.startedAt)}${run.alive && running ? ` (${running} ago)` : ''}`);
235
- lines.push(`Media: ${describeMedia(run)}`);
236
+ lines.push(`Media: ${describeMedia(run)}`);
237
+ lines.push(`Profile: ${text(run.profile) || 'global (no profile)'}`);
236
238
  lines.push(`Output: ${text(run.output) || 'not resolved yet'}`);
237
239
  lines.push(`Job: ${text(run.job) || 'not created yet'}`);
238
240
  const urls = (Array.isArray(run.urls) ? run.urls : []).map(text).filter(Boolean);
@@ -286,7 +288,8 @@ export async function runsMain(args = [], { stdout = process.stdout, root = cach
286
288
  function publicRun(run) {
287
289
  return { id: run.id, pid: run.pid, state: run.alive ? 'running' : 'stale', startedAt: run.startedAt || null,
288
290
  urls: Array.isArray(run.urls) ? run.urls : [], output: run.output || null, media: run.media || null,
289
- quality: run.quality || null, format: run.format || null, playlist: Boolean(run.playlist), job: run.job || null,
291
+ quality: run.quality || null, format: run.format || null, profile: run.profile || null,
292
+ playlist: Boolean(run.playlist), job: run.job || null,
290
293
  progress: run.progress || null };
291
294
  }
292
295