parley-live 0.5.1 → 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 +253 -3
- package/lens-builder/build.mjs +9 -1
- package/lens-builder/cli.mjs +15 -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/publish.mjs +23 -5
- 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\`.`,
|
|
@@ -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/lens-builder/build.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { Bridge, ROOT, DATA } from './lib/bridge.mjs';
|
|
|
7
7
|
import { compile } from './training/compile.mjs';
|
|
8
8
|
import { verify } from './verify.mjs';
|
|
9
9
|
import { prepareSubmission } from './lib/submission.mjs';
|
|
10
|
+
import { tidyProjectFolder } from './lib/handoff.mjs';
|
|
10
11
|
|
|
11
12
|
const MAX_ITERATIONS = 5;
|
|
12
13
|
const here = path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'));
|
|
@@ -34,7 +35,11 @@ export async function build(form, { log = console.log, planner = null } = {}) {
|
|
|
34
35
|
result.stage = 'implement';
|
|
35
36
|
const bridge = new Bridge({ logPath: path.join(runDir, 'calls.jsonl') });
|
|
36
37
|
await bridge.connect();
|
|
37
|
-
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;
|
|
38
43
|
log(`project: ${project.esproj}`);
|
|
39
44
|
|
|
40
45
|
await bridge.specsPreview();
|
|
@@ -71,6 +76,9 @@ export async function build(form, { log = console.log, planner = null } = {}) {
|
|
|
71
76
|
}
|
|
72
77
|
|
|
73
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 });
|
|
74
82
|
result.ok = !!report?.pass;
|
|
75
83
|
result.stage = result.ok ? 'done' : 'escalated';
|
|
76
84
|
result.seconds = Math.round((Date.now() - started) / 1000);
|
package/lens-builder/cli.mjs
CHANGED
|
@@ -76,6 +76,20 @@ try {
|
|
|
76
76
|
if (cmd === 'status') await status();
|
|
77
77
|
else if (cmd === 'metrics') metrics();
|
|
78
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
|
+
}
|
|
79
93
|
else if (cmd === 'publish') {
|
|
80
94
|
const { publish } = await import('./publish.mjs');
|
|
81
95
|
const { target } = await readStdin();
|
|
@@ -91,7 +105,7 @@ try {
|
|
|
91
105
|
const { build } = await import('./build.mjs');
|
|
92
106
|
const { form, planner = null } = await readStdin();
|
|
93
107
|
const r = await build(form, { planner, log: (message) => emit({ type: 'log', message }) });
|
|
94
|
-
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 });
|
|
95
109
|
process.exitCode = r.ok ? 0 : 1;
|
|
96
110
|
} else {
|
|
97
111
|
emit({ type: 'error', error: `unknown command: ${cmd}` });
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// The Lenses a person has made, and getting back to one.
|
|
2
|
+
//
|
|
3
|
+
// A build used to end with the files somewhere only the pipeline knew about. These two calls are what
|
|
4
|
+
// make a finished Lens something the person owns: a list they can see, and a way back into Lens Studio.
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { Bridge, DATA, PROJECTS } from './lib/bridge.mjs';
|
|
8
|
+
|
|
9
|
+
/** Every Lens on this computer, newest first, with where it lives and how its last build went. */
|
|
10
|
+
export function listLenses() {
|
|
11
|
+
if (!fs.existsSync(PROJECTS)) return [];
|
|
12
|
+
const runs = readRuns();
|
|
13
|
+
return fs.readdirSync(PROJECTS, { withFileTypes: true })
|
|
14
|
+
.filter((d) => d.isDirectory() && d.name !== '_templates')
|
|
15
|
+
.map((d) => {
|
|
16
|
+
const dir = path.join(PROJECTS, d.name);
|
|
17
|
+
const esproj = fs.readdirSync(dir).find((f) => f.toLowerCase().endsWith('.esproj'));
|
|
18
|
+
if (!esproj) return null;
|
|
19
|
+
const run = runs.find((r) => sameName(r.title, d.name) || sameName(r.lens_id, d.name)) ?? null;
|
|
20
|
+
return {
|
|
21
|
+
name: run?.title || d.name,
|
|
22
|
+
folder: d.name,
|
|
23
|
+
dir,
|
|
24
|
+
file: path.join(dir, esproj),
|
|
25
|
+
builtAt: run?.finished_at ?? fs.statSync(path.join(dir, esproj)).mtime.toISOString(),
|
|
26
|
+
steps: run?.steps ?? null,
|
|
27
|
+
passed: run?.pass ?? null,
|
|
28
|
+
reportPath: run?.reportPath ?? null,
|
|
29
|
+
};
|
|
30
|
+
})
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
.sort((a, b) => String(b.builtAt).localeCompare(String(a.builtAt)));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Put a Lens back in front of the person.
|
|
37
|
+
* where 'lens_studio' (default) opens it in Lens Studio, which is where Send to Spectacles lives.
|
|
38
|
+
* where 'folder' only resolves the path; revealing a folder belongs to the app, not to this process.
|
|
39
|
+
*/
|
|
40
|
+
export async function openLens(target, where = 'lens_studio', log = () => {}) {
|
|
41
|
+
const { resolveProject, openProject } = await import('./publish.mjs');
|
|
42
|
+
const file = resolveProject(target || '');
|
|
43
|
+
if (!file) {
|
|
44
|
+
const known = listLenses().map((l) => l.name);
|
|
45
|
+
throw new Error(`No Lens called "${target}" on this computer.${known.length ? ` Built so far: ${known.join(', ')}.` : ' Nothing has been built yet.'}`);
|
|
46
|
+
}
|
|
47
|
+
const dir = path.dirname(file);
|
|
48
|
+
if (where === 'folder') return { file, dir, opened: 'folder' };
|
|
49
|
+
log(`opening ${path.basename(file)} in Lens Studio`);
|
|
50
|
+
const bridge = new Bridge();
|
|
51
|
+
await bridge.connect();
|
|
52
|
+
await openProject(bridge, file);
|
|
53
|
+
return { file, dir, opened: 'lens_studio' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readRuns() {
|
|
57
|
+
const runsDir = path.join(DATA, 'runs');
|
|
58
|
+
if (!fs.existsSync(runsDir)) return [];
|
|
59
|
+
return fs.readdirSync(runsDir)
|
|
60
|
+
.map((name) => {
|
|
61
|
+
const metrics = path.join(runsDir, name, 'metrics.json');
|
|
62
|
+
if (!fs.existsSync(metrics)) return null;
|
|
63
|
+
try {
|
|
64
|
+
const m = JSON.parse(fs.readFileSync(metrics, 'utf8'));
|
|
65
|
+
return { ...m, reportPath: path.join(runsDir, name, 'report.md') };
|
|
66
|
+
} catch { return null; }
|
|
67
|
+
})
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.sort((a, b) => String(b.finished_at).localeCompare(String(a.finished_at)));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const sameName = (a, b) => String(a ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '') === String(b ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Put a Lens on the paired Spectacles: the "Send to Spectacles" step, done by Parley.
|
|
76
|
+
*
|
|
77
|
+
* Snap exposes it over MCP as push_leaf_lens, which pushes whatever project is open, so the Lens is
|
|
78
|
+
* opened first. The execution target has to be switched to the device or the push goes to the
|
|
79
|
+
* preview and reports success, which would be a lie. On failure the target is put back, so Lens
|
|
80
|
+
* Studio is left the way it was found.
|
|
81
|
+
*/
|
|
82
|
+
export async function sendToGlasses(target, log = () => {}) {
|
|
83
|
+
const { resolveProject, openProject } = await import('./publish.mjs');
|
|
84
|
+
const file = resolveProject(target || '');
|
|
85
|
+
if (!file) throw new Error(`No Lens called "${target}" on this computer.`);
|
|
86
|
+
// Nobody should sit and watch a spinner for four minutes to learn their glasses are not on.
|
|
87
|
+
const bridge = new Bridge({ timeoutMs: 90000 });
|
|
88
|
+
await bridge.connect();
|
|
89
|
+
log('opening the Lens in Lens Studio');
|
|
90
|
+
await openProject(bridge, file);
|
|
91
|
+
await bridge.call('open_leaf_panel', {});
|
|
92
|
+
log('pointing Lens Studio at your Spectacles');
|
|
93
|
+
const aimed = await bridge.call('set_leaf_execution_target', { onDevice: true });
|
|
94
|
+
if (!aimed || aimed.target !== 'device') {
|
|
95
|
+
await bridge.call('set_leaf_execution_target', { onDevice: false }).catch(() => {});
|
|
96
|
+
throw new Error('Lens Studio would not switch to the glasses. Pair your Spectacles in Lens Studio first.');
|
|
97
|
+
}
|
|
98
|
+
log('sending it across');
|
|
99
|
+
try {
|
|
100
|
+
const pushed = await bridge.call('push_leaf_lens', {});
|
|
101
|
+
if (!pushed || pushed.pushed !== true || pushed.target !== 'device') {
|
|
102
|
+
throw new Error(`The Lens went to the ${pushed && pushed.target ? pushed.target : 'preview'} instead of the glasses.`);
|
|
103
|
+
}
|
|
104
|
+
return { file, dir: path.dirname(file), sent: true, target: 'device' };
|
|
105
|
+
} catch (e) {
|
|
106
|
+
await bridge.call('set_leaf_execution_target', { onDevice: false }).catch(() => {});
|
|
107
|
+
// Snap's message for "nothing answered" is about a lens not starting; say what it means.
|
|
108
|
+
const notThere = /did not start on the device|no device|not paired|timed out|timeout|abort/i.test(e.message);
|
|
109
|
+
throw new Error(notThere
|
|
110
|
+
? 'No Spectacles answered. Put them on and wake them, pair them in Lens Studio, and keep them on the same Wi-Fi as this computer, then ask again.'
|
|
111
|
+
: e.message);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Make the project folder something a person can open without being told which file to click.
|
|
2
|
+
//
|
|
3
|
+
// A Lens Studio project is a developer's folder: Assets, Packages, Cache, Workspaces, and the
|
|
4
|
+
// agent-tooling files Snap's template ships (.clad, .cursor, AGENTS.md and friends). Someone who
|
|
5
|
+
// asked for a training guide opens that and has no idea what any of it is. So after a build the
|
|
6
|
+
// folder is cut down to the two things that mean something to them: the Lens itself, and a note
|
|
7
|
+
// telling them what to do. Everything else is marked hidden. Nothing is deleted or moved, so Lens
|
|
8
|
+
// Studio still finds every file exactly where it expects.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
|
|
13
|
+
const README = 'Open me first.txt';
|
|
14
|
+
|
|
15
|
+
/** Hide Lens Studio's working files on Windows. Everywhere else this is a no-op. */
|
|
16
|
+
function hide(dir, names) {
|
|
17
|
+
if (process.platform !== 'win32' || !names.length) return 0;
|
|
18
|
+
const sysRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows';
|
|
19
|
+
const attrib = path.join(sysRoot, 'System32', 'attrib.exe');
|
|
20
|
+
if (!fs.existsSync(attrib)) return 0;
|
|
21
|
+
let hidden = 0;
|
|
22
|
+
for (const name of names) {
|
|
23
|
+
try { execFileSync(attrib, ['+H', path.join(dir, name)], { stdio: 'pipe', windowsHide: true }); hidden++; }
|
|
24
|
+
catch { /* a file Lens Studio holds open can refuse; it stays visible, which is not worth failing a build over */ }
|
|
25
|
+
}
|
|
26
|
+
return hidden;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readmeText({ title, esprojName, steps, completionMessage }) {
|
|
30
|
+
const lines = [
|
|
31
|
+
title,
|
|
32
|
+
'='.repeat(title.length),
|
|
33
|
+
'',
|
|
34
|
+
'This folder is your Lens: the step-by-step guide Parley built for you.',
|
|
35
|
+
'',
|
|
36
|
+
'TO OPEN IT',
|
|
37
|
+
` Double-click "${esprojName}". That opens it in Lens Studio.`,
|
|
38
|
+
' Or just ask Parley to open it. You do not have to come here at all.',
|
|
39
|
+
'',
|
|
40
|
+
'TO PUT IT ON YOUR GLASSES',
|
|
41
|
+
' Wake your Spectacles, make sure they are paired in Lens Studio and on',
|
|
42
|
+
' the same Wi-Fi as this computer, then ask Parley to send it to your',
|
|
43
|
+
' glasses. It does the rest.',
|
|
44
|
+
' By hand instead: open it in Lens Studio and press "Send to Spectacles".',
|
|
45
|
+
'',
|
|
46
|
+
'TO CHANGE IT',
|
|
47
|
+
' Tell Parley what to change, in your own words. It rebuilds the whole',
|
|
48
|
+
' Lens and tests it again. You do not edit anything in here by hand.',
|
|
49
|
+
'',
|
|
50
|
+
`WHAT IT GUIDES (${steps.length} step${steps.length === 1 ? '' : 's'})`,
|
|
51
|
+
...steps.map((s, i) => ` ${i + 1}. ${s.instruction}${s.warning ? `\r\n Caution: ${s.warning}` : ''}`),
|
|
52
|
+
];
|
|
53
|
+
if (completionMessage) lines.push('', `AT THE END, THE WEARER SEES`, ` ${completionMessage}`);
|
|
54
|
+
lines.push(
|
|
55
|
+
'',
|
|
56
|
+
'THE REST OF THIS FOLDER',
|
|
57
|
+
' Lens Studio keeps its own working files here. They are hidden because',
|
|
58
|
+
' you never need to touch them. Do not move or rename anything: the Lens',
|
|
59
|
+
' stops opening if you do.',
|
|
60
|
+
'',
|
|
61
|
+
`Built by Parley on ${new Date().toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' })}.`,
|
|
62
|
+
'',
|
|
63
|
+
);
|
|
64
|
+
return lines.join('\r\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Leave the project folder in a state a non-developer can use.
|
|
69
|
+
* Returns what it did, so the build report and the app can say so.
|
|
70
|
+
*/
|
|
71
|
+
export function tidyProjectFolder(projectDir, { title, esproj, steps = [], completionMessage = '' } = {}) {
|
|
72
|
+
const out = { readme: null, hidden: 0, esproj: esproj ? path.basename(esproj) : null };
|
|
73
|
+
if (!projectDir || !fs.existsSync(projectDir)) return out;
|
|
74
|
+
const esprojName = out.esproj || fs.readdirSync(projectDir).find((f) => f.toLowerCase().endsWith('.esproj')) || '';
|
|
75
|
+
out.esproj = esprojName || null;
|
|
76
|
+
|
|
77
|
+
const readmePath = path.join(projectDir, README);
|
|
78
|
+
try {
|
|
79
|
+
fs.writeFileSync(readmePath, readmeText({ title: title || esprojName.replace(/\.esproj$/i, ''), esprojName, steps, completionMessage }), 'utf8');
|
|
80
|
+
out.readme = readmePath;
|
|
81
|
+
} catch { /* a read-only folder is not a reason to fail a build that otherwise passed */ }
|
|
82
|
+
|
|
83
|
+
// Everything except the Lens and the note. Lens Studio reads hidden files exactly as before.
|
|
84
|
+
const keep = new Set([esprojName.toLowerCase(), README.toLowerCase()]);
|
|
85
|
+
const clutter = fs.readdirSync(projectDir).filter((n) => !keep.has(n.toLowerCase()));
|
|
86
|
+
out.hidden = hide(projectDir, clutter);
|
|
87
|
+
return out;
|
|
88
|
+
}
|
package/lens-builder/publish.mjs
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
|
-
import { Bridge, DATA } from './lib/bridge.mjs';
|
|
15
|
+
import { Bridge, DATA, PROJECTS } from './lib/bridge.mjs';
|
|
16
16
|
import { readSubmissionState } from './lib/submission.mjs';
|
|
17
17
|
|
|
18
18
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -21,17 +21,35 @@ const fwd = (p) => p.split(path.sep).join('/');
|
|
|
21
21
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
22
22
|
|
|
23
23
|
/** The .esproj for a lens id, a run folder name, or an explicit path. */
|
|
24
|
+
/**
|
|
25
|
+
* A Lens can be asked for by its id, by the folder the person sees in Documents, or by the name they
|
|
26
|
+
* gave the procedure. All three have to find it, because the name in chat is rarely the id.
|
|
27
|
+
*/
|
|
28
|
+
function findProjectDir(lensId) {
|
|
29
|
+
if (!fs.existsSync(PROJECTS)) return null;
|
|
30
|
+
// Never hand back the caller's spelling: Windows matches paths case-insensitively, so "espresso
|
|
31
|
+
// machine" would resolve and then be shown to the person instead of the folder they actually have.
|
|
32
|
+
const dirs = fs.readdirSync(PROJECTS, { withFileTypes: true }).filter((d) => d.isDirectory() && d.name !== '_templates');
|
|
33
|
+
const exact = dirs.find((d) => d.name === lensId);
|
|
34
|
+
if (exact) return path.join(PROJECTS, exact.name);
|
|
35
|
+
const want = String(lensId).toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
36
|
+
const hit = dirs.find((d) => d.name.toLowerCase().replace(/[^a-z0-9]+/g, '') === want)
|
|
37
|
+
|| dirs.find((d) => d.name.toLowerCase().replace(/[^a-z0-9]+/g, '').includes(want) && want.length > 3);
|
|
38
|
+
return hit ? path.join(PROJECTS, hit.name) : null;
|
|
39
|
+
}
|
|
40
|
+
|
|
24
41
|
export function resolveProject(target) {
|
|
25
42
|
if (!target) return null;
|
|
26
43
|
if (/\.esproj$/i.test(target) && fs.existsSync(target)) return path.resolve(target);
|
|
27
44
|
const asRun = path.join(DATA, 'runs', target, 'metrics.json');
|
|
28
45
|
const lensId = fs.existsSync(asRun) ? JSON.parse(fs.readFileSync(asRun, 'utf8')).lens_id : target;
|
|
29
|
-
const dir =
|
|
30
|
-
|
|
31
|
-
|
|
46
|
+
const dir = findProjectDir(lensId);
|
|
47
|
+
if (!dir) return null;
|
|
48
|
+
const esproj = fs.readdirSync(dir).find((f) => f.toLowerCase().endsWith('.esproj'));
|
|
49
|
+
return esproj ? path.join(dir, esproj) : null;
|
|
32
50
|
}
|
|
33
51
|
|
|
34
|
-
async function openProject(bridge, esproj) {
|
|
52
|
+
export async function openProject(bridge, esproj) {
|
|
35
53
|
const current = await bridge.projectInfo().catch(() => null);
|
|
36
54
|
if (current && fwd(current.file).toLowerCase() === fwd(esproj).toLowerCase()) return current;
|
|
37
55
|
await bridge.eec(`const E: any = (globalThis as any).Editor; const model: any = pluginSystem.findInterface(E.Model.IModel);
|
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, deploy static sites, and build Snap Specs Lenses through Lens Studio, from the terminal with Parley. Built for coding agents: every command has a --json mode.",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"description": "Generate images, video clips with named camera moves, narration, YouTube thumbnails, product ads, complete videos and Studio films, reuse them from your account library, deploy static sites, and build Snap Specs Lenses through Lens Studio, 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": {
|