From 3146266383ab67277351be544ef7867aa5d0cbfe Mon Sep 17 00:00:00 2001 From: kang Date: Sat, 23 May 2026 23:50:30 +0800 Subject: [PATCH] feat: redesign creative studio entry --- .memory/worklog.json | 40 +- api/main.py | 180 ++++ docs/source-analysis.html | 38 +- web/app/layout.tsx | 4 +- web/app/page.tsx | 1736 ++++++++++++------------------------- web/lib/api.ts | 48 + 6 files changed, 827 insertions(+), 1219 deletions(-) diff --git a/.memory/worklog.json b/.memory/worklog.json index 7195e6d..7c44d82 100644 --- a/.memory/worklog.json +++ b/.memory/worklog.json @@ -1,25 +1,5 @@ { "entries": [ - { - "files_changed": 1, - "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:fix: hide waveform filmstrip labels", - "ts": "2026-05-19T10:04:37Z", - "type": "session-heartbeat" - }, - { - "files_changed": 2, - "hash": "20f730a", - "message": "auto-save 2026-05-19 18:06 (~2)", - "ts": "2026-05-19T18:06:17+08:00", - "type": "commit" - }, - { - "files_changed": 3, - "hash": "818d785", - "message": "fix: enlarge filmstrip hover near waveform", - "ts": "2026-05-19T18:08:17+08:00", - "type": "commit" - }, { "files_changed": 2, "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 2 项未提交变更 · 最近提交:fix: enlarge filmstrip hover near waveform", @@ -3201,6 +3181,26 @@ "message": "auto-save 2026-05-23 23:36 (~2)", "hash": "d551c45", "files_changed": 2 + }, + { + "ts": "2026-05-23T23:45:06+08:00", + "type": "commit", + "message": "auto-save 2026-05-23 23:45 (~2)", + "hash": "0e55945", + "files_changed": 2 + }, + { + "ts": "2026-05-23T15:47:20Z", + "type": "session-heartbeat", + "message": "Codex 会话活跃 · 最近命令:codex · 分支 main · 1 项未提交变更 · 最近提交:auto-save 2026-05-23 23:45 (~2)", + "files_changed": 1 + }, + { + "ts": "2026-05-23T23:50:30+08:00", + "type": "commit", + "message": "auto-save 2026-05-23 23:50 (~3)", + "hash": "e13bb0b", + "files_changed": 3 } ] } diff --git a/api/main.py b/api/main.py index b11f805..7b89cd8 100644 --- a/api/main.py +++ b/api/main.py @@ -4275,6 +4275,32 @@ class TranslateReq(BaseModel): target: Literal["en", "zh"] = "en" +class CreativeCopyReq(BaseModel): + goal: str + product: str = "" + audience: str = "" + platform: str = "TikTok / Reels" + tone: str = "direct" + seconds: int = 20 + source_text: str = "" + + +class CreativeCopyVariant(BaseModel): + title: str = "" + hook_zh: str = "" + script_zh: str = "" + script_en: str = "" + image_prompt_en: str = "" + video_prompt_en: str = "" + caption_zh: str = "" + hashtags: list[str] = Field(default_factory=list) + + +class CreativeCopyResp(BaseModel): + model: str + variants: list[CreativeCopyVariant] + + class ScriptRewriteSegmentReq(BaseModel): index: int start: float = 0.0 @@ -4339,6 +4365,74 @@ def _ensure_english(text: str) -> str: return text +def _creative_copy_fallback(req: CreativeCopyReq) -> CreativeCopyResp: + goal = req.goal.strip() or "展示 SKG 产品的核心卖点" + product = req.product.strip() or "SKG 健康科技产品" + seconds = max(6, min(60, int(req.seconds or 20))) + script_zh = ( + f"开场 0-3 秒:直接展示{product}和使用场景,提出一个具体痛点。\n" + f"中段 3-{max(4, seconds - 5)} 秒:用三个连续镜头说明{goal},画面保持产品清晰可见。\n" + f"结尾 {max(4, seconds - 5)}-{seconds} 秒:给出一句明确行动口播,收在产品近景。" + ) + script_en = _ensure_english(script_zh) + image_prompt = _ensure_english( + f"{product}, premium health-tech product advertising image, clean lifestyle scene, clear product visibility, natural lighting, vertical composition" + ) + video_prompt = _ensure_english( + f"{seconds}-second vertical short video ad for {product}. {goal}. Start with the product in use, show one clear benefit, keep camera motion smooth, realistic lifestyle lighting, no medical treatment claims." + ) + return CreativeCopyResp( + model="fallback", + variants=[ + CreativeCopyVariant( + title="快速成片版", + hook_zh=f"{product},把一个日常痛点变成一个清楚的使用理由。", + script_zh=script_zh, + script_en=script_en, + image_prompt_en=image_prompt, + video_prompt_en=video_prompt, + caption_zh=f"{product}|{goal}", + hashtags=["#SKG", "#健康科技", "#短视频广告"], + ) + ], + ) + + +def _parse_creative_copy_response(raw: str, req: CreativeCopyReq) -> CreativeCopyResp: + text = (raw or "").strip() + text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I).strip() + text = re.sub(r"\s*```$", "", text).strip() + match = re.search(r"\{[\s\S]*\}", text) + json_text = match.group(0) if match else text + try: + data = json.loads(json_text) + except Exception: + return _creative_copy_fallback(req) + raw_items = data.get("variants") if isinstance(data, dict) else None + if not isinstance(raw_items, list): + return _creative_copy_fallback(req) + variants: list[CreativeCopyVariant] = [] + for item in raw_items[:3]: + if not isinstance(item, dict): + continue + hashtags = item.get("hashtags") or [] + if not isinstance(hashtags, list): + hashtags = [] + variants.append(CreativeCopyVariant( + title=str(item.get("title") or "").strip()[:80], + hook_zh=str(item.get("hook_zh") or "").strip()[:180], + script_zh=str(item.get("script_zh") or "").strip()[:900], + script_en=_ensure_english(str(item.get("script_en") or item.get("script_zh") or "").strip())[:1200], + image_prompt_en=_ensure_english(str(item.get("image_prompt_en") or "").strip())[:1200], + video_prompt_en=_ensure_english(str(item.get("video_prompt_en") or "").strip())[:1400], + caption_zh=str(item.get("caption_zh") or "").strip()[:240], + hashtags=[str(tag).strip()[:40] for tag in hashtags if str(tag).strip()][:8], + )) + if not variants: + return _creative_copy_fallback(req) + return CreativeCopyResp(model=REWRITE_MODEL if LLM_API_KEY else "fallback", variants=variants) + + @app.post("/translate") def translate_text(req: TranslateReq) -> dict: """单条文本翻译(给生图自定义提取元素 zh→en 用)""" @@ -4374,6 +4468,44 @@ def translate_text(req: TranslateReq) -> dict: raise HTTPException(500, f"translate failed: {e}") +@app.post("/creative/copy", response_model=CreativeCopyResp) +def generate_creative_copy(req: CreativeCopyReq) -> CreativeCopyResp: + goal = req.goal.strip() + if not goal: + raise HTTPException(400, "goal required") + if not LLM_API_KEY: + return _creative_copy_fallback(req) + seconds = max(6, min(60, int(req.seconds or 20))) + prompt = ( + "You are creating practical short-form ad material for an SKG AI creative tool. " + "Return strict JSON only. Create 3 distinct variants that can be pasted directly into image/video generation. " + "Avoid medical treatment claims; describe comfort, relaxation, daily use, visual proof, and product clarity instead. " + "Every variant must include title, hook_zh, script_zh, script_en, image_prompt_en, video_prompt_en, caption_zh, hashtags.\n\n" + f"Goal: {goal}\n" + f"Product: {req.product.strip() or 'SKG health-tech product'}\n" + f"Audience: {req.audience.strip() or 'short-form shoppers'}\n" + f"Platform: {req.platform.strip() or 'TikTok / Reels'}\n" + f"Tone: {req.tone.strip() or 'direct'}\n" + f"Length: {seconds}s\n" + f"Source/reference text:\n{req.source_text.strip()[:1500]}" + ) + try: + resp = llm().chat.completions.create( + model=REWRITE_MODEL, + messages=[ + {"role": "system", "content": "Return valid JSON only. No markdown. No explanation."}, + {"role": "user", "content": prompt}, + ], + response_format={"type": "json_object"}, + temperature=0.72, + max_tokens=2200, + ) + return _parse_creative_copy_response(resp.choices[0].message.content or "", req) + except Exception as e: + print(f"[creative copy fallback] {e}", flush=True) + return _creative_copy_fallback(req) + + def _fallback_script_rewrite_item(segment: ScriptRewriteSegmentReq, author_intent: str = "") -> dict: source = (segment.source or "").strip() intent = _ensure_english(author_intent or "") @@ -4663,6 +4795,54 @@ async def create_job_from_upload(bg: BackgroundTasks, file: UploadFile = File(.. return job +def _write_creative_reference_frame(job_id: str, file_bytes: bytes | None = None) -> tuple[int, int]: + frames_dir = job_dir(job_id) / "frames" + frames_dir.mkdir(parents=True, exist_ok=True) + out = frames_dir / "000.jpg" + if file_bytes: + try: + with Image.open(io.BytesIO(file_bytes)) as raw: + im = ImageOps.exif_transpose(raw).convert("RGB") + im.thumbnail((1600, 1600), Image.LANCZOS) + width, height = im.size + im.save(out, "JPEG", quality=92) + return width, height + except Exception as e: + raise HTTPException(400, f"invalid image file: {e}") + im = Image.new("RGB", (1024, 1024), (246, 248, 246)) + im.save(out, "JPEG", quality=92) + return im.size + + +@app.post("/creative/jobs/image", response_model=Job) +async def create_creative_image_job(file: UploadFile | None = File(default=None)) -> Job: + job_id = uuid.uuid4().hex[:12] + file_bytes: bytes | None = None + source_label = "blank" + if file and file.filename: + ext = Path(file.filename).suffix.lower() + if ext not in {".jpg", ".jpeg", ".png", ".webp"}: + raise HTTPException(400, f"unsupported image format: {ext}") + file_bytes = await file.read() + source_label = file.filename + width, height = _write_creative_reference_frame(job_id, file_bytes) + frame = KeyFrame(index=0, timestamp=0, url=f"/jobs/{job_id}/frames/0.jpg") + job = Job( + id=job_id, + url=f"creative://{source_label}", + status="frames_extracted", + progress=100, + message="创作任务已就绪", + width=width, + height=height, + duration=0, + frames=[frame], + ) + JOBS[job_id] = job + save_state(job) + return job + + @app.post("/jobs/{job_id}/analyze", response_model=Job) async def trigger_analyze( job_id: str, diff --git a/docs/source-analysis.html b/docs/source-analysis.html index c23c6d3..af63957 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -569,6 +569,9 @@

业务管线

+
+

2026-05-23 产品重置:默认首页已从“TK 信息流复刻管线”改为 SKG Creative Studio 直接创作台。主路径现在是三件事:一图/文字生图、一图/文字生视频、自动写短视频文案并把文案提示词送去生图或生视频。下面旧 TK 复刻管线作为最后版本保留在代码、数据快照和 git tag 中,不再是默认入口。

+

当前产品方向已收窄为“信息流广告快速复刻”:主界面左侧是拉满工作台可用高度的 65px 胶囊工具条,鼠标移入或键盘聚焦会从侧边滑出素材输入面板,点击素材任务按钮可固定展开,右侧主画布是信息流复刻工作表;工作台已取消 1800x1000 固定画布和整页 zoom 缩放,改为正常流式桌面容器,宽度跟随浏览器展开,只保留 1280px 最低操作宽度防止核心表格被压烂,避免小数缩放造成文字发虚和比例失衡。后台仍按 01-09 流程顺序计算素材任务、源视频、音频文案、抽帧、主体资产、产品资产、分镜文案、三字段规划和视频候选这些状态,但这些判断不再默认显现在工作区顶部,避免状态提示挤占首屏操作空间。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动音频文案路和视频视觉路。音频文案路自动识别中文、英文和其他多语言原音频文案/字幕,统一补齐中文镜像,分析讲话人、语速节奏、背景音乐/环境声/音效,并为后续新口播和分镜文案提供时间轴;视频视觉路同步抽取参考帧。源视频工作区主体链路是“上方参考帧池 + 转换层、下方主体元素结果栏”:参考帧池只作为竖向原始参考;转换层改为轻量对话式生图确认区,参考图可通过左侧缩略图 +、参考帧拖拽、胶片拖拽或本地图片拖入进入转换层,用户选择 GPT/Gemini 套件后先分析参考图,再在下方消息输入区发送复刻、创新、卡通、数量和画面要求;系统返回英文出图 prompt 后不再自动弹窗,发送区主按钮直接切换为“确认生成 N 张”,用户点击后才调用主体生成并把结果送到下方主体元素结果栏。主体元素结果栏保留已有套图输出、文件夹分组、单张重生、删除和 hover 预览逻辑,空态只保留紧凑提示,不再占据右侧整列。旧下方主体模板库不再作为主路径。波形下方的画面胶片由前端临时从源视频截取,密度可调,点击只跳转原视频时间点,双击或拖入参考帧池才调用手动抽帧接口正式写入关键帧;已写入的胶片显示“已添加”,相同素材、相同密度和时长下会复用内存缓存,避免返回页面时重复扫视频。产品图上传后独立形成产品资产包:自动识别视角、左右/上下/内外侧、结构点、比例和风险,并补缺角度。最终分镜规划按逐句时间轴把文案、主体元素和产品资产汇合;每条分镜默认是左侧“文案 / 场景一句话 / 人物+产品+动作”三字段、右侧横向视频候选轨。客户可直接改中文镜像,前端会调用改写/翻译链路自动优化对应英文主值;单条和整片都可选择生成数量,整片按行排队提交。视频候选提交后立即写入当前任务,完成后自动回填 mp4,不需要用户另点“保存”;候选卡的普通点击只用于打开预览,右上角提供显式下载按钮;候选选择不再作为默认点击语义。首尾帧、视觉规划、产品出现方式等细节保留在高级抽屉和后端自动展开逻辑里,不再作为客户默认闸门。

01

素材输入

有当前素材任务即通过;输入框只负责创建或切换任务。

@@ -592,7 +595,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产品工作台主状态:jobs、activeJobId、生成任务状态;主渲染为全屏左侧工具条 + 可滑出的素材输入抽屉 + 右侧信息流广告复刻工作表;“开始分析”会把 job 放入并行素材分析队列,下载完成后触发 triggerTranscribe 解析音频,并触发 analyzeJob 自动抽 12 张参考帧,形成“音频文案路 + 视频视觉路”同步推进;音频失败时会忽略失败状态下残留的半成品 transcript,允许再次触发音频解析;底部吸附音频条和旧全局浮动主题按钮不再从主界面渲染,避免和工作台内的明暗模式切换重复。 + web/app/page.tsx当前默认首页:SKG Creative Studio 直接创作台。页面围绕“生视频 / 生图 / 写文案”三种模式组织,左侧是模式切换、参考图上传和最近任务,中间是产品、人群、时长和创作要求输入,右侧展示图片、视频和文案结果。图片/视频缩略图统一复用 MediaAssetTile,支持顶层 hover 预览和删除;文案结果可一键把生成的 image/video prompt 填回生图或生视频模式。旧 TK 复刻工作台组件仍保留在 web/components/ad-recreation-board.tsx,但不再作为默认首页渲染。 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 链路和短时熔断规则,点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 SourceSubjectPipeline源视频工作区主体管线主路径:上方是竖向 参考帧池 和宽幅 转换层,下方是 主体元素 结果栏。参考帧池固定 140px,转换层不再固定高度,按内容自然显示并以 560px 为最大高度,超出后在自身区域内滚动;主体空态固定为 78px 紧凑提示。参考帧池保留自动 12 张、胶片拖入正式成帧、点击勾选和删除;参考帧缩略图保持小尺寸固定宽度、aspect-[9/16]object-contain 显示,hover 预览通过 MediaAssetTile 的左侧紧凑浮层显示,并新增 + 操作把参考帧送入转换层。转换层是轻量对话式生图确认区:顶部选择 GPT 套件或 Gemini 套件,参考输入区支持左侧 +、拖拽参考帧、胶片拖入和本地图片拖拽上传(上传图会写入 job.frames),下方固定为带张数控件的“发送消息”输入 composer,不再重复显示当前要求摘要、保留元素副本或收起记录计数;模型确认类回复不再逐条显示,复刻、参考创意换人物、卡通风格和人物占比等常用意图也不再显示为独立快捷 chip;识别结果里的特征 chip 是“保留元素”选择,点亮表示随下一条消息提交给 subject-agent/message,再次点击取消,清空按钮一次性取消全部,单次点击不再直接请求模型;subject-agent/message 返回英文 generation_prompt_en 后不再自动弹窗,标题右侧显示“提示词就绪”,底部主按钮从“发送消息”切换为“确认生成 N 张”;用户继续输入会更新需求,点击确认生成才调用 generateSubjectAssets。后端会为每次主体套图注入同一份 pack bible:参考创新模式锁定同一个全新主体和同一套服装,源形象锁定模式锁定参考帧里的可见主体、体态、发型、服装和配色;后处理会裁出白底主体并允许放大到画布高度上限约 96%,实测典型主体有效高度约 90%,避免模型生成“小人 + 大白边”。主体元素结果栏按每次生成的 pack_id 组织成“套图文件夹”:左侧横向展开当前选中套图,右侧显示可滚动的套图包列表;同一方向可保留多套,生成中按 pack 显示 2/6 这类进度,单张完成就替换对应占位卡;空态只显示紧凑提示,不再占右侧整列。缩略图复用 MediaAssetTile,支持 hover 放大、单张重生和删除。旧下方 SourceReferenceBuildPanel 不再主路径渲染。 @@ -613,7 +616,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、翻译、视觉、图像、视频等模型名作为前端模型标注的真源。资源库相关类型包括 PromptLibraryItemAssetLibraryItemAssetLibraryKind;API client 新增提示词 CRUD/use、素材 CRUD/refs/copy-to-job、最近 24 小时混合列表等函数。 + web/lib/api.ts前端类型和 API client,是前后端数据契约镜像;RuntimeHealth / RuntimeModels 读取 GET /health,把 ASR、翻译、视觉、图像、视频等模型名作为前端模型标注的真源。新增 createCreativeImageJobgenerateCreativeCopy,让新首页可以先创建一张参考图/空白图任务,再复用既有 generateImagegenerateStoryboardVideo;资源库相关类型和 CRUD/use/copy-to-job 函数继续保留给旧工作台和后续资源中心。
@@ -621,7 +624,7 @@

后端核心

- + @@ -636,7 +639,16 @@
api/main.pyFastAPI 单文件后端:登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、原音频转写/翻译、声音与背景音分析、后续口播改写/TTS、文件返回;同时承载全局 prompt_libraryasset_library 的磁盘索引、CRUD、删除保护和复制到 job API。新增 AgentRun 一键出片状态机:链接下载、产品图归一化、透明骨架主体参考、12 段镜头计划、Seedance 视频生成、失败重跑、ffmpeg 合成和终端日志都由后端托管,模型不直接拥有无限执行权。
api/main.pyFastAPI 单文件后端:登录会话、状态模型、任务恢复、下载、抽帧、Vision、清洗、元素、分镜、原音频转写/翻译、声音与背景音分析、后续口播改写/TTS、文件返回;同时承载全局 prompt_libraryasset_library 的磁盘索引、CRUD、删除保护和复制到 job API。新增轻量创作入口 POST /creative/jobs/imagePOST /creative/copy:前者把上传图片或空白底图写成一个只有 0 号关键帧的 Job,让首页直接复用生图/生视频接口;后者输出 3 组可直接进入图片/视频模型的短视频文案和提示词。旧 AgentRun 一键出片状态机和 TK 复刻接口继续保留。
api/product_library/skg-products内置 SKG 白底产品图库:manifest.json 记录从桌面产品图筛出的 gallery 白底图和桌面 4 张产品角度图,images/ 存 45 张参考图。
api/character_library/skg-characters内置相似主体形象库:从桌面 5 套策划形象导入,manifest.json 记录运动阳光男、都市型男、优雅白领女、运动辣妹、绅士大叔,每套含 7 张透明骨架参考图和一段 prompt_brief。相似主体生成时优先使用文字 brief 作为创意方向,避免把内置图作为强参考图复制。
asset_library/全局素材库目录,和 jobs/ 平级,不写入任何 job state。四类目录为 subjectsproductsscenesvideos;每个素材自带 manifest.json 和图片/视频文件,index.json 只是启动扫描重建出来的缓存。库素材选用到 job 时必须复制文件到 jobs/<jobId>/assetsstoryboard-videos,禁止直接保存 library 引用。
-
前端主链路:
+        
当前前端主链路:
+web/app/page.tsx
+  -> SKG Creative Studio 三模式创作台:生视频 / 生图 / 写文案
+  -> 参考图上传或空白任务: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=8 → 读取旧数据和新创作任务
+
+旧版 TK 复刻链路(最后版本保留):
 web/app/page.tsx
   -> 信息流广告复刻工作表:web/components/ad-recreation-board.tsx
   -> 开始分析:创建/激活 job → 下载完成后并行触发视频视觉路 analyzeJob 与音频文案路 triggerTranscribe
@@ -658,6 +670,11 @@ api/main.py
       

界面区域到源码

+
+
你看到的区域SKG Creative Studio 首页
+
主要源码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
+
适合怎么描述“首页模式、参考图上传、创作要求、文案结果如何回填到生图/生视频、结果区缩略图/视频预览/删除、最近任务恢复”。
+
你看到的区域一分钟二创出片终端
主要源码web/app/agent/page.tsx;后端状态机、日志、最终成片文件和审片图在 api/main.py/agent-runs 系列接口。
@@ -1155,6 +1172,19 @@ ProductRefStateItem {

变更记录

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

+
+
+

2026-05-23 · 默认首页重置为直接创作台

+ UI + API + Product +
+
+

问题:原首页围绕 TK 复刻、抽帧、转换层、主体 6 视图和分镜工作台展开,功能完整但对普通团队成员太重。真实需求更接近“能直接生图、生视频、自动写文案,或给一张图加一句描述快速出图/出片”。

+

改动:进入新设计前先把当前数据复制到 .backups/pre-redesign-20260523-235959,并创建本地 tag pre-redesign-20260523 指向旧版代码 0d5c326。默认 web/app/page.tsx 改成 SKG Creative Studio 三模式首页:生视频、生图、写文案;图片和视频缩略图继续复用 MediaAssetTileapi/main.py 新增 POST /creative/jobs/image 创建参考图/空白图创作任务,新增 POST /creative/copy 生成短视频文案和可直接投喂模型的 image/video prompt;web/lib/api.ts 同步新增类型和 client。

+

影响:旧信息流复刻工作台和 Agent Cut 代码不删除,作为最后版本和后续可复用能力保留;默认产品使用方式已经变成“输入一句要求,必要时加一张图,直接生成图片/视频/文案”。后续需求应优先描述首页三模式创作体验,不要默认回到旧转换层或 09 步管线。

+
+

2026-05-22 · 音频解析改为中文和多语言自动识别

diff --git a/web/app/layout.tsx b/web/app/layout.tsx index b337dce..fdc0db6 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -12,8 +12,8 @@ const _playfairDisplay = Playfair_Display({ }) export const metadata: Metadata = { - title: "SKG TK 二创工作台", - description: "SKG AI 素材生产管线 · 节点工作流", + title: "SKG Creative Studio", + description: "SKG AI 图片、视频和文案创作台", } export default function RootLayout({ diff --git a/web/app/page.tsx b/web/app/page.tsx index d891979..ddac36d 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,1242 +1,592 @@ "use client" + import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { - ReactFlow, Background, BackgroundVariant, Controls, - useNodesState, useEdgesState, - type Node, type Edge, -} from "@xyflow/react" + ArrowRight, + Clapperboard, + Copy, + FileText, + Image as ImageIcon, + Loader2, + PenLine, + Play, + RefreshCw, + Sparkles, + Upload, + Wand2, +} from "lucide-react" import { Toaster, toast } from "sonner" +import { MediaAssetTile } from "@/components/media-asset-tile" import { - InputNode, VisualLabNode, AudioNode, - ComposeNode, KeyframePanelNode, - VideoFramePanelNode, - type CanvasPanelDock, - type NodeData, -} from "@/components/nodes" -import { AdRecreationBoard } from "@/components/ad-recreation-board" -import { - addManualFrame, analyzeJob, createJob, getJob, listJobs, uploadJob, deleteJob, deleteFrame, deleteGeneratedImage, - deleteGeneratedVideo, deleteCutout, generateStoryboardVideo, triggerTranscribe, describeFrame, updateStoryboard, copyProductLibraryAsset, - formatJobError, retryJobDownload, - type Job, type ImageRef, type KeyFrame, type ProductFusionShot, type StoryboardScene, type FrameExtractMode, type FrameExtractQuality, type FrameExtractTarget, + apiAssetUrl, + createCreativeImageJob, + deleteGeneratedImage, + deleteGeneratedVideo, + generateCreativeCopy, + generateImage, + generateStoryboardVideo, + getJob, + listJobs, + type CreativeCopyVariant, + type GeneratedImage, + type GeneratedVideo, + type Job, + type JobSummary, } from "@/lib/api" -import { TRANSPARENT_HUMAN_NEGATIVE_PROMPT, TRANSPARENT_HUMAN_VIDEO_PROMPT } from "@/lib/workflow-target" -type FlowNodeData = NodeData & Record -type StudioFlowNode = Node +type CreatorMode = "video" | "image" | "copy" +type BusyTask = "image" | "video" | "copy" | null -const NODE_TYPES = { - input: InputNode, - visual: VisualLabNode, - audio: AudioNode, - compose: ComposeNode, - keyframePanel: KeyframePanelNode, - videoFramePanel: VideoFramePanelNode, -} - -const KEYFRAME_PANEL_ID = "keyframe-detail-panel" -const VIDEO_FRAME_PANEL_ID = "video-frame-panel" -const FLOATING_PANEL_IDS = new Set([KEYFRAME_PANEL_ID, VIDEO_FRAME_PANEL_ID]) -const DIRECT_VIDEO_GENERATION_PAUSED = true -const FRAME_TARGET_LABELS: Record = { - transparent_human: "透明骨架人", - balanced: "综合关键帧", - subject: "清晰主体", - transition: "转场变化", - expression: "表情瞬间", - motion: "动作峰值", -} -const FRAME_QUALITY_LABELS: Record = { - auto: "自动", - fast: "快速", - accurate: "精细", - ultra: "极准", -} -const DEFAULT_PRODUCT_LIBRARY_IDS = [ - "desktop-skg-product-angle-01", - "desktop-skg-product-angle-02", - "desktop-skg-product-angle-03", - "desktop-skg-product-angle-04", -] -const VIDEO_READY_STATUSES: Job["status"][] = ["downloaded", "frames_extracted", "transcribed", "failed"] - -function isAudioProcessing(job?: Job | null) { - if (!job) return false - return job.audio_script?.status === "rewriting" || (job.status === "transcribing" && job.audio_script?.status !== "failed") -} - -const PRODUCT_FUSION_WEARING_PROMPT = [ - "Product placement must be physically correct:", - "The SKG device is a rigid opaque white U-shaped neck massager, not a soft scarf, necklace, cable, collar, sticker, implant, or transparent body part.", - "It must stay OUTSIDE the transparent skin shell. It should rest on the external surface around the back of the neck and upper shoulders, like a wearable collar-shaped device.", - "The curved bridge sits behind the neck. The two open arms come forward along the left and right sides of the neck, above the collarbones and near the upper trapezius.", - "The inner metal massage pads face the back/side of the neck. The outer glossy plastic shell remains visible and opaque.", - "Hands may hold the two ends while putting it on, then release or lightly press the side control button. Hands must not hide the device silhouette.", - "The product should occlude the transparent skin where it is in front, cast a small contact shadow, and keep realistic perspective and scale.", - "Keep a tiny readable separation between product edge and skeleton/skin whenever needed so it never looks embedded.", -].join("\n") - -const PRODUCT_FUSION_PRODUCT_IDENTITY_PROMPT = [ - "Product identity is strict:", - "The four SKG product reference images are real product photographs, not concept art and not style inspiration. Treat them as the immutable physical object to insert into the shot.", - "The four SKG product reference images are the single source of truth for the object. Preserve the same white U-shaped body, rounded arms, inner massage pads/nodes, side buttons, seams, glossy plastic material, thickness, proportions, and viewing angles.", - "Use visual compositing behavior: place the real product object onto the character externally, then match lighting, shadow, scale, and perspective around it. Do not redraw a new product from memory.", - "Do not redesign, stylize, simplify, melt, inflate, shrink, recolor, add logos/text/screens/wires/extra parts, or turn it into a generic neckband/headphone/medical brace.", - "If the product and character conflict, prioritize preserving the product shape and place it externally on the neck rather than merging it into the character.", -].join("\n") - -const PRODUCT_FUSION_NEGATIVE_PROMPT = [ - "Hard negatives for product fusion:", - "no product passing through the neck, no product inside the transparent body, no x-ray blending, no transparent product, no product becoming bones or skin, no product fused with spine/ribs/throat, no clipping through shoulders, no floating device, no melted device, no deformed U-shape, no wrong body part, no necklace/scarf/headphones/brace, no random replacement product.", -].join("\n") - -function storyboardNeedsProduct(scene: StoryboardScene) { - if (scene.needs_product === false) return false - if (scene.needs_product === true) return true - const text = `${scene.visual_mode ?? ""} ${scene.product ?? ""} ${scene.product_placement ?? ""}`.toLowerCase() - return !/(不出现产品|不露产品|无需产品|不需要产品|无产品|no product|environment|person_only)/.test(text) -} - -function storyboardNeedsSubject(scene: StoryboardScene) { - if (scene.needs_subject === false) return false - if (scene.needs_subject === true) return true - const text = `${scene.visual_mode ?? ""} ${scene.subject ?? ""}`.toLowerCase() - return !/(不需要人物|无人物|不出现人物|no person|product_only|environment)/.test(text) -} - -// 合并 input + download + split 为一个节点 -// 分叉:上路 input → visual lab ↘ -// 下路 input → audio ──────────────────────────→ compose -const LAYOUT: Array<{ id: string; type: keyof typeof NODE_TYPES; x: number; y: number; w: number }> = [ - { id: "input", type: "input", x: 40, y: 240, w: 320 }, - { id: "visual", type: "visual", x: 460, y: 60, w: 620 }, - { id: "audio", type: "audio", x: 460, y: 440, w: 320 }, - { id: "compose", type: "compose", x: 1160, y: 240, w: 320 }, +const MODE_ITEMS: Array<{ + id: CreatorMode + 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" }, ] -const NODE_SIZES_KEY = "skg-tk:node-sizes:v2" - -type NodeSize = { w?: number; h?: number } - -function loadNodeSizes(): Record { - if (typeof window === "undefined") return {} - try { - const raw = window.localStorage.getItem(NODE_SIZES_KEY) - return raw ? JSON.parse(raw) : {} - } catch { - return {} - } -} - -const NODE_PINS_KEY = "skg-tk:node-pins:v1" - -function loadNodePins(): string[] { - if (typeof window === "undefined") return [] - try { - const raw = window.localStorage.getItem(NODE_PINS_KEY) - return raw ? JSON.parse(raw) : [] - } catch { - return [] - } -} - -const EDGES_RAW: Array<[string, string]> = [ - ["input", "visual"], - ["input", "audio"], - ["visual", "compose"], - ["audio", "compose"], +const PROMPT_TEMPLATES = [ + "一张 SKG 颈部按摩仪的信息流广告首帧,真实生活方式,产品清楚可见,画面干净高级", + "把参考图里的主体变成适合 TikTok 的 9:16 产品短视频,开头 2 秒要抓人,镜头有轻微推进", + "自动写一条 20 秒 SKG 产品短视频脚本,语气直接,突出日常放松和佩戴场景", ] +function cx(...items: Array) { + return items.filter(Boolean).join(" ") +} + +function latestGeneratedImage(job: Job | null): GeneratedImage | null { + return job?.frames?.[0]?.generated_images?.at(-1) ?? null +} + +function videoSrc(job: Job, video: GeneratedVideo) { + return apiAssetUrl(video.url || `/jobs/${job.id}/storyboard-videos/${video.id}.mp4`) +} + export default function Home() { - const [jobs, setJobs] = useState([]) - const [activeJobId, setActiveJobId] = useState(null) - const job = useMemo(() => jobs.find((j) => j.id === activeJobId) ?? null, [jobs, activeJobId]) - const [submitting, setSubmitting] = useState(false) - const [analyzing, setAnalyzing] = useState(false) - const [frameTargets, setFrameTargets] = useState>({}) - const [frameCounts, setFrameCounts] = useState>({}) - const [frameQualities, setFrameQualities] = useState>({}) - const [selectedFramesByJob, setSelectedFramesByJob] = useState>({}) - const [expandedFrameByJob, setExpandedFrameByJob] = useState>({}) - const selectedFrames = useMemo( - () => new Set(activeJobId ? selectedFramesByJob[activeJobId] ?? [] : []), - [activeJobId, selectedFramesByJob], - ) - const expandedFrame = activeJobId ? expandedFrameByJob[activeJobId] ?? null : null - const [framePanelScale, setFramePanelScale] = useState(1) - const [framePanelDock, setFramePanelDock] = useState("left") - const framePanelPinned = framePanelDock !== "canvas" - const [videoPanelJobId, setVideoPanelJobId] = useState(null) - const [videoPanelScale, setVideoPanelScale] = useState(1) - const [videoPanelDock, setVideoPanelDock] = useState("left") - const [videoPanelOpenTick, setVideoPanelOpenTick] = useState(0) - const [clipboard, setClipboard] = useState(null) - const [productionJobIds, setProductionJobIds] = useState>(new Set()) - const [planningJobIds, setPlanningJobIds] = useState>(new Set()) - const [defaultProductRefsByJob, setDefaultProductRefsByJob] = useState>({}) - const autoTriggeredRef = useRef>(new Set()) - const flowRef = useRef(null) - const lastVideoPanelFocusKey = useRef("") + const [mode, setMode] = useState("video") + const [prompt, setPrompt] = useState("") + const [product, setProduct] = useState("SKG 颈部按摩仪") + const [audience, setAudience] = useState("久坐办公、低头刷手机的人群") + const [tone, setTone] = useState("直接、可信、有购买理由") + const [seconds, setSeconds] = useState(20) + const [referenceFile, setReferenceFile] = useState(null) + const [referencePreview, setReferencePreview] = useState("") + const [job, setJob] = useState(null) + const [busy, setBusy] = useState(null) + const [copyVariants, setCopyVariants] = useState([]) + const [recentJobs, setRecentJobs] = useState([]) + const [error, setError] = useState("") + const fileInputRef = useRef(null) - const setSelectedFramesForJob = useCallback((jobId: string, updater: Set | ((prev: Set) => Set)) => { - setSelectedFramesByJob((prev) => { - const current = new Set(prev[jobId] ?? []) - const next = typeof updater === "function" ? updater(current) : updater - return { ...prev, [jobId]: [...next].sort((a, b) => a - b) } - }) - }, []) - - const clearWorkflowStateForJob = useCallback((jobId: string) => { - setSelectedFramesByJob((prev) => ({ ...prev, [jobId]: [] })) - setExpandedFrameByJob((prev) => ({ ...prev, [jobId]: null })) - }, []) - - const updateJobInList = useCallback((updated: Job) => { - setJobs((prev) => { - const idx = prev.findIndex((j) => j.id === updated.id) - if (idx < 0) return [...prev, updated] - const arr = [...prev] - arr[idx] = updated - return arr - }) - }, []) - - // 新增 job + 设为 active - const addJob = useCallback((j: Job) => { - setJobs((prev) => [...prev.filter((x) => x.id !== j.id), j]) - setActiveJobId(j.id) - }, []) - - const handleSwitchJob = useCallback((id: string) => { - setActiveJobId(id) - }, []) - const pollRef = useRef | null>(null) - - const handleSubmit = useCallback(async (url: string) => { - setSubmitting(true) - try { - const created = await createJob(url) - addJob(created) - toast.success(`已创建任务 ${created.id.slice(0, 8)}`) - return created - } catch (e) { - toast.error("提交失败:" + (e instanceof Error ? e.message : String(e))) - return undefined - } finally { - setSubmitting(false) - } - }, [addJob]) - - const handleUpload = useCallback(async (file: File) => { - setSubmitting(true) - try { - toast.info(`上传中:${file.name} (${(file.size / 1024 / 1024).toFixed(1)} MB)`) - const created = await uploadJob(file) - addJob(created) - setProductionJobIds((prev) => new Set(prev).add(created.id)) - toast.success(`已上传 ${created.id.slice(0, 8)},视频就绪后自动跑音频和抽帧`) - } catch (e) { - toast.error("上传失败:" + (e instanceof Error ? e.message : String(e))) - } finally { - setSubmitting(false) - } - }, [addJob]) - - const handleAnalyzeJob = useCallback(async (jobId: string, options?: { mode?: FrameExtractMode }) => { - const targetJob = jobs.find((item) => item.id === jobId) - if (!targetJob) return - const frameTarget = frameTargets[jobId] ?? "transparent_human" - const frameCount = frameCounts[jobId] ?? 12 - const frameQuality = frameQualities[jobId] ?? "auto" - const mode = options?.mode ?? (targetJob.frames.length > 0 ? "append" : "replace") - setActiveJobId(jobId) - setAnalyzing(true) - if (mode === "replace") clearWorkflowStateForJob(jobId) - try { - await analyzeJob(jobId, frameCount, frameTarget, mode, frameQuality) - toast.info(`${mode === "append" ? "追加抽帧" : "开始解析"}:${FRAME_QUALITY_LABELS[frameQuality]} · ${FRAME_TARGET_LABELS[frameTarget]} · ${frameCount} 张`) - setJobs((prev) => prev.map((item) => item.id === jobId ? { - ...item, - status: "splitting", - message: `${mode === "append" ? "追加抽帧中" : "拆轨中"} · ${FRAME_QUALITY_LABELS[frameQuality]} · ${FRAME_TARGET_LABELS[frameTarget]}…`, - progress: 30, - } : item)) - } catch (e) { - toast.error("解析触发失败:" + (e instanceof Error ? e.message : String(e))) - } finally { - setAnalyzing(false) - } - }, [jobs, frameCounts, frameQualities, frameTargets, clearWorkflowStateForJob]) - - const handleAnalyze = useCallback(async (options?: { mode?: FrameExtractMode }) => { - if (!job) return - await handleAnalyzeJob(job.id, options) - }, [job?.id, handleAnalyzeJob]) - - const handleFrameTargetChange = useCallback((jobId: string, target: FrameExtractTarget) => { - setFrameTargets((prev) => ({ ...prev, [jobId]: target })) - }, []) - - const handleFrameCountChange = useCallback((jobId: string, count: number) => { - setFrameCounts((prev) => ({ ...prev, [jobId]: Math.max(1, Math.min(20, count)) })) - }, []) - - const handleFrameQualityChange = useCallback((jobId: string, quality: FrameExtractQuality) => { - setFrameQualities((prev) => ({ ...prev, [jobId]: quality })) - }, []) - - const handleAddManualFrameForJob = useCallback(async (jobId: string, t: number) => { - try { - const updated = await addManualFrame(jobId, t) - updateJobInList(updated) - setActiveJobId((prev) => prev ?? updated.id) - toast.success(`已加帧 @ ${t.toFixed(1)}s · 共 ${updated.frames.length} 张`) - return updated - } catch (e) { - toast.error("加帧失败:" + (e instanceof Error ? e.message : String(e))) - return undefined - } - }, [updateJobInList]) - - const handleAddManualFrame = useCallback(async (t: number) => { - if (!job) return - await handleAddManualFrameForJob(job.id, t) - }, [job?.id, handleAddManualFrameForJob]) - - const handleOpenVideoPanel = useCallback((jobId: string) => { - setActiveJobId(jobId) - if (!videoPanelJobId) setVideoPanelDock("left") - setVideoPanelJobId(jobId) - setVideoPanelOpenTick((n) => n + 1) - }, [videoPanelJobId]) - - const handleVideoPanelScaleChange = useCallback((scale: number) => { - setVideoPanelScale(Math.max(0.65, Math.min(1.6, Number(scale.toFixed(2))))) - }, []) - - const handleToggleFrame = useCallback((idx: number) => { - if (!activeJobId) return - setSelectedFramesForJob(activeJobId, (prev) => { - const next = new Set(prev) - if (next.has(idx)) next.delete(idx) - else next.add(idx) - return next - }) - }, [activeJobId, setSelectedFramesForJob]) - - const handleOpenFramePanel = useCallback((idx: number) => { - if (!activeJobId) return - if (expandedFrame === null) setFramePanelDock("left") - setExpandedFrameByJob((prev) => ({ ...prev, [activeJobId]: idx })) - }, [activeJobId, expandedFrame]) - - const handleCloseExpandedFrame = useCallback(() => { - if (!activeJobId) return - setExpandedFrameByJob((prev) => ({ ...prev, [activeJobId]: null })) - }, [activeJobId]) - - const handleOpenStoryboard = useCallback((idx: number) => { - if (!activeJobId) return - setSelectedFramesForJob(activeJobId, (prev) => { - const next = new Set(prev) - next.add(idx) - return next - }) - }, [activeJobId, setSelectedFramesForJob]) - - const handleOpenWorkbench = useCallback((idx?: number) => { - if (!activeJobId || typeof idx !== "number") return - setSelectedFramesForJob(activeJobId, (prev) => { - const next = new Set(prev) - next.add(idx) - return next - }) - }, [activeJobId, setSelectedFramesForJob]) - - const handleFramePanelScaleChange = useCallback((scale: number) => { - setFramePanelScale(Math.max(0.65, Math.min(1.6, Number(scale.toFixed(2))))) - }, []) - - const handleDeleteFrameForJob = useCallback(async (jobId: string, idx: number) => { - const wasActive = activeJobId === jobId - try { - const updated = await deleteFrame(jobId, idx) - updateJobInList(updated) - setSelectedFramesForJob(jobId, (prev) => { - if (!prev.has(idx)) return prev - const next = new Set(prev) - next.delete(idx) - return next - }) - setExpandedFrameByJob((prev) => prev[jobId] === idx ? { ...prev, [jobId]: null } : prev) - if (wasActive) setActiveJobId(updated.id) - toast.success(`分镜 ${idx + 1} 已删除`) - } catch (e) { - toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) - } - }, [activeJobId, setSelectedFramesForJob, updateJobInList]) - - const handleDeleteFrame = useCallback(async (idx: number) => { - if (!activeJobId) return - await handleDeleteFrameForJob(activeJobId, idx) - }, [activeJobId, handleDeleteFrameForJob]) - - const handleDeleteGenerated = useCallback(async (frameIdx: number, genId: string) => { - if (!activeJobId) return - try { - const updated = await deleteGeneratedImage(activeJobId, frameIdx, genId) - updateJobInList(updated) - toast.success("生成图已删除") - } catch (e) { - toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) - } - }, [activeJobId, updateJobInList]) - - const handleDeleteVideo = useCallback(async (videoId: string) => { - if (!activeJobId) return - try { - const updated = await deleteGeneratedVideo(activeJobId, videoId) - updateJobInList(updated) - toast.success("视频任务已删除") - } catch (e) { - toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) - } - }, [activeJobId, updateJobInList]) - - const handleDeleteJob = useCallback(async (jobId: string) => { - try { - await deleteJob(jobId) - setVideoPanelJobId((prev) => prev === jobId ? null : prev) - setJobs((prev) => { - const idx = prev.findIndex((x) => x.id === jobId) - const next = prev.filter((x) => x.id !== jobId) - if (activeJobId === jobId) { - const fallback = next[idx] ?? next[idx - 1] ?? next[next.length - 1] ?? null - setActiveJobId(fallback?.id ?? null) - } - return next - }) - setSelectedFramesByJob((prev) => { - const { [jobId]: _removed, ...rest } = prev - return rest - }) - setExpandedFrameByJob((prev) => { - const { [jobId]: _removed, ...rest } = prev - return rest - }) - toast.success("输入视频已删除") - } catch (e) { - toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) - } - }, [activeJobId]) - - const handleDeleteCutout = useCallback(async (frameIdx: number, elementId: string, cutoutId: string) => { - if (!activeJobId) return - try { - const updated = await deleteCutout(activeJobId, frameIdx, elementId, cutoutId) - updateJobInList(updated) - toast.success("元素提取图已删除") - } catch (e) { - toast.error("删除失败:" + (e instanceof Error ? e.message : String(e))) - } - }, [activeJobId, updateJobInList]) - - const handleCopyImage = useCallback((ref: ImageRef) => { - setClipboard(ref) - toast.success(`已复制:${ref.label || (ref.kind === "keyframe" ? "关键帧" : "元素")} · 到分镜头编排工作台粘贴`) - }, []) - - const handleTranscribeAudio = useCallback(async (jobId?: string, options?: { silent?: boolean }) => { - const targetId = jobId ?? activeJobId - if (!targetId) return - const target = jobs.find((item) => item.id === targetId) - if (!target) return - if (!target.video_url) { - if (!options?.silent) toast.info("视频导入完成后,可在音频卡片点击提取音频") - return - } - if (isAudioProcessing(target)) { - if (!options?.silent) toast.info("音频正在处理中") - return - } - try { - const updated = await triggerTranscribe(targetId) - updateJobInList(updated) - if (!options?.silent) toast.success("已开始提取音频") - } catch (e) { - if (!options?.silent) toast.error("音频处理启动失败:" + (e instanceof Error ? e.message : String(e))) - } - }, [activeJobId, jobs, updateJobInList]) - - const startProductionLanesForJob = useCallback(async (target: Job) => { - const videoReady = !!target.video_url && VIDEO_READY_STATUSES.includes(target.status) - if (!videoReady) return - - const audioKey = `${target.id}:audio` - const audioFailed = target.audio_script?.status === "failed" - const hasAudioResult = !audioFailed && (!!target.audio_script?.source_text || target.transcript.length > 0) - const audioRunning = isAudioProcessing(target) - if (!hasAudioResult && !audioRunning && !autoTriggeredRef.current.has(audioKey)) { - autoTriggeredRef.current.add(audioKey) - try { - const updated = await triggerTranscribe(target.id) - updateJobInList(updated) - toast.info("音频路已启动:字幕、讲话人、节奏和背景音同步解析") - } catch (e) { - autoTriggeredRef.current.delete(audioKey) - toast.error("音频解析启动失败:" + (e instanceof Error ? e.message : String(e))) - } - } - - const visualKey = `${target.id}:visual` - const hasVisualResult = target.frames.length > 0 - const visualRunning = target.status === "splitting" - if (!hasVisualResult && !visualRunning && !autoTriggeredRef.current.has(visualKey)) { - autoTriggeredRef.current.add(visualKey) - const frameTarget = frameTargets[target.id] ?? "motion" - const frameCount = frameCounts[target.id] ?? 12 - const frameQuality = frameQualities[target.id] ?? "accurate" - try { - const updated = await analyzeJob(target.id, frameCount, frameTarget, "replace", frameQuality) - updateJobInList(updated) - toast.info(`视觉路已启动:${FRAME_QUALITY_LABELS[frameQuality]} · ${FRAME_TARGET_LABELS[frameTarget]} · ${frameCount} 张参考帧`) - } catch (e) { - autoTriggeredRef.current.delete(visualKey) - toast.error("视觉抽帧启动失败:" + (e instanceof Error ? e.message : String(e))) - } - } - }, [frameCounts, frameQualities, frameTargets, updateJobInList]) - - const ensureDefaultProductRefs = useCallback(async (jobId: string) => { - const cached = defaultProductRefsByJob[jobId] - if (cached?.length >= 4) return cached.slice(0, 4) - const refs = await Promise.all(DEFAULT_PRODUCT_LIBRARY_IDS.map((id) => copyProductLibraryAsset(jobId, id))) - setDefaultProductRefsByJob((prev) => ({ ...prev, [jobId]: refs })) - return refs - }, [defaultProductRefsByJob]) - - const buildPlannedScene = useCallback((targetJob: Job, frame: KeyFrame, order: number): StoryboardScene => { - const frames = [...targetJob.frames].sort((a, b) => a.timestamp - b.timestamp) - const nextFrame = frames.find((item) => item.timestamp > frame.timestamp) ?? null - const totalDuration = Math.max(targetJob.duration || 0, frames.length * 5, 5) - const duration = Math.max(3.5, Math.min(7.5, totalDuration / Math.max(frames.length, 1))) - const audioLine = targetJob.audio_script?.rewritten_text?.trim() - || targetJob.transcript?.slice(0, 4).map((item) => item.en || item.zh).filter(Boolean).join(" ") - || "按原视频说话节奏生成 SKG 产品口播。" - const sceneText = frame.description?.scene?.trim() - || `参考原视频第 ${order + 1} 个关键画面,建立一个可复刻的信息流广告分镜。` - const objectText = frame.description?.objects?.slice(0, 5).map((item) => item.name).filter(Boolean).join("、") - return { - duration: Number(duration.toFixed(1)), - first_image: { kind: "keyframe", frame_idx: frame.index, label: `分镜 ${order + 1} 首帧` }, - last_image: nextFrame ? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${order + 1} 尾帧` } : null, - subject: objectText ? `优先保留并改造这些可选关键元素:${objectText}。` : "保留原视频里最能驱动剧情的主体动作和镜头关系。", - scene: `${sceneText}\n音频节奏依据:${audioLine.slice(0, 220)}`, - product: "把这一镜改成 SKG 颈部/肩颈按摩仪的信息流广告表达。默认使用 SKG 四张真实产品角度图作为产品真源,产品必须外置佩戴在肩颈位置,不要变成其他物体。", - action: frame.description?.style - ? `沿用原画面的镜头节奏和 ${frame.description.style},动作要从首帧自然过渡到尾帧,突出使用前紧绷、使用后放松。` - : "沿用原视频的讲话/动作节奏,动作要从首帧自然过渡到尾帧,突出使用前紧绷、使用后放松。", - reference_ids: [], - } - }, []) - - const handlePlanStoryboardJob = useCallback(async (jobId: string) => { - if (planningJobIds.has(jobId)) return - const initial = jobs.find((item) => item.id === jobId) - if (!initial || initial.frames.length === 0) return - setPlanningJobIds((prev) => new Set(prev).add(jobId)) - try { - let latest = initial - const frames = [...latest.frames].sort((a, b) => a.timestamp - b.timestamp) - toast.info(`开始扫描关键元素 · ${frames.length} 个分镜`) - for (let order = 0; order < frames.length; order += 1) { - const frame = frames[order] - let currentFrame = latest.frames.find((item) => item.index === frame.index) ?? frame - if (!currentFrame.description) { - latest = await describeFrame(jobId, frame.index) - updateJobInList(latest) - currentFrame = latest.frames.find((item) => item.index === frame.index) ?? currentFrame - } - if (!currentFrame.storyboard) { - const planned = buildPlannedScene(latest, currentFrame, order) - latest = await updateStoryboard(jobId, frame.index, planned) - updateJobInList(latest) - } - } - toast.success("关键元素扫描和分镜初稿已生成") - } catch (e) { - toast.error("分镜规划失败:" + (e instanceof Error ? e.message : String(e))) - } finally { - setPlanningJobIds((prev) => { - const next = new Set(prev) - next.delete(jobId) - return next - }) - } - }, [buildPlannedScene, jobs, planningJobIds, updateJobInList]) - - const handleStartProduction = useCallback(async (inputUrl?: string) => { - const trimmed = inputUrl?.trim() - const created = trimmed ? await handleSubmit(trimmed) : undefined - let target = created ?? job - if (!target) { - toast.info("先粘贴视频链接或选择一个素材任务") - return - } - if (!created && target.status === "failed") { - autoTriggeredRef.current.delete(`${target.id}:audio`) - autoTriggeredRef.current.delete(`${target.id}:visual`) - } - if (!created && target.status === "failed" && !target.video_url) { - try { - target = await retryJobDownload(target.id) - updateJobInList(target) - toast.info("已重新提交下载;下载完成后会自动跑音频文案路和视觉抽帧路") - } catch (e) { - toast.error("重新下载失败:" + (e instanceof Error ? e.message : String(e))) - return - } - } - setProductionJobIds((prev) => new Set(prev).add(target.id)) - if (target.video_url) toast.success("已进入并行素材分析:音频文案路和视觉抽帧路会同步推进") - else toast.success("已进入并行素材分析:下载完成后自动跑音频文案路和视觉抽帧路") - void startProductionLanesForJob(target) - }, [handleSubmit, job, startProductionLanesForJob, updateJobInList]) + const latestImage = latestGeneratedImage(job) + const generatedVideos = useMemo(() => job?.generated_videos ?? [], [job]) + const hasRunningVideo = generatedVideos.some((item) => item.status === "queued" || item.status === "in_progress") useEffect(() => { - if (productionJobIds.size === 0) return - for (const item of jobs) { - if (!productionJobIds.has(item.id)) continue - const videoReady = !!item.video_url && VIDEO_READY_STATUSES.includes(item.status) - if (!videoReady) continue - void startProductionLanesForJob(item) + let cancelled = false + listJobs(8) + .then((items) => { + if (!cancelled) setRecentJobs(items) + }) + .catch(() => { + if (!cancelled) setRecentJobs([]) + }) + return () => { + cancelled = true } - }, [jobs, productionJobIds, startProductionLanesForJob]) + }, [job?.id]) - const handleQuickGenerateVideo = useCallback(async (frameIdx: number, scene: StoryboardScene, model: string) => { - if (DIRECT_VIDEO_GENERATION_PAUSED) { - toast.info("视频生成调用已暂停:先生成并审核每条分镜的首帧/尾帧,再开放单条提交") + useEffect(() => { + if (!referenceFile) { + setReferencePreview("") return } - if (!job) return - const frame = job.frames.find((f) => f.index === frameIdx) - if (!frame) return + const url = URL.createObjectURL(referenceFile) + setReferencePreview(url) + return () => URL.revokeObjectURL(url) + }, [referenceFile]) - const labelOf = (ref?: ImageRef | null, fallback = "未提供") => ref?.label || fallback - const keyframeRef: ImageRef = { - kind: "keyframe", - frame_idx: frameIdx, - label: `分镜 ${frameIdx + 1} 首帧`, - } - const orderedSelected = job.frames - .filter((f) => selectedFrames.size === 0 || selectedFrames.has(f.index)) - .sort((a, b) => a.timestamp - b.timestamp) - const nextFrame = orderedSelected.find((f) => f.timestamp > frame.timestamp) ?? null - const defaultLastRef: ImageRef | null = nextFrame - ? { kind: "keyframe", frame_idx: nextFrame.index, label: `分镜 ${nextFrame.index + 1} 尾帧` } - : null - const firstRef = scene.first_image ?? keyframeRef - const lastRef = scene.last_image ?? defaultLastRef - const needsProduct = storyboardNeedsProduct(scene) - const needsSubject = storyboardNeedsSubject(scene) - let productRefs = needsProduct ? (scene.product_images?.length ? scene.product_images : scene.product_image ? [scene.product_image] : []) : [] - if (needsProduct && productRefs.length === 0) { + useEffect(() => { + if (!job || !hasRunningVideo) return + const timer = window.setInterval(async () => { try { - productRefs = await ensureDefaultProductRefs(job.id) - } catch (e) { - toast.error("默认 SKG 产品图准备失败:" + (e instanceof Error ? e.message : String(e))) - return + setJob(await getJob(job.id)) + } catch { + window.clearInterval(timer) } - } - const subjectRefs: ImageRef[] = needsSubject ? (frame.elements ?? []) - .flatMap((element) => element.subject_assets ?? []) - .slice(0, 6) - .map((asset) => ({ - kind: "asset", - frame_idx: frameIdx, - element_id: asset.id, - cutout_id: asset.id, - label: asset.label, - })) : [] - const primarySubjectRef = needsSubject ? (subjectRefs[0] ?? firstRef) : null - const duration = scene.duration && scene.duration > 0 ? scene.duration : 5 - const sourceScene = frame.description?.scene ? `参考画面识别:${frame.description.scene}` : "" - const sourceStyle = frame.description?.style ? `参考风格:${frame.description.style}` : "" - const sourceObjects = frame.description?.objects?.length - ? `参考元素:${frame.description.objects.slice(0, 6).map((o) => o.name).join("、")}` - : "" - const subjectDirection = scene.subject?.trim() - || "保留首尾帧里的主体位置、手部动作关系和镜头调度;如果参考主体是人形骷髅、透明骨骼人或卡通骨骼角色,可以保留为视觉隐喻,让它正确佩戴 SKG 颈部按摩仪后状态变好。不要做恐怖、血腥或严肃医疗治疗画面。" - const productDirection = scene.product?.trim() - || "以已上传 SKG 产品图为唯一产品真源,把参考视频或首尾帧里的任何非 SKG 产品替换成该产品;产品轮廓、颜色、材质、按键/接口/包装比例必须稳定,不要变成其他物体。" - const sceneDirection = scene.scene?.trim() - || "借鉴参考画面的构图、可信感和空间层次,但改造成适合 SKG 产品广告的现代家居、办公或零售场景。" - const actionDirection = scene.action?.trim() - || "按首帧到尾帧做平滑过渡,动作连续自然,镜头运动稳定,最后准确停在尾帧意图。" - const productNature = [ - "产品性质:这是 SKG 白色 U 形可穿戴颈部/肩颈按摩仪,不是药品、护肤品、饮料、瓶罐、医疗器械镜头道具或普通项链。", - "正确使用方式:产品应佩戴/环绕在人的脖子和肩颈位置,U 形机身落在肩颈两侧,内侧金属按摩触点贴合后颈或肩颈肌肉区域。", - "可表现的交互:手拿起产品、展开并戴到脖子上、轻按侧边圆形按键/控制区、轻微调整贴合位置、闭眼放松、肩颈从紧绷变舒展。", - "效果表达:使用后状态变好,表现为颈肩放松、姿态更自然、表情舒缓、精神恢复;如果主体是人形骷髅,可以通过放下揉脖子的手、抬头、肩颈舒展、表情/动作变轻松来表现改善。不要表现治愈疾病、骨骼修复、皮肤祛痘或夸张医疗效果。", - ].join("\n") - const prompt = [ - `竖屏 9:16,${duration.toFixed(1)} 秒,SKG 产品短视频广告。`, - needsProduct - ? productNature - : "本条分镜规划为非产品主镜头:可以只拍人物状态、场景过渡、情绪停点或节奏承接。不要硬插 SKG 产品、白底产品图、包装或任何随机商品。", - needsProduct && productRefs.length - ? `已上传 ${productRefs.length} 张 SKG 真实产品参考图。产品参考图是唯一产品真源:视频中出现的产品必须严格匹配这些图的外观、颜色、材质、结构比例和关键细节。` - : needsProduct - ? "未上传产品图时,仍需生成一个干净高级的 SKG 产品广告画面,但不得保留原视频里的竞品包装或平台元素。" - : "本条不传产品参考图;如首尾帧里出现竞品、包装或非 SKG 商品,应弱化、移除或作为模糊背景,不要替换成 SKG 产品。", - needsProduct - ? "首帧和尾帧只用于控制画面起止、构图、场景和动作方向;如果首尾帧里有竞品、文字包装或非 SKG 产品,必须替换为上传的 SKG 产品参考。" - : "首帧和尾帧用于控制画面起止、构图、场景和动作方向;本条没有产品任务,不要因为广告语而自动添加产品。", - "使用首帧和尾帧生成连续过渡视频:首帧必须严格作为视频开始画面,尾帧必须作为视频结束目标画面,中间只做自然运动补间。", - "生成一段单镜头连续视频,一镜到底,从首帧平滑过渡到尾帧;不要跳切,不要突然换场景,不要突然换主体,不要蒙太奇,不要多镜头拼接。", - "如果提供了原视频链接,把它只作为节奏、镜头运动、动作顺序和画面调度参考;不要照搬原视频里的品牌、文字、水印、竞品产品或具体人物。", - "时间线:0%-15% 锁住首帧构图并轻微启动;15%-85% 做平滑连续运动;85%-100% 缓慢贴近尾帧并稳定收住。", - `镜头类型:${scene.visual_mode ?? "未标注"};需要人物=${needsSubject ? "是" : "否"};需要产品=${needsProduct ? "是" : "否"}。`, - scene.first_frame_plan ? `首帧规划:${scene.first_frame_plan}` : "", - scene.last_frame_plan ? `尾帧规划:${scene.last_frame_plan}` : "", - scene.product_placement ? `产品出现方式:${scene.product_placement}` : "", - needsSubject - ? TRANSPARENT_HUMAN_VIDEO_PROMPT - : "本条不传人物主体参考图;如果画面需要人物,只能作为背景、手部局部或模糊生活方式元素,不要生成主角式透明骨架人。", - `主体改造:${subjectDirection}`, - needsProduct - ? `产品替换:${productDirection} 产品必须作为颈部/肩颈按摩仪被正确佩戴或展示,不要放在脸上、手臂上、桌面当摆件,也不要变成瓶子、面霜、医疗设备或食品。` - : `产品处理:${productDirection} 本条不需要露出 SKG 产品,不要硬插产品、包装、瓶罐、医疗器械或随机商品。`, - `场景改造:${sceneDirection}`, - `连续动作和镜头:${actionDirection}`, - `首帧:${labelOf(firstRef, "当前分镜关键帧")}`, - `尾帧:${labelOf(lastRef, "未指定,按首帧小幅自然运动收尾")}`, - needsProduct ? `SKG 产品参考:${productRefs.length ? productRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "SKG 产品角度")}`).join(";") : "SKG 产品视觉主角"}` : "SKG 产品参考:本条不使用产品参考图。", - needsSubject - ? (subjectRefs.length ? `关键元素 6 视图参考:${subjectRefs.map((ref, i) => `${i + 1}. ${labelOf(ref, "元素视图")}`).join(";")}` : "如果该分镜还没有关键元素 6 视图,优先使用首帧主体关系生成。") - : "关键元素 6 视图参考:本条不使用人物主体参考图。", - sourceScene, - sourceStyle, - sourceObjects, - needsProduct ? "产品一致性要求:整个视频只能出现同一个白色 U 形 SKG 颈部按摩仪或同一套包装;不要生成第二种产品,不要改变 U 形机身、金属按摩触点、侧边按键、白色材质和整体比例,不要凭空增加屏幕、线缆、文字标签或说明书。" : "", - needsProduct ? "产品呈现要求:至少一次让产品在脖子/肩颈位置清晰占据视觉中心,边缘清楚、材质真实、比例可信;手部接触产品时不要遮挡关键外观,产品不能融化、扭曲、穿帮或漂移。" : "", - needsSubject || needsProduct ? "状态改善要求:画面应形成明确的使用前后感受变化:使用前可以是低头久坐、揉脖子、肩颈疲惫或紧绷;使用后变为肩颈放松、抬头、动作舒展、精神更好。人形骷髅也可以表现为从僵硬难受变轻松放松。表达舒缓和放松,不要承诺治疗。" : "节奏要求:作为过渡镜头时只负责情绪、空间和节奏承接,不承诺疗效,不强行展示使用动作。", - needsProduct ? "运动要求:动作幅度小而连续,速度均匀,手部和产品位置前后一致,产品外形不变形,人物表情和姿态不漂移,背景只允许轻微景深和光影变化。" : "运动要求:动作幅度小而连续,速度均匀,构图从首帧自然过渡到尾帧,不突然添加人物或产品。", - "商业质感:真实拍摄感,干净高级,柔和稳定打光,产品边缘清晰,材质真实,画面无抖动、无拉伸、无闪烁。", - "禁止:字幕、文字、平台 UI、TikTok 水印、logo 水印、免责声明、竞品包装、随机新物体、非 SKG 产品、医学骨架、夸张病症画面、恐怖元素、画面撕裂、人物或产品突然变形。", - TRANSPARENT_HUMAN_NEGATIVE_PROMPT, - ].join("\n") + }, 2600) + return () => window.clearInterval(timer) + }, [job, hasRunningVideo]) + const ensureJob = useCallback(async () => { + if (job) return job + const created = await createCreativeImageJob(referenceFile) + setJob(created) + return created + }, [job, referenceFile]) + + const onFileChange = (file: File | null) => { + setReferenceFile(file) + setJob(null) + setError("") + } + + const runImage = async () => { + if (!prompt.trim()) { + toast.error("先写创作要求") + return + } + setBusy("image") + setError("") try { - toast.info(`已提交 ${model} 生视频 · 分镜 ${frameIdx + 1}`) - const sourceUrl = job.url?.trim() - const updated = await generateStoryboardVideo(job.id, frameIdx, { + const target = await ensureJob() + const updated = await generateImage(target.id, 0, { prompt, - duration, - first_image: firstRef, - last_image: lastRef, - product_images: productRefs, - subject_image: primarySubjectRef, - subject_images: subjectRefs, - scene_image: null, - product_image: needsProduct ? (productRefs[0] ?? null) : null, - action_image: null, - source_ref: sourceUrl ? { kind: "source_video", url: sourceUrl } : null, - model, + mode: referenceFile ? "edit" : "text", + }) + setJob(updated) + toast.success("图片已生成") + } catch (e) { + const message = e instanceof Error ? e.message : "生图失败" + setError(message) + toast.error(message) + } finally { + setBusy(null) + } + } + + const runVideo = async () => { + if (!prompt.trim()) { + toast.error("先写创作要求") + return + } + setBusy("video") + setError("") + try { + const target = await ensureJob() + const updated = await generateStoryboardVideo(target.id, 0, { + prompt, + duration: seconds, + count: 1, + first_image: { kind: "keyframe", frame_idx: 0 }, size: "720x1280", }) - updateJobInList(updated) - void navigator.clipboard?.writeText(prompt).catch(() => {}) - toast.success("视频任务已进入候选片段") + setJob(updated) + toast.success("视频已提交") } catch (e) { - toast.error("提交视频失败:" + (e instanceof Error ? e.message : String(e))) + const message = e instanceof Error ? e.message : "生视频失败" + setError(message) + toast.error(message) + } finally { + setBusy(null) } - }, [ensureDefaultProductRefs, job, selectedFrames, updateJobInList]) + } - const handleGenerateProductFusionVideo = useCallback(async (frameIdx: number, shot: ProductFusionShot) => { - if (DIRECT_VIDEO_GENERATION_PAUSED) { - toast.info("视频生成调用已暂停:当前只做首尾帧和素材规划") + const runCopy = async () => { + if (!prompt.trim()) { + toast.error("先写文案目标") return } - if (!job) return - const frame = job.frames.find((f) => f.index === frameIdx) - if (!frame) return - const productRefs = (shot.product_images ?? []).filter(Boolean).slice(0, 4) as ImageRef[] - const subjectRefs = (shot.subject_images ?? []).filter(Boolean).slice(0, 7) as ImageRef[] - const primarySubject = shot.subject_image ?? subjectRefs[0] ?? null - if (!primarySubject || subjectRefs.length < 1 || productRefs.length < 4 || !shot.action_text?.trim()) { - toast.error("产品融合镜头缺少内置角色、固定产品图或描述词") - return - } - const duration = shot.duration && shot.duration > 0 ? shot.duration : 5 - const labelOf = (ref?: ImageRef | null, fallback = "未提供") => ref?.label || fallback + setBusy("copy") + setError("") try { - const prompt = [ - `产品融合镜头ID:${shot.id || `shot-${frameIdx + 1}`}`, - `竖屏 9:16,${duration.toFixed(1)} 秒,Seedance 参考图生视频。`, - "没有首帧和尾帧:请根据内置人物角色参考图、固定 SKG 产品图、场景/使用/享受描述直接生成完整视频。", - `人物角色:${shot.character_name || "透明骨架人"}。必须保持同一透明/半透明人体外壳、干净白色骨架、体型比例、服装风格和非恐怖广告气质。`, - `人物参考图:${subjectRefs.map((ref, index) => `角色图${index + 1}=${labelOf(ref, "透明骨架人参考")}`).join(";")}。`, - `产品角度图 1:${labelOf(productRefs[0], "SKG 产品正面/主视角")}。`, - `产品角度图 2:${labelOf(productRefs[1], "SKG 产品侧面/斜侧视角")}。`, - `产品角度图 3:${labelOf(productRefs[2], "SKG 产品背面/细节视角")}。`, - `产品角度图 4:${labelOf(productRefs[3], "SKG 产品补充/底部或佩戴视角")}。`, - PRODUCT_FUSION_PRODUCT_IDENTITY_PROMPT, - PRODUCT_FUSION_WEARING_PROMPT, - "Camera direction: follow the scene description, but always stage the product as a separate external wearable object. Show the hands placing or adjusting the device on the outside of the neck; do not imply the product is merged with the transparent body.", - `场景/使用/享受描述:${shot.action_text.trim()}`, - TRANSPARENT_HUMAN_VIDEO_PROMPT, - "Fusion rule: this is product placement, not body fusion. The product is an opaque physical device worn outside the body with believable contact shadow, occlusion, scale, and perspective.", - "连续性:镜头必须完整连贯,中间不要跳切,不换角色,不换产品,不突然改变场景。", - "场景要求:背景、空间、光线和阴影要自然统一,不要出现水印、平台 UI、字幕或竞品包装。", - "商业质感:真实拍摄感、干净高级、产品清楚可辨、人物动作自然、镜头稳定。", - PRODUCT_FUSION_NEGATIVE_PROMPT, - "禁止:文字、水印、随机品牌、非 SKG 产品、医学治疗承诺、夸张病症、恐怖元素、产品位置漂移、透明衣服但非透明身体。", - TRANSPARENT_HUMAN_NEGATIVE_PROMPT, - ].join("\n") - const updated = await generateStoryboardVideo(job.id, frameIdx, { - prompt, - duration, - first_image: null, - last_image: null, - product_images: productRefs, - subject_image: null, - subject_images: subjectRefs, - scene_image: null, - product_image: productRefs[0] ?? null, - action_image: null, - source_ref: null, - model: "seedance", - size: "720x1280", + const result = await generateCreativeCopy({ + goal: prompt, + product, + audience, + tone, + seconds, }) - updateJobInList(updated) - void navigator.clipboard?.writeText(prompt).catch(() => {}) - toast.success("产品融合视频已进入 Video Gen 队列") + setCopyVariants(result.variants) + toast.success("文案已生成") } catch (e) { - toast.error("产品融合生成失败:" + (e instanceof Error ? e.message : String(e))) + const message = e instanceof Error ? e.message : "写文案失败" + setError(message) + toast.error(message) + } finally { + setBusy(null) } - }, [job, updateJobInList]) + } - // 启动恢复:URL ?job=xxx,yyy 优先;否则从后端拉全部历史(按 mtime 倒序,最新放末尾) - useEffect(() => { - const params = new URLSearchParams(window.location.search) - const ids = (params.get("job") ?? "").split(",").filter(Boolean) - const restore = async () => { - let targetIds = ids - if (targetIds.length === 0) { - try { - const list = await listJobs() - targetIds = list.map((s) => s.id).reverse() - } catch { - return - } - } - if (targetIds.length === 0) return - const results = await Promise.all(targetIds.map((id) => getJob(id).catch(() => null))) - const valid = results.filter((j): j is Job => !!j) - if (valid.length > 0) { - setJobs(valid) - setActiveJobId(valid[valid.length - 1].id) - } + const runPrimary = () => { + if (mode === "image") return runImage() + if (mode === "copy") return runCopy() + return runVideo() + } + + const deleteImage = async (image: GeneratedImage) => { + if (!job) return + try { + setJob(await deleteGeneratedImage(job.id, 0, image.id)) + toast.success("图片已删除") + } catch (e) { + toast.error(e instanceof Error ? e.message : "删除失败") } - void restore() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + } - // 写回 URL(所有 jobs id 用 , 分隔) - useEffect(() => { - const url = new URL(window.location.href) - if (jobs.length > 0) { - url.searchParams.set("job", jobs.map((j) => j.id).join(",")) - } else { - url.searchParams.delete("job") + 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 : "删除失败") } - window.history.replaceState({}, "", url.toString()) - }, [jobs.length]) + } - // 恢复已保存的分镜选择:每个视频自己的 storyboard 帧仍保留在自己的编排上下文里。 - useEffect(() => { - if (jobs.length === 0) return - setSelectedFramesByJob((prev) => { - let changed = false - const nextByJob = { ...prev } - for (const item of jobs) { - const persisted = item.frames.filter((f) => !!f.storyboard).map((f) => f.index) - if (persisted.length === 0) continue - const next = new Set(nextByJob[item.id] ?? []) - for (const idx of persisted) { - if (!next.has(idx)) { - next.add(idx) - changed = true - } - } - nextByJob[item.id] = [...next].sort((a, b) => a - b) - } - return changed ? nextByJob : prev - }) - }, [jobs]) - - // 轮询 Job:任一视频在下载 / 抽帧 / 生视频时都继续轮询,支持多个抽帧任务排队。 - const prevStatusRef = useRef(null) - useEffect(() => { - if (jobs.length === 0) return - // 状态切到 downloaded 时提示用户点解析(仅一次) - if (job?.status === "downloaded" && prevStatusRef.current !== "downloaded") { - toast.info("视频已下载,音频解析会自动开始;也可以在右侧手动重试", { duration: 6000 }) + const copyText = async (text: string) => { + try { + await navigator.clipboard.writeText(text) + toast.success("已复制") + } catch { + toast.error("复制失败") } - if (job?.status === "failed" && prevStatusRef.current !== "failed") { - toast.error(formatJobError(job.error) || "任务失败", { duration: 10000 }) - } - prevStatusRef.current = job?.status ?? null + } - const TERMINAL: Job["status"][] = ["downloaded", "frames_extracted", "transcribed", "failed"] - const runningIds = jobs - .filter((item) => { - const runningVideo = !!item.generated_videos?.some((v) => v.status === "queued" || v.status === "in_progress") - const runningAudio = item.audio_script?.status === "rewriting" - const runningSubject = item.frames.some((frame) => - frame.elements?.some((element) => - element.subject_assets?.some((asset) => asset.status === "queued" || asset.status === "in_progress"), - ), - ) - return runningVideo || runningAudio || runningSubject || !TERMINAL.includes(item.status) - }) - .map((item) => item.id) + const useVariant = (variant: CreativeCopyVariant, nextMode: CreatorMode) => { + setMode(nextMode) + setPrompt(nextMode === "image" ? variant.image_prompt_en : variant.video_prompt_en) + } - if (runningIds.length === 0) { - if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null } - return - } - pollRef.current = setInterval(async () => { - try { - const latestJobs = await Promise.all(runningIds.map((id) => getJob(id).catch(() => null))) - const byId = new Map(latestJobs.filter((item): item is Job => !!item).map((item) => [item.id, item])) - if (byId.size > 0) { - setJobs((prev) => prev.map((item) => byId.get(item.id) ?? item)) - } - } catch { /* silent */ } - }, 1500) - return () => { if (pollRef.current) clearInterval(pollRef.current) } - }, [ - job?.id, - job?.status, - jobs.map((item) => { - const subjectState = item.frames.flatMap((frame) => - frame.elements?.flatMap((element) => - element.subject_assets?.map((asset) => `${asset.id}:${asset.status ?? "completed"}:${asset.progress ?? 100}:${asset.url ?? ""}`) ?? [], - ) ?? [], - ).join(",") - return `${item.id}:${item.status}:${item.progress}:${item.audio_script?.status ?? ""}:${item.audio_script?.voice_url ?? ""}:${item.generated_videos?.map((v) => `${v.id}:${v.status}:${v.progress}`).join(",")}:${subjectState}` - }).join("|"), - ]) - - const [pinnedNodes, setPinnedNodes] = useState>(() => new Set(loadNodePins())) - const handleToggleNodePin = useCallback((id: string) => { - setPinnedNodes((prev) => { - const next = new Set(prev) - if (next.has(id)) next.delete(id); else next.add(id) - try { window.localStorage.setItem(NODE_PINS_KEY, JSON.stringify([...next])) } catch {} - return next - }) - }, []) - - const nodeData: NodeData = useMemo(() => ({ - job, - jobs, - activeJobId, - submitting, - analyzing, - frameTargets, - frameCounts, - frameQualities, - selectedFrames, - expandedFrame, - framePanelScale, - framePanelPinned, - framePanelDock, - videoPanelJobId, - videoPanelScale, - videoPanelDock, - onSubmitUrl: handleSubmit, - onStartProduction: handleStartProduction, - onUploadFile: handleUpload, - onAnalyze: handleAnalyze, - onAnalyzeJob: handleAnalyzeJob, - onFrameTargetChange: handleFrameTargetChange, - onFrameCountChange: handleFrameCountChange, - onFrameQualityChange: handleFrameQualityChange, - onToggleFrame: handleToggleFrame, - onExpandFrame: handleOpenFramePanel, - onOpenFramePanel: handleOpenFramePanel, - onFramePanelScaleChange: handleFramePanelScaleChange, - onFramePanelPinnedChange: (pinned: boolean) => setFramePanelDock(pinned ? "left" : "canvas"), - onFramePanelDockChange: setFramePanelDock, - onCloseExpandedFrame: handleCloseExpandedFrame, - onAddManualFrame: handleAddManualFrame, - onAddManualFrameForJob: handleAddManualFrameForJob, - onOpenVideoPanel: handleOpenVideoPanel, - onCloseVideoPanel: () => setVideoPanelJobId(null), - onVideoPanelScaleChange: handleVideoPanelScaleChange, - onVideoPanelDockChange: setVideoPanelDock, - onSwitchJob: handleSwitchJob, - onJobUpdate: updateJobInList, - onDeleteJob: handleDeleteJob, - onDeleteFrame: handleDeleteFrame, - onDeleteFrameForJob: handleDeleteFrameForJob, - onDeleteGenerated: handleDeleteGenerated, - onDeleteVideo: handleDeleteVideo, - onDeleteCutout: handleDeleteCutout, - onOpenStoryboard: handleOpenStoryboard, - onOpenWorkbench: handleOpenWorkbench, - clipboard, - onCopyImage: handleCopyImage, - onGenerateProductFusionVideo: handleGenerateProductFusionVideo, - onTranscribeAudio: handleTranscribeAudio, - pinnedNodes, - onToggleNodePin: handleToggleNodePin, - }), [job, jobs, activeJobId, submitting, analyzing, frameTargets, frameCounts, frameQualities, selectedFrames, expandedFrame, framePanelScale, framePanelPinned, framePanelDock, videoPanelJobId, videoPanelScale, videoPanelDock, handleSubmit, handleStartProduction, handleUpload, handleAnalyze, handleAnalyzeJob, handleFrameTargetChange, handleFrameCountChange, handleFrameQualityChange, handleToggleFrame, handleOpenFramePanel, handleFramePanelScaleChange, handleCloseExpandedFrame, handleAddManualFrame, handleAddManualFrameForJob, handleOpenVideoPanel, handleVideoPanelScaleChange, handleSwitchJob, updateJobInList, handleDeleteJob, handleDeleteFrame, handleDeleteFrameForJob, handleDeleteGenerated, handleDeleteVideo, handleDeleteCutout, handleOpenStoryboard, handleOpenWorkbench, clipboard, handleCopyImage, handleGenerateProductFusionVideo, handleTranscribeAudio, pinnedNodes, handleToggleNodePin]) - - // 用 useNodesState 让 ReactFlow 自己管位置(避免轮询时重置 drag) - const savedSizes = useMemo(() => loadNodeSizes(), []) - const [nodes, setNodes] = useNodesState( - LAYOUT.map((n) => { - const s = savedSizes[n.id] ?? {} - const w = s.w ?? n.w - const h = s.h - const isPinned = pinnedNodes.has(n.id) - return { - id: n.id, - type: n.type, - position: { x: n.x, y: n.y }, - data: nodeData as FlowNodeData, - draggable: !isPinned, - width: w, - ...(typeof h === "number" ? { height: h } : {}), - style: { width: w, ...(typeof h === "number" ? { height: h } : {}) }, - } - }), - ) - - // pinned 变化时同步每个节点 draggable - useEffect(() => { - setNodes((prev) => prev.map((n) => - FLOATING_PANEL_IDS.has(n.id) ? n : { ...n, draggable: !pinnedNodes.has(n.id) }, - )) - }, [pinnedNodes, setNodes]) - - // 首次挂载、所有节点都被 ReactFlow 测量到后,自动整理一次(用户偏好:每次刷新自动归位) - const initialLayoutDone = useRef(false) - - // 自动排版:保留每个节点的当前宽高(用户为方便看而调过的尺寸),只重新计算 position - // 让卡片按管线列分组、列间和列内留出统一间距,不重叠。 - const handleResetLayout = useCallback(() => { - const zoom = flowRef.current?.getZoom?.() ?? 1 - const measure = (id: string): { w: number; h: number } => { - const el = document.querySelector(`.react-flow__node[data-id="${id}"]`) as HTMLElement | null - if (!el) return { w: 320, h: 220 } - const r = el.getBoundingClientRect() - return { w: r.width / zoom, h: r.height / zoom } - } - - // 按管线列分组(顶 → 底):图层 1 输入 → 5 合成 - const COLUMNS: string[][] = [ - ["input"], - ["visual", "audio"], - ["compose"], - ] - const GAP_X = 80 - const GAP_Y = 56 - const START_X = 40 - const START_Y = 60 - - const positions: Record = {} - let cursorX = START_X - for (const col of COLUMNS) { - let cursorY = START_Y - let colMaxW = 0 - for (const id of col) { - const { w, h } = measure(id) - positions[id] = { x: cursorX, y: cursorY } - cursorY += h + GAP_Y - if (w > colMaxW) colMaxW = w - } - cursorX += colMaxW + GAP_X - } - - setNodes((prev) => prev.map((n) => { - if (FLOATING_PANEL_IDS.has(n.id)) return n - const p = positions[n.id] - if (!p) return n - return { ...n, position: { x: p.x, y: p.y } } - })) - setTimeout(() => flowRef.current?.fitView?.({ padding: 0.12, duration: 400 }), 30) - toast.success("已自动排版 · 保留每个节点的尺寸") - }, [setNodes]) - - // 首次:等所有节点都被 ReactFlow 测量到(n.measured 出现)后自动排版一次,避免叠在一起 - useEffect(() => { - if (initialLayoutDone.current) return - const main = nodes.filter((n) => !FLOATING_PANEL_IDS.has(n.id)) - if (main.length === 0) return - const allMeasured = main.every((n) => { - const m = (n as any).measured as { width?: number; height?: number } | undefined - return m && typeof m.width === "number" && typeof m.height === "number" && m.height > 0 - }) - if (!allMeasured) return - initialLayoutDone.current = true - setTimeout(() => handleResetLayout(), 80) - }, [nodes, handleResetLayout]) - - // 持久化每个节点宽 / 高到 localStorage(KeyframePanelNode 自己管尺寸,不写回) - useEffect(() => { - const sizes: Record = {} - for (const n of nodes) { - if (FLOATING_PANEL_IDS.has(n.id)) continue - const w = typeof n.width === "number" ? Math.round(n.width) : undefined - const h = typeof n.height === "number" ? Math.round(n.height) : undefined - if (w === undefined && h === undefined) continue - sizes[n.id] = { ...(w !== undefined ? { w } : {}), ...(h !== undefined ? { h } : {}) } - } - try { window.localStorage.setItem(NODE_SIZES_KEY, JSON.stringify(sizes)) } catch {} - }, [nodes]) - const [, setEdges] = useEdgesState( - EDGES_RAW.map(([from, to], i) => ({ - id: `e${i}`, source: from, target: to, animated: false, type: "default", - })), - ) - - // Job 数据变化时只更新节点 data 不动 position - useEffect(() => { - setNodes((prev) => prev.map((n) => ({ ...n, data: nodeData as FlowNodeData }))) - }, [nodeData, setNodes]) - - // 关键帧详情面板是独立 ReactFlow 节点:可拖动、跟随画布缩放。 - // 已打开时点击其他关键帧只切换内容,不移动用户拖好的面板位置。 - useEffect(() => { - if (!job || expandedFrame === null) { - setNodes((prev) => prev.filter((n) => n.id !== KEYFRAME_PANEL_ID)) - return - } - - let shouldFocusNewPanel = false - setNodes((prev) => { - const visualNode = prev.find((n) => n.id === "visual") - const inputNode = prev.find((n) => n.id === "input") - const defaultPosition = { - x: (inputNode?.position.x ?? 40) - 820, - y: (visualNode?.position.y ?? 60), - } - const exists = prev.some((n) => n.id === KEYFRAME_PANEL_ID) - if (exists) { - return prev.map((n) => n.id === KEYFRAME_PANEL_ID - ? { - ...n, - data: nodeData as FlowNodeData, - draggable: !framePanelPinned, - dragHandle: framePanelPinned ? undefined : ".keyframe-panel-drag", - } - : n, - ) - } - shouldFocusNewPanel = true - return [ - ...prev, - { - id: KEYFRAME_PANEL_ID, - type: "keyframePanel", - position: defaultPosition, - data: nodeData as FlowNodeData, - draggable: !framePanelPinned, - dragHandle: framePanelPinned ? undefined : ".keyframe-panel-drag", - selectable: true, - }, - ] - }) - if (shouldFocusNewPanel && !framePanelPinned) { - window.setTimeout(() => { - flowRef.current?.fitView?.({ - nodes: [{ id: KEYFRAME_PANEL_ID }, { id: "visual" }], - padding: 0.18, - duration: 260, - }) - }, 0) - } - }, [job?.id, expandedFrame, framePanelPinned, nodeData, setNodes]) - - // 视频抽帧面板也是独立 ReactFlow 节点:默认在 Input 附近打开,可拖动;吸附后走 portal 固定到屏幕边缘。 - useEffect(() => { - const panelJob = videoPanelJobId ? jobs.find((j) => j.id === videoPanelJobId) ?? null : null - if (!panelJob?.video_url) { - setNodes((prev) => prev.filter((n) => n.id !== VIDEO_FRAME_PANEL_ID)) - return - } - - const focusKey = `${videoPanelJobId}:${videoPanelOpenTick}:${videoPanelDock}` - let panelWasCreated = false - setNodes((prev) => { - const inputNode = prev.find((n) => n.id === "input") - const defaultPosition = { - x: inputNode?.position.x ?? 40, - y: (inputNode?.position.y ?? 240) - 650, - } - const exists = prev.some((n) => n.id === VIDEO_FRAME_PANEL_ID) - if (exists) { - return prev.map((n) => n.id === VIDEO_FRAME_PANEL_ID - ? { - ...n, - data: nodeData as FlowNodeData, - draggable: videoPanelDock === "canvas", - dragHandle: videoPanelDock === "canvas" ? ".video-frame-panel-drag" : undefined, - } - : n, - ) - } - panelWasCreated = true - return [ - ...prev, - { - id: VIDEO_FRAME_PANEL_ID, - type: "videoFramePanel", - position: defaultPosition, - data: nodeData as FlowNodeData, - draggable: videoPanelDock === "canvas", - dragHandle: videoPanelDock === "canvas" ? ".video-frame-panel-drag" : undefined, - selectable: true, - }, - ] - }) - - if (videoPanelDock === "canvas" && (panelWasCreated || lastVideoPanelFocusKey.current !== focusKey)) { - lastVideoPanelFocusKey.current = focusKey - window.setTimeout(() => { - flowRef.current?.fitView?.({ - nodes: [{ id: VIDEO_FRAME_PANEL_ID }, { id: "input" }], - padding: 0.18, - duration: 260, - }) - }, 0) - } - }, [videoPanelJobId, videoPanelOpenTick, videoPanelDock, jobs, nodeData, setNodes]) - - // 边的 animated 状态跟 Job 进度联动 - useEffect(() => { - const doneOf: Record = { - input: !!job?.video_url, - visual: !!job && (job.frames.length > 0 || (job.generated_videos?.length ?? 0) > 0), - asr: !!job && job.transcript.length > 0, - translate: !!job && (job.transcript.some((s) => s.zh) ?? false), - rewrite: !!job && !!job.audio_script?.rewritten_text, - } - setEdges((prev) => prev.map((e) => ({ ...e, animated: !!doneOf[e.source] }))) - }, [job, setEdges]) + const activeMode = MODE_ITEMS.find((item) => item.id === mode) ?? MODE_ITEMS[0] + const ActiveIcon = activeMode.icon return ( - <> -
-
- - -
- +
+ +
+
+
+
+ +
+
+

SKG Creative Studio

+

图片、视频、文案

+
+
+
+ gpt-image-2 + Seedance / Kling / Veo +
+
+ +
+ + +
+
+
+
+
+
+ +
+
+

{activeMode.label}

+

{mode === "copy" ? "输入目标,生成可直接进图/视频模型的脚本和提示词" : "写一句要求,必要时加一张参考图"}

+
+
+ +
+ +
+ + + +
+ +