优化住流程和添加复刻视频页面

This commit is contained in:
Azmat@qq.com
2026-08-26 18:44:03 +08:00
parent 0ee498d807
commit c25060c6c6
30 changed files with 2574 additions and 155 deletions
+6 -5
View File
@@ -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):
+13 -4
View File
@@ -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",