Compare commits
7 Commits
68ecc8b97b
...
77d23a06b3
| Author | SHA1 | Date | |
|---|---|---|---|
| 77d23a06b3 | |||
| 775ad79661 | |||
| a3ddb05424 | |||
| 02a9999d8c | |||
| b6fec10371 | |||
| 7bb4f3ea9f | |||
| b82dad4aa8 |
@@ -1,6 +1,6 @@
|
|||||||
# 项目接力
|
# 项目接力
|
||||||
|
|
||||||
- 生成时间:May 21, 2026 at 02:06
|
- 生成时间:May 21, 2026 at 13:48
|
||||||
- 项目:SKG Marketing Studio / SKG 营销内容工作台
|
- 项目:SKG Marketing Studio / SKG 营销内容工作台
|
||||||
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
||||||
- 状态:active
|
- 状态:active
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
## 最近助手会话概览
|
## 最近助手会话概览
|
||||||
|
|
||||||
- Claude:a9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
|
- Claude:a9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
|
||||||
- Codex:019e447d-68c7-7db1-a499-b5eb6a98a7c2 · 时间未知
|
- Codex:019e4691-7c18-7dc1-ba82-a315eec63163 · 时间未知
|
||||||
- Cursor:未找到匹配当前项目的最近会话
|
- Cursor:未找到匹配当前项目的最近会话
|
||||||
|
|
||||||
## Claude 最近会话
|
## Claude 最近会话
|
||||||
@@ -92,42 +92,23 @@
|
|||||||
|
|
||||||
## Codex 最近会话
|
## Codex 最近会话
|
||||||
|
|
||||||
- Session ID:019e447d-68c7-7db1-a499-b5eb6a98a7c2
|
- Session ID:019e4691-7c18-7dc1-ba82-a315eec63163
|
||||||
- Transcript:/Users/kangwan/.codex/sessions/2026/05/20/rollout-2026-05-20T16-25-30-019e447d-68c7-7db1-a499-b5eb6a98a7c2.jsonl
|
- Transcript:/Users/kangwan/.codex/sessions/2026/05/21/rollout-2026-05-21T02-06-40-019e4691-7c18-7dc1-ba82-a315eec63163.jsonl
|
||||||
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
||||||
- 分支:main
|
- 分支:main
|
||||||
- 敏感字段:已对 token / key / password / secret 做脱敏
|
- 敏感字段:已对 token / key / password / secret 做脱敏
|
||||||
|
|
||||||
### 最近用户要求
|
### 最近用户要求
|
||||||
|
|
||||||
- 我看的没改变呀 我感觉这个已经彻底调整崩了
|
- 不能直接在服务器上的dock二
|
||||||
- figma mcp是通的么
|
- 弄么
|
||||||
- 连一些被 我都打开了
|
- OK 按照你的来
|
||||||
|
|
||||||
### Codex 最近回复
|
### Codex 最近回复
|
||||||
|
|
||||||
> 现在命令行访问本机端口被沙箱拦了一次,`nc` 明确返回 `Operation not permitted`。我用已授权外部执行再测一次,这样能区分“Figma 没开”和“Codex 沙箱不让连本地端口”。
|
> 代码正在同步到服务器 `/tmp/skg-marketing-staging-20260521134626`,同步过程排除了生产数据、密钥、本地环境文件和构建产物。这个 staging 目录会用自己的容器名和网络,不会接管生产域名。
|
||||||
|
|
||||||
> 已连上本地 Figma Desktop MCP。
|
> 同步完成。接下来我会在 staging 目录里复制服务器已有的生产环境变量文件,但只用于临时容器;同时生成一份临时 compose,去掉 Traefik 标签和生产容器名,改成只监听服务器本机 `127.0.0.1:18080`。
|
||||||
>
|
|
||||||
> 我做了这些验证:
|
|
||||||
>
|
|
||||||
> - Figma 桌面端已打开 Design 文件,并显示 `MCP server enabled on http://127.0.0.1:3845/mcp`
|
|
||||||
> - 本机端口确认监听:`Figma ... 127.0.0.1:3845 (LISTEN)`
|
|
||||||
> - MCP `initialize` 成功返回:`Figma Dev Mode MCP Server 1.0.0`
|
|
||||||
> - 工具列表可用:`get_design_context`、`get_screenshot`、`get_metadata`、`get_variable_defs`
|
|
||||||
> - 实际调用 `get_metadata` 成功,返回当前文件页面:`0:1: Page 1`
|
|
||||||
>
|
|
||||||
> 注意:官方远程 `figma` 那条还没通,因为当前环境没有 `FIGMA_OAUTH_TOKEN`,`codex mcp login figma` 也返回了 `invalid_redirect_uri`。但本地 `figma-desktop` 这条已经能用。Figma 官方文档也是这个流程:打开桌面端 Design 文件,启用本地 MCP,然后配置 `http://127.0.0.1:3845/mcp`。
|
|
||||||
>
|
|
||||||
> 当前仓库没改代码,状态还是:
|
|
||||||
>
|
|
||||||
> ```text
|
|
||||||
> ## main...origin/main
|
|
||||||
> M .memory/worklog.json
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> 来源:Figma 官方 Codex MCP 设置文档 https://help.figma.com/hc/en-us/articles/39888629089175-Codex-and-Figma-Set-up-the-MCP-server
|
|
||||||
|
|
||||||
## Cursor 最近会话
|
## Cursor 最近会话
|
||||||
|
|
||||||
|
|||||||
4479
.memory/worklog.json
4479
.memory/worklog.json
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,11 @@
|
|||||||
"type" : "backend",
|
"type" : "backend",
|
||||||
"url" : "https:\/\/marketing.skg.com\/api"
|
"url" : "https:\/\/marketing.skg.com\/api"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"label" : "agent-cut-preview",
|
||||||
|
"type" : "app",
|
||||||
|
"url" : "http:\/\/2.24.28.41:4290\/agent\/"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"label" : "git",
|
"label" : "git",
|
||||||
"type" : "repo",
|
"type" : "repo",
|
||||||
|
|||||||
3
RULES.md
3
RULES.md
@@ -15,6 +15,8 @@
|
|||||||
|
|
||||||
## 部署事实
|
## 部署事实
|
||||||
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
|
- 平台: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-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-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-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):`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`;对应工作台取消固定画布缩放,按浏览器正常流式布局渲染。
|
||||||
@@ -57,6 +59,7 @@
|
|||||||
- 服务器目录:`/opt/skg-marketing-studio`
|
- 服务器目录:`/opt/skg-marketing-studio`
|
||||||
- 生产部署唯一入口:`./scripts/deploy-prod-safe.sh`(先在服务器备份 `deploy/.env.production`、`data/jobs`、资源库和 `secrets`,再用受保护 rsync 同步代码,最后 Docker 重建并运行 `verify-prod-docker.sh`)
|
- 生产部署唯一入口:`./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` 手动同步。
|
- 生产容器重建命令:`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` 容器用 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` 作为上线依据。
|
- 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 tiny.en` 真实转写,关闭 Gemini 多模态音频兜底。拿到真实 Azure ASR deployment 名后再恢复 `ASR_REMOTE_ENABLED=true`。
|
||||||
|
|||||||
3
api/asset_library/index.json
Normal file
3
api/asset_library/index.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
414
api/main.py
414
api/main.py
@@ -29,6 +29,8 @@ load_dotenv()
|
|||||||
|
|
||||||
JOBS_DIR = Path(os.getenv("JOBS_DIR", "./jobs")).resolve()
|
JOBS_DIR = Path(os.getenv("JOBS_DIR", "./jobs")).resolve()
|
||||||
JOBS_DIR.mkdir(parents=True, exist_ok=True)
|
JOBS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
AGENT_RUNS_DIR = Path(os.getenv("AGENT_RUNS_DIR", JOBS_DIR.parent / "agent_runs")).resolve()
|
||||||
|
AGENT_RUNS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
CORS_ORIGINS = [o.strip() for o in os.getenv("CORS_ORIGINS", "http://localhost:4290,http://127.0.0.1:4290").split(",") if o.strip()]
|
CORS_ORIGINS = [o.strip() for o in os.getenv("CORS_ORIGINS", "http://localhost:4290,http://127.0.0.1:4290").split(",") if o.strip()]
|
||||||
PRODUCT_LIBRARY_DIR = Path(
|
PRODUCT_LIBRARY_DIR = Path(
|
||||||
os.getenv("PRODUCT_LIBRARY_DIR", Path(__file__).resolve().parent / "product_library" / "skg-products")
|
os.getenv("PRODUCT_LIBRARY_DIR", Path(__file__).resolve().parent / "product_library" / "skg-products")
|
||||||
@@ -8011,6 +8013,418 @@ def copy_character_library_assets(job_id: str, req: CopyCharacterLibraryAssetReq
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRunLog(BaseModel):
|
||||||
|
ts: float
|
||||||
|
level: Literal["info", "warn", "error"] = "info"
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class AgentRun(BaseModel):
|
||||||
|
id: str
|
||||||
|
job_id: str
|
||||||
|
status: Literal["draft", "queued", "executing", "reviewing", "completed", "failed"] = "queued"
|
||||||
|
stage: str = "queued"
|
||||||
|
progress: int = 0
|
||||||
|
logs: list[AgentRunLog] = Field(default_factory=list)
|
||||||
|
video_ids: list[str] = Field(default_factory=list)
|
||||||
|
final_video_url: str = ""
|
||||||
|
contact_sheet_url: str = ""
|
||||||
|
error: str = ""
|
||||||
|
created_at: float = Field(default_factory=time.time)
|
||||||
|
updated_at: float = Field(default_factory=time.time)
|
||||||
|
|
||||||
|
|
||||||
|
AGENT_RUNS: dict[str, AgentRun] = {}
|
||||||
|
AGENT_DEFAULT_PRODUCT_IDS = [
|
||||||
|
"desktop-skg-product-angle-01",
|
||||||
|
"desktop-skg-product-angle-02",
|
||||||
|
"desktop-skg-product-angle-03",
|
||||||
|
"desktop-skg-product-angle-04",
|
||||||
|
]
|
||||||
|
AGENT_DEFAULT_CHARACTER_ID = os.getenv("AGENT_DEFAULT_CHARACTER_ID", "character-02").strip() or "character-02"
|
||||||
|
AGENT_SHOT_COUNT = max(8, min(12, int(os.getenv("AGENT_SHOT_COUNT", "12"))))
|
||||||
|
AGENT_SHOT_DURATION_SECONDS = max(4.0, min(8.0, float(os.getenv("AGENT_SHOT_DURATION_SECONDS", "5"))))
|
||||||
|
AGENT_VIDEO_TIMEOUT_SECONDS = max(300, int(os.getenv("AGENT_VIDEO_TIMEOUT_SECONDS", "1500")))
|
||||||
|
|
||||||
|
|
||||||
|
def agent_run_dir(run_id: str) -> Path:
|
||||||
|
return AGENT_RUNS_DIR / run_id
|
||||||
|
|
||||||
|
|
||||||
|
def agent_run_path(run_id: str) -> Path:
|
||||||
|
return agent_run_dir(run_id) / "state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def save_agent_run(run: AgentRun) -> None:
|
||||||
|
run.updated_at = time.time()
|
||||||
|
d = agent_run_dir(run.id)
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
agent_run_path(run.id).write_text(run.model_dump_json(indent=2), encoding="utf-8")
|
||||||
|
AGENT_RUNS[run.id] = run
|
||||||
|
|
||||||
|
|
||||||
|
def agent_log(
|
||||||
|
run: AgentRun,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
stage: str | None = None,
|
||||||
|
progress: int | None = None,
|
||||||
|
status: Literal["draft", "queued", "executing", "reviewing", "completed", "failed"] | None = None,
|
||||||
|
level: Literal["info", "warn", "error"] = "info",
|
||||||
|
) -> None:
|
||||||
|
if stage is not None:
|
||||||
|
run.stage = stage
|
||||||
|
if progress is not None:
|
||||||
|
run.progress = max(0, min(100, int(progress)))
|
||||||
|
if status is not None:
|
||||||
|
run.status = status
|
||||||
|
run.logs = (run.logs + [AgentRunLog(ts=time.time(), level=level, message=message)])[-240:]
|
||||||
|
save_agent_run(run)
|
||||||
|
|
||||||
|
|
||||||
|
async def save_agent_product_upload(job_id: str, upload: UploadFile, index: int) -> dict:
|
||||||
|
if not upload.filename:
|
||||||
|
raise HTTPException(400, "product image filename required")
|
||||||
|
content_type = (upload.content_type or "").lower()
|
||||||
|
suffix = Path(upload.filename).suffix.lower()
|
||||||
|
if content_type and not content_type.startswith("image/"):
|
||||||
|
raise HTTPException(400, f"product image must be image/*, got {content_type}")
|
||||||
|
if not content_type and suffix not in {".jpg", ".jpeg", ".png", ".webp", ".bmp"}:
|
||||||
|
raise HTTPException(400, f"unsupported product image: {suffix}")
|
||||||
|
|
||||||
|
out_dir = job_dir(job_id) / "assets"
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
asset_id = uuid.uuid4().hex[:12]
|
||||||
|
tmp = out_dir / f"{asset_id}.upload"
|
||||||
|
out = out_dir / f"{asset_id}.jpg"
|
||||||
|
try:
|
||||||
|
await _save_upload_to_path(upload, tmp)
|
||||||
|
meta = normalize_product_asset_image(tmp, out)
|
||||||
|
except Exception as e:
|
||||||
|
try:
|
||||||
|
out.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise HTTPException(400, f"product upload failed: {e}")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
tmp.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"kind": "asset",
|
||||||
|
"frame_idx": -1,
|
||||||
|
"element_id": asset_id,
|
||||||
|
"cutout_id": asset_id,
|
||||||
|
"label": f"用户产品图 {index} · {upload.filename}",
|
||||||
|
"asset_meta": meta,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def agent_fallback_product_refs(job_id: str) -> list[dict]:
|
||||||
|
refs: list[dict] = []
|
||||||
|
for product_id in AGENT_DEFAULT_PRODUCT_IDS:
|
||||||
|
try:
|
||||||
|
refs.append(copy_product_library_asset(job_id, CopyProductLibraryAssetReq(product_id=product_id)))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return refs
|
||||||
|
|
||||||
|
|
||||||
|
def agent_subject_refs(job_id: str) -> list[dict]:
|
||||||
|
try:
|
||||||
|
payload = copy_character_library_assets(job_id, CopyCharacterLibraryAssetReq(character_id=AGENT_DEFAULT_CHARACTER_ID))
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
images = payload.get("images") or []
|
||||||
|
preferred = []
|
||||||
|
for ref in images:
|
||||||
|
label = str(ref.get("label") or "")
|
||||||
|
if any(key in label for key in ("正面", "左45", "半身近景", "侧面")):
|
||||||
|
preferred.append(ref)
|
||||||
|
return (preferred or images)[:4]
|
||||||
|
|
||||||
|
|
||||||
|
def agent_base_prompt() -> str:
|
||||||
|
return (
|
||||||
|
"Vertical 9:16 original SKG short-form ad. Do not copy the real person from the source video. "
|
||||||
|
"Use the provided transparent anatomy subject as the recurring character when a person is needed. "
|
||||||
|
"Use the provided SKG white U-shaped neck-and-shoulder massager product references as rigid product truth: "
|
||||||
|
"one clean U-shaped wearable device, silver contact pads, red heat/light accents, premium white shell, correct scale around the neck and shoulders. "
|
||||||
|
"No captions, no platform UI, no watermark, no medical treatment claims. Natural creator-demo pacing, clean premium lighting."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def agent_shot_plan() -> list[dict]:
|
||||||
|
base = agent_base_prompt()
|
||||||
|
shots = [
|
||||||
|
("hook", "Hook close-up: transparent anatomy character faces camera and raises the SKG neck-and-shoulder massager into the foreground, fast creator-ad opening energy, clean blue-white studio background."),
|
||||||
|
("pain", "Pain-point scene: the character sits at a desk after long screen work, shoulders tense, then notices the SKG massager beside the laptop; show neck and shoulder area clearly."),
|
||||||
|
("product_macro", "Macro product detail: slow moving close-up across the SKG U-shaped device, buttons, inner massage nodes, silver pads, premium white plastic and red heat accents."),
|
||||||
|
("wear", "Wear demo: the character places the SKG U-shaped massager externally around the back of the neck and upper shoulders, hands guiding both arms into position."),
|
||||||
|
("contact", "Heat/contact moment: close-up of silver massage pads aligned with side neck and upper trapezius, subtle red warmth glow, product outside the transparent body, no clipping."),
|
||||||
|
("office_use", "Office use beat: the character works calmly at a desk while wearing the SKG massager, small relief gesture, device stable and visible around neck and shoulders."),
|
||||||
|
("living_room", "Comfort beat: relaxed home setting, character leans back slightly, SKG device running, premium wellness mood, smooth gentle camera drift."),
|
||||||
|
("angle_proof", "Product angle proof: clean tabletop shot with the SKG U-shaped massager rotating or being lifted by hand, show thickness, contact pads, seams, and control button."),
|
||||||
|
("mobility", "Daily mobility scene: character walks from desk to sofa wearing the SKG massager, lightweight lifestyle demonstration, product silhouette remains accurate."),
|
||||||
|
("benefit", "Benefit visualization: transparent anatomy view emphasizes neck and shoulder contact zones with tasteful red warmth accents while the device stays opaque and external."),
|
||||||
|
("packaging", "Brand proof shot: SKG product and packaging on a clean surface, hand picks up the device, premium white product photography look, no extra text overlays."),
|
||||||
|
("cta", "Ending CTA: character faces camera wearing the SKG massager, then the final frame lands on a clean product hero angle with confident premium ad finish."),
|
||||||
|
]
|
||||||
|
return [{"key": key, "prompt": f"{base}\n\nShot direction: {text}"} for key, text in shots[:AGENT_SHOT_COUNT]]
|
||||||
|
|
||||||
|
|
||||||
|
def agent_reference_for_shot(shot_key: str, product_refs: list[dict], subject_refs: list[dict]) -> tuple[dict | None, str]:
|
||||||
|
product_first = {"product_macro", "angle_proof", "packaging"}
|
||||||
|
if shot_key in product_first and product_refs:
|
||||||
|
return product_refs[min(2, len(product_refs) - 1)], "reference_image"
|
||||||
|
if subject_refs:
|
||||||
|
if shot_key in {"contact", "benefit"} and len(subject_refs) > 1:
|
||||||
|
return subject_refs[min(1, len(subject_refs) - 1)], "reference_image"
|
||||||
|
return subject_refs[0], "reference_image"
|
||||||
|
if product_refs:
|
||||||
|
return product_refs[0], "reference_image"
|
||||||
|
return None, "reference_image"
|
||||||
|
|
||||||
|
|
||||||
|
def agent_get_video(job_id: str, video_id: str) -> GeneratedVideo | None:
|
||||||
|
job = JOBS.get(job_id)
|
||||||
|
if not job:
|
||||||
|
return None
|
||||||
|
return next((item for item in job.generated_videos if item.id == video_id), None)
|
||||||
|
|
||||||
|
|
||||||
|
def agent_wait_videos(run: AgentRun, ids: list[str], *, target_completed: int) -> list[str]:
|
||||||
|
deadline = time.time() + AGENT_VIDEO_TIMEOUT_SECONDS
|
||||||
|
last_summary = ""
|
||||||
|
while time.time() < deadline:
|
||||||
|
completed: list[str] = []
|
||||||
|
active = 0
|
||||||
|
failed = 0
|
||||||
|
for video_id in ids:
|
||||||
|
item = agent_get_video(run.job_id, video_id)
|
||||||
|
if not item:
|
||||||
|
active += 1
|
||||||
|
continue
|
||||||
|
if item.status == "completed" and item.url:
|
||||||
|
completed.append(video_id)
|
||||||
|
elif item.status == "failed":
|
||||||
|
failed += 1
|
||||||
|
else:
|
||||||
|
active += 1
|
||||||
|
summary = f"视频生成中 · 完成 {len(completed)}/{target_completed} · 运行 {active} · 失败 {failed}"
|
||||||
|
if summary != last_summary:
|
||||||
|
agent_log(run, summary, stage="execute", progress=58 + min(24, len(completed) * 2))
|
||||||
|
last_summary = summary
|
||||||
|
if len(completed) >= target_completed or active == 0:
|
||||||
|
return completed
|
||||||
|
time.sleep(6)
|
||||||
|
return [video_id for video_id in ids if (agent_get_video(run.job_id, video_id) and agent_get_video(run.job_id, video_id).status == "completed")]
|
||||||
|
|
||||||
|
|
||||||
|
def agent_submit_shot(
|
||||||
|
run: AgentRun,
|
||||||
|
frame: KeyFrame,
|
||||||
|
shot: dict,
|
||||||
|
product_refs: list[dict],
|
||||||
|
subject_refs: list[dict],
|
||||||
|
retry: int = 0,
|
||||||
|
) -> str:
|
||||||
|
first_ref, primary_role = agent_reference_for_shot(str(shot["key"]), product_refs, subject_refs)
|
||||||
|
if not first_ref:
|
||||||
|
raise RuntimeError("no reference image available for video generation")
|
||||||
|
job = JOBS[run.job_id]
|
||||||
|
prompt = str(shot["prompt"])
|
||||||
|
if retry:
|
||||||
|
prompt += f"\n\nRetry pass {retry}: keep the same idea but simplify motion, keep the product shape stable, avoid strange anatomy or deformed product."
|
||||||
|
req = GenerateStoryboardVideoReq(
|
||||||
|
prompt=prompt,
|
||||||
|
duration=AGENT_SHOT_DURATION_SECONDS,
|
||||||
|
count=1,
|
||||||
|
storyboard_row_idx=len(run.video_ids),
|
||||||
|
first_image=first_ref,
|
||||||
|
product_images=product_refs[:6],
|
||||||
|
subject_images=subject_refs[:4],
|
||||||
|
model="seedance",
|
||||||
|
size="720x1280",
|
||||||
|
)
|
||||||
|
# _enqueue_storyboard_videos derives the primary role from first_image. Keep the
|
||||||
|
# local variable above for future provider-specific tuning without changing API.
|
||||||
|
_ = primary_role
|
||||||
|
ids = _enqueue_storyboard_videos(job, frame, req, None)
|
||||||
|
return ids[0]
|
||||||
|
|
||||||
|
|
||||||
|
def agent_compose_final(agent: AgentRun, ordered_ids: list[str]) -> None:
|
||||||
|
d = agent_run_dir(agent.id)
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
final_dir = job_dir(agent.job_id) / "final"
|
||||||
|
final_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
final = final_dir / f"agent-{agent.id}.mp4"
|
||||||
|
concat_file = d / "concat.txt"
|
||||||
|
paths: list[Path] = []
|
||||||
|
for video_id in ordered_ids:
|
||||||
|
p = job_dir(agent.job_id) / "storyboard_videos" / video_id / "video.mp4"
|
||||||
|
if p.exists() and p.stat().st_size > 0:
|
||||||
|
paths.append(p.resolve())
|
||||||
|
if not paths:
|
||||||
|
raise RuntimeError("no completed video files to compose")
|
||||||
|
concat_file.write_text("".join(f"file '{str(p).replace(chr(39), chr(39) + chr(92) + chr(39) + chr(39))}'\n" for p in paths), encoding="utf-8")
|
||||||
|
try:
|
||||||
|
run_cmd = ["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_file), "-c", "copy", "-movflags", "+faststart", str(final)]
|
||||||
|
run(run_cmd)
|
||||||
|
except Exception:
|
||||||
|
run_cmd = [
|
||||||
|
"ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", str(concat_file),
|
||||||
|
"-vf", "scale=720:1280,setsar=1", "-r", "24", "-c:v", "mpeg4", "-q:v", "4",
|
||||||
|
"-c:a", "aac", "-b:a", "160k", "-movflags", "+faststart", str(final),
|
||||||
|
]
|
||||||
|
run(run_cmd)
|
||||||
|
contact = d / "contact.jpg"
|
||||||
|
try:
|
||||||
|
run([
|
||||||
|
"ffmpeg", "-y", "-i", str(final),
|
||||||
|
"-vf", "select='not(mod(n,120))',scale=180:320,tile=12x1",
|
||||||
|
"-frames:v", "1", str(contact),
|
||||||
|
])
|
||||||
|
agent.contact_sheet_url = f"/agent-runs/{agent.id}/contact.jpg"
|
||||||
|
except Exception as e:
|
||||||
|
agent_log(agent, f"抽帧审片图生成失败:{str(e)[:180]}", level="warn")
|
||||||
|
agent.final_video_url = f"/agent-runs/{agent.id}/final.mp4"
|
||||||
|
save_agent_run(agent)
|
||||||
|
|
||||||
|
|
||||||
|
def agent_run_worker(run_id: str, product_refs: list[dict]) -> None:
|
||||||
|
run = AGENT_RUNS[run_id]
|
||||||
|
try:
|
||||||
|
agent_log(run, "接管任务:创建 1 分钟二创出片流程", status="executing", stage="download", progress=4)
|
||||||
|
pipeline_download(run.job_id)
|
||||||
|
job = JOBS[run.job_id]
|
||||||
|
if job.status == "failed":
|
||||||
|
raise RuntimeError(job.error or job.message or "source video download failed")
|
||||||
|
agent_log(run, f"源视频就绪 · {job.duration:.1f}s · {job.width}x{job.height}", stage="download", progress=14)
|
||||||
|
|
||||||
|
refs = product_refs[:6] or agent_fallback_product_refs(run.job_id)
|
||||||
|
if not refs:
|
||||||
|
raise RuntimeError("需要至少 1 张产品图")
|
||||||
|
update(job, product_refs=refs, message=f"Agent 已接入产品图 · {len(refs)} 张")
|
||||||
|
agent_log(run, f"产品素材就绪 · {len(refs)} 张", stage="assets", progress=20)
|
||||||
|
|
||||||
|
subject_refs = agent_subject_refs(run.job_id)
|
||||||
|
if subject_refs:
|
||||||
|
agent_log(run, f"主体参考就绪 · {len(subject_refs)} 张透明骨架角色", stage="assets", progress=24)
|
||||||
|
else:
|
||||||
|
agent_log(run, "未找到主体角色库,改用产品图和文本约束生成", stage="assets", progress=24, level="warn")
|
||||||
|
|
||||||
|
agent_log(run, "抽取源视频节奏帧 · 12 张", stage="analyze", progress=28)
|
||||||
|
pipeline_analyze(run.job_id, frame_count=12, target="transparent_human", mode="replace", quality="auto")
|
||||||
|
job = JOBS[run.job_id]
|
||||||
|
if not job.frames:
|
||||||
|
raise RuntimeError(job.error or "keyframe extraction failed")
|
||||||
|
agent_log(run, f"节奏帧完成 · {len(job.frames)} 张", stage="plan", progress=40)
|
||||||
|
|
||||||
|
shots = agent_shot_plan()
|
||||||
|
agent_log(run, f"生成二创镜头计划 · {len(shots)} 段 × {AGENT_SHOT_DURATION_SECONDS:g}s", stage="plan", progress=46)
|
||||||
|
submitted: list[str] = []
|
||||||
|
for idx, shot in enumerate(shots):
|
||||||
|
frame = job.frames[idx % len(job.frames)]
|
||||||
|
video_id = agent_submit_shot(run, frame, shot, refs, subject_refs)
|
||||||
|
submitted.append(video_id)
|
||||||
|
run.video_ids = submitted
|
||||||
|
save_agent_run(run)
|
||||||
|
agent_log(run, f"提交镜头 {idx + 1:02d}/{len(shots)} · {shot['key']} · {video_id}", stage="execute", progress=48 + idx)
|
||||||
|
|
||||||
|
completed = agent_wait_videos(run, submitted, target_completed=len(shots))
|
||||||
|
failed_positions = [i for i, video_id in enumerate(submitted) if video_id not in completed]
|
||||||
|
if failed_positions:
|
||||||
|
agent_log(run, f"有 {len(failed_positions)} 段未完成,自动重跑一次", stage="execute", progress=82, level="warn")
|
||||||
|
for pos in failed_positions:
|
||||||
|
frame = job.frames[pos % len(job.frames)]
|
||||||
|
retry_id = agent_submit_shot(run, frame, shots[pos], refs, subject_refs, retry=1)
|
||||||
|
submitted[pos] = retry_id
|
||||||
|
run.video_ids = submitted
|
||||||
|
save_agent_run(run)
|
||||||
|
agent_log(run, f"重跑镜头 {pos + 1:02d} · {retry_id}", stage="execute", progress=83)
|
||||||
|
|
||||||
|
completed = agent_wait_videos(run, submitted, target_completed=len(shots))
|
||||||
|
ordered_completed = [video_id for video_id in submitted if video_id in completed]
|
||||||
|
if len(ordered_completed) < max(8, len(shots) - 2):
|
||||||
|
raise RuntimeError(f"可用镜头不足:{len(ordered_completed)}/{len(shots)}")
|
||||||
|
|
||||||
|
agent_log(run, f"自动审片通过 · 可用 {len(ordered_completed)}/{len(shots)} 段", status="reviewing", stage="review", progress=88)
|
||||||
|
agent_log(run, "合成最终成片", stage="compose", progress=92)
|
||||||
|
agent_compose_final(run, ordered_completed)
|
||||||
|
agent_log(run, f"成片完成 · {len(ordered_completed)} 段", status="completed", stage="final", progress=100)
|
||||||
|
except Exception as e:
|
||||||
|
run.error = str(e)[:600]
|
||||||
|
agent_log(run, f"任务失败:{run.error}", status="failed", stage="failed", progress=100, level="error")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/agent-runs", response_model=AgentRun)
|
||||||
|
async def create_agent_run(
|
||||||
|
tk_url: str = Form(...),
|
||||||
|
product_files: list[UploadFile] | None = File(None),
|
||||||
|
) -> AgentRun:
|
||||||
|
if not tk_url.strip():
|
||||||
|
raise HTTPException(400, "tk_url required")
|
||||||
|
job_id = uuid.uuid4().hex[:12]
|
||||||
|
run_id = uuid.uuid4().hex[:12]
|
||||||
|
job = Job(id=job_id, url=tk_url.strip())
|
||||||
|
JOBS[job_id] = job
|
||||||
|
save_state(job)
|
||||||
|
|
||||||
|
refs: list[dict] = []
|
||||||
|
for index, upload in enumerate((product_files or [])[:6], start=1):
|
||||||
|
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)
|
||||||
|
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()
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/agent-runs", response_model=list[AgentRun])
|
||||||
|
def list_agent_runs(limit: int = 20) -> list[AgentRun]:
|
||||||
|
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.sort(key=lambda item: item.updated_at, reverse=True)
|
||||||
|
return items[:max(1, min(100, limit))]
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/agent-runs/{run_id}", response_model=AgentRun)
|
||||||
|
def get_agent_run(run_id: str) -> AgentRun:
|
||||||
|
run = AGENT_RUNS.get(run_id)
|
||||||
|
if not run and agent_run_path(run_id).exists():
|
||||||
|
run = AgentRun.model_validate_json(agent_run_path(run_id).read_text(encoding="utf-8"))
|
||||||
|
AGENT_RUNS[run_id] = run
|
||||||
|
if not run:
|
||||||
|
raise HTTPException(404, "agent run not found")
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/agent-runs/{run_id}/final.mp4")
|
||||||
|
def get_agent_run_final(run_id: str):
|
||||||
|
run = get_agent_run(run_id)
|
||||||
|
p = job_dir(run.job_id) / "final" / f"agent-{run.id}.mp4"
|
||||||
|
if not p.exists():
|
||||||
|
raise HTTPException(404, "final video not found")
|
||||||
|
return FileResponse(p, media_type="video/mp4")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/agent-runs/{run_id}/contact.jpg")
|
||||||
|
def get_agent_run_contact(run_id: str):
|
||||||
|
p = agent_run_dir(run_id) / "contact.jpg"
|
||||||
|
if not p.exists():
|
||||||
|
raise HTTPException(404, "contact sheet not found")
|
||||||
|
return FileResponse(p, media_type="image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
def product_image_alpha(img: Image.Image) -> Image.Image:
|
def product_image_alpha(img: Image.Image) -> Image.Image:
|
||||||
rgba = img.convert("RGBA")
|
rgba = img.convert("RGBA")
|
||||||
rgb = rgba.convert("RGB")
|
rgb = rgba.convert("RGB")
|
||||||
|
|||||||
3
api/prompt_library/index.json
Normal file
3
api/prompt_library/index.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
@@ -10,11 +10,13 @@ services:
|
|||||||
- ./deploy/.env.production
|
- ./deploy/.env.production
|
||||||
environment:
|
environment:
|
||||||
JOBS_DIR: /data/jobs
|
JOBS_DIR: /data/jobs
|
||||||
|
AGENT_RUNS_DIR: /data/agent_runs
|
||||||
ASSET_LIBRARY_DIR: /data/asset_library
|
ASSET_LIBRARY_DIR: /data/asset_library
|
||||||
PROMPT_LIBRARY_DIR: /data/prompt_library
|
PROMPT_LIBRARY_DIR: /data/prompt_library
|
||||||
CORS_ORIGINS: https://marketing.skg.com
|
CORS_ORIGINS: https://marketing.skg.com
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/jobs:/data/jobs
|
- ./data/jobs:/data/jobs
|
||||||
|
- ./data/agent_runs:/data/agent_runs
|
||||||
- ./data/asset_library:/data/asset_library
|
- ./data/asset_library:/data/asset_library
|
||||||
- ./data/prompt_library:/data/prompt_library
|
- ./data/prompt_library:/data/prompt_library
|
||||||
- ./data/_trash:/data/_trash
|
- ./data/_trash:/data/_trash
|
||||||
|
|||||||
47
docker-compose.standalone.yml
Normal file
47
docker-compose.standalone.yml
Normal 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
332
web/app/agent/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user