diff --git a/.gitignore b/.gitignore
index 722460d..c2bbe26 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,6 +3,7 @@
# runtime state, regenerated by the server, not source
live-state.json
+dance-state.json
# scratch server logs, if any get written locally
*.log
diff --git a/dance-channel.test.js b/dance-channel.test.js
new file mode 100644
index 0000000..21b91e8
--- /dev/null
+++ b/dance-channel.test.js
@@ -0,0 +1,247 @@
+// Integration tests for dance.html's backend (T11 独立播出状态机, T12
+// GET /api/dance/wardrobe, T15 POST /api/dance/pick, T17 点赞/在线人数).
+// Same mock-H3-Console approach as dressup-generate.test.js so the full
+// submit -> generate -> land-in-schedule path runs offline and fast.
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const os = require("node:os");
+const http = require("node:http");
+const { spawn } = require("node:child_process");
+
+const PORT = 22934 + Math.floor(Math.random() * 1000);
+const BASE = `http://localhost:${PORT}`;
+const ADMIN_PASSWORD = "test-password-only-for-this-run";
+let cookie;
+let child;
+let cwd;
+let mockH3;
+let mockH3Port;
+let videoPollCounts;
+
+function waitForServer(url, timeoutMs = 10000) {
+ const deadline = Date.now() + timeoutMs;
+ return (async function poll() {
+ while (Date.now() < deadline) {
+ try {
+ await fetch(url);
+ return;
+ } catch {
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ }
+ throw new Error("server did not come up in time");
+ })();
+}
+
+function authed(opts = {}) {
+ return { ...opts, headers: { "Content-Type": "application/json", Cookie: cookie, ...opts.headers } };
+}
+
+// Same shape as dressup-generate.test.js's mock — enough of /v1/assets,
+// /v1/videos and /v1/videos/:id for generateDanceVideo to run end to end.
+function startMockH3() {
+ videoPollCounts = new Map();
+ let nextId = 1;
+ return new Promise((resolve) => {
+ mockH3 = http.createServer((req, res) => {
+ req.url = req.url.replace(/^\/+/, "/");
+ const chunks = [];
+ req.on("data", (c) => chunks.push(c));
+ req.on("end", () => {
+ if (req.method === "POST" && req.url === "/v1/assets") {
+ const id = `asset-${nextId++}`;
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ id, url: `http://mock-h3/asset/${id}`, kind: "image" }));
+ return;
+ }
+ if (req.method === "POST" && req.url === "/v1/videos") {
+ const id = `job-${nextId++}`;
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ id }));
+ return;
+ }
+ const pollMatch = req.method === "GET" && req.url.match(/^\/v1\/videos\/(.+)$/);
+ if (pollMatch) {
+ const id = pollMatch[1];
+ const seen = (videoPollCounts.get(id) || 0) + 1;
+ videoPollCounts.set(id, seen);
+ if (seen < 2) {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ id, status: "generating" }));
+ return;
+ }
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ id, status: "done", partition: "ref2va", req: { width: 480, height: 832, frames: 240 } }));
+ return;
+ }
+ res.writeHead(404, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "not found in mock" }));
+ });
+ });
+ mockH3.listen(0, () => {
+ mockH3Port = mockH3.address().port;
+ resolve();
+ });
+ });
+}
+
+test.before(async () => {
+ await startMockH3();
+
+ cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-channel-"));
+ for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) {
+ fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
+ }
+ fs.mkdirSync(path.join(cwd, "uploads"), { recursive: true });
+ fs.writeFileSync(path.join(cwd, "uploads", "person.png"), "fake-png-bytes");
+ fs.writeFileSync(path.join(cwd, "uploads", "garment.png"), "fake-png-bytes");
+ fs.writeFileSync(path.join(cwd, "uploads", "ref.mp4"), "fake-mp4-bytes");
+
+ child = spawn(process.execPath, ["server.js"], {
+ cwd,
+ env: {
+ ...process.env,
+ PORT: String(PORT),
+ H3_CONSOLE_BASE: `http://localhost:${mockH3Port}`,
+ H3_CONSOLE_TOKEN: "mock-token",
+ ADMIN_PASSWORD,
+ GENERATION_POLL_INTERVAL_MS: "400",
+ SCHEDULE_TICK_MS: "150",
+ },
+ stdio: "pipe",
+ });
+ await waitForServer(BASE);
+
+ const loginRes = await fetch(`${BASE}/api/admin/login`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ password: ADMIN_PASSWORD }),
+ });
+ cookie = (loginRes.headers.get("set-cookie") || "").split(";")[0];
+});
+
+test.after(() => {
+ child.kill();
+ mockH3.close();
+ fs.rmSync(cwd, { recursive: true, force: true });
+});
+
+async function waitUntil(fn, timeoutMs = 8000, intervalMs = 100) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const v = await fn();
+ if (v) return v;
+ await new Promise((r) => setTimeout(r, intervalMs));
+ }
+ throw new Error("condition not met in time");
+}
+
+let defaultCharacter, activeGarment, inactiveGarment, danceRef;
+
+test("seed: default character + active/inactive garments + dance-ref", async () => {
+ defaultCharacter = await fetch(
+ `${BASE}/api/admin/characters`,
+ authed({ method: "POST", body: JSON.stringify({ name: "衣柜小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair", isDefault: true }) })
+ ).then((r) => r.json());
+ assert.equal(defaultCharacter.isDefault, true);
+
+ activeGarment = await fetch(
+ `${BASE}/api/admin/garments`,
+ authed({ method: "POST", body: JSON.stringify({ name: "上架红裙", imageUrl: "/uploads/garment.png", appearanceDescriptor: "red dress", isActive: true }) })
+ ).then((r) => r.json());
+ inactiveGarment = await fetch(
+ `${BASE}/api/admin/garments`,
+ authed({ method: "POST", body: JSON.stringify({ name: "下架旗袍", imageUrl: "/uploads/garment.png", appearanceDescriptor: "qipao", isActive: false }) })
+ ).then((r) => r.json());
+
+ danceRef = await fetch(
+ `${BASE}/api/admin/dance-refs`,
+ authed({ method: "POST", body: JSON.stringify({ name: "衣柜拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) })
+ ).then((r) => r.json());
+ assert.ok(danceRef.id);
+});
+
+test("T12 GET /api/dance/wardrobe: only active garments, no admin auth needed", async () => {
+ const res = await fetch(`${BASE}/api/dance/wardrobe`); // deliberately no cookie
+ assert.equal(res.status, 200);
+ const list = await res.json();
+ assert.ok(list.some((g) => g.id === activeGarment.id));
+ assert.ok(!list.some((g) => g.id === inactiveGarment.id));
+});
+
+test("T15 POST /api/dance/pick: rejects unknown/inactive garmentId", async () => {
+ const missing = await fetch(`${BASE}/api/dance/pick`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ garmentId: "nope" }) });
+ assert.equal(missing.status, 400);
+
+ const inactive = await fetch(`${BASE}/api/dance/pick`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ garmentId: inactiveGarment.id }),
+ });
+ assert.equal(inactive.status, 400);
+});
+
+test("T15/T11 pick a garment (no login/cookie needed) -> generates -> lands in dance schedule; T17 vote + viewer count along the way", async () => {
+ const pickRes = await fetch(`${BASE}/api/dance/pick`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ garmentId: activeGarment.id }),
+ });
+ assert.equal(pickRes.status, 200);
+ const picked = await pickRes.json();
+ assert.equal(picked.status, "generating");
+ assert.ok(picked.id);
+
+ // While it's still generating, it should show up in the public queue and
+ // accept a vote (T17) — separate viewer/vote namespace from live.html.
+ const stateWhileGenerating = await fetch(`${BASE}/api/dance/live/state?cid=test-cid-1`).then((r) => r.json());
+ assert.ok(stateWhileGenerating.queue.some((q) => q.id === picked.id && q.status === "generating"));
+ assert.equal(stateWhileGenerating.watching, 1);
+
+ const voteRes = await fetch(`${BASE}/api/dance/queue/vote/${picked.id}`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ uid: "viewer-1" }),
+ });
+ assert.equal(voteRes.status, 200);
+ const vote = await voteRes.json();
+ assert.equal(vote.voted, true);
+ assert.equal(vote.votes, 1);
+
+ // Eventually the mock generation finishes and ensureDanceSchedule sweeps
+ // it into the schedule — current or upNext should carry the right
+ // character/garment traceability, and the entry should drop out of queue.
+ const finalState = await waitUntil(async () => {
+ const state = await fetch(`${BASE}/api/dance/live/state?cid=test-cid-1`).then((r) => r.json());
+ const inQueue = state.queue.some((q) => q.id === picked.id);
+ const landed = (state.current && state.current.clip && state.current.clip.garmentId === activeGarment.id) || state.upNext.some((c) => c.garmentId === activeGarment.id);
+ return !inQueue && landed ? state : null;
+ });
+ const landedClip = finalState.current && finalState.current.clip && finalState.current.clip.garmentId === activeGarment.id ? finalState.current.clip : finalState.upNext.find((c) => c.garmentId === activeGarment.id);
+ assert.equal(landedClip.characterId, defaultCharacter.id);
+ assert.equal(landedClip.refDanceVideoId, danceRef.id);
+ assert.equal(landedClip.channel, "dance");
+});
+
+test("T15 dedupe: re-picking the same garment returns ready immediately (M.7 cache hit), no new H3 submission", async () => {
+ const before = videoPollCounts.size;
+ const res = await fetch(`${BASE}/api/dance/pick`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ garmentId: activeGarment.id }),
+ });
+ assert.equal(res.status, 200);
+ const data = await res.json();
+ assert.equal(data.status, "ready");
+ assert.ok(data.videoId);
+ assert.equal(videoPollCounts.size, before); // no new /v1/videos/:id job started
+});
+
+test("T17: live.html and dance.html viewer counts are independent", async () => {
+ await fetch(`${BASE}/api/live/state?cid=live-viewer-a`);
+ await fetch(`${BASE}/api/live/state?cid=live-viewer-b`);
+ const danceState = await fetch(`${BASE}/api/dance/live/state?cid=dance-viewer-only`).then((r) => r.json());
+ assert.equal(danceState.watching, 2); // test-cid-1 (still within TTL) + dance-viewer-only, not the two live viewers
+});
diff --git a/dance.css b/dance.css
new file mode 100644
index 0000000..b46b870
--- /dev/null
+++ b/dance.css
@@ -0,0 +1,55 @@
+/* dance.html deltas on top of live.css — same comic-panel visual language,
+ loaded after live.css so these rules win. Only what dance.html actually
+ needs differently: a boxed 9:16 player (T13) and the wardrobe grid (T16).
+ Everything else (topbar, clip-row, qrow/up-btn, nickname modal, mobile
+ sub-tabs) is reused as-is from live.css. */
+
+/* dance.html's video is a fixed 480x832 (9:16) product, not the arbitrary
+ aspect ratio live.html's text-prompt clips can end up at — box it to that
+ ratio and center it instead of letting it stretch edge-to-edge on a wide
+ desktop viewport. */
+.player-wrap {
+ aspect-ratio: 9 / 16;
+ max-width: 100%;
+ max-height: 100%;
+ margin: auto;
+ width: auto;
+ height: 100%;
+}
+@media (max-width: 768px) {
+ .player-wrap { height: auto; width: 100%; }
+}
+
+.wardrobe-tabs { display: flex; gap: 8px; padding: 0 2px 4px; }
+.wardrobe-tabs button {
+ flex: 1;
+ font: bold 12px Tahoma, sans-serif;
+ background: #fff;
+ border: 3px solid #000;
+ color: var(--ink);
+ border-radius: 12px;
+ padding: 7px 4px;
+ box-shadow: var(--shadow-sm);
+ cursor: pointer;
+}
+.wardrobe-tabs button.active { background: var(--accent); }
+
+.wardrobe-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; }
+.wardrobe-card {
+ color: var(--ink);
+ background: #fff;
+ border: 3px solid #000;
+ border-radius: 14px;
+ padding: 6px;
+ box-shadow: var(--shadow);
+ cursor: pointer;
+ text-align: center;
+}
+.wardrobe-card:active { transform: translate(2px, 2px); box-shadow: 2px 2px 0 #000; }
+.wardrobe-card img { width: 100%; height: 90px; object-fit: cover; border-radius: 8px; margin-bottom: 4px; display: block; }
+.wardrobe-card .name { font-size: 12px; font-weight: bold; line-height: 1.3; }
+.wardrobe-card.disabled { opacity: .5; cursor: default; }
+
+.coming-soon { color: var(--muted); text-align: center; margin-top: 40px; font-size: 13px; font-weight: bold; padding: 0 12px; }
+
+.age-box .age-disclaimer { font-size: 12px; line-height: 1.6; color: #444; font-weight: normal; }
diff --git a/dance.html b/dance.html
new file mode 100644
index 0000000..a1756bd
--- /dev/null
+++ b/dance.html
@@ -0,0 +1,390 @@
+
+
+
+
+
+换装跳舞直播间
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/live.html b/live.html
index b180ba6..aea244d 100644
--- a/live.html
+++ b/live.html
@@ -22,6 +22,7 @@
+ 换装跳舞 →
diff --git a/server.js b/server.js
index d0bc773..96ac41f 100644
--- a/server.js
+++ b/server.js
@@ -81,6 +81,25 @@ function saveLiveState() {
fs.writeFileSync(LIVE_STATE_PATH, JSON.stringify({ schedule, promptQueue, chatMessages }, null, 2));
}
+// dance.html's own playout state (T11) — separate file/schedule/queue from
+// live.html's (architecture.md 2.1: two independent channels, not shared).
+// Per MVCP M.8, `schedule` here stores `videoId` references, not inlined
+// clip snapshots like live's `schedule` does — dance clips join back to
+// library.json at read time so an admin edit/takedown isn't stuck showing a
+// stale copy.
+const DANCE_STATE_PATH = path.join(__dirname, "dance-state.json");
+function loadDanceState() {
+ try {
+ const s = JSON.parse(fs.readFileSync(DANCE_STATE_PATH, "utf8"));
+ return { danceSchedule: s.schedule || [], danceQueue: s.freeQueue || [] };
+ } catch {
+ return { danceSchedule: [], danceQueue: [] };
+ }
+}
+function saveDanceState() {
+ fs.writeFileSync(DANCE_STATE_PATH, JSON.stringify({ schedule: danceSchedule, freeQueue: danceQueue }, null, 2));
+}
+
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let data = "";
@@ -398,6 +417,14 @@ let schedule = initialLiveState.schedule; // [{clip, startAt, endAt, requestedBy
let promptQueue = initialLiveState.promptQueue; // [{id, prompt, nickname?, status, clip?, error?, submittedAt, h3JobId?}]
let chatMessages = initialLiveState.chatMessages; // [{id, uid, nickname, text, createdAt, status}]
+const initialDanceState = loadDanceState();
+let danceSchedule = initialDanceState.danceSchedule; // [{videoId, startAt, endAt, requestedBy?}]
+let danceQueue = initialDanceState.danceQueue; // [{id, garmentId, characterId, nickname?, status, jobId?, videoId?, error?, submittedAt, votes}]
+
+// How often ensureSchedule/ensureDanceSchedule tick — configurable so tests
+// don't have to wait out a real 2s to see a schedule fill.
+const SCHEDULE_TICK_MS = Number(process.env.SCHEDULE_TICK_MS) || 2000;
+
// ---- chat + moderation ----
// ponytail: a starter blocklist, not a real content-policy list — swap in
// whatever the actual moderation requirements are. Substring match on the
@@ -484,46 +511,59 @@ function markPlayed(id) {
function voteCount(q) {
return (q.votes || []).length;
}
-function pickBestIndexByStatus(status) {
+// Shared by live's promptQueue and dance's freeQueue (T11) — takes the queue
+// array as a param rather than closing over a module-scope variable so both
+// channels' independent queues can use it.
+function pickBestIndexByStatus(queue, status) {
let bestIdx = -1;
- for (let i = 0; i < promptQueue.length; i++) {
- const q = promptQueue[i];
+ for (let i = 0; i < queue.length; i++) {
+ const q = queue[i];
if (q.status !== status) continue;
if (bestIdx === -1) {
bestIdx = i;
continue;
}
- const best = promptQueue[bestIdx];
+ const best = queue[bestIdx];
if (voteCount(q) > voteCount(best)) bestIdx = i;
}
return bestIdx;
}
+// Random-fill fallback shared by live's pickNextClip and dance's
+// pickNextDanceClip (T11) — least-played-first within a cooldown-filtered
+// pool, so idle filler time rotates evenly instead of a few clips dominating
+// by chance. `pool` is caller-filtered (live excludes dance channel clips
+// and vice versa, architecture.md 2.1: independent schedules per channel).
+function pickLeastPlayedClip(pool, prevId) {
+ if (!pool.length) return null;
+ const notPrev = pool.length > 1 ? pool.filter((c) => c.id !== prevId) : pool;
+ const now = Date.now();
+ // Never-played clips (no lastPlayedAt) are always eligible — genuinely
+ // random among everything that isn't on cooldown, no bias toward "fresh".
+ const offCooldown = notPrev.filter((c) => !c.lastPlayedAt || now - c.lastPlayedAt > REPEAT_COOLDOWN_MS);
+ const candidates = offCooldown.length ? offCooldown : notPrev;
+ const minPlays = Math.min(...candidates.map((c) => c.playCount || 0));
+ const leastPlayed = candidates.filter((c) => (c.playCount || 0) === minPlays);
+ return leastPlayed[Math.floor(Math.random() * leastPlayed.length)];
+}
function pickNextClip() {
- const readyIdx = pickBestIndexByStatus("ready");
+ const readyIdx = pickBestIndexByStatus(promptQueue, "ready");
if (readyIdx !== -1) {
const q = promptQueue.splice(readyIdx, 1)[0];
return { clip: q.clip, requestedBy: q.nickname, chatId: q.id };
}
- const matchedIdx = pickBestIndexByStatus("matched");
+ const matchedIdx = pickBestIndexByStatus(promptQueue, "matched");
if (matchedIdx !== -1) {
const q = promptQueue.splice(matchedIdx, 1)[0];
return { clip: q.clip, requestedBy: q.nickname, chatId: q.id };
}
- const lib = getLibrary();
+ // dance-channel clips (T8) don't belong in live.html's random filler pool
+ // — the two channels keep independent schedules (architecture.md 2.1).
+ const lib = getLibrary().filter((c) => c.channel !== "dance");
if (!lib.length) return null;
const prev = schedule.length ? schedule[schedule.length - 1].clip.id : null;
- const notPrev = lib.length > 1 ? lib.filter((c) => c.id !== prev) : lib;
- const now = Date.now();
- // Never-played clips (no lastPlayedAt) are always eligible — genuinely
- // random among everything that isn't on cooldown, no bias toward "fresh".
- const offCooldown = notPrev.filter((c) => !c.lastPlayedAt || now - c.lastPlayedAt > REPEAT_COOLDOWN_MS);
- const pool = offCooldown.length ? offCooldown : notPrev;
- // Within that pool, only randomize among the least-played clips — keeps
- // rotation even instead of letting a few clips dominate airtime by chance.
- const minPlays = Math.min(...pool.map((c) => c.playCount || 0));
- const leastPlayed = pool.filter((c) => (c.playCount || 0) === minPlays);
- return { clip: leastPlayed[Math.floor(Math.random() * leastPlayed.length)], requestedBy: null, chatId: null };
+ const clip = pickLeastPlayedClip(lib, prev);
+ return clip ? { clip, requestedBy: null, chatId: null } : null;
}
const FAILED_ENTRY_TTL_MS = 20000; // how long a "generation failed" entry stays visible before cleanup
@@ -545,11 +585,14 @@ function ensureSchedule() {
for (const [cid, lastSeen] of viewers) if (now - lastSeen > VIEWER_TTL_MS) viewers.delete(cid);
saveLiveState();
}
-setInterval(ensureSchedule, 2000);
+setInterval(ensureSchedule, SCHEDULE_TICK_MS);
// ---- viewer count: anyone whose /api/live/state poll landed in the last VIEWER_TTL_MS ----
const VIEWER_TTL_MS = 12000;
const viewers = new Map(); // clientId -> lastSeenAt
+// dance.html keeps its own viewer count (T17) — the two channels' "在线人数"
+// must not be summed together, they're separate rooms.
+const danceViewers = new Map(); // clientId -> lastSeenAt
// ---- rate limiting: cheap per-IP throttle on prompt submission ----
const RATE_LIMIT_MAX = 3;
@@ -776,6 +819,62 @@ function resumeDressupGenerations() {
}
}
+// ---- dance.html: independent playout schedule + free (mode-一/衣柜) queue (T11) ----
+// Mirrors live's pickNextClip/ensureSchedule shape but pulls from
+// danceQueue/danceSchedule instead, and picks its random-fill pool from
+// channel:"dance" clips only.
+
+function pickNextDanceClip() {
+ const readyIdx = pickBestIndexByStatus(danceQueue, "ready");
+ if (readyIdx !== -1) {
+ const q = danceQueue.splice(readyIdx, 1)[0];
+ return { videoId: q.videoId, requestedBy: q.nickname };
+ }
+ const pool = getLibrary().filter((c) => c.channel === "dance");
+ if (!pool.length) return null;
+ const prevVideoId = danceSchedule.length ? danceSchedule[danceSchedule.length - 1].videoId : null;
+ const clip = pickLeastPlayedClip(pool, prevVideoId);
+ return clip ? { videoId: clip.id, requestedBy: null } : null;
+}
+
+function ensureDanceSchedule() {
+ const now = Date.now();
+
+ // Reconcile in-flight picks against generationJobs — the store, not an
+ // in-memory promise, is the source of truth (T8's resumeDressupGenerations
+ // already re-polls H3 for jobs still running after a restart; this tick
+ // just needs to notice when they land, restart or not).
+ for (const q of danceQueue) {
+ if (q.status !== "generating" || !q.jobId) continue;
+ const job = generationJobs.get(q.jobId);
+ if (!job) continue;
+ if (job.status === "done") {
+ q.status = "ready";
+ q.videoId = job.generatedVideoId;
+ } else if (job.status === "failed") {
+ q.status = "failed";
+ q.error = job.error;
+ q.failedAt = now;
+ }
+ }
+
+ while (danceSchedule.length > 1 && danceSchedule[1].endAt <= now) danceSchedule.shift();
+ while (danceSchedule.length === 0 || danceSchedule[danceSchedule.length - 1].endAt < now + FILLER_DURATION_MS) {
+ const picked = pickNextDanceClip();
+ if (!picked || !picked.videoId) break;
+ const clip = getLibrary().find((c) => c.id === picked.videoId);
+ if (!clip) break; // stale/removed videoId — guard rather than schedule a dead entry
+ markPlayed(clip.id);
+ const startAt = danceSchedule.length ? danceSchedule[danceSchedule.length - 1].endAt : now;
+ danceSchedule.push({ videoId: picked.videoId, startAt, endAt: startAt + clip.duration * 1000, requestedBy: picked.requestedBy });
+ }
+ danceQueue = danceQueue.filter((q) => q.status !== "failed" || now - q.failedAt < FAILED_ENTRY_TTL_MS);
+
+ for (const [cid, lastSeen] of danceViewers) if (now - lastSeen > VIEWER_TTL_MS) danceViewers.delete(cid);
+ saveDanceState();
+}
+setInterval(ensureDanceSchedule, SCHEDULE_TICK_MS);
+
http
.createServer((req, res) => {
if (req.url.startsWith("/fal/")) {
@@ -926,6 +1025,138 @@ http
return;
}
+ // ---- dance.html: public viewer-facing endpoints (T11/T12/T15/T17) ----
+ // No admin auth (T6 only gates /api/admin/*) — these are the free,
+ // anonymous, 模式一 (衣柜选衣服) path. Modes 二/三 (paid upload) aren't
+ // built yet (require accounts/credits, dev-plan-2.md 第三/四期).
+
+ if (pathname === "/api/dance/wardrobe" && req.method === "GET") {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify(garments.list().filter((g) => g.isActive)));
+ return;
+ }
+
+ if (pathname === "/api/dance/live/state" && req.method === "GET") {
+ ensureDanceSchedule();
+ const now = Date.now();
+ const params = new URL(req.url, "http://x").searchParams;
+ const cid = params.get("cid");
+ const uid = params.get("uid") || null;
+ if (cid) danceViewers.set(cid, now);
+ let idx = danceSchedule.findIndex((s) => s.startAt <= now && now < s.endAt);
+ if (idx === -1) idx = danceSchedule.length - 1;
+ const cur = danceSchedule[idx];
+ const lib = getLibrary();
+ const joinClip = (videoId) => lib.find((c) => c.id === videoId) || null;
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(
+ JSON.stringify({
+ now,
+ watching: danceViewers.size,
+ current: cur ? { clip: joinClip(cur.videoId), startAt: cur.startAt, endAt: cur.endAt, requestedBy: cur.requestedBy || null } : null,
+ upNext: danceSchedule.slice(idx + 1, idx + 4).map((s) => ({ ...(joinClip(s.videoId) || {}), requestedBy: s.requestedBy || null })),
+ queue: danceQueue
+ .filter((q) => ["generating", "failed"].includes(q.status))
+ .slice()
+ .sort((a, b) => voteCount(b) - voteCount(a))
+ .map((q) => ({
+ id: q.id,
+ garmentId: q.garmentId,
+ nickname: q.nickname,
+ status: q.status,
+ error: q.error,
+ votes: voteCount(q),
+ voted: uid ? (q.votes || []).includes(uid) : false,
+ })),
+ })
+ );
+ return;
+ }
+
+ // P.1 免费模式一:{garmentId, nickname?}. Uses the single配置的直播默认
+ // 人物 (characters.isDefault) — 3.1 节: 免费用户不能指定/上传人物。
+ // Reuses submitGeneration (T8/T9) whole — same M.7 dedupe cache — then
+ // hands the result to danceQueue for ensureDanceSchedule to pick up.
+ if (pathname === "/api/dance/pick" && req.method === "POST") {
+ readJsonBody(req)
+ .then((body) => {
+ const garment = garments.get(body.garmentId);
+ if (!garment || !garment.isActive) {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "garmentId must reference an active garment" }));
+ return;
+ }
+ const defaultCharacter = characters.list().find((c) => c.isDefault);
+ if (!defaultCharacter) {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "no default character configured" }));
+ return;
+ }
+ const { status, body: respBody } = submitGeneration({ characterId: defaultCharacter.id, garmentId: garment.id });
+ if (status !== 200) {
+ res.writeHead(status, { "Content-Type": "application/json" });
+ res.end(JSON.stringify(respBody));
+ return;
+ }
+ const entry = {
+ id: crypto.randomUUID(),
+ garmentId: garment.id,
+ characterId: defaultCharacter.id,
+ nickname: (body.nickname || "").trim() || null,
+ submittedAt: Date.now(),
+ votes: [],
+ };
+ if (respBody.cached) {
+ entry.status = "ready";
+ entry.videoId = respBody.clip.id;
+ } else {
+ entry.status = "generating";
+ entry.jobId = respBody.jobId;
+ }
+ danceQueue.push(entry);
+ saveDanceState();
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ status: entry.status, id: entry.id, videoId: entry.videoId || null }));
+ })
+ .catch(() => {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "invalid JSON body" }));
+ });
+ return;
+ }
+
+ if (pathname.startsWith("/api/dance/queue/vote/") && req.method === "POST") {
+ const id = pathname.slice("/api/dance/queue/vote/".length);
+ readJsonBody(req)
+ .then((body) => {
+ const uid = (body.uid || "").trim();
+ if (!uid) {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "uid required" }));
+ return;
+ }
+ const entry = danceQueue.find((q) => q.id === id);
+ if (!entry || !["generating", "ready", "failed"].includes(entry.status)) {
+ res.writeHead(404, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "not found" }));
+ return;
+ }
+ if (!Array.isArray(entry.votes)) entry.votes = [];
+ const i = entry.votes.indexOf(uid);
+ const voted = i === -1;
+ if (voted) entry.votes.push(uid);
+ else entry.votes.splice(i, 1);
+ saveDanceState();
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ votes: entry.votes.length, voted }));
+ })
+ .catch(() => {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "invalid JSON body" }));
+ });
+ return;
+ }
+
if (pathname === "/api/library") {
const sort = new URL(req.url, "http://x").searchParams.get("sort") || "newest";
const lib = getLibrary().slice();