优化视频复刻

This commit is contained in:
Azmat@qq.com
2026-08-27 17:58:48 +08:00
parent d2b786a3cf
commit b3bf159e08
10 changed files with 545 additions and 64 deletions
+191 -27
View File
@@ -44,12 +44,34 @@ REVIEW_UNAVAILABLE = "素材审核服务暂不可用,请稍后重试"
REVIEW_FAILED = "参考素材未通过真人合规审核,请更换视频或图片后重试"
REVIEW_SUBMIT_FAILED = "素材提交审核失败,请稍后重试"
PRODUCT_PROMPT = (
"使用@参考视频作为镜头、节奏与口播氛围基准,"
"将画面中需要替换的原商品完整替换为@目标商品的外观。"
"保留参考视频的人物、场景、镜头运动、剪辑节奏与口播氛围,"
"商品外观、材质、包装必须与参考图一致,不要改变原片构图和人物表演。"
# 商品复刻不再把参考视频直接丢给火山(实测换不动商品,出片跑偏),改走两步:
# 先用「提炼提示词」那一整套(同一份 SKILL.md + 同一个 Gemini 3.1 Pro)把参考视频拆成
# 中文分镜稿,再把分镜稿当导演脚本、连同商品图一起交给 Seedance。
# 角色复刻仍是「参考视频 + 角色图」直传,已验证有效,不要顺手一起改。
PRODUCT_DIGEST_HEAD = (
"下面是参考视频的完整分镜稿,它逐镜记录了这条片子的时间、景别、机位、运镜、画面、"
"人物动作与表情、台词旁白和声音。请把它当作导演脚本,按镜头顺序还原成一条新视频。"
)
PRODUCT_DIGEST_TAIL = (
"【复刻要求】\n"
"1. 严格按上面分镜稿的镜头顺序、时间分配、景别、机位、运镜和剪辑节奏还原全片,不要自行增删镜头。\n"
"2. 分镜稿里出现的原商品,全部替换成 @目标商品;商品的外形、颜色、材质、包装和标识必须与参考图完全一致,"
"不要改造型、不要改配色、不要凭空补细节。\n"
"3. 人物、场景、光线、色调、动作和表情按分镜稿保持不变,只换商品。\n"
"4. 台词/旁白按分镜稿逐字念出;原文提到旧商品名称的地方,改说「{subject}」。\n"
"5. 分镜稿里的「字幕」栏只是对原片的记录,不要把这些文字画到画面上。"
)
# 拆解期间的占位提示词。真正的提示词在 worker 拆完后回写。
DIGEST_PENDING_PROMPT = "正在提炼参考视频的分镜稿…"
# 分镜稿是给人逐镜校对的导演稿,整篇塞给视频模型太长也太杂,按需瘦身:
# ·「字幕」栏是原片花字的记录,留着会诱导模型把字画到画面上,与全站「成片无字幕」冲突;
# ·【拆解存疑】是给用户校对用的,视频模型用不上。
DIGEST_DROP_FIELDS = ("字幕",)
# 瘦身完还超长时再砍这几栏(信息量最低,砍掉不影响镜头结构)。
DIGEST_TRIM_FIELDS = ("音效", "背景音乐", "备注")
DIGEST_TAIL_SECTION = "【拆解存疑】"
MAX_DIGEST_CHARS = 4000
CHARACTER_PROMPT = (
"使用@参考视频作为镜头、节奏与口播氛围基准,"
"将画面中需要替换的原人物完整替换为@目标角色。"
@@ -58,6 +80,39 @@ CHARACTER_PROMPT = (
)
def _drop_digest_fields(lines: list[str], fields: tuple[str, ...]) -> list[str]:
prefixes = tuple(f"{name}:" for name in fields)
return [line for line in lines if not line.strip().startswith(prefixes)]
def compact_digest_for_video(digest_text: str) -> str:
"""分镜稿 → 交给视频模型的精简版。保留镜头结构,砍掉给人看的部分。"""
body = (digest_text or "").strip()
head, sep, _tail = body.partition(DIGEST_TAIL_SECTION)
if sep:
body = head.strip()
lines = _drop_digest_fields(body.split("\n"), DIGEST_DROP_FIELDS)
if sum(len(line) for line in lines) > MAX_DIGEST_CHARS:
lines = _drop_digest_fields(lines, DIGEST_TRIM_FIELDS)
return "\n".join(line for line in lines if line.strip()).strip()
def build_product_replace_prompt(digest_text: str, subject_name: str) -> str:
"""分镜稿 + 商品替换要求 → 交给 Seedance 的完整提示词。
@目标商品 必须与 references 里第一张商品图的 label 对上,否则 build_content_items
不会把它换成火山认的「图片N」指代。
"""
from .services import enforce_no_embedded_captions
body = compact_digest_for_video(digest_text)
if not body:
raise ValueError("参考视频拆解结果为空,请重试")
subject = (subject_name or "").strip() or "目标商品"
tail = PRODUCT_DIGEST_TAIL.format(subject=subject)
return enforce_no_embedded_captions(f"{PRODUCT_DIGEST_HEAD}\n\n{body}\n\n{tail}")
def is_video_replace_task(task) -> bool:
payload = task.request_payload or {}
if payload.get("feature") == FEATURE:
@@ -73,7 +128,9 @@ def serialize_video_replace_task(task, *, include_deleted_assets: bool = False)
data = serialize_free_video_task(task, include_deleted_assets=include_deleted_assets)
payload = task.request_payload or {}
replace_mode = payload.get("replace_mode") or _legacy_replace_mode(payload.get("prompt") or "")
reviewing = task.status == AITask.Status.CREATED and bool(payload.get("review_pending"))
created = task.status == AITask.Status.CREATED
digesting = created and bool(payload.get("digest_pending"))
reviewing = created and bool(payload.get("review_pending")) and not digesting
data.update({
"feature": FEATURE,
"replace_mode": replace_mode,
@@ -82,6 +139,12 @@ def serialize_video_replace_task(task, *, include_deleted_assets: bool = False)
"product_id": payload.get("product_id") or "",
"model_id": payload.get("model_id") or "",
"review_stage": "reviewing" if reviewing else "",
# 商品复刻专有:参考视频拆出来的分镜稿(给用户看,也便于排查出片跑偏)
"digest_stage": "digesting" if digesting else "",
"digest_text": str(payload.get("digest_text") or ""),
"digest_shots": payload.get("digest_shots") or 0,
"digest_source_name": payload.get("digest_source_name") or "",
"digest_source": payload.get("digest_source_ref") or None,
})
return data
@@ -127,8 +190,48 @@ def submit_video_replace(*, team, user, params: dict):
noun = "商品" if replace_mode == "product" else "角色"
subject_name, image_refs, subject_source = _temporary_image_refs(team, image_ids, noun=noun)
prompt = PRODUCT_PROMPT if replace_mode == "product" else CHARACTER_PROMPT
duration = _output_duration(params.get("duration"), video_seconds)
extra = {
"replace_mode": replace_mode,
"subject_name": subject_name,
"subject_source": subject_source,
"product_id": str(product_id) if product_id else "",
"model_id": str(model_id) if model_id else "",
}
base_params = {
"mode": "universal",
"model": str(params.get("model") or HIGH_RES_MODEL),
"aspect_ratio": str(params.get("aspect_ratio") or "9:16"),
"resolution": str(params.get("resolution") or "720p"),
"duration": duration,
"seed": params.get("seed", -1),
"generate_audio": True,
"feature": FEATURE,
}
if replace_mode == "product":
# 商品复刻:参考视频只喂给提炼模型,不进火山 references(所以也不用送火山审核)。
# 先秒回一个 CREATED 任务,拆解这种慢活(Gemini 半分钟起)交给 worker。
extra.update({
"digest_source_asset_id": str(video.id),
"digest_source_name": video.name or "参考视频",
"digest_source_duration": round(video_seconds, 2),
# 参考视频虽然不进 references,历史卡的「原视频」和「重新生成」仍要拿到它,
# 这里存一份引用快照,免得序列化历史时每条再查一次库。
"digest_source_ref": _owned_ref(video, kind="video", role="reference_video", label="参考视频"),
})
return _create_reviewing_task(
team=team,
user=user,
params={
**base_params,
"prompt": DIGEST_PENDING_PROMPT,
"references": image_refs,
"extra_payload": extra,
},
digest_pending=True,
)
references = [
_owned_ref(video, kind="video", role="reference_video", label="参考视频"),
*image_refs,
@@ -137,25 +240,11 @@ def submit_video_replace(*, team, user, params: dict):
if review_state == "failed":
raise ValueError(REVIEW_FAILED)
references = _refresh_replace_refs(team, references)
extra = {
"replace_mode": replace_mode,
"subject_name": subject_name,
"subject_source": subject_source,
"product_id": str(product_id) if product_id else "",
"model_id": str(model_id) if model_id else "",
"review_pending": review_state != "ready",
}
extra["review_pending"] = review_state != "ready"
submit_params = {
"prompt": prompt,
"mode": "universal",
"model": str(params.get("model") or HIGH_RES_MODEL),
"aspect_ratio": str(params.get("aspect_ratio") or "9:16"),
"resolution": str(params.get("resolution") or "720p"),
"duration": duration,
"seed": params.get("seed", -1),
"generate_audio": True,
**base_params,
"prompt": CHARACTER_PROMPT,
"references": references,
"feature": FEATURE,
"extra_payload": extra,
}
if review_state == "ready":
@@ -174,6 +263,10 @@ def advance_video_replace(task):
return finalize_free_video(task=task)
payload = task.request_payload or {}
if payload.get("digest_pending"):
# worker 还在拆参考视频,提示词都没生成,别当成「审核中」反复送审。
# worker 挂了也不会永远卡着:CREATED 超 16 分钟由 _reap_stale_free_video_tasks 回收退费。
return task
references = list(payload.get("references") or [])
try:
state = _ensure_replace_refs_reviewed(task.team, references)
@@ -202,6 +295,61 @@ def advance_video_replace(task):
return start_pending_free_video(task)
def run_replace_digest(task) -> None:
"""Worker:商品复刻第一道工序——参考视频 → 分镜稿 → 完整提示词 → 走审核提交火山。
这一步失败 = 整条复刻失败。此时还没预留视频积分(CREATED 阶段不预留),不扣费。
"""
from apps.ai.video_digest import VideoDigestError, digest_asset_video
if not is_video_replace_task(task) or task.status != AITask.Status.CREATED:
return
payload = task.request_payload or {}
if not payload.get("digest_pending"):
return
try:
asset = _load_replace_asset(
task.team, uuid.UUID(str(payload.get("digest_source_asset_id") or "")), "参考视频"
)
except (ValueError, TypeError):
_fail_reviewing_task(task, "参考视频已失效,请重新上传")
return
try:
digest, meta = digest_asset_video(asset=asset, task=task)
prompt = build_product_replace_prompt(digest, str(payload.get("subject_name") or ""))
except (VideoDigestError, ValueError) as exc:
_fail_reviewing_task(task, str(exc))
return
except Exception as exc: # noqa: BLE001 — 拆解任何异常都要把任务收尾,别留 CREATED 僵尸
logger.exception("video replace digest failed for %s", task.id)
_fail_reviewing_task(task, f"参考视频拆解失败:{exc}")
return
with transaction.atomic():
locked = AITask.objects.select_for_update().get(id=task.id)
if locked.status != AITask.Status.CREATED:
return
next_payload = dict(locked.request_payload or {})
next_payload.update(meta)
next_payload["digest_pending"] = False
next_payload["digest_text"] = digest[:32000]
next_payload["prompt"] = prompt
locked.request_payload = next_payload
locked.save(update_fields=["request_payload", "updated_at"])
task = locked
# 拆完就地推进一次:商品图多半早已过审,能直接提交火山,省掉一轮 8s 轮询。
try:
task = advance_video_replace(task)
except Exception: # noqa: BLE001 — 推进失败交给轮询重试,任务还在 CREATED
logger.warning("video replace advance after digest failed for %s", task.id, exc_info=True)
task.refresh_from_db()
if task.status == AITask.Status.CREATED:
_enqueue_replace_review_poll(task)
def _legacy_replace_mode(prompt: str) -> str:
return "character" if prompt.startswith("[视频复刻·角色]") else "product"
@@ -221,8 +369,20 @@ def _enqueue_replace_review_poll(task):
logger.error("video replace review poll enqueue failed; relying on client polling", exc_info=True)
def _create_reviewing_task(*, team, user, params: dict):
"""审核未完成:只建 CREATED 任务,不预留积分。"""
def _enqueue_replace_digest(task):
try:
from .tasks import run_video_replace_digest_task
run_video_replace_digest_task.delay(str(task.id))
except Exception: # noqa: BLE001
logger.error("video replace digest enqueue failed", exc_info=True)
def _create_reviewing_task(*, team, user, params: dict, digest_pending: bool = False):
"""还不能提交火山时:只建 CREATED 任务,不预留积分。
两种情况共用:①角色复刻的素材还在审核 ②商品复刻的参考视频还没拆解。
"""
model_name = str(params.get("model") or HIGH_RES_MODEL)
aspect_ratio = str(params.get("aspect_ratio") or "9:16")
resolution = str(params.get("resolution") or "720p")
@@ -294,6 +454,7 @@ def _create_reviewing_task(*, team, user, params: dict):
"references": references,
"model_routing_v1": True,
"review_pending": True,
"digest_pending": digest_pending,
}
for key, value in extra.items():
if key in request_payload or value in (None, ""):
@@ -312,7 +473,10 @@ def _create_reviewing_task(*, team, user, params: dict):
estimated_cost=quote.points,
base_cost=Decimal("0"),
)
_enqueue_replace_review_poll(task)
if digest_pending:
_enqueue_replace_digest(task)
else:
_enqueue_replace_review_poll(task)
return task