完成极速成品和脚本优化
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user