极速成片页面和提炼提示词

This commit is contained in:
Azmat@qq.com
2026-08-24 15:52:05 +08:00
parent 2f5b7304b0
commit 00fc454db7
29 changed files with 1424 additions and 483 deletions
+4
View File
@@ -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
+138 -5
View File
@@ -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"))
+194 -53
View File
@@ -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),
+2 -2
View File
@@ -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]
+22
View File
@@ -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)
+17
View File
@@ -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
+30 -57
View File
@@ -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 标题符号(`#`)和代码块围栏。