parley-live 0.1.0 → 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 +34 -5
- package/dist/client.js +33 -0
- package/dist/index.js +378 -10
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Generate images, video clips, YouTube thumbnails and spokesperson product ads fr
|
|
|
5
5
|
```bash
|
|
6
6
|
npm install -g parley-live
|
|
7
7
|
parley login # one browser sign-in, stored under ~/.parley
|
|
8
|
-
parley
|
|
8
|
+
parley doctor # everything an agent needs to know before spending: session, credits, engines, fixes
|
|
9
9
|
parley image "a matte black espresso machine on marble, morning light" --out hero.png
|
|
10
10
|
parley video "steam rises, slow push in" --image hero.png --look standard --move dolly-in --ratio 9:16
|
|
11
11
|
parley thumbnail "I tested every AI video tool" --overlay "ALL OF THEM" --variants 3
|
|
@@ -16,12 +16,15 @@ parley ugc-ad --product product.jpg --name "Cold Brew Kit" --actor jordan --mark
|
|
|
16
16
|
|
|
17
17
|
| Command | What it does |
|
|
18
18
|
|---|---|
|
|
19
|
-
| `parley
|
|
19
|
+
| `parley doctor [--require <version>]` | Node, server, session, credits, live engines, the `@last` outputs, whether a newer release exists, and the fix for anything missing. `--require` fails "ready" when this CLI is older than what your skills expect. Run it first; `--json` for agents. |
|
|
20
|
+
| `parley voice "<text>" \| --file script.txt` | Narration as a hosted WAV. `--voice warm\|energetic\|documentary\|deep\|bright\|storyteller`, `--quality natural\|fish\|studio`, `--no-fallback`, `--out`. Natural voice: 1 credit per 1,000 characters; studio: 1 per 300. Becomes `@last` for `--narration`. `parley voices` lists personas and prices. |
|
|
21
|
+
| `parley studio …` | The film pipeline without JSON: `character add\|cast\|list`, `shot add\|list`, `render <shotId> [--wait]`, `assemble`, `reels`. Every subcommand takes `--project proj_…`. |
|
|
22
|
+
| `parley login [--no-browser]` | Device-code sign-in. Prints a link, you sign in once in the browser, the CLI receives a session token. `--no-browser` prints the link instead of opening it (cloud sandboxes, SSH). |
|
|
20
23
|
| `parley account` | Credit balance. |
|
|
21
24
|
| `parley engines` | Which video engines this server can run right now, with credit rates. |
|
|
22
25
|
| `parley camera-moves` | The named camera-move presets accepted by `video --move`. |
|
|
23
|
-
| `parley image "<prompt>"` | Generate an image. `--
|
|
24
|
-
| `parley video "<motion prompt>"` | Animate a still (`--image
|
|
26
|
+
| `parley image "<prompt>"` | Generate an image. `--ratio 16:9\|9:16\|1:1\|4:3\|3:4` sizes it for the destination; `--model`, `--quality`, `--image-size`, `--ref <file\|url\|@last>` (repeatable), `--out`. |
|
|
27
|
+
| `parley video "<motion prompt>"` | Animate a still (`--image <file\|url\|@last>`) or text-to-video (`--look photoreal`). `--look`, `--move` (repeatable, up to 3), `--duration`, `--ratio`, `--out`. |
|
|
25
28
|
| `parley thumbnail "<title>"` | 1-3 YouTube thumbnail variants. `--overlay`, `--accent`, `--person`, `--out <dir>`. |
|
|
26
29
|
| `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
30
|
| `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`. |
|
|
@@ -41,6 +44,30 @@ parley ugc-ad --product product.jpg --name "Cold Brew Kit" --actor jordan --mark
|
|
|
41
44
|
|
|
42
45
|
`--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
46
|
|
|
47
|
+
### Chaining with `@last`
|
|
48
|
+
|
|
49
|
+
The CLI remembers the last image and the last clip it made (`~/.parley/last.json`), so a still becomes a clip without re-typing a URL:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
parley image "matte black espresso machine on marble, morning light" --ratio 9:16 --out hero.png
|
|
53
|
+
parley video "steam rises, a hand lifts the cup" --image @last --move dolly-in --ratio 9:16
|
|
54
|
+
parley image "same machine, top-down on oak" --ref @last --ratio 1:1
|
|
55
|
+
parley voice --file script.txt --voice documentary --out narration.wav
|
|
56
|
+
parley youtube --beat https://…/01.png --beat https://…/02.png --narration @last --captions
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
### A film from the terminal
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
parley studio character add --project proj_letter-7f3k --name Maya --description "Maya, 32, athletic, oval face, dark hair in a low knot, oat technical top"
|
|
63
|
+
parley studio character cast char_…
|
|
64
|
+
parley studio shot add --project proj_letter-7f3k --order 0 --framing "Maya at a kitchen table at dawn, medium shot" \
|
|
65
|
+
--directing "She unfolds the letter and reads. Clip ends with her eyes on the page." \
|
|
66
|
+
--characters char_… --duration 12 --camera "arri-alexa-35, dolly-in"
|
|
67
|
+
parley studio render shot_… --wait
|
|
68
|
+
parley studio assemble --project proj_letter-7f3k --title "The Letter" --synopsis "A woman reads the letter she avoided."
|
|
69
|
+
```
|
|
70
|
+
|
|
44
71
|
## For agents
|
|
45
72
|
|
|
46
73
|
Add `--json` to any command and read the last line of stdout:
|
|
@@ -51,7 +78,9 @@ Add `--json` to any command and read the last line of stdout:
|
|
|
51
78
|
|
|
52
79
|
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
80
|
|
|
54
|
-
The MIT-licensed agent skills that teach Claude Code, Cursor and
|
|
81
|
+
The MIT-licensed agent skills that teach Claude Code, Cursor, Codex, Gemini CLI, Copilot and 20 other agents how to use this CLI well live at [github.com/onehermes/parley-skills](https://github.com/onehermes/parley-skills): `npx skills add onehermes/parley-skills`.
|
|
82
|
+
|
|
83
|
+
In a cloud sandbox or over SSH: `parley login --no-browser` prints the approval link, `PARLEY_TOKEN` skips login entirely, and `npx -y parley-live …` runs without a global install.
|
|
55
84
|
|
|
56
85
|
## Configuration
|
|
57
86
|
|
package/dist/client.js
CHANGED
|
@@ -169,6 +169,39 @@ export function fileToDataUri(filePath) {
|
|
|
169
169
|
export function isUrl(s) {
|
|
170
170
|
return /^https?:\/\//i.test(s);
|
|
171
171
|
}
|
|
172
|
+
function lastPath() {
|
|
173
|
+
return path.join(homeDir(), 'last.json');
|
|
174
|
+
}
|
|
175
|
+
export function loadLast() {
|
|
176
|
+
try {
|
|
177
|
+
const p = lastPath();
|
|
178
|
+
if (!existsSync(p))
|
|
179
|
+
return {};
|
|
180
|
+
const parsed = JSON.parse(readFileSync(p, 'utf8'));
|
|
181
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return {};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
export function rememberLast(kind, entry) {
|
|
188
|
+
try {
|
|
189
|
+
mkdirSync(homeDir(), { recursive: true });
|
|
190
|
+
const cur = loadLast();
|
|
191
|
+
cur[kind] = { file: path.resolve(entry.file), url: entry.url, at: new Date().toISOString() };
|
|
192
|
+
writeFileSync(lastPath(), JSON.stringify(cur, null, 2) + '\n');
|
|
193
|
+
}
|
|
194
|
+
catch { /* best effort: chaining is a convenience, never a failure */ }
|
|
195
|
+
}
|
|
196
|
+
/** Resolve "@last" (or "@last-image" / "@last-video") to the most recent output of that kind. */
|
|
197
|
+
export function resolveRef(ref, kind) {
|
|
198
|
+
if (ref !== '@last' && ref !== `@last-${kind}`)
|
|
199
|
+
return ref;
|
|
200
|
+
const last = loadLast()[kind];
|
|
201
|
+
if (!last)
|
|
202
|
+
throw new CliError(`No previous ${kind} on this machine yet. Make one first (parley ${kind} …), then pass @last.`);
|
|
203
|
+
return last.url || last.file;
|
|
204
|
+
}
|
|
172
205
|
export function sleep(ms) {
|
|
173
206
|
return new Promise((r) => setTimeout(r, ms));
|
|
174
207
|
}
|
package/dist/index.js
CHANGED
|
@@ -16,8 +16,18 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { readFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
18
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.
|
|
19
|
+
import { api, authWebUrl, backendUrl, clearCredentials, CliError, downloadTo, fileToDataUri, isUrl, loadCredentials, loadLast, openInBrowser, rememberLast, resolveRef, saveCredentials, sleep, } from './client.js';
|
|
20
|
+
const VERSION = '0.3.0';
|
|
21
|
+
/** Semver-ish compare: 1 if a > b, -1 if a < b, 0 if equal (numeric parts only). */
|
|
22
|
+
function compareVersions(a, b) {
|
|
23
|
+
const pa = a.replace(/^v/, '').split('.').map((x) => parseInt(x, 10) || 0);
|
|
24
|
+
const pb = b.replace(/^v/, '').split('.').map((x) => parseInt(x, 10) || 0);
|
|
25
|
+
for (let i = 0; i < 3; i++) {
|
|
26
|
+
if ((pa[i] || 0) !== (pb[i] || 0))
|
|
27
|
+
return (pa[i] || 0) > (pb[i] || 0) ? 1 : -1;
|
|
28
|
+
}
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
21
31
|
function parseArgs(argv) {
|
|
22
32
|
const out = { command: '', positionals: [], flags: {} };
|
|
23
33
|
const rest = [...argv];
|
|
@@ -72,6 +82,10 @@ async function cmdLogin(p) {
|
|
|
72
82
|
const backend = backendUrl(str(p.flags.backend));
|
|
73
83
|
const started = await api('/api/auth/device/start', { method: 'POST', body: {}, auth: false, backend });
|
|
74
84
|
const link = `${authWebUrl()}/desktop-auth?code=${encodeURIComponent(started.user_code)}`;
|
|
85
|
+
// --no-browser: print the link only. The right choice inside a cloud
|
|
86
|
+
// sandbox or over SSH, where "open a browser" means someone else's screen.
|
|
87
|
+
if (p.flags['no-browser'] === true)
|
|
88
|
+
process.env.PARLEY_NO_BROWSER = '1';
|
|
75
89
|
const opened = openInBrowser(link);
|
|
76
90
|
// Always on stderr, even in --json mode: an agent driving `parley login
|
|
77
91
|
// --json` must be able to relay the link to the person who has to click it.
|
|
@@ -140,12 +154,22 @@ function outPathFor(p, defaultName) {
|
|
|
140
154
|
async function cmdImage(p) {
|
|
141
155
|
const prompt = p.positionals.join(' ').trim();
|
|
142
156
|
if (!prompt)
|
|
143
|
-
throw new CliError(
|
|
144
|
-
const refs = list(p.flags.ref).map((r) => (isUrl(r) ? r : fileToDataUri(r)));
|
|
157
|
+
throw new CliError(`Usage: parley image "<prompt>" [--ratio ${Object.keys(RATIO_TO_SIZE).join('|')}] [--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|@last>]... [--out file.png]`);
|
|
158
|
+
const refs = list(p.flags.ref).map((r) => resolveRef(r, 'image')).map((r) => (isUrl(r) ? r : fileToDataUri(r)));
|
|
145
159
|
const body = { prompt };
|
|
146
160
|
const model = str(p.flags.model);
|
|
147
161
|
if (model)
|
|
148
162
|
body.model = model;
|
|
163
|
+
// --ratio is the destination-first way to size an image: it sets the exact
|
|
164
|
+
// gpt-image-2 size AND the aspect ratio the other models take, so the same
|
|
165
|
+
// flag works whichever model serves the request. --size still wins if given.
|
|
166
|
+
const ratio = str(p.flags.ratio);
|
|
167
|
+
if (ratio) {
|
|
168
|
+
if (!(ratio in RATIO_TO_SIZE))
|
|
169
|
+
throw new CliError(`--ratio must be one of ${Object.keys(RATIO_TO_SIZE).join(', ')}`);
|
|
170
|
+
body.aspectRatio = ratio;
|
|
171
|
+
body.gptImageSize = RATIO_TO_SIZE[ratio];
|
|
172
|
+
}
|
|
149
173
|
const size = str(p.flags.size);
|
|
150
174
|
if (size)
|
|
151
175
|
body.gptImageSize = size;
|
|
@@ -175,8 +199,13 @@ async function cmdImage(p) {
|
|
|
175
199
|
}
|
|
176
200
|
else
|
|
177
201
|
throw new CliError('The server returned no image.');
|
|
202
|
+
rememberLast('image', { file: out, url: r.imageUrl ?? null });
|
|
178
203
|
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
204
|
}
|
|
205
|
+
/** Destination ratio → the exact gpt-image-2 size (the other models take the ratio itself). */
|
|
206
|
+
const RATIO_TO_SIZE = {
|
|
207
|
+
'16:9': '1536x1024', '9:16': '1024x1536', '1:1': '1024x1024', '4:3': '1536x1024', '3:4': '1024x1536',
|
|
208
|
+
};
|
|
180
209
|
const LOOKS = ['fast', 'standard', 'cinematic', 'photoreal', 'transform', 'camera'];
|
|
181
210
|
const LOOK_TO_PROVIDER = {
|
|
182
211
|
fast: 'hailuo', standard: 'kling', illustrated: 'kling', cinematic: 'veo', photoreal: 'seedance-2.0', transform: 'gemini-omni', camera: 'higgsfield-dop',
|
|
@@ -188,7 +217,8 @@ async function cmdVideo(p) {
|
|
|
188
217
|
const look = str(p.flags.look, 'standard');
|
|
189
218
|
if (!(look in LOOK_TO_PROVIDER))
|
|
190
219
|
throw new CliError(`Unknown --look "${look}". Use one of: ${LOOKS.join(', ')}.`);
|
|
191
|
-
const
|
|
220
|
+
const imageFlag = str(p.flags.image);
|
|
221
|
+
const image = imageFlag ? resolveRef(imageFlag, 'image') : undefined;
|
|
192
222
|
const imageFiles = image ? [isUrl(image) ? image : fileToDataUri(image)] : [];
|
|
193
223
|
if (!image && look !== 'photoreal') {
|
|
194
224
|
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.');
|
|
@@ -226,6 +256,7 @@ async function cmdVideo(p) {
|
|
|
226
256
|
const out = outPathFor(p, `parley-video-${jobId}.mp4`);
|
|
227
257
|
const src = r.videoUrl || `${backendUrl()}/video/jobs/${jobId}/result?download=1`;
|
|
228
258
|
const bytes = await downloadTo(src, out);
|
|
259
|
+
rememberLast('video', { file: out, url: r.videoUrl ?? null });
|
|
229
260
|
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
261
|
}
|
|
231
262
|
async function cmdThumbnail(p) {
|
|
@@ -341,12 +372,16 @@ async function cmdYoutube(p) {
|
|
|
341
372
|
const tier = str(p.flags.tier, 'standard');
|
|
342
373
|
if (!YT_TIERS.includes(tier))
|
|
343
374
|
throw new CliError(`--tier must be one of ${YT_TIERS.join(', ')}`);
|
|
344
|
-
const
|
|
375
|
+
const narrationFlag = str(p.flags.narration);
|
|
376
|
+
const narration = narrationFlag ? resolveRef(narrationFlag, 'audio') : undefined;
|
|
345
377
|
const music = str(p.flags.music);
|
|
346
378
|
const project = str(p.flags.project);
|
|
347
379
|
const silent = p.flags.silent === true;
|
|
348
380
|
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.');
|
|
381
|
+
throw new CliError('Pass --narration <https audio URL | @last> (the voiceover the video is cut to; make one with `parley voice`), or --silent if the user explicitly wants no voiceover.');
|
|
382
|
+
}
|
|
383
|
+
if (narration && !isUrl(narration)) {
|
|
384
|
+
throw new CliError(`--narration must be a public https URL (got "${narration}"). \`parley voice\` prints one, and @last reuses it.`);
|
|
350
385
|
}
|
|
351
386
|
const body = {
|
|
352
387
|
beats: beats.map((imageUrl) => ({ imageUrl })),
|
|
@@ -385,6 +420,222 @@ async function cmdYoutube(p) {
|
|
|
385
420
|
const bytes = await downloadTo(videoUrl, out);
|
|
386
421
|
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
422
|
}
|
|
423
|
+
const VOICES = ['warm', 'energetic', 'documentary', 'deep', 'bright', 'storyteller'];
|
|
424
|
+
const VOICE_TIERS = ['natural', 'fish', 'studio'];
|
|
425
|
+
/**
|
|
426
|
+
* parley voice "<text>" | --file script.txt [--voice warm|…] [--quality natural|fish|studio]
|
|
427
|
+
* [--no-fallback] [--out narration.wav]
|
|
428
|
+
* Narration as a hosted WAV. Natural (Kokoro) costs 1 credit per 1,000
|
|
429
|
+
* characters, studio (ElevenLabs) 1 per 300; the server never ships the
|
|
430
|
+
* synthetic fallback voice silently (voiceWarning), and --no-fallback refuses
|
|
431
|
+
* it outright. The result is @last for --narration.
|
|
432
|
+
*/
|
|
433
|
+
async function cmdVoice(p) {
|
|
434
|
+
const file = str(p.flags.file);
|
|
435
|
+
let text = p.positionals.join(' ').trim();
|
|
436
|
+
if (file) {
|
|
437
|
+
if (!existsSync(file))
|
|
438
|
+
throw new CliError(`Script file not found: ${file}`);
|
|
439
|
+
text = readFileSync(file, 'utf8').trim();
|
|
440
|
+
}
|
|
441
|
+
if (!text)
|
|
442
|
+
throw new CliError(`Usage: parley voice "<text>" | --file script.txt [--voice ${VOICES.join('|')}] [--quality ${VOICE_TIERS.join('|')}] [--no-fallback] [--out narration.wav]`);
|
|
443
|
+
if (text.length > 5000)
|
|
444
|
+
throw new CliError(`The text is ${text.length} characters; each request takes up to 5,000. Split the script into sections and make one file per section.`);
|
|
445
|
+
const voice = str(p.flags.voice);
|
|
446
|
+
if (voice && !VOICES.includes(voice))
|
|
447
|
+
throw new CliError(`--voice must be one of ${VOICES.join(', ')}`);
|
|
448
|
+
const quality = str(p.flags.quality, 'natural');
|
|
449
|
+
if (!VOICE_TIERS.includes(quality))
|
|
450
|
+
throw new CliError(`--quality must be one of ${VOICE_TIERS.join(', ')}`);
|
|
451
|
+
const body = { text, quality, ...(voice ? { voice } : {}), ...(p.flags['no-fallback'] === true ? { allowFallback: false } : {}) };
|
|
452
|
+
note(`Narrating ${text.length} characters with the ${quality} voice${voice ? ` (${voice})` : ''}…`);
|
|
453
|
+
const r = await api('/api/voice/generate', { body });
|
|
454
|
+
const out = outPathFor(p, `parley-voice-${Date.now()}.wav`);
|
|
455
|
+
const bytes = await downloadTo(r.audioUrl, out);
|
|
456
|
+
rememberLast('audio', { file: out, url: r.audioUrl });
|
|
457
|
+
if (r.voiceWarning)
|
|
458
|
+
console.error(`warning: ${r.voiceWarning}`);
|
|
459
|
+
emit({ ok: true, file: out, bytes, audioUrl: r.audioUrl, durationSeconds: r.durationSeconds, engine: r.engine, voice: r.voice, creditsCharged: r.creditsCharged, voiceWarning: r.voiceWarning ?? null }, `Saved ${out} (${r.durationSeconds ?? '?'}s, ${r.engine}, ${r.creditsCharged} credits)\nHosted: ${r.audioUrl}`);
|
|
460
|
+
}
|
|
461
|
+
async function cmdVoices() {
|
|
462
|
+
const v = await api('/api/voice/voices', { auth: false });
|
|
463
|
+
if (JSON_MODE) {
|
|
464
|
+
console.log(JSON.stringify({ ok: true, ...v }));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
for (const x of v.voices)
|
|
468
|
+
console.log(`${x.id.padEnd(13)} ${x.label}`);
|
|
469
|
+
console.log('');
|
|
470
|
+
for (const [tier, t] of Object.entries(v.tiers))
|
|
471
|
+
console.log(`${tier.padEnd(13)} ${t.creditsPer} (${t.engine})`);
|
|
472
|
+
console.log(`\nup to ${v.maxChars} characters per request`);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* parley studio <character|shot|render|assemble|reels> …
|
|
476
|
+
* First-class Studio commands over the same API `parley api` reaches, so a
|
|
477
|
+
* person at a terminal gets the film pipeline without writing JSON:
|
|
478
|
+
* studio character add --project P --name N --description D [--tags a,b] [--wardrobe W] [--voice V]
|
|
479
|
+
* studio character cast <characterId> [--count 1] [--direction "…"] [--force]
|
|
480
|
+
* studio character list --project P
|
|
481
|
+
* studio shot add --project P --order N --framing "…" --directing "…" [--characters id,id] [--location id]
|
|
482
|
+
* [--duration 12] [--camera "arri-alexa-35, dolly-in"] [--line "characterId:text"]…
|
|
483
|
+
* studio shot list --project P
|
|
484
|
+
* studio render <shotId> [--force] [--wait]
|
|
485
|
+
* studio assemble --project P --title T [--synopsis S] [--title-text X] [--music path] [--narration url]
|
|
486
|
+
* studio reels --project P
|
|
487
|
+
*/
|
|
488
|
+
async function cmdStudio(p) {
|
|
489
|
+
const [group, action, target] = p.positionals;
|
|
490
|
+
const project = str(p.flags.project);
|
|
491
|
+
const needProject = () => {
|
|
492
|
+
if (!project || !/^proj_[A-Za-z0-9_-]{6,}$/.test(project))
|
|
493
|
+
throw new CliError('--project must be a Studio project id like proj_letter-7f3k (proj_ plus at least six letters, digits, _ or -).');
|
|
494
|
+
return project;
|
|
495
|
+
};
|
|
496
|
+
const out = (obj, human) => { if (JSON_MODE)
|
|
497
|
+
console.log(JSON.stringify(obj));
|
|
498
|
+
else
|
|
499
|
+
console.log(human()); };
|
|
500
|
+
if (group === 'character' && action === 'add') {
|
|
501
|
+
const P = needProject();
|
|
502
|
+
const name = str(p.flags.name);
|
|
503
|
+
const description = str(p.flags.description);
|
|
504
|
+
if (!name || !description)
|
|
505
|
+
throw new CliError('studio character add needs --name and --description (the identity lock: age, build, face, hair, skin, wardrobe).');
|
|
506
|
+
const body = { name, description };
|
|
507
|
+
const tags = list(p.flags.tags);
|
|
508
|
+
if (tags.length)
|
|
509
|
+
body.personalityTags = tags;
|
|
510
|
+
const wardrobe = str(p.flags.wardrobe);
|
|
511
|
+
if (wardrobe)
|
|
512
|
+
body.wardrobe = wardrobe;
|
|
513
|
+
const voice = str(p.flags.voice);
|
|
514
|
+
if (voice)
|
|
515
|
+
body.voiceId = voice;
|
|
516
|
+
const c = await api(`/api/studio/projects/${P}/characters`, { body });
|
|
517
|
+
return out({ ok: true, character: c }, () => `Character ${c.name} created: ${c.id}\nNext: parley studio character cast ${c.id}`);
|
|
518
|
+
}
|
|
519
|
+
if (group === 'character' && action === 'cast') {
|
|
520
|
+
if (!target)
|
|
521
|
+
throw new CliError('Usage: parley studio character cast <characterId> [--count 1] [--direction "…"] [--force]');
|
|
522
|
+
const body = { count: num(p.flags.count, 1) };
|
|
523
|
+
const direction = str(p.flags.direction);
|
|
524
|
+
if (direction)
|
|
525
|
+
body.additionalDirection = direction;
|
|
526
|
+
if (p.flags.force === true)
|
|
527
|
+
body.force = true;
|
|
528
|
+
note('Casting (renders the reference image)…');
|
|
529
|
+
const r = await api(`/api/studio/characters/${target}/cast`, { body });
|
|
530
|
+
const refs = r.character?.reference_image_paths || r.reference_image_paths || [];
|
|
531
|
+
return out({ ok: true, ...r }, () => `${r.skipped_reason ? r.skipped_reason + '\n' : ''}Reference images: ${refs.length ? refs.join(', ') : '(see JSON)'}`);
|
|
532
|
+
}
|
|
533
|
+
if (group === 'character' && action === 'list') {
|
|
534
|
+
const P = needProject();
|
|
535
|
+
const r = await api(`/api/studio/projects/${P}/characters`);
|
|
536
|
+
const items = Array.isArray(r) ? r : r.characters || [];
|
|
537
|
+
return out({ ok: true, characters: items }, () => items.map((c) => `${c.id} ${c.name} refs=${(c.reference_image_paths || []).length}`).join('\n') || '(no characters yet)');
|
|
538
|
+
}
|
|
539
|
+
if (group === 'shot' && action === 'add') {
|
|
540
|
+
const P = needProject();
|
|
541
|
+
const framing = str(p.flags.framing);
|
|
542
|
+
const directing = str(p.flags.directing);
|
|
543
|
+
if (!framing || !directing)
|
|
544
|
+
throw new CliError('studio shot add needs --framing (the still) and --directing (what happens, with the ending stated).');
|
|
545
|
+
const body = { orderIndex: num(p.flags.order, 0), framingPrompt: framing, directingPrompt: directing };
|
|
546
|
+
const chars = list(p.flags.characters);
|
|
547
|
+
if (chars.length)
|
|
548
|
+
body.characterIds = chars;
|
|
549
|
+
const location = str(p.flags.location);
|
|
550
|
+
if (location)
|
|
551
|
+
body.locationId = location;
|
|
552
|
+
const duration = str(p.flags.duration);
|
|
553
|
+
if (duration)
|
|
554
|
+
body.durationSeconds = Number(duration);
|
|
555
|
+
const camera = str(p.flags.camera);
|
|
556
|
+
if (camera)
|
|
557
|
+
body.cameraEmulation = camera;
|
|
558
|
+
const lines = list(p.flags.line).map((l) => { const i = l.indexOf(':'); if (i < 1)
|
|
559
|
+
throw new CliError('--line must be "characterId:text"'); return { characterId: l.slice(0, i).trim(), text: l.slice(i + 1).trim() }; });
|
|
560
|
+
if (lines.length)
|
|
561
|
+
body.dialogLines = lines;
|
|
562
|
+
const s = await api(`/api/studio/projects/${P}/shots`, { body });
|
|
563
|
+
return out({ ok: true, shot: s }, () => `Shot #${(s.order_index ?? 0) + 1} created: ${s.id}\nNext: parley studio render ${s.id} --wait`);
|
|
564
|
+
}
|
|
565
|
+
if (group === 'shot' && action === 'list') {
|
|
566
|
+
const P = needProject();
|
|
567
|
+
const r = await api(`/api/studio/projects/${P}/shots`);
|
|
568
|
+
const items = Array.isArray(r) ? r : r.shots || [];
|
|
569
|
+
return out({ ok: true, shots: items }, () => items.map((s) => `#${(s.order_index ?? 0) + 1} ${s.id} ${String(s.status).padEnd(9)} ${s.duration_seconds ?? '?'}s ${s.camera_emulation || ''}${s.video_path ? ' clip ready' : ''}`).join('\n') || '(no shots yet)');
|
|
570
|
+
}
|
|
571
|
+
if (group === 'render') {
|
|
572
|
+
const shotId = action;
|
|
573
|
+
if (!shotId)
|
|
574
|
+
throw new CliError('Usage: parley studio render <shotId> [--force] [--wait]');
|
|
575
|
+
const body = {};
|
|
576
|
+
if (p.flags.force === true)
|
|
577
|
+
body.force = true;
|
|
578
|
+
note('Starting the render (a single multi-minute call)…');
|
|
579
|
+
const r = await api(`/api/studio/shots/${shotId}/render`, { body });
|
|
580
|
+
if (r.skipped_reason)
|
|
581
|
+
return out({ ok: true, ...r }, () => r.skipped_reason);
|
|
582
|
+
if (p.flags.wait !== true)
|
|
583
|
+
return out({ ok: true, ...r }, () => `Render started for ${shotId} (status ${r.status ?? 'running'}). Poll with: parley studio shot list --project ${r.project_id || '<project>'}`);
|
|
584
|
+
const P = r.project_id || project;
|
|
585
|
+
if (!P)
|
|
586
|
+
throw new CliError('Cannot wait without a project id; pass --project.');
|
|
587
|
+
const deadline = Date.now() + 45 * 60 * 1000;
|
|
588
|
+
const pollMs = Number(process.env.PARLEY_POLL_MS) || 30000;
|
|
589
|
+
let last = '';
|
|
590
|
+
while (Date.now() < deadline) {
|
|
591
|
+
await sleep(pollMs);
|
|
592
|
+
const shots = await api(`/api/studio/projects/${P}/shots`).then((x) => (Array.isArray(x) ? x : x.shots || []));
|
|
593
|
+
const s = shots.find((x) => x.id === shotId);
|
|
594
|
+
if (!s)
|
|
595
|
+
throw new CliError(`Shot ${shotId} disappeared from project ${P}.`);
|
|
596
|
+
if (s.status !== last) {
|
|
597
|
+
note(` ${s.status}`);
|
|
598
|
+
last = s.status;
|
|
599
|
+
}
|
|
600
|
+
if (s.status === 'ready' && s.video_path)
|
|
601
|
+
return out({ ok: true, shot: s }, () => `Shot ${shotId} rendered: ${s.video_path}`);
|
|
602
|
+
if (s.status === 'failed')
|
|
603
|
+
throw new CliError(`Shot ${shotId} failed to render${s.error ? `: ${s.error}` : ''}.`);
|
|
604
|
+
}
|
|
605
|
+
throw new CliError('Timed out waiting for the render (45 minutes).');
|
|
606
|
+
}
|
|
607
|
+
if (group === 'assemble') {
|
|
608
|
+
const P = needProject();
|
|
609
|
+
const title = str(p.flags.title);
|
|
610
|
+
if (!title)
|
|
611
|
+
throw new CliError('studio assemble needs --title (and usually --synopsis).');
|
|
612
|
+
const body = { title };
|
|
613
|
+
const synopsis = str(p.flags.synopsis);
|
|
614
|
+
if (synopsis)
|
|
615
|
+
body.synopsis = synopsis;
|
|
616
|
+
const titleText = str(p.flags['title-text']);
|
|
617
|
+
if (titleText)
|
|
618
|
+
body.titleText = titleText;
|
|
619
|
+
const music = str(p.flags.music);
|
|
620
|
+
if (music)
|
|
621
|
+
body.musicPath = music;
|
|
622
|
+
const narration = str(p.flags.narration);
|
|
623
|
+
if (narration)
|
|
624
|
+
body.narrationUrl = resolveRef(narration, 'audio');
|
|
625
|
+
note('Assembling the reel (score generated and ducked automatically)…');
|
|
626
|
+
const reel = await api(`/api/studio/projects/${P}/assemble`, { body });
|
|
627
|
+
if (reel.public_url)
|
|
628
|
+
rememberLast('video', { file: reel.video_path || reel.public_url, url: reel.public_url });
|
|
629
|
+
return out({ ok: true, reel }, () => `Reel ${reel.id || ''}: ${reel.public_url || reel.video_path || '(see JSON)'}`);
|
|
630
|
+
}
|
|
631
|
+
if (group === 'reels') {
|
|
632
|
+
const P = needProject();
|
|
633
|
+
const r = await api(`/api/studio/projects/${P}/reels`);
|
|
634
|
+
const items = r.reels || (Array.isArray(r) ? r : []);
|
|
635
|
+
return out({ ok: true, reels: items }, () => items.map((x) => `${x.id} ${x.public_url || x.video_path}`).join('\n') || '(no reels yet)');
|
|
636
|
+
}
|
|
637
|
+
throw new CliError('Usage: parley studio <character add|cast|list | shot add|list | render <shotId> | assemble | reels> … (see `parley help studio`)');
|
|
638
|
+
}
|
|
388
639
|
/**
|
|
389
640
|
* parley api <GET|POST|PATCH|PUT|DELETE> <path> [--data '<json>' | --data @file.json]
|
|
390
641
|
* Signed raw call to any Parley endpoint (the Studio's projects, characters,
|
|
@@ -413,11 +664,114 @@ async function cmdApi(p) {
|
|
|
413
664
|
else
|
|
414
665
|
console.log(JSON.stringify(r, null, 2));
|
|
415
666
|
}
|
|
667
|
+
/**
|
|
668
|
+
* parley doctor [--json]
|
|
669
|
+
* One call that tells an agent (or a person) exactly what state this machine
|
|
670
|
+
* is in and what to do next: Node version, whether the API is reachable,
|
|
671
|
+
* whether a session exists, the credit balance, which engines are live, and
|
|
672
|
+
* the last outputs available as @last. Exit 0 always; `ready` says whether a
|
|
673
|
+
* generation would succeed right now.
|
|
674
|
+
*/
|
|
675
|
+
async function cmdDoctor(p) {
|
|
676
|
+
const report = { cli: VERSION, node: process.version, backendUrl: backendUrl() };
|
|
677
|
+
const fixes = [];
|
|
678
|
+
const major = Number(process.version.replace(/^v/, '').split('.')[0]);
|
|
679
|
+
report.nodeOk = major >= 20;
|
|
680
|
+
if (!report.nodeOk)
|
|
681
|
+
fixes.push(`Node ${process.version} is too old: install Node 20 or newer.`);
|
|
682
|
+
// --require <version>: the skills declare the CLI they were written for.
|
|
683
|
+
// A CLI older than that is not "ready", whatever else is fine, so an agent
|
|
684
|
+
// never runs a skill against a CLI that lacks the commands the skill uses.
|
|
685
|
+
const required = str(p.flags.require);
|
|
686
|
+
if (required) {
|
|
687
|
+
report.required = required;
|
|
688
|
+
report.cliOk = compareVersions(VERSION, required) >= 0;
|
|
689
|
+
if (!report.cliOk)
|
|
690
|
+
fixes.push(`parley ${VERSION} is older than the ${required} these skills expect: run \`npm install -g parley-live@latest\` (or use \`npx -y parley-live@latest\`).`);
|
|
691
|
+
}
|
|
692
|
+
else {
|
|
693
|
+
report.cliOk = true;
|
|
694
|
+
}
|
|
695
|
+
// Best-effort "is there a newer release" check against the registry; never
|
|
696
|
+
// blocks, never fails the report (a sandbox without npm access just skips it).
|
|
697
|
+
// PARLEY_SKIP_UPDATE_CHECK=1 turns it off (tests, air-gapped CI).
|
|
698
|
+
if (process.env.PARLEY_SKIP_UPDATE_CHECK !== '1')
|
|
699
|
+
try {
|
|
700
|
+
const ctrl = new AbortController();
|
|
701
|
+
const t = setTimeout(() => ctrl.abort(), 3000);
|
|
702
|
+
const r = await fetch('https://registry.npmjs.org/parley-live/latest', { signal: ctrl.signal, headers: { Accept: 'application/json' } });
|
|
703
|
+
clearTimeout(t);
|
|
704
|
+
if (r.ok) {
|
|
705
|
+
const latest = (await r.json()).version;
|
|
706
|
+
if (latest) {
|
|
707
|
+
report.latest = latest;
|
|
708
|
+
if (compareVersions(latest, VERSION) > 0)
|
|
709
|
+
report.update = `parley-live ${latest} is available (you have ${VERSION}): npm install -g parley-live@latest`;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
catch { /* offline or blocked: fine */ }
|
|
714
|
+
try {
|
|
715
|
+
const e = await api('/video/engines', { auth: false });
|
|
716
|
+
report.reachable = true;
|
|
717
|
+
report.engines = Object.fromEntries(Object.entries(e.engines).map(([k, v]) => [k, { ready: v.ready, creditsPerSec: v.creditsPerSec ?? null, fallback: v.fallback ?? null }]));
|
|
718
|
+
report.cameraMoves = Array.isArray(e.cameraMoves) ? e.cameraMoves.length : 0;
|
|
719
|
+
}
|
|
720
|
+
catch (e) {
|
|
721
|
+
report.reachable = false;
|
|
722
|
+
fixes.push(`Cannot reach ${backendUrl()} (${e?.message || e}). Check the network, or set PARLEY_BACKEND_URL if you use a different server.`);
|
|
723
|
+
}
|
|
724
|
+
const creds = loadCredentials();
|
|
725
|
+
report.signedIn = false;
|
|
726
|
+
if (!creds && !process.env.PARLEY_TOKEN) {
|
|
727
|
+
fixes.push('Not signed in: run `parley login` (or `parley login --no-browser` and open the printed link). In CI or a cloud sandbox, set PARLEY_TOKEN instead.');
|
|
728
|
+
}
|
|
729
|
+
else if (report.reachable) {
|
|
730
|
+
try {
|
|
731
|
+
const bal = await api('/api/credits/balance');
|
|
732
|
+
report.signedIn = true;
|
|
733
|
+
report.email = creds?.email ?? null;
|
|
734
|
+
report.credits = bal.credits_balance;
|
|
735
|
+
if (bal.credits_balance <= 0)
|
|
736
|
+
fixes.push('Credit balance is 0: top up at https://labs.parley.live before generating.');
|
|
737
|
+
}
|
|
738
|
+
catch (e) {
|
|
739
|
+
if (e instanceof CliError && e.exitCode === 2)
|
|
740
|
+
fixes.push('The stored session is no longer valid: run `parley login` again.');
|
|
741
|
+
else
|
|
742
|
+
fixes.push(`Could not read the balance: ${e?.message || e}`);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
const last = loadLast();
|
|
746
|
+
report.last = { image: last.image?.url ?? last.image?.file ?? null, video: last.video?.url ?? last.video?.file ?? null, audio: last.audio?.url ?? last.audio?.file ?? null };
|
|
747
|
+
report.ready = report.nodeOk === true && report.cliOk === true && report.reachable === true && report.signedIn === true && (typeof report.credits !== 'number' || report.credits > 0);
|
|
748
|
+
report.fixes = fixes;
|
|
749
|
+
if (JSON_MODE) {
|
|
750
|
+
console.log(JSON.stringify({ ok: true, ...report }));
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
console.log(`parley ${VERSION} on Node ${process.version}${report.nodeOk ? '' : ' (too old)'}${report.update ? ` (update: ${report.latest})` : ''}${required ? ` (skills expect ${required}: ${report.cliOk ? 'ok' : 'TOO OLD'})` : ''}`);
|
|
754
|
+
console.log(`server ${report.backendUrl} ${report.reachable ? 'reachable' : 'UNREACHABLE'}`);
|
|
755
|
+
console.log(`session ${report.signedIn ? `signed in as ${report.email || 'user'}, ${report.credits} credits` : 'not signed in'}`);
|
|
756
|
+
if (report.engines) {
|
|
757
|
+
const live = Object.entries(report.engines).filter(([, v]) => v.ready).map(([k]) => k);
|
|
758
|
+
console.log(`engines ${live.join(', ')} (${report.cameraMoves} camera moves)`);
|
|
759
|
+
}
|
|
760
|
+
if (report.last && (report.last.image || report.last.video || report.last.audio))
|
|
761
|
+
console.log(`last image=${report.last.image || '-'} video=${report.last.video || '-'} audio=${report.last.audio || '-'}`);
|
|
762
|
+
console.log(`ready ${report.ready ? 'yes' : 'no'}`);
|
|
763
|
+
for (const f of fixes)
|
|
764
|
+
console.log(` fix: ${f}`);
|
|
765
|
+
}
|
|
416
766
|
function help(topic) {
|
|
417
767
|
const lines = {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
768
|
+
doctor: 'parley doctor [--require <version>] [--json]\n Node, server, session, credits, live engines, the @last outputs and whether a newer release exists, with the fix for anything missing. --require fails "ready" when this CLI is older than what your skills expect. Run this first.',
|
|
769
|
+
voice: `parley voice "<text>" | --file script.txt [--voice ${VOICES.join('|')}] [--quality ${VOICE_TIERS.join('|')}] [--no-fallback] [--out narration.wav]\n Narration as a hosted WAV (natural voice 1 credit per 1,000 characters; studio 1 per 300). Becomes @last for --narration.`,
|
|
770
|
+
voices: 'parley voices\n The narration personas, the tiers and their prices.',
|
|
771
|
+
studio: 'parley studio character add|cast|list · shot add|list · render <shotId> [--wait] · assemble · reels\n The film pipeline without JSON: cast characters (identity lock), write shots ("lens look, move" camera), render, assemble a reel. Every subcommand takes --project proj_… and --json.',
|
|
772
|
+
login: 'parley login [--no-browser] [--backend URL]\n Sign in once in your browser. --no-browser prints the link instead of opening it (cloud sandboxes, SSH). Stores a session token under ~/.parley (or $PARLEY_HOME).',
|
|
773
|
+
image: `parley image "<prompt>" [--ratio ${Object.keys(RATIO_TO_SIZE).join('|')}] [--model gpt-image-2|gemini-3-pro-image-preview] [--quality low|medium|high] [--image-size 1K|2K|4K] [--ref <file|url|@last>]... [--out file]\n Generate an image. --ratio sizes it for the destination; --ref passes reference images (identity / style locks).`,
|
|
774
|
+
video: `parley video "<motion prompt>" --image <file|url|@last> [--look ${LOOKS.join('|')}] [--move <slug>]... [--duration 5] [--ratio 16:9|9:16|1:1] [--out clip.mp4]\n Animate a still (@last = the image you just made) or --look photoreal for text-to-video. --move adds named camera moves; see \`parley camera-moves\`.`,
|
|
421
775
|
thumbnail: 'parley thumbnail "<video title>" [--variants 1-3] [--overlay "2-4 WORDS"] [--accent WORD] [--person true|false] [--out dir]',
|
|
422
776
|
'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
777
|
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.`,
|
|
@@ -444,6 +798,20 @@ async function main(argv) {
|
|
|
444
798
|
case 'login':
|
|
445
799
|
await cmdLogin(p);
|
|
446
800
|
break;
|
|
801
|
+
case 'doctor':
|
|
802
|
+
case 'status':
|
|
803
|
+
await cmdDoctor(p);
|
|
804
|
+
break;
|
|
805
|
+
case 'voice':
|
|
806
|
+
case 'narrate':
|
|
807
|
+
await cmdVoice(p);
|
|
808
|
+
break;
|
|
809
|
+
case 'voices':
|
|
810
|
+
await cmdVoices();
|
|
811
|
+
break;
|
|
812
|
+
case 'studio':
|
|
813
|
+
await cmdStudio(p);
|
|
814
|
+
break;
|
|
447
815
|
case 'logout':
|
|
448
816
|
await cmdLogout();
|
|
449
817
|
break;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parley-live",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Generate images, video clips, YouTube thumbnails
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Generate images, video clips with named camera moves, narration, YouTube thumbnails, product ads, complete videos and Studio films from the terminal with Parley. Built for coding agents: every command has a --json mode.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|