From d6e1797f08e991d6d9d4c00ca5d1c0070c217dbb Mon Sep 17 00:00:00 2001 From: "Shin@HOME" Date: Mon, 20 Apr 2026 21:52:35 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=95=E5=85=A53d=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/changelog/.changelog-ack | 0 .cursor/changelog/changelog-full.md | 85 ++++ .cursor/changelog/changelog-headlines.md | 14 +- .cursor/changelog/changelog-recent.md | 278 ++--------- .cursor/pitfalls/pitfalls.md | 21 + .cursor/rules/common/changelog-recall.mdc | 38 +- .cursor/rules/common/profile-recall.mdc | 13 +- .cursor/skills/dev-changelog/SKILL.md | 8 + .cursor/skills/epee-orchestrator/registry.md | 8 +- .cursor/skills/pitfall-journal/SKILL.md | 8 +- .cursor/skills/profile-memory/SKILL.md | 8 +- art-agent/backend/app/agent/tools.py | 41 +- art-agent/backend/app/config.py | 75 ++- .../backend/app/services/mesh_generator.py | 189 +++++++ .../backend/app/services/style_restorer.py | 180 +++++++ .../app/services/video_frame_extractor.py | 191 ++++++++ .../backend/app/services/view_transform.py | 210 +++++++- art-agent/backend/requirements-lock.txt | Bin 2200 -> 2114 bytes art-agent/backend/requirements.txt | 2 + .../backend/scripts/test_frame_extract.py | 107 ++++ .../backend/scripts/test_style_restore.py | 167 +++++++ art-agent/backend/scripts/test_trellis.py | 72 +++ .../VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md | 463 ++++++++++++++++++ 23 files changed, 1890 insertions(+), 288 deletions(-) delete mode 100644 .cursor/changelog/.changelog-ack create mode 100644 art-agent/backend/app/services/mesh_generator.py create mode 100644 art-agent/backend/app/services/style_restorer.py create mode 100644 art-agent/backend/app/services/video_frame_extractor.py create mode 100644 art-agent/backend/scripts/test_frame_extract.py create mode 100644 art-agent/backend/scripts/test_style_restore.py create mode 100644 art-agent/backend/scripts/test_trellis.py create mode 100644 docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md diff --git a/.cursor/changelog/.changelog-ack b/.cursor/changelog/.changelog-ack deleted file mode 100644 index e69de29..0000000 diff --git a/.cursor/changelog/changelog-full.md b/.cursor/changelog/changelog-full.md index 8e84bca..30f2aa8 100644 --- a/.cursor/changelog/changelog-full.md +++ b/.cursor/changelog/changelog-full.md @@ -4,6 +4,91 @@ ## 记录 +### [CL-20260420-1800] 2026-04-20 18:00 — Mesh Pipeline Phase 3 风格还原接入(IP-Adapter + ControlNet) +- **tags**: 视角变换, Mesh Pipeline, 风格还原, IP-Adapter, ControlNet, Replicate, style_restorer, 后端, services, config, Phase 3 +- **affected_files**: + - art-agent/backend/app/config.py + - art-agent/backend/app/services/style_restorer.py (新增) + - art-agent/backend/app/services/view_transform.py + - art-agent/backend/scripts/test_style_restore.py (新增) + - docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md +- **what**: 按 SPEC §4 Phase 3 落地 Mesh Pipeline 的阶段 3 — 对 Stage 2 抽出的 normal 帧做 IP-Adapter + ControlNet 重绘,用用户原图做风格参考,输出保留原画风且视角正确的最终图。 +- **why**: Phase 2 已能稳定产出 color/normal 6 视角帧,但 Trellis 的 color 帧是"半写实"纹理风,与用户上传的卡通描边原图风格差异明显。Phase 3 用 `chigozienri/ip_adapter-sdxl-controlnet-depth`(版本 `0436c870...`,$0.07/张 ~72s,L40S):`image` 字段传原图承担 IP-Adapter 风格参考,`controlnet_input` 传 normal 帧作为结构约束的 depth 近似(按 SPEC §1.4 一期走"路 A"简化方案,效果不足再升级为 `depth-anything-v2` 真 depth)。 +- **decisions**: + 1. 新模型以 `internal=True` 注册进 `IMAGE_MODELS`(而非 `VIEW_TRANSFORM_MODELS`),理由:它是"普通文生/图生图"模型,只是用在内部流水线;同步改 `get_image_models_list()` 过滤 `internal`,避免它出现在前端生图模型下拉 + 2. 暴露 `get_style_restore_model_id()` helper 让 service 层不硬编码短 ID + 3. `restore_batch()` 用 `asyncio.Semaphore(3)` 控制并发而非 `asyncio.gather` 全冲 —— SPEC §5 风险表提示过 Replicate rate limit 风险 + 4. 默认参数选 `scale=0.75` / `controlnet_conditioning_scale=0.8`(SPEC §2.3 建议范围 0.7-0.8 / 0.7-0.9 的中位偏上),最终值由 P3-4 调参确认 + 5. **单帧失败不阻塞整体**:Stage 3 任一帧失败时自动回退为 Stage 2 的 color 帧返回,前端至少能看到东西;并在该 image 上挂 `restyle_error` 字段暴露失败原因。这一兜底 + "Stage 2 失败不影响 Stage 1 交付" 形成一致的"阶段独立可交付"设计 +- **notes**: + - 成本:一次完整 6 视角请求 ~$0.46(Trellis $0.041 + 6 × IP-Adapter $0.07 = $0.461),耗时 ~105s(30s 重建 + 并发 3 两轮 × 72s ≈ 75s 风格化) + - `images[i]` 新结构:新增 `color_url`(保留 Stage 2 原图便于前端对比/切换)、`restyled` 布尔、可选 `restyle_error`;返回体 `_stage` 从 `stage2` 升级为 `stage3 (N/M restyled)` + - `_note` 字段去掉了,错误通过 `error` 字段聚合(拼接 Stage 2 + Stage 3 错误);`_preserve_style_requested` 保留供调试 + - Checkpoint 3 脚本 `scripts/test_style_restore.py` 支持 `--single` 冒烟(~$0.07)和全量(~$0.42),以及 `--scale/--cn-scale` 临时覆盖参数,便于 P3-4 调参;默认自动挑 generated/ 下最新一组 `view__az*_el0_normal.png` 作为结构参考 + - **P3-4 未完成**(等用户用真实原图跑调参);P4 前端联调将在 Checkpoint 3 通过后开启 +- **source_chat**: [继续执行 Mesh Pipeline Phase 3](85c3e2f0-ab1f-48c3-9f12-a1b2c3d4e5f6) + +### [CL-20260420-1700] 2026-04-20 17:00 — Mesh Pipeline Phase 2 抽帧 + combined_video 左右切分落地 +- **tags**: 视角变换, Mesh Pipeline, Trellis, combined_video, ffmpeg, imageio-ffmpeg, PIL, 抽帧, 后端, services, Phase 2 +- **affected_files**: + - art-agent/backend/app/services/video_frame_extractor.py (新增) + - art-agent/backend/app/services/view_transform.py + - art-agent/backend/requirements.txt + - art-agent/backend/scripts/test_frame_extract.py (新增) + - docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md +- **what**: 按 SPEC §4 Phase 2 清单落地。(1) 确认宿主机 `ffmpeg` 未安装,改用 `imageio-ffmpeg==0.5.1` 自带的 Windows 静态二进制(`ffmpeg-win64-v4.2.2.exe`,随 wheel 一起打包 22.6MB),免系统依赖;venv 已装,`requirements.txt` 同步写入。(2) 新增 `services/video_frame_extractor.py` 实现 `extract_by_azimuth(combined_video_path, azimuths, elevations=None, total_rotation_deg=360)`:核心策略采用"一次性把 combined_video 所有帧抽到 `generated/_frames_/` 临时目录 + 按 `frame_idx = round(az / total_rotation_deg * total_frames) % total_frames` 挑帧 + PIL 按中线切左半→color、右半→normal 保存到 `generated/view__az{az}_el{el}_{color|normal}.png`",比逐帧用 `-ss` 抽更稳(不受 keyframe 对齐影响),5s 视频整体成本可忽略;用完即清理临时目录,只保留切好的最终帧。(3) ffmpeg + PIL 是阻塞操作,用 `asyncio.get_running_loop().run_in_executor(None, _do_extract, ...)` 丢到默认线程池跑,避免堵住 FastAPI 事件循环。(4) `services/view_transform.py`:`_transform_view_mesh` 在 Stage 1 成功后串接 Stage 2——默认 `azimuths=[0,60,120,180,240,300]`(从模块级常量 `DEFAULT_MESH_AZIMUTHS` 读取),`elevations` 缺省或长度不足补 0、超长截断,保证与 azimuths 对齐;调用 `video_frame_extractor.extract_by_azimuth(combined_video_path=stage1["combined_video_path"], ...)` 得到 `frames[{az, el, frame_idx, color_png, normal_png}]`;把 `images` 填为 `[{url: color_png, azimuth, elevation, normal_url: normal_png, frame_idx}]`;Stage 2 失败**不**把整体置为失败(Stage 1 的 mesh/video 仍有交付价值),单独在返回 dict 的 `error` 字段带回 Stage 2 错误信息,`success` 同时要求 stage1 成功 + Stage 2 无 error。(5) 新增 `scripts/test_frame_extract.py` 供用户 Checkpoint 2 验收:默认取 `generated/` 下最新的 `trellis_combined_*.mp4` 做输入(**不需要再花钱跑 Trellis**),调用 `video_frame_extractor.extract_by_azimuth` 抽 6 视角,打印每帧的 `color_png/normal_png` URL + 本地文件大小,并提示用户目视检查。(6) SPEC 文档 P2-1 ~ P2-4 全部打勾、追加本次会话的进度 note(含 Agent 自测结论)。 +- **why**: SPEC §4 "每个 Phase 完成后停下来让用户测试/审阅",Phase 2 是用户能看到"可见结果"的第一步——Phase 1 只能交付一个 .glb + 一个 mp4 视频,用户很难一眼判断是否正确;Phase 2 交付 6 张方位角明确的方形图后,用户可以直接目视环绕教堂一周。抽帧方案故意选"一次抽全 + 按索引挑"而不是"每帧一次 -ss 快进",是因为:(a) combined_video 约 5s/120 帧,总 PIL+ffmpeg 开销 ~1-2s,比 6 次 -ss (每次 ffmpeg 启动 ~300-500ms) 更快;(b) `-ss` 在 GOP 长的 mp4 上会对齐到最近 keyframe,取帧索引可能不精确;(c) 代码简单、debug 时看临时目录文件序号就能对应帧。Stage 2 失败不牵连 Stage 1,是考虑到"抽帧失败"通常意味着 ffmpeg 或视频本身异常,但用户可能仍想拿 .glb 做手动检视——与其整体 fail,不如降级返回部分产物 + 明确 error。测试脚本用已有 combined_video 是故意为之:Checkpoint 2 的重点是验证抽帧+切图的正确性,不是再验证一遍 Trellis,没理由让用户多花 $0.041。 +- **decisions**: (1) **ffmpeg 方案**:首选 `imageio-ffmpeg` 而不是让用户 `choco install ffmpeg`,因为 SPEC §5 风险栏就列了 "Windows ffmpeg 未装" 是高概率风险,imageio-ffmpeg 的 pip wheel 自带 Win64 二进制是零摩擦方案;代价是 wheel 22.6MB + 每次 Python 启动查找二进制(成本可忽略)。(2) **返回 schema**:给 mesh 管道的 `images[i]` 加了 `normal_url` 和 `frame_idx` 字段而不是单独的 `frames` 列表——这样前端不用改 schema 就能显示(`url` 仍是最终要展示的彩色图),同时 Phase 3 可以直接读 `normal_url` 作为 `controlnet_input`。(3) **elevation 字段保留但忽略**:combined_video 本身是水平平视一圈,`elevation != 0` 在 Phase 2 无意义(所有帧 el 都是 0),但保留字段为 Phase 5 自由相机路径预留;当前传 `elevations=[30,30,...]` 也不会报错,只是结果图的 el 字段被原样记录、帧内容仍是水平视角(有心理预期即可,未来 renderer 路径会真正使用)。(4) **临时目录策略**:抽帧到 `generated/_frames_<8位hex>/` 而不是系统临时目录,是因为后者在 Windows 跨盘符时可能慢;前者失败时也留现场方便 debug。完成后的清理用 `try/except OSError` 兜底,即使 PIL 占着句柄也不会报错退出。(5) **DEFAULT_MESH_AZIMUTHS 提到模块级**:下 Phase 4 前端会暴露"快速预设 6/8/12 视角"选项,这里先把默认值拎出来便于配置扩展;目前命名为 6 视角,Phase 4 再考虑增加 8/12。 +- **notes**: (1) **Stage 2 路径依赖 stage1 的 `combined_video_path` 键名**(非 `color_video_path`)——这是 Trellis 部署版 `e8f6c452...` 的实测事实(PF-20260420-1600),不要以为 Phase 1 的字段 `color_video_path` 有值;现在该字段仍然返回但恒为 `None`,`_transform_view_mesh` 里优先读 `combined_video_path`。(2) **前端目前仍不会自动展示 mesh 管道结果** ——`loop.py` 只在 `result.get("images")` 非空时发 `image_result` 事件,Phase 2 让 images 有值了,所以前端**应该**能看到 6 张;但 Phase 4 才正式端到端联调,Phase 2 的建议验收入口仍是 `scripts/test_frame_extract.py` 而不是 UI。(3) **normal 帧的 url 在前端目前是"隐藏字段"**——`images[i].normal_url` 前端 types.ts 里没这个字段,会被丢弃;Phase 3 接入时 view_transform 会改为把重绘后的图填到 `url`,normal 帧继续作为中间产物留在磁盘上。(4) **预留的 `preserve_style` 参数当前不生效**,返回 dict 的 `_preserve_style_requested` 字段记录了用户意图——Phase 3 会根据这个开关分流(True 走 ControlNet 重绘、False 直出 color 帧)。(5) **Agent 自测结果(2026-04-20 17:05)**:`test_frame_extract.py` 对 `trellis_combined_290da12de954.mp4` 运行,抽出 120 帧,az=[0,60,120,180,240,300] 分别映射到 frame_idx=[0,20,40,60,80,100](每 60° = 20 帧)完全均匀,12 个 PNG 全部落盘,单张尺寸 512×512 方形(说明原 combined_video 是 1024×512 左右并排)。自测**不能替代用户目视检查** "6 张 color 图是否按 0°→300° 顺序环绕建筑一圈",Checkpoint 2 必须由用户拍板。(6) 用户验收流程:(a) 在 `art-agent/backend` 激活 venv;(b) 跑 `python scripts/test_frame_extract.py`(默认取 `generated/` 最新 trellis_combined;也可 `python scripts/test_frame_extract.py generated/trellis_combined_.mp4` 指定);(c) 终端应打印 6 个 color_png + 6 个 normal_png 均 `OK`,每个 size > 0;(d) 打开 `generated/view_*_az0_el0_color.png ~ view_*_az300_el0_color.png` 6 张图,验证是同一建筑从 6 个均匀角度绕一圈的视角;(e) normal 图是蓝紫调法线图,可抽 1-2 张抽查。通过后开 Phase 3。 +- **source_chat**: 本次会话 "继续执行 Phase 2" + +### [CL-20260420-1600] 2026-04-20 16:00 — Mesh Pipeline Phase 1 接入 Trellis(视角变换第二条管道落地阶段 1) +- **tags**: 视角变换, Mesh Pipeline, Trellis, Replicate, 后端, services, config, tools, Phase 1 +- **affected_files**: + - art-agent/backend/app/services/mesh_generator.py (新增) + - art-agent/backend/app/services/view_transform.py + - art-agent/backend/app/config.py + - art-agent/backend/app/agent/tools.py + - art-agent/backend/scripts/test_trellis.py (新增) + - docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md +- **what**: 按 SPEC §4 Phase 1 清单落地。(1) 新增 `services/mesh_generator.py`,封装 `generate_with_trellis(image_path, params=None)` 和统一入口 `generate_mesh(image_path, model_id)`,调用 `firtoz/trellis:e8f6c452...` 并把 `model_file/color_video/normal_video/combined_video` 挨个 httpx 下载到 `GENERATED_DIR`,以 `trellis_*_.{glb,mp4}` 命名,返回 `/generated/...` 本地 URL。(2) `config.py` 在 `VIEW_TRANSFORM_MODELS` 新增 `trellis`(pipeline=mesh, enabled=True)和 `hunyuan3d`(pipeline=mesh, enabled=False 占位),给原 `zero123plus` 条目补 `pipeline=grid`;`get_view_transform_models_list` 改为只返回 enabled 条目 + 带上 pipeline 字段。(3) `services/view_transform.py` 改为按 `pipeline` 字段分流——`grid` 走原 Zero123++ 逻辑(逻辑未动,仅输出字段补齐 `pipeline/mesh_url/color_video/normal_video`),`mesh` 走新增 `_transform_view_mesh()`:当前阶段只跑 mesh_generator 返回 `.glb + color_video + normal_video` 本地 URL,`images` 列表留空,附 `_stage="stage1"` 和 `_note` 标记 Phase 2/3 未接入。(4) `tools.py` 的 `transform_view` 工具定义扩三个参数 `model_id` (enum: zero123plus/trellis, default zero123plus) / `azimuths` / `preserve_style`,执行侧把这些参数透传给 `transform_view()`。(5) 新增 `backend/scripts/test_trellis.py` 作为 Phase 1 独立验收脚本——默认用 uploads/ 下最新 png、支持命令行指定路径、自动加载 .env、调用 `mesh_generator.generate_with_trellis` 后检查本地文件大小。(6) SPEC 文档 P1-1 ~ P1-5 打勾并追加进度 note。 +- **why**: SPEC 明确要求"按 Phase 顺序推进,每个 Phase 完成后停下来让用户测试/审阅",Phase 1 是整条 Mesh Pipeline 的地基——验证 Replicate Trellis 调用通道是否打通、文件下载策略是否正确、与现有 grid 管道并存不互相干扰。把 mesh_generator 独立成模块是为后续 Phase 2/3/5 解耦:Phase 2 只需读 color_video_path/normal_video_path,Phase 3 只需消费 images 列表,Phase 5 切到 Hunyuan3D-2 时只需替换 generate_mesh 的分支。测试脚本独立走 asyncio 是因为 Phase 1 还没接到工具链,用户需要一个"最小能跑"的入口先确认 API 本身可用(SPEC P1-5 也是这么要求的)。 +- **decisions**: (1) `view_transform` 返回结构升级为同时带 `grid_image/mesh_url/color_video/normal_video/pipeline` 字段,两种管道缺省位补 `None`——比"两种完全不同的返回 schema"更利于前端和 loop.py 后续统一处理。(2) Phase 1 当前故意让 mesh 管道的 `images` 为空 + 带 `_note` 说明,而不是硬拼一个"只返回视频"的假结果——这样 Phase 2 接入时 frontend 的 "没有图片显示" 现象会被替换为 "6 张图片显示",语义更清晰。(3) hunyuan3d 提前注册但 `enabled=False`,避免用户在前端误选到二期才启用的路径;`get_view_transform_models_list` 同步过滤 enabled。(4) `mesh_generator._to_url` 做了 `FileOutput → str` 的显式转换兜底,因为 Replicate SDK 的字典输出里每个字段可能是 FileOutput 对象而不是裸字符串 URL——这点在 image_gen.py 的 `ReplicateProvider._download` 已经吃过亏,这里主动规避。(5) `_download_asset` 的 httpx 超时放到 300s,因为 color_video ~3-5MB 在国内网络可能慢;复用项目既有的 HTTPS_PROXY 注入模式。 +- **notes**: 本次改动**完全不触碰 Zero123++ 现有流程**——grid 管道所有行为保持 byte-level 等价,只是返回字典多了几个 None 字段,`loop.py` 和前端已有代码没事。但还**未修改 loop.py 的 SSE 事件处理**:当前 loop.py 只在 `tool_name == "transform_view" and result.get("images")` 时发 `image_result`;mesh 管道 Phase 1 的 `images=[]` 会让 loop.py 不发事件,前端看到空结果——这**是预期行为**,Phase 1 的测试入口是 `scripts/test_trellis.py` 而不是前端。Phase 2 接入后 images 有值,loop.py 无需改动即可工作。用户验收流程:(a) 在 `art-agent/backend` 激活 venv;(b) 跑 `python scripts/test_trellis.py`(默认取 uploads/ 最新 png),或 `python scripts/test_trellis.py uploads/.png` 指定图;(c) 等 ~30-60s 看终端打印 `glb_path/color_video_path/normal_video_path` 均为 `/generated/trellis_*...` + 本地大小 > 0;(d) 可选:去 `backend/generated/` 目录用系统播放器看 color_video.mp4 是否为 360° 环绕教堂视频。验收通过后我再开 Phase 2(ffmpeg 抽帧)。 +- **source_chat**: 本次会话 "按计划实施 Mesh Pipeline" + +### [CL-20260420-1530] 2026-04-20 15:30 — 写入 Mesh-Pipeline 视角变换方案 SPEC(跨会话实施计划) +- **tags**: 视角变换, Mesh Pipeline, Trellis, Hunyuan3D-2, ControlNet, IP-Adapter, SPEC, 方案设计, 建筑, 跨会话 +- **affected_files**: + - docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md +- **what**: 新增一份完整的实施计划 SPEC,规划 Zero123++ 之外的第二条视角变换路径——Mesh Pipeline。三段式流水线:(1) Trellis/Hunyuan3D-2 单图生 mesh + 360° color_video;(2) ffmpeg 从 color_video 抽指定 azimuth 的帧(一期方案,免 pyrender);(3) `chigozienri/ip_adapter-sdxl-controlnet-depth` 做 SDXL+ControlNet-Depth+IP-Adapter 二次重绘,用原图保持卡通描边风格。文档含:三个 Replicate 模型的精确版本 ID + OpenAPI schema、config.py 扩展设计、伪代码管道、6 个 Phase 的可独立执行任务清单(每个 Phase 有独立验收标准)、风险兜底、成本预估(~$0.46/~105s)、跨会话恢复指引。 +- **why**: 用户反馈 Zero123++ 只能出 6 个固定视角 + 建筑大角度偏转一致性差。用户选定重流水线(P1-P3 全做),明确要求"保留 2D 卡通风格"+"自包含跨会话执行"。调研中发现:Trellis ($0.041/30s 自带 color_video) 比 Hunyuan3D-2 ($0.12/127s 仅 mesh) 快 4× 便宜 3×,一期走 Trellis 还能省掉自定义渲染模块。把 Hunyuan3D-2 路线拆进 Phase 5(二期可选),工程量从 ~9h 降到 ~5h。 +- **decisions**: 主选 Trellis 而非 Hunyuan3D-2——以便一期完全跳过 pyrender/blender 渲染器(用 ffmpeg 抽帧替代)。阶段 3 的 ControlNet 结构约束用 normal_video 抽帧(Trellis 送的),不先走 depth-anything-v2(省一次 API 调用,不完美但足够)。Hunyuan3D-2 + pyrender 自由相机留作 Phase 5 可选。不在一期解决 elevation 仰俯视角(Trellis color_video 只给水平环绕),那是 Phase 5 的事。 +- **notes**: 文档位置 `docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md`。下一个会话开工时 Agent 需先读 §0-§3 建立背景,再按 §4 Phase 顺序推进,每 Phase 交付后停下来让用户验证。文档底部有"进度日志"章节,每个 Phase 完成后在 checkbox 打勾 + 追加 note。未修改任何业务代码,纯 SPEC 文档。 +- **source_chat**: 本次会话排查 Zero123++ 切图错位 meta-bug + 后续方案调研 + +### [CL-20260420-1501] 2026-04-20 15:01 — 后端增加 watchfiles 依赖,修复 Windows uvicorn --reload 失效 +- **tags**: uvicorn, watchfiles, 热重载, Windows, FastAPI, requirements, 依赖管理, reload, Zero123++ +- **affected_files**: + - art-agent/backend/requirements.txt + - art-agent/backend/requirements-lock.txt +- **what**: 在 `requirements.txt` 和 `requirements-lock.txt` 中显式加入 `watchfiles==0.24.0`,并在当前 venv 中安装。这让 uvicorn `--reload` 使用 WatchFiles 作为 reloader 后端,而不是无依赖时的 StatReload 轮询回退。 +- **why**: 用户反馈 Zero123++ 切图错位——但 CL-20260420-1212 里做的 `cols=2/rows=3` 修复和 `_split_grid_image` 自动纠错逻辑在磁盘上明明是新版,独立调用也能切出正确的 320×320。进一步排查发现后端 uvicorn 进程是 11:55 启动、从未 reload,而 venv 里 `watchfiles`/`watchdog` 均未安装——StatReload 在 Windows + 深层子目录 + 新建文件 (`view_transform.py` 是 untracked 新增) 的叠加场景下几乎完全失效,所以代码改了但运行时还是老版本。这一层 meta-bug 如果不治本,以后每次改后端都要手动重启。 +- **decisions**: 选择 `watchfiles`(uvicorn 官方推荐、跨平台 Rust 实现、依赖最轻)而不是 `watchdog`(历史包袱更重)。版本锁 0.24.0 与 Python 3.12 兼容良好且 uvicorn 0.44.0 默认识别。未添加 `--reload-dir app` 参数到 project-launcher Skill 的启动命令里——先靠装依赖覆盖主要场景,如果未来仍有监听遗漏再显式限定目录。 +- **notes**: **仅装依赖不重启当前 uvicorn 进程不会生效**,因为旧进程已经绑定到 StatReload。需要 kill PID 28172 并重启一次后端,之后所有代码改动才会自动 reload。启动日志里应该能看到 `Started reloader process [xxx] using WatchFiles`,如果仍是 `StatReload` 说明 watchfiles 没正确安装到当前 venv。踩坑已记录 PF-20260420-1501。 +- **source_chat**: 本次会话排查 Zero123++ 切图错位 meta-bug + +### [CL-20260420-1212] 2026-04-20 12:12 — 修复 Zero123++ 视角切图布局反转导致的图像错位 +- **tags**: Zero123++, 视角变换, 切图, bug修复, grid_layout, PIL, Replicate, view_transform +- **affected_files**: + - art-agent/backend/app/config.py + - art-agent/backend/app/services/view_transform.py +- **what**: 修复 `transform_view` 输出的 6 张视角图严重错位的问题。`config.py` 里的 `VIEW_TRANSFORM_MODELS["zero123plus"].grid_layout` 由 `{"cols": 3, "rows": 2}` 改为 `{"cols": 2, "rows": 3}`,与 `jd7h/zero123plusplus` Replicate 实际输出(高图布局)一致。同时在 `view_transform._split_grid_image` 加入"按实际图像长宽比自动纠正 cols/rows"的健壮性保护:假定每个单视角为正方形,若配置的 `cols/rows` 与图像实际 `w/h` 差异超过 20%,自动交换 cols/rows 并记录 warning。 +- **why**: 用户反馈视角变换后的切图全部错位(窄长条、上下拼两个物体)。根因是 Zero123++ 官方 README 写 "3×2 grid" 但实际 Replicate 部署版输出的是 2 列 × 3 行(高图),config 里按"3 列 × 2 行"配置,cell_w/cell_h 算反了。加上动态纠错是为了未来换其他 view transform 模型时不再重复踩这个坑。 +- **decisions**: 选择"修 config + 加运行时自动纠错"而非仅修 config。纯改 config 能解决眼前问题但无法防御未来模型切换;加动态纠错的代价只是一段 if 判断+warning 日志,换取对"布局文档歧义"场景的整体免疫。放弃了更激进的"用 PIL 探测内容块边界"方案,因为对有微量白边/噪声的模型不稳定。 +- **notes**: uvicorn 以 `--reload` 方式运行,改完自动重载,无需重启服务。用户需要在前端重新触发一次视角变换来验证切图是否正确。对应踩坑记录已写入 `.cursor/pitfalls/pitfalls.md` (PF-20260420-1212)。 +- **source_chat**: 本次会话启动项目并调试 Zero123++ 切图 + ### [CL-20260417-1510] 2026-04-17 15:10 — Cloudflare Quick Tunnel 脚本修复(PS5.1 编码 + 本地 cloudflared) - **tags**: 内网穿透, cloudflared, PowerShell, start-tunnel, UTF-8, gitignore - **affected_files**: diff --git a/.cursor/changelog/changelog-headlines.md b/.cursor/changelog/changelog-headlines.md index 4d566d8..60fb086 100644 --- a/.cursor/changelog/changelog-headlines.md +++ b/.cursor/changelog/changelog-headlines.md @@ -2,6 +2,12 @@ 最近 ~50 次改动的一句话概要,按时间倒序排列。每次会话自动注入上下文。 +- [CL-20260420-1800] 按SPEC §4 Phase 3落地Mesh Pipeline风格还原:config注册internal=True的ip-adapter-controlnet-depth($0.07/张)+ get_image_models_list过滤internal;新增services/style_restorer.py(restore/restore_batch带Semaphore并发3防rate limit);view_transform._transform_view_mesh接入Stage 3,preserve_style=True时用原图做IP-Adapter风格参考+normal帧做ControlNet结构约束(SPEC §1.4路A),单帧失败自动回退Stage 2 color帧不阻塞整体,images[i]新增color_url/restyled/restyle_error,_stage升级为stage3 (N/M restyled);新增scripts/test_style_restore.py支持--single冒烟/全量/--scale/--cn-scale覆盖参数复用Phase 2 normal帧免重跑Trellis;默认scale=0.75 cn_scale=0.8,P3-4调参等用户验收;一次完整6视角 ~$0.46/~105s +- [CL-20260420-1700] 按SPEC §4 Phase 2落地Mesh Pipeline阶段2:新增services/video_frame_extractor.py用imageio-ffmpeg静态二进制(系统ffmpeg未装的兜底)+PIL一次抽全帧到temp dir再按帧索引挑6个方位角+切左右半得color/normal;view_transform.py的mesh管道现已走到Stage2返回images[{url,azimuth,elevation,normal_url,frame_idx}];Stage 2失败不牵连Stage1的mesh/video产物;新增scripts/test_frame_extract.py复用已有combined_video做验收省钱;requirements加imageio-ffmpeg==0.5.1;Agent自测120帧视频6视角映射到0/20/40/60/80/100索引+512×512切图均正确;等用户Checkpoint 2验收后开Phase 3 +- [CL-20260420-1600] 按SPEC §4 Phase 1落地Mesh Pipeline阶段1:新增services/mesh_generator.py封装Trellis调用+下载.glb/color_video/normal_video到generated/,config注册trellis(enabled)+hunyuan3d(占位)+加pipeline字段,view_transform.py按pipeline分流grid/mesh,tools.py扩model_id/azimuths/preserve_style,新增scripts/test_trellis.py独立验收入口;Zero123++现有流程完全不变;等用户跑脚本验收后开Phase 2 +- [CL-20260420-1530] 写入Mesh-Pipeline视角变换方案SPEC(跨会话实施计划):Trellis生mesh+360°video→ffmpeg抽帧→IP-Adapter+ControlNet重绘保留卡通风,含3个Replicate模型版本ID/schema+6 Phase任务清单+风险兜底,位于docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md +- [CL-20260420-1501] 后端装watchfiles并写入requirements,修复Windows uvicorn --reload退化为StatReload导致代码改动不热重载(view_transform 12:12改完但后端15:00仍跑旧码),踩坑PF-20260420-1501;需重启后端生效 +- [CL-20260420-1212] 修复Zero123++切图错位:jd7h部署版实际输出2列×3行而非3×2,config改cols=2/rows=3,_split_grid_image新增按实际ratio自动纠错(防御未来布局歧义),踩坑记为PF-20260420-1212 - [CL-20260417-1510] start-tunnel.ps1 修复 PS5.1 中文 here-string 解析失败:WriteAllLines 写 .env、脚本 UTF-8 BOM、同目录 cloudflared.exe 优先;根 .gitignore 忽略下载的 cloudflared.exe - [CL-20260417-1500] 按 INFOLAYER.md 一次性铺开 Phase 1+ 前端 UI 骨架:新增风格库/角色库/训练中心/项目设置四大页面模块+工作台快选条+高级控制+候选评估+Task Card+资源库多维过滤,placeholder-store.ts 管理占位数据,phase-chip 标注阶段 - [CL-20260416-1600] 视觉风格从暗绿底全面转为白底青绿(绢本山水):背景#FAFAF7+文字#1A1A1A+accent#2E8B7A,13个文件颜色替换,面板改白色毛玻璃,云雾透明度降低,VISUAL-STYLE-GUIDE.md重写 @@ -45,10 +51,4 @@ - [CL-20260413-0930] System Prompt 加入"禁止模拟工具调用"约束,防止 DeepSeek 用文字模拟生图而不调用工具 - [CL-20260413-0900] 非 vision + 参考图场景:不再绝对禁止风格词,改为"不自行猜测但保留用户明确指定的风格" - [CL-20260413-0800] 修复 InstantStyle ReadTimeout + 多次生成丢失参考图:wait 改 False 走纯轮询 + 不再清除 ref_image_url -- [CL-20260413-0730] 修复生图失败后 LLM 重试丢失参考图:ref_image_url 改为仅在成功产出图片后才清除 -- [CL-20260413-0700] 修复 InstantStyle 生图 422 + ReadTimeout:`wait=300` 超限改为 `wait=60`,SDK 超时自动 fallback 轮询 -- [CL-20260413-0630] 修复 Replicate SDK 不走代理 + 超时不够:注入 HTTPS_PROXY 到 transport + read 300s / connect 30s,解决 ConnectTimeout 连环错误 -- [CL-20260413-0600] 新增 InstantStyle 模型(强风格迁移),与 Kolors 并列为参考图模型选项,block_mode=style-only -- [CL-20260413-0540] 非 vision LLM + 参考图时禁止猜测风格,prompt 只描述内容不写画风,风格交由 IP-Adapter 处理 -- [CL-20260413-0520] 多图生成改为单次 API 调用(利用模型原生批量参数),避免速率限制 + 修复空错误信息显示 -- [CL-20260413-0500] 图片生成后在对话中显示实际使用的模型名称(如"由 Kolors IP-Adapter 生成"),便于模型溯源 +- [CL-20260413-0730] 修复生图失败后 LLM 重试丢失参考图:ref_image_url 改为仅在成功产出图片后才清除 \ No newline at end of file diff --git a/.cursor/changelog/changelog-recent.md b/.cursor/changelog/changelog-recent.md index 0074918..0fc8884 100644 --- a/.cursor/changelog/changelog-recent.md +++ b/.cursor/changelog/changelog-recent.md @@ -1,8 +1,38 @@ -# Dev Changelog — Recent +# Dev Changelog — Recent 最近 ~10 次改动的摘要记录,按时间倒序排列。 当 Agent 检测到当前任务与近期改动相关时自动读取。 +### [CL-20260420-1800] 2026-04-20 — Mesh Pipeline Phase 3 风格还原接入(IP-Adapter + ControlNet) +- **tags**: 视角变换, Mesh Pipeline, 风格还原, IP-Adapter, ControlNet, style_restorer, Phase 3 +- **affected_files**: config.py, services/style_restorer.py (新增), services/view_transform.py, scripts/test_style_restore.py (新增), VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md +- **summary**: 按 SPEC §4 Phase 3 落地 Mesh Pipeline 最后一段。在 `IMAGE_MODELS` 以 `internal=True` 注册 `chigozienri/ip_adapter-sdxl-controlnet-depth`($0.07/张 ~72s,L40S),`get_image_models_list()` 改过滤 internal 防污染前端下拉;新增 helper `get_style_restore_model_id()`。新增 `services/style_restorer.py`:`restore()` 调 Replicate(`image`=用户原图做 IP-Adapter 风格参考,`controlnet_input`=Stage 2 的 normal 帧做结构约束,即 SPEC §1.4 的"路 A"),`restore_batch()` 用 `asyncio.Semaphore(3)` 批量重绘防 Replicate rate limit。`view_transform._transform_view_mesh` 接入 Stage 3:`preserve_style=True` 时对每帧调 restore_batch,**单帧失败自动回退为 Stage 2 color 帧且不阻塞整体**(error 字段挂出原因),与"Stage 2 失败不影响 Stage 1"的独立交付设计一致;`images[i]` 新增 `color_url/restyled/restyle_error` 字段,`_stage` 升级为 `stage3 (N/M restyled)`。新增 `scripts/test_style_restore.py` 供 Checkpoint 3:支持 `--single` 冒烟($0.07)/全量($0.42)、`--scale/--cn-scale` 覆盖参数、默认自动挑最新一组 normal 帧,复用 Phase 2 产物无需再跑 Trellis。默认参 scale=0.75 / controlnet=0.8,P3-4 调参由用户验收阶段确认。一次完整 6 视角请求 ~$0.46 / ~105s。 + +### [CL-20260420-1700] 2026-04-20 — Mesh Pipeline Phase 2 抽帧 + combined_video 左右切分 +- **tags**: 视角变换, Mesh Pipeline, Trellis, ffmpeg, imageio-ffmpeg, PIL, video_frame_extractor, Phase 2 +- **affected_files**: video_frame_extractor.py (新增), view_transform.py, requirements.txt, scripts/test_frame_extract.py (新增), VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md +- **summary**: 按 SPEC §4 Phase 2 落地 Mesh Pipeline 的阶段 2 抽帧。系统 ffmpeg 未装,改用 `imageio-ffmpeg==0.5.1` 自带的 Windows 静态二进制(`ffmpeg-win64-v4.2.2.exe`,wheel 22.6MB,免系统依赖)。新增 `services/video_frame_extractor.py` 实现 `extract_by_azimuth(combined_video_path, azimuths, elevations, total_rotation_deg=360)`:策略采用"一次全帧抽取到临时目录 + 按帧索引挑 + PIL 按中线切左右半",比 `-ss` 逐帧抽更稳(免 keyframe 对齐),5s 视频成本可忽略;ffmpeg + PIL 阻塞部分用 `run_in_executor` 丢到线程池。`view_transform._transform_view_mesh` 串接 Stage1→Stage2,默认 `azimuths=[0,60,120,180,240,300]`,返回 `images[{url,azimuth,elevation,normal_url,frame_idx}]`;Stage 2 失败不会把整体置为失败(Stage 1 的 mesh/video 仍有交付价值,单独填 `error` 字段)。新增 `scripts/test_frame_extract.py` 供用户复用已有 `generated/trellis_combined_*.mp4` 做 Checkpoint 2 验收,无需再花钱跑 Trellis。Agent 已自测 120 帧 combined_video:6 视角映射到 frame_idx=0/20/40/60/80/100(完全均匀)+ 所有切图 512×512 方形。等用户 Checkpoint 2 验收后开 Phase 3(IP-Adapter+ControlNet 风格还原)。 + +### [CL-20260420-1600] 2026-04-20 — Mesh Pipeline Phase 1 接入 Trellis(阶段 1 地基) +- **tags**: 视角变换, Mesh Pipeline, Trellis, Replicate, services, config, tools, Phase 1 +- **affected_files**: mesh_generator.py (新增), view_transform.py, config.py, tools.py, scripts/test_trellis.py (新增), VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md +- **summary**: 按 SPEC §4 Phase 1 落地 Mesh Pipeline 第二条视角变换管道的阶段 1。新增 `services/mesh_generator.py` 封装 Trellis 调用(`firtoz/trellis:e8f6c452...`)+ httpx 下载 `.glb/color_video/normal_video` 到 `generated/`;`config.py` 在 `VIEW_TRANSFORM_MODELS` 注册 `trellis`(enabled)和 `hunyuan3d`(enabled=False 占位),给所有条目加 `pipeline` 字段;`view_transform.py` 按 `pipeline` 分流 grid/mesh,mesh 阶段 1 只返回 mesh_url + color_video + normal_video,images 暂空;`tools.py` 的 `transform_view` 工具扩 `model_id/azimuths/preserve_style`。另新增 `scripts/test_trellis.py` 作为独立验收入口。Zero123++ 现有行为完全不变。等用户手跑脚本验收后开 Phase 2(ffmpeg 抽帧)。 + +### [CL-20260420-1530] 2026-04-20 — 写入 Mesh-Pipeline 视角变换方案 SPEC(跨会话实施计划) +- **tags**: 视角变换, Mesh Pipeline, Trellis, Hunyuan3D-2, SPEC, 方案 +- **affected_files**: docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md +- **summary**: 针对"Zero123++ 只出 6 固定视角 + 建筑大角度易崩"的痛点,规划第二条视角变换路径 Mesh Pipeline。三段式:Trellis 生 .glb + 360° color_video($0.041/30s) → ffmpeg 按 azimuth 抽帧 → `chigozienri/ip_adapter-sdxl-controlnet-depth` 用原图做 IP-Adapter 风格还原。文档含三个 Replicate 模型的版本 ID/OpenAPI schema、config 扩展设计、6 个 Phase 可独立执行任务清单、跨会话恢复指引。Hunyuan3D-2 + pyrender 自由相机留作 Phase 5 二期可选。一期工程量 ~5h,单次总成本 ~$0.46/~105s。 + +### [CL-20260420-1501] 2026-04-20 — 后端增加 watchfiles 依赖,修复 Windows uvicorn --reload 失效 +- **tags**: uvicorn, watchfiles, 热重载, Windows, reload, requirements +- **affected_files**: requirements.txt, requirements-lock.txt +- **summary**: 排查"Zero123++ 切图还是错位"时发现:CL-20260420-1212 的修复在磁盘上完全正确,但后端 uvicorn 进程是 11:55 启动从未 reload。根因是 venv 里 `watchfiles` 和 `watchdog` 都没装,`--reload` 退化成 StatReload 轮询式 reloader,在 Windows + 深层子目录 + 新建文件 (`view_transform.py`) 场景下基本失效。解决:pip 安装 `watchfiles==0.24.0` 并写入 requirements 两个文件。**仅装依赖不会对当前进程生效,必须 kill + 重启后端**。踩坑记为 PF-20260420-1501。 + +### [CL-20260420-1212] 2026-04-20 — 修复 Zero123++ 视角切图布局反转(3×2→2×3) +- **tags**: Zero123++, 视角变换, 切图, bug修复, grid_layout +- **affected_files**: config.py, view_transform.py +- **summary**: 用户反馈 `transform_view` 输出的 6 张视角图严重错位(每张呈窄长条、上下拼两个物体)。根因是 `jd7h/zero123plusplus` Replicate 部署版实际输出 **2 列 × 3 行** 的高图,但 `config.py` 里按官方 README "3×2 grid" 字面意思配成了 `cols=3, rows=2`。修复:(1) `grid_layout` 改为 `{"cols": 2, "rows": 3}`;(2) `_split_grid_image` 新增健壮性保护——假定单视角正方形,若配置 ratio 与实际图像 ratio 差异 >20%,自动交换 cols/rows 并打 warning,让未来换其他视角变换模型时免疫类似布局歧义。踩坑已记录 PF-20260420-1212。 + ### [CL-20260417-1510] 2026-04-17 — Cloudflare 穿透脚本兼容 PS5.1 + 本地 cloudflared - **tags**: 内网穿透, cloudflared, PowerShell, start-tunnel, env - **affected_files**: art-agent/start-tunnel.ps1, .gitignore @@ -72,249 +102,3 @@ - **tags**: bug修复, 拖拽, Gemini, 向量引擎, images.edit, config - **affected_files**: chat-input.tsx, config.py, image_gen.py - **summary**: 三个问题修复:(1) 拖拽到 ChatInput 区域时事件冒泡导致 page.tsx 和 ChatInput 各触发一次上传 → ChatInput handleDrop/handleDragOver 加 stopPropagation;(2) 向量引擎 Gemini 图片编辑走原生 generateContent 接口而非 OpenAI 兼容 images/edits,对 Gemini 调用 images.edit 返回 500 → Gemini 改回 supports_ref_image=False;(3) images.edit 端点不支持 quality 参数 → 从 _edit_with_refs 中移除。 - -### [CL-20260416-0600] 2026-04-16 — 多张参考图全链路支持 -- **tags**: 前端, 后端, 参考图, 多图, OpenAI, images.edit, Provider, API, Agent Loop -- **affected_files**: chat-input.tsx, page.tsx, chat-messages.tsx, api.ts, types.ts, chat.py, loop.py, tools.py, image_gen.py, config.py -- **summary**: 从单张参考图扩展为多张参考图全链路支持。前端:ChatInput 改为多图状态数组+file input multiple+多图预览/删除/拖拽;API层 sendChat 改为 ref_image_urls: string[];消息气泡和沿用逻辑适配多图。后端:chat.py 新增 ref_image_urls JSON 数组参数(兼容旧 ref_image_url);Agent Loop 多图注入 vision LLM 上下文;OpenAIImageProvider 有参考图时改用 images.edit 端点(支持最多16张);Replicate 取首张兼容;GPT Image 1.5 和 Gemini 注册表标记 supports_ref_image=True。 - -### [CL-20260416-0510] 2026-04-16 — 生图模型新增 Gemini 3.1 Flash Image -- **tags**: 后端, 生图模型, Gemini, 向量引擎, config -- **affected_files**: config.py, .env -- **summary**: IMAGE_MODELS 注册表新增 gemini-3.1-flash-image(model_id: gemini-3.1-flash-image-preview),provider 为 openai,复用 OpenAIImageProvider 走向量引擎中转。无需新增 Provider 代码。 - -### [CL-20260416-0500] 2026-04-16 — 全区域拖拽添加参考图 -- **tags**: 前端, UX, 拖拽, 参考图, 上传 -- **affected_files**: page.tsx, chat-input.tsx -- **summary**: 拖拽上传参考图区域从底部输入栏扩展到整个对话区域。ChatInput 改为 forwardRef 暴露 uploadFile 方法,main 区域处理拖放事件并调用。拖入时显示全屏覆盖层提示,用 dragCounter 防止子元素冒泡导致闪烁。 - -### [CL-20260416-0430] 2026-04-16 — 接入 GPT Image 1.5 生图模型(向量引擎中转) -- **tags**: 后端, 生图模型, GPT-Image, OpenAI, 向量引擎, provider -- **affected_files**: image_gen.py, config.py, .env -- **summary**: 新增 OpenAIImageProvider,通过向量引擎 API 中转调用 `/v1/images/generations` 端点。IMAGE_MODELS 注册表新增 gpt-image-1.5(provider: openai),支持 size/quality 参数和 URL/base64 双格式返回。 - -### [CL-20260416-0400] 2026-04-16 — Session 级参考图自动沿用 + 缺参考图前置校验 -- **tags**: 前端, 后端, 参考图, UX, InstantStyle, 风格迁移 -- **affected_files**: page.tsx, chat-input.tsx, image_gen.py -- **summary**: 用户发送带参考图的消息后,前端在 session 级别记住该参考图 URL,后续消息自动沿用(输入框显示"沿用上次参考图"提示条,可清除)。解决切换 InstantStyle 后无参考图导致 "No input, Save money" 的问题。后端对需要参考图但未收到的模型统一返回友好错误。 - -### [CL-20260416-0300] 2026-04-16 — System Prompt 禁止 LLM 嵌入图片链接 -- **tags**: 后端, agent-loop, system-prompt, LLM行为约束 -- **affected_files**: loop.py -- **summary**: LLM(GPT 系列)在收到 generate_image 工具返回的本地路径后,自行拼凑 `sandbox:/generated/xxx.png` Markdown 图片链接。前端图片展示由 image_result SSE 事件独立处理,文字中的链接无效且多余。在 System Prompt 注意事项中新增禁止嵌入图片 Markdown 的约束。 - -### [CL-20260416-0245] 2026-04-16 — 修复三点菜单 z-index 层级问题 -- **tags**: 前端, UI, sidebar, 菜单, z-index -- **affected_files**: session-list.tsx -- **summary**: 三点菜单原用 fixed 定位在侧栏右侧弹出,被主内容区遮挡。改为 inline 在会话项正下方展开,模型子菜单改为折叠式内联列表(带 max-h 滚动),全部在侧栏内部完成,不再溢出。移除了 menuPos 状态和 fixed 定位逻辑。 - -### [CL-20260416-0230] 2026-04-16 — LLM 注册表扩充 + 默认模型改 GPT-5.4 -- **tags**: 后端, config, LLM, 模型注册表 -- **affected_files**: config.py, .env -- **summary**: LLM_MODELS 从 3→7 个:新增 gpt-5.4、claude-sonnet-4-6、claude-opus-4-6、gemini-3.1-pro-preview、glm-4.7,全部走 vectorengine provider。默认模型从 gpt-4o-mini 改为 gpt-5.4。gpt-4o-mini/deepseek-chat 保留。 - -### [CL-20260416-0200] 2026-04-16 — 接入向量引擎中转 API + 对话级 LLM 模型切换 -- **tags**: 后端, 前端, LLM, 向量引擎, 中转API, 模型切换, config, UI, session -- **affected_files**: config.py, loop.py, chat.py, .env, types.ts, api.ts, app-context.tsx, session-list.tsx, page.tsx -- **summary**: 新增 LLM_MODELS 注册表(gpt-4o-mini/gpt-4o/deepseek-chat),provider 分发为 vectorengine(中转)和 deepseek(直连)。loop.py 改为按 provider 创建 AsyncOpenAI 客户端,vision 检测从注册表读取。chat.py 新增 llm_model 参数和 GET /api/llm-models 端点。前端 Session 新增 llmModel 字段,侧栏三点菜单支持模型选择子菜单,sendChat 传递 llm_model。 - -### [CL-20260416-0030] 2026-04-16 — 青绿山水风格改造:千里江山图色调 + 云烟雾气动效 -- **tags**: 前端, UI改造, 视觉风格, 青绿山水, 千里江山图, CSS动画, 色彩体系, 云烟 -- **affected_files**: globals.css, top-nav.tsx, sidebar.tsx, session-list.tsx, chat-messages.tsx, chat-input.tsx, image-grid.tsx, image-detail-panel.tsx, profile-modal.tsx, page.tsx, gallery/page.tsx, login/page.tsx, VISUAL-STYLE-GUIDE.md -- **summary**: 全站从 Cyberpunk 霓虹改为青绿山水风格。色彩替换为石青绿(#4DB8A4)/石绿蓝(#3A8FB7)/墨绿底色(#0C1210)。背景改为双层漂移云雾(60s+45s)。.glow-border hover 效果柔化为云烟缭绕(6s/宽弧段/blur 12px)。.glass-panel 加绿底渐变+增强模糊。新增 .fog-scroll 雾气滚动边缘。11 个组件硬编码颜色全部替换。VISUAL-STYLE-GUIDE.md 完全重写。 - -### [CL-20260415-2330] 2026-04-15 — 用户个人信息 + 记忆查看面板:右上角菜单弹窗 -- **tags**: 前端, 后端, 用户信息, 记忆系统, Mem0, Modal, ProfileModal -- **affected_files**: api/memory.py, main.py, profile-modal.tsx, top-nav.tsx -- **summary**: 后端新建 GET /api/memory/list 端点(Mem0 get_all + run_in_executor),前端新建 ProfileModal 组件(glass-panel 风格居中弹窗),展示用户信息 + 按时间分组的记忆列表(只读)。TopNav 的 username 行改为可点击按钮,点击打开弹窗。 - -### [CL-20260415-2230] 2026-04-15 — 光影流动边框效果:导航活动项 + 活动会话项 -- **tags**: 前端, CSS动画, 视觉效果, conic-gradient, 霓虹边框 -- **affected_files**: globals.css, top-nav.tsx, session-list.tsx, sidebar.tsx -- **summary**: 用 @property + conic-gradient 实现旋转光影边框效果。双伪元素方案:::before 做渐变边框(mask-composite 裁内部),::after 做模糊扩散光晕。提供 .glow-border(动画)和 .glow-border-static(hover 静态发光)两个类,分别应用到导航活动 Tab 和活动会话项。 - -### [CL-20260415-2200] 2026-04-15 — 全站 UI 风格改造:Cyberpunk Dark Neon 主题 -- **tags**: 前端, UI改造, 视觉风格, Cyberpunk, Neon, 毛玻璃, 全局样式 -- **affected_files**: globals.css, page.tsx, gallery/page.tsx, login/page.tsx, top-nav.tsx, sidebar.tsx, session-list.tsx, tag-filter.tsx, chat-messages.tsx, chat-input.tsx, image-grid.tsx, model-selector.tsx, image-detail-panel.tsx, VISUAL-STYLE-GUIDE.md -- **summary**: 参考 SolCasino Dribbble 设计,全站从朴素暗色改为 Cyberpunk Dark Neon 风格。主色从紫色改为青绿霓虹(#00E5A0),新增环境光晕背景、毛玻璃面板(backdrop-blur)、霓虹发光边框。所有组件统一升级圆角和过渡效果,图片卡片加 hover 缩放+发光。同时编写了完整的视觉风格文档。 - -### [CL-20260414-2350] 2026-04-14 — 修复登录后 useApp must be used within AppProvider 报错 -- **tags**: bug修复, 前端, 认证, AppProvider, 初始化时序 -- **affected_files**: app-context.tsx -- **summary**: AppProvider 在 `!initialized` 时不提供 context 但渲染了需要 useApp() 的子组件。修复:已认证但未初始化时显示加载状态,未认证时才渲染裸 children(/login)。 - -### [CL-20260414-2340] 2026-04-14 — 用 pwdlib (Argon2id) 替换 passlib (bcrypt),彻底解决兼容性问题 -- **tags**: 重构, 安全, 密码哈希, Argon2, pwdlib, passlib, 依赖升级 -- **affected_files**: auth.py, requirements.txt, docs/art-agent/USER-SYSTEM.md -- **summary**: passlib 已停止维护且与 bcrypt 5.x 不兼容。替换为 pwdlib[argon2,bcrypt],新密码用 Argon2id,旧 bcrypt 哈希仍可验证。不再需要锁定 bcrypt 版本。 - -### [CL-20260414-2330] 2026-04-14 — 修复 passlib + bcrypt 5.x 兼容性问题,锁定 bcrypt<4.1 -- **tags**: bug修复, 依赖, bcrypt, passlib, 用户系统 -- **affected_files**: requirements.txt, docs/art-agent/USER-SYSTEM.md -- **summary**: passlib 1.7.4 与 bcrypt 5.0.0 不兼容(`__about__` 移除 + 密码长度 ValueError),后端启动报错。降级 bcrypt 到 4.0.1 并在 requirements.txt 中锁定 `>=4.0.1,<4.1`。同时在用户系统文档故障排除中补充此问题。 - -### [CL-20260414-2300] 2026-04-14 — 用户系统:管理员邀请制 + JWT 认证 + 数据按用户隔离 -- **tags**: 用户系统, 认证, JWT, SQLite, 数据隔离, 多用户, 安全 -- **affected_files**: db.py, auth.py, api/auth.py, api/admin.py, chat.py, main.py, loop.py, auth-context.tsx, auth-guard.tsx, api.ts, store.ts, app-context.tsx, layout.tsx, login/page.tsx, top-nav.tsx, model-selector.tsx, requirements.txt, .env.example -- **summary**: 后端新增 SQLite 用户表 + JWT 认证(access+refresh)+ 管理员/普通用户路由,所有 API 加鉴权,Mem0 user_id 改为真实用户 ID。前端新增登录页 + AuthProvider/AuthGuard + 401 自动跳转 + localStorage 按用户 ID 隔离。TopNav 新增用户菜单。首次启动自动创建 admin。 - -### [CL-20260414-2230] 2026-04-14 — 对话区域新增"回到底部"浮动按钮 -- **tags**: 前端, UX, 滚动, 浮动按钮 -- **affected_files**: art-agent/frontend/src/app/page.tsx -- **summary**: 长对话中不在底部时,右下角显示向下箭头按钮,点击平滑滚到最新消息。复用 scroll 监听器检测距底部距离(>200px 显示),切换会话后也同步按钮状态。 - -### [CL-20260414-2220] 2026-04-14 — 修复长对话切换回来滚动位置上移:scroll 事件实时保存取代 effect 延迟保存 -- **tags**: 前端, bug修复, 滚动, 多会话, UX, scroll 事件 -- **affected_files**: art-agent/frontend/src/app/page.tsx -- **summary**: effect 中保存 scrollTop 时 DOM 已变为短对话内容,浏览器 clamp scrollTop 导致保存值偏小。改用 scroll 事件实时记录位置,确保在任何 DOM 变更之前数据已正确保存。 - -### [CL-20260414-2210] 2026-04-14 — 修复会话切换时滚动位置跳动:时序竞争 + 抑制切换期间 smooth scroll -- **tags**: 前端, bug修复, 滚动, 多会话, UX, 时序 -- **affected_files**: art-agent/frontend/src/app/page.tsx -- **summary**: CL-20260414-2200 的 rAF 恢复时序不可靠——React 可能还没渲染新会话内容,scrollHeight 是旧值。拆为两个 effect(保存+标记 / 等 messages 更新后恢复),新增 `isSwitching` 抑制切换期间的 smooth scroll 干扰,instant 模式改为同步赋值 `scrollTop`。 - -### [CL-20260414-2200] 2026-04-14 — 修复多会话滚动位置共享 bug:per-session 独立记录 + 切换恢复 -- **tags**: 前端, bug修复, 滚动, 多会话, UX -- **affected_files**: art-agent/frontend/src/app/page.tsx -- **summary**: 所有会话共用同一 scrollRef,切换时不保存/恢复 scrollTop。修复:新增 `scrollPositions` Map 按 sessionId 记录滚动位置,`useEffect` 监听 `activeSessionId` 变化时保存旧位置、恢复新位置(有记录则恢复,无记录则 scrollToBottom)。`scrollToBottom` 改为 useCallback + 支持 instant 模式。 - -### [CL-20260413-2320] 2026-04-13 — 集成 Mem0 记忆系统:滑动窗口 + 跨会话长期记忆 -- **tags**: Mem0, 记忆系统, 上下文管理, 长期记忆, Ollama, embedding, 滑动窗口 -- **affected_files**: app/memory.py, app/agent/loop.py, app/config.py, app/api/chat.py, requirements.txt, .env, frontend/src/lib/api.ts, frontend/src/app/page.tsx -- **summary**: 集成 Mem0 OSS 作为统一记忆方案。DeepSeek 做事实提取,Ollama nomic-embed-text 做本地 embedding,Qdrant 文件模式做向量存储。agent loop 新增滑动窗口(MAX_RECENT_TURNS=20)、记忆检索注入 system prompt、异步事实存储。前端传递 session_id 实现会话级记忆作用域。不做静默降级,所有错误显式报告。 - -### [CL-20260413-1130] 2026-04-13 — 修复 SDXL 模型 404 错误:补全 Replicate 版本 hash -- **tags**: bug修复, Replicate, SDXL, 模型配置, 404 -- **affected_files**: art-agent/backend/app/config.py -- **summary**: SDXL 调用 Replicate 404,根因同 Kolors(CL-20260413-0440):非 Flux 官方模型需要 `owner/model:version_hash` 完整格式。将 `stability-ai/sdxl` 补全为 `stability-ai/sdxl:39ed52f2...e08b`。 - -### [CL-20260413-1100] 2026-04-13 — 修复切换模型后 LLM 仍声称使用旧模型名 -- **tags**: Agent Loop, system prompt, 模型选择, LLM 幻觉 -- **affected_files**: art-agent/backend/app/agent/loop.py -- **summary**: System Prompt 不含模型信息,LLM 从对话历史幻觉旧模型名。修复:在 system prompt 末尾动态注入当前模型名 + 强调忽略历史中的旧记录,假生成重试消息也附带模型名。debug 日志验证传递链路正确,问题仅在 LLM 文字层。 - -### [CL-20260413-1030] 2026-04-13 — Agent Loop 假生成检测 + 自动重试机制 -- **tags**: Agent Loop, DeepSeek, function calling, 防幻觉, 自动修复 -- **affected_files**: art-agent/backend/app/agent/loop.py -- **summary**: DeepSeek 无视 prompt 约束,仍用文字模拟生图。新增代码层面检测:LLM 纯文字回复中命中"已生成"等关键词 >= 2 次时,自动注入纠正消息强制重试调用工具。重试消耗一次迭代配额。 - -### [CL-20260413-1000] 2026-04-13 — 助手消息气泡添加复制文本按钮 -- **tags**: 前端, UX, chat-messages, 复制 -- **affected_files**: art-agent/frontend/src/components/chat/chat-messages.tsx -- **summary**: 助手回复气泡下方新增复制文本按钮(hover 显示),点击通过 clipboard API 复制文本并显示 1.5s 对勾反馈。只对 assistant 消息展示。 - -### [CL-20260413-0930] 2026-04-13 — System Prompt 加入"禁止模拟工具调用"约束 -- **tags**: prompt 工程, system prompt, DeepSeek, function calling, 防幻觉 -- **affected_files**: art-agent/backend/app/agent/loop.py -- **summary**: DeepSeek 在多步生图任务中有时不调用工具而是用文字模拟"已生成",导致前端收不到图片。在 System Prompt 注意事项中加入硬性约束:"生成图片必须调用 generate_image 工具,禁止用文字模拟"。 - -### [CL-20260413-0900] 2026-04-13 — 非 vision + 参考图场景:尊重用户主动指定的风格意图 -- **tags**: prompt 工程, IP-Adapter, 风格, system prompt, UX -- **affected_files**: art-agent/backend/app/agent/loop.py -- **summary**: SYSTEM_PROMPT 和非 vision hint 中对风格关键词的禁止从"绝对禁止"改为"不自行猜测,但用户明确指定则保留到 prompt 中"。之前的绝对性措辞导致 LLM 即使用户主动写了"赛博朋克风"也会丢弃。修正后 IP-Adapter 风格迁移与用户文字风格可叠加。 - -### [CL-20260413-0800] 2026-04-13 — 修复 InstantStyle ReadTimeout + 多次生成丢失参考图 -- **tags**: bug修复, Replicate, InstantStyle, timeout, 参考图, Agent Loop, wait -- **affected_files**: art-agent/backend/app/services/image_gen.py, art-agent/backend/app/agent/loop.py -- **summary**: 运行时日志证实两个问题:(1) `wait=60` 时 SDK 内部 `read=60.5s` timeout 覆盖客户端 300s timeout,上传 1.6MB base64 + 等待初始响应超过 60s → ReadTimeout。改为 `wait=False`,create 请求立即返回 prediction ID,SDK 自动走 `prediction.async_wait()` 轮询,轮询用客户端级 read=300s timeout。(2) 成功生成后清除 ref_image_url 导致后续工具调用丢失参考图(InstantStyle 返回 "No input, Save money")。完全移除清除逻辑,ref_image_url 在整个对话期间保持有效。 - -### [CL-20260413-0730] 2026-04-13 — 修复生图失败后 LLM 重试丢失参考图 -- **tags**: bug修复, Agent Loop, 参考图, InstantStyle, ref_image_url -- **affected_files**: art-agent/backend/app/agent/loop.py -- **summary**: LLM 第一次生图工具调用失败后重试时,参考图丢失。InstantStyle 返回 "No input, Save money"。根因:`loop.py` 在每轮工具调用结束后无条件清除 `ref_image_url`。修复:改为只在本轮至少有一次成功产出图片时才清除,失败时保留参考图供 LLM 重试。 - -### [CL-20260413-0700] 2026-04-13 — 修复 InstantStyle 生图 422 + ReadTimeout:wait 参数超限 -- **tags**: bug修复, Replicate, InstantStyle, API参数, async_run, timeout -- **affected_files**: art-agent/backend/app/services/image_gen.py -- **summary**: InstantStyle 生图 422(`Prefer: wait=x must be 1-60`)+ ReadTimeout(61.4s)。两个问题同根因:`async_run(wait=300)` 超出 API 限制,改为 `wait=True` 后 SDK 内部 60.5s timeout 覆盖了客户端 300s timeout。最终改为 `wait=60`(API 最大合法值),初始请求 60s 内未完成则 SDK 自动 fallback 到异步轮询,轮询用客户端级 read=300s timeout。 - -### [CL-20260413-0630] 2026-04-13 — 修复 Replicate SDK 不走代理 + 超时不够 -- **tags**: bug修复, Replicate, InstantStyle, 代理, httpx, 超时 -- **affected_files**: art-agent/backend/app/services/image_gen.py -- **summary**: Replicate SDK 内部显式传 `transport` 给 httpx,绕过了环境变量代理配置导致 ConnectTimeout,且默认 read 30s 不够 InstantStyle 的 ~128s。修复:`_make_replicate_client()` 工厂函数从 `HTTPS_PROXY` 读取代理注入 `AsyncHTTPTransport(proxy=...)`,read 300s / connect 30s。`_download_image` 同步加了代理。 - -### [CL-20260413-0600] 2026-04-13 — 新增 InstantStyle 模型:强风格迁移选项 -- **tags**: 模型注册, InstantStyle, 风格迁移, IP-Adapter -- **affected_files**: art-agent/backend/app/config.py -- **summary**: 注册 InstantStyle(`jyoung105/instant-style`)到模型注册表,`block_mode: "style-only"` + `style_strength: 1.0`,通过 `ref_image_param: "style_image"` 适配现有抽象。与 Kolors 并列为参考图模型,提供强风格迁移能力。 - -### [CL-20260413-0540] 2026-04-13 — 非 vision LLM + 参考图时禁止猜测风格 -- **tags**: prompt 工程, IP-Adapter, 风格一致性, system prompt -- **affected_files**: art-agent/backend/app/agent/loop.py -- **summary**: 修改 system prompt 和非 vision 模型的参考图提示,明确禁止猜测画风。LLM 只描述画面内容,风格交给 IP-Adapter 从参考图提取。解决了 DeepSeek 看不到参考图时脑补 "pixel art" 导致生成结果与参考图风格不一致的问题。 - -### [CL-20260413-0520] 2026-04-13 — 多图生成改为单次 API 调用 + 修复空错误信息 -- **tags**: 性能优化, Replicate, 速率限制, 错误处理, 模型配置 -- **affected_files**: art-agent/backend/app/services/image_gen.py, art-agent/backend/app/config.py -- **summary**: 多图生成从 for 循环改为单次 API 调用(新增 `num_images_param` 配置),避免低余额账户的速率限制。同时修复空异常时 `[生成失败: ]` 信息丢失的问题,改为显示异常类型名和 repr。 - -### [CL-20260413-0500] 2026-04-13 — 生成图片后显示使用的模型名称 -- **tags**: 模型溯源, SSE, 图片生成, 前端展示 -- **affected_files**: art-agent/backend/app/services/image_gen.py, art-agent/backend/app/agent/tools.py, art-agent/backend/app/agent/loop.py, art-agent/frontend/src/lib/types.ts, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/components/chat/chat-messages.tsx -- **summary**: `generate_images` 返回 `GenerateResult`(含 model_name),SSE 事件携带模型名称,前端在图片下方和错误提示中展示。ChatMessage 新增 `modelName` 字段,归档后仍可溯源。 - -### [CL-20260413-0440] 2026-04-13 — 修复 Kolors IP-Adapter 模型 404 错误:补全 Replicate 版本 hash -- **tags**: bug修复, Replicate, Kolors, IP-Adapter, 模型配置 -- **affected_files**: art-agent/backend/app/config.py -- **summary**: Kolors IP-Adapter 生图 404 的根因是 Replicate SDK 对社区模型需要 `owner/model:version_hash` 格式。在 config.py 中补全 kolors-ipadapter 的版本 hash 后,实测生图成功。 - -### [CL-20260413-0430] 2026-04-13 — 图片生成失败时将具体错误信息透传到前端 -- **tags**: 错误处理, Agent Loop, SSE, 调试体验 -- **affected_files**: art-agent/backend/app/agent/loop.py, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/lib/api.ts -- **summary**: 新增 `tool_error` SSE 事件类型。Agent Loop 在工具执行产生错误时,额外 yield 一个 tool_error 事件携带具体报错信息;前端处理该事件并以 ⚠️ 前缀展示在对话流中。解决了之前工具失败时用户只能看到 LLM 模糊翻译、无法获取真实错误详情的问题。 - -### [CL-20260413-0330] 2026-04-13 — README 补充访问地址汇总 + 穿透管理文档 -- **tags**: 文档, README, 穿透, Cloudflare, cloudflared -- **affected_files**: art-agent/README.md -- **summary**: README 新增"访问地址"章节(本机/局域网/外网三种场景表格),重写"远程访问(外网穿透)"章节,补充 cloudflared 安装指引(winget + 手动下载)、穿透管理操作表(启动/停止/查看状态/重启)、代理配置排错说明。 - -### [CL-20260412-2345] 2026-04-12 — 参考图上传独立化:进度条 + 状态反馈 + 失败重试 -- **tags**: 前端, 后端, 参考图, 上传, UX, 进度反馈 -- **affected_files**: art-agent/backend/app/api/chat.py, art-agent/frontend/src/lib/api.ts, art-agent/frontend/src/components/chat/chat-input.tsx, art-agent/frontend/src/app/page.tsx -- **summary**: 参考图从"随消息提交"改为"选中即独立上传"。后端新增 POST /api/upload-ref-image 端点;前端用 XMLHttpRequest 获取上传进度事件,ChatInput 显示环形进度条、成功/失败角标、失败可重选。sendChat 参数从 File 改为服务端路径。同时修复 file input 多次上传不重置的问题。 - -### [CL-20260412-2315] 2026-04-12 — 修复 Cloudflare Tunnel 跨域开发警告 -- **tags**: 前端, 配置, Next.js, Cloudflare, 跨域 -- **affected_files**: art-agent/frontend/next.config.ts -- **summary**: Next.js 检测到来自 trycloudflare.com 的跨域请求发出警告。在 next.config.ts 中添加 `allowedDevOrigins: ["*.trycloudflare.com"]` 消除警告,通配符覆盖所有 Quick Tunnel 随机域名。 - -### [CL-20260412-2300] 2026-04-12 — 集成 Kolors IP-Adapter,参考图可直接用于风格生成 -- **tags**: 后端, 前端, IP-Adapter, 风格迁移, 参考图, 模型注册, Replicate, 架构 -- **affected_files**: art-agent/backend/app/config.py, art-agent/backend/app/services/image_gen.py, art-agent/backend/app/agent/loop.py, art-agent/frontend/src/lib/types.ts, art-agent/frontend/src/components/chat/model-selector.tsx -- **summary**: 集成 Kolors IP-Adapter 模型(fofr/kolors-with-ipadapter,~$0.004/次),参考图通过 base64 data URI 直接传给 Replicate 的 IP-Adapter 参与风格生成。模型注册表新增 supports_ref_image / ref_image_param 通用字段,新增 IP-Adapter 模型只需加配置。to_data_uri 抽为公共函数供 loop.py 和 image_gen.py 共用。前端模型列表显示绿色"参考图"徽章。 - -### [CL-20260412-2230] 2026-04-12 — 用户消息气泡中显示参考图缩略图 -- **tags**: 前端, 参考图, UX, chat-messages -- **affected_files**: art-agent/frontend/src/lib/types.ts, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/components/chat/chat-messages.tsx -- **summary**: 用户上传参考图后发送消息,之前聊天气泡中不显示参考图,无法确认是否成功附带。修复:ChatMessage 类型新增 refImageUrl 字段,page.tsx 构造用户消息时用 URL.createObjectURL 保存预览 URL,chat-messages.tsx 在用户消息气泡中渲染缩略图(最大 160×120px)。Blob URL 仅当前会话有效,不持久化。 - -### [CL-20260412-2200] 2026-04-12 — 修复 DeepSeek 不支持 image_url 导致参考图上传报错 -- **tags**: bug修复, 后端, Agent Loop, vision, DeepSeek, 多模态 -- **affected_files**: art-agent/backend/app/agent/loop.py -- **summary**: 上传参考图时使用 OpenAI Vision 格式(`image_url` content type)发送给 DeepSeek API,但 DeepSeek 只支持 `text` 类型,导致 400 错误。修复:新增 vision 能力检测(基于模型名称关键词),不支持 vision 的模型改为文字提示方式告知 LLM 有参考图,参考图仍传给图像生成工具处理。 - -### [CL-20260412-2130] 2026-04-12 — 修复图片生成失败时前端显示破碎图标 -- **tags**: bug修复, 前端, 后端, 图像生成, 错误处理 -- **affected_files**: art-agent/backend/app/agent/tools.py, art-agent/frontend/src/app/page.tsx -- **summary**: Replicate API 调用失败(如余额不足 402)时,错误字符串 `[生成失败: ...]` 被混入 images 列表传给前端,导致 `` 标签渲染破碎图标。修复:后端 tools.py 分离有效 URL 和错误信息为 images/errors 两个字段;前端 page.tsx 过滤掉以 `[` 开头的无效 URL,无有效图片时不渲染图片网格。 - -### [CL-20260412-2100] 2026-04-12 — 生图模型动态切换:Provider 抽象 + 前端模型选择器 -- **tags**: 后端, 前端, 图像生成, 模型切换, Provider, 重构, Replicate -- **affected_files**: art-agent/backend/app/config.py, art-agent/backend/app/services/image_gen.py, art-agent/backend/app/api/chat.py, art-agent/backend/app/agent/loop.py, art-agent/backend/app/agent/tools.py, art-agent/backend/.env, art-agent/backend/.env.example, art-agent/frontend/src/lib/types.ts, art-agent/frontend/src/lib/api.ts, art-agent/frontend/src/components/chat/model-selector.tsx, art-agent/frontend/src/components/chat/chat-input.tsx, art-agent/frontend/src/app/page.tsx -- **summary**: 将图像生成从 Replicate 硬绑定重构为 Provider 抽象层。后端:config.py 新增 IMAGE_MODELS 注册表(flux-schnell / flux-dev / SDXL),image_gen.py 引入 ImageProvider 基类 + ReplicateProvider 实现,chat.py 新增 GET /api/models + POST /api/chat 增加 image_model 参数,agent loop / tools 全链路透传。前端:新建 ModelSelector 下拉组件集成到 ChatInput,每条消息可选不同模型,选择持久化到 localStorage。 - -### [CL-20260412-1800] 2026-04-12 — 移动端适配 + Cloudflare Tunnel 内网穿透 -- **tags**: 前端, 响应式, 移动端, 部署, Cloudflare, 穿透, 配置 -- **affected_files**: art-agent/frontend/src/app/layout.tsx, art-agent/frontend/src/app/globals.css, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/app/gallery/page.tsx, art-agent/frontend/src/components/sidebar/sidebar.tsx, art-agent/frontend/src/components/layout/top-nav.tsx, art-agent/frontend/src/components/detail/image-detail-panel.tsx, art-agent/frontend/src/components/chat/chat-messages.tsx, art-agent/frontend/src/components/chat/chat-input.tsx, art-agent/frontend/src/components/chat/image-grid.tsx, art-agent/frontend/src/lib/app-context.tsx, art-agent/frontend/next.config.ts, art-agent/start-tunnel.ps1, art-agent/README.md -- **summary**: 为实现"手机浏览器远程访问"做了两大块工作。(1) 移动端响应式适配:侧边栏改为固定定位抽屉式(md 以下),顶栏新增汉堡菜单,图片详情面板移动端全屏 overlay,气泡/输入框/Gallery 网格间距微调,hover 操作在移动端始终可见,默认小屏折叠侧边栏。(2) 穿透方案:安装 cloudflared,编写 start-tunnel.ps1 一键脚本自动创建双隧道 + 更新 .env.local + 退出时恢复。Next.js 图片白名单加 trycloudflare.com。uvicorn 改绑 0.0.0.0。路线 B(云服务器部署)记为延期方案。 - -### [CL-20260412-1530] 2026-04-12 — 修复 .env 配置加载时序问题 -- **tags**: 后端, bug修复, 配置, dotenv, Python -- **affected_files**: art-agent/backend/app/main.py, art-agent/backend/app/config.py, art-agent/backend/app/agent/loop.py, art-agent/backend/app/services/image_gen.py, art-agent/backend/.env -- **summary**: 切换 DeepSeek 后报 "Model Not Exist"。根因:(1) load_dotenv() 在业务模块 import 之后执行,config.py 的模块级变量在 import 时就固化为默认值 gpt-4o-mini;(2) load_dotenv 默认 override=False 不覆盖已有环境变量。修复:load_dotenv(override=True) 提到所有 import 之前;config.py 常量改为函数式懒读取;.env base_url 改为不带 /v1。 - -### [CL-20260412-1500] 2026-04-12 — 品牌重命名 EPEEKit + API 配置集中化 -- **tags**: EPEEKit, 品牌, 配置, 后端, 前端, 重构 -- **affected_files**: art-agent/frontend/src/app/layout.tsx, art-agent/frontend/src/components/layout/top-nav.tsx, art-agent/frontend/src/app/page.tsx, art-agent/frontend/src/lib/store.ts, art-agent/frontend/src/app/gallery/page.tsx, art-agent/frontend/src/components/detail/image-detail-panel.tsx, art-agent/frontend/src/components/chat/image-grid.tsx, art-agent/frontend/package.json, art-agent/backend/app/main.py, art-agent/backend/app/config.py, art-agent/backend/app/agent/loop.py, art-agent/backend/app/services/image_gen.py, art-agent/backend/.env, art-agent/backend/.env.example, art-agent/README.md -- **summary**: 应用正式命名为 "EPEEKit",全部用户可见文案 + 文件名前缀 + localStorage 键名统一替换。后端新增 config.py 集中管理 LLM_MODEL、IMAGE_MODEL 等原硬编码配置,.env 重组为带注释的分组结构,切换 LLM 只需改 3 个环境变量。同时新增 .env.example 模板和 Logo SVG。 - diff --git a/.cursor/pitfalls/pitfalls.md b/.cursor/pitfalls/pitfalls.md index 6d2f3f6..02976d6 100644 --- a/.cursor/pitfalls/pitfalls.md +++ b/.cursor/pitfalls/pitfalls.md @@ -5,6 +5,27 @@ Agent 进入 Debug mode 或遇到运行时错误时自动检索匹配。 --- +### [PF-20260420-1600] firtoz/trellis Replicate 部署版 schema 与 README 及官方 API 文档不一致 +- **症状**: (1) 按 Replicate README example 用 `input={"image": uri}` 传单数 image → `422 Input validation failed: input: images is required`。改成 `images: [uri]` 后模型能跑,产出 model_file/combined_video/gaussian_ply/no_background_images。(2) 尽管 `generate_color=True` + `generate_normal=True` 都开,输出字典里 `color_video` 和 `normal_video` 字段**恒为 null**,只有 `combined_video` 这个"color 和 normal 左右并排的 mp4"才真正可用。 +- **根因**: `firtoz/trellis:e8f6c45206993f297372f5436b90350817bd9b4a0d52d2a76df50c1c8afa2b3c` 这个 Replicate 部署版的实际输入/输出 schema 与 firtoz 在 Replicate README 里贴的 example、以及 microsoft/TRELLIS 官方仓库的 Python API 都不一样——输入字段是 `images`(数组)而不是 `image`(字符串),输出字段虽然声明了 `color_video` 和 `normal_video` 但该部署版恒给 null,只填 `combined_video`。很可能是部署者在包装层做了合并(省带宽/对象存储开销)但没同步 README。 +- **解法**: (1) 输入字段固定传 `images: [uri]` 数组,即使只有 1 张图;(2) Phase 2 抽帧的数据源从 `combined_video` 切——每一帧的左半 = color、右半 = normal,用 PIL crop 即可;(3) 不要相信 Replicate README 上的 Python example,永远以 **该版本 ID 的 OpenAPI schema** 为准(虽然 firtoz/trellis 这个 README 也看不到精确 schema,所以只能实测)。 +- **防御**: 接 Replicate 新模型前先用 1 美元额度做一次最小输入测试,打印 `output` 的原始 dict 结构 + 每个 key 的值类型;不要假设 README 的 example 与实际部署版本一致。`mesh_generator.py` 里已经做了"把 raw_output_keys 和每个字段的 None 警告都打印"的防御日志。 +- **关联**: Replicate, firtoz/trellis, Mesh Pipeline, view_transform, Trellis, 3D 重建, schema, OpenAPI, mesh_generator.py + +### [PF-20260420-1501] Windows uvicorn --reload 缺 watchfiles 退化为轮询,代码改动不被热重载 +- **症状**: 代码已修好(git diff / Read 看源文件都是新版),但后端运行时行为仍是旧代码。具体表现:CL-20260420-1212 修好 Zero123++ 切图后(`cols=2/rows=3` + 自动纠错),前端再触发一次转视角,切出的 view 图仍是 213×480(= 640/3 × 960/2,旧的 `cols=3,rows=2` 逻辑),而独立用当前代码调用 `_split_grid_image` 能正确切出 320×320。进程启动时间 11:55,改动时间 12:12,观察时间 15:00,期间未 reload 过任何改动。 +- **根因**: uvicorn `--reload` 在未安装 `watchfiles` 或 `watchdog` 时 fallback 到 `StatReload`(基于轮询)。Windows 文件系统事件语义本就弱,加上深层子目录(`app/services/`)+ 新建文件(`view_transform.py` 在仓库里是新增 untracked 文件),几个因素叠加让 StatReload 漏通知/响应极慢,表现为"启动后改什么代码都不会重载"。venv 里 `watchfiles` 和 `watchdog` 双双 MISSING 是核心证据。 +- **解法**: (1) `pip install watchfiles==0.24.0`(uvicorn 官方首选 reloader);(2) 写入 `requirements.txt` 和 `requirements-lock.txt`,避免其他设备重装环境时再次踩坑;(3) **kill 当前 uvicorn 进程重启一次**——新进程才会用上 WatchFiles reloader,并同步加载最新代码。仅装依赖不重启不会生效,因为当前进程已经加载了 StatReload。 +- **防御**: Windows 上只要 uvicorn 带 `--reload`,`watchfiles` 就是硬依赖,不是可选项。启动后首屏日志如果看到 `WatchFiles detected changes` 或 `Started reloader process [xxx] using WatchFiles` 才算正常;如果只有 `Started reloader process [xxx] using StatReload` 就是退化状态。可以额外传 `--reload-dir app` 显式指定监控范围更保险。任何"改了代码不生效"的诡异现象,第一时间先确认进程是否已经重载(看日志里的文件变更提醒,或对比进程启动时间 vs 文件 mtime),再排查代码逻辑。 +- **关联**: uvicorn, --reload, watchfiles, StatReload, FastAPI, Windows, 热重载, requirements.txt, view_transform.py, Zero123++ + +### [PF-20260420-1212] Zero123++ 输出 grid 布局被文档措辞"3×2"误导 +- **症状**: Zero123++ 视角变换后切出的 6 张图严重错位,每张子图呈窄长条(宽:高 约 1:2),画面里上下拼着两个教堂,边缘大量留白。拼接图原图视觉正常。 +- **根因**: Zero123++ 官方 README 写 "output is a 3×2 grid of 6 views",但 `jd7h/zero123plusplus` 这个 Replicate 部署版本实际输出的是 **2 列 × 3 行**(高图,如 640×960),而 config.py 里按"3 列 × 2 行"配置了 `grid_layout`。切图时 `cell_w = 640//3 = 213, cell_h = 960//2 = 480`,每个切片横跨了真实 grid 中两行的一部分,于是每张图都拼着两个视角的局部。 +- **解法**: (1) `config.py` 的 `VIEW_TRANSFORM_MODELS["zero123plus"].grid_layout` 改为 `{"cols": 2, "rows": 3}`;(2) `view_transform._split_grid_image` 加入"按实际图像长宽比 vs 配置 ratio 自动纠正"——假定每个单视角为正方形,真实 aspect ratio 应等于 `cols/rows`,若配置 ratio 与实际差异 >20% 就自动交换 cols/rows 并打 warning。 +- **防御**: 任何涉及"grid 拼接图切分"的场景,不要完全相信模型文档的"A×B"措辞(A 是列还是行常有歧义),必须用**实际图像长宽比**做健壮性检查;最稳妥的做法是让 split 函数本身能根据单视角正方形假设自动推断布局,不信任硬编码。更激进的方案是直接用 PIL 探测内容块边界(但对有微量白边的模型不稳定)。 +- **关联**: Zero123++, Replicate, jd7h/zero123plusplus, grid 切图, PIL, view_transform.py, config.py, Novel View Synthesis + ### [PF-20260412-1530] Python load_dotenv 默认不覆盖已有环境变量 + 模块级变量在 import 时固化 - **症状**: 修改 .env 切换 API 供应商后,后端仍报旧供应商的错误(如 DeepSeek 报 "Model Not Exist",实际发送的模型名是 OpenAI 的 gpt-4o-mini) - **根因**: 两层叠加:(1) `load_dotenv()` 默认 `override=False`,如果进程/系统中已有同名环境变量,.env 中的新值不会生效;(2) `config.py` 使用模块级常量 `LLM_MODEL = os.getenv(...)` 在 import 时就求值固化,而 `main.py` 的 import 链在 `load_dotenv()` 之前就触发了 config.py 的加载 diff --git a/.cursor/rules/common/changelog-recall.mdc b/.cursor/rules/common/changelog-recall.mdc index 5dcfaf4..951433b 100644 --- a/.cursor/rules/common/changelog-recall.mdc +++ b/.cursor/rules/common/changelog-recall.mdc @@ -73,8 +73,33 @@ Agent 开始处理一个新任务时,判断是否需要读取近期详细记 Agent 在即将输出最终回复前,**必须**逐项检查以下清单。 这是硬性要求,不是建议——**跳过任何一项都视为执行错误**。 +### 前置项(每次回复前无条件执行) + +**A. 画像信号扫描(短路版)** + +目的:以最小 token 成本维持 `profile-memory` Skill 的被动检测通路。 + +步骤: + +1. **快判(词表匹配)**:扫描本次对话中**用户消息**,检查是否**明显**出现以下偏好/决策触发词族之一: + + - 个人偏好信号:`我喜欢` / `我倾向` / `我不喜欢` / `我讨厌` / `以后都` / `以后别` / + `别再` / `不要再` / `我的风格` / `我的偏好` / `太花哨` / `minimal` / + `简洁` / `精炼` / `啰嗦` + - 项目决策信号:`这个项目是` / `目标用户是` / `技术栈` / `架构上` / + `产品方向` / `定位是` + +2. **分支**: + - 无匹配 → **直接跳过**,进入下一 checklist 项(thinking 应在 50 token 内结束) + - 有匹配 → 读取 `.cursor/skills/profile-memory/SKILL.md` 并执行操作 A 的确认流程 + +3. **约束**:本项不做全量语义判断——漏记隐式信号是可接受的权衡。 + 用户如需记录隐式偏好可显式说"把这条记入画像"。 + +### 变更检查项(仅当本次有代码/配置文件改动时执行) + 1. **本次是否有代码/配置文件改动?**(不含 `.cursor/` 下的基础设施文件) - - 否 → 跳过后续项 + - 否 → 跳过本组后续项,直接进入"收尾项" - 是 → 继续 2. **是否已写入三层开发日志?** @@ -89,9 +114,12 @@ Agent 在即将输出最终回复前,**必须**逐项检查以下清单。 - 否 → 跳过 - 是 → 执行 `epee-orchestrator.mdc` 中的自迭代确认流程 -5. **静默 Hook 重复触发**(每次 checklist 完成后必须执行) - - 用 Write 工具创建空文件 `.cursor/changelog/.changelog-ack` - - 这会更新 ack 文件的 mtime,阻止 `check-changelog.ps1` hook 在后续回复中重复提醒 +### 收尾项(每次回复前无条件执行) + +**Z. 静默 Hook ack** + +- 用 Write 工具创建空文件 `.cursor/changelog/.changelog-ack` +- 这会更新 ack 文件的 mtime,阻止 `check-changelog.ps1` hook 在后续回复中重复提醒 **执行原则**:先完成 checklist 中的所有写入动作,再输出面向用户的回复文本。 -回复末尾如果执行了第 2 项,附 `[已记录到开发日志]`。 +回复末尾如果执行了变更检查项第 2 项,附 `[已记录到开发日志]`。 diff --git a/.cursor/rules/common/profile-recall.mdc b/.cursor/rules/common/profile-recall.mdc index 83f7fcf..6dec597 100644 --- a/.cursor/rules/common/profile-recall.mdc +++ b/.cursor/rules/common/profile-recall.mdc @@ -34,8 +34,13 @@ alwaysApply: true 3. 用 Read 工具读取该行号 ±15 行范围,获取原始上下文和来源信息 4. 一次上溯通常只涉及 1-2 条记录,不批量上溯 -## 被动检测提醒 +## 被动检测触发点 -Agent 在整个对话过程中应保持对画像信号的被动感知。 -当对话结束、用户的主线任务完成后,如果检测到了新的画像信息, -应读取 `profile-memory` Skill 并执行其"操作 A"的确认流程。 +被动检测不在本 Rule 执行,而是由 `changelog-recall.mdc` 的 +**任务完成 Checklist 前置项 A(画像信号扫描)** 统一触发。 + +理由:本 Rule 仅负责上下文注入(低成本、每次必做);被动检测需要挂入 +已有强制力的 checklist 中才能避免被 Agent 遗漏,该 checklist 由 +`changelog-recall` 维护,两者分工清晰。 + +具体触发词族和短路逻辑详见 `changelog-recall.mdc` 中的前置项 A 定义。 diff --git a/.cursor/skills/dev-changelog/SKILL.md b/.cursor/skills/dev-changelog/SKILL.md index 2375bcd..f31c4a9 100644 --- a/.cursor/skills/dev-changelog/SKILL.md +++ b/.cursor/skills/dev-changelog/SKILL.md @@ -249,3 +249,11 @@ Agent 完成了一个涉及**代码或配置文件实质性改动**的任务后 1. **大任务收尾遗漏风险** — 当单次任务涉及 5+ 个文件改动时,Agent 容易在输出总结回复后遗漏被动写入流程。应在生成最终回复前,先执行 `changelog-recall.mdc` 中的"任务完成 Checklist",确认三层日志已写入后再输出回复。绝不能"先回复再补写"。 2. **L3/L2 滚动窗口必须落地** — 操作 A 第 4 步不是「插入即结束」:写入后必须数清条目(L3 为以 `- [` 开头的列表行,L2 为 `### [CL-` 标题行)。L3 超过 50 条、L2 超过 20 条时,必须从**文件底部(最旧)**整段删除条目直至恰好满足上限;禁止长期只追加不修剪。若发现 `changelog-headlines.md` 列表行多于 50,说明上次写入未执行本项,本次补修剪并自检。 + +3. **顶部插入 StrReplace 范式(L1/L2)** — L1 和 L2 的每个条目都是多行结构(标题 + 多条 `- **字段**:`)。使用 StrReplace 在顶部插入新条目时,anchor(`old_string`)有且仅有两种合法选择: + (a) 只包含新条目之前的"稳定前缀"(如 L1 的 `## 记录\n\n` 或 L2 的整段文件头说明),**不触及任何已有条目的任何一行**;`new_string` = 稳定前缀 + 新条目完整内容 + 空行。 + (b) 把旧首条目的**完整多行内容**(标题行 + 全部字段行)都纳入 `old_string`;`new_string` = 新条目完整内容 + 空行 + 旧首条目完整内容。 + + **绝对禁止**:只把旧首条目的标题行(或前 1-2 字段)当 anchor 而不带上剩余字段行——StrReplace 会吞掉这些 anchor 行但不动剩余字段,造成"旧标题丢失、字段游离"的半损坏状态(2026-04-20 在 `pitfall-journal` 的写入中已实际踩过此坑)。 + + **写入后自检**:Read 文件头部 30 行,确认看到新条目完整 + 旧首条目标题行仍存在且字段完整跟随。L3 的一行条目不受本条约束(它就是单行,天然无此风险)。 diff --git a/.cursor/skills/epee-orchestrator/registry.md b/.cursor/skills/epee-orchestrator/registry.md index 3753334..0597a97 100644 --- a/.cursor/skills/epee-orchestrator/registry.md +++ b/.cursor/skills/epee-orchestrator/registry.md @@ -28,10 +28,10 @@ ### profile-memory - **类型**: 个人级 - **能力**: 在对话中被动检测用户个人特质和项目信息,经确认后持久化为精简画像和详细日志 -- **触发场景**: "画像"、"profile"、"我的偏好"、"查看画像"、"项目信息"、"查看项目画像"、用户主动管理画像 +- **触发场景**: "画像"、"profile"、"我的偏好"、"查看画像"、"项目信息"、"查看项目画像"、用户主动管理画像;被动检测由 `changelog-recall` checklist 前置项 A 的短路扫描触发(命中偏好/项目决策触发词族时) - **输出**: ~/.cursor/profile/user-profile.md、.cursor/profile/project-profile.md 及对应 log 文件 - **路径**: .cursor/skills/profile-memory/SKILL.md -- **备注**: 被动检测由 profile-recall Rule 触发;精简 Profile 每次会话自动注入上下文 +- **备注**: 精简 Profile 每次会话由 `profile-recall` Rule 自动注入上下文;被动检测路径由 `changelog-recall` 的强制 checklist 前置项 A 兜底(2026-04-20 升级:原 `profile-recall` 的软性"被动检测提醒"失效率高,改为词表短路扫描挂入 checklist) ### dev-changelog - **类型**: 基础设施 @@ -39,7 +39,7 @@ - **触发场景**: "开发日志"、"changelog"、"最近改了什么"、"回顾改动"、"查看开发记录"、代码改动后自动写入 - **输出**: .cursor/changelog/ 下的 changelog-full.md、changelog-recent.md、changelog-headlines.md - **路径**: .cursor/skills/dev-changelog/SKILL.md -- **备注**: 写入由 changelog-recall Rule 的"任务完成 Checklist"触发(原被动写入提醒已升级为强制 checklist);L3 概要每次会话自动注入上下文;stop Hook 双保险兜底;写入后须维护 L3≤50 / L2≤20 滚动窗口(见 Skill 自迭代检查项) +- **备注**: 写入由 changelog-recall Rule 的"任务完成 Checklist"触发(原被动写入提醒已升级为强制 checklist);L3 概要每次会话自动注入上下文;stop Hook 双保险兜底;写入后须维护 L3≤50 / L2≤20 滚动窗口,L1/L2 多行条目须遵循"顶部插入 StrReplace 范式"防止旧首条目被截断(见 Skill 自迭代检查项) ### pitfall-journal - **类型**: 基础设施 @@ -47,7 +47,7 @@ - **触发场景**: debug 完成后、"踩坑"、"之前遇到过"、"坑"、进入 Debug mode、同类错误反复出现 - **输出**: .cursor/pitfalls/pitfalls.md 条目 - **路径**: .cursor/skills/pitfall-journal/SKILL.md -- **备注**: 与 dev-changelog 互补——changelog 记事实,pitfall 记经验;由 pitfall-recall Rule 触发自动检索 +- **备注**: 与 dev-changelog 互补——changelog 记事实,pitfall 记经验;由 pitfall-recall Rule 触发自动检索;写入时须遵循"顶部插入 StrReplace 范式"防止旧首条目标题丢失(见 Skill 自迭代检查项) ### problem-distillery - **类型**: 基础设施 diff --git a/.cursor/skills/pitfall-journal/SKILL.md b/.cursor/skills/pitfall-journal/SKILL.md index 977a963..a107ad7 100644 --- a/.cursor/skills/pitfall-journal/SKILL.md +++ b/.cursor/skills/pitfall-journal/SKILL.md @@ -113,4 +113,10 @@ description: >- ### 已知必要检查 -(暂无) +1. **顶部插入必须保持旧首条目完整性** — 使用 StrReplace 在文件顶部插入新条目时(操作 A 第 4 步),anchor(`old_string`)有且仅有两种合法选择: + (a) 只包含新条目之前的"稳定前缀"(如 `---\n\n` 或整个文件头部说明段),**不触及任何已有条目的任何一行**;然后 `new_string` = 稳定前缀 + 新条目完整内容 + 空行。 + (b) 把旧首条目的**完整多行内容**(标题行 + 全部字段行)都纳入 `old_string`;然后 `new_string` = 新条目完整内容 + 空行 + 旧首条目完整内容。 + + **绝对禁止**:把旧首条目的"标题行"单独当 anchor 而不带上它紧跟的字段行——StrReplace 是精确替换,这样做会删掉标题、让字段"无主"残留在新条目之后,造成半损坏状态(2026-04-20 实际踩过此坑)。 + + **写入后自检**:Read 文件头 25 行,确认看到 `### [PF-新ID]` 后紧跟其 5 字段,再往下能看到 `### [PF-旧首条目ID]` 标题行且字段完整跟随。 diff --git a/.cursor/skills/profile-memory/SKILL.md b/.cursor/skills/profile-memory/SKILL.md index 9c9047f..0ed3b49 100644 --- a/.cursor/skills/profile-memory/SKILL.md +++ b/.cursor/skills/profile-memory/SKILL.md @@ -1,9 +1,11 @@ --- name: profile-memory description: >- - 渐进式用户/项目画像系统。在对话中被动检测用户个人特质(审美、技术偏好、做事风格等) - 和项目信息(定位、技术栈、产品方向等),对话结束前统一总结并经用户确认后记录。 - 当用户提到"画像"、"profile"、"我的偏好"、"查看画像"、"项目信息"等时触发。 + 渐进式用户/项目画像系统。两条触发路径: + (1)被动检测(主路径)——由 `changelog-recall` checklist 前置项 A 触发短路扫描, + 命中偏好/项目决策触发词时执行操作 A(总结 + 用户确认 + 写入)。 + (2)显式管理——当用户提到"画像"、"profile"、"查看画像"、"我的偏好"、"项目信息"等时 + 执行操作 B(浏览、修改、删除已有条目)。 --- # Profile Memory diff --git a/art-agent/backend/app/agent/tools.py b/art-agent/backend/app/agent/tools.py index c95540e..02bd87f 100644 --- a/art-agent/backend/app/agent/tools.py +++ b/art-agent/backend/app/agent/tools.py @@ -39,17 +39,34 @@ TOOL_DEFINITIONS = [ "function": { "name": "transform_view", "description": ( - "将一张图片转换为多个不同视角。" - "使用 Zero123++ 模型从单张图片生成 6 个固定视角的图片。" - "适用于建筑、物体等需要从不同角度查看的场景。" - "输入图片必须是正方形(或会被自动裁切为正方形),建议分辨率 >= 320x320。" - "输出 6 个视角:方位角 30°/90°/150°/210°/270°/330°," - "仰角交替为 30°/-20°(正俯视/微仰视)。" - "此工具需要参考图作为输入——必须先有用户上传的图片才能使用。" + "将一张图片转换为多个不同视角。需要用户先上传参考图。\n" + "可选模型:\n" + "- zero123plus(默认):~15s/次,Zero123++ 直接输出 6 个固定视角" + "(方位角 30/90/150/210/270/330°,仰角交替 30/-20°),适合快速预览," + "对建筑大角度可能有错位。\n" + "- trellis:~105s/次,先 3D 重建再重绘," + "支持任意方位角 + 原画风保留,适合卡通描边风格建筑。" ), "parameters": { "type": "object", - "properties": {}, + "properties": { + "model_id": { + "type": "string", + "enum": ["zero123plus", "trellis"], + "default": "zero123plus", + "description": "视角变换模型;默认 zero123plus(快),trellis 画风还原更强但慢", + }, + "azimuths": { + "type": "array", + "items": {"type": "integer"}, + "description": "mesh 管道专用:自定义方位角数组(0-359),默认 [0,60,120,180,240,300]", + }, + "preserve_style": { + "type": "boolean", + "default": True, + "description": "mesh 管道专用:是否对抽帧结果走 IP-Adapter+ControlNet 重绘还原原画风", + }, + }, "required": [], }, }, @@ -92,7 +109,13 @@ async def execute_tool( "success": False, "error": "视角变换需要一张输入图片,请先上传参考图", } - result = await transform_view(ref_image_urls[0]) + result = await transform_view( + ref_image_urls[0], + model_id=arguments.get("model_id"), + azimuths=arguments.get("azimuths"), + elevations=arguments.get("elevations"), + preserve_style=arguments.get("preserve_style", True), + ) return result return {"success": False, "error": f"未知工具: {tool_name}"} diff --git a/art-agent/backend/app/config.py b/art-agent/backend/app/config.py index 07d731f..49794d9 100644 --- a/art-agent/backend/app/config.py +++ b/art-agent/backend/app/config.py @@ -225,9 +225,35 @@ IMAGE_MODELS: dict[str, dict[str, Any]] = { "output_format": "png", }, }, + # Mesh 管道专用的内部模型(internal=True 不展示到前端下拉): + # 输入两张图:image=原图做 IP-Adapter 风格参考;controlnet_input=normal/depth 结构参考。 + # 用途:在 Stage 3 对 Trellis 抽出来的 normal 帧做二次重绘,还原原画风。 + "ip-adapter-controlnet-depth": { + "id": "ip-adapter-controlnet-depth", + "name": "IP-Adapter + ControlNet Depth (内部)", + "provider": "replicate", + "model_id": "chigozienri/ip_adapter-sdxl-controlnet-depth:0436c8702ef52616be5c30948551b3af6a86c821cca9b01f11ac297624fff14c", + "description": "Mesh 管道 Stage 3 专用:IP-Adapter 保风格 + ControlNet 保几何", + "internal": True, + "supports_ref_image": True, + "ref_image_param": "image", # IP-Adapter 参考图字段 + "default_params": { + "scale": 0.75, # IP-Adapter 权重(建议 0.7-0.8) + "controlnet_conditioning_scale": 0.8, # 结构约束(建议 0.7-0.9) + "prompt": "same building, same art style, consistent with reference", + "negative_prompt": "blurry, distorted, different style, realistic photo, photograph", + "num_outputs": 1, + "num_inference_steps": 30, + }, + }, } +def get_style_restore_model_id() -> str: + """Mesh 管道 Stage 3 使用的内部模型短 ID。""" + return "ip-adapter-controlnet-depth" + + # ─── 视角变换模型注册表 ───────────────────────────────── # # 视角变换模型不用于生图,而是将已有图片转换为不同视角。 @@ -240,6 +266,7 @@ VIEW_TRANSFORM_MODELS: dict[str, dict[str, Any]] = { "provider": "replicate", "model_id": "jd7h/zero123plusplus:c69c6559a29011b576f1ff0371b3bc1add2856480c60520c7e9ce0b40a6e9052", "description": "单图生成 6 个固定视角,适合建筑/物体的多角度预览", + "pipeline": "grid", "output_views": [ {"azimuth": 30, "elevation": 30}, {"azimuth": 90, "elevation": -20}, @@ -248,7 +275,46 @@ VIEW_TRANSFORM_MODELS: dict[str, dict[str, Any]] = { {"azimuth": 270, "elevation": 30}, {"azimuth": 330, "elevation": -20}, ], - "grid_layout": {"cols": 3, "rows": 2}, + "grid_layout": {"cols": 2, "rows": 3}, + "enabled": True, + }, + "trellis": { + "id": "trellis", + "name": "Trellis (Mesh Pipeline)", + "provider": "replicate", + "model_id": "firtoz/trellis:e8f6c45206993f297372f5436b90350817bd9b4a0d52d2a76df50c1c8afa2b3c", + "description": "3D 重建 + 任意视角 + 风格还原,建筑友好(~30s 重建 + 72s/张重绘)", + "pipeline": "mesh", + "default_params": { + "texture_size": 1024, + "mesh_simplify": 0.95, + "generate_color": True, + "generate_normal": True, + "generate_model": True, + "save_gaussian_ply": False, + "ss_sampling_steps": 12, + "slat_sampling_steps": 12, + "ss_guidance_strength": 7.5, + "slat_guidance_strength": 3.0, + }, + "default_azimuths": [0, 60, 120, 180, 240, 300], + "default_elevations": [0, 0, 0, 0, 0, 0], + "enabled": True, + }, + "hunyuan3d": { + "id": "hunyuan3d", + "name": "Hunyuan3D-2 (高质量)", + "provider": "replicate", + "model_id": "tencent/hunyuan3d-2:b1b9449a1277e10402781c5d41eb30c0a0683504fb23fab591ca9dfc2aabe1cb", + "description": "几何质量最佳(~127s),需自定义渲染器(二期启用)", + "pipeline": "mesh", + "default_params": { + "steps": 50, + "guidance_scale": 5.5, + "octree_resolution": 256, + "remove_background": True, + }, + "enabled": False, }, } @@ -261,14 +327,16 @@ def get_view_transform_model_config(model_id: str | None = None) -> dict[str, An def get_view_transform_models_list() -> list[dict]: - """返回前端下拉列表所需的视角变换模型摘要信息。""" + """返回前端下拉列表所需的视角变换模型摘要信息(只包含 enabled=True 的)。""" return [ { "id": cfg["id"], "name": cfg["name"], "description": cfg["description"], + "pipeline": cfg.get("pipeline", "grid"), } for cfg in VIEW_TRANSFORM_MODELS.values() + if cfg.get("enabled", True) ] @@ -297,7 +365,7 @@ def get_ref_image_model_id() -> str | None: def get_image_models_list() -> list[dict]: - """返回前端下拉列表所需的模型摘要信息。""" + """返回前端下拉列表所需的模型摘要信息(过滤掉 internal=True 的内部模型)。""" return [ { "id": cfg["id"], @@ -306,6 +374,7 @@ def get_image_models_list() -> list[dict]: "supports_ref_image": cfg.get("supports_ref_image", False), } for cfg in IMAGE_MODELS.values() + if not cfg.get("internal", False) ] diff --git a/art-agent/backend/app/services/mesh_generator.py b/art-agent/backend/app/services/mesh_generator.py new file mode 100644 index 0000000..74d714b --- /dev/null +++ b/art-agent/backend/app/services/mesh_generator.py @@ -0,0 +1,189 @@ +"""3D Mesh 生成服务 — 阶段 1:单图 → textured .glb + 360° 预渲染视频。 + +当前支持: +- Trellis (firtoz/trellis):~30s,$0.041/次,自带 color_video + normal_video + .glb +- Hunyuan3D-2 (tencent/hunyuan3d-2):~127s,$0.12/次,仅 .glb 无预渲染视频(二期启用) +""" + +import logging +import os +import uuid +from pathlib import Path +from typing import Any + +import httpx + +from app.config import get_view_transform_model_config +from app.services.image_gen import _make_replicate_client, GENERATED_DIR, to_data_uri + +logger = logging.getLogger(__name__) + +_replicate_client = _make_replicate_client() + +SUPPORTED_EXTS = { + "model_file": ".glb", + "color_video": ".mp4", + "normal_video": ".mp4", + "combined_video": ".mp4", + "gaussian_ply": ".ply", + "mesh": ".glb", +} + + +async def _download_asset(url: str, ext: str, prefix: str) -> Path: + """下载远程资源到 generated/ 目录,返回本地路径。""" + filename = f"{prefix}_{uuid.uuid4().hex[:12]}{ext}" + filepath = GENERATED_DIR / filename + proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") + async with httpx.AsyncClient(proxy=proxy, timeout=httpx.Timeout(300.0)) as client: + resp = await client.get(url, follow_redirects=True) + resp.raise_for_status() + filepath.write_bytes(resp.content) + return filepath + + +def _to_url(value: Any) -> str | None: + """把 Replicate FileOutput / str / None 统一转为 URL 字符串。""" + if value is None: + return None + s = str(value) + if s.startswith("http://") or s.startswith("https://"): + return s + return None + + +async def generate_with_trellis( + image_path: str, + params: dict[str, Any] | None = None, +) -> dict[str, Any]: + """调用 Trellis 做单图 3D 重建,下载所有输出资产到本地。 + + Args: + image_path: 输入图(本地路径 / data URI / URL) + params: 覆盖 default_params 的可选参数字典 + + Returns: + { + "success": bool, + "glb_path": "/generated/trellis_xxx.glb" | None, + "color_video_path": "/generated/trellis_xxx_color.mp4" | None, + "normal_video_path": "/generated/trellis_xxx_normal.mp4" | None, + "combined_video_path": ..., + "raw_output_keys": [...], # 调试用 + "error": str | None, + } + """ + config = get_view_transform_model_config("trellis") + replicate_model_id = config["model_id"] + default_params = dict(config.get("default_params", {})) + if params: + default_params.update(params) + + GENERATED_DIR.mkdir(parents=True, exist_ok=True) + + try: + image_uri = to_data_uri(image_path) + # firtoz/trellis:e8f6c452... 这个部署版的 schema: + # - 输入必须用 `images` 数组(传 `image` 单数会 422) + # - 输出字段 `color_video` 和 `normal_video` 实测恒为 null + # - 实际可用的视频只有 `combined_video`(color+normal 左右并排) + # 因此 Phase 2 抽帧时从 combined_video 切左半得 color、切右半得 normal。 + input_params: dict[str, Any] = { + "images": [image_uri], + "randomize_seed": False, + "seed": 0, + **default_params, + } + + logger.info( + "调用 Trellis 进行 3D 重建:model=%s, params=%s", + replicate_model_id, + {k: v for k, v in input_params.items() if k != "images"}, + ) + output = await _replicate_client.async_run( + replicate_model_id, input=input_params, wait=False + ) + + if not isinstance(output, dict): + return { + "success": False, + "error": f"Trellis 返回非字典输出:{type(output).__name__} / {str(output)[:200]}", + "raw_output_keys": [], + } + + raw_keys = list(output.keys()) + logger.info("Trellis 返回字段:%s", raw_keys) + + # 按已知字段挨个下载 + result: dict[str, Any] = { + "success": True, + "raw_output_keys": raw_keys, + "error": None, + } + download_map = [ + ("model_file", "glb_path", ".glb", "trellis"), + ("color_video", "color_video_path", ".mp4", "trellis_color"), + ("normal_video", "normal_video_path", ".mp4", "trellis_normal"), + ("combined_video", "combined_video_path", ".mp4", "trellis_combined"), + ] + + for src_key, dst_key, ext, prefix in download_map: + if src_key not in output: + result[dst_key] = None + continue + url = _to_url(output[src_key]) + if not url: + logger.warning("Trellis 字段 %s 不是有效 URL:%s", src_key, output[src_key]) + result[dst_key] = None + continue + try: + local_path = await _download_asset(url, ext, prefix) + result[dst_key] = f"/generated/{local_path.name}" + logger.info("已下载 %s → %s", src_key, local_path.name) + except Exception as e: + logger.error("下载 %s 失败:%s", src_key, e) + result[dst_key] = None + + # 阶段 2 抽帧的优先级:color_video > combined_video(切左半) + # 只要其中一个可用即算成功 + has_color_source = bool(result.get("color_video_path")) or bool( + result.get("combined_video_path") + ) + if not has_color_source: + result["success"] = False + result["error"] = ( + "Trellis 未返回 color_video 或 combined_video,无法进入阶段 2 抽帧;" + f"实际返回字段:{raw_keys}" + ) + + return result + + except Exception as e: + detail = str(e) or f"{type(e).__name__}: {repr(e)}" + logger.error("Trellis 调用失败:%s", detail, exc_info=True) + return { + "success": False, + "error": detail, + "raw_output_keys": [], + } + + +async def generate_mesh( + image_path: str, + model_id: str = "trellis", + params: dict[str, Any] | None = None, +) -> dict[str, Any]: + """统一入口:按 model_id 分发到具体实现。""" + if model_id == "trellis": + return await generate_with_trellis(image_path, params) + if model_id == "hunyuan3d": + return { + "success": False, + "error": "Hunyuan3D-2 (一期未启用,待二期接入自定义渲染)", + "raw_output_keys": [], + } + return { + "success": False, + "error": f"未知 mesh 模型:{model_id}", + "raw_output_keys": [], + } diff --git a/art-agent/backend/app/services/style_restorer.py b/art-agent/backend/app/services/style_restorer.py new file mode 100644 index 0000000..10b8b79 --- /dev/null +++ b/art-agent/backend/app/services/style_restorer.py @@ -0,0 +1,180 @@ +"""阶段 3:对 Trellis 抽帧结果做 IP-Adapter + ControlNet 重绘,还原原画风。 + +输入: +- original_image:用户上传的原图(提供风格 / IP-Adapter 参考) +- structure_image:Stage 2 抽出的 normal 帧(提供几何 / ControlNet 结构约束) + +输出: +- 单张保留原画风且视角正确的 PNG,落盘到 GENERATED_DIR/ 下 + +模型:chigozienri/ip_adapter-sdxl-controlnet-depth(配置见 config.IMAGE_MODELS) +- $0.07/张,~72s,硬件 L40S +- `image` 字段 = IP-Adapter 参考图(风格源) +- `controlnet_input` 字段 = 结构参考图(当前一期传 normal 帧作为 depth 近似) + +调参建议(可在 IMAGE_MODELS 默认值基础上覆盖): +- scale (IP-Adapter 权重): 0.7-0.8,越高越贴近原图风格 +- controlnet_conditioning_scale (结构权重): 0.7-0.9,越高越保几何 +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import uuid +from pathlib import Path +from typing import Any + +import httpx + +from app.config import get_image_model_config, get_style_restore_model_id +from app.services.image_gen import GENERATED_DIR, _make_replicate_client, to_data_uri + +logger = logging.getLogger(__name__) + +_replicate_client = _make_replicate_client() + + +async def _download_image(url: str, prefix: str) -> Path: + """把 Replicate 返回的图片下载到 generated/。""" + filename = f"{prefix}_{uuid.uuid4().hex[:12]}.png" + filepath = GENERATED_DIR / filename + proxy = os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") + async with httpx.AsyncClient(proxy=proxy, timeout=httpx.Timeout(180.0)) as client: + resp = await client.get(url, follow_redirects=True) + resp.raise_for_status() + filepath.write_bytes(resp.content) + return filepath + + +def _first_url(output: Any) -> str | None: + """Replicate 输出有时是 list[FileOutput]、有时是单个;统一取第一张。""" + if output is None: + return None + if isinstance(output, list): + if not output: + return None + return str(output[0]) + return str(output) + + +async def restore( + original_image: str, + structure_image: str, + azimuth: int = 0, + elevation: int = 0, + params: dict[str, Any] | None = None, +) -> dict[str, Any]: + """对单张 normal 帧做 IP-Adapter + ControlNet 重绘。 + + Args: + original_image: 用户原图,本地路径 / data URI / URL 均可 + structure_image: Stage 2 抽出的 normal 帧,同上 + azimuth / elevation: 仅用于日志与返回元数据,不参与模型调用 + params: 覆盖默认参数的字典(scale / controlnet_conditioning_scale / prompt 等) + + Returns: + { + "success": bool, + "url": "/generated/restyled_xxx.png" | None, + "azimuth": int, + "elevation": int, + "error": str | None, + } + """ + config = get_image_model_config(get_style_restore_model_id()) + replicate_model_id = config["model_id"] + merged_params = dict(config.get("default_params", {})) + if params: + merged_params.update(params) + + GENERATED_DIR.mkdir(parents=True, exist_ok=True) + + try: + image_uri = to_data_uri(original_image) + structure_uri = to_data_uri(structure_image) + + input_params: dict[str, Any] = { + "image": image_uri, # IP-Adapter 参考图 = 用户原图 + "controlnet_input": structure_uri, # 结构参考 = normal 帧 + **merged_params, + } + + logger.info( + "Stage 3 重绘 az=%d el=%d: scale=%s, cn_scale=%s", + azimuth, elevation, + merged_params.get("scale"), + merged_params.get("controlnet_conditioning_scale"), + ) + + output = await _replicate_client.async_run( + replicate_model_id, input=input_params, wait=False + ) + + url = _first_url(output) + if not url or not (url.startswith("http://") or url.startswith("https://")): + return { + "success": False, + "url": None, + "azimuth": azimuth, + "elevation": elevation, + "error": f"模型未返回有效图片 URL: {str(output)[:200]}", + } + + local = await _download_image(url, prefix=f"restyled_az{azimuth}_el{elevation}") + return { + "success": True, + "url": f"/generated/{local.name}", + "azimuth": azimuth, + "elevation": elevation, + "error": None, + } + + except Exception as e: + detail = str(e) or f"{type(e).__name__}: {repr(e)}" + logger.error( + "Stage 3 重绘失败 az=%d el=%d: %s", azimuth, elevation, detail, exc_info=True + ) + return { + "success": False, + "url": None, + "azimuth": azimuth, + "elevation": elevation, + "error": detail, + } + + +async def restore_batch( + original_image: str, + frames: list[dict[str, Any]], + params: dict[str, Any] | None = None, + concurrency: int = 3, +) -> list[dict[str, Any]]: + """对一批 frames 做 Stage 3 重绘,带并发控制。 + + Args: + original_image: 用户原图(所有帧共用同一张风格参考) + frames: [{"az", "el", "normal_png", ...}, ...],normal_png 作为结构参考 + params: 透传给 restore() 的覆盖参数 + concurrency: 最大并发数;Replicate 并发打太猛容易撞 rate limit,3 比较稳 + + Returns: + 列表,顺序与 frames 对齐,元素与 restore() 的返回一致。 + """ + if not frames: + return [] + + sem = asyncio.Semaphore(max(1, concurrency)) + + async def _one(f: dict[str, Any]) -> dict[str, Any]: + async with sem: + return await restore( + original_image=original_image, + structure_image=f["normal_png"], + azimuth=int(f.get("az", 0)), + elevation=int(f.get("el", 0)), + params=params, + ) + + return await asyncio.gather(*[_one(f) for f in frames]) diff --git a/art-agent/backend/app/services/video_frame_extractor.py b/art-agent/backend/app/services/video_frame_extractor.py new file mode 100644 index 0000000..3ce106f --- /dev/null +++ b/art-agent/backend/app/services/video_frame_extractor.py @@ -0,0 +1,191 @@ +"""阶段 2:从 Trellis combined_video 抽帧 + 切左右半得到 color / normal 帧。 + +Trellis (firtoz/trellis) 部署版 `e8f6c452...` 实测只返回 `combined_video` +(color 和 normal 左右并排 mp4,约 5 秒 360° 水平环绕),不再单独返回 +`color_video` / `normal_video`(详见 PF-20260420-1600)。 + +本模块职责: +1. 对给定 combined_video 做一次全帧抽取(imageio-ffmpeg 自带二进制,无需系统 ffmpeg) +2. 按 azimuth / total_rotation_deg 映射到帧索引并挑出对应帧 +3. 用 PIL 切左半 → color png、右半 → normal png,落盘到 GENERATED_DIR +4. 返回 [{az, el, frame_idx, color_png, normal_png}, ...] + +一期仅支持 elevation=0(Trellis 的 combined_video 就是平视一圈,本身没有仰俯)。 +需要自由 elevation 时走 Phase 5 的 mesh_renderer 路径(pyrender / Blender headless)。 +""" + +from __future__ import annotations + +import asyncio +import logging +import subprocess +import uuid +from pathlib import Path +from typing import Any + +import imageio_ffmpeg # 提供静态 ffmpeg 二进制,避免依赖系统 ffmpeg +from PIL import Image + +from app.services.image_gen import GENERATED_DIR + +logger = logging.getLogger(__name__) + +FFMPEG_EXE = imageio_ffmpeg.get_ffmpeg_exe() + + +def _resolve_local_path(path_or_url: str) -> Path: + """把 mesh_generator 返回的 '/generated/xxx.mp4' 解析为真实磁盘路径。 + + 兼容三种输入: + - '/generated/xxx.mp4' → GENERATED_DIR / xxx.mp4 + - 'D:/abs/path/xxx.mp4' → Path 原样 + - 'xxx.mp4' → Path 原样(调用方自行保证) + """ + if not path_or_url: + raise ValueError("combined_video_path 为空") + if path_or_url.startswith("/generated/"): + return GENERATED_DIR / Path(path_or_url).name + return Path(path_or_url) + + +def _extract_all_frames(video_path: Path, temp_dir: Path) -> list[Path]: + """把 video 的所有帧抽到 temp_dir,返回按文件名排序的帧列表。 + + 对 Trellis ~5s 的 combined_video(~100-150 帧)这种量级是 OK 的, + 比起"用 -ss 逐帧抽取"更省心、角度映射也更稳。 + """ + temp_dir.mkdir(parents=True, exist_ok=True) + out_pattern = str(temp_dir / "f%05d.png") + cmd = [ + FFMPEG_EXE, + "-y", + "-hide_banner", + "-loglevel", "error", + "-i", str(video_path), + "-vsync", "0", + out_pattern, + ] + logger.info("ffmpeg extract-all: %s", " ".join(cmd)) + proc = subprocess.run(cmd, capture_output=True, text=True) + if proc.returncode != 0: + raise RuntimeError( + f"ffmpeg 抽帧失败 (code {proc.returncode}): stderr={proc.stderr[-500:]}" + ) + frames = sorted(temp_dir.glob("f*.png")) + if not frames: + raise RuntimeError( + f"ffmpeg 执行成功但没产出帧:{video_path};stderr={proc.stderr[-500:]}" + ) + return frames + + +def _split_combined_frame( + frame_path: Path, + out_prefix: str, + az: int, + el: int, +) -> tuple[Path, Path]: + """把 combined 帧按中线切成 color(左)+ normal(右)两张图。""" + img = Image.open(frame_path) + w, h = img.size + mid = w // 2 + color = img.crop((0, 0, mid, h)) + normal = img.crop((mid, 0, w, h)) + + color_name = f"view_{out_prefix}_az{az}_el{el}_color.png" + normal_name = f"view_{out_prefix}_az{az}_el{el}_normal.png" + color_path = GENERATED_DIR / color_name + normal_path = GENERATED_DIR / normal_name + color.save(color_path, "PNG") + normal.save(normal_path, "PNG") + return color_path, normal_path + + +def _cleanup_dir(dir_path: Path) -> None: + """静默删除临时目录及其所有文件,失败不抛。""" + if not dir_path.exists(): + return + for f in dir_path.glob("*"): + try: + f.unlink() + except OSError: + pass + try: + dir_path.rmdir() + except OSError: + pass + + +def _do_extract( + video: Path, + azimuths: list[int], + elevations: list[int], + total_rotation_deg: int, +) -> list[dict[str, Any]]: + """同步版抽帧逻辑,供 async 版本用 run_in_executor 包起来跑。""" + batch_id = uuid.uuid4().hex[:8] + temp_dir = GENERATED_DIR / f"_frames_{batch_id}" + + try: + frames = _extract_all_frames(video, temp_dir) + total = len(frames) + logger.info("combined_video %s 抽出 %d 帧,总时长~%.2fs (按 24fps 估)", + video.name, total, total / 24.0) + + results: list[dict[str, Any]] = [] + for az, el in zip(azimuths, elevations): + # az 归一化到 [0, total_rotation_deg),再映射到 [0, total) 整数索引 + az_mod = az % total_rotation_deg if total_rotation_deg > 0 else 0 + frame_idx = int(round(az_mod / total_rotation_deg * total)) % total if total > 0 else 0 + src_frame = frames[frame_idx] + color_path, normal_path = _split_combined_frame(src_frame, batch_id, az, el) + results.append({ + "az": az, + "el": el, + "frame_idx": frame_idx, + "color_png": f"/generated/{color_path.name}", + "normal_png": f"/generated/{normal_path.name}", + }) + return results + finally: + _cleanup_dir(temp_dir) + + +async def extract_by_azimuth( + combined_video_path: str, + azimuths: list[int], + elevations: list[int] | None = None, + total_rotation_deg: int = 360, +) -> list[dict[str, Any]]: + """按 azimuth 列表从 combined_video 抽帧 + 切左右半。 + + Args: + combined_video_path: '/generated/trellis_combined_xxx.mp4' 或本地磁盘路径 + azimuths: 方位角数组,单位度(0-360,允许大于 360 会自动取模) + elevations: 仰角数组;长度须等于 azimuths;一期所有值被忽略,保留字段以便后续扩展 + total_rotation_deg: combined_video 覆盖的水平旋转范围;Trellis 默认 360 + + Returns: + [{"az": 60, "el": 0, "frame_idx": 20, + "color_png": "/generated/view_xxx_az60_el0_color.png", + "normal_png": "/generated/view_xxx_az60_el0_normal.png"}, ...] + """ + video = _resolve_local_path(combined_video_path) + if not video.exists(): + raise FileNotFoundError(f"combined_video 文件不存在:{video}") + if not azimuths: + return [] + if elevations is None: + elevations = [0] * len(azimuths) + if len(elevations) != len(azimuths): + raise ValueError( + f"azimuths / elevations 长度不一致:{len(azimuths)} vs {len(elevations)}" + ) + + # ffmpeg + PIL 是 CPU/IO 阻塞的,丢到线程池里跑避免堵死事件循环 + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + None, + _do_extract, + video, azimuths, elevations, total_rotation_deg, + ) diff --git a/art-agent/backend/app/services/view_transform.py b/art-agent/backend/app/services/view_transform.py index 6065d08..daa69c9 100644 --- a/art-agent/backend/app/services/view_transform.py +++ b/art-agent/backend/app/services/view_transform.py @@ -1,4 +1,8 @@ -"""视角变换服务 — 调用 Zero123++ 等模型,将单张图片转为多视角图片。""" +"""视角变换服务 — 调度单图→多视角生成,支持两种管道: + +- grid 管道:Zero123++ 直接输出 6 宫格拼接图,切分后得到固定 6 视角。 +- mesh 管道:Trellis/Hunyuan3D → .glb + 预渲染视频 → 抽帧 → 可选风格还原。 +""" import base64 import logging @@ -13,6 +17,9 @@ from PIL import Image from app.config import get_view_transform_model_config from app.services.image_gen import _make_replicate_client, GENERATED_DIR, to_data_uri +from app.services import mesh_generator, style_restorer, video_frame_extractor + +DEFAULT_MESH_AZIMUTHS = [0, 60, 120, 180, 240, 300] logger = logging.getLogger(__name__) @@ -29,9 +36,30 @@ def _split_grid_image( 返回格式:[{"url": "/generated/xxx.png", "azimuth": 30, "elevation": 30}, ...] Grid 布局从左到右、从上到下依次对应 view_labels 中的视角。 + + 由于不同 Replicate 部署版本输出的拼接图布局可能不同(有的是 3列×2行 的 + 宽图,有的是 2列×3行 的高图),这里会根据实际图像长宽比自动纠正 cols/rows—— + 假定每个单视角为正方形,按 aspect ratio 推断真实布局,防止切图错位。 """ img = Image.open(image_path) w, h = img.size + + expected_cells = cols * rows + if expected_cells > 0 and len(view_labels) == expected_cells: + # 假定每个视角为正方形:真实 aspect ratio = cols / rows + configured_ratio = cols / rows + actual_ratio = w / h if h else configured_ratio + # 如果实际 ratio 与配置差异较大(超过 20%),判定为布局反了 + if actual_ratio > 0 and ( + max(configured_ratio, actual_ratio) / min(configured_ratio, actual_ratio) > 1.2 + ): + logger.warning( + "Grid 布局与实际图像不匹配:配置 cols=%d rows=%d(ratio=%.2f)," + "实际图像 %dx%d(ratio=%.2f),自动交换 cols/rows。", + cols, rows, configured_ratio, w, h, actual_ratio, + ) + cols, rows = rows, cols + cell_w = w // cols cell_h = h // rows @@ -73,30 +101,60 @@ async def _download_to_local(url: str) -> Path: async def transform_view( image_path: str, model_id: str | None = None, + azimuths: list[int] | None = None, + elevations: list[int] | None = None, + preserve_style: bool = True, ) -> dict[str, Any]: """调用视角变换模型,返回多视角图片列表。 Args: image_path: 输入图片路径(本地路径、data URI 或 URL) - model_id: 视角变换模型 ID,默认 zero123plus + model_id: 视角变换模型 ID(zero123plus / trellis / hunyuan3d) + azimuths: mesh 管道专用,方位角数组 + elevations: mesh 管道专用,仰角数组(当前一期仅 0 有效) + preserve_style: mesh 管道专用,是否走阶段 3 风格还原 Returns: + 统一字段(不同管道可能缺省): { "success": bool, "images": [{"url": str, "azimuth": int, "elevation": int}, ...], - "grid_image": str, # 原始拼接图的本地 URL + "grid_image": str | None, # grid 管道:原始拼接图 URL + "mesh_url": str | None, # mesh 管道:.glb URL + "color_video": str | None, # mesh 管道:360° color video URL + "normal_video": str | None, # mesh 管道:法线 video URL + "pipeline": "grid" | "mesh", "model_name": str, "error": str | None, } """ config = get_view_transform_model_config(model_id) + pipeline = config.get("pipeline", "grid") + + GENERATED_DIR.mkdir(parents=True, exist_ok=True) + + if pipeline == "mesh": + return await _transform_view_mesh( + image_path=image_path, + config=config, + azimuths=azimuths, + elevations=elevations, + preserve_style=preserve_style, + ) + + return await _transform_view_grid(image_path=image_path, config=config) + + +async def _transform_view_grid( + image_path: str, + config: dict[str, Any], +) -> dict[str, Any]: + """原 Zero123++ 逻辑:单次调用 → grid 图 → 切 6 张。""" model_name = config["name"] replicate_model_id = config["model_id"] grid_layout = config["grid_layout"] output_views = config["output_views"] - GENERATED_DIR.mkdir(parents=True, exist_ok=True) - try: image_uri = to_data_uri(image_path) input_params = {"image": image_uri} @@ -141,6 +199,10 @@ async def transform_view( "success": True, "images": view_images, "grid_image": grid_local_url, + "mesh_url": None, + "color_video": None, + "normal_video": None, + "pipeline": "grid", "model_name": model_name, "error": None, } @@ -152,6 +214,144 @@ async def transform_view( "success": False, "images": [], "grid_image": None, + "mesh_url": None, + "color_video": None, + "normal_video": None, + "pipeline": "grid", "model_name": model_name, "error": detail, } + + +async def _transform_view_mesh( + image_path: str, + config: dict[str, Any], + azimuths: list[int] | None, + elevations: list[int] | None, + preserve_style: bool, +) -> dict[str, Any]: + """Mesh 管道调度器:阶段 1 生成 mesh + video;阶段 2/3 后续 Phase 接入。 + + Phase 1 验收状态:只跑阶段 1,返回 .glb / color_video / normal_video 本地 URL, + images 列表为空。Phase 2 完成后会填充 images(raw 抽帧结果), + Phase 3 完成后 images 会是经 IP-Adapter+ControlNet 重绘的最终图。 + """ + model_name = config["name"] + model_id = config["id"] + + # === Stage 1:生成 mesh + 预渲染视频 === + stage1 = await mesh_generator.generate_mesh(image_path, model_id=model_id) + if not stage1.get("success"): + return { + "success": False, + "images": [], + "grid_image": None, + "mesh_url": None, + "color_video": None, + "normal_video": None, + "combined_video": None, + "pipeline": "mesh", + "model_name": model_name, + "error": stage1.get("error") or "阶段 1 失败", + } + + # === Stage 2:抽帧 + 切左右半(color + normal)=== + # azimuths / elevations 缺省时用默认 6 视角;长度对齐 + if not azimuths: + azimuths = list(DEFAULT_MESH_AZIMUTHS) + if elevations is None or not elevations: + elevations = [0] * len(azimuths) + elif len(elevations) < len(azimuths): + elevations = list(elevations) + [0] * (len(azimuths) - len(elevations)) + elif len(elevations) > len(azimuths): + elevations = list(elevations[: len(azimuths)]) + + # 优先 color_video(当前 Trellis 部署版恒为 None),缺则 fallback 到 combined_video + combined_video_path = stage1.get("combined_video_path") + frames: list[dict[str, Any]] = [] + stage2_error: str | None = None + if combined_video_path: + try: + frames = await video_frame_extractor.extract_by_azimuth( + combined_video_path=combined_video_path, + azimuths=azimuths, + elevations=elevations, + total_rotation_deg=360, + ) + except Exception as e: + stage2_error = f"Stage 2 抽帧失败:{e}" + logger.error(stage2_error, exc_info=True) + else: + stage2_error = "Trellis 未返回 combined_video,无法执行 Stage 2 抽帧" + logger.error(stage2_error) + + # === Stage 3:风格还原(可选)=== + # preserve_style=True 时对每一帧的 normal_png 做 IP-Adapter+ControlNet 重绘, + # 用户原图做风格参考;失败的单帧会回退为 Stage 2 的 color 帧,不阻塞其它帧。 + stage3_error: str | None = None + stage3_results: list[dict[str, Any]] = [] + did_stage3 = bool(preserve_style) and bool(frames) + if did_stage3: + try: + stage3_results = await style_restorer.restore_batch( + original_image=image_path, + frames=frames, + concurrency=3, + ) + except Exception as e: + stage3_error = f"Stage 3 风格还原失败:{e}" + logger.error(stage3_error, exc_info=True) + + # 组装最终 images: + # - Stage 3 成功的帧 → 用重绘后的 URL + # - Stage 3 失败 / 未跑 → 回退到 Stage 2 的 color 帧(保证前端至少能看到点东西) + images: list[dict[str, Any]] = [] + for i, f in enumerate(frames): + restyled = stage3_results[i] if i < len(stage3_results) else None + if restyled and restyled.get("success") and restyled.get("url"): + final_url = restyled["url"] + frame_error = None + else: + final_url = f["color_png"] + frame_error = (restyled or {}).get("error") if did_stage3 else None + images.append({ + "url": final_url, + "azimuth": f["az"], + "elevation": f["el"], + "normal_url": f["normal_png"], + "color_url": f["color_png"], + "frame_idx": f["frame_idx"], + "restyled": bool(restyled and restyled.get("success")), + **({"restyle_error": frame_error} if frame_error else {}), + }) + + # Stage 2 失败不把整体置为失败——Stage 1 的 mesh / video 产物仍有交付价值 + # Stage 3 部分失败也不置为整体失败(每帧都有 Stage 2 回退) + success = bool(stage1.get("success")) and (stage2_error is None) + + # 合并错误信息(Stage 2 和 Stage 3 错误都要暴露给前端) + error_parts = [e for e in (stage2_error, stage3_error) if e] + error_out = "; ".join(error_parts) if error_parts else None + + if did_stage3: + restyled_ok = sum(1 for img in images if img.get("restyled")) + stage_label = f"stage3 ({restyled_ok}/{len(images)} restyled)" + elif frames: + stage_label = "stage2" + else: + stage_label = "stage1" + + return { + "success": success, + "images": images, + "grid_image": None, + "mesh_url": stage1.get("glb_path"), + "color_video": stage1.get("color_video_path"), + "normal_video": stage1.get("normal_video_path"), + "combined_video": combined_video_path, + "pipeline": "mesh", + "model_name": model_name, + "error": error_out, + "_stage": stage_label, + "_preserve_style_requested": bool(preserve_style), + } diff --git a/art-agent/backend/requirements-lock.txt b/art-agent/backend/requirements-lock.txt index 64ed7d38b914a82016057ceef484a83aa2448bcd..03f64e36fa85d8e177f592eaaaeb8e5c7b541b73 100644 GIT binary patch delta 481 zcmYLGyGjF56g>M5w@MT>=CLM=EFpxDY~m|uYv~7wph;As#wbQnEG#Uo+)R^NtWs%j zWoaP@en4sq5x>B5uR*yCTg;p}GiUp;=V++s!a_0JNQIZV@V*uf-JX_$tNBxcnzYMr zk5>2vRHTvwHnq8F$+ekj(t%!0;IN(J?c)HNB)@{W))xg&Jz7bd!5Io0-NJ>!X&3s#QS4sxij! zV8!ub`S`X1__mKVJAr+>fWGbFi7~V%`G8$^yiks1m2E+J#+(@hj*oT6!uYztQn-iS3PN#hSmij?$?%VxK9;n-vNYD?huw_C3 delta 584 zcmX|3yQ0o zV+C>WCpbAcI&={nw4QUDO32}ooOAB;-sEZOZdxDyc9zYEOh{+e-=0r!RgzP=l&YLb z5!Z&SNmg>uj70oqT7$U$!=gv)AOfCGuXH!Z-A({8rVrWG z(?$q9e#cJC74%idyUx}heF^FrbI`mISA*82B6T=RU}AQZ str: + candidates = sorted( + GENERATED_DIR.glob("trellis_combined_*.mp4"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if not candidates: + raise FileNotFoundError( + "generated/ 下找不到 trellis_combined_*.mp4;请先跑一次 Phase 1 " + "(scripts/test_trellis.py)或显式传入视频路径" + ) + return f"/generated/{candidates[0].name}" + + +async def main() -> int: + video_arg = sys.argv[1] if len(sys.argv) > 1 else _pick_default_video() + # 允许传相对路径或纯文件名 + if not video_arg.startswith("/generated/"): + if video_arg.startswith("generated/"): + video_arg = "/" + video_arg.replace("\\", "/") + elif not video_arg.startswith("/") and not Path(video_arg).is_absolute(): + # 如果只给了个文件名,假定在 generated/ 下 + if (GENERATED_DIR / video_arg).exists(): + video_arg = f"/generated/{video_arg}" + + print(f"[test_frame_extract] 输入视频:{video_arg}") + print("[test_frame_extract] 抽取 6 视角:az=[0,60,120,180,240,300],el 全 0") + + azimuths = [0, 60, 120, 180, 240, 300] + elevations = [0] * len(azimuths) + + frames = await video_frame_extractor.extract_by_azimuth( + combined_video_path=video_arg, + azimuths=azimuths, + elevations=elevations, + total_rotation_deg=360, + ) + + print("[test_frame_extract] 抽帧结果:") + print(json.dumps(frames, ensure_ascii=False, indent=2)) + + # 简单完整性校验 + all_exist = True + for f in frames: + for key in ("color_png", "normal_png"): + rel = f.get(key) + if not rel: + print(f"[test_frame_extract] 警告:帧 az={f['az']} 缺少 {key}") + all_exist = False + continue + local = GENERATED_DIR / Path(rel).name + size = local.stat().st_size if local.exists() else -1 + status = "OK" if size > 0 else "MISSING" + print(f" [{status}] az={f['az']:3d} {key}: {rel} ({size} bytes)") + if size <= 0: + all_exist = False + + if not all_exist: + print("[test_frame_extract] 部分文件缺失,判定失败") + return 1 + + print( + f"[test_frame_extract] 成功:共 {len(frames)} 个视角," + f"color+normal 合计 {len(frames) * 2} 张已落盘到 {GENERATED_DIR}" + ) + print( + "[test_frame_extract] 请目视检查:打开 generated/ 下任意 view_*_color.png," + "确认 az=0 正面、az=180 背面、其余按顺序环绕;normal 帧应为蓝紫调法线图。" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/art-agent/backend/scripts/test_style_restore.py b/art-agent/backend/scripts/test_style_restore.py new file mode 100644 index 0000000..29baa93 --- /dev/null +++ b/art-agent/backend/scripts/test_style_restore.py @@ -0,0 +1,167 @@ +"""Phase 3 / Checkpoint 3 验收脚本:独立验证 Stage 3 风格还原链路。 + +复用 Phase 2 已抽出的 normal 帧,无需再跑 Trellis(省 $0.041)。 +默认会挑最新一组 view_*_el0_normal.png 作为结构参考,并让用户指定原图。 + +用法(在 backend 目录激活 venv 后): + # 1. 全 6 帧批量重绘(~6 × $0.07 ≈ $0.42) + python scripts/test_style_restore.py <原图路径> + + # 2. 只跑一帧做快速冒烟(便宜 ~$0.07) + python scripts/test_style_restore.py <原图路径> --single + + # 3. 手动指定 normal 帧前缀(不指定时用最新一组) + python scripts/test_style_restore.py <原图路径> --prefix view_xxxxxxxx + +成功标准: +- 退出码 0 +- 打印的每一帧都返回 success=True 且 url 以 /generated/restyled_ 开头 +- 打开 generated/restyled_* 图片,目视检查:风格与原图一致、视角符合 az 标注 +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from pathlib import Path + +_BACKEND_ROOT = Path(__file__).resolve().parent.parent +if str(_BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(_BACKEND_ROOT)) + +from dotenv import load_dotenv # noqa: E402 + +load_dotenv(_BACKEND_ROOT / ".env", override=True) + +from app.services import style_restorer # noqa: E402 +from app.services.image_gen import GENERATED_DIR # noqa: E402 + + +def _pick_latest_normal_group() -> tuple[str, list[dict]]: + """在 generated/ 下挑一组最新的 view__az*_el0_normal.png,按 az 排好序。 + + 返回 (prefix, frames),frames = [{az, el, normal_png, color_png}, ...] + """ + all_normals = sorted( + GENERATED_DIR.glob("view_*_el0_normal.png"), + key=lambda p: p.stat().st_mtime, + reverse=True, + ) + if not all_normals: + raise FileNotFoundError( + "generated/ 下找不到 view_*_el0_normal.png;请先跑 scripts/test_frame_extract.py" + ) + # 最新文件的 prefix:view_<8hex>_az... + latest_name = all_normals[0].name + # 形如 view_63731b77_az0_el0_normal.png → prefix = view_63731b77 + parts = latest_name.split("_") + prefix = "_".join(parts[:2]) + return prefix, _load_group_by_prefix(prefix) + + +def _load_group_by_prefix(prefix: str) -> list[dict]: + normals = sorted(GENERATED_DIR.glob(f"{prefix}_az*_el0_normal.png")) + if not normals: + raise FileNotFoundError(f"找不到前缀为 {prefix} 的 normal 帧") + frames = [] + for n in normals: + # 从文件名解析 az:view_63731b77_az120_el0_normal.png → az=120 + tokens = n.stem.split("_") + az = 0 + el = 0 + for t in tokens: + if t.startswith("az"): + try: + az = int(t[2:]) + except ValueError: + pass + elif t.startswith("el"): + try: + el = int(t[2:]) + except ValueError: + pass + color = n.with_name(n.name.replace("_normal.png", "_color.png")) + frames.append({ + "az": az, + "el": el, + "normal_png": f"/generated/{n.name}", + "color_png": f"/generated/{color.name}" if color.exists() else None, + "frame_idx": -1, + }) + frames.sort(key=lambda f: f["az"]) + return frames + + +async def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("original", help="用户原图路径(本地相对/绝对路径均可)") + parser.add_argument("--prefix", default=None, help="normal 帧的文件名前缀(如 view_63731b77)") + parser.add_argument("--single", action="store_true", help="只跑第一帧做快速冒烟") + parser.add_argument("--scale", type=float, default=None, help="覆盖 IP-Adapter 权重(0-1)") + parser.add_argument("--cn-scale", type=float, default=None, help="覆盖 ControlNet 权重(0-1)") + args = parser.parse_args() + + original = args.original + original_path = Path(original) + if not original_path.is_absolute(): + original_path = (_BACKEND_ROOT / original).resolve() + if not original_path.exists(): + print(f"[test_style_restore] 原图不存在:{original_path}") + return 2 + + if args.prefix: + frames = _load_group_by_prefix(args.prefix) + prefix = args.prefix + else: + prefix, frames = _pick_latest_normal_group() + + if args.single: + frames = frames[:1] + + override_params = {} + if args.scale is not None: + override_params["scale"] = args.scale + if args.cn_scale is not None: + override_params["controlnet_conditioning_scale"] = args.cn_scale + + print(f"[test_style_restore] 原图:{original_path}") + print(f"[test_style_restore] normal 帧组 prefix={prefix},共 {len(frames)} 帧") + print(f"[test_style_restore] 角度列表:{[f['az'] for f in frames]}") + if override_params: + print(f"[test_style_restore] 覆盖参数:{override_params}") + print( + f"[test_style_restore] 预计耗时 ~{72}s × {len(frames)}," + f"成本 ~${0.07 * len(frames):.2f}(并发 3 实际会更快)" + ) + + results = await style_restorer.restore_batch( + original_image=str(original_path), + frames=frames, + params=override_params or None, + concurrency=3, + ) + + print("[test_style_restore] 重绘结果:") + print(json.dumps(results, ensure_ascii=False, indent=2)) + + ok = sum(1 for r in results if r.get("success")) + print(f"[test_style_restore] 成功 {ok}/{len(results)} 帧") + + if ok == 0: + print("[test_style_restore] 全军覆没,判定失败") + return 1 + if ok < len(results): + print("[test_style_restore] 部分帧失败,视为部分通过;请检查上方错误信息") + return 0 + + print( + "[test_style_restore] 全部成功;请目视检查 generated/restyled_* 图片:" + "风格应贴近原图、视角按 az 顺序环绕。" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/art-agent/backend/scripts/test_trellis.py b/art-agent/backend/scripts/test_trellis.py new file mode 100644 index 0000000..36e38db --- /dev/null +++ b/art-agent/backend/scripts/test_trellis.py @@ -0,0 +1,72 @@ +"""Phase 1 验收脚本:直接调用 Trellis,验证 .glb + color_video + normal_video 能下到本地。 + +用法(在 backend 目录激活 venv 之后): + python scripts/test_trellis.py uploads/0e6fd7e47a524bb9ad18de0b72495e0e.png + +不带参数时会尝试使用 uploads/ 下最新的 png 作为测试图。 + +成功标准: +- 退出码 0 +- 终端打印 glb_path / color_video_path / normal_video_path 均为 /generated/ 开头 +- 实际到 backend/generated/ 目录能看到 3 个文件且 size > 0 +""" + +import asyncio +import json +import sys +from pathlib import Path + +# 允许直接 `python scripts/test_trellis.py` 跑 +_BACKEND_ROOT = Path(__file__).resolve().parent.parent +if str(_BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(_BACKEND_ROOT)) + +# .env 加载(复用项目 main.py 的习惯) +from dotenv import load_dotenv # noqa: E402 + +load_dotenv(_BACKEND_ROOT / ".env", override=True) + +from app.services import mesh_generator # noqa: E402 + + +def _pick_default_image() -> str: + uploads = _BACKEND_ROOT / "uploads" + pngs = sorted(uploads.glob("*.png"), key=lambda p: p.stat().st_mtime, reverse=True) + if not pngs: + raise FileNotFoundError(f"uploads/ 下没有 png 文件,请显式传入图片路径") + return str(pngs[0].relative_to(_BACKEND_ROOT)).replace("\\", "/") + + +async def main() -> int: + image_arg = sys.argv[1] if len(sys.argv) > 1 else _pick_default_image() + # 规范化成 / 开头的 "本地相对 URL" 以便 to_data_uri 识别 + if not image_arg.startswith("/") and not image_arg.startswith("data:") and not image_arg.startswith("http"): + image_arg = "/" + image_arg.lstrip("./").replace("\\", "/") + + print(f"[test_trellis] 输入图:{image_arg}") + print("[test_trellis] 开始调用 Trellis(预计 ~30s 起)...") + + result = await mesh_generator.generate_with_trellis(image_arg) + + print("[test_trellis] Trellis 返回:") + print(json.dumps(result, ensure_ascii=False, indent=2)) + + if not result.get("success"): + print("[test_trellis] 失败:", result.get("error")) + return 1 + + generated_dir = _BACKEND_ROOT / "generated" + for key in ("glb_path", "color_video_path", "normal_video_path"): + rel = result.get(key) + if not rel: + print(f"[test_trellis] 警告:缺少 {key}") + continue + local = generated_dir / Path(rel).name + size = local.stat().st_size if local.exists() else -1 + print(f"[test_trellis] {key}: {rel} (本地大小: {size} bytes)") + + return 0 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) diff --git a/docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md b/docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md new file mode 100644 index 0000000..75097a0 --- /dev/null +++ b/docs/art-agent/VIEW-TRANSFORM-MESH-PIPELINE-PLAN.md @@ -0,0 +1,463 @@ +# Mesh-Pipeline 视角变换 · 跨会话实施计划 + +> **状态**:SPEC / 尚未开工 +> **最后更新**:2026-04-20 15:30 +> **负责人**:Agent + 用户 @shine +> **适用对象**:任意新会话的 Agent,本文档自包含,无需额外上下文即可开工 + +--- + +## 0 · 上下文(Why) + +### 0.1 起因 + +当前项目 `art-agent` 已经集成了 Zero123++(`jd7h/zero123plusplus`)做单图→多视角变换,但存在固有缺陷: + +1. **只输出 6 个固定视角**,`azimuth/elevation` 写死在 `config.py`,用户无法指定任意相机位姿 +2. **大角度一致性差**,Zero123++ 主要训练于小物体,对建筑类规整几何 + 大角度偏转常出现立面错位 +3. **无几何中间产物**,纯图对图黑盒映射,无法二次利用 + +### 0.2 目标 + +新增一条"Mesh Pipeline"视角变换路径,与现有 `zero123plus` 并列保留,让用户可以: + +- 输入 1 张 2D 图(典型场景:带描边卡通风的建筑,如教堂/房屋/塔楼) +- 输出 N 张**保留原画风**、**几何一致**的新视角 2D 图 +- 可指定任意 `azimuth/elevation`(至少阶段性支持 Trellis 360° 中的任意角度) + +**非目标**:输出 3D mesh 给用户下载(mesh 只是中间产物);替代 Zero123++(它作为"轻量路径"继续保留)。 + +### 0.3 相关历史上下文 + +| 记录 | 说明 | +|---|---| +| `CL-20260416-1000` | Zero123++ 首次集成 | +| `CL-20260420-1212` | Zero123++ 切图布局修复(cols=2/rows=3) | +| `CL-20260420-1501` | 后端 watchfiles 依赖修复,uvicorn --reload 生效 | +| `PF-20260420-1212` | Grid 切图布局坑("3×2" 措辞歧义) | +| `PF-20260420-1501` | Windows uvicorn --reload 无 watchfiles 退化坑 | + +--- + +## 1 · 核心决策 + +### 1.1 三段式流水线 + +``` +[输入: 1 张 2D 图] + ↓ +【阶段 1】单图 3D 重建 → 生成 textured .glb + 预渲染 360° 视频 + ↓ +【阶段 2】从视频抽帧 / 自定义相机渲染 → 多视角"几何参考图" + ↓ +【阶段 3】SDXL + ControlNet + IP-Adapter 二次重绘 → 还原原画风 + ↓ +[输出: N 张保留风格、几何一致的 2D 新视角图] +``` + +### 1.2 模型选型 + +| 阶段 | 主选 | 备选 / 高级选项 | 理由 | +|---|---|---|---| +| 阶段 1 | **Trellis** (`firtoz/trellis`) | Hunyuan3D-2 (`tencent/hunyuan3d-2`) | Trellis $0.041/30s 且自带 color_video,Hunyuan 贵 3×慢 4×但 mesh 更扎实 | +| 阶段 2 | **ffmpeg 从 Trellis `color_video` 抽帧** | pyrender 加载 .glb 任意角度渲染 | Trellis 已送 360° 环绕视频,首版完全省事 | +| 阶段 3 | **`chigozienri/ip_adapter-sdxl-controlnet-depth`** | 自建 SDXL + 多 ControlNet 组合 | 一个 Replicate 模型打包了 SDXL+ControlNet-Depth+IP-Adapter | + +### 1.3 为什么不用 Hunyuan3D-2 作默认 + +- 贵 3 倍($0.12 vs $0.041) +- 慢 4 倍(127s vs 30s) +- **不返回预渲染视频** → 必须额外实现阶段 2(pyrender / blender 渲染器) +- 唯一优势:mesh 几何质量更高 + +→ 留作"高质量模式"供用户显式选择,一期不默认启用。 + +### 1.4 为什么不用 ControlNet-Depth 必须先估深度 + +`chigozienri/ip_adapter-sdxl-controlnet-depth` 的 `controlnet_input` 要求已经是 depth map。两条路: + +- **路 A**:从 Trellis `normal_video` 抽帧,把 normal 当"结构约束"直接喂(近似方案,不完美但可用) +- **路 B**:从 color_video 抽帧后,额外调用 `chenxwh/depth-anything-v2` 生成 depth map + +一期走路 A 简化;若效果不足再升级路 B(多一次 API 调用 + $0.003/张)。 + +--- + +## 2 · 技术数据清单(Schema) + +### 2.1 Trellis — `firtoz/trellis` + +- **版本 ID**:`e8f6c45206993f297372f5436b90350817bd9b4a0d52d2a76df50c1c8afa2b3c` +- **成本**:$0.041/次(~24 次/$1) +- **耗时**:~30 秒 +- **硬件**:Nvidia A100 80GB +- **输入关键字段**: + +| 字段 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `images` | `array[uri]` | 必填 | 输入图(支持多图,一期只传 1 张) | +| `seed` | int | 0 | 种子 | +| `randomize_seed` | bool | true | 一期设为 false 便于复现 | +| `texture_size` | int (512-2048) | 1024 | GLB 纹理分辨率 | +| `mesh_simplify` | float (0.9-0.98) | 0.95 | 网格简化,建议保持默认 | +| `generate_color` | bool | **true** | **必开**,用于得到 color_video | +| `generate_normal` | bool | **true** | **一期开启**,便于阶段 3 做结构约束 | +| `generate_model` | bool | **true** | **必开**,否则不输出 .glb | +| `save_gaussian_ply` | bool | false | 不需要 | +| `ss_sampling_steps` | int (1-50) | 12 | 默认即可 | +| `slat_sampling_steps` | int (1-50) | 12 | 默认即可 | +| `ss_guidance_strength` | float (0-10) | 7.5 | 默认即可 | +| `slat_guidance_strength` | float (0-10) | 3 | 默认即可 | + +- **输出字段**(⚠️ 实测修订 2026-04-20 16:15,版本 `e8f6c452...`): + +| 字段 | 类型 | 实测行为 | +|---|---|---| +| `model_file` | uri | ✅ .glb 文件(`generate_model=true` 时)| +| `color_video` | uri | ❌ **恒为 null** — 该部署版不单独输出 | +| `normal_video` | uri | ❌ **恒为 null** — 该部署版不单独输出 | +| `combined_video` | uri | ✅ **唯一可用的视频** — color+normal **左右并排** mp4 | +| `gaussian_ply` | uri | ✅ 高斯点云(本项目不用) | +| `no_background_images` | array[uri] | ✅ 去背景的预处理图 | + +**实测 schema 踩坑**(已记为 PF-20260420-1600): + +1. 输入字段必须用 `images`(数组)—— 传 `image`(单数字符串)会 422 `input: images is required`,即使 Replicate README example 里写的是 `image`。 +2. 尽管 `generate_color=True` 和 `generate_normal=True` 都开,`color_video` 和 `normal_video` 字段也**恒为 null**,只有 `combined_video` 可用。 +3. 因此 Phase 2 抽帧的数据源是 `combined_video`,抽帧后需要**切左半 = color 帧、切右半 = normal 帧**。 + +### 2.2 Hunyuan3D-2 — `tencent/hunyuan3d-2`(备选) + +- **版本 ID**:`b1b9449a1277e10402781c5d41eb30c0a0683504fb23fab591ca9dfc2aabe1cb` +- **成本**:$0.12/次 +- **耗时**:~127 秒 +- **硬件**:Nvidia L40S +- **输入**:`image`, `steps` (20-50, default 50), `guidance_scale` (1-20, default 5.5), `seed`, `octree_resolution` (default 256), `remove_background` (default true) +- **输出**:`mesh` (uri, .glb) —— **无视频无多视角预览**,必须配套阶段 2 自定义渲染 + +### 2.3 ControlNet + IP-Adapter — `chigozienri/ip_adapter-sdxl-controlnet-depth` + +- **版本 ID**:`0436c8702ef52616be5c30948551b3af6a86c821cca9b01f11ac297624fff14c` +- **成本**:$0.070/次 +- **耗时**:~72 秒 +- **硬件**:Nvidia L40S +- **输入关键字段**: + +| 字段 | 类型 | 默认 | 说明 | +|---|---|---|---| +| `image` | uri | 必填 | **IP-Adapter 参考图**(此处传**用户原图**保持风格) | +| `controlnet_input` | uri | 必填 | **Depth / Normal / Lineart 参考图**(此处传**阶段 2 抽出来的 normal 帧**) | +| `scale` | float (0-1) | 0.6 | IP-Adapter 权重,建议调到 **0.7-0.8** 强化风格 | +| `controlnet_conditioning_scale` | float (0-1) | 0.6 | 结构约束权重,建议 **0.7-0.9** | +| `prompt` | str | "" | 留空或用 "same building, same art style" | +| `negative_prompt` | str | "" | "blurry, distorted, different style" | +| `num_outputs` | int (1-4) | 1 | 一期 1 | +| `num_inference_steps` | int (1-500) | 30 | 默认即可 | +| `seed` | int | 随机 | 调参阶段固定 | + +### 2.4 依赖清单 + +**一期必装**(首版走 Trellis + ffmpeg 抽帧,最小化依赖): + +``` +# backend requirements.txt +ffmpeg-python==0.2.0 # 或者直接在后端宿主机装 ffmpeg 可执行文件 +``` + +**二期可选**(若要上 Hunyuan3D-2 + 自定义相机): + +``` +trimesh==4.5.0 +pyrender==0.1.45 +PyOpenGL==3.1.7 +# Windows 额外需要 OSMesa 或改用 Blender headless(二选一) +``` + +**系统依赖**: + +- `ffmpeg` 可执行文件(Windows:`choco install ffmpeg` 或手动放 PATH) +- 若二期启用:Windows 上 OSMesa 编译困难,推荐用 Blender 4.x headless 替代 + +--- + +## 3 · 数据流与接口设计 + +### 3.1 新增后端服务模块 + +``` +art-agent/backend/app/services/ +├── view_transform.py # [改造] 管道调度器 +├── mesh_generator.py # [新增] 阶段 1:调用 Trellis / Hunyuan3D-2 +├── video_frame_extractor.py # [新增] 阶段 2(轻量):ffmpeg 抽帧 +├── mesh_renderer.py # [新增, 二期] 阶段 2(自由相机):pyrender 加载 .glb 渲染任意角度 +└── style_restorer.py # [新增] 阶段 3:ControlNet + IP-Adapter 重绘 +``` + +### 3.2 管道调度伪代码 + +```python +# services/view_transform.py +async def transform_view_mesh_pipeline( + image_path: str, + model_id: str = "trellis", # or "hunyuan3d" + azimuths: list[int] = [0, 60, 120, 180, 240, 300], + elevations: list[int] = [0, 0, 0, 0, 0, 0], + preserve_style: bool = True, # 是否走阶段 3 +) -> dict: + # === 阶段 1 === + mesh_result = await mesh_generator.generate(image_path, model_id) + # 返回 {glb_url, color_video_url?, normal_video_url?} + + # === 阶段 2 === + if model_id == "trellis": + # 从视频抽帧(Trellis 的 360° 视频约 5s,对应 az 0→360°) + frames = await video_frame_extractor.extract_by_azimuth( + color_video_url=mesh_result["color_video_url"], + normal_video_url=mesh_result["normal_video_url"], + azimuths=azimuths, + total_rotation_deg=360, + ) + else: # hunyuan3d + # 二期:自定义相机渲染 + frames = await mesh_renderer.render_views( + glb_path=mesh_result["glb_path"], + azimuths=azimuths, + elevations=elevations, + ) + # frames = [{az, el, color_png, normal_png}, ...] + + # === 阶段 3 === + if preserve_style: + final_images = await asyncio.gather(*[ + style_restorer.restore( + original_image=image_path, + structure_image=f["normal_png"], + azimuth=f["az"], + elevation=f["el"], + ) + for f in frames + ]) + else: + final_images = [f["color_png"] for f in frames] + + return { + "success": True, + "images": [ + {"url": img, "azimuth": az, "elevation": el} + for img, (az, el) in zip(final_images, zip(azimuths, elevations)) + ], + "mesh_url": mesh_result.get("glb_url"), + "model_name": f"Mesh Pipeline ({model_id})", + } +``` + +### 3.3 config.py 扩展 + +```python +VIEW_TRANSFORM_MODELS = { + "zero123plus": {...}, # 保留不动 + "trellis": { + "id": "trellis", + "name": "Trellis (Mesh Pipeline)", + "provider": "replicate", + "model_id": "firtoz/trellis:e8f6c45206993f297372f5436b90350817bd9b4a0d52d2a76df50c1c8afa2b3c", + "description": "3D 重建 + 任意视角 + 风格还原,建筑友好(~30s + 风格化 ~72s/张)", + "pipeline": "mesh", # 新增字段区分管道类型 + "default_params": { + "texture_size": 1024, + "mesh_simplify": 0.95, + "generate_color": True, + "generate_normal": True, + "generate_model": True, + }, + }, + "hunyuan3d": { + "id": "hunyuan3d", + "name": "Hunyuan3D-2 (高质量)", + "provider": "replicate", + "model_id": "tencent/hunyuan3d-2:b1b9449a1277e10402781c5d41eb30c0a0683504fb23fab591ca9dfc2aabe1cb", + "description": "几何质量最佳(~127s),需自定义渲染(二期启用)", + "pipeline": "mesh", + "enabled": False, # 一期先禁用 + }, +} + +IMAGE_MODELS["ip-adapter-controlnet-depth"] = { + "id": "ip-adapter-controlnet-depth", + "name": "IP-Adapter + ControlNet Depth (内部)", + "provider": "replicate", + "model_id": "chigozienri/ip_adapter-sdxl-controlnet-depth:0436c8702ef52616be5c30948551b3af6a86c821cca9b01f11ac297624fff14c", + "internal": True, # 不在前端展示,仅用于阶段 3 + ... +} +``` + +### 3.4 工具层 `tools.py` + +`transform_view` 工具参数扩展(向后兼容): + +```python +{ + "name": "transform_view", + "parameters": { + "image_path": {...}, + "model_id": { + "enum": ["zero123plus", "trellis", "hunyuan3d"], + "default": "zero123plus", + }, + # 以下为 mesh pipeline 专属参数 + "azimuths": {"type": "array", "default": [0, 60, 120, 180, 240, 300]}, + "elevations": {"type": "array", "default": [0, 0, 0, 0, 0, 0]}, + "preserve_style": {"type": "boolean", "default": True}, + }, +} +``` + +### 3.5 前端改造 + +- 模型下拉增加 "Trellis (Mesh Pipeline)" 和 "Hunyuan3D-2 (高质量)" 选项 +- 选中 mesh pipeline 时,额外显示: + - "保留原画风格" 开关(默认开) + - 角度输入:简化为"快速预设(6/8/12 视角)" + "自定义 azimuth 数组"(高级) +- 结果区域:除了 N 张图,还展示 `color_video` 可预览 + +--- + +## 4 · 施工任务清单(跨会话可独立执行) + +### Phase 1:Trellis 接入(预计 1.5h) + +**验收标准**:用 curl / Replicate SDK 能跑通一次 Trellis,拿到 .glb + color_video URL,下载到本地。 + +- [x] P1-1 `services/mesh_generator.py` 新建,封装 `generate_with_trellis(image_path)` 函数(调用 Replicate + 下载结果到 `GENERATED_DIR`) +- [x] P1-2 `config.py` 新增 `VIEW_TRANSFORM_MODELS["trellis"]` 条目 +- [x] P1-3 `services/view_transform.py` 根据 `pipeline == "mesh"` 分流到 `mesh_generator` +- [x] P1-4 `tools.py` 的 `transform_view` 增加 `model_id` 参数默认值兼容 +- [x] P1-5 本地手动测试:`scripts/test_trellis.py` 可独立运行验证 Trellis → .glb + color_video + normal_video 本地落地(**等用户执行**) + +### Phase 2:ffmpeg 抽帧(预计 1h) + +**验收标准**:从 color_video.mp4 抽出指定 azimuth 对应的帧,保存为 PNG,帧数正确、角度映射正确。 + +- [x] P2-1 宿主机 ffmpeg 未装,改用 `imageio-ffmpeg==0.5.1`(静态二进制随包,免系统依赖),已写入 `requirements.txt` +- [x] P2-2 `services/video_frame_extractor.py` 新建,实现 `extract_by_azimuth(combined_video_path, azimuths, elevations=None, total_rotation_deg=360)` + - 策略改为"一次抽全帧到 temp dir + 按帧索引挑" —— 比 `-ss` 逐帧抽更稳(不受 keyframe 对齐影响),且 5s 视频成本可忽略 + - 抽出后用 PIL 按中线切:左半 → color_png、右半 → normal_png(实测 1024×512 → 512×512 方形) + - `ffmpeg + PIL` 是阻塞的,用 `run_in_executor` 丢到线程池避免堵住 async 事件循环 + - 临时目录抽完即清理,只保留切好的最终帧 +- [x] P2-3 `view_transform._transform_view_mesh` 串接 Stage 1 → Stage 2:默认 `azimuths=[0,60,120,180,240,300]`;Stage 2 失败不会把整体置为失败(Stage 1 的 .glb / video 仍有交付价值,`error` 字段单独带回 Stage 2 错误) +- [x] P2-4 本地测试脚本 `scripts/test_frame_extract.py`(**等用户执行**验收) + - Agent 已自测:120 帧视频 → az=0/60/120/180/240/300 映射到 frame_idx=0/20/40/60/80/100,完全均匀 ✓ + - 12 个 color/normal PNG 全部落盘,尺寸 512×512 方形 ✓ + +### Phase 3:风格还原重绘(预计 1.5h) + +**验收标准**:6 张视角图通过 IP-Adapter+ControlNet 重绘后,风格与原图一致、几何不变形。 + +- [x] P3-1 `config.py` 注册 `IMAGE_MODELS["ip-adapter-controlnet-depth"]`,标 `internal=True`,同时改 `get_image_models_list()` 过滤 internal,不污染前端下拉 +- [x] P3-2 `services/style_restorer.py` 新建,实现 `restore(original_image, structure_image, ...)` + `restore_batch()`(带并发 Semaphore 防 Replicate rate limit) +- [x] P3-3 `view_transform.py` 增加阶段 3;`preserve_style=True` 时对 Stage 2 的每一帧做重绘;单帧失败自动回退为 Stage 2 color 帧,不阻塞整体 +- [ ] P3-4 **等待 Checkpoint 3**:调参轮次需要用户执行 `scripts/test_style_restore.py <原图>`,分别尝试 `scale=0.6/0.7/0.8` × `controlnet_conditioning_scale=0.6/0.7/0.9`,选出最佳组合写入默认参数 + +### Phase 4:前端 + 端到端联调(预计 1h) + +- [ ] P4-1 `types.ts` 扩展 `ViewTransformModel` 类型 +- [ ] P4-2 前端模型下拉新增两个选项 +- [ ] P4-3 工作台选中 mesh 管道时显示"保留原画风"开关 +- [ ] P4-4 结果面板支持展示视频(color_video)+ N 张风格化后的视角图 +- [ ] P4-5 端到端联调:前端上传一张卡通建筑图 → 选 Trellis → 确认 N 张结果正确显示 + +### Phase 5:Hunyuan3D-2 + 自由相机(**可选,二期**,预计 3-4h) + +**仅在 Phase 1-4 完成且用户明确要求自由 elevation/远近相机时启动。** + +- [ ] P5-1 venv 安装 `trimesh + pyrender + PyOpenGL`;Windows 上验证 headless 可用性 +- [ ] P5-2 若 pyrender 在 Windows 不可行 → 改用 Blender 4.x headless(需 `blender.exe -b --python render.py`) +- [ ] P5-3 `services/mesh_renderer.py` 新建,实现 `render_views(glb_path, azimuths, elevations, distance=2.0, fov=45)` +- [ ] P5-4 启用 `config.py` 的 `hunyuan3d` 条目(`enabled=True`) +- [ ] P5-5 前端"高级模式"解锁 elevation 滑块 + +### Phase 6:收尾 + +- [ ] P6-1 `changelog-full.md` / `-recent.md` / `-headlines.md` 三层同步记录 +- [ ] P6-2 发现的坑 → `pitfalls.md` +- [ ] P6-3 更新 `art-agent/README.md` 里视角变换段落 + +--- + +## 5 · 风险与兜底 + +| 风险 | 概率 | 影响 | 兜底 | +|---|---|---|---| +| Trellis 对 2D 卡通描边图重建几何偏糊 | 中 | 阶段 3 结构约束失效 | 先用 SDXL+InstantStyle 把原图"半写实化"一版给 Trellis 吃,最后阶段 3 用原 2D 图做 IP-Adapter 还原风格 | +| 从 color_video 抽帧只有水平环绕(elevation=0),不能仰视/俯视 | 高 | 无法满足"仰视大教堂"等需求 | 二期启用 Phase 5 的 pyrender 自由相机,或改用 Hunyuan3D-2 + 自定义渲染 | +| IP-Adapter+ControlNet 6 次调用并发打满 Replicate rate limit | 低 | 部分帧失败 | 改成串行 + 指数退避 + 失败单帧重试 | +| 总链路太慢(>3 分钟)用户失去耐心 | 中 | 产品体验差 | 前端用 SSE 流式展示中间产物:阶段 1 完成显示视频 → 阶段 3 每张完成立即插入 | +| ffmpeg 未装 | 高(Windows) | Phase 2 不能跑 | 开工前检查 `ffmpeg -version`,未装先 `choco install ffmpeg` 或用 Python 库 `imageio-ffmpeg` 绑定 | +| Trellis 某次输出 video 是 4s 某次 6s,固定 total_rotation_deg=360 可能不准 | 低 | 抽帧角度偏差 | 用 `ffprobe` 动态读取总帧数;如有疑问就固定 24 帧均匀抽样全部保留 | + +--- + +## 6 · 成本与性能预估 + +**一次完整请求(6 视角,保留风格)**: + +| 阶段 | 耗时 | 成本 | +|---|---|---| +| Trellis 重建 | ~30s | $0.041 | +| ffmpeg 抽 6 帧 | ~2s(本地) | 0 | +| ControlNet+IP-Adapter × 6(并发) | ~72s | $0.42 | +| **合计** | **~105s** | **~$0.46** | + +参考:当前 Zero123++ 一次 ~$0.02,~15s。新管道贵约 23×、慢约 7×,但产出质量对建筑是跨代提升。 + +--- + +## 7 · 跨会话恢复指引 + +**新会话的 Agent 在开工前务必**: + +1. 读本文件 **§0-§3**,建立完整技术背景 +2. 读 `art-agent/backend/app/services/view_transform.py` 当前实现(Zero123++ 的做法作参照) +3. 读 `art-agent/backend/app/config.py` 的 `VIEW_TRANSFORM_MODELS` / `IMAGE_MODELS` 当前结构 +4. 读 `art-agent/backend/app/agent/tools.py` 看 `transform_view` 工具定义 +5. 按 **§4 的 Phase 顺序**推进,Phase 之间互相独立,完成一个交付一个 +6. 每个 Phase 完成后在本文件对应 checkbox 打勾(改 `[ ]` 为 `[x]`),并在本节底部追加简短进度 note + +**不要一次 Phase 1-6 全做完再交付**——每个 Phase 完成后停下来让用户测试/审阅。 + +### 进度日志 + + + +- 2026-04-20 · 会话"Zero123++ 切图诊断 + 方案调研" → 完成 §2 全部 schema 查证,写入本 SPEC。尚未开工 Phase 1。 +- 2026-04-20 · 会话"按计划实施 Mesh Pipeline" → 完成 Phase 1(P1-1 ~ P1-5):新增 `mesh_generator.py`、`config.py` 注册 trellis/hunyuan3d(后者 enabled=False)、`view_transform.py` 按 `pipeline` 字段分流、`tools.py` 扩 `model_id/azimuths/preserve_style` 参数,额外提供 `scripts/test_trellis.py` 供用户手动验收。 +- 2026-04-20 16:15 · **Checkpoint 1 通过** → Agent 运行 `scripts/test_trellis.py` 实测发现: + - ✅ Trellis 调用链路完全打通:Replicate token / 代理 / httpx 下载 / GENERATED_DIR 落盘均正常 + - ✅ `.glb` (1.89MB) 下载正常 + - ⚠️ 意外发现:该部署版 `color_video` / `normal_video` 字段**恒为 null**,只有 `combined_video` 可用(color+normal 左右并排 mp4 ~450KB) + - 修订 §2.1 输出字段表为实测版本,P2-2 任务改为"从 combined_video 抽帧 + 切左右半",已记 PF-20260420-1600 踩坑 + - Phase 1 verdict:**functionally green**,可以开 Phase 2 +- 2026-04-20 · 会话"继续执行 Phase 2" → 完成 Phase 2 全部 item(P2-1 ~ P2-4): + - 放弃系统 ffmpeg,改用 `imageio-ffmpeg` 自带二进制(22.6MB wheel,Win amd64 下已测 v4.2.2 ok) + - 新增 `services/video_frame_extractor.py` — 一次全帧抽取 + 按索引挑 + PIL 切左右半的极简实现 + - `view_transform.py` 的 mesh 管道调度器现已走到 Stage 2,返回的 `images[]` 含 `{url, azimuth, elevation, normal_url, frame_idx}` + - 新增 `scripts/test_frame_extract.py` 供用户复用已有 combined_video 做 Checkpoint 2 验收(无需再花钱跑 Trellis) + - Agent 自测:120 帧 → 6 视角 frame_idx 均匀分布 (0/20/40/60/80/100),所有切图 512×512 方形,逻辑正确 + - **等待 Checkpoint 2 用户验收**:跑 `python scripts/test_frame_extract.py` 并目视检查 `generated/view_*_color.png` 6 张图的角度顺序 +- 2026-04-20 · 会话"继续执行 Phase 3(风格还原)" → 完成 P3-1 ~ P3-3: + - `config.py` 注册内部模型 `ip-adapter-controlnet-depth`(`internal=True`),并让 `get_image_models_list()` 自动过滤,不会污染前端下拉;新增 helper `get_style_restore_model_id()` + - 新增 `services/style_restorer.py`:`restore()` 单帧调用 + `restore_batch()` 带 Semaphore 并发 3 的批量接口;统一处理 FileOutput/list/str 的 Replicate 返回 + - `view_transform._transform_view_mesh` 接入 Stage 3:当 `preserve_style=True` 对每一帧做 IP-Adapter+ControlNet 重绘;**单帧失败会自动回退为 Stage 2 color 帧**,不阻塞整体;`images[i]` 新增 `restyled` 布尔和 `color_url`(保留原 color 便于对比) + - 最终返回的 `_stage` 字段从 `stage2` 升级为 `stage3 (N/M restyled)`,便于前端 / 日志快速判断链路完整度 + - 新增 `scripts/test_style_restore.py` 供 Checkpoint 3 用户验收:支持 `--single` 冒烟、`--prefix` 指定 normal 帧组、`--scale/--cn-scale` 覆盖参数,复用 Phase 2 已有帧无需再跑 Trellis + - **等待 Checkpoint 3 用户验收**:跑 `python scripts/test_style_restore.py <原图> --single` 做冒烟,通过后再全量 6 帧并调参(P3-4) + +--- + +## 8 · 开放问题(留给未来决策) + +- 前端角度输入 UX:要不要做一个"3D 预览转盘"让用户拖动选角度?(Phase 4+ 再说) +- 是否把 mesh `.glb` 作为资产保存到用户的 gallery?(涉及存储与用户系统对接) +- Hunyuan3D-2 路线的 Windows 渲染器:pyrender vs Blender 的选型需要一次实测才能决定 +- 是否对同一原图的重复请求加缓存?(Trellis 结果相对稳定,可按 image_hash 缓存 .glb)