完成极速成品和脚本优化

This commit is contained in:
Azmat@qq.com
2026-08-25 11:13:07 +08:00
parent 00fc454db7
commit e2ec2d14af
46 changed files with 4734 additions and 432 deletions
+364 -21
View File
@@ -46,8 +46,8 @@ MIN_FRAMES = 8
MAX_FRAMES = 36
FRAME_WIDTH = 768
FRAME_QUALITY = 3
DIGEST_MAX_TOKENS = 8192
_SHOT_MARK = re.compile(r"【第\s*\d+\s*镜】")
DIGEST_MAX_TOKENS = 12288
_SHOT_MARK = re.compile(r"(?:镜头\s*\d+|\s*\d+\s*镜)")
_FFMPEG_TIMEOUT = 60
@@ -93,9 +93,10 @@ def load_digest_skill() -> str:
return main.read_text(encoding="utf-8")
# 兜底:skill 丢了也别整条链路挂掉,退化成一句话提示词(产出会明显变差,交接文档已注明须带 skills 目录)
return (
"你是分镜拆解 agent。输入是一条电商短视频的完整文件,含画面和口播"
"必须覆盖全片,从 0 写到片尾,有几镜写几镜,不要概括成几大段。"
"每镜写画面和解说词,解说词按听到的口播逐字写。输出中文纯文本。"
"你是分镜拆解 agent。输入是一条短视频的完整文件,含画面和音轨"
"必须覆盖全片,从 00:00 写到片尾,有几镜写几镜,不要概括成几大段。"
"每镜按【镜头 01】写出时间、时长、景别、机位、运镜、画面、人物动作、人物表情、"
"台词/旁白、音效、背景音乐、字幕、备注。没有就写无。输出中文纯文本。"
)
@@ -130,6 +131,73 @@ def probe_duration(path: str | Path) -> float:
return duration
def probe_video_size(path: str | Path) -> tuple[int, int]:
"""ffprobe 读画面宽高。读不到返回 0,0,不挡拆解。"""
try:
out = subprocess.run(
[
_binary("ffprobe"), "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "csv=p=0:s=x",
str(path),
],
capture_output=True, timeout=_FFMPEG_TIMEOUT, check=True,
).stdout.decode("utf-8", errors="replace").strip()
width_s, height_s = out.split("x", 1)
return max(0, int(width_s)), max(0, int(height_s))
except Exception: # noqa: BLE001
return 0, 0
def extract_cover_jpeg(path: str | Path, duration: float) -> bytes:
"""抽一帧作历史封面。失败返回空字节,不挡拆解。"""
try:
ffmpeg = _binary("ffmpeg")
except VideoDigestError:
return b""
at = max(0.0, min(float(duration) * 0.35, max(0.0, float(duration) - 0.15)))
try:
done = subprocess.run(
[
ffmpeg, "-v", "error", "-ss", f"{at:.2f}", "-i", str(path),
"-frames:v", "1", "-vf", "scale=640:-2",
"-q:v", "4", "-f", "image2", "-",
],
capture_output=True, timeout=_FFMPEG_TIMEOUT, check=True,
)
except Exception: # noqa: BLE001
return b""
return done.stdout or b""
def ratio_label(width: int, height: int) -> str:
if not width or not height:
return ""
ratio = width / height
if abs(ratio - 9 / 16) < 0.08:
return "9:16 竖屏"
if abs(ratio - 16 / 9) < 0.08:
return "16:9 横屏"
if abs(ratio - 1) < 0.08:
return "1:1"
return "横屏" if width > height else "竖屏"
def title_from_filename(name: str) -> str:
stem = Path(name or "").stem.strip()
return (stem or "参考视频")[:80]
def duration_clock(seconds: float) -> str:
total = max(0, int(round(float(seconds or 0))))
return f"{total // 60:02d}:{total % 60:02d}"
def shot_count(text: str) -> int:
return len(_SHOT_MARK.findall(text or ""))
def plan_frame_times(duration: float) -> list[int]:
"""均匀采样时间点。取每段的**中点**,避开首尾黑场与片尾卡片。"""
count = max(MIN_FRAMES, min(MAX_FRAMES, math.ceil(duration / SECONDS_PER_FRAME)))
@@ -223,6 +291,60 @@ def _compress_video(path: str) -> bytes | None:
Path(out.name).unlink(missing_ok=True)
def _is_browser_playable(path: str) -> bool:
"""Chrome 播不了微信常见的 HEVC。H.264 + AAC/无音轨才直接存。"""
try:
out = subprocess.run(
[
_binary("ffprobe"), "-v", "error",
"-show_entries", "stream=codec_name,codec_type",
"-of", "json",
str(path),
],
capture_output=True, timeout=_FFMPEG_TIMEOUT, check=True,
)
streams = json.loads(out.stdout).get("streams") or []
except Exception: # noqa: BLE001
return False
video_ok = False
audio_ok = True
for stream in streams:
kind = stream.get("codec_type")
codec = str(stream.get("codec_name") or "").lower()
if kind == "video":
video_ok = codec in {"h264", "vp8", "vp9", "av1"}
elif kind == "audio":
audio_ok = codec in {"aac", "mp3", "opus", "vorbis"}
return video_ok and audio_ok
def _prepare_browser_video(path: str, suffix: str) -> tuple[str, str, bool]:
"""转成浏览器能播的 H.264 AAC。失败退回原片。第三项表示调用方要删临时文件。"""
ext = suffix if str(suffix).startswith(".") else f".{suffix or 'mp4'}"
if _is_browser_playable(path):
return path, ext, False
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", "23",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart", out.name,
],
capture_output=True, timeout=_FFMPEG_TIMEOUT * 3,
)
if done.returncode != 0 or not Path(out.name).is_file() or Path(out.name).stat().st_size <= 0:
Path(out.name).unlink(missing_ok=True)
return path, ext, False
return out.name, ".mp4", True
except Exception: # noqa: BLE001 — 转码失败仍存原片,总比历史卡不能播好
Path(out.name).unlink(missing_ok=True)
return path, ext, False
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:
@@ -233,17 +355,37 @@ def _native_video(path: str, size: int, suffix: str) -> DigestVideo | None:
return None
def digest_input_from_upload(upload) -> tuple[DigestVideo | None, list[VideoFrame], float]:
"""优先整段视频(含音轨);塞不进请求才抽帧。"""
def digest_input_from_upload(
upload,
*,
keep_source: bool = False,
) -> tuple[DigestVideo | None, list[VideoFrame], float, dict]:
"""优先整段视频(含音轨);塞不进请求才抽帧。顺带抽出历史卡要用的封面/宽高。
keep_source=True 时不删临时文件,调用方上传原片后再删。
"""
path = ""
keep = False
original_name = Path(getattr(upload, "name", "") or "参考视频.mp4").name or "参考视频.mp4"
try:
path, suffix, size, duration = _materialize_upload(upload)
width, height = probe_video_size(path)
cover_jpeg = extract_cover_jpeg(path, duration)
video = _native_video(path, size, suffix)
if video is not None:
return video, [], duration
return None, extract_frames(path, plan_frame_times(duration)), duration
frames = [] if video is not None else extract_frames(path, plan_frame_times(duration))
extras = {
"file_name": original_name,
"file_size": size,
"width": width,
"height": height,
"cover_jpeg": cover_jpeg,
"suffix": suffix,
"source_path": path if keep_source else "",
}
keep = keep_source
return video, frames, duration, extras
finally:
if path:
if path and not keep:
Path(path).unlink(missing_ok=True)
@@ -267,20 +409,28 @@ def build_digest_messages(
*,
product_hint: str = "",
video: DigestVideo | None = None,
aspect_ratio: str = "",
file_title: str = "",
) -> list[dict]:
"""system = 拆解 skill;user = 完整视频(优先)或抽帧。"""
frames = frames or []
measured = f"实测时长约 {round(duration)}"
if aspect_ratio:
measured += f",画面比例 {aspect_ratio}"
title_hint = f"文件名可参考片名:《{file_title}》。" if file_title else ""
if video is not None:
head = [
f"这是一条时长约 {round(duration)} 秒的电商带货短视频**完整文件**(含画面和口播音轨)。",
"请按技能还原全片分镜:从 0 写到片尾,有几镜写几镜。",
"解说词按听到的口播逐字写;画面上的花字一并写进画面",
f"这是一条{measured}短视频**完整文件**(含画面和口播音轨)。{title_hint}",
"请按技能还原全片导演分镜稿:从 00:00 写到片尾,有几镜写几镜。",
"每镜必须写齐时间、时长、景别、机位、运镜、画面、人物动作、人物表情、台词/旁白、音效、背景音乐、字幕、备注",
"台词/旁白按听到的口播逐字写;画面上的花字写进字幕。",
]
else:
head = [
f"这是一条时长约 {round(duration)} 秒的电商带货短视频,",
f"按时间顺序均匀抽了 {len(frames)} 帧。每帧图前面标了它在原片中的时间点。",
"请按技能还原**全片**分镜:从 0 写到片尾,有几镜写几镜。",
f"这是一条{measured}短视频,",
f"按时间顺序均匀抽了 {len(frames)} 帧。每帧图前面标了它在原片中的时间点。{title_hint}",
"请按技能还原**全片**导演分镜稿:从 00:00 写到片尾,有几镜写几镜。",
"每镜必须写齐时间、时长、景别、机位、运镜、画面、人物动作、人物表情、台词/旁白、音效、背景音乐、字幕、备注。",
]
if product_hint:
head.append(f"用户接下来想用这条片子的结构去拍自己的商品:{product_hint}")
@@ -429,6 +579,127 @@ def digest_team_video(*, team, user, upload, model_config_id=None) -> dict:
)
def _store_digest_cover(*, team, jpeg: bytes) -> tuple[str, str]:
"""封面传到 TOS。失败返回空,历史卡走占位底。"""
from io import BytesIO
from apps.assets.storage import TosStorage
if not jpeg:
return "", ""
key = f"teams/{team.id}/video-digest/{uuid.uuid4()}.jpg"
storage = TosStorage()
stored = storage.upload_fileobj(fileobj=BytesIO(jpeg), object_key=key, content_type="image/jpeg")
return stored.object_key, storage.public_url(object_key=stored.object_key)
def _store_digest_video(*, team, path: str, suffix: str) -> tuple[str, str]:
"""原片(优先转成 H.264)传到 TOS,历史封面点击才能播。失败返回空,不挡拆解。"""
from apps.assets.storage import TosStorage
if not path or not Path(path).is_file():
return "", ""
upload_path, ext, ephemeral = _prepare_browser_video(path, suffix)
try:
if ext.lower() not in ALLOWED_SUFFIXES:
ext = ".mp4"
mime = _SUFFIX_MIME.get(ext.lower(), "video/mp4")
key = f"teams/{team.id}/video-digest/{uuid.uuid4()}{ext.lower()}"
storage = TosStorage()
with Path(upload_path).open("rb") as fileobj:
stored = storage.upload_fileobj(fileobj=fileobj, object_key=key, content_type=mime)
return stored.object_key, storage.public_url(object_key=stored.object_key)
finally:
if ephemeral:
Path(upload_path).unlink(missing_ok=True)
def _media_url_from_payload(req: dict, url_key: str, object_key_name: str, *, signed: bool = False) -> str:
stored = str(req.get(url_key) or "").strip()
key = str(req.get(object_key_name) or "").strip()
if not key:
return stored
try:
from apps.assets.storage import TosStorage
storage = TosStorage()
if signed:
return storage.presigned_get_url(object_key=key, expires_in=6 * 3600)
return storage.public_url(object_key=key)
except Exception: # noqa: BLE001
return stored
def serialize_digest_history(task) -> dict:
from django.utils import timezone
from apps.ai.models import AITask
req = task.request_payload or {}
resp = task.response_payload or {}
prompt = str(resp.get("prompt") or resp.get("digest") or "").strip()
duration = float(req.get("duration_seconds") or 0)
file_name = str(req.get("file_name") or "") or "参考视频.mp4"
title = str(req.get("title") or "").strip() or title_from_filename(file_name)
shots = int(req.get("shot_count") or 0) or shot_count(prompt)
created = timezone.localtime(task.created_at) if task.created_at else timezone.now()
return {
"id": str(task.id),
"title": title,
"status": "已完成" if task.status == AITask.Status.SUCCEEDED else "失败",
"duration": int(round(duration)),
"duration_label": duration_clock(duration),
"ratio": str(req.get("ratio") or ""),
"shots": shots,
"file_name": file_name,
"cover_url": _media_url_from_payload(req, "cover_url", "cover_key"),
"video_url": _media_url_from_payload(req, "video_url", "video_key", signed=True),
"prompt": prompt,
"created_at": task.created_at.isoformat() if task.created_at else "",
"created_date": created.strftime("%Y-%m-%d"),
}
def list_team_digest_history(*, team, limit: int = 50) -> list[dict]:
from apps.ai.models import AITask
qs = (
AITask.objects.filter(
team=team,
task_type=AITask.Type.VIDEO_DIGEST,
project__isnull=True,
status=AITask.Status.SUCCEEDED,
is_deleted=False,
purged_at__isnull=True,
)
.order_by("-created_at")[: max(1, min(int(limit), 50))]
)
return [serialize_digest_history(task) for task in qs]
def save_digest_prompt(*, team, task_id, prompt: str) -> dict | None:
from apps.ai.models import AITask
cleaned = (prompt or "").strip()
if not cleaned:
raise VideoDigestError("提示词不能为空")
task = AITask.objects.filter(
id=task_id,
team=team,
task_type=AITask.Type.VIDEO_DIGEST,
project__isnull=True,
is_deleted=False,
purged_at__isnull=True,
).first()
if task is None:
return None
payload = dict(task.response_payload or {})
payload["prompt"] = cleaned[:32000]
task.response_payload = payload
task.save(update_fields=["response_payload", "updated_at"])
return serialize_digest_history(task)
def _digest_video(*, team, user, upload, project=None, product_hint="", model_config_id=None) -> dict:
"""上传视频 → 分镜稿。抽帧在建任务之前做,文件不合格不占积分。"""
from django.db import transaction
@@ -439,10 +710,20 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
from apps.billing.pricing import quote_video_digest
from apps.billing.services.ledger import charge_reserved_credit, reserve_credit
video, frames, duration = digest_input_from_upload(upload)
video, frames, duration, extras = digest_input_from_upload(
upload, keep_source=(project is None)
)
extras = extras or {}
source_path = str(extras.get("source_path") or "")
file_name = str(extras.get("file_name") or getattr(upload, "name", "") or "参考视频.mp4")
width = int(extras.get("width") or 0)
height = int(extras.get("height") or 0)
file_size = int(extras.get("file_size") or getattr(upload, "size", 0) or 0)
model_config = resolve_digest_model_config(preferred_id=model_config_id)
if model_config is None:
if source_path:
Path(source_path).unlink(missing_ok=True)
raise VideoDigestError("视频提炼需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员")
logger.info(
"video digest using %s:%s (%s) input=%s duration=%.1fs bytes=%s frames=%s",
@@ -456,7 +737,12 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
)
messages = build_digest_messages(
frames, duration, product_hint=product_hint, video=video
frames,
duration,
product_hint=product_hint,
video=video,
aspect_ratio=ratio_label(width, height),
file_title=title_from_filename(file_name),
)
request_payload = {
"model": model_config.name,
@@ -467,6 +753,12 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
"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],
"file_name": file_name,
"title": title_from_filename(file_name),
"file_size": file_size,
"width": width,
"height": height,
"ratio": ratio_label(width, height),
}
quote = quote_video_digest(team=team, model_config=model_config)
@@ -502,6 +794,8 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
task.status = AITask.Status.RESERVED
task.save(update_fields=["status", "updated_at"])
except ValueError as exc:
if source_path:
Path(source_path).unlink(missing_ok=True)
if "insufficient credit" in str(exc).lower():
raise VideoDigestError("团队余额不足,请充值后重试") from exc
raise VideoDigestError(str(exc)) from exc
@@ -532,15 +826,56 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
)
_text, _response, digest = routed.value
except Exception as exc: # noqa: BLE001
if source_path:
Path(source_path).unlink(missing_ok=True)
_fail_digest_task(task, reservation, str(exc))
raise
cover_key, cover_url, video_key, video_url = "", "", "", ""
if project is None:
try:
cover_key, cover_url = _store_digest_cover(team=team, jpeg=extras.get("cover_jpeg") or b"")
except Exception: # noqa: BLE001 — 封面失败不挡拆解结果
logger.warning("video digest cover upload failed", exc_info=True)
try:
video_key, video_url = _store_digest_video(
team=team,
path=source_path,
suffix=str(extras.get("suffix") or ".mp4"),
)
except Exception: # noqa: BLE001 — 原片失败仍可看提示词,封面不能播
logger.warning("video digest source upload failed", exc_info=True)
if source_path:
Path(source_path).unlink(missing_ok=True)
source_path = ""
shots = shot_count(digest)
request_payload = {
**(task.request_payload or {}),
**request_payload,
"shot_count": shots,
"cover_key": cover_key,
"cover_url": cover_url,
"video_key": video_key,
"video_url": video_url,
}
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = {"digest": digest[:32000]}
task.request_payload = request_payload
task.response_payload = {"digest": digest[:32000], "prompt": 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"])
task.save(
update_fields=[
"status",
"request_payload",
"response_payload",
"actual_cost",
"completed_at",
"updated_at",
]
)
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
return {
@@ -551,6 +886,14 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
"duration": round(duration, 1),
"task_id": str(task.id),
"estimated_cost": str(task.estimated_cost),
"title": request_payload.get("title") or title_from_filename(file_name),
"file_name": file_name,
"ratio": request_payload.get("ratio") or "",
"shots": shots,
"cover_url": cover_url,
"video_url": video_url,
"width": width,
"height": height,
}