// Static file server + same-origin proxy to the fal.ai queue API. // ponytail: keeping the proxy (rather than calling queue.fal.run directly // from the browser) sidesteps having to verify fal's CORS behavior at all — // same trick used for the MiniMax-native integration this replaced. const http = require("http"); const https = require("https"); const fs = require("fs"); const path = require("path"); const os = require("os"); const crypto = require("crypto"); const { execFileSync } = require("child_process"); try { process.loadEnvFile(path.join(__dirname, ".env")); } catch { // no .env file — fine, env vars may be set another way } const PORT = process.env.PORT || 8934; const FAL_HOST = "queue.fal.run"; const H3_CONSOLE_BASE = process.env.H3_CONSOLE_BASE || ""; const H3_CONSOLE_TOKEN = process.env.H3_CONSOLE_TOKEN || ""; const H3_CONSOLE_URL = H3_CONSOLE_BASE ? new URL(H3_CONSOLE_BASE) : null; // GLM Coding Plan — used only to expand a viewer's short chat prompt into a // detailed shot-by-shot prompt before handing it to H3 (see optimizePrompt). // Must hit the /api/coding/ path specifically — the plain /api/paas/ path // bills against a different (pay-per-token) quota than the coding plan. const GLM_API_KEY = process.env.GLM_API_KEY || ""; const GLM_HOST = "open.bigmodel.cn"; const GLM_PATH = "/api/coding/paas/v4/chat/completions"; const GLM_MODEL = "glm-5.3-flash"; // T24 credit topup. No test-mode key yet (2026-09-03: user said "先做一个 // mock,我明天再给你真实测试的 key") — see createCheckoutSession()'s doc // comment for how the two paths split. const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || ""; const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || ""; const APP_BASE_URL = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 8934}`; // T33 (dev-plan-5.md): fal.ai real-time compute for paid requests (需求文档 // 4.3 节双算力策略) — no key configured yet, same situation T24 was in with // Stripe. See generateDanceVideoViaFal()'s doc comment for what's verified // vs. still unverified (§8 item 1 / dev-plan-5.md §1.2 item 3: whether the // one-step attribute_transfer prompt that works on H3 Console also works on // fal's model — that needs a real call, not something code can assert). const FAL_API_KEY = process.env.FAL_API_KEY || ""; // Overridable so tests can point these at a local mock (same reasoning as // H3_CONSOLE_BASE) — default to fal's real, documented hosts. const FAL_REST_BASE = process.env.FAL_REST_BASE || "https://rest.alpha.fal.ai"; const FAL_QUEUE_BASE = process.env.FAL_QUEUE_BASE || "https://queue.fal.run"; const FAL_MODEL_ID = "minimax/h3/reference-to-video"; // Standalone "人衣图生成" admin tool (admin-dressgen.html) — person 3-views + // garment -> composite portrait via gpt-image-2, hosted behind 万界方舟's // maas-openapi relay. NOT the same pipeline as generateDanceVideoViaFal // above (that's H3/fal video); this is a one-shot image edit call. // // Contract confirmed against a real curl example the user supplied for the // sibling /images/generations endpoint on this same host, plus this // environment's own OPTIONS/GET probing of /images/edits (2026-09-03): // - host+path is maas-openapi.wanjiedata.com/api/v1/images/edits, NOT // fangzhou.wanjiedata.com/v1/images/edits (that first guess 405'd — // wrong host *and* wrong path; fangzhou is the docs/dashboard SPA, not // the API gateway. GET on the real /images/edits path 405s with an // explicit `Allow: POST` header from APISIX, confirming it's a real // registered route, not the SPA's catch-all). // - model id is the literal string "GPT Image 2" (with spaces) — the user // was warned a typo here gets WAF-blocked with 410 or "model // unauthorized", so don't "clean up" this string. // - this host's TLS chain does not validate from this machine (confirmed: // the user's own working curl needs -k) — generateDressImage() below // sends the request via a raw https.request with rejectUnauthorized // false scoped to just this one call, not a process-wide // NODE_TLS_REJECT_UNAUTHORIZED=0 (that would silently weaken every // other HTTPS call this server makes — Stripe, GLM, H3 Console, fal.ai). // - the /generations sibling responds JSON {data:[{url}]} (a short-lived // presigned URL, must be fetched immediately) and takes size/n as // top-level fields, not response_format (explicitly unsupported — don't // send it). /images/edits itself has NOT been call-tested (no example // for it specifically was available, only /generations + this // environment's unauthenticated OPTIONS/GET probing of the route's // existence) — its multipart shape here (image[] repeated, prompt, // model, size, n) mirrors OpenAI's own public /v1/images/edits // contract, which the user confirmed this vendor keeps in lockstep // with. First real authenticated call is still the actual proof. // - a real edit call takes 60-180s; the timeout must allow for 300s+. const IMAGE2_API_KEY = process.env.IMAGE2_API_KEY || ""; const IMAGE2_API_BASE = process.env.IMAGE2_API_BASE || "https://maas-openapi.wanjiedata.com"; const IMAGE2_EDIT_PATH = "/api/v1/images/edits"; const IMAGE2_MODEL = "GPT Image 2"; const IMAGE2_SIZE = "1024x1536"; // within the ~157万-pixel cap the user measured; anything larger is silently downscaled const IMAGE2_TIMEOUT_MS = 300000; const IMAGE2_MIME = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp" }; // Second dressgen provider (2026-09-03) — admin-picked per task // (record.provider) via the UI, or picked automatically as a fallback (see // below). // // UPDATE (2026-09-03, later same day): the paragraph above originally said // this would never auto-fallback on an IMAGE2 failure, reasoning that it'd // silently re-route content around whichever provider's own moderation // rejected it first. Asked directly about that tradeoff — with the // concrete case in hand (a real-person-photo + swimwear task whose IMAGE2 // 400 looked like a moderation rejection, not a technical fault) — the // product call came back "any failure auto-switches", overriding that // stance. See runDressGenViews' fallback branch below. Recorded here so // the next session doesn't rediscover this as a bug. // // Contract confirmed 2026-09-03 via real calls (not guessed): upload each // image with POST {base}/api/v3/media/upload/binary (multipart, field // "file") -> {data.download_url}; submit with POST {base}/api/v3/{model} // (JSON: {prompt, images:[url,...]}) -> {data.id, data.urls.get}; poll // data.urls.get until status is "completed" (-> data.outputs[0] is the // result URL) or "failed" (-> data.error). No TLS quirks here (unlike // IMAGE2) — plain fetch works. const WAVESPEED_API_KEY = process.env.WAVESPEED_API_KEY || ""; const WAVESPEED_API_BASE = process.env.WAVESPEED_API_BASE || "https://api.wavespeed.ai"; const WAVESPEED_MODEL = "bytedance/seedream-v4/edit"; const WAVESPEED_POLL_INTERVAL_MS = Number(process.env.WAVESPEED_POLL_INTERVAL_MS) || 3000; const WAVESPEED_TIMEOUT_MS = 180000; // observed ~30s in practice; generous headroom over that const DRESSGEN_PROVIDERS = ["image2", "wavespeed"]; const DRESSGEN_DEFAULT_PROVIDER = "image2"; function dressGenProviderKeyMissing(provider) { if (provider === "wavespeed") return !WAVESPEED_API_KEY ? "WAVESPEED_API_KEY not configured" : null; return !IMAGE2_API_KEY ? "IMAGE2_API_KEY not configured" : null; } // Raw https.request wrapper (not fetch) so rejectUnauthorized:false can be // scoped to exactly this one call — see the doc comment above. bodyBuffer // may be omitted for a plain GET-style fetch (used for the presigned result // URL, which may or may not share 万界's own broken cert chain — safer to // go through the same relaxed path than to assume it doesn't). Dispatches // on the URL's own protocol (not hardcoded to https) purely so tests can // point IMAGE2_API_BASE at a plain-http local mock; the real vendor is // always https. function insecureHttpsRequest(urlString, { method = "GET", headers = {}, bodyBuffer } = {}) { return new Promise((resolve, reject) => { const url = new URL(urlString); const transport = url.protocol === "http:" ? http : https; const req = transport.request( { hostname: url.hostname, port: url.port || (url.protocol === "http:" ? 80 : 443), path: `${url.pathname}${url.search}`, method, rejectUnauthorized: false, headers, timeout: IMAGE2_TIMEOUT_MS, }, (res) => { const chunks = []; res.on("data", (c) => chunks.push(c)); res.on("end", () => resolve({ status: res.statusCode, buffer: Buffer.concat(chunks) })); } ); req.on("timeout", () => req.destroy(new Error(`image2 request timed out after ${IMAGE2_TIMEOUT_MS}ms`))); req.on("error", reject); if (bodyBuffer) req.write(bodyBuffer); req.end(); }); } // 人物参考图最多 6 张定妆照(全身/脸部 × 正/侧/背),但不强制全传——传几张 // 算几张(至少 1 张)。gpt-image-2 的 edit 端点没有"生成 3 张不同视角"的原生 // 能力(同一次调用的 n 个输出是同一编辑的独立采样,不是可寻址的不同机位, // CODEX-IMAGEGEN.md §4.3 也确认 edit 没有 batch 子命令),所以三个目标视角 // 分三次独立调用;每次调用都把用户实际上传的全部人物参考图 + 新衣服图一起 // 喂进去(不按 slot 精确配对),靠 prompt 里的目标视角文字区分三次调用要出 // 哪个角度。三次调用按顺序(依次)发起,不并发。 const DRESSGEN_PERSON_SLOTS = ["bodyFront", "bodySide", "bodyBack", "faceFront", "faceSide", "faceBack"]; const DRESSGEN_VIEWS = ["front", "side", "back"]; const DRESSGEN_VIEW_LABEL = { front: "正面", side: "侧面", back: "背面" }; function defaultDressgenPrompt(view) { const label = DRESSGEN_VIEW_LABEL[view]; return ( "前面的图是同一位人物的参考图(可能包含全身正/侧/背、脸正/侧/背等不同角度和部位,具体张数以实际上传为准),最后一张图是新服装参考图。" + `保持人物的脸部五官、发型、体型完全不变,仅将服装替换为新服装图所示服装,生成人物穿着新服装的${label}全身人物形象图。` + "鞋子沿用人物原有款式,不要更换;背景、地面材质、光照方向和色调都必须跟人物参考图里的场景保持一致——" + "不要换成纯色影棚背景,也不要换成另一个场景,三个视角(正/侧/背)用的是同一个背景。全身入镜。Size 1024x1536." ); } // Runs (a subset of) DRESSGEN_VIEWS' edit calls in sequence against an // already-created dressGenRecords row, writing progress back after every // single view so admin-dressgen.html's poll (GET /api/admin/dressgen) can // show "queued/generating/done/failed" per view while it's still running — // not just a final result once the whole thing settles. Called // fire-and-forget (`.catch(() => {})` at the call site) right after the // HTTP response for POST /dressgen or POST /dressgen/:id/retry has already // gone out; every failure mode here is recorded on the row itself, not // thrown back to a caller that's no longer listening. `views` lets retry // reprocess only the views that didn't succeed last time, without touching // ones that already have an output. async function runDressGenViews(recordId, imageAbsPaths, views) { for (const view of views) { const before = dressGenRecords.get(recordId); if (!before) return; // row was deleted mid-flight dressGenRecords.update(recordId, { viewStatus: { ...before.viewStatus, [view]: "generating" } }); const provider = before.provider || DRESSGEN_DEFAULT_PROVIDER; let usedProvider = provider; try { let imageBuffer; try { imageBuffer = await generateDressImage({ imageAbsPaths, prompt: before.prompts[view], provider }); } catch (err) { // Automatic fallback: default provider (image2) failed for any // reason -> retry once via wavespeed before giving up on this view. // See the note above generateDressImage's declaration for why. if (provider === DRESSGEN_DEFAULT_PROVIDER && !dressGenProviderKeyMissing("wavespeed")) { usedProvider = "wavespeed"; imageBuffer = await generateDressImage({ imageAbsPaths, prompt: before.prompts[view], provider: "wavespeed" }); } else { throw err; } } const { relPath } = placeInUploads(imageBuffer, "png"); const after = dressGenRecords.get(recordId); if (!after) return; dressGenRecords.update(recordId, { outputImages: { ...after.outputImages, [view]: relPath }, errors: { ...after.errors, [view]: null }, viewStatus: { ...after.viewStatus, [view]: "done" }, viewProvider: { ...(after.viewProvider || {}), [view]: usedProvider }, }); } catch (err) { const after = dressGenRecords.get(recordId); if (!after) return; dressGenRecords.update(recordId, { errors: { ...after.errors, [view]: err.message }, viewStatus: { ...after.viewStatus, [view]: "failed" }, }); } } const final = dressGenRecords.get(recordId); if (!final) return; const succeeded = DRESSGEN_VIEWS.filter((v) => final.outputImages[v]).length; const status = succeeded === DRESSGEN_VIEWS.length ? "done" : succeeded > 0 ? "partial" : "failed"; dressGenRecords.update(recordId, { status }); } // Startup recovery, same idea as resumeDressupGenerations() above: a // record can be sitting in "generating" if the process died mid-view. // Unlike generationJobs there's no in-flight request id to reconnect to // (image2/wavespeed calls are plain awaited HTTP round-trips inside // runDressGenViews, nothing survives the process exiting) — every // "queued"/"generating" view is unrecoverable, so it's marked failed with // a distinguishable error, same as a real generation failure would be, and // is one click to retry from either list or detail page. function resumeDressgenTasks() { for (const record of dressGenRecords.list()) { const staleViews = DRESSGEN_VIEWS.filter((v) => record.viewStatus[v] === "queued" || record.viewStatus[v] === "generating"); if (staleViews.length === 0) continue; const viewStatus = { ...record.viewStatus }; const errors = { ...record.errors }; for (const v of staleViews) { viewStatus[v] = "failed"; errors[v] = "进程重启,任务丢失,请点重试"; } const succeeded = DRESSGEN_VIEWS.filter((v) => record.outputImages[v]).length; const status = succeeded === DRESSGEN_VIEWS.length ? "done" : succeeded > 0 ? "partial" : "failed"; dressGenRecords.update(record.id, { viewStatus, errors, status }); } } // imageAbsPaths for a dressGenRecords row, recomputed from its own stored // fields — used both at creation time and by retry (which reuses the // original upload, no re-selecting images). function dressGenImageAbsPaths(record) { const personPaths = DRESSGEN_PERSON_SLOTS.filter((f) => record[f]).map((f) => record[f]); return [...personPaths, record.garmentImage].map((p) => path.join(__dirname, p)); } async function generateDressImage({ imageAbsPaths, prompt, provider }) { return provider === "wavespeed" ? generateDressImageViaWavespeed({ imageAbsPaths, prompt }) : generateDressImageViaImage2({ imageAbsPaths, prompt }); } async function generateDressImageViaImage2({ imageAbsPaths, prompt }) { if (!IMAGE2_API_KEY) throw new Error("IMAGE2_API_KEY not configured"); const form = new FormData(); form.set("model", IMAGE2_MODEL); form.set("prompt", prompt); form.set("n", "1"); form.set("size", IMAGE2_SIZE); for (const absPath of imageAbsPaths) { const ext = path.extname(absPath).slice(1).toLowerCase(); form.append("image[]", new Blob([fs.readFileSync(absPath)], { type: IMAGE2_MIME[ext] || "application/octet-stream" }), path.basename(absPath)); } // Encode via the platform's own FormData/Response to get correct // multipart boundary handling for free, then send over insecureHttpsRequest // (not fetch) — see IMAGE2_API_KEY's doc comment for why. const encoded = new Response(form); const contentType = encoded.headers.get("content-type"); const bodyBuffer = Buffer.from(await encoded.arrayBuffer()); const { status, buffer } = await insecureHttpsRequest(`${IMAGE2_API_BASE}${IMAGE2_EDIT_PATH}`, { method: "POST", headers: { Authorization: `Bearer ${IMAGE2_API_KEY}`, "Content-Type": contentType, "Content-Length": bodyBuffer.length }, bodyBuffer, }); const text = buffer.toString("utf8"); if (status < 200 || status >= 300) throw new Error(`image2 edit failed: ${status} ${text.slice(0, 500)}`); let json; try { json = JSON.parse(text); } catch { throw new Error(`image2 edit returned non-JSON response: ${text.slice(0, 500)}`); } const item = json.data && json.data[0]; if (!item) throw new Error("image2 edit response missing data[0]"); if (item.b64_json) return Buffer.from(item.b64_json, "base64"); if (item.url) { const { status: imgStatus, buffer: imgBuffer } = await insecureHttpsRequest(item.url); if (imgStatus < 200 || imgStatus >= 300) throw new Error(`failed to fetch generated image: ${imgStatus}`); return imgBuffer; } throw new Error("image2 edit response missing b64_json/url"); } async function wavespeedUpload(absPath) { const ext = path.extname(absPath).slice(1).toLowerCase(); const form = new FormData(); form.set("file", new Blob([fs.readFileSync(absPath)], { type: IMAGE2_MIME[ext] || "application/octet-stream" }), path.basename(absPath)); const res = await fetch(`${WAVESPEED_API_BASE}/api/v3/media/upload/binary`, { method: "POST", headers: { Authorization: `Bearer ${WAVESPEED_API_KEY}` }, body: form, }); const json = await res.json(); if (!res.ok || !json.data || !json.data.download_url) throw new Error(`wavespeed upload failed: ${res.status} ${JSON.stringify(json).slice(0, 500)}`); return json.data.download_url; } async function generateDressImageViaWavespeed({ imageAbsPaths, prompt }) { if (!WAVESPEED_API_KEY) throw new Error("WAVESPEED_API_KEY not configured"); const imageUrls = []; for (const absPath of imageAbsPaths) imageUrls.push(await wavespeedUpload(absPath)); const submitRes = await fetch(`${WAVESPEED_API_BASE}/api/v3/${WAVESPEED_MODEL}`, { method: "POST", headers: { Authorization: `Bearer ${WAVESPEED_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ prompt, images: imageUrls }), }); const submitJson = await submitRes.json(); if (!submitRes.ok || !submitJson.data || !submitJson.data.urls || !submitJson.data.urls.get) { throw new Error(`wavespeed submit failed: ${submitRes.status} ${JSON.stringify(submitJson).slice(0, 500)}`); } const resultUrl = submitJson.data.urls.get; const deadline = Date.now() + WAVESPEED_TIMEOUT_MS; while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, WAVESPEED_POLL_INTERVAL_MS)); const pollRes = await fetch(resultUrl, { headers: { Authorization: `Bearer ${WAVESPEED_API_KEY}` } }); const pollJson = await pollRes.json(); const status = pollJson.data && pollJson.data.status; if (status === "completed") { const outputUrl = pollJson.data.outputs && pollJson.data.outputs[0]; if (!outputUrl) throw new Error("wavespeed result completed but has no outputs[0]"); const imgRes = await fetch(outputUrl); if (!imgRes.ok) throw new Error(`failed to fetch wavespeed output: ${imgRes.status}`); return Buffer.from(await imgRes.arrayBuffer()); } if (status === "failed") throw new Error(`wavespeed generation failed: ${pollJson.data.error || "unknown error"}`); // otherwise still "created"/"processing" — keep polling } throw new Error(`wavespeed generation timed out after ${WAVESPEED_TIMEOUT_MS}ms`); } const MIME = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".json": "application/json", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".mp4": "video/mp4", }; const { syncLibrary } = require("./ingest.js"); const { UPLOADS_DIR, readRawBody, parseMultipart, boundaryFromContentType, sniffImageType, sniffVideoType, stripMetadataAndSave, saveVideoUpload, placeInUploads, forwardToH3Assets, forwardToFalAssets, } = require("./uploads.js"); const { characters, garments, danceRefs, characterImages, generationJobs, moderationLog, blacklist, dressGenRecords } = require("./dressup-store.js"); const { buildStage2Prompt } = require("./prompt-template.js"); const { users: userStore, sessions: userSessions, creditLedger } = require("./db.js"); const { pricing } = require("./pricing.js"); const LIBRARY_PATH = path.join(__dirname, "library.json"); function getLibrary() { try { return JSON.parse(fs.readFileSync(LIBRARY_PATH, "utf8")); } catch { return []; } } function saveLibrary(lib) { fs.writeFileSync(LIBRARY_PATH, JSON.stringify(lib, null, 2)); } // One-time backfill (2026-09 上架/下架): clips already QC'd "correct" before // `published` existed count as already having passed verification, so they // stay live in dance.html's random-fill pool instead of the room going // quiet until someone re-publishes 57+ clips by hand. (function backfillPublished() { const lib = getLibrary(); let changed = false; for (const c of lib) { if (c.channel === "dance" && c.published === undefined) { c.published = c.qcStatus === "correct"; changed = true; } } if (changed) saveLibrary(lib); })(); const LIVE_STATE_PATH = path.join(__dirname, "live-state.json"); function loadLiveState() { try { const s = JSON.parse(fs.readFileSync(LIVE_STATE_PATH, "utf8")); return { schedule: s.schedule || [], promptQueue: s.promptQueue || [], chatMessages: s.chatMessages || [] }; } catch { return { schedule: [], promptQueue: [], chatMessages: [] }; } } function saveLiveState() { fs.writeFileSync(LIVE_STATE_PATH, JSON.stringify({ schedule, promptQueue, chatMessages }, null, 2)); } // dance.html's own playout state (T11) — separate file/schedule/queue from // live.html's (architecture.md 2.1: two independent channels, not shared). // Per MVCP M.8, `schedule` here stores `videoId` references, not inlined // clip snapshots like live's `schedule` does — dance clips join back to // library.json at read time so an admin edit/takedown isn't stuck showing a // stale copy. // // One live room per character (人物库多直播间, 2026-09): schedule/queue/ // priorityInbox used to be single global arrays (one shared room, always // the one characters.isDefault character). They're now keyed by characterId // — `danceRooms[characterId] = { schedule, queue, priorityInbox }` — a room // exists for every character that has at least one channel:"dance" clip // (getDanceRoomCharacterIds below), created lazily on first tick/request. const DANCE_STATE_PATH = path.join(__dirname, "dance-state.json"); function loadDanceState() { try { const s = JSON.parse(fs.readFileSync(DANCE_STATE_PATH, "utf8")); if (s.rooms) return { danceRooms: s.rooms }; // Pre-multi-room save file: migrate the one flat room forward onto // whichever character it actually belonged to (characters.isDefault), // rather than silently dropping in-flight generations/queued picks. const defaultCharacter = characters.list().find((c) => c.isDefault); const hadState = (s.schedule || []).length || (s.freeQueue || []).length || (s.priorityInbox || []).length; if (defaultCharacter && hadState) { return { danceRooms: { [defaultCharacter.id]: { schedule: s.schedule || [], queue: s.freeQueue || [], priorityInbox: s.priorityInbox || [] } } }; } return { danceRooms: {} }; } catch { return { danceRooms: {} }; } } function saveDanceState() { fs.writeFileSync(DANCE_STATE_PATH, JSON.stringify({ rooms: danceRooms }, null, 2)); } function getDanceRoom(characterId) { if (!danceRooms[characterId]) danceRooms[characterId] = { schedule: [], queue: [], priorityInbox: [] }; return danceRooms[characterId]; } // Every character with >=1 generated dance video gets a room automatically // — no separate "publish/go-live" toggle (confirmed: "有视频就自动开间"). function getDanceRoomCharacterIds() { const ids = new Set(Object.keys(danceRooms)); for (const clip of getLibrary()) if (clip.channel === "dance" && clip.characterId && clip.qcStatus !== "incorrect") ids.add(clip.characterId); return ids; } function getRoomViewers(characterId) { if (!danceViewers.has(characterId)) danceViewers.set(characterId, new Map()); return danceViewers.get(characterId); } function readJsonBody(req) { return new Promise((resolve, reject) => { let data = ""; req.on("data", (chunk) => (data += chunk)); req.on("end", () => { try { resolve(data ? JSON.parse(data) : {}); } catch (err) { reject(err); } }); req.on("error", reject); }); } // Stripe's webhook signature is computed over the exact raw request bytes // (T24) — JSON.parse-then-restringify would not reproduce the same bytes, // so this can't reuse readJsonBody for that one route. function readRawTextBody(req) { return new Promise((resolve, reject) => { let data = ""; req.on("data", (chunk) => (data += chunk)); req.on("end", () => resolve(data)); req.on("error", reject); }); } // Minimal admin auth (dev-plan.md T6): one shared password from .env, an // HttpOnly session cookie, no accounts/roles/DB — the account system in // MVCP M.1/M.10 supersedes this in period 3. Sessions are in-memory only, // so a process restart logs everyone out (acceptable for this stopgap). const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || ""; const ADMIN_SESSION_COOKIE = "admin_session"; const ADMIN_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days, matches MVCP M.10's session TTL const adminSessions = new Map(); // token -> expiresAt function parseCookies(req) { const header = req.headers.cookie || ""; const cookies = {}; for (const pair of header.split(";")) { const idx = pair.indexOf("="); if (idx === -1) continue; cookies[pair.slice(0, idx).trim()] = decodeURIComponent(pair.slice(idx + 1).trim()); } return cookies; } // T30: two accepted paths, either is enough — the ADMIN_PASSWORD cookie // (T6, unchanged) stays as a fallback channel so nobody's locked out of the // backend before any users.role='admin' account has been created by hand; // requireUserSession + role='admin' (below, hoisted — same module scope) is // the real per-admin identity path, MVCP M.10's stated rule for // /api/admin/*. Calls requireUserSession before it's declared further down // this file — fine, `function` declarations are fully hoisted. function isAdminAuthed(req) { const token = parseCookies(req)[ADMIN_SESSION_COOKIE]; if (token) { const expiresAt = adminSessions.get(token); if (expiresAt && expiresAt >= Date.now()) return true; adminSessions.delete(token); } const user = requireUserSession(req); return !!(user && user.role === "admin"); } // Who to attribute an admin action to (T30's "credit_ledger 里能查到是哪个 //管理员做的") — null when the request came in over the legacy shared // ADMIN_PASSWORD channel, which has no per-admin identity to attribute to. function adminActorUserId(req) { const user = requireUserSession(req); return user && user.role === "admin" ? user.id : null; } // User account login (dev-plan-3.md T19; dressup-dance-room-mvcp.md // M.1/M.10) — a separate cookie/session table from admin_session above, on // purpose: this is for viewers, not the single shared admin credential, and // the two must never be confused for one another. const USER_SESSION_COOKIE = "user_session"; const USER_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days, MVCP M.10 const EMAIL_VERIFY_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours, MVCP M.10 // T20: no email-delivery vendor has been picked yet (dev-plan-3.md §1's // open question — SMTP vs. an HTTP-API provider like Resend/SendGrid). // This is the one seam a real integration plugs into later; nothing else // needs to change. For now it logs, and outside production also returns // the link directly so registering can be tested end to end without a // real inbox. function sendVerificationEmail(email, token) { const verifyUrl = `/api/auth/verify?token=${token}`; console.log(`[email_verify] would send to ${email}: ${verifyUrl}`); return verifyUrl; } // T25: no vision-model vendor has been picked yet (dev-plan-4.md §1.2 item // 2 — needed because mode 2's uploader has no admin around to hand-write // garments.appearanceDescriptor the way admin_seed garments do). Same seam // pattern as sendVerificationEmail above: today it's a fixed placeholder, // swapping in a real vendor later is a one-function change, callers (T27) // don't need to know the difference. const VISION_API_KEY = process.env.VISION_API_KEY || ""; async function describeGarmentImage(absImagePath) { if (!VISION_API_KEY) return "用户上传的服装"; throw new Error("vision vendor not implemented — set VISION_API_KEY only once a provider is wired up"); } // T35 (dev-plan-5.md): same seam as describeGarmentImage above, for mode // 3's uploaded face photos — appearanceDescriptor has no admin to // hand-write it either, same underlying gap. Shares VISION_API_KEY (one // vendor question, not two) since both are "describe an uploaded photo" // calls to whatever vision model eventually gets picked. async function describeCharacterImage(absImagePath) { if (!VISION_API_KEY) return "用户上传的人物形象"; throw new Error("vision vendor not implemented — set VISION_API_KEY only once a provider is wired up"); } // T26: same seam pattern, for image-content moderation (dev-plan-4.md §1.2 // item 1 — the requirements doc names a vendor as "TBD" and never actually // lists it as its own task, so this is that gap filled in). Every call logs // to moderation_log (M.9) regardless of which branch runs, so the audit // trail exists before a real vendor does. `assetId` is the caller's own // stable id for the upload (T27 passes the just-saved file's relPath, since // at moderation time there's no garments.id yet — moderation runs before // the garment record is created, per dev-plan-4.md T27's ordering). const MODERATION_API_KEY = process.env.MODERATION_API_KEY || ""; async function moderateGarmentImage(absImagePath, assetId) { let verdict, reason; if (!MODERATION_API_KEY) { // No vendor configured: default-permissive placeholder pass (T2's // upload endpoint already enforces the real trust-boundary checks — // file type by magic bytes, 8MB cap, EXIF-stripped re-encode — this is // only the *content* check, which nothing here can actually perform // without a vendor). verdict = "auto_pass"; reason = null; } else { throw new Error("moderation vendor not implemented — set MODERATION_API_KEY only once a provider is wired up"); } moderationLog.create({ assetType: "garment_upload", assetId, verdict, reason, reviewedBy: "system" }); return { approved: verdict === "auto_pass", verdict, reason }; } // T32 (dev-plan-5.md): same seam pattern as T26 above, but for mode 3's // uploaded face photos against a "sensitive figure" blacklist (需求文档 5 // 节 — e.g. filtering politically sensitive likenesses), not general image // content. No vendor has been picked (needs research, dev-plan-5.md §1.2 // item 1). Default policy when unconfigured is default-permissive // (auto_pass) — deliberately confirmed with the user rather than assumed, // since the risk profile here (a real uploaded face, potentially a // sensitive public figure) is not the same as T26's garment photos, where // a wrong default-pass is low-stakes. Do not silently change this default // without asking again. const FACE_BLACKLIST_API_KEY = process.env.FACE_BLACKLIST_API_KEY || ""; async function moderateFaceBlacklist(absImagePath, assetId) { let verdict, reason; if (!FACE_BLACKLIST_API_KEY) { verdict = "auto_pass"; reason = null; } else { throw new Error("face-blacklist vendor not implemented — set FACE_BLACKLIST_API_KEY only once a provider is wired up"); } moderationLog.create({ assetType: "character_upload", assetId, verdict, reason, reviewedBy: "system" }); return { approved: verdict === "auto_pass", verdict, reason }; } // T21: mirrors live.html/dance.html's own setCookie() exactly (path=/, // samesite=lax, no httponly, max-age in seconds, 365-day TTL) — these are // the same viewer_uid/viewer_nickname cookies those pages already read // with plain client-side JS, not a new cookie scheme. function viewerIdentityCookie(name, value) { return `${name}=${encodeURIComponent(value)}; Max-Age=${365 * 86400}; Path=/; SameSite=Lax`; } function hashPassword(password) { const salt = crypto.randomBytes(16).toString("hex"); const hash = crypto.scryptSync(password, salt, 64).toString("hex"); return `${salt}:${hash}`; } function verifyPassword(password, stored) { const [salt, hash] = (stored || "").split(":"); if (!salt || !hash) return false; const candidate = crypto.scryptSync(password, salt, 64); const expected = Buffer.from(hash, "hex"); // scryptSync's output length is fixed by the third argument above, so // both buffers are always the same length — timingSafeEqual would throw // otherwise, but that can't happen here. return crypto.timingSafeEqual(candidate, expected); } // Returns the logged-in user (plain object, MVCP M.1 shape) or null. Free // endpoints (/api/dance/*) don't call this at all — only routes that // actually need an account (T20's email verify, T21's identity binding, // anything paid in period 4) do. function requireUserSession(req) { const token = parseCookies(req)[USER_SESSION_COOKIE]; if (!token) return null; const session = userSessions.findByToken(token); if (!session || session.kind !== "session") return null; return userStore.get(session.userId); } // Generic CRUD router for the three cold-start asset libraries (T3-T5) — // GET list / POST create / PATCH update / DELETE, backed by store.js. The // three entities' shapes differ (see MVCP M.3/M.4/M.5) but the routing and // validation boilerplate is identical, so this is mounted three times below // instead of copy-pasted. function mountCrud(prefix, store, opts = {}) { const { requiredFields = [], sanitize = (b) => b, onCreate, onUpdate, onDelete } = opts; function missingField(body) { return requiredFields.find((f) => { const v = body[f]; return v === undefined || v === null || (typeof v === "string" && !v.trim()); }); } return function handle(pathname, req, res) { if (pathname === prefix && req.method === "GET") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(store.list())); return true; } if (pathname === prefix && req.method === "POST") { readJsonBody(req) .then((body) => { const missing = missingField(body); if (missing) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: `${missing} required` })); return; } const item = store.create(sanitize(body)); if (onCreate) onCreate(item, store); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(item)); }) .catch(() => { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid JSON body" })); }); return true; } if (pathname.startsWith(`${prefix}/`) && req.method === "PATCH") { const id = pathname.slice(prefix.length + 1); readJsonBody(req) .then((body) => { // Partial update: only reject an explicit blank-out of a required // field, not the field's mere absence from this patch. const missing = requiredFields.find((f) => f in body && missingField({ [f]: body[f] }) === f); if (missing) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: `${missing} required` })); return; } const updated = store.update(id, sanitize(body, true)); if (!updated) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "not found" })); return; } if (onUpdate) onUpdate(updated, store); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(updated)); }) .catch(() => { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid JSON body" })); }); return true; } if (pathname.startsWith(`${prefix}/`) && req.method === "DELETE") { const id = pathname.slice(prefix.length + 1); const existing = store.get(id); const removed = store.remove(id); if (removed && onDelete) onDelete(existing, store); res.writeHead(removed ? 200 : 404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ deleted: removed })); return true; } return false; }; } // isDefault is a singleton across the whole characters library (2 节: // "第一期只允许同时有一个人物被设为默认") — enforced here rather than in // store.js, which stays schema-agnostic. function unsetOtherDefaultCharacters(item, store) { if (!item.isDefault) return; for (const c of store.list()) { if (c.id !== item.id && c.isDefault) store.update(c.id, { isDefault: false }); } } const charactersCrud = mountCrud("/api/admin/characters", characters, { requiredFields: ["name", "imageUrl", "appearanceDescriptor"], sanitize: (body, isPatch) => isPatch ? body : { name: body.name, imageUrl: body.imageUrl, tags: Array.isArray(body.tags) ? body.tags : [], source: "admin_seed", ownerUserId: null, appearanceDescriptor: body.appearanceDescriptor, isDefault: Boolean(body.isDefault), moderationStatus: "auto_passed", totalSegments: null, remainingSegments: null, status: "active", }, onCreate: (item, store) => { unsetOtherDefaultCharacters(item, store); // Mirrors the single image the add-character form just uploaded into // the gallery as its first/cover row, so admin_seed characters created // the old one-image way still show up correctly in the image manager. if (item.imageUrl) characterImages.create({ characterId: item.id, imageUrl: item.imageUrl, angleLabel: "主图", isCover: true }); }, onUpdate: unsetOtherDefaultCharacters, // character-images has no FK constraint of its own (store.js is // schema-agnostic) — deleting a character would otherwise leave its // gallery rows orphaned forever. onDelete: (character) => { if (!character) return; for (const img of characterImages.list().filter((i) => i.characterId === character.id)) characterImages.remove(img.id); }, }); const garmentsCrud = mountCrud("/api/admin/garments", garments, { requiredFields: ["name", "imageUrl", "appearanceDescriptor"], sanitize: (body, isPatch) => isPatch ? body : { name: body.name, category: body.category || "", thumbnailUrl: body.thumbnailUrl || body.imageUrl, imageUrl: body.imageUrl, appearanceDescriptor: body.appearanceDescriptor, source: "admin_seed", ownerUserId: null, moderationStatus: "approved", isActive: body.isActive === undefined ? true : Boolean(body.isActive), }, }); const danceRefsCrud = mountCrud("/api/admin/dance-refs", danceRefs, { requiredFields: ["name", "videoUrl", "motionDescriptor"], sanitize: (body, isPatch) => isPatch ? body : { name: body.name, videoUrl: body.videoUrl, styleTags: Array.isArray(body.styleTags) ? body.styleTags : [], durationSec: body.durationSec || null, motionDescriptor: body.motionDescriptor, copyrightSource: "test_placeholder", isActive: body.isActive === undefined ? true : Boolean(body.isActive), }, }); // Setting isCover on one image unsets it on the character's other images // (singleton-per-characterId, same idea as unsetOtherDefaultCharacters) and // mirrors the url onto characters.imageUrl. h3AssetUrl/falAssetUrl are // forward-once caches keyed off the OLD imageUrl (ensureH3AssetUrl/ // ensureFalAssetUrl below) — a cover swap must clear them or generation // keeps sending the previous photo. function syncCoverToCharacter(image, store) { if (!image.isCover) return; for (const img of store.list()) { if (img.id !== image.id && img.characterId === image.characterId && img.isCover) store.update(img.id, { isCover: false }); } characters.update(image.characterId, { imageUrl: image.imageUrl, h3AssetUrl: null, falAssetUrl: null }); } // Deleting the cover image must not leave characters.imageUrl pointing at a // gallery row that no longer exists — promote whatever's left, if anything. function promoteNewCoverOnDelete(deletedImage, store) { if (!deletedImage || !deletedImage.isCover) return; const remaining = store.list().filter((img) => img.characterId === deletedImage.characterId); if (remaining.length === 0) return; store.update(remaining[0].id, { isCover: true }); characters.update(deletedImage.characterId, { imageUrl: remaining[0].imageUrl, h3AssetUrl: null, falAssetUrl: null }); } const characterImagesCrud = mountCrud("/api/admin/character-images", characterImages, { requiredFields: ["characterId", "imageUrl"], sanitize: (body, isPatch) => isPatch ? body : { characterId: body.characterId, imageUrl: body.imageUrl, angleLabel: body.angleLabel || "", isCover: Boolean(body.isCover) }, onCreate: (item, store) => { // A character's first image has nothing to be "cover" relative to — // make it the cover automatically instead of leaving the gallery // coverless until the admin remembers to flip a checkbox. const siblingCount = store.list().filter((img) => img.characterId === item.characterId).length; if (siblingCount === 1 && !item.isCover) { store.update(item.id, { isCover: true }); item.isCover = true; } syncCoverToCharacter(item, store); }, onUpdate: syncCoverToCharacter, onDelete: promoteNewCoverOnDelete, }); function h3ConsoleFetch(pathAndQuery, options) { return new Promise((resolve, reject) => { const proxyReq = http.request( { hostname: H3_CONSOLE_URL.hostname, port: H3_CONSOLE_URL.port || 80, path: `${H3_CONSOLE_URL.pathname}${pathAndQuery}`, method: (options && options.method) || "GET", headers: { Authorization: `Bearer ${H3_CONSOLE_TOKEN}`, "Content-Type": "application/json" }, }, (proxyRes) => { let data = ""; proxyRes.on("data", (c) => (data += c)); proxyRes.on("end", () => { try { resolve(JSON.parse(data)); } catch (err) { reject(err); } }); } ); proxyReq.on("error", reject); if (options && options.body) proxyReq.write(JSON.stringify(options.body)); proxyReq.end(); }); } // timeoutMs default matches the original text-only prompt-optimize use — // the QC caller (multi-image, much bigger payload) passes a longer one. function glmChat(messages, timeoutMs = 15000) { return new Promise((resolve, reject) => { const body = JSON.stringify({ model: GLM_MODEL, messages, temperature: 0.7 }); const req = https.request( { hostname: GLM_HOST, path: GLM_PATH, method: "POST", headers: { Authorization: `Bearer ${GLM_API_KEY}`, "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }, }, (res) => { let data = ""; res.on("data", (c) => (data += c)); res.on("end", () => { try { resolve(JSON.parse(data)); } catch (err) { reject(err); } }); } ); req.on("error", reject); req.setTimeout(timeoutMs, () => req.destroy(new Error("GLM request timed out"))); req.write(body); req.end(); }); } // ---- T24: Stripe topup, with a mock fallback until a real test-mode key // exists (2026-09-03: user said "先做一个 mock,我明天再给你真实测试的 // key") — createCheckoutSession() is the single seam both paths sit // behind, matching T20/T25/T26's "one pluggable function" precedent. ---- const pendingMockCheckouts = new Map(); // token -> {userId, tierKey, expiresAt} const MOCK_CHECKOUT_TTL_MS = 30 * 60 * 1000; // Both the mock path (GET /api/credits/mock-checkout) and the real path // (POST /api/credits/webhook) end up here — one place that actually moves // money, so there's exactly one code path responsible for the // ledger/idempotency bookkeeping (topup()'s paymentRef dedup, db.js). function completeTopup({ userId, tierKey, paymentRef }) { const tier = pricing.get(tierKey); if (!tier || tier.kind !== "topup_tier" || !tier.isActive) { throw new Error(`unknown or inactive topup tier: ${tierKey}`); } return creditLedger.topup({ userId, amount: tier.credits, reason: "topup", paymentRef }); } // Real Stripe path: plain REST via global fetch, no stripe npm SDK — this // project has no package.json/dependencies, and Stripe's Checkout Sessions // API is documented as plain form-encoded REST (same "use fetch, not a new // dependency" call as forwardToH3Assets in uploads.js). Untested against a // real Stripe account as of this commit — no test-mode key exists yet, see // dev-plan-4.md §1.2 item 4. async function createRealStripeCheckoutSession({ userId, tierKey, tier }) { const params = new URLSearchParams({ mode: "payment", "line_items[0][quantity]": "1", "line_items[0][price_data][currency]": "usd", "line_items[0][price_data][unit_amount]": String(tier.priceCents), "line_items[0][price_data][product_data][name]": `${tier.credits} credits`, success_url: `${APP_BASE_URL}/dance.html?topup=success`, cancel_url: `${APP_BASE_URL}/dance.html?topup=cancelled`, "metadata[userId]": userId, "metadata[tierKey]": tierKey, }); const res = await fetch("https://api.stripe.com/v1/checkout/sessions", { method: "POST", headers: { Authorization: `Bearer ${STRIPE_SECRET_KEY}`, "Content-Type": "application/x-www-form-urlencoded" }, body: params, }); const session = await res.json(); if (!res.ok) throw new Error(session.error?.message || "Stripe checkout session creation failed"); return session.url; } // Mock path: no real payment happens, but the whole checkout -> redirect -> // credited loop is real and testable today — T29's UI and topup()'s own // dedup logic don't need to wait on a real Stripe key to be built and // exercised end to end. function createMockCheckoutUrl({ userId, tierKey }) { const token = crypto.randomUUID(); pendingMockCheckouts.set(token, { userId, tierKey, expiresAt: Date.now() + MOCK_CHECKOUT_TTL_MS }); return `/api/credits/mock-checkout?token=${token}`; } async function createCheckoutSession({ userId, tierKey }) { const tier = pricing.get(tierKey); if (!tier || tier.kind !== "topup_tier" || !tier.isActive) { throw new Error(`unknown or inactive topup tier: ${tierKey}`); } if (!STRIPE_SECRET_KEY) return { url: createMockCheckoutUrl({ userId, tierKey }) }; return { url: await createRealStripeCheckoutSession({ userId, tierKey, tier }) }; } // Stripe's documented signature scheme (Stripe-Signature header: // "t=,v1="): HMAC-SHA256 over "." // with the webhook signing secret, compared in constant time. Hand-rolled // rather than the stripe SDK for the same "no new dependency" reason as // the rest of this integration. function verifyStripeWebhookSignature(rawBody, signatureHeader, secret) { if (!signatureHeader) return false; const parts = Object.fromEntries( signatureHeader .split(",") .map((kv) => kv.split("=")) .filter((kv) => kv.length === 2) ); if (!parts.t || !parts.v1) return false; const expected = crypto.createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex"); let expectedBuf, actualBuf; try { expectedBuf = Buffer.from(expected, "hex"); actualBuf = Buffer.from(parts.v1, "hex"); } catch { return false; } return expectedBuf.length === actualBuf.length && crypto.timingSafeEqual(expectedBuf, actualBuf); } // Distilled from docs/h3skill.md (MiniMax's own H3 prompt-writing methodology) down to the one // path that applies here: T2VA (no reference images — every chat prompt is plain text) on a clip // fixed at ~10s (H3_GEN_PARAMS.frames / FPS). At that length it's one continuous shot, never a // multi-cut montage — an earlier version asked for "shot-by-shot" and reliably got 4-5 fake cuts // with timestamps running past the clip's own length, which the model can't actually deliver. const PROMPT_OPTIMIZE_SYSTEM = `You are writing H3 text-to-video generation prompts (T2VA mode — no reference images, pure text-to-video). Expand the user's short request (may be in Chinese or English) into the official H3 three-field format, in this exact order, using these exact field labels: integrated_multimodal_description: [Shot 1] one short style statement, e.g. "Live-action, cinematic." Then describe, in one continuous unbroken shot (the clip is only about 10 seconds long — never split into multiple shots or write more than [Shot 1]): the setting, the subject's specific action, and ONE dominant camera movement using this vocabulary — Zoom In/Out, Push In/Pull Out, Pan Left/Right, Truck Left/Right, Tilt Up/Down, Pedestal Up/Down, Arc Shot, Tracking Shot, Static Shot, Shake Slightly/ Strongly, POV, Roll Clockwise/Counterclockwise — stated as type + amplitude (small/large) + speed (slow/fast). Describe light concretely: where it comes from, what it hits, what effect it creates. Never use hollow quality words alone (8K, HD, masterpiece, stunning, cinematic used alone, beautiful, high quality) — replace them with what is actually visible. 60-100 English words. overall_soundscape: 1-4 English sentences describing the ambient environmental sound the scene would naturally have. non_diegetic_music: 1-3 English sentences describing the score the audience (not the characters) hears, or exactly "N/A" if none fits. Output only these three labeled fields in this exact order and exact labels, nothing else — no preamble, no extra commentary, no markdown formatting.`; // Best-effort — a viewer's short chat line ("一只猫跳舞") makes for a weak // H3 prompt on its own. Never let a GLM outage or slow response block // generation: on any failure this just falls back to the original text. async function optimizePrompt(userPrompt) { if (!GLM_API_KEY) return userPrompt; try { const res = await glmChat([ { role: "system", content: PROMPT_OPTIMIZE_SYSTEM }, { role: "user", content: userPrompt }, ]); const text = res.choices && res.choices[0] && res.choices[0].message && res.choices[0].message.content; return text ? text.trim() : userPrompt; } catch { return userPrompt; } } // ---- Dance video QC (2026-09): AI-assisted check that a generated dance // clip actually shows the right garment on a consistent character from // first frame to last. Reuses glmChat() — confirmed live (2026-09-07) that // the coding-plan endpoint/glm-5.3-flash model accepts multiple image_url // content parts and correctly distinguishes them, so no new vision vendor // is needed. ffmpeg extracts frames locally, same "shell out to a system // media tool" pattern uploads.js already uses for sips. function imageDataUrl(absPath) { const ext = path.extname(absPath).slice(1).toLowerCase(); const mime = ext === "jpg" ? "jpeg" : ext; return `data:image/${mime};base64,${fs.readFileSync(absPath).toString("base64")}`; } // Reference photos (character/garment uploads) can be several MB at full // resolution — 5 of those in one GLM request is what pushed the very first // QC call past a 15s timeout. Downscales via `sips` (same tool // stripMetadataAndSave already shells out to) before encoding; falls back // to the original file if sips fails for any reason rather than blocking // the whole check on a resize hiccup. function resizedImageDataUrl(absPath, maxDim) { const tmpPath = path.join(os.tmpdir(), `qc-resize-${crypto.randomUUID()}${path.extname(absPath) || ".jpg"}`); try { execFileSync("sips", ["-Z", String(maxDim), absPath, "--out", tmpPath], { stdio: "pipe" }); return imageDataUrl(tmpPath); } catch { return imageDataUrl(absPath); } finally { fs.rm(tmpPath, { force: true }, () => {}); } } // Goes through this clip's own /lib/video/:id route rather than duplicating // its source dispatch (manual/fal videoUrl redirect vs H3 Console proxy) — // whatever a viewer's