From 4bfa5769809527ad3728700db836a15ab86af1df Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 2 Sep 2026 22:23:05 +0800 Subject: [PATCH] Add T32 (face blacklist stub) + T33 (fal.ai real-time compute) --- dance-mode2.test.js | 13 ++++++++++--- fal-generation.test.js | 214 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server.js | 241 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------- uploads.js | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 488 insertions(+), 13 deletions(-) create mode 100644 fal-generation.test.js diff --git a/dance-mode2.test.js b/dance-mode2.test.js index 465d129..fcca05e 100644 --- a/dance-mode2.test.js +++ b/dance-mode2.test.js @@ -256,10 +256,17 @@ test("full happy path: upload -> moderated+auto-tagged -> spent -> generated -> assert.ok(created.appearanceDescriptor && created.appearanceDescriptor.length > 0, "T25's auto-tagging must fill a non-empty appearanceDescriptor even with no vision vendor configured"); // lands in the dance schedule and plays, same end state dance-channel.test.js checks for mode 1 - await waitUntil(async () => { - const state = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json()); - return state.current && state.current.clip && state.current.clip.garmentId === body.garmentId ? state : null; + const state = await waitUntil(async () => { + const s = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json()); + return s.current && s.current.clip && s.current.clip.garmentId === body.garmentId ? s : null; }); + // T33 fix: mode 2 is a paid request, so the produced clip must carry + // requestedByUserId (M.7) — this was silently always null before T33 + // threaded userId through submitGeneration. Still priority:"normal" — + // mode 2 stays in the vote-ranked queue, only mode 3 gets paid_boost. + assert.equal(state.current.clip.requestedByUserId, userId); + assert.equal(state.current.clip.priority, "normal"); + assert.equal(state.current.clip.backend, "h3_console"); // no FAL_API_KEY set in this test's env }); // ---- async generation failure -> refund (its own server + a mock H3 that diff --git a/fal-generation.test.js b/fal-generation.test.js new file mode 100644 index 0000000..3b6e3f8 --- /dev/null +++ b/fal-generation.test.js @@ -0,0 +1,214 @@ +// Integration test for T33 (fal.ai real-time compute path), dev-plan-5.md. +// Runs server.js against a mock fal (both the storage/upload/initiate REST +// host and the queue.fal.run host, pointed at the same mock server via +// FAL_REST_BASE/FAL_QUEUE_BASE) so the whole submit -> asset-upload -> +// queue-submit -> poll -> fetch-result -> land-in-library path runs offline +// and fast, without needing a real FAL_API_KEY. This does NOT prove the +// prompt fal receives is interpreted correctly by the real model (that +// needs a real key + call, see server.js's generateDanceVideoViaFal doc +// comment) — it proves this codebase's side of the integration (the exact +// endpoints/params/response-parsing) is wired correctly against fal's +// documented contract. +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); +const http = require("node:http"); +const { spawn } = require("node:child_process"); + +const PORT = 31934 + Math.floor(Math.random() * 1000); +const BASE = `http://localhost:${PORT}`; +const ADMIN_PASSWORD = "test-password-only-for-this-run"; +const FAL_API_KEY = "mock-fal-key"; +let adminCookie, child, cwd, mockFal, mockFalPort; +let statusPollCounts; + +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 } }; +} + +// Serves both fal's storage-upload contract (initiate + the PUT target) +// and its queue contract (submit/status/result) for +// minimax/h3/reference-to-video — verified against fal's published docs +// and the fal-js client's source, see uploads.js's forwardToFalAssets. +function startMockFal() { + statusPollCounts = new Map(); + let nextAssetId = 1; + let nextRequestId = 1; + return new Promise((resolve) => { + mockFal = http.createServer((req, res) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + if (req.method === "POST" && req.url.startsWith("/storage/upload/initiate")) { + assert.equal(req.headers.authorization, `Key ${FAL_API_KEY}`, "storage upload must authenticate with Authorization: Key "); + const id = nextAssetId++; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ file_url: `${BASE_URL()}/asset/${id}`, upload_url: `${BASE_URL()}/put/${id}` })); + return; + } + if (req.method === "PUT" && req.url.startsWith("/put/")) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + return; + } + if (req.method === "POST" && req.url === "/minimax/h3/reference-to-video") { + assert.equal(req.headers.authorization, `Key ${FAL_API_KEY}`, "queue submit must authenticate with Authorization: Key "); + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); + assert.ok(Array.isArray(body.reference_image_urls) && body.reference_image_urls.length === 2, "must submit exactly 2 reference images (character + garment)"); + assert.ok(Array.isArray(body.reference_video_urls) && body.reference_video_urls.length === 1, "must submit exactly 1 reference video"); + assert.equal(typeof body.prompt, "string"); + const id = `req-${nextRequestId++}`; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ request_id: id, status_url: `${BASE_URL()}/minimax/h3/reference-to-video/requests/${id}/status` })); + return; + } + const statusMatch = req.method === "GET" && req.url.match(/^\/minimax\/h3\/reference-to-video\/requests\/(.+)\/status$/); + if (statusMatch) { + const id = statusMatch[1]; + const seen = (statusPollCounts.get(id) || 0) + 1; + statusPollCounts.set(id, seen); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: seen < 2 ? "IN_PROGRESS" : "COMPLETED" })); + return; + } + const resultMatch = req.method === "GET" && req.url.match(/^\/minimax\/h3\/reference-to-video\/requests\/(.+)$/); + if (resultMatch) { + const id = resultMatch[1]; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ video: { url: `https://mock-fal-cdn.example/${id}.mp4`, content_type: "video/mp4" }, expanded_prompt: "expanded" })); + return; + } + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "not found in mock" })); + }); + }); + mockFal.listen(0, () => { + mockFalPort = mockFal.address().port; + resolve(); + }); + }); +} +function BASE_URL() { + return `http://localhost:${mockFalPort}`; +} + +async function waitUntil(fn, timeoutMs = 8000, intervalMs = 100) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const v = await fn(); + if (v) return v; + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error("condition not met in time"); +} + +let defaultCharacter, garment, danceRef; + +test.before(async () => { + await startMockFal(); + + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "fal-generation-")); + 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: "", + H3_CONSOLE_TOKEN: "", + FAL_API_KEY, + FAL_REST_BASE: BASE_URL_ENV(), + FAL_QUEUE_BASE: BASE_URL_ENV(), + ADMIN_PASSWORD, + GENERATION_POLL_INTERVAL_MS: "200", + }, + 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()); + garment = 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()); + danceRef = await fetch( + `${BASE}/api/admin/dance-refs`, + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) }) + ).then((r) => r.json()); +}); +// mockFalPort isn't known until startMockFal() resolves, but env vars for +// spawn() are needed before that — this indirection just defers reading it. +function BASE_URL_ENV() { + return `http://localhost:${mockFalPort}`; +} + +test.after(() => { + child.kill(); + mockFal.close(); + fs.rmSync(cwd, { recursive: true, force: true }); +}); + +test("a paid request (userId set) with FAL_API_KEY configured routes through fal, not H3 Console", async () => { + const res = await fetch( + `${BASE}/api/admin/generate`, + authed(adminCookie, { method: "POST", body: JSON.stringify({ characterId: defaultCharacter.id, garmentId: garment.id, refDanceVideoId: danceRef.id, userId: "user-123" }) }) + ); + assert.equal(res.status, 200); + const { jobId } = await res.json(); + assert.ok(jobId); + + const clip = await waitUntil(async () => { + const job = await fetch(`${BASE}/api/admin/generate/${jobId}`, authed(adminCookie)).then((r) => r.json()); + if (job.status === "failed") throw new Error(`generation failed: ${job.error}`); + if (job.status !== "done") return null; + const lib = await fetch(`${BASE}/api/library`).then((r) => r.json()); + return lib.find((c) => c.id === job.generatedVideoId); + }); + + assert.equal(clip.backend, "fal"); + assert.equal(clip.source, "fal"); + assert.match(clip.videoUrl, /^https:\/\/mock-fal-cdn\.example\//); + assert.equal(clip.priority, "normal"); // this route (POST /api/admin/generate) never sets paid_boost itself + assert.equal(clip.requestedByUserId, "user-123"); + assert.equal(clip.channel, "dance"); + assert.equal(clip.duration, 10); +}); diff --git a/server.js b/server.js index f56fb5f..6dcfe06 100644 --- a/server.js +++ b/server.js @@ -36,6 +36,19 @@ const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || ""; const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || ""; const APP_BASE_URL = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 8934}`; +// T33 (dev-plan-5.md): fal.ai real-time compute for paid requests (需求文档 +// 4.3 节双算力策略) — no key configured yet, same situation T24 was in with +// Stripe. See generateDanceVideoViaFal()'s doc comment for what's verified +// vs. still unverified (§8 item 1 / dev-plan-5.md §1.2 item 3: whether the +// one-step attribute_transfer prompt that works on H3 Console also works on +// fal's model — that needs a real call, not something code can assert). +const FAL_API_KEY = process.env.FAL_API_KEY || ""; +// Overridable so tests can point these at a local mock (same reasoning as +// H3_CONSOLE_BASE) — default to fal's real, documented hosts. +const FAL_REST_BASE = process.env.FAL_REST_BASE || "https://rest.alpha.fal.ai"; +const FAL_QUEUE_BASE = process.env.FAL_QUEUE_BASE || "https://queue.fal.run"; +const FAL_MODEL_ID = "minimax/h3/reference-to-video"; + const MIME = { ".html": "text/html", ".js": "text/javascript", @@ -59,6 +72,7 @@ const { stripMetadataAndSave, saveVideoUpload, forwardToH3Assets, + forwardToFalAssets, } = require("./uploads.js"); const { characters, garments, danceRefs, generationJobs, moderationLog } = require("./dressup-store.js"); const { buildStage2Prompt } = require("./prompt-template.js"); @@ -240,6 +254,29 @@ async function moderateGarmentImage(absImagePath, assetId) { return { approved: verdict === "auto_pass", verdict, reason }; } +// T32 (dev-plan-5.md): same seam pattern as T26 above, but for mode 3's +// uploaded face photos against a "sensitive figure" blacklist (需求文档 5 +// 节 — e.g. filtering politically sensitive likenesses), not general image +// content. No vendor has been picked (needs research, dev-plan-5.md §1.2 +// item 1). Default policy when unconfigured is default-permissive +// (auto_pass) — deliberately confirmed with the user rather than assumed, +// since the risk profile here (a real uploaded face, potentially a +// sensitive public figure) is not the same as T26's garment photos, where +// a wrong default-pass is low-stakes. Do not silently change this default +// without asking again. +const FACE_BLACKLIST_API_KEY = process.env.FACE_BLACKLIST_API_KEY || ""; +async function moderateFaceBlacklist(absImagePath, assetId) { + let verdict, reason; + if (!FACE_BLACKLIST_API_KEY) { + verdict = "auto_pass"; + reason = null; + } else { + throw new Error("face-blacklist vendor not implemented — set FACE_BLACKLIST_API_KEY only once a provider is wired up"); + } + moderationLog.create({ assetType: "character_upload", assetId, verdict, reason, reviewedBy: "system" }); + return { approved: verdict === "auto_pass", verdict, reason }; +} + // T21: mirrors live.html/dance.html's own setCookie() exactly (path=/, // samesite=lax, no httponly, max-age in seconds, 365-day TTL) — these are // the same viewer_uid/viewer_nickname cookies those pages already read @@ -920,6 +957,48 @@ async function ensureH3AssetUrl(store, record, localUrlField) { return asset.url; } +// Same caching idea as ensureH3AssetUrl, separate field (fal is a +// completely different storage service, not interchangeable with +// h3AssetUrl — M.13's note that "S4 spike only solved the H3 Console side"). +async function ensureFalAssetUrl(store, record, localUrlField) { + if (record.falAssetUrl) return record.falAssetUrl; + const absPath = path.join(__dirname, record[localUrlField]); + const asset = await forwardToFalAssets(absPath, FAL_API_KEY, FAL_REST_BASE); + store.update(record.id, { falAssetUrl: asset.url }); + return asset.url; +} + +// T33: submit + poll + fetch-result against fal's queue API (verified +// contract, see FAL_MODEL_ID's doc comment above and forwardToFalAssets in +// uploads.js — POST {queueHost}/{model}, poll .../requests/{id}/status, +// fetch .../requests/{id}). Kept separate from h3ConsoleFetch's shape +// (single-poll-returns-everything) since fal's status/result are two +// different endpoints, not one. +async function falSubmit(input) { + const res = await fetch(`${FAL_QUEUE_BASE}/${FAL_MODEL_ID}`, { + method: "POST", + headers: { Authorization: `Key ${FAL_API_KEY}`, "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + const body = await res.json(); + if (!res.ok || !body.request_id) throw new Error(body.detail || body.error || `fal submit failed: ${res.status}`); + return body; // { request_id, status_url, response_url, ... } +} +async function falPollStatus(requestId) { + const res = await fetch(`${FAL_QUEUE_BASE}/${FAL_MODEL_ID}/requests/${requestId}/status`, { + headers: { Authorization: `Key ${FAL_API_KEY}` }, + }); + return res.json(); // { status: "IN_QUEUE"|"IN_PROGRESS"|"COMPLETED"|... } +} +async function falFetchResult(requestId) { + const res = await fetch(`${FAL_QUEUE_BASE}/${FAL_MODEL_ID}/requests/${requestId}`, { + headers: { Authorization: `Key ${FAL_API_KEY}` }, + }); + const body = await res.json(); + if (!res.ok || !body.video || !body.video.url) throw new Error(body.detail || body.error || "fal result missing video.url"); + return body; // { video: { url, ... }, expanded_prompt } +} + function pickRefDanceVideo() { const pool = danceRefs.list().filter((d) => d.isActive); if (pool.length === 0) throw new Error("no active dance-refs available"); @@ -937,7 +1016,12 @@ function findExistingGeneratedVideo(characterId, garmentId) { // record and fires generateDanceVideo (fire-and-forget — callers poll // GET /api/admin/generate/:id or /api/admin/batch-gen/status for progress). // Returns {status, body} so route handlers can just write it out. -function submitGeneration({ characterId, garmentId, refDanceVideoId, batchId }) { +// userId/priority (T33/T34, dev-plan-5.md): a free mode-一 pick passes +// neither (backend stays H3 Console, priority "normal"); T27 (mode 2) +// passes userId only (still "normal" — 需求文档 3 节: mode 2 stays in the +// vote-ranked queue, only mode 3 skips it); T35 (mode 3, once built) passes +// both userId and priority:"paid_boost". +function submitGeneration({ characterId, garmentId, refDanceVideoId, batchId, userId, priority }) { if (!characters.get(characterId) || !garments.get(garmentId)) { return { status: 400, body: { error: "characterId/garmentId must reference existing records" } }; } @@ -951,16 +1035,42 @@ function submitGeneration({ characterId, garmentId, refDanceVideoId, batchId }) return { status: 400, body: { error: err.message } }; } } - const job = generationJobs.create({ characterId, garmentId, refDanceVideoId, batchId: batchId || null, status: "queued", h3JobId: null, error: null }); + // T33: paid requests (userId set) route to fal's real-time compute once + // FAL_API_KEY is configured (需求文档 4.3 节双算力策略); everything else + // — free mode-一, or a paid request before a key exists — stays on the + // H3 Console path that's been working since T8. + const backend = userId && FAL_API_KEY ? "fal" : "h3_console"; + const job = generationJobs.create({ + characterId, + garmentId, + refDanceVideoId, + batchId: batchId || null, + status: "queued", + h3JobId: null, + falRequestId: null, + error: null, + backend, + userId: userId || null, + priority: priority || "normal", + }); generateDanceVideo(job).catch(() => {}); return { status: 200, body: { cached: false, jobId: job.id } }; } -// job: a generationJobs record ({id, characterId, garmentId, -// refDanceVideoId, status, h3JobId, error, fullPrompt}). Mutates it via -// generationJobs.update and keeps the local variable in sync (store.js has -// no in-memory cache, so every update is a fresh read-modify-write). +// Dispatches on job.backend (T33) — H3 Console and fal.ai have genuinely +// different submit/poll/result shapes (see generateDanceVideoViaFal's doc +// comment), not just a different header, so this stays two implementations +// behind one entry point rather than one branchy function. async function generateDanceVideo(job, opts = {}) { + return job.backend === "fal" ? generateDanceVideoViaFal(job, opts) : generateDanceVideoViaH3(job, opts); +} + +// job: a generationJobs record ({id, characterId, garmentId, +// refDanceVideoId, status, h3JobId, error, fullPrompt, backend, userId, +// priority}). Mutates it via generationJobs.update and keeps the local +// variable in sync (store.js has no in-memory cache, so every update is a +// fresh read-modify-write). +async function generateDanceVideoViaH3(job, opts = {}) { const pollIntervalMs = opts.pollIntervalMs || Number(process.env.GENERATION_POLL_INTERVAL_MS) || 5000; try { const character = characters.get(job.characterId); @@ -1033,8 +1143,8 @@ async function generateDanceVideo(job, opts = {}) { garmentId: job.garmentId, refDanceVideoId: job.refDanceVideoId, backend: "h3_console", - priority: "normal", - requestedByUserId: null, + priority: job.priority || "normal", + requestedByUserId: job.userId || null, prompt: `${character.name} × ${garment.name}`, fullPrompt: job.fullPrompt, width: polled.req.width, @@ -1061,11 +1171,117 @@ async function generateDanceVideo(job, opts = {}) { } } +// T33: fal.ai's queue API — submit -> poll .../status -> once COMPLETED, +// fetch .../requests/{id} for the actual result (fal splits status and +// result into two endpoints; H3 Console's single poll returns both at +// once, see generateDanceVideoViaH3 above — genuinely different shapes, +// not just a header swap). +// +// NOT VERIFIED against a real fal account as of this commit — no +// FAL_API_KEY has ever been configured (dev-plan-5.md §1.2 item 2). The +// endpoints/params below come from fal's published docs and the fal-js +// client's own source (see forwardToFalAssets in uploads.js for +// citations), not guesswork — but whether the exact prompt text +// buildStage2Prompt produces (written for and verified against H3 +// Console's /