@officexapp/vidfarm-devcli 0.21.58 → 0.21.60
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/.agents/skills/vidfarm/SKILL.md +3 -3
- package/.agents/skills/vidfarm/recipes/retheme-template.md +6 -5
- package/.agents/skills/vidfarm/references/content-ideas.md +97 -14
- package/.agents/skills/vidfarm/references/core-workflows.md +4 -0
- package/.agents/skills/vidfarm/references/editor-workflows.md +25 -0
- package/SKILL.director.md +135 -22
- package/SKILL.md +9 -2
- package/clipper.md +7 -3
- package/dist/src/cli.js +201 -16
- package/dist/src/devcli/clipper-panel.js +1828 -0
- package/dist/src/devcli/clipper-run.js +643 -126
- package/dist/src/devcli/marketplace-gigs.js +307 -4
- package/dist/src/devcli/proof-verify.js +465 -0
- package/dist/src/devcli/skill-docs.js +14 -0
- package/experimental/flash-harness.md +142 -18
- package/experimental/meme-recaption.md +1044 -79
- package/experimental/sticker-slideshow-tips.md +511 -86
- package/marketplace.md +427 -12
- package/package.json +6 -1
- package/update.md +21 -1
|
@@ -0,0 +1,1828 @@
|
|
|
1
|
+
// THE CLIPPER DASHBOARD — `vidfarm dashboard` (aka `vidfarm panel`), the gigworker's
|
|
2
|
+
// local page over a clipper mission folder.
|
|
3
|
+
//
|
|
4
|
+
// The terminal already answers "what is waiting on me" (`clipper-run next` /
|
|
5
|
+
// `review` / REVIEW_QUEUE.md). What it CANNOT do is play a video, and the cut
|
|
6
|
+
// gate is exactly "watch this before it is sent". So the gigworker reads a
|
|
7
|
+
// path, opens the file by hand, goes back to the terminal, and types a verb —
|
|
8
|
+
// once per task. This page collapses that into scroll, watch, click.
|
|
9
|
+
//
|
|
10
|
+
// It also joins the two halves of a long-horizon mission that no other surface
|
|
11
|
+
// can see at once: the LOCAL disk state (which agent is mid-cut, where the
|
|
12
|
+
// local render sits, which raws landed) and the CLOUD outcome (proof id,
|
|
13
|
+
// awaiting review, paid). Cloud alone never knows the first; disk alone never
|
|
14
|
+
// knows the second.
|
|
15
|
+
//
|
|
16
|
+
// ONE WRITER. This server never writes state.json itself — every action calls
|
|
17
|
+
// the same exported transition the CLI verb calls (clipper-run.ts § the
|
|
18
|
+
// transitions). Two writers would let the terminal and the page disagree about
|
|
19
|
+
// which gate is open, and that gate is the rail that stops an unreviewed cut
|
|
20
|
+
// from being submitted.
|
|
21
|
+
//
|
|
22
|
+
// SECURITY. The panel can approve work that is then submitted for money, so a
|
|
23
|
+
// write must not be reachable by any random page the gigworker has open in the
|
|
24
|
+
// same browser. Three rules, all enforced here:
|
|
25
|
+
// 1. bind 127.0.0.1 only (never 0.0.0.0),
|
|
26
|
+
// 2. a per-run session token on EVERY request (query `t` or header
|
|
27
|
+
// `x-vidfarm-panel`) — a cross-origin tab cannot read it, because no CORS
|
|
28
|
+
// header is ever sent, and
|
|
29
|
+
// 3. writes additionally require an Origin that is this server.
|
|
30
|
+
//
|
|
31
|
+
// Backend-free (Node built-ins + clipper-run.js) so it ships in the public
|
|
32
|
+
// cloud-only CLI.
|
|
33
|
+
import { createServer } from "node:http";
|
|
34
|
+
import { createReadStream, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
35
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
36
|
+
import { spawn } from "node:child_process";
|
|
37
|
+
import path from "node:path";
|
|
38
|
+
import { DEFAULT_RUN_MODE, MODE_BLURB, MODE_GATES, RUN_MODES, STAGES, addTaskNote, approveGate, dropTask, isUsableTaskId, listTaskStates, nextAction, normalizeRunMode, pickTemplate, readLedger, readMissionMode, readTaskState, reopenGate, requestChanges, setRunMode, taskFolder, unreadableTasks } from "./clipper-run.js";
|
|
39
|
+
import { readStoredAuth } from "./auth-store.js";
|
|
40
|
+
// ── auth ────────────────────────────────────────────────────────────────────
|
|
41
|
+
export function mintPanelToken() {
|
|
42
|
+
return randomBytes(24).toString("hex");
|
|
43
|
+
}
|
|
44
|
+
function presentedToken(req, url) {
|
|
45
|
+
const header = req.headers["x-vidfarm-panel"];
|
|
46
|
+
if (typeof header === "string" && header)
|
|
47
|
+
return header;
|
|
48
|
+
return url.searchParams.get("t");
|
|
49
|
+
}
|
|
50
|
+
/** Constant-time compare. Length is public (both are hex of known length). */
|
|
51
|
+
function tokenMatches(a, b) {
|
|
52
|
+
if (!a)
|
|
53
|
+
return false;
|
|
54
|
+
// Compare BYTE lengths: timingSafeEqual throws on a mismatch, and a token of
|
|
55
|
+
// 48 multi-byte characters passes a UTF-16 length check.
|
|
56
|
+
const given = Buffer.from(a, "utf8");
|
|
57
|
+
const want = Buffer.from(b, "utf8");
|
|
58
|
+
if (given.length !== want.length)
|
|
59
|
+
return false;
|
|
60
|
+
return timingSafeEqual(given, want);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A write must come from this page, not from some other tab that guessed the
|
|
64
|
+
* port. A browser always sends Origin on a cross-origin POST, so an Origin that
|
|
65
|
+
* is absent (curl, the CLI itself) or is us is fine; anything else is not.
|
|
66
|
+
*/
|
|
67
|
+
/**
|
|
68
|
+
* The request must be addressed to loopback. `new URL(req.url, …)` throws the
|
|
69
|
+
* Host header away, so a DNS-rebound name pointing at 127.0.0.1 would otherwise
|
|
70
|
+
* be same-origin to an attacker's page. The token already stops that, but a
|
|
71
|
+
* loopback-only tool has no reason to answer to a name it does not own.
|
|
72
|
+
*/
|
|
73
|
+
function hostAllowed(req, port) {
|
|
74
|
+
const host = req.headers.host;
|
|
75
|
+
if (!host)
|
|
76
|
+
return false;
|
|
77
|
+
const name = host.replace(/:\d+$/, "").replace(/^\[|\]$/g, "");
|
|
78
|
+
return (name === "127.0.0.1" || name === "localhost" || name === "::1")
|
|
79
|
+
&& (!/:\d+$/.test(host) || host.endsWith(`:${port}`));
|
|
80
|
+
}
|
|
81
|
+
function originAllowed(req, port) {
|
|
82
|
+
const origin = req.headers.origin;
|
|
83
|
+
if (!origin)
|
|
84
|
+
return true;
|
|
85
|
+
return origin === `http://127.0.0.1:${port}` || origin === `http://localhost:${port}`;
|
|
86
|
+
}
|
|
87
|
+
// A gate answered on the strength of a filename is a gate answered blind, so
|
|
88
|
+
// the brief and the plan are READ AND SENT, not linked. These caps keep a
|
|
89
|
+
// runaway file from making the 2s poll heavy; the path stays on screen for the
|
|
90
|
+
// rare case where somebody really does need the whole thing.
|
|
91
|
+
const BRIEF_CHAR_LIMIT = 8_000;
|
|
92
|
+
const PLAN_CHAR_LIMIT = 12_000;
|
|
93
|
+
const RAWS_LIST_LIMIT = 40;
|
|
94
|
+
function readTextCapped(file, limit) {
|
|
95
|
+
if (!file || !fileExists(file))
|
|
96
|
+
return null;
|
|
97
|
+
try {
|
|
98
|
+
const text = readFileSync(file, "utf8");
|
|
99
|
+
return text.length > limit ? `${text.slice(0, limit)}\n\n… truncated — the whole file is at ${file}` : text;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** A file that is present AND has bytes. A 0-byte render is not a video. */
|
|
106
|
+
function fileExists(candidate) {
|
|
107
|
+
if (!candidate)
|
|
108
|
+
return false;
|
|
109
|
+
try {
|
|
110
|
+
const stat = statSync(candidate);
|
|
111
|
+
return stat.isFile() && stat.size > 0;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function listRaws(dir) {
|
|
118
|
+
if (!dir || !existsSync(dir))
|
|
119
|
+
return [];
|
|
120
|
+
try {
|
|
121
|
+
return readdirSync(dir).filter((name) => !name.startsWith("."));
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
function toView(root, state) {
|
|
128
|
+
const raws = listRaws(state.raws_dir);
|
|
129
|
+
return {
|
|
130
|
+
...state,
|
|
131
|
+
next: nextAction(state),
|
|
132
|
+
has_cut: fileExists(state.cut_path),
|
|
133
|
+
has_clean_master: fileExists(state.clean_master),
|
|
134
|
+
raws_count: raws.length,
|
|
135
|
+
raws_files: raws.slice(0, RAWS_LIST_LIMIT),
|
|
136
|
+
plan_exists: fileExists(state.plan_path),
|
|
137
|
+
brief: readTextCapped(path.join(taskFolder(root, state.task_id), "brief.md"), BRIEF_CHAR_LIMIT),
|
|
138
|
+
plan_text: readTextCapped(state.plan_path, PLAN_CHAR_LIMIT)
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The money question, answered once and correctly. Two bugs lived in the page's
|
|
143
|
+
* version: it summed only the last 100 ledger rows (so a long mission
|
|
144
|
+
* under-reported), and it summed every `submitted` row, so one task that was
|
|
145
|
+
* submitted twice was counted twice. Latest row per task wins.
|
|
146
|
+
*/
|
|
147
|
+
function ledgerSummary(ledger) {
|
|
148
|
+
const latest = new Map();
|
|
149
|
+
for (const row of ledger) {
|
|
150
|
+
const id = typeof row.task_id === "string" ? row.task_id : null;
|
|
151
|
+
if (id)
|
|
152
|
+
latest.set(id, row);
|
|
153
|
+
}
|
|
154
|
+
let awaiting = 0;
|
|
155
|
+
let sent = 0;
|
|
156
|
+
let dropped = 0;
|
|
157
|
+
for (const row of latest.values()) {
|
|
158
|
+
if (row.status === "submitted") {
|
|
159
|
+
sent += 1;
|
|
160
|
+
awaiting += Number(row.locked_price) || 0;
|
|
161
|
+
}
|
|
162
|
+
else if (row.status === "discarded") {
|
|
163
|
+
dropped += 1;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { awaiting, sent, dropped, rows: latest.size };
|
|
167
|
+
}
|
|
168
|
+
function buildState(root) {
|
|
169
|
+
// Deleted out from under us. "Empty" and "gone" must not look the same: one
|
|
170
|
+
// says claim some work, the other says your mission folder is missing.
|
|
171
|
+
if (!existsSync(root)) {
|
|
172
|
+
return {
|
|
173
|
+
ok: true,
|
|
174
|
+
root,
|
|
175
|
+
missing: true,
|
|
176
|
+
run_mode: DEFAULT_RUN_MODE,
|
|
177
|
+
run_mode_is_set: false,
|
|
178
|
+
modes: RUN_MODES.map((m) => ({ mode: m, gates: MODE_GATES[m], blurb: MODE_BLURB[m] })),
|
|
179
|
+
stages: STAGES,
|
|
180
|
+
enrichment: false,
|
|
181
|
+
tasks: [],
|
|
182
|
+
summary: { awaiting: 0, sent: 0, dropped: 0, rows: 0 },
|
|
183
|
+
ledger: [],
|
|
184
|
+
unreadable: [],
|
|
185
|
+
mode_unreadable: null,
|
|
186
|
+
generated_at: new Date().toISOString()
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
// A task whose RECORDED id is unsafe (hand-edited state.json, or written
|
|
190
|
+
// before ids were validated) must not be rendered — and must not take the
|
|
191
|
+
// whole panel down with it either, which is what throwing here did. Quarantine
|
|
192
|
+
// it and say so, the same way an unparseable file is reported.
|
|
193
|
+
const quarantined = [];
|
|
194
|
+
const tasks = [];
|
|
195
|
+
for (const state of listTaskStates(root)) {
|
|
196
|
+
if (!isUsableTaskId(state.task_id)) {
|
|
197
|
+
quarantined.push({
|
|
198
|
+
folder: String(state.task_id).slice(0, 60),
|
|
199
|
+
why: "its recorded task_id is not a usable id, so it is not shown"
|
|
200
|
+
});
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
tasks.push(toView(root, state));
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
quarantined.push({ folder: state.task_id, why: error instanceof Error ? error.message : String(error) });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const mode = readMissionMode(root);
|
|
211
|
+
const ledger = readLedger(root);
|
|
212
|
+
return {
|
|
213
|
+
ok: true,
|
|
214
|
+
root,
|
|
215
|
+
run_mode: mode.mode,
|
|
216
|
+
run_mode_is_set: mode.isSet,
|
|
217
|
+
modes: RUN_MODES.map((m) => ({ mode: m, gates: MODE_GATES[m], blurb: MODE_BLURB[m] })),
|
|
218
|
+
stages: STAGES,
|
|
219
|
+
// Whether template previews can be filled in at all. The page says so
|
|
220
|
+
// rather than silently showing bare ids and looking broken.
|
|
221
|
+
enrichment: Boolean(readStoredAuth()?.apiKey),
|
|
222
|
+
missing: false,
|
|
223
|
+
tasks,
|
|
224
|
+
// Named so the page cannot silently show a stale count: this is over EVERY
|
|
225
|
+
// row, while `ledger` below is only the recent slice used for display.
|
|
226
|
+
summary: ledgerSummary(ledger),
|
|
227
|
+
ledger: ledger.slice(-100).reverse(),
|
|
228
|
+
// Task folders whose state.json will not parse. The page says so out loud —
|
|
229
|
+
// a task missing from a review queue must never be silent.
|
|
230
|
+
unreadable: Array.from(unreadableTasks.entries())
|
|
231
|
+
.map(([folder, why]) => ({ folder, why }))
|
|
232
|
+
.concat(quarantined),
|
|
233
|
+
mode_unreadable: mode.unreadable ?? null,
|
|
234
|
+
generated_at: new Date().toISOString()
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
const templateCache = new Map();
|
|
238
|
+
const TEMPLATE_LOOKUP_TIMEOUT_MS = 12_000;
|
|
239
|
+
function previewKind(url) {
|
|
240
|
+
if (!url)
|
|
241
|
+
return null;
|
|
242
|
+
const clean = url.split("?")[0].toLowerCase();
|
|
243
|
+
if (/\.(mp4|webm|mov|m4v)$/.test(clean))
|
|
244
|
+
return "video";
|
|
245
|
+
if (/\.(jpg|jpeg|png|webp|gif|avif)$/.test(clean))
|
|
246
|
+
return "image";
|
|
247
|
+
// Unknown extension: a still is the safe guess — an <img> that fails is a
|
|
248
|
+
// broken icon, while a <video> that fails is a dead black box.
|
|
249
|
+
return "image";
|
|
250
|
+
}
|
|
251
|
+
async function lookupTemplate(templateId) {
|
|
252
|
+
const cached = templateCache.get(templateId);
|
|
253
|
+
if (cached)
|
|
254
|
+
return cached;
|
|
255
|
+
const bare = {
|
|
256
|
+
template_id: templateId,
|
|
257
|
+
resolved: false,
|
|
258
|
+
reason: null,
|
|
259
|
+
title: null,
|
|
260
|
+
preview_url: null,
|
|
261
|
+
preview_kind: null,
|
|
262
|
+
duration_seconds: null,
|
|
263
|
+
source_type: null,
|
|
264
|
+
summary: null,
|
|
265
|
+
editor_url: null
|
|
266
|
+
};
|
|
267
|
+
const auth = readStoredAuth();
|
|
268
|
+
if (!auth?.apiKey)
|
|
269
|
+
return { ...bare, reason: "not logged in" };
|
|
270
|
+
const host = (auth.host ?? "https://vidfarm.cc").replace(/\/+$/, "");
|
|
271
|
+
try {
|
|
272
|
+
const res = await fetch(`${host}/api/v1/templates/${encodeURIComponent(templateId)}`, {
|
|
273
|
+
headers: { "vidfarm-api-key": auth.apiKey, accept: "application/json" },
|
|
274
|
+
signal: AbortSignal.timeout(TEMPLATE_LOOKUP_TIMEOUT_MS)
|
|
275
|
+
});
|
|
276
|
+
if (!res.ok)
|
|
277
|
+
return { ...bare, reason: `the catalog answered ${res.status}` };
|
|
278
|
+
const json = await res.json();
|
|
279
|
+
const preview = typeof json.previewUrl === "string" && json.previewUrl ? json.previewUrl : null;
|
|
280
|
+
const card = {
|
|
281
|
+
template_id: templateId,
|
|
282
|
+
resolved: true,
|
|
283
|
+
reason: null,
|
|
284
|
+
title: typeof json.title === "string" ? json.title : null,
|
|
285
|
+
preview_url: preview,
|
|
286
|
+
preview_kind: previewKind(preview),
|
|
287
|
+
duration_seconds: typeof json.durationSeconds === "number" ? json.durationSeconds : null,
|
|
288
|
+
source_type: typeof json.sourceType === "string" ? json.sourceType : null,
|
|
289
|
+
summary: typeof json.summary === "string" && json.summary
|
|
290
|
+
? json.summary
|
|
291
|
+
: (typeof json.viralDna === "string" ? json.viralDna : null),
|
|
292
|
+
editor_url: `${host}/template/${encodeURIComponent(templateId)}`
|
|
293
|
+
};
|
|
294
|
+
// Only a real answer is worth caching — a transient failure must not pin an
|
|
295
|
+
// empty card for the life of the process.
|
|
296
|
+
if (card.preview_url || card.title) {
|
|
297
|
+
// Bounded: one entry per template the reviewer has actually seen, and an
|
|
298
|
+
// FIFO trim so a long mission cannot grow this without limit.
|
|
299
|
+
if (templateCache.size > 500)
|
|
300
|
+
templateCache.delete(templateCache.keys().next().value);
|
|
301
|
+
templateCache.set(templateId, card);
|
|
302
|
+
}
|
|
303
|
+
return card;
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
return { ...bare, reason: error instanceof Error ? error.message : "the lookup failed" };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// ── media streaming (the whole point of a page) ─────────────────────────────
|
|
310
|
+
const VIDEO_MIME = {
|
|
311
|
+
".mp4": "video/mp4",
|
|
312
|
+
".webm": "video/webm",
|
|
313
|
+
".mov": "video/quicktime",
|
|
314
|
+
".m4v": "video/x-m4v"
|
|
315
|
+
};
|
|
316
|
+
/**
|
|
317
|
+
* Serve a task's recorded video WITH byte-range support. Without 206 the
|
|
318
|
+
* browser can play from the start but cannot seek, and a reviewer who cannot
|
|
319
|
+
* scrub back to second 3 is not really reviewing the hook.
|
|
320
|
+
*
|
|
321
|
+
* The path comes from state.json, never from the URL, so there is nothing to
|
|
322
|
+
* traverse: the request only names a task id and "cut" | "clean".
|
|
323
|
+
*/
|
|
324
|
+
function streamVideo(req, res, filePath) {
|
|
325
|
+
const size = statSync(filePath).size;
|
|
326
|
+
if (size === 0) {
|
|
327
|
+
// A recorded cut that is zero bytes is a failed render, not a video. Say so
|
|
328
|
+
// rather than serving an empty 200 the player shows as a black frame.
|
|
329
|
+
res.statusCode = 409;
|
|
330
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
331
|
+
res.end("That file is empty (0 bytes) — the render produced nothing.\n");
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const type = VIDEO_MIME[path.extname(filePath).toLowerCase()] ?? "video/mp4";
|
|
335
|
+
const range = req.headers.range;
|
|
336
|
+
// Both halves empty ("bytes=-") is malformed; it used to be served as a 206
|
|
337
|
+
// covering the whole file.
|
|
338
|
+
const match = typeof range === "string" ? range.match(/^bytes=(\d*)-(\d*)$/) : null;
|
|
339
|
+
if (match && !match[1] && !match[2]) {
|
|
340
|
+
res.statusCode = 416;
|
|
341
|
+
res.setHeader("content-range", `bytes */${size}`);
|
|
342
|
+
res.end();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
let start = 0;
|
|
346
|
+
let end = size - 1;
|
|
347
|
+
if (match) {
|
|
348
|
+
const rawStart = match[1];
|
|
349
|
+
const rawEnd = match[2];
|
|
350
|
+
if (rawStart) {
|
|
351
|
+
start = Number(rawStart);
|
|
352
|
+
if (rawEnd)
|
|
353
|
+
end = Number(rawEnd);
|
|
354
|
+
}
|
|
355
|
+
else if (rawEnd) {
|
|
356
|
+
// A suffix range ("last N bytes") — what some players ask for first.
|
|
357
|
+
start = Math.max(0, size - Number(rawEnd));
|
|
358
|
+
}
|
|
359
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= size) {
|
|
360
|
+
res.statusCode = 416;
|
|
361
|
+
res.setHeader("content-range", `bytes */${size}`);
|
|
362
|
+
res.end();
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
end = Math.min(end, size - 1);
|
|
366
|
+
res.statusCode = 206;
|
|
367
|
+
res.setHeader("content-range", `bytes ${start}-${end}/${size}`);
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
res.statusCode = 200;
|
|
371
|
+
}
|
|
372
|
+
res.setHeader("content-type", type);
|
|
373
|
+
res.setHeader("accept-ranges", "bytes");
|
|
374
|
+
res.setHeader("content-length", String(end - start + 1));
|
|
375
|
+
res.setHeader("cache-control", "no-store");
|
|
376
|
+
if (req.method === "HEAD") {
|
|
377
|
+
res.end();
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
const stream = createReadStream(filePath, { start, end });
|
|
381
|
+
// A disk read error must fail THIS response, not take the panel down.
|
|
382
|
+
stream.on("error", () => res.destroy());
|
|
383
|
+
res.on("close", () => stream.destroy());
|
|
384
|
+
stream.pipe(res);
|
|
385
|
+
}
|
|
386
|
+
/** Reveal a file or folder in the OS file manager. */
|
|
387
|
+
function revealInFileManager(target, isFile) {
|
|
388
|
+
// Reveal, do not OPEN. `open <file>` launches the default handler, which for
|
|
389
|
+
// a recorded cut path means playing it in another app — and for anything else
|
|
390
|
+
// means running whatever is registered for that extension.
|
|
391
|
+
const command = process.platform === "darwin"
|
|
392
|
+
? { bin: "open", args: isFile ? ["-R", target] : [target] }
|
|
393
|
+
: process.platform === "win32"
|
|
394
|
+
? { bin: "explorer.exe", args: isFile ? [`/select,${target}`] : [target] }
|
|
395
|
+
: { bin: "xdg-open", args: [isFile ? path.dirname(target) : target] };
|
|
396
|
+
// No shell: the path comes from state.json, and a shell would make a task id
|
|
397
|
+
// with a quote in it interesting.
|
|
398
|
+
const child = spawn(command.bin, command.args, { stdio: "ignore", detached: true });
|
|
399
|
+
child.on("error", () => { });
|
|
400
|
+
child.unref();
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Every button on the page lands here, and every branch calls the SAME exported
|
|
404
|
+
* transition the matching CLI verb calls. Adding a branch that writes state
|
|
405
|
+
* directly would break the single-writer rule this file exists to keep.
|
|
406
|
+
*/
|
|
407
|
+
function runAction(root, body) {
|
|
408
|
+
if (!existsSync(root)) {
|
|
409
|
+
throw new Error(`The mission folder ${root} is gone. Nothing can be written to it — restart the panel once it is back.`);
|
|
410
|
+
}
|
|
411
|
+
const action = (body?.action ?? "").trim();
|
|
412
|
+
const taskId = (body?.task_id ?? "").trim();
|
|
413
|
+
switch (action) {
|
|
414
|
+
case "approve": {
|
|
415
|
+
const { state, approved } = approveGate(root, taskId, body.note ?? null);
|
|
416
|
+
return { ok: true, action, approved, task: toView(root, state) };
|
|
417
|
+
}
|
|
418
|
+
case "changes": {
|
|
419
|
+
const { state, from } = requestChanges(root, taskId, body.note ?? "");
|
|
420
|
+
return { ok: true, action, from, task: toView(root, state) };
|
|
421
|
+
}
|
|
422
|
+
case "drop": {
|
|
423
|
+
const { state, reason } = dropTask(root, taskId, body.reason ?? null);
|
|
424
|
+
return { ok: true, action, reason, task: toView(root, state) };
|
|
425
|
+
}
|
|
426
|
+
case "undo": {
|
|
427
|
+
const { state, stage } = reopenGate(root, taskId);
|
|
428
|
+
return { ok: true, action, reopened: stage, task: toView(root, state) };
|
|
429
|
+
}
|
|
430
|
+
case "pick": {
|
|
431
|
+
const { state } = pickTemplate(root, taskId, body.template_id ?? null, "human");
|
|
432
|
+
return { ok: true, action, task: toView(root, state) };
|
|
433
|
+
}
|
|
434
|
+
case "note": {
|
|
435
|
+
const { state } = addTaskNote(root, taskId, body.note ?? "");
|
|
436
|
+
return { ok: true, action, task: toView(root, state) };
|
|
437
|
+
}
|
|
438
|
+
case "mode": {
|
|
439
|
+
const mode = normalizeRunMode(body.mode);
|
|
440
|
+
if (!mode)
|
|
441
|
+
throw new Error(`Unknown run mode "${body.mode}". Choose one of: ${RUN_MODES.join(", ")}.`);
|
|
442
|
+
const saved = setRunMode(root, mode);
|
|
443
|
+
return { ok: true, action, run_mode: saved.mode };
|
|
444
|
+
}
|
|
445
|
+
case "reveal": {
|
|
446
|
+
// Open a folder the task owns. Only paths this task recorded are eligible
|
|
447
|
+
// — the request names a task and a slot, never a path.
|
|
448
|
+
const state = readTaskState(root, taskId);
|
|
449
|
+
const slot = body.target ?? "raws";
|
|
450
|
+
const target = slot === "cut"
|
|
451
|
+
? state.cut_path
|
|
452
|
+
: slot === "plan"
|
|
453
|
+
? state.plan_path
|
|
454
|
+
: state.raws_dir;
|
|
455
|
+
if (!target || !existsSync(target))
|
|
456
|
+
throw new Error(`Nothing recorded at "${slot}" for ${taskId}.`);
|
|
457
|
+
revealInFileManager(target, slot !== "raws");
|
|
458
|
+
return { ok: true, action, revealed: target };
|
|
459
|
+
}
|
|
460
|
+
default:
|
|
461
|
+
throw new Error(`Unknown action "${action}".`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// ── the page ────────────────────────────────────────────────────────────────
|
|
465
|
+
// One self-contained document: no CDN, no bundle, no build step. The client JS
|
|
466
|
+
// below deliberately avoids template literals so this file's own template
|
|
467
|
+
// literal stays readable.
|
|
468
|
+
const PAGE_HTML = `<!doctype html>
|
|
469
|
+
<html lang="en">
|
|
470
|
+
<head>
|
|
471
|
+
<meta charset="utf-8">
|
|
472
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
473
|
+
<title>Clipper dashboard | VidFarm</title>
|
|
474
|
+
<style>
|
|
475
|
+
:root{
|
|
476
|
+
--bg:#0d0e11; --rail:#131519; --panel:#181b21; --panel-2:#1e222a; --line:#282d37;
|
|
477
|
+
--ink:#f3f4f6; --muted:#8d94a1; --dim:#5e6572;
|
|
478
|
+
--gold:#ffc738; --gold-ink:#0d0e11; --green:#3ddc84; --red:#ff6b6b; --blue:#7fb0ff;
|
|
479
|
+
}
|
|
480
|
+
*{box-sizing:border-box}
|
|
481
|
+
html,body{height:100%}
|
|
482
|
+
body{margin:0;background:var(--bg);color:var(--ink);overflow:hidden;
|
|
483
|
+
font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif}
|
|
484
|
+
a{color:var(--blue);text-decoration:none}
|
|
485
|
+
a:hover{text-decoration:underline}
|
|
486
|
+
code,.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
487
|
+
button{font:inherit;color:inherit;background:var(--panel-2);border:1px solid var(--line);
|
|
488
|
+
border-radius:9px;padding:8px 14px;cursor:pointer;font-weight:550}
|
|
489
|
+
button:hover:not(:disabled){border-color:#3b4250}
|
|
490
|
+
button:disabled{opacity:.4;cursor:not-allowed}
|
|
491
|
+
select{font:inherit;color:inherit;background:var(--panel-2);border:1px solid var(--line);
|
|
492
|
+
border-radius:8px;padding:6px 9px;font-size:12px;width:100%}
|
|
493
|
+
kbd{background:#242933;border:1px solid var(--line);border-radius:4px;padding:0 4px;
|
|
494
|
+
font-family:ui-monospace,Menlo,monospace;font-size:10px}
|
|
495
|
+
|
|
496
|
+
.app{display:grid;grid-template-columns:252px 1fr;height:100vh}
|
|
497
|
+
.rail{background:var(--rail);border-right:1px solid var(--line);display:flex;
|
|
498
|
+
flex-direction:column;min-height:0}
|
|
499
|
+
.brand{padding:15px 16px 12px;border-bottom:1px solid var(--line)}
|
|
500
|
+
.brand .t{font-size:13px;font-weight:650;display:flex;align-items:center;gap:8px}
|
|
501
|
+
.live{width:7px;height:7px;border-radius:50%;background:var(--green);flex:none}
|
|
502
|
+
.live.stale{background:var(--red)}
|
|
503
|
+
.brand .p{font-size:11px;color:var(--dim);margin-top:5px;word-break:break-all;line-height:1.4}
|
|
504
|
+
nav{padding:9px 9px 4px;display:flex;flex-direction:column;gap:2px}
|
|
505
|
+
nav button{display:flex;justify-content:space-between;align-items:center;background:none;
|
|
506
|
+
border:none;border-radius:8px;padding:8px 10px;font-size:13px;color:var(--muted);font-weight:500}
|
|
507
|
+
nav button:hover{background:#1c1f26;color:var(--ink)}
|
|
508
|
+
nav button.on{background:var(--panel-2);color:var(--ink);font-weight:600}
|
|
509
|
+
nav button b{font-size:11px;background:#242933;border-radius:999px;padding:1px 8px;color:var(--muted);font-weight:600}
|
|
510
|
+
nav button.on b{background:#323947;color:var(--ink)}
|
|
511
|
+
nav button.hot b{background:var(--gold);color:var(--gold-ink)}
|
|
512
|
+
.railq{flex:1;overflow-y:auto;padding:6px 9px 12px;min-height:0}
|
|
513
|
+
.railq .h{font-size:10px;text-transform:uppercase;letter-spacing:.1em;color:var(--dim);
|
|
514
|
+
padding:10px 10px 6px;font-weight:600}
|
|
515
|
+
.qi{display:flex;gap:9px;align-items:center;padding:7px 9px;border-radius:9px;cursor:pointer;
|
|
516
|
+
border:1px solid transparent;width:100%;text-align:left;background:none}
|
|
517
|
+
.qi:hover{background:#1c1f26}
|
|
518
|
+
.qi.on{background:var(--panel-2);border-color:var(--line)}
|
|
519
|
+
.qi .n{width:20px;height:20px;border-radius:6px;background:#242933;color:var(--muted);
|
|
520
|
+
font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center;flex:none}
|
|
521
|
+
.qi.on .n{background:var(--gold);color:var(--gold-ink)}
|
|
522
|
+
.qi .tx{min-width:0;flex:1}
|
|
523
|
+
.qi .tx b{display:block;font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;
|
|
524
|
+
text-overflow:ellipsis}
|
|
525
|
+
.qi .tx span{display:block;font-size:11px;color:var(--dim)}
|
|
526
|
+
.railfoot{border-top:1px solid var(--line);padding:12px 14px;font-size:11px;color:var(--dim)}
|
|
527
|
+
.railfoot .money{color:var(--muted);margin-bottom:9px;line-height:1.5}
|
|
528
|
+
.railfoot .money b{color:var(--ink)}
|
|
529
|
+
.railfoot .warn{color:var(--gold);margin-bottom:9px;line-height:1.45}
|
|
530
|
+
|
|
531
|
+
.stage{min-width:0;min-height:0;display:flex;flex-direction:column}
|
|
532
|
+
.topbar{display:none}
|
|
533
|
+
.sh{display:flex;align-items:center;gap:12px;padding:13px 20px;border-bottom:1px solid var(--line);
|
|
534
|
+
flex:none;flex-wrap:wrap}
|
|
535
|
+
.sh h2{margin:0;font-size:14px;font-weight:650;max-width:46vw;overflow:hidden;
|
|
536
|
+
text-overflow:ellipsis;white-space:nowrap}
|
|
537
|
+
.sh .sub{color:var(--dim);font-size:12px}
|
|
538
|
+
.sh button.back{padding:6px 13px;font-size:12.5px}
|
|
539
|
+
.sp{flex:1}
|
|
540
|
+
.tag{font-size:11px;border-radius:6px;padding:2px 8px;border:1px solid var(--line);
|
|
541
|
+
color:var(--muted);font-weight:500;white-space:nowrap}
|
|
542
|
+
.tag.gold{border-color:#7a5f17;color:var(--gold)}
|
|
543
|
+
.tag.green{border-color:#1e5c3a;color:var(--green)}
|
|
544
|
+
.tag.red{border-color:#6b2626;color:var(--red)}
|
|
545
|
+
.body{flex:1;min-height:0;overflow:auto;padding:20px}
|
|
546
|
+
.err{background:#2a1414;border:1px solid #6b2626;color:#ffb4b4;border-radius:9px;
|
|
547
|
+
padding:10px 14px;margin:12px 20px 0;font-size:13px;display:none;flex:none;
|
|
548
|
+
align-items:flex-start;gap:12px}
|
|
549
|
+
.err.on{display:flex}
|
|
550
|
+
.err .msg{flex:1}
|
|
551
|
+
.err button{background:none;border:none;color:#ffb4b4;padding:0 4px;font-size:16px;line-height:1}
|
|
552
|
+
|
|
553
|
+
.rv{flex:1;min-height:0;display:grid;grid-template-columns:minmax(0,1fr) 430px}
|
|
554
|
+
.screen{background:#000;display:flex;align-items:center;justify-content:center;
|
|
555
|
+
min-width:0;min-height:0;padding:18px;position:relative;flex-direction:column;gap:12px}
|
|
556
|
+
.screen video{max-width:100%;max-height:100%;border-radius:8px;display:block;background:#000}
|
|
557
|
+
.screen .nofile{color:var(--dim);font-size:13px;text-align:center;max-width:360px;line-height:1.6}
|
|
558
|
+
.screen .nofile b{color:var(--red)}
|
|
559
|
+
.mediatabs{position:absolute;top:14px;right:16px;display:flex;gap:6px;z-index:2}
|
|
560
|
+
.mediatabs button{padding:5px 11px;font-size:11.5px;background:#000000c4}
|
|
561
|
+
.mediatabs button.on{background:var(--gold);border-color:var(--gold);color:var(--gold-ink)}
|
|
562
|
+
/* min-width:0 is load-bearing: without it this column's min-content width is
|
|
563
|
+
set by the longest unbreakable path, which pushed the action buttons off
|
|
564
|
+
the screen entirely at 1024px and below. */
|
|
565
|
+
.side{border-left:1px solid var(--line);display:flex;flex-direction:column;min-height:0;
|
|
566
|
+
min-width:0;background:var(--panel-2)}
|
|
567
|
+
.pane{flex:1;overflow-y:auto;padding:15px 16px 22px;min-height:0}
|
|
568
|
+
.acts{flex:none;padding:13px 16px;border-top:1px solid var(--line);
|
|
569
|
+
display:flex;flex-direction:column;gap:8px}
|
|
570
|
+
.acts .rowb{display:flex;gap:8px}
|
|
571
|
+
.acts .rowb button{flex:1}
|
|
572
|
+
button.go{background:var(--gold);border-color:var(--gold);color:var(--gold-ink)}
|
|
573
|
+
button.go:hover:not(:disabled){background:#ffd465}
|
|
574
|
+
button.warn:hover:not(:disabled){border-color:#6b2626;color:var(--red)}
|
|
575
|
+
.keys{font-size:10.5px;color:var(--dim);text-align:center;letter-spacing:.02em;line-height:1.7}
|
|
576
|
+
|
|
577
|
+
.qhead{font-size:12px;color:var(--muted);margin:0 0 11px;line-height:1.5}
|
|
578
|
+
.crit{display:flex;flex-direction:column;gap:2px;margin:0 0 4px}
|
|
579
|
+
.crit label{display:flex;gap:10px;align-items:flex-start;padding:8px 9px;border-radius:8px;
|
|
580
|
+
cursor:pointer;font-size:13px;line-height:1.45}
|
|
581
|
+
.crit label:hover{background:#242933}
|
|
582
|
+
.crit input{margin:3px 0 0;accent-color:var(--gold);width:15px;height:15px;flex:none}
|
|
583
|
+
.crit label.ok{color:var(--green)}
|
|
584
|
+
.note{font-size:11.5px;color:var(--dim);margin:10px 0 0;line-height:1.5}
|
|
585
|
+
.note.alert{color:var(--gold)}
|
|
586
|
+
|
|
587
|
+
.doc{font-size:13px;line-height:1.62;color:#dde0e6}
|
|
588
|
+
.doc h4{margin:0 0 8px;font-size:13.5px;font-weight:650;color:var(--ink)}
|
|
589
|
+
.doc h5{margin:16px 0 6px;font-size:11px;text-transform:uppercase;letter-spacing:.08em;
|
|
590
|
+
color:var(--muted);font-weight:650}
|
|
591
|
+
.doc p{margin:0 0 11px}
|
|
592
|
+
.doc ul{margin:0 0 11px;padding-left:19px}
|
|
593
|
+
.doc li{margin:0 0 4px}
|
|
594
|
+
.doc blockquote{margin:0 0 12px;padding:9px 13px;background:#13161b;border-left:2px solid var(--gold);
|
|
595
|
+
border-radius:0 8px 8px 0;color:#e8eaee}
|
|
596
|
+
.doc strong{color:#fff}
|
|
597
|
+
.doc code{background:#13161b;border-radius:4px;padding:1px 5px;font-size:12px}
|
|
598
|
+
.doc .empty{color:var(--dim);font-style:italic}
|
|
599
|
+
.paths{margin:14px 0 0;font-size:10.5px;color:var(--dim);word-break:break-all;line-height:1.5}
|
|
600
|
+
.paper{max-width:660px;max-height:100%;overflow:auto;padding:24px 26px;background:var(--panel);
|
|
601
|
+
border-radius:12px}
|
|
602
|
+
.sect{margin:20px 0 0;border-top:1px solid var(--line);padding-top:15px}
|
|
603
|
+
.sect h5{margin:0 0 9px;font-size:10.5px;text-transform:uppercase;letter-spacing:.1em;
|
|
604
|
+
color:var(--muted);font-weight:650}
|
|
605
|
+
details.sect > summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:7px}
|
|
606
|
+
details.sect > summary::-webkit-details-marker{display:none}
|
|
607
|
+
details.sect > summary h5{margin:0}
|
|
608
|
+
details.sect > summary::after{content:"show";font-size:10px;color:var(--dim);
|
|
609
|
+
border:1px solid var(--line);border-radius:5px;padding:1px 6px}
|
|
610
|
+
details.sect[open] > summary::after{content:"hide"}
|
|
611
|
+
details.sect > summary + *{margin-top:11px}
|
|
612
|
+
|
|
613
|
+
.cands{display:grid;gap:16px;padding:20px;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));
|
|
614
|
+
align-content:start;width:100%}
|
|
615
|
+
.screen.candwrap{align-items:flex-start;justify-content:flex-start;overflow:auto;
|
|
616
|
+
background:var(--bg);padding:0}
|
|
617
|
+
.cand{background:var(--panel);border:1px solid var(--line);border-radius:13px;overflow:hidden;
|
|
618
|
+
display:flex;flex-direction:column}
|
|
619
|
+
.cand .shot{aspect-ratio:9/16;max-height:50vh;background:#000;position:relative}
|
|
620
|
+
.cand .shot video,.cand .shot img{width:100%;height:100%;object-fit:contain;display:block}
|
|
621
|
+
.cand .shot .none{display:block;padding:16px;color:var(--dim);font-size:11px;text-align:center;
|
|
622
|
+
line-height:1.6}
|
|
623
|
+
.cand .k{position:absolute;top:9px;left:9px;background:#000000b8;border-radius:6px;
|
|
624
|
+
padding:2px 7px;font-size:10px;font-weight:700;letter-spacing:.04em;z-index:2}
|
|
625
|
+
.cand .b{padding:12px 13px;flex:1;display:flex;flex-direction:column;gap:7px;min-width:0}
|
|
626
|
+
.cand .nm{font-size:13px;font-weight:650;line-height:1.35;overflow:hidden;text-overflow:ellipsis}
|
|
627
|
+
.cand .wy{font-size:12px;color:var(--gold);line-height:1.5}
|
|
628
|
+
.cand .sb{font-size:11px;color:var(--dim);line-height:1.5;overflow-wrap:anywhere}
|
|
629
|
+
.cand .f{padding:0 13px 13px}
|
|
630
|
+
.cand .f button{width:100%}
|
|
631
|
+
|
|
632
|
+
.lcard{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:15px 17px;
|
|
633
|
+
margin:0 0 11px}
|
|
634
|
+
.lcard .top{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
|
635
|
+
.lcard .top b{font-size:13.5px}
|
|
636
|
+
.lcard .meta{color:var(--dim);font-size:12px;margin:7px 0 0;overflow-wrap:anywhere}
|
|
637
|
+
.lcard .nxt{margin:12px 0 0;padding:10px 13px;background:var(--panel-2);border-radius:9px;
|
|
638
|
+
font-size:12px;color:var(--muted);white-space:pre-wrap;line-height:1.6}
|
|
639
|
+
.lcard .nxt b{color:var(--ink)}
|
|
640
|
+
.pipe{display:flex;gap:3px;margin:11px 0 0;flex-wrap:wrap}
|
|
641
|
+
.step{font-size:9.5px;letter-spacing:.05em;text-transform:uppercase;color:var(--dim);
|
|
642
|
+
border:1px solid var(--line);border-radius:5px;padding:2px 6px}
|
|
643
|
+
.step.done{color:var(--muted);border-color:#333a45}
|
|
644
|
+
.step.at{color:var(--gold-ink);background:var(--gold);border-color:var(--gold);font-weight:700}
|
|
645
|
+
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
646
|
+
th{text-align:left;font-size:10.5px;text-transform:uppercase;letter-spacing:.08em;
|
|
647
|
+
color:var(--dim);font-weight:650;padding:0 12px 9px 0}
|
|
648
|
+
td{padding:10px 12px 10px 0;border-top:1px solid var(--line);vertical-align:top}
|
|
649
|
+
tr.clickrow{cursor:pointer}
|
|
650
|
+
tr.clickrow:hover td{background:#1c1f26}
|
|
651
|
+
.thumbcell{width:64px}
|
|
652
|
+
.thumbcell video{width:56px;height:84px;object-fit:cover;border-radius:6px;background:#000;display:block}
|
|
653
|
+
.thumbcell .nothumb{width:56px;height:84px;border-radius:6px;border:1px dashed var(--line);
|
|
654
|
+
display:flex;align-items:center;justify-content:center;font-size:9px;color:var(--dim);text-align:center}
|
|
655
|
+
.files{display:flex;flex-wrap:wrap;gap:6px;margin:11px 0 0}
|
|
656
|
+
.files span{font-size:11px;background:var(--panel-2);border:1px solid var(--line);border-radius:6px;
|
|
657
|
+
padding:3px 8px;font-family:ui-monospace,Menlo,monospace;color:var(--muted)}
|
|
658
|
+
.pathrow{display:flex;gap:8px;align-items:center;margin:11px 0 0}
|
|
659
|
+
.pathrow{flex-wrap:wrap}
|
|
660
|
+
.pathrow code{flex:1 1 100%;min-width:0;width:0;font-size:11px;background:#13161b;border-radius:6px;
|
|
661
|
+
padding:6px 9px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--muted)}
|
|
662
|
+
.pathrow button{padding:6px 11px;font-size:11.5px;flex:none}
|
|
663
|
+
|
|
664
|
+
.clear{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
|
665
|
+
gap:11px;text-align:center;padding:40px}
|
|
666
|
+
.clear .big{font-size:19px;font-weight:650}
|
|
667
|
+
.clear .sm{color:var(--dim);font-size:13px;max-width:470px;line-height:1.7}
|
|
668
|
+
.clear code{background:var(--panel-2);border-radius:5px;padding:2px 7px;font-size:12px}
|
|
669
|
+
.clear .row{display:flex;gap:8px;margin-top:6px}
|
|
670
|
+
|
|
671
|
+
.toast{position:fixed;left:50%;bottom:26px;transform:translateX(-50%);z-index:40;
|
|
672
|
+
background:#20242c;border:1px solid var(--line);border-radius:11px;padding:11px 13px 11px 16px;
|
|
673
|
+
display:none;align-items:center;gap:14px;box-shadow:0 10px 40px -12px #000c;font-size:13px}
|
|
674
|
+
.toast.on{display:flex}
|
|
675
|
+
.toast b{font-weight:600}
|
|
676
|
+
.toast button{padding:6px 13px;font-size:12px}
|
|
677
|
+
|
|
678
|
+
dialog{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:14px;
|
|
679
|
+
padding:20px;max-width:470px;width:92%}
|
|
680
|
+
dialog::backdrop{background:#000a}
|
|
681
|
+
dialog h4{margin:0 0 4px;font-size:14px}
|
|
682
|
+
dialog p{margin:0 0 12px;color:var(--muted);font-size:12px;line-height:1.6}
|
|
683
|
+
textarea{width:100%;min-height:104px;background:var(--panel-2);border:1px solid var(--line);
|
|
684
|
+
border-radius:9px;padding:11px;resize:vertical;font:inherit;color:inherit;font-size:13px}
|
|
685
|
+
.dlgrow{display:flex;gap:8px;justify-content:flex-end;margin-top:12px}
|
|
686
|
+
.dlgerr{color:var(--red);font-size:12px;margin:8px 0 0;display:none}
|
|
687
|
+
.dlgerr.on{display:block}
|
|
688
|
+
|
|
689
|
+
/* Below the rail's width the rail hides, so the view switcher moves to a top
|
|
690
|
+
bar — otherwise there is NO navigation at all. The stage becomes one
|
|
691
|
+
scrolling column instead of two crushed ones. */
|
|
692
|
+
@media (max-width:1080px){
|
|
693
|
+
.app{grid-template-columns:1fr}
|
|
694
|
+
.rail{display:none}
|
|
695
|
+
.topbar{display:flex;gap:6px;padding:10px 14px;border-bottom:1px solid var(--line);
|
|
696
|
+
overflow-x:auto;flex:none;align-items:center}
|
|
697
|
+
.topbar button{padding:6px 12px;font-size:12.5px;white-space:nowrap;border-radius:8px}
|
|
698
|
+
.topbar button.on{background:var(--panel-2);border-color:#3b4250;color:var(--ink)}
|
|
699
|
+
.topbar .money{margin-left:auto;font-size:11px;color:var(--dim);white-space:nowrap}
|
|
700
|
+
.rv{grid-template-columns:minmax(0,1fr);grid-template-rows:auto auto;overflow-y:auto;min-height:0}
|
|
701
|
+
.screen{min-height:44vh;padding:12px}
|
|
702
|
+
.screen video{max-height:44vh}
|
|
703
|
+
.side{border-left:none;border-top:1px solid var(--line);min-height:0}
|
|
704
|
+
.pane{overflow:visible;flex:none}
|
|
705
|
+
.acts{position:sticky;bottom:0;background:var(--panel-2)}
|
|
706
|
+
.sh h2{max-width:100%}
|
|
707
|
+
}
|
|
708
|
+
</style>
|
|
709
|
+
</head>
|
|
710
|
+
<body>
|
|
711
|
+
<div class="app">
|
|
712
|
+
<aside class="rail">
|
|
713
|
+
<div class="brand">
|
|
714
|
+
<div class="t"><span class="live" id="live"></span>Clipper dashboard</div>
|
|
715
|
+
<div class="p" id="root"></div>
|
|
716
|
+
</div>
|
|
717
|
+
<nav id="nav"></nav>
|
|
718
|
+
<div class="railq" id="railq"></div>
|
|
719
|
+
<div class="railfoot">
|
|
720
|
+
<div class="warn" id="railwarn" style="display:none"></div>
|
|
721
|
+
<div class="money" id="money"></div>
|
|
722
|
+
<select id="mode" title="Which stages stop for you"></select>
|
|
723
|
+
<div class="note" id="modehint" style="margin-top:7px"></div>
|
|
724
|
+
</div>
|
|
725
|
+
</aside>
|
|
726
|
+
<main class="stage">
|
|
727
|
+
<div class="topbar" id="topbar"></div>
|
|
728
|
+
<div class="err" id="err">
|
|
729
|
+
<span class="msg" id="errmsg"></span>
|
|
730
|
+
<button data-act="dismiss-error" title="Dismiss">×</button>
|
|
731
|
+
</div>
|
|
732
|
+
<div id="stage" style="flex:1;min-height:0;display:flex;flex-direction:column"></div>
|
|
733
|
+
</main>
|
|
734
|
+
</div>
|
|
735
|
+
|
|
736
|
+
<div class="toast" id="toast">
|
|
737
|
+
<span id="toast-text"></span>
|
|
738
|
+
<button id="toast-undo" data-act="toast-undo">Undo</button>
|
|
739
|
+
</div>
|
|
740
|
+
|
|
741
|
+
<dialog id="prompt">
|
|
742
|
+
<h4 id="prompt-title"></h4>
|
|
743
|
+
<p id="prompt-help"></p>
|
|
744
|
+
<textarea id="prompt-text"></textarea>
|
|
745
|
+
<div class="dlgerr" id="prompt-err"></div>
|
|
746
|
+
<div class="dlgrow">
|
|
747
|
+
<button data-act="prompt-cancel">Cancel</button>
|
|
748
|
+
<button class="go" id="prompt-ok">Send</button>
|
|
749
|
+
</div>
|
|
750
|
+
</dialog>
|
|
751
|
+
|
|
752
|
+
<dialog id="confirm">
|
|
753
|
+
<h4 id="confirm-title"></h4>
|
|
754
|
+
<p id="confirm-help"></p>
|
|
755
|
+
<div class="dlgrow">
|
|
756
|
+
<button data-act="confirm-cancel">Cancel</button>
|
|
757
|
+
<button class="go" id="confirm-ok">Yes</button>
|
|
758
|
+
</div>
|
|
759
|
+
</dialog>
|
|
760
|
+
|
|
761
|
+
<script>
|
|
762
|
+
var TOKEN = "__TOKEN__";
|
|
763
|
+
var DATA = null;
|
|
764
|
+
var view = "review"; // review | progress | done
|
|
765
|
+
var cursorId = null; // the task being reviewed, BY ID — never by position
|
|
766
|
+
var inspectId = null;
|
|
767
|
+
var mediaKind = "cut"; // cut | clean
|
|
768
|
+
var ticks = {}; // taskId -> { criterion: true }
|
|
769
|
+
var busy = false;
|
|
770
|
+
var pollFails = 0;
|
|
771
|
+
var keys = { shell: "", head: "", screen: "", side: "", rail: "" };
|
|
772
|
+
|
|
773
|
+
function esc(v){
|
|
774
|
+
return String(v==null?"":v)
|
|
775
|
+
.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">")
|
|
776
|
+
.replace(/"/g,""").replace(/'/g,"'");
|
|
777
|
+
}
|
|
778
|
+
function money(v){return v==null?"—":"$"+Number(v).toFixed(2);}
|
|
779
|
+
function ago(iso){
|
|
780
|
+
if(!iso) return "";
|
|
781
|
+
var s=Math.max(0,(Date.now()-new Date(iso).getTime())/1000);
|
|
782
|
+
if(s<90) return Math.round(s)+"s ago";
|
|
783
|
+
if(s<5400) return Math.round(s/60)+"m ago";
|
|
784
|
+
if(s<172800) return Math.round(s/3600)+"h ago";
|
|
785
|
+
return Math.round(s/86400)+"d ago";
|
|
786
|
+
}
|
|
787
|
+
function showError(m){
|
|
788
|
+
document.getElementById("errmsg").textContent=m||"";
|
|
789
|
+
document.getElementById("err").classList.toggle("on",Boolean(m));
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
// ── markdown-lite ───────────────────────────────────────────────────────────
|
|
793
|
+
function md(text){
|
|
794
|
+
if(!text) return '<p class="empty">Nothing recorded.</p>';
|
|
795
|
+
var lines=String(text).split("\\n"), out=[], list=false, quote=false;
|
|
796
|
+
function closeList(){ if(list){ out.push("</ul>"); list=false; } }
|
|
797
|
+
function closeQuote(){ if(quote){ out.push("</blockquote>"); quote=false; } }
|
|
798
|
+
function closeAll(){ closeList(); closeQuote(); }
|
|
799
|
+
function inline(s){
|
|
800
|
+
return s.replace(/\\*\\*([^*]+)\\*\\*/g,"<strong>$1</strong>")
|
|
801
|
+
.replace(/\`([^\`]+)\`/g,"<code>$1</code>");
|
|
802
|
+
}
|
|
803
|
+
for(var i=0;i<lines.length;i++){
|
|
804
|
+
var line=esc(lines[i]).trim();
|
|
805
|
+
if(!line){ closeAll(); continue; }
|
|
806
|
+
var h=line.match(/^(#{1,6})\\s+(.*)$/);
|
|
807
|
+
if(h){ closeAll(); out.push(h[1].length<=1?"<h4>"+inline(h[2])+"</h4>":"<h5>"+inline(h[2])+"</h5>"); continue; }
|
|
808
|
+
if(/^>\\s?/.test(line)){
|
|
809
|
+
closeList();
|
|
810
|
+
if(!quote){ out.push("<blockquote>"); quote=true; } else { out.push(" "); }
|
|
811
|
+
out.push(inline(line.replace(/^>\\s?/,"")));
|
|
812
|
+
continue;
|
|
813
|
+
}
|
|
814
|
+
if(/^[-*]\\s+/.test(line)){
|
|
815
|
+
closeQuote();
|
|
816
|
+
if(!list){ out.push("<ul>"); list=true; }
|
|
817
|
+
out.push("<li>"+inline(line.replace(/^[-*]\\s+/,""))+"</li>");
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
closeAll(); out.push("<p>"+inline(line)+"</p>");
|
|
821
|
+
}
|
|
822
|
+
closeAll();
|
|
823
|
+
return out.join("");
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// The criteria come from the SERVER as a list. They are never derived by
|
|
827
|
+
// splitting the question apart, which used to invent criteria out of a custom
|
|
828
|
+
// --ask and could write one into an approval record.
|
|
829
|
+
function criteriaOf(task){
|
|
830
|
+
return (task.gate && Array.isArray(task.gate.criteria)) ? task.gate.criteria : [];
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// ── template previews ───────────────────────────────────────────────────────
|
|
834
|
+
var cards={}, pending={}, failed={};
|
|
835
|
+
var undecodable={}; // taskId -> true, set by the <video> error event
|
|
836
|
+
function ensureTemplate(id){
|
|
837
|
+
if(!id||cards[id]||pending[id]) return;
|
|
838
|
+
// Retry a failed lookup at most once a minute rather than never or always.
|
|
839
|
+
if(failed[id]&&Date.now()-(failed[id].at||0)<60000) return;
|
|
840
|
+
pending[id]=true;
|
|
841
|
+
fetch("/api/template/"+encodeURIComponent(id)+"?t="+TOKEN,{headers:{"x-vidfarm-panel":TOKEN}})
|
|
842
|
+
.then(function(r){return r.json();})
|
|
843
|
+
.then(function(d){
|
|
844
|
+
if(!d||!d.card) return;
|
|
845
|
+
// A card the server could not resolve is not an answer — caching it means
|
|
846
|
+
// \`vidfarm login\` in another terminal never takes effect here.
|
|
847
|
+
if(d.card.resolved) cards[id]=d.card;
|
|
848
|
+
else { d.card.at=Date.now(); failed[id]=d.card; }
|
|
849
|
+
// Only the side pane and the shortlist grid use this. Clearing the screen
|
|
850
|
+
// key recreated the <video> a second into every cut gate.
|
|
851
|
+
keys.side="";
|
|
852
|
+
if(isShortlistScreen()) keys.screen="";
|
|
853
|
+
paint();
|
|
854
|
+
})
|
|
855
|
+
.catch(function(){})
|
|
856
|
+
.then(function(){ delete pending[id]; });
|
|
857
|
+
}
|
|
858
|
+
function shotHtml(card,id){
|
|
859
|
+
if(!card&&id&&failed[id]) card=failed[id];
|
|
860
|
+
if(card&&card.preview_url){
|
|
861
|
+
if(card.preview_kind==="video"){
|
|
862
|
+
return '<video src="'+esc(card.preview_url)+'" preload="metadata" playsinline controls></video>';
|
|
863
|
+
}
|
|
864
|
+
return '<img src="'+esc(card.preview_url)+'" alt="" loading="lazy">';
|
|
865
|
+
}
|
|
866
|
+
if(!card) return '<div class="none">loading the preview…</div>';
|
|
867
|
+
// "no preview stored" and "we could not ask" are different facts.
|
|
868
|
+
if(card.resolved) return '<div class="none">this template has no preview stored</div>';
|
|
869
|
+
return '<div class="none">could not load a preview<br>'+esc(card.reason||"the lookup failed")+"</div>";
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
// ── the network ─────────────────────────────────────────────────────────────
|
|
873
|
+
// act() REJECTS on failure. It used to catch, show a banner, and then resolve,
|
|
874
|
+
// so the .then() chain fired a success toast on top of the error — with a live
|
|
875
|
+
// Undo that would reopen a gate the reviewer never answered.
|
|
876
|
+
function act(payload){
|
|
877
|
+
if(busy){
|
|
878
|
+
showError("Still finishing the last action — give it a second.");
|
|
879
|
+
return Promise.reject(new Error("busy"));
|
|
880
|
+
}
|
|
881
|
+
busy=true;
|
|
882
|
+
return fetch("/api/action?t="+TOKEN,{
|
|
883
|
+
method:"POST",
|
|
884
|
+
headers:{"content-type":"application/json","x-vidfarm-panel":TOKEN},
|
|
885
|
+
body:JSON.stringify(payload)
|
|
886
|
+
}).then(function(r){
|
|
887
|
+
return r.json()
|
|
888
|
+
.catch(function(){ throw new Error("The panel sent back something that is not JSON ("+r.status+")."); })
|
|
889
|
+
.then(function(d){
|
|
890
|
+
if(!r.ok||!d.ok) throw new Error(d.error||("Request failed ("+r.status+")"));
|
|
891
|
+
showError("");
|
|
892
|
+
return d;
|
|
893
|
+
});
|
|
894
|
+
}).then(function(d){
|
|
895
|
+
return poll().then(function(){ busy=false; return d; });
|
|
896
|
+
},function(e){
|
|
897
|
+
if(e&&e.message!=="busy") showError(e.message);
|
|
898
|
+
return poll().then(function(){ busy=false; throw e; });
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// ── queue, keyed by task id ─────────────────────────────────────────────────
|
|
903
|
+
function queue(){ return DATA?DATA.tasks.filter(function(t){return t.gate;}):[]; }
|
|
904
|
+
function isShortlistScreen(){
|
|
905
|
+
if(inspectId||view!=="review") return false;
|
|
906
|
+
var t=current();
|
|
907
|
+
return Boolean(t&&t.gate&&t.gate.stage==="shortlist");
|
|
908
|
+
}
|
|
909
|
+
function current(){
|
|
910
|
+
var q=queue();
|
|
911
|
+
if(!q.length) return null;
|
|
912
|
+
for(var i=0;i<q.length;i++){ if(q[i].task_id===cursorId) return q[i]; }
|
|
913
|
+
// The task we were on is gone (answered here, or from the terminal). Fall to
|
|
914
|
+
// the front rather than to whatever now occupies the old index — an index
|
|
915
|
+
// cursor slid a different video under the reviewer mid-watch.
|
|
916
|
+
cursorId=q[0].task_id;
|
|
917
|
+
mediaKind="cut"; // never carry "clean" onto a task you did not choose
|
|
918
|
+
return q[0];
|
|
919
|
+
}
|
|
920
|
+
function cursorIndex(){
|
|
921
|
+
var q=queue();
|
|
922
|
+
for(var i=0;i<q.length;i++){ if(q[i].task_id===cursorId) return i; }
|
|
923
|
+
return 0;
|
|
924
|
+
}
|
|
925
|
+
function moveCursor(delta){
|
|
926
|
+
var q=queue(); if(!q.length) return;
|
|
927
|
+
var i=Math.min(q.length-1,Math.max(0,cursorIndex()+delta));
|
|
928
|
+
if(q[i].task_id===cursorId) return;
|
|
929
|
+
cursorId=q[i].task_id; mediaKind="cut"; paint();
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function tickState(task){ return ticks[task.task_id]||(ticks[task.task_id]={}); }
|
|
933
|
+
|
|
934
|
+
// The task under the cursor can change between the reviewer LOOKING and the
|
|
935
|
+
// reviewer CLICKING: current() falls back to the front of the queue when its
|
|
936
|
+
// task is answered elsewhere. Acting on the new one would approve a video
|
|
937
|
+
// nobody watched, so refuse once and let them look again.
|
|
938
|
+
var renderedSubjectId=null;
|
|
939
|
+
function subjectStillOnScreen(task){
|
|
940
|
+
if(renderedSubjectId&&task.task_id!==renderedSubjectId){
|
|
941
|
+
showError("The task on screen changed just now (something else answered \\""+renderedSubjectId
|
|
942
|
+
+"\\"). Nothing was sent — look at what is in front of you and act again.");
|
|
943
|
+
keys.head=""; keys.screen=""; keys.side=""; paint();
|
|
944
|
+
return false;
|
|
945
|
+
}
|
|
946
|
+
return true;
|
|
947
|
+
}
|
|
948
|
+
function canApprove(task){
|
|
949
|
+
if(!task||!task.gate) return false;
|
|
950
|
+
// Approving a cut gate writes "a human watched this". There must be something
|
|
951
|
+
// to watch.
|
|
952
|
+
if(task.gate.stage==="cut"&&!task.has_cut) return false;
|
|
953
|
+
// The file exists and has bytes, but the browser could not decode it. Only
|
|
954
|
+
// the player knows this, so only the player can disarm the button.
|
|
955
|
+
if(task.gate.stage==="cut"&&undecodable[task.task_id]) return false;
|
|
956
|
+
return task.gate.stage!=="shortlist";
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// ── actions ─────────────────────────────────────────────────────────────────
|
|
960
|
+
function approveCurrent(){
|
|
961
|
+
var task=current(); if(!task) return;
|
|
962
|
+
if(!subjectStillOnScreen(task)) return;
|
|
963
|
+
if(!canApprove(task)){
|
|
964
|
+
showError(task.gate&&task.gate.stage==="cut"
|
|
965
|
+
? (undecodable[task.task_id]
|
|
966
|
+
? "That file will not play, so it cannot be approved. Request changes or drop it."
|
|
967
|
+
: "There is no cut on disk to watch, so this cannot be approved. Request changes or drop it.")
|
|
968
|
+
: "This gate is answered by picking a template, not by approving.");
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
var items=criteriaOf(task), t=tickState(task);
|
|
972
|
+
var passed=items.filter(function(i){return t[i];});
|
|
973
|
+
var note=passed.length?"checked: "+passed.join("; "):null;
|
|
974
|
+
var label=task.title||task.task_id;
|
|
975
|
+
act({action:"approve",task_id:task.task_id,note:note}).then(function(){
|
|
976
|
+
delete ticks[task.task_id];
|
|
977
|
+
toast("Approved <b>"+esc(label)+"</b>",task.task_id);
|
|
978
|
+
},function(){});
|
|
979
|
+
}
|
|
980
|
+
function changesCurrent(){
|
|
981
|
+
var task=current(); if(!task) return;
|
|
982
|
+
if(!subjectStillOnScreen(task)) return;
|
|
983
|
+
var items=criteriaOf(task), t=tickState(task);
|
|
984
|
+
var failed=items.filter(function(i){return !t[i];});
|
|
985
|
+
// Nothing ticked is the ORDINARY "this is bad, send it back" case, and it used
|
|
986
|
+
// to prefill an empty box — the one case the feature exists for.
|
|
987
|
+
var prefill=failed.length?"Fix: "+failed.join("; "):"";
|
|
988
|
+
var label=task.title||task.task_id;
|
|
989
|
+
ask("Request changes","The agent redoes that step from this note, with a fresh context.",prefill,
|
|
990
|
+
function(text){
|
|
991
|
+
act({action:"changes",task_id:task.task_id,note:text}).then(function(){
|
|
992
|
+
delete ticks[task.task_id];
|
|
993
|
+
toast("Requested changes on <b>"+esc(label)+"</b>",task.task_id);
|
|
994
|
+
},function(){});
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
function dropCurrent(){
|
|
998
|
+
var task=current(); if(!task) return;
|
|
999
|
+
if(!subjectStillOnScreen(task)) return;
|
|
1000
|
+
var label=task.title||task.task_id;
|
|
1001
|
+
ask("Drop this task","Logged to LEDGER.jsonl. Release it on the gig, or let it expire.","",
|
|
1002
|
+
function(text){
|
|
1003
|
+
act({action:"drop",task_id:task.task_id,reason:text}).then(function(){
|
|
1004
|
+
toast("Dropped <b>"+esc(label)+"</b>",task.task_id);
|
|
1005
|
+
},function(){});
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
function pickCandidate(index){
|
|
1009
|
+
var task=current();
|
|
1010
|
+
if(!task||!task.gate||task.gate.stage!=="shortlist") return;
|
|
1011
|
+
if(!subjectStillOnScreen(task)) return;
|
|
1012
|
+
var row=task.shortlist[index]; if(!row) return;
|
|
1013
|
+
var card=cards[row.template_id];
|
|
1014
|
+
var label=(card&&card.title)||row.template_id;
|
|
1015
|
+
act({action:"pick",task_id:task.task_id,template_id:row.template_id}).then(function(){
|
|
1016
|
+
toast("Picked <b>"+esc(label)+"</b>",task.task_id);
|
|
1017
|
+
},function(){});
|
|
1018
|
+
}
|
|
1019
|
+
function undoTask(taskId){
|
|
1020
|
+
hideToast();
|
|
1021
|
+
act({action:"undo",task_id:taskId}).then(function(d){
|
|
1022
|
+
cursorId=taskId;
|
|
1023
|
+
toast("Reopened the <b>"+esc((d&&d.reopened)||"last")+"</b> gate",null);
|
|
1024
|
+
},function(){});
|
|
1025
|
+
}
|
|
1026
|
+
function revealFor(taskId,target){ act({action:"reveal",task_id:taskId,target:target}).then(null,function(){}); }
|
|
1027
|
+
|
|
1028
|
+
// ── toast + dialogs ─────────────────────────────────────────────────────────
|
|
1029
|
+
var toastTimer=null, toastTask=null;
|
|
1030
|
+
function toast(html,taskId){
|
|
1031
|
+
toastTask=taskId||null;
|
|
1032
|
+
document.getElementById("toast-text").innerHTML=html;
|
|
1033
|
+
document.getElementById("toast-undo").style.display=taskId?"":"none";
|
|
1034
|
+
document.getElementById("toast").classList.add("on");
|
|
1035
|
+
if(toastTimer) clearTimeout(toastTimer);
|
|
1036
|
+
toastTimer=setTimeout(hideToast,12000);
|
|
1037
|
+
}
|
|
1038
|
+
function hideToast(){
|
|
1039
|
+
document.getElementById("toast").classList.remove("on");
|
|
1040
|
+
toastTask=null;
|
|
1041
|
+
if(toastTimer){ clearTimeout(toastTimer); toastTimer=null; }
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
var promptRun=null;
|
|
1045
|
+
function ask(title,help,prefill,then){
|
|
1046
|
+
var box=document.getElementById("prompt");
|
|
1047
|
+
document.getElementById("prompt-title").textContent=title;
|
|
1048
|
+
document.getElementById("prompt-help").textContent=help;
|
|
1049
|
+
document.getElementById("prompt-err").classList.remove("on");
|
|
1050
|
+
var f=document.getElementById("prompt-text");
|
|
1051
|
+
f.value=prefill||"";
|
|
1052
|
+
promptRun=then;
|
|
1053
|
+
box.showModal(); f.focus();
|
|
1054
|
+
}
|
|
1055
|
+
document.getElementById("prompt-ok").addEventListener("click",function(){
|
|
1056
|
+
var f=document.getElementById("prompt-text");
|
|
1057
|
+
var text=f.value.trim();
|
|
1058
|
+
if(!text){
|
|
1059
|
+
// Silently re-focusing read as a dead button.
|
|
1060
|
+
var e=document.getElementById("prompt-err");
|
|
1061
|
+
e.textContent="Write what needs to change — this note is what the agent works from.";
|
|
1062
|
+
e.classList.add("on"); f.focus(); return;
|
|
1063
|
+
}
|
|
1064
|
+
document.getElementById("prompt").close();
|
|
1065
|
+
if(promptRun) promptRun(text);
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
var confirmRun=null;
|
|
1069
|
+
function confirmThen(title,help,label,then){
|
|
1070
|
+
document.getElementById("confirm-title").textContent=title;
|
|
1071
|
+
document.getElementById("confirm-help").textContent=help;
|
|
1072
|
+
document.getElementById("confirm-ok").textContent=label;
|
|
1073
|
+
confirmRun=then;
|
|
1074
|
+
document.getElementById("confirm").showModal();
|
|
1075
|
+
}
|
|
1076
|
+
document.getElementById("confirm-ok").addEventListener("click",function(){
|
|
1077
|
+
document.getElementById("confirm").close();
|
|
1078
|
+
if(confirmRun) confirmRun();
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
// ── one delegated listener; NO generated inline handlers anywhere ───────────
|
|
1082
|
+
// Building \`onclick="fn('<id>')"\` strings is what let a task id containing a
|
|
1083
|
+
// quote run arbitrary JS in this page — and what silently broke every criterion
|
|
1084
|
+
// containing an apostrophe.
|
|
1085
|
+
document.addEventListener("click",function(e){
|
|
1086
|
+
var el=e.target.closest?e.target.closest("[data-act]"):null;
|
|
1087
|
+
if(!el) return;
|
|
1088
|
+
var a=el.getAttribute("data-act");
|
|
1089
|
+
var id=el.getAttribute("data-task");
|
|
1090
|
+
if(a==="approve") approveCurrent();
|
|
1091
|
+
else if(a==="changes") changesCurrent();
|
|
1092
|
+
else if(a==="drop") dropCurrent();
|
|
1093
|
+
else if(a==="pick") pickCandidate(Number(el.getAttribute("data-index")));
|
|
1094
|
+
else if(a==="undo") undoTask(id);
|
|
1095
|
+
else if(a==="toast-undo"){ if(toastTask) undoTask(toastTask); }
|
|
1096
|
+
else if(a==="reveal") revealFor(id,el.getAttribute("data-target"));
|
|
1097
|
+
else if(a==="open"){ inspectId=id; mediaKind="cut"; paint(); }
|
|
1098
|
+
else if(a==="close-inspect"){ inspectId=null; paint(); }
|
|
1099
|
+
else if(a==="view"){ inspectId=null; view=el.getAttribute("data-view"); paint(); }
|
|
1100
|
+
else if(a==="cursor"){ cursorId=id; mediaKind="cut"; paint(); }
|
|
1101
|
+
else if(a==="step") moveCursor(Number(el.getAttribute("data-delta")));
|
|
1102
|
+
else if(a==="media"){ mediaKind=el.getAttribute("data-kind"); keys.screen=""; paint(); }
|
|
1103
|
+
else if(a==="copy"){
|
|
1104
|
+
var text=el.getAttribute("data-value")||"";
|
|
1105
|
+
if(!navigator.clipboard){ showError("This browser will not let the page copy. The path is: "+text); return; }
|
|
1106
|
+
navigator.clipboard.writeText(text).then(function(){
|
|
1107
|
+
el.textContent="Copied"; setTimeout(function(){ el.textContent="Copy path"; },1400);
|
|
1108
|
+
},function(){
|
|
1109
|
+
showError("The browser refused the copy (the window may not be focused). The path is: "+text);
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
else if(a==="dismiss-error") showError("");
|
|
1113
|
+
else if(a==="prompt-cancel") document.getElementById("prompt").close();
|
|
1114
|
+
else if(a==="confirm-cancel") document.getElementById("confirm").close();
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1117
|
+
// Ticking updates the DOM in place. It used to force a full repaint, which
|
|
1118
|
+
// recreated the <video> and threw away the reviewer's place — five times per
|
|
1119
|
+
// review, in the middle of the one thing this page exists to do.
|
|
1120
|
+
document.addEventListener("change",function(e){
|
|
1121
|
+
var box=e.target;
|
|
1122
|
+
if(!box.matches||!box.matches("[data-crit]")) return;
|
|
1123
|
+
var task=current(); if(!task) return;
|
|
1124
|
+
var key=box.getAttribute("data-crit");
|
|
1125
|
+
var t=tickState(task);
|
|
1126
|
+
if(box.checked) t[key]=true; else delete t[key];
|
|
1127
|
+
var label=box.closest("label");
|
|
1128
|
+
if(label) label.classList.toggle("ok",Boolean(t[key]));
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
// ── keyboard ────────────────────────────────────────────────────────────────
|
|
1132
|
+
document.addEventListener("keydown",function(e){
|
|
1133
|
+
if(document.getElementById("prompt").open||document.getElementById("confirm").open) return;
|
|
1134
|
+
var t=e.target;
|
|
1135
|
+
if(t&&(t.tagName==="TEXTAREA"||t.tagName==="INPUT"||t.tagName==="SELECT")) return;
|
|
1136
|
+
if(e.metaKey||e.ctrlKey||e.altKey) return;
|
|
1137
|
+
var key=(e.key||"").toLowerCase();
|
|
1138
|
+
if(e.key==="Escape"&&inspectId){ e.preventDefault(); inspectId=null; paint(); return; }
|
|
1139
|
+
// U works on whatever is in front of you, not only while a toast happens to
|
|
1140
|
+
// be up — it was advertised unconditionally and inert most of the time.
|
|
1141
|
+
if(key==="u"){
|
|
1142
|
+
e.preventDefault();
|
|
1143
|
+
if(toastTask) undoTask(toastTask);
|
|
1144
|
+
else if(inspectId) undoTask(inspectId);
|
|
1145
|
+
// A task in the review queue is BY DEFINITION already waiting on you, so
|
|
1146
|
+
// there is nothing of yours to undo on it. Say that instead of firing a
|
|
1147
|
+
// request that always fails.
|
|
1148
|
+
else showError("Nothing to undo — undo applies to a decision you just made. "
|
|
1149
|
+
+"Open a task from In progress or Submitted to reopen its last gate.");
|
|
1150
|
+
return;
|
|
1151
|
+
}
|
|
1152
|
+
if(e.key===" "){
|
|
1153
|
+
var iv=document.getElementById("cutplayer");
|
|
1154
|
+
if(iv){ e.preventDefault(); if(iv.paused) iv.play(); else iv.pause(); return; }
|
|
1155
|
+
}
|
|
1156
|
+
if(view!=="review"||inspectId) return;
|
|
1157
|
+
var task=current(); if(!task) return;
|
|
1158
|
+
if(task.gate.stage==="shortlist"&&/^[1-9]$/.test(key)){ e.preventDefault(); pickCandidate(Number(key)-1); return; }
|
|
1159
|
+
if(key==="a"){ e.preventDefault(); approveCurrent(); }
|
|
1160
|
+
else if(key==="c"){ e.preventDefault(); changesCurrent(); }
|
|
1161
|
+
else if(key==="d"){ e.preventDefault(); dropCurrent(); }
|
|
1162
|
+
else if(key==="j"||e.key==="ArrowRight"){ e.preventDefault(); moveCursor(1); }
|
|
1163
|
+
else if(key==="k"||e.key==="ArrowLeft"){ e.preventDefault(); moveCursor(-1); }
|
|
1164
|
+
else if(e.key===" "){
|
|
1165
|
+
var v=document.getElementById("cutplayer");
|
|
1166
|
+
if(v){ e.preventDefault(); if(v.paused) v.play(); else v.pause(); }
|
|
1167
|
+
}
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
// ── rendering ───────────────────────────────────────────────────────────────
|
|
1171
|
+
function pipeline(task,stages){
|
|
1172
|
+
var at=task.gate?task.gate.stage:task.stage, index=stages.indexOf(at);
|
|
1173
|
+
return '<div class="pipe">'+stages.map(function(s,i){
|
|
1174
|
+
return '<span class="'+(s===at?"step at":(index>=0&&i<index?"step done":"step"))+'">'+esc(s)+"</span>";
|
|
1175
|
+
}).join("")+"</div>";
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
function mediaTabs(task){
|
|
1179
|
+
if(!task.has_cut||!task.has_clean_master) return "";
|
|
1180
|
+
return '<div class="mediatabs">'
|
|
1181
|
+
+'<button data-act="media" data-kind="cut" class="'+(mediaKind==="cut"?"on":"")+'">Watermarked</button>'
|
|
1182
|
+
+'<button data-act="media" data-kind="clean" class="'+(mediaKind==="clean"?"on":"")+'">Clean master</button>'
|
|
1183
|
+
+"</div>";
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function screenHtml(task){
|
|
1187
|
+
var kind=(mediaKind==="clean"&&task.has_clean_master)?"clean":"cut";
|
|
1188
|
+
var have=kind==="clean"?task.has_clean_master:task.has_cut;
|
|
1189
|
+
if(have){
|
|
1190
|
+
// No autoplay and no muted: one of the criteria is "is the audio clean",
|
|
1191
|
+
// and a player that starts silent answers it wrong by default.
|
|
1192
|
+
return mediaTabs(task)
|
|
1193
|
+
+'<video id="cutplayer" controls preload="metadata" src="/media/'
|
|
1194
|
+
+encodeURIComponent(task.task_id)+"/"+kind+"?t="+TOKEN+'"></video>';
|
|
1195
|
+
}
|
|
1196
|
+
if(task.gate&&task.gate.stage==="cut"){
|
|
1197
|
+
return '<div class="nofile"><b>No cut on disk.</b><br>'
|
|
1198
|
+
+esc(task.cut_path||"(no path was recorded)")
|
|
1199
|
+
+"<br><br>Nothing to watch, so this cannot be approved — request changes or drop it.</div>";
|
|
1200
|
+
}
|
|
1201
|
+
if(task.plan_text) return '<div class="paper"><div class="doc"><h4>The plan</h4>'+md(task.plan_text)+"</div></div>";
|
|
1202
|
+
if(task.brief) return '<div class="paper"><div class="doc"><h4>The buyer asked for</h4>'+md(task.brief)+"</div></div>";
|
|
1203
|
+
return '<div class="nofile">Nothing has been produced on this task yet.</div>';
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function briefSection(task){
|
|
1207
|
+
return '<div class="sect"><h5>the buyer asked for</h5>'
|
|
1208
|
+
+(task.brief
|
|
1209
|
+
? '<div class="doc">'+md(task.brief)+"</div>"
|
|
1210
|
+
: '<p class="note">No brief was recorded. The agent writes one with '
|
|
1211
|
+
+"<code>clipper-run start --brief <file></code>.</p>")
|
|
1212
|
+
+"</div>";
|
|
1213
|
+
}
|
|
1214
|
+
function planSection(task){
|
|
1215
|
+
if(!task.plan_text) return "";
|
|
1216
|
+
return '<details class="sect"><summary><h5>the plan it was built from</h5></summary>'
|
|
1217
|
+
+'<div class="doc">'+md(task.plan_text)+"</div>"
|
|
1218
|
+
+(task.plan_path?'<div class="paths">'+esc(task.plan_path)+"</div>":"")+"</details>";
|
|
1219
|
+
}
|
|
1220
|
+
function pathRow(task,target,value){
|
|
1221
|
+
return '<div class="pathrow"><code title="'+esc(value)+'">'+esc(value)+"</code>"
|
|
1222
|
+
+'<button data-act="copy" data-value="'+esc(value)+'">Copy path</button>'
|
|
1223
|
+
+'<button data-act="reveal" data-task="'+esc(task.task_id)+'" data-target="'+esc(target)+'">Open folder</button></div>';
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function paneHtml(task,inspect){
|
|
1227
|
+
var stage=task.gate?task.gate.stage:null;
|
|
1228
|
+
var items=criteriaOf(task), t=tickState(task);
|
|
1229
|
+
var pane="";
|
|
1230
|
+
if(inspect){
|
|
1231
|
+
var headline=task.stage==="discarded"?"Dropped"
|
|
1232
|
+
:(task.stage==="done"?"Submitted":"In flight — "+esc(task.stage));
|
|
1233
|
+
pane+='<div class="doc"><h4>'+headline+"</h4>";
|
|
1234
|
+
if(task.stage==="discarded"){
|
|
1235
|
+
var why="";
|
|
1236
|
+
for(var i=task.history.length-1;i>=0;i--){
|
|
1237
|
+
if(task.history[i].event==="discarded"){ why=task.history[i].note||""; break; }
|
|
1238
|
+
}
|
|
1239
|
+
pane+="<blockquote>"+esc(why||"No reason was recorded.")+"</blockquote>";
|
|
1240
|
+
}else if(task.stage==="done"){
|
|
1241
|
+
pane+="<p>"+(task.proof_id
|
|
1242
|
+
?"Proof <code>"+esc(task.proof_id)+"</code> is with the buyer. Approved is not paid."
|
|
1243
|
+
:"Closed as submitted, but no proof id was recorded.")+"</p>";
|
|
1244
|
+
}else{
|
|
1245
|
+
pane+="<p>The agent still owns this one.</p><p class=\\"note\\" style=\\"white-space:pre-wrap\\">"
|
|
1246
|
+
+esc(String(task.next.what))+"</p>";
|
|
1247
|
+
}
|
|
1248
|
+
pane+="<p>"+(task.reviewed_by_gigworker
|
|
1249
|
+
?"You watched the cut on "+esc(String(task.reviewed_by_gigworker).slice(0,16).replace("T"," "))+"."
|
|
1250
|
+
:(task.mode==="auto-submit"?"auto-submit — nobody watched this one."
|
|
1251
|
+
:"No record that anybody watched it."))+"</p></div>";
|
|
1252
|
+
}else if(stage==="shortlist"){
|
|
1253
|
+
pane+='<p class="qhead">'+esc(task.gate.ask)+"</p>"
|
|
1254
|
+
+'<p class="note">Pick the one that fits the brief below — click <b>Use this</b>, or press '
|
|
1255
|
+
+"<kbd>1</kbd>–<kbd>"+Math.max(1,task.shortlist.length)+"</kbd>.</p>";
|
|
1256
|
+
}else{
|
|
1257
|
+
pane+='<p class="qhead">'+esc(task.gate.ask)+"</p>";
|
|
1258
|
+
if(items.length){
|
|
1259
|
+
pane+='<div class="crit">'+items.map(function(item){
|
|
1260
|
+
return '<label class="'+(t[item]?"ok":"")+'"><input type="checkbox" data-crit="'+esc(item)+'"'
|
|
1261
|
+
+(t[item]?" checked":"")+"><span>"+esc(item)+"</span></label>";
|
|
1262
|
+
}).join("")+"</div>";
|
|
1263
|
+
pane+='<p class="note">Ticks go into the task history on approve. Anything left unticked is '
|
|
1264
|
+
+"pre-filled as the note when you request changes.</p>";
|
|
1265
|
+
}
|
|
1266
|
+
if(stage==="cut"&&!task.has_cut){
|
|
1267
|
+
pane+='<p class="note alert">There is no cut on disk, so Approve is disabled.</p>';
|
|
1268
|
+
} else if(stage==="cut"&&undecodable[task.task_id]){
|
|
1269
|
+
pane+='<p class="note alert">That file will not play, so Approve is disabled.</p>';
|
|
1270
|
+
}
|
|
1271
|
+
if(stage==="raws"){
|
|
1272
|
+
pane+='<p class="note">'+task.raws_count+" file(s) collected so far.</p>";
|
|
1273
|
+
if(task.raws_files.length){
|
|
1274
|
+
pane+='<div class="files">'+task.raws_files.map(function(f){return "<span>"+esc(f)+"</span>";}).join("")+"</div>";
|
|
1275
|
+
}
|
|
1276
|
+
if(task.raws_dir) pane+=pathRow(task,"raws",task.raws_dir);
|
|
1277
|
+
}
|
|
1278
|
+
if(stage==="cut"&&task.cut_path) pane+=pathRow(task,"cut",task.cut_path);
|
|
1279
|
+
}
|
|
1280
|
+
pane+=briefSection(task);
|
|
1281
|
+
if(!(task.gate&&task.gate.stage==="plan")) pane+=planSection(task);
|
|
1282
|
+
if(task.template_id){
|
|
1283
|
+
ensureTemplate(task.template_id);
|
|
1284
|
+
var card=cards[task.template_id];
|
|
1285
|
+
pane+='<div class="sect"><h5>adapting</h5><p class="note" style="margin:0"><b>'
|
|
1286
|
+
+esc((card&&card.title)||task.template_id)+"</b>, picked by the "
|
|
1287
|
+
+esc(task.template_picked_by||"agent")+".</p></div>";
|
|
1288
|
+
}
|
|
1289
|
+
if(inspect&&task.history&&task.history.length){
|
|
1290
|
+
pane+='<details class="sect"><summary><h5>trail</h5></summary><div class="doc"><ul>'
|
|
1291
|
+
+task.history.slice(-10).map(function(h){
|
|
1292
|
+
var n=h.note?String(h.note):"";
|
|
1293
|
+
if(n.indexOf("/")>-1&&n.indexOf(" ")===-1) n=n.slice(n.lastIndexOf("/")+1);
|
|
1294
|
+
return "<li>"+esc(h.event)+(n?" — "+esc(n):"")+"</li>";
|
|
1295
|
+
}).join("")+"</ul></div></details>";
|
|
1296
|
+
}
|
|
1297
|
+
return pane;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
function actsHtml(task,inspect){
|
|
1301
|
+
var id=esc(task.task_id);
|
|
1302
|
+
if(inspect){
|
|
1303
|
+
var blocked=task.stage==="done";
|
|
1304
|
+
return (blocked?'<p class="note" style="margin:0 0 4px">This one already went out — undo is local and '
|
|
1305
|
+
+"cannot unsend a proof. Withdraw it on the gig.</p>":"")
|
|
1306
|
+
+'<button data-act="undo" data-task="'+id+'"'+(blocked?" disabled":"")+">Undo my last decision</button>"
|
|
1307
|
+
+'<button data-act="close-inspect">Back to the list</button>';
|
|
1308
|
+
}
|
|
1309
|
+
if(task.gate.stage==="shortlist"){
|
|
1310
|
+
return '<div class="rowb"><button data-act="changes">None of these — reshortlist</button>'
|
|
1311
|
+
+'<button class="warn" data-act="drop">Drop</button></div>'
|
|
1312
|
+
+'<div class="keys"><kbd>1</kbd>–<kbd>'+Math.max(1,task.shortlist.length)+"</kbd> pick · "
|
|
1313
|
+
+"<kbd>C</kbd> reshortlist · <kbd>D</kbd> drop · <kbd>U</kbd> undo</div>";
|
|
1314
|
+
}
|
|
1315
|
+
var ok=canApprove(task);
|
|
1316
|
+
return '<button class="go" data-act="approve"'+(ok?"":" disabled")+">Approve & continue</button>"
|
|
1317
|
+
+'<div class="rowb"><button data-act="changes">Request changes</button>'
|
|
1318
|
+
+'<button class="warn" data-act="drop">Drop</button></div>'
|
|
1319
|
+
+'<div class="keys"><kbd>A</kbd> approve · <kbd>C</kbd> changes · <kbd>D</kbd> drop '
|
|
1320
|
+
+"· <kbd>U</kbd> undo · <kbd>Space</kbd> play<br><kbd>J</kbd>/<kbd>K</kbd> next task</div>";
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
function headerHtml(task,inspect){
|
|
1324
|
+
var q=queue();
|
|
1325
|
+
var left=inspect?'<button class="back" data-act="close-inspect">← Back</button>':"";
|
|
1326
|
+
var badge=inspect
|
|
1327
|
+
? (task.stage==="discarded"?'<span class="tag red">dropped</span>'
|
|
1328
|
+
:(task.stage==="done"?'<span class="tag green">awaiting the buyer</span>'
|
|
1329
|
+
:'<span class="tag">'+esc(task.stage)+"</span>"))
|
|
1330
|
+
: '<span class="tag gold">'+esc(task.gate.stage)+" gate</span>";
|
|
1331
|
+
var right=inspect?"":'<span class="sub">'+(cursorIndex()+1)+" of "+q.length+"</span>"
|
|
1332
|
+
+'<button data-act="step" data-delta="-1"'+(cursorIndex()<=0?" disabled":"")+">←</button>"
|
|
1333
|
+
+'<button data-act="step" data-delta="1"'+(cursorIndex()>=q.length-1?" disabled":"")+">→</button>";
|
|
1334
|
+
return '<div class="sh">'+left+"<h2>"+esc(task.title||task.task_id)+"</h2>"+badge
|
|
1335
|
+
+'<span class="tag">'+esc(money(task.price))+"</span>"
|
|
1336
|
+
+'<span class="tag">'+esc(task.gig_id||"no gig")+"</span>"
|
|
1337
|
+
+'<span class="sp"></span>'+right+"</div>";
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
function candidateGrid(task){
|
|
1341
|
+
if(!task.shortlist.length){
|
|
1342
|
+
return '<div class="clear"><div class="sm">The agent has not shortlisted anything yet.</div></div>';
|
|
1343
|
+
}
|
|
1344
|
+
return '<div class="cands">'+task.shortlist.map(function(row,i){
|
|
1345
|
+
ensureTemplate(row.template_id);
|
|
1346
|
+
var card=cards[row.template_id];
|
|
1347
|
+
var secs=card&&card.duration_seconds?Math.round(card.duration_seconds)+"s":"";
|
|
1348
|
+
return '<div class="cand"><div class="shot"><span class="k">'+(i+1)+"</span>"+shotHtml(card,row.template_id)+"</div>"
|
|
1349
|
+
+'<div class="b"><div class="nm" title="'+esc(row.template_id)+'">'
|
|
1350
|
+
+esc((card&&card.title)||row.template_id)+"</div>"
|
|
1351
|
+
+(row.why?'<div class="wy">'+esc(row.why)+"</div>":"")
|
|
1352
|
+
+(card&&card.summary?'<div class="sb">'+esc(card.summary)+"</div>":"")
|
|
1353
|
+
+'<div class="sb mono">'+esc(secs)+"</div></div>"
|
|
1354
|
+
+'<div class="f"><button class="go" data-act="pick" data-index="'+i+'">Use this</button></div>'
|
|
1355
|
+
+"</div>";
|
|
1356
|
+
}).join("")+"</div>";
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
function emptyReview(){
|
|
1360
|
+
if(DATA.missing){
|
|
1361
|
+
return '<div class="clear"><div class="big">The mission folder is gone.</div>'
|
|
1362
|
+
+'<div class="sm">Nothing can be read or written until it is back:<br><br><code>'
|
|
1363
|
+
+esc(DATA.root)+"</code><br><br>If you moved it, restart the panel with the new "
|
|
1364
|
+
+"<code>--dir</code>.</div></div>";
|
|
1365
|
+
}
|
|
1366
|
+
var anyTask=DATA.tasks.length>0;
|
|
1367
|
+
var working=DATA.tasks.filter(function(t){return !t.gate&&t.stage!=="done"&&t.stage!=="discarded";}).length;
|
|
1368
|
+
if(!anyTask){
|
|
1369
|
+
return '<div class="clear"><div class="big">No tasks yet.</div>'
|
|
1370
|
+
+'<div class="sm">This mission folder is empty. Claim work, then open a task:<br><br>'
|
|
1371
|
+
+"<code>vidfarm gigs earn</code> → <code>vidfarm gigs claim <gig-id></code> → "
|
|
1372
|
+
+"<code>vidfarm clipper-run start <task-id> --gig <gig-id></code></div></div>";
|
|
1373
|
+
}
|
|
1374
|
+
if(working){
|
|
1375
|
+
return '<div class="clear"><div class="big">Nothing waiting on you.</div>'
|
|
1376
|
+
+'<div class="sm">'+working+" task(s) are mid-flight — the agent's move. This page lights up the "
|
|
1377
|
+
+'moment one comes back.</div><div class="row">'
|
|
1378
|
+
+'<button data-act="view" data-view="progress">See what the agent is doing</button></div></div>';
|
|
1379
|
+
}
|
|
1380
|
+
return '<div class="clear"><div class="big">You are clear.</div>'
|
|
1381
|
+
+'<div class="sm">Nothing is waiting on you and nothing is in flight. Claim more work with '
|
|
1382
|
+
+"<code>vidfarm gigs earn</code>.</div></div>";
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
function progressHtml(){
|
|
1386
|
+
var rows=DATA.tasks.filter(function(t){return !t.gate&&t.stage!=="done"&&t.stage!=="discarded";});
|
|
1387
|
+
if(!rows.length){
|
|
1388
|
+
return '<div class="sh"><h2>In progress</h2></div><div class="clear">'
|
|
1389
|
+
+'<div class="sm">No task is mid-flight. Claim one: <code>vidfarm gigs claim <gig-id></code></div></div>';
|
|
1390
|
+
}
|
|
1391
|
+
return '<div class="sh"><h2>In progress</h2><span class="sub">'+rows.length
|
|
1392
|
+
+' task(s) the agent still owns</span></div><div class="body">'
|
|
1393
|
+
+rows.map(function(t){
|
|
1394
|
+
return '<div class="lcard"><div class="top"><b>'+esc(t.title||t.task_id)+"</b>"
|
|
1395
|
+
+'<span class="tag">'+esc(t.stage)+'</span><span class="tag">'+esc(money(t.price))+"</span>"
|
|
1396
|
+
+(t.has_cut?'<span class="tag green">has a cut</span>':"")+"</div>"
|
|
1397
|
+
+'<div class="meta mono">'+esc(t.task_id)+" · "+esc(t.gig_id||"no gig")
|
|
1398
|
+
+" · updated "+esc(ago(t.updated_at))+"</div>"
|
|
1399
|
+
+pipeline(t,DATA.stages)
|
|
1400
|
+
+'<div class="nxt"><b>next ('+esc(t.next.who)+"):</b> "+esc(t.next.what)+"</div>"
|
|
1401
|
+
+'<div style="display:flex;gap:8px;margin-top:12px">'
|
|
1402
|
+
+'<button data-act="open" data-task="'+esc(t.task_id)+'">Open</button>'
|
|
1403
|
+
+'<button data-act="undo" data-task="'+esc(t.task_id)+'">Undo my last decision</button></div>'
|
|
1404
|
+
+"</div>";
|
|
1405
|
+
}).join("")+"</div>";
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
function doneHtml(){
|
|
1409
|
+
var rows=DATA.tasks.filter(function(t){return t.stage==="done"||t.stage==="discarded";});
|
|
1410
|
+
var known={};
|
|
1411
|
+
DATA.tasks.forEach(function(t){ known[t.task_id]=true; });
|
|
1412
|
+
var seen={};
|
|
1413
|
+
var orphans=DATA.ledger.filter(function(r){
|
|
1414
|
+
if(!r.task_id||known[r.task_id]||seen[r.task_id]) return false;
|
|
1415
|
+
seen[r.task_id]=true; // newest first, so the first row is the current one
|
|
1416
|
+
return true;
|
|
1417
|
+
});
|
|
1418
|
+
if(!rows.length&&!orphans.length){
|
|
1419
|
+
return '<div class="sh"><h2>Submitted</h2></div><div class="clear"><div class="sm">'
|
|
1420
|
+
+"Nothing closed yet. A task lands here once you approve the cut and the agent sends the proof."
|
|
1421
|
+
+"</div></div>";
|
|
1422
|
+
}
|
|
1423
|
+
var s=DATA.summary||{awaiting:0,sent:0,dropped:0};
|
|
1424
|
+
var body=rows.map(function(t){
|
|
1425
|
+
var tag=t.stage==="discarded"?'<span class="tag red">dropped</span>'
|
|
1426
|
+
:(t.proof_id?'<span class="tag green">awaiting the buyer</span>':'<span class="tag">done</span>');
|
|
1427
|
+
var watched=t.reviewed_by_gigworker?"you watched it"
|
|
1428
|
+
:(t.mode==="auto-submit"?"auto-submit — nobody watched":"not watched");
|
|
1429
|
+
return '<tr class="clickrow" data-act="open" data-task="'+esc(t.task_id)+'"><td class="thumbcell">'
|
|
1430
|
+
+(t.has_cut?'<video muted preload="metadata" src="/media/'+encodeURIComponent(t.task_id)
|
|
1431
|
+
+"/cut?t="+TOKEN+'#t=0.5"></video>':'<div class="nothumb">no cut</div>')+"</td>"
|
|
1432
|
+
+"<td><b>"+esc(t.title||t.task_id)+'</b><div class="meta mono">'+esc(t.task_id)+"</div></td>"
|
|
1433
|
+
+"<td>"+tag+'</td><td class="mono">'+esc(t.proof_id||"—")+"</td><td>"+esc(money(t.price))
|
|
1434
|
+
+'</td><td class="meta">'+esc(watched)+'</td><td class="meta">'+esc(ago(t.updated_at))+"</td></tr>";
|
|
1435
|
+
}).join("");
|
|
1436
|
+
body+=orphans.map(function(r){
|
|
1437
|
+
return '<tr><td class="thumbcell"><div class="nothumb">no task</div></td>'
|
|
1438
|
+
+'<td><b class="meta">'+esc(r.task_id)+'</b><div class="meta">ledger record only — the task '
|
|
1439
|
+
+"folder is gone</div></td>"
|
|
1440
|
+
+"<td>"+esc(r.status)+'</td><td class="mono">'+esc(r.proof_id||"—")+"</td>"
|
|
1441
|
+
+"<td>"+esc(money(r.locked_price))+'</td><td class="meta">'+esc(r.reason||"")+"</td>"
|
|
1442
|
+
+'<td class="meta">'+esc(ago(r.ts))+"</td></tr>";
|
|
1443
|
+
}).join("");
|
|
1444
|
+
return '<div class="sh"><h2>Submitted & closed</h2>'
|
|
1445
|
+
+'<span class="sub">click a row to watch what went out</span><span class="sp"></span>'
|
|
1446
|
+
+'<span class="tag">'+esc(money(s.awaiting))+" awaiting the buyer</span>"
|
|
1447
|
+
+'<span class="tag">'+s.sent+' sent</span><span class="tag">'+s.dropped+" dropped</span></div>"
|
|
1448
|
+
+'<div class="body"><table>'
|
|
1449
|
+
+"<tr><th></th><th>task</th><th>status</th><th>proof</th><th>price</th><th>review</th><th>closed</th></tr>"
|
|
1450
|
+
+body+"</table></div>";
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// The stage is drawn in THREE independently-keyed pieces so a change to one
|
|
1454
|
+
// never destroys another. The video used to be thrown away whenever any task
|
|
1455
|
+
// anywhere was written, because the whole stage was a single innerHTML.
|
|
1456
|
+
function paintStage(){
|
|
1457
|
+
var stage=document.getElementById("stage");
|
|
1458
|
+
var task=inspectId?DATA.tasks.filter(function(t){return t.task_id===inspectId;})[0]:null;
|
|
1459
|
+
if(inspectId&&!task) inspectId=null;
|
|
1460
|
+
var isReview=!inspectId&&view==="review";
|
|
1461
|
+
var subject=inspectId?task:(isReview?current():null);
|
|
1462
|
+
|
|
1463
|
+
if(!subject){
|
|
1464
|
+
renderedSubjectId=null;
|
|
1465
|
+
var html=view==="progress"?progressHtml():view==="done"?doneHtml():emptyReview();
|
|
1466
|
+
var k="list:"+view+":"+JSON.stringify(DATA.tasks.map(function(t){
|
|
1467
|
+
return [t.task_id,t.stage,t.updated_at,t.has_cut,t.proof_id];
|
|
1468
|
+
}))+":"+JSON.stringify(DATA.summary)+":"+DATA.ledger.length
|
|
1469
|
+
// "3m ago" must not sit there saying 3m for an hour.
|
|
1470
|
+
+":"+Math.floor(Date.now()/60000);
|
|
1471
|
+
if(keys.shell!==k){ keys.shell=k; keys.head=""; keys.screen=""; keys.side=""; stage.innerHTML=html; }
|
|
1472
|
+
return;
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
var inspect=Boolean(inspectId);
|
|
1476
|
+
// Deliberately WITHOUT the queue position/length: those only move the counter
|
|
1477
|
+
// in the header, and rebuilding the stage for them destroyed the player every
|
|
1478
|
+
// time an agent finished an unrelated task.
|
|
1479
|
+
var shellKey="one:"+subject.task_id+":"+(subject.gate?subject.gate.stage:subject.stage)+":"+inspect
|
|
1480
|
+
+":"+subject.price+":"+subject.title;
|
|
1481
|
+
if(keys.shell!==shellKey){
|
|
1482
|
+
keys.shell=shellKey; keys.head=""; keys.screen=""; keys.side="";
|
|
1483
|
+
var wrapClass=(!inspect&&subject.gate.stage==="shortlist")?"screen candwrap":"screen";
|
|
1484
|
+
stage.innerHTML='<div id="stagehead"></div>'
|
|
1485
|
+
+'<div class="rv"><div class="'+wrapClass+'" id="screen"></div>'
|
|
1486
|
+
+'<div class="side"><div class="pane" id="pane"></div><div class="acts" id="acts"></div></div></div>';
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// The header owns the counter, so it repaints on queue churn while the body
|
|
1490
|
+
// (and the player) stays put.
|
|
1491
|
+
var headKey="head:"+subject.task_id+":"+inspect+":"+cursorIndex()+":"+queue().length
|
|
1492
|
+
+":"+(subject.gate?subject.gate.stage:subject.stage)+":"+subject.price+":"+subject.title;
|
|
1493
|
+
if(keys.head!==headKey){
|
|
1494
|
+
keys.head=headKey;
|
|
1495
|
+
document.getElementById("stagehead").innerHTML=headerHtml(subject,inspect);
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
renderedSubjectId=subject.task_id;
|
|
1499
|
+
var isShortlist=!inspect&&subject.gate&&subject.gate.stage==="shortlist";
|
|
1500
|
+
var screenKey="scr:"+subject.task_id+":"+mediaKind+":"+subject.has_cut+":"+subject.has_clean_master
|
|
1501
|
+
+":"+(subject.cut_path||"")+":"+(isShortlist
|
|
1502
|
+
?JSON.stringify(subject.shortlist.map(function(r){return [r.template_id,Boolean(cards[r.template_id])];})):"");
|
|
1503
|
+
if(keys.screen!==screenKey){
|
|
1504
|
+
keys.screen=screenKey;
|
|
1505
|
+
var el=document.getElementById("screen");
|
|
1506
|
+
el.innerHTML=isShortlist?candidateGrid(subject):screenHtml(subject);
|
|
1507
|
+
var v=document.getElementById("cutplayer");
|
|
1508
|
+
if(v){
|
|
1509
|
+
// A .txt renamed .mp4, or a truncated render, used to look exactly like a
|
|
1510
|
+
// black opening frame with Approve armed.
|
|
1511
|
+
v.addEventListener("error",function(){
|
|
1512
|
+
// Replace only the player. The media tabs live in this container too,
|
|
1513
|
+
// and wiping them hid the clean master when the watermarked cut was the
|
|
1514
|
+
// broken one.
|
|
1515
|
+
var msg=document.createElement("div");
|
|
1516
|
+
msg.className="nofile";
|
|
1517
|
+
msg.innerHTML='<b>This file will not play.</b><br>'+esc(subject.cut_path||"")
|
|
1518
|
+
+"<br><br>It is on disk but the browser cannot decode it — a failed or truncated "
|
|
1519
|
+
+"render. Request changes or drop it.";
|
|
1520
|
+
v.replaceWith(msg);
|
|
1521
|
+
// Disarm the decision as well as the pixels.
|
|
1522
|
+
undecodable[subject.task_id]=true;
|
|
1523
|
+
keys.side="";
|
|
1524
|
+
paint();
|
|
1525
|
+
});
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
var sideKey="side:"+subject.task_id+":"+subject.updated_at+":"+inspect+":"+mediaKind
|
|
1530
|
+
+":"+(subject.brief?subject.brief.length:0)+":"+(subject.plan_text?subject.plan_text.length:0)
|
|
1531
|
+
+":"+subject.raws_count+":"+(cards[subject.template_id]?1:0)
|
|
1532
|
+
// These two are read from DISK each poll and can flip without a state
|
|
1533
|
+
// write, so they must be in the key or Approve goes stale in both
|
|
1534
|
+
// directions — dead next to a playing video, or live next to a gone one.
|
|
1535
|
+
+":"+subject.has_cut+":"+subject.has_clean_master;
|
|
1536
|
+
if(keys.side!==sideKey){
|
|
1537
|
+
keys.side=sideKey;
|
|
1538
|
+
document.getElementById("pane").innerHTML=paneHtml(subject,inspect);
|
|
1539
|
+
document.getElementById("acts").innerHTML=actsHtml(subject,inspect);
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
function paintRail(){
|
|
1544
|
+
var q=queue();
|
|
1545
|
+
var working=DATA.tasks.filter(function(t){return !t.gate&&t.stage!=="done"&&t.stage!=="discarded";});
|
|
1546
|
+
var closed=DATA.tasks.filter(function(t){return t.stage==="done"||t.stage==="discarded";});
|
|
1547
|
+
var s=DATA.summary||{awaiting:0,sent:0,dropped:0};
|
|
1548
|
+
|
|
1549
|
+
var navs=[["review","Waiting on you",q.length,q.length>0],
|
|
1550
|
+
["progress","In progress",working.length,false],
|
|
1551
|
+
["done","Submitted",closed.length,false]];
|
|
1552
|
+
var railKey=JSON.stringify([view,inspectId,cursorId,navs,DATA.run_mode,DATA.run_mode_is_set,
|
|
1553
|
+
s,DATA.unreadable,DATA.mode_unreadable,DATA.root,
|
|
1554
|
+
q.map(function(t){return [t.task_id,t.gate.stage,t.price,t.title];})]);
|
|
1555
|
+
if(keys.rail===railKey) return;
|
|
1556
|
+
keys.rail=railKey;
|
|
1557
|
+
|
|
1558
|
+
document.getElementById("root").textContent=DATA.root;
|
|
1559
|
+
var navHtml=navs.map(function(r){
|
|
1560
|
+
return '<button data-act="view" data-view="'+r[0]+'" class="'+(view===r[0]&&!inspectId?"on ":"")
|
|
1561
|
+
+(r[3]?"hot":"")+'">'+r[1]+"<b>"+r[2]+"</b></button>";
|
|
1562
|
+
}).join("");
|
|
1563
|
+
document.getElementById("nav").innerHTML=navHtml;
|
|
1564
|
+
document.getElementById("topbar").innerHTML=navHtml
|
|
1565
|
+
+'<span class="money">'+esc(money(s.awaiting))+" awaiting</span>";
|
|
1566
|
+
|
|
1567
|
+
document.getElementById("railq").innerHTML=(view!=="review"||inspectId||!q.length)?"":
|
|
1568
|
+
'<div class="h">the queue</div>'+q.map(function(t,i){
|
|
1569
|
+
return '<button class="qi'+(t.task_id===cursorId?" on":"")+'" data-act="cursor" data-task="'
|
|
1570
|
+
+esc(t.task_id)+'"><span class="n">'+(i+1)+'</span><span class="tx"><b>'
|
|
1571
|
+
+esc(t.title||t.task_id)+"</b><span>"+esc(t.gate.stage)+" · "+esc(money(t.price))
|
|
1572
|
+
+"</span></span></button>";
|
|
1573
|
+
}).join("");
|
|
1574
|
+
|
|
1575
|
+
document.getElementById("money").innerHTML=
|
|
1576
|
+
"<b>"+esc(money(s.awaiting))+"</b> submitted, awaiting the buyer<br>"
|
|
1577
|
+
+s.sent+" proof(s) sent · "+s.dropped+" dropped";
|
|
1578
|
+
|
|
1579
|
+
// A task the panel cannot read must never be silently absent from the queue.
|
|
1580
|
+
var warn=document.getElementById("railwarn");
|
|
1581
|
+
var lines=[];
|
|
1582
|
+
if(DATA.unreadable&&DATA.unreadable.length){
|
|
1583
|
+
lines.push(DATA.unreadable.length+" task folder(s) could not be read: "
|
|
1584
|
+
+DATA.unreadable.map(function(u){return u.folder;}).join(", ")+" — they are NOT in the queue.");
|
|
1585
|
+
}
|
|
1586
|
+
if(DATA.mode_unreadable) lines.push(DATA.mode_unreadable+" — falling back to "+DATA.run_mode+".");
|
|
1587
|
+
warn.innerHTML=lines.map(esc).join("<br>");
|
|
1588
|
+
warn.style.display=lines.length?"":"none";
|
|
1589
|
+
|
|
1590
|
+
var sel=document.getElementById("mode");
|
|
1591
|
+
if(sel.options.length!==DATA.modes.length){
|
|
1592
|
+
sel.innerHTML=DATA.modes.map(function(m){
|
|
1593
|
+
return '<option value="'+esc(m.mode)+'">'+esc(m.mode)+"</option>";}).join("");
|
|
1594
|
+
sel.addEventListener("change",function(){
|
|
1595
|
+
var wanted=sel.value;
|
|
1596
|
+
if(wanted===DATA.run_mode) return;
|
|
1597
|
+
var apply=function(){ act({action:"mode",mode:wanted}).then(null,function(){ sel.value=DATA.run_mode; }); };
|
|
1598
|
+
// auto-submit means nobody watches anything before it reaches a buyer.
|
|
1599
|
+
// One stray scroll over a dropdown should not be able to choose that.
|
|
1600
|
+
var blurbFor=(DATA.modes.filter(function(m){return m.mode===wanted;})[0]||{}).blurb||"";
|
|
1601
|
+
if(wanted!=="auto-submit"){
|
|
1602
|
+
// Even a keystroke on a focused select fires change. This changes the
|
|
1603
|
+
// gate set for every future task, so it is always confirmed.
|
|
1604
|
+
sel.value=DATA.run_mode;
|
|
1605
|
+
confirmThen("Switch to "+wanted+"?",blurbFor,"Yes, use "+wanted,apply);
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
if(wanted==="auto-submit"){
|
|
1609
|
+
sel.value=DATA.run_mode;
|
|
1610
|
+
confirmThen("Turn on auto-submit?",
|
|
1611
|
+
"The agent will pick, build AND send proofs with no human review. A rejection is scored on "
|
|
1612
|
+
+"your wallet and shown to buyers as a trust score. Revert to auto-batch after any rejection.",
|
|
1613
|
+
"Yes, submit unattended",apply);
|
|
1614
|
+
}
|
|
1615
|
+
});
|
|
1616
|
+
}
|
|
1617
|
+
sel.value=DATA.run_mode;
|
|
1618
|
+
var mine=DATA.modes.filter(function(m){return m.mode===DATA.run_mode;})[0]||{};
|
|
1619
|
+
sel.title=mine.blurb||"";
|
|
1620
|
+
document.getElementById("modehint").textContent=DATA.run_mode_is_set
|
|
1621
|
+
? "Stops at: "+((mine.gates||[]).join(" · ")||"nothing")
|
|
1622
|
+
: "Not chosen yet — this is the assumed default. Pick one before the first task.";
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
function paint(){
|
|
1626
|
+
if(!DATA) return;
|
|
1627
|
+
paintRail();
|
|
1628
|
+
paintStage();
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
function poll(){
|
|
1632
|
+
return fetch("/api/state?t="+TOKEN,{headers:{"x-vidfarm-panel":TOKEN}})
|
|
1633
|
+
.then(function(r){
|
|
1634
|
+
if(!r.ok) throw new Error("state "+r.status);
|
|
1635
|
+
return r.json();
|
|
1636
|
+
})
|
|
1637
|
+
.then(function(d){
|
|
1638
|
+
document.getElementById("live").className="live";
|
|
1639
|
+
if(pollFails){ pollFails=0; showError(""); }
|
|
1640
|
+
DATA=d;
|
|
1641
|
+
if(!cursorId||!d.tasks.some(function(t){return t.task_id===cursorId&&t.gate;})){
|
|
1642
|
+
var q=queue();
|
|
1643
|
+
cursorId=q.length?q[0].task_id:null;
|
|
1644
|
+
}
|
|
1645
|
+
paint();
|
|
1646
|
+
})
|
|
1647
|
+
.catch(function(e){
|
|
1648
|
+
document.getElementById("live").className="live stale";
|
|
1649
|
+
pollFails+=1;
|
|
1650
|
+
// Two misses is a real problem, not a blip — say so rather than leaving a
|
|
1651
|
+
// dark page and a small red dot as the only signal.
|
|
1652
|
+
if(pollFails>=2){
|
|
1653
|
+
showError("Lost contact with the panel server ("+(e&&e.message||"no response")
|
|
1654
|
+
+"). Check the terminal that started it.");
|
|
1655
|
+
}
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
poll();
|
|
1660
|
+
setInterval(poll,2000);
|
|
1661
|
+
</script>
|
|
1662
|
+
</body>
|
|
1663
|
+
</html>
|
|
1664
|
+
`;
|
|
1665
|
+
function sendJson(res, status, payload) {
|
|
1666
|
+
res.statusCode = status;
|
|
1667
|
+
res.setHeader("content-type", "application/json; charset=utf-8");
|
|
1668
|
+
res.setHeader("cache-control", "no-store");
|
|
1669
|
+
res.end(JSON.stringify(payload));
|
|
1670
|
+
}
|
|
1671
|
+
function readBody(req) {
|
|
1672
|
+
return new Promise((resolve, reject) => {
|
|
1673
|
+
const chunks = [];
|
|
1674
|
+
let size = 0;
|
|
1675
|
+
let oversized = false;
|
|
1676
|
+
req.on("data", (chunk) => {
|
|
1677
|
+
size += chunk.length;
|
|
1678
|
+
// A control-panel action is a few hundred bytes; refuse anything absurd.
|
|
1679
|
+
if (size > 256_000) {
|
|
1680
|
+
oversized = true;
|
|
1681
|
+
reject(new Error("Request body too large."));
|
|
1682
|
+
return;
|
|
1683
|
+
}
|
|
1684
|
+
chunks.push(chunk);
|
|
1685
|
+
});
|
|
1686
|
+
req.on("end", () => { if (!oversized)
|
|
1687
|
+
resolve(Buffer.concat(chunks).toString("utf8")); });
|
|
1688
|
+
req.on("error", reject);
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
export async function handlePanelRequest(req, res, opts) {
|
|
1692
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
1693
|
+
const pathname = url.pathname.replace(/\/+$/, "") || "/";
|
|
1694
|
+
if (pathname === "/favicon.ico") {
|
|
1695
|
+
// Asked for by the browser itself, with no token. A 403 here is a red
|
|
1696
|
+
// console error on a page that is working fine.
|
|
1697
|
+
res.statusCode = 204;
|
|
1698
|
+
res.end();
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
// Rule 1b: addressed to loopback by name as well as by socket.
|
|
1702
|
+
if (!hostAllowed(req, opts.port)) {
|
|
1703
|
+
res.statusCode = 421;
|
|
1704
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
1705
|
+
res.end("Misdirected request — the panel only answers on 127.0.0.1/localhost.\n");
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
// Rule 2: the token gates everything, including the page itself. No CORS
|
|
1709
|
+
// header is ever sent, so another origin cannot read a response to steal it.
|
|
1710
|
+
if (!tokenMatches(presentedToken(req, url), opts.token)) {
|
|
1711
|
+
res.statusCode = 403;
|
|
1712
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
1713
|
+
res.end("Forbidden — open the URL printed by `vidfarm panel` (it carries the session token).\n");
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
if (pathname === "/" && req.method === "GET") {
|
|
1717
|
+
res.statusCode = 200;
|
|
1718
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
1719
|
+
res.setHeader("cache-control", "no-store");
|
|
1720
|
+
// Deny framing outright: nothing here should ever render inside another page.
|
|
1721
|
+
res.setHeader("x-frame-options", "DENY");
|
|
1722
|
+
res.setHeader("referrer-policy", "no-referrer");
|
|
1723
|
+
res.end(PAGE_HTML.split("__TOKEN__").join(opts.token));
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
if (pathname === "/api/state" && req.method === "GET") {
|
|
1727
|
+
try {
|
|
1728
|
+
sendJson(res, 200, buildState(opts.root));
|
|
1729
|
+
}
|
|
1730
|
+
catch (error) {
|
|
1731
|
+
sendJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
1732
|
+
}
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
if (pathname === "/api/action" && req.method === "POST") {
|
|
1736
|
+
// Rule 3: a write must originate from this page.
|
|
1737
|
+
if (!originAllowed(req, opts.port)) {
|
|
1738
|
+
sendJson(res, 403, { ok: false, error: "Cross-origin write refused." });
|
|
1739
|
+
return;
|
|
1740
|
+
}
|
|
1741
|
+
let body = {};
|
|
1742
|
+
try {
|
|
1743
|
+
const parsedBody = JSON.parse((await readBody(req)) || "{}");
|
|
1744
|
+
body = (parsedBody && typeof parsedBody === "object") ? parsedBody : {};
|
|
1745
|
+
}
|
|
1746
|
+
catch (error) {
|
|
1747
|
+
const tooBig = error instanceof Error && /too large/i.test(error.message);
|
|
1748
|
+
sendJson(res, tooBig ? 413 : 400, {
|
|
1749
|
+
ok: false,
|
|
1750
|
+
error: tooBig ? "That request body is too large for the panel (256 KB max)." : "Body was not JSON."
|
|
1751
|
+
});
|
|
1752
|
+
return;
|
|
1753
|
+
}
|
|
1754
|
+
try {
|
|
1755
|
+
sendJson(res, 200, runAction(opts.root, body));
|
|
1756
|
+
}
|
|
1757
|
+
catch (error) {
|
|
1758
|
+
sendJson(res, 400, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
1759
|
+
}
|
|
1760
|
+
return;
|
|
1761
|
+
}
|
|
1762
|
+
const templateLookup = pathname.match(/^\/api\/template\/([^/]+)$/);
|
|
1763
|
+
if (templateLookup && req.method === "GET") {
|
|
1764
|
+
const card = await lookupTemplate(decodeURIComponent(templateLookup[1]));
|
|
1765
|
+
sendJson(res, 200, { ok: true, card: card });
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
const media = pathname.match(/^\/media\/([^/]+)\/(cut|clean)$/);
|
|
1769
|
+
if (media && (req.method === "GET" || req.method === "HEAD")) {
|
|
1770
|
+
try {
|
|
1771
|
+
const state = readTaskState(opts.root, decodeURIComponent(media[1]));
|
|
1772
|
+
const file = media[2] === "clean" ? state.clean_master : state.cut_path;
|
|
1773
|
+
// `fileExists` now means "watchable", so a 0-byte render would 404 as if
|
|
1774
|
+
// nothing had been recorded. Tell the truth: it is there, and it is empty.
|
|
1775
|
+
if (file && existsSync(file) && !fileExists(file)) {
|
|
1776
|
+
streamVideo(req, res, file);
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
if (!file || !fileExists(file)) {
|
|
1780
|
+
res.statusCode = 404;
|
|
1781
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
1782
|
+
res.end("No such recording on disk.\n");
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
streamVideo(req, res, file);
|
|
1786
|
+
}
|
|
1787
|
+
catch (error) {
|
|
1788
|
+
res.statusCode = 404;
|
|
1789
|
+
res.end(error instanceof Error ? error.message : "Not found");
|
|
1790
|
+
}
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
res.statusCode = 404;
|
|
1794
|
+
res.end("Not found");
|
|
1795
|
+
}
|
|
1796
|
+
export function startClipperPanel(opts) {
|
|
1797
|
+
const server = createServer((req, res) => {
|
|
1798
|
+
void handlePanelRequest(req, res, opts).catch((error) => {
|
|
1799
|
+
if (!res.headersSent) {
|
|
1800
|
+
res.statusCode = 500;
|
|
1801
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
1802
|
+
}
|
|
1803
|
+
res.end(`Panel error: ${error instanceof Error ? error.message : String(error)}`);
|
|
1804
|
+
});
|
|
1805
|
+
});
|
|
1806
|
+
return new Promise((resolve, reject) => {
|
|
1807
|
+
server.once("error", (error) => {
|
|
1808
|
+
reject(error.code === "EADDRINUSE"
|
|
1809
|
+
? new Error(`Port ${opts.port} is already in use. Pass --port <n>.`)
|
|
1810
|
+
: error);
|
|
1811
|
+
});
|
|
1812
|
+
// Rule 1: loopback only. This page approves work that gets submitted for
|
|
1813
|
+
// money — it must never be reachable from the network.
|
|
1814
|
+
server.listen(opts.port, "127.0.0.1", () => {
|
|
1815
|
+
resolve({
|
|
1816
|
+
port: opts.port,
|
|
1817
|
+
url: `http://127.0.0.1:${opts.port}/?t=${opts.token}`,
|
|
1818
|
+
// A caller that starts the panel inside a longer-lived process (a test,
|
|
1819
|
+
// a future `serve` mount) needs a way to give the port back.
|
|
1820
|
+
close: () => new Promise((done) => {
|
|
1821
|
+
server.closeAllConnections?.();
|
|
1822
|
+
server.close(() => done());
|
|
1823
|
+
})
|
|
1824
|
+
});
|
|
1825
|
+
});
|
|
1826
|
+
});
|
|
1827
|
+
}
|
|
1828
|
+
//# sourceMappingURL=clipper-panel.js.map
|