逻辑(后端):用 BaseAssetGroup 本来的设计,不再每次生成新建组 - generate_base_asset 复用同实体的组(商品=唯一商品组;人物/场景按 label):追加 candidate + 采用最新 - 版本 = candidate_assets,采用/当前 = adopted_asset;采用/切换走真 adopt-base-asset(下游只认 adopted) - 人物三视图绑定到「某一版立绘 asset」:metadata.triview_of=<立绘 asset id>,同立绘多次=同组追加候选 - generate-triview 端点改取 portrait_asset_id;attach-base-asset 坏参数 404 加固 设计(前端): - 人物/场景卡对齐设计稿(.prompt-box 可编辑 + 重跑/替换 + 转圈态,区头 N 个 + 克制「+ 新增」) - 详情弹窗整套换成设计稿 asset-detail-grid:左大立绘(3:4)+版本缩略条;右三视图 section(16:9 + 重跑/下载 + 单张大图 + 暂无 tip + v1/v2 版本条)+ 提示词 section;脚部 下载立绘 + 使用该资产;场景无三视图 - 商品三视图内联面板版本=candidate_assets、采用走真 adopt - 修:.thumb/.v-thumb/.h-thumb 非 .placeholder 导致缩略图不显图 → 接 --mock-media-url 本地 playwright 截图实测:基础资产卡 + 详情弹窗均按设计稿渲染。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1442 lines
70 KiB
Python
1442 lines
70 KiB
Python
import json
|
||
import re
|
||
import subprocess
|
||
import tempfile
|
||
import uuid
|
||
from datetime import timedelta
|
||
from decimal import Decimal
|
||
from io import BytesIO
|
||
from pathlib import Path
|
||
from django.conf import settings
|
||
from django.core.exceptions import ObjectDoesNotExist
|
||
from django.db import transaction
|
||
from django.utils import timezone
|
||
|
||
from apps.ai.models import AITask, ModelConfig
|
||
from apps.ai.providers import (
|
||
OpenAICompatibleProvider,
|
||
TtsNotConfigured,
|
||
VolcanoArkProvider,
|
||
VolcanoTtsProvider,
|
||
)
|
||
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
|
||
from apps.projects.models import (
|
||
BaseAssetGroup,
|
||
ExportJob,
|
||
ProjectStage,
|
||
ScriptSegment,
|
||
ScriptVersion,
|
||
StoryboardFrame,
|
||
StoryboardVersion,
|
||
Timeline,
|
||
VideoSegment,
|
||
VideoSegmentVersion,
|
||
)
|
||
|
||
|
||
def get_default_model(capability: str) -> ModelConfig:
|
||
return (
|
||
ModelConfig.objects.select_related("provider")
|
||
.filter(capability=capability, status=ModelConfig.Status.ACTIVE, provider__status="active")
|
||
.order_by("created_at")
|
||
.first()
|
||
)
|
||
|
||
|
||
# 火山官方直连(SeeDream 生图 / Seedance 视频 / 豆包文本)走 ARK SDK;其余 provider 一律
|
||
# 视为「OpenAI 兼容中转站」走通用适配器。加/换中转站 = DB 加一行 ModelProvider,零改代码。
|
||
# 注意:DB 里火山 provider 实际命名为 "volcengine"(豆包),必须包含,否则会被错路由到中转站。
|
||
OFFICIAL_DIRECT_PROVIDERS = {"volcengine", "volcano", "ark", "volcano_ark"}
|
||
|
||
|
||
def resolve_provider_credentials(provider) -> tuple[str | None, str | None]:
|
||
"""解析中转站凭证。可插拔顺序:DB(ModelProvider.base_url/api_key)优先 → settings(.env)回退。
|
||
两者都不写死;换站只改 DB 这一行,或改 .env 对应项。"""
|
||
base_url = (provider.base_url or "").strip() or settings.PROVIDER_BASE_URLS.get(provider.name)
|
||
api_key = (getattr(provider, "api_key", "") or "").strip() or settings.PROVIDER_KEYS.get(provider.name)
|
||
return (base_url or None), (api_key or None)
|
||
|
||
|
||
def build_provider(model_config: ModelConfig):
|
||
"""按 provider.name 分流:火山官方直连 → VolcanoArkProvider;其余 → 通用 OpenAICompatibleProvider。
|
||
两条路都走 resolve_provider_credentials,统一 DB→.env 优先级(官方直连的 None 再由 __post_init__ 回退 settings.VOLCANO)。"""
|
||
provider = model_config.provider
|
||
base_url, api_key = resolve_provider_credentials(provider)
|
||
if provider.name in OFFICIAL_DIRECT_PROVIDERS:
|
||
return VolcanoArkProvider(base_url=base_url, api_key=api_key)
|
||
return OpenAICompatibleProvider(base_url=base_url, api_key=api_key)
|
||
|
||
|
||
def get_image_provider(model_config: ModelConfig):
|
||
return build_provider(model_config)
|
||
|
||
|
||
def get_text_provider(model_config: ModelConfig):
|
||
return build_provider(model_config)
|
||
|
||
|
||
def get_video_provider(model_config: ModelConfig):
|
||
return build_provider(model_config)
|
||
|
||
|
||
def estimate_cost(model_config: ModelConfig) -> Decimal:
|
||
return model_config.unit_price if model_config.unit_price > 0 else Decimal("1.0000")
|
||
|
||
|
||
def build_script_prompt(*, project, user_prompt: str, selling_point_ids: list[str] | None = None) -> list[dict[str, str]]:
|
||
product = project.product
|
||
selling_points = product.selling_points.all()
|
||
if selling_point_ids:
|
||
selling_points = selling_points.filter(id__in=selling_point_ids)
|
||
selling_text = "\n".join(f"- {item.title}: {item.detail}" for item in selling_points)
|
||
system = (
|
||
"你是电商短视频脚本导演。请为 9:16 竖屏带货短视频生成 60 秒脚本,"
|
||
"拆成 4 个 15 秒段落。严格按以下格式输出,段落之间空一行,不要输出其他内容:\n"
|
||
"镜头1\n旁白:这一镜要念出来的口播文案(一两句话)\n画面:这一镜的画面描述、商品露出方式和转场建议\n\n"
|
||
"镜头2\n旁白:…\n画面:…(依此类推到镜头4)"
|
||
)
|
||
user = f"""
|
||
商品标题:{product.title}
|
||
品牌:{product.brand or "未填写"}
|
||
类目:{product.category or "未填写"}
|
||
目标人群:{product.target_audience or "未填写"}
|
||
商品描述:{product.description or "未填写"}
|
||
卖点:
|
||
{selling_text or "未选择卖点,请根据商品信息自行提炼。"}
|
||
|
||
用户补充需求:
|
||
{user_prompt or "生成一条结构完整、节奏清晰、适合投放的带货短视频脚本。"}
|
||
""".strip()
|
||
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 build_cast_scene_extract_prompt(content: str) -> list[dict[str, str]]:
|
||
"""轻量抽取提示词:从镜头脚本里提炼人物 / 场景标签,并给每个标签一句可直接生图的画面提示词。"""
|
||
system = (
|
||
"你是短视频脚本分析助手。请从给定的镜头脚本中提取出现的『人物』和『场景』,"
|
||
"并为每个人物 / 场景写一句可直接用于文生图的画面提示词(中文,30 字内,描述外形 / 着装 / 环境 / 光线,9:16 竖屏)。"
|
||
"人物指出镜的角色(例:女主、同事、闺蜜);场景指画面发生的地点或环境(例:卫生间、地铁、办公室)。"
|
||
"去重,人物与场景各最多 6 个。只输出一个 JSON 对象,不要 markdown 代码块,不要任何额外文字,格式如下:\n"
|
||
'{"cast":[{"name":"女主","prompt":"26岁都市女性,自然妆容,米色针织衫,柔和室内光,9:16竖屏"}],'
|
||
'"scenes":[{"name":"卫生间","prompt":"现代简约浴室,暖色灯光,干净台面,9:16竖屏"}]}'
|
||
)
|
||
return [{"role": "system", "content": system}, {"role": "user", "content": f"镜头脚本如下:\n{content}".strip()}]
|
||
|
||
|
||
def _coerce_tag_entries(items: object, limit: int = 6) -> tuple[list[str], dict[str, str]]:
|
||
"""把模型回的 [{"name","prompt"}] 列表整理成 (标签列表, {标签: 提示词}),去重保序、容错。"""
|
||
names: list[str] = []
|
||
prompts: dict[str, str] = {}
|
||
if not isinstance(items, list):
|
||
return names, prompts
|
||
for item in items:
|
||
if isinstance(item, dict):
|
||
name = str(item.get("name") or "").strip()
|
||
prompt = str(item.get("prompt") or "").strip()
|
||
else:
|
||
name, prompt = str(item or "").strip(), ""
|
||
if not name or name in ("无", "暂无", "未提及") or name in names:
|
||
continue
|
||
names.append(name)
|
||
if prompt:
|
||
prompts[name] = prompt
|
||
if len(names) >= limit:
|
||
break
|
||
return names, prompts
|
||
|
||
|
||
def extract_cast_and_scenes(*, project, user, content: str) -> dict:
|
||
"""轻量调一次文本模型,从脚本里抽取人物 / 场景标签及每个标签的建议生图提示词。
|
||
|
||
出稿后的增益步骤(对齐流程文档「自动从脚本提取信息」),走完整的 AITask + 计费闭环
|
||
(reserve→charge/release,任务类型记为 script_optimization)。但全程 best-effort:
|
||
无可用模型 / 预扣失败 / 调用失败 / 解析失败都吞掉返回空,绝不阻断脚本生成主流程。
|
||
返回 {cast, scenes, cast_prompts, scene_prompts}。
|
||
"""
|
||
empty = {"cast": [], "scenes": [], "cast_prompts": {}, "scene_prompts": {}}
|
||
model_config = get_default_model(ModelConfig.Capability.TEXT)
|
||
if model_config is None or not (content or "").strip():
|
||
return empty
|
||
|
||
messages = build_cast_scene_extract_prompt(content)
|
||
try:
|
||
task = create_ai_task(
|
||
project=project,
|
||
user=user,
|
||
task_type=AITask.Type.SCRIPT_OPTIMIZATION,
|
||
model_config=model_config,
|
||
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "messages": messages},
|
||
)
|
||
except Exception:
|
||
return empty # 余额不足等预扣失败:跳过提取,不挡出稿
|
||
reservation = task.credit_reservation
|
||
|
||
try:
|
||
task.status = AITask.Status.SUBMITTED
|
||
task.submitted_at = timezone.now()
|
||
task.save(update_fields=["status", "submitted_at", "updated_at"])
|
||
|
||
provider = build_provider(model_config)
|
||
response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages)
|
||
text = provider.extract_text(response)
|
||
|
||
# LLM 调用已真实消耗 token → 按成功计费(无论后续能否解析出标签)
|
||
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)
|
||
|
||
match = re.search(r"\{.*\}", text, re.DOTALL) # 容忍模型多裹了 markdown / 解释文字
|
||
if not match:
|
||
return empty
|
||
data = json.loads(match.group(0))
|
||
cast, cast_prompts = _coerce_tag_entries(data.get("cast"))
|
||
scenes, scene_prompts = _coerce_tag_entries(data.get("scenes"))
|
||
return {"cast": cast, "scenes": scenes, "cast_prompts": cast_prompts, "scene_prompts": scene_prompts}
|
||
except Exception as exc:
|
||
with transaction.atomic():
|
||
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))
|
||
return empty
|
||
|
||
|
||
def split_script_into_segments(content: str, count: int = 4) -> list[str]:
|
||
"""把一段脚本稳健地拆成 `count` 个分镜文本,保证每镜都非空、且所有内容都被分配到某一镜。
|
||
|
||
原实现按行 `[:4]`,ARK 返回整段散文时常变成「第1镜有词、2/3/4镜全空」,
|
||
导致后续故事板帧 / 视频段拿到空提示词,前后内容断裂。这里改为:
|
||
优先按空行/标号块切,块数够就把全部块均匀分桶;块不够再按句子切;仍不够则补齐。
|
||
"""
|
||
|
||
def _bucketize(items: list[str], joiner: str) -> list[str]:
|
||
buckets: list[list[str]] = [[] for _ in range(count)]
|
||
per = len(items) / count
|
||
for index, item in enumerate(items):
|
||
buckets[min(count - 1, int(index / per))].append(item)
|
||
return [joiner.join(bucket).strip() for bucket in buckets]
|
||
|
||
text = (content or "").strip()
|
||
if not text:
|
||
return [""] * count
|
||
|
||
# 1) 优先按空行分段;只有一段时退回按行分
|
||
blocks = [block.strip() for block in re.split(r"\n\s*\n", text) if block.strip()]
|
||
if len(blocks) < 2:
|
||
blocks = [line.strip() for line in text.splitlines() if line.strip()]
|
||
if len(blocks) >= count:
|
||
return _bucketize(blocks, "\n")
|
||
|
||
# 2) 段落不足:按中英文句末标点切句,再均匀分桶
|
||
sentences = [s.strip() for s in re.split(r"(?<=[。!?!?.;;\n])", text) if s.strip()]
|
||
if len(sentences) >= count:
|
||
return _bucketize(sentences, " ")
|
||
|
||
# 3) 仍不足:用已有块/句补齐到 count,绝不留空镜
|
||
base = blocks or sentences or [text]
|
||
filled = list(base)
|
||
while len(filled) < count:
|
||
filled.append(base[-1])
|
||
return filled[:count]
|
||
|
||
|
||
@transaction.atomic
|
||
def create_ai_task(*, project, user, task_type: str, model_config: ModelConfig, request_payload: dict) -> AITask:
|
||
cost = estimate_cost(model_config)
|
||
task = AITask.objects.create(
|
||
team=project.team,
|
||
created_by=user,
|
||
project=project,
|
||
task_type=task_type,
|
||
status=AITask.Status.CREATED,
|
||
model_config=model_config,
|
||
idempotency_key=f"{task_type}:{project.id}:{uuid.uuid4()}",
|
||
request_payload=request_payload,
|
||
estimated_cost=cost,
|
||
)
|
||
reserve_credit(team=project.team, user=user, task=task, amount=cost)
|
||
task.status = AITask.Status.RESERVED
|
||
task.save(update_fields=["status", "updated_at"])
|
||
return task
|
||
|
||
|
||
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")
|
||
|
||
messages = build_script_prompt(project=project, user_prompt=user_prompt, selling_point_ids=selling_point_ids)
|
||
payload = {"model": model_config.name, "endpoint": model_config.endpoint, "messages": messages}
|
||
task = create_ai_task(
|
||
project=project,
|
||
user=user,
|
||
task_type=AITask.Type.SCRIPT_GENERATION,
|
||
model_config=model_config,
|
||
request_payload=payload,
|
||
)
|
||
reservation = task.credit_reservation
|
||
|
||
try:
|
||
task.status = AITask.Status.SUBMITTED
|
||
task.submitted_at = timezone.now()
|
||
task.save(update_fields=["status", "submitted_at", "updated_at"])
|
||
|
||
provider = build_provider(model_config)
|
||
response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages)
|
||
content = provider.extract_text(response)
|
||
|
||
# 出稿后自动提取人物 / 场景(best-effort,失败返回空,不挡主流程),供脚本页标签与基础资产 seed
|
||
extracted = extract_cast_and_scenes(project=project, user=user, content=content)
|
||
|
||
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)
|
||
|
||
script = ScriptVersion.objects.create(
|
||
project=project,
|
||
task=task,
|
||
title="AI 脚本",
|
||
content=content,
|
||
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=narration,
|
||
visual_prompt=visual,
|
||
)
|
||
|
||
# 把提取到的人物 / 场景(含每个标签的建议生图提示词)回填进 project.metadata:
|
||
# 脚本页标签自动读 cast/scenes,基础资产据 cast_prompts/scene_prompts seed 每张卡。
|
||
# 仅在提取到内容时覆盖,空结果不清掉用户已有标签。
|
||
if extracted["cast"] or extracted["scenes"]:
|
||
metadata = dict(project.metadata or {})
|
||
if extracted["cast"]:
|
||
metadata["cast"] = extracted["cast"]
|
||
metadata["cast_prompts"] = extracted["cast_prompts"]
|
||
if extracted["scenes"]:
|
||
metadata["scenes"] = extracted["scenes"]
|
||
metadata["scene_prompts"] = extracted["scene_prompts"]
|
||
project.metadata = metadata
|
||
project.save(update_fields=["metadata", "updated_at"])
|
||
|
||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
|
||
stage.status = ProjectStage.Status.NEEDS_REVIEW
|
||
stage.save(update_fields=["status", "updated_at"])
|
||
return script
|
||
except Exception as exc:
|
||
with transaction.atomic():
|
||
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
|
||
|
||
|
||
def build_segment_rerun_prompt(*, project, segment, instruction: str = "") -> list[dict[str, str]]:
|
||
"""单镜重跑提示词:带商品/卖点上下文 + 该镜当前内容 + 前后镜上下文(保连贯)+ 用户修改意见。
|
||
只重写这一镜,严格输出「旁白:…/画面:…」两行,供 parse_segment_fields 精确解析。"""
|
||
product = project.product
|
||
selling_points = product.selling_points.all()
|
||
selling_text = "\n".join(f"- {item.title}: {item.detail}" for item in selling_points)
|
||
|
||
script = segment.script_version
|
||
siblings = list(script.segments.order_by("sort_order"))
|
||
prev_seg = next((s for s in reversed(siblings) if s.sort_order < segment.sort_order), None)
|
||
next_seg = next((s for s in siblings if s.sort_order > segment.sort_order), None)
|
||
|
||
def _brief(seg) -> str:
|
||
narration = (seg.narration or "").strip()
|
||
visual = (seg.visual_prompt or "").strip()
|
||
return f"旁白:{narration or '无'};画面:{visual or '无'}"
|
||
|
||
system = (
|
||
"你是电商短视频脚本导演。现在只需要**重写一条分镜**(其它分镜保持不变)。"
|
||
"结合商品卖点、该镜当前内容、前后镜上下文和用户的修改意见,重新生成这一镜的旁白口播与画面描述,"
|
||
"并保证与前后镜衔接连贯。严格按以下格式输出两行,不要输出镜头编号或任何其它内容:\n"
|
||
"旁白:这一镜要念出来的口播文案(一两句话)\n画面:这一镜的画面描述、商品露出方式和转场建议"
|
||
)
|
||
context_lines = [
|
||
f"商品标题:{product.title}",
|
||
f"品牌:{product.brand or '未填写'}",
|
||
f"类目:{product.category or '未填写'}",
|
||
f"目标人群:{product.target_audience or '未填写'}",
|
||
f"卖点:\n{selling_text or '未选择卖点,请根据商品信息自行提炼。'}",
|
||
"",
|
||
f"这是第 {segment.sort_order + 1} 镜(共 {len(siblings)} 镜),时长约 {segment.duration_seconds} 秒。",
|
||
f"该镜当前内容:{_brief(segment)}",
|
||
]
|
||
if prev_seg is not None:
|
||
context_lines.append(f"上一镜(保持不变,用于衔接):{_brief(prev_seg)}")
|
||
if next_seg is not None:
|
||
context_lines.append(f"下一镜(保持不变,用于衔接):{_brief(next_seg)}")
|
||
context_lines.append("")
|
||
context_lines.append(f"用户的修改意见:{instruction.strip() or '让这一镜更有吸引力、表达更清晰,并与前后镜自然衔接。'}")
|
||
user = "\n".join(context_lines).strip()
|
||
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
||
|
||
|
||
def regenerate_script_segment(*, project, user, segment, instruction: str = "") -> ScriptVersion:
|
||
"""单镜 AI 重跑:只重新生成该 ScriptSegment 的 narration + visual_prompt(其它镜不动),
|
||
沿用 generate_project_script 的 AITask + 计费(reserve/charge/release)闭环,同步调 LLM。
|
||
返回该 segment 所属的 ScriptVersion。"""
|
||
model_config = get_default_model(ModelConfig.Capability.TEXT)
|
||
if model_config is None:
|
||
raise ValueError("no active text model configured")
|
||
|
||
messages = build_segment_rerun_prompt(project=project, segment=segment, instruction=instruction)
|
||
payload = {
|
||
"model": model_config.name,
|
||
"endpoint": model_config.endpoint,
|
||
"messages": messages,
|
||
"script_segment": str(segment.id),
|
||
}
|
||
task = create_ai_task(
|
||
project=project,
|
||
user=user,
|
||
task_type=AITask.Type.SCRIPT_OPTIMIZATION,
|
||
model_config=model_config,
|
||
request_payload=payload,
|
||
)
|
||
reservation = task.credit_reservation
|
||
|
||
try:
|
||
task.status = AITask.Status.SUBMITTED
|
||
task.submitted_at = timezone.now()
|
||
task.save(update_fields=["status", "submitted_at", "updated_at"])
|
||
|
||
provider = build_provider(model_config)
|
||
response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages)
|
||
content = provider.extract_text(response)
|
||
narration, visual = parse_segment_fields(content)
|
||
|
||
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)
|
||
|
||
segment.narration = narration
|
||
segment.visual_prompt = visual
|
||
segment.save(update_fields=["narration", "visual_prompt", "updated_at"])
|
||
return segment.script_version
|
||
except Exception as exc:
|
||
with transaction.atomic():
|
||
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
|
||
|
||
|
||
def _generate_video_poster(*, video_bytes: bytes, team, project, asset_id) -> "StoredObject | None":
|
||
"""用 ffmpeg 抽视频首帧作为封面(poster)并上传 TOS。best-effort:任何失败都返回 None,不影响视频资产落地。"""
|
||
if not video_bytes:
|
||
return None
|
||
try:
|
||
with tempfile.TemporaryDirectory(prefix="airshelf-poster-") as tmp:
|
||
tmp_dir = Path(tmp)
|
||
video_path = tmp_dir / "in.mp4"
|
||
poster_path = tmp_dir / "poster.jpg"
|
||
video_path.write_bytes(video_bytes)
|
||
proc = subprocess.run(
|
||
["ffmpeg", "-y", "-ss", "0", "-i", str(video_path), "-frames:v", "1", "-q:v", "3", str(poster_path)],
|
||
capture_output=True,
|
||
timeout=60,
|
||
)
|
||
if proc.returncode != 0 or not poster_path.exists():
|
||
return None
|
||
poster_bytes = poster_path.read_bytes()
|
||
if not poster_bytes:
|
||
return None
|
||
object_key = f"teams/{team.id}/projects/{project.id}/generated/{asset_id}-poster.jpg"
|
||
return TosStorage().upload_fileobj(
|
||
fileobj=BytesIO(poster_bytes), object_key=object_key, content_type="image/jpeg"
|
||
)
|
||
except Exception: # noqa: BLE001 — poster 仅用于展示,失败不阻断
|
||
return None
|
||
|
||
|
||
def _store_generated_media(*, team, user, project, task, media: str, name: str, category: str, asset_type: str) -> Asset:
|
||
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
|
||
suffix = ".png"
|
||
if "video" in content_type:
|
||
suffix = ".mp4"
|
||
elif "jpeg" in content_type:
|
||
suffix = ".jpg"
|
||
elif "webp" in content_type:
|
||
suffix = ".webp"
|
||
asset_id = uuid.uuid4()
|
||
object_key = f"teams/{team.id}/projects/{project.id}/generated/{asset_id}{suffix}"
|
||
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type)
|
||
asset = Asset.objects.create(
|
||
id=asset_id,
|
||
team=team,
|
||
created_by=user,
|
||
name=name,
|
||
asset_type=asset_type,
|
||
source=Asset.Source.AI_GENERATED,
|
||
category=category,
|
||
origin_task=task,
|
||
)
|
||
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,
|
||
)
|
||
# 视频资产:额外抽首帧作为封面图,挂成同一 Asset 下的 image 文件,供任务中心/列表显示缩略图
|
||
if "video" in content_type:
|
||
try:
|
||
video_bytes = fileobj.getvalue() if isinstance(fileobj, BytesIO) else b""
|
||
except Exception: # noqa: BLE001
|
||
video_bytes = b""
|
||
poster = _generate_video_poster(video_bytes=video_bytes, team=team, project=project, asset_id=asset_id)
|
||
if poster:
|
||
AssetFile.objects.create(
|
||
asset=asset,
|
||
object_key=poster.object_key,
|
||
bucket=poster.bucket,
|
||
content_type=poster.content_type,
|
||
size_bytes=poster.size_bytes,
|
||
is_primary=False,
|
||
)
|
||
return asset
|
||
|
||
|
||
def _find_entity_group(project, kind: str, label: str, group_id: str | None):
|
||
"""复用同一实体的基础资产组(版本=candidate_assets,采用=adopted_asset)。
|
||
优先 group_id;商品=该项目唯一商品组;人物/场景按 label 命中非三视图组;都没有则 None(新建)。"""
|
||
if group_id:
|
||
return project.base_asset_groups.filter(id=group_id).first()
|
||
candidates = list(project.base_asset_groups.filter(kind=kind).order_by("created_at"))
|
||
candidates = [g for g in candidates if not (g.metadata or {}).get("triview_of")] # 三视图组不算实体本体
|
||
if kind == BaseAssetGroup.Kind.PRODUCT:
|
||
return candidates[0] if candidates else None
|
||
lbl = (label or "").strip()
|
||
if lbl:
|
||
return next((g for g in candidates if (g.metadata or {}).get("label") == lbl), None)
|
||
return None
|
||
|
||
|
||
def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "", group_id: str | None = None) -> BaseAssetGroup:
|
||
model_config = get_default_model(ModelConfig.Capability.IMAGE)
|
||
if model_config is None:
|
||
raise ValueError("no active image model configured")
|
||
payload = {"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "kind": kind}
|
||
task = create_ai_task(
|
||
project=project,
|
||
user=user,
|
||
task_type={
|
||
BaseAssetGroup.Kind.PRODUCT: AITask.Type.PRODUCT_IMAGE,
|
||
BaseAssetGroup.Kind.PERSON: AITask.Type.PERSON_IMAGE,
|
||
BaseAssetGroup.Kind.SCENE: AITask.Type.SCENE_IMAGE,
|
||
}[kind],
|
||
model_config=model_config,
|
||
request_payload=payload,
|
||
)
|
||
reservation = task.credit_reservation
|
||
try:
|
||
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():
|
||
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)
|
||
category = {
|
||
BaseAssetGroup.Kind.PRODUCT: Asset.Category.PRODUCT_IMAGE,
|
||
BaseAssetGroup.Kind.PERSON: Asset.Category.PERSON,
|
||
BaseAssetGroup.Kind.SCENE: Asset.Category.SCENE,
|
||
}[kind]
|
||
asset = _store_generated_media(
|
||
team=project.team,
|
||
user=user,
|
||
project=project,
|
||
task=task,
|
||
media=media,
|
||
name=f"{project.name}-{kind}",
|
||
category=category,
|
||
asset_type=Asset.Type.IMAGE,
|
||
)
|
||
# 复用同实体的组:追加候选 + 采用最新(版本=candidate_assets,采用=adopted_asset);无则新建
|
||
group = _find_entity_group(project, kind, label, group_id)
|
||
if group is None:
|
||
group_meta = {"label": label.strip()} if label and label.strip() else {}
|
||
group = BaseAssetGroup.objects.create(project=project, kind=kind, task=task, prompt=prompt, metadata=group_meta)
|
||
elif prompt:
|
||
group.prompt = prompt
|
||
group.candidate_assets.add(asset)
|
||
group.adopted_asset = asset
|
||
group.save(update_fields=["adopted_asset", "prompt", "updated_at"])
|
||
# 真人资产:事务提交后静默送火山审核(best-effort,网络调用放 on_commit 避免占着事务)
|
||
if kind == BaseAssetGroup.Kind.PERSON:
|
||
from apps.assets.review import submit_asset_for_review
|
||
|
||
transaction.on_commit(lambda a=asset: submit_asset_for_review(a))
|
||
return group
|
||
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
|
||
|
||
|
||
def generate_person_triview(*, project, user, portrait_asset) -> "BaseAssetGroup":
|
||
"""流程步骤4 · 据「某一版立绘资产」生成它配套的三视图(image_edit 以立绘为参考,锁角色一致性)。
|
||
三视图与立绘 1:1 绑定:metadata.triview_of=<立绘 asset id>;同一立绘多次=同组追加候选(版本)。"""
|
||
from apps.ai.model_library import THREE_VIEW_PROMPT
|
||
|
||
if portrait_asset is None:
|
||
raise ValueError("该立绘尚未生成,无法据它生成三视图")
|
||
asset_key = str(portrait_asset.id)
|
||
model_config = get_default_model(ModelConfig.Capability.IMAGE)
|
||
if model_config is None:
|
||
raise ValueError("no active image model configured")
|
||
provider = get_image_provider(model_config)
|
||
if not hasattr(provider, "image_edit"):
|
||
raise ValueError(f"当前图像模型 {model_config.provider.name}:{model_config.name} 不支持参考图三视图(image_edit)")
|
||
ref_url = _asset_preview_url(portrait_asset)
|
||
payload = {"model": model_config.name, "prompt": THREE_VIEW_PROMPT, "kind": "person", "triview_of": asset_key}
|
||
task = create_ai_task(project=project, user=user, task_type=AITask.Type.PERSON_IMAGE, model_config=model_config, request_payload=payload)
|
||
reservation = task.credit_reservation
|
||
try:
|
||
response = provider.image_edit(model=model_config.name, prompt=THREE_VIEW_PROMPT, images=[ref_url], size="1536x1024")
|
||
media = provider.extract_first_media_url(response)
|
||
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)
|
||
asset = _store_generated_media(
|
||
team=project.team, user=user, project=project, task=task, media=media,
|
||
name=f"{project.name}-三视图", category=Asset.Category.PERSON, asset_type=Asset.Type.IMAGE,
|
||
)
|
||
# 复用该立绘的三视图组(triview_of==立绘asset id):追加候选 + 采用最新
|
||
group = next((g for g in project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PERSON).order_by("created_at")
|
||
if (g.metadata or {}).get("triview_of") == asset_key), None)
|
||
if group is None:
|
||
group = BaseAssetGroup.objects.create(
|
||
project=project, kind=BaseAssetGroup.Kind.PERSON, task=task, prompt=THREE_VIEW_PROMPT,
|
||
metadata={"label": "·三视图", "triview_of": asset_key},
|
||
)
|
||
group.candidate_assets.add(asset)
|
||
group.adopted_asset = asset
|
||
group.save(update_fields=["adopted_asset", "updated_at"])
|
||
return group
|
||
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
|
||
|
||
|
||
def _scene_context(project) -> str:
|
||
"""从商品 + 已采用基础资产提炼一句「风格锚点」,贯穿故事板 / 视频,保证各镜内容一致。"""
|
||
product = project.product
|
||
parts = [f"商品:{product.title}"]
|
||
if product.brand:
|
||
parts.append(f"品牌:{product.brand}")
|
||
if product.category:
|
||
parts.append(f"类目:{product.category}")
|
||
if getattr(product, "target_audience", ""):
|
||
parts.append(f"人群:{product.target_audience}")
|
||
adopted_kinds = set(
|
||
project.base_asset_groups.filter(adopted_asset__isnull=False).values_list("kind", flat=True)
|
||
)
|
||
if BaseAssetGroup.Kind.PERSON in adopted_kinds:
|
||
parts.append("真人出镜,保持人物一致")
|
||
if BaseAssetGroup.Kind.SCENE in adopted_kinds:
|
||
parts.append("统一场景与色调")
|
||
return " · ".join(parts)
|
||
|
||
|
||
def build_storyboard_frame_prompt(project, version, segment) -> str:
|
||
"""单帧故事板提示词:风格锚点 + 本镜画面(回退旁白)+ 版本统一指令。"""
|
||
visual = (segment.visual_prompt or segment.narration or "").strip()
|
||
lines = [
|
||
_scene_context(project),
|
||
f"第 {segment.sort_order + 1} 镜画面:{visual}" if visual else f"第 {segment.sort_order + 1} 镜",
|
||
]
|
||
if version.prompt:
|
||
lines.append(version.prompt.strip())
|
||
lines.append("电商竖屏分镜图,构图清晰,可直接指导视频生成")
|
||
return "\n".join(line for line in lines if line)
|
||
|
||
|
||
def build_video_segment_prompt(project, video_segment, scene, user_prompt: str) -> str:
|
||
"""单段视频提示词:把本镜旁白 + 画面 + 风格锚点织进去,让每个视频片段跟住对应脚本/故事板。"""
|
||
lines = [_scene_context(project)]
|
||
if scene is not None:
|
||
if scene.narration:
|
||
lines.append(f"旁白:{scene.narration.strip()}")
|
||
visual = (scene.visual_prompt or scene.narration or "").strip()
|
||
if visual:
|
||
lines.append(f"画面:{visual}")
|
||
if user_prompt:
|
||
lines.append(user_prompt.strip())
|
||
lines.append(
|
||
f"第 {video_segment.sort_order + 1} 段 · {video_segment.target_duration_seconds}s · "
|
||
"9:16 竖屏电商带货短视频,镜头稳定,商品露出清晰,节奏有转化感"
|
||
)
|
||
return "\n".join(line for line in lines if line)
|
||
|
||
|
||
def submit_storyboard(*, project, user, prompt: str = "") -> StoryboardVersion:
|
||
"""异步故事板·提交:快速创建(或复用)一个未采用的版本,不在此处生图。逐帧生成交给 generate_storyboard_frame(轮询)。"""
|
||
adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
|
||
if adopted_script is None:
|
||
raise ValueError("script must be adopted before generating storyboard")
|
||
if get_default_model(ModelConfig.Capability.IMAGE) is None:
|
||
raise ValueError("no active image model configured")
|
||
# 复用尚未完成(未采用)的版本,避免重复提交产生多版本;否则新建
|
||
version = project.storyboard_versions.filter(is_adopted=False).order_by("-created_at").first()
|
||
if version is None:
|
||
version = StoryboardVersion.objects.create(project=project, prompt=prompt)
|
||
elif prompt and version.prompt != prompt:
|
||
version.prompt = prompt
|
||
version.save(update_fields=["prompt", "updated_at"])
|
||
return version
|
||
|
||
|
||
_ENTITY_TYPE_CN = {"character": "角色", "scene": "场景", "product": "商品"}
|
||
|
||
|
||
def _storyboard_reference_images(project, segment) -> list[dict]:
|
||
"""按本镜 entity_refs 取参考图(角色/场景/商品的已采用基础资产),供 gpt-image-2 多图合成 @图N。
|
||
返回 [{url,label,type}],最多 4 张;无匹配时兜底商品组。依赖脚本 agent 落进 metadata 的 script_entities。"""
|
||
entities = {
|
||
e.get("id"): e
|
||
for e in (project.metadata or {}).get("script_entities", [])
|
||
if isinstance(e, dict)
|
||
}
|
||
kind_by_type = {
|
||
"character": BaseAssetGroup.Kind.PERSON,
|
||
"scene": BaseAssetGroup.Kind.SCENE,
|
||
"product": BaseAssetGroup.Kind.PRODUCT,
|
||
}
|
||
groups = list(project.base_asset_groups.filter(adopted_asset__isnull=False).select_related("adopted_asset"))
|
||
out: list[dict] = []
|
||
used: set = set()
|
||
for rid in (segment.entity_refs or []):
|
||
ent = entities.get(rid)
|
||
if not ent:
|
||
continue
|
||
kind = kind_by_type.get(ent.get("type"))
|
||
name = (ent.get("name") or "").strip()
|
||
match = next(
|
||
(g for g in groups if g.kind == kind and (g.metadata or {}).get("label", "").strip() == name and g.id not in used),
|
||
None,
|
||
) or next((g for g in groups if g.kind == kind and g.id not in used), None)
|
||
if match:
|
||
used.add(match.id)
|
||
url = _asset_preview_url(match.adopted_asset)
|
||
if url:
|
||
out.append({"url": url, "label": name or _ENTITY_TYPE_CN.get(ent.get("type"), "参考"), "type": ent.get("type")})
|
||
if len(out) >= 4:
|
||
break
|
||
if not out:
|
||
pg = next((g for g in groups if g.kind == BaseAssetGroup.Kind.PRODUCT), None)
|
||
if pg:
|
||
url = _asset_preview_url(pg.adopted_asset)
|
||
if url:
|
||
out.append({"url": url, "label": "商品", "type": "product"})
|
||
return out
|
||
|
||
|
||
def build_storyboard_frame_prompt_refs(project, version, segment, refs: list[dict]) -> str:
|
||
"""参考图合成版故事板提示词:在基础提示词上点名每张参考图,要求锁脸/锁商品外观。"""
|
||
base = build_storyboard_frame_prompt(project, version, segment)
|
||
if not refs:
|
||
return base
|
||
ref_lines = ";".join(
|
||
f"参考图{i + 1}={r['label']}({_ENTITY_TYPE_CN.get(r.get('type'), '参考')})" for i, r in enumerate(refs)
|
||
)
|
||
return (
|
||
f"{base}\n参考图对应:{ref_lines}。"
|
||
"请严格保持各参考图中角色的同一张脸、同一商品的外观与配色,按本镜画面重新构图合成为一张电商竖屏分镜图。"
|
||
)
|
||
|
||
|
||
def _storyboard_frame_worker(task_id, version_id, segment_id, user_id) -> None:
|
||
"""后台线程:真正调 ARK 生成一帧故事板图并落库。每次 poll 不阻塞在此——HTTP 永远秒回。"""
|
||
import threading # noqa: F401 — 仅标注此函数运行在独立线程
|
||
from django.db import connections
|
||
|
||
from apps.accounts.models import User
|
||
|
||
try:
|
||
task = AITask.objects.select_related("model_config__provider").get(id=task_id)
|
||
version = StoryboardVersion.objects.select_related("project__team").get(id=version_id)
|
||
segment = ScriptSegment.objects.get(id=segment_id)
|
||
user = User.objects.get(id=user_id)
|
||
project = version.project
|
||
model_config = task.model_config
|
||
reservation = task.credit_reservation
|
||
task.status = AITask.Status.SUBMITTED
|
||
task.save(update_fields=["status", "updated_at"])
|
||
try:
|
||
provider = get_image_provider(model_config)
|
||
refs = _storyboard_reference_images(project, segment)
|
||
ref_urls = [r["url"] for r in refs]
|
||
if ref_urls and hasattr(provider, "image_edit"):
|
||
# gpt-image-2 多图参考:必须用 refs 版提示词(点名「参考图N=角色/场景/商品」+锁脸锁商品),
|
||
# 不能复用 request_payload['prompt'](那是建任务时写死的基础提示词,恒为真值会架空一致性约束)。
|
||
frame_prompt = build_storyboard_frame_prompt_refs(project, version, segment, refs)
|
||
response = provider.image_edit(
|
||
model=model_config.name,
|
||
prompt=frame_prompt,
|
||
images=ref_urls,
|
||
size="1024x1536",
|
||
)
|
||
else:
|
||
frame_prompt = task.request_payload.get("prompt") or build_storyboard_frame_prompt(project, version, segment)
|
||
response = provider.image_generation(
|
||
model=model_config.name,
|
||
endpoint=model_config.endpoint,
|
||
prompt=frame_prompt,
|
||
)
|
||
media = provider.extract_first_media_url(response)
|
||
# 注意顺序:task 是 poll 端的「占位锁」,必须等帧真正落库后才置 SUCCEEDED。
|
||
# 旧实现先置 SUCCEEDED 再上传 TOS(数秒)最后建帧,中间窗口 poll 会判「无在途且帧缺失」
|
||
# 为同一镜重复起线程 → 重复帧 + 重复扣费(实测 4 帧出 6 帧)。
|
||
asset = _store_generated_media(
|
||
team=project.team,
|
||
user=user,
|
||
project=project,
|
||
task=task,
|
||
media=media,
|
||
name=f"{project.name}-storyboard-{segment.sort_order + 1}",
|
||
category=Asset.Category.SCENE,
|
||
asset_type=Asset.Type.IMAGE,
|
||
)
|
||
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)
|
||
task.completed_at = timezone.now()
|
||
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||
release_credit(reservation=reservation, reason=str(exc))
|
||
finally:
|
||
connections.close_all() # 释放该线程的 DB 连接
|
||
|
||
|
||
def generate_storyboard_frame(*, project, user) -> dict:
|
||
"""异步故事板·轮询(秒回):读取进度;若无帧在生成则后台起线程生成下一帧。永不阻塞在 ARK 调用上。
|
||
返回 {status: generating|succeeded|failed, done, total, version_id}。全部完成→采用版本。"""
|
||
import threading
|
||
|
||
version = project.storyboard_versions.filter(is_adopted=False).order_by("-created_at").first()
|
||
adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
|
||
if version is None or adopted_script is None:
|
||
latest = project.storyboard_versions.order_by("-created_at").first()
|
||
n = latest.frames.count() if latest else 0
|
||
return {"status": "succeeded", "done": n, "total": n, "version_id": str(latest.id) if latest else ""}
|
||
|
||
segments = list(adopted_script.segments.all().order_by("sort_order"))
|
||
total = len(segments)
|
||
done_segment_ids = set(version.frames.values_list("script_segment_id", flat=True))
|
||
done = len(done_segment_ids)
|
||
|
||
if done >= total:
|
||
_finalize_storyboard(project, version)
|
||
return {"status": "succeeded", "done": total, "total": total, "version_id": str(version.id)}
|
||
|
||
# 每镜独立「占位锁」(该镜有 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_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]
|
||
# 单帧失败次数上限,避免持续失败时无限重试;任一镜到上限即整版上报失败
|
||
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)
|
||
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)}
|
||
|
||
|
||
def _finalize_storyboard(project, version) -> None:
|
||
"""全部帧就绪:采用该版本(反采用其余版本)。项目阶段推进由视图负责(与原同步实现一致)。"""
|
||
project.storyboard_versions.exclude(id=version.id).update(is_adopted=False)
|
||
if not version.is_adopted:
|
||
version.is_adopted = True
|
||
version.save(update_fields=["is_adopted", "updated_at"])
|
||
|
||
|
||
def _asset_preview_url(asset) -> str:
|
||
"""资产主文件的可公开访问 URL(已写绝对 URL 优先,否则实时签 TOS GET)。"""
|
||
if asset is None:
|
||
return ""
|
||
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
|
||
if primary is None:
|
||
return ""
|
||
if primary.preview_url:
|
||
return primary.preview_url
|
||
try:
|
||
return TosStorage().presigned_get_url(object_key=primary.object_key)
|
||
except Exception:
|
||
return ""
|
||
|
||
|
||
def _video_reference_images(project, video_segment) -> list[str]:
|
||
"""为本视频段挑一张视觉参考图:优先本镜故事板帧,兜底已采用商品基础资产。"""
|
||
version = (
|
||
project.storyboard_versions.filter(is_adopted=True).order_by("-created_at").first()
|
||
or project.storyboard_versions.order_by("-created_at").first()
|
||
)
|
||
if version is not None:
|
||
frame = (
|
||
version.frames.filter(sort_order=video_segment.sort_order).first()
|
||
or version.frames.order_by("sort_order").first()
|
||
)
|
||
if frame is not None:
|
||
url = _asset_preview_url(frame.asset)
|
||
if url:
|
||
return [url]
|
||
product_group = (
|
||
project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PRODUCT, adopted_asset__isnull=False)
|
||
.order_by("-created_at")
|
||
.first()
|
||
)
|
||
if product_group is not None:
|
||
url = _asset_preview_url(product_group.adopted_asset)
|
||
if url:
|
||
return [url]
|
||
return []
|
||
|
||
|
||
def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> VideoSegmentVersion | None:
|
||
model_config = get_default_model(ModelConfig.Capability.VIDEO)
|
||
if model_config is None:
|
||
raise ValueError("no active video model configured")
|
||
project = video_segment.project
|
||
|
||
# 衔接:按 sort_order 把视频段绑到对应脚本镜,并织出跟住该镜的提示词。
|
||
scene = None
|
||
adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
|
||
if adopted_script is not None:
|
||
scene = adopted_script.segments.filter(sort_order=video_segment.sort_order).first()
|
||
if scene is not None and video_segment.script_segment_id != scene.id:
|
||
video_segment.script_segment = scene
|
||
video_segment.save(update_fields=["script_segment", "updated_at"])
|
||
final_prompt = build_video_segment_prompt(project, video_segment, scene, prompt)
|
||
|
||
# 参考图:优先用本镜故事板帧,其次商品/人物基础资产,给视频做视觉锚点(衔接故事板→视频)。
|
||
reference_images = _video_reference_images(project, video_segment)
|
||
|
||
task = create_ai_task(
|
||
project=project,
|
||
user=user,
|
||
task_type=AITask.Type.VIDEO_SEGMENT,
|
||
model_config=model_config,
|
||
request_payload={
|
||
"model": model_config.name,
|
||
"endpoint": model_config.endpoint,
|
||
"prompt": final_prompt,
|
||
"duration": video_segment.target_duration_seconds,
|
||
"ratio": "9:16",
|
||
"video_segment_id": str(video_segment.id),
|
||
"reference_images": reference_images,
|
||
},
|
||
)
|
||
try:
|
||
provider = build_provider(model_config)
|
||
try:
|
||
response = provider.create_video_task(
|
||
model=model_config.name,
|
||
endpoint=model_config.endpoint,
|
||
prompt=final_prompt,
|
||
duration=video_segment.target_duration_seconds,
|
||
ratio="9:16",
|
||
resolution="720p",
|
||
reference_images=reference_images or None,
|
||
)
|
||
except Exception:
|
||
# 降级:带参考图被拒时退回纯文生视频(文本里已含本镜旁白/画面,衔接不丢)
|
||
if not reference_images:
|
||
raise
|
||
response = provider.create_video_task(
|
||
model=model_config.name,
|
||
endpoint=model_config.endpoint,
|
||
prompt=final_prompt,
|
||
duration=video_segment.target_duration_seconds,
|
||
ratio="9:16",
|
||
resolution="720p",
|
||
reference_images=None,
|
||
)
|
||
task.provider_task_id = str(response.get("id") or response.get("task_id") or "")
|
||
task.response_payload = response
|
||
task.status = AITask.Status.SUBMITTED
|
||
task.submitted_at = timezone.now()
|
||
task.save(update_fields=["provider_task_id", "response_payload", "status", "submitted_at", "updated_at"])
|
||
video_segment.status = VideoSegment.Status.RUNNING
|
||
video_segment.save(update_fields=["status", "updated_at"])
|
||
return None
|
||
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=task.credit_reservation, reason=str(exc))
|
||
video_segment.status = VideoSegment.Status.FAILED
|
||
video_segment.error_message = str(exc)
|
||
video_segment.save(update_fields=["status", "error_message", "updated_at"])
|
||
raise
|
||
|
||
|
||
def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVersion | None:
|
||
# 幂等:已完成的段直接回采用版;已失败的段不再 poll。避免对已成功 task 再 poll → 二次建版 / 二次扣费。
|
||
if video_segment.status == VideoSegment.Status.SUCCEEDED:
|
||
return video_segment.adopted_version or video_segment.versions.order_by("-created_at").first()
|
||
if video_segment.status == VideoSegment.Status.FAILED:
|
||
return None
|
||
|
||
# ★ 先找「在途任务」再回退旧版本的任务。旧实现反过来:重跑时段上已有(旧)版本,
|
||
# 取到旧版本挂的已成功任务 → 短路返回旧版,在途的新任务永远没人轮询,
|
||
# 段永远卡「生成中」、新视频取不回来(实测重跑卡 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:
|
||
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")
|
||
|
||
# task 已终态(可能被并发的 worker / 另一次 poll 处理过):直接回已有版,不再调 ARK。
|
||
if ai_task.status == AITask.Status.SUCCEEDED:
|
||
return video_segment.versions.filter(task=ai_task).order_by("-created_at").first()
|
||
if ai_task.status in (AITask.Status.FAILED, AITask.Status.CANCELLED):
|
||
return None
|
||
|
||
provider = build_provider(ai_task.model_config)
|
||
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"}:
|
||
# 仍在生成:只在状态首次进入 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
|
||
ai_task.response_payload = response
|
||
ai_task.error_message = response.get("error", {}).get("message", "video generation failed")
|
||
ai_task.completed_at = timezone.now()
|
||
ai_task.save(update_fields=["status", "response_payload", "error_message", "completed_at", "updated_at"])
|
||
release_credit(reservation=ai_task.credit_reservation, reason=ai_task.error_message)
|
||
video_segment.status = VideoSegment.Status.FAILED
|
||
video_segment.error_message = ai_task.error_message
|
||
video_segment.save(update_fields=["status", "error_message", "updated_at"])
|
||
return None
|
||
|
||
media = provider.extract_first_media_url(response)
|
||
asset = _store_generated_media(
|
||
team=video_segment.project.team,
|
||
user=user,
|
||
project=video_segment.project,
|
||
task=ai_task,
|
||
media=media,
|
||
name=f"{video_segment.project.name}-segment-{video_segment.sort_order + 1}",
|
||
category=Asset.Category.VIDEO_CLIP,
|
||
asset_type=Asset.Type.VIDEO,
|
||
)
|
||
# 终态化必须持锁原子做:两个并发 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
|
||
|
||
|
||
def create_export_job(*, timeline, user) -> ExportJob:
|
||
return ExportJob.objects.create(timeline=timeline, status=ExportJob.Status.QUEUED)
|
||
|
||
|
||
_STANDALONE_CATEGORY = {
|
||
"model": Asset.Category.PERSON,
|
||
"cover": Asset.Category.PRODUCT_IMAGE,
|
||
"image": Asset.Category.PRODUCT_IMAGE,
|
||
}
|
||
_STANDALONE_TASK_TYPE = {
|
||
"model": AITask.Type.PERSON_IMAGE,
|
||
"cover": AITask.Type.PRODUCT_IMAGE,
|
||
"image": AITask.Type.PRODUCT_IMAGE,
|
||
}
|
||
|
||
|
||
def _reap_stale_standalone_image_tasks(*, team) -> None:
|
||
"""兜底:worker 崩溃/重启(OOM、部署)可能留下卡在 RESERVED 的出图任务,额度被一直占住、
|
||
前端轮询也永远等不到结果。超过 10 分钟(远大于单张真实出图耗时 ~60s)仍 RESERVED 的判为僵尸:
|
||
标记失败并退还预留额度。趁每次新提交时顺手回收,无需额外的定时任务(与导出僵尸清理同思路)。"""
|
||
cutoff = timezone.now() - timedelta(minutes=10)
|
||
stale = AITask.objects.filter(
|
||
team=team,
|
||
project__isnull=True,
|
||
task_type__in=[AITask.Type.PERSON_IMAGE, AITask.Type.PRODUCT_IMAGE],
|
||
status=AITask.Status.RESERVED,
|
||
updated_at__lt=cutoff,
|
||
)
|
||
for task in stale:
|
||
try:
|
||
with transaction.atomic():
|
||
task.status = AITask.Status.FAILED
|
||
task.error_message = "worker 未在预期时间内完成(僵尸任务自动回收)"
|
||
task.completed_at = timezone.now()
|
||
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
|
||
try:
|
||
reservation = task.credit_reservation
|
||
except ObjectDoesNotExist:
|
||
reservation = None
|
||
if reservation is not None:
|
||
release_credit(reservation=reservation, reason="僵尸出图任务自动回收")
|
||
except Exception: # noqa: BLE001 — 单个回收失败不应阻断新任务提交
|
||
continue
|
||
|
||
|
||
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1) -> list[AITask]:
|
||
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
|
||
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
|
||
|
||
这样 Web 层(gunicorn)不会被慢出图请求占住 worker → 健康探针不会被饿死 → 根治"几张图就整站 502"。
|
||
且任务一旦提交(额度已预留),浏览器关掉 / 断网都不影响——worker 照样把图生成并落库,扣费/退费在
|
||
worker 内闭环。返回已 RESERVED 的 AITask 列表,前端拿 id 轮询 GET /api/ai/generate-image/?ids=… 取结果。"""
|
||
from apps.ai.tasks import generate_standalone_image_task
|
||
|
||
_reap_stale_standalone_image_tasks(team=team)
|
||
model_config = get_default_model(ModelConfig.Capability.IMAGE)
|
||
if model_config is None:
|
||
raise ValueError("no active image model configured")
|
||
task_type = _STANDALONE_TASK_TYPE.get(mode, AITask.Type.PRODUCT_IMAGE)
|
||
count = max(1, min(int(count or 1), 12))
|
||
tasks: list[AITask] = []
|
||
for index in range(count):
|
||
cost = estimate_cost(model_config)
|
||
task = AITask.objects.create(
|
||
team=team,
|
||
created_by=user,
|
||
project=None,
|
||
task_type=task_type,
|
||
status=AITask.Status.CREATED,
|
||
model_config=model_config,
|
||
idempotency_key=f"standalone-image:{team.id}:{uuid.uuid4()}",
|
||
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index},
|
||
estimated_cost=cost,
|
||
)
|
||
# 预留额度若余额不足会抛 ValueError,在同步的 Web 请求里立刻反馈给前端(不会先建半套任务)
|
||
reserve_credit(team=team, user=user, task=task, amount=cost)
|
||
task.status = AITask.Status.RESERVED
|
||
task.save(update_fields=["status", "updated_at"])
|
||
tasks.append(task)
|
||
# 额度都预留成功后再统一派发,避免"派发了任务但后面某张预留失败"的半成品状态
|
||
for task in tasks:
|
||
generate_standalone_image_task.delay(str(task.id))
|
||
return tasks
|
||
|
||
|
||
def run_standalone_image_task(*, task_id: str) -> None:
|
||
"""Celery worker 内执行**单张**图的慢活:调 ARK → 成功落库扣费 / 失败退费。
|
||
幂等:只处理 RESERVED 状态的任务,重复投递(celery retry / 重启重放)不会二次出图、二次扣费。"""
|
||
task = AITask.objects.select_related("team", "created_by", "model_config").filter(id=task_id).first()
|
||
if task is None or task.status != AITask.Status.RESERVED:
|
||
return
|
||
team = task.team
|
||
user = task.created_by
|
||
payload = task.request_payload or {}
|
||
prompt = str(payload.get("prompt") or "")
|
||
mode = str(payload.get("mode") or "image")
|
||
index = int(payload.get("index") or 0)
|
||
category = _STANDALONE_CATEGORY.get(mode, Asset.Category.UNCATEGORIZED)
|
||
model_config = task.model_config
|
||
provider = get_image_provider(model_config)
|
||
reservation = task.credit_reservation
|
||
try:
|
||
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
|
||
media = provider.extract_first_media_url(response)
|
||
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)
|
||
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
|
||
suffix = ".jpg" if "jpeg" in content_type else (".webp" if "webp" in content_type else ".png")
|
||
asset_id = uuid.uuid4()
|
||
object_key = f"teams/{team.id}/standalone/{asset_id}{suffix}"
|
||
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type)
|
||
asset = Asset.objects.create(
|
||
id=asset_id, team=team, created_by=user, name=f"AI 生成 · {mode} · {index + 1}",
|
||
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=category, origin_task=task,
|
||
)
|
||
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)
|
||
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
|
||
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))
|
||
|
||
|
||
# ── 旁白配音(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
|