diff --git a/core/backend/.env b/core/backend/.env index 3c194c0..2603af2 100644 --- a/core/backend/.env +++ b/core/backend/.env @@ -22,3 +22,9 @@ TOS_SECRET_ACCESS_KEY=TWpjNVpqVm1NbVkzTWprNE5ESXlZMkUyT1dNNFlqVmtaRGRoTVdNME5qRQ VOLCANO_ARK_API_KEY=ark-24d5627e-28e4-4412-8679-46a6e9f26aab-6e951 VOLCANO_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 DEFAULT_TRIAL_CREDITS=1000.0000 +YUNQI_API_KEY=sk-xdP2iy5kzmehinLkI1lxV2BmpGSXma2wvKbSVP3tZBPHH6zf +YUNQI_BASE_URL=https://www.yunqiai.chat/v1 + +# 豆包语音合成(旁白配音 TTS)· 火山控制台-语音技术-语音合成 +VOLC_TTS_APPID=8945759494 +VOLC_TTS_ACCESS_TOKEN=w7Ye8FdTHADU05PV5cVNjud8FseOnYzR diff --git a/core/backend/.env.example b/core/backend/.env.example index 2437815..d8a9104 100644 --- a/core/backend/.env.example +++ b/core/backend/.env.example @@ -24,3 +24,5 @@ TOS_SECRET_ACCESS_KEY=change-me VOLCANO_ARK_API_KEY=change-me VOLCANO_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3 +YUNQI_API_KEY=change-me +YUNQI_BASE_URL=https://www.yunqiai.chat/v1 diff --git a/core/backend/airshelf/settings/base.py b/core/backend/airshelf/settings/base.py index ab4ec9e..8205e93 100644 --- a/core/backend/airshelf/settings/base.py +++ b/core/backend/airshelf/settings/base.py @@ -172,4 +172,18 @@ VOLCANO = { "ark_base_url": env("VOLCANO_ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"), } +# 豆包语音合成(旁白配音 TTS)。与 ARK 不是同一套钥匙: +# 火山引擎控制台 → 语音技术 → 语音合成大模型 → 创建应用,取 APPID 和 Access Token +VOLC_TTS = { + "appid": env("VOLC_TTS_APPID", ""), + "access_token": env("VOLC_TTS_ACCESS_TOKEN", ""), + "cluster": env("VOLC_TTS_CLUSTER", "volcano_tts"), + "base_url": env("VOLC_TTS_BASE_URL", "https://openspeech.bytedance.com/api/v1/tts"), +} + +YUNQI = { + "api_key": env("YUNQI_API_KEY"), + "base_url": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"), +} + DEFAULT_TRIAL_CREDITS = env("DEFAULT_TRIAL_CREDITS", "100.0000") diff --git a/core/backend/apps/accounts/serializers.py b/core/backend/apps/accounts/serializers.py index b1dfc0e..4a23ac4 100644 --- a/core/backend/apps/accounts/serializers.py +++ b/core/backend/apps/accounts/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from apps.billing.models import CreditAccount +from apps.billing.models import CreditAccount, CreditLedger from .models import LoginSession, Team, TeamMember, User, UserPreference @@ -82,10 +82,19 @@ class RegisterSerializer(serializers.Serializer): owner=user, ) TeamMember.objects.create(team=team, user=user, role=TeamMember.Role.OWNER) - CreditAccount.objects.create( - team=team, - balance=Decimal(str(settings.DEFAULT_TRIAL_CREDITS)), - ) + trial = Decimal(str(settings.DEFAULT_TRIAL_CREDITS)) + CreditAccount.objects.create(team=team, balance=trial) + # 开户赠送额度必须进流水,否则这笔钱在账单里查无凭证、无法对账(余额从第一条起就可追溯) + if trial > 0: + CreditLedger.objects.create( + team=team, + user=user, + ledger_type=CreditLedger.Type.RECHARGE, + amount=trial, + balance_after=trial, + reason="新用户试用额度赠送", + metadata={"kind": "trial_grant"}, + ) return {"user": user, "team": team} diff --git a/core/backend/apps/accounts/tests.py b/core/backend/apps/accounts/tests.py index 836e8ee..f0a4a7e 100644 --- a/core/backend/apps/accounts/tests.py +++ b/core/backend/apps/accounts/tests.py @@ -1,8 +1,11 @@ +from decimal import Decimal + +from django.conf import settings from django.test import TestCase from rest_framework.test import APIClient from apps.accounts.models import Team, TeamMember, User -from apps.billing.models import CreditAccount +from apps.billing.models import CreditAccount, CreditLedger class AuthApiTests(TestCase): @@ -27,3 +30,20 @@ class AuthApiTests(TestCase): self.assertTrue(TeamMember.objects.filter(team=team, user=user, role=TeamMember.Role.OWNER).exists()) self.assertTrue(CreditAccount.objects.filter(team=team).exists()) + def test_register_trial_grant_is_recorded_in_ledger(self): + """开户赠送额度必须进流水:余额=赠送额,且有一条 balance_after=赠送额的 genesis 流水,账目从第一条起可对账。""" + client = APIClient() + client.post( + "/api/auth/register/", + {"username": "ledger-owner", "password": "strong-password", "team_name": "Ledger Team"}, + format="json", + ) + team = Team.objects.get(name="Ledger Team") + trial = Decimal(str(settings.DEFAULT_TRIAL_CREDITS)) + account = CreditAccount.objects.get(team=team) + self.assertEqual(account.balance, trial) + genesis = CreditLedger.objects.filter(team=team, ledger_type=CreditLedger.Type.RECHARGE) + self.assertEqual(genesis.count(), 1) + self.assertEqual(genesis.first().amount, trial) + self.assertEqual(genesis.first().balance_after, trial) # 流水终点 == 账户余额,可对账 + diff --git a/core/backend/apps/ai/catalog.py b/core/backend/apps/ai/catalog.py index 93a978b..f5f2341 100644 --- a/core/backend/apps/ai/catalog.py +++ b/core/backend/apps/ai/catalog.py @@ -4,6 +4,22 @@ VOLCANO_PROVIDER = { "base_url": "https://ark.cn-beijing.volces.com/api/v3", } +YUNQI_PROVIDER = { + "name": "yunqi", + "display_name": "YunQi AI(New API 网关)", + "base_url": "https://www.yunqiai.chat/v1", +} + +YUNQI_MODELS = [ + { + "display_name": "GPT-Image-2", + "name": "gpt-image-2", + "capability": "image", + "endpoint": "images/generations", + "metadata": {"modes": ["text"], "response_format": "b64_json", "source": "https://www.yunqiai.chat"}, + }, +] + VOLCANO_MODELS = [ { "display_name": "Doubao-Seed-2.0-Pro", diff --git a/core/backend/apps/ai/migrations/0003_alter_aitask_task_type_alter_modelconfig_capability.py b/core/backend/apps/ai/migrations/0003_alter_aitask_task_type_alter_modelconfig_capability.py new file mode 100644 index 0000000..8a4777f --- /dev/null +++ b/core/backend/apps/ai/migrations/0003_alter_aitask_task_type_alter_modelconfig_capability.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1.15 on 2026-06-11 07:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("ai", "0002_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="aitask", + name="task_type", + field=models.CharField( + choices=[ + ("script_generation", "Script Generation"), + ("script_optimization", "Script Optimization"), + ("product_image", "Product Image"), + ("person_image", "Person Image"), + ("scene_image", "Scene Image"), + ("storyboard", "Storyboard"), + ("video_segment", "Video Segment"), + ("voiceover", "Voiceover"), + ("export", "Export"), + ], + max_length=48, + ), + ), + migrations.AlterField( + model_name="modelconfig", + name="capability", + field=models.CharField( + choices=[ + ("text", "Text"), + ("image", "Image"), + ("video", "Video"), + ("vision", "Vision"), + ("audio", "Audio"), + ("export", "Export"), + ], + max_length=32, + ), + ), + ] diff --git a/core/backend/apps/ai/migrations/0004_seed_tts_model.py b/core/backend/apps/ai/migrations/0004_seed_tts_model.py new file mode 100644 index 0000000..3755b47 --- /dev/null +++ b/core/backend/apps/ai/migrations/0004_seed_tts_model.py @@ -0,0 +1,36 @@ +# 旁白配音(TTS):给 volcengine provider 补一条语音合成 ModelConfig。 +# unit_price=0 时计费走 estimate_cost 默认 ¥1/次(一次=整批旁白合成),与其它能力同口径。 +from django.db import migrations + + +def seed_tts_model(apps, schema_editor): + ModelProvider = apps.get_model("ai", "ModelProvider") + ModelConfig = apps.get_model("ai", "ModelConfig") + provider = ModelProvider.objects.filter(name="volcengine").first() + if provider is None: + return + ModelConfig.objects.get_or_create( + provider=provider, + name="doubao-voice-bigtts", + defaults={ + "display_name": "豆包语音合成", + "capability": "audio", + "endpoint": "api/v1/tts", + "unit_price": "0.0000", + }, + ) + + +def unseed_tts_model(apps, schema_editor): + ModelConfig = apps.get_model("ai", "ModelConfig") + ModelConfig.objects.filter(name="doubao-voice-bigtts").delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("ai", "0003_alter_aitask_task_type_alter_modelconfig_capability"), + ] + + operations = [ + migrations.RunPython(seed_tts_model, unseed_tts_model), + ] diff --git a/core/backend/apps/ai/models.py b/core/backend/apps/ai/models.py index c1e9b86..0099707 100644 --- a/core/backend/apps/ai/models.py +++ b/core/backend/apps/ai/models.py @@ -24,6 +24,7 @@ class ModelConfig(TimeStampedModel): IMAGE = "image", "Image" VIDEO = "video", "Video" VISION = "vision", "Vision" + AUDIO = "audio", "Audio" EXPORT = "export", "Export" class Status(models.TextChoices): @@ -56,6 +57,7 @@ class AITask(TeamOwnedModel): SCENE_IMAGE = "scene_image", "Scene Image" STORYBOARD = "storyboard", "Storyboard" VIDEO_SEGMENT = "video_segment", "Video Segment" + VOICEOVER = "voiceover", "Voiceover" EXPORT = "export", "Export" class Status(models.TextChoices): diff --git a/core/backend/apps/ai/providers/__init__.py b/core/backend/apps/ai/providers/__init__.py index c61e4b6..0e10207 100644 --- a/core/backend/apps/ai/providers/__init__.py +++ b/core/backend/apps/ai/providers/__init__.py @@ -1,6 +1,6 @@ from .base import AIProvider, AIProviderResult -from .volcano import VolcanoArkProvider +from .volcano import TtsNotConfigured, VolcanoArkProvider, VolcanoTtsProvider +from .yunqi import YunqiProvider -__all__ = ["AIProvider", "AIProviderResult", "VolcanoArkProvider"] - +__all__ = ["AIProvider", "AIProviderResult", "TtsNotConfigured", "VolcanoArkProvider", "VolcanoTtsProvider", "YunqiProvider"] diff --git a/core/backend/apps/ai/providers/volcano.py b/core/backend/apps/ai/providers/volcano.py index 319a6e7..e2a4f00 100644 --- a/core/backend/apps/ai/providers/volcano.py +++ b/core/backend/apps/ai/providers/volcano.py @@ -1,5 +1,6 @@ from dataclasses import dataclass import base64 +import uuid from io import BytesIO from typing import Any @@ -171,3 +172,60 @@ class VolcanoArkProvider: content_type = header.split(";")[0].replace("data:", "") or "application/octet-stream" return BytesIO(base64.b64decode(raw)), content_type return BytesIO(base64.b64decode(media)), "image/png" + + +class TtsNotConfigured(ValueError): + """语音合成凭证未配置(VOLC_TTS_APPID / VOLC_TTS_ACCESS_TOKEN)。""" + + +@dataclass +class VolcanoTtsProvider: + """豆包语音合成大模型(openspeech V1 HTTP,非流式)。 + 注意:与 ARK 不是同一套钥匙,要在 火山引擎控制台→语音技术→语音合成大模型 创建应用拿 APPID/Access Token。""" + + appid: str | None = None + access_token: str | None = None + cluster: str | None = None + base_url: str | None = None + + def __post_init__(self) -> None: + cfg = getattr(settings, "VOLC_TTS", {}) or {} + self.appid = self.appid or cfg.get("appid") + self.access_token = self.access_token or cfg.get("access_token") + self.cluster = self.cluster or cfg.get("cluster") or "volcano_tts" + self.base_url = self.base_url or cfg.get("base_url") or "https://openspeech.bytedance.com/api/v1/tts" + + @property + def configured(self) -> bool: + return bool(self.appid and self.access_token) + + def synthesize(self, *, text: str, voice_type: str, speed_ratio: float = 1.0, uid: str = "airshelf") -> tuple[bytes, int]: + """合成一段语音。返回 (mp3 字节, 时长毫秒;接口没回时长则为 0)。""" + if not self.configured: + raise TtsNotConfigured( + "语音合成未配置:请在后端环境变量设置 VOLC_TTS_APPID 和 VOLC_TTS_ACCESS_TOKEN" + "(火山引擎控制台 → 语音技术 → 语音合成大模型 → 创建应用)" + ) + body = { + "app": {"appid": self.appid, "token": self.access_token, "cluster": self.cluster}, + "user": {"uid": uid}, + "audio": {"voice_type": voice_type, "encoding": "mp3", "speed_ratio": float(speed_ratio or 1.0)}, + "request": {"reqid": str(uuid.uuid4()), "text": text, "operation": "query"}, + } + response = requests.post( + self.base_url, + headers={"Authorization": f"Bearer;{self.access_token}"}, + json=body, + timeout=60, + ) + response.raise_for_status() + data = response.json() + if data.get("code") != 3000 or not data.get("data"): + raise ValueError(f"语音合成失败:{data.get('message') or data.get('code')}") + audio = base64.b64decode(data["data"]) + duration_ms = 0 + try: + duration_ms = int(float((data.get("addition") or {}).get("duration") or 0)) + except (TypeError, ValueError): + duration_ms = 0 + return audio, duration_ms diff --git a/core/backend/apps/ai/providers/yunqi.py b/core/backend/apps/ai/providers/yunqi.py new file mode 100644 index 0000000..b650d44 --- /dev/null +++ b/core/backend/apps/ai/providers/yunqi.py @@ -0,0 +1,43 @@ +from typing import Any + +import requests +from django.conf import settings + +from .volcano import VolcanoArkProvider + + +class YunqiProvider(VolcanoArkProvider): + """YunQi AI(New API 网关)适配器:OpenAI 兼容 images/generations。 + + 与火山 ARK 的差异:不接受 watermark/sequential_image_generation/response_format 私有参数; + size 用 OpenAI 枚举(1024x1024/1024x1536/1536x1024);gpt-image-2 固定返回 b64_json, + 下游 extract_first_media_url / media_to_bytes 已兼容 base64,无需改动。 + """ + + def __post_init__(self) -> None: + self.api_key = self.api_key or settings.YUNQI.get("api_key") + self.base_url = self.base_url or settings.YUNQI.get("base_url") + + def image_generation( + self, + *, + model: str, + prompt: str, + endpoint: str = "images/generations", + image: str | list[str] | None = None, + size: str = "1024x1536", + ) -> dict[str, Any]: + if not self.api_key: + raise ValueError("YUNQI_API_KEY is not configured") + if image: + raise ValueError("gpt-image-2 via YunQi only supports text-to-image; reference image is not supported") + body: dict[str, Any] = {"model": model, "prompt": prompt, "size": size, "n": 1} + # 实测该网关生图平均延迟 75s+,超时须显著高于火山的 180s + response = requests.post( + f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}", + headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}, + json=body, + timeout=300, + ) + response.raise_for_status() + return response.json() diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index abcbe6a..ae7b6fc 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -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 diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 417ab4d..d0ec7db 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -5,6 +5,7 @@ from rest_framework.viewsets import ReadOnlyModelViewSet from apps.assets.serializers import AssetSerializer from apps.common.api import TeamScopedViewSetMixin, get_current_team +from apps.common.celery_health import require_worker from .models import AITask, ModelConfig from .serializers import AITaskSerializer, ModelConfigSerializer @@ -15,6 +16,7 @@ class GenerateImageView(APIView): """POST /api/ai/generate-image/ — 独立生图(不绑项目)· 图片创作/模特图/平台套图共用。""" def post(self, request): + require_worker() prompt = str(request.data.get("prompt") or "").strip() if not prompt: return Response({"detail": "prompt 不能为空"}, status=status.HTTP_400_BAD_REQUEST) diff --git a/core/backend/apps/assets/views.py b/core/backend/apps/assets/views.py index 829ec08..1c0fb77 100644 --- a/core/backend/apps/assets/views.py +++ b/core/backend/apps/assets/views.py @@ -1,8 +1,11 @@ from pathlib import Path import uuid +import requests from django.db import transaction +from django.http import StreamingHttpResponse from rest_framework import status +from rest_framework.decorators import action from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.response import Response from rest_framework.views import APIView @@ -21,6 +24,29 @@ class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet): search_fields = ["name", "description"] ordering_fields = ["created_at", "updated_at", "name"] + @action(detail=True, methods=["get"], url_path="raw") + def raw(self, request, pk=None): + """同源流式代理资产主文件。TOS 桶未配 CORS,浏览器 JS 读不到跨域媒体数据—— + 编辑器抽视频缩略图(canvas 会被 taint)/解码音频波形(fetch 被拦)都需要走这里。""" + asset = self.get_object() + primary = asset.files.filter(is_primary=True).first() or asset.files.first() + if primary is None: + return Response({"detail": "asset has no file"}, status=status.HTTP_404_NOT_FOUND) + url = TosStorage().presigned_get_url(object_key=primary.object_key, expires_in=600) + upstream_headers = {} + if request.META.get("HTTP_RANGE"): + upstream_headers["Range"] = request.META["HTTP_RANGE"] + upstream = requests.get(url, headers=upstream_headers, stream=True, timeout=120) + response = StreamingHttpResponse( + upstream.iter_content(chunk_size=256 * 1024), + status=upstream.status_code, + content_type=primary.content_type or "application/octet-stream", + ) + for header in ("Content-Length", "Content-Range", "Accept-Ranges"): + if header in upstream.headers: + response[header] = upstream.headers[header] + return response + class AssetUploadView(APIView): parser_classes = [MultiPartParser, FormParser] diff --git a/core/backend/apps/billing/management/__init__.py b/core/backend/apps/billing/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/backend/apps/billing/management/commands/__init__.py b/core/backend/apps/billing/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/backend/apps/billing/management/commands/sweep_stale_reservations.py b/core/backend/apps/billing/management/commands/sweep_stale_reservations.py new file mode 100644 index 0000000..8098a9b --- /dev/null +++ b/core/backend/apps/billing/management/commands/sweep_stale_reservations.py @@ -0,0 +1,63 @@ +"""释放僵尸预留额度。 + +生成流程中断(进程被杀/浏览器关闭/轮询停止)会留下 status=ACTIVE 的 CreditReservation, +对应 AITask 永远停在 reserved/submitted/polling——这些预留会一直占用 reserved_balance, +吃掉团队可用额度。本命令把「超过 --hours 小时仍未终态」的预留按正规 release 流程释放 +(走 release_credit,有 RELEASE 流水可审计),并把对应任务标记为 FAILED。 + +用法: + python manage.py sweep_stale_reservations # 默认释放超过 12 小时的 + python manage.py sweep_stale_reservations --hours 1 # 收紧窗口 + python manage.py sweep_stale_reservations --dry-run # 只看不动 +""" +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from apps.ai.models import AITask +from apps.billing.models import CreditReservation +from apps.billing.services.ledger import release_credit + +# 这些任务状态意味着流程没走到终态(成功会 charge、失败会 release),超时即视为僵尸 +STUCK_TASK_STATUSES = ( + AITask.Status.CREATED, + AITask.Status.RESERVED, + AITask.Status.SUBMITTED, + AITask.Status.POLLING, +) + + +class Command(BaseCommand): + help = "释放超时未终态的 ACTIVE 预留额度(僵尸预留),并把对应任务标记为失败" + + def add_arguments(self, parser): + parser.add_argument("--hours", type=int, default=12, help="超过多少小时视为僵尸(默认 12)") + parser.add_argument("--dry-run", action="store_true", help="只列出将要释放的,不实际执行") + + def handle(self, *args, **options): + cutoff = timezone.now() - timedelta(hours=options["hours"]) + stale = ( + CreditReservation.objects.filter(status=CreditReservation.Status.ACTIVE, created_at__lt=cutoff) + .select_related("task") + .order_by("created_at") + ) + released = 0 + for reservation in stale: + task = reservation.task + if task is not None and task.status not in STUCK_TASK_STATUSES: + # 任务已终态却仍 ACTIVE:同样是泄漏,照常释放 + pass + label = f"resv {str(reservation.id)[:8]} ¥{reservation.amount} task={str(task.id)[:8] if task else '-'}({task.status if task else '-'}) created={reservation.created_at:%m-%d %H:%M}" + if options["dry_run"]: + self.stdout.write(f"[dry-run] would release {label}") + continue + release_credit(reservation=reservation, reason=f"僵尸预留清扫(>{options['hours']}h 未终态)") + if task is not None and task.status in STUCK_TASK_STATUSES: + task.status = AITask.Status.FAILED + task.error_message = "stale task swept: 流程中断超时,预留已释放" + task.completed_at = timezone.now() + task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) + released += 1 + self.stdout.write(f"released {label}") + self.stdout.write(self.style.SUCCESS(f"done: released {released} stale reservation(s)")) diff --git a/core/backend/apps/billing/services/ledger.py b/core/backend/apps/billing/services/ledger.py index 748d4e5..ce23afe 100644 --- a/core/backend/apps/billing/services/ledger.py +++ b/core/backend/apps/billing/services/ledger.py @@ -1,16 +1,46 @@ from decimal import Decimal from django.db import transaction +from django.db.models import Sum +from django.utils import timezone from apps.billing.models import CreditAccount, CreditLedger, CreditReservation +def _enforce_member_monthly_limit(*, team, user, amount: Decimal) -> None: + """成员月度限额管控。团队页可设 monthly_credit_limit(0=不限),此前只存不管——纯装饰。 + 口径与成员列表的 month_charged 一致:自然月内该成员的 CHARGE 流水合计; + 再加上其当前 ACTIVE 预留(在途任务),防止并发把限额冲穿。 + 调用方(reserve_credit)已持有 account 行锁,团队内 reserve 串行化,此检查无竞态。""" + if user is None: + return + member = team.members.filter(user=user).first() + if member is None: + return + limit = member.monthly_credit_limit or Decimal("0") + if limit <= 0: + return # 0 = 不限额 + now = timezone.now() + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + charged = ( + CreditLedger.objects.filter(team=team, user=user, ledger_type=CreditLedger.Type.CHARGE, created_at__gte=month_start) + .aggregate(s=Sum("amount"))["s"] or Decimal("0") + ) + reserved = ( + CreditReservation.objects.filter(team=team, user=user, status=CreditReservation.Status.ACTIVE) + .aggregate(s=Sum("amount"))["s"] or Decimal("0") + ) + if charged + reserved + amount > limit: + raise ValueError(f"成员本月额度不足:限额 ¥{limit},本月已用 ¥{charged}(另有在途 ¥{reserved})") + + @transaction.atomic def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation: account, _ = CreditAccount.objects.select_for_update().get_or_create(team=team) available = account.balance - account.reserved_balance if available < amount: raise ValueError("insufficient credit") + _enforce_member_monthly_limit(team=team, user=user, amount=amount) account.reserved_balance += amount account.save(update_fields=["reserved_balance", "updated_at"]) @@ -37,6 +67,9 @@ def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation: @transaction.atomic def release_credit(*, reservation: CreditReservation, reason: str = "") -> None: account = CreditAccount.objects.select_for_update().get(team=reservation.team) + # 锁内重读:与 charge 同款竞态——并发双 release(或 charge+release 交错)用陈旧快照都看到 ACTIVE, + # reserved_balance 会被减两次直接记坏账。已终态(已扣/已释放)→ 幂等返回。 + reservation = CreditReservation.objects.select_for_update().get(id=reservation.id) if reservation.status != CreditReservation.Status.ACTIVE: return @@ -59,6 +92,11 @@ def release_credit(*, reservation: CreditReservation, reason: str = "") -> None: @transaction.atomic def charge_reserved_credit(*, reservation: CreditReservation, actual_amount: Decimal) -> None: account = CreditAccount.objects.select_for_update().get(team=reservation.team) + # 锁内重读 reservation:调用方传入的可能是并发竞态下的陈旧快照(双方都看到 ACTIVE), + # 旧实现会对同一笔预留扣两次费(实测同秒两条 charge 流水)。已扣费 → 幂等直接返回。 + reservation = CreditReservation.objects.select_for_update().get(id=reservation.id) + if reservation.status == CreditReservation.Status.CHARGED: + return if reservation.status != CreditReservation.Status.ACTIVE: raise ValueError("reservation is not active") if actual_amount > reservation.amount: diff --git a/core/backend/apps/billing/tests.py b/core/backend/apps/billing/tests.py index 17007e3..cf79225 100644 --- a/core/backend/apps/billing/tests.py +++ b/core/backend/apps/billing/tests.py @@ -1,6 +1,7 @@ from decimal import Decimal from django.test import TestCase +from rest_framework.test import APIClient from apps.accounts.models import Team, TeamMember, User from apps.ai.models import AITask, ModelConfig, ModelProvider @@ -58,3 +59,31 @@ class CreditLedgerTests(TestCase): self.assertEqual(self.account.reserved_balance, Decimal("0.0000")) self.assertEqual(CreditLedger.objects.filter(ledger_type=CreditLedger.Type.RELEASE).count(), 1) + +class RechargePermissionTests(TestCase): + """充值是团队资金操作:仅 owner/admin 可发起,普通成员/访客被 403 拦截。""" + + def setUp(self): + self.owner = User.objects.create_user(username="r-owner", password="ownerpass123") + self.member = User.objects.create_user(username="r-member", password="memberpass123") + self.team = Team.objects.create(name="Recharge Team", owner=self.owner) + TeamMember.objects.create(team=self.team, user=self.owner, role=TeamMember.Role.OWNER, status=TeamMember.Status.ACTIVE) + TeamMember.objects.create(team=self.team, user=self.member, role=TeamMember.Role.MEMBER, status=TeamMember.Status.ACTIVE) + CreditAccount.objects.create(team=self.team, balance=Decimal("0.0000")) + + def test_owner_can_recharge(self): + client = APIClient() + client.force_authenticate(self.owner) + response = client.post("/api/billing/recharge/", {"amount": "100"}, format="json") + self.assertEqual(response.status_code, 201) + account = CreditAccount.objects.get(team=self.team) + self.assertEqual(account.balance, Decimal("100.0000")) + + def test_member_cannot_recharge(self): + client = APIClient() + client.force_authenticate(self.member) + response = client.post("/api/billing/recharge/", {"amount": "100"}, format="json") + self.assertEqual(response.status_code, 403) + account = CreditAccount.objects.get(team=self.team) + self.assertEqual(account.balance, Decimal("0.0000")) # 余额未变,越权被拦 + diff --git a/core/backend/apps/billing/views.py b/core/backend/apps/billing/views.py index 4a30cef..4e0afd7 100644 --- a/core/backend/apps/billing/views.py +++ b/core/backend/apps/billing/views.py @@ -11,7 +11,7 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from apps.ai.models import AITask -from apps.common.api import get_current_team +from apps.common.api import can_manage_team, get_current_team from .models import CreditAccount, CreditLedger from .serializers import CreditAccountSerializer, CreditLedgerSerializer @@ -83,6 +83,9 @@ def ledgers(request): @permission_classes([IsAuthenticated]) def recharge(request): team = get_current_team(request.user) + # 充值是团队资金操作:仅 owner/admin 可发起,普通成员/访客一律拒绝 + if not can_manage_team(request.user, team): + return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN) try: amount = Decimal(str(request.data.get("amount", "0"))) bonus = Decimal(str(request.data.get("bonus", "0"))) diff --git a/core/backend/apps/common/api.py b/core/backend/apps/common/api.py index 3eac9ae..1519d2f 100644 --- a/core/backend/apps/common/api.py +++ b/core/backend/apps/common/api.py @@ -8,6 +8,12 @@ def get_current_team(user): return membership.team +def can_manage_team(user, team): + """团队管理权限:仅 owner / admin 可做充值、改限额、增删成员等管理动作。""" + membership = user.team_memberships.filter(team=team, status="active").first() + return bool(membership and membership.role in {"owner", "admin"}) + + class TeamScopedViewSetMixin: team_field = "team" diff --git a/core/backend/apps/common/celery_health.py b/core/backend/apps/common/celery_health.py new file mode 100644 index 0000000..04fe934 --- /dev/null +++ b/core/backend/apps/common/celery_health.py @@ -0,0 +1,57 @@ +"""Celery worker 在线探测 —— 生成类任务的提交前置闸。 + +设计决策(2026-06-10):无 worker 时**禁止提交**图片/视频生成,而非静默降级到前端轮询。 +旧行为的风险:提交到 ARK 后浏览器一关就无人轮询,生成结果悬在云端无人取回入库、 +额度预扣长期冻结——等于数据丢失。额度预检挡"钱不够",这道闸挡"环境不齐"。 + +探测用 control.ping 广播,结果带缓存:在线缓存 30s(别让每次提交都付 ping 开销), +离线只缓存 5s(worker 一启动尽快解封)。CELERY_TASK_ALWAYS_EAGER(测试/同步模式) +下任务就地执行不需要 worker,直接放行。 +""" + +import time + +from django.conf import settings +from rest_framework.exceptions import APIException + + +class WorkerUnavailable(APIException): + status_code = 503 + default_detail = ( + "后台任务服务(Celery worker)未运行:生成结果将无人取回入库,可能造成数据丢失。" + "已暂停生成功能,请启动 worker 后重试(celery -A airshelf worker)。" + ) + default_code = "worker_unavailable" + + +_OK_TTL = 30.0 +_FAIL_TTL = 5.0 +_cache = {"ok": False, "expires": 0.0} + + +def _ping_workers() -> bool: + from airshelf.celery import app as celery_app + + # limit=1:收到第一个 worker 回包立即返回,不傻等满 timeout + return bool(celery_app.control.ping(timeout=1.0, limit=1)) + + +def celery_worker_available() -> bool: + if getattr(settings, "CELERY_TASK_ALWAYS_EAGER", False): + return True + now = time.monotonic() + if now < _cache["expires"]: + return _cache["ok"] + try: + ok = _ping_workers() + except Exception: # noqa: BLE001 — broker 不可达等同于无 worker,一样要拦 + ok = False + _cache["ok"] = ok + _cache["expires"] = now + (_OK_TTL if ok else _FAIL_TTL) + return ok + + +def require_worker() -> None: + """生成类提交入口的前置检查:无 worker 直接 503,不让任务出门。""" + if not celery_worker_available(): + raise WorkerUnavailable() diff --git a/core/backend/apps/common/management/commands/bootstrap_yunqi_models.py b/core/backend/apps/common/management/commands/bootstrap_yunqi_models.py new file mode 100644 index 0000000..e211066 --- /dev/null +++ b/core/backend/apps/common/management/commands/bootstrap_yunqi_models.py @@ -0,0 +1,45 @@ +from django.core.management.base import BaseCommand + +from apps.ai.catalog import VOLCANO_PROVIDER, YUNQI_MODELS, YUNQI_PROVIDER +from apps.ai.models import ModelConfig, ModelProvider + + +class Command(BaseCommand): + help = "Create or update YunQi (New API) provider with gpt-image-2, and disable Volcano image models." + + def handle(self, *args, **options): + provider, _ = ModelProvider.objects.update_or_create( + name=YUNQI_PROVIDER["name"], + defaults={ + "display_name": YUNQI_PROVIDER["display_name"], + "base_url": YUNQI_PROVIDER["base_url"], + "status": ModelProvider.Status.ACTIVE, + }, + ) + + count = 0 + for item in YUNQI_MODELS: + ModelConfig.objects.update_or_create( + provider=provider, + name=item["name"], + capability=item["capability"], + defaults={ + "display_name": item["display_name"], + "endpoint": item["endpoint"], + "status": ModelConfig.Status.ACTIVE, + "metadata": item["metadata"], + }, + ) + count += 1 + + # 全站生图切到 YunQi:火山 image 模型停用,文本/视频模型保持不动 + disabled = ModelConfig.objects.filter( + provider__name=VOLCANO_PROVIDER["name"], + capability=ModelConfig.Capability.IMAGE, + ).update(status=ModelConfig.Status.DISABLED) + + self.stdout.write( + self.style.SUCCESS( + f"Bootstrapped {count} YunQi model config(s); disabled {disabled} Volcano image model(s)." + ) + ) diff --git a/core/backend/apps/projects/serializers.py b/core/backend/apps/projects/serializers.py index 242f622..bc8b9d0 100644 --- a/core/backend/apps/projects/serializers.py +++ b/core/backend/apps/projects/serializers.py @@ -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): diff --git a/core/backend/apps/projects/services/export.py b/core/backend/apps/projects/services/export.py index 948becb..299c1e5 100644 --- a/core/backend/apps/projects/services/export.py +++ b/core/backend/apps/projects/services/export.py @@ -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: diff --git a/core/backend/apps/projects/tests.py b/core/backend/apps/projects/tests.py index c9b8a20..54ab811 100644 --- a/core/backend/apps/projects/tests.py +++ b/core/backend/apps/projects/tests.py @@ -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 diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 71cdf0f..edfcbaa 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -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) diff --git a/core/docs/BP-产品介绍资料.md b/core/docs/BP-产品介绍资料.md new file mode 100644 index 0000000..4dcb46c --- /dev/null +++ b/core/docs/BP-产品介绍资料.md @@ -0,0 +1,185 @@ +# AirShelf 产品介绍资料(BP「产品与业务」章节) + +> 版本:2026-06-10 | 面向投资人 | 数据口径与出处见各节标注 +> 产品名:AirShelf(中文暂定名「带货流水线」,对外名称待定,见文末清单) + +--- + +## 一句话介绍 + +**AirShelf 是一条给电商商家用的"AI 带货视频生产线":商家选好商品,AI 自动写脚本、生成主播和场景画面、画出分镜、生成视频,最后拼成一条可以直接发抖音的 60 秒带货成片——全程 1-2 小时,单条成本只要二三十元。** + +--- + +## 行业痛点:内容电商把"做内容"变成了商家最贵的固定成本 + +抖音电商 2024 年成交额约 3.5 万亿元,同比增长 30%(来源:36氪独家报道,2025 年 2 月)。在这个盘子里,每一单成交背后都是一条条短视频在带货——视频就是货架,不发视频等于关店。 + +但对占商家主体的中小白牌商家来说,"持续产出带货视频"是一件又贵、又慢、又碎的事: + +- **贵**:传统方式做一条带货视频,主播、场地、拍摄、剪辑加起来单条几百元起步;一张商品精修图,摄影棚报价几十到几百元,还要按天排期。 +- **慢**:找代运营外包,一条视频沟通加交付要 3-7 天。抖音的玩法是日更甚至一天多条,这个速度根本跟不上。 +- **碎**:市面上的 AI 工具是"零件"不是"生产线"——写文案一个工具、生图一个工具、做视频又一个工具,商家要自己当导演把零件攒起来,最后还经常拼不出能直接发抖音的竖版成片。 +- **怕**:AI 生成有不确定性,商家最怕"点一下扣一笔钱,出来的东西不能用"。这层心理障碍挡住了大量本来愿意尝试的商家。 + +一句话:**市场不缺 AI 工具,缺的是一条让普通商家敢用、用得起、出来就能发的完整生产线。** + +--- + +## 我们的答案:把"拍视频"变成"下订单" + +商家在 AirShelf 上做一条带货视频,体验就像点一份外卖: + +1. **选商品**:把自家商品放进商品库(传几张图、写几条卖点),全团队共用。 +2. **AI 写脚本**:不会写脚本的商家选"AI 全生成",AI 直接产出 60 秒的分镜脚本;自己有文案的直接粘贴进来;只有一个想法的,说一句话主题就行。商家可以像聊天一样让 AI 改:"第二段语气活泼一点"。 +3. **AI 生成画面素材**:AI 根据脚本自动生成主播形象、拍摄场景,并把商品图一键变成专业级效果(去掉杂乱背景、重新打光、变清晰)。 +4. **AI 画分镜、出视频**:先画出整条片子的分镜画面给商家过目,确认后再生成视频片段。 +5. **一键成片**:自动拼接、加抖音风格字幕、配热门卡点音乐,导出高清竖版成片,直接可发。 + +三条贯穿全程的产品规则,专门拆掉商家的心理防线: + +- **每一步商家点头才往下走**:脚本、画面、分镜、视频,每个环节商家确认满意才进入下一步,不满意免费重做。 +- **失败不扣费、采用才扣费**:生成失败、效果不满意重跑,一律不收钱;只有商家点了"采用"才计费。商家试错零成本。 +- **素材越用越省**:生成过的主播形象、场景画面沉淀进团队资产库,下一条视频直接复用——主播形象稳定(观众认得出"这是这家店的人"),成本还更低。 + +服务三类客户:抖音白牌商家(自己做内容的小店主)、代运营机构/MCN(多人团队批量生产,平台内置成员管理和额度分配)、带货达人(要稳定人设、多变剧情)。 + +--- + +## 市场与竞争 + +### 这个赛道已经被全球资本验证过了 + +AI 视频生成是当前 AI 应用层融资最热的赛道之一,两家头部公司的估值轨迹可以直接说明问题: + +**Synthesia(英国,全球最大的 AI 视频生成公司)** +一句话定位:用 AI 数字人帮企业把文档变成培训、营销视频。 + +| 轮次 | 时间 | 金额 | 估值 | 主要投资方 | +|---|---|---|---|---| +| 种子轮 | 2019 | 310 万美元 | — | LDV Capital、MMC 等 | +| A 轮 | 2021.4 | 1,250 万美元 | — | FirstMark | +| B 轮 | 2021.12 | 5,000 万美元 | — | Kleiner Perkins、GV(谷歌风投) | +| C 轮 | 2023.6 | 9,000 万美元 | 10 亿美元 | Accel 领投,英伟达 NVentures 参投 | +| D 轮 | 2025.1 | 1.8 亿美元 | 21 亿美元 | NEA 领投 | +| E 轮 | 2026.1 | 2 亿美元 | **40 亿美元** | GV 领投,英伟达等跟投 | + +累计融资超 5.3 亿美元;2025 年 4 月年度经常性收入(ARR)突破 1 亿美元,客户覆盖超过 60% 的财富 100 强。(来源:CNBC 2025-01-15、2026-01-26 报道;TechCrunch 2026-01-26 报道;Synthesia 官方公告) + +**HeyGen(美国,华人创办,赛道内增速最快)** +一句话定位:让企业用 AI 数字人快速生成营销和销售视频。 + +- 2024 年 6 月:A 轮融资 6,000 万美元,估值 **5 亿美元**,Benchmark 领投,Thrive Capital、BOND 等参投(来源:彭博社 2024-06-20 报道);早期投资方包括红杉中国(HongShan)、真格基金,累计融资约 7,460 万美元(来源:Crunchbase / Tracxn,截至 2026 年 6 月)。 +- 2025 年 10 月:年度经常性收入(ARR)突破 **1 亿美元**,两年增长超过 10 倍,付费企业客户超 8.5 万家(来源:Sacra / 公司公开披露)。 + +**对投资人的含义**:两家公司证明了"AI 生成商业视频"是一个能在 2-3 年内做到 1 亿美元收入、支撑 40 亿美元估值的赛道,且资本(Benchmark、Accel、NEA、GV、英伟达)持续加注。 + +### 我们和它们的差异:它们做"企业宣传",我们做"电商卖货" + +| 维度 | Synthesia / HeyGen | AirShelf | +|---|---|---| +| 核心场景 | 企业培训、品牌宣传、横屏口播视频 | 中国电商带货,60 秒竖版短剧化成片 | +| 交付物 | 数字人讲解视频(半成品居多) | 直接能发抖音的完整成片(脚本+画面+字幕+音乐) | +| 客户形态 | 欧美大企业市场部 | 中国白牌商家、MCN、代运营团队 | +| 付费方式 | 订阅制(按席位/年) | 预充值按量计费(用多少花多少,门槛极低) | +| 团队协作 | 通用协作 | 为 MCN 定制:成员额度分配、按项目/按人对账 | + +它们验证了赛道,但没有覆盖"中国内容电商"这个最高频、最刚需的子市场——商家不需要一条精美的企业宣传片,需要的是每天都能发、发了能出单的带货视频。这是一个用量逻辑完全不同的市场:企业一年拍几十条宣传片,电商商家一个月就要几十条带货视频、上千张商品图。**高频消耗 + 按量计费,是我们和订阅制玩家在商业模式上的根本分野。** + +--- + +## 核心竞争力 + +1. **端到端生产线,不是工具箱。** 从商品到可发布成片一条线打通,商家不需要在五个工具之间搬运素材。市面上单点工具越多,"最后一公里拼装"的价值反而越稀缺——我们交付的是结果(成片),不是原料。 + +2. **"失败不扣费、采用才扣费"的信任机制。** 每个环节有人工确认关口,不满意免费重做。这直接解决了商家"怕 AI 乱花钱"的最大使用障碍,是把尝鲜用户转成常用用户的关键设计,也是同类产品普遍没有做的。 + +3. **资产沉淀形成切换成本。** 商家的主播形象、场景画面、优化后的商品图都沉淀在团队资产库里,跨视频复用——用得越久,素材库越厚、出片越快越便宜、主播人设越稳定。商家换平台等于放弃整个素材资产,这是天然的留存护城河。 + +4. **火山引擎深度绑定,供给侧有先发权。** 已与火山引擎(字节跳动旗下云服务)签署模型服务白名单协议与年度框架合同,享有新模型能力优先接入权。这意味着两件事:上游算力成本与供给稳定可控;字节系新一代生成能力上线时,我们能先于市场拿到——在能力快速迭代的赛道里,"早一个季度接入"就是产品代差。 + +5. **为 MCN/代运营长出来的团队体系。** 成员邀请、角色权限、四层额度管控(人/天、人/月、团队/月、团队总额)、按项目按成员对账——机构客户最看重的"管人管钱"能力是产品原生的,不是后补的。机构客户客单价高、用量稳定,是天然的收入压舱石。 + +--- + +## 商业模式 + +**谁付费**:商家/机构充值开通团队账户(超级管理员充值,给成员分配额度),三类客户画像中,白牌商家约占一半,MCN/代运营机构约占三分之一强,带货达人为补充。 + +**怎么收**:预充值 + 按量计费。每生成一张图、一段文案、一条视频按单价从余额扣费;失败不扣、采用才扣。对客户来说没有订阅门槛、用多少花多少;对平台来说,客户用量随其生意增长自然爬坡,收入跟着客户的内容产量走。 + +**未来增收方向**(不计入当前模型):去水印/高级模板等增值订阅、面向机构的批量生产能力、爆款复刻等高阶功能。 + +--- + +## 利润估算 + +### 1. 单位经济模型 + +| 业务 | 单位成本 | 对客定价 | 名义毛利率 | +|---|---|---|---| +| AI 出图(商品图/主播/场景/分镜) | 约 0.03 元/张 | 约 0.2 元/张 | 约 85% | +| AI 文案/脚本(语言模型调用) | 同等加价倍率 | 按次计费 | 70%-80% | + +**为什么这个定价站得住——客户的替代成本**:传统电商摄影一张精修图几十到几百元、按天排期交付;传统带货视频单条制作几百元起、外包周期 3-7 天。对比之下,0.2 元/张约等于免费——哪怕我们把价格翻倍,对客户依然是百分之一的成本。**客户对这个价位不敏感,定价有充足的安全垫,毛利率的下行风险很小。** + +### 2. 典型客户月度用量测算 + +以一家 20-30 个商品、按抖音日更节奏运营的白牌店铺为例: + +| 用量项 | 测算 | 月出图量 | +|---|---|---| +| 带货视频生产 | 月产约 20 条成片,每条计费出图约 8 张(分镜 4 张 + 主播/场景/商品图约 4 张,资产复用后的稳态值) | 约 160 张 | +| 日常图片素材 | 在售商品的主图优化、详情更新、短视频封面、活动海报、测款素材,日均 40-50 张 | 约 1,300 张 | +| **合计** | | **约 1,500 张/月** | + +- 出图收入:1,500 张 × 0.2 元 ≈ **300 元/月** +- 文案/脚本收入:20 条视频的脚本生成与改稿,加商品卖点文案,约 **65 元/月** +- **单店客户:月收入约 365 元,年收入约 4,400 元** +- **机构客户(MCN/代运营,5-20 人团队)**:用量约为单店的 8-10 倍,月消耗约 3,000 元(与产品内典型团队月额度设置一致),**年收入约 3.6 万元** + +### 3. 综合毛利率(按收入结构加权) + +- 出图收入占比约 82%。计入"失败不扣费"承担的免费重跑成本(按每张计费图平均消耗 1.4 次实际生成估算),出图的**有效毛利率约 79%**(0.2 − 0.03×1.4 = 0.158 元/张)。 +- 文案收入占比约 18%,毛利率按区间稳健取 72%。 +- **加权综合毛利率 ≈ 82% × 79% + 18% × 72% ≈ 78%**,落在 70%-80% 区间。 +- **对外口径建议表述为"综合毛利率约 75%"**——已经把免费重跑、文案低毛利部分都算进去,留有余量,经得起验算。 + +### 4. 这个模型的含义 + +一个客户一年 4,400 元,对应的替代预算(传统摄影 + 视频外包)是数万元——我们只取了客户原预算的零头,客户省下 90% 以上,平台仍有约 75% 毛利。**便宜不是靠补贴,是靠生成成本的结构性优势;客户越省,我们越赚,这个模型两头都成立。** + +--- + +## 当前进展 + +- **产品已上线内测**:从商品入库、AI 脚本、画面素材生成、分镜、视频生成到成片导出的完整生产线已经打通,内测环境可稳定产出 60 秒竖版成片。 +- **团队与计费体系完整可用**:团队账户、成员角色与额度管控、预充值与按量扣费、"失败不扣费"规则均已实现。 +- **供给侧合作落地**:已与火山引擎签署模型服务白名单协议与年度框架合同,锁定算力供给与新模型优先接入权。 +- 当前阶段以内测打磨为主,暂无对外运营数据;下一阶段目标为扩大内测商家规模、校准用量模型后正式开放。 + +--- + +## 待 PM 确认清单 + +1. **产品对外名称**:资料中暂用工程名"AirShelf"(PRD 中文暂名"带货流水线"),对外品牌名需最终确认。 +2. **视频生成环节的成本与定价底数未提供**:本文利润估算按既定口径只计入"出图 + 文案"两项;若计入视频生成环节收入(PRD 场景示例中单条成片整体消耗约 22 元),单客户月收入会显著高于本文测算,综合毛利率也需按其实际毛利重新加权。 +3. **典型客户月出图量假设(约 1,500 张/月,其中日常图片素材约 1,300 张)**:为合理假设,需用内测真实用量数据校准后再对外引用。 +4. **免费重跑系数假设(每张计费图平均 1.4 次实际生成)**:影响有效毛利率约 6 个百分点,需用内测数据验证。 +5. **文案/脚本月消耗约 65 元的假设**:单次调用定价与典型调用次数需财务定价细则落定后复核。 +6. **机构客户"用量为单店 8-10 倍、月消耗约 3,000 元"**:参考产品内典型团队额度设置推估,需销售侧确认目标客单价。 +7. **火山引擎协议的可披露范围**:白名单协议与年度框架合同的具体条款(金额、期限、独占性)哪些可写入 BP 正文、哪些只能口头沟通,需确认。 +8. **外部数据引用复核**:抖音电商 2024 年 GMV 约 3.5 万亿元为 36氪报道口径(非官方发布);Synthesia、HeyGen 融资与估值数据截至 2026 年 6 月检索(CNBC、TechCrunch、彭博社、Crunchbase 等公开报道),BP 定稿前建议复核一次是否有新轮次。 + +--- + +### 主要信息来源 + +- [CNBC:Synthesia 估值翻倍至 21 亿美元(2025-01-15)](https://www.cnbc.com/2025/01/15/ai-video-platform-synthesia-doubles-valuation-to-2point1-billion.html) +- [CNBC:英伟达、谷歌风投加注 Synthesia,估值 40 亿美元(2026-01-26)](https://www.cnbc.com/2026/01/26/nvidia-alphabet-vc-arms-back-synthesia.html) +- [TechCrunch:Synthesia E 轮 2 亿美元、估值 40 亿(2026-01-26)](https://techcrunch.com/2026/01/26/synthesia-hits-4b-valuation-lets-employees-cash-in/) +- [Synthesia 官方:E 轮融资公告](https://www.synthesia.io/post/series-e-200-million-4-billion-valuation-future-work) +- [彭博社:HeyGen 融资估值 5 亿美元(2024-06-20)](https://www.bloomberg.com/news/articles/2024-06-20/ai-video-startup-heygen-valued-at-500-million-in-funding-round) +- [HeyGen 官方:A 轮 6,000 万美元公告](https://www.heygen.com/blog/announcing-our-series-a) +- [Sacra:HeyGen 收入与估值档案](https://sacra.com/c/heygen/) +- [36氪:抖音电商 2024 年 GMV 约 3.5 万亿(2025-02)](https://eu.36kr.com/zh/p/3166066575158018) diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index fad99cc..8796dc5 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { api, getToken, setToken } from "./api"; +import { api, ApiError, getToken, setToken } from "./api"; import { IconKitSvg } from "./components/IconKitSvg"; import type { AITask, @@ -177,12 +177,24 @@ export function App() { let cancelled = false; (async () => { try { - const identity = await api.me(); - if (cancelled) return; + // 瞬时故障(后端重启/网络抖动)要重试,不能把人踢回登录页;只有 401/403 才清 token + let identity: Awaited> | null = null; + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + identity = await api.me(); + break; + } catch (error) { + const status = error instanceof ApiError ? error.status : 0; + if (status === 401 || status === 403 || attempt === 2) throw error; + await new Promise((resolve) => setTimeout(resolve, 1200)); + } + } + if (cancelled || !identity) return; setUser(identity.user); setTeam(identity.team); await loadData(); - } catch { + } catch (bootError) { + console.error("[boot] failed:", bootError); setToken(null); if (!cancelled) setAuthed(false); } finally { @@ -233,18 +245,23 @@ export function App() { }, [authed, page, activeProjectId]); // 静默轮询运行中的视频段(本机无 Celery worker,由前端驱动 poll-video-segment),实时刷新管线进度,不弹 toast。 + // 资源账:旧实现每轮「GET 项目 → 逐段串行 POST → 再 GET 项目」,4 段在途时一轮 = 2 个 26KB GET + 4 个串行 + // ARK 轮询(总耗时随段数线性涨)。现用内存态定位在途段(省前置 GET),段间 Promise.all 并行,一轮只回读一次。 + const projectDetailRef = useRef(null); + useEffect(() => { + projectDetailRef.current = projectDetail; + }, [projectDetail]); const pollVideosQuiet = useCallback(async () => { if (!activeProjectId) return; - const detail = await api.project(activeProjectId).catch(() => null); - if (!detail) return; - const active = detail.video_segments.filter((segment) => ["running", "queued"].includes(segment.status)); - if (active.length === 0) { + let detail = projectDetailRef.current; + if (!detail || detail.id !== activeProjectId) { + detail = await api.project(activeProjectId).catch(() => null); + if (!detail) return; setProjectDetail(detail); - return; - } - for (const segment of active) { - await api.pollVideo(activeProjectId, segment.id).catch(() => undefined); } + const active = detail.video_segments.filter((segment) => ["running", "queued"].includes(segment.status)); + if (active.length === 0) return; + await Promise.all(active.map((segment) => api.pollVideo(activeProjectId, segment.id).catch(() => undefined))); const next = await api.project(activeProjectId).catch(() => null); if (next) setProjectDetail(next); }, [activeProjectId]); @@ -457,6 +474,7 @@ export function App() { return ( navigate("projects")} onCreate={async (payload) => { const created = await action(() => api.createProject(payload), "项目已创建"); @@ -532,9 +550,9 @@ export function App() { case "modelPhotoDemoB": return navigate("modelPhoto")} navigate={navigate} />; case "settings": - return setNotice({ type: "success", text })} />; + return setNotice({ type: "success", text })} onLogout={logout} />; case "settingsNotify": - return setNotice({ type: "success", text })} />; + return setNotice({ type: "success", text })} onLogout={logout} />; default: return ; } @@ -546,9 +564,11 @@ export function App() { // 生产管线 · 全屏 bespoke 布局(自带顶栏 + 步进器),脱离常规 shell const pipelineProject = projectDetail || activeProject; if (page === "pipeline" && pipelineProject) { + const textModel = modelConfigs.find((m) => m.capability === "text" && m.status === "active") || modelConfigs.find((m) => m.capability === "text"); return ( action(() => api.generateScript(pipelineProject.id, { prompt }), "脚本已生成")} + onGenerateScript={(prompt, source) => action(() => api.generateScript(pipelineProject.id, { prompt, source }), "脚本已生成")} onAdoptScript={(scriptId) => action(() => api.adoptScript(pipelineProject.id, scriptId), "脚本已采用")} + onUpdateShot={(payload) => action(() => api.updateScriptSegment(pipelineProject.id, payload), "分镜已更新")} + onAddShot={(afterSegmentId) => action(() => api.addScriptSegment(pipelineProject.id, { after_segment_id: afterSegmentId }), "分镜已添加")} + onDeleteShot={(segmentId) => action(() => api.deleteScriptSegment(pipelineProject.id, { segment_id: segmentId }), "分镜已删除")} + onAdoptVideoVersion={(segmentId, versionId) => action(() => api.adoptVideoVersion(pipelineProject.id, { video_segment_id: segmentId, version_id: versionId }), "已采用该版本")} + onGenerateVoiceover={(payload) => action(() => api.generateVoiceover(pipelineProject.id, payload), "配音已生成")} onGenerateBaseAsset={(kind, prompt) => action(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt }), "基础资产已生成")} onGenerateStoryboard={(prompt) => action(async () => { @@ -571,16 +595,17 @@ export function App() { await api.generateStoryboard(pipelineProject.id, { prompt }); for (let i = 0; i < 60; i += 1) { const res = await api.pollStoryboard(pipelineProject.id); - if (res.status === "succeeded") break; - if (res.status === "failed") throw new Error("故事板生成失败,请重试"); + if (res.status === "succeeded") return true; + if (res.status === "failed") throw new Error(res.error || "故事板生成失败,请重试"); await new Promise((resolve) => setTimeout(resolve, 4000)); } - return true; + // 轮询窗口耗尽仍未完成:如实报超时,不能让 action 弹「已生成」的假成功 toast + throw new Error("故事板生成超时,请稍后刷新查看或重试"); }, "故事板已生成") } + onAdoptBaseAsset={(groupId, assetId) => action(() => api.adoptBaseAsset(pipelineProject.id, { group_id: groupId, asset_id: assetId }), "已采用该候选")} onSkipStoryboard={() => action(() => api.skipStoryboard(pipelineProject.id), "已跳过故事板")} onSubmitVideo={(segmentId, prompt) => action(() => api.submitVideo(pipelineProject.id, { video_segment_id: segmentId, prompt }), "视频片段已提交,生成中…")} - onPollVideo={(segmentId) => action(() => api.pollVideo(pipelineProject.id, segmentId), "片段状态已刷新")} onSubmitAllVideos={(prompt) => action(async () => { const targets = pipelineProject.video_segments.filter((segment) => !["running", "succeeded"].includes(segment.status)); @@ -594,17 +619,9 @@ export function App() { }, "多段视频已提交,生成中…") } onPollVideosQuiet={pollVideosQuiet} - onPollAllVideos={() => - action(async () => { - const targets = pipelineProject.video_segments.filter((segment) => ["running", "queued"].includes(segment.status)); - for (const segment of targets) { - await api.pollVideo(pipelineProject.id, segment.id).catch(() => undefined); - } - return targets.length; - }, "视频片段状态已刷新") - } exportResult={exportResult} onRefreshExport={refreshExport} + onRefreshProject={refreshProjectDetail} onUploadVideoSegment={(segmentId, file) => action(() => api.uploadVideoSegment(pipelineProject.id, segmentId, file), "视频已上传")} onUploadBgm={(file, volume) => action(() => api.uploadBgm(pipelineProject.id, file, volume), "BGM 已上传")} onSaveTimeline={(payload) => action(() => api.saveTimeline(pipelineProject.id, payload), "草稿已保存")} @@ -621,7 +638,8 @@ export function App() { if (res.status === "failed") throw new Error(res.error_message || "拼接导出失败,请重试"); await new Promise((resolve) => setTimeout(resolve, 2500)); } - return null; + // 轮询窗口耗尽:如实报超时(后台 ffmpeg 可能仍在跑,进入拼接页会自动回填) + throw new Error("导出超时,后台可能仍在拼接,稍后回到本页查看"); }, "成片已导出") } /> diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index 401aac8..0974bab 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -17,7 +17,8 @@ import type { Team, TeamMember, User, - UserPreference + UserPreference, + VoiceoverInfo } from "./types"; const API_BASE = import.meta.env.VITE_API_BASE_URL || ""; @@ -53,7 +54,17 @@ async function request(path: string, options: RequestInit = {}): Promise { const response = await fetch(`${API_BASE}${path}`, { ...options, headers }); if (!response.ok) { const text = await response.text(); - throw new ApiError(response.status, text || `${response.status} ${response.statusText}`); + // DRF 错误体是 JSON({"detail": "..."} 或 {field: ["..."]}),提取人话给 toast,别把原始 JSON 怼到用户脸上 + let message = text || `${response.status} ${response.statusText}`; + try { + const data = JSON.parse(text) as Record; + const first = data.detail ?? data.error ?? data.message ?? Object.values(data)[0]; + if (typeof first === "string") message = first; + else if (Array.isArray(first) && typeof first[0] === "string") message = first[0]; + } catch { + /* 非 JSON(如网关 HTML)保持原文 */ + } + throw new ApiError(response.status, message); } if (response.status === 204) return undefined as T; return response.json() as Promise; @@ -168,7 +179,7 @@ export const api = { deleteProject(id: string) { return request(`/api/projects/${id}/`, { method: "DELETE" }); }, - generateScript(projectId: string, payload: { prompt: string; selling_point_ids?: string[] }) { + generateScript(projectId: string, payload: { prompt: string; source?: string; selling_point_ids?: string[] }) { return request(`/api/projects/${projectId}/generate-script/`, { method: "POST", body: JSON.stringify(payload) @@ -180,6 +191,21 @@ export const api = { body: JSON.stringify({ script_version_id }) }); }, + updateScriptSegment(projectId: string, payload: { segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) { + return request(`/api/projects/${projectId}/update-script-segment/`, { method: "POST", body: JSON.stringify(payload) }); + }, + addScriptSegment(projectId: string, payload: { after_segment_id: string; narration?: string; visual_prompt?: string; duration_seconds?: number }) { + return request(`/api/projects/${projectId}/add-script-segment/`, { method: "POST", body: JSON.stringify(payload) }); + }, + deleteScriptSegment(projectId: string, payload: { segment_id: string }) { + return request(`/api/projects/${projectId}/delete-script-segment/`, { method: "POST", body: JSON.stringify(payload) }); + }, + adoptVideoVersion(projectId: string, payload: { video_segment_id: string; version_id: string }) { + return request(`/api/projects/${projectId}/adopt-video-version/`, { method: "POST", body: JSON.stringify(payload) }); + }, + generateVoiceover(projectId: string, payload: { items: Array<{ index: number; text: string }>; voice_type?: string; speed_ratio?: number }) { + return request<{ voiceover: VoiceoverInfo }>(`/api/projects/${projectId}/generate-voiceover/`, { method: "POST", body: JSON.stringify(payload) }); + }, generateBaseAsset(projectId: string, payload: { kind: "product" | "person" | "scene"; prompt: string }) { return request(`/api/projects/${projectId}/generate-base-asset/`, { method: "POST", body: JSON.stringify(payload) }); }, @@ -231,6 +257,15 @@ export const api = { assets() { return request>("/api/assets/"); }, + // 经后端同源代理取资产原始文件(TOS 未配 CORS,浏览器抽帧/解码必须同源) + async fetchAssetBlob(id: string): Promise { + const token = getToken(); + const response = await fetch(`${API_BASE}/api/assets/${id}/raw/`, { + headers: token ? { Authorization: `Token ${token}` } : undefined + }); + if (!response.ok) throw new ApiError(response.status, `fetch asset raw failed: ${response.status}`); + return response.blob(); + }, // 跟随 DRF 分页 next 取全部资产 —— 商品图/AI 素材/资产库都靠 asset.id 在这份列表里查 preview_url, // 只取第 1 页(20 条)会让第 20 条之后的资产解析不到图、渲染成空占位。 async allAssets(): Promise { diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index 212cf2e..fd32a45 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { createPortal } from "react-dom"; import { Check } from "lucide-react"; import { IconKitSvg } from "./IconKitSvg"; +import { useBodyScrollLock } from "./overlays"; import type { Product, Project, Team, User } from "../types"; import type { Notice, Page } from "../routes/route-config"; @@ -28,6 +29,7 @@ const SHELL_COMMANDS: Command[] = [ function CommandPalette({ open, onClose, navigate }: { open: boolean; onClose: () => void; navigate: Navigate }) { const [query, setQuery] = useState(""); + useBodyScrollLock(open); useEffect(() => { if (open) setQuery(""); }, [open]); const items = useMemo(() => { const q = query.trim().toLowerCase(); @@ -149,6 +151,16 @@ export function Sidebar({ page, navigate, user, team, products, projects }: { return () => document.body.classList.remove("sidebar-collapsed"); }, [collapsed]); + // 移动端导航抽屉:窄屏侧栏默认收起(display:none),靠左上汉堡键拉出为浮层 + 背景遮罩。 + // 点导航项 / 遮罩 / Esc 关闭。桌面端(>1100px)汉堡键 CSS 隐藏,不影响原有收窄逻辑。 + const [mobileNavOpen, setMobileNavOpen] = useState(false); + useEffect(() => { + if (!mobileNavOpen) return; + const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setMobileNavOpen(false); }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [mobileNavOpen]); + // 命令面板:Ctrl/Cmd K 开关,点搜索框打开 const [paletteOpen, setPaletteOpen] = useState(false); const openCommandPalette = () => setPaletteOpen(true); @@ -165,7 +177,12 @@ export function Sidebar({ page, navigate, user, team, products, projects }: { return ( <> -