3 Commits

Author SHA1 Message Date
3146266383 feat: redesign creative studio entry 2026-05-23 23:55:05 +08:00
0e55945352 auto-save 2026-05-23 23:45 (~2) 2026-05-23 23:45:06 +08:00
d551c45006 auto-save 2026-05-23 23:36 (~2) 2026-05-23 23:39:41 +08:00
8 changed files with 965 additions and 1379 deletions

1
.gitignore vendored
View File

@@ -17,6 +17,7 @@ __pycache__/
deploy/.env.production
deploy/.htpasswd
secrets/
.backups/
# api
api/.venv/

View File

@@ -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 @@
## 最近助手会话概览
- Claudea9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
- Codex019e49d1-d114-7d52-8cf0-fbb4585f5ce6 · 时间未知
- Codex019e4d33-ad65-7673-934e-815226984ea6 · 时间未知
- Cursor未找到匹配当前项目的最近会话
## Claude 最近会话
@@ -92,57 +92,39 @@
## 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 ID019e4d33-ad65-7673-934e-815226984ea6
- Transcript/Users/kangwan/.codex/sessions/2026/05/22/rollout-2026-05-22T09-01-32-019e4d33-ad65-7673-934e-815226984ea6.jsonl
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
- 分支main
- 敏感字段:已对 token / key / password / secret 做脱敏
### 最近用户要求
- 分镜这个板块,我每次点击视频 都变成了选择 其实我就是想打开看下而已 你这个逻辑不太对 你看下
- 为什么要选择呢 你给我点击下载的按钮不就好了
- 而且每个生成的视频当然是自动保存呀 还需要我点击保存干啥
- 音频解析 也需需要有中文识别 和多语言识别
- 脚本那边是自动识别语音识别的内容然后生成脚本文案么?
- 分镜那边
### Codex 最近回复
> 生产部署正在跑第二轮远端备份和同步已经完成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

View File

@@ -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
}
]
}

View File

@@ -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

View File

@@ -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({

File diff suppressed because it is too large Load Diff

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") {