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/CHANGELOG.md +17 -0
- package/README.md +2 -0
- package/agents.mjs +67 -18
- package/core.mjs +29 -3
- package/guard.mjs +4 -3
- package/headless.mjs +105 -9
- package/home.mjs +8 -6
- package/index.mjs +60 -7
- package/lanes.mjs +9 -5
- package/marketing.mjs +86 -11
- package/mind.mjs +96 -20
- package/package.json +4 -3
- package/post.mjs +4 -2
- package/server.mjs +13 -3
- package/tray.mjs +154 -0
- package/ui.mjs +157 -32
- package/watch.mjs +57 -10
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 };
|