大量修改二期功能清单内容

This commit is contained in:
Azmat@qq.com
2026-08-17 18:26:42 +08:00
parent 36e91aab3c
commit d1ecb52125
76 changed files with 4222 additions and 294 deletions
+99 -13
View File
@@ -15,7 +15,13 @@ from rest_framework.viewsets import ModelViewSet
from apps.ai.models import AITask, ModelConfig
from apps.ai.providers import TtsNotConfigured
from apps.ai.script_agent import stream_script_agent
from apps.ai.script_agent import (
SEGMENT_DURATION_MAX,
SEGMENT_DURATION_MIN,
coerce_combo,
coerce_total_duration,
stream_script_agent,
)
from apps.ai.services import (
DEFAULT_VOICEOVER_VOICE,
VOICEOVER_VOICES,
@@ -39,6 +45,7 @@ from apps.assets.storage import TosStorage
from apps.common.api import TeamScopedViewSetMixin
from apps.common.celery_health import require_worker
from apps.ai.generation_errors import classify_generation_error, public_error_for_task
from apps.ai.video_digest import VideoDigestError, digest_project_video
from .models import (
BaseAssetGroup,
@@ -69,6 +76,7 @@ from .serializers import (
)
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 .tasks import poll_video_segment_task
logger = logging.getLogger(__name__)
@@ -417,14 +425,18 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
project = serializer.save(team=self.get_team(), created_by=self.request.user)
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=15)
VideoSegment.objects.create(
project=project, sort_order=index, target_duration_seconds=SEGMENT_DURATION_MAX
)
@action(detail=True, methods=["post"], url_path="script-agent-stream", renderer_classes=[ServerSentEventRenderer])
def script_agent_stream(self, request, pk=None):
"""对话式脚本 agent · 流式(SSE)。出稿 + 改稿一体,多模型可选。
请求体:mode(auto|theme|revise)、prompt、model_config_id、selling_point_ids、
base_version_id(改稿)、aspect_ratio、total_duration
base_version_id(改稿)、aspect_ratio、total_duration(5-60,5 秒步进)、
presentation_format(oral|drama|vlog)、video_structure(pain|contrast|review|scene)。
响应:text/event-stream,逐帧吐 tool/delta/draft/saved/done/error。"""
project = self.get_object()
mode = str(request.data.get("mode") or "auto")
@@ -432,10 +444,12 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
selling_point_ids = request.data.get("selling_point_ids") or []
base_version_id = request.data.get("base_version_id") or None
aspect_ratio = str(request.data.get("aspect_ratio") or "9:16")
try:
total_duration = int(request.data.get("total_duration") or 60)
except (TypeError, ValueError):
total_duration = 60
# 非法值一律由 agent 侧 coerce 兜底(夹区间/回落默认),这里不做 400,避免生成被参数噪声打断
total_duration = coerce_total_duration(request.data.get("total_duration"))
presentation_format, video_structure = coerce_combo(
request.data.get("presentation_format"),
request.data.get("video_structure"),
)
target_index = request.data.get("target_index")
try:
target_index = int(target_index) if target_index is not None else None
@@ -466,7 +480,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
base_version_id=base_version_id,
aspect_ratio=aspect_ratio,
total_duration=total_duration,
presentation_format=presentation_format,
video_structure=video_structure,
target_index=target_index,
entry_source=str(request.data.get("source") or ""),
)
response = StreamingHttpResponse(stream, content_type="text/event-stream")
response["Cache-Control"] = "no-cache"
@@ -790,11 +807,16 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
# ── Stage 1 · 镜头脚本逐字段编辑 / 增删分镜 ──
def _sync_video_segments_to_script(self, project: Project, script: ScriptVersion) -> None:
"""采用版分镜数变化时,同步 VideoSegment 数量:不足则尾部补 NOT_STARTED,
多出且尾部是「从未生成过」的段则裁掉(已生成的段绝不动)。"""
"""采用版分镜数变化时,同步 VideoSegment 的**数量和时长**:不足则尾部补 NOT_STARTED,
多出且尾部是「从未生成过」的段则裁掉(已生成的段绝不动)。
时长同步是二期补的:脚本镜可以不等长了,而出片、计价、时间线读的都是
VideoSegment.target_duration_seconds。不同步的话脚本写了 8 秒、出片仍按 15 秒跑。
已出片的段不改时长——改了会跟已渲染的成片对不上。"""
if not script.is_adopted:
return
target = script.segments.count()
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]
@@ -805,9 +827,34 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
break
next_order = (segments[-1].sort_order + 1) if segments else 0
for _ in range(target - len(segments)):
segments.append(VideoSegment.objects.create(project=project, sort_order=next_order, target_duration_seconds=15))
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"])
def _sync_storyboard_shots_to_script(self, project: Project, script: ScriptVersion) -> None:
"""采用版分镜数变化时,同步 StoryboardShot 数量(与视频段同策略,按位置对齐):
多出且尾部「从未出过图」的 shot 裁掉(已出图的不动);不主动建——出图时 ensure_storyboard_shots 按需补。"""
@@ -834,7 +881,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
changed.append(field)
if "duration_seconds" in request.data:
try:
segment.duration_seconds = max(1, min(60, int(request.data["duration_seconds"])))
# 单镜必须落在 4–15 秒:出片模型的硬上限,越界下游直接拒片
segment.duration_seconds = max(
SEGMENT_DURATION_MIN, min(SEGMENT_DURATION_MAX, int(request.data["duration_seconds"]))
)
changed.append("duration_seconds")
except (TypeError, ValueError):
pass
@@ -877,7 +927,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
created = ScriptSegment.objects.create(
script_version=script,
sort_order=insert_at,
duration_seconds=int(request.data.get("duration_seconds") or 15),
duration_seconds=max(
SEGMENT_DURATION_MIN,
min(SEGMENT_DURATION_MAX, int(request.data.get("duration_seconds") or SEGMENT_DURATION_MAX)),
),
narration=str(request.data.get("narration") or "").strip(),
visual_prompt=str(request.data.get("visual_prompt") or "").strip(),
)
@@ -1187,6 +1240,39 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
segment.save(update_fields=["adopted_version", "status", "error_message", "updated_at"])
return Response(ProjectSerializer(project).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"], url_path="extract-script-file", parser_classes=[MultiPartParser, FormParser])
def extract_script_file_action(self, request, pk=None):
"""上传脚本取正文:docx / txt → 纯文本。只读不落库,交前端填进对话框再走脚本 agent。"""
self.get_object() # 走一遍团队权限校验
upload = request.data.get("file")
if upload is None:
return Response({"detail": "no file uploaded"}, status=status.HTTP_400_BAD_REQUEST)
try:
name, text = extract_script_text(upload)
except ScriptFileError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
return Response({"name": name, "chars": len(text), "text": text})
@action(detail=True, methods=["post"], url_path="extract-script-video", parser_classes=[MultiPartParser, FormParser])
def extract_script_video_action(self, request, pk=None):
"""上传视频提炼:参考视频 → 中文分镜稿。
与 extract-script-file 同形状(只读不落脚本),交前端填进对话框让用户逐镜改,
改完再走脚本 agent 出结构化稿。差别是这条要真调模型,故按一次文本任务计费。
"""
project = self.get_object()
upload = request.data.get("file")
if upload is None:
return Response({"detail": "no file uploaded"}, status=status.HTTP_400_BAD_REQUEST)
try:
result = digest_project_video(project=project, user=request.user, upload=upload)
except VideoDigestError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except Exception as exc: # noqa: BLE001 — 模型/网络失败:走统一安全文案,不回传原始异常
public_error = classify_generation_error(exc, operation="video_digest")
return Response(public_error, status=status.HTTP_502_BAD_GATEWAY)
return Response({"name": getattr(upload, "name", "") or "参考视频", **result})
@action(detail=True, methods=["post"], url_path="upload-bgm", parser_classes=[MultiPartParser, FormParser])
@transaction.atomic
def upload_bgm_action(self, request, pk=None):