symbiot 0.58.1 → 0.58.3

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/post.mjs CHANGED
@@ -33,7 +33,7 @@ import { resolveProvider, write } from "./ai.mjs";
33
33
  import { commits, discoveredRepos, laneMap } from "./scan.mjs";
34
34
  import { readTexts, pagePicture, pageClip, siteUrl, CLIP } from "./headless.mjs";
35
35
  import { runHandoff, runningHandoff } from "./agents.mjs";
36
- import { MARKETING_DIR, ensureMarketing, setDraftStatus, productOf, productNames } from "./marketing.mjs";
36
+ import { MARKETING_DIR, ensureMarketing, setDraftStatus, productOf, productNames, draftStatuses, postedCmd } from "./marketing.mjs";
37
37
 
38
38
  const PATHS = { posts: join(CONFIG_DIR, "posts.json"), log: join(CONFIG_DIR, "posts-log.jsonl"), voice: join(CONFIG_DIR, "voice.md"), media: join(CONFIG_DIR, "post-media"), test: join(CONFIG_DIR, "post-test.json") };
39
39
  const PLATFORM = "linkedin";
@@ -321,6 +321,8 @@ function handToMarketing(p, { now = Date.now(), paths = PATHS, dir = MARKETING_D
321
321
  if (!ensureMarketing(dir)) return { error: "Couldn't make Marketing's folder." };
322
322
  if (!names) { try { names = productNames(laneMap()); } catch { names = []; } }
323
323
  const product = productOf(p.text, names), folder = (product.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "linkedin"), rel = `drafts/${folder}/post-${p.id}.md`;
324
+ // posted already (its agent marked it): never written over and handed out a second time
325
+ const was = draftStatuses(dir)[rel]; if (was && (was.status === "posted" || was.posted)) return { error: `It's posted already (${rel}): it isn't posted twice.` };
324
326
  const media = [];
325
327
  try {
326
328
  mkdirSync(join(dir, "drafts", folder), { recursive: true });
@@ -330,7 +332,7 @@ function handToMarketing(p, { now = Date.now(), paths = PATHS, dir = MARKETING_D
330
332
  const st = setDraftStatus(rel, "approved", { dir, now }); if (st.error) return st;
331
333
  try {
332
334
  const f = join(dir, ".symbiot", "ANSWERS.md"), had = existsSync(f) ? readFileSync(f, "utf8") : "# Answers\n";
333
- writeFileSync(f, `${had.replace(/\s*$/, "")}\n\n### Approved: ${rel}\nThe user approved this post under Drafts to post. Post it on LinkedIn now, through Symbiot's signed-in browser, with exactly the text under its post (${p.text.length} characters, unchanged)${media.length ? ` and ${media.join(", ")} attached` : ""}. Then check it's there, and say so in your last message.\n_answered ${new Date(now).toISOString().slice(0, 10)}_\n`);
335
+ writeFileSync(f, `${had.replace(/\s*$/, "")}\n\n### Approved: ${rel}\nThe user approved this post under Drafts to post. Post it on LinkedIn now, through Symbiot's signed-in browser, with exactly the text under its post (${p.text.length} characters, unchanged)${media.length ? ` and ${media.join(", ")} attached` : ""}. Then check it's there, mark it posted (\`${postedCmd(rel)}\`), and say so in your last message.\n_answered ${new Date(now).toISOString().slice(0, 10)}_\n`);
334
336
  } catch (e) { return { error: "Couldn't tell Marketing's agent: " + ((e && e.message) || e) }; }
335
337
  if (running(dir)) return { rel, queued: true, said: "Approved. Marketing's agent posts it on LinkedIn once the run there now finishes." };
336
338
  const e = run(dir, { force: true });
package/server.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  // work, on 127.0.0.1 only and behind the per-install token. (Watch on your phone
4
4
  // listens on your network separately, only for that: phone.mjs.)
5
5
  import { claudeSetup, installClaude, signInClaude, sendClaudeCode } from "./claudesetup.mjs";
6
+ import { voiceState, prepareVoice, speak, stopVoices } from "./voice.mjs";
6
7
  import { spawn, spawnSync } from "node:child_process";
7
8
  import { toggleAway, closeAway } from "./away.mjs";
8
9
  import { homedir } from "node:os";
@@ -14,7 +15,7 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSy
14
15
  import { createServer } from "node:http";
15
16
  import { randomBytes } from "node:crypto";
16
17
  import { EMBEDDED_UI } from "./ui.mjs";
17
- import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, loadTasks, hasCmd, chromeBinary, CONFIG_DIR } from "./core.mjs";
18
+ import { VERSION, LATEST_VERSION, REGISTRY, semverGt, checkLatest, CONFIG_PATH, loadConfig, saveConfig, moveSecrets, loadTasks, hasCmd, chromeBinary, CONFIG_DIR } from "./core.mjs";
18
19
  import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, linkReach, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull, readLastWords, sandboxState } from "./agents.mjs";
19
20
  import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
20
21
  import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap, laneMap, setScanOptions, refreshRepos, REPO_STATE, reposState } from "./scan.mjs";
@@ -22,7 +23,7 @@ import { computeDrift } from "./drift.mjs";
22
23
  import { addTask, toggleTask, removeTask, restoreTask, syncTasks, taskType, pushTasks, pendingReview, workingDiff, learnNpm, withReleases, releaseInput, approveRepo, approveChanges, sendBack, setAutoMerge } from "./tasks.mjs";
23
24
  import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce, releaseNotes } from "./writeups.mjs";
24
25
  import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
25
- import { mapPage, wholePage, pressRegion, typeRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
26
+ import { mapPage, wholePage, pressRegion, typeRegion, uploadRegion, scrollPage, signIn, keepBrowserOpen, isTrusted, trustedSites, trustSite, untrustSite } from "./headless.mjs";
26
27
  import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart, installLauncher, iconSvg, setLauncherLook } from "./desktop.mjs";
27
28
  import { markNews, newsSince, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
28
29
  import { linksState, linkSite, checkLink, unlinkSite } from "./links.mjs";
@@ -32,7 +33,8 @@ import { lanesTick, lanesState, partlyDone, orcaRelink } from "./lanes.mjs";
32
33
  import { keepFacts, skipFacts, awaitTick, awaitingState, stopWaiting } from "./handback.mjs";
33
34
  import { adaptState, noteUse } from "./adapt.mjs";
34
35
  import { reportIdeasAdd, reportAsk, reportDraftAnswer, homeState, homeAsk, homeAnswer, homeNext, workScene, workGo, workTick, firstSteps, marketingState, marketingGo, marketingDraftAnswer, marketingTask, moveToMarketing, onboarding, setOnboarding } from "./home.mjs";
35
- import { MARKETING_DIR, MARKETING, draftPreview, laneMedia, displayName } from "./marketing.mjs";
36
+ import { MARKETING_DIR, MARKETING, draftPreview, laneMedia, displayName, setDraftMedia } from "./marketing.mjs";
37
+ import { trays, trayMedia, setBlur, renderCapture } from "./tray.mjs";
36
38
  import { listReports, readReport, reportImage, markAllRead } from "./reports.mjs";
37
39
  import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
38
40
  import { knowledgeState, addKnowledgeFolder, removeKnowledgeFolder, indexKnowledge, knowledgeTick, searchKnowledge } from "./knowledge.mjs";
@@ -203,6 +205,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
203
205
  const FRESH = !SANDBOX && !existsSync(CONFIG_PATH);
204
206
  // Stable token + port so the URL survives a restart — the open tab can
205
207
  // reconnect and auto-reload itself instead of you closing and reopening it.
208
+ try { moveSecrets(); } catch {} // keys from before secrets.json: out of config.json, where a run could read them
206
209
  const cfg0 = loadConfig();
207
210
  let TOKEN = cfg0.appToken;
208
211
  if (!TOKEN) { TOKEN = randomBytes(16).toString("hex"); try { saveConfig({ ...loadConfig(), appToken: TOKEN }); } catch {} }
@@ -359,6 +362,8 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
359
362
  if (u.pathname === "/api/screens/whole" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await wholePage(String(b.id || "")))); }
360
363
  if (u.pathname === "/api/screens/scroll" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await scrollPage(String(b.id || ""), String(b.to || "down")))); }
361
364
  if (u.pathname === "/api/screens/type" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await typeRegion(String(b.id || ""), String(b.region || ""), b.text, { enter: b.enter === true, confirmed: b.confirmed === true, noSend: b.noSend === true }))); }
365
+ // upload: a file of yours to the page's file box, asked like press and type
366
+ if (u.pathname === "/api/screens/upload" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await uploadRegion(String(b.id || ""), String(b.region || ""), Array.isArray(b.files) ? b.files.map(String) : String(b.files || ""), { confirmed: b.confirmed === true }))); }
362
367
  if (u.pathname === "/api/screens/trusted") return json(res, { sites: trustedSites() });
363
368
  if (u.pathname === "/api/screens/trusted/add" && req.method === "POST") { const b = await readBody(req); return json(res, trustSite(b.site)); }
364
369
  if (u.pathname === "/api/screens/trusted/remove" && req.method === "POST") { const b = await readBody(req); return json(res, untrustSite(b.site)); }
@@ -394,6 +399,12 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
394
399
  if (u.pathname === "/api/marketing/media") { const m = laneMedia(String(u.searchParams.get("rel") || "")); if (!m) { res.writeHead(404); res.end("not found"); return; } res.writeHead(200, { "content-type": m.type, "cache-control": "private, max-age=300" }); res.end(readFileSync(m.file)); return; }
395
400
  if (u.pathname === "/api/marketing/draft/answer" && req.method === "POST") { const b = await readBody(req); return json(res, marketingDraftAnswer(String(b.rel || ""), b.skip ? "skipped" : "approved")); }
396
401
  if (u.pathname === "/api/marketing/open" && req.method === "POST") { const b = await readBody(req), f = join(MARKETING_DIR, String(b.rel || "")); return json(res, f.startsWith(MARKETING_DIR + "/") && existsSync(f) ? { ok: openUrl(f) } : { error: "That draft isn't there any more." }); }
402
+ // the pick tray (tray.mjs): its captures and their blur boxes, a capture (only from inside
403
+ // a tray), a box switched on or off and the capture blurred again, a capture as a post's media
404
+ if (u.pathname === "/api/marketing/tray") return json(res, { trays: trays() });
405
+ if (u.pathname === "/api/marketing/tray/media") { const m = trayMedia(String(u.searchParams.get("rel") || "")); if (!m) { res.writeHead(404); res.end("not found"); return; } res.writeHead(200, { "content-type": m.type, "cache-control": "private, max-age=86400" }); res.end(readFileSync(m.file)); return; }
406
+ if (u.pathname === "/api/marketing/tray/blur" && req.method === "POST") { const b = await readBody(req), r = setBlur(String(b.tray || ""), String(b.name || ""), b.id, !!b.on); if (r.error) return json(res, r); const d = await renderCapture(String(b.tray), String(b.name)); return json(res, { ...r, rendered: !!d.ok, ...(d.error ? { note: d.error } : {}) }); }
407
+ if (u.pathname === "/api/marketing/tray/use" && req.method === "POST") { const b = await readBody(req); return json(res, setDraftMedia(String(b.rel || ""), [].concat(b.file || []).map(String))); }
397
408
  if (u.pathname === "/api/posts/draft" && req.method === "POST") return json(res, await draftPosts());
398
409
  if (u.pathname === "/api/posts/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approvePost(String(b.id || ""))); }
399
410
  if (u.pathname === "/api/posts/edit" && req.method === "POST") { const b = await readBody(req); return json(res, editPost(String(b.id || ""), b.text)); }
@@ -430,6 +441,14 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
430
441
  if (u.pathname === "/api/home/answer" && req.method === "POST") { const b = await readBody(req); return json(res, homeAnswer(b.id, { pick: b.pick, text: b.text })); }
431
442
  if (u.pathname === "/api/away" && req.method === "POST") { const b = await readBody(req); return json(res, toggleAway(`http://127.0.0.1:${server.address().port}/?t=${TOKEN}`, b.open)); }
432
443
  if (u.pathname === "/api/home/next" && req.method === "POST") { const b = await readBody(req); return json(res, homeNext(b.id)); }
444
+ // Symbiot's own voice (voice.mjs), for computers without a natural one: the page asks
445
+ if (u.pathname === "/api/voice") return json(res, voiceState(u.searchParams.get("lang") || ""));
446
+ if (u.pathname === "/api/voice/prepare" && req.method === "POST") { const b = await readBody(req); return json(res, prepareVoice(b.lang || "")); }
447
+ if (u.pathname === "/api/voice/say" && req.method === "POST") {
448
+ const b = await readBody(req);
449
+ try { const wav = await speak(b.text, b.lang || ""); res.writeHead(200, { "content-type": "audio/wav", "cache-control": "no-store", "content-length": wav.length }); return res.end(wav); }
450
+ catch (e) { return json(res, { error: String((e && e.message) || e) }); }
451
+ }
433
452
  if (u.pathname === "/api/home/ask" && req.method === "POST") { const b = await readBody(req); return json(res, await homeAsk(b.question, { images: saveShots(b.images) })); }
434
453
  if (u.pathname === "/api/adapt") return json(res, adaptState({ from: String(u.searchParams.get("from") || ""), commit: u.searchParams.get("commit") === "1", ...(u.searchParams.has("touch") ? { touch: u.searchParams.get("touch") === "1" } : {}) }));
435
454
  if (u.pathname === "/api/adapt/use" && req.method === "POST") { const b = await readBody(req); return json(res, noteUse(b)); }
@@ -520,7 +539,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
520
539
  const e = attempt(1);
521
540
  return json(res, { started: true, id: e ? e.id : "", target });
522
541
  }
523
- if (u.pathname === "/api/quit") { res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
542
+ if (u.pathname === "/api/quit") { stopVoices(); res.writeHead(200); res.end("bye"); setTimeout(() => process.exit(0), 150); return; }
524
543
  } catch (e) { res.writeHead(500, { "content-type": "application/json" }); res.end(JSON.stringify({ error: String((e && e.message) || e) })); return; }
525
544
  res.writeHead(404); res.end("not found");
526
545
  });
package/tray.mjs ADDED
@@ -0,0 +1,154 @@
1
+ // symbiot — the pick tray: every capture Marketing's agent took of a product, blurred where
2
+ // it shows something private, to pick a post's picture or video from on the Marketing page.
3
+ // A tray is drafts/<product>/tray/ in the lane: tray.json, its index ({ tray, items: [{ name,
4
+ // kind: still|clip|reel, files, screen, caption, for: [draft file], blur }] }), and for each
5
+ // capture <name>.blur.json ({ source, kind, size: [w, h], trim?, fps?, width?, regions: [{ id,
6
+ // kind, x, y, w, h, on, hint, from?, to? }] }) beside its blurred files; a reel is
7
+ // <name>.reel.json ({ parts: [clip names] }), its clips' videos one after another. A box
8
+ // switched on or off is written back to that .blur.json, then Symbiot blurs the capture again
9
+ // itself, with ffmpeg: the app never runs a script an agent wrote (the lane's tools/tray.py
10
+ // did it until 2026-10-09, with the app's access). The unblurred originals stay in the lane's
11
+ // .symbiot/tray-originals/<product>/ and are never served from here.
12
+ import { join, dirname, basename } from "node:path";
13
+ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync, realpathSync, renameSync, rmSync, mkdtempSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { spawn, spawnSync } from "node:child_process";
16
+ import { MARKETING_DIR, MEDIA_TYPES, draftFiles, parseDraft, inLane } from "./marketing.mjs";
17
+
18
+ const NAME = /^\w[\w.-]{0,100}$/; // a capture's file: no slashes, no dot first
19
+ const IMG = /\.(?:png|jpe?g|gif|webp)$/i, VID = /\.(?:mp4|webm|mov)$/i;
20
+ const readJson = (f) => { try { return JSON.parse(readFileSync(f, "utf8")); } catch { return null; } };
21
+
22
+ // The lane's trays, by their path there: drafts/<product>/tray, each with its tray.json.
23
+ function trayDirs(dir = MARKETING_DIR) {
24
+ let ents = []; try { ents = readdirSync(join(dir, "drafts"), { withFileTypes: true }); } catch { return []; }
25
+ return ents.filter((e) => e.isDirectory() && !e.name.startsWith(".") && existsSync(join(dir, "drafts", e.name, "tray", "tray.json"))).map((e) => `drafts/${e.name}/tray`).sort();
26
+ }
27
+ const isTray = (dir, rel) => trayDirs(dir).includes(String(rel || ""));
28
+
29
+ // A file of a tray, only if it's really in one (no ../, no link out of it): { file, type } or null.
30
+ function trayMedia(rel, { dir = MARKETING_DIR } = {}) {
31
+ const m = String(rel || "").match(/^(drafts\/[^/]+\/tray)\/([^/]+)$/); if (!m || !NAME.test(m[2]) || !isTray(dir, m[1])) return null;
32
+ const f = inLane(dir, rel), type = f && MEDIA_TYPES[f.split(".").pop().toLowerCase()];
33
+ try { return type && dirname(f) === realpathSync(join(dir, m[1])) ? { file: f, type } : null; } catch { return null; }
34
+ }
35
+
36
+ // One tray as the page shows it: each capture with what shows it (its picture, else its
37
+ // video), the file a post takes (a still's picture, a clip's video), its blur boxes as
38
+ // fractions of its size (so they sit on it at any width), and when it was last rendered.
39
+ function trayOf(dir, rel) {
40
+ const root = join(dir, rel), idx = readJson(join(root, "tray.json")) || {};
41
+ const items = (Array.isArray(idx.items) ? idx.items : []).filter((it) => it && NAME.test(String(it.name || ""))).map((it) => {
42
+ const files = (Array.isArray(it.files) ? it.files : []).filter((f) => NAME.test(String(f)) && existsSync(join(root, f)));
43
+ const show = files.find((f) => IMG.test(f)) || files.find((f) => VID.test(f)) || "";
44
+ const use = (it.kind === "still" ? files.find((f) => IMG.test(f)) : files.find((f) => VID.test(f))) || show;
45
+ const spec = NAME.test(`${it.name}.blur.json`) ? readJson(join(root, `${it.name}.blur.json`)) : null;
46
+ const [w, h] = spec && Array.isArray(spec.size) ? spec.size.map(Number) : [0, 0];
47
+ const regions = spec && Array.isArray(spec.regions) && w > 0 && h > 0 ? spec.regions.filter((r) => r && r.id != null).map((r) => ({ id: r.id, kind: String(r.kind || "box"), hint: String(r.hint || ""), on: !!r.on,
48
+ x: r.x / w, y: r.y / h, w: r.w / w, h: r.h / h, ...(r.from != null || r.to != null ? { from: Number(r.from) || 0, to: Number(r.to) || 0 } : {}) })) : [];
49
+ let at = 0; try { at = show ? statSync(join(root, show)).mtimeMs : 0; } catch {}
50
+ return { name: it.name, kind: String(it.kind || (spec && spec.kind) || "still"), caption: String(it.caption || (spec && spec.caption) || ""), screen: String(it.screen || ""), for: (Array.isArray(it.for) ? it.for : []).map(String),
51
+ show: show ? `${rel}/${show}` : "", showKind: VID.test(show) ? "video" : "image", use: use ? `${rel}/${use}` : "", blur: !!spec, regions, parts: Array.isArray(it.parts) ? it.parts.map(String) : [], at };
52
+ });
53
+ return { rel, folder: dirname(rel), items };
54
+ }
55
+ // Every tray in the lane, each with its product's drafts (not those in the tray) to use a
56
+ // capture in: their status (posted and superseded ones take no picture) and media now.
57
+ function trays({ dir = MARKETING_DIR } = {}) {
58
+ const drafts = draftFiles(dir, [], { max: 500 });
59
+ return trayDirs(dir).map((rel) => {
60
+ const t = trayOf(dir, rel), ds = drafts.filter((d) => d.rel.startsWith(t.folder + "/") && !d.rel.startsWith(rel + "/"));
61
+ const media = (r) => { try { return parseDraft(readFileSync(join(dir, r), "utf8")).media.map((m) => join(dirname(r), m)); } catch { return []; } };
62
+ return { ...t, product: (ds.find((d) => d.product) || {}).product || basename(t.folder), drafts: ds.sort((a, b) => a.rel.localeCompare(b.rel)).map((d) => ({ rel: d.rel, name: d.name, status: d.status, media: media(d.rel) })) };
63
+ });
64
+ }
65
+
66
+ // A blur box switched on or off (or several: a clip's box over the same place in each of its
67
+ // moments), written back to its capture's .blur.json, nothing else in it touched. { ok, on } or { error }.
68
+ function setBlur(tray, name, id, on, { dir = MARKETING_DIR } = {}) {
69
+ if (!isTray(dir, tray) || !NAME.test(String(name || ""))) return { error: "That capture isn't in a tray any more." };
70
+ const f = join(dir, tray, `${name}.blur.json`), spec = readJson(f);
71
+ if (!spec || !Array.isArray(spec.regions)) return { error: "That capture has no blur boxes." };
72
+ const ids = [].concat(id).map(String), rs = spec.regions.filter((x) => x && ids.includes(String(x.id)));
73
+ if (!rs.length || rs.length < new Set(ids).size) return { error: "That blur box isn't there any more." };
74
+ for (const r of rs) r.on = !!on;
75
+ try { writeFileSync(f, JSON.stringify(spec, null, 2)); } catch (e) { return { error: "Couldn't save it: " + ((e && e.message) || e) }; }
76
+ return { ok: true, on: !!on, ids };
77
+ }
78
+ // The capture rendered again from its original with its boxes as they are now, and every
79
+ // reel it's in joined again (a box blurred in a clip is blurred in its reel too). One at a
80
+ // time a capture: two clicks don't write the same files at once. { ok, files } or { error }.
81
+ const RENDERING = new Map();
82
+ function renderCapture(tray, name, { dir = MARKETING_DIR, run = ffmpeg, size = sizeOf } = {}) {
83
+ if (!isTray(dir, tray) || !NAME.test(String(name || ""))) return Promise.resolve({ error: "That capture isn't in a tray any more." });
84
+ const key = `${dir}\0${tray}\0${name}`, next = (RENDERING.get(key) || Promise.resolve()).then(() => render(dir, tray, name, run, size));
85
+ RENDERING.set(key, next.catch(() => {}));
86
+ return next;
87
+ }
88
+ async function render(dir, tray, name, run, size) {
89
+ const root = join(dir, tray), spec = readJson(join(root, `${name}.blur.json`));
90
+ if (!spec || !Array.isArray(spec.regions) || !["still", "clip"].includes(spec.kind)) return { error: "That capture has nothing to blur." };
91
+ const orig = join(dir, ".symbiot", "tray-originals", basename(dirname(root))), src = join(orig, String(spec.source || ""));
92
+ let real = ""; try { real = NAME.test(String(spec.source || "")) && dirname(realpathSync(src)) === realpathSync(orig) ? realpathSync(src) : ""; } catch {}
93
+ if (!real) return { error: "Its unblurred original isn't in the lane's .symbiot/tray-originals: its agent renders it next time." };
94
+ const [w, h] = size(real) || (Array.isArray(spec.size) ? spec.size.map(Number) : []);
95
+ if (!(w > 0 && h > 0)) return { error: "Couldn't tell how big the capture is." };
96
+ const still = spec.kind === "still", fps = Number(spec.fps) || 12, [start, end] = Array.isArray(spec.trim) ? spec.trim.map(Number) : [0, 0];
97
+ const { graph, out } = blurGraph(spec.regions.filter((r) => r && r.on), w, h, still ? null : start || 0, still ? "[0:v]format=rgb24[v0]" : `[0:v]fps=${fps},format=rgb24[v0]`);
98
+ const outs = still ? [`${name}.png`] : [`${name}.gif`, `${name}.mp4`], tmp = outs.map((f) => join(root, `.${f}.render.${f.split(".").pop()}`)); // dotted: not served, nor in tray.json, until it's whole
99
+ const args = still ? ["-i", real, "-filter_complex", graph, "-map", out, "-frames:v", "1", tmp[0]]
100
+ : [...(start > 0 ? ["-ss", String(start)] : []), ...(end > 0 ? ["-to", String(end)] : []), "-i", real, "-filter_complex",
101
+ `${graph};${out}split[g][m];[g]scale=${Number(spec.width) || 1200}:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=128:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=4:diff_mode=rectangle[gif];[m]scale=1600:-2[mp4]`,
102
+ "-map", "[gif]", tmp[0], "-map", "[mp4]", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "20", "-movflags", "+faststart", "-an", tmp[1]];
103
+ const r = await run(["-loglevel", "error", "-y", ...args]);
104
+ if (!r.ok) { for (const f of tmp) rmSync(f, { force: true }); return r; }
105
+ outs.forEach((f, k) => renameSync(tmp[k], join(root, f)));
106
+ const files = [...outs];
107
+ if (!still) for (const reel of reelsWith(root, name)) { const j = await joinReel(root, reel, run); if (!j.ok) return { error: `Blurred, but its reel ${reel} isn't joined again: ${j.error}` }; files.push(`${reel}.mp4`); }
108
+ return { ok: true, files };
109
+ }
110
+ // ffmpeg's filters for the boxes switched on: each pixelated (a tenth its size), then blurred,
111
+ // so the text under it can't be read back, as tray.py did. A clip's box is there only in its
112
+ // moments (from/to, in the original's seconds; its times start at its trim). { graph, out }
113
+ function blurGraph(boxes, W, H, start, head) {
114
+ const parts = [head]; let n = 0;
115
+ for (const b of boxes) {
116
+ const x = Math.max(0, Math.round(Number(b.x) || 0)), y = Math.max(0, Math.round(Number(b.y) || 0));
117
+ const cw = Math.min(W, Math.round((Number(b.x) || 0) + (Number(b.w) || 0))) - x, ch = Math.min(H, Math.round((Number(b.y) || 0) + (Number(b.h) || 0))) - y;
118
+ if (cw <= 0 || ch <= 0) continue;
119
+ const when = start == null || (b.from == null && b.to == null) ? "" : `:enable='between(t,${(Number(b.from) || 0) - start},${(b.to == null ? 1e9 : Number(b.to)) - start})'`;
120
+ parts.push(`[v${n}]split[v${n}a][v${n}b];[v${n}b]crop=${cw}:${ch}:${x}:${y},scale=${Math.max(1, Math.floor(cw / 10))}:${Math.max(1, Math.floor(ch / 10))}:flags=bilinear,scale=${cw}:${ch}:flags=neighbor,gblur=sigma=6[p${n}];[v${n}a][p${n}]overlay=${x}:${y}:format=rgb${when}[v${n + 1}]`);
121
+ n++;
122
+ }
123
+ return { graph: parts.join(";"), out: `[v${n}]` };
124
+ }
125
+ // The reels (<reel>.reel.json) a clip is a part of, and one joined again from its clips' videos.
126
+ function reelsWith(root, name) {
127
+ let fs = []; try { fs = readdirSync(root); } catch {}
128
+ return fs.filter((f) => f.endsWith(".reel.json") && NAME.test(f)).map((f) => f.slice(0, -10)).filter((r) => { const s = readJson(join(root, `${r}.reel.json`)); return s && Array.isArray(s.parts) && s.parts.includes(name); });
129
+ }
130
+ async function joinReel(root, reel, run) {
131
+ const s = readJson(join(root, `${reel}.reel.json`)), parts = (s && Array.isArray(s.parts) ? s.parts : []).map(String);
132
+ if (!parts.length || !parts.every((p) => NAME.test(p) && existsSync(join(root, `${p}.mp4`)))) return { error: "one of its clips is missing" };
133
+ const t = mkdtempSync(join(tmpdir(), "symbiot-reel-")), list = join(t, "parts.txt"), tmp = join(root, `.${reel}.mp4.render.mp4`);
134
+ try {
135
+ writeFileSync(list, parts.map((p) => `file '${join(root, `${p}.mp4`).replace(/'/g, "'\\''")}'\n`).join(""));
136
+ const r = await run(["-loglevel", "error", "-y", "-f", "concat", "-safe", "0", "-i", list, "-c", "copy", "-movflags", "+faststart", tmp]);
137
+ if (!r.ok) { rmSync(tmp, { force: true }); return r; }
138
+ renameSync(tmp, join(root, `${reel}.mp4`)); return { ok: true };
139
+ } finally { rmSync(t, { recursive: true, force: true }); }
140
+ }
141
+ // A capture's size, by ffprobe ([w, h]), or null (then its .blur.json's size is used).
142
+ function sizeOf(file) {
143
+ try { const r = spawnSync("ffprobe", ["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", file], { encoding: "utf8", timeout: 20000 }); const m = String(r.stdout || "").match(/(\d+)x(\d+)/); return m ? [+m[1], +m[2]] : null; } catch { return null; }
144
+ }
145
+ function ffmpeg(args, { timeout = 180000 } = {}) {
146
+ return new Promise((done) => {
147
+ let err = "", p; try { p = spawn("ffmpeg", args, { stdio: ["ignore", "ignore", "pipe"], timeout }); } catch (e) { done({ error: String((e && e.message) || e) }); return; }
148
+ p.stderr.on("data", (d) => { err = (err + d).slice(-2000); });
149
+ p.on("error", (e) => done({ error: e.code === "ENOENT" ? "Blurring needs ffmpeg, which isn't installed (on Linux: sudo apt install ffmpeg; on a Mac: brew install ffmpeg)." : String(e.message || e) }));
150
+ p.on("close", (code) => done(code === 0 ? { ok: true } : { error: (err.trim().split("\n").pop() || `ffmpeg stopped (${code})`).slice(0, 300) }));
151
+ });
152
+ }
153
+
154
+ export { trayDirs, trayMedia, trayOf, trays, setBlur, renderCapture };