From 4d437a4c88b1015aef69c1f005ae5af8e4740324 Mon Sep 17 00:00:00 2001 From: yibo Date: Wed, 2 Sep 2026 10:18:09 +0800 Subject: [PATCH] Add admin-batch-gen.html and batch generation endpoints (T9) --- admin-batch-gen.html | 267 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ admin-characters.html | 1 + admin-dance-refs.html | 1 + admin-garments.html | 1 + admin.html | 1 + dressup-generate.test.js | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server.js | 103 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------- 7 files changed, 410 insertions(+), 26 deletions(-) create mode 100644 admin-batch-gen.html diff --git a/admin-batch-gen.html b/admin-batch-gen.html new file mode 100644 index 0000000..ab7c0c1 --- /dev/null +++ b/admin-batch-gen.html @@ -0,0 +1,267 @@ + + + + +冷启动批量生成 + + + + +
+

冷启动批量生成

+
+ 视频库 + 人物库 + 衣服库 + 参考跳舞视频库 + ← 返回直播频道 +
+ +
+
+ +
+ + +
+ + +
+ +
+ + +
+ + +
+ + + + diff --git a/admin-characters.html b/admin-characters.html index 7ca5099..3489951 100644 --- a/admin-characters.html +++ b/admin-characters.html @@ -31,6 +31,7 @@ 视频库 衣服库 参考跳舞视频库 + 批量生成 ← 返回直播频道 diff --git a/admin-dance-refs.html b/admin-dance-refs.html index 3f86722..29d784e 100644 --- a/admin-dance-refs.html +++ b/admin-dance-refs.html @@ -32,6 +32,7 @@ 视频库 人物库 衣服库 + 批量生成 ← 返回直播频道 diff --git a/admin-garments.html b/admin-garments.html index d781e7a..229ab59 100644 --- a/admin-garments.html +++ b/admin-garments.html @@ -31,6 +31,7 @@ 视频库 人物库 参考跳舞视频库 + 批量生成 ← 返回直播频道 diff --git a/admin.html b/admin.html index d760b51..578f4d3 100644 --- a/admin.html +++ b/admin.html @@ -32,6 +32,7 @@ 人物库 衣服库 参考跳舞视频库 + 批量生成 ← 返回直播频道 diff --git a/dressup-generate.test.js b/dressup-generate.test.js index c39f689..bb821a3 100644 --- a/dressup-generate.test.js +++ b/dressup-generate.test.js @@ -208,3 +208,65 @@ test("generate: 404 for unknown job id", async () => { const res = await fetch(`${BASE}/api/admin/generate/nonexistent`, authed()); assert.equal(res.status, 404); }); + +async function waitForBatchDone(batchId, expectedJobCount, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const jobs = await fetch(`${BASE}/api/admin/batch-gen/status?batchId=${batchId}`, authed()).then((r) => r.json()); + if (jobs.length >= expectedJobCount && jobs.every((j) => j.status === "done" || j.status === "failed")) return jobs; + await new Promise((r) => setTimeout(r, 30)); + } + throw new Error("batch did not finish in time"); +} + +test("batch-gen: 1 character x N garments expands to N jobs, progress trackable by batchId, dedupe skips repeats", 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 garmentNames = ["红旗袍", "校服", "婚纱"]; + const garments = []; + for (const name of garmentNames) { + garments.push( + await fetch( + `${BASE}/api/admin/garments`, + authed({ method: "POST", body: JSON.stringify({ name, imageUrl: "/uploads/garment.png", appearanceDescriptor: name }) }) + ).then((r) => r.json()) + ); + } + await fetch( + `${BASE}/api/admin/dance-refs`, + authed({ method: "POST", body: JSON.stringify({ name: "批量拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) }) + ); + + const batchRes = await fetch( + `${BASE}/api/admin/batch-gen`, + authed({ method: "POST", body: JSON.stringify({ characterId: character.id, garmentIds: garments.map((g) => g.id) }) }) + ); + assert.equal(batchRes.status, 200); + const batch = await batchRes.json(); + assert.equal(batch.results.length, 3); + assert.ok(batch.results.every((r) => r.cached === false && r.jobId)); + + const jobs = await waitForBatchDone(batch.batchId, 3); + assert.equal(jobs.length, 3); + assert.ok(jobs.every((j) => j.status === "done")); + assert.deepEqual( + jobs.map((j) => j.garmentId).sort(), + garments.map((g) => g.id).sort() + ); + + // Resubmitting the same character x garments batch should skip every + // combo via the M.7 cache instead of generating again. + const repeatRes = await fetch( + `${BASE}/api/admin/batch-gen`, + authed({ method: "POST", body: JSON.stringify({ characterId: character.id, garmentIds: garments.map((g) => g.id) }) }) + ); + const repeat = await repeatRes.json(); + assert.ok(repeat.results.every((r) => r.cached === true)); +}); + +test("batch-gen: rejects missing characterId or empty garmentIds", async () => { + const res = await fetch(`${BASE}/api/admin/batch-gen`, authed({ method: "POST", body: JSON.stringify({ characterId: "nope", garmentIds: [] }) })); + assert.equal(res.status, 400); +}); diff --git a/server.js b/server.js index e7292ad..d0bc773 100644 --- a/server.js +++ b/server.js @@ -666,6 +666,31 @@ function findExistingGeneratedVideo(characterId, garmentId) { return getLibrary().find((c) => c.channel === "dance" && c.characterId === characterId && c.garmentId === garmentId); } +// Shared by the single-shot POST /api/admin/generate and the T9 batch +// endpoint: validates a (character, garment) pair, returns the cached clip +// if M.7's dedupe index already has one, otherwise queues a generationJobs +// record and fires generateDanceVideo (fire-and-forget — callers poll +// GET /api/admin/generate/:id or /api/admin/batch-gen/status for progress). +// Returns {status, body} so route handlers can just write it out. +function submitGeneration({ characterId, garmentId, refDanceVideoId, batchId }) { + if (!characters.get(characterId) || !garments.get(garmentId)) { + return { status: 400, body: { error: "characterId/garmentId must reference existing records" } }; + } + const existing = findExistingGeneratedVideo(characterId, garmentId); + if (existing) return { status: 200, body: { cached: true, clip: existing } }; + + if (!refDanceVideoId) { + try { + refDanceVideoId = pickRefDanceVideo().id; + } catch (err) { + return { status: 400, body: { error: err.message } }; + } + } + const job = generationJobs.create({ characterId, garmentId, refDanceVideoId, batchId: batchId || null, status: "queued", h3JobId: null, error: null }); + generateDanceVideo(job).catch(() => {}); + return { status: 200, body: { cached: false, jobId: job.id } }; +} + // 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 @@ -829,32 +854,9 @@ http 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 })); + const { status, body: respBody } = submitGeneration(body); + res.writeHead(status, { "Content-Type": "application/json" }); + res.end(JSON.stringify(respBody)); }) .catch(() => { res.writeHead(400, { "Content-Type": "application/json" }); @@ -875,6 +877,55 @@ http return; } + // T9 冷启动批量生成:{characterId, garmentIds[], refDanceVideoIds?[]}. + // No refDanceVideoIds given -> one job per garment, each with its own + // random active dance-ref (same as the single-generate default). + // refDanceVideoIds given -> full garmentIds x refDanceVideoIds cross + // product, so an operator can pin specific reference dances. + // Every combo goes through submitGeneration, so the M.7 dedupe cache + // (same characterId+garmentId already generated) is honored per combo, + // not just per batch. + if (pathname === "/api/admin/batch-gen" && req.method === "POST") { + readJsonBody(req) + .then((body) => { + const { characterId, garmentIds, refDanceVideoIds } = body; + if (!characters.get(characterId) || !Array.isArray(garmentIds) || garmentIds.length === 0) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "characterId must exist and garmentIds must be a non-empty array" })); + return; + } + const batchId = crypto.randomUUID(); + const refCombos = Array.isArray(refDanceVideoIds) && refDanceVideoIds.length > 0 ? refDanceVideoIds : [undefined]; + const results = []; + for (const garmentId of garmentIds) { + for (const refDanceVideoId of refCombos) { + const { status, body: respBody } = submitGeneration({ characterId, garmentId, refDanceVideoId, batchId }); + results.push({ garmentId, refDanceVideoId: refDanceVideoId || null, status, ...respBody }); + } + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ batchId, results })); + }) + .catch(() => { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "invalid JSON body" })); + }); + return; + } + + if (pathname === "/api/admin/batch-gen/status" && req.method === "GET") { + const batchId = new URL(req.url, "http://x").searchParams.get("batchId"); + if (!batchId) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "batchId query param required" })); + return; + } + const jobs = generationJobs.list().filter((j) => j.batchId === batchId); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(jobs)); + return; + } + if (pathname === "/api/library") { const sort = new URL(req.url, "http://x").searchParams.get("sort") || "newest"; const lib = getLibrary().slice(); -- libgit2 0.21.4