测试视频复刻

This commit is contained in:
Azmat@qq.com
2026-08-31 10:13:56 +08:00
parent 6b6146e595
commit 284748eeff
7 changed files with 193 additions and 245 deletions
+62 -94
View File
@@ -1,10 +1,11 @@
"""视频复刻:参考视频 + 商品图/人物图 → Seedance 2.5 换商品或换角色。
"""视频复刻:参考视频提炼分镜稿 → Seedance 2.5 按稿出片。
2.0 效果好是因为把 @参考视频 直接喂给火山锁切镜。2.5 单次能出 30 秒,模型换成
2.5 即可;不要改写成一大段分镜散文再文生视频,否则切镜对不上、描写过头。
流程很短:
1. 用户上传的参考视频只走「提炼提示词」同一套接口,拆成中文分镜稿;
2. 把这份稿(格式与提炼页一模一样)加上换商品/换角色,交给 Seedance 2.5;
3. 只带商品图/角色图。用户原片不传火山,避免素材审核报错。
流程:「提炼提示词」同一套接口拆出每镜时间轴 → 一镜一次 Seedance(仍带上原片)
→ 按原片顺序拼接。商品图/人物图过审后用 asset://;审核失败不扣费。
商品图/人物图过审后用 asset://;审核失败不扣费。
"""
from __future__ import annotations
@@ -78,19 +79,15 @@ def get_inflight_video_replace(team):
.first()
)
# 2.0 就是这四句话 + @参考视频。不要再扩写成「镜头表 / 必须原样保持」长文,
# 2.5 会把多出来的描写当成新画面去演。
PRODUCT_PROMPT = (
"使用@参考视频作为镜头、节奏与口播氛围基准,"
"将画面中需要替换的原商品完整替换为@目标商品的外观。"
"保留参考视频的人物、场景、镜头运动、剪辑节奏与口播氛围,"
"商品外观、材质、包装必须与参考图一致,不要改变原片构图和人物表演。"
"按下面这份分镜稿生成视频。镜头数量、每镜时长、景别、机位、运镜、人物、场景、口播全部按稿执行,"
"不要增删镜头、不要改顺序。"
"把稿里的原商品换成@目标商品,外观、材质、包装以参考图为准。"
)
CHARACTER_PROMPT = (
"使用@参考视频作为镜头、节奏与口播氛围基准,"
"将画面中需要替换的原人物完整替换为@目标角色。"
"保留参考视频的商品、场景、镜头运动、剪辑节奏与口播氛围,"
"角色五官、发型、体态必须与参考图一致,不要改变原片构图和商品展示。"
"按下面这份分镜稿生成视频。镜头数量、每镜时长、景别、机位、运镜、商品、场景、口播全部按稿执行,"
"不要增删镜头、不要改顺序。"
"把稿里的原人物换成@目标角色,五官、发型、体态以参考图为准。"
)
# 拆解期间的占位提示词。真正的提示词在 worker 拆完后回写。
@@ -175,10 +172,9 @@ def _render_shot_table(header: dict, shots: list[dict]) -> str:
# —— 逐镜生成 ——
# 2.5 一次塞整条 30 秒会自己切镜。拆成一镜一次,每段只还原原片对应时间,
# 并且把 @参考视频 一起带上(2.0 锁切镜的那一招),再按顺序拼起来。
# 一镜一次 Seedance,每段只还原原片对应时间,并把 @参考视频 带上锁切镜。
MIN_SHOT_SECONDS = 4 # 火山单次出片下限
MAX_CLIP_SECONDS = 30 # Seedance 2.5 单次上限;单镜再长也一次出,避免凭空切一刀
MAX_CLIP_SECONDS = 15 # Seedance 2.0 单次上限;单镜再长也一次出,避免凭空切一刀
MAX_SHOTS = 12 # 再多就是积分黑洞,也超出用户预期
_CLOCK_RE = re.compile(r"(\d{1,2}):(\d{2})")
@@ -339,17 +335,24 @@ def build_shot_prompt(
return enforce_no_embedded_captions("\n".join(part for part in parts if part))
def _digest_for_seedance(digest_text: str) -> str:
"""提炼页同款分镜稿。只砍给用户校对的【拆解存疑】,其余原文照传。"""
body = (digest_text or "").strip()
if not body:
raise ValueError("参考视频拆解结果为空,请重试")
head, sep, _tail = body.partition(DIGEST_TAIL_SECTION)
return (head if sep else body).strip()
def build_character_replace_prompt(
digest_text: str,
*,
source_seconds: float = 0,
output_seconds: int = 0,
) -> str:
"""解析不出逐镜计划时的整片回落:2.0 短指令,切镜靠 @参考视频。"""
"""提炼页分镜稿 + 换角色。原片不传 Seedance。"""
from .services import enforce_no_embedded_captions
if not (digest_text or "").strip():
raise ValueError("参考视频拆解结果为空,请重试")
source = int(round(float(source_seconds or 0)))
output = int(output_seconds or 0)
condense = (
@@ -357,7 +360,9 @@ def build_character_replace_prompt(
if source and output and source - output >= 3
else ""
)
return enforce_no_embedded_captions("\n".join(part for part in (CHARACTER_PROMPT, condense) if part))
return enforce_no_embedded_captions(
"\n".join(part for part in (CHARACTER_PROMPT, _digest_for_seedance(digest_text), condense) if part)
)
def compact_digest_for_video(digest_text: str) -> str:
@@ -382,11 +387,9 @@ def build_product_replace_prompt(
source_seconds: float = 0,
output_seconds: int = 0,
) -> str:
"""解析不出逐镜计划时的整片回落:2.0 短指令,切镜靠 @参考视频。"""
"""提炼页分镜稿 + 换商品。原片不传 Seedance。"""
from .services import enforce_no_embedded_captions
if not (digest_text or "").strip():
raise ValueError("参考视频拆解结果为空,请重试")
subject = (subject_name or "").strip()
source = int(round(float(source_seconds or 0)))
output = int(output_seconds or 0)
@@ -400,7 +403,9 @@ def build_product_replace_prompt(
extra.append(PRODUCT_TRIVIEW_NOTE)
if subject:
extra.append(f"口播里的旧商品名改说「{subject}」。")
return enforce_no_embedded_captions("\n".join(part for part in (PRODUCT_PROMPT, *extra, condense) if part))
return enforce_no_embedded_captions(
"\n".join(part for part in (PRODUCT_PROMPT, *extra, _digest_for_seedance(digest_text), condense) if part)
)
def is_video_replace_task(task) -> bool:
@@ -508,7 +513,7 @@ def submit_video_replace(*, team, user, params: dict):
}
base_params = {
"mode": "universal",
# 商品和角色都固定走 Seedance 2.5:只有它支持 30 秒单次出片,不接受前端指定别的档
# 固定 Seedance 2.5,不接受前端指定别的档
"model": REPLACE_MODEL,
"aspect_ratio": str(params.get("aspect_ratio") or "9:16"),
"resolution": str(params.get("resolution") or "720p"),
@@ -518,8 +523,7 @@ def submit_video_replace(*, team, user, params: dict):
"feature": FEATURE,
}
# 分镜仍用「提炼提示词」同一套拆。参考视频先不进审核列表(原片不是要登记的人物素材),
# 出片时再带上 @参考视频 锁切镜 —— 这是 2.0 效果好的原因,2.5 同样需要。
# 分镜用「提炼提示词」同一套拆。原片只当拆解源,不进审核列表、也不传火山。
from .video_digest import resolve_digest_model_config
if resolve_digest_model_config() is None:
@@ -533,7 +537,7 @@ def submit_video_replace(*, team, user, params: dict):
"digest_source_asset_id": str(video.id),
"digest_source_name": video.name or "参考视频",
"digest_source_duration": round(video_seconds, 2),
# 审核只盯商品图/角色图。原片快照留给出片时锁切镜,也给历史卡「原视频」用。
# 原片快照只给历史卡「原视频」和重新生成用,不进 Seedance references。
"digest_source_ref": _owned_ref(video, kind="video", role="reference_video", label="参考视频"),
"review_pending": review_state != "ready",
})
@@ -551,15 +555,10 @@ def submit_video_replace(*, team, user, params: dict):
def advance_video_replace(task):
"""推进复刻:审核中 → 提交;逐镜生成中 → 下一段 / 拼接。"""
"""推进复刻:审核中 → 整份分镜稿一次出片。旧逐镜任务也按单次收尾,不再拼接。"""
if not is_video_replace_task(task):
return task
if task.status != AITask.Status.CREATED:
payload = task.request_payload or {}
if payload.get("shot_plan") and task.status in (
AITask.Status.RESERVED, AITask.Status.SUBMITTED, AITask.Status.POLLING,
):
return advance_replace_shots(task)
from .free_video import finalize_free_video
return finalize_free_video(task=task)
@@ -569,7 +568,7 @@ def advance_video_replace(task):
# worker 还在拆参考视频,提示词都没生成,别当成「审核中」反复送审。
# worker 挂了也不会永远卡着:CREATED 超 16 分钟由 _reap_stale_free_video_tasks 回收退费。
return task
references = list(payload.get("references") or [])
references = _image_refs(payload.get("references") or [])
try:
state = _ensure_replace_refs_reviewed(task.team, references)
except ValueError as exc:
@@ -598,21 +597,18 @@ def advance_video_replace(task):
def start_replace_generation(task):
"""审核过了:有逐镜计划就一镜一次,否则回落整片一次生成(仍带 @参考视频)。"""
payload = task.request_payload or {}
if payload.get("shot_plan"):
return start_pending_replace_shots(task)
refs = _seedance_references(payload)
if refs:
next_payload = dict(payload)
next_payload["references"] = refs
task.request_payload = next_payload
task.save(update_fields=["request_payload", "updated_at"])
"""审核过了:整份分镜稿一次交给 Seedance 2.5。只带商品图/角色图,不传用户原片。"""
payload = dict(task.request_payload or {})
payload["references"] = _image_refs(payload.get("references") or [])
payload.pop("shot_plan", None)
payload.pop("shot_total", None)
task.request_payload = payload
task.save(update_fields=["request_payload", "updated_at"])
return start_pending_free_video(task)
def run_replace_digest(task) -> None:
"""Worker:参考视频 → 分镜稿 → 逐镜计划 → 走审核提交火山。
"""Worker:参考视频 → 提炼页同款分镜稿 → 一次交给 Seedance。不传原片。
商品和角色共用。这一步失败 = 整条复刻失败。此时还没预留视频积分,不扣费。
"""
@@ -642,13 +638,7 @@ def run_replace_digest(task) -> None:
try:
digest, meta = digest_asset_video(asset=asset, task=task)
plan = build_shot_plan(
digest, subject, has_triview=has_triview,
replace_mode=replace_mode, output_seconds=output_seconds,
)
if plan:
prompt = plan[0]["prompt"]
elif replace_mode == "character":
if replace_mode == "character":
prompt = build_character_replace_prompt(
digest, source_seconds=source_seconds, output_seconds=output_seconds,
)
@@ -674,21 +664,9 @@ def run_replace_digest(task) -> None:
next_payload["digest_pending"] = False
next_payload["digest_text"] = digest[:32000]
next_payload["prompt"] = prompt
if plan:
next_payload["shot_plan"] = [
{
"index": item["index"],
"seconds": item["seconds"],
"prompt": item["prompt"],
"status": "queued",
"provider_task_id": "",
"media": "",
"tokens": 0,
}
for item in plan
]
next_payload["shot_total"] = len(plan)
next_payload["duration"] = sum(item["seconds"] for item in plan)
next_payload["references"] = _image_refs(next_payload.get("references") or [])
next_payload.pop("shot_plan", None)
next_payload.pop("shot_total", None)
locked.request_payload = next_payload
locked.save(update_fields=["request_payload", "updated_at"])
task = locked
@@ -704,6 +682,11 @@ def run_replace_digest(task) -> None:
def start_pending_replace_shots(task):
"""旧逐镜路径已下线。一律整片一次出,且不传用户原片。"""
return start_replace_generation(task)
def _legacy_start_pending_replace_shots(task):
"""CREATED → 按全部镜头总时长预留积分 → 提交第一镜。"""
from apps.billing.services.ledger import reserve_credit
@@ -1545,33 +1528,18 @@ def _owned_ref(asset: Asset, *, kind: str, role: str, label: str) -> dict:
return ref
def _seedance_references(payload: dict) -> list:
"""出片用的引用:原片 + 已过审的商品图/角色图。
审核列表里只有图。原片走直链(source=upload),不当三库人物素材再送一遍审。
2.0 就是靠这条视频锁切镜,2.5 同样必须带上。
"""
images = [
def _image_refs(references) -> list:
"""出片/审核只用图。用户原片哪怕还躺在旧 payload 里,也一律丢掉。"""
return [
dict(item)
for item in (payload.get("references") or [])
for item in (references or [])
if (item or {}).get("type") != "video"
]
video = payload.get("digest_source_ref")
if not isinstance(video, dict) or not (video.get("url") or video.get("resolved_url")):
video = next(
(item for item in (payload.get("references") or []) if (item or {}).get("type") == "video"),
None,
)
if not isinstance(video, dict):
return images
copy_ref = dict(video)
copy_ref.update({
"type": "video",
"role": "reference_video",
"label": copy_ref.get("label") or "参考视频",
"source": "upload",
})
return [copy_ref, *images]
def _seedance_references(payload: dict) -> list:
"""出片只带商品图/角色图。用户上传的原片不传 Seedance,避免素材审核报错。"""
return _image_refs(payload.get("references") or [])
def _library_image_ref(asset: Asset, *, team, label: str) -> dict: