Commit 4d437a4c88b1015aef69c1f005ae5af8e4740324
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>
Showing
7 changed files
with
410 additions
and
26 deletions
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("generate: 404 for unknown job id", async () => { | @@ -208,3 +208,65 @@ test("generate: 404 for unknown job id", async () => { | ||
| 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(); |