完成极速成品和脚本优化

This commit is contained in:
Azmat@qq.com
2026-08-25 11:13:07 +08:00
parent 00fc454db7
commit e2ec2d14af
46 changed files with 4734 additions and 432 deletions
+281 -75
View File
@@ -44,9 +44,10 @@ from apps.assets.models import Asset, AssetFile
from apps.assets.serializers import AssetFileSerializer
from apps.assets.storage import TosStorage
from apps.common.api import TeamScopedViewSetMixin
from apps.common.celery_health import require_worker
from apps.common.celery_health import require_worker, require_worker_task
from apps.ai.generation_errors import classify_generation_error, public_error_for_task
from apps.ai.video_digest import VideoDigestError, digest_project_video
from apps.products.models import Product, ProductImage, ProductSellingPoint
from .models import (
BaseAssetGroup,
@@ -54,6 +55,7 @@ from .models import (
ExportJob,
Project,
ProjectStage,
QuickCreateJob,
ScriptSegment,
ScriptTemplate,
ScriptVersion,
@@ -72,6 +74,7 @@ from .serializers import (
ExportJobSerializer,
ProjectListSerializer,
ProjectSerializer,
QuickCreateJobSerializer,
ScriptTemplateSerializer,
ScriptVersionSerializer,
StoryboardVersionSerializer,
@@ -79,10 +82,15 @@ from .serializers import (
is_playable_video,
)
from .services.export import run_export_job_in_thread
from .services.pipeline import STAGE_ORDER
from .services.pipeline import (
adopt_script_version,
finish_storyboard_stage,
initialize_project_pipeline,
sync_video_segments_to_script,
)
from .services.script_import import ScriptFileError, extract_script_text
from .services.templates import build_template_fields, coerce_persona, coerce_template_combo, render_outline_text
from .tasks import poll_video_segment_task
from .tasks import advance_quick_create_task, poll_video_segment_task
logger = logging.getLogger(__name__)
@@ -101,7 +109,12 @@ class ServerSentEventRenderer(BaseRenderer):
def _store_uploaded_asset(*, team, user, upload, asset_type: str, category: str, name: str) -> Asset:
"""把上传的文件落到 TOS,建 Asset+AssetFile(主文件)。供上传视频段 / 上传 BGM 复用。"""
suffix = Path(upload.name).suffix.lower() or (".mp4" if asset_type == Asset.Type.VIDEO else ".mp3")
fallback_suffix = {
Asset.Type.IMAGE: ".png",
Asset.Type.VIDEO: ".mp4",
Asset.Type.AUDIO: ".mp3",
}.get(asset_type, ".bin")
suffix = Path(upload.name).suffix.lower() or fallback_suffix
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/uploads/{asset_id}{suffix}"
stored = TosStorage().upload_fileobj(
@@ -435,17 +448,271 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
by_status = {row["status"]: row["n"] for row in base.values("status").annotate(n=Count("id"))}
return Response({"total": base.count(), "by_status": by_status})
@action(
detail=False,
methods=["post"],
url_path="quick-create",
parser_classes=[MultiPartParser, FormParser],
)
def quick_create(self, request):
"""商品名称 + 1–9 张图 → 新建商品与项目,并启动完整自动生产流水线。"""
require_worker_task("apps.projects.tasks.advance_quick_create_task")
name = str(request.data.get("name") or "").strip()
uploads = request.FILES.getlist("images") or request.FILES.getlist("images[]")
source_product_id = str(request.data.get("source_product_id") or "").strip()
requested_asset_ids = [
str(value).strip()
for value in (request.data.getlist("image_asset_ids") or request.data.getlist("image_asset_ids[]") or [])
if str(value).strip()
]
if not name:
return Response({"detail": "请填写商品名称"}, status=status.HTTP_400_BAD_REQUEST)
if len(name) > 255:
return Response({"detail": "商品名称不能超过255个字符"}, status=status.HTTP_400_BAD_REQUEST)
if not uploads and not source_product_id:
return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST)
if len(uploads) > 9:
return Response({"detail": "商品图片最多上传9张"}, status=status.HTTP_400_BAD_REQUEST)
allowed_types = {"image/jpeg", "image/png", "image/webp"}
for upload in uploads:
if (upload.content_type or "").lower() not in allowed_types:
return Response({"detail": "仅支持 JPG、PNG 或 WebP 商品图片"}, status=status.HTTP_400_BAD_REQUEST)
if upload.size > 20 * 1024 * 1024:
return Response({"detail": "单张商品图片不能超过20MB"}, status=status.HTTP_400_BAD_REQUEST)
aspect_ratio = str(request.data.get("aspect_ratio") or "9:16")
resolution = str(request.data.get("resolution") or "720p").lower()
try:
total_duration = int(request.data.get("total_duration") or 15)
except (TypeError, ValueError):
total_duration = 0
if aspect_ratio not in {"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}:
return Response({"detail": "请选择有效的视频比例"}, status=status.HTTP_400_BAD_REQUEST)
if resolution not in {"480p", "720p", "1080p", "4k"}:
return Response({"detail": "请选择有效的视频分辨率"}, status=status.HTTP_400_BAD_REQUEST)
if total_duration not in {15, 30, 45, 60}:
return Response({"detail": "视频时长仅支持15、30、45或60秒"}, status=status.HTTP_400_BAD_REQUEST)
text_model = get_default_model(ModelConfig.Capability.TEXT)
image_model = get_default_model(ModelConfig.Capability.IMAGE)
requested_video_model_id = str(request.data.get("video_model_config_id") or "").strip()
video_model = None
if requested_video_model_id:
try:
video_model_uuid = uuid.UUID(requested_video_model_id)
except (TypeError, ValueError):
return Response({"detail": "请选择有效的视频模型"}, status=status.HTTP_400_BAD_REQUEST)
video_model = (
ModelConfig.objects.select_related("provider")
.filter(
id=video_model_uuid,
capability=ModelConfig.Capability.VIDEO,
status=ModelConfig.Status.ACTIVE,
provider__status="active",
)
.first()
)
else:
video_model = get_default_model(ModelConfig.Capability.VIDEO)
missing = [
label
for model, label in ((text_model, "文本"), (image_model, "图像"), (video_model, "视频"))
if model is None
]
if missing:
return Response(
{"detail": f"当前缺少可用的{''.join(missing)}模型,请联系管理员配置"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
capabilities = dict((video_model.metadata or {}).get("capabilities") or {})
supported_resolutions = set(capabilities.get("resolutions") or (video_model.metadata or {}).get("resolutions") or [])
supported_ratios = set(capabilities.get("aspect_ratios") or [])
supported_durations = set(capabilities.get("durations") or (video_model.metadata or {}).get("durations") or [])
if supported_resolutions and resolution not in supported_resolutions:
return Response(
{"detail": f"{video_model.display_name} 不支持 {resolution.upper()},请更换清晰度或模型"},
status=status.HTTP_400_BAD_REQUEST,
)
if supported_ratios and aspect_ratio not in supported_ratios:
return Response(
{"detail": f"{video_model.display_name} 不支持 {aspect_ratio} 比例"},
status=status.HTTP_400_BAD_REQUEST,
)
# 极速成片把总时长拆成多个15秒镜头,视频模型只需支持单镜15秒。
if supported_durations and 15 not in supported_durations:
return Response(
{"detail": f"{video_model.display_name} 不支持15秒单镜生成"},
status=status.HTTP_400_BAD_REQUEST,
)
team = self.get_team()
reused_assets = []
if source_product_id:
try:
source_uuid = uuid.UUID(source_product_id)
except (TypeError, ValueError):
return Response({"detail": "找不到可复用的商品图片"}, status=status.HTTP_400_BAD_REQUEST)
source = (
Product.objects.filter(id=source_uuid, team=team)
.prefetch_related("images__asset")
.first()
)
if source is None:
return Response({"detail": "找不到可复用的商品图片"}, status=status.HTTP_400_BAD_REQUEST)
source_images = list(source.images.all())
by_asset_id = {str(image.asset_id): image.asset for image in source_images}
if requested_asset_ids:
if any(asset_id not in by_asset_id for asset_id in requested_asset_ids):
return Response({"detail": "找不到可复用的商品图片"}, status=status.HTTP_400_BAD_REQUEST)
reused_assets = [by_asset_id[asset_id] for asset_id in requested_asset_ids]
else:
reused_assets = [image.asset for image in source_images]
if not reused_assets and not uploads:
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)
with transaction.atomic():
assets = list(reused_assets)
assets.extend(
_store_uploaded_asset(
team=team,
user=request.user,
upload=upload,
asset_type=Asset.Type.IMAGE,
category=Asset.Category.PRODUCT_IMAGE,
name=upload.name or f"{name}-商品图{index + 1}",
)
for index, upload in enumerate(uploads)
)
if not assets:
return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST)
product = Product.objects.create(
team=team,
created_by=request.user,
title=name,
business_type=Product.BusinessType.ECOMMERCE,
cover_asset=assets[0],
)
ProductImage.objects.bulk_create(
[
ProductImage(
product=product,
asset=asset,
sort_order=index,
is_primary=index == 0,
)
for index, asset in enumerate(assets)
]
)
project = Project.objects.create(
team=team,
created_by=request.user,
product=product,
name=f"{name} · 极速成片",
status=Project.Status.SCRIPTING,
current_stage=ProjectStage.Stage.SCRIPT,
metadata={
"quick_create": True,
"wizard": {
"aspect_ratio": aspect_ratio,
"resolution": resolution,
"total_duration": total_duration,
"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",
},
},
)
initialize_project_pipeline(project, placeholder_segments=max(1, total_duration // 15))
if not product.selling_points.exists():
ProductSellingPoint.objects.create(
product=product,
title=name,
detail="由极速成片根据商品名称自动填写",
sort_order=0,
)
job = QuickCreateJob.objects.create(
team=team,
created_by=request.user,
project=project,
status=QuickCreateJob.Status.QUEUED,
phase=QuickCreateJob.Phase.PRODUCT,
progress=0,
message="等待开始极速成片",
)
try:
advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
except Exception as exc: # noqa: BLE001 — broker 极小窗口失败也必须给任务落终态
from .services.quick_create import fail_quick_create
logger.exception("quick create enqueue failed for job %s", job.id)
fail_quick_create(job, "生成队列暂时不可用,请稍后重试", internal_error=str(exc))
return Response(QuickCreateJobSerializer(job).data, status=status.HTTP_503_SERVICE_UNAVAILABLE)
job = self._quick_job_queryset().get(id=job.id)
return Response(QuickCreateJobSerializer(job).data, status=status.HTTP_202_ACCEPTED)
def _quick_job_queryset(self):
return (
QuickCreateJob.objects.select_related("project__product", "project__timeline")
.prefetch_related(
"project__product__images__asset__files",
"project__script_versions",
"project__base_asset_groups",
"project__storyboard_shots__adopted_version__asset__files",
"project__video_segments__adopted_version__asset__files",
"project__timeline__export_jobs__output_asset__files",
)
.filter(team=self.get_team())
)
@action(detail=False, methods=["get"], url_path=r"quick-create-status/(?P<job_id>[^/.]+)")
def quick_create_status(self, request, job_id=None):
job = self._quick_job_queryset().filter(id=job_id).first()
if job is None:
return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
from .services.quick_create import recover_quick_create
try:
recover_quick_create(job)
except Exception: # noqa: BLE001 — 恢复失败不能把进度接口打成 500
logger.exception("quick create recover failed for job %s", job.id)
job = self._quick_job_queryset().get(id=job.id)
return Response(QuickCreateJobSerializer(job).data)
@action(detail=False, methods=["post"], url_path=r"quick-create-cancel/(?P<job_id>[^/.]+)")
def quick_create_cancel(self, request, job_id=None):
job = self._quick_job_queryset().filter(id=job_id).first()
if job is None:
return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
if job.status == QuickCreateJob.Status.SUCCEEDED:
return Response({"detail": "已完成的任务不能取消"}, status=status.HTTP_400_BAD_REQUEST)
from .services.quick_create import cancel_quick_create
cancel_quick_create(job)
job = self._quick_job_queryset().get(id=job.id)
return Response(QuickCreateJobSerializer(job).data)
@action(detail=False, methods=["get"], url_path="quick-create-history")
def quick_create_history(self, request):
jobs = self._quick_job_queryset().filter(status=QuickCreateJob.Status.SUCCEEDED).order_by("-created_at")
return Response({
"count": jobs.count(),
"results": QuickCreateJobSerializer(jobs[:30], many=True).data,
})
@transaction.atomic
def perform_create(self, serializer):
project = serializer.save(team=self.get_team(), created_by=self.request.user)
self._apply_wizard_template(project)
for stage in STAGE_ORDER:
ProjectStage.objects.create(project=project, stage=stage)
# 先铺 4 段占位;真实段数与每段时长在采用脚本时由 _sync_video_segments_to_script 收口
for index in range(4):
VideoSegment.objects.create(
project=project, sort_order=index, target_duration_seconds=SEGMENT_DURATION_MAX
)
# 先铺 4 段占位;真实段数与每段时长在采用脚本时收口。
initialize_project_pipeline(project, placeholder_segments=4)
def _apply_wizard_template(self, project) -> None:
"""新建向导选了套路模板 → 后端按模板真值回填 metadata.wizard,并计一次使用。
@@ -583,19 +850,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
project = self.get_object()
script_id = request.data.get("script_version_id")
script = ScriptVersion.objects.select_for_update().get(project=project, id=script_id)
ScriptVersion.objects.filter(project=project).update(is_adopted=False)
script.is_adopted = True
script.save(update_fields=["is_adopted", "updated_at"])
# 采用脚本时把视频片段数对齐到这版分镜数:用户常在「采用前」就增删分镜,
# 那些编辑因 _sync 的 is_adopted 闸而未同步到 VideoSegment(项目创建时固定铺了 4 段),
# 不在此收口的话视频步骤会一直停在 4 段,与故事板/分镜数对不上。已生成的段绝不动。
self._sync_video_segments_to_script(project, script)
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
stage.status = ProjectStage.Status.SUCCEEDED
stage.save(update_fields=["status", "updated_at"])
project.current_stage = ProjectStage.Stage.BASE_ASSETS
project.status = Project.Status.ASSETING
project.save(update_fields=["current_stage", "status", "updated_at"])
adopt_script_version(project, script)
return Response(ScriptVersionSerializer(script).data)
@action(detail=True, methods=["post"], url_path="extract-entities")
@@ -900,47 +1155,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
时长同步:脚本镜主流程固定 15 秒,而出片、计价、时间线读的都是
VideoSegment.target_duration_seconds。不同步的话脚本写了 15 秒、出片仍按旧默认跑。
已出片的段不改时长——改了会跟已渲染的成片对不上。"""
if not script.is_adopted:
return
script_segments = list(script.segments.order_by("sort_order"))
target = len(script_segments)
segments = list(project.video_segments.order_by("sort_order"))
while len(segments) > target:
tail = segments[-1]
if tail.status == VideoSegment.Status.NOT_STARTED and not tail.versions.exists():
tail.delete()
segments.pop()
else:
break
next_order = (segments[-1].sort_order + 1) if segments else 0
for _ in range(target - len(segments)):
index = len(segments)
seconds = (
script_segments[index].duration_seconds
if index < target
else SEGMENT_DURATION_MAX
)
segments.append(
VideoSegment.objects.create(
project=project, sort_order=next_order, target_duration_seconds=seconds
)
)
next_order += 1
# 已存在的段:只对「还没出过片」的回填脚本时长,已渲染的保持原样
stale: list[VideoSegment] = []
for index, video_segment in enumerate(segments):
if index >= target:
break
seconds = script_segments[index].duration_seconds
if not seconds or video_segment.target_duration_seconds == seconds:
continue
if video_segment.status == VideoSegment.Status.SUCCEEDED or video_segment.versions.exists():
continue
video_segment.target_duration_seconds = seconds
stale.append(video_segment)
if stale:
VideoSegment.objects.bulk_update(stale, ["target_duration_seconds"])
sync_video_segments_to_script(project, script)
def _sync_storyboard_shots_to_script(self, project: Project, script: ScriptVersion) -> None:
"""采用版分镜数变化时,同步 StoryboardShot 数量(与视频段同策略,按位置对齐):
@@ -1133,16 +1348,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
project = self.get_object()
result = poll_storyboard(project=project, user=request.user)
if result.get("status") == "succeeded":
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
stage.status = ProjectStage.Status.SUCCEEDED
stage.save(update_fields=["status", "updated_at"])
# 进入视频阶段前再收口一次视频片段数,确保与采用版分镜数一致(已生成的段不动)
adopted_script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first()
if adopted_script is not None:
self._sync_video_segments_to_script(project, adopted_script)
project.current_stage = ProjectStage.Stage.VIDEO
project.status = Project.Status.VIDEOING
project.save(update_fields=["current_stage", "status", "updated_at"])
finish_storyboard_stage(project)
http_status = status.HTTP_200_OK if result.get("status") == "succeeded" else status.HTTP_202_ACCEPTED
return Response(result, status=http_status)