大量修改二期功能清单内容
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
"""上传脚本取正文 —— 只认 docx / txt。
|
||||
|
||||
docx 用标准库拆(zip + xml),不引 python-docx:镜像少一个依赖,也避免它对损坏文件抛一堆内部异常。
|
||||
段落 = <w:p>,文字 = 其中所有 <w:t> 拼接;<w:br>/<w:tab> 补空白,否则整段会粘成一坨。
|
||||
"""
|
||||
|
||||
import re
|
||||
import zipfile
|
||||
from io import BytesIO
|
||||
from xml.etree import ElementTree
|
||||
|
||||
W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
|
||||
MAX_UPLOAD_BYTES = 5 * 1024 * 1024 # 脚本是纯文字,5MB 足够;更大的基本是误传
|
||||
|
||||
|
||||
class ScriptFileError(ValueError):
|
||||
"""取正文失败,message 直接给用户看(中文)。"""
|
||||
|
||||
|
||||
def _docx_text(raw: bytes) -> str:
|
||||
try:
|
||||
with zipfile.ZipFile(BytesIO(raw)) as bundle:
|
||||
document = bundle.read("word/document.xml")
|
||||
except KeyError:
|
||||
raise ScriptFileError("这个 docx 里没有正文,请确认文件没有损坏")
|
||||
except zipfile.BadZipFile:
|
||||
raise ScriptFileError("这个文件不是有效的 docx,请另存为 .docx 后重试")
|
||||
|
||||
try:
|
||||
root = ElementTree.fromstring(document)
|
||||
except ElementTree.ParseError:
|
||||
raise ScriptFileError("docx 正文解析失败,请另存一份后重试")
|
||||
|
||||
paragraphs = []
|
||||
for node in root.iter(f"{{{W_NS}}}p"):
|
||||
pieces = []
|
||||
for child in node.iter():
|
||||
tag = child.tag
|
||||
if tag == f"{{{W_NS}}}t":
|
||||
pieces.append(child.text or "")
|
||||
elif tag in (f"{{{W_NS}}}br", f"{{{W_NS}}}cr"):
|
||||
pieces.append("\n")
|
||||
elif tag == f"{{{W_NS}}}tab":
|
||||
pieces.append("\t")
|
||||
line = "".join(pieces).strip()
|
||||
if line:
|
||||
paragraphs.append(line)
|
||||
return "\n".join(paragraphs)
|
||||
|
||||
|
||||
def _txt_text(raw: bytes) -> str:
|
||||
# 中文 txt 常见三种落盘编码;utf-8 失败再退 GBK 家族,最后一步忽略坏字节保底出文本。
|
||||
for encoding in ("utf-8-sig", "utf-8", "gb18030"):
|
||||
try:
|
||||
return raw.decode(encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return raw.decode("utf-8", errors="ignore")
|
||||
|
||||
|
||||
def extract_script_text(upload) -> tuple[str, str]:
|
||||
"""(文件名, 正文)。只收 docx / txt,其余一律拒绝。"""
|
||||
name = getattr(upload, "name", "") or "script"
|
||||
lowered = name.lower()
|
||||
if not lowered.endswith((".docx", ".txt")):
|
||||
raise ScriptFileError("只支持 docx 和 txt 两种脚本文件")
|
||||
|
||||
raw = upload.read()
|
||||
if len(raw) > MAX_UPLOAD_BYTES:
|
||||
raise ScriptFileError("脚本文件不能超过 5MB")
|
||||
|
||||
text = _docx_text(raw) if lowered.endswith(".docx") else _txt_text(raw)
|
||||
# 统一换行 + 压掉连续空行,避免原稿的排版空白撑爆后面的提示词
|
||||
text = re.sub(r"\n{3,}", "\n\n", text.replace("\r\n", "\n").replace("\r", "\n")).strip()
|
||||
if not text:
|
||||
raise ScriptFileError("没能从这个文件里读到文字,请换一份")
|
||||
return name, text
|
||||
@@ -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