// 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 crypto = require("crypto"); 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"; 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, } = require("./uploads.js"); const { characters, garments, danceRefs } = require("./dressup-store.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)); } 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)); } 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); }); } // 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; } function isAdminAuthed(req) { const token = parseCookies(req)[ADMIN_SESSION_COOKIE]; if (!token) return false; const expiresAt = adminSessions.get(token); if (!expiresAt || expiresAt < Date.now()) { adminSessions.delete(token); return false; } return true; } // 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 } = 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 removed = store.remove(id); 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: unsetOtherDefaultCharacters, onUpdate: unsetOtherDefaultCharacters, }); 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), }, }); 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(); }); } function glmChat(messages) { 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(15000, () => req.destroy(new Error("GLM request timed out"))); req.write(body); req.end(); }); } // 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; } } // ---- Live channel: shared virtual playhead + prompt queue ---- // Persisted to live-state.json (see saveLiveState/loadLiveState) so a // restart resumes the schedule instead of starting cold; ensureSchedule's // own past-entry trimming naturally handles a state file that's gone stale // (server was down long enough that every persisted entry is in the past). const FILLER_DURATION_MS = 30000; // keep the schedule filled at least this far ahead const H3_GEN_PARAMS = { width: 480, height: 832, frames: 240, steps: 20, turbo: false, seed: 0 }; const FPS = 24; const initialLiveState = loadLiveState(); let schedule = initialLiveState.schedule; // [{clip, startAt, endAt, requestedBy?}] let promptQueue = initialLiveState.promptQueue; // [{id, prompt, nickname?, status, clip?, error?, submittedAt, h3JobId?}] let chatMessages = initialLiveState.chatMessages; // [{id, uid, nickname, text, createdAt, status}] // ---- chat + moderation ---- // ponytail: a starter blocklist, not a real content-policy list — swap in // whatever the actual moderation requirements are. Substring match on the // lowercased text; cheap and deterministic. Anything this doesn't catch // (content that's fine as *text* but unsafe to *generate*) falls through to // the H3 console's own generation-time safety checker — a rejected/failed // job there just surfaces as a normal "failed" queue entry. const BANNED_WORDS = ["色情", "porn", "自杀", "血腥暴力", "毒品"]; function moderateText(text) { const lower = text.toLowerCase(); const hit = BANNED_WORDS.find((w) => lower.includes(w)); if (hit) return { ok: false, reason: `包含违禁词` }; return { ok: true }; } const CHAT_HISTORY_LIMIT = 300; function pushChatMessage(msg) { chatMessages.push(msg); if (chatMessages.length > CHAT_HISTORY_LIMIT) chatMessages = chatMessages.slice(-CHAT_HISTORY_LIMIT); } function updateChatStatus(id, status) { const msg = chatMessages.find((m) => m.id === id); if (msg) msg.status = status; } // Shared by /api/chat (after moderation) and /api/prompt (direct API use, // e.g. testing) — looks up a library match or kicks off generation, exactly // like before chat existed. `nickname` is optional (empty for /api/prompt). function submitPromptToQueue(id, prompt, nickname) { const entry = { id, prompt, nickname: nickname || null, submittedAt: Date.now(), votes: [] }; const match = findLibraryMatch(normalizePrompt(prompt)); if (match) { entry.status = "matched"; entry.clip = match; promptQueue.push(entry); return { status: "matched", clip: match }; } entry.status = "generating"; promptQueue.push(entry); generateForPrompt(entry); // fire and forget — polled via /api/live/state return { status: "generating", id: entry.id }; } function normalizePrompt(s) { return s.trim().toLowerCase().replace(/\s+/g, " "); } function findLibraryMatch(promptNorm) { return getLibrary().find((c) => { const zh = normalizePrompt(c.prompt); const en = c.promptEn ? normalizePrompt(c.promptEn) : null; return ( zh === promptNorm || zh.includes(promptNorm) || promptNorm.includes(zh) || (en && (en === promptNorm || en.includes(promptNorm) || promptNorm.includes(en))) ); }); } // Idle fallback pick avoids anything played within this window, so a clip // that just aired doesn't come back around too soon. Falls back to ignoring // the cooldown (keeping only the immediate-repeat guard) if literally // everything is on cooldown, rather than ever stalling the schedule. const REPEAT_COOLDOWN_MS = 20 * 60 * 1000; function markPlayed(id) { const lib = getLibrary(); const clip = lib.find((c) => c.id === id); if (!clip) return; clip.lastPlayedAt = Date.now(); clip.playCount = (clip.playCount || 0) + 1; saveLibrary(lib); } // Consumed entries are spliced out immediately rather than left as // status:"scheduled" — once a clip is in `schedule` it shows up via // current/upNext, so keeping a second, never-cleared copy in promptQueue // just grows forever and duplicates the same info. // Returns {clip, requestedBy, chatId} — chatId lets the caller flip the // originating chat message to "scheduled" once it's actually about to air. // Among entries of the same status, the most-upvoted prompt goes first // (ties broken by submission order) — lets chat bump a favorite to the // front of the line instead of strict FIFO. function voteCount(q) { return (q.votes || []).length; } function pickBestIndexByStatus(status) { let bestIdx = -1; for (let i = 0; i < promptQueue.length; i++) { const q = promptQueue[i]; if (q.status !== status) continue; if (bestIdx === -1) { bestIdx = i; continue; } const best = promptQueue[bestIdx]; if (voteCount(q) > voteCount(best)) bestIdx = i; } return bestIdx; } function pickNextClip() { const readyIdx = pickBestIndexByStatus("ready"); if (readyIdx !== -1) { const q = promptQueue.splice(readyIdx, 1)[0]; return { clip: q.clip, requestedBy: q.nickname, chatId: q.id }; } const matchedIdx = pickBestIndexByStatus("matched"); if (matchedIdx !== -1) { const q = promptQueue.splice(matchedIdx, 1)[0]; return { clip: q.clip, requestedBy: q.nickname, chatId: q.id }; } const lib = getLibrary(); if (!lib.length) return null; const prev = schedule.length ? schedule[schedule.length - 1].clip.id : null; const notPrev = lib.length > 1 ? lib.filter((c) => c.id !== prev) : lib; const now = Date.now(); // Never-played clips (no lastPlayedAt) are always eligible — genuinely // random among everything that isn't on cooldown, no bias toward "fresh". const offCooldown = notPrev.filter((c) => !c.lastPlayedAt || now - c.lastPlayedAt > REPEAT_COOLDOWN_MS); const pool = offCooldown.length ? offCooldown : notPrev; // Within that pool, only randomize among the least-played clips — keeps // rotation even instead of letting a few clips dominate airtime by chance. const minPlays = Math.min(...pool.map((c) => c.playCount || 0)); const leastPlayed = pool.filter((c) => (c.playCount || 0) === minPlays); return { clip: leastPlayed[Math.floor(Math.random() * leastPlayed.length)], requestedBy: null, chatId: null }; } const FAILED_ENTRY_TTL_MS = 20000; // how long a "generation failed" entry stays visible before cleanup function ensureSchedule() { const now = Date.now(); while (schedule.length > 1 && schedule[1].endAt <= now) schedule.shift(); while (schedule.length === 0 || schedule[schedule.length - 1].endAt < now + FILLER_DURATION_MS) { const picked = pickNextClip(); if (!picked || !picked.clip) break; const { clip, requestedBy, chatId } = picked; markPlayed(clip.id); if (chatId) updateChatStatus(chatId, "scheduled"); const startAt = schedule.length ? schedule[schedule.length - 1].endAt : now; schedule.push({ clip, startAt, endAt: startAt + clip.duration * 1000, requestedBy }); } promptQueue = promptQueue.filter((q) => q.status !== "failed" || now - q.failedAt < FAILED_ENTRY_TTL_MS); for (const [cid, lastSeen] of viewers) if (now - lastSeen > VIEWER_TTL_MS) viewers.delete(cid); saveLiveState(); } setInterval(ensureSchedule, 2000); // ---- viewer count: anyone whose /api/live/state poll landed in the last VIEWER_TTL_MS ---- const VIEWER_TTL_MS = 12000; const viewers = new Map(); // clientId -> lastSeenAt // ---- rate limiting: cheap per-IP throttle on prompt submission ---- const RATE_LIMIT_MAX = 3; const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000; const rateLimitHits = new Map(); // ip -> [timestamps] function isRateLimited(ip) { const now = Date.now(); const hits = (rateLimitHits.get(ip) || []).filter((t) => now - t < RATE_LIMIT_WINDOW_MS); hits.push(now); rateLimitHits.set(ip, hits); return hits.length > RATE_LIMIT_MAX; } // Runs in the background; never blocks the live schedule or the POST /api/prompt response. // `entry.h3JobId` set (and persisted via saveLiveState) as soon as we have it, // so a server restart mid-generation can resume polling instead of losing // track of the job (see resumeGenerations() at startup). async function generateForPrompt(entry) { try { if (!entry.h3JobId) { if (!entry.expandedPrompt) { entry.expandedPrompt = await optimizePrompt(entry.prompt); saveLiveState(); } const submitted = await h3ConsoleFetch("/v1/videos", { method: "POST", body: { prompt: entry.expandedPrompt, ...H3_GEN_PARAMS }, }); if (!submitted.id) throw new Error(submitted.detail || "submit failed"); entry.h3JobId = submitted.id; saveLiveState(); } for (let i = 0; i < 120; i++) { await new Promise((r) => setTimeout(r, 5000)); const job = await h3ConsoleFetch(`/v1/videos/${entry.h3JobId}`); if (job.deleted_at) throw new Error("job was deleted on the H3 console before it finished"); if (job.status === "done") { const clip = { id: job.id, source: "h3console", prompt: entry.prompt, fullPrompt: entry.expandedPrompt || entry.prompt, width: job.req.width, height: job.req.height, duration: Math.round((job.req.frames / FPS) * 10) / 10, partition: job.partition, likes: 0, createdAt: Date.now(), }; const lib = getLibrary(); lib.push(clip); saveLibrary(lib); entry.status = "ready"; entry.clip = clip; updateChatStatus(entry.id, "ready"); saveLiveState(); return; } if (job.status === "error" || job.status === "failed") throw new Error(job.error || "generation failed"); } throw new Error("generation timed out"); } catch (err) { entry.status = "failed"; entry.error = err.message; entry.failedAt = Date.now(); updateChatStatus(entry.id, "failed"); saveLiveState(); } } // Startup recovery: resume polling any generation that was mid-flight when // the process last exited. Anything genuinely orphaned (no h3JobId — should // never happen given the flow above) just gets dropped as failed. function resumeGenerations() { for (const entry of promptQueue) { if (entry.status !== "generating") continue; if (entry.h3JobId) generateForPrompt(entry); else { entry.status = "failed"; entry.error = "lost track of this generation across a restart"; entry.failedAt = Date.now(); } } } http .createServer((req, res) => { if (req.url.startsWith("/fal/")) { const proxyReq = https.request( { hostname: FAL_HOST, path: req.url.slice(4), method: req.method, headers: { ...req.headers, host: FAL_HOST }, }, (proxyRes) => { res.writeHead(proxyRes.statusCode, proxyRes.headers); proxyRes.pipe(res); } ); proxyReq.on("error", (err) => { res.writeHead(502, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: { message: err.message } })); }); req.pipe(proxyReq); return; } const pathname = req.url.split("?")[0]; if (pathname === "/api/admin/login" && req.method === "POST") { readJsonBody(req) .then((body) => { if (!ADMIN_PASSWORD || body.password !== ADMIN_PASSWORD) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid password" })); return; } const token = crypto.randomUUID(); adminSessions.set(token, Date.now() + ADMIN_SESSION_TTL_MS); res.writeHead(200, { "Content-Type": "application/json", "Set-Cookie": `${ADMIN_SESSION_COOKIE}=${token}; HttpOnly; Path=/; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; SameSite=Lax`, }); res.end(JSON.stringify({ ok: true })); }) .catch(() => { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid JSON body" })); }); return; } if (pathname === "/api/admin/logout" && req.method === "POST") { const token = parseCookies(req)[ADMIN_SESSION_COOKIE]; if (token) adminSessions.delete(token); res.writeHead(200, { "Content-Type": "application/json", "Set-Cookie": `${ADMIN_SESSION_COOKIE}=; HttpOnly; Path=/; Max-Age=0`, }); res.end(JSON.stringify({ ok: true })); return; } if (pathname.startsWith("/api/admin/") && !isAdminAuthed(req)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; } if (charactersCrud(pathname, req, res)) return; if (garmentsCrud(pathname, req, res)) return; if (danceRefsCrud(pathname, req, res)) return; if (pathname === "/api/library") { const sort = new URL(req.url, "http://x").searchParams.get("sort") || "newest"; const lib = getLibrary().slice(); const sorters = { newest: (a, b) => b.createdAt - a.createdAt, oldest: (a, b) => a.createdAt - b.createdAt, top: (a, b) => (b.likes || 0) - (a.likes || 0) || b.createdAt - a.createdAt, }; lib.sort(sorters[sort] || sorters.newest); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(lib)); return; } if (pathname === "/api/live/state") { ensureSchedule(); const now = Date.now(); const params = new URL(req.url, "http://x").searchParams; const cid = params.get("cid"); const uid = params.get("uid") || null; if (cid) viewers.set(cid, now); let idx = schedule.findIndex((s) => s.startAt <= now && now < s.endAt); if (idx === -1) idx = schedule.length - 1; const cur = schedule[idx]; res.writeHead(200, { "Content-Type": "application/json" }); res.end( JSON.stringify({ now, watching: viewers.size, current: cur ? { clip: cur.clip, startAt: cur.startAt, endAt: cur.endAt, requestedBy: cur.requestedBy || null } : null, upNext: schedule.slice(idx + 1, idx + 4).map((s) => ({ ...s.clip, requestedBy: s.requestedBy || null })), queue: promptQueue .filter((q) => ["matched", "generating", "failed"].includes(q.status)) .slice() .sort((a, b) => voteCount(b) - voteCount(a)) .map((q) => ({ id: q.id, prompt: q.prompt, nickname: q.nickname, status: q.status, error: q.error, votes: voteCount(q), voted: uid ? (q.votes || []).includes(uid) : false, })), }) ); return; } if (pathname === "/api/prompt" && req.method === "POST") { if (isRateLimited(req.socket.remoteAddress)) { res.writeHead(429, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: `最多每 ${RATE_LIMIT_WINDOW_MS / 60000} 分钟提交 ${RATE_LIMIT_MAX} 次,请稍后再试` })); return; } readJsonBody(req) .then((body) => { const prompt = (body.prompt || "").trim(); if (!prompt || prompt.length > 300) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "prompt required, max 300 chars" })); return; } const result = submitPromptToQueue(Date.now().toString(36), prompt, null); saveLiveState(); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(result)); }) .catch(() => { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid JSON body" })); }); return; } if (pathname === "/api/chat" && req.method === "GET") { const cid = new URL(req.url, "http://x").searchParams.get("cid"); if (cid) viewers.set(cid, Date.now()); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(chatMessages.slice(-100))); return; } if (pathname === "/api/chat" && req.method === "POST") { if (isRateLimited(req.socket.remoteAddress)) { res.writeHead(429, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: `最多每 ${RATE_LIMIT_WINDOW_MS / 60000} 分钟发送 ${RATE_LIMIT_MAX} 条,请稍后再试` })); return; } readJsonBody(req) .then((body) => { const uid = (body.uid || "").trim(); const nickname = (body.nickname || "").trim().slice(0, 20); const text = (body.text || "").trim(); if (!uid || !nickname || !text || text.length > 300) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "uid, nickname, text required, text max 300 chars" })); return; } const id = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; const msg = { id, uid, nickname, text, createdAt: Date.now() }; const mod = moderateText(text); if (!mod.ok) { msg.status = "rejected"; msg.rejectReason = mod.reason; pushChatMessage(msg); saveLiveState(); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: "rejected", reason: mod.reason })); return; } msg.status = "approved"; pushChatMessage(msg); const result = submitPromptToQueue(id, text, nickname); msg.status = result.status; // "matched" | "generating" saveLiveState(); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(result)); }) .catch(() => { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid JSON body" })); }); return; } if (pathname === "/api/admin/library" && req.method === "POST") { readJsonBody(req) .then((body) => { const prompt = (body.prompt || "").trim(); const videoUrl = (body.videoUrl || "").trim(); if (!prompt || !videoUrl) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "prompt and videoUrl required" })); return; } const clip = { id: `manual-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, source: "manual", edited: true, prompt, promptEn: (body.promptEn || "").trim(), fullPrompt: (body.fullPrompt || prompt).trim(), videoUrl, width: body.width || null, height: body.height || null, duration: body.duration || null, partition: "manual", likes: 0, createdAt: Date.now(), }; const lib = getLibrary(); lib.push(clip); saveLibrary(lib); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(clip)); }) .catch(() => { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid JSON body" })); }); return; } if (pathname.startsWith("/api/admin/library/") && req.method === "PATCH") { const id = pathname.slice("/api/admin/library/".length); readJsonBody(req) .then((body) => { const lib = getLibrary(); const clip = lib.find((c) => c.id === id); if (!clip) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "not found" })); return; } for (const field of ["prompt", "promptEn", "fullPrompt", "videoUrl"]) { if (typeof body[field] === "string" && body[field].trim()) clip[field] = body[field].trim(); } // Storing a videoUrl (whether this clip started as h3console or // manual) means /lib/video/:id should serve that directly instead // of proxying the H3 console — see the handler below. if (body.videoUrl) clip.source = "manual"; // Marks this entry as admin-owned so the periodic H3 library sync // (ingest.js's syncLibrary) carries it forward as-is instead of // overwriting it with a freshly re-extracted prompt. clip.edited = true; saveLibrary(lib); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(clip)); }) .catch(() => { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid JSON body" })); }); return; } if (pathname.startsWith("/api/admin/library/") && req.method === "DELETE") { const id = pathname.slice("/api/admin/library/".length); const lib = getLibrary(); const clip = lib.find((c) => c.id === id); if (!clip) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "not found" })); return; } saveLibrary(lib.filter((c) => c.id !== id)); // Without this, the next periodic syncLibrary() would just re-add it — // it's still a "done" job on the H3 console until explicitly deleted there too. const cleanup = clip.source === "h3console" ? h3ConsoleFetch(`/v1/videos/${id}`, { method: "DELETE" }).catch(() => {}) : Promise.resolve(); cleanup.then(() => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ deleted: true })); }); return; } if (pathname.startsWith("/api/queue/vote/") && req.method === "POST") { const id = pathname.slice("/api/queue/vote/".length); readJsonBody(req) .then((body) => { const uid = (body.uid || "").trim(); if (!uid) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "uid required" })); return; } const entry = promptQueue.find((q) => q.id === id); if (!entry || !["matched", "generating", "ready"].includes(entry.status)) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "not found" })); return; } if (!Array.isArray(entry.votes)) entry.votes = []; const i = entry.votes.indexOf(uid); const voted = i === -1; if (voted) entry.votes.push(uid); else entry.votes.splice(i, 1); saveLiveState(); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ votes: entry.votes.length, voted })); }) .catch(() => { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid JSON body" })); }); return; } if (pathname.startsWith("/api/like/") && req.method === "POST") { const id = pathname.slice("/api/like/".length); const lib = getLibrary(); const clip = lib.find((c) => c.id === id); if (!clip) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "not found" })); return; } clip.likes = (clip.likes || 0) + 1; saveLibrary(lib); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ likes: clip.likes })); return; } if (pathname === "/api/admin/upload" && req.method === "POST") { const boundary = boundaryFromContentType(req.headers["content-type"] || ""); if (!boundary) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "expected multipart/form-data" })); return; } readRawBody(req) .then((body) => { const filePart = parseMultipart(body, boundary).find((p) => p.name === "file"); if (!filePart) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "missing file field" })); return; } const sniffed = sniffImageType(filePart.data); if (!sniffed) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unrecognized image type (expected jpeg/png/webp)" })); return; } try { const { relPath } = stripMetadataAndSave(filePart.data, sniffed); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ url: relPath })); } catch { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "failed to process image" })); } }) .catch((err) => { if (res.writableEnded) return; try { const status = err.code === "PAYLOAD_TOO_LARGE" ? 413 : 400; res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: err.message })); } catch { // socket already gone (e.g. destroyed for exceeding the size cap) } }); return; } if (pathname === "/api/admin/upload-video" && req.method === "POST") { const boundary = boundaryFromContentType(req.headers["content-type"] || ""); if (!boundary) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "expected multipart/form-data" })); return; } readRawBody(req, 50 * 1024 * 1024) .then((body) => { const filePart = parseMultipart(body, boundary).find((p) => p.name === "file"); if (!filePart) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "missing file field" })); return; } const sniffed = sniffVideoType(filePart.data); if (!sniffed) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unrecognized video type (expected mp4)" })); return; } const { relPath } = saveVideoUpload(filePart.data, sniffed); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ url: relPath })); }) .catch((err) => { if (res.writableEnded) return; try { const status = err.code === "PAYLOAD_TOO_LARGE" ? 413 : 400; res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: err.message })); } catch { // socket already gone (e.g. destroyed for exceeding the size cap) } }); return; } if (pathname.startsWith("/uploads/")) { const rel = pathname.slice("/uploads/".length); const abs = path.join(UPLOADS_DIR, rel); if (!abs.startsWith(UPLOADS_DIR + path.sep)) { res.writeHead(403); res.end("Forbidden"); return; } fs.readFile(abs, (err, data) => { if (err) { res.writeHead(404); res.end("Not found"); return; } res.writeHead(200, { "Content-Type": MIME[path.extname(abs)] || "application/octet-stream" }); res.end(data); }); return; } if (pathname.startsWith("/lib/video/")) { const id = pathname.slice("/lib/video/".length); const clip = getLibrary().find((c) => c.id === id); if (clip && clip.videoUrl) { res.writeHead(302, { Location: clip.videoUrl }); res.end(); return; } if (!H3_CONSOLE_URL) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "H3_CONSOLE_BASE not configured" })); return; } const upstreamHeaders = { Authorization: `Bearer ${H3_CONSOLE_TOKEN}` }; if (req.headers.range) upstreamHeaders.range = req.headers.range; const proxyReq = http.request( { hostname: H3_CONSOLE_URL.hostname, port: H3_CONSOLE_URL.port || 80, path: `${H3_CONSOLE_URL.pathname}/v1/videos/${id}/file`, method: "GET", headers: upstreamHeaders, }, (proxyRes) => { res.writeHead(proxyRes.statusCode, proxyRes.headers); proxyRes.pipe(res); } ); proxyReq.on("error", (err) => { res.writeHead(502, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: { message: err.message } })); }); proxyReq.end(); return; } const filePath = path.join(__dirname, pathname === "/" ? "/index.html" : pathname); fs.readFile(filePath, (err, data) => { if (err) { res.writeHead(404); res.end("Not found"); return; } res.writeHead(200, { "Content-Type": MIME[path.extname(filePath)] || "application/octet-stream" }); res.end(data); }); }) .listen(PORT, () => console.log(`http://localhost:${PORT}`)); resumeGenerations(); // Auto-sync the library from the H3 Console so content added there directly // (not through our own /api/prompt) shows up without a manual `node ingest.js`. const LIBRARY_SYNC_INTERVAL_MS = 5 * 60 * 1000; syncLibrary() .then((n) => console.log(`library synced — ${n} clips`)) .catch((err) => console.error(`library sync failed: ${err.message}`)); setInterval(() => { syncLibrary().catch((err) => console.error(`library sync failed: ${err.message}`)); }, LIBRARY_SYNC_INTERVAL_MS);