Commit f59c66d05eeb8bbb063b8d920da44905f617957b

Authored by yibo
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
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, &quot;pricing.j @@ -13,6 +13,10 @@ const PRICING_PATH = process.env.PRICING_PATH || path.join(__dirname, &quot;pricing.j
13 // in pricing.json (or here, before first run) whenever real pricing lands. 13 // in pricing.json (or here, before first run) whenever real pricing lands.
14 const DEFAULTS = [ 14 const DEFAULTS = [
15 { key: "spend_garment", kind: "spend", credits: 10, priceCents: null, segmentsPerUnit: null, isActive: true, updatedAt: Date.now() }, 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 { key: "topup_tier_1", kind: "topup_tier", credits: 100, priceCents: 999, segmentsPerUnit: null, isActive: true, updatedAt: Date.now() }, 20 { key: "topup_tier_1", kind: "topup_tier", credits: 100, priceCents: 999, segmentsPerUnit: null, isActive: true, updatedAt: Date.now() },
17 { key: "topup_tier_2", kind: "topup_tier", credits: 550, priceCents: 4999, segmentsPerUnit: null, isActive: true, updatedAt: Date.now() }, 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, &quot;dance-state.json&quot;); @@ -114,13 +114,13 @@ const DANCE_STATE_PATH = path.join(__dirname, &quot;dance-state.json&quot;);
114 function loadDanceState() { 114 function loadDanceState() {
115 try { 115 try {
116 const s = JSON.parse(fs.readFileSync(DANCE_STATE_PATH, "utf8")); 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 } catch { 118 } catch {
119 - return { danceSchedule: [], danceQueue: [] }; 119 + return { danceSchedule: [], danceQueue: [], priorityInbox: [] };
120 } 120 }
121 } 121 }
122 function saveDanceState() { 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 function readJsonBody(req) { 126 function readJsonBody(req) {
@@ -228,6 +228,16 @@ async function describeGarmentImage(absImagePath) { @@ -228,6 +228,16 @@ async function describeGarmentImage(absImagePath) {
228 throw new Error("vision vendor not implemented — set VISION_API_KEY only once a provider is wired up"); 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 // T26: same seam pattern, for image-content moderation (dev-plan-4.md §1.2 241 // T26: same seam pattern, for image-content moderation (dev-plan-4.md §1.2
232 // item 1 — the requirements doc names a vendor as "TBD" and never actually 242 // item 1 — the requirements doc names a vendor as "TBD" and never actually
233 // lists it as its own task, so this is that gap filled in). Every call logs 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,6 +689,10 @@ let chatMessages = initialLiveState.chatMessages; // [{id, uid, nickname, text,
679 const initialDanceState = loadDanceState(); 689 const initialDanceState = loadDanceState();
680 let danceSchedule = initialDanceState.danceSchedule; // [{videoId, startAt, endAt, requestedBy?}] 690 let danceSchedule = initialDanceState.danceSchedule; // [{videoId, startAt, endAt, requestedBy?}]
681 let danceQueue = initialDanceState.danceQueue; // [{id, garmentId, characterId, nickname?, status, jobId?, videoId?, error?, submittedAt, votes}] 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 // How often ensureSchedule/ensureDanceSchedule tick — configurable so tests 697 // How often ensureSchedule/ensureDanceSchedule tick — configurable so tests
684 // don't have to wait out a real 2s to see a schedule fill. 698 // don't have to wait out a real 2s to see a schedule fill.
@@ -1311,13 +1325,24 @@ function ensureDanceSchedule() { @@ -1311,13 +1325,24 @@ function ensureDanceSchedule() {
1311 // in-memory promise, is the source of truth (T8's resumeDressupGenerations 1325 // in-memory promise, is the source of truth (T8's resumeDressupGenerations
1312 // already re-polls H3 for jobs still running after a restart; this tick 1326 // already re-polls H3 for jobs still running after a restart; this tick
1313 // just needs to notice when they land, restart or not). 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 for (const q of danceQueue) { 1334 for (const q of danceQueue) {
1315 if (q.status !== "generating" || !q.jobId) continue; 1335 if (q.status !== "generating" || !q.jobId) continue;
1316 const job = generationJobs.get(q.jobId); 1336 const job = generationJobs.get(q.jobId);
1317 if (!job) continue; 1337 if (!job) continue;
1318 if (job.status === "done") { 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 } else if (job.status === "failed") { 1346 } else if (job.status === "failed") {
1322 q.status = "failed"; 1347 q.status = "failed";
1323 q.error = job.error; 1348 q.error = job.error;
@@ -1352,6 +1377,30 @@ function ensureDanceSchedule() { @@ -1352,6 +1377,30 @@ function ensureDanceSchedule() {
1352 } 1377 }
1353 1378
1354 while (danceSchedule.length > 1 && danceSchedule[1].endAt <= now) danceSchedule.shift(); 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 while (danceSchedule.length === 0 || danceSchedule[danceSchedule.length - 1].endAt < now + FILLER_DURATION_MS) { 1404 while (danceSchedule.length === 0 || danceSchedule[danceSchedule.length - 1].endAt < now + FILLER_DURATION_MS) {
1356 const picked = pickNextDanceClip(); 1405 const picked = pickNextDanceClip();
1357 if (!picked || !picked.videoId) break; 1406 if (!picked || !picked.videoId) break;
@@ -1361,7 +1410,7 @@ function ensureDanceSchedule() { @@ -1361,7 +1410,7 @@ function ensureDanceSchedule() {
1361 const startAt = danceSchedule.length ? danceSchedule[danceSchedule.length - 1].endAt : now; 1410 const startAt = danceSchedule.length ? danceSchedule[danceSchedule.length - 1].endAt : now;
1362 danceSchedule.push({ videoId: picked.videoId, startAt, endAt: startAt + clip.duration * 1000, requestedBy: picked.requestedBy }); 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 for (const [cid, lastSeen] of danceViewers) if (now - lastSeen > VIEWER_TTL_MS) danceViewers.delete(cid); 1415 for (const [cid, lastSeen] of danceViewers) if (now - lastSeen > VIEWER_TTL_MS) danceViewers.delete(cid);
1367 saveDanceState(); 1416 saveDanceState();
@@ -2046,6 +2095,193 @@ http @@ -2046,6 +2095,193 @@ http
2046 return; 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 if (pathname.startsWith("/api/dance/queue/vote/") && req.method === "POST") { 2285 if (pathname.startsWith("/api/dance/queue/vote/") && req.method === "POST") {
2050 const id = pathname.slice("/api/dance/queue/vote/".length); 2286 const id = pathname.slice("/api/dance/queue/vote/".length);
2051 readJsonBody(req) 2287 readJsonBody(req)