大量修改二期功能清单内容
This commit is contained in:
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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。"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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` 取 **5–60 秒之间的 5 的倍数**(5/10/15/…/60),输入未指定时默认 `30`。**不要写死。**
|
||||
- **单镜时长 4–15 秒,允许不等长**:每个 `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 秒**:一段话写清 ①主体+动作 ②景别/运镜(特写/全景/手持跟拍/推拉摇,至少给一个镜头语言)③一个画面或情绪的变化(从…到…)。约 **40–70 字**,**禁止只写一句静态动作**(如「女主举起商品展示」撑不住 15 秒,要补镜头与变化)。注意:这是给生图/视频导演的画面,**不占 narration 的 55 字额度**。
|
||||
- **每镜 `visual` 要够厚撑满这一镜的秒数**:一段话写清 ①主体+动作 ②景别/运镜(特写/全景/手持跟拍/推拉摇,至少给一个镜头语言)③一个画面或情绪的变化(从…到…)。约 **40–70 字**,**禁止只写一句静态动作**(如「女主举起商品展示」撑不住十几秒,要补镜头与变化)。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**) |
|
||||
| 总时长 | **5–60 秒,5 秒步进**,由输入给定;未指定时默认 30 |
|
||||
| 表现形式 | **口播 / 短剧 / Vlog**,由输入给定;未指定时按品类人群自选 |
|
||||
| 视频结构 | **痛点解决 / 前后对比 / 测评验证 / 场景种草**,由输入给定;未指定时按品类人群自选 |
|
||||
| 分镜 | **单镜 4–15 秒,可不等长**;镜数按表现形式的推荐节奏定(见方法论) |
|
||||
| 镜头功能 | 钩子 → 痛点 → 卖点 → 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)与总时长(5–60 秒,未指定默认 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` 是 **5–60 之间 5 的倍数**。
|
||||
- [ ] **每个 `segment.duration` 是 4–15 之间的整数**(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。
|
||||
- **单镜时长 4–15 秒**,这是下游出片模型的硬上限,**任何一镜都不许超过 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 秒
|
||||
- 场景可以有两个(如「工位」→「家里」),但不要超过两个,换太多观众会晕
|
||||
|
||||
---
|
||||
|
||||
## 五、节奏与单镜时长
|
||||
|
||||
- **单镜 6–10 秒**(短剧靠切换制造节奏,镜头长了就拖)
|
||||
- 一镜一个动作或一次对话交换,**别在一镜里塞完整对话**
|
||||
- 有对白的镜,每句 `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 | 近景回到人 | 眼睛回到镜头,语速放慢 |
|
||||
|
||||
**铁律:讲卖点的那一镜,商品必须实体出现在画面里被手拿着。** 光靠嘴说卖点、
|
||||
画面还是大头,是口播最常见的失败。
|
||||
|
||||
---
|
||||
|
||||
## 四、节奏与单镜时长
|
||||
|
||||
- **单镜 10–15 秒**(口播里说完一个完整意思需要时间,切太碎会让话说不完整)
|
||||
- 一句话讲一件事,**一镜讲一到两句话**
|
||||
- 语速按 **3.5 字 / 秒** 估:12 秒的镜 ≈ 42 字,别超
|
||||
|
||||
**推荐总时长 30 秒。** 口播超过 45 秒完播率掉得很快,除非是测评验证这种
|
||||
需要举证的结构。
|
||||
|
||||
---
|
||||
|
||||
## 五、发声方式
|
||||
|
||||
- `narration` 全填,`dialogue` **永远留空 `[]`**(只有一个人,没有对话)
|
||||
- `speaker` 指向那个 character 的 id(不是画外音,是这个人在说)
|
||||
- 口播不用画外旁白 —— 人就在画面里,再加一层旁白会精神分裂
|
||||
|
||||
---
|
||||
|
||||
## 六、口播专属红线
|
||||
|
||||
- ❌ **开场自我介绍 + 寒暄** —— 前 3 秒必须是钩子,不是「大家好」
|
||||
- ❌ **一个机位怼到底** —— 至少 3 次景别变化
|
||||
- ❌ **念稿腔** —— 短句、口语、允许有语气词(「说真的」「你听我讲完」)
|
||||
- ❌ **多个人轮流说** —— 那是短剧或访谈,不是口播
|
||||
- ❌ **台词写成书面语** —— 「其具备优异的保湿性能」→「它是真的锁水,我这脸一天不干」
|
||||
@@ -0,0 +1,85 @@
|
||||
# 表现形式 · Vlog
|
||||
|
||||
> 第一人称的生活记录。镜头是「我的眼睛」,观众跟着我过一段生活,商品自然地出现在里面。
|
||||
> 广告感最低的形式,适合高频消费品和生活方式类商品。
|
||||
|
||||
---
|
||||
|
||||
## 一、本质
|
||||
|
||||
Vlog 卖的是**生活方式**,不是商品参数。观众想要的是「我也想过这样的日子」,
|
||||
商品是这种生活的一部分,顺手被带走。
|
||||
|
||||
**判断标准:** 如果观众看完记住的是「这个人的生活真好」而不是「这个商品参数真好」,
|
||||
但依然想买,那 Vlog 就写对了。**Vlog 不许硬推。**
|
||||
|
||||
---
|
||||
|
||||
## 二、人物设置
|
||||
|
||||
- **一个第一人称主角**,通常出镜但不总是对着镜头说话
|
||||
- 主角可以是**半出镜**的:只有手、只有背影、只有侧脸 —— 这反而更有代入感
|
||||
- 可以有配角,但配角是**生活的一部分**(室友、同事、宠物),不承担推销功能
|
||||
- 主角身份靠**生活细节**建立,不靠自我介绍:
|
||||
- ✅ 画面里是加班到十点的工位、外卖盒、屏幕上的表格
|
||||
- ❌ 台词说「我是一个每天加班的上班族」
|
||||
|
||||
---
|
||||
|
||||
## 三、时间线结构
|
||||
|
||||
Vlog 靠**时间推进**组织,不靠论证推进。这是它跟口播、短剧最大的区别。
|
||||
|
||||
常用时间骨架(选一条,别混):
|
||||
|
||||
| 骨架 | 适合品类 | 节点示例 |
|
||||
| ---- | ---- | ---- |
|
||||
| **一天** | 日用 / 护肤 / 食品 / 咖啡 | 早上起床 → 出门 → 午间 → 下班 → 睡前 |
|
||||
| **一次外出** | 服饰 / 箱包 / 户外 / 相机 | 出门前准备 → 路上 → 到达 → 回程 |
|
||||
| **一件事** | 厨具 / 家居 / 工具 | 决定做 → 准备 → 过程 → 完成 |
|
||||
|
||||
**商品出现在某个时间节点上,作为「这个时刻我在用它」**,不单独开一段介绍。
|
||||
|
||||
---
|
||||
|
||||
## 四、镜头语言
|
||||
|
||||
Vlog 的镜头必须有**手持感和生活质感**,这是它的可信度来源。
|
||||
|
||||
- **手持跟拍**为主,允许轻微晃动
|
||||
- 大量**空镜和细节镜**:窗外的光、杯子上的水汽、桌面一角 —— 这些是 Vlog 的质感
|
||||
- 商品**不给硬广式特写**,给「使用中的手部特写」
|
||||
- 光线尽量自然光,时间感要对(早上是冷白光,傍晚是暖黄光)
|
||||
|
||||
---
|
||||
|
||||
## 五、节奏与单镜时长
|
||||
|
||||
- **单镜 5–8 秒**(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 标题符号(`#`)和代码块围栏。
|
||||
Reference in New Issue
Block a user