Commit 4bfa5769809527ad3728700db836a15ab86af1df

Authored by yibo
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
dance-mode2.test.js
... ... @@ -256,10 +256,17 @@ test(&quot;full happy path: upload -&gt; moderated+auto-tagged -&gt; spent -&gt; generated -&gt;
256 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 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 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 || &quot;&quot;;
36 36 const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || "";
37 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 52 const MIME = {
40 53 ".html": "text/html",
41 54 ".js": "text/javascript",
... ... @@ -59,6 +72,7 @@ const {
59 72 stripMetadataAndSave,
60 73 saveVideoUpload,
61 74 forwardToH3Assets,
  75 + forwardToFalAssets,
62 76 } = require("./uploads.js");
63 77 const { characters, garments, danceRefs, generationJobs, moderationLog } = require("./dressup-store.js");
64 78 const { buildStage2Prompt } = require("./prompt-template.js");
... ... @@ -240,6 +254,29 @@ async function moderateGarmentImage(absImagePath, assetId) {
240 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 280 // T21: mirrors live.html/dance.html's own setCookie() exactly (path=/,
244 281 // samesite=lax, no httponly, max-age in seconds, 365-day TTL) — these are
245 282 // the same viewer_uid/viewer_nickname cookies those pages already read
... ... @@ -920,6 +957,48 @@ async function ensureH3AssetUrl(store, record, localUrlField) {
920 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 1002 function pickRefDanceVideo() {
924 1003 const pool = danceRefs.list().filter((d) => d.isActive);
925 1004 if (pool.length === 0) throw new Error("no active dance-refs available");
... ... @@ -937,7 +1016,12 @@ function findExistingGeneratedVideo(characterId, garmentId) {
937 1016 // record and fires generateDanceVideo (fire-and-forget — callers poll
938 1017 // GET /api/admin/generate/:id or /api/admin/batch-gen/status for progress).
939 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 1025 if (!characters.get(characterId) || !garments.get(garmentId)) {
942 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 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 1056 generateDanceVideo(job).catch(() => {});
956 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 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 1074 const pollIntervalMs = opts.pollIntervalMs || Number(process.env.GENERATION_POLL_INTERVAL_MS) || 5000;
965 1075 try {
966 1076 const character = characters.get(job.characterId);
... ... @@ -1033,8 +1143,8 @@ async function generateDanceVideo(job, opts = {}) {
1033 1143 garmentId: job.garmentId,
1034 1144 refDanceVideoId: job.refDanceVideoId,
1035 1145 backend: "h3_console",
1036   - priority: "normal",
1037   - requestedByUserId: null,
  1146 + priority: job.priority || "normal",
  1147 + requestedByUserId: job.userId || null,
1038 1148 prompt: `${character.name} × ${garment.name}`,
1039 1149 fullPrompt: job.fullPrompt,
1040 1150 width: polled.req.width,
... ... @@ -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 1279 // Startup recovery, same idea as resumeGenerations() but for generationJobs.
1065 1280 function resumeDressupGenerations() {
1066 1281 for (const job of generationJobs.list()) {
1067 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 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 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 2010 if (status !== 200) {
1790 2011 creditLedger.refund({ userId: user.id, amount, reason: "refund_stage2_fail", relatedAssetId: garment.id });
1791 2012 garments.remove(garment.id);
... ...
uploads.js
... ... @@ -166,6 +166,38 @@ async function forwardToH3Assets(absPath, base, token) {
166 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 201 module.exports = {
170 202 MAX_UPLOAD_BYTES,
171 203 MAX_VIDEO_UPLOAD_BYTES,
... ... @@ -178,4 +210,5 @@ module.exports = {
178 210 stripMetadataAndSave,
179 211 saveVideoUpload,
180 212 forwardToH3Assets,
  213 + forwardToFalAssets,
181 214 };
... ...