11 Commits

20 changed files with 4614 additions and 3528 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 22, 2026 at 09:01
- 生成时间May 25, 2026 at 09:02
- 项目SKG Marketing Studio / SKG 营销内容工作台
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 状态active
@@ -9,7 +9,7 @@
## 最近助手会话概览
- Claudea9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
- Codex019e49d1-d114-7d52-8cf0-fbb4585f5ce6 · 时间未知
- Codex019e55d1-ccf1-7a90-805f-3bd4799a49cd · 时间未知
- Cursor未找到匹配当前项目的最近会话
## Claude 最近会话
@@ -92,57 +92,44 @@
## Codex 最近会话
- Session ID019e49d1-d114-7d52-8cf0-fbb4585f5ce6
- Transcript/Users/kangwan/.codex/sessions/2026/05/21/rollout-2026-05-21T17-15-47-019e49d1-d114-7d52-8cf0-fbb4585f5ce6.jsonl
- Session ID019e55d1-ccf1-7a90-805f-3bd4799a49cd
- Transcript/Users/kangwan/.codex/sessions/2026/05/24/rollout-2026-05-24T01-11-13-019e55d1-ccf1-7a90-805f-3bd4799a49cd.jsonl
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 分支main
- 敏感字段:已对 token / key / password / secret 做脱敏
### 最近用户要求
- 分镜这个板块,我每次点击视频 都变成了选择 其实我就是想打开看下而已 你这个逻辑不太对 你看下
- 为什么要选择呢 你给我点击下载的按钮不就好了
- 而且每个生成的视频当然是自动保存呀 还需要我点击保存干啥
- 还是不够简洁简单,我们再深度研究下怎么样的排列才最简单,别人一看就会用
- 我看一下样式你用figma画一下
- 设计的版面还是太差了 我找一下模版
### Codex 最近回复
> 生产部署正在跑第二轮远端备份和同步已经完成Web 镜像在构建;我等脚本验证结束。
> 我画了一版简化首页样式稿并打开了:
>
> - HTML 样式稿:`output/design/skg-simple-home-mockup.html`
> - 截图:`output/design/skg-simple-home-mockup-fullpage.png`
>
> 说明一下:当前会话没有可写入 Figma 云文件的 MCP 工具所以我先用本地“Figma 风格画板”做了可看的样式稿。这个稿不影响线上代码,也没有提交。
>
> 核心变化是:首屏只保留一个主流程:
> `选择产物 → 上传素材可选 → 写一句话需求 → 开始生成`
>
> `git status -sb` 当前仍只有 `.memory/worklog.json` 自动日志脏文件。
> 对,这个点你说得对。生成的视频本来就会写进当前 job 的 `generated_videos`,完成后自动回填 mp4不应该让用户理解成还要点“保存视频”
> 可以,你先找模板
>
> 已经改完并上线
> 建议你找这类方向的,不要找复杂 AI 工具后台
>
> - 分镜视频候选轨现在显示 `自动保存`
> - 视频候选普通点击只负责打开预览
> - 右上角提供显式 `下载` 按钮
> - “保存全部 / 保存本条规划”改成了“同步规划 / 同步本条规划”,只表达同步三字段文案规划,不再像保存视频
> - 源码解析页也同步更新了这条交互语义
> - “AI image generator dashboard”
> - “content creation workspace”
> - “simple prompt editor”
> - “Canva / CapCut style creation home”
> - “one page SaaS tool dashboard”
> - “creative brief generator UI”
>
> 验证和发布:
>
> - `web/npm run build` 通过
> - 已推送 Gitea
> - `4efb2ce fix: make storyboard video clicks previewable`
> - `eb4347a fix: clarify storyboard videos auto-save`
> - 已执行 `./scripts/deploy-prod-safe.sh` 上线,生产验证通过:`/login/ 200`、`/api/health 401`、容器内 `api:health ok`
>
> 当前 `git status -sb`
>
> ```text
> ## main...origin/main
> M .memory/worklog.json
> ```
>
> 只剩自动日志 `.memory/worklog.json`,业务代码和文档都已提交并推送。
>
> 补充:`npm run lint` 现在跑不了,因为项目里没有可执行的 `eslint``tsc --noEmit` 仍有一批既有的 dashboard/ui 旧类型错误,不是这次改动引入的。
>
> <oai-mem-citation>
> <citation_entries>
> MEMORY.md:214-286|note=[used SKG worktable context and docs update expectation]
> </citation_entries>
> <rollout_ids>
> 019e2fef-97a7-7523-a1ff-051a8c6…
> 最适合我们的是:中间一个大输入区,顶部或左侧选择产物,右侧只放最近任务/结果。不要找那种很多统计卡、很多侧边栏菜单、很多模型参数的模板,那类还是会把普通同事绕晕。
## Cursor 最近会话
@@ -152,7 +139,7 @@
- 当前分支main
- 未提交变更1 项
- 最近提交:fix: clarify storyboard videos auto-save
- 最近提交: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 视图,再汇合产品素材池、分镜口播和视频候选生成。",
"description" : "SKG 营销内容多人创作平台:默认首页面向公司团队成员的个人隔离创作空间,主路径为文生图、图生图、文生视频、图生视频和营销图文方案生成;每个登录用户只看到自己的任务和结果。任务详情页沉淀参考图、生成图、视频候选、提示词和图文方案,可继续生成、删除和复用。旧 TK 复刻\/一键出片能力保留为高级入口,不再作为默认工作台。",
"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"

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,13 +11,14 @@
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`
- 第一冲刺:步骤 1-4下载 / 拆轨 / 关键帧 / ASR+翻译)
- 当前产品方向2026-05-20 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”;工作台已取消 1800x1000 固定画布和整页缩放,改为正常流式桌面容器,宽度跟随浏览器展开,只保留 1280px 最低操作宽度防止核心表格被压烂,不再通过应用层 `zoom` 把整页缩小导致文字发虚。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路自动识别中文、英文和其他多语言原音频文案/字幕,统一补齐中文镜像,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区主体链路改为“上方参考帧池 + 转换层、下方主体元素结果栏”:参考帧池竖向排列;转换层是轻量对话式生图确认区,参考图可通过左侧缩略图 `+`、参考帧拖拽、胶片拖拽或本地图片拖入进入转换层,用户选择 GPT/Gemini 套件后先分析参考图;识别结果里的特征 chip 只作为“保留元素”本地选择,点亮=保留、再点取消,点击不立即请求模型,随下一条发送消息提交;用户再在下方发送区发送复刻/创新/卡通和画面要求,界面只保留生成要求输入框、张数控件和提示词就绪状态,不展示当前要求摘要、保留元素副本、收起记录计数或重复模型确认话术,生成数量通过发送区旁边的张数控件控制;后端返回英文出图 prompt 后不再自动弹窗,发送区主按钮直接切换为“确认生成 N 张”,用户点击才生成对应数量的统一多角度套图。主体元素结果栏在转换层下方横向展示套图输出、文件夹分组、单张重生、删除和 hover 预览,空态只保留紧凑提示,不再挤占右侧整列。旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不再作为客户默认闸门
- 当前产品方向2026-05-24 重设计):默认首页彻底从“信息流广告复刻管线”切换为多人通用的 SKG 营销内容创作平台,服务约 6 名公司成员同时使用。主路径是文生图、图生图、文生视频、图生视频和营销图文方案生成;用户登录后只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离。首页结构为左侧创作入口 + 参考图 + 我的任务,中间创作台,右侧当前任务结果;任务详情页固定为 `/detail/?job=<id>`,沉淀参考图、生成图、视频候选、提示词和图文方案,并支持继续生成、删除和复用。旧 TK 复刻工作台和 Agent Cut 一键出片保留为高级入口,不再作为默认工作台或默认理解框架
## 部署事实
- 平台VPS `76.13.31.179`Ubuntu 24.04 / Docker Compose / Coolify Traefik
- Agent Cut 独立预览服务器:`2.24.28.41`Ubuntu 24.04 / Docker Compose / 裸端口 `4290`),部署目录 `/opt/skg-marketing-studio`Compose 入口 `docker-compose.standalone.yml`,访问地址 `http://2.24.28.41:4290/agent/`。该入口用于“一分钟二创出片终端”预览:用户只提交 TikTok 链接和产品图,后端 `AgentRun` 状态机负责下载、抽帧、规划、生成、自动重跑、审片和合成。
- Agent Cut 独立预览验证2026-05-21已在 `2.24.28.41``/opt/skg-marketing-studio``docker-compose.standalone.yml` 启动 `skg-agent-api` / `skg-agent-web`;独立 compose 通过网络别名兼容 Nginx 的 `skg-marketing-api` upstream。该裸 IP HTTP 入口的服务器 `deploy/.env.production` 需要 `WEB_AUTH_COOKIE_SECURE=false`;本次已补齐 `WEB_AUTH_*` 后重启验证通过:未登录 `/agent/` 返回 302 到 `/login/`,登录后 `/agent/` 返回 200`/api/agent-runs` 返回数组,容器内 `/health` 返回 `ok:true``auth_configured:true`
- 发布状态已部署并验证2026-05-20主体元素按套图文件夹分组展示主体生成接口提交后立即返回 queued 占位并后台逐视角生成、逐张回填;工作台外层取消 1800x1000 固定画布和应用层 `zoom` 缩放,改为正常流式桌面容器,最低操作宽度 1280px源视频工作区主体链路为上方竖向参考帧池 + 宽幅对话式转换层、下方主体元素结果栏;转换层通过参考帧 `+` 加入、参考图分析、生图对话,英文 prompt 就绪后由发送区主按钮切换为确认生成,点击后才触发主体套图生成;转换层不再固定 640px 长高,按内容自然高度显示,仅以 560px 最大高度兜底内部滚动;下方主体元素结果栏的套图输出、轮询、文件夹分组、单张重生、删除和 hover 预览逻辑保持不变;胶片双击/拖拽加入参考帧池 + 胶片缓存复用 + 音频解析失败可重试,参考帧缩略图保持小尺寸 9:16 比例 + hover 左侧紧凑预览,旧主体模板区移出主路径 + 逐句时间轴移到原版视频下方并支持双行文案 + 波形同框时间对齐画面胶片 + 胶片密度按钮上移波形顶部 + 去分隔线 + 胶片上下错落 + body 顶层原位大放大 + 隐藏源视频工作区音频解析摘要卡 + 隐藏工作区顶部状态提示条 + 三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401认证后首页 200容器内 `/health` 返回 `ok:true`
- 最近部署验证2026-05-24`828b86d` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,生产入口切换为多人通用营销内容创作平台首页,并保留 `/agent/` 作为高级复刻入口、`/detail/?job=<id>` 作为任务详情页。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260523175306.tgz`;生产 Docker 重建后脚本内验证通过web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok``api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。
- 最近部署验证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`;对应工作台取消固定画布缩放,按浏览器正常流式布局渲染。
@@ -66,14 +67,15 @@
- 当前音频解析:`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`
@@ -121,7 +123,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

@@ -4,6 +4,7 @@ import asyncio
import base64
import hashlib
import hmac
import io
import json
import os
import random
@@ -17,12 +18,13 @@ import uuid
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Literal
from urllib.parse import urlencode
import httpx
from dotenv import load_dotenv
from fastapi import BackgroundTasks, FastAPI, File, Form, HTTPException, Request, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from pydantic import BaseModel, ConfigDict, Field
load_dotenv()
@@ -194,7 +196,30 @@ WEB_AUTH_PASSWORD = os.getenv("WEB_AUTH_PASSWORD", "").strip()
WEB_AUTH_SESSION_SECRET = os.getenv("WEB_AUTH_SESSION_SECRET", "").strip()
WEB_AUTH_COOKIE_NAME = os.getenv("WEB_AUTH_COOKIE_NAME", "skg_marketing_session").strip() or "skg_marketing_session"
WEB_AUTH_COOKIE_SECURE = os.getenv("WEB_AUTH_COOKIE_SECURE", "true").strip().lower() not in {"0", "false", "no"}
WEB_AUTH_CONFIGURED = bool(WEB_AUTH_USERNAME and WEB_AUTH_PASSWORD and WEB_AUTH_SESSION_SECRET)
FEISHU_APP_ID = (os.getenv("FEISHU_APP_ID") or os.getenv("FEISHU_CLIENT_ID") or "").strip()
FEISHU_APP_SECRET = (os.getenv("FEISHU_APP_SECRET") or os.getenv("FEISHU_CLIENT_SECRET") or "").strip()
FEISHU_REDIRECT_URI = os.getenv("FEISHU_REDIRECT_URI", "").strip()
FEISHU_OAUTH_SCOPE = os.getenv("FEISHU_OAUTH_SCOPE", "").strip()
FEISHU_AUTHORIZE_URL = os.getenv(
"FEISHU_AUTHORIZE_URL",
"https://accounts.feishu.cn/open-apis/authen/v1/authorize",
).strip()
FEISHU_TOKEN_URL = os.getenv(
"FEISHU_TOKEN_URL",
"https://open.feishu.cn/open-apis/authen/v2/oauth/token",
).strip()
FEISHU_USER_INFO_URL = os.getenv(
"FEISHU_USER_INFO_URL",
"https://open.feishu.cn/open-apis/authen/v1/user_info",
).strip()
FEISHU_STATE_COOKIE_NAME = os.getenv("FEISHU_STATE_COOKIE_NAME", "skg_feishu_oauth_state").strip() or "skg_feishu_oauth_state"
FEISHU_ALLOWED_EMAIL_DOMAINS = os.getenv("FEISHU_ALLOWED_EMAIL_DOMAINS", "").strip()
FEISHU_ALLOWED_EMAILS = os.getenv("FEISHU_ALLOWED_EMAILS", "").strip()
FEISHU_ALLOWED_TENANT_KEYS = os.getenv("FEISHU_ALLOWED_TENANT_KEYS", "").strip()
AUTH_DATA_ISOLATION_ENABLED = os.getenv("AUTH_DATA_ISOLATION_ENABLED", "true").strip().lower() not in {"0", "false", "no", "off"}
PASSWORD_AUTH_CONFIGURED = bool(WEB_AUTH_USERNAME and WEB_AUTH_PASSWORD and WEB_AUTH_SESSION_SECRET)
FEISHU_AUTH_CONFIGURED = bool(FEISHU_APP_ID and FEISHU_APP_SECRET and WEB_AUTH_SESSION_SECRET)
WEB_AUTH_CONFIGURED = bool(PASSWORD_AUTH_CONFIGURED or FEISHU_AUTH_CONFIGURED)
def default_video_gateway_paths(base_url: str) -> tuple[str, str, str]:
@@ -794,6 +819,11 @@ class SubjectAgentState(BaseModel):
class Job(BaseModel):
id: str
url: str
owner_id: str = ""
owner_name: str = ""
owner_email: str = ""
owner_provider: str = ""
tenant_key: str = ""
status: JobStatus = "created"
progress: int = 0
message: str = ""
@@ -827,7 +857,17 @@ AUDIO_WORKERS_LOCK = threading.Lock()
def ensure_auth_configured() -> None:
if not WEB_AUTH_CONFIGURED:
raise HTTPException(503, "WEB_AUTH_USERNAME、WEB_AUTH_PASSWORD 或 WEB_AUTH_SESSION_SECRET 未配置")
raise HTTPException(503, "WEB_AUTH_SESSION_SECRET 以及账号密码或飞书 OAuth 未配置")
def ensure_password_auth_configured() -> None:
if not PASSWORD_AUTH_CONFIGURED:
raise HTTPException(503, "账号密码登录未配置")
def ensure_feishu_auth_configured() -> None:
if not FEISHU_AUTH_CONFIGURED:
raise HTTPException(503, "飞书免登录未配置")
def _auth_signature(body: str) -> str:
@@ -846,16 +886,80 @@ def _decode_auth_body(body: str) -> dict:
return data if isinstance(data, dict) else {}
def make_auth_token(username: str, ttl_seconds: int) -> str:
body = _encode_auth_body({
"u": username,
def _csv_values(raw: str) -> set[str]:
return {item.strip().lower() for item in raw.split(",") if item.strip()}
def _normalize_next_url(value: str | None) -> str:
value = (value or "/").strip() or "/"
if not value.startswith("/") or value.startswith("//"):
return "/"
return value
def _public_base_url(request: Request) -> str:
proto = request.headers.get("x-forwarded-proto") or request.url.scheme
host = request.headers.get("host") or request.url.netloc
return f"{proto}://{host}".rstrip("/")
def _feishu_redirect_uri(request: Request) -> str:
if FEISHU_REDIRECT_URI:
return FEISHU_REDIRECT_URI
return f"{_public_base_url(request)}/api/auth/feishu/callback"
def _session_user_id(payload: dict | None) -> str:
payload = payload or {}
explicit = str(payload.get("uid") or "").strip()
if explicit:
return explicit
provider = str(payload.get("provider") or "").strip().lower()
if provider == "feishu":
for key in ("open_id", "union_id", "email", "u"):
value = str(payload.get(key) or "").strip()
if value:
return f"feishu:{value.lower() if key == 'email' else value}"
username = str(payload.get("u") or "").strip() or "anonymous"
return f"password:{username}"
def _public_session(payload: dict) -> dict:
return {
"uid": _session_user_id(payload),
"provider": str(payload.get("provider") or "password"),
"username": str(payload.get("u") or payload.get("name") or ""),
"name": str(payload.get("name") or payload.get("u") or ""),
"email": str(payload.get("email") or ""),
"open_id": str(payload.get("open_id") or ""),
"union_id": str(payload.get("union_id") or ""),
"tenant_key": str(payload.get("tenant_key") or ""),
"avatar_url": str(payload.get("avatar_url") or ""),
}
def make_auth_token(user: str | dict, ttl_seconds: int) -> str:
if isinstance(user, str):
payload = {
"u": user,
"name": user,
"provider": "password",
"uid": f"password:{user}",
}
else:
payload = dict(user)
payload["uid"] = _session_user_id(payload)
payload.setdefault("u", payload.get("name") or payload.get("email") or payload["uid"])
payload.setdefault("name", payload.get("u") or payload["uid"])
payload.update({
"exp": int(time.time()) + ttl_seconds,
"n": secrets.token_hex(8),
})
body = _encode_auth_body(payload)
return f"{body}.{_auth_signature(body)}"
def verify_auth_token(token: str) -> str | None:
def verify_auth_token(token: str) -> dict | None:
if not WEB_AUTH_CONFIGURED or "." not in token:
return None
body, supplied_sig = token.rsplit(".", 1)
@@ -867,14 +971,228 @@ def verify_auth_token(token: str) -> str | None:
expires_at = int(payload.get("exp") or 0)
except Exception:
return None
if username != WEB_AUTH_USERNAME or expires_at < int(time.time()):
if expires_at < int(time.time()):
return None
return username
provider = str(payload.get("provider") or "").strip().lower()
if not provider:
provider = "password" if username else ""
if provider == "password":
if not PASSWORD_AUTH_CONFIGURED or username != WEB_AUTH_USERNAME:
return None
payload["provider"] = "password"
payload["uid"] = f"password:{username}"
payload.setdefault("name", username)
return _public_session(payload)
if provider == "feishu":
if not FEISHU_AUTH_CONFIGURED:
return None
payload["provider"] = "feishu"
payload["uid"] = _session_user_id(payload)
return _public_session(payload)
return None
def auth_session_from_request(request: Request) -> dict | None:
token = request.cookies.get(WEB_AUTH_COOKIE_NAME, "")
return verify_auth_token(token)
def auth_username_from_request(request: Request) -> str | None:
token = request.cookies.get(WEB_AUTH_COOKIE_NAME, "")
return verify_auth_token(token)
session = auth_session_from_request(request)
return str(session.get("username") or session.get("name") or session.get("uid")) if session else None
def data_user_from_request(request: Request) -> dict:
session = auth_session_from_request(request)
if session:
return session
if not WEB_AUTH_CONFIGURED:
return {"uid": "local:dev", "provider": "local", "username": "local-dev", "name": "local-dev", "email": "", "tenant_key": ""}
raise HTTPException(401, "unauthorized")
def _is_password_session(user: dict | None) -> bool:
return bool(user and str(user.get("provider") or "") == "password")
def assign_owner(model: Job | "AgentRun", user: dict) -> None:
model.owner_id = _session_user_id(user)
model.owner_name = str(user.get("name") or user.get("username") or model.owner_id)
model.owner_email = str(user.get("email") or "")
model.owner_provider = str(user.get("provider") or "")
model.tenant_key = str(user.get("tenant_key") or "")
def user_can_access_job(job: Job | None, user: dict | None) -> bool:
if not job:
return False
if not AUTH_DATA_ISOLATION_ENABLED or not WEB_AUTH_CONFIGURED:
return True
owner_id = str(getattr(job, "owner_id", "") or "").strip()
if owner_id:
return bool(user and owner_id == _session_user_id(user))
return _is_password_session(user)
def _load_agent_run_for_access(run_id: str):
run = AGENT_RUNS.get(run_id)
if not run and agent_run_path(run_id).exists():
try:
run = AgentRun.model_validate_json(agent_run_path(run_id).read_text(encoding="utf-8"))
AGENT_RUNS[run_id] = run
except Exception:
return None
return run
def user_can_access_agent_run(run_id: str, user: dict | None) -> bool:
if not AUTH_DATA_ISOLATION_ENABLED or not WEB_AUTH_CONFIGURED:
return True
run = _load_agent_run_for_access(run_id)
if not run:
return False
owner_id = str(getattr(run, "owner_id", "") or "").strip()
if owner_id:
return bool(user and owner_id == _session_user_id(user))
return user_can_access_job(JOBS.get(run.job_id), user) or _is_password_session(user)
JOB_PATH_RE = re.compile(r"^/jobs/([0-9a-f]{8,32})(?:/|$)")
COPY_TO_JOB_PATH_RE = re.compile(r"^/asset-library/[^/]+/[^/]+/copy-to-job/([0-9a-f]{8,32})(?:/|$)")
AGENT_RUN_PATH_RE = re.compile(r"^/agent-runs/([0-9a-f]{8,32})(?:/|$)")
def _extract_protected_job_id(path: str) -> str:
for pattern in (JOB_PATH_RE, COPY_TO_JOB_PATH_RE):
match = pattern.match(path)
if match:
return match.group(1)
return ""
def _feishu_oauth_state(next_url: str) -> str:
body = _encode_auth_body({
"kind": "feishu_oauth_state",
"next": _normalize_next_url(next_url),
"exp": int(time.time()) + 600,
"n": secrets.token_hex(12),
})
return f"{body}.{_auth_signature(body)}"
def _verify_feishu_oauth_state(token: str) -> dict | None:
if not token or "." not in token:
return None
body, supplied_sig = token.rsplit(".", 1)
if not hmac.compare_digest(_auth_signature(body), supplied_sig):
return None
try:
payload = _decode_auth_body(body)
except Exception:
return None
if payload.get("kind") != "feishu_oauth_state" or int(payload.get("exp") or 0) < int(time.time()):
return None
payload["next"] = _normalize_next_url(str(payload.get("next") or "/"))
return payload
def _feishu_authorize_url(request: Request, state: str) -> str:
params = {
"client_id": FEISHU_APP_ID,
"redirect_uri": _feishu_redirect_uri(request),
"response_type": "code",
"state": state,
}
if FEISHU_OAUTH_SCOPE:
params["scope"] = FEISHU_OAUTH_SCOPE
return f"{FEISHU_AUTHORIZE_URL}?{urlencode(params)}"
def _exchange_feishu_code(code: str, redirect_uri: str) -> str:
payload = {
"grant_type": "authorization_code",
"client_id": FEISHU_APP_ID,
"client_secret": FEISHU_APP_SECRET,
"code": code,
"redirect_uri": redirect_uri,
}
with httpx.Client(timeout=20) as client:
response = client.post(FEISHU_TOKEN_URL, json=payload)
response.raise_for_status()
data = response.json()
if data.get("code") not in (None, 0, "0"):
raise HTTPException(401, f"飞书授权失败:{data.get('msg') or data.get('message') or data.get('code')}")
token_data = data.get("data") if isinstance(data.get("data"), dict) else data
token = str(
token_data.get("access_token")
or token_data.get("user_access_token")
or token_data.get("accessToken")
or ""
).strip()
if not token:
raise HTTPException(401, "飞书授权未返回 user_access_token")
return token
def _fetch_feishu_user(access_token: str) -> dict:
with httpx.Client(timeout=20) as client:
response = client.get(FEISHU_USER_INFO_URL, headers={"Authorization": f"Bearer {access_token}"})
response.raise_for_status()
data = response.json()
if data.get("code") not in (None, 0, "0"):
raise HTTPException(401, f"飞书用户信息获取失败:{data.get('msg') or data.get('message') or data.get('code')}")
user = data.get("data") if isinstance(data.get("data"), dict) else data
if not isinstance(user, dict):
raise HTTPException(401, "飞书用户信息格式异常")
return user
def _build_feishu_session(user: dict) -> dict:
email = str(user.get("email") or user.get("enterprise_email") or "").strip().lower()
open_id = str(user.get("open_id") or "").strip()
union_id = str(user.get("union_id") or "").strip()
tenant_key = str(user.get("tenant_key") or "").strip()
name = str(user.get("name") or user.get("en_name") or user.get("nickname") or email or open_id or union_id or "Feishu User").strip()
avatar_url = str(
user.get("avatar_url")
or user.get("avatar_thumb")
or user.get("avatar_middle")
or user.get("avatar_big")
or ""
).strip()
session = {
"provider": "feishu",
"u": name,
"name": name,
"email": email,
"open_id": open_id,
"union_id": union_id,
"tenant_key": tenant_key,
"avatar_url": avatar_url,
}
session["uid"] = _session_user_id(session)
return session
def _validate_feishu_session(session: dict) -> None:
allowed_emails = _csv_values(FEISHU_ALLOWED_EMAILS)
allowed_domains = {item.lstrip("@") for item in _csv_values(FEISHU_ALLOWED_EMAIL_DOMAINS)}
allowed_tenants = _csv_values(FEISHU_ALLOWED_TENANT_KEYS)
email = str(session.get("email") or "").lower()
domain = email.rsplit("@", 1)[1] if "@" in email else ""
tenant_key = str(session.get("tenant_key") or "").lower()
if allowed_emails and email not in allowed_emails:
raise HTTPException(403, "当前飞书账号不在允许登录名单")
if allowed_domains and domain not in allowed_domains:
raise HTTPException(403, "当前飞书账号邮箱域不允许登录")
if allowed_tenants and tenant_key not in allowed_tenants:
raise HTTPException(403, "当前飞书租户不允许登录")
def job_dir(job_id: str) -> Path:
@@ -1474,7 +1792,7 @@ async def lifespan(_: FastAPI):
yield
app = FastAPI(title="SKG TK 二创 API", lifespan=lifespan)
app = FastAPI(title="SKG 营销内容工作台 API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ORIGINS,
@@ -1484,17 +1802,56 @@ app.add_middleware(
)
@app.middleware("http")
async def enforce_data_isolation(request: Request, call_next):
path = request.url.path
if AUTH_DATA_ISOLATION_ENABLED and WEB_AUTH_CONFIGURED:
try:
user = data_user_from_request(request)
except HTTPException:
user = None
job_id = _extract_protected_job_id(path)
if job_id and not user_can_access_job(JOBS.get(job_id), user):
return JSONResponse({"detail": "job not found"}, status_code=404)
run_match = AGENT_RUN_PATH_RE.match(path)
if run_match and not user_can_access_agent_run(run_match.group(1), user):
return JSONResponse({"detail": "agent run not found"}, status_code=404)
return await call_next(request)
@app.get("/auth/check")
def auth_check(request: Request) -> Response:
ensure_auth_configured()
if not auth_username_from_request(request):
if not auth_session_from_request(request):
raise HTTPException(401, "unauthorized")
return Response(status_code=204)
@app.get("/auth/config")
def auth_config() -> dict:
return {
"ok": True,
"auth_configured": WEB_AUTH_CONFIGURED,
"password_enabled": PASSWORD_AUTH_CONFIGURED,
"feishu_enabled": FEISHU_AUTH_CONFIGURED,
"data_isolation_enabled": AUTH_DATA_ISOLATION_ENABLED,
}
@app.get("/auth/me")
def auth_me(request: Request) -> dict:
session = auth_session_from_request(request)
if not session:
raise HTTPException(401, "unauthorized")
return {"ok": True, "user": session}
@app.post("/auth/login")
def auth_login(payload: AuthLoginPayload, response: Response) -> dict:
ensure_auth_configured()
ensure_password_auth_configured()
username = payload.username.strip()
password = payload.password
valid_user = hmac.compare_digest(username, WEB_AUTH_USERNAME)
@@ -1515,6 +1872,66 @@ def auth_login(payload: AuthLoginPayload, response: Response) -> dict:
return {"ok": True, "username": WEB_AUTH_USERNAME}
@app.get("/auth/feishu/start")
def auth_feishu_start(request: Request) -> RedirectResponse:
ensure_feishu_auth_configured()
next_url = _normalize_next_url(request.query_params.get("next"))
state = _feishu_oauth_state(next_url)
response = RedirectResponse(_feishu_authorize_url(request, state), status_code=302)
response.set_cookie(
key=FEISHU_STATE_COOKIE_NAME,
value=state,
max_age=600,
httponly=True,
secure=WEB_AUTH_COOKIE_SECURE,
samesite="lax",
path="/",
)
return response
@app.get("/auth/feishu/callback")
def auth_feishu_callback(request: Request) -> RedirectResponse:
ensure_feishu_auth_configured()
if request.query_params.get("error"):
raise HTTPException(401, f"飞书授权取消或失败:{request.query_params.get('error')}")
code = str(request.query_params.get("code") or "").strip()
supplied_state = str(request.query_params.get("state") or "").strip()
cookie_state = request.cookies.get(FEISHU_STATE_COOKIE_NAME, "")
if not code:
raise HTTPException(400, "missing feishu code")
if not supplied_state or not cookie_state or not hmac.compare_digest(supplied_state, cookie_state):
raise HTTPException(401, "invalid feishu state")
state_payload = _verify_feishu_oauth_state(supplied_state)
if not state_payload:
raise HTTPException(401, "expired feishu state")
access_token = _exchange_feishu_code(code, _feishu_redirect_uri(request))
session = _build_feishu_session(_fetch_feishu_user(access_token))
_validate_feishu_session(session)
ttl_seconds = 60 * 60 * 24 * 30
response = RedirectResponse(_normalize_next_url(str(state_payload.get("next") or "/")), status_code=302)
response.set_cookie(
key=WEB_AUTH_COOKIE_NAME,
value=make_auth_token(session, ttl_seconds),
max_age=ttl_seconds,
httponly=True,
secure=WEB_AUTH_COOKIE_SECURE,
samesite="lax",
path="/",
)
response.delete_cookie(
key=FEISHU_STATE_COOKIE_NAME,
path="/",
secure=WEB_AUTH_COOKIE_SECURE,
samesite="lax",
)
return response
@app.post("/auth/logout")
def auth_logout(response: Response) -> dict:
response.delete_cookie(
@@ -2224,8 +2641,8 @@ SUBJECT_VIEW_LABELS: dict[str, str] = {
"three_quarter_right": "右前 45°",
"side": "侧面",
"side_walk": "侧面走路",
"top": "顶部视角",
"bottom": "底部视角",
"top": "正投影俯视图",
"bottom": "正投影仰视图",
"expression_neutral": "中性表情",
"expression_smile": "微笑表情",
"expression_happy": "开心表情",
@@ -2247,6 +2664,15 @@ SUBJECT_VIEW_LABELS: dict[str, str] = {
"back_detail": "背部特写",
}
OBJECT_PATENT_VIEW_LABELS: dict[str, str] = {
"front": "正投影主视图",
"back": "正投影后视图",
"left": "正投影左视图",
"right": "正投影右视图",
"top": "正投影俯视图",
"bottom": "正投影仰视图",
}
def _subject_view_labels(kind: SubjectKind, requested: list[str] | None = None) -> list[tuple[SubjectView, str]]:
if requested:
@@ -2255,7 +2681,8 @@ def _subject_view_labels(kind: SubjectKind, requested: list[str] | None = None)
key = "".join(ch for ch in str(raw).strip().lower() if ch.isalnum() or ch == "_")
if key and key not in normalized:
normalized.append(key)
return [(key, SUBJECT_VIEW_LABELS.get(key, key.replace("_", " "))) for key in normalized[:10]]
labels = OBJECT_PATENT_VIEW_LABELS if kind == "object" else SUBJECT_VIEW_LABELS
return [(key, labels.get(key, SUBJECT_VIEW_LABELS.get(key, key.replace("_", " ")))) for key in normalized[:10]]
if kind == "living":
return [
("front", "正面站立"),
@@ -2270,15 +2697,51 @@ def _subject_view_labels(kind: SubjectKind, requested: list[str] | None = None)
("back_neck_detail", "后颈/肩背特写"),
]
return [
("front", "正面"),
("back", "背面"),
("left", "左侧"),
("right", "右侧"),
("top", "顶部"),
("bottom", "底部"),
("front", OBJECT_PATENT_VIEW_LABELS["front"]),
("back", OBJECT_PATENT_VIEW_LABELS["back"]),
("left", OBJECT_PATENT_VIEW_LABELS["left"]),
("right", OBJECT_PATENT_VIEW_LABELS["right"]),
("top", OBJECT_PATENT_VIEW_LABELS["top"]),
("bottom", OBJECT_PATENT_VIEW_LABELS["bottom"]),
]
def _subject_view_projection_clause(view: str) -> str:
if view == "front":
return (
"Patent-style orthographic main/front elevation view: look straight at the designated main face, "
"with the viewing direction perpendicular to that face. No perspective, no tilt, no 3/4 angle, no isometric view. "
)
if view == "back":
return (
"Patent-style orthographic rear elevation view: look straight at the rear face, "
"with the viewing direction perpendicular to that face. No perspective, no tilt, no 3/4 angle, no isometric view. "
)
if view == "left":
return (
"Patent-style orthographic left side elevation view: look straight at the product's left side, "
"with the viewing direction perpendicular to that side face. No perspective, no tilt, no 3/4 angle, no isometric view. "
)
if view == "right":
return (
"Patent-style orthographic right side elevation view: look straight at the product's right side, "
"with the viewing direction perpendicular to that side face. No perspective, no tilt, no 3/4 angle, no isometric view. "
)
if view == "top":
return (
"Patent-style orthographic top view: look straight down from directly above the product, "
"with the viewing direction perpendicular to the top face. No perspective, no tilt, no 3/4 angle, "
"no oblique overhead camera, no visible front/side depth unless it is true product thickness in orthographic projection. "
)
if view == "bottom":
return (
"Patent-style orthographic bottom view: look straight up at the underside/bottom face, "
"with the viewing direction perpendicular to the bottom face. No perspective, no tilt, no 3/4 angle, "
"no low-angle perspective camera, no visible front/side depth unless it is true product thickness in orthographic projection. "
)
return ""
def _attach_temporal_metrics(items: list[dict]) -> None:
"""相邻低清帧差异:转场 / 动作目标依赖它,不需要逐帧高分辨率扫描。"""
for i, it in enumerate(items):
@@ -4275,6 +4738,32 @@ class TranslateReq(BaseModel):
target: Literal["en", "zh"] = "en"
class CreativeCopyReq(BaseModel):
goal: str
product: str = ""
audience: str = ""
platform: str = "TikTok / Reels"
tone: str = "direct"
seconds: int = 20
source_text: str = ""
class CreativeCopyVariant(BaseModel):
title: str = ""
hook_zh: str = ""
script_zh: str = ""
script_en: str = ""
image_prompt_en: str = ""
video_prompt_en: str = ""
caption_zh: str = ""
hashtags: list[str] = Field(default_factory=list)
class CreativeCopyResp(BaseModel):
model: str
variants: list[CreativeCopyVariant]
class ScriptRewriteSegmentReq(BaseModel):
index: int
start: float = 0.0
@@ -4339,6 +4828,74 @@ def _ensure_english(text: str) -> str:
return text
def _creative_copy_fallback(req: CreativeCopyReq) -> CreativeCopyResp:
goal = req.goal.strip() or "展示 SKG 产品的核心卖点"
product = req.product.strip() or "SKG 健康科技产品"
seconds = max(6, min(60, int(req.seconds or 20)))
script_zh = (
f"开场 0-3 秒:直接展示{product}和使用场景,提出一个具体痛点。\n"
f"中段 3-{max(4, seconds - 5)} 秒:用三个连续镜头说明{goal},画面保持产品清晰可见。\n"
f"结尾 {max(4, seconds - 5)}-{seconds} 秒:给出一句明确行动口播,收在产品近景。"
)
script_en = _ensure_english(script_zh)
image_prompt = _ensure_english(
f"{product}, premium health-tech product advertising image, clean lifestyle scene, clear product visibility, natural lighting, vertical composition"
)
video_prompt = _ensure_english(
f"{seconds}-second vertical short video ad for {product}. {goal}. Start with the product in use, show one clear benefit, keep camera motion smooth, realistic lifestyle lighting, no medical treatment claims."
)
return CreativeCopyResp(
model="fallback",
variants=[
CreativeCopyVariant(
title="快速成片版",
hook_zh=f"{product},把一个日常痛点变成一个清楚的使用理由。",
script_zh=script_zh,
script_en=script_en,
image_prompt_en=image_prompt,
video_prompt_en=video_prompt,
caption_zh=f"{product}{goal}",
hashtags=["#SKG", "#健康科技", "#短视频广告"],
)
],
)
def _parse_creative_copy_response(raw: str, req: CreativeCopyReq) -> CreativeCopyResp:
text = (raw or "").strip()
text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I).strip()
text = re.sub(r"\s*```$", "", text).strip()
match = re.search(r"\{[\s\S]*\}", text)
json_text = match.group(0) if match else text
try:
data = json.loads(json_text)
except Exception:
return _creative_copy_fallback(req)
raw_items = data.get("variants") if isinstance(data, dict) else None
if not isinstance(raw_items, list):
return _creative_copy_fallback(req)
variants: list[CreativeCopyVariant] = []
for item in raw_items[:3]:
if not isinstance(item, dict):
continue
hashtags = item.get("hashtags") or []
if not isinstance(hashtags, list):
hashtags = []
variants.append(CreativeCopyVariant(
title=str(item.get("title") or "").strip()[:80],
hook_zh=str(item.get("hook_zh") or "").strip()[:180],
script_zh=str(item.get("script_zh") or "").strip()[:900],
script_en=_ensure_english(str(item.get("script_en") or item.get("script_zh") or "").strip())[:1200],
image_prompt_en=_ensure_english(str(item.get("image_prompt_en") or "").strip())[:1200],
video_prompt_en=_ensure_english(str(item.get("video_prompt_en") or "").strip())[:1400],
caption_zh=str(item.get("caption_zh") or "").strip()[:240],
hashtags=[str(tag).strip()[:40] for tag in hashtags if str(tag).strip()][:8],
))
if not variants:
return _creative_copy_fallback(req)
return CreativeCopyResp(model=REWRITE_MODEL if LLM_API_KEY else "fallback", variants=variants)
@app.post("/translate")
def translate_text(req: TranslateReq) -> dict:
"""单条文本翻译(给生图自定义提取元素 zh→en 用)"""
@@ -4374,6 +4931,44 @@ def translate_text(req: TranslateReq) -> dict:
raise HTTPException(500, f"translate failed: {e}")
@app.post("/creative/copy", response_model=CreativeCopyResp)
def generate_creative_copy(req: CreativeCopyReq) -> CreativeCopyResp:
goal = req.goal.strip()
if not goal:
raise HTTPException(400, "goal required")
if not LLM_API_KEY:
return _creative_copy_fallback(req)
seconds = max(6, min(60, int(req.seconds or 20)))
prompt = (
"You are creating practical short-form ad material for an SKG AI creative tool. "
"Return strict JSON only. Create 3 distinct variants that can be pasted directly into image/video generation. "
"Avoid medical treatment claims; describe comfort, relaxation, daily use, visual proof, and product clarity instead. "
"Every variant must include title, hook_zh, script_zh, script_en, image_prompt_en, video_prompt_en, caption_zh, hashtags.\n\n"
f"Goal: {goal}\n"
f"Product: {req.product.strip() or 'SKG health-tech product'}\n"
f"Audience: {req.audience.strip() or 'short-form shoppers'}\n"
f"Platform: {req.platform.strip() or 'TikTok / Reels'}\n"
f"Tone: {req.tone.strip() or 'direct'}\n"
f"Length: {seconds}s\n"
f"Source/reference text:\n{req.source_text.strip()[:1500]}"
)
try:
resp = llm().chat.completions.create(
model=REWRITE_MODEL,
messages=[
{"role": "system", "content": "Return valid JSON only. No markdown. No explanation."},
{"role": "user", "content": prompt},
],
response_format={"type": "json_object"},
temperature=0.72,
max_tokens=2200,
)
return _parse_creative_copy_response(resp.choices[0].message.content or "", req)
except Exception as e:
print(f"[creative copy fallback] {e}", flush=True)
return _creative_copy_fallback(req)
def _fallback_script_rewrite_item(segment: ScriptRewriteSegmentReq, author_intent: str = "") -> dict:
source = (segment.source or "").strip()
intent = _ensure_english(author_intent or "")
@@ -4510,6 +5105,11 @@ def health() -> dict:
"ok": True,
"llm_configured": bool(LLM_API_KEY),
"auth_configured": WEB_AUTH_CONFIGURED,
"auth_modes": {
"password": PASSWORD_AUTH_CONFIGURED,
"feishu": FEISHU_AUTH_CONFIGURED,
"data_isolation": AUTH_DATA_ISOLATION_ENABLED,
},
"base_url": LLM_BASE_URL or "openai-default",
"asr_base_url": ASR_BASE_URL or LLM_BASE_URL or "openai-default",
"image_base_url": IMAGE_BASE_URL or LLM_BASE_URL or "openai-default",
@@ -4557,6 +5157,9 @@ def health() -> dict:
class JobSummary(BaseModel):
id: str
url: str
owner_name: str = ""
owner_email: str = ""
owner_provider: str = ""
status: JobStatus
progress: int = 0
message: str = ""
@@ -4572,16 +5175,22 @@ class JobSummary(BaseModel):
@app.get("/jobs", response_model=list[JobSummary])
def list_jobs(limit: int | None = None) -> list[JobSummary]:
"""所有 job 的精简列表,按磁盘 state.json mtime 倒序(最新优先)。前端无 ?job= 时用它回填历史。"""
def list_jobs(request: Request, limit: int | None = None) -> list[JobSummary]:
"""当前用户可见 job 的精简列表,按磁盘 state.json mtime 倒序(最新优先)。"""
user = data_user_from_request(request)
items: list[JobSummary] = []
for job_id, job in JOBS.items():
if not user_can_access_job(job, user):
continue
state_path = JOBS_DIR / job_id / "state.json"
mtime = state_path.stat().st_mtime if state_path.exists() else 0.0
thumb = f"/jobs/{job_id}/frames/{job.frames[0].index}.jpg" if job.frames else ""
items.append(JobSummary(
id=job.id,
url=job.url,
owner_name=job.owner_name,
owner_email=job.owner_email,
owner_provider=job.owner_provider,
status=job.status,
progress=job.progress,
message=job.message,
@@ -4602,11 +5211,13 @@ def list_jobs(limit: int | None = None) -> list[JobSummary]:
@app.post("/jobs", response_model=Job)
async def create_job(req: CreateJobReq, bg: BackgroundTasks) -> Job:
async def create_job(req: CreateJobReq, bg: BackgroundTasks, request: Request) -> Job:
if not req.url.strip():
raise HTTPException(400, "url required")
user = data_user_from_request(request)
job_id = uuid.uuid4().hex[:12]
job = Job(id=job_id, url=req.url.strip())
assign_owner(job, user)
JOBS[job_id] = job
save_state(job)
bg.add_task(pipeline_download, job_id)
@@ -4640,13 +5251,14 @@ async def retry_job_download(job_id: str, bg: BackgroundTasks) -> Job:
@app.post("/jobs/upload", response_model=Job)
async def create_job_from_upload(bg: BackgroundTasks, file: UploadFile = File(...)) -> Job:
async def create_job_from_upload(bg: BackgroundTasks, request: Request, file: UploadFile = File(...)) -> Job:
if not file.filename:
raise HTTPException(400, "file required")
ext = Path(file.filename).suffix.lower()
if ext not in {".mp4", ".mov", ".webm", ".mkv", ".m4v"}:
raise HTTPException(400, f"unsupported video format: {ext}")
user = data_user_from_request(request)
job_id = uuid.uuid4().hex[:12]
d = job_dir(job_id)
mp4 = d / "source.mp4"
@@ -4657,12 +5269,63 @@ async def create_job_from_upload(bg: BackgroundTasks, file: UploadFile = File(..
raise HTTPException(500, "upload failed")
job = Job(id=job_id, url=f"upload://{file.filename}")
assign_owner(job, user)
JOBS[job_id] = job
save_state(job)
bg.add_task(pipeline_download, job_id)
return job
def _write_creative_reference_frame(job_id: str, file_bytes: bytes | None = None) -> tuple[int, int]:
frames_dir = job_dir(job_id) / "frames"
frames_dir.mkdir(parents=True, exist_ok=True)
out = frames_dir / "000.jpg"
if file_bytes:
try:
with Image.open(io.BytesIO(file_bytes)) as raw:
im = ImageOps.exif_transpose(raw).convert("RGB")
im.thumbnail((1600, 1600), Image.LANCZOS)
width, height = im.size
im.save(out, "JPEG", quality=92)
return width, height
except Exception as e:
raise HTTPException(400, f"invalid image file: {e}")
im = Image.new("RGB", (1024, 1024), (246, 248, 246))
im.save(out, "JPEG", quality=92)
return im.size
@app.post("/creative/jobs/image", response_model=Job)
async def create_creative_image_job(request: Request, file: UploadFile | None = File(default=None)) -> Job:
user = data_user_from_request(request)
job_id = uuid.uuid4().hex[:12]
file_bytes: bytes | None = None
source_label = "blank"
if file and file.filename:
ext = Path(file.filename).suffix.lower()
if ext not in {".jpg", ".jpeg", ".png", ".webp"}:
raise HTTPException(400, f"unsupported image format: {ext}")
file_bytes = await file.read()
source_label = file.filename
width, height = _write_creative_reference_frame(job_id, file_bytes)
frame = KeyFrame(index=0, timestamp=0, url=f"/jobs/{job_id}/frames/0.jpg")
job = Job(
id=job_id,
url=f"creative://{source_label}",
status="frames_extracted",
progress=100,
message="创作任务已就绪",
width=width,
height=height,
duration=0,
frames=[frame],
)
assign_owner(job, user)
JOBS[job_id] = job
save_state(job)
return job
@app.post("/jobs/{job_id}/analyze", response_model=Job)
async def trigger_analyze(
job_id: str,
@@ -4964,13 +5627,14 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
reference_path = sel_path
reference_source = f"gen:{sel.id[:6]}"
full_prompt = req.prompt.strip()
raw_prompt = req.prompt.strip()
if req.extra_prompt.strip():
full_prompt = f"{full_prompt}. Include: {req.extra_prompt.strip()}"
raw_prompt = f"{raw_prompt}. Include: {req.extra_prompt.strip()}"
if req.negative_prompt.strip():
full_prompt = f"{full_prompt}. Avoid: {req.negative_prompt.strip()}"
if not full_prompt:
raw_prompt = f"{raw_prompt}. Avoid: {req.negative_prompt.strip()}"
if not raw_prompt:
raise HTTPException(400, "prompt required")
full_prompt = _ensure_english(raw_prompt)
if not IMAGE_API_KEY:
raise HTTPException(503, "IMAGE_API_KEY 或 LLM_API_KEY 未配置")
@@ -6293,11 +6957,13 @@ def _generate_subject_assets_sync(job_id: str, idx: int, element_id: str, req: G
else:
view_prompt = f"complete object/product reference, {view_label} view"
view_name = view.replace("_", " ")
projection_clause = _subject_view_projection_clause(view)
single_view_clause = (
f"Single-image output rule: this output file is ONLY for the {view_label} view ({view_name}). "
"Render exactly one subject, one time, in one pose and one camera angle. "
"Do not create a multi-view sheet, contact sheet, grid, storyboard, lineup, comparison layout, before/after layout, mirrored pair, duplicate subjects, thumbnails, labels, captions, arrows, view names, panel borders, or multiple versions in the same image. "
"Do not include any other views in this image. "
+ projection_clause
)
framing_clause = (
"For this close-up view, intentionally crop as an upper-body asset from head/neck to chest or upper back; the neck, shoulders, collarbone or upper spine area must be large, clear, and useful for placing a neck-and-shoulder massage device. "
@@ -8044,6 +8710,11 @@ class AgentRunLog(BaseModel):
class AgentRun(BaseModel):
id: str
job_id: str
owner_id: str = ""
owner_name: str = ""
owner_email: str = ""
owner_provider: str = ""
tenant_key: str = ""
status: Literal["draft", "queued", "executing", "reviewing", "completed", "failed"] = "queued"
stage: str = "queued"
progress: int = 0
@@ -8384,14 +9055,17 @@ def agent_run_worker(run_id: str, product_refs: list[dict]) -> None:
@app.post("/agent-runs", response_model=AgentRun)
async def create_agent_run(
request: Request,
tk_url: str = Form(...),
product_files: list[UploadFile] | None = File(None),
) -> AgentRun:
if not tk_url.strip():
raise HTTPException(400, "tk_url required")
user = data_user_from_request(request)
job_id = uuid.uuid4().hex[:12]
run_id = uuid.uuid4().hex[:12]
job = Job(id=job_id, url=tk_url.strip())
assign_owner(job, user)
JOBS[job_id] = job
save_state(job)
@@ -8400,6 +9074,7 @@ async def create_agent_run(
refs.append(await save_agent_product_upload(job_id, upload, index))
run = AgentRun(id=run_id, job_id=job_id, status="queued", stage="queued", progress=1)
assign_owner(run, user)
save_agent_run(run)
agent_log(run, f"任务已入队 · job={job_id} · 产品图 {len(refs)}", status="queued", stage="queued", progress=1)
threading.Thread(target=agent_run_worker, args=(run_id, refs), daemon=True).start()
@@ -8407,14 +9082,15 @@ async def create_agent_run(
@app.get("/agent-runs", response_model=list[AgentRun])
def list_agent_runs(limit: int = 20) -> list[AgentRun]:
def list_agent_runs(request: Request, limit: int = 20) -> list[AgentRun]:
user = data_user_from_request(request)
for p in AGENT_RUNS_DIR.iterdir():
if p.is_dir() and (p / "state.json").exists() and p.name not in AGENT_RUNS:
try:
AGENT_RUNS[p.name] = AgentRun.model_validate_json((p / "state.json").read_text(encoding="utf-8"))
except Exception:
pass
items = list(AGENT_RUNS.values())
items = [item for item in AGENT_RUNS.values() if user_can_access_agent_run(item.id, user)]
items.sort(key=lambda item: item.updated_at, reverse=True)
return items[:max(1, min(100, limit))]

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

@@ -0,0 +1,76 @@
# 2026-05-24 Pre-Redesign Backup
This record was created before the full platform redesign that replaces the old video-recreation-first product flow.
## Code Restore Point
- Branch at backup time: `main`
- Commit at backup time: `04a822ac7903aa249b491c68c80b3cd98d88ae85`
- Remote: `ssh://git@git.kang-kang.com:22222/kangwan/20260512-skg-tk.git`
- Pushed tag: `backup/pre-redesign-20260524-012047`
Restore tracked code to the exact pre-redesign point:
```bash
git fetch origin --tags
git reset --hard backup/pre-redesign-20260524-012047
```
Only run the reset when intentionally replacing the current working tree.
## Local Data Snapshot
Backup directory:
```text
.backups/pre-redesign-20260524-012047/
```
Snapshot contents:
- `api-jobs/` from `api/jobs/`
- `api-product_library/` from `api/product_library/`
- `api-character_library/` from `api/character_library/`
- `api-asset_library/` from `api/asset_library/`
- `api-prompt_library/` from `api/prompt_library/`
- `asset_library/` from `asset_library/`
- `prompt_library/` from `prompt_library/`
- `jobs/` from `jobs/`
- `_trash/` from `_trash/`
- `output/` from `output/`
- `source-04a822ac7903.tgz`
- `dirty-worktree.patch`
Verification at backup time:
- Full backup size: `328M`
- `api/jobs` source task directories: `12`
- `api-jobs` backup task directories: `12`
- `api-jobs` backup size: `132M`
- `api-product_library` backup size: `9.6M`
- `api-character_library` backup size: `91M`
## Data Restore By Overwrite
Use `ditto` to copy the backed up data over the current runtime data directories:
```bash
ditto .backups/pre-redesign-20260524-012047/api-jobs api/jobs
ditto .backups/pre-redesign-20260524-012047/api-product_library api/product_library
ditto .backups/pre-redesign-20260524-012047/api-character_library api/character_library
ditto .backups/pre-redesign-20260524-012047/api-asset_library api/asset_library
ditto .backups/pre-redesign-20260524-012047/api-prompt_library api/prompt_library
ditto .backups/pre-redesign-20260524-012047/asset_library asset_library
ditto .backups/pre-redesign-20260524-012047/prompt_library prompt_library
ditto .backups/pre-redesign-20260524-012047/jobs jobs
ditto .backups/pre-redesign-20260524-012047/_trash _trash
ditto .backups/pre-redesign-20260524-012047/output output
```
`ditto` overwrites matching files but does not delete extra files that were created after the backup. If an exact replacement is required later, remove or move the target directory first, then run the matching `ditto` command.
## Notes
- `.backups/` is ignored by Git and remains local.
- The pushed Git tag protects the source-code restore point even if the local `.backups/` folder is moved.
- The only dirty working-tree change at backup time was `.memory/worklog.json`; its diff was saved as `dirty-worktree.patch`.

File diff suppressed because one or more lines are too long

543
web/app/detail/page.tsx Normal file
View File

@@ -0,0 +1,543 @@
"use client"
import { useCallback, useEffect, useMemo, useState } from "react"
import {
ArrowLeft,
Clapperboard,
Copy,
FileText,
Image as ImageIcon,
Loader2,
Play,
RefreshCw,
Sparkles,
Wand2,
} from "lucide-react"
import { Toaster, toast } from "sonner"
import { MediaAssetTile } from "@/components/media-asset-tile"
import {
apiAssetUrl,
deleteGeneratedImage,
deleteGeneratedVideo,
generateCreativeCopy,
generateImage,
generateStoryboardVideo,
getJob,
listJobs,
type CreativeCopyVariant,
type GeneratedImage,
type GeneratedVideo,
type Job,
type JobSummary,
} from "@/lib/api"
type ImageItem = GeneratedImage & { frameIdx: number }
type BusyTask = "image" | "video" | "copy" | "load" | null
function cx(...items: Array<string | false | null | undefined>) {
return items.filter(Boolean).join(" ")
}
function jobTitle(item: Job | JobSummary | null) {
if (!item) return "未选择任务"
const raw = item.url.replace(/^creative:\/\//, "").replace(/^upload:\/\//, "")
return raw || item.id
}
function sourceFrameSrc(job: Job | null) {
return job?.frames?.[0]?.url ? apiAssetUrl(job.frames[0].url) : ""
}
function videoSrc(job: Job, video: GeneratedVideo) {
return apiAssetUrl(video.url || `/jobs/${job.id}/storyboard-videos/${video.id}.mp4`)
}
function imageItems(job: Job | null): ImageItem[] {
if (!job) return []
return job.frames
.flatMap((frame) => (frame.generated_images ?? []).map((image) => ({ ...image, frameIdx: frame.index })))
.sort((a, b) => b.created_at - a.created_at)
}
function createdLabel(ts?: number) {
if (!ts) return ""
return new Date(ts * 1000).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })
}
export default function DetailPage() {
const [jobId, setJobId] = useState("")
const [job, setJob] = useState<Job | null>(null)
const [recentJobs, setRecentJobs] = useState<JobSummary[]>([])
const [prompt, setPrompt] = useState("")
const [product, setProduct] = useState("SKG 颈部按摩仪")
const [audience, setAudience] = useState("久坐办公、低头刷手机的人群")
const [tone, setTone] = useState("真实、直接、有购买理由")
const [seconds, setSeconds] = useState(12)
const [copyVariants, setCopyVariants] = useState<CreativeCopyVariant[]>([])
const [busy, setBusy] = useState<BusyTask>(null)
const [error, setError] = useState("")
const images = useMemo(() => imageItems(job), [job])
const videos = useMemo(() => job?.generated_videos ?? [], [job])
const runningVideo = videos.some((item) => item.status === "queued" || item.status === "in_progress")
const loadJob = useCallback(async (id: string) => {
if (!id) return
setBusy("load")
setError("")
try {
const loaded = await getJob(id)
setJob(loaded)
setJobId(id)
window.history.replaceState(null, "", `/detail/?job=${id}`)
} catch (e) {
const message = e instanceof Error ? e.message : "读取任务失败"
setError(message)
toast.error(message)
} finally {
setBusy(null)
}
}, [])
const refreshJobs = useCallback(async () => {
try {
setRecentJobs(await listJobs(20))
} catch {
setRecentJobs([])
}
}, [])
useEffect(() => {
const id = new URLSearchParams(window.location.search).get("job") || ""
setJobId(id)
refreshJobs()
if (id) loadJob(id)
}, [loadJob, refreshJobs])
useEffect(() => {
if (!job || !runningVideo) return
const timer = window.setInterval(async () => {
try {
setJob(await getJob(job.id))
} catch {
window.clearInterval(timer)
}
}, 2600)
return () => window.clearInterval(timer)
}, [job, runningVideo])
const requireJobAndPrompt = () => {
if (!job) {
toast.error("先选择任务")
return false
}
if (!prompt.trim()) {
toast.error("先写生成要求")
return false
}
return true
}
const runImage = async () => {
if (!requireJobAndPrompt() || !job) return
setBusy("image")
setError("")
try {
setJob(await generateImage(job.id, 0, {
prompt: `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Tone: ${tone}.`,
mode: sourceFrameSrc(job) ? "edit" : "text",
}))
toast.success("图片已生成")
} catch (e) {
const message = e instanceof Error ? e.message : "生图失败"
setError(message)
toast.error(message)
} finally {
setBusy(null)
}
}
const runVideo = async () => {
if (!requireJobAndPrompt() || !job) return
setBusy("video")
setError("")
try {
setJob(await generateStoryboardVideo(job.id, 0, {
prompt: `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Tone: ${tone}. Keep the SKG product shape stable and visible.`,
duration: seconds,
count: 1,
first_image: { kind: "keyframe", frame_idx: 0 },
size: "720x1280",
}))
toast.success("视频已提交")
} catch (e) {
const message = e instanceof Error ? e.message : "生视频失败"
setError(message)
toast.error(message)
} finally {
setBusy(null)
}
}
const runCopy = async () => {
const goal = prompt.trim() || `${product} ${audience}`
setBusy("copy")
setError("")
try {
const result = await generateCreativeCopy({ goal, product, audience, tone, seconds })
setCopyVariants(result.variants)
toast.success("图文方案已生成")
} catch (e) {
const message = e instanceof Error ? e.message : "写文案失败"
setError(message)
toast.error(message)
} finally {
setBusy(null)
}
}
const deleteImage = async (image: ImageItem) => {
if (!job) return
try {
setJob(await deleteGeneratedImage(job.id, image.frameIdx, image.id))
toast.success("图片已删除")
} catch (e) {
toast.error(e instanceof Error ? e.message : "删除失败")
}
}
const deleteVideo = async (video: GeneratedVideo) => {
if (!job) return
try {
setJob(await deleteGeneratedVideo(job.id, video.id))
toast.success("视频已删除")
} catch (e) {
toast.error(e instanceof Error ? e.message : "删除失败")
}
}
const copyText = async (text: string) => {
try {
await navigator.clipboard.writeText(text)
toast.success("已复制")
} catch {
toast.error("复制失败")
}
}
return (
<main className="min-h-screen bg-[#eef2ec] text-[#17201d]">
<Toaster richColors position="top-center" />
<div className="mx-auto grid min-h-screen w-full max-w-[1760px] grid-rows-[auto_minmax(0,1fr)] px-4 py-4 sm:px-6">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-[#d8dfd4] pb-4">
<div className="flex items-center gap-3">
<a
href="/"
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-[#cbd6d0] bg-white text-[#35443f] transition hover:border-[#0f766e]/60"
aria-label="返回工作台"
title="返回工作台"
>
<ArrowLeft className="h-5 w-5" />
</a>
<div>
<h1 className="text-xl font-semibold tracking-normal"></h1>
<p className="mt-1 max-w-[620px] truncate text-sm text-[#66746e]">{jobTitle(job)}</p>
</div>
</div>
<button
type="button"
onClick={() => jobId && loadJob(jobId)}
disabled={!jobId || !!busy}
className="inline-flex h-9 items-center gap-2 rounded-md border border-[#cbd6d0] bg-white px-3 text-sm font-semibold text-[#35443f] transition hover:border-[#0f766e]/60 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy === "load" ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
</button>
</header>
<section className="grid min-h-0 gap-4 py-4 xl:grid-cols-[300px_minmax(0,1fr)_420px]">
<aside className="grid min-h-0 grid-rows-[auto_minmax(0,1fr)] gap-3">
<section className="rounded-lg border border-[#d8dfd4] bg-white p-3">
<h2 className="text-sm font-semibold"></h2>
<div className="mt-3 grid gap-2 text-sm">
<div className="flex justify-between gap-3 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 py-2">
<span className="text-[#66746e]">ID</span>
<span className="font-mono text-xs">{job?.id || jobId || "-"}</span>
</div>
<div className="flex justify-between gap-3 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 py-2">
<span className="text-[#66746e]"></span>
<span>{images.length}</span>
</div>
<div className="flex justify-between gap-3 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 py-2">
<span className="text-[#66746e]"></span>
<span>{videos.length}</span>
</div>
</div>
{sourceFrameSrc(job) ? (
<div className="mt-3">
<MediaAssetTile
src={sourceFrameSrc(job)}
alt="reference"
objectFit="contain"
previewObjectFit="contain"
className="aspect-[4/5] w-full rounded-md"
label="参考图"
meta={job?.frames?.[0]?.index ?? 0}
/>
</div>
) : null}
</section>
<section className="min-h-0 rounded-lg border border-[#d8dfd4] bg-white p-3">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-sm font-semibold"></h2>
<button
type="button"
onClick={refreshJobs}
className="rounded p-1 text-[#66746e] hover:bg-[#f0f3ee] hover:text-[#17201d]"
aria-label="刷新任务列表"
title="刷新任务列表"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
<div className="grid max-h-[540px] gap-2 overflow-y-auto pr-1">
{recentJobs.map((item) => (
<button
key={item.id}
type="button"
onClick={() => loadJob(item.id)}
className={cx(
"grid grid-cols-[48px_minmax(0,1fr)] gap-2 rounded-md border bg-[#f7f9f5] p-1.5 text-left transition hover:border-[#0f766e]/60",
job?.id === item.id ? "border-[#0f766e] ring-2 ring-[#0f766e]/10" : "border-[#d8dfd4]",
)}
>
<MediaAssetTile
src={apiAssetUrl(item.thumbnail)}
alt=""
objectFit="cover"
className="aspect-square rounded"
disablePreview={!item.thumbnail}
/>
<span className="min-w-0">
<span className="block truncate text-xs font-semibold">{jobTitle(item)}</span>
<span className="block text-[11px] text-[#66746e]">{item.frame_count} · {item.video_count} </span>
</span>
</button>
))}
{!recentJobs.length ? (
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-3 py-5 text-center text-xs text-[#66746e]"></div>
) : null}
</div>
</section>
</aside>
<section className="min-h-0 overflow-y-auto rounded-lg border border-[#d8dfd4] bg-white p-4">
{job ? (
<div className="grid gap-6">
<section className="grid gap-3">
<div className="flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-[#0f766e]" />
<h2 className="text-base font-semibold"></h2>
</div>
{images.length ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{images.map((image) => (
<div key={image.id} className="grid gap-1.5">
<MediaAssetTile
src={apiAssetUrl(image.url)}
alt="generated image"
objectFit="contain"
previewObjectFit="contain"
className="aspect-[4/5] w-full rounded-md"
label={image.model}
meta={image.mode}
onDelete={() => deleteImage(image)}
/>
<div className="truncate text-[11px] text-[#66746e]">{createdLabel(image.created_at)}</div>
</div>
))}
</div>
) : (
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-4 py-8 text-center text-sm text-[#66746e]"></div>
)}
</section>
<section className="grid gap-3">
<div className="flex items-center gap-2">
<Clapperboard className="h-4 w-4 text-[#ea5b2d]" />
<h2 className="text-base font-semibold"></h2>
</div>
{videos.length ? (
<div className="grid gap-3 sm:grid-cols-2">
{videos.map((video) => (
<div key={video.id} className="grid gap-1.5 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-2">
<MediaAssetTile
kind="video"
src={video.status === "completed" ? videoSrc(job, video) : undefined}
poster={apiAssetUrl(video.poster_url)}
objectFit="cover"
previewObjectFit="contain"
className="aspect-video w-full rounded-md"
label={video.model}
meta={`${video.status} · ${Math.round(video.progress)}%`}
busy={video.status === "queued" || video.status === "in_progress"}
onDelete={() => deleteVideo(video)}
/>
<div className="h-1 overflow-hidden rounded-full bg-[#e4ebe6]">
<div className="h-full rounded-full bg-[#ea5b2d]" style={{ width: `${Math.max(4, video.progress)}%` }} />
</div>
{video.error ? <div className="text-xs text-rose-700">{video.error}</div> : null}
</div>
))}
</div>
) : (
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-4 py-8 text-center text-sm text-[#66746e]"></div>
)}
</section>
<section className="grid gap-3">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-[#2563eb]" />
<h2 className="text-base font-semibold"></h2>
</div>
{copyVariants.length ? (
<div className="grid gap-3 lg:grid-cols-3">
{copyVariants.map((variant, index) => (
<article key={`${variant.title}-${index}`} className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-3">
<div className="mb-2 flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold">{variant.title || `方案 ${index + 1}`}</h3>
<button
type="button"
onClick={() => copyText([variant.hook_zh, variant.script_zh, variant.caption_zh].filter(Boolean).join("\n\n"))}
className="rounded p-1 text-[#66746e] hover:bg-white hover:text-[#17201d]"
aria-label="复制文案"
title="复制文案"
>
<Copy className="h-4 w-4" />
</button>
</div>
<p className="text-sm leading-6 text-[#243530]">{variant.hook_zh}</p>
<pre className="mt-2 max-h-44 overflow-y-auto whitespace-pre-wrap rounded bg-white p-2 text-xs leading-5 text-[#42524c]">{variant.script_zh}</pre>
</article>
))}
</div>
) : (
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-4 py-8 text-center text-sm text-[#66746e]"></div>
)}
</section>
</div>
) : (
<div className="flex min-h-[520px] items-center justify-center rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] text-sm text-[#66746e]">
</div>
)}
</section>
<aside className="grid min-h-0 grid-rows-[auto_auto_minmax(0,1fr)] gap-3">
<section className="rounded-lg border border-[#d8dfd4] bg-white p-3">
<h2 className="text-sm font-semibold"></h2>
<div className="mt-3 grid gap-3">
<label className="grid gap-1.5">
<span className="text-xs font-medium text-[#52635d]"></span>
<input
value={product}
onChange={(event) => setProduct(event.target.value)}
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
/>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-[#52635d]"></span>
<input
value={audience}
onChange={(event) => setAudience(event.target.value)}
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
/>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-[#52635d]"></span>
<textarea
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder="继续生成一组更高端的营销图,或者提交一条快速视频要求。"
className="min-h-40 resize-none rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-3 text-sm leading-6 outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
/>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-[#52635d]"></span>
<input
value={tone}
onChange={(event) => setTone(event.target.value)}
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
/>
</label>
<label className="grid gap-1.5">
<span className="text-xs font-medium text-[#52635d]"></span>
<select
value={seconds}
onChange={(event) => setSeconds(Number(event.target.value))}
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
>
{[8, 12, 15, 20, 30, 45].map((value) => <option key={value} value={value}>{value} </option>)}
</select>
</label>
{error ? <div className="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div> : null}
</div>
</section>
<section className="grid gap-2 rounded-lg border border-[#d8dfd4] bg-white p-3">
<button
type="button"
onClick={runImage}
disabled={!job || !!busy}
className="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-[#0f766e] text-sm font-semibold text-white transition hover:bg-[#115e59] disabled:cursor-not-allowed disabled:opacity-60"
>
{busy === "image" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wand2 className="h-4 w-4" />}
</button>
<button
type="button"
onClick={runVideo}
disabled={!job || !!busy}
className="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-[#ea5b2d] text-sm font-semibold text-white transition hover:bg-[#d94f25] disabled:cursor-not-allowed disabled:opacity-60"
>
{busy === "video" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
</button>
<button
type="button"
onClick={runCopy}
disabled={!!busy}
className="inline-flex h-10 items-center justify-center gap-2 rounded-md border border-[#cbd6d0] bg-[#f7f9f5] text-sm font-semibold text-[#35443f] transition hover:border-[#9ba9a2] disabled:cursor-not-allowed disabled:opacity-60"
>
{busy === "copy" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
</button>
</section>
<section className="min-h-0 rounded-lg border border-[#d8dfd4] bg-white p-3">
<h2 className="mb-2 text-sm font-semibold"></h2>
<div className="grid max-h-[360px] gap-2 overflow-y-auto pr-1">
{[...images.slice(0, 4).map((item) => item.prompt), ...videos.slice(0, 4).map((item) => item.prompt)]
.filter(Boolean)
.map((item, index) => (
<button
key={`${index}-${item.slice(0, 20)}`}
type="button"
onClick={() => setPrompt(item)}
className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-2 text-left text-xs leading-5 text-[#42524c] transition hover:border-[#0f766e]/60"
>
{item.slice(0, 180)}
</button>
))}
{!images.length && !videos.length ? (
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-3 py-5 text-center text-xs text-[#66746e]"></div>
) : null}
</div>
</section>
</aside>
</section>
</div>
</main>
)
}

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

@@ -2019,6 +2019,11 @@ function selectProductItemsForRow(
}
function subjectViewLabel(view: string) {
const labels: Record<string, string> = {
top: "正投影俯视图",
bottom: "正投影仰视图",
}
if (labels[view]) return labels[view]
return SUBJECT_ASSET_VIEWS.find((item) => item.value === view)?.label ?? view
}
@@ -2034,6 +2039,8 @@ function subjectViewRoleHint(view: string) {
bust_left_45: "肩颈左前近景、手部调整、佩戴贴合",
bust_right_45: "肩颈右前近景、手部调整、佩戴贴合",
back_neck_detail: "后颈肩背特写、触点位置、产品贴合",
top: "正投影俯视、无透视、无倾斜、产品顶部轮廓",
bottom: "正投影仰视、无透视、无倾斜、产品底部结构",
}
return hints[view] ?? "主体参考视角"
}
@@ -2050,6 +2057,8 @@ function subjectViewPromptHint(view: string) {
bust_left_45: "left three-quarter neck-and-shoulder close-up, hand adjustment, wearable fit",
bust_right_45: "right three-quarter neck-and-shoulder close-up, hand adjustment, wearable fit",
back_neck_detail: "back-neck and upper-back detail, contact-pad position, product fit",
top: "orthographic top view, straight down from above, no perspective, no tilt, no three-quarter angle",
bottom: "orthographic bottom view, straight up at underside, no perspective, no tilt, no three-quarter angle",
}
return hints[view] ?? "subject reference view"
}

View File

@@ -32,12 +32,12 @@ interface Props {
}
const OBJECT_VIEW_OPTIONS = [
["front", "正"],
["back", "背面"],
["left", "左侧"],
["right", "右侧"],
["top", "顶部"],
["bottom", "底部"],
["front", "正投影主视图"],
["back", "正投影后视图"],
["left", "正投影左视图"],
["right", "正投影右视图"],
["top", "正投影俯视图"],
["bottom", "正投影仰视图"],
]
const LIVING_VIEW_OPTIONS = [

View File

@@ -129,7 +129,7 @@ export function StoryboardEditor({ job, frameIndex, onClose }: Props) {
📐
</div>
<div className="text-[9.5px] text-white/45 leading-tight">
/ /
/ /
</div>
</button>
<button

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