完善二期清单
This commit is contained in:
@@ -3,11 +3,12 @@ from pathlib import Path
|
||||
import uuid
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models import Count, F, Prefetch, Q
|
||||
from django.http import HttpResponse, JsonResponse, StreamingHttpResponse
|
||||
from django.utils import timezone
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.renderers import BaseRenderer
|
||||
from rest_framework.response import Response
|
||||
@@ -18,7 +19,7 @@ from apps.ai.providers import TtsNotConfigured
|
||||
from apps.ai.script_agent import (
|
||||
SEGMENT_DURATION_MAX,
|
||||
SEGMENT_DURATION_MIN,
|
||||
coerce_combo,
|
||||
combo_keys,
|
||||
coerce_total_duration,
|
||||
stream_script_agent,
|
||||
)
|
||||
@@ -54,6 +55,7 @@ from .models import (
|
||||
Project,
|
||||
ProjectStage,
|
||||
ScriptSegment,
|
||||
ScriptTemplate,
|
||||
ScriptVersion,
|
||||
StoryboardFrame,
|
||||
StoryboardShot,
|
||||
@@ -70,13 +72,16 @@ from .serializers import (
|
||||
ExportJobSerializer,
|
||||
ProjectListSerializer,
|
||||
ProjectSerializer,
|
||||
ScriptTemplateSerializer,
|
||||
ScriptVersionSerializer,
|
||||
StoryboardVersionSerializer,
|
||||
VideoSegmentVersionSerializer,
|
||||
is_playable_video,
|
||||
)
|
||||
from .services.export import run_export_job_in_thread
|
||||
from .services.pipeline import STAGE_ORDER
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -315,8 +320,18 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
# ——原列表把每个项目的 阶段/片段/故事板/时间线/资产文件全拉出,20 个项目实测 ~2s。
|
||||
if self.action == "list":
|
||||
qs = (
|
||||
Project.objects.select_related("product", "product__cover_asset")
|
||||
.prefetch_related("product__cover_asset__files")
|
||||
Project.objects.select_related("product", "product__cover_asset", "timeline")
|
||||
.prefetch_related(
|
||||
"product__cover_asset__files",
|
||||
# 成片地址(final_video_url)只需要「成功的导出任务」,预取到位后列表不再逐项目查库
|
||||
Prefetch(
|
||||
"timeline__export_jobs",
|
||||
queryset=ExportJob.objects.filter(status=ExportJob.Status.SUCCEEDED)
|
||||
.select_related("output_asset")
|
||||
.prefetch_related("output_asset__files")
|
||||
.order_by("-created_at"),
|
||||
),
|
||||
)
|
||||
.annotate(
|
||||
script_version_count=Count("script_versions", distinct=True),
|
||||
video_segment_count=Count("video_segments", distinct=True),
|
||||
@@ -423,6 +438,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
@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 收口
|
||||
@@ -431,6 +447,74 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
project=project, sort_order=index, target_duration_seconds=SEGMENT_DURATION_MAX
|
||||
)
|
||||
|
||||
def _apply_wizard_template(self, project) -> None:
|
||||
"""新建向导选了套路模板 → 后端按模板真值回填 metadata.wizard,并计一次使用。
|
||||
前端只传 template_id,套路参数一律以库里的模板为准,避免前后端两份真相。"""
|
||||
wizard = (project.metadata or {}).get("wizard") or {}
|
||||
template_id = wizard.get("template_id")
|
||||
if not template_id:
|
||||
return
|
||||
template = ScriptTemplate.objects.filter(id=template_id, team=project.team).first()
|
||||
if template is None:
|
||||
# 模板被删/跨团队 → 静默丢弃这个字段,项目照常建,不阻断创建
|
||||
wizard.pop("template_id", None)
|
||||
project.metadata = {**(project.metadata or {}), "wizard": wizard}
|
||||
project.save(update_fields=["metadata", "updated_at"])
|
||||
return
|
||||
wizard.update(
|
||||
{
|
||||
"template_id": str(template.id),
|
||||
"template_name": template.name,
|
||||
"template_outline": render_outline_text({"outline": template.outline, "cta": template.cta}),
|
||||
}
|
||||
)
|
||||
# 套路参数只在模板真有值时覆盖,空值不要把设定卡的智能推荐顶掉。
|
||||
# 旧模板可能存了中文标签(「短剧」),这里归一成 key 再写入 wizard。
|
||||
raw_format = getattr(template, "presentation_format", "") or ""
|
||||
raw_structure = getattr(template, "video_structure", "") or ""
|
||||
if raw_format or raw_structure:
|
||||
fmt, structure = coerce_template_combo(raw_format, raw_structure)
|
||||
wizard["presentation_format"] = fmt
|
||||
wizard["video_structure"] = structure
|
||||
persona = coerce_persona(getattr(template, "persona", "") or "")
|
||||
if persona:
|
||||
wizard["persona"] = persona
|
||||
if template.total_duration:
|
||||
wizard["total_duration"] = template.total_duration
|
||||
project.metadata = {**(project.metadata or {}), "wizard": wizard}
|
||||
project.save(update_fields=["metadata", "updated_at"])
|
||||
ScriptTemplate.objects.filter(id=template.id).update(usage_count=F("usage_count") + 1)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="save-as-template")
|
||||
def save_as_template(self, request, pk=None):
|
||||
"""把当前脚本存成团队套路模板(5.1)。只抽套路不抽文案,见 services/templates.py。"""
|
||||
project = self.get_object()
|
||||
name = str(request.data.get("name") or "").strip()
|
||||
if len(name) < 2:
|
||||
return Response({"detail": "模板名至少 2 个字"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
script_id = request.data.get("script_version_id")
|
||||
scripts = ScriptVersion.objects.filter(project=project).prefetch_related("segments")
|
||||
script = (
|
||||
scripts.filter(id=script_id).first()
|
||||
if script_id
|
||||
else (scripts.filter(is_adopted=True).order_by("-created_at").first() or scripts.order_by("-created_at").first())
|
||||
)
|
||||
if script is None:
|
||||
return Response({"detail": "这个项目还没有脚本,先生成一版再存模板"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not script.segments.exists():
|
||||
return Response({"detail": "这版脚本没有分镜,存不出套路"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if ScriptTemplate.objects.filter(team=project.team, name=name).exists():
|
||||
return Response({"detail": "模板名已存在,换一个"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
template = ScriptTemplate.objects.create(
|
||||
team=project.team,
|
||||
created_by=request.user,
|
||||
name=name,
|
||||
source_project=project,
|
||||
source_script=script,
|
||||
**build_template_fields(project=project, script=script),
|
||||
)
|
||||
return Response(ScriptTemplateSerializer(template).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="script-agent-stream", renderer_classes=[ServerSentEventRenderer])
|
||||
def script_agent_stream(self, request, pk=None):
|
||||
"""对话式脚本 agent · 流式(SSE)。出稿 + 改稿一体,多模型可选。
|
||||
@@ -446,7 +530,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
aspect_ratio = str(request.data.get("aspect_ratio") or "9:16")
|
||||
# 非法值一律由 agent 侧 coerce 兜底(夹区间/回落默认),这里不做 400,避免生成被参数噪声打断
|
||||
total_duration = coerce_total_duration(request.data.get("total_duration"))
|
||||
presentation_format, video_structure = coerce_combo(
|
||||
presentation_format, video_structure = combo_keys(
|
||||
request.data.get("presentation_format"),
|
||||
request.data.get("video_structure"),
|
||||
)
|
||||
@@ -1135,6 +1219,8 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
@action(detail=True, methods=["post"], url_path="submit-export")
|
||||
@transaction.atomic
|
||||
def submit_export(self, request, pk=None):
|
||||
"""把已出片的各场视频用 ffmpeg 拼成一条成片。HTTP 秒回,拼接在后台线程跑,
|
||||
前端轮询 poll-export 取进度 / 成片地址。"""
|
||||
project = self.get_object()
|
||||
missing_segments = project.video_segments.filter(adopted_version__isnull=True).count()
|
||||
if missing_segments:
|
||||
@@ -1146,6 +1232,27 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
project=project,
|
||||
defaults={"name": f"{project.name} Timeline", "duration_seconds": 60},
|
||||
)
|
||||
# 清理僵尸任务:后台线程跑导出时若 Pod 被重启/OOM 杀掉,旧任务会永远停在 RUNNING、
|
||||
# 前端无限轮询。新建前把本时间线超过 5 分钟仍未结束的旧任务标失败,避免界面一直转圈。
|
||||
from datetime import timedelta
|
||||
|
||||
ExportJob.objects.filter(
|
||||
timeline=timeline,
|
||||
status__in=[ExportJob.Status.QUEUED, ExportJob.Status.RUNNING],
|
||||
updated_at__lt=timezone.now() - timedelta(minutes=5),
|
||||
).update(status=ExportJob.Status.FAILED, error_message="任务中断(服务重启或超时),已自动失败,请重新导出")
|
||||
# 防重复合成:仍在跑的任务直接复用,不再新建 —— 刷新页面 / 连点两次 / 开两个标签页
|
||||
# 都只会有一条 ffmpeg 在跑(拼接吃 CPU,跑两遍纯属浪费,且两条任务会互相覆盖成片)。
|
||||
inflight = ExportJob.objects.filter(
|
||||
timeline=timeline,
|
||||
status__in=[ExportJob.Status.QUEUED, ExportJob.Status.RUNNING],
|
||||
).order_by("-created_at").first()
|
||||
if inflight is not None:
|
||||
return Response(ExportJobSerializer(inflight).data, status=status.HTTP_202_ACCEPTED)
|
||||
if VIDEO_IS_FINAL_STAGE:
|
||||
# V1 没有剪辑台(第 5 阶段雪藏),时间线纯由后端按「当前采用的片段」生成。
|
||||
# 每次合成都重建:某场重跑/换采用版之后再合成,成片必须跟着换,不能沿用上次的旧片段。
|
||||
timeline.clips.all().delete()
|
||||
if not timeline.clips.exists():
|
||||
start_ms = 0
|
||||
for segment in project.video_segments.select_related("adopted_version__asset").order_by("sort_order"):
|
||||
@@ -1158,22 +1265,17 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
duration_ms=segment.target_duration_seconds * 1000,
|
||||
)
|
||||
start_ms += segment.target_duration_seconds * 1000
|
||||
# 清理僵尸任务:后台线程跑导出时若 Pod 被重启/OOM 杀掉,旧任务会永远停在 RUNNING、
|
||||
# 前端无限轮询。新建前把本时间线超过 5 分钟仍未结束的旧任务标失败,避免界面一直转圈。
|
||||
from django.utils import timezone
|
||||
from datetime import timedelta
|
||||
ExportJob.objects.filter(
|
||||
timeline=timeline,
|
||||
status__in=[ExportJob.Status.QUEUED, ExportJob.Status.RUNNING],
|
||||
updated_at__lt=timezone.now() - timedelta(minutes=5),
|
||||
).update(status=ExportJob.Status.FAILED, error_message="任务中断(服务重启或超时),已自动失败,请重新导出")
|
||||
export_job = create_export_job(timeline=timeline, user=request.user)
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.EXPORT)
|
||||
stage.status = ProjectStage.Status.RUNNING
|
||||
stage.save(update_fields=["status", "updated_at"])
|
||||
project.current_stage = ProjectStage.Stage.EXPORT
|
||||
project.status = Project.Status.EXPORTING
|
||||
project.save(update_fields=["current_stage", "status", "updated_at"])
|
||||
if not VIDEO_IS_FINAL_STAGE:
|
||||
# V1 视频是末阶段:一旦把 current_stage 推到 export,流水线页会落到被雪藏的第 5 阶段
|
||||
# (前端 STAGE_STEPS 只有 4 格)→ 用户点完合成就掉进一个看不见的页面。故 V1 留在视频阶段,
|
||||
# 合成进度由 poll-export 内联展示。V2 恢复剪辑台后此处照旧推进。
|
||||
project.current_stage = ProjectStage.Stage.EXPORT
|
||||
project.status = Project.Status.EXPORTING
|
||||
project.save(update_fields=["current_stage", "status", "updated_at"])
|
||||
# 后台线程跑真实 ffmpeg 拼接(无需 Celery worker);前端轮询 poll-export 取进度/成片。
|
||||
transaction.on_commit(lambda: run_export_job_in_thread(str(export_job.id)))
|
||||
return Response(ExportJobSerializer(export_job).data, status=status.HTTP_202_ACCEPTED)
|
||||
@@ -1189,7 +1291,9 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
|
||||
output_url = ""
|
||||
output = export_job.output_asset
|
||||
if output is not None:
|
||||
# 必须是真能播的视频文件才下发:演示种子数据里有「导出标成功、挂的却是张 PNG 海报」的成片,
|
||||
# 直接下发会让「播放成片」打开一个放不出来的图(与 serializers._final_video_url 同一把闸)。
|
||||
if output is not None and is_playable_video(output):
|
||||
primary = output.files.filter(is_primary=True).first() or output.files.first()
|
||||
if primary is not None:
|
||||
output_url = AssetFileSerializer().get_preview_url(primary)
|
||||
@@ -1412,3 +1516,19 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
timeline.metadata = metadata
|
||||
timeline.save(update_fields=["metadata", "duration_seconds", "updated_at"])
|
||||
return Response(ProjectSerializer(self.get_object()).data)
|
||||
|
||||
|
||||
class ScriptTemplateViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"""套路模板库(团队隔离)。建模板走 projects/{id}/save-as-template/,这里只做 列 / 改名 / 删。"""
|
||||
|
||||
queryset = ScriptTemplate.objects.select_related("source_project").all()
|
||||
serializer_class = ScriptTemplateSerializer
|
||||
http_method_names = ["get", "patch", "delete", "head", "options"]
|
||||
|
||||
def perform_update(self, serializer):
|
||||
name = str(serializer.validated_data.get("name") or "").strip()
|
||||
if len(name) < 2:
|
||||
raise ValidationError({"detail": "模板名至少 2 个字"})
|
||||
if ScriptTemplate.objects.filter(team=self.get_team(), name=name).exclude(id=serializer.instance.id).exists():
|
||||
raise ValidationError({"detail": "模板名已存在,换一个"})
|
||||
serializer.save(name=name)
|
||||
|
||||
Reference in New Issue
Block a user