Commit f59c66d05eeb8bbb063b8d920da44905f617957b
1 parent
4bfa5769
Add T34 (priority scheduling) + T35: mode 3 — POST /api/dance/upload-character
T34: priorityInbox (M.8) — paid_boost clips get inserted immediately after whatever's currently playing (danceSchedule[0]), pushing every later entry's timing back, rather than waiting for the fill-loop's normal "top up to 30s ahead" trigger. That distinction mattered: draining priorityInbox only from inside the fill loop (first attempt) technically satisfied "never loses to a vote-ranked pick" but could leave a ready paid_boost clip waiting a long time if the schedule already had ~30s queued — dance-mode3.test.js's race test caught this (timed out) before the eager-insert fix. ensureDanceSchedule's existing "generating -> done" reconciliation loop diverts a job into priorityInbox instead of danceQueue's "ready" state when job.priority === "paid_boost", spliced out of danceQueue after the loop (not mid-iteration). T35: POST /api/dance/upload-character (需登录+emailVerified, same rule as T27). Face photo -> T2 upload+EXIF strip -> T32 moderateFaceBlacklist -> T25-style describeCharacterImage (new pluggable fn, shares VISION_API_KEY) -> characters.create(source:"user_paid", isDefault: false, totalSegments/remainingSegments computed from pricing_config's new spend_character_per_segment×segmentsPerUnit) -> creditLedger.spend (relatedAssetId=character.id, rolled back on failure) -> submitGeneration(..., priority:"paid_boost") -> lands in priorityInbox (cache hit) or danceQueue pending T34's reconciliation (fresh generation). Garment is optional (need求文档 3.3 第 4 点): given garmentId is validated active-or-400, otherwise a random active garment is picked — a fully empty wardrobe is a 400 before any spend. dance-mode3.test.js covers both: auth/balance/no-active-garments gates, the happy path (cost scales with requested segments, character record shape, clip carries priority/requestedByUserId), and T34's actual guarantee — a paid_boost character is scheduled at or ahead of an already-ready free pick for the same garment, distinguished by characterId since both target the same garment on purpose. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011DUiYs6UjxDMkfvnjtsKFo
Showing
3 changed files
with
542 additions
and
6 deletions
dance-mode3.test.js
0 → 100644
| 1 | +// Integration test for T34 (priorityInbox/paid_boost scheduling) + T35 | |
| 2 | +// (mode 3: 付费传人物), dev-plan-5.md. Same mock-H3-Console approach as | |
| 3 | +// dance-mode2.test.js. | |
| 4 | +const test = require("node:test"); | |
| 5 | +const assert = require("node:assert/strict"); | |
| 6 | +const fs = require("node:fs"); | |
| 7 | +const path = require("node:path"); | |
| 8 | +const os = require("node:os"); | |
| 9 | +const http = require("node:http"); | |
| 10 | +const { spawn } = require("node:child_process"); | |
| 11 | + | |
| 12 | +const TINY_VALID_PNG = Buffer.from( | |
| 13 | + "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGPkEpFjYGBgYgADAALmAEAUQs4PAAAAAElFTkSuQmCC", | |
| 14 | + "base64" | |
| 15 | +); | |
| 16 | + | |
| 17 | +const PORT = 32934 + Math.floor(Math.random() * 1000); | |
| 18 | +const BASE = `http://localhost:${PORT}`; | |
| 19 | +const ADMIN_PASSWORD = "test-password-only-for-this-run"; | |
| 20 | +let adminCookie, child, cwd, mockH3, mockH3Port, videoPollCounts, defaultCharacter, freeGarment; | |
| 21 | + | |
| 22 | +function waitForServer(url, timeoutMs = 10000) { | |
| 23 | + const deadline = Date.now() + timeoutMs; | |
| 24 | + return (async function poll() { | |
| 25 | + while (Date.now() < deadline) { | |
| 26 | + try { | |
| 27 | + await fetch(url); | |
| 28 | + return; | |
| 29 | + } catch { | |
| 30 | + await new Promise((r) => setTimeout(r, 100)); | |
| 31 | + } | |
| 32 | + } | |
| 33 | + throw new Error("server did not come up in time"); | |
| 34 | + })(); | |
| 35 | +} | |
| 36 | + | |
| 37 | +function sessionCookie(res) { | |
| 38 | + const setCookie = res.headers.get("set-cookie") || ""; | |
| 39 | + return setCookie.split(";")[0]; | |
| 40 | +} | |
| 41 | +function authed(cookie, opts = {}) { | |
| 42 | + return { ...opts, headers: { "Content-Type": "application/json", Cookie: cookie, ...opts.headers } }; | |
| 43 | +} | |
| 44 | + | |
| 45 | +// Same mock as dance-mode2.test.js — enough of /v1/assets, /v1/videos, | |
| 46 | +// /v1/videos/:id for generateDanceVideoViaH3 to run end to end. | |
| 47 | +function startMockH3() { | |
| 48 | + videoPollCounts = new Map(); | |
| 49 | + let nextId = 1; | |
| 50 | + return new Promise((resolve) => { | |
| 51 | + mockH3 = http.createServer((req, res) => { | |
| 52 | + req.url = req.url.replace(/^\/+/, "/"); | |
| 53 | + const chunks = []; | |
| 54 | + req.on("data", (c) => chunks.push(c)); | |
| 55 | + req.on("end", () => { | |
| 56 | + if (req.method === "POST" && req.url === "/v1/assets") { | |
| 57 | + const id = `asset-${nextId++}`; | |
| 58 | + res.writeHead(200, { "Content-Type": "application/json" }); | |
| 59 | + res.end(JSON.stringify({ id, url: `http://mock-h3/asset/${id}`, kind: "image" })); | |
| 60 | + return; | |
| 61 | + } | |
| 62 | + if (req.method === "POST" && req.url === "/v1/videos") { | |
| 63 | + const id = `job-${nextId++}`; | |
| 64 | + res.writeHead(200, { "Content-Type": "application/json" }); | |
| 65 | + res.end(JSON.stringify({ id })); | |
| 66 | + return; | |
| 67 | + } | |
| 68 | + const pollMatch = req.method === "GET" && req.url.match(/^\/v1\/videos\/(.+)$/); | |
| 69 | + if (pollMatch) { | |
| 70 | + const id = pollMatch[1]; | |
| 71 | + const seen = (videoPollCounts.get(id) || 0) + 1; | |
| 72 | + videoPollCounts.set(id, seen); | |
| 73 | + if (seen < 2) { | |
| 74 | + res.writeHead(200, { "Content-Type": "application/json" }); | |
| 75 | + res.end(JSON.stringify({ id, status: "generating" })); | |
| 76 | + return; | |
| 77 | + } | |
| 78 | + res.writeHead(200, { "Content-Type": "application/json" }); | |
| 79 | + res.end(JSON.stringify({ id, status: "done", partition: "ref2va", req: { width: 480, height: 832, frames: 240 } })); | |
| 80 | + return; | |
| 81 | + } | |
| 82 | + res.writeHead(404, { "Content-Type": "application/json" }); | |
| 83 | + res.end(JSON.stringify({ error: "not found in mock" })); | |
| 84 | + }); | |
| 85 | + }); | |
| 86 | + mockH3.listen(0, () => { | |
| 87 | + mockH3Port = mockH3.address().port; | |
| 88 | + resolve(); | |
| 89 | + }); | |
| 90 | + }); | |
| 91 | +} | |
| 92 | + | |
| 93 | +async function waitUntil(fn, timeoutMs = 8000, intervalMs = 100) { | |
| 94 | + const deadline = Date.now() + timeoutMs; | |
| 95 | + while (Date.now() < deadline) { | |
| 96 | + const v = await fn(); | |
| 97 | + if (v) return v; | |
| 98 | + await new Promise((r) => setTimeout(r, intervalMs)); | |
| 99 | + } | |
| 100 | + throw new Error("condition not met in time"); | |
| 101 | +} | |
| 102 | + | |
| 103 | +async function registerVerifiedUser(base) { | |
| 104 | + const email = `mode3-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`; | |
| 105 | + const registerRes = await fetch(`${base}/api/auth/register`, { | |
| 106 | + method: "POST", | |
| 107 | + headers: { "Content-Type": "application/json" }, | |
| 108 | + body: JSON.stringify({ email, password: "correct-horse-battery" }), | |
| 109 | + }); | |
| 110 | + const { devVerifyUrl } = await registerRes.json(); | |
| 111 | + const cookie = sessionCookie(registerRes); | |
| 112 | + await fetch(`${base}${devVerifyUrl}`); | |
| 113 | + return cookie; | |
| 114 | +} | |
| 115 | + | |
| 116 | +async function topUp(base, cookie, tierKey = "topup_tier_2") { | |
| 117 | + const checkoutRes = await fetch(`${base}/api/credits/checkout`, authed(cookie, { method: "POST", body: JSON.stringify({ tierKey }) })); | |
| 118 | + const { url } = await checkoutRes.json(); | |
| 119 | + await fetch(`${base}${url}`, { redirect: "manual" }); | |
| 120 | +} | |
| 121 | + | |
| 122 | +function uploadCharacter(base, cookie, { png = TINY_VALID_PNG, garmentId, segments } = {}) { | |
| 123 | + const fd = new FormData(); | |
| 124 | + fd.append("file", new Blob([png], { type: "image/png" }), "character.png"); | |
| 125 | + if (garmentId) fd.append("garmentId", garmentId); | |
| 126 | + if (segments) fd.append("segments", String(segments)); | |
| 127 | + return fetch(`${base}/api/dance/upload-character`, { method: "POST", headers: { Cookie: cookie }, body: fd }); | |
| 128 | +} | |
| 129 | + | |
| 130 | +test.before(async () => { | |
| 131 | + await startMockH3(); | |
| 132 | + | |
| 133 | + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dance-mode3-")); | |
| 134 | + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { | |
| 135 | + fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); | |
| 136 | + } | |
| 137 | + fs.mkdirSync(path.join(cwd, "uploads"), { recursive: true }); | |
| 138 | + fs.writeFileSync(path.join(cwd, "uploads", "person.png"), "fake-png-bytes"); | |
| 139 | + fs.writeFileSync(path.join(cwd, "uploads", "garment.png"), "fake-png-bytes"); | |
| 140 | + fs.writeFileSync(path.join(cwd, "uploads", "ref.mp4"), "fake-mp4-bytes"); | |
| 141 | + | |
| 142 | + child = spawn(process.execPath, ["server.js"], { | |
| 143 | + cwd, | |
| 144 | + env: { | |
| 145 | + ...process.env, | |
| 146 | + PORT: String(PORT), | |
| 147 | + H3_CONSOLE_BASE: `http://localhost:${mockH3Port}`, | |
| 148 | + H3_CONSOLE_TOKEN: "mock-token", | |
| 149 | + ADMIN_PASSWORD, | |
| 150 | + GENERATION_POLL_INTERVAL_MS: "200", | |
| 151 | + SCHEDULE_TICK_MS: "100", | |
| 152 | + }, | |
| 153 | + stdio: "pipe", | |
| 154 | + }); | |
| 155 | + await waitForServer(BASE); | |
| 156 | + | |
| 157 | + const loginRes = await fetch(`${BASE}/api/admin/login`, { | |
| 158 | + method: "POST", | |
| 159 | + headers: { "Content-Type": "application/json" }, | |
| 160 | + body: JSON.stringify({ password: ADMIN_PASSWORD }), | |
| 161 | + }); | |
| 162 | + adminCookie = sessionCookie(loginRes); | |
| 163 | + | |
| 164 | + defaultCharacter = await fetch( | |
| 165 | + `${BASE}/api/admin/characters`, | |
| 166 | + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "衣柜小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair", isDefault: true }) }) | |
| 167 | + ).then((r) => r.json()); | |
| 168 | + freeGarment = await fetch( | |
| 169 | + `${BASE}/api/admin/garments`, | |
| 170 | + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "免费红裙", imageUrl: "/uploads/garment.png", appearanceDescriptor: "red dress" }) }) | |
| 171 | + ).then((r) => r.json()); | |
| 172 | + await fetch( | |
| 173 | + `${BASE}/api/admin/dance-refs`, | |
| 174 | + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) }) | |
| 175 | + ); | |
| 176 | +}); | |
| 177 | + | |
| 178 | +test.after(() => { | |
| 179 | + child.kill(); | |
| 180 | + mockH3.close(); | |
| 181 | + fs.rmSync(cwd, { recursive: true, force: true }); | |
| 182 | +}); | |
| 183 | + | |
| 184 | +test("upload-character requires login and a verified email", async () => { | |
| 185 | + const noAuth = await uploadCharacter(BASE, ""); | |
| 186 | + assert.equal(noAuth.status, 401); | |
| 187 | + | |
| 188 | + const registerRes = await fetch(`${BASE}/api/auth/register`, { | |
| 189 | + method: "POST", | |
| 190 | + headers: { "Content-Type": "application/json" }, | |
| 191 | + body: JSON.stringify({ email: `mode3-unverified-${Date.now()}@example.com`, password: "correct-horse-battery" }), | |
| 192 | + }); | |
| 193 | + const unverifiedCookie = sessionCookie(registerRes); | |
| 194 | + const unverified = await uploadCharacter(BASE, unverifiedCookie); | |
| 195 | + assert.equal(unverified.status, 403); | |
| 196 | +}); | |
| 197 | + | |
| 198 | +test("insufficient balance is rejected (402) and leaves no character behind", async () => { | |
| 199 | + const cookie = await registerVerifiedUser(BASE); | |
| 200 | + const before = await fetch(`${BASE}/api/admin/characters`, authed(adminCookie)).then((r) => r.json()); | |
| 201 | + | |
| 202 | + const res = await uploadCharacter(BASE, cookie, { garmentId: freeGarment.id, segments: 2 }); | |
| 203 | + assert.equal(res.status, 402); | |
| 204 | + | |
| 205 | + const after = await fetch(`${BASE}/api/admin/characters`, authed(adminCookie)).then((r) => r.json()); | |
| 206 | + assert.equal(after.length, before.length); | |
| 207 | +}); | |
| 208 | + | |
| 209 | +test("with no garmentId and no active garments, upload is rejected before any spend", async () => { | |
| 210 | + // Deactivate the only garment for this one check, then reactivate. | |
| 211 | + await fetch(`${BASE}/api/admin/garments/${freeGarment.id}`, authed(adminCookie, { method: "PATCH", body: JSON.stringify({ isActive: false }) })); | |
| 212 | + const cookie = await registerVerifiedUser(BASE); | |
| 213 | + await topUp(BASE, cookie); | |
| 214 | + const res = await uploadCharacter(BASE, cookie, { segments: 1 }); // no garmentId -> forced to random-pick, pool is empty | |
| 215 | + assert.equal(res.status, 400); | |
| 216 | + await fetch(`${BASE}/api/admin/garments/${freeGarment.id}`, authed(adminCookie, { method: "PATCH", body: JSON.stringify({ isActive: true }) })); | |
| 217 | +}); | |
| 218 | + | |
| 219 | +test("full happy path: cost scales with requested segments, character carries user_paid/ownerUserId/totalSegments, clip is paid_boost", async () => { | |
| 220 | + const cookie = await registerVerifiedUser(BASE); | |
| 221 | + await topUp(BASE, cookie); | |
| 222 | + const meBefore = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); | |
| 223 | + | |
| 224 | + const res = await uploadCharacter(BASE, cookie, { garmentId: freeGarment.id, segments: 4 }); | |
| 225 | + assert.equal(res.status, 200); | |
| 226 | + const body = await res.json(); | |
| 227 | + assert.equal(body.totalSegments, 4); | |
| 228 | + assert.ok(body.creditsSpent > 0); | |
| 229 | + | |
| 230 | + const pricingEntry = await fetch(`${BASE}/api/pricing`).then((r) => r.json()).then((rows) => rows.find((p) => p.key === "spend_character_per_segment")); | |
| 231 | + const expectedCost = Math.ceil((pricingEntry.credits / (pricingEntry.segmentsPerUnit || 1)) * 4); | |
| 232 | + assert.equal(body.creditsSpent, expectedCost, "cost must scale with requested segments, not be a flat price like mode 2's"); | |
| 233 | + | |
| 234 | + const meAfter = await fetch(`${BASE}/api/auth/me`, { headers: { Cookie: cookie } }).then((r) => r.json()); | |
| 235 | + assert.equal(meAfter.creditBalance, meBefore.creditBalance - expectedCost); | |
| 236 | + | |
| 237 | + const characters = await fetch(`${BASE}/api/admin/characters`, authed(adminCookie)).then((r) => r.json()); | |
| 238 | + const created = characters.find((c) => c.id === body.characterId); | |
| 239 | + assert.ok(created); | |
| 240 | + assert.equal(created.source, "user_paid"); | |
| 241 | + assert.equal(created.ownerUserId, meBefore.id); | |
| 242 | + assert.equal(created.isDefault, false, "a paid character must never become the shared default (需求 3.3 第 6 点)"); | |
| 243 | + assert.equal(created.totalSegments, 4); | |
| 244 | + assert.equal(created.remainingSegments, 4); | |
| 245 | + assert.equal(created.status, "active"); | |
| 246 | + assert.ok(created.appearanceDescriptor && created.appearanceDescriptor.length > 0); | |
| 247 | + | |
| 248 | + const state = await waitUntil(async () => { | |
| 249 | + const s = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json()); | |
| 250 | + const all = [s.current, ...(s.upNext || [])].filter(Boolean); | |
| 251 | + return all.find((c) => c.clip ? c.clip.characterId === body.characterId : c.characterId === body.characterId) ? s : null; | |
| 252 | + }); | |
| 253 | + const clipView = state.current && state.current.clip && state.current.clip.characterId === body.characterId ? state.current.clip : state.upNext.find((c) => c.characterId === body.characterId); | |
| 254 | + assert.equal(clipView.priority, "paid_boost"); | |
| 255 | + assert.equal(clipView.requestedByUserId, meBefore.id); | |
| 256 | + assert.equal(clipView.garmentId, freeGarment.id); | |
| 257 | +}); | |
| 258 | + | |
| 259 | +test("T34: a paid_boost character skips the vote queue — it's scheduled ahead of an already-ready free pick", async () => { | |
| 260 | + // A second, distinct garment so this free pick doesn't hit M.7's | |
| 261 | + // dedupe cache against a clip from an earlier test using the same | |
| 262 | + // (defaultCharacter, freeGarment) combo. | |
| 263 | + const freeGarment2 = await fetch( | |
| 264 | + `${BASE}/api/admin/garments`, | |
| 265 | + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "免费蓝裙", imageUrl: "/uploads/garment.png", appearanceDescriptor: "blue dress" }) }) | |
| 266 | + ).then((r) => r.json()); | |
| 267 | + | |
| 268 | + // Submit the free pick first, giving it every opportunity to be | |
| 269 | + // generated and scheduled before the paid_boost one even exists. | |
| 270 | + await fetch(`${BASE}/api/dance/pick`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ garmentId: freeGarment2.id, nickname: "免费观众" }) }); | |
| 271 | + | |
| 272 | + const cookie = await registerVerifiedUser(BASE); | |
| 273 | + await topUp(BASE, cookie); | |
| 274 | + const paidRes = await uploadCharacter(BASE, cookie, { garmentId: freeGarment2.id, segments: 1 }); | |
| 275 | + assert.equal(paidRes.status, 200); | |
| 276 | + const paidBody = await paidRes.json(); | |
| 277 | + | |
| 278 | + // Matched by characterId — free entries always use defaultCharacter.id, | |
| 279 | + // paid_boost entries always create a brand-new character, so this | |
| 280 | + // unambiguously tells the two apart even though both target the same | |
| 281 | + // garment. | |
| 282 | + const state = await waitUntil(async () => { | |
| 283 | + const s = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json()); | |
| 284 | + const all = [s.current, ...(s.upNext || [])].filter(Boolean); | |
| 285 | + const hasPaid = all.some((c) => (c.clip ? c.clip.characterId : c.characterId) === paidBody.characterId); | |
| 286 | + return hasPaid ? s : null; | |
| 287 | + }); | |
| 288 | + const all = [state.current, ...(state.upNext || [])].filter(Boolean); | |
| 289 | + const characterIdOf = (c) => (c.clip ? c.clip.characterId : c.characterId); | |
| 290 | + const paidIndex = all.findIndex((c) => characterIdOf(c) === paidBody.characterId); | |
| 291 | + const freeIndex = all.findIndex((c) => characterIdOf(c) === defaultCharacter.id && (c.clip ? c.clip.garmentId : c.garmentId) === freeGarment2.id); | |
| 292 | + assert.ok(paidIndex !== -1, "paid_boost clip must appear in current/upNext"); | |
| 293 | + if (freeIndex !== -1) { | |
| 294 | + assert.ok(paidIndex <= freeIndex, `paid_boost (index ${paidIndex}) must not be scheduled after the competing free pick (index ${freeIndex})`); | |
| 295 | + } | |
| 296 | +}); | ... | ... |
pricing.js
| ... | ... | @@ -13,6 +13,10 @@ const PRICING_PATH = process.env.PRICING_PATH || path.join(__dirname, "pricing.j |
| 13 | 13 | // in pricing.json (or here, before first run) whenever real pricing lands. |
| 14 | 14 | const DEFAULTS = [ |
| 15 | 15 | { key: "spend_garment", kind: "spend", credits: 10, priceCents: null, segmentsPerUnit: null, isActive: true, updatedAt: Date.now() }, |
| 16 | + // dev-plan-5.md T35: credits/segmentsPerUnit together mean "20 credits | |
| 17 | + // buys 1 ten-second segment" — cost = ceil(credits/segmentsPerUnit * | |
| 18 | + // requestedSegments). Placeholder, same as spend_garment above. | |
| 19 | + { key: "spend_character_per_segment", kind: "spend", credits: 20, priceCents: null, segmentsPerUnit: 1, isActive: true, updatedAt: Date.now() }, | |
| 16 | 20 | { key: "topup_tier_1", kind: "topup_tier", credits: 100, priceCents: 999, segmentsPerUnit: null, isActive: true, updatedAt: Date.now() }, |
| 17 | 21 | { key: "topup_tier_2", kind: "topup_tier", credits: 550, priceCents: 4999, segmentsPerUnit: null, isActive: true, updatedAt: Date.now() }, |
| 18 | 22 | ]; | ... | ... |
server.js
| ... | ... | @@ -114,13 +114,13 @@ const DANCE_STATE_PATH = path.join(__dirname, "dance-state.json"); |
| 114 | 114 | function loadDanceState() { |
| 115 | 115 | try { |
| 116 | 116 | const s = JSON.parse(fs.readFileSync(DANCE_STATE_PATH, "utf8")); |
| 117 | - return { danceSchedule: s.schedule || [], danceQueue: s.freeQueue || [] }; | |
| 117 | + return { danceSchedule: s.schedule || [], danceQueue: s.freeQueue || [], priorityInbox: s.priorityInbox || [] }; | |
| 118 | 118 | } catch { |
| 119 | - return { danceSchedule: [], danceQueue: [] }; | |
| 119 | + return { danceSchedule: [], danceQueue: [], priorityInbox: [] }; | |
| 120 | 120 | } |
| 121 | 121 | } |
| 122 | 122 | function saveDanceState() { |
| 123 | - fs.writeFileSync(DANCE_STATE_PATH, JSON.stringify({ schedule: danceSchedule, freeQueue: danceQueue }, null, 2)); | |
| 123 | + fs.writeFileSync(DANCE_STATE_PATH, JSON.stringify({ schedule: danceSchedule, freeQueue: danceQueue, priorityInbox }, null, 2)); | |
| 124 | 124 | } |
| 125 | 125 | |
| 126 | 126 | function readJsonBody(req) { |
| ... | ... | @@ -228,6 +228,16 @@ async function describeGarmentImage(absImagePath) { |
| 228 | 228 | throw new Error("vision vendor not implemented — set VISION_API_KEY only once a provider is wired up"); |
| 229 | 229 | } |
| 230 | 230 | |
| 231 | +// T35 (dev-plan-5.md): same seam as describeGarmentImage above, for mode | |
| 232 | +// 3's uploaded face photos — appearanceDescriptor has no admin to | |
| 233 | +// hand-write it either, same underlying gap. Shares VISION_API_KEY (one | |
| 234 | +// vendor question, not two) since both are "describe an uploaded photo" | |
| 235 | +// calls to whatever vision model eventually gets picked. | |
| 236 | +async function describeCharacterImage(absImagePath) { | |
| 237 | + if (!VISION_API_KEY) return "用户上传的人物形象"; | |
| 238 | + throw new Error("vision vendor not implemented — set VISION_API_KEY only once a provider is wired up"); | |
| 239 | +} | |
| 240 | + | |
| 231 | 241 | // T26: same seam pattern, for image-content moderation (dev-plan-4.md §1.2 |
| 232 | 242 | // item 1 — the requirements doc names a vendor as "TBD" and never actually |
| 233 | 243 | // 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, |
| 679 | 689 | const initialDanceState = loadDanceState(); |
| 680 | 690 | let danceSchedule = initialDanceState.danceSchedule; // [{videoId, startAt, endAt, requestedBy?}] |
| 681 | 691 | let danceQueue = initialDanceState.danceQueue; // [{id, garmentId, characterId, nickname?, status, jobId?, videoId?, error?, submittedAt, votes}] |
| 692 | +// T34 (dev-plan-5.md; schema M.8): paid_boost (mode 3) clips land here once | |
| 693 | +// ready, never in danceQueue's vote-ranked path — pickNextDanceClip drains | |
| 694 | +// this first, unconditionally, every tick. | |
| 695 | +let priorityInbox = initialDanceState.priorityInbox; // [{videoId, requestedBy}] | |
| 682 | 696 | |
| 683 | 697 | // How often ensureSchedule/ensureDanceSchedule tick — configurable so tests |
| 684 | 698 | // don't have to wait out a real 2s to see a schedule fill. |
| ... | ... | @@ -1311,13 +1325,24 @@ function ensureDanceSchedule() { |
| 1311 | 1325 | // in-memory promise, is the source of truth (T8's resumeDressupGenerations |
| 1312 | 1326 | // already re-polls H3 for jobs still running after a restart; this tick |
| 1313 | 1327 | // just needs to notice when they land, restart or not). |
| 1328 | + // T34: a paid_boost job landing here gets diverted to priorityInbox | |
| 1329 | + // instead of "ready" (which would otherwise route it through | |
| 1330 | + // pickBestIndexByStatus's vote-ranked pick below) — collected here and | |
| 1331 | + // spliced out of danceQueue after the loop rather than mid-iteration, | |
| 1332 | + // since danceQueue is being iterated live. | |
| 1333 | + const divertedToPriorityInbox = []; | |
| 1314 | 1334 | for (const q of danceQueue) { |
| 1315 | 1335 | if (q.status !== "generating" || !q.jobId) continue; |
| 1316 | 1336 | const job = generationJobs.get(q.jobId); |
| 1317 | 1337 | if (!job) continue; |
| 1318 | 1338 | if (job.status === "done") { |
| 1319 | - q.status = "ready"; | |
| 1320 | - q.videoId = job.generatedVideoId; | |
| 1339 | + if (job.priority === "paid_boost") { | |
| 1340 | + priorityInbox.push({ videoId: job.generatedVideoId, requestedBy: q.nickname || null }); | |
| 1341 | + divertedToPriorityInbox.push(q); | |
| 1342 | + } else { | |
| 1343 | + q.status = "ready"; | |
| 1344 | + q.videoId = job.generatedVideoId; | |
| 1345 | + } | |
| 1321 | 1346 | } else if (job.status === "failed") { |
| 1322 | 1347 | q.status = "failed"; |
| 1323 | 1348 | q.error = job.error; |
| ... | ... | @@ -1352,6 +1377,30 @@ function ensureDanceSchedule() { |
| 1352 | 1377 | } |
| 1353 | 1378 | |
| 1354 | 1379 | while (danceSchedule.length > 1 && danceSchedule[1].endAt <= now) danceSchedule.shift(); |
| 1380 | + | |
| 1381 | + // T34: paid_boost content must play "next" (需求文档 3 节: "确定性上镜"), | |
| 1382 | + // not just "whenever the 30s-ahead fill horizon next needs topping up" — | |
| 1383 | + // if the schedule is already pre-filled (common: FILLER_DURATION_MS keeps | |
| 1384 | + // ~30s queued), the fill loop below wouldn't run again for a while and a | |
| 1385 | + // ready priorityInbox entry could sit for a long time. So this inserts | |
| 1386 | + // eagerly, right after whatever's currently playing (danceSchedule[0], | |
| 1387 | + // guaranteed accurate now that expired entries were just shifted out | |
| 1388 | + // above), pushing every later entry's timing back by this clip's | |
| 1389 | + // duration rather than waiting for its "natural" turn. | |
| 1390 | + while (priorityInbox.length) { | |
| 1391 | + const next = priorityInbox.shift(); | |
| 1392 | + const clip = getLibrary().find((c) => c.id === next.videoId); | |
| 1393 | + if (!clip) continue; // stale/removed videoId — drop rather than schedule a dead entry | |
| 1394 | + markPlayed(clip.id); | |
| 1395 | + const insertAt = danceSchedule.length ? 1 : 0; | |
| 1396 | + const prevEndAt = insertAt > 0 ? danceSchedule[insertAt - 1].endAt : now; | |
| 1397 | + danceSchedule.splice(insertAt, 0, { videoId: next.videoId, startAt: prevEndAt, endAt: prevEndAt + clip.duration * 1000, requestedBy: next.requestedBy }); | |
| 1398 | + for (let i = insertAt + 1; i < danceSchedule.length; i++) { | |
| 1399 | + danceSchedule[i].startAt += clip.duration * 1000; | |
| 1400 | + danceSchedule[i].endAt += clip.duration * 1000; | |
| 1401 | + } | |
| 1402 | + } | |
| 1403 | + | |
| 1355 | 1404 | while (danceSchedule.length === 0 || danceSchedule[danceSchedule.length - 1].endAt < now + FILLER_DURATION_MS) { |
| 1356 | 1405 | const picked = pickNextDanceClip(); |
| 1357 | 1406 | if (!picked || !picked.videoId) break; |
| ... | ... | @@ -1361,7 +1410,7 @@ function ensureDanceSchedule() { |
| 1361 | 1410 | const startAt = danceSchedule.length ? danceSchedule[danceSchedule.length - 1].endAt : now; |
| 1362 | 1411 | danceSchedule.push({ videoId: picked.videoId, startAt, endAt: startAt + clip.duration * 1000, requestedBy: picked.requestedBy }); |
| 1363 | 1412 | } |
| 1364 | - danceQueue = danceQueue.filter((q) => q.status !== "failed" || now - q.failedAt < FAILED_ENTRY_TTL_MS); | |
| 1413 | + danceQueue = danceQueue.filter((q) => !divertedToPriorityInbox.includes(q) && (q.status !== "failed" || now - q.failedAt < FAILED_ENTRY_TTL_MS)); | |
| 1365 | 1414 | |
| 1366 | 1415 | for (const [cid, lastSeen] of danceViewers) if (now - lastSeen > VIEWER_TTL_MS) danceViewers.delete(cid); |
| 1367 | 1416 | saveDanceState(); |
| ... | ... | @@ -2046,6 +2095,193 @@ http |
| 2046 | 2095 | return; |
| 2047 | 2096 | } |
| 2048 | 2097 | |
| 2098 | + // T35 模式三: 付费传人物 (dev-plan-5.md). 需登录 + emailVerified, same | |
| 2099 | + // rule as T27. Differs from T27 in three structural ways: (1) the | |
| 2100 | + // upload itself becomes the new characters record (not paired with an | |
| 2101 | + // existing default character — garment is what's optional/random here, | |
| 2102 | + //需求文档 3.3 第 4 点); (2) cost scales with how many 10s segments the | |
| 2103 | + // buyer requests (M.12's segmentsPerUnit), not a flat per-upload price; | |
| 2104 | + // (3) the produced clip carries priority:"paid_boost" end to end | |
| 2105 | + // (submitGeneration -> generationJobs -> either straight into | |
| 2106 | + // priorityInbox if cached, or into danceQueue to be diverted there once | |
| 2107 | + // T34's ensureDanceSchedule reconciliation sees the job land) — it | |
| 2108 | + // never enters the vote-ranked path T27's mode-2 entries do. | |
| 2109 | + if (pathname === "/api/dance/upload-character" && req.method === "POST") { | |
| 2110 | + const user = requireUserSession(req); | |
| 2111 | + if (!user) { | |
| 2112 | + res.writeHead(401, { "Content-Type": "application/json" }); | |
| 2113 | + res.end(JSON.stringify({ error: "login required" })); | |
| 2114 | + return; | |
| 2115 | + } | |
| 2116 | + if (!user.emailVerified) { | |
| 2117 | + res.writeHead(403, { "Content-Type": "application/json" }); | |
| 2118 | + res.end(JSON.stringify({ error: "email verification required before uploading a character" })); | |
| 2119 | + return; | |
| 2120 | + } | |
| 2121 | + const boundary = boundaryFromContentType(req.headers["content-type"] || ""); | |
| 2122 | + if (!boundary) { | |
| 2123 | + res.writeHead(400, { "Content-Type": "application/json" }); | |
| 2124 | + res.end(JSON.stringify({ error: "expected multipart/form-data" })); | |
| 2125 | + return; | |
| 2126 | + } | |
| 2127 | + readRawBody(req) | |
| 2128 | + .then(async (raw) => { | |
| 2129 | + const parts = parseMultipart(raw, boundary); | |
| 2130 | + const filePart = parts.find((p) => p.name === "file"); | |
| 2131 | + if (!filePart) { | |
| 2132 | + res.writeHead(400, { "Content-Type": "application/json" }); | |
| 2133 | + res.end(JSON.stringify({ error: "missing file field" })); | |
| 2134 | + return; | |
| 2135 | + } | |
| 2136 | + const sniffed = sniffImageType(filePart.data); | |
| 2137 | + if (!sniffed) { | |
| 2138 | + res.writeHead(400, { "Content-Type": "application/json" }); | |
| 2139 | + res.end(JSON.stringify({ error: "unrecognized image type (expected jpeg/png/webp)" })); | |
| 2140 | + return; | |
| 2141 | + } | |
| 2142 | + | |
| 2143 | + // 需求文档 3.3 第 4 点:衣服可选,指定就用指定的,否则系统随机 | |
| 2144 | + // 挑一件衣柜库服装. | |
| 2145 | + const garmentIdPart = parts.find((p) => p.name === "garmentId"); | |
| 2146 | + const requestedGarmentId = garmentIdPart ? garmentIdPart.data.toString("utf8").trim() : ""; | |
| 2147 | + let targetGarment; | |
| 2148 | + if (requestedGarmentId) { | |
| 2149 | + targetGarment = garments.get(requestedGarmentId); | |
| 2150 | + if (!targetGarment || !targetGarment.isActive) { | |
| 2151 | + res.writeHead(400, { "Content-Type": "application/json" }); | |
| 2152 | + res.end(JSON.stringify({ error: "garmentId must reference an active garment" })); | |
| 2153 | + return; | |
| 2154 | + } | |
| 2155 | + } else { | |
| 2156 | + const activeGarments = garments.list().filter((g) => g.isActive); | |
| 2157 | + if (!activeGarments.length) { | |
| 2158 | + res.writeHead(400, { "Content-Type": "application/json" }); | |
| 2159 | + res.end(JSON.stringify({ error: "no active garments available to pair with the new character" })); | |
| 2160 | + return; | |
| 2161 | + } | |
| 2162 | + targetGarment = activeGarments[Math.floor(Math.random() * activeGarments.length)]; | |
| 2163 | + } | |
| 2164 | + | |
| 2165 | + // 需求文档 3.3 第 3 点:花的积分决定能播放几个 10 秒片段. | |
| 2166 | + const segmentsPart = parts.find((p) => p.name === "segments"); | |
| 2167 | + const requestedSegments = Math.min(20, Math.max(1, parseInt(segmentsPart ? segmentsPart.data.toString("utf8").trim() : "3", 10) || 3)); | |
| 2168 | + const pricingEntry = pricing.get("spend_character_per_segment"); | |
| 2169 | + if (!pricingEntry) { | |
| 2170 | + res.writeHead(500, { "Content-Type": "application/json" }); | |
| 2171 | + res.end(JSON.stringify({ error: "spend_character_per_segment pricing not configured" })); | |
| 2172 | + return; | |
| 2173 | + } | |
| 2174 | + const cost = Math.ceil((pricingEntry.credits / (pricingEntry.segmentsPerUnit || 1)) * requestedSegments); | |
| 2175 | + | |
| 2176 | + let relPath, absPath; | |
| 2177 | + try { | |
| 2178 | + ({ relPath } = stripMetadataAndSave(filePart.data, sniffed)); | |
| 2179 | + absPath = path.join(__dirname, relPath); | |
| 2180 | + } catch { | |
| 2181 | + res.writeHead(400, { "Content-Type": "application/json" }); | |
| 2182 | + res.end(JSON.stringify({ error: "failed to process image" })); | |
| 2183 | + return; | |
| 2184 | + } | |
| 2185 | + | |
| 2186 | + const moderation = await moderateFaceBlacklist(absPath, relPath); | |
| 2187 | + if (!moderation.approved) { | |
| 2188 | + res.writeHead(400, { "Content-Type": "application/json" }); | |
| 2189 | + res.end(JSON.stringify({ error: "image rejected by moderation", reason: moderation.reason })); | |
| 2190 | + return; | |
| 2191 | + } | |
| 2192 | + | |
| 2193 | + const appearanceDescriptor = await describeCharacterImage(absPath); | |
| 2194 | + // 需求文档 3.3 第 6 点:新人物默认不沉淀进公共人物池(isDefault | |
| 2195 | + // 恒为 false,且不会被 /api/dance/pick 的默认人物逻辑抽中——那条 | |
| 2196 | + // 逻辑只认 isDefault:true,一个 user_paid 人物永远不会是它)。 | |
| 2197 | + const character = characters.create({ | |
| 2198 | + name: `${user.nickname || user.email.split("@")[0]} 的新角色`, | |
| 2199 | + imageUrl: relPath, | |
| 2200 | + tags: [], | |
| 2201 | + source: "user_paid", | |
| 2202 | + ownerUserId: user.id, | |
| 2203 | + appearanceDescriptor, | |
| 2204 | + isDefault: false, | |
| 2205 | + moderationStatus: "auto_passed", | |
| 2206 | + totalSegments: requestedSegments, | |
| 2207 | + remainingSegments: requestedSegments, | |
| 2208 | + status: "active", | |
| 2209 | + }); | |
| 2210 | + | |
| 2211 | + let spendRow; | |
| 2212 | + try { | |
| 2213 | + spendRow = creditLedger.spend({ userId: user.id, amount: cost, reason: "spend_character", relatedAssetId: character.id }); | |
| 2214 | + } catch (err) { | |
| 2215 | + characters.remove(character.id); | |
| 2216 | + const status = err.code === "INSUFFICIENT_BALANCE" ? 402 : 500; | |
| 2217 | + res.writeHead(status, { "Content-Type": "application/json" }); | |
| 2218 | + res.end(JSON.stringify({ error: err.message })); | |
| 2219 | + return; | |
| 2220 | + } | |
| 2221 | + | |
| 2222 | + // priority:"paid_boost" (T34) — this is the one call site in the | |
| 2223 | + // whole codebase that requests it; mode 2 (T27) never does. | |
| 2224 | + const { status, body: respBody } = submitGeneration({ | |
| 2225 | + characterId: character.id, | |
| 2226 | + garmentId: targetGarment.id, | |
| 2227 | + userId: user.id, | |
| 2228 | + priority: "paid_boost", | |
| 2229 | + }); | |
| 2230 | + if (status !== 200) { | |
| 2231 | + creditLedger.refund({ userId: user.id, amount: cost, reason: "refund_stage2_fail", relatedAssetId: character.id }); | |
| 2232 | + characters.remove(character.id); | |
| 2233 | + res.writeHead(status, { "Content-Type": "application/json" }); | |
| 2234 | + res.end(JSON.stringify(respBody)); | |
| 2235 | + return; | |
| 2236 | + } | |
| 2237 | + | |
| 2238 | + const entry = { | |
| 2239 | + id: crypto.randomUUID(), | |
| 2240 | + characterId: character.id, | |
| 2241 | + garmentId: targetGarment.id, | |
| 2242 | + nickname: user.nickname || null, | |
| 2243 | + userId: user.id, | |
| 2244 | + amountPaid: cost, | |
| 2245 | + priority: "paid_boost", | |
| 2246 | + submittedAt: Date.now(), | |
| 2247 | + votes: [], | |
| 2248 | + }; | |
| 2249 | + let videoId = null; | |
| 2250 | + if (respBody.cached) { | |
| 2251 | + // M.7 去重缓存命中是即时的 —— T34 的 ensureDanceSchedule 转运 | |
| 2252 | + // 逻辑只在意"generating -> done"这个过渡,命中缓存跳过了整个 | |
| 2253 | + // 过渡,所以这里直接进 priorityInbox,不经过 danceQueue。 | |
| 2254 | + videoId = respBody.clip.id; | |
| 2255 | + priorityInbox.push({ videoId, requestedBy: entry.nickname }); | |
| 2256 | + } else { | |
| 2257 | + entry.status = "generating"; | |
| 2258 | + entry.jobId = respBody.jobId; | |
| 2259 | + danceQueue.push(entry); | |
| 2260 | + } | |
| 2261 | + saveDanceState(); | |
| 2262 | + res.writeHead(200, { "Content-Type": "application/json" }); | |
| 2263 | + res.end( | |
| 2264 | + JSON.stringify({ | |
| 2265 | + status: respBody.cached ? "ready" : entry.status, | |
| 2266 | + id: entry.id, | |
| 2267 | + characterId: character.id, | |
| 2268 | + garmentId: targetGarment.id, | |
| 2269 | + videoId, | |
| 2270 | + creditsSpent: cost, | |
| 2271 | + totalSegments: requestedSegments, | |
| 2272 | + balanceAfter: spendRow.balanceAfter, | |
| 2273 | + }) | |
| 2274 | + ); | |
| 2275 | + }) | |
| 2276 | + .catch((err) => { | |
| 2277 | + if (res.writableEnded) return; | |
| 2278 | + const status = err.code === "PAYLOAD_TOO_LARGE" ? 413 : 400; | |
| 2279 | + res.writeHead(status, { "Content-Type": "application/json" }); | |
| 2280 | + res.end(JSON.stringify({ error: err.message || "upload failed" })); | |
| 2281 | + }); | |
| 2282 | + return; | |
| 2283 | + } | |
| 2284 | + | |
| 2049 | 2285 | if (pathname.startsWith("/api/dance/queue/vote/") && req.method === "POST") { |
| 2050 | 2286 | const id = pathname.slice("/api/dance/queue/vote/".length); |
| 2051 | 2287 | readJsonBody(req) | ... | ... |