后端生成闸+多项修复;前端全站更新;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:
@@ -1,3 +1,5 @@
|
||||
import uuid
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.assets.serializers import AssetFileSerializer
|
||||
@@ -40,10 +42,11 @@ class ProjectStageSerializer(serializers.ModelSerializer):
|
||||
class VideoSegmentSerializer(serializers.ModelSerializer):
|
||||
adopted_asset = serializers.SerializerMethodField()
|
||||
adopted_asset_url = serializers.SerializerMethodField()
|
||||
versions = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = VideoSegment
|
||||
fields = ["id", "sort_order", "target_duration_seconds", "status", "error_message", "adopted_version", "adopted_asset", "adopted_asset_url"]
|
||||
fields = ["id", "sort_order", "target_duration_seconds", "status", "error_message", "adopted_version", "adopted_asset", "adopted_asset_url", "versions"]
|
||||
read_only_fields = ["id", "sort_order", "target_duration_seconds", "status", "error_message", "adopted_version"]
|
||||
|
||||
def get_adopted_asset(self, obj):
|
||||
@@ -55,6 +58,21 @@ class VideoSegmentSerializer(serializers.ModelSerializer):
|
||||
version = obj.adopted_version
|
||||
return _asset_preview_url(version.asset) if version is not None else ""
|
||||
|
||||
def get_versions(self, obj):
|
||||
# 视频详情弹窗:历史版本(倒序,带可播放 URL)。模型无 Meta.ordering,这里按 created_at 排
|
||||
versions = sorted(obj.versions.all(), key=lambda v: (v.created_at is None, v.created_at), reverse=True)
|
||||
return [
|
||||
{
|
||||
"id": str(v.id),
|
||||
"asset": str(v.asset_id) if v.asset_id else None,
|
||||
"asset_url": _asset_preview_url(v.asset) if v.asset_id else "",
|
||||
"prompt": v.prompt,
|
||||
"is_adopted": v.is_adopted,
|
||||
"created_at": v.created_at.isoformat() if v.created_at else "",
|
||||
}
|
||||
for v in versions
|
||||
]
|
||||
|
||||
|
||||
class BaseAssetGroupSerializer(serializers.ModelSerializer):
|
||||
candidate_assets = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
|
||||
@@ -167,11 +185,37 @@ class TimelineSerializer(serializers.ModelSerializer):
|
||||
export_jobs = TimelineExportJobSerializer(many=True, read_only=True)
|
||||
subtitle_tracks = SubtitleTrackSerializer(many=True, read_only=True)
|
||||
bgm_tracks = BgmTrackSerializer(many=True, read_only=True)
|
||||
voiceover = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Timeline
|
||||
fields = ["id", "name", "aspect_ratio", "resolution", "duration_seconds", "metadata", "clips", "export_jobs", "subtitle_tracks", "bgm_tracks"]
|
||||
read_only_fields = ["id", "clips", "export_jobs", "subtitle_tracks", "bgm_tracks"]
|
||||
fields = ["id", "name", "aspect_ratio", "resolution", "duration_seconds", "metadata", "voiceover", "clips", "export_jobs", "subtitle_tracks", "bgm_tracks"]
|
||||
read_only_fields = ["id", "voiceover", "clips", "export_jobs", "subtitle_tracks", "bgm_tracks"]
|
||||
|
||||
def get_voiceover(self, obj):
|
||||
# 旁白配音映射(metadata.voiceover)+ 每段新鲜的可播放 URL(TOS 签名 URL 会过期,不能存死)
|
||||
vo = (obj.metadata or {}).get("voiceover")
|
||||
if not isinstance(vo, dict) or not vo.get("items"):
|
||||
return None
|
||||
from apps.assets.models import Asset
|
||||
|
||||
ids = []
|
||||
for item in vo["items"]:
|
||||
try:
|
||||
ids.append(uuid.UUID(str(item.get("asset"))))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
assets = {str(a.id): a for a in Asset.objects.filter(id__in=ids).prefetch_related("files")}
|
||||
items = []
|
||||
for item in vo["items"]:
|
||||
asset = assets.get(str(item.get("asset")))
|
||||
items.append({**item, "asset_url": _asset_preview_url(asset)})
|
||||
return {
|
||||
"enabled": bool(vo.get("enabled")),
|
||||
"voice_type": str(vo.get("voice_type") or ""),
|
||||
"speed_ratio": vo.get("speed_ratio", 1.0),
|
||||
"items": items,
|
||||
}
|
||||
|
||||
|
||||
class ExportJobSerializer(serializers.ModelSerializer):
|
||||
|
||||
@@ -139,17 +139,49 @@ def _output_starts(specs: list[dict], xfade: float) -> tuple[list[float], float]
|
||||
_AFMT = "aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo"
|
||||
|
||||
|
||||
def _probe_fps(path: Path) -> float:
|
||||
"""探测视频帧率(avg_frame_rate)。失败返回 0 由调用方兜底。"""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
|
||||
"stream=avg_frame_rate", "-of", "csv=p=0", str(path)],
|
||||
capture_output=True, timeout=60,
|
||||
)
|
||||
raw = proc.stdout.decode("utf-8", "ignore").strip().splitlines()[0] if proc.stdout else ""
|
||||
num, _, den = raw.partition("/")
|
||||
fps = float(num) / float(den or 1)
|
||||
return fps if 1.0 <= fps <= 120.0 else 0.0
|
||||
except Exception: # noqa: BLE001
|
||||
return 0.0
|
||||
|
||||
|
||||
def _pick_output_fps(fps_list: list[float]) -> float:
|
||||
"""输出帧率取各片段帧率的众数(并列取更高)。
|
||||
源 24fps 被硬转 30fps 会按 4:5 不均匀复帧——成片肉眼可见一卡一顿;帧率跟随源才是帧帧对应。"""
|
||||
valid = [round(f, 3) for f in fps_list if f > 0]
|
||||
if not valid:
|
||||
return 30.0
|
||||
counts: dict[float, int] = {}
|
||||
for f in valid:
|
||||
counts[f] = counts.get(f, 0) + 1
|
||||
best = max(counts.items(), key=lambda kv: (kv[1], kv[0]))
|
||||
return best[0]
|
||||
|
||||
|
||||
def _build_export_command(*, n: int, specs: list[dict], starts: list[float], total: float,
|
||||
transition: str, sub_overlays: list[tuple[str, float, float]],
|
||||
bgm_name: str | None, bgm_volume: float,
|
||||
has_audio: list[bool] | None = None) -> list[str]:
|
||||
has_audio: list[bool] | None = None, fps: float = 30.0,
|
||||
voice_overlays: list[tuple[str, float, float]] | None = None) -> list[str]:
|
||||
has_audio = has_audio or [False] * n
|
||||
voice_overlays = voice_overlays or []
|
||||
fps_expr = f"{fps:.6g}"
|
||||
parts: list[str] = []
|
||||
for i, s in enumerate(specs):
|
||||
parts.append(
|
||||
f"[{i}:v]trim=start={s['ts']:.3f}:end={s['te']:.3f},setpts=PTS-STARTPTS,"
|
||||
"scale=1080:1920:force_original_aspect_ratio=decrease,"
|
||||
"pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1,fps=30,format=yuv420p[v" + str(i) + "]"
|
||||
f"pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps_expr},format=yuv420p[v" + str(i) + "]"
|
||||
)
|
||||
xname = XFADE_MAP.get(transition or "none")
|
||||
if xname and n > 1:
|
||||
@@ -175,7 +207,7 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
|
||||
# 音频:片段自带的人声/原声必须保留(有声片段取原音轨,无声片段补等长静音,否则 concat 会缺流);
|
||||
# 若另挂了 BGM,则把 BGM 混到原声之上(amix,normalize=0 不自动衰减原声音量)。
|
||||
# 三种片段全无声且无 BGM 时,保持旧行为=纯视频不带音轨。
|
||||
want_audio = any(has_audio) or bool(bgm_name)
|
||||
want_audio = any(has_audio) or bool(bgm_name) or bool(voice_overlays)
|
||||
audio_label: str | None = None
|
||||
if want_audio:
|
||||
for i, s in enumerate(specs):
|
||||
@@ -191,9 +223,23 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
|
||||
parts.append("".join(f"[a{i}]" for i in range(n)) + f"concat=n={n}:v=0:a=1[avoice0]")
|
||||
parts.append(f"[avoice0]atrim=0:{total:.3f},asetpts=PTS-STARTPTS[avoice]")
|
||||
audio_label = "avoice"
|
||||
# 旁白配音(TTS):每段延迟到所属片段在输出时间轴的起点,裁到片段时长,叠在基础音轨之上
|
||||
if voice_overlays:
|
||||
vo_base = n + (1 if bgm_name else 0) + len(sub_overlays)
|
||||
for j, (_name, vstart, vdur) in enumerate(voice_overlays):
|
||||
delay_ms = max(0, int(round(vstart * 1000)))
|
||||
parts.append(
|
||||
f"[{vo_base + j}:a]atrim=0:{vdur:.3f},asetpts=PTS-STARTPTS,{_AFMT},"
|
||||
f"adelay={delay_ms}|{delay_ms}[vo{j}]"
|
||||
)
|
||||
vo_inputs = "".join(f"[vo{j}]" for j in range(len(voice_overlays)))
|
||||
parts.append(
|
||||
f"[avoice]{vo_inputs}amix=inputs={len(voice_overlays) + 1}:duration=first:dropout_transition=0:normalize=0[anarr]"
|
||||
)
|
||||
audio_label = "anarr"
|
||||
if bgm_name:
|
||||
parts.append(f"[{n}:a]volume={bgm_volume:.3f},atrim=0:{total:.3f},asetpts=PTS-STARTPTS,{_AFMT}[abgm]")
|
||||
parts.append("[avoice][abgm]amix=inputs=2:duration=longest:dropout_transition=0:normalize=0[aout]")
|
||||
parts.append(f"[{audio_label}][abgm]amix=inputs=2:duration=longest:dropout_transition=0:normalize=0[aout]")
|
||||
audio_label = "aout"
|
||||
|
||||
cmd = ["ffmpeg", "-y"]
|
||||
@@ -203,10 +249,12 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
|
||||
cmd += ["-stream_loop", "-1", "-i", bgm_name]
|
||||
for png, _s, _e in sub_overlays:
|
||||
cmd += ["-loop", "1", "-i", png]
|
||||
for name, _vs, _vd in voice_overlays:
|
||||
cmd += ["-i", name]
|
||||
cmd += ["-filter_complex", ";".join(parts), "-map", f"[{vlabel}]"]
|
||||
if audio_label:
|
||||
cmd += ["-map", f"[{audio_label}]"]
|
||||
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", "30", "-preset", "veryfast"]
|
||||
cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", fps_expr, "-preset", "veryfast"]
|
||||
if audio_label:
|
||||
cmd += ["-c:a", "aac", "-b:a", "192k"]
|
||||
cmd += ["-t", f"{total:.3f}", "-movflags", "+faststart", "output.mp4"]
|
||||
@@ -232,23 +280,113 @@ def run_export_job_in_thread(export_job_id: str) -> None:
|
||||
threading.Thread(target=_worker, daemon=True).start()
|
||||
|
||||
|
||||
def _split_subtitle_text(text: str) -> list[str]:
|
||||
"""整段旁白 → 短句列表(与前端 splitSubtitleCues 同规则):
|
||||
硬标点(。!?;…)必切;长句(≥12 字)在逗号处再切;过短碎句(<5 字)并入前句;去尾部逗号句号。"""
|
||||
clean = " ".join(str(text or "").split())
|
||||
if not clean:
|
||||
return []
|
||||
hard = "\u3002\uff01\uff1f!?\uff1b;\u2026" # 。!?!?;;… 全角+半角
|
||||
soft = "\uff0c,\u3001" # ,,、
|
||||
parts: list[str] = []
|
||||
cur = ""
|
||||
for ch in clean:
|
||||
cur += ch
|
||||
if ch in hard or (ch in soft and len(cur) >= 12):
|
||||
parts.append(cur)
|
||||
cur = ""
|
||||
if cur.strip():
|
||||
parts.append(cur)
|
||||
merged: list[str] = []
|
||||
for raw in parts:
|
||||
p = raw.strip()
|
||||
if not p:
|
||||
continue
|
||||
core = sum(1 for c in p if c not in hard and c not in soft)
|
||||
if merged and core < 5:
|
||||
merged[-1] += p
|
||||
else:
|
||||
merged.append(p)
|
||||
out: list[str] = []
|
||||
for p in merged:
|
||||
p = p.rstrip("\uff0c\u3002\uff1b,;\u3001")
|
||||
if p:
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def _subtitle_cues(timeline, project, specs, starts, total) -> list[tuple[float, float, str]]:
|
||||
"""字幕条目:文本取 SubtitleTrack.content,空则回退脚本旁白;时间按输出布局(对 xfade 也对齐)。"""
|
||||
"""字幕条目(逐句):优先用 SubtitleTrack.content 里每条 cue 自带的 start_ms——
|
||||
先定位到所属片段(输入时间轴=各片段时长累计),再重映射到输出时间轴(xfade 会压缩起点);
|
||||
无保存字幕时回退脚本旁白,逐段按句切分铺满片段时长。旧行为是整段旁白糊满 15s(切割错误)。"""
|
||||
track = timeline.subtitle_tracks.filter(enabled=True).first() or timeline.subtitle_tracks.first()
|
||||
if track is None or track.enabled is False:
|
||||
return []
|
||||
texts: list[str] = [str((c or {}).get("text", "")) for c in (track.content or [])]
|
||||
if not any(t.strip() for t in texts):
|
||||
script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
|
||||
if script is not None:
|
||||
texts = [seg.narration for seg in script.segments.all().order_by("sort_order")]
|
||||
in_starts: list[float] = []
|
||||
acc = 0.0
|
||||
for s in specs:
|
||||
in_starts.append(acc)
|
||||
acc += s["dur"]
|
||||
|
||||
def clip_index(t_in: float) -> int:
|
||||
for k in range(len(specs)):
|
||||
if t_in < in_starts[k] + specs[k]["dur"]:
|
||||
return k
|
||||
return len(specs) - 1
|
||||
|
||||
# 旁白配音时长(秒)按片段索引取:有配音时字幕窗口必须跟语音走,而不是铺满片段
|
||||
vo_meta = (timeline.metadata or {}).get("voiceover") or {}
|
||||
vo_durs: dict[int, float] = {}
|
||||
if vo_meta.get("enabled"):
|
||||
for item in vo_meta.get("items") or []:
|
||||
try:
|
||||
vo_idx = int(item.get("index", -1))
|
||||
vo_dur = float(item.get("duration_ms") or 0) / 1000.0
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if vo_idx >= 0 and vo_dur > 0:
|
||||
vo_durs[vo_idx] = vo_dur
|
||||
|
||||
cues: list[tuple[float, float, str]] = []
|
||||
content = [c for c in (track.content or []) if str((c or {}).get("text", "")).strip()]
|
||||
if content:
|
||||
entries = sorted(content, key=lambda c: int((c or {}).get("start_ms", 0) or 0))
|
||||
outs: list[tuple[float, int, str, float | None]] = []
|
||||
for c in entries:
|
||||
t_in = int(c.get("start_ms", 0) or 0) / 1000.0
|
||||
i = clip_index(t_in)
|
||||
offset = max(0.0, min(specs[i]["dur"], t_in - in_starts[i]))
|
||||
# 新格式 cue 自带 end_ms(已对齐配音语速),同样重映射到输出时间轴;旧草稿无 end_ms 走相邻推断
|
||||
out_end: float | None = None
|
||||
if c.get("end_ms"):
|
||||
e_off = max(0.0, min(specs[i]["dur"], int(c["end_ms"]) / 1000.0 - in_starts[i]))
|
||||
out_end = starts[i] + e_off
|
||||
outs.append((starts[i] + offset, i, str(c.get("text", "")).strip(), out_end))
|
||||
for j, (start, i, text, out_end) in enumerate(outs):
|
||||
if out_end is None:
|
||||
if j + 1 < len(outs) and outs[j + 1][1] == i:
|
||||
out_end = outs[j + 1][0]
|
||||
else:
|
||||
out_end = min(total, starts[i] + specs[i]["dur"])
|
||||
cues.append((start, max(start + 0.5, min(total, out_end)), text))
|
||||
return cues
|
||||
|
||||
script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
|
||||
texts = [seg.narration for seg in script.segments.all().order_by("sort_order")] if script else []
|
||||
for i in range(len(specs)):
|
||||
text = texts[i] if i < len(texts) else ""
|
||||
start = starts[i]
|
||||
end = starts[i + 1] if i + 1 < len(starts) else total
|
||||
if text and text.strip():
|
||||
cues.append((start, max(start + 0.5, end), text))
|
||||
pieces = _split_subtitle_text(texts[i] if i < len(texts) else "")
|
||||
if not pieces:
|
||||
continue
|
||||
span = (starts[i + 1] if i + 1 < len(starts) else total) - starts[i]
|
||||
if i in vo_durs:
|
||||
span = min(span, vo_durs[i])
|
||||
total_chars = sum(len(p) for p in pieces) or 1
|
||||
acc_chars = 0
|
||||
for p in pieces:
|
||||
start = starts[i] + (acc_chars / total_chars) * span
|
||||
acc_chars += len(p)
|
||||
end = starts[i] + (acc_chars / total_chars) * span
|
||||
cues.append((start, max(start + 0.5, end), p))
|
||||
return cues
|
||||
|
||||
|
||||
@@ -279,6 +417,8 @@ def run_export_job(export_job_id: str) -> ExportJob:
|
||||
_download_asset_primary_file(clip.asset, tmp / f"clip{index}.mp4")
|
||||
# 逐片段探测是否自带音轨:有声→保留原声,无声→补静音(见 _build_export_command)
|
||||
has_audio = [_has_audio_stream(tmp / f"clip{index}.mp4") for index in range(len(clips))]
|
||||
# 输出帧率跟随源帧率众数(Seedance 出 24fps,硬转 30fps 会不均匀复帧=成片一卡一顿)
|
||||
output_fps = _pick_output_fps([_probe_fps(tmp / f"clip{index}.mp4") for index in range(len(clips))])
|
||||
|
||||
bgm_name = None
|
||||
if bgm_track is not None and bgm_track.asset_id:
|
||||
@@ -294,13 +434,36 @@ def run_export_job(export_job_id: str) -> ExportJob:
|
||||
_render_subtitle_png(text, style_key, tmp / png)
|
||||
sub_overlays.append((png, start, end))
|
||||
|
||||
# 旁白配音(TTS 资产):按 timeline.metadata.voiceover 映射下载,人声轨混在 BGM 之上;
|
||||
# 逐句条目带 offset_ms(句内起点,可被拖动调整),输出位置 = 片段输出起点 + 句内偏移
|
||||
vo_meta = (timeline.metadata or {}).get("voiceover") or {}
|
||||
voice_overlays: list[tuple[str, float, float]] = []
|
||||
if vo_meta.get("enabled") and isinstance(vo_meta.get("items"), list):
|
||||
for j, item in enumerate(vo_meta["items"]):
|
||||
try:
|
||||
seg_index = int(item.get("index", -1))
|
||||
offset_s = max(0.0, float(item.get("offset_ms") or 0) / 1000.0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if seg_index < 0 or seg_index >= len(clips) or not item.get("asset"):
|
||||
continue
|
||||
remain = specs[seg_index]["dur"] - offset_s
|
||||
if remain <= 0.05:
|
||||
continue # 句子被拖到片段尾外,导出时丢弃(预览同样不响)
|
||||
vo_asset = Asset.objects.filter(team=project.team, id=item["asset"]).first()
|
||||
if vo_asset is None:
|
||||
continue
|
||||
vo_name = f"vo{j}.mp3"
|
||||
_download_asset_primary_file(vo_asset, tmp / vo_name)
|
||||
voice_overlays.append((vo_name, starts[seg_index] + offset_s, remain))
|
||||
|
||||
export_job.progress = 35
|
||||
export_job.save(update_fields=["progress", "updated_at"])
|
||||
|
||||
command = _build_export_command(
|
||||
n=len(clips), specs=specs, starts=starts, total=total, transition=transition,
|
||||
sub_overlays=sub_overlays, bgm_name=bgm_name, bgm_volume=(bgm_track.volume / 100.0) if bgm_track else 1.0,
|
||||
has_audio=has_audio,
|
||||
has_audio=has_audio, fps=output_fps, voice_overlays=voice_overlays,
|
||||
)
|
||||
proc = subprocess.run(command, cwd=str(tmp), capture_output=True)
|
||||
if proc.returncode != 0:
|
||||
|
||||
@@ -5,9 +5,19 @@ from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
from apps.ai.models import ModelConfig, ModelProvider
|
||||
from apps.assets.models import Asset
|
||||
from apps.billing.models import CreditAccount, CreditLedger
|
||||
from apps.products.models import Product
|
||||
from apps.projects.models import Project, ProjectStage, ScriptVersion, VideoSegment
|
||||
from apps.projects.models import (
|
||||
Project,
|
||||
ProjectStage,
|
||||
ScriptVersion,
|
||||
SubtitleTrack,
|
||||
Timeline,
|
||||
TimelineClip,
|
||||
VideoSegment,
|
||||
VideoSegmentVersion,
|
||||
)
|
||||
|
||||
|
||||
class ProjectApiTests(TestCase):
|
||||
@@ -100,3 +110,207 @@ class ProjectApiTests(TestCase):
|
||||
self.assertEqual(script.segments.count(), 4)
|
||||
self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1)
|
||||
|
||||
def test_adopt_video_version_remaps_timeline_draft(self):
|
||||
"""切换采用版本后,时间线草稿里引用本场旧版本资产的片段必须跟随换成新资产(剪辑台/导出都读草稿)。"""
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
|
||||
make_asset = lambda name: Asset.objects.create(
|
||||
team=self.team, created_by=self.user, name=name,
|
||||
asset_type=Asset.Type.VIDEO, source=Asset.Source.AI_GENERATED, category=Asset.Category.VIDEO_CLIP,
|
||||
)
|
||||
old_asset, new_asset = make_asset("v1"), make_asset("v2")
|
||||
segment = VideoSegment.objects.create(project=project, sort_order=0)
|
||||
old_ver = VideoSegmentVersion.objects.create(video_segment=segment, asset=old_asset, is_adopted=True)
|
||||
new_ver = VideoSegmentVersion.objects.create(video_segment=segment, asset=new_asset)
|
||||
segment.adopted_version = old_ver
|
||||
segment.save(update_fields=["adopted_version"])
|
||||
timeline = Timeline.objects.create(project=project)
|
||||
clip = TimelineClip.objects.create(
|
||||
timeline=timeline, asset=old_asset, sort_order=0,
|
||||
duration_ms=15000, trim_start_ms=1000, trim_end_ms=9000,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/projects/{project.id}/adopt-video-version/",
|
||||
{"video_segment_id": str(segment.id), "version_id": str(new_ver.id)},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
clip.refresh_from_db()
|
||||
self.assertEqual(clip.asset_id, new_asset.id)
|
||||
# 旧素材上的裁剪点对新素材无意义,必须复位
|
||||
self.assertEqual(clip.trim_start_ms, 0)
|
||||
self.assertIsNone(clip.trim_end_ms)
|
||||
|
||||
def _make_audio_model(self):
|
||||
return ModelConfig.objects.create(
|
||||
provider=self.provider, name="doubao-voice-bigtts", display_name="豆包语音合成",
|
||||
capability="audio", endpoint="api/v1/tts", unit_price="2.0000",
|
||||
)
|
||||
|
||||
@patch("apps.ai.services.TosStorage")
|
||||
@patch("apps.ai.services.VolcanoTtsProvider")
|
||||
def test_generate_voiceover_creates_assets_and_charges_once(self, provider_cls, storage_cls):
|
||||
"""每镜旁白 TTS:产出音频资产、timeline.metadata 落映射、一次调用只计一次费。"""
|
||||
self._make_audio_model()
|
||||
provider = provider_cls.return_value
|
||||
provider.configured = True
|
||||
provider.synthesize.return_value = (b"fake-mp3-bytes", 4200)
|
||||
stored = storage_cls.return_value.upload_fileobj.return_value
|
||||
stored.object_key, stored.bucket, stored.content_type, stored.size_bytes = "k.mp3", "b", "audio/mpeg", 14
|
||||
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="VO")
|
||||
response = self.client.post(
|
||||
f"/api/projects/{project.id}/generate-voiceover/",
|
||||
{"items": [{"index": 0, "text": "第一镜旁白"}, {"index": 1, "text": "第二镜旁白"}]},
|
||||
format="json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
voiceover = response.json()["voiceover"]
|
||||
self.assertTrue(voiceover["enabled"])
|
||||
self.assertEqual(len(voiceover["items"]), 2)
|
||||
self.assertEqual(provider.synthesize.call_count, 2)
|
||||
from apps.assets.models import Asset as AssetModel
|
||||
self.assertEqual(AssetModel.objects.filter(team=self.team, asset_type="audio").count(), 2)
|
||||
self.assertEqual(
|
||||
CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1
|
||||
)
|
||||
project.refresh_from_db()
|
||||
self.assertEqual(len(project.timeline.metadata["voiceover"]["items"]), 2)
|
||||
|
||||
@patch("apps.ai.services.VolcanoTtsProvider")
|
||||
def test_generate_voiceover_unconfigured_returns_400_and_no_charge(self, provider_cls):
|
||||
"""语音凭证未配置:400 + 人话提示,不建任务不扣费。"""
|
||||
self._make_audio_model()
|
||||
provider_cls.return_value.configured = False
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="VO2")
|
||||
response = self.client.post(
|
||||
f"/api/projects/{project.id}/generate-voiceover/",
|
||||
{"items": [{"index": 0, "text": "旁白"}]},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("语音合成未配置", response.json()["detail"])
|
||||
self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 0)
|
||||
|
||||
def test_export_command_mixes_voiceover_above_bgm(self):
|
||||
"""导出命令:人声按片段起点 adelay 延迟、先与原声 amix、再与 BGM amix。"""
|
||||
from apps.projects.services.export import _build_export_command
|
||||
|
||||
specs = [{"ts": 0.0, "te": 15.0, "dur": 15.0}, {"ts": 0.0, "te": 15.0, "dur": 15.0}]
|
||||
cmd = _build_export_command(
|
||||
n=2, specs=specs, starts=[0.0, 15.0], total=30.0, transition="none",
|
||||
sub_overlays=[], bgm_name="bgm.mp3", bgm_volume=0.6, has_audio=[False, False],
|
||||
voice_overlays=[("vo0.mp3", 0.0, 15.0), ("vo1.mp3", 15.0, 15.0)],
|
||||
)
|
||||
self.assertIn("vo0.mp3", cmd)
|
||||
self.assertIn("vo1.mp3", cmd)
|
||||
graph = cmd[cmd.index("-filter_complex") + 1]
|
||||
self.assertIn("adelay=15000|15000", graph)
|
||||
self.assertIn("[avoice][vo0][vo1]amix=inputs=3", graph)
|
||||
self.assertIn("[anarr][abgm]amix=inputs=2", graph)
|
||||
|
||||
def test_save_timeline_updates_voiceover_offsets(self):
|
||||
"""拖动字幕块后保存:按 asset 回写句内起点 offset_ms,未拖动的句不受影响。"""
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="VOFF")
|
||||
Timeline.objects.create(project=project, metadata={"voiceover": {"enabled": True, "items": [
|
||||
{"index": 0, "cue": 0, "asset": "a1", "text": "x", "duration_ms": 2000, "offset_ms": 0},
|
||||
{"index": 0, "cue": 1, "asset": "a2", "text": "y", "duration_ms": 2000, "offset_ms": 2000},
|
||||
]}})
|
||||
response = self.client.post(
|
||||
f"/api/projects/{project.id}/save-timeline/",
|
||||
{"voiceover": {"items": [{"asset": "a2", "offset_ms": 9000}]}},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
project.timeline.refresh_from_db()
|
||||
items = project.timeline.metadata["voiceover"]["items"]
|
||||
self.assertEqual(items[0]["offset_ms"], 0)
|
||||
self.assertEqual(items[1]["offset_ms"], 9000)
|
||||
|
||||
def test_subtitle_cues_use_end_ms_and_follow_voiceover(self):
|
||||
"""字幕烧入时序:cue 自带 end_ms 时按它收尾(不再拖到片段尾)——这是配音对齐的关键;
|
||||
无保存字幕的回退路径,有配音时逐句窗口也要压进语音真实时长。"""
|
||||
from apps.projects.services.export import _subtitle_cues
|
||||
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="SUB")
|
||||
timeline = Timeline.objects.create(
|
||||
project=project,
|
||||
metadata={"voiceover": {"enabled": True, "items": [{"index": 0, "asset": "x", "text": "t", "duration_ms": 4000}]}},
|
||||
)
|
||||
SubtitleTrack.objects.create(timeline=timeline, enabled=True, content=[
|
||||
{"start_ms": 0, "end_ms": 2000, "text": "第一句"},
|
||||
{"start_ms": 2000, "end_ms": 4000, "text": "第二句"},
|
||||
])
|
||||
specs = [{"ts": 0.0, "te": 15.0, "dur": 15.0}]
|
||||
cues = _subtitle_cues(timeline, project, specs, [0.0], 15.0)
|
||||
self.assertEqual(len(cues), 2)
|
||||
self.assertEqual((cues[0][0], cues[0][1]), (0.0, 2.0))
|
||||
self.assertEqual(cues[1][1], 4.0) # 最后一句在语音念完处收尾,而非拖到 15s
|
||||
|
||||
def test_save_timeline_voiceover_toggle_and_clear(self):
|
||||
"""保存草稿可关/开配音、可移除映射(不动已生成的音频资产)。"""
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="VO3")
|
||||
Timeline.objects.create(
|
||||
project=project,
|
||||
metadata={"voiceover": {"enabled": True, "voice_type": "v", "items": [{"index": 0, "asset": "a", "text": "t"}]}},
|
||||
)
|
||||
response = self.client.post(
|
||||
f"/api/projects/{project.id}/save-timeline/", {"voiceover": {"enabled": False}}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
project.timeline.refresh_from_db()
|
||||
self.assertFalse(project.timeline.metadata["voiceover"]["enabled"])
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/projects/{project.id}/save-timeline/", {"voiceover": {"clear": True}}, format="json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
project.timeline.refresh_from_db()
|
||||
self.assertNotIn("voiceover", project.timeline.metadata)
|
||||
|
||||
|
||||
class WorkerGateTests(TestCase):
|
||||
"""无 Celery worker 时,图片/视频生成入口必须 503 拒绝(防结果无人取回=数据丢失)。"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="gate", password="pass")
|
||||
self.team = Team.objects.create(name="Gate Team", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
CreditAccount.objects.create(team=self.team, balance="100.0000")
|
||||
self.product = Product.objects.create(team=self.team, created_by=self.user, title="Gate Product")
|
||||
self.project = Project.objects.create(team=self.team, created_by=self.user, name="Gate", product=self.product)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
|
||||
def _blocked(self):
|
||||
return patch("apps.common.celery_health.celery_worker_available", return_value=False)
|
||||
|
||||
def test_generation_endpoints_blocked_without_worker(self):
|
||||
cases = [
|
||||
(f"/api/projects/{self.project.id}/generate-base-asset/", {"kind": "person"}),
|
||||
(f"/api/projects/{self.project.id}/generate-storyboard/", {}),
|
||||
(f"/api/projects/{self.project.id}/submit-video-segment/", {"video_segment_id": "x"}),
|
||||
("/api/ai/generate-image/", {"prompt": "测试"}),
|
||||
]
|
||||
with self._blocked():
|
||||
for url, payload in cases:
|
||||
response = self.client.post(url, payload, format="json")
|
||||
self.assertEqual(response.status_code, 503, msg=f"{url} 应被 worker 闸拦截")
|
||||
self.assertIn("worker", response.json()["detail"].lower() if isinstance(response.json().get("detail"), str) else "")
|
||||
|
||||
def test_eager_mode_bypasses_gate(self):
|
||||
from apps.common.celery_health import celery_worker_available
|
||||
|
||||
# 测试配置 CELERY_TASK_ALWAYS_EAGER=True:任务就地执行,无需 worker,闸放行
|
||||
self.assertTrue(celery_worker_available())
|
||||
|
||||
def test_ping_failure_means_unavailable(self):
|
||||
from apps.common import celery_health
|
||||
|
||||
celery_health._cache["expires"] = 0.0
|
||||
with self.settings(CELERY_TASK_ALWAYS_EAGER=False):
|
||||
with patch.object(celery_health, "_ping_workers", side_effect=ConnectionError("broker down")):
|
||||
self.assertFalse(celery_health.celery_worker_available())
|
||||
celery_health._cache["expires"] = 0.0
|
||||
|
||||
@@ -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