From 59c73988acefbadb8fb1e81b76c207cad3e4e6ab Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 2 Sep 2026 10:15:14 +0800 Subject: [PATCH] Add single dance-video generation orchestration (T8) --- admin-pages-flow.test.js | 2 +- dressup-crud.test.js | 2 +- dressup-generate.test.js | 210 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ dressup-store.js | 8 +++++++- server.js | 180 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 5 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 dressup-generate.test.js diff --git a/admin-pages-flow.test.js b/admin-pages-flow.test.js index 0c61bec..55a31da 100644 --- a/admin-pages-flow.test.js +++ b/admin-pages-flow.test.js @@ -41,7 +41,7 @@ function waitForServer(url, timeoutMs = 10000) { test.before(async () => { cwd = fs.mkdtempSync(path.join(os.tmpdir(), "admin-pages-flow-")); - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js"]) { + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); } diff --git a/dressup-crud.test.js b/dressup-crud.test.js index c4d9506..d63afb6 100644 --- a/dressup-crud.test.js +++ b/dressup-crud.test.js @@ -41,7 +41,7 @@ test.before(async () => { // empty and this test can't collide with real seed data or the live // pm2 instance. cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-crud-")); - for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js"]) { + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) { fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); } diff --git a/dressup-generate.test.js b/dressup-generate.test.js new file mode 100644 index 0000000..c39f689 --- /dev/null +++ b/dressup-generate.test.js @@ -0,0 +1,210 @@ +// Integration test for the T8 single-generation orchestration +// (POST/GET /api/admin/generate): submit -> forward assets to H3 /v1/assets +// -> POST /v1/videos -> poll -> land a clip in library.json with +// characterId/garmentId/refDanceVideoId. Runs server.js against a mock H3 +// Console (not the real one) so it's fast and offline. +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const os = require("node:os"); +const http = require("node:http"); +const { spawn } = require("node:child_process"); + +const PORT = 21934 + Math.floor(Math.random() * 1000); +const BASE = `http://localhost:${PORT}`; +const ADMIN_PASSWORD = "test-password-only-for-this-run"; +let cookie; +let child; +let cwd; +let mockH3; +let mockH3Port; +let videoPollCounts; // h3JobId -> number of GETs seen, so we can return "generating" once then "done" + +function waitForServer(url, timeoutMs = 10000) { + const deadline = Date.now() + timeoutMs; + return (async function poll() { + while (Date.now() < deadline) { + try { + await fetch(url); + return; + } catch { + await new Promise((r) => setTimeout(r, 100)); + } + } + throw new Error("server did not come up in time"); + })(); +} + +function authed(opts = {}) { + return { ...opts, headers: { "Content-Type": "application/json", Cookie: cookie, ...opts.headers } }; +} + +// Minimal stand-in for H3 Console: enough of /v1/assets, /v1/videos and +// /v1/videos/:id to exercise generateDanceVideo end to end. +function startMockH3() { + videoPollCounts = new Map(); + let nextId = 1; + return new Promise((resolve) => { + mockH3 = http.createServer((req, res) => { + // h3ConsoleFetch builds paths as `${new URL(H3_CONSOLE_BASE).pathname}${path}`; + // with a bare "http://host:port" base (pathname "/") that yields a + // leading "//v1/...", which real deployments avoid by pointing + // H3_CONSOLE_BASE at a sub-path (e.g. ".../console"). Normalize here + // so the mock matches routes regardless. + req.url = req.url.replace(/^\/+/, "/"); + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + if (req.method === "POST" && req.url === "/v1/assets") { + const id = `asset-${nextId++}`; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ id, url: `http://mock-h3/asset/${id}`, kind: "image" })); + return; + } + if (req.method === "POST" && req.url === "/v1/videos") { + const id = `job-${nextId++}`; + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ id })); + return; + } + const pollMatch = req.method === "GET" && req.url.match(/^\/v1\/videos\/(.+)$/); + if (pollMatch) { + const id = pollMatch[1]; + const seen = (videoPollCounts.get(id) || 0) + 1; + videoPollCounts.set(id, seen); + if (seen < 2) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ id, status: "generating" })); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ + id, + status: "done", + partition: "ref2va", + req: { width: 480, height: 832, frames: 240 }, + }) + ); + return; + } + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "not found in mock" })); + }); + }); + mockH3.listen(0, () => { + mockH3Port = mockH3.address().port; + resolve(); + }); + }); +} + +test.before(async () => { + await startMockH3(); + + cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-generate-")); + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) { + fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f)); + } + // ensureH3AssetUrl reads these off disk (path.join(__dirname, record.imageUrl)) + // before forwarding to /v1/assets, so they need to actually exist. + fs.mkdirSync(path.join(cwd, "uploads"), { recursive: true }); + fs.writeFileSync(path.join(cwd, "uploads", "person.png"), "fake-png-bytes"); + fs.writeFileSync(path.join(cwd, "uploads", "garment.png"), "fake-png-bytes"); + fs.writeFileSync(path.join(cwd, "uploads", "ref.mp4"), "fake-mp4-bytes"); + + child = spawn(process.execPath, ["server.js"], { + cwd, + env: { + ...process.env, + PORT: String(PORT), + H3_CONSOLE_BASE: `http://localhost:${mockH3Port}`, + H3_CONSOLE_TOKEN: "mock-token", + ADMIN_PASSWORD, + GENERATION_POLL_INTERVAL_MS: "20", + }, + stdio: "pipe", + }); + await waitForServer(BASE); + + const loginRes = await fetch(`${BASE}/api/admin/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: ADMIN_PASSWORD }), + }); + cookie = (loginRes.headers.get("set-cookie") || "").split(";")[0]; +}); + +test.after(() => { + child.kill(); + mockH3.close(); + fs.rmSync(cwd, { recursive: true, force: true }); +}); + +async function waitForJobDone(jobId, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const job = await fetch(`${BASE}/api/admin/generate/${jobId}`, authed()).then((r) => r.json()); + if (job.status === "done" || job.status === "failed") return job; + await new Promise((r) => setTimeout(r, 30)); + } + throw new Error("job did not finish in time"); +} + +test("generate: full happy path lands a clip with character/garment/dance-ref traceability", async () => { + const character = await fetch( + `${BASE}/api/admin/characters`, + authed({ method: "POST", body: JSON.stringify({ name: "小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair" }) }) + ).then((r) => r.json()); + const garment = await fetch( + `${BASE}/api/admin/garments`, + authed({ method: "POST", body: JSON.stringify({ name: "红旗袍", imageUrl: "/uploads/garment.png", appearanceDescriptor: "red qipao" }) }) + ).then((r) => r.json()); + const danceRef = await fetch( + `${BASE}/api/admin/dance-refs`, + authed({ method: "POST", body: JSON.stringify({ name: "拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) }) + ).then((r) => r.json()); + + const submitRes = await fetch( + `${BASE}/api/admin/generate`, + authed({ method: "POST", body: JSON.stringify({ characterId: character.id, garmentId: garment.id }) }) + ); + assert.equal(submitRes.status, 200); + const submitted = await submitRes.json(); + assert.equal(submitted.cached, false); + assert.ok(submitted.jobId); + + const job = await waitForJobDone(submitted.jobId); + assert.equal(job.status, "done"); + assert.equal(job.refDanceVideoId, danceRef.id); // auto-picked since none was given + assert.ok(job.generatedVideoId); + + const library = await fetch(`${BASE}/api/library`).then((r) => r.json()); + const clip = library.find((c) => c.id === job.generatedVideoId); + assert.ok(clip, "generated clip should show up in /api/library"); + assert.equal(clip.characterId, character.id); + assert.equal(clip.garmentId, garment.id); + assert.equal(clip.refDanceVideoId, danceRef.id); + assert.equal(clip.channel, "dance"); + + // Re-submitting the same character x garment combo should hit the M.7 + // dedupe cache instead of generating again. + const repeatRes = await fetch( + `${BASE}/api/admin/generate`, + authed({ method: "POST", body: JSON.stringify({ characterId: character.id, garmentId: garment.id }) }) + ); + const repeat = await repeatRes.json(); + assert.equal(repeat.cached, true); + assert.equal(repeat.clip.id, clip.id); +}); + +test("generate: rejects unknown character/garment ids", async () => { + const res = await fetch(`${BASE}/api/admin/generate`, authed({ method: "POST", body: JSON.stringify({ characterId: "nope", garmentId: "nope" }) })); + assert.equal(res.status, 400); +}); + +test("generate: 404 for unknown job id", async () => { + const res = await fetch(`${BASE}/api/admin/generate/nonexistent`, authed()); + assert.equal(res.status, 404); +}); diff --git a/dressup-store.js b/dressup-store.js index 77418a0..e130b27 100644 --- a/dressup-store.js +++ b/dressup-store.js @@ -7,4 +7,10 @@ const characters = createStore(path.join(__dirname, "characters.json")); const garments = createStore(path.join(__dirname, "garments.json")); const danceRefs = createStore(path.join(__dirname, "dance-refs.json")); -module.exports = { characters, garments, danceRefs }; +// In-flight/completed generation jobs (dev-plan.md T8/T9) — tracks a +// submission from "queued" through "generating" to "done"/"failed" so a +// restart can resume polling (h3JobId) and T9's batch UI can show progress. +// The finished *video* itself lands in library.json (M.7), not here. +const generationJobs = createStore(path.join(__dirname, "generation-jobs.json")); + +module.exports = { characters, garments, danceRefs, generationJobs }; diff --git a/server.js b/server.js index b54055e..e7292ad 100644 --- a/server.js +++ b/server.js @@ -51,8 +51,10 @@ const { sniffVideoType, stripMetadataAndSave, saveVideoUpload, + forwardToH3Assets, } = require("./uploads.js"); -const { characters, garments, danceRefs } = require("./dressup-store.js"); +const { characters, garments, danceRefs, generationJobs } = require("./dressup-store.js"); +const { buildStage2Prompt } = require("./prompt-template.js"); const LIBRARY_PATH = path.join(__dirname, "library.json"); function getLibrary() { @@ -386,6 +388,11 @@ const FILLER_DURATION_MS = 30000; // keep the schedule filled at least this far const H3_GEN_PARAMS = { width: 480, height: 832, frames: 240, steps: 20, turbo: false, seed: 0 }; const FPS = 24; +// dance.html's own generation params (architecture.md 2.4: each channel +// keeps an independent H3_GEN_PARAMS even though the values happen to +// match live.html's today — don't merge the two constants). +const DANCE_GEN_PARAMS = { width: 480, height: 832, frames: 240, steps: 20, turbo: false, seed: 0 }; + const initialLiveState = loadLiveState(); let schedule = initialLiveState.schedule; // [{clip, startAt, endAt, requestedBy?}] let promptQueue = initialLiveState.promptQueue; // [{id, prompt, nickname?, status, clip?, error?, submittedAt, h3JobId?}] @@ -629,6 +636,121 @@ function resumeGenerations() { } } +// ---- Dressup dance room: one-step generation orchestration (T8) ---- +// Mirrors generateForPrompt's shape (submit -> persist h3JobId -> poll -> +// push a library.json clip on success) but for ref2va instead of plain +// text, and persisted in generationJobs (dressup-store.js) instead of +// live-state.json's promptQueue — this is an admin/batch-driven pipeline, +// not the viewer-facing live queue, so it shouldn't share that store. + +// Forwards a store record's local upload to H3 Console's /v1/assets +// (S4 spike) the first time it's needed, then caches the result on the +// record so later generations reusing the same character/garment/dance-ref +// don't re-forward the same file (MVCP M.13). +async function ensureH3AssetUrl(store, record, localUrlField) { + if (record.h3AssetUrl) return record.h3AssetUrl; + const absPath = path.join(__dirname, record[localUrlField]); + const asset = await forwardToH3Assets(absPath, H3_CONSOLE_BASE, H3_CONSOLE_TOKEN); + store.update(record.id, { h3AssetUrl: asset.url }); + return asset.url; +} + +function pickRefDanceVideo() { + const pool = danceRefs.list().filter((d) => d.isActive); + if (pool.length === 0) throw new Error("no active dance-refs available"); + return pool[Math.floor(Math.random() * pool.length)]; +} + +// 4.6 节去重缓存:相同 (characterId, garmentId) 组合直接复用已有产物。 +function findExistingGeneratedVideo(characterId, garmentId) { + return getLibrary().find((c) => c.channel === "dance" && c.characterId === characterId && c.garmentId === garmentId); +} + +// job: a generationJobs record ({id, characterId, garmentId, +// refDanceVideoId, status, h3JobId, error, fullPrompt}). Mutates it via +// generationJobs.update and keeps the local variable in sync (store.js has +// no in-memory cache, so every update is a fresh read-modify-write). +async function generateDanceVideo(job, opts = {}) { + const pollIntervalMs = opts.pollIntervalMs || Number(process.env.GENERATION_POLL_INTERVAL_MS) || 5000; + try { + const character = characters.get(job.characterId); + const garment = garments.get(job.garmentId); + const danceRef = danceRefs.get(job.refDanceVideoId); + if (!character || !garment || !danceRef) throw new Error("missing character/garment/dance-ref record"); + + if (!job.h3JobId) { + if (!job.fullPrompt) { + const built = buildStage2Prompt({ character, garment, danceRef }); + job = generationJobs.update(job.id, { fullPrompt: built.prompt }); + } + + const [characterAssetUrl, garmentAssetUrl, danceRefAssetUrl] = await Promise.all([ + ensureH3AssetUrl(characters, character, "imageUrl"), + ensureH3AssetUrl(garments, garment, "imageUrl"), + ensureH3AssetUrl(danceRefs, danceRef, "videoUrl"), + ]); + + const submitted = await h3ConsoleFetch("/v1/videos", { + method: "POST", + body: { + prompt: job.fullPrompt, + ...DANCE_GEN_PARAMS, + reference_image_urls: [characterAssetUrl, garmentAssetUrl], + reference_video_urls: [danceRefAssetUrl], + }, + }); + if (!submitted.id) throw new Error(submitted.detail || "submit failed"); + job = generationJobs.update(job.id, { h3JobId: submitted.id, status: "generating" }); + } + + for (let i = 0; i < 200; i++) { + await new Promise((r) => setTimeout(r, pollIntervalMs)); + const polled = await h3ConsoleFetch(`/v1/videos/${job.h3JobId}`); + if (polled.deleted_at) throw new Error("job was deleted on the H3 console before it finished"); + if (polled.status === "done") { + const clip = { + id: polled.id, + source: "h3console", + channel: "dance", + characterId: job.characterId, + garmentId: job.garmentId, + refDanceVideoId: job.refDanceVideoId, + backend: "h3_console", + priority: "normal", + requestedByUserId: null, + prompt: `${character.name} × ${garment.name}`, + fullPrompt: job.fullPrompt, + width: polled.req.width, + height: polled.req.height, + duration: Math.round((polled.req.frames / FPS) * 10) / 10, + partition: polled.partition, + likes: 0, + createdAt: Date.now(), + }; + const lib = getLibrary(); + lib.push(clip); + saveLibrary(lib); + generationJobs.update(job.id, { status: "done", generatedVideoId: clip.id }); + return clip; + } + if (polled.status === "error" || polled.status === "failed") throw new Error(polled.error || "generation failed"); + } + throw new Error("generation timed out"); + } catch (err) { + generationJobs.update(job.id, { status: "failed", error: err.message }); + throw err; + } +} + +// Startup recovery, same idea as resumeGenerations() but for generationJobs. +function resumeDressupGenerations() { + for (const job of generationJobs.list()) { + if (job.status !== "generating" && job.status !== "queued") continue; + if (job.h3JobId) generateDanceVideo(job).catch(() => {}); + else generationJobs.update(job.id, { status: "failed", error: "lost track of this generation across a restart" }); + } +} + http .createServer((req, res) => { if (req.url.startsWith("/fal/")) { @@ -698,6 +820,61 @@ http if (garmentsCrud(pathname, req, res)) return; if (danceRefsCrud(pathname, req, res)) return; + if (pathname === "/api/admin/generate" && req.method === "GET") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(generationJobs.list())); + return; + } + + if (pathname === "/api/admin/generate" && req.method === "POST") { + readJsonBody(req) + .then((body) => { + const { characterId, garmentId } = body; + if (!characters.get(characterId) || !garments.get(garmentId)) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "characterId/garmentId must reference existing records" })); + return; + } + const existing = findExistingGeneratedVideo(characterId, garmentId); + if (existing) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ cached: true, clip: existing })); + return; + } + let refDanceVideoId = body.refDanceVideoId; + if (!refDanceVideoId) { + try { + refDanceVideoId = pickRefDanceVideo().id; + } catch (err) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err.message })); + return; + } + } + const job = generationJobs.create({ characterId, garmentId, refDanceVideoId, status: "queued", h3JobId: null, error: null }); + generateDanceVideo(job).catch(() => {}); // fire-and-forget; poll GET /api/admin/generate/:id for status + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ cached: false, jobId: job.id })); + }) + .catch(() => { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid JSON body" })); + }); + return; + } + + if (pathname.startsWith("/api/admin/generate/") && req.method === "GET") { + const job = generationJobs.get(pathname.slice("/api/admin/generate/".length)); + if (!job) { + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "not found" })); + return; + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(job)); + return; + } + if (pathname === "/api/library") { const sort = new URL(req.url, "http://x").searchParams.get("sort") || "newest"; const lib = getLibrary().slice(); @@ -1115,6 +1292,7 @@ http .listen(PORT, () => console.log(`http://localhost:${PORT}`)); resumeGenerations(); +resumeDressupGenerations(); // Auto-sync the library from the H3 Console so content added there directly // (not through our own /api/prompt) shows up without a manual `node ingest.js`. -- libgit2 0.21.4