大量修改二期功能清单内容

This commit is contained in:
Azmat@qq.com
2026-08-17 18:26:42 +08:00
parent 36e91aab3c
commit d1ecb52125
76 changed files with 4222 additions and 294 deletions
+51
View File
@@ -99,6 +99,31 @@ def _refresh_processing_free_asset(free_asset: FreeAsset) -> bool:
return False
def _guard_asset_reference(asset: Asset, label: str) -> None:
"""三库引用前的审核闸(模块4 · 4.2)。
平台生成的资产免审直接过;用户上传的必须真过一遍审核才能当生成参考。
判据是 Asset.source,**不是**「在不在资产库里」—— 按库免审等于把审核架空:
用户传一张图进库、再从自由创作引用出去,就绕过了整套人像审核。
"""
from apps.assets.review import poll_asset_review, reference_review_state, submit_asset_for_review
state = reference_review_state(asset)
if state == "processing":
poll_asset_review(asset) # 实时刷一次,别让用户干等下一轮轮询
state = reference_review_state(asset)
if state == "allowed":
return
name = label or asset.name or "未命名"
if state == "processing":
raise ValueError(f"素材「{name}」正在审核中,请稍后再引用")
if state == "failed":
raise ValueError(f"素材「{name}」未通过审核,不能用作生成参考")
# 从没送过审(资产库上传不自动送审):这里补送一次,用户等审核结果即可,不必回去手动点
submit_asset_for_review(asset, force=True)
raise ValueError(f"素材「{name}」是上传素材,已提交审核,通过后即可引用")
def build_content_items(*, team, prompt: str, mode: str, references: list) -> dict:
"""references → 火山 content_items + api_prompt(@label 已替换)。
@@ -244,6 +269,32 @@ def build_content_items(*, team, prompt: str, mode: str, references: list) -> di
_remember_library_asset(fa)
continue
# 三库引用(模块4 · 4.1):资产库 / 模特库 / 商品库 挑出来的东西最终都是一行 Asset,
# 所以后端只认一种 source=asset,三个库的差别全在前端的 picker 上。
if source == "asset" and ref.get("asset_id"):
asset = Asset.objects.filter(id=ref["asset_id"], team=team, is_deleted=False).first()
if asset is None:
raise ValueError(f"素材「{label or '未命名'}」不存在或已被删除")
_guard_asset_reference(asset, label)
from .services import _asset_preview_url, _seedance_ref_url
raw_url = _asset_preview_url(asset)
if not raw_url:
raise ValueError(f"素材「{label or asset.name}」没有可用文件,无法引用")
# 已登记火山素材库的走 asset://(写实人脸走直链会被 InputImageSensitiveContentDetected 拒)
resolved_url = _seedance_ref_url(raw_url, asset.review_status, asset.review_remote_id)
kind = asset.asset_type if asset.asset_type in {"image", "video", "audio"} else "image"
if mode == "keyframe":
if kind != "image":
raise ValueError("首尾帧模式仅支持图片素材")
effective_role = role if role in {"first_frame", "last_frame"} else "first_frame"
else:
effective_role = "reference_video" if kind == "video" else ("reference_audio" if kind == "audio" else "reference_image")
asset_type = _push(kind, resolved_url, effective_role, duration)
if label and label not in label_to_placeholder:
label_to_placeholder[label] = _placeholder_for(asset_type)
continue
# 直传素材(已上传 TOS 的直链)
if ref_type == "image":
# 参考图模式下所有图 role 必须 reference_image;keyframe 用 first_frame/last_frame
@@ -0,0 +1,18 @@
# Generated by Django 5.1.15 on 2026-08-17 09:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ai', '0029_aimodelattempt'),
]
operations = [
migrations.AlterField(
model_name='aitask',
name='task_type',
field=models.CharField(choices=[('script_generation', 'Script Generation'), ('script_optimization', 'Script Optimization'), ('entity_extraction', 'Entity Extraction'), ('video_digest', 'Video Digest'), ('product_image', 'Product Image'), ('person_image', 'Person Image'), ('model_triview', 'Model Triview'), ('scene_image', 'Scene Image'), ('storyboard', 'Storyboard'), ('video_segment', 'Video Segment'), ('voiceover', 'Voiceover'), ('export', 'Export'), ('free_video', 'Free Video')], max_length=48),
),
]
+1
View File
@@ -92,6 +92,7 @@ class AITask(TeamOwnedModel):
SCRIPT_GENERATION = "script_generation", "Script Generation"
SCRIPT_OPTIMIZATION = "script_optimization", "Script Optimization"
ENTITY_EXTRACTION = "entity_extraction", "Entity Extraction"
VIDEO_DIGEST = "video_digest", "Video Digest" # 上传视频提炼:参考视频 → 分镜稿
PRODUCT_IMAGE = "product_image", "Product Image"
PERSON_IMAGE = "person_image", "Person Image"
MODEL_TRIVIEW = "model_triview", "Model Triview"
+301 -44
View File
@@ -19,6 +19,7 @@ SSE 事件(每帧 `data: {json}\n\n`,json 带 type):
from __future__ import annotations
import json
import math
import re
from decimal import Decimal
from functools import lru_cache
@@ -34,7 +35,70 @@ from apps.billing.services.ledger import charge_reserved_credit, release_credit
VALID_TONES = ["种草", "测评", "剧情", "痛点"]
VALID_ROLES = ["钩子", "痛点", "卖点", "CTA"]
VALID_ENTITY_TYPES = ["character", "scene", "product"]
DURATION_TIERS = [15, 30, 60, 90]
# 时长:总时长 5–60 秒按 5 秒步进;单镜 4–15 秒(15 是出片模型硬上限,越界下游直接拒片)。
TOTAL_DURATION_MIN = 5
TOTAL_DURATION_MAX = 60
TOTAL_DURATION_STEP = 5
SEGMENT_DURATION_MIN = 4
SEGMENT_DURATION_MAX = 15
DEFAULT_TOTAL_DURATION = 30
# 表现形式 × 视频结构(二期)。key 用 ASCII 找套路文件,label 是给模型和用户看的中文。
PRESENTATION_FORMATS: dict[str, str] = {"oral": "口播", "drama": "短剧", "vlog": "Vlog"}
VIDEO_STRUCTURES: dict[str, str] = {
"pain": "痛点解决",
"contrast": "前后对比",
"review": "测评验证",
"scene": "场景种草",
}
DEFAULT_PRESENTATION_FORMAT = "oral"
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}
# 可懂语速上限 3.5 字/秒 —— 旁白字数按这一镜自己的秒数算,不再全场 55 字一刀切。
NARRATION_CHARS_PER_SECOND = 3.5
NARRATION_CHARS_HARD_CAP = 55
_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 combo_keys(value_format, value_structure) -> tuple[str, str]:
"""把「中文标签或 ASCII key」都归一成 key。落库存的是中文,请求传的是 key,两边都要认。"""
fmt = _FORMAT_KEY_BY_LABEL.get(value_format, value_format)
structure = _STRUCTURE_KEY_BY_LABEL.get(value_structure, value_structure)
return coerce_combo(fmt, structure)
def allowed_structures(fmt: str) -> list[str]:
"""某表现形式下可选的视频结构 key(1.8 组合联动:换表现形式,结构列表跟着变)。"""
fmt = fmt if fmt in PRESENTATION_FORMATS else DEFAULT_PRESENTATION_FORMAT
return [key for key in VIDEO_STRUCTURES if (fmt, key) not in FORBIDDEN_COMBOS]
def coerce_combo(fmt: str | None, structure: str | None) -> tuple[str, str]:
"""把任意输入夹成一组合法的(表现形式, 视频结构)。禁用组合回落到该形式的第一个合法结构。"""
fmt = fmt if fmt in PRESENTATION_FORMATS else DEFAULT_PRESENTATION_FORMAT
structure = structure if structure in VIDEO_STRUCTURES else DEFAULT_VIDEO_STRUCTURE
if (fmt, structure) in FORBIDDEN_COMBOS:
structure = allowed_structures(fmt)[0]
return fmt, structure
def narration_limit(duration: int) -> int:
"""这一镜旁白的字数上限:秒数 × 3.5,且不超过硬上限 55。"""
return max(1, min(NARRATION_CHARS_HARD_CAP, int(duration * NARRATION_CHARS_PER_SECOND)))
# --------------------------------------------------------------------------- #
@@ -53,9 +117,18 @@ def _skill_dir() -> Path:
return base / "skills" / "ecommerce-video-script"
def _read_ref(path: Path, label: str) -> str:
if not path.exists():
return ""
return f"\n\n===== {label} =====\n\n{path.read_text(encoding='utf-8')}"
@lru_cache(maxsize=1)
def load_ecommerce_skill() -> str:
"""读取 SKILL.md + 全部 references 拼成系统提示词(领域知识)。缺文件不致命,尽量给。"""
def _load_skill_base() -> str:
"""SKILL.md + references 根目录下的通用资料(方法论/钩子库/品类/平台/自检),每次都要。
playbooks/ 是子目录,glob("*.md") 不会递归到,套路由 load_ecommerce_skill 按组合单独挑。
"""
skill_dir = _skill_dir()
parts: list[str] = []
main = skill_dir / "SKILL.md"
@@ -64,11 +137,33 @@ def load_ecommerce_skill() -> str:
ref_dir = skill_dir / "references"
if ref_dir.exists():
for ref in sorted(ref_dir.glob("*.md")):
parts.append(f"\n\n===== references/{ref.name} =====\n\n{ref.read_text(encoding='utf-8')}")
if not parts:
parts.append(_read_ref(ref, f"references/{ref.name}"))
return "".join(parts)
@lru_cache(maxsize=32)
def load_ecommerce_skill(
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
) -> str:
"""通用资料 + 组合矩阵 + **只挑被选中的那一份表现形式和那一份视频结构**。
套路全量灌进去会让系统提示词翻倍(每份 2-3K 字),而且模型会在 11 套互相矛盾的
镜头语言里挑花眼。只给当前这一组,提示词更短、约束更硬。
"""
fmt, structure = coerce_combo(presentation_format, video_structure)
playbooks = _skill_dir() / "references" / "playbooks"
parts = [
_load_skill_base(),
_read_ref(playbooks / "combo-matrix.md", "references/playbooks/combo-matrix.md"),
_read_ref(playbooks / f"format-{fmt}.md", f"references/playbooks/format-{fmt}.md"),
_read_ref(playbooks / f"structure-{structure}.md", f"references/playbooks/structure-{structure}.md"),
]
joined = "".join(parts)
if not joined.strip():
# 兜底:skill 文件缺失也能退化生成(交接文档会提示补 skills 目录)
return "你是电商带货短视频脚本生成 agent,输出结构化 ScriptDraft JSON。"
return "".join(parts)
return joined
# 运行时输出协议:优先级高于 skill 里的「只输出 JSON / 不展示思考」,只为流式体感放开一句前言。
@@ -119,12 +214,25 @@ def build_agent_messages(
base_draft: dict | None,
aspect_ratio: str,
total_duration: int,
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
target_index: int | None = None,
) -> list[dict[str, str]]:
system = load_ecommerce_skill() + _OUTPUT_PROTOCOL
fmt, structure = coerce_combo(presentation_format, video_structure)
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)
head = (
f"【画幅】{aspect_ratio}\n"
f"总时长{total_duration} 秒(每 15 秒一镜,共 {total_duration // 15})\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)}"
)
if mode == "revise" and base_draft and target_index is not None:
@@ -154,7 +262,8 @@ def build_agent_messages(
)
else:
user = (
"【任务】全自动(模式①):仅凭商品与前置条件,自动定档/选 tone/造 entity/填黄金结构。\n"
"【任务】全自动(模式①):仅凭商品与前置条件,按指定的表现形式与视频结构套路"
"自动定镜数/选 tone/造 entity/填结构骨架。\n"
f"{head}\n\n"
"请按技能流程一次性产出 ScriptDraft。"
)
@@ -217,14 +326,87 @@ def _extract_json(text: str) -> str | None:
return _balanced_object(text)
def _nearest_duration(value) -> int:
def coerce_total_duration(value) -> int:
"""总时长夹到 5–60 秒、5 秒步进。空值/0/非法输入一律回落默认 30。"""
if value in (None, "", 0):
return DEFAULT_TOTAL_DURATION
try:
value = int(value)
except (TypeError, ValueError):
return 60
if value in DURATION_TIERS:
return value
return min(DURATION_TIERS, key=lambda t: abs(t - value))
return DEFAULT_TOTAL_DURATION
if value <= 0:
return DEFAULT_TOTAL_DURATION
value = max(TOTAL_DURATION_MIN, min(TOTAL_DURATION_MAX, value))
stepped = int(round(value / TOTAL_DURATION_STEP) * TOTAL_DURATION_STEP)
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 秒——
这只是**兜底**,模型自己给的不等长时长只要合法就照用。
"""
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)]
def plan_roles(count: int) -> list[str]:
"""镜数 → role 序列。通用规则:首钩子、次痛点、末 CTA,中间全是卖点。"""
if count <= 1:
return ["钩子"]
if count == 2:
return ["钩子", "卖点"]
if count == 3:
return ["钩子", "卖点", "CTA"]
return ["钩子", "痛点"] + ["卖点"] * (count - 3) + ["CTA"]
def _fit_segment_durations(raw: list, total: int, presentation_format: str) -> list[int]:
"""采纳模型给的每镜时长(允许不等长),非法就修;修不动就整组回落到 plan_segment_durations。
合法定义:每镜 4–15 秒的整数,且加总 == 总时长。模型很容易把总数算错一两秒,
所以先夹单镜范围,再把差额摊到还有余量的镜上,尽量保住模型的节奏意图。
"""
if not raw:
return plan_segment_durations(total, presentation_format)
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
return durations
# 模型每次生成都可能换字段名(scene/screenDescription/visual…、dialogue/lines/caption…),
@@ -306,7 +488,14 @@ def _resolve_segments(draft: dict) -> list:
return best
def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) -> dict:
def normalize_draft(
raw_text: str,
*,
aspect_ratio: str,
total_duration: int,
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
) -> dict:
"""把模型输出抽成 JSON 并按铁律1契约规范化。宽容:小问题就地修,不轻易抛错。"""
blob = _extract_json(raw_text)
if not blob:
@@ -332,10 +521,12 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) ->
draft["segments"] = _resolve_segments(draft)
draft["aspect_ratio"] = (draft.get("aspect_ratio") or aspect_ratio or "9:16").strip()
dur = _nearest_duration(draft.get("total_duration") or total_duration)
# 总时长以「请求参数」为准:模型经常把它算错,而下游出片/计价都按这个数走。
dur = coerce_total_duration(total_duration)
draft["total_duration"] = dur
seg_count = max(1, dur // 15)
draft["segment_count"] = seg_count
fmt, structure = coerce_combo(presentation_format, video_structure)
draft["presentation_format"] = PRESENTATION_FORMATS[fmt]
draft["video_structure"] = VIDEO_STRUCTURES[structure]
tone = (draft.get("tone") or "").strip()
draft["tone"] = tone if tone in VALID_TONES else "种草"
draft["hook"] = (draft.get("hook") or "").strip()
@@ -367,15 +558,21 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) ->
draft["entities"] = norm_entities
valid_ids = {e["id"] for e in norm_entities}
# segments 规范化:对齐镜数,role 枚举,引用合法
# segments 规范化:镜数交给模型(只夹进合法区间),role 枚举,引用合法
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))
role_plan = plan_roles(seg_count)
norm_segments: list[dict] = []
for i, seg in enumerate(segments[:seg_count]):
for i, seg in enumerate(segments):
if not isinstance(seg, dict):
seg = {}
role = (seg.get("role") or "").strip()
if role not in VALID_ROLES:
role = VALID_ROLES[min(i, len(VALID_ROLES) - 1)]
role = role_plan[i]
speaker = seg.get("speaker")
speaker = speaker if (speaker in valid_ids) else None
refs = [r for r in (seg.get("entity_refs") or []) if r in valid_ids]
@@ -406,7 +603,7 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) ->
norm_segments.append(
{
"index": i,
"duration": 15,
"duration": seg.get("duration"), # 先原样收着,等镜数定了再统一夹进 4–15 秒并配平总时长
"role": role,
"narration": narration,
"speaker": speaker,
@@ -416,14 +613,14 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) ->
"dialogue": dialogue,
}
)
# 不足镜数则补占位镜(极少发生,避免下游镜数对不上)
# 不足下限则补占位镜(极少发生,避免出现超过 15 秒的镜导致下游拒片)
while len(norm_segments) < seg_count:
i = len(norm_segments)
norm_segments.append(
{
"index": i,
"duration": 15,
"role": VALID_ROLES[min(i, len(VALID_ROLES) - 1)],
"duration": None,
"role": role_plan[i],
"narration": "",
"speaker": None,
"visual": "",
@@ -434,11 +631,26 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) ->
)
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
draft["segments"] = norm_segments
draft["segment_count"] = len(norm_segments)
return draft
def _merge_single_segment(base: dict, new: dict, idx: int, aspect_ratio: str, total_duration: int) -> dict:
def _merge_single_segment(
base: dict,
new: dict,
idx: int,
aspect_ratio: str,
total_duration: int,
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
) -> dict:
"""精准改一镜:以基准稿为底,只用新稿的第 idx 镜替换,其余镜逐字保持;合并新稿引入的新 entity(对白可能加角色)。再整体规范化。"""
merged = json.loads(json.dumps(base)) # 深拷贝
base_ids = {e.get("id") for e in merged.get("entities", []) if isinstance(e, dict)}
@@ -459,7 +671,13 @@ def _merge_single_segment(base: dict, new: dict, idx: int, aspect_ratio: str, to
target["index"] = idx
segs[idx] = target
merged["segments"] = segs
return normalize_draft(json.dumps(merged, ensure_ascii=False), aspect_ratio=aspect_ratio, total_duration=total_duration)
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,
)
# --------------------------------------------------------------------------- #
@@ -496,14 +714,17 @@ def persist_script_draft(*, project, user, task, draft: dict, source: str):
task=task,
title=(draft.get("hook") or "AI 脚本")[:128],
content=json.dumps(draft, ensure_ascii=False, indent=2),
source=source if source in ("ai", "theme", "manual", "revise") else "ai",
source=source if source in ("ai", "theme", "manual", "video", "revise") else "ai",
is_adopted=False,
metadata={
"hook": draft.get("hook", ""),
"tone": draft.get("tone", ""),
"aspect_ratio": draft.get("aspect_ratio", "9:16"),
"total_duration": draft.get("total_duration", 60),
"segment_count": draft.get("segment_count", 4),
"total_duration": draft.get("total_duration", DEFAULT_TOTAL_DURATION),
"segment_count": draft.get("segment_count", len(draft.get("segments") or [])),
# 二期:表现形式 × 视频结构 跟着稿子走,改稿和「保存模板」都要读它
"presentation_format": draft.get("presentation_format", ""),
"video_structure": draft.get("video_structure", ""),
"entities": draft.get("entities", []),
},
)
@@ -511,7 +732,7 @@ def persist_script_draft(*, project, user, task, draft: dict, source: str):
ScriptSegment.objects.create(
script_version=script,
sort_order=seg["index"],
duration_seconds=seg.get("duration", 15),
duration_seconds=seg.get("duration") or SEGMENT_DURATION_MAX,
narration=seg.get("narration", ""),
visual_prompt=seg.get("visual", ""),
role=seg.get("role", ""),
@@ -570,15 +791,20 @@ def stream_script_agent(
selling_point_ids: list[str] | None = None,
base_version_id: str | None = None,
aspect_ratio: str = "9:16",
total_duration: int = 60,
total_duration: int = DEFAULT_TOTAL_DURATION,
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
target_index: int | None = None,
entry_source: str = "",
):
"""生成 SSE 帧字符串的同步生成器,供 StreamingHttpResponse 包裹。
target_index 非空 = 精准只改第 N 镜(读全脚本上下文,后端强制保留其余镜原样)。"""
from apps.ai.services import create_ai_task, stream_routed_text_request
yield _sse({"type": "tool", "id": "skill", "label": "加载电商脚本技能", "status": "running"})
skill_loaded = bool(load_ecommerce_skill())
fmt, structure = coerce_combo(presentation_format, video_structure)
yield _sse({"type": "tool", "id": "skill", "label": f"加载套路:{PRESENTATION_FORMATS[fmt]} · {VIDEO_STRUCTURES[structure]}", "status": "running"})
skill_loaded = bool(load_ecommerce_skill(fmt, structure))
yield _sse({"type": "tool", "id": "skill", "status": "done" if skill_loaded else "error"})
yield _sse({"type": "tool", "id": "analyze", "label": f"分析商品:{project.product.title}", "status": "running"})
@@ -587,8 +813,10 @@ def stream_script_agent(
base_draft = _load_base_draft(project, base_version_id)
if base_draft is None:
target_index = None # 没有基准稿就退回整版生成,单镜改无从谈起
# 改稿以基准稿的时长/镜数为准,避免请求侧默认值(前端可能硬编码 60)把 90s/6镜稿的尾镜挤掉
effective_duration = (base_draft.get("total_duration") if base_draft else None) or total_duration
# 改稿以基准稿的时长/镜数为准,避免请求侧默认值把长稿的尾镜挤掉
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", []))
@@ -602,7 +830,9 @@ def stream_script_agent(
selling_point_ids=selling_point_ids,
base_draft=base_draft,
aspect_ratio=aspect_ratio,
total_duration=effective_duration, # 改稿用基准稿时长,prompt head 才不会误导模型镜数(否则模型按60s只出4镜)
total_duration=effective_duration, # 改稿用基准稿时长,prompt head 才不会误导模型镜数
presentation_format=fmt,
video_structure=structure,
target_index=target_index,
)
yield _sse({"type": "tool", "id": "analyze", "status": "done"})
@@ -648,6 +878,8 @@ def stream_script_agent(
raw_text,
aspect_ratio=aspect_ratio,
total_duration=effective_duration,
presentation_format=fmt,
video_structure=structure,
)
if target_index is not None and base_draft:
return _merge_single_segment(
@@ -656,6 +888,8 @@ def stream_script_agent(
target_index,
aspect_ratio,
effective_duration,
fmt,
structure,
)
return candidate
@@ -735,7 +969,14 @@ def stream_script_agent(
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
source = "revise" if mode == "revise" else ("theme" if mode == "theme" else "ai")
# 三个入口(辅助生成 / 上传脚本 / 上传视频提炼)都走 mode=auto,只有 entry_source
# 分得清是哪个来的 —— 脚本卡的「来源」徽标靠它,别一律记成 ai。
if mode == "revise":
source = "revise"
elif mode == "theme":
source = "theme"
else:
source = entry_source if entry_source in {"manual", "video"} else "ai"
script = persist_script_draft(project=project, user=user, task=task, draft=draft, source=source)
settled = True # charge 已提交
except Exception as exc: # noqa: BLE001 — 落库失败:atomic 已回滚 charge,补释放预留
@@ -819,20 +1060,24 @@ def _load_base_draft(project, base_version_id: str) -> dict | None:
def _draft_from_version(version) -> dict:
"""从 ScriptVersion 的 DB 行(segments + metadata)重建 ScriptDraft —— 比解析可能已 stale 的 content 可靠
(用户增删/改镜后 content 不一定同步)。total_duration 按真实镜数算,避免 normalize 按 stale 值截/补镜。"""
(用户增删/改镜后 content 不一定同步)。total_duration 按各镜真实秒数加总,避免 normalize 按 stale 值截/补镜。"""
meta = version.metadata or {}
segs = list(version.segments.order_by("sort_order"))
# 镜可以不等长了,总时长必须按实际相加(旧写法 15×镜数 会在不等长稿上算出错误总时长)
actual_total = sum(s.duration_seconds or SEGMENT_DURATION_MAX for s in segs)
return {
"hook": meta.get("hook", ""),
"tone": meta.get("tone", ""),
"presentation_format": meta.get("presentation_format", ""),
"video_structure": meta.get("video_structure", ""),
"aspect_ratio": meta.get("aspect_ratio", "9:16"),
"total_duration": max(int(meta.get("total_duration") or 0), 15 * len(segs)) or 60,
"total_duration": actual_total or coerce_total_duration(meta.get("total_duration")),
"segment_count": len(segs),
"entities": meta.get("entities", []),
"segments": [
{
"index": s.sort_order,
"duration": s.duration_seconds or 15,
"duration": s.duration_seconds or SEGMENT_DURATION_MAX,
"role": s.role or "",
"narration": s.narration or "",
"speaker": s.speaker or None,
@@ -860,7 +1105,9 @@ def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, se
seg_n = len(base_draft.get("segments") or [])
if not (0 <= target_index < seg_n):
raise ValueError(f"镜号越界:第 {target_index + 1} 镜(共 {seg_n} 镜)")
total_duration = base_draft["total_duration"] # 已按真实镜数算,normalize 不会截掉用户增删后的镜
total_duration = base_draft["total_duration"] # 已按各镜真实秒数加总,normalize 不会截掉用户增删后的镜
# 改一镜要沿用原稿的套路,否则重写出来的那一镜镜头语言会跟其余镜打架
fmt, structure = combo_keys(base_draft.get("presentation_format"), base_draft.get("video_structure"))
messages = build_agent_messages(
project=project,
@@ -870,6 +1117,8 @@ def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, se
base_draft=base_draft,
aspect_ratio=aspect_ratio,
total_duration=total_duration,
presentation_format=fmt,
video_structure=structure,
target_index=target_index,
)
task = create_ai_task(
@@ -897,8 +1146,16 @@ 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:
candidate = normalize_draft(raw_text, aspect_ratio=aspect_ratio, total_duration=total_duration)
return _merge_single_segment(base_draft, candidate, target_index, aspect_ratio, total_duration)
candidate = normalize_draft(
raw_text,
aspect_ratio=aspect_ratio,
total_duration=total_duration,
presentation_format=fmt,
video_structure=structure,
)
return _merge_single_segment(
base_draft, candidate, target_index, aspect_ratio, total_duration, fmt, structure
)
routed = execute_routed_text_request(
task=task,
@@ -0,0 +1,150 @@
"""自由创作 @引用三库(模块4 · 4.1/4.2)单测:平台资产解析 + 审核闸。
运行:DB_ENGINE=sqlite python manage.py test apps.ai.test_free_video_asset_ref --settings=airshelf.settings.test
关键不变量:引用是否放行只看 Asset.source + review_status,**不看**资产在不在库里 ——
按库免审等于把审核架空(用户传图进库再引用出去就绕过了人像审核)。
"""
from unittest.mock import patch
from django.test import TestCase
from apps.accounts.models import Team, User
from apps.ai.free_video import build_content_items
from apps.assets.models import Asset, AssetFile
from apps.assets.review import reference_review_state
def _asset(team, *, source, review_status="", review_remote_id="", category=Asset.Category.UPLOAD):
asset = Asset.objects.create(
team=team,
name="素材",
asset_type=Asset.Type.IMAGE,
source=source,
category=category,
review_status=review_status,
review_remote_id=review_remote_id,
)
AssetFile.objects.create(
asset=asset,
object_key="k/1.png",
bucket="b",
content_type="image/png",
size_bytes=1,
preview_url="http://tos/1.png",
is_primary=True,
)
return asset
class ReferenceReviewStateTests(TestCase):
"""审核判定四态。审核服务开着时才生效,关着一律放行(不能拿没配置的机制拦人)。"""
def setUp(self):
self.user = User.objects.create_user(username="refowner", password="p")
self.team = Team.objects.create(name="REF", owner=self.user)
patch("apps.assets.assets_client.is_enabled", return_value=True).start()
self.addCleanup(patch.stopall)
def test_platform_generated_is_exempt(self):
asset = _asset(self.team, source=Asset.Source.AI_GENERATED)
self.assertEqual(reference_review_state(asset), "allowed")
def test_upload_needs_active(self):
self.assertEqual(reference_review_state(_asset(self.team, source=Asset.Source.UPLOAD)), "unsubmitted")
self.assertEqual(
reference_review_state(_asset(self.team, source=Asset.Source.UPLOAD, review_status="processing")),
"processing",
)
self.assertEqual(
reference_review_state(_asset(self.team, source=Asset.Source.UPLOAD, review_status="failed")),
"failed",
)
self.assertEqual(
reference_review_state(_asset(self.team, source=Asset.Source.UPLOAD, review_status="active")),
"allowed",
)
def test_in_library_alone_does_not_exempt(self):
"""核心:进了资产库 ≠ 审过。只要是上传来源,没审过就不能引用。"""
asset = _asset(self.team, source=Asset.Source.UPLOAD)
asset.in_library = True
asset.save(update_fields=["in_library"])
self.assertEqual(reference_review_state(asset), "unsubmitted")
def test_review_disabled_lets_everything_through(self):
patch("apps.assets.assets_client.is_enabled", return_value=False).start()
self.assertEqual(reference_review_state(_asset(self.team, source=Asset.Source.UPLOAD)), "allowed")
class AssetReferenceBuildTests(TestCase):
"""source=asset 分支:URL 解析 + 闸门拦截 + @label 映射。"""
def setUp(self):
self.user = User.objects.create_user(username="refbuild", password="p")
self.team = Team.objects.create(name="REFB", owner=self.user)
patch("apps.assets.assets_client.is_enabled", return_value=True).start()
self.addCleanup(patch.stopall)
def _build(self, asset, label="模特A"):
return build_content_items(
team=self.team,
prompt=f"@{label} 走过来",
mode="universal",
references=[{"type": "image", "label": label, "asset_id": str(asset.id), "source": "asset"}],
)
def test_platform_asset_resolves_to_preview_url(self):
asset = _asset(self.team, source=Asset.Source.AI_GENERATED)
built = self._build(asset)
self.assertEqual(built["image_n"], 1)
self.assertEqual(built["content_items"][0]["image_url"]["url"], "http://tos/1.png")
self.assertEqual(built["api_prompt"], "图片1 走过来")
def test_registered_asset_uses_volcano_asset_scheme(self):
"""已登记火山素材库的走 asset://,写实人脸传直链会被拒。"""
asset = _asset(
self.team,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.PERSON,
review_status="active",
review_remote_id="asset-abc",
)
built = self._build(asset)
self.assertEqual(built["content_items"][0]["image_url"]["url"], "asset://asset-abc")
def test_unreviewed_upload_is_blocked_and_submitted(self):
asset = _asset(self.team, source=Asset.Source.UPLOAD)
with patch("apps.assets.review.submit_asset_for_review", return_value=True) as submit:
with self.assertRaises(ValueError) as ctx:
self._build(asset)
self.assertIn("已提交审核", str(ctx.exception))
submit.assert_called_once()
self.assertTrue(submit.call_args.kwargs["force"]) # 上传素材不看类目白名单,一律登记
def test_failed_review_is_blocked(self):
asset = _asset(self.team, source=Asset.Source.UPLOAD, review_status="failed")
with self.assertRaises(ValueError) as ctx:
self._build(asset)
self.assertIn("未通过审核", str(ctx.exception))
def test_other_team_asset_is_not_visible(self):
stranger = User.objects.create_user(username="stranger", password="p")
other = Team.objects.create(name="OTHER", owner=stranger)
asset = _asset(other, source=Asset.Source.AI_GENERATED)
with self.assertRaises(ValueError) as ctx:
self._build(asset)
self.assertIn("不存在", str(ctx.exception))
def test_keyframe_rejects_non_image(self):
asset = _asset(self.team, source=Asset.Source.AI_GENERATED)
asset.asset_type = Asset.Type.VIDEO
asset.save(update_fields=["asset_type"])
with self.assertRaises(ValueError) as ctx:
build_content_items(
team=self.team,
prompt="动起来",
mode="keyframe",
references=[{"type": "video", "asset_id": str(asset.id), "source": "asset", "role": "first_frame"}],
)
self.assertIn("首尾帧模式仅支持图片素材", str(ctx.exception))
@@ -0,0 +1,162 @@
"""二期第2段:时长自由化 + 表现形式 × 视频结构 的纯函数单测(不碰 DB / 不调模型)。
覆盖三件容易悄悄坏掉的事:
1. 切镜结果必须「每镜 4–15 秒」且「加总精确等于总时长」—— 越界下游出片会直接拒。
2. 模型给的每镜时长可能是错的(加总对不上/单镜越界),后端必须能修回来而不是原样落库。
3. 套路必须按组合**选择性**加载,全量灌进去会让系统提示词翻倍且互相打架。
"""
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,
_fit_segment_durations,
allowed_structures,
coerce_combo,
coerce_total_duration,
combo_keys,
load_ecommerce_skill,
narration_limit,
plan_roles,
plan_segment_durations,
)
ALL_TOTALS = list(range(TOTAL_DURATION_MIN, TOTAL_DURATION_MAX + 1, 5))
class TotalDurationCoercionTests(SimpleTestCase):
def test_empty_and_invalid_fall_back_to_default(self):
for raw in (None, "", 0, -5, "abc", object()):
self.assertEqual(coerce_total_duration(raw), DEFAULT_TOTAL_DURATION, raw)
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(45), 45)
class SegmentPlanningTests(SimpleTestCase):
def test_every_total_and_format_yields_legal_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)
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_roles_follow_shot_count(self):
self.assertEqual(plan_roles(1), ["钩子"])
self.assertEqual(plan_roles(2), ["钩子", "卖点"])
self.assertEqual(plan_roles(3), ["钩子", "卖点", "CTA"])
self.assertEqual(plan_roles(4), ["钩子", "痛点", "卖点", "CTA"])
self.assertEqual(plan_roles(6), ["钩子", "痛点", "卖点", "卖点", "卖点", "CTA"])
def test_roles_always_open_with_hook_and_close_with_cta(self):
for count in range(3, 13):
plan = plan_roles(count)
self.assertEqual(len(plan), count)
self.assertEqual(plan[0], "钩子")
self.assertEqual(plan[-1], "CTA")
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_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_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))
def test_empty_falls_back_to_planner(self):
self.assertEqual(_fit_segment_durations([], 20, "vlog"), plan_segment_durations(20, "vlog"))
def test_garbage_values_never_escape_the_legal_range(self):
for raw in ([None, None], ["a", "b"], [0, 0, 0], [3, 3], [40, 40, 40]):
for total in (10, 30, 60):
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)
class NarrationLimitTests(SimpleTestCase):
def test_limit_scales_with_shot_length(self):
self.assertEqual(narration_limit(4), 14)
self.assertEqual(narration_limit(8), 28)
self.assertEqual(narration_limit(15), 52)
def test_never_exceeds_hard_cap(self):
self.assertLessEqual(narration_limit(60), 55)
class ComboTests(SimpleTestCase):
def test_drama_cannot_pick_review(self):
# 演出来的实测没有可信度 —— 这是唯一的禁用组合
self.assertNotIn("review", allowed_structures("drama"))
self.assertEqual(len(allowed_structures("drama")), 3)
def test_other_formats_allow_everything(self):
for fmt in ("oral", "vlog"):
self.assertEqual(len(allowed_structures(fmt)), len(VIDEO_STRUCTURES))
def test_forbidden_combo_falls_back_instead_of_raising(self):
fmt, structure = coerce_combo("drama", "review")
self.assertEqual(fmt, "drama")
self.assertNotEqual(structure, "review")
def test_unknown_values_fall_back_to_defaults(self):
self.assertEqual(coerce_combo("nope", "nope"), ("oral", "pain"))
def test_chinese_labels_round_trip_back_to_keys(self):
# 落库存的是中文标签,改稿时要能还原成 key 去挑套路文件
self.assertEqual(combo_keys("短剧", "痛点解决"), ("drama", "pain"))
self.assertEqual(combo_keys("Vlog", "场景种草"), ("vlog", "scene"))
self.assertEqual(combo_keys("口播", "测评验证"), ("oral", "review"))
class SkillLoadingTests(SimpleTestCase):
def test_only_the_selected_playbooks_are_loaded(self):
for fmt in PRESENTATION_FORMATS:
for structure in allowed_structures(fmt):
with self.subTest(fmt=fmt, structure=structure):
text = load_ecommerce_skill(fmt, structure)
self.assertIn(f"format-{fmt}.md", text)
self.assertIn(f"structure-{structure}.md", text)
for other in PRESENTATION_FORMATS:
if other != fmt:
self.assertNotIn(f"format-{other}.md", text)
for other in VIDEO_STRUCTURES:
if other != structure:
self.assertNotIn(f"structure-{other}.md", text)
def test_combo_matrix_is_always_loaded(self):
self.assertIn("combo-matrix.md", load_ecommerce_skill("oral", "pain"))
def test_skill_text_is_substantial(self):
# skills 没随镜像打进去时会退化成一句兜底,这里守住「提示词没丢」
self.assertGreater(len(load_ecommerce_skill("oral", "pain")), 5000)
@@ -0,0 +1,118 @@
"""脚本「来源」要记准是哪个入口生成的。
三个入口(脚本辅助生成 / 上传脚本 / 上传视频提炼)都走 mode=auto,后端原先一律记成 ai,
脚本卡上的「来源」徽标因此永远显示「脚本辅助生成」。加了 entry_source 之后锁住这个行为。
"""
from __future__ import annotations
import json
from decimal import Decimal
from unittest.mock import Mock, patch
from django.test import TransactionTestCase
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import ModelConfig, ModelProvider
from apps.ai.script_agent import stream_script_agent
from apps.billing.models import CreditAccount
from apps.products.models import Product
from apps.projects.models import Project, ScriptVersion
class ScriptEntrySourceTests(TransactionTestCase):
reset_sequences = True
def setUp(self):
ModelConfig.objects.filter(capability=ModelConfig.Capability.TEXT).update(
status=ModelConfig.Status.DISABLED
)
self.user = User.objects.create_user(username="entry-source", password="x")
self.team = Team.objects.create(name="Entry Source", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
CreditAccount.objects.create(team=self.team, balance=Decimal("1000"))
product = Product.objects.create(team=self.team, created_by=self.user, title="测试商品")
self.project = Project.objects.create(
team=self.team, created_by=self.user, product=product, name="入口来源项目"
)
provider = ModelProvider.objects.create(
name="entry-source-provider",
display_name="entry-source-provider",
status=ModelProvider.Status.ACTIVE,
metadata={"routing": {"fallback_priority": 20}},
)
self.model_config = ModelConfig.objects.create(
provider=provider,
name="entry-source-model",
display_name="entry-source-model",
capability=ModelConfig.Capability.TEXT,
endpoint="chat/completions",
unit_price=Decimal("10"),
status=ModelConfig.Status.ACTIVE,
metadata={
"routing": {"fallback_on_failure": True, "fallback_candidate": True},
"capabilities": {"operations": ["chat"], "features": ["streaming", "structured_output"]},
"pricing": {"base_cost_yuan": "0.50"},
},
)
patch("apps.ai.services.get_text_provider", side_effect=self._provider).start()
self.addCleanup(patch.stopall)
def _provider(self, _model):
raw = json.dumps(
{
"hook": "开场钩子",
"tone": "自然",
"aspect_ratio": "9:16",
"total_duration": 15,
"segment_count": 1,
"entities": [
{"id": "c1", "type": "character", "name": "女主", "visual_prompt": "都市女主", "ref_index": 1}
],
"segments": [
{
"index": 0, "duration": 15, "role": "钩子",
"narration": "全新脚本口播", "visual": "女主展示商品",
"speaker": "女主", "product_exposure": "展示",
"entity_refs": ["c1"], "dialogue": [],
}
],
},
ensure_ascii=False,
)
provider = Mock()
provider.chat_completion_stream.return_value = iter(
[{"type": "delta", "text": raw}, {"type": "done"}]
)
return provider
def _run(self, **kwargs) -> str:
frames = list(
stream_script_agent(
project=self.project,
user=self.user,
model_config=self.model_config,
mode="auto",
user_prompt="生成一版脚本",
aspect_ratio="9:16",
total_duration=15,
**kwargs,
)
)
events = [json.loads(frame.removeprefix("data: ").strip()) for frame in frames]
errors = [e for e in events if e.get("type") == "error"]
self.assertFalse(errors, f"生成失败:{events}")
return ScriptVersion.objects.filter(project=self.project).latest("created_at").source
def test_video_entry_is_recorded_as_video(self):
self.assertEqual(self._run(entry_source="video"), "video")
def test_upload_script_entry_is_recorded_as_manual(self):
self.assertEqual(self._run(entry_source="manual"), "manual")
def test_assisted_entry_falls_back_to_ai(self):
self.assertEqual(self._run(entry_source=""), "ai")
def test_unknown_entry_source_is_not_trusted(self):
"""来源直接进 DB 并渲染成徽标,不认的值一律回落 ai,别把请求体原样落库。"""
self.assertEqual(self._run(entry_source="../../etc/passwd"), "ai")
+142
View File
@@ -0,0 +1,142 @@
"""1.11 上传视频提炼:抽帧规划、上传校验、多模态消息组装、产出判空。
不碰真模型:模型侧已在真视频上端到端验证过,这里只锁纯函数与边界。
"""
from __future__ import annotations
import subprocess
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import SimpleTestCase
from apps.ai.video_digest import (
MAX_DURATION_SECONDS,
MAX_FRAMES,
MAX_UPLOAD_BYTES,
MIN_FRAMES,
SECONDS_PER_FRAME,
VideoDigestError,
VideoFrame,
build_digest_messages,
frames_from_upload,
load_digest_skill,
plan_frame_times,
validate_digest_text,
)
class FramePlanTests(SimpleTestCase):
def test_frame_count_scales_with_duration_within_bounds(self):
for duration, expected in [(3, MIN_FRAMES), (10, MIN_FRAMES), (30, 6), (60, 12), (180, MAX_FRAMES)]:
self.assertEqual(len(plan_frame_times(duration)), expected, duration)
def test_short_clip_still_gets_min_frames(self):
"""5 秒的片子按每 5 秒一帧只有 1 帧,判不出分镜,必须兜到 MIN_FRAMES。"""
self.assertEqual(len(plan_frame_times(SECONDS_PER_FRAME)), MIN_FRAMES)
def test_times_are_inside_the_clip_and_ordered(self):
times = plan_frame_times(22.2)
self.assertEqual(times, sorted(times))
self.assertGreaterEqual(times[0], 0)
self.assertLess(times[-1], 22.2)
def test_samples_midpoints_not_edges(self):
"""首帧常是黑场、尾帧常是片尾卡片,取每段中点避开。"""
self.assertGreater(plan_frame_times(60)[0], 0)
class UploadGuardTests(SimpleTestCase):
def _upload(self, name: str, size: int = 1024):
return SimpleUploadedFile(name, b"x" * size, content_type="video/mp4")
def test_rejects_non_video_suffix(self):
with self.assertRaises(VideoDigestError) as ctx:
frames_from_upload(self._upload("script.docx"))
self.assertIn("mp4", str(ctx.exception))
def test_rejects_oversized_file_before_touching_ffmpeg(self):
big = self._upload("big.mp4", size=8)
big.size = MAX_UPLOAD_BYTES + 1
with self.assertRaises(VideoDigestError) as ctx:
frames_from_upload(big)
self.assertIn("MB", str(ctx.exception))
def test_rejects_unreadable_file(self):
with self.assertRaises(VideoDigestError):
frames_from_upload(self._upload("broken.mp4"))
def test_rejects_clip_longer_than_cap(self):
"""超长片抽样密度不够,拆出来是错的,宁可让用户先剪。"""
with self.assertRaises(VideoDigestError) as ctx:
_digest_with_duration(_synth_clip(seconds=1), MAX_DURATION_SECONDS + 1)
self.assertIn("分钟", str(ctx.exception))
def test_accepts_clip_within_cap_and_returns_frames(self):
frames, duration = frames_from_upload(_synth_clip(seconds=6))
self.assertGreaterEqual(len(frames), MIN_FRAMES)
self.assertTrue(all(f.jpeg.startswith(b"\xff\xd8") for f in frames))
self.assertAlmostEqual(duration, 6, delta=1)
class MessageBuildTests(SimpleTestCase):
frames = [VideoFrame(at_seconds=2, jpeg=b"\xff\xd8fake"), VideoFrame(at_seconds=7, jpeg=b"\xff\xd8fake2")]
def test_system_prompt_is_the_digest_skill(self):
messages = build_digest_messages(self.frames, 10)
self.assertEqual(messages[0]["role"], "system")
self.assertIn("七要素", messages[0]["content"])
def test_every_frame_is_inlined_with_its_timestamp(self):
content = build_digest_messages(self.frames, 10)[1]["content"]
images = [c for c in content if c["type"] == "image_url"]
self.assertEqual(len(images), 2)
for image in images:
self.assertTrue(image["image_url"]["url"].startswith("data:image/jpeg;base64,"))
stamps = [c["text"] for c in content if c["type"] == "text"]
self.assertIn("[第 2 秒]", stamps)
self.assertIn("[第 7 秒]", stamps)
def test_product_hint_reaches_the_model(self):
"""带上商品,模型才会在存疑里提示「这条结构换到你的商品要改哪镜」。"""
content = build_digest_messages(self.frames, 10, product_hint="蓝牙耳机 · 数码3C")[1]["content"]
self.assertIn("蓝牙耳机 · 数码3C", content[0]["text"])
def test_skill_loads_from_disk(self):
self.assertIn("画面七要素", load_digest_skill())
class DigestValidationTests(SimpleTestCase):
def test_rejects_empty_or_apologetic_output(self):
for bad in ["", " ", "抱歉,我无法处理这个请求。"]:
with self.assertRaises(ValueError):
validate_digest_text(bad)
def test_rejects_prose_without_shot_blocks(self):
with self.assertRaises(ValueError):
validate_digest_text("这是一条很好的带货视频,节奏明快,画面精美。" * 5)
def test_accepts_well_formed_digest(self):
good = "【整体结构】\n形式:口播\n" + "【第 1 镜】0-4 秒 · 钩子\n主体:一位女生\n" * 3
self.assertEqual(validate_digest_text(f" {good} "), good.strip())
# --------------------------------------------------------------------------- #
# helpers
# --------------------------------------------------------------------------- #
def _synth_clip(seconds: int) -> SimpleUploadedFile:
"""用 ffmpeg 造一段纯色小视频当夹具,不依赖外部素材。"""
done = subprocess.run(
["ffmpeg", "-v", "error", "-f", "lavfi", "-i", f"color=c=orange:s=320x180:d={seconds}",
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov", "-"],
capture_output=True, check=True,
)
return SimpleUploadedFile("clip.mp4", done.stdout, content_type="video/mp4")
def _digest_with_duration(clip, duration: float):
"""把探到的时长顶成 duration,验时长闸门,不必真造一条 3 分钟的片。"""
from unittest.mock import patch
with patch("apps.ai.video_digest.probe_duration", return_value=duration):
return frames_from_upload(clip)
+295
View File
@@ -0,0 +1,295 @@
"""上传视频提炼 —— 参考视频 → 可人工逐镜编辑的中文分镜稿。
链路:ffmpeg 抽帧(均匀采样) → 帧内联进多模态 messages → 走现有文本模型路由 → 纯文本分镜稿。
两个「本来以为要新建、其实已经有」的前提:
1. **读图能力**:默认脚本模型(YunQi gemini-3.1-pro)本身是多模态的,OpenAI 兼容的
``content: [{type:"text"},{type:"image_url"}]`` 直接透传即可,不需要新 provider、新模型、新 key。
2. **ffmpeg**:第 5 阶段导出早就依赖它,已装进后端镜像(见 Dockerfile)。
**没有音轨**:帧里读不到口播,只能读画面上的字幕。这是刻意取舍——接语音转写要另开火山 ASR 服务、
另加一套凭证与计价,而带货参考片绝大多数带硬字幕,且口播词下游本来就要按用户自己的商品重写。
skill 里已要求模型「无字幕就如实写缺失,不许编口播词」。
"""
from __future__ import annotations
import base64
import json
import math
import shutil
import subprocess
import tempfile
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from django.conf import settings
# 上传限制:超了直接 400,不进 ffmpeg,也不花模型钱
ALLOWED_SUFFIXES = (".mp4", ".mov", ".m4v", ".webm")
MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200 MB
MAX_DURATION_SECONDS = 180 # 3 分钟。带货参考片远短于此;更长的帧采样密度不够,拆出来也是错的
# 抽帧:每 5 秒一帧,夹在 4~12 帧之间。12 帧 × 512px JPEG ≈ 0.5 MB base64,单次请求扛得住
SECONDS_PER_FRAME = 5
MIN_FRAMES = 4
MAX_FRAMES = 12
FRAME_WIDTH = 512 # 帧宽上限;分镜拆解看的是构图与景别,不需要原分辨率
FRAME_QUALITY = 5 # ffmpeg -q:v,2(最好)~31(最差)
_FFMPEG_TIMEOUT = 60
class VideoDigestError(ValueError):
"""用户可见的失败(文件不合格 / ffmpeg 读不动),一律 400。"""
@dataclass(frozen=True)
class VideoFrame:
at_seconds: int
jpeg: bytes
def as_data_url(self) -> str:
return "data:image/jpeg;base64," + base64.b64encode(self.jpeg).decode("ascii")
# --------------------------------------------------------------------------- #
# skill 加载
# --------------------------------------------------------------------------- #
def _skill_dir() -> Path:
"""与 script_agent._skill_dir 同源:优先 BASE_DIR/skills(镜像内),回落仓库根(本地旧布局)。"""
base = Path(settings.BASE_DIR)
for cand in (base / "skills", base.parent.parent / "skills"):
if (cand / "video-shot-digest").is_dir():
return cand / "video-shot-digest"
return base / "skills" / "video-shot-digest"
@lru_cache(maxsize=1)
def load_digest_skill() -> str:
main = _skill_dir() / "SKILL.md"
if main.exists():
return main.read_text(encoding="utf-8")
# 兜底:skill 丢了也别整条链路挂掉,退化成一句话提示词(产出会明显变差,交接文档已注明须带 skills 目录)
return (
"你是分镜拆解 agent。输入是一条电商短视频按时间均匀抽出的截帧。"
"逐镜还原分镜,每镜写清主体/动作/场景/景别/运镜/光线氛围/商品露出七要素,"
"台词只抄画面上的字幕,看不见的不要编。输出中文纯文本。"
)
# --------------------------------------------------------------------------- #
# ffmpeg:探时长 + 抽帧
# --------------------------------------------------------------------------- #
def _binary(name: str) -> str:
found = shutil.which(name)
if not found:
raise VideoDigestError("服务器暂时无法解析视频,请稍后再试")
return found
def probe_duration(path: str | Path) -> float:
"""ffprobe 读时长(秒)。读不到 = 不是能解的视频。"""
try:
out = subprocess.run(
[
_binary("ffprobe"), "-v", "error",
"-print_format", "json", "-show_format",
str(path),
],
capture_output=True, timeout=_FFMPEG_TIMEOUT, check=True,
).stdout
duration = float(json.loads(out)["format"]["duration"])
except VideoDigestError:
raise
except Exception as exc: # noqa: BLE001 — ffprobe 各种失败对用户是同一件事
raise VideoDigestError("这个视频读不出来,请换一个 mp4 / mov 文件") from exc
if duration <= 0:
raise VideoDigestError("这个视频读不出来,请换一个 mp4 / mov 文件")
return duration
def plan_frame_times(duration: float) -> list[int]:
"""均匀采样时间点。取每段的**中点**,避开首尾黑场与片尾卡片。"""
count = max(MIN_FRAMES, min(MAX_FRAMES, math.ceil(duration / SECONDS_PER_FRAME)))
step = duration / count
return [int(step * (i + 0.5)) for i in range(count)]
def extract_frames(path: str | Path, times: list[int]) -> list[VideoFrame]:
"""逐时间点抽一帧。``-ss`` 放在 ``-i`` 前走关键帧快速定位,每帧约几十毫秒。"""
ffmpeg = _binary("ffmpeg")
frames: list[VideoFrame] = []
for at in times:
try:
done = subprocess.run(
[
ffmpeg, "-v", "error", "-ss", str(at), "-i", str(path),
"-frames:v", "1", "-vf", f"scale={FRAME_WIDTH}:-2",
"-q:v", str(FRAME_QUALITY), "-f", "image2", "-",
],
capture_output=True, timeout=_FFMPEG_TIMEOUT, check=True,
)
except Exception: # noqa: BLE001 — 单帧抽失败(定位越界等)跳过,别拖垮整次提炼
continue
if done.stdout:
frames.append(VideoFrame(at_seconds=at, jpeg=done.stdout))
if not frames:
raise VideoDigestError("没能从这个视频里取到画面,请换一个文件")
return frames
def frames_from_upload(upload) -> tuple[list[VideoFrame], float]:
"""校验上传文件 → 落临时盘 → 探时长 → 抽帧。临时文件退出即删。"""
name = (getattr(upload, "name", "") or "").lower()
if not name.endswith(ALLOWED_SUFFIXES):
raise VideoDigestError("只支持 mp4 / mov / m4v / webm 四种视频格式")
size = getattr(upload, "size", 0) or 0
if size > MAX_UPLOAD_BYTES:
raise VideoDigestError(f"视频不能超过 {MAX_UPLOAD_BYTES // 1024 // 1024} MB,请压缩后再传")
suffix = Path(name).suffix or ".mp4"
with tempfile.NamedTemporaryFile(suffix=suffix) as tmp:
for chunk in upload.chunks():
tmp.write(chunk)
tmp.flush()
duration = probe_duration(tmp.name)
if duration > MAX_DURATION_SECONDS:
raise VideoDigestError(
f"视频不能超过 {MAX_DURATION_SECONDS // 60} 分钟,请剪出要参考的那一段再传"
)
return extract_frames(tmp.name, plan_frame_times(duration)), duration
# --------------------------------------------------------------------------- #
# 组装多模态消息
# --------------------------------------------------------------------------- #
def build_digest_messages(
frames: list[VideoFrame],
duration: float,
*,
product_hint: str = "",
) -> list[dict]:
"""system = 拆解 skill;user = 时间戳 + 帧图交替,让模型知道每张图在原片的第几秒。"""
head = [
f"这是一条时长约 {round(duration)} 秒的电商带货短视频,",
f"按时间顺序均匀抽了 {len(frames)} 帧。每帧图前面标了它在原片中的时间点。",
]
if product_hint:
head.append(f"用户接下来想用这条片子的结构去拍自己的商品:{product_hint}")
head.append("请按技能里的输出格式还原它的分镜。")
content: list[dict] = [{"type": "text", "text": "".join(head)}]
for frame in frames:
content.append({"type": "text", "text": f"[第 {frame.at_seconds} 秒]"})
content.append({"type": "image_url", "image_url": {"url": frame.as_data_url()}})
return [
{"role": "system", "content": load_digest_skill()},
{"role": "user", "content": content},
]
def validate_digest_text(text: str) -> str:
"""模型偶尔吐空 / 吐一句道歉。判空后交给路由层重试或切模型,别把废稿塞给用户。"""
cleaned = (text or "").strip()
if len(cleaned) < 80 or "" not in cleaned:
raise ValueError("视频拆解结果不完整")
return cleaned
# --------------------------------------------------------------------------- #
# 入口:一次真实的计费调用
# --------------------------------------------------------------------------- #
def digest_project_video(*, project, user, upload) -> dict:
"""上传视频 → 分镜稿。抽帧在建任务之前做,文件不合格不占积分。"""
from django.db import transaction
from django.utils import timezone
from apps.ai.models import AITask, ModelConfig
from apps.ai.services import create_ai_task, execute_routed_text_request, get_default_model
from apps.billing.services.ledger import charge_reserved_credit
frames, duration = frames_from_upload(upload)
model_config = get_default_model(ModelConfig.Capability.TEXT)
if model_config is None:
raise VideoDigestError("暂时没有可用的模型,请联系管理员")
product = getattr(project, "product", None)
messages = build_digest_messages(
frames,
duration,
product_hint=" · ".join(
filter(None, [getattr(product, "title", ""), getattr(product, "category", "")])
),
)
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.VIDEO_DIGEST,
model_config=model_config,
# 帧是几百 KB base64,绝不进 request_payload(会把 AITask 表撑爆),只记形状
request_payload={
"model": model_config.name,
"endpoint": model_config.endpoint,
"duration_seconds": round(duration, 2),
"frame_count": len(frames),
"frame_times": [f.at_seconds for f in frames],
},
)
reservation = task.credit_reservation
try:
task.status = AITask.Status.SUBMITTED
task.submitted_at = timezone.now()
task.save(update_fields=["status", "submitted_at", "updated_at"])
routed = execute_routed_text_request(
task=task,
primary_model=model_config,
messages=messages,
streaming=False,
structured_output=False,
business_operation="video_digest",
temperature=0.4,
validate_text=validate_digest_text,
request_summary={"duration_seconds": round(duration, 2), "frame_count": len(frames)},
)
_text, _response, digest = routed.value
except Exception as exc: # noqa: BLE001
_fail_digest_task(task, reservation, str(exc))
raise
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = {"digest": digest[:8000]}
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
return {
"text": digest,
"chars": len(digest),
"frames": len(frames),
"duration": round(duration, 1),
"task_id": str(task.id),
}
def _fail_digest_task(task, reservation, message: str) -> None:
from django.utils import timezone
from apps.ai.models import AITask
from apps.billing.services.ledger import release_credit
try:
task.status = AITask.Status.FAILED
task.error_message = message[:2000]
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
finally:
try:
release_credit(reservation=reservation, reason=message[:200])
except Exception: # noqa: BLE001
pass
+34 -3
View File
@@ -59,11 +59,16 @@ def get_or_create_team_group(team) -> AssetReviewGroup:
return grp
def submit_asset_for_review(asset: Asset) -> bool:
def submit_asset_for_review(asset: Asset, *, force: bool = False) -> bool:
"""真人资产送审:建组(若无)→ 传素材 → 标 processing。出错只记日志,不抛。
返回是否真正进入审核(True=已标 processing;False=未送审/未配置/失败),
供手动兜底端点据此如实回报,避免前端把「没送出去」误显示成「审核中」。"""
if not assets_client.is_enabled() or asset.category not in Asset.REVIEW_CATEGORIES:
供手动兜底端点据此如实回报,避免前端把「没送出去」误显示成「审核中」。
force=True 跳过 REVIEW_CATEGORIES 白名单:用户上传的资产被拿去当生成参考时,
我们无从判断里面有没有真人脸,一律登记一次(与人物素材库上传同策略)。"""
if not assets_client.is_enabled():
return False
if not force and asset.category not in Asset.REVIEW_CATEGORIES:
return False
url = _asset_url(asset)
if not url:
@@ -85,6 +90,32 @@ def submit_asset_for_review(asset: Asset) -> bool:
return False
# 平台自己生成的资产,提示词与生成链路都在我们手里,视为免审;用户上传的必须真过一遍审核。
# 判据是 Asset.source 而不是「在不在某个库里」—— 按库免审等于把审核架空:
# 用户上传一张图进资产库,再从自由创作引用出去,就绕过了整套人像审核。
SELF_TRUSTED_SOURCES = (Asset.Source.AI_GENERATED, Asset.Source.SYSTEM)
def reference_review_state(asset: Asset) -> str:
"""引用一个平台资产(自由创作 @引用三库)前的审核判定。
返回 allowed / processing / failed / unsubmitted 四态之一,由调用方决定放行还是给提示。
未送审(unsubmitted)不代表拒绝到底 —— 调用方应顺手送一次审,让用户等一会儿再来。
"""
if not assets_client.is_enabled():
# 审核整套机制没配置时不能拿它拦人,否则一关审核全平台引用都用不了
return "allowed"
if asset.source in SELF_TRUSTED_SOURCES:
return "allowed"
if asset.review_status == "active":
return "allowed"
if asset.review_status == "processing":
return "processing"
if asset.review_status == "failed":
return "failed"
return "unsubmitted"
def poll_asset_review(asset: Asset) -> str:
"""查单个真人资产审核状态并更新 review_status。返回最新状态。
只在状态变化时落库(保留 updated_at 作为「进入 processing 的时刻」);processing 超时兜底为 failed。"""
+1 -1
View File
@@ -853,7 +853,7 @@ class SubmitReviewTests(TestCase):
def test_submit_review_200_when_started(self):
"""真正进入审核(processing)→ 200 + 真态,供前端落 processing。"""
def _mark_processing(asset):
def _mark_processing(asset, *, force=False):
asset.review_status = "processing"
asset.save(update_fields=["review_status"])
return True
+8 -3
View File
@@ -265,13 +265,18 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
@action(detail=True, methods=["post"], url_path="submit-review")
def submit_review(self, request, pk=None):
"""手动兜底:把单个送审类素材(角色/三视图/分镜)送火山审核 —— 用户点灰盾「待审核」时触发。
正常走 on_commit 自动送审;此口给万一漏提交时补提交。非送审类拒绝。"""
正常走 on_commit 自动送审;此口给万一漏提交时补提交。非送审类拒绝。
for_reference=true:素材要被拿去当生成参考(自由创作 @引用三库)。此时不再看类目白名单——
白名单只是「别为无关素材白跑一趟审核」的优化,而参考素材里有没有真人脸我们无从判断,
一律登记一次。放宽的是送审范围,不是放行范围,不会削弱审核。"""
from apps.assets.review import submit_asset_for_review
asset = self.get_object() # team-scoped + is_deleted 过滤
if asset.category not in Asset.REVIEW_CATEGORIES:
for_reference = bool(request.data.get("for_reference"))
if not for_reference and asset.category not in Asset.REVIEW_CATEGORIES:
return Response({"detail": "该素材无需审核"}, status=status.HTTP_400_BAD_REQUEST)
started = submit_asset_for_review(asset)
started = submit_asset_for_review(asset, force=for_reference)
asset.refresh_from_db()
# 没真正送出去(审核服务未启用/未配置 SDK/火山未回 Id/调用异常),且素材也不是已终态:
# 如实回报 503,别返回空 review_status —— 否则前端会把「没送出去」误显示成「审核中」,刷新就打回原形。
@@ -0,0 +1,77 @@
"""上传脚本取正文 —— 只认 docx / txt。
docx 用标准库拆(zip + xml),不引 python-docx:镜像少一个依赖,也避免它对损坏文件抛一堆内部异常。
段落 = <w:p>,文字 = 其中所有 <w:t> 拼接;<w:br>/<w:tab> 补空白,否则整段会粘成一坨。
"""
import re
import zipfile
from io import BytesIO
from xml.etree import ElementTree
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
MAX_UPLOAD_BYTES = 5 * 1024 * 1024 # 脚本是纯文字,5MB 足够;更大的基本是误传
class ScriptFileError(ValueError):
"""取正文失败,message 直接给用户看(中文)。"""
def _docx_text(raw: bytes) -> str:
try:
with zipfile.ZipFile(BytesIO(raw)) as bundle:
document = bundle.read("word/document.xml")
except KeyError:
raise ScriptFileError("这个 docx 里没有正文,请确认文件没有损坏")
except zipfile.BadZipFile:
raise ScriptFileError("这个文件不是有效的 docx,请另存为 .docx 后重试")
try:
root = ElementTree.fromstring(document)
except ElementTree.ParseError:
raise ScriptFileError("docx 正文解析失败,请另存一份后重试")
paragraphs = []
for node in root.iter(f"{{{W_NS}}}p"):
pieces = []
for child in node.iter():
tag = child.tag
if tag == f"{{{W_NS}}}t":
pieces.append(child.text or "")
elif tag in (f"{{{W_NS}}}br", f"{{{W_NS}}}cr"):
pieces.append("\n")
elif tag == f"{{{W_NS}}}tab":
pieces.append("\t")
line = "".join(pieces).strip()
if line:
paragraphs.append(line)
return "\n".join(paragraphs)
def _txt_text(raw: bytes) -> str:
# 中文 txt 常见三种落盘编码;utf-8 失败再退 GBK 家族,最后一步忽略坏字节保底出文本。
for encoding in ("utf-8-sig", "utf-8", "gb18030"):
try:
return raw.decode(encoding)
except UnicodeDecodeError:
continue
return raw.decode("utf-8", errors="ignore")
def extract_script_text(upload) -> tuple[str, str]:
"""(文件名, 正文)。只收 docx / txt,其余一律拒绝。"""
name = getattr(upload, "name", "") or "script"
lowered = name.lower()
if not lowered.endswith((".docx", ".txt")):
raise ScriptFileError("只支持 docx 和 txt 两种脚本文件")
raw = upload.read()
if len(raw) > MAX_UPLOAD_BYTES:
raise ScriptFileError("脚本文件不能超过 5MB")
text = _docx_text(raw) if lowered.endswith(".docx") else _txt_text(raw)
# 统一换行 + 压掉连续空行,避免原稿的排版空白撑爆后面的提示词
text = re.sub(r"\n{3,}", "\n\n", text.replace("\r\n", "\n").replace("\r", "\n")).strip()
if not text:
raise ScriptFileError("没能从这个文件里读到文字,请换一份")
return name, text
+99 -13
View File
@@ -15,7 +15,13 @@ from rest_framework.viewsets import ModelViewSet
from apps.ai.models import AITask, ModelConfig
from apps.ai.providers import TtsNotConfigured
from apps.ai.script_agent import stream_script_agent
from apps.ai.script_agent import (
SEGMENT_DURATION_MAX,
SEGMENT_DURATION_MIN,
coerce_combo,
coerce_total_duration,
stream_script_agent,
)
from apps.ai.services import (
DEFAULT_VOICEOVER_VOICE,
VOICEOVER_VOICES,
@@ -39,6 +45,7 @@ from apps.assets.storage import TosStorage
from apps.common.api import TeamScopedViewSetMixin
from apps.common.celery_health import require_worker
from apps.ai.generation_errors import classify_generation_error, public_error_for_task
from apps.ai.video_digest import VideoDigestError, digest_project_video
from .models import (
BaseAssetGroup,
@@ -69,6 +76,7 @@ from .serializers import (
)
from .services.export import run_export_job_in_thread
from .services.pipeline import STAGE_ORDER
from .services.script_import import ScriptFileError, extract_script_text
from .tasks import poll_video_segment_task
logger = logging.getLogger(__name__)
@@ -417,14 +425,18 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
project = serializer.save(team=self.get_team(), created_by=self.request.user)
for stage in STAGE_ORDER:
ProjectStage.objects.create(project=project, stage=stage)
# 先铺 4 段占位;真实段数与每段时长在采用脚本时由 _sync_video_segments_to_script 收口
for index in range(4):
VideoSegment.objects.create(project=project, sort_order=index, target_duration_seconds=15)
VideoSegment.objects.create(
project=project, sort_order=index, target_duration_seconds=SEGMENT_DURATION_MAX
)
@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
base_version_id(改稿)、aspect_ratio、total_duration(5-60,5 秒步进)、
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")
@@ -432,10 +444,12 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
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")
try:
total_duration = int(request.data.get("total_duration") or 60)
except (TypeError, ValueError):
total_duration = 60
# 非法值一律由 agent 侧 coerce 兜底(夹区间/回落默认),这里不做 400,避免生成被参数噪声打断
total_duration = coerce_total_duration(request.data.get("total_duration"))
presentation_format, video_structure = coerce_combo(
request.data.get("presentation_format"),
request.data.get("video_structure"),
)
target_index = request.data.get("target_index")
try:
target_index = int(target_index) if target_index is not None else None
@@ -466,7 +480,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
base_version_id=base_version_id,
aspect_ratio=aspect_ratio,
total_duration=total_duration,
presentation_format=presentation_format,
video_structure=video_structure,
target_index=target_index,
entry_source=str(request.data.get("source") or ""),
)
response = StreamingHttpResponse(stream, content_type="text/event-stream")
response["Cache-Control"] = "no-cache"
@@ -790,11 +807,16 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
# ── Stage 1 · 镜头脚本逐字段编辑 / 增删分镜 ──
def _sync_video_segments_to_script(self, project: Project, script: ScriptVersion) -> None:
"""采用版分镜数变化时,同步 VideoSegment 数量:不足则尾部补 NOT_STARTED,
多出且尾部是「从未生成过」的段则裁掉(已生成的段绝不动)。"""
"""采用版分镜数变化时,同步 VideoSegment 的**数量和时长**:不足则尾部补 NOT_STARTED,
多出且尾部是「从未生成过」的段则裁掉(已生成的段绝不动)。
时长同步是二期补的:脚本镜可以不等长了,而出片、计价、时间线读的都是
VideoSegment.target_duration_seconds。不同步的话脚本写了 8 秒、出片仍按 15 秒跑。
已出片的段不改时长——改了会跟已渲染的成片对不上。"""
if not script.is_adopted:
return
target = script.segments.count()
script_segments = list(script.segments.order_by("sort_order"))
target = len(script_segments)
segments = list(project.video_segments.order_by("sort_order"))
while len(segments) > target:
tail = segments[-1]
@@ -805,9 +827,34 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
break
next_order = (segments[-1].sort_order + 1) if segments else 0
for _ in range(target - len(segments)):
segments.append(VideoSegment.objects.create(project=project, sort_order=next_order, target_duration_seconds=15))
index = len(segments)
seconds = (
script_segments[index].duration_seconds
if index < target
else SEGMENT_DURATION_MAX
)
segments.append(
VideoSegment.objects.create(
project=project, sort_order=next_order, target_duration_seconds=seconds
)
)
next_order += 1
# 已存在的段:只对「还没出过片」的回填脚本时长,已渲染的保持原样
stale: list[VideoSegment] = []
for index, video_segment in enumerate(segments):
if index >= target:
break
seconds = script_segments[index].duration_seconds
if not seconds or video_segment.target_duration_seconds == seconds:
continue
if video_segment.status == VideoSegment.Status.SUCCEEDED or video_segment.versions.exists():
continue
video_segment.target_duration_seconds = seconds
stale.append(video_segment)
if stale:
VideoSegment.objects.bulk_update(stale, ["target_duration_seconds"])
def _sync_storyboard_shots_to_script(self, project: Project, script: ScriptVersion) -> None:
"""采用版分镜数变化时,同步 StoryboardShot 数量(与视频段同策略,按位置对齐):
多出且尾部「从未出过图」的 shot 裁掉(已出图的不动);不主动建——出图时 ensure_storyboard_shots 按需补。"""
@@ -834,7 +881,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
changed.append(field)
if "duration_seconds" in request.data:
try:
segment.duration_seconds = max(1, min(60, int(request.data["duration_seconds"])))
# 单镜必须落在 4–15 秒:出片模型的硬上限,越界下游直接拒片
segment.duration_seconds = max(
SEGMENT_DURATION_MIN, min(SEGMENT_DURATION_MAX, int(request.data["duration_seconds"]))
)
changed.append("duration_seconds")
except (TypeError, ValueError):
pass
@@ -877,7 +927,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
created = ScriptSegment.objects.create(
script_version=script,
sort_order=insert_at,
duration_seconds=int(request.data.get("duration_seconds") or 15),
duration_seconds=max(
SEGMENT_DURATION_MIN,
min(SEGMENT_DURATION_MAX, int(request.data.get("duration_seconds") or SEGMENT_DURATION_MAX)),
),
narration=str(request.data.get("narration") or "").strip(),
visual_prompt=str(request.data.get("visual_prompt") or "").strip(),
)
@@ -1187,6 +1240,39 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
segment.save(update_fields=["adopted_version", "status", "error_message", "updated_at"])
return Response(ProjectSerializer(project).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"], url_path="extract-script-file", parser_classes=[MultiPartParser, FormParser])
def extract_script_file_action(self, request, pk=None):
"""上传脚本取正文:docx / txt → 纯文本。只读不落库,交前端填进对话框再走脚本 agent。"""
self.get_object() # 走一遍团队权限校验
upload = request.data.get("file")
if upload is None:
return Response({"detail": "no file uploaded"}, status=status.HTTP_400_BAD_REQUEST)
try:
name, text = extract_script_text(upload)
except ScriptFileError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response({"name": name, "chars": len(text), "text": text})
@action(detail=True, methods=["post"], url_path="extract-script-video", parser_classes=[MultiPartParser, FormParser])
def extract_script_video_action(self, request, pk=None):
"""上传视频提炼:参考视频 → 中文分镜稿。
与 extract-script-file 同形状(只读不落脚本),交前端填进对话框让用户逐镜改,
改完再走脚本 agent 出结构化稿。差别是这条要真调模型,故按一次文本任务计费。
"""
project = self.get_object()
upload = request.data.get("file")
if upload is None:
return Response({"detail": "no file uploaded"}, status=status.HTTP_400_BAD_REQUEST)
try:
result = digest_project_video(project=project, user=request.user, upload=upload)
except VideoDigestError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except Exception as exc: # noqa: BLE001 — 模型/网络失败:走统一安全文案,不回传原始异常
public_error = classify_generation_error(exc, operation="video_digest")
return Response(public_error, status=status.HTTP_502_BAD_GATEWAY)
return Response({"name": getattr(upload, "name", "") or "参考视频", **result})
@action(detail=True, methods=["post"], url_path="upload-bgm", parser_classes=[MultiPartParser, FormParser])
@transaction.atomic
def upload_bgm_action(self, request, pk=None):
@@ -4,7 +4,9 @@ description: >
电商带货短视频·脚本生成领域技能(模型无关)。
服务对象不是人类编剧,而是 AirShelf 产品后端的「脚本生成 agent」——在运行时按需加载本技能作为领域知识。
能力:把【商品信息 + 前置条件】或【一句话主题】或【用户已有脚本】,
自动收敛成一份结构化的带货短视频脚本 JSON(默认 9:16 竖屏可改 / 时长 15·30·60·90 秒四档 / 每 15 秒一镜)。
自动收敛成一份结构化的带货短视频脚本 JSON
(默认 9:16 竖屏可改 / 总时长 5–60 秒按 5 秒步进 / 单镜 4–15 秒可不等长 /
按「表现形式 × 视频结构」套路生成)。
当任务为「生成带货脚本 / 扩写主题 / 优化已有脚本 / 商品转视频脚本」时使用本技能。
核心目标是「电商小白点一下按钮就出能吸睛、能转化的脚本」,不是影视级艺术性。
---
@@ -29,9 +31,11 @@ description: >
{
"hook": "前3秒主打钩子(一句话)",
"tone": "种草|测评|剧情|痛点",
"presentation_format": "口播|短剧|Vlog",
"video_structure": "痛点解决|前后对比|测评验证|场景种草",
"aspect_ratio": "9:16",
"total_duration": 60,
"segment_count": 4,
"total_duration": 30,
"segment_count": 3,
"entities": [
{
"id": "c1",
@@ -53,11 +57,11 @@ description: >
"segments": [
{
"index": 0,
"duration": 15,
"duration": 12,
"role": "钩子|痛点|卖点|CTA",
"narration": "这一镜被说出来的台词/旁白,≤55字",
"narration": "这一镜被说出来的台词/旁白,字数上限=duration×3.5",
"speaker": "可选,指向某 entity 的 id;画外旁白时为 null",
"visual": "这一镜的画面:主体+动作+景别/运镜(特写/全景/手持跟拍/推拉摇)+一个画面或情绪的变化,够导演撑满15秒,约40-70字,别只写一句静态动作",
"visual": "这一镜的画面:主体+动作+景别/运镜(特写/全景/手持跟拍/推拉摇)+一个画面或情绪的变化,够导演撑满这一镜的秒数,约40-70字,别只写一句静态动作",
"product_exposure": "商品露出方式(手持/特写/使用中)",
"entity_refs": ["c1", "s1"],
"dialogue": []
@@ -68,20 +72,29 @@ description: >
字段纪律:
- `tone` 必须是四选一枚举;`role` 必须是四选一枚举。
- **表现形式与视频结构由输入给定**`presentation_format``口播|短剧|Vlog`)和
`video_structure``痛点解决|前后对比|测评验证|场景种草`)**原样回填到输出里**,不要自己改。
输入没给时,按商品品类与人群自行选一组最合适的,并如实填进这两个字段。
- **画幅由输入给定**`aspect_ratio` 默认 `"9:16"`(电商竖屏主场景),但**不写死**——输入指定了其他比例(如 `"16:9"``"1:1"``"4:5"`)就照用,原样透传给下游。画幅只影响 `visual` 的构图措辞,不改变结构与镜数。
- **时长档位由输入给定**`total_duration` 只能取 `15 | 30 | 60 | 90` 之一(输入未指定时默认 `60`。**不要写死。**
- **每 15 秒切一镜**`segment_count = total_duration / 15`,即 15→1 镜、30→2 镜、60→4 镜、90→6 镜;每镜 `duration=15``index` 从 0 连续递增(粗暴切,不做复杂时长算法)。
- 各档位的 4 镜功能(role)如何分配/压缩/扩展,见 `references/methodology.md`「档位 × 黄金结构映射」
- **时长由输入给定**`total_duration` **560 秒之间的 5 的倍数**5/10/15/…/60),输入未指定时默认 `30`。**不要写死。**
- **单镜时长 415 秒,允许不等长**每个 `segments[].duration` 必须是 **4 到 15 之间的整数**
(15 秒是下游出片模型的硬上限,越界下游直接拒绝出片)
该长的镜给足、该短的镜压短,**不要机械均分**。
- **镜时长必须精确加总**`sum(segments[].duration) == total_duration`,一秒都不能差。
- **`segment_count` = `segments` 的实际长度**,且 `index` 从 0 连续递增。
- 镜数怎么定、role 怎么按镜数分配,见 `references/methodology.md`「时长 → 镜数 → 黄金结构映射」。
- `entities[].id` 全局唯一,`segments[].entity_refs``speaker` 只能引用已声明的 id。
- **发声方式每镜自己判断**(不强求统一,看这一镜的内容和场景):
- **旁白/口播** → 填 `narration``dialogue` 留空 `[]`(大多数电商镜是这种);
- **角色对话** → 填 `dialogue`:元素为 `{"speaker":"角色 entity 的 id,或 null=旁白","line":"台词"}`,每条 `line` ≤55 字;并把各 `line` 拼进 `narration` 兜底下游字幕/配音。剧情向、多角色互动、或一句自然的吐槽/接话更带感时都可以用,**不必非到「剧情」档**;
- **纯画面展示**`narration``dialogue` 都留空(没人说话,只有画面)。
- **判断权交给你**:依据用户输入与这一镜的功能/场景决定。**不是每个 15 秒都得有对白,也不必死守旁白**;但**别无故给每镜都塞对白**(那样很假),自然才好。
- **判断权交给你**:依据用户输入与这一镜的功能/场景决定。**不是每一镜都得有对白,也不必死守旁白**;但**别无故给每镜都塞对白**(那样很假),自然才好。
- 发声方式还受**表现形式**约束:口播只用 `narration`、短剧必须用 `dialogue`、Vlog 以画外旁白为主。
`references/playbooks/format-*.md` 里那一份为准。
- **每个声明的 entity 至少被一个 segment 引用**(不留孤儿 entity)。
- **场景必抽,且每镜必绑一个场景**:每条脚本**至少声明 1 个 `type:"scene"` 实体**表示画面所在环境;**每个 segment 的 `entity_refs` 必须恰好引用一个 scene**。多镜在同一环境就**复用同一个 scene id**(绝不为同一环境写两份 visual_prompt,否则下游背景漂移);只有真正换了环境才新建另一个 scene。纯产品特写镜也要绑它所处环境的 scene(如「宿舍书桌」「厨房台面」),没有合适环境时复用主场景。
- `visual_prompt` 由你自动生成,小白无需打字。
- **每镜 `visual` 要够厚撑满 15 秒**:一段话写清 ①主体+动作 ②景别/运镜(特写/全景/手持跟拍/推拉摇,至少给一个镜头语言)③一个画面或情绪的变化(从…到…)。约 **4070 字**,**禁止只写一句静态动作**(如「女主举起商品展示」撑不住 15 秒,要补镜头与变化)。注意:这是给生图/视频导演的画面,**不占 narration 的 55 字额度**。
- **每镜 `visual` 要够厚撑满这一镜的秒数**:一段话写清 ①主体+动作 ②景别/运镜(特写/全景/手持跟拍/推拉摇,至少给一个镜头语言)③一个画面或情绪的变化(从…到…)。约 **4070 字**,**禁止只写一句静态动作**(如「女主举起商品展示」撑不住十几秒,要补镜头与变化)。4–6 秒的短镜可以只给一个动作 + 一个镜头语言,但仍要写清景别。注意:这是给生图/视频导演的画面,**不占 narration 的字额度**。
- 不要输出 schema 之外的字段,也不要省略必填字段。
### 铁律 2 · 输出前自检
@@ -93,7 +106,8 @@ description: >
详见 `references/methodology.md`「旁白红线」。最关键的几条:
- **口语化**,像真人对着镜头说话,不准书面腔 / AI 腔。
- **每镜 narration ≤ 55 字**Seedance 15 秒内直接发声,字数 = 可懂语速上限)。
- **每镜 narration ≤ `duration × 3.5` 字,且绝不超过 55 字**(出片模型在镜内直接发声,
3.5 字/秒是可懂语速上限)。短镜装不下就拆到下一镜,或干脆留空走纯画面。
- **禁违规词**:医疗功效(治疗/根治/抗癌…)、绝对化用语(最/第一/100%/国家级…)一律不写。
- 不浮夸、不空喊,卖点要落到「商品怎么解决痛点」。
@@ -110,10 +124,12 @@ description: >
| 参数 | 值 |
| ---- | ---- |
| 画幅 | **默认 9:16 竖屏**,由输入可覆盖(16:9 / 1:1 / 4:5 等照用) |
| 总时长 | **15 / 30 / 60 / 90 秒四档**,由输入给定;未指定时默认 60 |
| 分镜 | 每 15 秒一镜,均分(粗暴切,不做复杂算法)→ 1 / 2 / 4 / 6 镜 |
| 镜头功能 | 钩子 → 痛点 → 卖点 → CTA(黄金结构;不同档位按映射表压缩/扩展,见方法论) |
| 发声方式 | Seedance 直接生成画面+音效+人声(**不走 TTS** |
| 总时长 | **560 秒,5 秒步进**,由输入给定;未指定时默认 30 |
| 表现形式 | **口播 / 短剧 / Vlog**,由输入给定;未指定时按品类人群自选 |
| 视频结构 | **痛点解决 / 前后对比 / 测评验证 / 场景种草**,由输入给定;未指定时按品类人群自选 |
| 分镜 | **单镜 415 秒,可不等长**;镜数按表现形式的推荐节奏定(见方法论 |
| 镜头功能 | 钩子 → 痛点 → 卖点 → CTA(黄金结构;具体骨架以视频结构套路为准) |
| 发声方式 | 出片模型直接生成画面+音效+人声(**不走 TTS**) |
---
@@ -123,23 +139,38 @@ description: >
| 模式 | 触发条件 | 处理逻辑 | 需读取 |
| ---- | ---- | ---- | ---- |
| **① 全自动** | 用户只给【商品信息 + 前置条件(调性/平台/时长/卖点勾选)】,无主题无原稿 | 凭商品与前置条件,自动定、选 tone、造 entity、按映射填镜 | `methodology.md` + `hook-library.md` + `category-playbook.md` + `platform-tone.md` |
| **① 全自动** | 用户只给【商品信息 + 前置条件(表现形式/视频结构/时长/人群/卖点勾选)】,无主题无原稿 | 凭商品与前置条件,自动定镜数、选 tone、造 entity、按套路填镜 | `methodology.md` + `hook-library.md` + `category-playbook.md` + `platform-tone.md` |
| **② 一句话** | 用户额外给了一句主题(如「主打熬夜党救星」) | 以该主题为脚本主轴扩写,其余同全自动 | 同上(主题优先于自动选题) |
| **③ 改稿** | 用户给了已有脚本/文案 | **保留用户原意**增强钩子/节奏/卖点/CTA,并归一化到 JSON 结构 | `methodology.md` + `hook-library.md` + `checklist.md` |
| **③ 改稿 / 上传脚本** | 用户给了已有脚本/文案 | **保留用户原意**先识别原稿结构与叙述顺序,再增强钩子/节奏/卖点证明/CTA,并归一化到 JSON 结构 | `methodology.md` + `hook-library.md` + `checklist.md` |
| 信息不足 | 连商品信息都缺,无法生成 | 唯一允许的回退:用一句话问清最少必要信息 | — |
**进入任何模式前,必须先读取该行列出的参考资料。**
### 套路资料(三种模式都适用)
除上表外,**每次生成都必须读** `references/playbooks/combo-matrix.md`
并根据输入指定的表现形式与视频结构,**各读一份**:
- `references/playbooks/format-{口播→oral | 短剧→drama | Vlog→vlog}.md`
- `references/playbooks/structure-{痛点解决→pain | 前后对比→contrast | 测评验证→review | 场景种草→scene}.md`
> 运行时后端只会把被指定的那两份套路拼进上下文。**看到哪份就用哪份,
> 不要凭记忆套用没加载进来的套路。**
> 套路里的结构骨架、镜头语言、发声方式、红线,**优先级高于本文件的通用默认值**。
---
## 生成流程(内部执行,一次走完,不暂停)
1. **路由** — 判定输入模式(①/②/③),加载对应 references。
2. **定**从输入读取画幅(`aspect_ratio` 默认 9:16)与时长档位(15/30/60/90,未指定默认 60,算出 `segment_count = total_duration / 15`
3. **定调(tone** — 依据品类话术 + 平台调性 + 前置条件,选定 `tone`;②③ 模式尊重用户已表达的倾向
1. **路由** — 判定输入模式(①/②/③),加载对应 references;再按输入的表现形式与视频结构加载那两份套路
2. **定**画幅(`aspect_ratio` 默认 9:16)与时长560 秒,未指定默认 30
按表现形式的推荐单镜节奏定出镜数,再给每镜分配 4–15 秒的时长,**确保加总等于总时长**
3. **定调(tone** — 依据视频结构 + 品类话术 + 平台调性,选定 `tone`;②③ 模式尊重用户已表达的倾向。
4. **抽取/创建 entities** — 识别脚本需要的角色 / 场景 / 商品;为每个 entity 写一份**全脚本共用**的 `visual_prompt`(保证多镜同一角色同一张脸);可选写 `voice_ref` 锁音色。
5. **按档位填黄金结构**依「档位 × 黄金结构映射表」给每个 segment 分配 `role`钩子镜套用 `hook-library.md` 的公式。
6. **写 narration / visual / 商品露出** — 每镜旁白 ≤55 字、口语化、过红线;每镜规划自然的 `product_exposure`
5. **按套路填结构**优先用视频结构套路里的骨架给每个 segment 分配 `role`
套路没覆盖到的用「role 按镜数分配」表兜底;钩子镜套用该套路指定的钩子写法
6. **写 narration / visual / 商品露出** — 每镜旁白 ≤ `duration × 3.5` 字、口语化、过红线;
发声方式按表现形式套路来;每镜规划自然的 `product_exposure`
7. **连引用** — 填 `entity_refs``speaker`,确认每个 entity 都被引用、id 都合法。
8. **自检** — 跑 `checklist.md`,过了再输出。
9. **输出** — 仅输出铁律 1 的 JSON。
@@ -150,7 +181,10 @@ description: >
| 文件 | 内容 | 何时读取 |
| ---- | ---- | ---- |
| `references/methodology.md` | 黄金结构模板(钩子→痛点→卖点→CTA)、档位 × 结构映射表、entity 一致性原则、商品露出规范、旁白红线(含违规词清单) | **每次生成都读** |
| `references/playbooks/combo-matrix.md` | 表现形式 × 视频结构 合法组合表、各组合交叉点要点、结构最短可用时长 | **每次生成都读** |
| `references/playbooks/format-*.md` | 单份表现形式套路:人物设置、镜头语言、单镜节奏、发声方式、专属红线 | **读被指定的那一份** |
| `references/playbooks/structure-*.md` | 单份视频结构套路:结构骨架、每段功能与判断标准、钩子写法、专属红线 | **读被指定的那一份** |
| `references/methodology.md` | 黄金结构模板(钩子→痛点→卖点→CTA)、时长 → 镜数映射、entity 一致性原则、商品露出规范、旁白红线(含违规词清单) | **每次生成都读** |
| `references/hook-library.md` | 前 3 秒钩子公式库(痛点提问 / 反差 / 数字冲击 / 身份代入 …,含例句) | 每次生成都读(写钩子镜时) |
| `references/category-playbook.md` | 分品类话术(美妆 / 食品 / 3C / 服饰 / 家居…的语气与卖点侧重) | 全自动 / 一句话模式 |
| `references/platform-tone.md` | 平台调性(抖音 / 快手 / 小红书 / 视频号 的节奏与风格差异) | 全自动 / 一句话模式 |
@@ -10,9 +10,14 @@
- [ ] 最终只输出**一个** JSON 对象,无 schema 外的多余文字/注释。
- [ ] `tone``{种草, 测评, 剧情, 痛点}`
- [ ] `aspect_ratio` 存在;输入指定了就用输入值,未指定为 `"9:16"`
- [ ] `total_duration``{15, 30, 60, 90}`
- [ ] `segment_count == total_duration / 15`,且 `segments` 数组长度等于它
- [ ] 每个 `segment.duration == 15``index` 从 0 连续递增无跳号
- [ ] `presentation_format``{口播, 短剧, Vlog}`,且与输入一致(输入没给则自选并如实填写)
- [ ] `video_structure``{痛点解决, 前后对比, 测评验证, 场景种草}`,同上
- [ ] **组合合法**:不是「短剧 × 测评验证」(唯一禁用组合,见 `playbooks/combo-matrix.md`
- [ ] `total_duration`**560 之间 5 的倍数**
- [ ] **每个 `segment.duration` 是 415 之间的整数**(15 是下游出片硬上限,越界直接出不了片)。
- [ ] **`sum(segments[].duration) == total_duration`**,逐个加一遍,差一秒都不行。
- [ ] `segment_count == len(segments)``index` 从 0 连续递增无跳号。
- [ ] 镜数落在 `ceil(total_duration/15)``floor(total_duration/4)` 区间内。
- [ ] 每个 `segment.role``{钩子, 痛点, 卖点, CTA}`
- [ ] 首镜 `role == 钩子`;末镜收 CTA(独立 CTA 镜,或末镜旁白末尾含明确行动指令)。
- [ ] `entities` 每项 `type``{character, scene, product}``id` 全局唯一。
@@ -20,7 +25,7 @@
- [ ] **每个声明的 entity 至少被一个 segment 引用**(无孤儿 entity)。
- [ ] **至少声明 1 个 `type==scene` 实体;每个 segment 的 `entity_refs` 恰好引用一个 scene**(同环境复用同一 scene id,换环境才新建;产品特写镜也要绑所处环境的 scene)。
- [ ] `speaker` 要么为 `null`,要么指向一个 `type==character` 的 id。
- [ ] 必填字段无缺失:顶层 `hook/tone/aspect_ratio/total_duration/segment_count/entities/segments`
- [ ] 必填字段无缺失:顶层 `hook/tone/presentation_format/video_structure/aspect_ratio/total_duration/segment_count/entities/segments`
每 entity 有 `id/type/name/visual_prompt/ref_index`
每 segment 有 `index/duration/role/narration/visual/product_exposure/entity_refs`
@@ -29,17 +34,22 @@
- [ ] 钩子镜第一句在前 3 秒抛出钩子,套用了 `hook-library.md` 的某个公式。
- [ ] 顶层 `hook` 与钩子镜口径一致。
- [ ] 卖点镜的卖点**挂在前面铺的痛点上**,不是干罗列参数。
- [ ] CTA 给了明确动作(点小黄车/领券/主页链接)。
- [ ] 档位映射正确:role 序列符合 `methodology.md`「档位 × 黄金结构映射」表。
- [ ] 90s 的多个卖点镜**各有侧重不重复**
- [ ] CTA 给了明确动作(点小黄车/领券/主页链接)。**场景种草结构的 CTA 要软**,别硬喊。
- [ ] **结构骨架走完了**:段的顺序与功能符合 `playbooks/structure-*.md` 里那份骨架表。
- [ ] **表现形式的镜头语言到位**:景别变化、单镜时长、人物数量符合 `playbooks/format-*.md`
- [ ] role 序列符合结构套路;套路没覆盖的部分回落到 `methodology.md`「role 按镜数分配」表。
- [ ] 多个卖点镜**各有侧重不重复**。
- [ ] **逐条过了这两份套路各自的「专属红线」**,一条都没踩。
- [ ] 同一角色/场景/商品全程复用同一 entity(没有给同一对象写出两份 visual_prompt)。
- [ ] 每个 `visual_prompt` 信息足够喂图模型(角色/场景/商品的外观特征写清)。
- [ ] 每镜 `product_exposure` 自然、与 role 匹配(参考方法论露出表)。
## C. 旁白红线扫描(逐镜)
- [ ] 每镜 `narration`55 字(硬上限;目标 ≤50 留缓冲,逐字数一遍,别凭感觉
- [ ] 每镜 `visual` ≥ 40 字且含「景别/运镜 + 一个画面变化」,不是一句静态动作(撑满 15 秒)。
- [ ] **每镜 `narration` ≤ `duration × 3.5` 字,且绝不超过 55 字**(逐镜按自己的秒数数一遍,别凭感觉。
4 秒镜 ≤14 字、8 秒镜 ≤28 字、15 秒镜 ≤52 字,对照表见 `methodology.md`)。
- [ ] 每镜 `visual` 含「景别/运镜 + 一个画面变化」,不是一句静态动作;能撑满这一镜的秒数
(≥8 秒的镜约 40–70 字;4–6 秒的短镜可以更短,但景别必须写清)。
- [ ] 口语化,无书面腔/AI 腔("综上""不仅…而且""值得一提"等已清除)。
- [ ] **违规词扫描**:无医疗功效词(治疗/根治/抗癌/消炎/排毒/速效…)。
- [ ] **绝对化用语扫描**:无 最/第一/唯一/100%/国家级/永久/绝对/史上 等。
@@ -22,22 +22,50 @@
---
## 二、档位 × 黄金结构映射(关键)
## 二、时长 → 镜数 → 黄金结构映射(关键)
时长由输入给定(15/30/60/90 四档),每 15 秒一镜,`segment_count = total_duration / 15`
镜数变了,四个功能要**压缩或扩展**,按下表分配 `role`
### 2.1 时长规则(不再是固定四档,也不再每镜等长)
| 档位 | 镜数 | role 序列(index 0→N | 压缩/扩展说明 |
| ---- | ---- | ---- | ---- |
| **15s** | 1 | `[钩子]` | 一镜到底:开头 3 秒钩子 → 中段一句卖点 → 末尾一句 CTA,全压进这 15 秒旁白里 |
| **30s** | 2 | `[钩子, 卖点]` | 镜0 钩子里带出痛点;镜1 讲卖点并在旁白末尾收一句 CTA |
| **60s** | 4 | `[钩子, 痛点, 卖点, CTA]` | 标准黄金结构,一镜一功能 |
| **90s** | 6 | `[钩子, 痛点, 卖点, 卖点, 卖点, CTA]` | 痛点后给 3 个卖点镜:核心卖点 / 场景化演示 / 信任背书(口碑·数据);末镜 CTA |
- **总时长**由输入给定:**5–60 秒之间、5 秒一档**5/10/15/…/60)。未指定时默认 30。
- **单镜时长 415 秒**,这是下游出片模型的硬上限,**任何一镜都不许超过 15 秒、不许低于 4 秒**。
- **每镜时长可以不一样。** 该长的镜(讲卖点、举证)就给足,该短的镜(空镜、转场、一句钩子)就压短。
别再机械均分。
- 所有 `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)。
### 2.3 role 按镜数分配
| 镜数 | role 序列(index 0→N | 说明 |
| ---- | ---- | ---- |
| **1** | `[钩子]` | 一镜到底:钩子 → 一句卖点 → 一句 CTA,全压进这一镜的旁白 |
| **2** | `[钩子, 卖点]` | 镜0 钩子里带出痛点;镜1 讲卖点并在末尾收 CTA |
| **3** | `[钩子, 卖点, CTA]` | 痛点并进钩子镜 |
| **4** | `[钩子, 痛点, 卖点, CTA]` | 标准黄金结构,一镜一功能 |
| **5** | `[钩子, 痛点, 卖点, 卖点, CTA]` | 两个卖点镜要各有侧重 |
| **N ≥ 4** | `[钩子, 痛点, 卖点 × (N-3), CTA]` | 通用规则:首钩子、次痛点、末 CTA,中间全是卖点 |
说明:
- `role` 字段只能取 `钩子|痛点|卖点|CTA` 四个枚举值。"信任背书""场景演示"等都归入 `卖点`
- 短档位(15/30s)靠**旁白内压缩多功能**达成"留人+转化",不要因为镜少就丢掉 CTA
- 90s 的 3 个卖点镜要**各有侧重、不重复**(核心功效 / 真实使用场景 / 别人为什么买)。
- `role` 只能取 `钩子|痛点|卖点|CTA` 四个枚举值。"信任背书""场景演示""前后对比"等都归入 `卖点`
- **镜少也不许丢 CTA** —— 镜数 ≤2 时把 CTA 压进末镜旁白的最后一句
- 个卖点镜要**各有侧重、不重复**(核心功效 / 真实使用场景 / 别人为什么买 / 举证读数)。
- 视频结构会覆盖这里的默认分配 —— 以 `playbooks/structure-*.md` 里的骨架为准,
本表只是没有指定结构时的兜底。
---
@@ -74,8 +102,22 @@
## 五、旁白红线(硬规则,违反即不合格)
- **口语化**:像真人对着镜头唠嗑,不准书面腔 / 不准 AI 腔("综上所述""不仅…而且""值得一提的是"全禁)。
- **每镜 `narration` ≤ 55 字**Seedance 在 15 秒内直接发声,55 字是可懂语速**硬上限**
**写作目标 ≤ 50 字**,留 5 字缓冲——宁可短、不要卡满;短档位(15/30s)一镜要装多功能时尤其要狠删,先保 CTA 不被砍。
- **`narration` 字数按这一镜的秒数算**:出片模型在镜内直接发声,可懂语速上限约 **3.5 字 / 秒**
**每镜 `narration``duration × 3.5` 字,且任何情况下不超过 55 字。**
对照表(写作目标再留 10% 缓冲,宁可短不要卡满):
| 单镜时长 | 字数上限 | 写作目标 |
| ---- | ---- | ---- |
| 4 秒 | 14 字 | ≤ 12 字 |
| 5 秒 | 17 字 | ≤ 15 字 |
| 6 秒 | 21 字 | ≤ 19 字 |
| 8 秒 | 28 字 | ≤ 25 字 |
| 10 秒 | 35 字 | ≤ 31 字 |
| 12 秒 | 42 字 | ≤ 38 字 |
| 15 秒 | 52 字 | ≤ 47 字 |
**短镜不要硬塞长句** —— 装不下就把这句拆到下一镜,或者干脆让这镜没有旁白(纯画面)。
总时长很短、一镜要装多功能时尤其要狠删,先保 CTA 不被砍。
- **不浮夸、不空喊**:卖点要给具体理由,不堆形容词。
- **违规词禁令**(电商广告法红线,一律不写):
- **医疗功效类**:治疗 / 根治 / 疗效 / 抗癌 / 消炎 / 杀菌(无证) / 排毒 / 速效 / 抑制 ××病 …
@@ -0,0 +1,108 @@
# 表现形式 × 视频结构 · 组合矩阵
> 表现形式决定**怎么拍**(谁在画面里、镜头怎么动、谁在说话)。
> 视频结构决定**怎么说服**(信息按什么顺序给、靠什么建立可信)。
> 两者正交:同一个结构用三种形式拍出来完全不同,同一种形式套四个结构说服力也不同。
**加载规则:** 本文件每次都读。另外只读输入指定的那一份 `format-*.md` 和一份 `structure-*.md`
不要凭记忆套用没加载的套路。
---
## 一、合法组合表
| | 痛点解决 | 前后对比 | 测评验证 | 场景种草 |
| ---- | :----: | :----: | :----: | :----: |
| **口播** | ✅ 强 | ✅ 可 | ✅ **最强** | ✅ 可 |
| **短剧** | ✅ **最强** | ✅ 强 | ❌ **禁用** | ✅ 可 |
| **Vlog** | ✅ 可 | ✅ 可 | ✅ 强 | ✅ **最强** |
**唯一禁用组合:短剧 × 测评验证。**
理由:测评的说服力来自「这是真实发生的」,短剧的本质是「这是演的」。
两者直接冲突 —— 演出来的实测读数没有任何可信度,反而会让观众怀疑品牌。
输入若指定了这个组合,按「短剧 × 痛点解决」处理,并在 `hook` 里保持剧情感。
---
## 二、各组合要点(只看你被指定的那一格)
### 口播 × 痛点解决
最直给的一条。人在画面里直接把痛点说出来,靠**共犯身份**建立信任。
- 钩子用「我们这种___的人都懂」,第一句就把观众归类进来
- 讲卖点那镜必须切商品特写,手要入画
- 全片一个人一个场景,靠景别变化撑节奏
### 口播 × 前后对比
口播里唯一画面重于台词的组合。人要**退到画外**让位给对比画面。
- 「之前 / 之后」两镜人不出镜或只出手,避免抢戏
- 人只在开头(交代条件)和结尾(收 CTA)出镜
- 台词负责讲清「同机位、同光线、几天」,画面负责证明
### 口播 × 测评验证 ★ 最强组合
带货转化率最高的组合,尤其适合中高客单价。
- 人的**身份权威性**要在前 3 秒立住(从业年限 / 专业背景 / 测过多少款)
- 读数特写时人可以出画外,但声音要连着不断
- 必须说一个缺点,且要从「专业判断」的角度说,不是随口一句
### 口播 × 场景种草
最弱的一格,因为口播是「对镜说话」,天然打断场景沉浸。
- **必须弱化对镜**:人在场景里自然活动,偶尔看向镜头,不要全程盯着
- 台词改成自言自语的语气,不要介绍腔
- 如果商品本身氛围属性强(香氛、饮品),优先考虑改用 Vlog
### 短剧 × 痛点解决 ★ 最强组合
完播率和转化率兼顾的组合,短剧的天然主场。
- 第一幕直接**演**痛点,不用台词解释(这比口播讲痛点强十倍)
- 副角在转折点带出商品,主角不要自夸
- 第三幕的「结果」就是痛点解决后的同一场景,形成闭环
### 短剧 × 前后对比
把对比拆进剧情里,用**两幕**承载前后。
- 前后两幕必须同场景同机位,只有主角状态变了
- 中间用一个短镜交代使用过程,否则反转不可信
- 台词极简,让画面对比自己说话
### 短剧 × 场景种草
剧情化的生活切片,广告感最低。
- 冲突要**很轻**(不是麻烦,是一个小小的期待)
- 商品是场景里的一个自然道具,不承担解决问题的功能
- 结尾不要强反转,停在氛围里就好
### Vlog × 痛点解决
用生活流水记录痛点,比口播柔和,比短剧真实。
- 痛点出现在时间线上的某个节点(「下午三点,又开始了」)
- 商品在下一个节点自然出现,不做介绍
- CTA 要软,硬推会破坏 Vlog 的可信度
### Vlog × 前后对比
用**时间跨度**做对比,Vlog 天然适合记录「第 1 天 / 第 7 天」。
- 每个时间点用同机位拍一次,形成序列
- 画面上标时间(Day 1 / Day 7),这是 Vlog 里唯一允许的字幕硬信息
- 中间的日常镜头就是「过程可见」,可信度天然高
### Vlog × 测评验证
生活化实测,比正经测评亲和,但证据强度要守住。
- 测的过程放进真实生活场景(在自家厨房测,不在摄影棚)
- 读数依然要给特写、要看得清,这条不能因为是 Vlog 就放松
- 结论用自述语气给(「反正我是回购了,但它确实有点贵」)
### Vlog × 场景种草 ★ 最强组合
氛围感最强的组合,适合食品饮料、家居、香氛、户外。
- 全片可以没有一句参数,纯靠光、声音、细节
- 商品在两到三个时间节点重复出现,靠**重复**建立记忆
- 至少一个镜头让商品外形被看清,这是本组合唯一的硬要求
---
## 三、组合与时长的关系
表现形式给出推荐时长,视频结构决定能压到多短:
| 结构 | 最短可用 | 原因 |
| ---- | ---- | ---- |
| 痛点解决 | 15 秒 | 痛点一句、卖点一句、CTA 一句,勉强够 |
| 前后对比 | 10 秒 | 前后两镜就能成立,最抗压缩 |
| 测评验证 | 20 秒 | 举证需要时间,低于 20 秒证据不足 |
| 场景种草 | 20 秒 | 氛围需要铺陈,太短像随机片段 |
低于该结构的最短时长时,优先保住「钩子 + 结构核心 + CTA」三件,其余全砍。
@@ -0,0 +1,81 @@
# 表现形式 · 短剧
> 有角色、有冲突、有转折的微型情节剧。观众先被故事勾住,商品在转折点出场。
> 完播率最高的形式,但**商品露出最容易被故事盖掉**——这是短剧唯一的死穴。
---
## 一、本质
短剧卖的是**代入感**:观众在主角身上看到自己,于是相信主角的选择。
所以短剧的商品不能靠「介绍」,只能靠「解决」——它必须是剧情里那个**转折的原因**。
**判断标准:** 把商品从剧情里拿掉,如果故事依然能讲通,那这条短剧就白拍了。
商品必须是不可替换的转折点。
---
## 二、人物设置
- **两到三个角色,不能更多。** 短视频装不下第四个人,观众记不住。
- 标准配置是**一主一副**
- **主角** = 有困扰的人(观众的镜子)—— 戏份最重,情绪弧线在他身上
- **副角** = 推动者(闺蜜 / 同事 / 家人 / 店员)—— 负责把商品带进来
- 让**副角**说出商品,别让主角自卖自夸。第三方推荐的可信度远高于自述。
- 每个角色在 `entities` 里独立声明,`visual_prompt` 写清外形与穿着,全片锁脸。
---
## 三、三幕结构(必须走完)
短剧再短也要有完整的起承转合,否则就是没头没尾的片段。
| 幕 | 占比 | 内容 | 必须做到 |
| ---- | ---- | ---- | ---- |
| **第一幕 · 困境** | 前 30% | 主角遇到具体的麻烦,情绪是负面的 | 麻烦要**具体可见**,不能是抽象的「我很累」 |
| **第二幕 · 转折** | 中间 40% | 副角带出商品,主角将信将疑到尝试 | **商品在这里第一次出现**,且要被手拿着看清 |
| **第三幕 · 结果** | 后 30% | 主角状态反转,情绪变正面 | 反转要**看得见**,画面上的变化比台词重要 |
**转折点必须在整片的 1/3 处。** 商品出现太晚(超过一半时长)观众已经划走了。
---
## 四、镜头语言
- **对话镜用中景**(能看到两个人的关系和肢体)
- **情绪镜用近景**(主角的表情就是共鸣点)
- **商品出场必须给一个特写**,哪怕只有 2 秒
- 场景可以有两个(如「工位」→「家里」),但不要超过两个,换太多观众会晕
---
## 五、节奏与单镜时长
- **单镜 610 秒**(短剧靠切换制造节奏,镜头长了就拖)
- 一镜一个动作或一次对话交换,**别在一镜里塞完整对话**
- 有对白的镜,每句 `line` ≤ 25 字(两个人说话,总量还是受 55 字限制)
**推荐总时长 45 秒。** 短剧需要装下三幕,低于 30 秒会讲不完整;
超过 60 秒在电商场景下性价比开始下降。
---
## 六、发声方式
短剧是**唯一必须用 `dialogue` 的形式**。
- `dialogue` 填角色对白:`[{"speaker":"c1","line":"..."},{"speaker":"c2","line":"..."}]`
- `narration` 把各句 `line` 拼起来兜底(下游字幕和配音要用)
- 允许个别镜是纯画面(`narration``dialogue` 都空),用来给情绪留白
- **别每一镜都塞满对白** —— 有沉默的镜,情绪才有落点
---
## 七、短剧专属红线
- ❌ **商品在最后才出现** —— 转折点在 1/3 处,不是结尾
- ❌ **角色超过三个** —— 观众记不住
- ❌ **主角自己夸商品** —— 让副角说,或让结果说
- ❌ **冲突是抽象的** —— 「最近好烦」不算冲突,「客户又打回来第五版」才算
- ❌ **结局只靠台词交代** —— 反转必须在画面上看得见
- ❌ **把测评做成短剧** —— 演出来的测评没有可信度,这个组合是禁用的
@@ -0,0 +1,75 @@
# 表现形式 · 口播
> 一个人对着镜头说话。观众看的是「这个人可信不可信」,不是「这个故事好不好看」。
> 电商带货里最稳、转化最直接的形式。信息密度最高,制作最省。
---
## 一、本质
口播的全部说服力压在**说话的人**身上。画面只是背书,台词才是主体。
所以口播脚本的重心是:**每一句话都要有信息**,不能有一句是废话铺垫。
**判断标准:** 把画面全遮住只听声音,如果这条视频依然成立,那口播就写对了。
---
## 二、人物设置
- **只要一个人。** 口播不需要第二个角色。真的需要对话就别选口播,选短剧。
- 这个人必须有一个**明确身份**,且身份要跟商品可信度挂钩:
- 用过的人(真实用户)→ 讲体验
- 懂行的人(成分党 / 从业者 / 测评师)→ 讲原理
- 同类人(同龄 / 同职业 / 同困扰)→ 讲共鸣
- 身份在**第一镜前 3 秒内交代完**,用一句话,别铺垫。
- ✅「我做了六年护肤研发,这瓶我拆开跟你讲。」
- ❌「大家好,欢迎来到我的频道,今天想跟大家聊一个话题。」
`entities` 里这个人是唯一的 `type:"character"`,全片复用同一 `visual_prompt`
---
## 三、镜头语言
口播的画面变化不靠剧情,靠**景别切换**和**手部动作**。同一个人同一个场景,
连续 30 秒不换机位会让人划走。
| 镜头功能 | 景别 | 用法 |
| ---- | ---- | ---- |
| 开场钩子 | 近景(胸上) | 眼睛对镜头,情绪最强的一句话 |
| 讲痛点 | 中近景 | 可以有手势,身体略前倾 |
| 讲卖点 | 特写 / 手持商品 | **必须切到商品**,手入画,让观众看清 |
| 演示 | 商品特写(人出画外) | 只拍手和商品,人声继续 |
| 收 CTA | 近景回到人 | 眼睛回到镜头,语速放慢 |
**铁律:讲卖点的那一镜,商品必须实体出现在画面里被手拿着。** 光靠嘴说卖点、
画面还是大头,是口播最常见的失败。
---
## 四、节奏与单镜时长
- **单镜 1015 秒**(口播里说完一个完整意思需要时间,切太碎会让话说不完整)
- 一句话讲一件事,**一镜讲一到两句话**
- 语速按 **3.5 字 / 秒** 估:12 秒的镜 ≈ 42 字,别超
**推荐总时长 30 秒。** 口播超过 45 秒完播率掉得很快,除非是测评验证这种
需要举证的结构。
---
## 五、发声方式
- `narration` 全填,`dialogue` **永远留空 `[]`**(只有一个人,没有对话)
- `speaker` 指向那个 character 的 id(不是画外音,是这个人在说)
- 口播不用画外旁白 —— 人就在画面里,再加一层旁白会精神分裂
---
## 六、口播专属红线
- ❌ **开场自我介绍 + 寒暄** —— 前 3 秒必须是钩子,不是「大家好」
- ❌ **一个机位怼到底** —— 至少 3 次景别变化
- ❌ **念稿腔** —— 短句、口语、允许有语气词(「说真的」「你听我讲完」)
- ❌ **多个人轮流说** —— 那是短剧或访谈,不是口播
- ❌ **台词写成书面语** —— 「其具备优异的保湿性能」→「它是真的锁水,我这脸一天不干」
@@ -0,0 +1,85 @@
# 表现形式 · Vlog
> 第一人称的生活记录。镜头是「我的眼睛」,观众跟着我过一段生活,商品自然地出现在里面。
> 广告感最低的形式,适合高频消费品和生活方式类商品。
---
## 一、本质
Vlog 卖的是**生活方式**,不是商品参数。观众想要的是「我也想过这样的日子」,
商品是这种生活的一部分,顺手被带走。
**判断标准:** 如果观众看完记住的是「这个人的生活真好」而不是「这个商品参数真好」,
但依然想买,那 Vlog 就写对了。**Vlog 不许硬推。**
---
## 二、人物设置
- **一个第一人称主角**,通常出镜但不总是对着镜头说话
- 主角可以是**半出镜**的:只有手、只有背影、只有侧脸 —— 这反而更有代入感
- 可以有配角,但配角是**生活的一部分**(室友、同事、宠物),不承担推销功能
- 主角身份靠**生活细节**建立,不靠自我介绍:
- ✅ 画面里是加班到十点的工位、外卖盒、屏幕上的表格
- ❌ 台词说「我是一个每天加班的上班族」
---
## 三、时间线结构
Vlog 靠**时间推进**组织,不靠论证推进。这是它跟口播、短剧最大的区别。
常用时间骨架(选一条,别混):
| 骨架 | 适合品类 | 节点示例 |
| ---- | ---- | ---- |
| **一天** | 日用 / 护肤 / 食品 / 咖啡 | 早上起床 → 出门 → 午间 → 下班 → 睡前 |
| **一次外出** | 服饰 / 箱包 / 户外 / 相机 | 出门前准备 → 路上 → 到达 → 回程 |
| **一件事** | 厨具 / 家居 / 工具 | 决定做 → 准备 → 过程 → 完成 |
**商品出现在某个时间节点上,作为「这个时刻我在用它」**,不单独开一段介绍。
---
## 四、镜头语言
Vlog 的镜头必须有**手持感和生活质感**,这是它的可信度来源。
- **手持跟拍**为主,允许轻微晃动
- 大量**空镜和细节镜**:窗外的光、杯子上的水汽、桌面一角 —— 这些是 Vlog 的质感
- 商品**不给硬广式特写**,给「使用中的手部特写」
- 光线尽量自然光,时间感要对(早上是冷白光,傍晚是暖黄光)
---
## 五、节奏与单镜时长
- **单镜 58 秒**(Vlog 是碎片拼贴,镜头短、数量多)
- 允许有 4–5 秒的纯空镜,用来换时间段
- 不需要每镜都说话,**画面自己会讲**
**推荐总时长 30 秒。** Vlog 靠氛围,时长太短装不下时间感(低于 20 秒会像随机片段),
太长又超出电商场景的耐心。
---
## 六、发声方式
- 以**画外旁白**为主:`narration` 填,`speaker` 设为 `null`(是「我」在回想,不是对镜头说)
- `dialogue` 基本不用;只有真实生活对话才填(如跟室友的一句闲聊)
- **允许整镜无声**`narration``dialogue` 都空),纯画面 + 环境音
- 旁白语气是**自言自语**,不是介绍:
- ✅「这杯是今天唯一的清醒时刻。」
- ❌「这款咖啡采用精品阿拉比卡豆,风味层次丰富。」
---
## 七、Vlog 专属红线
- ❌ **中途切换成口播** —— 突然对着镜头开始介绍商品,Vlog 的气就泄了
- ❌ **给商品硬广特写** —— 商品只在「被使用」时出现
- ❌ **旁白讲参数** —— 参数是口播和测评的活,Vlog 只讲感受
- ❌ **时间线跳跃混乱** —— 时间必须单向推进,不许倒叙
- ❌ **强行喊 CTA** —— Vlog 的 CTA 要软(「链接我放下面了」而不是「快点小黄车」)
- ❌ **摆拍痕迹重** —— 完美构图反而失真,允许不完美
@@ -0,0 +1,95 @@
# 视频结构 · 前后对比
> 用「之前」和「之后」的差距直接说服。视觉冲击最强的一条结构,几乎不需要台词。
> 核心风险:对比不可信 → 观众觉得是摆拍 → 反噬品牌。
---
## 一、结构骨架
```
[ 之前 · 惨状 ] → [ 使用过程 ] → [ 之后 · 反转 ] → [ 并排复现 + CTA ]
↑ 两端必须同机位、同光线、同角度
```
| 段 | 功能 | role | 判断标准 |
| ---- | ---- | ---- | ---- |
| **1 · 之前** | 建立基准 | `钩子` | 直接展示,不铺垫。第一帧就是「之前」 |
| **2 · 使用过程** | 建立因果 | `卖点` | 让观众看到**怎么做到的**,否则反转不可信 |
| **3 · 之后** | 制造冲击 | `卖点` | 反转要在同一条件下呈现 |
| **4 · 并排 + CTA** | 落定 + 转化 | `CTA` | 左右分屏或快切复现对比,然后收 CTA |
**时长很短(≤20 秒)时可压成三段**:之前 → 之后 → CTA,省掉过程段。
但过程段是可信度的来源,能留就留。
---
## 二、可信度铁律(本结构的生死线)
前后对比最容易被观众判定为「假」。守住这四条:
1. **同机位** —— 前后两镜的角度、距离、构图必须一致
2. **同光线** —— 不许「之前」打冷光、「之后」打暖光,这是最常见的作弊
3. **同条件** —— 不许「之前」素颜、「之后」化妆;变量只能有一个,就是商品
4. **过程可见** —— 中间必须有真实的使用过程,不能直接跳切
**在 `visual` 里明确写出「与第 X 镜同机位同光线」**,让下游生图时保持一致。
---
## 三、对比什么
按品类选一个维度,**只对比一个维度**:
| 品类 | 对比维度 | 呈现方式 |
| ---- | ---- | ---- |
| 清洁 / 护理 | 状态变化 | 同一块区域的特写前后 |
| 收纳 / 家居 | 空间变化 | 同一角度的全景前后 |
| 服饰 | 版型 / 搭配 | 同一姿势的全身前后 |
| 食品 / 厨具 | 成品效果 | 同一容器同一角度 |
| 工具 / 3C | 效率变化 | 同一任务的耗时对比(可上计时器) |
**禁止同时对比两个维度**,观众的注意力只够看一个变化。
---
## 四、时间标注
前后对比几乎都需要交代**时间跨度**,否则观众不知道要等多久。
- 在画面上或旁白里给出明确时间:「7 天」「一次」「30 秒」
- 时间必须真实,**不许模糊化**(「短时间内」这种写法会降低可信度)
- 短时间见效的商品把时间往前放(是卖点);需要长期的就诚实说(是预期管理)
---
## 五、钩子写法(第一镜)
前后对比的钩子就是**「之后」的画面先闪一下**,或者直接从「之前」开始。
1. **结果前置** — 先给 1 秒「之后」,再回到「之前」(最抓人)
2. **惨状直击** — 直接展示「之前」,不加任何解释
3. **数字冲击** —「7 天,同一个角度。」
---
## 六、台词分寸
**这是台词最少的一条结构。** 画面已经在说话了,台词只做三件事:
- 交代条件(同机位、时间跨度)
- 交代过程(用了什么、怎么用)
- 收 CTA
**别用台词描述画面已经展示的东西。** 「你看,明显干净多了」是废话,观众看得见。
---
## 七、本结构红线
- ❌ **前后条件不一致** —— 换光、换角度、换妆容,一律判假
- ❌ **跳过使用过程** —— 直接从「之前」切「之后」,观众不信
- ❌ **对比两个以上维度** —— 注意力分散,一个都记不住
- ❌ **时间含糊** —— 必须给明确时长
- ❌ **夸大反转幅度** —— 效果超出商品实际能力就是虚假宣传
- ❌ **用台词代替画面** —— 这条结构的说服力在画面,不在嘴
@@ -0,0 +1,94 @@
# 视频结构 · 痛点解决
> 先把观众的痛戳到,再把商品端上来。电商转化率最稳的一条结构,适用面最广。
> 核心风险:痛点不够具体 → 观众不对号入座 → 整条视频白做。
---
## 一、结构骨架
```
[ 痛点场景 ] → [ 痛点放大 ] → [ 商品介入 ] → [ 结果 + CTA ]
↑ 必须具体到可复现的一幕
```
| 段 | 功能 | role | 判断标准 |
| ---- | ---- | ---- | ---- |
| **1 · 痛点场景** | 让观众「这就是我」 | `钩子` | 一个具体的、有画面的、发生在昨天的场景 |
| **2 · 痛点放大** | 让观众意识到代价 | `痛点` | 说出这个麻烦带来的**后果**,不只是麻烦本身 |
| **3 · 商品介入** | 给出解法 | `卖点` | 卖点必须**正面对准**前面的痛点,一一对应 |
| **4 · 结果 + CTA** | 转化 | `CTA` | 结果要可见,CTA 要有动作 |
时长短时按此顺序压缩,段可以合并但**顺序不能变**。
---
## 二、痛点怎么写才算合格
**唯一标准:具体到能拍出来。**
| ❌ 不合格 | ✅ 合格 |
| ---- | ---- |
| 头发很油 | 早上洗的头,下午三点开会刘海已经一绺一绺 |
| 收纳很乱 | 每次找充电线要翻三个抽屉,最后在沙发缝里 |
| 皮肤状态不好 | 昨晚两点睡,今早粉底卡在法令纹里 |
| 做饭很麻烦 | 下班到家七点半,等米饭熟就八点十分 |
**方法:给痛点加时间、加地点、加一个具体后果。** 抽象的痛点没有人会认领。
---
## 三、痛点放大的分寸
放大是为了让观众意识到「这事得解决」,不是为了制造焦虑。
- ✅ 放大**代价**:耽误的时间、多花的钱、错过的场合、别人的眼光
- ❌ 不许贩卖容貌焦虑、身材焦虑、年龄焦虑
- ❌ 不许暗示不用这个商品会有健康后果(这条越线就是违规)
**一句话就够。** 放大段是全片最短的,超过一句就变成说教。
---
## 四、卖点必须一一对应
这是「痛点解决」结构最容易翻车的地方。
**规则:前面戳了几个痛点,后面就只讲几个卖点,且必须一一对上。**
```
痛点:下午三点头发就油
↓ 必须对上
卖点:它的控油是靠 XX 成分吸附,我实测撑到晚上八点
```
**禁止**在这里罗列商品的全部卖点。跟痛点无关的卖点一个都不要提 —— 它们只会
稀释说服力。一条视频解决一个痛点就够了。
---
## 五、结果与 CTA
- **结果必须可见**:同一个场景、同一个时间点,状态变了
- CTA 给**一个**明确动作,不要给两个(「点小黄车」和「关注我」二选一)
- 紧迫感可以有,但不许用绝对化用语(「最后一天」可以,「全网最低」不行)
---
## 六、钩子写法(第一镜)
痛点解决结构的钩子优先用这三种:
1. **痛点提问** —「你有没有过这种情况:___?」
2. **场景直击** — 直接演那一幕,不解释(画面自己说话)
3. **共犯身份** —「我们这种___的人,都懂。」
---
## 七、本结构红线
- ❌ **痛点抽象** —— 最致命,写不具体就重写
- ❌ **卖点跟痛点对不上** —— 观众会觉得答非所问
- ❌ **一条视频戳三个痛点** —— 一个就够,多了就散
- ❌ **贩卖焦虑** —— 放大代价可以,制造恐慌不行
- ❌ **痛点段太长** —— 痛点占比不超过全片一半,否则观众会难受到划走
@@ -0,0 +1,98 @@
# 视频结构 · 测评验证
> 用举证的方式说服。观众要的不是「你说它好」,而是「你怎么证明它好」。
> 客单价越高,这条结构越有效。核心风险:只有结论没有证据 → 变成软广。
---
## 一、结构骨架
```
[ 提出质疑 ] → [ 设定验证方法 ] → [ 当场验证 ] → [ 给出结论 + CTA ]
↑ 站在观众那一边,而不是商品那一边
```
| 段 | 功能 | role | 判断标准 |
| ---- | ---- | ---- | ---- |
| **1 · 提出质疑** | 建立中立立场 | `钩子` | 用观众的口吻问出怀疑,不许一开始就夸 |
| **2 · 验证方法** | 建立方法可信 | `痛点` | 说清「我要怎么测」,方法要看起来公平 |
| **3 · 当场验证** | 举证 | `卖点` | 必须有**可见的过程和读数**,不能只给结论 |
| **4 · 结论 + CTA** | 转化 | `CTA` | 结论要有保留(说清适合谁不适合谁) |
---
## 二、立场问题(本结构的灵魂)
测评验证跟其他结构最大的区别:**你不站在商品这边,你站在观众这边。**
- 开场是**质疑**,不是推荐:
- ✅「这瓶卖 299,说能扛住一整天。我不太信,今天实测。」
- ❌「今天给大家推荐一款超好用的产品。」
- 全片语气是**验证**,不是介绍
- 结论必须**有保留**,全是好话反而没人信
**如果整条视频没有一句对商品不利的话,观众会判定这是广告。**
---
## 三、证据类型(至少给两种)
| 证据类型 | 呈现方式 | 可信度 |
| ---- | ---- | ---- |
| **实测读数** | 计时器 / 温度计 / 秤 / 尺 —— 画面上要看得见数字 | 最高 |
| **对照组** | 同时测另一个东西作参照 | 高 |
| **过程可见** | 完整拍出使用过程,不剪断 | 高 |
| **时间跨度** | 「连续用了 7 天」+ 每天记录 | 中高 |
| **第三方背书** | 检测报告 / 成分表 / 销量 | 中(不能单独用) |
| **主观感受** | 「我觉得挺好用」 | 最低(不能单独用) |
**铁律:至少要有一种是可见的客观证据(读数或对照组)。** 全靠主观感受的
「测评」就是伪装成测评的口播。
---
## 四、诚实分寸
- **必须说一个缺点。** 缺点要真实但不致命(价格偏高、颜色少、需要适应期)
- 说清**适合谁、不适合谁** —— 这既是诚实,也是精准筛选目标客户
- 不许伪造读数、不许剪掉不利结果
- 不许用绝对化用语和医疗功效词(这条结构最容易踩,因为在「讲效果」)
---
## 五、钩子写法(第一镜)
1. **数字质疑** —「标称 8 小时,我测测到底几小时。」
2. **价格质疑** —「299 一瓶,凭什么?」
3. **打脸预告** —「我本来想吐槽它的,结果……」(反转型,很抓人)
4. **对照开场** — 直接把两个东西并排放在画面里
---
## 六、镜头语言
- **验证过程必须是长镜或连续镜**,频繁跳剪会让人怀疑剪掉了什么
- 读数、仪表、计时器要给**特写**,让观众自己看清
- 人可以出画外只留手,重点在被测的东西上
- 对照组要**同框**,不要分别拍
---
## 七、时长注意
测评验证是**唯一适合做长的结构**。举证需要时间,压得太短就没有证据密度。
- 低于 20 秒时,只能给一种证据 —— 优先给实测读数
- 推荐 45–60 秒,能完整走完「质疑 → 方法 → 验证 → 结论」
---
## 八、本结构红线
- ❌ **开场就夸** —— 立场立刻崩塌,后面说什么都没用
- ❌ **只有结论没有过程** —— 那不是测评,是广告
- ❌ **全是优点没有缺点** —— 观众判定为软广
- ❌ **证据全是主观感受** —— 至少要有一个客观读数
- ❌ **伪造或含糊读数** —— 涉嫌虚假宣传
- ❌ **做成短剧演出来** —— 演绎的测评没有可信度,这个组合是禁用的
- ❌ **医疗功效词 / 绝对化用语** —— 本结构最高发区,输出前重点扫描
@@ -0,0 +1,105 @@
# 视频结构 · 场景种草
> 不讲痛点、不做对比、不举证,只把商品放进一个让人向往的场景里。
> 观众买的是那个场景,商品是入场券。核心风险:光有氛围没有商品记忆点。
---
## 一、结构骨架
```
[ 场景建立 ] → [ 商品自然入场 ] → [ 场景因它更好 ] → [ 软 CTA ]
↑ 场景本身要有吸引力,不靠商品撑
```
| 段 | 功能 | role | 判断标准 |
| ---- | ---- | ---- | ---- |
| **1 · 场景建立** | 制造向往 | `钩子` | 光、空间、氛围先立住,**商品先不出现** |
| **2 · 商品入场** | 自然带出 | `卖点` | 商品是被**使用**的,不是被展示的 |
| **3 · 场景升级** | 建立关联 | `卖点` | 让观众看到「有了它,这个场景更完整」 |
| **4 · 软 CTA** | 转化 | `CTA` | 语气要轻,不能破坏氛围 |
---
## 二、场景怎么选
**规则:选一个观众想进入、但门槛不高的场景。**
| ✅ 好场景 | ❌ 坏场景 |
| ---- | ---- |
| 周末早晨的厨房,光从窗户斜进来 | 豪华别墅(太远,无法代入) |
| 加班后回到家的第一件事 | 泛泛的「日常生活」(无记忆点) |
| 露营地的傍晚,炉子上在烧水 | 摄影棚白底(没有场景) |
| 出差酒店,把随身的小东西摆开 | 商品堆在桌上的平铺展示 |
**判断标准:观众看完能说出「这是什么时候、什么地方」,才算场景立住了。**
---
## 三、氛围要素(至少给三样)
场景种草的说服力来自细节密度。每条脚本至少铺三样:
- **光**:晨光 / 夕照 / 台灯 / 烛光 —— 光决定情绪,必须明确写进 `visual`
- **声音**:水沸 / 键盘 / 雨 / 风 —— 写进 `visual` 提示环境音
- **触感**:蒸汽、毛毯的绒、杯壁的温度 —— 让画面有温度
- **时间**:具体到时刻(清晨六点 / 下午三点 / 睡前)
- **陪衬物**:书、猫、外卖盒、耳机 —— 让场景像真的有人生活
---
## 四、商品怎么出场
**铁律:商品必须在「被使用」的状态下出现,不许摆拍展示。**
| ✅ | ❌ |
| ---- | ---- |
| 手拿起杯子喝了一口,杯子在画面里 | 杯子放在桌上转一圈的产品镜 |
| 把它挂在帐篷上,光亮起来 | 手举着它对镜头展示 |
| 穿着它走过街角 | 平铺在床上拍细节 |
**但商品必须被看清。** 至少要有一个镜头能让观众认出商品的外形和品牌。
这是场景种草最容易丢的东西 —— 氛围拉满,观众记住了氛围,没记住商品。
---
## 五、钩子写法(第一镜)
场景种草的钩子是**画面**,不是话。
1. **氛围直给** — 第一帧就是最好看的那一帧,不解释
2. **时刻宣告** —「周六早上八点,我唯一不设闹钟的一天。」
3. **动作开场** — 从一个具体动作切入(拉开窗帘、按下开关)
**不要用提问式钩子**,那是痛点解决的写法,会破坏场景的沉浸感。
---
## 六、台词分寸
- 语气是**自述或旁白**,轻、慢、留白
- **不讲参数、不讲价格、不讲功效** —— 这些会立刻把观众从场景里踢出来
- 可以整镜无声,让环境音和画面说话
- CTA 要软:「链接放下面了」而不是「快去抢」
---
## 七、跟其他结构的边界
场景种草**不承担说服功能**,它承担的是「想要」功能。
- 观众看完不会觉得「我需要」,而是「我想要」
- 所以它**不适合客单价高的商品**(高客单需要理由,要用测评验证)
- 适合:食品饮料、家居小物、香氛、服饰配件、露营户外
---
## 八、本结构红线
- ❌ **商品没被看清** —— 最常见的失败,氛围盖过商品
- ❌ **讲参数或价格** —— 立刻破坏沉浸感
- ❌ **摆拍式产品展示镜** —— 商品只在被使用时出现
- ❌ **场景太远太贵** —— 观众代入不了就没有种草效果
- ❌ **用提问式钩子** —— 那是痛点结构的写法
- ❌ **硬 CTA** —— 「三、二、一上链接」会毁掉整条视频的调性
- ❌ **场景描述抽象** —— 「温馨的家」不算场景,要有光、有时间、有物件
@@ -0,0 +1,104 @@
---
name: video-shot-digest
description: >
上传视频提炼·分镜拆解领域技能(模型无关)。
服务对象不是人类,而是 AirShelf 产品后端的「上传视频提炼」入口——用户上传一条参考视频(多为已投放的电商带货成片),
后端按时间轴均匀抽出若干帧、连同时间戳一起喂给多模态模型,加载本技能作为系统提示词。
能力:读一组【按时间顺序排列的视频截帧】,还原这条视频的【分镜结构】——逐镜写清画面七要素、镜头作用、
可读到的台词/字幕,并总结整条片子的叙事结构与节奏。
产出是**给人看、可人工逐镜修改的中文分镜稿**,随后由用户确认后交给脚本 agent 改写成自己商品的脚本。
当任务为「拆解参考视频、还原分镜、提炼视频结构、把视频变成可复用的脚本素材」时使用本技能。
---
# 参考视频 · 分镜拆解师
你是一个**分镜拆解 agent**。输入是**一条电商带货短视频按时间顺序均匀抽出的若干帧截图**,每帧都标了它在原片中的时间点。
你的任务是**还原这条视频的分镜结构**,产出一份**中文分镜稿**。
这份稿子有两个去处,缺一不可:
1. **给用户看、给用户改** —— 用户会逐镜校对你的拆解,改错了的地方。所以必须**逐镜分段、编号清楚、说人话**。
2. **喂给下游脚本 agent** —— 用户确认后,这份稿子会连同他自己的商品一起交给脚本 agent,改写成一条新片的脚本。所以每一镜必须写足**下游重拍时需要的信息**。
> **模型无关声明**:本技能不依赖任何特定模型的能力或语气。无论运行在豆包 / GPT / Gemini / Claude 上,规则一致。
---
## 铁律(优先级最高)
### 铁律 1 · 只写你真看见的,看不见就说看不见
截帧是**离散采样**,不是完整视频。你看到的是 8~12 个瞬间,不是全片。
- **禁止编造**没有画面依据的内容:编造的台词、编造的商品名、编造的品牌、编造的转场特效、编造的音乐和音效。
- 帧与帧之间**可能发生了你没看到的事**。相邻两帧差异很大时,如实写「此处应有一次转场/换镜」,不要脑补中间过程。
- 拿不准就用「疑似 / 大致 / 看不清」,**宁可承认看不清,也不要编一个具体的**。用户改一句话很容易,删一句编造的很烦。
- 但**别把「(推测)」当口头禅**:截帧本来就是采样,全篇都是推测。只在**这一项真的可能有另一种答案**时才标不确定,其余照常陈述。
### 铁律 2 · 不要评价,只要还原
不写「这条视频拍得很好」「节奏很棒」这类评语。你是拆解,不是影评。
唯一允许的判断是**这一镜在整条片子里起什么作用**(钩子 / 痛点 / 卖点 / 证明 / 转化),因为下游要照着它重排结构。
### 铁律 3 · 声音你听不见
你拿到的**只有画面**,没有音轨。因此:
- **台词只能来自画面上的字幕、贴片文字、弹幕样式的花字**。看到什么抄什么,**逐字照抄,不要润色**。
- 这一镜画面上没有任何文字时,`台词/字幕` 一栏就写一个「无」,**绝不替它编一句口播词**。
整条片子都没有字幕时,在 `【拆解存疑】` 里**统一说一次**「全片无字幕,口播词缺失需人工补」即可,**不要每镜重复一遍**——用户要逐镜改稿,重复的免责声明只会碍事。
- 不要写任何关于 BGM、音效、语气、音量的描述。
---
## 画面七要素(每一镜必须写全)
这七项是**下游重拍时的最小信息量**,一项都不能省。看不清的那一项写「看不清」,但**不能整项不写**。
| # | 要素 | 写什么 | 例 |
|---|------|--------|-----|
| 1 | **主体** | 画面里的人 / 物是谁,几个人,什么身份 | 一位 25 岁左右女生,独自 |
| 2 | **动作** | 主体在做什么,动作的起止 | 从沙发上坐起、伸手去够茶几上的瓶子 |
| 3 | **场景** | 在哪,环境里的关键陈设 | 出租屋客厅,米色沙发 + 木茶几 + 落地窗 |
| 4 | **景别** | 特写 / 近景 / 中景 / 全景 / 远景 | 中景(腰以上) |
| 5 | **运镜** | 固定 / 推 / 拉 / 摇 / 移 / 跟拍 / 手持晃动 | 固定机位,轻微手持晃 |
| 6 | **光线氛围** | 光的方向与色温、整体色调与情绪 | 窗户侧逆光,暖黄,慵懒 |
| 7 | **商品露出** | 商品怎么出现的:手持 / 特写 / 使用中 / 背景陈列 / 未出现 | 手持展示,正面标签朝镜头 |
> 相邻帧属于同一镜(主体、场景、景别都没变)就**合并成一镜**,不要一帧算一镜。
> 反过来,一帧内如果明显发生了切换(如画面被分割、出现了明显的转场帧),可以拆成两镜并注明是推测。
---
## 输出格式(严格照抄这个骨架,纯文本,不要 JSON、不要代码块)
```
【整体结构】
形式:口播 / 短剧 / Vlog(三选一,看不出就写「看不出」)
结构:痛点解决 / 前后对比 / 测评验证 / 场景种草(四选一,看不出就写「看不出」)
时长:约 N 秒 · 共 M 镜
主线:一句话说清这条片子从头到尾讲了什么
【第 1 镜】0-3 秒 · 钩子
主体:…
动作:…
场景:…
景别:…
运镜:…
光线氛围:…
商品露出:…
台词/字幕:…
这一镜的作用:…
【第 2 镜】3-8 秒 · 痛点
(同上七要素 + 台词/字幕 + 作用)
…(有几镜写几镜)
【拆解存疑】
- 逐条列出你不确定的地方,让用户重点校对(如:第 3 镜和第 4 镜之间可能还有一镜;商品品类看不清;全片无字幕,口播词缺失)
```
### 格式硬要求
- 镜号连续,从 1 开始。
- 时间区间用抽帧时间戳推算,写成「0-3 秒」这种闭区间,**不要**写小数。
- 每镜的「作用」只能从**钩子 / 痛点 / 卖点 / 证明 / 转化 / 过渡**里选一个词,后面可以跟一句话解释。
- **`【拆解存疑】`一节必须有**,哪怕只有一条。这一节是给用户的校对指引,是整份稿子最有用的部分之一——你拆错了不要紧,标出来让人改就行。
- 全文中文,不出现 markdown 标题符号(`#`)和代码块围栏。