diff --git a/core/backend/apps/ai/generation_errors.py b/core/backend/apps/ai/generation_errors.py index b5db56a..1ab4875 100644 --- a/core/backend/apps/ai/generation_errors.py +++ b/core/backend/apps/ai/generation_errors.py @@ -404,6 +404,17 @@ def classify_generation_error( return _build_error("invalid_input", operation, reference_id=reference_id) if _contains(compact_code, "postprocesserror"): return _build_error("processing_failed", operation, reference_id=reference_id) + if _contains( + signal, + "没有用上商品卖点原词", + "没有出现品牌原词", + "必须原词用上", + "旁白太短", + "画面描写太短", + "按秒拆分镜", + "切两次景别", + ): + return _build_error("processing_failed", operation, reference_id=reference_id) return _build_error("unknown", operation, reference_id=reference_id) diff --git a/core/backend/apps/ai/migrations/0031_script_beat_prompt_templates.py b/core/backend/apps/ai/migrations/0031_script_beat_prompt_templates.py new file mode 100644 index 0000000..834a362 --- /dev/null +++ b/core/backend/apps/ai/migrations/0031_script_beat_prompt_templates.py @@ -0,0 +1,47 @@ +from django.db import migrations + +STORYBOARD = ( + "{设定}根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。\n{场景上下文}\n" + "【分镜脚本】(本段时长约 {时长} 秒)\n{脚本}\n" + "请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;" + "电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。" + "脚本里的 visual 是本段秒级分镜清单;请画商品用法正确、最能看清商品的那一拍作为关键帧。" + "禁止画出违背常识的用法(例如把茶包丢进冷白开、悬浮的手)。{补充}" +) +STORYBOARD_OLD = ( + "{设定}根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。\n{场景上下文}\n" + "【分镜脚本】(本段时长约 {时长} 秒)\n{脚本}\n" + "请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;" + "电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。{补充}" +) +VIDEO = ( + "{设定}{分镜}【脚本】{脚本}\n" + "严格按照脚本里的秒级分镜切换景别和动作,商品用法必须真实,不要诡异姿势或错误容器。" + "电商带货短视频,商品露出清晰,节奏有转化感。不要字幕,不要背景音乐,但是要有音效,逼真的音效。" +) +VIDEO_OLD = ( + "{设定}{分镜}【脚本】{脚本}\n" + "电商带货短视频,商品露出清晰,节奏有转化感。不要字幕,不要背景音乐,但是要有音效,逼真的音效。" +) + + +def _set(apps, key, template): + PromptTemplate = apps.get_model("ai", "PromptTemplate") + PromptTemplate.objects.filter(key=key).update(template=template) + + +def forwards(apps, schema_editor): + _set(apps, "storyboard_frame", STORYBOARD) + _set(apps, "video_segment", VIDEO) + + +def backwards(apps, schema_editor): + _set(apps, "storyboard_frame", STORYBOARD_OLD) + _set(apps, "video_segment", VIDEO_OLD) + + +class Migration(migrations.Migration): + + dependencies = [("ai", "0030_alter_aitask_task_type")] + + operations = [migrations.RunPython(forwards, backwards)] diff --git a/core/backend/apps/ai/script_agent.py b/core/backend/apps/ai/script_agent.py index 6b65b54..e69e9ca 100644 --- a/core/backend/apps/ai/script_agent.py +++ b/core/backend/apps/ai/script_agent.py @@ -19,8 +19,8 @@ SSE 事件(每帧 `data: {json}\n\n`,json 带 type): from __future__ import annotations import json -import math import re +import uuid from decimal import Decimal from functools import lru_cache from pathlib import Path @@ -36,10 +36,11 @@ VALID_TONES = ["种草", "测评", "剧情", "痛点"] VALID_ROLES = ["钩子", "痛点", "卖点", "CTA"] VALID_ENTITY_TYPES = ["character", "scene", "product"] -# 时长:总时长 5–60 秒按 5 秒步进;单镜 4–15 秒(15 是出片模型硬上限,越界下游直接拒片)。 -TOTAL_DURATION_MIN = 5 +# 时长:主流程每镜固定 15 秒;总时长只能是 15/30/45/60。 +# 4–15 仍是出片模型合法区间,手改单镜 / 精准改一镜(旧稿)才用。 +TOTAL_DURATION_MIN = 15 TOTAL_DURATION_MAX = 60 -TOTAL_DURATION_STEP = 5 +TOTAL_DURATION_STEP = 15 SEGMENT_DURATION_MIN = 4 SEGMENT_DURATION_MAX = 15 DEFAULT_TOTAL_DURATION = 30 @@ -58,22 +59,84 @@ DEFAULT_VIDEO_STRUCTURE = "pain" # 唯一禁用组合:短剧 × 测评验证。演出来的实测没有可信度,详见 playbooks/combo-matrix.md。 FORBIDDEN_COMBOS: set[tuple[str, str]] = {("drama", "review")} -# 表现形式推荐的单镜节奏(秒)。镜数 ≈ 总时长 / 该值,再夹到 4–15 秒的合法区间。 -FORMAT_SHOT_PACE: dict[str, int] = {"oral": 12, "drama": 8, "vlog": 7} # 表现形式推荐的默认总时长:口播短平快,短剧要装下三幕,Vlog 要铺氛围。 FORMAT_DEFAULT_DURATION: dict[str, int] = {"oral": 30, "drama": 45, "vlog": 30} -# 各结构能压到的最短总时长(低于此值证据/氛围不成立),见 playbooks/combo-matrix.md。 -STRUCTURE_MIN_DURATION: dict[str, int] = {"pain": 15, "contrast": 10, "review": 20, "scene": 20} +# 各结构能压到的最短总时长(须落在 15 秒步进上),见 playbooks/combo-matrix.md。 +STRUCTURE_MIN_DURATION: dict[str, int] = {"pain": 15, "contrast": 15, "review": 30, "scene": 30} -# 可懂语速上限 3.5 字/秒 —— 旁白字数按这一镜自己的秒数算,不再全场 55 字一刀切。 +# 设定卡人物 key → 中文(与前端 WIZ_PERSONA_LABEL / 模板 coerce_persona 对齐)。 +PERSONA_LABELS: dict[str, str] = { + "urban": "都市白领女性", + "bestie": "闺蜜种草", + "ceo": "总裁亲选", + "reviewer": "专业测评师", + "mom": "实用宝妈", + "genz": "学生党", +} +PERSONA_BRIEFS: dict[str, str] = { + "urban": "25–32岁都市白领,工位或下班回家,说话像跟同事吐槽,不要主播腔", + "bestie": "闺蜜分享口吻,带点兴奋,爱用「你懂的」「我跟你讲」", + "ceo": "利落、判断句、少形容词,像拍板不是带货", + "reviewer": "先讲怎么试的再给结论,允许提一个小缺点才可信", + "mom": "带娃/家务间隙,讲省事,孩子或家人能沾边", + "genz": "宿舍或通勤,短句,像给朋友发语音条", +} +_PERSONA_KEY_BY_LABEL = {label: key for key, label in PERSONA_LABELS.items()} + +# 可懂语速上限 3.5 字/秒;15 秒口播还要有下限,否则模型写一句 20 字就收,撑不满。 NARRATION_CHARS_PER_SECOND = 3.5 +NARRATION_CHARS_PER_SECOND_MIN = 2.4 NARRATION_CHARS_HARD_CAP = 55 +VISUAL_CHARS_MIN = 72 +SHOT_BEATS_MIN = 3 +BEAT_SPAN_RE = re.compile( + r"(?P\d{1,2})\s*[-–—~到至]\s*(?P\d{1,2})\s*(?:s|秒)?\s*[::]", + re.IGNORECASE, +) +_SHOT_SIZE_MARKERS = ( + "特写", "近景", "中近景", "中景", "全景", "远景", "胸上", "过肩", + "手持", "跟拍", "俯拍", "仰拍", "推近", "拉远", "摇", +) _FORMAT_KEY_BY_LABEL = {label: key for key, label in PRESENTATION_FORMATS.items()} _STRUCTURE_KEY_BY_LABEL = {label: key for key, label in VIDEO_STRUCTURES.items()} +def coerce_persona(value: str | None) -> str: + """把设定卡 key 或中文标签收成 key;空/未知原样(未知时当补充描述用)。""" + raw = (value or "").strip() + if not raw: + return "" + if raw in PERSONA_LABELS: + return raw + return _PERSONA_KEY_BY_LABEL.get(raw, raw) + + +def persona_label(value: str | None) -> str: + key = coerce_persona(value) + return PERSONA_LABELS.get(key, key) + + +def _wizard_meta(project) -> dict: + meta = getattr(project, "metadata", None) or {} + wizard = meta.get("wizard") if isinstance(meta, dict) else None + return wizard if isinstance(wizard, dict) else {} + + +def _resolve_selling_point_ids(project, selling_point_ids: list | None) -> list[str]: + if selling_point_ids: + return [str(item) for item in selling_point_ids if item] + raw = _wizard_meta(project).get("selling_point_ids") or [] + if not isinstance(raw, list): + return [] + return [str(item) for item in raw if item] + + +def _resolve_persona(project, persona: str | None) -> str: + return coerce_persona(persona) or coerce_persona(_wizard_meta(project).get("persona")) + + def combo_keys(value_format, value_structure) -> tuple[str, str]: """把「中文标签或 ASCII key」都归一成 key。落库存的是中文,请求传的是 key,两边都要认。""" fmt = _FORMAT_KEY_BY_LABEL.get(value_format, value_format) @@ -101,6 +164,61 @@ def narration_limit(duration: int) -> int: return max(1, min(NARRATION_CHARS_HARD_CAP, int(duration * NARRATION_CHARS_PER_SECOND))) +def narration_floor(duration: int) -> int: + """15 秒口播至少要说到这个字数,再短就撑不满镜头。""" + cap = narration_limit(duration) + return max(1, min(cap - 6, int(duration * NARRATION_CHARS_PER_SECOND_MIN))) + + +def _compact_len(text: str) -> int: + return len(re.sub(r"\s+", "", text or "")) + + +def _speech_text(seg: dict) -> str: + dialogue = seg.get("dialogue") if isinstance(seg.get("dialogue"), list) else [] + lines = [ + str(item.get("line") or "") + for item in dialogue + if isinstance(item, dict) and (item.get("line") or "").strip() + ] + if lines: + return "".join(lines) + return str(seg.get("narration") or "") + + +def min_beats_for_duration(duration: int) -> int: + if duration >= 12: + return SHOT_BEATS_MIN + if duration >= 8: + return 2 + return 0 + + +def parse_visual_beats(text: str) -> list[tuple[int, int, str]]: + """从 visual 文本里抽出「0-3s:…」分镜。接得上才能喂下游视频。""" + raw = (text or "").strip() + if not raw: + return [] + matches = list(BEAT_SPAN_RE.finditer(raw)) + if not matches: + return [] + beats: list[tuple[int, int, str]] = [] + for index, match in enumerate(matches): + start, end = int(match.group("start")), int(match.group("end")) + if end < start: + start, end = end, start + body_to = matches[index + 1].start() if index + 1 < len(matches) else len(raw) + body = re.sub(r"[\n\r]+", " ", raw[match.end():body_to]).strip(" ;;,,、") + if start == end or not body: + continue + beats.append((start, end, body)) + return beats + + +def format_visual_beats(beats: list[tuple[int, int, str]]) -> str: + return "\n".join(f"{start}-{end}s:{body}" for start, end, body in beats) + + # --------------------------------------------------------------------------- # # skill 加载(缓存) # --------------------------------------------------------------------------- # @@ -174,14 +292,16 @@ _OUTPUT_PROTOCOL = """ ## 运行时输出协议(AirShelf 流式展示专用,优先级高于技能正文的「只输出 JSON」) 严格按以下顺序输出,不要有别的内容: -1. 先用 **1 句中文口语**告诉用户你正在做什么(≤40 字,例:「在为这款保温杯生成 4 镜痛点脚本…」),让用户看到进展; +1. 先用 **1 句中文口语**告诉用户你正在做什么(≤40 字,例:「在为这款保温杯生成 2 镜痛点脚本…」),让用户看到进展; 2. 紧接着输出**且仅输出一个** ```json 代码块,内容为符合技能契约(铁律1)的 ScriptDraft 对象; 3. json 代码块**收尾之后另起一行**,用 **1–2 句中文口语**跟用户交付这一版:做了什么、为什么这么改、可以怎么接着调(像同事汇报,**别复述 JSON 字段、别再写代码块**)。这句会作为你的回复气泡展示给用户。 ### 字段名锚定(硬性 · 下游靠它取数,跑偏即数据全空) - 分镜数组的键名**必须**叫 `segments`(禁止用 scenes / script / shots / 分镜 等同义词)。 - 每镜口播键名**必须**叫 `narration`(禁止用 voiceover / audio / line)。 -- 每镜画面键名**必须**叫 `visual`,且为**一句话字符串**(禁止用 scene / screen / 画面,也禁止写成 {setting,camera,...} 对象)。 +- 每镜画面键名**必须**叫 `visual`(禁止用 scene / screen / 画面 当键名)。 +- `visual` 是**多行字符串**,每行一条秒级分镜:`0-3s:近景,……`(禁止只写一句静态动作,也禁止只给 setting/camera 对象而不给 visual)。 +- 允许额外给 `beats`/`shots` 数组,后端会折进 visual;有数组也必须能折成「起-止秒」格式。 - 即使你额外附带了 scenes / shots 等创作结构,也**必须同时**给出标准 `segments` 数组,并把口播填进 `narration`、画面填进 `visual`,否则视为不合格。 """ @@ -189,25 +309,87 @@ _OUTPUT_PROTOCOL = """ # --------------------------------------------------------------------------- # # 提示词构建(3 模式) # --------------------------------------------------------------------------- # -def _product_context(project, selling_point_ids: list[str] | None) -> str: +def _specs_lines(specs) -> str: + if not isinstance(specs, dict) or not specs: + return "" + skip = {"source"} + labels = {"price": "价格"} + lines: list[str] = [] + for key, val in specs.items(): + if key in skip or val in (None, "", [], {}): + continue + if isinstance(val, (dict, list)): + rendered = json.dumps(val, ensure_ascii=False) + else: + rendered = str(val).strip() + if not rendered: + continue + lines.append(f"- {labels.get(key, key)}:{rendered}") + return "\n".join(lines) + + +def _product_facts(project, selling_point_ids: list[str] | None): + """商品事实 + 本次勾选的卖点(供提示词和落库前原词校验共用)。 + + 新建向导历史上把卖点**标题**(如「茶」)写进 metadata.wizard.selling_point_ids, + 字段名叫 ids 但不是 UUID。按 id__in 过滤会直接 ValidationError 把 SSE 打崩。 + 这里 UUID 和标题都认;对不上就回落全部卖点,绝不抛。 + """ product = project.product - selling_points = product.selling_points.all() - if selling_point_ids: - selling_points = selling_points.filter(id__in=selling_point_ids) - selling_text = "\n".join(f"- {sp.title}:{sp.detail}" for sp in selling_points) + keys = [str(item).strip() for item in _resolve_selling_point_ids(project, selling_point_ids) if str(item).strip()] + all_points = list(product.selling_points.all()) + if not keys: + return product, all_points + uuid_keys: set[str] = set() + title_keys: set[str] = set() + for key in keys: + try: + uuid_keys.add(str(uuid.UUID(key))) + except (ValueError, AttributeError, TypeError): + title_keys.add(key) + matched = [ + point + for point in all_points + if str(getattr(point, "id", "") or "") in uuid_keys + or (getattr(point, "title", "") or "").strip() in title_keys + ] + return product, matched or all_points + + +def _product_context(project, selling_point_ids: list[str] | None, persona: str | None = None) -> str: + product, selling_points = _product_facts(project, selling_point_ids) + selling_text = "\n".join(f"- {sp.title}:{sp.detail or sp.title}" for sp in selling_points) business_type = getattr(product, "business_type", "") or "ecommerce" if business_type == "local_life": type_line = "业务类型:本地生活(团购/到店核销,无实物,按虚拟商品走主流程 SOP)\n" else: type_line = "业务类型:电商(实物商品)\n" + specs_text = _specs_lines(getattr(product, "specs", None) or {}) + persona_key = _resolve_persona(project, persona) + persona_text = persona_label(persona_key) + persona_brief = PERSONA_BRIEFS.get(persona_key, "") + must = [f"商品名「{product.title}」"] if product.title else [] + if product.brand: + must.append(f"品牌「{product.brand}」") + must.extend(f"卖点「{sp.title.strip()}」" for sp in selling_points if (sp.title or "").strip()) + must_line = "、".join(must) if must else "无(根据商品描述自行提炼,禁止空话套话)" + specs_block = f"\n{specs_text}" if specs_text else "未填写" + desc = (product.description or "").strip() or "未填写" return ( f"商品标题:{product.title}\n" f"品牌:{product.brand or '未填写'}\n" f"{type_line}" f"品类:{product.category or '未填写'}\n" f"目标人群:{product.target_audience or '未填写'}\n" - f"商品描述:{product.description or '未填写'}\n" - f"卖点:\n{selling_text or '未勾选卖点,请根据商品信息自行提炼。'}" + f"人物设定:{persona_text or '未指定,按目标人群自行定一个具体身份'}\n" + f"人物口吻:{persona_brief or '按人物设定自己定一个具体身份,不要用万能主播腔'}\n" + f"商品描述:{desc}\n" + f"规格:{specs_block}\n" + f"本次必须用上的卖点:\n{selling_text or '未勾选卖点,请根据商品信息自行提炼,禁止空话。'}\n" + f"【必须原词用上】旁白/对白里要出现:{must_line}。" + f"禁止用「补水/好用/值得买/宝藏」这类空卖点替换上面的原词。" + f"商品名全片点名 1–2 次即可,其余镜用卖点原词和可感知细节(口感/气味/动作/使用场景),不要每句重复商品名。" + f"描述若未填写,就从标题+卖点把使用感写具体,禁止只喊商品名。" ) @@ -223,23 +405,80 @@ def build_agent_messages( presentation_format: str = DEFAULT_PRESENTATION_FORMAT, video_structure: str = DEFAULT_VIDEO_STRUCTURE, target_index: int | None = None, + persona: str | None = None, ) -> list[dict[str, str]]: fmt, structure = coerce_combo(presentation_format, video_structure) - total = coerce_total_duration(total_duration) + if target_index is not None: + try: + total = int(total_duration) + except (TypeError, ValueError): + total = DEFAULT_TOTAL_DURATION + if total <= 0: + total = DEFAULT_TOTAL_DURATION + else: + total = coerce_total_duration(total_duration) system = load_ecommerce_skill(fmt, structure) + _OUTPUT_PROTOCOL - # 给一组建议时长(不是硬性),模型可以按内容调整,只要每镜 4–15 秒且加总不变。 suggested = plan_segment_durations(total, fmt) - pace_hint = "+".join(str(d) for d in suggested) + shot_n = len(suggested) + extra = (user_prompt or "").strip() + if target_index is not None and base_draft: + existing_n = len(base_draft.get("segments") or []) + duration_line = ( + f"【分镜时长】保持现有 {existing_n} 镜和每镜秒数," + f"只改第 {target_index + 1} 镜的文案与画面,不要加减镜、不要改时长。\n" + ) + else: + duration_line = ( + f"【分镜时长】每镜必须 {SEGMENT_DURATION_MAX} 秒,一共 {shot_n} 镜;" + f"禁止写成 8/10/12 这种不等长,也禁止加减镜数。\n" + ) + speech_floor = narration_floor(SEGMENT_DURATION_MAX) + speech_cap = narration_limit(SEGMENT_DURATION_MAX) + beats_line = ( + f"【秒级分镜】每个 {SEGMENT_DURATION_MAX} 秒场必须拆成 3–5 个分镜,visual 写成多行," + f"格式`0-3s:景别,谁在做什么`,最后一条接到 {SEGMENT_DURATION_MAX}s。" + "每条写清手/商品/容器的空间关系和真实用法" + "(茶=热水+蒸汽+茶汤变色,禁止茶包丢进冷白开;手从真实方向入画,禁止悬浮肢体)。" + "禁止一句空画面撑满 15 秒。允许另给 beats 数组,后端会折进 visual。\n" + ) + if fmt == "oral": + writing_line = ( + f"【写法硬约束】口播每镜必须说满 {speech_floor}–{speech_cap} 字(2–4 句短句," + f"禁止一句 20 字收工);visual 至少 {VISUAL_CHARS_MIN} 字。" + "第一镜用人物口吻交代身份,不要万能主播腔。\n" + ) + elif fmt == "drama": + writing_line = ( + f"【写法硬约束】对白每镜合计 {speech_floor}–{speech_cap} 字;" + f"visual 至少 {VISUAL_CHARS_MIN} 字。\n" + ) + else: + writing_line = ( + f"【写法硬约束】有人声的镜口播/对白 {speech_floor}–{speech_cap} 字,允许个别镜纯画面;" + f"visual 至少 {VISUAL_CHARS_MIN} 字。\n" + ) + structure_line = "" + if structure == "scene": + structure_line = ( + "【场景种草骨架】按镜走完:场景建立(商品先不出现)→商品被使用着入场→场景因它更好/软CTA。" + "每镜写出具体时刻+地点+光/声音/物件。口播时每镜仍要说满字数,留白靠停顿不是靠少写字。\n" + ) + combo_line = ( + f"严格按已加载的「{PRESENTATION_FORMATS[fmt]} × {VIDEO_STRUCTURES[structure]}」套路写," + f"不要串成别的结构的套话。\n" + ) head = ( f"【画幅】{aspect_ratio}\n" f"【表现形式】{PRESENTATION_FORMATS[fmt]}(套路见 playbooks/format-{fmt}.md,已加载)\n" f"【视频结构】{VIDEO_STRUCTURES[structure]}(套路见 playbooks/structure-{structure}.md,已加载)\n" f"【总时长】{total} 秒\n" - f"【分镜时长】单镜 4–15 秒,可以不等长;各镜相加必须精确等于 {total} 秒。\n" - f" 建议切成 {len(suggested)} 镜({pace_hint}),这是按「{PRESENTATION_FORMATS[fmt]}」的节奏算的;\n" - f" 你可以按内容调整镜数与每镜长短(该长的给足、该短的压短),但必须守住上面两条硬约束。\n" - f"【商品信息】\n{_product_context(project, selling_point_ids)}" + f"{duration_line}" + f"{writing_line}" + f"{beats_line}" + f"{structure_line}" + f"{combo_line}" + f"【商品信息】\n{_product_context(project, selling_point_ids, persona)}" ) if mode == "revise" and base_draft and target_index is not None: # 精准改一镜:读全脚本上下文,只重写第 N 镜,强制与前后镜衔接;其余镜后端会强制保持原样。 @@ -247,7 +486,7 @@ def build_agent_messages( f"【任务】只重写第 {target_index + 1} 镜(共 {len(base_draft.get('segments', []))} 镜),其余镜保持不变。\n" f"{head}\n\n" f"【现有完整脚本 JSON(读它保证与前后镜衔接)】\n{json.dumps(base_draft, ensure_ascii=False)}\n\n" - f"【对第 {target_index + 1} 镜的修改意见】{user_prompt.strip() or '让这一镜更有吸引力、表达更清晰。'}\n\n" + f"【对第 {target_index + 1} 镜的修改意见】{extra or '让这一镜更有吸引力、表达更清晰。'}\n\n" "仍输出**完整** ScriptDraft(我只会采用第 " f"{target_index + 1} 镜的改动);若意见涉及角色对白,就给这一镜填 dialogue。" ) @@ -256,21 +495,25 @@ def build_agent_messages( "【任务】改稿(模式③):在保留用户原意的前提下,增强钩子/节奏/卖点/CTA,并归一化到契约 JSON。\n" f"{head}\n\n" f"【现有脚本 JSON】\n{json.dumps(base_draft, ensure_ascii=False)}\n\n" - f"【用户修改意见】{user_prompt.strip() or '让整体更有吸引力、转化感更强,并保持各镜衔接连贯。'}\n\n" + f"【用户修改意见】{extra or '让整体更有吸引力、转化感更强,并保持各镜衔接连贯。'}\n\n" "请输出修订后的**完整** ScriptDraft。" ) - elif mode == "theme" or (user_prompt and user_prompt.strip()): + elif mode == "theme": user = ( - "【任务】一句话主题扩写(模式②):以用户主题为脚本主轴,其余自动补全。\n" + "【任务】一句话主题扩写(模式②):以用户主题为脚本主轴,但商品事实和必须原词仍要全部用上。\n" f"{head}\n\n" - f"【用户主题】{user_prompt.strip()}\n\n" + f"【用户主题】{extra or '按商品最强卖点选题'}\n\n" "请按技能流程一次性产出 ScriptDraft。" ) else: + extra_block = f"\n\n【补充要求】{extra}\n" if extra else "\n" user = ( - "【任务】全自动(模式①):仅凭商品与前置条件,按指定的表现形式与视频结构套路" - "自动定镜数/选 tone/造 entity/填结构骨架。\n" - f"{head}\n\n" + "【任务】全自动(模式①):仅凭上面的商品事实与前置条件,按指定的表现形式与视频结构套路" + "自动定镜/选 tone/造 entity/填结构骨架。" + "不要另起一个空主题;钩子、痛点、卖点必须能对上这份商品,而不是品类套话。" + "每镜旁白要能撑满 15 秒;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n" + f"{head}" + f"{extra_block}\n" "请按技能流程一次性产出 ScriptDraft。" ) return [{"role": "system", "content": system}, {"role": "user", "content": user}] @@ -333,7 +576,7 @@ def _extract_json(text: str) -> str | None: def coerce_total_duration(value) -> int: - """总时长夹到 5–60 秒、5 秒步进。空值/0/非法输入一律回落默认 30。""" + """总时长夹到 15–60 秒、15 秒步进(15/30/45/60)。空值/0/非法输入一律回落默认 30。""" if value in (None, "", 0): return DEFAULT_TOTAL_DURATION try: @@ -347,21 +590,11 @@ def coerce_total_duration(value) -> int: return max(TOTAL_DURATION_MIN, min(TOTAL_DURATION_MAX, stepped)) -def plan_segment_durations(total_duration: int, presentation_format: str) -> list[int]: - """把总时长切成每镜 4–15 秒、加总精确等于总时长的一组时长。 - - 镜数按表现形式的推荐节奏定(口播 12s/镜、短剧 8s/镜、Vlog 7s/镜),再夹进 - ceil(total/15) ~ total//4 的合法区间。余数摊到前面几镜,所以镜与镜之间最多差 1 秒—— - 这只是**兜底**,模型自己给的不等长时长只要合法就照用。 - """ +def plan_segment_durations(total_duration: int, presentation_format: str | None = None) -> list[int]: + """主流程每镜固定 15 秒。镜数 = 总时长 / 15。presentation_format 保留签名以免调用方改动。""" total = coerce_total_duration(total_duration) - fmt = presentation_format if presentation_format in FORMAT_SHOT_PACE else DEFAULT_PRESENTATION_FORMAT - count_min = math.ceil(total / SEGMENT_DURATION_MAX) - count_max = max(count_min, total // SEGMENT_DURATION_MIN) - count = max(1, round(total / FORMAT_SHOT_PACE[fmt])) - count = max(count_min, min(count_max, count)) - base, remainder = divmod(total, count) - return [base + 1 if i < remainder else base for i in range(count)] + count = max(1, total // SEGMENT_DURATION_MAX) + return [SEGMENT_DURATION_MAX] * count def plan_roles(count: int) -> list[str]: @@ -376,45 +609,106 @@ def plan_roles(count: int) -> list[str]: def _fit_segment_durations(raw: list, total: int, presentation_format: str) -> list[int]: - """采纳模型给的每镜时长(允许不等长),非法就修;修不动就整组回落到 plan_segment_durations。 + """主流程强制每镜 15 秒。模型给多少秒都丢掉,按总时长切成 N 个 15。""" + return plan_segment_durations(total, presentation_format) - 合法定义:每镜 4–15 秒的整数,且加总 == 总时长。模型很容易把总数算错一两秒, - 所以先夹单镜范围,再把差额摊到还有余量的镜上,尽量保住模型的节奏意图。 - """ - if not raw: - return plan_segment_durations(total, presentation_format) +def _layout_durations(preserve_layout: dict | None, count: int) -> list[int] | None: + """精准改一镜:保住原稿每镜秒数,避免 3 镜旧稿被压成 2×15。""" + if not preserve_layout: + return None + raw = preserve_layout.get("durations") + if not isinstance(raw, list) or len(raw) != count: + return None durations: list[int] = [] for value in raw: try: seconds = int(value) except (TypeError, ValueError): seconds = 0 - durations.append(max(SEGMENT_DURATION_MIN, min(SEGMENT_DURATION_MAX, seconds or SEGMENT_DURATION_MIN))) - - # 镜数本身就装不下总时长(太少会超 15s/镜,太多会低于 4s/镜)→ 模型节奏不可用,整组重排。 - count = len(durations) - if not (count * SEGMENT_DURATION_MIN <= total <= count * SEGMENT_DURATION_MAX): - return plan_segment_durations(total, presentation_format) - - diff = total - sum(durations) - while diff != 0: - step = 1 if diff > 0 else -1 - # 每轮只给「还有余量」的镜加/减 1 秒,均匀铺开,避免把某一镜顶到边界 - movable = [ - i for i, d in enumerate(durations) - if (step > 0 and d < SEGMENT_DURATION_MAX) or (step < 0 and d > SEGMENT_DURATION_MIN) - ] - if not movable: - return plan_segment_durations(total, presentation_format) - for i in movable: - if diff == 0: - break - durations[i] += step - diff -= step + durations.append(max(SEGMENT_DURATION_MIN, min(SEGMENT_DURATION_MAX, seconds or SEGMENT_DURATION_MAX))) return durations +def _script_plaintext(draft: dict) -> str: + parts = [str(draft.get("hook") or "")] + for seg in draft.get("segments") or []: + if not isinstance(seg, dict): + continue + parts.append(str(seg.get("narration") or "")) + parts.append(str(seg.get("visual") or "")) + parts.append(str(seg.get("product_exposure") or "")) + for item in seg.get("dialogue") or []: + if isinstance(item, dict): + parts.append(str(item.get("line") or "")) + return "".join(parts) + + +def _fact_mentioned(text: str, fact: str) -> bool: + fact = (fact or "").strip() + if not fact: + return False + if fact in text: + return True + # 超长卖点/标题模型常念前半截,允许前 4 字命中 + return len(fact) >= 6 and fact[:4] in text + + +def assert_product_facts_used(draft: dict, *, brand: str = "", selling_titles: list[str] | None = None) -> None: + """整版生成时至少用上一个勾选卖点原词。单字卖点(如「茶」)也算。 + + 品牌不强制:口播经常只喊卖点不念品牌,硬卡会把已经写对的稿整单作废。 + """ + del brand # 保留调用方签名,避免改一串入口 + text = _script_plaintext(draft) + titles = [t.strip() for t in (selling_titles or []) if (t or "").strip()] + if titles and not any(_fact_mentioned(text, title) for title in titles): + raise ValueError("脚本没有用上商品卖点原词,请按【必须原词用上】重写") + + +def assert_shot_density(draft: dict, presentation_format: str = DEFAULT_PRESENTATION_FORMAT) -> None: + """口播/短剧每镜要说满、画面要写厚;Vlog 允许个别镜无声,但开口就不能偷懒。""" + fmt, _ = coerce_combo(_FORMAT_KEY_BY_LABEL.get(presentation_format, presentation_format), DEFAULT_VIDEO_STRUCTURE) + allow_silent = fmt == "vlog" + for index, seg in enumerate(draft.get("segments") or []): + if not isinstance(seg, dict): + continue + try: + duration = int(seg.get("duration") or SEGMENT_DURATION_MAX) + except (TypeError, ValueError): + duration = SEGMENT_DURATION_MAX + visual_n = _compact_len(str(seg.get("visual") or "")) + if visual_n < VISUAL_CHARS_MIN: + raise ValueError(f"第 {index + 1} 镜画面描写太短,撑不满 {duration} 秒") + assert_intra_shot_beats(seg, index, duration) + speech_n = _compact_len(_speech_text(seg)) + if speech_n == 0 and allow_silent: + continue + floor = narration_floor(duration) + if speech_n < floor: + raise ValueError(f"第 {index + 1} 镜旁白太短,口播至少要说到 {floor} 字才能撑满 {duration} 秒") + + +def assert_intra_shot_beats(seg: dict, index: int, duration: int) -> None: + """15 秒场必须按秒拆出分镜,否则下游视频只能对着一句空描述乱编。""" + need = min_beats_for_duration(duration) + if need <= 0: + return + visual = str(seg.get("visual") or "") + beats = parse_visual_beats(visual) + if len(beats) < need: + raise ValueError( + f"第 {index + 1} 镜画面必须按秒拆分镜,至少 {need} 条「0-3s:景别,动作」,接到 {duration}s" + ) + if beats[0][0] > 1: + raise ValueError(f"第 {index + 1} 镜画面必须按秒拆分镜,第一条要从 0 秒起") + if beats[-1][1] < duration - 1: + raise ValueError(f"第 {index + 1} 镜画面必须按秒拆分镜,最后一条要接到 {duration}s") + markers = [mark for mark in _SHOT_SIZE_MARKERS if mark in visual] + if len(set(markers)) < 2: + raise ValueError(f"第 {index + 1} 镜 15 秒内至少要切两次景别,并写进秒级分镜") + + # 模型每次生成都可能换字段名(scene/screenDescription/visual…、dialogue/lines/caption…), # 与其逐一追变体,不如「优先键命中 → 否则按关键词模糊匹配」通用解析。SKIP 掉明显的非内容键, # 避免误抓(shotNo/duration/bgMusic/note 等)。 @@ -422,6 +716,7 @@ _PICK_SKIP_KEYS = { "shotno", "shotsize", "shotsizetype", "duration", "duration_seconds", "starttime", "endtime", "timerange", "time_range", "bgmusic", "bg_music", "music", "sound", "sfx", "note", "notes", "tips", "index", "role", "speaker", "entity_refs", "product_exposure", "id", "no", "transition", + "beats", "timeline", # 标题/编号类:含 scene 字样会误命中画面 fuzzy,显式跳过(注意:不跳裸 scene,它常=画面) "scene_id", "sceneid", "scene_no", "sceneno", "scene_number", "scenenumber", "scene_title", "scenetitle", "title", "scene_title_type", @@ -463,6 +758,113 @@ def _pick_field(seg: dict, exact: tuple[str, ...], fuzzy: tuple[str, ...]) -> st return "" +def _opt_int(value) -> int | None: + try: + if value is None or value == "": + return None + return int(value) + except (TypeError, ValueError): + return None + + +def _beat_from_dict(item: dict) -> dict | None: + action = _flatten_text( + item.get("action") + or item.get("shot") + or item.get("visual") + or item.get("description") + or item.get("画面") + or "" + ) + shot_size = str(item.get("shot_size") or item.get("camera") or item.get("景别") or "").strip() + start = _opt_int(item.get("start") if item.get("start") is not None else item.get("from")) + end = _opt_int(item.get("end") if item.get("end") is not None else item.get("to")) + time_s = str(item.get("time") or item.get("t") or "").strip() + if start is None and end is None and time_s: + parsed = parse_visual_beats(f"{time_s}:{action or '画面'}") + if parsed: + start, end, parsed_body = parsed[0] + action = action or parsed_body + if not action and not shot_size: + return None + piece = f"{shot_size},{action}" if shot_size and action else (action or shot_size) + return {"start": start, "end": end, "action": piece} + + +def coerce_beats(raw) -> list[dict]: + if raw is None or raw == "": + return [] + if isinstance(raw, str): + return [{"start": start, "end": end, "action": body} for start, end, body in parse_visual_beats(raw)] + if isinstance(raw, dict): + nested = raw.get("beats") or raw.get("shots") or raw.get("timeline") or raw.get("key_shots") or raw.get("分镜") + if nested: + return coerce_beats(nested) + beat = _beat_from_dict(raw) + return [beat] if beat else [] + if isinstance(raw, list): + out: list[dict] = [] + for item in raw: + if isinstance(item, dict): + nested = item.get("beats") or item.get("shots") + if nested and not (item.get("action") or item.get("shot") or item.get("visual")): + out.extend(coerce_beats(nested)) + continue + beat = _beat_from_dict(item) + if beat: + out.append(beat) + else: + out.extend(coerce_beats(item)) + return out + return [] + + +def _assign_beat_times(beats: list[dict], duration: int) -> list[tuple[int, int, str]]: + n = len(beats) + if n == 0: + return [] + out: list[tuple[int, int, str]] = [] + for index, beat in enumerate(beats): + start = beat.get("start") + end = beat.get("end") + if start is None: + start = int(round(index * duration / n)) + if end is None: + end = duration if index == n - 1 else int(round((index + 1) * duration / n)) + if end <= start: + end = start + 1 + out.append((int(start), int(end), str(beat.get("action") or "").strip())) + return [item for item in out if item[2]] + + +def compose_segment_visual(seg: dict, duration: int = SEGMENT_DURATION_MAX) -> str: + """把 beats 数组或已写好的秒级 visual 收成多行「0-3s:…」。""" + visual_raw = seg.get("visual") + if isinstance(visual_raw, str) and visual_raw.strip(): + parsed = parse_visual_beats(visual_raw) + if len(parsed) >= 2: + return format_visual_beats(parsed) + candidates = [ + seg.get("beats"), + seg.get("timeline"), + seg.get("分镜"), + visual_raw if isinstance(visual_raw, (list, dict)) else None, + ] + shots_raw = seg.get("shots") + if isinstance(shots_raw, list) and shots_raw: + sample = shots_raw[0] + looks_like_nested_segments = isinstance(sample, dict) and ( + sample.get("narration") or sample.get("role") in VALID_ROLES + ) + if not looks_like_nested_segments: + candidates.append(shots_raw) + for raw in candidates: + assigned = _assign_beat_times(coerce_beats(raw), duration) + if len(assigned) >= 2: + return format_visual_beats(assigned) + return _pick_field(seg, _VISUAL_EXACT, _VISUAL_FUZZY) + + # 模型给分镜数组的键名五花八门(segments/scenes/script/shots…),且常同时给一个**空的** # segments 骨架 + 真内容放在 scenes 里。所以不能「见 segments 是 list 就用」,要在所有候选里 # 挑「能解析出最多非空旁白/画面」的那个。segments(契约本名)排第一,同分时优先。 @@ -501,6 +903,7 @@ def normalize_draft( total_duration: int, presentation_format: str = DEFAULT_PRESENTATION_FORMAT, video_structure: str = DEFAULT_VIDEO_STRUCTURE, + preserve_layout: dict | None = None, ) -> dict: """把模型输出抽成 JSON 并按铁律1契约规范化。宽容:小问题就地修,不轻易抛错。""" blob = _extract_json(raw_text) @@ -564,13 +967,17 @@ def normalize_draft( draft["entities"] = norm_entities valid_ids = {e["id"] for e in norm_entities} - # segments 规范化:镜数交给模型(只夹进合法区间),role 枚举,引用合法 + # segments 规范化:主流程镜数 = 总时长/15;精准改一镜则保住原稿镜数。 segments = draft.get("segments") if isinstance(draft.get("segments"), list) else [] - # 镜数上下限由「单镜 4–15 秒」倒推:少于 count_min 会有镜超 15 秒,多于 count_max 会有镜不足 4 秒。 - count_min = math.ceil(dur / SEGMENT_DURATION_MAX) - count_max = max(count_min, dur // SEGMENT_DURATION_MIN) - segments = segments[:count_max] - seg_count = max(count_min, len(segments)) + layout_count = 0 + if preserve_layout: + try: + layout_count = int(preserve_layout.get("count") or 0) + except (TypeError, ValueError): + layout_count = 0 + expected = layout_count or max(1, dur // SEGMENT_DURATION_MAX) + segments = segments[:expected] + seg_count = expected role_plan = plan_roles(seg_count) norm_segments: list[dict] = [] for i, seg in enumerate(segments): @@ -604,12 +1011,12 @@ def normalize_draft( narration = " ".join(d["line"] for d in dialogue) # 扁平拼接,兼容下游字幕/配音 if not narration: narration = _pick_field(seg, _NARRATION_EXACT, _NARRATION_FUZZY) - # 画面:通用解析(visual/scene/screenDescription/画面… 都能命中,跳过 shotNo/bgMusic 等) - visual = _pick_field(seg, _VISUAL_EXACT, _VISUAL_FUZZY) + # 画面:优先收成秒级分镜;beats 数组会折进 visual,下游故事板/视频直接读这一段。 + visual = compose_segment_visual(seg) norm_segments.append( { "index": i, - "duration": seg.get("duration"), # 先原样收着,等镜数定了再统一夹进 4–15 秒并配平总时长 + "duration": seg.get("duration"), # 先原样收着,后面统一写成 15 或保住原稿秒数 "role": role, "narration": narration, "speaker": speaker, @@ -638,16 +1045,36 @@ def normalize_draft( if not norm_segments: raise ValueError("脚本没有任何分镜") - # 每镜时长:采纳模型的不等长意图,非法就修,修不动整组回落到按表现形式节奏均切。 - fitted = _fit_segment_durations([s["duration"] for s in norm_segments], dur, fmt) - for seg, seconds in zip(norm_segments, fitted): - seg["duration"] = seconds + kept = _layout_durations(preserve_layout, len(norm_segments)) + if kept: + fitted = kept + draft["total_duration"] = sum(fitted) + else: + fitted = plan_segment_durations(dur, fmt) + if len(fitted) != len(norm_segments): + fitted = [SEGMENT_DURATION_MAX] * len(norm_segments) + draft["total_duration"] = sum(fitted) + for index, (norm, seconds) in enumerate(zip(norm_segments, fitted)): + norm["duration"] = seconds + if index < len(segments) and isinstance(segments[index], dict): + composed = compose_segment_visual(segments[index], seconds) + if composed: + norm["visual"] = composed draft["segments"] = norm_segments draft["segment_count"] = len(norm_segments) return draft +def _preserve_layout_from(draft: dict | None) -> dict | None: + if not draft: + return None + segs = [s for s in (draft.get("segments") or []) if isinstance(s, dict)] + if not segs: + return None + return {"count": len(segs), "durations": [s.get("duration") for s in segs]} + + def _merge_single_segment( base: dict, new: dict, @@ -677,12 +1104,14 @@ def _merge_single_segment( target["index"] = idx segs[idx] = target merged["segments"] = segs + layout = _preserve_layout_from(base) return normalize_draft( json.dumps(merged, ensure_ascii=False), aspect_ratio=aspect_ratio, total_duration=total_duration, presentation_format=presentation_format, video_structure=video_structure, + preserve_layout=layout, ) @@ -802,6 +1231,7 @@ def stream_script_agent( video_structure: str = DEFAULT_VIDEO_STRUCTURE, target_index: int | None = None, entry_source: str = "", + persona: str | None = None, ): """生成 SSE 帧字符串的同步生成器,供 StreamingHttpResponse 包裹。 target_index 非空 = 精准只改第 N 镜(读全脚本上下文,后端强制保留其余镜原样)。""" @@ -820,15 +1250,24 @@ def stream_script_agent( if base_draft is None: target_index = None # 没有基准稿就退回整版生成,单镜改无从谈起 # 改稿以基准稿的时长/镜数为准,避免请求侧默认值把长稿的尾镜挤掉 - effective_duration = coerce_total_duration( - (base_draft.get("total_duration") if base_draft else None) or total_duration - ) + # 精准改一镜不把总时长夹成 15 步进,否则旧的不等长稿会被提示词误导切镜。 + if target_index is not None and base_draft is not None: + effective_duration = int(base_draft.get("total_duration") or total_duration or DEFAULT_TOTAL_DURATION) + else: + effective_duration = coerce_total_duration( + (base_draft.get("total_duration") if base_draft else None) or total_duration + ) # 精准改一镜:镜号越界直接报错返回,绝不建任务/扣费(避免计费空转的静默 no-op) if target_index is not None and base_draft is not None: seg_n = len(base_draft.get("segments", [])) if not (0 <= target_index < seg_n): yield _sse({"type": "error", "detail": f"镜号越界:第 {target_index + 1} 镜(共 {seg_n} 镜)"}) return + selling_point_ids = _resolve_selling_point_ids(project, selling_point_ids) + persona = _resolve_persona(project, persona) + product, selling_points = _product_facts(project, selling_point_ids) + selling_titles = [sp.title for sp in selling_points] + layout = _preserve_layout_from(base_draft) if target_index is not None else None messages = build_agent_messages( project=project, mode=mode, @@ -840,6 +1279,7 @@ def stream_script_agent( presentation_format=fmt, video_structure=structure, target_index=target_index, + persona=persona, ) yield _sse({"type": "tool", "id": "analyze", "status": "done"}) @@ -886,6 +1326,7 @@ def stream_script_agent( total_duration=effective_duration, presentation_format=fmt, video_structure=structure, + preserve_layout=layout, ) if target_index is not None and base_draft: return _merge_single_segment( @@ -897,6 +1338,13 @@ def stream_script_agent( fmt, structure, ) + if mode != "revise": + assert_product_facts_used( + candidate, + brand=getattr(product, "brand", "") or "", + selling_titles=selling_titles, + ) + assert_shot_density(candidate, fmt) return candidate routed_stream = stream_routed_text_request( @@ -1069,7 +1517,7 @@ def _draft_from_version(version) -> dict: (用户增删/改镜后 content 不一定同步)。total_duration 按各镜真实秒数加总,避免 normalize 按 stale 值截/补镜。""" meta = version.metadata or {} segs = list(version.segments.order_by("sort_order")) - # 镜可以不等长了,总时长必须按实际相加(旧写法 15×镜数 会在不等长稿上算出错误总时长) + # 总时长按各镜真实秒数加总(旧稿可能不等长;新稿每镜 15 秒)。 actual_total = sum(s.duration_seconds or SEGMENT_DURATION_MAX for s in segs) return { "hook": meta.get("hook", ""), @@ -1119,13 +1567,14 @@ def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, se project=project, mode="revise", user_prompt=instruction, - selling_point_ids=None, + selling_point_ids=_resolve_selling_point_ids(project, None), base_draft=base_draft, aspect_ratio=aspect_ratio, total_duration=total_duration, presentation_format=fmt, video_structure=structure, target_index=target_index, + persona=_resolve_persona(project, None), ) task = create_ai_task( project=project, @@ -1152,12 +1601,14 @@ def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, se task.submitted_at = timezone.now() task.save(update_fields=["status", "submitted_at", "updated_at"]) def validate_segment_text(raw_text: str) -> dict: + layout = _preserve_layout_from(base_draft) candidate = normalize_draft( raw_text, aspect_ratio=aspect_ratio, total_duration=total_duration, presentation_format=fmt, video_structure=structure, + preserve_layout=layout, ) return _merge_single_segment( base_draft, candidate, target_index, aspect_ratio, total_duration, fmt, structure diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 8558247..9722b57 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -2508,7 +2508,8 @@ def _segment_script_text(segment, entities=None, *, with_dialogue: bool = False, parts = [] visual = (segment.visual_prompt or "").strip() if visual: - parts.append(f"画面:{visual}") + label = "秒级分镜(按时间切镜,商品用法必须真实,禁止诡异动作/错误容器)" if ("s:" in visual or "s:" in visual or "秒" in visual[:12]) else "画面" + parts.append(f"{label}:\n{visual}") dialogue = getattr(segment, "dialogue", None) if with_dialogue else None name_by_id = {e.get("id"): (e.get("name") or "").strip() for e in (entities or []) if isinstance(e, dict)} lines = [] @@ -2540,7 +2541,9 @@ def build_storyboard_frame_prompt(project, segment, extra_prompt: str = "") -> s "{设定}根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。\n{场景上下文}\n" "【分镜脚本】(本段时长约 {时长} 秒)\n{脚本}\n" "请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;" - "电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。{补充}" + "电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。" + "脚本里的 visual 是本段秒级分镜清单;请画商品用法正确、最能看清商品的那一拍作为关键帧。" + "禁止画出违背常识的用法(例如把茶包丢进冷白开、悬浮的手)。{补充}" ) rendered = render_prompt( "storyboard_frame", default, @@ -2574,7 +2577,11 @@ def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt: script_text = (script_text + "\n" + extra).strip() if script_text else extra # 正文可在 admin「提示词·视频」页改。占位符:{设定}=@图N点名、{分镜}=分镜图引用、{脚本}=本段脚本、{时长}。 # 时长/比例靠 API 参数传(duration/ratio),不写进正文;尾句给风格 + 音效/字幕要求。 - default = "{设定}{分镜}【脚本】{脚本}\n电商带货短视频,商品露出清晰,节奏有转化感。不要字幕,不要背景音乐,但是要有音效,逼真的音效。" + default = ( + "{设定}{分镜}【脚本】{脚本}\n" + "严格按照脚本里的秒级分镜切换景别和动作,商品用法必须真实,不要诡异姿势或错误容器。" + "电商带货短视频,商品露出清晰,节奏有转化感。不要字幕,不要背景音乐,但是要有音效,逼真的音效。" + ) rendered = render_prompt( "video_segment", default, 设定=("【设定】" + ",".join(setup_parts) + "。\n" if setup_parts else ""), @@ -2744,7 +2751,9 @@ def build_storyboard_frame_prompt_refs(project, segment, refs: list[dict], extra "{设定}根据以下脚本生成一个导演故事板,用于指导 seedance 的视频生成。\n{场景上下文}\n" "【分镜脚本】(本段时长约 {时长} 秒)\n{脚本}\n" "请严格保持各参考图中角色的同一张脸、同一商品的外观与配色;" - "电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。{补充}" + "电商竖屏 9:16 导演故事板,一镜一图,画面清晰,可直接指导视频生成。" + "脚本里的 visual 是本段秒级分镜清单;请画商品用法正确、最能看清商品的那一拍作为关键帧。" + "禁止画出违背常识的用法(例如把茶包丢进冷白开、悬浮的手)。{补充}" ) rendered = render_prompt( "storyboard_frame", default, diff --git a/core/backend/apps/ai/test_generation_errors.py b/core/backend/apps/ai/test_generation_errors.py index b7049d3..fca89ec 100644 --- a/core/backend/apps/ai/test_generation_errors.py +++ b/core/backend/apps/ai/test_generation_errors.py @@ -322,3 +322,11 @@ class GenerationErrorClassifierTests(SimpleTestCase): self.assertEqual(input_error.code, "invalid_input") self.assertEqual(processing_error.code, "processing_failed") self.assertNotIn("storage", processing_error.fallback_message) + + def test_short_script_copy_is_processing_failed_not_unknown(self): + error = classify_generation_error( + ValueError("第 1 镜旁白太短,口播至少要说到 36 字才能撑满 15 秒"), + operation="script_generate", + ) + self.assertEqual(error.code, "processing_failed") + self.assertNotEqual(error.code, "unknown") diff --git a/core/backend/apps/ai/test_script_duration_combo.py b/core/backend/apps/ai/test_script_duration_combo.py index 868a3b3..a1c82fe 100644 --- a/core/backend/apps/ai/test_script_duration_combo.py +++ b/core/backend/apps/ai/test_script_duration_combo.py @@ -1,32 +1,42 @@ -"""二期第2段:时长自由化 + 表现形式 × 视频结构 的纯函数单测(不碰 DB / 不调模型)。 +"""主流程:每镜固定 15 秒 + 表现形式 × 视频结构 的纯函数单测(不碰 DB / 不调模型)。 覆盖三件容易悄悄坏掉的事: -1. 切镜结果必须「每镜 4–15 秒」且「加总精确等于总时长」—— 越界下游出片会直接拒。 -2. 模型给的每镜时长可能是错的(加总对不上/单镜越界),后端必须能修回来而不是原样落库。 +1. 切镜结果必须「每镜 15 秒」且「加总精确等于总时长」—— 不再允许 8/10/12。 +2. 模型给的每镜时长可能是错的,后端必须改写成 15×N,而不是原样落库。 3. 套路必须按组合**选择性**加载,全量灌进去会让系统提示词翻倍且互相打架。 """ +from unittest.mock import Mock + from django.test import SimpleTestCase from apps.ai.script_agent import ( DEFAULT_TOTAL_DURATION, PRESENTATION_FORMATS, SEGMENT_DURATION_MAX, - SEGMENT_DURATION_MIN, TOTAL_DURATION_MAX, TOTAL_DURATION_MIN, VIDEO_STRUCTURES, + VISUAL_CHARS_MIN, _fit_segment_durations, + _product_facts, allowed_structures, + assert_product_facts_used, + assert_shot_density, + build_agent_messages, coerce_combo, coerce_total_duration, combo_keys, + compose_segment_visual, load_ecommerce_skill, + narration_floor, narration_limit, + normalize_draft, + parse_visual_beats, plan_roles, plan_segment_durations, ) -ALL_TOTALS = list(range(TOTAL_DURATION_MIN, TOTAL_DURATION_MAX + 1, 5)) +ALL_TOTALS = [15, 30, 45, 60] class TotalDurationCoercionTests(SimpleTestCase): @@ -37,28 +47,24 @@ class TotalDurationCoercionTests(SimpleTestCase): def test_clamped_into_range_and_snapped_to_step(self): self.assertEqual(coerce_total_duration(3), TOTAL_DURATION_MIN) self.assertEqual(coerce_total_duration(999), TOTAL_DURATION_MAX) - self.assertEqual(coerce_total_duration(7), 5) - self.assertEqual(coerce_total_duration(8), 10) + self.assertEqual(coerce_total_duration(7), 15) + self.assertEqual(coerce_total_duration(20), 15) + self.assertEqual(coerce_total_duration(25), 30) self.assertEqual(coerce_total_duration(45), 45) class SegmentPlanningTests(SimpleTestCase): - def test_every_total_and_format_yields_legal_shots(self): + def test_every_total_and_format_yields_fixed_15s_shots(self): for total in ALL_TOTALS: for fmt in PRESENTATION_FORMATS: with self.subTest(total=total, fmt=fmt): durations = plan_segment_durations(total, fmt) self.assertEqual(sum(durations), total) - for seconds in durations: - self.assertGreaterEqual(seconds, SEGMENT_DURATION_MIN) - self.assertLessEqual(seconds, SEGMENT_DURATION_MAX) + self.assertEqual(durations, [SEGMENT_DURATION_MAX] * (total // 15)) - def test_faster_format_produces_more_shots(self): - # 同样 60 秒,Vlog(碎片)镜数应多于口播(要把话说完) - self.assertGreater( - len(plan_segment_durations(60, "vlog")), - len(plan_segment_durations(60, "oral")), - ) + def test_format_no_longer_changes_shot_count(self): + self.assertEqual(plan_segment_durations(60, "vlog"), plan_segment_durations(60, "oral")) + self.assertEqual(plan_segment_durations(30, "drama"), [15, 15]) def test_roles_follow_shot_count(self): self.assertEqual(plan_roles(1), ["钩子"]) @@ -76,32 +82,195 @@ class SegmentPlanningTests(SimpleTestCase): class SegmentDurationFittingTests(SimpleTestCase): - def test_legal_uneven_durations_are_kept_as_is(self): - # 模型的不等长节奏意图只要合法就不该被抹平 - self.assertEqual(_fit_segment_durations([12, 8, 10], 30, "oral"), [12, 8, 10]) + def test_uneven_durations_are_forced_to_15(self): + self.assertEqual(_fit_segment_durations([12, 8, 10], 30, "oral"), [15, 15]) - def test_wrong_total_is_repaired(self): - fitted = _fit_segment_durations([15, 15, 15, 15], 30, "oral") - self.assertEqual(sum(fitted), 30) - self.assertTrue(all(SEGMENT_DURATION_MIN <= d <= SEGMENT_DURATION_MAX for d in fitted)) + def test_too_many_shots_are_collapsed(self): + self.assertEqual(_fit_segment_durations([15, 15, 15, 15], 30, "oral"), [15, 15]) def test_out_of_range_shot_is_repaired(self): - fitted = _fit_segment_durations([99, 1], 30, "drama") - self.assertEqual(sum(fitted), 30) - self.assertTrue(all(SEGMENT_DURATION_MIN <= d <= SEGMENT_DURATION_MAX for d in fitted)) + self.assertEqual(_fit_segment_durations([99, 1], 30, "drama"), [15, 15]) def test_empty_falls_back_to_planner(self): - self.assertEqual(_fit_segment_durations([], 20, "vlog"), plan_segment_durations(20, "vlog")) + self.assertEqual(_fit_segment_durations([], 30, "vlog"), plan_segment_durations(30, "vlog")) - def test_garbage_values_never_escape_the_legal_range(self): + def test_garbage_values_never_escape_15s_grid(self): for raw in ([None, None], ["a", "b"], [0, 0, 0], [3, 3], [40, 40, 40]): - for total in (10, 30, 60): + for total in ALL_TOTALS: with self.subTest(raw=raw, total=total): fitted = _fit_segment_durations(list(raw), total, "oral") self.assertEqual(sum(fitted), total) - for seconds in fitted: - self.assertGreaterEqual(seconds, SEGMENT_DURATION_MIN) - self.assertLessEqual(seconds, SEGMENT_DURATION_MAX) + self.assertTrue(all(d == SEGMENT_DURATION_MAX for d in fitted)) + + +class NormalizeDurationTests(SimpleTestCase): + def test_extra_shots_are_truncated_to_duration_grid(self): + raw = { + "segments": [ + {"role": "钩子", "narration": "口播1", "visual": "画面1", "duration": 12}, + {"role": "痛点", "narration": "口播2", "visual": "画面2", "duration": 8}, + {"role": "CTA", "narration": "口播3", "visual": "画面3", "duration": 10}, + ] + } + import json + + draft = normalize_draft(json.dumps(raw, ensure_ascii=False), aspect_ratio="9:16", total_duration=30) + self.assertEqual(len(draft["segments"]), 2) + self.assertEqual([s["duration"] for s in draft["segments"]], [15, 15]) + + def test_preserve_layout_keeps_legacy_shot_count(self): + import json + + raw = { + "segments": [ + {"role": "钩子", "narration": "口播1", "visual": "画面1"}, + {"role": "痛点", "narration": "口播2", "visual": "画面2"}, + {"role": "CTA", "narration": "口播3", "visual": "画面3"}, + ] + } + draft = normalize_draft( + json.dumps(raw, ensure_ascii=False), + aspect_ratio="9:16", + total_duration=30, + preserve_layout={"count": 3, "durations": [12, 8, 10]}, + ) + self.assertEqual(len(draft["segments"]), 3) + self.assertEqual([s["duration"] for s in draft["segments"]], [12, 8, 10]) + self.assertEqual(draft["total_duration"], 30) + + +class ProductFactTests(SimpleTestCase): + def test_missing_selling_point_is_rejected(self): + with self.assertRaises(ValueError): + assert_product_facts_used( + {"hook": "夏天好热", "segments": [{"narration": "补水很重要"}]}, + selling_titles=["控油12小时"], + ) + + def test_selling_point_original_word_passes(self): + assert_product_facts_used( + {"hook": "控油12小时真的扛得住", "segments": [{"narration": "晚上八点还不油"}]}, + selling_titles=["控油12小时"], + ) + + def test_single_character_selling_point_counts(self): + assert_product_facts_used( + {"hook": "这杯茶真香", "segments": [{"narration": "下午一杯茶"}]}, + selling_titles=["茶"], + ) + + def test_brand_is_not_required(self): + assert_product_facts_used( + {"hook": "控油12小时", "segments": [{"narration": "控油12小时"}]}, + brand="AirBrand", + selling_titles=["控油12小时"], + ) + + +class ProductFactsLookupTests(SimpleTestCase): + def _project(self, points): + class _QS(list): + def filter(self, **kwargs): + raise AssertionError("不要再用 id__in 过滤,标题不是 UUID") + + product = Mock() + product.selling_points.all.return_value = _QS(points) + project = Mock() + project.product = product + project.metadata = {} + return project + + def test_title_keys_match_without_uuid_filter(self): + tea = Mock(id="11111111-1111-1111-1111-111111111111", title="茶", detail="茶香") + oil = Mock(id="22222222-2222-2222-2222-222222222222", title="控油", detail="x") + _product, matched = _product_facts(self._project([tea, oil]), ["茶"]) + self.assertEqual([item.title for item in matched], ["茶"]) + + def test_uuid_keys_still_match(self): + tea = Mock(id="11111111-1111-1111-1111-111111111111", title="茶", detail="茶香") + oil = Mock(id="22222222-2222-2222-2222-222222222222", title="控油", detail="x") + _product, matched = _product_facts(self._project([tea, oil]), [str(tea.id)]) + self.assertEqual([item.title for item in matched], ["茶"]) + + +class PromptAssemblyTests(SimpleTestCase): + def _project(self): + sp = Mock(title="控油12小时", detail="到晚八点") + + class _QS(list): + def filter(self, **kwargs): + return self + + product = Mock() + product.title = "玻尿酸精华" + product.brand = "AirBrand" + product.business_type = "ecommerce" + product.category = "美妆" + product.target_audience = "油皮" + product.description = "夜间修护" + product.specs = {"price": "99", "source": "product-create-upload"} + product.selling_points.all.return_value = _QS([sp]) + project = Mock() + project.product = product + project.metadata = {"wizard": {"persona": "urban", "selling_point_ids": ["1"]}} + return project + + def test_auto_mode_with_prompt_is_not_theme(self): + messages = build_agent_messages( + project=self._project(), + mode="auto", + user_prompt="节奏紧凑,适合短视频投放", + selling_point_ids=["1"], + base_draft=None, + aspect_ratio="9:16", + total_duration=30, + persona="urban", + ) + user = messages[1]["content"] + self.assertIn("全自动", user) + self.assertNotIn("一句话主题扩写", user) + self.assertIn("【补充要求】节奏紧凑,适合短视频投放", user) + self.assertIn("控油12小时", user) + self.assertIn("都市白领女性", user) + self.assertIn("价格:99", user) + self.assertIn("每镜必须 15 秒", user) + self.assertIn("一共 2 镜", user) + self.assertIn("必须原词用上", user) + self.assertIn("跟同事吐槽", user) + self.assertIn("禁止一句 20 字收工", user) + self.assertIn("秒级分镜", user) + + def test_scene_oral_prompt_locks_skeleton_and_density(self): + messages = build_agent_messages( + project=self._project(), + mode="auto", + user_prompt="", + selling_point_ids=["1"], + base_draft=None, + aspect_ratio="9:16", + total_duration=45, + presentation_format="oral", + video_structure="scene", + persona="urban", + ) + user = messages[1]["content"] + self.assertIn("场景种草骨架", user) + self.assertIn("口播每镜必须说满", user) + self.assertIn("一共 3 镜", user) + self.assertIn("秒级分镜", user) + + def test_theme_mode_still_exists(self): + messages = build_agent_messages( + project=self._project(), + mode="theme", + user_prompt="主打熬夜党", + selling_point_ids=None, + base_draft=None, + aspect_ratio="9:16", + total_duration=15, + ) + self.assertIn("一句话主题扩写", messages[1]["content"]) + self.assertIn("主打熬夜党", messages[1]["content"]) class NarrationLimitTests(SimpleTestCase): @@ -113,6 +282,107 @@ class NarrationLimitTests(SimpleTestCase): def test_never_exceeds_hard_cap(self): self.assertLessEqual(narration_limit(60), 55) + def test_fifteen_second_floor_is_about_thirty_six(self): + self.assertEqual(narration_floor(15), 36) + self.assertLess(narration_floor(15), narration_limit(15)) + + +class ShotDensityTests(SimpleTestCase): + _VISUAL = ( + "0-3s:近景,女主对镜头抬眼,工位键盘还亮着,窗外是下午的光\n" + "3-8s:手持跟到桌面,右手把茶包放进盛了热水的玻璃杯,水面起蒸汽\n" + "8-12s:杯壁特写,茶汤从浅金慢慢变深,标签贴在杯沿\n" + "12-15s:拉回中近景,女主喝一口,眼神松下来" + ) + + def test_short_oral_narration_is_rejected(self): + with self.assertRaises(ValueError) as ctx: + assert_shot_density( + { + "segments": [ + { + "duration": 15, + "narration": "下午三点来杯100numur茶解乏。", + "visual": self._VISUAL, + } + ] + }, + "oral", + ) + self.assertIn("旁白太短", str(ctx.exception)) + + def test_oral_meeting_floor_passes(self): + narration = "下午三点工位犯困,键盘都敲不利索。我倒了杯茶,第一口是回甘不是苦,整个人才慢慢醒过来。" + self.assertGreaterEqual(len(narration.replace(" ", "")), narration_floor(15)) + self.assertGreaterEqual(len(self._VISUAL.replace(" ", "")), VISUAL_CHARS_MIN) + assert_shot_density( + {"segments": [{"duration": 15, "narration": narration, "visual": self._VISUAL}]}, + "oral", + ) + + def test_short_visual_is_rejected(self): + with self.assertRaises(ValueError) as ctx: + assert_shot_density( + { + "segments": [ + { + "duration": 15, + "narration": "下午三点工位犯困,键盘都敲不利索。我倒了杯茶,第一口是回甘不是苦,整个人才慢慢醒过来。", + "visual": "女主举起杯子。", + } + ] + }, + "oral", + ) + self.assertIn("画面描写太短", str(ctx.exception)) + + def test_vlog_allows_silent_shot(self): + assert_shot_density( + {"segments": [{"duration": 15, "narration": "", "visual": self._VISUAL}]}, + "vlog", + ) + + def test_paragraph_visual_without_timestamps_is_rejected(self): + narration = "下午三点工位犯困,键盘都敲不利索。我倒了杯茶,第一口是回甘不是苦,整个人才慢慢醒过来。" + long_visual = ( + "近景对镜头,女主从键盘抬眼,端起杯子喝一口,再切杯壁特写,蒸汽从杯口升起," + "茶汤慢慢变深,窗外下午的光打在桌面上,手没有离开杯柄,整段都不写起止秒。" + ) + self.assertGreaterEqual(len(long_visual.replace(" ", "")), VISUAL_CHARS_MIN) + with self.assertRaises(ValueError) as ctx: + assert_shot_density( + {"segments": [{"duration": 15, "narration": narration, "visual": long_visual}]}, + "oral", + ) + self.assertIn("按秒拆分镜", str(ctx.exception)) + + def test_beats_array_flattens_into_visual(self): + import json + + raw = { + "segments": [ + { + "role": "钩子", + "narration": "下午三点工位犯困,键盘都敲不利索。", + "beats": [ + {"start": 0, "end": 3, "shot_size": "近景", "action": "女主对镜头抬眼,键盘还亮着"}, + {"start": 3, "end": 8, "shot_size": "手持", "action": "右手把茶包放进热水杯,蒸汽升起"}, + {"start": 8, "end": 12, "shot_size": "特写", "action": "茶汤从浅金慢慢变深"}, + {"start": 12, "end": 15, "shot_size": "中近景", "action": "女主喝一口眼神松开"}, + ], + } + ] + } + draft = normalize_draft(json.dumps(raw, ensure_ascii=False), aspect_ratio="9:16", total_duration=15) + visual = draft["segments"][0]["visual"] + self.assertIn("0-3s:", visual) + self.assertIn("近景", visual) + self.assertGreaterEqual(len(parse_visual_beats(visual)), 3) + + def test_compose_keeps_timestamped_visual(self): + visual = compose_segment_visual({"visual": self._VISUAL}) + self.assertGreaterEqual(len(parse_visual_beats(visual)), 3) + class ComboTests(SimpleTestCase): def test_drama_cannot_pick_review(self): @@ -160,3 +430,9 @@ class SkillLoadingTests(SimpleTestCase): def test_skill_text_is_substantial(self): # skills 没随镜像打进去时会退化成一句兜底,这里守住「提示词没丢」 self.assertGreater(len(load_ecommerce_skill("oral", "pain")), 5000) + + def test_skill_now_requires_fixed_15s(self): + text = load_ecommerce_skill("oral", "pain") + self.assertIn("每镜固定 15 秒", text) + self.assertIn("商品原词", text) + self.assertNotIn("单镜 4–15 秒,允许不等长", text) diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 396c0ed..2d6fdd5 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -518,14 +518,13 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): @action(detail=True, methods=["post"], url_path="script-agent-stream", renderer_classes=[ServerSentEventRenderer]) def script_agent_stream(self, request, pk=None): """对话式脚本 agent · 流式(SSE)。出稿 + 改稿一体,多模型可选。 - 请求体:mode(auto|theme|revise)、prompt、model_config_id、selling_point_ids、 - base_version_id(改稿)、aspect_ratio、total_duration(5-60,5 秒步进)、 + 请求体:mode(auto|theme|revise)、prompt、model_config_id、selling_point_ids、persona、 + base_version_id(改稿)、aspect_ratio、total_duration(15/30/45/60)、 presentation_format(oral|drama|vlog)、video_structure(pain|contrast|review|scene)。 响应:text/event-stream,逐帧吐 tool/delta/draft/saved/done/error。""" project = self.get_object() mode = str(request.data.get("mode") or "auto") prompt = str(request.data.get("prompt") or "") - selling_point_ids = request.data.get("selling_point_ids") or [] base_version_id = request.data.get("base_version_id") or None aspect_ratio = str(request.data.get("aspect_ratio") or "9:16") # 非法值一律由 agent 侧 coerce 兜底(夹区间/回落默认),这里不做 400,避免生成被参数噪声打断 @@ -534,6 +533,9 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): request.data.get("presentation_format"), request.data.get("video_structure"), ) + wizard = ((project.metadata or {}).get("wizard") if isinstance(project.metadata, dict) else None) or {} + selling_point_ids = request.data.get("selling_point_ids") or wizard.get("selling_point_ids") or [] + persona = str(request.data.get("persona") or wizard.get("persona") or "") target_index = request.data.get("target_index") try: target_index = int(target_index) if target_index is not None else None @@ -568,6 +570,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): video_structure=video_structure, target_index=target_index, entry_source=str(request.data.get("source") or ""), + persona=persona, ) response = StreamingHttpResponse(stream, content_type="text/event-stream") response["Cache-Control"] = "no-cache" @@ -894,8 +897,8 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): """采用版分镜数变化时,同步 VideoSegment 的**数量和时长**:不足则尾部补 NOT_STARTED, 多出且尾部是「从未生成过」的段则裁掉(已生成的段绝不动)。 - 时长同步是二期补的:脚本镜可以不等长了,而出片、计价、时间线读的都是 - VideoSegment.target_duration_seconds。不同步的话脚本写了 8 秒、出片仍按 15 秒跑。 + 时长同步:脚本镜主流程固定 15 秒,而出片、计价、时间线读的都是 + VideoSegment.target_duration_seconds。不同步的话脚本写了 15 秒、出片仍按旧默认跑。 已出片的段不改时长——改了会跟已渲染的成片对不上。""" if not script.is_adopted: return diff --git a/core/backend/skills/ecommerce-video-script/SKILL.md b/core/backend/skills/ecommerce-video-script/SKILL.md index 12fdb27..971565e 100644 --- a/core/backend/skills/ecommerce-video-script/SKILL.md +++ b/core/backend/skills/ecommerce-video-script/SKILL.md @@ -5,7 +5,7 @@ description: > 服务对象不是人类编剧,而是 AirShelf 产品后端的「脚本生成 agent」——在运行时按需加载本技能作为领域知识。 能力:把【商品信息 + 前置条件】或【一句话主题】或【用户已有脚本】, 自动收敛成一份结构化的带货短视频脚本 JSON - (默认 9:16 竖屏可改 / 总时长 5–60 秒按 5 秒步进 / 单镜 4–15 秒可不等长 / + (默认 9:16 竖屏可改 / 总时长 15/30/45/60 秒 / 每镜固定 15 秒 / 按「表现形式 × 视频结构」套路生成)。 当任务为「生成带货脚本 / 扩写主题 / 优化已有脚本 / 商品转视频脚本」时使用本技能。 核心目标是「电商小白点一下按钮就出能吸睛、能转化的脚本」,不是影视级艺术性。 @@ -57,11 +57,11 @@ description: > "segments": [ { "index": 0, - "duration": 12, + "duration": 15, "role": "钩子|痛点|卖点|CTA", - "narration": "这一镜被说出来的台词/旁白,字数上限=duration×3.5", + "narration": "这一镜被说出来的台词/旁白,15秒口播目标38-47字(下限36,上限52)", "speaker": "可选,指向某 entity 的 id;画外旁白时为 null", - "visual": "这一镜的画面:主体+动作+景别/运镜(特写/全景/手持跟拍/推拉摇)+一个画面或情绪的变化,够导演撑满这一镜的秒数,约40-70字,别只写一句静态动作", + "visual": "0-3s:近景,……\\n3-8s:手持跟拍,开始使用商品\\n8-12s:特写,商品用法过程\\n12-15s:拉回中近景,使用后的反应", "product_exposure": "商品露出方式(手持/特写/使用中)", "entity_refs": ["c1", "s1"], "dialogue": [] @@ -76,11 +76,12 @@ description: > `video_structure`(`痛点解决|前后对比|测评验证|场景种草`)**原样回填到输出里**,不要自己改。 输入没给时,按商品品类与人群自行选一组最合适的,并如实填进这两个字段。 - **画幅由输入给定**:`aspect_ratio` 默认 `"9:16"`(电商竖屏主场景),但**不写死**——输入指定了其他比例(如 `"16:9"`、`"1:1"`、`"4:5"`)就照用,原样透传给下游。画幅只影响 `visual` 的构图措辞,不改变结构与镜数。 -- **总时长由输入给定**:`total_duration` 取 **5–60 秒之间的 5 的倍数**(5/10/15/…/60),输入未指定时默认 `30`。**不要写死。** -- **单镜时长 4–15 秒,允许不等长**:每个 `segments[].duration` 必须是 **4 到 15 之间的整数** - (15 秒是下游出片模型的硬上限,越界下游直接拒绝出片)。 - 该长的镜给足、该短的镜压短,**不要机械均分**。 +- **总时长由输入给定**:`total_duration` 取 **15 / 30 / 45 / 60**(15 秒步进),输入未指定时默认 `30`。**不要写死成别的数。** +- **每镜固定 15 秒**:每个 `segments[].duration` **必须是 15**,镜数 = `total_duration / 15`(15 秒 1 镜、30 秒 2 镜、45 秒 3 镜、60 秒 4 镜)。 + 禁止 8/10/12 这种不等长,禁止自行加减镜。15 秒也是下游出片模型的硬上限。 - **镜时长必须精确加总**:`sum(segments[].duration) == total_duration`,一秒都不能差。 +- **商品原词铁律**:钩子/旁白/对白必须用上输入里给出的**商品名、品牌、本次勾选卖点的原词**。 + 禁止用「补水 / 好用 / 值得买 / 宝藏 / 闭眼入」这类空话替换真实卖点。品类话术和钩子库只是写法参考,不是正文。 - **`segment_count` = `segments` 的实际长度**,且 `index` 从 0 连续递增。 - 镜数怎么定、role 怎么按镜数分配,见 `references/methodology.md`「时长 → 镜数 → 黄金结构映射」。 - `entities[].id` 全局唯一,`segments[].entity_refs` 与 `speaker` 只能引用已声明的 id。 @@ -94,7 +95,11 @@ description: > - **每个声明的 entity 至少被一个 segment 引用**(不留孤儿 entity)。 - **场景必抽,且每镜必绑一个场景**:每条脚本**至少声明 1 个 `type:"scene"` 实体**表示画面所在环境;**每个 segment 的 `entity_refs` 必须恰好引用一个 scene**。多镜在同一环境就**复用同一个 scene id**(绝不为同一环境写两份 visual_prompt,否则下游背景漂移);只有真正换了环境才新建另一个 scene。纯产品特写镜也要绑它所处环境的 scene(如「宿舍书桌」「厨房台面」),没有合适环境时复用主场景。 - `visual_prompt` 由你自动生成,小白无需打字。 -- **每镜 `visual` 要够厚撑满这一镜的秒数**:一段话写清 ①主体+动作 ②景别/运镜(特写/全景/手持跟拍/推拉摇,至少给一个镜头语言)③一个画面或情绪的变化(从…到…)。约 **40–70 字**,**禁止只写一句静态动作**(如「女主举起商品展示」撑不住十几秒,要补镜头与变化)。4–6 秒的短镜可以只给一个动作 + 一个镜头语言,但仍要写清景别。注意:这是给生图/视频导演的画面,**不占 narration 的字数额度**。 +- **每镜 `visual` 必须按秒拆分镜**:15 秒一场里至少 3 刀、目标 4 刀,写成多行: + `0-3s:景别,谁在做什么,手和商品的空间关系` + 第一条从 0 秒起,最后一条接到 15s。每条都要有景别/运镜,15 秒内至少切两次景别。 + **禁止**用一句静态动作撑满 15 秒(如「女主举起商品展示」)。这是给故事板和 Seedance 的导演说明书,**不占 narration 字数**。 +- **画面物理常识**:商品用法必须是真人会做的。茶/咖啡 = 热水、蒸汽、茶汤渐染,禁止茶包丢进看起来像冷白开的杯子;护肤品 = 挤出/涂抹;食品 = 打开/入口。手从真实方向入画,禁止悬浮肢体、反关节、商品凭空出现。每条分镜写清容器/包装长什么样、液体或材质当前状态。 - 不要输出 schema 之外的字段,也不要省略必填字段。 ### 铁律 2 · 输出前自检 @@ -106,10 +111,12 @@ description: > 详见 `references/methodology.md`「旁白红线」。最关键的几条: - **口语化**,像真人对着镜头说话,不准书面腔 / AI 腔。 -- **每镜 narration ≤ `duration × 3.5` 字,且绝不超过 55 字**(出片模型在镜内直接发声, - 3.5 字/秒是可懂语速上限)。短镜装不下就拆到下一镜,或干脆留空走纯画面。 +- **口播 15 秒镜要说满**:`narration` **36–52 字**(写作目标 **38–47 字,2–4 句短句**)。 + 上限仍是 `duration × 3.5` 且不超过 55 字。禁止一句 20 字收工。 + 商品名全片点名 1–2 次即可,其余镜用卖点原词和可感知细节(口感/气味/动作/使用场景)。 + Vlog 才允许个别镜纯画面;口播禁止整镜无声。 - **禁违规词**:医疗功效(治疗/根治/抗癌…)、绝对化用语(最/第一/100%/国家级…)一律不写。 -- 不浮夸、不空喊,卖点要落到「商品怎么解决痛点」。 +- 不浮夸、不空喊,卖点要落到「商品怎么解决痛点」。人物口吻跟输入的人物设定走,不要万能主播腔。 ### 铁律 4 · 一键自动化(与影视母版相反) @@ -124,10 +131,10 @@ description: > | 参数 | 值 | | ---- | ---- | | 画幅 | **默认 9:16 竖屏**,由输入可覆盖(16:9 / 1:1 / 4:5 等照用) | -| 总时长 | **5–60 秒,5 秒步进**,由输入给定;未指定时默认 30 | +| 总时长 | **15 / 30 / 45 / 60 秒**,由输入给定;未指定时默认 30 | | 表现形式 | **口播 / 短剧 / Vlog**,由输入给定;未指定时按品类人群自选 | | 视频结构 | **痛点解决 / 前后对比 / 测评验证 / 场景种草**,由输入给定;未指定时按品类人群自选 | -| 分镜 | **单镜 4–15 秒,可不等长**;镜数按表现形式的推荐节奏定(见方法论) | +| 分镜 | **每镜固定 15 秒**;镜数 = 总时长 / 15 | | 镜头功能 | 钩子 → 痛点 → 卖点 → CTA(黄金结构;具体骨架以视频结构套路为准) | | 发声方式 | 出片模型直接生成画面+音效+人声(**不走 TTS**) | @@ -163,13 +170,14 @@ description: > ## 生成流程(内部执行,一次走完,不暂停) 1. **路由** — 判定输入模式(①/②/③),加载对应 references;再按输入的表现形式与视频结构加载那两份套路。 -2. **定镜** — 读画幅(`aspect_ratio` 默认 9:16)与总时长(5–60 秒,未指定默认 30); - 按表现形式的推荐单镜节奏定出镜数,再给每镜分配 4–15 秒的时长,**确保加总等于总时长**。 +2. **定镜** — 读画幅(`aspect_ratio` 默认 9:16)与总时长(15/30/45/60,未指定默认 30); + 镜数 = 总时长 / 15,**每镜 duration 一律填 15**。 3. **定调(tone)** — 依据视频结构 + 品类话术 + 平台调性,选定 `tone`;②③ 模式尊重用户已表达的倾向。 4. **抽取/创建 entities** — 识别脚本需要的角色 / 场景 / 商品;为每个 entity 写一份**全脚本共用**的 `visual_prompt`(保证多镜同一角色同一张脸);可选写 `voice_ref` 锁音色。 5. **按套路填结构** — 优先用视频结构套路里的骨架给每个 segment 分配 `role`; 套路没覆盖到的用「role 按镜数分配」表兜底;钩子镜套用该套路指定的钩子写法。 -6. **写 narration / visual / 商品露出** — 每镜旁白 ≤ `duration × 3.5` 字、口语化、过红线; +6. **写 narration / visual / 商品露出** — 口播 15 秒镜旁白 38–47 字(下限 36)、口语化、过红线; + **visual 按秒拆成 3–5 条分镜**;必须把输入给出的卖点原词和商品描述细节写进旁白或对白,不要每句只重复商品名; 发声方式按表现形式套路来;每镜规划自然的 `product_exposure`。 7. **连引用** — 填 `entity_refs` 与 `speaker`,确认每个 entity 都被引用、id 都合法。 8. **自检** — 跑 `checklist.md`,过了再输出。 diff --git a/core/backend/skills/ecommerce-video-script/references/category-playbook.md b/core/backend/skills/ecommerce-video-script/references/category-playbook.md index f0f35a7..6a5ee21 100644 --- a/core/backend/skills/ecommerce-video-script/references/category-playbook.md +++ b/core/backend/skills/ecommerce-video-script/references/category-playbook.md @@ -2,6 +2,7 @@ > 全自动 / 一句话模式读。根据商品所属品类,调整语气与卖点侧重。 > 不确定品类时,按商品功能就近归类;都不沾就用"通用"原则(讲场景痛点 + 真实使用感)。 +> **示例旁白只学语气,禁止把例句里的假商品/假卖点抄进正文。** 正文必须用输入给的商品名和卖点原词。 每个品类给:**语气** / **卖点侧重** / **常用露出** / **违规雷区** / **示例旁白**。 diff --git a/core/backend/skills/ecommerce-video-script/references/checklist.md b/core/backend/skills/ecommerce-video-script/references/checklist.md index be9fdc0..6f807cb 100644 --- a/core/backend/skills/ecommerce-video-script/references/checklist.md +++ b/core/backend/skills/ecommerce-video-script/references/checklist.md @@ -13,11 +13,11 @@ - [ ] `presentation_format` ∈ `{口播, 短剧, Vlog}`,且与输入一致(输入没给则自选并如实填写)。 - [ ] `video_structure` ∈ `{痛点解决, 前后对比, 测评验证, 场景种草}`,同上。 - [ ] **组合合法**:不是「短剧 × 测评验证」(唯一禁用组合,见 `playbooks/combo-matrix.md`)。 -- [ ] `total_duration` 是 **5–60 之间 5 的倍数**。 -- [ ] **每个 `segment.duration` 是 4–15 之间的整数**(15 是下游出片硬上限,越界直接出不了片)。 +- [ ] `total_duration` 是 **15 / 30 / 45 / 60**。 +- [ ] **每个 `segment.duration` 必须是 15**。 - [ ] **`sum(segments[].duration) == total_duration`**,逐个加一遍,差一秒都不行。 - [ ] `segment_count == len(segments)`;`index` 从 0 连续递增无跳号。 -- [ ] 镜数落在 `ceil(total_duration/15)` 到 `floor(total_duration/4)` 区间内。 +- [ ] 镜数 **等于** `total_duration / 15`。 - [ ] 每个 `segment.role` ∈ `{钩子, 痛点, 卖点, CTA}`。 - [ ] 首镜 `role == 钩子`;末镜收 CTA(独立 CTA 镜,或末镜旁白末尾含明确行动指令)。 - [ ] `entities` 每项 `type` ∈ `{character, scene, product}`,`id` 全局唯一。 @@ -46,10 +46,11 @@ ## C. 旁白红线扫描(逐镜) -- [ ] **每镜 `narration` ≤ `duration × 3.5` 字,且绝不超过 55 字**(逐镜按自己的秒数数一遍,别凭感觉。 - 4 秒镜 ≤14 字、8 秒镜 ≤28 字、15 秒镜 ≤52 字,对照表见 `methodology.md`)。 -- [ ] 每镜 `visual` 含「景别/运镜 + 一个画面变化」,不是一句静态动作;能撑满这一镜的秒数 - (≥8 秒的镜约 40–70 字;4–6 秒的短镜可以更短,但景别必须写清)。 +- [ ] **每镜 `narration` ≤ `duration × 3.5` 字,且绝不超过 55 字**(15 秒镜 ≤52 字)。 +- [ ] **口播 15 秒镜旁白 ≥ 36 字**(写作目标 38–47 字,2–4 句)。禁止一句 20 字收工;口播禁止整镜无声。 +- [ ] **秒级分镜**:每镜 `visual` 至少 3 条 `0-3s:景别,动作`,从 0 接到 15s;15 秒内至少切两次景别。 +- [ ] **商品原词**:旁白/对白出现了输入给出的至少一个勾选卖点原词;商品名全片点名 1–2 次即可,其余镜写描述/卖点细节,没有用空话替换。 +- [ ] 每镜 `visual` 能撑满 15 秒(至少 72 字),写清手/商品/容器的空间关系和真实用法,不是一句静态动作。 - [ ] 口语化,无书面腔/AI 腔("综上""不仅…而且""值得一提"等已清除)。 - [ ] **违规词扫描**:无医疗功效词(治疗/根治/抗癌/消炎/排毒/速效…)。 - [ ] **绝对化用语扫描**:无 最/第一/唯一/100%/国家级/永久/绝对/史上 等。 diff --git a/core/backend/skills/ecommerce-video-script/references/methodology.md b/core/backend/skills/ecommerce-video-script/references/methodology.md index c4bb6ae..4f44835 100644 --- a/core/backend/skills/ecommerce-video-script/references/methodology.md +++ b/core/backend/skills/ecommerce-video-script/references/methodology.md @@ -24,30 +24,18 @@ ## 二、时长 → 镜数 → 黄金结构映射(关键) -### 2.1 时长规则(不再是固定四档,也不再每镜等长) +### 2.1 时长规则(每镜固定 15 秒) -- **总时长**由输入给定:**5–60 秒之间、5 秒一档**(5/10/15/…/60)。未指定时默认 30。 -- **单镜时长 4–15 秒**,这是下游出片模型的硬上限,**任何一镜都不许超过 15 秒、不许低于 4 秒**。 -- **每镜时长可以不一样。** 该长的镜(讲卖点、举证)就给足,该短的镜(空镜、转场、一句钩子)就压短。 - 别再机械均分。 +- **总时长**由输入给定:**15 / 30 / 45 / 60**。未指定时默认 30。 +- **每镜时长固定 15 秒**,这既是主流程产品规则,也是下游出片模型的硬上限。 +- 镜数直接由总时长决定:15 秒 → 1 镜,30 秒 → 2 镜,45 秒 → 3 镜,60 秒 → 4 镜。 +- **不要**写成 8/10/12 这种不等长,也**不要**自行加减镜。15 秒要靠画面变化和一句完整意思撑满,不是靠切碎镜头。 - 所有 `segments[].duration` 相加**必须精确等于** `total_duration`。 ### 2.2 镜数怎么定 -镜数不是算出来的,是**按表现形式的节奏定**的。先取该形式的推荐单镜时长,再用总时长去除: - -| 表现形式 | 推荐单镜时长 | 理由 | -| ---- | ---- | ---- | -| **口播** | 10–15 秒 | 说完一个完整意思需要时间,切太碎话会断 | -| **短剧** | 6–10 秒 | 靠切换制造节奏,镜头长了拖沓 | -| **Vlog** | 5–8 秒 | 碎片拼贴感,镜头短、数量多 | - -`segment_count ≈ total_duration / 推荐单镜时长`,向最接近的整数取整, -然后**必须**落在 `ceil(total_duration/15)` 到 `floor(total_duration/4)` 这个区间内。 - -例:口播 30 秒 → 30/12 ≈ 2.5 → 3 镜(如 12+10+8)。 -  短剧 45 秒 → 45/8 ≈ 5.6 → 6 镜(如 8+7+9+7+8+6)。 -  Vlog 30 秒 → 30/6.5 ≈ 4.6 → 5 镜(如 6+6+7+6+5)。 +镜数**不是按表现形式估算的**,就是 `total_duration / 15`。 +表现形式只影响这一镜里怎么拍(口播把 15 秒说完一件事;短剧在 15 秒里完成一次冲突/对白交换;Vlog 用镜内空镜+细节撑满),**不改变镜数**。 ### 2.3 role 按镜数分配 @@ -102,22 +90,23 @@ ## 五、旁白红线(硬规则,违反即不合格) - **口语化**:像真人对着镜头唠嗑,不准书面腔 / 不准 AI 腔("综上所述""不仅…而且""值得一提的是"全禁)。 -- **`narration` 字数按这一镜的秒数算**:出片模型在镜内直接发声,可懂语速上限约 **3.5 字 / 秒**。 - **每镜 `narration` ≤ `duration × 3.5` 字,且任何情况下不超过 55 字。** - 对照表(写作目标再留 10% 缓冲,宁可短不要卡满): +- **必须用商品原词**:输入给了商品名、品牌、卖点,就用这些词,不许改成「补水/好用/值得买」这种空卖点。品类示例句只是语气参考。 +- **`narration` 字数按这一镜的秒数算**:出片模型在镜内直接发声。 + 可懂语速上限约 **3.5 字 / 秒**,口播下限约 **2.4 字 / 秒**(再短镜头空一半)。 + **每镜上限 `duration × 3.5` 字,且不超过 55 字。口播 15 秒镜还有下限 36 字。** - | 单镜时长 | 字数上限 | 写作目标 | - | ---- | ---- | ---- | - | 4 秒 | 14 字 | ≤ 12 字 | - | 5 秒 | 17 字 | ≤ 15 字 | - | 6 秒 | 21 字 | ≤ 19 字 | - | 8 秒 | 28 字 | ≤ 25 字 | - | 10 秒 | 35 字 | ≤ 31 字 | - | 12 秒 | 42 字 | ≤ 38 字 | - | 15 秒 | 52 字 | ≤ 47 字 | + | 单镜时长 | 下限 | 字数上限 | 写作目标 | + | ---- | ---- | ---- | ---- | + | 4 秒 | — | 14 字 | ≤ 12 字 | + | 5 秒 | — | 17 字 | ≤ 15 字 | + | 6 秒 | — | 21 字 | ≤ 19 字 | + | 8 秒 | — | 28 字 | ≤ 25 字 | + | 10 秒 | — | 35 字 | ≤ 31 字 | + | 12 秒 | — | 42 字 | ≤ 38 字 | + | 15 秒 | **36 字** | 52 字 | **38–47 字,2–4 句** | - **短镜不要硬塞长句** —— 装不下就把这句拆到下一镜,或者干脆让这镜没有旁白(纯画面)。 - 总时长很短、一镜要装多功能时尤其要狠删,先保 CTA 不被砍。 + 主流程每镜都是 15 秒:**要说满,不要宁可短。** 一句 20 字撑不满镜头。 + 留白靠句间停顿和画面,不靠少写字。只有 Vlog 才允许个别镜纯画面无声。 - **不浮夸、不空喊**:卖点要给具体理由,不堆形容词。 - **违规词禁令**(电商广告法红线,一律不写): - **医疗功效类**:治疗 / 根治 / 疗效 / 抗癌 / 消炎 / 杀菌(无证) / 排毒 / 速效 / 抑制 ××病 … @@ -126,3 +115,22 @@ - 替换策略:用"帮助""更""不少人反馈""上脸清爽"等柔性、主观化表达替代。 > 自检阶段必须逐镜扫一遍违规词,命中即改写。详见 `checklist.md`。 + +--- + +## 六、15 秒场内的秒级分镜(硬规则) + +主流程一场就是 15 秒。15 秒里**必须再切 3–5 刀**,写进 `visual`,下游故事板和视频只认这段。 + +``` +0-3s:近景,人看镜头或看向场景,先立住环境和情绪 +3-8s:手部入画,开始使用商品(写清容器、材质、液体/膏体状态) +8-12s:商品特写,用法过程可见 +12-15s:拉回人物,使用后的反应 +``` + +- 第一条从 **0 秒**起,最后一条接到 **15s**。 +- 每一条写:景别 + 谁 + 手从哪来 + 商品/容器什么样 + 这一拍在变什么。 +- 15 秒内至少切 **两次景别**(近景 / 特写 / 手持跟拍…)。 +- 商品用法必须真实:茶要热水和蒸汽,不要把茶包丢进冷白开;手不要悬浮。 +- 允许另给 `beats:[{start,end,shot_size,action}]`,但 `visual` 仍要能折成上面这种多行文本。 diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/combo-matrix.md b/core/backend/skills/ecommerce-video-script/references/playbooks/combo-matrix.md index 067f441..e76dcab 100644 --- a/core/backend/skills/ecommerce-video-script/references/playbooks/combo-matrix.md +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/combo-matrix.md @@ -48,6 +48,8 @@ 最弱的一格,因为口播是「对镜说话」,天然打断场景沉浸。 - **必须弱化对镜**:人在场景里自然活动,偶尔看向镜头,不要全程盯着 - 台词改成自言自语的语气,不要介绍腔 +- **每镜仍要说满 36–47 字**:写具体时刻、动作、使用感,不要一句商品名就收 +- 商品名全片点名 1–2 次,其余镜用卖点原词和场景细节 - 如果商品本身氛围属性强(香氛、饮品),优先考虑改用 Vlog ### 短剧 × 痛点解决 ★ 最强组合 @@ -100,9 +102,9 @@ | 结构 | 最短可用 | 原因 | | ---- | ---- | ---- | -| 痛点解决 | 15 秒 | 痛点一句、卖点一句、CTA 一句,勉强够 | -| 前后对比 | 10 秒 | 前后两镜就能成立,最抗压缩 | -| 测评验证 | 20 秒 | 举证需要时间,低于 20 秒证据不足 | -| 场景种草 | 20 秒 | 氛围需要铺陈,太短像随机片段 | +| 痛点解决 | 15 秒 | 1 镜也能压进钩子+卖点+CTA | +| 前后对比 | 15 秒 | 前后对比至少要撑满一镜 | +| 测评验证 | 30 秒 | 举证需要时间,1 镜证据不足 | +| 场景种草 | 30 秒 | 氛围需要铺陈,太短像随机片段 | 低于该结构的最短时长时,优先保住「钩子 + 结构核心 + CTA」三件,其余全砍。 diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/format-drama.md b/core/backend/skills/ecommerce-video-script/references/playbooks/format-drama.md index 57aa88e..d51cf42 100644 --- a/core/backend/skills/ecommerce-video-script/references/playbooks/format-drama.md +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/format-drama.md @@ -51,11 +51,11 @@ ## 五、节奏与单镜时长 -- **单镜 6–10 秒**(短剧靠切换制造节奏,镜头长了就拖) -- 一镜一个动作或一次对话交换,**别在一镜里塞完整对话** +- **单镜固定 15 秒**(短剧用镜内动作和对白交换撑满这 15 秒,不要靠切很多短镜) +- 一镜一次冲突或一次对话交换,**别在一镜里塞完整三幕** - 有对白的镜,每句 `line` ≤ 25 字(两个人说话,总量还是受 55 字限制) -**推荐总时长 45 秒。** 短剧需要装下三幕,低于 30 秒会讲不完整; +**推荐总时长 45 秒(3 镜)。** 短剧需要装下三幕,低于 30 秒会讲不完整; 超过 60 秒在电商场景下性价比开始下降。 --- diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/format-oral.md b/core/backend/skills/ecommerce-video-script/references/playbooks/format-oral.md index 144861f..c04a1a7 100644 --- a/core/backend/skills/ecommerce-video-script/references/playbooks/format-oral.md +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/format-oral.md @@ -49,11 +49,12 @@ ## 四、节奏与单镜时长 -- **单镜 10–15 秒**(口播里说完一个完整意思需要时间,切太碎会让话说不完整) -- 一句话讲一件事,**一镜讲一到两句话** -- 语速按 **3.5 字 / 秒** 估:12 秒的镜 ≈ 42 字,别超 +- **单镜固定 15 秒**(口播里说完一个完整意思需要时间;15 秒靠景别切换和手部动作撑满,不要切碎) +- 一句话讲一件事,**一镜 2–4 句短句**,禁止一句收工 +- **visual 按秒拆 3–5 刀**,写成 `0-3s:近景……` / `3-8s:手持……` / `8-12s:特写……` / `12-15s:拉回……` +- 语速按 **2.4–3.5 字 / 秒** 估:15 秒镜 **36–52 字**,写作目标 **38–47 字** -**推荐总时长 30 秒。** 口播超过 45 秒完播率掉得很快,除非是测评验证这种 +**推荐总时长 30 秒(2 镜)。** 口播超过 45 秒完播率掉得很快,除非是测评验证这种 需要举证的结构。 --- diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/format-vlog.md b/core/backend/skills/ecommerce-video-script/references/playbooks/format-vlog.md index 0ec6671..ce4aa41 100644 --- a/core/backend/skills/ecommerce-video-script/references/playbooks/format-vlog.md +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/format-vlog.md @@ -55,11 +55,11 @@ Vlog 的镜头必须有**手持感和生活质感**,这是它的可信度来 ## 五、节奏与单镜时长 -- **单镜 5–8 秒**(Vlog 是碎片拼贴,镜头短、数量多) -- 允许有 4–5 秒的纯空镜,用来换时间段 +- **单镜固定 15 秒**(Vlog 的碎片感靠镜内空镜、手部细节、光线变化,不靠把镜子切到 5 秒) +- 允许镜内有一段纯画面(旁白留空或只留半句) - 不需要每镜都说话,**画面自己会讲** -**推荐总时长 30 秒。** Vlog 靠氛围,时长太短装不下时间感(低于 20 秒会像随机片段), +**推荐总时长 30 秒(2 镜)。** Vlog 靠氛围,时长太短装不下时间感(低于 30 秒会像随机片段), 太长又超出电商场景的耐心。 --- diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/structure-scene.md b/core/backend/skills/ecommerce-video-script/references/playbooks/structure-scene.md index fc36494..8ac0299 100644 --- a/core/backend/skills/ecommerce-video-script/references/playbooks/structure-scene.md +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/structure-scene.md @@ -40,12 +40,14 @@ 场景种草的说服力来自细节密度。每条脚本至少铺三样: -- **光**:晨光 / 夕照 / 台灯 / 烛光 —— 光决定情绪,必须明确写进 `visual` +- **光**:晨光 / 夕照 / 台灯 / 烛光 —— 光决定情绪,必须写进每一条秒级分镜 - **声音**:水沸 / 键盘 / 雨 / 风 —— 写进 `visual` 提示环境音 - **触感**:蒸汽、毛毯的绒、杯壁的温度 —— 让画面有温度 - **时间**:具体到时刻(清晨六点 / 下午三点 / 睡前) - **陪衬物**:书、猫、外卖盒、耳机 —— 让场景像真的有人生活 +`visual` 不要写成一段氛围散文,按秒切开:先立场景,再让商品被使用着入场。 + --- ## 四、商品怎么出场 @@ -77,10 +79,12 @@ ## 六、台词分寸 -- 语气是**自述或旁白**,轻、慢、留白 +- 语气是**自述或旁白**,轻、慢;留白靠**句间停顿和画面**,不靠少写字 - **不讲参数、不讲价格、不讲功效** —— 这些会立刻把观众从场景里踢出来 -- 可以整镜无声,让环境音和画面说话 +- **口播 × 场景种草禁止整镜无声**:每镜仍要说满 36–47 字(2–4 句自述),像在场景里自言自语 +- **Vlog** 才可以个别镜无声,让环境音和画面说话 - CTA 要软:「链接放下面了」而不是「快去抢」 +- 商品名全片点名 1–2 次,其余镜写时刻、动作、使用感、卖点原词 --- diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index cf8fb0c..8ad18a7 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -374,6 +374,7 @@ export const api = { prompt?: string; model_config_id?: string; selling_point_ids?: string[]; + persona?: string; base_version_id?: string; aspect_ratio?: string; total_duration?: number; diff --git a/core/frontend/src/design-restraint.css b/core/frontend/src/design-restraint.css index 4e194b4..f8e9c85 100644 --- a/core/frontend/src/design-restraint.css +++ b/core/frontend/src/design-restraint.css @@ -81,16 +81,18 @@ --honey-bg: rgba(236, 183, 48, .08); --honey-bd: rgba(236, 183, 48, .20); - /* ===== Heat · 单 hue + 8 档 alpha · 丁香蓝(对齐 YZ 主 CTA) ===== */ - --heat: #A5B4FC; - --heat-hover: #8ea0f8; - --heat-90: rgba(165, 180, 252, .90); - --heat-40: rgba(165, 180, 252, .40); - --heat-20: rgba(165, 180, 252, .20); - --heat-16: rgba(165, 180, 252, .16); - --heat-12: rgba(165, 180, 252, .12); - --heat-8: rgba(165, 180, 252, .08); - --heat-4: rgba(165, 180, 252, .04); + /* ===== Heat · 单 hue + 8 档 alpha · 克莱因蓝(对齐 YZ 主 CTA,别用浅紫以免像禁用) ===== */ + --klein: #002fa7; + --klein-hover: #002680; + --heat: var(--klein); + --heat-hover: var(--klein-hover); + --heat-90: rgba(0, 47, 167, .90); + --heat-40: rgba(0, 47, 167, .40); + --heat-20: rgba(0, 47, 167, .20); + --heat-16: rgba(0, 47, 167, .16); + --heat-12: rgba(0, 47, 167, .12); + --heat-8: rgba(0, 47, 167, .08); + --heat-4: rgba(0, 47, 167, .04); /* ===== Black-alpha 阶梯 (20 档) ===== */ --black-alpha-1: rgba(0, 0, 0, .01); @@ -133,13 +135,13 @@ /* ===== Shadows ===== */ --shadow-cta: - 0 1px 2px rgba(129, 140, 248, .16), - 0 6px 16px rgba(165, 180, 252, .28); + 0 1px 2px rgba(0, 47, 167, .16), + 0 6px 16px rgba(0, 47, 167, .24); --shadow-cta-hover: - 0 2px 4px rgba(129, 140, 248, .20), - 0 10px 22px rgba(165, 180, 252, .34); + 0 2px 4px rgba(0, 47, 167, .20), + 0 10px 22px rgba(0, 47, 167, .28); --shadow-cta-active: - 0 1px 2px rgba(129, 140, 248, .18); + 0 1px 2px rgba(0, 47, 167, .18); --shadow-floating: 0 8px 28px rgba(17, 19, 24, .06); } @@ -220,11 +222,11 @@ img, svg, video { display: block; max-width: 100%; } /* ─── App shell ─── */ .app { display: grid; - grid-template-columns: var(--sidebar-width) 1fr; + grid-template-columns: var(--sidebar-width) minmax(0, 1fr); min-height: 100vh; transition: grid-template-columns var(--t-base); } -body.sidebar-collapsed .app { grid-template-columns: 96px 1fr; } +body.sidebar-collapsed .app { grid-template-columns: 96px minmax(0, 1fr); } /* ─── Sidebar ─── */ aside.sidebar { @@ -758,7 +760,17 @@ body.sidebar-collapsed .user .em, body.sidebar-collapsed .user::after { display: none; } /* ─── Main + grid background ─── */ -main { position: relative; background: #fff; min-width: 0; } +main { position: relative; background: #fff; min-width: 0; overflow-x: hidden; } +.app:has(.pipeline-page) { + height: 100vh; + overflow: hidden; +} +main:has(.pipeline-page) { + height: 100vh; + overflow: hidden; + display: flex; + flex-direction: column; +} .grid-bg { position: absolute; inset: 0; @@ -817,12 +829,13 @@ main { position: relative; background: #fff; min-width: 0; } /* ─── Topbar ─── */ .topbar { display: flex; align-items: center; gap: 16px; - padding: 0 24px; + padding: 0 clamp(36px, 3.2vw, 68px); border-bottom: 1px solid rgba(27, 32, 40, 0.08); background: #fff; position: sticky; top: 0; z-index: 50; height: 116px; min-height: 116px; + min-width: 0; box-sizing: border-box; flex-wrap: nowrap; } @@ -926,7 +939,7 @@ main { position: relative; background: #fff; min-width: 0; } color: var(--black-alpha-40); letter-spacing: .02em; } -.topbar .right { margin-left: auto; display: flex; align-items: center; gap: 10px; } +.topbar .right { margin-left: auto; display: flex; align-items: center; gap: 10px; min-width: 0; flex: 0 1 auto; } .balance-chip { display: inline-flex; align-items: center; gap: 7px; height: 40px; @@ -1007,6 +1020,14 @@ main { position: relative; background: #fff; min-width: 0; } min-height: calc(100vh - 116px); animation: yz-page-enter 280ms cubic-bezier(0.22, 1, 0.36, 1); } +.content:has(.pipeline-page) { + padding: 0; + flex: 1 1 auto; + height: auto; + min-height: 0; + overflow: hidden; + animation: none; +} @keyframes yz-page-enter { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } diff --git a/core/frontend/src/pipeline-page.css b/core/frontend/src/pipeline-page.css index 21fe5f3..8f62a11 100644 --- a/core/frontend/src/pipeline-page.css +++ b/core/frontend/src/pipeline-page.css @@ -7,17 +7,14 @@ width: 100%; max-width: none; box-sizing: border-box; - margin: -24px -28px -60px; + margin: 0; padding: 28px clamp(40px, 3.2vw, 68px) 32px; - height: calc(100vh - 116px); + height: 100%; min-height: 0; display: flex; flex-direction: column; overflow: hidden; } -@media (max-width: 1100px) { - .pipeline-page { margin: -28px -24px -48px; } -} .pipeline-page .pl-head { min-height: 54px; @@ -1223,9 +1220,10 @@ overflow: hidden; display: flex; flex-direction: column; - border: 1px solid var(--border-faint); + border: 1px solid rgba(0, 47, 167, 0.13); border-radius: 12px; background: var(--surface); + box-shadow: 0 12px 28px rgba(20, 27, 38, 0.07); cursor: pointer; } .video-card:hover { border-color: rgba(0, 47, 167, 0.28); } @@ -1235,7 +1233,55 @@ aspect-ratio: 9 / 16; max-height: 280px; overflow: hidden; - background: #e5e6e9; + background: #fff; + } + .video-thumb.pending::before { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background-color: #fff; + background-image: + radial-gradient(ellipse 4px 1px at center, rgba(0, 47, 167, 0.28) 98%, transparent), + radial-gradient(ellipse 1px 4px at center, rgba(0, 47, 167, 0.28) 98%, transparent); + background-position: center; + background-size: 10px 10px; + -webkit-mask-image: radial-gradient(ellipse 42% 28% at center, #000 0 18%, rgba(0, 0, 0, 0.78) 40%, rgba(0, 0, 0, 0.24) 72%, transparent 100%); + mask-image: radial-gradient(ellipse 42% 28% at center, #000 0 18%, rgba(0, 0, 0, 0.78) 40%, rgba(0, 0, 0, 0.24) 72%, transparent 100%); + } + .video-thumb.pending::after, + .video-thumb.generating::after { + content: "待生成"; + position: absolute; + top: 50%; + left: 50%; + z-index: 2; + pointer-events: none; + min-width: 72px; + height: 32px; + display: grid; + place-items: center; + padding: 0 14px; + border: 1px solid rgba(0, 47, 167, 0.26); + border-radius: 8px; + color: var(--klein); + background: #fff; + box-shadow: 0 8px 22px rgba(0, 47, 167, 0.11); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + transform: translate(-50%, -50%); + } + .video-thumb.generating::after { content: "生成中"; } + .video-thumb .video-loader { + position: absolute; + inset: 0; + z-index: 1; + overflow: hidden; + background: + linear-gradient(120deg, transparent 20%, rgba(255, 255, 255, 0.88) 43%, transparent 66%) 0 0 / 220% 100%, + linear-gradient(145deg, #eaf1fb, #f8fbff 48%, #e9eef7); + animation: videoShimmer 2.2s linear infinite; } .video-thumb video { position: absolute; @@ -1531,8 +1577,265 @@ .vd-history-thumb.tri .placeholder { aspect-ratio: 16/9; } } +/* 弹窗 portal 到 body,选择器必须顶层,否则会被工作区 overflow 裁掉顶栏 */ +.asset-modal-bg { + --klein: #002fa7; + position: fixed; + inset: 0; + z-index: 1800; + display: flex; + align-items: center; + justify-content: center; + padding: 32px 24px; + background: rgba(21, 20, 15, 0.42); +} +.asset-modal { + background: var(--surface); + border: 1px solid var(--border-faint); + border-radius: 12px; + width: min(960px, 100%); + max-height: calc(100vh - 48px); + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18); +} +.asset-modal-h { + display: flex; + align-items: center; + gap: 10px; + min-height: 56px; + padding: 12px 20px; + border-bottom: 1px solid var(--border-faint); + flex-shrink: 0; +} +.asset-modal-h h2 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--accent-black); +} +.asset-modal-h .ad-tag, +.asset-modal-h .mono { + font-family: var(--font-mono); + font-size: 12px; + color: var(--black-alpha-48); + letter-spacing: .02em; +} +.asset-modal-h .x { + width: 30px; + height: 30px; + display: grid; + place-items: center; + background: transparent; + border: 0; + cursor: pointer; + color: var(--black-alpha-56); + border-radius: var(--r-sm); + margin-left: auto; +} +.asset-modal-h .x:hover { background: var(--black-alpha-08); color: var(--accent-black); } +.asset-modal-body { padding: 20px 24px 24px; overflow-y: auto; flex: 1; min-height: 0; } +.asset-modal-f { + padding: 14px 20px; + border-top: 1px solid var(--border-faint); + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} +.asset-detail-grid { display: grid; grid-template-columns: 280px minmax(0, 1fr); gap: 24px; align-items: start; } +.asset-detail-lead { display: flex; flex-direction: column; gap: 10px; } +.asset-detail-lead .ad-lead-wrap { position: relative; } +.asset-detail-lead .placeholder.ad-lead-img { aspect-ratio: 9/16; border-radius: var(--r-md); } +.ad-zoom-btn { + position: absolute; + right: 8px; + bottom: 8px; + width: 32px; + height: 32px; + padding: 0; + background: rgba(21, 20, 15, .7); + color: #fff; + border: 0; + border-radius: var(--r-sm); + display: grid; + place-items: center; + cursor: pointer; + opacity: 0; + transition: opacity var(--t-base), background var(--t-base); + z-index: 3; +} +.ad-zoom-btn:hover { background: rgba(21, 20, 15, .92); } +.ad-zoom-btn svg { width: 14px; height: 14px; } +.asset-detail-lead .ad-lead-wrap:hover .ad-zoom-btn, +.asset-detail-tri-row .ad-tri-wrap:hover .ad-zoom-btn { opacity: 1; } +.asset-detail-tri-row .ad-tri-wrap { position: relative; } +.asset-detail-lead .ad-thumbs { display: flex; gap: 8px; flex-wrap: wrap; } +.asset-detail-lead .ad-thumbs .thumb { + flex: 0 0 56px; + width: 56px; + aspect-ratio: 3/4; + border-radius: var(--r-sm); + border: 1px solid var(--border-faint); + cursor: pointer; + overflow: hidden; + transition: border-color var(--t-base); +} +.asset-detail-lead .ad-thumbs .thumb:hover { border-color: var(--heat-40); } +.asset-detail-lead .ad-thumbs .thumb.active { border-color: var(--heat); border-width: 2px; } +.asset-detail-right .ad-section + .ad-section { margin-top: 18px; } +.asset-detail-section-h { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + color: var(--accent-black); + margin-bottom: 10px; +} +.asset-detail-section-h .ic { width: 14px; height: 14px; color: var(--klein); flex-shrink: 0; display: grid; place-items: center; } +.asset-detail-section-h .ic svg { width: 14px; height: 14px; } +.asset-detail-section-h .ad-ratio-chip { + margin-left: auto; + font-family: var(--font-mono); + font-size: 12px; + padding: 2px 8px; + border-radius: var(--r-sm); + background: var(--background-lighter); + border: 1px solid var(--border-faint); + color: var(--black-alpha-56); + letter-spacing: .02em; +} +.asset-detail-section-h .ad-icon-btn { + width: 28px; + height: 28px; + display: grid; + place-items: center; + background: var(--surface); + border: 1px solid var(--border-faint); + border-radius: var(--r-sm); + color: var(--black-alpha-56); + cursor: pointer; + transition: background var(--t-base), color var(--t-base), border-color var(--t-base); +} +.asset-detail-section-h .ad-icon-btn:hover { background: var(--klein); color: #fff; border-color: var(--klein); } +.asset-detail-section-h .ad-icon-btn svg { width: 12px; height: 12px; } +.asset-detail-section-h .ad-icon-gap { margin-left: 8px; } +.asset-detail-tri-row .placeholder { aspect-ratio: 16 / 9; border-radius: var(--r-md); } +.md-view-versions { display: flex; gap: 6px; overflow-x: auto; padding: 2px; margin-top: 8px; } +.md-view-versions .v-thumb { + flex: 0 0 auto; + width: 72px; + aspect-ratio: 16/9; + background: var(--background-lighter); + border: 1px solid var(--border-faint); + border-radius: var(--r-sm); + position: relative; + cursor: pointer; + display: grid; + place-items: center; + overflow: hidden; + transition: border-color var(--t-base); +} +.md-view-versions .v-thumb:hover { border-color: var(--heat-40); } +.md-view-versions .v-thumb.active { border-color: var(--heat); border-width: 2px; box-shadow: 0 0 0 2px var(--heat-12); } +.md-view-versions .v-thumb .v { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; } +.md-view-versions .v-thumb.active .v { color: var(--heat); font-weight: 600; } +.asset-detail-tip { + margin-top: 10px; + padding: 10px 12px; + background: var(--heat-12); + border: 1px solid var(--heat-20); + border-radius: var(--r-sm); + font-size: 12px; + color: var(--accent-black); + display: flex; + align-items: center; + gap: 8px; + line-height: 1.5; +} +.asset-detail-tip svg { width: 14px; height: 14px; color: var(--heat); flex-shrink: 0; } +.asset-detail-tip .ai-gen-btn { + margin-left: auto; + height: 26px; + padding: 0 10px; + background: var(--heat); + color: var(--accent-white); + border: 1px solid var(--heat); + border-radius: var(--r-sm); + font-size: 12px; + cursor: pointer; + font-family: inherit; + flex-shrink: 0; +} +.asset-modal-f .ad-foot-stats { display: flex; gap: 6px; margin-right: auto; } +.asset-modal-f .ad-stat-btn { + height: 32px; + padding: 0 12px; + display: inline-flex; + align-items: center; + gap: 6px; + background: transparent; + border: 1px solid var(--border-faint); + border-radius: var(--r-sm); + color: var(--black-alpha-72); + font-size: 12px; + font-family: inherit; + cursor: pointer; + transition: background var(--t-base), color var(--t-base), border-color var(--t-base); +} +.asset-modal-f .ad-stat-btn:hover { background: var(--background-lighter); color: var(--accent-black); } +.asset-modal-f .ad-stat-btn svg { width: 13px; height: 13px; } +.ad-detail-prompt { + width: 100%; + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.6; + color: var(--black-alpha-72); + background: var(--background-base); + border: 1px solid var(--border-faint); + border-radius: var(--r-sm); + padding: 10px 12px; + outline: none; + resize: none; + height: 88px; + transition: border-color var(--t-base), background var(--t-base), box-shadow var(--t-base); +} +.ad-thumbs .thumb.has-mock-media, +.md-view-versions .v-thumb.has-mock-media, +.prod-preview-history .h-thumb.has-mock-media { + background-image: var(--mock-media-url); + background-size: cover; + background-position: center; + background-repeat: no-repeat; +} +.ad-detail-prompt:hover { border-color: var(--heat-20); } +.ad-detail-prompt:focus { border-color: var(--heat); background: var(--surface); color: var(--accent-black); box-shadow: 0 0 0 3px var(--heat-12); } +.vd-main-wrap { display: flex; gap: 18px; align-items: flex-start; } +.vd-main { flex: 0 0 280px; aspect-ratio: 9/16; max-height: 460px; background: #000; border-radius: var(--r-md); overflow: hidden; position: relative; } +.vd-main video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; } +.vd-info { flex: 1; min-width: 0; } +.vd-section-h { display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 600; color: var(--accent-black); margin-bottom: 10px; } +.vd-kv { display: grid; grid-template-columns: 72px 1fr; gap: 6px 10px; font-size: 13px; } +.vd-kv .k { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .03em; padding-top: 1px; } +.vd-kv .v { color: var(--accent-black); min-width: 0; overflow-wrap: anywhere; } +.vd-history-h { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .06em; text-transform: uppercase; margin-bottom: 10px; } +.vd-history-row { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 6px; scrollbar-width: thin; } +.vd-history-thumb { flex: 0 0 64px; min-width: 64px; display: flex; flex-direction: column; gap: 4px; padding: 4px; border: 1px solid var(--border-faint); border-radius: var(--r-sm); background: var(--surface); cursor: pointer; transition: border-color var(--t-base); position: relative; } +.vd-history-thumb:hover { border-color: var(--heat); } +.vd-history-thumb.current { border-color: var(--heat); background: var(--heat-12); } +.vd-history-thumb .placeholder { aspect-ratio: 9/16; } +.vd-history-thumb .ts { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); text-align: center; } +.vd-prompt-field { margin-top: 16px; } +.vd-prompt-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 6px; } +.vd-prompt-head .label { font-family: var(--font-mono); font-size: 12px; font-weight: 500; color: var(--black-alpha-56); letter-spacing: .04em; } +.vd-prompt-edit { background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-md); padding: 12px 14px; font-family: var(--font-mono); font-size: 12px; line-height: 1.7; color: var(--accent-black); white-space: pre-wrap; min-height: 96px; outline: none; letter-spacing: .01em; cursor: text; } +.vd-modal-actions { margin-left: auto; display: flex; align-items: center; gap: 8px; } + /* 流程步骤4 · 基础资产 loading 转圈(顶层 @keyframes 才注册) */ @keyframes assetSpin { to { transform: rotate(360deg); } } +@keyframes videoShimmer { to { background-position: -220% 0, 0 0; } } /* 进度流逐条滚入(顶层 @keyframes 才注册) */ @keyframes psRowIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } } @@ -1646,7 +1949,7 @@ .ref-gate-mask { position: fixed; inset: 0; - z-index: 60; + z-index: 1800; display: flex; align-items: center; justify-content: center; diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index b5fe6bf..ada8f69 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -1,4 +1,5 @@ import { Fragment, memo, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import type { ChangeEvent, CSSProperties, PointerEvent as ReactPointerEvent } from "react"; import { ArrowLeft, ArrowRight, Check, ChevronRight, Image, Info, LayoutList, Play, RefreshCw, Route, Sparkles, Upload, UsersRound, X } from "lucide-react"; import { api, ApiError } from "../api"; @@ -352,7 +353,9 @@ function PromptBox({ value, onChange, className = "prompt-box", id, ariaLabel, s // 分镜「旁白 / 画面」可编辑框:非受控 textarea,落库走 onBlur。 // 聚焦中绝不覆盖,避免提交后父组件用旧值把正在输入的字冲掉。 -function ShotArea({ value, placeholder, ariaLabel, onCommit }: { value: string; placeholder: string; ariaLabel: string; onCommit: (text: string) => void }) { +function ShotArea({ value, placeholder, ariaLabel, onCommit, rows = 2 }: { + value: string; placeholder: string; ariaLabel: string; onCommit: (text: string) => void; rows?: number; +}) { const ref = useRef(null); useEffect(() => { const el = ref.current; @@ -366,7 +369,7 @@ function ShotArea({ value, placeholder, ariaLabel, onCommit }: { value: string; defaultValue={value} placeholder={placeholder} aria-label={ariaLabel} - rows={2} + rows={rows} onBlur={(e) => { const text = e.currentTarget.value.trim(); if (text !== value) onCommit(text); @@ -404,7 +407,7 @@ function DraftShotCard({ draft, onCommit, onCancel }: {