Commit 59c73988acefbadb8fb1e81b76c207cad3e4e6ab
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>
Showing
5 changed files
with
398 additions
and
4 deletions
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 () => { |
| 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, "characters.json")); |
| 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`. | ... | ... |