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/CHANGELOG.md +21 -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 +6 -4
- package/post.mjs +4 -2
- package/server.mjs +23 -4
- package/tray.mjs +154 -0
- package/ui.mjs +190 -37
- package/voice.mjs +113 -0
- package/watch.mjs +57 -10
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 };
|