parley-live 0.4.2 → 0.5.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 +1 -0
- package/dist/index.js +8 -2
- package/dist/lens.js +252 -0
- package/kb/index/bm25.json +1 -0
- package/kb/index/chunks-meta.jsonl +11523 -0
- package/lens-builder/build-from-prompt.mjs +19 -0
- package/lens-builder/build.mjs +147 -0
- package/lens-builder/cli.mjs +103 -0
- package/lens-builder/kb/lib-fetch.mjs +35 -0
- package/lens-builder/kb/search.mjs +61 -0
- package/lens-builder/kb/show.mjs +4 -0
- package/lens-builder/kb/tokenize.mjs +27 -0
- package/lens-builder/lib/bridge.mjs +186 -0
- package/lens-builder/lib/submission.mjs +246 -0
- package/lens-builder/mcp-client.mjs +101 -0
- package/lens-builder/pitch/one-pager.mjs +70 -0
- package/lens-builder/planner.mjs +108 -0
- package/lens-builder/publish.mjs +127 -0
- package/lens-builder/training/author-scene.ts +77 -0
- package/lens-builder/training/compile.mjs +258 -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 +186 -0
- package/lens-builder/webxr/generate.mjs +238 -0
- package/package.json +11 -5
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Verifier: runs the plan section 6.3 checklist against the live Lens Studio preview.
|
|
2
|
+
// status: 'pass' | 'fail' | 'hardware' (cannot be judged in preview; flagged for on-device testing).
|
|
3
|
+
// Nothing is marked pass unless it was actually observed in this run.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { LENS_VERSION } from './training/compile.mjs';
|
|
7
|
+
import { readSubmissionState, SNAP_LENS_NAME_MAX } from './lib/submission.mjs';
|
|
8
|
+
|
|
9
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
10
|
+
const TAG = '[ParleyTG]';
|
|
11
|
+
|
|
12
|
+
function printsOf(logs) {
|
|
13
|
+
return (logs?.prints || []).map((p) => (typeof p === 'string' ? p : p.text || p.message || JSON.stringify(p)));
|
|
14
|
+
}
|
|
15
|
+
function errorsOf(logs) {
|
|
16
|
+
return (logs?.errors || []).map((p) => (typeof p === 'string' ? p : p.text || p.message || JSON.stringify(p)));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Log lines look like "I 11:36:52.645 [Preview 1] ...". Returns the time of day in ms, or null.
|
|
20
|
+
function timeOfDayMs(line) {
|
|
21
|
+
const m = line.match(/^\s*[A-Z]\s+(\d{2}):(\d{2}):(\d{2})\.(\d{3})/);
|
|
22
|
+
return m ? ((+m[1] * 60 + +m[2]) * 60 + +m[3]) * 1000 + +m[4] : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// RunAndCollectLogsTool's refresh mode only returns lines after its reset marker, and a Lens's first OnStart prints
|
|
26
|
+
// can land just before that marker (observed 5.24.0). So: restart the preview with refresh, then read the tail and keep
|
|
27
|
+
// the lines written since the restart was triggered. Lines from earlier sessions are excluded by the timestamp.
|
|
28
|
+
async function printsSince(bridge, sinceDate) {
|
|
29
|
+
const since = ((sinceDate.getHours() * 60 + sinceDate.getMinutes()) * 60 + sinceDate.getSeconds()) * 1000 + sinceDate.getMilliseconds() - 1500;
|
|
30
|
+
const tail = await bridge.tailLogs();
|
|
31
|
+
return printsOf(tail).filter((p) => { const t = timeOfDayMs(p); return t !== null && t >= since; });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function verify({ bridge, plan, form, runDir, iteration, projectDir, submission }) {
|
|
35
|
+
const checks = [];
|
|
36
|
+
const add = (item, status, detail, extra = {}) => checks.push({ item, status, detail, ...extra });
|
|
37
|
+
const n = form.steps.length;
|
|
38
|
+
|
|
39
|
+
// 1. Compiles with zero errors
|
|
40
|
+
const comp = await bridge.recompile();
|
|
41
|
+
add('Project compiles with zero errors', comp.ok && comp.errors.length === 0 ? 'pass' : 'fail', comp.ok ? 'RecompileTypeScriptTool: succeeded' : comp.errors.join(' | ').slice(0, 600) || JSON.stringify(comp.raw).slice(0, 300));
|
|
42
|
+
if (!comp.ok) return { pass: false, iteration, checks };
|
|
43
|
+
|
|
44
|
+
// 2. Start-up logs
|
|
45
|
+
let restartedAt = new Date();
|
|
46
|
+
let logs = await bridge.runAndCollectLogs(5000);
|
|
47
|
+
let prints = [...new Set([...printsOf(logs), ...(await printsSince(bridge, restartedAt))])];
|
|
48
|
+
// A freshly opened project can answer the first refresh before the new script has been picked up; retry briefly.
|
|
49
|
+
for (let attempt = 0; attempt < 3 && prints.filter((p) => p.includes(TAG)).length === 0; attempt++) {
|
|
50
|
+
await sleep(4000);
|
|
51
|
+
restartedAt = new Date();
|
|
52
|
+
logs = await bridge.runAndCollectLogs(6000);
|
|
53
|
+
prints = [...new Set([...printsOf(logs), ...(await printsSince(bridge, restartedAt))])];
|
|
54
|
+
}
|
|
55
|
+
fs.writeFileSync(path.join(runDir, 'preview', `logs-start-${iteration}.json`), JSON.stringify({ restartedAt, prints, refresh: logs }, null, 1));
|
|
56
|
+
const ready = prints.some((p) => p.includes(`${TAG} ready steps=${n}`));
|
|
57
|
+
const step1 = prints.some((p) => p.includes(`${TAG} step=1/${n}`));
|
|
58
|
+
add('Lens starts and shows step 1', ready && step1 ? 'pass' : 'fail', ready && step1 ? `log: "${TAG} ready steps=${n}", "${TAG} step=1/${n}"` : `prints: ${prints.slice(-5).join(' / ') || '(none)'}`, { transient: prints.length === 0 });
|
|
59
|
+
|
|
60
|
+
const version = form.version || LENS_VERSION;
|
|
61
|
+
const versionLogged = prints.some((p) => p.includes(`Lens Opened: v${version}`));
|
|
62
|
+
add('Version is logged on startup and shown on the panel (Snap Spectacles publishing checklist)', versionLogged ? 'pass' : 'fail', versionLogged ? `log: "Lens Opened: v${version}"; panel shows v${version}` : 'startup version line not found in the log');
|
|
63
|
+
|
|
64
|
+
// 3. Every step reachable: press the real buttons in the preview with simulated pinches.
|
|
65
|
+
let reach = { ok: false, detail: 'not attempted' };
|
|
66
|
+
if (ready && step1) reach = await walkFlow(bridge, n, runDir, iteration);
|
|
67
|
+
add('Every step in user_flow is reachable (Done pressed through to completion, Back returns)', reach.ok ? 'pass' : 'fail', reach.detail);
|
|
68
|
+
|
|
69
|
+
// 4. Runtime errors across the whole flow
|
|
70
|
+
const tail = await bridge.tailLogs().catch(() => null);
|
|
71
|
+
const VOICE_ENV = /Login to My Lenses is required|AsrTranscriber|VoiceML|speech::transcribe|GrpcTranscribe|fetch auth token/i;
|
|
72
|
+
const rawErrors = [...new Set([...errorsOf(logs), ...errorsOf(tail)])];
|
|
73
|
+
const voiceEnv = rawErrors.filter((e) => VOICE_ENV.test(e));
|
|
74
|
+
// Attribute errors. An error belongs to the Lens when it comes from the running Lens (framed by [Preview N]),
|
|
75
|
+
// names project files/packages, is a JS exception, or reports a missing asset. Editor housekeeping errors
|
|
76
|
+
// (for example Lens Studio failing to delete its own temp folder) are reported but do not fail the Lens.
|
|
77
|
+
const serious = rawErrors.filter((e) => !VOICE_ENV.test(e)).filter((e) => /^\s*[EF]\b|TypeError|ReferenceError|SyntaxError|Exception|Cannot find asset|\[ParleyTG\] ERROR/.test(e));
|
|
78
|
+
const LENS_ATTRIBUTED = /\[Preview|Assets\/|Packages\/|\.ts\b|\.js\b|\.lspkg|TypeError|ReferenceError|SyntaxError|Exception|Cannot find asset|\[ParleyTG\] ERROR|script/i;
|
|
79
|
+
const allErrors = serious.filter((e) => LENS_ATTRIBUTED.test(e));
|
|
80
|
+
const editorNoise = serious.filter((e) => !LENS_ATTRIBUTED.test(e));
|
|
81
|
+
add('No runtime errors from the Lens during the full user flow', allErrors.length === 0 ? 'pass' : 'fail',
|
|
82
|
+
allErrors.length ? allErrors.slice(0, 3).join(' | ').slice(0, 600)
|
|
83
|
+
: 'no script exceptions, no Lens-attributed E/F lines' + (editorNoise.length ? `. Not attributed to the Lens (${editorNoise.length}): ${editorNoise[0].slice(0, 200)}` : ''));
|
|
84
|
+
|
|
85
|
+
// 5. Assets resolve
|
|
86
|
+
const missing = [...errorsOf(logs), ...errorsOf(tail)].filter((e) => /Cannot find asset|missing reference|Failed to load/i.test(e));
|
|
87
|
+
add('All assets resolve (no missing references)', missing.length === 0 ? 'pass' : 'fail', missing.length ? missing[0].slice(0, 300) : 'no missing-asset diagnostics in the log');
|
|
88
|
+
|
|
89
|
+
// 6. Acceptance tests from the plan map onto the observations above
|
|
90
|
+
const accOk = comp.ok && ready && step1 && versionLogged && reach.ok && allErrors.length === 0;
|
|
91
|
+
add(`All ${plan.acceptance_tests.length} acceptance tests in the plan pass`, accOk ? 'pass' : 'fail', accOk ? 'compile, start-up log, full Done walk, Back, no runtime errors: all observed' : 'one or more observations above failed');
|
|
92
|
+
|
|
93
|
+
// 7. Text within bounds: enforced by form limits + Wrap/Shrink layout rects; screenshot kept as evidence.
|
|
94
|
+
const shot = path.join(runDir, 'preview', `final-${iteration}.png`);
|
|
95
|
+
const shotOk = await bridge.screenshot(shot).catch(() => false);
|
|
96
|
+
add('Text is within display bounds (53x77 cm area at 110 cm; wrap + shrink layout, length-limited form)', shotOk ? 'pass' : 'fail', shotOk ? `layout rects are 52 cm wide; screenshot: ${path.relative(runDir, shot)}` : 'screenshot could not be captured');
|
|
97
|
+
|
|
98
|
+
// 8. Size budget (proxy: bytes of project Assets + Packages)
|
|
99
|
+
const bytes = dirSize(path.join(projectDir, 'Assets')) + dirSize(path.join(projectDir, 'Packages'));
|
|
100
|
+
add('Lens size within the 25 MB limit Snap sets for a published Spectacles Lens', bytes < 25 * 1024 * 1024 ? 'pass' : 'fail', `${(bytes / 1048576).toFixed(1)} MB of project assets + packages on disk (upper bound; the published Lens is compressed)`);
|
|
101
|
+
|
|
102
|
+
// 9. What Snap requires of a submittable Lens, read back from the project rather than assumed.
|
|
103
|
+
const snap = await readSubmissionState(bridge).catch((e) => ({ error: e.message }));
|
|
104
|
+
if (snap.error) {
|
|
105
|
+
add('Ready for Snap submission (name, icon, signing key)', 'fail', snap.error.slice(0, 300));
|
|
106
|
+
} else {
|
|
107
|
+
const nameOk = snap.lensNameValid && snap.lensName.length <= SNAP_LENS_NAME_MAX;
|
|
108
|
+
add(`Lens name Snap accepts (at most ${SNAP_LENS_NAME_MAX} characters, not the template default)`, nameOk ? 'pass' : 'fail',
|
|
109
|
+
nameOk ? `"${snap.lensName}" (${snap.lensName.length} characters)` : `"${snap.lensName}" would be rejected at publish`);
|
|
110
|
+
add('Lens icon set on the project', snap.iconPresent ? 'pass' : 'fail', snap.iconPresent ? '320x320 PNG: ' + String(snap.iconPath).split(/[\\/]/).pop() : 'no icon; Snap requires one');
|
|
111
|
+
add('Production signing key attached', snap.productionKeySet ? 'pass' : 'fail', snap.productionKeySet ? 'ed25519 key set on the project' : 'no signing key; the SnapOS Package cannot be exported without one');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 10. Items preview cannot judge
|
|
115
|
+
if (form.voiceShortcuts !== false) add('Voice shortcuts ("done", "next", "back") recognized reliably', 'hardware', (voiceEnv.length ? 'preview could not start speech recognition (Lens Studio is not logged in to My Lenses); the Lens fell back to buttons as designed. ' : '') + 'ASR needs a microphone and real speech; Snap advises buttons as the dependable path, which the checks above exercised');
|
|
116
|
+
add('Panel comfort and legibility in the real workspace', 'hardware', 'distance, glare and hand reach depend on the wearer and the room');
|
|
117
|
+
add('60 FPS, power and thermal budget', 'hardware', 'Snap requires checking the Lens Performance Overlay and Spectacles Monitor on a real device; the preview cannot measure this');
|
|
118
|
+
add('Signed in to Lens Studio, so the Lens can be exported and submitted', snap.signedIn ? 'pass' : 'hardware',
|
|
119
|
+
snap.signedIn === true ? 'signed in: Snap\'s preflight, the SnapOS Package export and the upload can run'
|
|
120
|
+
: 'not signed in. This is Snap\'s gate, not a missing piece of the build: it blocks their preflight, the package export and the upload. Everything on this side of it (name, icon, signing key) is done.');
|
|
121
|
+
add('Preview image or video for the store listing', 'hardware', 'captured from the Lens running on real glasses; the description, release notes and test list are already written into this report');
|
|
122
|
+
|
|
123
|
+
const pass = checks.every((c) => c.status !== 'fail');
|
|
124
|
+
return { pass, iteration, checks };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// UIKit buttons keep their collider on a child object, which PreviewInteractTool's id-targeted pinch reports as an
|
|
128
|
+
// obstruction (observed 5.24.0). Snap's specs-preview-interaction skill documents the coordinate-targeted fallback:
|
|
129
|
+
// read the live world position, then pinch there.
|
|
130
|
+
async function pressButton(bridge, name) {
|
|
131
|
+
const q = await bridge.call('QueryRuntimeSceneTool', { query: `{ sceneObjects(filter:{nameContains:"${name}", enabledOnly:true}) { matches { summary transform { worldPosition } } } }` });
|
|
132
|
+
const match = (q?.data?.sceneObjects?.matches || []).find((m) => m.summary.name === name);
|
|
133
|
+
if (!match) throw new Error(`button "${name}" is not enabled in the running Lens`);
|
|
134
|
+
return bridge.call('PreviewInteractTool', { action: 'Pinch', worldPosition: match.transform.worldPosition, handType: 'right' });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function lastPrints(bridge) {
|
|
138
|
+
const all = printsOf(await bridge.tailLogs());
|
|
139
|
+
// Only this Lens session counts: drop everything before the most recent start-up line.
|
|
140
|
+
let from = -1;
|
|
141
|
+
all.forEach((p, i) => { if (p.includes(`${TAG} ready steps=`)) from = i; });
|
|
142
|
+
return from >= 0 ? all.slice(from) : all;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function walkFlow(bridge, n, runDir, iteration) {
|
|
146
|
+
const trace = [];
|
|
147
|
+
try {
|
|
148
|
+
for (let k = 2; k <= n; k++) {
|
|
149
|
+
await pressButton(bridge, 'TG_BtnNext');
|
|
150
|
+
await sleep(1200);
|
|
151
|
+
const seen = (await lastPrints(bridge)).some((p) => p.includes(`${TAG} step=${k}/${n}`));
|
|
152
|
+
trace.push(`Done -> step ${k}/${n}: ${seen ? 'seen' : 'NOT seen'}`);
|
|
153
|
+
if (!seen) return { ok: false, detail: trace.join('; ') };
|
|
154
|
+
}
|
|
155
|
+
if (n >= 2) {
|
|
156
|
+
await pressButton(bridge, 'TG_BtnBack');
|
|
157
|
+
await sleep(1200);
|
|
158
|
+
const prints = await lastPrints(bridge);
|
|
159
|
+
const idx = prints.map((p, i) => (p.includes(`${TAG} step=`) ? i : -1)).filter((i) => i >= 0).pop();
|
|
160
|
+
const backOk = idx !== undefined && prints[idx].includes(`${TAG} step=${n - 1}/${n}`);
|
|
161
|
+
trace.push(`Back -> step ${n - 1}/${n}: ${backOk ? 'seen' : 'NOT seen'}`);
|
|
162
|
+
if (!backOk) return { ok: false, detail: trace.join('; ') };
|
|
163
|
+
await pressButton(bridge, 'TG_BtnNext');
|
|
164
|
+
await sleep(1200);
|
|
165
|
+
}
|
|
166
|
+
await pressButton(bridge, 'TG_BtnNext');
|
|
167
|
+
await sleep(1200);
|
|
168
|
+
const done = (await lastPrints(bridge)).some((p) => p.includes(`${TAG} complete`));
|
|
169
|
+
trace.push(`Finish -> complete: ${done ? 'seen' : 'NOT seen'}`);
|
|
170
|
+
fs.writeFileSync(path.join(runDir, 'preview', `flow-${iteration}.txt`), trace.join('\n'));
|
|
171
|
+
return { ok: done, detail: trace.join('; ') };
|
|
172
|
+
} catch (e) {
|
|
173
|
+
trace.push(`interaction failed: ${e.message.slice(0, 400)}`);
|
|
174
|
+
return { ok: false, detail: trace.join('; ') };
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function dirSize(dir) {
|
|
179
|
+
if (!fs.existsSync(dir)) return 0;
|
|
180
|
+
let total = 0;
|
|
181
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
182
|
+
const p = path.join(dir, e.name);
|
|
183
|
+
total += e.isDirectory() ? dirSize(p) : fs.statSync(p).size;
|
|
184
|
+
}
|
|
185
|
+
return total;
|
|
186
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Phase 5: WebXR fallback. Same training form -> one self-contained HTML page that runs in the Specs Browser Lens
|
|
2
|
+
// (WebXR Core + AR module + hand input: https://developers.snap.com/spectacles/about-spectacles-features/webxr)
|
|
3
|
+
// and degrades to an on-page 3D preview and a plain 2D step-through on any other browser.
|
|
4
|
+
// Usage: node lens-builder/webxr/generate.mjs <form.json> [outDir]
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { validateForm, lensIdFor } from '../training/compile.mjs';
|
|
9
|
+
|
|
10
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
11
|
+
const THREE_VERSION = '0.170.0';
|
|
12
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
13
|
+
|
|
14
|
+
export function generateWebXR(form) {
|
|
15
|
+
const errors = validateForm(form);
|
|
16
|
+
if (errors.length) throw new Error('Form is not buildable: ' + errors.join('; '));
|
|
17
|
+
const data = {
|
|
18
|
+
title: form.procedureName,
|
|
19
|
+
steps: form.steps.map((s) => ({ instruction: s.instruction, warning: s.warning || '' })),
|
|
20
|
+
completion: form.completionMessage || 'Procedure complete. Well done.',
|
|
21
|
+
};
|
|
22
|
+
// </script> inside JSON would end the script block early
|
|
23
|
+
const json = JSON.stringify(data).replace(/</g, '\\u003c');
|
|
24
|
+
return `<!doctype html>
|
|
25
|
+
<html lang="en">
|
|
26
|
+
<head>
|
|
27
|
+
<meta charset="utf-8">
|
|
28
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
29
|
+
<title>${esc(form.procedureName)}</title>
|
|
30
|
+
<style>
|
|
31
|
+
:root { color-scheme: dark; }
|
|
32
|
+
* { box-sizing: border-box; }
|
|
33
|
+
body { margin: 0; background: #09090b; color: #f4f4f5; font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
34
|
+
main { max-width: 760px; margin: 0 auto; padding: 24px 16px 48px; }
|
|
35
|
+
h1 { font-size: 1.5rem; margin: 0 0 4px; }
|
|
36
|
+
.muted { color: #a1a1aa; font-size: .9rem; }
|
|
37
|
+
.card { border: 1px solid #27272a; background: #18181b; border-radius: 16px; padding: 20px; margin-top: 16px; }
|
|
38
|
+
#progress { color: #38bdf8; font-size: .85rem; font-weight: 600; letter-spacing: .02em; }
|
|
39
|
+
#instruction { font-size: 1.25rem; margin: 8px 0; min-height: 3.2em; }
|
|
40
|
+
#warning { color: #fbbf24; font-weight: 600; min-height: 1.5em; }
|
|
41
|
+
.row { display: flex; gap: 12px; margin-top: 16px; flex-wrap: wrap; }
|
|
42
|
+
button { font: inherit; font-weight: 600; border-radius: 12px; border: 1px solid #3f3f46; background: #27272a; color: #f4f4f5; padding: 12px 22px; cursor: pointer; }
|
|
43
|
+
button.primary { background: #0ea5e9; border-color: #0ea5e9; color: #09090b; }
|
|
44
|
+
button:disabled { opacity: .4; cursor: default; }
|
|
45
|
+
canvas#preview { width: 100%; aspect-ratio: 16 / 10; display: block; border-radius: 12px; background: #000; touch-action: none; }
|
|
46
|
+
#xrNote { margin-top: 8px; }
|
|
47
|
+
</style>
|
|
48
|
+
</head>
|
|
49
|
+
<body>
|
|
50
|
+
<main>
|
|
51
|
+
<h1>${esc(form.procedureName)}</h1>
|
|
52
|
+
<div class="muted">Hands-free guide, ${form.steps.length} step${form.steps.length === 1 ? '' : 's'}. Built with Parley.</div>
|
|
53
|
+
|
|
54
|
+
<div class="card">
|
|
55
|
+
<button id="enterXR" class="primary" disabled>Start on glasses</button>
|
|
56
|
+
<div id="xrNote" class="muted">Checking this device...</div>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<div class="card">
|
|
60
|
+
<div class="muted" style="margin-bottom:8px">3D preview. Click Done or Back on the panel, the same way a pinch works on glasses.</div>
|
|
61
|
+
<canvas id="preview"></canvas>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div class="card" aria-live="polite">
|
|
65
|
+
<div id="progress"></div>
|
|
66
|
+
<div id="instruction"></div>
|
|
67
|
+
<div id="warning"></div>
|
|
68
|
+
<div class="row">
|
|
69
|
+
<button id="back">Back</button>
|
|
70
|
+
<button id="next" class="primary">Done</button>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</main>
|
|
74
|
+
|
|
75
|
+
<script type="importmap">{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@${THREE_VERSION}/build/three.module.js" } }</script>
|
|
76
|
+
<script type="module">
|
|
77
|
+
import * as THREE from 'three';
|
|
78
|
+
|
|
79
|
+
const GUIDE = ${json};
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------- state (shared by 2D, preview and XR)
|
|
82
|
+
const state = { index: 0, finished: false, lastPressAt: 0 };
|
|
83
|
+
const listeners = [];
|
|
84
|
+
function changed() { listeners.forEach((fn) => fn()); }
|
|
85
|
+
function press(which) {
|
|
86
|
+
const now = performance.now();
|
|
87
|
+
if (now - state.lastPressAt < 600) return; // one press per pinch
|
|
88
|
+
state.lastPressAt = now;
|
|
89
|
+
if (which === 'next') {
|
|
90
|
+
if (state.finished) return;
|
|
91
|
+
if (state.index < GUIDE.steps.length - 1) state.index++; else state.finished = true;
|
|
92
|
+
} else if (!state.finished && state.index > 0) state.index--;
|
|
93
|
+
changed();
|
|
94
|
+
}
|
|
95
|
+
function view() {
|
|
96
|
+
if (state.finished) return { progress: 'Complete', instruction: GUIDE.completion, warning: '', next: 'Finished', canBack: false };
|
|
97
|
+
const s = GUIDE.steps[state.index];
|
|
98
|
+
return { progress: 'Step ' + (state.index + 1) + ' of ' + GUIDE.steps.length, instruction: s.instruction, warning: s.warning ? 'Caution: ' + s.warning : '', next: state.index === GUIDE.steps.length - 1 ? 'Finish' : 'Done', canBack: state.index > 0 };
|
|
99
|
+
}
|
|
100
|
+
window.__guide = { state, press, view }; // test hook
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------- 2D fallback
|
|
103
|
+
const $ = (id) => document.getElementById(id);
|
|
104
|
+
listeners.push(() => {
|
|
105
|
+
const v = view();
|
|
106
|
+
$('progress').textContent = v.progress;
|
|
107
|
+
$('instruction').textContent = v.instruction;
|
|
108
|
+
$('warning').textContent = v.warning;
|
|
109
|
+
$('next').textContent = v.next;
|
|
110
|
+
$('next').disabled = state.finished;
|
|
111
|
+
$('back').disabled = !v.canBack;
|
|
112
|
+
});
|
|
113
|
+
$('next').addEventListener('click', () => press('next'));
|
|
114
|
+
$('back').addEventListener('click', () => press('back'));
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------- panel (canvas texture) used by preview and XR
|
|
117
|
+
const PANEL = { w: 0.62, h: 0.44, z: -1.1, y: -0.04 }; // metres: 110 cm away, slightly below eye level (Snap spatial design guidance)
|
|
118
|
+
const BTN = { w: 0.16, h: 0.06, y: -0.15, x: 0.13 }; // 6 cm tall targets
|
|
119
|
+
|
|
120
|
+
function wrap(ctx, text, maxWidth) {
|
|
121
|
+
const words = text.split(/\\s+/); const lines = []; let line = '';
|
|
122
|
+
for (const w of words) { const t = line ? line + ' ' + w : w; if (ctx.measureText(t).width > maxWidth && line) { lines.push(line); line = w; } else line = t; }
|
|
123
|
+
if (line) lines.push(line);
|
|
124
|
+
return lines;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function makePanel() {
|
|
128
|
+
const canvas = document.createElement('canvas'); canvas.width = 1240; canvas.height = 880;
|
|
129
|
+
const ctx = canvas.getContext('2d');
|
|
130
|
+
const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace;
|
|
131
|
+
const group = new THREE.Group();
|
|
132
|
+
const panel = new THREE.Mesh(new THREE.PlaneGeometry(PANEL.w, PANEL.h), new THREE.MeshBasicMaterial({ map: texture, transparent: true }));
|
|
133
|
+
group.add(panel);
|
|
134
|
+
const mk = (name, x) => { const m = new THREE.Mesh(new THREE.PlaneGeometry(BTN.w, BTN.h), new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false })); m.position.set(x, BTN.y, 0.002); m.name = name; group.add(m); return m; };
|
|
135
|
+
const buttons = [mk('back', -BTN.x), mk('next', BTN.x)];
|
|
136
|
+
group.position.set(0, PANEL.y, PANEL.z);
|
|
137
|
+
|
|
138
|
+
function draw() {
|
|
139
|
+
const v = view(); const W = canvas.width, H = canvas.height, px = W / PANEL.w;
|
|
140
|
+
ctx.clearRect(0, 0, W, H);
|
|
141
|
+
ctx.fillStyle = 'rgba(24,24,27,0.92)'; ctx.beginPath(); ctx.roundRect(0, 0, W, H, 40); ctx.fill();
|
|
142
|
+
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
143
|
+
ctx.fillStyle = '#f4f4f5'; ctx.font = '700 54px system-ui, sans-serif'; ctx.fillText(GUIDE.title, W / 2, 90, W - 120);
|
|
144
|
+
ctx.fillStyle = '#38bdf8'; ctx.font = '600 38px system-ui, sans-serif'; ctx.fillText(v.progress, W / 2, 165);
|
|
145
|
+
ctx.fillStyle = '#f4f4f5'; ctx.font = '500 44px system-ui, sans-serif';
|
|
146
|
+
const lines = wrap(ctx, v.instruction, W - 160).slice(0, 5);
|
|
147
|
+
lines.forEach((l, i) => ctx.fillText(l, W / 2, 330 - (lines.length - 1) * 28 + i * 56));
|
|
148
|
+
if (v.warning) { ctx.fillStyle = '#fbbf24'; ctx.font = '700 38px system-ui, sans-serif'; wrap(ctx, v.warning, W - 160).slice(0, 2).forEach((l, i) => ctx.fillText(l, W / 2, 530 + i * 46)); }
|
|
149
|
+
const drawBtn = (cx, label, primary, enabled) => {
|
|
150
|
+
if (!enabled) return;
|
|
151
|
+
const bw = BTN.w * px, bh = BTN.h * px, x = W / 2 + cx * px - bw / 2, y = H / 2 - BTN.y * px - bh / 2;
|
|
152
|
+
ctx.fillStyle = primary ? '#0ea5e9' : '#3f3f46'; ctx.beginPath(); ctx.roundRect(x, y, bw, bh, 24); ctx.fill();
|
|
153
|
+
ctx.fillStyle = primary ? '#09090b' : '#f4f4f5'; ctx.font = '600 40px system-ui, sans-serif'; ctx.fillText(label, x + bw / 2, y + bh / 2 + 2);
|
|
154
|
+
};
|
|
155
|
+
drawBtn(-BTN.x, 'Back', false, v.canBack);
|
|
156
|
+
drawBtn(BTN.x, v.next, true, !state.finished);
|
|
157
|
+
buttons[0].visible = v.canBack; buttons[1].visible = !state.finished;
|
|
158
|
+
texture.needsUpdate = true;
|
|
159
|
+
}
|
|
160
|
+
return { group, buttons, draw };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const raycaster = new THREE.Raycaster();
|
|
164
|
+
function hitButton(buttons) {
|
|
165
|
+
const hit = raycaster.intersectObjects(buttons.filter((b) => b.visible), false)[0];
|
|
166
|
+
return hit ? hit.object.name : null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------- on-page 3D preview (mouse / touch)
|
|
170
|
+
const previewCanvas = $('preview');
|
|
171
|
+
const renderer = new THREE.WebGLRenderer({ canvas: previewCanvas, antialias: true, alpha: true });
|
|
172
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
173
|
+
const scene = new THREE.Scene();
|
|
174
|
+
const camera = new THREE.PerspectiveCamera(36, 16 / 10, 0.05, 20);
|
|
175
|
+
const ui = makePanel();
|
|
176
|
+
scene.add(ui.group);
|
|
177
|
+
listeners.push(ui.draw);
|
|
178
|
+
|
|
179
|
+
function resize() {
|
|
180
|
+
const w = previewCanvas.clientWidth, h = previewCanvas.clientHeight;
|
|
181
|
+
if (previewCanvas.width !== Math.floor(w * renderer.getPixelRatio())) { renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); }
|
|
182
|
+
}
|
|
183
|
+
previewCanvas.addEventListener('pointerdown', (e) => {
|
|
184
|
+
if (renderer.xr.isPresenting) return;
|
|
185
|
+
const r = previewCanvas.getBoundingClientRect();
|
|
186
|
+
raycaster.setFromCamera(new THREE.Vector2(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1), camera);
|
|
187
|
+
const name = hitButton(ui.buttons);
|
|
188
|
+
if (name) press(name);
|
|
189
|
+
});
|
|
190
|
+
renderer.setAnimationLoop(() => { if (!renderer.xr.isPresenting) resize(); renderer.render(scene, camera); });
|
|
191
|
+
|
|
192
|
+
// ---------------------------------------------------------------- WebXR (immersive-ar). A pinch arrives as a 'select' event.
|
|
193
|
+
renderer.xr.enabled = true;
|
|
194
|
+
const tmpMatrix = new THREE.Matrix4();
|
|
195
|
+
for (let i = 0; i < 2; i++) {
|
|
196
|
+
const controller = renderer.xr.getController(i);
|
|
197
|
+
controller.addEventListener('select', () => {
|
|
198
|
+
tmpMatrix.identity().extractRotation(controller.matrixWorld);
|
|
199
|
+
raycaster.ray.origin.setFromMatrixPosition(controller.matrixWorld);
|
|
200
|
+
raycaster.ray.direction.set(0, 0, -1).applyMatrix4(tmpMatrix);
|
|
201
|
+
const name = hitButton(ui.buttons);
|
|
202
|
+
if (name) press(name);
|
|
203
|
+
});
|
|
204
|
+
scene.add(controller);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function setupXR() {
|
|
208
|
+
const note = $('xrNote'), btn = $('enterXR');
|
|
209
|
+
if (!navigator.xr) { note.textContent = 'This browser has no WebXR. Use the preview or the buttons below, or open this page in the Browser Lens on Specs.'; return; }
|
|
210
|
+
const ok = await navigator.xr.isSessionSupported('immersive-ar').catch(() => false);
|
|
211
|
+
if (!ok) { note.textContent = 'This device cannot show AR. Use the preview or the buttons below, or open this page in the Browser Lens on Specs.'; return; }
|
|
212
|
+
note.textContent = 'Pinch Done to move on. Use the palm button to leave.';
|
|
213
|
+
btn.disabled = false;
|
|
214
|
+
btn.addEventListener('click', async () => {
|
|
215
|
+
try {
|
|
216
|
+
const session = await navigator.xr.requestSession('immersive-ar', { optionalFeatures: ['hand-tracking', 'local-floor'] });
|
|
217
|
+
await renderer.xr.setSession(session);
|
|
218
|
+
session.addEventListener('end', () => { note.textContent = 'Session ended. You can start again.'; });
|
|
219
|
+
} catch (err) { note.textContent = 'Could not start AR: ' + err.message; }
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
changed();
|
|
224
|
+
setupXR();
|
|
225
|
+
</script>
|
|
226
|
+
</body>
|
|
227
|
+
</html>
|
|
228
|
+
`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
232
|
+
const form = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
|
|
233
|
+
const outDir = process.argv[3] || path.join(ROOT, 'webxr-out', lensIdFor(form));
|
|
234
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
235
|
+
const file = path.join(outDir, 'index.html');
|
|
236
|
+
fs.writeFileSync(file, generateWebXR(form));
|
|
237
|
+
console.log(file);
|
|
238
|
+
}
|
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,
|
|
3
|
+
"version": "0.5.1",
|
|
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.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"dist",
|
|
12
12
|
"README.md",
|
|
13
|
-
"LICENSE"
|
|
13
|
+
"LICENSE",
|
|
14
|
+
"lens-builder",
|
|
15
|
+
"kb/index"
|
|
14
16
|
],
|
|
15
17
|
"engines": {
|
|
16
18
|
"node": ">=20"
|
|
@@ -18,7 +20,8 @@
|
|
|
18
20
|
"scripts": {
|
|
19
21
|
"build": "tsc -p tsconfig.json",
|
|
20
22
|
"test": "npm run build && node test/cli.test.mjs",
|
|
21
|
-
"prepublishOnly": "npm run build"
|
|
23
|
+
"prepublishOnly": "npm run build && npm run stage:lens",
|
|
24
|
+
"stage:lens": "node scripts/stage-lens-builder.cjs"
|
|
22
25
|
},
|
|
23
26
|
"devDependencies": {
|
|
24
27
|
"@types/node": "^22.0.0",
|
|
@@ -30,6 +33,9 @@
|
|
|
30
33
|
"video",
|
|
31
34
|
"image",
|
|
32
35
|
"cli",
|
|
33
|
-
"agent-skills"
|
|
36
|
+
"agent-skills",
|
|
37
|
+
"snap-specs",
|
|
38
|
+
"lens-studio",
|
|
39
|
+
"ar"
|
|
34
40
|
]
|
|
35
41
|
}
|