parley-live 0.1.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/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/client.js +174 -0
- package/dist/index.js +507 -0
- package/package.json +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Parley
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# parley
|
|
2
|
+
|
|
3
|
+
Generate images, video clips, YouTube thumbnails and spokesperson product ads from the terminal with [Parley](https://labs.parley.live). Built for coding agents as much as people: every command has a `--json` mode, exits with a meaningful code, and never asks a question mid-run.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -g parley-live
|
|
7
|
+
parley login # one browser sign-in, stored under ~/.parley
|
|
8
|
+
parley account # your credit balance
|
|
9
|
+
parley image "a matte black espresso machine on marble, morning light" --out hero.png
|
|
10
|
+
parley video "steam rises, slow push in" --image hero.png --look standard --move dolly-in --ratio 9:16
|
|
11
|
+
parley thumbnail "I tested every AI video tool" --overlay "ALL OF THEM" --variants 3
|
|
12
|
+
parley ugc-ad --product product.jpg --name "Cold Brew Kit" --actor jordan --marketplace amazon
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Commands
|
|
16
|
+
|
|
17
|
+
| Command | What it does |
|
|
18
|
+
|---|---|
|
|
19
|
+
| `parley login` | Device-code sign-in. Prints a link, you sign in once in the browser, the CLI receives a session token. |
|
|
20
|
+
| `parley account` | Credit balance. |
|
|
21
|
+
| `parley engines` | Which video engines this server can run right now, with credit rates. |
|
|
22
|
+
| `parley camera-moves` | The named camera-move presets accepted by `video --move`. |
|
|
23
|
+
| `parley image "<prompt>"` | Generate an image. `--model`, `--size`, `--quality`, `--image-size`, `--ref <file\|url>` (repeatable), `--out`. |
|
|
24
|
+
| `parley video "<motion prompt>"` | Animate a still (`--image`) or text-to-video (`--look photoreal`). `--look`, `--move` (repeatable, up to 3), `--duration`, `--ratio`, `--out`. |
|
|
25
|
+
| `parley thumbnail "<title>"` | 1-3 YouTube thumbnail variants. `--overlay`, `--accent`, `--person`, `--out <dir>`. |
|
|
26
|
+
| `parley ugc-ad` | Product photo → spokesperson ad with a marketplace CTA end-card. `--product`, `--name`, `--actor` or `--face`, `--angle`, `--marketplace`, `--points`, `--seconds`, `--url`, `--out`. |
|
|
27
|
+
| `parley youtube` | A complete faceless video from ordered beat stills: animated per beat, cut to `--narration` with crossfades, `--music` ducked, `--captions` burned in. `--beat` (repeatable, hosted https URLs), `--tier fast\|standard\|cinematic`, `--silent`, `--project`, `--out`. |
|
|
28
|
+
| `parley api <METHOD> </path>` | A signed raw call to any Parley endpoint, JSON in (`--data '{…}'` or `--data @file.json`), JSON out. This is how the Studio (projects, characters, shots, renders, reels) is driven. |
|
|
29
|
+
| `parley whoami`, `parley logout` | Session management. |
|
|
30
|
+
|
|
31
|
+
### Video looks
|
|
32
|
+
|
|
33
|
+
| `--look` | Engine | Use for |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `fast` | Hailuo 02 | cheapest drafts and B-roll |
|
|
36
|
+
| `standard` | Kling image-to-video | the default: animates the exact still, keeps its style |
|
|
37
|
+
| `cinematic` | Veo 3.1 | hero and establishing shots |
|
|
38
|
+
| `photoreal` | Seedance 2.0 | text-to-video with no still |
|
|
39
|
+
| `transform` | Gemini Omni Flash | change the picture while animating it (native audio) |
|
|
40
|
+
| `camera` | Higgsfield DoP | named camera moves as real motion parameters; falls back to `standard` with the moves as camera direction when the engine is not enabled |
|
|
41
|
+
|
|
42
|
+
`--move` works with every look. On `camera` the move drives the engine; on the others it becomes precise camera direction in the prompt. A named move beats "the camera moves dramatically" every time.
|
|
43
|
+
|
|
44
|
+
## For agents
|
|
45
|
+
|
|
46
|
+
Add `--json` to any command and read the last line of stdout:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{"ok":true,"file":"/work/clip.mp4","bytes":812344,"jobId":"…","videoUrl":"https://…/generated-videos/….mp4","model":"kling-2.5-turbo-pro","duration":5}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Errors are `{"ok":false,"error":"…","code":N}`. Exit codes: `2` not signed in, `3` cannot reach the server, `4` not enough credits, `5` the model declined the prompt, `64` bad usage, `1` anything else. Progress goes to stderr, results to stdout.
|
|
53
|
+
|
|
54
|
+
The MIT-licensed agent skills that teach Claude Code, Cursor and Codex how to use this CLI well live in the [`skills`](../skills) directory.
|
|
55
|
+
|
|
56
|
+
## Configuration
|
|
57
|
+
|
|
58
|
+
| Variable | Default | Purpose |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `PARLEY_BACKEND_URL` | `https://licensing.parley.live` | API host |
|
|
61
|
+
| `PARLEY_AUTH_WEB_URL` | `https://labs.parley.live` | where `login` sends the browser |
|
|
62
|
+
| `PARLEY_HOME` | `~/.parley` | credentials directory |
|
|
63
|
+
| `PARLEY_TOKEN` | | use this session token instead of the stored one (CI) |
|
|
64
|
+
| `PARLEY_NO_BROWSER` | | `1` prints the sign-in link instead of opening it |
|
|
65
|
+
|
|
66
|
+
## Development
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
npm install
|
|
70
|
+
npm test # builds, then drives the binary against a local mock API (no network, no credits)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Node 20 or newer. No runtime dependencies.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parley API client for the CLI: credential store + thin HTTP layer.
|
|
3
|
+
*
|
|
4
|
+
* Auth is the same device-code flow the desktop app uses: the CLI asks the
|
|
5
|
+
* server for a short user code, the user signs in once in their browser, and
|
|
6
|
+
* the server hands the CLI a session token. The token lives in
|
|
7
|
+
* $PARLEY_HOME/credentials.json (default ~/.parley) with 0600 permissions and
|
|
8
|
+
* is sent as `Authorization: Bearer <token>` on every call. Nothing else is
|
|
9
|
+
* stored, and no secret is ever placed in a URL.
|
|
10
|
+
*
|
|
11
|
+
* Environment:
|
|
12
|
+
* PARLEY_BACKEND_URL API host (default https://licensing.parley.live)
|
|
13
|
+
* PARLEY_AUTH_WEB_URL sign-in page (default https://labs.parley.live)
|
|
14
|
+
* PARLEY_HOME credentials directory (default ~/.parley)
|
|
15
|
+
* PARLEY_TOKEN use this session token instead of the stored one
|
|
16
|
+
* PARLEY_NO_BROWSER set to 1 to print the sign-in link instead of opening it
|
|
17
|
+
*/
|
|
18
|
+
import { mkdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, chmodSync } from 'node:fs';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
export const DEFAULT_BACKEND_URL = 'https://licensing.parley.live';
|
|
23
|
+
export const DEFAULT_AUTH_WEB_URL = 'https://labs.parley.live';
|
|
24
|
+
export class CliError extends Error {
|
|
25
|
+
exitCode;
|
|
26
|
+
constructor(message, exitCode = 1) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = 'CliError';
|
|
29
|
+
this.exitCode = exitCode;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function homeDir() {
|
|
33
|
+
return process.env.PARLEY_HOME || path.join(homedir(), '.parley');
|
|
34
|
+
}
|
|
35
|
+
function credentialsPath() {
|
|
36
|
+
return path.join(homeDir(), 'credentials.json');
|
|
37
|
+
}
|
|
38
|
+
export function backendUrl(override) {
|
|
39
|
+
const raw = override || process.env.PARLEY_BACKEND_URL || loadCredentials()?.backendUrl || DEFAULT_BACKEND_URL;
|
|
40
|
+
return raw.replace(/\/+$/, '');
|
|
41
|
+
}
|
|
42
|
+
export function authWebUrl() {
|
|
43
|
+
return (process.env.PARLEY_AUTH_WEB_URL || DEFAULT_AUTH_WEB_URL).replace(/\/+$/, '');
|
|
44
|
+
}
|
|
45
|
+
export function loadCredentials() {
|
|
46
|
+
try {
|
|
47
|
+
const p = credentialsPath();
|
|
48
|
+
if (!existsSync(p))
|
|
49
|
+
return null;
|
|
50
|
+
const parsed = JSON.parse(readFileSync(p, 'utf8'));
|
|
51
|
+
if (parsed && typeof parsed.token === 'string' && typeof parsed.backendUrl === 'string')
|
|
52
|
+
return parsed;
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export function saveCredentials(creds) {
|
|
60
|
+
mkdirSync(homeDir(), { recursive: true });
|
|
61
|
+
const p = credentialsPath();
|
|
62
|
+
writeFileSync(p, JSON.stringify(creds, null, 2) + '\n', { mode: 0o600 });
|
|
63
|
+
try {
|
|
64
|
+
chmodSync(p, 0o600);
|
|
65
|
+
}
|
|
66
|
+
catch { /* windows */ }
|
|
67
|
+
return p;
|
|
68
|
+
}
|
|
69
|
+
export function clearCredentials() {
|
|
70
|
+
const p = credentialsPath();
|
|
71
|
+
if (!existsSync(p))
|
|
72
|
+
return false;
|
|
73
|
+
unlinkSync(p);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
export function currentToken() {
|
|
77
|
+
return process.env.PARLEY_TOKEN || loadCredentials()?.token || null;
|
|
78
|
+
}
|
|
79
|
+
/** Open a URL in the user's browser; returns false when it could not. */
|
|
80
|
+
export function openInBrowser(url) {
|
|
81
|
+
if (process.env.PARLEY_NO_BROWSER === '1')
|
|
82
|
+
return false;
|
|
83
|
+
try {
|
|
84
|
+
const platform = process.platform;
|
|
85
|
+
const child = platform === 'win32'
|
|
86
|
+
? spawn('cmd', ['/c', 'start', '', url], { stdio: 'ignore', detached: true })
|
|
87
|
+
: platform === 'darwin'
|
|
88
|
+
? spawn('open', [url], { stdio: 'ignore', detached: true })
|
|
89
|
+
: spawn('xdg-open', [url], { stdio: 'ignore', detached: true });
|
|
90
|
+
child.on('error', () => { });
|
|
91
|
+
child.unref();
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Call the API and parse JSON. Maps the server's conventions to typed errors:
|
|
100
|
+
* 401 → sign in again; 402 → credits; HTTP 200 + {ok:false,error} → error.
|
|
101
|
+
*/
|
|
102
|
+
export async function api(pathname, opts = {}) {
|
|
103
|
+
const base = backendUrl(opts.backend);
|
|
104
|
+
const url = `${base}${pathname.startsWith('/') ? pathname : `/${pathname}`}`;
|
|
105
|
+
const headers = { Accept: 'application/json', 'User-Agent': 'parley-live/0.1.0' };
|
|
106
|
+
if (opts.auth !== false) {
|
|
107
|
+
const token = currentToken();
|
|
108
|
+
if (!token)
|
|
109
|
+
throw new CliError('Not signed in. Run `parley login` first.', 2);
|
|
110
|
+
headers.Authorization = `Bearer ${token}`;
|
|
111
|
+
}
|
|
112
|
+
let body;
|
|
113
|
+
if (opts.form) {
|
|
114
|
+
body = opts.form;
|
|
115
|
+
}
|
|
116
|
+
else if (opts.body !== undefined) {
|
|
117
|
+
headers['Content-Type'] = 'application/json';
|
|
118
|
+
body = JSON.stringify(opts.body);
|
|
119
|
+
}
|
|
120
|
+
let res;
|
|
121
|
+
try {
|
|
122
|
+
res = await fetch(url, { method: opts.method || (body ? 'POST' : 'GET'), headers, body });
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
throw new CliError(`Could not reach ${base}: ${e?.message || e}`, 3);
|
|
126
|
+
}
|
|
127
|
+
const text = await res.text();
|
|
128
|
+
let json = null;
|
|
129
|
+
try {
|
|
130
|
+
json = text ? JSON.parse(text) : null;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
json = { raw: text };
|
|
134
|
+
}
|
|
135
|
+
if (res.status === 401)
|
|
136
|
+
throw new CliError('Your session has expired or is invalid. Run `parley login` again.', 2);
|
|
137
|
+
if (res.status === 402) {
|
|
138
|
+
throw new CliError(json?.message || json?.error || 'Not enough credits for this request. Top up at https://labs.parley.live and try again.', 4);
|
|
139
|
+
}
|
|
140
|
+
if (!res.ok)
|
|
141
|
+
throw new CliError(json?.error || json?.message || `${res.status} ${res.statusText} from ${pathname}`, 1);
|
|
142
|
+
if (json && json.ok === false)
|
|
143
|
+
throw new CliError(json.error || 'The server declined the request.', 1);
|
|
144
|
+
if (json && json.refused === true)
|
|
145
|
+
throw new CliError(json.error || 'The model declined this prompt.', 5);
|
|
146
|
+
return json;
|
|
147
|
+
}
|
|
148
|
+
/** Download a URL (with Bearer auth when it is on our backend) to a file. */
|
|
149
|
+
export async function downloadTo(url, outPath) {
|
|
150
|
+
const headers = {};
|
|
151
|
+
const token = currentToken();
|
|
152
|
+
if (token && url.startsWith(backendUrl()))
|
|
153
|
+
headers.Authorization = `Bearer ${token}`;
|
|
154
|
+
const res = await fetch(url, { headers });
|
|
155
|
+
if (!res.ok)
|
|
156
|
+
throw new CliError(`Download failed (HTTP ${res.status}) from ${url}`);
|
|
157
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
158
|
+
mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
|
|
159
|
+
writeFileSync(outPath, buf);
|
|
160
|
+
return buf.length;
|
|
161
|
+
}
|
|
162
|
+
/** Local file → data: URI (what the API accepts for reference images). */
|
|
163
|
+
export function fileToDataUri(filePath) {
|
|
164
|
+
const buf = readFileSync(filePath);
|
|
165
|
+
const ext = path.extname(filePath).toLowerCase().replace('.', '');
|
|
166
|
+
const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : ext === 'gif' ? 'image/gif' : 'image/png';
|
|
167
|
+
return `data:${mime};base64,${buf.toString('base64')}`;
|
|
168
|
+
}
|
|
169
|
+
export function isUrl(s) {
|
|
170
|
+
return /^https?:\/\//i.test(s);
|
|
171
|
+
}
|
|
172
|
+
export function sleep(ms) {
|
|
173
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
174
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* parley — generate images, video clips, YouTube thumbnails and product ads
|
|
4
|
+
* from the terminal. Every command supports --json for agents.
|
|
5
|
+
*
|
|
6
|
+
* parley login sign in (browser, one time)
|
|
7
|
+
* parley account credit balance
|
|
8
|
+
* parley engines which video engines are live + rates
|
|
9
|
+
* parley camera-moves the named camera-move presets
|
|
10
|
+
* parley image "<prompt>" [...] generate an image
|
|
11
|
+
* parley video "<prompt>" [...] generate a clip / animate a still
|
|
12
|
+
* parley thumbnail "<title>" [...] YouTube thumbnail variants
|
|
13
|
+
* parley ugc-ad --product <img> ... spokesperson product ad
|
|
14
|
+
*
|
|
15
|
+
* Run `parley help <command>` for the flags of one command.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { api, authWebUrl, backendUrl, clearCredentials, CliError, downloadTo, fileToDataUri, isUrl, loadCredentials, openInBrowser, saveCredentials, sleep, } from './client.js';
|
|
20
|
+
const VERSION = '0.1.0';
|
|
21
|
+
function parseArgs(argv) {
|
|
22
|
+
const out = { command: '', positionals: [], flags: {} };
|
|
23
|
+
const rest = [...argv];
|
|
24
|
+
out.command = rest.shift() || 'help';
|
|
25
|
+
while (rest.length) {
|
|
26
|
+
const a = rest.shift();
|
|
27
|
+
if (a === '--') {
|
|
28
|
+
out.positionals.push(...rest);
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
if (a.startsWith('--')) {
|
|
32
|
+
const eq = a.indexOf('=');
|
|
33
|
+
const key = (eq > 0 ? a.slice(2, eq) : a.slice(2)).trim();
|
|
34
|
+
let value = eq > 0 ? a.slice(eq + 1) : true;
|
|
35
|
+
if (value === true && rest.length && !rest[0].startsWith('--'))
|
|
36
|
+
value = rest.shift();
|
|
37
|
+
const prev = out.flags[key];
|
|
38
|
+
if (prev === undefined)
|
|
39
|
+
out.flags[key] = value;
|
|
40
|
+
else if (Array.isArray(prev))
|
|
41
|
+
prev.push(String(value));
|
|
42
|
+
else
|
|
43
|
+
out.flags[key] = [String(prev), String(value)];
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
out.positionals.push(a);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
const str = (v, fallback) => v === undefined ? fallback : Array.isArray(v) ? v[v.length - 1] : v === true ? fallback : String(v);
|
|
52
|
+
const list = (v) => v === undefined || v === true ? [] : Array.isArray(v) ? v : String(v).split(',').map((s) => s.trim()).filter(Boolean);
|
|
53
|
+
const num = (v, fallback) => {
|
|
54
|
+
const s = str(v);
|
|
55
|
+
const n = s === undefined ? NaN : Number(s);
|
|
56
|
+
return Number.isFinite(n) ? n : fallback;
|
|
57
|
+
};
|
|
58
|
+
// ── output ───────────────────────────────────────────────────────────
|
|
59
|
+
let JSON_MODE = false;
|
|
60
|
+
function emit(obj, human) {
|
|
61
|
+
if (JSON_MODE)
|
|
62
|
+
console.log(JSON.stringify(obj));
|
|
63
|
+
else
|
|
64
|
+
console.log(human);
|
|
65
|
+
}
|
|
66
|
+
function note(msg) {
|
|
67
|
+
if (!JSON_MODE)
|
|
68
|
+
console.error(msg);
|
|
69
|
+
}
|
|
70
|
+
// ── commands ─────────────────────────────────────────────────────────
|
|
71
|
+
async function cmdLogin(p) {
|
|
72
|
+
const backend = backendUrl(str(p.flags.backend));
|
|
73
|
+
const started = await api('/api/auth/device/start', { method: 'POST', body: {}, auth: false, backend });
|
|
74
|
+
const link = `${authWebUrl()}/desktop-auth?code=${encodeURIComponent(started.user_code)}`;
|
|
75
|
+
const opened = openInBrowser(link);
|
|
76
|
+
// Always on stderr, even in --json mode: an agent driving `parley login
|
|
77
|
+
// --json` must be able to relay the link to the person who has to click it.
|
|
78
|
+
console.error(opened
|
|
79
|
+
? `Opening your browser to sign in. If it did not open, visit:\n ${link}\nCode: ${started.user_code}`
|
|
80
|
+
: `Sign in here (one time):\n ${link}\nCode: ${started.user_code}`);
|
|
81
|
+
const intervalMs = Math.max(1000, (started.interval || 3) * 1000);
|
|
82
|
+
const deadline = Date.now() + Math.min(started.expires_in || 600, 600) * 1000;
|
|
83
|
+
while (Date.now() < deadline) {
|
|
84
|
+
await sleep(intervalMs);
|
|
85
|
+
let poll;
|
|
86
|
+
try {
|
|
87
|
+
poll = await api('/api/auth/device/poll', { method: 'POST', body: { device_code: started.device_code }, auth: false, backend });
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
if (e instanceof CliError && /expired/i.test(e.message))
|
|
91
|
+
throw new CliError('The sign-in code expired. Run `parley login` again.');
|
|
92
|
+
throw e;
|
|
93
|
+
}
|
|
94
|
+
if (poll.status === 'ok' && poll.auth_token && poll.user_id) {
|
|
95
|
+
const file = saveCredentials({ backendUrl: backend, token: poll.auth_token, userId: poll.user_id, email: poll.email || null, savedAt: new Date().toISOString() });
|
|
96
|
+
emit({ ok: true, email: poll.email || null, userId: poll.user_id, credentials: file }, `Signed in as ${poll.email || poll.user_id}. Credentials saved to ${file}`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
throw new CliError('Timed out waiting for the browser sign-in. Run `parley login` again.');
|
|
101
|
+
}
|
|
102
|
+
async function cmdLogout() {
|
|
103
|
+
const removed = clearCredentials();
|
|
104
|
+
emit({ ok: true, removed }, removed ? 'Signed out.' : 'You were not signed in.');
|
|
105
|
+
}
|
|
106
|
+
async function cmdWhoami() {
|
|
107
|
+
const c = loadCredentials();
|
|
108
|
+
if (!c && !process.env.PARLEY_TOKEN)
|
|
109
|
+
throw new CliError('Not signed in. Run `parley login`.', 2);
|
|
110
|
+
emit({ ok: true, email: c?.email ?? null, userId: c?.userId ?? null, backendUrl: backendUrl() }, `${c?.email || c?.userId || '(token from PARLEY_TOKEN)'} @ ${backendUrl()}`);
|
|
111
|
+
}
|
|
112
|
+
async function cmdAccount() {
|
|
113
|
+
const bal = await api('/api/credits/balance');
|
|
114
|
+
emit({ ok: true, ...bal }, `Credits: ${bal.credits_balance}`);
|
|
115
|
+
}
|
|
116
|
+
async function cmdEngines() {
|
|
117
|
+
const e = await api('/video/engines', { auth: false });
|
|
118
|
+
if (JSON_MODE) {
|
|
119
|
+
console.log(JSON.stringify({ ok: true, ...e }));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
for (const [id, info] of Object.entries(e.engines)) {
|
|
123
|
+
const rate = info.creditsPerSec == null ? (info.note || '') : `${info.creditsPerSec} cr/s`;
|
|
124
|
+
console.log(`${info.ready ? 'live ' : 'off '} ${id.padEnd(16)} ${String(rate).padEnd(10)} ${info.label}${info.fallback ? ` (falls back to ${info.fallback})` : ''}`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function cmdCameraMoves() {
|
|
128
|
+
const e = await api('/video/engines', { auth: false });
|
|
129
|
+
if (JSON_MODE) {
|
|
130
|
+
console.log(JSON.stringify({ ok: true, cameraMoves: e.cameraMoves }));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
for (const m of e.cameraMoves)
|
|
134
|
+
console.log(`${m.slug.padEnd(20)} ${m.label}`);
|
|
135
|
+
}
|
|
136
|
+
function outPathFor(p, defaultName) {
|
|
137
|
+
const o = str(p.flags.out);
|
|
138
|
+
return o ? o : path.join(process.cwd(), defaultName);
|
|
139
|
+
}
|
|
140
|
+
async function cmdImage(p) {
|
|
141
|
+
const prompt = p.positionals.join(' ').trim();
|
|
142
|
+
if (!prompt)
|
|
143
|
+
throw new CliError('Usage: parley image "<prompt>" [--model gpt-image-2|gemini-3-pro-image-preview] [--size 1024x1024|1024x1536|1536x1024] [--quality low|medium|high] [--image-size 1K|2K|4K] [--ref <file|url>]... [--out file.png]');
|
|
144
|
+
const refs = list(p.flags.ref).map((r) => (isUrl(r) ? r : fileToDataUri(r)));
|
|
145
|
+
const body = { prompt };
|
|
146
|
+
const model = str(p.flags.model);
|
|
147
|
+
if (model)
|
|
148
|
+
body.model = model;
|
|
149
|
+
const size = str(p.flags.size);
|
|
150
|
+
if (size)
|
|
151
|
+
body.gptImageSize = size;
|
|
152
|
+
const quality = str(p.flags.quality);
|
|
153
|
+
if (quality)
|
|
154
|
+
body.gptImageQuality = quality;
|
|
155
|
+
const imageSize = str(p.flags['image-size']);
|
|
156
|
+
if (imageSize)
|
|
157
|
+
body.imageSize = imageSize;
|
|
158
|
+
if (refs.length)
|
|
159
|
+
body.inputImages = refs;
|
|
160
|
+
note('Generating image…');
|
|
161
|
+
const r = await api('/image/generate', { body });
|
|
162
|
+
const ext = /jpe?g/.test(r.mimeType || '') ? 'jpg' : /webp/.test(r.mimeType || '') ? 'webp' : 'png';
|
|
163
|
+
const out = outPathFor(p, `parley-image-${Date.now()}.${ext}`);
|
|
164
|
+
let bytes = 0;
|
|
165
|
+
if (r.imageUrl)
|
|
166
|
+
bytes = await downloadTo(r.imageUrl, out);
|
|
167
|
+
else if (r.dataUrl) {
|
|
168
|
+
const m = /^data:[^;]+;base64,(.*)$/.exec(r.dataUrl);
|
|
169
|
+
if (!m)
|
|
170
|
+
throw new CliError('The server returned an image in an unexpected format.');
|
|
171
|
+
const buf = Buffer.from(m[1], 'base64');
|
|
172
|
+
mkdirSync(path.dirname(path.resolve(out)), { recursive: true });
|
|
173
|
+
(await import('node:fs')).writeFileSync(out, buf);
|
|
174
|
+
bytes = buf.length;
|
|
175
|
+
}
|
|
176
|
+
else
|
|
177
|
+
throw new CliError('The server returned no image.');
|
|
178
|
+
emit({ ok: true, file: out, bytes, model: r.model, imageUrl: r.imageUrl ?? null, revisedPrompt: r.revisedPrompt ?? null }, `Saved ${out} (${Math.round(bytes / 1024)} KB, ${r.model})${r.imageUrl ? `\nHosted: ${r.imageUrl}` : ''}`);
|
|
179
|
+
}
|
|
180
|
+
const LOOKS = ['fast', 'standard', 'cinematic', 'photoreal', 'transform', 'camera'];
|
|
181
|
+
const LOOK_TO_PROVIDER = {
|
|
182
|
+
fast: 'hailuo', standard: 'kling', illustrated: 'kling', cinematic: 'veo', photoreal: 'seedance-2.0', transform: 'gemini-omni', camera: 'higgsfield-dop',
|
|
183
|
+
};
|
|
184
|
+
async function cmdVideo(p) {
|
|
185
|
+
const prompt = p.positionals.join(' ').trim();
|
|
186
|
+
if (!prompt)
|
|
187
|
+
throw new CliError(`Usage: parley video "<motion prompt>" [--image <file|url>] [--look ${LOOKS.join('|')}] [--move <slug>]... [--duration 5] [--ratio 16:9|9:16|1:1] [--out clip.mp4]`);
|
|
188
|
+
const look = str(p.flags.look, 'standard');
|
|
189
|
+
if (!(look in LOOK_TO_PROVIDER))
|
|
190
|
+
throw new CliError(`Unknown --look "${look}". Use one of: ${LOOKS.join(', ')}.`);
|
|
191
|
+
const image = str(p.flags.image);
|
|
192
|
+
const imageFiles = image ? [isUrl(image) ? image : fileToDataUri(image)] : [];
|
|
193
|
+
if (!image && look !== 'photoreal') {
|
|
194
|
+
throw new CliError('Every look except "photoreal" animates a still: pass --image <file|url> (make one with `parley image` first), or use --look photoreal for text-to-video.');
|
|
195
|
+
}
|
|
196
|
+
const moves = list(p.flags.move).slice(0, 3);
|
|
197
|
+
const ratio = str(p.flags.ratio, '16:9');
|
|
198
|
+
const duration = num(p.flags.duration, 5);
|
|
199
|
+
const body = {
|
|
200
|
+
provider: LOOK_TO_PROVIDER[look], prompt, duration, ratio, resolution: '720p',
|
|
201
|
+
functionMode: 'omni_reference', modelSpeed: 'seedance_2.0_fast', generate_audio: true,
|
|
202
|
+
...(imageFiles.length ? { image_files: imageFiles } : {}),
|
|
203
|
+
...(moves.length ? { motions: moves } : {}),
|
|
204
|
+
};
|
|
205
|
+
note(`Submitting to the ${look} tier${moves.length ? ` with camera moves ${moves.join(', ')}` : ''}…`);
|
|
206
|
+
const created = await api('/video/generate', { body });
|
|
207
|
+
const jobId = created.jobId;
|
|
208
|
+
if (!jobId)
|
|
209
|
+
throw new CliError('The server did not start a job.');
|
|
210
|
+
let lastStage = '';
|
|
211
|
+
const deadline = Date.now() + 30 * 60 * 1000;
|
|
212
|
+
while (Date.now() < deadline) {
|
|
213
|
+
await sleep(3000);
|
|
214
|
+
const s = await api(`/video/jobs/${jobId}`);
|
|
215
|
+
const stage = s.progress?.stage || s.status;
|
|
216
|
+
if (stage !== lastStage) {
|
|
217
|
+
note(` ${stage}${s.progress?.message ? ` – ${s.progress.message}` : ''}`);
|
|
218
|
+
lastStage = stage;
|
|
219
|
+
}
|
|
220
|
+
if (s.status === 'succeeded')
|
|
221
|
+
break;
|
|
222
|
+
if (s.status === 'failed')
|
|
223
|
+
throw new CliError(s.error || 'Video generation failed.');
|
|
224
|
+
}
|
|
225
|
+
const r = await api(`/video/jobs/${jobId}/result`);
|
|
226
|
+
const out = outPathFor(p, `parley-video-${jobId}.mp4`);
|
|
227
|
+
const src = r.videoUrl || `${backendUrl()}/video/jobs/${jobId}/result?download=1`;
|
|
228
|
+
const bytes = await downloadTo(src, out);
|
|
229
|
+
emit({ ok: true, file: out, bytes, jobId, videoUrl: r.videoUrl ?? null, model: r.model ?? null, duration: r.duration ?? null, width: r.width ?? null, height: r.height ?? null, deduped: created.deduped === true }, `Saved ${out} (${Math.round(bytes / 1024)} KB, ${r.model || look}, ${r.duration ?? '?'}s)${r.videoUrl ? `\nHosted: ${r.videoUrl}` : ''}`);
|
|
230
|
+
}
|
|
231
|
+
async function cmdThumbnail(p) {
|
|
232
|
+
const title = p.positionals.join(' ').trim();
|
|
233
|
+
if (!title)
|
|
234
|
+
throw new CliError('Usage: parley thumbnail "<video title>" [--variants 1-3] [--overlay "2-4 WORDS"] [--accent WORD] [--person true|false] [--model X] [--out dir]');
|
|
235
|
+
const body = { title, variants: Math.min(3, Math.max(1, num(p.flags.variants, 3))) };
|
|
236
|
+
const overlay = str(p.flags.overlay);
|
|
237
|
+
if (overlay)
|
|
238
|
+
body.overlayText = overlay;
|
|
239
|
+
const accent = str(p.flags.accent);
|
|
240
|
+
if (accent)
|
|
241
|
+
body.accentWord = accent;
|
|
242
|
+
const model = str(p.flags.model);
|
|
243
|
+
if (model)
|
|
244
|
+
body.model = model;
|
|
245
|
+
const person = str(p.flags.person);
|
|
246
|
+
if (person)
|
|
247
|
+
body.includePerson = person === 'true';
|
|
248
|
+
note('Generating thumbnail variants…');
|
|
249
|
+
const r = await api('/image/thumbnail', { body });
|
|
250
|
+
const urls = Array.isArray(r.variants) ? r.variants.map((v) => v.imageUrl || v.url).filter(Boolean)
|
|
251
|
+
: Array.isArray(r.imageUrls) ? r.imageUrls : r.imageUrl ? [r.imageUrl] : [];
|
|
252
|
+
const dir = str(p.flags.out) || path.join(process.cwd(), `parley-thumbnails-${Date.now()}`);
|
|
253
|
+
mkdirSync(dir, { recursive: true });
|
|
254
|
+
const files = [];
|
|
255
|
+
for (let i = 0; i < urls.length; i++) {
|
|
256
|
+
const f = path.join(dir, `thumbnail-${i + 1}.png`);
|
|
257
|
+
await downloadTo(urls[i], f);
|
|
258
|
+
files.push(f);
|
|
259
|
+
}
|
|
260
|
+
emit({ ok: true, files, urls, creditsCharged: r.creditsCharged ?? null, raw: files.length ? undefined : r }, files.length ? `Saved ${files.length} variant${files.length === 1 ? '' : 's'} to ${dir} (${r.creditsCharged ?? '?'} credits)` : `Server response:\n${JSON.stringify(r, null, 2)}`);
|
|
261
|
+
}
|
|
262
|
+
const ANGLES = ['problem_solution', 'honest_review', 'unboxing', 'before_after', 'founder'];
|
|
263
|
+
const MARKETPLACES = ['amazon', 'ebay', 'etsy', 'tiktok_shop', 'shopify', 'other'];
|
|
264
|
+
async function cmdUgcAd(p) {
|
|
265
|
+
const product = str(p.flags.product);
|
|
266
|
+
const name = str(p.flags.name);
|
|
267
|
+
if (!product || !name) {
|
|
268
|
+
throw new CliError(`Usage: parley ugc-ad --product <image file> --name "Product name" [--actor maya|jordan|sofia|marcus | --face <image file>] [--angle ${ANGLES.join('|')}] [--marketplace ${MARKETPLACES.join('|')}] [--points "point one;point two"] [--seconds 8] [--url https://...] [--out ad.mp4]`);
|
|
269
|
+
}
|
|
270
|
+
if (!existsSync(product))
|
|
271
|
+
throw new CliError(`Product image not found: ${product}`);
|
|
272
|
+
const angle = str(p.flags.angle, 'problem_solution');
|
|
273
|
+
if (!ANGLES.includes(angle))
|
|
274
|
+
throw new CliError(`--angle must be one of ${ANGLES.join(', ')}`);
|
|
275
|
+
const marketplace = str(p.flags.marketplace, 'amazon');
|
|
276
|
+
if (!MARKETPLACES.includes(marketplace))
|
|
277
|
+
throw new CliError(`--marketplace must be one of ${MARKETPLACES.join(', ')}`);
|
|
278
|
+
const form = new FormData();
|
|
279
|
+
const fileBlob = (f) => new Blob([readFileSync(f)], { type: /\.jpe?g$/i.test(f) ? 'image/jpeg' : 'image/png' });
|
|
280
|
+
form.append('productImage', fileBlob(product), path.basename(product));
|
|
281
|
+
form.append('productName', name);
|
|
282
|
+
form.append('angle', angle);
|
|
283
|
+
form.append('marketplace', marketplace);
|
|
284
|
+
form.append('sellingPoints', JSON.stringify(list(p.flags.points).flatMap((s) => s.split(';')).map((s) => s.trim()).filter(Boolean)));
|
|
285
|
+
form.append('spokespersonSeconds', String(num(p.flags.seconds, 8)));
|
|
286
|
+
const face = str(p.flags.face);
|
|
287
|
+
const actor = str(p.flags.actor);
|
|
288
|
+
if (face) {
|
|
289
|
+
if (!existsSync(face))
|
|
290
|
+
throw new CliError(`Face image not found: ${face}`);
|
|
291
|
+
form.append('actorImage', fileBlob(face), path.basename(face));
|
|
292
|
+
}
|
|
293
|
+
else
|
|
294
|
+
form.append('actorId', actor || 'maya');
|
|
295
|
+
const url = str(p.flags.url);
|
|
296
|
+
if (url)
|
|
297
|
+
form.append('destinationUrl', url);
|
|
298
|
+
note('Submitting the ad…');
|
|
299
|
+
const created = await api('/api/ugc-ad/generate', { form });
|
|
300
|
+
const jobId = created.jobId || created.id;
|
|
301
|
+
if (!jobId)
|
|
302
|
+
throw new CliError('The server did not start an ad job.');
|
|
303
|
+
let last = '';
|
|
304
|
+
const deadline = Date.now() + 40 * 60 * 1000;
|
|
305
|
+
let job = null;
|
|
306
|
+
while (Date.now() < deadline) {
|
|
307
|
+
await sleep(4000);
|
|
308
|
+
job = await api(`/api/ugc-ad/jobs/${jobId}`);
|
|
309
|
+
const stage = job.progress?.stage || job.progress?.message || job.status;
|
|
310
|
+
if (stage !== last) {
|
|
311
|
+
note(` ${stage}`);
|
|
312
|
+
last = stage;
|
|
313
|
+
}
|
|
314
|
+
if (job.status === 'succeeded' || job.status === 'completed' || job.status === 'done')
|
|
315
|
+
break;
|
|
316
|
+
if (job.status === 'failed')
|
|
317
|
+
throw new CliError(job.error || 'Ad generation failed.');
|
|
318
|
+
}
|
|
319
|
+
const videoUrl = job?.result?.videoUrl || job?.result?.finalVideoUrl || job?.result?.url;
|
|
320
|
+
const out = outPathFor(p, `parley-ad-${jobId}.mp4`);
|
|
321
|
+
const bytes = videoUrl ? await downloadTo(videoUrl, out) : 0;
|
|
322
|
+
emit({ ok: true, jobId, file: videoUrl ? out : null, bytes, videoUrl: videoUrl ?? null, result: job?.result ?? null }, videoUrl ? `Saved ${out} (${Math.round(bytes / 1024)} KB)\nHosted: ${videoUrl}` : `Finished. Result:\n${JSON.stringify(job?.result, null, 2)}`);
|
|
323
|
+
}
|
|
324
|
+
const YT_TIERS = ['fast', 'standard', 'cinematic'];
|
|
325
|
+
/**
|
|
326
|
+
* parley youtube --beat <https still> ... [--narration <https audio>] [--music <https audio>]
|
|
327
|
+
* [--tier fast|standard|cinematic] [--captions] [--silent] [--project <workspace id>] [--out video.mp4]
|
|
328
|
+
* Hands an ordered list of beat stills (made with `parley image`, same ratio and
|
|
329
|
+
* look) to Parley's production orchestrator, which animates each beat, cuts
|
|
330
|
+
* them to the narration with crossfades, ducks the music, optionally burns
|
|
331
|
+
* captions, and returns one finished MP4.
|
|
332
|
+
*/
|
|
333
|
+
async function cmdYoutube(p) {
|
|
334
|
+
const beats = list(p.flags.beat);
|
|
335
|
+
if (beats.length === 0) {
|
|
336
|
+
throw new CliError(`Usage: parley youtube --beat <https still> [--beat ...] [--narration <https audio>] [--music <https audio>] [--tier ${YT_TIERS.join('|')}] [--captions] [--silent] [--project <workspace id>] [--out video.mp4]\n Beats are the hosted image URLs "parley image" prints, in narration order.`);
|
|
337
|
+
}
|
|
338
|
+
const bad = beats.find((b) => !isUrl(b));
|
|
339
|
+
if (bad)
|
|
340
|
+
throw new CliError(`Every --beat must be a public https URL (got "${bad}"). Make the still with \`parley image\` and pass its hosted URL.`);
|
|
341
|
+
const tier = str(p.flags.tier, 'standard');
|
|
342
|
+
if (!YT_TIERS.includes(tier))
|
|
343
|
+
throw new CliError(`--tier must be one of ${YT_TIERS.join(', ')}`);
|
|
344
|
+
const narration = str(p.flags.narration);
|
|
345
|
+
const music = str(p.flags.music);
|
|
346
|
+
const project = str(p.flags.project);
|
|
347
|
+
const silent = p.flags.silent === true;
|
|
348
|
+
if (!narration && !silent) {
|
|
349
|
+
throw new CliError('Pass --narration <https audio URL> (the voiceover the video is cut to), or --silent if the user explicitly wants no voiceover.');
|
|
350
|
+
}
|
|
351
|
+
const body = {
|
|
352
|
+
beats: beats.map((imageUrl) => ({ imageUrl })),
|
|
353
|
+
tier,
|
|
354
|
+
captions: p.flags.captions === true,
|
|
355
|
+
...(narration ? { narrationUrl: narration } : {}),
|
|
356
|
+
...(music ? { musicUrl: music } : {}),
|
|
357
|
+
...(silent ? { silent: true } : {}),
|
|
358
|
+
...(project ? { projectId: project } : {}),
|
|
359
|
+
};
|
|
360
|
+
note(`Submitting ${beats.length} beat${beats.length === 1 ? '' : 's'} on the ${tier} tier…`);
|
|
361
|
+
const created = await api('/api/youtube/produce', { body });
|
|
362
|
+
if (!created.jobId)
|
|
363
|
+
throw new CliError('The server did not start a production.');
|
|
364
|
+
note(`Production ${created.jobId} started${created.estimatedCredits != null ? ` (about ${created.estimatedCredits} credits)` : ''}. This takes minutes.`);
|
|
365
|
+
let last = '';
|
|
366
|
+
const deadline = Date.now() + 60 * 60 * 1000;
|
|
367
|
+
let job = null;
|
|
368
|
+
while (Date.now() < deadline) {
|
|
369
|
+
await sleep(5000);
|
|
370
|
+
job = await api(`/api/youtube/produce/${created.jobId}`);
|
|
371
|
+
const stage = job.progress ? `${job.progress.stage}${job.progress.total ? ` ${job.progress.done}/${job.progress.total}` : ''}${job.progress.message ? ` – ${job.progress.message}` : ''}` : job.status;
|
|
372
|
+
if (stage !== last) {
|
|
373
|
+
note(` ${stage}`);
|
|
374
|
+
last = stage;
|
|
375
|
+
}
|
|
376
|
+
if (job.status === 'succeeded')
|
|
377
|
+
break;
|
|
378
|
+
if (job.status === 'failed')
|
|
379
|
+
throw new CliError(job.error || 'Production failed.');
|
|
380
|
+
}
|
|
381
|
+
const videoUrl = job?.result?.videoUrl;
|
|
382
|
+
if (!videoUrl)
|
|
383
|
+
throw new CliError('Production finished without a video URL.');
|
|
384
|
+
const out = outPathFor(p, `parley-youtube-${created.jobId}.mp4`);
|
|
385
|
+
const bytes = await downloadTo(videoUrl, out);
|
|
386
|
+
emit({ ok: true, file: out, bytes, jobId: created.jobId, videoUrl, estimatedCredits: created.estimatedCredits ?? null, result: job.result }, `Saved ${out} (${Math.round(bytes / 1024)} KB)\nHosted: ${videoUrl}`);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* parley api <GET|POST|PATCH|PUT|DELETE> <path> [--data '<json>' | --data @file.json]
|
|
390
|
+
* Signed raw call to any Parley endpoint (the Studio's projects, characters,
|
|
391
|
+
* shots, renders and reels live here). Prints the JSON reply.
|
|
392
|
+
*/
|
|
393
|
+
async function cmdApi(p) {
|
|
394
|
+
const [methodRaw, pathname] = p.positionals;
|
|
395
|
+
const method = (methodRaw || '').toUpperCase();
|
|
396
|
+
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(method) || !pathname || !pathname.startsWith('/')) {
|
|
397
|
+
throw new CliError('Usage: parley api <GET|POST|PATCH|PUT|DELETE> </path> [--data \'{"json":true}\' | --data @body.json]');
|
|
398
|
+
}
|
|
399
|
+
let body;
|
|
400
|
+
const data = str(p.flags.data);
|
|
401
|
+
if (data !== undefined) {
|
|
402
|
+
const text = data.startsWith('@') ? readFileSync(data.slice(1), 'utf8') : data;
|
|
403
|
+
try {
|
|
404
|
+
body = JSON.parse(text);
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
throw new CliError('--data must be valid JSON (or @file.json).');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
const r = await api(pathname, { method, ...(body !== undefined ? { body } : {}) });
|
|
411
|
+
if (JSON_MODE)
|
|
412
|
+
console.log(JSON.stringify(r));
|
|
413
|
+
else
|
|
414
|
+
console.log(JSON.stringify(r, null, 2));
|
|
415
|
+
}
|
|
416
|
+
function help(topic) {
|
|
417
|
+
const lines = {
|
|
418
|
+
login: 'parley login [--backend URL]\n Sign in once in your browser. Stores a session token under ~/.parley (or $PARLEY_HOME).',
|
|
419
|
+
image: 'parley image "<prompt>" [--model gpt-image-2|gemini-3-pro-image-preview] [--size 1024x1024|1024x1536|1536x1024] [--quality low|medium|high] [--image-size 1K|2K|4K] [--ref <file|url>]... [--out file]\n Generate an image. --ref passes reference images (identity / style locks).',
|
|
420
|
+
video: `parley video "<motion prompt>" --image <file|url> [--look ${LOOKS.join('|')}] [--move <slug>]... [--duration 5] [--ratio 16:9|9:16|1:1] [--out clip.mp4]\n Animate a still (or --look photoreal for text-to-video). --move adds named camera moves; see \`parley camera-moves\`.`,
|
|
421
|
+
thumbnail: 'parley thumbnail "<video title>" [--variants 1-3] [--overlay "2-4 WORDS"] [--accent WORD] [--person true|false] [--out dir]',
|
|
422
|
+
'ugc-ad': `parley ugc-ad --product <image> --name "Product" [--actor maya|jordan|sofia|marcus | --face <image>] [--angle ${ANGLES.join('|')}] [--marketplace ${MARKETPLACES.join('|')}] [--points "a;b;c"] [--seconds 3-16] [--url https://...] [--out ad.mp4]`,
|
|
423
|
+
youtube: `parley youtube --beat <https still>... [--narration <https audio>] [--music <https audio>] [--tier ${YT_TIERS.join('|')}] [--captions] [--silent] [--project <workspace id>] [--out video.mp4]\n Animate ordered beat stills and cut them to the narration into one finished video.`,
|
|
424
|
+
api: 'parley api <GET|POST|PATCH|PUT|DELETE> </path> [--data \'{...}\' | --data @file.json]\n Signed raw call to any Parley endpoint (Studio projects, characters, shots, renders, reels).',
|
|
425
|
+
engines: 'parley engines\n Which video engines this server can run right now and their credit rates.',
|
|
426
|
+
'camera-moves': 'parley camera-moves\n The named camera-move presets accepted by `parley video --move`.',
|
|
427
|
+
account: 'parley account\n Your credit balance.',
|
|
428
|
+
};
|
|
429
|
+
if (topic && lines[topic]) {
|
|
430
|
+
console.log(lines[topic]);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
console.log(`parley ${VERSION}\n\n${Object.values(lines).map((l) => ' ' + l.split('\n')[0]).join('\n')}\n\n parley whoami | logout | help <command>\n\nGlobal flags: --json (machine-readable output), --backend <url>.`);
|
|
434
|
+
}
|
|
435
|
+
async function main(argv) {
|
|
436
|
+
const p = parseArgs(argv);
|
|
437
|
+
JSON_MODE = p.flags.json === true || p.flags.json === 'true';
|
|
438
|
+
if (p.flags.version === true || p.command === 'version' || p.command === '--version') {
|
|
439
|
+
console.log(VERSION);
|
|
440
|
+
return 0;
|
|
441
|
+
}
|
|
442
|
+
try {
|
|
443
|
+
switch (p.command) {
|
|
444
|
+
case 'login':
|
|
445
|
+
await cmdLogin(p);
|
|
446
|
+
break;
|
|
447
|
+
case 'logout':
|
|
448
|
+
await cmdLogout();
|
|
449
|
+
break;
|
|
450
|
+
case 'whoami':
|
|
451
|
+
await cmdWhoami();
|
|
452
|
+
break;
|
|
453
|
+
case 'account':
|
|
454
|
+
case 'balance':
|
|
455
|
+
await cmdAccount();
|
|
456
|
+
break;
|
|
457
|
+
case 'engines':
|
|
458
|
+
await cmdEngines();
|
|
459
|
+
break;
|
|
460
|
+
case 'camera-moves':
|
|
461
|
+
case 'moves':
|
|
462
|
+
await cmdCameraMoves();
|
|
463
|
+
break;
|
|
464
|
+
case 'image':
|
|
465
|
+
await cmdImage(p);
|
|
466
|
+
break;
|
|
467
|
+
case 'video':
|
|
468
|
+
await cmdVideo(p);
|
|
469
|
+
break;
|
|
470
|
+
case 'thumbnail':
|
|
471
|
+
await cmdThumbnail(p);
|
|
472
|
+
break;
|
|
473
|
+
case 'ugc-ad':
|
|
474
|
+
case 'ad':
|
|
475
|
+
await cmdUgcAd(p);
|
|
476
|
+
break;
|
|
477
|
+
case 'youtube':
|
|
478
|
+
case 'yt':
|
|
479
|
+
await cmdYoutube(p);
|
|
480
|
+
break;
|
|
481
|
+
case 'api':
|
|
482
|
+
await cmdApi(p);
|
|
483
|
+
break;
|
|
484
|
+
case 'help':
|
|
485
|
+
case '--help':
|
|
486
|
+
case '-h':
|
|
487
|
+
help(p.positionals[0]);
|
|
488
|
+
break;
|
|
489
|
+
default:
|
|
490
|
+
throw new CliError(`Unknown command "${p.command}". Run \`parley help\`.`, 64);
|
|
491
|
+
}
|
|
492
|
+
return 0;
|
|
493
|
+
}
|
|
494
|
+
catch (e) {
|
|
495
|
+
const code = e instanceof CliError ? e.exitCode : 1;
|
|
496
|
+
if (JSON_MODE)
|
|
497
|
+
console.log(JSON.stringify({ ok: false, error: e?.message || String(e), code }));
|
|
498
|
+
else
|
|
499
|
+
console.error(`error: ${e?.message || e}`);
|
|
500
|
+
return code;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
// Set the exit code and let the event loop drain rather than calling
|
|
504
|
+
// process.exit(): a hard exit while a download socket is still closing
|
|
505
|
+
// aborts Node on Windows (libuv UV_HANDLE_CLOSING assertion) even after the
|
|
506
|
+
// result was printed, which turned every successful download into a crash.
|
|
507
|
+
main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "parley-live",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generate images, video clips, YouTube thumbnails and product ads from the terminal with Parley. Built for coding agents: every command has a --json mode.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"parley": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.json",
|
|
20
|
+
"test": "npm run build && node test/cli.test.mjs",
|
|
21
|
+
"prepublishOnly": "npm run build"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.0.0",
|
|
25
|
+
"typescript": "^5.6.0"
|
|
26
|
+
},
|
|
27
|
+
"keywords": [
|
|
28
|
+
"parley",
|
|
29
|
+
"ai",
|
|
30
|
+
"video",
|
|
31
|
+
"image",
|
|
32
|
+
"cli",
|
|
33
|
+
"agent-skills"
|
|
34
|
+
]
|
|
35
|
+
}
|