diff --git a/RULES.md b/RULES.md index 9fb45ca..751b9f6 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-20 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区右侧主体链路是“参考帧池 → 转换层 → 主体元素”:参考帧池竖向排列;转换层是轻量对话式生图确认区,参考帧通过左侧缩略图 `+` 送入转换层,用户选择 GPT/Gemini 套件后先分析参考图,再用对话描述复刻/创新/卡通/数量和画面要求;后端返回英文出图 prompt 后必须弹窗确认,用户点确认才生成对应数量的统一多角度套图。右侧主体元素区的套图输出、文件夹分组、单张重生、删除和 hover 预览逻辑保持不变。旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。 +- 当前产品方向(2026-05-20 再确认):信息流广告快速复刻默认进入“三字段候选生成”工作流。主界面为“左侧素材输入列 + 右侧信息流复刻工作表”。用户粘贴 TK 链接或上传视频后点击“开始分析”,系统自动下载源视频;下载完成后并行启动两条路:音频文案路提取原音频文案/字幕,并分析讲话人、语速节奏、背景音乐/环境声/音效;视频视觉路自动抽取参考帧。源视频工作区右侧主体链路是“参考帧池 → 转换层 → 主体元素”:参考帧池竖向排列;转换层是轻量对话式生图确认区,参考图可通过左侧缩略图 `+`、参考帧拖拽、胶片拖拽或本地图片拖入进入转换层,用户选择 GPT/Gemini 套件后先分析参考图,再在下方消息输入区发送复刻/创新/卡通/数量和画面要求;后端返回英文出图 prompt 后必须弹窗确认,用户点确认才生成对应数量的统一多角度套图。右侧主体元素区的套图输出、文件夹分组、单张重生、删除和 hover 预览逻辑保持不变。旧下方“相似主体 / 主体模板库”不再作为主路径。波形下方的画面胶片只是临时预览,点击只跳转原视频时间点,双击或拖进参考帧池才正式加入关键帧,已加入的胶片直接显示“已添加”。产品图上传后独立形成产品资产包,自动识别视角/结构/比例并补缺角度。分镜工作台按逐句时间轴默认只露“文案 / 场景一句话 / 人物+产品+动作”,产品素材池、批量控制、三字段、视频候选和高级区都必须可折叠;视频候选无内容时默认不占大面积,有候选时默认只显示迷你缩略条,展开后才显示 4-grid。单条默认生成 4 个视频候选,顶部支持整片批量生成候选;首尾帧、视觉规划、产品出现方式和旧 6 字段保留在“高级”抽屉与后端 quick-plan 自动展开中,不能再作为客户默认闸门。 ## 部署事实 - 平台:VPS `76.13.31.179`(Ubuntu 24.04 / Docker Compose / Coolify Traefik) diff --git a/api/main.py b/api/main.py index dad9b0d..02f2f8d 100644 --- a/api/main.py +++ b/api/main.py @@ -4706,6 +4706,60 @@ def add_manual_frame(job_id: str, t: float) -> Job: return job +@app.post("/jobs/{job_id}/frames/upload", response_model=Job) +async def upload_reference_frame(job_id: str, file: UploadFile = File(...)) -> Job: + """把用户拖入的图片保存为一张参考帧,供转换层和主体生成复用。""" + job = JOBS.get(job_id) + if not job: + raise HTTPException(404, "job not found") + content_type = (file.content_type or "").lower() + suffix = Path(file.filename or "").suffix.lower() + if content_type and not content_type.startswith("image/"): + raise HTTPException(400, "only image uploads are supported") + if not content_type and suffix not in {".jpg", ".jpeg", ".png", ".webp", ".bmp"}: + raise HTTPException(400, "only image uploads are supported") + + d = job_dir(job_id) + frames_dir = d / "frames" + frames_dir.mkdir(parents=True, exist_ok=True) + next_idx = max((f.index for f in job.frames), default=-1) + 1 + tmp = frames_dir / f"{next_idx:03d}.upload" + out = frames_dir / f"{next_idx:03d}.jpg" + try: + await _save_upload_to_path(file, tmp) + with Image.open(tmp) as raw: + img = ImageOps.exif_transpose(raw).convert("RGB") + img.thumbnail((2400, 2400), Image.LANCZOS) + img.save(out, "JPEG", quality=92, optimize=True) + except Exception as e: + try: + out.unlink() + except OSError: + pass + raise HTTPException(400, f"reference image upload failed: {e}") + finally: + try: + tmp.unlink() + except OSError: + pass + + next_timestamp = max((float(f.timestamp) for f in job.frames), default=float(job.duration or 0)) + 0.01 + new_frame = KeyFrame( + index=next_idx, + timestamp=round(next_timestamp, 2), + url=f"/jobs/{job_id}/frames/{next_idx}.jpg", + description={ + "scene": "用户拖入的转换层参考图", + "objects": [], + "style": "uploaded reference image", + "suggested_prompt": "", + }, + ) + merged = sorted(list(job.frames) + [new_frame], key=lambda f: f.timestamp) + update(job, frames=merged, message=f"已加入上传参考图,共 {len(merged)} 张") + return job + + @app.get("/jobs/{job_id}", response_model=Job) def get_job(job_id: str) -> Job: job = JOBS.get(job_id) diff --git a/docs/source-analysis.html b/docs/source-analysis.html index 15f024a..3161669 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -569,7 +569,7 @@

业务管线

-

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

+

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

01

素材输入

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

02

源视频下载

job.video_url 存在即通过;created/downloading 视为运行中。公开视频默认不带 cookies 下载;只有 TikTok 明确要求登录态时才配置 YTDLP_COOKIES_FILE,生产容器禁止使用 YTDLP_COOKIES_FROM_BROWSER=chrome

@@ -593,8 +593,8 @@ web/next.config.mjsNext.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator,并移除 Next 16 已不支持的 eslint 顶层配置,避免本地 dev 出现配置 Issue 提示。 web/app/globals.css全局主题变量、登录页视觉样式、信息流工作台同源品牌 token、ReactFlow 样式引用,以及本地开发态 nextjs-portal 遮挡隐藏规则。工作台在 skg-board-theme 内定义 --skg-gold-1--skg-gold-2--skg-cream--skg-bg-*--skg-text-*--skg-radius-* 和按钮阴影等变量,并新增 skg-board-brandskg-stat-cardskg-primary-actionskg-secondary-actionskg-empty-state 等样式。暗色工作台复用登录页金色聚焦、米白主按钮和弱暖光氛围;明亮模式通过 skg-board-theme--light 复用同一套结构,改成暖白底、白色 panel、黑底主 CTA 和深色文本,不另起一套界面。 web/app/page.tsx产品工作台主状态:jobs、activeJobId、生成任务状态;主渲染为全屏素材输入列 + 信息流广告复刻工作表;“开始分析”会把 job 放入并行素材分析队列,下载完成后触发 triggerTranscribe 解析音频,并触发 analyzeJob 自动抽 12 张参考帧,形成“音频文案路 + 视频视觉路”同步推进;音频失败时会忽略失败状态下残留的半成品 transcript,允许再次触发音频解析;底部吸附音频条和旧全局浮动主题按钮不再从主界面渲染,避免和工作台内的明暗模式切换重复。 - web/components/ad-recreation-board.tsx信息流广告复刻工作表:顶部先展示与登录页连续的 SKG brand strip,包含 SKG 字标、“未来健康 · 营销内容工作台”和“营销内容工作台 · TK 二创”;右侧素材/任务/视频/文案统计改为米白 stat 卡片,主动作按钮统一走 skg-primary-action,次动作走 skg-secondary-action,空状态复用 AnimatedLoginCharactersbuildWorkflowSteps 仍统一生成 01-09 流程顺序、状态和判定依据,WorkflowStepBadge / PipelineLane / 分镜列标题也继续共用同一套编号;但完整 WorkflowOrderBar、右侧素材/视频/音频/文案/参考帧需求 chips、文案依据下拉和“音频文案、抽帧参考、主体重构、产品素材池”四个状态条不再默认渲染在工作区顶部。左侧素材输入只负责链接/上传和任务切换,不再重复放横版原视频预览;右侧源视频工作区直接进入核心操作。讲话人、节奏和背景音分析仍写入 AudioScript,但不再作为“音频解析结果”卡片默认渲染;主工作区左侧宽度调整为 430-460px,上方是按 9:16 显示的竖版原视频播放器,播放器内覆盖“当前点抽帧”,按当前播放秒数手动补参考帧,播放器下方是逐句时间轴,英文和中文都最多显示两行;右侧上方是无标题的波形与切点参考框,下方是三栏主体管线。音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点,顶部把低/中/高密度按钮和当前播放秒数、总时长、鼠标指针停点秒数直接放在波形上方。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频波形下方同框渲染无标题的 TimelineFilmstrip 临时画面胶片,前端按低/中/高密度从源视频 canvas 截取预览缩略图,并按 frame.time / duration 的百分比定位到和波形同一条时间轴上;波形与胶片之间不显示分隔横线,胶片轨道贴近波形,缩略图轻微上下错落并倾斜重叠排列,hover 时用同一张胶片卡在原位置生成固定顶层克隆,约 4.8 倍放大并自动限制在视口内,避免被工作区、滚动容器或相邻面板遮挡;单击胶片只跳转原视频时间,不写入任务数据,双击胶片或拖进参考帧池时才调用手动抽帧并正式加入 job.frames,已加入的胶片显示“已添加”;胶片预览按 job、视频、密度和时长缓存,未切换低/中/高时返回页面不重新扫视频。右侧参考帧池的主入口是“自动抽帧 12 张”,一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化;缩略图按竖版完整比例显示不裁切,点选状态直接叠在参考帧池缩略图上,鼠标停留会通过固定浮层放大展示完整帧。转换层改为轻量对话式生图确认区:左侧参考帧可点 + 送入,转换层内选择 GPT/Gemini 套件、分析参考图、通过对话生成英文 prompt,并在固定弹窗里确认后才调用主体套图生成;右侧主体元素区仍按每次生成的套图文件夹显示结果,保留单张重生和删除。主体元素区按每次生成的套图文件夹显示结果;缩略图上提供“重新生成这一张”和“删除这一张”,单张重生会用 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源视频工作区右侧主体管线主路径:三栏分别是竖向 参考帧池转换层主体元素。参考帧池保留自动 12 张、胶片拖入正式成帧、点击勾选和删除;参考帧缩略图保持小尺寸固定宽度、aspect-[9/16]object-contain 显示,hover 预览通过 MediaAssetTile 的左侧紧凑浮层显示,并新增 + 操作把参考帧送入转换层。转换层是轻量对话式生图确认区:顶部选择 GPT 套件或 Gemini 套件,中部展示最多 3 张参考图和“分析参考图”,下方是生图对话、快捷需求、输入框和“生成提示词”;subject-agent/message 返回英文 generation_prompt_en 后先显示待确认 prompt,并通过固定弹窗展示用户要求、最终英文提示词、模型套件、方向和数量,用户点“确定生成”才调用 generateSubjectAssets。后端会为每次主体套图注入同一份 pack bible:参考创新模式锁定同一个全新主体和同一套服装,源形象锁定模式锁定参考帧里的可见主体、体态、发型、服装和配色;后处理会裁出白底主体并允许放大到画布高度上限约 96%,实测典型主体有效高度约 90%,避免模型生成“小人 + 大白边”。主体元素区按每次生成的 pack_id 组织成“套图文件夹”:顶部展开当前选中套图,下面是可滚动的套图包列表;同一方向可保留多套,生成中按 pack 显示 2/6 这类进度,单张完成就替换对应占位卡。缩略图复用 MediaAssetTile,支持 hover 放大、单张重生和删除。旧下方 SourceReferenceBuildPanel 不再主路径渲染。 + web/components/ad-recreation-board.tsx信息流广告复刻工作表:顶部先展示与登录页连续的 SKG brand strip,包含 SKG 字标、“未来健康 · 营销内容工作台”和“营销内容工作台 · TK 二创”;右侧素材/任务/视频/文案统计改为米白 stat 卡片,主动作按钮统一走 skg-primary-action,次动作走 skg-secondary-action,空状态复用 AnimatedLoginCharactersbuildWorkflowSteps 仍统一生成 01-09 流程顺序、状态和判定依据,WorkflowStepBadge / PipelineLane / 分镜列标题也继续共用同一套编号;但完整 WorkflowOrderBar、右侧素材/视频/音频/文案/参考帧需求 chips、文案依据下拉和“音频文案、抽帧参考、主体重构、产品素材池”四个状态条不再默认渲染在工作区顶部。左侧素材输入只负责链接/上传和任务切换,不再重复放横版原视频预览;右侧源视频工作区直接进入核心操作。讲话人、节奏和背景音分析仍写入 AudioScript,但不再作为“音频解析结果”卡片默认渲染;主工作区左侧宽度调整为 430-460px,上方是按 9:16 显示的竖版原视频播放器,播放器内覆盖“当前点抽帧”,按当前播放秒数手动补参考帧,播放器下方是逐句时间轴,英文和中文都最多显示两行;右侧上方是无标题的波形与切点参考框,下方是三栏主体管线。音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点,顶部把低/中/高密度按钮和当前播放秒数、总时长、鼠标指针停点秒数直接放在波形上方。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。音频波形下方同框渲染无标题的 TimelineFilmstrip 临时画面胶片,前端按低/中/高密度从源视频 canvas 截取预览缩略图,并按 frame.time / duration 的百分比定位到和波形同一条时间轴上;波形与胶片之间不显示分隔横线,胶片轨道贴近波形,缩略图轻微上下错落并倾斜重叠排列,hover 时用同一张胶片卡在原位置生成固定顶层克隆,约 4.8 倍放大并自动限制在视口内,避免被工作区、滚动容器或相邻面板遮挡;单击胶片只跳转原视频时间,不写入任务数据,双击胶片或拖进参考帧池时才调用手动抽帧并正式加入 job.frames,已加入的胶片显示“已添加”;胶片预览按 job、视频、密度和时长缓存,未切换低/中/高时返回页面不重新扫视频。右侧参考帧池的主入口是“自动抽帧 12 张”,一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化;缩略图按竖版完整比例显示不裁切,点选状态直接叠在参考帧池缩略图上,鼠标停留会通过固定浮层放大展示完整帧。转换层改为轻量对话式生图确认区:左侧参考帧可点 + 或直接拖入转换层,本地图片拖入会通过 uploadReferenceFrame 保存为参考帧;转换层上方是参考输入区,下方是生图对话消息、快捷需求和“发送消息”输入 composer,生成英文 prompt 后仍在固定弹窗里确认后才调用主体套图生成;右侧主体元素区仍按每次生成的套图文件夹显示结果,保留单张重生和删除。主体元素区按每次生成的套图文件夹显示结果;缩略图上提供“重新生成这一张”和“删除这一张”,单张重生会用 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源视频工作区右侧主体管线主路径:三栏分别是竖向 参考帧池转换层主体元素。参考帧池保留自动 12 张、胶片拖入正式成帧、点击勾选和删除;参考帧缩略图保持小尺寸固定宽度、aspect-[9/16]object-contain 显示,hover 预览通过 MediaAssetTile 的左侧紧凑浮层显示,并新增 + 操作把参考帧送入转换层。转换层是轻量对话式生图确认区:顶部选择 GPT 套件或 Gemini 套件,参考输入区支持左侧 +、拖拽参考帧、胶片拖入和本地图片拖拽上传(上传图会写入 job.frames),下方固定为生图对话消息区、快捷需求和“发送消息”输入 composer;subject-agent/message 返回英文 generation_prompt_en 后先显示待确认 prompt,并通过固定弹窗展示用户要求、最终英文提示词、模型套件、方向和数量,用户点“确定生成”才调用 generateSubjectAssets。后端会为每次主体套图注入同一份 pack bible:参考创新模式锁定同一个全新主体和同一套服装,源形象锁定模式锁定参考帧里的可见主体、体态、发型、服装和配色;后处理会裁出白底主体并允许放大到画布高度上限约 96%,实测典型主体有效高度约 90%,避免模型生成“小人 + 大白边”。主体元素区按每次生成的 pack_id 组织成“套图文件夹”:顶部展开当前选中套图,下面是可滚动的套图包列表;同一方向可保留多套,生成中按 pack 显示 2/6 这类进度,单张完成就替换对应占位卡。缩略图复用 MediaAssetTile,支持 hover 放大、单张重生和删除。旧下方 SourceReferenceBuildPanel 不再主路径渲染。 AudioStoryboardPlanPanel 三字段候选生成当前分镜主路径:每行是左右双栏,左侧默认显示 skg_copy_*scene_one_line_*action_one_line_* 三组中英字段,右侧直接显示视频候选横向轨。用户改中文镜像后,字段失焦会通过 refineStoryboard 优化对应英文主值,失败时退回 translateText;英文仍是后续 prompt 主值。quickPlanStoryboard 把三字段和主体 brief 展开为完整 StoryboardScenegenerateStoryboardVideocount 可由单行数字控件选择,候选新生成后持续向右追加,不再用 4-grid 撑高每行。整片生成同样可选择每行数量,并以 concurrency=1 按行排队提交。产品素材池、批量控制、每行主体区和高级区都可折叠,高级抽屉仍展示旧 6 字段、首尾帧 prompt 和首尾帧资产槽,但客户默认不用先处理首尾帧。 web/components/resource-library/library-drawer.tsx全局资源中心浮窗:由工作台顶部“资源库”按钮打开,叠加在工作台上方但不阻塞主界面;尺寸、位置和当前 Tab 写入 localStorage["skg-resource-library-drawer"]。提示词 Tab 固定 5 列(场景描述、视频描述、主体描述、SKG 文案、产品角度),每列先显示 use_count 排名前 5 的“常用”,再按月份倒序分组;提示词节点常驻复制按钮,hover 可选英文/中文/双语复制,并调用 use 接口。素材 Tab 固定 4 列(主体、产品、场景、视频),节点不可拖动,按月份倒序硬编码排列;“应用到当前 job”只调用后端复制接口,得到普通 ImageRef(kind="asset") 后再写入产品素材池或复制 ID。浮窗顶部最近 24 小时横条混合显示提示词和素材;新建提示词、上传素材、删除前查引用、详情侧栏都在该组件内完成。 AdRecreationBoard 主题切换顶部指标区左侧有“明亮/暗色”按钮,使用 Sun / Moon 图标切换 skg-board-theme--light 类名,并把选择写入 localStorage["skg-board-theme"]。暗色仍是默认模式;明亮模式只改变工作台外观,不改变任务、素材、分镜、模型调用或接口数据。 @@ -996,6 +996,7 @@ ProductRefStateItem { 原始音频文件GET /jobs/{id}/audio.wavsourceAudioUrl返回拆轨得到的 wav;当前主界面不再渲染底部吸附音频条,右侧复刻工作表会读取该文件生成参考图式横向响度波形,并和原视频、逐句时间轴联动;波形标题栏显示当前播放秒数、总时长和鼠标指针停点秒数。 改写配音文件GET /jobs/{id}/audio-script.mp3apiAssetUrl(job.audio_script.voice_url)后续新配音阶段保留的 TTS 产物;服务端固定走 VOICE_PROVIDER=azure_openai,通过 AZURE_OPENAI_BASE_URL 的 OpenAI 协议生成 mp3,并按 AZURE_TTS_PATHS 依次尝试 /audio/speech/v1/audio/speech 等路径。当前第一步不默认生成该文件。 手动加帧POST /jobs/{id}/frames?t=addManualFrame按视频时间戳抽一帧,index 递增但 frames 按 timestamp 排序。当前主界面会把原版视频播放器的播放秒数传给 AudioIntakePanel 标题栏右侧的“当前点抽帧”;胶片缩略图双击或拖入参考帧池也调用同一接口,成功后胶片显示已添加。 + 上传转换层参考图POST /jobs/{id}/frames/uploaduploadReferenceFrame把用户拖入转换层的本地图片转成 JPEG 参考帧并写入 job.frames,随后前端把新 frame index 加入转换层参考输入区;这让对话式生图可以直接用桌面图片、左侧参考帧或胶片帧作为同一套 1-3 张参考图。 删除参考帧DELETE /jobs/{id}/frames/{idx}deleteFrame删除单张抽帧参考帧并清掉对应选择态;当前主界面每张缩略图右下角提供删除入口,方便手动抽错后直接修正。接口返回状态消息必须称为“参考帧/关键帧”,不能写成“分镜”,避免和逐句 storyboard 行混淆。 Vision 识别POST /frames/{idx}/describedescribeFrame写入 frame.description,后续可从 objects 加候选元素。 清洗水印POST /frames/{idx}/cleanupcleanupFrame支持全图和区域清洗,生成 cleaned 待应用版本;前端批量清洗会顺序调用该接口,不自动覆盖原图。单帧清洗状态按 frame.index 隔离,清洗某一张不会禁用其他关键帧的清洗按钮。 @@ -1150,6 +1151,8 @@ ProductRefStateItem { Workflow
+

补充:转换层排版改为“参考输入区在上、消息对话区在下”的对话式生图 composer。参考输入区可接收左侧参考帧拖拽、胶片拖拽和本地图片拖拽上传;本地图片通过 POST /jobs/{id}/frames/upload 写入 job.frames 后加入当前转换层参考图。

+

影响:“生成提示词”按钮语义收敛为底部“发送消息”,用户先围绕参考图发需求,系统再返回待确认英文 prompt;右侧主体元素套图输出、轮询、文件夹分组、单张重生和删除不变。

问题:用户希望转换层只做清晰的“上传图/选图 → 分析图 → 对话确认需求 → 弹出出图提示词 → 用户确认 → 生成多角度统一套图”闭环,不能拖入参考后自动开跑,也不能继续保留旧四投放区。

改动:SourceSubjectPipeline 恢复轻量对话式转换层:参考帧池缩略图新增 + 操作送入转换层;转换层内可选 GPT/Gemini 套件、分析 1-3 张参考图、查看特征 chips、通过对话生成英文 prompt。subject-agent/message 返回后只打开提示词确认弹窗,不直接生图;用户点“确定生成”才调用 generateSubjectAssets

影响:右侧主体元素输出、套图文件夹、逐张回填、单张重生和删除逻辑不变。生成数量、方向和风格继续由对话解析,最终英文 prompt 会在确认弹窗中可见。

diff --git a/web/app/page.tsx b/web/app/page.tsx index 97ad983..d891979 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -291,8 +291,10 @@ export default function Home() { 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]) diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index 72a4f1a..b76fcb0 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -1,6 +1,6 @@ "use client" -import { type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react" +import { type DragEvent as ReactDragEvent, type MouseEvent as ReactMouseEvent, type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react" import { createPortal } from "react-dom" import { AlertTriangle, BookOpen, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Info, Link2, Loader2, @@ -66,6 +66,7 @@ import { subjectTemplateImageUrl, updateElement, updateStoryboard, + uploadReferenceFrame, uploadStoryboardAsset, translateText, videoUrl, @@ -2688,7 +2689,7 @@ function AudioIntakePanel({ selectedFrames: Set onToggleFrame: (idx: number) => void onJobUpdate: (job: Job) => void - onAddFrame?: (jobId: string, t: number) => Promise | void + onAddFrame?: (jobId: string, t: number) => Promise | Job | void onDeleteFrame?: (jobId: string, idx: number) => Promise | void runtimeModels?: RuntimeModels }) { @@ -2839,17 +2840,21 @@ function AudioIntakePanel({ } const addFilmstripFrame = async (time: number) => { - if (!job || !onAddFrame) return + if (!job || !onAddFrame) return null const next = clampNumber(time, 0, timelineDuration) const duplicate = frames.find((frame) => Math.abs(frame.timestamp - next) < 0.45) if (duplicate) { toast.warning(`附近已有关键帧:${duplicate.timestamp.toFixed(1)}s`) - return + return duplicate } setFilmstripBusyTime(next) try { - await onAddFrame(job.id, next) + const known = new Set(frames.map((frame) => frame.index)) + const updated = await onAddFrame(job.id, next) toast.success(`已加入关键帧:${next.toFixed(1)}s`) + const updatedJob = updated && typeof updated === "object" && "frames" in updated ? updated : null + const added = updatedJob?.frames.find((frame) => !known.has(frame.index) && Math.abs(frame.timestamp - next) < 0.45) ?? null + return added } finally { setFilmstripBusyTime(null) setFilmstripDragTime(null) @@ -2999,7 +3004,7 @@ function AudioIntakePanel({ onJobUpdate={onJobUpdate} runtimeModels={runtimeModels} filmstripDragging={filmstripDragTime !== null} - onDropFilmstripFrame={(time) => void addFilmstripFrame(time)} + onDropFilmstripFrame={(time) => addFilmstripFrame(time)} />
@@ -3305,10 +3310,12 @@ function SourceSubjectPipeline({ onJobUpdate: (job: Job) => void runtimeModels?: RuntimeModels filmstripDragging?: boolean - onDropFilmstripFrame?: (time: number) => void + onDropFilmstripFrame?: (time: number) => Promise | KeyFrame | null | void }) { const [referenceDropActive, setReferenceDropActive] = useState(false) const [agentDropActive, setAgentDropActive] = useState(false) + const [referenceFrameDragging, setReferenceFrameDragging] = useState(false) + const [agentReferenceUploadBusy, setAgentReferenceUploadBusy] = useState(false) const [reconstructionDirections, setReconstructionDirections] = useState>(() => ({ ...DEFAULT_RECONSTRUCTION_DIRECTIONS })) const [subjectModelBundle, setSubjectModelBundle] = useState(() => job.subject_agent?.model_bundle ?? "gpt") const [agentReferenceFrameIndices, setAgentReferenceFrameIndices] = useState(() => job.subject_agent?.source_frame_indices ?? []) @@ -3636,15 +3643,30 @@ function SourceSubjectPipeline({ } } + const mergeAgentReferenceIndices = (current: number[], incoming: number[]) => { + let replaced = false + const next = [...current] + for (const index of incoming) { + const numericIndex = Number(index) + if (!Number.isFinite(numericIndex) || next.includes(numericIndex)) continue + next.push(numericIndex) + while (next.length > RECONSTRUCTION_FRAME_LIMIT) { + next.shift() + replaced = true + } + } + return { next, replaced } + } + const addAgentReferenceFrame = (frame: KeyFrame) => { setAgentReferenceFrameIndices((current) => { if (current.includes(frame.index)) { toast.info("这张参考帧已经在转换层里。") return current } - const next = current.length >= RECONSTRUCTION_FRAME_LIMIT ? [...current.slice(1), frame.index] : [...current, frame.index] - if (current.length >= RECONSTRUCTION_FRAME_LIMIT) { - toast.warning(`最多保留 ${RECONSTRUCTION_FRAME_LIMIT} 张参考帧,已替换为最近拖入的组合。`) + const { next, replaced } = mergeAgentReferenceIndices(current, [frame.index]) + if (replaced) { + toast.warning(`最多保留 ${RECONSTRUCTION_FRAME_LIMIT} 张参考图,已替换为最近拖入的组合。`) } else { toast.info(`已加入转换层:${frame.timestamp.toFixed(1)}s。`) } @@ -3652,10 +3674,109 @@ function SourceSubjectPipeline({ }) } + const addAgentReferenceIndices = (indices: number[], notice = "已加入转换层") => { + if (!indices.length) return + setAgentReferenceFrameIndices((current) => { + const { next, replaced } = mergeAgentReferenceIndices(current, indices) + if (next.length === current.length && next.every((item, idx) => item === current[idx])) { + toast.info("这些参考图已经在转换层里。") + return current + } + if (replaced) { + toast.warning(`最多保留 ${RECONSTRUCTION_FRAME_LIMIT} 张参考图,已保留最近加入的组合。`) + } else { + toast.success(`${notice}:${indices.length} 张。`) + } + return next + }) + } + const removeAgentReferenceFrame = (frameIndex: number) => { setAgentReferenceFrameIndices((current) => current.filter((index) => index !== frameIndex)) } + const transferHasAgentReference = (transfer: DataTransfer) => { + const types = Array.from(transfer.types || []) + return ( + types.includes(SOURCE_KEYFRAME_DRAG_TYPE) || + types.includes(FILMSTRIP_DRAG_TYPE) || + types.includes("Files") + ) + } + + const handleAgentReferenceDragEnter = (event: ReactDragEvent) => { + if (!transferHasAgentReference(event.dataTransfer)) return + event.preventDefault() + setAgentDropActive(true) + } + + const handleAgentReferenceDragOver = (event: ReactDragEvent) => { + if (!transferHasAgentReference(event.dataTransfer)) return + event.preventDefault() + event.dataTransfer.dropEffect = "copy" + setAgentDropActive(true) + } + + const handleAgentReferenceDragLeave = (event: ReactDragEvent) => { + const next = event.relatedTarget as Node | null + if (next && event.currentTarget.contains(next)) return + setAgentDropActive(false) + } + + const uploadAgentReferenceFiles = async (files: File[]) => { + const imageFiles = files.filter((file) => { + const name = file.name.toLowerCase() + return file.type.startsWith("image/") || /\.(jpe?g|png|webp|bmp)$/i.test(name) + }).slice(0, RECONSTRUCTION_FRAME_LIMIT) + if (!imageFiles.length) { + toast.warning("只支持拖入图片文件。") + return + } + setAgentReferenceUploadBusy(true) + try { + let workingJob = job + const known = new Set(job.frames.map((frame) => frame.index)) + const addedIndices: number[] = [] + for (const file of imageFiles) { + const updated = await uploadReferenceFrame(workingJob.id, file) + workingJob = updated + onJobUpdate(updated) + const added = updated.frames.filter((frame) => !known.has(frame.index)) + added.forEach((frame) => { + known.add(frame.index) + addedIndices.push(frame.index) + }) + } + addAgentReferenceIndices(addedIndices, "已上传并加入转换层") + } catch (e) { + toast.error("参考图上传失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setAgentReferenceUploadBusy(false) + } + } + + const handleAgentReferenceDrop = async (event: ReactDragEvent) => { + if (!transferHasAgentReference(event.dataTransfer)) return + event.preventDefault() + setAgentDropActive(false) + const files = Array.from(event.dataTransfer.files || []) + if (files.length) { + await uploadAgentReferenceFiles(files) + return + } + const frameIndex = Number(event.dataTransfer.getData(SOURCE_KEYFRAME_DRAG_TYPE)) + if (Number.isFinite(frameIndex)) { + const frame = frames.find((item) => item.index === frameIndex) + if (frame) addAgentReferenceFrame(frame) + return + } + const filmstripTime = Number(event.dataTransfer.getData(FILMSTRIP_DRAG_TYPE)) + if (Number.isFinite(filmstripTime) && onDropFilmstripFrame) { + const addedFrame = await onDropFilmstripFrame(filmstripTime) + if (addedFrame) addAgentReferenceFrame(addedFrame) + } + } + const runSubjectAgentAnalyze = async () => { if (!agentReferenceFrameIndices.length) { toast.warning("先从左侧拖入 1-3 张参考帧,再开始分析。") @@ -3748,7 +3869,7 @@ function SourceSubjectPipeline({ return ( <> -
+
} title="参考帧池" /> @@ -3805,7 +3926,15 @@ function SourceSubjectPipeline({ return (
{ + event.dataTransfer.setData(SOURCE_KEYFRAME_DRAG_TYPE, String(frame.index)) + event.dataTransfer.effectAllowed = "copy" + setReferenceFrameDragging(true) + }} + onDragEnd={() => setReferenceFrameDragging(false)} + className="relative cursor-grab active:cursor-grabbing" + title="拖到转换层作为生图参考" >
-
+
{SUBJECT_MODEL_BUNDLE_OPTIONS.map((option) => ( +
+ + + 图片区 + +
{agentAnalysis ? ( @@ -3945,14 +4091,17 @@ function SourceSubjectPipeline({
) : null} -
+
- 生图对话 + + + 生图对话 + {reconstructionModeConfig(effectiveAgentMode).label} · {effectiveAgentQuantity} 张
-
+
{agentMessages.length ? agentMessages.slice(-5).map((message, index) => (
)) : ( -
- 分析后,直接写你要复刻、创新、卡通、数量和画面要求。 +
+ 直接发送复刻、创新、卡通、数量和画面要求。
)}
@@ -3982,21 +4131,23 @@ function SourceSubjectPipeline({ ))}
-