添加视频复刻和优化脚本旁白
This commit is contained in:
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 首尾帧)。
|
||||||
|
|
||||||
|
|||||||
@@ -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` **36–52 字**(写作目标 **38–47 字,2–4 句短句**)。
|
- **口播 15 秒镜要说满**:`narration` **54–67 字**(写作目标 **55–63 字,3–5 句短句**)。
|
||||||
上限仍是 `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 秒镜旁白 38–47 字(下限 36)、口语化、过红线;
|
6. **写 narration / visual / 商品露出** — 口播 15 秒镜旁白 55–63 字(下限 54)、口语化、过红线;
|
||||||
**visual 按秒拆成 3–5 条分镜**;必须把输入给出的卖点原词和商品描述细节写进旁白或对白,不要每句只重复商品名;
|
**visual 按秒拆成 3–5 条分镜**;必须把输入给出的卖点原词和商品描述细节写进旁白或对白,不要每句只重复商品名;
|
||||||
发声方式按表现形式套路来;每镜规划自然的 `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 字**(写作目标 38–47 字,2–4 句)。禁止一句 20 字收工;口播禁止整镜无声。
|
- [ ] **口播 15 秒镜旁白 ≥ 54 字**(写作目标 55–63 字,3–5 句)。禁止一句 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 字 | **38–47 字,2–4 句** |
|
| 15 秒 | **54 字** | 67 字 | **55–63 字,3–5 句** |
|
||||||
|
|
||||||
主流程每镜都是 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 按秒拆 3–5 刀**,写成 `0-3s:近景……` / `3-8s:手持……` / `8-12s:特写……` / `12-15s:拉回……`
|
- **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 秒完播率掉得很快,除非是测评验证这种
|
**推荐总时长 30 秒(2 镜)。** 口播超过 45 秒完播率掉得很快,除非是测评验证这种
|
||||||
需要举证的结构。
|
需要举证的结构。
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user