Commit 4bfa5769809527ad3728700db836a15ab86af1df
1 parent
061dc393
Add T32 (face blacklist stub) + T33 (fal.ai real-time compute)
T32: moderateFaceBlacklist(imagePath, assetId) — same pluggable seam
shape as T26, for mode 3's uploaded face photos (需求文档 5 节: filtering
politically-sensitive likenesses). Default-permissive (auto_pass) when
unconfigured, per explicit user confirmation this session — deliberately
not assumed, since a wrong-default here (a real face silently passed
through) has a different risk profile than T26's garment photos.
T33: forwardToFalAssets (uploads.js) + fal's queue API (falSubmit/
falPollStatus/falFetchResult) wired into submitGeneration/
generateDanceVideo, which now dispatches on job.backend between the
existing H3 Console path (renamed generateDanceVideoViaH3, unchanged
behavior) and the new generateDanceVideoViaFal. A paid request (userId
set) routes to fal once FAL_API_KEY is configured; everything else —
free mode-一, or paid but no key yet — stays on H3 Console exactly as
before (verified: full suite green, no FAL_API_KEY set in any existing
test's env).
Endpoints/params (storage/upload/initiate, queue.fal.run/{model}/
requests/{id}/status vs /requests/{id} as two separate calls,
Authorization: Key <FAL_API_KEY>) are taken from fal's published docs
and the fal-js client's own source, not guessed — fal-generation.test.js
proves this codebase's side of the integration against a mock fal
server. What remains genuinely unverified (no FAL_API_KEY exists) is
whether the H3-Console-tuned prompt is interpreted the same way by
fal's minimax/h3/reference-to-video model, and fal's exact
terminal-failure status string(s) — flagged in code comments rather
than guessed at.
Also fixes a real gap T27 shipped with: submitGeneration never received
userId, so every mode-2 clip's requestedByUserId (M.7) was silently
null. T27's call site now passes userId (priority stays "normal" —
only mode 3 skips voting), which also makes mode 2 fal-eligible.
dance-mode2.test.js's happy-path test now asserts this.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011DUiYs6UjxDMkfvnjtsKFo
Showing
4 changed files
with
488 additions
and
13 deletions
dance-mode2.test.js
| @@ -256,10 +256,17 @@ test("full happy path: upload -> moderated+auto-tagged -> spent -> generated -> | @@ -256,10 +256,17 @@ test("full happy path: upload -> moderated+auto-tagged -> spent -> generated -> | ||
| 256 | assert.ok(created.appearanceDescriptor && created.appearanceDescriptor.length > 0, "T25's auto-tagging must fill a non-empty appearanceDescriptor even with no vision vendor configured"); | 256 | assert.ok(created.appearanceDescriptor && created.appearanceDescriptor.length > 0, "T25's auto-tagging must fill a non-empty appearanceDescriptor even with no vision vendor configured"); |
| 257 | 257 | ||
| 258 | // lands in the dance schedule and plays, same end state dance-channel.test.js checks for mode 1 | 258 | // lands in the dance schedule and plays, same end state dance-channel.test.js checks for mode 1 |
| 259 | - await waitUntil(async () => { | ||
| 260 | - const state = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json()); | ||
| 261 | - return state.current && state.current.clip && state.current.clip.garmentId === body.garmentId ? state : null; | 259 | + const state = await waitUntil(async () => { |
| 260 | + const s = await fetch(`${BASE}/api/dance/live/state`).then((r) => r.json()); | ||
| 261 | + return s.current && s.current.clip && s.current.clip.garmentId === body.garmentId ? s : null; | ||
| 262 | }); | 262 | }); |
| 263 | + // T33 fix: mode 2 is a paid request, so the produced clip must carry | ||
| 264 | + // requestedByUserId (M.7) — this was silently always null before T33 | ||
| 265 | + // threaded userId through submitGeneration. Still priority:"normal" — | ||
| 266 | + // mode 2 stays in the vote-ranked queue, only mode 3 gets paid_boost. | ||
| 267 | + assert.equal(state.current.clip.requestedByUserId, userId); | ||
| 268 | + assert.equal(state.current.clip.priority, "normal"); | ||
| 269 | + assert.equal(state.current.clip.backend, "h3_console"); // no FAL_API_KEY set in this test's env | ||
| 263 | }); | 270 | }); |
| 264 | 271 | ||
| 265 | // ---- async generation failure -> refund (its own server + a mock H3 that | 272 | // ---- async generation failure -> refund (its own server + a mock H3 that |
fal-generation.test.js
0 → 100644
| 1 | +// Integration test for T33 (fal.ai real-time compute path), dev-plan-5.md. | ||
| 2 | +// Runs server.js against a mock fal (both the storage/upload/initiate REST | ||
| 3 | +// host and the queue.fal.run host, pointed at the same mock server via | ||
| 4 | +// FAL_REST_BASE/FAL_QUEUE_BASE) so the whole submit -> asset-upload -> | ||
| 5 | +// queue-submit -> poll -> fetch-result -> land-in-library path runs offline | ||
| 6 | +// and fast, without needing a real FAL_API_KEY. This does NOT prove the | ||
| 7 | +// prompt fal receives is interpreted correctly by the real model (that | ||
| 8 | +// needs a real key + call, see server.js's generateDanceVideoViaFal doc | ||
| 9 | +// comment) — it proves this codebase's side of the integration (the exact | ||
| 10 | +// endpoints/params/response-parsing) is wired correctly against fal's | ||
| 11 | +// documented contract. | ||
| 12 | +const test = require("node:test"); | ||
| 13 | +const assert = require("node:assert/strict"); | ||
| 14 | +const fs = require("node:fs"); | ||
| 15 | +const path = require("node:path"); | ||
| 16 | +const os = require("node:os"); | ||
| 17 | +const http = require("node:http"); | ||
| 18 | +const { spawn } = require("node:child_process"); | ||
| 19 | + | ||
| 20 | +const PORT = 31934 + Math.floor(Math.random() * 1000); | ||
| 21 | +const BASE = `http://localhost:${PORT}`; | ||
| 22 | +const ADMIN_PASSWORD = "test-password-only-for-this-run"; | ||
| 23 | +const FAL_API_KEY = "mock-fal-key"; | ||
| 24 | +let adminCookie, child, cwd, mockFal, mockFalPort; | ||
| 25 | +let statusPollCounts; | ||
| 26 | + | ||
| 27 | +function waitForServer(url, timeoutMs = 10000) { | ||
| 28 | + const deadline = Date.now() + timeoutMs; | ||
| 29 | + return (async function poll() { | ||
| 30 | + while (Date.now() < deadline) { | ||
| 31 | + try { | ||
| 32 | + await fetch(url); | ||
| 33 | + return; | ||
| 34 | + } catch { | ||
| 35 | + await new Promise((r) => setTimeout(r, 100)); | ||
| 36 | + } | ||
| 37 | + } | ||
| 38 | + throw new Error("server did not come up in time"); | ||
| 39 | + })(); | ||
| 40 | +} | ||
| 41 | + | ||
| 42 | +function sessionCookie(res) { | ||
| 43 | + const setCookie = res.headers.get("set-cookie") || ""; | ||
| 44 | + return setCookie.split(";")[0]; | ||
| 45 | +} | ||
| 46 | + | ||
| 47 | +function authed(cookie, opts = {}) { | ||
| 48 | + return { ...opts, headers: { "Content-Type": "application/json", Cookie: cookie, ...opts.headers } }; | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +// Serves both fal's storage-upload contract (initiate + the PUT target) | ||
| 52 | +// and its queue contract (submit/status/result) for | ||
| 53 | +// minimax/h3/reference-to-video — verified against fal's published docs | ||
| 54 | +// and the fal-js client's source, see uploads.js's forwardToFalAssets. | ||
| 55 | +function startMockFal() { | ||
| 56 | + statusPollCounts = new Map(); | ||
| 57 | + let nextAssetId = 1; | ||
| 58 | + let nextRequestId = 1; | ||
| 59 | + return new Promise((resolve) => { | ||
| 60 | + mockFal = http.createServer((req, res) => { | ||
| 61 | + const chunks = []; | ||
| 62 | + req.on("data", (c) => chunks.push(c)); | ||
| 63 | + req.on("end", () => { | ||
| 64 | + if (req.method === "POST" && req.url.startsWith("/storage/upload/initiate")) { | ||
| 65 | + assert.equal(req.headers.authorization, `Key ${FAL_API_KEY}`, "storage upload must authenticate with Authorization: Key <FAL_API_KEY>"); | ||
| 66 | + const id = nextAssetId++; | ||
| 67 | + res.writeHead(200, { "Content-Type": "application/json" }); | ||
| 68 | + res.end(JSON.stringify({ file_url: `${BASE_URL()}/asset/${id}`, upload_url: `${BASE_URL()}/put/${id}` })); | ||
| 69 | + return; | ||
| 70 | + } | ||
| 71 | + if (req.method === "PUT" && req.url.startsWith("/put/")) { | ||
| 72 | + res.writeHead(200, { "Content-Type": "application/json" }); | ||
| 73 | + res.end(JSON.stringify({ ok: true })); | ||
| 74 | + return; | ||
| 75 | + } | ||
| 76 | + if (req.method === "POST" && req.url === "/minimax/h3/reference-to-video") { | ||
| 77 | + assert.equal(req.headers.authorization, `Key ${FAL_API_KEY}`, "queue submit must authenticate with Authorization: Key <FAL_API_KEY>"); | ||
| 78 | + const body = JSON.parse(Buffer.concat(chunks).toString("utf8")); | ||
| 79 | + assert.ok(Array.isArray(body.reference_image_urls) && body.reference_image_urls.length === 2, "must submit exactly 2 reference images (character + garment)"); | ||
| 80 | + assert.ok(Array.isArray(body.reference_video_urls) && body.reference_video_urls.length === 1, "must submit exactly 1 reference video"); | ||
| 81 | + assert.equal(typeof body.prompt, "string"); | ||
| 82 | + const id = `req-${nextRequestId++}`; | ||
| 83 | + res.writeHead(200, { "Content-Type": "application/json" }); | ||
| 84 | + res.end(JSON.stringify({ request_id: id, status_url: `${BASE_URL()}/minimax/h3/reference-to-video/requests/${id}/status` })); | ||
| 85 | + return; | ||
| 86 | + } | ||
| 87 | + const statusMatch = req.method === "GET" && req.url.match(/^\/minimax\/h3\/reference-to-video\/requests\/(.+)\/status$/); | ||
| 88 | + if (statusMatch) { | ||
| 89 | + const id = statusMatch[1]; | ||
| 90 | + const seen = (statusPollCounts.get(id) || 0) + 1; | ||
| 91 | + statusPollCounts.set(id, seen); | ||
| 92 | + res.writeHead(200, { "Content-Type": "application/json" }); | ||
| 93 | + res.end(JSON.stringify({ status: seen < 2 ? "IN_PROGRESS" : "COMPLETED" })); | ||
| 94 | + return; | ||
| 95 | + } | ||
| 96 | + const resultMatch = req.method === "GET" && req.url.match(/^\/minimax\/h3\/reference-to-video\/requests\/(.+)$/); | ||
| 97 | + if (resultMatch) { | ||
| 98 | + const id = resultMatch[1]; | ||
| 99 | + res.writeHead(200, { "Content-Type": "application/json" }); | ||
| 100 | + res.end(JSON.stringify({ video: { url: `https://mock-fal-cdn.example/${id}.mp4`, content_type: "video/mp4" }, expanded_prompt: "expanded" })); | ||
| 101 | + return; | ||
| 102 | + } | ||
| 103 | + res.writeHead(404, { "Content-Type": "application/json" }); | ||
| 104 | + res.end(JSON.stringify({ error: "not found in mock" })); | ||
| 105 | + }); | ||
| 106 | + }); | ||
| 107 | + mockFal.listen(0, () => { | ||
| 108 | + mockFalPort = mockFal.address().port; | ||
| 109 | + resolve(); | ||
| 110 | + }); | ||
| 111 | + }); | ||
| 112 | +} | ||
| 113 | +function BASE_URL() { | ||
| 114 | + return `http://localhost:${mockFalPort}`; | ||
| 115 | +} | ||
| 116 | + | ||
| 117 | +async function waitUntil(fn, timeoutMs = 8000, intervalMs = 100) { | ||
| 118 | + const deadline = Date.now() + timeoutMs; | ||
| 119 | + while (Date.now() < deadline) { | ||
| 120 | + const v = await fn(); | ||
| 121 | + if (v) return v; | ||
| 122 | + await new Promise((r) => setTimeout(r, intervalMs)); | ||
| 123 | + } | ||
| 124 | + throw new Error("condition not met in time"); | ||
| 125 | +} | ||
| 126 | + | ||
| 127 | +let defaultCharacter, garment, danceRef; | ||
| 128 | + | ||
| 129 | +test.before(async () => { | ||
| 130 | + await startMockFal(); | ||
| 131 | + | ||
| 132 | + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "fal-generation-")); | ||
| 133 | + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) { | ||
| 134 | + fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); | ||
| 135 | + } | ||
| 136 | + fs.mkdirSync(path.join(cwd, "uploads"), { recursive: true }); | ||
| 137 | + fs.writeFileSync(path.join(cwd, "uploads", "person.png"), "fake-png-bytes"); | ||
| 138 | + fs.writeFileSync(path.join(cwd, "uploads", "garment.png"), "fake-png-bytes"); | ||
| 139 | + fs.writeFileSync(path.join(cwd, "uploads", "ref.mp4"), "fake-mp4-bytes"); | ||
| 140 | + | ||
| 141 | + child = spawn(process.execPath, ["server.js"], { | ||
| 142 | + cwd, | ||
| 143 | + env: { | ||
| 144 | + ...process.env, | ||
| 145 | + PORT: String(PORT), | ||
| 146 | + H3_CONSOLE_BASE: "", | ||
| 147 | + H3_CONSOLE_TOKEN: "", | ||
| 148 | + FAL_API_KEY, | ||
| 149 | + FAL_REST_BASE: BASE_URL_ENV(), | ||
| 150 | + FAL_QUEUE_BASE: BASE_URL_ENV(), | ||
| 151 | + ADMIN_PASSWORD, | ||
| 152 | + GENERATION_POLL_INTERVAL_MS: "200", | ||
| 153 | + }, | ||
| 154 | + stdio: "pipe", | ||
| 155 | + }); | ||
| 156 | + await waitForServer(BASE); | ||
| 157 | + | ||
| 158 | + const loginRes = await fetch(`${BASE}/api/admin/login`, { | ||
| 159 | + method: "POST", | ||
| 160 | + headers: { "Content-Type": "application/json" }, | ||
| 161 | + body: JSON.stringify({ password: ADMIN_PASSWORD }), | ||
| 162 | + }); | ||
| 163 | + adminCookie = sessionCookie(loginRes); | ||
| 164 | + | ||
| 165 | + defaultCharacter = await fetch( | ||
| 166 | + `${BASE}/api/admin/characters`, | ||
| 167 | + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "衣柜小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair", isDefault: true }) }) | ||
| 168 | + ).then((r) => r.json()); | ||
| 169 | + garment = await fetch( | ||
| 170 | + `${BASE}/api/admin/garments`, | ||
| 171 | + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "红裙", imageUrl: "/uploads/garment.png", appearanceDescriptor: "red dress" }) }) | ||
| 172 | + ).then((r) => r.json()); | ||
| 173 | + danceRef = await fetch( | ||
| 174 | + `${BASE}/api/admin/dance-refs`, | ||
| 175 | + authed(adminCookie, { method: "POST", body: JSON.stringify({ name: "拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) }) | ||
| 176 | + ).then((r) => r.json()); | ||
| 177 | +}); | ||
| 178 | +// mockFalPort isn't known until startMockFal() resolves, but env vars for | ||
| 179 | +// spawn() are needed before that — this indirection just defers reading it. | ||
| 180 | +function BASE_URL_ENV() { | ||
| 181 | + return `http://localhost:${mockFalPort}`; | ||
| 182 | +} | ||
| 183 | + | ||
| 184 | +test.after(() => { | ||
| 185 | + child.kill(); | ||
| 186 | + mockFal.close(); | ||
| 187 | + fs.rmSync(cwd, { recursive: true, force: true }); | ||
| 188 | +}); | ||
| 189 | + | ||
| 190 | +test("a paid request (userId set) with FAL_API_KEY configured routes through fal, not H3 Console", async () => { | ||
| 191 | + const res = await fetch( | ||
| 192 | + `${BASE}/api/admin/generate`, | ||
| 193 | + authed(adminCookie, { method: "POST", body: JSON.stringify({ characterId: defaultCharacter.id, garmentId: garment.id, refDanceVideoId: danceRef.id, userId: "user-123" }) }) | ||
| 194 | + ); | ||
| 195 | + assert.equal(res.status, 200); | ||
| 196 | + const { jobId } = await res.json(); | ||
| 197 | + assert.ok(jobId); | ||
| 198 | + | ||
| 199 | + const clip = await waitUntil(async () => { | ||
| 200 | + const job = await fetch(`${BASE}/api/admin/generate/${jobId}`, authed(adminCookie)).then((r) => r.json()); | ||
| 201 | + if (job.status === "failed") throw new Error(`generation failed: ${job.error}`); | ||
| 202 | + if (job.status !== "done") return null; | ||
| 203 | + const lib = await fetch(`${BASE}/api/library`).then((r) => r.json()); | ||
| 204 | + return lib.find((c) => c.id === job.generatedVideoId); | ||
| 205 | + }); | ||
| 206 | + | ||
| 207 | + assert.equal(clip.backend, "fal"); | ||
| 208 | + assert.equal(clip.source, "fal"); | ||
| 209 | + assert.match(clip.videoUrl, /^https:\/\/mock-fal-cdn\.example\//); | ||
| 210 | + assert.equal(clip.priority, "normal"); // this route (POST /api/admin/generate) never sets paid_boost itself | ||
| 211 | + assert.equal(clip.requestedByUserId, "user-123"); | ||
| 212 | + assert.equal(clip.channel, "dance"); | ||
| 213 | + assert.equal(clip.duration, 10); | ||
| 214 | +}); |
server.js
| @@ -36,6 +36,19 @@ const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || ""; | @@ -36,6 +36,19 @@ const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY || ""; | ||
| 36 | const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || ""; | 36 | const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || ""; |
| 37 | const APP_BASE_URL = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 8934}`; | 37 | const APP_BASE_URL = process.env.APP_BASE_URL || `http://localhost:${process.env.PORT || 8934}`; |
| 38 | 38 | ||
| 39 | +// T33 (dev-plan-5.md): fal.ai real-time compute for paid requests (需求文档 | ||
| 40 | +// 4.3 节双算力策略) — no key configured yet, same situation T24 was in with | ||
| 41 | +// Stripe. See generateDanceVideoViaFal()'s doc comment for what's verified | ||
| 42 | +// vs. still unverified (§8 item 1 / dev-plan-5.md §1.2 item 3: whether the | ||
| 43 | +// one-step attribute_transfer prompt that works on H3 Console also works on | ||
| 44 | +// fal's model — that needs a real call, not something code can assert). | ||
| 45 | +const FAL_API_KEY = process.env.FAL_API_KEY || ""; | ||
| 46 | +// Overridable so tests can point these at a local mock (same reasoning as | ||
| 47 | +// H3_CONSOLE_BASE) — default to fal's real, documented hosts. | ||
| 48 | +const FAL_REST_BASE = process.env.FAL_REST_BASE || "https://rest.alpha.fal.ai"; | ||
| 49 | +const FAL_QUEUE_BASE = process.env.FAL_QUEUE_BASE || "https://queue.fal.run"; | ||
| 50 | +const FAL_MODEL_ID = "minimax/h3/reference-to-video"; | ||
| 51 | + | ||
| 39 | const MIME = { | 52 | const MIME = { |
| 40 | ".html": "text/html", | 53 | ".html": "text/html", |
| 41 | ".js": "text/javascript", | 54 | ".js": "text/javascript", |
| @@ -59,6 +72,7 @@ const { | @@ -59,6 +72,7 @@ const { | ||
| 59 | stripMetadataAndSave, | 72 | stripMetadataAndSave, |
| 60 | saveVideoUpload, | 73 | saveVideoUpload, |
| 61 | forwardToH3Assets, | 74 | forwardToH3Assets, |
| 75 | + forwardToFalAssets, | ||
| 62 | } = require("./uploads.js"); | 76 | } = require("./uploads.js"); |
| 63 | const { characters, garments, danceRefs, generationJobs, moderationLog } = require("./dressup-store.js"); | 77 | const { characters, garments, danceRefs, generationJobs, moderationLog } = require("./dressup-store.js"); |
| 64 | const { buildStage2Prompt } = require("./prompt-template.js"); | 78 | const { buildStage2Prompt } = require("./prompt-template.js"); |
| @@ -240,6 +254,29 @@ async function moderateGarmentImage(absImagePath, assetId) { | @@ -240,6 +254,29 @@ async function moderateGarmentImage(absImagePath, assetId) { | ||
| 240 | return { approved: verdict === "auto_pass", verdict, reason }; | 254 | return { approved: verdict === "auto_pass", verdict, reason }; |
| 241 | } | 255 | } |
| 242 | 256 | ||
| 257 | +// T32 (dev-plan-5.md): same seam pattern as T26 above, but for mode 3's | ||
| 258 | +// uploaded face photos against a "sensitive figure" blacklist (需求文档 5 | ||
| 259 | +// 节 — e.g. filtering politically sensitive likenesses), not general image | ||
| 260 | +// content. No vendor has been picked (needs research, dev-plan-5.md §1.2 | ||
| 261 | +// item 1). Default policy when unconfigured is default-permissive | ||
| 262 | +// (auto_pass) — deliberately confirmed with the user rather than assumed, | ||
| 263 | +// since the risk profile here (a real uploaded face, potentially a | ||
| 264 | +// sensitive public figure) is not the same as T26's garment photos, where | ||
| 265 | +// a wrong default-pass is low-stakes. Do not silently change this default | ||
| 266 | +// without asking again. | ||
| 267 | +const FACE_BLACKLIST_API_KEY = process.env.FACE_BLACKLIST_API_KEY || ""; | ||
| 268 | +async function moderateFaceBlacklist(absImagePath, assetId) { | ||
| 269 | + let verdict, reason; | ||
| 270 | + if (!FACE_BLACKLIST_API_KEY) { | ||
| 271 | + verdict = "auto_pass"; | ||
| 272 | + reason = null; | ||
| 273 | + } else { | ||
| 274 | + throw new Error("face-blacklist vendor not implemented — set FACE_BLACKLIST_API_KEY only once a provider is wired up"); | ||
| 275 | + } | ||
| 276 | + moderationLog.create({ assetType: "character_upload", assetId, verdict, reason, reviewedBy: "system" }); | ||
| 277 | + return { approved: verdict === "auto_pass", verdict, reason }; | ||
| 278 | +} | ||
| 279 | + | ||
| 243 | // T21: mirrors live.html/dance.html's own setCookie() exactly (path=/, | 280 | // T21: mirrors live.html/dance.html's own setCookie() exactly (path=/, |
| 244 | // samesite=lax, no httponly, max-age in seconds, 365-day TTL) — these are | 281 | // samesite=lax, no httponly, max-age in seconds, 365-day TTL) — these are |
| 245 | // the same viewer_uid/viewer_nickname cookies those pages already read | 282 | // the same viewer_uid/viewer_nickname cookies those pages already read |
| @@ -920,6 +957,48 @@ async function ensureH3AssetUrl(store, record, localUrlField) { | @@ -920,6 +957,48 @@ async function ensureH3AssetUrl(store, record, localUrlField) { | ||
| 920 | return asset.url; | 957 | return asset.url; |
| 921 | } | 958 | } |
| 922 | 959 | ||
| 960 | +// Same caching idea as ensureH3AssetUrl, separate field (fal is a | ||
| 961 | +// completely different storage service, not interchangeable with | ||
| 962 | +// h3AssetUrl — M.13's note that "S4 spike only solved the H3 Console side"). | ||
| 963 | +async function ensureFalAssetUrl(store, record, localUrlField) { | ||
| 964 | + if (record.falAssetUrl) return record.falAssetUrl; | ||
| 965 | + const absPath = path.join(__dirname, record[localUrlField]); | ||
| 966 | + const asset = await forwardToFalAssets(absPath, FAL_API_KEY, FAL_REST_BASE); | ||
| 967 | + store.update(record.id, { falAssetUrl: asset.url }); | ||
| 968 | + return asset.url; | ||
| 969 | +} | ||
| 970 | + | ||
| 971 | +// T33: submit + poll + fetch-result against fal's queue API (verified | ||
| 972 | +// contract, see FAL_MODEL_ID's doc comment above and forwardToFalAssets in | ||
| 973 | +// uploads.js — POST {queueHost}/{model}, poll .../requests/{id}/status, | ||
| 974 | +// fetch .../requests/{id}). Kept separate from h3ConsoleFetch's shape | ||
| 975 | +// (single-poll-returns-everything) since fal's status/result are two | ||
| 976 | +// different endpoints, not one. | ||
| 977 | +async function falSubmit(input) { | ||
| 978 | + const res = await fetch(`${FAL_QUEUE_BASE}/${FAL_MODEL_ID}`, { | ||
| 979 | + method: "POST", | ||
| 980 | + headers: { Authorization: `Key ${FAL_API_KEY}`, "Content-Type": "application/json" }, | ||
| 981 | + body: JSON.stringify(input), | ||
| 982 | + }); | ||
| 983 | + const body = await res.json(); | ||
| 984 | + if (!res.ok || !body.request_id) throw new Error(body.detail || body.error || `fal submit failed: ${res.status}`); | ||
| 985 | + return body; // { request_id, status_url, response_url, ... } | ||
| 986 | +} | ||
| 987 | +async function falPollStatus(requestId) { | ||
| 988 | + const res = await fetch(`${FAL_QUEUE_BASE}/${FAL_MODEL_ID}/requests/${requestId}/status`, { | ||
| 989 | + headers: { Authorization: `Key ${FAL_API_KEY}` }, | ||
| 990 | + }); | ||
| 991 | + return res.json(); // { status: "IN_QUEUE"|"IN_PROGRESS"|"COMPLETED"|... } | ||
| 992 | +} | ||
| 993 | +async function falFetchResult(requestId) { | ||
| 994 | + const res = await fetch(`${FAL_QUEUE_BASE}/${FAL_MODEL_ID}/requests/${requestId}`, { | ||
| 995 | + headers: { Authorization: `Key ${FAL_API_KEY}` }, | ||
| 996 | + }); | ||
| 997 | + const body = await res.json(); | ||
| 998 | + if (!res.ok || !body.video || !body.video.url) throw new Error(body.detail || body.error || "fal result missing video.url"); | ||
| 999 | + return body; // { video: { url, ... }, expanded_prompt } | ||
| 1000 | +} | ||
| 1001 | + | ||
| 923 | function pickRefDanceVideo() { | 1002 | function pickRefDanceVideo() { |
| 924 | const pool = danceRefs.list().filter((d) => d.isActive); | 1003 | const pool = danceRefs.list().filter((d) => d.isActive); |
| 925 | if (pool.length === 0) throw new Error("no active dance-refs available"); | 1004 | if (pool.length === 0) throw new Error("no active dance-refs available"); |
| @@ -937,7 +1016,12 @@ function findExistingGeneratedVideo(characterId, garmentId) { | @@ -937,7 +1016,12 @@ function findExistingGeneratedVideo(characterId, garmentId) { | ||
| 937 | // record and fires generateDanceVideo (fire-and-forget — callers poll | 1016 | // record and fires generateDanceVideo (fire-and-forget — callers poll |
| 938 | // GET /api/admin/generate/:id or /api/admin/batch-gen/status for progress). | 1017 | // GET /api/admin/generate/:id or /api/admin/batch-gen/status for progress). |
| 939 | // Returns {status, body} so route handlers can just write it out. | 1018 | // Returns {status, body} so route handlers can just write it out. |
| 940 | -function submitGeneration({ characterId, garmentId, refDanceVideoId, batchId }) { | 1019 | +// userId/priority (T33/T34, dev-plan-5.md): a free mode-一 pick passes |
| 1020 | +// neither (backend stays H3 Console, priority "normal"); T27 (mode 2) | ||
| 1021 | +// passes userId only (still "normal" — 需求文档 3 节: mode 2 stays in the | ||
| 1022 | +// vote-ranked queue, only mode 3 skips it); T35 (mode 3, once built) passes | ||
| 1023 | +// both userId and priority:"paid_boost". | ||
| 1024 | +function submitGeneration({ characterId, garmentId, refDanceVideoId, batchId, userId, priority }) { | ||
| 941 | if (!characters.get(characterId) || !garments.get(garmentId)) { | 1025 | if (!characters.get(characterId) || !garments.get(garmentId)) { |
| 942 | return { status: 400, body: { error: "characterId/garmentId must reference existing records" } }; | 1026 | return { status: 400, body: { error: "characterId/garmentId must reference existing records" } }; |
| 943 | } | 1027 | } |
| @@ -951,16 +1035,42 @@ function submitGeneration({ characterId, garmentId, refDanceVideoId, batchId }) | @@ -951,16 +1035,42 @@ function submitGeneration({ characterId, garmentId, refDanceVideoId, batchId }) | ||
| 951 | return { status: 400, body: { error: err.message } }; | 1035 | return { status: 400, body: { error: err.message } }; |
| 952 | } | 1036 | } |
| 953 | } | 1037 | } |
| 954 | - const job = generationJobs.create({ characterId, garmentId, refDanceVideoId, batchId: batchId || null, status: "queued", h3JobId: null, error: null }); | 1038 | + // T33: paid requests (userId set) route to fal's real-time compute once |
| 1039 | + // FAL_API_KEY is configured (需求文档 4.3 节双算力策略); everything else | ||
| 1040 | + // — free mode-一, or a paid request before a key exists — stays on the | ||
| 1041 | + // H3 Console path that's been working since T8. | ||
| 1042 | + const backend = userId && FAL_API_KEY ? "fal" : "h3_console"; | ||
| 1043 | + const job = generationJobs.create({ | ||
| 1044 | + characterId, | ||
| 1045 | + garmentId, | ||
| 1046 | + refDanceVideoId, | ||
| 1047 | + batchId: batchId || null, | ||
| 1048 | + status: "queued", | ||
| 1049 | + h3JobId: null, | ||
| 1050 | + falRequestId: null, | ||
| 1051 | + error: null, | ||
| 1052 | + backend, | ||
| 1053 | + userId: userId || null, | ||
| 1054 | + priority: priority || "normal", | ||
| 1055 | + }); | ||
| 955 | generateDanceVideo(job).catch(() => {}); | 1056 | generateDanceVideo(job).catch(() => {}); |
| 956 | return { status: 200, body: { cached: false, jobId: job.id } }; | 1057 | return { status: 200, body: { cached: false, jobId: job.id } }; |
| 957 | } | 1058 | } |
| 958 | 1059 | ||
| 959 | -// job: a generationJobs record ({id, characterId, garmentId, | ||
| 960 | -// refDanceVideoId, status, h3JobId, error, fullPrompt}). Mutates it via | ||
| 961 | -// generationJobs.update and keeps the local variable in sync (store.js has | ||
| 962 | -// no in-memory cache, so every update is a fresh read-modify-write). | 1060 | +// Dispatches on job.backend (T33) — H3 Console and fal.ai have genuinely |
| 1061 | +// different submit/poll/result shapes (see generateDanceVideoViaFal's doc | ||
| 1062 | +// comment), not just a different header, so this stays two implementations | ||
| 1063 | +// behind one entry point rather than one branchy function. | ||
| 963 | async function generateDanceVideo(job, opts = {}) { | 1064 | async function generateDanceVideo(job, opts = {}) { |
| 1065 | + return job.backend === "fal" ? generateDanceVideoViaFal(job, opts) : generateDanceVideoViaH3(job, opts); | ||
| 1066 | +} | ||
| 1067 | + | ||
| 1068 | +// job: a generationJobs record ({id, characterId, garmentId, | ||
| 1069 | +// refDanceVideoId, status, h3JobId, error, fullPrompt, backend, userId, | ||
| 1070 | +// priority}). Mutates it via generationJobs.update and keeps the local | ||
| 1071 | +// variable in sync (store.js has no in-memory cache, so every update is a | ||
| 1072 | +// fresh read-modify-write). | ||
| 1073 | +async function generateDanceVideoViaH3(job, opts = {}) { | ||
| 964 | const pollIntervalMs = opts.pollIntervalMs || Number(process.env.GENERATION_POLL_INTERVAL_MS) || 5000; | 1074 | const pollIntervalMs = opts.pollIntervalMs || Number(process.env.GENERATION_POLL_INTERVAL_MS) || 5000; |
| 965 | try { | 1075 | try { |
| 966 | const character = characters.get(job.characterId); | 1076 | const character = characters.get(job.characterId); |
| @@ -1033,8 +1143,8 @@ async function generateDanceVideo(job, opts = {}) { | @@ -1033,8 +1143,8 @@ async function generateDanceVideo(job, opts = {}) { | ||
| 1033 | garmentId: job.garmentId, | 1143 | garmentId: job.garmentId, |
| 1034 | refDanceVideoId: job.refDanceVideoId, | 1144 | refDanceVideoId: job.refDanceVideoId, |
| 1035 | backend: "h3_console", | 1145 | backend: "h3_console", |
| 1036 | - priority: "normal", | ||
| 1037 | - requestedByUserId: null, | 1146 | + priority: job.priority || "normal", |
| 1147 | + requestedByUserId: job.userId || null, | ||
| 1038 | prompt: `${character.name} × ${garment.name}`, | 1148 | prompt: `${character.name} × ${garment.name}`, |
| 1039 | fullPrompt: job.fullPrompt, | 1149 | fullPrompt: job.fullPrompt, |
| 1040 | width: polled.req.width, | 1150 | width: polled.req.width, |
| @@ -1061,11 +1171,117 @@ async function generateDanceVideo(job, opts = {}) { | @@ -1061,11 +1171,117 @@ async function generateDanceVideo(job, opts = {}) { | ||
| 1061 | } | 1171 | } |
| 1062 | } | 1172 | } |
| 1063 | 1173 | ||
| 1174 | +// T33: fal.ai's queue API — submit -> poll .../status -> once COMPLETED, | ||
| 1175 | +// fetch .../requests/{id} for the actual result (fal splits status and | ||
| 1176 | +// result into two endpoints; H3 Console's single poll returns both at | ||
| 1177 | +// once, see generateDanceVideoViaH3 above — genuinely different shapes, | ||
| 1178 | +// not just a header swap). | ||
| 1179 | +// | ||
| 1180 | +// NOT VERIFIED against a real fal account as of this commit — no | ||
| 1181 | +// FAL_API_KEY has ever been configured (dev-plan-5.md §1.2 item 2). The | ||
| 1182 | +// endpoints/params below come from fal's published docs and the fal-js | ||
| 1183 | +// client's own source (see forwardToFalAssets in uploads.js for | ||
| 1184 | +// citations), not guesswork — but whether the exact prompt text | ||
| 1185 | +// buildStage2Prompt produces (written for and verified against H3 | ||
| 1186 | +// Console's <Picture N>/<Video N> convention) is interpreted the same way | ||
| 1187 | +// by minimax/h3/reference-to-video on fal is an open question (dev-plan-5.md | ||
| 1188 | +// §1.2 item 3) only a real call can answer. Also unverified: fal's | ||
| 1189 | +// terminal-failure status string(s) — the docs found only named IN_QUEUE/ | ||
| 1190 | +// IN_PROGRESS/COMPLETED, so this deliberately does NOT guess an error | ||
| 1191 | +// enum; a failed fal job surfaces via the existing deadline timeout | ||
| 1192 | +// instead of a fast-fail, same conservative tradeoff as a transient poll | ||
| 1193 | +// hiccup below. | ||
| 1194 | +async function generateDanceVideoViaFal(job, opts = {}) { | ||
| 1195 | + const pollIntervalMs = opts.pollIntervalMs || Number(process.env.GENERATION_POLL_INTERVAL_MS) || 5000; | ||
| 1196 | + try { | ||
| 1197 | + const character = characters.get(job.characterId); | ||
| 1198 | + const garment = garments.get(job.garmentId); | ||
| 1199 | + const danceRef = danceRefs.get(job.refDanceVideoId); | ||
| 1200 | + if (!character || !garment || !danceRef) throw new Error("missing character/garment/dance-ref record"); | ||
| 1201 | + | ||
| 1202 | + if (!job.falRequestId) { | ||
| 1203 | + if (!job.fullPrompt) { | ||
| 1204 | + const built = buildStage2Prompt({ character, garment, danceRef }); | ||
| 1205 | + job = generationJobs.update(job.id, { fullPrompt: built.prompt }); | ||
| 1206 | + } | ||
| 1207 | + | ||
| 1208 | + const [characterAssetUrl, garmentAssetUrl, danceRefAssetUrl] = await Promise.all([ | ||
| 1209 | + ensureFalAssetUrl(characters, character, "imageUrl"), | ||
| 1210 | + ensureFalAssetUrl(garments, garment, "imageUrl"), | ||
| 1211 | + ensureFalAssetUrl(danceRefs, danceRef, "videoUrl"), | ||
| 1212 | + ]); | ||
| 1213 | + | ||
| 1214 | + const submitted = await falSubmit({ | ||
| 1215 | + prompt: job.fullPrompt, | ||
| 1216 | + duration: 10, // seconds — fal's param is already in seconds, matching our fixed 10s default directly (no /24fps conversion like H3's `frames`) | ||
| 1217 | + resolution: "480P", | ||
| 1218 | + aspect_ratio: "9:16", | ||
| 1219 | + reference_image_urls: [characterAssetUrl, garmentAssetUrl], | ||
| 1220 | + reference_video_urls: [danceRefAssetUrl], | ||
| 1221 | + }); | ||
| 1222 | + job = generationJobs.update(job.id, { falRequestId: submitted.request_id, status: "generating" }); | ||
| 1223 | + } | ||
| 1224 | + | ||
| 1225 | + const deadline = Date.now() + (opts.timeoutMs || Number(process.env.GENERATION_TIMEOUT_MS) || 2 * 60 * 60 * 1000); | ||
| 1226 | + while (Date.now() < deadline) { | ||
| 1227 | + await new Promise((r) => setTimeout(r, pollIntervalMs)); | ||
| 1228 | + let polled; | ||
| 1229 | + try { | ||
| 1230 | + polled = await falPollStatus(job.falRequestId); | ||
| 1231 | + } catch { | ||
| 1232 | + // Same reasoning as generateDanceVideoViaH3's poll try/catch: a | ||
| 1233 | + // single bad poll isn't a generation failure, retry next tick. | ||
| 1234 | + continue; | ||
| 1235 | + } | ||
| 1236 | + if (polled.status === "COMPLETED") { | ||
| 1237 | + const result = await falFetchResult(job.falRequestId); | ||
| 1238 | + const clip = { | ||
| 1239 | + id: `fal-${job.falRequestId}`, | ||
| 1240 | + source: "fal", | ||
| 1241 | + // Already a public fal CDN URL — /lib/video/:id's existing | ||
| 1242 | + // videoUrl branch (added for "manual" source clips) serves it | ||
| 1243 | + // via redirect directly, no H3-style proxying needed. | ||
| 1244 | + videoUrl: result.video.url, | ||
| 1245 | + edited: true, | ||
| 1246 | + channel: "dance", | ||
| 1247 | + characterId: job.characterId, | ||
| 1248 | + garmentId: job.garmentId, | ||
| 1249 | + refDanceVideoId: job.refDanceVideoId, | ||
| 1250 | + backend: "fal", | ||
| 1251 | + priority: job.priority || "normal", | ||
| 1252 | + requestedByUserId: job.userId || null, | ||
| 1253 | + prompt: `${character.name} × ${garment.name}`, | ||
| 1254 | + fullPrompt: job.fullPrompt, | ||
| 1255 | + // fal's result schema (video.url/content_type/file_name/file_size) | ||
| 1256 | + // doesn't echo back pixel dimensions the way H3 Console's `req` | ||
| 1257 | + // does — these are what we *requested* via resolution/aspect_ratio, | ||
| 1258 | + // not a confirmed readback. | ||
| 1259 | + width: DANCE_GEN_PARAMS.width, | ||
| 1260 | + height: DANCE_GEN_PARAMS.height, | ||
| 1261 | + duration: 10, | ||
| 1262 | + likes: 0, | ||
| 1263 | + createdAt: Date.now(), | ||
| 1264 | + }; | ||
| 1265 | + const lib = getLibrary(); | ||
| 1266 | + lib.push(clip); | ||
| 1267 | + saveLibrary(lib); | ||
| 1268 | + generationJobs.update(job.id, { status: "done", generatedVideoId: clip.id }); | ||
| 1269 | + return clip; | ||
| 1270 | + } | ||
| 1271 | + } | ||
| 1272 | + throw new Error("generation timed out"); | ||
| 1273 | + } catch (err) { | ||
| 1274 | + generationJobs.update(job.id, { status: "failed", error: err.message }); | ||
| 1275 | + throw err; | ||
| 1276 | + } | ||
| 1277 | +} | ||
| 1278 | + | ||
| 1064 | // Startup recovery, same idea as resumeGenerations() but for generationJobs. | 1279 | // Startup recovery, same idea as resumeGenerations() but for generationJobs. |
| 1065 | function resumeDressupGenerations() { | 1280 | function resumeDressupGenerations() { |
| 1066 | for (const job of generationJobs.list()) { | 1281 | for (const job of generationJobs.list()) { |
| 1067 | if (job.status !== "generating" && job.status !== "queued") continue; | 1282 | if (job.status !== "generating" && job.status !== "queued") continue; |
| 1068 | - if (job.h3JobId) generateDanceVideo(job).catch(() => {}); | 1283 | + const inFlightId = job.backend === "fal" ? job.falRequestId : job.h3JobId; |
| 1284 | + if (inFlightId) generateDanceVideo(job).catch(() => {}); | ||
| 1069 | else generationJobs.update(job.id, { status: "failed", error: "lost track of this generation across a restart" }); | 1285 | else generationJobs.update(job.id, { status: "failed", error: "lost track of this generation across a restart" }); |
| 1070 | } | 1286 | } |
| 1071 | } | 1287 | } |
| @@ -1785,7 +2001,12 @@ http | @@ -1785,7 +2001,12 @@ http | ||
| 1785 | return; | 2001 | return; |
| 1786 | } | 2002 | } |
| 1787 | 2003 | ||
| 1788 | - const { status, body: respBody } = submitGeneration({ characterId: defaultCharacter.id, garmentId: garment.id }); | 2004 | + // userId (T33): mode 2 is a paid request, so this both fixes M.7's |
| 2005 | + // requestedByUserId tracking (previously always null — a gap left | ||
| 2006 | + // over from T27) and makes it fal-eligible once FAL_API_KEY is | ||
| 2007 | + // configured. priority stays "normal" (T27/dev-plan-4.md's | ||
| 2008 | + // explicit scope: only mode 3 skips voting, mode 2 doesn't). | ||
| 2009 | + const { status, body: respBody } = submitGeneration({ characterId: defaultCharacter.id, garmentId: garment.id, userId: user.id }); | ||
| 1789 | if (status !== 200) { | 2010 | if (status !== 200) { |
| 1790 | creditLedger.refund({ userId: user.id, amount, reason: "refund_stage2_fail", relatedAssetId: garment.id }); | 2011 | creditLedger.refund({ userId: user.id, amount, reason: "refund_stage2_fail", relatedAssetId: garment.id }); |
| 1791 | garments.remove(garment.id); | 2012 | garments.remove(garment.id); |
uploads.js
| @@ -166,6 +166,38 @@ async function forwardToH3Assets(absPath, base, token) { | @@ -166,6 +166,38 @@ async function forwardToH3Assets(absPath, base, token) { | ||
| 166 | return res.json(); // { id, url, kind } | 166 | return res.json(); // { id, url, kind } |
| 167 | } | 167 | } |
| 168 | 168 | ||
| 169 | +const EXT_MIME = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", webp: "image/webp", mp4: "video/mp4" }; | ||
| 170 | + | ||
| 171 | +// T33 (dev-plan-5.md): fal's storage isn't the same service as H3 | ||
| 172 | +// Console's /v1/assets (S4 spike's finding only covered H3), so this | ||
| 173 | +// forwards a local upload through fal's own two-step contract instead — | ||
| 174 | +// verified against fal's docs (https://fal.ai/docs/model-apis/file-uploads) | ||
| 175 | +// and the fal-js client's storage.ts source, not guessed: | ||
| 176 | +// 1. POST {restBase}/storage/upload/initiate {content_type, file_name} | ||
| 177 | +// -> {file_url, upload_url} | ||
| 178 | +// 2. PUT the raw bytes to upload_url | ||
| 179 | +// file_url is then a publicly-fetchable URL usable directly as a | ||
| 180 | +// reference_image_urls/reference_video_urls entry. | ||
| 181 | +async function forwardToFalAssets(absPath, apiKey, restBase = "https://rest.alpha.fal.ai") { | ||
| 182 | + const buffer = fs.readFileSync(absPath); | ||
| 183 | + const ext = path.extname(absPath).slice(1).toLowerCase(); | ||
| 184 | + const contentType = EXT_MIME[ext] || "application/octet-stream"; | ||
| 185 | + const fileName = path.basename(absPath); | ||
| 186 | + | ||
| 187 | + const initiateRes = await fetch(`${restBase}/storage/upload/initiate?storage_type=fal-cdn-v3`, { | ||
| 188 | + method: "POST", | ||
| 189 | + headers: { Authorization: `Key ${apiKey}`, "Content-Type": "application/json" }, | ||
| 190 | + body: JSON.stringify({ content_type: contentType, file_name: fileName }), | ||
| 191 | + }); | ||
| 192 | + if (!initiateRes.ok) throw new Error(`fal upload initiate failed: ${initiateRes.status} ${await initiateRes.text()}`); | ||
| 193 | + const { file_url: fileUrl, upload_url: uploadUrl } = await initiateRes.json(); | ||
| 194 | + | ||
| 195 | + const putRes = await fetch(uploadUrl, { method: "PUT", headers: { "Content-Type": contentType }, body: buffer }); | ||
| 196 | + if (!putRes.ok) throw new Error(`fal upload PUT failed: ${putRes.status} ${await putRes.text()}`); | ||
| 197 | + | ||
| 198 | + return { url: fileUrl }; | ||
| 199 | +} | ||
| 200 | + | ||
| 169 | module.exports = { | 201 | module.exports = { |
| 170 | MAX_UPLOAD_BYTES, | 202 | MAX_UPLOAD_BYTES, |
| 171 | MAX_VIDEO_UPLOAD_BYTES, | 203 | MAX_VIDEO_UPLOAD_BYTES, |
| @@ -178,4 +210,5 @@ module.exports = { | @@ -178,4 +210,5 @@ module.exports = { | ||
| 178 | stripMetadataAndSave, | 210 | stripMetadataAndSave, |
| 179 | saveVideoUpload, | 211 | saveVideoUpload, |
| 180 | forwardToH3Assets, | 212 | forwardToH3Assets, |
| 213 | + forwardToFalAssets, | ||
| 181 | }; | 214 | }; |