后端生成闸+多项修复;前端全站更新;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:
@@ -10,7 +10,7 @@ from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.ai.models import AITask, ModelConfig
|
||||
from apps.ai.providers import VolcanoArkProvider
|
||||
from apps.ai.providers import TtsNotConfigured, VolcanoArkProvider, VolcanoTtsProvider, YunqiProvider
|
||||
from apps.assets.models import Asset, AssetFile
|
||||
from apps.assets.storage import TosStorage
|
||||
from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit
|
||||
@@ -22,6 +22,7 @@ from apps.projects.models import (
|
||||
ScriptVersion,
|
||||
StoryboardFrame,
|
||||
StoryboardVersion,
|
||||
Timeline,
|
||||
VideoSegment,
|
||||
VideoSegmentVersion,
|
||||
)
|
||||
@@ -36,6 +37,13 @@ def get_default_model(capability: str) -> ModelConfig:
|
||||
)
|
||||
|
||||
|
||||
def get_image_provider(model_config: ModelConfig):
|
||||
"""生图按 provider 分流:yunqi 走 OpenAI 兼容网关(gpt-image-2),其余沿用火山 ARK。"""
|
||||
if model_config.provider.name == "yunqi":
|
||||
return YunqiProvider(base_url=model_config.provider.base_url or None)
|
||||
return VolcanoArkProvider(base_url=model_config.provider.base_url or None)
|
||||
|
||||
|
||||
def estimate_cost(model_config: ModelConfig) -> Decimal:
|
||||
return model_config.unit_price if model_config.unit_price > 0 else Decimal("1.0000")
|
||||
|
||||
@@ -48,7 +56,9 @@ def build_script_prompt(*, project, user_prompt: str, selling_point_ids: list[st
|
||||
selling_text = "\n".join(f"- {item.title}: {item.detail}" for item in selling_points)
|
||||
system = (
|
||||
"你是电商短视频脚本导演。请为 9:16 竖屏带货短视频生成 60 秒脚本,"
|
||||
"拆成 4 个 15 秒段落。每段包含旁白、画面描述、商品露出方式和转场建议。"
|
||||
"拆成 4 个 15 秒段落。严格按以下格式输出,段落之间空一行,不要输出其他内容:\n"
|
||||
"镜头1\n旁白:这一镜要念出来的口播文案(一两句话)\n画面:这一镜的画面描述、商品露出方式和转场建议\n\n"
|
||||
"镜头2\n旁白:…\n画面:…(依此类推到镜头4)"
|
||||
)
|
||||
user = f"""
|
||||
商品标题:{product.title}
|
||||
@@ -65,6 +75,42 @@ def build_script_prompt(*, project, user_prompt: str, selling_point_ids: list[st
|
||||
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
||||
|
||||
|
||||
def parse_segment_fields(block: str) -> tuple[str, str]:
|
||||
"""从一镜文本里拆出(旁白, 画面)。
|
||||
|
||||
模型按 build_script_prompt 的格式输出「旁白:…/画面:…」标签行时精确拆分;
|
||||
自带脚本/旧格式没有标签则两个字段都用整段(保持旧行为),字幕/故事板各自兜底。
|
||||
"""
|
||||
narration_lines: list[str] = []
|
||||
visual_lines: list[str] = []
|
||||
current: list[str] | None = None
|
||||
for raw in (block or "").splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
matched = re.match(r"^(旁白|口播|台词|文案)\s*[::]\s*(.*)$", line)
|
||||
if matched:
|
||||
current = narration_lines
|
||||
if matched.group(2):
|
||||
current.append(matched.group(2))
|
||||
continue
|
||||
matched = re.match(r"^(画面|镜头描述|视觉|画面描述)\s*[::]\s*(.*)$", line)
|
||||
if matched:
|
||||
current = visual_lines
|
||||
if matched.group(2):
|
||||
current.append(matched.group(2))
|
||||
continue
|
||||
if re.match(r"^(镜头|分镜|场)\s*\d+", line):
|
||||
continue # 「镜头N」标题行不计入任何字段
|
||||
if current is not None:
|
||||
current.append(line)
|
||||
narration = " ".join(narration_lines).strip()
|
||||
visual = " ".join(visual_lines).strip()
|
||||
if not narration and not visual:
|
||||
return block.strip(), block.strip()
|
||||
return narration or visual, visual or narration
|
||||
|
||||
|
||||
def split_script_into_segments(content: str, count: int = 4) -> list[str]:
|
||||
"""把一段脚本稳健地拆成 `count` 个分镜文本,保证每镜都非空、且所有内容都被分配到某一镜。
|
||||
|
||||
@@ -124,7 +170,7 @@ def create_ai_task(*, project, user, task_type: str, model_config: ModelConfig,
|
||||
return task
|
||||
|
||||
|
||||
def generate_project_script(*, project, user, user_prompt: str, selling_point_ids: list[str] | None = None) -> ScriptVersion:
|
||||
def generate_project_script(*, project, user, user_prompt: str, selling_point_ids: list[str] | None = None, source: str = "ai") -> ScriptVersion:
|
||||
model_config = get_default_model(ModelConfig.Capability.TEXT)
|
||||
if model_config is None:
|
||||
raise ValueError("no active text model configured")
|
||||
@@ -162,16 +208,17 @@ def generate_project_script(*, project, user, user_prompt: str, selling_point_id
|
||||
task=task,
|
||||
title="AI 脚本",
|
||||
content=content,
|
||||
source="ai",
|
||||
source=source if source in ("ai", "theme", "manual") else "ai",
|
||||
is_adopted=False,
|
||||
)
|
||||
for index, segment_text in enumerate(split_script_into_segments(content)):
|
||||
narration, visual = parse_segment_fields(segment_text)
|
||||
ScriptSegment.objects.create(
|
||||
script_version=script,
|
||||
sort_order=index,
|
||||
duration_seconds=15,
|
||||
narration=segment_text,
|
||||
visual_prompt=segment_text,
|
||||
narration=narration,
|
||||
visual_prompt=visual,
|
||||
)
|
||||
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
|
||||
@@ -283,7 +330,7 @@ def generate_base_asset(*, project, user, kind: str, prompt: str) -> BaseAssetGr
|
||||
)
|
||||
reservation = task.credit_reservation
|
||||
try:
|
||||
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
|
||||
provider = get_image_provider(model_config)
|
||||
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
|
||||
media = provider.extract_first_media_url(response)
|
||||
with transaction.atomic():
|
||||
@@ -408,7 +455,7 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
|
||||
task.status = AITask.Status.SUBMITTED
|
||||
task.save(update_fields=["status", "updated_at"])
|
||||
try:
|
||||
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
|
||||
provider = get_image_provider(model_config)
|
||||
frame_prompt = task.request_payload.get("prompt") or build_storyboard_frame_prompt(project, version, segment)
|
||||
response = provider.image_generation(
|
||||
model=model_config.name,
|
||||
@@ -416,12 +463,9 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
|
||||
prompt=frame_prompt,
|
||||
)
|
||||
media = provider.extract_first_media_url(response)
|
||||
task.status = AITask.Status.SUCCEEDED
|
||||
task.response_payload = response
|
||||
task.actual_cost = task.estimated_cost
|
||||
task.completed_at = timezone.now()
|
||||
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
|
||||
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
|
||||
# 注意顺序:task 是 poll 端的「占位锁」,必须等帧真正落库后才置 SUCCEEDED。
|
||||
# 旧实现先置 SUCCEEDED 再上传 TOS(数秒)最后建帧,中间窗口 poll 会判「无在途且帧缺失」
|
||||
# 为同一镜重复起线程 → 重复帧 + 重复扣费(实测 4 帧出 6 帧)。
|
||||
asset = _store_generated_media(
|
||||
team=project.team,
|
||||
user=user,
|
||||
@@ -432,13 +476,22 @@ def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
|
||||
category=Asset.Category.SCENE,
|
||||
asset_type=Asset.Type.IMAGE,
|
||||
)
|
||||
StoryboardFrame.objects.create(
|
||||
storyboard=version,
|
||||
script_segment=segment,
|
||||
asset=asset,
|
||||
sort_order=segment.sort_order,
|
||||
prompt=segment.visual_prompt,
|
||||
)
|
||||
with transaction.atomic():
|
||||
task.status = AITask.Status.SUCCEEDED
|
||||
task.response_payload = response
|
||||
task.actual_cost = task.estimated_cost
|
||||
task.completed_at = timezone.now()
|
||||
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
|
||||
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
|
||||
# 幂等守卫:该镜已有帧(任何残余竞态/双 poll)就不再建,保持一镜一帧
|
||||
if not StoryboardFrame.objects.filter(storyboard=version, script_segment=segment).exists():
|
||||
StoryboardFrame.objects.create(
|
||||
storyboard=version,
|
||||
script_segment=segment,
|
||||
asset=asset,
|
||||
sort_order=segment.sort_order,
|
||||
prompt=segment.visual_prompt,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — 失败回滚额度,标记任务失败供 poll 上报
|
||||
task.status = AITask.Status.FAILED
|
||||
task.error_message = str(exc)
|
||||
@@ -470,53 +523,64 @@ def generate_storyboard_frame(*, project, user) -> dict:
|
||||
_finalize_storyboard(project, version)
|
||||
return {"status": "succeeded", "done": total, "total": total, "version_id": str(version.id)}
|
||||
|
||||
# 该版本内是否已有帧在后台生成中(RESERVED/SUBMITTED 的故事板任务即为「占位锁」)。
|
||||
# 仅算「近 3 分钟内」的任务:若进程/线程意外中断留下僵尸任务,超时后不再视为在生成,允许重新发起。
|
||||
# 每镜独立「占位锁」(该镜有 CREATED/RESERVED/SUBMITTED 的任务=在生成中)。
|
||||
# 旧实现是版本级单锁、一次只生成一帧;改为缺哪几镜就同时起哪几镜的线程。
|
||||
# ★ 实测注记(2026-06-10):4 线程并发时当前 Seedream 端点在服务侧排队,单帧 25s→83-105s,
|
||||
# 整版总时长 ≈ 串行(117s)。瓶颈是 ARK 端点并发配额而非本机;并行无额外成本,
|
||||
# 配额提升后自动受益。可用 settings.STORYBOARD_MAX_PARALLEL 调并发(1=回到串行)。
|
||||
# 仅算「近 3 分钟内」的任务:线程意外中断留下的僵尸任务超时后不再占锁,允许重新发起。
|
||||
from django.conf import settings as dj_settings
|
||||
STORYBOARD_MAX_PARALLEL = int(getattr(dj_settings, "STORYBOARD_MAX_PARALLEL", 4))
|
||||
stale_cutoff = timezone.now() - timedelta(minutes=3)
|
||||
inflight = AITask.objects.filter(
|
||||
project=project,
|
||||
task_type=AITask.Type.STORYBOARD,
|
||||
status__in=[AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED],
|
||||
request_payload__storyboard_version=str(version.id),
|
||||
created_at__gte=stale_cutoff,
|
||||
).exists()
|
||||
if inflight:
|
||||
return {"status": "generating", "done": done, "total": total, "version_id": str(version.id)}
|
||||
inflight_segment_ids = {
|
||||
str(v)
|
||||
for v in AITask.objects.filter(
|
||||
project=project,
|
||||
task_type=AITask.Type.STORYBOARD,
|
||||
status__in=[AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED],
|
||||
request_payload__storyboard_version=str(version.id),
|
||||
created_at__gte=stale_cutoff,
|
||||
).values_list("request_payload__storyboard_segment", flat=True)
|
||||
if v
|
||||
}
|
||||
|
||||
pending = [s for s in segments if s.id not in done_segment_ids]
|
||||
segment = pending[0]
|
||||
# 单帧失败次数上限,避免持续失败时无限重试
|
||||
failed_for_segment = AITask.objects.filter(
|
||||
project=project,
|
||||
task_type=AITask.Type.STORYBOARD,
|
||||
status=AITask.Status.FAILED,
|
||||
request_payload__storyboard_segment=str(segment.id),
|
||||
).count()
|
||||
if failed_for_segment >= 2:
|
||||
last = AITask.objects.filter(project=project, task_type=AITask.Type.STORYBOARD, status=AITask.Status.FAILED,
|
||||
request_payload__storyboard_segment=str(segment.id)).order_by("-created_at").first()
|
||||
return {"status": "failed", "done": done, "total": total, "version_id": str(version.id),
|
||||
"error": last.error_message if last else "storyboard frame failed"}
|
||||
# 单帧失败次数上限,避免持续失败时无限重试;任一镜到上限即整版上报失败
|
||||
for segment in pending:
|
||||
failed_for_segment = AITask.objects.filter(
|
||||
project=project,
|
||||
task_type=AITask.Type.STORYBOARD,
|
||||
status=AITask.Status.FAILED,
|
||||
request_payload__storyboard_segment=str(segment.id),
|
||||
).count()
|
||||
if failed_for_segment >= 2:
|
||||
last = AITask.objects.filter(project=project, task_type=AITask.Type.STORYBOARD, status=AITask.Status.FAILED,
|
||||
request_payload__storyboard_segment=str(segment.id)).order_by("-created_at").first()
|
||||
return {"status": "failed", "done": done, "total": total, "version_id": str(version.id),
|
||||
"error": last.error_message if last else "storyboard frame failed"}
|
||||
|
||||
spawnable = [s for s in pending if str(s.id) not in inflight_segment_ids]
|
||||
slots = max(0, STORYBOARD_MAX_PARALLEL - len(inflight_segment_ids))
|
||||
model_config = get_default_model(ModelConfig.Capability.IMAGE)
|
||||
task = create_ai_task(
|
||||
project=project,
|
||||
user=user,
|
||||
task_type=AITask.Type.STORYBOARD,
|
||||
model_config=model_config,
|
||||
request_payload={
|
||||
"model": model_config.name,
|
||||
"endpoint": model_config.endpoint,
|
||||
"prompt": build_storyboard_frame_prompt(project, version, segment),
|
||||
"storyboard_version": str(version.id),
|
||||
"storyboard_segment": str(segment.id),
|
||||
},
|
||||
)
|
||||
threading.Thread(
|
||||
target=_storyboard_frame_worker,
|
||||
args=(str(task.id), str(version.id), str(segment.id), str(user.id)),
|
||||
daemon=True,
|
||||
).start()
|
||||
for segment in spawnable[:slots]:
|
||||
task = create_ai_task(
|
||||
project=project,
|
||||
user=user,
|
||||
task_type=AITask.Type.STORYBOARD,
|
||||
model_config=model_config,
|
||||
request_payload={
|
||||
"model": model_config.name,
|
||||
"endpoint": model_config.endpoint,
|
||||
"prompt": build_storyboard_frame_prompt(project, version, segment),
|
||||
"storyboard_version": str(version.id),
|
||||
"storyboard_segment": str(segment.id),
|
||||
},
|
||||
)
|
||||
threading.Thread(
|
||||
target=_storyboard_frame_worker,
|
||||
args=(str(task.id), str(version.id), str(segment.id), str(user.id)),
|
||||
daemon=True,
|
||||
).start()
|
||||
return {"status": "generating", "done": done, "total": total, "version_id": str(version.id)}
|
||||
|
||||
|
||||
@@ -656,16 +720,17 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
|
||||
if video_segment.status == VideoSegment.Status.FAILED:
|
||||
return None
|
||||
|
||||
task = video_segment.versions.order_by("-created_at").first()
|
||||
ai_task = None
|
||||
if task:
|
||||
ai_task = task.task
|
||||
# ★ 先找「在途任务」再回退旧版本的任务。旧实现反过来:重跑时段上已有(旧)版本,
|
||||
# 取到旧版本挂的已成功任务 → 短路返回旧版,在途的新任务永远没人轮询,
|
||||
# 段永远卡「生成中」、新视频取不回来(实测重跑卡 40 分钟,ARK 侧其实早已生成完)。
|
||||
ai_task = video_segment.project.ai_tasks.filter(
|
||||
task_type=AITask.Type.VIDEO_SEGMENT,
|
||||
request_payload__video_segment_id=str(video_segment.id),
|
||||
status__in=[AITask.Status.SUBMITTED, AITask.Status.POLLING],
|
||||
).order_by("-created_at").first()
|
||||
if ai_task is None:
|
||||
ai_task = video_segment.project.ai_tasks.filter(
|
||||
task_type=AITask.Type.VIDEO_SEGMENT,
|
||||
request_payload__video_segment_id=str(video_segment.id),
|
||||
status__in=[AITask.Status.SUBMITTED, AITask.Status.POLLING],
|
||||
).order_by("-created_at").first()
|
||||
latest_version = video_segment.versions.order_by("-created_at").first()
|
||||
ai_task = latest_version.task if latest_version else None
|
||||
if ai_task is None:
|
||||
raise ValueError("no active video generation task")
|
||||
|
||||
@@ -679,9 +744,11 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
|
||||
response = provider.poll_video_task(endpoint=ai_task.model_config.endpoint, provider_task_id=ai_task.provider_task_id)
|
||||
remote_status = response.get("status")
|
||||
if remote_status in {"queued", "running", "processing"}:
|
||||
ai_task.status = AITask.Status.POLLING
|
||||
ai_task.response_payload = response
|
||||
ai_task.save(update_fields=["status", "response_payload", "updated_at"])
|
||||
# 仍在生成:只在状态首次进入 POLLING 时落一次库。旧实现每次 poll(5s 一次)都把完整
|
||||
# response JSON 回写远程 MySQL——纯浪费写带宽,终态时反正会存完整 payload。
|
||||
if ai_task.status != AITask.Status.POLLING:
|
||||
ai_task.status = AITask.Status.POLLING
|
||||
ai_task.save(update_fields=["status", "updated_at"])
|
||||
return None
|
||||
if remote_status in {"failed", "expired", "cancelled"}:
|
||||
ai_task.status = AITask.Status.FAILED
|
||||
@@ -706,23 +773,33 @@ def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVers
|
||||
category=Asset.Category.VIDEO_CLIP,
|
||||
asset_type=Asset.Type.VIDEO,
|
||||
)
|
||||
ai_task.status = AITask.Status.SUCCEEDED
|
||||
ai_task.response_payload = response
|
||||
ai_task.actual_cost = ai_task.estimated_cost
|
||||
ai_task.completed_at = timezone.now()
|
||||
ai_task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
|
||||
charge_reserved_credit(reservation=ai_task.credit_reservation, actual_amount=ai_task.actual_cost)
|
||||
version = VideoSegmentVersion.objects.create(
|
||||
video_segment=video_segment,
|
||||
task=ai_task,
|
||||
asset=asset,
|
||||
prompt=ai_task.request_payload.get("prompt", ""),
|
||||
is_adopted=True,
|
||||
)
|
||||
video_segment.adopted_version = version
|
||||
video_segment.status = VideoSegment.Status.SUCCEEDED
|
||||
video_segment.error_message = ""
|
||||
video_segment.save(update_fields=["adopted_version", "status", "error_message", "updated_at"])
|
||||
# 终态化必须持锁原子做:两个并发 poll(前端 5s 静默轮询 × 提交后轮询/worker)同时走到这里时,
|
||||
# 旧实现会同 task 建两个版本 + charge_reserved_credit 双扣费(实测 03:08:25 同秒双版本)。
|
||||
# select_for_update 锁 task 行,后到者看到 SUCCEEDED 直接回已有版,不再建版/扣费。
|
||||
with transaction.atomic():
|
||||
locked_task = AITask.objects.select_for_update().get(id=ai_task.id)
|
||||
if locked_task.status == AITask.Status.SUCCEEDED:
|
||||
existing = video_segment.versions.filter(task=locked_task).order_by("-created_at").first()
|
||||
if existing is not None:
|
||||
return existing
|
||||
locked_task.status = AITask.Status.SUCCEEDED
|
||||
locked_task.response_payload = response
|
||||
locked_task.actual_cost = locked_task.estimated_cost
|
||||
locked_task.completed_at = timezone.now()
|
||||
locked_task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
|
||||
charge_reserved_credit(reservation=locked_task.credit_reservation, actual_amount=locked_task.actual_cost)
|
||||
version = VideoSegmentVersion.objects.create(
|
||||
video_segment=video_segment,
|
||||
task=locked_task,
|
||||
asset=asset,
|
||||
prompt=locked_task.request_payload.get("prompt", ""),
|
||||
is_adopted=True,
|
||||
)
|
||||
video_segment.versions.exclude(id=version.id).update(is_adopted=False)
|
||||
video_segment.adopted_version = version
|
||||
video_segment.status = VideoSegment.Status.SUCCEEDED
|
||||
video_segment.error_message = ""
|
||||
video_segment.save(update_fields=["adopted_version", "status", "error_message", "updated_at"])
|
||||
return version
|
||||
|
||||
|
||||
@@ -750,7 +827,7 @@ def generate_standalone_image(*, team, user, prompt: str, mode: str = "image", c
|
||||
category = _STANDALONE_CATEGORY.get(mode, Asset.Category.UNCATEGORIZED)
|
||||
task_type = _STANDALONE_TASK_TYPE.get(mode, AITask.Type.PRODUCT_IMAGE)
|
||||
count = max(1, min(int(count or 1), 4))
|
||||
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
|
||||
provider = get_image_provider(model_config)
|
||||
assets: list[Asset] = []
|
||||
for index in range(count):
|
||||
cost = estimate_cost(model_config)
|
||||
@@ -798,3 +875,112 @@ def generate_standalone_image(*, team, user, prompt: str, mode: str = "image", c
|
||||
release_credit(reservation=reservation, reason=str(exc))
|
||||
raise
|
||||
return assets
|
||||
|
||||
|
||||
# ── 旁白配音(TTS):每镜旁白合成一段语音,导出时作为人声轨混在 BGM 之上 ──
|
||||
|
||||
# 音色按「语音合成(经典版)」试用包实测可用清单配置;大模型音色(*_bigtts)需另开通「语音合成大模型」服务,当前账号 403
|
||||
VOICEOVER_VOICES = [
|
||||
{"key": "BV700_streaming", "label": "灿灿 · 活力女声"},
|
||||
{"key": "BV034_streaming", "label": "知性姐姐 · 沉稳女声"},
|
||||
{"key": "BV001_streaming", "label": "通用女声"},
|
||||
{"key": "BV056_streaming", "label": "阳光男声"},
|
||||
{"key": "BV102_streaming", "label": "儒雅青年 · 解说男声"},
|
||||
{"key": "BV002_streaming", "label": "通用男声"},
|
||||
]
|
||||
DEFAULT_VOICEOVER_VOICE = VOICEOVER_VOICES[0]["key"]
|
||||
|
||||
|
||||
def synthesize_project_voiceover(*, project, user, items: list[dict], voice_type: str, speed_ratio: float = 1.0) -> dict:
|
||||
"""每镜旁白 → **逐句** TTS 配音资产(一句一段音频,带句内起点 offset_ms),映射写入
|
||||
timeline.metadata["voiceover"]。逐句才能支持「拖动字幕块 = 字幕和它的语音一起移动」;
|
||||
一次调用 = 一个 AITask = 计一次费;任何一句失败则整体失败并释放预留(不留半套配音)。"""
|
||||
from apps.projects.services.export import _split_subtitle_text
|
||||
|
||||
texts = [] # (片段 index, 句序 cue, 句文本)
|
||||
for n, item in enumerate(items or []):
|
||||
text = str(item.get("text") or "").strip()
|
||||
if not text:
|
||||
continue
|
||||
idx = int(item.get("index", n))
|
||||
pieces = _split_subtitle_text(text) or [text]
|
||||
for j, piece in enumerate(pieces):
|
||||
texts.append((idx, j, piece))
|
||||
if not texts:
|
||||
raise ValueError("没有可配音的旁白文本")
|
||||
provider = VolcanoTtsProvider()
|
||||
if not provider.configured:
|
||||
raise TtsNotConfigured(
|
||||
"语音合成未配置:请在后端环境变量设置 VOLC_TTS_APPID 和 VOLC_TTS_ACCESS_TOKEN"
|
||||
"(火山引擎控制台 → 语音技术 → 语音合成大模型 → 创建应用)"
|
||||
)
|
||||
voice_type = voice_type or DEFAULT_VOICEOVER_VOICE
|
||||
model_config = get_default_model(ModelConfig.Capability.AUDIO)
|
||||
if model_config is None:
|
||||
raise ValueError("no active audio model configured")
|
||||
task = create_ai_task(
|
||||
project=project,
|
||||
user=user,
|
||||
task_type=AITask.Type.VOICEOVER,
|
||||
model_config=model_config,
|
||||
request_payload={
|
||||
"voice_type": voice_type,
|
||||
"speed_ratio": float(speed_ratio or 1.0),
|
||||
"items": [{"index": idx, "cue": j, "text": text} for idx, j, text in texts],
|
||||
},
|
||||
)
|
||||
reservation = task.credit_reservation
|
||||
try:
|
||||
synthesized = []
|
||||
for idx, j, text in texts:
|
||||
audio, duration_ms = provider.synthesize(text=text, voice_type=voice_type, speed_ratio=speed_ratio, uid=str(user.id))
|
||||
synthesized.append((idx, j, text, audio, duration_ms))
|
||||
with transaction.atomic():
|
||||
task.status = AITask.Status.SUCCEEDED
|
||||
task.actual_cost = task.estimated_cost
|
||||
task.completed_at = timezone.now()
|
||||
task.response_payload = {"segments": len(synthesized)}
|
||||
task.save(update_fields=["status", "actual_cost", "completed_at", "response_payload", "updated_at"])
|
||||
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
|
||||
vo_items = []
|
||||
offset_acc: dict[int, int] = {} # 同一片段内逐句顺排:句 j 的默认起点 = 前面句时长之和
|
||||
for idx, j, text, audio, duration_ms in synthesized:
|
||||
asset_id = uuid.uuid4()
|
||||
object_key = f"teams/{project.team_id}/projects/{project.id}/voiceover/{asset_id}.mp3"
|
||||
stored = TosStorage().upload_fileobj(fileobj=BytesIO(audio), object_key=object_key, content_type="audio/mpeg")
|
||||
asset = Asset.objects.create(
|
||||
id=asset_id, team=project.team, created_by=user,
|
||||
name=f"配音 · 场 {idx + 1} · 句 {j + 1}", asset_type=Asset.Type.AUDIO,
|
||||
source=Asset.Source.AI_GENERATED, category=Asset.Category.UNCATEGORIZED,
|
||||
origin_task=task, description=text,
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset, object_key=stored.object_key, bucket=stored.bucket,
|
||||
content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True,
|
||||
)
|
||||
offset_ms = offset_acc.get(idx, 0)
|
||||
offset_acc[idx] = offset_ms + (duration_ms or 0)
|
||||
vo_items.append({
|
||||
"index": idx, "cue": j, "text": text, "asset": str(asset.id),
|
||||
"duration_ms": duration_ms, "offset_ms": offset_ms,
|
||||
})
|
||||
timeline, _ = Timeline.objects.get_or_create(
|
||||
project=project, defaults={"name": f"{project.name} Timeline", "duration_seconds": 60}
|
||||
)
|
||||
metadata = dict(timeline.metadata or {})
|
||||
metadata["voiceover"] = {
|
||||
"enabled": True,
|
||||
"voice_type": voice_type,
|
||||
"speed_ratio": float(speed_ratio or 1.0),
|
||||
"items": vo_items,
|
||||
}
|
||||
timeline.metadata = metadata
|
||||
timeline.save(update_fields=["metadata", "updated_at"])
|
||||
return metadata["voiceover"]
|
||||
except Exception as exc:
|
||||
task.status = AITask.Status.FAILED
|
||||
task.error_message = str(exc)
|
||||
task.completed_at = timezone.now()
|
||||
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||||
release_credit(reservation=reservation, reason=str(exc))
|
||||
raise
|
||||
|
||||
Reference in New Issue
Block a user