From 00fc454db756a4838363095a674735341f3ed164 Mon Sep 17 00:00:00 2001 From: "Azmat@qq.com" Date: Mon, 24 Aug 2026 15:52:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9E=81=E9=80=9F=E6=88=90=E7=89=87=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=92=8C=E6=8F=90=E7=82=BC=E6=8F=90=E7=A4=BA=E8=AF=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/backend/apps/ai/services.py | 4 + core/backend/apps/ai/test_video_digest.py | 143 +++- core/backend/apps/ai/video_digest.py | 247 +++++-- core/backend/apps/ai/views.py | 4 +- core/backend/apps/billing/pricing.py | 22 + core/backend/apps/billing/tests.py | 17 + .../backend/skills/video-shot-digest/SKILL.md | 87 +-- core/frontend/src/App.tsx | 3 + core/frontend/src/ai-tools-page.css | 20 +- core/frontend/src/api.ts | 10 +- core/frontend/src/components/app-shell.tsx | 4 +- core/frontend/src/library-page.css | 87 ++- core/frontend/src/main.tsx | 1 + core/frontend/src/models-page.css | 25 +- core/frontend/src/products-page.css | 20 +- core/frontend/src/project-wizard-page.css | 16 +- core/frontend/src/projects-page.css | 26 +- core/frontend/src/quick-create-page.css | 37 ++ core/frontend/src/routes/dashboard.tsx | 6 +- core/frontend/src/routes/free-create.tsx | 2 +- core/frontend/src/routes/index.ts | 1 + core/frontend/src/routes/pipeline.tsx | 14 +- core/frontend/src/routes/projects.tsx | 4 +- core/frontend/src/routes/quick-create.tsx | 78 +++ core/frontend/src/routes/route-config.ts | 9 +- core/frontend/src/routes/video-remix.tsx | 392 ++++++----- core/frontend/src/trash-page.css | 10 +- core/frontend/src/video-remix-page.css | 613 ++++++++++++++---- core/frontend/vite.config.ts | 5 +- 29 files changed, 1424 insertions(+), 483 deletions(-) create mode 100644 core/frontend/src/quick-create-page.css create mode 100644 core/frontend/src/routes/quick-create.tsx diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index b46cb7b..6983363 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -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 diff --git a/core/backend/apps/ai/test_video_digest.py b/core/backend/apps/ai/test_video_digest.py index b5af31b..ffbfe9d 100644 --- a/core/backend/apps/ai/test_video_digest.py +++ b/core/backend/apps/ai/test_video_digest.py @@ -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")) diff --git a/core/backend/apps/ai/video_digest.py b/core/backend/apps/ai/video_digest.py index bf79415..f9ad66d 100644 --- a/core/backend/apps/ai/video_digest.py +++ b/core/backend/apps/ai/video_digest.py @@ -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), diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 662cdea..c6770c3 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -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] diff --git a/core/backend/apps/billing/pricing.py b/core/backend/apps/billing/pricing.py index a2caf39..1a07eca 100644 --- a/core/backend/apps/billing/pricing.py +++ b/core/backend/apps/billing/pricing.py @@ -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) diff --git a/core/backend/apps/billing/tests.py b/core/backend/apps/billing/tests.py index 532b660..d77a6c8 100644 --- a/core/backend/apps/billing/tests.py +++ b/core/backend/apps/billing/tests.py @@ -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 diff --git a/core/backend/skills/video-shot-digest/SKILL.md b/core/backend/skills/video-shot-digest/SKILL.md index aab8bf5..089a680 100644 --- a/core/backend/skills/video-shot-digest/SKILL.md +++ b/core/backend/skills/video-shot-digest/SKILL.md @@ -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 标题符号(`#`)和代码块围栏。 diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 1eca608..5c00b75 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -29,6 +29,7 @@ import { AuthScreen, Dashboard, FreeCreatePage, + QuickCreatePage, VideoRemixPage, ImageWorkbenchPage, LibraryPage, @@ -1046,6 +1047,8 @@ export function App() { return ; case "freeCreate": return setNotice({ type, text })} onTaskSettled={refreshFreeCreateShell} onBack={() => goBack("projects")} />; + case "quickCreate": + return goBack("projects")} />; case "videoRemix": return m.capability === "text" && m.status === "active")} onNotify={(type, text) => setNotice({ type, text })} onBack={() => goBack("projects")} navigate={navigate} />; case "imageOptimize": diff --git a/core/frontend/src/ai-tools-page.css b/core/frontend/src/ai-tools-page.css index b2c5583..78c5521 100644 --- a/core/frontend/src/ai-tools-page.css +++ b/core/frontend/src/ai-tools-page.css @@ -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; diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index b6859cb..1329761 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -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 } ); diff --git a/core/frontend/src/components/app-shell.tsx b/core/frontend/src/components/app-shell.tsx index 0ca2a67..abb70e6 100644 --- a/core/frontend/src/components/app-shell.tsx +++ b/core/frontend/src/components/app-shell.tsx @@ -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; } diff --git a/core/frontend/src/library-page.css b/core/frontend/src/library-page.css index 16778a1..2535fd0 100644 --- a/core/frontend/src/library-page.css +++ b/core/frontend/src/library-page.css @@ -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 · 类型分段 + 拖拽区 + 预览 diff --git a/core/frontend/src/main.tsx b/core/frontend/src/main.tsx index 9f084c2..a0d5820 100644 --- a/core/frontend/src/main.tsx +++ b/core/frontend/src/main.tsx @@ -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(); diff --git a/core/frontend/src/models-page.css b/core/frontend/src/models-page.css index 029965f..cdab182 100644 --- a/core/frontend/src/models-page.css +++ b/core/frontend/src/models-page.css @@ -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; } diff --git a/core/frontend/src/products-page.css b/core/frontend/src/products-page.css index ec8e1dc..c2b3452 100644 --- a/core/frontend/src/products-page.css +++ b/core/frontend/src/products-page.css @@ -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; diff --git a/core/frontend/src/project-wizard-page.css b/core/frontend/src/project-wizard-page.css index 5731726..7f8dd20 100644 --- a/core/frontend/src/project-wizard-page.css +++ b/core/frontend/src/project-wizard-page.css @@ -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; diff --git a/core/frontend/src/projects-page.css b/core/frontend/src/projects-page.css index 3339448..2a078dd 100644 --- a/core/frontend/src/projects-page.css +++ b/core/frontend/src/projects-page.css @@ -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; } diff --git a/core/frontend/src/quick-create-page.css b/core/frontend/src/quick-create-page.css new file mode 100644 index 0000000..d95c8cf --- /dev/null +++ b/core/frontend/src/quick-create-page.css @@ -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; } } diff --git a/core/frontend/src/routes/dashboard.tsx b/core/frontend/src/routes/dashboard.tsx index c3f15c0..c8a969e 100644 --- a/core/frontend/src/routes/dashboard.tsx +++ b/core/frontend/src/routes/dashboard.tsx @@ -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" }, ], }, ]; diff --git a/core/frontend/src/routes/free-create.tsx b/core/frontend/src/routes/free-create.tsx index 7645089..32c320d 100644 --- a/core/frontend/src/routes/free-create.tsx +++ b/core/frontend/src/routes/free-create.tsx @@ -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]); // —— 弹窗 —— diff --git a/core/frontend/src/routes/index.ts b/core/frontend/src/routes/index.ts index 7ff59f2..638fe01 100644 --- a/core/frontend/src/routes/index.ts +++ b/core/frontend/src/routes/index.ts @@ -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"; diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 940476d..e920517 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -1356,7 +1356,7 @@ export function PipelinePage(props: { }, [chatText]); const chatFileRef = useRef(null); const [scriptFileBusy, setScriptFileBusy] = useState(false); // 脚本文件读取中(docx 走后端,有网络往返) - // 1.11 上传视频提炼:抽帧 + 读帧要 30~60 秒,比读 docx 慢一个量级,单独一个 busy 位并在聊天区留一条进度消息 + // 1.11 上传视频提炼:整段视频交给 Gemini,大约 1–2 分钟,单独一个 busy 位并在聊天区留一条进度消息 const videoFileRef = useRef(null); const [videoDigestBusy, setVideoDigestBusy] = useState(false); const chatBodyRef = useRef(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) { 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", "这条视频没能拆开,可以换一条再试。"); diff --git a/core/frontend/src/routes/projects.tsx b/core/frontend/src/routes/projects.tsx index ef78407..68cdc53 100644 --- a/core/frontend/src/routes/projects.tsx +++ b/core/frontend/src/routes/projects.tsx @@ -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 { diff --git a/core/frontend/src/routes/quick-create.tsx b/core/frontend/src/routes/quick-create.tsx new file mode 100644 index 0000000..c2b0a0f --- /dev/null +++ b/core/frontend/src/routes/quick-create.tsx @@ -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([]); + 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 ( +
+
+
+ +

极速成片

输入商品名称并上传图片,系统将自动完成从商品理解到视频生成的全部流程

+
+
AI 自动编排· 无需逐步配置
+
+ +
+
+
+

告诉我们这是什么商品

+

系统会识别商品信息,自动选择带货结构、表现形式、模特和场景,并完成15秒竖屏视频。

+
+ + + +
+ 商品图片必填 · 最多9张 + +
+ +
识别商品与卖点推荐脚本方向匹配模特与场景生成故事板与视频
+ +
+
仅在视频生成成功后扣费预计 240 积分
+ +
+
+ +
+
+
+

系统会替你完成所有选择

+

上传商品后,AI 将根据品类、图片信息和适用场景自动编排完整视频,不需要理解复杂的制作参数。

+
自动推荐结构自动选择表现形式自动匹配资产自动质量检查
+
+
+
+
+ ); +} diff --git a/core/frontend/src/routes/route-config.ts b/core/frontend/src/routes/route-config.ts index 00d0c46..a5c825d 100644 --- a/core/frontend/src/routes/route-config.ts +++ b/core/frontend/src/routes/route-config.ts @@ -28,6 +28,7 @@ export type Page = | "messages" | "assetFactory" | "freeCreate" + | "quickCreate" | "videoRemix" | "imageOptimize" | "modelPhoto" @@ -102,7 +103,8 @@ export const routeLabels: Record = { 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": diff --git a/core/frontend/src/routes/video-remix.tsx b/core/frontend/src/routes/video-remix.tsx index 51c3080..67cbf57 100644 --- a/core/frontend/src/routes/video-remix.tsx +++ b/core/frontend/src/routes/video-remix.tsx @@ -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(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 (
-
-
- -
-

视频复刻

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

上传参考视频

- // 视频提炼 -
-

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

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

提炼提示词

+

上传参考视频,生成可编辑的视频提示词

+
+
+ + +
+
+

上传参考视频

+
+
+ 参考视频 + MP4 / MOV · 最长 60 秒 +
+ +
+
+ +
+
+ + +
+ +
+
+ +
等待生成提示词
+
+
+
+
+

提示词内容

+
+
+