From edaa31426105cf983d4b3755b81ec94f0924b234 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Wed, 17 Jun 2026 21:50:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E6=95=85=E4=BA=8B=E6=9D=BF/?= =?UTF-8?q?=E8=A7=86=E9=A2=91=E6=8F=90=E7=A4=BA=E8=AF=8D=E6=94=B9=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E9=92=A6=E5=AE=9A=20@=E5=9B=BEN=20=E6=A8=A1=E6=9D=BF?= =?UTF-8?q?=E6=8B=BC=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 故事板:撤『分镜表』回『一镜=一张导演故事板』;refs 版出【设定】@图N是角色/商品/场景 + 导演故事板指令 + 【分镜脚本】(本段约X秒) + 该镜脚本;_storyboard_reference_images 按 角色→商品→场景 排序,@图N 与 image_edit 传图顺序锁死。视频:_video_reference_images 收齐 角色/场景/商品 基础资产 + 本镜故事板帧(四类带类型,角色→场景→商品→分镜图);build_video_segment_prompt 出【设定】@图N…【分镜】根据@图N分镜图…【脚本】该镜脚本;submit_video_segment 传图顺序与 @图N 对齐。新增 _segment_script_text 复用。 Co-Authored-By: Claude Opus 4.8 (1M context) --- core/backend/apps/ai/services.py | 160 +++++++++++++++++++------------ 1 file changed, 97 insertions(+), 63 deletions(-) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 1f2503e..425af2f 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -676,48 +676,59 @@ def _scene_context(project) -> str: return " · ".join(parts) -def build_storyboard_frame_prompt(project, version, segment) -> str: - """单场故事板提示词:把这一「场」拆成多个分镜,排成一张竖屏【分镜表】。 - 用户要的结构是「一场 = 一个故事板,内含多个分镜图(不同景别/运镜)」,而非一场一张大图。""" - visual = (segment.visual_prompt or segment.narration or "").strip() - dur = segment.duration_seconds or 15 - sub = max(2, min(4, round(dur / 5))) # 按本场时长估分镜数:15s≈3 格 - lines = [ - _scene_context(project), - f"生成一张竖屏【分镜故事板表】:把第 {segment.sort_order + 1} 场(约 {dur} 秒)拆成 {sub} 个分镜," - f"从上到下纵向排成 {sub} 个等宽分格,格与格之间用细线或留白分隔,每格角落标注分镜序号、景别与时长。", - ] +def _segment_script_text(segment) -> str: + """本镜脚本文本(画面 + 口播/旁白 + 商品露出),拼进故事板/视频提示词的【分镜脚本】。""" + parts = [] + visual = (segment.visual_prompt or "").strip() if visual: - lines.append(f"本场画面:{visual}") - if segment.narration: - lines.append(f"台词/口播:{segment.narration.strip()}") - if segment.product_exposure: - lines.append(f"商品露出方式:{segment.product_exposure.strip()}") - lines.append( - "各分镜要体现不同景别与运镜(如 中景固定 → 特写微距 → 全景跟拍),镜头连贯," - "同一人物保持同一张脸、同一商品保持外观与配色一致。" - ) + parts.append(f"画面:{visual}") + narration = (segment.narration or "").strip() + if narration: + parts.append(f"口播/旁白:{narration}") + if getattr(segment, "product_exposure", ""): + parts.append(f"商品露出:{segment.product_exposure.strip()}") + return "\n".join(parts) + + +def build_storyboard_frame_prompt(project, version, segment) -> str: + """单镜导演故事板提示词(一镜 = 一张导演故事板,用户钦定结构)。无参考图时的文本版。""" + dur = segment.duration_seconds or 15 + lines = [ + "根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。", + _scene_context(project), + f"【分镜脚本】(本段时长约 {dur} 秒)", + _segment_script_text(segment) or f"第 {segment.sort_order + 1} 镜", + ] if version.prompt: lines.append(version.prompt.strip()) - lines.append("真实清新、生活化的电商带货分镜表,竖屏 9:16,排版工整,画面清晰,可直接指导视频生成。") + lines.append("电商竖屏 9:16 导演故事板,真实清新、生活化,画面清晰,可直接指导视频生成。") return "\n".join(line for line in lines if line) -def build_video_segment_prompt(project, video_segment, scene, user_prompt: str) -> str: - """单段视频提示词:把本镜旁白 + 画面 + 风格锚点织进去,让每个视频片段跟住对应脚本/故事板。""" - lines = [_scene_context(project)] - if scene is not None: - if scene.narration: - lines.append(f"旁白:{scene.narration.strip()}") - visual = (scene.visual_prompt or scene.narration or "").strip() - if visual: - lines.append(f"画面:{visual}") - if user_prompt: - lines.append(user_prompt.strip()) - lines.append( - f"第 {video_segment.sort_order + 1} 段 · {video_segment.target_duration_seconds}s · " - "9:16 竖屏电商带货短视频,镜头稳定,商品露出清晰,节奏有转化感" - ) +def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt: str = "") -> str: + """单段视频提示词(用户钦定 @图N 格式): + 【设定】@图N 点名 角色/场景/商品;【分镜】根据 @图(分镜图) 生成;【脚本】本镜脚本。 + refs 顺序与传给 seedance 的 reference_images 一致(@图N 对齐不错位)。""" + product_name = (getattr(project.product, "title", "") or "商品").strip() + setup_parts = [] + storyboard_idx = None + for i, r in enumerate(refs or []): + n = i + 1 + if r.get("type") == "storyboard": + storyboard_idx = n + else: + setup_parts.append(f"@图{n}是{r.get('label') or ''}({_ENTITY_TYPE_CN.get(r.get('type'), '参考')})") + lines = [] + if setup_parts: + lines.append("【设定】" + ",".join(setup_parts) + "。") + if storyboard_idx is not None: + lines.append(f"【分镜】根据@图{storyboard_idx}分镜图生成「{product_name}」短视频。") + script_text = _segment_script_text(scene) if scene is not None else "" + extra = (user_prompt or "").strip() + if extra: + script_text = (script_text + "\n" + extra).strip() if script_text else extra + lines.append("【脚本】" + (script_text or f"第 {video_segment.sort_order + 1} 段")) + lines.append(f"{video_segment.target_duration_seconds}s · 9:16 竖屏电商带货短视频,镜头稳定,商品露出清晰,节奏有转化感。") return "\n".join(line for line in lines if line) @@ -780,22 +791,32 @@ def _storyboard_reference_images(project, segment) -> list[dict]: url = _asset_preview_url(pg.adopted_asset) if url: out.append({"url": url, "label": "商品", "type": "product"}) + # 规范 @图N 顺序:角色 → 商品 → 场景(与下游 image_edit 传图顺序一致,标注不错位) + _ord = {"character": 0, "product": 1, "scene": 2} + out.sort(key=lambda r: _ord.get(r.get("type"), 9)) return out def build_storyboard_frame_prompt_refs(project, version, segment, refs: list[dict]) -> str: - """参考图合成版故事板提示词:在基础提示词上点名每张参考图,要求锁脸/锁商品外观。""" - base = build_storyboard_frame_prompt(project, version, segment) + """参考图合成版(用户钦定格式):顶部 @图N 点名每张参考图(角色/商品/场景),再给导演故事板指令 + 分镜脚本。 + @图N 顺序与传给 gpt-image-2 的参考图顺序严格一致(refs 即 image_edit 的 images 顺序)。""" if not refs: - return base - ref_lines = ";".join( - f"参考图{i + 1}={r['label']}({_ENTITY_TYPE_CN.get(r.get('type'), '参考')})" for i, r in enumerate(refs) - ) - return ( - f"{base}\n参考图对应:{ref_lines}。" - "请严格保持各参考图中角色的同一张脸、同一商品的外观与配色,按本场各分镜画面重新构图," - "合成为一张包含多个分镜格的竖屏分镜表(每格一个分镜画面)。" + return build_storyboard_frame_prompt(project, version, segment) + setup = ",".join( + f"@图{i + 1}是{r['label']}({_ENTITY_TYPE_CN.get(r.get('type'), '参考')})" for i, r in enumerate(refs) ) + dur = segment.duration_seconds or 15 + lines = [ + f"【设定】{setup}。", + "根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。", + _scene_context(project), + f"【分镜脚本】(本段时长约 {dur} 秒)", + _segment_script_text(segment) or f"第 {segment.sort_order + 1} 镜", + "请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。", + ] + if version.prompt: + lines.append(version.prompt.strip()) + return "\n".join(line for line in lines if line) def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None: @@ -981,8 +1002,21 @@ def _asset_preview_url(asset) -> str: return "" -def _video_reference_images(project, video_segment) -> list[str]: - """为本视频段挑一张视觉参考图:优先本镜故事板帧,兜底已采用商品基础资产。""" +def _video_reference_images(project, video_segment) -> list[dict]: + """视频参考图(带类型,供 @图N):角色/场景/商品 基础资产 + 本镜故事板帧。 + 顺序:角色 → 场景 → 商品 → 分镜图(与用户钦定 @图1角色@图2场景@图3商品@图4分镜图 一致)。 + 返回 [{url,label,type}];都取不到时兜底商品图。""" + out: list[dict] = [] + scene = None + adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first() + if adopted_script is not None: + scene = adopted_script.segments.filter(sort_order=video_segment.sort_order).first() + if scene is not None: + refs = list(_storyboard_reference_images(project, scene)) # 角色/商品/场景 实体图 + _vord = {"character": 0, "scene": 1, "product": 2} + refs.sort(key=lambda r: _vord.get(r.get("type"), 9)) + out = refs + # 末位追加本镜故事板帧(@图N 末位 = 分镜图) version = ( project.storyboard_versions.filter(is_adopted=True).order_by("-created_at").first() or project.storyboard_versions.order_by("-created_at").first() @@ -995,17 +1029,17 @@ def _video_reference_images(project, video_segment) -> list[str]: if frame is not None: url = _asset_preview_url(frame.asset) if url: - return [url] - product_group = ( - project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PRODUCT, adopted_asset__isnull=False) - .order_by("-created_at") - .first() - ) - if product_group is not None: - url = _asset_preview_url(product_group.adopted_asset) - if url: - return [url] - return [] + out.append({"url": url, "label": "分镜图", "type": "storyboard"}) + if not out: + product_group = ( + project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PRODUCT, adopted_asset__isnull=False) + .order_by("-created_at").first() + ) + if product_group is not None: + url = _asset_preview_url(product_group.adopted_asset) + if url: + out.append({"url": url, "label": "商品", "type": "product"}) + return out def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> VideoSegmentVersion | None: @@ -1022,10 +1056,10 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V if scene is not None and video_segment.script_segment_id != scene.id: video_segment.script_segment = scene video_segment.save(update_fields=["script_segment", "updated_at"]) - final_prompt = build_video_segment_prompt(project, video_segment, scene, prompt) - - # 参考图:优先用本镜故事板帧,其次商品/人物基础资产,给视频做视觉锚点(衔接故事板→视频)。 - reference_images = _video_reference_images(project, video_segment) + # 参考图(带类型):角色/场景/商品 基础资产 + 本镜故事板帧;@图N 提示词与传图顺序严格对齐。 + refs = _video_reference_images(project, video_segment) + reference_images = [r["url"] for r in refs] + final_prompt = build_video_segment_prompt(project, video_segment, scene, refs, prompt) task = create_ai_task( project=project,