Commit 719fdbb7d5adca663f6e326979207e30e4fe508d
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>
Showing
6 changed files
with
944 additions
and
19 deletions
.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(); |