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 张人物参考图。
+ + +
+
+
+ + +
+
+
+
+
+ +
+ + +
+ + + + +
+
+ + + + 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