后端生成闸+多项修复;前端全站更新;QA 审计与报告
后端: - 新增 celery_health 生成前置闸——无 worker 在线时图片/视频生成入口 一律 503,防"提交到 ARK 后无人轮询、结果悬空+额度冻结"的数据丢失 - 拼接导出:帧率跟随源众数、字幕逐句重映射、原声/BGM 混音修复 - 资金核算审计脚本 + genesis 账目回填 + 滞留预留清理命令 - 接入 yunqi provider 与豆包 TTS 模型(catalog/migrations/bootstrap 命令) 前端:全站页面更新(pipeline/library/products/projects/team/account 等), 新增共享 pager 分页组件 QA:刷新 function-audit 全量输出,新增 full-qa 报告 文档:BP 产品介绍资料、design/CLAUDE.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,10 @@ from rest_framework.parsers import FormParser, MultiPartParser
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from apps.ai.providers import TtsNotConfigured
|
||||
from apps.ai.services import (
|
||||
DEFAULT_VOICEOVER_VOICE,
|
||||
VOICEOVER_VOICES,
|
||||
create_export_job,
|
||||
generate_base_asset,
|
||||
generate_project_script,
|
||||
@@ -17,11 +20,13 @@ from apps.ai.services import (
|
||||
poll_video_segment,
|
||||
submit_storyboard,
|
||||
submit_video_segment,
|
||||
synthesize_project_voiceover,
|
||||
)
|
||||
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 .models import (
|
||||
BaseAssetGroup,
|
||||
@@ -29,6 +34,7 @@ from .models import (
|
||||
ExportJob,
|
||||
Project,
|
||||
ProjectStage,
|
||||
ScriptSegment,
|
||||
ScriptVersion,
|
||||
SubtitleTrack,
|
||||
Timeline,
|
||||
@@ -92,6 +98,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"stages",
|
||||
"video_segments",
|
||||
"video_segments__adopted_version__asset__files",
|
||||
"video_segments__versions__asset__files",
|
||||
"script_versions",
|
||||
"script_versions__segments",
|
||||
"base_asset_groups",
|
||||
@@ -121,6 +128,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
user=request.user,
|
||||
user_prompt=request.data.get("prompt", ""),
|
||||
selling_point_ids=request.data.get("selling_point_ids") or [],
|
||||
source=request.data.get("source") or "ai",
|
||||
)
|
||||
return Response(ScriptVersionSerializer(script).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -143,6 +151,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="generate-base-asset")
|
||||
def generate_base_asset_action(self, request, pk=None):
|
||||
require_worker()
|
||||
project = self.get_object()
|
||||
kind = request.data.get("kind")
|
||||
if kind not in BaseAssetGroup.Kind.values:
|
||||
@@ -167,9 +176,117 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
promote_base_asset_stage_if_ready(project)
|
||||
return Response(BaseAssetGroupSerializer(group).data)
|
||||
|
||||
# ── Stage 1 · 镜头脚本逐字段编辑 / 增删分镜 ──
|
||||
|
||||
def _sync_video_segments_to_script(self, project: Project, script: ScriptVersion) -> None:
|
||||
"""采用版分镜数变化时,同步 VideoSegment 数量:不足则尾部补 NOT_STARTED,
|
||||
多出且尾部是「从未生成过」的段则裁掉(已生成的段绝不动)。"""
|
||||
if not script.is_adopted:
|
||||
return
|
||||
target = script.segments.count()
|
||||
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)):
|
||||
segments.append(VideoSegment.objects.create(project=project, sort_order=next_order, target_duration_seconds=15))
|
||||
next_order += 1
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="update-script-segment")
|
||||
def update_script_segment(self, request, pk=None):
|
||||
project = self.get_object()
|
||||
segment = ScriptSegment.objects.get(id=request.data.get("segment_id"), script_version__project=project)
|
||||
changed = []
|
||||
for field in ("narration", "visual_prompt"):
|
||||
if field in request.data:
|
||||
setattr(segment, field, str(request.data.get(field) or "").strip())
|
||||
changed.append(field)
|
||||
if "duration_seconds" in request.data:
|
||||
try:
|
||||
segment.duration_seconds = max(1, min(60, int(request.data["duration_seconds"])))
|
||||
changed.append("duration_seconds")
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if changed:
|
||||
segment.save(update_fields=[*changed, "updated_at"])
|
||||
return Response(ScriptVersionSerializer(segment.script_version).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="add-script-segment")
|
||||
@transaction.atomic
|
||||
def add_script_segment(self, request, pk=None):
|
||||
project = self.get_object()
|
||||
after = ScriptSegment.objects.select_related("script_version").get(
|
||||
id=request.data.get("after_segment_id"), script_version__project=project
|
||||
)
|
||||
script = after.script_version
|
||||
segments = list(script.segments.order_by("sort_order"))
|
||||
insert_at = next(i for i, s in enumerate(segments) if s.id == after.id) + 1
|
||||
created = ScriptSegment.objects.create(
|
||||
script_version=script,
|
||||
sort_order=insert_at,
|
||||
duration_seconds=int(request.data.get("duration_seconds") or 15),
|
||||
narration=str(request.data.get("narration") or "").strip(),
|
||||
visual_prompt=str(request.data.get("visual_prompt") or "").strip(),
|
||||
)
|
||||
segments.insert(insert_at, created)
|
||||
for index, seg in enumerate(segments):
|
||||
if seg.sort_order != index:
|
||||
seg.sort_order = index
|
||||
seg.save(update_fields=["sort_order", "updated_at"])
|
||||
self._sync_video_segments_to_script(project, script)
|
||||
return Response(ScriptVersionSerializer(script).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="delete-script-segment")
|
||||
@transaction.atomic
|
||||
def delete_script_segment(self, request, pk=None):
|
||||
project = self.get_object()
|
||||
segment = ScriptSegment.objects.select_related("script_version").get(
|
||||
id=request.data.get("segment_id"), script_version__project=project
|
||||
)
|
||||
script = segment.script_version
|
||||
if script.segments.count() <= 1:
|
||||
return Response({"detail": "至少保留一个分镜"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
segment.delete()
|
||||
for index, seg in enumerate(script.segments.order_by("sort_order")):
|
||||
if seg.sort_order != index:
|
||||
seg.sort_order = index
|
||||
seg.save(update_fields=["sort_order", "updated_at"])
|
||||
self._sync_video_segments_to_script(project, script)
|
||||
return Response(ScriptVersionSerializer(script).data)
|
||||
|
||||
# ── Stage 4 · 视频版本采用(详情弹窗里切历史版) ──
|
||||
@action(detail=True, methods=["post"], url_path="adopt-video-version")
|
||||
@transaction.atomic
|
||||
def adopt_video_version(self, request, pk=None):
|
||||
project = self.get_object()
|
||||
segment = VideoSegment.objects.get(project=project, id=request.data.get("video_segment_id"))
|
||||
version = VideoSegmentVersion.objects.get(video_segment=segment, id=request.data.get("version_id"))
|
||||
segment.versions.update(is_adopted=False)
|
||||
version.is_adopted = True
|
||||
version.save(update_fields=["is_adopted", "updated_at"])
|
||||
segment.adopted_version = version
|
||||
segment.status = VideoSegment.Status.SUCCEEDED
|
||||
segment.error_message = ""
|
||||
segment.save(update_fields=["adopted_version", "status", "error_message", "updated_at"])
|
||||
# 时间线草稿(剪辑台/拼接导出都读它)若还引用本场旧版本的资产,必须跟随切换,
|
||||
# 否则采用新版本后预览和导出仍是旧片段;裁剪点是对旧素材设的,一并复位
|
||||
timeline = Timeline.objects.filter(project=project).first()
|
||||
if timeline is not None and version.asset_id:
|
||||
segment_asset_ids = list(segment.versions.values_list("asset_id", flat=True))
|
||||
timeline.clips.filter(asset_id__in=segment_asset_ids).exclude(asset_id=version.asset_id).update(
|
||||
asset_id=version.asset_id, trim_start_ms=0, trim_end_ms=None
|
||||
)
|
||||
return Response(ProjectSerializer(project).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="generate-storyboard")
|
||||
def generate_storyboard_action(self, request, pk=None):
|
||||
"""异步故事板·提交:快速创建版本(不在此生图、不推进阶段)。前端随后轮询 poll-storyboard 逐帧生成。"""
|
||||
require_worker()
|
||||
project = self.get_object()
|
||||
storyboard = submit_storyboard(project=project, user=request.user, prompt=request.data.get("prompt", ""))
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
|
||||
@@ -206,15 +323,17 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="submit-video-segment")
|
||||
def submit_video_segment_action(self, request, pk=None):
|
||||
# 前置闸:无 worker 禁止提交——提交到 ARK 后若无人轮询,结果悬在云端、预扣额度冻结。
|
||||
require_worker()
|
||||
project = self.get_object()
|
||||
segment = VideoSegment.objects.get(project=project, id=request.data.get("video_segment_id"))
|
||||
submit_video_segment(video_segment=segment, user=request.user, prompt=request.data.get("prompt", ""))
|
||||
# 有 Celery worker 时由它自动轮询;无 worker(本机 dev)则前端驱动 poll-video-segment。
|
||||
# 队列不可用不应让提交 500——已提交到 ARK,轮询是次要路径。
|
||||
# worker 在线由上面的闸保证;此处入队失败只剩极小窗口(刚提交完 broker 闪断),
|
||||
# 任务已在 ARK,前端轮询仍可兜底取回,故不让提交 500。
|
||||
try:
|
||||
poll_video_segment_task.apply_async(args=[str(segment.id)], countdown=30)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.warning("poll_video_segment_task enqueue failed; relying on client polling", exc_info=True)
|
||||
logger.error("poll_video_segment_task enqueue failed; relying on client polling", exc_info=True)
|
||||
return Response(ProjectSerializer(project).data, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="poll-video-segment")
|
||||
@@ -345,6 +464,24 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
BgmTrack.objects.create(timeline=timeline, asset=asset, volume=max(0, min(100, volume)), start_ms=0)
|
||||
return Response(ProjectSerializer(self.get_object()).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
# ── Stage 5 · 旁白配音(TTS):每镜旁白合成语音资产,导出时作为人声轨混在 BGM 之上 ──
|
||||
@action(detail=True, methods=["get", "post"], url_path="generate-voiceover")
|
||||
def generate_voiceover_action(self, request, pk=None):
|
||||
project = self.get_object()
|
||||
if request.method == "GET":
|
||||
return Response({"voices": VOICEOVER_VOICES, "default_voice": DEFAULT_VOICEOVER_VOICE})
|
||||
try:
|
||||
voiceover = synthesize_project_voiceover(
|
||||
project=project,
|
||||
user=request.user,
|
||||
items=request.data.get("items") or [],
|
||||
voice_type=request.data.get("voice_type") or DEFAULT_VOICEOVER_VOICE,
|
||||
speed_ratio=float(request.data.get("speed_ratio") or 1.0),
|
||||
)
|
||||
except (TtsNotConfigured, ValueError) as exc:
|
||||
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
|
||||
return Response({"voiceover": voiceover}, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=["post", "put"], url_path="save-timeline")
|
||||
@transaction.atomic
|
||||
def save_timeline_action(self, request, pk=None):
|
||||
@@ -402,6 +539,27 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
metadata["transition"] = {"type": str(data["transition"].get("type", "none"))}
|
||||
if isinstance(data.get("draft"), dict):
|
||||
metadata["draft"] = data["draft"]
|
||||
if isinstance(data.get("voiceover"), dict):
|
||||
vo_patch = data["voiceover"]
|
||||
existing = metadata.get("voiceover")
|
||||
if vo_patch.get("clear"):
|
||||
metadata.pop("voiceover", None)
|
||||
elif isinstance(existing, dict):
|
||||
if vo_patch.get("enabled") is not None:
|
||||
existing["enabled"] = bool(vo_patch["enabled"])
|
||||
# 拖动字幕块后回写每句语音的句内起点(按 asset id 对位,只动 offset_ms)
|
||||
if isinstance(vo_patch.get("items"), list):
|
||||
offsets = {}
|
||||
for it in vo_patch["items"]:
|
||||
if isinstance(it, dict) and it.get("asset") is not None and it.get("offset_ms") is not None:
|
||||
try:
|
||||
offsets[str(it["asset"])] = max(0, int(it["offset_ms"]))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
for it in existing.get("items") or []:
|
||||
if str(it.get("asset")) in offsets:
|
||||
it["offset_ms"] = offsets[str(it.get("asset"))]
|
||||
metadata["voiceover"] = existing
|
||||
timeline.metadata = metadata
|
||||
timeline.save(update_fields=["metadata", "duration_seconds", "updated_at"])
|
||||
return Response(ProjectSerializer(self.get_object()).data)
|
||||
|
||||
Reference in New Issue
Block a user