极速成片页面和提炼提示词
This commit is contained in:
@@ -571,6 +571,7 @@ def _collect_extract_text(
|
||||
temperature: float = 0.3,
|
||||
timeout: float = 300,
|
||||
on_event=None,
|
||||
extra_body=None,
|
||||
) -> tuple[str, dict]:
|
||||
"""走与「脚本生成」同一条已在生产验证稳定的流式通道把模型输出收全。
|
||||
|
||||
@@ -587,6 +588,7 @@ def _collect_extract_text(
|
||||
messages=messages,
|
||||
temperature=temperature, # 结构化抽取要稳:低温降低 JSON 漂移
|
||||
timeout=timeout,
|
||||
extra_body=extra_body,
|
||||
):
|
||||
if on_event is not None:
|
||||
on_event(ev)
|
||||
@@ -624,6 +626,7 @@ def execute_routed_text_request(
|
||||
abort_check=None,
|
||||
allow_retry: bool = True,
|
||||
allow_fallback: bool = True,
|
||||
extra_body=None,
|
||||
):
|
||||
"""文本入口共用的模型调用薄层:能力声明、Provider 调用、输出校验和尝试成本审计。
|
||||
|
||||
@@ -658,6 +661,7 @@ def execute_routed_text_request(
|
||||
messages,
|
||||
temperature=temperature,
|
||||
timeout=timeout,
|
||||
extra_body=extra_body,
|
||||
on_event=(
|
||||
(lambda event: stream_event_callback(event, stream_call_number))
|
||||
if stream_event_callback is not None
|
||||
|
||||
@@ -23,9 +23,11 @@ from apps.ai.video_digest import (
|
||||
MAX_UPLOAD_BYTES,
|
||||
MIN_FRAMES,
|
||||
SECONDS_PER_FRAME,
|
||||
DigestVideo,
|
||||
VideoDigestError,
|
||||
VideoFrame,
|
||||
build_digest_messages,
|
||||
digest_input_from_upload,
|
||||
frames_from_upload,
|
||||
load_digest_skill,
|
||||
plan_frame_times,
|
||||
@@ -36,11 +38,11 @@ from apps.ai.video_digest import (
|
||||
|
||||
class FramePlanTests(SimpleTestCase):
|
||||
def test_frame_count_scales_with_duration_within_bounds(self):
|
||||
for duration, expected in [(3, MIN_FRAMES), (10, MIN_FRAMES), (30, 6), (60, 12), (180, MAX_FRAMES)]:
|
||||
for duration, expected in [(3, MIN_FRAMES), (10, MIN_FRAMES), (30, 15), (60, 30), (180, MAX_FRAMES)]:
|
||||
self.assertEqual(len(plan_frame_times(duration)), expected, duration)
|
||||
|
||||
def test_short_clip_still_gets_min_frames(self):
|
||||
"""5 秒的片子按每 5 秒一帧只有 1 帧,判不出分镜,必须兜到 MIN_FRAMES。"""
|
||||
"""2 秒的片子按每 2 秒一帧只有 1 帧,判不出分镜,必须兜到 MIN_FRAMES。"""
|
||||
self.assertEqual(len(plan_frame_times(SECONDS_PER_FRAME)), MIN_FRAMES)
|
||||
|
||||
def test_times_are_inside_the_clip_and_ordered(self):
|
||||
@@ -90,10 +92,18 @@ class UploadGuardTests(SimpleTestCase):
|
||||
class MessageBuildTests(SimpleTestCase):
|
||||
frames = [VideoFrame(at_seconds=2, jpeg=b"\xff\xd8fake"), VideoFrame(at_seconds=7, jpeg=b"\xff\xd8fake2")]
|
||||
|
||||
def setUp(self):
|
||||
load_digest_skill.cache_clear()
|
||||
|
||||
def test_system_prompt_is_the_digest_skill(self):
|
||||
messages = build_digest_messages(self.frames, 10)
|
||||
self.assertEqual(messages[0]["role"], "system")
|
||||
self.assertIn("七要素", messages[0]["content"])
|
||||
self.assertIn("解说词", messages[0]["content"])
|
||||
self.assertIn("覆盖全片", messages[0]["content"])
|
||||
|
||||
def test_user_prompt_asks_for_full_timeline(self):
|
||||
content = build_digest_messages(self.frames, 60)[1]["content"]
|
||||
self.assertIn("全片", content[0]["text"])
|
||||
|
||||
def test_every_frame_is_inlined_with_its_timestamp(self):
|
||||
content = build_digest_messages(self.frames, 10)[1]["content"]
|
||||
@@ -111,7 +121,25 @@ class MessageBuildTests(SimpleTestCase):
|
||||
self.assertIn("蓝牙耳机 · 数码3C", content[0]["text"])
|
||||
|
||||
def test_skill_loads_from_disk(self):
|
||||
self.assertIn("画面七要素", load_digest_skill())
|
||||
self.assertIn("解说词", load_digest_skill())
|
||||
|
||||
|
||||
class NativeVideoInputTests(SimpleTestCase):
|
||||
def test_short_clip_is_sent_as_whole_video(self):
|
||||
video, frames, duration = digest_input_from_upload(_synth_clip(seconds=6))
|
||||
self.assertIsNotNone(video)
|
||||
self.assertEqual(frames, [])
|
||||
self.assertTrue(video.mime.startswith("video/"))
|
||||
self.assertGreater(len(video.data), 100)
|
||||
self.assertAlmostEqual(duration, 6, delta=1)
|
||||
|
||||
def test_native_video_message_inlines_video_data_uri(self):
|
||||
video = DigestVideo(mime="video/mp4", data=b"\x00\x00fake")
|
||||
content = build_digest_messages(duration=20, video=video)[1]["content"]
|
||||
media = [c for c in content if c["type"] == "image_url"]
|
||||
self.assertEqual(len(media), 1)
|
||||
self.assertTrue(media[0]["image_url"]["url"].startswith("data:video/mp4;base64,"))
|
||||
self.assertIn("口播", content[0]["text"])
|
||||
|
||||
|
||||
class DigestValidationTests(SimpleTestCase):
|
||||
@@ -128,6 +156,17 @@ class DigestValidationTests(SimpleTestCase):
|
||||
good = "【整体结构】\n形式:口播\n" + "【第 1 镜】0-4 秒 · 钩子\n主体:一位女生\n" * 3
|
||||
self.assertEqual(validate_digest_text(f" {good} "), good.strip())
|
||||
|
||||
def test_rejects_too_few_shots_for_a_long_clip(self):
|
||||
stub = "【整体结构】\n形式:口播\n" + "【第 1 镜】0-4 秒 · 钩子\n主体:一位女生\n" * 3
|
||||
with self.assertRaises(ValueError):
|
||||
validate_digest_text(stub, duration=60, frame_count=30)
|
||||
|
||||
def test_accepts_dense_shots_for_a_long_clip(self):
|
||||
body = "【整体结构】\n形式:口播\n时长:约 60 秒\n" + "".join(
|
||||
f"【第 {i} 镜】{i * 3}-{(i + 1) * 3} 秒 · 卖点\n主体:一位女生\n" for i in range(1, 11)
|
||||
)
|
||||
self.assertTrue(validate_digest_text(body, duration=60, frame_count=30).startswith("【整体结构】"))
|
||||
|
||||
|
||||
class DigestModelPinTests(TestCase):
|
||||
"""视频提炼必须钉会看图的 Gemini,不能跟默认语言模型走。"""
|
||||
@@ -226,7 +265,7 @@ def _digest_with_duration(clip, duration: float):
|
||||
|
||||
|
||||
class VideoDigestApiTests(TestCase):
|
||||
"""视频复刻页独立入口:不绑项目,缺文件直接 400。"""
|
||||
"""视频提炼页独立入口:不绑项目,缺文件直接 400。"""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="remix-api", password="p")
|
||||
@@ -284,3 +323,97 @@ class VideoDigestApiTests(TestCase):
|
||||
self.assertIsInstance(response.data["detail"], str)
|
||||
self.assertTrue(response.data["detail"])
|
||||
self.assertEqual(response.data["error"]["operation"], "video_digest")
|
||||
|
||||
|
||||
class VideoDigestBillingTests(TestCase):
|
||||
"""提炼提示词按功能价 30 积分结算,不跟 Gemini 文本单价。"""
|
||||
|
||||
def setUp(self):
|
||||
from decimal import Decimal
|
||||
|
||||
from apps.billing.models import CreditAccount
|
||||
|
||||
self.user = User.objects.create_user(username="digest-bill", password="p")
|
||||
self.team = Team.objects.create(name="DigestBill", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
self.account = CreditAccount.objects.create(team=self.team, balance=Decimal("100"))
|
||||
provider, _ = ModelProvider.objects.get_or_create(
|
||||
name="digest-bill-relay",
|
||||
defaults={"display_name": "Gemini 官转", "status": ModelProvider.Status.ACTIVE},
|
||||
)
|
||||
provider.status = ModelProvider.Status.ACTIVE
|
||||
provider.save(update_fields=["status"])
|
||||
self.model = ModelConfig.objects.create(
|
||||
provider=provider,
|
||||
name=DIGEST_VISION_MODEL_NAME,
|
||||
display_name="Gemini 3.1 Pro 官转",
|
||||
capability=ModelConfig.Capability.TEXT,
|
||||
status=ModelConfig.Status.ACTIVE,
|
||||
unit_price=Decimal("10"),
|
||||
)
|
||||
|
||||
def _run_digest(self):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from apps.ai.video_digest import digest_team_video
|
||||
|
||||
digest = "【整体结构】\n形式:口播\n" + "【第 1 镜】0-4 秒 · 钩子\n主体:一位女生\n" * 3
|
||||
upload = SimpleUploadedFile("ref.mp4", b"x", content_type="video/mp4")
|
||||
with (
|
||||
patch(
|
||||
"apps.ai.video_digest.digest_input_from_upload",
|
||||
return_value=(None, [VideoFrame(at_seconds=2, jpeg=b"\xff\xd8x")], 15.0),
|
||||
),
|
||||
patch(
|
||||
"apps.ai.services.execute_routed_text_request",
|
||||
return_value=SimpleNamespace(value=(digest, {}, digest)),
|
||||
),
|
||||
):
|
||||
return digest_team_video(
|
||||
team=self.team,
|
||||
user=self.user,
|
||||
upload=upload,
|
||||
model_config_id=str(self.model.id),
|
||||
)
|
||||
|
||||
def test_charges_thirty_points_not_model_unit_price(self):
|
||||
from decimal import Decimal
|
||||
|
||||
from apps.ai.models import AITask
|
||||
|
||||
result = self._run_digest()
|
||||
self.account.refresh_from_db()
|
||||
self.assertEqual(Decimal(result["estimated_cost"]), Decimal("30"))
|
||||
self.assertEqual(self.account.balance, Decimal("70"))
|
||||
self.assertEqual(self.account.reserved_balance, Decimal("0"))
|
||||
task = AITask.objects.get(id=result["task_id"])
|
||||
self.assertEqual(task.estimated_cost, Decimal("30"))
|
||||
self.assertEqual(task.actual_cost, Decimal("30"))
|
||||
|
||||
def test_insufficient_credit_skips_model_and_keeps_balance(self):
|
||||
from decimal import Decimal
|
||||
|
||||
from apps.ai.video_digest import digest_team_video
|
||||
|
||||
self.account.balance = Decimal("20")
|
||||
self.account.save(update_fields=["balance"])
|
||||
upload = SimpleUploadedFile("ref.mp4", b"x", content_type="video/mp4")
|
||||
with (
|
||||
patch(
|
||||
"apps.ai.video_digest.digest_input_from_upload",
|
||||
return_value=(None, [VideoFrame(at_seconds=2, jpeg=b"\xff\xd8x")], 15.0),
|
||||
),
|
||||
patch("apps.ai.services.execute_routed_text_request") as mocked,
|
||||
):
|
||||
with self.assertRaises(VideoDigestError) as ctx:
|
||||
digest_team_video(
|
||||
team=self.team,
|
||||
user=self.user,
|
||||
upload=upload,
|
||||
model_config_id=str(self.model.id),
|
||||
)
|
||||
self.assertIn("余额不足", str(ctx.exception))
|
||||
mocked.assert_not_called()
|
||||
self.account.refresh_from_db()
|
||||
self.assertEqual(self.account.balance, Decimal("20"))
|
||||
self.assertEqual(self.account.reserved_balance, Decimal("0"))
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
"""上传视频提炼 —— 参考视频 → 可人工逐镜编辑的中文分镜稿。
|
||||
|
||||
链路:ffmpeg 抽帧(均匀采样) → 帧内联进多模态 messages → 走现有文本模型路由 → 纯文本分镜稿。
|
||||
优先把**完整视频(含音轨)**内联给 Gemini 3.1 Pro,跟官网「直接上传视频」同一条能力:
|
||||
模型能看全片、听口播,不会只拿到稀疏静帧。文件太大塞不进请求时,才退回 ffmpeg 抽帧。
|
||||
|
||||
两个「本来以为要新建、其实已经有」的前提:
|
||||
1. **读图能力**:拆视频是多模态(帧图 + 文本)。默认语言模型现在可能是纯文本豆包,
|
||||
**不能再用 get_default_model(TEXT)**。固定钉 Gemini 3.1 Pro 官转
|
||||
(``gemini-3.1-pro-preview``,展示名带「官转」优先)。
|
||||
2. **ffmpeg**:第 5 阶段导出早就依赖它,已装进后端镜像(见 Dockerfile)。
|
||||
|
||||
**没有音轨**:帧里读不到口播,只能读画面上的字幕。这是刻意取舍——接语音转写要另开火山 ASR 服务、
|
||||
另加一套凭证与计价,而带货参考片绝大多数带硬字幕,且口播词下游本来就要按用户自己的商品重写。
|
||||
skill 里已要求模型「无字幕就如实写缺失,不许编口播词」。
|
||||
拆视频必须会看视频/图。默认语言模型现在可能是纯文本豆包,不能用 get_default_model(TEXT)。
|
||||
固定钉 Gemini 3.1 Pro 官转(``gemini-3.1-pro-preview``,展示名带「官转」优先)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,6 +13,7 @@ import base64
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -36,12 +31,23 @@ ALLOWED_SUFFIXES = (".mp4", ".mov", ".m4v", ".webm")
|
||||
MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200 MB
|
||||
MAX_DURATION_SECONDS = 180 # 3 分钟。带货参考片远短于此;更长的帧采样密度不够,拆出来也是错的
|
||||
|
||||
# 抽帧:每 5 秒一帧,夹在 4~12 帧之间。12 帧 × 512px JPEG ≈ 0.5 MB base64,单次请求扛得住
|
||||
SECONDS_PER_FRAME = 5
|
||||
MIN_FRAMES = 4
|
||||
MAX_FRAMES = 12
|
||||
FRAME_WIDTH = 512 # 帧宽上限;分镜拆解看的是构图与景别,不需要原分辨率
|
||||
FRAME_QUALITY = 5 # ffmpeg -q:v,2(最好)~31(最差)
|
||||
# 官网直接传视频的上限大约是请求 20MB;base64 会胀到 4/3,所以原文件卡在 15MB。
|
||||
INLINE_VIDEO_MAX_BYTES = 15 * 1024 * 1024
|
||||
_SUFFIX_MIME = {
|
||||
".mp4": "video/mp4",
|
||||
".m4v": "video/mp4",
|
||||
".mov": "video/quicktime",
|
||||
".webm": "video/webm",
|
||||
}
|
||||
|
||||
# 抽帧只在整段视频塞不进请求时启用。
|
||||
SECONDS_PER_FRAME = 2
|
||||
MIN_FRAMES = 8
|
||||
MAX_FRAMES = 36
|
||||
FRAME_WIDTH = 768
|
||||
FRAME_QUALITY = 3
|
||||
DIGEST_MAX_TOKENS = 8192
|
||||
_SHOT_MARK = re.compile(r"【第\s*\d+\s*镜】")
|
||||
|
||||
_FFMPEG_TIMEOUT = 60
|
||||
|
||||
@@ -59,6 +65,15 @@ class VideoFrame:
|
||||
return "data:image/jpeg;base64," + base64.b64encode(self.jpeg).decode("ascii")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DigestVideo:
|
||||
mime: str
|
||||
data: bytes
|
||||
|
||||
def as_data_url(self) -> str:
|
||||
return f"data:{self.mime};base64," + base64.b64encode(self.data).decode("ascii")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# skill 加载
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -78,9 +93,9 @@ def load_digest_skill() -> str:
|
||||
return main.read_text(encoding="utf-8")
|
||||
# 兜底:skill 丢了也别整条链路挂掉,退化成一句话提示词(产出会明显变差,交接文档已注明须带 skills 目录)
|
||||
return (
|
||||
"你是分镜拆解 agent。输入是一条电商短视频按时间均匀抽出的截帧。"
|
||||
"逐镜还原分镜,每镜写清主体/动作/场景/景别/运镜/光线氛围/商品露出七要素,"
|
||||
"台词只抄画面上的字幕,看不见的不要编。输出中文纯文本。"
|
||||
"你是分镜拆解 agent。输入是一条电商短视频的完整文件,含画面和口播。"
|
||||
"必须覆盖全片,从 0 秒写到片尾,有几镜写几镜,不要概括成几大段。"
|
||||
"每镜写画面和解说词,解说词按听到的口播逐字写。输出中文纯文本。"
|
||||
)
|
||||
|
||||
|
||||
@@ -145,61 +160,169 @@ def extract_frames(path: str | Path, times: list[int]) -> list[VideoFrame]:
|
||||
return frames
|
||||
|
||||
|
||||
def frames_from_upload(upload) -> tuple[list[VideoFrame], float]:
|
||||
"""校验上传文件 → 落临时盘 → 探时长 → 抽帧。临时文件退出即删。"""
|
||||
def _write_upload(upload) -> tuple[str, str, int]:
|
||||
"""校验后缀和体积,把上传落到临时文件。返回 (path, suffix, size)。调用方负责删除。"""
|
||||
name = (getattr(upload, "name", "") or "").lower()
|
||||
if not name.endswith(ALLOWED_SUFFIXES):
|
||||
raise VideoDigestError("只支持 mp4 / mov / m4v / webm 四种视频格式")
|
||||
size = getattr(upload, "size", 0) or 0
|
||||
if size > MAX_UPLOAD_BYTES:
|
||||
raise VideoDigestError(f"视频不能超过 {MAX_UPLOAD_BYTES // 1024 // 1024} MB,请压缩后再传")
|
||||
|
||||
suffix = Path(name).suffix or ".mp4"
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix) as tmp:
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
|
||||
try:
|
||||
for chunk in upload.chunks():
|
||||
tmp.write(chunk)
|
||||
tmp.flush()
|
||||
duration = probe_duration(tmp.name)
|
||||
if duration > MAX_DURATION_SECONDS:
|
||||
raise VideoDigestError(
|
||||
f"视频不能超过 {MAX_DURATION_SECONDS // 60} 分钟,请剪出要参考的那一段再传"
|
||||
)
|
||||
return extract_frames(tmp.name, plan_frame_times(duration)), duration
|
||||
finally:
|
||||
tmp.close()
|
||||
return tmp.name, suffix, Path(tmp.name).stat().st_size
|
||||
|
||||
|
||||
def _materialize_upload(upload) -> tuple[str, str, int, float]:
|
||||
"""校验 → 落盘 → 探时长。返回 (path, suffix, size, duration),调用方负责删文件。"""
|
||||
path, suffix, size = _write_upload(upload)
|
||||
try:
|
||||
duration = probe_duration(path)
|
||||
except Exception:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
raise
|
||||
if duration > MAX_DURATION_SECONDS:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
raise VideoDigestError(
|
||||
f"视频不能超过 {MAX_DURATION_SECONDS // 60} 分钟,请剪出要参考的那一段再传"
|
||||
)
|
||||
return path, suffix, size, duration
|
||||
|
||||
|
||||
def _compress_video(path: str) -> bytes | None:
|
||||
"""压到能内联的体积。失败返回 None,由调用方改抽帧。"""
|
||||
ffmpeg = _binary("ffmpeg")
|
||||
out = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
|
||||
out.close()
|
||||
try:
|
||||
done = subprocess.run(
|
||||
[
|
||||
ffmpeg, "-v", "error", "-y", "-i", path,
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "28",
|
||||
"-vf", "scale='min(1280,iw)':-2",
|
||||
"-c:a", "aac", "-b:a", "64k",
|
||||
"-movflags", "+faststart", out.name,
|
||||
],
|
||||
capture_output=True, timeout=_FFMPEG_TIMEOUT * 3,
|
||||
)
|
||||
if done.returncode != 0:
|
||||
return None
|
||||
data = Path(out.name).read_bytes()
|
||||
if not data or len(data) > INLINE_VIDEO_MAX_BYTES:
|
||||
return None
|
||||
return data
|
||||
except Exception: # noqa: BLE001 — 压缩失败就抽帧,别挡住提炼
|
||||
return None
|
||||
finally:
|
||||
Path(out.name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _native_video(path: str, size: int, suffix: str) -> DigestVideo | None:
|
||||
mime = _SUFFIX_MIME.get(suffix.lower(), "video/mp4")
|
||||
if size <= INLINE_VIDEO_MAX_BYTES:
|
||||
return DigestVideo(mime=mime, data=Path(path).read_bytes())
|
||||
compressed = _compress_video(path)
|
||||
if compressed:
|
||||
return DigestVideo(mime="video/mp4", data=compressed)
|
||||
return None
|
||||
|
||||
|
||||
def digest_input_from_upload(upload) -> tuple[DigestVideo | None, list[VideoFrame], float]:
|
||||
"""优先整段视频(含音轨);塞不进请求才抽帧。"""
|
||||
path = ""
|
||||
try:
|
||||
path, suffix, size, duration = _materialize_upload(upload)
|
||||
video = _native_video(path, size, suffix)
|
||||
if video is not None:
|
||||
return video, [], duration
|
||||
return None, extract_frames(path, plan_frame_times(duration)), duration
|
||||
finally:
|
||||
if path:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
def frames_from_upload(upload) -> tuple[list[VideoFrame], float]:
|
||||
"""校验上传文件 → 落临时盘 → 探时长 → 抽帧。单测与抽帧兜底用。"""
|
||||
path = ""
|
||||
try:
|
||||
path, _suffix, _size, duration = _materialize_upload(upload)
|
||||
return extract_frames(path, plan_frame_times(duration)), duration
|
||||
finally:
|
||||
if path:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 组装多模态消息
|
||||
# --------------------------------------------------------------------------- #
|
||||
def build_digest_messages(
|
||||
frames: list[VideoFrame],
|
||||
duration: float,
|
||||
frames: list[VideoFrame] | None = None,
|
||||
duration: float = 0,
|
||||
*,
|
||||
product_hint: str = "",
|
||||
video: DigestVideo | None = None,
|
||||
) -> list[dict]:
|
||||
"""system = 拆解 skill;user = 时间戳 + 帧图交替,让模型知道每张图在原片的第几秒。"""
|
||||
head = [
|
||||
f"这是一条时长约 {round(duration)} 秒的电商带货短视频,",
|
||||
f"按时间顺序均匀抽了 {len(frames)} 帧。每帧图前面标了它在原片中的时间点。",
|
||||
]
|
||||
"""system = 拆解 skill;user = 完整视频(优先)或抽帧。"""
|
||||
frames = frames or []
|
||||
if video is not None:
|
||||
head = [
|
||||
f"这是一条时长约 {round(duration)} 秒的电商带货短视频的**完整文件**(含画面和口播音轨)。",
|
||||
"请按技能还原全片分镜:从 0 秒写到片尾,有几镜写几镜。",
|
||||
"解说词按听到的口播逐字写;画面上的花字一并写进画面。",
|
||||
]
|
||||
else:
|
||||
head = [
|
||||
f"这是一条时长约 {round(duration)} 秒的电商带货短视频,",
|
||||
f"按时间顺序均匀抽了 {len(frames)} 帧。每帧图前面标了它在原片中的时间点。",
|
||||
"请按技能还原**全片**分镜:从 0 秒写到片尾,有几镜写几镜。",
|
||||
]
|
||||
if product_hint:
|
||||
head.append(f"用户接下来想用这条片子的结构去拍自己的商品:{product_hint}。")
|
||||
head.append("请按技能里的输出格式还原它的分镜。")
|
||||
|
||||
content: list[dict] = [{"type": "text", "text": "".join(head)}]
|
||||
for frame in frames:
|
||||
content.append({"type": "text", "text": f"[第 {frame.at_seconds} 秒]"})
|
||||
content.append({"type": "image_url", "image_url": {"url": frame.as_data_url()}})
|
||||
if video is not None:
|
||||
data_url = video.as_data_url()
|
||||
# 官转(New-API)把 OpenAI image_url 的 data URI 转成 Gemini inline_data,
|
||||
# mime 从 data:video/mp4 头读取,模型按整段视频+音轨理解,等同官网直接上传。
|
||||
content.append({"type": "image_url", "image_url": {"url": data_url}})
|
||||
else:
|
||||
for frame in frames:
|
||||
content.append({"type": "text", "text": f"[第 {frame.at_seconds} 秒]"})
|
||||
content.append({"type": "image_url", "image_url": {"url": frame.as_data_url()}})
|
||||
return [
|
||||
{"role": "system", "content": load_digest_skill()},
|
||||
{"role": "user", "content": content},
|
||||
]
|
||||
|
||||
|
||||
def validate_digest_text(text: str) -> str:
|
||||
"""模型偶尔吐空 / 吐一句道歉。判空后交给路由层重试或切模型,别把废稿塞给用户。"""
|
||||
def min_digest_shots(duration: float, frame_count: int) -> int:
|
||||
"""短片至少 3 镜;20 秒以上约每 6 秒一镜,且不超过抽到的帧数。"""
|
||||
if duration < 20 and frame_count < 8:
|
||||
return 3
|
||||
by_time = max(4, int(duration // 6))
|
||||
if frame_count:
|
||||
return min(frame_count, by_time)
|
||||
return by_time
|
||||
|
||||
|
||||
def validate_digest_text(text: str, *, duration: float = 0, frame_count: int = 0) -> str:
|
||||
"""模型偶尔吐空、只写片头、或概括成几大段。废稿不塞给用户。"""
|
||||
cleaned = (text or "").strip()
|
||||
if len(cleaned) < 80 or "【" not in cleaned:
|
||||
raise ValueError("视频拆解结果不完整")
|
||||
shots = _SHOT_MARK.findall(cleaned)
|
||||
if not shots:
|
||||
raise ValueError("视频拆解结果不完整")
|
||||
if duration >= 20 or frame_count >= 8:
|
||||
needed = min_digest_shots(duration, frame_count)
|
||||
if len(shots) < needed:
|
||||
raise ValueError("视频拆解镜头过少,请重试")
|
||||
return cleaned
|
||||
|
||||
|
||||
@@ -295,7 +418,7 @@ def digest_project_video(*, project, user, upload, model_config_id=None) -> dict
|
||||
|
||||
|
||||
def digest_team_video(*, team, user, upload, model_config_id=None) -> dict:
|
||||
"""视频复刻页:不绑项目,计费挂当前团队。"""
|
||||
"""视频提炼页:不绑项目,计费挂当前团队。"""
|
||||
return _digest_video(
|
||||
team=team,
|
||||
user=user,
|
||||
@@ -313,31 +436,43 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
|
||||
from apps.ai.models import AITask
|
||||
from apps.ai.services import create_ai_task, execute_routed_text_request
|
||||
from apps.billing.pricing import quote_flat
|
||||
from apps.billing.pricing import quote_video_digest
|
||||
from apps.billing.services.ledger import charge_reserved_credit, reserve_credit
|
||||
|
||||
frames, duration = frames_from_upload(upload)
|
||||
video, frames, duration = digest_input_from_upload(upload)
|
||||
|
||||
model_config = resolve_digest_model_config(preferred_id=model_config_id)
|
||||
if model_config is None:
|
||||
raise VideoDigestError("视频提炼需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员")
|
||||
logger.info(
|
||||
"video digest using %s:%s (%s)",
|
||||
"video digest using %s:%s (%s) input=%s duration=%.1fs bytes=%s frames=%s",
|
||||
model_config.provider.name,
|
||||
model_config.name,
|
||||
model_config.display_name,
|
||||
"native_video" if video is not None else "frames",
|
||||
duration,
|
||||
len(video.data) if video is not None else 0,
|
||||
len(frames),
|
||||
)
|
||||
|
||||
messages = build_digest_messages(frames, duration, product_hint=product_hint)
|
||||
messages = build_digest_messages(
|
||||
frames, duration, product_hint=product_hint, video=video
|
||||
)
|
||||
request_payload = {
|
||||
"model": model_config.name,
|
||||
"endpoint": model_config.endpoint,
|
||||
"feature": "video_remix" if project is None else "video_digest",
|
||||
"duration_seconds": round(duration, 2),
|
||||
"input": "native_video" if video is not None else "frames",
|
||||
"frame_count": len(frames),
|
||||
"video_bytes": len(video.data) if video is not None else 0,
|
||||
"frame_times": [f.at_seconds for f in frames],
|
||||
}
|
||||
|
||||
quote = quote_video_digest(team=team, model_config=model_config)
|
||||
if quote.meta.get("rate"):
|
||||
request_payload = {**request_payload, "points_per_yuan_snapshot": quote.meta["rate"]}
|
||||
|
||||
if project is not None:
|
||||
task = create_ai_task(
|
||||
project=project,
|
||||
@@ -346,11 +481,9 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
model_config=model_config,
|
||||
# 帧是几百 KB base64,绝不进 request_payload(会把 AITask 表撑爆),只记形状
|
||||
request_payload=request_payload,
|
||||
quote=quote,
|
||||
)
|
||||
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(
|
||||
@@ -385,8 +518,15 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
structured_output=False,
|
||||
business_operation="video_digest",
|
||||
temperature=0.4,
|
||||
validate_text=validate_digest_text,
|
||||
request_summary={"duration_seconds": round(duration, 2), "frame_count": len(frames)},
|
||||
validate_text=lambda text: validate_digest_text(
|
||||
text, duration=duration, frame_count=len(frames) or (24 if video else 0)
|
||||
),
|
||||
extra_body={"max_tokens": DIGEST_MAX_TOKENS},
|
||||
request_summary={
|
||||
"duration_seconds": round(duration, 2),
|
||||
"frame_count": len(frames),
|
||||
"input": "native_video" if video is not None else "frames",
|
||||
},
|
||||
allow_retry=False,
|
||||
allow_fallback=False,
|
||||
)
|
||||
@@ -397,7 +537,7 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
|
||||
with transaction.atomic():
|
||||
task.status = AITask.Status.SUCCEEDED
|
||||
task.response_payload = {"digest": digest[:8000]}
|
||||
task.response_payload = {"digest": digest[:32000]}
|
||||
task.actual_cost = task.estimated_cost
|
||||
task.completed_at = timezone.now()
|
||||
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
|
||||
@@ -407,6 +547,7 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
|
||||
"text": digest,
|
||||
"chars": len(digest),
|
||||
"frames": len(frames),
|
||||
"input": "native_video" if video is not None else "frames",
|
||||
"duration": round(duration, 1),
|
||||
"task_id": str(task.id),
|
||||
"estimated_cost": str(task.estimated_cost),
|
||||
|
||||
@@ -697,9 +697,9 @@ def _set_free_video_generated_assets_deleted(task, deleted):
|
||||
|
||||
|
||||
class VideoDigestView(APIView):
|
||||
"""视频复刻 · 上传参考视频提炼分镜稿(不绑项目)。
|
||||
"""视频提炼 · 上传参考视频提炼分镜稿(不绑项目)。
|
||||
|
||||
POST /api/ai/video-digest/ multipart file → 中文分镜稿。慢(30~60 秒),计一次文本费。
|
||||
POST /api/ai/video-digest/ multipart file → 中文分镜稿。慢(30~60 秒),固定 30 积分/次,失败退还。
|
||||
"""
|
||||
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
@@ -33,6 +33,8 @@ _CONFIG_TTL = 60
|
||||
# 实扣会比消费页公示价低一半(review 确认的三方不一致)。
|
||||
FLAT_FALLBACK_POINTS = Decimal("10")
|
||||
FLAT_FALLBACK_POINTS_IMAGE = Decimal("20")
|
||||
# 提炼提示词 / 上传视频提炼:功能挂牌价,不跟 Gemini unit_price(那是普通文本 10 积分/次)。
|
||||
VIDEO_DIGEST_POINTS = Decimal("30")
|
||||
|
||||
|
||||
def get_billing_config() -> BillingConfig:
|
||||
@@ -114,6 +116,26 @@ def quote_flat(model_config, *, units: int = 1, team=None) -> Quote:
|
||||
)
|
||||
|
||||
|
||||
def quote_video_digest(*, team=None, model_config=None) -> Quote:
|
||||
"""提炼提示词:固定 30 积分/次。失败退还。team 传入则乘团队价格系数。"""
|
||||
multiplier = team_price_multiplier(team)
|
||||
points = apply_team_price(VIDEO_DIGEST_POINTS, multiplier)
|
||||
base_cost = Decimal("0")
|
||||
if model_config is not None:
|
||||
base_cost = Decimal(str(_pricing_meta(model_config).get("base_cost_yuan") or 0))
|
||||
return Quote(
|
||||
points=points,
|
||||
base_cost_yuan=base_cost,
|
||||
meta={
|
||||
"rule": "video_digest_flat",
|
||||
"units": 1,
|
||||
"unit_points": str(VIDEO_DIGEST_POINTS),
|
||||
"price_multiplier": str(multiplier),
|
||||
"rate": str(get_billing_config().points_per_yuan),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def quote_voiceover(model_config, *, char_count: int, team=None) -> Quote:
|
||||
"""配音字符阶梯:ceil(chars / chars_per_unit) × points_per_unit,最低 min_units 档。team 传入则乘系数。"""
|
||||
pricing = _pricing_meta(model_config)
|
||||
|
||||
@@ -231,6 +231,15 @@ class PricingEngineTests(TestCase):
|
||||
# 多单位(单图批量按张)
|
||||
self.assertEqual(quote_flat(self.text_model, units=3).points, Decimal("30"))
|
||||
|
||||
def test_video_digest_is_fixed_thirty_not_model_unit_price(self):
|
||||
from apps.billing.pricing import VIDEO_DIGEST_POINTS, quote_video_digest
|
||||
|
||||
self.assertEqual(VIDEO_DIGEST_POINTS, Decimal("30"))
|
||||
quote = quote_video_digest(model_config=self.text_model)
|
||||
self.assertEqual(quote.points, Decimal("30"))
|
||||
self.assertEqual(quote.meta["rule"], "video_digest_flat")
|
||||
self.assertEqual(quote.meta["unit_points"], "30")
|
||||
|
||||
def test_flat_fallback_when_unit_price_unset(self):
|
||||
from apps.billing.pricing import FLAT_FALLBACK_POINTS, quote_flat
|
||||
|
||||
@@ -414,6 +423,14 @@ class TeamPriceMultiplierTests(TestCase):
|
||||
# meta.rate 是汇率快照契约:create_ai_task 落 payload.points_per_yuan_snapshot,毛利报表用它防汇率漂移
|
||||
self.assertEqual(Decimal(quote.meta["rate"]), Decimal("10"))
|
||||
|
||||
def test_video_digest_applies_multiplier_to_feature_price(self):
|
||||
from apps.billing.pricing import quote_video_digest
|
||||
|
||||
# 30 × 0.8 = 24;不跟模型 unit_price=20
|
||||
quote = quote_video_digest(team=self.team, model_config=self.image_model)
|
||||
self.assertEqual(quote.points, Decimal("24"))
|
||||
self.assertEqual(quote_video_digest().points, Decimal("30"))
|
||||
|
||||
def test_two_step_rounding(self):
|
||||
from apps.billing.pricing import quote_video_from_cost
|
||||
|
||||
|
||||
@@ -3,22 +3,21 @@ name: video-shot-digest
|
||||
description: >
|
||||
上传视频提炼·分镜拆解领域技能(模型无关)。
|
||||
服务对象不是人类,而是 AirShelf 产品后端的「上传视频提炼」入口——用户上传一条参考视频(多为已投放的电商带货成片),
|
||||
后端按时间轴均匀抽出若干帧、连同时间戳一起喂给多模态模型,加载本技能作为系统提示词。
|
||||
能力:读一组【按时间顺序排列的视频截帧】,还原这条视频的【分镜结构】——逐镜写清画面七要素、镜头作用、
|
||||
可读到的台词/字幕,并总结整条片子的叙事结构与节奏。
|
||||
后端把**完整视频文件(含音轨)**喂给多模态模型,加载本技能作为系统提示词。
|
||||
能力:看完整画面、听口播,还原这条视频的分镜——逐镜写清画面和解说词。
|
||||
产出是**给人看、可人工逐镜修改的中文分镜稿**,随后由用户确认后交给脚本 agent 改写成自己商品的脚本。
|
||||
当任务为「拆解参考视频、还原分镜、提炼视频结构、把视频变成可复用的脚本素材」时使用本技能。
|
||||
---
|
||||
|
||||
# 参考视频 · 分镜拆解师
|
||||
|
||||
你是一个**分镜拆解 agent**。输入是**一条电商带货短视频按时间顺序均匀抽出的若干帧截图**,每帧都标了它在原片中的时间点。
|
||||
你是一个**分镜拆解 agent**。输入是**一条电商带货短视频的完整文件**,包含画面和口播音轨。
|
||||
你的任务是**还原这条视频的分镜结构**,产出一份**中文分镜稿**。
|
||||
|
||||
这份稿子有两个去处,缺一不可:
|
||||
|
||||
1. **给用户看、给用户改** —— 用户会逐镜校对你的拆解,改错了的地方。所以必须**逐镜分段、编号清楚、说人话**。
|
||||
2. **喂给下游脚本 agent** —— 用户确认后,这份稿子会连同他自己的商品一起交给脚本 agent,改写成一条新片的脚本。所以每一镜必须写足**下游重拍时需要的信息**。
|
||||
1. **给用户看、给用户改** —— 用户会逐镜校对你的拆解。必须逐镜分段、编号清楚、说人话。
|
||||
2. **喂给下游脚本 agent** —— 用户确认后,这份稿子会连同他自己的商品一起交给脚本 agent。每一镜要把画面动作和解说词写清楚。
|
||||
|
||||
> **模型无关声明**:本技能不依赖任何特定模型的能力或语气。无论运行在豆包 / GPT / Gemini / Claude 上,规则一致。
|
||||
|
||||
@@ -26,48 +25,27 @@ description: >
|
||||
|
||||
## 铁律(优先级最高)
|
||||
|
||||
### 铁律 1 · 只写你真看见的,看不见就说看不见
|
||||
截帧是**离散采样**,不是完整视频。你看到的是 8~12 个瞬间,不是全片。
|
||||
### 铁律 1 · 只写你真看见、真听见的
|
||||
- **禁止编造**没有画面或声音依据的内容:编造的商品名、编造的品牌、编造的口播。
|
||||
- 口播听不清就写「听不清」,画面看不清就写「看不清」。宁可承认缺失,也不要编一个具体的。
|
||||
- 不要评价(「拍得很好」「节奏很棒」)。你是拆解,不是影评。
|
||||
|
||||
- **禁止编造**没有画面依据的内容:编造的台词、编造的商品名、编造的品牌、编造的转场特效、编造的音乐和音效。
|
||||
- 帧与帧之间**可能发生了你没看到的事**。相邻两帧差异很大时,如实写「此处应有一次转场/换镜」,不要脑补中间过程。
|
||||
- 拿不准就用「疑似 / 大致 / 看不清」,**宁可承认看不清,也不要编一个具体的**。用户改一句话很容易,删一句编造的很烦。
|
||||
- 但**别把「(推测)」当口头禅**:截帧本来就是采样,全篇都是推测。只在**这一项真的可能有另一种答案**时才标不确定,其余照常陈述。
|
||||
### 铁律 2 · 口播按听到的写
|
||||
你拿得到音轨。`解说词` 一栏写**口播原文**,逐字转写,不要润色、不要概括成「强调结实」。
|
||||
- 这一镜没有人说话:写画面上的花字 / 字幕;都没有就写「无」。
|
||||
- 不要写 BGM、音效、语气、音量。
|
||||
|
||||
### 铁律 2 · 不要评价,只要还原
|
||||
不写「这条视频拍得很好」「节奏很棒」这类评语。你是拆解,不是影评。
|
||||
唯一允许的判断是**这一镜在整条片子里起什么作用**(钩子 / 痛点 / 卖点 / 证明 / 转化),因为下游要照着它重排结构。
|
||||
### 铁律 3 · 覆盖全片,宁多勿少
|
||||
用户要的是**整条片子的分镜稿**,不是片头摘要,也不是三幕概括。
|
||||
|
||||
### 铁律 3 · 声音你听不见
|
||||
你拿到的**只有画面**,没有音轨。因此:
|
||||
|
||||
- **台词只能来自画面上的字幕、贴片文字、弹幕样式的花字**。看到什么抄什么,**逐字照抄,不要润色**。
|
||||
- 这一镜画面上没有任何文字时,`台词/字幕` 一栏就写一个「无」,**绝不替它编一句口播词**。
|
||||
整条片子都没有字幕时,在 `【拆解存疑】` 里**统一说一次**「全片无字幕,口播词缺失需人工补」即可,**不要每镜重复一遍**——用户要逐镜改稿,重复的免责声明只会碍事。
|
||||
- 不要写任何关于 BGM、音效、语气、音量的描述。
|
||||
- 从 **0 秒写到片尾**。`【整体结构】` 里的时长必须接近输入给出的时长。
|
||||
- **有几镜写几镜**。口播带货片常见 6~20 镜;每换一个动作、一个卖点、一句新口播,就另起一镜。
|
||||
- **禁止**把全片压成「开头 / 中间 / 结尾」三大段,禁止只写前几镜就停。
|
||||
- 花字和字幕**逐字照抄**进画面描述,不要改写成「强调功效」。
|
||||
|
||||
---
|
||||
|
||||
## 画面七要素(每一镜必须写全)
|
||||
|
||||
这七项是**下游重拍时的最小信息量**,一项都不能省。看不清的那一项写「看不清」,但**不能整项不写**。
|
||||
|
||||
| # | 要素 | 写什么 | 例 |
|
||||
|---|------|--------|-----|
|
||||
| 1 | **主体** | 画面里的人 / 物是谁,几个人,什么身份 | 一位 25 岁左右女生,独自 |
|
||||
| 2 | **动作** | 主体在做什么,动作的起止 | 从沙发上坐起、伸手去够茶几上的瓶子 |
|
||||
| 3 | **场景** | 在哪,环境里的关键陈设 | 出租屋客厅,米色沙发 + 木茶几 + 落地窗 |
|
||||
| 4 | **景别** | 特写 / 近景 / 中景 / 全景 / 远景 | 中景(腰以上) |
|
||||
| 5 | **运镜** | 固定 / 推 / 拉 / 摇 / 移 / 跟拍 / 手持晃动 | 固定机位,轻微手持晃 |
|
||||
| 6 | **光线氛围** | 光的方向与色温、整体色调与情绪 | 窗户侧逆光,暖黄,慵懒 |
|
||||
| 7 | **商品露出** | 商品怎么出现的:手持 / 特写 / 使用中 / 背景陈列 / 未出现 | 手持展示,正面标签朝镜头 |
|
||||
|
||||
> 相邻帧属于同一镜(主体、场景、景别都没变)就**合并成一镜**,不要一帧算一镜。
|
||||
> 反过来,一帧内如果明显发生了切换(如画面被分割、出现了明显的转场帧),可以拆成两镜并注明是推测。
|
||||
|
||||
---
|
||||
|
||||
## 输出格式(严格照抄这个骨架,纯文本,不要 JSON、不要代码块)
|
||||
## 输出格式(严格照抄这个骨架,纯文本,不要 JSON、不要代码块、不要 markdown 表格)
|
||||
|
||||
```
|
||||
【整体结构】
|
||||
@@ -77,28 +55,23 @@ description: >
|
||||
主线:一句话说清这条片子从头到尾讲了什么
|
||||
|
||||
【第 1 镜】0-3 秒 · 钩子
|
||||
主体:…
|
||||
动作:…
|
||||
场景:…
|
||||
景别:…
|
||||
运镜:…
|
||||
光线氛围:…
|
||||
商品露出:…
|
||||
台词/字幕:…
|
||||
这一镜的作用:…
|
||||
画面:谁在哪做什么,商品怎么出现,景别和动作写进这一句
|
||||
解说词:口播原文。没有口播就写花字/字幕,都没有写「无」
|
||||
|
||||
【第 2 镜】3-8 秒 · 痛点
|
||||
(同上七要素 + 台词/字幕 + 作用)
|
||||
【第 2 镜】3-8 秒 · 卖点
|
||||
画面:…
|
||||
解说词:…
|
||||
|
||||
…(有几镜写几镜)
|
||||
|
||||
【拆解存疑】
|
||||
- 逐条列出你不确定的地方,让用户重点校对(如:第 3 镜和第 4 镜之间可能还有一镜;商品品类看不清;全片无字幕,口播词缺失)
|
||||
- 逐条列出你不确定的地方,让用户重点校对
|
||||
```
|
||||
|
||||
### 格式硬要求
|
||||
- 镜号连续,从 1 开始。
|
||||
- 时间区间用抽帧时间戳推算,写成「0-3 秒」这种闭区间,**不要**写小数。
|
||||
- 每镜的「作用」只能从**钩子 / 痛点 / 卖点 / 证明 / 转化 / 过渡**里选一个词,后面可以跟一句话解释。
|
||||
- **`【拆解存疑】`一节必须有**,哪怕只有一条。这一节是给用户的校对指引,是整份稿子最有用的部分之一——你拆错了不要紧,标出来让人改就行。
|
||||
- 时间区间写成「0-3 秒」这种整数闭区间。
|
||||
- 每镜标题里的作用只能从**钩子 / 痛点 / 卖点 / 证明 / 转化 / 过渡**里选一个词。
|
||||
- 每镜只要 **画面 + 解说词** 两行,不要再拆成主体/运镜/光线等七栏——栏太多会写不完整。
|
||||
- **`【拆解存疑】`一节必须有**,哪怕只有一条。
|
||||
- 全文中文,不出现 markdown 标题符号(`#`)和代码块围栏。
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
AuthScreen,
|
||||
Dashboard,
|
||||
FreeCreatePage,
|
||||
QuickCreatePage,
|
||||
VideoRemixPage,
|
||||
ImageWorkbenchPage,
|
||||
LibraryPage,
|
||||
@@ -1046,6 +1047,8 @@ export function App() {
|
||||
return <AssetFactoryPage navigate={navigate} />;
|
||||
case "freeCreate":
|
||||
return <FreeCreatePage modelConfigs={modelConfigs} onNotify={(type, text) => setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} onBack={() => goBack("projects")} />;
|
||||
case "quickCreate":
|
||||
return <QuickCreatePage 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":
|
||||
|
||||
@@ -433,14 +433,17 @@
|
||||
.asset-factory .list-pager { margin-top: 20px; }
|
||||
|
||||
.asset-factory .af-empty {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 190px;
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
grid-column: 1 / -1;
|
||||
margin-top: 18px;
|
||||
padding: 28px;
|
||||
padding: 28px 24px;
|
||||
border: 1px dashed rgba(0, 47, 167, 0.20);
|
||||
border-radius: 16px;
|
||||
color: var(--af-muted);
|
||||
@@ -448,11 +451,18 @@
|
||||
text-align: center;
|
||||
}
|
||||
.asset-factory .af-empty svg {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--klein);
|
||||
opacity: 0.72;
|
||||
}
|
||||
.asset-factory .af-empty strong,
|
||||
.asset-factory .af-empty span {
|
||||
display: block;
|
||||
max-width: 28em;
|
||||
}
|
||||
.asset-factory .af-empty strong {
|
||||
color: var(--accent-black);
|
||||
font-size: 14px;
|
||||
|
||||
@@ -340,17 +340,17 @@ export const api = {
|
||||
{ method: "POST", body: formData }
|
||||
);
|
||||
},
|
||||
// 1.11 上传视频提炼:后端抽帧 → 多模态读帧 → 中文分镜稿。与上传脚本同形状(只回文本不落库),
|
||||
// 差别是真调模型,慢(整条 30~60 秒)且计一次费,故前端要给等待反馈。
|
||||
// 1.11 上传视频提炼:整段视频交给 Gemini → 中文分镜稿。与上传脚本同形状(只回文本不落库),
|
||||
// 差别是真调模型,慢(约 1–2 分钟)且计一次费,故前端要给等待反馈。
|
||||
extractScriptVideo(projectId: string, formData: FormData) {
|
||||
return request<{ name: string; chars: number; text: string; frames: number; duration: number }>(
|
||||
return request<{ name: string; chars: number; text: string; frames: number; duration: number; input?: string }>(
|
||||
`/api/projects/${projectId}/extract-script-video/`,
|
||||
{ method: "POST", body: formData }
|
||||
);
|
||||
},
|
||||
// 视频复刻页:不绑项目,抽帧 + 多模态读帧出中文分镜稿。慢(30~60 秒),计一次文本费。
|
||||
// 视频提炼页:不绑项目,整段视频交给 Gemini 出中文分镜稿。慢(约 1–2 分钟),固定 30 积分,失败退还。
|
||||
extractVideoDigest(formData: FormData) {
|
||||
return request<{ name: string; chars: number; text: string; frames: number; duration: number; estimated_cost?: string }>(
|
||||
return request<{ name: string; chars: number; text: string; frames: number; duration: number; input?: string; estimated_cost?: string }>(
|
||||
"/api/ai/video-digest/",
|
||||
{ method: "POST", body: formData }
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ const SHELL_COMMANDS: Command[] = [
|
||||
{ id: "projects", group: "导航", label: "视频创作", sub: "从商品或参考视频出发,选择生产方式", page: "projects", icon: "clapperboard", key: "V" },
|
||||
{ id: "asset-factory", group: "导航", label: "图片生成", sub: "模特上身图、平台套图、图片创作", page: "assetFactory", icon: "sparkles", key: "I" },
|
||||
{ id: "free-create", group: "导航", label: "自由创作", sub: "AI 视频生成 · 全能参考 / 首尾帧", page: "freeCreate", icon: "film", key: "F" },
|
||||
{ id: "video-remix", group: "导航", label: "视频复刻", sub: "上传参考视频,拆解镜头并生成提示词", page: "videoRemix", icon: "scan", key: "R" },
|
||||
{ id: "video-remix", group: "导航", label: "提炼提示词", sub: "上传参考视频,提炼可编辑提示词", page: "videoRemix", icon: "scan", key: "R" },
|
||||
{ id: "library", group: "导航", label: "成品库", sub: "素材、人物、场景、成片统一管理", page: "library", icon: "folder", key: "A" },
|
||||
{ id: "team", group: "导航", label: "团队", sub: "成员、权限、额度、协作记录", page: "team", icon: "users" },
|
||||
{ id: "account", group: "导航", label: "账单库", sub: "余额、充值、账单流水", page: "account", icon: "creditCard" },
|
||||
@@ -270,7 +270,7 @@ export function topModuleForPage(page: Page): TopModule | null {
|
||||
|| page === "modelPhotoDemoB"
|
||||
|| page === "platformCover"
|
||||
) return "image";
|
||||
if (page === "projects" || page === "projectWizard" || page === "pipeline" || page === "freeCreate" || page === "videoRemix") return "video";
|
||||
if (page === "projects" || page === "projectWizard" || page === "quickCreate" || page === "pipeline" || page === "freeCreate" || page === "videoRemix") return "video";
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -359,19 +359,34 @@
|
||||
.library-page.manage-mode .lib-card .lib-count { left: 44px; }
|
||||
|
||||
.library-page .lib-empty {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 190px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 28px;
|
||||
padding: 28px 24px;
|
||||
border: 1px dashed rgba(0, 47, 167, 0.20);
|
||||
border-radius: 16px;
|
||||
color: var(--lib-muted);
|
||||
background: rgba(0, 47, 167, 0.03);
|
||||
text-align: center;
|
||||
}
|
||||
.library-page .lib-empty svg { width: 28px; height: 28px; color: var(--klein); opacity: 0.72; }
|
||||
.library-page .lib-empty svg {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--klein);
|
||||
opacity: 0.72;
|
||||
}
|
||||
.library-page .lib-empty strong,
|
||||
.library-page .lib-empty span {
|
||||
display: block;
|
||||
max-width: 28em;
|
||||
}
|
||||
.library-page .lib-empty strong { color: var(--accent-black); font-size: 14px; font-weight: 600; }
|
||||
.library-page .lib-empty span { font-size: 12px; }
|
||||
|
||||
@@ -450,13 +465,46 @@
|
||||
.adx-x { width: 30px; height: 30px; display: grid; place-items: center; background: transparent; border: 0; cursor: pointer; color: var(--black-alpha-56); border-radius: var(--r-sm); margin-left: auto; flex-shrink: 0; transition: background var(--t-base), color var(--t-base); }
|
||||
.adx-x:hover { background: var(--black-alpha-8); color: var(--accent-crimson); }
|
||||
|
||||
.adx-body { padding: 20px 24px 24px; overflow-y: auto; flex: 1; min-height: 0; }
|
||||
.adx-grid { display: grid; grid-template-columns: 320px 1fr; gap: 24px; }
|
||||
.adx-body { padding: 20px 24px 24px; overflow-x: hidden; overflow-y: auto; flex: 1; min-width: 0; min-height: 0; }
|
||||
.adx-grid {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 左:立绘 / 主图 */
|
||||
.adx-lead { display: flex; flex-direction: column; gap: 10px; }
|
||||
.adx-lead-wrap { position: relative; }
|
||||
.adx-lead-img { aspect-ratio: 9 / 16; border-radius: var(--r-md); overflow: hidden; display: grid; place-items: center; background: var(--background-lighter); }
|
||||
.adx-lead {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
flex: 0 1 240px;
|
||||
width: 240px;
|
||||
max-width: 34%;
|
||||
min-width: 0;
|
||||
contain: inline-size;
|
||||
}
|
||||
.adx-right {
|
||||
flex: 1 1 0%;
|
||||
min-width: min(280px, 52%);
|
||||
overflow: hidden;
|
||||
}
|
||||
.adx-lead-wrap { position: relative; width: 100%; min-width: 0; overflow: hidden; }
|
||||
.adx-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);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--background-lighter);
|
||||
}
|
||||
.asset-detail-modal.product-mode .adx-lead-img { aspect-ratio: 1; }
|
||||
.adx-lead-img[role="button"] { cursor: zoom-in; }
|
||||
.adx-lead-img img,
|
||||
@@ -516,9 +564,26 @@
|
||||
.adx-foot-meta + .btn { margin-left: 0; }
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.adx-grid { grid-template-columns: 260px 1fr; }
|
||||
.adx-grid { flex-wrap: wrap; }
|
||||
.adx-lead {
|
||||
flex: 0 1 200px;
|
||||
width: 200px;
|
||||
max-width: 42%;
|
||||
}
|
||||
.adx-gallery { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.adx-grid { flex-direction: column; }
|
||||
.adx-lead,
|
||||
.adx-right {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
contain: none;
|
||||
}
|
||||
.adx-lead { max-width: 200px; }
|
||||
}
|
||||
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
上传资产 · 居中 Modal · 类型分段 + 拖拽区 + 预览
|
||||
|
||||
@@ -17,6 +17,7 @@ import "./free-create-page.css";
|
||||
import "./video-remix-page.css";
|
||||
import "./product-create-page.css";
|
||||
import "./project-wizard-page.css";
|
||||
import "./quick-create-page.css";
|
||||
import "./admin-page.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
@@ -204,19 +204,34 @@
|
||||
.models-page.manage-mode .ml-card .card-del-btn { opacity: 0 !important; pointer-events: none !important; }
|
||||
|
||||
.models-page .ml-empty {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 190px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 28px;
|
||||
padding: 28px 24px;
|
||||
border: 1px dashed rgba(0, 47, 167, 0.20);
|
||||
border-radius: 16px;
|
||||
color: var(--ml-muted);
|
||||
background: rgba(0, 47, 167, 0.03);
|
||||
text-align: center;
|
||||
}
|
||||
.models-page .ml-empty svg { width: 28px; height: 28px; color: var(--klein); opacity: 0.72; }
|
||||
.models-page .ml-empty svg {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--klein);
|
||||
opacity: 0.72;
|
||||
}
|
||||
.models-page .ml-empty strong,
|
||||
.models-page .ml-empty span {
|
||||
display: block;
|
||||
max-width: 28em;
|
||||
}
|
||||
.models-page .ml-empty strong { color: var(--accent-black); font-size: 14px; font-weight: 600; }
|
||||
.models-page .ml-empty span { font-size: 12px; }
|
||||
|
||||
|
||||
@@ -423,13 +423,16 @@
|
||||
}
|
||||
|
||||
.pl-empty {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 190px;
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 28px;
|
||||
grid-column: 1 / -1;
|
||||
padding: 28px 24px;
|
||||
border: 1px dashed rgba(0, 47, 167, 0.20);
|
||||
border-radius: 16px;
|
||||
color: var(--pl-muted);
|
||||
@@ -437,11 +440,18 @@
|
||||
text-align: center;
|
||||
}
|
||||
.pl-empty svg {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--klein);
|
||||
opacity: 0.72;
|
||||
}
|
||||
.pl-empty strong,
|
||||
.pl-empty span {
|
||||
display: block;
|
||||
max-width: 28em;
|
||||
}
|
||||
.pl-empty strong {
|
||||
color: var(--accent-black);
|
||||
font-size: 14px;
|
||||
|
||||
@@ -450,19 +450,27 @@
|
||||
}
|
||||
|
||||
.project-wizard-page .nw-empty {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
grid-column: 1 / -1;
|
||||
min-height: 190px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 28px;
|
||||
padding: 28px 24px;
|
||||
border: 1px dashed rgba(0, 47, 167, 0.20);
|
||||
border-radius: 16px;
|
||||
color: var(--nw-muted);
|
||||
background: rgba(0, 47, 167, 0.03);
|
||||
text-align: center;
|
||||
}
|
||||
.project-wizard-page .nw-empty strong,
|
||||
.project-wizard-page .nw-empty span {
|
||||
display: block;
|
||||
max-width: 28em;
|
||||
}
|
||||
.project-wizard-page .nw-empty strong { color: var(--accent-black); font-size: 14px; }
|
||||
.project-wizard-page .nw-empty-reset {
|
||||
height: 36px;
|
||||
|
||||
@@ -441,6 +441,7 @@
|
||||
height: 40px;
|
||||
}
|
||||
.projects-page .vc-grid.list .vc-play svg { width: 18px; height: 18px; }
|
||||
.projects-page .vc-grid.list .vc-meta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
padding: 18px 22px;
|
||||
@@ -449,20 +450,35 @@
|
||||
}
|
||||
|
||||
.projects-page .vc-empty {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 190px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
padding: 28px;
|
||||
padding: 28px 24px;
|
||||
border: 1px dashed rgba(0, 47, 167, 0.20);
|
||||
border-radius: 16px;
|
||||
color: var(--vc-muted);
|
||||
background: rgba(0, 47, 167, 0.03);
|
||||
text-align: center;
|
||||
}
|
||||
.projects-page .vc-empty svg { width: 28px; height: 28px; color: var(--klein); opacity: 0.72; }
|
||||
.projects-page .vc-empty svg {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--klein);
|
||||
opacity: 0.72;
|
||||
}
|
||||
.projects-page .vc-empty strong,
|
||||
.projects-page .vc-empty span {
|
||||
display: block;
|
||||
max-width: 28em;
|
||||
}
|
||||
.projects-page .vc-empty strong { color: var(--accent-black); font-size: 14px; font-weight: 600; }
|
||||
.projects-page .vc-empty span { font-size: 12px; }
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/* 极速成片:按用户提供的「影擎」页面逐项转写,仅作用于该页面。 */
|
||||
.quick-create-page { --quick-blue: #002fa7; --quick-blue-hover: #002680; --quick-muted: #6f747c; width: min(1560px,100%); min-height: calc(100vh - 164px); margin: -24px auto -60px; padding: 52px 0 48px; }
|
||||
.quick-create-page .project-builder-header { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 20px; }
|
||||
.quick-create-page .project-builder-title { display: flex; align-items: center; gap: 14px; }
|
||||
.quick-create-page .project-builder-title h1 { margin: 0 0 5px; color: #17181a; font-size: 28px; line-height: 1.2; font-weight: 700; }
|
||||
.quick-create-page .project-builder-title p { margin: 0; color: var(--quick-muted); font-size: 13px; }
|
||||
.quick-create-page .project-builder-status { display: inline-flex; align-items: center; gap: 8px; padding: 9px 13px; border: 1px solid rgba(34,42,54,.09); border-radius: 999px; color: var(--quick-muted); background: rgba(255,255,255,.76); font-size: 12px; }
|
||||
.quick-create-page .project-builder-status strong { color: var(--quick-blue); font-size: 13px; }
|
||||
.quick-create-page .image-back-button { width: 38px; height: 38px; display: grid; place-items: center; flex: 0 0 auto; margin-top: 2px; border: 1px solid rgba(0,47,167,.46); border-radius: 12px; color: var(--quick-blue); background: rgba(255,255,255,.82); cursor: pointer; box-shadow: 0 5px 14px rgba(0,47,167,.07); transition: background-color 180ms ease, transform 180ms ease; }
|
||||
.quick-create-page .image-back-button:hover { transform: translateX(-2px); border-color: var(--quick-blue); background: rgba(0,47,167,.055); }.quick-create-page .image-back-button svg { width: 19px; height: 19px; }
|
||||
.quick-create-header { margin-bottom: 22px !important; }
|
||||
.quick-create-page .quick-create-shell { min-height: clamp(650px, calc(100vh - 266px), 850px); display: grid; grid-template-columns: minmax(0, .92fr) minmax(0, 1.08fr); gap: 22px; }
|
||||
.quick-create-page .quick-create-panel { min-width: 0; overflow: hidden; border: 1px solid rgba(34,42,54,.10); border-radius: 18px; background: rgba(255,255,255,.88); box-shadow: 0 16px 40px rgba(20,27,38,.09); }
|
||||
.quick-create-page .quick-form-panel { display: flex; flex-direction: column; padding: 30px; }
|
||||
.quick-create-page .quick-form-copy h2 { margin: 0 0 8px; font-size: 26px; }
|
||||
.quick-create-page .quick-form-copy p { margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }
|
||||
.quick-create-page .quick-field { display: grid; gap: 9px; margin-top: 24px; }
|
||||
.quick-create-page .quick-field-label { display: flex; align-items: center; justify-content: space-between; gap: 14px; font-size: 14px; font-weight: 700; }
|
||||
.quick-create-page .quick-field-label small { color: var(--quick-muted); font-size: 11px; font-weight: 500; }
|
||||
.quick-create-page .quick-name-input { height: 50px; padding: 0 15px; border: 1px solid rgba(34,42,54,.13); border-radius: 12px; outline: none; background: rgba(248,249,252,.88); transition: border-color 180ms ease, box-shadow 180ms ease, background 180ms ease; }
|
||||
.quick-create-page .quick-name-input:focus { border-color: rgba(0,47,167,.55); background: #fff; box-shadow: 0 0 0 4px rgba(0,47,167,.08); }
|
||||
.quick-create-page .quick-upload { position: relative; min-height: 190px; display: grid; place-items: center; overflow: hidden; border: 1px dashed rgba(0,47,167,.28); border-radius: 14px; background: radial-gradient(circle at center,rgba(0,47,167,.07),transparent 47%),rgba(248,249,252,.86); cursor: pointer; }
|
||||
.quick-create-page .quick-upload input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
||||
.quick-create-page .quick-upload-copy { display: grid; justify-items: center; gap: 8px; padding: 24px; text-align: center; }
|
||||
.quick-create-page .quick-upload-icon { width: 40px; height: 40px; display: grid; place-items: center; color: var(--quick-blue); }
|
||||
.quick-create-page .quick-upload-icon svg { width: 34px; height: 34px; stroke-width: 1.75; }
|
||||
.quick-create-page .quick-upload-copy strong { font-size: 15px; }.quick-create-page .quick-upload-copy small { color: var(--quick-muted); font-size: 11px; }
|
||||
.quick-create-page .quick-upload-preview { position: absolute; inset: 0; display: none; background: #f5f6f9; }.quick-create-page .quick-upload.has-image .quick-upload-copy { display: none; }.quick-create-page .quick-upload.has-image .quick-upload-preview { display: block; }
|
||||
.quick-create-page .quick-upload-preview img { width: 100%; height: 100%; object-fit: cover; }.quick-create-page .quick-image-count { position: absolute; right: 12px; bottom: 12px; padding: 6px 9px; border-radius: 999px; color: #fff; background: rgba(16,16,18,.72); font-size: 11px; }
|
||||
.quick-create-page .quick-image-clear { position: absolute; top: 12px; right: 12px; width: 34px; height: 34px; display: grid; place-items: center; border: 0; border-radius: 10px; color: #fff; background: rgba(16,16,18,.72); cursor: pointer; }.quick-create-page .quick-image-clear svg { width: 16px; height: 16px; }
|
||||
.quick-create-page .quick-auto-note { display: grid; grid-template-columns: repeat(4,minmax(0,1fr)); gap: 8px; margin-top: 20px; }.quick-create-page .quick-auto-note span { min-height: 54px; display: grid; place-items: center; padding: 8px; border-radius: 10px; color: #4f5662; background: rgba(34,42,54,.045); text-align: center; font-size: 11px; line-height: 1.45; }
|
||||
.quick-create-page .quick-form-footer { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-top: auto; padding-top: 26px; }.quick-create-page .quick-cost strong,.quick-create-page .quick-cost span { display: block; }.quick-create-page .quick-cost span { color: var(--quick-muted); font-size: 11px; }.quick-create-page .quick-cost strong { margin-top: 5px; font-size: 14px; }
|
||||
.quick-create-page .quick-generate-button { min-width: 220px; height: 48px; display: inline-flex; align-items: center; justify-content: center; gap: 9px; border: 0; border-radius: 12px; color: #fff; background: var(--quick-blue); font: inherit; font-weight: 700; cursor: pointer; box-shadow: 0 12px 24px rgba(0,47,167,.22); }.quick-create-page .quick-generate-button:disabled { color: #99a1ae; background: #e5e8ee; box-shadow: none; cursor: not-allowed; }.quick-create-page .quick-generate-button svg { width: 20px; height: 20px; }
|
||||
.quick-create-page .quick-status-panel { position: relative; display: grid; place-items: center; min-height: 0; padding: 34px; background: radial-gradient(circle at 50% 45%,rgba(0,47,167,.075),transparent 34%),rgba(250,251,253,.88); }.quick-create-page .quick-state { width: min(560px,100%); }.quick-create-page .quick-state-ready { display: grid; justify-items: center; text-align: center; }
|
||||
.quick-create-page .quick-ready-orbit { position: relative; width: 176px; height: 176px; display: grid; place-items: center; margin-bottom: 24px; border: 1px solid rgba(0,47,167,.14); border-radius: 50%; }.quick-create-page .quick-ready-orbit::before,.quick-create-page .quick-ready-orbit::after { content: ""; position: absolute; border: 1px solid rgba(0,47,167,.07); border-radius: 50%; }.quick-create-page .quick-ready-orbit::before { inset: 20px; }.quick-create-page .quick-ready-orbit::after { inset: -28px; }.quick-create-page .quick-ready-icon { width: 54px; height: 54px; display: grid; place-items: center; color: var(--quick-blue); }.quick-create-page .quick-ready-icon svg { width: 42px; height: 42px; stroke-width: 1.7; }
|
||||
.quick-create-page .quick-state-ready h2 { margin: 0 0 9px; font-size: 23px; }.quick-create-page .quick-state-ready > p { max-width: 430px; margin: 0; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }.quick-create-page .quick-ready-tags { display: flex; flex-wrap: wrap; justify-content: center; gap: 8px; margin-top: 20px; }.quick-create-page .quick-ready-tags span { padding: 7px 10px; border: 1px solid rgba(34,42,54,.08); border-radius: 999px; color: #505762; background: rgba(255,255,255,.72); font-size: 11px; }
|
||||
@media (max-width: 1400px) { .quick-create-page .quick-create-shell { grid-template-columns: minmax(0,.9fr) minmax(0,1.1fr); }.quick-create-page .quick-form-panel,.quick-create-page .quick-status-panel { padding: 24px; } }
|
||||
@@ -28,7 +28,7 @@ const CREATE_GROUPS: Array<{
|
||||
label: "从商品开始",
|
||||
hint: "围绕商品卖点生成完整带货内容",
|
||||
cards: [
|
||||
{ title: "极速成片", desc: "上传商品图片,自动完成整条视频", tone: "primary", page: "projectWizard", icon: "wand" },
|
||||
{ title: "极速成片", desc: "上传商品图片,自动完成整条视频", tone: "primary", page: "quickCreate", icon: "wand" },
|
||||
{ title: "专业创作", desc: "控制脚本、资产、故事板与生成", tone: "primary", page: "projectWizard", icon: "folder" },
|
||||
],
|
||||
},
|
||||
@@ -36,8 +36,8 @@ const CREATE_GROUPS: Array<{
|
||||
label: "从参考视频开始",
|
||||
hint: "复用成熟视频的镜头表达与节奏",
|
||||
cards: [
|
||||
{ title: "视频复刻", desc: "拆解参考视频并提炼可编辑提示词", tone: "subtle", page: "videoRemix", icon: "scan" },
|
||||
{ title: "商品替换", desc: "保留原片表达,替换为自己的商品", tone: "subtle", page: "freeCreate", icon: "replace" },
|
||||
{ title: "提炼提示词", desc: "上传参考视频,提炼可编辑提示词", tone: "subtle", page: "videoRemix", icon: "scan" },
|
||||
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容", tone: "subtle", page: "freeCreate", icon: "replace" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -139,7 +139,7 @@ export function FreeCreatePage({ modelConfigs, onNotify, onTaskSettled, onBack }
|
||||
if (!draft) return;
|
||||
sessionStorage.removeItem(REMIX_PROMPT_KEY);
|
||||
window.setTimeout(() => promptRef.current?.setContent(draft, []), 0);
|
||||
notify("info", "已填入复刻提示词,可改完再生成");
|
||||
notify("info", "已填入分镜稿,可改完再生成");
|
||||
}, [notify]);
|
||||
|
||||
// —— 弹窗 ——
|
||||
|
||||
@@ -11,5 +11,6 @@ export { TeamPage } from "./team";
|
||||
export { MessagesPage } from "./messages";
|
||||
export { AssetFactoryPage, ImageWorkbenchPage, ModelPhotoDemoPage } from "./ai-tools";
|
||||
export { FreeCreatePage } from "./free-create";
|
||||
export { QuickCreatePage } from "./quick-create";
|
||||
export { VideoRemixPage } from "./video-remix";
|
||||
export { SettingsPage } from "./settings";
|
||||
|
||||
@@ -1356,7 +1356,7 @@ export function PipelinePage(props: {
|
||||
}, [chatText]);
|
||||
const chatFileRef = useRef<HTMLInputElement | null>(null);
|
||||
const [scriptFileBusy, setScriptFileBusy] = useState(false); // 脚本文件读取中(docx 走后端,有网络往返)
|
||||
// 1.11 上传视频提炼:抽帧 + 读帧要 30~60 秒,比读 docx 慢一个量级,单独一个 busy 位并在聊天区留一条进度消息
|
||||
// 1.11 上传视频提炼:整段视频交给 Gemini,大约 1–2 分钟,单独一个 busy 位并在聊天区留一条进度消息
|
||||
const videoFileRef = useRef<HTMLInputElement | null>(null);
|
||||
const [videoDigestBusy, setVideoDigestBusy] = useState(false);
|
||||
const chatBodyRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -1751,7 +1751,7 @@ export function PipelinePage(props: {
|
||||
window.setTimeout(() => chatTextareaRef.current?.focus(), 0);
|
||||
}
|
||||
}
|
||||
// 1.11 上传视频提炼:后端抽帧读帧出分镜稿 → 填进输入框。
|
||||
// 1.11 上传视频提炼:整段视频(含口播)交给 Gemini → 中文分镜稿填进输入框。
|
||||
// 稿子**故意落在可编辑的输入框里而不是直接出脚本**:AI 拆解必然有错,用户先逐镜改完再交给脚本 agent。
|
||||
async function onPickVideoFile(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
@@ -1769,7 +1769,7 @@ export function PipelinePage(props: {
|
||||
}
|
||||
setVideoDigestBusy(true);
|
||||
pushMsg("user", `已上传视频《${file.name}》`);
|
||||
pushMsg("ai", "正在抽帧拆解这条视频,大约需要半分钟…");
|
||||
pushMsg("ai", "正在把整段视频交给模型,大约需要一到两分钟…");
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
@@ -1779,8 +1779,12 @@ export function PipelinePage(props: {
|
||||
setChatText((prev) => (prev ? `${prev}\n${text}` : text));
|
||||
setChatAttachments((list) => [...list, { name: file.name, chars: text.length }]);
|
||||
setChatMode("video");
|
||||
pushMsg("ai", `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧。拆解稿在下面输入框里,`
|
||||
+ "请先逐镜核对再点确定 —— 尤其是「拆解存疑」那几条。确认后我按你的商品把它改写成新脚本。");
|
||||
pushMsg(
|
||||
"ai",
|
||||
digest.input === "native_video"
|
||||
? `已拆出 ${digest.duration} 秒整段视频。拆解稿在下面输入框里,请先逐镜核对再点确定 —— 尤其是「拆解存疑」那几条。确认后我按你的商品把它改写成新脚本。`
|
||||
: `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧。拆解稿在下面输入框里,请先逐镜核对再点确定 —— 尤其是「拆解存疑」那几条。确认后我按你的商品把它改写成新脚本。`
|
||||
);
|
||||
chatTextareaRef.current?.focus();
|
||||
} catch (error) {
|
||||
pushMsg("ai", "这条视频没能拆开,可以换一条再试。");
|
||||
|
||||
@@ -440,8 +440,8 @@ const VIDEO_MAKE: Array<{ title: string; desc: string; page: Page; image: string
|
||||
];
|
||||
const VIDEO_TOOLS: Array<{ title: string; desc: string; page: Page; image: string }> = [
|
||||
{ title: "自由生成", desc: "使用提示词、参考图片与素材库,自由控制画面和生成参数。", page: "freeCreate", image: "/assets/yz/video-free.jpg" },
|
||||
{ title: "视频复刻", desc: "上传参考视频,拆解镜头、动作和节奏,生成可编辑复刻提示词。", page: "videoRemix", image: "/assets/yz/video-remix.jpg" },
|
||||
{ title: "商品替换", desc: "保留参考视频的人物、场景与节奏,将原商品替换为自己的商品。", page: "freeCreate", image: "/assets/yz/video-replace.jpg" },
|
||||
{ title: "提炼提示词", desc: "从参考视频中提炼可编辑的视频生成提示词。", page: "videoRemix", image: "/assets/yz/video-remix.jpg" },
|
||||
{ title: "视频复刻", desc: "保留参考视频的人物、场景与节奏,复刻出自己的带货内容。", page: "freeCreate", image: "/assets/yz/video-replace.jpg" },
|
||||
];
|
||||
|
||||
function projCardSub(project: Project, productTitle: string): string {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ArrowLeft, Sparkles, Trash2, Upload, WandSparkles } from "lucide-react";
|
||||
import type { Page } from "./route-config";
|
||||
|
||||
export function QuickCreatePage({ onBack }: { onBack: () => void; navigate?: (page: Page) => void }) {
|
||||
const [name, setName] = useState("");
|
||||
const [images, setImages] = useState<File[]>([]);
|
||||
const [preview, setPreview] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!images[0]) {
|
||||
setPreview("");
|
||||
return;
|
||||
}
|
||||
const url = URL.createObjectURL(images[0]);
|
||||
setPreview(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [images]);
|
||||
|
||||
function selectImages(files: FileList | null) {
|
||||
setImages(Array.from(files || []).slice(0, 9));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="quick-create-page">
|
||||
<header className="project-builder-header quick-create-header">
|
||||
<div className="project-builder-title">
|
||||
<button type="button" className="image-back-button" onClick={onBack} aria-label="返回工作台"><ArrowLeft /></button>
|
||||
<div><h1>极速成片</h1><p>输入商品名称并上传图片,系统将自动完成从商品理解到视频生成的全部流程</p></div>
|
||||
</div>
|
||||
<div className="project-builder-status"><strong>AI 自动编排</strong><span>· 无需逐步配置</span></div>
|
||||
</header>
|
||||
|
||||
<div className="quick-create-shell" id="quickCreateShell">
|
||||
<section className="quick-create-panel quick-form-panel">
|
||||
<div className="quick-form-copy">
|
||||
<h2>告诉我们这是什么商品</h2>
|
||||
<p>系统会识别商品信息,自动选择带货结构、表现形式、模特和场景,并完成15秒竖屏视频。</p>
|
||||
</div>
|
||||
|
||||
<label className="quick-field">
|
||||
<span className="quick-field-label"><span>商品名称</span><small>必填</small></span>
|
||||
<input className="quick-name-input" autoComplete="off" value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:净颜精华、轻醒咖啡" />
|
||||
</label>
|
||||
|
||||
<div className="quick-field">
|
||||
<span className="quick-field-label"><span>商品图片</span><small>必填 · 最多9张</small></span>
|
||||
<label className={`quick-upload${preview ? " has-image" : ""}`}>
|
||||
<input type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={(event) => selectImages(event.target.files)} />
|
||||
<span className="quick-upload-copy"><span className="quick-upload-icon"><Upload /></span><strong>上传商品图片</strong><small>建议包含清晰主图、包装和使用细节</small></span>
|
||||
<span className="quick-upload-preview">
|
||||
<img src={preview} alt="极速成片商品预览" />
|
||||
<span className="quick-image-count">{images.length}张图片</span>
|
||||
<button type="button" className="quick-image-clear" onClick={(event) => { event.preventDefault(); setImages([]); }} aria-label="删除已上传图片"><Trash2 /></button>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="quick-auto-note" aria-label="系统自动完成内容"><span>识别商品与卖点</span><span>推荐脚本方向</span><span>匹配模特与场景</span><span>生成故事板与视频</span></div>
|
||||
|
||||
<div className="quick-form-footer">
|
||||
<div className="quick-cost"><span>仅在视频生成成功后扣费</span><strong>预计 240 积分</strong></div>
|
||||
<button type="button" className="quick-generate-button" disabled><WandSparkles /><span>立即生成视频</span></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="quick-create-panel quick-status-panel" aria-live="polite">
|
||||
<div className="quick-state quick-state-ready">
|
||||
<div className="quick-ready-orbit"><span className="quick-ready-icon"><Sparkles /></span></div>
|
||||
<h2>系统会替你完成所有选择</h2>
|
||||
<p>上传商品后,AI 将根据品类、图片信息和适用场景自动编排完整视频,不需要理解复杂的制作参数。</p>
|
||||
<div className="quick-ready-tags"><span>自动推荐结构</span><span>自动选择表现形式</span><span>自动匹配资产</span><span>自动质量检查</span></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ export type Page =
|
||||
| "messages"
|
||||
| "assetFactory"
|
||||
| "freeCreate"
|
||||
| "quickCreate"
|
||||
| "videoRemix"
|
||||
| "imageOptimize"
|
||||
| "modelPhoto"
|
||||
@@ -102,7 +103,8 @@ export const routeLabels: Record<Page, string> = {
|
||||
messages: "消息",
|
||||
assetFactory: "图片工具",
|
||||
freeCreate: "自由创作",
|
||||
videoRemix: "视频复刻",
|
||||
quickCreate: "极速成片",
|
||||
videoRemix: "提炼提示词",
|
||||
imageOptimize: "图片创作",
|
||||
modelPhoto: "模特上身图",
|
||||
modelPhotoDemoA: "模特图方案 A",
|
||||
@@ -120,7 +122,7 @@ export function isPage(value: string): value is Page {
|
||||
|
||||
export function parentPage(page: Page): Page {
|
||||
if (["productDetail", "productCreateUpload"].includes(page)) return "products";
|
||||
if (["projectWizard", "freeCreate", "videoRemix", "pipeline"].includes(page)) return "projects";
|
||||
if (["projectWizard", "freeCreate", "quickCreate", "videoRemix", "pipeline"].includes(page)) return "projects";
|
||||
if (["imageOptimize", "modelPhoto", "modelPhotoDemoA", "modelPhotoDemoB", "platformCover"].includes(page)) {
|
||||
return "assetFactory";
|
||||
}
|
||||
@@ -163,6 +165,7 @@ export function resolveRoute(): ResolvedRoute {
|
||||
if (path === "/messages") return { page: "messages", authMode: "login", hash };
|
||||
if (path === "/asset-factory") return { page: "assetFactory", authMode: "login", hash };
|
||||
if (path === "/free-create") return { page: "freeCreate", authMode: "login", hash };
|
||||
if (path === "/quick-create") return { page: "quickCreate", authMode: "login", hash };
|
||||
if (path === "/video-remix") return { page: "videoRemix", authMode: "login", hash };
|
||||
if (path === "/image-optimize") {
|
||||
return { page: "imageOptimize", authMode: "login", productId: search.get("product_id") || undefined, hash };
|
||||
@@ -207,6 +210,8 @@ export function pathForPage(page: Page, options: NavigateOptions = {}) {
|
||||
return "/asset-factory";
|
||||
case "freeCreate":
|
||||
return "/free-create";
|
||||
case "quickCreate":
|
||||
return "/quick-create";
|
||||
case "videoRemix":
|
||||
return "/video-remix";
|
||||
case "imageOptimize":
|
||||
|
||||
@@ -1,23 +1,37 @@
|
||||
// 视频复刻 · 上传参考视频 → 视频提炼接口 → 可编辑提示词。
|
||||
// 拆解跟生成脚本同一套接口口径:模型下拉 + Gemini 3.1 Pro 官转看图。
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ArrowLeft, ArrowRight, FileVideo, Save, ScanLine, ScanSearch } from "lucide-react";
|
||||
// 提炼提示词 · 上传参考视频 → 拆解接口 → 可编辑提示词。对照影擎 remix-page。
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
BadgeCheck,
|
||||
Clock3,
|
||||
FileVideo,
|
||||
FileVideo2,
|
||||
PanelsTopLeft,
|
||||
RectangleVertical,
|
||||
Save,
|
||||
ScanLine,
|
||||
ScanSearch,
|
||||
TextCursorInput,
|
||||
} 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";
|
||||
const VIDEO_DIGEST_POINTS = 30;
|
||||
|
||||
type RemixDraft = {
|
||||
prompt: string;
|
||||
duration: number;
|
||||
shots: number;
|
||||
rhythm: string;
|
||||
ratio: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
modelId: string;
|
||||
fileKind: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
function loadDraft(): RemixDraft | null {
|
||||
@@ -43,24 +57,11 @@ function saveDraft(draft: RemixDraft) {
|
||||
|
||||
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";
|
||||
@@ -76,6 +77,45 @@ function pickDigestModel(models: ModelConfig[]) {
|
||||
);
|
||||
}
|
||||
|
||||
function fileKind(file: File | null, name: string) {
|
||||
if (file?.type === "video/quicktime" || /\.mov$/i.test(file?.name || name)) return "MOV";
|
||||
if (/\.webm$/i.test(file?.name || name)) return "WEBM";
|
||||
return "MP4";
|
||||
}
|
||||
|
||||
function ratioLabel(width: number, height: number) {
|
||||
if (!width || !height) return "—";
|
||||
const r = width / height;
|
||||
if (Math.abs(r - 9 / 16) < 0.08) return "9:16 竖屏";
|
||||
if (Math.abs(r - 16 / 9) < 0.08) return "16:9 横屏";
|
||||
if (Math.abs(r - 1) < 0.08) return "1:1";
|
||||
return width > height ? "横屏" : "竖屏";
|
||||
}
|
||||
|
||||
function readVideoMeta(file: File): Promise<{ duration: number; width: number; height: number }> {
|
||||
return new Promise((resolve) => {
|
||||
const url = URL.createObjectURL(file);
|
||||
const video = document.createElement("video");
|
||||
video.preload = "metadata";
|
||||
video.onloadedmetadata = () => {
|
||||
const meta = { duration: video.duration || 0, width: video.videoWidth || 0, height: video.videoHeight || 0 };
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(meta);
|
||||
};
|
||||
video.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve({ duration: 0, width: 0, height: 0 });
|
||||
};
|
||||
video.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function fileMetaCopy(kind: string, size: number, width: number, height: number) {
|
||||
if (width && height) return `${kind} · ${width} × ${height}`;
|
||||
if (size > 0) return `${kind} · ${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return kind;
|
||||
}
|
||||
|
||||
export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }: {
|
||||
textModels?: ModelConfig[];
|
||||
onNotify: (type: "success" | "error" | "info", text: string) => void;
|
||||
@@ -88,31 +128,27 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
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 [ratio, setRatio] = useState(draft?.ratio || "");
|
||||
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 [kind, setKind] = useState(draft?.fileKind || "MP4");
|
||||
const [width, setWidth] = useState(draft?.width || 0);
|
||||
const [height, setHeight] = useState(draft?.height || 0);
|
||||
const [hasResult, setHasResult] = useState(Boolean(draft?.prompt.trim()));
|
||||
const promptRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
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));
|
||||
|
||||
const activeModel = useMemo(() => pickDigestModel(digestModels), [digestModels]);
|
||||
const [priceMultiplier, setPriceMultiplier] = useState(1);
|
||||
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]);
|
||||
void api.billingConfig().then((cfg) => setPriceMultiplier(Number(cfg.team_price_multiplier) || 1)).catch(() => undefined);
|
||||
}, []);
|
||||
const estimatedPoints = priceMultiplier === 1
|
||||
? VIDEO_DIGEST_POINTS
|
||||
: Math.max(1, Math.round(Number((VIDEO_DIGEST_POINTS * priceMultiplier).toFixed(6))));
|
||||
|
||||
useEffect(() => {
|
||||
if (!prompt.trim()) return;
|
||||
@@ -120,14 +156,23 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
prompt,
|
||||
duration,
|
||||
shots,
|
||||
rhythm,
|
||||
ratio,
|
||||
fileName: file?.name || fileName,
|
||||
fileSize: file?.size || fileSize,
|
||||
modelId: activeModelId,
|
||||
fileKind: kind,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}, [prompt, duration, shots, rhythm, file, fileName, fileSize, activeModelId]);
|
||||
}, [prompt, duration, shots, ratio, file, fileName, fileSize, kind, width, height]);
|
||||
|
||||
const pickFile = (next: File | null) => {
|
||||
useEffect(() => {
|
||||
const el = promptRef.current;
|
||||
if (!el || !hasResult) return;
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.max(132, el.scrollHeight)}px`;
|
||||
}, [prompt, hasResult]);
|
||||
|
||||
const pickFile = async (next: File | null) => {
|
||||
if (!next) return;
|
||||
if (!/\.(mp4|mov|m4v|webm)$/i.test(next.name)) {
|
||||
onNotify("error", "只支持 mp4 / mov / m4v / webm 四种视频格式");
|
||||
@@ -137,9 +182,16 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
onNotify("error", "视频不能超过 200MB,请压缩后再传");
|
||||
return;
|
||||
}
|
||||
const meta = await readVideoMeta(next);
|
||||
setFile(next);
|
||||
setFileName(next.name);
|
||||
setFileSize(next.size);
|
||||
setKind(fileKind(next, next.name));
|
||||
setWidth(meta.width);
|
||||
setHeight(meta.height);
|
||||
setRatio(ratioLabel(meta.width, meta.height));
|
||||
if (meta.duration) setDuration(Math.round(meta.duration));
|
||||
setHasResult(false);
|
||||
};
|
||||
|
||||
const analyze = async () => {
|
||||
@@ -148,16 +200,16 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
if (activeModelId) fd.append("model_config_id", activeModelId);
|
||||
if (activeModel?.id) fd.append("model_config_id", activeModel.id);
|
||||
const digest = await api.extractVideoDigest(fd);
|
||||
const text = digest.text.trim();
|
||||
setPrompt(text);
|
||||
setDuration(digest.duration);
|
||||
setDuration(digest.duration || duration);
|
||||
setShots(shotCount(text, digest.frames));
|
||||
setRhythm(rhythmCopy(text));
|
||||
setFileName(file.name);
|
||||
setFileSize(file.size);
|
||||
onNotify("success", `已拆出 ${digest.duration} 秒 · 取了 ${digest.frames} 帧,请先逐镜核对`);
|
||||
setHasResult(true);
|
||||
onNotify("success", "视频拆解完成,已生成提示词");
|
||||
} catch (error) {
|
||||
onNotify("error", error instanceof Error && error.message ? error.message : "视频拆解失败,请重试");
|
||||
} finally {
|
||||
@@ -184,140 +236,134 @@ export function VideoRemixPage({ textModels = [], onNotify, onBack, navigate }:
|
||||
navigate("freeCreate");
|
||||
};
|
||||
|
||||
const analyzeLabel = busy
|
||||
? "正在拆解…"
|
||||
: `生成这个需要 ${estimatedPoints} 积分`;
|
||||
|
||||
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>
|
||||
<section className="video-tool-page remix-page">
|
||||
<header className="page-header">
|
||||
<div className="image-title-row">
|
||||
<button type="button" className="image-back-button" aria-label="返回上一入口页面" onClick={onBack}>
|
||||
<ArrowLeft />
|
||||
</button>
|
||||
<div className="page-heading">
|
||||
<h1>提炼提示词</h1>
|
||||
<p>上传参考视频,生成可编辑的视频提示词</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="video-flow-grid remix-flow-grid">
|
||||
<section className="video-flow-panel video-remix-upload-panel">
|
||||
<h2>上传参考视频</h2>
|
||||
<div className="video-flow-step">
|
||||
<div className="video-flow-step-head">
|
||||
<strong>参考视频</strong>
|
||||
<span>MP4 / MOV · 最长 60 秒</span>
|
||||
</div>
|
||||
<label className={`video-upload-field${file ? " has-file" : ""}`}>
|
||||
<input
|
||||
type="file"
|
||||
accept="video/mp4,video/quicktime"
|
||||
hidden
|
||||
onChange={(event) => {
|
||||
void pickFile(event.target.files?.[0] || null);
|
||||
event.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
<FileVideo />
|
||||
<strong>{file?.name || fileName || "点击上传参考视频"}</strong>
|
||||
<small>
|
||||
{file
|
||||
? "视频已就绪,可开始拆解"
|
||||
: fileName
|
||||
? "上次拆解还在,刷新不会丢。要重拆请再选一次文件"
|
||||
: "上传后自动识别镜头结构与内容节奏"}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="video-flow-actions remix-analyze-actions">
|
||||
<button type="button" className="primary-action" disabled={!file || busy} onClick={() => void analyze()}>
|
||||
<ScanSearch />
|
||||
<span>{analyzeLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside className={`video-result-panel remix-information-panel${hasResult ? " has-result" : ""}`} aria-live="polite">
|
||||
<div className="video-result-placeholder">
|
||||
<div>
|
||||
<span className="remix-placeholder-icon"><ScanLine /></span>
|
||||
<strong>等待视频解析</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="video-analysis-result">
|
||||
<div className="remix-info-heading">
|
||||
<span className="remix-status-icon"><BadgeCheck /></span>
|
||||
<div>
|
||||
<span className="remix-eyebrow">Analysis complete</span>
|
||||
<h2>参考视频信息</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="remix-info-list">
|
||||
<div className="remix-info-item">
|
||||
<span><Clock3 />视频时长</span>
|
||||
<strong>{duration ? `${duration} 秒` : "—"}</strong>
|
||||
</div>
|
||||
<div className="remix-info-item">
|
||||
<span><RectangleVertical />画面比例</span>
|
||||
<strong>{ratio || "—"}</strong>
|
||||
</div>
|
||||
<div className="remix-info-item">
|
||||
<span><PanelsTopLeft />镜头数量</span>
|
||||
<strong>{shots ? `${shots} 个镜头` : "—"}</strong>
|
||||
</div>
|
||||
<div className="remix-info-item remix-info-file">
|
||||
<span><FileVideo2 />已解析文件</span>
|
||||
<strong>{fileName || "参考视频.mp4"}</strong>
|
||||
<small>{fileMetaCopy(kind, fileSize, width, height)}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section className={`remix-prompt-panel${hasResult ? " has-result" : ""}`} aria-live="polite">
|
||||
<div className="remix-prompt-placeholder">
|
||||
<span><TextCursorInput /></span>
|
||||
<div><strong>等待生成提示词</strong></div>
|
||||
</div>
|
||||
<div className="remix-prompt-result">
|
||||
<div className="remix-prompt-head">
|
||||
<div className="remix-prompt-title">
|
||||
<div><h2>提示词内容</h2></div>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
ref={promptRef}
|
||||
className="analysis-prompt"
|
||||
aria-label="复刻提示词"
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
/>
|
||||
<div className="video-flow-actions remix-prompt-actions">
|
||||
<button type="button" className="secondary-action" onClick={() => void savePrompt()}>
|
||||
<Save />
|
||||
保存提示词
|
||||
</button>
|
||||
<button type="button" className="primary-action" onClick={continueGenerate}>
|
||||
<span>生成视频</span>
|
||||
<ArrowRight />
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,15 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.trash-empty { min-height: 220px; flex-direction: column; gap: 10px; }
|
||||
.trash-empty {
|
||||
min-height: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 分区(商品 / 资产):mono 小标题 + 各自列表 */
|
||||
.trash-section { margin-bottom: 22px; }
|
||||
|
||||
@@ -1,119 +1,465 @@
|
||||
/* 视频复刻页 · 两栏工作台。不重写共享 .btn/.pill/.input/.chip/.textarea。 */
|
||||
|
||||
/* 提炼提示词 · 对照影擎 remix-page。不重写共享 .btn/.pill/.input/.chip/.textarea。 */
|
||||
.vr-page {
|
||||
--klein: #002fa7;
|
||||
--klein-hover: #002680;
|
||||
--muted: #6f747c;
|
||||
--text: #17181a;
|
||||
--shadow-card: 0 8px 20px rgba(20, 27, 38, 0.09);
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
box-sizing: border-box;
|
||||
margin: -24px -28px -60px;
|
||||
padding: 52px clamp(40px, 3.2vw, 68px) 48px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.vr-page { margin: -28px -24px -48px; }
|
||||
}
|
||||
|
||||
.vr-page .vr-inner {
|
||||
width: min(1320px, 100%);
|
||||
width: min(1560px, 100%);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.vr-page .vr-title-row {
|
||||
.vr-page .video-tool-page {
|
||||
min-height: calc(100vh - 116px - 100px);
|
||||
}
|
||||
|
||||
.vr-page .page-header {
|
||||
position: relative;
|
||||
min-height: 76px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 30px;
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
|
||||
.vr-page .image-title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 13px;
|
||||
}
|
||||
|
||||
.vr-page .image-back-button {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: 0 0 auto;
|
||||
margin-top: 2px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.46);
|
||||
border-radius: 12px;
|
||||
color: var(--klein);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
cursor: pointer;
|
||||
box-shadow: 0 5px 14px rgba(0, 47, 167, 0.07);
|
||||
transition: background-color 180ms ease, transform 180ms ease;
|
||||
}
|
||||
.vr-page .image-back-button:hover {
|
||||
transform: translateX(-2px);
|
||||
border-color: var(--klein);
|
||||
background: rgba(0, 47, 167, 0.055);
|
||||
}
|
||||
.vr-page .image-back-button svg { width: 19px; height: 19px; }
|
||||
|
||||
.vr-page .page-heading h1 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 32px;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
letter-spacing: -.02em;
|
||||
color: var(--text);
|
||||
}
|
||||
.vr-page .page-heading p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.vr-page .video-flow-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.08fr) minmax(360px, 0.92fr);
|
||||
gap: 22px;
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.vr-page .remix-flow-grid {
|
||||
grid-template-columns: minmax(0, 1.42fr) minmax(310px, 0.58fr);
|
||||
}
|
||||
|
||||
.vr-page .video-flow-panel,
|
||||
.vr-page .video-result-panel {
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(34, 42, 54, 0.1);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.vr-page .video-remix-upload-panel,
|
||||
.vr-page .remix-information-panel,
|
||||
.vr-page .remix-prompt-panel {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-color: rgba(0, 47, 167, 0.13);
|
||||
background-color: rgba(251, 253, 255, 0.96);
|
||||
box-shadow: 0 16px 38px rgba(22, 45, 92, 0.07), 0 2px 8px rgba(34, 42, 54, 0.035);
|
||||
}
|
||||
|
||||
.vr-page .video-remix-upload-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.vr-page .video-remix-upload-panel::before,
|
||||
.vr-page .remix-prompt-panel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: var(--klein);
|
||||
}
|
||||
|
||||
.vr-page .remix-information-panel {
|
||||
isolation: isolate;
|
||||
background-color: rgba(249, 252, 255, 0.96);
|
||||
}
|
||||
|
||||
.vr-page .remix-information-panel::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
z-index: -1;
|
||||
height: 3px;
|
||||
background: var(--klein);
|
||||
transition: height 220ms ease;
|
||||
}
|
||||
|
||||
.vr-page .remix-information-panel.has-result::before {
|
||||
height: 86px;
|
||||
background: var(--klein);
|
||||
}
|
||||
|
||||
.vr-page .remix-information-panel > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.vr-page .remix-information-panel .video-result-placeholder,
|
||||
.vr-page .remix-information-panel .video-analysis-result {
|
||||
min-height: 286px;
|
||||
}
|
||||
|
||||
.vr-page .remix-information-panel .video-result-placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.vr-page .remix-information-panel .video-result-placeholder strong {
|
||||
color: var(--text);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.vr-page .remix-information-panel .video-result-placeholder svg {
|
||||
color: var(--klein);
|
||||
}
|
||||
|
||||
.vr-page .remix-placeholder-icon {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.13);
|
||||
border-radius: 16px;
|
||||
background: rgba(0, 47, 167, 0.055);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.vr-page .remix-info-heading {
|
||||
min-height: 62px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.vr-page .vr-back {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
.vr-page .remix-status-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
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;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.vr-page .vr-panel,
|
||||
.vr-page .vr-result {
|
||||
position: relative;
|
||||
padding: 22px 24px;
|
||||
border-radius: var(--r-md);
|
||||
background: var(--surface);
|
||||
.vr-page .remix-status-icon svg { width: 19px; height: 19px; }
|
||||
|
||||
.vr-page .remix-eyebrow {
|
||||
display: block;
|
||||
margin-bottom: 3px;
|
||||
color: rgba(255, 255, 255, 0.66);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.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 .remix-info-heading h2 {
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vr-page .vr-lead {
|
||||
margin: 0 0 18px;
|
||||
color: var(--black-alpha-56);
|
||||
.vr-page .remix-info-heading p {
|
||||
margin: 5px 0 0;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.vr-page .remix-info-list {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-rows: repeat(2, minmax(0, 1fr));
|
||||
gap: 9px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.vr-page .remix-info-item {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.09);
|
||||
border-radius: 11px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
|
||||
.vr-page .remix-info-item span,
|
||||
.vr-page .remix-info-file span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #72809a;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vr-page .remix-info-item span svg,
|
||||
.vr-page .remix-info-file span svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--klein);
|
||||
}
|
||||
|
||||
.vr-page .remix-info-item strong {
|
||||
display: block;
|
||||
margin-top: 9px;
|
||||
color: #1f2b42;
|
||||
font-size: 16px;
|
||||
line-height: 1.3;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vr-page .remix-info-list .remix-info-file {
|
||||
margin-top: 0;
|
||||
padding: 11px 12px;
|
||||
}
|
||||
|
||||
.vr-page .remix-info-list .remix-info-file strong { font-size: 14px; }
|
||||
.vr-page .remix-info-list .remix-info-file small { font-size: 11px; }
|
||||
|
||||
.vr-page .remix-info-file strong,
|
||||
.vr-page .remix-info-file small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vr-page .remix-info-file strong {
|
||||
margin-top: 6px;
|
||||
color: #1f2b42;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vr-page .remix-info-file small {
|
||||
margin-top: 3px;
|
||||
color: #8a95a9;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.vr-page .remix-prompt-panel {
|
||||
min-height: 188px;
|
||||
margin-top: 22px;
|
||||
padding: 24px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.13);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.vr-page .remix-prompt-placeholder {
|
||||
min-height: 138px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
color: var(--muted);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.vr-page .remix-prompt-placeholder > span {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgba(0, 47, 167, 0.13);
|
||||
border-radius: 14px;
|
||||
color: var(--klein);
|
||||
background: rgba(0, 47, 167, 0.055);
|
||||
}
|
||||
|
||||
.vr-page .remix-prompt-placeholder svg { width: 22px; height: 22px; }
|
||||
.vr-page .remix-prompt-placeholder strong {
|
||||
display: block;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vr-page .remix-prompt-result { display: none; }
|
||||
.vr-page .remix-prompt-panel.has-result .remix-prompt-placeholder { display: none; }
|
||||
.vr-page .remix-prompt-panel.has-result .remix-prompt-result { display: block; }
|
||||
|
||||
.vr-page .remix-prompt-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.vr-page .remix-prompt-title {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.vr-page .remix-prompt-title h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.vr-page .remix-prompt-panel .analysis-prompt {
|
||||
width: 100%;
|
||||
min-height: 132px;
|
||||
overflow-y: hidden;
|
||||
resize: none;
|
||||
padding: 15px;
|
||||
border: 1px solid rgba(0, 47, 167, 0.14);
|
||||
border-radius: 11px;
|
||||
outline: none;
|
||||
color: var(--text);
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
box-shadow: inset 0 1px 2px rgba(27, 45, 83, 0.025);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.vr-page .vr-step { margin-top: 4px; }
|
||||
.vr-page .remix-prompt-panel .analysis-prompt:focus {
|
||||
border-color: var(--klein);
|
||||
box-shadow: 0 0 0 3px rgba(0, 47, 167, 0.08);
|
||||
}
|
||||
|
||||
.vr-page .vr-step-h {
|
||||
.vr-page .video-flow-actions.remix-prompt-actions {
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vr-page .video-flow-actions.remix-analyze-actions {
|
||||
justify-content: flex-end;
|
||||
margin-top: auto;
|
||||
padding-top: 22px;
|
||||
}
|
||||
|
||||
.vr-page .video-flow-panel h2,
|
||||
.vr-page .video-result-panel h2 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.vr-page .remix-information-panel.has-result .remix-info-heading h2 {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.vr-page .video-flow-step { margin-top: 22px; }
|
||||
|
||||
.vr-page .video-flow-step-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.vr-page .vr-step-h strong {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--accent-black);
|
||||
.vr-page .video-flow-step-head strong {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.vr-page .vr-step-h span {
|
||||
color: var(--black-alpha-48);
|
||||
font-family: var(--font-mono);
|
||||
.vr-page .video-flow-step-head span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
.vr-page .vr-upload {
|
||||
.vr-page .video-upload-field {
|
||||
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);
|
||||
border: 1px dashed rgba(0, 47, 167, 0.3);
|
||||
border-radius: 13px;
|
||||
color: #42506a;
|
||||
background: rgba(0, 47, 167, 0.04);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color 160ms ease, background 160ms ease;
|
||||
}
|
||||
|
||||
.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 .video-upload-field:hover,
|
||||
.vr-page .video-upload-field.has-file {
|
||||
border-color: var(--klein);
|
||||
background: rgba(0, 47, 167, 0.075);
|
||||
}
|
||||
|
||||
.vr-page .vr-upload > span {
|
||||
.vr-page .video-upload-field > span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vr-page .vr-upload svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
.vr-page .video-upload-field svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--klein);
|
||||
}
|
||||
|
||||
.vr-page .vr-upload strong {
|
||||
color: var(--accent-black);
|
||||
.vr-page .video-upload-field strong {
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
@@ -122,12 +468,12 @@
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.vr-page .vr-upload small {
|
||||
color: var(--black-alpha-48);
|
||||
.vr-page .video-upload-field small {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.vr-page .vr-actions {
|
||||
.vr-page .video-flow-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -135,93 +481,88 @@
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.vr-page .vr-meta {
|
||||
display: flex;
|
||||
.vr-page .primary-action,
|
||||
.vr-page .secondary-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
border-radius: 11px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
transition: transform 180ms ease, background-color 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.vr-page .vr-meta > span {
|
||||
color: var(--black-alpha-48);
|
||||
font-size: 12px;
|
||||
.vr-page .primary-action {
|
||||
min-width: 158px;
|
||||
height: 50px;
|
||||
padding: 0 22px;
|
||||
color: #fff;
|
||||
background: var(--klein);
|
||||
border: 0;
|
||||
box-shadow: 0 9px 18px rgba(0, 47, 167, 0.18);
|
||||
}
|
||||
|
||||
.vr-page .vr-placeholder,
|
||||
.vr-page .vr-analysis { min-height: 360px; }
|
||||
.vr-page .primary-action:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
background: var(--klein-hover);
|
||||
}
|
||||
|
||||
.vr-page .vr-placeholder {
|
||||
.vr-page .primary-action:disabled {
|
||||
opacity: 0.38;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.vr-page .secondary-action {
|
||||
min-width: 132px;
|
||||
height: 46px;
|
||||
padding: 0 18px;
|
||||
color: var(--text);
|
||||
border: 1px solid rgba(28, 34, 43, 0.12);
|
||||
background: rgba(34, 42, 54, 0.05);
|
||||
}
|
||||
|
||||
.vr-page .secondary-action:hover:not(:disabled) {
|
||||
background: rgba(34, 42, 54, 0.10);
|
||||
}
|
||||
|
||||
.vr-page .primary-action svg,
|
||||
.vr-page .secondary-action svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
}
|
||||
|
||||
.vr-page .video-result-placeholder,
|
||||
.vr-page .video-analysis-result {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.vr-page .video-result-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--black-alpha-48);
|
||||
text-align: left;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.vr-page .vr-placeholder > div {
|
||||
.vr-page .video-result-placeholder > div {
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
gap: 8px;
|
||||
max-width: 260px;
|
||||
place-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vr-page .vr-placeholder svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: var(--black-alpha-32);
|
||||
.vr-page .video-analysis-result { display: none; }
|
||||
.vr-page .video-result-panel.has-result .video-result-placeholder { display: none; }
|
||||
.vr-page .video-result-panel.has-result .video-analysis-result { display: block; }
|
||||
.vr-page .remix-information-panel.has-result .video-analysis-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.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; }
|
||||
@media (max-width: 1120px) {
|
||||
.vr-page .video-flow-grid,
|
||||
.vr-page .remix-flow-grid { grid-template-columns: 1fr; }
|
||||
.vr-page .remix-prompt-head { flex-direction: column; }
|
||||
.vr-page .video-flow-actions { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@ export default defineConfig({
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: process.env.VITE_API_TARGET || "http://127.0.0.1:8010",
|
||||
changeOrigin: true
|
||||
changeOrigin: true,
|
||||
// 视频提炼抽帧更密,模型写全片分镜稿可达 1–2 分钟;默认代理超时会先断
|
||||
timeout: 300_000,
|
||||
proxyTimeout: 300_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user