添加视频复刻和优化脚本旁白

This commit is contained in:
Azmat@qq.com
2026-08-24 10:48:01 +08:00
parent 368586d33e
commit 2f5b7304b0
33 changed files with 1172 additions and 109 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ YUNQI_API_KEY=sk-xvfKxM6q2TlGEV4SMZxmg8nf20tR94yG6nSBT5iHfXyuaAvl
YUNQI_BASE_URL=https://www.yunqiai.chat/v1 YUNQI_BASE_URL=https://www.yunqiai.chat/v1
# YunQi 对话(脚本助手)· 按模型分开计费的两把 key(同一 base_url,各自独立额度) # YunQi 对话(脚本助手)· 按模型分开计费的两把 key(同一 base_url,各自独立额度)
YUNQI_GPT_API_KEY=sk-xvfKxM6q2TlGEV4SMZxmg8nf20tR94yG6nSBT5iHfXyuaAvl 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 中转站(主力:gpt-image-2 参考图 + gpt-5.5/gemini 文本 + gemini 图像)· 一把 key 通吃 90 模型
TOKENSSR_API_KEY=sk-vDg00IAX6EW4ABo4ePwAFPRaIjScFLUs1ZM1lInZJvs7z6M0 TOKENSSR_API_KEY=sk-vDg00IAX6EW4ABo4ePwAFPRaIjScFLUs1ZM1lInZJvs7z6M0
+2
View File
@@ -248,6 +248,7 @@ PROVIDER_BASE_URLS = {
# 故拆成两个 provider(一 provider 一 key 是现有约定)。base_url 都指 YunQi。 # 故拆成两个 provider(一 provider 一 key 是现有约定)。base_url 都指 YunQi。
"yunqi_gpt": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"), "yunqi_gpt": env("YUNQI_BASE_URL", "https://www.yunqiai.chat/v1"),
"yunqi_gemini": 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"), "tokenssr": env("TOKENSSR_BASE_URL", "https://king.tokenssr.com/v1"),
} }
PROVIDER_KEYS = { PROVIDER_KEYS = {
@@ -255,6 +256,7 @@ PROVIDER_KEYS = {
"yunqi": env("YUNQI_API_KEY", ""), "yunqi": env("YUNQI_API_KEY", ""),
"yunqi_gpt": env("YUNQI_GPT_API_KEY", ""), "yunqi_gpt": env("YUNQI_GPT_API_KEY", ""),
"yunqi_gemini": env("YUNQI_GEMINI_API_KEY", ""), "yunqi_gemini": env("YUNQI_GEMINI_API_KEY", ""),
"yunqi_gemini_guanzhuan": env("YUNQI_GEMINI_API_KEY", ""),
"tokenssr": env("TOKENSSR_API_KEY", ""), "tokenssr": env("TOKENSSR_API_KEY", ""),
} }
# 临时:视频(Seedance)单独借 AirDrama 火山账号的 ARK key。真人素材库审核(ASSETS_API)建在 AirDrama 账号下, # 临时:视频(Seedance)单独借 AirDrama 火山账号的 ARK key。真人素材库审核(ASSETS_API)建在 AirDrama 账号下,
@@ -53,6 +53,7 @@ OPERATION_LABELS = {
"video_generate": "视频", "video_generate": "视频",
"voiceover_generate": "配音", "voiceover_generate": "配音",
"entity_extract": "脚本信息", "entity_extract": "脚本信息",
"video_digest": "视频提炼",
} }
TASK_OPERATIONS = { TASK_OPERATIONS = {
@@ -67,6 +68,7 @@ TASK_OPERATIONS = {
"video_segment": "video_generate", "video_segment": "video_generate",
"voiceover": "voiceover_generate", "voiceover": "voiceover_generate",
"free_video": "video_generate", "free_video": "video_generate",
"video_digest": "video_digest",
} }
+6
View File
@@ -316,6 +316,8 @@ def execute_model_call(
error_metadata: Callable[[Exception, ModelConfig], AttemptMetadata | Mapping[str, Any]] | None = None, error_metadata: Callable[[Exception, ModelConfig], AttemptMetadata | Mapping[str, Any]] | None = None,
error_classifier: Callable[[Exception, ModelConfig], PublicGenerationError] | None = None, error_classifier: Callable[[Exception, ModelConfig], PublicGenerationError] | None = None,
candidate_resolver: Callable[..., list[ModelConfig]] | None = None, candidate_resolver: Callable[..., list[ModelConfig]] | None = None,
allow_retry: bool = True,
allow_fallback: bool = True,
sleep: Callable[[float], None] = time.sleep, sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic, monotonic: Callable[[], float] = time.monotonic,
uniform: Callable[[float, float], float] = random.uniform, uniform: Callable[[float, float], float] = random.uniform,
@@ -328,7 +330,11 @@ def execute_model_call(
raise ValueError("主模型 capability 与本次 ModelRequirements 不一致") raise ValueError("主模型 capability 与本次 ModelRequirements 不一致")
policy, retry_delays, request_timeout, total_timeout, retry_after_cap = _ability_policy(requirements) 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 resolver = candidate_resolver or resolve_fallback_candidates
if not allow_fallback:
resolver = lambda **_kwargs: []
result_meta = result_metadata or _default_result_metadata result_meta = result_metadata or _default_result_metadata
error_meta = error_metadata or _default_error_metadata error_meta = error_metadata or _default_error_metadata
classify = error_classifier or ( classify = error_classifier or (
+6 -5
View File
@@ -83,10 +83,11 @@ PERSONA_BRIEFS: dict[str, str] = {
} }
_PERSONA_KEY_BY_LABEL = {label: key for key, label in PERSONA_LABELS.items()} _PERSONA_KEY_BY_LABEL = {label: key for key, label in PERSONA_LABELS.items()}
# 可懂语速上限 3.5 字/秒;15 秒口播还要有下限,否则模型写一句 20 字就收,撑不满 # 成片有同期画面与自然停顿,3.5 字/秒会让 15 秒口播在约 10 秒时说完
NARRATION_CHARS_PER_SECOND = 3.5 # 电商真人口播以 3.6–4.5 字/秒为可用区间:既留出换气,也不会让画面空转。
NARRATION_CHARS_PER_SECOND_MIN = 2.4 NARRATION_CHARS_PER_SECOND = 4.5
NARRATION_CHARS_HARD_CAP = 55 NARRATION_CHARS_PER_SECOND_MIN = 3.6
NARRATION_CHARS_HARD_CAP = 68
VISUAL_CHARS_MIN = 72 VISUAL_CHARS_MIN = 72
SHOT_BEATS_MIN = 3 SHOT_BEATS_MIN = 3
BEAT_SPAN_RE = re.compile( 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: 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))) return max(1, min(NARRATION_CHARS_HARD_CAP, int(duration * NARRATION_CHARS_PER_SECOND)))
+12 -2
View File
@@ -122,8 +122,14 @@ def public_model_name(model_config: ModelConfig) -> str:
def resolve_provider_credentials(provider) -> tuple[str | None, str | None]: def resolve_provider_credentials(provider) -> tuple[str | None, str | None]:
"""解析中转站凭证。可插拔顺序:DB(ModelProvider.base_url/api_key)优先 → settings(.env)回退。 """解析中转站凭证。可插拔顺序:DB(ModelProvider.base_url/api_key)优先 → settings(.env)回退。
两者都不写死;换站只改 DB 这一行,或改 .env 对应项""" 两者都不写死;换站只改 DB 这一行,或改 .env 对应项"""
base_url = (provider.base_url or "").strip() or settings.PROVIDER_BASE_URLS.get(provider.name) name = provider.name
api_key = (getattr(provider, "api_key", "") or "").strip() or settings.PROVIDER_KEYS.get(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) return (base_url or None), (api_key or None)
@@ -616,6 +622,8 @@ def execute_routed_text_request(
request_summary: dict | None = None, request_summary: dict | None = None,
stream_event_callback=None, stream_event_callback=None,
abort_check=None, abort_check=None,
allow_retry: bool = True,
allow_fallback: bool = True,
): ):
"""文本入口共用的模型调用薄层:能力声明、Provider 调用、输出校验和尝试成本审计。 """文本入口共用的模型调用薄层:能力声明、Provider 调用、输出校验和尝试成本审计。
@@ -721,6 +729,8 @@ def execute_routed_text_request(
internal_kind="processing_failed" if isinstance(exc, _RoutedTextStreamCancelled) else "", internal_kind="processing_failed" if isinstance(exc, _RoutedTextStreamCancelled) else "",
reference_id=str(task.id), reference_id=str(task.id),
), ),
allow_retry=allow_retry,
allow_fallback=allow_fallback,
) )
@@ -121,6 +121,31 @@ class RoutingExecutorTests(TestCase):
self.assertEqual([item.is_retry for item in attempts], [False, True]) self.assertEqual([item.is_retry for item in attempts], [False, True])
self.assertEqual(attempts[1].previous_attempt_id, attempts[0].id) 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): def test_outcome_unknown_never_retries_or_resolves_fallback_candidates(self):
primary = self.model(self.provider("exec-unknown-primary", 100), "primary") primary = self.model(self.provider("exec-unknown-primary", 100), "primary")
candidate = self.model(self.provider("exec-unknown-candidate", 10), "candidate") candidate = self.model(self.provider("exec-unknown-candidate", 10), "candidate")
@@ -284,15 +284,15 @@ class PromptAssemblyTests(SimpleTestCase):
class NarrationLimitTests(SimpleTestCase): class NarrationLimitTests(SimpleTestCase):
def test_limit_scales_with_shot_length(self): def test_limit_scales_with_shot_length(self):
self.assertEqual(narration_limit(4), 14) self.assertEqual(narration_limit(4), 18)
self.assertEqual(narration_limit(8), 28) self.assertEqual(narration_limit(8), 36)
self.assertEqual(narration_limit(15), 52) self.assertEqual(narration_limit(15), 67)
def test_never_exceeds_hard_cap(self): 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): def test_fifteen_second_floor_is_about_fifty_four(self):
self.assertEqual(narration_floor(15), 36) self.assertEqual(narration_floor(15), 54)
self.assertLess(narration_floor(15), narration_limit(15)) self.assertLess(narration_floor(15), narration_limit(15))
@@ -321,7 +321,7 @@ class ShotDensityTests(SimpleTestCase):
self.assertIn("旁白太短", str(ctx.exception)) self.assertIn("旁白太短", str(ctx.exception))
def test_oral_meeting_floor_passes(self): def test_oral_meeting_floor_passes(self):
narration = "下午三点工位犯困,键盘都敲不利索。我倒了杯茶,第一口是回甘不是苦,整个人才慢慢醒过来。" narration = "下午三点工位犯困,键盘都敲不利索。我以前总是硬扛,越扛脑子越乱。后来会先倒杯热茶,第一口是回甘不是苦,桌上的文件终于能看进去,整个人才慢慢醒过来。"
self.assertGreaterEqual(len(narration.replace(" ", "")), narration_floor(15)) self.assertGreaterEqual(len(narration.replace(" ", "")), narration_floor(15))
self.assertGreaterEqual(len(self._VISUAL.replace(" ", "")), VISUAL_CHARS_MIN) self.assertGreaterEqual(len(self._VISUAL.replace(" ", "")), VISUAL_CHARS_MIN)
assert_shot_density( assert_shot_density(
+90
View File
@@ -7,8 +7,13 @@ from __future__ import annotations
import subprocess import subprocess
from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import SimpleTestCase, TestCase 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.models import ModelConfig, ModelProvider
from apps.ai.video_digest import ( 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._model(doubao, "doubao-seed-2-0-pro-260215", "豆包 2.0 Pro", is_default=True)
self.assertIsNone(resolve_digest_model_config()) 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 # helpers
@@ -194,3 +223,64 @@ def _digest_with_duration(clip, duration: float):
with patch("apps.ai.video_digest.probe_duration", return_value=duration): with patch("apps.ai.video_digest.probe_duration", return_value=duration):
return frames_from_upload(clip) 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")
+2
View File
@@ -3,6 +3,7 @@ from rest_framework.routers import DefaultRouter
from .views import ( from .views import (
AITaskViewSet, AITaskViewSet,
VideoDigestView,
FreeVideoDetailView, FreeVideoDetailView,
FreeVideoFavoriteView, FreeVideoFavoriteView,
FreeVideoPurgeView, FreeVideoPurgeView,
@@ -23,6 +24,7 @@ router.register("image-conversations", ImageConversationViewSet, basename="image
urlpatterns = [ urlpatterns = [
path("generate-image/", GenerateImageView.as_view(), name="ai-generate-image"), 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/", FreeVideoView.as_view(), name="ai-free-video"),
path("free-video/trash/", FreeVideoTrashView.as_view(), name="ai-free-video-trash"), path("free-video/trash/", FreeVideoTrashView.as_view(), name="ai-free-video-trash"),
path("free-video/upload/", FreeVideoUploadView.as_view(), name="ai-free-video-upload"), path("free-video/upload/", FreeVideoUploadView.as_view(), name="ai-free-video-upload"),
+116 -34
View File
@@ -17,14 +17,18 @@ from __future__ import annotations
import base64 import base64
import json import json
import logging
import math import math
import shutil import shutil
import subprocess import subprocess
import tempfile import tempfile
import uuid
from dataclasses import dataclass from dataclasses import dataclass
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
logger = logging.getLogger(__name__)
from django.conf import settings from django.conf import settings
# 上传限制:超了直接 400,不进 ffmpeg,也不花模型钱 # 上传限制:超了直接 400,不进 ffmpeg,也不花模型钱
@@ -199,13 +203,31 @@ def validate_digest_text(text: str) -> str:
return cleaned return cleaned
# 拆视频必须会看图。默认文本模型现在常是纯文本豆包,传帧图会直接失败 # 拆视频必须会看图。火山豆包直连读不了这组帧图,会在 ARK 上挂满 120s
# Gemini 3.1 Pro;展示名带「官转」的优先(中转站官方 Gemini 通道) # 只认中转站的 Gemini 3.1 Pro;展示名带「官转」的优先。
DIGEST_VISION_MODEL_NAME = "gemini-3.1-pro-preview" DIGEST_VISION_MODEL_NAME = "gemini-3.1-pro-preview"
def resolve_digest_model_config(): def _is_digest_vision_model(model) -> bool:
"""视频提炼用的多模态文本模型:Gemini 3.1 Pro 官转。找不到不回落默认语言模型。""" 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 from apps.ai.models import ModelConfig
qs = ( qs = (
@@ -216,6 +238,10 @@ def resolve_digest_model_config():
provider__status="active", 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: def _blob(model) -> str:
return " ".join( return " ".join(
@@ -232,11 +258,9 @@ def resolve_digest_model_config():
ranked = [] ranked = []
for model in qs: for model in qs:
blob = _blob(model) if not _is_digest_vision_model(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):
continue continue
blob = _blob(model)
# 官转 > 精确模型名 > 其它 Gemini 3.1 # 官转 > 精确模型名 > 其它 Gemini 3.1
score = 0 score = 0
if "官转" in blob: 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.db import transaction
from django.utils import timezone from django.utils import timezone
from apps.ai.models import AITask from apps.ai.models import AITask
from apps.ai.services import create_ai_task, execute_routed_text_request 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) 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: if model_config is None:
raise VideoDigestError("视频提炼需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员") raise VideoDigestError("视频提炼需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员")
logger.info(
product = getattr(project, "product", None) "video digest using %s:%s (%s)",
messages = build_digest_messages( model_config.provider.name,
frames, model_config.name,
duration, model_config.display_name,
product_hint=" · ".join(
filter(None, [getattr(product, "title", ""), getattr(product, "category", "")])
),
) )
task = create_ai_task( messages = build_digest_messages(frames, duration, product_hint=product_hint)
project=project, request_payload = {
user=user, "model": model_config.name,
task_type=AITask.Type.VIDEO_DIGEST, "endpoint": model_config.endpoint,
model_config=model_config, "feature": "video_remix" if project is None else "video_digest",
# 帧是几百 KB base64,绝不进 request_payload(会把 AITask 表撑爆),只记形状 "duration_seconds": round(duration, 2),
request_payload={ "frame_count": len(frames),
"model": model_config.name, "frame_times": [f.at_seconds for f in frames],
"endpoint": model_config.endpoint, }
"duration_seconds": round(duration, 2),
"frame_count": len(frames), if project is not None:
"frame_times": [f.at_seconds for f in frames], 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 reservation = task.credit_reservation
try: try:
task.status = AITask.Status.SUBMITTED task.status = AITask.Status.SUBMITTED
@@ -302,12 +381,14 @@ def digest_project_video(*, project, user, upload) -> dict:
task=task, task=task,
primary_model=model_config, primary_model=model_config,
messages=messages, messages=messages,
streaming=False, streaming=True,
structured_output=False, structured_output=False,
business_operation="video_digest", business_operation="video_digest",
temperature=0.4, temperature=0.4,
validate_text=validate_digest_text, validate_text=validate_digest_text,
request_summary={"duration_seconds": round(duration, 2), "frame_count": len(frames)}, request_summary={"duration_seconds": round(duration, 2), "frame_count": len(frames)},
allow_retry=False,
allow_fallback=False,
) )
_text, _response, digest = routed.value _text, _response, digest = routed.value
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
@@ -328,6 +409,7 @@ def digest_project_video(*, project, user, upload) -> dict:
"frames": len(frames), "frames": len(frames),
"duration": round(duration, 1), "duration": round(duration, 1),
"task_id": str(task.id), "task_id": str(task.id),
"estimated_cost": str(task.estimated_cost),
} }
+37
View File
@@ -1,3 +1,4 @@
import logging
import uuid import uuid
from django.db import transaction from django.db import transaction
@@ -27,6 +28,8 @@ from .serializers import (
) )
from .services import enqueue_standalone_images from .services import enqueue_standalone_images
logger = logging.getLogger(__name__)
class GenerateImageView(APIView): 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) 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): class FreeVideoView(APIView):
"""自由创作·视频生成(不绑项目,universal 全能参考 / keyframe 首尾帧)。 """自由创作·视频生成(不绑项目,universal 全能参考 / keyframe 首尾帧)。
+10 -2
View File
@@ -1372,12 +1372,20 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
if upload is None: if upload is None:
return Response({"detail": "no file uploaded"}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": "no file uploaded"}, status=status.HTTP_400_BAD_REQUEST)
try: 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: except VideoDigestError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
except Exception as exc: # noqa: BLE001 — 模型/网络失败:走统一安全文案,不回传原始异常 except Exception as exc: # noqa: BLE001 — 模型/网络失败:走统一安全文案,不回传原始异常
public_error = classify_generation_error(exc, operation="video_digest") 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}) return Response({"name": getattr(upload, "name", "") or "参考视频", **result})
@action(detail=True, methods=["post"], url_path="upload-bgm", parser_classes=[MultiPartParser, FormParser]) @action(detail=True, methods=["post"], url_path="upload-bgm", parser_classes=[MultiPartParser, FormParser])
@@ -59,7 +59,7 @@ description: >
"index": 0, "index": 0,
"duration": 15, "duration": 15,
"role": "钩子|痛点|卖点|CTA", "role": "钩子|痛点|卖点|CTA",
"narration": "这一镜被说出来的台词/旁白,15秒口播目标38-47字(下限36,上限52)", "narration": "这一镜被说出来的台词/旁白,15秒口播目标55-63字(下限54,上限67)",
"speaker": "可选,指向某 entity 的 id;画外旁白时为 null", "speaker": "可选,指向某 entity 的 id;画外旁白时为 null",
"visual": "0-3s:近景,……\\n3-8s:手持跟拍,开始使用商品\\n8-12s:特写,商品用法过程\\n12-15s:拉回中近景,使用后的反应", "visual": "0-3s:近景,……\\n3-8s:手持跟拍,开始使用商品\\n8-12s:特写,商品用法过程\\n12-15s:拉回中近景,使用后的反应",
"product_exposure": "商品露出方式(手持/特写/使用中)", "product_exposure": "商品露出方式(手持/特写/使用中)",
@@ -117,8 +117,8 @@ description: >
详见 `references/methodology.md`「旁白红线」。最关键的几条: 详见 `references/methodology.md`「旁白红线」。最关键的几条:
- **口语化**,像真人对着镜头说话,不准书面腔 / AI 腔。 - **口语化**,像真人对着镜头说话,不准书面腔 / AI 腔。
- **口播 15 秒镜要说满**`narration` **3652**(写作目标 **3847 字,24 句短句**)。 - **口播 15 秒镜要说满**`narration` **5467**(写作目标 **5563 字,35 句短句**)。
上限仍是 `duration × 3.5` 且不超过 55 字。禁止一句 20 字收工。 上限 `duration × 4.5` 且不超过 68 字。禁止一句 20 字收工。
商品名全片点名 1–2 次即可,其余镜用卖点原词和可感知细节(口感/气味/动作/使用场景)。 商品名全片点名 1–2 次即可,其余镜用卖点原词和可感知细节(口感/气味/动作/使用场景)。
Vlog 才允许个别镜纯画面;口播禁止整镜无声。 Vlog 才允许个别镜纯画面;口播禁止整镜无声。
- **禁违规词**:医疗功效(治疗/根治/抗癌…)、绝对化用语(最/第一/100%/国家级…)一律不写。 - **禁违规词**:医疗功效(治疗/根治/抗癌…)、绝对化用语(最/第一/100%/国家级…)一律不写。
@@ -182,7 +182,7 @@ description: >
4. **抽取/创建 entities** — 识别脚本需要的角色 / 场景 / 商品;为每个 entity 写一份**全脚本共用**的 `visual_prompt`(保证多镜同一角色同一张脸);可选写 `voice_ref` 锁音色。 4. **抽取/创建 entities** — 识别脚本需要的角色 / 场景 / 商品;为每个 entity 写一份**全脚本共用**的 `visual_prompt`(保证多镜同一角色同一张脸);可选写 `voice_ref` 锁音色。
5. **按套路填结构** — 优先用视频结构套路里的骨架给每个 segment 分配 `role` 5. **按套路填结构** — 优先用视频结构套路里的骨架给每个 segment 分配 `role`
套路没覆盖到的用「role 按镜数分配」表兜底;钩子镜套用该套路指定的钩子写法。 套路没覆盖到的用「role 按镜数分配」表兜底;钩子镜套用该套路指定的钩子写法。
6. **写 narration / visual / 商品露出** — 口播 15 秒镜旁白 3847 字(下限 36)、口语化、过红线; 6. **写 narration / visual / 商品露出** — 口播 15 秒镜旁白 5563 字(下限 54)、口语化、过红线;
**visual 按秒拆成 35 条分镜**;必须把输入给出的卖点原词和商品描述细节写进旁白或对白,不要每句只重复商品名; **visual 按秒拆成 35 条分镜**;必须把输入给出的卖点原词和商品描述细节写进旁白或对白,不要每句只重复商品名;
发声方式按表现形式套路来;每镜规划自然的 `product_exposure` 发声方式按表现形式套路来;每镜规划自然的 `product_exposure`
7. **连引用** — 填 `entity_refs``speaker`,确认每个 entity 都被引用、id 都合法。 7. **连引用** — 填 `entity_refs``speaker`,确认每个 entity 都被引用、id 都合法。
@@ -46,8 +46,8 @@
## C. 旁白红线扫描(逐镜) ## C. 旁白红线扫描(逐镜)
- [ ] **每镜 `narration` ≤ `duration × 3.5` 字,且绝不超过 55**15 秒镜 ≤52 字)。 - [ ] **每镜 `narration` ≤ `duration × 4.5` 字,且绝不超过 68**15 秒镜 ≤67 字)。
- [ ] **口播 15 秒镜旁白 ≥ 36**(写作目标 3847 字,24 句)。禁止一句 20 字收工;口播禁止整镜无声。 - [ ] **口播 15 秒镜旁白 ≥ 54**(写作目标 5563 字,35 句)。禁止一句 20 字收工;口播禁止整镜无声。
- [ ] **秒级分镜**:每镜 `visual` 至少 3 条 `0-3s:景别,动作`,从 0 接到 15s;15 秒内至少切两次景别。 - [ ] **秒级分镜**:每镜 `visual` 至少 3 条 `0-3s:景别,动作`,从 0 接到 15s;15 秒内至少切两次景别。
- [ ] **商品原词**:旁白/对白出现了输入给出的至少一个勾选卖点原词;商品名全片点名 1–2 次即可,其余镜写描述/卖点细节,没有用空话替换。 - [ ] **商品原词**:旁白/对白出现了输入给出的至少一个勾选卖点原词;商品名全片点名 1–2 次即可,其余镜写描述/卖点细节,没有用空话替换。
- [ ] 每镜 `visual` 能撑满 15 秒(至少 72 字),写清手/商品/容器的空间关系和真实用法,不是一句静态动作。 - [ ] 每镜 `visual` 能撑满 15 秒(至少 72 字),写清手/商品/容器的空间关系和真实用法,不是一句静态动作。
@@ -96,8 +96,8 @@
- **口语化**:像真人对着镜头唠嗑,不准书面腔 / 不准 AI 腔("综上所述""不仅…而且""值得一提的是"全禁)。 - **口语化**:像真人对着镜头唠嗑,不准书面腔 / 不准 AI 腔("综上所述""不仅…而且""值得一提的是"全禁)。
- **必须用商品原词**:输入给了商品名、品牌、卖点,就用这些词,不许改成「补水/好用/值得买」这种空卖点。品类示例句只是语气参考。 - **必须用商品原词**:输入给了商品名、品牌、卖点,就用这些词,不许改成「补水/好用/值得买」这种空卖点。品类示例句只是语气参考。
- **`narration` 字数按这一镜的秒数算**:出片模型在镜内直接发声。 - **`narration` 字数按这一镜的秒数算**:出片模型在镜内直接发声。
可懂语速上限约 **3.5 字 / 秒**,口播下限约 **2.4 字 / 秒**(再短镜头空一半)。 可懂语速上限约 **4.5 字 / 秒**,口播下限约 **3.6 字 / 秒**(再短镜头空一半)。
**每镜上限 `duration × 3.5` 字,且不超过 55 字。口播 15 秒镜还有下限 36 字。** **每镜上限 `duration × 4.5` 字,且不超过 68 字。口播 15 秒镜还有下限 54 字。**
| 单镜时长 | 下限 | 字数上限 | 写作目标 | | 单镜时长 | 下限 | 字数上限 | 写作目标 |
| ---- | ---- | ---- | ---- | | ---- | ---- | ---- | ---- |
@@ -107,7 +107,7 @@
| 8 秒 | — | 28 字 | ≤ 25 字 | | 8 秒 | — | 28 字 | ≤ 25 字 |
| 10 秒 | — | 35 字 | ≤ 31 字 | | 10 秒 | — | 35 字 | ≤ 31 字 |
| 12 秒 | — | 42 字 | ≤ 38 字 | | 12 秒 | — | 42 字 | ≤ 38 字 |
| 15 秒 | **36** | 52 字 | **3847 字,24** | | 15 秒 | **54** | 67 字 | **5563 字,35** |
主流程每镜都是 15 秒:**要说满,不要宁可短。** 一句 20 字撑不满镜头。 主流程每镜都是 15 秒:**要说满,不要宁可短。** 一句 20 字撑不满镜头。
留白靠句间停顿和画面,不靠少写字。只有 Vlog 才允许个别镜纯画面无声。 留白靠句间停顿和画面,不靠少写字。只有 Vlog 才允许个别镜纯画面无声。
@@ -53,7 +53,7 @@
- **单镜固定 15 秒**(短剧用镜内动作和对白交换撑满这 15 秒,不要靠切很多短镜) - **单镜固定 15 秒**(短剧用镜内动作和对白交换撑满这 15 秒,不要靠切很多短镜)
- 一镜一次冲突或一次对话交换,**别在一镜里塞完整三幕** - 一镜一次冲突或一次对话交换,**别在一镜里塞完整三幕**
- 有对白的镜,每句 `line` ≤ 25 字(两个人说话,总量还是受 55 字限制) - 有对白的镜,每句 `line` ≤ 25 字(两个人说话,总量还是受 68 字限制)
**推荐总时长 45 秒(3 镜)。** 短剧需要装下三幕,低于 30 秒会讲不完整; **推荐总时长 45 秒(3 镜)。** 短剧需要装下三幕,低于 30 秒会讲不完整;
超过 60 秒在电商场景下性价比开始下降。 超过 60 秒在电商场景下性价比开始下降。
@@ -52,7 +52,7 @@
- **单镜固定 15 秒**(口播里说完一个完整意思需要时间;15 秒靠景别切换和手部动作撑满,不要切碎) - **单镜固定 15 秒**(口播里说完一个完整意思需要时间;15 秒靠景别切换和手部动作撑满,不要切碎)
- 一句话讲一件事,**一镜 2–4 句短句**,禁止一句收工 - 一句话讲一件事,**一镜 2–4 句短句**,禁止一句收工
- **visual 按秒拆 35 刀**,写成 `0-3s:近景……` / `3-8s:手持……` / `8-12s:特写……` / `12-15s:拉回……` - **visual 按秒拆 35 刀**,写成 `0-3s:近景……` / `3-8s:手持……` / `8-12s:特写……` / `12-15s:拉回……`
- 语速按 **2.43.5 字 / 秒** 估:15 秒镜 **3652**,写作目标 **3847 字** - 语速按 **3.64.5 字 / 秒** 估:15 秒镜 **5467**,写作目标 **5563 字**
**推荐总时长 30 秒(2 镜)。** 口播超过 45 秒完播率掉得很快,除非是测评验证这种 **推荐总时长 30 秒(2 镜)。** 口播超过 45 秒完播率掉得很快,除非是测评验证这种
需要举证的结构。 需要举证的结构。
+3
View File
@@ -29,6 +29,7 @@ import {
AuthScreen, AuthScreen,
Dashboard, Dashboard,
FreeCreatePage, FreeCreatePage,
VideoRemixPage,
ImageWorkbenchPage, ImageWorkbenchPage,
LibraryPage, LibraryPage,
MessagesPage, MessagesPage,
@@ -1045,6 +1046,8 @@ export function App() {
return <AssetFactoryPage navigate={navigate} />; return <AssetFactoryPage navigate={navigate} />;
case "freeCreate": case "freeCreate":
return <FreeCreatePage modelConfigs={modelConfigs} onNotify={(type, text) => setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} onBack={() => goBack("projects")} />; return <FreeCreatePage modelConfigs={modelConfigs} onNotify={(type, text) => setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} onBack={() => goBack("projects")} />;
case "videoRemix":
return <VideoRemixPage textModels={modelConfigs.filter((m) => m.capability === "text" && m.status === "active")} onNotify={(type, text) => setNotice({ type, text })} onBack={() => goBack("projects")} navigate={navigate} />;
case "imageOptimize": case "imageOptimize":
return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} imageProductId={route.productId} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => goBack("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />; return <ImageWorkbenchPage mode="image" products={products} modelConfigs={modelConfigs} imageProductId={route.productId} initialProductId={activeProductId} onProductChange={setActiveProductId} unreadByProduct={aiUnreadByProduct} onProductViewed={markAiRead} onBack={() => goBack("assetFactory")} navigate={navigate} onGenerate={generateImages} onResume={resumeImages} onNotify={(type, text) => setNotice({ type, text })} />;
case "modelPhoto": case "modelPhoto":
+7
View File
@@ -348,6 +348,13 @@ export const api = {
{ method: "POST", body: formData } { 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 换商品重跑)· 团队隔离 ── // ── 套路模板(5.1 存 / 5.2 换商品重跑)· 团队隔离 ──
// 存模板只抽套路不抽文案:后端从这版脚本抽镜数/每镜作用/节奏/商品露出/转化写法。 // 存模板只抽套路不抽文案:后端从这版脚本抽镜数/每镜作用/节奏/商品露出/转化写法。
saveProjectAsTemplate(projectId: string, payload: { name: string; script_version_id?: string }) { saveProjectAsTemplate(projectId: string, payload: { name: string; script_version_id?: string }) {
+3 -1
View File
@@ -16,6 +16,7 @@ const SHELL_COMMANDS: Command[] = [
{ id: "projects", group: "导航", label: "视频创作", sub: "从商品或参考视频出发,选择生产方式", page: "projects", icon: "clapperboard", key: "V" }, { id: "projects", group: "导航", label: "视频创作", sub: "从商品或参考视频出发,选择生产方式", page: "projects", icon: "clapperboard", key: "V" },
{ id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" }, { 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: "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: "library", group: "导航", label: "成品库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" },
{ id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" }, { id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" },
{ id: "account", group: "导航", label: "账单库", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" }, { id: "account", group: "导航", label: "账单库", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" },
@@ -269,7 +270,7 @@ export function topModuleForPage(page: Page): TopModule | null {
|| page === "modelPhotoDemoB" || page === "modelPhotoDemoB"
|| page === "platformCover" || page === "platformCover"
) return "image"; ) 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; return null;
} }
@@ -399,6 +400,7 @@ const PAGE_TO_NAV: Partial<Record<Page, Page>> = {
modelPhotoDemoB: "assetFactory", modelPhotoDemoB: "assetFactory",
platformCover: "assetFactory", platformCover: "assetFactory",
freeCreate: "freeCreate", freeCreate: "freeCreate",
videoRemix: "projects",
library: "library", library: "library",
team: "team", team: "team",
account: "account", account: "account",
@@ -5,11 +5,6 @@ import type { FreeVideoTask } from "../../types";
import { MODE_LABELS, STATUS_LABELS, isInFlight, modelLabel } from "./constants"; import { MODE_LABELS, STATUS_LABELS, isInFlight, modelLabel } from "./constants";
import { isPublicGenerationError, presentGenerationError } from "../../generation-error"; 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) { function ratioCopy(ratio: string) {
const [w, h] = ratio.split(":").map(Number); const [w, h] = ratio.split(":").map(Number);
if (!w || !h) return ratio; if (!w || !h) return ratio;
@@ -45,7 +40,6 @@ export function GenerationCard({ task, progress, onOpen, onRetry, onToggleFavori
<article className={`fc-card${done ? " done" : ""}`}> <article className={`fc-card${done ? " done" : ""}`}>
<div <div
className="fc-card-media" className="fc-card-media"
style={ratioStyle(task.aspect_ratio)}
role={done ? "button" : undefined} role={done ? "button" : undefined}
tabIndex={done ? 0 : undefined} tabIndex={done ? 0 : undefined}
onClick={done ? onOpen : undefined} onClick={done ? onOpen : undefined}
+68 -8
View File
@@ -120,12 +120,12 @@
display: grid; display: grid;
place-items: center; place-items: center;
} }
.fc-page .fc-stage:has(.fc-empty) { .fc-page .fc-stage:has(.fc-empty:not(.is-soft)) {
display: grid; display: grid;
place-items: center; place-items: center;
overflow: hidden; overflow: hidden;
} }
.fc-page .fc-stage:has(.fc-empty)::before { .fc-page .fc-stage:has(.fc-empty:not(.is-soft))::before {
content: ""; content: "";
position: absolute; position: absolute;
inset: 5% 7%; inset: 5% 7%;
@@ -165,11 +165,60 @@
font-size: 13px; font-size: 13px;
} }
.fc-page .fc-feedbar {
display: flex;
flex-wrap: wrap;
gap: 7px;
flex-shrink: 0;
margin-bottom: 14px;
}
.fc-page .fc-filter {
height: 30px;
padding: 0 11px;
border: 0;
border-radius: 8px;
color: var(--fc-muted);
background: rgba(34, 42, 54, 0.055);
font: inherit;
font-size: 12px;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
}
.fc-page .fc-filter span {
font-variant-numeric: tabular-nums;
opacity: 0.72;
}
.fc-page .fc-filter:hover {
color: var(--accent-black);
background: rgba(34, 42, 54, 0.10);
}
.fc-page .fc-filter.active {
color: #fff;
background: var(--fc-black);
}
.fc-page .fc-group + .fc-group { margin-top: 28px; }
.fc-page .fc-group-h {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 13px;
font-weight: 600;
color: var(--accent-black);
}
.fc-page .fc-group-h span {
font-weight: 400;
color: var(--fc-muted);
font-variant-numeric: tabular-nums;
}
.fc-page .fc-feed { .fc-page .fc-feed {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px; gap: 18px;
align-items: start; align-items: stretch;
padding-bottom: 8px; padding-bottom: 8px;
} }
.fc-page .fc-sentinel { .fc-page .fc-sentinel {
@@ -181,6 +230,8 @@
.fc-page .fc-card { .fc-page .fc-card {
overflow: hidden; overflow: hidden;
min-width: 0;
height: 100%;
border: 1px solid var(--fc-line); border: 1px solid var(--fc-line);
border-radius: 13px; border-radius: 13px;
background: rgba(34, 42, 54, 0.05); background: rgba(34, 42, 54, 0.05);
@@ -196,12 +247,14 @@
} }
.fc-page .fc-card-media { .fc-page .fc-card-media {
position: relative; position: relative;
width: 100%;
aspect-ratio: 16 / 10;
background: #e5e6e9; background: #e5e6e9;
max-height: 280px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
flex: 0 0 auto;
} }
.fc-page .fc-card-media video { width: 100%; height: 100%; object-fit: cover; display: block; } .fc-page .fc-card-media video { width: 100%; height: 100%; object-fit: cover; display: block; }
.fc-page .fc-dur { .fc-page .fc-dur {
@@ -314,15 +367,18 @@
.fc-page .fc-hover-btn:hover { color: var(--accent-black); } .fc-page .fc-hover-btn:hover { color: var(--accent-black); }
.fc-page .fc-hover-btn.fav { color: var(--klein); } .fc-page .fc-hover-btn.fav { color: var(--klein); }
.fc-page .fc-hover-btn.danger:hover { color: var(--accent-crimson); } .fc-page .fc-hover-btn.danger:hover { color: var(--accent-crimson); }
.fc-page .fc-card-meta { padding: 15px 16px 17px; } .fc-page .fc-card-meta { padding: 15px 16px 17px; flex: 1 1 auto; }
.fc-page .fc-card-meta h3 { .fc-page .fc-card-meta h3 {
margin: 0 0 6px; margin: 0 0 6px;
min-height: calc(1.35em * 2);
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
line-height: 1.35;
color: var(--accent-black); color: var(--accent-black);
white-space: nowrap; display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
} }
.fc-page .fc-card-meta p { .fc-page .fc-card-meta p {
margin: 0; margin: 0;
@@ -979,9 +1035,13 @@
.fc-lib-model-picks .btn { height: 24px; padding: 0 8px; font-size: 11.5px; } .fc-lib-model-picks .btn { height: 24px; padding: 0 8px; font-size: 11.5px; }
.fc-lib-more { display: flex; justify-content: center; padding-top: 14px; } .fc-lib-more { display: flex; justify-content: center; padding-top: 14px; }
@media (max-width: 1500px) {
.fc-page .fc-feed { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.fc-page .fc-head { flex-direction: column; align-items: stretch; } .fc-page .fc-head { flex-direction: column; align-items: stretch; }
.fc-page .fc-material .fc-ghost { min-width: 0; } .fc-page .fc-material .fc-ghost { min-width: 0; }
.fc-page .fc-feed { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.fc-page .fc-feed { grid-template-columns: 1fr; } .fc-page .fc-feed { grid-template-columns: 1fr; }
+1
View File
@@ -14,6 +14,7 @@ import "./messages-page.css";
import "./settings-page.css"; import "./settings-page.css";
import "./ai-tools-page.css"; import "./ai-tools-page.css";
import "./free-create-page.css"; import "./free-create-page.css";
import "./video-remix-page.css";
import "./product-create-page.css"; import "./product-create-page.css";
import "./project-wizard-page.css"; import "./project-wizard-page.css";
import "./admin-page.css"; import "./admin-page.css";
+91 -14
View File
@@ -1547,10 +1547,10 @@
.tri-cand-card .tri-cand-foot .mono { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); } .tri-cand-card .tri-cand-foot .mono { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); }
/* ── 流程步骤4 · 人物/场景详情弹窗(对齐设计稿 asset-detail-grid)── */ /* ── 流程步骤4 · 人物/场景详情弹窗(对齐设计稿 asset-detail-grid)── */
.asset-modal-h .ad-tag { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; } .asset-modal-h .ad-tag { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-48); letter-spacing: .02em; }
.asset-detail-grid { display: grid; grid-template-columns: 320px 1fr; gap: 24px; } .asset-detail-grid { display: flex; align-items: flex-start; gap: 24px; width: 100%; min-width: 0; }
.asset-detail-lead { display: flex; flex-direction: column; gap: 10px; } .asset-detail-lead { display: flex; flex-direction: column; gap: 10px; flex: 0 1 240px; width: 240px; max-width: 34%; min-width: 0; }
.asset-detail-lead .ad-lead-wrap { position: relative; } .asset-detail-lead .ad-lead-wrap { position: relative; width: 100%; min-width: 0; overflow: hidden; }
.asset-detail-lead .placeholder.ad-lead-img { aspect-ratio: 9/16; border-radius: var(--r-md); } .asset-detail-lead .placeholder.ad-lead-img { width: 100%; max-width: 100%; min-width: 0; aspect-ratio: 9/16; max-height: min(420px, 48vh); height: auto; box-sizing: border-box; border-radius: var(--r-md); }
.ad-zoom-btn { position: absolute; right: 8px; bottom: 8px; width: 32px; height: 32px; padding: 0; background: rgba(21,20,15,.7); color: #fff; border: 0; border-radius: var(--r-sm); display: grid; place-items: center; cursor: pointer; opacity: 0; transition: opacity var(--t-base), background var(--t-base); z-index: 3; } .ad-zoom-btn { position: absolute; right: 8px; bottom: 8px; width: 32px; height: 32px; padding: 0; background: rgba(21,20,15,.7); color: #fff; border: 0; border-radius: var(--r-sm); display: grid; place-items: center; cursor: pointer; opacity: 0; transition: opacity var(--t-base), background var(--t-base); z-index: 3; }
.ad-zoom-btn:hover { background: rgba(21,20,15,.92); } .ad-zoom-btn:hover { background: rgba(21,20,15,.92); }
.ad-zoom-btn svg { width: 14px; height: 14px; } .ad-zoom-btn svg { width: 14px; height: 14px; }
@@ -1570,7 +1570,8 @@
.asset-detail-section-h .ad-icon-btn:hover { background: var(--heat); color: var(--accent-white); border-color: var(--heat); } .asset-detail-section-h .ad-icon-btn:hover { background: var(--heat); color: var(--accent-white); border-color: var(--heat); }
.asset-detail-section-h .ad-icon-btn svg { width: 12px; height: 12px; } .asset-detail-section-h .ad-icon-btn svg { width: 12px; height: 12px; }
.asset-detail-section-h .ad-icon-gap { margin-left: 8px; } .asset-detail-section-h .ad-icon-gap { margin-left: 8px; }
.asset-detail-tri-row .placeholder { aspect-ratio: 16 / 9; border-radius: var(--r-md); } .asset-detail-tri-row { width: 100%; min-width: 0; }
.asset-detail-tri-row .placeholder { width: 100%; max-width: 100%; min-width: 0; aspect-ratio: 16 / 9; box-sizing: border-box; border-radius: var(--r-md); }
.asset-detail-tri-row .placeholder.missing { display: grid; place-items: center; border: 1px dashed var(--border-faint); background: var(--background-lighter); color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 12px; padding: 12px; text-align: center; gap: 8px; } .asset-detail-tri-row .placeholder.missing { display: grid; place-items: center; border: 1px dashed var(--border-faint); background: var(--background-lighter); color: var(--black-alpha-48); font-family: var(--font-mono); font-size: 12px; padding: 12px; text-align: center; gap: 8px; }
.md-view-versions { display: flex; gap: 6px; overflow-x: auto; padding: 2px; margin-top: 8px; } .md-view-versions { display: flex; gap: 6px; overflow-x: auto; padding: 2px; margin-top: 8px; }
.md-view-versions .v-thumb { flex: 0 0 auto; width: 72px; aspect-ratio: 16/9; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm); position: relative; cursor: pointer; display: grid; place-items: center; overflow: hidden; transition: border-color var(--t-base); } .md-view-versions .v-thumb { flex: 0 0 auto; width: 72px; aspect-ratio: 16/9; background: var(--background-lighter); border: 1px solid var(--border-faint); border-radius: var(--r-sm); position: relative; cursor: pointer; display: grid; place-items: center; overflow: hidden; transition: border-color var(--t-base); }
@@ -1578,13 +1579,16 @@
.md-view-versions .v-thumb.active { border-color: var(--heat); border-width: 2px; box-shadow: 0 0 0 2px var(--heat-12); } .md-view-versions .v-thumb.active { border-color: var(--heat); border-width: 2px; box-shadow: 0 0 0 2px var(--heat-12); }
.md-view-versions .v-thumb .v { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; } .md-view-versions .v-thumb .v { font-family: var(--font-mono); font-size: 10px; color: var(--black-alpha-56); letter-spacing: .02em; }
.md-view-versions .v-thumb.active .v { color: var(--heat); font-weight: 600; } .md-view-versions .v-thumb.active .v { color: var(--heat); font-weight: 600; }
.asset-detail-tip { margin-top: 10px; padding: 10px 12px; background: var(--heat-12); border: 1px solid var(--heat-20); border-radius: var(--r-sm); font-size: 12px; color: var(--accent-black); display: flex; align-items: center; gap: 8px; line-height: 1.5; } .asset-detail-tip { margin-top: 10px; padding: 10px 12px; background: var(--heat-12); border: 1px solid var(--heat-20); border-radius: var(--r-sm); font-size: 12px; color: var(--accent-black); display: flex; align-items: center; flex-wrap: wrap; gap: 8px; line-height: 1.5; min-width: 0; }
.asset-detail-tip svg { width: 14px; height: 14px; color: var(--heat); flex-shrink: 0; } .asset-detail-tip svg { width: 14px; height: 14px; color: var(--heat); flex-shrink: 0; }
.asset-detail-tip span { min-width: 0; flex: 1 1 160px; }
.asset-detail-tip .ai-gen-btn { margin-left: auto; height: 26px; padding: 0 10px; background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: var(--r-sm); font-size: 12px; cursor: pointer; font-family: inherit; flex-shrink: 0; } .asset-detail-tip .ai-gen-btn { margin-left: auto; height: 26px; padding: 0 10px; background: var(--heat); color: var(--accent-white); border: 1px solid var(--heat); border-radius: var(--r-sm); font-size: 12px; cursor: pointer; font-family: inherit; flex-shrink: 0; }
.asset-modal-f .ad-foot-stats { display: flex; gap: 6px; margin-right: auto; } .asset-modal-f .ad-foot-stats { display: flex; gap: 6px; margin-right: auto; }
.asset-modal-f .ad-stat-btn { height: 32px; padding: 0 12px; display: inline-flex; align-items: center; gap: 6px; background: transparent; border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-72); font-size: 12px; font-family: inherit; cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); } .asset-modal-f .ad-stat-btn { height: 32px; padding: 0 12px; display: inline-flex; align-items: center; gap: 6px; background: transparent; border: 1px solid var(--border-faint); border-radius: var(--r-sm); color: var(--black-alpha-72); font-size: 12px; font-family: inherit; cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
.asset-modal-f .ad-stat-btn:hover { background: var(--background-lighter); color: var(--accent-black); } .asset-modal-f .ad-stat-btn:hover { background: var(--background-lighter); color: var(--accent-black); }
.asset-modal-f .ad-stat-btn svg { width: 13px; height: 13px; } .asset-modal-f .ad-stat-btn svg { width: 13px; height: 13px; }
.asset-detail-lead { flex: 0 1 240px; width: 240px; max-width: 34%; min-width: 0; contain: inline-size; }
.asset-detail-right { flex: 1 1 0%; min-width: min(280px, 52%); overflow: hidden; }
/* 固定大小、不可拖拽(对齐设计稿:描述角色立绘的提示词栏固定) */ /* 固定大小、不可拖拽(对齐设计稿:描述角色立绘的提示词栏固定) */
.ad-detail-prompt { width: 100%; font-family: var(--font-mono); font-size: 12px; line-height: 1.6; color: var(--black-alpha-72); background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 10px 12px; outline: none; resize: none; height: 88px; transition: border-color var(--t-base), background var(--t-base), box-shadow var(--t-base); } .ad-detail-prompt { width: 100%; font-family: var(--font-mono); font-size: 12px; line-height: 1.6; color: var(--black-alpha-72); background: var(--background-base); border: 1px solid var(--border-faint); border-radius: var(--r-sm); padding: 10px 12px; outline: none; resize: none; height: 88px; transition: border-color var(--t-base), background var(--t-base), box-shadow var(--t-base); }
/* 版本缩略图(非 .placeholder,需自己接 --mock-media-url 才显图) */ /* 版本缩略图(非 .placeholder,需自己接 --mock-media-url 才显图) */
@@ -1631,11 +1635,15 @@
background: var(--surface); background: var(--surface);
border: 1px solid var(--border-faint); border: 1px solid var(--border-faint);
border-radius: 12px; border-radius: 12px;
width: min(960px, 100%); width: 960px;
max-width: calc(100vw - 48px);
max-height: calc(100vh - 48px); max-height: calc(100vh - 48px);
max-height: calc(100dvh - 48px);
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-width: 0;
box-sizing: border-box;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18); box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18);
} }
.asset-modal-h { .asset-modal-h {
@@ -1673,19 +1681,57 @@
margin-left: auto; margin-left: auto;
} }
.asset-modal-h .x:hover { background: var(--black-alpha-08); color: var(--accent-black); } .asset-modal-h .x:hover { background: var(--black-alpha-08); color: var(--accent-black); }
.asset-modal-body { padding: 20px 24px 24px; overflow-y: auto; flex: 1; min-height: 0; } .asset-modal-body { padding: 20px 24px 24px; overflow-x: hidden; overflow-y: auto; flex: 1 1 auto; min-width: 0; min-height: 0; }
.asset-modal-f { .asset-modal-f {
padding: 14px 20px; padding: 14px 20px;
border-top: 1px solid var(--border-faint); border-top: 1px solid var(--border-faint);
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 8px; gap: 8px;
flex-shrink: 0; flex-shrink: 0;
} }
.asset-detail-grid { display: grid; grid-template-columns: 280px minmax(0, 1fr); gap: 24px; align-items: start; } .asset-detail-grid {
.asset-detail-lead { display: flex; flex-direction: column; gap: 10px; } display: flex;
.asset-detail-lead .ad-lead-wrap { position: relative; } align-items: flex-start;
.asset-detail-lead .placeholder.ad-lead-img { aspect-ratio: 9/16; border-radius: var(--r-md); } gap: 24px;
width: 100%;
min-width: 0;
}
.asset-detail-lead {
display: flex;
flex-direction: column;
gap: 10px;
flex: 0 1 240px;
width: 240px;
max-width: 34%;
min-width: 0;
contain: inline-size;
}
.asset-detail-right {
flex: 1 1 0%;
min-width: min(280px, 52%);
overflow: hidden;
}
.asset-detail-lead .ad-lead-wrap {
position: relative;
width: 100%;
min-width: 0;
overflow: hidden;
}
.asset-detail-lead .placeholder.ad-lead-img {
width: 100%;
max-width: 100%;
min-width: 0;
aspect-ratio: 9 / 16;
max-height: min(420px, 48vh);
height: auto;
box-sizing: border-box;
border-radius: var(--r-md);
}
@supports not (aspect-ratio: 9 / 16) {
.asset-detail-lead .placeholder.ad-lead-img { height: min(420px, 48vh); }
}
.ad-zoom-btn { .ad-zoom-btn {
position: absolute; position: absolute;
right: 8px; right: 8px;
@@ -1708,7 +1754,15 @@
.ad-zoom-btn svg { width: 14px; height: 14px; } .ad-zoom-btn svg { width: 14px; height: 14px; }
.asset-detail-lead .ad-lead-wrap:hover .ad-zoom-btn, .asset-detail-lead .ad-lead-wrap:hover .ad-zoom-btn,
.asset-detail-tri-row .ad-tri-wrap:hover .ad-zoom-btn { opacity: 1; } .asset-detail-tri-row .ad-tri-wrap:hover .ad-zoom-btn { opacity: 1; }
.asset-detail-tri-row .ad-tri-wrap { position: relative; } .asset-detail-tri-row {
width: 100%;
min-width: 0;
}
.asset-detail-tri-row .ad-tri-wrap {
position: relative;
width: 100%;
min-width: 0;
}
.asset-detail-lead .ad-thumbs { display: flex; gap: 8px; flex-wrap: wrap; } .asset-detail-lead .ad-thumbs { display: flex; gap: 8px; flex-wrap: wrap; }
.asset-detail-lead .ad-thumbs .thumb { .asset-detail-lead .ad-thumbs .thumb {
flex: 0 0 56px; flex: 0 0 56px;
@@ -1760,7 +1814,15 @@
.asset-detail-section-h .ad-icon-btn:hover { background: var(--klein); color: #fff; border-color: var(--klein); } .asset-detail-section-h .ad-icon-btn:hover { background: var(--klein); color: #fff; border-color: var(--klein); }
.asset-detail-section-h .ad-icon-btn svg { width: 12px; height: 12px; } .asset-detail-section-h .ad-icon-btn svg { width: 12px; height: 12px; }
.asset-detail-section-h .ad-icon-gap { margin-left: 8px; } .asset-detail-section-h .ad-icon-gap { margin-left: 8px; }
.asset-detail-tri-row .placeholder { aspect-ratio: 16 / 9; border-radius: var(--r-md); } .asset-detail-right .ad-section { min-width: 0; }
.asset-detail-tri-row .placeholder {
width: 100%;
max-width: 100%;
min-width: 0;
aspect-ratio: 16 / 9;
box-sizing: border-box;
border-radius: var(--r-md);
}
.md-view-versions { display: flex; gap: 6px; overflow-x: auto; padding: 2px; margin-top: 8px; } .md-view-versions { display: flex; gap: 6px; overflow-x: auto; padding: 2px; margin-top: 8px; }
.md-view-versions .v-thumb { .md-view-versions .v-thumb {
flex: 0 0 auto; flex: 0 0 auto;
@@ -1790,10 +1852,13 @@
color: var(--accent-black); color: var(--accent-black);
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 8px; gap: 8px;
line-height: 1.5; line-height: 1.5;
min-width: 0;
} }
.asset-detail-tip svg { width: 14px; height: 14px; color: var(--heat); flex-shrink: 0; } .asset-detail-tip svg { width: 14px; height: 14px; color: var(--heat); flex-shrink: 0; }
.asset-detail-tip span { min-width: 0; flex: 1 1 160px; }
.asset-detail-tip .ai-gen-btn { .asset-detail-tip .ai-gen-btn {
margin-left: auto; margin-left: auto;
height: 26px; height: 26px;
@@ -1807,6 +1872,18 @@
font-family: inherit; font-family: inherit;
flex-shrink: 0; flex-shrink: 0;
} }
@media (max-width: 720px) {
.asset-detail-grid { flex-direction: column; }
.asset-detail-lead,
.asset-detail-right {
flex: none;
width: 100%;
max-width: none;
min-width: 0;
contain: none;
}
.asset-detail-lead { max-width: 200px; }
}
.asset-modal-f .ad-foot-stats { display: flex; gap: 6px; margin-right: auto; } .asset-modal-f .ad-foot-stats { display: flex; gap: 6px; margin-right: auto; }
.asset-modal-f .ad-stat-btn { .asset-modal-f .ad-stat-btn {
height: 32px; height: 32px;
+1 -1
View File
@@ -36,7 +36,7 @@ const CREATE_GROUPS: Array<{
label: "从参考视频开始", label: "从参考视频开始",
hint: "复用成熟视频的镜头表达与节奏", hint: "复用成熟视频的镜头表达与节奏",
cards: [ cards: [
{ title: "视频复刻", desc: "拆解参考视频并提炼可编辑提示词", tone: "subtle", page: "freeCreate", icon: "scan" }, { title: "视频复刻", desc: "拆解参考视频并提炼可编辑提示词", tone: "subtle", page: "videoRemix", icon: "scan" },
{ title: "商品替换", desc: "保留原片表达,替换为自己的商品", tone: "subtle", page: "freeCreate", icon: "replace" }, { title: "商品替换", desc: "保留原片表达,替换为自己的商品", tone: "subtle", page: "freeCreate", icon: "replace" },
], ],
}, },
+110 -14
View File
@@ -27,10 +27,47 @@ import { PlatformLibraryModal } from "../components/free-create/platform-library
import type { PromptInputHandle } from "../components/free-create/prompt-input"; import type { PromptInputHandle } from "../components/free-create/prompt-input";
import { SystemLoading } from "../components/loading"; import { SystemLoading } from "../components/loading";
import { ConfirmModal } from "../components/overlays"; import { ConfirmModal } from "../components/overlays";
import { REMIX_PROMPT_KEY } from "./video-remix";
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const PROGRESS_KEY = "fc-progress"; const PROGRESS_KEY = "fc-progress";
type FeedFilter = "all" | "inflight" | "succeeded" | "failed";
const FEED_FILTERS: Array<{ key: FeedFilter; label: string }> = [
{ 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<string, number> { function loadProgress(): Record<string, number> {
try { try {
return JSON.parse(sessionStorage.getItem(PROGRESS_KEY) || "{}") as Record<string, number>; return JSON.parse(sessionStorage.getItem(PROGRESS_KEY) || "{}") as Record<string, number>;
@@ -97,12 +134,21 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const promptRef = useRef<PromptInputHandle | null>(null); const promptRef = useRef<PromptInputHandle | null>(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<string | null>(null); const [detailId, setDetailId] = useState<string | null>(null);
const [libraryOpen, setLibraryOpen] = useState(false); const [libraryOpen, setLibraryOpen] = useState(false);
const [platformOpen, setPlatformOpen] = useState(false); const [platformOpen, setPlatformOpen] = useState(false);
const [libraryTargetRole, setLibraryTargetRole] = useState<"first_frame" | "last_frame" | null>(null); const [libraryTargetRole, setLibraryTargetRole] = useState<"first_frame" | "last_frame" | null>(null);
const [deleteTarget, setDeleteTarget] = useState<FreeVideoTask | null>(null); const [deleteTarget, setDeleteTarget] = useState<FreeVideoTask | null>(null);
const [feedFilter, setFeedFilter] = useState<FeedFilter>("all");
// —— 轮询/进度 —— // —— 轮询/进度 ——
const pollTimersRef = useRef(new Map<string, number>()); const pollTimersRef = useRef(new Map<string, number>());
@@ -522,6 +568,26 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
setRefs([]); 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 doneTasks = tasks.filter((t) => t.status === "succeeded" && t.video_url);
const detailTask = detailId ? tasks.find((t) => t.id === detailId) || null : null; const detailTask = detailId ? tasks.find((t) => t.id === detailId) || null : null;
@@ -554,6 +620,22 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
</div> </div>
</header> </header>
{tasks.length > 0 && (
<div className="fc-feedbar">
{FEED_FILTERS.map((item) => (
<button
key={item.key}
type="button"
className={`fc-filter${feedFilter === item.key ? " active" : ""}`}
onClick={() => setFeedFilter(item.key)}
>
{item.label}
<span>{filterCounts[item.key]}</span>
</button>
))}
</div>
)}
<div className="fc-stage" ref={stageRef}> <div className="fc-stage" ref={stageRef}>
{loading ? ( {loading ? (
<SystemLoading title="正在加载作品" description="正在同步最新数据,请稍候。" icon="film" /> <SystemLoading title="正在加载作品" description="正在同步最新数据,请稍候。" icon="film" />
@@ -563,21 +645,35 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
<strong></strong> <strong></strong>
<p></p> <p></p>
</div> </div>
) : ( ) : feedGroups.length === 0 ? (
<div className="fc-feed"> <div className={`fc-empty${hasMore ? " is-soft" : ""}`}>
{tasks.map((task) => ( <div className="fc-empty-icon"><Clapperboard /></div>
<GenerationCard <strong></strong>
key={task.id} <p>{hasMore ? "当前已加载的记录里没有,下滑可继续查找" : "换一个状态筛选,或继续在下方生成新视频"}</p>
task={task}
progress={progress[task.id] || 3}
onOpen={() => setDetailId(task.id)}
onRetry={() => handleRetry(task)}
onToggleFavorite={() => handleFavorite(task)}
onDelete={() => setDeleteTarget(task)}
onDownload={() => void handleDownload(task)}
/>
))}
</div> </div>
) : (
feedGroups.map((group) => (
<section className="fc-group" key={group.key}>
<div className="fc-group-h">
{GROUP_LABEL[group.key]}
<span>{group.items.length}</span>
</div>
<div className="fc-feed">
{group.items.map((task) => (
<GenerationCard
key={task.id}
task={task}
progress={progress[task.id] || 3}
onOpen={() => setDetailId(task.id)}
onRetry={() => handleRetry(task)}
onToggleFavorite={() => handleFavorite(task)}
onDelete={() => setDeleteTarget(task)}
onDownload={() => void handleDownload(task)}
/>
))}
</div>
</section>
))
)} )}
{hasMore && <div ref={sentinelRef} className="fc-sentinel"></div>} {hasMore && <div ref={sentinelRef} className="fc-sentinel"></div>}
</div> </div>
+1
View File
@@ -11,4 +11,5 @@ export { TeamPage } from "./team";
export { MessagesPage } from "./messages"; export { MessagesPage } from "./messages";
export { AssetFactoryPage, ImageWorkbenchPage, ModelPhotoDemoPage } from "./ai-tools"; export { AssetFactoryPage, ImageWorkbenchPage, ModelPhotoDemoPage } from "./ai-tools";
export { FreeCreatePage } from "./free-create"; export { FreeCreatePage } from "./free-create";
export { VideoRemixPage } from "./video-remix";
export { SettingsPage } from "./settings"; export { SettingsPage } from "./settings";
+2 -1
View File
@@ -34,7 +34,7 @@ import {
import { isLocalLife } from "../product-business"; import { isLocalLife } from "../product-business";
// 真实资产缩略图注入:与全站一致用 --mock-media-url(.placeholder.has-mock-media 负责 cover 裁切 + 8px 圆角) // 真实资产缩略图注入:与全站一致用 --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:视频项目资产趴「人物」→「角色」,可引用模特库的可复用形象) // 基础资产组 kind → 中文区块名(期3:视频项目资产趴「人物」→「角色」,可引用模特库的可复用形象)
const KIND_LABEL: Record<string, string> = { product: "商品", person: "角色", scene: "场景" }; const KIND_LABEL: Record<string, string> = { product: "商品", person: "角色", scene: "场景" };
// 脚本来源 → 「来源」brief pill 文案 // 脚本来源 → 「来源」brief pill 文案
@@ -1773,6 +1773,7 @@ export function PipelinePage(props: {
try { try {
const fd = new FormData(); const fd = new FormData();
fd.append("file", file); fd.append("file", file);
if (activeScriptModelId) fd.append("model_config_id", activeScriptModelId);
const digest = await api.extractScriptVideo(project.id, fd); const digest = await api.extractScriptVideo(project.id, fd);
const text = digest.text.trim(); const text = digest.text.trim();
setChatText((prev) => (prev ? `${prev}\n${text}` : text)); setChatText((prev) => (prev ? `${prev}\n${text}` : text));
+1 -1
View File
@@ -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 }> = [ 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-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" }, { title: "商品替换", desc: "保留参考视频的人物、场景与节奏,将原商品替换为自己的商品。", page: "freeCreate", image: "/assets/yz/video-replace.jpg" },
]; ];
+6 -1
View File
@@ -28,6 +28,7 @@ export type Page =
| "messages" | "messages"
| "assetFactory" | "assetFactory"
| "freeCreate" | "freeCreate"
| "videoRemix"
| "imageOptimize" | "imageOptimize"
| "modelPhoto" | "modelPhoto"
| "modelPhotoDemoA" | "modelPhotoDemoA"
@@ -101,6 +102,7 @@ export const routeLabels: Record<Page, string> = {
messages: "消息", messages: "消息",
assetFactory: "图片工具", assetFactory: "图片工具",
freeCreate: "自由创作", freeCreate: "自由创作",
videoRemix: "视频复刻",
imageOptimize: "图片创作", imageOptimize: "图片创作",
modelPhoto: "模特上身图", modelPhoto: "模特上身图",
modelPhotoDemoA: "模特图方案 A", modelPhotoDemoA: "模特图方案 A",
@@ -118,7 +120,7 @@ export function isPage(value: string): value is Page {
export function parentPage(page: Page): Page { export function parentPage(page: Page): Page {
if (["productDetail", "productCreateUpload"].includes(page)) return "products"; 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)) { if (["imageOptimize", "modelPhoto", "modelPhotoDemoA", "modelPhotoDemoB", "platformCover"].includes(page)) {
return "assetFactory"; return "assetFactory";
} }
@@ -161,6 +163,7 @@ export function resolveRoute(): ResolvedRoute {
if (path === "/messages") return { page: "messages", authMode: "login", hash }; if (path === "/messages") return { page: "messages", authMode: "login", hash };
if (path === "/asset-factory") return { page: "assetFactory", 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 === "/free-create") return { page: "freeCreate", authMode: "login", hash };
if (path === "/video-remix") return { page: "videoRemix", authMode: "login", hash };
if (path === "/image-optimize") { if (path === "/image-optimize") {
return { page: "imageOptimize", authMode: "login", productId: search.get("product_id") || undefined, hash }; 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"; return "/asset-factory";
case "freeCreate": case "freeCreate":
return "/free-create"; return "/free-create";
case "videoRemix":
return "/video-remix";
case "imageOptimize": case "imageOptimize":
return options.productId ? `/image-optimize?product_id=${encodeURIComponent(options.productId)}` : "/image-optimize"; return options.productId ? `/image-optimize?product_id=${encodeURIComponent(options.productId)}` : "/image-optimize";
case "modelPhoto": case "modelPhoto":
+324
View File
@@ -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<File | null>(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 (
<div className="vr-page">
<div className="vr-inner">
<header className="page-head">
<div className="vr-title-row">
<button type="button" className="btn btn-ghost vr-back" aria-label="返回上一入口页面" onClick={onBack}>
<ArrowLeft />
</button>
<div>
<h1></h1>
<div className="sub">
<span className="mono">[ /remix ]</span>
</div>
</div>
</div>
</header>
<div className="vr-grid">
<section className="vr-panel">
<div className="section-h">
<h2></h2>
<span className="more">// 视频提炼</span>
</div>
<p className="vr-lead">使稿</p>
<div className="vr-step">
<div className="vr-step-h">
<strong></strong>
<span>MP4 / MOV · 3 · 200MB</span>
</div>
<label className={`vr-upload${file ? " has-file" : ""}`}>
<input
type="file"
accept="video/mp4,video/quicktime,.mp4,.mov,.m4v,.webm"
hidden
onChange={(event) => {
pickFile(event.target.files?.[0] || null);
event.target.value = "";
}}
/>
<span>
<FileVideo />
<strong>{file?.name || fileName || "点击上传参考视频"}</strong>
<small>
{file
? `${formatSize(file.size)} · 视频已就绪,可开始拆解`
: fileName
? `${formatSize(fileSize)} · 上次拆解还在,刷新不会丢。要重拆请再选一次文件`
: "上传后抽帧拆解镜头结构与节奏"}
</small>
</span>
</label>
</div>
<div className="vr-actions">
<div className="vr-meta">
{digestModels.length > 0 ? (
<div className={`chip-wrap vr-model-pick${modelMenuOpen ? " open" : ""}`}>
<button type="button" className="chip" title="选择视频提炼模型" onClick={() => setModelMenuOpen((open) => !open)}>
{activeModelName}
<svg className="caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m6 9 6 6 6-6" /></svg>
</button>
<div className="chip-menu">
{digestModels.map((model) => (
<div
key={model.id}
className={`mi${model.id === activeModelId ? " selected" : ""}`}
role="menuitemradio"
aria-checked={model.id === activeModelId}
tabIndex={0}
onClick={() => { setModelId(model.id); setModelMenuOpen(false); }}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setModelId(model.id);
setModelMenuOpen(false);
}
}}
>
{publicModelDisplayName(model)}
<svg className="mi-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
</div>
))}
</div>
</div>
) : null}
<span>{estimatedPoints > 0 ? `视频解析预计消耗 ${estimatedPoints} 积分` : "视频解析按一次文本模型计费"}</span>
</div>
<button type="button" className="btn btn-primary" disabled={!file || busy} onClick={() => void analyze()}>
{busy ? <span className="spinner" aria-hidden="true" /> : <ScanSearch />}
<span>{busy ? "正在拆解…" : done ? "重新拆解" : "开始拆解"}</span>
</button>
</div>
</section>
<aside className={`vr-result${done ? " has-result" : ""}`}>
{!done ? (
<div className="vr-placeholder">
<div>
<ScanLine />
<strong>{busy ? "正在抽帧拆解这条视频" : "等待视频解析"}</strong>
<span>{busy ? "大约需要半分钟。拆解稿会落在这里,请先逐镜核对再去生成。" : "完成后将在这里展示镜头摘要和复刻提示词"}</span>
</div>
</div>
) : (
<div className="vr-analysis">
<div className="section-h">
<h2></h2>
<span className="more">[ {shots} SHOTS ]</span>
</div>
<p className="vr-lead"> {shots} </p>
<div className="vr-summary">
<span><strong>{duration} </strong></span>
<span><strong>{shots} </strong></span>
<span><strong>{rhythm}</strong></span>
</div>
<textarea
className="textarea vr-prompt"
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
/>
<div className="vr-actions">
<button type="button" className="btn" onClick={() => void savePrompt()}>
<Save />
</button>
<button type="button" className="btn btn-primary" onClick={continueGenerate}>
<span></span>
<ArrowRight />
</button>
</div>
</div>
)}
</aside>
</div>
</div>
</div>
);
}
+227
View File
@@ -0,0 +1,227 @@
/* 视频复刻页 · 两栏工作台。不重写共享 .btn/.pill/.input/.chip/.textarea。 */
.vr-page {
width: 100%;
max-width: none;
box-sizing: border-box;
}
.vr-page .vr-inner {
width: min(1320px, 100%);
margin: 0 auto;
}
.vr-page .vr-title-row {
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
}
.vr-page .vr-back {
width: 36px;
height: 36px;
padding: 0;
flex: 0 0 auto;
margin-top: 2px;
}
.vr-page .vr-grid {
display: grid;
grid-template-columns: minmax(0, 1.08fr) minmax(320px, 0.92fr);
gap: 16px;
}
.vr-page .vr-panel,
.vr-page .vr-result {
position: relative;
padding: 22px 24px;
border-radius: var(--r-md);
background: var(--surface);
}
.vr-page .vr-panel::before,
.vr-page .vr-result::before {
content: "";
position: absolute;
inset: 0;
border: 1px solid var(--border-faint);
border-radius: inherit;
pointer-events: none;
}
.vr-page .vr-lead {
margin: 0 0 18px;
color: var(--black-alpha-56);
font-size: 13px;
line-height: 1.65;
}
.vr-page .vr-step { margin-top: 4px; }
.vr-page .vr-step-h {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.vr-page .vr-step-h strong {
font-size: 13px;
font-weight: 500;
color: var(--accent-black);
}
.vr-page .vr-step-h span {
color: var(--black-alpha-48);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: .04em;
}
.vr-page .vr-upload {
min-height: 148px;
display: grid;
place-items: center;
padding: 22px;
border: 1px dashed var(--border-muted);
border-radius: var(--r-md);
color: var(--black-alpha-56);
background: var(--background-lighter);
text-align: center;
cursor: pointer;
}
.vr-page .vr-upload:hover,
.vr-page .vr-upload.has-file {
border-color: var(--heat-40);
background: var(--heat-12);
color: var(--heat);
}
.vr-page .vr-upload > span {
display: grid;
place-items: center;
gap: 8px;
min-width: 0;
}
.vr-page .vr-upload svg {
width: 22px;
height: 22px;
}
.vr-page .vr-upload strong {
color: var(--accent-black);
font-size: 14px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.vr-page .vr-upload small {
color: var(--black-alpha-48);
font-size: 12px;
}
.vr-page .vr-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 22px;
}
.vr-page .vr-meta {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.vr-page .vr-meta > span {
color: var(--black-alpha-48);
font-size: 12px;
}
.vr-page .vr-placeholder,
.vr-page .vr-analysis { min-height: 360px; }
.vr-page .vr-placeholder {
display: grid;
place-items: center;
color: var(--black-alpha-48);
text-align: left;
}
.vr-page .vr-placeholder > div {
display: grid;
justify-items: start;
gap: 8px;
max-width: 260px;
}
.vr-page .vr-placeholder svg {
width: 22px;
height: 22px;
color: var(--black-alpha-32);
}
.vr-page .vr-placeholder strong {
font-size: 14px;
font-weight: 500;
color: var(--accent-black);
}
.vr-page .vr-placeholder span {
font-size: 13px;
line-height: 1.65;
color: var(--black-alpha-56);
}
.vr-page .vr-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
margin: 0 0 16px;
border: 1px solid var(--border-faint);
border-radius: var(--r-md);
overflow: hidden;
}
.vr-page .vr-summary span {
padding: 12px 14px;
color: var(--black-alpha-48);
background: var(--background-lighter);
font-size: 11px;
}
.vr-page .vr-summary span + span {
border-left: 1px solid var(--border-faint);
}
.vr-page .vr-summary strong {
display: block;
margin-top: 4px;
color: var(--accent-black);
font-size: 14px;
font-weight: 500;
}
.vr-page .vr-prompt {
min-height: 174px;
}
.vr-page .vr-analysis .vr-actions {
justify-content: flex-end;
}
@media (max-width: 1100px) {
.vr-page .vr-grid { grid-template-columns: 1fr; }
.vr-page .vr-summary { grid-template-columns: 1fr; }
.vr-page .vr-summary span + span { border-left: 0; border-top: 1px solid var(--border-faint); }
.vr-page .vr-actions { flex-wrap: wrap; }
}