完成极速成片

This commit is contained in:
Azmat@qq.com
2026-08-25 18:46:20 +08:00
parent df6784b90c
commit 2f70d3e8a0
15 changed files with 495 additions and 146 deletions
+33 -7
View File
@@ -81,7 +81,7 @@ PERSONA_BRIEFS: dict[str, str] = {
"urban": "25–32岁都市白领,工位或下班回家,说话像跟同事吐槽,不要主播腔",
"bestie": "闺蜜分享口吻,带点兴奋,爱用「你懂的」「我跟你讲」",
"ceo": "利落、判断句、少形容词,像拍板不是带货",
"reviewer": "先讲怎么试的再给结论,允许提一个小缺点才可信",
"reviewer": "先讲怎么试的再给结论;可信度来自过程、适用边界或意外发现,不编造小缺点",
"mom": "带娃/家务间隙,讲省事,孩子或家人能沾边",
"genz": "宿舍或通勤,短句,像给朋友发语音条",
}
@@ -103,10 +103,18 @@ _SHOT_SIZE_MARKERS = (
"手持", "跟拍", "俯拍", "仰拍", "推近", "拉远", "摇",
)
_MINOR_CHARACTER_RE = re.compile(
r"(?:婴儿|宝宝|宝贝|幼儿|儿童|小孩|小朋友|未成年|男童|女童|baby|toddler|infant)"
r"|(?:[0-9零一二三四五六七八九十]{1,3}\s*岁)",
r"(?:婴儿|宝宝|宝贝|幼儿|儿童|小孩|小朋友|未成年|男童|女童|baby|toddler|infant)",
re.IGNORECASE,
)
_NUMERIC_AGE_RE = re.compile(r"(?<!\d)(\d{1,2})\s*岁")
_CHINESE_MINOR_AGE_RE = re.compile(r"(?:[零一二三四五六七八九]岁|十岁|十[一二三四五六七]岁)")
_CANNED_DEFECT_RE = re.compile(r"唯一(?:的)?(?:小)?(?:缺点|不足|遗憾)")
_NATURAL_TURN_PHRASES = (
"我会额外留意的使用细节",
"换个场景更能看出差别的地方",
"下单前可以先确认的一点",
"实测时我更在意的细节",
)
_FORMAT_KEY_BY_LABEL = {label: key for key, label in PRESENTATION_FORMATS.items()}
@@ -231,7 +239,10 @@ def format_visual_beats(beats: list[tuple[int, int, str]]) -> str:
def _is_minor_character(name: str, visual_prompt: str) -> bool:
"""角色基础资产不得是未成年人,避免真人生图审核拦截与儿童肖像风险。"""
return bool(_MINOR_CHARACTER_RE.search(f"{name or ''} {visual_prompt or ''}"))
text = f"{name or ''} {visual_prompt or ''}"
if _MINOR_CHARACTER_RE.search(text) or _CHINESE_MINOR_AGE_RE.search(text):
return True
return any(int(age) < 18 for age in _NUMERIC_AGE_RE.findall(text))
def _product_only_visual(duration: int) -> str:
@@ -253,6 +264,14 @@ def _product_only_visual(duration: int) -> str:
return "【本镜任务】用商品本身传达关键信息,不出现人物。\n【声音】旁白继续,画面不出现未成年人。\n【画面内容】\n" + format_visual_beats(beats)
def _replace_canned_defect_phrase(text: str) -> str:
"""不改模型给出的事实,只替换会让测评显得千篇一律的“唯一缺点”话术。"""
if not text or not _CANNED_DEFECT_RE.search(text):
return text
variant = _NATURAL_TURN_PHRASES[sum(map(ord, text)) % len(_NATURAL_TURN_PHRASES)]
return _CANNED_DEFECT_RE.sub(variant, text)
# --------------------------------------------------------------------------- #
# skill 加载(缓存)
# --------------------------------------------------------------------------- #
@@ -613,6 +632,11 @@ def build_agent_messages(
f"严格按已加载的「{PRESENTATION_FORMATS[fmt]} × {VIDEO_STRUCTURES[structure]}」套路写,"
f"不要串成别的结构的套话。\n"
)
authenticity_line = (
"【真实感转折】禁止使用「唯一缺点/唯一不足/唯一的小遗憾」这类模板句,也不要为了显得真实而编造缺点。"
"每版从以下角度自然选一个推进:测试过程里的意外发现、适用人群的边界、不同使用场景的反差、"
"一个可观察的细节、或使用习惯建议;必须由商品资料或画面可观察事实支持,不能每镜重复同一种。\n"
)
head = (
f"【画幅】{aspect_ratio}\n"
f"【表现形式】{PRESENTATION_FORMATS[fmt]}(套路见 playbooks/format-{fmt}.md,已加载)\n"
@@ -623,6 +647,7 @@ def build_agent_messages(
f"{beats_line}"
f"{structure_line}"
f"{combo_line}"
f"{authenticity_line}"
f"【商品信息】\n{_product_context(project, selling_point_ids, persona)}"
)
if mode == "revise" and base_draft and target_index is not None:
@@ -1196,7 +1221,7 @@ def normalize_draft(
if not line:
continue
sp = d.get("speaker")
dialogue.append({"speaker": sp if sp in valid_ids else None, "line": line})
dialogue.append({"speaker": sp if sp in valid_ids else None, "line": _replace_canned_defect_phrase(line)})
# 旁白:结构化对白/lines 优先,其次整句字符串 dialogue,再退到通用字段解析(narration/voiceover/caption/字幕…)
narration = ""
if isinstance(raw_dialogue, str) and raw_dialogue.strip():
@@ -1205,8 +1230,9 @@ def normalize_draft(
narration = " ".join(d["line"] for d in dialogue) # 扁平拼接,兼容下游字幕/配音
if not narration:
narration = _pick_field(seg, _NARRATION_EXACT, _NARRATION_FUZZY)
narration = _replace_canned_defect_phrase(narration)
# 画面:优先收成秒级分镜;beats 数组会折进 visual,下游故事板/视频直接读这一段。
visual = compose_segment_visual(seg)
visual = _replace_canned_defect_phrase(compose_segment_visual(seg))
norm_segments.append(
{
"index": i,
@@ -1255,7 +1281,7 @@ def normalize_draft(
elif index < len(segments) and isinstance(segments[index], dict):
composed = compose_segment_visual(segments[index], seconds)
if composed:
norm["visual"] = composed
norm["visual"] = _replace_canned_defect_phrase(composed)
draft["segments"] = norm_segments
draft["segment_count"] = len(norm_segments)
+73 -26
View File
@@ -2217,10 +2217,20 @@ def generate_person_triview(*, project, user, portrait_asset) -> AITask:
ref_url = _asset_preview_url(portrait_asset)
# 人物三视图提示词:正文可在 admin「提示词」页改(无占位符)
tri_prompt = render_prompt("person_triview", THREE_VIEW_PROMPT)
portrait_label = ""
for group in project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PERSON):
meta = group.metadata or {}
if meta.get("triview_of"):
continue
candidates = [str(value) for value in (group.candidate_assets or [])]
if str(group.adopted_asset_id or "") == asset_key or asset_key in candidates:
portrait_label = str(meta.get("label") or "")
break
payload = {
"model": model_config.name,
"prompt": tri_prompt,
"kind": "person",
"label": portrait_label or str(portrait_asset.name or ""),
"triview_of": asset_key,
"reference_image": ref_url,
"model_routing_v1": True,
@@ -3234,6 +3244,26 @@ def collect_video_review_blockers(project, only_segment: "VideoSegment | None" =
return blockers
_VIDEO_INFLIGHT_STATUSES = {
AITask.Status.CREATED,
AITask.Status.RESERVED,
AITask.Status.SUBMITTED,
AITask.Status.POLLING,
AITask.Status.POSTPROCESSING,
}
def video_segment_has_inflight_task(video_segment: VideoSegment) -> bool:
"""同一镜头是否已有在途视频任务。防止极速成片并发推进把同一段提交两次。"""
segment_id = str(video_segment.id)
tasks = AITask.objects.filter(
project_id=video_segment.project_id,
task_type=AITask.Type.VIDEO_SEGMENT,
status__in=_VIDEO_INFLIGHT_STATUSES,
).only("request_payload")
return any(str((task.request_payload or {}).get("video_segment_id") or "") == segment_id for task in tasks)
def submit_video_segment(
*,
video_segment: VideoSegment,
@@ -3261,7 +3291,17 @@ def submit_video_segment(
model_config = get_default_model(ModelConfig.Capability.VIDEO)
if model_config is None:
raise ValueError("no active video model configured")
project = video_segment.project
with transaction.atomic():
video_segment = VideoSegment.objects.select_for_update().select_related("project").get(pk=video_segment.pk)
project = video_segment.project
if video_segment.status in {VideoSegment.Status.RUNNING, VideoSegment.Status.SUCCEEDED}:
return None
if video_segment_has_inflight_task(video_segment):
return None
if video_segment.status != VideoSegment.Status.QUEUED:
video_segment.status = VideoSegment.Status.QUEUED
video_segment.save(update_fields=["status", "updated_at"])
# 衔接:按 sort_order 把视频段绑到对应脚本镜,并织出跟住该镜的提示词。
scene = None
@@ -3289,31 +3329,38 @@ def submit_video_segment(
references=[],
team=project.team,
)
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.VIDEO_SEGMENT,
model_config=model_config,
quote=quote,
reserve_amount=video_reserve_amount(quote.points),
request_payload={
"model": model_config.name,
"endpoint": model_config.endpoint,
"prompt": final_prompt,
"duration": video_segment.target_duration_seconds,
"ratio": aspect_ratio,
"resolution": resolution,
"estimated_tokens": est_tokens,
# 团队价格系数快照:按实结算用它,中途改价不影响在途任务(jimeng 同款纪律)
"price_multiplier": quote.meta.get("price_multiplier", "1"),
"video_segment_id": str(video_segment.id),
"reference_images": reference_images,
"model_routing_v1": True,
},
)
# 提交尝试的实际平台成本由 AIModelAttempt 累加;成片后再用实际模型的 usage true-up 覆盖。
task.base_cost = Decimal("0")
task.save(update_fields=["base_cost", "updated_at"])
with transaction.atomic():
video_segment = VideoSegment.objects.select_for_update().select_related("project").get(pk=video_segment.pk)
project = video_segment.project
if video_segment.status in {VideoSegment.Status.RUNNING, VideoSegment.Status.SUCCEEDED}:
return None
if video_segment_has_inflight_task(video_segment):
return None
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.VIDEO_SEGMENT,
model_config=model_config,
quote=quote,
reserve_amount=video_reserve_amount(quote.points),
request_payload={
"model": model_config.name,
"endpoint": model_config.endpoint,
"prompt": final_prompt,
"duration": video_segment.target_duration_seconds,
"ratio": aspect_ratio,
"resolution": resolution,
"estimated_tokens": est_tokens,
# 团队价格系数快照:按实结算用它,中途改价不影响在途任务(jimeng 同款纪律)
"price_multiplier": quote.meta.get("price_multiplier", "1"),
"video_segment_id": str(video_segment.id),
"reference_images": reference_images,
"model_routing_v1": True,
},
)
# 提交尝试的实际平台成本由 AIModelAttempt 累加;成片后再用实际模型的 usage true-up 覆盖。
task.base_cost = Decimal("0")
task.save(update_fields=["base_cost", "updated_at"])
try:
routed = execute_routed_video_submit(
task=task,
@@ -159,6 +159,36 @@ class NormalizeDurationTests(SimpleTestCase):
self.assertIn("不出现人物", draft["segments"][0]["visual"])
self.assertNotIn("宝宝坐", draft["segments"][0]["visual"])
def test_adult_student_is_not_mistaken_for_a_minor(self):
import json
raw = {
"entities": [
{"id": "student", "type": "character", "name": "20岁大学生", "visual_prompt": "20岁成年学生,宿舍使用场景"},
{"id": "scene", "type": "scene", "name": "宿舍", "visual_prompt": "宿舍桌面"},
],
"segments": [{"role": "钩子", "visual": "大学生在宿舍展示商品。", "entity_refs": ["student", "scene"]}],
}
draft = normalize_draft(json.dumps(raw, ensure_ascii=False), aspect_ratio="9:16", total_duration=15)
self.assertIn("student", [entity["id"] for entity in draft["entities"]])
self.assertIn("student", draft["segments"][0]["entity_refs"])
self.assertNotIn("不出现人物", draft["segments"][0]["visual"])
def test_canned_only_defect_phrase_is_replaced_without_dropping_content(self):
import json
raw = {
"segments": [{
"role": "卖点", "narration": "唯一缺点是瓶口需要慢一点倒,但质地很舒服。",
"visual": "近景展示唯一小缺点是瓶口开口较窄。",
}],
}
draft = normalize_draft(json.dumps(raw, ensure_ascii=False), aspect_ratio="9:16", total_duration=15)
joined = draft["segments"][0]["narration"] + draft["segments"][0]["visual"]
self.assertNotIn("唯一缺点", joined)
self.assertNotIn("唯一小缺点", joined)
self.assertIn("瓶口", joined)
class ProductFactTests(SimpleTestCase):
def test_missing_selling_point_is_rejected(self):
@@ -268,6 +298,8 @@ class PromptAssemblyTests(SimpleTestCase):
self.assertIn("跟同事吐槽", user)
self.assertIn("禁止一句 20 字收工", user)
self.assertIn("秒级分镜", user)
self.assertIn("禁止使用「唯一缺点", user)
self.assertIn("测试过程里的意外发现", user)
def test_video_digest_prompt_maps_camera_and_sound(self):
digest = (
@@ -141,6 +141,20 @@ class VideoSegmentRoutingTests(TestCase):
self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 0)
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RELEASE), 0)
def test_second_submit_does_not_create_another_task(self):
primary = self.model(self.provider("video-primary-once", 20), "video-primary-once", outbound=True, default=True)
provider = self._provider_for(primary)
provider.create_video_task.return_value = {"id": "remote-once", "status": "queued"}
first = self._submit()
second = submit_video_segment(video_segment=self.segment, user=self.user, prompt="test")
self.assertIsNone(second)
self.assertEqual(AITask.objects.filter(task_type=AITask.Type.VIDEO_SEGMENT, team=self.team).count(), 1)
self.assertEqual(provider.create_video_task.call_count, 1)
first.refresh_from_db()
self.assertEqual(first.status, AITask.Status.SUBMITTED)
@patch("apps.ai.services._store_generated_media")
def test_retry_then_dynamic_fallback_polls_actual_model_and_charges_once(self, store_media):
primary = self.model(self.provider("video-primary-fail", 20), "video-primary-fail", outbound=True, price=46, default=True)