// 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"; // 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}`; 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, forwardToH3Assets, } = require("./uploads.js"); const { characters, garments, danceRefs, generationJobs, moderationLog } = 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)); } 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. const DANCE_STATE_PATH = path.join(__dirname, "dance-state.json"); function loadDanceState() { try { const s = JSON.parse(fs.readFileSync(DANCE_STATE_PATH, "utf8")); return { danceSchedule: s.schedule || [], danceQueue: s.freeQueue || [] }; } catch { return { danceSchedule: [], danceQueue: [] }; } } function saveDanceState() { fs.writeFileSync(DANCE_STATE_PATH, JSON.stringify({ schedule: danceSchedule, freeQueue: danceQueue }, 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); }); } // 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"); } // 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 }; } // 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 } = 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(); }); } // ---- 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; } } // ---- 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; // dance.html's own generation params (architecture.md 2.4: each channel // keeps an independent H3_GEN_PARAMS even though the values happen to // match live.html's today — don't merge the two constants). const DANCE_GEN_PARAMS = { width: 480, height: 832, frames: 240, steps: 20, turbo: false, seed: 0 }; 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}] const initialDanceState = loadDanceState(); let danceSchedule = initialDanceState.danceSchedule; // [{videoId, startAt, endAt, requestedBy?}] let danceQueue = initialDanceState.danceQueue; // [{id, garmentId, characterId, nickname?, status, jobId?, videoId?, error?, submittedAt, votes}] // How often ensureSchedule/ensureDanceSchedule tick — configurable so tests // don't have to wait out a real 2s to see a schedule fill. const SCHEDULE_TICK_MS = Number(process.env.SCHEDULE_TICK_MS) || 2000; // ---- 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; } // Shared by live's promptQueue and dance's freeQueue (T11) — takes the queue // array as a param rather than closing over a module-scope variable so both // channels' independent queues can use it. function pickBestIndexByStatus(queue, status) { let bestIdx = -1; for (let i = 0; i < queue.length; i++) { const q = queue[i]; if (q.status !== status) continue; if (bestIdx === -1) { bestIdx = i; continue; } const best = queue[bestIdx]; if (voteCount(q) > voteCount(best)) bestIdx = i; } return bestIdx; } // Random-fill fallback shared by live's pickNextClip and dance's // pickNextDanceClip (T11) — least-played-first within a cooldown-filtered // pool, so idle filler time rotates evenly instead of a few clips dominating // by chance. `pool` is caller-filtered (live excludes dance channel clips // and vice versa, architecture.md 2.1: independent schedules per channel). function pickLeastPlayedClip(pool, prevId) { if (!pool.length) return null; const notPrev = pool.length > 1 ? pool.filter((c) => c.id !== prevId) : pool; 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 candidates = offCooldown.length ? offCooldown : notPrev; const minPlays = Math.min(...candidates.map((c) => c.playCount || 0)); const leastPlayed = candidates.filter((c) => (c.playCount || 0) === minPlays); return leastPlayed[Math.floor(Math.random() * leastPlayed.length)]; } function pickNextClip() { const readyIdx = pickBestIndexByStatus(promptQueue, "ready"); if (readyIdx !== -1) { const q = promptQueue.splice(readyIdx, 1)[0]; return { clip: q.clip, requestedBy: q.nickname, chatId: q.id }; } const matchedIdx = pickBestIndexByStatus(promptQueue, "matched"); if (matchedIdx !== -1) { const q = promptQueue.splice(matchedIdx, 1)[0]; return { clip: q.clip, requestedBy: q.nickname, chatId: q.id }; } // dance-channel clips (T8) don't belong in live.html's random filler pool // — the two channels keep independent schedules (architecture.md 2.1). const lib = getLibrary().filter((c) => c.channel !== "dance"); if (!lib.length) return null; const prev = schedule.length ? schedule[schedule.length - 1].clip.id : null; const clip = pickLeastPlayedClip(lib, prev); return clip ? { clip, requestedBy: null, chatId: null } : 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, SCHEDULE_TICK_MS); // ---- 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 // dance.html keeps its own viewer count (T17) — the two channels' "在线人数" // must not be summed together, they're separate rooms. const danceViewers = 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(); } } } // ---- Dressup dance room: one-step generation orchestration (T8) ---- // Mirrors generateForPrompt's shape (submit -> persist h3JobId -> poll -> // push a library.json clip on success) but for ref2va instead of plain // text, and persisted in generationJobs (dressup-store.js) instead of // live-state.json's promptQueue — this is an admin/batch-driven pipeline, // not the viewer-facing live queue, so it shouldn't share that store. // Forwards a store record's local upload to H3 Console's /v1/assets // (S4 spike) the first time it's needed, then caches the result on the // record so later generations reusing the same character/garment/dance-ref // don't re-forward the same file (MVCP M.13). async function ensureH3AssetUrl(store, record, localUrlField) { if (record.h3AssetUrl) return record.h3AssetUrl; const absPath = path.join(__dirname, record[localUrlField]); const asset = await forwardToH3Assets(absPath, H3_CONSOLE_BASE, H3_CONSOLE_TOKEN); store.update(record.id, { h3AssetUrl: asset.url }); return asset.url; } function pickRefDanceVideo() { const pool = danceRefs.list().filter((d) => d.isActive); if (pool.length === 0) throw new Error("no active dance-refs available"); return pool[Math.floor(Math.random() * pool.length)]; } // 4.6 节去重缓存:相同 (characterId, garmentId) 组合直接复用已有产物。 function findExistingGeneratedVideo(characterId, garmentId) { return getLibrary().find((c) => c.channel === "dance" && c.characterId === characterId && c.garmentId === garmentId); } // Shared by the single-shot POST /api/admin/generate and the T9 batch // endpoint: validates a (character, garment) pair, returns the cached clip // if M.7's dedupe index already has one, otherwise queues a generationJobs // record and fires generateDanceVideo (fire-and-forget — callers poll // GET /api/admin/generate/:id or /api/admin/batch-gen/status for progress). // Returns {status, body} so route handlers can just write it out. function submitGeneration({ characterId, garmentId, refDanceVideoId, batchId }) { if (!characters.get(characterId) || !garments.get(garmentId)) { return { status: 400, body: { error: "characterId/garmentId must reference existing records" } }; } const existing = findExistingGeneratedVideo(characterId, garmentId); if (existing) return { status: 200, body: { cached: true, clip: existing } }; if (!refDanceVideoId) { try { refDanceVideoId = pickRefDanceVideo().id; } catch (err) { return { status: 400, body: { error: err.message } }; } } const job = generationJobs.create({ characterId, garmentId, refDanceVideoId, batchId: batchId || null, status: "queued", h3JobId: null, error: null }); generateDanceVideo(job).catch(() => {}); return { status: 200, body: { cached: false, jobId: job.id } }; } // job: a generationJobs record ({id, characterId, garmentId, // refDanceVideoId, status, h3JobId, error, fullPrompt}). Mutates it via // generationJobs.update and keeps the local variable in sync (store.js has // no in-memory cache, so every update is a fresh read-modify-write). async function generateDanceVideo(job, opts = {}) { const pollIntervalMs = opts.pollIntervalMs || Number(process.env.GENERATION_POLL_INTERVAL_MS) || 5000; try { const character = characters.get(job.characterId); const garment = garments.get(job.garmentId); const danceRef = danceRefs.get(job.refDanceVideoId); if (!character || !garment || !danceRef) throw new Error("missing character/garment/dance-ref record"); if (!job.h3JobId) { if (!job.fullPrompt) { const built = buildStage2Prompt({ character, garment, danceRef }); job = generationJobs.update(job.id, { fullPrompt: built.prompt }); } const [characterAssetUrl, garmentAssetUrl, danceRefAssetUrl] = await Promise.all([ ensureH3AssetUrl(characters, character, "imageUrl"), ensureH3AssetUrl(garments, garment, "imageUrl"), ensureH3AssetUrl(danceRefs, danceRef, "videoUrl"), ]); const submitted = await h3ConsoleFetch("/v1/videos", { method: "POST", body: { prompt: job.fullPrompt, ...DANCE_GEN_PARAMS, reference_image_urls: [characterAssetUrl, garmentAssetUrl], reference_video_urls: [danceRefAssetUrl], }, }); if (!submitted.id) throw new Error(submitted.detail || "submit failed"); job = generationJobs.update(job.id, { h3JobId: submitted.id, status: "generating" }); } // ponytail: H3's 4 GPUs run one sharded model, not 4 independent // workers (H3-CONSOLE.md /v1/status), so a batch of N jobs is served // ~serially — the Nth job in queue_position can sit "queued" for // N * ~700s before it even starts. A fixed poll-count budget doesn't // know about queue depth, so this is wall-clock and generous instead; // upgrade path if batches grow past a couple dozen: read `eta_seconds`/ // `queue_position` off the poll response and size the deadline to that. const deadline = Date.now() + (opts.timeoutMs || Number(process.env.GENERATION_TIMEOUT_MS) || 2 * 60 * 60 * 1000); while (Date.now() < deadline) { await new Promise((r) => setTimeout(r, pollIntervalMs)); let polled; try { polled = await h3ConsoleFetch(`/v1/videos/${job.h3JobId}`); } catch { // A single bad poll (H3 Console hiccup — non-JSON 500 page, dropped // connection) isn't a generation failure; the job itself is still // fine on H3's side. Skip this tick and retry on the next one, // bounded by the same deadline as everything else. Confirmed live // 2026-09-02: a 20-job batch's concurrent polling briefly 500'd H3 // Console, and every job got marked "failed" over a transient // response even though H3 still had them queued/running. continue; } if (polled.deleted_at) throw new Error("job was deleted on the H3 console before it finished"); if (polled.status === "done") { const clip = { id: polled.id, source: "h3console", // ingest.js's syncLibrary() only carries an entry forward as-is // when `edited` is true — otherwise it rebuilds the record from // the raw H3 job on its next periodic sync, which knows nothing // about channel/characterId/garmentId/refDanceVideoId and would // silently strip them (and leak this clip into the non-dance // pickNextClip() pool, since `channel` would be gone too). edited: true, channel: "dance", characterId: job.characterId, garmentId: job.garmentId, refDanceVideoId: job.refDanceVideoId, backend: "h3_console", priority: "normal", requestedByUserId: null, prompt: `${character.name} × ${garment.name}`, fullPrompt: job.fullPrompt, width: polled.req.width, height: polled.req.height, duration: Math.round((polled.req.frames / FPS) * 10) / 10, partition: polled.partition, likes: 0, createdAt: Date.now(), }; const lib = getLibrary(); lib.push(clip); saveLibrary(lib); generationJobs.update(job.id, { status: "done", generatedVideoId: clip.id }); return clip; } if (polled.status === "error" || polled.status === "failed" || polled.status === "cancelled") { throw new Error(polled.error || `generation ${polled.status}`); } } throw new Error("generation timed out"); } catch (err) { generationJobs.update(job.id, { status: "failed", error: err.message }); throw err; } } // Startup recovery, same idea as resumeGenerations() but for generationJobs. function resumeDressupGenerations() { for (const job of generationJobs.list()) { if (job.status !== "generating" && job.status !== "queued") continue; if (job.h3JobId) generateDanceVideo(job).catch(() => {}); else generationJobs.update(job.id, { status: "failed", error: "lost track of this generation across a restart" }); } } // ---- dance.html: independent playout schedule + free (mode-一/衣柜) queue (T11) ---- // Mirrors live's pickNextClip/ensureSchedule shape but pulls from // danceQueue/danceSchedule instead, and picks its random-fill pool from // channel:"dance" clips only. function pickNextDanceClip() { const readyIdx = pickBestIndexByStatus(danceQueue, "ready"); if (readyIdx !== -1) { const q = danceQueue.splice(readyIdx, 1)[0]; return { videoId: q.videoId, requestedBy: q.nickname }; } const pool = getLibrary().filter((c) => c.channel === "dance"); if (!pool.length) return null; const prevVideoId = danceSchedule.length ? danceSchedule[danceSchedule.length - 1].videoId : null; const clip = pickLeastPlayedClip(pool, prevVideoId); return clip ? { videoId: clip.id, requestedBy: null } : null; } function ensureDanceSchedule() { const now = Date.now(); // Reconcile in-flight picks against generationJobs — the store, not an // in-memory promise, is the source of truth (T8's resumeDressupGenerations // already re-polls H3 for jobs still running after a restart; this tick // just needs to notice when they land, restart or not). for (const q of danceQueue) { if (q.status !== "generating" || !q.jobId) continue; const job = generationJobs.get(q.jobId); if (!job) continue; if (job.status === "done") { q.status = "ready"; q.videoId = job.generatedVideoId; } else if (job.status === "failed") { q.status = "failed"; q.error = job.error; q.failedAt = now; // T27's paid path (userId/amountPaid only set there — free mode-一 // picks have neither): refund on generation failure, per "共同后置 // 流程"'s stated floor. Guarded by an existing-refund lookup rather // than trusting this branch only ever runs once — it normally does // (danceQueue entries only transition "generating" -> "failed" a // single time, this loop's own `q.status !== "generating"` guard // above prevents re-entry), but a crash between this write and // saveDanceState() persisting it could in principle replay the same // transition once after restart, and a duplicate refund is real // money, not a cosmetic bug. if (q.userId && q.amountPaid) { const alreadyRefunded = creditLedger.findByRelatedAssetAndReason(q.garmentId, "refund_stage2_fail").length > 0; if (!alreadyRefunded) { try { creditLedger.refund({ userId: q.userId, amount: q.amountPaid, reason: "refund_stage2_fail", relatedAssetId: q.garmentId }); } catch (err) { // NO_SUCH_USER (deleted account) is the only realistic failure // here — refund only ever adds, so it can't hit // INSUFFICIENT_BALANCE. This branch won't be revisited (the // guard above only enters on "generating" -> "failed", a // one-time transition), so this is a genuine miss, not a // deferred retry — logged rather than silently dropped. console.error(`[refund_stage2_fail] failed to refund userId=${q.userId} garmentId=${q.garmentId}: ${err.message}`); } } } } } while (danceSchedule.length > 1 && danceSchedule[1].endAt <= now) danceSchedule.shift(); while (danceSchedule.length === 0 || danceSchedule[danceSchedule.length - 1].endAt < now + FILLER_DURATION_MS) { const picked = pickNextDanceClip(); if (!picked || !picked.videoId) break; const clip = getLibrary().find((c) => c.id === picked.videoId); if (!clip) break; // stale/removed videoId — guard rather than schedule a dead entry markPlayed(clip.id); const startAt = danceSchedule.length ? danceSchedule[danceSchedule.length - 1].endAt : now; danceSchedule.push({ videoId: picked.videoId, startAt, endAt: startAt + clip.duration * 1000, requestedBy: picked.requestedBy }); } danceQueue = danceQueue.filter((q) => q.status !== "failed" || now - q.failedAt < FAILED_ENTRY_TTL_MS); for (const [cid, lastSeen] of danceViewers) if (now - lastSeen > VIEWER_TTL_MS) danceViewers.delete(cid); saveDanceState(); } setInterval(ensureDanceSchedule, SCHEDULE_TICK_MS); 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; } // Viewer account auth (dev-plan-3.md T19) — entirely separate from the // /api/admin/* gate right below. Registering/logging in is always // optional: /api/dance/* keeps working on the plain viewer_uid cookie // with no session at all (see requireUserSession's doc comment). if (pathname === "/api/auth/register" && req.method === "POST") { readJsonBody(req) .then((body) => { const email = (body.email || "").trim().toLowerCase(); const password = body.password || ""; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid email" })); return; } if (password.length < 8) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "password must be at least 8 characters" })); return; } // T21: an existing anonymous viewer_uid/viewer_nickname cookie // (set client-side by live.html/dance.html, plaintext, never // trusted for anything sensitive) gets carried onto the new // account — history isn't rewritten, but future votes/plays from // this browser stay attributed to the same identity. const existingCookies = parseCookies(req); let user; try { user = userStore.create({ email, passwordHash: hashPassword(password), nickname: existingCookies.viewer_nickname || null, legacyUid: existingCookies.viewer_uid || null, }); } catch (err) { if (err.code === "EMAIL_TAKEN") { res.writeHead(409, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "email already registered" })); return; } throw err; } const session = userSessions.create({ userId: user.id, kind: "session", expiresAt: Date.now() + USER_SESSION_TTL_MS }); // T20: fire off the verification email. sendVerificationEmail is // the entire integration seam — see its own comment. const verifySession = userSessions.create({ userId: user.id, kind: "email_verify", expiresAt: Date.now() + EMAIL_VERIFY_TTL_MS }); const devVerifyUrl = sendVerificationEmail(user.email, verifySession.token); const responseBody = { id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname }; if (process.env.NODE_ENV !== "production") responseBody.devVerifyUrl = devVerifyUrl; res.writeHead(200, { "Content-Type": "application/json", "Set-Cookie": `${USER_SESSION_COOKIE}=${session.token}; HttpOnly; Path=/; Max-Age=${Math.floor(USER_SESSION_TTL_MS / 1000)}; SameSite=Lax`, }); res.end(JSON.stringify(responseBody)); }) .catch(() => { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid JSON body" })); }); return; } if (pathname === "/api/auth/login" && req.method === "POST") { readJsonBody(req) .then((body) => { const email = (body.email || "").trim().toLowerCase(); const password = body.password || ""; const user = userStore.findByEmail(email); // Deliberately the same error for "no such email" and "wrong // password" — distinguishing the two lets an attacker enumerate // which emails are registered. if (!user || !verifyPassword(password, user.passwordHash)) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid email or password" })); return; } const session = userSessions.create({ userId: user.id, kind: "session", expiresAt: Date.now() + USER_SESSION_TTL_MS }); const setCookies = [`${USER_SESSION_COOKIE}=${session.token}; HttpOnly; Path=/; Max-Age=${Math.floor(USER_SESSION_TTL_MS / 1000)}; SameSite=Lax`]; // T21: push the account's bound identity back onto this browser, // even if it showed up with no viewer_uid cookie at all or a // fresh one from a different device — otherwise logging in from // a clean browser would silently start a new anonymous identity, // which for anything vote-limited defeats the point of binding // it to an account in the first place. if (user.legacyUid) setCookies.push(viewerIdentityCookie("viewer_uid", user.legacyUid)); if (user.nickname) setCookies.push(viewerIdentityCookie("viewer_nickname", user.nickname)); res.writeHead(200, { "Content-Type": "application/json", "Set-Cookie": setCookies }); res.end(JSON.stringify({ id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname })); }) .catch(() => { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid JSON body" })); }); return; } // T20: single-use — the email_verify session is revoked on success, so // a second visit to the same link (or an expired one) hits the same // "invalid or expired" error rather than silently re-succeeding. if (pathname === "/api/auth/verify" && req.method === "GET") { const params = new URLSearchParams(req.url.split("?")[1] || ""); const token = params.get("token") || ""; const verifySession = userSessions.findByToken(token); if (!verifySession || verifySession.kind !== "email_verify") { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid or expired verification link" })); return; } userStore.update(verifySession.userId, { emailVerified: true }); userSessions.revoke(token); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true })); return; } if (pathname === "/api/auth/logout" && req.method === "POST") { const token = parseCookies(req)[USER_SESSION_COOKIE]; if (token) userSessions.revoke(token); res.writeHead(200, { "Content-Type": "application/json", "Set-Cookie": `${USER_SESSION_COOKIE}=; HttpOnly; Path=/; Max-Age=0`, }); res.end(JSON.stringify({ ok: true })); return; } // Debug-only endpoint, per T19's own acceptance criteria — fine to // keep past this task too (a real account page will want it), but // intentionally minimal for now. if (pathname === "/api/auth/me" && req.method === "GET") { const user = requireUserSession(req); if (!user) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unauthorized" })); return; } res.writeHead(200, { "Content-Type": "application/json" }); // creditBalance riding along here (T29) rather than a dedicated // /api/credits/balance endpoint — it's the same "logged-in user's own // state" this route already exists to serve, and refreshAuthState() // in live.html/dance.html (T22) already polls this after every login. res.end(JSON.stringify({ id: user.id, email: user.email, emailVerified: user.emailVerified, nickname: user.nickname, creditBalance: user.creditBalance })); return; } // T24: credit topup. Login + emailVerified matches M.10's "任何扣积分/ // 写用户资产的接口" rule — a topup adds money rather than spending it, // but still needs an accountable identity before a real charge runs. if (pathname === "/api/credits/checkout" && req.method === "POST") { const user = requireUserSession(req); if (!user) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "login required" })); return; } if (!user.emailVerified) { res.writeHead(403, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "email verification required before purchasing credits" })); return; } readJsonBody(req) .then(async (body) => { try { const session = await createCheckoutSession({ userId: user.id, tierKey: (body.tierKey || "").trim() }); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(session)); } catch (err) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: err.message })); } }) .catch(() => { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid JSON body" })); }); return; } // Dev-only completion of a mock checkout created above — simulates the // browser landing back from Stripe's hosted page after a successful // payment. Only ever reachable via a token this same process handed // out (createMockCheckoutUrl), so there's nothing to authenticate here // beyond "does this token exist and is it unused." if (pathname === "/api/credits/mock-checkout" && req.method === "GET") { const token = new URL(req.url, "http://x").searchParams.get("token") || ""; const pending = pendingMockCheckouts.get(token); pendingMockCheckouts.delete(token); // single-use, same spirit as T20's email-verify tokens if (!pending || pending.expiresAt < Date.now()) { res.writeHead(400, { "Content-Type": "text/plain" }); res.end("invalid or expired mock checkout token"); return; } try { completeTopup({ userId: pending.userId, tierKey: pending.tierKey, paymentRef: `mock_${token}` }); res.writeHead(302, { Location: "/dance.html?topup=success" }); res.end(); } catch (err) { res.writeHead(400, { "Content-Type": "text/plain" }); res.end(err.message); } return; } // Real Stripe webhook — only meaningful once a real // STRIPE_WEBHOOK_SECRET is configured (tomorrow, per the user). // Refuses anything it can't verify rather than trusting an // unsigned/unverifiable request, even before a secret exists // (dev-plan-4.md T24's explicit acceptance criterion). if (pathname === "/api/credits/webhook" && req.method === "POST") { readRawTextBody(req) .then((rawBody) => { if (!STRIPE_WEBHOOK_SECRET || !verifyStripeWebhookSignature(rawBody, req.headers["stripe-signature"], STRIPE_WEBHOOK_SECRET)) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "signature verification failed" })); return; } let event; try { event = JSON.parse(rawBody); } catch { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid JSON" })); return; } if (event.type === "checkout.session.completed") { const session = event.data.object; try { // event.id, not session.id: Stripe's at-least-once // redelivery resends the same event id for the same // occurrence, which is exactly what topup()'s paymentRef // dedup (db.js) keys on. completeTopup({ userId: session.metadata.userId, tierKey: session.metadata.tierKey, paymentRef: event.id }); } catch (err) { // Already-applied duplicates are handled inside topup() // itself (returns the existing row, doesn't throw) — a throw // here means something genuinely went wrong (unknown tier, // missing user), not a benign redelivery. res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: err.message })); return; } } res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ received: true })); }) .catch(() => { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "failed to read request body" })); }); 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; // T30: manual balance correction, e.g. a support gesture ("sorry about // the failed generation that didn't auto-refund, here's some credits") // or clawing back an abused promo. Always attributable — see // adminActorUserId's doc comment. if (pathname === "/api/admin/credit/adjust" && req.method === "POST") { readJsonBody(req) .then((body) => { const userId = (body.userId || "").trim(); const delta = Number(body.delta); if (!userId || !userStore.get(userId) || !Number.isFinite(delta) || delta === 0) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "userId (must reference an existing user) and a non-zero numeric delta are required" })); return; } try { const row = creditLedger.adjust({ userId, delta, reason: (body.reason || "admin_adjust").trim(), actorUserId: adminActorUserId(req) }); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(row)); } catch (err) { const status = err.code === "INSUFFICIENT_BALANCE" ? 400 : 500; res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: err.message })); } }) .catch(() => { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid JSON body" })); }); return; } if (pathname === "/api/admin/generate" && req.method === "GET") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(generationJobs.list())); return; } if (pathname === "/api/admin/generate" && req.method === "POST") { readJsonBody(req) .then((body) => { const { status, body: respBody } = submitGeneration(body); res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(respBody)); }) .catch(() => { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid JSON body" })); }); return; } if (pathname.startsWith("/api/admin/generate/") && req.method === "GET") { const job = generationJobs.get(pathname.slice("/api/admin/generate/".length)); if (!job) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "not found" })); return; } res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(job)); return; } // T9 冷启动批量生成:{characterId, garmentIds[], refDanceVideoIds?[]}. // No refDanceVideoIds given -> one job per garment, each with its own // random active dance-ref (same as the single-generate default). // refDanceVideoIds given -> full garmentIds x refDanceVideoIds cross // product, so an operator can pin specific reference dances. // Every combo goes through submitGeneration, so the M.7 dedupe cache // (same characterId+garmentId already generated) is honored per combo, // not just per batch. if (pathname === "/api/admin/batch-gen" && req.method === "POST") { readJsonBody(req) .then((body) => { const { characterId, garmentIds, refDanceVideoIds } = body; if (!characters.get(characterId) || !Array.isArray(garmentIds) || garmentIds.length === 0) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "characterId must exist and garmentIds must be a non-empty array" })); return; } const batchId = crypto.randomUUID(); const refCombos = Array.isArray(refDanceVideoIds) && refDanceVideoIds.length > 0 ? refDanceVideoIds : [undefined]; const results = []; for (const garmentId of garmentIds) { for (const refDanceVideoId of refCombos) { const { status, body: respBody } = submitGeneration({ characterId, garmentId, refDanceVideoId, batchId }); results.push({ garmentId, refDanceVideoId: refDanceVideoId || null, status, ...respBody }); } } res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ batchId, results })); }) .catch(() => { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid JSON body" })); }); return; } if (pathname === "/api/admin/batch-gen/status" && req.method === "GET") { const batchId = new URL(req.url, "http://x").searchParams.get("batchId"); if (!batchId) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "batchId query param required" })); return; } const jobs = generationJobs.list().filter((j) => j.batchId === batchId); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(jobs)); return; } // ---- dance.html: public viewer-facing endpoints (T11/T12/T15/T17) ---- // No admin auth (T6 only gates /api/admin/*) — these are the free, // anonymous, 模式一 (衣柜选衣服) path. 模式二 (付费传衣服) is T27, // below, and does require login+emailVerified on its own. 模式三 isn't // built yet (第五期). if (pathname === "/api/dance/wardrobe" && req.method === "GET") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(garments.list().filter((g) => g.isActive))); return; } // T28/T29 need pricing (spend_garment's cost, topup_tier options) on // the client before any purchase decision — public/unauthenticated on // purpose, same reasoning as GET /api/dance/wardrobe above. if (pathname === "/api/pricing" && req.method === "GET") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(pricing.list().filter((p) => p.isActive))); return; } if (pathname === "/api/dance/live/state" && req.method === "GET") { ensureDanceSchedule(); 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) danceViewers.set(cid, now); let idx = danceSchedule.findIndex((s) => s.startAt <= now && now < s.endAt); if (idx === -1) idx = danceSchedule.length - 1; const cur = danceSchedule[idx]; const lib = getLibrary(); const joinClip = (videoId) => lib.find((c) => c.id === videoId) || null; res.writeHead(200, { "Content-Type": "application/json" }); res.end( JSON.stringify({ now, watching: danceViewers.size, current: cur ? { clip: joinClip(cur.videoId), startAt: cur.startAt, endAt: cur.endAt, requestedBy: cur.requestedBy || null } : null, upNext: danceSchedule.slice(idx + 1, idx + 4).map((s) => ({ ...(joinClip(s.videoId) || {}), requestedBy: s.requestedBy || null })), queue: danceQueue .filter((q) => ["generating", "failed"].includes(q.status)) .slice() .sort((a, b) => voteCount(b) - voteCount(a)) .map((q) => ({ id: q.id, garmentId: q.garmentId, nickname: q.nickname, status: q.status, error: q.error, votes: voteCount(q), voted: uid ? (q.votes || []).includes(uid) : false, })), }) ); return; } // P.1 免费模式一:{garmentId, nickname?}. Uses the single配置的直播默认 // 人物 (characters.isDefault) — 3.1 节: 免费用户不能指定/上传人物。 // Reuses submitGeneration (T8/T9) whole — same M.7 dedupe cache — then // hands the result to danceQueue for ensureDanceSchedule to pick up. if (pathname === "/api/dance/pick" && req.method === "POST") { readJsonBody(req) .then((body) => { const garment = garments.get(body.garmentId); if (!garment || !garment.isActive) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "garmentId must reference an active garment" })); return; } const defaultCharacter = characters.list().find((c) => c.isDefault); if (!defaultCharacter) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "no default character configured" })); return; } const { status, body: respBody } = submitGeneration({ characterId: defaultCharacter.id, garmentId: garment.id }); if (status !== 200) { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(respBody)); return; } const entry = { id: crypto.randomUUID(), garmentId: garment.id, characterId: defaultCharacter.id, nickname: (body.nickname || "").trim() || null, submittedAt: Date.now(), votes: [], }; if (respBody.cached) { entry.status = "ready"; entry.videoId = respBody.clip.id; } else { entry.status = "generating"; entry.jobId = respBody.jobId; } danceQueue.push(entry); saveDanceState(); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: entry.status, id: entry.id, videoId: entry.videoId || null })); }) .catch(() => { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "invalid JSON body" })); }); return; } // T27 模式二: 付费传衣服. 需登录 + emailVerified (M.10). Order matters — // moderation runs before any garments record or spend exists (a // rejected upload leaves nothing behind); the garment record is // created before spend so the ledger row can carry relatedAssetId, and // is rolled back (garments.remove) if the spend or the generation // submission itself fails, so a failed/rejected attempt never leaves a // phantom garment in the library. Async generation failures (after // this handler has already responded) are refunded from // ensureDanceSchedule's tick instead — see its own comment. if (pathname === "/api/dance/upload-garment" && req.method === "POST") { const user = requireUserSession(req); if (!user) { res.writeHead(401, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "login required" })); return; } if (!user.emailVerified) { res.writeHead(403, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "email verification required before uploading a garment" })); return; } 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(async (raw) => { const filePart = parseMultipart(raw, 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; } const defaultCharacter = characters.list().find((c) => c.isDefault); if (!defaultCharacter) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "no default character configured" })); return; } let relPath, absPath; try { ({ relPath } = stripMetadataAndSave(filePart.data, sniffed)); absPath = path.join(__dirname, relPath); } catch { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "failed to process image" })); return; } const moderation = await moderateGarmentImage(absPath, relPath); if (!moderation.approved) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "image rejected by moderation", reason: moderation.reason })); return; } const appearanceDescriptor = await describeGarmentImage(absPath); const garment = garments.create({ name: `${user.nickname || user.email.split("@")[0]} 上传`, category: "", thumbnailUrl: relPath, imageUrl: relPath, appearanceDescriptor, source: "user_paid", ownerUserId: user.id, moderationStatus: "approved", isActive: true, }); const spendGarment = pricing.get("spend_garment"); const amount = spendGarment ? spendGarment.credits : 0; let spendRow; try { spendRow = creditLedger.spend({ userId: user.id, amount, reason: "spend_garment", relatedAssetId: garment.id }); } catch (err) { garments.remove(garment.id); const status = err.code === "INSUFFICIENT_BALANCE" ? 402 : 500; res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: err.message })); return; } const { status, body: respBody } = submitGeneration({ characterId: defaultCharacter.id, garmentId: garment.id }); if (status !== 200) { creditLedger.refund({ userId: user.id, amount, reason: "refund_stage2_fail", relatedAssetId: garment.id }); garments.remove(garment.id); res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(respBody)); return; } const entry = { id: crypto.randomUUID(), garmentId: garment.id, characterId: defaultCharacter.id, nickname: user.nickname || null, userId: user.id, amountPaid: amount, submittedAt: Date.now(), votes: [], }; if (respBody.cached) { entry.status = "ready"; entry.videoId = respBody.clip.id; } else { entry.status = "generating"; entry.jobId = respBody.jobId; } danceQueue.push(entry); saveDanceState(); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ status: entry.status, id: entry.id, garmentId: garment.id, videoId: entry.videoId || null, creditsSpent: amount, balanceAfter: spendRow.balanceAfter })); }) .catch((err) => { if (res.writableEnded) return; const status = err.code === "PAYLOAD_TOO_LARGE" ? 413 : 400; res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: err.message || "upload failed" })); }); return; } if (pathname.startsWith("/api/dance/queue/vote/") && req.method === "POST") { const id = pathname.slice("/api/dance/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 = danceQueue.find((q) => q.id === id); if (!entry || !["generating", "ready", "failed"].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); saveDanceState(); 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 === "/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(), // channel: "dance" puts this clip in dance.html's pool // (pickNextClip filters on it, architecture.md 2.1); anything // else stays out of it and is live.html's/text-channel pool. channel: body.channel === "dance" ? "dance" : undefined, }; 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(); } if (body.channel === "dance" || body.channel === "video") clip.channel = body.channel === "dance" ? "dance" : undefined; // 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(); resumeDressupGenerations(); // 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);