Refine seller store implementation
This commit is contained in:
@@ -40,11 +40,26 @@ APP_BASE_URL=http://127.0.0.1:8800
|
||||
# ZIP 超过 2MB 自动使用临时磁盘,不继续占用服务进程内存。
|
||||
# IMAGE_UPLOAD_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
|
||||
# EXPORT_MAX_IMAGES=50
|
||||
# EXPORT_ZIP_MAX_BYTES=209715200
|
||||
# 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 按模型名自动路由)──
|
||||
# 豆包 Seedream(火山方舟)
|
||||
ARK_API_KEY=
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 工作交接说明(HANDOFF)
|
||||
|
||||
> 更新:2026-08-30
|
||||
> 更新:2026-08-31
|
||||
> 读者:下一个接手工作的 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)(后端结构)
|
||||
|
||||
@@ -73,6 +73,10 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
|
||||
| **本轮:生成结果区** | 「导出图片」按钮(水印按钮右侧,复用 POST /api/export/images,ZIP 内分「套图结果/单张生图」文件夹);「单张 AI 生图」小节改名「生图记录」(落库历史,刷新后保留) |
|
||||
| **本轮:图片生命周期** | 采集、上传与 AI 图片统一保存为 `original + current`;只保留当前水印版;重加/移除水印自动回收旧对象;删除单图/硬删商品会清理本地或七牛对象;具体规则见 [`image-asset-lifecycle.md`](./image-asset-lifecycle.md) |
|
||||
| **本轮:图片资源上限** | 单张上传/下载默认 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 可在刷新页面后从浏览器会话恢复 |
|
||||
| **本轮:持久化图片任务** | 套图、单张编辑与批量添加水印已迁入数据库任务表和独立 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` |
|
||||
@@ -90,7 +94,7 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
|
||||
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`
|
||||
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 插件自动构建**
|
||||
`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`、试算页水印入口
|
||||
@@ -132,8 +136,8 @@ pnpm -C extensions/collector build # 产物 .output/chrome-mv3
|
||||
| 01 商品信息(采买地址图标、型号/货号联动) | `pages/trial/TrialInfoPanel.tsx` | `api/materials.py` |
|
||||
| 02 价格试算 | `pages/trial/TrialPricingPanel.tsx` + `pricing/pricing.ts` | — |
|
||||
| 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` |
|
||||
| 图片原图/水印/删除回收 | `TrialSuitePanel.tsx`(水印设置) | `api/materials.py`、`api/products.py`、`services/{image_assets,storage}.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`(状态与调用)+ `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 等) |
|
||||
| 05 登记与导出 | `pages/trial/TrialExportPanel.tsx`(localStorage `trialRegisterRecords`) | — |
|
||||
| 生成结果导出 | `TrialSuitePanel.tsx` handleExportResult(套图+单张分组 ZIP) | `api/export.py` |
|
||||
|
||||
+14
-3
@@ -101,7 +101,14 @@
|
||||
|
||||
`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 |
|
||||
|---|---|---|---|
|
||||
@@ -111,11 +118,15 @@
|
||||
|
||||
图片资源上限:默认单张上传/远程下载 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}`)。
|
||||
- `GET /api/collected?platform=&itemId=`:采集前查重。
|
||||
|
||||
## 10. 冻结接口(不投入,保留代码)
|
||||
## 11. 冻结接口(不投入,保留代码)
|
||||
|
||||
`/api/publish/*`、`/api/shops/*`、`/api/categories/*`——Ozon API 直传链路(D1)。
|
||||
|
||||
@@ -54,22 +54,25 @@
|
||||
- 已实施:页面独立轮询水印任务,刷新可恢复,支持取消、重试全部失败项和按单张重试;等待期间素材被修改时会拒绝旧任务覆盖,单项重试会重新冻结最新版本。
|
||||
- 边界:移除水印目前仍是同步短操作;若未来单次处理规模继续提高,可迁入同一 Worker 框架。
|
||||
|
||||
### P2:统一图片格式、尺寸和元数据校验
|
||||
### P2:统一图片格式、尺寸和元数据校验(已实施)
|
||||
|
||||
- 现状:存储层主要依据 URL 后缀或 Content-Type 推断格式,未统一校验文件大小、像素、实际 MIME 类型和方向信息。
|
||||
- 涉及:`server/services/storage.py`、`server/api/materials.py`。
|
||||
- 建议:上传/下载后用图片库校验真实格式,限制像素与文件大小,按需要压缩、纠正 EXIF 方向并记录宽高。
|
||||
- 已实施:`normalize_image_bytes()` 在上传、采集转存和 AI 生成图写入素材库时,使用 Pillow 解码真实字节,不再信任 URL 后缀或客户端 Content-Type;仅接受 JPEG、PNG、WebP、GIF、BMP。
|
||||
- 已实施:维持原有单文件字节上限,并新增 `IMAGE_MAX_PIXELS`(默认 40,000,000)限制;强制完整解码,拒绝损坏、伪造或像素过大的图片。
|
||||
- 已实施:静态图纠正 EXIF 方向;记录可信的实际宽高到 `product_assets.width`、`height`。动画图只校验并保留原字节,不重编码以避免丢帧。
|
||||
- 已实施:历史本地库的 39 条图片素材已通过 `server/scripts/backfill_asset_dimensions.py` 回填宽高;该脚本不重写图片文件,可用于其他旧库。
|
||||
- 边界:当前不做强制压缩/转码,避免无谓损失采集原图质量;后续若存储成本成为问题,再按真实运营数据引入按格式和尺寸分级压缩策略。
|
||||
|
||||
---
|
||||
|
||||
## 3. 服务端:安全与资源保护
|
||||
|
||||
### P0:限制图片代理和导出 URL,防止 SSRF
|
||||
### P0:限制图片代理和导出 URL,防止 SSRF(已实施)
|
||||
|
||||
- 现状:图片代理、单图下载、ZIP 导出会请求客户端传入的 HTTP(S) URL,并跟随重定向。
|
||||
- 涉及:`server/api/proxy.py`、`server/api/export.py`、`server/services/storage.py`。
|
||||
- 风险:若服务可被非本机访问,可能被用于读取内网地址、云元数据地址,或下载超大文件。
|
||||
- 建议:解析并拒绝回环、内网、链路本地与保留 IP;每次重定向后再次校验;设置域名白名单/允许列表、最大响应体、连接与总耗时上限。
|
||||
- 已实施:`services.storage.download_bytes()` 统一校验代理、采集转存、AI 参考图和远程导出的 URL,仅允许公网 `http(s)` 地址;拒绝 `localhost`、回环、内网、链路本地、多播及其他保留 IP。
|
||||
- 已实施:关闭 HTTP 客户端自动重定向,最多手动跟随 3 跳,并在每一跳重新解析和校验目标地址;限制连接、读写和整次下载总耗时。原有的流式大小上限继续生效。
|
||||
- 兼容:项目自身已经转存的 `/media/...` 素材仍通过受限本地文件读取;采集扩展的 `GET /api/proxy-image?url=https://公网源图` 防盗链回退不受影响。
|
||||
- 涉及:`server/api/proxy.py`、`server/api/export.py`、`server/services/storage.py`、`.env.example`。
|
||||
- 边界:当前策略按 DNS 解析结果判定公网地址;若未来服务面向高风险公网环境,可进一步使用出站网络策略或固定 DNS/代理以降低 DNS 重绑定窗口。
|
||||
|
||||
### P0:限制上传与 ZIP 导出的内存占用(已实施基础版本)
|
||||
|
||||
@@ -89,16 +92,17 @@
|
||||
|
||||
## 4. 前端:可维护性与交互
|
||||
|
||||
### P2:拆分 TrialSuitePanel
|
||||
### P2:拆分 TrialSuitePanel(已实施)
|
||||
|
||||
- 现状:`TrialSuitePanel.tsx` 已约 1,400 行,采集图和生成图卡片、悬浮操作、选择、下载等逻辑有重复。
|
||||
- 建议:拆分为素材网格、结果网格、图片动作层、预览下载、水印设置、套图轮询等子组件/hooks。
|
||||
- 收益:降低后续 UI 修改遗漏风险,便于复用和测试。
|
||||
- 已实施:`TrialSuitePanel.tsx` 从约 1,829 行缩减为约 1,326 行;采集素材网格、生成结果网格、失败结果卡片提取到 `TrialImageGrids.tsx`,水印设置和水印任务状态提取到 `TrialWatermarkSettings.tsx`,图片分组、预览和结果类型统一到 `trialImageTypes.ts`。
|
||||
- 已实施:采集图与生成图统一复用卡片的选择、删除、下载、AI 编辑和预览行为,避免两处网格逻辑继续分叉。
|
||||
- 边界:套图和水印轮询保留在父组件,继续集中管理页面级状态、刷新与取消逻辑;若轮询逻辑再次扩张,再独立为 hook。
|
||||
|
||||
### P2:图片多时采用懒加载/虚拟化
|
||||
### P2:图片多时采用懒加载/虚拟化(已实施基础版本)
|
||||
|
||||
- 现状:图片网格会一次性创建全部 Ant Design Image 与预览节点。
|
||||
- 建议:大量历史生成图时采用分页、虚拟列表或按需加载;缩略图使用合适尺寸和 `loading="lazy"`。
|
||||
- 已实施:采集素材按分组、套图结果和历史生成记录均按每页 24 张分页;仅当前页创建 Ant Design `Image` 及预览节点,避免历史图片一次性进入 DOM。
|
||||
- 已实施:缩略图使用 `loading="lazy"`,滚动接近可视区才请求图片资源。
|
||||
- 边界:当前采用分页而非滚动虚拟列表,预览导航限于当前页;若单个分组进一步增长到数百张以上,可基于真实使用情况再替换为虚拟网格或服务端游标分页。
|
||||
|
||||
### P2:补触屏与键盘操作
|
||||
|
||||
|
||||
+16
-1
@@ -20,7 +20,14 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from api.proxy import guess_referer
|
||||
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"])
|
||||
|
||||
@@ -111,8 +118,12 @@ async def export_images(req: ExportImagesRequest):
|
||||
for img in req.images:
|
||||
try:
|
||||
data, ctype = await _download(img.url)
|
||||
except UnsafeRemoteUrlError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"图片地址不允许导出:{exc}") from exc
|
||||
except ResourceSizeLimitError as 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
|
||||
continue # 单张下载失败不中断整包
|
||||
if not _is_image(img.url, ctype):
|
||||
@@ -161,8 +172,12 @@ async def export_image(img: ExportImageItem):
|
||||
"""代理下载单张图片,兼容源站防盗链与本地媒体路径。"""
|
||||
try:
|
||||
data, ctype = await _download(img.url)
|
||||
except UnsafeRemoteUrlError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"图片地址不允许下载:{exc}") from exc
|
||||
except ResourceSizeLimitError as 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
|
||||
raise HTTPException(status_code=422, detail="图片下载失败") from exc
|
||||
if not _is_image(img.url, ctype):
|
||||
|
||||
+22
-2
@@ -16,6 +16,7 @@ from models.enums import AssetStatus, Stage
|
||||
from schemas.collection import MaterialsRequest, MaterialsResponse, TextMaterial
|
||||
from schemas.suite import SuiteOut, WatermarkOptions
|
||||
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 config import get_settings
|
||||
|
||||
@@ -258,6 +259,15 @@ async def upload_material_bytes(
|
||||
raise HTTPException(status_code=413, detail=f"图片文件过大,单张最多 {limit // 1024 // 1024} MB")
|
||||
chunks.append(chunk)
|
||||
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(
|
||||
product_id=product.id,
|
||||
group_key=group_key,
|
||||
@@ -272,13 +282,23 @@ async def upload_material_bytes(
|
||||
# 直接转存为素材唯一原图(current 与 original 初始指向同一对象)
|
||||
image_assets = get_image_asset_service()
|
||||
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
|
||||
except Exception as exc: # noqa: BLE001
|
||||
asset.status = AssetStatus.failed
|
||||
asset.error = str(exc)[:500]
|
||||
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")
|
||||
|
||||
+10
-1
@@ -8,7 +8,12 @@ from urllib.parse import urlparse
|
||||
|
||||
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"])
|
||||
|
||||
@@ -40,8 +45,12 @@ async def proxy_image(url: str = Query(..., description="源站图片 URL")):
|
||||
raise HTTPException(status_code=400, detail="仅支持 http/https URL")
|
||||
try:
|
||||
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:
|
||||
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
|
||||
raise HTTPException(status_code=502, detail=f"图片拉取失败: {exc}") from exc
|
||||
if not ctype.startswith("image/"):
|
||||
|
||||
@@ -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)
|
||||
@@ -53,12 +53,35 @@ class Settings(BaseSettings):
|
||||
# 单张图片在后端需要解码/加水印,限制单文件可避免一次请求耗尽内存。
|
||||
image_upload_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
|
||||
export_max_images: int = 50
|
||||
export_zip_max_bytes: int = 200 * 1024 * 1024
|
||||
# ZIP 超过该值自动落临时磁盘,不继续占用进程内存。
|
||||
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 一致)──
|
||||
image_provider: str = "doubao" # 未知模型时的兜底 provider:doubao | tongyi | rightapi
|
||||
request_timeout: int = 300 # 单张生图请求超时(秒)
|
||||
|
||||
+2
-1
@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
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 db import get_engine
|
||||
|
||||
@@ -36,6 +36,7 @@ app.include_router(image.router)
|
||||
app.include_router(suite.router)
|
||||
app.include_router(export.router)
|
||||
app.include_router(proxy.router)
|
||||
app.include_router(video.router)
|
||||
|
||||
# ⚠️ 冻结链路(Ozon API 直传):代码移至 legacy/,接口保留但不再投入
|
||||
from legacy.api import categories, ozon, publish, shops # noqa: E402
|
||||
|
||||
@@ -19,7 +19,14 @@ depends_on = None
|
||||
def upgrade() -> None:
|
||||
# 开发态 FastAPI 会在 startup 以 Base.metadata.create_all 兜底;若它先于 Alembic
|
||||
# 启动,表已完整存在,此处只补迁移版本记录,避免 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
|
||||
op.create_table(
|
||||
"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_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_updated_at", "background_job_items", ["updated_at"])
|
||||
|
||||
|
||||
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_jobs")
|
||||
|
||||
@@ -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()
|
||||
@@ -11,8 +11,17 @@ import uuid
|
||||
from dataclasses import dataclass
|
||||
|
||||
from api.proxy import guess_referer
|
||||
from config import get_settings
|
||||
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
|
||||
|
||||
|
||||
@@ -46,8 +55,20 @@ class ImageAssetService:
|
||||
return read_local_bytes(path), mimetypes.guess_type(path.name)[0] or "image/jpeg"
|
||||
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 同步到原图。"""
|
||||
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)
|
||||
url = await self.storage.save_bytes(data, key, content_type)
|
||||
asset.original_url = url
|
||||
@@ -60,7 +81,12 @@ class ImageAssetService:
|
||||
|
||||
async def save_original_from_url(self, asset: ProductAsset, url: str) -> str:
|
||||
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]:
|
||||
"""为旧记录补齐原图字段;首次触碰旧素材时迁入新的商品/素材路径。"""
|
||||
|
||||
@@ -26,6 +26,7 @@ from services.storage import get_storage
|
||||
from services.suite_service import append_generated_asset
|
||||
from services.tasks import TaskImage
|
||||
from services.watermark import apply_watermark
|
||||
from services.video_renderer import VideoRenderCancelled, render_slideshow
|
||||
|
||||
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))
|
||||
|
||||
|
||||
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:
|
||||
"""推进一次子任务。外部模型未完成时只写 provider id 并退出,让出 Worker。"""
|
||||
if claim.job_kind == "watermark_batch":
|
||||
await _process_watermark_item(claim)
|
||||
return
|
||||
if claim.job_kind == "slideshow_video":
|
||||
await _process_slideshow_video_item(claim)
|
||||
return
|
||||
if claim.job_kind not in {"image_suite", "image_edit"}:
|
||||
await mark_item_failed(claim.item_id, f"Worker 暂不支持任务类型: {claim.job_kind}")
|
||||
return
|
||||
@@ -201,7 +243,7 @@ async def run_worker(*, once: bool = False, idle_seconds: float = 0.75) -> None:
|
||||
log.warning("恢复了 %d 个过期后台任务租约", recovered)
|
||||
log.info("后台 Worker 已启动:%s", owner)
|
||||
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 once:
|
||||
return
|
||||
|
||||
@@ -310,6 +310,21 @@ async def cancel_job(job_id: str) -> BackgroundJob | None:
|
||||
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:
|
||||
"""硬删除商品前取消其未完成任务,Worker 随后只会丢弃结果而不会回写素材。"""
|
||||
try:
|
||||
@@ -468,3 +483,25 @@ def suite_view(job: BackgroundJob, items: list[BackgroundJobItem]) -> dict[str,
|
||||
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,
|
||||
}
|
||||
|
||||
+201
-5
@@ -1,12 +1,20 @@
|
||||
"""图片/文件存储抽象:本地文件系统(开发兜底)+ 七牛(生产)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import ipaddress
|
||||
import mimetypes
|
||||
import socket
|
||||
import shutil
|
||||
import uuid
|
||||
import warnings
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
|
||||
from config import get_settings
|
||||
|
||||
@@ -26,6 +34,157 @@ class ResourceSizeLimitError(ValueError):
|
||||
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:
|
||||
ctype = (content_type or "").split(";")[0].strip().lower()
|
||||
mapping = {
|
||||
@@ -48,22 +207,55 @@ def image_extension(data: bytes, content_type: str = "") -> str:
|
||||
return ".webp"
|
||||
if data[:3] == b"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)
|
||||
|
||||
|
||||
async def download_bytes(
|
||||
url: str,
|
||||
referer: str | None = None,
|
||||
timeout: float = 60.0,
|
||||
timeout: float | None = None,
|
||||
max_bytes: int | None = None,
|
||||
) -> 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:
|
||||
raise ValueError("图片下载大小上限必须大于 0")
|
||||
total_timeout = timeout if timeout is not None else settings.image_download_total_timeout
|
||||
if total_timeout <= 0:
|
||||
raise ValueError("图片下载总超时必须大于 0")
|
||||
if settings.image_download_connect_timeout <= 0 or settings.image_download_read_timeout <= 0:
|
||||
raise ValueError("图片下载连接和读取超时必须大于 0")
|
||||
if settings.image_download_max_redirects < 0:
|
||||
raise ValueError("图片下载重定向上限不能小于 0")
|
||||
|
||||
headers = {"Referer": referer} if referer else {}
|
||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||
async with client.stream("GET", url, headers=headers) as resp:
|
||||
http_timeout = httpx.Timeout(
|
||||
connect=settings.image_download_connect_timeout,
|
||||
read=settings.image_download_read_timeout,
|
||||
write=settings.image_download_read_timeout,
|
||||
pool=settings.image_download_connect_timeout,
|
||||
)
|
||||
current_url = url
|
||||
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:
|
||||
@@ -83,6 +275,10 @@ async def download_bytes(
|
||||
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:
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -26,9 +26,20 @@ const SECTIONS = [
|
||||
{ id: 'section-collect', label: '采集图片' },
|
||||
{ id: 'section-plan', label: '出图方案' },
|
||||
{ id: 'section-result', label: '生成结果' },
|
||||
{ id: 'section-video', 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() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -62,7 +73,7 @@ export default function TrialPage() {
|
||||
let current = SECTIONS[0].id;
|
||||
for (const s of SECTIONS) {
|
||||
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);
|
||||
};
|
||||
@@ -170,7 +181,7 @@ export default function TrialPage() {
|
||||
{/* 4/5/6 三张卡片由 TrialSuitePanel 渲染,锚点在面板内部 */}
|
||||
<TrialSuitePanel product={product} assets={assets} onRefreshAssets={load} />
|
||||
<div id="section-export">
|
||||
<Card title="7 入库与导出">
|
||||
<Card title="8 入库与导出">
|
||||
<TrialExportPanel product={product} />
|
||||
</Card>
|
||||
</div>
|
||||
@@ -188,7 +199,7 @@ export default function TrialPage() {
|
||||
style={{ borderInlineEnd: 0, background: 'transparent' }}
|
||||
items={SECTIONS.map((s) => ({ key: s.id, label: s.label }))}
|
||||
onClick={({ key }) => {
|
||||
document.getElementById(key)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
scrollToSection(key);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react';
|
||||
import {
|
||||
Alert, App as AntdApp, Button, Card, Checkbox, Col, Empty, Image, Input, InputNumber, Popconfirm, Popover, Progress,
|
||||
Radio, Row, Segmented, Select, Space, Tag, Typography, Upload, type ImageProps,
|
||||
Alert, App as AntdApp, Button, Card, Checkbox, Col, Empty, Input, Popconfirm, Popover, Progress,
|
||||
Row, Segmented, Select, Space, Tag, Typography, Upload,
|
||||
} 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 { apiErrorMessage } from '@/services/api';
|
||||
import {
|
||||
@@ -19,21 +19,14 @@ import {
|
||||
} from '@/services/suite';
|
||||
import { cleanFilename, downloadBlob } from '@/utils/file';
|
||||
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 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';
|
||||
type ImagePreviewConfig = Pick<NonNullable<Exclude<ImageProps['preview'], boolean>>, 'actionsRender'>;
|
||||
|
||||
/** 方案数量步进器:左减号 / 中间可输入 / 右加号(0-5) */
|
||||
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 [hiddenResultKeys, setHiddenResultKeys] = useState<Set<string>>(() => new Set());
|
||||
const [hoveredImageKey, setHoveredImageKey] = useState<string | null>(null);
|
||||
const [downloadingResultKeys, setDownloadingResultKeys] = useState<Set<string>>(() => new Set());
|
||||
const [applyingWatermark, setApplyingWatermark] = useState(false);
|
||||
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 productTitle = ((rawObj.title_zh as string) || (rawObj.title as string) || product.name || '').trim();
|
||||
const resultImages = useMemo(() => {
|
||||
const resultImages = useMemo<ResultImage[]>(() => {
|
||||
const suiteImages = (suite?.images ?? [])
|
||||
.filter((img) => img.status === 'done')
|
||||
.map((img, index) => {
|
||||
@@ -899,155 +891,27 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
};
|
||||
|
||||
const watermarkPopup = (
|
||||
<div style={{ width: 280 }}>
|
||||
<Checkbox
|
||||
checked={watermark.enabled}
|
||||
onChange={(e) => patchWatermark({ enabled: e.target.checked })}
|
||||
>
|
||||
新生成图片自动加水印
|
||||
</Checkbox>
|
||||
{watermarkJob && (
|
||||
<Alert
|
||||
showIcon
|
||||
type={watermarkJob.status === 'done' ? 'success' : watermarkJob.status === 'failed' ? 'error' : 'info'}
|
||||
style={{ marginTop: 10 }}
|
||||
message={
|
||||
['queued', 'running'].includes(watermarkJob.status)
|
||||
? `水印处理中:${watermarkJob.completed ?? 0}/${watermarkJob.total ?? 0}`
|
||||
: watermarkJob.status === 'partial'
|
||||
? `水印部分完成:${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 && (
|
||||
<>
|
||||
<div style={{ margin: '10px 0 6px' }}>
|
||||
<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(
|
||||
<TrialWatermarkSettings
|
||||
watermark={watermark}
|
||||
watermarkJob={watermarkJob}
|
||||
applying={applyingWatermark}
|
||||
selectedCollectionCount={selectedCollectionWatermarkAssetIds.length}
|
||||
selectedResultCount={selectedWatermarkAssetIds.length}
|
||||
selectedWatermarkedCollectionCount={selectedWatermarkedCollectionIds.length}
|
||||
selectedWatermarkedResultCount={selectedWatermarkedResultIds.length}
|
||||
onPatch={patchWatermark}
|
||||
onApplyCollection={() => handleApplyWatermark(selectedCollectionWatermarkAssetIds, '采集图片')}
|
||||
onApplyResult={() => handleApplyWatermark(selectedWatermarkAssetIds, '生成结果图片', selectedWatermarkItems)}
|
||||
onRemoveCollection={() => handleRemoveWatermark(selectedWatermarkedCollectionIds, '采集图片')}
|
||||
onRemoveResult={() => handleRemoveWatermark(
|
||||
selectedWatermarkedResultIds,
|
||||
'生成结果图片',
|
||||
selectedWatermarkItems.filter((item) => assetById.get(item.assetId!)?.watermark_enabled),
|
||||
)}
|
||||
>
|
||||
移除已选生成图水印({selectedWatermarkedResultIds.length})
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
onCancelJob={() => void handleCancelWatermarkJob()}
|
||||
onRetryJob={() => void handleRetryWatermarkJob()}
|
||||
onRetryItem={(itemId) => void handleRetryWatermarkItem(itemId)}
|
||||
/>
|
||||
);
|
||||
|
||||
const selectableCount = imgs.length;
|
||||
@@ -1120,152 +984,17 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
{displayGroups.length === 0 ? (
|
||||
<Empty description="暂无素材,可点击右上角「上传图片」补充参考底图" />
|
||||
) : (
|
||||
<Image.PreviewGroup preview={imagePreviewConfig}>
|
||||
{displayGroups.map((g) => (
|
||||
<div key={g} style={{ marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<Text strong>
|
||||
{GROUP_LABELS[g] ?? g}
|
||||
<Text type="secondary" style={{ fontWeight: 400, marginLeft: 6 }}>
|
||||
{groupImages(g).length}
|
||||
</Text>
|
||||
</Text>
|
||||
<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>"
|
||||
<CollectionImageGroups
|
||||
groups={displayGroups.map((group) => ({ key: group, assets: groupImages(group) }))}
|
||||
selectedKeys={selectedKeys}
|
||||
preview={imagePreviewConfig}
|
||||
imageUrl={assetUrl}
|
||||
imageName={assetName}
|
||||
onToggle={toggleKey}
|
||||
onToggleGroup={toggleGroup}
|
||||
onDelete={(assetId) => void handleDeleteAsset(assetId)}
|
||||
onEdit={(asset) => setGenModal({ url: assetOriginalUrl(asset), name: asset.variant_name || asset.id })}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
@@ -1538,137 +1267,17 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
<Image.PreviewGroup preview={imagePreviewConfig}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: 10, width: '100%' }}>
|
||||
{suiteResultImages.map((img) => {
|
||||
const selected = selectedResultKeys.has(img.key);
|
||||
return (
|
||||
<div key={img.key} style={{ minWidth: 0 }} title={img.name}>
|
||||
<div
|
||||
onMouseEnter={() => setHoveredImageKey(img.key)}
|
||||
onMouseLeave={() => setHoveredImageKey(null)}
|
||||
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
|
||||
src={img.url}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ objectFit: 'cover', borderRadius: 6, display: 'block' }}
|
||||
<GeneratedImageGrid
|
||||
images={suiteResultImages}
|
||||
selectedKeys={selectedResultKeys}
|
||||
downloadingKeys={downloadingResultKeys}
|
||||
preview={imagePreviewConfig}
|
||||
onToggle={toggleResultKey}
|
||||
onDelete={(image) => image.assetId && void handleDeleteAsset(image.assetId, [image.key, `asset-${image.assetId}`])}
|
||||
onDownload={(image) => void handleDownloadResultImage(image)}
|
||||
onEdit={(image) => setGenModal({ url: assetById.get(image.assetId || '')?.original_url || image.url, name: image.name, afterAssetId: image.assetId })}
|
||||
/>
|
||||
{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>
|
||||
<FailedSuiteImages images={suite.images.filter((image) => image.status === 'failed')} />
|
||||
{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} 张)
|
||||
</Text>
|
||||
</Text>
|
||||
<Image.PreviewGroup preview={imagePreviewConfig}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: 10, marginTop: 10, width: '100%' }}>
|
||||
{historyResultImages.map((img) => {
|
||||
const selected = selectedResultKeys.has(img.key);
|
||||
return (
|
||||
<div key={img.key} style={{ minWidth: 0 }}>
|
||||
<div
|
||||
onMouseEnter={() => setHoveredImageKey(img.key)}
|
||||
onMouseLeave={() => setHoveredImageKey(null)}
|
||||
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
|
||||
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>"
|
||||
<div style={{ marginTop: 10 }}>
|
||||
<GeneratedImageGrid
|
||||
images={historyResultImages}
|
||||
selectedKeys={selectedResultKeys}
|
||||
downloadingKeys={downloadingResultKeys}
|
||||
preview={imagePreviewConfig}
|
||||
onToggle={toggleResultKey}
|
||||
onDelete={(image) => image.assetId && void handleDeleteAsset(image.assetId, [image.key])}
|
||||
onDownload={(image) => void handleDownloadResultImage(image)}
|
||||
onEdit={(image) => setGenModal({ url: assetById.get(image.assetId || '')?.original_url || image.url, name: image.name, afterAssetId: image.assetId })}
|
||||
/>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
@@ -1811,6 +1309,13 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* ── 7 视频制作 ── */}
|
||||
<div id="section-video">
|
||||
<Card title="7 视频制作">
|
||||
<TrialVideoMaker productId={product.id} assets={assets} />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 单张 AI 生图弹窗 */}
|
||||
<AiImageGenModal
|
||||
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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -266,3 +266,45 @@ export async function uploadProductAsset(
|
||||
const res = await apiClient.post('/materials/bytes', form);
|
||||
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`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user