优化住流程和添加复刻视频页面
This commit is contained in:
@@ -423,11 +423,12 @@ class QuickCreateJobSerializer(serializers.ModelSerializer):
|
||||
if version is not None and is_playable_video(version.asset):
|
||||
video_url = _asset_preview_url(version.asset)
|
||||
break
|
||||
first_shot = next(
|
||||
(shot for shot in project.storyboard_shots.all() if shot.adopted_version_id),
|
||||
None,
|
||||
)
|
||||
poster_url = _asset_preview_url(first_shot.adopted_version.asset) if first_shot else ""
|
||||
product = getattr(project, "product", None)
|
||||
cover = getattr(product, "cover_asset", None) if product is not None else None
|
||||
poster_url = _asset_preview_url(cover) if cover else ""
|
||||
if not poster_url and product is not None:
|
||||
first_image = next(iter(product.images.all()), None)
|
||||
poster_url = _asset_preview_url(first_image.asset) if first_image else ""
|
||||
script = next((version for version in project.script_versions.all() if version.is_adopted), None)
|
||||
script_meta = (script.metadata or {}) if script is not None else {}
|
||||
format_label = {"oral": "口播展示", "drama": "短剧演绎", "vlog": "Vlog种草"}.get(
|
||||
|
||||
@@ -15,7 +15,13 @@ from django.db import connections, transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.ai.models import AITask, ModelConfig
|
||||
from apps.ai.script_agent import stream_script_agent
|
||||
from apps.ai.script_agent import (
|
||||
PRESENTATION_FORMATS,
|
||||
VIDEO_STRUCTURES,
|
||||
persona_label,
|
||||
recommend_script_setup,
|
||||
stream_script_agent,
|
||||
)
|
||||
from apps.ai.services import (
|
||||
collect_video_review_blockers,
|
||||
create_export_job,
|
||||
@@ -364,6 +370,17 @@ def _consume_script_agent(job: QuickCreateJob) -> None:
|
||||
if model_config is None:
|
||||
raise ValueError(f"{QUICK_SCRIPT_MODEL_NAME} is not configured")
|
||||
|
||||
wizard = dict((project.metadata or {}).get("wizard") or {})
|
||||
product = project.product
|
||||
rec = recommend_script_setup(
|
||||
getattr(product, "category", "") or "",
|
||||
getattr(product, "title", "") or "",
|
||||
)
|
||||
fmt = wizard.get("presentation_format") or rec["format"]
|
||||
structure = wizard.get("video_structure") or rec["structure"]
|
||||
persona = wizard.get("persona") or rec["persona"]
|
||||
format_label = PRESENTATION_FORMATS.get(fmt, fmt)
|
||||
structure_label = VIDEO_STRUCTURES.get(structure, structure)
|
||||
error_detail = ""
|
||||
stream = stream_script_agent(
|
||||
project=project,
|
||||
@@ -371,15 +388,16 @@ def _consume_script_agent(job: QuickCreateJob) -> None:
|
||||
model_config=model_config,
|
||||
mode="auto",
|
||||
user_prompt=(
|
||||
"请根据商品名称与商品参考图信息,自动推荐最适合的"
|
||||
f"{settings['total_duration']}秒、{settings['aspect_ratio']}画幅带货方案。"
|
||||
"请根据商品名称、品类与商品参考图信息,按推荐的"
|
||||
f"{format_label} × {structure_label} × {persona_label(persona)},"
|
||||
f"生成{settings['total_duration']}秒、{settings['aspect_ratio']}画幅带货方案。"
|
||||
),
|
||||
aspect_ratio=settings["aspect_ratio"],
|
||||
total_duration=settings["total_duration"],
|
||||
presentation_format="oral",
|
||||
video_structure="pain",
|
||||
presentation_format=fmt,
|
||||
video_structure=structure,
|
||||
entry_source="ai",
|
||||
persona="reviewer",
|
||||
persona=persona,
|
||||
)
|
||||
# 豆包思考模型会连续吐几百帧 reasoning。原先每帧 refresh_from_db,远程 MySQL
|
||||
# 一抖动就会把生成器关掉,模型调用被误记成 stream aborted (client disconnected)。
|
||||
|
||||
@@ -76,6 +76,7 @@ class QuickCreateApiTests(TestCase):
|
||||
"aspect_ratio": "9:16",
|
||||
"resolution": "720p",
|
||||
"total_duration": "30",
|
||||
"category": "食品饮料",
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
@@ -83,10 +84,14 @@ class QuickCreateApiTests(TestCase):
|
||||
self.assertEqual(response.status_code, 202)
|
||||
product = Product.objects.get(title="轻醒咖啡")
|
||||
self.assertEqual(product.images.count(), 2)
|
||||
self.assertEqual(product.category, "食品饮料")
|
||||
self.assertEqual(product.cover_asset_id, product.images.order_by("sort_order").first().asset_id)
|
||||
project = Project.objects.get(product=product)
|
||||
self.assertTrue(project.metadata["quick_create"])
|
||||
self.assertEqual(project.metadata["wizard"]["total_duration"], 30)
|
||||
self.assertEqual(project.metadata["wizard"]["presentation_format"], "oral")
|
||||
self.assertEqual(project.metadata["wizard"]["video_structure"], "scene")
|
||||
self.assertEqual(project.metadata["wizard"]["persona"], "bestie")
|
||||
self.assertEqual(project.metadata["wizard"]["resolution"], "720p")
|
||||
self.assertEqual(project.metadata["wizard"]["video_model_config_id"], str(video_model_id))
|
||||
self.assertEqual(project.stages.count(), 5)
|
||||
@@ -106,6 +111,30 @@ class QuickCreateApiTests(TestCase):
|
||||
{str(image.asset_id) for image in product.images.order_by("sort_order")},
|
||||
)
|
||||
|
||||
@patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object())
|
||||
@patch("apps.projects.views.get_default_model")
|
||||
@patch("apps.projects.tasks.advance_quick_create_task.apply_async")
|
||||
@patch("apps.projects.views.require_worker_task")
|
||||
@patch("apps.projects.views._store_uploaded_asset")
|
||||
def test_submit_requires_product_category(self, store_asset, require_worker_task, enqueue, get_model, get_quick_model):
|
||||
store_asset.side_effect = self._uploaded_asset
|
||||
get_model.side_effect = [
|
||||
object(),
|
||||
SimpleNamespace(id=uuid.uuid4(), name="seedance", display_name="Seedance", metadata={}),
|
||||
]
|
||||
response = self.client.post(
|
||||
"/api/projects/quick-create/",
|
||||
{
|
||||
"name": "无品类咖啡",
|
||||
"images": [SimpleUploadedFile("front.png", b"png", content_type="image/png")],
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("品类", str(response.data["detail"]))
|
||||
self.assertFalse(Product.objects.filter(title="无品类咖啡").exists())
|
||||
enqueue.assert_not_called()
|
||||
|
||||
@patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object())
|
||||
@patch("apps.projects.views.get_default_model", return_value=object())
|
||||
@patch("apps.projects.tasks.advance_quick_create_task.apply_async")
|
||||
@@ -129,6 +158,7 @@ class QuickCreateApiTests(TestCase):
|
||||
{
|
||||
"name": "复用商品图",
|
||||
"source_product_id": str(source.id),
|
||||
"category": "美妆个护",
|
||||
"aspect_ratio": "9:16",
|
||||
"resolution": "720p",
|
||||
"total_duration": "15",
|
||||
@@ -140,6 +170,7 @@ class QuickCreateApiTests(TestCase):
|
||||
store_asset.assert_not_called()
|
||||
product = Product.objects.get(title="复用商品图")
|
||||
self.assertEqual(product.images.count(), 1)
|
||||
self.assertEqual(product.category, "美妆个护")
|
||||
self.assertEqual(product.images.first().asset_id, source_asset.id)
|
||||
self.assertEqual(response.data["product_images"][0]["asset_id"], str(source_asset.id))
|
||||
|
||||
@@ -651,6 +682,52 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING)
|
||||
enqueue.assert_called_once()
|
||||
|
||||
@patch("apps.projects.services.quick_create.get_quick_script_model")
|
||||
@patch("apps.projects.services.quick_create.stream_script_agent")
|
||||
def test_consume_script_uses_category_recommendation(self, stream_fn, get_model):
|
||||
get_model.return_value = self.model
|
||||
self.product.category = "食品饮料"
|
||||
self.product.title = "轻醒咖啡"
|
||||
self.product.save(update_fields=["category", "title", "updated_at"])
|
||||
self.project.metadata = {"wizard": {"total_duration": 30, "aspect_ratio": "9:16"}}
|
||||
self.project.save(update_fields=["metadata", "updated_at"])
|
||||
stream_fn.return_value = iter([])
|
||||
with self.assertRaises(ValueError):
|
||||
_consume_script_agent(self.job)
|
||||
kwargs = stream_fn.call_args.kwargs
|
||||
self.assertEqual(kwargs["presentation_format"], "oral")
|
||||
self.assertEqual(kwargs["video_structure"], "scene")
|
||||
self.assertEqual(kwargs["persona"], "bestie")
|
||||
self.assertEqual(kwargs["total_duration"], 30)
|
||||
self.assertIn("口播", kwargs["user_prompt"])
|
||||
self.assertIn("场景种草", kwargs["user_prompt"])
|
||||
self.assertIn("闺蜜种草", kwargs["user_prompt"])
|
||||
|
||||
@patch("apps.projects.services.quick_create.get_quick_script_model")
|
||||
@patch("apps.projects.services.quick_create.stream_script_agent")
|
||||
def test_consume_script_keeps_wizard_combo(self, stream_fn, get_model):
|
||||
get_model.return_value = self.model
|
||||
self.product.category = "食品饮料"
|
||||
self.product.save(update_fields=["category", "updated_at"])
|
||||
self.project.metadata = {
|
||||
"wizard": {
|
||||
"presentation_format": "oral",
|
||||
"video_structure": "pain",
|
||||
"persona": "reviewer",
|
||||
"total_duration": 15,
|
||||
"aspect_ratio": "9:16",
|
||||
}
|
||||
}
|
||||
self.project.save(update_fields=["metadata", "updated_at"])
|
||||
stream_fn.return_value = iter([])
|
||||
with self.assertRaises(ValueError):
|
||||
_consume_script_agent(self.job)
|
||||
kwargs = stream_fn.call_args.kwargs
|
||||
self.assertEqual(kwargs["presentation_format"], "oral")
|
||||
self.assertEqual(kwargs["video_structure"], "pain")
|
||||
self.assertEqual(kwargs["persona"], "reviewer")
|
||||
self.assertEqual(kwargs["total_duration"], 15)
|
||||
|
||||
@patch("apps.projects.services.quick_create.get_quick_script_model")
|
||||
@patch("apps.projects.services.quick_create.stream_script_agent")
|
||||
def test_consume_script_does_not_hit_db_on_every_sse_frame(self, stream_fn, get_model):
|
||||
|
||||
@@ -21,6 +21,7 @@ from apps.ai.script_agent import (
|
||||
SEGMENT_DURATION_MIN,
|
||||
combo_keys,
|
||||
coerce_total_duration,
|
||||
recommend_script_setup,
|
||||
stream_script_agent,
|
||||
)
|
||||
from apps.ai.services import (
|
||||
@@ -607,6 +608,11 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if len(reused_assets) + len(uploads) > 9:
|
||||
return Response({"detail": "商品图片最多上传9张"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
category = str(request.data.get("category") or "").strip()
|
||||
if not category:
|
||||
return Response({"detail": "请选择商品品类"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if category not in Product.ECOMMERCE_CATEGORIES:
|
||||
return Response({"detail": "请选择有效的商品品类"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
with transaction.atomic():
|
||||
assets = list(reused_assets)
|
||||
@@ -628,6 +634,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
created_by=request.user,
|
||||
title=name,
|
||||
business_type=Product.BusinessType.ECOMMERCE,
|
||||
category=category,
|
||||
cover_asset=assets[0],
|
||||
)
|
||||
ProductImage.objects.bulk_create(
|
||||
@@ -641,6 +648,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
for index, asset in enumerate(assets)
|
||||
]
|
||||
)
|
||||
rec = recommend_script_setup(category, name)
|
||||
project = Project.objects.create(
|
||||
team=team,
|
||||
created_by=request.user,
|
||||
@@ -657,9 +665,9 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"video_model_config_id": str(video_model.id),
|
||||
"video_model_name": video_model.name,
|
||||
"video_model_label": video_model.display_name,
|
||||
"presentation_format": "oral",
|
||||
"video_structure": "pain",
|
||||
"persona": "reviewer",
|
||||
"presentation_format": rec["format"],
|
||||
"video_structure": rec["structure"],
|
||||
"persona": rec["persona"],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -697,9 +705,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
|
||||
def _quick_job_queryset(self):
|
||||
return (
|
||||
QuickCreateJob.objects.select_related("project__product", "project__timeline")
|
||||
QuickCreateJob.objects.select_related("project__product", "project__product__cover_asset", "project__timeline")
|
||||
.prefetch_related(
|
||||
"project__product__images__asset__files",
|
||||
"project__product__cover_asset__files",
|
||||
"project__script_versions",
|
||||
"project__base_asset_groups",
|
||||
"project__storyboard_shots__adopted_version__asset__files",
|
||||
|
||||
Reference in New Issue
Block a user