symbiot 0.63.5 → 0.63.7

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/capture.mjs ADDED
@@ -0,0 +1,761 @@
1
+ // symbiot — Marketing's media capture: pictures and short clips of a product, taken by
2
+ // Symbiot itself for its pick tray (tray.mjs), so a post needs no demo video. Most people
3
+ // don't have one. A web app is opened in a browser of its own (a fresh profile: nothing
4
+ // you're signed in to shows) at the screens its code and README name (screensOf); a
5
+ // desktop app's window is recorded (captureDesktop). Before anything reaches the tray,
6
+ // whatever could get you into trouble is found and blurred: names, emails, phone numbers,
7
+ // addresses, API keys, tokens and secrets, account and card numbers, what's typed into
8
+ // forms, notifications and chats, avatars and pictures (faces), and text inside pictures
9
+ // (read with tesseract, where it's installed). Each capture keeps its boxes in its
10
+ // .blur.json, all switched on, so the Marketing page shows what was blurred and you can
11
+ // switch any off, or draw one of your own (tray.mjs addBlur). The unblurred original
12
+ // stays in the lane's .symbiot/tray-originals/<product>/, never served, never posted.
13
+ //
14
+ // Symbiot itself is captured from a copy with made-up work in it (demo.mjs: Acme), started
15
+ // for the capture and stopped after it, never your own app with your work in it: so its
16
+ // tray can be brought up to date after every release in one go.
17
+ //
18
+ // `symbiot capture <product> [--url <its address>] [--window "<title>"]` runs it, from
19
+ // the app (index.mjs, server.mjs /api/marketing/capture): the marketing lane's agent
20
+ // calls it on a product's first run (marketing.mjs marketingBrief), instead of writing
21
+ // capture scripts of its own, which Symbiot won't run with the app's access.
22
+ import { join, relative, basename } from "node:path";
23
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, mkdtempSync, rmSync } from "node:fs";
24
+ import { tmpdir } from "node:os";
25
+ import { spawn, spawnSync } from "node:child_process";
26
+ import { MARKETING_DIR, displayName } from "./marketing.mjs";
27
+ import { MARKETING } from "./handover.mjs";
28
+ import { scratchPage, openPage, evaluate, SCROLLER as SCROLLER_JS } from "./headless.mjs";
29
+ import { renderCapture } from "./tray.mjs";
30
+ import { CONFIG_DIR, hasCmd } from "./core.mjs";
31
+ import { desktopNotify } from "./desktop.mjs";
32
+ import { laneMap as laneMapNow } from "./scan.mjs";
33
+ import { startDemo, DEMO_NAMES } from "./demo.mjs";
34
+
35
+ const VIEW = { w: 1440, h: 900 };
36
+ const MAX_SCREENS = 8, MAX_CLIPS = 3, CLIP_SECONDS = 6, CLIP_FPS = 12;
37
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
38
+ const readJson = (f) => { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return null; } };
39
+ const slug = (s) => String(s || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
40
+
41
+ // ---- the key screens ----------------------------------------------------------------
42
+ // A product's screens, from the routes in its code (file-based routers: Next, Nuxt,
43
+ // SvelteKit, Astro, Remix; route tables: Express and the like, React and Vue Router,
44
+ // Flask, FastAPI, Django, Rails; plain .html pages) and the addresses its README gives.
45
+ // Not a route with a parameter (/users/:id), nor one that isn't a screen (/api, /login,
46
+ // /webhook, a file). The README's first, then the shallowest.
47
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "out", ".next", ".nuxt", ".output", ".svelte-kit", ".vercel", "vendor", "coverage", ".symbiot", "target", "venv", ".venv", "env", "__pycache__", "android", "ios", "Pods", "tmp", ".cache", "test", "tests", "__tests__", "e2e", "cypress", "fixtures", "migrations"]);
48
+ const CODE = /\.(?:m?[jt]sx?|cjs|vue|svelte|astro|mdx?|py|rb|html?)$/i;
49
+ const NOT_A_SCREEN = /^\/(?:api|auth|oauth2?|login|log-?out|sign-?(?:in|up|out)|register|callback|webhooks?|hooks?|health|healthz|status|static|assets|public|_[\w-]*|favicon[\w.-]*|robots\.txt|sitemap[\w.-]*|graphql|metrics|ws|socket(?:\.io)?)(?:\/|$)|\.(?!html?$)[a-z0-9]{1,5}$/i;
50
+ const PARAM = /[:*{}[\]<>$]|\(\.\*\)/;
51
+ function routeOf(p) {
52
+ let s = String(p || "").trim().split(/[?#]/)[0];
53
+ if (!s) return "/";
54
+ if (!s.startsWith("/")) s = "/" + s;
55
+ s = s.replace(/\/{2,}/g, "/").replace(/\/index(?:\.html?)?$/i, "/").replace(/(.)\/$/, "$1");
56
+ if (PARAM.test(s) || NOT_A_SCREEN.test(s) || /\s/.test(s) || s.length > 80) return "";
57
+ return s;
58
+ }
59
+ function sourceFiles(root, { max = 4000, depth = 8 } = {}) {
60
+ const out = [];
61
+ const walk = (dir, d) => {
62
+ if (out.length >= max || d > depth) return;
63
+ let ents = []; try { ents = readdirSync(dir, { withFileTypes: true }); } catch { return; }
64
+ for (const e of ents) {
65
+ if (out.length >= max) return;
66
+ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name) && !e.name.startsWith(".")) walk(join(dir, e.name), d + 1); }
67
+ else if (e.isFile() && CODE.test(e.name)) out.push(relative(root, join(dir, e.name)).split("\\").join("/"));
68
+ }
69
+ };
70
+ walk(root, 0);
71
+ return out;
72
+ }
73
+ // a file-based router's file, as its route: "" when it isn't one
74
+ const segs = (s) => s.split("/").filter((x) => x && !/^\(.*\)$/.test(x) && !x.startsWith("@")); // route groups and slots aren't in the address
75
+ function fileRoute(rel) {
76
+ let m; const at = "^(?:.*?\\/)?"; // in a monorepo, the front end is a folder down (apps/web/, frontend/)
77
+ if ((m = rel.match(new RegExp(at + "(?:src\\/)?pages\\/(.+)\\.(?:m?[jt]sx?|mdx?|vue|astro)$")))) { if (/^(?:api\/|_|404|500)/.test(m[1])) return ""; return "/" + segs(m[1].replace(/(?:^|\/)index$/, "")).join("/"); }
78
+ if ((m = rel.match(new RegExp(at + "(?:src\\/)?app\\/(?:(.*)\\/)?page\\.(?:m?[jt]sx?|mdx)$")))) return "/" + segs(m[1] || "").join("/");
79
+ if ((m = rel.match(new RegExp(at + "src\\/routes\\/(?:(.*)\\/)?\\+page\\.(?:svelte|[jt]s)$")))) return "/" + segs(m[1] || "").join("/");
80
+ if ((m = rel.match(new RegExp(at + "app\\/routes\\/([^/]+)\\.[jt]sx?$")))) { const p = m[1].replace(/(?:^|\.)_index$/, "").split(".").filter((x) => x && !x.startsWith("_")); return "/" + p.join("/"); }
81
+ if ((m = rel.match(/^(?:(?:.*?\/)?(?:public|static)\/)?([\w-]+)\.html?$/))) return m[1] === "index" ? "/" : "/" + m[1] + ".html";
82
+ return "";
83
+ }
84
+ // a server's GET routes are screens only where it renders a page there (an API's answer JSON)
85
+ const SERVER_ROUTES = [/\b(?:app|router|server|routes)\s*\.\s*(?:get|route)\(\s*(['"`])(\/[^'"`\s]*)\1/g, /@\w+\.(?:route|get)\(\s*(['"])(\/[^'"]*)\1/g]; // Express, Koa, Fastify, Hono; Flask, FastAPI
86
+ const RENDERS = /\bres\.render\(|\bsendFile\(|\breply\.view\(|\bc\.html\(|\brender_template\(|\bTemplateResponse\(|\bHTMLResponse\b|text\/html/;
87
+ const JSX_ROUTE = /<Route\b[^>]*?\bpath\s*=\s*\{?\s*(['"`])([^'"`]+)\1/g; // React Router
88
+ const ROUTER_KEY = /\bpath\s*:\s*(['"`])(\/?[\w\-/]*)\1/g; // Vue Router, React Router's objects, Angular
89
+ function codeRoutes(root, { files = sourceFiles(root) } = {}) {
90
+ const routes = new Set();
91
+ const add = (p) => { const r = routeOf(p); if (r) routes.add(r); };
92
+ // a file-based router's routes are the real ones: a table's path keys there are menu items, often relative
93
+ const byFile = files.map(fileRoute).filter((r) => r && r !== "/" && !r.endsWith(".html")).length > 0;
94
+ for (const rel of files) {
95
+ const fr = fileRoute(rel); if (fr) add(fr);
96
+ let text = ""; try { const f = join(root, rel); if (statSync(f).size > 400000) continue; text = readFileSync(f, "utf8"); } catch { continue; }
97
+ if (RENDERS.test(text)) for (const re of SERVER_ROUTES) for (const m of text.matchAll(re)) add(m[2]);
98
+ for (const m of text.matchAll(JSX_ROUTE)) add(m[2]);
99
+ if (!byFile && /\b(?:createBrowserRouter|createRouter|RouterModule|routes)\b/.test(text)) for (const m of text.matchAll(ROUTER_KEY)) add(m[2]);
100
+ if (/(?:^|\/)urls\.py$/.test(rel)) for (const m of text.matchAll(/\b(?:re_)?path\(\s*r?(['"])([\w\-/]*)\1/g)) add("/" + m[2]);
101
+ if (/(?:^|\/)config\/routes\.rb$/.test(rel)) { for (const m of text.matchAll(/^\s*get\s+['"]([\w\-/]+)['"]/gm)) add(m[1]); for (const m of text.matchAll(/^\s*resources?\s+:(\w+)/gm)) add("/" + m[1]); }
102
+ }
103
+ return [...routes];
104
+ }
105
+ function readmeOf(root) {
106
+ let ents = []; try { ents = readdirSync(root); } catch {}
107
+ const f = ents.find((x) => /^readme(?:\.md|\.markdown|\.txt)?$/i.test(x));
108
+ try { return f ? readFileSync(join(root, f), "utf8") : ""; } catch { return ""; }
109
+ }
110
+ // the addresses a README gives: its localhost links, and a path in backticks (`/dashboard`)
111
+ function readmePaths(text) {
112
+ const out = [];
113
+ for (const m of String(text || "").matchAll(/https?:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?(\/[^\s)"'`<>]*)?/g)) out.push([m.index, m[1] || "/"]);
114
+ // not an API call ("POST /send", "`GET /status`")
115
+ for (const m of String(text || "").matchAll(/(\b(?:GET|POST|PUT|PATCH|DELETE)\s+)?`(?:(?:GET|POST|PUT|PATCH|DELETE)\s+)?(\/[a-z][\w\-/]*)`/gi)) if (!m[1] && !/`(?:GET|POST|PUT|PATCH|DELETE)\s/i.test(m[0]) && !/^\/(?:usr|etc|home|tmp|var|opt|bin|Users|dev|proc)\b/.test(m[2])) out.push([m.index, m[2]]);
116
+ return out.sort((a, b) => a[0] - b[0]).map((x) => routeOf(x[1])).filter(Boolean); // in the README's order
117
+ }
118
+ const words = (p) => p.split("/").filter(Boolean).pop() || "";
119
+ const title = (p) => { const w = words(p).replace(/\.html?$/, "").replace(/[-_]+/g, " ").trim(); return w ? w.charAt(0).toUpperCase() + w.slice(1) : "Home"; };
120
+ // Symbiot's own screens: one page, each opened by its #name (ui.mjs lqDeep), from the made-up
121
+ // copy (demo.mjs). Home first: what only you can do, the agents' questions.
122
+ const SYMBIOT_SCREENS = [
123
+ { path: "/", name: "home", screen: "Home", caption: "Symbiot: Home, what only you can do, each agent's question with two answers" },
124
+ { path: "#tasks", name: "workdesk", screen: "Workdesk", caption: "Symbiot: the Workdesk, agents at work on every project" },
125
+ { path: "#board", name: "dashboard", screen: "Dashboard", caption: "Symbiot: the Dashboard, what arrived and which of it needs you" },
126
+ { path: "#week", name: "week", screen: "Week", caption: "Symbiot: the week, written up from git" },
127
+ { path: "#map", name: "map", screen: "Map", caption: "Symbiot: the Map, every repo and what needs you first" },
128
+ { path: "#marketing", name: "marketing", screen: "Marketing", caption: "Symbiot: Marketing, a drafted post waiting for your OK" },
129
+ ].map((x) => ({ ...x, from: "symbiot" }));
130
+ // Before each picture of it: its version without "· sandbox", which the copy shows by it
131
+ const SYMBIOT_TIDY = "(() => { const v = document.getElementById('ver'); if (v) v.textContent = v.textContent.replace(/\\s*·\\s*sandbox$/, ''); })()";
132
+ // A screen's address: its route, or on a one-page app its #name, with the address's own
133
+ // ?token kept (and a ?shot= of its own, so each is a fresh load, not a jump on one page).
134
+ function screenUrl(base, path, i) {
135
+ const q = base.includes("?");
136
+ if (String(path).startsWith("#")) return `${base}${q ? "&" : "?"}shot=${i}${path}`;
137
+ return q && path === "/" ? base : base + (path === "/" ? "/" : path);
138
+ }
139
+ // What the README says about a screen: the line that names it, plain. Else its name.
140
+ function captionFor(path, readme, product) {
141
+ const lines = String(readme || "").split(/\r?\n/).map((l) => l.replace(/^\s*(?:#+|[-*+]|\d+[.)])\s*/, "").replace(/[*_`[\]]|\(https?:[^)]*\)/g, "").trim()).filter((l) => l.length > 8 && l.length < 220 && !/^[<!|]/.test(l));
142
+ const w = words(path).replace(/\.html?$/, "").replace(/[-_]+/g, " ").trim();
143
+ if (!w) { const tag = lines.find((l) => !/^#/.test(l) && /[a-z]/.test(l) && l.split(/\s+/).length >= 4 && !/[─│└┌┐┘├┤►◄═║]/.test(l)); return `${product}: ${tag ? clip(tag) : "its first screen"}`; }
144
+ // a line that says something about it: words, not the path alone, a table or a drawing
145
+ const says = (l) => !/[─│└┌┐┘├┤►◄═║]|[={}]/.test(l) && l.replace(/\S*\/\S*/g, "").split(/\s+/).filter((x) => /[a-z]{2}/i.test(x)).length >= 4;
146
+ const hit = lines.find((l) => says(l) && new RegExp(`\\b${w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}s?\\b`, "i").test(l));
147
+ return `${product}: ${hit ? clip(hit) : `the ${title(path)} screen`}`;
148
+ }
149
+ const clip = (s, n = 110) => { s = String(s).replace(/\s+/g, " ").trim(); return s.length > n ? s.slice(0, n - 1).replace(/\s+\S*$/, "") + "…" : s; };
150
+ // [{ path, name, caption, from: readme|routes }], the home screen first, at most max.
151
+ function screensOf(root, { product = basename(root), max = MAX_SCREENS, extra = [], files } = {}) {
152
+ files = files || sourceFiles(root);
153
+ const readme = readmeOf(root), code = codeRoutes(root, { files }), told = readmePaths(readme), inCode = new Set(code);
154
+ const depth = (p) => p.split("/").filter(Boolean).length;
155
+ // what a product shows first: its README's screens, then its dashboard and the like, then the shallowest
156
+ const key = (p) => (/\/(?:dashboard|overview|home|app|inbox|projects?|analytics|reports?|agents?|workspace)$/i.test(p) ? 1 : 0);
157
+ const page = (p) => (p.endsWith(".html") ? 1 : 0); // a static page (terms, privacy) after the app's own
158
+ const ranked = [...code].sort((a, b) => (told.includes(b) ? 1 : 0) - (told.includes(a) ? 1 : 0) || page(a) - page(b) || key(b) - key(a) || depth(a) - depth(b) || a.length - b.length || (a < b ? -1 : 1));
159
+ // a path only its README gives counts when there's a front end to show it (not an API's endpoints)
160
+ const ui = files.some((f) => /\.(?:html?|[jt]sx|vue|svelte|astro)$/i.test(f));
161
+ const all = [...new Set(["/", ...extra.map(routeOf).filter(Boolean), ...told.filter((p) => inCode.has(p) || p === "/" || (!code.length && ui)), ...ranked])];
162
+ return all.slice(0, max).map((path) => ({ path, name: slug(words(path).replace(/\.html?$/, "")) || "home", caption: captionFor(path, readme, product), from: told.includes(path) ? "readme" : path === "/" ? "home" : "routes" }));
163
+ }
164
+ // A seed or demo script it has (package.json): captures with made-up data, not real.
165
+ function seedScript(root) {
166
+ const s = (readJson(join(root, "package.json")) || {}).scripts || {};
167
+ const k = Object.keys(s).find((x) => /^(?:db:)?seed$|demo|fixtures?/i.test(x));
168
+ return k ? `npm run ${k}` : "";
169
+ }
170
+
171
+ // ---- what's private ---------------------------------------------------------------------
172
+ // The kinds found in text, in the page (DETECT) and in what tesseract reads off a picture
173
+ // (ocrBoxes) alike. A card number counts only when its digits pass the Luhn check.
174
+ const TEXT_KINDS = [
175
+ ["email", "[\\w.+-]+@[\\w-]+(?:\\.[\\w-]+)+", "g"],
176
+ ["phone", "\\+?\\(?\\d[\\d ()-]{8,}\\d", "g"],
177
+ ["secret", "\\b(?:sk|pk|rk)[-_](?:live|test|proj)?[-_]?[\\w-]{12,}|\\bgh[pousr]_\\w{20,}|\\bxox[abposr]-[\\w-]{10,}|\\bAKIA[0-9A-Z]{12,}|\\bAIza[\\w-]{30,}|\\beyJ[\\w-]{8,}\\.[\\w-]{8,}\\.[\\w-]{8,}|\\b[a-f0-9]{32,}\\b|\\b[A-Za-z0-9_-]{40,}\\b", "g"],
178
+ ["key", "\\b(?:api[_-]?key|token|secret|password|passwd|pwd|bearer)\\b\\s*[:=]\\s*\\S{6,}", "gi"],
179
+ ["card", "\\b(?:\\d[ -]?){13,19}\\b", "g"],
180
+ ["iban", "\\b[A-Z]{2}\\d{2}(?: ?[A-Z0-9]{4}){3,7}(?: ?[A-Z0-9]{1,3})?\\b", "g"],
181
+ ["account", "\\b(?:acct|cus|sub|pi|ch|in|txn|ord)_[A-Za-z0-9]{6,}\\b|\\b\\d{9,}\\b", "g"],
182
+ ["path", "(?:\\/home|\\/Users|C:\\\\Users)[\\\\/][^\\s\"'<>]+|~\\/[\\w./-]+", "g"],
183
+ ["url token", "[?&#](?:t|token|key|code|sig|signature|auth|access_token|session)=[\\w.%-]{8,}", "gi"],
184
+ ["ip address", "\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b", "g"],
185
+ ["address", "\\b\\d{1,5}\\s+(?:[A-Z][a-z]+\\s){1,3}(?:Street|St|Road|Rd|Avenue|Ave|Lane|Ln|Drive|Dr|Boulevard|Blvd|Way|Close|Crescent|Court|Ct|Place|Pl)\\b\\.?", "g"],
186
+ ];
187
+ const luhn = (s) => { const d = String(s).replace(/\D/g, ""); if (d.length < 13 || d.length > 19) return false; let sum = 0; for (let i = 0; i < d.length; i++) { let n = +d[d.length - 1 - i]; if (i % 2) { n *= 2; if (n > 9) n -= 9; } sum += n; } return sum % 10 === 0; };
188
+ const isDate = (s) => /^\d{1,4}[-/.]\d{1,2}[-/.]\d{1,4}$|^\d{4}\s*-\s*\d{2}/.test(String(s).trim());
189
+ // the matches in one line of text: [{ kind, at, end, text }]; names: people's names to blur
190
+ function textHits(text, names = []) {
191
+ const out = [], t = String(text || "");
192
+ const kinds = [...TEXT_KINDS.map(([k, src, fl]) => [k, new RegExp(src, fl)])];
193
+ if (names.length) kinds.push(["name", new RegExp("\\b(?:" + names.map((n) => n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|") + ")\\b", "gi")]);
194
+ for (const [kind, re] of kinds) for (const m of t.matchAll(re)) {
195
+ if (kind === "phone" && (isDate(m[0]) || m[0].replace(/\D/g, "").length < 9)) continue;
196
+ if (kind === "card" && !luhn(m[0])) continue;
197
+ if (kind === "ip address" && m[0].split(".").some((x) => +x > 255)) continue;
198
+ out.push({ kind, at: m.index, end: m.index + m[0].length, text: m[0] });
199
+ }
200
+ return out;
201
+ }
202
+ const hint = (s) => { s = String(s || ""); return s.length > 4 ? s.slice(0, 2) + "…" + s.slice(-2) : "…"; };
203
+ // In the page: the boxes to blur, in CSS pixels, each { kind, x, y, w, h, hint }. Runs
204
+ // there as it is, so it carries its own copy of the kinds above.
205
+ const DETECT = (names, { drawings = true } = {}) => `(() => {
206
+ const KINDS = ${JSON.stringify(TEXT_KINDS)}, NAMES = ${JSON.stringify(names)};
207
+ const luhn = ${luhn.toString()}, isDate = ${isDate.toString()};
208
+ const kinds = KINDS.map(([k, s, f]) => [k, new RegExp(s, f)]);
209
+ if (NAMES.length) kinds.push(["name", new RegExp("\\\\b(?:" + NAMES.map((n) => n.replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&")).join("|") + ")\\\\b", "gi")]);
210
+ const out = [], W = innerWidth, H = innerHeight;
211
+ const seen = (b) => b.width > 1 && b.height > 1 && b.bottom > 0 && b.right > 0 && b.top < H && b.left < W;
212
+ const shows = (el) => { for (let e = el; e && e.nodeType === 1; e = e.parentElement) { const st = getComputedStyle(e); if (st.visibility === "hidden" || st.display === "none" || +st.opacity === 0) return false; } return true; };
213
+ const hint = (s) => s.length > 4 ? s.slice(0, 2) + "…" + s.slice(-2) : "…";
214
+ const box = (kind, b, h) => out.push({ kind, x: b.left, y: b.top, w: b.width, h: b.height, hint: h || "" });
215
+ // text: every match of a kind, as the boxes its characters take up
216
+ const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT);
217
+ for (let n; (n = walker.nextNode());) {
218
+ const t = n.nodeValue; if (!t || !t.trim() || !n.parentElement || !shows(n.parentElement)) continue;
219
+ for (const [kind, re] of kinds) {
220
+ re.lastIndex = 0;
221
+ for (let m; (m = re.exec(t));) {
222
+ if (!m[0]) { re.lastIndex++; continue; }
223
+ if (kind === "phone" && (isDate(m[0]) || m[0].replace(/\\D/g, "").length < 9)) continue;
224
+ if (kind === "card" && !luhn(m[0])) continue;
225
+ if (kind === "ip address" && m[0].split(".").some((x) => +x > 255)) continue;
226
+ const r = document.createRange(); r.setStart(n, m.index); r.setEnd(n, m.index + m[0].length);
227
+ for (const b of r.getClientRects()) if (seen(b)) box(kind, b, hint(m[0]));
228
+ }
229
+ }
230
+ }
231
+ const all = (sel) => [...document.querySelectorAll(sel)].filter((e) => { const b = e.getBoundingClientRect(); return seen(b) && shows(e); });
232
+ // what's typed into a form (a field's value isn't text on the page)
233
+ for (const e of all("input, textarea, select")) {
234
+ const t = (e.type || "").toLowerCase(); if (/^(?:button|submit|reset|checkbox|radio|range|color|hidden|file|image)$/.test(t)) continue;
235
+ if (t === "password" || String(e.value || "").trim()) box("typed in", e.getBoundingClientRect(), t === "password" ? "a password" : hint(String(e.value)));
236
+ }
237
+ // notifications and chats: what arrived, and who said what
238
+ // (a heading that's aria-live is the page's title, said as it changes: not a notification)
239
+ for (const e of all('[role=alert], [role=status], [aria-live]:not([aria-live=off]):not(h1,h2,h3,h4,h5,h6), [class*=toast], [class*=notification], [class*=snackbar]')) if (e.innerText && e.innerText.trim()) box("notification", e.getBoundingClientRect());
240
+ for (const e of all('[role=log], [class*=conversation], [class*=chat-], [class*=message-list], [class*=messages]')) { const b = e.getBoundingClientRect(); if (b.width > 40 && b.height > 30 && e.innerText && e.innerText.trim()) box("chat", b); }
241
+ // someone's name, whoever they are: the words beside an avatar, and what's marked as a person's name
242
+ const textBoxes = (el, kind) => { const w = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); for (let n; (n = w.nextNode());) { if (!n.nodeValue.trim()) continue; const r = document.createRange(); r.selectNodeContents(n); for (const b of r.getClientRects()) if (seen(b)) box(kind, b, hint(n.nodeValue.trim())); } };
243
+ const PERSONISH = /^[\\p{Lu}][\\p{L}'’.-]+(?:\\s+(?:(?:van|von|de|der|den|du|da|di|dos|le|la|bin|ibn|al|el)\\s+)*[\\p{Lu}][\\p{L}'’.-]+){0,3}$/u; // particles too: Pieter van Wyk
244
+ // the name-like words nearest an avatar (within 160px, a parent or two up), not all its row's
245
+ const near = (img) => {
246
+ const a = img.getBoundingClientRect(); let best = null, d0 = 160;
247
+ for (let p = img.parentElement, i = 0; p && i < 3; p = p.parentElement, i++) {
248
+ const w = document.createTreeWalker(p, NodeFilter.SHOW_TEXT);
249
+ for (let n; (n = w.nextNode());) {
250
+ const t = n.nodeValue.trim(); if (!t || t.length > 60 || !(PERSONISH.test(t) || /@/.test(t)) || document.title.trim().toLowerCase().startsWith(t.toLowerCase())) continue; // not the product's own name
251
+ const r = document.createRange(); r.selectNodeContents(n); const b = r.getBoundingClientRect(); if (!seen(b)) continue;
252
+ const d = Math.hypot(Math.max(0, b.left - a.right, a.left - b.right), Math.max(0, b.top - a.bottom, a.top - b.bottom)) + (b.right <= a.left || b.bottom <= a.top ? 20 : 0); // a name usually follows its picture
253
+ if (d < d0) { d0 = d; best = b; }
254
+ }
255
+ if (best) break;
256
+ }
257
+ if (best) box("name", best);
258
+ };
259
+ // a customer's or client's name in a table of them: a cell that's just a name (two to four words, each capitalised)
260
+ for (const e of all('td, [role=gridcell], [role=cell]')) { const t = (e.innerText || "").trim(); if (t.length <= 50 && /\\s/.test(t) && PERSONISH.test(t)) textBoxes(e, "name"); }
261
+ for (const e of all('[class*=username], [class*=user-name], [class*=author], [class*=display-name], [class*=displayname], [class*=profile-name], [class*=account-name], [itemprop=name], [data-testid*=user], [aria-label*=account i]')) { const t = (e.innerText || "").trim(); if (t && t.length <= 60 && (PERSONISH.test(t) || /@/.test(t))) textBoxes(e, "name"); }
262
+ // pictures: an avatar (a face, someone's photo), or any picture that isn't an icon or a logo
263
+ const pic = (e) => { const s = String(e.currentSrc || e.src || e.getAttribute("href") || getComputedStyle(e).backgroundImage || ""); return { s, svg: /\\.svg(?:[?#]|$)|^data:image\\/svg|image\\/svg/i.test(s) }; };
264
+ const ICON = /logo|icon|brand|sprite|emoji|badge|flag/i, AVATAR = /avatar|profile|user|gravatar|googleusercontent|pfp|headshot|portrait|people|person|member|author/i;
265
+ for (const e of all("img, picture > img, video, canvas, image, [style*=background-image]")) {
266
+ const b = e.getBoundingClientRect(); if (b.width < 16 || b.height < 16) continue;
267
+ const { s, svg } = pic(e), tag = e.tagName.toLowerCase(), what = [s, e.alt || "", e.className && e.className.baseVal != null ? e.className.baseVal : e.className || "", e.id || "", e.getAttribute("aria-label") || ""].join(" ");
268
+ if (tag !== "video" && tag !== "canvas" && (svg || (ICON.test(what) && !AVATAR.test(what)))) continue;
269
+ // a drawing hidden from screen readers that fills most of the window is the page's backdrop
270
+ // (Symbiot's liquid), not something on it; and with drawings off (made-up data), none is boxed
271
+ if (tag === "canvas" && (!${drawings ? "true" : "false"} || (e.getAttribute("aria-hidden") === "true" && b.width * b.height >= innerWidth * innerHeight * 0.5))) continue;
272
+ if (tag !== "img" && tag !== "video" && tag !== "canvas" && tag !== "image" && !/url\\(/.test(s)) continue;
273
+ const round = parseFloat(getComputedStyle(e).borderRadius) >= Math.min(b.width, b.height) * 0.35;
274
+ if ((round || AVATAR.test(what)) && b.width <= 200 && b.height <= 200) { box("avatar", b); near(e); }
275
+ else if (tag === "canvas") box("picture", b, "a drawing on the page");
276
+ else if (b.width >= 40 && b.height >= 40) box("picture", b);
277
+ }
278
+ return out;
279
+ })()`;
280
+ const pad = (r, W, H) => { const x = Math.max(0, Math.floor(r.x - 4)), y = Math.max(0, Math.floor(r.y - 3)); return { ...r, x, y, w: Math.min(W - x, Math.ceil(r.w + 8)), h: Math.min(H - y, Math.ceil(r.h + 6)) }; };
281
+ // one box per place: the same kind over the same spot once
282
+ function merge(boxes) {
283
+ const out = [];
284
+ for (const b of boxes) if (!out.some((o) => o.kind === b.kind && Math.abs(o.x - b.x) < 3 && Math.abs(o.y - b.y) < 3 && Math.abs(o.w - b.w) < 6 && Math.abs(o.h - b.h) < 6)) out.push(b);
285
+ return out;
286
+ }
287
+ const covered = (b, by) => by.some((o) => b.x >= o.x - 4 && b.y >= o.y - 4 && b.x + b.w <= o.x + o.w + 4 && b.y + b.h <= o.y + o.h + 4);
288
+
289
+ // Text read off a picture with tesseract (a page's pictures and drawings, or a desktop app's
290
+ // window), as boxes in its pixels. Read at twice its size (screen text is small). [] when
291
+ // tesseract isn't here: { boxes, read: false }.
292
+ const canOcr = () => hasCmd("tesseract") && hasCmd("ffmpeg");
293
+ function ocrBoxes(png, names = [], { run = spawnSync } = {}) {
294
+ if (!canOcr()) return { boxes: [], read: false };
295
+ const t = mkdtempSync(join(tmpdir(), "symbiot-ocr-")), big = join(t, "big.png");
296
+ try {
297
+ const s = run("ffmpeg", ["-loglevel", "error", "-y", "-i", png, "-vf", "scale=iw*2:ih*2:flags=lanczos,format=gray", big], { encoding: "utf8", timeout: 60000 });
298
+ if (s.status !== 0) return { boxes: [], read: false };
299
+ const r = run("tesseract", [big, "stdout", "--psm", "11", "tsv"], { encoding: "utf8", timeout: 120000, maxBuffer: 32 * 1024 * 1024 });
300
+ if (r.status !== 0) return { boxes: [], read: false };
301
+ return { boxes: tsvBoxes(String(r.stdout || ""), names, 2), read: true };
302
+ } catch { return { boxes: [], read: false }; } finally { rmSync(t, { recursive: true, force: true }); }
303
+ }
304
+ // tesseract's TSV (a row a word, with its box) as lines; each match's words as one box
305
+ function tsvBoxes(tsv, names = [], scale = 1) {
306
+ const lines = new Map();
307
+ for (const row of tsv.split("\n").slice(1)) {
308
+ const c = row.split("\t"); if (c.length < 12 || c[0] !== "5") continue;
309
+ const text = c.slice(11).join("\t").trim(); if (!text || Number(c[10]) < 30) continue;
310
+ const key = c.slice(1, 5).join("."), l = lines.get(key) || { words: [], text: "" };
311
+ const at = l.text ? l.text.length + 1 : 0; l.text += (l.text ? " " : "") + text;
312
+ l.words.push({ at, end: at + text.length, x: +c[6] / scale, y: +c[7] / scale, w: +c[8] / scale, h: +c[9] / scale });
313
+ lines.set(key, l);
314
+ }
315
+ const out = [];
316
+ for (const l of lines.values()) for (const m of textHits(l.text, names)) {
317
+ const ws = l.words.filter((w) => w.end > m.at && w.at < m.end); if (!ws.length) continue;
318
+ const x = Math.min(...ws.map((w) => w.x)), y = Math.min(...ws.map((w) => w.y)), x2 = Math.max(...ws.map((w) => w.x + w.w)), y2 = Math.max(...ws.map((w) => w.y + w.h));
319
+ out.push({ kind: m.kind, x, y, w: x2 - x, h: y2 - y, hint: hint(m.text), read: true });
320
+ }
321
+ return out;
322
+ }
323
+
324
+ // People's names to blur: the people Symbiot remembers, the product's git authors and
325
+ // yours, and any listed in the lane's .symbiot/names.json (["Jono", "Sam Rivera"]). A whole
326
+ // name, and its parts from four letters (not the product's own name).
327
+ const NOT_NAMES = new Set(["admin", "user", "users", "team", "home", "support", "info", "hello", "root", "github", "symbiot", "bot", "dependabot", "renovate"]);
328
+ function namesToBlur(root, { dir = MARKETING_DIR, product = "", mind } = {}) {
329
+ const raw = new Set();
330
+ if (!mind) mind = readJson(join(CONFIG_DIR, "mind.json"));
331
+ for (const n of (mind && Array.isArray(mind.nodes) ? mind.nodes : [])) if (n && n.kind === "person" && n.name) raw.add(String(n.name));
332
+ if (root) {
333
+ const r = spawnSync("git", ["-C", root, "log", "-500", "--format=%an"], { encoding: "utf8", timeout: 15000 });
334
+ for (const l of String(r.stdout || "").split("\n")) if (l.trim()) raw.add(l.trim());
335
+ const me = spawnSync("git", ["-C", root, "config", "user.name"], { encoding: "utf8", timeout: 5000 });
336
+ if (String(me.stdout || "").trim()) raw.add(String(me.stdout).trim());
337
+ }
338
+ for (const n of [].concat(readJson(join(dir, ".symbiot", "names.json")) || [])) if (typeof n === "string" && n.trim()) raw.add(n.trim());
339
+ const own = new Set(slug(product).split("-"));
340
+ const out = new Set();
341
+ for (const n of raw) {
342
+ if (/\[bot\]|noreply|^\W*$/i.test(n)) continue;
343
+ const whole = n.replace(/\s+/g, " ").trim(); if (whole.length >= 3 && !NOT_NAMES.has(whole.toLowerCase())) out.add(whole);
344
+ for (const p of whole.split(/[\s._-]+/)) if (p.length >= 4 && /^[A-Za-z]/.test(p) && !NOT_NAMES.has(p.toLowerCase()) && !own.has(p.toLowerCase())) out.add(p);
345
+ }
346
+ return [...out].sort((a, b) => b.length - a.length).slice(0, 300);
347
+ }
348
+
349
+ // ---- the tray -----------------------------------------------------------------------
350
+ // drafts/<product>/tray/ in the lane, with its tray.json; the originals beside it in the
351
+ // lane's .symbiot/tray-originals/<product>/ (tray.mjs reads them from there).
352
+ function trayPaths(product, dir = MARKETING_DIR) {
353
+ const folder = slug(product) || "product";
354
+ return { folder, rel: `drafts/${folder}/tray`, root: join(dir, "drafts", folder, "tray"), orig: join(dir, ".symbiot", "tray-originals", folder) };
355
+ }
356
+ // A capture into tray.json (one of its name replaced), and its .blur.json beside it.
357
+ function putInTray(t, item, spec) {
358
+ mkdirSync(t.root, { recursive: true }); mkdirSync(t.orig, { recursive: true });
359
+ writeFileSync(join(t.root, `${item.name}.blur.json`), JSON.stringify(spec, null, 2));
360
+ const f = join(t.root, "tray.json"), idx = readJson(f) || { tray: t.folder, items: [] };
361
+ idx.items = [...(Array.isArray(idx.items) ? idx.items : []).filter((x) => x && x.name !== item.name), item];
362
+ writeFileSync(f, JSON.stringify(idx, null, 2));
363
+ }
364
+ const regionsOf = (boxes) => boxes.map((r, i) => ({ id: i + 1, kind: r.kind, x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.w), h: Math.round(r.h), hint: r.hint || "", on: true, ...(r.from != null ? { from: r.from, to: r.to } : {}) }));
365
+ // one track a box across a clip's moments: the same kind within a few pixels, close in time
366
+ function tracks(samples, gap = 0.6) {
367
+ const out = [];
368
+ for (const s of samples) for (const r of s.r) {
369
+ const tr = out.find((k) => k.kind === r.kind && Math.abs(k.x - r.x) < 8 && Math.abs(k.y - r.y) < 8 && Math.abs(k.w - r.w) < 14 && s.t - k.last <= gap);
370
+ if (tr) { tr.last = s.t; tr.x = Math.min(tr.x, r.x); tr.y = Math.min(tr.y, r.y); tr.w = Math.max(tr.w, r.w); tr.h = Math.max(tr.h, r.h); } else out.push({ ...r, first: s.t, last: s.t });
371
+ }
372
+ // a little before it's seen and after it goes (a sample is a moment apart); one still there at the end stays to the end
373
+ const end = samples.length ? samples[samples.length - 1].t : 0;
374
+ return out.map(({ first, last, ...r }) => ({ ...r, from: Math.max(0, +(first - 0.4).toFixed(2)), to: end - last < 0.3 ? 9999 : +(last + 0.4).toFixed(2) }));
375
+ }
376
+
377
+ // A stepped clip's boxes: each moment's, over the whole of it and a little either side
378
+ // (a jump's frame may come a beat before or after it's found), in seconds from its start.
379
+ const HOLD = 1.5, SLACK = 0.35;
380
+ function heldBoxes(held, start) {
381
+ const out = [];
382
+ held.forEach((h, i) => {
383
+ const from = Math.max(0, +(h.at - start - SLACK).toFixed(2)), to = i === held.length - 1 ? 9999 : +(held[i + 1].at - start + SLACK).toFixed(2);
384
+ for (const b of h.boxes) out.push({ ...b, from, to });
385
+ });
386
+ return out;
387
+ }
388
+
389
+ // ---- a web app --------------------------------------------------------------------------
390
+ // Each screen: a still (its picture, with its boxes, and the text tesseract reads off its
391
+ // pictures); the first few also a short clip, scrolling through it, its boxes found as it
392
+ // moves. A screen that sends you to sign in is skipped, said so. { items, skipped, read }.
393
+ async function captureWeb({ product, url, screens, t, names = [], drawings = true, tidy = "", clips = MAX_CLIPS, seconds = CLIP_SECONDS, page: withPage = scratchPage, ocr = ocrBoxes, encode = framesToVideo, now = Date.now() }) {
394
+ const base = String(url).replace(/\/+$/, ""), items = [], skipped = []; let read = null;
395
+ await withPage(async (page) => {
396
+ for (const [i, s] of screens.entries()) {
397
+ const at = screenUrl(base, s.path, i), scr = s.screen || title(s.path);
398
+ try { await openPage(page, at); } catch (e) { skipped.push({ path: s.path, why: String((e && e.message) || e) }); continue; }
399
+ await sleep(800);
400
+ if (tidy) await evaluate(page, tidy);
401
+ if (s.path !== "/" && await signInWall(page, at)) { skipped.push({ path: s.path, why: "it asks you to sign in (capture it with a demo account signed in, or give screens that don't)" }); continue; }
402
+ const name = `${s.name}`, still = `${name}.png`;
403
+ const { data } = await page.send("Page.captureScreenshot", { format: "png" });
404
+ writeFileSync(join(t.orig, still), Buffer.from(data, "base64"));
405
+ let boxes = merge(((await evaluate(page, DETECT(names, { drawings }))) || []).map((b) => pad(b, VIEW.w, VIEW.h)));
406
+ const o = ocr(join(t.orig, still), names); read = read === null ? o.read : read && o.read;
407
+ boxes = [...boxes, ...o.boxes.map((b) => pad(b, VIEW.w, VIEW.h)).filter((b) => !covered(b, boxes))];
408
+ putInTray(t, { name, kind: "still", files: [still], screen: scr, caption: s.caption, for: [], blur: `${name}.blur.json`, path: s.path, captured: now },
409
+ { source: still, kind: "still", size: [VIEW.w, VIEW.h], screen: scr, caption: s.caption, path: s.path, regions: regionsOf(boxes), ...(o.read ? {} : { review: NO_OCR }) });
410
+ items.push(name);
411
+ if (i >= clips) continue;
412
+ // the clip: the page a window at a time, a moment on each. It jumps rather than glides,
413
+ // so what's private is found where it is in each, and its box covers the whole of that
414
+ // moment, a little either side: nothing is ever between two looks unblurred.
415
+ const frames = [], held = [];
416
+ // each frame timed as it arrives, by the same clock its moments are (not the browser's own)
417
+ const off = page.on("Page.screencastFrame", (p) => { frames.push({ ts: Date.now() / 1000, jpeg: Buffer.from(p.data, "base64") }); page.send("Page.screencastFrameAck", { sessionId: p.sessionId }).catch(() => {}); });
418
+ try {
419
+ const steps = Math.max(2, Math.round(seconds / HOLD));
420
+ const tall = Number(await evaluate(page, `(() => { ${SCROLLER_JS} const sc = scroller(); window.__symbiotSc = sc; return sc ? sc.scrollHeight - sc.clientHeight : 0; })()`)) || 0;
421
+ await evaluate(page, "window.__symbiotSc ? (window.__symbiotSc.scrollTop = 0) : 0");
422
+ await page.send("Page.startScreencast", { format: "jpeg", quality: 90, maxWidth: VIEW.w, maxHeight: VIEW.h, everyNthFrame: 1 });
423
+ for (let k = 0; k < steps; k++) {
424
+ const at = Date.now() / 1000;
425
+ if (k) await evaluate(page, `window.__symbiotSc ? (window.__symbiotSc.scrollTop = ${Math.round((tall * k) / (steps - 1))}) : 0`);
426
+ await evaluate(page, "new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)))");
427
+ const first = merge(((await evaluate(page, DETECT(names, { drawings }))) || []).map((b) => pad(b, VIEW.w, VIEW.h)));
428
+ await sleep(HOLD * 1000 - 300);
429
+ const again = merge(((await evaluate(page, DETECT(names, { drawings }))) || []).map((b) => pad(b, VIEW.w, VIEW.h)));
430
+ held.push({ at, until: Date.now() / 1000, boxes: merge([...first, ...again]) });
431
+ if (!tall) { await sleep(Math.max(0, (seconds - HOLD) * 1000)); held[held.length - 1].until = Date.now() / 1000; break; }
432
+ }
433
+ await page.send("Page.stopScreencast").catch(() => {});
434
+ } finally { off(); }
435
+ if (frames.length < 2) { skipped.push({ path: s.path, clip: true, why: "nothing moved on it, so its picture is all there is" }); continue; }
436
+ const start = frames[0].ts, end = Math.max(frames[frames.length - 1].ts, held.length ? held[held.length - 1].until : start + seconds), cname = `${name}-clip`;
437
+ const v = encode(frames, end, join(t.orig, `${cname}.mp4`)); if (v.error) { skipped.push({ path: s.path, clip: true, why: v.error }); continue; }
438
+ const regions = heldBoxes(held, start);
439
+ putInTray(t, { name: cname, kind: "clip", files: [`${cname}.gif`, `${cname}.mp4`], screen: scr, caption: `${s.caption} (a clip)`, for: [], blur: `${cname}.blur.json`, path: s.path, captured: now },
440
+ { source: `${cname}.mp4`, kind: "clip", size: [VIEW.w, VIEW.h], screen: scr, caption: `${s.caption} (a clip)`, path: s.path, trim: [0, 0], fps: CLIP_FPS, width: 1200, regions: regionsOf(regions),
441
+ ...(o.read ? {} : { review: NO_OCR }) });
442
+ items.push(cname);
443
+ }
444
+ }, { width: VIEW.w, height: VIEW.h });
445
+ return { items, skipped, read: !!read };
446
+ }
447
+ // Sent to sign in instead of the screen: taken to a sign-in address, or a page that's a
448
+ // sign-in form (a password box, a Log in button, hardly any other fields). A settings page
449
+ // with a password field on it isn't one.
450
+ async function signInWall(page, asked) {
451
+ const where = String((await evaluate(page, "location.href")) || asked);
452
+ if (where.split(/[?#]/)[0] !== asked.split(/[?#]/)[0] && /\/(?:log-?in|sign-?in|auth|sso|session)\b/i.test(where)) return true;
453
+ return !!(await evaluate(page, `(() => { const pw = document.querySelector('input[type=password]'); if (!pw) return false;
454
+ const fields = [...document.querySelectorAll('input:not([type=hidden]):not([type=checkbox]):not([type=radio]):not([type=submit]), textarea, select')].length;
455
+ const go = [...document.querySelectorAll('button, input[type=submit], a, [role=button]')].some((b) => /^(?:log ?in|sign ?in|continue|next)$/i.test(String(b.innerText || b.value || '').trim()));
456
+ return go && fields <= 3; })()`));
457
+ }
458
+ const NO_OCR = "tesseract isn't installed here, so text inside this capture's pictures wasn't read (pictures are blurred anyway): look it over before you use it.";
459
+ // A clip's frames (each shown until the next) as an MP4 at `out`. { ok } or { error }.
460
+ function framesToVideo(frames, end, out) {
461
+ const t = mkdtempSync(join(tmpdir(), "symbiot-clip-"));
462
+ try {
463
+ const name = (i) => `f${String(i).padStart(5, "0")}.jpg`, list = ["ffconcat version 1.0"];
464
+ frames.forEach((f, i) => { writeFileSync(join(t, name(i)), f.jpeg); list.push(`file ${name(i)}`, `duration ${Math.max(0.001, (i + 1 < frames.length ? frames[i + 1].ts : end) - f.ts).toFixed(3)}`); });
465
+ list.push(`file ${name(frames.length - 1)}`); writeFileSync(join(t, "list.ffconcat"), list.join("\n") + "\n");
466
+ const r = spawnSync("ffmpeg", ["-y", "-loglevel", "error", "-f", "concat", "-i", "list.ffconcat", "-vf", "fps=30,scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p", "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-an", out], { cwd: t, encoding: "utf8", timeout: 180000 });
467
+ return r.error || r.status !== 0 ? { error: r.error && r.error.code === "ENOENT" ? NO_FFMPEG : `ffmpeg couldn't make the clip: ${String(r.stderr || "").trim().split("\n").pop() || "it stopped"}` } : { ok: true };
468
+ } finally { rmSync(t, { recursive: true, force: true }); }
469
+ }
470
+ const NO_FFMPEG = "Capturing needs ffmpeg, which isn't installed (on Linux: sudo apt install ffmpeg; on a Mac: brew install ffmpeg).";
471
+
472
+ // ---- a desktop app ----------------------------------------------------------------------
473
+ // Its window recorded with ffmpeg: on Linux an X11 window (x11grab, by the id xdotool
474
+ // finds for its title: an app under XWayland counts), on Windows by its title
475
+ // (gdigrab), on a Mac the screen (avfoundation). A Wayland window no other program can
476
+ // see, so under Wayland (COSMIC, GNOME, KDE) it goes through the desktop's own screen
477
+ // sharing (portalRecord): you pick the window in its prompt once, and it's remembered.
478
+ // Then each second of it read with tesseract for what's private. It can't see faces or
479
+ // pictures in a window, so the capture says so: look it over, and draw a box over
480
+ // anything (tray.mjs addBlur) before it goes on a post.
481
+ function windowOf(title, { run = spawnSync, platform = process.platform, env = process.env, has = hasCmd } = {}) {
482
+ if (platform !== "linux") return { title };
483
+ const wayland = !!env.WAYLAND_DISPLAY || env.XDG_SESSION_TYPE === "wayland";
484
+ if (env.DISPLAY && has("xdotool")) {
485
+ const r = run("xdotool", ["search", "--onlyvisible", "--name", String(title)], { encoding: "utf8", timeout: 10000 });
486
+ const id = String(r.stdout || "").split("\n").map((x) => x.trim()).find(Boolean);
487
+ if (id) return { id };
488
+ }
489
+ if (wayland) return has("python3") ? { title, portal: true } : { error: NO_PORTAL_KIT };
490
+ if (!env.DISPLAY) return { error: "There's no X display here (DISPLAY isn't set), so a window can't be recorded." };
491
+ if (!has("xdotool")) return { error: "Recording a window on Linux needs xdotool (sudo apt install xdotool), to find it." };
492
+ return { error: `No window called "${title}" that Symbiot can record here: start the app first, or give a web address instead.` };
493
+ }
494
+
495
+ // ---- a Wayland window, through the desktop's screen sharing ----------------------------
496
+ // The ScreenCast portal (xdg-desktop-portal: COSMIC, GNOME, KDE all have one) asks you to
497
+ // pick the window, then hands over a PipeWire stream of it, which GStreamer records: a
498
+ // picture, then the clip, in one go, so it's one prompt. The portal's restore token is
499
+ // kept for the window's title (capture-windows.json), so the next capture of it doesn't
500
+ // ask again. Python's GLib/GStreamer bindings do the D-Bus and PipeWire side: Node has
501
+ // neither. Prints one JSON line: { ok, token, still, clip } (each an error or null) or
502
+ // { error, detail }.
503
+ const NO_PORTAL_KIT = "Recording a window under Wayland goes through your desktop's screen sharing, which needs Python's GStreamer bindings and PipeWire's GStreamer plugin (on Ubuntu: sudo apt install python3-gi gir1.2-gst-plugins-base-1.0 gstreamer1.0-pipewire).";
504
+ const PORTAL_PY = String.raw`
505
+ import sys, os, json
506
+ def out(**k):
507
+ print(json.dumps(k), flush=True); sys.exit(0)
508
+ try:
509
+ import gi
510
+ gi.require_version("Gst", "1.0")
511
+ from gi.repository import Gio, GLib, Gst
512
+ except Exception as e:
513
+ out(error="missing", detail=str(e))
514
+ a = json.loads(sys.argv[1])
515
+ D, O, SC = "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.ScreenCast"
516
+ try:
517
+ bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
518
+ except Exception as e:
519
+ out(error="nobus", detail=str(e))
520
+ loop, me, n = GLib.MainLoop(), bus.get_unique_name()[1:].replace(".", "_"), [0]
521
+ def prop(name, default):
522
+ try:
523
+ return bus.call_sync(D, O, "org.freedesktop.DBus.Properties", "Get", GLib.Variant("(ss)", (SC, name)), GLib.VariantType("(v)"), Gio.DBusCallFlags.NONE, 5000, None).unpack()[0]
524
+ except Exception:
525
+ return default
526
+ def ask(method, sig, args, opts, wait):
527
+ n[0] += 1
528
+ tok = "symbiot%d_%d" % (os.getpid(), n[0])
529
+ path = "%s/request/%s/%s" % (O, me, tok)
530
+ got, timer = {}, {}
531
+ def on(conn, sender, p, iface, s, params):
532
+ got["r"] = params.unpack(); loop.quit()
533
+ def late():
534
+ timer["done"] = True; loop.quit(); return False
535
+ # by our own request path: a well-known sender name can miss the signal in GDBus
536
+ sub = bus.signal_subscribe(None, "org.freedesktop.portal.Request", "Response", path, None, Gio.DBusSignalFlags.NONE, on)
537
+ t = GLib.timeout_add_seconds(wait, late)
538
+ try:
539
+ opts["handle_token"] = GLib.Variant("s", tok)
540
+ bus.call_sync(D, O, SC, method, GLib.Variant(sig, tuple(args) + (opts,)), None, Gio.DBusCallFlags.NONE, -1, None)
541
+ loop.run()
542
+ finally:
543
+ bus.signal_unsubscribe(sub)
544
+ if not timer: GLib.source_remove(t)
545
+ return got.get("r")
546
+ try:
547
+ version = prop("version", 0)
548
+ if not version:
549
+ out(error="noportal")
550
+ types, cursors = prop("AvailableSourceTypes", 1), prop("AvailableCursorModes", 1)
551
+ r = ask("CreateSession", "(a{sv})", [], {"session_handle_token": GLib.Variant("s", "symbiot%d" % os.getpid())}, 30)
552
+ if not r or r[0] != 0:
553
+ out(error="nosession")
554
+ session = r[1]["session_handle"]
555
+ opts = {"types": GLib.Variant("u", 2 if types & 2 else 1), "multiple": GLib.Variant("b", False)}
556
+ if version >= 2:
557
+ opts["cursor_mode"] = GLib.Variant("u", 2 if cursors & 2 else 1)
558
+ if version >= 4:
559
+ opts["persist_mode"] = GLib.Variant("u", 2)
560
+ if a.get("token"):
561
+ opts["restore_token"] = GLib.Variant("s", a["token"])
562
+ r = ask("SelectSources", "(oa{sv})", [session], opts, 30)
563
+ if not r or r[0] != 0:
564
+ out(error="cancelled" if r and r[0] == 1 else "nosession")
565
+ r = ask("Start", "(osa{sv})", [session, ""], {}, int(a.get("wait") or 120))
566
+ if not r:
567
+ out(error="timeout")
568
+ if r[0] != 0:
569
+ out(error="cancelled" if r[0] == 1 else "nosession")
570
+ streams, token = r[1].get("streams") or [], r[1].get("restore_token") or ""
571
+ if not streams:
572
+ out(error="cancelled")
573
+ node = streams[0][0]
574
+ res, fds = bus.call_with_unix_fd_list_sync(D, O, SC, "OpenPipeWireRemote", GLib.Variant("(oa{sv})", (session, {})), GLib.VariantType("(h)"), Gio.DBusCallFlags.NONE, -1, None, None)
575
+ fd = fds.get(res.unpack()[0])
576
+ Gst.init(None)
577
+ def play(desc, file, seconds=0, wait=20):
578
+ p = Gst.parse_launch(desc)
579
+ src = p.get_by_name("src")
580
+ src.set_property("fd", os.dup(fd)); src.set_property("path", str(node))
581
+ p.get_by_name("out").set_property("location", file)
582
+ b, kinds = p.get_bus(), Gst.MessageType.ERROR | Gst.MessageType.EOS
583
+ p.set_state(Gst.State.PLAYING)
584
+ m = b.timed_pop_filtered(seconds * Gst.SECOND, kinds) if seconds else None
585
+ if seconds and m is None:
586
+ p.send_event(Gst.Event.new_eos())
587
+ if m is None:
588
+ m = b.timed_pop_filtered(wait * Gst.SECOND, kinds)
589
+ p.set_state(Gst.State.NULL)
590
+ if m is None:
591
+ return "the window sent no picture"
592
+ if m.type == Gst.MessageType.ERROR:
593
+ return m.parse_error()[0].message
594
+ return None
595
+ still = play("pipewiresrc name=src do-timestamp=true keepalive-time=500 ! videoconvert ! pngenc snapshot=true ! filesink name=out", a["still"]) if a.get("still") else None
596
+ clip = play("pipewiresrc name=src do-timestamp=true keepalive-time=100 ! videorate ! video/x-raw,framerate=%d/1 ! videoconvert ! jpegenc quality=92 ! avimux ! filesink name=out" % int(a.get("fps") or 12), a["clip"], int(a.get("seconds") or 6)) if a.get("clip") else None
597
+ try:
598
+ bus.call_sync(D, session, "org.freedesktop.portal.Session", "Close", None, None, Gio.DBusCallFlags.NONE, 5000, None)
599
+ except Exception:
600
+ pass
601
+ out(ok=True, token=token, still=still, clip=clip)
602
+ except SystemExit:
603
+ raise
604
+ except Exception as e:
605
+ out(error="failed", detail=str(e))
606
+ `;
607
+ const PORTAL_SAYS = {
608
+ missing: NO_PORTAL_KIT,
609
+ nobus: "Couldn't reach your desktop's session (D-Bus) to ask for the window. Capture from the Symbiot app (Marketing → Capture a product), not from a sandboxed run.",
610
+ noportal: "Your desktop has no screen sharing (xdg-desktop-portal) for Symbiot to ask, so a Wayland window can't be recorded: start the app under X11 (XWayland), or give a web address instead.",
611
+ nosession: "Your desktop's screen sharing wouldn't start a recording.",
612
+ };
613
+ const WINDOW_PICKS = () => join(CONFIG_DIR, "capture-windows.json");
614
+ const pickKey = (title) => String(title || "").trim().toLowerCase();
615
+ function pyRun(args, { timeout }) {
616
+ return new Promise((done) => {
617
+ let out = "", err = "", p; try { p = spawn("python3", args, { stdio: ["ignore", "pipe", "pipe"], timeout }); } catch (e) { done({ error: String((e && e.message) || e) }); return; }
618
+ p.stdout.on("data", (d) => { out += d; }); p.stderr.on("data", (d) => { err = (err + d).slice(-2000); });
619
+ p.on("error", (e) => done({ error: e.code === "ENOENT" ? NO_PORTAL_KIT : String(e.message || e) }));
620
+ p.on("close", (code) => done({ stdout: out, stderr: err, code }));
621
+ });
622
+ }
623
+ // The clip GStreamer wrote (MJPEG in AVI) as the MP4 the rest expects.
624
+ function aviToMp4(avi, out) {
625
+ const r = spawnSync("ffmpeg", ["-loglevel", "error", "-y", "-i", avi, "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-pix_fmt", "yuv420p", "-an", out], { encoding: "utf8", timeout: 180000 });
626
+ rmSync(avi, { force: true });
627
+ return r.error || r.status !== 0 ? { error: r.error && r.error.code === "ENOENT" ? NO_FFMPEG : `ffmpeg couldn't make the clip: ${String(r.stderr || "").trim().split("\n").pop() || "it stopped"}` } : { ok: true };
628
+ }
629
+ // { still: { ok } | { error }, clip: { ok } | { error } | null } or { error }.
630
+ async function portalRecord(title, { still, video = "", seconds = CLIP_SECONDS, fps = CLIP_FPS, wait = 120, file = WINDOW_PICKS(), py = pyRun, encode = aviToMp4, notify = desktopNotify } = {}) {
631
+ const picks = readJson(file) || {}, token = picks[pickKey(title)] || "";
632
+ // the first time, the desktop's prompt comes up out of nowhere: say which window to pick
633
+ if (!token) notify("Symbiot: pick a window to record", `Pick "${title}" in your desktop's screen-sharing prompt, for Marketing's pictures of it. Symbiot remembers it, so it won't ask again.`);
634
+ const avi = video ? video.replace(/\.mp4$/i, "") + ".avi" : "";
635
+ const r = await py(["-c", PORTAL_PY, JSON.stringify({ still, clip: avi, seconds, fps, token, wait })], { timeout: (wait + seconds + 90) * 1000 });
636
+ if (r.error) return { error: r.error };
637
+ const line = String(r.stdout || "").trim().split("\n").pop() || "";
638
+ let o = null; try { o = JSON.parse(line); } catch { /* not ours: its last words below */ }
639
+ if (!o) return { error: `Your desktop's screen sharing stopped: ${String(r.stderr || "").trim().split("\n").pop() || `python3 exited ${r.code}`}`.slice(0, 300) };
640
+ if (o.error === "cancelled") return { error: `The window wasn't picked in your desktop's screen-sharing prompt: capture again, and pick "${title}" there.` };
641
+ if (o.error === "timeout") return { error: `Nobody picked a window in your desktop's screen-sharing prompt within ${Math.round(wait / 60)} minutes: capture again, and pick "${title}" there.` };
642
+ if (o.error) return { error: PORTAL_SAYS[o.error] ? `${PORTAL_SAYS[o.error]}${o.error === "missing" ? "" : o.detail ? ` (${String(o.detail).slice(0, 160)})` : ""}` : `Your desktop's screen sharing stopped: ${String(o.detail || o.error).slice(0, 200)}` };
643
+ if (o.token) { picks[pickKey(title)] = o.token; try { mkdirSync(join(file, ".."), { recursive: true }); writeFileSync(file, JSON.stringify(picks, null, 2)); } catch { /* asked again next time */ } }
644
+ const clip = !video ? null : o.clip ? { error: o.clip } : existsSync(avi) ? encode(avi, video) : { error: "the window sent no picture" };
645
+ return { still: o.still ? { error: o.still } : { ok: true }, clip };
646
+ }
647
+ // ffmpeg's arguments for it: a picture (still) or `seconds` of video, to `out`.
648
+ function recordArgs(win, { seconds = CLIP_SECONDS, still = false, out, platform = process.platform, env = process.env, fps = CLIP_FPS } = {}) {
649
+ const take = still ? ["-frames:v", "1"] : ["-t", String(seconds), "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-pix_fmt", "yuv420p"];
650
+ if (platform === "linux") return ["-loglevel", "error", "-y", "-f", "x11grab", "-window_id", String(win.id), "-framerate", String(fps), "-draw_mouse", "1", "-i", env.DISPLAY || ":0", ...take, out];
651
+ if (platform === "win32") return ["-loglevel", "error", "-y", "-f", "gdigrab", "-framerate", String(fps), "-i", `title=${win.title}`, ...take, out];
652
+ if (platform === "darwin") return ["-loglevel", "error", "-y", "-f", "avfoundation", "-framerate", String(fps), "-capture_cursor", "1", "-i", "1:none", ...take, out];
653
+ return null;
654
+ }
655
+ function ffmpegRun(args, { timeout = 180000 } = {}) {
656
+ return new Promise((done) => {
657
+ let err = "", p; try { p = spawn("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"], timeout }); } catch (e) { done({ error: String((e && e.message) || e) }); return; }
658
+ p.stderr.on("data", (d) => { err = (err + d).slice(-2000); });
659
+ p.on("error", (e) => done({ error: e.code === "ENOENT" ? NO_FFMPEG : String(e.message || e) }));
660
+ p.on("close", (code) => done(code === 0 ? { ok: true } : { error: (err.trim().split("\n").pop() || `ffmpeg stopped (${code})`).slice(0, 300) }));
661
+ });
662
+ }
663
+ const sizeOf = (f) => { const r = spawnSync("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", f], { encoding: "utf8", timeout: 20000 }); const m = String(r.stdout || "").match(/(\d+)x(\d+)/); return m ? [+m[1], +m[2]] : null; };
664
+ const DESKTOP_REVIEW = "A desktop recording: Symbiot read the words in it, but it can't see faces or pictures in a window. Look it over, and draw a box over anything private, before you use it.";
665
+ async function captureDesktop({ product, window: title, t, names = [], seconds = CLIP_SECONDS, clip = true, record = ffmpegRun, portal = portalRecord, find = windowOf, ocr = ocrBoxes, size = sizeOf, platform = process.platform, now = Date.now() }) {
666
+ const win = find(title, { platform }); if (win.error) return { error: win.error };
667
+ const name = `${slug(title) || "window"}`, items = [], cname = `${name}-clip`, video = join(t.orig, `${cname}.mp4`);
668
+ // under Wayland the picture and the clip come from one pick in the desktop's prompt
669
+ const shot = win.portal ? await portal(title, { still: join(t.orig, `${name}.png`), video: clip ? video : "", seconds }) : null;
670
+ if (shot && shot.error) return { error: `Couldn't record the window: ${shot.error}` };
671
+ const still = `${name}.png`, r1 = shot ? shot.still : await record(recordArgs(win, { still: true, out: join(t.orig, still), platform }));
672
+ if (r1.error) return { error: `Couldn't record the window: ${r1.error}` };
673
+ const [w, h] = size(join(t.orig, still)) || [0, 0];
674
+ const o = ocr(join(t.orig, still), names);
675
+ putInTray(t, { name, kind: "still", files: [still], screen: title, caption: `${product}: ${title}`, for: [], blur: `${name}.blur.json`, desktop: true, captured: now },
676
+ { source: still, kind: "still", size: [w, h], screen: title, caption: `${product}: ${title}`, desktop: true, regions: regionsOf(o.boxes.map((b) => pad(b, w, h))), review: o.read ? DESKTOP_REVIEW : `${NO_OCR} ${DESKTOP_REVIEW}` });
677
+ items.push(name);
678
+ if (clip) {
679
+ const r2 = shot ? shot.clip : await record(recordArgs(win, { seconds, out: video, platform }));
680
+ if (r2.error) return { items, error: `The picture's in, but the clip didn't record: ${r2.error}` };
681
+ // a moment a second, read for what's private, as boxes over the time it shows
682
+ const tmp = mkdtempSync(join(tmpdir(), "symbiot-frames-")), samples = [];
683
+ try {
684
+ const x = spawnSync("ffmpeg", ["-loglevel", "error", "-y", "-i", video, "-vf", "fps=1", join(tmp, "s%03d.png")], { encoding: "utf8", timeout: 120000 });
685
+ if (x.status === 0) for (const f of readdirSync(tmp).filter((f) => /^s\d+\.png$/.test(f)).sort()) samples.push({ t: Number(f.slice(1, 4)) - 1, r: ocr(join(tmp, f), names).boxes.map((b) => pad(b, w, h)) });
686
+ } finally { rmSync(tmp, { recursive: true, force: true }); }
687
+ putInTray(t, { name: cname, kind: "clip", files: [`${cname}.gif`, `${cname}.mp4`], screen: title, caption: `${product}: ${title} (a clip)`, for: [], blur: `${cname}.blur.json`, desktop: true, captured: now },
688
+ { source: `${cname}.mp4`, kind: "clip", size: [w, h], screen: title, caption: `${product}: ${title} (a clip)`, desktop: true, trim: [0, 0], fps: CLIP_FPS, width: Math.min(1200, w || 1200), regions: regionsOf(tracks(samples, 1.5)), review: DESKTOP_REVIEW });
689
+ items.push(cname);
690
+ }
691
+ return { items };
692
+ }
693
+
694
+ // ---- one product, end to end --------------------------------------------------------
695
+ // Its repo (by folder, or by its name as people say it), where it runs (the address
696
+ // given, else an app of it listening here: post.mjs localApps), its screens, then each
697
+ // captured and blurred, and the tray's captures rendered (tray.mjs). { ok, product, tray,
698
+ // items, skipped, seed?, note } or { error }.
699
+ function productRepo(product, map = {}) {
700
+ const p = String(product || "").trim(); if (!p) return null;
701
+ if (p.startsWith("/") && existsSync(p)) return { path: p, name: displayName(p, basename(p)) };
702
+ const k = Object.keys(map).find((n) => n !== MARKETING && map[n] && [n, basename(map[n]), displayName(map[n], n)].some((x) => String(x).toLowerCase() === p.toLowerCase()));
703
+ return k ? { path: map[k], name: displayName(map[k], k) } : null;
704
+ }
705
+ // The products there are to capture (your projects), each with whether it has a tray yet:
706
+ // [{ name, tray }], the ones without one first.
707
+ function captureProducts({ map, dir = MARKETING_DIR } = {}) {
708
+ if (!map) { try { map = laneMapNow(); } catch { map = {}; } }
709
+ const seen = new Set(), out = [];
710
+ for (const [k, p] of Object.entries(map)) {
711
+ if (k === MARKETING || !p) continue;
712
+ const name = displayName(p, k); if (!name || seen.has(name.toLowerCase())) continue; seen.add(name.toLowerCase());
713
+ out.push({ name, tray: existsSync(join(trayPaths(name, dir).root, "tray.json")) });
714
+ }
715
+ return out.sort((a, b) => (a.tray ? 1 : 0) - (b.tray ? 1 : 0) || a.name.localeCompare(b.name));
716
+ }
717
+ let BUSY = null;
718
+ // Symbiot's own repo (this package, or a clone of it), by its package.json's name.
719
+ const isSymbiotRepo = (path) => { try { return JSON.parse(readFileSync(join(path, "package.json"), "utf8")).name === "symbiot"; } catch { return false; } };
720
+ async function captureProduct({ product, url = "", window: win = "", screens: extra = [], clips = true, seconds = CLIP_SECONDS, map, dir = MARKETING_DIR, apps, render = renderCapture, web = captureWeb, desktop = captureDesktop, demoApp = startDemo, ffmpeg = () => hasCmd("ffmpeg"), now = Date.now() } = {}) {
721
+ if (BUSY) return { error: `Already capturing ${BUSY}: one product at a time.` };
722
+ if (!map) { try { map = laneMapNow(); } catch { map = {}; } }
723
+ const repo = productRepo(product, map), name = (repo && repo.name) || String(product || "").trim();
724
+ if (!name) return { error: "Say which product to capture: its name, or its folder." };
725
+ let symbiot = false, demo = null;
726
+ if (!win && !url) {
727
+ if (!repo) return { error: `${name} isn't one of your projects here: give its address with --url, or its window with --window.` };
728
+ // Symbiot itself shows your real work: it's captured from a copy with made-up work in it instead
729
+ symbiot = isSymbiotRepo(repo.path);
730
+ const found = symbiot ? null : (await (apps || (await import("./post.mjs")).localApps)([{ name: name, path: repo.path }])).find((a) => a && a.url);
731
+ if (found && found.symbiot) symbiot = true;
732
+ url = found && !symbiot ? found.url : "";
733
+ if (!url && !symbiot) return { error: `${name} isn't running here, so there's nothing to capture yet. Start it (${seedScript(repo.path) ? `with its demo data: ${seedScript(repo.path)}, then ` : ""}its dev server), then capture again, or give its address with --url.` };
734
+ }
735
+ if (!ffmpeg()) return { error: NO_FFMPEG };
736
+ BUSY = name;
737
+ try {
738
+ if (symbiot) { demo = await demoApp(); if (demo.error) return { error: demo.error }; url = demo.url; }
739
+ const t = trayPaths(name, dir); mkdirSync(t.root, { recursive: true }); mkdirSync(t.orig, { recursive: true });
740
+ const names = [...namesToBlur(repo && repo.path, { dir, product: name }), ...(symbiot ? DEMO_NAMES : [])];
741
+ let r;
742
+ if (win) r = await desktop({ product: name, window: win, t, names, seconds, clip: clips, now });
743
+ else {
744
+ const screens = symbiot ? SYMBIOT_SCREENS : repo ? screensOf(repo.path, { product: name, extra }) : [{ path: "/", name: "home", caption: `${name}: its first screen`, from: "home" }, ...extra.map((p) => ({ path: routeOf(p), name: slug(words(routeOf(p))) || "home", caption: captionFor(routeOf(p), "", name), from: "given" })).filter((s) => s.path && s.path !== "/")];
745
+ // Symbiot's made-up copy: its charts and map are drawings of made-up work, the point of those screens
746
+ r = await web({ product: name, url, screens, t, names, ...(symbiot ? { drawings: false, tidy: SYMBIOT_TIDY } : {}), clips: clips ? MAX_CLIPS : 0, seconds, now });
747
+ }
748
+ if (r.error && !(r.items || []).length) return { error: r.error };
749
+ const failed = [];
750
+ for (const it of r.items || []) { const d = await render(t.rel, it, { dir }); if (!d.ok) failed.push(`${it}: ${d.error}`); }
751
+ // a reel of the clips, one after another, when there's more than one
752
+ const cl = (r.items || []).filter((x) => x.endsWith("-clip"));
753
+ if (cl.length > 1) { writeFileSync(join(t.root, "tour.reel.json"), JSON.stringify({ parts: cl }, null, 2)); const idx = readJson(join(t.root, "tray.json")); idx.items = [...idx.items.filter((x) => x.name !== "tour"), { name: "tour", kind: "reel", files: ["tour.mp4"], screen: "several", caption: `${name}: a tour, its clips one after another`, for: [], parts: cl, captured: now }]; writeFileSync(join(t.root, "tray.json"), JSON.stringify(idx, null, 2)); if (cl.length) await render(t.rel, cl[cl.length - 1], { dir }); }
754
+ const seed = repo && !win && !symbiot ? seedScript(repo.path) : "";
755
+ const n = (r.items || []).length, blurred = (r.items || []).reduce((k, it) => k + ((readJson(join(t.root, `${it}.blur.json`)) || {}).regions || []).length, 0);
756
+ return { ok: true, product: name, tray: t.rel, items: r.items || [], skipped: r.skipped || [], ...(failed.length ? { failed } : {}), ...(seed ? { seed } : {}), ...(r.error ? { warning: r.error } : {}),
757
+ note: `${n} capture${n === 1 ? "" : "s"} of ${name}${symbiot ? ", from a copy with made-up work in it (Acme), not yours," : ""} in its pick tray (Marketing), with ${blurred} blur box${blurred === 1 ? "" : "es"} over what could be private. Look ${n === 1 ? "it" : "them"} over there: switch a box off, or draw one of your own.` };
758
+ } finally { BUSY = null; if (demo && demo.stop) await demo.stop(); }
759
+ }
760
+
761
+ export { SYMBIOT_SCREENS, screenUrl, isSymbiotRepo, screensOf, codeRoutes, fileRoute, routeOf, readmePaths, captionFor, seedScript, TEXT_KINDS, textHits, DETECT, tsvBoxes, ocrBoxes, namesToBlur, trayPaths, putInTray, tracks, heldBoxes, captureWeb, captureDesktop, windowOf, recordArgs, portalRecord, PORTAL_PY, productRepo, captureProduct, captureProducts, framesToVideo, NO_OCR, DESKTOP_REVIEW };