Compare commits
3 Commits
pre-redesi
...
3146266383
| Author | SHA1 | Date | |
|---|---|---|---|
| 3146266383 | |||
| 0e55945352 | |||
| d551c45006 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,6 +17,7 @@ __pycache__/
|
||||
deploy/.env.production
|
||||
deploy/.htpasswd
|
||||
secrets/
|
||||
.backups/
|
||||
|
||||
# api
|
||||
api/.venv/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 项目接力
|
||||
|
||||
- 生成时间:May 22, 2026 at 09:01
|
||||
- 生成时间:May 23, 2026 at 23:37
|
||||
- 项目:SKG Marketing Studio / SKG 营销内容工作台
|
||||
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
||||
- 状态:active
|
||||
@@ -9,7 +9,7 @@
|
||||
## 最近助手会话概览
|
||||
|
||||
- Claude:a9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
|
||||
- Codex:019e49d1-d114-7d52-8cf0-fbb4585f5ce6 · 时间未知
|
||||
- Codex:019e4d33-ad65-7673-934e-815226984ea6 · 时间未知
|
||||
- Cursor:未找到匹配当前项目的最近会话
|
||||
|
||||
## Claude 最近会话
|
||||
@@ -92,57 +92,39 @@
|
||||
|
||||
## Codex 最近会话
|
||||
|
||||
- Session ID:019e49d1-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 ID:019e4d33-ad65-7673-934e-815226984ea6
|
||||
- Transcript:/Users/kangwan/.codex/sessions/2026/05/22/rollout-2026-05-22T09-01-32-019e4d33-ad65-7673-934e-815226984ea6.jsonl
|
||||
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
||||
- 分支:main
|
||||
- 敏感字段:已对 token / key / password / secret 做脱敏
|
||||
|
||||
### 最近用户要求
|
||||
|
||||
- 分镜这个板块,我每次点击视频 都变成了选择 其实我就是想打开看下而已 你这个逻辑不太对 你看下
|
||||
- 为什么要选择呢 你给我点击下载的按钮不就好了
|
||||
- 而且每个生成的视频当然是自动保存呀 还需要我点击保存干啥
|
||||
- 音频解析 也需需要有中文识别 和多语言识别
|
||||
- 脚本那边是自动识别语音识别的内容然后生成脚本文案么?
|
||||
- 分镜那边
|
||||
|
||||
### Codex 最近回复
|
||||
|
||||
> 生产部署正在跑第二轮,远端备份和同步已经完成,Web 镜像在构建;我等脚本验证结束。
|
||||
> 对,问题聚焦在分镜这边就更清楚了:我已经看了分镜面板当前实现,下面按“现在实际怎么跑”说。
|
||||
|
||||
> 对,这个点你说得对。生成的视频本来就会写进当前 job 的 `generated_videos`,完成后自动回填 mp4,不应该让用户理解成还要点“保存视频”。
|
||||
> 分镜这边现在是“半自动”,不是完全端到端自动重写整片脚本。
|
||||
>
|
||||
> 我已经改完并上线:
|
||||
> 当前链路是:
|
||||
>
|
||||
> - 分镜视频候选轨现在显示 `自动保存`
|
||||
> - 视频候选普通点击只负责打开预览
|
||||
> - 右上角提供显式 `下载` 按钮
|
||||
> - “保存全部 / 保存本条规划”改成了“同步规划 / 同步本条规划”,只表达同步三字段文案规划,不再像保存视频
|
||||
> - 源码解析页也同步更新了这条交互语义
|
||||
> 1. 音频解析完成后,`job.transcript` 里有逐句时间轴:原语言文案 + 中文镜像。
|
||||
> 2. 分镜面板会自动按每个 transcript 段生成一行分镜,保留原音频时间点和原文案依据。
|
||||
> 3. 每行会先自动套默认三字段:
|
||||
> - `文案`
|
||||
> - `场景一句话`
|
||||
> - `人物 + 产品 + 动作`
|
||||
> 4. 这些默认值目前主要是按段落位置和角色分类生成,比如 hook / pain / proof / solution / cta,不是每句都先让大模型深度理解原文后重写。
|
||||
> 5. 当你点单行生成视频候选或同步规划时,才会走 `quickPlanStoryboard`,把当前三字段、参考帧、产品信息扩成完整分镜 prompt。
|
||||
> 6. 你改中文字段后,系统会自动优化对应英文主字段,英文用于后续生视频。
|
||||
>
|
||||
> 验证和发布:
|
||||
> 所以准确说:分镜会自动读取语音识别结果来切分时间轴、显示原文依据、初始化分镜行;但“根据识别内容自动生成更贴合原视频语义的新脚本文案”这层现在还不够强,偏模板化。
|
||||
>
|
||||
> - `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…
|
||||
> 如果你想要的效果是:音频一识别完,分镜区就自动根据原音频内容生成每句对应的新 SKG 脚本文案,那需要再补一层“transcript -> 三字段候选”的自动脚本生成逻辑。这个方向是合理的。
|
||||
|
||||
## Cursor 最近会话
|
||||
|
||||
@@ -152,7 +134,7 @@
|
||||
|
||||
- 当前分支:main
|
||||
- 未提交变更:1 项
|
||||
- 最近提交:fix: clarify storyboard videos auto-save
|
||||
- 最近提交:docs: record multilingual asr deployment
|
||||
- 变更文件:
|
||||
- M .memory/worklog.json
|
||||
|
||||
|
||||
@@ -1,123 +1,5 @@
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"files_changed": 4,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 4 项未提交变更 · 最近提交:fix: rename source video section title",
|
||||
"ts": "2026-05-19T08:54:36Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 3,
|
||||
"hash": "7a7a3b5",
|
||||
"message": "fix: narrow transcript timeline panel",
|
||||
"ts": "2026-05-19T16:54:51+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: narrow transcript timeline panel",
|
||||
"ts": "2026-05-19T09:04:36Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: narrow transcript timeline panel",
|
||||
"ts": "2026-05-19T09:14:36Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 2,
|
||||
"hash": "7c69825",
|
||||
"message": "auto-save 2026-05-19 17:21 (~2)",
|
||||
"ts": "2026-05-19T17:22:01+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 3,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 3 项未提交变更 · 最近提交:auto-save 2026-05-19 17:21 (~2)",
|
||||
"ts": "2026-05-19T09:24:36Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 3,
|
||||
"hash": "1f6eabc",
|
||||
"message": "fix: add temporary filmstrip frame picker",
|
||||
"ts": "2026-05-19T17:27:09+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 4,
|
||||
"hash": "d461c65",
|
||||
"message": "fix: lift filmstrip preview above frame",
|
||||
"ts": "2026-05-19T17:33:37+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: lift filmstrip preview above frame",
|
||||
"ts": "2026-05-19T09:34:36Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 4,
|
||||
"hash": "cb991e7",
|
||||
"message": "fix: enlarge filmstrip frames in place",
|
||||
"ts": "2026-05-19T17:42:02+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: enlarge filmstrip frames in place",
|
||||
"ts": "2026-05-19T09:44:37Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 2,
|
||||
"hash": "8bceb90",
|
||||
"message": "auto-save 2026-05-19 17:49 (~2)",
|
||||
"ts": "2026-05-19T17:50:00+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 3,
|
||||
"hash": "883e1d4",
|
||||
"message": "fix: align filmstrip frames with waveform",
|
||||
"ts": "2026-05-19T17:53:17+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: align filmstrip frames with waveform",
|
||||
"ts": "2026-05-19T09:54:37Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 2,
|
||||
"hash": "97016dc",
|
||||
"message": "fix: hide waveform filmstrip labels",
|
||||
"ts": "2026-05-19T17:59:58+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: hide waveform filmstrip labels",
|
||||
"ts": "2026-05-19T10:04:37Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 2,
|
||||
"hash": "20f730a",
|
||||
"message": "auto-save 2026-05-19 18:06 (~2)",
|
||||
"ts": "2026-05-19T18:06:17+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 3,
|
||||
"hash": "818d785",
|
||||
"message": "fix: enlarge filmstrip hover near waveform",
|
||||
"ts": "2026-05-19T18:08:17+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 2,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 2 项未提交变更 · 最近提交:fix: enlarge filmstrip hover near waveform",
|
||||
@@ -3175,37 +3057,150 @@
|
||||
"type": "assistant-session"
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-22T09:05:48+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-22 09:01 (~2)",
|
||||
"files_changed": 2,
|
||||
"hash": "2342892",
|
||||
"message": "auto-save 2026-05-22 09:01 (~2)",
|
||||
"ts": "2026-05-22T09:05:48+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:auto-save 2026-05-22 09:01 (~2)",
|
||||
"ts": "2026-05-22T01:11:31Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 3,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 3 项未提交变更 · 最近提交:auto-save 2026-05-22 09:01 (~2)",
|
||||
"ts": "2026-05-22T01:21:31Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 5,
|
||||
"hash": "3c357b0",
|
||||
"message": "auto-save 2026-05-22 09:22 (~5)",
|
||||
"ts": "2026-05-22T09:22:27+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 7,
|
||||
"hash": "6427935",
|
||||
"message": "fix: support multilingual audio transcription",
|
||||
"ts": "2026-05-22T09:26:59+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 2,
|
||||
"hash": "7ae92ae",
|
||||
"message": "auto-save 2026-05-22 09:27 (~2)",
|
||||
"ts": "2026-05-22T09:27:54+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"hash": "0d5c326",
|
||||
"message": "docs: record multilingual asr deployment",
|
||||
"ts": "2026-05-22T09:30:59+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:docs: record multilingual asr deployment",
|
||||
"ts": "2026-05-22T01:31:31Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:docs: record multilingual asr deployment",
|
||||
"ts": "2026-05-22T01:41:31Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:docs: record multilingual asr deployment",
|
||||
"ts": "2026-05-22T01:51:31Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:docs: record multilingual asr deployment",
|
||||
"ts": "2026-05-22T02:01:32Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:docs: record multilingual asr deployment",
|
||||
"ts": "2026-05-22T02:11:59Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:docs: record multilingual asr deployment",
|
||||
"ts": "2026-05-22T02:21:59Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:docs: record multilingual asr deployment",
|
||||
"ts": "2026-05-22T02:31:59Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:docs: record multilingual asr deployment",
|
||||
"ts": "2026-05-22T02:41:59Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:docs: record multilingual asr deployment",
|
||||
"ts": "2026-05-22T02:51:59Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:docs: record multilingual asr deployment",
|
||||
"ts": "2026-05-22T03:05:43Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话结束 · 持续 0 秒 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:docs: record multilingual asr deployment",
|
||||
"ts": "2026-05-22T03:15:25Z",
|
||||
"type": "session-end"
|
||||
},
|
||||
{
|
||||
"files_changed": 2,
|
||||
"message": "启动 Codex 接力会话 · 已载入 Claude / Codex 最近会话,等待下一条指令 · 分支 main · 2 项未提交变更 · 最近提交:docs: record multilingual asr deployment",
|
||||
"ts": "2026-05-23T15:37:18Z",
|
||||
"type": "assistant-session"
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-23T23:39:41+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-23 23:36 (~2)",
|
||||
"hash": "d551c45",
|
||||
"files_changed": 2
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-22T01:11:31Z",
|
||||
"ts": "2026-05-23T23:45:06+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-23 23:45 (~2)",
|
||||
"hash": "0e55945",
|
||||
"files_changed": 2
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-23T15:47:20Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:auto-save 2026-05-22 09:01 (~2)",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:auto-save 2026-05-23 23:45 (~2)",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-22T01:21:31Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 3 项未提交变更 · 最近提交:auto-save 2026-05-22 09:01 (~2)",
|
||||
"ts": "2026-05-23T23:50:30+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-23 23:50 (~3)",
|
||||
"hash": "e13bb0b",
|
||||
"files_changed": 3
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-22T09:22:27+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-22 09:22 (~5)",
|
||||
"hash": "3c357b0",
|
||||
"files_changed": 5
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-22T09:26:59+08:00",
|
||||
"type": "commit",
|
||||
"message": "fix: support multilingual audio transcription",
|
||||
"hash": "6427935",
|
||||
"files_changed": 7
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
180
api/main.py
180
api/main.py
@@ -4275,6 +4275,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 +4365,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 +4468,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 "")
|
||||
@@ -4663,6 +4795,54 @@ async def create_job_from_upload(bg: BackgroundTasks, file: UploadFile = File(..
|
||||
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(file: UploadFile | None = File(default=None)) -> Job:
|
||||
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],
|
||||
)
|
||||
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,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -12,8 +12,8 @@ const _playfairDisplay = Playfair_Display({
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SKG TK 二创工作台",
|
||||
description: "SKG AI 素材生产管线 · 节点工作流",
|
||||
title: "SKG Creative Studio",
|
||||
description: "SKG AI 图片、视频和文案创作台",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
1736
web/app/page.tsx
1736
web/app/page.tsx
File diff suppressed because it is too large
Load Diff
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user