优化视频复刻和商品内容大小

This commit is contained in:
Azmat@qq.com
2026-08-28 18:42:28 +08:00
parent a618f02315
commit 6b6146e595
20 changed files with 1557 additions and 382 deletions
+11 -9
View File
@@ -811,15 +811,17 @@ def _notify_failure(task: AITask, *, raw: str, hint: str) -> None:
)
def _store_free_video_media(*, task: AITask, media: str) -> Asset:
"""下载火山结果 → 转存 TOS(火山原始 URL 仅 7 天有效)→ 建 Asset(FREE_CREATE,自动入库)
+ ffmpeg 抽首帧封面挂同 Asset 非主文件。"""
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
if "video" not in content_type:
content_type = "video/mp4"
# 先取字节再上传:boto3 upload_fileobj 完成后会 close 掉 BytesIO,之后 getvalue() 直接抛
# "I/O operation on closed file",封面抽帧就永远做不了(实测踩坑)。
video_bytes = fileobj.getvalue() if isinstance(fileobj, BytesIO) else b""
def _store_free_video_media(*, task: AITask, media: str = "", video_bytes: bytes | None = None) -> Asset:
"""下载火山结果(或已拼好的字节) → 转存 TOS → 建 Asset + ffmpeg 抽首帧封面。"""
content_type = "video/mp4"
if video_bytes is None:
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
if "video" not in content_type:
content_type = "video/mp4"
# 先取字节再上传:boto3 upload_fileobj 完成后会 close 掉 BytesIO,之后 getvalue() 直接抛
# "I/O operation on closed file",封面抽帧就永远做不了(实测踩坑)。
video_bytes = fileobj.getvalue() if isinstance(fileobj, BytesIO) else b""
fileobj = BytesIO(video_bytes)
asset_id = uuid.uuid4()
object_key = f"teams/{task.team_id}/free-create/{asset_id}.mp4"
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type)
+7 -5
View File
@@ -43,8 +43,8 @@ def run_video_digest_task(self, task_id: str) -> str:
@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,不向上抛重试"""
"""Worker:视频复刻第一道工序(参考视频 → 分镜稿,Gemini 半分钟起)在 worker 内跑。
商品和角色共用失败自己把任务收成 FAILED( run_replace_digest), max_retries=0,不向上抛重试"""
from apps.ai.models import AITask
from apps.ai.video_replace import run_replace_digest
@@ -97,7 +97,7 @@ def poll_free_video_task(self, task_id: str, attempt: int = 0) -> str:
if task is None:
return task_id
try:
if is_video_replace_task(task) and task.status == AITask.Status.CREATED:
if is_video_replace_task(task):
task = advance_video_replace(task)
else:
task = finalize_free_video(task=task)
@@ -110,9 +110,11 @@ def poll_free_video_task(self, task_id: str, attempt: int = 0) -> str:
if getattr(dj_settings, "CELERY_TASK_ALWAYS_EAGER", False):
return task_id
if task.status == AITask.Status.CREATED and attempt < 60:
# 复刻逐镜生成可能跑 8 段 × 数分钟,60 次(约 30 分钟)不够,拉到约 90 分钟。
poll_limit = 180 if is_video_replace_task(task) else 60
if task.status == AITask.Status.CREATED and attempt < poll_limit:
poll_free_video_task.apply_async(args=[task_id, attempt + 1], countdown=8 if attempt < 12 else 30)
elif task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING) and attempt < 60:
elif task.status in (AITask.Status.SUBMITTED, AITask.Status.POLLING) and attempt < poll_limit:
poll_free_video_task.apply_async(args=[task_id, attempt + 1], countdown=30)
return task_id
+219 -56
View File
@@ -20,6 +20,7 @@ from apps.ai.video_replace import (
REVIEW_FAILED,
REVIEW_UNAVAILABLE,
advance_video_replace,
build_shot_plan,
compact_digest_for_video,
run_replace_digest,
submit_video_replace,
@@ -72,6 +73,52 @@ DIGEST_SAMPLE = """片名:《晨间一杯》
- 2 镜标签文字看不清请校对品牌名
"""
DIGEST_FOUR = """片名:加长
画面比例9:16 竖屏
整体风格写实
主线四段卖点
镜头 01
时间00:00-00:05
时长5
景别中景
机位平视
运镜固定
画面第一镜开场
人物动作举手
台词/旁白第一句
镜头 02
时间00:05-00:10
时长5
景别近景
机位平视
运镜固定
画面第二镜展示
人物动作转瓶
台词/旁白第二句
镜头 03
时间00:10-00:15
时长5
景别特写
机位平视
运镜推近
画面第三镜特写
人物动作按下泵头
台词/旁白第三句
镜头 04
时间00:15-00:20
时长5
景别中近景
机位平视
运镜固定
画面第四镜收束
人物动作微笑
台词/旁白第四句
"""
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(
@@ -122,13 +169,13 @@ class SubmitVideoReplaceTests(TestCase):
"model": STANDARD,
"aspect_ratio": "9:16",
"resolution": "480p",
"duration": 4,
"duration": 8,
}
params.update(over)
return submit_video_replace(team=self.team, user=self.user, params=params)
def test_product_submit_waits_for_digest_and_keeps_video_out_of_refs(self):
"""商品复刻第一步:秒回 CREATED,参考视频只留作拆解源,不进火山 references"""
def test_product_submit_waits_for_digest_and_keeps_video_out_of_review_refs(self):
"""商品复刻第一步:秒回 CREATED。审核列表只有商品图;原片快照另存,出片时再带上"""
task = self._submit()
self.assertEqual(task.status, AITask.Status.CREATED)
payload = task.request_payload
@@ -145,8 +192,8 @@ class SubmitVideoReplaceTests(TestCase):
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 拆完 → 分镜稿变提示词 → 只带商品图提交火山"""
def test_product_digest_becomes_prompt_and_video_goes_to_ark(self):
"""第二步:worker 拆完 → 按镜拆成生成计划 → 原片 + 商品图一起提交第一镜"""
task = self._submit()
with patch(
"apps.ai.video_digest.digest_asset_video",
@@ -158,19 +205,28 @@ class SubmitVideoReplaceTests(TestCase):
payload = task.request_payload
self.assertFalse(payload["digest_pending"])
self.assertEqual(payload["digest_shots"], 2)
plan = payload["shot_plan"]
self.assertEqual(len(plan), 2)
self.assertEqual(plan[0]["seconds"], 4)
self.assertEqual(plan[1]["seconds"], 4)
prompt = payload["prompt"]
self.assertIn("【镜头 01】", prompt) # 分镜稿骨架进了提示词
self.assertIn("倒进玻璃杯", prompt) # 画面描述保留
self.assertIn("@目标商品", prompt) # 商品图引用锚点在
self.assertIn("净颜精华", prompt) # 口播里的旧商品名改说自家商品
self.assertNotIn("【拆解存疑】", prompt) # 给用户校对的段落不喂模型
self.assertNotIn("字幕:", prompt) # 花字记录不喂,免得模型画上去
self.assertIn("@参考视频", prompt) # 2.0 锁切镜的锚点
self.assertIn("第 1/", prompt) # 只喂当前这一镜
self.assertIn("倒进玻璃杯", prompt) # 提炼原文画面保留
self.assertIn("台词/旁白", prompt) # 不改写成「口播」
self.assertIn("@目标商品", prompt)
self.assertIn("净颜精华", prompt)
self.assertNotIn("【拆解存疑】", prompt)
self.assertNotIn("字幕:", prompt)
self.assertNotIn("转动瓶身", prompt) # 第二镜的动作不能漏进第一镜
self.assertEqual(self.provider.create_video_task.call_count, 1)
self.assertEqual(self.provider.create_video_task.call_args.kwargs.get("duration"), 4)
content = self.provider.create_video_task.call_args.kwargs.get("content_items") or []
roles = [item.get("role") for item in content]
self.assertIn("reference_image", roles)
self.assertNotIn("reference_video", roles) # 参考视频不再发给火山
self.assertNotIn("video_url", [item.get("type") for item in content])
self.assertIn("reference_video", roles)
self.assertIn("video_url", [item.get("type") for item in content])
def _retire(self, task):
"""把任务收成终态,好让单飞闸放行下一次提交。"""
@@ -206,7 +262,7 @@ class SubmitVideoReplaceTests(TestCase):
run_replace_digest(task)
task.refresh_from_db()
self.assertEqual(task.status, AITask.Status.SUBMITTED)
self.assertIn("【镜头 01】", task.request_payload["prompt"])
self.assertIn("第 1/", task.request_payload["prompt"])
self.assertIn("@目标商品", task.request_payload["prompt"])
def test_replace_digest_prompt_matches_standalone(self):
@@ -381,7 +437,7 @@ class SubmitVideoReplaceTests(TestCase):
tri.save(update_fields=["metadata"])
return tri
def _run_digest(self, task):
def _run_digest(self, task, digest=DIGEST_SAMPLE):
from apps.ai import video_digest
text_model = ModelConfig.objects.filter(capability="text", status="active").first()
@@ -391,7 +447,7 @@ class SubmitVideoReplaceTests(TestCase):
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, {})):
patch("apps.ai.services._collect_extract_text", return_value=(digest, {})):
run_replace_digest(task)
task.refresh_from_db()
return task
@@ -408,7 +464,6 @@ class SubmitVideoReplaceTests(TestCase):
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"]
@@ -457,12 +512,100 @@ class SubmitVideoReplaceTests(TestCase):
self.assertEqual(len(refs), MAX_IMAGES)
self.assertEqual(refs[-1]["label"], "目标商品三视图")
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_digest_becomes_a_shot_table_for_the_video_model(self):
"""分镜稿是给人校对的导演稿。喂给视频模型前要转成只讲「镜头怎么拍、人说什么」
的镜头表 元信息和音轨描述留着只会稀释真正的画面指令"""
table = compact_digest_for_video(DIGEST_SAMPLE)
# 镜头结构和画面指令留下
self.assertIn("镜头 01 | 00:00-00:04", table)
self.assertIn("镜头 02 | 00:04-00:08", table)
self.assertIn("画面:女生站在窗边", table)
self.assertIn("口播:每天早上我都要来一支旧牌精华。", table)
self.assertIn("镜头数:2 镜", table)
# 整片设定只留对画面有用的
self.assertIn("整体风格:写实、暖色调、清晨感", table)
self.assertNotIn("片名", table)
self.assertNotIn("视频类型", table)
self.assertNotIn("结构:", table)
# 非画面指令全部剔除
for noise in ("字幕:", "音效:", "背景音乐:", "备注:", "【拆解存疑】"):
self.assertNotIn(noise, table)
# 空值行不占篇幅("表情:不可见" 这种)
self.assertNotIn("不可见", table)
def test_unparseable_digest_falls_back_to_raw_text(self):
"""模型没按 skill 格式吐时,宁可原文照传也不能把镜头信息弄丢。"""
messy = "这条片子讲了一个女生的早晨。\n她拿起瓶子。\n【拆解存疑】\n- 看不清"
out = compact_digest_for_video(messy)
self.assertIn("这条片子讲了一个女生的早晨。", out)
self.assertNotIn("【拆解存疑】", out)
def test_shot_plan_keeps_one_seedance_job_per_shot(self):
plan = build_shot_plan(DIGEST_SAMPLE, "净颜精华", output_seconds=8)
self.assertEqual([item["seconds"] for item in plan], [4, 4])
self.assertIn("第 1/", plan[0]["prompt"])
self.assertIn("@参考视频", plan[0]["prompt"])
self.assertIn("倒进玻璃杯", plan[0]["prompt"])
self.assertIn("台词/旁白", plan[0]["prompt"])
self.assertNotIn("转动瓶身", plan[0]["prompt"])
self.assertIn("第 2/", plan[1]["prompt"])
self.assertIn("@目标商品", plan[0]["prompt"])
self.assertNotIn("@目标角色", plan[0]["prompt"])
def test_character_shot_plan_swaps_person_not_product(self):
plan = build_shot_plan(DIGEST_SAMPLE, "薇薇", replace_mode="character", output_seconds=8)
self.assertIn("@目标角色", plan[0]["prompt"])
self.assertIn("@参考视频", plan[0]["prompt"])
self.assertIn("商品", plan[0]["prompt"])
self.assertNotIn("@目标商品", plan[0]["prompt"])
self.assertNotIn("净颜精华", plan[0]["prompt"])
def test_adjacent_sub_four_second_shots_merge(self):
digest = """【镜头 01】
时长2
画面第一下
镜头 02
时长2
画面第二下
"""
plan = build_shot_plan(digest, "精华", output_seconds=8)
self.assertEqual(len(plan), 1)
self.assertEqual(plan[0]["seconds"], 4)
self.assertIn("切点", plan[0]["prompt"])
self.assertIn("第一下", plan[0]["prompt"])
self.assertIn("第二下", plan[0]["prompt"])
def test_shots_are_generated_one_by_one_then_concatenated(self):
"""2.5 不能一次塞完整镜头表。第一镜出完再提第二镜,全部完成才拼接。"""
self.provider.create_video_task.side_effect = [
_ark_create_response("ark-1"),
_ark_create_response("ark-2"),
]
self.provider.poll_video_task.side_effect = [
{"status": "succeeded", "usage": {"total_tokens": 1000}},
{"status": "succeeded", "usage": {"total_tokens": 1200}},
]
self.provider.extract_first_media_url.side_effect = ["http://ark/s1.mp4", "http://ark/s2.mp4"]
task = self._run_digest(self._submit())
self.assertEqual(task.provider_task_id, "ark-1")
self.assertEqual(self.provider.create_video_task.call_count, 1)
with patch("apps.ai.video_replace._concat_shot_media", return_value=b"concat-bytes") as concat, patch(
"apps.ai.free_video._store_free_video_media"
) as store:
task = advance_video_replace(task)
self.assertEqual(task.provider_task_id, "ark-2")
self.assertEqual(self.provider.create_video_task.call_count, 2)
self.assertEqual(task.request_payload["shot_plan"][0]["status"], "succeeded")
self.assertFalse(store.called)
task = advance_video_replace(task)
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
concat.assert_called_once()
self.assertEqual(concat.call_args[0][0], ["http://ark/s1.mp4", "http://ark/s2.mp4"])
store.assert_called_once()
self.assertEqual(store.call_args.kwargs.get("video_bytes"), b"concat-bytes")
self.assertEqual(task.request_payload.get("actual_tokens"), 2200)
def test_character_library_and_temp_are_exclusive(self):
portrait = _asset(self.team, self.user, name="模特.png", preview="http://tos/model.png")
@@ -472,6 +615,10 @@ class SubmitVideoReplaceTests(TestCase):
task = self._submit(replace_mode="character", product_id="", model_id=str(model.id))
self.assertEqual(task.request_payload["replace_mode"], "character")
self.assertEqual(task.request_payload["subject_name"], "薇薇")
self.assertTrue(task.request_payload["digest_pending"])
labels = [item["label"] for item in task.request_payload["references"]]
self.assertNotIn("参考视频", labels)
self.assertIn("目标角色", labels)
def test_both_modes_accept_up_to_30s(self):
"""商品和角色都走 Seedance 2.5(单次能出 30 秒),参考视频统一按 30 秒收口。"""
@@ -495,29 +642,31 @@ class SubmitVideoReplaceTests(TestCase):
with self.assertRaisesMessage(ValueError, "不能超过 30 秒"):
self._submit(replace_mode=mode, video_asset_id=str(too_long.id), **extra)
def test_long_source_asks_model_to_condense(self):
"""60 秒参考稿 + 15 秒成片:必须让模型压缩改编,不然会照着拍到一半戛然而止"""
def test_long_source_drops_trailing_shots(self):
"""参考片比成片长很多时整镜省略尾镜,不要压缩画面"""
long_video = _asset(self.team, self.user, kind=Asset.Type.VIDEO, name="半分钟.mp4", duration_ms=28000)
# 显式要一段短成片:参考 28 秒、成片 10 秒,差得多才要求模型压缩改编
task = self._run_digest(self._submit(video_asset_id=str(long_video.id), duration=10))
prompt = task.request_payload["prompt"]
self.assertIn("压缩改编", prompt)
self.assertIn("28 秒", prompt)
self.assertIn("10 秒", prompt)
task = self._run_digest(self._submit(video_asset_id=str(long_video.id), duration=10), digest=DIGEST_FOUR)
plan = task.request_payload["shot_plan"]
self.assertEqual(len(plan), 2)
self.assertIn("第一镜开场", plan[0]["prompt"])
self.assertIn("第二镜展示", plan[1]["prompt"])
self.assertNotIn("第四镜收束", plan[0]["prompt"] + plan[1]["prompt"])
def test_short_source_skips_condense_note(self):
"""参考视频和成片时长差不多时别啰嗦"""
def test_short_source_keeps_all_shots(self):
"""时长一致时保留全部镜头"""
task = self._run_digest(self._submit(duration=None)) # setUp 的参考视频 8 秒 → 成片也 8 秒
self.assertNotIn("压缩改编", task.request_payload["prompt"])
self.assertEqual(len(task.request_payload["shot_plan"]), 2)
self.assertNotIn("时长不一致时的取舍", task.request_payload["prompt"])
self.assertNotIn("省略次要镜头", task.request_payload["prompt"])
def test_prompt_always_pins_character_consistency(self):
"""上传图里可能有人物(服饰模特图/真人手持图),要锁住人物一致;
没有人物时也要说清楚别从商品图凭空套脸"""
def test_prompt_copies_source_video_instead_of_rewriting_a_script(self):
"""切镜靠 @参考视频,提示词只钉「换商品」+ 这一镜原文,不要再扩写成导演长文。"""
prompt = self._run_digest(self._submit()).request_payload["prompt"]
self.assertIn("自始至终是同一个人", prompt)
self.assertIn("不要中途换脸", prompt)
self.assertIn("参考图里如果有人物", prompt)
self.assertIn("不要从商品图上凭空套一张脸", prompt)
self.assertIn("@参考视频", prompt)
self.assertIn("人物", prompt)
self.assertIn("台词/旁白", prompt)
self.assertNotIn("必须原样保持", prompt)
self.assertNotIn("不要从商品图凭空套脸", prompt)
def test_accepts_15s_video_with_probe_slack(self):
from apps.ai.media_probe import REF_DURATION_MAX
@@ -658,8 +807,7 @@ class VideoReplaceApiTests(TestCase):
class VideoReplaceReviewGateTests(TestCase):
"""审核闸走角色复刻:只有它才把参考视频直传火山,需要真人合规审核。
商品复刻改成先提炼分镜稿,参考视频不进火山,自然也不再送审"""
"""审核闸只盯人物图/商品图。原片出片时直传火山锁切镜,不进人物素材库送审。"""
def setUp(self):
self.user = User.objects.create_user(username="vrrev", password="p")
@@ -704,54 +852,69 @@ class VideoReplaceReviewGateTests(TestCase):
def test_unreviewed_assets_create_pending_task_without_charging(self):
with patch("apps.assets.assets_client.is_enabled", return_value=True), patch(
"apps.assets.review.get_or_create_team_group"
) as grp, patch("apps.assets.assets_client.create_asset", side_effect=["Asset-vid", "Asset-img"]) as create, patch(
) as grp, patch("apps.assets.assets_client.create_asset", return_value="Asset-img") as create, patch(
"apps.assets.assets_client.get_asset", return_value={"Status": "Processing"}
):
grp.return_value = MagicMock(remote_group_id="Group-1")
task = self._submit()
self.assertEqual(task.status, AITask.Status.CREATED)
self.assertTrue((task.request_payload or {}).get("review_pending"))
self.assertTrue((task.request_payload or {}).get("digest_pending"))
self.assertEqual(CreditReservation.objects.filter(task=task).count(), 0)
self.assertFalse(self.provider.create_video_task.called)
passed_types = [call.kwargs.get("asset_type") for call in create.call_args_list]
self.assertIn("Video", passed_types)
self.assertIn("Image", passed_types)
self.assertEqual(passed_types, ["Image"])
labels = [item["label"] for item in (task.request_payload or {}).get("references") or []]
self.assertNotIn("参考视频", labels)
def test_advance_starts_generation_after_review_passes(self):
with patch("apps.assets.assets_client.is_enabled", return_value=True), patch(
"apps.assets.review.get_or_create_team_group"
) as grp, patch("apps.assets.assets_client.create_asset", side_effect=["Asset-vid", "Asset-img"]), patch(
) as grp, patch("apps.assets.assets_client.create_asset", return_value="Asset-img"), patch(
"apps.assets.assets_client.get_asset", return_value={"Status": "Processing"}
):
grp.return_value = MagicMock(remote_group_id="Group-1")
task = self._submit()
self.video.refresh_from_db()
self.image.refresh_from_db()
self.video.review_status = "active"
self.video.review_remote_id = "asset-vid"
self.video.save(update_fields=["review_status", "review_remote_id"])
self.image.review_status = "active"
self.image.review_remote_id = "asset-img"
self.image.save(update_fields=["review_status", "review_remote_id"])
with patch("apps.assets.assets_client.is_enabled", return_value=True), patch(
"apps.assets.assets_client.get_asset", return_value={"Status": "Active"}
), patch(
"apps.ai.video_digest.digest_asset_video",
return_value=(DIGEST_SAMPLE, {"digest_shots": 2}),
):
task = advance_video_replace(task)
run_replace_digest(task)
task.refresh_from_db()
self.assertEqual(task.status, AITask.Status.SUBMITTED)
self.assertTrue(CreditReservation.objects.filter(task=task).exists())
self.assertTrue(self.provider.create_video_task.called)
content = self.provider.create_video_task.call_args.kwargs.get("content_items") or []
urls = [item.get("video_url", item.get("image_url", {})).get("url") for item in content]
self.assertTrue(all(str(url).startswith("asset://") for url in urls if url))
types = [item.get("type") for item in content]
self.assertIn("video_url", types)
image_urls = [
(item.get("image_url") or {}).get("url")
for item in content if item.get("type") == "image_url"
]
self.assertTrue(all(str(url).startswith("asset://") for url in image_urls if url))
def test_advance_fails_review_without_charging(self):
with patch("apps.assets.assets_client.is_enabled", return_value=True), patch(
"apps.assets.review.get_or_create_team_group"
) as grp, patch("apps.assets.assets_client.create_asset", side_effect=["Asset-vid", "Asset-img"]), patch(
) as grp, patch("apps.assets.assets_client.create_asset", return_value="Asset-img"), patch(
"apps.assets.assets_client.get_asset", return_value={"Status": "Processing"}
):
grp.return_value = MagicMock(remote_group_id="Group-1")
task = self._submit()
with patch("apps.assets.assets_client.is_enabled", return_value=True), patch(
"apps.assets.assets_client.get_asset", return_value={"Status": "Processing"}
), patch(
"apps.ai.video_digest.digest_asset_video",
return_value=(DIGEST_SAMPLE, {"digest_shots": 2}),
):
run_replace_digest(task)
task.refresh_from_db()
self.assertEqual(task.status, AITask.Status.CREATED)
with patch("apps.assets.assets_client.is_enabled", return_value=True), patch(
"apps.assets.assets_client.get_asset", return_value={"Status": "Failed", "ErrorMessage": "real person"}
):
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -866,9 +866,8 @@ class VideoReplaceView(APIView):
def post(self, request):
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")
# 复刻的拆解在 worker 里跑;worker 还是旧镜像的话消息会被静默丢弃 → 永久「提炼中」。
require_worker_task("apps.ai.tasks.run_video_replace_digest_task")
from .video_replace import (
VideoReplaceInProgress,
serialize_video_replace_task,
@@ -897,8 +896,14 @@ class VideoReplaceView(APIView):
public_error = classify_generation_error(
exc, operation="video_generate", internal_kind=internal_kind
)
# detail 用校验本身的原话:submit_video_replace 抛的 ValueError 全都是写给用户看的
# 中文("这个商品还没有可用图片""参考视频不能超过 30 秒"…)。套成 invalid_input 那句
# 万能文案("请检查描述、参数或素材格式后重试")等于把原因丢了,用户和排查都没法下手。
# error 里仍带结构化 code/action,前端要按类型渲染照旧可用。
payload = public_error.as_dict()
payload["fallback_message"] = message
return Response(
{"detail": public_error.fallback_message, "error": public_error.as_dict()},
{"detail": message, "error": payload},
status=status.HTTP_400_BAD_REQUEST,
)
task = _free_video_task_queryset(team).get(id=task.id)
+39
View File
@@ -0,0 +1,39 @@
"""上传图片的尺寸闸。
火山素材审核要求图片宽高在 3006000 px 之间,小图会被
[InvalidParameter.WidthTooSmall] 拒掉这个限制以前只在自由创作的素材上传口做,
商品图 / 项目上传等入口没有 小图能一路存进商品库,直到生成视频送审那一刻才炸,
而且报错发生在离上传很远的地方,用户根本对不上号统一在入库前挡住
"""
from __future__ import annotations
from io import BytesIO
MIN_EDGE = 300
MAX_EDGE = 6000
class ImageTooSmallError(ValueError):
"""尺寸不合格。文案直接面向用户。"""
def probe_image_size(raw: bytes) -> tuple[int, int]:
"""读图片宽高。读不出来抛 ImageTooSmallError(当成坏文件)。"""
from PIL import Image
try:
with Image.open(BytesIO(raw)) as im:
return int(im.width), int(im.height)
except Exception as exc: # noqa: BLE001
raise ImageTooSmallError("图片解析失败,请更换文件") from exc
def ensure_reviewable_image(raw: bytes, *, label: str = "图片") -> tuple[int, int]:
"""校验上传图片的宽高,返回 (width, height)。不合格抛 ImageTooSmallError。"""
width, height = probe_image_size(raw)
if not (MIN_EDGE <= width <= MAX_EDGE and MIN_EDGE <= height <= MAX_EDGE):
raise ImageTooSmallError(
f"{label}尺寸需在 {MIN_EDGE}{MAX_EDGE} 像素之间(当前 {width}×{height}),"
"过小的图无法通过素材审核,请换一张更大的图"
)
return width, height
+34
View File
@@ -95,6 +95,7 @@ def submit_asset_for_review(asset: Asset, *, force: bool = False) -> bool:
if not remote_id:
# 火山没回 Id:不要标 processing(否则 remote_id 为空、poll 永远早退、卡死黄),留空可重试
logger.warning("create_asset 返回空 id,asset %s 暂不送审(可重试)", asset.id)
_record_submit_error(asset, "审核服务未返回素材编号")
return False
asset.review_remote_id = remote_id
asset.review_status = "processing"
@@ -103,9 +104,42 @@ def submit_asset_for_review(asset: Asset, *, force: bool = False) -> bool:
return True
except Exception as exc: # noqa: BLE001
logger.warning("submit_asset_for_review failed for asset %s: %s", asset.id, exc)
# 只写日志等于把原因埋了:调用方(视频复刻等)只能报「素材提交审核失败」这种没信息量的话,
# 用户和排查都无从下手。原因落到 review_error,让上层能带出来。
_record_submit_error(asset, str(exc))
return False
# 火山素材审核的常见拒因:原文是英文错误码,直接怼给用户看不懂也不知道怎么办。
_SUBMIT_ERROR_HINTS = (
("WidthTooSmall", "图片太小(宽高需在 300–6000 像素之间),请换一张更大的图"),
("HeightTooSmall", "图片太小(宽高需在 300–6000 像素之间),请换一张更大的图"),
("WidthTooLarge", "图片太大(宽高需在 300–6000 像素之间),请压缩后重试"),
("HeightTooLarge", "图片太大(宽高需在 300–6000 像素之间),请压缩后重试"),
("SizeTooLarge", "图片文件太大,请压缩后重试"),
("InvalidImageFormat", "图片格式不支持,请改用 JPG / PNG / WebP"),
("DownloadFailed", "审核服务拉取不到这张图,请重新上传"),
)
def humanize_submit_error(reason: str) -> str:
"""把火山的英文错误码换成能照着做的中文。认不出的原样返回。"""
text = reason or ""
for code, hint in _SUBMIT_ERROR_HINTS:
if code in text:
return hint
return text
def _record_submit_error(asset: Asset, reason: str) -> None:
"""把送审失败原因写回资产。写失败不抛 —— 审计不能反过来搞挂主流程。"""
try:
asset.review_error = humanize_submit_error(reason)[:2000]
asset.save(update_fields=["review_error", "updated_at"])
except Exception: # noqa: BLE001
logger.warning("记录 asset %s 送审失败原因时出错", asset.id, exc_info=True)
# 平台自己生成的资产,提示词与生成链路都在我们手里,视为免审;用户上传的必须真过一遍审核。
# 判据是 Asset.source 而不是「在不在某个库里」—— 按库免审等于把审核架空:
# 用户上传一张图进资产库,再从自由创作引用出去,就绕过了整套人像审核。
+66
View File
@@ -819,6 +819,20 @@ class FacetsProductsTests(TestCase):
data = self.client.get("/api/assets/facets/?tab=others").json()
self.assertEqual(data["products"], [])
def test_facets_products_newest_first(self):
from datetime import timedelta
from django.utils import timezone
from apps.products.models import Product
Asset.objects.create(
team=self.team, name="上身图2", asset_type="image", source="ai_generated",
category=Asset.Category.MODEL_TRYON, metadata={"product_id": str(self.p2.id)},
)
Product.objects.filter(id=self.p1.id).update(created_at=timezone.now() - timedelta(days=1))
data = self.client.get("/api/assets/facets/?tab=tryon").json()
self.assertEqual([p["id"] for p in data["products"]], [str(self.p2.id), str(self.p1.id)])
class SubmitReviewTests(TestCase):
"""手动兜底:灰盾点击 → 提交审核;只对送审类放行,非送审类 400。"""
@@ -927,3 +941,55 @@ class ReviewScopeTests(TestCase):
self.assertEqual(out["statuses"][str(proc.id)], "active")
sub.assert_called_once()
polled.assert_called_once()
class ImageSizeGuardTests(TestCase):
"""火山素材审核要求图片 300–6000 px。小图以前能一路存进商品库,
直到生成视频送审那一刻才炸([InvalidParameter.WidthTooSmall]) 报错离上传很远,
用户对不上号改成入库前就挡住"""
def _png(self, width, height):
from io import BytesIO
from PIL import Image
buf = BytesIO()
Image.new("RGB", (width, height), "white").save(buf, format="PNG")
return buf.getvalue()
def test_rejects_image_below_300px(self):
from apps.assets.image_guard import ImageTooSmallError, ensure_reviewable_image
with self.assertRaises(ImageTooSmallError) as ctx:
ensure_reviewable_image(self._png(120, 800), label="商品图")
self.assertIn("商品图", str(ctx.exception))
self.assertIn("300", str(ctx.exception))
self.assertIn("120×800", str(ctx.exception)) # 把实际尺寸写给用户,免得反复试
def test_rejects_image_above_6000px(self):
from apps.assets.image_guard import ImageTooSmallError, ensure_reviewable_image
with self.assertRaises(ImageTooSmallError):
ensure_reviewable_image(self._png(6400, 900))
def test_accepts_image_in_range_and_returns_size(self):
from apps.assets.image_guard import ensure_reviewable_image
self.assertEqual(ensure_reviewable_image(self._png(800, 1200)), (800, 1200))
def test_broken_file_is_rejected(self):
from apps.assets.image_guard import ImageTooSmallError, ensure_reviewable_image
with self.assertRaises(ImageTooSmallError):
ensure_reviewable_image(b"not-an-image")
def test_volcano_error_codes_are_humanized(self):
from apps.assets.review import humanize_submit_error
self.assertIn(
"图片太小",
humanize_submit_error("[InvalidParameter.WidthTooSmall] Width must be between 300px and 6000px."),
)
self.assertIn("格式不支持", humanize_submit_error("[InvalidParameter.InvalidImageFormat] bad"))
# 认不出的原样带出,不要吞掉
self.assertEqual(humanize_submit_error("Some brand new error"), "Some brand new error")
+7 -5
View File
@@ -413,11 +413,13 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
product_ids.update(str(x) for x in pid3 if x)
if not product_ids:
return []
# 团队隔离(即便某 product_id 越权也过滤掉);只取 id/title
rows = Product.objects.filter(team=self.get_team(), id__in=product_ids).values("id", "title")
products = [{"id": str(r["id"]), "title": r["title"]} for r in rows]
products.sort(key=lambda x: x["title"] or "")
return products
# 团队隔离(即便某 product_id 越权也过滤掉);只取 id/title,最新添加的在前
rows = (
Product.objects.filter(team=self.get_team(), id__in=product_ids)
.order_by("-created_at")
.values("id", "title")
)
return [{"id": str(r["id"]), "title": r["title"]} for r in rows]
@action(detail=True, methods=["get"], url_path="raw")
def raw(self, request, pk=None):
@@ -0,0 +1,15 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("products", "0004_product_business_type"),
]
operations = [
migrations.AlterModelOptions(
name="product",
options={"ordering": ["-created_at"]},
),
]
+1
View File
@@ -38,6 +38,7 @@ class Product(TeamOwnedModel):
)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["team", "status"]),
models.Index(fields=["team", "status", "purged_at"]),
+21 -1
View File
@@ -1,5 +1,8 @@
from rest_framework.test import APIClient
from datetime import timedelta
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.assets.models import Asset, AssetFile
@@ -242,6 +245,23 @@ class ProductBusinessTypeTests(TestCase):
self.assertIn("火锅套餐", local)
self.assertNotIn("面膜", local)
def test_list_newest_created_first(self):
older = Product.objects.create(team=self.team, created_by=self.user, title="旧商品")
newer = Product.objects.create(team=self.team, created_by=self.user, title="新商品")
Product.objects.filter(id=older.id).update(created_at=timezone.now() - timedelta(days=1))
titles = [p["title"] for p in self.client.get("/api/products/").json()["results"]]
self.assertEqual(titles[:2], ["新商品", "旧商品"])
self.assertEqual(titles[0], newer.title)
def test_trash_newest_created_first(self):
older = Product.objects.create(team=self.team, created_by=self.user, title="旧删")
newer = Product.objects.create(team=self.team, created_by=self.user, title="新删")
Product.objects.filter(id=older.id).update(created_at=timezone.now() - timedelta(days=1))
self.client.delete(f"/api/products/{older.id}/")
self.client.delete(f"/api/products/{newer.id}/")
titles = [p["title"] for p in self.client.get("/api/products/trash/").json()["results"]]
self.assertEqual(titles[:2], ["新删", "旧删"])
class ProductTriviewFieldTests(TestCase):
+16 -2
View File
@@ -1,3 +1,4 @@
from io import BytesIO
from pathlib import Path
import uuid
@@ -27,6 +28,7 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
serializer_class = ProductSerializer
search_fields = ["title", "brand", "category"]
ordering_fields = ["created_at", "updated_at", "title"]
ordering = ["-created_at"]
def get_queryset(self):
# 软删除即「进垃圾桶」(status=archived):正常列表/详情只看 active;
@@ -39,7 +41,7 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
business_type = self.request.query_params.get("business_type")
if business_type in dict(Product.BusinessType.choices):
qs = qs.filter(business_type=business_type)
return qs
return qs.order_by("-created_at")
def perform_destroy(self, instance):
# 删除 = 软删进垃圾桶(可恢复),不真删数据
@@ -174,11 +176,21 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
if upload is None:
return Response({"detail": "no file"}, status=status.HTTP_400_BAD_REQUEST)
# 尺寸闸:小图能存进商品库,但送火山审核时会被 WidthTooSmall 拒 ——
# 那时报错离上传很远,用户对不上号。在入库前就挡住。
from apps.assets.image_guard import ImageTooSmallError, ensure_reviewable_image
raw = upload.read()
try:
width, height = ensure_reviewable_image(raw, label="商品图")
except ImageTooSmallError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
suffix = Path(upload.name).suffix.lower() or ".png"
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/products/{product.id}/{asset_id}{suffix}"
stored = TosStorage().upload_fileobj(
fileobj=upload.file,
fileobj=BytesIO(raw),
object_key=object_key,
content_type=upload.content_type or "image/png",
)
@@ -197,6 +209,8 @@ class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
bucket=stored.bucket,
content_type=stored.content_type,
size_bytes=stored.size_bytes,
width=width,
height=height,
is_primary=True,
)
next_order = product.images.count()
+19 -2
View File
@@ -1,4 +1,5 @@
import logging
from io import BytesIO
from pathlib import Path
import uuid
@@ -115,8 +116,23 @@ def _store_uploaded_asset(*, team, user, upload, asset_type: str, category: str,
suffix = Path(upload.name).suffix.lower() or fallback_suffix
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/uploads/{asset_id}{suffix}"
# 图片走尺寸闸:小于 300px 的图能存进来,但送火山审核会被 WidthTooSmall 拒 ——
# 那时报错离上传很远(要到生成视频那一步),用户根本对不上号。
width = height = None
if asset_type == Asset.Type.IMAGE:
from apps.assets.image_guard import ImageTooSmallError, ensure_reviewable_image
raw = upload.read()
try:
width, height = ensure_reviewable_image(raw, label=name or "图片")
except ImageTooSmallError as exc:
# 转成 DRF 校验错 → 400 + 原文;裸 ValueError 会变成 500
raise ValidationError({"detail": str(exc)}) from exc
fileobj = BytesIO(raw)
else:
fileobj = upload.file
stored = TosStorage().upload_fileobj(
fileobj=upload.file,
fileobj=fileobj,
object_key=object_key,
content_type=upload.content_type or "application/octet-stream",
)
@@ -126,7 +142,8 @@ def _store_uploaded_asset(*, team, user, upload, asset_type: str, category: str,
)
AssetFile.objects.create(
asset=asset, object_key=stored.object_key, bucket=stored.bucket,
content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True,
content_type=stored.content_type, size_bytes=stored.size_bytes,
width=width, height=height, is_primary=True,
)
return asset
+30 -34
View File
@@ -12,7 +12,7 @@
max-width: none;
box-sizing: border-box;
margin: -24px -28px -60px;
padding: 52px clamp(40px, 3.2vw, 68px) 48px;
padding: 28px clamp(24px, 2.2vw, 40px) 36px;
}
@media (max-width: 1100px) {
@@ -25,33 +25,32 @@
}
.library-page .lib-head {
min-height: 76px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 30px;
margin-bottom: 34px;
gap: 20px;
margin-bottom: 18px;
}
.library-page .lib-head h1 {
margin: 0 0 10px;
font-size: 32px;
line-height: 1.2;
margin: 0 0 4px;
font-size: 22px;
line-height: 1.25;
font-weight: 600;
letter-spacing: -.02em;
color: var(--accent-black);
}
.library-page .lib-head p { margin: 0; color: var(--lib-muted); font-size: 15px; }
.library-page .lib-head p { margin: 0; color: var(--lib-muted); font-size: 13px; }
.library-page .lib-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.library-page .lib-ghost {
min-width: 132px;
height: 46px;
padding: 0 18px;
min-width: 108px;
height: 36px;
padding: 0 14px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
border: 1px solid rgba(28, 34, 43, 0.12);
border-radius: 11px;
border-radius: 8px;
color: var(--accent-black);
background: rgba(34, 42, 54, 0.05);
font: inherit;
@@ -59,17 +58,17 @@
}
.library-page .lib-ghost:hover { background: rgba(34, 42, 54, 0.10); }
.library-page .lib-ghost.is-on { color: #fff; border-color: var(--lib-black); background: var(--lib-black); }
.library-page .lib-ghost svg { width: 19px; height: 19px; }
.library-page .lib-ghost svg { width: 16px; height: 16px; }
.library-page .lib-toolbar {
min-height: 66px;
min-height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 10px 14px;
gap: 12px;
padding: 8px 10px;
border: 1px solid rgba(34, 42, 54, 0.09);
border-radius: 13px;
border-radius: 10px;
background: var(--lib-wash);
box-shadow: 0 7px 17px rgba(20, 27, 38, 0.07);
}
@@ -207,21 +206,21 @@
.library-page .lib-select-option:hover { background: #e8f2ff; }
.library-page .lib-select-option.selected { color: var(--klein); background: #f3f7ff; font-weight: 600; }
.library-page .lib-section { margin: 32px 0 17px; }
.library-page .lib-section h2 { margin: 0; font-size: 21px; font-weight: 600; color: var(--accent-black); }
.library-page .lib-section { margin: 22px 0 12px; }
.library-page .lib-section h2 { margin: 0; font-size: 16px; font-weight: 600; color: var(--accent-black); }
.library-page .lib-section p { margin: 5px 0 0; color: var(--lib-muted); font-size: 13px; }
.library-page .lib-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.library-page .lib-card {
position: relative;
min-width: 0;
overflow: hidden;
border: 1px solid var(--lib-line);
border-radius: 13px;
border-radius: 10px;
background: rgba(34, 42, 54, 0.05);
box-shadow: var(--lib-shadow);
cursor: pointer;
@@ -235,7 +234,7 @@
}
.library-page .lib-thumb {
position: relative;
aspect-ratio: 1 / 0.78;
aspect-ratio: 4 / 3;
overflow: hidden;
display: grid;
place-items: center;
@@ -253,10 +252,10 @@
display: block;
object-fit: cover;
}
.library-page .lib-meta { padding: 15px 16px 17px; }
.library-page .lib-meta { padding: 10px 12px 12px; }
.library-page .lib-meta h3 {
margin: 0 0 6px;
font-size: 15px;
margin: 0 0 4px;
font-size: 13px;
font-weight: 600;
color: var(--accent-black);
overflow: hidden;
@@ -276,14 +275,14 @@
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 13px;
margin-top: 8px;
}
.library-page .lib-tag {
display: inline-flex;
align-items: center;
height: 25px;
padding: 0 9px;
border-radius: 7px;
height: 22px;
padding: 0 7px;
border-radius: 6px;
color: #4c5360;
background: rgba(34, 42, 54, 0.08);
font-size: 11px;
@@ -430,15 +429,12 @@
.library-page .lib-sel-confirm:disabled { opacity: 0.4; cursor: not-allowed; }
.library-page .list-pager { margin-top: 20px; }
@media (max-width: 1500px) {
.library-page .lib-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1100px) {
.library-page .lib-sel-bar { left: 50%; }
.library-page .lib-toolbar { flex-wrap: wrap; }
}
@media (max-width: 860px) {
.library-page .lib-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.library-page .lib-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
.library-page .lib-search { width: 100%; }
}
+38 -42
View File
@@ -11,7 +11,7 @@
max-width: none;
box-sizing: border-box;
margin: -24px -28px -60px;
padding: 52px clamp(40px, 3.2vw, 68px) 48px;
padding: 28px clamp(24px, 2.2vw, 40px) 36px;
}
@media (max-width: 1100px) {
@@ -24,22 +24,21 @@
}
.models-page .ml-head {
min-height: 76px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 30px;
margin-bottom: 34px;
gap: 20px;
margin-bottom: 18px;
}
.models-page .ml-head h1 {
margin: 0 0 10px;
font-size: 32px;
line-height: 1.2;
margin: 0 0 4px;
font-size: 22px;
line-height: 1.25;
font-weight: 600;
letter-spacing: -.02em;
color: var(--accent-black);
}
.models-page .ml-head p { margin: 0; color: var(--ml-muted); font-size: 15px; }
.models-page .ml-head p { margin: 0; color: var(--ml-muted); font-size: 13px; }
.models-page .ml-actions { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.models-page .ml-ghost,
.models-page .ml-primary {
@@ -52,73 +51,73 @@
cursor: pointer;
}
.models-page .ml-ghost {
min-width: 132px;
height: 46px;
padding: 0 18px;
min-width: 108px;
height: 36px;
padding: 0 14px;
border: 1px solid rgba(28, 34, 43, 0.12);
border-radius: 11px;
border-radius: 8px;
color: var(--accent-black);
background: rgba(34, 42, 54, 0.05);
}
.models-page .ml-ghost:hover { background: rgba(34, 42, 54, 0.10); }
.models-page .ml-ghost.is-on { color: #fff; border-color: var(--ml-black); background: var(--ml-black); }
.models-page .ml-primary {
min-width: 158px;
height: 50px;
padding: 0 22px;
border-radius: 11px;
min-width: 124px;
height: 36px;
padding: 0 16px;
border-radius: 8px;
color: #fff;
background: var(--klein);
box-shadow: 0 9px 18px rgba(0, 47, 167, 0.18);
box-shadow: 0 6px 14px rgba(0, 47, 167, 0.18);
}
.models-page .ml-primary:hover { transform: translateY(-2px); background: var(--klein-hover); }
.models-page .ml-primary:disabled { opacity: 0.38; transform: none; box-shadow: none; cursor: not-allowed; }
.models-page .ml-ghost svg,
.models-page .ml-primary svg { width: 19px; height: 19px; }
.models-page .ml-primary svg { width: 16px; height: 16px; }
.models-page .ml-toolbar {
min-height: 66px;
min-height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 10px 14px;
gap: 12px;
padding: 8px 10px;
border: 1px solid rgba(34, 42, 54, 0.09);
border-radius: 13px;
border-radius: 10px;
background: var(--ml-wash);
box-shadow: 0 7px 17px rgba(20, 27, 38, 0.07);
}
.models-page .ml-seg { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.models-page .ml-seg-btn {
height: 38px;
padding: 0 16px;
height: 32px;
padding: 0 12px;
border: 0;
border-radius: 9px;
border-radius: 8px;
color: #50555d;
background: transparent;
font: inherit;
font-size: 14px;
font-size: 13px;
cursor: pointer;
}
.models-page .ml-seg-btn:hover { color: var(--accent-black); }
.models-page .ml-seg-btn.active { color: #fff; background: var(--ml-black); }
.models-page .ml-note { color: var(--ml-muted); font-size: 12px; }
.models-page .ml-section { margin: 32px 0 17px; }
.models-page .ml-section h2 { margin: 0; font-size: 21px; font-weight: 600; color: var(--accent-black); }
.models-page .ml-section { margin: 22px 0 12px; }
.models-page .ml-section h2 { margin: 0; font-size: 16px; font-weight: 600; color: var(--accent-black); }
.models-page .ml-section p { margin: 5px 0 0; color: var(--ml-muted); font-size: 13px; }
.models-page .ml-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.models-page .ml-card {
position: relative;
min-width: 0;
overflow: hidden;
border: 1px solid var(--ml-line);
border-radius: 13px;
border-radius: 10px;
background: rgba(34, 42, 54, 0.05);
box-shadow: var(--ml-shadow);
cursor: pointer;
@@ -147,10 +146,10 @@
border: 0;
border-radius: 0;
}
.models-page .ml-meta { padding: 12px 13px 14px; }
.models-page .ml-meta { padding: 8px 10px 10px; }
.models-page .ml-meta h3 {
margin: 0 0 6px;
font-size: 15px;
margin: 0 0 3px;
font-size: 13px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
@@ -162,14 +161,14 @@
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 13px;
margin-top: 8px;
}
.models-page .ml-tag {
display: inline-flex;
align-items: center;
height: 25px;
padding: 0 9px;
border-radius: 7px;
height: 20px;
padding: 0 6px;
border-radius: 6px;
color: #4c5360;
background: rgba(34, 42, 54, 0.08);
font-size: 11px;
@@ -274,14 +273,11 @@
.models-page .ml-sel-confirm { color: #fff; background: #002fa7; }
.models-page .ml-sel-confirm:disabled { opacity: 0.4; cursor: not-allowed; }
@media (max-width: 1500px) {
.models-page .ml-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1100px) {
.models-page .ml-sel-bar { left: 50%; }
}
@media (max-width: 860px) {
.models-page .ml-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.models-page .ml-grid { grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); }
}
/* 模特详情弹窗:参考视频项目角色详情结构 */
+45 -49
View File
@@ -12,7 +12,7 @@
max-width: none;
box-sizing: border-box;
margin: -24px -28px -60px;
padding: 52px clamp(40px, 3.2vw, 68px) 48px;
padding: 28px clamp(24px, 2.2vw, 40px) 36px;
.pl-inner {
width: min(1560px, 100%);
@@ -20,17 +20,16 @@
}
.pl-head {
min-height: 76px;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 30px;
margin-bottom: 34px;
gap: 20px;
margin-bottom: 18px;
}
.pl-head h1 {
margin: 0 0 10px;
font-size: 32px;
line-height: 1.2;
margin: 0 0 4px;
font-size: 22px;
line-height: 1.25;
font-weight: 600;
letter-spacing: -.02em;
color: var(--accent-black);
@@ -38,7 +37,7 @@
.pl-head p {
margin: 0;
color: var(--pl-muted);
font-size: 15px;
font-size: 13px;
}
.pl-actions {
display: flex;
@@ -58,11 +57,11 @@
transition: transform 180ms ease, background-color 180ms ease, box-shadow 180ms ease;
}
.pl-ghost {
min-width: 132px;
height: 46px;
padding: 0 18px;
min-width: 108px;
height: 36px;
padding: 0 14px;
border: 1px solid rgba(28, 34, 43, 0.12);
border-radius: 11px;
border-radius: 8px;
color: var(--accent-black);
background: rgba(34, 42, 54, 0.05);
}
@@ -73,36 +72,36 @@
background: var(--pl-black);
}
.pl-primary {
min-width: 158px;
height: 50px;
padding: 0 22px;
border-radius: 11px;
min-width: 124px;
height: 36px;
padding: 0 16px;
border-radius: 8px;
color: #fff;
background: var(--klein);
box-shadow: 0 9px 18px rgba(0, 47, 167, 0.18);
box-shadow: 0 6px 14px rgba(0, 47, 167, 0.18);
}
.pl-primary:hover {
transform: translateY(-2px);
background: var(--klein-hover);
}
.pl-ghost svg,
.pl-primary svg { width: 19px; height: 19px; flex: 0 0 auto; }
.pl-primary svg { width: 16px; height: 16px; flex: 0 0 auto; }
.pl-toolbar {
min-height: 66px;
min-height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 10px 14px;
gap: 12px;
padding: 8px 10px;
border: 1px solid rgba(34, 42, 54, 0.09);
border-radius: 13px;
border-radius: 10px;
background: var(--pl-wash);
box-shadow: 0 7px 17px rgba(20, 27, 38, 0.07);
}
.pl-search {
width: 250px;
height: 40px;
width: 220px;
height: 34px;
display: flex;
align-items: center;
gap: 8px;
@@ -140,7 +139,7 @@
.pl-select[data-key="cat"] { width: 168px; flex-basis: 168px; }
.pl-select-trigger {
width: 100%;
height: 40px;
height: 34px;
display: flex;
align-items: center;
justify-content: space-between;
@@ -225,7 +224,7 @@
.pl-select-option.selected .pl-cat-count { color: var(--klein); }
.pl-clear {
height: 40px;
height: 34px;
padding: 0 12px;
border: 0;
border-radius: 10px;
@@ -243,8 +242,8 @@
gap: 8px;
}
.pl-view-btn {
width: 40px;
height: 40px;
width: 34px;
height: 34px;
padding: 0;
display: inline-flex;
align-items: center;
@@ -264,11 +263,11 @@
align-items: center;
justify-content: space-between;
gap: 22px;
margin: 32px 0 17px;
margin: 22px 0 12px;
}
.pl-section h2 {
margin: 0;
font-size: 21px;
font-size: 16px;
font-weight: 600;
color: var(--accent-black);
}
@@ -280,8 +279,8 @@
.pl-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.pl-card {
position: relative;
@@ -290,7 +289,7 @@
display: flex;
flex-direction: column;
border: 1px solid var(--pl-line);
border-radius: 13px;
border-radius: 10px;
background: rgba(34, 42, 54, 0.05);
box-shadow: var(--pl-shadow);
cursor: pointer;
@@ -308,7 +307,7 @@
.pl-thumb {
position: relative;
aspect-ratio: 1 / 0.78;
aspect-ratio: 4 / 3;
overflow: hidden;
background: #e6e7ea;
border: 0;
@@ -332,10 +331,10 @@
}
.pl-thumb .ph-frame { display: none; }
.pl-meta { padding: 15px 16px 17px; }
.pl-meta { padding: 10px 12px 12px; }
.pl-meta h3 {
margin: 0 0 6px;
font-size: 15px;
margin: 0 0 4px;
font-size: 13px;
font-weight: 600;
color: var(--accent-black);
overflow: hidden;
@@ -355,15 +354,15 @@
align-items: center;
justify-content: space-between;
gap: 10px;
margin-top: 13px;
margin-top: 8px;
}
.pl-tag {
display: inline-flex;
align-items: center;
height: 25px;
padding: 0 9px;
height: 22px;
padding: 0 7px;
border: 0;
border-radius: 7px;
border-radius: 6px;
color: #4c5360;
background: rgba(34, 42, 54, 0.08);
font: inherit;
@@ -406,18 +405,18 @@
.pl-grid.list { grid-template-columns: 1fr; }
.pl-grid.list .pl-card {
display: grid;
grid-template-columns: 190px minmax(0, 1fr);
grid-template-columns: 148px minmax(0, 1fr);
align-items: stretch;
}
.pl-grid.list .pl-thumb {
width: 190px;
height: 130px;
width: 148px;
height: 100px;
aspect-ratio: auto;
}
.pl-grid.list .pl-meta {
display: flex;
min-width: 0;
padding: 18px 22px;
padding: 12px 16px;
flex-direction: column;
justify-content: center;
}
@@ -462,14 +461,11 @@
.list-pager { margin-top: 20px; }
}
@media (max-width: 1500px) {
.products-page .pl-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1100px) {
.products-page { margin: -28px -24px -48px; }
}
@media (max-width: 860px) {
.products-page .pl-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.products-page .pl-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
.products-page .pl-toolbar { flex-wrap: wrap; }
.products-page .pl-search { width: 100%; }
}
@@ -32,6 +32,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const [tab, setTab] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [preview, setPreview] = useState<{ src: string; name: string } | null>(null);
@@ -42,6 +43,11 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
if (res.results.length === 0 && res.count > 0 && page > 1) { setPage((p) => Math.max(1, p - 1)); return; }
setAssets(res.results);
setCount(res.count);
if (!silent) setSelected(new Set());
else {
const ids = new Set(res.results.map((a) => a.id));
setSelected((s) => new Set([...s].filter((id) => ids.has(id))));
}
} catch {
if (!silent) notify("error", "加载审核队列失败");
} finally {
@@ -71,15 +77,27 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
};
}, [load]);
async function retry(id: string) {
if (busy) return;
function toggleOne(id: string) {
setSelected((s) => {
const next = new Set(s);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
}
function toggleAll() {
setSelected((s) => (s.size === assets.length ? new Set() : new Set(assets.map((a) => a.id))));
}
async function submit(ids: string[]) {
if (busy || ids.length === 0) return;
setBusy(true);
try {
await adminApi.submitReviews([id]);
notify("success", "已重新送审");
const res = await adminApi.submitReviews(ids);
notify("success", `已提交 ${res.submitted} 个资产送审`);
setSelected(new Set());
await load({ silent: true });
} catch {
notify("error", "重试失败");
notify("error", "送审失败");
} finally {
setBusy(false);
}
@@ -113,7 +131,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
<div className="page-head">
<div>
<h1></h1>
<div className="sub"><span className="mono">{count} </span> · </div>
<div className="sub"><span className="mono">{count} </span> · </div>
</div>
<div className="actions">
<button className="btn" type="button" disabled={busy} onClick={() => void poll()}>
@@ -139,6 +157,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
<table className="t admin-table">
<thead>
<tr>
<th className="col-check"><input type="checkbox" checked={selected.size === assets.length && assets.length > 0} onChange={toggleAll} aria-label="全选" /></th>
<th></th>
<th></th>
<th></th>
@@ -149,6 +168,7 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
<tbody>
{assets.map((a) => (
<tr key={a.id}>
<td className="col-check"><input type="checkbox" checked={selected.has(a.id)} onChange={() => toggleOne(a.id)} aria-label="选择" /></td>
<td>
{a.preview_url
? (
@@ -171,11 +191,11 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
{a.review_status === "failed" && a.review_error && <span className="admin-review-err mono" title={a.review_error}>!</span>}
</td>
<td className="col-actions">
{a.review_status === "failed" ? (
<button className="btn btn-sm btn-ghost" type="button" disabled={busy} onClick={() => void retry(a.id)}>
{(a.review_status === "failed" || a.review_status === "") && (
<button className="btn btn-sm btn-ghost" type="button" disabled={busy} onClick={() => void submit([a.id])}>
{a.review_status === "failed" ? "重试" : "送审"}
</button>
) : null}
)}
</td>
</tr>
))}
@@ -185,6 +205,14 @@ export function AdminReviewsPage({ notify }: { notify: Notify }) {
</div>
)}
{selected.size > 0 && (
<div className="admin-bulk-bar" role="toolbar" aria-label="批量送审">
<span className="admin-bulk-count"> {selected.size} </span>
<button className="btn btn-sm" type="button" onClick={() => setSelected(new Set())}></button>
<button className="btn btn-sm btn-primary" type="button" disabled={busy} onClick={() => void submit([...selected])}></button>
</div>
)}
<MediaLightbox open={!!preview} src={preview?.src || ""} kind="image" name={preview?.name} close={() => setPreview(null)} />
</>
);
+16 -16
View File
@@ -54,7 +54,7 @@ const REPLACE_MODE_COPY = {
targetLabel: "商品",
targetStep: "2. 选择自己的商品",
videoEmpty: "系统会先把参考视频拆成分镜稿,再照着它换成你的商品",
videoReady: "视频已就绪,将先提炼分镜稿再复刻",
videoReady: "视频已就绪,将先提炼分镜稿再逐镜复刻",
libraryTitle: "从商品库选择",
libraryEmpty: "选择已创建的商品",
temporaryTitle: "临时上传素材",
@@ -62,9 +62,9 @@ const REPLACE_MODE_COPY = {
temporaryNoun: "商品图",
temporaryFallback: "临时商品素材",
generatingTitle: "正在进行商品复刻",
generatingCopy: "正在照着分镜稿还原镜头,并换上你的商品",
generatingCopy: "正在按分镜一镜一镜还原,并换上你的商品",
reviewingTitle: "正在审核参考素材",
reviewingCopy: "真人视频需先通过合规审核,通过后自动开始复刻",
reviewingCopy: "参考图需先通过合规审核,通过后自动开始复刻",
digestingTitle: "正在提炼参考视频",
digestingCopy: "逐镜拆解景别、运镜、节奏与口播,拆完自动开始复刻",
resultTitle: "商品复刻已完成",
@@ -80,8 +80,8 @@ const REPLACE_MODE_COPY = {
modeLabel: "角色复刻",
targetLabel: "角色",
targetStep: "2. 选择自己的角色",
videoEmpty: "系统将自动识别需要替换的原片角色",
videoReady: "视频已就绪,将自动识别原片角色",
videoEmpty: "系统会先把参考视频拆成分镜稿,再照着它换成你的角色",
videoReady: "视频已就绪,将先提炼分镜稿再逐镜复刻",
libraryTitle: "从人物库选择",
libraryEmpty: "选择已创建的人物",
temporaryTitle: "临时上传角色",
@@ -89,9 +89,9 @@ const REPLACE_MODE_COPY = {
temporaryNoun: "角色参考图",
temporaryFallback: "临时角色素材",
generatingTitle: "正在进行角色复刻",
generatingCopy: "正在匹配角色外观、表情与原片动作",
generatingCopy: "正在按分镜一镜一镜还原,并换上你的角色",
reviewingTitle: "正在审核参考素材",
reviewingCopy: "真人视频需先通过合规审核,通过后自动开始复刻",
reviewingCopy: "参考图需先通过合规审核,通过后自动开始复刻",
digestingTitle: "正在提炼参考视频",
digestingCopy: "逐镜拆解景别、运镜、节奏与口播,拆完自动开始复刻",
resultTitle: "角色复刻已完成",
@@ -365,10 +365,6 @@ export function VideoReplacePage({
resolution: "720p",
duration: outputDuration,
refs: [
// 商品复刻的参考视频只用来提炼分镜稿,不会发给火山,算进估价会让显示积分高于实际扣费。
...(videoRef && replaceMode === "character"
? [{ type: "video", duration: videoRef.duration || videoMeta.duration }]
: []),
...((source === "library"
? Array.from({ length: Math.max(1, libraryImageCount) }, () => ({ type: "image" as const }))
: (tempFiles.length ? tempFiles : tempAssetRefs)
@@ -379,10 +375,14 @@ export function VideoReplacePage({
// 上传参考视频不算「正在复刻」:右侧面板要保持待命,只在上传区自己显示进度。
// 生成按钮不会因此被误点 —— videoReady 要等 asset_id 回来才为真。
const generating = Boolean(job && isInFlight(job.status)) || submitting;
const digesting = Boolean(job && isDigesting(job));
// 商品复刻提交后先进拆解态;审核态是角色复刻专有(参考视频直传火山才需要送审)。
const reviewing = !digesting && (submitting || Boolean(job && (job.review_stage === "reviewing" || job.status === "created")));
const digesting = Boolean((job && isDigesting(job)) || (submitting && !job));
const reviewing = !digesting && Boolean(job && (job.review_stage === "reviewing" || job.status === "created"));
const hasResult = Boolean(job && job.status === "succeeded" && job.video_url);
const shotTotal = Number(job?.shot_total || 0);
const shotIndex = Number(job?.shot_index || 0);
const generatingCopy = shotTotal > 1 && shotIndex > 0
? `正在生成第 ${shotIndex}/${shotTotal} 镜,逐镜还原后再拼成完整片子`
: copy.generatingCopy;
const resultCopy = hasResult && job ? REPLACE_MODE_COPY[modeFromTask(job)] : copy;
const panelClass = [
"video-result-panel replace-result-panel",
@@ -392,7 +392,7 @@ export function VideoReplacePage({
hasResult ? "has-result" : "",
].filter(Boolean).join(" ");
const generateLabel = generating
? (digesting ? "正在提炼参考视频…" : reviewing ? "正在审核素材…" : "正在复刻…")
? (digesting ? "正在提炼参考视频…" : reviewing ? "正在审核素材…" : (shotTotal > 1 && shotIndex > 0 ? `正在复刻 ${shotIndex}/${shotTotal} 镜…` : "正在复刻…"))
: hasResult
? `再次${copy.modeLabel} · 消耗 ${points} 积分`
: `开始${copy.modeLabel} · 消耗 ${points} 积分`;
@@ -1121,7 +1121,7 @@ export function VideoReplacePage({
<span className="replace-generating-product">{replaceMode === "character" ? <UserRound /> : <Package />}</span>
</div>
<strong>{digesting ? copy.digestingTitle : reviewing ? copy.reviewingTitle : copy.generatingTitle}</strong>
<span>{digesting ? copy.digestingCopy : reviewing ? copy.reviewingCopy : copy.generatingCopy}</span>
<span>{digesting ? copy.digestingCopy : reviewing ? copy.reviewingCopy : generatingCopy}</span>
<div className="replace-generating-bar" aria-hidden="true"><span /></div>
</div>
</div>
+3 -1
View File
@@ -627,12 +627,14 @@ export type FreeVideoTask = {
product_id?: string;
model_id?: string;
review_stage?: "reviewing" | "";
// 商品复刻专有:参考视频先被提炼成分镜稿,再连商品图一起交给 Seedance。
// 商品复刻:参考视频先被提炼成分镜稿,再一镜一次交给 Seedance。
digest_stage?: "digesting" | "";
digest_text?: string;
digest_shots?: number;
digest_source_name?: string;
digest_source?: FreeVideoRef | null;
shot_index?: number;
shot_total?: number;
references: FreeVideoRef[];
estimated_tokens: number;
actual_tokens: number;