测试视频复刻
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user