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

This commit is contained in:
Azmat@qq.com
2026-08-17 18:26:42 +08:00
parent 36e91aab3c
commit d1ecb52125
76 changed files with 4222 additions and 294 deletions
+301 -44
View File
@@ -19,6 +19,7 @@ SSE 事件(每帧 `data: {json}\n\n`,json 带 type):
from __future__ import annotations
import json
import math
import re
from decimal import Decimal
from functools import lru_cache
@@ -34,7 +35,70 @@ from apps.billing.services.ledger import charge_reserved_credit, release_credit
VALID_TONES = ["种草", "测评", "剧情", "痛点"]
VALID_ROLES = ["钩子", "痛点", "卖点", "CTA"]
VALID_ENTITY_TYPES = ["character", "scene", "product"]
DURATION_TIERS = [15, 30, 60, 90]
# 时长:总时长 5–60 秒按 5 秒步进;单镜 4–15 秒(15 是出片模型硬上限,越界下游直接拒片)。
TOTAL_DURATION_MIN = 5
TOTAL_DURATION_MAX = 60
TOTAL_DURATION_STEP = 5
SEGMENT_DURATION_MIN = 4
SEGMENT_DURATION_MAX = 15
DEFAULT_TOTAL_DURATION = 30
# 表现形式 × 视频结构(二期)。key 用 ASCII 找套路文件,label 是给模型和用户看的中文。
PRESENTATION_FORMATS: dict[str, str] = {"oral": "口播", "drama": "短剧", "vlog": "Vlog"}
VIDEO_STRUCTURES: dict[str, str] = {
"pain": "痛点解决",
"contrast": "前后对比",
"review": "测评验证",
"scene": "场景种草",
}
DEFAULT_PRESENTATION_FORMAT = "oral"
DEFAULT_VIDEO_STRUCTURE = "pain"
# 唯一禁用组合:短剧 × 测评验证。演出来的实测没有可信度,详见 playbooks/combo-matrix.md。
FORBIDDEN_COMBOS: set[tuple[str, str]] = {("drama", "review")}
# 表现形式推荐的单镜节奏(秒)。镜数 ≈ 总时长 / 该值,再夹到 4–15 秒的合法区间。
FORMAT_SHOT_PACE: dict[str, int] = {"oral": 12, "drama": 8, "vlog": 7}
# 表现形式推荐的默认总时长:口播短平快,短剧要装下三幕,Vlog 要铺氛围。
FORMAT_DEFAULT_DURATION: dict[str, int] = {"oral": 30, "drama": 45, "vlog": 30}
# 各结构能压到的最短总时长(低于此值证据/氛围不成立),见 playbooks/combo-matrix.md。
STRUCTURE_MIN_DURATION: dict[str, int] = {"pain": 15, "contrast": 10, "review": 20, "scene": 20}
# 可懂语速上限 3.5 字/秒 —— 旁白字数按这一镜自己的秒数算,不再全场 55 字一刀切。
NARRATION_CHARS_PER_SECOND = 3.5
NARRATION_CHARS_HARD_CAP = 55
_FORMAT_KEY_BY_LABEL = {label: key for key, label in PRESENTATION_FORMATS.items()}
_STRUCTURE_KEY_BY_LABEL = {label: key for key, label in VIDEO_STRUCTURES.items()}
def combo_keys(value_format, value_structure) -> tuple[str, str]:
"""把「中文标签或 ASCII key」都归一成 key。落库存的是中文,请求传的是 key,两边都要认。"""
fmt = _FORMAT_KEY_BY_LABEL.get(value_format, value_format)
structure = _STRUCTURE_KEY_BY_LABEL.get(value_structure, value_structure)
return coerce_combo(fmt, structure)
def allowed_structures(fmt: str) -> list[str]:
"""某表现形式下可选的视频结构 key(1.8 组合联动:换表现形式,结构列表跟着变)。"""
fmt = fmt if fmt in PRESENTATION_FORMATS else DEFAULT_PRESENTATION_FORMAT
return [key for key in VIDEO_STRUCTURES if (fmt, key) not in FORBIDDEN_COMBOS]
def coerce_combo(fmt: str | None, structure: str | None) -> tuple[str, str]:
"""把任意输入夹成一组合法的(表现形式, 视频结构)。禁用组合回落到该形式的第一个合法结构。"""
fmt = fmt if fmt in PRESENTATION_FORMATS else DEFAULT_PRESENTATION_FORMAT
structure = structure if structure in VIDEO_STRUCTURES else DEFAULT_VIDEO_STRUCTURE
if (fmt, structure) in FORBIDDEN_COMBOS:
structure = allowed_structures(fmt)[0]
return fmt, structure
def narration_limit(duration: int) -> int:
"""这一镜旁白的字数上限:秒数 × 3.5,且不超过硬上限 55。"""
return max(1, min(NARRATION_CHARS_HARD_CAP, int(duration * NARRATION_CHARS_PER_SECOND)))
# --------------------------------------------------------------------------- #
@@ -53,9 +117,18 @@ def _skill_dir() -> Path:
return base / "skills" / "ecommerce-video-script"
def _read_ref(path: Path, label: str) -> str:
if not path.exists():
return ""
return f"\n\n===== {label} =====\n\n{path.read_text(encoding='utf-8')}"
@lru_cache(maxsize=1)
def load_ecommerce_skill() -> str:
"""读取 SKILL.md + 全部 references 拼成系统提示词(领域知识)。缺文件不致命,尽量给。"""
def _load_skill_base() -> str:
"""SKILL.md + references 根目录下的通用资料(方法论/钩子库/品类/平台/自检),每次都要。
playbooks/ 是子目录,glob("*.md") 不会递归到,套路由 load_ecommerce_skill 按组合单独挑。
"""
skill_dir = _skill_dir()
parts: list[str] = []
main = skill_dir / "SKILL.md"
@@ -64,11 +137,33 @@ def load_ecommerce_skill() -> str:
ref_dir = skill_dir / "references"
if ref_dir.exists():
for ref in sorted(ref_dir.glob("*.md")):
parts.append(f"\n\n===== references/{ref.name} =====\n\n{ref.read_text(encoding='utf-8')}")
if not parts:
parts.append(_read_ref(ref, f"references/{ref.name}"))
return "".join(parts)
@lru_cache(maxsize=32)
def load_ecommerce_skill(
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
) -> str:
"""通用资料 + 组合矩阵 + **只挑被选中的那一份表现形式和那一份视频结构**。
套路全量灌进去会让系统提示词翻倍(每份 2-3K 字),而且模型会在 11 套互相矛盾的
镜头语言里挑花眼。只给当前这一组,提示词更短、约束更硬。
"""
fmt, structure = coerce_combo(presentation_format, video_structure)
playbooks = _skill_dir() / "references" / "playbooks"
parts = [
_load_skill_base(),
_read_ref(playbooks / "combo-matrix.md", "references/playbooks/combo-matrix.md"),
_read_ref(playbooks / f"format-{fmt}.md", f"references/playbooks/format-{fmt}.md"),
_read_ref(playbooks / f"structure-{structure}.md", f"references/playbooks/structure-{structure}.md"),
]
joined = "".join(parts)
if not joined.strip():
# 兜底:skill 文件缺失也能退化生成(交接文档会提示补 skills 目录)
return "你是电商带货短视频脚本生成 agent,输出结构化 ScriptDraft JSON。"
return "".join(parts)
return joined
# 运行时输出协议:优先级高于 skill 里的「只输出 JSON / 不展示思考」,只为流式体感放开一句前言。
@@ -119,12 +214,25 @@ def build_agent_messages(
base_draft: dict | None,
aspect_ratio: str,
total_duration: int,
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
target_index: int | None = None,
) -> list[dict[str, str]]:
system = load_ecommerce_skill() + _OUTPUT_PROTOCOL
fmt, structure = coerce_combo(presentation_format, video_structure)
total = coerce_total_duration(total_duration)
system = load_ecommerce_skill(fmt, structure) + _OUTPUT_PROTOCOL
# 给一组建议时长(不是硬性),模型可以按内容调整,只要每镜 4–15 秒且加总不变。
suggested = plan_segment_durations(total, fmt)
pace_hint = "+".join(str(d) for d in suggested)
head = (
f"【画幅】{aspect_ratio}\n"
f"【总时长】{total_duration} 秒(每 15 秒一镜,共 {total_duration // 15} 镜)\n"
f"【表现形式】{PRESENTATION_FORMATS[fmt]}(套路见 playbooks/format-{fmt}.md,已加载)\n"
f"【视频结构】{VIDEO_STRUCTURES[structure]}(套路见 playbooks/structure-{structure}.md,已加载)\n"
f"【总时长】{total} 秒\n"
f"【分镜时长】单镜 4–15 秒,可以不等长;各镜相加必须精确等于 {total} 秒。\n"
f" 建议切成 {len(suggested)} 镜({pace_hint}),这是按「{PRESENTATION_FORMATS[fmt]}」的节奏算的;\n"
f" 你可以按内容调整镜数与每镜长短(该长的给足、该短的压短),但必须守住上面两条硬约束。\n"
f"【商品信息】\n{_product_context(project, selling_point_ids)}"
)
if mode == "revise" and base_draft and target_index is not None:
@@ -154,7 +262,8 @@ def build_agent_messages(
)
else:
user = (
"【任务】全自动(模式①):仅凭商品与前置条件,自动定档/选 tone/造 entity/填黄金结构。\n"
"【任务】全自动(模式①):仅凭商品与前置条件,按指定的表现形式与视频结构套路"
"自动定镜数/选 tone/造 entity/填结构骨架。\n"
f"{head}\n\n"
"请按技能流程一次性产出 ScriptDraft。"
)
@@ -217,14 +326,87 @@ def _extract_json(text: str) -> str | None:
return _balanced_object(text)
def _nearest_duration(value) -> int:
def coerce_total_duration(value) -> int:
"""总时长夹到 5–60 秒、5 秒步进。空值/0/非法输入一律回落默认 30。"""
if value in (None, "", 0):
return DEFAULT_TOTAL_DURATION
try:
value = int(value)
except (TypeError, ValueError):
return 60
if value in DURATION_TIERS:
return value
return min(DURATION_TIERS, key=lambda t: abs(t - value))
return DEFAULT_TOTAL_DURATION
if value <= 0:
return DEFAULT_TOTAL_DURATION
value = max(TOTAL_DURATION_MIN, min(TOTAL_DURATION_MAX, value))
stepped = int(round(value / TOTAL_DURATION_STEP) * TOTAL_DURATION_STEP)
return max(TOTAL_DURATION_MIN, min(TOTAL_DURATION_MAX, stepped))
def plan_segment_durations(total_duration: int, presentation_format: str) -> list[int]:
"""把总时长切成每镜 4–15 秒、加总精确等于总时长的一组时长。
镜数按表现形式的推荐节奏定(口播 12s/镜、短剧 8s/镜、Vlog 7s/镜),再夹进
ceil(total/15) ~ total//4 的合法区间。余数摊到前面几镜,所以镜与镜之间最多差 1 秒——
这只是**兜底**,模型自己给的不等长时长只要合法就照用。
"""
total = coerce_total_duration(total_duration)
fmt = presentation_format if presentation_format in FORMAT_SHOT_PACE else DEFAULT_PRESENTATION_FORMAT
count_min = math.ceil(total / SEGMENT_DURATION_MAX)
count_max = max(count_min, total // SEGMENT_DURATION_MIN)
count = max(1, round(total / FORMAT_SHOT_PACE[fmt]))
count = max(count_min, min(count_max, count))
base, remainder = divmod(total, count)
return [base + 1 if i < remainder else base for i in range(count)]
def plan_roles(count: int) -> list[str]:
"""镜数 → role 序列。通用规则:首钩子、次痛点、末 CTA,中间全是卖点。"""
if count <= 1:
return ["钩子"]
if count == 2:
return ["钩子", "卖点"]
if count == 3:
return ["钩子", "卖点", "CTA"]
return ["钩子", "痛点"] + ["卖点"] * (count - 3) + ["CTA"]
def _fit_segment_durations(raw: list, total: int, presentation_format: str) -> list[int]:
"""采纳模型给的每镜时长(允许不等长),非法就修;修不动就整组回落到 plan_segment_durations。
合法定义:每镜 4–15 秒的整数,且加总 == 总时长。模型很容易把总数算错一两秒,
所以先夹单镜范围,再把差额摊到还有余量的镜上,尽量保住模型的节奏意图。
"""
if not raw:
return plan_segment_durations(total, presentation_format)
durations: list[int] = []
for value in raw:
try:
seconds = int(value)
except (TypeError, ValueError):
seconds = 0
durations.append(max(SEGMENT_DURATION_MIN, min(SEGMENT_DURATION_MAX, seconds or SEGMENT_DURATION_MIN)))
# 镜数本身就装不下总时长(太少会超 15s/镜,太多会低于 4s/镜)→ 模型节奏不可用,整组重排。
count = len(durations)
if not (count * SEGMENT_DURATION_MIN <= total <= count * SEGMENT_DURATION_MAX):
return plan_segment_durations(total, presentation_format)
diff = total - sum(durations)
while diff != 0:
step = 1 if diff > 0 else -1
# 每轮只给「还有余量」的镜加/减 1 秒,均匀铺开,避免把某一镜顶到边界
movable = [
i for i, d in enumerate(durations)
if (step > 0 and d < SEGMENT_DURATION_MAX) or (step < 0 and d > SEGMENT_DURATION_MIN)
]
if not movable:
return plan_segment_durations(total, presentation_format)
for i in movable:
if diff == 0:
break
durations[i] += step
diff -= step
return durations
# 模型每次生成都可能换字段名(scene/screenDescription/visual…、dialogue/lines/caption…),
@@ -306,7 +488,14 @@ def _resolve_segments(draft: dict) -> list:
return best
def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) -> dict:
def normalize_draft(
raw_text: str,
*,
aspect_ratio: str,
total_duration: int,
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
) -> dict:
"""把模型输出抽成 JSON 并按铁律1契约规范化。宽容:小问题就地修,不轻易抛错。"""
blob = _extract_json(raw_text)
if not blob:
@@ -332,10 +521,12 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) ->
draft["segments"] = _resolve_segments(draft)
draft["aspect_ratio"] = (draft.get("aspect_ratio") or aspect_ratio or "9:16").strip()
dur = _nearest_duration(draft.get("total_duration") or total_duration)
# 总时长以「请求参数」为准:模型经常把它算错,而下游出片/计价都按这个数走。
dur = coerce_total_duration(total_duration)
draft["total_duration"] = dur
seg_count = max(1, dur // 15)
draft["segment_count"] = seg_count
fmt, structure = coerce_combo(presentation_format, video_structure)
draft["presentation_format"] = PRESENTATION_FORMATS[fmt]
draft["video_structure"] = VIDEO_STRUCTURES[structure]
tone = (draft.get("tone") or "").strip()
draft["tone"] = tone if tone in VALID_TONES else "种草"
draft["hook"] = (draft.get("hook") or "").strip()
@@ -367,15 +558,21 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) ->
draft["entities"] = norm_entities
valid_ids = {e["id"] for e in norm_entities}
# segments 规范化:对齐镜数,role 枚举,引用合法
# segments 规范化:镜数交给模型(只夹进合法区间),role 枚举,引用合法
segments = draft.get("segments") if isinstance(draft.get("segments"), list) else []
# 镜数上下限由「单镜 4–15 秒」倒推:少于 count_min 会有镜超 15 秒,多于 count_max 会有镜不足 4 秒。
count_min = math.ceil(dur / SEGMENT_DURATION_MAX)
count_max = max(count_min, dur // SEGMENT_DURATION_MIN)
segments = segments[:count_max]
seg_count = max(count_min, len(segments))
role_plan = plan_roles(seg_count)
norm_segments: list[dict] = []
for i, seg in enumerate(segments[:seg_count]):
for i, seg in enumerate(segments):
if not isinstance(seg, dict):
seg = {}
role = (seg.get("role") or "").strip()
if role not in VALID_ROLES:
role = VALID_ROLES[min(i, len(VALID_ROLES) - 1)]
role = role_plan[i]
speaker = seg.get("speaker")
speaker = speaker if (speaker in valid_ids) else None
refs = [r for r in (seg.get("entity_refs") or []) if r in valid_ids]
@@ -406,7 +603,7 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) ->
norm_segments.append(
{
"index": i,
"duration": 15,
"duration": seg.get("duration"), # 先原样收着,等镜数定了再统一夹进 4–15 秒并配平总时长
"role": role,
"narration": narration,
"speaker": speaker,
@@ -416,14 +613,14 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) ->
"dialogue": dialogue,
}
)
# 不足镜数则补占位镜(极少发生,避免下游镜数对不上)
# 不足下限则补占位镜(极少发生,避免出现超过 15 秒的镜导致下游拒片)
while len(norm_segments) < seg_count:
i = len(norm_segments)
norm_segments.append(
{
"index": i,
"duration": 15,
"role": VALID_ROLES[min(i, len(VALID_ROLES) - 1)],
"duration": None,
"role": role_plan[i],
"narration": "",
"speaker": None,
"visual": "",
@@ -434,11 +631,26 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) ->
)
if not norm_segments:
raise ValueError("脚本没有任何分镜")
# 每镜时长:采纳模型的不等长意图,非法就修,修不动整组回落到按表现形式节奏均切。
fitted = _fit_segment_durations([s["duration"] for s in norm_segments], dur, fmt)
for seg, seconds in zip(norm_segments, fitted):
seg["duration"] = seconds
draft["segments"] = norm_segments
draft["segment_count"] = len(norm_segments)
return draft
def _merge_single_segment(base: dict, new: dict, idx: int, aspect_ratio: str, total_duration: int) -> dict:
def _merge_single_segment(
base: dict,
new: dict,
idx: int,
aspect_ratio: str,
total_duration: int,
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
) -> dict:
"""精准改一镜:以基准稿为底,只用新稿的第 idx 镜替换,其余镜逐字保持;合并新稿引入的新 entity(对白可能加角色)。再整体规范化。"""
merged = json.loads(json.dumps(base)) # 深拷贝
base_ids = {e.get("id") for e in merged.get("entities", []) if isinstance(e, dict)}
@@ -459,7 +671,13 @@ def _merge_single_segment(base: dict, new: dict, idx: int, aspect_ratio: str, to
target["index"] = idx
segs[idx] = target
merged["segments"] = segs
return normalize_draft(json.dumps(merged, ensure_ascii=False), aspect_ratio=aspect_ratio, total_duration=total_duration)
return normalize_draft(
json.dumps(merged, ensure_ascii=False),
aspect_ratio=aspect_ratio,
total_duration=total_duration,
presentation_format=presentation_format,
video_structure=video_structure,
)
# --------------------------------------------------------------------------- #
@@ -496,14 +714,17 @@ def persist_script_draft(*, project, user, task, draft: dict, source: str):
task=task,
title=(draft.get("hook") or "AI 脚本")[:128],
content=json.dumps(draft, ensure_ascii=False, indent=2),
source=source if source in ("ai", "theme", "manual", "revise") else "ai",
source=source if source in ("ai", "theme", "manual", "video", "revise") else "ai",
is_adopted=False,
metadata={
"hook": draft.get("hook", ""),
"tone": draft.get("tone", ""),
"aspect_ratio": draft.get("aspect_ratio", "9:16"),
"total_duration": draft.get("total_duration", 60),
"segment_count": draft.get("segment_count", 4),
"total_duration": draft.get("total_duration", DEFAULT_TOTAL_DURATION),
"segment_count": draft.get("segment_count", len(draft.get("segments") or [])),
# 二期:表现形式 × 视频结构 跟着稿子走,改稿和「保存模板」都要读它
"presentation_format": draft.get("presentation_format", ""),
"video_structure": draft.get("video_structure", ""),
"entities": draft.get("entities", []),
},
)
@@ -511,7 +732,7 @@ def persist_script_draft(*, project, user, task, draft: dict, source: str):
ScriptSegment.objects.create(
script_version=script,
sort_order=seg["index"],
duration_seconds=seg.get("duration", 15),
duration_seconds=seg.get("duration") or SEGMENT_DURATION_MAX,
narration=seg.get("narration", ""),
visual_prompt=seg.get("visual", ""),
role=seg.get("role", ""),
@@ -570,15 +791,20 @@ def stream_script_agent(
selling_point_ids: list[str] | None = None,
base_version_id: str | None = None,
aspect_ratio: str = "9:16",
total_duration: int = 60,
total_duration: int = DEFAULT_TOTAL_DURATION,
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
target_index: int | None = None,
entry_source: str = "",
):
"""生成 SSE 帧字符串的同步生成器,供 StreamingHttpResponse 包裹。
target_index 非空 = 精准只改第 N 镜(读全脚本上下文,后端强制保留其余镜原样)。"""
from apps.ai.services import create_ai_task, stream_routed_text_request
yield _sse({"type": "tool", "id": "skill", "label": "加载电商脚本技能", "status": "running"})
skill_loaded = bool(load_ecommerce_skill())
fmt, structure = coerce_combo(presentation_format, video_structure)
yield _sse({"type": "tool", "id": "skill", "label": f"加载套路:{PRESENTATION_FORMATS[fmt]} · {VIDEO_STRUCTURES[structure]}", "status": "running"})
skill_loaded = bool(load_ecommerce_skill(fmt, structure))
yield _sse({"type": "tool", "id": "skill", "status": "done" if skill_loaded else "error"})
yield _sse({"type": "tool", "id": "analyze", "label": f"分析商品:{project.product.title}", "status": "running"})
@@ -587,8 +813,10 @@ def stream_script_agent(
base_draft = _load_base_draft(project, base_version_id)
if base_draft is None:
target_index = None # 没有基准稿就退回整版生成,单镜改无从谈起
# 改稿以基准稿的时长/镜数为准,避免请求侧默认值(前端可能硬编码 60)把 90s/6镜稿的尾镜挤掉
effective_duration = (base_draft.get("total_duration") if base_draft else None) or total_duration
# 改稿以基准稿的时长/镜数为准,避免请求侧默认值把长稿的尾镜挤掉
effective_duration = coerce_total_duration(
(base_draft.get("total_duration") if base_draft else None) or total_duration
)
# 精准改一镜:镜号越界直接报错返回,绝不建任务/扣费(避免计费空转的静默 no-op)
if target_index is not None and base_draft is not None:
seg_n = len(base_draft.get("segments", []))
@@ -602,7 +830,9 @@ def stream_script_agent(
selling_point_ids=selling_point_ids,
base_draft=base_draft,
aspect_ratio=aspect_ratio,
total_duration=effective_duration, # 改稿用基准稿时长,prompt head 才不会误导模型镜数(否则模型按60s只出4镜)
total_duration=effective_duration, # 改稿用基准稿时长,prompt head 才不会误导模型镜数
presentation_format=fmt,
video_structure=structure,
target_index=target_index,
)
yield _sse({"type": "tool", "id": "analyze", "status": "done"})
@@ -648,6 +878,8 @@ def stream_script_agent(
raw_text,
aspect_ratio=aspect_ratio,
total_duration=effective_duration,
presentation_format=fmt,
video_structure=structure,
)
if target_index is not None and base_draft:
return _merge_single_segment(
@@ -656,6 +888,8 @@ def stream_script_agent(
target_index,
aspect_ratio,
effective_duration,
fmt,
structure,
)
return candidate
@@ -735,7 +969,14 @@ def stream_script_agent(
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
source = "revise" if mode == "revise" else ("theme" if mode == "theme" else "ai")
# 三个入口(辅助生成 / 上传脚本 / 上传视频提炼)都走 mode=auto,只有 entry_source
# 分得清是哪个来的 —— 脚本卡的「来源」徽标靠它,别一律记成 ai。
if mode == "revise":
source = "revise"
elif mode == "theme":
source = "theme"
else:
source = entry_source if entry_source in {"manual", "video"} else "ai"
script = persist_script_draft(project=project, user=user, task=task, draft=draft, source=source)
settled = True # charge 已提交
except Exception as exc: # noqa: BLE001 — 落库失败:atomic 已回滚 charge,补释放预留
@@ -819,20 +1060,24 @@ def _load_base_draft(project, base_version_id: str) -> dict | None:
def _draft_from_version(version) -> dict:
"""从 ScriptVersion 的 DB 行(segments + metadata)重建 ScriptDraft —— 比解析可能已 stale 的 content 可靠
(用户增删/改镜后 content 不一定同步)。total_duration 按真实镜数算,避免 normalize 按 stale 值截/补镜。"""
(用户增删/改镜后 content 不一定同步)。total_duration 按各镜真实秒数加总,避免 normalize 按 stale 值截/补镜。"""
meta = version.metadata or {}
segs = list(version.segments.order_by("sort_order"))
# 镜可以不等长了,总时长必须按实际相加(旧写法 15×镜数 会在不等长稿上算出错误总时长)
actual_total = sum(s.duration_seconds or SEGMENT_DURATION_MAX for s in segs)
return {
"hook": meta.get("hook", ""),
"tone": meta.get("tone", ""),
"presentation_format": meta.get("presentation_format", ""),
"video_structure": meta.get("video_structure", ""),
"aspect_ratio": meta.get("aspect_ratio", "9:16"),
"total_duration": max(int(meta.get("total_duration") or 0), 15 * len(segs)) or 60,
"total_duration": actual_total or coerce_total_duration(meta.get("total_duration")),
"segment_count": len(segs),
"entities": meta.get("entities", []),
"segments": [
{
"index": s.sort_order,
"duration": s.duration_seconds or 15,
"duration": s.duration_seconds or SEGMENT_DURATION_MAX,
"role": s.role or "",
"narration": s.narration or "",
"speaker": s.speaker or None,
@@ -860,7 +1105,9 @@ def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, se
seg_n = len(base_draft.get("segments") or [])
if not (0 <= target_index < seg_n):
raise ValueError(f"镜号越界:第 {target_index + 1} 镜(共 {seg_n} 镜)")
total_duration = base_draft["total_duration"] # 已按真实镜数算,normalize 不会截掉用户增删后的镜
total_duration = base_draft["total_duration"] # 已按各镜真实秒数加总,normalize 不会截掉用户增删后的镜
# 改一镜要沿用原稿的套路,否则重写出来的那一镜镜头语言会跟其余镜打架
fmt, structure = combo_keys(base_draft.get("presentation_format"), base_draft.get("video_structure"))
messages = build_agent_messages(
project=project,
@@ -870,6 +1117,8 @@ def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, se
base_draft=base_draft,
aspect_ratio=aspect_ratio,
total_duration=total_duration,
presentation_format=fmt,
video_structure=structure,
target_index=target_index,
)
task = create_ai_task(
@@ -897,8 +1146,16 @@ def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, se
task.submitted_at = timezone.now()
task.save(update_fields=["status", "submitted_at", "updated_at"])
def validate_segment_text(raw_text: str) -> dict:
candidate = normalize_draft(raw_text, aspect_ratio=aspect_ratio, total_duration=total_duration)
return _merge_single_segment(base_draft, candidate, target_index, aspect_ratio, total_duration)
candidate = normalize_draft(
raw_text,
aspect_ratio=aspect_ratio,
total_duration=total_duration,
presentation_format=fmt,
video_structure=structure,
)
return _merge_single_segment(
base_draft, candidate, target_index, aspect_ratio, total_duration, fmt, structure
)
routed = execute_routed_text_request(
task=task,