@wavespeed/cli 0.2.5 → 0.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 +3 -1
- package/dist/commands/run.js +31 -1
- package/dist/lib/local-files.js +116 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -90,7 +90,9 @@ wavespeed run ... -p "..." --json | jq '.outputs[0]'
|
|
|
90
90
|
- **`defaultModel`** — `wavespeed run -p "…"` (no model arg) uses this. Can itself be an alias name.
|
|
91
91
|
- **`aliases`** — your own shortcuts. `wavespeed run hero -p "…"` expands to model + input. CLI `-i k=v` flags override. `wavespeed run hero -h` shows the resolved schema. List them with `wavespeed aliases`.
|
|
92
92
|
|
|
93
|
-
Resolution: positional with `/` is a model ID; otherwise looked up as an alias. Merge order on inputs: `alias.input` → `--input-file` → `-i k=v` → `-p`. **The CLI never
|
|
93
|
+
Resolution: positional with `/` is a model ID; otherwise looked up as an alias. Merge order on inputs: `alias.input` → `--input-file` → `-i k=v` → `-p`. **The CLI never rewrites your prompt or input values**, with one explicit exception: local file paths become hosted URLs.
|
|
94
|
+
|
|
95
|
+
**Local files as inputs.** `-i image=./cat.png` uploads the file first and submits its URL — no separate `wavespeed upload` step. A bare value is only treated as a file when it has a media extension (image/video/audio) *and* exists on disk; prompt fields never auto-detect. Prefix with `@` (`-i image=@./cat.png`) to force an upload of any file — then a missing file is an error instead of a pass-through. The same file referenced twice uploads once.
|
|
94
96
|
|
|
95
97
|
## Recommended starting models
|
|
96
98
|
|
package/dist/commands/run.js
CHANGED
|
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import { requireClient } from '../lib/client.js';
|
|
6
6
|
import { parseInputs } from '../lib/inputs.js';
|
|
7
|
+
import { resolveLocalFiles } from '../lib/local-files.js';
|
|
7
8
|
import { downloadOutputs, printOutputs } from '../lib/output.js';
|
|
8
9
|
import { emitJson, emitJsonError, isJsonMode, log, setJsonMode } from '../lib/log.js';
|
|
9
10
|
import { downloadOptsFromFlag } from '../lib/download-flag.js';
|
|
@@ -43,6 +44,13 @@ function failMissingModel() {
|
|
|
43
44
|
}
|
|
44
45
|
process.exit(1);
|
|
45
46
|
}
|
|
47
|
+
function failWith(msg) {
|
|
48
|
+
if (isJsonMode())
|
|
49
|
+
emitJsonError(msg);
|
|
50
|
+
else
|
|
51
|
+
console.error(chalk.red('Error: ') + msg);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
46
54
|
function failUnknownAlias(token) {
|
|
47
55
|
const msg = `"${token}" is neither a model ID (no \`/\`) nor a known alias.`;
|
|
48
56
|
if (isJsonMode())
|
|
@@ -59,7 +67,7 @@ export function registerRun(program) {
|
|
|
59
67
|
.command('run')
|
|
60
68
|
.description('Run any Wavespeed model — generic invoke with --input key=value pairs')
|
|
61
69
|
.argument('[model]', 'Model ID, alias name, or omit to use defaultModel.')
|
|
62
|
-
.option('-i, --input <pair...>', 'Inputs as key=value (repeatable).
|
|
70
|
+
.option('-i, --input <pair...>', 'Inputs as key=value (repeatable). Local media paths auto-upload; @path forces upload. Dotted keys nest.', [])
|
|
63
71
|
.option('-p, --prompt <text>', 'Shorthand for --input prompt=<text>')
|
|
64
72
|
.option('--input-file <path>', 'JSON file with full input object')
|
|
65
73
|
.option('--download [path]', 'Save outputs locally (optional path template, e.g. "./out/{index}.{ext}")')
|
|
@@ -87,6 +95,28 @@ export function registerRun(program) {
|
|
|
87
95
|
Object.assign(input, parseInputs(opts.input ?? []));
|
|
88
96
|
if (opts.prompt)
|
|
89
97
|
input.prompt = opts.prompt;
|
|
98
|
+
// Local paths become hosted URLs before submission. Uploads go straight
|
|
99
|
+
// to object storage via a presigned PUT, so this is one extra round trip
|
|
100
|
+
// per distinct file rather than a reason to make the user run `upload`
|
|
101
|
+
// first and paste the result back.
|
|
102
|
+
try {
|
|
103
|
+
const uploadSpinner = !isJsonMode() ? ora({ color: 'magenta' }) : null;
|
|
104
|
+
const resolved = await resolveLocalFiles(input, {
|
|
105
|
+
upload: (file) => client.upload(file),
|
|
106
|
+
onUpload: (file, i, total) => {
|
|
107
|
+
uploadSpinner?.start(total > 1
|
|
108
|
+
? `Uploading ${path.basename(file)} (${i + 1}/${total})…`
|
|
109
|
+
: `Uploading ${path.basename(file)}…`);
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
if (resolved.uploaded > 0) {
|
|
113
|
+
uploadSpinner?.succeed(`Uploaded ${resolved.uploaded} file${resolved.uploaded === 1 ? '' : 's'}.`);
|
|
114
|
+
}
|
|
115
|
+
input = resolved.input;
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
failWith(err.message ?? String(err));
|
|
119
|
+
}
|
|
90
120
|
log('');
|
|
91
121
|
log(chalk.bold('▶ ') +
|
|
92
122
|
chalk.cyan(model) +
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Turn local file paths in a model's input into hosted URLs.
|
|
2
|
+
//
|
|
3
|
+
// Uploading is cheap now that the platform hands out a presigned PUT and the
|
|
4
|
+
// bytes go straight to object storage, so making the user run `upload` first
|
|
5
|
+
// and paste the URL back is pure friction. This module finds the paths and
|
|
6
|
+
// swaps them for URLs; run.ts does it once, right before submitting.
|
|
7
|
+
//
|
|
8
|
+
// Two ways to point at a file:
|
|
9
|
+
//
|
|
10
|
+
// -i image=@./cat.png explicit — always uploaded, missing file is an error
|
|
11
|
+
// -i image=./cat.png implicit — uploaded only if it looks like media AND
|
|
12
|
+
// exists on disk; otherwise passed through untouched
|
|
13
|
+
//
|
|
14
|
+
// The implicit rule is deliberately narrow. A model input that happens to
|
|
15
|
+
// match a filename in the working directory must not be silently replaced by
|
|
16
|
+
// a URL, so a bare value has to clear both the extension check and the
|
|
17
|
+
// existence check. Anything already a URL is left alone.
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
// Extensions the platform's models actually accept as inputs. Kept explicit
|
|
21
|
+
// rather than "any file that exists": a bare `-i seed=1.txt` should stay a
|
|
22
|
+
// string even in a directory that happens to contain 1.txt.
|
|
23
|
+
const MEDIA_EXTENSIONS = new Set([
|
|
24
|
+
// images
|
|
25
|
+
'.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.tif', '.heic', '.heif', '.avif',
|
|
26
|
+
// video
|
|
27
|
+
'.mp4', '.mov', '.webm', '.mkv', '.avi', '.m4v',
|
|
28
|
+
// audio
|
|
29
|
+
'.mp3', '.wav', '.flac', '.m4a', '.aac', '.ogg', '.opus',
|
|
30
|
+
]);
|
|
31
|
+
// Free-text fields never auto-detect: `-p ./notes.txt` means that literal
|
|
32
|
+
// string. An explicit `@` still works if someone genuinely wants it.
|
|
33
|
+
const NO_IMPLICIT_DETECTION = new Set(['prompt', 'negative_prompt', 'system_prompt']);
|
|
34
|
+
function isRegularFile(p) {
|
|
35
|
+
try {
|
|
36
|
+
return fs.statSync(p).isFile();
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function isUrl(value) {
|
|
43
|
+
return /^(https?:|data:|s3:|gs:)/i.test(value);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Walk the input and list every value that should become an upload. Pure over
|
|
47
|
+
* `exists`, so the decision rules can be tested without touching the disk.
|
|
48
|
+
*/
|
|
49
|
+
export function collectLocalFiles(input, opts = {}) {
|
|
50
|
+
const exists = opts.exists ?? isRegularFile;
|
|
51
|
+
const found = [];
|
|
52
|
+
const walk = (node, at, key) => {
|
|
53
|
+
if (Array.isArray(node)) {
|
|
54
|
+
// Arrays inherit the key of their container, so `image_urls.0` is still
|
|
55
|
+
// judged as `image_urls` — element indices are not field names.
|
|
56
|
+
node.forEach((v, i) => walk(v, at ? `${at}.${i}` : String(i), key));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (node && typeof node === 'object') {
|
|
60
|
+
for (const [k, v] of Object.entries(node)) {
|
|
61
|
+
walk(v, at ? `${at}.${k}` : k, k);
|
|
62
|
+
}
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (typeof node !== 'string' || node === '')
|
|
66
|
+
return;
|
|
67
|
+
if (node.startsWith('@')) {
|
|
68
|
+
found.push({ path: node.slice(1), at, explicit: true });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (isUrl(node))
|
|
72
|
+
return;
|
|
73
|
+
if (NO_IMPLICIT_DETECTION.has(key))
|
|
74
|
+
return;
|
|
75
|
+
if (!MEDIA_EXTENSIONS.has(path.extname(node).toLowerCase()))
|
|
76
|
+
return;
|
|
77
|
+
if (!exists(node))
|
|
78
|
+
return;
|
|
79
|
+
found.push({ path: node, at, explicit: false });
|
|
80
|
+
};
|
|
81
|
+
walk(input, '', '');
|
|
82
|
+
return found;
|
|
83
|
+
}
|
|
84
|
+
function setAt(root, dotted, value) {
|
|
85
|
+
const parts = dotted.split('.');
|
|
86
|
+
let cur = root;
|
|
87
|
+
for (let i = 0; i < parts.length - 1; i++)
|
|
88
|
+
cur = cur[parts[i]];
|
|
89
|
+
cur[parts[parts.length - 1]] = value;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Replace local paths in `input` with uploaded URLs. Returns a new object;
|
|
93
|
+
* the caller's input is not mutated. Distinct paths upload once even when
|
|
94
|
+
* referenced several times — the same image passed as two fields is one
|
|
95
|
+
* upload, not two.
|
|
96
|
+
*/
|
|
97
|
+
export async function resolveLocalFiles(input, opts) {
|
|
98
|
+
const refs = collectLocalFiles(input, opts);
|
|
99
|
+
if (refs.length === 0)
|
|
100
|
+
return { input, uploaded: 0 };
|
|
101
|
+
const missing = refs.filter((r) => r.explicit && !(opts.exists ?? isRegularFile)(r.path));
|
|
102
|
+
if (missing.length > 0) {
|
|
103
|
+
throw new Error(`File not found: ${missing.map((m) => m.path).join(', ')}`);
|
|
104
|
+
}
|
|
105
|
+
const distinct = [...new Set(refs.map((r) => path.resolve(r.path)))];
|
|
106
|
+
const urls = new Map();
|
|
107
|
+
for (const [i, abs] of distinct.entries()) {
|
|
108
|
+
opts.onUpload?.(abs, i, distinct.length);
|
|
109
|
+
urls.set(abs, await opts.upload(abs));
|
|
110
|
+
}
|
|
111
|
+
const out = JSON.parse(JSON.stringify(input));
|
|
112
|
+
for (const ref of refs) {
|
|
113
|
+
setAt(out, ref.at, urls.get(path.resolve(ref.path)));
|
|
114
|
+
}
|
|
115
|
+
return { input: out, uploaded: distinct.length };
|
|
116
|
+
}
|