import json from django.test import SimpleTestCase from apps.ai.script_agent import normalize_draft class NormalizeDraftTests(SimpleTestCase): """normalize_draft 对模型不按契约输出的容错(防「旁白/画面全空」回归)。""" def test_scriptdraft_shots_variant_fills_narration_and_visual(self): """模型常见变体:{"ScriptDraft":{"basicInfo":..,"shots":[{scene,dialogue,subtitle}]}}。 必须解开外壳 + 把 shots→segments、scene→画面、dialogue/subtitle→旁白,而非全填空占位镜。""" raw = json.dumps({ "ScriptDraft": { "basicInfo": {"totalDuration": 60, "aspectRatio": "9:16", "totalShots": 4}, "shots": [ {"shotNo": 1, "duration": 15, "scene": "更衣室扯卡裆旧裤", "dialogue": "卡裆太社死!", "subtitle": "还在卡裆?"}, {"shotNo": 2, "duration": 15, "scene": "特写拉扯面料回弹", "dialogue": "高弹不变形!", "subtitle": "裸感面料"}, {"shotNo": 3, "duration": 15, "scene": "健身切通勤", "dialogue": "都能穿!", "subtitle": "一裤多穿"}, {"shotNo": 4, "duration": 15, "scene": "对镜弹小黄车", "dialogue": "点小黄车抢!", "subtitle": "点击入手"}, ], } }, ensure_ascii=False) draft = normalize_draft(raw, aspect_ratio="9:16", total_duration=60) self.assertEqual(len(draft["segments"]), 4) self.assertEqual([s["role"] for s in draft["segments"]], ["钩子", "痛点", "卖点", "CTA"]) for seg in draft["segments"]: self.assertTrue(seg["narration"], "旁白不应为空") self.assertTrue(seg["visual"], "画面不应为空") self.assertEqual(draft["segments"][0]["narration"], "卡裆太社死!") self.assertEqual(draft["segments"][0]["visual"], "更衣室扯卡裆旧裤") def test_lines_and_caption_variant_fills_narration(self): """另一种变体(Doubao-Seed-2.0-P):shots 用 lines:[{role,content}] 放口播、caption 放字幕。 必须把 lines 的 content 当对白/旁白,caption 兜底,而不是只填画面留旁白空。""" raw = json.dumps({ "ScriptDraft": { "basicInfo": {"totalDuration": 30, "aspectRatio": "9:16"}, "shots": [ {"shotNo": 1, "scene": "化妆台两闺蜜", "lines": [{"role": "女主", "content": "防晒泛白太尴尬!"}, {"role": "闺蜜", "content": "试试这个!"}], "caption": "防晒踩雷?"}, {"shotNo": 2, "scene": "手背挤膏体", "lines": [{"role": "闺蜜", "content": "质地清透不泛白"}], "caption": "清透质地"}, ], } }, ensure_ascii=False) draft = normalize_draft(raw, aspect_ratio="9:16", total_duration=30) self.assertEqual(len(draft["segments"]), 2) for seg in draft["segments"]: self.assertTrue(seg["narration"], "旁白不应为空") self.assertTrue(seg["visual"], "画面不应为空") self.assertIn("防晒泛白太尴尬", draft["segments"][0]["narration"]) self.assertEqual(len(draft["segments"][0]["dialogue"]), 2) # lines→结构化对白 def test_generic_resolver_covers_unseen_field_names(self): """模型每次换字段名(scene/screenDescription/画面…、dialogue/lines/caption…)。 通用解析应「优先键 + 关键词模糊匹配」都能填,且跳过 bgMusic/note 等非内容键。""" variants = { "canonical": {"total_duration": 15, "segments": [{"role": "钩子", "narration": "口播A", "visual": "画面A"}]}, "scene+dialogue_str+subtitle": {"total_duration": 15, "shots": [{"scene": "画面B", "dialogue": "口播B", "subtitle": "字幕B"}]}, "scene+lines+caption": {"total_duration": 15, "shots": [{"scene": "画面C", "lines": [{"role": "女主", "content": "口播C"}], "caption": "字幕C"}]}, "screenDescription+dialogue+bgMusic+note": {"total_duration": 15, "shots": [{"screenDescription": "画面D", "dialogue": "口播D", "bgMusic": "音乐D", "note": "备注D"}]}, } for name, raw in variants.items(): draft = normalize_draft(json.dumps(raw, ensure_ascii=False), aspect_ratio="9:16", total_duration=15) seg = draft["segments"][0] self.assertTrue(seg["narration"], f"{name}: 旁白为空") self.assertTrue(seg["visual"], f"{name}: 画面为空") # bgMusic/note 不得被误当画面/旁白 d = normalize_draft(json.dumps(variants["screenDescription+dialogue+bgMusic+note"], ensure_ascii=False), aspect_ratio="9:16", total_duration=15) self.assertEqual(d["segments"][0]["visual"], "画面D") self.assertEqual(d["segments"][0]["narration"], "口播D") def test_string_dialogue_not_iterated_as_chars(self): """dialogue 为整句字符串时,要当作旁白而不是逐字符遍历。""" raw = json.dumps({ "segments": [{"role": "钩子", "dialogue": "一句完整口播", "visual": "画面"}], "total_duration": 15, }, ensure_ascii=False) draft = normalize_draft(raw, aspect_ratio="9:16", total_duration=15) self.assertEqual(draft["segments"][0]["narration"], "一句完整口播") self.assertEqual(draft["segments"][0]["dialogue"], []) # 字符串不进结构化对白 def test_canonical_flat_schema_still_works(self): """契约内的扁平 schema(segments/narration/visual)不受兼容改动影响。""" raw = json.dumps({ "total_duration": 30, "segments": [ {"role": "钩子", "narration": "口播1", "visual": "画面1"}, {"role": "CTA", "narration": "口播2", "visual": "画面2"}, ], }, ensure_ascii=False) draft = normalize_draft(raw, aspect_ratio="9:16", total_duration=30) self.assertEqual(len(draft["segments"]), 2) self.assertEqual(draft["segments"][0]["narration"], "口播1") self.assertEqual(draft["segments"][1]["visual"], "画面2") from io import BytesIO from unittest.mock import patch from django.test import TestCase from apps.accounts.models import Team, User from apps.ai.services import enqueue_standalone_images from apps.assets.models import Asset, AssetFile from apps.billing.models import CreditAccount from apps.products.models import Product class StandaloneImageReferenceTests(TestCase): """独立生图(平台套图 / 模特上身图)必须把商品真实主图(+ 模特图)作为参考图走 image_edit, 而不是纯文生图——回归保护 #图片生成没参考商品主图# 这个 bug。 (图像默认模型由迁移 seed 的 tokenssr:gpt-image-2 提供,get_image_provider 全程 mock。)""" def setUp(self): self.user = User.objects.create_user(username="owner", password="pass") self.team = Team.objects.create(name="T", owner=self.user) CreditAccount.objects.create(team=self.team, balance="100.0000") # 商品 + 主图(带可访问 preview_url) self.product = Product.objects.create(team=self.team, created_by=self.user, title="南卡 Lite Pro") cover = Asset.objects.create( team=self.team, created_by=self.user, name="主图", asset_type=Asset.Type.IMAGE, source=Asset.Source.UPLOAD, category=Asset.Category.PRODUCT_IMAGE, ) AssetFile.objects.create(asset=cover, object_key="c.png", bucket="b", content_type="image/png", preview_url="http://x/cover.png", is_primary=True) self.product.cover_asset = cover self.product.save(update_fields=["cover_asset"]) def _patch_provider(self): # image_edit 返回值 + 媒体落库链路全部 mock,聚焦验证「传了哪些参考图」 provider = patch("apps.ai.services.get_image_provider").start() prov = provider.return_value prov.image_edit.return_value = {"data": [{"url": "http://x/out.png"}]} prov.image_generation.return_value = {"data": [{"url": "http://x/out.png"}]} prov.extract_first_media_url.return_value = "http://x/out.png" media = patch("apps.ai.services.VolcanoArkProvider.media_to_bytes").start() media.return_value = (BytesIO(b"img"), "image/png") store = patch("apps.ai.services.TosStorage").start() stored = store.return_value.upload_fileobj.return_value stored.object_key, stored.bucket, stored.content_type, stored.size_bytes = "o.png", "b", "image/png", 3 self.addCleanup(patch.stopall) return prov def test_cover_mode_references_product_main_image(self): prov = self._patch_provider() enqueue_standalone_images(team=self.team, user=self.user, prompt="平台套图", mode="cover", count=1, product_id=str(self.product.id), ratio="4:5") prov.image_edit.assert_called_once() self.assertEqual(prov.image_edit.call_args.kwargs["images"], ["http://x/cover.png"]) prov.image_generation.assert_not_called() def test_model_tryon_combines_product_and_model_images(self): prov = self._patch_provider() # 模特资产(PERSON)带 preview_url model_asset = Asset.objects.create( team=self.team, created_by=self.user, name="模特", asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=Asset.Category.PERSON, ) AssetFile.objects.create(asset=model_asset, object_key="m.png", bucket="b", content_type="image/png", preview_url="http://x/model.png", is_primary=True) enqueue_standalone_images(team=self.team, user=self.user, prompt="上身图", mode="model", count=1, product_id=str(self.product.id), model_id=str(model_asset.id), ratio="4:5") prov.image_edit.assert_called_once() self.assertEqual(prov.image_edit.call_args.kwargs["images"], ["http://x/cover.png", "http://x/model.png"]) prov.image_generation.assert_not_called() def test_cover_mode_falls_back_to_t2i_without_main_image(self): prov = self._patch_provider() self.product.cover_asset = None self.product.save(update_fields=["cover_asset"]) enqueue_standalone_images(team=self.team, user=self.user, prompt="平台套图", mode="cover", count=1, product_id=str(self.product.id), ratio="4:5") prov.image_generation.assert_called_once() prov.image_edit.assert_not_called()