diff --git a/.gitignore b/.gitignore index 722460d..c2bbe26 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ # runtime state, regenerated by the server, not source live-state.json +dance-state.json # scratch server logs, if any get written locally *.log diff --git a/dance-channel.test.js b/dance-channel.test.js new file mode 100644 index 0000000..21b91e8 --- /dev/null +++ b/dance-channel.test.js @@ -0,0 +1,247 @@ +// Integration tests for dance.html's backend (T11 独立播出状态机, T12 +// GET /api/dance/wardrobe, T15 POST /api/dance/pick, T17 点赞/在线人数). +// Same mock-H3-Console approach as dressup-generate.test.js so the full +// submit -> generate -> land-in-schedule path runs offline and fast. +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); +const http = require("node:http"); +const { spawn } = require("node:child_process"); + +const PORT = 22934 + Math.floor(Math.random() * 1000); +const BASE = `http://localhost:${PORT}`; +const ADMIN_PASSWORD = "test-password-only-for-this-run"; +let cookie; +let child; +let cwd; +let mockH3; +let mockH3Port; +let videoPollCounts; + +function waitForServer(url, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + return (async function poll() { + while (Date.now() < deadline) { + try { + await fetch(url); + return; + } catch { + await new Promise((r) => setTimeout(r, 100)); + } + } + throw new Error("server did not come up in time"); + })(); +} + +function authed(opts = {}) { + return { ...opts, headers: { "Content-Type": "application/json", Cookie: cookie, ...opts.headers } }; +} + +// Same shape as dressup-generate.test.js's mock — enough of /v1/assets, +// /v1/videos and /v1/videos/:id for generateDanceVideo to run end to end. +function startMockH3() { + videoPollCounts = new Map(); + let nextId = 1; + return new Promise((resolve) => { + mockH3 = http.createServer((req, res) => { + req.url = req.url.replace(/^\/+/, "/"); + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + if (req.method === "POST" && req.url === "/v1/assets") { + const id = `asset-${nextId++}`; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ id, url: `http://mock-h3/asset/${id}`, kind: "image" })); + return; + } + if (req.method === "POST" && req.url === "/v1/videos") { + const id = `job-${nextId++}`; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ id })); + return; + } + const pollMatch = req.method === "GET" && req.url.match(/^\/v1\/videos\/(.+)$/); + if (pollMatch) { + const id = pollMatch[1]; + const seen = (videoPollCounts.get(id) || 0) + 1; + videoPollCounts.set(id, seen); + if (seen < 2) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ id, status: "generating" })); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ id, status: "done", partition: "ref2va", req: { width: 480, height: 832, frames: 240 } })); + return; + } + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "not found in mock" })); + }); + }); + mockH3.listen(0, () => { + mockH3Port = mockH3.address().port; + resolve(); + }); + }); +} + +test.before(async () => { + await startMockH3(); + + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-channel-")); + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) { + fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); + } + fs.mkdirSync(path.join(cwd, "uploads"), { recursive: true }); + fs.writeFileSync(path.join(cwd, "uploads", "person.png"), "fake-png-bytes"); + fs.writeFileSync(path.join(cwd, "uploads", "garment.png"), "fake-png-bytes"); + fs.writeFileSync(path.join(cwd, "uploads", "ref.mp4"), "fake-mp4-bytes"); + + child = spawn(process.execPath, ["server.js"], { + cwd, + env: { + ...process.env, + PORT: String(PORT), + H3_CONSOLE_BASE: `http://localhost:${mockH3Port}`, + H3_CONSOLE_TOKEN: "mock-token", + ADMIN_PASSWORD, + GENERATION_POLL_INTERVAL_MS: "400", + SCHEDULE_TICK_MS: "150", + }, + stdio: "pipe", + }); + await waitForServer(BASE); + + const loginRes = await fetch(`${BASE}/api/admin/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: ADMIN_PASSWORD }), + }); + cookie = (loginRes.headers.get("set-cookie") || "").split(";")[0]; +}); + +test.after(() => { + child.kill(); + mockH3.close(); + fs.rmSync(cwd, { recursive: true, force: true }); +}); + +async function waitUntil(fn, timeoutMs = 8000, intervalMs = 100) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const v = await fn(); + if (v) return v; + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error("condition not met in time"); +} + +let defaultCharacter, activeGarment, inactiveGarment, danceRef; + +test("seed: default character + active/inactive garments + dance-ref", async () => { + defaultCharacter = await fetch( + `${BASE}/api/admin/characters`, + authed({ method: "POST", body: JSON.stringify({ name: "衣柜小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair", isDefault: true }) }) + ).then((r) => r.json()); + assert.equal(defaultCharacter.isDefault, true); + + activeGarment = await fetch( + `${BASE}/api/admin/garments`, + authed({ method: "POST", body: JSON.stringify({ name: "上架红裙", imageUrl: "/uploads/garment.png", appearanceDescriptor: "red dress", isActive: true }) }) + ).then((r) => r.json()); + inactiveGarment = await fetch( + `${BASE}/api/admin/garments`, + authed({ method: "POST", body: JSON.stringify({ name: "下架旗袍", imageUrl: "/uploads/garment.png", appearanceDescriptor: "qipao", isActive: false }) }) + ).then((r) => r.json()); + + danceRef = await fetch( + `${BASE}/api/admin/dance-refs`, + authed({ method: "POST", body: JSON.stringify({ name: "衣柜拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) }) + ).then((r) => r.json()); + assert.ok(danceRef.id); +}); + +test("T12 GET /api/dance/wardrobe: only active garments, no admin auth needed", async () => { + const res = await fetch(`${BASE}/api/dance/wardrobe`); // deliberately no cookie + assert.equal(res.status, 200); + const list = await res.json(); + assert.ok(list.some((g) => g.id === activeGarment.id)); + assert.ok(!list.some((g) => g.id === inactiveGarment.id)); +}); + +test("T15 POST /api/dance/pick: rejects unknown/inactive garmentId", async () => { + const missing = await fetch(`${BASE}/api/dance/pick`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ garmentId: "nope" }) }); + assert.equal(missing.status, 400); + + const inactive = await fetch(`${BASE}/api/dance/pick`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ garmentId: inactiveGarment.id }), + }); + assert.equal(inactive.status, 400); +}); + +test("T15/T11 pick a garment (no login/cookie needed) -> generates -> lands in dance schedule; T17 vote + viewer count along the way", async () => { + const pickRes = await fetch(`${BASE}/api/dance/pick`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ garmentId: activeGarment.id }), + }); + assert.equal(pickRes.status, 200); + const picked = await pickRes.json(); + assert.equal(picked.status, "generating"); + assert.ok(picked.id); + + // While it's still generating, it should show up in the public queue and + // accept a vote (T17) — separate viewer/vote namespace from live.html. + const stateWhileGenerating = await fetch(`${BASE}/api/dance/live/state?cid=test-cid-1`).then((r) => r.json()); + assert.ok(stateWhileGenerating.queue.some((q) => q.id === picked.id && q.status === "generating")); + assert.equal(stateWhileGenerating.watching, 1); + + const voteRes = await fetch(`${BASE}/api/dance/queue/vote/${picked.id}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ uid: "viewer-1" }), + }); + assert.equal(voteRes.status, 200); + const vote = await voteRes.json(); + assert.equal(vote.voted, true); + assert.equal(vote.votes, 1); + + // Eventually the mock generation finishes and ensureDanceSchedule sweeps + // it into the schedule — current or upNext should carry the right + // character/garment traceability, and the entry should drop out of queue. + const finalState = await waitUntil(async () => { + const state = await fetch(`${BASE}/api/dance/live/state?cid=test-cid-1`).then((r) => r.json()); + const inQueue = state.queue.some((q) => q.id === picked.id); + const landed = (state.current && state.current.clip && state.current.clip.garmentId === activeGarment.id) || state.upNext.some((c) => c.garmentId === activeGarment.id); + return !inQueue && landed ? state : null; + }); + const landedClip = finalState.current && finalState.current.clip && finalState.current.clip.garmentId === activeGarment.id ? finalState.current.clip : finalState.upNext.find((c) => c.garmentId === activeGarment.id); + assert.equal(landedClip.characterId, defaultCharacter.id); + assert.equal(landedClip.refDanceVideoId, danceRef.id); + assert.equal(landedClip.channel, "dance"); +}); + +test("T15 dedupe: re-picking the same garment returns ready immediately (M.7 cache hit), no new H3 submission", async () => { + const before = videoPollCounts.size; + const res = await fetch(`${BASE}/api/dance/pick`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ garmentId: activeGarment.id }), + }); + assert.equal(res.status, 200); + const data = await res.json(); + assert.equal(data.status, "ready"); + assert.ok(data.videoId); + assert.equal(videoPollCounts.size, before); // no new /v1/videos/:id job started +}); + +test("T17: live.html and dance.html viewer counts are independent", async () => { + await fetch(`${BASE}/api/live/state?cid=live-viewer-a`); + await fetch(`${BASE}/api/live/state?cid=live-viewer-b`); + const danceState = await fetch(`${BASE}/api/dance/live/state?cid=dance-viewer-only`).then((r) => r.json()); + assert.equal(danceState.watching, 2); // test-cid-1 (still within TTL) + dance-viewer-only, not the two live viewers +}); diff --git a/dance.css b/dance.css new file mode 100644 index 0000000..b46b870 --- /dev/null +++ b/dance.css @@ -0,0 +1,55 @@ +/* dance.html deltas on top of live.css — same comic-panel visual language, + loaded after live.css so these rules win. Only what dance.html actually + needs differently: a boxed 9:16 player (T13) and the wardrobe grid (T16). + Everything else (topbar, clip-row, qrow/up-btn, nickname modal, mobile + sub-tabs) is reused as-is from live.css. */ + +/* dance.html's video is a fixed 480x832 (9:16) product, not the arbitrary + aspect ratio live.html's text-prompt clips can end up at — box it to that + ratio and center it instead of letting it stretch edge-to-edge on a wide + desktop viewport. */ +.player-wrap { + aspect-ratio: 9 / 16; + max-width: 100%; + max-height: 100%; + margin: auto; + width: auto; + height: 100%; +} +@media (max-width: 768px) { + .player-wrap { height: auto; width: 100%; } +} + +.wardrobe-tabs { display: flex; gap: 8px; padding: 0 2px 4px; } +.wardrobe-tabs button { + flex: 1; + font: bold 12px Tahoma, sans-serif; + background: #fff; + border: 3px solid #000; + color: var(--ink); + border-radius: 12px; + padding: 7px 4px; + box-shadow: var(--shadow-sm); + cursor: pointer; +} +.wardrobe-tabs button.active { background: var(--accent); } + +.wardrobe-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; } +.wardrobe-card { + color: var(--ink); + background: #fff; + border: 3px solid #000; + border-radius: 14px; + padding: 6px; + box-shadow: var(--shadow); + cursor: pointer; + text-align: center; +} +.wardrobe-card:active { transform: translate(2px, 2px); box-shadow: 2px 2px 0 #000; } +.wardrobe-card img { width: 100%; height: 90px; object-fit: cover; border-radius: 8px; margin-bottom: 4px; display: block; } +.wardrobe-card .name { font-size: 12px; font-weight: bold; line-height: 1.3; } +.wardrobe-card.disabled { opacity: .5; cursor: default; } + +.coming-soon { color: var(--muted); text-align: center; margin-top: 40px; font-size: 13px; font-weight: bold; padding: 0 12px; } + +.age-box .age-disclaimer { font-size: 12px; line-height: 1.6; color: #444; font-weight: normal; } diff --git a/dance.html b/dance.html new file mode 100644 index 0000000..a1756bd --- /dev/null +++ b/dance.html @@ -0,0 +1,390 @@ + + + + + +换装跳舞直播间 + + + + + + + +
+
+

换装跳舞直播间

+
+ + + AI 短剧频道 → +
+ +
+ + +
+
+
直播中
+ + +
+
+ + + +
+ + +
+
+
+ + + + + + + + + diff --git a/live.html b/live.html index b180ba6..aea244d 100644 --- a/live.html +++ b/live.html @@ -22,6 +22,7 @@ + 换装跳舞 →
diff --git a/server.js b/server.js index d0bc773..96ac41f 100644 --- a/server.js +++ b/server.js @@ -81,6 +81,25 @@ 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 = ""; @@ -398,6 +417,14 @@ 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 @@ -484,46 +511,59 @@ function markPlayed(id) { function voteCount(q) { return (q.votes || []).length; } -function pickBestIndexByStatus(status) { +// 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 < promptQueue.length; i++) { - const q = promptQueue[i]; + 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 = promptQueue[bestIdx]; + 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("ready"); + 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("matched"); + 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 }; } - const lib = getLibrary(); + // 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 notPrev = lib.length > 1 ? lib.filter((c) => c.id !== prev) : lib; - const now = Date.now(); - // Never-played clips (no lastPlayedAt) are always eligible — genuinely - // random among everything that isn't on cooldown, no bias toward "fresh". - const offCooldown = notPrev.filter((c) => !c.lastPlayedAt || now - c.lastPlayedAt > REPEAT_COOLDOWN_MS); - const pool = offCooldown.length ? offCooldown : notPrev; - // Within that pool, only randomize among the least-played clips — keeps - // rotation even instead of letting a few clips dominate airtime by chance. - const minPlays = Math.min(...pool.map((c) => c.playCount || 0)); - const leastPlayed = pool.filter((c) => (c.playCount || 0) === minPlays); - return { clip: leastPlayed[Math.floor(Math.random() * leastPlayed.length)], requestedBy: null, chatId: null }; + const 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 @@ -545,11 +585,14 @@ function ensureSchedule() { for (const [cid, lastSeen] of viewers) if (now - lastSeen > VIEWER_TTL_MS) viewers.delete(cid); saveLiveState(); } -setInterval(ensureSchedule, 2000); +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; @@ -776,6 +819,62 @@ function resumeDressupGenerations() { } } +// ---- 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; + } + } + + 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/")) { @@ -926,6 +1025,138 @@ http 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. Modes 二/三 (paid upload) aren't + // built yet (require accounts/credits, dev-plan-2.md 第三/四期). + + 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; + } + + 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; + } + + 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();