From f59c66d05eeb8bbb063b8d920da44905f617957b Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 2 Sep 2026 22:33:14 +0800 Subject: [PATCH] Add T34 (priority scheduling) + T35: mode 3 — POST /api/dance/upload-character --- dance-mode3.test.js | 296 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ pricing.js | 4 ++++ server.js | 248 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------ 3 files changed, 542 insertions(+), 6 deletions(-) create mode 100644 dance-mode3.test.js diff --git a/dance-mode3.test.js b/dance-mode3.test.js new file mode 100644 index 0000000..868cced --- /dev/null +++ b/dance-mode3.test.js @@ -0,0 +1,296 @@ +// Integration test for T34 (priorityInbox/paid_boost scheduling) + T35 +// (mode 3: 付费传人物), dev-plan-5.md. Same mock-H3-Console approach as +// dance-mode2.test.js. +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 TINY_VALID_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGPkEpFjYGBgYgADAALmAEAUQs4PAAAAAElFTkSuQmCC", + "base64" +); + +const PORT = 32934 + Math.floor(Math.random() * 1000); +const BASE = `http://localhost:${PORT}`; +const ADMIN_PASSWORD = "test-password-only-for-this-run"; +let adminCookie, child, cwd, mockH3, mockH3Port, videoPollCounts, defaultCharacter, freeGarment; + +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 sessionCookie(res) { + const setCookie = res.headers.get("set-cookie") || ""; + return setCookie.split(";")[0]; +} +function authed(cookie, opts = {}) { + return { ...opts, headers: { "Content-Type": "application/json", Cookie: cookie, ...opts.headers } }; +} + +// Same mock as dance-mode2.test.js — enough of /v1/assets, /v1/videos, +// /v1/videos/:id for generateDanceVideoViaH3 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(); + }); + }); +} + +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"); +} + +async function registerVerifiedUser(base) { + const email = `mode3-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`; + const registerRes = await fetch(`${base}/api/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password: "correct-horse-battery" }), + }); + const { devVerifyUrl } = await registerRes.json(); + const cookie = sessionCookie(registerRes); + await fetch(`${base}${devVerifyUrl}`); + return cookie; +} + +async function topUp(base, cookie, tierKey = "topup_tier_2") { + const checkoutRes = await fetch(`${base}/api/credits/checkout`, authed(cookie, { method: "POST", body: JSON.stringify({ tierKey }) })); + const { url } = await checkoutRes.json(); + await fetch(`${base}${url}`, { redirect: "manual" }); +} + +function uploadCharacter(base, cookie, { png = TINY_VALID_PNG, garmentId, segments } = {}) { + const fd = new FormData(); + fd.append("file", new Blob([png], { type: "image/png" }), "character.png"); + if (garmentId) fd.append("garmentId", garmentId); + if (segments) fd.append("segments", String(segments)); + return fetch(`${base}/api/dance/upload-character`, { method: "POST", headers: { Cookie: cookie }, body: fd }); +} + +test.before(async () => { + await startMockH3(); + + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-mode3-")); + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.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: "200", + SCHEDULE_TICK_MS: "100", + }, + 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 }), + }); + adminCookie = sessionCookie(loginRes); + + defaultCharacter = await fetch( + `${BASE}/api/admin/characters`, + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "衣柜小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair", isDefault: true }) }) + ).then((r) => r.json()); + freeGarment = await fetch( + `${BASE}/api/admin/garments`, + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "免费红裙", imageUrl: "/uploads/garment.png", appearanceDescriptor: "red dress" }) }) + ).then((r) => r.json()); + await fetch( + `${BASE}/api/admin/dance-refs`, + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) }) + ); +}); + +test.after(() => { + child.kill(); + mockH3.close(); + fs.rmSync(cwd, { recursive: true, force: true }); +}); + +test("upload-character requires login and a verified email", async () => { + const noAuth = await uploadCharacter(BASE, ""); + assert.equal(noAuth.status, 401); + + const registerRes = await fetch(`${BASE}/api/auth/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: `mode3-unverified-${Date.now()}@example.com`, password: "correct-horse-battery" }), + }); + const unverifiedCookie = sessionCookie(registerRes); + const unverified = await uploadCharacter(BASE, unverifiedCookie); + assert.equal(unverified.status, 403); +}); + +test("insufficient balance is rejected (402) and leaves no character behind", async () => { + const cookie = await registerVerifiedUser(BASE); + const before = await fetch(`${BASE}/api/admin/characters`, authed(adminCookie)).then((r) => r.json()); + + const res = await uploadCharacter(BASE, cookie, { garmentId: freeGarment.id, segments: 2 }); + assert.equal(res.status, 402); + + const after = await fetch(`${BASE}/api/admin/characters`, authed(adminCookie)).then((r) => r.json()); + assert.equal(after.length, before.length); +}); + +test("with no garmentId and no active garments, upload is rejected before any spend", async () => { + // Deactivate the only garment for this one check, then reactivate. + await fetch(`${BASE}/api/admin/garments/${freeGarment.id}`, authed(adminCookie, { method: "PATCH", body: JSON.stringify({ isActive: false }) })); + const cookie = await registerVerifiedUser(BASE); + await topUp(BASE, cookie); + const res = await uploadCharacter(BASE, cookie, { segments: 1 }); // no garmentId -> forced to random-pick, pool is empty + assert.equal(res.status, 400); + await fetch(`${BASE}/api/admin/garments/${freeGarment.id}`, authed(adminCookie, { method: "PATCH", body: JSON.stringify({ isActive: true }) })); +}); + +test("full happy path: cost scales with requested segments, character carries user_paid/ownerUserId/totalSegments, clip is paid_boost", async () => { + const cookie = await registerVerifiedUser(BASE); + await topUp(BASE, cookie); + const meBefore = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); + + const res = await uploadCharacter(BASE, cookie, { garmentId: freeGarment.id, segments: 4 }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.totalSegments, 4); + assert.ok(body.creditsSpent > 0); + + const pricingEntry = await fetch(`${BASE}/api/pricing`).then((r) => r.json()).then((rows) => rows.find((p) => p.key === "spend_character_per_segment")); + const expectedCost = Math.ceil((pricingEntry.credits / (pricingEntry.segmentsPerUnit || 1)) * 4); + assert.equal(body.creditsSpent, expectedCost, "cost must scale with requested segments, not be a flat price like mode 2's"); + + const meAfter = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); + assert.equal(meAfter.creditBalance, meBefore.creditBalance - expectedCost); + + const characters = await fetch(`${BASE}/api/admin/characters`, authed(adminCookie)).then((r) => r.json()); + const created = characters.find((c) => c.id === body.characterId); + assert.ok(created); + assert.equal(created.source, "user_paid"); + assert.equal(created.ownerUserId, meBefore.id); + assert.equal(created.isDefault, false, "a paid character must never become the shared default (需求 3.3 第 6 点)"); + assert.equal(created.totalSegments, 4); + assert.equal(created.remainingSegments, 4); + assert.equal(created.status, "active"); + assert.ok(created.appearanceDescriptor && created.appearanceDescriptor.length > 0); + + const state = await waitUntil(async () => { + const s = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json()); + const all = [s.current, ...(s.upNext || [])].filter(Boolean); + return all.find((c) => c.clip ? c.clip.characterId === body.characterId : c.characterId === body.characterId) ? s : null; + }); + const clipView = state.current && state.current.clip && state.current.clip.characterId === body.characterId ? state.current.clip : state.upNext.find((c) => c.characterId === body.characterId); + assert.equal(clipView.priority, "paid_boost"); + assert.equal(clipView.requestedByUserId, meBefore.id); + assert.equal(clipView.garmentId, freeGarment.id); +}); + +test("T34: a paid_boost character skips the vote queue — it's scheduled ahead of an already-ready free pick", async () => { + // A second, distinct garment so this free pick doesn't hit M.7's + // dedupe cache against a clip from an earlier test using the same + // (defaultCharacter, freeGarment) combo. + const freeGarment2 = await fetch( + `${BASE}/api/admin/garments`, + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "免费蓝裙", imageUrl: "/uploads/garment.png", appearanceDescriptor: "blue dress" }) }) + ).then((r) => r.json()); + + // Submit the free pick first, giving it every opportunity to be + // generated and scheduled before the paid_boost one even exists. + await fetch(`${BASE}/api/dance/pick`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ garmentId: freeGarment2.id, nickname: "免费观众" }) }); + + const cookie = await registerVerifiedUser(BASE); + await topUp(BASE, cookie); + const paidRes = await uploadCharacter(BASE, cookie, { garmentId: freeGarment2.id, segments: 1 }); + assert.equal(paidRes.status, 200); + const paidBody = await paidRes.json(); + + // Matched by characterId — free entries always use defaultCharacter.id, + // paid_boost entries always create a brand-new character, so this + // unambiguously tells the two apart even though both target the same + // garment. + const state = await waitUntil(async () => { + const s = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json()); + const all = [s.current, ...(s.upNext || [])].filter(Boolean); + const hasPaid = all.some((c) => (c.clip ? c.clip.characterId : c.characterId) === paidBody.characterId); + return hasPaid ? s : null; + }); + const all = [state.current, ...(state.upNext || [])].filter(Boolean); + const characterIdOf = (c) => (c.clip ? c.clip.characterId : c.characterId); + const paidIndex = all.findIndex((c) => characterIdOf(c) === paidBody.characterId); + const freeIndex = all.findIndex((c) => characterIdOf(c) === defaultCharacter.id && (c.clip ? c.clip.garmentId : c.garmentId) === freeGarment2.id); + assert.ok(paidIndex !== -1, "paid_boost clip must appear in current/upNext"); + if (freeIndex !== -1) { + assert.ok(paidIndex <= freeIndex, `paid_boost (index ${paidIndex}) must not be scheduled after the competing free pick (index ${freeIndex})`); + } +}); diff --git a/pricing.js b/pricing.js index 2e4e654..acd6628 100644 --- a/pricing.js +++ b/pricing.js @@ -13,6 +13,10 @@ const PRICING_PATH = process.env.PRICING_PATH || path.join(__dirname, "pricing.j // in pricing.json (or here, before first run) whenever real pricing lands. const DEFAULTS = [ { key: "spend_garment", kind: "spend", credits: 10, priceCents: null, segmentsPerUnit: null, isActive: true, updatedAt: Date.now() }, + // dev-plan-5.md T35: credits/segmentsPerUnit together mean "20 credits + // buys 1 ten-second segment" — cost = ceil(credits/segmentsPerUnit * + // requestedSegments). Placeholder, same as spend_garment above. + { key: "spend_character_per_segment", kind: "spend", credits: 20, priceCents: null, segmentsPerUnit: 1, isActive: true, updatedAt: Date.now() }, { key: "topup_tier_1", kind: "topup_tier", credits: 100, priceCents: 999, segmentsPerUnit: null, isActive: true, updatedAt: Date.now() }, { key: "topup_tier_2", kind: "topup_tier", credits: 550, priceCents: 4999, segmentsPerUnit: null, isActive: true, updatedAt: Date.now() }, ]; diff --git a/server.js b/server.js index 6dcfe06..28b49c3 100644 --- a/server.js +++ b/server.js @@ -114,13 +114,13 @@ 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 || [] }; + return { danceSchedule: s.schedule || [], danceQueue: s.freeQueue || [], priorityInbox: s.priorityInbox || [] }; } catch { - return { danceSchedule: [], danceQueue: [] }; + return { danceSchedule: [], danceQueue: [], priorityInbox: [] }; } } function saveDanceState() { - fs.writeFileSync(DANCE_STATE_PATH, JSON.stringify({ schedule: danceSchedule, freeQueue: danceQueue }, null, 2)); + fs.writeFileSync(DANCE_STATE_PATH, JSON.stringify({ schedule: danceSchedule, freeQueue: danceQueue, priorityInbox }, null, 2)); } function readJsonBody(req) { @@ -228,6 +228,16 @@ async function describeGarmentImage(absImagePath) { throw new Error("vision vendor not implemented — set VISION_API_KEY only once a provider is wired up"); } +// T35 (dev-plan-5.md): same seam as describeGarmentImage above, for mode +// 3's uploaded face photos — appearanceDescriptor has no admin to +// hand-write it either, same underlying gap. Shares VISION_API_KEY (one +// vendor question, not two) since both are "describe an uploaded photo" +// calls to whatever vision model eventually gets picked. +async function describeCharacterImage(absImagePath) { + if (!VISION_API_KEY) return "用户上传的人物形象"; + throw new Error("vision vendor not implemented — set VISION_API_KEY only once a provider is wired up"); +} + // T26: same seam pattern, for image-content moderation (dev-plan-4.md §1.2 // item 1 — the requirements doc names a vendor as "TBD" and never actually // lists it as its own task, so this is that gap filled in). Every call logs @@ -679,6 +689,10 @@ let chatMessages = initialLiveState.chatMessages; // [{id, uid, nickname, text, 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}] +// T34 (dev-plan-5.md; schema M.8): paid_boost (mode 3) clips land here once +// ready, never in danceQueue's vote-ranked path — pickNextDanceClip drains +// this first, unconditionally, every tick. +let priorityInbox = initialDanceState.priorityInbox; // [{videoId, requestedBy}] // How often ensureSchedule/ensureDanceSchedule tick — configurable so tests // don't have to wait out a real 2s to see a schedule fill. @@ -1311,13 +1325,24 @@ function ensureDanceSchedule() { // 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). + // T34: a paid_boost job landing here gets diverted to priorityInbox + // instead of "ready" (which would otherwise route it through + // pickBestIndexByStatus's vote-ranked pick below) — collected here and + // spliced out of danceQueue after the loop rather than mid-iteration, + // since danceQueue is being iterated live. + const divertedToPriorityInbox = []; 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; + if (job.priority === "paid_boost") { + priorityInbox.push({ videoId: job.generatedVideoId, requestedBy: q.nickname || null }); + divertedToPriorityInbox.push(q); + } else { + q.status = "ready"; + q.videoId = job.generatedVideoId; + } } else if (job.status === "failed") { q.status = "failed"; q.error = job.error; @@ -1352,6 +1377,30 @@ function ensureDanceSchedule() { } while (danceSchedule.length > 1 && danceSchedule[1].endAt <= now) danceSchedule.shift(); + + // T34: paid_boost content must play "next" (需求文档 3 节: "确定性上镜"), + // not just "whenever the 30s-ahead fill horizon next needs topping up" — + // if the schedule is already pre-filled (common: FILLER_DURATION_MS keeps + // ~30s queued), the fill loop below wouldn't run again for a while and a + // ready priorityInbox entry could sit for a long time. So this inserts + // eagerly, right after whatever's currently playing (danceSchedule[0], + // guaranteed accurate now that expired entries were just shifted out + // above), pushing every later entry's timing back by this clip's + // duration rather than waiting for its "natural" turn. + while (priorityInbox.length) { + const next = priorityInbox.shift(); + const clip = getLibrary().find((c) => c.id === next.videoId); + if (!clip) continue; // stale/removed videoId — drop rather than schedule a dead entry + markPlayed(clip.id); + const insertAt = danceSchedule.length ? 1 : 0; + const prevEndAt = insertAt > 0 ? danceSchedule[insertAt - 1].endAt : now; + danceSchedule.splice(insertAt, 0, { videoId: next.videoId, startAt: prevEndAt, endAt: prevEndAt + clip.duration * 1000, requestedBy: next.requestedBy }); + for (let i = insertAt + 1; i < danceSchedule.length; i++) { + danceSchedule[i].startAt += clip.duration * 1000; + danceSchedule[i].endAt += clip.duration * 1000; + } + } + while (danceSchedule.length === 0 || danceSchedule[danceSchedule.length - 1].endAt < now + FILLER_DURATION_MS) { const picked = pickNextDanceClip(); if (!picked || !picked.videoId) break; @@ -1361,7 +1410,7 @@ function ensureDanceSchedule() { 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); + danceQueue = danceQueue.filter((q) => !divertedToPriorityInbox.includes(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(); @@ -2046,6 +2095,193 @@ http return; } + // T35 模式三: 付费传人物 (dev-plan-5.md). 需登录 + emailVerified, same + // rule as T27. Differs from T27 in three structural ways: (1) the + // upload itself becomes the new characters record (not paired with an + // existing default character — garment is what's optional/random here, + //需求文档 3.3 第 4 点); (2) cost scales with how many 10s segments the + // buyer requests (M.12's segmentsPerUnit), not a flat per-upload price; + // (3) the produced clip carries priority:"paid_boost" end to end + // (submitGeneration -> generationJobs -> either straight into + // priorityInbox if cached, or into danceQueue to be diverted there once + // T34's ensureDanceSchedule reconciliation sees the job land) — it + // never enters the vote-ranked path T27's mode-2 entries do. + if (pathname === "/api/dance/upload-character" && req.method === "POST") { + const user = requireUserSession(req); + if (!user) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "login required" })); + return; + } + if (!user.emailVerified) { + res.writeHead(403, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "email verification required before uploading a character" })); + return; + } + const boundary = boundaryFromContentType(req.headers["content-type"] || ""); + if (!boundary) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "expected multipart/form-data" })); + return; + } + readRawBody(req) + .then(async (raw) => { + const parts = parseMultipart(raw, boundary); + const filePart = parts.find((p) => p.name === "file"); + if (!filePart) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "missing file field" })); + return; + } + const sniffed = sniffImageType(filePart.data); + if (!sniffed) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "unrecognized image type (expected jpeg/png/webp)" })); + return; + } + + // 需求文档 3.3 第 4 点:衣服可选,指定就用指定的,否则系统随机 + // 挑一件衣柜库服装. + const garmentIdPart = parts.find((p) => p.name === "garmentId"); + const requestedGarmentId = garmentIdPart ? garmentIdPart.data.toString("utf8").trim() : ""; + let targetGarment; + if (requestedGarmentId) { + targetGarment = garments.get(requestedGarmentId); + if (!targetGarment || !targetGarment.isActive) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "garmentId must reference an active garment" })); + return; + } + } else { + const activeGarments = garments.list().filter((g) => g.isActive); + if (!activeGarments.length) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "no active garments available to pair with the new character" })); + return; + } + targetGarment = activeGarments[Math.floor(Math.random() * activeGarments.length)]; + } + + // 需求文档 3.3 第 3 点:花的积分决定能播放几个 10 秒片段. + const segmentsPart = parts.find((p) => p.name === "segments"); + const requestedSegments = Math.min(20, Math.max(1, parseInt(segmentsPart ? segmentsPart.data.toString("utf8").trim() : "3", 10) || 3)); + const pricingEntry = pricing.get("spend_character_per_segment"); + if (!pricingEntry) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "spend_character_per_segment pricing not configured" })); + return; + } + const cost = Math.ceil((pricingEntry.credits / (pricingEntry.segmentsPerUnit || 1)) * requestedSegments); + + let relPath, absPath; + try { + ({ relPath } = stripMetadataAndSave(filePart.data, sniffed)); + absPath = path.join(__dirname, relPath); + } catch { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "failed to process image" })); + return; + } + + const moderation = await moderateFaceBlacklist(absPath, relPath); + if (!moderation.approved) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "image rejected by moderation", reason: moderation.reason })); + return; + } + + const appearanceDescriptor = await describeCharacterImage(absPath); + // 需求文档 3.3 第 6 点:新人物默认不沉淀进公共人物池(isDefault + // 恒为 false,且不会被 /api/dance/pick 的默认人物逻辑抽中——那条 + // 逻辑只认 isDefault:true,一个 user_paid 人物永远不会是它)。 + const character = characters.create({ + name: `${user.nickname || user.email.split("@")[0]} 的新角色`, + imageUrl: relPath, + tags: [], + source: "user_paid", + ownerUserId: user.id, + appearanceDescriptor, + isDefault: false, + moderationStatus: "auto_passed", + totalSegments: requestedSegments, + remainingSegments: requestedSegments, + status: "active", + }); + + let spendRow; + try { + spendRow = creditLedger.spend({ userId: user.id, amount: cost, reason: "spend_character", relatedAssetId: character.id }); + } catch (err) { + characters.remove(character.id); + const status = err.code === "INSUFFICIENT_BALANCE" ? 402 : 500; + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err.message })); + return; + } + + // priority:"paid_boost" (T34) — this is the one call site in the + // whole codebase that requests it; mode 2 (T27) never does. + const { status, body: respBody } = submitGeneration({ + characterId: character.id, + garmentId: targetGarment.id, + userId: user.id, + priority: "paid_boost", + }); + if (status !== 200) { + creditLedger.refund({ userId: user.id, amount: cost, reason: "refund_stage2_fail", relatedAssetId: character.id }); + characters.remove(character.id); + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(respBody)); + return; + } + + const entry = { + id: crypto.randomUUID(), + characterId: character.id, + garmentId: targetGarment.id, + nickname: user.nickname || null, + userId: user.id, + amountPaid: cost, + priority: "paid_boost", + submittedAt: Date.now(), + votes: [], + }; + let videoId = null; + if (respBody.cached) { + // M.7 去重缓存命中是即时的 —— T34 的 ensureDanceSchedule 转运 + // 逻辑只在意"generating -> done"这个过渡,命中缓存跳过了整个 + // 过渡,所以这里直接进 priorityInbox,不经过 danceQueue。 + videoId = respBody.clip.id; + priorityInbox.push({ videoId, requestedBy: entry.nickname }); + } else { + entry.status = "generating"; + entry.jobId = respBody.jobId; + danceQueue.push(entry); + } + saveDanceState(); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + status: respBody.cached ? "ready" : entry.status, + id: entry.id, + characterId: character.id, + garmentId: targetGarment.id, + videoId, + creditsSpent: cost, + totalSegments: requestedSegments, + balanceAfter: spendRow.balanceAfter, + }) + ); + }) + .catch((err) => { + if (res.writableEnded) return; + const status = err.code === "PAYLOAD_TOO_LARGE" ? 413 : 400; + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err.message || "upload failed" })); + }); + return; + } + if (pathname.startsWith("/api/dance/queue/vote/") && req.method === "POST") { const id = pathname.slice("/api/dance/queue/vote/".length); readJsonBody(req) -- libgit2 0.21.4