diff --git a/core/backend/.env b/core/backend/.env index 7f6ccff..4f2be41 100644 --- a/core/backend/.env +++ b/core/backend/.env @@ -28,7 +28,7 @@ YUNQI_API_KEY=sk-xvfKxM6q2TlGEV4SMZxmg8nf20tR94yG6nSBT5iHfXyuaAvl YUNQI_BASE_URL=https://www.yunqiai.chat/v1 # YunQi 对话(脚本助手)· 按模型分开计费的两把 key(同一 base_url,各自独立额度) YUNQI_GPT_API_KEY=sk-xvfKxM6q2TlGEV4SMZxmg8nf20tR94yG6nSBT5iHfXyuaAvl -YUNQI_GEMINI_API_KEY=sk-xvfKxM6q2TlGEV4SMZxmg8nf20tR94yG6nSBT5iHfXyuaAvl +YUNQI_GEMINI_API_KEY=sk-mLqAO8PixQVBSD1ZHeIsQMUSmlsJ9OZGDxPDna8TBi7krUHQ # tokenssr 中转站(主力:gpt-image-2 参考图 + gpt-5.5/gemini 文本 + gemini 图像)· 一把 key 通吃 90 模型 TOKENSSR_API_KEY=sk-vDg00IAX6EW4ABo4ePwAFPRaIjScFLUs1ZM1lInZJvs7z6M0 diff --git a/core/backend/airshelf/settings/base.py b/core/backend/airshelf/settings/base.py index 9f74d30..1b5fe9a 100644 --- a/core/backend/airshelf/settings/base.py +++ b/core/backend/airshelf/settings/base.py @@ -248,6 +248,7 @@ PROVIDER_BASE_URLS = { # 故拆成两个 provider(一 provider 一 key 是现有约定)。base_url 都指 YunQi。 "yunqi_gpt": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"), "yunqi_gemini": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"), + "yunqi_gemini_guanzhuan": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"), "tokenssr": env("TOKENSSR_BASE_URL", "https://king.tokenssr.com/v1"), } PROVIDER_KEYS = { @@ -255,6 +256,7 @@ PROVIDER_KEYS = { "yunqi": env("YUNQI_API_KEY", ""), "yunqi_gpt": env("YUNQI_GPT_API_KEY", ""), "yunqi_gemini": env("YUNQI_GEMINI_API_KEY", ""), + "yunqi_gemini_guanzhuan": env("YUNQI_GEMINI_API_KEY", ""), "tokenssr": env("TOKENSSR_API_KEY", ""), } # 临时:视频(Seedance)单独借 AirDrama 火山账号的 ARK key。真人素材库审核(ASSETS_API)建在 AirDrama 账号下, diff --git a/core/backend/apps/ai/generation_errors.py b/core/backend/apps/ai/generation_errors.py index 1ab4875..fb7d349 100644 --- a/core/backend/apps/ai/generation_errors.py +++ b/core/backend/apps/ai/generation_errors.py @@ -53,6 +53,7 @@ OPERATION_LABELS = { "video_generate": "视频", "voiceover_generate": "配音", "entity_extract": "脚本信息", + "video_digest": "视频提炼", } TASK_OPERATIONS = { @@ -67,6 +68,7 @@ TASK_OPERATIONS = { "video_segment": "video_generate", "voiceover": "voiceover_generate", "free_video": "video_generate", + "video_digest": "video_digest", } diff --git a/core/backend/apps/ai/routing_executor.py b/core/backend/apps/ai/routing_executor.py index 58b7363..2916a45 100644 --- a/core/backend/apps/ai/routing_executor.py +++ b/core/backend/apps/ai/routing_executor.py @@ -316,6 +316,8 @@ def execute_model_call( error_metadata: Callable[[Exception, ModelConfig], AttemptMetadata | Mapping[str, Any]] | None = None, error_classifier: Callable[[Exception, ModelConfig], PublicGenerationError] | None = None, candidate_resolver: Callable[..., list[ModelConfig]] | None = None, + allow_retry: bool = True, + allow_fallback: bool = True, sleep: Callable[[float], None] = time.sleep, monotonic: Callable[[], float] = time.monotonic, uniform: Callable[[float, float], float] = random.uniform, @@ -328,7 +330,11 @@ def execute_model_call( raise ValueError("主模型 capability 与本次 ModelRequirements 不一致") policy, retry_delays, request_timeout, total_timeout, retry_after_cap = _ability_policy(requirements) + if not allow_retry: + retry_delays = () resolver = candidate_resolver or resolve_fallback_candidates + if not allow_fallback: + resolver = lambda **_kwargs: [] result_meta = result_metadata or _default_result_metadata error_meta = error_metadata or _default_error_metadata classify = error_classifier or ( diff --git a/core/backend/apps/ai/script_agent.py b/core/backend/apps/ai/script_agent.py index ab6a25b..c1641fc 100644 --- a/core/backend/apps/ai/script_agent.py +++ b/core/backend/apps/ai/script_agent.py @@ -83,10 +83,11 @@ PERSONA_BRIEFS: dict[str, str] = { } _PERSONA_KEY_BY_LABEL = {label: key for key, label in PERSONA_LABELS.items()} -# 可懂语速上限 3.5 字/秒;15 秒口播还要有下限,否则模型写一句 20 字就收,撑不满。 -NARRATION_CHARS_PER_SECOND = 3.5 -NARRATION_CHARS_PER_SECOND_MIN = 2.4 -NARRATION_CHARS_HARD_CAP = 55 +# 成片有同期画面与自然停顿,3.5 字/秒会让 15 秒口播在约 10 秒时说完。 +# 电商真人口播以 3.6–4.5 字/秒为可用区间:既留出换气,也不会让画面空转。 +NARRATION_CHARS_PER_SECOND = 4.5 +NARRATION_CHARS_PER_SECOND_MIN = 3.6 +NARRATION_CHARS_HARD_CAP = 68 VISUAL_CHARS_MIN = 72 SHOT_BEATS_MIN = 3 BEAT_SPAN_RE = re.compile( @@ -160,7 +161,7 @@ def coerce_combo(fmt: str | None, structure: str | None) -> tuple[str, str]: def narration_limit(duration: int) -> int: - """这一镜旁白的字数上限:秒数 × 3.5,且不超过硬上限 55。""" + """这一镜旁白的字数上限:秒数 × 4.5,且不超过硬上限 68。""" return max(1, min(NARRATION_CHARS_HARD_CAP, int(duration * NARRATION_CHARS_PER_SECOND))) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index e8c3326..b46cb7b 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -122,8 +122,14 @@ def public_model_name(model_config: ModelConfig) -> str: def resolve_provider_credentials(provider) -> tuple[str | None, str | None]: """解析中转站凭证。可插拔顺序:DB(ModelProvider.base_url/api_key)优先 → settings(.env)回退。 两者都不写死;换站只改 DB 这一行,或改 .env 对应项。""" - base_url = (provider.base_url or "").strip() or settings.PROVIDER_BASE_URLS.get(provider.name) - api_key = (getattr(provider, "api_key", "") or "").strip() or settings.PROVIDER_KEYS.get(provider.name) + name = provider.name + base_url = (provider.base_url or "").strip() or settings.PROVIDER_BASE_URLS.get(name) + api_key = (getattr(provider, "api_key", "") or "").strip() or settings.PROVIDER_KEYS.get(name) + # 官转等 yunqi_gemini_* 变体共用同一把 YunQi Gemini key,避免新 provider 没写进 .env 就落到火山。 + if not api_key and name.startswith("yunqi_gemini"): + api_key = (settings.PROVIDER_KEYS.get("yunqi_gemini") or "").strip() + if not base_url and name.startswith("yunqi_gemini"): + base_url = (settings.PROVIDER_BASE_URLS.get("yunqi_gemini") or "").strip() return (base_url or None), (api_key or None) @@ -616,6 +622,8 @@ def execute_routed_text_request( request_summary: dict | None = None, stream_event_callback=None, abort_check=None, + allow_retry: bool = True, + allow_fallback: bool = True, ): """文本入口共用的模型调用薄层:能力声明、Provider 调用、输出校验和尝试成本审计。 @@ -721,6 +729,8 @@ def execute_routed_text_request( internal_kind="processing_failed" if isinstance(exc, _RoutedTextStreamCancelled) else "", reference_id=str(task.id), ), + allow_retry=allow_retry, + allow_fallback=allow_fallback, ) diff --git a/core/backend/apps/ai/test_routing_executor.py b/core/backend/apps/ai/test_routing_executor.py index 5ac07e3..21c4a69 100644 --- a/core/backend/apps/ai/test_routing_executor.py +++ b/core/backend/apps/ai/test_routing_executor.py @@ -121,6 +121,31 @@ class RoutingExecutorTests(TestCase): self.assertEqual([item.is_retry for item in attempts], [False, True]) self.assertEqual(attempts[1].previous_attempt_id, attempts[0].id) + def test_allow_retry_false_fails_once_without_fallback(self): + primary = self.model(self.provider("exec-no-retry", 100), "primary") + candidate = self.model(self.provider("exec-no-retry-unused", 10), "unused") + task = self.task(primary, "exec-no-retry") + resolver = Mock(return_value=[candidate]) + calls = [] + + def invoke(model, timeout): + calls.append(model.id) + raise requests.ConnectionError("temporary network failure") + + with self.assertRaises(requests.ConnectionError): + self.execute( + task=task, + primary=primary, + invoke=invoke, + candidate_resolver=resolver, + allow_retry=False, + allow_fallback=False, + ) + + self.assertEqual(calls, [primary.id]) + resolver.assert_not_called() + self.assertEqual(task.model_attempts.count(), 1) + def test_outcome_unknown_never_retries_or_resolves_fallback_candidates(self): primary = self.model(self.provider("exec-unknown-primary", 100), "primary") candidate = self.model(self.provider("exec-unknown-candidate", 10), "candidate") diff --git a/core/backend/apps/ai/test_script_duration_combo.py b/core/backend/apps/ai/test_script_duration_combo.py index d92df26..f6f0be9 100644 --- a/core/backend/apps/ai/test_script_duration_combo.py +++ b/core/backend/apps/ai/test_script_duration_combo.py @@ -284,15 +284,15 @@ class PromptAssemblyTests(SimpleTestCase): class NarrationLimitTests(SimpleTestCase): def test_limit_scales_with_shot_length(self): - self.assertEqual(narration_limit(4), 14) - self.assertEqual(narration_limit(8), 28) - self.assertEqual(narration_limit(15), 52) + self.assertEqual(narration_limit(4), 18) + self.assertEqual(narration_limit(8), 36) + self.assertEqual(narration_limit(15), 67) def test_never_exceeds_hard_cap(self): - self.assertLessEqual(narration_limit(60), 55) + self.assertLessEqual(narration_limit(60), 68) - def test_fifteen_second_floor_is_about_thirty_six(self): - self.assertEqual(narration_floor(15), 36) + def test_fifteen_second_floor_is_about_fifty_four(self): + self.assertEqual(narration_floor(15), 54) self.assertLess(narration_floor(15), narration_limit(15)) @@ -321,7 +321,7 @@ class ShotDensityTests(SimpleTestCase): self.assertIn("旁白太短", str(ctx.exception)) def test_oral_meeting_floor_passes(self): - narration = "下午三点工位犯困,键盘都敲不利索。我倒了杯茶,第一口是回甘不是苦,整个人才慢慢醒过来。" + narration = "下午三点工位犯困,键盘都敲不利索。我以前总是硬扛,越扛脑子越乱。后来会先倒杯热茶,第一口是回甘不是苦,桌上的文件终于能看进去,整个人才慢慢醒过来。" self.assertGreaterEqual(len(narration.replace(" ", "")), narration_floor(15)) self.assertGreaterEqual(len(self._VISUAL.replace(" ", "")), VISUAL_CHARS_MIN) assert_shot_density( diff --git a/core/backend/apps/ai/test_video_digest.py b/core/backend/apps/ai/test_video_digest.py index 49cc321..b5af31b 100644 --- a/core/backend/apps/ai/test_video_digest.py +++ b/core/backend/apps/ai/test_video_digest.py @@ -7,8 +7,13 @@ from __future__ import annotations import subprocess +from unittest.mock import patch + from django.core.files.uploadedfile import SimpleUploadedFile from django.test import SimpleTestCase, TestCase +from rest_framework.test import APIClient + +from apps.accounts.models import Team, TeamMember, User from apps.ai.models import ModelConfig, ModelProvider from apps.ai.video_digest import ( @@ -174,6 +179,30 @@ class DigestModelPinTests(TestCase): self._model(doubao, "doubao-seed-2-0-pro-260215", "豆包 2.0 Pro", is_default=True) self.assertIsNone(resolve_digest_model_config()) + def test_honors_preferred_gemini_id(self): + relay = self._provider("digest-pin-two", "Gemini") + first = self._model(relay, DIGEST_VISION_MODEL_NAME, "Gemini 3.1 Pro") + official = self._model( + self._provider("digest-pin-official", "官转"), + "gemini-3.1-pro-preview-official", + "Gemini 3.1 Pro 官转", + ) + self.assertEqual(resolve_digest_model_config(preferred_id=first.id).id, first.id) + self.assertEqual(resolve_digest_model_config(preferred_id=official.id).id, official.id) + + def test_ignores_preferred_text_only_model(self): + doubao = self._provider("digest-pin-ignore", "火山") + text_only = self._model(doubao, "doubao-seed-2-0-pro-260215", "豆包 2.0 Pro", is_default=True) + gemini = self._model(self._provider("digest-pin-keep", "官转"), DIGEST_VISION_MODEL_NAME, "Gemini 3.1 Pro 官转") + self.assertEqual(resolve_digest_model_config(preferred_id=text_only.id).id, gemini.id) + + def test_ignores_volcano_hosted_gemini_label(self): + volcano = self._provider("volcengine", "火山") + fake = self._model(volcano, DIGEST_VISION_MODEL_NAME, "Gemini 3.1 Pro 官转", is_default=True) + relay = self._model(self._provider("digest-pin-relay-real", "官转"), DIGEST_VISION_MODEL_NAME, "Gemini 3.1 Pro 官转") + self.assertEqual(resolve_digest_model_config().id, relay.id) + self.assertEqual(resolve_digest_model_config(preferred_id=fake.id).id, relay.id) + # --------------------------------------------------------------------------- # # helpers @@ -194,3 +223,64 @@ def _digest_with_duration(clip, duration: float): with patch("apps.ai.video_digest.probe_duration", return_value=duration): return frames_from_upload(clip) + + +class VideoDigestApiTests(TestCase): + """视频复刻页独立入口:不绑项目,缺文件直接 400。""" + + def setUp(self): + self.user = User.objects.create_user(username="remix-api", password="p") + self.team = Team.objects.create(name="Remix", owner=self.user) + TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER) + self.client = APIClient() + self.client.force_authenticate(self.user) + + def test_rejects_missing_file(self): + response = self.client.post("/api/ai/video-digest/") + self.assertEqual(response.status_code, 400) + self.assertIn("视频", response.data["detail"]) + + def test_returns_digest_without_project(self): + payload = { + "text": "【整体结构】\n形式:口播\n" + "【第 1 镜】0-4 秒 · 钩子\n主体:一位女生\n" * 3, + "chars": 120, + "frames": 4, + "duration": 15.0, + "task_id": "00000000-0000-0000-0000-000000000001", + "estimated_cost": "30", + } + with patch("apps.ai.video_digest.digest_team_video", return_value=payload): + upload = SimpleUploadedFile("ref.mp4", b"x", content_type="video/mp4") + response = self.client.post("/api/ai/video-digest/", {"file": upload}, format="multipart") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["name"], "ref.mp4") + self.assertIn("【第 1 镜】", response.data["text"]) + self.assertEqual(response.data["duration"], 15.0) + + def test_forwards_model_config_id(self): + payload = { + "text": "【整体结构】\n形式:口播\n" + "【第 1 镜】0-4 秒 · 钩子\n主体:一位女生\n" * 3, + "chars": 120, + "frames": 4, + "duration": 15.0, + "task_id": "00000000-0000-0000-0000-000000000002", + "estimated_cost": "30", + } + with patch("apps.ai.video_digest.digest_team_video", return_value=payload) as mocked: + upload = SimpleUploadedFile("ref.mp4", b"x", content_type="video/mp4") + response = self.client.post( + "/api/ai/video-digest/", + {"file": upload, "model_config_id": "abc-123"}, + format="multipart", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(mocked.call_args.kwargs["model_config_id"], "abc-123") + + def test_provider_failure_returns_json_not_500(self): + with patch("apps.ai.video_digest.digest_team_video", side_effect=RuntimeError("upstream 402")): + upload = SimpleUploadedFile("ref.mp4", b"x", content_type="video/mp4") + response = self.client.post("/api/ai/video-digest/", {"file": upload}, format="multipart") + self.assertEqual(response.status_code, 502) + self.assertIsInstance(response.data["detail"], str) + self.assertTrue(response.data["detail"]) + self.assertEqual(response.data["error"]["operation"], "video_digest") diff --git a/core/backend/apps/ai/urls.py b/core/backend/apps/ai/urls.py index f9240a7..2c9ae1d 100644 --- a/core/backend/apps/ai/urls.py +++ b/core/backend/apps/ai/urls.py @@ -3,6 +3,7 @@ from rest_framework.routers import DefaultRouter from .views import ( AITaskViewSet, + VideoDigestView, FreeVideoDetailView, FreeVideoFavoriteView, FreeVideoPurgeView, @@ -23,6 +24,7 @@ router.register("image-conversations", ImageConversationViewSet, basename="image urlpatterns = [ path("generate-image/", GenerateImageView.as_view(), name="ai-generate-image"), + path("video-digest/", VideoDigestView.as_view(), name="ai-video-digest"), path("free-video/", FreeVideoView.as_view(), name="ai-free-video"), path("free-video/trash/", FreeVideoTrashView.as_view(), name="ai-free-video-trash"), path("free-video/upload/", FreeVideoUploadView.as_view(), name="ai-free-video-upload"), diff --git a/core/backend/apps/ai/video_digest.py b/core/backend/apps/ai/video_digest.py index d2692b0..bf79415 100644 --- a/core/backend/apps/ai/video_digest.py +++ b/core/backend/apps/ai/video_digest.py @@ -17,14 +17,18 @@ from __future__ import annotations import base64 import json +import logging import math import shutil import subprocess import tempfile +import uuid from dataclasses import dataclass from functools import lru_cache from pathlib import Path +logger = logging.getLogger(__name__) + from django.conf import settings # 上传限制:超了直接 400,不进 ffmpeg,也不花模型钱 @@ -199,13 +203,31 @@ def validate_digest_text(text: str) -> str: return cleaned -# 拆视频必须会看图。默认文本模型现在常是纯文本豆包,传帧图会直接失败。 -# 钉 Gemini 3.1 Pro;展示名带「官转」的优先(中转站官方 Gemini 通道)。 +# 拆视频必须会看图。火山豆包直连读不了这组帧图,会在 ARK 上挂满 120s。 +# 只认中转站的 Gemini 3.1 Pro;展示名带「官转」的优先。 DIGEST_VISION_MODEL_NAME = "gemini-3.1-pro-preview" -def resolve_digest_model_config(): - """视频提炼用的多模态文本模型:Gemini 3.1 Pro 官转。找不到不回落默认语言模型。""" +def _is_digest_vision_model(model) -> bool: + from apps.ai.services import OFFICIAL_DIRECT_PROVIDERS + + provider_name = getattr(getattr(model, "provider", None), "name", "") or "" + if provider_name in OFFICIAL_DIRECT_PROVIDERS: + return False + blob = f"{model.name} {model.display_name}".lower() + return ( + model.name == DIGEST_VISION_MODEL_NAME + or "gemini-3.1" in blob + or "gemini 3.1" in blob + ) + + +def resolve_digest_model_config(preferred_id=None): + """视频提炼用的多模态文本模型:Gemini 3.1 Pro 官转。找不到不回落默认语言模型。 + + 前端可传 model_config_id(跟生成脚本同一套下拉)。只有会看图的 Gemini 3.1 才认, + 选了豆包等纯文本模型仍钉回官转,避免拆帧直接失败。 + """ from apps.ai.models import ModelConfig qs = ( @@ -216,6 +238,10 @@ def resolve_digest_model_config(): provider__status="active", ) ) + if preferred_id: + chosen = qs.filter(pk=preferred_id).first() + if chosen is not None and _is_digest_vision_model(chosen): + return chosen def _blob(model) -> str: return " ".join( @@ -232,11 +258,9 @@ def resolve_digest_model_config(): ranked = [] for model in qs: - blob = _blob(model) - name_hit = "gemini-3.1" in model.name.lower() or "gemini-3.1" in (model.display_name or "").lower() - label_hit = "gemini 3.1" in (model.display_name or "").lower() - if not (name_hit or label_hit or model.name == DIGEST_VISION_MODEL_NAME): + if not _is_digest_vision_model(model): continue + blob = _blob(model) # 官转 > 精确模型名 > 其它 Gemini 3.1 score = 0 if "官转" in blob: @@ -255,44 +279,99 @@ def resolve_digest_model_config(): # --------------------------------------------------------------------------- # # 入口:一次真实的计费调用 # --------------------------------------------------------------------------- # -def digest_project_video(*, project, user, upload) -> dict: +def digest_project_video(*, project, user, upload, model_config_id=None) -> dict: + """上传视频 → 分镜稿。抽帧在建任务之前做,文件不合格不占积分。""" + product = getattr(project, "product", None) + return _digest_video( + team=project.team, + user=user, + upload=upload, + project=project, + product_hint=" · ".join( + filter(None, [getattr(product, "title", ""), getattr(product, "category", "")]) + ), + model_config_id=model_config_id, + ) + + +def digest_team_video(*, team, user, upload, model_config_id=None) -> dict: + """视频复刻页:不绑项目,计费挂当前团队。""" + return _digest_video( + team=team, + user=user, + upload=upload, + project=None, + product_hint="", + model_config_id=model_config_id, + ) + + +def _digest_video(*, team, user, upload, project=None, product_hint="", model_config_id=None) -> dict: """上传视频 → 分镜稿。抽帧在建任务之前做,文件不合格不占积分。""" from django.db import transaction from django.utils import timezone from apps.ai.models import AITask from apps.ai.services import create_ai_task, execute_routed_text_request - from apps.billing.services.ledger import charge_reserved_credit + from apps.billing.pricing import quote_flat + from apps.billing.services.ledger import charge_reserved_credit, reserve_credit frames, duration = frames_from_upload(upload) - model_config = resolve_digest_model_config() + model_config = resolve_digest_model_config(preferred_id=model_config_id) if model_config is None: raise VideoDigestError("视频提炼需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员") - - product = getattr(project, "product", None) - messages = build_digest_messages( - frames, - duration, - product_hint=" · ".join( - filter(None, [getattr(product, "title", ""), getattr(product, "category", "")]) - ), + logger.info( + "video digest using %s:%s (%s)", + model_config.provider.name, + model_config.name, + model_config.display_name, ) - task = create_ai_task( - project=project, - user=user, - task_type=AITask.Type.VIDEO_DIGEST, - model_config=model_config, - # 帧是几百 KB base64,绝不进 request_payload(会把 AITask 表撑爆),只记形状 - request_payload={ - "model": model_config.name, - "endpoint": model_config.endpoint, - "duration_seconds": round(duration, 2), - "frame_count": len(frames), - "frame_times": [f.at_seconds for f in frames], - }, - ) + messages = build_digest_messages(frames, duration, product_hint=product_hint) + request_payload = { + "model": model_config.name, + "endpoint": model_config.endpoint, + "feature": "video_remix" if project is None else "video_digest", + "duration_seconds": round(duration, 2), + "frame_count": len(frames), + "frame_times": [f.at_seconds for f in frames], + } + + if project is not None: + task = create_ai_task( + project=project, + user=user, + task_type=AITask.Type.VIDEO_DIGEST, + model_config=model_config, + # 帧是几百 KB base64,绝不进 request_payload(会把 AITask 表撑爆),只记形状 + request_payload=request_payload, + ) + else: + quote = quote_flat(model_config, team=team) + if quote.meta.get("rate"): + request_payload = {**request_payload, "points_per_yuan_snapshot": quote.meta["rate"]} + try: + with transaction.atomic(): + task = AITask.objects.create( + team=team, + created_by=user, + project=None, + task_type=AITask.Type.VIDEO_DIGEST, + status=AITask.Status.CREATED, + model_config=model_config, + idempotency_key=f"video_digest:{team.id}:{uuid.uuid4()}", + request_payload=request_payload, + estimated_cost=quote.points, + base_cost=quote.base_cost_yuan, + ) + reserve_credit(team=team, user=user, task=task, amount=quote.points) + task.status = AITask.Status.RESERVED + task.save(update_fields=["status", "updated_at"]) + except ValueError as exc: + if "insufficient credit" in str(exc).lower(): + raise VideoDigestError("团队余额不足,请充值后重试") from exc + raise VideoDigestError(str(exc)) from exc reservation = task.credit_reservation try: task.status = AITask.Status.SUBMITTED @@ -302,12 +381,14 @@ def digest_project_video(*, project, user, upload) -> dict: task=task, primary_model=model_config, messages=messages, - streaming=False, + streaming=True, structured_output=False, business_operation="video_digest", temperature=0.4, validate_text=validate_digest_text, request_summary={"duration_seconds": round(duration, 2), "frame_count": len(frames)}, + allow_retry=False, + allow_fallback=False, ) _text, _response, digest = routed.value except Exception as exc: # noqa: BLE001 @@ -328,6 +409,7 @@ def digest_project_video(*, project, user, upload) -> dict: "frames": len(frames), "duration": round(duration, 1), "task_id": str(task.id), + "estimated_cost": str(task.estimated_cost), } diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 27903c0..662cdea 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -1,3 +1,4 @@ +import logging import uuid from django.db import transaction @@ -27,6 +28,8 @@ from .serializers import ( ) from .services import enqueue_standalone_images +logger = logging.getLogger(__name__) + class GenerateImageView(APIView): """独立生图(不绑项目)· 图片创作/模特图/平台套图共用 —— **异步**。 @@ -693,6 +696,40 @@ def _set_free_video_generated_assets_deleted(task, deleted): Asset.objects.filter(team=task.team, origin_task=task, purged_at__isnull=True).update(is_deleted=deleted) +class VideoDigestView(APIView): + """视频复刻 · 上传参考视频提炼分镜稿(不绑项目)。 + + POST /api/ai/video-digest/ multipart file → 中文分镜稿。慢(30~60 秒),计一次文本费。 + """ + + parser_classes = [MultiPartParser, FormParser] + + def post(self, request): + from .video_digest import VideoDigestError, digest_team_video + + upload = request.FILES.get("file") or request.data.get("file") + if upload is None: + return Response({"detail": "请先上传参考视频"}, status=status.HTTP_400_BAD_REQUEST) + team = get_current_team(request.user) + try: + result = digest_team_video( + team=team, + user=request.user, + upload=upload, + model_config_id=request.data.get("model_config_id") or None, + ) + except VideoDigestError as exc: + return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + except Exception as exc: # noqa: BLE001 — 模型/网络失败:走统一安全文案 + logger.exception("video digest failed") + public_error = classify_generation_error(exc, operation="video_digest") + return Response( + {"detail": public_error.fallback_message, "error": public_error.as_dict()}, + status=status.HTTP_502_BAD_GATEWAY, + ) + return Response({"name": getattr(upload, "name", "") or "参考视频", **result}) + + class FreeVideoView(APIView): """自由创作·视频生成(不绑项目,universal 全能参考 / keyframe 首尾帧)。 diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 2d6fdd5..8b0bd57 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -1372,12 +1372,20 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): if upload is None: return Response({"detail": "no file uploaded"}, status=status.HTTP_400_BAD_REQUEST) try: - result = digest_project_video(project=project, user=request.user, upload=upload) + result = digest_project_video( + project=project, + user=request.user, + upload=upload, + model_config_id=request.data.get("model_config_id") or None, + ) except VideoDigestError as exc: return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) except Exception as exc: # noqa: BLE001 — 模型/网络失败:走统一安全文案,不回传原始异常 public_error = classify_generation_error(exc, operation="video_digest") - return Response(public_error, status=status.HTTP_502_BAD_GATEWAY) + return Response( + {"detail": public_error.fallback_message, "error": public_error.as_dict()}, + status=status.HTTP_502_BAD_GATEWAY, + ) return Response({"name": getattr(upload, "name", "") or "参考视频", **result}) @action(detail=True, methods=["post"], url_path="upload-bgm", parser_classes=[MultiPartParser, FormParser]) diff --git a/core/backend/skills/ecommerce-video-script/SKILL.md b/core/backend/skills/ecommerce-video-script/SKILL.md index 62e76ba..fa97458 100644 --- a/core/backend/skills/ecommerce-video-script/SKILL.md +++ b/core/backend/skills/ecommerce-video-script/SKILL.md @@ -59,7 +59,7 @@ description: > "index": 0, "duration": 15, "role": "钩子|痛点|卖点|CTA", - "narration": "这一镜被说出来的台词/旁白,15秒口播目标38-47字(下限36,上限52)", + "narration": "这一镜被说出来的台词/旁白,15秒口播目标55-63字(下限54,上限67)", "speaker": "可选,指向某 entity 的 id;画外旁白时为 null", "visual": "0-3s:近景,……\\n3-8s:手持跟拍,开始使用商品\\n8-12s:特写,商品用法过程\\n12-15s:拉回中近景,使用后的反应", "product_exposure": "商品露出方式(手持/特写/使用中)", @@ -117,8 +117,8 @@ description: > 详见 `references/methodology.md`「旁白红线」。最关键的几条: - **口语化**,像真人对着镜头说话,不准书面腔 / AI 腔。 -- **口播 15 秒镜要说满**:`narration` **36–52 字**(写作目标 **38–47 字,2–4 句短句**)。 - 上限仍是 `duration × 3.5` 且不超过 55 字。禁止一句 20 字收工。 +- **口播 15 秒镜要说满**:`narration` **54–67 字**(写作目标 **55–63 字,3–5 句短句**)。 + 上限为 `duration × 4.5` 且不超过 68 字。禁止一句 20 字收工。 商品名全片点名 1–2 次即可,其余镜用卖点原词和可感知细节(口感/气味/动作/使用场景)。 Vlog 才允许个别镜纯画面;口播禁止整镜无声。 - **禁违规词**:医疗功效(治疗/根治/抗癌…)、绝对化用语(最/第一/100%/国家级…)一律不写。 @@ -182,7 +182,7 @@ description: > 4. **抽取/创建 entities** — 识别脚本需要的角色 / 场景 / 商品;为每个 entity 写一份**全脚本共用**的 `visual_prompt`(保证多镜同一角色同一张脸);可选写 `voice_ref` 锁音色。 5. **按套路填结构** — 优先用视频结构套路里的骨架给每个 segment 分配 `role`; 套路没覆盖到的用「role 按镜数分配」表兜底;钩子镜套用该套路指定的钩子写法。 -6. **写 narration / visual / 商品露出** — 口播 15 秒镜旁白 38–47 字(下限 36)、口语化、过红线; +6. **写 narration / visual / 商品露出** — 口播 15 秒镜旁白 55–63 字(下限 54)、口语化、过红线; **visual 按秒拆成 3–5 条分镜**;必须把输入给出的卖点原词和商品描述细节写进旁白或对白,不要每句只重复商品名; 发声方式按表现形式套路来;每镜规划自然的 `product_exposure`。 7. **连引用** — 填 `entity_refs` 与 `speaker`,确认每个 entity 都被引用、id 都合法。 diff --git a/core/backend/skills/ecommerce-video-script/references/checklist.md b/core/backend/skills/ecommerce-video-script/references/checklist.md index 6f807cb..b8f754b 100644 --- a/core/backend/skills/ecommerce-video-script/references/checklist.md +++ b/core/backend/skills/ecommerce-video-script/references/checklist.md @@ -46,8 +46,8 @@ ## C. 旁白红线扫描(逐镜) -- [ ] **每镜 `narration` ≤ `duration × 3.5` 字,且绝不超过 55 字**(15 秒镜 ≤52 字)。 -- [ ] **口播 15 秒镜旁白 ≥ 36 字**(写作目标 38–47 字,2–4 句)。禁止一句 20 字收工;口播禁止整镜无声。 +- [ ] **每镜 `narration` ≤ `duration × 4.5` 字,且绝不超过 68 字**(15 秒镜 ≤67 字)。 +- [ ] **口播 15 秒镜旁白 ≥ 54 字**(写作目标 55–63 字,3–5 句)。禁止一句 20 字收工;口播禁止整镜无声。 - [ ] **秒级分镜**:每镜 `visual` 至少 3 条 `0-3s:景别,动作`,从 0 接到 15s;15 秒内至少切两次景别。 - [ ] **商品原词**:旁白/对白出现了输入给出的至少一个勾选卖点原词;商品名全片点名 1–2 次即可,其余镜写描述/卖点细节,没有用空话替换。 - [ ] 每镜 `visual` 能撑满 15 秒(至少 72 字),写清手/商品/容器的空间关系和真实用法,不是一句静态动作。 diff --git a/core/backend/skills/ecommerce-video-script/references/methodology.md b/core/backend/skills/ecommerce-video-script/references/methodology.md index ced2a3f..96eb11a 100644 --- a/core/backend/skills/ecommerce-video-script/references/methodology.md +++ b/core/backend/skills/ecommerce-video-script/references/methodology.md @@ -96,8 +96,8 @@ - **口语化**:像真人对着镜头唠嗑,不准书面腔 / 不准 AI 腔("综上所述""不仅…而且""值得一提的是"全禁)。 - **必须用商品原词**:输入给了商品名、品牌、卖点,就用这些词,不许改成「补水/好用/值得买」这种空卖点。品类示例句只是语气参考。 - **`narration` 字数按这一镜的秒数算**:出片模型在镜内直接发声。 - 可懂语速上限约 **3.5 字 / 秒**,口播下限约 **2.4 字 / 秒**(再短镜头空一半)。 - **每镜上限 `duration × 3.5` 字,且不超过 55 字。口播 15 秒镜还有下限 36 字。** + 可懂语速上限约 **4.5 字 / 秒**,口播下限约 **3.6 字 / 秒**(再短镜头空一半)。 + **每镜上限 `duration × 4.5` 字,且不超过 68 字。口播 15 秒镜还有下限 54 字。** | 单镜时长 | 下限 | 字数上限 | 写作目标 | | ---- | ---- | ---- | ---- | @@ -107,7 +107,7 @@ | 8 秒 | — | 28 字 | ≤ 25 字 | | 10 秒 | — | 35 字 | ≤ 31 字 | | 12 秒 | — | 42 字 | ≤ 38 字 | - | 15 秒 | **36 字** | 52 字 | **38–47 字,2–4 句** | + | 15 秒 | **54 字** | 67 字 | **55–63 字,3–5 句** | 主流程每镜都是 15 秒:**要说满,不要宁可短。** 一句 20 字撑不满镜头。 留白靠句间停顿和画面,不靠少写字。只有 Vlog 才允许个别镜纯画面无声。 diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/format-drama.md b/core/backend/skills/ecommerce-video-script/references/playbooks/format-drama.md index d51cf42..c80db04 100644 --- a/core/backend/skills/ecommerce-video-script/references/playbooks/format-drama.md +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/format-drama.md @@ -53,7 +53,7 @@ - **单镜固定 15 秒**(短剧用镜内动作和对白交换撑满这 15 秒,不要靠切很多短镜) - 一镜一次冲突或一次对话交换,**别在一镜里塞完整三幕** -- 有对白的镜,每句 `line` ≤ 25 字(两个人说话,总量还是受 55 字限制) +- 有对白的镜,每句 `line` ≤ 25 字(两个人说话,总量还是受 68 字限制) **推荐总时长 45 秒(3 镜)。** 短剧需要装下三幕,低于 30 秒会讲不完整; 超过 60 秒在电商场景下性价比开始下降。 diff --git a/core/backend/skills/ecommerce-video-script/references/playbooks/format-oral.md b/core/backend/skills/ecommerce-video-script/references/playbooks/format-oral.md index c04a1a7..19d1616 100644 --- a/core/backend/skills/ecommerce-video-script/references/playbooks/format-oral.md +++ b/core/backend/skills/ecommerce-video-script/references/playbooks/format-oral.md @@ -52,7 +52,7 @@ - **单镜固定 15 秒**(口播里说完一个完整意思需要时间;15 秒靠景别切换和手部动作撑满,不要切碎) - 一句话讲一件事,**一镜 2–4 句短句**,禁止一句收工 - **visual 按秒拆 3–5 刀**,写成 `0-3s:近景……` / `3-8s:手持……` / `8-12s:特写……` / `12-15s:拉回……` -- 语速按 **2.4–3.5 字 / 秒** 估:15 秒镜 **36–52 字**,写作目标 **38–47 字** +- 语速按 **3.6–4.5 字 / 秒** 估:15 秒镜 **54–67 字**,写作目标 **55–63 字** **推荐总时长 30 秒(2 镜)。** 口播超过 45 秒完播率掉得很快,除非是测评验证这种 需要举证的结构。 diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 2754c65..1eca608 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -29,6 +29,7 @@ import { AuthScreen, Dashboard, FreeCreatePage, + VideoRemixPage, ImageWorkbenchPage, LibraryPage, MessagesPage, @@ -1045,6 +1046,8 @@ export function App() { return ; case "freeCreate": return setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} onBack={() => goBack("projects")} />; + case "videoRemix": + return m.capability === "text" && m.status === "active")} onNotify={(type, text) => setNotice({ type, text })} onBack={() => goBack("projects")} navigate={navigate} />; case "imageOptimize": return goBack("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />; case "modelPhoto": diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index e744615..b6859cb 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -348,6 +348,13 @@ export const api = { { method: "POST", body: formData } ); }, + // 视频复刻页:不绑项目,抽帧 + 多模态读帧出中文分镜稿。慢(30~60 秒),计一次文本费。 + extractVideoDigest(formData: FormData) { + return request<{ name: string; chars: number; text: string; frames: number; duration: number; estimated_cost?: string }>( + "/api/ai/video-digest/", + { method: "POST", body: formData } + ); + }, // ── 套路模板(5.1 存 / 5.2 换商品重跑)· 团队隔离 ── // 存模板只抽套路不抽文案:后端从这版脚本抽镜数/每镜作用/节奏/商品露出/转化写法。 saveProjectAsTemplate(projectId: string, payload: { name: string; script_version_id?: string }) { diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index 103e917..0ca2a67 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -16,6 +16,7 @@ const SHELL_COMMANDS: Command[] = [ { id: "projects", group: "导航", label: "视频创作", sub: "从商品或参考视频出发,选择生产方式", page: "projects", icon: "clapperboard", key: "V" }, { id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" }, { id: "free-create", group: "导航", label: "自由创作", sub: "AI 视频生成 · 全能参考 / 首尾帧", page: "freeCreate", icon: "film", key: "F" }, + { id: "video-remix", group: "导航", label: "视频复刻", sub: "上传参考视频,拆解镜头并生成提示词", page: "videoRemix", icon: "scan", key: "R" }, { id: "library", group: "导航", label: "成品库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" }, { id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" }, { id: "account", group: "导航", label: "账单库", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" }, @@ -269,7 +270,7 @@ export function topModuleForPage(page: Page): TopModule | null { || page === "modelPhotoDemoB" || page === "platformCover" ) return "image"; - if (page === "projects" || page === "projectWizard" || page === "pipeline" || page === "freeCreate") return "video"; + if (page === "projects" || page === "projectWizard" || page === "pipeline" || page === "freeCreate" || page === "videoRemix") return "video"; return null; } @@ -399,6 +400,7 @@ const PAGE_TO_NAV: Partial> = { modelPhotoDemoB: "assetFactory", platformCover: "assetFactory", freeCreate: "freeCreate", + videoRemix: "projects", library: "library", team: "team", account: "account", diff --git a/core/frontend/src/components/free-create/generation-card.tsx b/core/frontend/src/components/free-create/generation-card.tsx index 509edfa..803c389 100644 --- a/core/frontend/src/components/free-create/generation-card.tsx +++ b/core/frontend/src/components/free-create/generation-card.tsx @@ -5,11 +5,6 @@ import type { FreeVideoTask } from "../../types"; import { MODE_LABELS, STATUS_LABELS, isInFlight, modelLabel } from "./constants"; import { isPublicGenerationError, presentGenerationError } from "../../generation-error"; -function ratioStyle(ratio: string) { - const [w, h] = ratio.split(":").map(Number); - return { aspectRatio: w && h ? `${w} / ${h}` : "16 / 9" }; -} - function ratioCopy(ratio: string) { const [w, h] = ratio.split(":").map(Number); if (!w || !h) return ratio; @@ -45,7 +40,6 @@ export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavori
= [ + { key: "all", label: "全部" }, + { key: "inflight", label: "生成中" }, + { key: "succeeded", label: "已完成" }, + { key: "failed", label: "失败" } +]; + +const GROUP_LABEL: Record<"today" | "yesterday" | "earlier", string> = { + today: "今天", + yesterday: "昨天", + earlier: "更早" +}; + +function chinaDay(iso: string | null) { + if (!iso) return ""; + return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date(iso)); +} + +function dayBucket(iso: string | null): "today" | "yesterday" | "earlier" { + const day = chinaDay(iso); + if (!day) return "earlier"; + const today = chinaDay(new Date().toISOString()); + if (day === today) return "today"; + const yesterday = chinaDay(new Date(Date.now() - 86400000).toISOString()); + return day === yesterday ? "yesterday" : "earlier"; +} + +function matchesFeedFilter(task: FreeVideoTask, filter: FeedFilter) { + if (filter === "all") return true; + if (filter === "inflight") return isInFlight(task.status); + if (filter === "succeeded") return task.status === "succeeded"; + return task.status === "failed" || task.status === "cancelled"; +} + function loadProgress(): Record { try { return JSON.parse(sessionStorage.getItem(PROGRESS_KEY) || "{}") as Record; @@ -97,12 +134,21 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack } const [submitting, setSubmitting] = useState(false); const promptRef = useRef(null); + useEffect(() => { + const draft = sessionStorage.getItem(REMIX_PROMPT_KEY); + if (!draft) return; + sessionStorage.removeItem(REMIX_PROMPT_KEY); + window.setTimeout(() => promptRef.current?.setContent(draft, []), 0); + notify("info", "已填入复刻提示词,可改完再生成"); + }, [notify]); + // —— 弹窗 —— const [detailId, setDetailId] = useState(null); const [libraryOpen, setLibraryOpen] = useState(false); const [platformOpen, setPlatformOpen] = useState(false); const [libraryTargetRole, setLibraryTargetRole] = useState<"first_frame" | "last_frame" | null>(null); const [deleteTarget, setDeleteTarget] = useState(null); + const [feedFilter, setFeedFilter] = useState("all"); // —— 轮询/进度 —— const pollTimersRef = useRef(new Map()); @@ -522,6 +568,26 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack } setRefs([]); }, []); + const filterCounts = useMemo(() => ({ + all: tasks.length, + inflight: tasks.filter((t) => isInFlight(t.status)).length, + succeeded: tasks.filter((t) => t.status === "succeeded").length, + failed: tasks.filter((t) => t.status === "failed" || t.status === "cancelled").length + }), [tasks]); + + const feedGroups = useMemo(() => { + const visible = tasks.filter((t) => matchesFeedFilter(t, feedFilter)); + const groups: Array<{ key: "today" | "yesterday" | "earlier"; items: FreeVideoTask[] }> = [ + { key: "today", items: [] }, + { key: "yesterday", items: [] }, + { key: "earlier", items: [] } + ]; + visible.forEach((task) => { + groups.find((g) => g.key === dayBucket(task.created_at))?.items.push(task); + }); + return groups.filter((g) => g.items.length > 0); + }, [tasks, feedFilter]); + // 详情弹窗的上一条/下一条(只在已完成的任务间切换) const doneTasks = tasks.filter((t) => t.status === "succeeded" && t.video_url); const detailTask = detailId ? tasks.find((t) => t.id === detailId) || null : null; @@ -554,6 +620,22 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
+ {tasks.length > 0 && ( +
+ {FEED_FILTERS.map((item) => ( + + ))} +
+ )} +
{loading ? ( @@ -563,21 +645,35 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack } 准备生成新视频

在下方输入提示词,或从素材库引用参考素材

- ) : ( -
- {tasks.map((task) => ( - setDetailId(task.id)} - onRetry={() => handleRetry(task)} - onToggleFavorite={() => handleFavorite(task)} - onDelete={() => setDeleteTarget(task)} - onDownload={() => void handleDownload(task)} - /> - ))} + ) : feedGroups.length === 0 ? ( +
+
+ 没有符合条件的视频 +

{hasMore ? "当前已加载的记录里没有,下滑可继续查找" : "换一个状态筛选,或继续在下方生成新视频"}

+ ) : ( + feedGroups.map((group) => ( +
+
+ {GROUP_LABEL[group.key]} + {group.items.length} +
+
+ {group.items.map((task) => ( + setDetailId(task.id)} + onRetry={() => handleRetry(task)} + onToggleFavorite={() => handleFavorite(task)} + onDelete={() => setDeleteTarget(task)} + onDownload={() => void handleDownload(task)} + /> + ))} +
+
+ )) )} {hasMore &&
下滑加载更多
}
diff --git a/core/frontend/src/routes/index.ts b/core/frontend/src/routes/index.ts index c72f9de..7ff59f2 100644 --- a/core/frontend/src/routes/index.ts +++ b/core/frontend/src/routes/index.ts @@ -11,4 +11,5 @@ export { TeamPage } from "./team"; export { MessagesPage } from "./messages"; export { AssetFactoryPage, ImageWorkbenchPage, ModelPhotoDemoPage } from "./ai-tools"; export { FreeCreatePage } from "./free-create"; +export { VideoRemixPage } from "./video-remix"; export { SettingsPage } from "./settings"; diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index c9d4307..940476d 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -34,7 +34,7 @@ import { import { isLocalLife } from "../product-business"; // 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角) -const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url(${url})` } as CSSProperties); +const mediaStyle = (url: string): CSSProperties => ({ ["--mock-media-url"]: `url("${url.replace(/"/g, "")}")` } as CSSProperties); // 基础资产组 kind → 中文区块名(期3:视频项目资产趴「人物」→「角色」,可引用模特库的可复用形象) const KIND_LABEL: Record = { product: "商品", person: "角色", scene: "场景" }; // 脚本来源 → 「来源」brief pill 文案 @@ -1773,6 +1773,7 @@ export function PipelinePage(props: { try { const fd = new FormData(); fd.append("file", file); + if (activeScriptModelId) fd.append("model_config_id", activeScriptModelId); const digest = await api.extractScriptVideo(project.id, fd); const text = digest.text.trim(); setChatText((prev) => (prev ? `${prev}\n${text}` : text)); diff --git a/core/frontend/src/routes/projects.tsx b/core/frontend/src/routes/projects.tsx index c2e4f63..ef78407 100644 --- a/core/frontend/src/routes/projects.tsx +++ b/core/frontend/src/routes/projects.tsx @@ -440,7 +440,7 @@ const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string ]; const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string }> = [ { title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/yz/video-free.jpg" }, - { title: "视频复刻", desc: "上传参考视频,拆解镜头、动作和节奏,生成可编辑复刻提示词。", page: "freeCreate", image: "/assets/yz/video-remix.jpg" }, + { title: "视频复刻", desc: "上传参考视频,拆解镜头、动作和节奏,生成可编辑复刻提示词。", page: "videoRemix", image: "/assets/yz/video-remix.jpg" }, { title: "商品替换", desc: "保留参考视频的人物、场景与节奏,将原商品替换为自己的商品。", page: "freeCreate", image: "/assets/yz/video-replace.jpg" }, ]; diff --git a/core/frontend/src/routes/route-config.ts b/core/frontend/src/routes/route-config.ts index c78e829..00d0c46 100644 --- a/core/frontend/src/routes/route-config.ts +++ b/core/frontend/src/routes/route-config.ts @@ -28,6 +28,7 @@ export type Page = | "messages" | "assetFactory" | "freeCreate" + | "videoRemix" | "imageOptimize" | "modelPhoto" | "modelPhotoDemoA" @@ -101,6 +102,7 @@ export const routeLabels: Record = { messages: "消息", assetFactory: "图片工具", freeCreate: "自由创作", + videoRemix: "视频复刻", imageOptimize: "图片创作", modelPhoto: "模特上身图", modelPhotoDemoA: "模特图方案 A", @@ -118,7 +120,7 @@ export function isPage(value: string): value is Page { export function parentPage(page: Page): Page { if (["productDetail", "productCreateUpload"].includes(page)) return "products"; - if (["projectWizard", "freeCreate", "pipeline"].includes(page)) return "projects"; + if (["projectWizard", "freeCreate", "videoRemix", "pipeline"].includes(page)) return "projects"; if (["imageOptimize", "modelPhoto", "modelPhotoDemoA", "modelPhotoDemoB", "platformCover"].includes(page)) { return "assetFactory"; } @@ -161,6 +163,7 @@ export function resolveRoute(): ResolvedRoute { if (path === "/messages") return { page: "messages", authMode: "login", hash }; if (path === "/asset-factory") return { page: "assetFactory", authMode: "login", hash }; if (path === "/free-create") return { page: "freeCreate", authMode: "login", hash }; + if (path === "/video-remix") return { page: "videoRemix", authMode: "login", hash }; if (path === "/image-optimize") { return { page: "imageOptimize", authMode: "login", productId: search.get("product_id") || undefined, hash }; } @@ -204,6 +207,8 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) { return "/asset-factory"; case "freeCreate": return "/free-create"; + case "videoRemix": + return "/video-remix"; case "imageOptimize": return options.productId ? `/image-optimize?product_id=${encodeURIComponent(options.productId)}` : "/image-optimize"; case "modelPhoto": diff --git a/core/frontend/src/routes/video-remix.tsx b/core/frontend/src/routes/video-remix.tsx new file mode 100644 index 0000000..51c3080 --- /dev/null +++ b/core/frontend/src/routes/video-remix.tsx @@ -0,0 +1,324 @@ +// 视频复刻 · 上传参考视频 → 视频提炼接口 → 可编辑提示词。 +// 拆解跟生成脚本同一套接口口径:模型下拉 + Gemini 3.1 Pro 官转看图。 +import { useEffect, useMemo, useState } from "react"; +import { ArrowLeft, ArrowRight, FileVideo, Save, ScanLine, ScanSearch } from "lucide-react"; +import { api } from "../api"; +import { publicModelDisplayName } from "../model-display"; +import type { ModelConfig } from "../types"; +import type { NavigateFn } from "./route-config"; + +export const REMIX_PROMPT_KEY = "fc-remix-prompt"; +const REMIX_DRAFT_KEY = "vr-digest-draft"; + +type RemixDraft = { + prompt: string; + duration: number; + shots: number; + rhythm: string; + fileName: string; + fileSize: number; + modelId: string; +}; + +function loadDraft(): RemixDraft | null { + try { + const raw = localStorage.getItem(REMIX_DRAFT_KEY); + if (!raw) return null; + const draft = JSON.parse(raw) as RemixDraft; + if (typeof draft.prompt !== "string" || !draft.prompt.trim()) return null; + return draft; + } catch { + return null; + } +} + +function saveDraft(draft: RemixDraft) { + try { + localStorage.setItem(REMIX_DRAFT_KEY, JSON.stringify(draft)); + localStorage.setItem(REMIX_PROMPT_KEY, draft.prompt); + } catch { + /* 隐私模式写满时静默,内存态仍可用 */ + } +} + +const VIDEO_DIGEST_MAX_BYTES = 200 * 1024 * 1024; + +function formatSize(bytes: number) { + if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function shotCount(text: string, frames: number) { + const marks = text.match(/【第\s*\d+\s*镜】/g); + return marks?.length || frames || 0; +} + +function rhythmCopy(text: string) { + if (/快节奏|紧凑/.test(text)) return "快节奏种草"; + if (/慢节奏|舒缓/.test(text)) return "慢节奏展示"; + const form = text.match(/形式[::]\s*([^\n]+)/); + if (form?.[1]) return form[1].trim().slice(0, 12); + return "按参考片节奏"; +} + +function isGemini31(model: ModelConfig) { + const blob = `${model.name} ${model.display_name}`.toLowerCase(); + return blob.includes("gemini-3.1") || blob.includes("gemini 3.1") || model.name === "gemini-3.1-pro-preview"; +} + +function pickDigestModel(models: ModelConfig[]) { + const active = models.filter((model) => model.status === "active" && isGemini31(model)); + return ( + active.find((model) => /官转/.test(model.display_name)) + || active.find((model) => model.name === "gemini-3.1-pro-preview") + || active[0] + || null + ); +} + +export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: { + textModels?: ModelConfig[]; + onNotify: (type: "success" | "error" | "info", text: string) => void; + onBack: () => void; + navigate: NavigateFn; +}) { + const [file, setFile] = useState(null); + const [busy, setBusy] = useState(false); + const [draft] = useState(loadDraft); + const [prompt, setPrompt] = useState(draft?.prompt || ""); + const [duration, setDuration] = useState(draft?.duration || 0); + const [shots, setShots] = useState(draft?.shots || 0); + const [rhythm, setRhythm] = useState(draft?.rhythm || ""); + const [fileName, setFileName] = useState(draft?.fileName || ""); + const [fileSize, setFileSize] = useState(draft?.fileSize || 0); + const [modelId, setModelId] = useState(draft?.modelId || ""); + const [modelMenuOpen, setModelMenuOpen] = useState(false); + const done = prompt.length > 0; + + const digestModels = useMemo( + () => textModels.filter((model) => model.status === "active" && isGemini31(model)), + [textModels] + ); + const defaultModel = useMemo(() => pickDigestModel(digestModels), [digestModels]); + const activeModelId = modelId || defaultModel?.id || ""; + const activeModel = digestModels.find((model) => model.id === activeModelId) || defaultModel; + const activeModelName = publicModelDisplayName(activeModel, "Gemini 3.1 Pro 官转"); + const estimatedPoints = Math.round(Number(activeModel?.unit_price || 0)); + + useEffect(() => { + if (!modelMenuOpen) return; + const close = (event: MouseEvent) => { + if (!(event.target as HTMLElement).closest(".vr-model-pick")) setModelMenuOpen(false); + }; + document.addEventListener("click", close); + return () => document.removeEventListener("click", close); + }, [modelMenuOpen]); + + useEffect(() => { + if (!prompt.trim()) return; + saveDraft({ + prompt, + duration, + shots, + rhythm, + fileName: file?.name || fileName, + fileSize: file?.size || fileSize, + modelId: activeModelId, + }); + }, [prompt, duration, shots, rhythm, file, fileName, fileSize, activeModelId]); + + const pickFile = (next: File | null) => { + if (!next) return; + if (!/\.(mp4|mov|m4v|webm)$/i.test(next.name)) { + onNotify("error", "只支持 mp4 / mov / m4v / webm 四种视频格式"); + return; + } + if (next.size > VIDEO_DIGEST_MAX_BYTES) { + onNotify("error", "视频不能超过 200MB,请压缩后再传"); + return; + } + setFile(next); + setFileName(next.name); + setFileSize(next.size); + }; + + const analyze = async () => { + if (!file || busy) return; + setBusy(true); + try { + const fd = new FormData(); + fd.append("file", file); + if (activeModelId) fd.append("model_config_id", activeModelId); + const digest = await api.extractVideoDigest(fd); + const text = digest.text.trim(); + setPrompt(text); + setDuration(digest.duration); + setShots(shotCount(text, digest.frames)); + setRhythm(rhythmCopy(text)); + setFileName(file.name); + setFileSize(file.size); + onNotify("success", `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧,请先逐镜核对`); + } catch (error) { + onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试"); + } finally { + setBusy(false); + } + }; + + const savePrompt = async () => { + const text = prompt.trim(); + if (!text) return; + sessionStorage.setItem(REMIX_PROMPT_KEY, text); + try { + await navigator.clipboard.writeText(text); + onNotify("success", "提示词已保存,可继续生成或直接粘贴"); + } catch { + onNotify("success", "提示词已保存,可继续生成视频"); + } + }; + + const continueGenerate = () => { + const text = prompt.trim(); + if (!text) return; + sessionStorage.setItem(REMIX_PROMPT_KEY, text); + navigate("freeCreate"); + }; + + return ( +
+
+
+
+ +
+

视频复刻

+
+ 上传参考视频,拆解镜头、动作、运镜和节奏,生成可编辑提示词 + [ /remix ] +
+
+
+
+ +
+
+
+

上传参考视频

+ // 视频提炼 +
+

建议使用主体清晰、镜头完整的电商短视频。拆解稿会落在右侧,先逐镜改完再去生成。

+
+
+ 参考视频 + MP4 / MOV · 最长 3 分钟 · 200MB +
+ +
+
+
+ {digestModels.length > 0 ? ( +
+ +
+ {digestModels.map((model) => ( +
{ setModelId(model.id); setModelMenuOpen(false); }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setModelId(model.id); + setModelMenuOpen(false); + } + }} + > + {publicModelDisplayName(model)} + +
+ ))} +
+
+ ) : null} + {estimatedPoints > 0 ? `视频解析预计消耗 ${estimatedPoints} 积分` : "视频解析按一次文本模型计费"} +
+ +
+
+ +