优化视频复刻
This commit is contained in:
@@ -901,6 +901,15 @@ NO_EMBEDDED_CAPTIONS_REQUIREMENT = (
|
|||||||
"只保留商品包装上参考图中原有的真实物理文字与标识。旁白和环境音可以保留,但不要把声音转成画面文字。"
|
"只保留商品包装上参考图中原有的真实物理文字与标识。旁白和环境音可以保留,但不要把声音转成画面文字。"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 故事板图会作为 Seedance 的参考图。导演信息应由页面从结构化脚本渲染,
|
||||||
|
# 不能烧进参考图,否则边框、时间标签和旁白文字会被视频模型误当成待保留的画面元素。
|
||||||
|
STORYBOARD_VIDEO_KEYFRAME_REQUIREMENT = (
|
||||||
|
"【视频关键帧硬性规则】这张图将直接作为视频生成参考图:只生成一张干净、全幅、写实的 9:16 关键画面,"
|
||||||
|
"画面必须完整呈现本段最关键的角色、商品、场景、动作和空间关系。"
|
||||||
|
"禁止拼图、多宫格、故事板边框、时间轴、机位标注、旁白文字、标题、价格贴片、字幕、UI 或任何叠加文字;"
|
||||||
|
"只保留商品包装参考图中原有的真实物理文字与标识。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def enforce_no_embedded_captions(prompt: str) -> str:
|
def enforce_no_embedded_captions(prompt: str) -> str:
|
||||||
"""所有视频入口最终汇入这里,避免某个入口漏传“无字幕”导致模型自行加花字。"""
|
"""所有视频入口最终汇入这里,避免某个入口漏传“无字幕”导致模型自行加花字。"""
|
||||||
@@ -2710,7 +2719,9 @@ def build_storyboard_frame_prompt(project, segment, extra_prompt: str = "") -> s
|
|||||||
补充=(("\n" + extra_prompt.strip()) if extra_prompt else ""),
|
补充=(("\n" + extra_prompt.strip()) if extra_prompt else ""),
|
||||||
)
|
)
|
||||||
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 enforce_no_embedded_captions(_apply_storyboard_output_ratio(cleaned, project))
|
return _apply_storyboard_output_ratio(
|
||||||
|
f"{cleaned}\n{STORYBOARD_VIDEO_KEYFRAME_REQUIREMENT}", project
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt: str = "") -> str:
|
def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt: str = "") -> str:
|
||||||
@@ -2921,7 +2932,9 @@ def build_storyboard_frame_prompt_refs(project, segment, refs: list[dict], extra
|
|||||||
补充=(("\n" + extra_prompt.strip()) if extra_prompt else ""),
|
补充=(("\n" + extra_prompt.strip()) if extra_prompt else ""),
|
||||||
)
|
)
|
||||||
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 enforce_no_embedded_captions(_apply_storyboard_output_ratio(cleaned, project))
|
return _apply_storyboard_output_ratio(
|
||||||
|
f"{cleaned}\n{STORYBOARD_VIDEO_KEYFRAME_REQUIREMENT}", project
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _is_transient_error(exc: Exception) -> bool:
|
def _is_transient_error(exc: Exception) -> bool:
|
||||||
|
|||||||
@@ -41,6 +41,19 @@ def run_video_digest_task(self, task_id: str) -> str:
|
|||||||
return task_id
|
return task_id
|
||||||
|
|
||||||
|
|
||||||
|
@app.task(bind=True, max_retries=0)
|
||||||
|
def run_video_replace_digest_task(self, task_id: str) -> str:
|
||||||
|
"""商品复刻的第一道工序(参考视频 → 分镜稿,Gemini 半分钟起)在 worker 内跑。
|
||||||
|
失败自己把任务收成 FAILED(见 run_replace_digest),故 max_retries=0,不向上抛重试。"""
|
||||||
|
from apps.ai.models import AITask
|
||||||
|
from apps.ai.video_replace import run_replace_digest
|
||||||
|
|
||||||
|
task = AITask.objects.select_related("team", "created_by", "model_config").filter(id=task_id).first()
|
||||||
|
if task is not None:
|
||||||
|
run_replace_digest(task)
|
||||||
|
return task_id
|
||||||
|
|
||||||
|
|
||||||
@app.task(bind=True, max_retries=0)
|
@app.task(bind=True, max_retries=0)
|
||||||
def generate_base_asset_task(self, task_id: str) -> str:
|
def generate_base_asset_task(self, task_id: str) -> str:
|
||||||
"""基础资产(商品/人物/场景立绘)的慢出图在 worker 内跑,Web 层不被占住。
|
"""基础资产(商品/人物/场景立绘)的慢出图在 worker 内跑,Web 层不被占住。
|
||||||
|
|||||||
@@ -12,12 +12,65 @@ from apps.accounts.models import Team, TeamMember, User
|
|||||||
from apps.ai.free_video import submit_free_video
|
from apps.ai.free_video import submit_free_video
|
||||||
from apps.ai.models import AITask, ModelConfig
|
from apps.ai.models import AITask, ModelConfig
|
||||||
from apps.ai.test_free_video import STANDARD, _ark_create_response
|
from apps.ai.test_free_video import STANDARD, _ark_create_response
|
||||||
from apps.ai.video_replace import PRODUCT_PROMPT, REVIEW_FAILED, REVIEW_UNAVAILABLE, advance_video_replace, submit_video_replace
|
from apps.ai.video_replace import (
|
||||||
|
CHARACTER_PROMPT,
|
||||||
|
DIGEST_PENDING_PROMPT,
|
||||||
|
REVIEW_FAILED,
|
||||||
|
REVIEW_UNAVAILABLE,
|
||||||
|
advance_video_replace,
|
||||||
|
compact_digest_for_video,
|
||||||
|
run_replace_digest,
|
||||||
|
submit_video_replace,
|
||||||
|
)
|
||||||
from apps.assets.models import Asset, AssetFile, Model
|
from apps.assets.models import Asset, AssetFile, Model
|
||||||
from apps.billing.models import CreditAccount, CreditReservation
|
from apps.billing.models import CreditAccount, CreditReservation
|
||||||
from apps.products.models import Product, ProductImage
|
from apps.products.models import Product, ProductImage
|
||||||
|
|
||||||
|
|
||||||
|
DIGEST_SAMPLE = """片名:《晨间一杯》
|
||||||
|
视频类型:口播带货
|
||||||
|
画面比例:9:16 竖屏
|
||||||
|
时长:8秒
|
||||||
|
整体风格:写实、暖色调、清晨感
|
||||||
|
形式:口播
|
||||||
|
结构:场景种草
|
||||||
|
主线:女生早晨用旧牌精华后出门。
|
||||||
|
|
||||||
|
【镜头 01】
|
||||||
|
时间:00:00-00:04
|
||||||
|
时长:4秒
|
||||||
|
景别:中近景
|
||||||
|
机位:平视
|
||||||
|
运镜:固定
|
||||||
|
画面:女生站在窗边,把旧牌精华倒进玻璃杯。
|
||||||
|
人物动作:右手拿瓶,左手扶杯。
|
||||||
|
人物表情:轻松。
|
||||||
|
台词/旁白:每天早上我都要来一支旧牌精华。
|
||||||
|
音效:液体倒入声。
|
||||||
|
背景音乐:轻快钢琴。
|
||||||
|
字幕:每天早上来一支
|
||||||
|
备注:晨光从左侧打入。
|
||||||
|
|
||||||
|
【镜头 02】
|
||||||
|
时间:00:04-00:08
|
||||||
|
时长:4秒
|
||||||
|
景别:特写
|
||||||
|
机位:平视
|
||||||
|
运镜:缓慢推近
|
||||||
|
画面:手持瓶身正对镜头,标签清晰。
|
||||||
|
人物动作:转动瓶身。
|
||||||
|
人物表情:不可见。
|
||||||
|
台词/旁白:一支顶三支。
|
||||||
|
音效:无。
|
||||||
|
背景音乐:钢琴延续。
|
||||||
|
字幕:一支顶三支
|
||||||
|
备注:无。
|
||||||
|
|
||||||
|
【拆解存疑】
|
||||||
|
- 第 2 镜标签文字看不清,请校对品牌名。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _asset(team, user, *, kind=Asset.Type.IMAGE, name="素材", duration_ms=None, preview="http://tos/1.png", review_status="active", review_remote_id=None):
|
def _asset(team, user, *, kind=Asset.Type.IMAGE, name="素材", duration_ms=None, preview="http://tos/1.png", review_status="active", review_remote_id=None):
|
||||||
asset = Asset.objects.create(
|
asset = Asset.objects.create(
|
||||||
team=team,
|
team=team,
|
||||||
@@ -51,6 +104,7 @@ class SubmitVideoReplaceTests(TestCase):
|
|||||||
self.provider.create_video_task.return_value = _ark_create_response()
|
self.provider.create_video_task.return_value = _ark_create_response()
|
||||||
patch("apps.ai.services.build_provider", return_value=self.provider).start()
|
patch("apps.ai.services.build_provider", return_value=self.provider).start()
|
||||||
patch("apps.ai.tasks.poll_free_video_task.apply_async").start()
|
patch("apps.ai.tasks.poll_free_video_task.apply_async").start()
|
||||||
|
patch("apps.ai.tasks.run_video_replace_digest_task.delay").start()
|
||||||
patch("apps.assets.assets_client.is_enabled", return_value=False).start()
|
patch("apps.assets.assets_client.is_enabled", return_value=False).start()
|
||||||
self.addCleanup(patch.stopall)
|
self.addCleanup(patch.stopall)
|
||||||
self.video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="参考.mp4", duration_ms=8000, preview="http://tos/video.mp4")
|
self.video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="参考.mp4", duration_ms=8000, preview="http://tos/video.mp4")
|
||||||
@@ -71,30 +125,72 @@ class SubmitVideoReplaceTests(TestCase):
|
|||||||
params.update(over)
|
params.update(over)
|
||||||
return submit_video_replace(team=self.team, user=self.user, params=params)
|
return submit_video_replace(team=self.team, user=self.user, params=params)
|
||||||
|
|
||||||
def test_product_library_uses_backend_prompt_and_feature(self):
|
def test_product_submit_waits_for_digest_and_keeps_video_out_of_refs(self):
|
||||||
|
"""商品复刻第一步:秒回 CREATED,参考视频只留作拆解源,不进火山 references。"""
|
||||||
task = self._submit()
|
task = self._submit()
|
||||||
self.assertEqual(task.status, AITask.Status.SUBMITTED)
|
self.assertEqual(task.status, AITask.Status.CREATED)
|
||||||
payload = task.request_payload
|
payload = task.request_payload
|
||||||
self.assertEqual(payload["feature"], "video_replace")
|
self.assertEqual(payload["feature"], "video_replace")
|
||||||
self.assertEqual(payload["replace_mode"], "product")
|
self.assertEqual(payload["replace_mode"], "product")
|
||||||
self.assertEqual(payload["subject_name"], "净颜精华")
|
self.assertEqual(payload["subject_name"], "净颜精华")
|
||||||
self.assertEqual(payload["subject_source"], "library")
|
self.assertEqual(payload["subject_source"], "library")
|
||||||
self.assertEqual(payload["prompt"], PRODUCT_PROMPT)
|
self.assertTrue(payload["digest_pending"])
|
||||||
|
self.assertEqual(payload["prompt"], DIGEST_PENDING_PROMPT)
|
||||||
|
self.assertEqual(payload["digest_source_asset_id"], str(self.video.id))
|
||||||
labels = [item["label"] for item in payload["references"]]
|
labels = [item["label"] for item in payload["references"]]
|
||||||
self.assertIn("参考视频", labels)
|
self.assertNotIn("参考视频", labels)
|
||||||
self.assertIn("目标商品", labels)
|
self.assertIn("目标商品", labels)
|
||||||
|
self.assertFalse(self.provider.create_video_task.called)
|
||||||
|
self.assertEqual(CreditReservation.objects.filter(task=task).count(), 0)
|
||||||
|
|
||||||
|
def test_product_digest_becomes_prompt_and_only_images_go_to_ark(self):
|
||||||
|
"""第二步:worker 拆完 → 分镜稿变提示词 → 只带商品图提交火山。"""
|
||||||
|
task = self._submit()
|
||||||
|
with patch(
|
||||||
|
"apps.ai.video_digest.digest_asset_video",
|
||||||
|
return_value=(DIGEST_SAMPLE, {"digest_shots": 2, "digest_model": "gemini-3.1-pro-preview"}),
|
||||||
|
):
|
||||||
|
run_replace_digest(task)
|
||||||
|
task.refresh_from_db()
|
||||||
|
self.assertEqual(task.status, AITask.Status.SUBMITTED)
|
||||||
|
payload = task.request_payload
|
||||||
|
self.assertFalse(payload["digest_pending"])
|
||||||
|
self.assertEqual(payload["digest_shots"], 2)
|
||||||
|
prompt = payload["prompt"]
|
||||||
|
self.assertIn("【镜头 01】", prompt) # 分镜稿骨架进了提示词
|
||||||
|
self.assertIn("倒进玻璃杯", prompt) # 画面描述保留
|
||||||
|
self.assertIn("@目标商品", prompt) # 商品图引用锚点在
|
||||||
|
self.assertIn("净颜精华", prompt) # 口播里的旧商品名改说自家商品
|
||||||
|
self.assertNotIn("【拆解存疑】", prompt) # 给用户校对的段落不喂模型
|
||||||
|
self.assertNotIn("字幕:", prompt) # 花字记录不喂,免得模型画上去
|
||||||
|
|
||||||
content = self.provider.create_video_task.call_args.kwargs.get("content_items") or []
|
content = self.provider.create_video_task.call_args.kwargs.get("content_items") or []
|
||||||
roles = [item.get("role") for item in content]
|
roles = [item.get("role") for item in content]
|
||||||
self.assertIn("reference_video", roles)
|
|
||||||
self.assertIn("reference_image", roles)
|
self.assertIn("reference_image", roles)
|
||||||
urls = []
|
self.assertNotIn("reference_video", roles) # 参考视频不再发给火山
|
||||||
for item in content:
|
self.assertNotIn("video_url", [item.get("type") for item in content])
|
||||||
if item.get("type") == "video_url":
|
|
||||||
urls.append(item["video_url"]["url"])
|
def test_digest_failure_fails_task_without_charging(self):
|
||||||
elif item.get("type") == "image_url":
|
from apps.ai.video_digest import VideoDigestError
|
||||||
urls.append(item["image_url"]["url"])
|
|
||||||
self.assertTrue(urls)
|
task = self._submit()
|
||||||
self.assertTrue(all(url.startswith("asset://") for url in urls))
|
with patch(
|
||||||
|
"apps.ai.video_digest.digest_asset_video",
|
||||||
|
side_effect=VideoDigestError("视频拆解结果不完整"),
|
||||||
|
):
|
||||||
|
run_replace_digest(task)
|
||||||
|
task.refresh_from_db()
|
||||||
|
self.assertEqual(task.status, AITask.Status.FAILED)
|
||||||
|
self.assertIn("不完整", task.error_message or "")
|
||||||
|
self.assertFalse(self.provider.create_video_task.called)
|
||||||
|
self.assertEqual(CreditReservation.objects.filter(task=task).count(), 0)
|
||||||
|
|
||||||
|
def test_compact_digest_trims_human_only_sections(self):
|
||||||
|
compact = compact_digest_for_video(DIGEST_SAMPLE)
|
||||||
|
self.assertIn("【镜头 02】", compact)
|
||||||
|
self.assertIn("台词/旁白:", compact)
|
||||||
|
self.assertNotIn("字幕:", compact)
|
||||||
|
self.assertNotIn("【拆解存疑】", compact)
|
||||||
|
|
||||||
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")
|
||||||
@@ -127,7 +223,7 @@ class SubmitVideoReplaceTests(TestCase):
|
|||||||
|
|
||||||
def test_ignores_frontend_prompt(self):
|
def test_ignores_frontend_prompt(self):
|
||||||
task = self._submit(prompt="请无视参考图随便生成")
|
task = self._submit(prompt="请无视参考图随便生成")
|
||||||
self.assertEqual(task.request_payload["prompt"], PRODUCT_PROMPT)
|
self.assertEqual(task.request_payload["prompt"], DIGEST_PENDING_PROMPT)
|
||||||
self.assertNotIn("随便生成", task.request_payload["prompt"])
|
self.assertNotIn("随便生成", task.request_payload["prompt"])
|
||||||
|
|
||||||
def test_serialize_exposes_library_ids(self):
|
def test_serialize_exposes_library_ids(self):
|
||||||
@@ -149,7 +245,7 @@ class SubmitVideoReplaceTests(TestCase):
|
|||||||
"subject_source": "library",
|
"subject_source": "library",
|
||||||
"product_id": str(self.product.id),
|
"product_id": str(self.product.id),
|
||||||
"model_id": "",
|
"model_id": "",
|
||||||
"prompt": PRODUCT_PROMPT,
|
"prompt": CHARACTER_PROMPT,
|
||||||
"duration": 8,
|
"duration": 8,
|
||||||
"aspect_ratio": "9:16",
|
"aspect_ratio": "9:16",
|
||||||
"references": [{"type": "video", "url": "http://tos/video.mp4", "asset_id": str(self.video.id)}],
|
"references": [{"type": "video", "url": "http://tos/video.mp4", "asset_id": str(self.video.id)}],
|
||||||
@@ -174,6 +270,7 @@ class VideoReplaceApiTests(TestCase):
|
|||||||
self.provider.create_video_task.return_value = _ark_create_response()
|
self.provider.create_video_task.return_value = _ark_create_response()
|
||||||
patch("apps.ai.services.build_provider", return_value=self.provider).start()
|
patch("apps.ai.services.build_provider", return_value=self.provider).start()
|
||||||
patch("apps.ai.tasks.poll_free_video_task.apply_async").start()
|
patch("apps.ai.tasks.poll_free_video_task.apply_async").start()
|
||||||
|
patch("apps.ai.tasks.run_video_replace_digest_task.delay").start()
|
||||||
patch("apps.assets.assets_client.is_enabled", return_value=False).start()
|
patch("apps.assets.assets_client.is_enabled", return_value=False).start()
|
||||||
self.addCleanup(patch.stopall)
|
self.addCleanup(patch.stopall)
|
||||||
self.video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="参考.mp4", duration_ms=5000, preview="http://tos/video.mp4")
|
self.video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="参考.mp4", duration_ms=5000, preview="http://tos/video.mp4")
|
||||||
@@ -248,6 +345,9 @@ class VideoReplaceApiTests(TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class VideoReplaceReviewGateTests(TestCase):
|
class VideoReplaceReviewGateTests(TestCase):
|
||||||
|
"""审核闸走角色复刻:只有它才把参考视频直传火山,需要真人合规审核。
|
||||||
|
商品复刻改成先提炼分镜稿,参考视频不进火山,自然也不再送审。"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.user = User.objects.create_user(username="vrrev", password="p")
|
self.user = User.objects.create_user(username="vrrev", password="p")
|
||||||
self.team = Team.objects.create(name="VRR", owner=self.user)
|
self.team = Team.objects.create(name="VRR", owner=self.user)
|
||||||
@@ -256,6 +356,7 @@ class VideoReplaceReviewGateTests(TestCase):
|
|||||||
self.provider.create_video_task.return_value = _ark_create_response()
|
self.provider.create_video_task.return_value = _ark_create_response()
|
||||||
patch("apps.ai.services.build_provider", return_value=self.provider).start()
|
patch("apps.ai.services.build_provider", return_value=self.provider).start()
|
||||||
patch("apps.ai.tasks.poll_free_video_task.apply_async").start()
|
patch("apps.ai.tasks.poll_free_video_task.apply_async").start()
|
||||||
|
patch("apps.ai.tasks.run_video_replace_digest_task.delay").start()
|
||||||
self.addCleanup(patch.stopall)
|
self.addCleanup(patch.stopall)
|
||||||
self.video = _asset(
|
self.video = _asset(
|
||||||
self.team, self.user, kind=Asset.Type.VIDEO, name="真人.mp4", duration_ms=8000,
|
self.team, self.user, kind=Asset.Type.VIDEO, name="真人.mp4", duration_ms=8000,
|
||||||
@@ -265,14 +366,13 @@ class VideoReplaceReviewGateTests(TestCase):
|
|||||||
self.team, self.user, name="乔尔.png", preview="http://tos/face.png",
|
self.team, self.user, name="乔尔.png", preview="http://tos/face.png",
|
||||||
review_status="", review_remote_id="",
|
review_status="", review_remote_id="",
|
||||||
)
|
)
|
||||||
self.product = Product.objects.create(team=self.team, created_by=self.user, title="净颜精华", cover_asset=self.image)
|
self.model = Model.objects.create(team=self.team, name="乔尔", portrait_asset=self.image)
|
||||||
ProductImage.objects.create(product=self.product, asset=self.image, is_primary=True)
|
|
||||||
|
|
||||||
def _submit(self, **over):
|
def _submit(self, **over):
|
||||||
params = {
|
params = {
|
||||||
"replace_mode": "product",
|
"replace_mode": "character",
|
||||||
"video_asset_id": str(self.video.id),
|
"video_asset_id": str(self.video.id),
|
||||||
"product_id": str(self.product.id),
|
"model_id": str(self.model.id),
|
||||||
"model": STANDARD,
|
"model": STANDARD,
|
||||||
"aspect_ratio": "9:16",
|
"aspect_ratio": "9:16",
|
||||||
"resolution": "480p",
|
"resolution": "480p",
|
||||||
|
|||||||
@@ -1344,3 +1344,85 @@ def _fail_digest_task(task, reservation, message: str) -> None:
|
|||||||
release_credit(reservation=reservation, reason=message[:200])
|
release_credit(reservation=reservation, reason=message[:200])
|
||||||
except Exception: # noqa: BLE001
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 复用入口:视频复刻·商品 拿分镜稿
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict]:
|
||||||
|
"""把一条已入库的参考视频拆成中文分镜稿,供「视频复刻·商品」当提示词骨架。
|
||||||
|
|
||||||
|
与「提炼提示词」页共用同一份 SKILL.md、同一个 Gemini 3.1 Pro 和同一套质检,
|
||||||
|
保证两处产出一致;区别只在于这里不另建 VIDEO_DIGEST 任务、也不单独扣积分——
|
||||||
|
复刻本来就是一次收费,拆解是它的内部工序。模型调用的审计仍记在传入的复刻
|
||||||
|
task 上(AIModelAttempt),出问题查得到。
|
||||||
|
"""
|
||||||
|
from apps.ai.services import execute_routed_text_request
|
||||||
|
|
||||||
|
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
|
||||||
|
if primary is None or not primary.object_key:
|
||||||
|
raise VideoDigestError("参考视频没有可用文件,请重新上传")
|
||||||
|
suffix = Path(primary.object_key).suffix.lower()
|
||||||
|
if suffix not in ALLOWED_SUFFIXES:
|
||||||
|
suffix = ".mp4"
|
||||||
|
|
||||||
|
model_config = resolve_digest_model_config(preferred_id=model_config_id)
|
||||||
|
if model_config is None:
|
||||||
|
raise VideoDigestError("视频复刻需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员")
|
||||||
|
|
||||||
|
local_path = ""
|
||||||
|
try:
|
||||||
|
local_path = _download_source_to_temp(primary.object_key, suffix)
|
||||||
|
base_name = (asset.name or "参考视频").rsplit(".", 1)[0]
|
||||||
|
upload = _FileFromPath(local_path, f"{base_name}{suffix}")
|
||||||
|
video, frames, duration, extras = digest_input_from_upload(upload)
|
||||||
|
extras = extras or {}
|
||||||
|
logger.info(
|
||||||
|
"replace digest using %s:%s input=%s duration=%.1fs frames=%s",
|
||||||
|
model_config.provider.name,
|
||||||
|
model_config.name,
|
||||||
|
"native_video" if video is not None else "frames",
|
||||||
|
duration,
|
||||||
|
len(frames),
|
||||||
|
)
|
||||||
|
messages = build_digest_messages(
|
||||||
|
frames,
|
||||||
|
duration,
|
||||||
|
video=video,
|
||||||
|
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 "")),
|
||||||
|
)
|
||||||
|
routed = execute_routed_text_request(
|
||||||
|
task=task,
|
||||||
|
primary_model=model_config,
|
||||||
|
messages=messages,
|
||||||
|
streaming=True,
|
||||||
|
structured_output=False,
|
||||||
|
business_operation="video_digest",
|
||||||
|
temperature=0.4,
|
||||||
|
validate_text=lambda text: validate_digest_text(
|
||||||
|
text, duration=duration, frame_count=len(frames) or (24 if video else 0)
|
||||||
|
),
|
||||||
|
extra_body={"max_tokens": DIGEST_MAX_TOKENS},
|
||||||
|
request_summary={
|
||||||
|
"duration_seconds": round(duration, 2),
|
||||||
|
"frame_count": len(frames),
|
||||||
|
"input": "native_video" if video is not None else "frames",
|
||||||
|
"for": "video_replace",
|
||||||
|
},
|
||||||
|
allow_retry=False,
|
||||||
|
allow_fallback=False,
|
||||||
|
)
|
||||||
|
_text, _response, digest = routed.value
|
||||||
|
meta = {
|
||||||
|
"digest_model": model_config.name,
|
||||||
|
"digest_input": "native_video" if video is not None else "frames",
|
||||||
|
"digest_frames": len(frames),
|
||||||
|
"digest_duration": round(duration, 2),
|
||||||
|
"digest_shots": shot_count(digest),
|
||||||
|
"digest_ratio": ratio_label(int(extras.get("width") or 0), int(extras.get("height") or 0)),
|
||||||
|
}
|
||||||
|
return digest, meta
|
||||||
|
finally:
|
||||||
|
if local_path:
|
||||||
|
Path(local_path).unlink(missing_ok=True)
|
||||||
|
|||||||
@@ -44,12 +44,34 @@ REVIEW_UNAVAILABLE = "素材审核服务暂不可用,请稍后重试"
|
|||||||
REVIEW_FAILED = "参考素材未通过真人合规审核,请更换视频或图片后重试"
|
REVIEW_FAILED = "参考素材未通过真人合规审核,请更换视频或图片后重试"
|
||||||
REVIEW_SUBMIT_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 = (
|
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:
|
def is_video_replace_task(task) -> bool:
|
||||||
payload = task.request_payload or {}
|
payload = task.request_payload or {}
|
||||||
if payload.get("feature") == FEATURE:
|
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)
|
data = serialize_free_video_task(task, include_deleted_assets=include_deleted_assets)
|
||||||
payload = task.request_payload or {}
|
payload = task.request_payload or {}
|
||||||
replace_mode = payload.get("replace_mode") or _legacy_replace_mode(payload.get("prompt") 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({
|
data.update({
|
||||||
"feature": FEATURE,
|
"feature": FEATURE,
|
||||||
"replace_mode": replace_mode,
|
"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 "",
|
"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 "",
|
||||||
|
# 商品复刻专有:参考视频拆出来的分镜稿(给用户看,也便于排查出片跑偏)
|
||||||
|
"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
|
return data
|
||||||
|
|
||||||
@@ -127,8 +190,48 @@ def submit_video_replace(*, team, user, params: dict):
|
|||||||
noun = "商品" if replace_mode == "product" else "角色"
|
noun = "商品" if replace_mode == "product" else "角色"
|
||||||
subject_name, image_refs, subject_source = _temporary_image_refs(team, image_ids, noun=noun)
|
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)
|
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 = [
|
references = [
|
||||||
_owned_ref(video, kind="video", role="reference_video", label="参考视频"),
|
_owned_ref(video, kind="video", role="reference_video", label="参考视频"),
|
||||||
*image_refs,
|
*image_refs,
|
||||||
@@ -137,25 +240,11 @@ def submit_video_replace(*, team, user, params: dict):
|
|||||||
if review_state == "failed":
|
if review_state == "failed":
|
||||||
raise ValueError(REVIEW_FAILED)
|
raise ValueError(REVIEW_FAILED)
|
||||||
references = _refresh_replace_refs(team, references)
|
references = _refresh_replace_refs(team, references)
|
||||||
extra = {
|
extra["review_pending"] = review_state != "ready"
|
||||||
"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",
|
|
||||||
}
|
|
||||||
submit_params = {
|
submit_params = {
|
||||||
"prompt": prompt,
|
**base_params,
|
||||||
"mode": "universal",
|
"prompt": CHARACTER_PROMPT,
|
||||||
"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,
|
|
||||||
"references": references,
|
"references": references,
|
||||||
"feature": FEATURE,
|
|
||||||
"extra_payload": extra,
|
"extra_payload": extra,
|
||||||
}
|
}
|
||||||
if review_state == "ready":
|
if review_state == "ready":
|
||||||
@@ -174,6 +263,10 @@ def advance_video_replace(task):
|
|||||||
return finalize_free_video(task=task)
|
return finalize_free_video(task=task)
|
||||||
|
|
||||||
payload = task.request_payload or {}
|
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 [])
|
references = list(payload.get("references") or [])
|
||||||
try:
|
try:
|
||||||
state = _ensure_replace_refs_reviewed(task.team, references)
|
state = _ensure_replace_refs_reviewed(task.team, references)
|
||||||
@@ -202,6 +295,61 @@ def advance_video_replace(task):
|
|||||||
return start_pending_free_video(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:
|
def _legacy_replace_mode(prompt: str) -> str:
|
||||||
return "character" if prompt.startswith("[视频复刻·角色]") else "product"
|
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)
|
logger.error("video replace review poll enqueue failed; relying on client polling", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
def _create_reviewing_task(*, team, user, params: dict):
|
def _enqueue_replace_digest(task):
|
||||||
"""审核未完成:只建 CREATED 任务,不预留积分。"""
|
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)
|
model_name = str(params.get("model") or HIGH_RES_MODEL)
|
||||||
aspect_ratio = str(params.get("aspect_ratio") or "9:16")
|
aspect_ratio = str(params.get("aspect_ratio") or "9:16")
|
||||||
resolution = str(params.get("resolution") or "720p")
|
resolution = str(params.get("resolution") or "720p")
|
||||||
@@ -294,6 +454,7 @@ def _create_reviewing_task(*, team, user, params: dict):
|
|||||||
"references": references,
|
"references": references,
|
||||||
"model_routing_v1": True,
|
"model_routing_v1": True,
|
||||||
"review_pending": True,
|
"review_pending": True,
|
||||||
|
"digest_pending": digest_pending,
|
||||||
}
|
}
|
||||||
for key, value in extra.items():
|
for key, value in extra.items():
|
||||||
if key in request_payload or value in (None, ""):
|
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,
|
estimated_cost=quote.points,
|
||||||
base_cost=Decimal("0"),
|
base_cost=Decimal("0"),
|
||||||
)
|
)
|
||||||
_enqueue_replace_review_poll(task)
|
if digest_pending:
|
||||||
|
_enqueue_replace_digest(task)
|
||||||
|
else:
|
||||||
|
_enqueue_replace_review_poll(task)
|
||||||
return task
|
return task
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -863,6 +863,9 @@ class VideoReplaceView(APIView):
|
|||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
require_worker()
|
require_worker()
|
||||||
|
# 商品复刻的拆解在 worker 里跑;worker 还是旧镜像的话消息会被静默丢弃 → 永久「提炼中」。
|
||||||
|
if str((request.data or {}).get("replace_mode") or "") == "product":
|
||||||
|
require_worker_task("apps.ai.tasks.run_video_replace_digest_task")
|
||||||
from .video_replace import serialize_video_replace_task, submit_video_replace
|
from .video_replace import serialize_video_replace_task, submit_video_replace
|
||||||
|
|
||||||
team = get_current_team(request.user)
|
team = get_current_team(request.user)
|
||||||
|
|||||||
@@ -1231,6 +1231,20 @@
|
|||||||
.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,6 +34,26 @@ 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: "灿灿 · 活力女声" },
|
||||||
@@ -2050,7 +2070,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);
|
||||||
@@ -3851,6 +3871,13 @@ 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">
|
||||||
@@ -3922,7 +3949,22 @@ 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" }}>每个场生成一张干净视频关键帧;下方导演稿按本镜真实脚本展开,视频只使用关键帧,不会带入文字或边框。</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"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
Package,
|
Package,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Replace,
|
Replace,
|
||||||
|
ScanLine,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Upload,
|
Upload,
|
||||||
UserRound,
|
UserRound,
|
||||||
@@ -33,6 +34,7 @@ import type { FreeVideoRef, FreeVideoTask, ModelConfig, ModelEntity, Product } f
|
|||||||
import type { NavigateFn } from "./route-config";
|
import type { NavigateFn } from "./route-config";
|
||||||
|
|
||||||
const JOB_KEY = "airshelf:video-replace-job";
|
const JOB_KEY = "airshelf:video-replace-job";
|
||||||
|
const MODE_KEY = "airshelf:video-replace-mode";
|
||||||
const CHARACTER_MARK = "[视频复刻·角色]";
|
const CHARACTER_MARK = "[视频复刻·角色]";
|
||||||
const VIDEO_ACCEPT = "video/mp4,video/quicktime";
|
const VIDEO_ACCEPT = "video/mp4,video/quicktime";
|
||||||
|
|
||||||
@@ -44,8 +46,8 @@ const REPLACE_MODE_COPY = {
|
|||||||
modeLabel: "商品复刻",
|
modeLabel: "商品复刻",
|
||||||
targetLabel: "商品",
|
targetLabel: "商品",
|
||||||
targetStep: "2. 选择自己的商品",
|
targetStep: "2. 选择自己的商品",
|
||||||
videoEmpty: "系统将自动识别需要替换的商品区域",
|
videoEmpty: "系统会先把参考视频拆成分镜稿,再照着它换成你的商品",
|
||||||
videoReady: "视频已就绪,将自动识别原商品区域",
|
videoReady: "视频已就绪,将先提炼分镜稿再复刻",
|
||||||
libraryTitle: "从商品库选择",
|
libraryTitle: "从商品库选择",
|
||||||
libraryEmpty: "选择已创建的商品",
|
libraryEmpty: "选择已创建的商品",
|
||||||
temporaryTitle: "临时上传商品",
|
temporaryTitle: "临时上传商品",
|
||||||
@@ -53,9 +55,11 @@ const REPLACE_MODE_COPY = {
|
|||||||
temporaryNoun: "商品图",
|
temporaryNoun: "商品图",
|
||||||
temporaryFallback: "临时商品素材",
|
temporaryFallback: "临时商品素材",
|
||||||
generatingTitle: "正在进行商品复刻",
|
generatingTitle: "正在进行商品复刻",
|
||||||
generatingCopy: "正在匹配商品外观与原片镜头",
|
generatingCopy: "正在照着分镜稿还原镜头,并换上你的商品",
|
||||||
reviewingTitle: "正在审核参考素材",
|
reviewingTitle: "正在审核参考素材",
|
||||||
reviewingCopy: "真人视频需先通过合规审核,通过后自动开始复刻",
|
reviewingCopy: "真人视频需先通过合规审核,通过后自动开始复刻",
|
||||||
|
digestingTitle: "正在提炼参考视频",
|
||||||
|
digestingCopy: "逐镜拆解景别、运镜、节奏与口播,拆完自动开始复刻",
|
||||||
resultTitle: "商品复刻已完成",
|
resultTitle: "商品复刻已完成",
|
||||||
resultPreview: "商品复刻预览",
|
resultPreview: "商品复刻预览",
|
||||||
consistency: "商品一致性检查通过",
|
consistency: "商品一致性检查通过",
|
||||||
@@ -81,6 +85,8 @@ const REPLACE_MODE_COPY = {
|
|||||||
generatingCopy: "正在匹配角色外观、表情与原片动作",
|
generatingCopy: "正在匹配角色外观、表情与原片动作",
|
||||||
reviewingTitle: "正在审核参考素材",
|
reviewingTitle: "正在审核参考素材",
|
||||||
reviewingCopy: "真人视频需先通过合规审核,通过后自动开始复刻",
|
reviewingCopy: "真人视频需先通过合规审核,通过后自动开始复刻",
|
||||||
|
digestingTitle: "正在提炼参考视频",
|
||||||
|
digestingCopy: "逐镜拆解景别、运镜、节奏与口播,拆完自动开始复刻",
|
||||||
resultTitle: "角色复刻已完成",
|
resultTitle: "角色复刻已完成",
|
||||||
resultPreview: "角色复刻预览",
|
resultPreview: "角色复刻预览",
|
||||||
consistency: "角色一致性检查通过",
|
consistency: "角色一致性检查通过",
|
||||||
@@ -116,6 +122,24 @@ function forgetJob() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readReplaceMode(): ReplaceMode {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(MODE_KEY);
|
||||||
|
if (stored === "character" || stored === "product") return stored;
|
||||||
|
} catch {
|
||||||
|
/* 无痕模式忽略 */
|
||||||
|
}
|
||||||
|
return "product";
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberReplaceMode(mode: ReplaceMode) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(MODE_KEY, mode);
|
||||||
|
} catch {
|
||||||
|
/* 无痕模式忽略 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function fileKey(file: File) {
|
function fileKey(file: File) {
|
||||||
return `${file.name}:${file.size}:${file.lastModified}`;
|
return `${file.name}:${file.size}:${file.lastModified}`;
|
||||||
}
|
}
|
||||||
@@ -187,7 +211,12 @@ function remixSourceLabel(task?: Partial<FreeVideoTask> | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function videoRefFromTask(task?: Partial<FreeVideoTask> | null) {
|
function videoRefFromTask(task?: Partial<FreeVideoTask> | null) {
|
||||||
return (task?.references || []).find((item) => item.type === "video") || null;
|
// 商品复刻的参考视频只用来提炼分镜稿,不进 references,引用快照存在 digest_source。
|
||||||
|
return (task?.references || []).find((item) => item.type === "video") || task?.digest_source || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDigesting(task?: Partial<FreeVideoTask> | null) {
|
||||||
|
return task?.digest_stage === "digesting";
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageRefsFromTask(task?: Partial<FreeVideoTask> | null) {
|
function imageRefsFromTask(task?: Partial<FreeVideoTask> | null) {
|
||||||
@@ -235,7 +264,7 @@ export function VideoReplacePage({
|
|||||||
}) {
|
}) {
|
||||||
const [products, setProducts] = useState(initialProducts);
|
const [products, setProducts] = useState(initialProducts);
|
||||||
const [models, setModels] = useState<ModelEntity[]>([]);
|
const [models, setModels] = useState<ModelEntity[]>([]);
|
||||||
const [replaceMode, setReplaceMode] = useState<ReplaceMode>("product");
|
const [replaceMode, setReplaceMode] = useState<ReplaceMode>(readReplaceMode);
|
||||||
const [videoFile, setVideoFile] = useState<File | null>(null);
|
const [videoFile, setVideoFile] = useState<File | null>(null);
|
||||||
const [videoRef, setVideoRef] = useState<FreeVideoRef | null>(null);
|
const [videoRef, setVideoRef] = useState<FreeVideoRef | null>(null);
|
||||||
const [videoUploading, setVideoUploading] = useState(false);
|
const [videoUploading, setVideoUploading] = useState(false);
|
||||||
@@ -260,6 +289,7 @@ export function VideoReplacePage({
|
|||||||
const tempInputRef = useRef<HTMLInputElement>(null);
|
const tempInputRef = useRef<HTMLInputElement>(null);
|
||||||
const completedNoticeRef = useRef("");
|
const completedNoticeRef = useRef("");
|
||||||
const wasReviewingRef = useRef(false);
|
const wasReviewingRef = useRef(false);
|
||||||
|
const wasDigestingRef = useRef(false);
|
||||||
|
|
||||||
const videoConfigs = useMemo(
|
const videoConfigs = useMemo(
|
||||||
() => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"),
|
() => modelConfigs.filter((config) => config.capability === "video" && config.status === "active"),
|
||||||
@@ -313,17 +343,19 @@ export function VideoReplacePage({
|
|||||||
}, billingRates);
|
}, billingRates);
|
||||||
const points = estimated.points || 220;
|
const points = estimated.points || 220;
|
||||||
const generating = Boolean(job && isInFlight(job.status)) || submitting || videoUploading;
|
const generating = Boolean(job && isInFlight(job.status)) || submitting || videoUploading;
|
||||||
const reviewing = submitting || Boolean(job && (job.review_stage === "reviewing" || job.status === "created"));
|
const digesting = Boolean(job && isDigesting(job));
|
||||||
|
// 商品复刻提交后先进拆解态;审核态是角色复刻专有(参考视频直传火山才需要送审)。
|
||||||
|
const reviewing = !digesting && (submitting || Boolean(job && (job.review_stage === "reviewing" || job.status === "created")));
|
||||||
const hasResult = Boolean(job && job.status === "succeeded" && job.video_url);
|
const hasResult = Boolean(job && job.status === "succeeded" && job.video_url);
|
||||||
const resultCopy = hasResult && job ? REPLACE_MODE_COPY[modeFromTask(job)] : copy;
|
const resultCopy = hasResult && job ? REPLACE_MODE_COPY[modeFromTask(job)] : copy;
|
||||||
const panelClass = [
|
const panelClass = [
|
||||||
"video-result-panel replace-result-panel",
|
"video-result-panel replace-result-panel",
|
||||||
generating ? "is-generating" : "",
|
generating ? "is-generating" : "",
|
||||||
reviewing ? "is-reviewing" : "",
|
reviewing || digesting ? "is-reviewing" : "",
|
||||||
hasResult ? "has-result" : "",
|
hasResult ? "has-result" : "",
|
||||||
].filter(Boolean).join(" ");
|
].filter(Boolean).join(" ");
|
||||||
const generateLabel = generating
|
const generateLabel = generating
|
||||||
? (reviewing ? "正在审核素材…" : "正在复刻…")
|
? (digesting ? "正在提炼参考视频…" : reviewing ? "正在审核素材…" : "正在复刻…")
|
||||||
: hasResult
|
: hasResult
|
||||||
? `再次${copy.modeLabel} · 消耗 ${points} 积分`
|
? `再次${copy.modeLabel} · 消耗 ${points} 积分`
|
||||||
: `开始${copy.modeLabel} · 消耗 ${points} 积分`;
|
: `开始${copy.modeLabel} · 消耗 ${points} 积分`;
|
||||||
@@ -372,11 +404,17 @@ export function VideoReplacePage({
|
|||||||
const data = await api.pollVideoReplace(jobId);
|
const data = await api.pollVideoReplace(jobId);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setJob(data.task);
|
setJob(data.task);
|
||||||
|
const jobMode = modeFromTask(data.task);
|
||||||
|
setReplaceMode(jobMode);
|
||||||
|
rememberReplaceMode(jobMode);
|
||||||
|
const stillDigesting = isDigesting(data.task);
|
||||||
const stillReviewing = data.task.review_stage === "reviewing" || data.task.status === "created";
|
const stillReviewing = data.task.review_stage === "reviewing" || data.task.status === "created";
|
||||||
|
if (stillReviewing) wasDigestingRef.current = stillDigesting || wasDigestingRef.current;
|
||||||
if (stillReviewing) wasReviewingRef.current = true;
|
if (stillReviewing) wasReviewingRef.current = true;
|
||||||
else if (wasReviewingRef.current && isInFlight(data.task.status)) {
|
else if (wasReviewingRef.current && isInFlight(data.task.status)) {
|
||||||
wasReviewingRef.current = false;
|
wasReviewingRef.current = false;
|
||||||
onNotify("success", "素材审核已通过,正在复刻");
|
onNotify("success", wasDigestingRef.current ? "分镜稿已提炼完成,正在复刻" : "素材审核已通过,正在复刻");
|
||||||
|
wasDigestingRef.current = false;
|
||||||
}
|
}
|
||||||
if (isInFlight(data.task.status)) {
|
if (isInFlight(data.task.status)) {
|
||||||
timer = window.setTimeout(poll, stillReviewing ? 2000 : 2500);
|
timer = window.setTimeout(poll, stillReviewing ? 2000 : 2500);
|
||||||
@@ -492,6 +530,7 @@ export function VideoReplacePage({
|
|||||||
const switchReplaceMode = (next: ReplaceMode) => {
|
const switchReplaceMode = (next: ReplaceMode) => {
|
||||||
if (next === replaceMode || generating) return;
|
if (next === replaceMode || generating) return;
|
||||||
setReplaceMode(next);
|
setReplaceMode(next);
|
||||||
|
rememberReplaceMode(next);
|
||||||
setSource("");
|
setSource("");
|
||||||
setSelectedProduct(null);
|
setSelectedProduct(null);
|
||||||
setSelectedModel(null);
|
setSelectedModel(null);
|
||||||
@@ -588,9 +627,13 @@ export function VideoReplacePage({
|
|||||||
setJob(data.task);
|
setJob(data.task);
|
||||||
setJobId(data.task.id);
|
setJobId(data.task.id);
|
||||||
rememberJob(data.task.id);
|
rememberJob(data.task.id);
|
||||||
|
rememberReplaceMode(modeFromTask(data.task) || replaceMode);
|
||||||
completedNoticeRef.current = "";
|
completedNoticeRef.current = "";
|
||||||
wasReviewingRef.current = data.task.review_stage === "reviewing" || data.task.status === "created";
|
wasReviewingRef.current = data.task.review_stage === "reviewing" || data.task.status === "created";
|
||||||
if (wasReviewingRef.current) {
|
wasDigestingRef.current = isDigesting(data.task);
|
||||||
|
if (wasDigestingRef.current) {
|
||||||
|
onNotify("success", "已开始提炼参考视频,拆完自动进入复刻");
|
||||||
|
} else if (wasReviewingRef.current) {
|
||||||
onNotify("success", "已提交素材审核,通过后自动开始复刻");
|
onNotify("success", "已提交素材审核,通过后自动开始复刻");
|
||||||
} else {
|
} else {
|
||||||
onNotify("success", "视频复刻任务已开始");
|
onNotify("success", "视频复刻任务已开始");
|
||||||
@@ -618,6 +661,7 @@ export function VideoReplacePage({
|
|||||||
const images = imageRefsFromTask(task);
|
const images = imageRefsFromTask(task);
|
||||||
const subject = subjectNameFromTask(task);
|
const subject = subjectNameFromTask(task);
|
||||||
setReplaceMode(mode);
|
setReplaceMode(mode);
|
||||||
|
rememberReplaceMode(mode);
|
||||||
setVideoFile(null);
|
setVideoFile(null);
|
||||||
setVideoRef({
|
setVideoRef({
|
||||||
...video,
|
...video,
|
||||||
@@ -898,11 +942,11 @@ export function VideoReplacePage({
|
|||||||
<div className="replace-generating-state" role="status" aria-live="polite">
|
<div className="replace-generating-state" role="status" aria-live="polite">
|
||||||
<div className="replace-generating-content">
|
<div className="replace-generating-content">
|
||||||
<div className="replace-generating-visual">
|
<div className="replace-generating-visual">
|
||||||
<span className="replace-generating-frame">{reviewing ? <ShieldCheck /> : <Clapperboard />}</span>
|
<span className="replace-generating-frame">{digesting ? <ScanLine /> : reviewing ? <ShieldCheck /> : <Clapperboard />}</span>
|
||||||
<span className="replace-generating-product">{replaceMode === "character" ? <UserRound /> : <Package />}</span>
|
<span className="replace-generating-product">{replaceMode === "character" ? <UserRound /> : <Package />}</span>
|
||||||
</div>
|
</div>
|
||||||
<strong>{reviewing ? copy.reviewingTitle : copy.generatingTitle}</strong>
|
<strong>{digesting ? copy.digestingTitle : reviewing ? copy.reviewingTitle : copy.generatingTitle}</strong>
|
||||||
<span>{reviewing ? copy.reviewingCopy : copy.generatingCopy}</span>
|
<span>{digesting ? copy.digestingCopy : reviewing ? copy.reviewingCopy : copy.generatingCopy}</span>
|
||||||
<div className="replace-generating-bar" aria-hidden="true"><span /></div>
|
<div className="replace-generating-bar" aria-hidden="true"><span /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -663,6 +663,12 @@ export type FreeVideoTask = {
|
|||||||
product_id?: string;
|
product_id?: string;
|
||||||
model_id?: string;
|
model_id?: string;
|
||||||
review_stage?: "reviewing" | "";
|
review_stage?: "reviewing" | "";
|
||||||
|
// 商品复刻专有:参考视频先被提炼成分镜稿,再连商品图一起交给 Seedance。
|
||||||
|
digest_stage?: "digesting" | "";
|
||||||
|
digest_text?: string;
|
||||||
|
digest_shots?: number;
|
||||||
|
digest_source_name?: string;
|
||||||
|
digest_source?: FreeVideoRef | null;
|
||||||
references: FreeVideoRef[];
|
references: FreeVideoRef[];
|
||||||
estimated_tokens: number;
|
estimated_tokens: number;
|
||||||
actual_tokens: number;
|
actual_tokens: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user