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 @@
+
+
+
+
+冷启动批量生成
+
+
+
+
+
+
+
+
+
1. 选一个人物
+
+
+
2. 选衣服(可多选)
+
+
+
3. 参考跳舞视频(可选,不选则每条生成随机分配一条)
+
+
+
+
+
+
+
+
+
进度
+
+
+
+
+ | 衣服 |
+ 参考视频 |
+ 状态 |
+ 说明 |
+ 操作 |
+
+
+
+
+
+
+
+
+
+
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();