diff --git a/api/main.py b/api/main.py index b1a0215..ff1f1ab 100644 --- a/api/main.py +++ b/api/main.py @@ -339,6 +339,7 @@ class StoryboardScene(BaseModel): visual_mode: Literal["person_only", "person_product", "product_only", "environment"] = "person_product" needs_product: bool = True needs_subject: bool = True + subject_brief: str = "" first_frame_plan: str = "" last_frame_plan: str = "" product_placement: str = "" @@ -532,6 +533,7 @@ class KeyElement(BaseModel): cutout_background: Literal["white", "black"] = "white" subject_kind: SubjectKind = "object" subject_assets: list[SubjectAsset] = Field(default_factory=list) + subject_consensus_brief: str = "" created_at: float = 0.0 @@ -3014,6 +3016,19 @@ def _describe_subject_template_from_images(name: str, subject_style: str, image_ return _vision_brief_from_images(image_paths, prompt, max_images=10) +def _describe_subject_consensus_from_images(name: str, subject_style: str, image_paths: list[Path], note: str = "") -> str: + prompt = ( + f"You are extracting the stable character bible from a generated SKG subject view pack named '{name}'. " + f"Subject style: {subject_style}. User/profile note: {note[:700]}. " + "These images are multiple views of ONE generated subject. Summarize the reusable identity as text for future first/last-frame generation. " + "Do NOT identify a real person and do NOT mention exact facial identity. " + "Output strict JSON only with keys: gender_presentation, age_range, body_proportion, hair, skin_tone, " + "wardrobe_or_material_style, pose_language, camera_readability, neck_shoulder_readiness, commercial_mood, brief. " + "The brief should be 90-160 words, describe one consistent subject, and explicitly allow new poses, new framing, new expressions, and new environments while preserving identity, proportions, material/style, and ad role." + ) + return _vision_brief_from_images(image_paths, prompt, max_images=10) + + # ---------- API 路由 ---------- class CreateJobReq(BaseModel): @@ -3934,6 +3949,7 @@ class UpdateElementReq(BaseModel): name_zh: str | None = None name_en: str | None = None position: str | None = None + subject_consensus_brief: str | None = None class GenerateSceneAssetReq(BaseModel): @@ -3943,6 +3959,7 @@ class GenerateSceneAssetReq(BaseModel): scene_style: SceneStyle = "source" asset_role: SceneAssetRole = "scene" prompt: str = "" + subject_brief: str = "" source_frame_indices: list[int] | None = None subject_images: list[dict] = Field(default_factory=list) product_images: list[dict] = Field(default_factory=list) @@ -4107,6 +4124,8 @@ def update_element(job_id: str, idx: int, element_id: str, req: UpdateElementReq e.name_en = req.name_en.strip() if req.position is not None: e.position = req.position.strip() + if req.subject_consensus_brief is not None: + e.subject_consensus_brief = req.subject_consensus_brief.strip()[:2200] new_frames.append(f) if not found: raise HTTPException(404, "element not found") @@ -4161,20 +4180,14 @@ def generate_scene_asset(job_id: str, idx: int, req: GenerateSceneAssetReq) -> J source_indices = list(dict.fromkeys(source_indices))[:8] model_src = src sheet_tmp: Path | None = None - asset_sheet_tmp: Path | None = None - if len(source_indices) > 1: + if req.asset_role == "scene" and len(source_indices) > 1: sheet_tmp = job_dir(job_id) / "tmp" / f"scene_refs_{idx:03d}_{uuid.uuid4().hex[:6]}.jpg" sheet = _make_reference_contact_sheet(job_id, source_indices, sheet_tmp) if sheet: model_src = sheet - subject_ref_paths = [p for p in (storyboard_ref_path(job_id, r) for r in req.subject_images[:8]) if p and p.exists()] - product_ref_paths = [p for p in (storyboard_ref_path(job_id, r) for r in req.product_images[:6]) if p and p.exists()] - asset_ref_paths = [*subject_ref_paths, *product_ref_paths] - if req.asset_role != "scene" and asset_ref_paths: - asset_sheet_tmp = job_dir(job_id) / "tmp" / f"endpoint_refs_{idx:03d}_{uuid.uuid4().hex[:6]}.jpg" - asset_sheet = _make_paths_contact_sheet(asset_ref_paths, asset_sheet_tmp, max_items=10) - if asset_sheet: - model_src = asset_sheet + # Endpoint frames deliberately ignore subject image references. Character identity comes + # from subject_brief text, while only 1-2 product images remain hard visual truth. + product_ref_paths = [p for p in (storyboard_ref_path(job_id, r) for r in req.product_images[:2]) if p and p.exists()] confirmed_subjects = [ (e.name_en or e.name_zh).strip() @@ -4195,12 +4208,13 @@ def generate_scene_asset(job_id: str, idx: int, req: GenerateSceneAssetReq) -> J if confirmed_subjects else "Remove the main foreground subject from the frame if present. " ) - identity_clause = ( - f"Use the generated subject asset references as the primary character identity lock ({len(subject_ref_paths)} image(s)); preserve the subject type, material, proportions, style, age/gender presentation, pose vocabulary, and ad-friendly identity exactly as shown in those selected views. " - if subject_ref_paths - else ( - "No generated subject reference was provided for this endpoint. Do not add a main character unless the user scene direction explicitly asks for one. " - ) + subject_brief = req.subject_brief.strip() + subject_brief_clause = ( + f"Subject identity (text only, no image reference): {subject_brief[:1800]}. " + "Maintain this identity across this and other endpoint frames in the same storyboard. " + "Vary pose, framing, expression, gesture, camera distance, and environment freely according to the user prompt; do not fall back to any specific reference photo or ID-card pose. " + if subject_brief + else "No subject identity brief was provided. Do not add a main character unless the user scene direction explicitly asks for one. " ) mode_clause = { "remove_subject": ( @@ -4229,9 +4243,14 @@ def generate_scene_asset(job_id: str, idx: int, req: GenerateSceneAssetReq) -> J if user_prompt else "" ) - if req.asset_role != "scene" and asset_ref_paths: + if req.asset_role != "scene" and product_ref_paths: reference_clause = ( - f"Use the provided asset contact sheet as the primary visual reference: {len(subject_ref_paths)} generated subject image(s) and {len(product_ref_paths)} SKG product image(s). " + f"Use the provided {len(product_ref_paths)} SKG product image(s) only as rigid product reference. " + "Do not use the original keyframe as the first/last-frame truth; it is only a storage anchor for this row. No subject image reference is attached. " + ) + elif req.asset_role != "scene": + reference_clause = ( + "No image reference is attached for this endpoint frame. Generate from text only. " "Do not use the original keyframe as the first/last-frame truth; it is only a storage anchor for this row. " ) else: @@ -4241,18 +4260,14 @@ def generate_scene_asset(job_id: str, idx: int, req: GenerateSceneAssetReq) -> J else "Use the provided frame as the primary visual reference. " ) product_asset_clause = ( - "Use the provided SKG product references as the rigid product truth when the user prompt asks for product presence: a white U-shaped neck-and-shoulder wearable massage device worn around the neck/shoulders, not headphones, a collar pillow, skincare, food, or a medical prop. Keep product scale believable, preserve left/right asymmetry, side thickness, inner contact pads, buttons, white material, and real wearable placement. " + "The provided product image(s) are the only product truth. The product is a white U-shaped neck-and-shoulder wearable massage device worn around the neck/shoulders, not headphones, a collar pillow, skincare, food, or a medical prop. Do not vary left/right asymmetry, button placement, contact pad position, side thickness, opening direction, inner/outer shell relationship, or wearable scale relative to the human neck. Preserve all structural details exactly while integrating it into the new scene. " if product_ref_paths else "Do not invent a random product. Only include an SKG product if the user prompt explicitly asks for it. " ) subject_asset_clause = ( - TRANSPARENT_HUMAN_POSITIVE_PROMPT + " " - + TRANSPARENT_HUMAN_NEGATIVE_PROMPT + " " - + "If the selected subject references are transparent humanoid assets, keep the same friendly transparent or translucent human character: glass/acrylic/vinyl-like transparent outer body, visible clean white skeleton inside, clean commercial wellness style, non-horror. " - + "If the selected subject references are normal actor assets, keep them as a normal believable commercial actor and do not convert them into a transparent skeleton. " - + "Use the selected subject views only to understand identity, proportions, material, pose vocabulary, camera language, and lighting; do not copy watermarks, subtitles, platform UI, logos, or accidental artifacts. " - if subject_ref_paths - else "No main character should be generated unless the user scene direction explicitly requires one; product-only and environment-only frames should stay product-only or scene-only. " + (TRANSPARENT_HUMAN_POSITIVE_PROMPT + " " + TRANSPARENT_HUMAN_NEGATIVE_PROMPT + " ") + if subject_brief and ("透明" in subject_brief or "transparent" in subject_brief.lower() or "skeleton" in subject_brief.lower()) + else "" ) if req.asset_role == "scene": prompt = ( @@ -4275,7 +4290,7 @@ def generate_scene_asset(job_id: str, idx: int, req: GenerateSceneAssetReq) -> J prompt = ( "Create one premium 9:16 high-definition video endpoint frame from text direction. " + role_clause - + identity_clause + + subject_brief_clause + reference_clause + user_prompt_clause + style_clause + " " @@ -4288,9 +4303,17 @@ def generate_scene_asset(job_id: str, idx: int, req: GenerateSceneAssetReq) -> J try: if req.asset_role == "scene": img_bytes, _mode = _image_edit_call(model_src, prompt, models=models, fallback_text=False, max_attempts=3, max_side=1280) - elif asset_ref_paths: - img_bytes, _mode = _image_edit_call(model_src, prompt, models=models, fallback_text=False, max_attempts=3, max_side=1600) + elif product_ref_paths: + print( + f"[scene asset] role={req.asset_role} endpoint=/images/edits product_refs={len(product_ref_paths)} subject_refs=0 contact_sheet=0 model={GPT_IMAGE_MODEL}", + flush=True, + ) + img_bytes, _mode = _image_edit_call(product_ref_paths, prompt, models=models, fallback_text=False, max_attempts=3, max_side=1600) else: + print( + f"[scene asset] role={req.asset_role} endpoint=/images/generations product_refs=0 subject_refs=0 contact_sheet=0 model={GPT_IMAGE_MODEL}", + flush=True, + ) img_bytes, _mode = _image_text_call(prompt, models=models, max_attempts=3) except RuntimeError as e: raise HTTPException(500, f"{req.asset_role} asset failed: {e}") @@ -4298,9 +4321,6 @@ def generate_scene_asset(job_id: str, idx: int, req: GenerateSceneAssetReq) -> J if sheet_tmp and sheet_tmp.exists(): try: sheet_tmp.unlink() except OSError: pass - if asset_sheet_tmp and asset_sheet_tmp.exists(): - try: asset_sheet_tmp.unlink() - except OSError: pass asset_id = f"scene_{idx:03d}_{uuid.uuid4().hex[:8]}" out_path = job_dir(job_id) / "assets" / f"{asset_id}.jpg" @@ -4451,6 +4471,7 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat similar_mode = req.reconstruction_mode == "similar" character_reference_paths: list[Path] = [] template_brief_clause = "" + selected_template_brief = "" character_label = "" subject_template_id = (req.subject_template_id or "").strip() character_id = (req.character_id or "").strip() @@ -4462,6 +4483,7 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat brief = template.prompt_brief.strip() or template.note.strip() or template.description.strip() if similar_mode and not brief: brief = _describe_subject_template_from_images(template.name, template.subject_style, template_paths, template.note) + selected_template_brief = brief.strip() template_brief_clause = ( f"Reference character brief from saved database template '{template.name}': {brief}. " "Use this as a high-quality creative direction and identity bible only; do not copy a face, exact pose, pixels, file artifacts, labels, or accidental defects. " @@ -4474,6 +4496,7 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat character_label = character.name character_reference_paths.extend(character_library_file(image.filename) for image in character.images[:7]) brief = character.prompt_brief.strip() or character.description.strip() + selected_template_brief = brief.strip() template_brief_clause = ( f"Reference character brief from built-in creative character '{character.name}': {brief}. " "Use this planned character brief as a high-quality creative direction and anatomy/style bible only; " @@ -4672,7 +4695,36 @@ def generate_subject_assets(job_id: str, idx: int, element_id: str, req: Generat if old_asset.view in replaced_views: _delete_subject_asset_file(job_id, old_asset.id) current_assets = [asset for asset in current_assets if asset.view not in replaced_views] - e.subject_assets = current_assets + generated + final_assets = current_assets + generated + e.subject_assets = final_assets + if req.subject_kind == "living": + current_brief = (e.subject_consensus_brief or "").strip() + should_refresh_brief = bool(selected_template_brief) or not current_brief or len(generated) >= 3 + if should_refresh_brief: + fallback_parts = [ + selected_template_brief, + (req.subject_profile.resolved_summary if req.subject_profile else ""), + source_subject_brief, + prompt_extra, + ] + fallback_brief = " ".join(part.strip() for part in fallback_parts if part and part.strip())[:1800] + if selected_template_brief: + e.subject_consensus_brief = selected_template_brief[:1800] + else: + asset_paths = [ + job_dir(job_id) / "assets" / f"{asset.id}.jpg" + for asset in final_assets[:10] + if asset.id + ] + brief = _describe_subject_consensus_from_images( + e.name_zh or e.name_en or "generated subject", + req.subject_style, + asset_paths, + fallback_brief, + ) + e.subject_consensus_brief = brief or current_brief or fallback_brief or ( + "Generated SKG ad subject; identity brief unavailable. Keep one consistent commercial subject with clear neck and shoulder placement area." + ) new_frames.append(f) if generation_errors: msg = f"主体资产包部分生成完成 · {el.name_zh} · {len(generated)} 张,失败 {len(generation_errors)} 张" diff --git a/docs/source-analysis.html b/docs/source-analysis.html index 5d38c7c..8fb1e54 100644 --- a/docs/source-analysis.html +++ b/docs/source-analysis.html @@ -593,7 +593,7 @@ web/next.config.mjsNext.js 构建配置:静态导出、图片不走优化、禁用开发环境左下角 Next Dev Indicator,并移除 Next 16 已不支持的 eslint 顶层配置,避免本地 dev 出现配置 Issue 提示。 web/app/globals.css全局主题变量、登录页视觉样式、信息流工作台同源质感样式、ReactFlow 样式引用,以及本地开发态 nextjs-portal 遮挡隐藏规则。工作台新增 skg-board-theme / skg-board-panel / skg-board-topbar 等样式,把主界面的颜色、玻璃质感和金绿细节统一到登录页的暗场视觉语言;明亮模式通过 skg-board-theme--light 复用同一套结构,改成暖白底、浅绿金色层次和深色文本,不另起一套界面。 web/app/page.tsx产品工作台主状态:jobs、activeJobId、生成任务状态;主渲染为全屏素材输入列 + 信息流广告复刻工作表;“开始分析”会把 job 放入并行素材分析队列,下载完成后触发 triggerTranscribe 解析音频,并触发 analyzeJob 自动抽 12 张参考帧,形成“音频文案路 + 视频视觉路”同步推进;底部吸附音频条和旧全局浮动主题按钮不再从主界面渲染,避免和工作台内的明暗模式切换重复。 - web/components/ad-recreation-board.tsx信息流广告复刻工作表:顶部由 buildWorkflowSteps 统一生成 01-09 流程顺序、状态和判定依据,WorkflowOrderBar 展示完整顺序,WorkflowStepBadge / PipelineLane / 分镜列标题共用同一套编号。左侧素材输入只负责链接/上传和任务切换,不再重复放横版原视频预览;右侧顶部用“音频文案、抽帧参考、相似主体、产品素材池”四个状态条显示后台并行进度。源视频工作区展示视频下载状态和默认折叠的文案依据。音频解析结果改成默认折叠的辅助信息,展开后同一行看讲话人/节奏/背景音;主工作区左侧放大为按 9:16 显示的竖版原视频播放器,播放器内覆盖“当前点抽帧”,按当前播放秒数手动补参考帧;右侧上方是音频波形 / 切点参考,下方左侧是参考帧池,右侧是逐句时间轴;下一行只保留“相似主体 / 主体模板”。音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点,顶部同时显示当前播放秒数、总时长和鼠标指针停点秒数。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。逐句时间轴左侧参考帧池的主入口是“自动抽帧 12 张”,一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化;缩略图按竖版完整比例显示不裁切并用更多列紧凑铺开,点选状态直接叠在参考帧池缩略图上,鼠标停留会通过固定浮层放大展示完整帧。“生成 10 张高清图”放在下方相似主体白底视图区,不和抽参考按钮平齐;如果用户没有勾选帧,默认把全部关键帧作为主体参考,勾选后只传已选帧;生成区可在“透明骨架 / 普通真人”之间切换,可选择桌面导入的 5 套内置形象作为创意方向,并可填写统一主体方向,例如年轻女性、更运动、更高级。关键帧和相似主体白底视图都用更小的竖版缩略图密排;白底视图只展示每个 view 的最新一张,缩略图上提供“重新生成这一张”和“删除这一张”,单张重生会用 replace_views=true 替换同一视角。前端调用 generateSubjectAssets 时按主体类型传 subject_style=transparent_humansource_actor,按需传 character_id,并使用 reconstruction_mode=similar;后端会把关键帧和内置形象视为同一个主体的创意证据,并锁定同一性别表现、年龄段、体型、材质、风格和视觉身份,同时生成全身多视角 + 肩颈正/左右近景 + 后颈肩背特写,避免整套图出现男女性别、老少年龄或样式混杂。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写,分镜时间和原内容列压缩为窄摘要列,新口播列进一步收窄,把横向空间留给画面规划和首尾帧。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入和历史候选视频槽;画面规划区先选择镜头类型(人物/情绪、人物+产品、产品特写、场景过渡),再用人物/产品开关、首帧规划、尾帧规划和产品出现方式决定这一条到底需不需要产品图或相似主体参考。当前主流程暂停直接调用视频模型,不再提供“生成本条 · Seedance”或“一键提交全部”视频入口;行内新增“首尾帧闸门”,分别显示/生成首帧和尾帧,旧 keyframe 类型首尾帧会被忽略,只认真正的 asset 首尾帧。生成首尾帧时调用 generateSceneAsset,先按人物描述、镜头类型、首尾状态和产品佩戴需求,从相似主体 6/10 视图里自动挑选最多 5 张最相关主体视角,再传入 subject_images 和该行自动挑选的产品图 product_images;关键帧只作为前置主体重构证据和行数据承载位置,不再作为后续视频首尾帧参考。视频候选槽只展示历史候选和待生成占位,按钮改为“保存本条规划 / 保存全部规划”。只有该行勾选“产品”时,首尾帧生成才会从产品素材池按分镜角色、视角优先级、用途标签、置信度和风险自动挑选最多 6 张相关产品图;未勾选产品时不会把产品图提交给首尾帧/后续生视频模型。只有该行勾选“人物”时,才会传按需筛选后的相似主体参考图;否则 prompt 会明确禁止强行添加主角式透明骨架人,后端也不会再给产品特写强加透明骨架人约束。ModelTrace 会在音频解析、产品识别/补图、相似主体高清视图包、脚本改写等入口旁直接展示模型名;所有生图入口都显示并使用 gpt-image-2,没有其他图片模型 fallback;点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 + web/components/ad-recreation-board.tsx信息流广告复刻工作表:顶部由 buildWorkflowSteps 统一生成 01-09 流程顺序、状态和判定依据,WorkflowOrderBar 展示完整顺序,WorkflowStepBadge / PipelineLane / 分镜列标题共用同一套编号。左侧素材输入只负责链接/上传和任务切换,不再重复放横版原视频预览;右侧顶部用“音频文案、抽帧参考、相似主体、产品素材池”四个状态条显示后台并行进度。源视频工作区展示视频下载状态和默认折叠的文案依据。音频解析结果改成默认折叠的辅助信息,展开后同一行看讲话人/节奏/背景音;主工作区左侧放大为按 9:16 显示的竖版原视频播放器,播放器内覆盖“当前点抽帧”,按当前播放秒数手动补参考帧;右侧上方是音频波形 / 切点参考,下方左侧是参考帧池,右侧是逐句时间轴;下一行只保留“相似主体 / 主体模板”。音频波形用参考图式的连续灰色包络显示响度、停顿和密集爆点,顶部同时显示当前播放秒数、总时长和鼠标指针停点秒数。视频播放时通过 requestAnimationFrame 平滑驱动波形播放线,同时同步高亮并滚动当前句;点击音频波形或字幕行会跳转原视频时间。逐句时间轴左侧参考帧池的主入口是“自动抽帧 12 张”,一键按动作峰值目标重新抽取 12 张源视频参考帧,优先抓手势、表情变化、节奏点和镜头变化;缩略图按竖版完整比例显示不裁切并用更多列紧凑铺开,点选状态直接叠在参考帧池缩略图上,鼠标停留会通过固定浮层放大展示完整帧。“生成 10 张高清图”放在下方相似主体白底视图区,不和抽参考按钮平齐;如果用户没有勾选帧,默认把全部关键帧作为主体参考,勾选后只传已选帧;生成区可在“透明骨架 / 普通真人”之间切换,可选择桌面导入的 5 套内置形象作为创意方向,并可填写统一主体方向,例如年轻女性、更运动、更高级。关键帧和相似主体白底视图都用更小的竖版缩略图密排;白底视图只展示每个 view 的最新一张,缩略图上提供“重新生成这一张”和“删除这一张”,单张重生会用 replace_views=true 替换同一视角。前端调用 generateSubjectAssets 时按主体类型传 subject_style=transparent_humansource_actor,按需传 character_id,并使用 reconstruction_mode=similar;后端会把关键帧和内置形象视为同一个主体的创意证据,并锁定同一性别表现、年龄段、体型、材质、风格和视觉身份,同时生成全身多视角 + 肩颈正/左右近景 + 后颈肩背特写,避免整套图出现男女性别、老少年龄或样式混杂。主体生成完成后会形成 subject_consensus_brief,主体模板保存区可预览/编辑这段 brief。音频结果下方是信息流复刻分镜工作台:顶部产品参考区是“同一产品素材池”,不限量上传产品图,不做不同产品身份判断;上传原图推荐长边 1200-2000px、短边至少 600px,但后端会统一生成最长边 1600px、JPEG 92 的 AI 工作副本,并回显尺寸、自动转换和风险标注;上传后按“套在脖子上的 U 形肩颈按摩仪”进行同一产品批量识别,左/右按佩戴者身体左右、上/下按佩戴方向,额外标注内外侧、开口方向、局部结构点、背景类型、用途标签、生成风险和备注,用户只检查备注,鼠标悬停通过固定浮层显示大图预览,能盖过滚动容器和分镜框架;缺视角补图失败时保留重试入口。脚本区在分镜行上方提供“作者想法”和“整片改写”,每行新口播文案可直接编辑并可单段 AI 改写,分镜时间和原内容列压缩为窄摘要列,新口播列进一步收窄,把横向空间留给画面规划和首尾帧。每条音频分镜纵向排列,行内从左到右串起原内容、新口播文案、画面规划/产品融入和历史候选视频槽;画面规划区先选择镜头类型(人物/情绪、人物+产品、产品特写、场景过渡),再用人物/产品开关、首帧规划、尾帧规划和产品出现方式决定这一条到底需不需要产品图或相似主体参考。当前主流程暂停直接调用视频模型,不再提供“生成本条 · Seedance”或“一键提交全部”视频入口;行内新增“首尾帧闸门”,分别显示/生成首帧和尾帧,旧 keyframe 类型首尾帧会被忽略,只认真正的 asset 首尾帧。生成首尾帧时调用 generateSceneAsset,主体只传 subject_brief,不再传主体图;产品按端点选择最多 1-2 张硬参考图,默认正面,侧面/后颈/厚度/特写等关键词会额外补一张对应视角。关键帧只作为前置主体重构证据和行数据承载位置,不再作为后续视频首尾帧参考。视频候选槽只展示历史候选和待生成占位,按钮改为“保存本条规划 / 保存全部规划”。只有该行勾选“产品”时,首尾帧生成才会从产品素材池按端点视角策略自动挑选最多 1-2 张相关产品图;未勾选产品时不会把产品图提交给首尾帧/后续生视频模型,并走纯文字首尾帧。只有该行勾选“人物”时,才会把主体 brief 注入 prompt;否则 prompt 会明确禁止强行添加主角式透明骨架人,后端也不会再给产品特写强加透明骨架人约束。ModelTrace 会在音频解析、产品识别/补图、相似主体高清视图包、脚本改写等入口旁直接展示模型名;所有生图入口都显示并使用 gpt-image-2,没有其他图片模型 fallback;点击后用固定浮层展示模型链路、输入输出和回退逻辑。旧分镜卡、抽帧控制和视频生成组件仍保留在文件里,但当前主路径不渲染。 AdRecreationBoard 主题切换顶部指标区左侧有“明亮/暗色”按钮,使用 Sun / Moon 图标切换 skg-board-theme--light 类名,并把选择写入 localStorage["skg-board-theme"]。暗色仍是默认模式;明亮模式只改变工作台外观,不改变任务、素材、分镜、模型调用或接口数据。 SourceReferenceBuildPanel“相似主体 / 主体模板”当前承担主体资产生成和主体模板复用的前端入口:顶部用 radio 区分“用模板生成”和“不用模板(从源视频关键帧创新)”,源视频相似 不再作为模板卡混进网格。模板库把 GET /subject-templates 数据库模板和 GET /character-library/skg 内置形象合并成 120px 竖排卡片,选中态统一用 cyan;当选择“不用模板”时模板网格会收起,避免把生成按钮和结果缩略图挤到折叠区域之外。保存为主体模板的名称、备注和按钮固定在模板区底部一行。下方“生成主体视图”独立显示模型链路,支持透明骨架/真人、全部 10 / 常用 4 / 自定义视图;同时新增“主体设定”,默认随机组合性别表现、年龄段、着装风格、地域人种、肤色、体型比例、发型和气质场景,也可切到手动指定。随机组合会在点击生成时解析成一套固定 profile 并传给后端 subject_profile,整包视图共用同一人设,不会一张男一张女或一张年轻一张银发。已有生成结果会优先显示在生成区标题下方,再显示控制项,避免用户生成后还要继续向下找图。主体缩略图放大为可单张重生、删除和 hover 放大的媒体卡;生成中会显示本次请求锁定的素材 ID 和主体设定,切换其他模块不会改变已经提交的生成目标。前端仍传 reconstruction_mode=similar,后端先用 VISION_MODEL 把关键帧/模板图转成非身份化文字 brief;如果 brief 失败,则继续用用户方向、模板文字、内置形象 brief 和结构化主体设定。最终主体图只走 gpt-image-2/images/generations 文字生图,不再把原帧或模板图作为强 image-edit 锚点。 web/components/media-asset-tile.tsx项目内媒体素材缩略图基底组件:图片、视频、抽帧、产品图、相似主体图、首尾帧和视频候选默认从这里获得统一交互。组件负责缩略图显示、顶层固定浮层 hover 放大、删除按钮、重新生成等操作按钮、忙碌遮罩和图片/视频共用预览,避免每个新板块重复手写不同的媒体交互。 @@ -663,7 +663,7 @@ api/main.py
你看到的区域信息流复刻分镜工作台
-
主要源码AudioStoryboardPlanPanelProductReferenceCardMissingProductViewSlotbuildAudioStoryboardRowsselectProductItemsForRowsubjectAssetRefsForPlanningendpointAssetRefbuildEndpointFramePromptbuildStoryboardSceneFromAudioRowgenerateEndpointFrameForRowsaveRowStoryboardDraftsaveAllStoryboardDraftsEndpointFrameSlotStoryboardVideoSlots in web/components/ad-recreation-board.tsx;产品图、首尾帧和视频候选缩略图统一复用 MediaAssetTile,包括顶层 hover 放大和删除入口。产品白底图上传复用 uploadStoryboardAsset,视角自动识别调用 analyzeProductViews,缺角度自动补图调用 generateProductAngleAsset。当前单条/批量按钮只保存规划;首尾帧按钮调用 generateSceneAsset,把按需求筛选后的相似主体白底视图和产品素材写入 subject_images/product_images,再用 PUT /frames/{idx}/storyboard 保存 asset 首尾帧引用;首尾帧删除只移除本条规划中的引用,避免继续误用旧资产。web/app/page.tsx 的视频提交回调有暂停保护,旧入口误触也不会请求 /storyboard/video
+
主要源码AudioStoryboardPlanPanelProductReferenceCardMissingProductViewSlotbuildAudioStoryboardRowsselectProductItemsForRowsubjectAssetRefsForPlanningsubjectBriefForEndpointendpointAssetRefbuildEndpointFramePromptbuildStoryboardSceneFromAudioRowgenerateEndpointFrameForRowsaveRowStoryboardDraftsaveAllStoryboardDraftsEndpointFrameSlotStoryboardVideoSlots in web/components/ad-recreation-board.tsx;产品图、首尾帧和视频候选缩略图统一复用 MediaAssetTile,包括顶层 hover 放大和删除入口。产品白底图上传复用 uploadStoryboardAsset,视角自动识别调用 analyzeProductViews,缺角度自动补图调用 generateProductAngleAsset。当前单条/批量按钮只保存规划;首尾帧按钮调用 generateSceneAsset,传 subject_brief 和端点选择后的 1-2 张 product_images,不再传主体图或 contact sheet,再用 PUT /frames/{idx}/storyboard 保存 asset 首尾帧引用;首尾帧删除只移除本条规划中的引用,避免继续误用旧资产。web/app/page.tsx 的视频提交回调有暂停保护,旧入口误触也不会请求 /storyboard/video
适合怎么描述“按音频逐句生成产品分镜、每行怎样改写口播、哪几句不需要产品或人物、首帧/尾帧该怎么停、首尾帧是否已经生成并准确、产品素材池识别/补图后的备注是否准确、哪些分镜后续才值得进入单条视频候选”。
@@ -736,7 +736,8 @@ api/main.py cutouts: string[], cutout_id, subject_kind: object | living, - subject_assets: SubjectAsset[] + subject_assets: SubjectAsset[], + subject_consensus_brief }
@@ -869,6 +870,7 @@ ProductRefStateItem { visual_mode: person_only | person_product | product_only | environment, needs_product, needs_subject, + subject_brief, first_frame_plan, last_frame_plan, product_placement, @@ -916,8 +918,8 @@ ProductRefStateItem { 应用清洗POST /cleanup/applyapplyCleanedFrame物理覆盖 frames/{idx}.jpg,并备份原图。 元素增改删POST/PATCH/DELETE /elementsaddElement/updateElement/deleteElement让用户修正 Vision 错误,避免候选结果锁死。 元素提取POST /elements/{element_id}/cutoutcutoutElement调用图像模型生成独立白底素材图,每次累积一张 cutout。 - 主体资产包POST /elements/{element_id}/subject-assets
DELETE /elements/{element_id}/subject-assets/{asset_id}generateSubjectAssets
deleteSubjectAsset根据参考帧、可选内置形象或数据库主体模板重新绘制一个统一主体资产包;前端默认把全部关键帧作为 source_frame_indices,如果用户手动选择了关键帧则只传已选帧,也可传 character_id 选择 5 套内置透明骨架形象之一,或传 subject_template_id 使用已保存的主体模板。当前源视频工作区支持 subject_style=transparent_humansource_actor 两种相似主体。reconstruction_mode=similar 是创新路径:后端先用 VISION_MODEL 把关键帧、内置形象或数据库模板反推成非身份化文字 brief,再调用 gpt-image-2/images/generations 文字生图,日志会显示 image_refs=0;不再把 10 张同一人物实拍图上传给 /images/editsreconstruction_mode=same 仍保留旧 image-edit 路径,用于确实需要精确复刻且有授权的场景。生成视图可由前端传 views 控制:全部 10、常用 4 或自定义;每个 view 单独调用一次生图,明确禁止多视图拼图、contact sheet、多主体、多面板、标签或对比排版。后端不再要求整包全成功才写入:单个视图失败时会保留已成功生成的主体图,返回“部分生成完成”,只有一张都没生成出来才返回错误。replace_views=true 时会替换同一视角旧图;删除接口会移除对应 subject asset 记录并删除本地 jpg 文件。 - 首尾帧资产POST /frames/{idx}/scene-assetgenerateSceneAsset同一接口兼容旧场景图和新首尾帧;当前信息流复刻流程传 asset_role=first_frame/last_framesubject_imagesproduct_images。后端优先把相似主体白底视图与产品素材拼成 asset contact sheet 给 gpt-image-2 做图像编辑,关键帧只作为行数据承载位置。生成结果保存在 scene_assets,前端再写入 StoryboardScene.first_image/last_image。 + 主体资产包POST /elements/{element_id}/subject-assets
DELETE /elements/{element_id}/subject-assets/{asset_id}generateSubjectAssets
deleteSubjectAsset根据参考帧、可选内置形象或数据库主体模板重新绘制一个统一主体资产包;前端默认把全部关键帧作为 source_frame_indices,如果用户手动选择了关键帧则只传已选帧,也可传 character_id 选择 5 套内置透明骨架形象之一,或传 subject_template_id 使用已保存的主体模板。当前源视频工作区支持 subject_style=transparent_humansource_actor 两种相似主体。reconstruction_mode=similar 是创新路径:后端先用 VISION_MODEL 把关键帧、内置形象或数据库模板反推成非身份化文字 brief,再调用 gpt-image-2/images/generations 文字生图,日志会显示 image_refs=0;不再把 10 张同一人物实拍图上传给 /images/edits。生成完成后,后端会把生成视图或模板 prompt_brief 反推/写入 KeyElement.subject_consensus_brief,作为后续首尾帧的唯一主体身份文字依据。reconstruction_mode=same 仍保留旧 image-edit 路径,用于确实需要精确复刻且有授权的场景。生成视图可由前端传 views 控制:全部 10、常用 4 或自定义;每个 view 单独调用一次生图,明确禁止多视图拼图、contact sheet、多主体、多面板、标签或对比排版。后端不再要求整包全成功才写入:单个视图失败时会保留已成功生成的主体图,返回“部分生成完成”,只有一张都没生成出来才返回错误。replace_views=true 时会替换同一视角旧图;删除接口会移除对应 subject asset 记录并删除本地 jpg 文件。 + 首尾帧资产POST /frames/{idx}/scene-assetgenerateSceneAsset同一接口兼容旧场景图和新首尾帧;当前信息流复刻流程传 asset_role=first_frame/last_framesubject_brief 和最多 1-2 张 product_images。首尾帧不再传主体图、不再把主体图和产品图拼成 contact sheet;主体只走文字 brief,允许新动作、新景别、新表情和新环境。若本条需要产品,后端只把产品参考图作为 gpt-image-2 image-edit 的硬视觉真源;若不需要产品,则走纯文字生图。关键帧只作为行数据承载位置。生成结果保存在 scene_assets,前端再写入 StoryboardScene.first_image/last_image。 产品图库GET /product-library/skglistProductLibrary读取内置 SKG 白底图库 manifest,返回产品标题、品类、尺寸、白底评分和预览图 URL。 产品图入库到 jobPOST /jobs/{id}/assetsPOST /jobs/{id}/assets/product-libraryuploadStoryboardAssetcopyProductLibraryAsset上传产品图或把内置产品图库条目复制为当前 job 的普通 asset。后端统一生成最长边 1600px、JPEG 92 的 AI 工作副本,透明底铺白,过大/过小图片会在 ImageRef.asset_meta 里返回转换动作和风险;黑底/白底背景本身不强行转换。注意该接口只写图片文件,产品素材池列表另由 PUT /jobs/{id}/product-refs 持久化。 产品素材池保存PUT /jobs/{id}/product-refssaveProductRefs把当前 job 的产品素材池列表、识别视角、用途标签、方向、结构点、备注、AI 补图和删除结果保存到 Job.product_refs / state.json。前端上传、识别完成、补角度、编辑备注和删除时都会同步保存;刷新页面或热更新后从 job 恢复,不再要求重新上传和重新识别。 @@ -1033,6 +1035,19 @@ ProductRefStateItem {

变更记录

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

+
+
+

2026-05-18 · 首尾帧改为主体 brief + 产品少量硬参考

+ Prompt + API + UI +
+
+

问题:首尾帧旧链路把 5 张主体图和 6 张产品图拼成 contact sheet 再做 image-edit,主体姿态和景别被参考图锁死,模型还容易把拼图布局误认为输出布局。

+

改动:KeyElement 新增 subject_consensus_brief;主体视图生成后由 Vision LLM 从 10 张图或模板 prompt_brief 写入统一主体 brief。generateEndpointFrameForRow 生成首尾帧时只传 subject_brief 和最多 1-2 张产品图,不再传 subject_imagesgenerate_scene_asset 在首/尾帧里只对产品图走 image-edit,没有产品图时走纯文字生图。前端状态栏改成“依据:主体 brief · N 张产品参考”,首尾帧 slot 的 info 图标可查看 brief 全文。

+

影响:主体可以在首尾帧里演新动作、新景别和新场景,但仍保持同一人设;产品结构继续由少量硬参考图锁住左右非对称、按键、触点、厚度和真实佩戴比例。

+
+

2026-05-18 · 主体生成加入随机/手动人设控制

diff --git a/web/components/ad-recreation-board.tsx b/web/components/ad-recreation-board.tsx index b66b276..ce706bb 100644 --- a/web/components/ad-recreation-board.tsx +++ b/web/components/ad-recreation-board.tsx @@ -51,6 +51,7 @@ import { saveProductRefs, sourceAudioUrl, subjectTemplateImageUrl, + updateElement, updateStoryboard, uploadStoryboardAsset, videoUrl, @@ -118,7 +119,7 @@ type AudioStoryboardRow = { } type ProductRefItem = ProductRefStateItem -type SubjectPlanningRef = ImageRef & { view: string; roleHint: string } +type SubjectPlanningRef = ImageRef & { view: string; roleHint: string; consensusBrief?: string } type SubjectStyleMode = "transparent_human" | "source_actor" type SubjectMode = "template" | "source_similar" type SubjectViewMode = "all" | "common" | "custom" @@ -301,6 +302,7 @@ const PRODUCT_VIEW_SLOTS = [ ] as const const MAX_PRODUCT_REFS_PER_VIDEO = 6 +const MAX_PRODUCT_REFS_PER_ENDPOINT = 2 const MAX_SUBJECT_REFS_PER_ENDPOINT = 5 const PRODUCT_BACKGROUND_LABELS: Record = { @@ -1235,8 +1237,30 @@ function productPriorityForRow(row: AudioStoryboardRow) { } } -function scoreProductItemForRow(row: AudioStoryboardRow, item: ProductRefItem, index: number) { - const priority = productPriorityForRow(row) +function endpointProductPriority(row: AudioStoryboardRow, role?: "first_frame" | "last_frame") { + const text = `${row.role} ${row.visualMode} ${row.visualPlan} ${row.firstFramePlan} ${row.lastFramePlan} ${row.productIntegration} ${row.productPlacement} ${role ?? ""}`.toLowerCase() + const views = ["front"] + const tags = ["hero_packshot", "wearing_scale"] + const add = (view: string, tag?: string) => { + if (!views.includes(view)) views.push(view) + if (tag && !tags.includes(tag)) tags.push(tag) + } + if (/后颈|肩背|背面|背部|后背|上背|尾帧|佩戴完成|贴合完成/.test(text)) add("back_bottom", "back_bottom") + if (/侧面|侧身|厚度|侧厚|体积|左侧|右侧|45|调整|拿起|靠近肩颈/.test(text)) add("side_thickness", "side_thickness") + if (/内侧|触点|按摩头|贴颈|接触|皮肤接触/.test(text)) add("inner_contacts", "inner_contact") + if (/佩戴比例|上身|真人佩戴|脖子|肩颈|锁骨/.test(text)) add("left_45", "wearing_scale") + if (/按键|按钮|控制|开关|logo/.test(text)) add("right_45", "button_detail") + return { views, tags } +} + +function endpointProductMaxForRow(row: AudioStoryboardRow, role?: "first_frame" | "last_frame") { + const text = `${row.visualPlan} ${row.firstFramePlan} ${row.lastFramePlan} ${row.productIntegration} ${row.productPlacement} ${role ?? ""}`.toLowerCase() + return /侧面|侧身|厚度|侧厚|后颈|肩背|背面|背部|内侧|触点|按摩头|贴颈|特写|近景|按键|按钮|佩戴完成|上背/.test(text) + ? MAX_PRODUCT_REFS_PER_ENDPOINT + : 1 +} + +function scoreProductItem(row: AudioStoryboardRow, item: ProductRefItem, index: number, priority: { views: string[]; tags: string[] }) { const viewRank = priority.views.indexOf(item.view) const tagScore = item.useTags.reduce((sum, tag) => { const rank = priority.tags.indexOf(tag) @@ -1249,20 +1273,26 @@ function scoreProductItemForRow(row: AudioStoryboardRow, item: ProductRefItem, i return (viewRank >= 0 ? 30 - viewRank * 4 : 0) + tagScore + backgroundScore + riskScore + confidenceScore + rotationScore } -function selectProductItemsForRow(row: AudioStoryboardRow, items: ProductRefItem[]) { +function selectProductItemsForRow( + row: AudioStoryboardRow, + items: ProductRefItem[], + mode: "video" | "endpoint" = "video", + role?: "first_frame" | "last_frame", +) { if (!items.length) return [] const picked: ProductRefItem[] = [] const pickedIds = new Set() + const maxItems = mode === "endpoint" ? endpointProductMaxForRow(row, role) : MAX_PRODUCT_REFS_PER_VIDEO + const priority = mode === "endpoint" ? endpointProductPriority(row, role) : productPriorityForRow(row) const add = (item?: ProductRefItem) => { - if (!item || pickedIds.has(item.id) || picked.length >= MAX_PRODUCT_REFS_PER_VIDEO) return + if (!item || pickedIds.has(item.id) || picked.length >= maxItems) return picked.push(item) pickedIds.add(item.id) } - const priority = productPriorityForRow(row) for (const view of priority.views) { const matches = items - .map((item, index) => ({ item, score: scoreProductItemForRow(row, item, index) })) + .map((item, index) => ({ item, score: scoreProductItem(row, item, index, priority) })) .filter(({ item }) => item.view === view) .sort((a, b) => b.score - a.score) add(matches[0]?.item) @@ -1270,14 +1300,14 @@ function selectProductItemsForRow(row: AudioStoryboardRow, items: ProductRefItem for (const tag of priority.tags) { const matches = items - .map((item, index) => ({ item, score: scoreProductItemForRow(row, item, index) })) + .map((item, index) => ({ item, score: scoreProductItem(row, item, index, priority) })) .filter(({ item }) => item.useTags.includes(tag)) .sort((a, b) => b.score - a.score) add(matches[0]?.item) } const ranked = items - .map((item, index) => ({ item, score: scoreProductItemForRow(row, item, index) })) + .map((item, index) => ({ item, score: scoreProductItem(row, item, index, priority) })) .sort((a, b) => b.score - a.score) for (const { item } of ranked) { add(item) @@ -1366,9 +1396,19 @@ function subjectAssetRefsForPlanning(source: { frame: KeyFrame; element: KeyElem label: asset.label || asset.view || "相似主体视图", view: asset.view, roleHint: subjectViewRoleHint(asset.view), + consensusBrief: source.element.subject_consensus_brief || "", })) } +function subjectBriefForEndpoint(row: AudioStoryboardRow, refs: SubjectPlanningRef[]) { + const storedBrief = refs.find((ref) => ref.consensusBrief?.trim())?.consensusBrief?.trim() + if (storedBrief) return storedBrief + const manualBrief = row.subjectDescription.trim() + if (manualBrief) return manualBrief + if (row.needsSubject) return subjectDescriptionForRow(row, refs) + return "" +} + function endpointAssetRef(frame: KeyFrame | null, role: "first_frame" | "last_frame"): ImageRef | null { if (!frame) return null const key = role === "first_frame" ? "first_image" : "last_image" @@ -1387,12 +1427,10 @@ function endpointAssetRef(frame: KeyFrame | null, role: "first_frame" | "last_fr } } -function buildEndpointFramePrompt(row: AudioStoryboardRow, role: "first_frame" | "last_frame", selectedProductItems: ProductRefItem[], subjectRefs: SubjectPlanningRef[]) { +function buildEndpointFramePrompt(row: AudioStoryboardRow, role: "first_frame" | "last_frame", selectedProductItems: ProductRefItem[], subjectBrief: string) { const target = role === "first_frame" ? row.firstFramePlan : row.lastFramePlan const opposite = role === "first_frame" ? row.lastFramePlan : row.firstFramePlan const productNotes = selectedProductItems.length ? productReferenceNotes(selectedProductItems) : "" - const subjectNotes = subjectRefs.length ? subjectReferenceNotes(subjectRefs) : "" - const subjectDescription = subjectDescriptionForRow(row, subjectRefs) return [ `分镜 ${row.index + 1} ${role === "first_frame" ? "首帧" : "尾帧"}。`, `新口播文案:${row.skgCopy}`, @@ -1401,10 +1439,10 @@ function buildEndpointFramePrompt(row: AudioStoryboardRow, role: "first_frame" | `另一端画面用于连续性参考:${opposite}`, `画面规划:${row.visualPlan}`, row.needsSubject - ? `人物主体:${subjectDescription} 必须使用已生成的相似主体白底视图作为人物真源;本次只选择 ${subjectRefs.length} 张最符合镜头需求的主体视角:${subjectNotes}。不要回到原视频关键帧复刻人物。` + ? `人物主体 brief:${subjectBrief || "主体 brief 暂缺,请保持一个统一的商业广告主体,肩颈区域清晰可佩戴产品。"}。主体只依据这段文字身份描述,不上传主体参考图;可以根据本镜头自由改变动作、景别、表情和环境,但不能换成另一个人设。不要回到原视频关键帧复刻人物。` : "本条不需要主角人物;如出现人物,只能是局部手部、背影或环境人物,不要生成透明骨架主角。", row.needsProduct - ? `产品融入:${row.productPlacement}。${row.productIntegration}。已提供 ${selectedProductItems.length} 张同一 SKG 肩颈按摩仪产品参考;${productNotes}。产品是套在脖子上的 U 形肩颈按摩仪,必须保持真实佩戴大小、左右非对称和贴颈位置。` + ? `产品融入:${row.productPlacement}。${row.productIntegration}。本次只提供 ${selectedProductItems.length} 张同一 SKG 肩颈按摩仪产品硬参考;${productNotes}。产品是套在脖子上的 U 形肩颈按摩仪,必须保持真实佩戴大小、左右非对称、按键、触点、厚度和贴颈位置。` : "本条不露出产品,不要强行生成 SKG 产品、包装、白底图或随机商品。", "输出一张单独的 9:16 高清首/尾帧,不要拼图,不要字幕,不要平台 UI,不要水印。画面要能作为后续视频生成的明确起止帧。", ].join("\n") @@ -1422,6 +1460,7 @@ function buildStoryboardSceneFromAudioRow( const notes = productReferenceNotes(selectedProductItems) const subjectDescription = subjectDescriptionForRow(row, subjectRefs) const subjectNotes = subjectReferenceNotes(subjectRefs) + const subjectBrief = subjectBriefForEndpoint(row, subjectRefs) const productGuidance = !row.needsProduct ? "本条规划为不露出产品或不把产品作为画面主体;视频生成时不要硬插 SKG 产品、包装、白底图或错误商品。" : productItems.length @@ -1434,6 +1473,7 @@ function buildStoryboardSceneFromAudioRow( visual_mode: row.visualMode, needs_product: row.needsProduct, needs_subject: row.needsSubject, + subject_brief: row.needsSubject ? subjectBrief : "", first_frame_plan: row.firstFramePlan, last_frame_plan: row.lastFramePlan, product_placement: row.productPlacement, @@ -2353,6 +2393,8 @@ function SourceReferenceBuildPanel({ const [templateSaveBusy, setTemplateSaveBusy] = useState(false) const [templateDraftName, setTemplateDraftName] = useState("") const [templateDraftNote, setTemplateDraftNote] = useState("") + const [subjectBriefDraft, setSubjectBriefDraft] = useState("") + const [subjectBriefBusy, setSubjectBriefBusy] = useState(false) const frames = useMemo(() => [...job.frames].sort((a, b) => a.timestamp - b.timestamp), [job.frames]) const selectedReferenceFrames = useMemo( () => frames.filter((frame) => selectedFrames.has(frame.index)), @@ -2423,6 +2465,10 @@ function SourceReferenceBuildPanel({ const generationCtaLabel = subjectMode === "template" ? `用模板生成 ${selectedSubjectViews.length} 张主体视图` : `从源视频创新生成 ${selectedSubjectViews.length} 张主体视图` + const currentSubjectBrief = actorSource?.element.subject_consensus_brief?.trim() + || selectedSubjectTemplate?.prompt_brief?.trim() + || selectedCharacter?.prompt_brief?.trim() + || "" const buildSubjectProfileForRequest = () => { if (subjectProfileMode === "random") { @@ -2468,6 +2514,10 @@ function SourceReferenceBuildPanel({ setLastSubjectProfile(null) }, [job.id]) + useEffect(() => { + setSubjectBriefDraft(currentSubjectBrief) + }, [actorSource?.element.id, currentSubjectBrief]) + const generateSimilarActor = async () => { if (!frames.length) { toast.warning("请先自动抽帧 12 张,或在原版视频上手动补帧。") @@ -2584,6 +2634,25 @@ function SourceReferenceBuildPanel({ } } + const saveSubjectBriefDraft = async () => { + if (!actorSource) { + toast.warning("先生成本次主体视图,才能把 brief 绑定到主体元素。") + return + } + setSubjectBriefBusy(true) + try { + const updated = await updateElement(job.id, actorSource.frame.index, actorSource.element.id, { + subject_consensus_brief: subjectBriefDraft.trim(), + }) + onJobUpdate(updated) + toast.success("主体 brief 已保存,后续首尾帧会使用这段文字依据") + } catch (e) { + toast.error("主体 brief 保存失败:" + (e instanceof Error ? e.message : String(e))) + } finally { + setSubjectBriefBusy(false) + } + } + const saveGeneratedSubjectTemplate = async () => { if (!actorSource || !visibleActorAssets.length) { toast.warning("请先生成相似主体视图。") @@ -2772,6 +2841,29 @@ function SourceReferenceBuildPanel({
{templateSaveHint}
+
+
+ 主体 brief 预览 / 首尾帧文字依据 + +
+