// 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}`; // T33 (dev-plan-5.md): fal.ai real-time compute for paid requests (需求文档 // 4.3 节双算力策略) — no key configured yet, same situation T24 was in with // Stripe. See generateDanceVideoViaFal()'s doc comment for what's verified // vs. still unverified (§8 item 1 / dev-plan-5.md §1.2 item 3: whether the // one-step attribute_transfer prompt that works on H3 Console also works on // fal's model — that needs a real call, not something code can assert). const FAL_API_KEY = process.env.FAL_API_KEY || ""; // Overridable so tests can point these at a local mock (same reasoning as // H3_CONSOLE_BASE) — default to fal's real, documented hosts. const FAL_REST_BASE = process.env.FAL_REST_BASE || "https://rest.alpha.fal.ai"; const FAL_QUEUE_BASE = process.env.FAL_QUEUE_BASE || "https://queue.fal.run"; const FAL_MODEL_ID = "minimax/h3/reference-to-video"; 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, forwardToFalAssets, } = 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 }; } // T32 (dev-plan-5.md): same seam pattern as T26 above, but for mode 3's // uploaded face photos against a "sensitive figure" blacklist (需求文档 5 // 节 — e.g. filtering politically sensitive likenesses), not general image // content. No vendor has been picked (needs research, dev-plan-5.md §1.2 // item 1). Default policy when unconfigured is default-permissive // (auto_pass) — deliberately confirmed with the user rather than assumed, // since the risk profile here (a real uploaded face, potentially a // sensitive public figure) is not the same as T26's garment photos, where // a wrong default-pass is low-stakes. Do not silently change this default // without asking again. const FACE_BLACKLIST_API_KEY = process.env.FACE_BLACKLIST_API_KEY || ""; async function moderateFaceBlacklist(absImagePath, assetId) { let verdict, reason; if (!FACE_BLACKLIST_API_KEY) { verdict = "auto_pass"; reason = null; } else { throw new Error("face-blacklist vendor not implemented — set FACE_BLACKLIST_API_KEY only once a provider is wired up"); } moderationLog.create({ assetType: "character_upload", assetId, verdict, reason, reviewedBy: "system" }); return { approved: verdict === "auto_pass", verdict, reason }; } // T21: mirrors live.html/dance.html's own setCookie() exactly (path=/, // samesite=lax, no httponly, max-age in seconds, 365-day TTL) — these are // the same viewer_uid/viewer_nickname cookies those pages already read // with plain client-side JS, not a new cookie scheme. function viewerIdentityCookie(name, value) { return `${name}=${encodeURIComponent(value)}; Max-Age=${365 * 86400}; Path=/; SameSite=Lax`; } function hashPassword(password) { const salt = crypto.randomBytes(16).toString("hex"); const hash = crypto.scryptSync(password, salt, 64).toString("hex"); return `${salt}:${hash}`; } function verifyPassword(password, stored) { const [salt, hash] = (stored || "").split(":"); if (!salt || !hash) return false; const candidate = crypto.scryptSync(password, salt, 64); const expected = Buffer.from(hash, "hex"); // scryptSync's output length is fixed by the third argument above, so // both buffers are always the same length — timingSafeEqual would throw // otherwise, but that can't happen here. return crypto.timingSafeEqual(candidate, expected); } // Returns the logged-in user (plain object, MVCP M.1 shape) or null. Free // endpoints (/api/dance/*) don't call this at all — only routes that // actually need an account (T20's email verify, T21's identity binding, // anything paid in period 4) do. function requireUserSession(req) { const token = parseCookies(req)[USER_SESSION_COOKIE]; if (!token) return null; const session = userSessions.findByToken(token); if (!session || session.kind !== "session") return null; return userStore.get(session.userId); } // Generic CRUD router for the three cold-start asset libraries (T3-T5) — // GET list / POST create / PATCH update / DELETE, backed by store.js. The // three entities' shapes differ (see MVCP M.3/M.4/M.5) but the routing and // validation boilerplate is identical, so this is mounted three times below // instead of copy-pasted. function mountCrud(prefix, store, opts = {}) { const { requiredFields = [], sanitize = (b) => b, onCreate, onUpdate } = 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; } // Same caching idea as ensureH3AssetUrl, separate field (fal is a // completely different storage service, not interchangeable with // h3AssetUrl — M.13's note that "S4 spike only solved the H3 Console side"). async function ensureFalAssetUrl(store, record, localUrlField) { if (record.falAssetUrl) return record.falAssetUrl; const absPath = path.join(__dirname, record[localUrlField]); const asset = await forwardToFalAssets(absPath, FAL_API_KEY, FAL_REST_BASE); store.update(record.id, { falAssetUrl: asset.url }); return asset.url; } // T33: submit + poll + fetch-result against fal's queue API (verified // contract, see FAL_MODEL_ID's doc comment above and forwardToFalAssets in // uploads.js — POST {queueHost}/{model}, poll .../requests/{id}/status, // fetch .../requests/{id}). Kept separate from h3ConsoleFetch's shape // (single-poll-returns-everything) since fal's status/result are two // different endpoints, not one. async function falSubmit(input) { const res = await fetch(`${FAL_QUEUE_BASE}/${FAL_MODEL_ID}`, { method: "POST", headers: { Authorization: `Key ${FAL_API_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify(input), }); const body = await res.json(); if (!res.ok || !body.request_id) throw new Error(body.detail || body.error || `fal submit failed: ${res.status}`); return body; // { request_id, status_url, response_url, ... } } async function falPollStatus(requestId) { const res = await fetch(`${FAL_QUEUE_BASE}/${FAL_MODEL_ID}/requests/${requestId}/status`, { headers: { Authorization: `Key ${FAL_API_KEY}` }, }); return res.json(); // { status: "IN_QUEUE"|"IN_PROGRESS"|"COMPLETED"|... } } async function falFetchResult(requestId) { const res = await fetch(`${FAL_QUEUE_BASE}/${FAL_MODEL_ID}/requests/${requestId}`, { headers: { Authorization: `Key ${FAL_API_KEY}` }, }); const body = await res.json(); if (!res.ok || !body.video || !body.video.url) throw new Error(body.detail || body.error || "fal result missing video.url"); return body; // { video: { url, ... }, expanded_prompt } } 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. // userId/priority (T33/T34, dev-plan-5.md): a free mode-一 pick passes // neither (backend stays H3 Console, priority "normal"); T27 (mode 2) // passes userId only (still "normal" — 需求文档 3 节: mode 2 stays in the // vote-ranked queue, only mode 3 skips it); T35 (mode 3, once built) passes // both userId and priority:"paid_boost". function submitGeneration({ characterId, garmentId, refDanceVideoId, batchId, userId, priority }) { 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 } }; } } // T33: paid requests (userId set) route to fal's real-time compute once // FAL_API_KEY is configured (需求文档 4.3 节双算力策略); everything else // — free mode-一, or a paid request before a key exists — stays on the // H3 Console path that's been working since T8. const backend = userId && FAL_API_KEY ? "fal" : "h3_console"; const job = generationJobs.create({ characterId, garmentId, refDanceVideoId, batchId: batchId || null, status: "queued", h3JobId: null, falRequestId: null, error: null, backend, userId: userId || null, priority: priority || "normal", }); generateDanceVideo(job).catch(() => {}); return { status: 200, body: { cached: false, jobId: job.id } }; } // Dispatches on job.backend (T33) — H3 Console and fal.ai have genuinely // different submit/poll/result shapes (see generateDanceVideoViaFal's doc // comment), not just a different header, so this stays two implementations // behind one entry point rather than one branchy function. async function generateDanceVideo(job, opts = {}) { return job.backend === "fal" ? generateDanceVideoViaFal(job, opts) : generateDanceVideoViaH3(job, opts); } // job: a generationJobs record ({id, characterId, garmentId, // refDanceVideoId, status, h3JobId, error, fullPrompt, backend, userId, // priority}). 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 generateDanceVideoViaH3(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: job.priority || "normal", requestedByUserId: job.userId || 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; } } // T33: fal.ai's queue API — submit -> poll .../status -> once COMPLETED, // fetch .../requests/{id} for the actual result (fal splits status and // result into two endpoints; H3 Console's single poll returns both at // once, see generateDanceVideoViaH3 above — genuinely different shapes, // not just a header swap). // // NOT VERIFIED against a real fal account as of this commit — no // FAL_API_KEY has ever been configured (dev-plan-5.md §1.2 item 2). The // endpoints/params below come from fal's published docs and the fal-js // client's own source (see forwardToFalAssets in uploads.js for // citations), not guesswork — but whether the exact prompt text // buildStage2Prompt produces (written for and verified against H3 // Console's /