diff --git a/admin-batch-gen.html b/admin-batch-gen.html
index ab7c0c1..de513ae 100644
--- a/admin-batch-gen.html
+++ b/admin-batch-gen.html
@@ -29,6 +29,7 @@
人物库
衣服库
参考跳舞视频库
+ 人衣图生成
← 返回直播频道
diff --git a/admin-characters.html b/admin-characters.html
index 95df689..8c4b11c 100644
--- a/admin-characters.html
+++ b/admin-characters.html
@@ -32,6 +32,7 @@
衣服库
参考跳舞视频库
批量生成
+ 人衣图生成
← 返回直播频道
diff --git a/admin-dance-refs.html b/admin-dance-refs.html
index 29d784e..92bb248 100644
--- a/admin-dance-refs.html
+++ b/admin-dance-refs.html
@@ -33,6 +33,7 @@
人物库
衣服库
批量生成
+ 人衣图生成
← 返回直播频道
diff --git a/admin-dressgen.html b/admin-dressgen.html
new file mode 100644
index 0000000..c1c7957
--- /dev/null
+++ b/admin-dressgen.html
@@ -0,0 +1,173 @@
+
+
+
+
+新建人衣图任务
+
+
+
+
+
+
+
+
+
+
+
人物 · 全身(可选)
+
+
+
人物 · 脸部(可选)
+
+
全身/脸部 6 张不用都传,传几张算几张,至少上传 1 张人物参考图。
+
+
新衣服(必传)
+
+
+
生成 prompt(每个视角各一条,已填默认值,改不改都能直接提交)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin-dressgendetail.html b/admin-dressgendetail.html
new file mode 100644
index 0000000..b022382
--- /dev/null
+++ b/admin-dressgendetail.html
@@ -0,0 +1,201 @@
+
+
+
+
+人衣图任务详情
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin-dressgenlist.html b/admin-dressgenlist.html
new file mode 100644
index 0000000..808e85f
--- /dev/null
+++ b/admin-dressgenlist.html
@@ -0,0 +1,219 @@
+
+
+
+
+人衣图任务列表
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 任务号 |
+ 供应商 |
+ 人物参考图 |
+ 衣服图 |
+ 生成 · 正面 |
+ 生成 · 侧面 |
+ 生成 · 背面 |
+ 状态 |
+ 时间 |
+ 操作 |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/admin-garments.html b/admin-garments.html
index 229ab59..6300bc3 100644
--- a/admin-garments.html
+++ b/admin-garments.html
@@ -32,6 +32,7 @@
人物库
参考跳舞视频库
批量生成
+ 人衣图生成
← 返回直播频道
diff --git a/admin.html b/admin.html
index 7eb3e7d..488b706 100644
--- a/admin.html
+++ b/admin.html
@@ -36,6 +36,7 @@
衣服库
参考跳舞视频库
批量生成
+ 人衣图生成
视频直播间 ↗
跳舞直播间 ↗
diff --git a/docs/todo/dressgen-admin-tool.md b/docs/todo/dressgen-admin-tool.md
new file mode 100644
index 0000000..9686329
--- /dev/null
+++ b/docs/todo/dressgen-admin-tool.md
@@ -0,0 +1,103 @@
+# 人衣图生成 admin 工具 — 现状与待办
+
+独立于主线换装跳舞直播间任务序列(T1-T39,见 `dev-plan*.md`)之外的一个
+standalone 工具:`admin-dressgen.html`(新建任务)+
+`admin-dressgenlist.html`(任务列表)+ `admin-dressgendetail.html`(任务
+详情)。输入人物定妆照(全身/脸部 × 正/侧/背,传几张算几张,至少 1
+张)+ 1 张新衣服图,依次生成这位人物穿新衣服的全身正/侧/背 3 张图。
+2026-09-03 当天从头做到这里,记录一下现状,避免下次接着做时重新踩一遍
+已经踩过的坑。
+
+---
+
+## 1. 已完成
+
+- **异步任务队列**(不是同步阻塞接口——最早的同步版本真的把前端代理
+ 拖到 502 超时过一次):`POST /api/admin/dressgen` 立即返回
+ `status:"queued"` 的任务行,`runDressGenViews()`(server.js)在响应
+ 发出后 fire-and-forget 依次(不并发)跑 3 个视角,每一步都写回
+ `dressGenRecords`(`dress-gen.json`),前端轮询 `GET
+ /api/admin/dressgen/:id` 看进度。
+- **重试**:`POST /api/admin/dressgen/:id/retry`(整体重试,只重跑没成
+ 功的视角)、`POST /api/admin/dressgen/:id/retry/:view`(单张重试,
+ 可选带 `{prompt}` 覆盖那一张的 prompt)——都复用任务自己存的图片/
+ prompt,不用重新上传、不用重新填 prompt。两个重试接口都额外接受可选
+ `{provider}` 覆盖,手动把某个任务/某一张换供应商重跑。
+- **两个供应商都已接入并验证成功**:
+ - GPT Image 2(万界方舟):`https://maas-openapi.wanjiedata.com
+ /api/v1/images/edits`,`model:"GPT Image 2"`(带空格,写错会被 WAF
+ 拦 410 或报 unauthorized),`rejectUnauthorized:false`(万界这个
+ host 的证书链在这台机器上验证不过,`-k`),300s 超时,60-180秒/张。
+ - Seedream v4(wavespeed.ai 的 `bytedance/seedream-v4/edit`):上传
+ `POST /api/v3/media/upload/binary` → 提交 `POST /api/v3/{model}` →
+ 轮询 `GET /api/v3/predictions/{id}/result`,约 30 秒/张,$0.027/次,
+ 无 TLS 证书问题、无内容审核。`.env` 已配置 `WAVESPEED_API_KEY`。
+ - **默认行为(2026-09-03 当天二次确认的产品决策,覆盖了更早的"不做
+ 自动切换"那版)**:新任务默认用 image2;某一视角 image2 失败(不
+ 区分技术故障还是审核拒绝)→ 自动改用 wavespeed 重试一次,成功的话
+ 该视角 `viewProvider[view]` 记为 `"wavespeed"`,前端详情页会显示
+ "实际使用: Seedream v4"。手动重试接口仍可传 `{provider}` 强制指定。
+ **这条决策的取舍写在 `server.js` 里 `WAVESPEED_API_KEY` 声明上方的
+ 注释里,包含为什么会改主意——下次改这块之前先看那段注释。**
+- **17 个自动化测试全过**(`dressgen-generate.test.js`:任务创建即时
+ 返回、轮询到终态、per-view 自定义 prompt、单视角失败不拖累其它两个、
+ 整体/单视角重试(含 provider 覆盖)、非法路径防护、wavespeed 供应商
+ 路径、image2→wavespeed 自动降级等)。
+- **前端拆成三个页面**(原来是"新建+列表"揉在一张页面里,"详情"按钮在
+ 宽表格右侧、窄窗口下需要横向滚动才能看到,容易被当成"消失了"):
+ - `admin-dressgen.html`:新建任务表单,提交后跳到该任务的详情页。
+ - `admin-dressgenlist.html`:任务列表,操作列(详情/重试/删除)用
+ `position: sticky; right: 0` 钉住,横向滚动时始终可见;所有缩略图
+ (人物参考图/衣服图/生成结果)点击可放大预览(一个简单的
+ lightbox,点哪张放大哪张,点遮罩关闭)。
+ - `admin-dressgendetail.html`:单任务详情(原 `admin-dressgen-task.
+ html` 改名),生成结果图也支持点击放大,每张视角显示"实际使用"的
+ 供应商。
+ - 其它 admin 页面导航栏的"人衣图生成"链接都改指向
+ `admin-dressgenlist.html`(列表作为默认落地页,列表页右上角
+ "+新建任务" 去创建页)。
+
+## 2. 待办
+
+- [ ] 生成结果目前是完全独立的列表(`dress-gen.json`),不会进
+ `characters.json`/`garments.json`(人物库/衣服库)。**现状确认
+ (2026-09-03):人物库现在每个人物只有单张 `imageUrl`,不是"一个
+ 人物对应多张可复用资产图"的模型**——`characters.json` 唯一一条
+ 种子记录也只有一个 `imageUrl` 字段,没有相册/资产列表这回事。
+ "存为新人物"要做成什么形态(人物库本身要不要先改成多资产模型、
+ 用 dressgen 的哪张视角图当定妆照等)还没定,做之前需要先对齐。
+
+## 3. 已完成(第二批,2026-09-03 当天晚些时候)
+
+- **任务僵死检测**:`server.js` 的 `resumeDressgenTasks()`,进程启动时
+ (`resumeGenerations()`/`resumeDressupGenerations()` 旁边)扫一遍
+ `dressGenRecords`,把仍停在 `queued`/`generating` 的视角标记为
+ `failed`(错误信息"进程重启,任务丢失,请点重试",前端详情页会照常
+ 显示),并按 T8 `generationJobs` 同款逻辑重算整条任务的 `status`。
+ 没有可恢复的 in-flight 请求 id(image2/wavespeed 调用是
+ `runDressGenViews` 里直接 await 的 HTTP 往返,进程一退什么都不剩),
+ 所以是标失败而不是真的续跑——用户点"重试"即可,跟真实生成失败的
+ 体验一致。测试:`dressgen-generate.test.js` 里专门起了一个独立
+ cwd/mock(mock 故意不响应,制造一个真实卡在 generating 的视角),
+ `SIGKILL` 模拟崩溃,重新起一个指向同一份 `dress-gen.json` 的进程,
+ 断言恢复后的状态。
+- **列表分页**:`GET /api/admin/dressgen` 现在接受 `?page=&pageSize=`
+ (默认 `pageSize=20`,上限 100),按 `createdAt` 倒序(最新的在前)
+ 切片,返回 `{items, total, page, pageSize}`(之前是裸数组,前端自己
+ `.reverse()`——这是个破坏性的响应格式变更,前端和测试已同步改掉)。
+ `admin-dressgenlist.html` 加了"上一页/下一页",操作列还是 sticky,
+ 互不冲突。
+
+## 3. 相关文件
+
+- `server.js`:`IMAGE2_*`/`WAVESPEED_*` 常量、`generateDressImage`/
+ `generateDressImageViaImage2`/`generateDressImageViaWavespeed`/
+ `runDressGenViews`(含自动降级分支)/`dressGenImageAbsPaths`、
+ `/api/admin/dressgen*` 路由
+- `dressup-store.js`:`dressGenRecords`
+- `dress-gen.json`:数据文件
+- `admin-dressgen.html`(新建)/ `admin-dressgenlist.html`(列表)/
+ `admin-dressgendetail.html`(详情):前端
+- `dressgen-generate.test.js`:测试
+- `.env`:`IMAGE2_API_KEY` / `IMAGE2_API_BASE` / `WAVESPEED_API_KEY` /
+ `WAVESPEED_API_BASE`
diff --git a/dress-gen.json b/dress-gen.json
new file mode 100644
index 0000000..0e3be74
--- /dev/null
+++ b/dress-gen.json
@@ -0,0 +1,100 @@
+[
+ {
+ "id": "725d721b-32ca-4eeb-b262-3c74d1315d44",
+ "createdAt": 1788430838504,
+ "garmentImage": "/uploads/202609/31946cdf-87b9-4ae7-b2bc-739872d383cc.png",
+ "prompts": {
+ "front": "前面的图是同一位人物的参考图(可能包含全身正/侧/背、脸正/侧/背等不同角度和部位,具体张数以实际上传为准),最后一张图是新服装参考图。保持人物的脸部五官、发型、体型完全不变,仅将服装替换为新服装图所示服装,生成人物穿着新服装的正面全身人物形象图。鞋子沿用人物原有款式,不要更换;背景干净,光线均匀,全身入镜。Size 1024x1536.",
+ "side": "前面的图是同一位人物的参考图(可能包含全身正/侧/背、脸正/侧/背等不同角度和部位,具体张数以实际上传为准),最后一张图是新服装参考图。保持人物的脸部五官、发型、体型完全不变,仅将服装替换为新服装图所示服装,生成人物穿着新服装的侧面全身人物形象图。鞋子沿用人物原有款式,不要更换;背景干净,光线均匀,全身入镜。Size 1024x1536.",
+ "back": "前面的图是同一位人物的参考图(可能包含全身正/侧/背、脸正/侧/背等不同角度和部位,具体张数以实际上传为准),最后一张图是新服装参考图。保持人物的脸部五官、发型、体型完全不变,仅将服装替换为新服装图所示服装,生成人物穿着新服装的背面全身人物形象图。鞋子沿用人物原有款式,不要更换;背景干净,光线均匀,全身入镜。Size 1024x1536."
+ },
+ "bodyFront": "/uploads/202609/7d190e15-6306-4245-8db2-275e14db33b6.png",
+ "bodySide": "/uploads/202609/ed7747a4-09be-4b22-8f2d-c2a80d4e2d51.png",
+ "bodyBack": null,
+ "faceFront": "/uploads/202609/32d90d15-9c76-483b-8a71-1fc82476bcce.png",
+ "faceSide": "/uploads/202609/a5cb5ada-435d-484c-9daf-a25f89b8bf03.png",
+ "faceBack": null,
+ "outputImages": {
+ "front": "/uploads/202609/bddb0291-a063-430c-93c2-063dde3e3194.png",
+ "side": "/uploads/202609/195494fd-015e-43c4-9e6e-5ca4e5a2da0c.png",
+ "back": "/uploads/202609/026e2bd4-d8d1-483c-8536-0ba7cf003e23.png"
+ },
+ "errors": {
+ "front": null,
+ "side": null,
+ "back": null
+ },
+ "status": "done",
+ "provider": "wavespeed",
+ "viewStatus": {
+ "front": "done",
+ "side": "done",
+ "back": "done"
+ }
+ },
+ {
+ "id": "3a765740-27b4-4c5c-861b-4c439d4d2739",
+ "createdAt": 1788433218590,
+ "garmentImage": "/uploads/202609/66564e87-5de9-400e-b676-667fc1503e09.png",
+ "prompts": {
+ "front": "前面的图是同一位人物的参考图(可能包含全身正/侧/背、脸正/侧/背等不同角度和部位,具体张数以实际上传为准),最后一张图是新服装参考图。保持人物的脸部五官、发型、体型完全不变,仅将服装替换为新服装图所示服装,生成人物穿着新服装的正面全身人物形象图。鞋子沿用人物原有款式,不要更换;背景干净,光线均匀,全身入镜。Size 1024x1536.",
+ "side": "前面的图是同一位人物的参考图(可能包含全身正/侧/背、脸正/侧/背等不同角度和部位,具体张数以实际上传为准),最后一张图是新服装参考图。保持人物的脸部五官、发型、体型完全不变,仅将服装替换为新服装图所示服装,生成人物穿着新服装的侧面全身人物形象图。鞋子沿用人物原有款式,不要更换;背景干净,光线均匀,全身入镜。Size 1024x1536.",
+ "back": "前面的图是同一位人物的参考图(可能包含全身正/侧/背、脸正/侧/背等不同角度和部位,具体张数以实际上传为准),最后一张图是新服装参考图。保持人物的脸部五官、发型、体型完全不变,仅将服装替换为新服装图所示服装,生成人物穿着新服装的背面全身人物形象图。鞋子沿用人物原有款式,不要更换;背景干净,光线均匀,全身入镜。Size 1024x1536."
+ },
+ "bodyFront": "/uploads/202609/930c143c-0c0f-4a8a-86eb-c9140c88bda2.png",
+ "bodySide": "/uploads/202609/fc21ef8f-6cb6-4716-bd67-898b12729492.png",
+ "bodyBack": null,
+ "faceFront": "/uploads/202609/a1709278-b6b6-4a50-8da7-87e148d0cc80.png",
+ "faceSide": "/uploads/202609/102131f9-8b96-4665-abdf-d1991eb6dd10.png",
+ "faceBack": null,
+ "status": "done",
+ "viewStatus": {
+ "front": "done",
+ "side": "done",
+ "back": "done"
+ },
+ "outputImages": {
+ "front": "/uploads/202609/192a2405-80a8-454a-bd11-0c69e7104192.png",
+ "side": "/uploads/202609/bb470070-ddf5-4c6f-9f35-29e6a3dc6bb7.png",
+ "back": "/uploads/202609/1422156f-7e06-43f6-b70c-1736316a1b42.png"
+ },
+ "errors": {
+ "front": null,
+ "side": null,
+ "back": null
+ },
+ "provider": "wavespeed"
+ },
+ {
+ "id": "7aedd360-2d18-49f0-819c-daf217c3e5c1",
+ "createdAt": 1788435468569,
+ "garmentImage": "/uploads/202609/24b0d70c-3b4d-4b1b-8dd5-69dda68368e2.png",
+ "prompts": {
+ "front": "前面的图是同一位人物的参考图(可能包含全身正/侧/背、脸正/侧/背等不同角度和部位,具体张数以实际上传为准),最后一张图是新服装参考图。保持人物的脸部五官、发型、体型完全不变,仅将服装替换为新服装图所示服装,生成人物穿着新服装的正面全身人物形象图。鞋子沿用人物原有款式,不要更换;背景干净,光线均匀,全身入镜。Size 1024x1536.",
+ "side": "前面的图是同一位人物的参考图(可能包含全身正/侧/背、脸正/侧/背等不同角度和部位,具体张数以实际上传为准),最后一张图是新服装参考图。保持人物的脸部五官、发型、体型完全不变,仅将服装替换为新服装图所示服装,生成人物穿着新服装的侧面全身人物形象图。鞋子沿用人物原有款式,不要更换;背景干净,光线均匀,全身入镜。Size 1024x1536.",
+ "back": "前面的图是同一位人物的参考图(可能包含全身正/侧/背、脸正/侧/背等不同角度和部位,具体张数以实际上传为准),最后一张图是新服装参考图。保持人物的脸部五官、发型、体型完全不变,仅将服装替换为新服装图所示服装,生成人物穿着新服装的背面全身人物形象图。鞋子沿用人物原有款式,不要更换;背景干净,光线均匀,全身入镜。Size 1024x1536."
+ },
+ "bodyFront": "/uploads/202609/f0a8a9ba-d13f-4249-b124-4a14f323b7d0.png",
+ "bodySide": null,
+ "bodyBack": null,
+ "faceFront": null,
+ "faceSide": null,
+ "faceBack": null,
+ "status": "done",
+ "viewStatus": {
+ "front": "done",
+ "side": "done",
+ "back": "done"
+ },
+ "outputImages": {
+ "front": "/uploads/202609/9ad0387f-757c-47e1-aca4-f8af16c297c5.png",
+ "side": "/uploads/202609/34ffe8b7-9e0f-446c-bf51-b0666144d786.png",
+ "back": "/uploads/202609/300a073e-a7bb-46c4-a969-7ec038b7e4ad.png"
+ },
+ "errors": {
+ "front": null,
+ "side": null,
+ "back": null
+ }
+ }
+]
\ No newline at end of file
diff --git a/dressgen-generate.test.js b/dressgen-generate.test.js
new file mode 100644
index 0000000..ebe0b1d
--- /dev/null
+++ b/dressgen-generate.test.js
@@ -0,0 +1,609 @@
+// Integration test for the standalone "人衣图生成" admin tool
+// (admin-dressgen.html + admin-dressgen-task.html): however many person
+// reference photos the admin actually uploads (any subset of full-body/face
+// × front/side/back, at least 1) + 1 garment image -> 3 new full-body images
+// (front/side/back) of that person wearing the garment. This is a real
+// background task: POST /api/admin/dressgen returns the row (status
+// "queued") immediately and the 3 sequential gpt-image-2 calls run
+// fire-and-forget after the response — a blocking synchronous version of
+// this endpoint hit a real 502 from a front-end proxy timing out on the
+// ~3.5 minutes 3 sequential ~70s calls can take (CODEX-IMAGEGEN.md), which
+// is why this is async now. Tests poll GET /api/admin/dressgen/:id until
+// the task reaches a terminal status, the same way a real client would.
+//
+// Runs server.js against a mock gpt-image-2 relay (IMAGE2_API_BASE points
+// at it, over plain http — insecureHttpsRequest dispatches on the URL's own
+// protocol for exactly this reason) so the whole upload -> task -> 3x
+// edit-call -> save-outputs path runs offline. This does NOT prove the real
+// 万界方舟 relay actually speaks this contract at /api/v1/images/edits — see
+// generateDressImage()'s doc comment in server.js: that path's *existence*
+// and the model-id/host/TLS quirks are confirmed (an OPTIONS/GET probe plus
+// a working curl for the sibling /images/generations endpoint), but nobody
+// has yet made a real authenticated call to /images/edits itself. Its
+// request shape here mirrors OpenAI's own public /v1/images/edits contract,
+// which the vendor is expected (per the user, not yet verified end-to-end)
+// to mirror too.
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const path = require("node:path");
+const os = require("node:os");
+const http = require("node:http");
+const { spawn } = require("node:child_process");
+
+const PORT = 25934 + Math.floor(Math.random() * 1000);
+const BASE = `http://localhost:${PORT}`;
+const ADMIN_PASSWORD = "test-password-only-for-this-run";
+const IMAGE2_API_KEY = "sk-mock-image2-key";
+let cookie, child, cwd, mockImage2, mockImage2Port;
+let editRequests;
+
+const TINY_VALID_PNG = Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEklEQVR4nGPkEpFjYGBgYgADAALmAEAUQs4PAAAAAElFTkSuQmCC",
+ "base64"
+);
+// A second, distinguishable 1x1 PNG, so the "generated" output isn't
+// byte-identical to an input (would mask a wiring bug that echoes an input
+// back instead of the mock's own response).
+const OUTPUT_PNG = Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAAg+lmCAAAAAXNSR0IArs4c6QAAAAtJREFUCNdj+M8AAAICAQD8p9y9AAAAAElFTkSuQmCC",
+ "base64"
+);
+
+function waitForServer(url, timeoutMs = 10000) {
+ const deadline = Date.now() + timeoutMs;
+ return (async function poll() {
+ while (Date.now() < deadline) {
+ try {
+ await fetch(url);
+ return;
+ } catch {
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ }
+ throw new Error("server did not come up in time");
+ })();
+}
+
+async function waitUntilTerminal(id, timeoutMs = 8000) {
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ const record = await fetch(`${BASE}/api/admin/dressgen/${id}`, { headers: { Cookie: cookie } }).then((r) => r.json());
+ if (["done", "partial", "failed"].includes(record.status)) return record;
+ await new Promise((r) => setTimeout(r, 50));
+ }
+ throw new Error("task did not reach a terminal status in time");
+}
+
+// "success": every call succeeds. "fail-all": every call fails. "fail-2nd":
+// only the 2nd call (by arrival order — calls are sequential, so this is
+// deterministic) fails.
+let editMode = "success";
+let concurrentCallsSeen = false;
+let inFlight = 0;
+
+// wavespeed mock state — a separate provider path exercised by its own
+// tests (provider:"wavespeed"), sharing the same mock http server as
+// IMAGE2 (routing is by path, same trick as the rest of this file).
+let wavespeedUploadCount = 0;
+let wavespeedSubmitBody = null;
+let wavespeedShouldFail = false;
+function BASE_MOCK_URL() {
+ return `http://localhost:${mockImage2Port}`;
+}
+
+function startMockImage2() {
+ return new Promise((resolve) => {
+ mockImage2 = http.createServer((req, res) => {
+ const chunks = [];
+ req.on("data", (c) => chunks.push(c));
+ req.on("end", () => {
+ if (req.method === "POST" && req.url === "/api/v3/media/upload/binary") {
+ wavespeedUploadCount++;
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ code: 200, message: "success", data: { type: "image", download_url: `${BASE_MOCK_URL()}/uploaded-${wavespeedUploadCount}.png`, filename: `uploaded-${wavespeedUploadCount}.png`, size: 123 } }));
+ return;
+ }
+ if (req.method === "POST" && req.url === "/api/v3/bytedance/seedream-v4/edit") {
+ wavespeedSubmitBody = JSON.parse(Buffer.concat(chunks).toString("utf8"));
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ code: 200, message: "success", data: { id: "ws-task-1", status: "created", urls: { get: `${BASE_MOCK_URL()}/api/v3/predictions/ws-task-1/result` } } }));
+ return;
+ }
+ if (req.method === "GET" && req.url === "/api/v3/predictions/ws-task-1/result") {
+ if (wavespeedShouldFail) {
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ code: 200, message: "success", data: { id: "ws-task-1", status: "failed", error: "mock: wavespeed content rejected" } }));
+ return;
+ }
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ code: 200, message: "success", data: { id: "ws-task-1", status: "completed", outputs: [`${BASE_MOCK_URL()}/wavespeed-output.png`] } }));
+ return;
+ }
+ if (req.method === "GET" && req.url === "/wavespeed-output.png") {
+ res.writeHead(200, { "Content-Type": "image/png" });
+ res.end(OUTPUT_PNG);
+ return;
+ }
+ if (req.method === "POST" && req.url === "/api/v1/images/edits") {
+ inFlight++;
+ if (inFlight > 1) concurrentCallsSeen = true;
+ const bodyText = Buffer.concat(chunks).toString("latin1");
+ editRequests.push({
+ headers: req.headers,
+ imageCount: (bodyText.match(/name="image\[\]"/g) || []).length,
+ promptMatch: bodyText.match(/name="prompt"\r\n\r\n([^\r]*)/),
+ modelMatch: bodyText.match(/name="model"\r\n\r\n([^\r]*)/),
+ sizeMatch: bodyText.match(/name="size"\r\n\r\n([^\r]*)/),
+ });
+ const callNumber = editRequests.length;
+ const shouldFail = editMode === "fail-all" || (editMode === "fail-2nd" && callNumber === 2);
+ setTimeout(() => {
+ inFlight--;
+ if (shouldFail) {
+ res.writeHead(400, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "mock: content rejected" }));
+ return;
+ }
+ res.writeHead(200, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ data: [{ b64_json: OUTPUT_PNG.toString("base64") }] }));
+ }, 20); // small delay so a concurrent implementation would overlap calls
+ return;
+ }
+ res.writeHead(404, { "Content-Type": "application/json" });
+ res.end(JSON.stringify({ error: "not found in mock" }));
+ });
+ });
+ mockImage2.listen(0, () => {
+ mockImage2Port = mockImage2.address().port;
+ resolve();
+ });
+ });
+}
+
+test.before(async () => {
+ await startMockImage2();
+
+ cwd = fs.mkdtempSync(path.join(os.tmpdir(), "dressgen-generate-"));
+ for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) {
+ fs.copyFileSync(path.join(__dirname, f), path.join(cwd, f));
+ }
+ fs.writeFileSync(path.join(cwd, "dress-gen.json"), "[]");
+
+ child = spawn(process.execPath, ["server.js"], {
+ cwd,
+ env: {
+ ...process.env,
+ PORT: String(PORT),
+ H3_CONSOLE_BASE: "",
+ H3_CONSOLE_TOKEN: "",
+ ADMIN_PASSWORD,
+ IMAGE2_API_KEY,
+ IMAGE2_API_BASE: `http://localhost:${mockImage2Port}`,
+ WAVESPEED_API_KEY: "ws-mock-key",
+ WAVESPEED_API_BASE: `http://localhost:${mockImage2Port}`,
+ WAVESPEED_POLL_INTERVAL_MS: "50",
+ },
+ stdio: "pipe",
+ });
+ await waitForServer(BASE);
+
+ const loginRes = await fetch(`${BASE}/api/admin/login`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ password: ADMIN_PASSWORD }),
+ });
+ cookie = (loginRes.headers.get("set-cookie") || "").split(";")[0];
+});
+
+test.after(() => {
+ child.kill();
+ mockImage2.close();
+ fs.rmSync(cwd, { recursive: true, force: true });
+});
+
+async function uploadTinyPng(name) {
+ const fd = new FormData();
+ fd.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), name);
+ const res = await fetch(`${BASE}/api/admin/upload`, { method: "POST", headers: { Cookie: cookie }, body: fd });
+ assert.equal(res.status, 200);
+ return (await res.json()).url;
+}
+
+async function createTask(body) {
+ const res = await fetch(`${BASE}/api/admin/dressgen`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Cookie: cookie },
+ body: JSON.stringify(body),
+ });
+ return { status: res.status, record: await res.json() };
+}
+
+test("GET /api/admin/dressgen/prompts returns one default prompt per view, front/side/back all distinct", async () => {
+ const res = await fetch(`${BASE}/api/admin/dressgen/prompts`, { headers: { Cookie: cookie } });
+ assert.equal(res.status, 200);
+ const prompts = await res.json();
+ assert.ok(prompts.front && prompts.side && prompts.back);
+ assert.notEqual(prompts.front, prompts.side);
+ assert.notEqual(prompts.side, prompts.back);
+});
+
+test("POST /api/admin/dressgen returns immediately with a queued task id (doesn't block on the 3 upstream calls)", async () => {
+ editMode = "success";
+ editRequests = [];
+ const bodyFront = await uploadTinyPng("body-front.png");
+ const faceFront = await uploadTinyPng("face-front.png");
+ const garmentImage = await uploadTinyPng("garment.png");
+
+ const { status, record } = await createTask({ bodyFront, faceFront, garmentImage });
+ assert.equal(status, 200);
+ assert.ok(record.id);
+ assert.equal(record.status, "queued");
+ assert.equal(record.outputImages.front, null, "must not have waited for generation to finish");
+ assert.equal(editRequests.length, 0, "the edit calls happen after the response, not before it");
+
+ const final = await waitUntilTerminal(record.id);
+ assert.equal(final.status, "done");
+ assert.equal(editRequests.length, 3, "one call per view even with fewer person refs");
+ for (const r of editRequests) {
+ assert.equal(r.imageCount, 3, "2 uploaded person refs + garment = 3 images per call");
+ assert.equal(r.modelMatch && r.modelMatch[1], "GPT Image 2", "model id must be the literal vendor string, with spaces");
+ assert.equal(r.sizeMatch && r.sizeMatch[1], "1024x1536");
+ }
+ assert.equal(concurrentCallsSeen, false, "calls must be sequential, not concurrent");
+ for (const view of ["front", "side", "back"]) {
+ assert.equal(final.viewStatus[view], "done");
+ const served = await fetch(`${BASE}${final.outputImages[view]}`);
+ assert.ok(Buffer.from(await served.arrayBuffer()).equals(OUTPUT_PNG));
+ }
+
+ const list = await fetch(`${BASE}/api/admin/dressgen?pageSize=100`, { headers: { Cookie: cookie } }).then((r) => r.json());
+ assert.ok(list.items.some((r) => r.id === record.id));
+ assert.ok(list.total >= 1);
+
+ const fetched = await fetch(`${BASE}/api/admin/dressgen/${record.id}`, { headers: { Cookie: cookie } });
+ assert.equal(fetched.status, 200);
+});
+
+test("GET /api/admin/dressgen/:id 404s for an unknown id", async () => {
+ const res = await fetch(`${BASE}/api/admin/dressgen/does-not-exist`, { headers: { Cookie: cookie } });
+ assert.equal(res.status, 404);
+});
+
+test("POST /api/admin/dressgen: a missing garmentImage is rejected synchronously, no task created", async () => {
+ const bodyFront = await uploadTinyPng("body-front2.png");
+ const { status } = await createTask({ bodyFront });
+ assert.equal(status, 400);
+});
+
+test("POST /api/admin/dressgen: zero person refs is rejected synchronously (need at least one)", async () => {
+ const garmentImage = await uploadTinyPng("garment2.png");
+ const { status } = await createTask({ garmentImage });
+ assert.equal(status, 400);
+});
+
+test("POST /api/admin/dressgen: a per-view custom prompt is sent verbatim to that view's edit call, others keep their own default", async () => {
+ editMode = "success";
+ editRequests = [];
+ const bodyFront = await uploadTinyPng("body-front3.png");
+ const garmentImage = await uploadTinyPng("garment3.png");
+ // ASCII-only on purpose: the mock decodes the raw multipart body as
+ // latin1 (binary-safe for the image bytes elsewhere in the same body),
+ // which would mangle a UTF-8/Chinese string — not a real server
+ // limitation, just this test's own byte-extraction trick.
+ const customSidePrompt = "CUSTOM_SIDE_PROMPT_ONLY_FOR_THIS_TEST";
+
+ const { status, record } = await createTask({ bodyFront, garmentImage, prompts: { side: customSidePrompt } });
+ assert.equal(status, 200);
+ assert.equal(record.prompts.side, customSidePrompt);
+ assert.notEqual(record.prompts.front, record.prompts.side);
+
+ await waitUntilTerminal(record.id);
+ const sidePromptSent = editRequests.map((r) => r.promptMatch && r.promptMatch[1]).find((p) => p === customSidePrompt);
+ assert.ok(sidePromptSent, "the custom side prompt must actually reach the edit call, not just the stored record");
+});
+
+test("one view failing (2nd, sequential) doesn't lose the other two — task settles as 'partial'", async () => {
+ // Both providers must fail for this view to genuinely fail, now that
+ // image2 auto-falls-back to wavespeed (see the fallback test above).
+ editMode = "fail-2nd";
+ wavespeedShouldFail = true;
+ editRequests = [];
+ const bodyFront = await uploadTinyPng("body-front4.png");
+ const garmentImage = await uploadTinyPng("garment4.png");
+
+ const { record } = await createTask({ bodyFront, garmentImage });
+ const final = await waitUntilTerminal(record.id);
+ assert.equal(final.status, "partial");
+ assert.ok(final.outputImages.front);
+ assert.equal(final.outputImages.side, null);
+ assert.equal(final.viewStatus.side, "failed");
+ assert.match(final.errors.side, /wavespeed generation failed/, "fallback ran and its failure is what's recorded");
+ assert.ok(final.outputImages.back);
+ editMode = "success";
+ wavespeedShouldFail = false;
+});
+
+test("all 3 views failing -> task settles as 'failed'", async () => {
+ editMode = "fail-all";
+ wavespeedShouldFail = true; // both providers must fail — see note above
+ editRequests = [];
+ const bodyFront = await uploadTinyPng("body-front5.png");
+ const garmentImage = await uploadTinyPng("garment5.png");
+
+ const { record } = await createTask({ bodyFront, garmentImage });
+ const final = await waitUntilTerminal(record.id);
+ assert.equal(final.status, "failed");
+ for (const view of ["front", "side", "back"]) {
+ assert.equal(final.outputImages[view], null);
+ assert.match(final.errors[view], /wavespeed generation failed/);
+ }
+ wavespeedShouldFail = false;
+});
+
+test("POST /api/admin/dressgen: rejects a non-/uploads/ path (defense against arbitrary local file reads)", async () => {
+ const garmentImage = await uploadTinyPng("garment6.png");
+ const { status } = await createTask({ bodyFront: "/etc/passwd", garmentImage });
+ assert.equal(status, 400);
+});
+
+test("POST /api/admin/dressgen/:id/retry: whole-task retry reuses the original images/prompts, only redoes the failed view", async () => {
+ // Pick up the failed-all task from the previous test and fix the mock.
+ const failedRes = await fetch(`${BASE}/api/admin/dressgen?pageSize=100`, { headers: { Cookie: cookie } }).then((r) => r.json());
+ const failedTask = failedRes.items.find((r) => r.status === "failed"); // items are already newest-first
+ assert.ok(failedTask, "expected a failed task from the earlier all-fail test");
+
+ editMode = "success";
+ editRequests = [];
+ const res = await fetch(`${BASE}/api/admin/dressgen/${failedTask.id}/retry`, { method: "POST", headers: { Cookie: cookie } });
+ assert.equal(res.status, 200);
+ const kicked = await res.json();
+ assert.equal(kicked.status, "generating");
+
+ const final = await waitUntilTerminal(failedTask.id);
+ assert.equal(final.status, "done");
+ assert.equal(editRequests.length, 3, "retry reprocesses all 3 views since all 3 had failed");
+ // No new images were uploaded for the retry — it must be the original ones.
+ assert.equal(final.garmentImage, failedTask.garmentImage);
+ assert.equal(final.bodyFront, failedTask.bodyFront);
+});
+
+test("POST /api/admin/dressgen/:id/retry/:view: regenerates only that one view, leaves the others untouched", async () => {
+ editMode = "fail-2nd";
+ wavespeedShouldFail = true; // both providers must fail to get a genuinely partial task
+ editRequests = [];
+ const bodyFront = await uploadTinyPng("body-front6.png");
+ const garmentImage = await uploadTinyPng("garment7.png");
+ const { record } = await createTask({ bodyFront, garmentImage });
+ const partial = await waitUntilTerminal(record.id);
+ assert.equal(partial.status, "partial");
+ const originalFront = partial.outputImages.front;
+ wavespeedShouldFail = false;
+
+ editMode = "success";
+ editRequests = [];
+ const res = await fetch(`${BASE}/api/admin/dressgen/${record.id}/retry/side`, { method: "POST", headers: { Cookie: cookie } });
+ assert.equal(res.status, 200);
+
+ const final = await waitUntilTerminal(record.id);
+ assert.equal(final.status, "done");
+ assert.equal(editRequests.length, 1, "per-view retry only calls the API once, for that view");
+ assert.equal(final.outputImages.front, originalFront, "the already-succeeded front view must be untouched by a side-only retry");
+ assert.ok(final.outputImages.side);
+});
+
+test("POST /api/admin/dressgen/:id/retry/:view: an optional prompt override is used for that call and persisted on the record", async () => {
+ editMode = "fail-2nd";
+ editRequests = [];
+ const bodyFront = await uploadTinyPng("body-front7.png");
+ const garmentImage = await uploadTinyPng("garment8.png");
+ const { record } = await createTask({ bodyFront, garmentImage });
+ await waitUntilTerminal(record.id);
+
+ editMode = "success";
+ editRequests = [];
+ const overridePrompt = "REGENERATE_SIDE_WITH_THIS_EXACT_PROMPT";
+ const res = await fetch(`${BASE}/api/admin/dressgen/${record.id}/retry/side`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Cookie: cookie },
+ body: JSON.stringify({ prompt: overridePrompt }),
+ });
+ assert.equal(res.status, 200);
+ const kicked = await res.json();
+ assert.equal(kicked.prompts.side, overridePrompt, "the override must be persisted on the record immediately");
+
+ const final = await waitUntilTerminal(record.id);
+ assert.equal(final.prompts.side, overridePrompt);
+ const sidePromptSent = editRequests.map((r) => r.promptMatch && r.promptMatch[1]).find((p) => p === overridePrompt);
+ assert.ok(sidePromptSent, "the override must actually reach the edit call");
+});
+
+test("POST /api/admin/dressgen/:id/retry: unknown id 404s", async () => {
+ const res = await fetch(`${BASE}/api/admin/dressgen/does-not-exist/retry`, { method: "POST", headers: { Cookie: cookie } });
+ assert.equal(res.status, 404);
+});
+
+// provider:"wavespeed" — the second provider (2026-09-03), pickable per
+// task via the admin UI, or used automatically as an image2-failure
+// fallback (see the auto-fallback test below, and server.js's
+// WAVESPEED_API_KEY doc comment for the history of that decision).
+// Exercises the real upload -> submit -> poll -> fetch-output contract
+// against the mock wavespeed routes added to the same mock server.
+test("POST /api/admin/dressgen with provider:'wavespeed' uploads every image, submits once per view, and downloads the completed output", async () => {
+ editMode = "success";
+ wavespeedShouldFail = false;
+ wavespeedUploadCount = 0;
+ wavespeedSubmitBody = null;
+ const bodyFront = await uploadTinyPng("ws-body-front.png");
+ const garmentImage = await uploadTinyPng("ws-garment.png");
+
+ const { status, record } = await createTask({ bodyFront, garmentImage, provider: "wavespeed" });
+ assert.equal(status, 200);
+ assert.equal(record.provider, "wavespeed");
+
+ const final = await waitUntilTerminal(record.id);
+ assert.equal(final.status, "done");
+ assert.equal(wavespeedUploadCount, 6, "2 images (body+garment) uploaded once per each of the 3 sequential view calls");
+ assert.ok(Array.isArray(wavespeedSubmitBody.images) && wavespeedSubmitBody.images.length === 2, "submit body must carry the uploaded image URLs, not local paths");
+ assert.ok(wavespeedSubmitBody.prompt, "submit body must carry the view's prompt");
+
+ for (const view of ["front", "side", "back"]) {
+ const served = await fetch(`${BASE}${final.outputImages[view]}`);
+ assert.ok(Buffer.from(await served.arrayBuffer()).equals(OUTPUT_PNG));
+ }
+});
+
+test("POST /api/admin/dressgen with provider:'wavespeed': a failed generation is recorded per view same as the image2 path", async () => {
+ wavespeedShouldFail = true;
+ const bodyFront = await uploadTinyPng("ws-body-front2.png");
+ const garmentImage = await uploadTinyPng("ws-garment2.png");
+
+ const { record } = await createTask({ bodyFront, garmentImage, provider: "wavespeed" });
+ const final = await waitUntilTerminal(record.id);
+ assert.equal(final.status, "failed");
+ for (const view of ["front", "side", "back"]) {
+ assert.equal(final.outputImages[view], null);
+ assert.match(final.errors[view], /wavespeed generation failed/);
+ }
+ wavespeedShouldFail = false;
+});
+
+test("POST /api/admin/dressgen: default provider (image2) failing auto-falls-back to wavespeed per view, no admin action needed", async () => {
+ editMode = "fail-all";
+ wavespeedShouldFail = false;
+ const bodyFront = await uploadTinyPng("fallback-body-front.png");
+ const garmentImage = await uploadTinyPng("fallback-garment.png");
+
+ const { status, record } = await createTask({ bodyFront, garmentImage }); // no provider -> defaults to image2
+ assert.equal(status, 200);
+ assert.equal(record.provider, "image2");
+
+ const final = await waitUntilTerminal(record.id);
+ assert.equal(final.status, "done", "image2 failed on every view but wavespeed picked up each one");
+ for (const view of ["front", "side", "back"]) {
+ assert.ok(final.outputImages[view], `${view} must have an output despite image2 failing`);
+ assert.equal(final.errors[view], null);
+ assert.equal(final.viewProvider[view], "wavespeed", "record must reflect which provider actually produced this view");
+ }
+ editMode = "success";
+});
+
+test("POST /api/admin/dressgen: an unrecognized provider value falls back to the default (image2), not an error", async () => {
+ editMode = "success";
+ editRequests = [];
+ const bodyFront = await uploadTinyPng("ws-body-front3.png");
+ const garmentImage = await uploadTinyPng("ws-garment3.png");
+
+ const { status, record } = await createTask({ bodyFront, garmentImage, provider: "not-a-real-provider" });
+ assert.equal(status, 200);
+ assert.equal(record.provider, "image2");
+ await waitUntilTerminal(record.id);
+});
+
+// Pagination (2026-09-03) — GET /api/admin/dressgen used to return the
+// whole file as a bare array; by this point in the suite there are well
+// over 2 records, so this doesn't need to create its own.
+test("GET /api/admin/dressgen: paginates, newest first, page 1 and page 2 don't overlap", async () => {
+ const page1 = await fetch(`${BASE}/api/admin/dressgen?page=1&pageSize=2`, { headers: { Cookie: cookie } }).then((r) => r.json());
+ assert.equal(page1.items.length, 2);
+ assert.equal(page1.page, 1);
+ assert.equal(page1.pageSize, 2);
+ assert.ok(page1.total >= 10, "the suite has created well more than 2 tasks by now");
+
+ const page2 = await fetch(`${BASE}/api/admin/dressgen?page=2&pageSize=2`, { headers: { Cookie: cookie } }).then((r) => r.json());
+ assert.equal(page2.items.length, 2);
+ const page1Ids = new Set(page1.items.map((r) => r.id));
+ assert.ok(page2.items.every((r) => !page1Ids.has(r.id)), "page 2 must not repeat page 1's items");
+ assert.ok(page1.items[0].createdAt >= page1.items[1].createdAt, "newest-first within a page");
+ assert.ok(page1.items[1].createdAt >= page2.items[0].createdAt, "newest-first across pages too");
+});
+
+// Stale-task recovery (2026-09-03): a record can be left sitting in
+// "generating" forever if the process dies mid-view — nothing resumes an
+// in-flight HTTP call across a restart. Own isolated server + mock (a mock
+// that never responds, so the task is provably still "generating" when
+// killed) sharing one on-disk dress-gen.json across two child processes,
+// simulating a crash-and-restart.
+test("a task stuck in 'generating' when the process restarts is marked failed on the next boot, not stuck forever", async () => {
+ let hangingMock;
+ const hangingPort = await new Promise((resolve) => {
+ hangingMock = http.createServer((req, res) => {
+ if (req.method === "POST" && req.url === "/api/v1/images/edits") return; // never responds
+ res.writeHead(404).end();
+ });
+ hangingMock.listen(0, () => resolve(hangingMock.address().port));
+ });
+
+ const port2 = 26934 + Math.floor(Math.random() * 1000);
+ const base2 = `http://localhost:${port2}`;
+ const cwd2 = fs.mkdtempSync(path.join(os.tmpdir(), "dressgen-restart-"));
+ for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js", "db.js", "pricing.js"]) {
+ fs.copyFileSync(path.join(__dirname, f), path.join(cwd2, f));
+ }
+ fs.writeFileSync(path.join(cwd2, "dress-gen.json"), "[]");
+ fs.mkdirSync(path.join(cwd2, "uploads"), { recursive: true });
+ fs.writeFileSync(path.join(cwd2, "uploads", "restart-body.png"), TINY_VALID_PNG);
+ fs.writeFileSync(path.join(cwd2, "uploads", "restart-garment.png"), TINY_VALID_PNG);
+
+ const env2 = {
+ ...process.env,
+ PORT: String(port2),
+ H3_CONSOLE_BASE: "",
+ H3_CONSOLE_TOKEN: "",
+ ADMIN_PASSWORD,
+ IMAGE2_API_KEY,
+ IMAGE2_API_BASE: `http://localhost:${hangingPort}`,
+ WAVESPEED_API_KEY: "", // no fallback — this test wants a plain stuck "generating" view
+ };
+
+ let child2 = spawn(process.execPath, ["server.js"], { cwd: cwd2, env: env2, stdio: "pipe" });
+ try {
+ await waitForServer(base2);
+ const loginRes = await fetch(`${base2}/api/admin/login`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ password: ADMIN_PASSWORD }),
+ });
+ const cookie2 = (loginRes.headers.get("set-cookie") || "").split(";")[0];
+
+ const created = await fetch(`${base2}/api/admin/dressgen`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Cookie: cookie2 },
+ body: JSON.stringify({ bodyFront: "/uploads/restart-body.png", garmentImage: "/uploads/restart-garment.png" }),
+ }).then((r) => r.json());
+
+ // Wait for the front view to actually reach "generating" (i.e. the
+ // hanging mock has been called) before killing — proves this is a
+ // genuinely in-flight task, not a "queued" one that never started.
+ const deadline = Date.now() + 5000;
+ let record;
+ do {
+ record = await fetch(`${base2}/api/admin/dressgen/${created.id}`, { headers: { Cookie: cookie2 } }).then((r) => r.json());
+ if (record.viewStatus.front === "generating") break;
+ await new Promise((r) => setTimeout(r, 20));
+ } while (Date.now() < deadline);
+ assert.equal(record.viewStatus.front, "generating", "front view must genuinely be in flight before the simulated crash");
+
+ child2.kill("SIGKILL"); // simulate a crash — no graceful shutdown, no chance to mark anything failed on the way out
+ await new Promise((r) => setTimeout(r, 200));
+
+ child2 = spawn(process.execPath, ["server.js"], { cwd: cwd2, env: env2, stdio: "pipe" });
+ await waitForServer(base2);
+ // adminSessions lives in memory — a fresh process means the old cookie
+ // is gone too, not just the in-flight generation.
+ const loginRes2 = await fetch(`${base2}/api/admin/login`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ password: ADMIN_PASSWORD }),
+ });
+ const cookie3 = (loginRes2.headers.get("set-cookie") || "").split(";")[0];
+ const recovered = await fetch(`${base2}/api/admin/dressgen/${created.id}`, { headers: { Cookie: cookie3 } }).then((r) => r.json());
+ assert.equal(recovered.viewStatus.front, "failed");
+ assert.match(recovered.errors.front, /进程重启/);
+ assert.equal(recovered.status, "failed", "no view ever succeeded, so the whole task is 'failed'");
+ } finally {
+ child2.kill();
+ hangingMock.close();
+ fs.rmSync(cwd2, { recursive: true, force: true });
+ }
+});
diff --git a/dressup-store.js b/dressup-store.js
index 9314eb3..4067b74 100644
--- a/dressup-store.js
+++ b/dressup-store.js
@@ -23,4 +23,9 @@ const moderationLog = createStore(path.join(__dirname, "moderation-log.json"));
// the automated face-blacklist model (T32) or garment moderation (T26).
const blacklist = createStore(path.join(__dirname, "blacklist.json"));
-module.exports = { characters, garments, danceRefs, generationJobs, moderationLog, blacklist };
+// Standalone admin tool (separate from the dressup dance room pipeline
+// above): one row per person-3-views + garment -> composite portrait call
+// through the gpt-image-2 relay, see generateDressImage() in server.js.
+const dressGenRecords = createStore(path.join(__dirname, "dress-gen.json"));
+
+module.exports = { characters, garments, danceRefs, generationJobs, moderationLog, blacklist, dressGenRecords };
diff --git a/generation-jobs.json b/generation-jobs.json
index 36fe621..2541cf5 100644
--- a/generation-jobs.json
+++ b/generation-jobs.json
@@ -149,9 +149,9 @@
"garmentId": "98a5fd6a-5db0-48d9-a21b-a3d800c5a4fd",
"refDanceVideoId": "7b8e13ce-c9e3-4a19-bccc-5f54e6aae98e",
"batchId": "2c01df77-d1fb-4c46-a6d9-5d1d63436f5b",
- "status": "generating",
+ "status": "failed",
"h3JobId": "f068a14de195",
- "error": null,
+ "error": "用户手动取消",
"fullPrompt": "subject_definitions:\n is the young woman whose visual identity is defined by : 年轻女性,长直黑发,鹅蛋脸,气质清冷. Preserve her face, hairstyle, and body exactly as shown; only her outfit, pose, motion, and framing change in the target video.\n is the character reference frame for , showing her face, hairstyle, and body in a neutral standing pose, wearing a neutral outfit that does not appear in the target video; her footwear as shown here is retained unchanged in the target video.\n is the garment reference image: 彩色旗袍变体,金色滚边,盘扣,侧开衩(占位素材,待替换), laid flat with no person wearing it. Only its garment attributes (silhouette, color, fabric, trim, cut) transfer onto ; no other content of this image appears in the target video, and it does not affect her footwear.\n