Commit 4d437a4c88b1015aef69c1f005ae5af8e4740324

Authored by yibo
1 parent 59c73988

Add admin-batch-gen.html and batch generation endpoints (T9)

POST /api/admin/batch-gen takes {characterId, garmentIds[],
refDanceVideoIds?[]}, expands to one job per garment (cross-producted
with refDanceVideoIds when given, else one random active dance-ref per
garment like the single-generate default), and submits each through
the same submitGeneration() helper the single POST /api/admin/generate
route now uses — so the M.7 dedupe cache (existing characterId+
garmentId combo) is honored per combo, not just per batch.
GET /api/admin/batch-gen/status?batchId= returns that batch's
generationJobs records for progress polling.

admin-batch-gen.html: pick one character, multiple garments, and
optionally pin specific reference dances; submits the batch, polls
status every 3s into a progress table, and lets failed rows be
retried individually via POST /api/admin/generate. Linked from the
other admin pages' nav.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
admin-batch-gen.html 0 → 100644
  1 +<!DOCTYPE html>
  2 +<html lang="zh-CN">
  3 +<head>
  4 +<meta charset="UTF-8" />
  5 +<title>冷启动批量生成</title>
  6 +<link rel="stylesheet" href="style.css" />
  7 +<style>
  8 + .admin-wrap { padding: 18px 24px; max-width: 1400px; }
  9 + .pick-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 10px; margin-top: 8px; }
  10 + .pick-card { border: 1px solid var(--border); border-radius: 6px; padding: 6px; text-align: center; cursor: pointer; font-size: 12px; }
  11 + .pick-card.selected { border-color: #2e7d32; background: #eef7ee; }
  12 + .pick-card img { width: 100%; height: 90px; object-fit: cover; border-radius: 4px; margin-bottom: 4px; }
  13 + .section-label { font-weight: 600; margin-top: 16px; }
  14 + .hint { color: var(--muted); font-size: 12px; }
  15 + table.admin-table { width: 100%; border-collapse: collapse; font-size: 13px; background: #fff; margin-top: 12px; }
  16 + table.admin-table th, table.admin-table td { border-bottom: 1px solid var(--border); padding: 8px 10px; text-align: left; }
  17 + .status-badge { padding: 2px 8px; border-radius: 4px; font-size: 12px; }
  18 + .status-queued, .status-generating { background: #fff8e1; color: #b26a00; }
  19 + .status-done { background: #eef7ee; color: #2e7d32; }
  20 + .status-failed { background: #fdecea; color: #c62828; }
  21 + .status-cached { background: #eceff1; color: #455a64; }
  22 +</style>
  23 +</head>
  24 +<body>
  25 +<header class="topbar">
  26 + <h1>冷启动批量生成</h1>
  27 + <div class="spacer"></div>
  28 + <a href="admin.html">视频库</a>
  29 + <a href="admin-characters.html">人物库</a>
  30 + <a href="admin-garments.html">衣服库</a>
  31 + <a href="admin-dance-refs.html">参考跳舞视频库</a>
  32 + <a href="live.html">← 返回直播频道</a>
  33 +</header>
  34 +
  35 +<main class="admin-wrap">
  36 + <div class="card">
  37 + <div class="section-label">1. 选一个人物</div>
  38 + <div class="pick-grid" id="characterGrid"></div>
  39 +
  40 + <div class="section-label">2. 选衣服(可多选)</div>
  41 + <div class="pick-grid" id="garmentGrid"></div>
  42 +
  43 + <div class="section-label">3. 参考跳舞视频(可选,不选则每条生成随机分配一条)</div>
  44 + <div class="pick-grid" id="danceRefGrid"></div>
  45 +
  46 + <div class="hint" id="comboHint" style="margin-top:12px;"></div>
  47 + <button id="submitBtn" style="margin-top:8px;">批量生成</button>
  48 + <div id="submitError" class="error-box" hidden></div>
  49 + </div>
  50 +
  51 + <div class="card" id="progressCard" hidden>
  52 + <div class="section-label" style="margin-top:0;">进度</div>
  53 + <div class="hint" id="progressHint"></div>
  54 + <table class="admin-table">
  55 + <thead>
  56 + <tr>
  57 + <th>衣服</th>
  58 + <th>参考视频</th>
  59 + <th>状态</th>
  60 + <th>说明</th>
  61 + <th>操作</th>
  62 + </tr>
  63 + </thead>
  64 + <tbody id="progressBody"></tbody>
  65 + </table>
  66 + </div>
  67 +</main>
  68 +
  69 +<script>
  70 +const _fetch = window.fetch;
  71 +window.fetch = async (...args) => {
  72 + const res = await _fetch(...args);
  73 + if (res.status === 401 && String(args[0]).startsWith("/api/admin/") && !String(args[0]).includes("/login")) {
  74 + location.href = "/admin-login.html?next=" + encodeURIComponent(location.pathname);
  75 + }
  76 + return res;
  77 +};
  78 +
  79 +function escapeHtml(s) {
  80 + const div = document.createElement("div");
  81 + div.textContent = s || "";
  82 + return div.innerHTML;
  83 +}
  84 +
  85 +let characters = [];
  86 +let garments = [];
  87 +let danceRefs = [];
  88 +let selectedCharacterId = null;
  89 +const selectedGarmentIds = new Set();
  90 +const selectedDanceRefIds = new Set();
  91 +
  92 +// batchId -> { garmentId, refDanceVideoId, jobId?, cached?, clip?, name } rows
  93 +// pollTimer keeps refreshing job status until every row is done/failed.
  94 +let currentBatchId = null;
  95 +let progressRows = [];
  96 +let pollTimer = null;
  97 +
  98 +async function loadAll() {
  99 + [characters, garments, danceRefs] = await Promise.all([
  100 + fetch("/api/admin/characters").then((r) => r.json()),
  101 + fetch("/api/admin/garments").then((r) => r.json()),
  102 + fetch("/api/admin/dance-refs").then((r) => r.json()),
  103 + ]);
  104 + garments = garments.filter((g) => g.isActive);
  105 + danceRefs = danceRefs.filter((d) => d.isActive);
  106 + renderPickers();
  107 +}
  108 +
  109 +function renderPickers() {
  110 + document.getElementById("characterGrid").innerHTML = characters
  111 + .map(
  112 + (c) => `
  113 + <div class="pick-card ${c.id === selectedCharacterId ? "selected" : ""}" data-character="${c.id}">
  114 + <img src="${escapeHtml(c.imageUrl)}" />
  115 + ${escapeHtml(c.name)}
  116 + </div>`
  117 + )
  118 + .join("");
  119 + document.getElementById("garmentGrid").innerHTML = garments
  120 + .map(
  121 + (g) => `
  122 + <div class="pick-card ${selectedGarmentIds.has(g.id) ? "selected" : ""}" data-garment="${g.id}">
  123 + <img src="${escapeHtml(g.thumbnailUrl || g.imageUrl)}" />
  124 + ${escapeHtml(g.name)}
  125 + </div>`
  126 + )
  127 + .join("");
  128 + document.getElementById("danceRefGrid").innerHTML = danceRefs
  129 + .map(
  130 + (d) => `
  131 + <div class="pick-card ${selectedDanceRefIds.has(d.id) ? "selected" : ""}" data-danceref="${d.id}">
  132 + ${escapeHtml(d.name)}
  133 + </div>`
  134 + )
  135 + .join("");
  136 +
  137 + document.querySelectorAll("[data-character]").forEach((el) =>
  138 + el.addEventListener("click", () => {
  139 + selectedCharacterId = el.dataset.character;
  140 + renderPickers();
  141 + updateComboHint();
  142 + })
  143 + );
  144 + document.querySelectorAll("[data-garment]").forEach((el) =>
  145 + el.addEventListener("click", () => {
  146 + const id = el.dataset.garment;
  147 + selectedGarmentIds.has(id) ? selectedGarmentIds.delete(id) : selectedGarmentIds.add(id);
  148 + renderPickers();
  149 + updateComboHint();
  150 + })
  151 + );
  152 + document.querySelectorAll("[data-danceref]").forEach((el) =>
  153 + el.addEventListener("click", () => {
  154 + const id = el.dataset.danceref;
  155 + selectedDanceRefIds.has(id) ? selectedDanceRefIds.delete(id) : selectedDanceRefIds.add(id);
  156 + renderPickers();
  157 + updateComboHint();
  158 + })
  159 + );
  160 + updateComboHint();
  161 +}
  162 +
  163 +function updateComboHint() {
  164 + const garmentCount = selectedGarmentIds.size;
  165 + const refCount = selectedDanceRefIds.size || 1; // "不选" 展开成每条一次
  166 + document.getElementById("comboHint").textContent = selectedCharacterId
  167 + ? `将提交 ${garmentCount * refCount} 条生成任务(已有产物的组合会自动跳过)`
  168 + : "先选一个人物";
  169 +}
  170 +
  171 +async function submitBatch() {
  172 + const errorBox = document.getElementById("submitError");
  173 + errorBox.hidden = true;
  174 + if (!selectedCharacterId || selectedGarmentIds.size === 0) {
  175 + errorBox.hidden = false;
  176 + errorBox.textContent = "至少选一个人物和一件衣服";
  177 + return;
  178 + }
  179 + const body = {
  180 + characterId: selectedCharacterId,
  181 + garmentIds: [...selectedGarmentIds],
  182 + refDanceVideoIds: [...selectedDanceRefIds],
  183 + };
  184 + const res = await fetch("/api/admin/batch-gen", {
  185 + method: "POST",
  186 + headers: { "Content-Type": "application/json" },
  187 + body: JSON.stringify(body),
  188 + });
  189 + if (!res.ok) {
  190 + errorBox.hidden = false;
  191 + errorBox.textContent = (await res.json()).error || "提交失败";
  192 + return;
  193 + }
  194 + const data = await res.json();
  195 + currentBatchId = data.batchId;
  196 + progressRows = data.results.map((r) => ({ ...r }));
  197 + document.getElementById("progressCard").hidden = false;
  198 + renderProgress();
  199 + startPolling();
  200 +}
  201 +
  202 +function nameFor(list, id) {
  203 + const item = list.find((x) => x.id === id);
  204 + return item ? item.name : id || "(随机分配)";
  205 +}
  206 +
  207 +function renderProgress() {
  208 + const done = progressRows.filter((r) => r.cached || r.status === "done" || r.status === "failed").length;
  209 + document.getElementById("progressHint").textContent = `共 ${progressRows.length} 条,已完成 ${done} 条`;
  210 + document.getElementById("progressBody").innerHTML = progressRows
  211 + .map((r) => {
  212 + const status = r.cached ? "cached" : r.status || "queued";
  213 + const label = { cached: "已有产物(跳过)", queued: "排队中", generating: "生成中", done: "完成", failed: "失败" }[status] || status;
  214 + const note = r.cached ? "" : r.error || "";
  215 + const retryBtn = status === "failed" ? `<button class="secondary" data-retry='${JSON.stringify({ garmentId: r.garmentId, refDanceVideoId: r.refDanceVideoId })}'>重试</button>` : "";
  216 + return `
  217 + <tr>
  218 + <td>${escapeHtml(nameFor(garments, r.garmentId))}</td>
  219 + <td>${escapeHtml(nameFor(danceRefs, r.refDanceVideoId))}</td>
  220 + <td><span class="status-badge status-${status}">${label}</span></td>
  221 + <td>${escapeHtml(note)}</td>
  222 + <td>${retryBtn}</td>
  223 + </tr>`;
  224 + })
  225 + .join("");
  226 +
  227 + document.querySelectorAll("[data-retry]").forEach((b) =>
  228 + b.addEventListener("click", async () => {
  229 + const { garmentId, refDanceVideoId } = JSON.parse(b.dataset.retry);
  230 + const res = await fetch("/api/admin/generate", {
  231 + method: "POST",
  232 + headers: { "Content-Type": "application/json" },
  233 + body: JSON.stringify({ characterId: selectedCharacterId, garmentId, refDanceVideoId: refDanceVideoId || undefined }),
  234 + });
  235 + const data = await res.json();
  236 + const row = progressRows.find((r) => r.garmentId === garmentId && (r.refDanceVideoId || null) === (refDanceVideoId || null));
  237 + if (row) Object.assign(row, data, { status: data.cached ? "done" : "queued", error: null });
  238 + renderProgress();
  239 + startPolling();
  240 + })
  241 + );
  242 +}
  243 +
  244 +function startPolling() {
  245 + if (pollTimer) return;
  246 + pollTimer = setInterval(async () => {
  247 + if (!currentBatchId) return;
  248 + const jobs = await fetch(`/api/admin/batch-gen/status?batchId=${currentBatchId}`).then((r) => r.json());
  249 + for (const job of jobs) {
  250 + const row = progressRows.find((r) => r.jobId === job.id);
  251 + if (row) Object.assign(row, { status: job.status, error: job.error });
  252 + }
  253 + renderProgress();
  254 + const allSettled = progressRows.every((r) => r.cached || r.status === "done" || r.status === "failed");
  255 + if (allSettled) {
  256 + clearInterval(pollTimer);
  257 + pollTimer = null;
  258 + }
  259 + }, 3000);
  260 +}
  261 +
  262 +document.getElementById("submitBtn").addEventListener("click", submitBatch);
  263 +
  264 +loadAll();
  265 +</script>
  266 +</body>
  267 +</html>
admin-characters.html
@@ -31,6 +31,7 @@ @@ -31,6 +31,7 @@
31 <a href="admin.html">视频库</a> 31 <a href="admin.html">视频库</a>
32 <a href="admin-garments.html">衣服库</a> 32 <a href="admin-garments.html">衣服库</a>
33 <a href="admin-dance-refs.html">参考跳舞视频库</a> 33 <a href="admin-dance-refs.html">参考跳舞视频库</a>
  34 + <a href="admin-batch-gen.html">批量生成</a>
34 <a href="live.html">← 返回直播频道</a> 35 <a href="live.html">← 返回直播频道</a>
35 </header> 36 </header>
36 37
admin-dance-refs.html
@@ -32,6 +32,7 @@ @@ -32,6 +32,7 @@
32 <a href="admin.html">视频库</a> 32 <a href="admin.html">视频库</a>
33 <a href="admin-characters.html">人物库</a> 33 <a href="admin-characters.html">人物库</a>
34 <a href="admin-garments.html">衣服库</a> 34 <a href="admin-garments.html">衣服库</a>
  35 + <a href="admin-batch-gen.html">批量生成</a>
35 <a href="live.html">← 返回直播频道</a> 36 <a href="live.html">← 返回直播频道</a>
36 </header> 37 </header>
37 38
admin-garments.html
@@ -31,6 +31,7 @@ @@ -31,6 +31,7 @@
31 <a href="admin.html">视频库</a> 31 <a href="admin.html">视频库</a>
32 <a href="admin-characters.html">人物库</a> 32 <a href="admin-characters.html">人物库</a>
33 <a href="admin-dance-refs.html">参考跳舞视频库</a> 33 <a href="admin-dance-refs.html">参考跳舞视频库</a>
  34 + <a href="admin-batch-gen.html">批量生成</a>
34 <a href="live.html">← 返回直播频道</a> 35 <a href="live.html">← 返回直播频道</a>
35 </header> 36 </header>
36 37
admin.html
@@ -32,6 +32,7 @@ @@ -32,6 +32,7 @@
32 <a href="admin-characters.html">人物库</a> 32 <a href="admin-characters.html">人物库</a>
33 <a href="admin-garments.html">衣服库</a> 33 <a href="admin-garments.html">衣服库</a>
34 <a href="admin-dance-refs.html">参考跳舞视频库</a> 34 <a href="admin-dance-refs.html">参考跳舞视频库</a>
  35 + <a href="admin-batch-gen.html">批量生成</a>
35 <a href="live.html">← 返回直播频道</a> 36 <a href="live.html">← 返回直播频道</a>
36 </header> 37 </header>
37 38
dressup-generate.test.js
@@ -208,3 +208,65 @@ test(&quot;generate: 404 for unknown job id&quot;, async () =&gt; { @@ -208,3 +208,65 @@ test(&quot;generate: 404 for unknown job id&quot;, async () =&gt; {
208 const res = await fetch(`${BASE}/api/admin/generate/nonexistent`, authed()); 208 const res = await fetch(`${BASE}/api/admin/generate/nonexistent`, authed());
209 assert.equal(res.status, 404); 209 assert.equal(res.status, 404);
210 }); 210 });
  211 +
  212 +async function waitForBatchDone(batchId, expectedJobCount, timeoutMs = 5000) {
  213 + const deadline = Date.now() + timeoutMs;
  214 + while (Date.now() < deadline) {
  215 + const jobs = await fetch(`${BASE}/api/admin/batch-gen/status?batchId=${batchId}`, authed()).then((r) => r.json());
  216 + if (jobs.length >= expectedJobCount && jobs.every((j) => j.status === "done" || j.status === "failed")) return jobs;
  217 + await new Promise((r) => setTimeout(r, 30));
  218 + }
  219 + throw new Error("batch did not finish in time");
  220 +}
  221 +
  222 +test("batch-gen: 1 character x N garments expands to N jobs, progress trackable by batchId, dedupe skips repeats", async () => {
  223 + const character = await fetch(
  224 + `${BASE}/api/admin/characters`,
  225 + authed({ method: "POST", body: JSON.stringify({ name: "批量小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair" }) })
  226 + ).then((r) => r.json());
  227 + const garmentNames = ["红旗袍", "校服", "婚纱"];
  228 + const garments = [];
  229 + for (const name of garmentNames) {
  230 + garments.push(
  231 + await fetch(
  232 + `${BASE}/api/admin/garments`,
  233 + authed({ method: "POST", body: JSON.stringify({ name, imageUrl: "/uploads/garment.png", appearanceDescriptor: name }) })
  234 + ).then((r) => r.json())
  235 + );
  236 + }
  237 + await fetch(
  238 + `${BASE}/api/admin/dance-refs`,
  239 + authed({ method: "POST", body: JSON.stringify({ name: "批量拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) })
  240 + );
  241 +
  242 + const batchRes = await fetch(
  243 + `${BASE}/api/admin/batch-gen`,
  244 + authed({ method: "POST", body: JSON.stringify({ characterId: character.id, garmentIds: garments.map((g) => g.id) }) })
  245 + );
  246 + assert.equal(batchRes.status, 200);
  247 + const batch = await batchRes.json();
  248 + assert.equal(batch.results.length, 3);
  249 + assert.ok(batch.results.every((r) => r.cached === false && r.jobId));
  250 +
  251 + const jobs = await waitForBatchDone(batch.batchId, 3);
  252 + assert.equal(jobs.length, 3);
  253 + assert.ok(jobs.every((j) => j.status === "done"));
  254 + assert.deepEqual(
  255 + jobs.map((j) => j.garmentId).sort(),
  256 + garments.map((g) => g.id).sort()
  257 + );
  258 +
  259 + // Resubmitting the same character x garments batch should skip every
  260 + // combo via the M.7 cache instead of generating again.
  261 + const repeatRes = await fetch(
  262 + `${BASE}/api/admin/batch-gen`,
  263 + authed({ method: "POST", body: JSON.stringify({ characterId: character.id, garmentIds: garments.map((g) => g.id) }) })
  264 + );
  265 + const repeat = await repeatRes.json();
  266 + assert.ok(repeat.results.every((r) => r.cached === true));
  267 +});
  268 +
  269 +test("batch-gen: rejects missing characterId or empty garmentIds", async () => {
  270 + const res = await fetch(`${BASE}/api/admin/batch-gen`, authed({ method: "POST", body: JSON.stringify({ characterId: "nope", garmentIds: [] }) }));
  271 + assert.equal(res.status, 400);
  272 +});
server.js
@@ -666,6 +666,31 @@ function findExistingGeneratedVideo(characterId, garmentId) { @@ -666,6 +666,31 @@ function findExistingGeneratedVideo(characterId, garmentId) {
666 return getLibrary().find((c) => c.channel === "dance" && c.characterId === characterId && c.garmentId === garmentId); 666 return getLibrary().find((c) => c.channel === "dance" && c.characterId === characterId && c.garmentId === garmentId);
667 } 667 }
668 668
  669 +// Shared by the single-shot POST /api/admin/generate and the T9 batch
  670 +// endpoint: validates a (character, garment) pair, returns the cached clip
  671 +// if M.7's dedupe index already has one, otherwise queues a generationJobs
  672 +// record and fires generateDanceVideo (fire-and-forget — callers poll
  673 +// GET /api/admin/generate/:id or /api/admin/batch-gen/status for progress).
  674 +// Returns {status, body} so route handlers can just write it out.
  675 +function submitGeneration({ characterId, garmentId, refDanceVideoId, batchId }) {
  676 + if (!characters.get(characterId) || !garments.get(garmentId)) {
  677 + return { status: 400, body: { error: "characterId/garmentId must reference existing records" } };
  678 + }
  679 + const existing = findExistingGeneratedVideo(characterId, garmentId);
  680 + if (existing) return { status: 200, body: { cached: true, clip: existing } };
  681 +
  682 + if (!refDanceVideoId) {
  683 + try {
  684 + refDanceVideoId = pickRefDanceVideo().id;
  685 + } catch (err) {
  686 + return { status: 400, body: { error: err.message } };
  687 + }
  688 + }
  689 + const job = generationJobs.create({ characterId, garmentId, refDanceVideoId, batchId: batchId || null, status: "queued", h3JobId: null, error: null });
  690 + generateDanceVideo(job).catch(() => {});
  691 + return { status: 200, body: { cached: false, jobId: job.id } };
  692 +}
  693 +
669 // job: a generationJobs record ({id, characterId, garmentId, 694 // job: a generationJobs record ({id, characterId, garmentId,
670 // refDanceVideoId, status, h3JobId, error, fullPrompt}). Mutates it via 695 // refDanceVideoId, status, h3JobId, error, fullPrompt}). Mutates it via
671 // generationJobs.update and keeps the local variable in sync (store.js has 696 // generationJobs.update and keeps the local variable in sync (store.js has
@@ -829,32 +854,9 @@ http @@ -829,32 +854,9 @@ http
829 if (pathname === "/api/admin/generate" && req.method === "POST") { 854 if (pathname === "/api/admin/generate" && req.method === "POST") {
830 readJsonBody(req) 855 readJsonBody(req)
831 .then((body) => { 856 .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 })); 857 + const { status, body: respBody } = submitGeneration(body);
  858 + res.writeHead(status, { "Content-Type": "application/json" });
  859 + res.end(JSON.stringify(respBody));
858 }) 860 })
859 .catch(() => { 861 .catch(() => {
860 res.writeHead(400, { "Content-Type": "application/json" }); 862 res.writeHead(400, { "Content-Type": "application/json" });
@@ -875,6 +877,55 @@ http @@ -875,6 +877,55 @@ http
875 return; 877 return;
876 } 878 }
877 879
  880 + // T9 冷启动批量生成:{characterId, garmentIds[], refDanceVideoIds?[]}.
  881 + // No refDanceVideoIds given -> one job per garment, each with its own
  882 + // random active dance-ref (same as the single-generate default).
  883 + // refDanceVideoIds given -> full garmentIds x refDanceVideoIds cross
  884 + // product, so an operator can pin specific reference dances.
  885 + // Every combo goes through submitGeneration, so the M.7 dedupe cache
  886 + // (same characterId+garmentId already generated) is honored per combo,
  887 + // not just per batch.
  888 + if (pathname === "/api/admin/batch-gen" && req.method === "POST") {
  889 + readJsonBody(req)
  890 + .then((body) => {
  891 + const { characterId, garmentIds, refDanceVideoIds } = body;
  892 + if (!characters.get(characterId) || !Array.isArray(garmentIds) || garmentIds.length === 0) {
  893 + res.writeHead(400, { "Content-Type": "application/json" });
  894 + res.end(JSON.stringify({ error: "characterId must exist and garmentIds must be a non-empty array" }));
  895 + return;
  896 + }
  897 + const batchId = crypto.randomUUID();
  898 + const refCombos = Array.isArray(refDanceVideoIds) && refDanceVideoIds.length > 0 ? refDanceVideoIds : [undefined];
  899 + const results = [];
  900 + for (const garmentId of garmentIds) {
  901 + for (const refDanceVideoId of refCombos) {
  902 + const { status, body: respBody } = submitGeneration({ characterId, garmentId, refDanceVideoId, batchId });
  903 + results.push({ garmentId, refDanceVideoId: refDanceVideoId || null, status, ...respBody });
  904 + }
  905 + }
  906 + res.writeHead(200, { "Content-Type": "application/json" });
  907 + res.end(JSON.stringify({ batchId, results }));
  908 + })
  909 + .catch(() => {
  910 + res.writeHead(400, { "Content-Type": "application/json" });
  911 + res.end(JSON.stringify({ error: "invalid JSON body" }));
  912 + });
  913 + return;
  914 + }
  915 +
  916 + if (pathname === "/api/admin/batch-gen/status" && req.method === "GET") {
  917 + const batchId = new URL(req.url, "http://x").searchParams.get("batchId");
  918 + if (!batchId) {
  919 + res.writeHead(400, { "Content-Type": "application/json" });
  920 + res.end(JSON.stringify({ error: "batchId query param required" }));
  921 + return;
  922 + }
  923 + const jobs = generationJobs.list().filter((j) => j.batchId === batchId);
  924 + res.writeHead(200, { "Content-Type": "application/json" });
  925 + res.end(JSON.stringify(jobs));
  926 + return;
  927 + }
  928 +
878 if (pathname === "/api/library") { 929 if (pathname === "/api/library") {
879 const sort = new URL(req.url, "http://x").searchParams.get("sort") || "newest"; 930 const sort = new URL(req.url, "http://x").searchParams.get("sort") || "newest";
880 const lib = getLibrary().slice(); 931 const lib = getLibrary().slice();