大量修改二期功能清单内容
This commit is contained in:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user