@viralspin/mcp 0.4.0 → 0.5.0
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/dist/cli.js +11 -1
- package/dist/client.js +4 -4
- package/dist/index.js +7 -0
- package/dist/local-render/cwd.js +29 -0
- package/dist/local-render/media-fix.js +71 -0
- package/dist/local-render/render.js +25 -3
- package/dist/local-render/startup.js +9 -9
- package/dist/local-render/tokens.js +4 -0
- package/dist/local-render/watch.js +49 -13
- package/dist/organize-watch.js +551 -0
- package/dist/organizer/edit.js +148 -0
- package/dist/organizer/images.js +163 -0
- package/dist/organizer/plan.js +204 -0
- package/dist/organizer/scan.js +331 -0
- package/dist/organizer/topics.js +204 -0
- package/dist/tools/organize.js +171 -0
- package/package.json +46 -42
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.embFile = exports.THUMB_PORT = void 0;
|
|
7
|
+
exports.warmFolderPicker = warmFolderPicker;
|
|
8
|
+
exports.pickFolder = pickFolder;
|
|
9
|
+
exports.clipFile = clipFile;
|
|
10
|
+
exports.startThumbServer = startThumbServer;
|
|
11
|
+
exports.resultOf = resultOf;
|
|
12
|
+
exports.organizeTick = organizeTick;
|
|
13
|
+
exports.organizeLoop = organizeLoop;
|
|
14
|
+
exports.organizeDelay = organizeDelay;
|
|
15
|
+
/**
|
|
16
|
+
* THE CONNECTOR'S ORGANIZER: while `viralspin-mcp watch` runs, a "Sort a folder" asked from the web app (My Story ▸ Organized) is done HERE, on the creator's own computer.
|
|
17
|
+
* It scans the folder (read-only, nothing moved), reports progress and the bucket names + counts, and serves the small thumbnails to the web page over loopback
|
|
18
|
+
* (127.0.0.1, only to ViralSpin's own origins), so the animation can show the creator's real pictures without any of them being uploaded. Adding the folder to My Story
|
|
19
|
+
* (the opt-in upload of thumbnails + tags) happens only when the web app asks for it.
|
|
20
|
+
*/
|
|
21
|
+
const child_process_1 = require("child_process");
|
|
22
|
+
const fs_1 = __importDefault(require("fs"));
|
|
23
|
+
const os_1 = __importDefault(require("os"));
|
|
24
|
+
const http_1 = __importDefault(require("http"));
|
|
25
|
+
const path_1 = __importDefault(require("path"));
|
|
26
|
+
const organize_js_1 = require("./tools/organize.js");
|
|
27
|
+
const plan_js_1 = require("./organizer/plan.js");
|
|
28
|
+
const scan_js_1 = require("./organizer/scan.js");
|
|
29
|
+
const edit_js_1 = require("./organizer/edit.js");
|
|
30
|
+
const topics_js_1 = require("./organizer/topics.js");
|
|
31
|
+
const images_js_1 = require("./organizer/images.js");
|
|
32
|
+
exports.THUMB_PORT = 47831;
|
|
33
|
+
const ORIGIN_OK = /^(https:\/\/([\w-]+\.)?viralspin\.ai|http:\/\/localhost:\d+|http:\/\/127\.0\.0\.1:\d+)$/;
|
|
34
|
+
/**
|
|
35
|
+
* Windows: ONE PowerShell kept running with the forms library already loaded, so "Choose a folder…" opens at once instead of after a cold start; it
|
|
36
|
+
* reads "pick" lines and answers each with the chosen path ("" = cancelled). The window is Windows' own folder picker (the Explorer dialog with
|
|
37
|
+
* folders only and a "Sort this folder" button, via IFileOpenDialog + FOS_PICKFOLDERS), forced to the front: a background process can not normally take the foreground, which left the
|
|
38
|
+
* old dialog hidden behind the browser and looking like it took forever.
|
|
39
|
+
*/
|
|
40
|
+
const WIN_PICKER = String.raw `
|
|
41
|
+
[Console]::OutputEncoding=[Text.Encoding]::UTF8
|
|
42
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
43
|
+
Add-Type -TypeDefinition @'
|
|
44
|
+
using System;
|
|
45
|
+
using System.Runtime.InteropServices;
|
|
46
|
+
[ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")] class VsFileOpenDialogRCW {}
|
|
47
|
+
[ComImport, Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
48
|
+
interface IVsShellItem {
|
|
49
|
+
void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
|
|
50
|
+
void GetParent(out IVsShellItem p);
|
|
51
|
+
void GetDisplayName(uint sigdn, [MarshalAs(UnmanagedType.LPWStr)] out string name);
|
|
52
|
+
void GetAttributes(uint m, out uint a);
|
|
53
|
+
void Compare(IVsShellItem i, uint h, out int o);
|
|
54
|
+
}
|
|
55
|
+
[ComImport, Guid("d57c7288-d4ad-4768-be02-9d969532d960"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
56
|
+
interface IVsFileOpenDialog {
|
|
57
|
+
[PreserveSig] int Show(IntPtr parent);
|
|
58
|
+
void SetFileTypes(uint c, IntPtr f); void SetFileTypeIndex(uint i); void GetFileTypeIndex(out uint i);
|
|
59
|
+
void Advise(IntPtr e, out uint c); void Unadvise(uint c);
|
|
60
|
+
void SetOptions(uint fos); void GetOptions(out uint fos);
|
|
61
|
+
void SetDefaultFolder(IVsShellItem i); void SetFolder(IVsShellItem i); void GetFolder(out IVsShellItem i); void GetCurrentSelection(out IVsShellItem i);
|
|
62
|
+
void SetFileName([MarshalAs(UnmanagedType.LPWStr)] string n); void GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string n);
|
|
63
|
+
void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string t); void SetOkButtonLabel([MarshalAs(UnmanagedType.LPWStr)] string t);
|
|
64
|
+
void SetFileNameLabel([MarshalAs(UnmanagedType.LPWStr)] string t);
|
|
65
|
+
void GetResult(out IVsShellItem i);
|
|
66
|
+
void AddPlace(IVsShellItem i, int fdap); void SetDefaultExtension([MarshalAs(UnmanagedType.LPWStr)] string e);
|
|
67
|
+
void Close(int hr); void SetClientGuid(ref Guid g); void ClearClientData(); void SetFilter(IntPtr f);
|
|
68
|
+
void GetResults(out IntPtr e); void GetSelectedItems(out IntPtr e);
|
|
69
|
+
}
|
|
70
|
+
public static class VsPick {
|
|
71
|
+
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);
|
|
72
|
+
[DllImport("user32.dll")] public static extern void keybd_event(byte k, byte s, int f, int e);
|
|
73
|
+
public static string Pick(IntPtr owner, string title) {
|
|
74
|
+
IVsFileOpenDialog d = (IVsFileOpenDialog)new VsFileOpenDialogRCW();
|
|
75
|
+
uint o; d.GetOptions(out o);
|
|
76
|
+
d.SetOptions(o | 0x20 | 0x40);
|
|
77
|
+
d.SetTitle(title); d.SetOkButtonLabel("Sort this folder");
|
|
78
|
+
if (d.Show(owner) != 0) return "";
|
|
79
|
+
IVsShellItem r; d.GetResult(out r);
|
|
80
|
+
string p; r.GetDisplayName(0x80058000, out p);
|
|
81
|
+
return p;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
'@
|
|
85
|
+
[Console]::Out.WriteLine("ready"); [Console]::Out.Flush()
|
|
86
|
+
while ($true) {
|
|
87
|
+
$line = [Console]::In.ReadLine(); if ($line -eq $null) { break }
|
|
88
|
+
$o = New-Object System.Windows.Forms.Form
|
|
89
|
+
$o.TopMost = $true; $o.ShowInTaskbar = $false; $o.FormBorderStyle = 'None'; $o.StartPosition = 'Manual'
|
|
90
|
+
$o.Location = New-Object System.Drawing.Point(-3000, -3000); $o.Size = New-Object System.Drawing.Size(1, 1)
|
|
91
|
+
$o.Show(); [VsPick]::keybd_event(0x12, 0, 0, 0); [VsPick]::keybd_event(0x12, 0, 2, 0); [void][VsPick]::SetForegroundWindow($o.Handle); $o.Activate()
|
|
92
|
+
$r = ''; try { $r = [VsPick]::Pick($o.Handle, 'Choose a folder of pictures and clips to sort') } catch { $r = '' }
|
|
93
|
+
$o.Close(); $o.Dispose(); [Console]::Out.WriteLine('PICKED:' + $r); [Console]::Out.Flush()
|
|
94
|
+
}
|
|
95
|
+
`;
|
|
96
|
+
let winPicker = null;
|
|
97
|
+
function winPickerProc() {
|
|
98
|
+
if (winPicker && winPicker.child.exitCode === null)
|
|
99
|
+
return winPicker;
|
|
100
|
+
const file = path_1.default.join(os_1.default.tmpdir(), "viralspin-folder-picker.ps1");
|
|
101
|
+
fs_1.default.writeFileSync(file, WIN_PICKER);
|
|
102
|
+
const child = (0, child_process_1.spawn)("powershell.exe", ["-NoProfile", "-STA", "-ExecutionPolicy", "Bypass", "-File", file], { windowsHide: true });
|
|
103
|
+
const me = { child, waiting: [] };
|
|
104
|
+
let buf = "";
|
|
105
|
+
child.stdout.on("data", (d) => { buf += d; let i; while ((i = buf.indexOf("\n")) >= 0) {
|
|
106
|
+
const l = buf.slice(0, i).trim();
|
|
107
|
+
buf = buf.slice(i + 1);
|
|
108
|
+
if (l.startsWith("PICKED:"))
|
|
109
|
+
me.waiting.shift()?.(l.slice(7));
|
|
110
|
+
} });
|
|
111
|
+
child.on("exit", () => { me.waiting.splice(0).forEach((f) => f("")); if (winPicker === me)
|
|
112
|
+
winPicker = null; });
|
|
113
|
+
child.on("error", () => { me.waiting.splice(0).forEach((f) => f("")); });
|
|
114
|
+
winPicker = me;
|
|
115
|
+
return me;
|
|
116
|
+
}
|
|
117
|
+
/** Start the Windows picker ahead of time, so the first click is fast too. */
|
|
118
|
+
function warmFolderPicker() { if (process.platform === "win32") {
|
|
119
|
+
try {
|
|
120
|
+
winPickerProc();
|
|
121
|
+
}
|
|
122
|
+
catch { /* the first click starts it */ }
|
|
123
|
+
} }
|
|
124
|
+
/** The computer's own "choose a folder" window. Resolves to the path, or "" when cancelled. A web page cannot learn a folder's real path, so the connector asks the OS. */
|
|
125
|
+
function pickFolder() {
|
|
126
|
+
const done = (resolve) => (err, out) => resolve(err ? "" : String(out).trim());
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
if (process.platform === "win32") {
|
|
129
|
+
const p = winPickerProc();
|
|
130
|
+
p.waiting.push(resolve);
|
|
131
|
+
p.child.stdin?.write("pick\n");
|
|
132
|
+
}
|
|
133
|
+
else if (process.platform === "darwin") {
|
|
134
|
+
(0, child_process_1.execFile)("osascript", ["-e", 'POSIX path of (choose folder with prompt "Choose a folder of pictures and clips to sort")'], { timeout: 300000 }, done(resolve));
|
|
135
|
+
}
|
|
136
|
+
else
|
|
137
|
+
(0, child_process_1.execFile)("zenity", ["--file-selection", "--directory", "--title=Choose a folder to sort"], { timeout: 300000 }, done(resolve));
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/** The sort running on this computer right now: its previews in the order they were made (the page builds its live pile from these). */
|
|
141
|
+
const live = { job: "", thumbs: [], at: new Map(), cards: [] };
|
|
142
|
+
const clipJobs = new Map();
|
|
143
|
+
/**
|
|
144
|
+
* A plan, parsed once and kept while its file is unchanged. A big sort's plan is tens of MB; the replay asks for a thousand thumbnails at once,
|
|
145
|
+
* and parsing the plan for each one held every thumbnail up for minutes (the page drew colour tiles where the pictures should be).
|
|
146
|
+
*/
|
|
147
|
+
const planCache = new Map();
|
|
148
|
+
function readPlan(planId) {
|
|
149
|
+
const f = (0, organize_js_1.planFile)(planId);
|
|
150
|
+
const st = fs_1.default.statSync(f);
|
|
151
|
+
const hit = planCache.get(planId);
|
|
152
|
+
if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size)
|
|
153
|
+
return hit.plan;
|
|
154
|
+
const plan = JSON.parse(fs_1.default.readFileSync(f, "utf8"));
|
|
155
|
+
planCache.set(planId, { mtimeMs: st.mtimeMs, size: st.size, plan });
|
|
156
|
+
if (planCache.size > 4)
|
|
157
|
+
planCache.delete(planCache.keys().next().value);
|
|
158
|
+
return plan;
|
|
159
|
+
}
|
|
160
|
+
/** The playable clip for a plan item (cached): the Live Photo's paired clip, or the first 10 s of a video, as H.264 + AAC, at most 1080 px tall. */
|
|
161
|
+
function clipFile(planId, i) {
|
|
162
|
+
const key = `${planId}:${i}`;
|
|
163
|
+
if (!clipJobs.has(key))
|
|
164
|
+
clipJobs.set(key, (async () => {
|
|
165
|
+
const plan = readPlan(planId);
|
|
166
|
+
const it = plan.items?.[i];
|
|
167
|
+
const src = it?.live ? path_1.default.join(plan.root, it.live) : it?.kind === "video" ? path_1.default.join(plan.root, it.rel) : null;
|
|
168
|
+
if (!src || !fs_1.default.existsSync(src))
|
|
169
|
+
return null;
|
|
170
|
+
const dir = path_1.default.join(organize_js_1.STATE_DIR, `${planId}-clips`);
|
|
171
|
+
const out = path_1.default.join(dir, `${i}.mp4`);
|
|
172
|
+
if (fs_1.default.existsSync(out) && fs_1.default.statSync(out).size > 0)
|
|
173
|
+
return out;
|
|
174
|
+
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
175
|
+
const ok = await new Promise((done) => {
|
|
176
|
+
(0, child_process_1.execFile)((0, images_js_1.ffmpegPath)(), ["-v", "error", "-y", "-i", src, "-t", "10", "-vf", "scale=-2:'min(1080,ih)'", "-c:v", "libx264", "-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", out], { timeout: 120000 }, (e) => done(!e && fs_1.default.existsSync(out)));
|
|
177
|
+
});
|
|
178
|
+
if (!ok) {
|
|
179
|
+
clipJobs.delete(key);
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
})());
|
|
184
|
+
return clipJobs.get(key);
|
|
185
|
+
}
|
|
186
|
+
/** Loopback server for thumbnails: GET /thumb/<planId>/<index>.jpg. Only the app's own origins get a CORS grant; plan ids are random. */
|
|
187
|
+
function startThumbServer(log) {
|
|
188
|
+
const server = http_1.default.createServer((req, res) => {
|
|
189
|
+
const origin = String(req.headers.origin || "");
|
|
190
|
+
if (ORIGIN_OK.test(origin)) {
|
|
191
|
+
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
192
|
+
res.setHeader("Access-Control-Allow-Private-Network", "true");
|
|
193
|
+
res.setHeader("Vary", "Origin");
|
|
194
|
+
}
|
|
195
|
+
if (req.method === "OPTIONS") {
|
|
196
|
+
res.statusCode = 204;
|
|
197
|
+
return res.end();
|
|
198
|
+
}
|
|
199
|
+
const route = (req.url || "").split("?")[0];
|
|
200
|
+
// the running sort's previews: /live/<job> -> { count }, /live/<job>/<n>.jpg -> the n-th preview made
|
|
201
|
+
// the cards so far, in the order they were placed: /live/<job>/cards/<from> -> { rows: [[preview n, card], ...] }
|
|
202
|
+
const lc = /^\/live\/([\w-]{4,60})\/cards\/(\d{1,6})$/.exec(route);
|
|
203
|
+
if (lc) {
|
|
204
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
205
|
+
res.statusCode = 403;
|
|
206
|
+
return res.end();
|
|
207
|
+
}
|
|
208
|
+
res.setHeader("Content-Type", "application/json");
|
|
209
|
+
return res.end(JSON.stringify({ rows: lc[1] === live.job ? live.cards.slice(Number(lc[2]), Number(lc[2]) + 500) : [] }));
|
|
210
|
+
}
|
|
211
|
+
const lm = /^\/live\/([\w-]{4,60})(?:\/(\d{1,6})\.jpg)?$/.exec(route);
|
|
212
|
+
if (lm) {
|
|
213
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
214
|
+
res.statusCode = 403;
|
|
215
|
+
return res.end();
|
|
216
|
+
}
|
|
217
|
+
if (lm[1] !== live.job) {
|
|
218
|
+
res.setHeader("Content-Type", "application/json");
|
|
219
|
+
return res.end(JSON.stringify({ count: 0 }));
|
|
220
|
+
}
|
|
221
|
+
if (lm[2] === undefined) {
|
|
222
|
+
res.setHeader("Content-Type", "application/json");
|
|
223
|
+
return res.end(JSON.stringify({ count: live.thumbs.length, cards: live.cards.length }));
|
|
224
|
+
}
|
|
225
|
+
const f = live.thumbs[Number(lm[2])];
|
|
226
|
+
if (!f || !fs_1.default.existsSync(f)) {
|
|
227
|
+
res.statusCode = 404;
|
|
228
|
+
return res.end();
|
|
229
|
+
}
|
|
230
|
+
res.setHeader("Content-Type", "image/jpeg");
|
|
231
|
+
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
232
|
+
return void fs_1.default.createReadStream(f).pipe(res);
|
|
233
|
+
}
|
|
234
|
+
if (route === "/ping") {
|
|
235
|
+
res.setHeader("Content-Type", "application/json");
|
|
236
|
+
return res.end(JSON.stringify({ ok: true, items: 1 }));
|
|
237
|
+
}
|
|
238
|
+
// a sorted folder's pictures, for browsing and using them on this computer without uploading anything: one row per picture with a preview
|
|
239
|
+
// a Live Photo's clip, or the first seconds of a video, converted once to H.264 (iPhone clips are HEVC, which most browsers on Windows
|
|
240
|
+
// can't play) and kept next to the previews; served with Range so the reel picker can show and seek it
|
|
241
|
+
const cm = /^\/clip\/([\w-]{4,40})\/(\d{1,6})\.mp4$/.exec(route);
|
|
242
|
+
if (cm) {
|
|
243
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
244
|
+
res.statusCode = 403;
|
|
245
|
+
return res.end();
|
|
246
|
+
}
|
|
247
|
+
void clipFile(cm[1], Number(cm[2])).then((file) => {
|
|
248
|
+
if (!file) {
|
|
249
|
+
res.statusCode = 404;
|
|
250
|
+
return res.end();
|
|
251
|
+
}
|
|
252
|
+
const size = fs_1.default.statSync(file).size;
|
|
253
|
+
const m = /bytes=(\d*)-(\d*)/.exec(String(req.headers.range || ""));
|
|
254
|
+
res.setHeader("Content-Type", "video/mp4");
|
|
255
|
+
res.setHeader("Accept-Ranges", "bytes");
|
|
256
|
+
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
257
|
+
if (m) {
|
|
258
|
+
const start = m[1] ? Number(m[1]) : 0, end = m[2] ? Math.min(Number(m[2]), size - 1) : size - 1;
|
|
259
|
+
res.statusCode = 206;
|
|
260
|
+
res.setHeader("Content-Range", `bytes ${start}-${end}/${size}`);
|
|
261
|
+
res.setHeader("Content-Length", String(end - start + 1));
|
|
262
|
+
fs_1.default.createReadStream(file, { start, end }).pipe(res);
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
res.setHeader("Content-Length", String(size));
|
|
266
|
+
fs_1.default.createReadStream(file).pipe(res);
|
|
267
|
+
}
|
|
268
|
+
}).catch(() => { res.statusCode = 500; res.end(); });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
// search a sort by meaning ("tennis skirt"): the words against every picture's kept embedding, best first; nothing is read again
|
|
272
|
+
const sm = /^\/plan\/([\w-]{4,40})\/search$/.exec(route);
|
|
273
|
+
if (sm) {
|
|
274
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
275
|
+
res.statusCode = 403;
|
|
276
|
+
return res.end();
|
|
277
|
+
}
|
|
278
|
+
const q = (new URL(req.url || "", "http://x").searchParams.get("q") || "").trim().slice(0, 80);
|
|
279
|
+
res.setHeader("Content-Type", "application/json");
|
|
280
|
+
void searchPlan(sm[1], q).then((r) => res.end(JSON.stringify(r))).catch((e) => res.end(JSON.stringify({ q, items: [], error: e.message })));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const pm = /^\/plan\/([\w-]{4,40})\/items$/.exec(route);
|
|
284
|
+
if (pm) {
|
|
285
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
286
|
+
res.statusCode = 403;
|
|
287
|
+
return res.end();
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
const plan = readPlan(pm[1]);
|
|
291
|
+
const where = new Map();
|
|
292
|
+
for (const b of plan.buckets)
|
|
293
|
+
if (b.kind !== "duplicates")
|
|
294
|
+
for (const i of b.items)
|
|
295
|
+
where.set(i, b);
|
|
296
|
+
const rows = [...where.entries()].filter(([i]) => plan.items[i]?.thumb).map(([i, b]) => {
|
|
297
|
+
const it = plan.items[i];
|
|
298
|
+
return { i, bucket: b.name, media: b.media ?? null, key: (0, organize_js_1.bucketKey)(b), tags: it.tags ?? [], month: it.month ?? null, live: !!it.live, kind: it.kind, file: path_1.default.basename(it.rel), clip: !!it.live || it.kind === "video" };
|
|
299
|
+
});
|
|
300
|
+
res.setHeader("Content-Type", "application/json");
|
|
301
|
+
return res.end(JSON.stringify({ plan: plan.id, root: path_1.default.basename(plan.root), items: rows }));
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
res.statusCode = 404;
|
|
305
|
+
return res.end();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (route === "/pick-folder") { // only ViralSpin's own pages may ask for the window (a page with no allowed Origin gets nothing)
|
|
309
|
+
if (!ORIGIN_OK.test(origin)) {
|
|
310
|
+
res.statusCode = 403;
|
|
311
|
+
return res.end();
|
|
312
|
+
}
|
|
313
|
+
void pickFolder().then((p) => { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify({ path: p })); });
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const m = /^\/thumb\/([\w-]{4,40})\/(\d{1,6})\.jpg$/.exec((req.url || "").split("?")[0]);
|
|
317
|
+
if (!m) {
|
|
318
|
+
res.statusCode = 404;
|
|
319
|
+
return res.end();
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
const plan = readPlan(m[1]);
|
|
323
|
+
const t = plan.items?.[Number(m[2])]?.thumb;
|
|
324
|
+
if (!t || !fs_1.default.existsSync(t)) {
|
|
325
|
+
res.statusCode = 404;
|
|
326
|
+
return res.end();
|
|
327
|
+
}
|
|
328
|
+
res.setHeader("Content-Type", "image/jpeg");
|
|
329
|
+
res.setHeader("Cache-Control", "private, max-age=3600");
|
|
330
|
+
fs_1.default.createReadStream(t).pipe(res);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
res.statusCode = 404;
|
|
334
|
+
res.end();
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
return new Promise((resolve, reject) => {
|
|
338
|
+
let port = exports.THUMB_PORT;
|
|
339
|
+
const tryListen = () => server.listen(port, "127.0.0.1");
|
|
340
|
+
server.on("error", (e) => { if (e.code === "EADDRINUSE" && port < exports.THUMB_PORT + 5) {
|
|
341
|
+
port++;
|
|
342
|
+
tryListen();
|
|
343
|
+
}
|
|
344
|
+
else
|
|
345
|
+
reject(e); });
|
|
346
|
+
server.on("listening", () => { warmFolderPicker(); log(`[organize] thumbnails are served to ViralSpin on 127.0.0.1:${port} (this computer only)`); resolve(port); });
|
|
347
|
+
tryListen();
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
/** The pictures of a sort that best match some words, best first: the closest and anything nearly as close (up to 200). */
|
|
351
|
+
async function searchPlan(planId, q) {
|
|
352
|
+
if (!q)
|
|
353
|
+
return { q, items: [] };
|
|
354
|
+
const plan = readPlan(planId);
|
|
355
|
+
const ef = (0, exports.embFile)(planId);
|
|
356
|
+
if (!fs_1.default.existsSync(ef))
|
|
357
|
+
return { q, items: [], error: "This sort has no picture fingerprints yet. Make one change to it first (add a label), then search." };
|
|
358
|
+
// a word the vocabulary knows keeps its descriptive prompt ("tennis" -> "a person playing tennis or a tennis court")
|
|
359
|
+
const p = (0, topics_js_1.labelSet)([q]).prompts[0] ?? q;
|
|
360
|
+
const T = await (0, topics_js_1.textEmbeddings)({ prompts: [p], names: [q], groups: [q], custom: new Set([q]) });
|
|
361
|
+
if (!T)
|
|
362
|
+
return { q, items: [], error: "The picture model is not available on this computer." };
|
|
363
|
+
const t = T[0], d = t.length;
|
|
364
|
+
const raw = fs_1.default.readFileSync(ef);
|
|
365
|
+
const E = new Float32Array(raw.buffer, raw.byteOffset, Math.floor(raw.byteLength / 4));
|
|
366
|
+
const where = new Map();
|
|
367
|
+
for (const b of plan.buckets)
|
|
368
|
+
if (b.kind !== "duplicates")
|
|
369
|
+
for (const i of b.items)
|
|
370
|
+
where.set(i, b);
|
|
371
|
+
const scored = [];
|
|
372
|
+
plan.items.forEach((it, i) => {
|
|
373
|
+
if (!it.thumb || it.dupOf !== undefined || (i + 1) * d > E.length || !where.has(i))
|
|
374
|
+
return;
|
|
375
|
+
let s = 0;
|
|
376
|
+
for (let k = 0; k < d; k++)
|
|
377
|
+
s += E[i * d + k] * t[k];
|
|
378
|
+
if (s)
|
|
379
|
+
scored.push({ i, s });
|
|
380
|
+
});
|
|
381
|
+
scored.sort((a, b) => b.s - a.s);
|
|
382
|
+
const top = scored[0]?.s ?? 0;
|
|
383
|
+
const keep = scored.filter((x, k) => k < 12 || (x.s >= top - 0.035 && x.s >= 0.22)).slice(0, 200);
|
|
384
|
+
return { q, items: keep.map(({ i, s }) => { const it = plan.items[i], b = where.get(i); return { i, score: Math.round(s * 1000) / 1000, bucket: b.name, media: b.media ?? null, month: it.month ?? null, file: path_1.default.basename(it.rel), clip: !!it.live || it.kind === "video" }; }) };
|
|
385
|
+
}
|
|
386
|
+
/** What the web page gets back for a plan: totals, the move preview and each bucket with a sample of previews. */
|
|
387
|
+
function resultOf(plan, port) {
|
|
388
|
+
return {
|
|
389
|
+
totals: { ...plan.totals, live: plan.items.filter((it) => it.live).length }, by: plan.by, port, thumbs: true, root: path_1.default.basename(plan.root), labels: (0, edit_js_1.labelsAfter)(plan.labels ?? [], plan.ops ?? []),
|
|
390
|
+
preview: { moves: plan.moves.length, into: path_1.default.join(plan.root, "_Organized"), sample: plan.moves.slice(0, 8).map((m) => ({ from: path_1.default.relative(plan.root, m.from), to: path_1.default.relative(plan.root, m.to) })) },
|
|
391
|
+
buckets: plan.buckets.map((b) => ({ name: b.name, kind: b.kind, media: b.media ?? null, count: b.items.length, sample: b.items.filter((i) => plan.items[i].thumb).slice(0, 40) })),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
const embFile = (id) => (0, organize_js_1.planFile)(id).replace(/\.json$/, ".emb");
|
|
395
|
+
exports.embFile = embFile;
|
|
396
|
+
async function runJob(d, base, job, port) {
|
|
397
|
+
const patch = (b) => d.req("PATCH", `${base}/v1/organize/jobs/${job.id}`, b);
|
|
398
|
+
fs_1.default.mkdirSync(organize_js_1.STATE_DIR, { recursive: true });
|
|
399
|
+
const tmp = Date.now().toString(36);
|
|
400
|
+
let last = 0;
|
|
401
|
+
// ONE PROGRESS WRITE AT A TIME, at most every 2 s. They were fired every 0.9 s without waiting, so when the database slowed they piled up
|
|
402
|
+
// into a stream of 504s on the same row and made it slower still (2026-10-31).
|
|
403
|
+
let inFlight = false;
|
|
404
|
+
try {
|
|
405
|
+
if (!(await (0, images_js_1.canReadPictures)()))
|
|
406
|
+
throw new Error("This computer can't read pictures yet (no image library or ffmpeg). Reinstall the ViralSpin connector.");
|
|
407
|
+
live.job = job.id;
|
|
408
|
+
live.thumbs = [];
|
|
409
|
+
live.at = new Map();
|
|
410
|
+
live.cards = [];
|
|
411
|
+
const plan = await (0, scan_js_1.organizeScan)(job.folder, {
|
|
412
|
+
onThumb: (f) => { live.at.set(Number(path_1.default.basename(f, ".jpg")), live.thumbs.length); live.thumbs.push(f); },
|
|
413
|
+
onLabel: (i, card) => { const n = live.at.get(i); if (n !== undefined)
|
|
414
|
+
live.cards.push([n, card]); }, by: job.by, vision: job.vision, labels: job.labels ?? [], embFile: path_1.default.join(organize_js_1.STATE_DIR, `${tmp}.emb`), thumbs: path_1.default.join(organize_js_1.STATE_DIR, `${tmp}-thumbs`), onProgress: (p) => {
|
|
415
|
+
if (p.phase !== "done" && (inFlight || Date.now() - last < 2000))
|
|
416
|
+
return;
|
|
417
|
+
last = Date.now();
|
|
418
|
+
inFlight = true;
|
|
419
|
+
void patch({ phase: p.phase, done: p.done ?? 0, total: p.total ?? 0, eta_ms: p.etaMs ?? 0, ...(p.phase === "found" ? { files_found: p.total ?? 0 } : {}) }).catch(() => { }).finally(() => { inFlight = false; });
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
fs_1.default.writeFileSync((0, organize_js_1.planFile)(plan.id), JSON.stringify(plan));
|
|
423
|
+
try {
|
|
424
|
+
fs_1.default.renameSync(path_1.default.join(organize_js_1.STATE_DIR, `${tmp}.emb`), (0, exports.embFile)(plan.id));
|
|
425
|
+
}
|
|
426
|
+
catch { /* sorted by folder or month: no embeddings */ }
|
|
427
|
+
const result = resultOf(plan, port);
|
|
428
|
+
await patch({ status: "done", plan_id: plan.id, result });
|
|
429
|
+
d.log(`[organize] sorted ${plan.totals.files} file(s) from ${job.folder} into ${plan.buckets.length} bucket(s)`);
|
|
430
|
+
}
|
|
431
|
+
catch (e) {
|
|
432
|
+
await patch({ status: "failed", error: e.message }).catch(() => { });
|
|
433
|
+
d.log(`[organize] ${job.folder} failed: ${e.message}`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/** One pass: take a pending job, or do the My Story upload a done job asked for. Returns whether it did anything. */
|
|
437
|
+
async function organizeTick(d, base, port) {
|
|
438
|
+
const { jobs } = await d.req("GET", `${base}/v1/organize/jobs`);
|
|
439
|
+
const pending = jobs.find((j) => j.status === "pending");
|
|
440
|
+
if (pending) {
|
|
441
|
+
try {
|
|
442
|
+
await d.req("PATCH", `${base}/v1/organize/jobs/${pending.id}`, { claim: true });
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
return false;
|
|
446
|
+
} // another computer took it
|
|
447
|
+
d.log(`[organize] sorting ${pending.folder}`);
|
|
448
|
+
await runJob(d, base, pending, port);
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
const edit = jobs.find((j) => j.status === "done" && j.plan_id && Array.isArray(j.pending_ops) && j.pending_ops.length);
|
|
452
|
+
if (edit) {
|
|
453
|
+
const patch = (b) => d.req("PATCH", `${base}/v1/organize/jobs/${edit.id}`, b);
|
|
454
|
+
try {
|
|
455
|
+
let last = 0;
|
|
456
|
+
const { plan, note } = await (0, edit_js_1.applyOps)((0, organize_js_1.planFile)(edit.plan_id), (0, exports.embFile)(edit.plan_id), edit.pending_ops, (done, total, etaMs) => {
|
|
457
|
+
if (Date.now() - last < 3000 && done < total)
|
|
458
|
+
return;
|
|
459
|
+
last = Date.now();
|
|
460
|
+
void patch({ phase: "relabel", done, total, eta_ms: etaMs }).catch(() => { });
|
|
461
|
+
});
|
|
462
|
+
await patch({ result: resultOf(plan, port), pending_ops: [], ops_note: note, plan_id: plan.id });
|
|
463
|
+
d.log(`[organize] ${note}`);
|
|
464
|
+
}
|
|
465
|
+
catch (e) {
|
|
466
|
+
await patch({ pending_ops: [], ops_note: e.message }).catch(() => { });
|
|
467
|
+
d.log(`[organize] change failed: ${e.message}`);
|
|
468
|
+
}
|
|
469
|
+
return true;
|
|
470
|
+
}
|
|
471
|
+
const story = jobs.find((j) => j.status === "done" && j.want_story && !j.story_status && j.plan_id);
|
|
472
|
+
if (story) {
|
|
473
|
+
const patch = (b) => d.req("PATCH", `${base}/v1/organize/jobs/${story.id}`, b);
|
|
474
|
+
await patch({ story_status: "running" });
|
|
475
|
+
try {
|
|
476
|
+
let last = 0;
|
|
477
|
+
await (0, organize_js_1.addPlanToStory)((0, organize_js_1.planFile)(story.plan_id), { post: (ep, b) => d.req("POST", base + ep, b) }, (done, total) => {
|
|
478
|
+
if (Date.now() - last < 2000 && done < total)
|
|
479
|
+
return;
|
|
480
|
+
last = Date.now();
|
|
481
|
+
void patch({ phase: "story", done, total }).catch(() => { });
|
|
482
|
+
}, story.story_buckets);
|
|
483
|
+
await patch({ story_status: "done" });
|
|
484
|
+
d.log(`[organize] added ${story.folder} to My Story`);
|
|
485
|
+
}
|
|
486
|
+
catch (e) {
|
|
487
|
+
await patch({ story_status: "failed", error: e.message }).catch(() => { });
|
|
488
|
+
d.log(`[organize] My Story upload failed: ${e.message}`);
|
|
489
|
+
}
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
const move = jobs.find((j) => j.status === "done" && j.plan_id && ((j.want_apply && !j.apply_status) || (j.want_undo && j.apply_status === "applied")));
|
|
493
|
+
if (move) {
|
|
494
|
+
const patch = (b) => d.req("PATCH", `${base}/v1/organize/jobs/${move.id}`, b);
|
|
495
|
+
const undo = move.want_undo && move.apply_status === "applied";
|
|
496
|
+
await patch({ apply_status: "running" });
|
|
497
|
+
try {
|
|
498
|
+
const plan = JSON.parse(fs_1.default.readFileSync((0, organize_js_1.planFile)(move.plan_id), "utf8"));
|
|
499
|
+
if (undo) {
|
|
500
|
+
const r = (0, plan_js_1.undoMoves)(path_1.default.join(plan.root, "_Organized", `.viralspin-undo-${plan.id}.json`));
|
|
501
|
+
await patch({ apply_status: "undone", apply_result: { restored: r.restored, left: r.left } });
|
|
502
|
+
d.log(`[organize] put back ${r.restored} file(s) in ${move.folder}`);
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
const r = (0, plan_js_1.applyMoves)(plan.root, plan.moves, plan.id);
|
|
506
|
+
await patch({ apply_status: "applied", apply_result: { moved: r.moved, skipped: r.skipped } });
|
|
507
|
+
d.log(`[organize] moved ${r.moved} file(s) in ${move.folder} (undo is one click in the app)`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
catch (e) {
|
|
511
|
+
await patch({ apply_status: "failed", error: e.message }).catch(() => { });
|
|
512
|
+
d.log(`[organize] move failed: ${e.message}`);
|
|
513
|
+
}
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
/** Runs beside the render watch loop until aborted. Asks every 4 s (the web app is waiting on it), a little slower after errors. */
|
|
519
|
+
async function organizeLoop(d, base, signal) {
|
|
520
|
+
let port = 0;
|
|
521
|
+
try {
|
|
522
|
+
port = await startThumbServer(d.log);
|
|
523
|
+
}
|
|
524
|
+
catch (e) {
|
|
525
|
+
d.log(`[organize] no thumbnail server (${e.message}); sorting still works, the web page will show placeholders`);
|
|
526
|
+
}
|
|
527
|
+
let errors = 0;
|
|
528
|
+
let idle = 0;
|
|
529
|
+
while (!signal.aborted) {
|
|
530
|
+
let did = false;
|
|
531
|
+
try {
|
|
532
|
+
did = await organizeTick(d, base, port);
|
|
533
|
+
errors = 0;
|
|
534
|
+
}
|
|
535
|
+
catch (e) {
|
|
536
|
+
if (errors++ === 0)
|
|
537
|
+
d.log(`[organize] ${e.message}`);
|
|
538
|
+
}
|
|
539
|
+
idle = did ? 0 : idle + 1;
|
|
540
|
+
await new Promise((r) => setTimeout(r, organizeDelay(did, idle, errors)));
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
/** 4 s while sorting is going on, easing to 12 s after about a minute with nothing asked (a new sort is still picked up within 12 s); much longer
|
|
544
|
+
* while ViralSpin is unreachable. Every 4 s around the clock was ~21,600 requests a day per computer, each one a database read. */
|
|
545
|
+
function organizeDelay(did, idle, errors) {
|
|
546
|
+
if (did)
|
|
547
|
+
return 500;
|
|
548
|
+
if (errors)
|
|
549
|
+
return Math.min(60000, 4000 * 2 ** Math.min(errors, 4));
|
|
550
|
+
return Math.min(12000, 4000 + Math.max(0, idle - 15) * 1000);
|
|
551
|
+
}
|