diff --git a/H3-CONSOLE.md b/H3-CONSOLE.md
index 8e13227..a9aeba9 100644
--- a/H3-CONSOLE.md
+++ b/H3-CONSOLE.md
@@ -136,6 +136,17 @@ Calling it on several jobs in a row puts the most-recently-prioritized one at
position 1 and pushes the others back slightly — still clusters them all near
the front, just not in a guaranteed relative order between themselves.
+### `POST {base}/v1/videos/{id}/cancel`
+Genuinely cancels a `queued`/`running` job — unlike `DELETE` (below), which
+only hides it from listings but lets it keep consuming compute. Confirmed
+live 2026-09-09: cancelling a queued job flips its `status` to `cancelled`,
+sets `error` to `"用户手动取消"` and `finished_at`, and the cluster's
+`queued` count in `GET /v1/backends` actually decrements. Use this (not
+DELETE) to actually stop a duplicate/unwanted queued job.
+```json
+{ "id": "6361be157e48", "status": "cancelled", "error": "用户手动取消", ... }
+```
+
### `GET {base}/v1/videos/{id}/file`
Downloads the mp4. `Content-Type: video/mp4`, supports range requests
(`Accept-Ranges: bytes`), `Content-Disposition: attachment`. Auth via
diff --git a/admin-dance-library.html b/admin-dance-library.html
index d5580d7..ad61afa 100644
--- a/admin-dance-library.html
+++ b/admin-dance-library.html
@@ -39,6 +39,12 @@
.pub-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; white-space: nowrap; }
.pub-on { background: #eef7ee; color: #2e7d32; }
.pub-off { background: #f0f0f0; color: var(--muted); }
+ /* 高清重绘: one cell per resolution tier — either a small preview (done),
+ a disabled "生成中…" state, or a "生成高清版" button (not generated yet). */
+ td.hd-cell { min-width: 110px; }
+ td.hd-cell video { display: block; width: 100px; height: auto; max-height: 160px; object-fit: contain; background: #000; border-radius: 4px; margin-bottom: 4px; }
+ td.hd-cell .hd-status { font-size: 12px; color: var(--muted); }
+ td.hd-cell .hd-status.hd-failed { color: #c0392b; }
@@ -87,6 +93,13 @@
已上架
未上架
+
+ 全部高清状态
+ 已有 576×1024
+ 已有 768×1344
+ 无 576×1024
+ 无 768×1344
+
全部来源
h3console
@@ -104,6 +117,8 @@
Prompt
视频地址
预览
+ 576×1024
+ 768×1344
人物
衣服
舞曲
@@ -139,15 +154,27 @@ let library = [];
let characters = [];
let garments = [];
let danceRefs = [];
+let generationJobs = [];
let editingId = null;
let sortKey = "createdAt";
let sortDir = -1;
+let hdAutoRefreshTimer = null;
+
+// 高清重绘: must match server.js's HD_RENDER_TIERS exactly (width/height are
+// sent verbatim in the POST body and validated against that list server-side).
+const HD_RENDER_TIERS = [
+ { width: 576, height: 1024, label: "576×1024" },
+ { width: 768, height: 1344, label: "768×1344" },
+];
// This page only ever shows/creates channel:"dance" clips — 跳舞直播间
// (dance.html) has its own排播池, independent from 视频直播间(live.html,
// admin.html), same split architecture.md 2.1 describes.
+// 高清重绘: an HD variant (c.sourceClipId set) isn't a row of its own here —
+// it's shown inside its root clip's row, in the tier column for its
+// resolution (see hdCell) — so the table stays "one row per dance content".
function inChannel(c) {
- return c.channel === "dance";
+ return c.channel === "dance" && !c.sourceClipId;
}
function escapeHtml(s) {
@@ -157,18 +184,32 @@ function escapeHtml(s) {
}
async function loadLibrary() {
- const [libRes, charRes, garmentRes, danceRefRes] = await Promise.all([
+ const [libRes, charRes, garmentRes, danceRefRes, jobsRes] = await Promise.all([
fetch("/api/library?sort=newest"),
fetch("/api/admin/characters"),
fetch("/api/admin/garments"),
fetch("/api/admin/dance-refs"),
+ fetch("/api/admin/generate"),
]);
library = await libRes.json();
characters = await charRes.json();
garments = await garmentRes.json();
danceRefs = await danceRefRes.json();
+ generationJobs = await jobsRes.json();
refreshFilterOptions();
render();
+ scheduleHdAutoRefresh();
+}
+
+// 高清重绘: an admin clicking "生成高清版" and then tabbing away shouldn't
+// have to remember to come back and manually refresh — while any hd_render
+// job is still queued/generating, keep polling the whole list every 20s
+// (cheap: this page is a low-traffic admin tool, not the live player) until
+// none are left in flight.
+function scheduleHdAutoRefresh() {
+ clearTimeout(hdAutoRefreshTimer);
+ const inFlight = generationJobs.some((j) => j.kind === "hd_render" && (j.status === "queued" || j.status === "generating"));
+ if (inFlight) hdAutoRefreshTimer = setTimeout(loadLibrary, 20000);
}
function nameOf(list, id) {
@@ -237,6 +278,7 @@ function filteredSorted() {
const refDanceVideoId = document.getElementById("danceRefFilter").value;
const qcStatus = document.getElementById("qcFilter").value;
const publishStatus = document.getElementById("publishFilter").value;
+ const hdStatus = document.getElementById("hdFilter").value;
let rows = library.filter(inChannel);
if (q) rows = rows.filter((c) => c.prompt.toLowerCase().includes(q) || (c.promptEn || "").toLowerCase().includes(q) || (c.fullPrompt || "").toLowerCase().includes(q) || c.id.toLowerCase().includes(q));
if (source) rows = rows.filter((c) => c.source === source);
@@ -245,6 +287,15 @@ function filteredSorted() {
if (refDanceVideoId) rows = rows.filter((c) => c.refDanceVideoId === refDanceVideoId);
if (qcStatus) rows = rows.filter((c) => (c.qcStatus || "pending") === qcStatus);
if (publishStatus) rows = rows.filter((c) => !!c.published === (publishStatus === "published"));
+ if (hdStatus) {
+ rows = rows.filter((c) => {
+ if (hdStatus === "has-576x1024") return !!hdVariantOf(c, HD_RENDER_TIERS[0]);
+ if (hdStatus === "has-768x1344") return !!hdVariantOf(c, HD_RENDER_TIERS[1]);
+ if (hdStatus === "missing-576x1024") return !hdVariantOf(c, HD_RENDER_TIERS[0]);
+ if (hdStatus === "missing-768x1344") return !hdVariantOf(c, HD_RENDER_TIERS[1]);
+ return true;
+ });
+ }
rows.sort((a, b) => {
const av = a[sortKey] ?? 0;
const bv = b[sortKey] ?? 0;
@@ -268,6 +319,35 @@ function pubBadge(c) {
return c.published ? `已上架 ` : `未上架 `;
}
+// 高清重绘: the finished HD variant of rootClip at `tier`, if any — shared
+// by hdCell (render) and the 高清状态 filter (below) so they can't disagree
+// about what "has a variant" means.
+function hdVariantOf(rootClip, tier) {
+ const groupId = rootClip.groupId || rootClip.id;
+ return library.find((c) => (c.groupId || c.id) === groupId && c.width === tier.width && c.height === tier.height) || null;
+}
+
+// 高清重绘: cell for one (rootClip, resolution tier) pair — a finished
+// variant's small preview, an in-flight job's "生成中…", or a "生成高清版"
+// button (showing the last failure, if any, so a retry isn't blind).
+function hdCell(rootClip, tier) {
+ const variant = hdVariantOf(rootClip, tier);
+ if (variant) {
+ return `${variant.duration ?? "-"}s
`;
+ }
+ const matchesJob = (j) => j.kind === "hd_render" && j.sourceClipId === rootClip.id && j.targetWidth === tier.width && j.targetHeight === tier.height;
+ const inFlightJob = generationJobs.find((j) => matchesJob(j) && (j.status === "queued" || j.status === "generating"));
+ if (inFlightJob) {
+ return `生成中… `;
+ }
+ const lastFailed = generationJobs.filter((j) => matchesJob(j) && j.status === "failed").sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))[0];
+ return `
+
+ ${lastFailed ? `上次失败
` : ""}
+ 生成高清版
+ `;
+}
+
const previewObserver = new IntersectionObserver(
(entries) => {
for (const e of entries) {
@@ -298,6 +378,8 @@ function render() {
+ ${hdCell(c, HD_RENDER_TIERS[0])}
+ ${hdCell(c, HD_RENDER_TIERS[1])}
${characterCell(c)}
${garmentCell(c)}
${danceRefCell(c)}
@@ -333,6 +415,8 @@ function render() {
${escapeHtml((c.fullPrompt || "").slice(0, 80))}${(c.fullPrompt || "").length > 80 ? "…" : ""}
${videoHref(c)}
+ ${hdCell(c, HD_RENDER_TIERS[0])}
+ ${hdCell(c, HD_RENDER_TIERS[1])}
${characterCell(c)}
${garmentCell(c)}
${danceRefCell(c)}
@@ -357,7 +441,7 @@ function render() {
// Loading preload="metadata" for every row at once would hammer the
// /lib/video proxy (each clip is a fresh H3 Console fetch); only pull a
// row's first frame once it's actually scrolled into view.
- tbody.querySelectorAll("td.preview video[data-src], td.ref-thumb video[data-src]").forEach((v) => previewObserver.observe(v));
+ tbody.querySelectorAll("td.preview video[data-src], td.ref-thumb video[data-src], td.hd-cell video[data-src]").forEach((v) => previewObserver.observe(v));
tbody.querySelectorAll("[data-edit]").forEach((b) => b.addEventListener("click", () => { editingId = b.dataset.edit; render(); }));
tbody.querySelectorAll("[data-cancel]").forEach((b) => b.addEventListener("click", () => { editingId = null; render(); }));
@@ -396,6 +480,33 @@ function render() {
else b.disabled = false;
})
);
+ tbody.querySelectorAll("[data-hd-gen]").forEach((b) =>
+ b.addEventListener("click", async () => {
+ const id = b.dataset.hdGen;
+ const width = Number(b.dataset.width);
+ const height = Number(b.dataset.height);
+ b.disabled = true;
+ b.textContent = "提交中…";
+ try {
+ const res = await fetch(`/api/admin/library/${id}/hd-render`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ width, height }),
+ });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) {
+ alert(data.error || "提交失败");
+ b.disabled = false;
+ b.textContent = "生成高清版";
+ return;
+ }
+ await loadLibrary(); // re-render shows "生成中…" (or the finished preview, if H3 happened to return cached:true)
+ } catch {
+ b.disabled = false;
+ b.textContent = "生成高清版";
+ }
+ })
+ );
tbody.querySelectorAll("[data-qc-check]").forEach((b) =>
b.addEventListener("click", async () => {
const id = b.dataset.qcCheck;
@@ -435,6 +546,7 @@ document.getElementById("garmentFilter").addEventListener("change", render);
document.getElementById("danceRefFilter").addEventListener("change", render);
document.getElementById("qcFilter").addEventListener("change", render);
document.getElementById("publishFilter").addEventListener("change", render);
+document.getElementById("hdFilter").addEventListener("change", render);
document.getElementById("addBtn").addEventListener("click", async () => {
const errorBox = document.getElementById("addError");
diff --git a/blacklist.json b/blacklist.json
new file mode 100644
index 0000000..a3eca8c
--- /dev/null
+++ b/blacklist.json
@@ -0,0 +1,10 @@
+[
+ {
+ "id": "69a398b4-b36d-4756-85d5-6ba76f9efb9c",
+ "createdAt": 1789886140463,
+ "targetType": "character",
+ "targetId": "8b992ec5-c320-499d-b380-a5907c897474",
+ "reason": "跟 Moera 长得太像,用户反馈需要下架",
+ "createdBy": null
+ }
+]
\ No newline at end of file
diff --git a/dance-refs.json b/dance-refs.json
index 69b18cf..d5a9c91 100644
--- a/dance-refs.json
+++ b/dance-refs.json
@@ -104,7 +104,8 @@
"durationSec": null,
"motionDescriptor": "双手举至脸颊两侧比划框脸手势,俏皮可爱,上身动作为主",
"copyrightSource": "test_placeholder",
- "isActive": true
+ "isActive": true,
+ "h3AssetUrl": "http://192.168.110.67:8080/assets/fba12b3d62e548ba__0638d68c-0aac-442e-b9ee-5a7f60f6cc65.mp4"
},
{
"id": "dee22838-4453-4386-9bed-4d69c78cd85f",
@@ -547,7 +548,8 @@
"durationSec": null,
"motionDescriptor": "单臂弯举靠近脸侧,头部微仰侧倾,慵懒俏皮的展示姿态",
"copyrightSource": "test_placeholder",
- "isActive": true
+ "isActive": true,
+ "h3AssetUrl": "http://192.168.110.67:8080/assets/3ec07a69bf86413f__96577e07-f4f9-49d8-9870-2591e561cf51.mp4"
},
{
"id": "40c054ee-3a52-4865-b965-8f695821be03",
diff --git a/dance-rooms.html b/dance-rooms.html
index 4aa311a..547e57a 100644
--- a/dance-rooms.html
+++ b/dance-rooms.html
@@ -9,11 +9,15 @@
\n\n\nThe page you requested was not found. \nSorry, the page you are looking for is currently unavailable. \nPlease try again later.
\nThe server is powered by frp .
\nFaithfully yours, frp.
\n\n