parley-live 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/client.js +4 -3
- package/dist/index.js +245 -21
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -18,7 +18,8 @@ parley ugc-ad --product product.jpg --name "Cold Brew Kit" --actor jordan --mark
|
|
|
18
18
|
|---|---|
|
|
19
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
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_…`. |
|
|
21
|
+
| `parley studio …` | The film pipeline without JSON: `character add\|cast\|list`, `location add\|cast\|list`, `shot add\|list`, `render <shotId> [--wait]`, `assemble`, `reels`. Every subcommand takes `--project proj_…`. |
|
|
22
|
+
| `parley deploy <dir> --name <slug>` | Ship a built static site (index.html at the root) to a live URL. Reuse `--project proj_…` to redeploy the same site at the same URL. Free. |
|
|
22
23
|
| `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). |
|
|
23
24
|
| `parley account` | Credit balance. |
|
|
24
25
|
| `parley engines` | Which video engines this server can run right now, with credit rates. |
|
|
@@ -27,7 +28,7 @@ parley ugc-ad --product product.jpg --name "Cold Brew Kit" --actor jordan --mark
|
|
|
27
28
|
| `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`. |
|
|
28
29
|
| `parley thumbnail "<title>"` | 1-3 YouTube thumbnail variants. `--overlay`, `--accent`, `--person`, `--out <dir>`. |
|
|
29
30
|
| `parley ugc-ad` | Product photo → spokesperson ad with a marketplace CTA end-card. `--product`, `--name`, `--actor` or `--face`, `--angle`, `--marketplace`, `--points`, `--seconds`, `--url`, `--out`. |
|
|
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`. |
|
|
31
|
+
| `parley youtube` | A complete faceless video from ordered beat stills: animated per beat (one 5 s clip each, so bring about one beat per 5 s of narration; a narration the beats cannot carry is refused before anything is charged), cut to `--narration` with crossfades, `--music` ducked, `--captions` burned in. `--beat` (repeatable, hosted https URLs), `--tier fast\|standard\|cinematic`, `--silent`, `--project`, `--out`. |
|
|
31
32
|
| `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. |
|
|
32
33
|
| `parley whoami`, `parley logout` | Session management. |
|
|
33
34
|
|
package/dist/client.js
CHANGED
|
@@ -188,7 +188,7 @@ export function rememberLast(kind, entry) {
|
|
|
188
188
|
try {
|
|
189
189
|
mkdirSync(homeDir(), { recursive: true });
|
|
190
190
|
const cur = loadLast();
|
|
191
|
-
cur[kind] = { file: path.resolve(entry.file), url: entry.url, at: new Date().toISOString() };
|
|
191
|
+
cur[kind] = { file: entry.file ? path.resolve(entry.file) : null, url: entry.url, at: new Date().toISOString() };
|
|
192
192
|
writeFileSync(lastPath(), JSON.stringify(cur, null, 2) + '\n');
|
|
193
193
|
}
|
|
194
194
|
catch { /* best effort: chaining is a convenience, never a failure */ }
|
|
@@ -198,9 +198,10 @@ export function resolveRef(ref, kind) {
|
|
|
198
198
|
if (ref !== '@last' && ref !== `@last-${kind}`)
|
|
199
199
|
return ref;
|
|
200
200
|
const last = loadLast()[kind];
|
|
201
|
-
|
|
201
|
+
const ref2 = last?.url || last?.file;
|
|
202
|
+
if (!ref2)
|
|
202
203
|
throw new CliError(`No previous ${kind} on this machine yet. Make one first (parley ${kind} …), then pass @last.`);
|
|
203
|
-
return
|
|
204
|
+
return ref2;
|
|
204
205
|
}
|
|
205
206
|
export function sleep(ms) {
|
|
206
207
|
return new Promise((r) => setTimeout(r, ms));
|
package/dist/index.js
CHANGED
|
@@ -14,10 +14,22 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Run `parley help <command>` for the flags of one command.
|
|
16
16
|
*/
|
|
17
|
-
import { readFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
17
|
+
import { readFileSync, existsSync, mkdirSync, readdirSync, statSync } 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.
|
|
20
|
+
const VERSION = '0.4.1';
|
|
21
|
+
/** Server advice written for Parley's in-app agent names its tools; a CLI user
|
|
22
|
+
* has commands instead, so the same advice is re-spoken in CLI terms. */
|
|
23
|
+
function humanizeServerMessage(message) {
|
|
24
|
+
return message
|
|
25
|
+
.replace(/\bvia generate_video\b/g, 'with parley video')
|
|
26
|
+
.replace(/\bcall assemble_video again\b/g, 'run parley youtube again')
|
|
27
|
+
.replace(/\bgenerate_video\b/g, 'parley video')
|
|
28
|
+
.replace(/\bassemble_video\b/g, 'parley youtube')
|
|
29
|
+
.replace(/\bproduce_full_video\b/g, 'parley youtube')
|
|
30
|
+
.replace(/\bgenerate_voiceover\b/g, 'parley voice')
|
|
31
|
+
.replace(/\bgenerate_image\b/g, 'parley image');
|
|
32
|
+
}
|
|
21
33
|
/** Semver-ish compare: 1 if a > b, -1 if a < b, 0 if equal (numeric parts only). */
|
|
22
34
|
function compareVersions(a, b) {
|
|
23
35
|
const pa = a.replace(/^v/, '').split('.').map((x) => parseInt(x, 10) || 0);
|
|
@@ -347,7 +359,9 @@ async function cmdUgcAd(p) {
|
|
|
347
359
|
if (job.status === 'failed')
|
|
348
360
|
throw new CliError(job.error || 'Ad generation failed.');
|
|
349
361
|
}
|
|
350
|
-
|
|
362
|
+
// The ad orchestrator reports the finished file as outputUrl; the other
|
|
363
|
+
// shapes cover older jobs and the generic video result.
|
|
364
|
+
const videoUrl = job?.result?.outputUrl || job?.result?.videoUrl || job?.result?.finalVideoUrl || job?.result?.url;
|
|
351
365
|
const out = outPathFor(p, `parley-ad-${jobId}.mp4`);
|
|
352
366
|
const bytes = videoUrl ? await downloadTo(videoUrl, out) : 0;
|
|
353
367
|
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)}`);
|
|
@@ -364,7 +378,8 @@ const YT_TIERS = ['fast', 'standard', 'cinematic'];
|
|
|
364
378
|
async function cmdYoutube(p) {
|
|
365
379
|
const beats = list(p.flags.beat);
|
|
366
380
|
if (beats.length === 0) {
|
|
367
|
-
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
|
|
381
|
+
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.
|
|
382
|
+
Every beat becomes one 5 s clip and the cut refuses more than 15% slow motion: bring about one beat per 5 s of narration.`);
|
|
368
383
|
}
|
|
369
384
|
const bad = beats.find((b) => !isUrl(b));
|
|
370
385
|
if (bad)
|
|
@@ -430,6 +445,53 @@ const VOICE_TIERS = ['natural', 'fish', 'studio'];
|
|
|
430
445
|
* synthetic fallback voice silently (voiceWarning), and --no-fallback refuses
|
|
431
446
|
* it outright. The result is @last for --narration.
|
|
432
447
|
*/
|
|
448
|
+
const VOICE_MAX_CHARS = 5000;
|
|
449
|
+
const VOICE_SPLIT_TARGET = 4500;
|
|
450
|
+
/**
|
|
451
|
+
* Split a long narration into sections of at most `max` characters, cutting
|
|
452
|
+
* at paragraph breaks first, then sentence ends, then (last resort) spaces,
|
|
453
|
+
* so a section never ends mid-word and rarely mid-sentence.
|
|
454
|
+
*/
|
|
455
|
+
export function splitNarration(text, max = VOICE_SPLIT_TARGET) {
|
|
456
|
+
const clean = text.replace(/\r\n/g, '\n').trim();
|
|
457
|
+
if (clean.length <= max)
|
|
458
|
+
return [clean];
|
|
459
|
+
const sections = [];
|
|
460
|
+
let current = '';
|
|
461
|
+
const push = () => { if (current.trim())
|
|
462
|
+
sections.push(current.trim()); current = ''; };
|
|
463
|
+
const units = clean.split(/\n\s*\n/).flatMap((para) => {
|
|
464
|
+
if (para.length <= max)
|
|
465
|
+
return [para];
|
|
466
|
+
const sentences = para.match(/[^.!?]+[.!?]+["')\]]*\s*|[^.!?]+$/g) || [para];
|
|
467
|
+
return sentences.flatMap((s) => {
|
|
468
|
+
if (s.length <= max)
|
|
469
|
+
return [s];
|
|
470
|
+
const words = s.split(/\s+/);
|
|
471
|
+
const chunks = [];
|
|
472
|
+
let w = '';
|
|
473
|
+
for (const word of words) {
|
|
474
|
+
if ((w + ' ' + word).trim().length > max) {
|
|
475
|
+
chunks.push(w.trim());
|
|
476
|
+
w = word;
|
|
477
|
+
}
|
|
478
|
+
else
|
|
479
|
+
w = (w + ' ' + word).trim();
|
|
480
|
+
}
|
|
481
|
+
if (w)
|
|
482
|
+
chunks.push(w);
|
|
483
|
+
return chunks;
|
|
484
|
+
});
|
|
485
|
+
});
|
|
486
|
+
for (const unit of units) {
|
|
487
|
+
const sep = current && /\n\s*\n/.test(clean.slice(clean.indexOf(unit) - 3, clean.indexOf(unit))) ? '\n\n' : ' ';
|
|
488
|
+
if ((current + sep + unit).trim().length > max)
|
|
489
|
+
push();
|
|
490
|
+
current = current ? `${current}${sep}${unit}` : unit;
|
|
491
|
+
}
|
|
492
|
+
push();
|
|
493
|
+
return sections;
|
|
494
|
+
}
|
|
433
495
|
async function cmdVoice(p) {
|
|
434
496
|
const file = str(p.flags.file);
|
|
435
497
|
let text = p.positionals.join(' ').trim();
|
|
@@ -439,24 +501,43 @@ async function cmdVoice(p) {
|
|
|
439
501
|
text = readFileSync(file, 'utf8').trim();
|
|
440
502
|
}
|
|
441
503
|
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.`);
|
|
504
|
+
throw new CliError(`Usage: parley voice "<text>" | --file script.txt [--voice ${VOICES.join('|')}] [--quality ${VOICE_TIERS.join('|')}] [--split] [--no-fallback] [--out narration.wav]`);
|
|
445
505
|
const voice = str(p.flags.voice);
|
|
446
506
|
if (voice && !VOICES.includes(voice))
|
|
447
507
|
throw new CliError(`--voice must be one of ${VOICES.join(', ')}`);
|
|
448
508
|
const quality = str(p.flags.quality, 'natural');
|
|
449
509
|
if (!VOICE_TIERS.includes(quality))
|
|
450
510
|
throw new CliError(`--quality must be one of ${VOICE_TIERS.join(', ')}`);
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
|
|
511
|
+
const split = p.flags.split === true || text.length > VOICE_MAX_CHARS;
|
|
512
|
+
if (text.length > VOICE_MAX_CHARS && p.flags.split !== true) {
|
|
513
|
+
note(`The text is ${text.length} characters (over one request's ${VOICE_MAX_CHARS}); splitting into sections and joining them into one narration.`);
|
|
514
|
+
}
|
|
515
|
+
const sections = split ? splitNarration(text) : [text];
|
|
516
|
+
const common = { quality, ...(voice ? { voice } : {}), ...(p.flags['no-fallback'] === true ? { allowFallback: false } : {}) };
|
|
517
|
+
const parts = [];
|
|
518
|
+
for (let i = 0; i < sections.length; i++) {
|
|
519
|
+
note(sections.length > 1
|
|
520
|
+
? `Narrating section ${i + 1}/${sections.length} (${sections[i].length} characters) with the ${quality} voice${voice ? ` (${voice})` : ''}…`
|
|
521
|
+
: `Narrating ${sections[i].length} characters with the ${quality} voice${voice ? ` (${voice})` : ''}…`);
|
|
522
|
+
parts.push(await api('/api/voice/generate', { body: { text: sections[i], ...common } }));
|
|
523
|
+
}
|
|
524
|
+
let finalUrl = parts[0].audioUrl;
|
|
525
|
+
let durationSeconds = parts[0].durationSeconds;
|
|
526
|
+
if (parts.length > 1) {
|
|
527
|
+
note(`Joining ${parts.length} sections into one narration…`);
|
|
528
|
+
const j = await api('/api/voice/join', { body: { urls: parts.map((x) => x.audioUrl) } });
|
|
529
|
+
finalUrl = j.audioUrl;
|
|
530
|
+
durationSeconds = j.durationSeconds;
|
|
531
|
+
}
|
|
454
532
|
const out = outPathFor(p, `parley-voice-${Date.now()}.wav`);
|
|
455
|
-
const bytes = await downloadTo(
|
|
456
|
-
rememberLast('audio', { file: out, url:
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
533
|
+
const bytes = await downloadTo(finalUrl, out);
|
|
534
|
+
rememberLast('audio', { file: out, url: finalUrl });
|
|
535
|
+
const warnings = parts.map((x) => x.voiceWarning).filter((w) => !!w);
|
|
536
|
+
if (warnings.length)
|
|
537
|
+
console.error(`warning: ${warnings[0]}${warnings.length > 1 ? ` (${warnings.length} sections)` : ''}`);
|
|
538
|
+
const creditsCharged = parts.reduce((s, x) => s + (x.creditsCharged || 0), 0);
|
|
539
|
+
const engines = [...new Set(parts.map((x) => x.engine))].join('+');
|
|
540
|
+
emit({ ok: true, file: out, bytes, audioUrl: finalUrl, durationSeconds, engine: engines, voice: parts[0].voice, creditsCharged, sections: parts.length, sectionUrls: parts.map((x) => x.audioUrl), voiceWarning: warnings[0] ?? null }, `Saved ${out} (${durationSeconds ?? '?'}s, ${engines}, ${creditsCharged} credits${parts.length > 1 ? `, ${parts.length} sections joined` : ''})\nHosted: ${finalUrl}`);
|
|
460
541
|
}
|
|
461
542
|
async function cmdVoices() {
|
|
462
543
|
const v = await api('/api/voice/voices', { auth: false });
|
|
@@ -536,6 +617,32 @@ async function cmdStudio(p) {
|
|
|
536
617
|
const items = Array.isArray(r) ? r : r.characters || [];
|
|
537
618
|
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
619
|
}
|
|
620
|
+
if (group === 'location' && action === 'add') {
|
|
621
|
+
const P = needProject();
|
|
622
|
+
const name = str(p.flags.name);
|
|
623
|
+
const description = str(p.flags.description);
|
|
624
|
+
if (!name || !description)
|
|
625
|
+
throw new CliError('studio location add needs --name and --description (the place, time of day, light, the props that identify it).');
|
|
626
|
+
const l = await api(`/api/studio/projects/${P}/locations`, { body: { name, description } });
|
|
627
|
+
return out({ ok: true, location: l }, () => `Location ${l.name} created: ${l.id}\nNext: parley studio location cast ${l.id}`);
|
|
628
|
+
}
|
|
629
|
+
if (group === 'location' && action === 'cast') {
|
|
630
|
+
if (!target)
|
|
631
|
+
throw new CliError('Usage: parley studio location cast <locationId> [--count 1] [--force]');
|
|
632
|
+
const body = { count: num(p.flags.count, 1) };
|
|
633
|
+
if (p.flags.force === true)
|
|
634
|
+
body.force = true;
|
|
635
|
+
note('Casting the location (renders its reference image)…');
|
|
636
|
+
const r = await api(`/api/studio/locations/${target}/cast`, { body });
|
|
637
|
+
const refs = r.location?.reference_image_paths || r.reference_image_paths || [];
|
|
638
|
+
return out({ ok: true, ...r }, () => `${r.skipped_reason ? r.skipped_reason + '\n' : ''}Reference images: ${refs.length ? refs.join(', ') : '(see JSON)'}`);
|
|
639
|
+
}
|
|
640
|
+
if (group === 'location' && action === 'list') {
|
|
641
|
+
const P = needProject();
|
|
642
|
+
const r = await api(`/api/studio/projects/${P}/locations`);
|
|
643
|
+
const items = Array.isArray(r) ? r : r.locations || [];
|
|
644
|
+
return out({ ok: true, locations: items }, () => items.map((l) => `${l.id} ${l.name} refs=${(l.reference_image_paths || []).length}`).join('\n') || '(no locations yet)');
|
|
645
|
+
}
|
|
539
646
|
if (group === 'shot' && action === 'add') {
|
|
540
647
|
const P = needProject();
|
|
541
648
|
const framing = str(p.flags.framing);
|
|
@@ -624,8 +731,9 @@ async function cmdStudio(p) {
|
|
|
624
731
|
body.narrationUrl = resolveRef(narration, 'audio');
|
|
625
732
|
note('Assembling the reel (score generated and ducked automatically)…');
|
|
626
733
|
const reel = await api(`/api/studio/projects/${P}/assemble`, { body });
|
|
734
|
+
// The reel stays hosted (nothing is downloaded here), so @last carries the URL only.
|
|
627
735
|
if (reel.public_url)
|
|
628
|
-
rememberLast('video', { file:
|
|
736
|
+
rememberLast('video', { file: null, url: reel.public_url });
|
|
629
737
|
return out({ ok: true, reel }, () => `Reel ${reel.id || ''}: ${reel.public_url || reel.video_path || '(see JSON)'}`);
|
|
630
738
|
}
|
|
631
739
|
if (group === 'reels') {
|
|
@@ -634,7 +742,118 @@ async function cmdStudio(p) {
|
|
|
634
742
|
const items = r.reels || (Array.isArray(r) ? r : []);
|
|
635
743
|
return out({ ok: true, reels: items }, () => items.map((x) => `${x.id} ${x.public_url || x.video_path}`).join('\n') || '(no reels yet)');
|
|
636
744
|
}
|
|
637
|
-
throw new CliError('Usage: parley studio <character add|cast|list | shot add|list | render <shotId> | assemble | reels> … (see `parley help studio`)');
|
|
745
|
+
throw new CliError('Usage: parley studio <character add|cast|list | location add|cast|list | shot add|list | render <shotId> | assemble | reels> … (see `parley help studio`)');
|
|
746
|
+
}
|
|
747
|
+
const DEPLOY_MAX_FILES = 600;
|
|
748
|
+
const DEPLOY_MAX_TOTAL_BYTES = 40 * 1024 * 1024;
|
|
749
|
+
const DEPLOY_SKIP_DIRS = new Set(['node_modules', '.git', '.svn', '.hg', '.parley', '.cache', '.next', '.nuxt', '.turbo', 'coverage']);
|
|
750
|
+
const TEXT_EXT = new Set(['html', 'htm', 'css', 'js', 'mjs', 'cjs', 'map', 'json', 'txt', 'md', 'svg', 'xml', 'webmanifest', 'txt', 'csv', 'ics', 'rss', 'atom', 'yml', 'yaml', 'toml', 'wasm.txt']);
|
|
751
|
+
const MIME = {
|
|
752
|
+
html: 'text/html', htm: 'text/html', css: 'text/css', js: 'text/javascript', mjs: 'text/javascript', cjs: 'text/javascript', map: 'application/json',
|
|
753
|
+
json: 'application/json', txt: 'text/plain', md: 'text/markdown', svg: 'image/svg+xml', xml: 'application/xml', webmanifest: 'application/manifest+json',
|
|
754
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', avif: 'image/avif', ico: 'image/x-icon',
|
|
755
|
+
mp4: 'video/mp4', webm: 'video/webm', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg',
|
|
756
|
+
woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf', pdf: 'application/pdf', wasm: 'application/wasm', glb: 'model/gltf-binary',
|
|
757
|
+
};
|
|
758
|
+
function walkDir(root, rel = '') {
|
|
759
|
+
const out = [];
|
|
760
|
+
for (const entry of readdirSync(path.join(root, rel), { withFileTypes: true })) {
|
|
761
|
+
if (entry.name.startsWith('.DS_Store'))
|
|
762
|
+
continue;
|
|
763
|
+
const r = rel ? `${rel}/${entry.name}` : entry.name;
|
|
764
|
+
if (entry.isDirectory()) {
|
|
765
|
+
if (!DEPLOY_SKIP_DIRS.has(entry.name))
|
|
766
|
+
out.push(...walkDir(root, r));
|
|
767
|
+
}
|
|
768
|
+
else if (entry.isFile())
|
|
769
|
+
out.push(r);
|
|
770
|
+
}
|
|
771
|
+
return out;
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* parley deploy <dir> --name <project> [--project proj_…] [--summary "…"]
|
|
775
|
+
* Ships a built static site (the folder with index.html at its root) to
|
|
776
|
+
* Parley's Cloudflare Pages deploy: every file is written into a fresh
|
|
777
|
+
* workspace (text as text, binaries base64), then deployed. Prints the live
|
|
778
|
+
* URL. Static only: the server's deploy is a direct upload with no build
|
|
779
|
+
* step, so run your build first and point this at its output.
|
|
780
|
+
*/
|
|
781
|
+
async function cmdDeploy(p) {
|
|
782
|
+
const dir = p.positionals[0];
|
|
783
|
+
const name = str(p.flags.name);
|
|
784
|
+
if (!dir || !name)
|
|
785
|
+
throw new CliError('Usage: parley deploy <built-site-dir> --name <project-name> [--project proj_…] [--summary "…"]\n The dir must contain index.html at its root (run your build first).');
|
|
786
|
+
const root = path.resolve(dir);
|
|
787
|
+
if (!existsSync(root) || !statSync(root).isDirectory())
|
|
788
|
+
throw new CliError(`Not a directory: ${dir}`);
|
|
789
|
+
if (!existsSync(path.join(root, 'index.html')))
|
|
790
|
+
throw new CliError(`No index.html at the root of ${dir}. Point deploy at the BUILT output (dist/, build/, out/, _site/), not the source.`);
|
|
791
|
+
if (!/[a-z0-9]/i.test(name))
|
|
792
|
+
throw new CliError('--name must contain at least one letter or digit.');
|
|
793
|
+
const files = walkDir(root);
|
|
794
|
+
if (files.length === 0)
|
|
795
|
+
throw new CliError('The directory is empty.');
|
|
796
|
+
if (files.length > DEPLOY_MAX_FILES)
|
|
797
|
+
throw new CliError(`${files.length} files is over the ${DEPLOY_MAX_FILES}-file limit; deploy the built output only.`);
|
|
798
|
+
let total = 0;
|
|
799
|
+
for (const f of files)
|
|
800
|
+
total += statSync(path.join(root, f)).size;
|
|
801
|
+
if (total > DEPLOY_MAX_TOTAL_BYTES)
|
|
802
|
+
throw new CliError(`${Math.round(total / 1048576)} MB is over the ${DEPLOY_MAX_TOTAL_BYTES / 1048576} MB limit. Move large media to Parley-hosted URLs (parley image / video print them) and reference them instead of shipping them.`);
|
|
803
|
+
const project = str(p.flags.project) || `proj_site-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
804
|
+
if (!/^proj_[A-Za-z0-9_-]{6,}$/.test(project))
|
|
805
|
+
throw new CliError('--project must look like proj_ followed by at least six letters, digits, _ or -.');
|
|
806
|
+
// Register the workspace as one of the account's projects first: file writes
|
|
807
|
+
// accept a brand-new id, but the deploy lane proves ownership against the
|
|
808
|
+
// projects table and refuses a workspace that was never registered.
|
|
809
|
+
note(`Registering project ${project}…`);
|
|
810
|
+
await api(`/v1/projects/${project}`, { method: 'PUT', body: { name, type: 'code' } });
|
|
811
|
+
note(`Uploading ${files.length} files (${Math.round(total / 1024)} KB) into workspace ${project}…`);
|
|
812
|
+
let n = 0;
|
|
813
|
+
for (const f of files) {
|
|
814
|
+
const abs = path.join(root, f);
|
|
815
|
+
const ext = path.extname(f).slice(1).toLowerCase();
|
|
816
|
+
const mimeType = MIME[ext] || (TEXT_EXT.has(ext) ? 'text/plain' : 'application/octet-stream');
|
|
817
|
+
const isText = TEXT_EXT.has(ext) || mimeType.startsWith('text/');
|
|
818
|
+
const buf = readFileSync(abs);
|
|
819
|
+
await api(`/api/workspace/${project}/files`, {
|
|
820
|
+
body: { path: f.replace(/\\/g, '/'), content: isText ? buf.toString('utf8') : buf.toString('base64'), mimeType, isBase64: !isText, allowFullRewrite: true },
|
|
821
|
+
});
|
|
822
|
+
n++;
|
|
823
|
+
if (n % 25 === 0)
|
|
824
|
+
note(` ${n}/${files.length}`);
|
|
825
|
+
}
|
|
826
|
+
note('Deploying…');
|
|
827
|
+
const summary = str(p.flags.summary);
|
|
828
|
+
const d = await api('/api/deploy-project', { body: { workspaceId: project, projectName: name, ...(summary ? { summary } : {}) } });
|
|
829
|
+
let url = d.url;
|
|
830
|
+
let deploymentId = d.deploymentId;
|
|
831
|
+
if (!url && d.jobId) {
|
|
832
|
+
// Asynchronous variant: poll the job until it reports a URL.
|
|
833
|
+
const deadline = Date.now() + 20 * 60 * 1000;
|
|
834
|
+
const pollMs = Number(process.env.PARLEY_POLL_MS) || 4000;
|
|
835
|
+
let last = '';
|
|
836
|
+
while (Date.now() < deadline) {
|
|
837
|
+
await sleep(pollMs);
|
|
838
|
+
const j = await api(`/api/deploy-project/jobs/${d.jobId}`);
|
|
839
|
+
const stage = j.progress?.stage || j.stage || j.status;
|
|
840
|
+
if (stage !== last) {
|
|
841
|
+
note(` ${stage}${j.progress?.message ? ` – ${j.progress.message}` : ''}`);
|
|
842
|
+
last = stage;
|
|
843
|
+
}
|
|
844
|
+
if (j.status === 'failed' || j.ok === false)
|
|
845
|
+
throw new CliError(j.error || j.message || 'Deploy failed.');
|
|
846
|
+
url = j.url || j.result?.url;
|
|
847
|
+
deploymentId = j.deploymentId || j.result?.deploymentId;
|
|
848
|
+
if (url && (j.status === 'succeeded' || j.status === 'done' || j.status === 'complete' || j.stage === 'done'))
|
|
849
|
+
break;
|
|
850
|
+
if (url && !j.status)
|
|
851
|
+
break;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (!url)
|
|
855
|
+
throw new CliError(`Deploy did not return a URL: ${JSON.stringify(d).slice(0, 200)}`);
|
|
856
|
+
emit({ ok: true, url, projectName: name, workspaceId: project, deploymentId: deploymentId ?? null, files: files.length, bytes: total }, `Live: ${url}\nWorkspace ${project} (${files.length} files). Redeploy the same site with --project ${project}.`);
|
|
638
857
|
}
|
|
639
858
|
/**
|
|
640
859
|
* parley api <GET|POST|PATCH|PUT|DELETE> <path> [--data '<json>' | --data @file.json]
|
|
@@ -766,15 +985,16 @@ async function cmdDoctor(p) {
|
|
|
766
985
|
function help(topic) {
|
|
767
986
|
const lines = {
|
|
768
987
|
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.`,
|
|
988
|
+
voice: `parley voice "<text>" | --file script.txt [--voice ${VOICES.join('|')}] [--quality ${VOICE_TIERS.join('|')}] [--split] [--no-fallback] [--out narration.wav]\n Narration as a hosted WAV (natural voice 1 credit per 1,000 characters; studio 1 per 300). Text over 5,000 characters is split at paragraph and sentence breaks and joined back into one narration. Becomes @last for --narration.`,
|
|
770
989
|
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
|
|
990
|
+
studio: 'parley studio character add|cast|list · location add|cast|list · shot add|list · render <shotId> [--wait] · assemble · reels\n The film pipeline without JSON: cast characters and locations (identity locks), write shots ("lens look, move" camera), render, assemble a reel. Every subcommand takes --project proj_… and --json.',
|
|
772
991
|
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
992
|
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
993
|
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\`.`,
|
|
775
994
|
thumbnail: 'parley thumbnail "<video title>" [--variants 1-3] [--overlay "2-4 WORDS"] [--accent WORD] [--person true|false] [--out dir]',
|
|
776
995
|
'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]`,
|
|
777
996
|
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.`,
|
|
997
|
+
deploy: 'parley deploy <built-site-dir> --name <project-name> [--project proj_…] [--summary "…"]\n Ship a built static site (index.html at the root) to a live URL on Parley\'s hosting. Reuse --project to redeploy the same site.',
|
|
778
998
|
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).',
|
|
779
999
|
engines: 'parley engines\n Which video engines this server can run right now and their credit rates.',
|
|
780
1000
|
'camera-moves': 'parley camera-moves\n The named camera-move presets accepted by `parley video --move`.',
|
|
@@ -849,6 +1069,9 @@ async function main(argv) {
|
|
|
849
1069
|
case 'api':
|
|
850
1070
|
await cmdApi(p);
|
|
851
1071
|
break;
|
|
1072
|
+
case 'deploy':
|
|
1073
|
+
await cmdDeploy(p);
|
|
1074
|
+
break;
|
|
852
1075
|
case 'help':
|
|
853
1076
|
case '--help':
|
|
854
1077
|
case '-h':
|
|
@@ -861,10 +1084,11 @@ async function main(argv) {
|
|
|
861
1084
|
}
|
|
862
1085
|
catch (e) {
|
|
863
1086
|
const code = e instanceof CliError ? e.exitCode : 1;
|
|
1087
|
+
const message = humanizeServerMessage(e?.message || String(e));
|
|
864
1088
|
if (JSON_MODE)
|
|
865
|
-
console.log(JSON.stringify({ ok: false, error:
|
|
1089
|
+
console.log(JSON.stringify({ ok: false, error: message, code }));
|
|
866
1090
|
else
|
|
867
|
-
console.error(`error: ${
|
|
1091
|
+
console.error(`error: ${message}`);
|
|
868
1092
|
return code;
|
|
869
1093
|
}
|
|
870
1094
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parley-live",
|
|
3
|
-
"version": "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.",
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "Generate images, video clips with named camera moves, narration, YouTube thumbnails, product ads, complete videos and Studio films, and deploy static sites, 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": {
|