视频复刻添加描述框

This commit is contained in:
Azmat@qq.com
2026-08-31 17:46:02 +08:00
parent 5bb1a4927b
commit a520b230c5
6 changed files with 150 additions and 2 deletions
@@ -700,6 +700,48 @@ class SubmitVideoReplaceTests(TestCase):
self.assertFalse(task.request_payload.get("shot_plan")) self.assertFalse(task.request_payload.get("shot_plan"))
self.assertNotIn("@参考视频", task.request_payload["prompt"]) self.assertNotIn("@参考视频", task.request_payload["prompt"])
def test_subject_brief_reaches_product_facts(self):
"""第 3 步填的商品信息要进 facts,并把「仅外观」升级成「部分」。"""
task = self._submit(subject_brief=" 素颜霜,膏体按压泵,主打提亮 ")
payload = task.request_payload
self.assertEqual(payload["subject_brief"], "素颜霜,膏体按压泵,主打提亮")
facts = payload["product_facts"]
self.assertEqual(facts["用户补充信息"], "素颜霜,膏体按压泵,主打提亮")
self.assertEqual(facts["资料状态"], "部分")
def test_character_brief_drives_voice_gender(self):
"""角色信息里的性别要变成写死的声线要求。
这条守的是「女换男之后画面是男的、声音还是女的」:提示词以前引用了
一个根本不存在的「系统提供的目标声音设定」,模型只能猜,于是沿用原音轨。
"""
from apps.ai.video_replace import build_character_submit_prompt
male = build_character_submit_prompt("男性,30 岁左右,中低音域")
self.assertIn("成年男性声线", male)
self.assertNotIn("成年女性声线", male)
self.assertIn("男性,30 岁左右", male)
# 前缀必须原样保留:老任务靠 prompt 前缀认 replace_mode
self.assertTrue(male.startswith(CHARACTER_PROMPT))
self.assertIn("成年女性声线", build_character_submit_prompt("女性,25 岁,声音甜美"))
# 男女都提到(「把女的换成男的」)时不下断言,交给通用规则
self.assertNotIn("【目标声音设定】", build_character_submit_prompt("把女的换成男的"))
# 没填也要有性别跟随规则,不能再引用不存在的设定
bare = build_character_submit_prompt("")
self.assertIn("必须随目标角色改变", bare)
self.assertNotIn("系统提供的目标声音设定", bare)
def test_character_brief_lands_in_submitted_prompt(self):
portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png")
model = Model.objects.create(team=self.team, name="阿哲", portrait_asset=portrait)
task = self._submit(
replace_mode="character", product_id="", model_id=str(model.id),
subject_brief="男性,稳重,中低音域",
)
self.assertEqual(task.request_payload["subject_brief"], "男性,稳重,中低音域")
self.assertIn("成年男性声线", task.request_payload["prompt"])
def test_character_library_and_temp_are_exclusive(self): def test_character_library_and_temp_are_exclusive(self):
portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png") portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png")
model = Model.objects.create(team=self.team, name="薇薇", portrait_asset=portrait) model = Model.objects.create(team=self.team, name="薇薇", portrait_asset=portrait)
+68 -2
View File
@@ -95,7 +95,9 @@ CHARACTER_PROMPT = (
"【声音重建规则——最高优先级】被替换人物对应的所有声音必须同步替换,包括画面内对白、口播、旁白、画外音、" "【声音重建规则——最高优先级】被替换人物对应的所有声音必须同步替换,包括画面内对白、口播、旁白、画外音、"
"语气词和呼吸声。只保留原口播的文字内容、信息含义、句子顺序、出现时间、停顿位置、情绪类型和表达节奏;" "语气词和呼吸声。只保留原口播的文字内容、信息含义、句子顺序、出现时间、停顿位置、情绪类型和表达节奏;"
"不得保留原人物的声线、音色、音高、年龄感、性别特征、口音、声纹或其他说话人身份特征。" "不得保留原人物的声线、音色、音高、年龄感、性别特征、口音、声纹或其他说话人身份特征。"
"根据@目标角色的身份及系统提供的目标声音设定,重新生成全部相关人声。" "根据@目标角色的形象、下方【目标角色设定】,以及参考图中可见的性别与年龄特征,重新生成全部相关人声。"
"当@目标角色与参考视频中原人物的性别或年龄段不同时,重建人声的性别音色必须随目标角色改变:"
"男性角色必须用男声,女性角色必须用女声,绝不允许沿用原人物的性别音色。"
"从视频开始到结束,被替换人物的声音必须始终使用同一个目标声音;不得出现原人物声线残留、声音身份中途变化、" "从视频开始到结束,被替换人物的声音必须始终使用同一个目标声音;不得出现原人物声线残留、声音身份中途变化、"
"新旧声音混用,或画面角色已经替换但声音仍属于原人物的情况。" "新旧声音混用,或画面角色已经替换但声音仍属于原人物的情况。"
"画面中目标角色的嘴部动作必须与重新生成的人声一致,口型、台词开始时间、停顿、语速和情绪表达保持同步。" "画面中目标角色的嘴部动作必须与重新生成的人声一致,口型、台词开始时间、停顿、语速和情绪表达保持同步。"
@@ -103,6 +105,51 @@ CHARACTER_PROMPT = (
"如保留原视频声音与目标角色声音冲突,必须舍弃原人物声音,以重新生成的目标声音为最高优先级。" "如保留原视频声音与目标角色声音冲突,必须舍弃原人物声音,以重新生成的目标声音为最高优先级。"
) )
# 用户在第 3 步填的补充信息(商品名/品类,或角色性别年龄音色)。选填,不填维持原行为。
MAX_SUBJECT_BRIEF = 500
def _clean_subject_brief(value) -> str:
return str(value or "").strip()[:MAX_SUBJECT_BRIEF]
def _voice_gender_directive(brief: str) -> str:
"""从用户填的角色信息里读出性别 → 写死声线要求。
Seedance 拿不到明确性别时倾向于沿用原音轨声线,女换男会出现
「画面换成男的、声音还是女的」。用户明确写了性别就不再让模型猜。
男女都提到(例如「把女的换成男的」)时不下断言,交给上面的通用规则。
"""
has_male = "" in brief
has_female = "" in brief
if has_male and not has_female:
return (
"【目标声音设定】目标角色为男性:重建的全部人声必须是成年男性声线,中低音域,胸腔共鸣,"
"绝不允许出现女性音色、女性音高或偏女的中性声线。"
)
if has_female and not has_male:
return (
"【目标声音设定】目标角色为女性:重建的全部人声必须是成年女性声线,中高音域,"
"绝不允许出现男性音色或男性音高。"
)
return ""
def build_character_submit_prompt(subject_brief: str = "") -> str:
"""角色复刻的提交提示词 = 固定规则 + 用户填的角色设定 + 推导出的声线要求。
⚠️ CHARACTER_PROMPT 必须留在开头:老任务靠 prompt 前缀认 replace_mode。
"""
brief = _clean_subject_brief(subject_brief)
parts = [CHARACTER_PROMPT]
if brief:
parts.append(f"【目标角色设定】(用户填写,形象与声音的依据){brief}")
voice = _voice_gender_directive(brief)
if voice:
parts.append(voice)
return "\n".join(parts)
# 拆解期间的占位提示词。真正的提示词在 worker 拆完后回写。 # 拆解期间的占位提示词。真正的提示词在 worker 拆完后回写。
DIGEST_PENDING_PROMPT = "正在提炼参考视频的分镜稿…" DIGEST_PENDING_PROMPT = "正在提炼参考视频的分镜稿…"
@@ -668,6 +715,7 @@ def serialize_video_replace_task(task, *, include_deleted_assets: bool = False)
"replace_mode": replace_mode, "replace_mode": replace_mode,
"subject_name": payload.get("subject_name") or "", "subject_name": payload.get("subject_name") or "",
"subject_source": payload.get("subject_source") or "", "subject_source": payload.get("subject_source") or "",
"subject_brief": payload.get("subject_brief") or "",
"product_id": payload.get("product_id") or "", "product_id": payload.get("product_id") or "",
"model_id": payload.get("model_id") or "", "model_id": payload.get("model_id") or "",
"review_stage": "reviewing" if reviewing else "", "review_stage": "reviewing" if reviewing else "",
@@ -726,6 +774,9 @@ def submit_video_replace(*, team, user, params: dict):
if video_seconds > REPLACE_REF_DURATION_MAX: if video_seconds > REPLACE_REF_DURATION_MAX:
raise ValueError("参考视频不能超过 30 秒,请剪短后重试") raise ValueError("参考视频不能超过 30 秒,请剪短后重试")
# 第 3 步用户填的补充信息。商品:名称/品类/卖点;角色:性别/年龄/音色。选填。
subject_brief = _clean_subject_brief(params.get("subject_brief"))
if has_product: if has_product:
subject_name, image_refs, subject_source = _product_library_refs(team, product_id) subject_name, image_refs, subject_source = _product_library_refs(team, product_id)
product_facts = _product_semantic_facts_for_id(team, product_id) product_facts = _product_semantic_facts_for_id(team, product_id)
@@ -744,6 +795,17 @@ def submit_video_replace(*, team, user, params: dict):
"禁止推断": "不得编造功效、成分、认证和任何具体卖点;口播只描述看得见的外观、质地和使用感受。", "禁止推断": "不得编造功效、成分、认证和任何具体卖点;口播只描述看得见的外观、质地和使用感受。",
} }
# 用户手填的商品信息优先级最高:临时上传时这往往是唯一的品类来源,
# 商品库选品时它补充库里没填的字段。填了就不再是「仅外观」。
if replace_mode == "product" and subject_brief:
product_facts = {
**product_facts,
"用户补充信息": subject_brief,
"用户补充信息的权威性": "这是用户手动填写的商品事实,可直接作为品类、用途和卖点的依据,优先级高于据图推断。",
}
if product_facts.get("资料状态") == "仅外观":
product_facts["资料状态"] = "部分"
# 角色替换是 Seedance 视频编辑:火山要求比例/时长跟随输入视频,不能由前端指定。 # 角色替换是 Seedance 视频编辑:火山要求比例/时长跟随输入视频,不能由前端指定。
duration = -1 if replace_mode == "character" else _output_duration(params.get("duration"), video_seconds) duration = -1 if replace_mode == "character" else _output_duration(params.get("duration"), video_seconds)
billing_ratio = str(params.get("aspect_ratio") or "9:16") billing_ratio = str(params.get("aspect_ratio") or "9:16")
@@ -756,6 +818,7 @@ def submit_video_replace(*, team, user, params: dict):
"product_id": str(product_id) if product_id else "", "product_id": str(product_id) if product_id else "",
"model_id": str(model_id) if model_id else "", "model_id": str(model_id) if model_id else "",
"product_facts": product_facts, "product_facts": product_facts,
"subject_brief": subject_brief,
# adaptive / -1 只用于火山请求;预估积分仍按参考视频实际时长和上传时检测出的比例计算。 # adaptive / -1 只用于火山请求;预估积分仍按参考视频实际时长和上传时检测出的比例计算。
"billing_duration": max(4, int(round(video_seconds or 4))), "billing_duration": max(4, int(round(video_seconds or 4))),
"billing_aspect_ratio": billing_ratio, "billing_aspect_ratio": billing_ratio,
@@ -802,7 +865,10 @@ def submit_video_replace(*, team, user, params: dict):
user=user, user=user,
params={ params={
**base_params, **base_params,
"prompt": DIGEST_PENDING_PROMPT if replace_mode == "product" else CHARACTER_PROMPT, "prompt": (
DIGEST_PENDING_PROMPT if replace_mode == "product"
else build_character_submit_prompt(subject_brief)
),
"references": image_refs, "references": image_refs,
"extra_payload": extra, "extra_payload": extra,
}, },
+1
View File
@@ -912,6 +912,7 @@ export const api = {
product_id?: string; product_id?: string;
model_id?: string; model_id?: string;
image_asset_ids?: string[]; image_asset_ids?: string[];
subject_brief?: string;
model?: string; model?: string;
aspect_ratio?: string; aspect_ratio?: string;
resolution?: string; resolution?: string;
@@ -43,6 +43,8 @@ const PRODUCT_SOURCE_PURPOSE = "video_replace_product";
const REF_SECONDS_MAX = { product: 30, character: 30 } as const; const REF_SECONDS_MAX = { product: 30, character: 30 } as const;
const REF_BYTES_MAX = { product: 200 * 1024 * 1024, character: 200 * 1024 * 1024 } as const; const REF_BYTES_MAX = { product: 200 * 1024 * 1024, character: 200 * 1024 * 1024 } as const;
const SEEDANCE_MAX_OUTPUT_SECONDS = 30; const SEEDANCE_MAX_OUTPUT_SECONDS = 30;
// 第 3 步补充信息上限,与后端 MAX_SUBJECT_BRIEF 保持一致
const SUBJECT_BRIEF_MAX = 500;
type ProductSource = "library" | "temporary" | ""; type ProductSource = "library" | "temporary" | "";
type ReplaceMode = "product" | "character"; type ReplaceMode = "product" | "character";
@@ -74,6 +76,10 @@ const REPLACE_MODE_COPY = {
drawerEmpty: "还没有商品,先去商品库创建一个", drawerEmpty: "还没有商品,先去商品库创建一个",
historyKind: "商品", historyKind: "商品",
pickToast: "已选择商品", pickToast: "已选择商品",
briefStep: "3. 商品信息",
briefOptional: "选填 · 填了更准",
briefPlaceholder: "例:素颜霜,膏体按压泵,主打提亮和保湿,适合日常通勤",
briefHint: "填写商品名称、品类和卖点后,口播和使用动作会按真实用途来写;不填则只按参考图的外观拍。",
}, },
character: { character: {
modeLabel: "角色复刻", modeLabel: "角色复刻",
@@ -101,6 +107,10 @@ const REPLACE_MODE_COPY = {
drawerEmpty: "还没有人物,先去模特库添加", drawerEmpty: "还没有人物,先去模特库添加",
historyKind: "角色", historyKind: "角色",
pickToast: "已选择角色", pickToast: "已选择角色",
briefStep: "3. 角色信息",
briefOptional: "选填 · 建议写明性别",
briefPlaceholder: "例:男性,30 岁左右,中低音域,语速平稳,声音干净有亲和力",
briefHint: "配音会按这里的设定重新生成声线,建议写明男 / 女,避免沿用原视频人物的音色。",
}, },
} as const; } as const;
@@ -284,6 +294,7 @@ interface ModeFormState {
tempFiles: File[]; tempFiles: File[];
tempAssetRefs: FreeVideoRef[]; tempAssetRefs: FreeVideoRef[];
filledSubjectName: string; filledSubjectName: string;
subjectBrief: string;
job: FreeVideoTask | null; job: FreeVideoTask | null;
jobId: string; jobId: string;
} }
@@ -300,6 +311,7 @@ const createInitialModeState = (): ModeFormState => ({
tempFiles: [], tempFiles: [],
tempAssetRefs: [], tempAssetRefs: [],
filledSubjectName: "", filledSubjectName: "",
subjectBrief: "",
job: null, job: null,
jobId: "", jobId: "",
}); });
@@ -357,6 +369,7 @@ export function VideoReplacePage({
tempFiles, tempFiles,
tempAssetRefs, tempAssetRefs,
filledSubjectName, filledSubjectName,
subjectBrief,
job, job,
jobId, jobId,
} = currentForm; } = currentForm;
@@ -824,6 +837,7 @@ export function VideoReplacePage({
product_id: current.source === "library" && replaceMode === "product" ? current.selectedProduct?.id : undefined, product_id: current.source === "library" && replaceMode === "product" ? current.selectedProduct?.id : undefined,
model_id: current.source === "library" && replaceMode === "character" ? current.selectedModel?.id : undefined, model_id: current.source === "library" && replaceMode === "character" ? current.selectedModel?.id : undefined,
image_asset_ids: current.source === "temporary" ? imageAssetIds : undefined, image_asset_ids: current.source === "temporary" ? imageAssetIds : undefined,
subject_brief: current.subjectBrief.trim() || undefined,
model: preferredModel.name, model: preferredModel.name,
aspect_ratio: aspectRatio, aspect_ratio: aspectRatio,
resolution: "720p", resolution: "720p",
@@ -902,6 +916,7 @@ export function VideoReplacePage({
...sizeFromRatio(task.aspect_ratio || "9:16"), ...sizeFromRatio(task.aspect_ratio || "9:16"),
}, },
filledSubjectName: subject, filledSubjectName: subject,
subjectBrief: task.subject_brief || "",
selectedModel: model || null, selectedModel: model || null,
selectedProduct: null, selectedProduct: null,
source: model ? "library" : (images.length ? "temporary" : ""), source: model ? "library" : (images.length ? "temporary" : ""),
@@ -926,6 +941,7 @@ export function VideoReplacePage({
...sizeFromRatio(task.aspect_ratio || "9:16"), ...sizeFromRatio(task.aspect_ratio || "9:16"),
}, },
filledSubjectName: subject, filledSubjectName: subject,
subjectBrief: task.subject_brief || "",
selectedProduct: product || null, selectedProduct: product || null,
selectedModel: null, selectedModel: null,
source: product ? "library" : (images.length ? "temporary" : ""), source: product ? "library" : (images.length ? "temporary" : ""),
@@ -1224,6 +1240,23 @@ export function VideoReplacePage({
</div> </div>
</div> </div>
<div className="video-flow-step">
<div className="video-flow-step-head">
<strong>{copy.briefStep}</strong>
<span>{copy.briefOptional}</span>
</div>
<textarea
className="textarea replace-brief-input"
value={subjectBrief}
maxLength={SUBJECT_BRIEF_MAX}
placeholder={copy.briefPlaceholder}
disabled={generating}
aria-label={copy.briefStep}
onChange={(event) => updateCurrentForm({ subjectBrief: event.target.value })}
/>
<p className="field-hint replace-brief-hint">{copy.briefHint}</p>
</div>
<div className="video-flow-actions replace-generate-action"> <div className="video-flow-actions replace-generate-action">
<button <button
type="button" type="button"
+1
View File
@@ -624,6 +624,7 @@ export type FreeVideoTask = {
replace_mode?: "product" | "character" | ""; replace_mode?: "product" | "character" | "";
subject_name?: string; subject_name?: string;
subject_source?: "library" | "temporary" | ""; subject_source?: "library" | "temporary" | "";
subject_brief?: string;
product_id?: string; product_id?: string;
model_id?: string; model_id?: string;
review_stage?: "reviewing" | ""; review_stage?: "reviewing" | "";
+5
View File
@@ -203,6 +203,11 @@
font-size: 11px; font-size: 11px;
} }
/* 第 3 步补充信息。输入框本体复用共享 .textarea,这里只给提示文案留出间距。 */
.vrep-page .replace-brief-hint {
margin: 6px 0 0;
}
.vrep-page .video-upload-field { .vrep-page .video-upload-field {
min-height: 148px; min-height: 148px;
display: grid; display: grid;