symbiot 0.58.2 → 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/server.mjs CHANGED
@@ -15,7 +15,7 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSy
15
15
  import { createServer } from "node:http";
16
16
  import { randomBytes } from "node:crypto";
17
17
  import { EMBEDDED_UI } from "./ui.mjs";
18
- 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";
19
19
  import { shSingle, handoffCmd, setHandoffCmd, grantAgent, runHandoff, track, detectHandoffs, connectorsInfo, linkReach, answerQuestions, skipIdea, agentsList, startWaiting, parkLane, parkedPaths, autoAllowSweep, trustFull, readLastWords, sandboxState } from "./agents.mjs";
20
20
  import { PROVIDERS, resolveProvider, connectProvider, detectHardware, recommendModels, hasOllama, ollamaInstall, ensureOllama, useOllamaModel } from "./ai.mjs";
21
21
  import { SCAN, SCAN_TIMEOUT_MS, scanRoots, scanHome, addScanRoot, removeScanRoot, buildMap, nodeDetail, repoPathMap, laneMap, setScanOptions, refreshRepos, REPO_STATE, reposState } from "./scan.mjs";
@@ -23,7 +23,7 @@ import { computeDrift } from "./drift.mjs";
23
23
  import { addTask, toggleTask, removeTask, restoreTask, syncTasks, taskType, pushTasks, pendingReview, workingDiff, learnNpm, withReleases, releaseInput, approveRepo, approveChanges, sendBack, setAutoMerge } from "./tasks.mjs";
24
24
  import { repoReview, repoSuggest, folderSuggest, taskChat, clearTaskChat, mailState, setMail, sentMail, produce, releaseNotes } from "./writeups.mjs";
25
25
  import { loadScreens, screenImage, captureScreen, splitScreen, listMonitors, allowScreenshots, importScreen, setRegions, renameScreen, removeScreen, blueprint, clickRegion } from "./screens.mjs";
26
- 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";
27
27
  import { weeklyState, setWeekly, runWeekly, startWeekly, autostartState, setAutostart, installLauncher, iconSvg, setLauncherLook } from "./desktop.mjs";
28
28
  import { markNews, newsSince, watchState, addWatch, setEvery, removeWatch, clearNews, seenWatch, checkWatch, startWatches, setBrief, draftReply, openChat, watchBoard, boardChat, clearBoardChat } from "./watch.mjs";
29
29
  import { linksState, linkSite, checkLink, unlinkSite } from "./links.mjs";
@@ -33,7 +33,8 @@ import { lanesTick, lanesState, partlyDone, orcaRelink } from "./lanes.mjs";
33
33
  import { keepFacts, skipFacts, awaitTick, awaitingState, stopWaiting } from "./handback.mjs";
34
34
  import { adaptState, noteUse } from "./adapt.mjs";
35
35
  import { reportIdeasAdd, reportAsk, reportDraftAnswer, homeState, homeAsk, homeAnswer, homeNext, workScene, workGo, workTick, firstSteps, marketingState, marketingGo, marketingDraftAnswer, marketingTask, moveToMarketing, onboarding, setOnboarding } from "./home.mjs";
36
- 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";
37
38
  import { listReports, readReport, reportImage, markAllRead } from "./reports.mjs";
38
39
  import { phoneState, setPhoneLink, newCode, unpairPhone, pairComputer, forgetComputer, pollComputer, startPhone } from "./phone.mjs";
39
40
  import { knowledgeState, addKnowledgeFolder, removeKnowledgeFolder, indexKnowledge, knowledgeTick, searchKnowledge } from "./knowledge.mjs";
@@ -204,6 +205,7 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
204
205
  const FRESH = !SANDBOX && !existsSync(CONFIG_PATH);
205
206
  // Stable token + port so the URL survives a restart — the open tab can
206
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
207
209
  const cfg0 = loadConfig();
208
210
  let TOKEN = cfg0.appToken;
209
211
  if (!TOKEN) { TOKEN = randomBytes(16).toString("hex"); try { saveConfig({ ...loadConfig(), appToken: TOKEN }); } catch {} }
@@ -360,6 +362,8 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
360
362
  if (u.pathname === "/api/screens/whole" && req.method === "POST") { const b = await readBody(req); return json(res, screenOut(await wholePage(String(b.id || "")))); }
361
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")))); }
362
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 }))); }
363
367
  if (u.pathname === "/api/screens/trusted") return json(res, { sites: trustedSites() });
364
368
  if (u.pathname === "/api/screens/trusted/add" && req.method === "POST") { const b = await readBody(req); return json(res, trustSite(b.site)); }
365
369
  if (u.pathname === "/api/screens/trusted/remove" && req.method === "POST") { const b = await readBody(req); return json(res, untrustSite(b.site)); }
@@ -395,6 +399,12 @@ async function startApp({ bin, since = 7, all = false, c = PLAIN_COLOURS } = {})
395
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; }
396
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")); }
397
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))); }
398
408
  if (u.pathname === "/api/posts/draft" && req.method === "POST") return json(res, await draftPosts());
399
409
  if (u.pathname === "/api/posts/approve" && req.method === "POST") { const b = await readBody(req); return json(res, approvePost(String(b.id || ""))); }
400
410
  if (u.pathname === "/api/posts/edit" && req.method === "POST") { const b = await readBody(req); return json(res, editPost(String(b.id || ""), b.text)); }
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 };