Commit 719fdbb7d5adca663f6e326979207e30e4fe508d

Authored by yibo
1 parent 30e370ca

Add dance.html: independent playout schedule + free wardrobe mode (T11-T17)

Backend (server.js):
- T11: dance.html gets its own schedule/queue state machine, persisted to
  dance-state.json (gitignored, same as live-state.json). Per MVCP M.8,
  the schedule stores videoId references rather than inlined clip
  snapshots like live's does, joining back to library.json at read time.
  Generalized pickBestIndexByStatus/extracted pickLeastPlayedClip so both
  channels' schedulers share the same selection logic instead of forking
  it; live's random-fill pool now excludes channel:"dance" clips (and
  vice versa) so the two channels' filler picks stay independent, which
  wasn't true before this (T8's dance clips could have leaked into
  live.html's filler).
- T12: GET /api/dance/wardrobe — public, unauthenticated list of active
  garments.
- T15: POST /api/dance/pick {garmentId, nickname?} — free path, always
  uses the configured default character, reuses submitGeneration (T8/T9)
  whole including its M.7 dedupe cache, then hands the result to
  danceQueue. In-flight generations are reconciled against generationJobs
  on each schedule tick rather than via a promise chain, so a pick
  request survives a server restart the same way T8's own jobs do.
- T17: separate danceViewers map and POST /api/dance/queue/vote/:id, so
  viewer counts and votes don't mix with live.html's.
- SCHEDULE_TICK_MS env var makes both channels' tick interval
  configurable so tests don't wait out a real 2s.

Frontend:
- dance.html (T13/T14/T16): reuses live.html's visual language via
  live.css, adds dance.css for the 9:16 boxed player and wardrobe grid.
  Left column is a 衣柜/传衣服/传人 tab set (only 衣柜 is wired up —
  the other two need accounts/credits, dev-plan-2.md 第三/四期); right
  column shows the queue with vote buttons instead of live.html's chat.
  Age-confirmation gate (5.1节) blocks the page on first visit via a
  cookie, non-dismissable by clicking the backdrop.

Tests: dance-channel.test.js spins up a mock H3 Console (same approach
as dressup-generate.test.js) to exercise wardrobe listing, pick
rejection/happy-path/dedupe, vote, and independent viewer counts
end to end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
.gitignore
@@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
3 3
4 # runtime state, regenerated by the server, not source 4 # runtime state, regenerated by the server, not source
5 live-state.json 5 live-state.json
  6 +dance-state.json
6 7
7 # scratch server logs, if any get written locally 8 # scratch server logs, if any get written locally
8 *.log 9 *.log
dance-channel.test.js 0 → 100644
  1 +// Integration tests for dance.html's backend (T11 独立播出状态机, T12
  2 +// GET /api/dance/wardrobe, T15 POST /api/dance/pick, T17 点赞/在线人数).
  3 +// Same mock-H3-Console approach as dressup-generate.test.js so the full
  4 +// submit -> generate -> land-in-schedule path runs offline and fast.
  5 +const test = require("node:test");
  6 +const assert = require("node:assert/strict");
  7 +const fs = require("node:fs");
  8 +const path = require("node:path");
  9 +const os = require("node:os");
  10 +const http = require("node:http");
  11 +const { spawn } = require("node:child_process");
  12 +
  13 +const PORT = 22934 + Math.floor(Math.random() * 1000);
  14 +const BASE = `http://localhost:${PORT}`;
  15 +const ADMIN_PASSWORD = "test-password-only-for-this-run";
  16 +let cookie;
  17 +let child;
  18 +let cwd;
  19 +let mockH3;
  20 +let mockH3Port;
  21 +let videoPollCounts;
  22 +
  23 +function waitForServer(url, timeoutMs = 10000) {
  24 + const deadline = Date.now() + timeoutMs;
  25 + return (async function poll() {
  26 + while (Date.now() < deadline) {
  27 + try {
  28 + await fetch(url);
  29 + return;
  30 + } catch {
  31 + await new Promise((r) => setTimeout(r, 100));
  32 + }
  33 + }
  34 + throw new Error("server did not come up in time");
  35 + })();
  36 +}
  37 +
  38 +function authed(opts = {}) {
  39 + return { ...opts, headers: { "Content-Type": "application/json", Cookie: cookie, ...opts.headers } };
  40 +}
  41 +
  42 +// Same shape as dressup-generate.test.js's mock — enough of /v1/assets,
  43 +// /v1/videos and /v1/videos/:id for generateDanceVideo to run end to end.
  44 +function startMockH3() {
  45 + videoPollCounts = new Map();
  46 + let nextId = 1;
  47 + return new Promise((resolve) => {
  48 + mockH3 = http.createServer((req, res) => {
  49 + req.url = req.url.replace(/^\/+/, "/");
  50 + const chunks = [];
  51 + req.on("data", (c) => chunks.push(c));
  52 + req.on("end", () => {
  53 + if (req.method === "POST" && req.url === "/v1/assets") {
  54 + const id = `asset-${nextId++}`;
  55 + res.writeHead(200, { "Content-Type": "application/json" });
  56 + res.end(JSON.stringify({ id, url: `http://mock-h3/asset/${id}`, kind: "image" }));
  57 + return;
  58 + }
  59 + if (req.method === "POST" && req.url === "/v1/videos") {
  60 + const id = `job-${nextId++}`;
  61 + res.writeHead(200, { "Content-Type": "application/json" });
  62 + res.end(JSON.stringify({ id }));
  63 + return;
  64 + }
  65 + const pollMatch = req.method === "GET" && req.url.match(/^\/v1\/videos\/(.+)$/);
  66 + if (pollMatch) {
  67 + const id = pollMatch[1];
  68 + const seen = (videoPollCounts.get(id) || 0) + 1;
  69 + videoPollCounts.set(id, seen);
  70 + if (seen < 2) {
  71 + res.writeHead(200, { "Content-Type": "application/json" });
  72 + res.end(JSON.stringify({ id, status: "generating" }));
  73 + return;
  74 + }
  75 + res.writeHead(200, { "Content-Type": "application/json" });
  76 + res.end(JSON.stringify({ id, status: "done", partition: "ref2va", req: { width: 480, height: 832, frames: 240 } }));
  77 + return;
  78 + }
  79 + res.writeHead(404, { "Content-Type": "application/json" });
  80 + res.end(JSON.stringify({ error: "not found in mock" }));
  81 + });
  82 + });
  83 + mockH3.listen(0, () => {
  84 + mockH3Port = mockH3.address().port;
  85 + resolve();
  86 + });
  87 + });
  88 +}
  89 +
  90 +test.before(async () => {
  91 + await startMockH3();
  92 +
  93 + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-channel-"));
  94 + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) {
  95 + fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
  96 + }
  97 + fs.mkdirSync(path.join(cwd, "uploads"), { recursive: true });
  98 + fs.writeFileSync(path.join(cwd, "uploads", "person.png"), "fake-png-bytes");
  99 + fs.writeFileSync(path.join(cwd, "uploads", "garment.png"), "fake-png-bytes");
  100 + fs.writeFileSync(path.join(cwd, "uploads", "ref.mp4"), "fake-mp4-bytes");
  101 +
  102 + child = spawn(process.execPath, ["server.js"], {
  103 + cwd,
  104 + env: {
  105 + ...process.env,
  106 + PORT: String(PORT),
  107 + H3_CONSOLE_BASE: `http://localhost:${mockH3Port}`,
  108 + H3_CONSOLE_TOKEN: "mock-token",
  109 + ADMIN_PASSWORD,
  110 + GENERATION_POLL_INTERVAL_MS: "400",
  111 + SCHEDULE_TICK_MS: "150",
  112 + },
  113 + stdio: "pipe",
  114 + });
  115 + await waitForServer(BASE);
  116 +
  117 + const loginRes = await fetch(`${BASE}/api/admin/login`, {
  118 + method: "POST",
  119 + headers: { "Content-Type": "application/json" },
  120 + body: JSON.stringify({ password: ADMIN_PASSWORD }),
  121 + });
  122 + cookie = (loginRes.headers.get("set-cookie") || "").split(";")[0];
  123 +});
  124 +
  125 +test.after(() => {
  126 + child.kill();
  127 + mockH3.close();
  128 + fs.rmSync(cwd, { recursive: true, force: true });
  129 +});
  130 +
  131 +async function waitUntil(fn, timeoutMs = 8000, intervalMs = 100) {
  132 + const deadline = Date.now() + timeoutMs;
  133 + while (Date.now() < deadline) {
  134 + const v = await fn();
  135 + if (v) return v;
  136 + await new Promise((r) => setTimeout(r, intervalMs));
  137 + }
  138 + throw new Error("condition not met in time");
  139 +}
  140 +
  141 +let defaultCharacter, activeGarment, inactiveGarment, danceRef;
  142 +
  143 +test("seed: default character + active/inactive garments + dance-ref", async () => {
  144 + defaultCharacter = await fetch(
  145 + `${BASE}/api/admin/characters`,
  146 + authed({ method: "POST", body: JSON.stringify({ name: "衣柜小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair", isDefault: true }) })
  147 + ).then((r) => r.json());
  148 + assert.equal(defaultCharacter.isDefault, true);
  149 +
  150 + activeGarment = await fetch(
  151 + `${BASE}/api/admin/garments`,
  152 + authed({ method: "POST", body: JSON.stringify({ name: "上架红裙", imageUrl: "/uploads/garment.png", appearanceDescriptor: "red dress", isActive: true }) })
  153 + ).then((r) => r.json());
  154 + inactiveGarment = await fetch(
  155 + `${BASE}/api/admin/garments`,
  156 + authed({ method: "POST", body: JSON.stringify({ name: "下架旗袍", imageUrl: "/uploads/garment.png", appearanceDescriptor: "qipao", isActive: false }) })
  157 + ).then((r) => r.json());
  158 +
  159 + danceRef = await fetch(
  160 + `${BASE}/api/admin/dance-refs`,
  161 + authed({ method: "POST", body: JSON.stringify({ name: "衣柜拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) })
  162 + ).then((r) => r.json());
  163 + assert.ok(danceRef.id);
  164 +});
  165 +
  166 +test("T12 GET /api/dance/wardrobe: only active garments, no admin auth needed", async () => {
  167 + const res = await fetch(`${BASE}/api/dance/wardrobe`); // deliberately no cookie
  168 + assert.equal(res.status, 200);
  169 + const list = await res.json();
  170 + assert.ok(list.some((g) => g.id === activeGarment.id));
  171 + assert.ok(!list.some((g) => g.id === inactiveGarment.id));
  172 +});
  173 +
  174 +test("T15 POST /api/dance/pick: rejects unknown/inactive garmentId", async () => {
  175 + const missing = await fetch(`${BASE}/api/dance/pick`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ garmentId: "nope" }) });
  176 + assert.equal(missing.status, 400);
  177 +
  178 + const inactive = await fetch(`${BASE}/api/dance/pick`, {
  179 + method: "POST",
  180 + headers: { "Content-Type": "application/json" },
  181 + body: JSON.stringify({ garmentId: inactiveGarment.id }),
  182 + });
  183 + assert.equal(inactive.status, 400);
  184 +});
  185 +
  186 +test("T15/T11 pick a garment (no login/cookie needed) -> generates -> lands in dance schedule; T17 vote + viewer count along the way", async () => {
  187 + const pickRes = await fetch(`${BASE}/api/dance/pick`, {
  188 + method: "POST",
  189 + headers: { "Content-Type": "application/json" },
  190 + body: JSON.stringify({ garmentId: activeGarment.id }),
  191 + });
  192 + assert.equal(pickRes.status, 200);
  193 + const picked = await pickRes.json();
  194 + assert.equal(picked.status, "generating");
  195 + assert.ok(picked.id);
  196 +
  197 + // While it's still generating, it should show up in the public queue and
  198 + // accept a vote (T17) — separate viewer/vote namespace from live.html.
  199 + const stateWhileGenerating = await fetch(`${BASE}/api/dance/live/state?cid=test-cid-1`).then((r) => r.json());
  200 + assert.ok(stateWhileGenerating.queue.some((q) => q.id === picked.id && q.status === "generating"));
  201 + assert.equal(stateWhileGenerating.watching, 1);
  202 +
  203 + const voteRes = await fetch(`${BASE}/api/dance/queue/vote/${picked.id}`, {
  204 + method: "POST",
  205 + headers: { "Content-Type": "application/json" },
  206 + body: JSON.stringify({ uid: "viewer-1" }),
  207 + });
  208 + assert.equal(voteRes.status, 200);
  209 + const vote = await voteRes.json();
  210 + assert.equal(vote.voted, true);
  211 + assert.equal(vote.votes, 1);
  212 +
  213 + // Eventually the mock generation finishes and ensureDanceSchedule sweeps
  214 + // it into the schedule — current or upNext should carry the right
  215 + // character/garment traceability, and the entry should drop out of queue.
  216 + const finalState = await waitUntil(async () => {
  217 + const state = await fetch(`${BASE}/api/dance/live/state?cid=test-cid-1`).then((r) => r.json());
  218 + const inQueue = state.queue.some((q) => q.id === picked.id);
  219 + const landed = (state.current && state.current.clip && state.current.clip.garmentId === activeGarment.id) || state.upNext.some((c) => c.garmentId === activeGarment.id);
  220 + return !inQueue && landed ? state : null;
  221 + });
  222 + const landedClip = finalState.current && finalState.current.clip && finalState.current.clip.garmentId === activeGarment.id ? finalState.current.clip : finalState.upNext.find((c) => c.garmentId === activeGarment.id);
  223 + assert.equal(landedClip.characterId, defaultCharacter.id);
  224 + assert.equal(landedClip.refDanceVideoId, danceRef.id);
  225 + assert.equal(landedClip.channel, "dance");
  226 +});
  227 +
  228 +test("T15 dedupe: re-picking the same garment returns ready immediately (M.7 cache hit), no new H3 submission", async () => {
  229 + const before = videoPollCounts.size;
  230 + const res = await fetch(`${BASE}/api/dance/pick`, {
  231 + method: "POST",
  232 + headers: { "Content-Type": "application/json" },
  233 + body: JSON.stringify({ garmentId: activeGarment.id }),
  234 + });
  235 + assert.equal(res.status, 200);
  236 + const data = await res.json();
  237 + assert.equal(data.status, "ready");
  238 + assert.ok(data.videoId);
  239 + assert.equal(videoPollCounts.size, before); // no new /v1/videos/:id job started
  240 +});
  241 +
  242 +test("T17: live.html and dance.html viewer counts are independent", async () => {
  243 + await fetch(`${BASE}/api/live/state?cid=live-viewer-a`);
  244 + await fetch(`${BASE}/api/live/state?cid=live-viewer-b`);
  245 + const danceState = await fetch(`${BASE}/api/dance/live/state?cid=dance-viewer-only`).then((r) => r.json());
  246 + assert.equal(danceState.watching, 2); // test-cid-1 (still within TTL) + dance-viewer-only, not the two live viewers
  247 +});
dance.css 0 → 100644
  1 +/* dance.html deltas on top of live.css — same comic-panel visual language,
  2 + loaded after live.css so these rules win. Only what dance.html actually
  3 + needs differently: a boxed 9:16 player (T13) and the wardrobe grid (T16).
  4 + Everything else (topbar, clip-row, qrow/up-btn, nickname modal, mobile
  5 + sub-tabs) is reused as-is from live.css. */
  6 +
  7 +/* dance.html's video is a fixed 480x832 (9:16) product, not the arbitrary
  8 + aspect ratio live.html's text-prompt clips can end up at — box it to that
  9 + ratio and center it instead of letting it stretch edge-to-edge on a wide
  10 + desktop viewport. */
  11 +.player-wrap {
  12 + aspect-ratio: 9 / 16;
  13 + max-width: 100%;
  14 + max-height: 100%;
  15 + margin: auto;
  16 + width: auto;
  17 + height: 100%;
  18 +}
  19 +@media (max-width: 768px) {
  20 + .player-wrap { height: auto; width: 100%; }
  21 +}
  22 +
  23 +.wardrobe-tabs { display: flex; gap: 8px; padding: 0 2px 4px; }
  24 +.wardrobe-tabs button {
  25 + flex: 1;
  26 + font: bold 12px Tahoma, sans-serif;
  27 + background: #fff;
  28 + border: 3px solid #000;
  29 + color: var(--ink);
  30 + border-radius: 12px;
  31 + padding: 7px 4px;
  32 + box-shadow: var(--shadow-sm);
  33 + cursor: pointer;
  34 +}
  35 +.wardrobe-tabs button.active { background: var(--accent); }
  36 +
  37 +.wardrobe-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
  38 +.wardrobe-card {
  39 + color: var(--ink);
  40 + background: #fff;
  41 + border: 3px solid #000;
  42 + border-radius: 14px;
  43 + padding: 6px;
  44 + box-shadow: var(--shadow);
  45 + cursor: pointer;
  46 + text-align: center;
  47 +}
  48 +.wardrobe-card:active { transform: translate(2px, 2px); box-shadow: 2px 2px 0 #000; }
  49 +.wardrobe-card img { width: 100%; height: 90px; object-fit: cover; border-radius: 8px; margin-bottom: 4px; display: block; }
  50 +.wardrobe-card .name { font-size: 12px; font-weight: bold; line-height: 1.3; }
  51 +.wardrobe-card.disabled { opacity: .5; cursor: default; }
  52 +
  53 +.coming-soon { color: var(--muted); text-align: center; margin-top: 40px; font-size: 13px; font-weight: bold; padding: 0 12px; }
  54 +
  55 +.age-box .age-disclaimer { font-size: 12px; line-height: 1.6; color: #444; font-weight: normal; }
dance.html 0 → 100644
  1 +<!DOCTYPE html>
  2 +<html lang="zh-CN">
  3 +<head>
  4 +<meta charset="UTF-8" />
  5 +<meta name="viewport" content="width=device-width, initial-scale=1" />
  6 +<title>换装跳舞直播间</title>
  7 +<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
  8 +<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Shrikhand&display=swap" />
  9 +<link rel="stylesheet" href="live.css" />
  10 +<link rel="stylesheet" href="dance.css" />
  11 +<style>
  12 + .like-btn { margin-top: 6px; font: bold 12px Tahoma, sans-serif; cursor: pointer; border: 3px solid #000; border-radius: 10px; background: #fff; color: var(--ink); padding: 4px 10px; box-shadow: var(--shadow-sm); }
  13 + .like-btn:active { transform: translate(2px, 2px); box-shadow: 1px 1px 0 #000; }
  14 +</style>
  15 +</head>
  16 +<body>
  17 +<div class="channel">
  18 + <header class="topbar">
  19 + <h1 class="logo">换装跳舞直播间</h1>
  20 + <div class="spacer"></div>
  21 + <div class="watching" id="watchingCount" hidden></div>
  22 + <button class="topbar-pill" id="muteBtn">🔇 静音</button>
  23 + <a class="topbar-pill" href="live.html">AI 短剧频道 →</a>
  24 + </header>
  25 +
  26 + <div class="body" id="body">
  27 + <aside class="left-col" id="leftCol">
  28 + <div class="wardrobe-tabs" id="wardrobeTabs">
  29 + <button data-tab="wardrobe" class="active">衣柜</button>
  30 + <button data-tab="garment">传衣服</button>
  31 + <button data-tab="character">传人</button>
  32 + </div>
  33 + <div class="clip-list" id="wardrobePanel"></div>
  34 + <div class="site-credit">🍋 <span>换装跳舞直播间</span></div>
  35 + </aside>
  36 +
  37 + <div class="player-col">
  38 + <div class="player-wrap">
  39 + <div class="live-pill" id="liveDot"><span class="dot"></span>直播中</div>
  40 + <div class="now-playing-box" id="nowPlayingBox" hidden>
  41 + <span class="np-nick" id="nowPlayingNick"></span>
  42 + <span class="np-time" id="nowPlayingTime"></span>
  43 + <div class="np-text" id="nowPlayingText">加载中…</div>
  44 + <div><button class="like-btn" id="likeBtn" hidden>❤️ <span id="likeCount">0</span></button></div>
  45 + </div>
  46 + <video id="player" autoplay playsinline muted></video>
  47 + </div>
  48 + </div>
  49 +
  50 + <aside class="right-col" id="rightCol">
  51 + <div class="section-title">互动</div>
  52 + <div class="clip-list" id="queueList"></div>
  53 + </aside>
  54 +
  55 + <div class="mobile-subtabs" id="mobileSubtabs">
  56 + <button data-sub="wardrobe" class="active">衣柜</button>
  57 + <button data-sub="queue">互动</button>
  58 + </div>
  59 + </div>
  60 +</div>
  61 +
  62 +<!-- 5.1 节:首次进入强制年龄确认,非模态(点背景不关),一次性 cookie 记忆 -->
  63 +<div class="nickname-modal" id="ageGateModal" hidden>
  64 + <div class="nickname-box age-box">
  65 + <div class="nickname-title">进入前确认</div>
  66 + <div class="age-disclaimer">
  67 + 本频道播放 AI 生成的换装舞蹈内容,服饰类别可能包含泳装/舞台表演装等,全部角色与画面均由 AI 生成,不代表真实人物行为。<br /><br />
  68 + 继续访问即表示您确认已年满 18 周岁。
  69 + </div>
  70 + <button id="ageConfirmBtn">我已满18岁,继续</button>
  71 + </div>
  72 +</div>
  73 +
  74 +<div class="nickname-modal" id="nicknameModal" hidden>
  75 + <div class="nickname-box">
  76 + <div class="nickname-title">先起个昵称才能点赞</div>
  77 + <input type="text" id="nicknameInput" placeholder="你的昵称" maxlength="20" />
  78 + <button id="nicknameConfirmBtn">确定</button>
  79 + </div>
  80 +</div>
  81 +
  82 +<script>
  83 +let wardrobe = [];
  84 +let leftTab = "wardrobe";
  85 +let mobileSub = "wardrobe";
  86 +let currentClipId = null;
  87 +let statePollTimer = null;
  88 +let advanceTimer = null;
  89 +let upNextCache = [];
  90 +const clientId = Math.random().toString(36).slice(2);
  91 +
  92 +const player = document.getElementById("player");
  93 +const wardrobePanel = document.getElementById("wardrobePanel");
  94 +const queueList = document.getElementById("queueList");
  95 +const nowPlayingBox = document.getElementById("nowPlayingBox");
  96 +const nowPlayingNick = document.getElementById("nowPlayingNick");
  97 +const nowPlayingTime = document.getElementById("nowPlayingTime");
  98 +const nowPlayingText = document.getElementById("nowPlayingText");
  99 +const likeBtn = document.getElementById("likeBtn");
  100 +const likeCount = document.getElementById("likeCount");
  101 +const watchingCount = document.getElementById("watchingCount");
  102 +const muteBtn = document.getElementById("muteBtn");
  103 +const leftCol = document.getElementById("leftCol");
  104 +const rightCol = document.getElementById("rightCol");
  105 +const mobileSubtabs = document.getElementById("mobileSubtabs");
  106 +const ageGateModal = document.getElementById("ageGateModal");
  107 +const ageConfirmBtn = document.getElementById("ageConfirmBtn");
  108 +const nicknameModal = document.getElementById("nicknameModal");
  109 +const nicknameInput = document.getElementById("nicknameInput");
  110 +const nicknameConfirmBtn = document.getElementById("nicknameConfirmBtn");
  111 +
  112 +function escapeHtml(s) {
  113 + const div = document.createElement("div");
  114 + div.textContent = s || "";
  115 + return div.innerHTML;
  116 +}
  117 +function fmtClock(ts) {
  118 + return new Date(ts).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
  119 +}
  120 +function getCookie(name) {
  121 + const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
  122 + return m ? decodeURIComponent(m[1]) : null;
  123 +}
  124 +function setCookie(name, value, days) {
  125 + document.cookie = `${name}=${encodeURIComponent(value)}; max-age=${days * 86400}; path=/; samesite=lax`;
  126 +}
  127 +
  128 +// ---- 5.1 年龄确认:非模态强制,一次性记忆,不能点背景关闭(没有绑定 backdrop click) ----
  129 +if (!getCookie("dance_age_confirmed")) ageGateModal.hidden = false;
  130 +ageConfirmBtn.addEventListener("click", () => {
  131 + setCookie("dance_age_confirmed", "1", 365);
  132 + ageGateModal.hidden = true;
  133 +});
  134 +
  135 +// ---- viewer identity: same cookie pair as live.html, only needed to vote ----
  136 +function getIdentity() {
  137 + const uid = getCookie("viewer_uid");
  138 + const nickname = getCookie("viewer_nickname");
  139 + return uid && nickname ? { uid, nickname } : null;
  140 +}
  141 +function openNicknameModal(prefill) {
  142 + return new Promise((resolve) => {
  143 + nicknameModal.hidden = false;
  144 + nicknameInput.value = prefill || "";
  145 + nicknameInput.focus();
  146 + const onConfirm = () => {
  147 + const nickname = nicknameInput.value.trim().slice(0, 20);
  148 + if (!nickname) return;
  149 + nicknameModal.hidden = true;
  150 + nicknameConfirmBtn.removeEventListener("click", onConfirm);
  151 + nicknameInput.removeEventListener("keydown", onKeydown);
  152 + resolve(nickname);
  153 + };
  154 + const onKeydown = (e) => {
  155 + if (e.key === "Enter") onConfirm();
  156 + };
  157 + nicknameConfirmBtn.addEventListener("click", onConfirm);
  158 + nicknameInput.addEventListener("keydown", onKeydown);
  159 + });
  160 +}
  161 +function ensureIdentity() {
  162 + const existing = getIdentity();
  163 + if (existing) return Promise.resolve(existing);
  164 + return openNicknameModal("").then((nickname) => {
  165 + const uid = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
  166 + setCookie("viewer_uid", uid, 365);
  167 + setCookie("viewer_nickname", nickname, 365);
  168 + return { uid, nickname };
  169 + });
  170 +}
  171 +
  172 +muteBtn.addEventListener("click", () => {
  173 + player.muted = !player.muted;
  174 + muteBtn.textContent = player.muted ? "🔇 静音" : "🔈 有声";
  175 + if (!player.muted) player.play().catch(() => {});
  176 +});
  177 +
  178 +// ---- 左侧栏:衣柜 / 传衣服 / 传人 三个 tab(3.1 节,本期只做衣柜) ----
  179 +function garmentName(id) {
  180 + const g = wardrobe.find((x) => x.id === id);
  181 + return g ? g.name : id;
  182 +}
  183 +
  184 +function renderWardrobe() {
  185 + document.querySelectorAll("#wardrobeTabs button").forEach((b) => b.classList.toggle("active", b.dataset.tab === leftTab));
  186 + if (leftTab !== "wardrobe") {
  187 + wardrobePanel.innerHTML = `<div class="coming-soon">${leftTab === "garment" ? "传衣服" : "传人"}功能开发中,敬请期待</div>`;
  188 + return;
  189 + }
  190 + if (wardrobe.length === 0) {
  191 + wardrobePanel.innerHTML = `<div class="empty-hint">衣柜还是空的</div>`;
  192 + return;
  193 + }
  194 + wardrobePanel.innerHTML = `<div class="wardrobe-grid">${wardrobe
  195 + .map(
  196 + (g) => `
  197 + <div class="wardrobe-card" data-garment="${g.id}">
  198 + <img src="${escapeHtml(g.thumbnailUrl || g.imageUrl)}" />
  199 + <div class="name">${escapeHtml(g.name)}</div>
  200 + </div>`
  201 + )
  202 + .join("")}</div>`;
  203 + wardrobePanel.querySelectorAll("[data-garment]").forEach((card) =>
  204 + card.addEventListener("click", () => pickGarment(card.dataset.garment, card))
  205 + );
  206 +}
  207 +
  208 +document.querySelectorAll("#wardrobeTabs button").forEach((b) =>
  209 + b.addEventListener("click", () => {
  210 + leftTab = b.dataset.tab;
  211 + renderWardrobe();
  212 + })
  213 +);
  214 +
  215 +async function loadWardrobe() {
  216 + const res = await fetch("/api/dance/wardrobe");
  217 + wardrobe = await res.json();
  218 + renderWardrobe();
  219 +}
  220 +
  221 +async function pickGarment(garmentId, card) {
  222 + if (card.classList.contains("disabled")) return;
  223 + card.classList.add("disabled");
  224 + try {
  225 + const identity = getIdentity(); // 免费模式一不强制登录(架构文档 2.7 节)— 有 cookie 就带上昵称,没有也能提交
  226 + const res = await fetch("/api/dance/pick", {
  227 + method: "POST",
  228 + headers: { "Content-Type": "application/json" },
  229 + body: JSON.stringify({ garmentId, nickname: identity ? identity.nickname : undefined }),
  230 + });
  231 + const data = await res.json();
  232 + if (!res.ok) {
  233 + alert(data.error || "提交失败");
  234 + return;
  235 + }
  236 + pollState();
  237 + } finally {
  238 + card.classList.remove("disabled");
  239 + }
  240 +}
  241 +
  242 +// ---- 右侧栏:接下来播放 / 生成中 / 排队中(可点赞),复用 live.html 的分组思路 ----
  243 +function renderQueue(state) {
  244 + const upcoming = state.upNext || [];
  245 + const queue = state.queue || [];
  246 + const generating = queue.filter((q) => q.status === "generating");
  247 + const failed = queue.filter((q) => q.status === "failed");
  248 +
  249 + const upcomingHtml = upcoming
  250 + .map(
  251 + (c) => `
  252 + <div class="clip-row">
  253 + <div class="prompt">${escapeHtml(c.prompt || garmentName(c.garmentId))}</div>
  254 + ${c.requestedBy ? `<div class="meta">by ${escapeHtml(c.requestedBy)}</div>` : ""}
  255 + </div>`
  256 + )
  257 + .join("");
  258 + const generatingHtml = generating
  259 + .map(
  260 + (q) => `
  261 + <div class="qrow">
  262 + <div class="clip-row gen">
  263 + <div class="prompt">👗 ${escapeHtml(garmentName(q.garmentId))}</div>
  264 + <div class="meta">${q.nickname ? `${escapeHtml(q.nickname)} · ` : ""}生成中…</div>
  265 + </div>
  266 + <button class="up-btn ${q.voted ? "voted" : ""}" data-vote="${q.id}">▲<span>${q.votes || 0}</span></button>
  267 + </div>`
  268 + )
  269 + .join("");
  270 + const failedHtml = failed
  271 + .map(
  272 + (q) => `
  273 + <div class="clip-row failed">
  274 + <div class="prompt">👗 ${escapeHtml(garmentName(q.garmentId))}</div>
  275 + <div class="meta">${q.nickname ? `${escapeHtml(q.nickname)} · ` : ""}❌ 生成失败</div>
  276 + </div>`
  277 + )
  278 + .join("");
  279 +
  280 + queueList.innerHTML =
  281 + (upcomingHtml ? `<div class="section-head">接下来播放</div>${upcomingHtml}` : "") +
  282 + (generatingHtml ? `<div class="section-head">生成中(可点赞)</div>${generatingHtml}` : "") +
  283 + (failedHtml ? `<div class="section-head">生成失败</div>${failedHtml}` : "") ||
  284 + `<div class="empty-hint">暂无排队,去左边衣柜选一件衣服吧</div>`;
  285 +
  286 + queueList.querySelectorAll("[data-vote]").forEach((btn) => {
  287 + btn.addEventListener("click", () => voteQueueItem(btn.dataset.vote, btn));
  288 + });
  289 +}
  290 +
  291 +async function voteQueueItem(id, btn) {
  292 + const identity = await ensureIdentity();
  293 + btn.disabled = true;
  294 + try {
  295 + const res = await fetch(`/api/dance/queue/vote/${id}`, {
  296 + method: "POST",
  297 + headers: { "Content-Type": "application/json" },
  298 + body: JSON.stringify({ uid: identity.uid }),
  299 + });
  300 + if (!res.ok) return;
  301 + const data = await res.json();
  302 + btn.classList.toggle("voted", data.voted);
  303 + btn.querySelector("span").textContent = data.votes;
  304 + } finally {
  305 + btn.disabled = false;
  306 + }
  307 +}
  308 +
  309 +// ---- 播放器:跟 live.html 一样,服务端 endAt 是权威时钟,轮询只是纠偏 ----
  310 +function setNowPlaying(nick, time, text, clip) {
  311 + nowPlayingBox.hidden = false;
  312 + nowPlayingNick.hidden = !nick;
  313 + nowPlayingNick.textContent = nick || "";
  314 + nowPlayingTime.hidden = !time;
  315 + nowPlayingTime.textContent = time || "";
  316 + nowPlayingText.textContent = text;
  317 + likeBtn.hidden = !clip;
  318 + if (clip) {
  319 + likeCount.textContent = clip.likes || 0;
  320 + likeBtn.onclick = async () => {
  321 + const res = await fetch(`/api/like/${clip.id}`, { method: "POST" });
  322 + if (res.ok) likeCount.textContent = (await res.json()).likes;
  323 + };
  324 + }
  325 +}
  326 +
  327 +function switchToClip(clip, offsetSec, requestedBy) {
  328 + currentClipId = clip.id;
  329 + player.src = `/lib/video/${clip.id}`;
  330 + if (offsetSec > 0) {
  331 + player.addEventListener(
  332 + "loadedmetadata",
  333 + () => {
  334 + player.currentTime = Math.min(offsetSec, player.duration || offsetSec);
  335 + },
  336 + { once: true }
  337 + );
  338 + }
  339 + player.play().catch(() => {});
  340 + setNowPlaying(requestedBy, fmtClock(Date.now()), clip.prompt, clip);
  341 +}
  342 +
  343 +async function pollState() {
  344 + try {
  345 + const identity = getIdentity();
  346 + const res = await fetch(`/api/dance/live/state?cid=${clientId}${identity ? `&uid=${encodeURIComponent(identity.uid)}` : ""}`);
  347 + const state = await res.json();
  348 + watchingCount.hidden = false;
  349 + watchingCount.textContent = `👀 ${state.watching}`;
  350 + renderQueue(state);
  351 + upNextCache = state.upNext || [];
  352 +
  353 + clearTimeout(advanceTimer);
  354 + if (state.current && state.current.clip) {
  355 + if (state.current.clip.id !== currentClipId) {
  356 + const offsetSec = Math.max(0, (state.now - state.current.startAt) / 1000);
  357 + switchToClip(state.current.clip, offsetSec, state.current.requestedBy);
  358 + }
  359 + if (upNextCache.length) {
  360 + const msUntilEnd = state.current.endAt - Date.now();
  361 + advanceTimer = setTimeout(() => switchToClip(upNextCache[0], 0, upNextCache[0].requestedBy), Math.max(0, msUntilEnd));
  362 + }
  363 + } else if (!currentClipId) {
  364 + nowPlayingText.textContent = "衣柜还没有内容,去左边选一件衣服开播吧";
  365 + nowPlayingBox.hidden = false;
  366 + }
  367 + } catch {
  368 + // transient — next poll retries
  369 + }
  370 +}
  371 +
  372 +function applyMobileSub() {
  373 + leftCol.classList.toggle("mobile-inactive", mobileSub !== "wardrobe");
  374 + rightCol.classList.toggle("mobile-inactive", mobileSub !== "queue");
  375 + mobileSubtabs.querySelectorAll("button").forEach((b) => b.classList.toggle("active", b.dataset.sub === mobileSub));
  376 +}
  377 +mobileSubtabs.querySelectorAll("button").forEach((b) =>
  378 + b.addEventListener("click", () => {
  379 + mobileSub = b.dataset.sub;
  380 + applyMobileSub();
  381 + })
  382 +);
  383 +applyMobileSub();
  384 +
  385 +loadWardrobe();
  386 +pollState();
  387 +statePollTimer = setInterval(pollState, 4000);
  388 +</script>
  389 +</body>
  390 +</html>
live.html
@@ -22,6 +22,7 @@ @@ -22,6 +22,7 @@
22 <div class="watching" id="watchingCount" hidden></div> 22 <div class="watching" id="watchingCount" hidden></div>
23 <button class="topbar-pill" id="chatToggleBtn">✕ 聊天</button> 23 <button class="topbar-pill" id="chatToggleBtn">✕ 聊天</button>
24 <button class="topbar-pill" id="muteBtn">🔈 有声</button> 24 <button class="topbar-pill" id="muteBtn">🔈 有声</button>
  25 + <a class="topbar-pill" href="dance.html">换装跳舞 →</a>
25 </header> 26 </header>
26 27
27 <div class="body" id="body"> 28 <div class="body" id="body">
server.js
@@ -81,6 +81,25 @@ function saveLiveState() { @@ -81,6 +81,25 @@ function saveLiveState() {
81 fs.writeFileSync(LIVE_STATE_PATH, JSON.stringify({ schedule, promptQueue, chatMessages }, null, 2)); 81 fs.writeFileSync(LIVE_STATE_PATH, JSON.stringify({ schedule, promptQueue, chatMessages }, null, 2));
82 } 82 }
83 83
  84 +// dance.html's own playout state (T11) — separate file/schedule/queue from
  85 +// live.html's (architecture.md 2.1: two independent channels, not shared).
  86 +// Per MVCP M.8, `schedule` here stores `videoId` references, not inlined
  87 +// clip snapshots like live's `schedule` does — dance clips join back to
  88 +// library.json at read time so an admin edit/takedown isn't stuck showing a
  89 +// stale copy.
  90 +const DANCE_STATE_PATH = path.join(__dirname, "dance-state.json");
  91 +function loadDanceState() {
  92 + try {
  93 + const s = JSON.parse(fs.readFileSync(DANCE_STATE_PATH, "utf8"));
  94 + return { danceSchedule: s.schedule || [], danceQueue: s.freeQueue || [] };
  95 + } catch {
  96 + return { danceSchedule: [], danceQueue: [] };
  97 + }
  98 +}
  99 +function saveDanceState() {
  100 + fs.writeFileSync(DANCE_STATE_PATH, JSON.stringify({ schedule: danceSchedule, freeQueue: danceQueue }, null, 2));
  101 +}
  102 +
84 function readJsonBody(req) { 103 function readJsonBody(req) {
85 return new Promise((resolve, reject) => { 104 return new Promise((resolve, reject) => {
86 let data = ""; 105 let data = "";
@@ -398,6 +417,14 @@ let schedule = initialLiveState.schedule; // [{clip, startAt, endAt, requestedBy @@ -398,6 +417,14 @@ let schedule = initialLiveState.schedule; // [{clip, startAt, endAt, requestedBy
398 let promptQueue = initialLiveState.promptQueue; // [{id, prompt, nickname?, status, clip?, error?, submittedAt, h3JobId?}] 417 let promptQueue = initialLiveState.promptQueue; // [{id, prompt, nickname?, status, clip?, error?, submittedAt, h3JobId?}]
399 let chatMessages = initialLiveState.chatMessages; // [{id, uid, nickname, text, createdAt, status}] 418 let chatMessages = initialLiveState.chatMessages; // [{id, uid, nickname, text, createdAt, status}]
400 419
  420 +const initialDanceState = loadDanceState();
  421 +let danceSchedule = initialDanceState.danceSchedule; // [{videoId, startAt, endAt, requestedBy?}]
  422 +let danceQueue = initialDanceState.danceQueue; // [{id, garmentId, characterId, nickname?, status, jobId?, videoId?, error?, submittedAt, votes}]
  423 +
  424 +// How often ensureSchedule/ensureDanceSchedule tick — configurable so tests
  425 +// don't have to wait out a real 2s to see a schedule fill.
  426 +const SCHEDULE_TICK_MS = Number(process.env.SCHEDULE_TICK_MS) || 2000;
  427 +
401 // ---- chat + moderation ---- 428 // ---- chat + moderation ----
402 // ponytail: a starter blocklist, not a real content-policy list — swap in 429 // ponytail: a starter blocklist, not a real content-policy list — swap in
403 // whatever the actual moderation requirements are. Substring match on the 430 // whatever the actual moderation requirements are. Substring match on the
@@ -484,46 +511,59 @@ function markPlayed(id) { @@ -484,46 +511,59 @@ function markPlayed(id) {
484 function voteCount(q) { 511 function voteCount(q) {
485 return (q.votes || []).length; 512 return (q.votes || []).length;
486 } 513 }
487 -function pickBestIndexByStatus(status) { 514 +// Shared by live's promptQueue and dance's freeQueue (T11) — takes the queue
  515 +// array as a param rather than closing over a module-scope variable so both
  516 +// channels' independent queues can use it.
  517 +function pickBestIndexByStatus(queue, status) {
488 let bestIdx = -1; 518 let bestIdx = -1;
489 - for (let i = 0; i < promptQueue.length; i++) {  
490 - const q = promptQueue[i]; 519 + for (let i = 0; i < queue.length; i++) {
  520 + const q = queue[i];
491 if (q.status !== status) continue; 521 if (q.status !== status) continue;
492 if (bestIdx === -1) { 522 if (bestIdx === -1) {
493 bestIdx = i; 523 bestIdx = i;
494 continue; 524 continue;
495 } 525 }
496 - const best = promptQueue[bestIdx]; 526 + const best = queue[bestIdx];
497 if (voteCount(q) > voteCount(best)) bestIdx = i; 527 if (voteCount(q) > voteCount(best)) bestIdx = i;
498 } 528 }
499 return bestIdx; 529 return bestIdx;
500 } 530 }
  531 +// Random-fill fallback shared by live's pickNextClip and dance's
  532 +// pickNextDanceClip (T11) — least-played-first within a cooldown-filtered
  533 +// pool, so idle filler time rotates evenly instead of a few clips dominating
  534 +// by chance. `pool` is caller-filtered (live excludes dance channel clips
  535 +// and vice versa, architecture.md 2.1: independent schedules per channel).
  536 +function pickLeastPlayedClip(pool, prevId) {
  537 + if (!pool.length) return null;
  538 + const notPrev = pool.length > 1 ? pool.filter((c) => c.id !== prevId) : pool;
  539 + const now = Date.now();
  540 + // Never-played clips (no lastPlayedAt) are always eligible — genuinely
  541 + // random among everything that isn't on cooldown, no bias toward "fresh".
  542 + const offCooldown = notPrev.filter((c) => !c.lastPlayedAt || now - c.lastPlayedAt > REPEAT_COOLDOWN_MS);
  543 + const candidates = offCooldown.length ? offCooldown : notPrev;
  544 + const minPlays = Math.min(...candidates.map((c) => c.playCount || 0));
  545 + const leastPlayed = candidates.filter((c) => (c.playCount || 0) === minPlays);
  546 + return leastPlayed[Math.floor(Math.random() * leastPlayed.length)];
  547 +}
501 function pickNextClip() { 548 function pickNextClip() {
502 - const readyIdx = pickBestIndexByStatus("ready"); 549 + const readyIdx = pickBestIndexByStatus(promptQueue, "ready");
503 if (readyIdx !== -1) { 550 if (readyIdx !== -1) {
504 const q = promptQueue.splice(readyIdx, 1)[0]; 551 const q = promptQueue.splice(readyIdx, 1)[0];
505 return { clip: q.clip, requestedBy: q.nickname, chatId: q.id }; 552 return { clip: q.clip, requestedBy: q.nickname, chatId: q.id };
506 } 553 }
507 - const matchedIdx = pickBestIndexByStatus("matched"); 554 + const matchedIdx = pickBestIndexByStatus(promptQueue, "matched");
508 if (matchedIdx !== -1) { 555 if (matchedIdx !== -1) {
509 const q = promptQueue.splice(matchedIdx, 1)[0]; 556 const q = promptQueue.splice(matchedIdx, 1)[0];
510 return { clip: q.clip, requestedBy: q.nickname, chatId: q.id }; 557 return { clip: q.clip, requestedBy: q.nickname, chatId: q.id };
511 } 558 }
512 559
513 - const lib = getLibrary(); 560 + // dance-channel clips (T8) don't belong in live.html's random filler pool
  561 + // — the two channels keep independent schedules (architecture.md 2.1).
  562 + const lib = getLibrary().filter((c) => c.channel !== "dance");
514 if (!lib.length) return null; 563 if (!lib.length) return null;
515 const prev = schedule.length ? schedule[schedule.length - 1].clip.id : null; 564 const prev = schedule.length ? schedule[schedule.length - 1].clip.id : null;
516 - const notPrev = lib.length > 1 ? lib.filter((c) => c.id !== prev) : lib;  
517 - const now = Date.now();  
518 - // Never-played clips (no lastPlayedAt) are always eligible — genuinely  
519 - // random among everything that isn't on cooldown, no bias toward "fresh".  
520 - const offCooldown = notPrev.filter((c) => !c.lastPlayedAt || now - c.lastPlayedAt > REPEAT_COOLDOWN_MS);  
521 - const pool = offCooldown.length ? offCooldown : notPrev;  
522 - // Within that pool, only randomize among the least-played clips — keeps  
523 - // rotation even instead of letting a few clips dominate airtime by chance.  
524 - const minPlays = Math.min(...pool.map((c) => c.playCount || 0));  
525 - const leastPlayed = pool.filter((c) => (c.playCount || 0) === minPlays);  
526 - return { clip: leastPlayed[Math.floor(Math.random() * leastPlayed.length)], requestedBy: null, chatId: null }; 565 + const clip = pickLeastPlayedClip(lib, prev);
  566 + return clip ? { clip, requestedBy: null, chatId: null } : null;
527 } 567 }
528 568
529 const FAILED_ENTRY_TTL_MS = 20000; // how long a "generation failed" entry stays visible before cleanup 569 const FAILED_ENTRY_TTL_MS = 20000; // how long a "generation failed" entry stays visible before cleanup
@@ -545,11 +585,14 @@ function ensureSchedule() { @@ -545,11 +585,14 @@ function ensureSchedule() {
545 for (const [cid, lastSeen] of viewers) if (now - lastSeen > VIEWER_TTL_MS) viewers.delete(cid); 585 for (const [cid, lastSeen] of viewers) if (now - lastSeen > VIEWER_TTL_MS) viewers.delete(cid);
546 saveLiveState(); 586 saveLiveState();
547 } 587 }
548 -setInterval(ensureSchedule, 2000); 588 +setInterval(ensureSchedule, SCHEDULE_TICK_MS);
549 589
550 // ---- viewer count: anyone whose /api/live/state poll landed in the last VIEWER_TTL_MS ---- 590 // ---- viewer count: anyone whose /api/live/state poll landed in the last VIEWER_TTL_MS ----
551 const VIEWER_TTL_MS = 12000; 591 const VIEWER_TTL_MS = 12000;
552 const viewers = new Map(); // clientId -> lastSeenAt 592 const viewers = new Map(); // clientId -> lastSeenAt
  593 +// dance.html keeps its own viewer count (T17) — the two channels' "在线人数"
  594 +// must not be summed together, they're separate rooms.
  595 +const danceViewers = new Map(); // clientId -> lastSeenAt
553 596
554 // ---- rate limiting: cheap per-IP throttle on prompt submission ---- 597 // ---- rate limiting: cheap per-IP throttle on prompt submission ----
555 const RATE_LIMIT_MAX = 3; 598 const RATE_LIMIT_MAX = 3;
@@ -776,6 +819,62 @@ function resumeDressupGenerations() { @@ -776,6 +819,62 @@ function resumeDressupGenerations() {
776 } 819 }
777 } 820 }
778 821
  822 +// ---- dance.html: independent playout schedule + free (mode-一/衣柜) queue (T11) ----
  823 +// Mirrors live's pickNextClip/ensureSchedule shape but pulls from
  824 +// danceQueue/danceSchedule instead, and picks its random-fill pool from
  825 +// channel:"dance" clips only.
  826 +
  827 +function pickNextDanceClip() {
  828 + const readyIdx = pickBestIndexByStatus(danceQueue, "ready");
  829 + if (readyIdx !== -1) {
  830 + const q = danceQueue.splice(readyIdx, 1)[0];
  831 + return { videoId: q.videoId, requestedBy: q.nickname };
  832 + }
  833 + const pool = getLibrary().filter((c) => c.channel === "dance");
  834 + if (!pool.length) return null;
  835 + const prevVideoId = danceSchedule.length ? danceSchedule[danceSchedule.length - 1].videoId : null;
  836 + const clip = pickLeastPlayedClip(pool, prevVideoId);
  837 + return clip ? { videoId: clip.id, requestedBy: null } : null;
  838 +}
  839 +
  840 +function ensureDanceSchedule() {
  841 + const now = Date.now();
  842 +
  843 + // Reconcile in-flight picks against generationJobs — the store, not an
  844 + // in-memory promise, is the source of truth (T8's resumeDressupGenerations
  845 + // already re-polls H3 for jobs still running after a restart; this tick
  846 + // just needs to notice when they land, restart or not).
  847 + for (const q of danceQueue) {
  848 + if (q.status !== "generating" || !q.jobId) continue;
  849 + const job = generationJobs.get(q.jobId);
  850 + if (!job) continue;
  851 + if (job.status === "done") {
  852 + q.status = "ready";
  853 + q.videoId = job.generatedVideoId;
  854 + } else if (job.status === "failed") {
  855 + q.status = "failed";
  856 + q.error = job.error;
  857 + q.failedAt = now;
  858 + }
  859 + }
  860 +
  861 + while (danceSchedule.length > 1 && danceSchedule[1].endAt <= now) danceSchedule.shift();
  862 + while (danceSchedule.length === 0 || danceSchedule[danceSchedule.length - 1].endAt < now + FILLER_DURATION_MS) {
  863 + const picked = pickNextDanceClip();
  864 + if (!picked || !picked.videoId) break;
  865 + const clip = getLibrary().find((c) => c.id === picked.videoId);
  866 + if (!clip) break; // stale/removed videoId — guard rather than schedule a dead entry
  867 + markPlayed(clip.id);
  868 + const startAt = danceSchedule.length ? danceSchedule[danceSchedule.length - 1].endAt : now;
  869 + danceSchedule.push({ videoId: picked.videoId, startAt, endAt: startAt + clip.duration * 1000, requestedBy: picked.requestedBy });
  870 + }
  871 + danceQueue = danceQueue.filter((q) => q.status !== "failed" || now - q.failedAt < FAILED_ENTRY_TTL_MS);
  872 +
  873 + for (const [cid, lastSeen] of danceViewers) if (now - lastSeen > VIEWER_TTL_MS) danceViewers.delete(cid);
  874 + saveDanceState();
  875 +}
  876 +setInterval(ensureDanceSchedule, SCHEDULE_TICK_MS);
  877 +
779 http 878 http
780 .createServer((req, res) => { 879 .createServer((req, res) => {
781 if (req.url.startsWith("/fal/")) { 880 if (req.url.startsWith("/fal/")) {
@@ -926,6 +1025,138 @@ http @@ -926,6 +1025,138 @@ http
926 return; 1025 return;
927 } 1026 }
928 1027
  1028 + // ---- dance.html: public viewer-facing endpoints (T11/T12/T15/T17) ----
  1029 + // No admin auth (T6 only gates /api/admin/*) — these are the free,
  1030 + // anonymous, 模式一 (衣柜选衣服) path. Modes 二/三 (paid upload) aren't
  1031 + // built yet (require accounts/credits, dev-plan-2.md 第三/四期).
  1032 +
  1033 + if (pathname === "/api/dance/wardrobe" && req.method === "GET") {
  1034 + res.writeHead(200, { "Content-Type": "application/json" });
  1035 + res.end(JSON.stringify(garments.list().filter((g) => g.isActive)));
  1036 + return;
  1037 + }
  1038 +
  1039 + if (pathname === "/api/dance/live/state" && req.method === "GET") {
  1040 + ensureDanceSchedule();
  1041 + const now = Date.now();
  1042 + const params = new URL(req.url, "http://x").searchParams;
  1043 + const cid = params.get("cid");
  1044 + const uid = params.get("uid") || null;
  1045 + if (cid) danceViewers.set(cid, now);
  1046 + let idx = danceSchedule.findIndex((s) => s.startAt <= now && now < s.endAt);
  1047 + if (idx === -1) idx = danceSchedule.length - 1;
  1048 + const cur = danceSchedule[idx];
  1049 + const lib = getLibrary();
  1050 + const joinClip = (videoId) => lib.find((c) => c.id === videoId) || null;
  1051 + res.writeHead(200, { "Content-Type": "application/json" });
  1052 + res.end(
  1053 + JSON.stringify({
  1054 + now,
  1055 + watching: danceViewers.size,
  1056 + current: cur ? { clip: joinClip(cur.videoId), startAt: cur.startAt, endAt: cur.endAt, requestedBy: cur.requestedBy || null } : null,
  1057 + upNext: danceSchedule.slice(idx + 1, idx + 4).map((s) => ({ ...(joinClip(s.videoId) || {}), requestedBy: s.requestedBy || null })),
  1058 + queue: danceQueue
  1059 + .filter((q) => ["generating", "failed"].includes(q.status))
  1060 + .slice()
  1061 + .sort((a, b) => voteCount(b) - voteCount(a))
  1062 + .map((q) => ({
  1063 + id: q.id,
  1064 + garmentId: q.garmentId,
  1065 + nickname: q.nickname,
  1066 + status: q.status,
  1067 + error: q.error,
  1068 + votes: voteCount(q),
  1069 + voted: uid ? (q.votes || []).includes(uid) : false,
  1070 + })),
  1071 + })
  1072 + );
  1073 + return;
  1074 + }
  1075 +
  1076 + // P.1 免费模式一:{garmentId, nickname?}. Uses the single配置的直播默认
  1077 + // 人物 (characters.isDefault) — 3.1 节: 免费用户不能指定/上传人物。
  1078 + // Reuses submitGeneration (T8/T9) whole — same M.7 dedupe cache — then
  1079 + // hands the result to danceQueue for ensureDanceSchedule to pick up.
  1080 + if (pathname === "/api/dance/pick" && req.method === "POST") {
  1081 + readJsonBody(req)
  1082 + .then((body) => {
  1083 + const garment = garments.get(body.garmentId);
  1084 + if (!garment || !garment.isActive) {
  1085 + res.writeHead(400, { "Content-Type": "application/json" });
  1086 + res.end(JSON.stringify({ error: "garmentId must reference an active garment" }));
  1087 + return;
  1088 + }
  1089 + const defaultCharacter = characters.list().find((c) => c.isDefault);
  1090 + if (!defaultCharacter) {
  1091 + res.writeHead(400, { "Content-Type": "application/json" });
  1092 + res.end(JSON.stringify({ error: "no default character configured" }));
  1093 + return;
  1094 + }
  1095 + const { status, body: respBody } = submitGeneration({ characterId: defaultCharacter.id, garmentId: garment.id });
  1096 + if (status !== 200) {
  1097 + res.writeHead(status, { "Content-Type": "application/json" });
  1098 + res.end(JSON.stringify(respBody));
  1099 + return;
  1100 + }
  1101 + const entry = {
  1102 + id: crypto.randomUUID(),
  1103 + garmentId: garment.id,
  1104 + characterId: defaultCharacter.id,
  1105 + nickname: (body.nickname || "").trim() || null,
  1106 + submittedAt: Date.now(),
  1107 + votes: [],
  1108 + };
  1109 + if (respBody.cached) {
  1110 + entry.status = "ready";
  1111 + entry.videoId = respBody.clip.id;
  1112 + } else {
  1113 + entry.status = "generating";
  1114 + entry.jobId = respBody.jobId;
  1115 + }
  1116 + danceQueue.push(entry);
  1117 + saveDanceState();
  1118 + res.writeHead(200, { "Content-Type": "application/json" });
  1119 + res.end(JSON.stringify({ status: entry.status, id: entry.id, videoId: entry.videoId || null }));
  1120 + })
  1121 + .catch(() => {
  1122 + res.writeHead(400, { "Content-Type": "application/json" });
  1123 + res.end(JSON.stringify({ error: "invalid JSON body" }));
  1124 + });
  1125 + return;
  1126 + }
  1127 +
  1128 + if (pathname.startsWith("/api/dance/queue/vote/") && req.method === "POST") {
  1129 + const id = pathname.slice("/api/dance/queue/vote/".length);
  1130 + readJsonBody(req)
  1131 + .then((body) => {
  1132 + const uid = (body.uid || "").trim();
  1133 + if (!uid) {
  1134 + res.writeHead(400, { "Content-Type": "application/json" });
  1135 + res.end(JSON.stringify({ error: "uid required" }));
  1136 + return;
  1137 + }
  1138 + const entry = danceQueue.find((q) => q.id === id);
  1139 + if (!entry || !["generating", "ready", "failed"].includes(entry.status)) {
  1140 + res.writeHead(404, { "Content-Type": "application/json" });
  1141 + res.end(JSON.stringify({ error: "not found" }));
  1142 + return;
  1143 + }
  1144 + if (!Array.isArray(entry.votes)) entry.votes = [];
  1145 + const i = entry.votes.indexOf(uid);
  1146 + const voted = i === -1;
  1147 + if (voted) entry.votes.push(uid);
  1148 + else entry.votes.splice(i, 1);
  1149 + saveDanceState();
  1150 + res.writeHead(200, { "Content-Type": "application/json" });
  1151 + res.end(JSON.stringify({ votes: entry.votes.length, voted }));
  1152 + })
  1153 + .catch(() => {
  1154 + res.writeHead(400, { "Content-Type": "application/json" });
  1155 + res.end(JSON.stringify({ error: "invalid JSON body" }));
  1156 + });
  1157 + return;
  1158 + }
  1159 +
929 if (pathname === "/api/library") { 1160 if (pathname === "/api/library") {
930 const sort = new URL(req.url, "http://x").searchParams.get("sort") || "newest"; 1161 const sort = new URL(req.url, "http://x").searchParams.get("sort") || "newest";
931 const lib = getLibrary().slice(); 1162 const lib = getLibrary().slice();