测试视频复刻
This commit is contained in:
@@ -901,13 +901,17 @@ NO_EMBEDDED_CAPTIONS_REQUIREMENT = (
|
|||||||
"只保留商品包装上参考图中原有的真实物理文字与标识。旁白和环境音可以保留,但不要把声音转成画面文字。"
|
"只保留商品包装上参考图中原有的真实物理文字与标识。旁白和环境音可以保留,但不要把声音转成画面文字。"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 故事板图会作为 Seedance 的参考图。导演信息应由页面从结构化脚本渲染,
|
# 故事板是创作者确认镜头节奏的导演稿,因此必须由 image-2 直接生成完整排版图;
|
||||||
# 不能烧进参考图,否则边框、时间标签和旁白文字会被视频模型误当成待保留的画面元素。
|
# 视频提示词会明确要求只提取其中的角色、商品、场景、动作和节奏,忽略版式文字。
|
||||||
STORYBOARD_VIDEO_KEYFRAME_REQUIREMENT = (
|
STORYBOARD_DIRECTOR_LAYOUT_REQUIREMENT = (
|
||||||
"【视频关键帧硬性规则】这张图将直接作为视频生成参考图:只生成一张干净、全幅、写实的 9:16 关键画面,"
|
"【导演故事板成图硬性要求】输出必须是一张完整、正式、可交付的电商短视频导演故事板图片,而不是无说明的拼图或单张海报。"
|
||||||
"画面必须完整呈现本段最关键的角色、商品、场景、动作和空间关系。"
|
"整体为竖版 9:16,采用清爽专业的影视分镜排版:顶部 1 行标题区,标出“导演故事板”、本段时长和画幅;"
|
||||||
"禁止拼图、多宫格、故事板边框、时间轴、机位标注、旁白文字、标题、价格贴片、字幕、UI 或任何叠加文字;"
|
"主体严格按照本段脚本的秒级分镜,优先整理为 3 个连续时间段(例如 0–5s、5–10s、10–15s;若脚本时长不同则按实际时间划分)。"
|
||||||
"只保留商品包装参考图中原有的真实物理文字与标识。"
|
"每个时间段必须包含三部分:左侧约 22% 宽的浅色信息栏,清晰写时间、景别、机位/运镜和简短动作;右侧约 78% 的写实主画面;"
|
||||||
|
"画面底部横条使用扬声器图标与“口播/旁白:”呈现该时间段对应旁白。各段按时间自上而下排列,边框、间距、对齐统一。"
|
||||||
|
"右侧画面要真实展现该秒级分镜的不同关键动作,人物脸、服装、商品外观、配色和场景连续一致;商品用法必须正确,包装真实文字和 Logo 不得改造。"
|
||||||
|
"故事板中的中文标注必须工整、简洁、可读,只写脚本已有的时间、机位、动作与旁白,不虚构价格、功效、活动或品牌信息。"
|
||||||
|
"禁止只给一张静态人物图、禁止无文字的四格拼图、禁止把三段画面重复成同一姿势。"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -2720,7 +2724,7 @@ def build_storyboard_frame_prompt(project, segment, extra_prompt: str = "") -> s
|
|||||||
)
|
)
|
||||||
cleaned = "\n".join(line for line in rendered.split("\n") if line.strip())
|
cleaned = "\n".join(line for line in rendered.split("\n") if line.strip())
|
||||||
return _apply_storyboard_output_ratio(
|
return _apply_storyboard_output_ratio(
|
||||||
f"{cleaned}\n{STORYBOARD_VIDEO_KEYFRAME_REQUIREMENT}", project
|
f"{cleaned}\n{STORYBOARD_DIRECTOR_LAYOUT_REQUIREMENT}", project
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -2748,6 +2752,8 @@ def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt:
|
|||||||
default = (
|
default = (
|
||||||
"{设定}{分镜}【脚本】{脚本}\n"
|
"{设定}{分镜}【脚本】{脚本}\n"
|
||||||
"严格按照脚本里的秒级分镜切换景别和动作,商品用法必须真实,不要诡异姿势或错误容器。"
|
"严格按照脚本里的秒级分镜切换景别和动作,商品用法必须真实,不要诡异姿势或错误容器。"
|
||||||
|
"如果参考图是带时间、机位、旁白标注的导演故事板,只提取其中的角色、商品、场景、动作和时间顺序;"
|
||||||
|
"不要把边框、标题、时间码、扬声器图标或任何故事板文字生成进视频。"
|
||||||
"电商带货短视频,商品露出清晰,节奏有转化感。不要字幕,不要背景音乐,但是要有音效,逼真的音效。"
|
"电商带货短视频,商品露出清晰,节奏有转化感。不要字幕,不要背景音乐,但是要有音效,逼真的音效。"
|
||||||
)
|
)
|
||||||
rendered = render_prompt(
|
rendered = render_prompt(
|
||||||
@@ -2933,7 +2939,7 @@ def build_storyboard_frame_prompt_refs(project, segment, refs: list[dict], extra
|
|||||||
)
|
)
|
||||||
cleaned = "\n".join(line for line in rendered.split("\n") if line.strip())
|
cleaned = "\n".join(line for line in rendered.split("\n") if line.strip())
|
||||||
return _apply_storyboard_output_ratio(
|
return _apply_storyboard_output_ratio(
|
||||||
f"{cleaned}\n{STORYBOARD_VIDEO_KEYFRAME_REQUIREMENT}", project
|
f"{cleaned}\n{STORYBOARD_DIRECTOR_LAYOUT_REQUIREMENT}", project
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
运行:DB_ENGINE=sqlite python manage.py test apps.ai.test_video_replace --settings=airshelf.settings.test
|
运行:DB_ENGINE=sqlite python manage.py test apps.ai.test_video_replace --settings=airshelf.settings.test
|
||||||
"""
|
"""
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
@@ -170,6 +172,95 @@ class SubmitVideoReplaceTests(TestCase):
|
|||||||
self.assertNotIn("reference_video", roles) # 参考视频不再发给火山
|
self.assertNotIn("reference_video", roles) # 参考视频不再发给火山
|
||||||
self.assertNotIn("video_url", [item.get("type") for item in content])
|
self.assertNotIn("video_url", [item.get("type") for item in content])
|
||||||
|
|
||||||
|
def _fake_source_file(self):
|
||||||
|
"""_FileFromPath 会 stat 真实文件,给个空的临时 mp4 顶替 TOS 下载结果。"""
|
||||||
|
tmp = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
|
||||||
|
tmp.write(b"\x00")
|
||||||
|
tmp.close()
|
||||||
|
self.addCleanup(lambda: Path(tmp.name).unlink(missing_ok=True))
|
||||||
|
return tmp.name
|
||||||
|
|
||||||
|
def test_digest_asset_video_runs_end_to_end_on_replace_task(self):
|
||||||
|
"""回归:复刻任务的 model_config 是 Seedance(视频),拆解若走
|
||||||
|
execute_routed_text_request 会撞「AITask.model_config 必须保持为主模型」直接炸。
|
||||||
|
这条用例不 mock digest_asset_video 本体,只挡住 TOS/ffmpeg/模型,确保真身跑得通。"""
|
||||||
|
from apps.ai import video_digest
|
||||||
|
|
||||||
|
task = self._submit()
|
||||||
|
text_model = ModelConfig.objects.filter(capability="text", status="active").first()
|
||||||
|
self.assertIsNotNone(text_model)
|
||||||
|
self.assertNotEqual(text_model.id, task.model_config_id) # 就是要两者不同
|
||||||
|
with patch.object(video_digest, "_download_source_to_temp", return_value=self._fake_source_file()), \
|
||||||
|
patch.object(
|
||||||
|
video_digest, "digest_input_from_upload",
|
||||||
|
return_value=(None, [], 8.0, {"width": 720, "height": 1280, "file_name": "参考.mp4"}),
|
||||||
|
), \
|
||||||
|
patch.object(video_digest, "resolve_digest_model_config", return_value=text_model), \
|
||||||
|
patch("apps.ai.services._collect_extract_text", return_value=(DIGEST_SAMPLE, {})):
|
||||||
|
run_replace_digest(task)
|
||||||
|
task.refresh_from_db()
|
||||||
|
self.assertEqual(task.status, AITask.Status.SUBMITTED)
|
||||||
|
self.assertIn("【镜头 01】", task.request_payload["prompt"])
|
||||||
|
self.assertIn("@目标商品", task.request_payload["prompt"])
|
||||||
|
|
||||||
|
def test_replace_digest_prompt_matches_standalone(self):
|
||||||
|
"""复刻内的提炼必须和「提炼提示词」页一字不差:同一份 SKILL.md、同一段引导语、
|
||||||
|
同样的 temperature / max_tokens,且不许掺商品信息。两处不一致 → 用户在提炼页
|
||||||
|
看到的分镜稿和复刻实际用的对不上,排查会乱。"""
|
||||||
|
from apps.ai import video_digest
|
||||||
|
|
||||||
|
task = self._submit()
|
||||||
|
text_model = ModelConfig.objects.filter(capability="text", status="active").first()
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
def _capture(provider, model_config, messages, **kwargs):
|
||||||
|
seen["messages"] = messages
|
||||||
|
seen["kwargs"] = kwargs
|
||||||
|
return (DIGEST_SAMPLE, {})
|
||||||
|
|
||||||
|
with patch.object(video_digest, "_download_source_to_temp", return_value=self._fake_source_file()), \
|
||||||
|
patch.object(
|
||||||
|
video_digest, "digest_input_from_upload",
|
||||||
|
return_value=(None, [], 8.0, {"width": 720, "height": 1280, "file_name": "参考.mp4"}),
|
||||||
|
), \
|
||||||
|
patch.object(video_digest, "resolve_digest_model_config", return_value=text_model), \
|
||||||
|
patch("apps.ai.services._collect_extract_text", side_effect=_capture):
|
||||||
|
run_replace_digest(task)
|
||||||
|
|
||||||
|
standalone = video_digest.build_digest_messages(
|
||||||
|
[], 8.0, product_hint="", video=None,
|
||||||
|
aspect_ratio="9:16 竖屏", file_title="参考",
|
||||||
|
)
|
||||||
|
self.assertEqual(seen["messages"], standalone)
|
||||||
|
self.assertEqual(seen["messages"][0]["content"], video_digest.load_digest_skill())
|
||||||
|
self.assertEqual(seen["kwargs"].get("temperature"), 0.4)
|
||||||
|
self.assertEqual(
|
||||||
|
seen["kwargs"].get("extra_body"), {"max_tokens": video_digest.DIGEST_MAX_TOKENS}
|
||||||
|
)
|
||||||
|
# 提炼阶段绝不能掺进商品名,否则拆出来的就不是「这条参考视频原本的样子」
|
||||||
|
self.assertNotIn("净颜精华", str(seen["messages"]))
|
||||||
|
|
||||||
|
def test_digest_retries_once_before_giving_up(self):
|
||||||
|
"""Gemini 偶尔吐废稿。复刻失败要用户从头重选素材,所以同模型再试一次。"""
|
||||||
|
from apps.ai import video_digest
|
||||||
|
|
||||||
|
task = self._submit()
|
||||||
|
text_model = ModelConfig.objects.filter(capability="text", status="active").first()
|
||||||
|
with patch.object(video_digest, "_download_source_to_temp", return_value=self._fake_source_file()), \
|
||||||
|
patch.object(
|
||||||
|
video_digest, "digest_input_from_upload",
|
||||||
|
return_value=(None, [], 8.0, {"width": 720, "height": 1280, "file_name": "参考.mp4"}),
|
||||||
|
), \
|
||||||
|
patch.object(video_digest, "resolve_digest_model_config", return_value=text_model), \
|
||||||
|
patch(
|
||||||
|
"apps.ai.services._collect_extract_text",
|
||||||
|
side_effect=[("", {}), (DIGEST_SAMPLE, {})],
|
||||||
|
) as call:
|
||||||
|
run_replace_digest(task)
|
||||||
|
self.assertEqual(call.call_count, 2) # 第一次空文废稿,第二次成功
|
||||||
|
task.refresh_from_db()
|
||||||
|
self.assertEqual(task.status, AITask.Status.SUBMITTED)
|
||||||
|
|
||||||
def test_digest_failure_fails_task_without_charging(self):
|
def test_digest_failure_fails_task_without_charging(self):
|
||||||
from apps.ai.video_digest import VideoDigestError
|
from apps.ai.video_digest import VideoDigestError
|
||||||
|
|
||||||
@@ -182,9 +273,86 @@ class SubmitVideoReplaceTests(TestCase):
|
|||||||
task.refresh_from_db()
|
task.refresh_from_db()
|
||||||
self.assertEqual(task.status, AITask.Status.FAILED)
|
self.assertEqual(task.status, AITask.Status.FAILED)
|
||||||
self.assertIn("不完整", task.error_message or "")
|
self.assertIn("不完整", task.error_message or "")
|
||||||
|
# 模型抖动不能报成「内容未通过生成审核」,那会让用户去白换素材
|
||||||
|
self.assertEqual(task.error_code, "processing_failed")
|
||||||
self.assertFalse(self.provider.create_video_task.called)
|
self.assertFalse(self.provider.create_video_task.called)
|
||||||
self.assertEqual(CreditReservation.objects.filter(task=task).count(), 0)
|
self.assertEqual(CreditReservation.objects.filter(task=task).count(), 0)
|
||||||
|
|
||||||
|
def test_missing_digest_model_is_rejected_at_submit(self):
|
||||||
|
"""提炼模型没配好要当场 400,不能建了任务、等一轮提炼才告诉用户。"""
|
||||||
|
|
||||||
|
|
||||||
|
before = AITask.objects.count()
|
||||||
|
with patch("apps.ai.video_digest.resolve_digest_model_config", return_value=None):
|
||||||
|
with self.assertRaisesMessage(ValueError, "视频提炼模型未配置"):
|
||||||
|
self._submit()
|
||||||
|
self.assertEqual(AITask.objects.count(), before)
|
||||||
|
self.assertFalse(self.provider.create_video_task.called)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_triview(self):
|
||||||
|
"""商品库的三视图是 standalone Asset:metadata.view=three_view + product_id。"""
|
||||||
|
tri = _asset(self.team, self.user, name="净颜精华·商品三视图", preview="http://tos/triview.png")
|
||||||
|
tri.metadata = {"product_id": str(self.product.id), "view": "three_view"}
|
||||||
|
tri.save(update_fields=["metadata"])
|
||||||
|
return tri
|
||||||
|
|
||||||
|
def _run_digest(self, task):
|
||||||
|
from apps.ai import video_digest
|
||||||
|
|
||||||
|
text_model = ModelConfig.objects.filter(capability="text", status="active").first()
|
||||||
|
with patch.object(video_digest, "_download_source_to_temp", return_value=self._fake_source_file()), \
|
||||||
|
patch.object(
|
||||||
|
video_digest, "digest_input_from_upload",
|
||||||
|
return_value=(None, [], 8.0, {"width": 720, "height": 1280, "file_name": "参考.mp4"}),
|
||||||
|
), \
|
||||||
|
patch.object(video_digest, "resolve_digest_model_config", return_value=text_model), \
|
||||||
|
patch("apps.ai.services._collect_extract_text", return_value=(DIGEST_SAMPLE, {})):
|
||||||
|
run_replace_digest(task)
|
||||||
|
task.refresh_from_db()
|
||||||
|
return task
|
||||||
|
|
||||||
|
def test_product_triview_is_sent_with_the_video(self):
|
||||||
|
"""商品库选商品时,三视图要跟着一起发给火山,并在提示词里被点名。"""
|
||||||
|
tri = self._make_triview()
|
||||||
|
task = self._submit()
|
||||||
|
labels = [ref["label"] for ref in task.request_payload["references"]]
|
||||||
|
self.assertIn("目标商品", labels)
|
||||||
|
self.assertIn("目标商品三视图", labels)
|
||||||
|
|
||||||
|
task = self._run_digest(task)
|
||||||
|
self.assertEqual(task.status, AITask.Status.SUBMITTED)
|
||||||
|
prompt = task.request_payload["prompt"]
|
||||||
|
self.assertIn("@目标商品三视图", prompt)
|
||||||
|
self.assertIn("白底多角度图", prompt)
|
||||||
|
|
||||||
|
content = self.provider.create_video_task.call_args.kwargs.get("content_items") or []
|
||||||
|
image_items = [item for item in content if item.get("type") == "image_url"]
|
||||||
|
self.assertEqual(len(image_items), 2) # 商品实拍图 + 三视图
|
||||||
|
api_prompt = task.request_payload.get("api_prompt") or ""
|
||||||
|
self.assertNotIn("@目标商品", api_prompt) # @label 已换成火山认的「图片N」指代
|
||||||
|
del tri
|
||||||
|
|
||||||
|
def test_prompt_skips_triview_note_when_product_has_none(self):
|
||||||
|
"""没有三视图就别在提示词里提它,免得模型去找一张不存在的图。"""
|
||||||
|
task = self._run_digest(self._submit())
|
||||||
|
prompt = task.request_payload["prompt"]
|
||||||
|
self.assertNotIn("三视图", prompt)
|
||||||
|
self.assertIn("@目标商品", prompt)
|
||||||
|
|
||||||
|
def test_triview_yields_budget_so_refs_never_exceed_nine(self):
|
||||||
|
"""三视图占一张名额:商品图要让位,否则火山那边超 9 张直接拒。"""
|
||||||
|
from apps.ai.video_replace import MAX_IMAGES
|
||||||
|
|
||||||
|
self._make_triview()
|
||||||
|
for i in range(MAX_IMAGES + 3):
|
||||||
|
extra = _asset(self.team, self.user, name=f"图{i}.png", preview=f"http://tos/p{i}.png")
|
||||||
|
ProductImage.objects.create(product=self.product, asset=extra, sort_order=i + 1)
|
||||||
|
task = self._submit()
|
||||||
|
refs = task.request_payload["references"]
|
||||||
|
self.assertEqual(len(refs), MAX_IMAGES)
|
||||||
|
self.assertEqual(refs[-1]["label"], "目标商品三视图")
|
||||||
|
|
||||||
def test_compact_digest_trims_human_only_sections(self):
|
def test_compact_digest_trims_human_only_sections(self):
|
||||||
compact = compact_digest_for_video(DIGEST_SAMPLE)
|
compact = compact_digest_for_video(DIGEST_SAMPLE)
|
||||||
self.assertIn("【镜头 02】", compact)
|
self.assertIn("【镜头 02】", compact)
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ MAX_FRAMES = 36
|
|||||||
FRAME_WIDTH = 768
|
FRAME_WIDTH = 768
|
||||||
FRAME_QUALITY = 3
|
FRAME_QUALITY = 3
|
||||||
DIGEST_MAX_TOKENS = 12288
|
DIGEST_MAX_TOKENS = 12288
|
||||||
|
# 视频复刻内联拆解的同模型重试次数(Gemini 偶尔吐废稿;复刻失败要用户从头重选素材,值得多试一次)
|
||||||
|
DIGEST_MAX_ATTEMPTS = 2
|
||||||
_SHOT_MARK = re.compile(r"【(?:镜头\s*\d+|第\s*\d+\s*镜)】")
|
_SHOT_MARK = re.compile(r"【(?:镜头\s*\d+|第\s*\d+\s*镜)】")
|
||||||
|
|
||||||
_FFMPEG_TIMEOUT = 60
|
_FFMPEG_TIMEOUT = 60
|
||||||
@@ -728,12 +730,12 @@ def _store_raw_source(*, team, path: str, suffix: str) -> tuple[str, str]:
|
|||||||
return stored.object_key, storage.public_url(object_key=stored.object_key)
|
return stored.object_key, storage.public_url(object_key=stored.object_key)
|
||||||
|
|
||||||
|
|
||||||
def _download_source_to_temp(object_key: str, suffix: str) -> str:
|
def _download_source_to_temp(object_key: str, suffix: str, *, bucket: str = "") -> str:
|
||||||
from apps.assets.storage import TosStorage
|
from apps.assets.storage import TosStorage
|
||||||
|
|
||||||
ext = suffix if str(suffix).startswith(".") else f".{suffix or 'mp4'}"
|
ext = suffix if str(suffix).startswith(".") else f".{suffix or 'mp4'}"
|
||||||
storage = TosStorage()
|
storage = TosStorage()
|
||||||
body = storage.client.get_object(Bucket=storage.bucket, Key=object_key)["Body"].read()
|
body = storage.client.get_object(Bucket=bucket or storage.bucket, Key=object_key)["Body"].read()
|
||||||
tmp = tempfile.NamedTemporaryFile(suffix=ext.lower(), delete=False)
|
tmp = tempfile.NamedTemporaryFile(suffix=ext.lower(), delete=False)
|
||||||
try:
|
try:
|
||||||
tmp.write(body)
|
tmp.write(body)
|
||||||
@@ -1357,7 +1359,7 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict]
|
|||||||
复刻本来就是一次收费,拆解是它的内部工序。模型调用的审计仍记在传入的复刻
|
复刻本来就是一次收费,拆解是它的内部工序。模型调用的审计仍记在传入的复刻
|
||||||
task 上(AIModelAttempt),出问题查得到。
|
task 上(AIModelAttempt),出问题查得到。
|
||||||
"""
|
"""
|
||||||
from apps.ai.services import execute_routed_text_request
|
from apps.ai.services import _collect_extract_text, get_text_provider
|
||||||
|
|
||||||
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
|
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
|
||||||
if primary is None or not primary.object_key:
|
if primary is None or not primary.object_key:
|
||||||
@@ -1372,7 +1374,7 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict]
|
|||||||
|
|
||||||
local_path = ""
|
local_path = ""
|
||||||
try:
|
try:
|
||||||
local_path = _download_source_to_temp(primary.object_key, suffix)
|
local_path = _download_source_to_temp(primary.object_key, suffix, bucket=primary.bucket or "")
|
||||||
base_name = (asset.name or "参考视频").rsplit(".", 1)[0]
|
base_name = (asset.name or "参考视频").rsplit(".", 1)[0]
|
||||||
upload = _FileFromPath(local_path, f"{base_name}{suffix}")
|
upload = _FileFromPath(local_path, f"{base_name}{suffix}")
|
||||||
video, frames, duration, extras = digest_input_from_upload(upload)
|
video, frames, duration, extras = digest_input_from_upload(upload)
|
||||||
@@ -1385,6 +1387,11 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict]
|
|||||||
duration,
|
duration,
|
||||||
len(frames),
|
len(frames),
|
||||||
)
|
)
|
||||||
|
# ⚠️ 这里必须和「提炼提示词」页(_digest_video)构造出完全相同的 messages:
|
||||||
|
# 同一份 SKILL.md 作 system、同一段 user 引导语、同样的 temperature / max_tokens。
|
||||||
|
# 绝对不要在这里追加商品信息(product_hint 保持默认空)——两处提炼稿一旦不同,
|
||||||
|
# 用户在提炼页看到的分镜和复刻实际用的分镜就对不上,排查会乱。
|
||||||
|
# test_video_replace.test_replace_digest_prompt_matches_standalone 会守住这一点。
|
||||||
messages = build_digest_messages(
|
messages = build_digest_messages(
|
||||||
frames,
|
frames,
|
||||||
duration,
|
duration,
|
||||||
@@ -1392,28 +1399,34 @@ def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict]
|
|||||||
aspect_ratio=ratio_label(int(extras.get("width") or 0), int(extras.get("height") or 0)),
|
aspect_ratio=ratio_label(int(extras.get("width") or 0), int(extras.get("height") or 0)),
|
||||||
file_title=title_from_filename(str(extras.get("file_name") or "")),
|
file_title=title_from_filename(str(extras.get("file_name") or "")),
|
||||||
)
|
)
|
||||||
routed = execute_routed_text_request(
|
# 这里不能走 execute_routed_text_request:它硬性要求 task.model_config 就是本次主模型,
|
||||||
task=task,
|
# 而复刻任务的 model_config 是 Seedance(视频模型),传进去必抛
|
||||||
primary_model=model_config,
|
# 「AITask.model_config 必须保持为用户选择或系统默认的主模型」。
|
||||||
messages=messages,
|
# 改为直连脚本/提炼同一条流式通道,并自己做「同模型重试」——绝不 fallback 到别的
|
||||||
streaming=True,
|
# 文本模型,它们看不了视频,换过去必然废稿。
|
||||||
structured_output=False,
|
provider = get_text_provider(model_config)
|
||||||
business_operation="video_digest",
|
expected_frames = len(frames) or (24 if video else 0)
|
||||||
temperature=0.4,
|
digest = ""
|
||||||
validate_text=lambda text: validate_digest_text(
|
last_error: Exception | None = None
|
||||||
text, duration=duration, frame_count=len(frames) or (24 if video else 0)
|
for attempt in range(1, DIGEST_MAX_ATTEMPTS + 1):
|
||||||
),
|
try:
|
||||||
extra_body={"max_tokens": DIGEST_MAX_TOKENS},
|
text, _payload = _collect_extract_text(
|
||||||
request_summary={
|
provider,
|
||||||
"duration_seconds": round(duration, 2),
|
model_config,
|
||||||
"frame_count": len(frames),
|
messages,
|
||||||
"input": "native_video" if video is not None else "frames",
|
temperature=0.4,
|
||||||
"for": "video_replace",
|
extra_body={"max_tokens": DIGEST_MAX_TOKENS},
|
||||||
},
|
)
|
||||||
allow_retry=False,
|
digest = validate_digest_text(text, duration=duration, frame_count=expected_frames)
|
||||||
allow_fallback=False,
|
break
|
||||||
)
|
except Exception as exc: # noqa: BLE001 — 空文/废稿/网络抖动都值得再来一次
|
||||||
_text, _response, digest = routed.value
|
last_error = exc
|
||||||
|
logger.warning(
|
||||||
|
"replace digest attempt %s/%s failed for task %s: %s",
|
||||||
|
attempt, DIGEST_MAX_ATTEMPTS, task.id, exc,
|
||||||
|
)
|
||||||
|
if not digest:
|
||||||
|
raise VideoDigestError(f"参考视频拆解失败:{last_error}")
|
||||||
meta = {
|
meta = {
|
||||||
"digest_model": model_config.name,
|
"digest_model": model_config.name,
|
||||||
"digest_input": "native_video" if video is not None else "frames",
|
"digest_input": "native_video" if video is not None else "frames",
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ FEATURE = "video_replace"
|
|||||||
REPLACE_MODES = {"product", "character"}
|
REPLACE_MODES = {"product", "character"}
|
||||||
MAX_IMAGES = 9
|
MAX_IMAGES = 9
|
||||||
LEGACY_PROMPT_PREFIX = "[视频复刻]"
|
LEGACY_PROMPT_PREFIX = "[视频复刻]"
|
||||||
|
# 商品三视图在商品库是 standalone Asset(metadata.view=three_view + product_id),
|
||||||
|
# 和项目内的 BaseAssetGroup 是两套存储,这里直接取商品库那份。
|
||||||
|
TRIVIEW_LABEL = "目标商品三视图"
|
||||||
REVIEW_UNAVAILABLE = "素材审核服务暂不可用,请稍后重试"
|
REVIEW_UNAVAILABLE = "素材审核服务暂不可用,请稍后重试"
|
||||||
REVIEW_FAILED = "参考素材未通过真人合规审核,请更换视频或图片后重试"
|
REVIEW_FAILED = "参考素材未通过真人合规审核,请更换视频或图片后重试"
|
||||||
REVIEW_SUBMIT_FAILED = "素材提交审核失败,请稍后重试"
|
REVIEW_SUBMIT_FAILED = "素材提交审核失败,请稍后重试"
|
||||||
@@ -57,10 +60,17 @@ PRODUCT_DIGEST_TAIL = (
|
|||||||
"1. 严格按上面分镜稿的镜头顺序、时间分配、景别、机位、运镜和剪辑节奏还原全片,不要自行增删镜头。\n"
|
"1. 严格按上面分镜稿的镜头顺序、时间分配、景别、机位、运镜和剪辑节奏还原全片,不要自行增删镜头。\n"
|
||||||
"2. 分镜稿里出现的原商品,全部替换成 @目标商品;商品的外形、颜色、材质、包装和标识必须与参考图完全一致,"
|
"2. 分镜稿里出现的原商品,全部替换成 @目标商品;商品的外形、颜色、材质、包装和标识必须与参考图完全一致,"
|
||||||
"不要改造型、不要改配色、不要凭空补细节。\n"
|
"不要改造型、不要改配色、不要凭空补细节。\n"
|
||||||
|
"{triview}"
|
||||||
"3. 人物、场景、光线、色调、动作和表情按分镜稿保持不变,只换商品。\n"
|
"3. 人物、场景、光线、色调、动作和表情按分镜稿保持不变,只换商品。\n"
|
||||||
"4. 台词/旁白按分镜稿逐字念出;原文提到旧商品名称的地方,改说「{subject}」。\n"
|
"4. 台词/旁白按分镜稿逐字念出;原文提到旧商品名称的地方,改说「{subject}」。\n"
|
||||||
"5. 分镜稿里的「字幕」栏只是对原片的记录,不要把这些文字画到画面上。"
|
"5. 分镜稿里的「字幕」栏只是对原片的记录,不要把这些文字画到画面上。"
|
||||||
)
|
)
|
||||||
|
# 商品库里有三视图时才追加这一条(没有就不提,免得模型去找一张不存在的图)。
|
||||||
|
PRODUCT_TRIVIEW_NOTE = (
|
||||||
|
" · @目标商品三视图 是这件商品的白底多角度图,用它确认商品的立体结构、比例和各个面的细节;"
|
||||||
|
"镜头转到任何角度,商品都要和三视图对得上。\n"
|
||||||
|
)
|
||||||
|
|
||||||
# 拆解期间的占位提示词。真正的提示词在 worker 拆完后回写。
|
# 拆解期间的占位提示词。真正的提示词在 worker 拆完后回写。
|
||||||
DIGEST_PENDING_PROMPT = "正在提炼参考视频的分镜稿…"
|
DIGEST_PENDING_PROMPT = "正在提炼参考视频的分镜稿…"
|
||||||
|
|
||||||
@@ -97,7 +107,7 @@ def compact_digest_for_video(digest_text: str) -> str:
|
|||||||
return "\n".join(line for line in lines if line.strip()).strip()
|
return "\n".join(line for line in lines if line.strip()).strip()
|
||||||
|
|
||||||
|
|
||||||
def build_product_replace_prompt(digest_text: str, subject_name: str) -> str:
|
def build_product_replace_prompt(digest_text: str, subject_name: str, *, has_triview: bool = False) -> str:
|
||||||
"""分镜稿 + 商品替换要求 → 交给 Seedance 的完整提示词。
|
"""分镜稿 + 商品替换要求 → 交给 Seedance 的完整提示词。
|
||||||
|
|
||||||
@目标商品 必须与 references 里第一张商品图的 label 对上,否则 build_content_items
|
@目标商品 必须与 references 里第一张商品图的 label 对上,否则 build_content_items
|
||||||
@@ -109,7 +119,10 @@ def build_product_replace_prompt(digest_text: str, subject_name: str) -> str:
|
|||||||
if not body:
|
if not body:
|
||||||
raise ValueError("参考视频拆解结果为空,请重试")
|
raise ValueError("参考视频拆解结果为空,请重试")
|
||||||
subject = (subject_name or "").strip() or "目标商品"
|
subject = (subject_name or "").strip() or "目标商品"
|
||||||
tail = PRODUCT_DIGEST_TAIL.format(subject=subject)
|
tail = PRODUCT_DIGEST_TAIL.format(
|
||||||
|
subject=subject,
|
||||||
|
triview=PRODUCT_TRIVIEW_NOTE if has_triview else "",
|
||||||
|
)
|
||||||
return enforce_no_embedded_captions(f"{PRODUCT_DIGEST_HEAD}\n\n{body}\n\n{tail}")
|
return enforce_no_embedded_captions(f"{PRODUCT_DIGEST_HEAD}\n\n{body}\n\n{tail}")
|
||||||
|
|
||||||
|
|
||||||
@@ -210,7 +223,17 @@ def submit_video_replace(*, team, user, params: dict):
|
|||||||
}
|
}
|
||||||
|
|
||||||
if replace_mode == "product":
|
if replace_mode == "product":
|
||||||
# 商品复刻:参考视频只喂给提炼模型,不进火山 references(所以也不用送火山审核)。
|
# 商品复刻:参考视频只喂给提炼模型,不进火山 references(所以参考视频本身不再送火山审核)。
|
||||||
|
# 两个「一定会失败」的前提在这里就查掉,别让用户白等一轮提炼才看到报错:
|
||||||
|
from .video_digest import resolve_digest_model_config
|
||||||
|
|
||||||
|
if resolve_digest_model_config() is None:
|
||||||
|
raise ValueError("视频提炼模型未配置,请联系管理员")
|
||||||
|
# 商品图仍要过审才能当生成参考。提前送审 → 审核和提炼并行跑,明确不过审的直接 400。
|
||||||
|
review_state = _ensure_replace_refs_reviewed(team, image_refs)
|
||||||
|
if review_state == "failed":
|
||||||
|
raise ValueError(REVIEW_FAILED)
|
||||||
|
image_refs = _refresh_replace_refs(team, image_refs)
|
||||||
# 先秒回一个 CREATED 任务,拆解这种慢活(Gemini 半分钟起)交给 worker。
|
# 先秒回一个 CREATED 任务,拆解这种慢活(Gemini 半分钟起)交给 worker。
|
||||||
extra.update({
|
extra.update({
|
||||||
"digest_source_asset_id": str(video.id),
|
"digest_source_asset_id": str(video.id),
|
||||||
@@ -313,18 +336,23 @@ def run_replace_digest(task) -> None:
|
|||||||
task.team, uuid.UUID(str(payload.get("digest_source_asset_id") or "")), "参考视频"
|
task.team, uuid.UUID(str(payload.get("digest_source_asset_id") or "")), "参考视频"
|
||||||
)
|
)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
_fail_reviewing_task(task, "参考视频已失效,请重新上传")
|
_fail_reviewing_task(task, "参考视频已失效,请重新上传", error_code="asset_unavailable")
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
digest, meta = digest_asset_video(asset=asset, task=task)
|
digest, meta = digest_asset_video(asset=asset, task=task)
|
||||||
prompt = build_product_replace_prompt(digest, str(payload.get("subject_name") or ""))
|
has_triview = any(
|
||||||
|
(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
|
||||||
|
)
|
||||||
except (VideoDigestError, ValueError) as exc:
|
except (VideoDigestError, ValueError) as exc:
|
||||||
_fail_reviewing_task(task, str(exc))
|
_fail_reviewing_task(task, str(exc), error_code="processing_failed")
|
||||||
return
|
return
|
||||||
except Exception as exc: # noqa: BLE001 — 拆解任何异常都要把任务收尾,别留 CREATED 僵尸
|
except Exception as exc: # noqa: BLE001 — 拆解任何异常都要把任务收尾,别留 CREATED 僵尸
|
||||||
logger.exception("video replace digest failed for %s", task.id)
|
logger.exception("video replace digest failed for %s", task.id)
|
||||||
_fail_reviewing_task(task, f"参考视频拆解失败:{exc}")
|
_fail_reviewing_task(task, f"参考视频拆解失败:{exc}", error_code="processing_failed")
|
||||||
return
|
return
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@@ -354,10 +382,20 @@ def _legacy_replace_mode(prompt: str) -> str:
|
|||||||
return "character" if prompt.startswith("[视频复刻·角色]") else "product"
|
return "character" if prompt.startswith("[视频复刻·角色]") else "product"
|
||||||
|
|
||||||
|
|
||||||
def _fail_reviewing_task(task, message: str):
|
def _fail_reviewing_task(task, message: str, *, error_code: str = ""):
|
||||||
|
"""收尾一个还没提交火山的任务。
|
||||||
|
|
||||||
|
``_fail_pending_free_video`` 把错误码写死成 content_rejected —— 那是审核不通过的语义,
|
||||||
|
前端会渲染成「内容未通过生成审核」且不可重试。拆解失败/素材丢失不是那回事,
|
||||||
|
这里按实际原因改码,否则模型抖一下会被误报成合规问题,用户白白去换素材。
|
||||||
|
"""
|
||||||
from .free_video import _fail_pending_free_video
|
from .free_video import _fail_pending_free_video
|
||||||
|
|
||||||
return _fail_pending_free_video(task, message)
|
task = _fail_pending_free_video(task, message)
|
||||||
|
if error_code and task.status == AITask.Status.FAILED and task.error_code != error_code:
|
||||||
|
task.error_code = error_code
|
||||||
|
task.save(update_fields=["error_code", "updated_at"])
|
||||||
|
return task
|
||||||
|
|
||||||
|
|
||||||
def _enqueue_replace_review_poll(task):
|
def _enqueue_replace_review_poll(task):
|
||||||
@@ -701,6 +739,21 @@ def _library_image_ref(asset: Asset, *, team, label: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _product_triview_asset(team, product_id: uuid.UUID):
|
||||||
|
"""商品库里这个商品的三视图(白底多角度单张 16:9)。没有就返回 None,不挡生成。"""
|
||||||
|
return (
|
||||||
|
Asset.objects.filter(
|
||||||
|
team=team,
|
||||||
|
is_deleted=False,
|
||||||
|
purged_at__isnull=True,
|
||||||
|
metadata__product_id=str(product_id),
|
||||||
|
metadata__view="three_view",
|
||||||
|
)
|
||||||
|
.order_by("-created_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _product_library_refs(team, product_id: uuid.UUID) -> tuple[str, list, str]:
|
def _product_library_refs(team, product_id: uuid.UUID) -> tuple[str, list, str]:
|
||||||
product = (
|
product = (
|
||||||
Product.objects.filter(id=product_id, team=team, purged_at__isnull=True, status=Product.Status.ACTIVE)
|
Product.objects.filter(id=product_id, team=team, purged_at__isnull=True, status=Product.Status.ACTIVE)
|
||||||
@@ -710,21 +763,27 @@ def _product_library_refs(team, product_id: uuid.UUID) -> tuple[str, list, str]:
|
|||||||
)
|
)
|
||||||
if product is None:
|
if product is None:
|
||||||
raise ValueError("商品不存在或已被删除")
|
raise ValueError("商品不存在或已被删除")
|
||||||
|
triview = _product_triview_asset(team, product_id)
|
||||||
|
# 三视图也占一张参考图的名额,商品图要给它让位,否则火山那边超 9 张直接拒。
|
||||||
|
image_budget = MAX_IMAGES - 1 if triview is not None else MAX_IMAGES
|
||||||
assets = []
|
assets = []
|
||||||
seen = set()
|
seen = {triview.id} if triview is not None else set()
|
||||||
for image in product.images.all():
|
for image in product.images.all():
|
||||||
asset = image.asset
|
asset = image.asset
|
||||||
if asset is None or asset.id in seen or asset.is_deleted:
|
if asset is None or asset.id in seen or asset.is_deleted:
|
||||||
continue
|
continue
|
||||||
seen.add(asset.id)
|
seen.add(asset.id)
|
||||||
assets.append(asset)
|
assets.append(asset)
|
||||||
if len(assets) >= MAX_IMAGES:
|
if len(assets) >= image_budget:
|
||||||
break
|
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 not product.cover_asset.is_deleted:
|
||||||
assets.append(product.cover_asset)
|
assets.append(product.cover_asset)
|
||||||
if not assets:
|
if not assets and triview is None:
|
||||||
raise ValueError("这个商品还没有可用图片")
|
raise ValueError("这个商品还没有可用图片")
|
||||||
refs = [_library_image_ref(asset, team=team, label="目标商品" if index == 0 else f"目标商品{index + 1}") for index, asset in enumerate(assets)]
|
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:
|
||||||
|
# 放最后:@目标商品 仍指向第一张实拍图,三视图作为「各面长什么样」的补充证据。
|
||||||
|
refs.append(_library_image_ref(triview, team=team, label=TRIVIEW_LABEL))
|
||||||
return product.title, refs, "library"
|
return product.title, refs, "library"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1231,20 +1231,6 @@
|
|||||||
.sb-scene-thumb .placeholder .sb-frame-rv .rv-label { display: none; }
|
.sb-scene-thumb .placeholder .sb-frame-rv .rv-label { display: none; }
|
||||||
.sb-main-img .sb-main-rv { position: absolute; top: 10px; left: 10px; z-index: 4; }
|
.sb-main-img .sb-main-rv { position: absolute; top: 10px; left: 10px; z-index: 4; }
|
||||||
|
|
||||||
/* 故事板说明层:从已确认脚本直接排版,不把文字烧进给视频模型的关键帧。 */
|
|
||||||
.sb-director-board { margin: 0 0 14px; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-md); overflow: hidden; }
|
|
||||||
.sb-director-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border-faint); background: var(--surface); }
|
|
||||||
.sb-director-head > div { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
|
||||||
.sb-director-head .mono { font-family: var(--font-mono); font-size: 10px; letter-spacing: .05em; color: var(--black-alpha-48); white-space: nowrap; }
|
|
||||||
.sb-director-head strong { font-size: 12px; font-weight: 500; color: var(--accent-black); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
||||||
.sb-director-beat { display: grid; grid-template-columns: 54px minmax(0, 1fr); border-bottom: 1px solid var(--border-faint); }
|
|
||||||
.sb-director-beat:last-child { border-bottom: 0; }
|
|
||||||
.sb-director-time { display: flex; align-items: flex-start; justify-content: center; padding: 11px 6px; background: var(--surface); color: var(--heat); font-family: var(--font-mono); font-size: 11px; font-variant-numeric: tabular-nums; }
|
|
||||||
.sb-director-copy { min-width: 0; padding: 10px 12px; }
|
|
||||||
.sb-director-direction { color: var(--accent-black); font-size: 12px; line-height: 1.55; }
|
|
||||||
.sb-director-narration { margin-top: 5px; color: var(--black-alpha-56); font-size: 11.5px; line-height: 1.55; }
|
|
||||||
.sb-director-empty { padding: 14px 12px; color: var(--black-alpha-48); font-size: 12px; line-height: 1.6; }
|
|
||||||
|
|
||||||
.sb-rerun-note { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; margin-bottom: 14px; background: rgba(180,83,9,.08); border: 1px solid rgba(180,83,9,.20); border-radius: var(--r-md); color: #7C3A05; line-height: 1.55; }
|
.sb-rerun-note { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; margin-bottom: 14px; background: rgba(180,83,9,.08); border: 1px solid rgba(180,83,9,.20); border-radius: var(--r-md); color: #7C3A05; line-height: 1.55; }
|
||||||
.sb-rerun-note .warn-ic { width: 22px; height: 22px; border-radius: var(--r-sm); background: rgba(180,83,9,.12); color: #B45309; display: grid; place-items: center; flex: 0 0 22px; }
|
.sb-rerun-note .warn-ic { width: 22px; height: 22px; border-radius: var(--r-sm); background: rgba(180,83,9,.12); color: #B45309; display: grid; place-items: center; flex: 0 0 22px; }
|
||||||
.sb-rerun-note .warn-ic svg { width: 14px; height: 14px; }
|
.sb-rerun-note .warn-ic svg { width: 14px; height: 14px; }
|
||||||
|
|||||||
@@ -34,26 +34,6 @@ const KIND_LABEL: Record<string, string> = { product: "商品", person: "角色"
|
|||||||
// 脚本来源 → 「来源」brief pill 文案
|
// 脚本来源 → 「来源」brief pill 文案
|
||||||
// 入口收敛为「脚本辅助生成 / 上传脚本」两种。theme 键保留:历史稿的 source 仍可能是旧的「一句话主题」。
|
// 入口收敛为「脚本辅助生成 / 上传脚本」两种。theme 键保留:历史稿的 source 仍可能是旧的「一句话主题」。
|
||||||
const SOURCE_LABEL: Record<string, string> = { ai: "脚本辅助生成", theme: "脚本辅助生成", manual: "上传脚本", video: "上传视频提炼" };
|
const SOURCE_LABEL: Record<string, string> = { ai: "脚本辅助生成", theme: "脚本辅助生成", manual: "上传脚本", video: "上传视频提炼" };
|
||||||
type DirectorBeat = { time: string; direction: string; narration: string };
|
|
||||||
const DIRECTOR_BEAT_RE = /(?:^|\n)\s*(\d{1,2})\s*[-–—~到至]\s*(\d{1,2})\s*(?:s|秒)?\s*[::]*\s*([^\n]+)/g;
|
|
||||||
function buildDirectorBeats(visual: string, narration: string, duration: number): DirectorBeat[] {
|
|
||||||
const matches = [...(visual || "").matchAll(DIRECTOR_BEAT_RE)];
|
|
||||||
const narrationLines = (narration || "").split(/(?<=[。!?!?])/).map((line) => line.trim()).filter(Boolean);
|
|
||||||
if (matches.length) {
|
|
||||||
return matches.slice(0, 5).map((match, index) => ({
|
|
||||||
time: `${match[1]}–${match[2]}s`,
|
|
||||||
direction: match[3].trim(),
|
|
||||||
narration: narrationLines[index] || (index === 0 ? narration.trim() : ""),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
const count = Math.min(3, Math.max(1, narrationLines.length || 1));
|
|
||||||
const step = duration / count;
|
|
||||||
return Array.from({ length: count }, (_, index) => ({
|
|
||||||
time: `${Math.round(index * step)}–${Math.round((index + 1) * step)}s`,
|
|
||||||
direction: visual.trim() || "按本镜脚本完成关键动作与商品露出",
|
|
||||||
narration: narrationLines[index] || (index === 0 ? narration.trim() : ""),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
// 旁白配音音色预设(语音合成经典版试用包实测可用,与后端 VOICEOVER_VOICES 对齐)
|
// 旁白配音音色预设(语音合成经典版试用包实测可用,与后端 VOICEOVER_VOICES 对齐)
|
||||||
const VO_VOICES = [
|
const VO_VOICES = [
|
||||||
{ key: "BV700_streaming", label: "灿灿 · 活力女声" },
|
{ key: "BV700_streaming", label: "灿灿 · 活力女声" },
|
||||||
@@ -2070,7 +2050,7 @@ export function PipelinePage(props: {
|
|||||||
document.body.style.userSelect = "";
|
document.body.style.userSelect = "";
|
||||||
};
|
};
|
||||||
}, [gutterDragging]);
|
}, [gutterDragging]);
|
||||||
const SB_PROMPT_DEFAULT = "统一商品、人物、场景风格,生成干净、可直接指导视频的关键帧;导演分镜说明按脚本自动生成";
|
const SB_PROMPT_DEFAULT = "统一商品、人物、场景风格,生成完整、可直接指导视频的导演故事板";
|
||||||
// 整张风格提示词:项目级(原 StoryboardVersion.prompt 的去处),重跑时生效
|
// 整张风格提示词:项目级(原 StoryboardVersion.prompt 的去处),重跑时生效
|
||||||
const sbSavedPrompt = (project.metadata as Record<string, unknown> | undefined)?.storyboard_prompt as string | undefined;
|
const sbSavedPrompt = (project.metadata as Record<string, unknown> | undefined)?.storyboard_prompt as string | undefined;
|
||||||
const [storyboardPrompt, setStoryboardPrompt] = useState(sbSavedPrompt || SB_PROMPT_DEFAULT);
|
const [storyboardPrompt, setStoryboardPrompt] = useState(sbSavedPrompt || SB_PROMPT_DEFAULT);
|
||||||
@@ -3871,13 +3851,6 @@ export function PipelinePage(props: {
|
|||||||
const sbViewedIsAdopted = !sbViewedVer || sbViewedVer.is_adopted || sbViewedVer.id === sbActiveShot?.adopted_version;
|
const sbViewedIsAdopted = !sbViewedVer || sbViewedVer.is_adopted || sbViewedVer.id === sbActiveShot?.adopted_version;
|
||||||
const mainUrl = sbViewedVer ? (sbViewedVer.asset_url || assetUrl(sbViewedVer.asset)) : shotImg(sbActiveShot);
|
const mainUrl = sbViewedVer ? (sbViewedVer.asset_url || assetUrl(sbViewedVer.asset)) : shotImg(sbActiveShot);
|
||||||
const mainAid = sbViewedVer?.asset || sbActiveShot?.adopted_asset || "";
|
const mainAid = sbViewedVer?.asset || sbActiveShot?.adopted_asset || "";
|
||||||
const activeScriptShot = sbActiveShot ? shots[sbActiveShot.sort_order] : null;
|
|
||||||
const directorBeats = buildDirectorBeats(
|
|
||||||
activeScriptShot?.visual_prompt || "",
|
|
||||||
activeScriptShot?.narration || "",
|
|
||||||
activeScriptShot?.duration_seconds || SEGMENT_DURATION_MAX,
|
|
||||||
);
|
|
||||||
const directorRange = sceneTimes[sbSelected] || "—";
|
|
||||||
return (
|
return (
|
||||||
<section className="stage active" data-stage-pane="3">
|
<section className="stage active" data-stage-pane="3">
|
||||||
<div className="stage-storyboard">
|
<div className="stage-storyboard">
|
||||||
@@ -3949,22 +3922,7 @@ export function PipelinePage(props: {
|
|||||||
<span>{sbActiveShot.error_message}</span>
|
<span>{sbActiveShot.error_message}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="muted-2" style={{ fontSize: "12px", lineHeight: 1.55, marginBottom: "10px" }}>每个场生成一张干净视频关键帧;下方导演稿按本镜真实脚本展开,视频只使用关键帧,不会带入文字或边框。</div>
|
<div className="muted-2" style={{ fontSize: "12px", lineHeight: 1.55, marginBottom: "10px" }}>每个场由 image-2 直接生成一张完整导演故事板图片;可单独「重跑本场」只重出这一张,每场各自留历史版本,互不影响。</div>
|
||||||
<div className="sb-director-board" aria-label="本场导演故事板">
|
|
||||||
<div className="sb-director-head">
|
|
||||||
<div><span className="mono">DIRECTOR BOARD</span><strong>场 {sbSelected + 1} · {directorRange}</strong></div>
|
|
||||||
<span className="pill pill-l3 neutral"><span className="dot" />9:16 · 关键帧</span>
|
|
||||||
</div>
|
|
||||||
{directorBeats.length ? directorBeats.map((beat, index) => (
|
|
||||||
<div className="sb-director-beat" key={`${beat.time}-${index}`}>
|
|
||||||
<div className="sb-director-time">{beat.time}</div>
|
|
||||||
<div className="sb-director-copy">
|
|
||||||
<div className="sb-director-direction">{beat.direction}</div>
|
|
||||||
{beat.narration && <div className="sb-director-narration">口播/旁白:{beat.narration}</div>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)) : <div className="sb-director-empty">脚本生成后,这里会自动展示每段时间、机位、动作和旁白。</div>}
|
|
||||||
</div>
|
|
||||||
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "6px", letterSpacing: ".04em" }}>整张风格提示词(重跑时生效,可编辑)</div>
|
<div className="muted mono" style={{ fontSize: "12px", fontWeight: 500, marginBottom: "6px", letterSpacing: ".04em" }}>整张风格提示词(重跑时生效,可编辑)</div>
|
||||||
<PromptBox
|
<PromptBox
|
||||||
className="prompt-edit"
|
className="prompt-edit"
|
||||||
|
|||||||
@@ -334,7 +334,10 @@ export function VideoReplacePage({
|
|||||||
resolution: "720p",
|
resolution: "720p",
|
||||||
duration: outputDuration,
|
duration: outputDuration,
|
||||||
refs: [
|
refs: [
|
||||||
...(videoRef ? [{ type: "video", duration: videoRef.duration || videoMeta.duration }] : []),
|
// 商品复刻的参考视频只用来提炼分镜稿,不会发给火山,算进估价会让显示积分高于实际扣费。
|
||||||
|
...(videoRef && replaceMode === "character"
|
||||||
|
? [{ type: "video", duration: videoRef.duration || videoMeta.duration }]
|
||||||
|
: []),
|
||||||
...((source === "library"
|
...((source === "library"
|
||||||
? Array.from({ length: Math.max(1, libraryImageCount) }, () => ({ type: "image" as const }))
|
? Array.from({ length: Math.max(1, libraryImageCount) }, () => ({ type: "image" as const }))
|
||||||
: (tempFiles.length ? tempFiles : tempAssetRefs)
|
: (tempFiles.length ? tempFiles : tempAssetRefs)
|
||||||
|
|||||||
Reference in New Issue
Block a user