33 Commits

Author SHA1 Message Date
04a822ac79 feat: add feishu multi-user auth 2026-05-24 00:31:06 +08:00
90dde14ac3 chore: ignore local verification artifacts 2026-05-24 00:00:58 +08:00
3146266383 feat: redesign creative studio entry 2026-05-23 23:55:05 +08:00
0e55945352 auto-save 2026-05-23 23:45 (~2) 2026-05-23 23:45:06 +08:00
d551c45006 auto-save 2026-05-23 23:36 (~2) 2026-05-23 23:39:41 +08:00
0d5c32630f docs: record multilingual asr deployment 2026-05-22 09:30:59 +08:00
7ae92ae4d9 auto-save 2026-05-22 09:27 (~2) 2026-05-22 09:27:54 +08:00
642793500f fix: support multilingual audio transcription 2026-05-22 09:26:59 +08:00
eb4347a843 fix: clarify storyboard videos auto-save 2026-05-21 17:44:40 +08:00
4efb2ce456 fix: make storyboard video clicks previewable 2026-05-21 17:29:16 +08:00
cc12d7c6a7 auto-save 2026-05-21 17:16 (~2) 2026-05-21 17:16:54 +08:00
77d23a06b3 feat: add one-click agent cut terminal 2026-05-21 16:14:58 +08:00
775ad79661 auto-save 2026-05-21 16:05 (~2) 2026-05-21 16:05:52 +08:00
a3ddb05424 auto-save 2026-05-21 15:43 (+1, ~4) 2026-05-21 15:43:13 +08:00
02a9999d8c auto-save 2026-05-21 15:25 (~2) 2026-05-21 15:26:00 +08:00
b6fec10371 auto-save 2026-05-21 15:20 (~2) 2026-05-21 15:20:33 +08:00
7bb4f3ea9f auto-save 2026-05-21 15:15 (+2, ~3) 2026-05-21 15:15:07 +08:00
b82dad4aa8 auto-save 2026-05-21 15:09 (+1, ~3) 2026-05-21 15:09:40 +08:00
68ecc8b97b docs: record production deploy for workbench header 2026-05-21 15:06:46 +08:00
8458dac4bf fix: update workbench header copy 2026-05-21 13:36:40 +08:00
5c47ea37c9 fix: stretch workbench rail height 2026-05-21 13:10:10 +08:00
36da23beb2 fix: refine glass workbench rail and theme colors 2026-05-21 12:37:27 +08:00
a48c2965d9 fix: extend transcript timeline height 2026-05-21 12:10:10 +08:00
d83e56169d fix: make material intake a rail drawer 2026-05-21 11:41:36 +08:00
8421af2af8 fix: place material intake inside rail strip 2026-05-21 11:17:50 +08:00
f2655e1418 fix: nest material intake inside workbench rail 2026-05-21 11:04:35 +08:00
def4900c1d feat: move material intake into sidebar 2026-05-21 10:51:24 +08:00
c805012adc feat: restyle workbench with glassmorphism shell 2026-05-21 10:37:54 +08:00
536b4d7f59 auto-save 2026-05-21 02:09 (~2) 2026-05-21 02:09:07 +08:00
1f193e95f3 docs: record fluid board layout deployment 2026-05-20 23:20:23 +08:00
6597db312b fix: remove fixed board canvas scaling 2026-05-20 23:10:03 +08:00
dbedabaae4 docs: record flexible conversion layout deployment 2026-05-20 22:56:47 +08:00
2b842fd106 fix: remove fixed conversion layer height 2026-05-20 22:51:53 +08:00
26 changed files with 5224 additions and 3753 deletions

2
.gitignore vendored
View File

@@ -17,6 +17,7 @@ __pycache__/
deploy/.env.production
deploy/.htpasswd
secrets/
.backups/
# api
api/.venv/
@@ -26,6 +27,7 @@ asset_library/*
prompt_library/*
!prompt_library/.gitkeep
_trash/
output/
# web
web/.next/

View File

@@ -1,6 +1,6 @@
# 项目接力
- 生成时间May 20, 2026 at 16:25
- 生成时间May 23, 2026 at 23:37
- 项目SKG Marketing Studio / SKG 营销内容工作台
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 状态active
@@ -9,7 +9,7 @@
## 最近助手会话概览
- Claudea9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
- Codex019e3db1-012e-7163-bc78-acf7cde326e7 · 时间未知
- Codex019e4d33-ad65-7673-934e-815226984ea6 · 时间未知
- Cursor未找到匹配当前项目的最近会话
## Claude 最近会话
@@ -92,27 +92,39 @@
## Codex 最近会话
- Session ID019e3db1-012e-7163-bc78-acf7cde326e7
- Transcript/Users/kangwan/.codex/sessions/2026/05/19/rollout-2026-05-19T08-44-30-019e3db1-012e-7163-bc78-acf7cde326e7.jsonl
- Session ID019e4d33-ad65-7673-934e-815226984ea6
- Transcript/Users/kangwan/.codex/sessions/2026/05/22/rollout-2026-05-22T09-01-32-019e4d33-ad65-7673-934e-815226984ea6.jsonl
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 分支main
- 敏感字段:已对 token / key / password / secret 做脱敏
### 最近用户要求
- 我之前的案例呢 我更新一次就清空了么
- 怎么避免这样的事情出现,我都快炸了
- 我要开新的窗口 继续这个项目了 你帮我打开吧
- 音频解析 也需需要有中文识别 和多语言识别
- 脚本那边是自动识别语音识别的内容然后生成脚本文案么?
- 分镜那边
### Codex 最近回复
> 这个旧路径不存在,我查一下当前机器上工作看板实际装在哪里,然后打开它
> 对,问题聚焦在分镜这边就更清楚了:我已经看了分镜面板当前实现,下面按“现在实际怎么跑”说
> 已打开工作看板
> 分镜这边现在是“半自动”,不是完全端到端自动重写整片脚本
>
> 新窗口继续这个项目时,项目根目录用
> 当前链路是
>
> `/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证`
> 1. 音频解析完成后,`job.transcript` 里有逐句时间轴:原语言文案 + 中文镜像。
> 2. 分镜面板会自动按每个 transcript 段生成一行分镜,保留原音频时间点和原文案依据。
> 3. 每行会先自动套默认三字段:
> - `文案`
> - `场景一句话`
> - `人物 + 产品 + 动作`
> 4. 这些默认值目前主要是按段落位置和角色分类生成,比如 hook / pain / proof / solution / cta不是每句都先让大模型深度理解原文后重写。
> 5. 当你点单行生成视频候选或同步规划时,才会走 `quickPlanStoryboard`,把当前三字段、参考帧、产品信息扩成完整分镜 prompt。
> 6. 你改中文字段后,系统会自动优化对应英文主字段,英文用于后续生视频。
>
> 所以准确说:分镜会自动读取语音识别结果来切分时间轴、显示原文依据、初始化分镜行;但“根据识别内容自动生成更贴合原视频语义的新脚本文案”这层现在还不够强,偏模板化。
>
> 如果你想要的效果是:音频一识别完,分镜区就自动根据原音频内容生成每句对应的新 SKG 脚本文案那需要再补一层“transcript -> 三字段候选”的自动脚本生成逻辑。这个方向是合理的。
## Cursor 最近会话
@@ -121,8 +133,8 @@
## 当前仓库状态
- 当前分支main
- 未提交变更:8
- 最近提交docs: record image timeout deployment
- 未提交变更:1
- 最近提交docs: record multilingual asr deployment
- 变更文件:
- M .memory/worklog.json

File diff suppressed because it is too large Load Diff

View File

@@ -33,15 +33,21 @@
"type" : "api_key"
},
{
"description" : "生产网页登录;用户名写 RULES.md密码只放服务器 \/root\/skg-marketing-studio-login.txt后端会话密钥只放服务器 deploy\/.env.production 的 WEB_AUTH_SESSION_SECRET",
"description" : "生产网页登录备用账号;飞书免登录为主路径,备用账号密码只放服务器 \/root\/skg-marketing-studio-login.txt后端会话密钥只放服务器 deploy\/.env.production 的 WEB_AUTH_SESSION_SECRET",
"name" : "WEB_LOGIN",
"storage" : "\/root\/skg-marketing-studio-login.txt \/ deploy\/.env.production",
"type" : "web_login"
},
{
"description" : "飞书免登录 OAuth 应用配置App ID 和 App Secret 只放服务器 deploy\/.env.production本地开发放 api\/.env不入库回调地址为 https:\/\/marketing.skg.com\/api\/auth\/feishu\/callback",
"name" : "FEISHU_OAUTH",
"storage" : "api\/.env \/ deploy\/.env.production \/ 飞书开放平台",
"type" : "oauth_app"
}
],
"description" : "SKG 信息流广告快速复刻工作台:粘贴 TK 链接或上传视频后点击开始,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频路提取原文案\/字幕、中文翻译、讲话人、语速节奏、背景音乐\/环境声\/音效;视觉路自动抽 12 张动作\/节奏参考帧,转换层按真人重构、卡通重构、元素重构、自主描述四个方向生成全新主体 6 视图,再汇合产品素材池、分镜口播和视频候选生成。",
"kind" : "app",
"name" : "SKG Marketing Studio \/ SKG 营销内容工作台",
"name" : "SKG 营销内容工作台",
"ownership" : "company",
"pin_order" : 1778664997,
"pinned" : true,
@@ -58,10 +64,10 @@
}
],
"quick_login" : {
"label" : "SKG Marketing Studio \/ SKG 营销内容工作台",
"password" : "c413cdc5bbbf2ca042",
"url" : "https:\/\/marketing.skg.com",
"username" : "skg"
"label" : "SKG 营销内容工作台",
"password" : "",
"url" : "https:\/\/marketing.skg.com\/login\/",
"username" : "飞书免登录;备用账号见 credentials.WEB_LOGIN"
},
"stack" : [
"Next.js + Python(yt-dlp\/ffmpeg) + OpenAI-compatible LLM + GPT Image 2 + Azure OpenAI TTS + Seedance\/Kling\/Veo video gateway"
@@ -78,6 +84,11 @@
"type" : "backend",
"url" : "https:\/\/marketing.skg.com\/api"
},
{
"label" : "agent-cut-preview",
"type" : "app",
"url" : "http:\/\/2.24.28.41:4290\/agent\/"
},
{
"label" : "git",
"type" : "repo",

View File

@@ -1,4 +1,4 @@
# SKG AI 素材管线 - TK 二创验证 Agent Rules
# SKG 营销内容工作台 Agent Rules
## Must Read First

View File

@@ -1,4 +1,4 @@
# SKG AI 素材管线 - TK 二创验证
# SKG 营销内容工作台
## 启动
- 后台启动(不弹 Terminal`./scripts/start-dev-background.sh`(通过 macOS launchd 后台托管;前端 4290 + 后端 4291日志写入 `.logs/`
@@ -11,11 +11,17 @@
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`
- 第一冲刺:步骤 1-4下载 / 拆轨 / 关键帧 / ASR+翻译)
- 当前产品方向2026-05-20 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”;工作台 1800x1000 为基准操作画布不同显示器或浏览器宽度下保持同一框架并按常见桌面宽度落到预设缩放档位0.72/0.76/0.8/0.86/0.92/1/1.06/1.16/1.24/1.34/1.48/1.6),保留适度左右呼吸感,缩放后高度小于视口时上下居中,必要时允许纵向滚动,不通过 `xl/2xl` 断点重排核心操作区。用户粘贴 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
- 发布状态已部署并验证2026-05-20主体元素按套图文件夹分组展示主体生成接口提交后立即返回 queued 占位并后台逐视角生成、逐张回填;源视频工作区主体链路为上方竖向参考帧池 + 宽幅对话式转换层、下方主体元素结果栏;转换层通过参考帧 `+` 加入、参考图分析、生图对话,英文 prompt 就绪后由发送区主按钮切换为确认生成,点击后才触发主体套图生成;下方主体元素结果栏的套图输出、轮询、文件夹分组、单张重生、删除和 hover 预览逻辑保持不变;胶片双击/拖拽加入参考帧池 + 胶片缓存复用 + 音频解析失败可重试,参考帧缩略图保持小尺寸 9:16 比例 + hover 左侧紧凑预览,旧主体模板区移出主路径 + 逐句时间轴移到原版视频下方并支持双行文案 + 波形同框时间对齐画面胶片 + 胶片密度按钮上移波形顶部 + 去分隔线 + 胶片上下错落 + body 顶层原位大放大 + 隐藏源视频工作区音频解析摘要卡 + 隐藏工作区顶部状态提示条 + 三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401认证后首页 200容器内 `/health` 返回 `ok:true`
- 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底部仍由发送区主按钮确认生成。
- 最近部署验证2026-05-20`ab31a98` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520144227.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/5bbecb6cf31316cb.js` 已包含 `h-40``提示词就绪``确认生成`,对应生成要求输入框加高到 160px出图提示词生成后不再自动弹窗底部主按钮直接切换为确认生成。
- 最近部署验证2026-05-20`215987a` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520142849.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/54e1ee55c5019be8.js` 已包含 `height:640`,对应转换层固定高度从 560px 扩到 640px。
- 最近部署验证2026-05-20`e1e9bf8` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520142145.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200``web:/api/health 401``api:health ok`)。线上登录后 Playwright 以 2048x1060 复测生成要求 composer文本输入区实际高约 119px张数控件和发送按钮实际高约 42px页面无客户端异常验证截图 `/tmp/skg-generation-composer-expanded.png`
@@ -54,19 +60,21 @@
- 服务器目录:`/opt/skg-marketing-studio`
- 生产部署唯一入口:`./scripts/deploy-prod-safe.sh`(先在服务器备份 `deploy/.env.production``data/jobs`、资源库和 `secrets`,再用受保护 rsync 同步代码,最后 Docker 重建并运行 `verify-prod-docker.sh`
- 生产容器重建命令:`docker compose -f docker-compose.prod.yml --env-file deploy/.env.production up -d --build`;只允许脚本内部或明确只重启容器时使用,不允许再用裸 `rsync --delete` 手动同步。
- 独立预览容器重建命令:服务器 `/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`
- 登录凭证:生产入口以飞书免登录为主;飞书 OAuth 的 `FEISHU_APP_ID` / `FEISHU_APP_SECRET` 只放服务器 `deploy/.env.production`,回调地址固定为 `https://marketing.skg.com/api/auth/feishu/callback` 并需要在飞书开放平台应用安全设置中登记。原账号密码登录保留为备用入口,用户名写下方快捷登录密码明文备份只放服务器 `/root/skg-marketing-studio-login.txt``WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET` 只放服务器 `deploy/.env.production`。开启 `AUTH_DATA_ISOLATION_ENABLED=true` 后,新建任务、素材任务和一键出片记录按登录用户隔离;历史无 owner 的旧任务只对备用账号可见,飞书用户互不可见。
- 禁止手动裸 `rsync --delete` 到服务器;必须使用 `./scripts/deploy-prod-safe.sh`。如遇极端情况必须手动同步,命令必须同时包含 protect/exclude`.git``.memory``.logs``.pids``data``jobs``secrets``api/jobs``api/.env``api/.env.local``api/.env.production``deploy/.env.production``web/node_modules``web/.next``web/out`。不要把本地 `api/.env``deploy/.env.production` 覆盖到 `/opt/skg-marketing-studio`,也不要删除服务器 `data/jobs`,否则会清空案例、登录和模型配置。
## 快捷登录
- 登录地址:`https://marketing.skg.com/login/`
- 用户名:`skg`
- 密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库)
- 说明当前是生产入口应用内登录页数据库密码、API Key、服务器 root 密码不要写这里
- 主路径:飞书免登录
- 备用用户名:`skg`
- 备用密码:见服务器 `/root/skg-marketing-studio-login.txt`(不入库)
- 说明:当前是生产入口应用内登录页;飞书 App Secret、数据库密码、API Key、服务器 root 密码不要写这里
## 元数据回写清单
- 新增或变更公网地址后,必须同步更新 `.project.json.urls`
@@ -88,11 +96,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` 不可用时生成真实逐句时间轴
@@ -114,7 +122,12 @@
- `AZURE_OPENAI_BASE_URL` / `AZURE_OPENAI_API_KEY`:微软 Azure OpenAI 协议配音网关;本地未单独配置 Key 时回退复用 `LLM_API_KEY`
- `AZURE_TTS_MODEL` / `AZURE_TTS_VOICE_ID` / `AZURE_TTS_VOICE_POOL` / `AZURE_TTS_PATH` / `AZURE_TTS_PATHS`Azure OpenAI TTS 模型、默认音色、音色池和 OpenAI 协议语音路径;后端会按 `AZURE_TTS_PATHS` 依次尝试,便于区分路径不对和整条语音服务不可用
- `POE_API_KEY` / `VIDEO_API_KEY`:视频生成通道 Key只能放本地环境变量
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库
- `WEB_AUTH_USERNAME` / `WEB_AUTH_PASSWORD` / `WEB_AUTH_SESSION_SECRET`:生产备用网页登录和会话签名配置;密码和 session secret 只放服务器环境变量,不入库。即使只开飞书免登录,也必须配置 `WEB_AUTH_SESSION_SECRET` 用于签名会话 Cookie。
- `FEISHU_APP_ID` / `FEISHU_APP_SECRET`:飞书免登录 OAuth 应用凭证;只放服务器 `deploy/.env.production` 或本地 `api/.env`,不入库。
- `FEISHU_REDIRECT_URI`:飞书 OAuth 回调地址,生产固定为 `https://marketing.skg.com/api/auth/feishu/callback`
- `FEISHU_OAUTH_SCOPE`:飞书 OAuth 授权范围;默认空值,按飞书应用后台已开权限执行。
- `FEISHU_ALLOWED_EMAIL_DOMAINS` / `FEISHU_ALLOWED_EMAILS` / `FEISHU_ALLOWED_TENANT_KEYS`:可选飞书账号白名单;留空时由飞书应用可见范围控制。
- `AUTH_DATA_ISOLATION_ENABLED`:多用户数据隔离开关,生产保持 `true`;新建 `Job` / `AgentRun` 会写入当前登录用户 owner列表和详情访问只返回本人数据。
- `FFMPEG_BIN` / `FFPROBE_BIN`:可选本地媒体二进制路径;本机 Homebrew ffmpeg 动态库损坏时,后端会自动跳过不可用的 PATH 版本并尝试本机静态 ffmpeg 备选,生产仍建议使用系统 ffmpeg/ffprobe
- 生产环境变量:服务器只使用 `deploy/.env.production`,模板为 `deploy/.env.production.example`;真实 Key 不入库
- 同步生产代码时必须排除服务器真实 `deploy/.env.production`,只同步 `deploy/.env.production.example`网页登录密码、session secret、ASR/API Key 只保留在服务器环境文件和 `/root/skg-marketing-studio-login.txt`

View File

@@ -8,6 +8,16 @@ WEB_AUTH_PASSWORD=
WEB_AUTH_SESSION_SECRET=
WEB_AUTH_COOKIE_NAME=skg_marketing_session
WEB_AUTH_COOKIE_SECURE=false
AUTH_DATA_ISOLATION_ENABLED=true
# 飞书免登录OAuth。生产回调地址需同步配置到飞书开放平台应用安全设置。
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_REDIRECT_URI=https://marketing.skg.com/api/auth/feishu/callback
FEISHU_OAUTH_SCOPE=
FEISHU_ALLOWED_EMAIL_DOMAINS=
FEISHU_ALLOWED_EMAILS=
FEISHU_ALLOWED_TENANT_KEYS=
# 模型分工
ASR_MODEL=whisper-1

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

@@ -0,0 +1,3 @@
{
"items": []
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
{
"items": []
}

View File

@@ -15,6 +15,17 @@ WEB_AUTH_PASSWORD=
WEB_AUTH_SESSION_SECRET=
WEB_AUTH_COOKIE_NAME=skg_marketing_session
WEB_AUTH_COOKIE_SECURE=true
AUTH_DATA_ISOLATION_ENABLED=true
# Feishu OAuth login. Register this callback in the Feishu developer console:
# https://marketing.skg.com/api/auth/feishu/callback
FEISHU_APP_ID=
FEISHU_APP_SECRET=
FEISHU_REDIRECT_URI=https://marketing.skg.com/api/auth/feishu/callback
FEISHU_OAUTH_SCOPE=
FEISHU_ALLOWED_EMAIL_DOMAINS=
FEISHU_ALLOWED_EMAILS=
FEISHU_ALLOWED_TENANT_KEYS=
# SKG AI gateway, OpenAI-compatible
LLM_BASE_URL=https://ai.skg.com/ezlink/v1

View File

@@ -20,6 +20,20 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
location /api/auth/ {
proxy_pass http://skg-marketing-api:4291/auth/;
proxy_http_version 1.1;
proxy_request_buffering off;
proxy_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_connect_timeout 60s;
}
location = /api/auth/login {
proxy_pass http://skg-marketing-api:4291/auth/login;
proxy_http_version 1.1;

View File

@@ -10,11 +10,13 @@ services:
- ./deploy/.env.production
environment:
JOBS_DIR: /data/jobs
AGENT_RUNS_DIR: /data/agent_runs
ASSET_LIBRARY_DIR: /data/asset_library
PROMPT_LIBRARY_DIR: /data/prompt_library
CORS_ORIGINS: https://marketing.skg.com
volumes:
- ./data/jobs:/data/jobs
- ./data/agent_runs:/data/agent_runs
- ./data/asset_library:/data/asset_library
- ./data/prompt_library:/data/prompt_library
- ./data/_trash:/data/_trash

View File

@@ -0,0 +1,47 @@
name: skg-agent-cut
services:
api:
build:
context: .
dockerfile: Dockerfile.api
container_name: skg-agent-api
env_file:
- ./deploy/.env.production
environment:
JOBS_DIR: /data/jobs
AGENT_RUNS_DIR: /data/agent_runs
ASSET_LIBRARY_DIR: /data/asset_library
PROMPT_LIBRARY_DIR: /data/prompt_library
CORS_ORIGINS: http://2.24.28.41:4290,http://localhost:4290
volumes:
- ./data/jobs:/data/jobs
- ./data/agent_runs:/data/agent_runs
- ./data/asset_library:/data/asset_library
- ./data/prompt_library:/data/prompt_library
- ./data/_trash:/data/_trash
- ./secrets/tiktok_cookies.txt:/run/secrets/tiktok_cookies.txt
restart: unless-stopped
networks:
skg-agent-internal:
aliases:
- skg-marketing-api
web:
build:
context: .
dockerfile: Dockerfile.web
args:
NEXT_PUBLIC_API_BASE: /api
container_name: skg-agent-web
depends_on:
- api
ports:
- "0.0.0.0:4290:80"
restart: unless-stopped
networks:
- skg-agent-internal
networks:
skg-agent-internal:
name: skg-agent-internal

File diff suppressed because one or more lines are too long

332
web/app/agent/page.tsx Normal file
View File

@@ -0,0 +1,332 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import {
ArrowDownToLine,
CheckCircle2,
CircleAlert,
Film,
ImagePlus,
Link2,
Loader2,
Play,
RotateCcw,
TerminalSquare,
Upload,
} from "lucide-react"
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:4291"
type AgentRunLog = {
ts: number
level: "info" | "warn" | "error"
message: string
}
type AgentRun = {
id: string
job_id: string
status: "draft" | "queued" | "executing" | "reviewing" | "completed" | "failed"
stage: string
progress: number
logs: AgentRunLog[]
video_ids: string[]
final_video_url: string
contact_sheet_url: string
error: string
created_at: number
updated_at: number
}
const STAGES = [
{ key: "download", label: "下载" },
{ key: "assets", label: "素材" },
{ key: "analyze", label: "拆解" },
{ key: "plan", label: "规划" },
{ key: "execute", label: "生成" },
{ key: "review", label: "审片" },
{ key: "compose", label: "合成" },
{ key: "final", label: "成片" },
]
function formatClock(ts: number) {
if (!ts) return "--:--:--"
return new Date(ts * 1000).toLocaleTimeString("zh-CN", { hour12: false })
}
function runVideoUrl(run: AgentRun | null) {
if (!run?.final_video_url) return ""
return `${API_BASE}${run.final_video_url}`
}
function runContactUrl(run: AgentRun | null) {
if (!run?.contact_sheet_url) return ""
return `${API_BASE}${run.contact_sheet_url}`
}
export default function AgentPage() {
const [url, setUrl] = useState("")
const [files, setFiles] = useState<File[]>([])
const [run, setRun] = useState<AgentRun | null>(null)
const [recent, setRecent] = useState<AgentRun[]>([])
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState("")
const terminalRef = useRef<HTMLDivElement>(null)
const previews = useMemo(() => files.map((file) => ({ file, url: URL.createObjectURL(file) })), [files])
useEffect(() => () => previews.forEach((item) => URL.revokeObjectURL(item.url)), [previews])
useEffect(() => {
fetch(`${API_BASE}/agent-runs?limit=8`, { cache: "no-store" })
.then((res) => (res.ok ? res.json() : []))
.then((items: AgentRun[]) => {
setRecent(items)
const latest = items.find((item) => item.status === "executing" || item.status === "reviewing" || item.status === "completed")
if (latest) setRun(latest)
})
.catch(() => undefined)
}, [])
useEffect(() => {
if (!run || run.status === "completed" || run.status === "failed") return
const timer = window.setInterval(async () => {
try {
const res = await fetch(`${API_BASE}/agent-runs/${run.id}`, { cache: "no-store" })
if (!res.ok) return
const next = await res.json()
setRun(next)
} catch {
/* keep current state */
}
}, 2000)
return () => window.clearInterval(timer)
}, [run?.id, run?.status])
useEffect(() => {
const el = terminalRef.current
if (el) el.scrollTop = el.scrollHeight
}, [run?.logs.length])
async function submit() {
setError("")
if (!url.trim()) {
setError("需要 TikTok 链接")
return
}
setSubmitting(true)
try {
const form = new FormData()
form.append("tk_url", url.trim())
files.slice(0, 6).forEach((file) => form.append("product_files", file))
const res = await fetch(`${API_BASE}/agent-runs`, { method: "POST", body: form })
if (!res.ok) {
const text = await res.text().catch(() => "")
throw new Error(text.slice(0, 260) || `HTTP ${res.status}`)
}
const created = await res.json()
setRun(created)
setRecent((prev) => [created, ...prev.filter((item) => item.id !== created.id)].slice(0, 8))
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSubmitting(false)
}
}
const activeStageIndex = run ? Math.max(0, STAGES.findIndex((item) => item.key === run.stage)) : -1
const canStart = !!url.trim() && !submitting
const videoSrc = runVideoUrl(run)
const contactSrc = runContactUrl(run)
return (
<main className="min-h-screen bg-[#f3f4f7] text-[#111318]">
<div className="mx-auto flex min-h-screen w-full max-w-[1720px] flex-col gap-5 px-5 py-5">
<header className="flex items-center justify-between rounded-[28px] border border-black/5 bg-white/80 px-5 py-4 shadow-[0_24px_80px_rgba(20,25,38,0.08)] backdrop-blur-xl">
<div>
<div className="text-[12px] font-semibold uppercase tracking-[0.18em] text-[#7b8190]">SKG Agent Cut</div>
<h1 className="mt-1 text-[26px] font-semibold tracking-normal text-[#111318]"></h1>
</div>
<div className="hidden items-center gap-2 rounded-full bg-[#111318] px-3 py-2 text-[12px] font-medium text-white md:flex">
<TerminalSquare className="h-4 w-4 text-[#81d4ff]" />
{run ? `${run.status} · ${run.progress}%` : "standby"}
</div>
</header>
<section className="grid min-h-[calc(100vh-128px)] grid-cols-1 gap-5 xl:grid-cols-[390px_minmax(520px,1fr)_420px]">
<aside className="flex flex-col gap-4 rounded-[30px] border border-black/5 bg-white/85 p-4 shadow-[0_24px_80px_rgba(20,25,38,0.08)] backdrop-blur-xl">
<div className="rounded-[24px] border border-[#dfe3ea] bg-[#f8f9fb] p-4">
<label className="mb-2 flex items-center gap-2 text-[13px] font-semibold text-[#2b3038]">
<Link2 className="h-4 w-4 text-[#0a84ff]" />
TikTok
</label>
<textarea
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://www.tiktok.com/@..."
className="h-28 w-full resize-none rounded-[18px] border border-[#d9dee8] bg-white px-4 py-3 text-[14px] leading-relaxed text-[#111318] outline-none transition focus:border-[#0a84ff] focus:ring-4 focus:ring-[#0a84ff]/10"
/>
</div>
<div className="rounded-[24px] border border-[#dfe3ea] bg-[#f8f9fb] p-4">
<label className="mb-3 flex items-center gap-2 text-[13px] font-semibold text-[#2b3038]">
<ImagePlus className="h-4 w-4 text-[#34c759]" />
</label>
<label className="flex h-32 cursor-pointer flex-col items-center justify-center rounded-[20px] border border-dashed border-[#c7ceda] bg-white text-center transition hover:border-[#0a84ff] hover:bg-[#f7fbff]">
<Upload className="mb-2 h-6 w-6 text-[#7b8190]" />
<span className="text-[13px] font-medium text-[#2b3038]"></span>
<span className="mt-1 text-[12px] text-[#7b8190]"> 6 </span>
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
const next = Array.from(e.target.files ?? []).slice(0, 6)
setFiles(next)
}}
/>
</label>
{previews.length > 0 && (
<div className="mt-3 grid grid-cols-3 gap-2">
{previews.map((item) => (
<div key={`${item.file.name}-${item.file.size}`} className="aspect-square overflow-hidden rounded-[14px] border border-black/5 bg-white">
<img src={item.url} alt={item.file.name} className="h-full w-full object-contain" />
</div>
))}
</div>
)}
</div>
{error && (
<div className="rounded-[18px] border border-[#ff453a]/20 bg-[#ff453a]/10 px-4 py-3 text-[13px] text-[#9f1d17]">
{error}
</div>
)}
<button
type="button"
disabled={!canStart}
onClick={submit}
className="flex h-14 items-center justify-center gap-2 rounded-[20px] bg-[#111318] text-[15px] font-semibold text-white shadow-[0_16px_40px_rgba(17,19,24,0.18)] transition hover:bg-black disabled:cursor-not-allowed disabled:bg-[#b8bec8]"
>
{submitting ? <Loader2 className="h-5 w-5 animate-spin" /> : <Play className="h-5 w-5" />}
</button>
<div className="mt-auto rounded-[24px] border border-[#dfe3ea] bg-[#f8f9fb] p-3">
<div className="mb-2 text-[12px] font-semibold text-[#7b8190]"></div>
<div className="space-y-2">
{recent.slice(0, 4).map((item) => (
<button
key={item.id}
type="button"
onClick={() => setRun(item)}
className="flex w-full items-center justify-between rounded-[16px] bg-white px-3 py-2 text-left text-[12px] text-[#2b3038] transition hover:bg-[#f1f5fb]"
>
<span className="font-medium">{item.id}</span>
<span className="text-[#7b8190]">{item.status}</span>
</button>
))}
</div>
</div>
</aside>
<section className="flex min-h-[680px] flex-col rounded-[30px] border border-black/5 bg-[#111318] p-4 shadow-[0_24px_80px_rgba(20,25,38,0.16)]">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-[16px] bg-white/8">
<TerminalSquare className="h-5 w-5 text-[#81d4ff]" />
</div>
<div>
<h2 className="text-[16px] font-semibold text-white">Agent Terminal</h2>
<p className="text-[12px] text-white/45">{run ? `run ${run.id} · job ${run.job_id}` : "waiting for input"}</p>
</div>
</div>
{run?.status === "failed" ? (
<CircleAlert className="h-5 w-5 text-[#ff453a]" />
) : run?.status === "completed" ? (
<CheckCircle2 className="h-5 w-5 text-[#34c759]" />
) : (
<Loader2 className={`h-5 w-5 text-[#81d4ff] ${run ? "animate-spin" : ""}`} />
)}
</div>
<div className="mb-4 grid grid-cols-4 gap-2 lg:grid-cols-8">
{STAGES.map((stage, index) => {
const active = index <= activeStageIndex || run?.status === "completed"
return (
<div key={stage.key} className={`rounded-[14px] px-3 py-2 text-[12px] ${active ? "bg-white text-[#111318]" : "bg-white/6 text-white/40"}`}>
{stage.label}
</div>
)
})}
</div>
<div className="mb-4 h-2 overflow-hidden rounded-full bg-white/8">
<div className="h-full rounded-full bg-[#34c759] transition-all duration-700" style={{ width: `${run?.progress ?? 0}%` }} />
</div>
<div ref={terminalRef} className="min-h-0 flex-1 overflow-auto rounded-[22px] border border-white/8 bg-black px-4 py-4 font-mono text-[12px] leading-relaxed text-[#d8f3dc]">
{!run && <div className="text-white/35">$ idle</div>}
{run?.logs.map((log, index) => (
<div key={`${log.ts}-${index}`} className={log.level === "error" ? "text-[#ff8a80]" : log.level === "warn" ? "text-[#ffd166]" : "text-[#d8f3dc]"}>
<span className="text-white/30">[{formatClock(log.ts)}]</span> {log.message}
</div>
))}
</div>
</section>
<aside className="flex flex-col gap-4 rounded-[30px] border border-black/5 bg-white/85 p-4 shadow-[0_24px_80px_rgba(20,25,38,0.08)] backdrop-blur-xl">
<div className="flex items-center justify-between">
<div>
<div className="text-[12px] font-semibold uppercase tracking-[0.16em] text-[#7b8190]">Final</div>
<h2 className="mt-1 text-[18px] font-semibold text-[#111318]"></h2>
</div>
<Film className="h-5 w-5 text-[#ff9f0a]" />
</div>
<div className="aspect-[9/16] overflow-hidden rounded-[26px] border border-black/8 bg-[#111318]">
{videoSrc ? (
<video key={videoSrc} src={videoSrc} controls playsInline className="h-full w-full bg-black object-contain" />
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 text-[#7b8190]">
<Film className="h-8 w-8" />
<span className="text-[13px]"></span>
</div>
)}
</div>
{contactSrc && (
<div className="overflow-hidden rounded-[18px] border border-black/8 bg-white">
<img src={contactSrc} alt="final contact sheet" className="w-full object-cover" />
</div>
)}
<div className="grid grid-cols-2 gap-2">
<a
href={videoSrc || undefined}
download
className={`flex h-11 items-center justify-center gap-2 rounded-[16px] text-[13px] font-semibold ${videoSrc ? "bg-[#0a84ff] text-white" : "pointer-events-none bg-[#dfe3ea] text-[#8d94a1]"}`}
>
<ArrowDownToLine className="h-4 w-4" />
</a>
<button
type="button"
onClick={() => {
setRun(null)
setError("")
}}
className="flex h-11 items-center justify-center gap-2 rounded-[16px] bg-[#eef1f6] text-[13px] font-semibold text-[#2b3038] transition hover:bg-[#e3e7ef]"
>
<RotateCcw className="h-4 w-4" />
</button>
</div>
</aside>
</section>
</div>
</main>
)
}

View File

@@ -469,29 +469,42 @@ nextjs-portal {
信息流工作台 · 登录页同源质感
============================================================ */
.skg-board-theme {
--skg-gold-1: #d6b36a;
--skg-gold-2: #c89b3c;
--skg-cream: #f5efe3;
--skg-bg-1: #0a0a0a;
--skg-bg-2: #111111;
--skg-bg-3: rgba(255, 255, 255, 0.035);
--skg-border: rgba(255, 255, 255, 0.1);
--skg-gold-1: #c8cd19;
--skg-gold-2: #a2c638;
--skg-cream: #f6f6ee;
--skg-bg-1: #1b1b1b;
--skg-bg-2: #242424;
--skg-bg-3: rgba(255, 255, 255, 0.1);
--skg-border: rgba(255, 255, 255, 0.14);
--skg-text-1: #ffffff;
--skg-text-2: rgba(255, 255, 255, 0.62);
--skg-text-3: rgba(255, 255, 255, 0.34);
--skg-success: #34d399;
--skg-warn: #fcd34d;
--skg-text-2: rgba(255, 255, 255, 0.56);
--skg-text-3: rgba(255, 255, 255, 0.36);
--skg-success: #a2c638;
--skg-warn: #c8cd19;
--skg-danger: #fb7185;
--skg-info: #67e8f9;
--skg-info: #a6d533;
--skg-radius-sm: 6px;
--skg-radius-md: 8px;
--skg-radius-lg: 12px;
--skg-shadow-button: 0 6px 24px -8px rgba(0, 0, 0, 0.45);
--skg-radius-lg: 20px;
--skg-shadow-button: 10px 10px 10px rgba(0, 0, 0, 0.3);
--skg-shadow-card: 10px 10px 10px rgba(0, 0, 0, 0.3);
--skg-glass-bg: rgba(255, 255, 255, 0.1);
--skg-glass-bg-soft: rgba(255, 255, 255, 0.055);
--skg-rail: #383838;
--skg-wave-bg: rgba(0, 0, 0, 0.35);
--skg-wave-fill: rgba(209, 213, 219, 0.74);
--skg-wave-stroke-1: rgba(229, 231, 235, 0.7);
--skg-wave-stroke-2: rgba(229, 231, 235, 0.52);
--skg-wave-grid: rgba(255, 255, 255, 0.14);
--skg-wave-marker: rgba(255, 255, 255, 0.12);
--skg-wave-hover: rgba(207, 250, 254, 0.7);
--skg-wave-playhead: #a7f3d0;
--skg-wave-playhead-shadow: rgba(110, 231, 183, 0.85);
color: var(--skg-text-1);
background:
radial-gradient(circle at 52% 4%, rgba(214, 179, 106, 0.1), transparent 30%),
radial-gradient(circle at 12% 96%, rgba(214, 179, 106, 0.065), transparent 34%),
linear-gradient(120deg, #0a0a0a 0%, #10100f 46%, #050505 100%);
radial-gradient(circle at 20% 18%, rgba(162, 198, 56, 0.09), transparent 28%),
radial-gradient(circle at 86% 78%, rgba(200, 205, 25, 0.1), transparent 28%),
linear-gradient(120deg, #171717 0%, #202020 48%, #101010 100%);
}
.skg-board-theme::before {
@@ -501,10 +514,10 @@ nextjs-portal {
z-index: 0;
pointer-events: none;
background:
linear-gradient(90deg, rgba(255, 255, 255, 0.026) 1px, transparent 1px),
linear-gradient(180deg, rgba(255, 255, 255, 0.022) 1px, transparent 1px);
background-size: 64px 64px;
opacity: 0.44;
linear-gradient(90deg, rgba(255, 255, 255, 0.018) 1px, transparent 1px),
linear-gradient(180deg, rgba(255, 255, 255, 0.016) 1px, transparent 1px);
background-size: 56px 56px;
opacity: 0.34;
}
.skg-board-theme::after {
@@ -514,39 +527,38 @@ nextjs-portal {
z-index: 0;
pointer-events: none;
background:
linear-gradient(180deg, rgba(0, 0, 0, 0.22), transparent 42%, rgba(0, 0, 0, 0.4)),
linear-gradient(90deg, rgba(0, 0, 0, 0.28), transparent 38%, rgba(0, 0, 0, 0.24));
linear-gradient(180deg, rgba(0, 0, 0, 0.2), transparent 45%, rgba(0, 0, 0, 0.42)),
linear-gradient(90deg, rgba(0, 0, 0, 0.38), transparent 36%, rgba(0, 0, 0, 0.22));
}
.skg-board-ambient {
background:
radial-gradient(circle at 78% 0%, rgba(232, 201, 122, 0.08), transparent 30%),
radial-gradient(circle at 8% 100%, rgba(214, 179, 106, 0.06), transparent 34%);
radial-gradient(circle at 72% 12%, rgba(162, 198, 56, 0.13), transparent 28%),
radial-gradient(circle at 18% 92%, rgba(200, 205, 25, 0.12), transparent 32%);
}
.skg-board-topbar,
.skg-board-panel {
border-color: var(--skg-border) !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0.022)),
rgba(17, 17, 17, 0.74) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.06),
0 18px 54px rgba(0, 0, 0, 0.34);
backdrop-filter: blur(10px);
radial-gradient(circle at 88% 22%, rgba(162, 198, 56, 0.06), transparent 38%),
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.032)),
rgba(30, 30, 30, 0.78) !important;
box-shadow: var(--skg-shadow-card);
backdrop-filter: blur(5px);
}
.skg-board-topbar {
background:
linear-gradient(100deg, rgba(214, 179, 106, 0.075), rgba(255, 255, 255, 0.03) 54%, rgba(214, 179, 106, 0.035)),
rgba(12, 12, 12, 0.76) !important;
linear-gradient(100deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.038) 58%, rgba(162, 198, 56, 0.07)),
rgba(28, 28, 28, 0.84) !important;
}
.skg-board-theme input:focus,
.skg-board-theme textarea:focus,
.skg-board-theme select:focus {
border-color: rgba(214, 179, 106, 0.58) !important;
box-shadow: 0 0 0 2px rgba(214, 179, 106, 0.14);
box-shadow: 0 0 0 2px rgba(162, 198, 56, 0.18);
}
.skg-board-theme input[type="checkbox"] {
@@ -558,23 +570,182 @@ nextjs-portal {
color: #fff;
}
.skg-board-shell {
min-height: calc(100vh - 32px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 24px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.026), rgba(255, 255, 255, 0.01)),
rgba(18, 18, 18, 0.72);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.38);
backdrop-filter: blur(8px);
}
.skg-board-rail {
width: 65px;
height: calc(100vh - 64px);
min-height: 600px;
max-height: calc(100vh - 32px);
align-self: flex-start;
top: 16px;
z-index: 30;
overflow: visible;
transition: width 220ms ease;
}
.skg-board-rail.is-open {
width: 397px;
}
.skg-board-rail__strip {
width: 65px;
height: 100%;
min-height: 600px;
border: 1px solid #383838;
border-radius: 0 70px 70px 0;
background:
radial-gradient(circle at 86% 18%, rgba(162, 198, 56, 0.1), transparent 28%),
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)),
#383838;
box-shadow: 10px 10px 10px rgba(0, 0, 0, 0.3);
overflow: hidden;
transition: width 220ms ease, border-radius 220ms ease;
}
.skg-board-rail.is-open .skg-board-rail__strip {
width: 397px;
border-radius: 0 34px 34px 0;
}
.skg-board-rail__iconbar {
width: 65px;
height: 100%;
}
.skg-board-rail__logo {
border: 2px solid rgba(255, 255, 255, 0.2);
background:
radial-gradient(circle at 68% 38%, #a2c638 0 34%, transparent 36%),
radial-gradient(circle at 50% 50%, #c8cd19 0 47%, transparent 49%),
#ffffff;
color: #ffffff;
}
.skg-board-rail__button {
color: rgba(255, 255, 255, 0.52);
transition: color 180ms ease, background 180ms ease, transform 180ms ease;
}
.skg-board-rail__button:hover,
.skg-board-rail__button:focus-visible,
.skg-board-rail__button.is-active {
color: #ffffff;
background: rgba(255, 255, 255, 0.08);
transform: translateX(2px);
}
.skg-board-rail__drawer {
height: 100%;
animation: skgRailDrawerIn 220ms ease both;
}
.skg-board-rail__drawer .skg-board-panel {
height: 100%;
min-height: 0 !important;
border-radius: 0 30px 30px 0;
}
@keyframes skgRailDrawerIn {
from {
opacity: 0;
transform: translateX(-12px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.skg-glass-card {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 20px;
background:
radial-gradient(circle at 80% 86%, rgba(162, 198, 56, 0.13), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.034)),
rgba(38, 38, 38, 0.76);
box-shadow: var(--skg-shadow-card);
backdrop-filter: blur(5px);
}
.skg-glass-card--flat {
border-radius: 16px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.022)),
rgba(18, 18, 18, 0.62);
}
.skg-audio-waveform {
border-color: rgba(255, 255, 255, 0.1) !important;
background: var(--skg-wave-bg) !important;
}
.skg-audio-waveform__center {
background: var(--skg-wave-grid);
}
.skg-audio-waveform__segment {
background: var(--skg-wave-marker);
}
.skg-audio-waveform__hover {
background: var(--skg-wave-hover);
}
.skg-audio-waveform__playhead {
background: var(--skg-wave-playhead);
box-shadow: 0 0 16px var(--skg-wave-playhead-shadow);
}
.skg-status-orb {
display: inline-flex;
align-items: center;
justify-content: center;
width: 50px;
height: 50px;
border: 5px solid rgba(255, 255, 255, 0.2);
border-radius: 999px;
background:
radial-gradient(circle at 78% 32%, #a2c638 0 12%, transparent 13%),
conic-gradient(from 40deg, #a2c638 0 74%, rgba(255, 255, 255, 0.22) 75% 100%);
color: #ffffff;
}
.skg-board-theme--light {
--skg-bg-1: #faf8f4;
--skg-bg-2: #ffffff;
--skg-bg-1: #f4f1e8;
--skg-bg-2: #fbfaf5;
--skg-bg-3: rgba(0, 0, 0, 0.03);
--skg-border: rgba(0, 0, 0, 0.08);
--skg-text-1: #0a0a0a;
--skg-text-2: rgba(0, 0, 0, 0.62);
--skg-text-3: rgba(0, 0, 0, 0.34);
--skg-border: rgba(72, 78, 56, 0.14);
--skg-text-1: #20241b;
--skg-text-2: rgba(32, 36, 27, 0.68);
--skg-text-3: rgba(32, 36, 27, 0.42);
--skg-success: #059669;
--skg-warn: #b7791f;
--skg-danger: #e11d48;
--skg-info: #0891b2;
--skg-wave-bg: rgba(255, 255, 255, 0.74);
--skg-wave-fill: rgba(80, 90, 70, 0.42);
--skg-wave-stroke-1: rgba(47, 57, 44, 0.46);
--skg-wave-stroke-2: rgba(47, 57, 44, 0.3);
--skg-wave-grid: rgba(72, 78, 56, 0.16);
--skg-wave-marker: rgba(72, 78, 56, 0.14);
--skg-wave-hover: rgba(23, 96, 111, 0.52);
--skg-wave-playhead: #10b981;
--skg-wave-playhead-shadow: rgba(16, 185, 129, 0.36);
color: var(--skg-text-1);
background:
radial-gradient(circle at 50% 0%, rgba(232, 212, 168, 0.18), transparent 31%),
radial-gradient(circle at 4% 100%, rgba(214, 179, 106, 0.12), transparent 28%),
linear-gradient(126deg, #faf8f4 0%, #f4efe5 48%, #ffffff 100%);
radial-gradient(circle at 50% 0%, rgba(232, 212, 168, 0.16), transparent 31%),
radial-gradient(circle at 4% 100%, rgba(143, 176, 113, 0.1), transparent 28%),
linear-gradient(126deg, #f5f2e9 0%, #ece7dc 48%, #fbfaf5 100%);
}
.skg-board-theme--light::before {
@@ -601,17 +772,50 @@ nextjs-portal {
.skg-board-theme--light .skg-board-panel {
border-color: rgba(82, 93, 62, 0.16) !important;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(255, 255, 255, 0.48)),
rgba(249, 246, 236, 0.7) !important;
radial-gradient(circle at 88% 18%, rgba(143, 176, 113, 0.12), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(249, 247, 239, 0.64)),
rgba(246, 243, 234, 0.84) !important;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.78),
0 18px 48px rgba(65, 55, 30, 0.1);
0 18px 48px rgba(65, 55, 30, 0.12);
}
.skg-board-theme--light .skg-board-topbar {
background:
linear-gradient(100deg, rgba(214, 179, 106, 0.14), rgba(143, 176, 113, 0.08) 42%, rgba(255, 255, 255, 0.58)),
rgba(252, 249, 241, 0.82) !important;
linear-gradient(100deg, rgba(214, 179, 106, 0.1), rgba(143, 176, 113, 0.07) 42%, rgba(255, 255, 255, 0.72)),
rgba(250, 248, 241, 0.9) !important;
}
.skg-board-theme--light .skg-board-shell {
border-color: rgba(72, 78, 56, 0.14);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(246, 243, 235, 0.62)),
rgba(246, 243, 235, 0.74);
box-shadow: 0 24px 70px rgba(74, 68, 44, 0.13);
}
.skg-board-theme--light .skg-board-rail__strip {
border-color: #3f4239;
background:
radial-gradient(circle at 86% 18%, rgba(162, 198, 56, 0.12), transparent 28%),
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)),
#3f4239;
box-shadow: 10px 10px 26px rgba(74, 68, 44, 0.18);
}
.skg-board-theme--light .skg-glass-card {
border-color: rgba(72, 78, 56, 0.16);
background:
radial-gradient(circle at 80% 86%, rgba(143, 176, 113, 0.12), transparent 36%),
linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(246, 243, 235, 0.64)),
rgba(250, 248, 241, 0.82);
box-shadow: 0 16px 42px rgba(74, 68, 44, 0.12);
}
.skg-board-theme--light .skg-glass-card--flat {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.7), rgba(246, 243, 235, 0.5)),
rgba(255, 255, 255, 0.54);
}
.skg-board-theme--light .skg-board-theme-toggle {
@@ -748,43 +952,82 @@ nextjs-portal {
}
.skg-stat-card {
border: 1px solid rgba(214, 179, 106, 0.18);
border-radius: var(--skg-radius-md);
background: var(--skg-cream);
color: #0a0a0a;
box-shadow: var(--skg-shadow-button);
--skg-stat-glow:
radial-gradient(circle at 92% 86%, rgba(162, 198, 56, 0.22), transparent 48%);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
position: relative;
isolation: isolate;
overflow: hidden;
background:
var(--skg-stat-glow),
linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.045)),
rgba(0, 0, 0, 0.16);
color: #ffffff;
box-shadow: 8px 8px 10px rgba(0, 0, 0, 0.22);
backdrop-filter: blur(5px);
}
.skg-stat-card--violet {
--skg-stat-glow:
radial-gradient(circle at 94% 82%, rgba(126, 87, 194, 0.74), transparent 48%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
}
.skg-stat-card--lime {
--skg-stat-glow:
radial-gradient(circle at 94% 84%, rgba(200, 205, 25, 0.72), transparent 48%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
}
.skg-stat-card--gold {
--skg-stat-glow:
radial-gradient(circle at 94% 84%, rgba(214, 179, 106, 0.7), transparent 48%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
}
.skg-stat-card--teal {
--skg-stat-glow:
radial-gradient(circle at 94% 84%, rgba(68, 162, 150, 0.7), transparent 48%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
}
.skg-stat-card--green {
--skg-stat-glow:
radial-gradient(circle at 94% 84%, rgba(84, 190, 104, 0.72), transparent 48%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.08), transparent 32%);
}
.skg-stat-card__label {
color: rgba(0, 0, 0, 0.5);
color: rgba(255, 255, 255, 0.48);
}
.skg-stat-card__value {
color: #0a0a0a;
color: #ffffff;
}
.skg-primary-action {
border-radius: var(--skg-radius-md);
background: #f5efe3;
color: #0a0a0a;
background: linear-gradient(135deg, #c8cd19, #a2c638);
color: #101010;
box-shadow: var(--skg-shadow-button);
}
.skg-primary-action:hover {
background: #fff7df;
background: linear-gradient(135deg, #d6db25, #b0d83d);
}
.skg-secondary-action {
border: 1px solid rgba(214, 179, 106, 0.3);
border: 1px solid rgba(255, 255, 255, 0.13);
border-radius: var(--skg-radius-md);
background: rgba(214, 179, 106, 0.08);
color: var(--skg-gold-1);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.76);
}
.skg-secondary-action:hover {
border-color: rgba(214, 179, 106, 0.54);
background: rgba(214, 179, 106, 0.12);
color: #f5d98e;
border-color: rgba(162, 198, 56, 0.44);
background: rgba(162, 198, 56, 0.11);
color: #ffffff;
}
.skg-empty-state {
@@ -826,27 +1069,75 @@ nextjs-portal {
}
.skg-board-theme--light .skg-stat-card {
background: #0a0a0a;
color: #fff;
box-shadow: 0 10px 24px -12px rgba(133, 96, 21, 0.38);
--skg-stat-glow:
radial-gradient(circle at 94% 84%, rgba(143, 176, 113, 0.2), transparent 50%);
border-color: rgba(72, 78, 56, 0.14);
background:
var(--skg-stat-glow),
linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(246, 243, 235, 0.58)),
rgba(255, 255, 255, 0.56);
color: #20241b;
box-shadow: 0 12px 28px rgba(74, 68, 44, 0.1);
}
.skg-board-theme--light .skg-stat-card--violet {
--skg-stat-glow:
radial-gradient(circle at 94% 84%, rgba(126, 87, 194, 0.22), transparent 50%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
}
.skg-board-theme--light .skg-stat-card--lime {
--skg-stat-glow:
radial-gradient(circle at 94% 84%, rgba(200, 205, 25, 0.28), transparent 50%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
}
.skg-board-theme--light .skg-stat-card--gold {
--skg-stat-glow:
radial-gradient(circle at 94% 84%, rgba(214, 179, 106, 0.26), transparent 50%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
}
.skg-board-theme--light .skg-stat-card--teal {
--skg-stat-glow:
radial-gradient(circle at 94% 84%, rgba(68, 162, 150, 0.24), transparent 50%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
}
.skg-board-theme--light .skg-stat-card--green {
--skg-stat-glow:
radial-gradient(circle at 94% 84%, rgba(84, 190, 104, 0.24), transparent 50%),
radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.5), transparent 34%);
}
.skg-board-theme--light .skg-stat-card__label {
color: rgba(255, 255, 255, 0.54);
color: rgba(32, 36, 27, 0.48);
}
.skg-board-theme--light .skg-stat-card__value {
color: #fff;
color: #20241b;
}
.skg-board-theme--light .skg-primary-action {
background: #0a0a0a;
color: #fff;
box-shadow: 0 10px 24px -12px rgba(133, 96, 21, 0.42);
background: linear-gradient(135deg, #c8cd19, #a2c638);
color: #10140d;
box-shadow: 0 12px 28px rgba(128, 144, 37, 0.2);
}
.skg-board-theme--light .skg-primary-action:hover {
background: #252525;
background: linear-gradient(135deg, #d6db25, #b0d83d);
}
.skg-board-theme--light .skg-secondary-action {
border-color: rgba(72, 78, 56, 0.16);
background: rgba(255, 255, 255, 0.5);
color: rgba(32, 36, 27, 0.72);
}
.skg-board-theme--light .skg-secondary-action:hover {
border-color: rgba(143, 176, 113, 0.38);
background: rgba(143, 176, 113, 0.12);
color: #20241b;
}
.skg-board-theme--light .skg-empty-state {

View File

@@ -12,8 +12,8 @@ const _playfairDisplay = Playfair_Display({
})
export const metadata: Metadata = {
title: "SKG TK 二创工作台",
description: "SKG AI 素材生产管线 · 节点工作流",
title: "SKG 营销内容工作台",
description: "SKG AI 图片、视频和文案创作台",
}
export default function RootLayout({

View File

@@ -4,6 +4,7 @@ import type { FormEvent } from "react"
import { useEffect, useMemo, useState } from "react"
import {
ArrowRight,
Building2,
CheckCircle2,
Eye,
EyeOff,
@@ -14,8 +15,14 @@ import { AnimatedLoginCharacters, type LoginCharacterMood } from "@/components/l
import { OasisCanvas } from "@/components/login/oasis-canvas"
type LoginStatus = "idle" | "loading" | "success"
type AuthConfig = {
auth_configured?: boolean
password_enabled?: boolean
feishu_enabled?: boolean
}
export default function LoginPage() {
const [authConfig, setAuthConfig] = useState<AuthConfig | null>(null)
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const [remember, setRemember] = useState(true)
@@ -25,6 +32,21 @@ export default function LoginPage() {
const [status, setStatus] = useState<LoginStatus>("idle")
const [eyeOffset, setEyeOffset] = useState({ x: 0, y: 0 })
useEffect(() => {
let cancelled = false
fetch("/api/auth/config", { cache: "no-store", credentials: "include" })
.then((res) => res.ok ? res.json() : null)
.then((data) => {
if (!cancelled && data) setAuthConfig(data)
})
.catch(() => {
if (!cancelled) setAuthConfig(null)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const onPointerMove = (event: PointerEvent) => {
const centerX = window.innerWidth / 2
@@ -38,6 +60,8 @@ export default function LoginPage() {
}, [])
const disabled = status === "loading" || status === "success"
const feishuEnabled = Boolean(authConfig?.feishu_enabled)
const passwordEnabled = authConfig?.password_enabled ?? true
const mood: LoginCharacterMood = useMemo(() => {
if (status === "success") return "success"
@@ -50,6 +74,7 @@ export default function LoginPage() {
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setHasError(false)
if (!passwordEnabled) return
if (!username.trim() || !password) {
setHasError(true)
return
@@ -75,6 +100,11 @@ export default function LoginPage() {
}
}
function onFeishuLogin() {
setStatus("loading")
window.location.href = "/api/auth/feishu/start?next=/"
}
return (
<main className="login-page login-page--oasis login-page--source relative min-h-screen overflow-hidden text-white">
<OasisCanvas />
@@ -89,7 +119,29 @@ export default function LoginPage() {
<AnimatedLoginCharacters mood={mood} eyeOffset={eyeOffset} />
</div>
<form className="login-source-form-pane w-full" onSubmit={onSubmit}>
<div className="space-y-3">
{feishuEnabled ? (
<button
className="mb-3 flex h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
type="button"
disabled={disabled}
onClick={onFeishuLogin}
>
<Building2 className="h-4 w-4" />
<ArrowRight className="h-4 w-4" />
</button>
) : null}
{feishuEnabled && passwordEnabled ? (
<div className="mb-3 flex items-center gap-3 text-xs text-white/35">
<span className="h-px flex-1 bg-white/10" />
<span></span>
<span className="h-px flex-1 bg-white/10" />
</div>
) : null}
{passwordEnabled ? (
<div className="space-y-3">
<label className="block">
<span className="flex h-11 items-center gap-3 rounded-[8px] border border-white/10 bg-black/25 px-3 text-white transition focus-within:border-[#d6b36a] focus-within:bg-black/35 focus-within:ring-2 focus-within:ring-[#d6b36a]/25">
<UserRound className="h-4 w-4 text-white/45" />
@@ -135,9 +187,11 @@ export default function LoginPage() {
</button>
</span>
</label>
</div>
</div>
) : null}
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm text-white/60">
{passwordEnabled ? (
<label className="mt-3 flex cursor-pointer items-center gap-2 text-sm text-white/60">
<input
className="h-4 w-4 rounded border-white/20 bg-black/30 accent-[#c89b3c]"
type="checkbox"
@@ -146,7 +200,8 @@ export default function LoginPage() {
onChange={(event) => setRemember(event.target.checked)}
/>
<span></span>
</label>
</label>
) : null}
{status === "success" ? (
<div className="mt-3">
@@ -156,13 +211,15 @@ export default function LoginPage() {
</div>
) : null}
<button
{passwordEnabled ? (
<button
className="mt-4 flex h-11 w-full items-center justify-center gap-2 rounded-[8px] bg-white px-4 text-base font-semibold text-black shadow-xl shadow-black/25 transition hover:bg-[#f5efe3] focus:outline-none focus:ring-2 focus:ring-[#d6b36a]/60 disabled:cursor-wait disabled:opacity-70"
type="submit"
disabled={disabled}
>
<ArrowRight className="h-4 w-4" />
</button>
</button>
) : null}
</form>
</section>
</div>

File diff suppressed because it is too large Load Diff

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"
@@ -103,24 +103,13 @@ type BoardThemeMode = "dark" | "light"
type AudioStoryboardRole = "hook" | "pain" | "proof" | "solution" | "cta" | "bridge"
const BOARD_THEME_STORAGE_KEY = "skg-board-theme"
const BOARD_FRAME_WIDTH = 1800
const BOARD_FRAME_HEIGHT = 1000
const BOARD_MIN_SCALE = 0.72
const BOARD_MAX_SCALE = 1.6
const BOARD_SCALE_PRESETS = [0.72, 0.76, 0.8, 0.86, 0.92, 1, 1.06, 1.16, 1.24, 1.34, 1.48, 1.6]
const SOURCE_LEFT_COLUMN_WIDTH = 380
const SOURCE_VIDEO_HEIGHT = 500
const SOURCE_TRANSCRIPT_MAX_HEIGHT = 270
const SOURCE_TRANSCRIPT_MAX_HEIGHT = 360
const SOURCE_REFERENCE_POOL_WIDTH = 140
const SOURCE_CONVERSION_HEIGHT = 640
const SOURCE_CONVERSION_MAX_HEIGHT = 560
const SOURCE_SUBJECT_EMPTY_HEIGHT = 78
const resolveBoardScale = (viewportWidth: number) => {
const maxFitScale = clampNumber(viewportWidth / BOARD_FRAME_WIDTH, BOARD_MIN_SCALE, BOARD_MAX_SCALE)
const preset = BOARD_SCALE_PRESETS.reduce((best, candidate) => (candidate <= maxFitScale ? candidate : best), BOARD_MIN_SCALE)
return Math.round(preset * 1000) / 1000
}
type DraftSegment = {
id: string
frameIndex: number | null
@@ -874,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()
@@ -1335,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: "点击“解析音频”后触发;开始任务下载完成后也会自动走这条链路。",
@@ -1384,7 +1384,7 @@ function scriptRewriteModelTrace(models?: RuntimeModels): ModelTraceSpec {
chain: [
`主改写:${modelValue(models?.audio_rewrite)} 根据原文案、当前分镜、作者想法生成新口播`,
`模型回退:依次尝试 ${modelValue(models?.asr_fallback)}${modelValue(models?.translate)};全部失败时用本地模板保留分镜可编辑`,
"返回结果写入当前分镜文案编辑框;点击保存规划后才写入 frame.storyboard.action",
"返回结果会先写入当前分镜文案编辑框;生成候选前会自动同步到分镜数据,无需单独保存视频",
],
}
}
@@ -1399,7 +1399,7 @@ function videoModelTrace(models: RuntimeModels | undefined, model: string): Mode
`服务商:${modelValue(models?.video_provider)} · ${modelValue(models?.video_base_url)}`,
"当前主工作台暂停直接提交视频;旧入口误触也会被页面层保护",
"开放后输入会包含已确认首尾帧、当前分镜文案、产品素材、相似主体资产和画面规划",
"输出为异步候选视频,完成后回填到对应分镜行Sora 已停用",
"输出为异步候选视频,提交后立即写入当前任务,完成后自动回填 mp4 到对应分镜行Sora 已停用",
],
}
}
@@ -2234,11 +2234,9 @@ export function AdRecreationBoard({
const [generatingAll, setGeneratingAll] = useState(false)
const [runtimeModels, setRuntimeModels] = useState<RuntimeModels | undefined>()
const [boardTheme, setBoardTheme] = useState<BoardThemeMode>("dark")
const [boardScale, setBoardScale] = useState(1)
const [boardViewportSize, setBoardViewportSize] = useState({ width: 0, height: 0 })
const [libraryOpen, setLibraryOpen] = useState(false)
const [materialRailOpen, setMaterialRailOpen] = useState(false)
const fileRef = useRef<HTMLInputElement | null>(null)
const boardViewportRef = useRef<HTMLElement | null>(null)
const selectedFrames = job
? job.frames.filter((frame) => data.selectedFrames.has(frame.index)).sort((a, b) => a.timestamp - b.timestamp)
: []
@@ -2286,30 +2284,6 @@ export function AdRecreationBoard({
}
}, [])
useEffect(() => {
const updateBoardScale = () => {
const node = boardViewportRef.current
if (!node) return
const nextWidth = node.clientWidth
const nextHeight = node.clientHeight
const nextScale = resolveBoardScale(nextWidth)
setBoardScale((current) => (Math.abs(current - nextScale) < 0.001 ? current : nextScale))
setBoardViewportSize((current) =>
current.width === nextWidth && current.height === nextHeight ? current : { width: nextWidth, height: nextHeight },
)
}
updateBoardScale()
const node = boardViewportRef.current
if (node && typeof ResizeObserver !== "undefined") {
const observer = new ResizeObserver(updateBoardScale)
observer.observe(node)
return () => observer.disconnect()
}
window.addEventListener("resize", updateBoardScale)
return () => window.removeEventListener("resize", updateBoardScale)
}, [])
useEffect(() => {
let cancelled = false
getRuntimeHealth()
@@ -2511,126 +2485,103 @@ export function AdRecreationBoard({
}
}
const boardScaledWidth = Math.round(BOARD_FRAME_WIDTH * boardScale)
const boardScaledHeight = Math.round(BOARD_FRAME_HEIGHT * boardScale)
const boardViewportHeight = boardViewportSize.height || boardScaledHeight
const boardShouldCenterVertically = boardScaledHeight < boardViewportHeight
return (
<section ref={boardViewportRef} className={`skg-board-theme ${boardTheme === "light" ? "skg-board-theme--light" : ""} relative z-20 h-screen w-screen overflow-auto bg-black text-white`}>
<section className={`skg-board-theme ${boardTheme === "light" ? "skg-board-theme--light" : ""} relative z-20 min-h-screen w-full overflow-auto bg-black text-white`}>
<div className="skg-board-ambient pointer-events-none fixed inset-0" />
<div
className={`relative z-10 flex min-h-screen justify-center ${boardShouldCenterVertically ? "items-center" : "items-start"}`}
style={{ minWidth: boardScaledWidth, minHeight: Math.max(boardScaledHeight, boardViewportHeight) }}
>
<div style={{ width: boardScaledWidth, height: boardScaledHeight }}>
<div
className="flex h-[1000px] w-[1800px] max-w-none flex-col px-4 py-4"
style={{ zoom: boardScale, transformOrigin: "top left" }}
>
<header className="skg-board-topbar mb-3 flex items-center justify-between gap-4 rounded-lg border border-white/10 bg-white/[0.04] px-4 py-3">
<div className="skg-board-brand">
<div className="skg-board-brand__logo-chip" aria-hidden="true">
<img className="skg-board-brand__logo" src="/skg-logo-black.svg" alt="" />
</div>
<div className="min-w-0">
<div className="skg-board-brand__system"> · </div>
<h1 className="skg-board-brand__title"> · TK </h1>
<p className="skg-board-brand__subtitle">广线</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => setLibraryOpen(true)}
className="skg-secondary-action inline-flex h-10 items-center gap-1.5 px-3 text-[11px] font-semibold transition"
title="打开全局资源中心"
>
<BookOpen className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={toggleBoardTheme}
className="skg-board-theme-toggle skg-secondary-action inline-flex h-10 items-center gap-1.5 px-3 text-[11px] font-semibold transition"
title={boardTheme === "dark" ? "切换到明亮模式" : "切换到暗色模式"}
>
{boardTheme === "dark" ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
{boardTheme === "dark" ? "明亮" : "暗色"}
</button>
<div className="grid min-w-[520px] grid-cols-5 gap-2 text-[11px]">
<Metric label="素材" value={`${jobs.length}`} />
<Metric label="当前" value={shortId(activeJobId)} />
<Metric label="视频" value={job?.video_url ? "ready" : "-"} />
<Metric label="文案段" value={`${transcriptCount}`} />
<Metric label="背景音" value={backgroundReady ? "ready" : "-"} />
</div>
</div>
</header>
<div className="grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)] gap-3">
<MaterialColumn
data={data}
step={workflow.input}
jobs={jobs}
job={job}
activeJobId={activeJobId}
url={url}
setUrl={setUrl}
fileRef={fileRef}
onSubmitUrl={submitUrl}
onStartProduction={startProduction}
<div className="relative z-10 mx-auto min-h-screen w-full min-w-[1280px] max-w-[1920px] px-4 py-4 xl:px-5">
<div className="skg-board-shell flex gap-4 p-4">
<WorkbenchRail
jobsCount={jobs.length}
materialOpen={materialRailOpen}
onToggleMaterial={() => setMaterialRailOpen((open) => !open)}
onOpenLibrary={() => setLibraryOpen(true)}
onToggleTheme={toggleBoardTheme}
boardTheme={boardTheme}
materialPanel={
<MaterialColumn
data={data}
step={workflow.input}
jobs={jobs}
job={job}
activeJobId={activeJobId}
url={url}
setUrl={setUrl}
fileRef={fileRef}
onSubmitUrl={submitUrl}
onStartProduction={startProduction}
/>
}
/>
<section className="skg-board-panel flex min-h-0 flex-col rounded-lg border border-white/10 bg-white/[0.035] shadow-2xl">
<header className="shrink-0 border-b border-white/10 p-3">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-[#d6b36a]/18 bg-[#d6b36a]/10 text-[#f2d58a]"><Mic className="h-3.5 w-3.5" /></span>
<WorkflowStepBadge step={workflow.source} compact />
<h2 className="text-[15px] font-semibold leading-tight text-white"></h2>
</div>
<div className="mt-1 truncate text-[11px] text-white/38" title={statusMessage}>
{statusMessage || "下载源视频后解析音频,再抽参考帧并生成相似主体。"}
<div className="flex min-w-0 flex-1 flex-col gap-3">
<header className="skg-board-topbar flex items-center justify-between gap-4 rounded-[20px] border border-white/10 bg-white/[0.04] px-4 py-3">
<div className="min-w-0">
<div className="flex items-center gap-3">
<span className="skg-status-orb shrink-0 text-[10px] font-semibold">SKG</span>
<div className="min-w-0">
<div className="text-[11px] font-medium tracking-[0.18em] text-white/40"> · </div>
<h1 className="mt-1 truncate text-[22px] font-semibold leading-tight text-white"></h1>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModelTrace trace={audioModelTrace(runtimeModels)} compact />
<ActionButton disabled={!job?.video_url || audioRunning} onClick={() => data.onTranscribeAudio?.(job?.id)}>
<Mic className="h-3.5 w-3.5" />
</ActionButton>
</div>
<p className="mt-2 truncate text-[12px] text-white/42">广</p>
</div>
<div className="grid min-w-[440px] grid-cols-5 gap-2 text-[11px]">
<Metric label="素材" value={`${jobs.length}`} variant="violet" />
<Metric label="当前" value={shortId(activeJobId)} variant="lime" />
<Metric label="视频" value={job?.video_url ? "ready" : "-"} variant="gold" />
<Metric label="文案段" value={`${transcriptCount}`} variant="teal" />
<Metric label="背景音" value={backgroundReady ? "ready" : "-"} variant="green" />
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<AudioIntakePanel
job={job}
selectedFrames={data.selectedFrames}
onToggleFrame={data.onToggleFrame}
onJobUpdate={data.onJobUpdate}
onAddFrame={data.onAddManualFrameForJob}
onDeleteFrame={data.onDeleteFrameForJob}
runtimeModels={runtimeModels}
/>
<AudioStoryboardPlanPanel
job={job}
selectedFrames={data.selectedFrames}
onJobUpdate={data.onJobUpdate}
onDeleteVideo={data.onDeleteVideo}
runtimeModels={runtimeModels}
productStep={workflow.product}
scriptStep={workflow.script}
sceneStep={workflow.scene}
videoStep={workflow.video}
/>
</div>
</section>
</div>
</div>
</div>
<section className="skg-board-panel flex min-h-0 flex-1 flex-col rounded-[20px] border border-white/10 bg-white/[0.035] shadow-2xl">
<header className="shrink-0 border-b border-white/10 p-3">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/12 bg-white/[0.07] text-[#c8cd19]"><Mic className="h-4 w-4" /></span>
<WorkflowStepBadge step={workflow.source} compact />
<h2 className="text-[15px] font-semibold leading-tight text-white"></h2>
</div>
<div className="mt-1 truncate text-[11px] text-white/38" title={statusMessage}>
{statusMessage || "下载源视频后解析音频,再抽参考帧并生成相似主体。"}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<ModelTrace trace={audioModelTrace(runtimeModels)} compact />
<ActionButton disabled={!job?.video_url || audioRunning} onClick={() => data.onTranscribeAudio?.(job?.id)}>
<Mic className="h-3.5 w-3.5" />
</ActionButton>
</div>
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<AudioIntakePanel
job={job}
selectedFrames={data.selectedFrames}
onToggleFrame={data.onToggleFrame}
onJobUpdate={data.onJobUpdate}
onAddFrame={data.onAddManualFrameForJob}
onDeleteFrame={data.onDeleteFrameForJob}
runtimeModels={runtimeModels}
/>
<AudioStoryboardPlanPanel
job={job}
selectedFrames={data.selectedFrames}
onJobUpdate={data.onJobUpdate}
onDeleteVideo={data.onDeleteVideo}
runtimeModels={runtimeModels}
productStep={workflow.product}
scriptStep={workflow.script}
sceneStep={workflow.scene}
videoStep={workflow.video}
/>
</div>
</section>
</div>
</div>
</div>
<LibraryDrawer
open={libraryOpen}
@@ -2642,6 +2593,86 @@ export function AdRecreationBoard({
)
}
function WorkbenchRail({
jobsCount,
materialOpen,
onToggleMaterial,
onOpenLibrary,
onToggleTheme,
boardTheme,
materialPanel,
}: {
jobsCount: number
materialOpen: boolean
onToggleMaterial: () => void
onOpenLibrary: () => void
onToggleTheme: () => void
boardTheme: BoardThemeMode
materialPanel: ReactNode
}) {
const [hoverOpen, setHoverOpen] = useState(false)
const railOpen = materialOpen || hoverOpen
return (
<aside
className={`skg-board-rail sticky flex shrink-0 items-stretch ${railOpen ? "is-open" : ""}`}
aria-label="工作台工具栏"
onMouseEnter={() => setHoverOpen(true)}
onMouseLeave={() => setHoverOpen(false)}
onFocus={() => setHoverOpen(true)}
onBlur={(event) => {
const nextTarget = event.relatedTarget
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) setHoverOpen(false)
}}
>
<div className="skg-board-rail__strip flex shrink-0 items-stretch gap-3" aria-label="工作台导航与素材输入">
<div className="skg-board-rail__iconbar flex shrink-0 flex-col items-center py-5" aria-label="工作台导航">
<div className="skg-board-rail__logo mb-8 flex h-9 w-9 items-center justify-center rounded-full text-[12px] font-semibold" title="SKG Marketing Studio">
S
</div>
<nav className="flex flex-col items-center gap-4">
<button
type="button"
onClick={onToggleMaterial}
className={`skg-board-rail__button inline-flex h-9 w-9 items-center justify-center rounded-full ${railOpen ? "is-active" : ""}`}
title={jobsCount ? `素材任务 · ${jobsCount}` : "素材任务"}
aria-label="素材任务"
aria-expanded={railOpen}
aria-controls="skg-material-rail-panel"
>
<Link2 className="h-[18px] w-[18px]" />
</button>
<button
type="button"
onClick={onOpenLibrary}
className="skg-board-rail__button inline-flex h-9 w-9 items-center justify-center rounded-full"
title="资源库"
aria-label="打开资源库"
>
<BookOpen className="h-[18px] w-[18px]" />
</button>
<button
type="button"
onClick={onToggleTheme}
className="skg-board-rail__button inline-flex h-9 w-9 items-center justify-center rounded-full"
title={boardTheme === "dark" ? "切换到明亮模式" : "切换到暗色模式"}
aria-label={boardTheme === "dark" ? "切换到明亮模式" : "切换到暗色模式"}
>
{boardTheme === "dark" ? <Sun className="h-[18px] w-[18px]" /> : <Moon className="h-[18px] w-[18px]" />}
</button>
</nav>
</div>
{railOpen ? (
<div id="skg-material-rail-panel" className="skg-board-rail__drawer w-[320px] shrink-0">
{materialPanel}
</div>
) : null}
</div>
</aside>
)
}
function MaterialColumn({
data,
step,
@@ -2669,10 +2700,10 @@ function MaterialColumn({
? job.video_url ? "重新解析" : "重新下载"
: "开始分析"
return (
<section className="skg-board-panel flex min-h-0 flex-col gap-3 rounded-lg border border-white/10 bg-white/[0.035] p-3 shadow-2xl">
<section className="skg-board-panel flex h-full min-h-[514px] flex-col gap-3 rounded-[20px] border border-white/10 bg-white/[0.035] p-3 shadow-2xl">
<header className="shrink-0 border-b border-white/10 pb-3">
<div className="mb-2 flex items-center gap-2">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-[#d6b36a]/18 bg-[#d6b36a]/10 text-[#f2d58a]"><Plus className="h-4 w-4" /></span>
<span className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-white/12 bg-white/[0.07] text-[#c8cd19]"><Plus className="h-4 w-4" /></span>
<WorkflowStepBadge step={step} compact />
</div>
<h2 className="text-[15px] font-semibold leading-tight text-white"></h2>
@@ -2685,7 +2716,7 @@ function MaterialColumn({
onChange={(e) => setUrl(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") onSubmitUrl() }}
placeholder="粘贴 TK / 信息流视频链接"
className="h-10 min-w-0 flex-1 rounded-md border border-white/10 bg-black/45 px-3 text-[13px] text-white outline-none placeholder:text-white/28 focus:border-[#d6b36a]/60"
className="h-10 min-w-0 flex-1 rounded-md border border-white/10 bg-black/35 px-3 text-[13px] text-white outline-none placeholder:text-white/28 focus:border-[#a2c638]/60"
/>
<button
type="button"
@@ -2962,7 +2993,7 @@ function AudioIntakePanel({
}
return (
<section className="rounded-lg border border-white/10 bg-black/28 p-2.5">
<section className="skg-glass-card border border-white/10 p-3">
<div className="mb-2 flex items-center justify-between gap-3">
<SectionTitle icon={<Film className="h-4 w-4" />} title="源视频工作区" />
<div className="flex items-center gap-2 font-mono text-[11px] text-white/38">
@@ -2983,7 +3014,7 @@ function AudioIntakePanel({
<span className="font-mono text-[11px] text-white/38">{currentTime.toFixed(1)}s</span>
</div>
<div
className="relative mx-auto aspect-[9/16] overflow-hidden rounded-md border border-white/10 bg-black"
className="relative mx-auto aspect-[9/16] overflow-hidden rounded-[20px] border border-white/10 bg-black shadow-[10px_10px_10px_rgba(0,0,0,0.3)]"
style={{ height: SOURCE_VIDEO_HEIGHT }}
>
{job.video_url ? (
@@ -3012,7 +3043,7 @@ function AudioIntakePanel({
onClick={() => void addFrameAtCurrentTime()}
disabled={!job.video_url || !onAddFrame || manualBusy || job.status === "splitting"}
title={`按当前播放位置手动抽帧:${currentTime.toFixed(1)}s`}
className="absolute right-2 top-2 inline-flex h-7 items-center justify-center gap-1 rounded-md border border-emerald-200/30 bg-black/78 px-2 text-[10.5px] font-semibold text-emerald-100 shadow-lg backdrop-blur transition hover:border-emerald-100/65 hover:bg-emerald-300/18 disabled:cursor-not-allowed disabled:opacity-35"
className="absolute right-2 top-2 inline-flex h-7 items-center justify-center gap-1 rounded-md border border-[#a2c638]/35 bg-black/78 px-2 text-[10.5px] font-semibold text-[#e0f5a0] shadow-lg backdrop-blur transition hover:border-[#a2c638]/70 hover:bg-[#a2c638]/18 disabled:cursor-not-allowed disabled:opacity-35"
>
{manualBusy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Plus className="h-3.5 w-3.5" />}
@@ -3029,7 +3060,7 @@ function AudioIntakePanel({
</div>
<div className="min-w-0 space-y-2">
<div className="relative z-40 overflow-visible rounded-md border border-white/10 bg-black/32 p-2">
<div className="skg-glass-card--flat relative z-40 overflow-visible border border-white/10 p-2">
<div className="mb-1 flex items-center justify-end gap-3 text-[10px] text-white/40">
<FilmstripDensityControls
density={filmstripDensity}
@@ -3969,13 +4000,13 @@ function SourceSubjectPipeline({
</button>
</div>
<div
className={`rounded-md border p-1.5 transition ${
filmstripDragging
? referenceDropActive
? "border-[#d6b36a]/80 bg-[#d6b36a]/12 ring-1 ring-[#d6b36a]/45"
: "border-[#d6b36a]/45 bg-[#d6b36a]/[0.065]"
: "border-white/10 bg-black/32"
}`}
className={`skg-glass-card--flat border p-1.5 transition ${
filmstripDragging
? referenceDropActive
? "border-[#a2c638]/80 bg-[#a2c638]/12 ring-1 ring-[#a2c638]/45"
: "border-[#a2c638]/45 bg-[#a2c638]/[0.065]"
: "border-white/10 bg-black/28"
}`}
onDragEnter={(event) => {
if (!onDropFilmstripFrame) return
event.preventDefault()
@@ -4004,7 +4035,7 @@ function SourceSubjectPipeline({
<span>{frames.length} </span>
<span>{filmstripDragging ? "松手加入" : "点击选择"}</span>
</div>
<div className="flex flex-col gap-1 overflow-y-auto pr-0.5" style={{ maxHeight: SOURCE_CONVERSION_HEIGHT }}>
<div className="flex flex-col gap-1 overflow-y-auto pr-0.5" style={{ maxHeight: SOURCE_CONVERSION_MAX_HEIGHT }}>
{frames.map((frame, index) => {
const selected = selectedFrames.has(frame.index)
return (
@@ -4070,8 +4101,8 @@ function SourceSubjectPipeline({
</span>
</div>
<div
className="flex flex-col gap-2 overflow-y-auto rounded-md border border-white/10 bg-black/24 p-2"
style={{ height: SOURCE_CONVERSION_HEIGHT }}
className="skg-glass-card--flat flex flex-col gap-2 overflow-y-auto border border-white/10 p-2"
style={{ maxHeight: SOURCE_CONVERSION_MAX_HEIGHT }}
>
<div className="grid shrink-0 grid-cols-2 gap-1.5">
{SUBJECT_MODEL_BUNDLE_OPTIONS.map((option) => (
@@ -4081,8 +4112,8 @@ function SourceSubjectPipeline({
onClick={() => setSubjectModelBundle(option.value)}
className={`rounded-md border px-2 py-1.5 text-left transition ${
subjectModelBundle === option.value
? "border-cyan-200/65 bg-cyan-300/12 text-cyan-50"
: "border-white/10 bg-black/26 text-white/52 hover:border-white/24 hover:text-white/76"
? "border-[#a2c638]/65 bg-[#a2c638]/12 text-white"
: "border-white/10 bg-black/24 text-white/52 hover:border-white/24 hover:text-white/76"
}`}
title={option.detail}
>
@@ -4094,9 +4125,9 @@ function SourceSubjectPipeline({
<div
className={`shrink-0 rounded-md border p-2 transition ${
agentDropActive || referenceFrameDragging || filmstripDragging || agentReferenceUploadBusy
? "border-cyan-200/65 bg-cyan-300/[0.08] ring-1 ring-cyan-200/25"
: "border-white/10 bg-black/22"
agentDropActive || referenceFrameDragging || filmstripDragging || agentReferenceUploadBusy
? "border-[#a2c638]/65 bg-[#a2c638]/[0.08] ring-1 ring-[#a2c638]/25"
: "border-white/10 bg-black/22"
}`}
onDragEnter={handleAgentReferenceDragEnter}
onDragOver={handleAgentReferenceDragOver}
@@ -4129,7 +4160,7 @@ function SourceSubjectPipeline({
</div>
) : (
<div className="flex h-[72px] flex-col items-center justify-center rounded border border-dashed border-white/15 px-3 text-center text-[10px] leading-snug text-white/34">
{agentReferenceUploadBusy ? <Loader2 className="mb-1.5 h-4 w-4 animate-spin text-cyan-100/80" /> : <Upload className="mb-1.5 h-4 w-4 text-cyan-100/55" />}
{agentReferenceUploadBusy ? <Loader2 className="mb-1.5 h-4 w-4 animate-spin text-[#d7efbc]" /> : <Upload className="mb-1.5 h-4 w-4 text-[#d7efbc]/70" />}
<span className="font-semibold text-white/50"></span>
<span className="mt-0.5 text-white/28"> +</span>
</div>
@@ -4151,9 +4182,9 @@ function SourceSubjectPipeline({
</div>
{agentAnalysis ? (
<div className="shrink-0 rounded-md border border-emerald-200/18 bg-emerald-300/[0.055] p-2">
<div className="shrink-0 rounded-md border border-[#a2c638]/24 bg-[#a2c638]/[0.07] p-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-semibold text-emerald-50/76"></span>
<span className="text-[10px] font-semibold text-[#d7efbc]"></span>
<span className="text-[9px] text-white/34">
= ·
</span>
@@ -4162,7 +4193,7 @@ function SourceSubjectPipeline({
{agentTraits.length ? (
<>
<div className="mt-2 flex items-center justify-between gap-2 text-[9px]">
<span className={agentSelectedTraitsDirty ? "text-cyan-100/56" : "text-white/34"}>
<span className={agentSelectedTraitsDirty ? "text-[#d7efbc]/70" : "text-white/34"}>
{selectedAgentTraits.length} {agentSelectedTraitsDirty ? " · 待发送" : ""}
</span>
{selectedAgentTraits.length ? (
@@ -4187,7 +4218,7 @@ function SourceSubjectPipeline({
title={active ? "已作为保留元素,再点取消" : "点一下加入保留元素"}
className={`inline-flex min-h-[22px] cursor-pointer items-center gap-1 rounded-full border px-2 py-0.5 text-[9px] transition ${
active
? "border-emerald-100/65 bg-emerald-300/16 text-emerald-50 shadow-[0_0_0_1px_rgba(167,243,208,0.12)]"
? "border-[#a2c638]/65 bg-[#a2c638]/16 text-[#e9f6b8] shadow-[0_0_0_1px_rgba(162,198,56,0.12)]"
: "border-white/10 bg-black/26 text-white/46 hover:border-white/22 hover:text-white/70"
}`}
>
@@ -4202,14 +4233,14 @@ function SourceSubjectPipeline({
</div>
) : null}
<div className="flex shrink-0 flex-col rounded-md border border-white/10 bg-black/22 p-2">
<div className="flex shrink-0 flex-col rounded-md border border-white/10 bg-black/22 p-2">
<div className="mb-1.5 flex items-center justify-between gap-2">
<span className="inline-flex items-center gap-1 text-[10px] font-semibold text-white/72">
<MessageSquare className="h-3.5 w-3.5 text-cyan-100/55" />
<MessageSquare className="h-3.5 w-3.5 text-[#d7efbc]/70" />
</span>
{effectivePrompt ? (
<span className="inline-flex h-6 items-center gap-1 rounded-md border border-[#d6b36a]/24 bg-[#d6b36a]/[0.07] px-2 text-[9px] font-semibold text-[#f4dc88]">
<span className="inline-flex h-6 items-center gap-1 rounded-md border border-[#a2c638]/28 bg-[#a2c638]/[0.08] px-2 text-[9px] font-semibold text-[#d7efbc]">
<Check className="h-3 w-3" />
· {effectiveAgentViews.length}
</span>
@@ -4224,7 +4255,7 @@ function SourceSubjectPipeline({
value={agentInput}
onChange={(event) => setAgentInput(event.target.value)}
placeholder="直接写要怎么生成,或补充要改什么。"
className="h-40 w-full resize-none rounded border border-transparent bg-transparent px-2 py-2 text-[11px] leading-relaxed text-white outline-none transition placeholder:text-white/24 focus:border-cyan-200/45"
className="h-32 w-full resize-none rounded border border-transparent bg-transparent px-2 py-2 text-[11px] leading-relaxed text-white outline-none transition placeholder:text-white/24 focus:border-[#a2c638]/45"
/>
<div className="mt-2 flex items-center gap-2">
<div className="flex h-10 shrink-0 items-center overflow-hidden rounded-md border border-white/10 bg-black/35">
@@ -4295,7 +4326,7 @@ function SourceSubjectPipeline({
{subjectAssetPacks.length ? `${subjectAssetPacks.length}` : "待生成"}
</span>
</div>
<div className="rounded-md border border-white/10 bg-black/32 p-2">
<div className="skg-glass-card--flat border border-white/10 p-2">
{subjectBusyFor ? (
<div className="mb-2 rounded-md border border-cyan-200/20 bg-cyan-300/[0.07] px-2.5 py-2 text-[10px] leading-snug text-cyan-50/70">
{reconstructionModeConfig(subjectBusyFor.mode).label} {subjectBusyFor.viewCount} {subjectBusyFor.sourceCount || "自主描述"}
@@ -4306,7 +4337,7 @@ function SourceSubjectPipeline({
{subjectAssetPacks.length ? (
<div className="grid grid-cols-[minmax(0,1fr)_260px] gap-2">
{activeSubjectPack ? (
<div className="rounded-md border border-[#d6b36a]/28 bg-[#d6b36a]/[0.07] p-2">
<div className="rounded-md border border-[#a2c638]/28 bg-[#a2c638]/[0.07] p-2">
<div className="mb-2 flex items-center justify-between gap-2">
<div className="min-w-0">
<div className="truncate text-[11px] font-semibold text-white">{activeSubjectPack.label}</div>
@@ -5564,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)
@@ -5884,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)
}
@@ -5898,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)
@@ -5912,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)
@@ -6287,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>
@@ -6448,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)}
@@ -6456,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>
@@ -6677,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)}
@@ -6686,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">
@@ -6700,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>
@@ -7042,7 +7054,6 @@ function StoryboardVideoSlots({
job,
videos,
enabled,
selectedVideoId = "",
busy = false,
count = 4,
onCountChange,
@@ -7050,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
@@ -7066,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 (
@@ -7083,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">
@@ -7126,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}
/>
@@ -7245,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="删除这个视频候选"
/>
@@ -7321,7 +7338,7 @@ function AudioWaveform({
}, [features])
return (
<div
className="relative h-24 cursor-pointer overflow-hidden rounded-md border border-white/10 bg-black/35 px-2"
className="skg-audio-waveform relative h-24 cursor-pointer overflow-hidden rounded-md border px-2"
aria-label="音频响度波形"
onClick={(event) => {
const rect = event.currentTarget.getBoundingClientRect()
@@ -7339,24 +7356,24 @@ function AudioWaveform({
{status === "failed" ? "audio.wav 解码失败" : status === "loading" ? "正在解码 audio.wav" : "等待音频文件"}
</div>
)}
<div className="absolute inset-x-0 top-1/2 h-px bg-white/14" />
<div className="skg-audio-waveform__center absolute inset-x-0 top-1/2 h-px" />
{hasFeatures && (
<svg className="pointer-events-none absolute inset-0 h-full w-full overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none">
<polygon
points={envelopePoints}
fill="rgba(209,213,219,0.74)"
fill="var(--skg-wave-fill)"
/>
<polyline
points={topPoints}
fill="none"
stroke="rgba(229,231,235,0.7)"
stroke="var(--skg-wave-stroke-1)"
strokeWidth="0.55"
vectorEffect="non-scaling-stroke"
/>
<polyline
points={bottomPoints}
fill="none"
stroke="rgba(229,231,235,0.52)"
stroke="var(--skg-wave-stroke-2)"
strokeWidth="0.55"
vectorEffect="non-scaling-stroke"
/>
@@ -7366,18 +7383,18 @@ function AudioWaveform({
{segments.map((segment, index) => (
<div
key={`${segment.start}-${index}`}
className="absolute inset-y-0 w-px bg-white/12"
className="skg-audio-waveform__segment absolute inset-y-0 w-px"
style={{ left: `${clampNumber((segment.start / Math.max(duration, 1)) * 100, 0, 100)}%` }}
/>
))}
{hoverPct !== null && (
<div
className="pointer-events-none absolute inset-y-0 w-px bg-cyan-100/70"
className="skg-audio-waveform__hover pointer-events-none absolute inset-y-0 w-px"
style={{ left: `${hoverPct}%` }}
/>
)}
<div
className="pointer-events-none absolute inset-y-0 w-[2px] bg-emerald-200 shadow-[0_0_16px_rgba(110,231,183,0.85)] will-change-[left]"
className="skg-audio-waveform__playhead pointer-events-none absolute inset-y-0 w-[2px] will-change-[left]"
style={{ left: `${pointerPct}%` }}
/>
</div>
@@ -7901,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" />
@@ -7936,7 +7953,7 @@ function MaterialCard({
<button
type="button"
onClick={onClick}
className={`group w-full rounded-lg border p-3 text-left transition ${active ? "border-[#d6b36a]/55 bg-[#d6b36a]/10 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]" : "border-white/10 bg-black/28 hover:border-[#d6b36a]/28 hover:bg-white/[0.045]"}`}
className={`group skg-glass-card--flat w-full border p-3 text-left transition ${active ? "border-[#a2c638]/55 bg-[#a2c638]/10 shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]" : "border-white/10 bg-black/24 hover:border-[#a2c638]/30 hover:bg-white/[0.055]"}`}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
@@ -7971,7 +7988,7 @@ function MaterialCard({
onDelete()
}
}}
className="mt-3 hidden h-8 items-center justify-center gap-1 rounded-md border border-white/10 text-[11px] text-white/50 transition hover:border-[#d6b36a]/40 hover:text-[#f2d58a] group-hover:flex"
className="mt-3 hidden h-8 items-center justify-center gap-1 rounded-md border border-white/10 text-[11px] text-white/50 transition hover:border-[#a2c638]/40 hover:text-[#c8cd19] group-hover:flex"
>
<Trash2 className="h-3.5 w-3.5" />
@@ -7981,9 +7998,11 @@ function MaterialCard({
)
}
function Metric({ label, value, compact }: { label: string; value: string; compact?: boolean }) {
type MetricVariant = "violet" | "lime" | "gold" | "teal" | "green"
function Metric({ label, value, compact, variant }: { label: string; value: string; compact?: boolean; variant?: MetricVariant }) {
return (
<div className={`skg-stat-card ${compact ? "px-2 py-1" : "px-2.5 py-1.5"}`}>
<div className={`skg-stat-card ${variant ? `skg-stat-card--${variant}` : ""} ${compact ? "px-2 py-1" : "px-2.5 py-1.5"}`}>
<div className="skg-stat-card__label">{label}</div>
<div className="skg-stat-card__value mt-0.5 truncate font-mono text-[13px] font-semibold">{value}</div>
</div>
@@ -8155,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>
@@ -8182,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)]">

View File

@@ -316,6 +316,54 @@ export async function getRuntimeHealth(): Promise<RuntimeHealth> {
return res.json()
}
export interface CreativeCopyVariant {
title: string
hook_zh: string
script_zh: string
script_en: string
image_prompt_en: string
video_prompt_en: string
caption_zh: string
hashtags: string[]
}
export interface CreativeCopyResult {
model: string
variants: CreativeCopyVariant[]
}
export async function generateCreativeCopy(body: {
goal: string
product?: string
audience?: string
platform?: string
tone?: string
seconds?: number
source_text?: string
}): Promise<CreativeCopyResult> {
const res = await fetch(`${API_BASE}/creative/copy`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw apiError("generateCreativeCopy", res.status, txt)
}
return res.json()
}
export async function createCreativeImageJob(file?: File | null): Promise<Job> {
const fd = new FormData()
if (file) fd.append("file", file)
const res = await fetch(`${API_BASE}/creative/jobs/image`, { method: "POST", body: fd })
if (!res.ok) {
const txt = await res.text().catch(() => "")
throw apiError("createCreativeImageJob", res.status, txt)
}
return res.json()
}
// 把 ImageRef 解析成可显示的 src URL
export function resolveImageRefUrl(jobId: string, ref: ImageRef): string {
if (ref.kind === "keyframe") {
@@ -953,6 +1001,11 @@ export interface ProductRefStateItem {
export interface Job {
id: string
url: string
owner_id?: string
owner_name?: string
owner_email?: string
owner_provider?: string
tenant_key?: string
status: JobStatus
progress: number
message?: string
@@ -975,6 +1028,11 @@ export interface BackendHealth {
ok: boolean
llm_configured: boolean
auth_configured?: boolean
auth_modes?: {
password?: boolean
feishu?: boolean
data_isolation?: boolean
}
base_url: string
models?: {
asr?: string
@@ -1071,6 +1129,9 @@ export async function deleteJob(id: string): Promise<{ ok: boolean; id: string }
export interface JobSummary {
id: string
url: string
owner_name?: string
owner_email?: string
owner_provider?: string
status: JobStatus
progress: number
message: string