From 2a667b4f3b4e6b5b9a55d2847a631fc3fa4519c7 Mon Sep 17 00:00:00 2001 From: R524809 Date: Mon, 31 Aug 2026 14:37:28 +0800 Subject: [PATCH] Refine seller store implementation --- .env.example | 15 + docs/v2.1/HANDOFF.md | 12 +- docs/v2.1/api.md | 17 +- docs/v2.1/optimization-audit.md | 36 +- server/api/export.py | 17 +- server/api/materials.py | 24 +- server/api/proxy.py | 11 +- server/api/video.py | 140 ++++ server/config/settings.py | 23 + server/main.py | 3 +- .../b5d2c9a3e1f7_add_background_jobs.py | 11 +- server/schemas/video.py | 56 ++ server/scripts/backfill_asset_dimensions.py | 69 ++ server/services/image_assets.py | 32 +- server/services/job_worker.py | 44 +- server/services/jobs.py | 37 + server/services/storage.py | 244 ++++++- server/services/video_renderer.py | 187 ++++++ server/tests/test_storage_security.py | 105 +++ server/tests/test_video_renderer.py | 26 + studio/src/pages/trial/TrialImageGrids.tsx | 229 +++++++ studio/src/pages/trial/TrialPage.tsx | 17 +- studio/src/pages/trial/TrialSuitePanel.tsx | 633 ++---------------- studio/src/pages/trial/TrialVideoMaker.tsx | 91 +++ .../pages/trial/TrialWatermarkSettings.tsx | 123 ++++ studio/src/pages/trial/trialImageTypes.ts | 30 + studio/src/services/suite.ts | 42 ++ 27 files changed, 1650 insertions(+), 624 deletions(-) create mode 100644 server/api/video.py create mode 100644 server/schemas/video.py create mode 100644 server/scripts/backfill_asset_dimensions.py create mode 100644 server/services/video_renderer.py create mode 100644 server/tests/test_storage_security.py create mode 100644 server/tests/test_video_renderer.py create mode 100644 studio/src/pages/trial/TrialImageGrids.tsx create mode 100644 studio/src/pages/trial/TrialVideoMaker.tsx create mode 100644 studio/src/pages/trial/TrialWatermarkSettings.tsx create mode 100644 studio/src/pages/trial/trialImageTypes.ts diff --git a/.env.example b/.env.example index c4c268d..efbdc3b 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/docs/v2.1/HANDOFF.md b/docs/v2.1/HANDOFF.md index d46f7c2..8302da0 100644 --- a/docs/v2.1/HANDOFF.md +++ b/docs/v2.1/HANDOFF.md @@ -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` | diff --git a/docs/v2.1/api.md b/docs/v2.1/api.md index 33ede7c..3e5ce25 100644 --- a/docs/v2.1/api.md +++ b/docs/v2.1/api.md @@ -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)。 diff --git a/docs/v2.1/optimization-audit.md b/docs/v2.1/optimization-audit.md index 19e16ba..7f2b765 100644 --- a/docs/v2.1/optimization-audit.md +++ b/docs/v2.1/optimization-audit.md @@ -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:补触屏与键盘操作 diff --git a/server/api/export.py b/server/api/export.py index f429d72..93741a2 100644 --- a/server/api/export.py +++ b/server/api/export.py @@ -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): diff --git a/server/api/materials.py b/server/api/materials.py index cf27f2d..133819e 100644 --- a/server/api/materials.py +++ b/server/api/materials.py @@ -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") diff --git a/server/api/proxy.py b/server/api/proxy.py index 258ff66..72a0acc 100644 --- a/server/api/proxy.py +++ b/server/api/proxy.py @@ -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/"): diff --git a/server/api/video.py b/server/api/video.py new file mode 100644 index 0000000..4f2661d --- /dev/null +++ b/server/api/video.py @@ -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) diff --git a/server/config/settings.py b/server/config/settings.py index 8d5b822..5fe82be 100644 --- a/server/config/settings.py +++ b/server/config/settings.py @@ -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 # 单张生图请求超时(秒) diff --git a/server/main.py b/server/main.py index 616cb64..3c34f5f 100644 --- a/server/main.py +++ b/server/main.py @@ -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 diff --git a/server/migrations/versions/b5d2c9a3e1f7_add_background_jobs.py b/server/migrations/versions/b5d2c9a3e1f7_add_background_jobs.py index a1fed28..ae14a14 100644 --- a/server/migrations/versions/b5d2c9a3e1f7_add_background_jobs.py +++ b/server/migrations/versions/b5d2c9a3e1f7_add_background_jobs.py @@ -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") diff --git a/server/schemas/video.py b/server/schemas/video.py new file mode 100644 index 0000000..15067ef --- /dev/null +++ b/server/schemas/video.py @@ -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 diff --git a/server/scripts/backfill_asset_dimensions.py b/server/scripts/backfill_asset_dimensions.py new file mode 100644 index 0000000..0b09051 --- /dev/null +++ b/server/scripts/backfill_asset_dimensions.py @@ -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() diff --git a/server/services/image_assets.py b/server/services/image_assets.py index ff7d7c2..aa57f72 100644 --- a/server/services/image_assets.py +++ b/server/services/image_assets.py @@ -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]: """为旧记录补齐原图字段;首次触碰旧素材时迁入新的商品/素材路径。""" diff --git a/server/services/job_worker.py b/server/services/job_worker.py index c00acbb..4579a18 100644 --- a/server/services/job_worker.py +++ b/server/services/job_worker.py @@ -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 diff --git a/server/services/jobs.py b/server/services/jobs.py index dbef344..c552e7a 100644 --- a/server/services/jobs.py +++ b/server/services/jobs.py @@ -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, + } diff --git a/server/services/storage.py b/server/services/storage.py index 50f153d..0ffff6e 100644 --- a/server/services/storage.py +++ b/server/services/storage.py @@ -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,41 +207,78 @@ 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") - 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: - resp.raise_for_status() - length = resp.headers.get("content-length") - if length: - try: - declared_size = int(length) - except ValueError: - declared_size = None - if declared_size is not None and declared_size > limit: - raise ResourceSizeLimitError(limit, declared_size) + 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") - chunks: list[bytes] = [] - total = 0 - async for chunk in resp.aiter_bytes(): - total += len(chunk) - if total > limit: - raise ResourceSizeLimitError(limit, total) - chunks.append(chunk) - ctype = (resp.headers.get("content-type") or "application/octet-stream").split(";")[0].strip() - return b"".join(chunks), ctype + headers = {"Referer": referer} if referer else {} + 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: + try: + declared_size = int(length) + except ValueError: + declared_size = None + if declared_size is not None and declared_size > limit: + raise ResourceSizeLimitError(limit, declared_size) + + chunks: list[bytes] = [] + total = 0 + async for chunk in resp.aiter_bytes(): + total += len(chunk) + if total > limit: + raise ResourceSizeLimitError(limit, total) + chunks.append(chunk) + ctype = (resp.headers.get("content-type") or "application/octet-stream").split(";")[0].strip() + return b"".join(chunks), ctype + except (TimeoutError, httpx.TimeoutException) as exc: + raise ResourceDownloadTimeoutError(f"图片下载超时(最多 {total_timeout:g} 秒)") from exc + + raise RuntimeError("图片下载未返回有效响应") def read_local_bytes(path: Path, max_bytes: int | None = None) -> bytes: diff --git a/server/services/video_renderer.py b/server/services/video_renderer.py new file mode 100644 index 0000000..2cee34b --- /dev/null +++ b/server/services/video_renderer.py @@ -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) diff --git a/server/tests/test_storage_security.py b/server/tests/test_storage_security.py new file mode 100644 index 0000000..b862f70 --- /dev/null +++ b/server/tests/test_storage_security.py @@ -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() diff --git a/server/tests/test_video_renderer.py b/server/tests/test_video_renderer.py new file mode 100644 index 0000000..3cfabd9 --- /dev/null +++ b/server/tests/test_video_renderer.py @@ -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() diff --git a/studio/src/pages/trial/TrialImageGrids.tsx b/studio/src/pages/trial/TrialImageGrids.tsx new file mode 100644 index 0000000..b179086 --- /dev/null +++ b/studio/src/pages/trial/TrialImageGrids.tsx @@ -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,无预览"; + +function useVisiblePage(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 ( +
+ `共 ${count} 张`} + onChange={onChange} + /> +
+ ); +} + +interface CollectionImageGroupsProps { + groups: Array<{ key: string; assets: ProductAsset[] }>; + selectedKeys: Set; + 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>({}); + + return ( + + {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 ( +
+
+ + {GROUP_LABELS[group] ?? group} + {assets.length} + + onToggleGroup(group, !allSelected)}> + {allSelected ? '取消全选' : '全选'} + + {group === 'main' && 勾选的图片作为生图参考底图} +
+
+ {visible.map((asset) => ( + onToggle(asset.id)} + onDelete={() => onDelete(asset.id)} + onEdit={() => onEdit(asset)} + /> + ))} +
+ setPageByGroup((prev) => ({ ...prev, [group]: page }))} + /> +
+ ); + })} +
+ ); +} + +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 ( +
+
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 }} + > + + + +
+
{asset.variant_name || imageName}
+
+ ); +} + +interface GeneratedImageGridProps { + images: ResultImage[]; + selectedKeys: Set; + downloadingKeys: Set; + 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 ( + <> + +
+ {visible.map((image) => ( + onToggle(image.key)} + onDelete={() => onDelete(image)} + onDownload={() => onDownload(image)} + onEdit={() => onEdit(image)} + /> + ))} +
+
+ + + ); +} + +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 ( +
+
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.assetId && +
+
{image.name}
+
+ ); +} + +export function FailedSuiteImages({ images }: { images: SuiteImageInfo[] }) { + if (images.length === 0) return null; + return ( +
+ {images.map((image) => ( +
+
✗
+
{image.error || image.name}
+
+ ))} +
+ ); +} diff --git a/studio/src/pages/trial/TrialPage.tsx b/studio/src/pages/trial/TrialPage.tsx index a3407ef..e359729 100644 --- a/studio/src/pages/trial/TrialPage.tsx +++ b/studio/src/pages/trial/TrialPage.tsx @@ -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 渲染,锚点在面板内部 */}
- +
@@ -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); }} /> diff --git a/studio/src/pages/trial/TrialSuitePanel.tsx b/studio/src/pages/trial/TrialSuitePanel.tsx index b429f11..0758058 100644 --- a/studio/src/pages/trial/TrialSuitePanel.tsx +++ b/studio/src/pages/trial/TrialSuitePanel.tsx @@ -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 = { - main: '主图', - sku: 'SKU 图', - detail: '详情图', - generated: '生成图', - upload: '手动上传', - param: '参数图', - video: '视频', -}; -const DISPLAY_GROUPS = ['main', 'sku', 'detail', 'generated', 'upload']; const WATERMARK_STORAGE_KEY = 'trialWatermark'; -type ImagePreviewConfig = Pick>, '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>(() => new Set()); /** 删除后在本次任务的内存结果中同步隐藏,避免刷新前仍可导出已删图片。 */ const [hiddenResultKeys, setHiddenResultKeys] = useState>(() => new Set()); - const [hoveredImageKey, setHoveredImageKey] = useState(null); const [downloadingResultKeys, setDownloadingResultKeys] = useState>(() => new Set()); const [applyingWatermark, setApplyingWatermark] = useState(false); const [watermarkJob, setWatermarkJob] = useState(null); @@ -212,7 +204,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr }; const rawObj = (product.raw ?? {}) as Record; const productTitle = ((rawObj.title_zh as string) || (rawObj.title as string) || product.name || '').trim(); - const resultImages = useMemo(() => { + const resultImages = useMemo(() => { 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 = ( -
- patchWatermark({ enabled: e.target.checked })} - > - 新生成图片自动加水印 - - {watermarkJob && ( - - {watermarkJob.error && {watermarkJob.error}} - {['partial', 'failed'].includes(watermarkJob.status) && ( - - )} - {['queued', 'running'].includes(watermarkJob.status) && ( - void handleCancelWatermarkJob()} - > - - - )} - {watermarkJob.images.filter((item) => item.status === 'failed').map((item) => ( - - - {item.name}:{item.error || '处理失败'} - - - - ))} - - } - /> + handleApplyWatermark(selectedCollectionWatermarkAssetIds, '采集图片')} + onApplyResult={() => handleApplyWatermark(selectedWatermarkAssetIds, '生成结果图片', selectedWatermarkItems)} + onRemoveCollection={() => handleRemoveWatermark(selectedWatermarkedCollectionIds, '采集图片')} + onRemoveResult={() => handleRemoveWatermark( + selectedWatermarkedResultIds, + '生成结果图片', + selectedWatermarkItems.filter((item) => assetById.get(item.assetId!)?.watermark_enabled), )} - {watermark.enabled && ( - <> -
- patchWatermark({ type: e.target.value })} - options={[ - { value: 'image', label: '图片徽章' }, - { value: 'text', label: '文字' }, - ]} - /> -
- {watermark.type === 'text' && ( - patchWatermark({ text: e.target.value })} - /> - )} -
- - 不透明度(%) - - patchWatermark({ opacity: v ?? 30 })} - /> -
- - 位置固定右下角;一键与单张 AI 生图会直接按此设置合成水印,也可应用到已选采集图或生成图 - - - - - )} - {(selectedWatermarkedCollectionIds.length > 0 || selectedWatermarkedResultIds.length > 0) && ( - <> - - 移除水印会恢复无水印原图,并删除当前水印版 - - - - - )} -
+ 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 ? ( ) : ( - - {displayGroups.map((g) => ( -
-
- - {GROUP_LABELS[g] ?? g} - - {groupImages(g).length} - - - { - const all = groupImages(g).every((a) => selectedKeys.has(a.id)); - toggleGroup(g, !all); - }} - > - {groupImages(g).every((a) => selectedKeys.has(a.id)) ? '取消全选' : '全选'} - - {g === 'main' && ( - - 勾选的图片作为生图参考底图 - - )} -
-
- {groupImages(g).map((a) => { - const on = selectedKeys.has(a.id); - return ( -
-
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, - }} - > - - handleDeleteAsset(a.id)} - > - -
-
- {a.variant_name || assetName(a)} -
-
- ); - })} -
-
- ))} -
+ ({ 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 })} + /> )} @@ -1538,137 +1267,17 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr ) : null} - -
- {suiteResultImages.map((img) => { - const selected = selectedResultKeys.has(img.key); - return ( -
-
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', - }} - > - - {img.assetId && ( - handleDeleteAsset(img.assetId!, [img.key, `asset-${img.assetId}`])} - > - -
-
- {img.name} -
-
- ); - })} - {suite.images.filter((img) => img.status === 'failed').map((img) => ( -
-
- ✗ -
-
- {img.error || img.name} -
-
- ))} -
-
+ 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 })} + /> + image.status === 'failed')} /> {suite.error && } )} @@ -1681,129 +1290,18 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr (一键生成与单张生图的落库历史,刷新后仍保留 · {historyResultImages.length} 张) - -
- {historyResultImages.map((img) => { - const selected = selectedResultKeys.has(img.key); - return ( -
-
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', - }} - > - - handleDeleteAsset(img.assetId!, [img.key])} - > - -
-
- {img.createdAt && ( - - {new Date(img.createdAt).toLocaleTimeString()} - - )} -
-
- ); - })} -
-
+
+ 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 })} + /> +
)} @@ -1811,6 +1309,13 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr + {/* ── 7 视频制作 ── */} +
+ + + +
+ {/* 单张 AI 生图弹窗 */} 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 +
+ {done ?
+
+ {statusText(job.status)} + {job.source_asset_ids.length} 图 · {job.duration_seconds ? `${job.duration_seconds.toFixed(1)} 秒` : `每图 ${job.slide_seconds} 秒`} + {done && } + {failed && } +
+ {job.error && {job.error}} +
; +} + +export default function TrialVideoMaker({ productId, assets }: { productId: string; assets: ProductAsset[] }) { + const { message } = App.useApp(); + const [open, setOpen] = useState(false); + const [selected, setSelected] = useState([]); + const [submitting, setSubmitting] = useState(false); + const [removingId, setRemovingId] = useState(null); + const [jobs, setJobs] = useState([]); + 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
+
+
视频制作选择 2–5 张图片,合成 810×1080 无音轨轮播 MP4
+ +
+ {jobs.filter((job) => job.status !== 'cancelled').length === 0 ? 生成的轮播视频会保存在这里,可随时下载。 :
{jobs.filter((job) => job.status !== 'cancelled').slice(0, 8).map((job) => void cancel(id)} onDelete={(id) => void remove(id)} onRetry={(id) => void retry(id)} />)}
} + setOpen(false)} onOk={() => void submit()} okText={`合成视频(${selected.length}/5)`} okButtonProps={{ disabled: selected.length < 2, loading: submitting }} width={900} title="选择轮播图片"> + + {candidates.length < 2 ? :
{candidates.map((asset) => { const index = selected.indexOf(asset.id); return
toggle(asset.id)} style={{ cursor: 'pointer', border: `2px solid ${index >= 0 ? '#1677ff' : 'transparent'}`, borderRadius: 7, padding: 2, position: 'relative' }}>{index >= 0 && {index + 1}}
{asset.group_key === 'generated' ? 'AI 图 · ' : ''}{nameOf(asset)}
; })}
} + {selected.length > 0 && 播放顺序即选中序号;取消后重新选择可调整顺序。} +
+
; +} diff --git a/studio/src/pages/trial/TrialWatermarkSettings.tsx b/studio/src/pages/trial/TrialWatermarkSettings.tsx new file mode 100644 index 0000000..7283b17 --- /dev/null +++ b/studio/src/pages/trial/TrialWatermarkSettings.tsx @@ -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) => 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 ( +
+ onPatch({ enabled: event.target.checked })}> + 新生成图片自动加水印 + + {watermarkJob && } + {watermark.enabled && ( + <> +
+ onPatch({ type: event.target.value })} + options={[{ value: 'image', label: '图片徽章' }, { value: 'text', label: '文字' }]} + /> +
+ {watermark.type === 'text' && onPatch({ text: event.target.value })} />} +
+ 不透明度(%) + onPatch({ opacity: value ?? 30 })} /> +
+ + 位置固定右下角;一键与单张 AI 生图会直接按此设置合成水印,也可应用到已选采集图或生成图 + + + + + )} + {(selectedWatermarkedCollectionCount > 0 || selectedWatermarkedResultCount > 0) && ( + <> + 移除水印会恢复无水印原图,并删除当前水印版 + + + + )} +
+ ); +} + +function WatermarkTaskStatus({ job, onCancel, onRetry, onRetryItem }: { job: SuiteInfo; onCancel: () => void; onRetry: () => void; onRetryItem: (itemId: string) => void }) { + const active = ['queued', 'running'].includes(job.status); + return ( + + {job.error && {job.error}} + {['partial', 'failed'].includes(job.status) && } + {active && } + {job.images.filter((item) => item.status === 'failed').map((item) => ( + + {item.name}:{item.error || '处理失败'} + + + ))} + + } + /> + ); +} diff --git a/studio/src/pages/trial/trialImageTypes.ts b/studio/src/pages/trial/trialImageTypes.ts new file mode 100644 index 0000000..b05a12c --- /dev/null +++ b/studio/src/pages/trial/trialImageTypes.ts @@ -0,0 +1,30 @@ +import type { ImageProps } from 'antd'; + +export const GROUP_LABELS: Record = { + 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>, + 'actionsRender' +>; + +export interface ResultImage { + key: string; + url: string; + name: string; + groupName: string; + variantName: string | null; + assetId?: string; + source: 'suite' | 'history'; + createdAt?: string; +} diff --git a/studio/src/services/suite.ts b/studio/src/services/suite.ts index 5071303..12ee729 100644 --- a/studio/src/services/suite.ts +++ b/studio/src/services/suite.ts @@ -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(`/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(`/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`; +}