animastor-worker 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +34 -0
- package/job-protocol-v2.cjs +291 -0
- package/package.json +36 -0
- package/worker-cleanup-journal.cjs +239 -0
- package/worker-cleanup.cjs +60 -0
- package/worker-env.cjs +50 -0
- package/worker.cjs +750 -0
package/worker.cjs
ADDED
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
// ======================================================
|
|
2
|
+
// GPU Worker - v2.1.0 (fail-closed authorization, PW-4)
|
|
3
|
+
// ======================================================
|
|
4
|
+
// CJS (CommonJS) — Node 18+ with global fetch is assumed.
|
|
5
|
+
// Job Protocol v2 comes from the GENERATED copy of the canonical
|
|
6
|
+
// @animastor/contracts package (Phase 9D, blocker B2 — option B):
|
|
7
|
+
// worker/worker/job-protocol-v2.cjs (regenerate: node worker/tools/sync-protocol.cjs)
|
|
8
|
+
// It is byte-parity guarded against the canonical source — never edit it.
|
|
9
|
+
|
|
10
|
+
const { execSync } = require("child_process");
|
|
11
|
+
const os = require("os");
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const fsp = require("fs").promises;
|
|
14
|
+
const path = require("path");
|
|
15
|
+
const { cleanupJobArtifacts } = require("./worker-cleanup.cjs");
|
|
16
|
+
const journal = require("./worker-cleanup-journal.cjs");
|
|
17
|
+
const { loadDotEnv } = require("./worker-env.cjs");
|
|
18
|
+
const { PROTOCOL_VERSION, JOB_ID_SPLIT_RE } = require("./job-protocol-v2.cjs");
|
|
19
|
+
|
|
20
|
+
// Bundle self-containment: load ./.env next to the worker (cp .env.example
|
|
21
|
+
// .env). Real environment variables always win over the file.
|
|
22
|
+
loadDotEnv(__dirname);
|
|
23
|
+
|
|
24
|
+
// ======================================================
|
|
25
|
+
// CONFIG
|
|
26
|
+
// ======================================================
|
|
27
|
+
|
|
28
|
+
const HUB_URL = process.env.HUB_URL || "https://animastor.in/gpu";
|
|
29
|
+
const COMFY_PORT = process.env.COMFY_PORT || 8188;
|
|
30
|
+
const WORKER_TYPE = process.env.WORKER_TYPE || "image";
|
|
31
|
+
|
|
32
|
+
// PW-4 (FAIL CLOSED): the worker credential (`wrk.<worker_id>.<secret>`,
|
|
33
|
+
// issued once at registration in Animastor) is REQUIRED. Every hub call
|
|
34
|
+
// carries `Authorization: Bearer <token>` and the hub derives identity,
|
|
35
|
+
// workspace and MODE from the registry — the worker never chooses its own
|
|
36
|
+
// mode. No credential → the worker refuses to start: a missing credential
|
|
37
|
+
// must never silently become a system/share worker.
|
|
38
|
+
const ANIMASTOR_WORKER_TOKEN = process.env.ANIMASTOR_WORKER_TOKEN || null;
|
|
39
|
+
|
|
40
|
+
// Backend API base for the startup credential verification. Derived from
|
|
41
|
+
// HUB_URL by default (…/gpu → …/api/v1); override with ANIMASTOR_API_URL.
|
|
42
|
+
const ANIMASTOR_API_URL = process.env.ANIMASTOR_API_URL
|
|
43
|
+
|| HUB_URL.replace(/\/gpu\/?$/, "") + "/api/v1";
|
|
44
|
+
|
|
45
|
+
const NOTEBOOK_PATH = process.env.NOTEBOOK_PATH || "";
|
|
46
|
+
const WORKER_ID = process.env.WORKER_ID || "gpu-" + os.hostname();
|
|
47
|
+
// Canonical bundle version lives in ./package.json (the hub publishes the
|
|
48
|
+
// same value with the worker-bundle artifact). WORKER_VERSION env overrides.
|
|
49
|
+
function readBundleVersion() {
|
|
50
|
+
try { return require("./package.json").version || null; } catch (_) { return null; }
|
|
51
|
+
}
|
|
52
|
+
const WORKER_VERSION = process.env.WORKER_VERSION || readBundleVersion();
|
|
53
|
+
const WORKER_IMAGE_TAG = process.env.WORKER_IMAGE_TAG || null;
|
|
54
|
+
// PROTOCOL_VERSION comes from the generated copy of @animastor/contracts
|
|
55
|
+
// (top of file) — the frozen Job Protocol v2 value (2) lives there only.
|
|
56
|
+
|
|
57
|
+
const RESULT_TIMEOUT_MS = Number(process.env.RESULT_TIMEOUT_MS || 600000);
|
|
58
|
+
// Видео-генерация длинная по своей природе (LTX: 5-10 мин, на слабом GPU —
|
|
59
|
+
// 20-30+ мин). Дефолт для видео НЕ может быть 10 мин — иначе нормальная
|
|
60
|
+
// долгая генерация убивается как timeout. Приоритет: task.timeout_ms
|
|
61
|
+
// (приходит от backend через gpu-hub, layer-config per-type timeout);
|
|
62
|
+
// fallback для видео — 2 часа (реальный потолок — dispatch-lease backend'а).
|
|
63
|
+
const VIDEO_RESULT_TIMEOUT_MS = Number(process.env.VIDEO_RESULT_TIMEOUT_MS || 7200000);
|
|
64
|
+
const TASK_SLEEP_MS = Number(process.env.TASK_SLEEP_MS || 2000);
|
|
65
|
+
const BEACON_INTERVAL_MS = Number(process.env.BEACON_INTERVAL_MS || 10000);
|
|
66
|
+
|
|
67
|
+
const COMFY_INPUT_DIR = process.env.COMFY_INPUT_DIR || "/home/jovyan/ComfyUI/input";
|
|
68
|
+
const COMFY_OUTPUT_DIR = path.resolve(COMFY_INPUT_DIR, "../output");
|
|
69
|
+
|
|
70
|
+
// ======================================================
|
|
71
|
+
// UTILS
|
|
72
|
+
// ======================================================
|
|
73
|
+
|
|
74
|
+
function log(level, msg, data) {
|
|
75
|
+
console.log(`[${new Date().toISOString()}] [${level}] ${msg}`);
|
|
76
|
+
if (data !== undefined) console.log(typeof data === 'string' ? data : JSON.stringify(data, null, 2));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function sleep(ms) {
|
|
80
|
+
return new Promise(r => setTimeout(r, ms));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function fetchTimeout(url, options = {}, timeout = 30000) {
|
|
84
|
+
const controller = new AbortController();
|
|
85
|
+
const id = setTimeout(() => controller.abort(), timeout);
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
return await fetch(url, { ...options, signal: controller.signal });
|
|
89
|
+
} finally {
|
|
90
|
+
clearTimeout(id);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function comfyUrl(p) {
|
|
95
|
+
return `http://127.0.0.1:${COMFY_PORT}${NOTEBOOK_PATH}${p}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// PW-4: hub request headers — always Bearer-authenticated (the startup gate
|
|
99
|
+
// guarantees a credential is present; defense-in-depth keeps it unconditional).
|
|
100
|
+
function hubHeaders() {
|
|
101
|
+
const headers = { "Content-Type": "application/json" };
|
|
102
|
+
if (ANIMASTOR_WORKER_TOKEN) headers["Authorization"] = `Bearer ${ANIMASTOR_WORKER_TOKEN}`;
|
|
103
|
+
return headers;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// FAIL CLOSED: an auth rejection from the hub/backend is terminal. Retrying
|
|
107
|
+
// an invalid credential forever would only hide the misconfiguration — the
|
|
108
|
+
// operator must fix ANIMASTOR_WORKER_TOKEN.
|
|
109
|
+
function authFailed(source, status) {
|
|
110
|
+
log("error", "Worker authentication failed — check ANIMASTOR_WORKER_TOKEN");
|
|
111
|
+
log("error", `${source} rejected the credential (HTTP ${status}). The token may be wrong, rotated or revoked.`);
|
|
112
|
+
log("error", "Create/rotate a worker in Animastor (Settings → Workers) and set the new token.");
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ======================================================
|
|
117
|
+
// STARTUP CREDENTIAL VERIFICATION
|
|
118
|
+
// ======================================================
|
|
119
|
+
// The registry is the source of truth: the worker learns its identity and
|
|
120
|
+
// mode from the backend (POST /api/v1/worker/verify). It never decides for
|
|
121
|
+
// itself whether it is private/share/system — it only confirms.
|
|
122
|
+
|
|
123
|
+
async function verifyCredential() {
|
|
124
|
+
let res;
|
|
125
|
+
try {
|
|
126
|
+
res = await fetchTimeout(`${ANIMASTOR_API_URL}/worker/verify`, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: hubHeaders(),
|
|
129
|
+
body: JSON.stringify({})
|
|
130
|
+
});
|
|
131
|
+
} catch (err) {
|
|
132
|
+
// Network failure — the backend may be temporarily down. The hub still
|
|
133
|
+
// enforces the credential on every call, so warn and continue.
|
|
134
|
+
log("warn", `Credential verification unavailable (${err.message}) — continuing, the hub will enforce auth`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (res.status === 401 || res.status === 403) {
|
|
138
|
+
authFailed("Animastor backend", res.status);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (!res.ok) {
|
|
142
|
+
log("warn", `Credential verification returned HTTP ${res.status} — continuing, the hub will enforce auth`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const data = await res.json();
|
|
147
|
+
log("info", "✓ Credential accepted");
|
|
148
|
+
if (data.workspace_name) log("info", `✓ Workspace: ${data.workspace_name}`);
|
|
149
|
+
log("info", `✓ Mode: ${String(data.mode || "").toUpperCase()}`);
|
|
150
|
+
log("info", `✓ Worker type (registry): ${data.worker_type}`);
|
|
151
|
+
if (data.worker_type && data.worker_type !== WORKER_TYPE) {
|
|
152
|
+
log("warn", `WORKER_TYPE=${WORKER_TYPE} differs from the registry type ${data.worker_type} — the registry wins at the hub`);
|
|
153
|
+
}
|
|
154
|
+
} catch (_) { /* cosmetic only */ }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ======================================================
|
|
158
|
+
// FIND OUTPUT NODES (Save*)
|
|
159
|
+
// ======================================================
|
|
160
|
+
|
|
161
|
+
function findOutputNodes(workflow) {
|
|
162
|
+
const result = { image: [], audio: [], video: [] };
|
|
163
|
+
|
|
164
|
+
for (const [id, node] of Object.entries(workflow || {})) {
|
|
165
|
+
const type = node.class_type || "";
|
|
166
|
+
|
|
167
|
+
if (type.startsWith("SaveImage")) result.image.push(id);
|
|
168
|
+
if (type.startsWith("SaveAudio")) result.audio.push(id);
|
|
169
|
+
if (type.startsWith("SaveVideo") || type.startsWith("CreateVideo")) result.video.push(id);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ======================================================
|
|
176
|
+
// GPU INFO
|
|
177
|
+
// ======================================================
|
|
178
|
+
|
|
179
|
+
function getGPUInfo() {
|
|
180
|
+
try {
|
|
181
|
+
const gpu = execSync(
|
|
182
|
+
"nvidia-smi --query-gpu=name,memory.total --format=csv,noheader"
|
|
183
|
+
).toString().trim();
|
|
184
|
+
|
|
185
|
+
const [name, vram] = gpu.split(",");
|
|
186
|
+
return { name: name.trim(), vram: vram.trim() };
|
|
187
|
+
} catch (err) {
|
|
188
|
+
log("error", "nvidia-smi failed", err.message);
|
|
189
|
+
return { name: "unknown", vram: "unknown" };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ======================================================
|
|
194
|
+
// WAIT COMFY with exponential backoff
|
|
195
|
+
// ======================================================
|
|
196
|
+
|
|
197
|
+
async function waitForComfyUI() {
|
|
198
|
+
log("info", "Waiting for ComfyUI");
|
|
199
|
+
let attempts = 0;
|
|
200
|
+
|
|
201
|
+
while (true) {
|
|
202
|
+
try {
|
|
203
|
+
const res = await fetchTimeout(comfyUrl("/system_stats"));
|
|
204
|
+
if (res.ok) {
|
|
205
|
+
log("info", "ComfyUI ready");
|
|
206
|
+
await sleep(3000);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
} catch (err) {
|
|
210
|
+
log("warn", `ComfyUI not ready (attempt ${attempts + 1}): ${err.message}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
attempts++;
|
|
214
|
+
const backoff = Math.min(1000 * (1 << Math.min(attempts, 5)), 30000);
|
|
215
|
+
process.stdout.write(".");
|
|
216
|
+
await sleep(backoff);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ======================================================
|
|
221
|
+
// BEACON
|
|
222
|
+
// ======================================================
|
|
223
|
+
|
|
224
|
+
async function sendBeacon() {
|
|
225
|
+
try {
|
|
226
|
+
const gpu = getGPUInfo();
|
|
227
|
+
|
|
228
|
+
const res = await fetchTimeout(`${HUB_URL}/beacon`, {
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: hubHeaders(),
|
|
231
|
+
body: JSON.stringify({
|
|
232
|
+
id: WORKER_ID,
|
|
233
|
+
type: WORKER_TYPE,
|
|
234
|
+
gpu: gpu.name,
|
|
235
|
+
vram: gpu.vram,
|
|
236
|
+
version: WORKER_VERSION,
|
|
237
|
+
image_tag: WORKER_IMAGE_TAG,
|
|
238
|
+
protocol_version: PROTOCOL_VERSION
|
|
239
|
+
})
|
|
240
|
+
});
|
|
241
|
+
if (res.status === 401 || res.status === 403) {
|
|
242
|
+
authFailed("GPU hub /beacon", res.status);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (!res.ok) {
|
|
246
|
+
throw new Error(`Hub rejected beacon: HTTP ${res.status}`);
|
|
247
|
+
}
|
|
248
|
+
} catch (err) {
|
|
249
|
+
log("error", "Beacon failed", err.message);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ======================================================
|
|
254
|
+
// GET TASK with backoff
|
|
255
|
+
// ======================================================
|
|
256
|
+
|
|
257
|
+
async function getTask() {
|
|
258
|
+
try {
|
|
259
|
+
const res = await fetchTimeout(
|
|
260
|
+
`${HUB_URL}/task/next?worker=${WORKER_ID}&type=${WORKER_TYPE}`,
|
|
261
|
+
{ headers: hubHeaders() }
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
if (res.status === 401 || res.status === 403) {
|
|
265
|
+
authFailed("GPU hub /task/next", res.status);
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
if (!res.ok) return null;
|
|
269
|
+
|
|
270
|
+
const data = await res.json();
|
|
271
|
+
return data?.task || null;
|
|
272
|
+
|
|
273
|
+
} catch (err) {
|
|
274
|
+
log("warn", "getTask failed", err.message);
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ======================================================
|
|
280
|
+
// SAVE IMAGE (async)
|
|
281
|
+
// ======================================================
|
|
282
|
+
|
|
283
|
+
async function saveBase64ImageSafe(base64, filename) {
|
|
284
|
+
const clean = base64.includes(",") ? base64.split(",")[1] : base64;
|
|
285
|
+
const buffer = Buffer.from(clean, "base64");
|
|
286
|
+
|
|
287
|
+
await fsp.mkdir(COMFY_INPUT_DIR, { recursive: true }).catch(err => log("warn", "mkdir", err.message));
|
|
288
|
+
|
|
289
|
+
const filePath = path.join(COMFY_INPUT_DIR, filename);
|
|
290
|
+
await fsp.writeFile(filePath, buffer);
|
|
291
|
+
|
|
292
|
+
return { path: filePath, expectedSize: buffer.length };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ======================================================
|
|
296
|
+
// WAIT FILE READY
|
|
297
|
+
// ======================================================
|
|
298
|
+
|
|
299
|
+
async function waitForFileReady(filePath, expectedSize, timeout = 5000) {
|
|
300
|
+
const start = Date.now();
|
|
301
|
+
|
|
302
|
+
while (true) {
|
|
303
|
+
if (Date.now() - start > timeout) {
|
|
304
|
+
throw new Error(`File not ready: ${filePath}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
try {
|
|
308
|
+
await fsp.access(filePath);
|
|
309
|
+
const stats = await fsp.stat(filePath);
|
|
310
|
+
if (stats.size === expectedSize && stats.size > 0) {
|
|
311
|
+
await sleep(50);
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
} catch (err) {
|
|
315
|
+
log("warn", `waitForFileReady error`, err.message);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
await sleep(100);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ======================================================
|
|
323
|
+
// RUN WORKFLOW
|
|
324
|
+
// ======================================================
|
|
325
|
+
|
|
326
|
+
async function runWorkflow(workflow) {
|
|
327
|
+
const body = workflow?.prompt
|
|
328
|
+
? { ...workflow, client_id: WORKER_ID }
|
|
329
|
+
: { prompt: workflow, client_id: WORKER_ID };
|
|
330
|
+
|
|
331
|
+
const res = await fetchTimeout(
|
|
332
|
+
comfyUrl("/prompt"),
|
|
333
|
+
{
|
|
334
|
+
method: "POST",
|
|
335
|
+
headers: { "Content-Type": "application/json" },
|
|
336
|
+
body: JSON.stringify(body)
|
|
337
|
+
}
|
|
338
|
+
);
|
|
339
|
+
|
|
340
|
+
const text = await res.text();
|
|
341
|
+
|
|
342
|
+
let data;
|
|
343
|
+
try {
|
|
344
|
+
data = JSON.parse(text);
|
|
345
|
+
} catch {
|
|
346
|
+
throw new Error("Invalid JSON from ComfyUI: " + text.slice(0, 500));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!data.prompt_id) {
|
|
350
|
+
log("error", "ComfyUI error", data);
|
|
351
|
+
throw new Error("No prompt_id");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return data.prompt_id;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ======================================================
|
|
358
|
+
// WAIT RESULT (with backoff)
|
|
359
|
+
// ======================================================
|
|
360
|
+
|
|
361
|
+
async function waitResult(prompt_id, workflow, timeoutMs) {
|
|
362
|
+
const start = Date.now();
|
|
363
|
+
const outputsMap = findOutputNodes(workflow);
|
|
364
|
+
const isVideoJob = outputsMap.video.length > 0;
|
|
365
|
+
const effectiveTimeoutMs = timeoutMs || (isVideoJob ? VIDEO_RESULT_TIMEOUT_MS : RESULT_TIMEOUT_MS);
|
|
366
|
+
log("debug", `waitResult: ${isVideoJob ? 'video' : 'other'} prompt=${prompt_id} timeoutMs=${effectiveTimeoutMs} (task.timeout_ms=${timeoutMs || 'none'})`);
|
|
367
|
+
|
|
368
|
+
let videoDir, beforeFiles, videoPrefix;
|
|
369
|
+
if (isVideoJob) {
|
|
370
|
+
videoDir = path.join(COMFY_OUTPUT_DIR, 'video');
|
|
371
|
+
try {
|
|
372
|
+
beforeFiles = new Set((await fsp.readdir(videoDir)).filter(f => f.endsWith('.mp4')));
|
|
373
|
+
} catch (err) {
|
|
374
|
+
log("warn", "waitResult readdir", err && err.message || err);
|
|
375
|
+
beforeFiles = new Set();
|
|
376
|
+
}
|
|
377
|
+
for (const id of outputsMap.video) {
|
|
378
|
+
const prefix = workflow?.[id]?.inputs?.filename_prefix;
|
|
379
|
+
if (prefix) {
|
|
380
|
+
videoPrefix = path.basename(prefix);
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let pollDelay = 500;
|
|
387
|
+
while (true) {
|
|
388
|
+
if (Date.now() - start > effectiveTimeoutMs) {
|
|
389
|
+
try {
|
|
390
|
+
const res = await fetchTimeout(comfyUrl(`/history/${prompt_id}`));
|
|
391
|
+
const d = await res.json();
|
|
392
|
+
log("error", `Timeout after ${Math.round(effectiveTimeoutMs / 60000)}min: last history response`, JSON.stringify(d).slice(0, 2000));
|
|
393
|
+
} catch (err) {
|
|
394
|
+
log("error", "Timeout: failed to fetch history", err.message);
|
|
395
|
+
}
|
|
396
|
+
throw new Error(`Timeout waiting result (${Math.round(effectiveTimeoutMs / 60000)}min)`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
const res = await fetchTimeout(comfyUrl(`/history/${prompt_id}`));
|
|
401
|
+
const data = await res.json();
|
|
402
|
+
const outputs = data?.[prompt_id]?.outputs || {};
|
|
403
|
+
|
|
404
|
+
for (const id of outputsMap.image) {
|
|
405
|
+
const node = outputs[id];
|
|
406
|
+
if (node?.images?.length > 0) return { type: "image", meta: node.images[0] };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
for (const id of outputsMap.audio) {
|
|
410
|
+
const node = outputs[id];
|
|
411
|
+
if (node?.audio) {
|
|
412
|
+
const a = Array.isArray(node.audio) ? node.audio[0] : node.audio;
|
|
413
|
+
if (a?.filename) return { type: "audio", meta: a };
|
|
414
|
+
if (a?.data || typeof a === "string") return { type: "audio_base64", data: a.data || a };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (isVideoJob) {
|
|
419
|
+
for (const id of outputsMap.video) {
|
|
420
|
+
const node = outputs[id];
|
|
421
|
+
for (const key of ['videos', 'video', 'gifs', 'result', 'files', 'media']) {
|
|
422
|
+
const arr = node?.[key];
|
|
423
|
+
if (Array.isArray(arr) && arr[0]?.filename) {
|
|
424
|
+
return { type: "video", meta: arr[0] };
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
for (const node of Object.values(outputs)) {
|
|
430
|
+
for (const arr of Object.values(node || {})) {
|
|
431
|
+
if (Array.isArray(arr) && arr[0]?.filename?.endsWith('.mp4')) {
|
|
432
|
+
return { type: "video", meta: arr[0] };
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (data?.[prompt_id]?.status?.completed && videoDir) {
|
|
438
|
+
const allFiles = await fsp.readdir(videoDir).catch(() => []);
|
|
439
|
+
const mp4Files = allFiles.filter(f => f.endsWith('.mp4'));
|
|
440
|
+
const newFiles = mp4Files.filter(f => !beforeFiles.has(f));
|
|
441
|
+
const matched = videoPrefix
|
|
442
|
+
? newFiles.filter(f => f.startsWith(videoPrefix))
|
|
443
|
+
: newFiles;
|
|
444
|
+
if (matched.length > 0) {
|
|
445
|
+
const newest = matched.sort().pop();
|
|
446
|
+
log("info", `FS video: ${newest}`);
|
|
447
|
+
return { type: "video", meta: { filename: newest, subfolder: 'video', type: 'output' } };
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Reset poll delay on successful response
|
|
453
|
+
pollDelay = 500;
|
|
454
|
+
} catch (err) {
|
|
455
|
+
log("warn", "waitResult poll failed", err.message);
|
|
456
|
+
// Exponential backoff on error: 1s → 2s → 4s → 8s cap
|
|
457
|
+
pollDelay = Math.min(pollDelay * 2, 8000);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
await sleep(pollDelay);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ======================================================
|
|
465
|
+
// DOWNLOAD RESULT (OOM-safe: читаем с диска, не через HTTP re-download)
|
|
466
|
+
// ======================================================
|
|
467
|
+
// ComfyUI уже сохранил результат на диск в COMFY_OUTPUT_DIR.
|
|
468
|
+
// Вместо повторного HTTP download (который держит 2x файл в памяти:
|
|
469
|
+
// arrayBuffer + base64), читаем локально.
|
|
470
|
+
// Для файлов > 50MB логируем предупреждение — они всё равно будут
|
|
471
|
+
// загружены в память как base64 (protocol limitation).
|
|
472
|
+
|
|
473
|
+
const MIME_MAP = {
|
|
474
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp',
|
|
475
|
+
'.mp3': 'audio/mp3', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.flac': 'audio/flac',
|
|
476
|
+
'.mp4': 'video/mp4', '.webm': 'video/webm', '.avi': 'video/avi', '.mov': 'video/quicktime',
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
async function downloadResult(result) {
|
|
480
|
+
if (result.type === "audio_base64") {
|
|
481
|
+
return `data:audio/mp3;base64,${result.data}`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const f = result.meta;
|
|
485
|
+
const filename = f.filename;
|
|
486
|
+
const subfolder = f.subfolder || "";
|
|
487
|
+
const ext = path.extname(filename).toLowerCase();
|
|
488
|
+
const mime = MIME_MAP[ext] || 'application/octet-stream';
|
|
489
|
+
|
|
490
|
+
// ── Try local filesystem first (OOM-safe, no HTTP overhead) ──
|
|
491
|
+
const localPath = path.resolve(COMFY_OUTPUT_DIR, subfolder, filename);
|
|
492
|
+
try {
|
|
493
|
+
const stat = await fsp.stat(localPath).catch(() => null);
|
|
494
|
+
if (stat && stat.isFile() && stat.size > 0) {
|
|
495
|
+
if (stat.size > 50 * 1024 * 1024) {
|
|
496
|
+
log("warn", `Large file (${(stat.size / 1024 / 1024).toFixed(1)}MB) — base64 will use significant memory: ${filename}`);
|
|
497
|
+
}
|
|
498
|
+
const buffer = await fsp.readFile(localPath);
|
|
499
|
+
return `data:${mime};base64,${buffer.toString('base64')}`;
|
|
500
|
+
}
|
|
501
|
+
} catch (_) {}
|
|
502
|
+
|
|
503
|
+
// ── Fallback: HTTP download from ComfyUI ──
|
|
504
|
+
log("info", `Falling back to HTTP download for ${filename}`);
|
|
505
|
+
let sf = subfolder;
|
|
506
|
+
if (sf.includes("/")) sf = sf.split("/")[0];
|
|
507
|
+
|
|
508
|
+
const url = comfyUrl(
|
|
509
|
+
`/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(sf)}&type=output`
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
const res = await fetchTimeout(url);
|
|
513
|
+
if (!res.ok) throw new Error("Download failed: " + res.status);
|
|
514
|
+
|
|
515
|
+
const buffer = await res.arrayBuffer();
|
|
516
|
+
const raw = Buffer.from(buffer).toString("base64");
|
|
517
|
+
|
|
518
|
+
return `data:${mime};base64,${raw}`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ======================================================
|
|
522
|
+
// SEND RESULT
|
|
523
|
+
// ======================================================
|
|
524
|
+
|
|
525
|
+
async function sendResult(task, data) {
|
|
526
|
+
const { job_id, build_id, dispatch_id } = task;
|
|
527
|
+
const res = await fetchTimeout(`${HUB_URL}/task/result`, {
|
|
528
|
+
method: "POST",
|
|
529
|
+
headers: hubHeaders(),
|
|
530
|
+
body: JSON.stringify({
|
|
531
|
+
job_id,
|
|
532
|
+
build_id,
|
|
533
|
+
dispatch_id,
|
|
534
|
+
protocol_version: PROTOCOL_VERSION,
|
|
535
|
+
result_base64: data,
|
|
536
|
+
worker_version: WORKER_VERSION,
|
|
537
|
+
worker_image_tag: WORKER_IMAGE_TAG
|
|
538
|
+
})
|
|
539
|
+
});
|
|
540
|
+
if (!res.ok) {
|
|
541
|
+
throw new Error(`Hub rejected result: HTTP ${res.status}`);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
async function sendTaskError(task, reason) {
|
|
546
|
+
const res = await fetchTimeout(`${HUB_URL}/task/error`, {
|
|
547
|
+
method: "POST",
|
|
548
|
+
headers: hubHeaders(),
|
|
549
|
+
body: JSON.stringify({
|
|
550
|
+
job_id: task.job_id,
|
|
551
|
+
build_id: task.build_id || null,
|
|
552
|
+
dispatch_id: task.dispatch_id,
|
|
553
|
+
protocol_version: PROTOCOL_VERSION,
|
|
554
|
+
reason: String(reason || "worker_error").slice(0, 500),
|
|
555
|
+
worker_version: WORKER_VERSION,
|
|
556
|
+
worker_image_tag: WORKER_IMAGE_TAG
|
|
557
|
+
})
|
|
558
|
+
});
|
|
559
|
+
if (!res.ok) {
|
|
560
|
+
throw new Error(`Hub rejected task error: HTTP ${res.status}`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// ======================================================
|
|
565
|
+
// LOOP (with backoff on empty queue)
|
|
566
|
+
// ======================================================
|
|
567
|
+
|
|
568
|
+
let emptyQueueDelay = TASK_SLEEP_MS;
|
|
569
|
+
|
|
570
|
+
async function workerLoop() {
|
|
571
|
+
setInterval(sendBeacon, BEACON_INTERVAL_MS);
|
|
572
|
+
|
|
573
|
+
while (true) {
|
|
574
|
+
const task = await getTask();
|
|
575
|
+
|
|
576
|
+
if (!task) {
|
|
577
|
+
await sleep(emptyQueueDelay);
|
|
578
|
+
emptyQueueDelay = Math.min(emptyQueueDelay * 2, 15000);
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
emptyQueueDelay = TASK_SLEEP_MS; // reset on task received
|
|
582
|
+
|
|
583
|
+
log("info", `Task ${task.job_id} (type=${WORKER_TYPE}, timeout_ms=${task.timeout_ms || 'default'})`);
|
|
584
|
+
|
|
585
|
+
if (task.protocol_version !== PROTOCOL_VERSION || !task.dispatch_id) {
|
|
586
|
+
const reason = `incompatible_task_protocol:${task.protocol_version || 'missing'}`;
|
|
587
|
+
log("error", `Rejecting incompatible task: protocol=${task.protocol_version}, dispatch=${task.dispatch_id || 'missing'}`);
|
|
588
|
+
if (task.job_id && task.build_id && task.dispatch_id) {
|
|
589
|
+
try {
|
|
590
|
+
await sendTaskError(task, reason);
|
|
591
|
+
} catch (sendErr) {
|
|
592
|
+
log("error", "Failed to report incompatible task", sendErr.message);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
await sleep(TASK_SLEEP_MS);
|
|
596
|
+
continue;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// ── Per-job artifact tracking (cleanup after job) ──
|
|
600
|
+
// Точечная уборка: удаляются ТОЛЬКО файлы этой job. Output удаляется
|
|
601
|
+
// только после успешной доставки результата (downloadResult + sendResult),
|
|
602
|
+
// чтобы при ошибке sendResult() единственный результат не потерялся.
|
|
603
|
+
// Journal (worker-local persistent) фиксирует lifecycle
|
|
604
|
+
// CREATED→GENERATED→DELIVERED→CLEANED, чтобы после crash restart мог
|
|
605
|
+
// дочистить файлы job, результат которой уже доставлен в hub.
|
|
606
|
+
const createdInputFiles = [];
|
|
607
|
+
let outputPath = null;
|
|
608
|
+
let outputDelivered = false;
|
|
609
|
+
const jobId = task.job_id;
|
|
610
|
+
const dispatchId = task.dispatch_id;
|
|
611
|
+
|
|
612
|
+
try {
|
|
613
|
+
// Journal: CREATED — создаётся ДО первого временного input-файла.
|
|
614
|
+
await journal.createJob({ jobId, dispatchId, log });
|
|
615
|
+
|
|
616
|
+
if (task.assets?.images) {
|
|
617
|
+
const [jobBase] = task.job_id.split(JOB_ID_SPLIT_RE);
|
|
618
|
+
const scenePrefix = jobBase.replace(/_g\d+$/, '');
|
|
619
|
+
for (const [unitId, base64] of Object.entries(task.assets.images)) {
|
|
620
|
+
const filename = `${scenePrefix}_${unitId}.png`;
|
|
621
|
+
const filePath = path.join(COMFY_INPUT_DIR, filename);
|
|
622
|
+
createdInputFiles.push(filePath);
|
|
623
|
+
const { expectedSize } = await saveBase64ImageSafe(base64, filename);
|
|
624
|
+
// Journal: каждый фактически созданный reference image.
|
|
625
|
+
await journal.addInputFile({ jobId, dispatchId, log }, filePath);
|
|
626
|
+
log("info", `Multi-image saved: ${filename}`);
|
|
627
|
+
await waitForFileReady(filePath, expectedSize);
|
|
628
|
+
log("info", `Multi-image ready: ${filename}`);
|
|
629
|
+
}
|
|
630
|
+
} else if (task.assets?.image) {
|
|
631
|
+
const [baseId] = task.job_id.split(JOB_ID_SPLIT_RE);
|
|
632
|
+
const filename = `${baseId}.png`;
|
|
633
|
+
const filePath = path.join(COMFY_INPUT_DIR, filename);
|
|
634
|
+
createdInputFiles.push(filePath);
|
|
635
|
+
const { expectedSize } = await saveBase64ImageSafe(task.assets.image, filename);
|
|
636
|
+
await journal.addInputFile({ jobId, dispatchId, log }, filePath);
|
|
637
|
+
log("info", `Image saved: ${filename}`);
|
|
638
|
+
await waitForFileReady(filePath, expectedSize);
|
|
639
|
+
log("info", `Image ready: ${filename}`);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const prompt_id = await runWorkflow(task.params);
|
|
643
|
+
// timeout_ms приходит с задачей (backend → gpu-hub → worker): per-job
|
|
644
|
+
// таймаут для данного типа генерации. Если нет — per-type fallback.
|
|
645
|
+
const result = await waitResult(prompt_id, task.params, task.timeout_ms);
|
|
646
|
+
|
|
647
|
+
// Точечный output этой job: COMFY_OUTPUT_DIR + subfolder + filename.
|
|
648
|
+
// Это именно тот файл, который waitResult выбрал как результат (в т.ч.
|
|
649
|
+
// для video — реально выбранный mp4 из history/fallback/fs-scan).
|
|
650
|
+
if (result.meta && result.meta.filename) {
|
|
651
|
+
outputPath = path.resolve(COMFY_OUTPUT_DIR, result.meta.subfolder || "", result.meta.filename);
|
|
652
|
+
// Journal: GENERATED — известен конкретный output-файл.
|
|
653
|
+
await journal.setOutputAndGenerated({ jobId, dispatchId, log }, outputPath);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const base64 = await downloadResult(result);
|
|
657
|
+
log("debug", `result for ${task.job_id}: type=${result.type} size=${Math.round(base64.length / 1024)}KB`);
|
|
658
|
+
await sendResult(task, base64);
|
|
659
|
+
// Journal: DELIVERED — HTTP 200 от hub = результат уже durable в hub
|
|
660
|
+
// Redis (animastor:result:* записан до ответа 200). Output можно удалять.
|
|
661
|
+
await journal.setDelivered({ jobId, dispatchId, log });
|
|
662
|
+
outputDelivered = true;
|
|
663
|
+
log("info", `Done ${task.job_id}`);
|
|
664
|
+
|
|
665
|
+
} catch (err) {
|
|
666
|
+
log("error", `Failed ${task.job_id}`, err.message);
|
|
667
|
+
|
|
668
|
+
try {
|
|
669
|
+
await sendTaskError(task, err && err.message || err || "worker_error");
|
|
670
|
+
} catch (sendErr) {
|
|
671
|
+
log("error", "Failed to send error to hub", sendErr.message);
|
|
672
|
+
}
|
|
673
|
+
} finally {
|
|
674
|
+
// Cleanup after job: только собственные временные файлы этой job.
|
|
675
|
+
// Output — ТОЛЬКО после успешной доставки результата; при ошибке
|
|
676
|
+
// downloadResult()/sendResult() output сохраняется.
|
|
677
|
+
try {
|
|
678
|
+
const toCleanOutput = outputDelivered ? outputPath : null;
|
|
679
|
+
const cleanupResult = await cleanupJobArtifacts({
|
|
680
|
+
inputFiles: createdInputFiles,
|
|
681
|
+
outputFile: toCleanOutput,
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
if (cleanupResult.cleaned > 0 || cleanupResult.failed.length > 0) {
|
|
685
|
+
log("info",
|
|
686
|
+
`Cleanup ${task.job_id}: removed ${cleanupResult.cleaned} artifact(s) ` +
|
|
687
|
+
`(${createdInputFiles.length} input, ${toCleanOutput ? "1 output" : "0 output"})`);
|
|
688
|
+
}
|
|
689
|
+
for (const f of cleanupResult.failed) {
|
|
690
|
+
log("warn", `Cleanup ${task.job_id}: failed to remove ${f.path}: ${f.reason}`);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (cleanupResult.failed.length === 0) {
|
|
694
|
+
// CLEANED: все файлы job удалены → journal больше не нужен.
|
|
695
|
+
await journal.removeJob({ jobId, dispatchId, log });
|
|
696
|
+
} else {
|
|
697
|
+
// Частичный cleanup: journal остаётся — следующий recovery дочистит
|
|
698
|
+
// оставшиеся файлы (например, один input из видео-набора).
|
|
699
|
+
log("warn", `Cleanup ${task.job_id}: partial cleanup (${cleanupResult.failed.length} failed) — journal kept for recovery`);
|
|
700
|
+
}
|
|
701
|
+
} catch (cleanupErr) {
|
|
702
|
+
// Cleanup никогда не должен маскировать исходную ошибку job.
|
|
703
|
+
log("warn", `Cleanup ${task.job_id}: error: ${cleanupErr.message}`);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// ======================================================
|
|
710
|
+
// MAIN
|
|
711
|
+
// ======================================================
|
|
712
|
+
|
|
713
|
+
async function main() {
|
|
714
|
+
// PW-4 FAIL CLOSED startup gate: without a credential the worker refuses
|
|
715
|
+
// to run. There is no "system pool (no credential)" mode anymore — a
|
|
716
|
+
// missing token must never silently turn this GPU into shared capacity.
|
|
717
|
+
if (!ANIMASTOR_WORKER_TOKEN) {
|
|
718
|
+
log("error", "Worker authentication failed — check ANIMASTOR_WORKER_TOKEN");
|
|
719
|
+
log("error", "No worker credential configured. This worker cannot start:");
|
|
720
|
+
log("error", " 1. Open Animastor → Settings → Workers and create a worker");
|
|
721
|
+
log("error", " (choose Private for your own workspace, or Share to volunteer it).");
|
|
722
|
+
log("error", " 2. Copy the one-time credential (wrk.…).");
|
|
723
|
+
log("error", " 3. Set ANIMASTOR_WORKER_TOKEN=wrk.… in ./.env (or the environment).");
|
|
724
|
+
process.exit(1);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
log("info", `Worker ${WORKER_TYPE} started`);
|
|
728
|
+
log("info", `Worker ID: ${WORKER_ID} (label only — identity comes from the credential)`);
|
|
729
|
+
log("info", `Worker version: ${WORKER_VERSION || 'unknown'}`);
|
|
730
|
+
log("info", `Worker image tag: ${WORKER_IMAGE_TAG || 'unknown'}`);
|
|
731
|
+
log("info", `Hub URL: ${HUB_URL}`);
|
|
732
|
+
log("info", `Protocol version: ${PROTOCOL_VERSION}`);
|
|
733
|
+
|
|
734
|
+
// Confirm identity + mode against the registry before doing any work.
|
|
735
|
+
await verifyCredential();
|
|
736
|
+
|
|
737
|
+
await waitForComfyUI();
|
|
738
|
+
|
|
739
|
+
// Crash-safe recovery: завершить cleanup незакрытых job упавшего worker.
|
|
740
|
+
// delivered → удаляем input+output; created/generated → только input
|
|
741
|
+
// (output без proof DELIVERED не трогаем — защита единственной копии).
|
|
742
|
+
await journal.recoverCleanupJournal({ log });
|
|
743
|
+
|
|
744
|
+
await workerLoop();
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
main().catch(err => {
|
|
748
|
+
log("error", "Worker crashed", err.message);
|
|
749
|
+
process.exit(1);
|
|
750
|
+
});
|