parley-live 0.5.0 → 0.6.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 +15 -1
- package/dist/index.js +254 -4
- package/dist/lens.js +31 -1
- package/lens-builder/build.mjs +32 -5
- package/lens-builder/cli.mjs +21 -1
- package/lens-builder/lenses.mjs +113 -0
- package/lens-builder/lib/bridge.mjs +0 -0
- package/lens-builder/lib/handoff.mjs +88 -0
- package/lens-builder/lib/submission.mjs +246 -0
- package/lens-builder/publish.mjs +145 -0
- package/lens-builder/vendor/specs-publish/LICENSE +208 -0
- package/lens-builder/vendor/specs-publish/NOTICE +13 -0
- package/lens-builder/vendor/specs-publish/scripts/make-eec-script.py +85 -0
- package/lens-builder/vendor/specs-publish/scripts/package.ts +189 -0
- package/lens-builder/vendor/specs-publish/scripts/preflight.ts +852 -0
- package/lens-builder/vendor/specs-publish/scripts/set-lens-icon.ts +107 -0
- package/lens-builder/vendor/specs-publish/scripts/set-lens-name.ts +121 -0
- package/lens-builder/vendor/specs-publish/scripts/set-signing-key.ts +129 -0
- package/lens-builder/vendor/specs-publish/scripts/signing-key.js +290 -0
- package/lens-builder/verify.mjs +19 -3
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -18,7 +18,9 @@ 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
|
|
21
|
+
| `parley library …` | Everything this account has generated, newest first: `list [--kind image|video|audio] [--lane <lane>] [--favorite] [--limit] [--cursor]`, `summary`, `show <mediaId>`, `fav <mediaId> [--off]`, `rm <mediaId>`, `get <mediaId> [--out file]`. Every row carries the prompt, the model, what it cost and a public URL, so an asset that was already paid for goes straight into `--image`, `--ref` or `--beat`. Free. |
|
|
22
|
+
| `parley character …` | Named people who outlive one film: `list`, `add --name X --description "…" [--ref <file|url|@last>]`, `show <id>`, `refs <id> --ref …`, `cast <id> [--count 3] [--dry-run]`, `use <id> --project proj_…`, `save <studioCharacterId>`, `rm <id>`. Casting reference images costs 3 credits each (`--dry-run` quotes it and spends nothing); attaching your own and casting a saved character into a Studio project are free. |
|
|
23
|
+
| `parley studio …` | The film pipeline without JSON: `character add\|cast\|list`, `location add\|cast\|list`, `shot add\|list\|update\|reorder\|rm`, `render <shotId> [--wait]`, `assemble`, `reels`. Editing a written shot and reordering the timeline are free. Every subcommand takes `--project proj_…`. |
|
|
22
24
|
| `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. |
|
|
23
25
|
| `parley lens <status|connect|build|describe|record|metrics>` | A Snap Specs Lens from a work procedure, built and tested on this computer through Lens Studio: a hands-free step-by-step guide with pinch buttons and optional voice shortcuts. `status` says exactly what to set up; `build <form.json>` is free; `describe "..."` plans the steps from prose (about a cent, billed to your credits). Every build is checked in the Lens Studio preview; what only glasses can judge is marked, never passed. |
|
|
24
26
|
| `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). |
|
|
@@ -58,6 +60,14 @@ parley voice --file script.txt --voice documentary --out narration.wav
|
|
|
58
60
|
parley youtube --beat https://…/01.png --beat https://…/02.png --narration @last --captions
|
|
59
61
|
```
|
|
60
62
|
|
|
63
|
+
A later session picks the work back up from the account instead of paying for it twice:
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
parley library list --kind image --limit 10 --json # what this account already has, with what it cost
|
|
67
|
+
parley video "steam rises" --image https://media.parley.live/generated-media/…png # a url from that list
|
|
68
|
+
parley library get media_… --out hero.png # pull one down; it becomes @last
|
|
69
|
+
```
|
|
70
|
+
|
|
61
71
|
### A film from the terminal
|
|
62
72
|
|
|
63
73
|
```bash
|
|
@@ -67,7 +77,11 @@ parley studio shot add --project proj_letter-7f3k --order 0 --framing "Maya at a
|
|
|
67
77
|
--directing "She unfolds the letter and reads. Clip ends with her eyes on the page." \
|
|
68
78
|
--characters char_… --duration 12 --camera "arri-alexa-35, dolly-in"
|
|
69
79
|
parley studio render shot_… --wait
|
|
80
|
+
parley studio shot update shot_… --duration 14 --camera "arri-alexa-35, push-in" # free, then re-render that shot
|
|
81
|
+
parley studio shot reorder --project proj_letter-7f3k --shots shot_c,shot_a,shot_b # free, no render
|
|
70
82
|
parley studio assemble --project proj_letter-7f3k --title "The Letter" --synopsis "A woman reads the letter she avoided."
|
|
83
|
+
parley character save char_… # keep Maya for the next film
|
|
84
|
+
parley character use uchar_… --project proj_sequel-2b9k # cast her again, free, same face
|
|
71
85
|
```
|
|
72
86
|
|
|
73
87
|
## For agents
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* parley video "<prompt>" [...] generate a clip / animate a still
|
|
12
12
|
* parley thumbnail "<title>" [...] YouTube thumbnail variants
|
|
13
13
|
* parley ugc-ad --product <img> ... spokesperson product ad
|
|
14
|
+
* parley library list everything you have made, and what it cost
|
|
15
|
+
* parley character list named people who outlive one film
|
|
14
16
|
*
|
|
15
17
|
* Run `parley help <command>` for the flags of one command.
|
|
16
18
|
*/
|
|
@@ -18,7 +20,7 @@ import { readFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node
|
|
|
18
20
|
import path from 'node:path';
|
|
19
21
|
import { api, authWebUrl, backendUrl, clearCredentials, CliError, downloadTo, fileToDataUri, isUrl, loadCredentials, loadLast, openInBrowser, rememberLast, resolveRef, saveCredentials, sleep, } from './client.js';
|
|
20
22
|
import { cmdLens, LensError } from './lens.js';
|
|
21
|
-
const VERSION = '0.
|
|
23
|
+
const VERSION = '0.6.0';
|
|
22
24
|
/** Server advice written for Parley's in-app agent names its tools; a CLI user
|
|
23
25
|
* has commands instead, so the same advice is re-spoken in CLI terms. */
|
|
24
26
|
function humanizeServerMessage(message) {
|
|
@@ -567,6 +569,205 @@ async function cmdVoices() {
|
|
|
567
569
|
* studio assemble --project P --title T [--synopsis S] [--title-text X] [--music path] [--narration url]
|
|
568
570
|
* studio reels --project P
|
|
569
571
|
*/
|
|
572
|
+
// ── library (the account's media and named characters) ───────────────
|
|
573
|
+
// Everything the lanes produce is recorded account-side, so a second
|
|
574
|
+
// session can find the clip that was already paid for instead of
|
|
575
|
+
// rendering it again. Library characters are the cast that outlives one
|
|
576
|
+
// film: casting one into a project copies the approved sheet for free.
|
|
577
|
+
const LIBRARY_KINDS = ['image', 'video', 'audio'];
|
|
578
|
+
const CHARACTER_REF_CREDITS = 3;
|
|
579
|
+
const CHARACTER_MAX_CAST = 6;
|
|
580
|
+
function mediaLine(m) {
|
|
581
|
+
const size = m.width && m.height ? `${m.width}x${m.height}` : '';
|
|
582
|
+
const dur = m.duration_seconds ? `${Math.round(m.duration_seconds)}s` : '';
|
|
583
|
+
const cost = typeof m.credits_charged === 'number' && m.credits_charged > 0 ? `${m.credits_charged}cr` : '';
|
|
584
|
+
const when = m.created_at ? new Date(m.created_at * 1000).toISOString().slice(0, 10) : '';
|
|
585
|
+
return [m.id, String(m.kind).padEnd(5), String(m.lane || '').padEnd(10), when, size || dur, cost, m.favorite ? '*' : '', m.url]
|
|
586
|
+
.filter((x) => x !== '' && x !== undefined)
|
|
587
|
+
.join(' ');
|
|
588
|
+
}
|
|
589
|
+
async function cmdLibrary(p) {
|
|
590
|
+
const action = p.positionals[0] || 'list';
|
|
591
|
+
const target = p.positionals[1];
|
|
592
|
+
const out = (obj, human) => { if (JSON_MODE)
|
|
593
|
+
console.log(JSON.stringify(obj));
|
|
594
|
+
else
|
|
595
|
+
console.log(human()); };
|
|
596
|
+
const needId = (usage) => {
|
|
597
|
+
if (!target)
|
|
598
|
+
throw new CliError(usage);
|
|
599
|
+
return target;
|
|
600
|
+
};
|
|
601
|
+
if (action === 'summary') {
|
|
602
|
+
const s = await api('/api/library/media/summary');
|
|
603
|
+
return out({ ok: true, ...s }, () => `images ${s.images} videos ${s.videos} audio ${s.audio}`);
|
|
604
|
+
}
|
|
605
|
+
if (action === 'list') {
|
|
606
|
+
const kind = str(p.flags.kind);
|
|
607
|
+
if (kind && !LIBRARY_KINDS.includes(kind))
|
|
608
|
+
throw new CliError(`--kind must be one of ${LIBRARY_KINDS.join(', ')}.`);
|
|
609
|
+
const q = new URLSearchParams();
|
|
610
|
+
if (kind)
|
|
611
|
+
q.set('kind', kind);
|
|
612
|
+
if (p.flags.favorite === true || p.flags.favorite === 'true')
|
|
613
|
+
q.set('favorite', '1');
|
|
614
|
+
const lane = str(p.flags.lane);
|
|
615
|
+
if (lane)
|
|
616
|
+
q.set('lane', lane);
|
|
617
|
+
const cursor = str(p.flags.cursor);
|
|
618
|
+
if (cursor)
|
|
619
|
+
q.set('cursor', cursor);
|
|
620
|
+
q.set('limit', String(Math.min(Math.max(num(p.flags.limit, 20), 1), 100)));
|
|
621
|
+
const r = await api(`/api/library/media?${q.toString()}`);
|
|
622
|
+
return out({ ok: true, ...r }, () => (r.items.length
|
|
623
|
+
? r.items.map(mediaLine).join('\n') + (r.nextCursor ? `\n(more: --cursor ${r.nextCursor})` : '')
|
|
624
|
+
: '(nothing in the library yet)'));
|
|
625
|
+
}
|
|
626
|
+
if (action === 'show') {
|
|
627
|
+
const id = needId('Usage: parley library show <mediaId>');
|
|
628
|
+
const m = await api(`/api/library/media/${encodeURIComponent(id)}`);
|
|
629
|
+
return out({ ok: true, media: m }, () => [
|
|
630
|
+
`${m.id} ${m.kind} ${m.lane}`,
|
|
631
|
+
`url ${m.url}`,
|
|
632
|
+
m.model ? `model ${m.model}` : '',
|
|
633
|
+
typeof m.credits_charged === 'number' ? `charged ${m.credits_charged} credits` : '',
|
|
634
|
+
m.project_id ? `project ${m.project_id}` : '',
|
|
635
|
+
m.prompt ? `prompt ${m.prompt}` : '',
|
|
636
|
+
].filter(Boolean).join('\n'));
|
|
637
|
+
}
|
|
638
|
+
if (action === 'fav' || action === 'favorite') {
|
|
639
|
+
const id = needId('Usage: parley library fav <mediaId> [--off]');
|
|
640
|
+
const favorite = p.flags.off !== true;
|
|
641
|
+
const m = await api(`/api/library/media/${encodeURIComponent(id)}`, { method: 'PATCH', body: { favorite } });
|
|
642
|
+
return out({ ok: true, media: m }, () => `${m.id} ${favorite ? 'marked a favourite' : 'no longer a favourite'}`);
|
|
643
|
+
}
|
|
644
|
+
if (action === 'rm' || action === 'delete') {
|
|
645
|
+
const id = needId('Usage: parley library rm <mediaId>');
|
|
646
|
+
await api(`/api/library/media/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
647
|
+
return out({ ok: true, removed: id }, () => `${id} hidden from the library (the file itself stays reachable from the chat it came from).`);
|
|
648
|
+
}
|
|
649
|
+
if (action === 'get' || action === 'download') {
|
|
650
|
+
const id = needId('Usage: parley library get <mediaId> [--out file]');
|
|
651
|
+
const m = await api(`/api/library/media/${encodeURIComponent(id)}`);
|
|
652
|
+
const ext = m.kind === 'video' ? 'mp4' : m.kind === 'audio' ? 'wav' : 'png';
|
|
653
|
+
const outPath = str(p.flags.out) || `${id}.${ext}`;
|
|
654
|
+
const bytes = await downloadTo(m.url, outPath);
|
|
655
|
+
rememberLast(m.kind === 'video' ? 'video' : m.kind === 'audio' ? 'audio' : 'image', { file: outPath, url: m.url });
|
|
656
|
+
return out({ ok: true, file: path.resolve(outPath), url: m.url, bytes }, () => `Saved ${outPath} (${(bytes / 1024).toFixed(0)} KB) - it is @last now.`);
|
|
657
|
+
}
|
|
658
|
+
throw new CliError('Usage: parley library <list|summary|show|fav|rm|get> … (see `parley help library`)');
|
|
659
|
+
}
|
|
660
|
+
async function cmdCharacter(p) {
|
|
661
|
+
const action = p.positionals[0] || 'list';
|
|
662
|
+
const target = p.positionals[1];
|
|
663
|
+
const out = (obj, human) => { if (JSON_MODE)
|
|
664
|
+
console.log(JSON.stringify(obj));
|
|
665
|
+
else
|
|
666
|
+
console.log(human()); };
|
|
667
|
+
const needId = (usage) => {
|
|
668
|
+
if (!target)
|
|
669
|
+
throw new CliError(usage);
|
|
670
|
+
return target;
|
|
671
|
+
};
|
|
672
|
+
// --ref accepts a local file (uploaded as a data URI), an https URL, or @last.
|
|
673
|
+
const refs = () => list(p.flags.ref).map((r) => {
|
|
674
|
+
const resolved = resolveRef(r, 'image');
|
|
675
|
+
return isUrl(resolved) ? resolved : fileToDataUri(resolved);
|
|
676
|
+
});
|
|
677
|
+
if (action === 'list') {
|
|
678
|
+
const r = await api('/api/library/characters');
|
|
679
|
+
const items = r.characters || [];
|
|
680
|
+
return out({ ok: true, characters: items }, () => (items.length
|
|
681
|
+
? items.map((c) => `${c.id} ${String(c.name).padEnd(16)} refs=${(c.reference_image_paths || []).length}${c.voice_id ? ` voice=${c.voice_id}` : ''}${c.source_character_id ? ' (saved from a film)' : ''}`).join('\n')
|
|
682
|
+
: '(no named characters yet)'));
|
|
683
|
+
}
|
|
684
|
+
if (action === 'add') {
|
|
685
|
+
const name = str(p.flags.name);
|
|
686
|
+
if (!name)
|
|
687
|
+
throw new CliError('parley character add needs --name (and --description: the identity lock — age, build, face shape, hair, skin, wardrobe).');
|
|
688
|
+
const body = { name };
|
|
689
|
+
const description = str(p.flags.description);
|
|
690
|
+
if (description)
|
|
691
|
+
body.description = description;
|
|
692
|
+
const wardrobe = str(p.flags.wardrobe);
|
|
693
|
+
if (wardrobe)
|
|
694
|
+
body.wardrobe = wardrobe;
|
|
695
|
+
const tags = list(p.flags.tags);
|
|
696
|
+
if (tags.length)
|
|
697
|
+
body.personalityTags = tags;
|
|
698
|
+
const voice = str(p.flags.voice);
|
|
699
|
+
if (voice)
|
|
700
|
+
body.voiceId = voice;
|
|
701
|
+
const images = refs();
|
|
702
|
+
if (images.length)
|
|
703
|
+
body.referenceImages = images;
|
|
704
|
+
const c = await api('/api/library/characters', { body });
|
|
705
|
+
return out({ ok: true, character: c }, () => `Character ${c.name} saved to your library: ${c.id}\n${(c.reference_image_paths || []).length ? 'Reference images attached.' : `Next: parley character cast ${c.id} --count 3 (${3 * CHARACTER_REF_CREDITS} credits)`}`);
|
|
706
|
+
}
|
|
707
|
+
if (action === 'show') {
|
|
708
|
+
const id = needId('Usage: parley character show <characterId>');
|
|
709
|
+
const c = await api(`/api/library/characters/${encodeURIComponent(id)}`);
|
|
710
|
+
return out({ ok: true, character: c }, () => [
|
|
711
|
+
`${c.id} ${c.name}`,
|
|
712
|
+
c.description ? `description ${c.description}` : '',
|
|
713
|
+
c.wardrobe ? `wardrobe ${c.wardrobe}` : '',
|
|
714
|
+
(c.personality_tags || []).length ? `tags ${c.personality_tags.join(', ')}` : '',
|
|
715
|
+
c.voice_id ? `voice ${c.voice_id}` : '',
|
|
716
|
+
`references ${(c.reference_image_urls || []).join(' ') || '(none yet)'}`,
|
|
717
|
+
].filter(Boolean).join('\n'));
|
|
718
|
+
}
|
|
719
|
+
if (action === 'refs') {
|
|
720
|
+
const id = needId('Usage: parley character refs <characterId> --ref <file|url|@last>…');
|
|
721
|
+
const images = refs();
|
|
722
|
+
if (!images.length)
|
|
723
|
+
throw new CliError('parley character refs needs at least one --ref <file|url|@last>.');
|
|
724
|
+
const c = await api(`/api/library/characters/${encodeURIComponent(id)}/references`, { body: { images } });
|
|
725
|
+
return out({ ok: true, character: c }, () => `${c.name} now has ${(c.reference_image_paths || []).length} reference images (free - nothing was generated).`);
|
|
726
|
+
}
|
|
727
|
+
if (action === 'cast') {
|
|
728
|
+
const id = needId('Usage: parley character cast <characterId> [--count 3] [--direction "…"]');
|
|
729
|
+
const count = Math.min(Math.max(num(p.flags.count, 3), 1), CHARACTER_MAX_CAST);
|
|
730
|
+
const body = { count };
|
|
731
|
+
const direction = str(p.flags.direction);
|
|
732
|
+
if (direction)
|
|
733
|
+
body.additionalDirection = direction;
|
|
734
|
+
const model = str(p.flags.model);
|
|
735
|
+
if (model)
|
|
736
|
+
body.model = model;
|
|
737
|
+
// --dry-run quotes the price and spends nothing: --json silences the note(),
|
|
738
|
+
// so an agent that must state the cost first has a way to read it.
|
|
739
|
+
if (p.flags['dry-run'] === true || p.flags.quote === true) {
|
|
740
|
+
const credits = count * CHARACTER_REF_CREDITS;
|
|
741
|
+
return out({ ok: true, dryRun: true, count, creditsPerReference: CHARACTER_REF_CREDITS, credits }, () => `${count} reference image${count === 1 ? '' : 's'} would cost ${credits} credits (${CHARACTER_REF_CREDITS} each). Nothing was charged.`);
|
|
742
|
+
}
|
|
743
|
+
note(`Casting ${count} reference image${count === 1 ? '' : 's'} (${count * CHARACTER_REF_CREDITS} credits)…`);
|
|
744
|
+
const r = await api(`/api/library/characters/${encodeURIComponent(id)}/cast`, { body });
|
|
745
|
+
const urls = r.character?.reference_image_urls || [];
|
|
746
|
+
const made = (r.generatedPaths || []).length;
|
|
747
|
+
return out({ ok: true, ...r }, () => `${r.character?.name || id}: ${made} new reference${made === 1 ? '' : 's'}, ${r.credits_charged ?? 0} credits charged.\nShow the user ${urls[urls.length - 1] || '(see JSON)'} and get their yes before writing shots.`);
|
|
748
|
+
}
|
|
749
|
+
if (action === 'use') {
|
|
750
|
+
const id = needId('Usage: parley character use <characterId> --project proj_…');
|
|
751
|
+
const project = str(p.flags.project);
|
|
752
|
+
if (!project || !/^proj_[A-Za-z0-9_-]{6,}$/.test(project))
|
|
753
|
+
throw new CliError('parley character use needs --project proj_… (proj_ plus at least six letters, digits, _ or -).');
|
|
754
|
+
const r = await api(`/api/library/characters/${encodeURIComponent(id)}/use-in-project/${encodeURIComponent(project)}`, { method: 'POST', body: {} });
|
|
755
|
+
const pc = r.character || r;
|
|
756
|
+
return out({ ok: true, ...r }, () => `Cast into ${project} as ${pc.id} with the approved sheet (free - no re-cast, so the face does not drift).\nUse ${pc.id} in --characters on that project's shots.`);
|
|
757
|
+
}
|
|
758
|
+
if (action === 'save') {
|
|
759
|
+
const id = needId('Usage: parley character save <studioCharacterId> (keeps a film character for later films)');
|
|
760
|
+
const r = await api(`/api/library/characters/from-studio/${encodeURIComponent(id)}`, { method: 'POST', body: {} });
|
|
761
|
+
const c = r.character || r;
|
|
762
|
+
return out({ ok: true, ...r }, () => `${c.name} ${r.existed ? 'was already in' : 'saved to'} your library as ${c.id}.`);
|
|
763
|
+
}
|
|
764
|
+
if (action === 'rm' || action === 'delete') {
|
|
765
|
+
const id = needId('Usage: parley character rm <characterId>');
|
|
766
|
+
await api(`/api/library/characters/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
767
|
+
return out({ ok: true, removed: id }, () => `${id} removed from your library (films that already cast them are untouched).`);
|
|
768
|
+
}
|
|
769
|
+
throw new CliError('Usage: parley character <list|add|show|refs|cast|use|save|rm> … (see `parley help character`)');
|
|
770
|
+
}
|
|
570
771
|
async function cmdStudio(p) {
|
|
571
772
|
const [group, action, target] = p.positionals;
|
|
572
773
|
const project = str(p.flags.project);
|
|
@@ -743,7 +944,46 @@ async function cmdStudio(p) {
|
|
|
743
944
|
const items = r.reels || (Array.isArray(r) ? r : []);
|
|
744
945
|
return out({ ok: true, reels: items }, () => items.map((x) => `${x.id} ${x.public_url || x.video_path}`).join('\n') || '(no reels yet)');
|
|
745
946
|
}
|
|
746
|
-
|
|
947
|
+
// Timeline: edit a written shot instead of deleting and re-adding it, and
|
|
948
|
+
// set the playback order. Both are free; the server refuses an edit while
|
|
949
|
+
// that shot is rendering so an in-flight render never reads a half-edited row.
|
|
950
|
+
if (group === 'shot' && (action === 'update' || action === 'edit')) {
|
|
951
|
+
if (!target)
|
|
952
|
+
throw new CliError('Usage: parley studio shot update <shotId> [--framing "…"] [--directing "…"] [--duration 12] [--camera "lens look, move"]');
|
|
953
|
+
const body = {};
|
|
954
|
+
const framing = str(p.flags.framing);
|
|
955
|
+
if (framing)
|
|
956
|
+
body.framingPrompt = framing;
|
|
957
|
+
const directing = str(p.flags.directing);
|
|
958
|
+
if (directing)
|
|
959
|
+
body.directingPrompt = directing;
|
|
960
|
+
const duration = str(p.flags.duration);
|
|
961
|
+
if (duration)
|
|
962
|
+
body.durationSeconds = Number(duration);
|
|
963
|
+
const camera = str(p.flags.camera);
|
|
964
|
+
if (camera !== undefined)
|
|
965
|
+
body.cameraEmulation = camera === 'none' ? null : camera;
|
|
966
|
+
if (Object.keys(body).length === 0)
|
|
967
|
+
throw new CliError('Nothing to change: pass --framing, --directing, --duration or --camera (--camera none clears it).');
|
|
968
|
+
const s = await api(`/api/studio/shots/${target}`, { method: 'PATCH', body });
|
|
969
|
+
return out({ ok: true, shot: s }, () => `Shot #${(s.order_index ?? 0) + 1} updated (${Object.keys(body).join(', ')}). Re-render it to see the change: parley studio render ${s.id} --wait`);
|
|
970
|
+
}
|
|
971
|
+
if (group === 'shot' && (action === 'reorder' || action === 'order')) {
|
|
972
|
+
const P = needProject();
|
|
973
|
+
const shotIds = list(p.flags.shots);
|
|
974
|
+
if (shotIds.length === 0)
|
|
975
|
+
throw new CliError('Usage: parley studio shot reorder --project proj_… --shots <shotId,shotId,…> (the new playback order, every shot listed)');
|
|
976
|
+
const r = await api(`/api/studio/projects/${P}/shots/reorder`, { body: { shotIds } });
|
|
977
|
+
const items = r.shots || [];
|
|
978
|
+
return out({ ok: true, shots: items }, () => items.map((s) => `#${(s.order_index ?? 0) + 1} ${s.id}`).join('\n') || 'Reordered.');
|
|
979
|
+
}
|
|
980
|
+
if (group === 'shot' && (action === 'rm' || action === 'delete')) {
|
|
981
|
+
if (!target)
|
|
982
|
+
throw new CliError('Usage: parley studio shot rm <shotId>');
|
|
983
|
+
const r = await api(`/api/studio/shots/${target}`, { method: 'DELETE' });
|
|
984
|
+
return out({ ok: true, ...r }, () => `${target} removed from the shot list (a clip already rendered for it stays in your library).`);
|
|
985
|
+
}
|
|
986
|
+
throw new CliError('Usage: parley studio <character add|cast|list | location add|cast|list | shot add|list|update|reorder|rm | render <shotId> | assemble | reels> … (see `parley help studio`)');
|
|
747
987
|
}
|
|
748
988
|
const DEPLOY_MAX_FILES = 600;
|
|
749
989
|
const DEPLOY_MAX_TOTAL_BYTES = 40 * 1024 * 1024;
|
|
@@ -993,7 +1233,9 @@ function help(topic) {
|
|
|
993
1233
|
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.',
|
|
994
1234
|
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.`,
|
|
995
1235
|
voices: 'parley voices\n The narration personas, the tiers and their prices.',
|
|
996
|
-
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.',
|
|
1236
|
+
studio: 'parley studio character add|cast|list · location add|cast|list · shot add|list|update|reorder|rm · render <shotId> [--wait] · assemble · reels\n The film pipeline without JSON: cast characters and locations (identity locks), write shots ("lens look, move" camera), edit and reorder the timeline for free, render, assemble a reel. Every subcommand takes --project proj_… and --json.',
|
|
1237
|
+
library: 'parley library list [--kind image|video|audio] [--lane <lane>] [--favorite] [--limit 20] [--cursor …] · summary · show <mediaId> · fav <mediaId> [--off] · rm <mediaId> · get <mediaId> [--out file]\n Everything you have generated, newest first, with what each one cost. Look here before paying to make something again: a library url goes straight into --image, --ref or --beat.',
|
|
1238
|
+
character: 'parley character list · add --name X --description "…" [--ref <file|url|@last>]… · show <id> · refs <id> --ref … · cast <id> [--count 3] [--dry-run] · use <id> --project proj_… · save <studioCharacterId> · rm <id>\n Named people who outlive one film. Casting reference images costs 3 credits each; refs attaches your own for free; use copies the approved sheet into a Studio project for free, so the same face comes back without a re-cast.',
|
|
997
1239
|
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).',
|
|
998
1240
|
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).`,
|
|
999
1241
|
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\`.`,
|
|
@@ -1005,7 +1247,7 @@ function help(topic) {
|
|
|
1005
1247
|
engines: 'parley engines\n Which video engines this server can run right now and their credit rates.',
|
|
1006
1248
|
'camera-moves': 'parley camera-moves\n The named camera-move presets accepted by `parley video --move`.',
|
|
1007
1249
|
account: 'parley account\n Your credit balance.',
|
|
1008
|
-
lens: 'parley lens <status|connect|build|describe|record|metrics>\n Build a Snap Specs Lens on this computer through Lens Studio: a step-by-step training guide with pinch buttons and optional voice shortcuts, tested in the preview before you see it. "describe" turns plain language into steps (about a cent, billed to your credits); "build <form.json>" is free. Run `parley lens status` first: it tells you exactly what to set up.',
|
|
1250
|
+
lens: 'parley lens <status|connect|build|describe|publish|record|metrics>\n Build a Snap Specs Lens on this computer through Lens Studio: a step-by-step training guide with pinch buttons and optional voice shortcuts, tested in the preview before you see it. "describe" turns plain language into steps (about a cent, billed to your credits); "build <form.json>" is free. Run `parley lens status` first: it tells you exactly what to set up. `publish` runs Snap\u0027s own preflight and SnapOS Package export (needs Lens Studio signed in).',
|
|
1009
1251
|
};
|
|
1010
1252
|
if (topic && lines[topic]) {
|
|
1011
1253
|
console.log(lines[topic]);
|
|
@@ -1039,6 +1281,14 @@ async function main(argv) {
|
|
|
1039
1281
|
case 'studio':
|
|
1040
1282
|
await cmdStudio(p);
|
|
1041
1283
|
break;
|
|
1284
|
+
case 'library':
|
|
1285
|
+
case 'lib':
|
|
1286
|
+
await cmdLibrary(p);
|
|
1287
|
+
break;
|
|
1288
|
+
case 'character':
|
|
1289
|
+
case 'characters':
|
|
1290
|
+
await cmdCharacter(p);
|
|
1291
|
+
break;
|
|
1042
1292
|
case 'logout':
|
|
1043
1293
|
await cmdLogout();
|
|
1044
1294
|
break;
|
package/dist/lens.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* parley lens connect --config '<json>' Save the config Lens Studio copies (AI Assistant > MCP > Configure Server)
|
|
8
8
|
* parley lens build <form.json> Form -> tested Lens ($0, no model call)
|
|
9
9
|
* parley lens describe "<procedure>" Plain language -> steps (billed, about a cent) -> tested Lens
|
|
10
|
+
* parley lens publish [<lens id>] Snap's preflight, then Snap's SnapOS Package export
|
|
10
11
|
* parley lens record <run> [--on-glasses passed|issues] [--published submitted|published]
|
|
11
12
|
* parley lens metrics
|
|
12
13
|
*/
|
|
@@ -198,6 +199,35 @@ export async function cmdLens(positionals, flags, jsonMode) {
|
|
|
198
199
|
throw new LensError('The build did not pass. Read the report.', 6);
|
|
199
200
|
return;
|
|
200
201
|
}
|
|
202
|
+
if (sub === 'publish') {
|
|
203
|
+
const target = positionals[1];
|
|
204
|
+
const events = await runPipeline('publish', { target }, (e) => { if (!jsonMode && e.type === 'log')
|
|
205
|
+
console.log(' ' + e.message); });
|
|
206
|
+
const r = events[events.length - 1];
|
|
207
|
+
if (r.type === 'error')
|
|
208
|
+
throw new LensError(r.error, 1);
|
|
209
|
+
out(r, () => {
|
|
210
|
+
console.log('');
|
|
211
|
+
console.log(`Lens name for Snap: ${r.lensName ?? '(unknown)'}`);
|
|
212
|
+
console.log(`Name, icon and signing key on the project: ${r.readyLocally ? 'all set' : 'incomplete'}`);
|
|
213
|
+
if (r.issues?.length)
|
|
214
|
+
for (const i of r.issues)
|
|
215
|
+
console.log(' - ' + i);
|
|
216
|
+
if (r.ok) {
|
|
217
|
+
console.log(`\nSnapOS Package exported: ${r.spkPath}`);
|
|
218
|
+
if (r.bytes)
|
|
219
|
+
console.log(`${r.bytes} bytes${r.checksum ? ', checksum ' + r.checksum : ''}`);
|
|
220
|
+
}
|
|
221
|
+
else if (r.stage === 'blocked_on_sign_in') {
|
|
222
|
+
console.log('\nNot packaged: Lens Studio is not signed in.');
|
|
223
|
+
}
|
|
224
|
+
for (const n of r.next ?? [])
|
|
225
|
+
console.log('\n' + n);
|
|
226
|
+
});
|
|
227
|
+
if (!r.ok)
|
|
228
|
+
throw new LensError(r.stage === 'blocked_on_sign_in' ? 'Sign in to Lens Studio, then run this again.' : 'The Lens was not packaged.', r.stage === 'blocked_on_sign_in' ? 2 : 6);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
201
231
|
if (sub === 'record') {
|
|
202
232
|
const run = positionals[1];
|
|
203
233
|
const hw = str(flags['on-glasses']);
|
|
@@ -218,5 +248,5 @@ export async function cmdLens(positionals, flags, jsonMode) {
|
|
|
218
248
|
console.log('No Lenses built on this computer yet.'); });
|
|
219
249
|
return;
|
|
220
250
|
}
|
|
221
|
-
throw new LensError(`Unknown: parley lens ${sub}. Use status | connect | build | describe | record | metrics.`, 64);
|
|
251
|
+
throw new LensError(`Unknown: parley lens ${sub}. Use status | connect | build | describe | publish | record | metrics.`, 64);
|
|
222
252
|
}
|
package/lens-builder/build.mjs
CHANGED
|
@@ -6,6 +6,8 @@ import path from 'node:path';
|
|
|
6
6
|
import { Bridge, ROOT, DATA } from './lib/bridge.mjs';
|
|
7
7
|
import { compile } from './training/compile.mjs';
|
|
8
8
|
import { verify } from './verify.mjs';
|
|
9
|
+
import { prepareSubmission } from './lib/submission.mjs';
|
|
10
|
+
import { tidyProjectFolder } from './lib/handoff.mjs';
|
|
9
11
|
|
|
10
12
|
const MAX_ITERATIONS = 5;
|
|
11
13
|
const here = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'));
|
|
@@ -33,10 +35,16 @@ export async function build(form, { log = console.log, planner = null } = {}) {
|
|
|
33
35
|
result.stage = 'implement';
|
|
34
36
|
const bridge = new Bridge({ logPath: path.join(runDir, 'calls.jsonl') });
|
|
35
37
|
await bridge.connect();
|
|
36
|
-
const project = await bridge.newProject(lens_id);
|
|
38
|
+
const project = await bridge.newProject(lens_id, { folderName: form.procedureName });
|
|
39
|
+
// Where the Lens lives, for the handoff card in chat.
|
|
40
|
+
result.project = { dir: project.dir, file: project.file };
|
|
41
|
+
result.title = plan.title;
|
|
42
|
+
result.scriptPath = scriptPath;
|
|
37
43
|
log(`project: ${project.esproj}`);
|
|
44
|
+
|
|
38
45
|
await bridge.specsPreview();
|
|
39
46
|
|
|
47
|
+
let submission = null;
|
|
40
48
|
let report = null;
|
|
41
49
|
for (let iteration = 1; iteration <= MAX_ITERATIONS; iteration++) {
|
|
42
50
|
result.iterations = iteration;
|
|
@@ -44,9 +52,19 @@ export async function build(form, { log = console.log, planner = null } = {}) {
|
|
|
44
52
|
const authored = await bridge.eecFile(path.join(here, 'training', 'author-scene.ts'), { layout, scriptPath: scriptPath.split(path.sep).join('/') }, 90000);
|
|
45
53
|
log(`iteration ${iteration}: authored ${authored.created.length} objects, script ${authored.script.mode}`);
|
|
46
54
|
|
|
55
|
+
// Snap will not take a Lens without a name it accepts, an icon and a production signing key.
|
|
56
|
+
// A project copied from Snap's Base Template arrives called "SpecsBaseTemplate"; left alone, that is
|
|
57
|
+
// the name that would reach Snap. Done after authoring, not straight after the project opens:
|
|
58
|
+
// writing metadata while Snap's UIKit package is still importing left the package half-resolved
|
|
59
|
+
// (its own textures then failed to load at runtime).
|
|
60
|
+
if (!submission) {
|
|
61
|
+
submission = await prepareSubmission(bridge, { projectDir: project.dir, procedureName: form.procedureName, lensName: form.lensName });
|
|
62
|
+
log(`for Snap: name "${submission.lensName}", icon set, signing key ${submission.signingKeyCreated ? 'generated' : 'reused'}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
47
65
|
// ---- Verifier
|
|
48
66
|
result.stage = 'verify';
|
|
49
|
-
report = await verify({ bridge, plan, form, runDir, iteration, projectDir: project.dir });
|
|
67
|
+
report = await verify({ bridge, plan, form, runDir, iteration, projectDir: project.dir, submission });
|
|
50
68
|
fs.writeFileSync(path.join(runDir, 'verifier', `iteration-${iteration}.json`), JSON.stringify(report, null, 2));
|
|
51
69
|
for (const c of report.checks) log(` ${c.status.toUpperCase().padEnd(8)} ${c.item}${c.detail ? ' -> ' + String(c.detail).slice(0, 160) : ''}`);
|
|
52
70
|
if (report.pass) break;
|
|
@@ -58,11 +76,15 @@ export async function build(form, { log = console.log, planner = null } = {}) {
|
|
|
58
76
|
}
|
|
59
77
|
|
|
60
78
|
await bridge.save();
|
|
79
|
+
// The person has to be able to open this folder and know what to click. Done after save so the
|
|
80
|
+
// project on disk is final, and only when it passed: a failed build is not handed over.
|
|
81
|
+
if (report?.pass) result.handoff = tidyProjectFolder(project.dir, { title: plan.title, esproj: project.esproj, steps: form.steps, completionMessage: form.completionMessage });
|
|
61
82
|
result.ok = !!report?.pass;
|
|
62
83
|
result.stage = result.ok ? 'done' : 'escalated';
|
|
63
84
|
result.seconds = Math.round((Date.now() - started) / 1000);
|
|
64
85
|
result.costUsd = planner ? planner.costUsd : 0;
|
|
65
|
-
|
|
86
|
+
result.submission = submission;
|
|
87
|
+
fs.writeFileSync(path.join(runDir, 'report.md'), renderReport({ plan, form, report, result, project, planner, submission }));
|
|
66
88
|
fs.writeFileSync(path.join(runDir, 'metrics.json'), JSON.stringify({ lens_id, title: plan.title, vertical: plan.vertical, source: planner ? 'prompt' : 'form', prompt: planner ? planner.prompt : null, pass: result.ok, iterations: result.iterations, seconds: result.seconds + (planner ? planner.seconds : 0), costUsd: result.costUsd, tokens: planner ? planner.usage : { input: 0, output: 0, calls: 0 }, steps: form.steps.length, hardwareItems: report.checks.filter((c) => c.status === 'hardware').length, hardwareTest: 'not tested', publishStatus: 'not published', finished_at: new Date().toISOString() }, null, 2));
|
|
67
89
|
return { ...result, report };
|
|
68
90
|
} catch (e) {
|
|
@@ -73,7 +95,7 @@ export async function build(form, { log = console.log, planner = null } = {}) {
|
|
|
73
95
|
}
|
|
74
96
|
}
|
|
75
97
|
|
|
76
|
-
function renderReport({ plan, form, report, result, project, planner }) {
|
|
98
|
+
function renderReport({ plan, form, report, result, project, planner, submission }) {
|
|
77
99
|
const line = (c) => `| ${c.status === 'pass' ? 'PASS' : c.status === 'fail' ? 'FAIL' : 'NEEDS HARDWARE'} | ${c.item} | ${String(c.detail ?? '').replace(/\|/g, '/').slice(0, 220)} |`;
|
|
78
100
|
const cost = planner
|
|
79
101
|
? `USD ${planner.costUsd.toFixed(4)} (${planner.usage.input} in / ${planner.usage.output} out tokens, ${planner.model}, planning took ${planner.seconds}s)`
|
|
@@ -111,12 +133,17 @@ Source: https://developers.snap.com/spectacles/get-started/start-building/publis
|
|
|
111
133
|
**Tests performed (preview):**
|
|
112
134
|
${report.checks.filter((c) => c.status === 'pass').map((c) => `- ${c.item}`).join('\n')}
|
|
113
135
|
|
|
136
|
+
**Lens name:** ${submission.lensName} (Snap accepts at most 18 characters; the full procedure name stays on the panel)
|
|
137
|
+
**Lens icon:** set, 320x320 PNG
|
|
138
|
+
**Signing key:** production key attached to the project
|
|
139
|
+
|
|
114
140
|
**Tests Snap should repeat on device:** open the Lens, confirm "v${form.version || '1.0.0'}" shows under the buttons, pinch Done through all ${form.steps.length} step${form.steps.length === 1 ? '' : 's'} to the completion message, pinch Back once from step 2${form.voiceShortcuts !== false ? ', say "done" to advance' : ''}.
|
|
115
141
|
|
|
116
142
|
## Manual steps remaining
|
|
117
143
|
- Put the Lens on glasses: pair Specs in Lens Studio, then Send to Spectacles.
|
|
118
144
|
- Check frame rate and thermals on device with the Lens Performance Overlay (Snap targets 60 FPS).
|
|
119
|
-
-
|
|
145
|
+
- Sign in to Lens Studio (My Lenses > Login). That is the one thing Parley cannot do for you, and it gates Snap's preflight, the SnapOS Package export and the upload.
|
|
146
|
+
- Add a preview image or video, then run the publish steps. Name, icon and signing key are already set on this project.
|
|
120
147
|
`;
|
|
121
148
|
}
|
|
122
149
|
|
package/lens-builder/cli.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// node lens-builder/cli.mjs plan < {"prompt":""} -> { type:'planner', decision, form|question|reason, costUsd }
|
|
4
4
|
// node lens-builder/cli.mjs build < {"form":{}, "planner":{}?} -> progress events, then { type:'result', ... }
|
|
5
5
|
// node lens-builder/cli.mjs metrics -> { type:'metrics', lenses:[...] }
|
|
6
|
+
// node lens-builder/cli.mjs publish [lensId|run] -> Snap preflight + SnapOS Package export (needs Lens Studio sign-in)
|
|
6
7
|
// node lens-builder/cli.mjs record < {"run":"","hardwareTest":"","publishStatus":"","notes":""} -> updated metrics row
|
|
7
8
|
import fs from 'node:fs';
|
|
8
9
|
import path from 'node:path';
|
|
@@ -75,6 +76,25 @@ try {
|
|
|
75
76
|
if (cmd === 'status') await status();
|
|
76
77
|
else if (cmd === 'metrics') metrics();
|
|
77
78
|
else if (cmd === 'record') await record();
|
|
79
|
+
else if (cmd === 'lenses') {
|
|
80
|
+
const { listLenses } = await import('./lenses.mjs');
|
|
81
|
+
emit({ type: 'lenses', lenses: listLenses() });
|
|
82
|
+
}
|
|
83
|
+
else if (cmd === 'send') {
|
|
84
|
+
const { sendToGlasses } = await import('./lenses.mjs');
|
|
85
|
+
const { target } = await readStdin();
|
|
86
|
+
emit({ type: 'sent', ...(await sendToGlasses(target, (message) => emit({ type: 'log', message }))) });
|
|
87
|
+
}
|
|
88
|
+
else if (cmd === 'open') {
|
|
89
|
+
const { openLens } = await import('./lenses.mjs');
|
|
90
|
+
const { target, where } = await readStdin();
|
|
91
|
+
emit({ type: 'opened', ...(await openLens(target, where, (message) => emit({ type: 'log', message }))) });
|
|
92
|
+
}
|
|
93
|
+
else if (cmd === 'publish') {
|
|
94
|
+
const { publish } = await import('./publish.mjs');
|
|
95
|
+
const { target } = await readStdin();
|
|
96
|
+
emit({ type: 'publish', ...(await publish(target, { log: (message) => emit({ type: 'log', message }) })) });
|
|
97
|
+
}
|
|
78
98
|
else if (cmd === 'plan') {
|
|
79
99
|
const { planFromPrompt } = await import('./planner.mjs');
|
|
80
100
|
const { prompt, auth } = await readStdin();
|
|
@@ -85,7 +105,7 @@ try {
|
|
|
85
105
|
const { build } = await import('./build.mjs');
|
|
86
106
|
const { form, planner = null } = await readStdin();
|
|
87
107
|
const r = await build(form, { planner, log: (message) => emit({ type: 'log', message }) });
|
|
88
|
-
emit({ type: 'result', ok: r.ok, lens_id: r.lens_id, iterations: r.iterations, seconds: r.seconds, costUsd: r.costUsd ?? 0, stage: r.stage, error: r.error, runDir: r.runDir, checks: r.report?.checks ?? [], reportPath: r.runDir ? path.join(r.runDir, 'report.md') : null });
|
|
108
|
+
emit({ type: 'result', ok: r.ok, lens_id: r.lens_id, iterations: r.iterations, seconds: r.seconds, costUsd: r.costUsd ?? 0, stage: r.stage, error: r.error, runDir: r.runDir, checks: r.report?.checks ?? [], reportPath: r.runDir ? path.join(r.runDir, 'report.md') : null, projectDir: r.project?.dir ?? null, projectFile: r.project?.file ?? null, title: r.title ?? null, lensName: r.submission?.lensName ?? null, scriptPath: r.scriptPath ?? null });
|
|
89
109
|
process.exitCode = r.ok ? 0 : 1;
|
|
90
110
|
} else {
|
|
91
111
|
emit({ type: 'error', error: `unknown command: ${cmd}` });
|