parley-live 0.2.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.
Files changed (3) hide show
  1. package/README.md +17 -1
  2. package/dist/index.js +286 -11
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -16,7 +16,9 @@ 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 doctor` | Node, server, session, credits, live engines, the `@last` outputs, and the fix for anything missing. Run it first; `--json` for agents. |
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_…`. |
20
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). |
21
23
  | `parley account` | Credit balance. |
22
24
  | `parley engines` | Which video engines this server can run right now, with credit rates. |
@@ -50,6 +52,20 @@ The CLI remembers the last image and the last clip it made (`~/.parley/last.json
50
52
  parley image "matte black espresso machine on marble, morning light" --ratio 9:16 --out hero.png
51
53
  parley video "steam rises, a hand lifts the cup" --image @last --move dolly-in --ratio 9:16
52
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."
53
69
  ```
54
70
 
55
71
  ## For agents
package/dist/index.js CHANGED
@@ -17,7 +17,17 @@
17
17
  import { readFileSync, existsSync, mkdirSync } from 'node:fs';
18
18
  import path from 'node:path';
19
19
  import { api, authWebUrl, backendUrl, clearCredentials, CliError, downloadTo, fileToDataUri, isUrl, loadCredentials, loadLast, openInBrowser, rememberLast, resolveRef, saveCredentials, sleep, } from './client.js';
20
- const VERSION = '0.2.0';
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];
@@ -362,12 +372,16 @@ async function cmdYoutube(p) {
362
372
  const tier = str(p.flags.tier, 'standard');
363
373
  if (!YT_TIERS.includes(tier))
364
374
  throw new CliError(`--tier must be one of ${YT_TIERS.join(', ')}`);
365
- const narration = str(p.flags.narration);
375
+ const narrationFlag = str(p.flags.narration);
376
+ const narration = narrationFlag ? resolveRef(narrationFlag, 'audio') : undefined;
366
377
  const music = str(p.flags.music);
367
378
  const project = str(p.flags.project);
368
379
  const silent = p.flags.silent === true;
369
380
  if (!narration && !silent) {
370
- 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.`);
371
385
  }
372
386
  const body = {
373
387
  beats: beats.map((imageUrl) => ({ imageUrl })),
@@ -406,6 +420,222 @@ async function cmdYoutube(p) {
406
420
  const bytes = await downloadTo(videoUrl, out);
407
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}`);
408
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
+ }
409
639
  /**
410
640
  * parley api <GET|POST|PATCH|PUT|DELETE> <path> [--data '<json>' | --data @file.json]
411
641
  * Signed raw call to any Parley endpoint (the Studio's projects, characters,
@@ -442,13 +672,45 @@ async function cmdApi(p) {
442
672
  * the last outputs available as @last. Exit 0 always; `ready` says whether a
443
673
  * generation would succeed right now.
444
674
  */
445
- async function cmdDoctor() {
675
+ async function cmdDoctor(p) {
446
676
  const report = { cli: VERSION, node: process.version, backendUrl: backendUrl() };
447
677
  const fixes = [];
448
678
  const major = Number(process.version.replace(/^v/, '').split('.')[0]);
449
679
  report.nodeOk = major >= 20;
450
680
  if (!report.nodeOk)
451
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 */ }
452
714
  try {
453
715
  const e = await api('/video/engines', { auth: false });
454
716
  report.reachable = true;
@@ -481,29 +743,32 @@ async function cmdDoctor() {
481
743
  }
482
744
  }
483
745
  const last = loadLast();
484
- report.last = { image: last.image?.url ?? last.image?.file ?? null, video: last.video?.url ?? last.video?.file ?? null };
485
- report.ready = report.nodeOk === true && report.reachable === true && report.signedIn === true && (typeof report.credits !== 'number' || report.credits > 0);
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);
486
748
  report.fixes = fixes;
487
749
  if (JSON_MODE) {
488
750
  console.log(JSON.stringify({ ok: true, ...report }));
489
751
  return;
490
752
  }
491
- console.log(`parley ${VERSION} on Node ${process.version}${report.nodeOk ? '' : ' (too old)'}`);
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'})` : ''}`);
492
754
  console.log(`server ${report.backendUrl} ${report.reachable ? 'reachable' : 'UNREACHABLE'}`);
493
755
  console.log(`session ${report.signedIn ? `signed in as ${report.email || 'user'}, ${report.credits} credits` : 'not signed in'}`);
494
756
  if (report.engines) {
495
757
  const live = Object.entries(report.engines).filter(([, v]) => v.ready).map(([k]) => k);
496
758
  console.log(`engines ${live.join(', ')} (${report.cameraMoves} camera moves)`);
497
759
  }
498
- if (report.last && (report.last.image || report.last.video))
499
- console.log(`last image=${report.last.image || '-'} video=${report.last.video || '-'}`);
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 || '-'}`);
500
762
  console.log(`ready ${report.ready ? 'yes' : 'no'}`);
501
763
  for (const f of fixes)
502
764
  console.log(` fix: ${f}`);
503
765
  }
504
766
  function help(topic) {
505
767
  const lines = {
506
- doctor: 'parley doctor [--json]\n Node, server, session, credits, live engines and the @last outputs, with the fix for anything missing. Run this first.',
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.',
507
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).',
508
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).`,
509
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\`.`,
@@ -535,7 +800,17 @@ async function main(argv) {
535
800
  break;
536
801
  case 'doctor':
537
802
  case 'status':
538
- await cmdDoctor();
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);
539
814
  break;
540
815
  case 'logout':
541
816
  await cmdLogout();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "parley-live",
3
- "version": "0.2.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.",
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": {