Compare commits
9 Commits
backup/pre
...
eca5213dab
| Author | SHA1 | Date | |
|---|---|---|---|
| eca5213dab | |||
| 976b318432 | |||
| 04d80c133a | |||
| 3b1d7645d1 | |||
| f8c51b5ef6 | |||
| 8e60c7dff9 | |||
| a27dcbda8d | |||
| 828b86d187 | |||
| c1eddda59e |
@@ -1,6 +1,6 @@
|
||||
# 项目接力
|
||||
|
||||
- 生成时间:May 23, 2026 at 23:37
|
||||
- 生成时间:May 25, 2026 at 09:02
|
||||
- 项目:SKG Marketing Studio / SKG 营销内容工作台
|
||||
- 路径:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
||||
- 状态:active
|
||||
@@ -9,7 +9,7 @@
|
||||
## 最近助手会话概览
|
||||
|
||||
- Claude:a9e0449c-d9cb-4a2a-bb16-16596dfb552a · 时间未知
|
||||
- Codex:019e4d33-ad65-7673-934e-815226984ea6 · 时间未知
|
||||
- Codex:019e55d1-ccf1-7a90-805f-3bd4799a49cd · 时间未知
|
||||
- Cursor:未找到匹配当前项目的最近会话
|
||||
|
||||
## Claude 最近会话
|
||||
@@ -92,39 +92,44 @@
|
||||
|
||||
## Codex 最近会话
|
||||
|
||||
- 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
|
||||
- Session ID:019e55d1-ccf1-7a90-805f-3bd4799a49cd
|
||||
- Transcript:/Users/kangwan/.codex/sessions/2026/05/24/rollout-2026-05-24T01-11-13-019e55d1-ccf1-7a90-805f-3bd4799a49cd.jsonl
|
||||
- 工作目录:/Users/kangwan/Projects/business/20260512-20260512-skg-tk-二创验证
|
||||
- 分支:main
|
||||
- 敏感字段:已对 token / key / password / secret 做脱敏
|
||||
|
||||
### 最近用户要求
|
||||
|
||||
- 音频解析 也需需要有中文识别 和多语言识别
|
||||
- 脚本那边是自动识别语音识别的内容然后生成脚本文案么?
|
||||
- 分镜那边
|
||||
- 还是不够简洁简单,我们再深度研究下怎么样的排列才最简单,别人一看就会用
|
||||
- 我看一下样式,你用figma画一下
|
||||
- 设计的版面还是太差了 我找一下模版
|
||||
|
||||
### Codex 最近回复
|
||||
|
||||
> 对,问题聚焦在分镜这边就更清楚了:我已经看了分镜面板当前实现,下面按“现在实际怎么跑”说。
|
||||
> 我画了一版简化首页样式稿并打开了:
|
||||
>
|
||||
> - HTML 样式稿:`output/design/skg-simple-home-mockup.html`
|
||||
> - 截图:`output/design/skg-simple-home-mockup-fullpage.png`
|
||||
>
|
||||
> 说明一下:当前会话没有可写入 Figma 云文件的 MCP 工具,所以我先用本地“Figma 风格画板”做了可看的样式稿。这个稿不影响线上代码,也没有提交。
|
||||
>
|
||||
> 核心变化是:首屏只保留一个主流程:
|
||||
> `选择产物 → 上传素材可选 → 写一句话需求 → 开始生成`
|
||||
>
|
||||
> `git status -sb` 当前仍只有 `.memory/worklog.json` 自动日志脏文件。
|
||||
|
||||
> 分镜这边现在是“半自动”,不是完全端到端自动重写整片脚本。
|
||||
> 可以,你先找模板。
|
||||
>
|
||||
> 当前链路是:
|
||||
> 我建议你找这类方向的,不要找复杂 AI 工具后台:
|
||||
>
|
||||
> 1. 音频解析完成后,`job.transcript` 里有逐句时间轴:原语言文案 + 中文镜像。
|
||||
> 2. 分镜面板会自动按每个 transcript 段生成一行分镜,保留原音频时间点和原文案依据。
|
||||
> 3. 每行会先自动套默认三字段:
|
||||
> - `文案`
|
||||
> - `场景一句话`
|
||||
> - `人物 + 产品 + 动作`
|
||||
> 4. 这些默认值目前主要是按段落位置和角色分类生成,比如 hook / pain / proof / solution / cta,不是每句都先让大模型深度理解原文后重写。
|
||||
> 5. 当你点单行生成视频候选或同步规划时,才会走 `quickPlanStoryboard`,把当前三字段、参考帧、产品信息扩成完整分镜 prompt。
|
||||
> 6. 你改中文字段后,系统会自动优化对应英文主字段,英文用于后续生视频。
|
||||
> - “AI image generator dashboard”
|
||||
> - “content creation workspace”
|
||||
> - “simple prompt editor”
|
||||
> - “Canva / CapCut style creation home”
|
||||
> - “one page SaaS tool dashboard”
|
||||
> - “creative brief generator UI”
|
||||
>
|
||||
> 所以准确说:分镜会自动读取语音识别结果来切分时间轴、显示原文依据、初始化分镜行;但“根据识别内容自动生成更贴合原视频语义的新脚本文案”这层现在还不够强,偏模板化。
|
||||
>
|
||||
> 如果你想要的效果是:音频一识别完,分镜区就自动根据原音频内容生成每句对应的新 SKG 脚本文案,那需要再补一层“transcript -> 三字段候选”的自动脚本生成逻辑。这个方向是合理的。
|
||||
> 最适合我们的是:中间一个大输入区,顶部或左侧选择产物,右侧只放最近任务/结果。不要找那种很多统计卡、很多侧边栏菜单、很多模型参数的模板,那类还是会把普通同事绕晕。
|
||||
|
||||
## Cursor 最近会话
|
||||
|
||||
|
||||
@@ -1,155 +1,5 @@
|
||||
{
|
||||
"entries": [
|
||||
{
|
||||
"files_changed": 3,
|
||||
"hash": "ce5f3b4",
|
||||
"message": "fix: move transcript timeline below video",
|
||||
"ts": "2026-05-19T18:43:54+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: move transcript timeline below video",
|
||||
"ts": "2026-05-19T10:44:37Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: move transcript timeline below video",
|
||||
"ts": "2026-05-19T10:54:38Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: move transcript timeline below video",
|
||||
"ts": "2026-05-19T11:04:38Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: move transcript timeline below video",
|
||||
"ts": "2026-05-19T11:14:38Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 2,
|
||||
"hash": "e21b9a1",
|
||||
"message": "auto-save 2026-05-19 19:16 (~2)",
|
||||
"ts": "2026-05-19T19:16:50+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 4,
|
||||
"hash": "326403d",
|
||||
"message": "auto-save 2026-05-19 19:22 (~4)",
|
||||
"ts": "2026-05-19T19:22:17+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 3,
|
||||
"hash": "b9bf50f",
|
||||
"message": "feat: add source subject conversion pipeline",
|
||||
"ts": "2026-05-19T19:23:01+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:feat: add source subject conversion pipeline",
|
||||
"ts": "2026-05-19T11:24:38Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 4,
|
||||
"hash": "a5979bb",
|
||||
"message": "fix: refine reference frame previews",
|
||||
"ts": "2026-05-19T19:31:45+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: refine reference frame previews",
|
||||
"ts": "2026-05-19T11:34:38Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 3,
|
||||
"hash": "bdadd71",
|
||||
"message": "fix: preserve reference frame aspect ratio",
|
||||
"ts": "2026-05-19T19:36:38+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 3,
|
||||
"hash": "9bca5b3",
|
||||
"message": "auto-save 2026-05-19 19:43 (~3)",
|
||||
"ts": "2026-05-19T19:44:00+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 2,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 2 项未提交变更 · 最近提交:auto-save 2026-05-19 19:43 (~3)",
|
||||
"ts": "2026-05-19T11:44:38Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 3,
|
||||
"hash": "fe60d5d",
|
||||
"message": "fix: shrink reference frame thumbnails",
|
||||
"ts": "2026-05-19T19:46:17+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 3,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 3 项未提交变更 · 最近提交:fix: shrink reference frame thumbnails",
|
||||
"ts": "2026-05-19T11:54:38Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 3,
|
||||
"hash": "e2be092",
|
||||
"message": "auto-save 2026-05-19 19:54 (~3)",
|
||||
"ts": "2026-05-19T19:54:52+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 6,
|
||||
"hash": "cf86417",
|
||||
"message": "auto-save 2026-05-19 20:00 (~6)",
|
||||
"ts": "2026-05-19T20:00:18+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 5,
|
||||
"hash": "aabddef",
|
||||
"message": "fix: improve filmstrip picking and audio retry",
|
||||
"ts": "2026-05-19T20:01:45+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: improve filmstrip picking and audio retry",
|
||||
"ts": "2026-05-19T12:04:39Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: improve filmstrip picking and audio retry",
|
||||
"ts": "2026-05-19T12:14:39Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 4,
|
||||
"hash": "ed471b3",
|
||||
"message": "auto-save 2026-05-19 20:21 (~4)",
|
||||
"ts": "2026-05-19T20:22:00+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 2,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 2 项未提交变更 · 最近提交:auto-save 2026-05-19 20:21 (~4)",
|
||||
"ts": "2026-05-19T12:24:39Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 2,
|
||||
"hash": "00872db",
|
||||
@@ -3130,76 +2980,226 @@
|
||||
"type": "assistant-session"
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-23T23:39:41+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-23 23:36 (~2)",
|
||||
"files_changed": 2,
|
||||
"hash": "d551c45",
|
||||
"files_changed": 2
|
||||
"message": "auto-save 2026-05-23 23:36 (~2)",
|
||||
"ts": "2026-05-23T23:39:41+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-23T23:45:06+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-23 23:45 (~2)",
|
||||
"files_changed": 2,
|
||||
"hash": "0e55945",
|
||||
"message": "auto-save 2026-05-23 23:45 (~2)",
|
||||
"ts": "2026-05-23T23:45:06+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:auto-save 2026-05-23 23:45 (~2)",
|
||||
"ts": "2026-05-23T15:47:20Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 3,
|
||||
"hash": "e13bb0b",
|
||||
"message": "auto-save 2026-05-23 23:50 (~3)",
|
||||
"ts": "2026-05-23T23:50:30+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 6,
|
||||
"hash": "3146266",
|
||||
"message": "feat: redesign creative studio entry",
|
||||
"ts": "2026-05-23T23:55:05+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:feat: redesign creative studio entry",
|
||||
"ts": "2026-05-23T15:57:18Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"hash": "90dde14",
|
||||
"message": "chore: ignore local verification artifacts",
|
||||
"ts": "2026-05-24T00:00:58+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:chore: ignore local verification artifacts",
|
||||
"ts": "2026-05-23T16:07:18Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:chore: ignore local verification artifacts",
|
||||
"ts": "2026-05-23T16:17:18Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 2,
|
||||
"hash": "91a7831",
|
||||
"message": "auto-save 2026-05-24 00:23 (~2)",
|
||||
"ts": "2026-05-24T00:23:22+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 13,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 13 项未提交变更 · 最近提交:auto-save 2026-05-24 00:23 (~2)",
|
||||
"ts": "2026-05-23T16:27:19Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 13,
|
||||
"hash": "95db558",
|
||||
"message": "auto-save 2026-05-24 00:28 (~13)",
|
||||
"ts": "2026-05-24T00:28:46+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 13,
|
||||
"hash": "04a822a",
|
||||
"message": "feat: add feishu multi-user auth",
|
||||
"ts": "2026-05-24T00:31:06+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:feat: add feishu multi-user auth",
|
||||
"ts": "2026-05-23T16:37:19Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:feat: add feishu multi-user auth",
|
||||
"ts": "2026-05-23T16:47:19Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:feat: add feishu multi-user auth",
|
||||
"ts": "2026-05-23T16:57:19Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:feat: add feishu multi-user auth",
|
||||
"ts": "2026-05-23T17:07:19Z",
|
||||
"type": "session-heartbeat"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"message": "Codex 会话结束 · 持续 0 秒 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:feat: add feishu multi-user auth",
|
||||
"ts": "2026-05-23T17:12:18Z",
|
||||
"type": "session-end"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"hash": "c1eddda",
|
||||
"message": "docs: record pre-redesign backup",
|
||||
"ts": "2026-05-24T01:23:16+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 2,
|
||||
"hash": "e68b1f7",
|
||||
"message": "auto-save 2026-05-24 01:28 (~2)",
|
||||
"ts": "2026-05-24T01:28:20+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 5,
|
||||
"hash": "0f92f10",
|
||||
"message": "auto-save 2026-05-24 01:33 (+1, ~4)",
|
||||
"ts": "2026-05-24T01:33:57+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 6,
|
||||
"hash": "c05836a",
|
||||
"message": "auto-save 2026-05-24 01:39 (~5)",
|
||||
"ts": "2026-05-24T01:39:44+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 5,
|
||||
"hash": "828b86d",
|
||||
"message": "feat: redesign marketing creation workspace",
|
||||
"ts": "2026-05-24T01:48:17+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 1,
|
||||
"hash": "a27dcbd",
|
||||
"message": "docs: record redesigned production deployment",
|
||||
"ts": "2026-05-24T01:57:40+08:00",
|
||||
"type": "commit"
|
||||
},
|
||||
{
|
||||
"files_changed": 2,
|
||||
"message": "启动 Codex 接力会话 · 已载入 Claude / Codex 最近会话,等待下一条指令 · 分支 main · 2 项未提交变更 · 最近提交:docs: record redesigned production deployment",
|
||||
"ts": "2026-05-25T01:02:12Z",
|
||||
"type": "assistant-session"
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-25T09:05:53+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-25 09:05 (~2)",
|
||||
"hash": "8e60c7d",
|
||||
"files_changed": 2
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-23T15:47:20Z",
|
||||
"ts": "2026-05-25T01:12:14Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:auto-save 2026-05-23 23:45 (~2)",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:auto-save 2026-05-25 09:05 (~2)",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-23T23:50:30+08:00",
|
||||
"ts": "2026-05-25T01:22:15Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:auto-save 2026-05-25 09:05 (~2)",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-25T09:27:31+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-23 23:50 (~3)",
|
||||
"hash": "e13bb0b",
|
||||
"message": "fix: enforce orthographic top bottom subject views",
|
||||
"hash": "f8c51b5",
|
||||
"files_changed": 5
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-25T01:32:15Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: enforce orthographic top bottom subject views",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-25T09:38:19+08:00",
|
||||
"type": "commit",
|
||||
"message": "fix: normalize media prompts and patent views",
|
||||
"hash": "3b1d764",
|
||||
"files_changed": 3
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-23T23:55:05+08:00",
|
||||
"ts": "2026-05-25T01:40:36Z",
|
||||
"type": "session-end",
|
||||
"message": "Codex 会话结束 · 持续 0 秒 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: normalize media prompts and patent views",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-25T01:40:36Z",
|
||||
"type": "session-end",
|
||||
"message": "Codex 会话结束 · 持续 0 秒 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: normalize media prompts and patent views",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-25T10:16:59+08:00",
|
||||
"type": "commit",
|
||||
"message": "feat: redesign creative studio entry",
|
||||
"hash": "3146266",
|
||||
"files_changed": 6
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-23T15:57:18Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:feat: redesign creative studio entry",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-24T00:00:58+08:00",
|
||||
"type": "commit",
|
||||
"message": "chore: ignore local verification artifacts",
|
||||
"hash": "90dde14",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-23T16:07:18Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:chore: ignore local verification artifacts",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-23T16:17:18Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:chore: ignore local verification artifacts",
|
||||
"files_changed": 1
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-24T00:23:22+08:00",
|
||||
"type": "commit",
|
||||
"message": "auto-save 2026-05-24 00:23 (~2)",
|
||||
"hash": "91a7831",
|
||||
"message": "auto-save 2026-05-25 10:16 (~2)",
|
||||
"hash": "04d80c1",
|
||||
"files_changed": 2
|
||||
},
|
||||
{
|
||||
"ts": "2026-05-23T16:27:19Z",
|
||||
"type": "session-heartbeat",
|
||||
"message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 13 项未提交变更 · 最近提交:auto-save 2026-05-24 00:23 (~2)",
|
||||
"files_changed": 13
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"type" : "oauth_app"
|
||||
}
|
||||
],
|
||||
"description" : "SKG 信息流广告快速复刻工作台:粘贴 TK 链接或上传视频后点击开始,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频路提取原文案\/字幕、中文翻译、讲话人、语速节奏、背景音乐\/环境声\/音效;视觉路自动抽 12 张动作\/节奏参考帧,转换层按真人重构、卡通重构、元素重构、自主描述四个方向生成全新主体 6 视图,再汇合产品素材池、分镜口播和视频候选生成。",
|
||||
"description" : "SKG 营销内容多人创作平台:默认首页面向公司团队成员的个人隔离创作空间,主路径为文生图、图生图、文生视频、图生视频和营销图文方案生成;每个登录用户只看到自己的任务和结果。任务详情页沉淀参考图、生成图、视频候选、提示词和图文方案,可继续生成、删除和复用。旧 TK 复刻\/一键出片能力保留为高级入口,不再作为默认工作台。",
|
||||
"kind" : "app",
|
||||
"name" : "SKG 营销内容工作台",
|
||||
"ownership" : "company",
|
||||
|
||||
3
RULES.md
3
RULES.md
@@ -11,13 +11,14 @@
|
||||
- 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解
|
||||
- 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`)
|
||||
- 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译)
|
||||
- 当前产品方向(2026-05-20 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”;工作台已取消 1800x1000 固定画布和整页缩放,改为正常流式桌面容器,宽度跟随浏览器展开,只保留 1280px 最低操作宽度防止核心表格被压烂,不再通过应用层 `zoom` 把整页缩小导致文字发虚。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路自动识别中文、英文和其他多语言原音频文案/字幕,统一补齐中文镜像,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区主体链路改为“上方参考帧池 + 转换层、下方主体元素结果栏”:参考帧池竖向排列;转换层是轻量对话式生图确认区,参考图可通过左侧缩略图 `+`、参考帧拖拽、胶片拖拽或本地图片拖入进入转换层,用户选择 GPT/Gemini 套件后先分析参考图;识别结果里的特征 chip 只作为“保留元素”本地选择,点亮=保留、再点取消,点击不立即请求模型,随下一条发送消息提交;用户再在下方发送区发送复刻/创新/卡通和画面要求,界面只保留生成要求输入框、张数控件和提示词就绪状态,不展示当前要求摘要、保留元素副本、收起记录计数或重复模型确认话术,生成数量通过发送区旁边的张数控件控制;后端返回英文出图 prompt 后不再自动弹窗,发送区主按钮直接切换为“确认生成 N 张”,用户点击才生成对应数量的统一多角度套图。主体元素结果栏在转换层下方横向展示套图输出、文件夹分组、单张重生、删除和 hover 预览,空态只保留紧凑提示,不再挤占右侧整列。旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。
|
||||
- 当前产品方向(2026-05-25 即梦 generate 式简化):默认首页彻底从“信息流广告复刻管线”切换为多人通用的 SKG 营销内容创作平台,服务约 6 名公司成员同时使用。主路径仍是图片、视频和营销图文方案生成,支持文字生成、参考图生成和图文提示词回填;用户登录后只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离。首页默认只保留窄导航栏 + 会话侧栏 + 中央 prompt composer:参考图入口是输入框左侧的上传卡,图片/视频/图文模式、自动设置和参考上传放在 composer 底部小按钮里,产品、人群、平台、时长和语气默认折叠到“自动”。结果不再占据首屏大面板,只在右下角浮层提示并进入 `/detail/?job=<id>` 沉淀参考图、生成图、视频候选、提示词和图文方案。旧 TK 复刻工作台和 Agent Cut 一键出片保留为高级入口,不再作为默认工作台或默认理解框架。
|
||||
|
||||
## 部署事实
|
||||
- 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik)
|
||||
- Agent Cut 独立预览服务器:`2.24.28.41`(Ubuntu 24.04 / Docker Compose / 裸端口 `4290`),部署目录 `/opt/skg-marketing-studio`,Compose 入口 `docker-compose.standalone.yml`,访问地址 `http://2.24.28.41:4290/agent/`。该入口用于“一分钟二创出片终端”预览:用户只提交 TikTok 链接和产品图,后端 `AgentRun` 状态机负责下载、抽帧、规划、生成、自动重跑、审片和合成。
|
||||
- Agent Cut 独立预览验证(2026-05-21):已在 `2.24.28.41` 的 `/opt/skg-marketing-studio` 用 `docker-compose.standalone.yml` 启动 `skg-agent-api` / `skg-agent-web`;独立 compose 通过网络别名兼容 Nginx 的 `skg-marketing-api` upstream。该裸 IP HTTP 入口的服务器 `deploy/.env.production` 需要 `WEB_AUTH_COOKIE_SECURE=false`;本次已补齐 `WEB_AUTH_*` 后重启验证通过:未登录 `/agent/` 返回 302 到 `/login/`,登录后 `/agent/` 返回 200,`/api/agent-runs` 返回数组,容器内 `/health` 返回 `ok:true` 且 `auth_configured:true`。
|
||||
- 发布状态:已部署并验证(2026-05-20,主体元素按套图文件夹分组展示,主体生成接口提交后立即返回 queued 占位并后台逐视角生成、逐张回填;工作台外层取消 1800x1000 固定画布和应用层 `zoom` 缩放,改为正常流式桌面容器,最低操作宽度 1280px;源视频工作区主体链路为上方竖向参考帧池 + 宽幅对话式转换层、下方主体元素结果栏;转换层通过参考帧 `+` 加入、参考图分析、生图对话,英文 prompt 就绪后由发送区主按钮切换为确认生成,点击后才触发主体套图生成;转换层不再固定 640px 长高,按内容自然高度显示,仅以 560px 最大高度兜底内部滚动;下方主体元素结果栏的套图输出、轮询、文件夹分组、单张重生、删除和 hover 预览逻辑保持不变;胶片双击/拖拽加入参考帧池 + 胶片缓存复用 + 音频解析失败可重试,参考帧缩略图保持小尺寸 9:16 比例 + hover 左侧紧凑预览,旧主体模板区移出主路径 + 逐句时间轴移到原版视频下方并支持双行文案 + 波形同框时间对齐画面胶片 + 胶片密度按钮上移波形顶部 + 去分隔线 + 胶片上下错落 + body 顶层原位大放大 + 隐藏源视频工作区音频解析摘要卡 + 隐藏工作区顶部状态提示条 + 三字段候选生成工作流 + 折叠紧凑候选区);`https://marketing.skg.com` 已启用应用内登录页,未登录 API 返回 401,认证后首页 200;容器内 `/health` 返回 `ok:true`
|
||||
- 最近部署验证(2026-05-24):`828b86d` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`,生产入口切换为多人通用营销内容创作平台首页,并保留 `/agent/` 作为高级复刻入口、`/detail/?job=<id>` 作为任务详情页。部署前脚本已备份生产私有环境、任务数据、资源库和 secrets 到 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260523175306.tgz`;生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、`api:ytdlp_cookie_args []`、未发现本地 API/dev URL 泄漏)。
|
||||
- 最近部署验证(2026-05-22):`6427935` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260522012756.tgz`,生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`、`api:ytdlp_cookie_args []`)。部署后已把生产私有 `deploy/.env.production` 明确固定为多语言本地 ASR 路径并重启 API:`ASR_LANGUAGE=auto`、`FASTER_WHISPER_MODEL=base`、`ASR_REMOTE_ENABLED=false`、`ASR_LOCAL_FALLBACK_ENABLED=true`、`ASR_AUDIO_FALLBACK_ENABLED=false`;复验 `./scripts/verify-prod-docker.sh root@76.13.31.179` 通过,容器内 `/health` 确认 `asr_language=auto`、`faster_whisper=base`。
|
||||
- 最近部署验证(2026-05-21):`8458dac` 已按“先本地 Docker、再上传部署”流程上线。上线前在本机 Docker 构建 `skg-marketing-studio-web:latest` / `skg-marketing-studio-api:latest`,并用本地 Compose 容器验证通过:`web:/ 302`、`web:/login/ 200`、`web:/_next/does-not-exist.js 404`、`web:/api/health 401`、`api:health ok`、`api:ytdlp_cookie_args []`、静态 bundle 包含 `未来健康 · 营销内容工作台` 和 `信息流广告复刻生产`,且未发现本地 API/dev URL 泄漏。随后通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260521070327.tgz`,生产 Docker 重建后脚本内验证通过(web/API 容器 Up、`/` 302、`/login/` 200、缺失 `_next` 资源 404、未登录 `/api/health` 401、容器内 `api:health ok`)。
|
||||
- 最近部署验证(2026-05-20):`6597db3` 已通过 `./scripts/deploy-prod-safe.sh` 部署到 `/opt/skg-marketing-studio`;部署前备份为 `/opt/skg-marketing-studio-backups/skg-marketing-preserve-20260520151033.tgz`,生产 Docker 重建后脚本内验证通过(`web:/login/ 200`、`web:/api/health 401`、`api:health ok`)。线上登录后检查首页静态资源,当前加载 chunk `/_next/static/chunks/c48f07b9aef1cd29.js` 已包含 `min-w-[1280px]` 和 `max-w-[1920px]`,未再命中旧的 `h-[1000px]`、`w-[1800px]`、`BOARD_SCALE_PRESETS` 或 `boardScale`;对应工作台取消固定画布缩放,按浏览器正常流式布局渲染。
|
||||
|
||||
75
api/main.py
75
api/main.py
@@ -2641,8 +2641,8 @@ SUBJECT_VIEW_LABELS: dict[str, str] = {
|
||||
"three_quarter_right": "右前 45°",
|
||||
"side": "侧面",
|
||||
"side_walk": "侧面走路",
|
||||
"top": "顶部视角",
|
||||
"bottom": "底部视角",
|
||||
"top": "正投影俯视图",
|
||||
"bottom": "正投影仰视图",
|
||||
"expression_neutral": "中性表情",
|
||||
"expression_smile": "微笑表情",
|
||||
"expression_happy": "开心表情",
|
||||
@@ -2664,6 +2664,15 @@ SUBJECT_VIEW_LABELS: dict[str, str] = {
|
||||
"back_detail": "背部特写",
|
||||
}
|
||||
|
||||
OBJECT_PATENT_VIEW_LABELS: dict[str, str] = {
|
||||
"front": "正投影主视图",
|
||||
"back": "正投影后视图",
|
||||
"left": "正投影左视图",
|
||||
"right": "正投影右视图",
|
||||
"top": "正投影俯视图",
|
||||
"bottom": "正投影仰视图",
|
||||
}
|
||||
|
||||
|
||||
def _subject_view_labels(kind: SubjectKind, requested: list[str] | None = None) -> list[tuple[SubjectView, str]]:
|
||||
if requested:
|
||||
@@ -2672,7 +2681,8 @@ def _subject_view_labels(kind: SubjectKind, requested: list[str] | None = None)
|
||||
key = "".join(ch for ch in str(raw).strip().lower() if ch.isalnum() or ch == "_")
|
||||
if key and key not in normalized:
|
||||
normalized.append(key)
|
||||
return [(key, SUBJECT_VIEW_LABELS.get(key, key.replace("_", " "))) for key in normalized[:10]]
|
||||
labels = OBJECT_PATENT_VIEW_LABELS if kind == "object" else SUBJECT_VIEW_LABELS
|
||||
return [(key, labels.get(key, SUBJECT_VIEW_LABELS.get(key, key.replace("_", " ")))) for key in normalized[:10]]
|
||||
if kind == "living":
|
||||
return [
|
||||
("front", "正面站立"),
|
||||
@@ -2687,15 +2697,51 @@ def _subject_view_labels(kind: SubjectKind, requested: list[str] | None = None)
|
||||
("back_neck_detail", "后颈/肩背特写"),
|
||||
]
|
||||
return [
|
||||
("front", "正面"),
|
||||
("back", "背面"),
|
||||
("left", "左侧"),
|
||||
("right", "右侧"),
|
||||
("top", "顶部"),
|
||||
("bottom", "底部"),
|
||||
("front", OBJECT_PATENT_VIEW_LABELS["front"]),
|
||||
("back", OBJECT_PATENT_VIEW_LABELS["back"]),
|
||||
("left", OBJECT_PATENT_VIEW_LABELS["left"]),
|
||||
("right", OBJECT_PATENT_VIEW_LABELS["right"]),
|
||||
("top", OBJECT_PATENT_VIEW_LABELS["top"]),
|
||||
("bottom", OBJECT_PATENT_VIEW_LABELS["bottom"]),
|
||||
]
|
||||
|
||||
|
||||
def _subject_view_projection_clause(view: str) -> str:
|
||||
if view == "front":
|
||||
return (
|
||||
"Patent-style orthographic main/front elevation view: look straight at the designated main face, "
|
||||
"with the viewing direction perpendicular to that face. No perspective, no tilt, no 3/4 angle, no isometric view. "
|
||||
)
|
||||
if view == "back":
|
||||
return (
|
||||
"Patent-style orthographic rear elevation view: look straight at the rear face, "
|
||||
"with the viewing direction perpendicular to that face. No perspective, no tilt, no 3/4 angle, no isometric view. "
|
||||
)
|
||||
if view == "left":
|
||||
return (
|
||||
"Patent-style orthographic left side elevation view: look straight at the product's left side, "
|
||||
"with the viewing direction perpendicular to that side face. No perspective, no tilt, no 3/4 angle, no isometric view. "
|
||||
)
|
||||
if view == "right":
|
||||
return (
|
||||
"Patent-style orthographic right side elevation view: look straight at the product's right side, "
|
||||
"with the viewing direction perpendicular to that side face. No perspective, no tilt, no 3/4 angle, no isometric view. "
|
||||
)
|
||||
if view == "top":
|
||||
return (
|
||||
"Patent-style orthographic top view: look straight down from directly above the product, "
|
||||
"with the viewing direction perpendicular to the top face. No perspective, no tilt, no 3/4 angle, "
|
||||
"no oblique overhead camera, no visible front/side depth unless it is true product thickness in orthographic projection. "
|
||||
)
|
||||
if view == "bottom":
|
||||
return (
|
||||
"Patent-style orthographic bottom view: look straight up at the underside/bottom face, "
|
||||
"with the viewing direction perpendicular to the bottom face. No perspective, no tilt, no 3/4 angle, "
|
||||
"no low-angle perspective camera, no visible front/side depth unless it is true product thickness in orthographic projection. "
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def _attach_temporal_metrics(items: list[dict]) -> None:
|
||||
"""相邻低清帧差异:转场 / 动作目标依赖它,不需要逐帧高分辨率扫描。"""
|
||||
for i, it in enumerate(items):
|
||||
@@ -5581,13 +5627,14 @@ def generate_image(job_id: str, idx: int, req: GenerateReq) -> Job:
|
||||
reference_path = sel_path
|
||||
reference_source = f"gen:{sel.id[:6]}"
|
||||
|
||||
full_prompt = req.prompt.strip()
|
||||
raw_prompt = req.prompt.strip()
|
||||
if req.extra_prompt.strip():
|
||||
full_prompt = f"{full_prompt}. Include: {req.extra_prompt.strip()}"
|
||||
raw_prompt = f"{raw_prompt}. Include: {req.extra_prompt.strip()}"
|
||||
if req.negative_prompt.strip():
|
||||
full_prompt = f"{full_prompt}. Avoid: {req.negative_prompt.strip()}"
|
||||
if not full_prompt:
|
||||
raw_prompt = f"{raw_prompt}. Avoid: {req.negative_prompt.strip()}"
|
||||
if not raw_prompt:
|
||||
raise HTTPException(400, "prompt required")
|
||||
full_prompt = _ensure_english(raw_prompt)
|
||||
if not IMAGE_API_KEY:
|
||||
raise HTTPException(503, "IMAGE_API_KEY 或 LLM_API_KEY 未配置")
|
||||
|
||||
@@ -6910,11 +6957,13 @@ def _generate_subject_assets_sync(job_id: str, idx: int, element_id: str, req: G
|
||||
else:
|
||||
view_prompt = f"complete object/product reference, {view_label} view"
|
||||
view_name = view.replace("_", " ")
|
||||
projection_clause = _subject_view_projection_clause(view)
|
||||
single_view_clause = (
|
||||
f"Single-image output rule: this output file is ONLY for the {view_label} view ({view_name}). "
|
||||
"Render exactly one subject, one time, in one pose and one camera angle. "
|
||||
"Do not create a multi-view sheet, contact sheet, grid, storyboard, lineup, comparison layout, before/after layout, mirrored pair, duplicate subjects, thumbnails, labels, captions, arrows, view names, panel borders, or multiple versions in the same image. "
|
||||
"Do not include any other views in this image. "
|
||||
+ projection_clause
|
||||
)
|
||||
framing_clause = (
|
||||
"For this close-up view, intentionally crop as an upper-body asset from head/neck to chest or upper back; the neck, shoulders, collarbone or upper spine area must be large, clear, and useful for placing a neck-and-shoulder massage device. "
|
||||
|
||||
76
docs/pre-redesign-backup-20260524.md
Normal file
76
docs/pre-redesign-backup-20260524.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# 2026-05-24 Pre-Redesign Backup
|
||||
|
||||
This record was created before the full platform redesign that replaces the old video-recreation-first product flow.
|
||||
|
||||
## Code Restore Point
|
||||
|
||||
- Branch at backup time: `main`
|
||||
- Commit at backup time: `04a822ac7903aa249b491c68c80b3cd98d88ae85`
|
||||
- Remote: `ssh://git@git.kang-kang.com:22222/kangwan/20260512-skg-tk.git`
|
||||
- Pushed tag: `backup/pre-redesign-20260524-012047`
|
||||
|
||||
Restore tracked code to the exact pre-redesign point:
|
||||
|
||||
```bash
|
||||
git fetch origin --tags
|
||||
git reset --hard backup/pre-redesign-20260524-012047
|
||||
```
|
||||
|
||||
Only run the reset when intentionally replacing the current working tree.
|
||||
|
||||
## Local Data Snapshot
|
||||
|
||||
Backup directory:
|
||||
|
||||
```text
|
||||
.backups/pre-redesign-20260524-012047/
|
||||
```
|
||||
|
||||
Snapshot contents:
|
||||
|
||||
- `api-jobs/` from `api/jobs/`
|
||||
- `api-product_library/` from `api/product_library/`
|
||||
- `api-character_library/` from `api/character_library/`
|
||||
- `api-asset_library/` from `api/asset_library/`
|
||||
- `api-prompt_library/` from `api/prompt_library/`
|
||||
- `asset_library/` from `asset_library/`
|
||||
- `prompt_library/` from `prompt_library/`
|
||||
- `jobs/` from `jobs/`
|
||||
- `_trash/` from `_trash/`
|
||||
- `output/` from `output/`
|
||||
- `source-04a822ac7903.tgz`
|
||||
- `dirty-worktree.patch`
|
||||
|
||||
Verification at backup time:
|
||||
|
||||
- Full backup size: `328M`
|
||||
- `api/jobs` source task directories: `12`
|
||||
- `api-jobs` backup task directories: `12`
|
||||
- `api-jobs` backup size: `132M`
|
||||
- `api-product_library` backup size: `9.6M`
|
||||
- `api-character_library` backup size: `91M`
|
||||
|
||||
## Data Restore By Overwrite
|
||||
|
||||
Use `ditto` to copy the backed up data over the current runtime data directories:
|
||||
|
||||
```bash
|
||||
ditto .backups/pre-redesign-20260524-012047/api-jobs api/jobs
|
||||
ditto .backups/pre-redesign-20260524-012047/api-product_library api/product_library
|
||||
ditto .backups/pre-redesign-20260524-012047/api-character_library api/character_library
|
||||
ditto .backups/pre-redesign-20260524-012047/api-asset_library api/asset_library
|
||||
ditto .backups/pre-redesign-20260524-012047/api-prompt_library api/prompt_library
|
||||
ditto .backups/pre-redesign-20260524-012047/asset_library asset_library
|
||||
ditto .backups/pre-redesign-20260524-012047/prompt_library prompt_library
|
||||
ditto .backups/pre-redesign-20260524-012047/jobs jobs
|
||||
ditto .backups/pre-redesign-20260524-012047/_trash _trash
|
||||
ditto .backups/pre-redesign-20260524-012047/output output
|
||||
```
|
||||
|
||||
`ditto` overwrites matching files but does not delete extra files that were created after the backup. If an exact replacement is required later, remove or move the target directory first, then run the matching `ditto` command.
|
||||
|
||||
## Notes
|
||||
|
||||
- `.backups/` is ignored by Git and remains local.
|
||||
- The pushed Git tag protects the source-code restore point even if the local `.backups/` folder is moved.
|
||||
- The only dirty working-tree change at backup time was `.memory/worklog.json`; its diff was saved as `dirty-worktree.patch`.
|
||||
File diff suppressed because one or more lines are too long
543
web/app/detail/page.tsx
Normal file
543
web/app/detail/page.tsx
Normal file
@@ -0,0 +1,543 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
ArrowLeft,
|
||||
Clapperboard,
|
||||
Copy,
|
||||
FileText,
|
||||
Image as ImageIcon,
|
||||
Loader2,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Wand2,
|
||||
} from "lucide-react"
|
||||
import { Toaster, toast } from "sonner"
|
||||
import { MediaAssetTile } from "@/components/media-asset-tile"
|
||||
import {
|
||||
apiAssetUrl,
|
||||
deleteGeneratedImage,
|
||||
deleteGeneratedVideo,
|
||||
generateCreativeCopy,
|
||||
generateImage,
|
||||
generateStoryboardVideo,
|
||||
getJob,
|
||||
listJobs,
|
||||
type CreativeCopyVariant,
|
||||
type GeneratedImage,
|
||||
type GeneratedVideo,
|
||||
type Job,
|
||||
type JobSummary,
|
||||
} from "@/lib/api"
|
||||
|
||||
type ImageItem = GeneratedImage & { frameIdx: number }
|
||||
type BusyTask = "image" | "video" | "copy" | "load" | null
|
||||
|
||||
function cx(...items: Array<string | false | null | undefined>) {
|
||||
return items.filter(Boolean).join(" ")
|
||||
}
|
||||
|
||||
function jobTitle(item: Job | JobSummary | null) {
|
||||
if (!item) return "未选择任务"
|
||||
const raw = item.url.replace(/^creative:\/\//, "").replace(/^upload:\/\//, "")
|
||||
return raw || item.id
|
||||
}
|
||||
|
||||
function sourceFrameSrc(job: Job | null) {
|
||||
return job?.frames?.[0]?.url ? apiAssetUrl(job.frames[0].url) : ""
|
||||
}
|
||||
|
||||
function videoSrc(job: Job, video: GeneratedVideo) {
|
||||
return apiAssetUrl(video.url || `/jobs/${job.id}/storyboard-videos/${video.id}.mp4`)
|
||||
}
|
||||
|
||||
function imageItems(job: Job | null): ImageItem[] {
|
||||
if (!job) return []
|
||||
return job.frames
|
||||
.flatMap((frame) => (frame.generated_images ?? []).map((image) => ({ ...image, frameIdx: frame.index })))
|
||||
.sort((a, b) => b.created_at - a.created_at)
|
||||
}
|
||||
|
||||
function createdLabel(ts?: number) {
|
||||
if (!ts) return ""
|
||||
return new Date(ts * 1000).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
export default function DetailPage() {
|
||||
const [jobId, setJobId] = useState("")
|
||||
const [job, setJob] = useState<Job | null>(null)
|
||||
const [recentJobs, setRecentJobs] = useState<JobSummary[]>([])
|
||||
const [prompt, setPrompt] = useState("")
|
||||
const [product, setProduct] = useState("SKG 颈部按摩仪")
|
||||
const [audience, setAudience] = useState("久坐办公、低头刷手机的人群")
|
||||
const [tone, setTone] = useState("真实、直接、有购买理由")
|
||||
const [seconds, setSeconds] = useState(12)
|
||||
const [copyVariants, setCopyVariants] = useState<CreativeCopyVariant[]>([])
|
||||
const [busy, setBusy] = useState<BusyTask>(null)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const images = useMemo(() => imageItems(job), [job])
|
||||
const videos = useMemo(() => job?.generated_videos ?? [], [job])
|
||||
const runningVideo = videos.some((item) => item.status === "queued" || item.status === "in_progress")
|
||||
|
||||
const loadJob = useCallback(async (id: string) => {
|
||||
if (!id) return
|
||||
setBusy("load")
|
||||
setError("")
|
||||
try {
|
||||
const loaded = await getJob(id)
|
||||
setJob(loaded)
|
||||
setJobId(id)
|
||||
window.history.replaceState(null, "", `/detail/?job=${id}`)
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "读取任务失败"
|
||||
setError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshJobs = useCallback(async () => {
|
||||
try {
|
||||
setRecentJobs(await listJobs(20))
|
||||
} catch {
|
||||
setRecentJobs([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const id = new URLSearchParams(window.location.search).get("job") || ""
|
||||
setJobId(id)
|
||||
refreshJobs()
|
||||
if (id) loadJob(id)
|
||||
}, [loadJob, refreshJobs])
|
||||
|
||||
useEffect(() => {
|
||||
if (!job || !runningVideo) return
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
setJob(await getJob(job.id))
|
||||
} catch {
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, 2600)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [job, runningVideo])
|
||||
|
||||
const requireJobAndPrompt = () => {
|
||||
if (!job) {
|
||||
toast.error("先选择任务")
|
||||
return false
|
||||
}
|
||||
if (!prompt.trim()) {
|
||||
toast.error("先写生成要求")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const runImage = async () => {
|
||||
if (!requireJobAndPrompt() || !job) return
|
||||
setBusy("image")
|
||||
setError("")
|
||||
try {
|
||||
setJob(await generateImage(job.id, 0, {
|
||||
prompt: `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Tone: ${tone}.`,
|
||||
mode: sourceFrameSrc(job) ? "edit" : "text",
|
||||
}))
|
||||
toast.success("图片已生成")
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "生图失败"
|
||||
setError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const runVideo = async () => {
|
||||
if (!requireJobAndPrompt() || !job) return
|
||||
setBusy("video")
|
||||
setError("")
|
||||
try {
|
||||
setJob(await generateStoryboardVideo(job.id, 0, {
|
||||
prompt: `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Tone: ${tone}. Keep the SKG product shape stable and visible.`,
|
||||
duration: seconds,
|
||||
count: 1,
|
||||
first_image: { kind: "keyframe", frame_idx: 0 },
|
||||
size: "720x1280",
|
||||
}))
|
||||
toast.success("视频已提交")
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "生视频失败"
|
||||
setError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const runCopy = async () => {
|
||||
const goal = prompt.trim() || `${product} ${audience}`
|
||||
setBusy("copy")
|
||||
setError("")
|
||||
try {
|
||||
const result = await generateCreativeCopy({ goal, product, audience, tone, seconds })
|
||||
setCopyVariants(result.variants)
|
||||
toast.success("图文方案已生成")
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "写文案失败"
|
||||
setError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteImage = async (image: ImageItem) => {
|
||||
if (!job) return
|
||||
try {
|
||||
setJob(await deleteGeneratedImage(job.id, image.frameIdx, image.id))
|
||||
toast.success("图片已删除")
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "删除失败")
|
||||
}
|
||||
}
|
||||
|
||||
const deleteVideo = async (video: GeneratedVideo) => {
|
||||
if (!job) return
|
||||
try {
|
||||
setJob(await deleteGeneratedVideo(job.id, video.id))
|
||||
toast.success("视频已删除")
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "删除失败")
|
||||
}
|
||||
}
|
||||
|
||||
const copyText = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast.success("已复制")
|
||||
} catch {
|
||||
toast.error("复制失败")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#eef2ec] text-[#17201d]">
|
||||
<Toaster richColors position="top-center" />
|
||||
<div className="mx-auto grid min-h-screen w-full max-w-[1760px] grid-rows-[auto_minmax(0,1fr)] px-4 py-4 sm:px-6">
|
||||
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-[#d8dfd4] pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href="/"
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-md border border-[#cbd6d0] bg-white text-[#35443f] transition hover:border-[#0f766e]/60"
|
||||
aria-label="返回工作台"
|
||||
title="返回工作台"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</a>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-normal">任务详情</h1>
|
||||
<p className="mt-1 max-w-[620px] truncate text-sm text-[#66746e]">{jobTitle(job)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => jobId && loadJob(jobId)}
|
||||
disabled={!jobId || !!busy}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-[#cbd6d0] bg-white px-3 text-sm font-semibold text-[#35443f] transition hover:border-[#0f766e]/60 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy === "load" ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||
刷新
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section className="grid min-h-0 gap-4 py-4 xl:grid-cols-[300px_minmax(0,1fr)_420px]">
|
||||
<aside className="grid min-h-0 grid-rows-[auto_minmax(0,1fr)] gap-3">
|
||||
<section className="rounded-lg border border-[#d8dfd4] bg-white p-3">
|
||||
<h2 className="text-sm font-semibold">任务信息</h2>
|
||||
<div className="mt-3 grid gap-2 text-sm">
|
||||
<div className="flex justify-between gap-3 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 py-2">
|
||||
<span className="text-[#66746e]">ID</span>
|
||||
<span className="font-mono text-xs">{job?.id || jobId || "-"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 py-2">
|
||||
<span className="text-[#66746e]">图片</span>
|
||||
<span>{images.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-3 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 py-2">
|
||||
<span className="text-[#66746e]">视频</span>
|
||||
<span>{videos.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
{sourceFrameSrc(job) ? (
|
||||
<div className="mt-3">
|
||||
<MediaAssetTile
|
||||
src={sourceFrameSrc(job)}
|
||||
alt="reference"
|
||||
objectFit="contain"
|
||||
previewObjectFit="contain"
|
||||
className="aspect-[4/5] w-full rounded-md"
|
||||
label="参考图"
|
||||
meta={job?.frames?.[0]?.index ?? 0}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="min-h-0 rounded-lg border border-[#d8dfd4] bg-white p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold">我的任务</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={refreshJobs}
|
||||
className="rounded p-1 text-[#66746e] hover:bg-[#f0f3ee] hover:text-[#17201d]"
|
||||
aria-label="刷新任务列表"
|
||||
title="刷新任务列表"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid max-h-[540px] gap-2 overflow-y-auto pr-1">
|
||||
{recentJobs.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => loadJob(item.id)}
|
||||
className={cx(
|
||||
"grid grid-cols-[48px_minmax(0,1fr)] gap-2 rounded-md border bg-[#f7f9f5] p-1.5 text-left transition hover:border-[#0f766e]/60",
|
||||
job?.id === item.id ? "border-[#0f766e] ring-2 ring-[#0f766e]/10" : "border-[#d8dfd4]",
|
||||
)}
|
||||
>
|
||||
<MediaAssetTile
|
||||
src={apiAssetUrl(item.thumbnail)}
|
||||
alt=""
|
||||
objectFit="cover"
|
||||
className="aspect-square rounded"
|
||||
disablePreview={!item.thumbnail}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-xs font-semibold">{jobTitle(item)}</span>
|
||||
<span className="block text-[11px] text-[#66746e]">{item.frame_count} 图源 · {item.video_count} 视频</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
{!recentJobs.length ? (
|
||||
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-3 py-5 text-center text-xs text-[#66746e]">暂无任务</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section className="min-h-0 overflow-y-auto rounded-lg border border-[#d8dfd4] bg-white p-4">
|
||||
{job ? (
|
||||
<div className="grid gap-6">
|
||||
<section className="grid gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ImageIcon className="h-4 w-4 text-[#0f766e]" />
|
||||
<h2 className="text-base font-semibold">生成图片</h2>
|
||||
</div>
|
||||
{images.length ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{images.map((image) => (
|
||||
<div key={image.id} className="grid gap-1.5">
|
||||
<MediaAssetTile
|
||||
src={apiAssetUrl(image.url)}
|
||||
alt="generated image"
|
||||
objectFit="contain"
|
||||
previewObjectFit="contain"
|
||||
className="aspect-[4/5] w-full rounded-md"
|
||||
label={image.model}
|
||||
meta={image.mode}
|
||||
onDelete={() => deleteImage(image)}
|
||||
/>
|
||||
<div className="truncate text-[11px] text-[#66746e]">{createdLabel(image.created_at)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-4 py-8 text-center text-sm text-[#66746e]">暂无图片结果</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clapperboard className="h-4 w-4 text-[#ea5b2d]" />
|
||||
<h2 className="text-base font-semibold">生成视频</h2>
|
||||
</div>
|
||||
{videos.length ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{videos.map((video) => (
|
||||
<div key={video.id} className="grid gap-1.5 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-2">
|
||||
<MediaAssetTile
|
||||
kind="video"
|
||||
src={video.status === "completed" ? videoSrc(job, video) : undefined}
|
||||
poster={apiAssetUrl(video.poster_url)}
|
||||
objectFit="cover"
|
||||
previewObjectFit="contain"
|
||||
className="aspect-video w-full rounded-md"
|
||||
label={video.model}
|
||||
meta={`${video.status} · ${Math.round(video.progress)}%`}
|
||||
busy={video.status === "queued" || video.status === "in_progress"}
|
||||
onDelete={() => deleteVideo(video)}
|
||||
/>
|
||||
<div className="h-1 overflow-hidden rounded-full bg-[#e4ebe6]">
|
||||
<div className="h-full rounded-full bg-[#ea5b2d]" style={{ width: `${Math.max(4, video.progress)}%` }} />
|
||||
</div>
|
||||
{video.error ? <div className="text-xs text-rose-700">{video.error}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-4 py-8 text-center text-sm text-[#66746e]">暂无视频结果</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-[#2563eb]" />
|
||||
<h2 className="text-base font-semibold">营销图文</h2>
|
||||
</div>
|
||||
{copyVariants.length ? (
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
{copyVariants.map((variant, index) => (
|
||||
<article key={`${variant.title}-${index}`} className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-3">
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold">{variant.title || `方案 ${index + 1}`}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText([variant.hook_zh, variant.script_zh, variant.caption_zh].filter(Boolean).join("\n\n"))}
|
||||
className="rounded p-1 text-[#66746e] hover:bg-white hover:text-[#17201d]"
|
||||
aria-label="复制文案"
|
||||
title="复制文案"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-[#243530]">{variant.hook_zh}</p>
|
||||
<pre className="mt-2 max-h-44 overflow-y-auto whitespace-pre-wrap rounded bg-white p-2 text-xs leading-5 text-[#42524c]">{variant.script_zh}</pre>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-4 py-8 text-center text-sm text-[#66746e]">暂无图文方案</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[520px] items-center justify-center rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] text-sm text-[#66746e]">
|
||||
请选择任务
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<aside className="grid min-h-0 grid-rows-[auto_auto_minmax(0,1fr)] gap-3">
|
||||
<section className="rounded-lg border border-[#d8dfd4] bg-white p-3">
|
||||
<h2 className="text-sm font-semibold">继续生成</h2>
|
||||
<div className="mt-3 grid gap-3">
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-[#52635d]">产品</span>
|
||||
<input
|
||||
value={product}
|
||||
onChange={(event) => setProduct(event.target.value)}
|
||||
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-[#52635d]">人群</span>
|
||||
<input
|
||||
value={audience}
|
||||
onChange={(event) => setAudience(event.target.value)}
|
||||
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-[#52635d]">要求</span>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder="继续生成一组更高端的营销图,或者提交一条快速视频要求。"
|
||||
className="min-h-40 resize-none rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-3 text-sm leading-6 outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-[#52635d]">语气</span>
|
||||
<input
|
||||
value={tone}
|
||||
onChange={(event) => setTone(event.target.value)}
|
||||
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-[#52635d]">视频时长</span>
|
||||
<select
|
||||
value={seconds}
|
||||
onChange={(event) => setSeconds(Number(event.target.value))}
|
||||
className="h-10 rounded-md border border-[#d8dfd4] bg-[#f7f9f5] px-3 text-sm outline-none focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/15"
|
||||
>
|
||||
{[8, 12, 15, 20, 30, 45].map((value) => <option key={value} value={value}>{value} 秒</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{error ? <div className="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-2 rounded-lg border border-[#d8dfd4] bg-white p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={runImage}
|
||||
disabled={!job || !!busy}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-[#0f766e] text-sm font-semibold text-white transition hover:bg-[#115e59] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy === "image" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Wand2 className="h-4 w-4" />}
|
||||
生成图片
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runVideo}
|
||||
disabled={!job || !!busy}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-[#ea5b2d] text-sm font-semibold text-white transition hover:bg-[#d94f25] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy === "video" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
|
||||
生成视频
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runCopy}
|
||||
disabled={!!busy}
|
||||
className="inline-flex h-10 items-center justify-center gap-2 rounded-md border border-[#cbd6d0] bg-[#f7f9f5] text-sm font-semibold text-[#35443f] transition hover:border-[#9ba9a2] disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy === "copy" ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
|
||||
生成图文方案
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="min-h-0 rounded-lg border border-[#d8dfd4] bg-white p-3">
|
||||
<h2 className="mb-2 text-sm font-semibold">提示词</h2>
|
||||
<div className="grid max-h-[360px] gap-2 overflow-y-auto pr-1">
|
||||
{[...images.slice(0, 4).map((item) => item.prompt), ...videos.slice(0, 4).map((item) => item.prompt)]
|
||||
.filter(Boolean)
|
||||
.map((item, index) => (
|
||||
<button
|
||||
key={`${index}-${item.slice(0, 20)}`}
|
||||
type="button"
|
||||
onClick={() => setPrompt(item)}
|
||||
className="rounded-md border border-[#d8dfd4] bg-[#f7f9f5] p-2 text-left text-xs leading-5 text-[#42524c] transition hover:border-[#0f766e]/60"
|
||||
>
|
||||
{item.slice(0, 180)}
|
||||
</button>
|
||||
))}
|
||||
{!images.length && !videos.length ? (
|
||||
<div className="rounded-md border border-dashed border-[#cbd6d0] bg-[#f7f9f5] px-3 py-5 text-center text-xs text-[#66746e]">暂无提示词</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
795
web/app/page.tsx
795
web/app/page.tsx
@@ -2,18 +2,22 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
Clapperboard,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Folder,
|
||||
Image as ImageIcon,
|
||||
Layers3,
|
||||
Loader2,
|
||||
PenLine,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Menu,
|
||||
Plus,
|
||||
Sparkles,
|
||||
Upload,
|
||||
Wand2,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react"
|
||||
import { Toaster, toast } from "sonner"
|
||||
import { MediaAssetTile } from "@/components/media-asset-tile"
|
||||
@@ -34,71 +38,150 @@ import {
|
||||
type JobSummary,
|
||||
} from "@/lib/api"
|
||||
|
||||
type CreatorMode = "video" | "image" | "copy"
|
||||
type BusyTask = "image" | "video" | "copy" | null
|
||||
type CreationMode = "video" | "image" | "copy"
|
||||
type BusyTask = CreationMode | "job" | null
|
||||
|
||||
const MODE_ITEMS: Array<{
|
||||
id: CreatorMode
|
||||
type ModeConfig = {
|
||||
id: CreationMode
|
||||
label: string
|
||||
icon: typeof Clapperboard
|
||||
accent: string
|
||||
}> = [
|
||||
{ id: "video", label: "生视频", icon: Clapperboard, accent: "from-orange-500 to-rose-500" },
|
||||
{ id: "image", label: "生图", icon: ImageIcon, accent: "from-teal-500 to-cyan-500" },
|
||||
{ id: "copy", label: "写文案", icon: PenLine, accent: "from-blue-500 to-indigo-500" },
|
||||
icon: LucideIcon
|
||||
placeholder: string
|
||||
}
|
||||
|
||||
type InspirationCard = {
|
||||
title: string
|
||||
mode: CreationMode
|
||||
prompt: string
|
||||
}
|
||||
|
||||
const OUTPUT_MODES: ModeConfig[] = [
|
||||
{
|
||||
id: "video",
|
||||
label: "视频",
|
||||
icon: Clapperboard,
|
||||
placeholder: "Seedance 2.0 全能参考,视频创意无限可能",
|
||||
},
|
||||
{
|
||||
id: "image",
|
||||
label: "图片",
|
||||
icon: ImageIcon,
|
||||
placeholder: "生成一张 9:16 信息流营销图,SKG 颈部按摩仪佩戴清楚,真实办公室午休场景。",
|
||||
},
|
||||
{
|
||||
id: "copy",
|
||||
label: "图文",
|
||||
icon: FileText,
|
||||
placeholder: "写一组 SKG 颈部按摩仪营销图文方案,包含 hook、脚本、caption 和生成提示词。",
|
||||
},
|
||||
]
|
||||
|
||||
const PROMPT_TEMPLATES = [
|
||||
"一张 SKG 颈部按摩仪的信息流广告首帧,真实生活方式,产品清楚可见,画面干净高级",
|
||||
"把参考图里的主体变成适合 TikTok 的 9:16 产品短视频,开头 2 秒要抓人,镜头有轻微推进",
|
||||
"自动写一条 20 秒 SKG 产品短视频脚本,语气直接,突出日常放松和佩戴场景",
|
||||
const PROMPT_PRESETS: InspirationCard[] = [
|
||||
{
|
||||
title: "办公室午休",
|
||||
mode: "video",
|
||||
prompt: "做一条 15 秒 TikTok 竖屏视频,办公室午休场景,人物放下电脑后戴上 SKG 颈部按摩仪,镜头缓慢推进,突出日常放松。",
|
||||
},
|
||||
{
|
||||
title: "下班回家放松",
|
||||
mode: "video",
|
||||
prompt: "做一条 12 秒竖屏短片,年轻上班族下班回家后放松肩颈,先表现疲惫,再自然戴上 SKG 产品,动作可信。",
|
||||
},
|
||||
{
|
||||
title: "白底产品功能图",
|
||||
mode: "image",
|
||||
prompt: "生成一张白底产品功能图,高级电商质感,突出 SKG 颈部按摩仪外形、佩戴方式和日常使用,产品结构不能变形。",
|
||||
},
|
||||
{
|
||||
title: "前三秒 Hook",
|
||||
mode: "copy",
|
||||
prompt: "写 3 套 SKG 颈部按摩仪信息流营销图文方案,每套包含前三秒 hook、中文脚本、caption、图片提示词和视频提示词。",
|
||||
},
|
||||
]
|
||||
|
||||
function cx(...items: Array<string | false | null | undefined>) {
|
||||
return items.filter(Boolean).join(" ")
|
||||
}
|
||||
|
||||
function allGeneratedImages(job: Job | null): GeneratedImage[] {
|
||||
if (!job) return []
|
||||
return job.frames.flatMap((frame) => frame.generated_images ?? []).sort((a, b) => b.created_at - a.created_at)
|
||||
}
|
||||
|
||||
function latestGeneratedImage(job: Job | null): GeneratedImage | null {
|
||||
return job?.frames?.[0]?.generated_images?.at(-1) ?? null
|
||||
return allGeneratedImages(job)[0] ?? null
|
||||
}
|
||||
|
||||
function latestGeneratedVideo(job: Job | null): GeneratedVideo | null {
|
||||
return [...(job?.generated_videos ?? [])].sort((a, b) => b.created_at - a.created_at)[0] ?? null
|
||||
}
|
||||
|
||||
function videoSrc(job: Job, video: GeneratedVideo) {
|
||||
return apiAssetUrl(video.url || `/jobs/${job.id}/storyboard-videos/${video.id}.mp4`)
|
||||
}
|
||||
|
||||
function jobTitle(item: Job | JobSummary | null) {
|
||||
if (!item) return "未选择任务"
|
||||
const raw = item.url.replace(/^creative:\/\//, "").replace(/^upload:\/\//, "")
|
||||
return raw || item.id
|
||||
}
|
||||
|
||||
function sourceFrameSrc(job: Job | null) {
|
||||
return job?.frames?.[0]?.url ? apiAssetUrl(job.frames[0].url) : ""
|
||||
}
|
||||
|
||||
function statusLabel(status?: string) {
|
||||
if (!status) return "就绪"
|
||||
const map: Record<string, string> = {
|
||||
created: "已创建",
|
||||
downloading: "下载中",
|
||||
downloaded: "已下载",
|
||||
splitting: "拆轨中",
|
||||
frames_extracted: "可创作",
|
||||
transcribing: "识别中",
|
||||
transcribed: "已解析",
|
||||
failed: "失败",
|
||||
}
|
||||
return map[status] ?? status
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [mode, setMode] = useState<CreatorMode>("video")
|
||||
const [mode, setMode] = useState<CreationMode>("video")
|
||||
const [prompt, setPrompt] = useState("")
|
||||
const [product, setProduct] = useState("SKG 颈部按摩仪")
|
||||
const [audience, setAudience] = useState("久坐办公、低头刷手机的人群")
|
||||
const [tone, setTone] = useState("直接、可信、有购买理由")
|
||||
const [seconds, setSeconds] = useState(20)
|
||||
const [platform, setPlatform] = useState("TikTok / Reels")
|
||||
const [tone, setTone] = useState("真实自然、有购买理由")
|
||||
const [seconds, setSeconds] = useState(15)
|
||||
const [referenceFile, setReferenceFile] = useState<File | null>(null)
|
||||
const [referencePreview, setReferencePreview] = useState("")
|
||||
const [job, setJob] = useState<Job | null>(null)
|
||||
const [busy, setBusy] = useState<BusyTask>(null)
|
||||
const [copyVariants, setCopyVariants] = useState<CreativeCopyVariant[]>([])
|
||||
const [recentJobs, setRecentJobs] = useState<JobSummary[]>([])
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [error, setError] = useState("")
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const activeMode = OUTPUT_MODES.find((item) => item.id === mode) ?? OUTPUT_MODES[0]
|
||||
const images = useMemo(() => allGeneratedImages(job), [job])
|
||||
const latestImage = latestGeneratedImage(job)
|
||||
const generatedVideos = useMemo(() => job?.generated_videos ?? [], [job])
|
||||
const hasRunningVideo = generatedVideos.some((item) => item.status === "queued" || item.status === "in_progress")
|
||||
const latestVideo = latestGeneratedVideo(job)
|
||||
const runningVideo = (job?.generated_videos ?? []).some((item) => item.status === "queued" || item.status === "in_progress")
|
||||
const currentReference = referencePreview || sourceFrameSrc(job)
|
||||
const canUseReference = !!referenceFile || !!sourceFrameSrc(job)
|
||||
const firstCopy = copyVariants[0]
|
||||
|
||||
const refreshJobs = useCallback(async () => {
|
||||
try {
|
||||
setRecentJobs(await listJobs(14))
|
||||
} catch {
|
||||
setRecentJobs([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
listJobs(8)
|
||||
.then((items) => {
|
||||
if (!cancelled) setRecentJobs(items)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRecentJobs([])
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [job?.id])
|
||||
refreshJobs()
|
||||
}, [refreshJobs, job?.id, images.length, job?.generated_videos?.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (!referenceFile) {
|
||||
@@ -111,7 +194,7 @@ export default function Home() {
|
||||
}, [referenceFile])
|
||||
|
||||
useEffect(() => {
|
||||
if (!job || !hasRunningVideo) return
|
||||
if (!job || !runningVideo) return
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
setJob(await getJob(job.id))
|
||||
@@ -120,33 +203,62 @@ export default function Home() {
|
||||
}
|
||||
}, 2600)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [job, hasRunningVideo])
|
||||
}, [job, runningVideo])
|
||||
|
||||
const ensureJob = useCallback(async () => {
|
||||
if (job) return job
|
||||
setBusy("job")
|
||||
const created = await createCreativeImageJob(referenceFile)
|
||||
setJob(created)
|
||||
await refreshJobs()
|
||||
return created
|
||||
}, [job, referenceFile])
|
||||
}, [job, referenceFile, refreshJobs])
|
||||
|
||||
const onFileChange = (file: File | null) => {
|
||||
setReferenceFile(file)
|
||||
setJob(null)
|
||||
setCopyVariants([])
|
||||
setError("")
|
||||
}
|
||||
|
||||
const runImage = async () => {
|
||||
if (!prompt.trim()) {
|
||||
toast.error("先写创作要求")
|
||||
return
|
||||
const loadJob = async (id: string) => {
|
||||
setBusy("job")
|
||||
setError("")
|
||||
try {
|
||||
const loaded = await getJob(id)
|
||||
setJob(loaded)
|
||||
setReferenceFile(null)
|
||||
setCopyVariants([])
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "读取任务失败"
|
||||
setError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const promptWithContext = () => (
|
||||
`${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Platform: ${platform}. Tone: ${tone}. Keep the SKG product shape stable and visible.`
|
||||
)
|
||||
|
||||
const validatePrompt = () => {
|
||||
if (!prompt.trim()) {
|
||||
toast.error("先写一句生成要求")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const runImage = async () => {
|
||||
if (!validatePrompt()) return
|
||||
setBusy("image")
|
||||
setError("")
|
||||
try {
|
||||
const target = await ensureJob()
|
||||
const updated = await generateImage(target.id, 0, {
|
||||
prompt,
|
||||
mode: referenceFile ? "edit" : "text",
|
||||
prompt: promptWithContext(),
|
||||
mode: canUseReference ? "edit" : "text",
|
||||
})
|
||||
setJob(updated)
|
||||
toast.success("图片已生成")
|
||||
@@ -160,16 +272,13 @@ export default function Home() {
|
||||
}
|
||||
|
||||
const runVideo = async () => {
|
||||
if (!prompt.trim()) {
|
||||
toast.error("先写创作要求")
|
||||
return
|
||||
}
|
||||
if (!validatePrompt()) return
|
||||
setBusy("video")
|
||||
setError("")
|
||||
try {
|
||||
const target = await ensureJob()
|
||||
const updated = await generateStoryboardVideo(target.id, 0, {
|
||||
prompt,
|
||||
prompt: promptWithContext(),
|
||||
duration: seconds,
|
||||
count: 1,
|
||||
first_image: { kind: "keyframe", frame_idx: 0 },
|
||||
@@ -187,24 +296,22 @@ export default function Home() {
|
||||
}
|
||||
|
||||
const runCopy = async () => {
|
||||
if (!prompt.trim()) {
|
||||
toast.error("先写文案目标")
|
||||
return
|
||||
}
|
||||
const goal = prompt.trim() || `${product} ${audience} ${platform}`
|
||||
setBusy("copy")
|
||||
setError("")
|
||||
try {
|
||||
const result = await generateCreativeCopy({
|
||||
goal: prompt,
|
||||
goal,
|
||||
product,
|
||||
audience,
|
||||
platform,
|
||||
tone,
|
||||
seconds,
|
||||
})
|
||||
setCopyVariants(result.variants)
|
||||
toast.success("文案已生成")
|
||||
toast.success("图文方案已生成")
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "写文案失败"
|
||||
const message = e instanceof Error ? e.message : "写图文失败"
|
||||
setError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
@@ -247,344 +354,338 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
const useVariant = (variant: CreativeCopyVariant, nextMode: CreatorMode) => {
|
||||
setMode(nextMode)
|
||||
setPrompt(nextMode === "image" ? variant.image_prompt_en : variant.video_prompt_en)
|
||||
const useInspiration = (item: InspirationCard) => {
|
||||
setMode(item.mode)
|
||||
setPrompt(item.prompt)
|
||||
setError("")
|
||||
}
|
||||
|
||||
const activeMode = MODE_ITEMS.find((item) => item.id === mode) ?? MODE_ITEMS[0]
|
||||
const ActiveIcon = activeMode.icon
|
||||
const useVariant = (variant: CreativeCopyVariant, nextMode: CreationMode) => {
|
||||
setMode(nextMode)
|
||||
setPrompt(nextMode === "image" ? variant.image_prompt_en : nextMode === "video" ? variant.video_prompt_en : variant.script_zh)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-[#f7f8f4] text-[#10211f]">
|
||||
<main className="min-h-screen bg-[#090a0f] text-white">
|
||||
<Toaster richColors position="top-center" />
|
||||
<div className="mx-auto flex min-h-screen w-full max-w-[1720px] flex-col px-4 py-4 sm:px-6 lg:px-8">
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-[#d9ded5] pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-[#10211f] text-white">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-normal">SKG 营销内容工作台</h1>
|
||||
<p className="text-sm text-[#5f6f69]">图片、视频、文案</p>
|
||||
<div className="grid min-h-screen" style={{ gridTemplateColumns: "42px 132px minmax(0, 1fr)" }}>
|
||||
<aside className="flex min-h-screen flex-col items-center border-r border-white/6 bg-[#090a0f] py-6">
|
||||
<div className="mb-[230px] flex h-7 w-7 items-center justify-center rounded-lg bg-cyan-400/12 text-cyan-200">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<nav className="grid gap-6 text-[10px] text-white/64">
|
||||
<button type="button" className="group grid justify-items-center gap-1.5 transition hover:text-white">
|
||||
<Sparkles className="h-4 w-4 text-white/84 group-hover:text-cyan-200" />
|
||||
灵感
|
||||
</button>
|
||||
<button type="button" className="group grid justify-items-center gap-1.5 text-white transition">
|
||||
<Wand2 className="h-4 w-4 text-cyan-200" />
|
||||
生成
|
||||
</button>
|
||||
<button type="button" onClick={refreshJobs} className="group grid justify-items-center gap-1.5 transition hover:text-white">
|
||||
<Folder className="h-4 w-4 text-white/84 group-hover:text-cyan-200" />
|
||||
资产
|
||||
</button>
|
||||
</nav>
|
||||
<div className="mt-auto grid justify-items-center gap-5 text-white/42">
|
||||
<div className="h-5 w-5 rounded-full bg-gradient-to-br from-slate-500 to-slate-800 ring-1 ring-white/12" />
|
||||
<a href="/agent/" className="text-[10px] transition hover:text-white" title="高级复刻">
|
||||
<Layers3 className="mx-auto mb-1 h-3.5 w-3.5" />
|
||||
Agent
|
||||
</a>
|
||||
<button type="button" className="rounded-lg p-2 transition hover:bg-white/8 hover:text-white" aria-label="菜单">
|
||||
<Menu className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<aside className="hidden min-h-screen border-r border-white/6 bg-[#15171e] px-2 py-5 md:block">
|
||||
<div className="mb-4 flex items-center justify-between px-1 text-xs font-semibold text-white/86">
|
||||
<span>开启创作</span>
|
||||
<button type="button" className="rounded-md p-1 text-white/36 transition hover:bg-white/8 hover:text-white" aria-label="收起侧栏">
|
||||
<Menu className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setJob(null)
|
||||
setPrompt("")
|
||||
setCopyVariants([])
|
||||
setReferenceFile(null)
|
||||
setError("")
|
||||
}}
|
||||
className="flex h-8 items-center gap-2 rounded-md bg-white/8 px-2 text-left text-xs font-semibold text-white/86 transition hover:bg-white/12"
|
||||
>
|
||||
<Wand2 className="h-3.5 w-3.5 text-white/58" />
|
||||
新对话
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPrompt(activeMode.placeholder)}
|
||||
className="flex h-8 items-center gap-2 rounded-md px-2 text-left text-xs font-semibold text-white/58 transition hover:bg-white/8 hover:text-white"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5 text-white/40" />
|
||||
默认创作
|
||||
</button>
|
||||
{job ? (
|
||||
<a
|
||||
href={`/detail/?job=${job.id}`}
|
||||
className="mt-2 flex min-h-9 items-center gap-2 rounded-md border border-cyan-200/16 bg-cyan-300/8 px-2 text-left text-xs font-semibold text-cyan-100 transition hover:border-cyan-200/30"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
任务详情
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-8 border-t border-white/6 pt-3">
|
||||
<div className="mb-2 px-1 text-[11px] font-semibold text-white/34">最近任务</div>
|
||||
<div className="grid gap-1.5">
|
||||
{recentJobs.slice(0, 5).map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => loadJob(item.id)}
|
||||
className={cx(
|
||||
"min-w-0 rounded-md px-2 py-2 text-left transition hover:bg-white/8",
|
||||
job?.id === item.id ? "bg-white/8 text-white" : "text-white/42",
|
||||
)}
|
||||
>
|
||||
<span className="block truncate text-[11px] font-semibold">{jobTitle(item)}</span>
|
||||
<span className="mt-0.5 block truncate text-[10px] text-white/30">{statusLabel(item.status)} · {item.video_count} 视频</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden items-center gap-2 text-sm text-[#5f6f69] md:flex">
|
||||
<span className="rounded-md border border-[#d9ded5] bg-white px-3 py-1.5">gpt-image-2</span>
|
||||
<span className="rounded-md border border-[#d9ded5] bg-white px-3 py-1.5">Seedance / Kling / Veo</span>
|
||||
</div>
|
||||
</header>
|
||||
</aside>
|
||||
|
||||
<section className="grid min-h-0 flex-1 gap-4 py-4 lg:grid-cols-[280px_minmax(0,1fr)_390px]">
|
||||
<aside className="flex min-h-0 flex-col gap-3 rounded-lg border border-[#d9ded5] bg-white p-3">
|
||||
<div className="grid gap-2">
|
||||
{MODE_ITEMS.map((item) => {
|
||||
const Icon = item.icon
|
||||
const selected = item.id === mode
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setMode(item.id)}
|
||||
className={cx(
|
||||
"flex h-12 items-center justify-between rounded-md border px-3 text-left text-sm font-medium transition focus:outline-none focus:ring-2 focus:ring-[#0d9488]/35",
|
||||
selected ? "border-[#10211f] bg-[#10211f] text-white" : "border-[#d9ded5] bg-[#f7f8f4] text-[#263b36] hover:border-[#9db4ad]",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</span>
|
||||
{selected ? <ArrowRight className="h-4 w-4" /> : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<section className="relative min-h-screen overflow-hidden bg-[#0b0c10]">
|
||||
<div className="absolute inset-x-0 top-0 h-28 bg-[radial-gradient(circle_at_50%_0%,rgba(17,211,239,0.08),transparent_58%)]" />
|
||||
<div className="relative flex min-h-screen items-center justify-center px-5 py-16">
|
||||
<section className="mb-20 grid w-full max-w-[520px] -translate-y-12 justify-items-center gap-5">
|
||||
<h1 className="text-center text-lg font-semibold tracking-normal text-white/92">你好,想创作什么?</h1>
|
||||
|
||||
<section className="relative w-full rounded-2xl border border-white/7 bg-[#1d1f27] p-3 shadow-[0_28px_80px_rgba(0,0,0,0.28)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="absolute left-5 top-4 flex h-16 w-12 -rotate-6 items-center justify-center overflow-hidden rounded bg-[#2a2d37] text-white/34 shadow-lg transition hover:text-white"
|
||||
aria-label="上传素材"
|
||||
title="上传素材"
|
||||
>
|
||||
{currentReference ? (
|
||||
<MediaAssetTile
|
||||
src={currentReference}
|
||||
alt="reference"
|
||||
objectFit="cover"
|
||||
previewObjectFit="contain"
|
||||
className="h-full w-full rounded"
|
||||
disablePreview={!currentReference}
|
||||
/>
|
||||
) : (
|
||||
<Plus className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(event) => onFileChange(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder={activeMode.placeholder}
|
||||
className="min-h-9 w-full resize-none bg-transparent pl-16 pr-8 pt-0.5 text-sm leading-6 text-white outline-none placeholder:text-white/24"
|
||||
/>
|
||||
|
||||
<div className="mt-2 rounded-md border border-[#d9ded5] bg-[#f7f8f4] p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-sm font-medium">参考图</span>
|
||||
{referenceFile ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFileChange(null)}
|
||||
className="text-xs text-[#c2410c] hover:text-[#9a3412]"
|
||||
className="absolute right-4 top-4 rounded-lg p-1 text-white/34 transition hover:bg-white/8 hover:text-white"
|
||||
aria-label="移除素材"
|
||||
title="移除素材"
|
||||
>
|
||||
移除
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
onChange={(event) => onFileChange(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
{referencePreview ? (
|
||||
<MediaAssetTile
|
||||
src={referencePreview}
|
||||
alt="reference"
|
||||
objectFit="cover"
|
||||
previewObjectFit="contain"
|
||||
className="aspect-[4/5] w-full rounded-md"
|
||||
label={referenceFile?.name}
|
||||
onDelete={() => onFileChange(null)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex aspect-[4/5] w-full flex-col items-center justify-center rounded-md border border-dashed border-[#b7c6c0] bg-white text-[#5f6f69] transition hover:border-[#0d9488] hover:text-[#0f766e] focus:outline-none focus:ring-2 focus:ring-[#0d9488]/35"
|
||||
>
|
||||
<Upload className="mb-2 h-5 w-5" />
|
||||
<span className="text-sm">上传图片</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-[#d9ded5] bg-[#f7f8f4] p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
最近任务
|
||||
</div>
|
||||
<div className="grid max-h-[260px] gap-2 overflow-y-auto pr-1">
|
||||
{recentJobs.length ? recentJobs.map((item) => (
|
||||
<div className="mt-1 flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{OUTPUT_MODES.map((item) => {
|
||||
const Icon = item.icon
|
||||
const selected = item.id === mode
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setMode(item.id)}
|
||||
className={cx(
|
||||
"inline-flex h-8 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-semibold transition",
|
||||
selected ? "border-cyan-300/24 bg-cyan-300/10 text-cyan-200" : "border-white/7 bg-black/14 text-white/48 hover:border-white/14 hover:text-white",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{item.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSettings((value) => !value)}
|
||||
className="inline-flex h-8 items-center rounded-lg border border-white/7 bg-black/14 px-2.5 text-xs font-semibold text-white/48 transition hover:border-white/14 hover:text-white"
|
||||
>
|
||||
自动
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-white/7 bg-black/14 px-2.5 text-xs font-semibold text-white/48 transition hover:border-white/14 hover:text-white"
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{referenceFile ? "已上传" : "参考"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setJob(await getJob(item.id))
|
||||
setError("")
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "读取任务失败")
|
||||
}
|
||||
}}
|
||||
className="grid grid-cols-[46px_minmax(0,1fr)] gap-2 rounded-md border border-[#d9ded5] bg-white p-1.5 text-left transition hover:border-[#0d9488]/70"
|
||||
onClick={runPrimary}
|
||||
disabled={!!busy}
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#424757] text-white/76 transition hover:bg-cyan-300 hover:text-black disabled:cursor-not-allowed disabled:opacity-60"
|
||||
aria-label="开始生成"
|
||||
title="开始生成"
|
||||
>
|
||||
<MediaAssetTile
|
||||
src={apiAssetUrl(item.thumbnail)}
|
||||
alt=""
|
||||
objectFit="cover"
|
||||
className="aspect-square rounded"
|
||||
disablePreview={!item.thumbnail}
|
||||
/>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-xs font-medium">{item.url.replace(/^creative:\/\//, "") || item.id}</span>
|
||||
<span className="block text-[11px] text-[#6b7b75]">{item.frame_count} 图 · {item.video_count} 视频</span>
|
||||
</span>
|
||||
{busy === mode || busy === "job" ? <Loader2 className="h-4 w-4 animate-spin" /> : <ArrowUp className="h-4 w-4" />}
|
||||
</button>
|
||||
)) : (
|
||||
<div className="rounded-md border border-dashed border-[#cbd6d1] bg-white px-3 py-4 text-center text-xs text-[#6b7b75]">暂无任务</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="flex min-h-[680px] flex-col rounded-lg border border-[#d9ded5] bg-white">
|
||||
<div className={cx("h-1.5 rounded-t-lg bg-gradient-to-r", activeMode.accent)} />
|
||||
<div className="flex flex-1 flex-col gap-4 p-4 sm:p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-md bg-[#eff8f6] text-[#0f766e]">
|
||||
<ActiveIcon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">{activeMode.label}</h2>
|
||||
<p className="text-sm text-[#6b7b75]">{mode === "copy" ? "输入目标,生成可直接进图/视频模型的脚本和提示词" : "写一句要求,必要时加一张参考图"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={runPrimary}
|
||||
disabled={!!busy}
|
||||
className="inline-flex h-11 min-w-[132px] items-center justify-center gap-2 rounded-md bg-[#f97316] px-5 text-sm font-semibold text-white transition hover:bg-[#ea580c] focus:outline-none focus:ring-2 focus:ring-[#f97316]/35 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{busy === mode ? <Loader2 className="h-4 w-4 animate-spin" /> : mode === "copy" ? <Wand2 className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
{mode === "copy" ? "生成文案" : mode === "image" ? "生成图片" : "生成视频"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-[#50645e]">产品</span>
|
||||
<input
|
||||
value={product}
|
||||
onChange={(event) => setProduct(event.target.value)}
|
||||
className="h-10 rounded-md border border-[#d9ded5] bg-[#f7f8f4] px-3 text-sm outline-none focus:border-[#0d9488] focus:ring-2 focus:ring-[#0d9488]/20"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-[#50645e]">人群</span>
|
||||
<input
|
||||
value={audience}
|
||||
onChange={(event) => setAudience(event.target.value)}
|
||||
className="h-10 rounded-md border border-[#d9ded5] bg-[#f7f8f4] px-3 text-sm outline-none focus:border-[#0d9488] focus:ring-2 focus:ring-[#0d9488]/20"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-[#50645e]">时长</span>
|
||||
<select
|
||||
value={seconds}
|
||||
onChange={(event) => setSeconds(Number(event.target.value))}
|
||||
className="h-10 rounded-md border border-[#d9ded5] bg-[#f7f8f4] px-3 text-sm outline-none focus:border-[#0d9488] focus:ring-2 focus:ring-[#0d9488]/20"
|
||||
>
|
||||
{[8, 12, 15, 20, 30, 45].map((value) => <option key={value} value={value}>{value} 秒</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{showSettings ? (
|
||||
<section className="grid w-full gap-3 rounded-2xl border border-white/7 bg-[#171922] p-3 md:grid-cols-2">
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-white/42">产品</span>
|
||||
<input
|
||||
value={product}
|
||||
onChange={(event) => setProduct(event.target.value)}
|
||||
className="h-10 rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none focus:border-cyan-200/40"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-white/42">人群</span>
|
||||
<input
|
||||
value={audience}
|
||||
onChange={(event) => setAudience(event.target.value)}
|
||||
className="h-10 rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none focus:border-cyan-200/40"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-white/42">平台</span>
|
||||
<select
|
||||
value={platform}
|
||||
onChange={(event) => setPlatform(event.target.value)}
|
||||
className="h-10 rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none focus:border-cyan-200/40"
|
||||
>
|
||||
{["TikTok / Reels", "Amazon", "独立站", "小红书", "抖音"].map((value) => <option key={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-white/42">时长</span>
|
||||
<select
|
||||
value={seconds}
|
||||
onChange={(event) => setSeconds(Number(event.target.value))}
|
||||
className="h-10 rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none focus:border-cyan-200/40"
|
||||
>
|
||||
{[8, 12, 15, 20, 30, 45].map((value) => <option key={value} value={value}>{value} 秒</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1.5 md:col-span-2">
|
||||
<span className="text-xs font-medium text-white/42">语气</span>
|
||||
<input
|
||||
value={tone}
|
||||
onChange={(event) => setTone(event.target.value)}
|
||||
className="h-10 rounded-xl border border-white/10 bg-black/20 px-3 text-sm text-white outline-none focus:border-cyan-200/40"
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<label className="grid flex-1 gap-2">
|
||||
<span className="text-xs font-medium text-[#50645e]">{mode === "copy" ? "文案目标" : "创作要求"}</span>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
placeholder={mode === "copy" ? PROMPT_TEMPLATES[2] : mode === "image" ? PROMPT_TEMPLATES[0] : PROMPT_TEMPLATES[1]}
|
||||
className="min-h-[220px] flex-1 resize-none rounded-lg border border-[#d9ded5] bg-[#f7f8f4] p-4 text-base leading-7 outline-none transition placeholder:text-[#8a9994] focus:border-[#0d9488] focus:ring-2 focus:ring-[#0d9488]/20"
|
||||
/>
|
||||
</label>
|
||||
{error ? (
|
||||
<div className="w-full rounded-2xl border border-rose-300/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">{error}</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PROMPT_TEMPLATES.map((item) => (
|
||||
<div className="mt-1 flex w-full flex-wrap justify-center gap-2">
|
||||
{PROMPT_PRESETS.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
key={item.title}
|
||||
type="button"
|
||||
onClick={() => setPrompt(item)}
|
||||
className="rounded-md border border-[#d9ded5] bg-[#f7f8f4] px-3 py-2 text-xs text-[#40534d] transition hover:border-[#0d9488]/70 hover:bg-[#eef8f5]"
|
||||
onClick={() => useInspiration(item)}
|
||||
className="rounded-xl border border-white/7 bg-white/[0.04] px-3 py-2 text-xs font-semibold text-white/38 transition hover:border-cyan-200/22 hover:text-white"
|
||||
>
|
||||
{item}
|
||||
{item.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{mode === "copy" ? (
|
||||
<label className="grid gap-1.5">
|
||||
<span className="text-xs font-medium text-[#50645e]">语气</span>
|
||||
<input
|
||||
value={tone}
|
||||
onChange={(event) => setTone(event.target.value)}
|
||||
className="h-10 rounded-md border border-[#d9ded5] bg-[#f7f8f4] px-3 text-sm outline-none focus:border-[#0d9488] focus:ring-2 focus:ring-[#0d9488]/20"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700">{error}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className="flex min-h-0 flex-col gap-3 rounded-lg border border-[#d9ded5] bg-white p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold">结果</h2>
|
||||
{job ? <span className="rounded bg-[#f1f4ef] px-2 py-1 font-mono text-[11px] text-[#6b7b75]">{job.id}</span> : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 overflow-y-auto pr-1">
|
||||
{latestImage ? (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-[#50645e]">
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
最新图片
|
||||
{(latestImage || latestVideo || firstCopy) && (
|
||||
<section className="fixed bottom-6 right-6 z-20 w-[320px] rounded-2xl border border-white/8 bg-[#171922]/95 p-3 shadow-2xl backdrop-blur">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-semibold text-white/88">当前结果</h2>
|
||||
{job ? <a href={`/detail/?job=${job.id}`} className="text-xs font-semibold text-cyan-200/82 hover:text-cyan-100">详情页</a> : null}
|
||||
</div>
|
||||
<MediaAssetTile
|
||||
src={apiAssetUrl(latestImage.url)}
|
||||
alt="generated image"
|
||||
objectFit="contain"
|
||||
previewObjectFit="contain"
|
||||
className="aspect-[4/5] w-full rounded-md"
|
||||
label={latestImage.model}
|
||||
meta={latestImage.mode}
|
||||
onDelete={() => deleteImage(latestImage)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPrompt(latestImage.prompt)}
|
||||
className="inline-flex h-9 items-center justify-center gap-2 rounded-md border border-[#d9ded5] bg-[#f7f8f4] text-sm text-[#40534d] transition hover:border-[#0d9488]/70"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
复用提示词
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed border-[#cbd6d1] bg-[#f7f8f4] px-3 py-8 text-center text-sm text-[#6b7b75]">图片结果</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-[#50645e]">
|
||||
<Clapperboard className="h-3.5 w-3.5" />
|
||||
视频
|
||||
</div>
|
||||
{generatedVideos.length ? generatedVideos.slice(0, 4).map((video) => (
|
||||
<div key={video.id} className="grid gap-1.5">
|
||||
{latestVideo && job ? (
|
||||
<MediaAssetTile
|
||||
kind="video"
|
||||
src={video.status === "completed" ? videoSrc(job!, video) : undefined}
|
||||
poster={apiAssetUrl(video.poster_url)}
|
||||
src={latestVideo.status === "completed" ? videoSrc(job, latestVideo) : undefined}
|
||||
poster={apiAssetUrl(latestVideo.poster_url)}
|
||||
objectFit="cover"
|
||||
previewObjectFit="contain"
|
||||
className="aspect-video w-full rounded-md"
|
||||
label={video.model}
|
||||
meta={`${video.status} · ${Math.round(video.progress)}%`}
|
||||
busy={video.status === "queued" || video.status === "in_progress"}
|
||||
onDelete={() => deleteVideo(video)}
|
||||
className="aspect-video w-full rounded-xl"
|
||||
label={latestVideo.model}
|
||||
meta={`${latestVideo.status} · ${Math.round(latestVideo.progress)}%`}
|
||||
busy={latestVideo.status === "queued" || latestVideo.status === "in_progress"}
|
||||
onDelete={() => deleteVideo(latestVideo)}
|
||||
/>
|
||||
<div className="h-1 overflow-hidden rounded-full bg-[#edf1ec]">
|
||||
<div className="h-full rounded-full bg-[#0d9488]" style={{ width: `${Math.max(4, video.progress)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)) : (
|
||||
<div className="rounded-md border border-dashed border-[#cbd6d1] bg-[#f7f8f4] px-3 py-8 text-center text-sm text-[#6b7b75]">视频结果</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{copyVariants.length ? (
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-[#50645e]">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
文案
|
||||
</div>
|
||||
{copyVariants.map((variant, index) => (
|
||||
<article key={`${variant.title}-${index}`} className="rounded-md border border-[#d9ded5] bg-[#f7f8f4] p-3">
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold">{variant.title || `方案 ${index + 1}`}</h3>
|
||||
) : latestImage ? (
|
||||
<MediaAssetTile
|
||||
src={apiAssetUrl(latestImage.url)}
|
||||
alt="generated image"
|
||||
objectFit="cover"
|
||||
previewObjectFit="contain"
|
||||
className="aspect-video w-full rounded-xl"
|
||||
label={latestImage.model}
|
||||
meta={latestImage.mode}
|
||||
onDelete={() => deleteImage(latestImage)}
|
||||
/>
|
||||
) : firstCopy ? (
|
||||
<article className="rounded-xl border border-white/8 bg-white/[0.04] p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="line-clamp-1 text-sm font-semibold text-white">{firstCopy.title || "营销方案"}</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText([variant.hook_zh, variant.script_zh, variant.caption_zh].filter(Boolean).join("\n\n"))}
|
||||
className="rounded p-1 text-[#5f6f69] hover:bg-white hover:text-[#10211f]"
|
||||
onClick={() => copyText([firstCopy.hook_zh, firstCopy.script_zh, firstCopy.caption_zh].filter(Boolean).join("\n\n"))}
|
||||
className="rounded-lg p-1 text-white/42 transition hover:bg-white/8 hover:text-white"
|
||||
aria-label="复制文案"
|
||||
title="复制文案"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm leading-6 text-[#263b36]">{variant.hook_zh}</p>
|
||||
<pre className="mt-2 whitespace-pre-wrap rounded bg-white p-2 text-xs leading-5 text-[#40534d]">{variant.script_zh}</pre>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => useVariant(variant, "image")}
|
||||
className="inline-flex h-9 items-center justify-center gap-2 rounded-md bg-[#0d9488] text-xs font-medium text-white hover:bg-[#0f766e]"
|
||||
>
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
去生图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => useVariant(variant, "video")}
|
||||
className="inline-flex h-9 items-center justify-center gap-2 rounded-md bg-[#f97316] text-xs font-medium text-white hover:bg-[#ea580c]"
|
||||
>
|
||||
<Clapperboard className="h-3.5 w-3.5" />
|
||||
去生视频
|
||||
</button>
|
||||
<p className="mt-2 line-clamp-3 text-xs leading-5 text-white/58">{firstCopy.hook_zh}</p>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2">
|
||||
<button type="button" onClick={() => useVariant(firstCopy, "image")} className="rounded-lg bg-emerald-300/12 px-2 py-2 text-xs font-semibold text-emerald-100">去生图</button>
|
||||
<button type="button" onClick={() => useVariant(firstCopy, "video")} className="rounded-lg bg-cyan-300/12 px-2 py-2 text-xs font-semibold text-cyan-100">去生视频</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -2019,6 +2019,11 @@ function selectProductItemsForRow(
|
||||
}
|
||||
|
||||
function subjectViewLabel(view: string) {
|
||||
const labels: Record<string, string> = {
|
||||
top: "正投影俯视图",
|
||||
bottom: "正投影仰视图",
|
||||
}
|
||||
if (labels[view]) return labels[view]
|
||||
return SUBJECT_ASSET_VIEWS.find((item) => item.value === view)?.label ?? view
|
||||
}
|
||||
|
||||
@@ -2034,6 +2039,8 @@ function subjectViewRoleHint(view: string) {
|
||||
bust_left_45: "肩颈左前近景、手部调整、佩戴贴合",
|
||||
bust_right_45: "肩颈右前近景、手部调整、佩戴贴合",
|
||||
back_neck_detail: "后颈肩背特写、触点位置、产品贴合",
|
||||
top: "正投影俯视、无透视、无倾斜、产品顶部轮廓",
|
||||
bottom: "正投影仰视、无透视、无倾斜、产品底部结构",
|
||||
}
|
||||
return hints[view] ?? "主体参考视角"
|
||||
}
|
||||
@@ -2050,6 +2057,8 @@ function subjectViewPromptHint(view: string) {
|
||||
bust_left_45: "left three-quarter neck-and-shoulder close-up, hand adjustment, wearable fit",
|
||||
bust_right_45: "right three-quarter neck-and-shoulder close-up, hand adjustment, wearable fit",
|
||||
back_neck_detail: "back-neck and upper-back detail, contact-pad position, product fit",
|
||||
top: "orthographic top view, straight down from above, no perspective, no tilt, no three-quarter angle",
|
||||
bottom: "orthographic bottom view, straight up at underside, no perspective, no tilt, no three-quarter angle",
|
||||
}
|
||||
return hints[view] ?? "subject reference view"
|
||||
}
|
||||
|
||||
@@ -32,12 +32,12 @@ interface Props {
|
||||
}
|
||||
|
||||
const OBJECT_VIEW_OPTIONS = [
|
||||
["front", "正面"],
|
||||
["back", "背面"],
|
||||
["left", "左侧"],
|
||||
["right", "右侧"],
|
||||
["top", "顶部"],
|
||||
["bottom", "底部"],
|
||||
["front", "正投影主视图"],
|
||||
["back", "正投影后视图"],
|
||||
["left", "正投影左视图"],
|
||||
["right", "正投影右视图"],
|
||||
["top", "正投影俯视图"],
|
||||
["bottom", "正投影仰视图"],
|
||||
]
|
||||
|
||||
const LIVING_VIEW_OPTIONS = [
|
||||
|
||||
@@ -129,7 +129,7 @@ export function StoryboardEditor({ job, frameIndex, onClose }: Props) {
|
||||
📐 多视角
|
||||
</div>
|
||||
<div className="text-[9.5px] text-white/45 leading-tight">
|
||||
侧面 / 仰视 / 俯视等同元素的不同角度
|
||||
侧面 / 正投影俯视 / 正投影仰视等同元素不同视图
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user