Refine seller store implementation

This commit is contained in:
R524809
2026-08-31 14:37:28 +08:00
parent 957289f717
commit 2a667b4f3b
27 changed files with 1650 additions and 624 deletions
+15
View File
@@ -40,11 +40,26 @@ APP_BASE_URL=http://127.0.0.1:8800
# ZIP 超过 2MB 自动使用临时磁盘,不继续占用服务进程内存。 # ZIP 超过 2MB 自动使用临时磁盘,不继续占用服务进程内存。
# IMAGE_UPLOAD_MAX_BYTES=20971520 # IMAGE_UPLOAD_MAX_BYTES=20971520
# IMAGE_DOWNLOAD_MAX_BYTES=20971520 # IMAGE_DOWNLOAD_MAX_BYTES=20971520
# 图片最大解码像素,默认 40,000,000;按真实宽 × 高限制,防止超大图耗尽内存。
# IMAGE_MAX_PIXELS=40000000
# 远程图片只允许公网 HTTP(S) 地址;每次跳转都会再次校验,禁止 localhost/内网地址。
# 连接超时、单次读写超时、整次下载总超时(秒),以及最多重定向次数。
# IMAGE_DOWNLOAD_CONNECT_TIMEOUT=10
# IMAGE_DOWNLOAD_READ_TIMEOUT=30
# IMAGE_DOWNLOAD_TOTAL_TIMEOUT=60
# IMAGE_DOWNLOAD_MAX_REDIRECTS=3
# COLLECTION_MAX_IMAGES=100 # COLLECTION_MAX_IMAGES=100
# EXPORT_MAX_IMAGES=50 # EXPORT_MAX_IMAGES=50
# EXPORT_ZIP_MAX_BYTES=209715200 # EXPORT_ZIP_MAX_BYTES=209715200
# EXPORT_ZIP_MEMORY_THRESHOLD_BYTES=2097152 # EXPORT_ZIP_MEMORY_THRESHOLD_BYTES=2097152
# ── 商品轮播视频(FFmpeg)──
# Ubuntu: sudo apt install -y ffmpeg。默认输出为 WB 兼容的 810×1080、30fps、无音轨 H.264 MP4。
# VIDEO_FFMPEG_PATH=ffmpeg
# VIDEO_FFPROBE_PATH=ffprobe
# VIDEO_MAX_BYTES=47185920
# VIDEO_RENDER_TIMEOUT_SECONDS=120
# ── V2.1:套图生图(试算页一键生成 / 单张 AI 生图,provider 按模型名自动路由)── # ── V2.1:套图生图(试算页一键生成 / 单张 AI 生图,provider 按模型名自动路由)──
# 豆包 Seedream(火山方舟) # 豆包 Seedream(火山方舟)
ARK_API_KEY= ARK_API_KEY=
+8 -4
View File
@@ -1,6 +1,6 @@
# 工作交接说明(HANDOFF) # 工作交接说明(HANDOFF)
> 更新:2026-08-30 > 更新:2026-08-31
> 读者:下一个接手工作的 AI / 开发者(工具无关)。先读本文再动手,避免重复探索。 > 读者:下一个接手工作的 AI / 开发者(工具无关)。先读本文再动手,避免重复探索。
> 配套文档:[`README`](../../README.md)(项目总览)、[`collect.md`](./collect.md)(采集)、[`trial-page.md`](./trial-page.md)(试算页)、[`image-suite.md`](./image-suite.md)(生图)、[`background-jobs.md`](./background-jobs.md)(**后台任务中心方案、实施状态与路线图**)、[`image-asset-lifecycle.md`](./image-asset-lifecycle.md)(图片与水印存储规则)、[`api.md`](./api.md)(契约)、[`backend-structure.md`](./backend-structure.md)(后端结构) > 配套文档:[`README`](../../README.md)(项目总览)、[`collect.md`](./collect.md)(采集)、[`trial-page.md`](./trial-page.md)(试算页)、[`image-suite.md`](./image-suite.md)(生图)、[`background-jobs.md`](./background-jobs.md)(**后台任务中心方案、实施状态与路线图**)、[`image-asset-lifecycle.md`](./image-asset-lifecycle.md)(图片与水印存储规则)、[`api.md`](./api.md)(契约)、[`backend-structure.md`](./backend-structure.md)(后端结构)
@@ -73,6 +73,10 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
| **本轮:生成结果区** | 「导出图片」按钮(水印按钮右侧,复用 POST /api/export/images,ZIP 内分「套图结果/单张生图」文件夹);「单张 AI 生图」小节改名「生图记录」(落库历史,刷新后保留) | | **本轮:生成结果区** | 「导出图片」按钮(水印按钮右侧,复用 POST /api/export/images,ZIP 内分「套图结果/单张生图」文件夹);「单张 AI 生图」小节改名「生图记录」(落库历史,刷新后保留) |
| **本轮:图片生命周期** | 采集、上传与 AI 图片统一保存为 `original + current`;只保留当前水印版;重加/移除水印自动回收旧对象;删除单图/硬删商品会清理本地或七牛对象;具体规则见 [`image-asset-lifecycle.md`](./image-asset-lifecycle.md) | | **本轮:图片生命周期** | 采集、上传与 AI 图片统一保存为 `original + current`;只保留当前水印版;重加/移除水印自动回收旧对象;删除单图/硬删商品会清理本地或七牛对象;具体规则见 [`image-asset-lifecycle.md`](./image-asset-lifecycle.md) |
| **本轮:图片资源上限** | 单张上传/下载默认 20 MB;采集最多 100 张;导出最多 50 张、总量 200 MB;ZIP 超过 2 MB 自动落临时磁盘并分块返回,配置见 `.env.example` | | **本轮:图片资源上限** | 单张上传/下载默认 20 MB;采集最多 100 张;导出最多 50 张、总量 200 MB;ZIP 超过 2 MB 自动落临时磁盘并分块返回,配置见 `.env.example` |
| **本轮:远程图片 SSRF 防护** | 代理、采集转存、AI 参考图、远程导出仅允许公网 HTTP(S) 地址;每跳重定向复验,限制连接/读写/总耗时与跳转次数;本项目 `/media` 素材继续本地直读,正常防盗链代理不受影响 |
| **本轮:图片格式与元数据校验** | 上传、采集转存、生成图统一按真实字节验证 JPEG/PNG/WebP/GIF/BMP,默认限制 4,000 万像素;静态图纠正 EXIF 方向并落库实际宽高。历史 39 条本地图像已回填尺寸;`server/scripts/backfill_asset_dimensions.py` 可用于其他旧库 |
| **本轮:试算页图片组件拆分与按页加载** | `TrialSuitePanel.tsx` 拆出采集/结果/失败图片网格(`TrialImageGrids.tsx`)、水印设置(`TrialWatermarkSettings.tsx`)与共享类型;采集分组、套图结果、历史结果按每页 24 张渲染,缩略图启用原生懒加载。套图/水印轮询仍由父组件集中管理。 |
| **本轮:商品轮播视频** | 试算页独立的「07 视频制作」卡片:AI 图优先选择、2–5 图按选择顺序合成为 810×1080/30fps/H.264 无音轨 MP4;进行中卡片为 3:4 灰底加载态,成片可预览、下载或删除。任务复用 `background_jobs`,刷新恢复进行中/失败/完成状态;取消与删除都会立即移除记录及视频文件。Worker 依赖系统 `ffmpeg`/`ffprobe`(Ubuntu 安装 `apt install -y ffmpeg`)。 |
| **本轮:生图任务与轮询** | 单张 AI 编辑改为提交统一异步任务并立即返回 `suite_id`;套图与单张弹窗均采用无重叠递归轮询、取消在途请求与失败退避;套图任务 ID 可在刷新页面后从浏览器会话恢复 | | **本轮:生图任务与轮询** | 单张 AI 编辑改为提交统一异步任务并立即返回 `suite_id`;套图与单张弹窗均采用无重叠递归轮询、取消在途请求与失败退避;套图任务 ID 可在刷新页面后从浏览器会话恢复 |
| **本轮:持久化图片任务** | 套图、单张编辑与批量添加水印已迁入数据库任务表和独立 Worker;Pcore/RightAPI/通义万相保存第三方任务 ID 并可在重启后恢复轮询;水印逐图进度可刷新恢复、取消并按失败项重试,详见 [`background-jobs.md`](./background-jobs.md) | | **本轮:持久化图片任务** | 套图、单张编辑与批量添加水印已迁入数据库任务表和独立 Worker;Pcore/RightAPI/通义万相保存第三方任务 ID 并可在重启后恢复轮询;水印逐图进度可刷新恢复、取消并按失败项重试,详见 [`background-jobs.md`](./background-jobs.md) |
| **本轮:关键 bug 修复** | ① axios 全局 Content-Type 导致 FormData 被 JSON 化 → 上传 422(`api.ts` 移除全局头,**今后勿设**);② TrialPage `load()` 置 loading 导致整页闪 Spin、卸载面板(杀生图轮询/丢勾选)→ 改静默刷新;③ web v1 页面因 cdn.tailwindcss.com 被墙整页无样式 → tailwind 运行时固化到 `web/vendor/tailwind.js` | | **本轮:关键 bug 修复** | ① axios 全局 Content-Type 导致 FormData 被 JSON 化 → 上传 422(`api.ts` 移除全局头,**今后勿设**);② TrialPage `load()` 置 loading 导致整页闪 Spin、卸载面板(杀生图轮询/丢勾选)→ 改静默刷新;③ web v1 页面因 cdn.tailwindcss.com 被墙整页无样式 → tailwind 运行时固化到 `web/vendor/tailwind.js` |
@@ -90,7 +94,7 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
1. **Pcore 生图接入 + 规划器修复 + 张数策略** 1. **Pcore 生图接入 + 规划器修复 + 张数策略**
`server/services/{generator,planner,suite_service,prompts/__init__}.py`、`server/{api/suite.py,schemas/suite.py,config/settings.py}`、`studio/src/services/suite.ts`、`.env.example`、`docs/v2.1/{image-suite,api}.md` `server/services/{generator,planner,suite_service,prompts/__init__}.py`、`server/{api/suite.py,schemas/suite.py,config/settings.py}`、`studio/src/services/suite.ts`、`.env.example`、`docs/v2.1/{image-suite,api}.md`
2. **试算页 UI 迭代 + 采集箱批量操作 + 请求层修复** 2. **试算页 UI 迭代 + 采集箱批量操作 + 请求层修复**
`studio/src/pages/trial/{TrialSuitePanel,TrialInfoPanel,TrialPage}.tsx`、`studio/src/pages/collection/CollectionPage.tsx`、`studio/src/services/api.ts` `studio/src/pages/trial/{TrialSuitePanel,TrialImageGrids,TrialWatermarkSettings,trialImageTypes,TrialInfoPanel,TrialPage}.tsx`、`studio/src/pages/collection/CollectionPage.tsx`、`studio/src/services/api.ts`
3. **web v1 样式修复(CDN 固化)+ start.command 插件自动构建** 3. **web v1 样式修复(CDN 固化)+ start.command 插件自动构建**
`web/ozonSeller.html`、`web/vendor/tailwind.js`(新目录)、`start.command` `web/ozonSeller.html`、`web/vendor/tailwind.js`(新目录)、`start.command`
4. **图片生命周期与水印回收**:`server/services/image_assets.py`、`server/services/storage.py`、`server/{api/materials,api/products,models/asset}.py`、迁移 `a41e8c7f4b20_add_asset_image_lifecycle.py`、试算页水印入口 4. **图片生命周期与水印回收**:`server/services/image_assets.py`、`server/services/storage.py`、`server/{api/materials,api/products,models/asset}.py`、迁移 `a41e8c7f4b20_add_asset_image_lifecycle.py`、试算页水印入口
@@ -132,8 +136,8 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
| 01 商品信息(采买地址图标、型号/货号联动) | `pages/trial/TrialInfoPanel.tsx` | `api/materials.py` | | 01 商品信息(采买地址图标、型号/货号联动) | `pages/trial/TrialInfoPanel.tsx` | `api/materials.py` |
| 02 价格试算 | `pages/trial/TrialPricingPanel.tsx` + `pricing/pricing.ts` | — | | 02 价格试算 | `pages/trial/TrialPricingPanel.tsx` + `pricing/pricing.ts` | — |
| 03 俄文文案 | `pages/product/CopyPanel.tsx`(共用) | `api/ai.py` + `services/deepseek.py` + `prompts/copy_ru.py` | | 03 俄文文案 | `pages/product/CopyPanel.tsx`(共用) | `api/ai.py` + `services/deepseek.py` + `prompts/copy_ru.py` |
| 04 图片与生图(7 列网格/悬浮按钮/图名) | `pages/trial/TrialSuitePanel.tsx` + `AiImageGenModal.tsx` | `api/suite.py` + `services/{jobs,job_worker,suite_service,generator,planner,prompts/,watermark,image_assets}.py` | | 04 图片与生图(7 列网格/悬浮按钮/图名) | `pages/trial/TrialSuitePanel.tsx`(状态/轮询)+ `TrialImageGrids.tsx`(素材/结果卡片与分页)+ `AiImageGenModal.tsx` | `api/suite.py` + `services/{jobs,job_worker,suite_service,generator,planner,prompts/,watermark,image_assets}.py` |
| 图片原图/水印/删除回收 | `TrialSuitePanel.tsx`(水印设置) | `api/materials.py`、`api/products.py`、`services/{image_assets,storage}.py` | | 图片原图/水印/删除回收 | `TrialSuitePanel.tsx`(状态与调用)+ `TrialWatermarkSettings.tsx`(设置与任务状态) | `api/materials.py`、`api/products.py`、`services/{image_assets,storage}.py` |
| 生图供应商路由 | `services/suite.ts`(IMAGE_MODEL_OPTIONS 带供应商名;DEFAULT_IMAGE_MODEL) | `schemas/suite.py`(MODEL_PROVIDERS/`@pcore` 约定)+ `services/generator.py`(generate_pcore 等) | | 生图供应商路由 | `services/suite.ts`(IMAGE_MODEL_OPTIONS 带供应商名;DEFAULT_IMAGE_MODEL) | `schemas/suite.py`(MODEL_PROVIDERS/`@pcore` 约定)+ `services/generator.py`(generate_pcore 等) |
| 05 登记与导出 | `pages/trial/TrialExportPanel.tsx`(localStorage `trialRegisterRecords`) | — | | 05 登记与导出 | `pages/trial/TrialExportPanel.tsx`(localStorage `trialRegisterRecords`) | — |
| 生成结果导出 | `TrialSuitePanel.tsx` handleExportResult(套图+单张分组 ZIP) | `api/export.py` | | 生成结果导出 | `TrialSuitePanel.tsx` handleExportResult(套图+单张分组 ZIP) | `api/export.py` |
+14 -3
View File
@@ -101,7 +101,14 @@
`POST /api/assets/watermark/remove` 当前仍为短时同步操作,返回 `{ "updated_ids": [], "failed": [] }`。两接口均只保留原图和当前展示版本:重新加水印会在数据库提交后删除旧水印文件;前端展示、单张下载与导出均使用 `stored_url`。 `POST /api/assets/watermark/remove` 当前仍为短时同步操作,返回 `{ "updated_ids": [], "failed": [] }`。两接口均只保留原图和当前展示版本:重新加水印会在数据库提交后删除旧水印文件;前端展示、单张下载与导出均使用 `stored_url`。
## 8. 导出与代理(Phase B / D) ## 8. 商品轮播视频
- `POST /api/products/{product_id}/video-jobs`:请求 `{ "asset_ids": ["uuid", "uuid"] }`,按传入顺序选择当前商品的 2–5 张已转存图片,立即返回 `{ "job_id": "uuid" }`。服务端只接受素材 ID,不接受任意图片 URL。
- `GET /api/products/{product_id}/video-jobs`、`GET /api/video-jobs/{job_id}`:读取任务和成片状态;`POST /api/video-jobs/{job_id}/cancel` 会取消并立即清理任务记录,`POST /api/video-jobs/{job_id}/retry` 重试失败任务,`DELETE /api/video-jobs/{job_id}` 删除成片及记录,`GET /api/video-jobs/{job_id}/download` 下载 MP4。
- Worker 使用 FFmpeg 合成无音轨轮播视频:AI 生成图在前端选择器中优先展示;支持 2–5 张,按 4.5 / 3.5 / 3.0 / 2.8 秒单图停留并使用 0.35 秒淡入淡出。默认输出为 `810×1080`、`30 fps`、`H.264`、`yuv420p`、最大 45 MB,结果存入 `products/{product_id}/videos/{job_id}/result.mp4`。
- 需要系统安装 `ffmpeg` 与 `ffprobe`(Ubuntu:`apt install -y ffmpeg`);路径和大小/超时限制可通过 `VIDEO_*` 环境变量覆盖。任务复用 `background_jobs`,无需新增视频业务表;进行中、失败和已完成成片可在刷新后继续查看,取消会立即移除任务及临时文件,不保留记录。
## 9. 导出与代理(Phase B / D)
| 接口 | 方法 | Req | Res | | 接口 | 方法 | Req | Res |
|---|---|---|---| |---|---|---|---|
@@ -111,11 +118,15 @@
图片资源上限:默认单张上传/远程下载 20 MB;一次导出最多 50 张,ZIP 内源图片总量与成品 ZIP 均最多 200 MB。超过限制返回 HTTP `413` 及可读错误提示;ZIP 超过 2 MB 自动写入临时磁盘,并以分块响应下载。 图片资源上限:默认单张上传/远程下载 20 MB;一次导出最多 50 张,ZIP 内源图片总量与成品 ZIP 均最多 200 MB。超过限制返回 HTTP `413` 及可读错误提示;ZIP 超过 2 MB 自动写入临时磁盘,并以分块响应下载。
## 9. 采集相关(Phase C 使用,均已实现) 图片校验与元数据:图片上传、采集转存和 AI 生成图写入素材库时,服务端以实际字节识别 JPEG、PNG、WebP、GIF 或 BMP,不信任 URL 后缀和上传声明的 MIME。解码失败或不支持的格式返回/记录为失败;默认限制为 40,000,000 像素(`IMAGE_MAX_PIXELS`),并记录 `product_assets.width`、`height`。静态图片若带 EXIF 方向,会在落库前纠正并移除该方向标记;动画图不重编码,以避免丢帧。`POST /api/materials/bytes` 成功响应会额外返回 `width`、`height`,不合规图片返回 `422`。
远程图片安全规则:`/api/proxy-image`、采集图转存、AI 参考图及远程导出统一只请求公网 `http(s)` 地址;`localhost`、回环、内网、链路本地、多播和其他保留 IP 会返回 `400`。重定向不会自动跟随:服务端最多跟随 3 跳,并在每一跳重新解析和校验目标地址。已转存的本项目 `/media/...` 素材直接读取本地磁盘,不经过 HTTP 回环请求。下载仍受 `IMAGE_DOWNLOAD_MAX_BYTES` 限制,连接/读写/总超时与重定向次数可通过 `IMAGE_DOWNLOAD_CONNECT_TIMEOUT`、`IMAGE_DOWNLOAD_READ_TIMEOUT`、`IMAGE_DOWNLOAD_TOTAL_TIMEOUT`、`IMAGE_DOWNLOAD_MAX_REDIRECTS` 配置;下载超时返回 `504`。
## 10. 采集相关(Phase C 使用,均已实现)
- `POST /api/materials`:插件上传采集结果;**响应含 `product_id`**(插件用于打开 `/trial/{id}`)。 - `POST /api/materials`:插件上传采集结果;**响应含 `product_id`**(插件用于打开 `/trial/{id}`)。
- `GET /api/collected?platform=&itemId=`:采集前查重。 - `GET /api/collected?platform=&itemId=`:采集前查重。
## 10. 冻结接口(不投入,保留代码) ## 11. 冻结接口(不投入,保留代码)
`/api/publish/*`、`/api/shops/*`、`/api/categories/*`——Ozon API 直传链路(D1)。 `/api/publish/*`、`/api/shops/*`、`/api/categories/*`——Ozon API 直传链路(D1)。
+20 -16
View File
@@ -54,22 +54,25 @@
- 已实施:页面独立轮询水印任务,刷新可恢复,支持取消、重试全部失败项和按单张重试;等待期间素材被修改时会拒绝旧任务覆盖,单项重试会重新冻结最新版本。 - 已实施:页面独立轮询水印任务,刷新可恢复,支持取消、重试全部失败项和按单张重试;等待期间素材被修改时会拒绝旧任务覆盖,单项重试会重新冻结最新版本。
- 边界:移除水印目前仍是同步短操作;若未来单次处理规模继续提高,可迁入同一 Worker 框架。 - 边界:移除水印目前仍是同步短操作;若未来单次处理规模继续提高,可迁入同一 Worker 框架。
### P2:统一图片格式、尺寸和元数据校验 ### P2:统一图片格式、尺寸和元数据校验(已实施)
- 现状:存储层主要依据 URL 后缀或 Content-Type 推断格式,未统一校验文件大小、像素、实际 MIME 类型和方向信息。 - 已实施:`normalize_image_bytes()` 在上传、采集转存和 AI 生成图写入素材库时,使用 Pillow 解码真实字节,不再信任 URL 后缀或客户端 Content-Type;仅接受 JPEG、PNG、WebP、GIF、BMP。
- 涉及:`server/services/storage.py`、`server/api/materials.py`。 - 已实施:维持原有单文件字节上限,并新增 `IMAGE_MAX_PIXELS`(默认 40,000,000)限制;强制完整解码,拒绝损坏、伪造或像素过大的图片。
- 建议:上传/下载后用图片库校验真实格式,限制像素与文件大小,按需要压缩、纠正 EXIF 方向并记录宽高。 - 已实施:静态图纠正 EXIF 方向;记录可信的实际宽高到 `product_assets.width`、`height`。动画图只校验并保留原字节,不重编码以避免丢帧。
- 已实施:历史本地库的 39 条图片素材已通过 `server/scripts/backfill_asset_dimensions.py` 回填宽高;该脚本不重写图片文件,可用于其他旧库。
- 边界:当前不做强制压缩/转码,避免无谓损失采集原图质量;后续若存储成本成为问题,再按真实运营数据引入按格式和尺寸分级压缩策略。
--- ---
## 3. 服务端:安全与资源保护 ## 3. 服务端:安全与资源保护
### P0:限制图片代理和导出 URL,防止 SSRF ### P0:限制图片代理和导出 URL,防止 SSRF(已实施)
- 现状:图片代理、单图下载、ZIP 导出会请求客户端传入的 HTTP(S) URL,并跟随重定向。 - 已实施:`services.storage.download_bytes()` 统一校验代理、采集转存、AI 参考图和远程导出的 URL,仅允许公网 `http(s)` 地址;拒绝 `localhost`、回环、内网、链路本地、多播及其他保留 IP。
- 涉及:`server/api/proxy.py`、`server/api/export.py`、`server/services/storage.py`。 - 已实施:关闭 HTTP 客户端自动重定向,最多手动跟随 3 跳,并在每一跳重新解析和校验目标地址;限制连接、读写和整次下载总耗时。原有的流式大小上限继续生效。
- 风险:若服务可被非本机访问,可能被用于读取内网地址、云元数据地址,或下载超大文件。 - 兼容:项目自身已经转存的 `/media/...` 素材仍通过受限本地文件读取;采集扩展的 `GET /api/proxy-image?url=https://公网源图` 防盗链回退不受影响。
- 建议:解析并拒绝回环、内网、链路本地与保留 IP;每次重定向后再次校验;设置域名白名单/允许列表、最大响应体、连接与总耗时上限。 - 涉及:`server/api/proxy.py`、`server/api/export.py`、`server/services/storage.py`、`.env.example`。
- 边界:当前策略按 DNS 解析结果判定公网地址;若未来服务面向高风险公网环境,可进一步使用出站网络策略或固定 DNS/代理以降低 DNS 重绑定窗口。
### P0:限制上传与 ZIP 导出的内存占用(已实施基础版本) ### P0:限制上传与 ZIP 导出的内存占用(已实施基础版本)
@@ -89,16 +92,17 @@
## 4. 前端:可维护性与交互 ## 4. 前端:可维护性与交互
### P2:拆分 TrialSuitePanel ### P2:拆分 TrialSuitePanel(已实施)
- 现状:`TrialSuitePanel.tsx` 已约 1,400 行,采集图和生成图卡片、悬浮操作、选择、下载等逻辑有重复。 - 已实施:`TrialSuitePanel.tsx` 从约 1,829 行缩减为约 1,326 行;采集素材网格、生成结果网格、失败结果卡片提取到 `TrialImageGrids.tsx`,水印设置和水印任务状态提取到 `TrialWatermarkSettings.tsx`,图片分组、预览和结果类型统一到 `trialImageTypes.ts`。
- 建议:拆分为素材网格、结果网格、图片动作层、预览下载、水印设置、套图轮询等子组件/hooks。 - 已实施:采集图与生成图统一复用卡片的选择、删除、下载、AI 编辑和预览行为,避免两处网格逻辑继续分叉。
- 收益:降低后续 UI 修改遗漏风险,便于复用和测试。 - 边界:套图和水印轮询保留在父组件,继续集中管理页面级状态、刷新与取消逻辑;若轮询逻辑再次扩张,再独立为 hook。
### P2:图片多时采用懒加载/虚拟化 ### P2:图片多时采用懒加载/虚拟化(已实施基础版本)
- 现状:图片网格会一次性创建全部 Ant Design Image 与预览节点。 - 已实施:采集素材按分组、套图结果和历史生成记录均按每页 24 张分页;仅当前页创建 Ant Design `Image` 及预览节点,避免历史图片一次性进入 DOM。
- 建议:大量历史生成图时采用分页、虚拟列表或按需加载;缩略图使用合适尺寸和 `loading="lazy"`。 - 已实施:缩略图使用 `loading="lazy"`,滚动接近可视区才请求图片资源。
- 边界:当前采用分页而非滚动虚拟列表,预览导航限于当前页;若单个分组进一步增长到数百张以上,可基于真实使用情况再替换为虚拟网格或服务端游标分页。
### P2:补触屏与键盘操作 ### P2:补触屏与键盘操作
+16 -1
View File
@@ -20,7 +20,14 @@ from pydantic import BaseModel, Field
from api.proxy import guess_referer from api.proxy import guess_referer
from config import get_settings from config import get_settings
from services.storage import ResourceSizeLimitError, download_bytes, local_path, read_local_bytes from services.storage import (
ResourceDownloadTimeoutError,
ResourceSizeLimitError,
UnsafeRemoteUrlError,
download_bytes,
local_path,
read_local_bytes,
)
router = APIRouter(prefix="/api", tags=["export"]) router = APIRouter(prefix="/api", tags=["export"])
@@ -111,8 +118,12 @@ async def export_images(req: ExportImagesRequest):
for img in req.images: for img in req.images:
try: try:
data, ctype = await _download(img.url) data, ctype = await _download(img.url)
except UnsafeRemoteUrlError as exc:
raise HTTPException(status_code=400, detail=f"图片地址不允许导出:{exc}") from exc
except ResourceSizeLimitError as exc: except ResourceSizeLimitError as exc:
raise HTTPException(status_code=413, detail=f"无法导出「{img.key or '图片'}」:{exc}") from exc raise HTTPException(status_code=413, detail=f"无法导出「{img.key or '图片'}」:{exc}") from exc
except ResourceDownloadTimeoutError as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
continue # 单张下载失败不中断整包 continue # 单张下载失败不中断整包
if not _is_image(img.url, ctype): if not _is_image(img.url, ctype):
@@ -161,8 +172,12 @@ async def export_image(img: ExportImageItem):
"""代理下载单张图片,兼容源站防盗链与本地媒体路径。""" """代理下载单张图片,兼容源站防盗链与本地媒体路径。"""
try: try:
data, ctype = await _download(img.url) data, ctype = await _download(img.url)
except UnsafeRemoteUrlError as exc:
raise HTTPException(status_code=400, detail=f"图片地址不允许下载:{exc}") from exc
except ResourceSizeLimitError as exc: except ResourceSizeLimitError as exc:
raise HTTPException(status_code=413, detail=f"图片文件过大,无法下载:{exc}") from exc raise HTTPException(status_code=413, detail=f"图片文件过大,无法下载:{exc}") from exc
except ResourceDownloadTimeoutError as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=422, detail="图片下载失败") from exc raise HTTPException(status_code=422, detail="图片下载失败") from exc
if not _is_image(img.url, ctype): if not _is_image(img.url, ctype):
+22 -2
View File
@@ -16,6 +16,7 @@ from models.enums import AssetStatus, Stage
from schemas.collection import MaterialsRequest, MaterialsResponse, TextMaterial from schemas.collection import MaterialsRequest, MaterialsResponse, TextMaterial
from schemas.suite import SuiteOut, WatermarkOptions from schemas.suite import SuiteOut, WatermarkOptions
from services.image_assets import WatermarkUpdate, get_image_asset_service from services.image_assets import WatermarkUpdate, get_image_asset_service
from services.storage import ImageValidationError, ResourceSizeLimitError, normalize_image_bytes
from services.jobs import create_image_job, get_job, list_product_jobs, suite_view from services.jobs import create_image_job, get_job, list_product_jobs, suite_view
from config import get_settings from config import get_settings
@@ -258,6 +259,15 @@ async def upload_material_bytes(
raise HTTPException(status_code=413, detail=f"图片文件过大,单张最多 {limit // 1024 // 1024} MB") raise HTTPException(status_code=413, detail=f"图片文件过大,单张最多 {limit // 1024 // 1024} MB")
chunks.append(chunk) chunks.append(chunk)
data = b"".join(chunks) data = b"".join(chunks)
if type != "img":
raise HTTPException(status_code=400, detail="该接口目前仅支持上传图片素材")
try:
normalized = normalize_image_bytes(data, max_bytes=limit)
except ResourceSizeLimitError as exc:
raise HTTPException(status_code=413, detail=str(exc)) from exc
except ImageValidationError as exc:
raise HTTPException(status_code=422, detail=str(exc)) from exc
asset = ProductAsset( asset = ProductAsset(
product_id=product.id, product_id=product.id,
group_key=group_key, group_key=group_key,
@@ -272,13 +282,23 @@ async def upload_material_bytes(
# 直接转存为素材唯一原图(current 与 original 初始指向同一对象) # 直接转存为素材唯一原图(current 与 original 初始指向同一对象)
image_assets = get_image_asset_service() image_assets = get_image_asset_service()
try: try:
await image_assets.save_original(asset, data, file.content_type or "") await image_assets.save_original(
asset,
normalized.data,
normalized.content_type,
max_bytes=limit,
)
asset.status = AssetStatus.uploaded asset.status = AssetStatus.uploaded
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
asset.status = AssetStatus.failed asset.status = AssetStatus.failed
asset.error = str(exc)[:500] asset.error = str(exc)[:500]
await db.commit() await db.commit()
return {"asset_id": str(asset.id), "status": asset.status.value} return {
"asset_id": str(asset.id),
"status": asset.status.value,
"width": asset.width,
"height": asset.height,
}
@router.get("/products/{product_id}/fingerprints") @router.get("/products/{product_id}/fingerprints")
+10 -1
View File
@@ -8,7 +8,12 @@ from urllib.parse import urlparse
from fastapi import APIRouter, HTTPException, Query, Response from fastapi import APIRouter, HTTPException, Query, Response
from services.storage import ResourceSizeLimitError, download_bytes from services.storage import (
ResourceDownloadTimeoutError,
ResourceSizeLimitError,
UnsafeRemoteUrlError,
download_bytes,
)
router = APIRouter(prefix="/api", tags=["proxy"]) router = APIRouter(prefix="/api", tags=["proxy"])
@@ -40,8 +45,12 @@ async def proxy_image(url: str = Query(..., description="源站图片 URL")):
raise HTTPException(status_code=400, detail="仅支持 http/https URL") raise HTTPException(status_code=400, detail="仅支持 http/https URL")
try: try:
data, ctype = await download_bytes(url, referer=guess_referer(url)) data, ctype = await download_bytes(url, referer=guess_referer(url))
except UnsafeRemoteUrlError as exc:
raise HTTPException(status_code=400, detail=f"图片地址不允许代理:{exc}") from exc
except ResourceSizeLimitError as exc: except ResourceSizeLimitError as exc:
raise HTTPException(status_code=413, detail=f"图片文件过大,无法代理:{exc}") from exc raise HTTPException(status_code=413, detail=f"图片文件过大,无法代理:{exc}") from exc
except ResourceDownloadTimeoutError as exc:
raise HTTPException(status_code=504, detail=str(exc)) from exc
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
raise HTTPException(status_code=502, detail=f"图片拉取失败: {exc}") from exc raise HTTPException(status_code=502, detail=f"图片拉取失败: {exc}") from exc
if not ctype.startswith("image/"): if not ctype.startswith("image/"):
+140
View File
@@ -0,0 +1,140 @@
"""商品图片轮播视频:复用后台任务状态机,FFmpeg 在独立 Worker 中执行。"""
from __future__ import annotations
import uuid
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, RedirectResponse
from sqlalchemy import select
from db import get_session_factory
from models import Product, ProductAsset
from models.enums import AssetStatus
from schemas.video import VideoCreateRequest, VideoCreateResponse, VideoDeleteResponse, VideoJobOut
from services.jobs import create_image_job, delete_job, get_job, list_product_jobs, retry_failed_items, video_job_view
from services.storage import get_storage
from services.storage import local_path
from services.video_renderer import slide_seconds_for
router = APIRouter(prefix="/api", tags=["video"])
VIDEO_JOB_KIND = "slideshow_video"
def _job_out(result) -> VideoJobOut:
return VideoJobOut(**video_job_view(*result))
@router.post("/products/{product_id}/video-jobs", response_model=VideoCreateResponse)
async def create_video_job(product_id: str, req: VideoCreateRequest) -> VideoCreateResponse:
try:
product_uuid = uuid.UUID(product_id)
asset_ids = [uuid.UUID(asset_id) for asset_id in req.asset_ids]
except ValueError as exc:
raise HTTPException(status_code=400, detail="商品或图片 ID 不合法") from exc
async with get_session_factory()() as db:
product = await db.get(Product, product_uuid)
if product is None:
raise HTTPException(status_code=404, detail="商品不存在")
assets = (await db.scalars(
select(ProductAsset).where(
ProductAsset.product_id == product_uuid,
ProductAsset.id.in_(asset_ids),
ProductAsset.type == "img",
ProductAsset.status == AssetStatus.uploaded,
)
)).all()
by_id = {asset.id: asset for asset in assets}
ordered = [by_id.get(asset_id) for asset_id in asset_ids]
if any(asset is None or not asset.stored_url for asset in ordered):
raise HTTPException(status_code=400, detail="所选图片必须属于当前商品且已完成转存")
seconds = slide_seconds_for(len(ordered))
task = await create_image_job(
product_id=product_id,
kind=VIDEO_JOB_KIND,
priority=5,
provider="ffmpeg",
model=None,
platform="wb_ozon",
lang="ru",
ratio="3:4",
style_set=0,
request_payload={
"preset": "wb_ozon",
"slide_seconds": seconds,
"sources": [
{"asset_id": str(asset.id), "url": asset.stored_url, "storage_key": asset.current_storage_key}
for asset in ordered if asset is not None
],
},
item_payloads=[{"title": "商品轮播视频", "kind": VIDEO_JOB_KIND}],
)
return VideoCreateResponse(job_id=str(task.id))
@router.get("/products/{product_id}/video-jobs", response_model=list[VideoJobOut])
async def list_video_jobs(product_id: str, active_only: bool = False):
jobs = await list_product_jobs(product_id, active_only=active_only, kinds=(VIDEO_JOB_KIND,))
views: list[VideoJobOut] = []
for job in jobs:
result = await get_job(str(job.id))
if result is not None:
views.append(_job_out(result))
return views
@router.get("/video-jobs/{job_id}", response_model=VideoJobOut)
async def get_video_job(job_id: str):
result = await get_job(job_id)
if result is None or result[0].kind != VIDEO_JOB_KIND:
raise HTTPException(status_code=404, detail="视频任务不存在")
return _job_out(result)
@router.post("/video-jobs/{job_id}/cancel", response_model=VideoDeleteResponse)
async def cancel_video_job(job_id: str) -> VideoDeleteResponse:
# 视频任务不像生图任务需要保留诊断历史:取消后立即从页面和数据库消失。
return await _purge_video_job(job_id)
@router.post("/video-jobs/{job_id}/retry", response_model=VideoJobOut)
async def retry_video_job(job_id: str):
job = await retry_failed_items(job_id)
result = await get_job(str(job.id)) if job and job.kind == VIDEO_JOB_KIND else None
if result is None:
raise HTTPException(status_code=404, detail="视频任务不存在")
return _job_out(result)
async def _purge_video_job(job_id: str) -> VideoDeleteResponse:
result = await get_job(job_id)
if result is None or result[0].kind != VIDEO_JOB_KIND:
raise HTTPException(status_code=404, detail="视频任务不存在")
job, items = result
# 先删该任务专属目录;即使 FFmpeg 正在运行,Worker 下一次检查也会发现任务不存在并终止。
if job.product_id:
await get_storage().delete_prefix(f"products/{job.product_id}/videos/{job.id}/")
if not await delete_job(job_id):
raise HTTPException(status_code=404, detail="视频任务不存在")
return VideoDeleteResponse()
@router.delete("/video-jobs/{job_id}", response_model=VideoDeleteResponse)
async def delete_video_job(job_id: str) -> VideoDeleteResponse:
"""删除完成/失败视频及其 MP4;任务记录不保留。"""
return await _purge_video_job(job_id)
@router.get("/video-jobs/{job_id}/download")
async def download_video(job_id: str):
result = await get_job(job_id)
if result is None or result[0].kind != VIDEO_JOB_KIND or not result[1] or not result[1][0].result_url:
raise HTTPException(status_code=404, detail="视频文件不存在")
url = result[1][0].result_url
path = local_path(url)
filename = f"product-video-{job_id}.mp4"
if path is not None and Path(path).is_file():
return FileResponse(path, media_type="video/mp4", filename=filename)
return RedirectResponse(url, status_code=307)
+23
View File
@@ -53,12 +53,35 @@ class Settings(BaseSettings):
# 单张图片在后端需要解码/加水印,限制单文件可避免一次请求耗尽内存。 # 单张图片在后端需要解码/加水印,限制单文件可避免一次请求耗尽内存。
image_upload_max_bytes: int = 20 * 1024 * 1024 image_upload_max_bytes: int = 20 * 1024 * 1024
image_download_max_bytes: int = 20 * 1024 * 1024 image_download_max_bytes: int = 20 * 1024 * 1024
# 图片解码前依据真实宽高限制像素,避免超大图耗尽 Pillow/水印处理内存。
image_max_pixels: int = 40_000_000
# 远程图片下载防护:连接/读写/总耗时分别受控;重定向逐跳校验目标地址。
image_download_connect_timeout: float = 10.0
image_download_read_timeout: float = 30.0
image_download_total_timeout: float = 60.0
image_download_max_redirects: int = 3
collection_max_images: int = 100 collection_max_images: int = 100
export_max_images: int = 50 export_max_images: int = 50
export_zip_max_bytes: int = 200 * 1024 * 1024 export_zip_max_bytes: int = 200 * 1024 * 1024
# ZIP 超过该值自动落临时磁盘,不继续占用进程内存。 # ZIP 超过该值自动落临时磁盘,不继续占用进程内存。
export_zip_memory_threshold_bytes: int = 2 * 1024 * 1024 export_zip_memory_threshold_bytes: int = 2 * 1024 * 1024
# ── 商品轮播视频(FFmpeg)──
# Ubuntu 默认安装包会提供同名命令;容器或特殊路径可在 .env 覆盖。
video_ffmpeg_path: str = "ffmpeg"
video_ffprobe_path: str = "ffprobe"
video_width: int = 810
video_height: int = 1080
video_fps: int = 30
video_transition_seconds: float = 0.35
video_crf: int = 22
video_max_rate: str = "3M"
video_buffer_size: str = "6M"
video_max_bytes: int = 45 * 1024 * 1024
video_max_duration_seconds: int = 180
video_render_timeout_seconds: int = 120
background_job_lease_seconds: int = 300
# ── V2.1 套图生图(provider 路由与 image-suite-studio 一致)── # ── V2.1 套图生图(provider 路由与 image-suite-studio 一致)──
image_provider: str = "doubao" # 未知模型时的兜底 provider:doubao | tongyi | rightapi image_provider: str = "doubao" # 未知模型时的兜底 provider:doubao | tongyi | rightapi
request_timeout: int = 300 # 单张生图请求超时(秒) request_timeout: int = 300 # 单张生图请求超时(秒)
+2 -1
View File
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from sqlalchemy import text from sqlalchemy import text
from api import ai, export, fx, image, materials, products, proxy, suite from api import ai, export, fx, image, materials, products, proxy, suite, video
from config import get_settings from config import get_settings
from db import get_engine from db import get_engine
@@ -36,6 +36,7 @@ app.include_router(image.router)
app.include_router(suite.router) app.include_router(suite.router)
app.include_router(export.router) app.include_router(export.router)
app.include_router(proxy.router) app.include_router(proxy.router)
app.include_router(video.router)
# ⚠️ 冻结链路(Ozon API 直传):代码移至 legacy/,接口保留但不再投入 # ⚠️ 冻结链路(Ozon API 直传):代码移至 legacy/,接口保留但不再投入
from legacy.api import categories, ozon, publish, shops # noqa: E402 from legacy.api import categories, ozon, publish, shops # noqa: E402
@@ -19,7 +19,14 @@ depends_on = None
def upgrade() -> None: def upgrade() -> None:
# 开发态 FastAPI 会在 startup 以 Base.metadata.create_all 兜底;若它先于 Alembic # 开发态 FastAPI 会在 startup 以 Base.metadata.create_all 兜底;若它先于 Alembic
# 启动,表已完整存在,此处只补迁移版本记录,避免 create_table 冲突。 # 启动,表已完整存在,此处只补迁移版本记录,避免 create_table 冲突。
if "background_jobs" in sa.inspect(op.get_bind()).get_table_names(): inspector = sa.inspect(op.get_bind())
if "background_jobs" in inspector.get_table_names():
# 开发态若先由 metadata.create_all 建表,仍补上后来新增的索引。
existing_indexes = {
index["name"] for index in inspector.get_indexes("background_job_items")
}
if "ix_background_job_items_updated_at" not in existing_indexes:
op.create_index("ix_background_job_items_updated_at", "background_job_items", ["updated_at"])
return return
op.create_table( op.create_table(
"background_jobs", "background_jobs",
@@ -85,8 +92,10 @@ def upgrade() -> None:
op.create_index("ix_background_job_items_provider_task_id", "background_job_items", ["provider_task_id"]) op.create_index("ix_background_job_items_provider_task_id", "background_job_items", ["provider_task_id"])
op.create_index("ix_background_job_items_next_run_at", "background_job_items", ["next_run_at"]) op.create_index("ix_background_job_items_next_run_at", "background_job_items", ["next_run_at"])
op.create_index("ix_background_job_items_lease_expires_at", "background_job_items", ["lease_expires_at"]) op.create_index("ix_background_job_items_lease_expires_at", "background_job_items", ["lease_expires_at"])
op.create_index("ix_background_job_items_updated_at", "background_job_items", ["updated_at"])
def downgrade() -> None: def downgrade() -> None:
op.drop_index("ix_background_job_items_updated_at", table_name="background_job_items")
op.drop_table("background_job_items") op.drop_table("background_job_items")
op.drop_table("background_jobs") op.drop_table("background_jobs")
+56
View File
@@ -0,0 +1,56 @@
"""轮播商品视频的请求与任务响应契约。"""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field, field_validator
class VideoCreateRequest(BaseModel):
"""按传入顺序把 2–5 张当前商品图片合成为无音轨轮播视频。"""
asset_ids: list[str] = Field(min_length=2, max_length=5)
@field_validator("asset_ids")
@classmethod
def asset_ids_must_be_unique(cls, values: list[str]) -> list[str]:
if len(set(values)) != len(values):
raise ValueError("合成视频不能重复选择同一张图片")
return values
class VideoCreateResponse(BaseModel):
job_id: str
class VideoDeleteResponse(BaseModel):
deleted: bool = True
class VideoResultOut(BaseModel):
item_id: str
status: str
url: str | None = None
error: str | None = None
class VideoJobOut(BaseModel):
id: str
kind: str = "slideshow_video"
status: str
total: int = 1
completed: int = 0
failed: int = 0
cancel_requested: bool = False
preset: str = "wb_ozon"
source_asset_ids: list[str] = Field(default_factory=list)
slide_seconds: float = 0.0
duration_seconds: float | None = None
width: int = 810
height: int = 1080
file_size_bytes: int | None = None
created_at: datetime | None = None
started_at: datetime | None = None
completed_at: datetime | None = None
result: VideoResultOut | None = None
error: str | None = None
@@ -0,0 +1,69 @@
"""为历史图片素材补齐真实宽高,不改写已有图片文件。"""
from __future__ import annotations
import argparse
import asyncio
import mimetypes
import sys
from pathlib import Path
from sqlalchemy import or_, select
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from db import get_session_factory
from config import get_settings
from models import ProductAsset
from services.storage import local_path, normalize_image_bytes, read_local_bytes
async def backfill(dry_run: bool = False) -> tuple[int, list[str]]:
"""返回成功回填数和最多十条失败摘要。"""
updated = 0
failures: list[str] = []
async with get_session_factory()() as db:
assets = (await db.scalars(
select(ProductAsset).where(
ProductAsset.type == "img",
or_(ProductAsset.width.is_(None), ProductAsset.height.is_(None)),
)
)).all()
for asset in assets:
url = asset.original_url or asset.stored_url
path = local_path(url or "")
if path is None:
if len(failures) < 10:
failures.append(f"{asset.id}: 本地原图不存在,跳过")
continue
try:
data = read_local_bytes(path)
image = normalize_image_bytes(
data,
max_bytes=max(len(data), get_settings().image_download_max_bytes),
)
asset.width = image.width
asset.height = image.height
updated += 1
except Exception as exc: # noqa: BLE001
if len(failures) < 10:
failures.append(f"{asset.id}: {exc}")
if not dry_run:
await db.commit()
return updated, failures
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dry-run", action="store_true", help="只检查,不写数据库")
args = parser.parse_args()
updated, failures = asyncio.run(backfill(dry_run=args.dry_run))
mode = "将回填" if args.dry_run else "已回填"
print(f"{mode} {updated} 条图片素材的宽高")
for failure in failures:
print(f"跳过:{failure}")
if __name__ == "__main__":
main()
+29 -3
View File
@@ -11,8 +11,17 @@ import uuid
from dataclasses import dataclass from dataclasses import dataclass
from api.proxy import guess_referer from api.proxy import guess_referer
from config import get_settings
from models import ProductAsset from models import ProductAsset
from services.storage import download_bytes, get_storage, image_extension, local_path, read_local_bytes, storage_key_from_url from services.storage import (
download_bytes,
get_storage,
image_extension,
local_path,
normalize_image_bytes,
read_local_bytes,
storage_key_from_url,
)
from services.watermark import apply_watermark from services.watermark import apply_watermark
@@ -46,8 +55,20 @@ class ImageAssetService:
return read_local_bytes(path), mimetypes.guess_type(path.name)[0] or "image/jpeg" return read_local_bytes(path), mimetypes.guess_type(path.name)[0] or "image/jpeg"
return await download_bytes(url, referer=guess_referer(url)) return await download_bytes(url, referer=guess_referer(url))
async def save_original(self, asset: ProductAsset, data: bytes, content_type: str = "") -> str: async def save_original(
self,
asset: ProductAsset,
data: bytes,
content_type: str = "",
max_bytes: int | None = None,
) -> str:
"""写入该素材唯一的无水印原图,并将 current 同步到原图。""" """写入该素材唯一的无水印原图,并将 current 同步到原图。"""
if asset.type == "img":
normalized = normalize_image_bytes(data, max_bytes=max_bytes)
data = normalized.data
content_type = normalized.content_type
asset.width = normalized.width
asset.height = normalized.height
key = self._key(asset, "original", data, content_type) key = self._key(asset, "original", data, content_type)
url = await self.storage.save_bytes(data, key, content_type) url = await self.storage.save_bytes(data, key, content_type)
asset.original_url = url asset.original_url = url
@@ -60,7 +81,12 @@ class ImageAssetService:
async def save_original_from_url(self, asset: ProductAsset, url: str) -> str: async def save_original_from_url(self, asset: ProductAsset, url: str) -> str:
data, content_type = await self._read(url) data, content_type = await self._read(url)
return await self.save_original(asset, data, content_type) return await self.save_original(
asset,
data,
content_type,
max_bytes=get_settings().image_download_max_bytes,
)
async def ensure_original(self, asset: ProductAsset) -> tuple[bytes, str]: async def ensure_original(self, asset: ProductAsset) -> tuple[bytes, str]:
"""为旧记录补齐原图字段;首次触碰旧素材时迁入新的商品/素材路径。""" """为旧记录补齐原图字段;首次触碰旧素材时迁入新的商品/素材路径。"""
+43 -1
View File
@@ -26,6 +26,7 @@ from services.storage import get_storage
from services.suite_service import append_generated_asset from services.suite_service import append_generated_asset
from services.tasks import TaskImage from services.tasks import TaskImage
from services.watermark import apply_watermark from services.watermark import apply_watermark
from services.video_renderer import VideoRenderCancelled, render_slideshow
log = logging.getLogger("background.worker") log = logging.getLogger("background.worker")
@@ -113,11 +114,52 @@ async def _process_watermark_item(claim: ClaimedItem) -> None:
await mark_item_failed(claim.item_id, str(exc)) await mark_item_failed(claim.item_id, str(exc))
async def _process_slideshow_video_item(claim: ClaimedItem) -> None:
"""一个视频任务只有一个子项;输入 URL 在提交时冻结,避免后续选择状态影响成片。"""
payload = claim.request_payload
sources = list(payload.get("sources") or [])
source_urls = [str(source.get("url") or "") for source in sources]
if not 2 <= len(source_urls) <= 5 or any(not url for url in source_urls):
await mark_item_failed(claim.item_id, "视频任务缺少 2–5 张可用图片")
return
try:
async def should_cancel() -> bool:
return not await job_can_write(claim.job_id)
rendered = await render_slideshow(source_urls, should_cancel=should_cancel)
if not await job_can_write(claim.job_id):
await mark_item_cancelled(claim.item_id)
return
key = f"products/{claim.product_id}/videos/{claim.job_id}/result.mp4"
result_url = await get_storage().save_bytes(rendered.data, key, "video/mp4")
async with get_session_factory()() as db:
from models import BackgroundJobItem
item = await db.get(BackgroundJobItem, uuid.UUID(claim.item_id))
if item is not None:
item.payload = {
**dict(item.payload),
"duration_seconds": rendered.duration_seconds,
"width": rendered.width,
"height": rendered.height,
"file_size_bytes": len(rendered.data),
}
await db.commit()
await mark_item_done(claim.item_id, result_url=result_url, source_url=None, watermark_config=None, generated_asset_id=None)
except VideoRenderCancelled:
await mark_item_cancelled(claim.item_id)
except Exception as exc: # noqa: BLE001
log.exception("后台轮播视频任务 %s/%s 执行失败", claim.job_id, claim.item_id)
await mark_item_failed(claim.item_id, str(exc))
async def process_item(claim: ClaimedItem) -> None: async def process_item(claim: ClaimedItem) -> None:
"""推进一次子任务。外部模型未完成时只写 provider id 并退出,让出 Worker。""" """推进一次子任务。外部模型未完成时只写 provider id 并退出,让出 Worker。"""
if claim.job_kind == "watermark_batch": if claim.job_kind == "watermark_batch":
await _process_watermark_item(claim) await _process_watermark_item(claim)
return return
if claim.job_kind == "slideshow_video":
await _process_slideshow_video_item(claim)
return
if claim.job_kind not in {"image_suite", "image_edit"}: if claim.job_kind not in {"image_suite", "image_edit"}:
await mark_item_failed(claim.item_id, f"Worker 暂不支持任务类型: {claim.job_kind}") await mark_item_failed(claim.item_id, f"Worker 暂不支持任务类型: {claim.job_kind}")
return return
@@ -201,7 +243,7 @@ async def run_worker(*, once: bool = False, idle_seconds: float = 0.75) -> None:
log.warning("恢复了 %d 个过期后台任务租约", recovered) log.warning("恢复了 %d 个过期后台任务租约", recovered)
log.info("后台 Worker 已启动:%s", owner) log.info("后台 Worker 已启动:%s", owner)
while True: while True:
claim = await claim_next_item(owner) claim = await claim_next_item(owner, lease_seconds=get_settings().background_job_lease_seconds)
if claim is None: if claim is None:
if once: if once:
return return
+37
View File
@@ -310,6 +310,21 @@ async def cancel_job(job_id: str) -> BackgroundJob | None:
return job return job
async def delete_job(job_id: str) -> bool:
"""硬删除一个后台任务及其子项;调用方须先处理对应的存储对象。"""
try:
parsed = uuid.UUID(job_id)
except ValueError:
return False
async with get_session_factory()() as db:
job = await db.get(BackgroundJob, parsed)
if job is None:
return False
await db.delete(job)
await db.commit()
return True
async def cancel_product_jobs(product_id: str) -> int: async def cancel_product_jobs(product_id: str) -> int:
"""硬删除商品前取消其未完成任务,Worker 随后只会丢弃结果而不会回写素材。""" """硬删除商品前取消其未完成任务,Worker 随后只会丢弃结果而不会回写素材。"""
try: try:
@@ -468,3 +483,25 @@ def suite_view(job: BackgroundJob, items: list[BackgroundJobItem]) -> dict[str,
for item in items for item in items
], ],
} }
def video_job_view(job: BackgroundJob, items: list[BackgroundJobItem]) -> dict[str, Any]:
"""轮播视频的轻量任务视图;仍复用 BackgroundJob 的恢复/取消/重试状态机。"""
payload = dict(job.request_payload or {})
item = items[0] if items else None
item_payload = dict(item.payload or {}) if item else {}
return {
"id": str(job.id), "kind": job.kind, "status": job.status,
"total": job.total, "completed": job.completed, "failed": job.failed,
"cancel_requested": job.cancel_requested, "preset": payload.get("preset", "wb_ozon"),
"source_asset_ids": [str(source.get("asset_id")) for source in payload.get("sources", [])],
"slide_seconds": payload.get("slide_seconds", 0),
"duration_seconds": item_payload.get("duration_seconds"),
"width": item_payload.get("width", 810), "height": item_payload.get("height", 1080),
"file_size_bytes": item_payload.get("file_size_bytes"),
"created_at": job.created_at, "started_at": job.started_at, "completed_at": job.completed_at,
"result": None if item is None else {
"item_id": str(item.id), "status": item.status, "url": item.result_url, "error": item.error,
},
"error": job.error,
}
+220 -24
View File
@@ -1,12 +1,20 @@
"""图片/文件存储抽象:本地文件系统(开发兜底)+ 七牛(生产)。""" """图片/文件存储抽象:本地文件系统(开发兜底)+ 七牛(生产)。"""
from __future__ import annotations from __future__ import annotations
import asyncio
import io
import ipaddress
import mimetypes import mimetypes
import socket
import shutil import shutil
import uuid import uuid
import warnings
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from urllib.parse import urljoin, urlparse
import httpx import httpx
from PIL import Image, ImageOps, UnidentifiedImageError
from config import get_settings from config import get_settings
@@ -26,6 +34,157 @@ class ResourceSizeLimitError(ValueError):
super().__init__(detail) super().__init__(detail)
class UnsafeRemoteUrlError(ValueError):
"""远程资源 URL 指向回环、内网或其他保留网络,不能由服务端代请求。"""
class ResourceDownloadTimeoutError(TimeoutError):
"""远程资源下载超过连接、读取或总耗时限制。"""
class ImageValidationError(ValueError):
"""图片字节无法解码,或不符合格式与像素限制。"""
@dataclass(frozen=True)
class NormalizedImage:
"""经真实格式校验后的图片字节及可落库元数据。"""
data: bytes
content_type: str
width: int
height: int
image_format: str
orientation_normalized: bool
_IMAGE_CONTENT_TYPES = {
"JPEG": "image/jpeg",
"PNG": "image/png",
"WEBP": "image/webp",
"GIF": "image/gif",
"BMP": "image/bmp",
}
def normalize_image_bytes(
data: bytes,
max_bytes: int | None = None,
max_pixels: int | None = None,
) -> NormalizedImage:
"""按真实图片内容校验、纠正 EXIF 方向,并返回可信 MIME 与宽高。
不盲从 URL 后缀或客户端 Content-Type。为避免无意义的质量损耗,仅当 EXIF
方向不是默认值时才重编码;动画图不重编码,以免丢失帧。
"""
settings = get_settings()
byte_limit = max_bytes if max_bytes is not None else settings.image_upload_max_bytes
pixel_limit = max_pixels if max_pixels is not None else settings.image_max_pixels
if byte_limit <= 0 or pixel_limit <= 0:
raise ValueError("图片大小和像素上限必须大于 0")
if not data:
raise ImageValidationError("图片内容为空")
if len(data) > byte_limit:
raise ResourceSizeLimitError(byte_limit, len(data))
try:
# Pillow 的默认 bomb 保护不能替代项目自定义像素上限;把 warning 升级为错误。
with warnings.catch_warnings():
warnings.simplefilter("error", Image.DecompressionBombWarning)
with Image.open(io.BytesIO(data)) as image:
image_format = (image.format or "").upper()
if image_format not in _IMAGE_CONTENT_TYPES:
raise ImageValidationError(f"不支持的图片格式:{image_format or '未知'}")
width, height = image.size
if width <= 0 or height <= 0 or width * height > pixel_limit:
raise ImageValidationError(
f"图片像素超过上限(最多 {pixel_limit:,},当前 {width:,} × {height:,})"
)
# 强制实际解码,拒绝截断/伪造图片而不是只相信文件头。
image.load()
frame_count = getattr(image, "n_frames", 1)
orientation = image.getexif().get(274, 1) if frame_count == 1 else 1
normalized = orientation not in (None, 1)
output = data
if normalized:
corrected = ImageOps.exif_transpose(image)
width, height = corrected.size
if image_format == "JPEG" and corrected.mode not in {"RGB", "L"}:
corrected = corrected.convert("RGB")
buffer = io.BytesIO()
corrected.save(buffer, format=image_format)
output = buffer.getvalue()
except (UnidentifiedImageError, OSError, Image.DecompressionBombError) as exc:
raise ImageValidationError("文件不是可用的图片,或图片数据已损坏") from exc
except Image.DecompressionBombWarning as exc:
raise ImageValidationError("图片像素过大,已拒绝解码") from exc
if len(output) > byte_limit:
raise ResourceSizeLimitError(byte_limit, len(output))
return NormalizedImage(
data=output,
content_type=_IMAGE_CONTENT_TYPES[image_format],
width=width,
height=height,
image_format=image_format,
orientation_normalized=normalized,
)
def _is_public_ip(address: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
"""仅允许可公开路由的地址;IPv4-mapped IPv6 需按其 IPv4 实际地址判断。"""
if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
return _is_public_ip(address.ipv4_mapped)
return address.is_global
async def validate_remote_url(url: str) -> None:
"""校验服务端允许代请求的公网 HTTP(S) URL。
该校验用于代理、采集转存、AI 参考图和导出。项目自己的 `/media/` 文件
在调用方会先直读磁盘;其他回环或私网 URL 一律不能通过网络访问。
"""
try:
parsed = urlparse(url)
port = parsed.port
except ValueError as exc:
raise UnsafeRemoteUrlError("图片地址端口无效") from exc
if parsed.scheme.lower() not in {"http", "https"}:
raise UnsafeRemoteUrlError("仅允许 http/https 图片地址")
if not parsed.hostname:
raise UnsafeRemoteUrlError("图片地址缺少主机名")
if parsed.username is not None or parsed.password is not None:
raise UnsafeRemoteUrlError("图片地址不能包含用户名或密码")
host = parsed.hostname.rstrip(".")
if host.lower() in {"localhost", "localhost.localdomain"}:
raise UnsafeRemoteUrlError("不允许请求 localhost 或本机网络地址")
try:
addresses = {ipaddress.ip_address(host)}
except ValueError:
try:
infos = await asyncio.get_running_loop().getaddrinfo(
host,
port or (443 if parsed.scheme.lower() == "https" else 80),
type=socket.SOCK_STREAM,
)
except socket.gaierror as exc:
raise UnsafeRemoteUrlError("图片地址域名无法解析") from exc
addresses = {ipaddress.ip_address(info[4][0]) for info in infos}
if not addresses:
raise UnsafeRemoteUrlError("图片地址域名未解析到有效地址")
private_addresses = [str(address) for address in addresses if not _is_public_ip(address)]
if private_addresses:
raise UnsafeRemoteUrlError(
f"不允许请求非公网地址({', '.join(sorted(private_addresses))})"
)
def _ext_from_content_type(content_type: str) -> str: def _ext_from_content_type(content_type: str) -> str:
ctype = (content_type or "").split(";")[0].strip().lower() ctype = (content_type or "").split(";")[0].strip().lower()
mapping = { mapping = {
@@ -48,41 +207,78 @@ def image_extension(data: bytes, content_type: str = "") -> str:
return ".webp" return ".webp"
if data[:3] == b"GIF": if data[:3] == b"GIF":
return ".gif" return ".gif"
if data[:2] == b"\xff\xd8":
return ".jpg"
if data[:2] == b"BM":
return ".bmp"
return _ext_from_content_type(content_type) return _ext_from_content_type(content_type)
async def download_bytes( async def download_bytes(
url: str, url: str,
referer: str | None = None, referer: str | None = None,
timeout: float = 60.0, timeout: float | None = None,
max_bytes: int | None = None, max_bytes: int | None = None,
) -> tuple[bytes, str]: ) -> tuple[bytes, str]:
"""流式下载远程图片并限制体积。返回 (bytes, content_type)。""" """安全地流式下载公网资源,并限制地址、重定向、体积和耗时。"""
limit = max_bytes or get_settings().image_download_max_bytes settings = get_settings()
limit = max_bytes or settings.image_download_max_bytes
if limit <= 0: if limit <= 0:
raise ValueError("图片下载大小上限必须大于 0") raise ValueError("图片下载大小上限必须大于 0")
headers = {"Referer": referer} if referer else {} total_timeout = timeout if timeout is not None else settings.image_download_total_timeout
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: if total_timeout <= 0:
async with client.stream("GET", url, headers=headers) as resp: raise ValueError("图片下载总超时必须大于 0")
resp.raise_for_status() if settings.image_download_connect_timeout <= 0 or settings.image_download_read_timeout <= 0:
length = resp.headers.get("content-length") raise ValueError("图片下载连接和读取超时必须大于 0")
if length: if settings.image_download_max_redirects < 0:
try: raise ValueError("图片下载重定向上限不能小于 0")
declared_size = int(length)
except ValueError:
declared_size = None
if declared_size is not None and declared_size > limit:
raise ResourceSizeLimitError(limit, declared_size)
chunks: list[bytes] = [] headers = {"Referer": referer} if referer else {}
total = 0 http_timeout = httpx.Timeout(
async for chunk in resp.aiter_bytes(): connect=settings.image_download_connect_timeout,
total += len(chunk) read=settings.image_download_read_timeout,
if total > limit: write=settings.image_download_read_timeout,
raise ResourceSizeLimitError(limit, total) pool=settings.image_download_connect_timeout,
chunks.append(chunk) )
ctype = (resp.headers.get("content-type") or "application/octet-stream").split(";")[0].strip() current_url = url
return b"".join(chunks), ctype try:
async with asyncio.timeout(total_timeout):
async with httpx.AsyncClient(timeout=http_timeout, follow_redirects=False) as client:
for redirect_count in range(settings.image_download_max_redirects + 1):
await validate_remote_url(current_url)
async with client.stream("GET", current_url, headers=headers) as resp:
if resp.is_redirect:
location = resp.headers.get("location")
if not location:
raise httpx.RemoteProtocolError("重定向响应缺少 Location")
if redirect_count >= settings.image_download_max_redirects:
raise httpx.TooManyRedirects("图片重定向次数超过上限")
current_url = urljoin(str(resp.url), location)
continue
resp.raise_for_status()
length = resp.headers.get("content-length")
if length:
try:
declared_size = int(length)
except ValueError:
declared_size = None
if declared_size is not None and declared_size > limit:
raise ResourceSizeLimitError(limit, declared_size)
chunks: list[bytes] = []
total = 0
async for chunk in resp.aiter_bytes():
total += len(chunk)
if total > limit:
raise ResourceSizeLimitError(limit, total)
chunks.append(chunk)
ctype = (resp.headers.get("content-type") or "application/octet-stream").split(";")[0].strip()
return b"".join(chunks), ctype
except (TimeoutError, httpx.TimeoutException) as exc:
raise ResourceDownloadTimeoutError(f"图片下载超时(最多 {total_timeout:g} 秒)") from exc
raise RuntimeError("图片下载未返回有效响应")
def read_local_bytes(path: Path, max_bytes: int | None = None) -> bytes: def read_local_bytes(path: Path, max_bytes: int | None = None) -> bytes:
+187
View File
@@ -0,0 +1,187 @@
"""用 FFmpeg 合成平台兼容的无音轨商品轮播视频。"""
from __future__ import annotations
import asyncio
import json
import os
import tempfile
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from pathlib import Path
from config import get_settings
from services.storage import (
ResourceSizeLimitError,
download_bytes,
image_extension,
local_path,
normalize_image_bytes,
read_local_bytes,
)
class VideoRenderError(RuntimeError):
"""FFmpeg 无法生成符合预设的商品视频。"""
class VideoRenderCancelled(VideoRenderError):
"""任务在 FFmpeg 运行期间被用户取消。"""
@dataclass(frozen=True)
class RenderedVideo:
data: bytes
duration_seconds: float
width: int
height: int
def slide_seconds_for(count: int) -> float:
"""按图片数量维持约 9–13 秒成片,并确保两图时也不会过短。"""
by_count = {2: 4.5, 3: 3.5, 4: 3.0, 5: 2.8}
try:
return by_count[count]
except KeyError as exc:
raise VideoRenderError("轮播视频只支持 2–5 张图片") from exc
async def _read_image(url: str) -> tuple[bytes, str]:
path = local_path(url)
if path is not None:
return read_local_bytes(path), ""
return await download_bytes(url, max_bytes=get_settings().image_download_max_bytes)
async def _run_process(
args: list[str],
*,
should_cancel: Callable[[], Awaitable[bool]],
timeout_seconds: int,
) -> None:
process = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
try:
waited = 0.0
while process.returncode is None:
if await should_cancel():
process.terminate()
try:
await asyncio.wait_for(process.wait(), timeout=5)
except TimeoutError:
process.kill()
await process.wait()
raise VideoRenderCancelled("视频任务已取消")
await asyncio.sleep(0.25)
waited += 0.25
if waited > timeout_seconds:
process.kill()
await process.wait()
raise VideoRenderError(f"视频渲染超过 {timeout_seconds} 秒,请减少图片后重试")
stderr = (await process.stderr.read()).decode("utf-8", errors="replace") if process.stderr else ""
if process.returncode != 0:
raise VideoRenderError(f"FFmpeg 渲染失败:{stderr[-800:] or '未知错误'}")
finally:
if process.returncode is None:
process.kill()
await process.wait()
async def _probe(path: Path) -> dict:
settings = get_settings()
process = await asyncio.create_subprocess_exec(
settings.video_ffprobe_path,
"-v", "error", "-show_entries", "format=duration,size:stream=codec_name,width,height,pix_fmt",
"-of", "json", str(path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
raise VideoRenderError(f"无法校验视频输出:{stderr.decode('utf-8', errors='replace')[-400:]}")
try:
return json.loads(stdout)
except json.JSONDecodeError as exc:
raise VideoRenderError("FFprobe 未返回可解析的视频信息") from exc
async def render_slideshow(
source_urls: list[str],
*,
should_cancel: Callable[[], Awaitable[bool]],
) -> RenderedVideo:
"""将已入库图片缩放留白后串为 MP4,严禁接收调用方任意命令参数。"""
settings = get_settings()
if not settings.video_ffmpeg_path or not settings.video_ffprobe_path:
raise VideoRenderError("未配置 FFmpeg 或 FFprobe 可执行路径")
count = len(source_urls)
seconds = slide_seconds_for(count)
transition = settings.video_transition_seconds
with tempfile.TemporaryDirectory(prefix="osk-video-") as temp_dir:
workdir = Path(temp_dir)
inputs: list[Path] = []
for index, url in enumerate(source_urls):
if await should_cancel():
raise VideoRenderCancelled("视频任务已取消")
data, content_type = await _read_image(url)
normalized = normalize_image_bytes(data, max_bytes=settings.image_download_max_bytes)
suffix = image_extension(normalized.data, content_type or normalized.content_type)
image_path = workdir / f"input-{index}{suffix}"
image_path.write_bytes(normalized.data)
inputs.append(image_path)
output_path = workdir / "result.mp4"
args = [settings.video_ffmpeg_path, "-y", "-hide_banner", "-loglevel", "error"]
for image_path in inputs:
args.extend(["-loop", "1", "-framerate", str(settings.video_fps), "-t", str(seconds), "-i", str(image_path)])
filters: list[str] = []
for index in range(count):
filters.append(
f"[{index}:v]scale={settings.video_width}:{settings.video_height}:"
"force_original_aspect_ratio=decrease,"
f"pad={settings.video_width}:{settings.video_height}:(ow-iw)/2:(oh-ih):color=white,"
"setsar=1,format=yuv420p"
f"[slide{index}]"
)
previous = "slide0"
accumulated = seconds
for index in range(1, count):
output = f"fade{index}"
filters.append(
f"[{previous}][slide{index}]xfade=transition=fade:duration={transition}:"
f"offset={accumulated - transition:.3f}[{output}]"
)
previous = output
accumulated += seconds - transition
filters.append(f"[{previous}]fps={settings.video_fps},format=yuv420p[video]")
args.extend([
"-filter_complex", ";".join(filters), "-map", "[video]",
"-an", "-c:v", "libx264", "-profile:v", "high", "-pix_fmt", "yuv420p",
"-preset", "medium", "-crf", str(settings.video_crf),
"-maxrate", settings.video_max_rate, "-bufsize", settings.video_buffer_size,
"-movflags", "+faststart", str(output_path),
])
await _run_process(args, should_cancel=should_cancel, timeout_seconds=settings.video_render_timeout_seconds)
if not output_path.is_file():
raise VideoRenderError("FFmpeg 未生成视频文件")
file_size = output_path.stat().st_size
if file_size > settings.video_max_bytes:
raise ResourceSizeLimitError(settings.video_max_bytes, file_size)
probe = await _probe(output_path)
stream = next((item for item in probe.get("streams", []) if item.get("codec_name") == "h264"), None)
if stream is None:
raise VideoRenderError("输出视频不是 H.264 编码")
width, height = int(stream.get("width", 0)), int(stream.get("height", 0))
duration = float((probe.get("format") or {}).get("duration", 0))
if (width, height) != (settings.video_width, settings.video_height):
raise VideoRenderError(f"输出分辨率异常:{width} × {height}")
if not 0 < duration <= settings.video_max_duration_seconds:
raise VideoRenderError(f"输出视频时长异常:{duration:.1f} 秒")
data = output_path.read_bytes()
if len(data) != file_size:
raise VideoRenderError("读取视频输出失败")
return RenderedVideo(data=data, duration_seconds=duration, width=width, height=height)
+105
View File
@@ -0,0 +1,105 @@
"""远程图片下载的 SSRF 防护回归测试。"""
from __future__ import annotations
import sys
import unittest
from unittest.mock import patch
from pathlib import Path
from io import BytesIO
import httpx
from PIL import Image
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from services.storage import (
ResourceDownloadTimeoutError,
ImageValidationError,
UnsafeRemoteUrlError,
download_bytes,
normalize_image_bytes,
validate_remote_url,
)
class RemoteUrlValidationTests(unittest.IsolatedAsyncioTestCase):
async def test_allows_public_literal_ip(self) -> None:
await validate_remote_url("https://1.1.1.1/image.jpg")
async def test_rejects_local_and_private_targets(self) -> None:
for url in (
"http://localhost:8800/image.jpg",
"http://127.0.0.1/image.jpg",
"http://10.0.0.1/image.jpg",
"http://169.254.169.254/latest/meta-data/",
"http://[::1]/image.jpg",
):
with self.subTest(url=url), self.assertRaises(UnsafeRemoteUrlError):
await validate_remote_url(url)
async def test_download_rejects_before_opening_a_local_connection(self) -> None:
with self.assertRaises(UnsafeRemoteUrlError):
await download_bytes("http://127.0.0.1:1/image.jpg")
async def test_redirect_target_is_validated_again(self) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
self.assertEqual(request.url.host, "1.1.1.1")
return httpx.Response(
302,
headers={"location": "http://127.0.0.1:9000/private.jpg"},
request=request,
)
real_async_client = httpx.AsyncClient
with patch(
"services.storage.httpx.AsyncClient",
side_effect=lambda **kwargs: real_async_client(
transport=httpx.MockTransport(handler), **kwargs
),
), self.assertRaises(UnsafeRemoteUrlError):
await download_bytes("https://1.1.1.1/redirect.jpg")
async def test_http_client_timeout_is_normalized(self) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
raise httpx.ReadTimeout("slow response", request=request)
real_async_client = httpx.AsyncClient
with patch(
"services.storage.httpx.AsyncClient",
side_effect=lambda **kwargs: real_async_client(
transport=httpx.MockTransport(handler), **kwargs
),
), self.assertRaises(ResourceDownloadTimeoutError):
await download_bytes("https://1.1.1.1/slow.jpg")
class ImageNormalizationTests(unittest.TestCase):
def test_uses_real_format_and_corrects_exif_orientation(self) -> None:
source = Image.new("RGB", (30, 10), "red")
exif = Image.Exif()
exif[274] = 6 # 顺时针旋转 90°
raw = BytesIO()
source.save(raw, format="JPEG", exif=exif)
image = normalize_image_bytes(raw.getvalue(), max_pixels=1_000)
self.assertEqual(image.content_type, "image/jpeg")
self.assertEqual((image.width, image.height), (10, 30))
self.assertTrue(image.orientation_normalized)
with Image.open(BytesIO(image.data)) as stored:
self.assertNotEqual(stored.getexif().get(274), 6)
def test_rejects_invalid_content_and_pixel_bomb(self) -> None:
with self.assertRaises(ImageValidationError):
normalize_image_bytes(b"not an image")
raw = BytesIO()
Image.new("RGB", (11, 10), "blue").save(raw, format="PNG")
with self.assertRaises(ImageValidationError):
normalize_image_bytes(raw.getvalue(), max_pixels=100)
if __name__ == "__main__":
unittest.main()
+26
View File
@@ -0,0 +1,26 @@
"""轮播视频的无 FFmpeg 依赖单测:时长策略是 API/前端说明的共同契约。"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from services.video_renderer import VideoRenderError, slide_seconds_for
class SlideDurationTests(unittest.TestCase):
def test_duration_strategy_for_two_to_five_images(self) -> None:
self.assertEqual([slide_seconds_for(count) for count in range(2, 6)], [4.5, 3.5, 3.0, 2.8])
def test_rejects_unsupported_count(self) -> None:
for count in (0, 1, 6):
with self.subTest(count=count), self.assertRaises(VideoRenderError):
slide_seconds_for(count)
if __name__ == "__main__":
unittest.main()
+229
View File
@@ -0,0 +1,229 @@
import { useEffect, useState } from 'react';
import { DeleteOutlined, DownloadOutlined } from '@ant-design/icons';
import { Button, Image, Pagination, Popconfirm, Tag, Typography } from 'antd';
import type { ProductAsset } from '@/services/product';
import type { SuiteImageInfo } from '@/services/suite';
import { GROUP_LABELS, IMAGE_GRID_PAGE_SIZE, type ImagePreviewConfig, type ResultImage } from './trialImageTypes';
const { Text } = Typography;
const FALLBACK_IMAGE = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'><rect width='100' height='100' fill='%23eee'/><text x='22' y='52' font-size='11' fill='%23999'>无预览</text></svg>";
function useVisiblePage<T>(items: T[], page: number): { current: number; pages: number; visible: T[] } {
const pages = Math.max(1, Math.ceil(items.length / IMAGE_GRID_PAGE_SIZE));
const current = Math.min(page, pages);
return {
current,
pages,
visible: items.slice((current - 1) * IMAGE_GRID_PAGE_SIZE, current * IMAGE_GRID_PAGE_SIZE),
};
}
function GridPagination({ total, page, onChange }: { total: number; page: number; onChange: (page: number) => void }) {
if (total <= IMAGE_GRID_PAGE_SIZE) return null;
return (
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 10 }}>
<Pagination
size="small"
current={page}
total={total}
pageSize={IMAGE_GRID_PAGE_SIZE}
showSizeChanger={false}
showTotal={(count) => `共 ${count} 张`}
onChange={onChange}
/>
</div>
);
}
interface CollectionImageGroupsProps {
groups: Array<{ key: string; assets: ProductAsset[] }>;
selectedKeys: Set<string>;
preview: ImagePreviewConfig;
imageUrl: (asset: ProductAsset) => string;
imageName: (asset: ProductAsset) => string;
onToggle: (assetId: string) => void;
onToggleGroup: (group: string, on: boolean) => void;
onDelete: (assetId: string) => void;
onEdit: (asset: ProductAsset) => void;
}
export function CollectionImageGroups({
groups,
selectedKeys,
preview,
imageUrl,
imageName,
onToggle,
onToggleGroup,
onDelete,
onEdit,
}: CollectionImageGroupsProps) {
const [pageByGroup, setPageByGroup] = useState<Record<string, number>>({});
return (
<Image.PreviewGroup preview={preview}>
{groups.map(({ key: group, assets }) => {
const requestedPage = pageByGroup[group] ?? 1;
const { current, visible } = useVisiblePage(assets, requestedPage);
const allSelected = assets.every((asset) => selectedKeys.has(asset.id));
return (
<section key={group} style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<Text strong>
{GROUP_LABELS[group] ?? group}
<Text type="secondary" style={{ fontWeight: 400, marginLeft: 6 }}>{assets.length}</Text>
</Text>
<a style={{ fontSize: 12 }} onClick={() => onToggleGroup(group, !allSelected)}>
{allSelected ? '取消全选' : '全选'}
</a>
{group === 'main' && <Text type="secondary" style={{ fontSize: 11 }}>勾选的图片作为生图参考底图</Text>}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: 10 }}>
{visible.map((asset) => (
<CollectionImageCard
key={asset.id}
asset={asset}
selected={selectedKeys.has(asset.id)}
imageUrl={imageUrl(asset)}
imageName={imageName(asset)}
onToggle={() => onToggle(asset.id)}
onDelete={() => onDelete(asset.id)}
onEdit={() => onEdit(asset)}
/>
))}
</div>
<GridPagination
total={assets.length}
page={current}
onChange={(page) => setPageByGroup((prev) => ({ ...prev, [group]: page }))}
/>
</section>
);
})}
</Image.PreviewGroup>
);
}
function CollectionImageCard({
asset,
selected,
imageUrl,
imageName,
onToggle,
onDelete,
onEdit,
}: {
asset: ProductAsset;
selected: boolean;
imageUrl: string;
imageName: string;
onToggle: () => void;
onDelete: () => void;
onEdit: () => void;
}) {
const [hovered, setHovered] = useState(false);
return (
<div>
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{ position: 'relative', border: `2px solid ${selected ? '#1677ff' : 'transparent'}`, borderRadius: 8, padding: 2, background: selected ? 'rgba(22,119,255,0.06)' : undefined }}
>
<Image
loading="lazy"
src={imageUrl}
width="100%"
style={{ objectFit: 'cover', borderRadius: 6, aspectRatio: '1 / 1', display: 'block' }}
wrapperStyle={{ width: '100%', display: 'block' }}
fallback={FALLBACK_IMAGE}
/>
<Popconfirm title="删除图片" description="确认删除这张采集图片?" okText="删除" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={onDelete}>
<Button size="small" danger type="text" icon={<DeleteOutlined />} title="删除这张图片" onClick={(event) => event.stopPropagation()} style={{ position: 'absolute', top: 5, left: 5, zIndex: 3, opacity: hovered ? 1 : 0, pointerEvents: hovered ? 'auto' : 'none', transition: 'opacity 0.15s', background: 'rgba(255,255,255,0.94)' }} />
</Popconfirm>
<span title="勾选为参考底图" onClick={onToggle} style={{ position: 'absolute', top: 6, right: 6, width: 20, height: 20, borderRadius: '50%', background: selected ? '#1677ff' : 'rgba(255,255,255,0.9)', color: selected ? '#fff' : '#bbb', border: '1px solid #d9d9d9', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 12, zIndex: 2, userSelect: 'none' }}>✓</span>
{asset.status !== 'uploaded' && <Tag color={asset.status === 'failed' ? 'red' : 'default'} style={{ position: 'absolute', bottom: 6, left: 6, margin: 0, zIndex: 2 }}>{asset.status === 'failed' ? '转存失败' : '转存中'}</Tag>}
<Button size="small" type="text" onClick={onEdit} style={{ position: 'absolute', right: 5, bottom: 5, zIndex: 2, background: 'rgba(0,0,0,0.58)', color: '#fff', borderColor: 'transparent', fontSize: 11 }}>AI 生图</Button>
</div>
<div title={asset.variant_name || imageName} style={{ fontSize: 11, color: '#999', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{asset.variant_name || imageName}</div>
</div>
);
}
interface GeneratedImageGridProps {
images: ResultImage[];
selectedKeys: Set<string>;
downloadingKeys: Set<string>;
preview: ImagePreviewConfig;
onToggle: (key: string) => void;
onDelete: (image: ResultImage) => void;
onDownload: (image: ResultImage) => void;
onEdit: (image: ResultImage) => void;
}
export function GeneratedImageGrid({ images, selectedKeys, downloadingKeys, preview, onToggle, onDelete, onDownload, onEdit }: GeneratedImageGridProps) {
const [page, setPage] = useState(1);
const { current, visible } = useVisiblePage(images, page);
useEffect(() => {
if (current !== page) setPage(current);
}, [current, page]);
return (
<>
<Image.PreviewGroup preview={preview}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: 10, width: '100%' }}>
{visible.map((image) => (
<GeneratedImageCard
key={image.key}
image={image}
selected={selectedKeys.has(image.key)}
downloading={downloadingKeys.has(image.key)}
onToggle={() => onToggle(image.key)}
onDelete={() => onDelete(image)}
onDownload={() => onDownload(image)}
onEdit={() => onEdit(image)}
/>
))}
</div>
</Image.PreviewGroup>
<GridPagination total={images.length} page={current} onChange={setPage} />
</>
);
}
function GeneratedImageCard({ image, selected, downloading, onToggle, onDelete, onDownload, onEdit }: {
image: ResultImage;
selected: boolean;
downloading: boolean;
onToggle: () => void;
onDelete: () => void;
onDownload: () => void;
onEdit: () => void;
}) {
const [hovered, setHovered] = useState(false);
return (
<div style={{ minWidth: 0 }} title={image.name}>
<div onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} style={{ position: 'relative', border: `2px solid ${selected ? '#1677ff' : 'transparent'}`, borderRadius: 8, padding: 2, background: selected ? 'rgba(22,119,255,0.06)' : undefined, aspectRatio: '3 / 4', overflow: 'hidden' }}>
<Image loading="lazy" src={image.url} width="100%" height="100%" style={{ objectFit: 'cover', borderRadius: 6, display: 'block' }} />
{image.assetId && <Popconfirm title="删除图片" description="确认删除这张已生成的图片?" okText="删除" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={onDelete}><Button size="small" danger type="text" icon={<DeleteOutlined />} title="删除这张图片" onClick={(event) => event.stopPropagation()} style={{ position: 'absolute', top: 5, left: 5, zIndex: 3, opacity: hovered ? 1 : 0, pointerEvents: hovered ? 'auto' : 'none', transition: 'opacity 0.15s', background: 'rgba(255,255,255,0.94)' }} /></Popconfirm>}
<span title="勾选导出" onClick={onToggle} style={{ position: 'absolute', top: 5, right: 5, width: 20, height: 20, borderRadius: '50%', background: selected ? '#1677ff' : 'rgba(255,255,255,0.9)', color: selected ? '#fff' : '#bbb', border: '1px solid #d9d9d9', display: 'flex', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 12, zIndex: 2, userSelect: 'none' }}>✓</span>
<Button size="small" type="text" icon={<DownloadOutlined />} title="下载这张图片" loading={downloading} onClick={(event) => { event.stopPropagation(); onDownload(); }} style={{ position: 'absolute', bottom: 5, left: 5, zIndex: 3, opacity: hovered ? 1 : 0, pointerEvents: hovered ? 'auto' : 'none', transition: 'opacity 0.15s', background: 'rgba(255,255,255,0.94)' }} />
<Button size="small" type="text" title="基于这张图再次 AI 编辑" onClick={(event) => { event.stopPropagation(); onEdit(); }} style={{ position: 'absolute', bottom: 5, right: 5, zIndex: 3, opacity: hovered ? 1 : 0, pointerEvents: hovered ? 'auto' : 'none', transition: 'opacity 0.15s', background: 'rgba(0,0,0,0.58)', color: '#fff', borderColor: 'transparent', fontSize: 11 }}>AI 编辑</Button>
</div>
<div style={{ fontSize: 11, color: 'rgba(0,0,0,0.45)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={image.name}>{image.name}</div>
</div>
);
}
export function FailedSuiteImages({ images }: { images: SuiteImageInfo[] }) {
if (images.length === 0) return null;
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: 10, width: '100%', marginTop: 10 }}>
{images.map((image) => (
<div key={`failed-${image.item_id}`} style={{ minWidth: 0 }} title={image.error || image.name}>
<div style={{ width: '100%', aspectRatio: '3 / 4', borderRadius: 6, background: '#f5f5f5', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#ff4d4f' }}>✗</div>
<div style={{ fontSize: 11, color: '#ff4d4f', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{image.error || image.name}</div>
</div>
))}
</div>
);
}
+14 -3
View File
@@ -26,9 +26,20 @@ const SECTIONS = [
{ id: 'section-collect', label: '采集图片' }, { id: 'section-collect', label: '采集图片' },
{ id: 'section-plan', label: '出图方案' }, { id: 'section-plan', label: '出图方案' },
{ id: 'section-result', label: '生成结果' }, { id: 'section-result', label: '生成结果' },
{ id: 'section-video', label: '视频制作' },
{ id: 'section-export', label: '入库与导出' }, { id: 'section-export', label: '入库与导出' },
]; ];
// 固定页头为 64px;额外保留内容间距,避免目标卡片标题被页头遮住。
const SECTION_SCROLL_OFFSET = 88;
function scrollToSection(id: string) {
const element = document.getElementById(id);
if (!element) return;
const top = window.scrollY + element.getBoundingClientRect().top - SECTION_SCROLL_OFFSET;
window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
}
export default function TrialPage() { export default function TrialPage() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -62,7 +73,7 @@ export default function TrialPage() {
let current = SECTIONS[0].id; let current = SECTIONS[0].id;
for (const s of SECTIONS) { for (const s of SECTIONS) {
const el = document.getElementById(s.id); const el = document.getElementById(s.id);
if (el && el.getBoundingClientRect().top <= 90) current = s.id; if (el && el.getBoundingClientRect().top <= SECTION_SCROLL_OFFSET) current = s.id;
} }
setActive(current); setActive(current);
}; };
@@ -170,7 +181,7 @@ export default function TrialPage() {
{/* 4/5/6 三张卡片由 TrialSuitePanel 渲染,锚点在面板内部 */} {/* 4/5/6 三张卡片由 TrialSuitePanel 渲染,锚点在面板内部 */}
<TrialSuitePanel product={product} assets={assets} onRefreshAssets={load} /> <TrialSuitePanel product={product} assets={assets} onRefreshAssets={load} />
<div id="section-export"> <div id="section-export">
<Card title="7 入库与导出"> <Card title="8 入库与导出">
<TrialExportPanel product={product} /> <TrialExportPanel product={product} />
</Card> </Card>
</div> </div>
@@ -188,7 +199,7 @@ export default function TrialPage() {
style={{ borderInlineEnd: 0, background: 'transparent' }} style={{ borderInlineEnd: 0, background: 'transparent' }}
items={SECTIONS.map((s) => ({ key: s.id, label: s.label }))} items={SECTIONS.map((s) => ({ key: s.id, label: s.label }))}
onClick={({ key }) => { onClick={({ key }) => {
document.getElementById(key)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); scrollToSection(key);
}} }}
/> />
</div> </div>
+69 -564
View File
@@ -5,10 +5,10 @@
*/ */
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react'; import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import { import {
Alert, App as AntdApp, Button, Card, Checkbox, Col, Empty, Image, Input, InputNumber, Popconfirm, Popover, Progress, Alert, App as AntdApp, Button, Card, Checkbox, Col, Empty, Input, Popconfirm, Popover, Progress,
Radio, Row, Segmented, Select, Space, Tag, Typography, Upload, type ImageProps, Row, Segmented, Select, Space, Tag, Typography, Upload,
} from 'antd'; } from 'antd';
import { DeleteOutlined, DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons'; import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
import { ProductAsset, ProductDetail } from '@/services/product'; import { ProductAsset, ProductDetail } from '@/services/product';
import { apiErrorMessage } from '@/services/api'; import { apiErrorMessage } from '@/services/api';
import { import {
@@ -19,21 +19,14 @@ import {
} from '@/services/suite'; } from '@/services/suite';
import { cleanFilename, downloadBlob } from '@/utils/file'; import { cleanFilename, downloadBlob } from '@/utils/file';
import AiImageGenModal from './AiImageGenModal'; import AiImageGenModal from './AiImageGenModal';
import { CollectionImageGroups, FailedSuiteImages, GeneratedImageGrid } from './TrialImageGrids';
import TrialWatermarkSettings from './TrialWatermarkSettings';
import TrialVideoMaker from './TrialVideoMaker';
import { DISPLAY_GROUPS, GROUP_LABELS, type ImagePreviewConfig, type ResultImage } from './trialImageTypes';
const { Text } = Typography; const { Text } = Typography;
const GROUP_LABELS: Record<string, string> = {
main: '主图',
sku: 'SKU 图',
detail: '详情图',
generated: '生成图',
upload: '手动上传',
param: '参数图',
video: '视频',
};
const DISPLAY_GROUPS = ['main', 'sku', 'detail', 'generated', 'upload'];
const WATERMARK_STORAGE_KEY = 'trialWatermark'; const WATERMARK_STORAGE_KEY = 'trialWatermark';
type ImagePreviewConfig = Pick<NonNullable<Exclude<ImageProps['preview'], boolean>>, 'actionsRender'>;
/** 方案数量步进器:左减号 / 中间可输入 / 右加号(0-5) */ /** 方案数量步进器:左减号 / 中间可输入 / 右加号(0-5) */
function PlanStepper({ value, onChange }: { value: number; onChange: (v: number) => void }) { function PlanStepper({ value, onChange }: { value: number; onChange: (v: number) => void }) {
@@ -177,7 +170,6 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
const [selectedResultKeys, setSelectedResultKeys] = useState<Set<string>>(() => new Set()); const [selectedResultKeys, setSelectedResultKeys] = useState<Set<string>>(() => new Set());
/** 删除后在本次任务的内存结果中同步隐藏,避免刷新前仍可导出已删图片。 */ /** 删除后在本次任务的内存结果中同步隐藏,避免刷新前仍可导出已删图片。 */
const [hiddenResultKeys, setHiddenResultKeys] = useState<Set<string>>(() => new Set()); const [hiddenResultKeys, setHiddenResultKeys] = useState<Set<string>>(() => new Set());
const [hoveredImageKey, setHoveredImageKey] = useState<string | null>(null);
const [downloadingResultKeys, setDownloadingResultKeys] = useState<Set<string>>(() => new Set()); const [downloadingResultKeys, setDownloadingResultKeys] = useState<Set<string>>(() => new Set());
const [applyingWatermark, setApplyingWatermark] = useState(false); const [applyingWatermark, setApplyingWatermark] = useState(false);
const [watermarkJob, setWatermarkJob] = useState<SuiteInfo | null>(null); const [watermarkJob, setWatermarkJob] = useState<SuiteInfo | null>(null);
@@ -212,7 +204,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
}; };
const rawObj = (product.raw ?? {}) as Record<string, unknown>; const rawObj = (product.raw ?? {}) as Record<string, unknown>;
const productTitle = ((rawObj.title_zh as string) || (rawObj.title as string) || product.name || '').trim(); const productTitle = ((rawObj.title_zh as string) || (rawObj.title as string) || product.name || '').trim();
const resultImages = useMemo(() => { const resultImages = useMemo<ResultImage[]>(() => {
const suiteImages = (suite?.images ?? []) const suiteImages = (suite?.images ?? [])
.filter((img) => img.status === 'done') .filter((img) => img.status === 'done')
.map((img, index) => { .map((img, index) => {
@@ -899,155 +891,27 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
}; };
const watermarkPopup = ( const watermarkPopup = (
<div style={{ width: 280 }}> <TrialWatermarkSettings
<Checkbox watermark={watermark}
checked={watermark.enabled} watermarkJob={watermarkJob}
onChange={(e) => patchWatermark({ enabled: e.target.checked })} applying={applyingWatermark}
> selectedCollectionCount={selectedCollectionWatermarkAssetIds.length}
新生成图片自动加水印 selectedResultCount={selectedWatermarkAssetIds.length}
</Checkbox> selectedWatermarkedCollectionCount={selectedWatermarkedCollectionIds.length}
{watermarkJob && ( selectedWatermarkedResultCount={selectedWatermarkedResultIds.length}
<Alert onPatch={patchWatermark}
showIcon onApplyCollection={() => handleApplyWatermark(selectedCollectionWatermarkAssetIds, '采集图片')}
type={watermarkJob.status === 'done' ? 'success' : watermarkJob.status === 'failed' ? 'error' : 'info'} onApplyResult={() => handleApplyWatermark(selectedWatermarkAssetIds, '生成结果图片', selectedWatermarkItems)}
style={{ marginTop: 10 }} onRemoveCollection={() => handleRemoveWatermark(selectedWatermarkedCollectionIds, '采集图片')}
message={ onRemoveResult={() => handleRemoveWatermark(
['queued', 'running'].includes(watermarkJob.status) selectedWatermarkedResultIds,
? `水印处理中:${watermarkJob.completed ?? 0}/${watermarkJob.total ?? 0}` '生成结果图片',
: watermarkJob.status === 'partial' selectedWatermarkItems.filter((item) => assetById.get(item.assetId!)?.watermark_enabled),
? `水印部分完成:${watermarkJob.completed ?? 0} 成功,${watermarkJob.failed ?? 0} 失败`
: watermarkJob.status === 'done'
? `水印已完成:${watermarkJob.completed ?? watermarkJob.total ?? 0} 张`
: watermarkJob.status === 'cancelled'
? '水印任务已取消'
: '水印任务失败'
}
description={
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{watermarkJob.error && <Text type="secondary" style={{ fontSize: 11 }}>{watermarkJob.error}</Text>}
{['partial', 'failed'].includes(watermarkJob.status) && (
<Button size="small" onClick={() => void handleRetryWatermarkJob()}>重试所有失败项</Button>
)}
{['queued', 'running'].includes(watermarkJob.status) && (
<Popconfirm
title="取消水印任务"
description="尚未处理的图片将停止;已经完成的图片会保留水印版。"
okText="取消任务"
cancelText="继续处理"
okButtonProps={{ danger: true }}
onConfirm={() => void handleCancelWatermarkJob()}
>
<Button size="small" danger>取消水印任务</Button>
</Popconfirm>
)}
{watermarkJob.images.filter((item) => item.status === 'failed').map((item) => (
<Space key={item.item_id} size={4} align="start">
<Text type="danger" style={{ fontSize: 11, maxWidth: 185 }} ellipsis={{ tooltip: item.error || item.name }}>
{item.name}:{item.error || '处理失败'}
</Text>
<Button size="small" onClick={() => void handleRetryWatermarkItem(item.item_id)}>重试</Button>
</Space>
))}
</Space>
}
/>
)} )}
{watermark.enabled && ( onCancelJob={() => void handleCancelWatermarkJob()}
<> onRetryJob={() => void handleRetryWatermarkJob()}
<div style={{ margin: '10px 0 6px' }}> onRetryItem={(itemId) => void handleRetryWatermarkItem(itemId)}
<Radio.Group />
size="small"
optionType="button"
buttonStyle="solid"
value={watermark.type}
onChange={(e) => patchWatermark({ type: e.target.value })}
options={[
{ value: 'image', label: '图片徽章' },
{ value: 'text', label: '文字' },
]}
/>
</div>
{watermark.type === 'text' && (
<Input
size="small"
value={watermark.text}
placeholder="水印文字"
onChange={(e) => patchWatermark({ text: e.target.value })}
/>
)}
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
不透明度(%)
</Text>
<InputNumber
size="small"
style={{ width: '100%' }}
min={1}
max={100}
step={5}
value={watermark.opacity}
onChange={(v) => patchWatermark({ opacity: v ?? 30 })}
/>
</div>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 6 }}>
位置固定右下角;一键与单张 AI 生图会直接按此设置合成水印,也可应用到已选采集图或生成图
</Text>
<Button
type="primary"
size="small"
block
style={{ marginTop: 10 }}
loading={applyingWatermark}
disabled={applyingWatermark || selectedCollectionWatermarkAssetIds.length === 0}
onClick={() => handleApplyWatermark(selectedCollectionWatermarkAssetIds, '采集图片')}
>
为已选采集图添加水印({selectedCollectionWatermarkAssetIds.length})
</Button>
<Button
type="primary"
size="small"
block
style={{ marginTop: 6 }}
loading={applyingWatermark}
disabled={applyingWatermark || selectedWatermarkAssetIds.length === 0}
onClick={() => handleApplyWatermark(selectedWatermarkAssetIds, '生成结果图片', selectedWatermarkItems)}
>
为已选生成图添加水印({selectedWatermarkAssetIds.length})
</Button>
</>
)}
{(selectedWatermarkedCollectionIds.length > 0 || selectedWatermarkedResultIds.length > 0) && (
<>
<Text type="secondary" style={{ display: 'block', fontSize: 11, marginTop: 10 }}>
移除水印会恢复无水印原图,并删除当前水印版
</Text>
<Button
size="small"
block
style={{ marginTop: 6 }}
loading={applyingWatermark}
disabled={applyingWatermark || selectedWatermarkedCollectionIds.length === 0}
onClick={() => handleRemoveWatermark(selectedWatermarkedCollectionIds, '采集图片')}
>
移除已选采集图水印({selectedWatermarkedCollectionIds.length})
</Button>
<Button
size="small"
block
style={{ marginTop: 6 }}
loading={applyingWatermark}
disabled={applyingWatermark || selectedWatermarkedResultIds.length === 0}
onClick={() => handleRemoveWatermark(
selectedWatermarkedResultIds,
'生成结果图片',
selectedWatermarkItems.filter((item) => assetById.get(item.assetId!)?.watermark_enabled),
)}
>
移除已选生成图水印({selectedWatermarkedResultIds.length})
</Button>
</>
)}
</div>
); );
const selectableCount = imgs.length; const selectableCount = imgs.length;
@@ -1120,152 +984,17 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
{displayGroups.length === 0 ? ( {displayGroups.length === 0 ? (
<Empty description="暂无素材,可点击右上角「上传图片」补充参考底图" /> <Empty description="暂无素材,可点击右上角「上传图片」补充参考底图" />
) : ( ) : (
<Image.PreviewGroup preview={imagePreviewConfig}> <CollectionImageGroups
{displayGroups.map((g) => ( groups={displayGroups.map((group) => ({ key: group, assets: groupImages(group) }))}
<div key={g} style={{ marginBottom: 14 }}> selectedKeys={selectedKeys}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}> preview={imagePreviewConfig}
<Text strong> imageUrl={assetUrl}
{GROUP_LABELS[g] ?? g} imageName={assetName}
<Text type="secondary" style={{ fontWeight: 400, marginLeft: 6 }}> onToggle={toggleKey}
{groupImages(g).length} onToggleGroup={toggleGroup}
</Text> onDelete={(assetId) => void handleDeleteAsset(assetId)}
</Text> onEdit={(asset) => setGenModal({ url: assetOriginalUrl(asset), name: asset.variant_name || asset.id })}
<a />
style={{ fontSize: 12 }}
onClick={() => {
const all = groupImages(g).every((a) => selectedKeys.has(a.id));
toggleGroup(g, !all);
}}
>
{groupImages(g).every((a) => selectedKeys.has(a.id)) ? '取消全选' : '全选'}
</a>
{g === 'main' && (
<Text type="secondary" style={{ fontSize: 11 }}>
勾选的图片作为生图参考底图
</Text>
)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: 10 }}>
{groupImages(g).map((a) => {
const on = selectedKeys.has(a.id);
return (
<div key={a.id}>
<div
onMouseEnter={() => setHoveredImageKey(`collect-${a.id}`)}
onMouseLeave={() => setHoveredImageKey(null)}
style={{
position: 'relative',
border: `2px solid ${on ? '#1677ff' : 'transparent'}`,
borderRadius: 8,
padding: 2,
background: on ? 'rgba(22,119,255,0.06)' : undefined,
}}
>
<Image
src={assetUrl(a)}
width="100%"
style={{ objectFit: 'cover', borderRadius: 6, aspectRatio: '1 / 1', display: 'block' }}
wrapperStyle={{ width: '100%', display: 'block' }}
fallback="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'><rect width='100' height='100' fill='%23eee'/><text x='22' y='52' font-size='11' fill='%23999'>无预览</text></svg>"
/>
<Popconfirm
title="删除图片"
description="确认删除这张采集图片?"
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => handleDeleteAsset(a.id)}
>
<Button
size="small"
danger
type="text"
icon={<DeleteOutlined />}
title="删除这张图片"
onClick={(e) => e.stopPropagation()}
style={{
position: 'absolute',
top: 5,
left: 5,
zIndex: 3,
opacity: hoveredImageKey === `collect-${a.id}` ? 1 : 0,
pointerEvents: hoveredImageKey === `collect-${a.id}` ? 'auto' : 'none',
transition: 'opacity 0.15s',
background: 'rgba(255,255,255,0.94)',
}}
/>
</Popconfirm>
<span
title="勾选为参考底图"
onClick={() => toggleKey(a.id)}
style={{
position: 'absolute',
top: 6,
right: 6,
width: 20,
height: 20,
borderRadius: '50%',
background: on ? '#1677ff' : 'rgba(255,255,255,0.9)',
color: on ? '#fff' : '#bbb',
border: '1px solid #d9d9d9',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
fontSize: 12,
zIndex: 2,
userSelect: 'none',
}}
>
✓
</span>
{a.status !== 'uploaded' && (
<Tag
color={a.status === 'failed' ? 'red' : 'default'}
style={{ position: 'absolute', bottom: 6, left: 6, margin: 0, zIndex: 2 }}
>
{a.status === 'failed' ? '转存失败' : '转存中'}
</Tag>
)}
{/* AI 生图:半透明悬浮在图片右下角 */}
<Button
size="small"
type="text"
onClick={() => setGenModal({ url: assetOriginalUrl(a), name: a.variant_name || a.id })}
style={{
position: 'absolute',
right: 5,
bottom: 5,
zIndex: 2,
background: 'rgba(0,0,0,0.58)',
color: '#fff',
borderColor: 'transparent',
fontSize: 11,
}}
>
AI 生图
</Button>
</div>
<div
title={a.variant_name || assetName(a)}
style={{
fontSize: 11,
color: '#999',
marginTop: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{a.variant_name || assetName(a)}
</div>
</div>
);
})}
</div>
</div>
))}
</Image.PreviewGroup>
)} )}
</Card> </Card>
</div> </div>
@@ -1538,137 +1267,17 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
) : null} ) : null}
</Space> </Space>
</div> </div>
<Image.PreviewGroup preview={imagePreviewConfig}> <GeneratedImageGrid
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: 10, width: '100%' }}> images={suiteResultImages}
{suiteResultImages.map((img) => { selectedKeys={selectedResultKeys}
const selected = selectedResultKeys.has(img.key); downloadingKeys={downloadingResultKeys}
return ( preview={imagePreviewConfig}
<div key={img.key} style={{ minWidth: 0 }} title={img.name}> onToggle={toggleResultKey}
<div onDelete={(image) => image.assetId && void handleDeleteAsset(image.assetId, [image.key, `asset-${image.assetId}`])}
onMouseEnter={() => setHoveredImageKey(img.key)} onDownload={(image) => void handleDownloadResultImage(image)}
onMouseLeave={() => setHoveredImageKey(null)} onEdit={(image) => setGenModal({ url: assetById.get(image.assetId || '')?.original_url || image.url, name: image.name, afterAssetId: image.assetId })}
style={{ />
position: 'relative', <FailedSuiteImages images={suite.images.filter((image) => image.status === 'failed')} />
border: `2px solid ${selected ? '#1677ff' : 'transparent'}`,
borderRadius: 8,
padding: 2,
background: selected ? 'rgba(22,119,255,0.06)' : undefined,
aspectRatio: '3 / 4',
overflow: 'hidden',
}}
>
<Image
src={img.url}
width="100%"
height="100%"
style={{ objectFit: 'cover', borderRadius: 6, display: 'block' }}
/>
{img.assetId && (
<Popconfirm
title="删除图片"
description="确认删除这张已生成的图片?"
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => handleDeleteAsset(img.assetId!, [img.key, `asset-${img.assetId}`])}
>
<Button
size="small"
danger
type="text"
icon={<DeleteOutlined />}
title="删除这张图片"
onClick={(e) => e.stopPropagation()}
style={{
position: 'absolute', top: 5, left: 5, zIndex: 3,
opacity: hoveredImageKey === img.key ? 1 : 0,
pointerEvents: hoveredImageKey === img.key ? 'auto' : 'none',
transition: 'opacity 0.15s', background: 'rgba(255,255,255,0.94)',
}}
/>
</Popconfirm>
)}
<span
title="勾选导出"
onClick={() => toggleResultKey(img.key)}
style={{
position: 'absolute', top: 5, right: 5, width: 20, height: 20,
borderRadius: '50%', background: selected ? '#1677ff' : 'rgba(255,255,255,0.9)',
color: selected ? '#fff' : '#bbb', border: '1px solid #d9d9d9', display: 'flex',
alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 12,
zIndex: 2, userSelect: 'none',
}}
>
✓
</span>
<Button
size="small"
type="text"
icon={<DownloadOutlined />}
title="下载这张图片"
loading={downloadingResultKeys.has(img.key)}
onClick={(e) => {
e.stopPropagation();
void handleDownloadResultImage(img);
}}
style={{
position: 'absolute', bottom: 5, left: 5, zIndex: 3,
opacity: hoveredImageKey === img.key ? 1 : 0,
pointerEvents: hoveredImageKey === img.key ? 'auto' : 'none',
transition: 'opacity 0.15s', background: 'rgba(255,255,255,0.94)',
}}
/>
<Button
size="small"
type="text"
title="基于这张图再次 AI 编辑"
onClick={(e) => {
e.stopPropagation();
setGenModal({
url: assetById.get(img.assetId || '')?.original_url || img.url,
name: img.name,
afterAssetId: img.assetId,
});
}}
style={{
position: 'absolute', bottom: 5, right: 5, zIndex: 3,
opacity: hoveredImageKey === img.key ? 1 : 0,
pointerEvents: hoveredImageKey === img.key ? 'auto' : 'none',
transition: 'opacity 0.15s', background: 'rgba(0,0,0,0.58)', color: '#fff',
borderColor: 'transparent', fontSize: 11,
}}
>
AI 编辑
</Button>
</div>
<div
style={{ fontSize: 11, color: 'rgba(0,0,0,0.45)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
title={img.name}
>
{img.name}
</div>
</div>
);
})}
{suite.images.filter((img) => img.status === 'failed').map((img) => (
<div key={`failed-${img.type_id}-${img.name}`} style={{ minWidth: 0 }} title={img.error || img.name}>
<div
style={{
width: '100%', aspectRatio: '3 / 4', borderRadius: 6, background: '#f5f5f5', display: 'flex',
alignItems: 'center', justifyContent: 'center', color: '#ff4d4f',
}}
>
✗
</div>
<div
style={{ fontSize: 11, color: '#ff4d4f', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{img.error || img.name}
</div>
</div>
))}
</div>
</Image.PreviewGroup>
{suite.error && <Alert type="warning" showIcon message={suite.error} style={{ marginTop: 8 }} />} {suite.error && <Alert type="warning" showIcon message={suite.error} style={{ marginTop: 8 }} />}
</> </>
)} )}
@@ -1681,129 +1290,18 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
(一键生成与单张生图的落库历史,刷新后仍保留 · {historyResultImages.length} 张) (一键生成与单张生图的落库历史,刷新后仍保留 · {historyResultImages.length} 张)
</Text> </Text>
</Text> </Text>
<Image.PreviewGroup preview={imagePreviewConfig}> <div style={{ marginTop: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: 10, marginTop: 10, width: '100%' }}> <GeneratedImageGrid
{historyResultImages.map((img) => { images={historyResultImages}
const selected = selectedResultKeys.has(img.key); selectedKeys={selectedResultKeys}
return ( downloadingKeys={downloadingResultKeys}
<div key={img.key} style={{ minWidth: 0 }}> preview={imagePreviewConfig}
<div onToggle={toggleResultKey}
onMouseEnter={() => setHoveredImageKey(img.key)} onDelete={(image) => image.assetId && void handleDeleteAsset(image.assetId, [image.key])}
onMouseLeave={() => setHoveredImageKey(null)} onDownload={(image) => void handleDownloadResultImage(image)}
style={{ onEdit={(image) => setGenModal({ url: assetById.get(image.assetId || '')?.original_url || image.url, name: image.name, afterAssetId: image.assetId })}
position: 'relative', />
border: `2px solid ${selected ? '#1677ff' : 'transparent'}`, </div>
borderRadius: 8,
padding: 2,
background: selected ? 'rgba(22,119,255,0.06)' : undefined,
aspectRatio: '3 / 4',
overflow: 'hidden',
}}
>
<Image
src={img.url}
width="100%"
height="100%"
style={{ objectFit: 'cover', borderRadius: 6 }}
fallback="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='133'><rect width='100' height='133' fill='%23eee'/><text x='18' y='70' font-size='11' fill='%23999'>无预览</text></svg>"
/>
<Popconfirm
title="删除图片"
description="确认删除这张已生成的图片?"
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => handleDeleteAsset(img.assetId!, [img.key])}
>
<Button
size="small"
danger
type="text"
icon={<DeleteOutlined />}
title="删除这张图片"
onClick={(e) => e.stopPropagation()}
style={{
position: 'absolute', top: 5, left: 5, zIndex: 3,
opacity: hoveredImageKey === img.key ? 1 : 0,
pointerEvents: hoveredImageKey === img.key ? 'auto' : 'none',
transition: 'opacity 0.15s', background: 'rgba(255,255,255,0.94)',
}}
/>
</Popconfirm>
<span
title="勾选导出"
onClick={() => toggleResultKey(img.key)}
style={{
position: 'absolute', top: 5, right: 5, width: 20, height: 20,
borderRadius: '50%', background: selected ? '#1677ff' : 'rgba(255,255,255,0.9)',
color: selected ? '#fff' : '#bbb', border: '1px solid #d9d9d9', display: 'flex',
alignItems: 'center', justifyContent: 'center', cursor: 'pointer', fontSize: 12,
zIndex: 2, userSelect: 'none',
}}
>
✓
</span>
<Button
size="small"
type="text"
icon={<DownloadOutlined />}
title="下载这张图片"
loading={downloadingResultKeys.has(img.key)}
onClick={(e) => {
e.stopPropagation();
void handleDownloadResultImage(img);
}}
style={{
position: 'absolute', bottom: 5, left: 5, zIndex: 3,
opacity: hoveredImageKey === img.key ? 1 : 0,
pointerEvents: hoveredImageKey === img.key ? 'auto' : 'none',
transition: 'opacity 0.15s', background: 'rgba(255,255,255,0.94)',
}}
/>
<Button
size="small"
type="text"
title="基于这张图再次 AI 编辑"
onClick={(e) => {
e.stopPropagation();
setGenModal({
url: assetById.get(img.assetId || '')?.original_url || img.url,
name: img.name,
afterAssetId: img.assetId,
});
}}
style={{
position: 'absolute', bottom: 5, right: 5, zIndex: 3,
opacity: hoveredImageKey === img.key ? 1 : 0,
pointerEvents: hoveredImageKey === img.key ? 'auto' : 'none',
transition: 'opacity 0.15s', background: 'rgba(0,0,0,0.58)', color: '#fff',
borderColor: 'transparent', fontSize: 11,
}}
>
AI 编辑
</Button>
</div>
<div
style={{
fontSize: 11,
marginTop: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={img.name}
>
{img.createdAt && (
<span style={{ color: 'rgba(0,0,0,0.45)' }}>
{new Date(img.createdAt).toLocaleTimeString()}
</span>
)}
</div>
</div>
);
})}
</div>
</Image.PreviewGroup>
</div> </div>
)} )}
</> </>
@@ -1811,6 +1309,13 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
</Card> </Card>
</div> </div>
{/* ── 7 视频制作 ── */}
<div id="section-video">
<Card title="7 视频制作">
<TrialVideoMaker productId={product.id} assets={assets} />
</Card>
</div>
{/* 单张 AI 生图弹窗 */} {/* 单张 AI 生图弹窗 */}
<AiImageGenModal <AiImageGenModal
open={genModal !== null} open={genModal !== null}
@@ -0,0 +1,91 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { CloseOutlined, DeleteOutlined, DownloadOutlined, LoadingOutlined, ReloadOutlined, VideoCameraOutlined } from '@ant-design/icons';
import { Alert, App, Button, Card, Empty, Image, Modal, Popconfirm, Space, Tag, Typography } from 'antd';
import type { ProductAsset } from '@/services/product';
import { apiErrorMessage } from '@/services/api';
import { cancelVideoJob, createVideoJob, deleteVideoJob, listVideoJobs, retryVideoJob, videoDownloadUrl, type VideoJobInfo } from '@/services/suite';
const { Text } = Typography;
const urlOf = (asset: ProductAsset) => asset.stored_url || asset.source_url;
const nameOf = (asset: ProductAsset) => decodeURIComponent((asset.source_url || asset.stored_url || '').split('?')[0].split('/').pop() || asset.variant_name || '未命名图片');
const statusText = (status: VideoJobInfo['status']) => ({ queued: '排队中', running: '合成中', done: '已完成', partial: '部分完成', failed: '失败', cancelled: '已取消' } as const)[status];
const statusColor = (status: VideoJobInfo['status']) => status === 'done' ? 'green' : status === 'failed' ? 'red' : status === 'partial' ? 'orange' : status === 'cancelled' ? 'default' : 'processing';
function VideoCard({ job, removing, onCancel, onDelete, onRetry }: {
job: VideoJobInfo;
removing: boolean;
onCancel: (id: string) => void;
onDelete: (id: string) => void;
onRetry: (id: string) => void;
}) {
const active = ['queued', 'running'].includes(job.status);
const done = job.status === 'done' && !!job.result?.url;
const failed = ['failed', 'partial'].includes(job.status);
return <Card size="small" styles={{ body: { padding: 10 } }} style={{ minWidth: 0 }}>
<div style={{ position: 'relative', aspectRatio: '3 / 4', overflow: 'hidden', borderRadius: 6, background: done ? '#111' : '#f5f5f5' }}>
{done ? <video controls preload="metadata" style={{ width: '100%', height: '100%', display: 'block', objectFit: 'cover' }} src={job.result?.url || undefined} /> : <div style={{ height: '100%', display: 'grid', placeItems: 'center', textAlign: 'center', color: failed ? '#ff4d4f' : 'rgba(0,0,0,0.55)' }}>
<Space direction="vertical" size={6}>{active ? <LoadingOutlined spin style={{ fontSize: 24, color: '#1677ff' }} /> : <CloseOutlined style={{ fontSize: 22 }} />}<Text type={failed ? 'danger' : 'secondary'}>{active ? '合成中' : '合成失败'}</Text></Space>
</div>}
<Popconfirm
title={active ? '取消视频合成?' : '删除视频?'}
description={active ? '任务会立即移除,已产生的临时文件会清理。' : '视频文件和任务记录将永久删除。'}
okText={active ? '取消并移除' : '删除'}
cancelText="保留"
okButtonProps={{ danger: true }}
onConfirm={() => active ? onCancel(job.id) : onDelete(job.id)}
>
<Button size="small" danger type="text" loading={removing} icon={active ? <CloseOutlined /> : <DeleteOutlined />} title={active ? '取消合成' : '删除视频'} style={{ position: 'absolute', top: 5, left: 5, zIndex: 2, background: 'rgba(255,255,255,0.94)' }} />
</Popconfirm>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0, marginTop: 8 }}>
<Tag color={statusColor(job.status)} style={{ margin: 0, fontSize: 13 }}>{statusText(job.status)}</Tag>
<Text type="secondary" style={{ minWidth: 0, fontSize: 13, whiteSpace: 'nowrap' }}>{job.source_asset_ids.length} 图 · {job.duration_seconds ? `${job.duration_seconds.toFixed(1)} 秒` : `每图 ${job.slide_seconds} 秒`}</Text>
{done && <Button type="link" size="small" icon={<DownloadOutlined />} href={videoDownloadUrl(job.id)} style={{ marginLeft: 'auto', paddingInline: 0, fontSize: 13, whiteSpace: 'nowrap' }}>下载 MP4</Button>}
{failed && <Button type="link" size="small" icon={<ReloadOutlined />} onClick={() => onRetry(job.id)} style={{ marginLeft: 'auto', paddingInline: 0, fontSize: 13, whiteSpace: 'nowrap' }}>重试</Button>}
</div>
{job.error && <Text type="danger" ellipsis={{ tooltip: job.error }} style={{ display: 'block', fontSize: 12, marginTop: 5 }}>{job.error}</Text>}
</Card>;
}
export default function TrialVideoMaker({ productId, assets }: { productId: string; assets: ProductAsset[] }) {
const { message } = App.useApp();
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const [removingId, setRemovingId] = useState<string | null>(null);
const [jobs, setJobs] = useState<VideoJobInfo[]>([]);
const candidates = useMemo(() => assets.filter((asset) => asset.type === 'img' && asset.status === 'uploaded' && !!urlOf(asset)).sort((a, b) => Number(b.group_key === 'generated') - Number(a.group_key === 'generated') || (b.created_at || '').localeCompare(a.created_at || '')), [assets]);
const loadJobs = useCallback(async () => { try { setJobs(await listVideoJobs(productId)); } catch (error) { message.error(apiErrorMessage(error)); } }, [message, productId]);
useEffect(() => { void loadJobs(); }, [loadJobs]);
useEffect(() => { if (!jobs.some((job) => ['queued', 'running'].includes(job.status))) return; const timer = window.setInterval(() => void loadJobs(), 1500); return () => window.clearInterval(timer); }, [jobs, loadJobs]);
const toggle = (id: string) => setSelected((prev) => prev.includes(id) ? prev.filter((value) => value !== id) : prev.length >= 5 ? (message.warning('首版最多选择 5 张图片'), prev) : [...prev, id]);
const submit = async () => {
if (selected.length < 2) return message.warning('请至少选择 2 张图片');
setSubmitting(true);
try { await createVideoJob(productId, selected); message.success('视频已加入后台合成队列'); setSelected([]); setOpen(false); await loadJobs(); } catch (error) { message.error(apiErrorMessage(error)); } finally { setSubmitting(false); }
};
const cancel = async (id: string) => {
setRemovingId(id);
try { await cancelVideoJob(id); setJobs((prev) => prev.filter((job) => job.id !== id)); } catch (error) { message.error(apiErrorMessage(error)); } finally { setRemovingId(null); }
};
const remove = async (id: string) => {
setRemovingId(id);
try { await deleteVideoJob(id); setJobs((prev) => prev.filter((job) => job.id !== id)); } catch (error) { message.error(apiErrorMessage(error)); } finally { setRemovingId(null); }
};
const retry = async (id: string) => { try { await retryVideoJob(id); await loadJobs(); } catch (error) { message.error(apiErrorMessage(error)); } };
return <div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div><Text strong>视频制作</Text><Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>选择 2–5 张图片,合成 810×1080 无音轨轮播 MP4</Text></div>
<Button type="primary" size="small" icon={<VideoCameraOutlined />} disabled={candidates.length < 2} onClick={() => setOpen(true)}>合成视频</Button>
</div>
{jobs.filter((job) => job.status !== 'cancelled').length === 0 ? <Text type="secondary" style={{ display: 'block', marginTop: 10, fontSize: 12 }}>生成的轮播视频会保存在这里,可随时下载。</Text> : <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(230px, 1fr))', alignItems: 'start', gap: 10, marginTop: 12 }}>{jobs.filter((job) => job.status !== 'cancelled').slice(0, 8).map((job) => <VideoCard key={job.id} job={job} removing={removingId === job.id} onCancel={(id) => void cancel(id)} onDelete={(id) => void remove(id)} onRetry={(id) => void retry(id)} />)}</div>}
<Modal open={open} onCancel={() => setOpen(false)} onOk={() => void submit()} okText={`合成视频(${selected.length}/5)`} okButtonProps={{ disabled: selected.length < 2, loading: submitting }} width={900} title="选择轮播图片">
<Alert type="info" showIcon message="AI 生成图排在前面;点击图片按播放顺序选择。" style={{ marginBottom: 12 }} />
{candidates.length < 2 ? <Empty description="至少需要两张已转存图片" /> : <div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 10, maxHeight: 430, overflowY: 'auto' }}>{candidates.map((asset) => { const index = selected.indexOf(asset.id); return <div key={asset.id} onClick={() => toggle(asset.id)} style={{ cursor: 'pointer', border: `2px solid ${index >= 0 ? '#1677ff' : 'transparent'}`, borderRadius: 7, padding: 2, position: 'relative' }}><Image preview={false} loading="lazy" src={urlOf(asset)} width="100%" style={{ aspectRatio: '3 / 4', objectFit: 'cover', borderRadius: 5 }} />{index >= 0 && <span style={{ position: 'absolute', top: 6, right: 6, background: '#1677ff', color: '#fff', borderRadius: 12, minWidth: 22, textAlign: 'center', lineHeight: '22px' }}>{index + 1}</span>}<div style={{ fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{asset.group_key === 'generated' ? 'AI 图 · ' : ''}{nameOf(asset)}</div></div>; })}</div>}
{selected.length > 0 && <Text type="secondary" style={{ display: 'block', marginTop: 10 }}>播放顺序即选中序号;取消后重新选择可调整顺序。</Text>}
</Modal>
</div>;
}
@@ -0,0 +1,123 @@
import { Alert, Button, Checkbox, Input, InputNumber, Popconfirm, Radio, Space, Typography } from 'antd';
import type { SuiteInfo, WatermarkPayload } from '@/services/suite';
const { Text } = Typography;
interface Props {
watermark: WatermarkPayload;
watermarkJob: SuiteInfo | null;
applying: boolean;
selectedCollectionCount: number;
selectedResultCount: number;
selectedWatermarkedCollectionCount: number;
selectedWatermarkedResultCount: number;
onPatch: (patch: Partial<WatermarkPayload>) => void;
onApplyCollection: () => void;
onApplyResult: () => void;
onRemoveCollection: () => void;
onRemoveResult: () => void;
onCancelJob: () => void;
onRetryJob: () => void;
onRetryItem: (itemId: string) => void;
}
export default function TrialWatermarkSettings({
watermark,
watermarkJob,
applying,
selectedCollectionCount,
selectedResultCount,
selectedWatermarkedCollectionCount,
selectedWatermarkedResultCount,
onPatch,
onApplyCollection,
onApplyResult,
onRemoveCollection,
onRemoveResult,
onCancelJob,
onRetryJob,
onRetryItem,
}: Props) {
return (
<div style={{ width: 280 }}>
<Checkbox checked={watermark.enabled} onChange={(event) => onPatch({ enabled: event.target.checked })}>
新生成图片自动加水印
</Checkbox>
{watermarkJob && <WatermarkTaskStatus job={watermarkJob} onCancel={onCancelJob} onRetry={onRetryJob} onRetryItem={onRetryItem} />}
{watermark.enabled && (
<>
<div style={{ margin: '10px 0 6px' }}>
<Radio.Group
size="small"
optionType="button"
buttonStyle="solid"
value={watermark.type}
onChange={(event) => onPatch({ type: event.target.value })}
options={[{ value: 'image', label: '图片徽章' }, { value: 'text', label: '文字' }]}
/>
</div>
{watermark.type === 'text' && <Input size="small" value={watermark.text} placeholder="水印文字" onChange={(event) => onPatch({ text: event.target.value })} />}
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>不透明度(%)</Text>
<InputNumber size="small" style={{ width: '100%' }} min={1} max={100} step={5} value={watermark.opacity} onChange={(value) => onPatch({ opacity: value ?? 30 })} />
</div>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 6 }}>
位置固定右下角;一键与单张 AI 生图会直接按此设置合成水印,也可应用到已选采集图或生成图
</Text>
<Button type="primary" size="small" block style={{ marginTop: 10 }} loading={applying} disabled={applying || selectedCollectionCount === 0} onClick={onApplyCollection}>
为已选采集图添加水印({selectedCollectionCount})
</Button>
<Button type="primary" size="small" block style={{ marginTop: 6 }} loading={applying} disabled={applying || selectedResultCount === 0} onClick={onApplyResult}>
为已选生成图添加水印({selectedResultCount})
</Button>
</>
)}
{(selectedWatermarkedCollectionCount > 0 || selectedWatermarkedResultCount > 0) && (
<>
<Text type="secondary" style={{ display: 'block', fontSize: 11, marginTop: 10 }}>移除水印会恢复无水印原图,并删除当前水印版</Text>
<Button size="small" block style={{ marginTop: 6 }} loading={applying} disabled={applying || selectedWatermarkedCollectionCount === 0} onClick={onRemoveCollection}>
移除已选采集图水印({selectedWatermarkedCollectionCount})
</Button>
<Button size="small" block style={{ marginTop: 6 }} loading={applying} disabled={applying || selectedWatermarkedResultCount === 0} onClick={onRemoveResult}>
移除已选生成图水印({selectedWatermarkedResultCount})
</Button>
</>
)}
</div>
);
}
function WatermarkTaskStatus({ job, onCancel, onRetry, onRetryItem }: { job: SuiteInfo; onCancel: () => void; onRetry: () => void; onRetryItem: (itemId: string) => void }) {
const active = ['queued', 'running'].includes(job.status);
return (
<Alert
showIcon
type={job.status === 'done' ? 'success' : job.status === 'failed' ? 'error' : 'info'}
style={{ marginTop: 10 }}
message={
active
? `水印处理中:${job.completed ?? 0}/${job.total ?? 0}`
: job.status === 'partial'
? `水印部分完成:${job.completed ?? 0} 成功,${job.failed ?? 0} 失败`
: job.status === 'done'
? `水印已完成:${job.completed ?? job.total ?? 0} 张`
: job.status === 'cancelled'
? '水印任务已取消'
: '水印任务失败'
}
description={
<Space direction="vertical" size={4} style={{ width: '100%' }}>
{job.error && <Text type="secondary" style={{ fontSize: 11 }}>{job.error}</Text>}
{['partial', 'failed'].includes(job.status) && <Button size="small" onClick={onRetry}>重试所有失败项</Button>}
{active && <Popconfirm title="取消水印任务" description="尚未处理的图片将停止;已经完成的图片会保留水印版。" okText="取消任务" cancelText="继续处理" okButtonProps={{ danger: true }} onConfirm={onCancel}><Button size="small" danger>取消水印任务</Button></Popconfirm>}
{job.images.filter((item) => item.status === 'failed').map((item) => (
<Space key={item.item_id} size={4} align="start">
<Text type="danger" style={{ fontSize: 11, maxWidth: 185 }} ellipsis={{ tooltip: item.error || item.name }}>{item.name}:{item.error || '处理失败'}</Text>
<Button size="small" onClick={() => onRetryItem(item.item_id)}>重试</Button>
</Space>
))}
</Space>
}
/>
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { ImageProps } from 'antd';
export const GROUP_LABELS: Record<string, string> = {
main: '主图',
sku: 'SKU 图',
detail: '详情图',
generated: '生成图',
upload: '手动上传',
param: '参数图',
video: '视频',
};
export const DISPLAY_GROUPS = ['main', 'sku', 'detail', 'generated', 'upload'];
export const IMAGE_GRID_PAGE_SIZE = 24;
export type ImagePreviewConfig = Pick<
NonNullable<Exclude<ImageProps['preview'], boolean>>,
'actionsRender'
>;
export interface ResultImage {
key: string;
url: string;
name: string;
groupName: string;
variantName: string | null;
assetId?: string;
source: 'suite' | 'history';
createdAt?: string;
}
+42
View File
@@ -266,3 +266,45 @@ export async function uploadProductAsset(
const res = await apiClient.post('/materials/bytes', form); const res = await apiClient.post('/materials/bytes', form);
return res.data as { asset_id: string; status: string }; return res.data as { asset_id: string; status: string };
} }
export interface VideoJobInfo {
id: string;
kind: 'slideshow_video';
status: 'queued' | 'running' | 'done' | 'partial' | 'failed' | 'cancelled';
total: number;
completed: number;
failed: number;
source_asset_ids: string[];
slide_seconds: number;
duration_seconds?: number | null;
width: number;
height: number;
file_size_bytes?: number | null;
created_at?: string | null;
error?: string | null;
result?: { item_id: string; status: string; url?: string | null; error?: string | null } | null;
}
export function createVideoJob(productId: string, assetIds: string[]) {
return api.post<{ job_id: string }>(`/products/${productId}/video-jobs`, { asset_ids: assetIds });
}
export function listVideoJobs(productId: string, config?: AxiosRequestConfig) {
return api.get<VideoJobInfo[]>(`/products/${productId}/video-jobs`, config);
}
export function cancelVideoJob(jobId: string) {
return api.post<{ deleted: boolean }>(`/video-jobs/${jobId}/cancel`);
}
export function retryVideoJob(jobId: string) {
return api.post<VideoJobInfo>(`/video-jobs/${jobId}/retry`);
}
export function deleteVideoJob(jobId: string) {
return api.delete<{ deleted: boolean }>(`/video-jobs/${jobId}`);
}
export function videoDownloadUrl(jobId: string) {
return `/api/video-jobs/${jobId}/download`;
}