优化视频复刻和商品内容大小
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""上传图片的尺寸闸。
|
||||
|
||||
火山素材审核要求图片宽高在 300–6000 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
|
||||
@@ -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 而不是「在不在某个库里」—— 按库免审等于把审核架空:
|
||||
# 用户上传一张图进资产库,再从自由创作引用出去,就绕过了整套人像审核。
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"]},
|
||||
),
|
||||
]
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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%; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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)); }
|
||||
}
|
||||
|
||||
/* 模特详情弹窗:参考视频项目角色详情结构 */
|
||||
|
||||
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user