Commit 59c73988acefbadb8fb1e81b76c207cad3e4e6ab

Authored by yibo
1 parent 2f567187

Add single dance-video generation orchestration (T8)

generateDanceVideo() takes a queued generationJobs record through
prompt build -> forward character/garment/dance-ref uploads to H3
Console's /v1/assets -> POST /v1/videos -> poll -> push a traced clip
into library.json (characterId/garmentId/refDanceVideoId). Mirrors
generateForPrompt's submit/poll/persist shape but keyed off the new
generationJobs store so a restart resumes any in-flight job by its
h3JobId, same as resumeGenerations() does for the live queue.

POST /api/admin/generate accepts characterId/garmentId (+ optional
refDanceVideoId, else a random active dance-ref is picked) and skips
straight to the cached clip if that character x garment combo was
already generated (M.7 dedupe). GET /api/admin/generate/:id polls job
status.

Tests spin up a mock H3 Console over HTTP so the full submit/poll/land
path runs offline. Also fixes two existing test fixtures
(dressup-crud, admin-pages-flow) that spawn server.js without copying
prompt-template.js — worked until T8 added the first require() of it
into server.js, at which point the spawned server silently failed to
boot.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
admin-pages-flow.test.js
... ... @@ -41,7 +41,7 @@ function waitForServer(url, timeoutMs = 10000) {
41 41  
42 42 test.before(async () => {
43 43 cwd = fs.mkdtempSync(path.join(os.tmpdir(), "admin-pages-flow-"));
44   - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js"]) {
  44 + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) {
45 45 fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
46 46 }
47 47  
... ...
dressup-crud.test.js
... ... @@ -41,7 +41,7 @@ test.before(async () =&gt; {
41 41 // empty and this test can't collide with real seed data or the live
42 42 // pm2 instance.
43 43 cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-crud-"));
44   - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js"]) {
  44 + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) {
45 45 fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
46 46 }
47 47  
... ...
dressup-generate.test.js 0 → 100644
  1 +// Integration test for the T8 single-generation orchestration
  2 +// (POST/GET /api/admin/generate): submit -> forward assets to H3 /v1/assets
  3 +// -> POST /v1/videos -> poll -> land a clip in library.json with
  4 +// characterId/garmentId/refDanceVideoId. Runs server.js against a mock H3
  5 +// Console (not the real one) so it's fast and offline.
  6 +const test = require("node:test");
  7 +const assert = require("node:assert/strict");
  8 +const fs = require("node:fs");
  9 +const path = require("node:path");
  10 +const os = require("node:os");
  11 +const http = require("node:http");
  12 +const { spawn } = require("node:child_process");
  13 +
  14 +const PORT = 21934 + Math.floor(Math.random() * 1000);
  15 +const BASE = `http://localhost:${PORT}`;
  16 +const ADMIN_PASSWORD = "test-password-only-for-this-run";
  17 +let cookie;
  18 +let child;
  19 +let cwd;
  20 +let mockH3;
  21 +let mockH3Port;
  22 +let videoPollCounts; // h3JobId -> number of GETs seen, so we can return "generating" once then "done"
  23 +
  24 +function waitForServer(url, timeoutMs = 10000) {
  25 + const deadline = Date.now() + timeoutMs;
  26 + return (async function poll() {
  27 + while (Date.now() < deadline) {
  28 + try {
  29 + await fetch(url);
  30 + return;
  31 + } catch {
  32 + await new Promise((r) => setTimeout(r, 100));
  33 + }
  34 + }
  35 + throw new Error("server did not come up in time");
  36 + })();
  37 +}
  38 +
  39 +function authed(opts = {}) {
  40 + return { ...opts, headers: { "Content-Type": "application/json", Cookie: cookie, ...opts.headers } };
  41 +}
  42 +
  43 +// Minimal stand-in for H3 Console: enough of /v1/assets, /v1/videos and
  44 +// /v1/videos/:id to exercise generateDanceVideo end to end.
  45 +function startMockH3() {
  46 + videoPollCounts = new Map();
  47 + let nextId = 1;
  48 + return new Promise((resolve) => {
  49 + mockH3 = http.createServer((req, res) => {
  50 + // h3ConsoleFetch builds paths as `${new URL(H3_CONSOLE_BASE).pathname}${path}`;
  51 + // with a bare "http://host:port" base (pathname "/") that yields a
  52 + // leading "//v1/...", which real deployments avoid by pointing
  53 + // H3_CONSOLE_BASE at a sub-path (e.g. ".../console"). Normalize here
  54 + // so the mock matches routes regardless.
  55 + req.url = req.url.replace(/^\/+/, "/");
  56 + const chunks = [];
  57 + req.on("data", (c) => chunks.push(c));
  58 + req.on("end", () => {
  59 + if (req.method === "POST" && req.url === "/v1/assets") {
  60 + const id = `asset-${nextId++}`;
  61 + res.writeHead(200, { "Content-Type": "application/json" });
  62 + res.end(JSON.stringify({ id, url: `http://mock-h3/asset/${id}`, kind: "image" }));
  63 + return;
  64 + }
  65 + if (req.method === "POST" && req.url === "/v1/videos") {
  66 + const id = `job-${nextId++}`;
  67 + res.writeHead(200, { "Content-Type": "application/json" });
  68 + res.end(JSON.stringify({ id }));
  69 + return;
  70 + }
  71 + const pollMatch = req.method === "GET" && req.url.match(/^\/v1\/videos\/(.+)$/);
  72 + if (pollMatch) {
  73 + const id = pollMatch[1];
  74 + const seen = (videoPollCounts.get(id) || 0) + 1;
  75 + videoPollCounts.set(id, seen);
  76 + if (seen < 2) {
  77 + res.writeHead(200, { "Content-Type": "application/json" });
  78 + res.end(JSON.stringify({ id, status: "generating" }));
  79 + return;
  80 + }
  81 + res.writeHead(200, { "Content-Type": "application/json" });
  82 + res.end(
  83 + JSON.stringify({
  84 + id,
  85 + status: "done",
  86 + partition: "ref2va",
  87 + req: { width: 480, height: 832, frames: 240 },
  88 + })
  89 + );
  90 + return;
  91 + }
  92 + res.writeHead(404, { "Content-Type": "application/json" });
  93 + res.end(JSON.stringify({ error: "not found in mock" }));
  94 + });
  95 + });
  96 + mockH3.listen(0, () => {
  97 + mockH3Port = mockH3.address().port;
  98 + resolve();
  99 + });
  100 + });
  101 +}
  102 +
  103 +test.before(async () => {
  104 + await startMockH3();
  105 +
  106 + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-generate-"));
  107 + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) {
  108 + fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
  109 + }
  110 + // ensureH3AssetUrl reads these off disk (path.join(__dirname, record.imageUrl))
  111 + // before forwarding to /v1/assets, so they need to actually exist.
  112 + fs.mkdirSync(path.join(cwd, "uploads"), { recursive: true });
  113 + fs.writeFileSync(path.join(cwd, "uploads", "person.png"), "fake-png-bytes");
  114 + fs.writeFileSync(path.join(cwd, "uploads", "garment.png"), "fake-png-bytes");
  115 + fs.writeFileSync(path.join(cwd, "uploads", "ref.mp4"), "fake-mp4-bytes");
  116 +
  117 + child = spawn(process.execPath, ["server.js"], {
  118 + cwd,
  119 + env: {
  120 + ...process.env,
  121 + PORT: String(PORT),
  122 + H3_CONSOLE_BASE: `http://localhost:${mockH3Port}`,
  123 + H3_CONSOLE_TOKEN: "mock-token",
  124 + ADMIN_PASSWORD,
  125 + GENERATION_POLL_INTERVAL_MS: "20",
  126 + },
  127 + stdio: "pipe",
  128 + });
  129 + await waitForServer(BASE);
  130 +
  131 + const loginRes = await fetch(`${BASE}/api/admin/login`, {
  132 + method: "POST",
  133 + headers: { "Content-Type": "application/json" },
  134 + body: JSON.stringify({ password: ADMIN_PASSWORD }),
  135 + });
  136 + cookie = (loginRes.headers.get("set-cookie") || "").split(";")[0];
  137 +});
  138 +
  139 +test.after(() => {
  140 + child.kill();
  141 + mockH3.close();
  142 + fs.rmSync(cwd, { recursive: true, force: true });
  143 +});
  144 +
  145 +async function waitForJobDone(jobId, timeoutMs = 5000) {
  146 + const deadline = Date.now() + timeoutMs;
  147 + while (Date.now() < deadline) {
  148 + const job = await fetch(`${BASE}/api/admin/generate/${jobId}`, authed()).then((r) => r.json());
  149 + if (job.status === "done" || job.status === "failed") return job;
  150 + await new Promise((r) => setTimeout(r, 30));
  151 + }
  152 + throw new Error("job did not finish in time");
  153 +}
  154 +
  155 +test("generate: full happy path lands a clip with character/garment/dance-ref traceability", async () => {
  156 + const character = await fetch(
  157 + `${BASE}/api/admin/characters`,
  158 + authed({ method: "POST", body: JSON.stringify({ name: "小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair" }) })
  159 + ).then((r) => r.json());
  160 + const garment = await fetch(
  161 + `${BASE}/api/admin/garments`,
  162 + authed({ method: "POST", body: JSON.stringify({ name: "红旗袍", imageUrl: "/uploads/garment.png", appearanceDescriptor: "red qipao" }) })
  163 + ).then((r) => r.json());
  164 + const danceRef = await fetch(
  165 + `${BASE}/api/admin/dance-refs`,
  166 + authed({ method: "POST", body: JSON.stringify({ name: "拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) })
  167 + ).then((r) => r.json());
  168 +
  169 + const submitRes = await fetch(
  170 + `${BASE}/api/admin/generate`,
  171 + authed({ method: "POST", body: JSON.stringify({ characterId: character.id, garmentId: garment.id }) })
  172 + );
  173 + assert.equal(submitRes.status, 200);
  174 + const submitted = await submitRes.json();
  175 + assert.equal(submitted.cached, false);
  176 + assert.ok(submitted.jobId);
  177 +
  178 + const job = await waitForJobDone(submitted.jobId);
  179 + assert.equal(job.status, "done");
  180 + assert.equal(job.refDanceVideoId, danceRef.id); // auto-picked since none was given
  181 + assert.ok(job.generatedVideoId);
  182 +
  183 + const library = await fetch(`${BASE}/api/library`).then((r) => r.json());
  184 + const clip = library.find((c) => c.id === job.generatedVideoId);
  185 + assert.ok(clip, "generated clip should show up in /api/library");
  186 + assert.equal(clip.characterId, character.id);
  187 + assert.equal(clip.garmentId, garment.id);
  188 + assert.equal(clip.refDanceVideoId, danceRef.id);
  189 + assert.equal(clip.channel, "dance");
  190 +
  191 + // Re-submitting the same character x garment combo should hit the M.7
  192 + // dedupe cache instead of generating again.
  193 + const repeatRes = await fetch(
  194 + `${BASE}/api/admin/generate`,
  195 + authed({ method: "POST", body: JSON.stringify({ characterId: character.id, garmentId: garment.id }) })
  196 + );
  197 + const repeat = await repeatRes.json();
  198 + assert.equal(repeat.cached, true);
  199 + assert.equal(repeat.clip.id, clip.id);
  200 +});
  201 +
  202 +test("generate: rejects unknown character/garment ids", async () => {
  203 + const res = await fetch(`${BASE}/api/admin/generate`, authed({ method: "POST", body: JSON.stringify({ characterId: "nope", garmentId: "nope" }) }));
  204 + assert.equal(res.status, 400);
  205 +});
  206 +
  207 +test("generate: 404 for unknown job id", async () => {
  208 + const res = await fetch(`${BASE}/api/admin/generate/nonexistent`, authed());
  209 + assert.equal(res.status, 404);
  210 +});
... ...
dressup-store.js
... ... @@ -7,4 +7,10 @@ const characters = createStore(path.join(__dirname, &quot;characters.json&quot;));
7 7 const garments = createStore(path.join(__dirname, "garments.json"));
8 8 const danceRefs = createStore(path.join(__dirname, "dance-refs.json"));
9 9  
10   -module.exports = { characters, garments, danceRefs };
  10 +// In-flight/completed generation jobs (dev-plan.md T8/T9) — tracks a
  11 +// submission from "queued" through "generating" to "done"/"failed" so a
  12 +// restart can resume polling (h3JobId) and T9's batch UI can show progress.
  13 +// The finished *video* itself lands in library.json (M.7), not here.
  14 +const generationJobs = createStore(path.join(__dirname, "generation-jobs.json"));
  15 +
  16 +module.exports = { characters, garments, danceRefs, generationJobs };
... ...
server.js
... ... @@ -51,8 +51,10 @@ const {
51 51 sniffVideoType,
52 52 stripMetadataAndSave,
53 53 saveVideoUpload,
  54 + forwardToH3Assets,
54 55 } = require("./uploads.js");
55   -const { characters, garments, danceRefs } = require("./dressup-store.js");
  56 +const { characters, garments, danceRefs, generationJobs } = require("./dressup-store.js");
  57 +const { buildStage2Prompt } = require("./prompt-template.js");
56 58  
57 59 const LIBRARY_PATH = path.join(__dirname, "library.json");
58 60 function getLibrary() {
... ... @@ -386,6 +388,11 @@ const FILLER_DURATION_MS = 30000; // keep the schedule filled at least this far
386 388 const H3_GEN_PARAMS = { width: 480, height: 832, frames: 240, steps: 20, turbo: false, seed: 0 };
387 389 const FPS = 24;
388 390  
  391 +// dance.html's own generation params (architecture.md 2.4: each channel
  392 +// keeps an independent H3_GEN_PARAMS even though the values happen to
  393 +// match live.html's today — don't merge the two constants).
  394 +const DANCE_GEN_PARAMS = { width: 480, height: 832, frames: 240, steps: 20, turbo: false, seed: 0 };
  395 +
389 396 const initialLiveState = loadLiveState();
390 397 let schedule = initialLiveState.schedule; // [{clip, startAt, endAt, requestedBy?}]
391 398 let promptQueue = initialLiveState.promptQueue; // [{id, prompt, nickname?, status, clip?, error?, submittedAt, h3JobId?}]
... ... @@ -629,6 +636,121 @@ function resumeGenerations() {
629 636 }
630 637 }
631 638  
  639 +// ---- Dressup dance room: one-step generation orchestration (T8) ----
  640 +// Mirrors generateForPrompt's shape (submit -> persist h3JobId -> poll ->
  641 +// push a library.json clip on success) but for ref2va instead of plain
  642 +// text, and persisted in generationJobs (dressup-store.js) instead of
  643 +// live-state.json's promptQueue — this is an admin/batch-driven pipeline,
  644 +// not the viewer-facing live queue, so it shouldn't share that store.
  645 +
  646 +// Forwards a store record's local upload to H3 Console's /v1/assets
  647 +// (S4 spike) the first time it's needed, then caches the result on the
  648 +// record so later generations reusing the same character/garment/dance-ref
  649 +// don't re-forward the same file (MVCP M.13).
  650 +async function ensureH3AssetUrl(store, record, localUrlField) {
  651 + if (record.h3AssetUrl) return record.h3AssetUrl;
  652 + const absPath = path.join(__dirname, record[localUrlField]);
  653 + const asset = await forwardToH3Assets(absPath, H3_CONSOLE_BASE, H3_CONSOLE_TOKEN);
  654 + store.update(record.id, { h3AssetUrl: asset.url });
  655 + return asset.url;
  656 +}
  657 +
  658 +function pickRefDanceVideo() {
  659 + const pool = danceRefs.list().filter((d) => d.isActive);
  660 + if (pool.length === 0) throw new Error("no active dance-refs available");
  661 + return pool[Math.floor(Math.random() * pool.length)];
  662 +}
  663 +
  664 +// 4.6 节去重缓存:相同 (characterId, garmentId) 组合直接复用已有产物。
  665 +function findExistingGeneratedVideo(characterId, garmentId) {
  666 + return getLibrary().find((c) => c.channel === "dance" && c.characterId === characterId && c.garmentId === garmentId);
  667 +}
  668 +
  669 +// job: a generationJobs record ({id, characterId, garmentId,
  670 +// refDanceVideoId, status, h3JobId, error, fullPrompt}). Mutates it via
  671 +// generationJobs.update and keeps the local variable in sync (store.js has
  672 +// no in-memory cache, so every update is a fresh read-modify-write).
  673 +async function generateDanceVideo(job, opts = {}) {
  674 + const pollIntervalMs = opts.pollIntervalMs || Number(process.env.GENERATION_POLL_INTERVAL_MS) || 5000;
  675 + try {
  676 + const character = characters.get(job.characterId);
  677 + const garment = garments.get(job.garmentId);
  678 + const danceRef = danceRefs.get(job.refDanceVideoId);
  679 + if (!character || !garment || !danceRef) throw new Error("missing character/garment/dance-ref record");
  680 +
  681 + if (!job.h3JobId) {
  682 + if (!job.fullPrompt) {
  683 + const built = buildStage2Prompt({ character, garment, danceRef });
  684 + job = generationJobs.update(job.id, { fullPrompt: built.prompt });
  685 + }
  686 +
  687 + const [characterAssetUrl, garmentAssetUrl, danceRefAssetUrl] = await Promise.all([
  688 + ensureH3AssetUrl(characters, character, "imageUrl"),
  689 + ensureH3AssetUrl(garments, garment, "imageUrl"),
  690 + ensureH3AssetUrl(danceRefs, danceRef, "videoUrl"),
  691 + ]);
  692 +
  693 + const submitted = await h3ConsoleFetch("/v1/videos", {
  694 + method: "POST",
  695 + body: {
  696 + prompt: job.fullPrompt,
  697 + ...DANCE_GEN_PARAMS,
  698 + reference_image_urls: [characterAssetUrl, garmentAssetUrl],
  699 + reference_video_urls: [danceRefAssetUrl],
  700 + },
  701 + });
  702 + if (!submitted.id) throw new Error(submitted.detail || "submit failed");
  703 + job = generationJobs.update(job.id, { h3JobId: submitted.id, status: "generating" });
  704 + }
  705 +
  706 + for (let i = 0; i < 200; i++) {
  707 + await new Promise((r) => setTimeout(r, pollIntervalMs));
  708 + const polled = await h3ConsoleFetch(`/v1/videos/${job.h3JobId}`);
  709 + if (polled.deleted_at) throw new Error("job was deleted on the H3 console before it finished");
  710 + if (polled.status === "done") {
  711 + const clip = {
  712 + id: polled.id,
  713 + source: "h3console",
  714 + channel: "dance",
  715 + characterId: job.characterId,
  716 + garmentId: job.garmentId,
  717 + refDanceVideoId: job.refDanceVideoId,
  718 + backend: "h3_console",
  719 + priority: "normal",
  720 + requestedByUserId: null,
  721 + prompt: `${character.name} × ${garment.name}`,
  722 + fullPrompt: job.fullPrompt,
  723 + width: polled.req.width,
  724 + height: polled.req.height,
  725 + duration: Math.round((polled.req.frames / FPS) * 10) / 10,
  726 + partition: polled.partition,
  727 + likes: 0,
  728 + createdAt: Date.now(),
  729 + };
  730 + const lib = getLibrary();
  731 + lib.push(clip);
  732 + saveLibrary(lib);
  733 + generationJobs.update(job.id, { status: "done", generatedVideoId: clip.id });
  734 + return clip;
  735 + }
  736 + if (polled.status === "error" || polled.status === "failed") throw new Error(polled.error || "generation failed");
  737 + }
  738 + throw new Error("generation timed out");
  739 + } catch (err) {
  740 + generationJobs.update(job.id, { status: "failed", error: err.message });
  741 + throw err;
  742 + }
  743 +}
  744 +
  745 +// Startup recovery, same idea as resumeGenerations() but for generationJobs.
  746 +function resumeDressupGenerations() {
  747 + for (const job of generationJobs.list()) {
  748 + if (job.status !== "generating" && job.status !== "queued") continue;
  749 + if (job.h3JobId) generateDanceVideo(job).catch(() => {});
  750 + else generationJobs.update(job.id, { status: "failed", error: "lost track of this generation across a restart" });
  751 + }
  752 +}
  753 +
632 754 http
633 755 .createServer((req, res) => {
634 756 if (req.url.startsWith("/fal/")) {
... ... @@ -698,6 +820,61 @@ http
698 820 if (garmentsCrud(pathname, req, res)) return;
699 821 if (danceRefsCrud(pathname, req, res)) return;
700 822  
  823 + if (pathname === "/api/admin/generate" && req.method === "GET") {
  824 + res.writeHead(200, { "Content-Type": "application/json" });
  825 + res.end(JSON.stringify(generationJobs.list()));
  826 + return;
  827 + }
  828 +
  829 + if (pathname === "/api/admin/generate" && req.method === "POST") {
  830 + readJsonBody(req)
  831 + .then((body) => {
  832 + const { characterId, garmentId } = body;
  833 + if (!characters.get(characterId) || !garments.get(garmentId)) {
  834 + res.writeHead(400, { "Content-Type": "application/json" });
  835 + res.end(JSON.stringify({ error: "characterId/garmentId must reference existing records" }));
  836 + return;
  837 + }
  838 + const existing = findExistingGeneratedVideo(characterId, garmentId);
  839 + if (existing) {
  840 + res.writeHead(200, { "Content-Type": "application/json" });
  841 + res.end(JSON.stringify({ cached: true, clip: existing }));
  842 + return;
  843 + }
  844 + let refDanceVideoId = body.refDanceVideoId;
  845 + if (!refDanceVideoId) {
  846 + try {
  847 + refDanceVideoId = pickRefDanceVideo().id;
  848 + } catch (err) {
  849 + res.writeHead(400, { "Content-Type": "application/json" });
  850 + res.end(JSON.stringify({ error: err.message }));
  851 + return;
  852 + }
  853 + }
  854 + const job = generationJobs.create({ characterId, garmentId, refDanceVideoId, status: "queued", h3JobId: null, error: null });
  855 + generateDanceVideo(job).catch(() => {}); // fire-and-forget; poll GET /api/admin/generate/:id for status
  856 + res.writeHead(200, { "Content-Type": "application/json" });
  857 + res.end(JSON.stringify({ cached: false, jobId: job.id }));
  858 + })
  859 + .catch(() => {
  860 + res.writeHead(400, { "Content-Type": "application/json" });
  861 + res.end(JSON.stringify({ error: "invalid JSON body" }));
  862 + });
  863 + return;
  864 + }
  865 +
  866 + if (pathname.startsWith("/api/admin/generate/") && req.method === "GET") {
  867 + const job = generationJobs.get(pathname.slice("/api/admin/generate/".length));
  868 + if (!job) {
  869 + res.writeHead(404, { "Content-Type": "application/json" });
  870 + res.end(JSON.stringify({ error: "not found" }));
  871 + return;
  872 + }
  873 + res.writeHead(200, { "Content-Type": "application/json" });
  874 + res.end(JSON.stringify(job));
  875 + return;
  876 + }
  877 +
701 878 if (pathname === "/api/library") {
702 879 const sort = new URL(req.url, "http://x").searchParams.get("sort") || "newest";
703 880 const lib = getLibrary().slice();
... ... @@ -1115,6 +1292,7 @@ http
1115 1292 .listen(PORT, () => console.log(`http://localhost:${PORT}`));
1116 1293  
1117 1294 resumeGenerations();
  1295 +resumeDressupGenerations();
1118 1296  
1119 1297 // Auto-sync the library from the H3 Console so content added there directly
1120 1298 // (not through our own /api/prompt) shows up without a manual `node ingest.js`.
... ...