完善视频提炼和视频复刻完善提交测试

This commit is contained in:
Azmat@qq.com
2026-08-28 10:15:15 +08:00
parent 52c5cdae1d
commit 849b14a0f6
33 changed files with 1475 additions and 208 deletions
+84 -6
View File
@@ -43,10 +43,38 @@ LEGACY_PROMPT_PREFIX = "[视频复刻]"
# 商品三视图在商品库是 standalone Asset(metadata.view=three_view + product_id),
# 和项目内的 BaseAssetGroup 是两套存储,这里直接取商品库那份。
TRIVIEW_LABEL = "目标商品三视图"
# 商品复刻的参考视频不进火山、只喂提炼模型,所以不受火山 15 秒参考视频限制。
# 半秒容差同 media_probe 的老规矩(60 秒成片常被探成 60.02)。角色复刻仍是 15 秒。
PRODUCT_REF_DURATION_MAX = 60.5
REVIEW_UNAVAILABLE = "素材审核服务暂不可用,请稍后重试"
REVIEW_FAILED = "参考素材未通过真人合规审核,请更换视频或图片后重试"
REVIEW_SUBMIT_FAILED = "素材提交审核失败,请稍后重试"
class VideoReplaceInProgress(ValueError):
"""本团队已有复刻在跑。视图转 409,并把在跑的那条带回去让前端接上。"""
def __init__(self, task):
super().__init__("已有一个视频正在复刻中,完成或取消后才能再提交")
self.task = task
def get_inflight_video_replace(team):
"""本团队正在跑的复刻任务(含审核中 / 提炼中)。没有返回 None。"""
_reap_stale_free_video_tasks(team=team)
return (
AITask.objects.filter(
team=team,
task_type=AITask.Type.FREE_VIDEO,
status__in=IN_FLIGHT_STATUSES,
is_deleted=False,
purged_at__isnull=True,
)
.filter(video_replace_q())
.order_by("-created_at")
.first()
)
# 商品复刻不再把参考视频直接丢给火山(实测换不动商品,出片跑偏),改走两步:
# 先用「提炼提示词」那一整套(同一份 SKILL.md + 同一个 Gemini 3.1 Pro)把参考视频拆成
# 中文分镜稿,再把分镜稿当导演脚本、连同商品图一起交给 Seedance。
@@ -62,8 +90,22 @@ PRODUCT_DIGEST_TAIL = (
"不要改造型、不要改配色、不要凭空补细节。\n"
"{triview}"
"3. 人物、场景、光线、色调、动作和表情按分镜稿保持不变,只换商品。\n"
" · 全片的人物必须自始至终是同一个人:五官、发型、体态和服装在所有镜头里保持一致,"
"不要中途换脸、换人或换装。\n"
" · 参考图里如果有人物(例如服饰类的模特图、真人手持商品的图),画面中的人物就以那张参考图为准,"
"五官、发型、体态和服装必须与之一致;参考图里只有商品、没有人物时,就按分镜稿描述的人物去演,"
"不要从商品图上凭空套一张脸。\n"
"4. 台词/旁白按分镜稿逐字念出;原文提到旧商品名称的地方,改说「{subject}」。\n"
"5. 分镜稿里的「字幕」栏只是对原片的记录,不要把这些文字画到画面上。"
"{condense}"
)
# 参考视频比成片长时才追加(火山单次出片上限 15 秒,60 秒参考稿必须压缩改编,
# 否则模型会照着分镜稿从头拍、拍到 15 秒戛然而止)。
PRODUCT_CONDENSE_NOTE = (
"\n6. 参考视频约 {source} 秒,而本次成片只有 {output} 秒:请按分镜稿的结构和节奏**压缩改编**,"
"保留主线、最关键的卖点镜头和结尾收束,可以合并或省略次要镜头;"
"不要只拍开头几镜就停,也不要把画面加速成快放。"
)
# 商品库里有三视图时才追加这一条(没有就不提,免得模型去找一张不存在的图)。
PRODUCT_TRIVIEW_NOTE = (
@@ -107,7 +149,14 @@ def compact_digest_for_video(digest_text: str) -> str:
return "\n".join(line for line in lines if line.strip()).strip()
def build_product_replace_prompt(digest_text: str, subject_name: str, *, has_triview: bool = False) -> str:
def build_product_replace_prompt(
digest_text: str,
subject_name: str,
*,
has_triview: bool = False,
source_seconds: float = 0,
output_seconds: int = 0,
) -> str:
"""分镜稿 + 商品替换要求 → 交给 Seedance 的完整提示词。
@目标商品 必须与 references 里第一张商品图的 label 对上,否则 build_content_items
@@ -119,9 +168,18 @@ def build_product_replace_prompt(digest_text: str, subject_name: str, *, has_tri
if not body:
raise ValueError("参考视频拆解结果为空,请重试")
subject = (subject_name or "").strip() or "目标商品"
source = int(round(float(source_seconds or 0)))
output = int(output_seconds or 0)
# 差 3 秒以内不啰嗦,模型自己会收;差得多才明确要求压缩改编。
condense = (
PRODUCT_CONDENSE_NOTE.format(source=source, output=output)
if source and output and source - output >= 3
else ""
)
tail = PRODUCT_DIGEST_TAIL.format(
subject=subject,
triview=PRODUCT_TRIVIEW_NOTE if has_triview else "",
condense=condense,
)
return enforce_no_embedded_captions(f"{PRODUCT_DIGEST_HEAD}\n\n{body}\n\n{tail}")
@@ -190,9 +248,20 @@ def submit_video_replace(*, team, user, params: dict):
if not has_model and not has_temp:
raise ValueError("请选择角色或上传角色参考图")
# 单飞闸:一个团队同时只允许一条复刻在跑。刷新页面时前端拉状态有空档,
# 用户容易以为「没任务」再点一次,重复扣费。_reap_stale_free_video_tasks 会先回收
# 死任务(CREATED 超 16 分钟等),所以不会被永久锁住。
running = get_inflight_video_replace(team)
if running is not None:
raise VideoReplaceInProgress(running)
video = _team_asset(team, params.get("video_asset_id"), kind=Asset.Type.VIDEO, label="参考视频")
video_seconds = _asset_duration_seconds(video)
if video_seconds > REF_DURATION_MAX:
if replace_mode == "product":
if video_seconds > PRODUCT_REF_DURATION_MAX:
raise ValueError("参考视频不能超过 60 秒,请剪短后重试")
elif video_seconds > REF_DURATION_MAX:
# 角色复刻把参考视频直传火山,15 秒是火山那边的硬限制,不能跟着放宽。
raise ValueError("参考视频不能超过 15 秒,请剪短后重试")
if has_product:
@@ -345,7 +414,11 @@ def run_replace_digest(task) -> None:
(ref or {}).get("label") == TRIVIEW_LABEL for ref in (payload.get("references") or [])
)
prompt = build_product_replace_prompt(
digest, str(payload.get("subject_name") or ""), has_triview=has_triview
digest,
str(payload.get("subject_name") or ""),
has_triview=has_triview,
source_seconds=float(payload.get("digest_source_duration") or 0),
output_seconds=int(payload.get("duration") or 0),
)
except (VideoDigestError, ValueError) as exc:
_fail_reviewing_task(task, str(exc), error_code="processing_failed")
@@ -776,10 +849,15 @@ def _product_library_refs(team, product_id: uuid.UUID) -> tuple[str, list, str]:
assets.append(asset)
if len(assets) >= image_budget:
break
if not assets and product.cover_asset_id and not product.cover_asset.is_deleted:
if not assets and product.cover_asset_id and product.cover_asset_id not in seen and not product.cover_asset.is_deleted:
assets.append(product.cover_asset)
if not assets and triview is None:
raise ValueError("这个商品还没有可用图片")
if not assets:
# 只有三视图、没有任何实拍图时,把三视图顶上来当 @目标商品。
# 否则提示词里的 @目标商品 找不到同名 label,火山拿到的是一句没有指代的字面量。
if triview is None:
raise ValueError("这个商品还没有可用图片")
assets = [triview]
triview = None
refs = [_library_image_ref(asset, team=team, label="目标商品" if index == 0 else f"目标商品{index + 1}") for index, asset in enumerate(assets)]
if triview is not None:
# 放最后:@目标商品 仍指向第一张实拍图,三视图作为「各面长什么样」的补充证据。