6 Commits

10 changed files with 700 additions and 615 deletions

View File

@@ -1,6 +1,6 @@
# 项目接力
- 生成时间May 21, 2026 at 13:48
- 生成时间May 22, 2026 at 09:01
- 项目SKG Marketing Studio / SKG 营销内容工作台
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 状态active
@@ -9,7 +9,7 @@
## 最近助手会话概览
- Claudea9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
- Codex019e4691-7c18-7dc1-ba82-a315eec63163 · 时间未知
- Codex019e49d1-d114-7d52-8cf0-fbb4585f5ce6 · 时间未知
- Cursor未找到匹配当前项目的最近会话
## Claude 最近会话
@@ -92,23 +92,57 @@
## Codex 最近会话
- Session ID019e4691-7c18-7dc1-ba82-a315eec63163
- Transcript/Users/kangwan/.codex/sessions/2026/05/21/rollout-2026-05-21T02-06-40-019e4691-7c18-7dc1-ba82-a315eec63163.jsonl
- Session ID019e49d1-d114-7d52-8cf0-fbb4585f5ce6
- Transcript/Users/kangwan/.codex/sessions/2026/05/21/rollout-2026-05-21T17-15-47-019e49d1-d114-7d52-8cf0-fbb4585f5ce6.jsonl
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 分支main
- 敏感字段:已对 token / key / password / secret 做脱敏
### 最近用户要求
- 不能直接在服务器上的dock二
- 弄么
- OK 按照你的来
- 分镜这个板块,我每次点击视频 都变成了选择 其实我就是想打开看下而已 你这个逻辑不太对 你看下
- 为什么要选择呢 你给我点击下载的按钮不就好了
- 而且每个生成的视频当然是自动保存呀 还需要我点击保存干啥
### Codex 最近回复
> 代码正在同步到服务器 `/tmp/skg-marketing-staging-20260521134626`,同步过程排除了生产数据、密钥、本地环境文件和构建产物。这个 staging 目录会用自己的容器名和网络,不会接管生产域名
> 生产部署正在跑第二轮远端备份和同步已经完成Web 镜像在构建;我等脚本验证结束
> 同步完成。接下来我会在 staging 目录里复制服务器已有的生产环境变量文件,但只用于临时容器;同时生成一份临时 compose去掉 Traefik 标签和生产容器名,改成只监听服务器本机 `127.0.0.1:18080`
> 对,这个点你说得对。生成的视频本来就会写进当前 job 的 `generated_videos`,完成后自动回填 mp4不应该让用户理解成还要点“保存视频”
>
> 我已经改完并上线:
>
> - 分镜视频候选轨现在显示 `自动保存`
> - 视频候选普通点击只负责打开预览
> - 右上角提供显式 `下载` 按钮
> - “保存全部 / 保存本条规划”改成了“同步规划 / 同步本条规划”,只表达同步三字段文案规划,不再像保存视频
> - 源码解析页也同步更新了这条交互语义
>
> 验证和发布:
>
> - `web/npm run build` 通过
> - 已推送 Gitea
> - `4efb2ce fix: make storyboard video clicks previewable`
> - `eb4347a fix: clarify storyboard videos auto-save`
> - 已执行 `./scripts/deploy-prod-safe.sh` 上线,生产验证通过:`/login/ 200`、`/api/health 401`、容器内 `api:health ok`
>
> 当前 `git status -sb`
>
> ```text
> ## main...origin/main
> M .memory/worklog.json
> ```
>
> 只剩自动日志 `.memory/worklog.json`,业务代码和文档都已提交并推送。
>
> 补充:`npm run lint` 现在跑不了,因为项目里没有可执行的 `eslint``tsc --noEmit` 仍有一批既有的 dashboard/ui 旧类型错误,不是这次改动引入的。
>
> <oai-mem-citation>
> <citation_entries>
> MEMORY.md:214-286|note=[used SKG worktable context and docs update expectation]
> </citation_entries>
> <rollout_ids>
> 019e2fef-97a7-7523-a1ff-051a8c6…
## Cursor 最近会话
@@ -117,8 +151,8 @@
## 当前仓库状态
- 当前分支main
- 未提交变更:8
- 最近提交:docs: record image timeout deployment
- 未提交变更:1
- 最近提交:fix: clarify storyboard videos auto-save
- 变更文件:
- M .memory/worklog.json

File diff suppressed because it is too large Load Diff

View File

@@ -11,13 +11,14 @@
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`
- 第一冲刺:步骤 1-4下载 / 拆轨 / 关键帧 / ASR+翻译)
- 当前产品方向2026-05-20 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”;工作台已取消 1800x1000 固定画布和整页缩放,改为正常流式桌面容器,宽度跟随浏览器展开,只保留 1280px 最低操作宽度防止核心表格被压烂,不再通过应用层 `zoom` 把整页缩小导致文字发虚。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区主体链路改为“上方参考帧池 + 转换层、下方主体元素结果栏”:参考帧池竖向排列;转换层是轻量对话式生图确认区,参考图可通过左侧缩略图 `+`、参考帧拖拽、胶片拖拽或本地图片拖入进入转换层,用户选择 GPT/Gemini 套件后先分析参考图;识别结果里的特征 chip 只作为“保留元素”本地选择,点亮=保留、再点取消,点击不立即请求模型,随下一条发送消息提交;用户再在下方发送区发送复刻/创新/卡通和画面要求,界面只保留生成要求输入框、张数控件和提示词就绪状态,不展示当前要求摘要、保留元素副本、收起记录计数或重复模型确认话术,生成数量通过发送区旁边的张数控件控制;后端返回英文出图 prompt 后不再自动弹窗,发送区主按钮直接切换为“确认生成 N 张”,用户点击才生成对应数量的统一多角度套图。主体元素结果栏在转换层下方横向展示套图输出、文件夹分组、单张重生、删除和 hover 预览,空态只保留紧凑提示,不再挤占右侧整列。旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。
- 当前产品方向2026-05-20 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”;工作台已取消 1800x1000 固定画布和整页缩放,改为正常流式桌面容器,宽度跟随浏览器展开,只保留 1280px 最低操作宽度防止核心表格被压烂,不再通过应用层 `zoom` 把整页缩小导致文字发虚。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路自动识别中文、英文和其他多语言原音频文案/字幕,统一补齐中文镜像,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区主体链路改为“上方参考帧池 + 转换层、下方主体元素结果栏”:参考帧池竖向排列;转换层是轻量对话式生图确认区,参考图可通过左侧缩略图 `+`、参考帧拖拽、胶片拖拽或本地图片拖入进入转换层,用户选择 GPT/Gemini 套件后先分析参考图;识别结果里的特征 chip 只作为“保留元素”本地选择,点亮=保留、再点取消,点击不立即请求模型,随下一条发送消息提交;用户再在下方发送区发送复刻/创新/卡通和画面要求,界面只保留生成要求输入框、张数控件和提示词就绪状态,不展示当前要求摘要、保留元素副本、收起记录计数或重复模型确认话术,生成数量通过发送区旁边的张数控件控制;后端返回英文出图 prompt 后不再自动弹窗,发送区主按钮直接切换为“确认生成 N 张”,用户点击才生成对应数量的统一多角度套图。主体元素结果栏在转换层下方横向展示套图输出、文件夹分组、单张重生、删除和 hover 预览,空态只保留紧凑提示,不再挤占右侧整列。旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。
## 部署事实
- 平台VPS `76.13.31.179`Ubuntu 24.04 / Docker Compose / Coolify Traefik
- Agent Cut 独立预览服务器:`2.24.28.41`Ubuntu 24.04 / Docker Compose / 裸端口 `4290`),部署目录 `/opt/skg-marketing-studio`Compose 入口 `docker-compose.standalone.yml`,访问地址 `http://2.24.28.41:4290/agent/`。该入口用于“一分钟二创出片终端”预览:用户只提交 TikTok 链接和产品图,后端 `AgentRun` 状态机负责下载、抽帧、规划、生成、自动重跑、审片和合成。
- Agent Cut 独立预览验证2026-05-21已在 `2.24.28.41``/opt/skg-marketing-studio``docker-compose.standalone.yml` 启动 `skg-agent-api` / `skg-agent-web`;独立 compose 通过网络别名兼容 Nginx 的 `skg-marketing-api` upstream。该裸 IP HTTP 入口的服务器 `deploy/.env.production` 需要 `WEB_AUTH_COOKIE_SECURE=false`;本次已补齐 `WEB_AUTH_*` 后重启验证通过:未登录 `/agent/` 返回 302 到 `/login/`,登录后 `/agent/` 返回 200`/api/agent-runs` 返回数组,容器内 `/health` 返回 `ok:true``auth_configured:true`
- 发布状态已部署并验证2026-05-20主体元素按套图文件夹分组展示主体生成接口提交后立即返回 queued 占位并后台逐视角生成、逐张回填;工作台外层取消 1800x1000 固定画布和应用层 `zoom` 缩放,改为正常流式桌面容器,最低操作宽度 1280px源视频工作区主体链路为上方竖向参考帧池 + 宽幅对话式转换层、下方主体元素结果栏;转换层通过参考帧 `+` 加入、参考图分析、生图对话,英文 prompt 就绪后由发送区主按钮切换为确认生成,点击后才触发主体套图生成;转换层不再固定 640px 长高,按内容自然高度显示,仅以 560px 最大高度兜底内部滚动;下方主体元素结果栏的套图输出、轮询、文件夹分组、单张重生、删除和 hover 预览逻辑保持不变;胶片双击/拖拽加入参考帧池 + 胶片缓存复用 + 音频解析失败可重试,参考帧缩略图保持小尺寸 9:16 比例 + hover 左侧紧凑预览,旧主体模板区移出主路径 + 逐句时间轴移到原版视频下方并支持双行文案 + 波形同框时间对齐画面胶片 + 胶片密度按钮上移波形顶部 + 去分隔线 + 胶片上下错落 + body 顶层原位大放大 + 隐藏源视频工作区音频解析摘要卡 + 隐藏工作区顶部状态提示条 + 三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401认证后首页 200容器内 `/health` 返回 `ok:true`
- 最近部署验证2026-05-22`6427935` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260522012756.tgz`,生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`)。部署后已把生产私有 `deploy/.env.production` 明确固定为多语言本地 ASR 路径并重启 API`ASR_LANGUAGE=auto``FASTER_WHISPER_MODEL=base``ASR_REMOTE_ENABLED=false``ASR_LOCAL_FALLBACK_ENABLED=true``ASR_AUDIO_FALLBACK_ENABLED=false`;复验 `./scripts/verify-prod-docker.sh root@76.13.31.179` 通过,容器内 `/health` 确认 `asr_language=auto``faster_whisper=base`
- 最近部署验证2026-05-21`8458dac` 已按“先本地 Docker、再上传部署”流程上线。上线前在本机 Docker 构建 `skg-marketing-studio-web:latest` / `skg-marketing-studio-api:latest`,并用本地 Compose 容器验证通过:`web:/ 302``web:/login/ 200``web:/_next/does-not-exist.js 404``web:/api/health 401``api:health ok``api:ytdlp_cookie_args []`、静态 bundle 包含 `未来健康 · 营销内容工作台``信息流广告复刻生产`,且未发现本地 API/dev URL 泄漏。随后通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260521070327.tgz`,生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。
- 最近部署验证2026-05-20`6597db3` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520151033.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/c48f07b9aef1cd29.js` 已包含 `min-w-[1280px]``max-w-[1920px]`,未再命中旧的 `h-[1000px]``w-[1800px]``BOARD_SCALE_PRESETS``boardScale`;对应工作台取消固定画布缩放,按浏览器正常流式布局渲染。
- 最近部署验证2026-05-20`2b842fd` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520145223.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/743b82648dfa9db9.js` 已包含 `h-32``maxHeight:560``提示词就绪``确认生成`,且未再命中旧的 `height:640` / `h-40`;对应转换层取消固定长高,生成要求输入区回到 128px底部仍由发送区主按钮确认生成。
@@ -62,7 +63,7 @@
- 独立预览容器重建命令:服务器 `/opt/skg-marketing-studio` 下执行 `docker compose -f docker-compose.standalone.yml --env-file deploy/.env.production up -d --build`Web 暴露 `0.0.0.0:4290->80`,后端仅在 compose 内部网络暴露,`/api/` 由 Web 容器 Nginx 反代并复用应用内登录校验。
- 生产架构:`web` 容器用 Nginx 承载 Next 静态导出;`/login/``/_next/``/assets/``/skg-logo-black.svg``/oasis-source/` 等登录页必需静态资源公开访问;未登录访问工作台跳转 `/login/``/api/` 通过 Nginx `auth_request` 校验 FastAPI 会话 Cookie 后反代到 `skg-marketing-api:4291`Traefik 通过 `coolify` 外部网络接入 80/443
- Web 验收必须以生产 Docker 形态为准:前端是 `next export` 静态产物 + Nginx不是 `next dev` / `next start`。任何 Web 改动部署后必须运行 `./scripts/verify-prod-docker.sh`,确认 `/login/``/_next/``/api/health`、本地 API 地址泄漏和 API 镜像 `.env` 污染检查通过;不能只用本地 `npm run build` 作为上线依据。
- 当前音频解析:`https://ai.skg.com/azure/v1``gpt-4o-transcribe` 当前返回 `DeploymentNotFound`,且官方 Azure OpenAI transcription 路径探测也未返回可用部署;生产临时复制本地成功策略,直接使用容器内 `faster-whisper tiny.en` 真实转写,关闭 Gemini 多模态音频兜底。拿到真实 Azure ASR deployment 名后再恢复 `ASR_REMOTE_ENABLED=true`
- 当前音频解析:`https://ai.skg.com/azure/v1``gpt-4o-transcribe` 当前返回 `DeploymentNotFound`,且官方 Azure OpenAI transcription 路径探测也未返回可用部署;生产临时复制本地成功策略,直接使用容器内多语言 `faster-whisper` 真实转写,默认语种为 `auto`,支持中文、英文和其他多语言原文识别,关闭 Gemini 多模态音频兜底。拿到真实 Azure ASR deployment 名后再恢复 `ASR_REMOTE_ENABLED=true`,并保持 `ASR_LANGUAGE` 为空或 `auto`,除非明确只想强制单一语种
- 持久化目录:服务器 `./data/jobs` 挂载到后端 `/data/jobs`;全局资源中心持久化在 `./data/asset_library``./data/prompt_library``./data/_trash`
- TikTok 下载登录态:公开视频默认不带 cookies 直接下载,生产环境变量必须显式保持 `YTDLP_COOKIES_FILE=``YTDLP_COOKIES_FROM_BROWSER=` 为空,防止容器读取不存在的浏览器 cookies。只有 TikTok 明确要求登录态时,才使用服务器私有 cookies 文件 `./secrets/tiktok_cookies.txt` 挂载到 API 容器 `/run/secrets/tiktok_cookies.txt` 并配置 `YTDLP_COOKIES_FILE=/run/secrets/tiktok_cookies.txt``yt-dlp` 会在任务结束时回写 cookies因此不要把该挂载设为只读不要使用云端浏览器读取方案也不要把 cookies 入库。生产容器严禁使用 `YTDLP_COOKIES_FROM_BROWSER=chrome`
- 登录凭证:用户名写下方快捷登录;密码明文备份只放服务器 `/root/skg-marketing-studio-login.txt`,生产环境变量 `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` 只放服务器 `deploy/.env.production`
@@ -94,11 +95,11 @@
- `LLM_BASE_URL` / `LLM_API_KEY`OpenAI 兼容网关,用于翻译、文案改写、音频分析等文本/多模态理解模型调用
- `ASR_BASE_URL` / `ASR_API_KEY`OpenAI Audio Transcriptions 兼容网关,用于上传 `audio.wav` 做真实转写;未配置 `ASR_API_KEY` 时复用 `LLM_API_KEY`,生产默认指向 `https://ai.skg.com/azure/v1`
- `ASR_MODEL`OpenAI Audio Transcriptions 音频转写模型;微软通道使用 Azure OpenAI 部署名 `gpt-4o-transcribe`,如果 Azure 侧实际部署名不同必须同步改这里
- `ASR_LANGUAGE`:远端 ASR 的输入语言提示,默认 `en`;微软官方说明指定 ISO-639-1 语言可改善准确率和延迟
- `ASR_LANGUAGE`:远端和本地 ASR 的可选输入语言提示,默认空值/`auto`,由模型自动识别中文、英文和其他多语言;只有明确知道素材固定语种时才填写 ISO-639-1 代码强制识别
- `ASR_REMOTE_ENABLED`:是否启用远端 OpenAI Audio Transcriptions微软 ASR 验收时必须为 `true`。当前生产因 `https://ai.skg.com/azure/v1``gpt-4o-transcribe` 返回 `DeploymentNotFound`,临时设为 `false`,直接走容器内 `faster-whisper`,等真实 Azure deployment 名补齐后再恢复。
- `ASR_LOCAL_FALLBACK_ENABLED`:是否允许远端 ASR 失败后落到本机 / 容器内 ASR当前生产为 `true`,复制本地成功路径的“本机真实转写”策略,云端用 CPU 版 `faster-whisper` 替代本机 Mac 的 `mlx_whisper`
- `ASR_AUDIO_FALLBACK_ENABLED`:是否允许远端和本机 ASR 失败后落到多模态音频兜底;生产微软 ASR 验收设为 `false`,避免静默使用 Gemini 音频
- `FASTER_WHISPER_MODEL` / `FASTER_WHISPER_DEVICE` / `FASTER_WHISPER_COMPUTE_TYPE`:容器内本地 ASR 兜底,仅在 `ASR_LOCAL_FALLBACK_ENABLED=true` 时启用
- `FASTER_WHISPER_MODEL` / `FASTER_WHISPER_DEVICE` / `FASTER_WHISPER_COMPUTE_TYPE`:容器内本地 ASR 兜底,仅在 `ASR_LOCAL_FALLBACK_ENABLED=true` 时启用;默认用多语言 `base`,不要改回 `*.en` 英文专用模型,否则中文和多语言识别会退化。
- `ASR_FALLBACK_MODEL`:多模态音频兜底模型,仅在 `ASR_AUDIO_FALLBACK_ENABLED=true` 时用于兜底或音频画像,默认 `gemini-2.5-flash`;如果模型不能真实听到音频或返回疑似逐秒假字幕,后端必须拒绝写入时间轴
- `ASR_TIMEOUT_SECONDS`:远端 ASR / 翻译 / 音频分析单次请求超时;当前生产本地转写模式设为 45 秒,微软 ASR 重新启用时可按素材长度提高。
- `LOCAL_ASR_BIN` / `LOCAL_ASR_MODEL` / `LOCAL_ASR_TIMEOUT_SECONDS`:本机 ASR 兜底,默认使用 `/opt/homebrew/bin/mlx_whisper` + `mlx-community/whisper-tiny`,用于当前 SKG 网关 `/audio/transcriptions` 不可用时生成真实逐句时间轴

View File

@@ -35,6 +35,6 @@ uvicorn main:app --host 127.0.0.1 --port 4291
- `ffmpeg` 系统二进制(拆轨 / 抽帧)
- `yt-dlp` 系统二进制(也可走 Python 包)
- OpenAI 兼容 LLM 网关ASR / 翻译 / 文案改写 / 视觉 brief远端 `whisper-1` 失败后先走本机 `mlx_whisper`,再用 `ASR_FALLBACK_MODEL` Gemini 多模态音频识别,后端会拒绝疑似假字幕或覆盖率过低的时间轴
- OpenAI 兼容 LLM 网关ASR / 翻译 / 文案改写 / 视觉 briefASR 默认自动识别中文、英文和其他多语言,远端失败后先走容器内多语言 `faster-whisper` / 本机 `mlx_whisper`,再按开关`ASR_FALLBACK_MODEL` 走多模态音频识别,后端会拒绝疑似假字幕或覆盖率过低的时间轴
- GPT 图片网关(当前所有生图 / 修图 / 产品视角识别 / 主体资产 / 首尾帧都强制使用 `gpt-image-2`,不做其他图片模型 fallback
- Azure OpenAI TTS后续新配音阶段使用 `AZURE_OPENAI_API_KEY`;默认模型 `gpt-4o-mini-tts`,按 `AZURE_TTS_PATHS` 依次尝试语音路径)

View File

@@ -63,13 +63,13 @@ LLM_API_KEY = os.getenv("LLM_API_KEY", "").strip()
ASR_BASE_URL = os.getenv("ASR_BASE_URL", LLM_BASE_URL).strip()
ASR_API_KEY = (os.getenv("ASR_API_KEY") or LLM_API_KEY).strip()
ASR_MODEL = os.getenv("ASR_MODEL", "whisper-1")
ASR_LANGUAGE = os.getenv("ASR_LANGUAGE", "en").strip()
ASR_LANGUAGE = os.getenv("ASR_LANGUAGE", "").strip()
ASR_REMOTE_ENABLED = os.getenv("ASR_REMOTE_ENABLED", "true").strip().lower() not in {"0", "false", "no", "off"}
ASR_LOCAL_FALLBACK_ENABLED = os.getenv("ASR_LOCAL_FALLBACK_ENABLED", "true").strip().lower() not in {"0", "false", "no", "off"}
ASR_AUDIO_FALLBACK_ENABLED = os.getenv("ASR_AUDIO_FALLBACK_ENABLED", "true").strip().lower() not in {"0", "false", "no", "off"}
ASR_FALLBACK_MODEL = os.getenv("ASR_FALLBACK_MODEL", "gemini-2.5-flash").strip() or "gemini-2.5-flash"
ASR_TIMEOUT_SECONDS = max(15, int(os.getenv("ASR_TIMEOUT_SECONDS", "45")))
FASTER_WHISPER_MODEL = os.getenv("FASTER_WHISPER_MODEL", "tiny.en").strip() or "tiny.en"
FASTER_WHISPER_MODEL = os.getenv("FASTER_WHISPER_MODEL", "base").strip() or "base"
FASTER_WHISPER_DEVICE = os.getenv("FASTER_WHISPER_DEVICE", "cpu").strip() or "cpu"
FASTER_WHISPER_COMPUTE_TYPE = os.getenv("FASTER_WHISPER_COMPUTE_TYPE", "int8").strip() or "int8"
LOCAL_ASR_BIN = os.getenv("LOCAL_ASR_BIN", "").strip()
@@ -79,6 +79,20 @@ TRANSLATE_MODEL = os.getenv("TRANSLATE_MODEL", "gemini-2.5-flash")
DEFAULT_GPT_TEXT_MODEL = os.getenv("GPT_TEXT_MODEL", "gpt-4o").strip() or "gpt-4o"
ASR_AUTO_LANGUAGE_VALUES = {"", "auto", "detect", "multilingual", "multi"}
def _asr_language_hint() -> str:
language = ASR_LANGUAGE.strip()
if language.lower() in ASR_AUTO_LANGUAGE_VALUES:
return ""
return language
def _asr_language_label() -> str:
return _asr_language_hint() or "auto"
def gpt_model_env(name: str, default: str | None = None) -> str:
value = os.getenv(name, default or DEFAULT_GPT_TEXT_MODEL).strip()
if not value or value.lower().startswith("gemini-"):
@@ -2811,7 +2825,7 @@ def _clean_asr_segments(segments: list[dict], duration: float) -> list[dict]:
def _segment_text_key(text: str) -> str:
return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip()
return re.sub(r"[^\w]+", " ", text.casefold(), flags=re.UNICODE).strip()
def _validate_asr_segments(segments: list[dict], duration: float, source: str) -> list[dict]:
@@ -2909,19 +2923,22 @@ def _transcribe_faster_whisper_sync(wav: Path) -> list[dict]:
device=FASTER_WHISPER_DEVICE,
compute_type=FASTER_WHISPER_COMPUTE_TYPE,
)
raw_segments, _info = model.transcribe(
str(wav.resolve()),
language="en",
beam_size=1,
vad_filter=True,
condition_on_previous_text=False,
)
language_hint = _asr_language_hint()
transcribe_options = {
"beam_size": 1,
"vad_filter": True,
"condition_on_previous_text": False,
}
if language_hint:
transcribe_options["language"] = language_hint
raw_segments, _info = model.transcribe(str(wav.resolve()), **transcribe_options)
detected_language = str(getattr(_info, "language", "") or language_hint or "auto")
segments = [
{"start": float(seg.start), "end": float(seg.end), "text": str(seg.text or "").strip()}
for seg in raw_segments
if str(seg.text or "").strip()
]
return _validate_asr_segments(segments, duration, f"faster-whisper:{FASTER_WHISPER_MODEL}")
return _validate_asr_segments(segments, duration, f"faster-whisper:{FASTER_WHISPER_MODEL}:{detected_language}")
def _transcribe_gemini_sync(wav: Path) -> list[dict]:
@@ -2931,8 +2948,9 @@ def _transcribe_gemini_sync(wav: Path) -> list[dict]:
"Transcribe the attached audio. Return strict JSON only, no markdown. "
"If you cannot truly hear the audio, return {\"can_hear\": false}. Do not guess. "
"If you can hear it, return {\"can_hear\": true, \"segments\": "
"[{\"start\": 0.0, \"end\": 1.2, \"text\": \"English transcript\"}]}. "
"Use English for the transcript. Only include timestamps you can infer from the audio."
"[{\"start\": 0.0, \"end\": 1.2, \"text\": \"original-language transcript\"}]}. "
"Keep the transcript in the spoken source language; do not translate it here. "
"Only include timestamps you can infer from the audio."
)
last_error: Exception | None = None
for attempt in range(3):
@@ -2961,19 +2979,21 @@ def _transcribe_sync(wav: Path) -> list[dict]:
if ASR_REMOTE_ENABLED:
try:
with wav.open("rb") as f:
language_hint = _asr_language_hint()
resp = asr_llm().with_options(timeout=ASR_TIMEOUT_SECONDS).audio.transcriptions.create(
file=(wav.name, f, "audio/wav"),
model=ASR_MODEL,
response_format="verbose_json",
timestamp_granularities=["segment"],
**({"language": ASR_LANGUAGE} if ASR_LANGUAGE else {}),
**({"language": language_hint} if language_hint else {}),
)
raw = resp.model_dump() if hasattr(resp, "model_dump") else resp
segments = raw.get("segments") or []
# 兜底:网关如果不返回 segments把全文当一段
if not segments and raw.get("text"):
segments = [{"start": 0.0, "end": float(raw.get("duration", 0) or 0), "text": raw["text"]}]
return _validate_asr_segments(segments, duration, ASR_MODEL)
detected_language = str(raw.get("language") or language_hint or "auto")
return _validate_asr_segments(segments, duration, f"{ASR_MODEL}:{detected_language}")
except Exception as e:
errors.append(f"{ASR_MODEL}: {e}")
else:
@@ -3001,11 +3021,13 @@ def _transcribe_sync(wav: Path) -> list[dict]:
def _translate_sync(segments: list[dict]) -> list[str]:
"""批量翻译为中文,按段返回"""
payload = [{"i": i, "en": s.get("text", "").strip()} for i, s in enumerate(segments)]
payload = [{"i": i, "text": s.get("text", "").strip()} for i, s in enumerate(segments)]
prompt = (
"你是字幕翻译。把下列英文字幕段翻译为简体中文,保持原意、口语化、自然流畅。"
"严格返回 JSON 数组,不要任何 markdown 或多余文字schema: "
'[{"i": 0, "zh": "..."}, ...]\n\n输入:\n'
"你是多语言字幕翻译。把下列原语言字幕段翻译为简体中文"
"如果原文已经是中文,只做简体中文规范化和口语化整理,不要改写意思。"
"保持原意、口语化、自然流畅。"
"严格返回 JSON object不要任何 markdown 或多余文字schema: "
'{"translations":[{"i": 0, "zh": "..."}]}\n\n输入:\n'
+ json.dumps(payload, ensure_ascii=False)
)
try:
@@ -3432,7 +3454,7 @@ def pipeline_transcribe(job_id: str, manage_job_status: bool = True) -> None:
return
# 1) whisper ASR
progress(f"{ASR_MODEL} 转录中…", 78)
progress(f"{ASR_MODEL} {_asr_language_label()} 语种转录中…", 78)
segments = _transcribe_sync(wav)
if not segments:
raise TranscriptionUnavailable("ASR 未返回可用字幕段")
@@ -4494,7 +4516,7 @@ def health() -> dict:
"voice_base_url": AZURE_OPENAI_BASE_URL,
"models": {
"asr": ASR_MODEL,
"asr_language": ASR_LANGUAGE,
"asr_language": _asr_language_label(),
"asr_base_url": ASR_BASE_URL or LLM_BASE_URL or "openai-default",
"asr_remote_enabled": ASR_REMOTE_ENABLED,
"asr_local_fallback_enabled": ASR_LOCAL_FALLBACK_ENABLED,

File diff suppressed because one or more lines are too long

View File

@@ -3,7 +3,7 @@
import { type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react"
import { createPortal } from "react-dom"
import {
AlertTriangle, BookOpen, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Info, Link2, Loader2, Minus,
AlertTriangle, BookOpen, Check, ChevronDown, Circle, Download, Film, FileText, Image as ImageIcon, Info, Link2, Loader2, Minus,
MessageSquare, Mic, Moon, Package, PanelRight, Play, Plus, RefreshCw, Scissors, Send, Sparkles, Sun, Trash2, Upload, Wand2,
} from "lucide-react"
import { toast } from "sonner"
@@ -863,6 +863,17 @@ function videoSrc(video: GeneratedVideo) {
return apiAssetUrl(video.url)
}
function downloadMedia(url: string, filename: string) {
if (!url || typeof document === "undefined") return
const link = document.createElement("a")
link.href = url
link.download = filename
link.rel = "noreferrer"
document.body.appendChild(link)
link.click()
link.remove()
}
function audioPreview(job: Job | null) {
if (!job) return "粘贴 TK 链接或上传视频后,系统会先下载视频;下载完成后自动提取音频文案。"
const source = job.audio_script?.source_text?.trim() || job.audio_script?.source_zh?.trim()
@@ -1324,8 +1335,8 @@ function audioModelTrace(models?: RuntimeModels): ModelTraceSpec {
title: "音频解析",
model: modelList([models?.asr, models?.translate, models?.asr_fallback]),
chain: [
`ASR 转写:远端 ${remoteState},模型 ${modelValue(models?.asr)}${models?.asr_language ? `,语言 ${models.asr_language}` : ""};本机转写 ${localState},使用 ${localModel};多模态兜底${models?.asr_audio_fallback_enabled === false ? "关闭" : `${modelValue(models?.asr_fallback)}`},并拒绝假字幕/重复时间轴`,
`字幕翻译:${modelValue(models?.translate)} 按 ASR 段落输出中文;失败时保留原文时间轴,中文可为空`,
`ASR 转写:远端 ${remoteState},模型 ${modelValue(models?.asr)},语言 ${models?.asr_language || "auto"};本机转写 ${localState},使用 ${localModel} 自动识别中文/多语言;多模态兜底${models?.asr_audio_fallback_enabled === false ? "关闭" : `${modelValue(models?.asr_fallback)}`},并拒绝假字幕/重复时间轴`,
`字幕翻译:${modelValue(models?.translate)}原语言 ASR 段落输出中文;原文已是中文时保留为中文镜像,失败时保留原文时间轴`,
`讲话人 / 节奏 / 背景音:${modelValue(models?.asr_fallback)} 读取 audio.wav + 转写时间轴做多模态分析;失败时用本地时长/段落估算兜底`,
],
note: "点击“解析音频”后触发;开始任务下载完成后也会自动走这条链路。",
@@ -1373,7 +1384,7 @@ function scriptRewriteModelTrace(models?: RuntimeModels): ModelTraceSpec {
chain: [
`主改写:${modelValue(models?.audio_rewrite)} 根据原文案、当前分镜、作者想法生成新口播`,
`模型回退:依次尝试 ${modelValue(models?.asr_fallback)}${modelValue(models?.translate)};全部失败时用本地模板保留分镜可编辑`,
"返回结果写入当前分镜文案编辑框;点击保存规划后才写入 frame.storyboard.action",
"返回结果会先写入当前分镜文案编辑框;生成候选前会自动同步到分镜数据,无需单独保存视频",
],
}
}
@@ -1388,7 +1399,7 @@ function videoModelTrace(models: RuntimeModels | undefined, model: string): Mode
`服务商:${modelValue(models?.video_provider)} · ${modelValue(models?.video_base_url)}`,
"当前主工作台暂停直接提交视频;旧入口误触也会被页面层保护",
"开放后输入会包含已确认首尾帧、当前分镜文案、产品素材、相似主体资产和画面规划",
"输出为异步候选视频,完成后回填到对应分镜行Sora 已停用",
"输出为异步候选视频,提交后立即写入当前任务,完成后自动回填 mp4 到对应分镜行Sora 已停用",
],
}
}
@@ -5584,23 +5595,6 @@ function AudioStoryboardPlanPanel({
}
}
const selectVideoForRow = async (row: AudioStoryboardRow, frame: KeyFrame | null, videoId: string) => {
if (!job || !frame) return
const plannedRow = { ...planForRow(row, frame), skgCopy: copyForRow(row), skgCopyZh: copyZhForRow(row) }
try {
const legacyRowIndex = legacyRowIndexForFrame(frame.index)
const savedSceneForRow = storyboardSceneBelongsToRow(frame.storyboard, row.index, legacyRowIndex)
? frame.storyboard
: null
const scene = buildSceneForPlannedRow(plannedRow, frame, savedSceneForRow, videoId)
const updated = await updateStoryboard(job.id, frame.index, scene)
onJobUpdate?.(updated)
toast.success(`分镜 ${row.index + 1} 已选用该视频`)
} catch (e) {
toast.error("选用视频失败:" + (e instanceof Error ? e.message : String(e)))
}
}
const clearVideosForRow = (videos: GeneratedVideo[]) => {
if (!videos.length) return
for (const video of videos) onDeleteVideo?.(video.id)
@@ -5904,9 +5898,9 @@ function AudioStoryboardPlanPanel({
setStoryboardSaveBusyRow(row.index)
try {
await saveRowStoryboardDraft(row, frame)
toast.success("已保存本条三字段规划")
toast.success("本条三字段规划已同步")
} catch (e) {
toast.error("保存本条规划失败:" + (e instanceof Error ? e.message : String(e)))
toast.error("同步本条规划失败:" + (e instanceof Error ? e.message : String(e)))
} finally {
setStoryboardSaveBusyRow(null)
}
@@ -5918,7 +5912,7 @@ function AudioStoryboardPlanPanel({
.map((row) => ({ row: planForRow(row, referenceFrameForRow(row)), frame: referenceFrameForRow(row) }))
.filter((item): item is { row: AudioStoryboardRow; frame: KeyFrame } => !!item.frame)
if (!jobsToSubmit.length) {
if (!quiet) toast.warning("先完成前置抽帧,让每条分镜有可保存的承载位置")
if (!quiet) toast.warning("先完成前置抽帧,让每条分镜有可同步的承载位置")
return { ok: 0, failed: rows.length }
}
setBatchStoryboardSaveBusy(true)
@@ -5932,12 +5926,12 @@ function AudioStoryboardPlanPanel({
ok += 1
} catch (e) {
failed += 1
console.warn("批量保存分镜规划失败", item.row.index, e)
console.warn("批量同步分镜规划失败", item.row.index, e)
}
}
if (!quiet) {
if (failed) toast.warning(`保存 ${ok} 条规划,${failed} 条失败`)
else toast.success(`保存全部 ${ok} 条分镜规划`)
if (failed) toast.warning(`同步 ${ok} 条规划,${failed} 条失败`)
else toast.success(`同步全部 ${ok} 条分镜规划`)
}
} finally {
setStoryboardSaveBusyRow(null)
@@ -6307,10 +6301,11 @@ function AudioStoryboardPlanPanel({
type="button"
onClick={() => void saveAllStoryboardDrafts()}
disabled={batchStoryboardSaveBusy || !rows.length || !orderedFrames.length}
className="skg-primary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
title="只同步三字段规划;生成的视频会自动保存到当前任务"
className="skg-secondary-action inline-flex h-9 items-center justify-center gap-1 px-2.5 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
>
{batchStoryboardSaveBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
</button>
</div>
</div>
@@ -6468,7 +6463,6 @@ function AudioStoryboardPlanPanel({
job={job}
videos={rowVideos}
enabled={!!referenceFrame}
selectedVideoId={selectedVideoIdForRow(row, referenceFrame)}
busy={quickVideoBusyRow === row.index}
count={rowVideoCount}
onCountChange={(count) => patchRowVideoCount(row.index, count)}
@@ -6476,7 +6470,6 @@ function AudioStoryboardPlanPanel({
onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)}
onClear={() => clearVideosForRow(rowVideos)}
onSelect={(videoId) => void selectVideoForRow(plannedRow, referenceFrame, videoId)}
onDeleteVideo={onDeleteVideo}
/>
</div>
@@ -6697,7 +6690,6 @@ function AudioStoryboardPlanPanel({
videos={rowVideos}
enabled={!!referenceFrame}
expanded={videosOpen}
selectedVideoId={selectedVideoIdForRow(row, referenceFrame)}
busy={quickVideoBusyRow === row.index}
count={rowVideoCount}
onCountChange={(count) => patchRowVideoCount(row.index, count)}
@@ -6706,7 +6698,6 @@ function AudioStoryboardPlanPanel({
onReroll={() => void drawVideosForRow(plannedRow, referenceFrame, rowVideoCount)}
onRegenerate={() => void drawVideosForRow(plannedRow, referenceFrame, 1)}
onClear={() => clearVideosForRow(rowVideos)}
onSelect={(videoId) => void selectVideoForRow(plannedRow, referenceFrame, videoId)}
onDeleteVideo={onDeleteVideo}
/>
<div className="mt-1 flex items-center justify-between gap-2">
@@ -6720,10 +6711,11 @@ function AudioStoryboardPlanPanel({
type="button"
onClick={() => void saveSingleRowStoryboardDraft(plannedRow, referenceFrame)}
disabled={!referenceFrame || savingStoryboard}
className="skg-primary-action mt-1.5 inline-flex h-8 w-full items-center justify-center gap-1 px-2 text-[11px] font-semibold transition disabled:cursor-not-allowed disabled:opacity-40"
title="只同步本条三字段规划;生成的视频会自动保存到当前任务"
className="mt-1.5 inline-flex h-8 w-full items-center justify-center gap-1 rounded-md border border-white/10 bg-white/[0.04] px-2 text-[11px] font-semibold text-white/58 transition hover:border-cyan-300/35 hover:text-cyan-100 disabled:cursor-not-allowed disabled:opacity-40"
>
{savingStoryboard ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
</button>
</StoryboardPlanCell>
</div>
@@ -7062,7 +7054,6 @@ function StoryboardVideoSlots({
job,
videos,
enabled,
selectedVideoId = "",
busy = false,
count = 4,
onCountChange,
@@ -7070,14 +7061,12 @@ function StoryboardVideoSlots({
onReroll,
onRegenerate,
onClear,
onSelect,
onDeleteVideo,
}: {
job: Job
videos: GeneratedVideo[]
enabled: boolean
expanded?: boolean
selectedVideoId?: string
busy?: boolean
count?: number
onCountChange?: (count: number) => void
@@ -7086,12 +7075,10 @@ function StoryboardVideoSlots({
onReroll?: () => void
onRegenerate?: () => void
onClear?: () => void
onSelect?: (videoId: string) => void
onDeleteVideo?: (videoId: string) => void
}) {
const visible = videos
const runningCount = videos.filter((video) => video.status === "queued" || video.status === "in_progress").length
const selectedVideo = selectedVideoId ? videos.find((video) => video.id === selectedVideoId) : null
const targetCount = clampVideoCount(count)
const emptyCount = visible.length ? 0 : Math.max(1, targetCount)
return (
@@ -7103,7 +7090,9 @@ function StoryboardVideoSlots({
<span className="shrink-0 text-[10px] text-white/34">
{videos.length ? `${videos.length}${runningCount ? ` · ${runningCount} 生成中` : ""}` : enabled ? "待生成" : "待抽帧"}
</span>
{selectedVideo ? <span className="rounded border border-emerald-300/20 bg-emerald-300/[0.08] px-1.5 py-0.5 text-[10px] text-emerald-100/72"> {shortId(selectedVideo.id)}</span> : null}
{videos.length ? (
<span className="rounded border border-emerald-300/18 bg-emerald-300/[0.07] px-1.5 py-0.5 text-[10px] text-emerald-100/70"></span>
) : null}
</div>
<div className="flex flex-wrap items-center gap-1.5">
<label className="inline-flex h-7 items-center gap-1 rounded-md border border-white/10 bg-black/36 px-1.5 text-[10px] font-semibold text-white/48">
@@ -7146,9 +7135,7 @@ function StoryboardVideoSlots({
key={video.id}
job={job}
video={video}
selected={selectedVideoId === video.id}
className="h-[168px] w-[94px]"
onSelect={onSelect ? () => onSelect(video.id) : undefined}
onRegenerate={onRegenerate}
onDelete={onDeleteVideo ? () => onDeleteVideo(video.id) : undefined}
/>
@@ -7265,40 +7252,50 @@ function StoryboardVideoPreview({
job,
video,
className = "h-20 w-12",
selected = false,
onSelect,
onRegenerate,
onDelete,
}: {
job: Job
video: GeneratedVideo
className?: string
selected?: boolean
onSelect?: () => void
onRegenerate?: () => void
onDelete?: () => void
}) {
const src = videoSrc(video)
const playableSrc = src && video.status === "completed" ? src : ""
const poster = videoPoster(job, video)
const running = video.status === "queued" || video.status === "in_progress"
return (
<MediaAssetTile
kind="video"
src={src && video.status === "completed" ? src : undefined}
src={playableSrc || undefined}
poster={poster}
href={onSelect ? undefined : src || undefined}
href={playableSrc || undefined}
alt={`片段 ${shortId(video.id)}`}
label={`${shortId(video.id)} · ${video.model}`}
meta={video.status}
className={`shrink-0 bg-black/45 ${className}`}
objectFit="cover"
selected={selected}
onClick={onSelect}
title={`${video.model} · ${video.status}`}
title={playableSrc ? "点击打开视频预览" : `${video.model} · ${video.status}`}
bottom={<span className="block truncate rounded bg-black/70 px-1 py-0.5 text-center font-mono text-[9px] text-white/62">{running ? "生成中" : video.status === "failed" ? "失败" : shortId(video.id)}</span>}
topLeft={selected ? <span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-emerald-400 text-black"><Check className="h-3 w-3" /></span> : undefined}
topRight={running ? <Loader2 className="h-3 w-3 animate-spin text-cyan-100" /> : undefined}
actions={onRegenerate ? [{ key: "regen", label: "重生一个候选", icon: <RefreshCw className="h-3 w-3" />, onClick: onRegenerate, tone: "cyan" }] : []}
actions={[
...(playableSrc ? [{
key: "download",
label: "下载视频",
icon: <Download className="h-3 w-3" />,
onClick: () => downloadMedia(playableSrc, `skg-storyboard-${shortId(video.id)}.mp4`),
tone: "cyan" as const,
}] : []),
...(onRegenerate ? [{
key: "regen",
label: "重生一个候选",
icon: <RefreshCw className="h-3 w-3" />,
onClick: onRegenerate,
tone: "neutral" as const,
}] : []),
]}
actionsAlwaysVisible={!!playableSrc}
onDelete={onDelete}
deleteLabel="删除这个视频候选"
/>
@@ -7921,13 +7918,13 @@ function ComposeSummary({
<PanelRight className="h-4 w-4 text-rose-200" />
<div>
<div className="text-[13px] font-semibold text-white"></div>
<div className="text-[11px] text-white/40">广</div>
<div className="text-[11px] text-white/40">广</div>
</div>
</div>
<div className="flex items-center gap-2 text-[11px] text-white/52">
<Requirement label="音频" ready={audioReady} detail={audioReady ? "已生成" : "待解析"} />
<Requirement label="候选" ready={generatedVideoCount > 0} detail={`${generatedVideoCount}`} />
<Requirement label="已选" ready={selectedVideoCount > 0} detail={`${selectedVideoCount}`} />
<Requirement label="自动存" ready={generatedVideoCount > 0} detail={generatedVideoCount > 0 ? "已保存" : "待候选"} />
<button type="button" disabled className="inline-flex h-10 cursor-not-allowed items-center justify-center gap-2 rounded-md border border-white/10 bg-white/[0.04] px-3 text-[12px] font-semibold text-white/34">
<Film className="h-4 w-4" />
@@ -8177,19 +8174,31 @@ function VideoCandidate({
const src = videoSrc(video)
const poster = videoPoster(job, video)
const running = video.status === "queued" || video.status === "in_progress"
const playableSrc = src && video.status === "completed" ? src : ""
const thumb = (
<>
{playableSrc ? (
<video src={playableSrc} poster={poster} muted playsInline className="h-full w-full object-cover" />
) : poster ? (
<img src={poster} alt={`片段 ${shortId(video.id)}`} className="h-full w-full object-cover opacity-80" />
) : (
<div className="flex h-full w-full items-center justify-center text-white/30"><Film className="h-4 w-4" /></div>
)}
<div className="absolute right-1 top-1 rounded-full bg-black/70 p-0.5">{selected ? <Check className="h-3 w-3 text-rose-200" /> : <Circle className="h-3 w-3 text-white/55" />}</div>
</>
)
return (
<div className={`rounded-lg border p-2 transition ${selected ? "border-rose-400/70 bg-rose-500/10" : "border-white/10 bg-black/30"}`}>
<div className="flex gap-2">
<button type="button" onClick={onToggle} className="relative h-24 w-14 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black">
{src && video.status === "completed" ? (
<video src={src} poster={poster} muted playsInline className="h-full w-full object-cover" />
) : poster ? (
<img src={poster} alt={`片段 ${shortId(video.id)}`} className="h-full w-full object-cover opacity-80" />
) : (
<div className="flex h-full w-full items-center justify-center text-white/30"><Film className="h-4 w-4" /></div>
)}
<div className="absolute right-1 top-1 rounded-full bg-black/70 p-0.5">{selected ? <Check className="h-3 w-3 text-rose-200" /> : <Circle className="h-3 w-3 text-white/55" />}</div>
</button>
{playableSrc ? (
<a href={playableSrc} target="_blank" rel="noreferrer" className="relative h-24 w-14 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black" title="打开视频预览">
{thumb}
</a>
) : (
<div className="relative h-24 w-14 shrink-0 overflow-hidden rounded-md border border-white/10 bg-black">
{thumb}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="truncate font-mono text-[12px] text-white/80">{shortId(video.id)} · {video.model}</div>
@@ -8204,11 +8213,17 @@ function VideoCandidate({
<span>{video.progress}%</span>
</div>
{video.error && <div className="mt-1 line-clamp-2 text-[11px] text-rose-200/80">{video.error}</div>}
{src && video.status === "completed" && (
<a href={src} target="_blank" rel="noreferrer" className="mt-2 inline-flex items-center gap-1 text-[11px] font-medium text-cyan-200 hover:text-cyan-100">
<Play className="h-3 w-3" />
</a>
{playableSrc && (
<div className="mt-2 flex flex-wrap items-center gap-2">
<a href={playableSrc} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 text-[11px] font-medium text-cyan-200 hover:text-cyan-100">
<Play className="h-3 w-3" />
</a>
<a href={playableSrc} download={`skg-storyboard-${shortId(video.id)}.mp4`} className="inline-flex items-center gap-1 text-[11px] font-medium text-emerald-200 hover:text-emerald-100">
<Download className="h-3 w-3" />
</a>
</div>
)}
</div>
</div>

View File

@@ -319,7 +319,7 @@ export function AudioStrip({ job, open, onClose }: { job: Job | null; open: bool
</div>
) : (
<div className="flex h-full items-center justify-center rounded-lg border border-dashed border-white/12 text-[12px] text-white/45">
</div>
)}
</div>

View File

@@ -46,6 +46,7 @@ type MediaAssetTileProps = {
deleting?: boolean
deleteDisabled?: boolean
actions?: MediaAssetAction[]
actionsAlwaysVisible?: boolean
disablePreview?: boolean
}
@@ -106,6 +107,7 @@ export function MediaAssetTile({
deleting = false,
deleteDisabled = false,
actions = [],
actionsAlwaysVisible = false,
disablePreview = false,
}: MediaAssetTileProps) {
const [position, setPosition] = useState<{ left: number; top: number; width: number } | null>(null)
@@ -200,7 +202,7 @@ export function MediaAssetTile({
{topRight ? <div className="pointer-events-none absolute right-1 top-1 z-10">{topRight}</div> : null}
{bottom ? <div className="pointer-events-none absolute bottom-1 left-1 right-1 z-10">{bottom}</div> : null}
{(actions.length || onDelete) ? (
<div className="absolute right-1 top-1 z-20 flex flex-col gap-0.5 opacity-0 transition group-hover:opacity-100 group-focus-within:opacity-100">
<div className={`absolute right-1 top-1 z-20 flex flex-col gap-0.5 transition ${actionsAlwaysVisible ? "opacity-100" : "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100"}`}>
{actions.map((action) => (
<button
key={action.key}

View File

@@ -2017,7 +2017,7 @@ export function ASRNode({ data, selected }: any) {
onTogglePin={() => d.onToggleNodePin?.("asr")}
>
<div className="text-[11.5px] text-[var(--text-soft)]">
OpenAI-compatible ASR ·
OpenAI-compatible ASR ·
</div>
{d.job && d.job.transcript.length > 0 && (
<div className="mt-2 max-h-24 overflow-y-auto text-[11px] space-y-1 text-[var(--text-strong)]">