From 6ba84a7603bcfa563c0aecb7648587880be3bb79 Mon Sep 17 00:00:00 2001 From: kang Date: Mon, 25 May 2026 10:42:03 +0800 Subject: [PATCH] feat: reduce home to single generation composer --- RULES.md | 2 +- docs/source-analysis.html | 46 ++- web/app/page.tsx | 790 ++++++++++++++------------------------ 3 files changed, 324 insertions(+), 514 deletions(-) diff --git a/RULES.md b/RULES.md index 206c0e3..83524ff 100644 --- a/RULES.md +++ b/RULES.md @@ -11,7 +11,7 @@ - 详见 `CLAUDE.md` 立项决策段 + `.memory/plan.md` 七步管线拆解 - 风格:`04-Dark-Gallery-Ambient`(路径:`~/Projects/research/20260305-网页风格库/04-Dark-Gallery-Ambient.md`) - 第一冲刺:步骤 1-4(下载 / 拆轨 / 关键帧 / ASR+翻译) -- 当前产品方向(2026-05-25 即梦 generate 式简化):默认首页彻底从“信息流广告复刻管线”切换为多人通用的 SKG 营销内容创作平台,服务约 6 名公司成员同时使用。主路径仍是图片、视频和营销图文方案生成,支持文字生成、参考图生成和图文提示词回填;用户登录后只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离。首页默认只保留窄导航栏 + 会话侧栏 + 中央 prompt composer:参考图入口是输入框左侧的上传卡,图片/视频/图文模式、自动设置和参考上传放在 composer 底部小按钮里,产品、人群、平台、时长和语气默认折叠到“自动”。结果不再占据首屏大面板,只在右下角浮层提示并进入 `/detail/?job=` 沉淀参考图、生成图、视频候选、提示词和图文方案。旧 TK 复刻工作台和 Agent Cut 一键出片保留为高级入口,不再作为默认工作台或默认理解框架。 +- 当前产品方向(2026-05-25 单对话框版):默认首页彻底从“信息流广告复刻管线”切换为多人通用的 SKG 营销内容生成入口,服务约 6 名公司成员同时使用。首页默认只保留一个中央对话框,不再显示侧栏、灵感区、任务列表或大结果面板;用户先选择四种生成方式之一:文生视频、文生图、首帧生视频、首尾帧生视频,然后手写提示词并点击生成。首帧 / 首尾帧模式只露必要图片上传位,视频模式只保留时长选择。用户登录后仍只看到自己的任务、结果和详情页,继续沿用后端 owner 隔离;结果生成后从对话框下方进入 `/detail/?job=` 沉淀参考图、生成图、视频候选和提示词。旧 TK 复刻工作台、Agent Cut 一键出片和营销图文方案保留为高级/详情页能力,不再作为默认首页入口或默认理解框架。 ## 部署事实 - 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik) diff --git a/docs/source-analysis.html b/docs/source-analysis.html index 5c86d92..8ca135a 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -572,16 +572,17 @@

2026-05-24 完整重设计:默认首页已从“TK 信息流复刻 / 三字段分镜管线”推倒,改为面向公司约 6 名成员同时使用的 SKG 营销内容多人创作平台。主路径是文生图、图生图、文生视频、图生视频和营销图文方案生成;每个登录用户只看到自己的任务和详情页结果。旧 TK 复刻工作台与 Agent Cut 一键出片保留为高级入口,不再作为默认工作台。

2026-05-25 即梦 generate 式简化:默认首页进一步压缩为窄导航栏、会话侧栏和中央 prompt composer,不再把四入口、参考图、我的任务和结果区平铺成三栏。图片 / 视频 / 图文模式、自动设置和参考上传都收进 composer 底部的小按钮;参考图是输入框左侧倾斜上传卡;结果只用右下角浮层提示,完整沉淀交给详情页。

+

2026-05-25 单对话框版:默认首页再收敛为一个中央对话框,首页只让用户选文生视频、文生图、首帧生视频、首尾帧生视频,然后手写提示词生成。首帧 / 首尾帧模式只出现必要上传位;营销图文不再作为首页默认入口。

-

当前默认业务管线是“个人隔离任务 → 在中央输入框选择产物模式 → 上传参考图或使用空白任务 → 生成图片 / 视频 / 图文方案 → 通过浮层进入详情页继续沉淀”。首页左侧只有窄导航栏和会话侧栏;主画布只保留一句问候语和中央 prompt composer,按图片、视频、图文三种产物模式发起生成。产品、人群、平台、时长和语气属于低频配置,默认折叠在“自动”里;结果不再占据首屏右栏,只在有产出时显示右下角“当前结果”浮层,并提供 /detail/?job=<id> 详情页入口。详情页读取同一个 Job,展示参考图、所有生成图、视频候选、提示词和本页生成的营销图文方案,并支持继续生成、删除和复制。底层仍复用既有 /creative/jobs/image/creative/copy/jobs/{id}/frames/{idx}/generate/jobs/{id}/frames/{idx}/storyboard/video;多人互不影响依赖后端 owner_id 和飞书 / 备用登录会话隔离。旧信息流复刻链路仍保留在 web/components/ad-recreation-board.tsx/agent/,但只作为高级能力。

+

当前默认业务管线是“个人隔离任务 → 在中央对话框选择生成方式 → 必要时上传首帧 / 尾帧 → 手写提示词 → 生成图片或视频 → 进入详情页继续沉淀”。首页不再渲染侧栏、灵感区、最近任务列表、自动设置或营销图文入口;默认只做四件事:文生视频、文生图、首帧生视频、首尾帧生视频。底层仍复用既有 /creative/jobs/image/jobs/{id}/frames/upload/jobs/{id}/frames/{idx}/generate/jobs/{id}/frames/{idx}/storyboard/video;首尾帧视频会把尾帧作为第二张参考帧上传,并通过 last_image 提交给视频接口。多人互不影响依赖后端 owner_id 和飞书 / 备用登录会话隔离。旧信息流复刻链路仍保留在 web/components/ad-recreation-board.tsx/agent/,营销图文能力仍在详情页和接口中保留,但不作为默认首页路径。

01

个人任务

GET /jobs 按当前登录用户过滤;旧无 owner 任务只对备用账号可见。

-
02

产物模式

首页 composer 底部选择视频、图片或图文;文生 / 图生由是否上传参考图自动决定。

-
03

参考图 / 空白任务

POST /creative/jobs/image 创建 0 号关键帧;有图则保存参考图,无图则创建空白图源。

-
04

生成图片

generateImage 复用 /frames/0/generate;图生图传 mode=edit,文生图传 mode=text

-
05

生成视频

generateStoryboardVideo 复用 0 号关键帧作为首帧/参考图,视频任务排队后写入 generated_videos

-
06

营销图文

POST /creative/copy 返回 hook、脚本、caption、image prompt 和 video prompt,可回填到图/视频入口。

-
07

结果沉淀

首页只在右下角显示当前任务最新图片、视频或图文浮层;所有图片/视频缩略图继续复用 MediaAssetTile

+
02

选择方式

首页对话框只提供文生视频、文生图、首帧生视频、首尾帧生视频四个按钮。

+
03

上传帧 / 空白任务

POST /creative/jobs/image 创建 0 号关键帧;首尾帧模式再用 /frames/upload 上传尾帧。

+
04

手写提示词

首页不再生成营销文案或自动展开产品 / 人群配置,用户直接写图片或视频提示词。

+
05

生成图片

generateImage 复用 /frames/0/generate,文生图传 mode=text

+
06

生成视频

generateStoryboardVideo 提交文本、可选 first_image 和可选 last_image,视频任务排队后写入 generated_videos

+
07

结果沉淀

首页只在对话框下方显示最新图片或视频;所有图片/视频缩略图继续复用 MediaAssetTile

08

详情页

/detail/?job=<id> 展示参考图、全量生成图、视频候选、提示词和营销图文,并支持继续生成。

09

高级复刻

AdRecreationBoard/agent/ 作为高级入口保留,不再是默认路径。

@@ -596,7 +597,7 @@ web/next.config.mjsNext.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator,并移除 Next 16 已不支持的 eslint 顶层配置,避免本地 dev 出现配置 Issue 提示。 web/app/globals.css全局主题变量、登录页视觉样式、信息流工作台玻璃拟态 token、ReactFlow 样式引用,以及本地开发态 nextjs-portal 遮挡隐藏规则。工作台在 skg-board-theme 内按 Figma 本地 MCP 参考改成黑灰玻璃系统:深灰背景、#383838 胶囊侧栏、rgba(255,255,255,.1) 玻璃面、backdrop-filter: blur(5px)20px 圆角、10px 10px 10px rgba(0,0,0,.3) 阴影和绿黄状态色;新增 skg-board-shellskg-board-railskg-glass-cardskg-glass-card--flatskg-status-orb 等样式。侧栏改为跟随视口拉满工作台可用高度的悬停胶囊,桌面最小 600px,展开时在同一侧栏内承载素材输入抽屉。明暗主题已分开维护 shell、panel、glass、stat、action 和音频波形 token;暗色压低灰雾和面板底色,明亮模式改为暖白工作台,避免指标卡、按钮和波形继续残留黑底/白线;顶部指标卡增加紫、黄绿、琥珀、青绿、绿色光斑变量,接近原版多色玻璃卡效果。主/次按钮、指标卡和空状态继续走统一类,避免各板块散写不同玻璃效果。 - web/app/page.tsx当前默认首页:即梦 generate 式极简创作台。页面只保留 42px 窄导航栏、132px 会话侧栏和中央 prompt composer;用户默认看到“你好,想创作什么?”、输入框左侧参考图上传卡、底部视频 / 图片 / 图文模式按钮、“自动”折叠设置、参考上传和发送按钮。产品、人群、平台、时长、语气默认折叠;最近任务放在会话侧栏;当前图片、视频或图文结果不再平铺首屏,只用右下角浮层展示并可打开详情页。图片/视频缩略图统一复用 MediaAssetTile,支持顶层 hover 预览和删除;文案结果可一键把 image/video prompt 回填到图或视频入口。旧 TK 复刻工作台组件仍保留在 web/components/ad-recreation-board.tsx,但不再作为默认首页渲染。 + web/app/page.tsx当前默认首页:单对话框生成台。页面只保留顶部极轻量品牌和中央对话框,四个主按钮是文生视频、文生图、首帧生视频、首尾帧生视频;首帧 / 首尾帧模式才显示上传位,视频模式只保留时长选择,用户必须手写提示词后点击生成。每次生成都会创建新的轻量 Job,文生图调用 generateImage,视频调用 generateStoryboardVideo;首尾帧模式先用 createCreativeImageJob 保存首帧,再用 uploadReferenceFrame 保存尾帧并以 last_image 提交。图片/视频缩略图统一复用 MediaAssetTile,支持顶层 hover 预览和删除;旧 TK 复刻工作台组件仍保留在 web/components/ad-recreation-board.tsx,但不再作为默认首页渲染。 web/app/detail/page.tsx任务详情页:静态导出路由 /detail/?job=<id>,通过 query 读取 job id,调用 getJob 恢复同一任务。页面展示参考图、全部生成图、视频候选、营销图文方案和历史提示词,可继续调用 generateImagegenerateStoryboardVideogenerateCreativeCopy,并支持删除图片/视频。该页继续依赖后端 owner 过滤,用户不能通过切换 URL 读取别人的任务。 web/app/agent/page.tsx新增一键出片终端页:只保留 TikTok 链接、产品图上传、实时 Agent Terminal 和最终成片播放器;通过 POST /agent-runs 创建受限后台状态机任务,通过 GET /agent-runs/{id} 轮询日志、进度、审片图和最终 mp4。该页不替代旧工作台深度编辑能力,只承接“用户只看成品”的快速出片主路径。 web/components/ad-recreation-board.tsx信息流广告复刻工作表:外壳按 Figma “Dashboard Glassmorphism”参考整体改为黑灰玻璃工作台,WorkbenchRail 默认收起为拉满工作台可用高度的 65px 胶囊工具条,只保留真实动作入口:素材任务、资源库和主题切换;鼠标移入或键盘聚焦侧栏时,skg-board-rail 切换 is-open 并从左侧展开 320px 素材输入抽屉,点击素材任务按钮可固定展开。顶部从登录页式 brand strip 改为轻量生产控制条,左侧显示 未来健康 · 营销内容工作台、主标题 营销内容工作台 和副标题 信息流广告复刻生产,右侧保留素材/当前/视频/文案段/背景音指标,并用紫、黄绿、琥珀、青绿、绿色光斑卡片增强原版玻璃拟态的颜色层次。主内容只保留源视频拆解工作区,素材输入的数据流、接口、模型调用和状态推导不变。工作台外层已取消 1800x1000 固定基准画布、ResizeObserver 档位计算和 CSS zoom 整页缩放,改为正常流式桌面容器:min-height: 100vhwidth: 100%max-width: 1920px,并保留 min-width: 1280px 作为最低操作宽度;核心列宽不再被整体缩放,文字、图标和边线由浏览器原生字号渲染,避免小数缩放导致发虚。buildWorkflowSteps 仍统一生成 01-09 流程顺序、状态和判定依据,WorkflowStepBadge / PipelineLane / 分镜列标题也继续共用同一套编号;但完整 WorkflowOrderBar、右侧素材/视频/音频/文案/参考帧需求 chips、文案依据下拉和“音频文案、抽帧参考、主体重构、产品素材池”四个状态条不再默认渲染在工作区顶部。侧边素材输入面板只负责链接/上传和任务切换,不再重复放横版原视频预览;主画布源视频工作区直接进入核心操作。讲话人、节奏和背景音分析仍写入 AudioScript,但不再作为“音频解析结果”卡片默认渲染;源视频工作区撤销右上“布局调节”临时面板,不再读取或写入 localStorage["skg-source-workspace-layout:v1"];当前固定为左侧原视频列 380px、9:16 视频高 500px、逐句时间轴最大高 360px、参考帧池 140px、主体空态 78px;转换层不再固定拉长,按内容自然高度显示,内容过多时最多到 560px 后在自身区域内滚动;上方是按 9:16 显示的竖版原视频播放器,播放器内覆盖“当前点抽帧”,按当前播放秒数手动补参考帧,播放器下方是逐句时间轴,英文和中文都最多显示两行;右侧上方是无标题的波形与切点参考框,下方主体链路改为上方参考帧池 + 转换层、下方主体元素结果栏。音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点,并通过 skg-audio-waveform 读取明暗主题变量,避免明亮模式继续使用黑底/白色波形;顶部把低/中/高密度按钮和当前播放秒数、总时长、鼠标指针停点秒数直接放在波形上方。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频波形下方同框渲染无标题的 TimelineFilmstrip 临时画面胶片,前端按低/中/高密度从源视频 canvas 截取预览缩略图,并按 frame.time / duration 的百分比定位到和波形同一条时间轴上;波形与胶片之间不显示分隔横线,胶片轨道贴近波形,缩略图轻微上下错落并倾斜重叠排列,hover 时用同一张胶片卡在原位置生成固定顶层克隆,约 4.8 倍放大并自动限制在视口内,避免被工作区、滚动容器或相邻面板遮挡;单击胶片只跳转视频时间点,不写入任务数据,双击胶片或拖进参考帧池时才调用手动抽帧并正式加入 job.frames,已加入的胶片显示“已添加”;胶片预览按 job、视频、密度和时长缓存,未切换低/中/高时返回页面不重新扫视频。参考帧池的主入口是“自动抽帧 12 张”,一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化;缩略图按竖版完整比例显示不裁切,点选状态直接叠在参考帧池缩略图上,鼠标停留会通过固定浮层放大展示完整帧。转换层改为轻量对话式生图确认区并拿到主操作宽度:左侧参考帧可点 + 或直接拖入转换层,本地图片拖入会通过 uploadReferenceFrame 保存为参考帧;转换层上方是参考输入区,下方不再显示当前要求摘要、保留元素副本或对话记录计数,只保留带张数控件的“发送消息”输入 composer;模型确认类回复不再逐条展示,生成英文 prompt 后发送区主按钮直接切换为“确认生成 N 张”,点击后才调用主体套图生成。主体元素结果栏在转换层下方,空态只占紧凑提示;有结果时按每次生成的套图文件夹显示,左侧横向展示当前套图,右侧切换套图包,保留单张重生和删除;缩略图上提供“重新生成这一张”和“删除这一张”,单张重生会用 replace_views=true 替换同一视角。前端对卡通重构传 subject_style=cartoon_subject,其他方向传 subject_style=source_actor;形象锁定或自主描述空文本可走 reconstruction_mode=same,其他参考创新走 similar 并把参考帧作为 /images/edits 的 image refs 一起提交。主体生成完成后会形成 subject_consensus_brief。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写。每条音频分镜默认是左侧三字段、右侧横向视频候选轨;高级区仍保留首尾帧 prompt、产品出现方式和旧 6 字段。ModelTrace 会在音频解析、产品识别/补图、主体重构视图包、脚本改写等入口旁直接展示模型名;生图入口会显示 gpt-image-2 / gemini-3-pro-image-preview 链路和短时熔断规则,点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 @@ -618,7 +619,7 @@ web/components/product-library-picker.tsxSKG 内置白底产品图库选择器:搜索、品类筛选、预览尺寸,并把库内图片复制为当前 job 的 assetweb/components/storyboard-bar.tsx顶部分镜编排条:展示选入编排的关键帧,并作为唯一分镜导航。 web/components/storyboard-workbench.tsx顶部分镜编排条下方的明细区:4 图槽、改造目标、时长、自动保存。 - web/lib/api.ts前端类型和 API client,是前后端数据契约镜像;RuntimeHealth / RuntimeModels 读取 GET /health,把 ASR、翻译、视觉、图像、视频等模型名作为前端模型标注的真源。新增 createCreativeImageJobgenerateCreativeCopy,让新首页可以先创建一张参考图/空白图任务,再复用既有 generateImagegenerateStoryboardVideo;资源库相关类型和 CRUD/use/copy-to-job 函数继续保留给旧工作台和后续资源中心。 + web/lib/api.ts前端类型和 API client,是前后端数据契约镜像;RuntimeHealth / RuntimeModels 读取 GET /health,把 ASR、翻译、视觉、图像、视频等模型名作为前端模型标注的真源。默认首页主要使用 createCreativeImageJobuploadReferenceFramegenerateImagegenerateStoryboardVideogenerateCreativeCopy 仍保留给详情页和后续高级能力。资源库相关类型和 CRUD/use/copy-to-job 函数继续保留给旧工作台和后续资源中心。 @@ -643,13 +644,11 @@
当前前端主链路:
 	web/app/page.tsx
-	  -> 即梦 generate 式中央 prompt composer:视频 / 图片 / 图文三种产物模式
-  -> 输入框左侧参考图上传或空白任务:POST /creative/jobs/image → 生成只有 0 号关键帧的 Job
-  -> 生图:generateImage(job.id, 0, { prompt, mode: edit/text }) → jobs/<jobId>/gen
-  -> 生视频:generateStoryboardVideo(job.id, 0, { prompt, first_image: keyframe 0, duration }) → jobs/<jobId>/storyboard_videos
-	  -> 营销图文:POST /creative/copy → 返回 hook、script、image_prompt_en、video_prompt_en → 可一键填回生图或生视频
-	  -> 最近任务:GET /jobs?limit=14 → 会话侧栏只读取当前登录用户可见的新创作任务;历史无 owner 旧任务只对备用账号可见
-	  -> 当前结果浮层:最新图片 / 视频 / 图文只在右下角提示,不占首屏主画布
+	  -> 单对话框:文生视频 / 文生图 / 首帧生视频 / 首尾帧生视频
+  -> 创建轻量任务:POST /creative/jobs/image → 生成只有 0 号关键帧的 Job;首尾帧时再 POST /jobs/{id}/frames/upload
+  -> 生图:generateImage(job.id, 0, { prompt, mode: text }) → jobs/<jobId>/gen
+  -> 生视频:generateStoryboardVideo(job.id, 0, { prompt, first_image?, last_image?, duration }) → jobs/<jobId>/storyboard_videos
+	  -> 当前结果:最新图片 / 视频只在对话框下方展示
 	  -> 任务详情页:web/app/detail/page.tsx?job=<id> → getJob → 展示参考图、生成图、视频、提示词、图文方案 → 可继续生成 / 删除 / 复制
 
 旧版 TK 复刻链路(最后版本保留):
@@ -677,7 +676,7 @@ api/main.py
           
你看到的区域SKG 营销内容工作台首页
主要源码web/app/page.tsx;前端 API client 在 web/lib/api.ts;轻量创作后端在 api/main.py/creative/jobs/image/creative/copy,实际图片和视频生成继续复用 /jobs/{id}/frames/{idx}/generate/jobs/{id}/frames/{idx}/storyboard/video
-
适合怎么描述“首页中央输入框、左侧上传参考图、视频/图片/图文模式、自动设置、会话侧栏最近任务、右下角当前结果浮层、进入详情页”。
+
适合怎么描述“首页只有一个对话框,四个模式是文生视频、文生图、首帧生视频、首尾帧生视频;用户上传必要帧并手写提示词后生成”。
你看到的区域任务详情页
@@ -1183,6 +1182,19 @@ ProductRefStateItem {

变更记录

这个记录不是 git log 的替代品。它记录“产品理解发生了什么变化、影响了哪些源码、你以后描述需求时该怎么说”。后续每次改功能都要补一条。

+
+
+

2026-05-25 · 默认首页收敛为单对话框四模式生成

+ UI + Product + Docs +
+
+

问题:即梦式版本仍有侧栏、模式附属按钮和结果浮层,用户希望再简单一点:只保留对话框,让使用者自己写提示词,再选择对应生成方式。

+

改动:web/app/page.tsx 改为单中央对话框,默认只露出文生视频、文生图、首帧生视频、首尾帧生视频四个按钮、提示词输入框、必要上传位、时长和生成按钮。首尾帧模式复用 uploadReferenceFrame 上传尾帧,并把 last_image 传给 generateStoryboardVideo

+

影响:默认首页不再展示营销图文、灵感、最近任务、侧栏或自动设置;默认使用方式是“选模式 → 上传必要帧 → 写提示词 → 生成”。详情页仍保留任务沉淀和继续生成能力。

+
+

2026-05-25 · 默认首页复刻即梦 generate 极简布局

diff --git a/web/app/page.tsx b/web/app/page.tsx index 7e1cf1f..1cfbc03 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -4,18 +4,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { ArrowUp, Clapperboard, - Copy, ExternalLink, - FileText, - Folder, Image as ImageIcon, - Layers3, Loader2, - Menu, Plus, Sparkles, Upload, - Wand2, X, type LucideIcon, } from "lucide-react" @@ -26,75 +20,55 @@ import { createCreativeImageJob, deleteGeneratedImage, deleteGeneratedVideo, - generateCreativeCopy, generateImage, generateStoryboardVideo, getJob, - listJobs, - type CreativeCopyVariant, + uploadReferenceFrame, type GeneratedImage, type GeneratedVideo, type Job, - type JobSummary, } from "@/lib/api" -type CreationMode = "video" | "image" | "copy" +type CreationMode = "text-video" | "text-image" | "first-frame-video" | "first-last-frame-video" type BusyTask = CreationMode | "job" | null +type UploadSlot = "first" | "last" type ModeConfig = { id: CreationMode label: string icon: LucideIcon placeholder: string + needsFirstFrame?: boolean + needsLastFrame?: boolean } -type InspirationCard = { - title: string - mode: CreationMode - prompt: string -} - -const OUTPUT_MODES: ModeConfig[] = [ +const MODES: ModeConfig[] = [ { - id: "video", - label: "视频", + id: "text-video", + label: "文生视频", icon: Clapperboard, - placeholder: "Seedance 2.0 全能参考,视频创意无限可能", + placeholder: "写清楚画面、人物动作、产品出现方式、镜头运动和风格。例如:15 秒竖屏,办公室午休,人物戴上 SKG 颈部按摩仪放松,镜头缓慢推进。", }, { - id: "image", - label: "图片", + id: "text-image", + label: "文生图", icon: ImageIcon, - placeholder: "生成一张 9:16 信息流营销图,SKG 颈部按摩仪佩戴清楚,真实办公室午休场景。", + placeholder: "写清楚画面、主体、构图、光线和比例。例如:9:16 信息流营销图,真实办公室场景,SKG 颈部按摩仪佩戴清楚。", }, { - id: "copy", - label: "图文", - icon: FileText, - placeholder: "写一组 SKG 颈部按摩仪营销图文方案,包含 hook、脚本、caption 和生成提示词。", - }, -] - -const PROMPT_PRESETS: InspirationCard[] = [ - { - title: "办公室午休", - mode: "video", - prompt: "做一条 15 秒 TikTok 竖屏视频,办公室午休场景,人物放下电脑后戴上 SKG 颈部按摩仪,镜头缓慢推进,突出日常放松。", + id: "first-frame-video", + label: "首帧生视频", + icon: Upload, + needsFirstFrame: true, + placeholder: "上传首帧后写视频变化:人物怎么动、镜头怎么动、产品要保持什么细节、时长多长。", }, { - title: "下班回家放松", - mode: "video", - prompt: "做一条 12 秒竖屏短片,年轻上班族下班回家后放松肩颈,先表现疲惫,再自然戴上 SKG 产品,动作可信。", - }, - { - title: "白底产品功能图", - mode: "image", - prompt: "生成一张白底产品功能图,高级电商质感,突出 SKG 颈部按摩仪外形、佩戴方式和日常使用,产品结构不能变形。", - }, - { - title: "前三秒 Hook", - mode: "copy", - prompt: "写 3 套 SKG 颈部按摩仪信息流营销图文方案,每套包含前三秒 hook、中文脚本、caption、图片提示词和视频提示词。", + id: "first-last-frame-video", + label: "首尾帧生视频", + icon: Sparkles, + needsFirstFrame: true, + needsLastFrame: true, + placeholder: "上传首帧和尾帧后,写中间如何过渡、动作节奏、镜头运动和产品细节保持要求。", }, ] @@ -119,79 +93,49 @@ 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 = { - created: "已创建", - downloading: "下载中", - downloaded: "已下载", - splitting: "拆轨中", - frames_extracted: "可创作", - transcribing: "识别中", - transcribed: "已解析", - failed: "失败", - } - return map[status] ?? status +function isVideoMode(mode: CreationMode) { + return mode !== "text-image" } export default function Home() { - const [mode, setMode] = useState("video") + const [mode, setMode] = useState("text-video") const [prompt, setPrompt] = useState("") - const [product, setProduct] = useState("SKG 颈部按摩仪") - const [audience, setAudience] = useState("久坐办公、低头刷手机的人群") - const [platform, setPlatform] = useState("TikTok / Reels") - const [tone, setTone] = useState("真实自然、有购买理由") - const [seconds, setSeconds] = useState(15) - const [referenceFile, setReferenceFile] = useState(null) - const [referencePreview, setReferencePreview] = useState("") + const [seconds, setSeconds] = useState(12) + const [firstFrameFile, setFirstFrameFile] = useState(null) + const [lastFrameFile, setLastFrameFile] = useState(null) + const [firstFramePreview, setFirstFramePreview] = useState("") + const [lastFramePreview, setLastFramePreview] = useState("") const [job, setJob] = useState(null) const [busy, setBusy] = useState(null) - const [copyVariants, setCopyVariants] = useState([]) - const [recentJobs, setRecentJobs] = useState([]) - const [showSettings, setShowSettings] = useState(false) const [error, setError] = useState("") - const fileInputRef = useRef(null) + const firstInputRef = useRef(null) + const lastInputRef = useRef(null) - const activeMode = OUTPUT_MODES.find((item) => item.id === mode) ?? OUTPUT_MODES[0] - const images = useMemo(() => allGeneratedImages(job), [job]) + const activeMode = MODES.find((item) => item.id === mode) ?? MODES[0] const latestImage = latestGeneratedImage(job) 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([]) - } - }, []) + const submitting = busy === mode || busy === "job" useEffect(() => { - refreshJobs() - }, [refreshJobs, job?.id, images.length, job?.generated_videos?.length]) - - useEffect(() => { - if (!referenceFile) { - setReferencePreview("") + if (!firstFrameFile) { + setFirstFramePreview("") return } - const url = URL.createObjectURL(referenceFile) - setReferencePreview(url) + const url = URL.createObjectURL(firstFrameFile) + setFirstFramePreview(url) return () => URL.revokeObjectURL(url) - }, [referenceFile]) + }, [firstFrameFile]) + + useEffect(() => { + if (!lastFrameFile) { + setLastFramePreview("") + return + } + const url = URL.createObjectURL(lastFrameFile) + setLastFramePreview(url) + return () => URL.revokeObjectURL(url) + }, [lastFrameFile]) useEffect(() => { if (!job || !runningVideo) return @@ -205,60 +149,68 @@ export default function Home() { return () => window.clearInterval(timer) }, [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, refreshJobs]) - - const onFileChange = (file: File | null) => { - setReferenceFile(file) + const resetResult = () => { setJob(null) - setCopyVariants([]) setError("") } - 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 onModeChange = (nextMode: CreationMode) => { + setMode(nextMode) + resetResult() + if (nextMode === "text-video" || nextMode === "text-image") { + setFirstFrameFile(null) + setLastFrameFile(null) + } + if (nextMode === "first-frame-video") { + setLastFrameFile(null) } } - const promptWithContext = () => ( - `${prompt.trim()}\n\nProduct: ${product}. Audience: ${audience}. Platform: ${platform}. Tone: ${tone}. Keep the SKG product shape stable and visible.` - ) + const setUploadFile = (slot: UploadSlot, file: File | null) => { + if (slot === "first") setFirstFrameFile(file) + if (slot === "last") setLastFrameFile(file) + resetResult() + } - const validatePrompt = () => { + const validate = () => { if (!prompt.trim()) { - toast.error("先写一句生成要求") + toast.error("先写提示词") + return false + } + if (activeMode.needsFirstFrame && !firstFrameFile) { + toast.error("先上传首帧") + return false + } + if (activeMode.needsLastFrame && !lastFrameFile) { + toast.error("先上传尾帧") return false } return true } + const promptWithGuardrails = () => ( + `${prompt.trim()}\n\nKeep the product shape stable when a product appears. Use a clean vertical marketing composition unless the prompt says otherwise.` + ) + + const prepareJob = useCallback(async () => { + setBusy("job") + let created = await createCreativeImageJob(firstFrameFile) + if (mode === "first-last-frame-video" && lastFrameFile) { + created = await uploadReferenceFrame(created.id, lastFrameFile) + } + setJob(created) + return created + }, [firstFrameFile, lastFrameFile, mode]) + const runImage = async () => { - if (!validatePrompt()) return - setBusy("image") + if (!validate()) return + setBusy("text-image") setError("") try { - const target = await ensureJob() + const target = await prepareJob() const updated = await generateImage(target.id, 0, { - prompt: promptWithContext(), - mode: canUseReference ? "edit" : "text", + prompt: promptWithGuardrails(), + mode: "text", }) setJob(updated) toast.success("图片已生成") @@ -272,16 +224,18 @@ export default function Home() { } const runVideo = async () => { - if (!validatePrompt()) return - setBusy("video") + if (!validate()) return + setBusy(mode) setError("") try { - const target = await ensureJob() + const target = await prepareJob() + const lastFrame = [...target.frames].sort((a, b) => b.index - a.index)[0] const updated = await generateStoryboardVideo(target.id, 0, { - prompt: promptWithContext(), + prompt: promptWithGuardrails(), duration: seconds, count: 1, - first_image: { kind: "keyframe", frame_idx: 0 }, + first_image: activeMode.needsFirstFrame ? { kind: "keyframe", frame_idx: 0 } : null, + last_image: activeMode.needsLastFrame && lastFrame ? { kind: "keyframe", frame_idx: lastFrame.index } : null, size: "720x1280", }) setJob(updated) @@ -295,33 +249,8 @@ export default function Home() { } } - const runCopy = async () => { - const goal = prompt.trim() || `${product} ${audience} ${platform}` - setBusy("copy") - setError("") - try { - const result = await generateCreativeCopy({ - goal, - product, - audience, - platform, - 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 runPrimary = () => { - if (mode === "image") return runImage() - if (mode === "copy") return runCopy() + if (mode === "text-image") return runImage() return runVideo() } @@ -345,349 +274,218 @@ export default function Home() { } } - const copyText = async (text: string) => { - try { - await navigator.clipboard.writeText(text) - toast.success("已复制") - } catch { - toast.error("复制失败") - } - } - - const useInspiration = (item: InspirationCard) => { - setMode(item.mode) - setPrompt(item.prompt) - setError("") - } - - 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 (
-
- + ) : null} +
- -
-
-
-
-

你好,想创作什么?

- -
- - - onFileChange(event.target.files?.[0] ?? null)} - /> - -