"""上传视频提炼 —— 参考视频 → 可人工逐镜编辑的中文分镜稿。 优先把**完整视频(含音轨)**内联给 Gemini 3.1 Pro,跟官网「直接上传视频」同一条能力: 模型能看全片、听口播,不会只拿到稀疏静帧。文件太大塞不进请求时,才退回 ffmpeg 抽帧。 拆视频必须会看视频/图。默认语言模型现在可能是纯文本豆包,不能用 get_default_model(TEXT)。 固定钉 Gemini 3.1 Pro 官转(``gemini-3.1-pro-preview``,展示名带「官转」优先)。 """ from __future__ import annotations import base64 import json import logging import math import re import shutil import subprocess import tempfile import uuid from dataclasses import dataclass from datetime import timedelta from functools import lru_cache from pathlib import Path logger = logging.getLogger(__name__) from django.conf import settings # 上传限制:超了直接 400,不进 ffmpeg,也不花模型钱 ALLOWED_SUFFIXES = (".mp4", ".mov", ".m4v", ".webm") MAX_UPLOAD_BYTES = 200 * 1024 * 1024 # 200 MB # 30 秒。复刻走 Seedance 2.5,单次出片上限就是 30 秒 —— 参考片再长也用不上, # 而且更长的帧采样密度不够,拆出来也是错的。半秒容差同 media_probe 的老规矩。 MAX_DURATION_SECONDS = 30.5 # 官网直接传视频的上限大约是请求 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 = 12288 # 视频复刻内联拆解的同模型重试次数(Gemini 偶尔吐废稿;复刻失败要用户从头重选素材,值得多试一次) DIGEST_MAX_ATTEMPTS = 2 _SHOT_MARK = re.compile(r"【(?:镜头\s*\d+|第\s*\d+\s*镜)】") _FFMPEG_TIMEOUT = 60 class VideoDigestError(ValueError): """用户可见的失败(文件不合格 / ffmpeg 读不动),一律 400。""" class VideoDigestInProgress(VideoDigestError): """本团队已有提炼在跑。视图转 409,并把在跑的那条带回去让前端接上。""" def __init__(self, job: dict): super().__init__("已有一个视频正在提炼中,完成或取消后才能再提交") self.job = job @dataclass(frozen=True) class VideoFrame: at_seconds: int jpeg: bytes def as_data_url(self) -> str: 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 加载 # --------------------------------------------------------------------------- # def _skill_dir() -> Path: """与 script_agent._skill_dir 同源:优先 BASE_DIR/skills(镜像内),回落仓库根(本地旧布局)。""" base = Path(settings.BASE_DIR) for cand in (base / "skills", base.parent.parent / "skills"): if (cand / "video-shot-digest").is_dir(): return cand / "video-shot-digest" return base / "skills" / "video-shot-digest" @lru_cache(maxsize=1) def load_digest_skill() -> str: main = _skill_dir() / "SKILL.md" if main.exists(): return main.read_text(encoding="utf-8") # 兜底:skill 丢了也别整条链路挂掉,退化成一句话提示词(产出会明显变差,交接文档已注明须带 skills 目录) return ( "你是分镜拆解 agent。输入是一条短视频的完整文件,含画面和音轨。" "必须覆盖全片,从 00:00 写到片尾,有几镜写几镜,不要概括成几大段。" "每镜按【镜头 01】写出时间、时长、景别、机位、运镜、画面、人物动作、人物表情、" "人声方式、台词/旁白、音效、背景音乐、字幕、备注。人声方式只能写画内人物对白、画外旁白或无;没有就写无。输出中文纯文本。" ) # --------------------------------------------------------------------------- # # ffmpeg:探时长 + 抽帧 # --------------------------------------------------------------------------- # def _binary(name: str) -> str: found = shutil.which(name) if not found: raise VideoDigestError("服务器暂时无法解析视频,请稍后再试") return found def probe_duration(path: str | Path) -> float: """ffprobe 读时长(秒)。读不到 = 不是能解的视频。""" try: out = subprocess.run( [ _binary("ffprobe"), "-v", "error", "-print_format", "json", "-show_format", str(path), ], capture_output=True, timeout=_FFMPEG_TIMEOUT, check=True, ).stdout duration = float(json.loads(out)["format"]["duration"]) except VideoDigestError: raise except Exception as exc: # noqa: BLE001 — ffprobe 各种失败对用户是同一件事 raise VideoDigestError("这个视频读不出来,请换一个 mp4 / mov 文件") from exc if duration <= 0: raise VideoDigestError("这个视频读不出来,请换一个 mp4 / mov 文件") 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))) step = duration / count return [int(step * (i + 0.5)) for i in range(count)] def extract_frames(path: str | Path, times: list[int]) -> list[VideoFrame]: """逐时间点抽一帧。``-ss`` 放在 ``-i`` 前走关键帧快速定位,每帧约几十毫秒。""" ffmpeg = _binary("ffmpeg") frames: list[VideoFrame] = [] for at in times: try: done = subprocess.run( [ ffmpeg, "-v", "error", "-ss", str(at), "-i", str(path), "-frames:v", "1", "-vf", f"scale={FRAME_WIDTH}:-2", "-q:v", str(FRAME_QUALITY), "-f", "image2", "-", ], capture_output=True, timeout=_FFMPEG_TIMEOUT, check=True, ) except Exception: # noqa: BLE001 — 单帧抽失败(定位越界等)跳过,别拖垮整次提炼 continue if done.stdout: frames.append(VideoFrame(at_seconds=at, jpeg=done.stdout)) if not frames: raise VideoDigestError("没能从这个视频里取到画面,请换一个文件") return frames 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" tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) try: for chunk in upload.chunks(): tmp.write(chunk) tmp.flush() 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"视频不能超过 {int(MAX_DURATION_SECONDS)} 秒,请剪出要参考的那一段再传" ) 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 _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: 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, *, 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) 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 and not keep: 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] | None = None, duration: float = 0, *, 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"这是一条{measured}的短视频**完整文件**(含画面和口播音轨)。{title_hint}", "请按技能还原全片导演分镜稿:从 00:00 写到片尾,有几镜写几镜。", "每镜必须写齐时间、时长、景别、机位、运镜、画面、人物动作、人物表情、人声方式、台词/旁白、音效、背景音乐、字幕、备注。", "人声方式必须区分画内人物对白、画外旁白或无;台词/旁白按听到的口播逐字写,画面中人物开口时必须标画内人物对白;画面上的花字写进字幕。", ] else: head = [ f"这是一条{measured}的短视频,", f"按时间顺序均匀抽了 {len(frames)} 帧。每帧图前面标了它在原片中的时间点。{title_hint}", "请按技能还原**全片**导演分镜稿:从 00:00 写到片尾,有几镜写几镜。", "每镜必须写齐时间、时长、景别、机位、运镜、画面、人物动作、人物表情、人声方式、台词/旁白、音效、背景音乐、字幕、备注。", ] if product_hint: head.append(f"用户接下来想用这条片子的结构去拍自己的商品:{product_hint}。") content: list[dict] = [{"type": "text", "text": "".join(head)}] 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 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 # 拆视频必须会看图。火山豆包直连读不了这组帧图,会在 ARK 上挂满 120s。 # 只认中转站的 Gemini 3.1 Pro;展示名带「官转」的优先。 DIGEST_VISION_MODEL_NAME = "gemini-3.1-pro-preview" def _is_digest_vision_model(model) -> bool: from apps.ai.services import OFFICIAL_DIRECT_PROVIDERS provider_name = getattr(getattr(model, "provider", None), "name", "") or "" if provider_name in OFFICIAL_DIRECT_PROVIDERS: return False blob = f"{model.name} {model.display_name}".lower() return ( model.name == DIGEST_VISION_MODEL_NAME or "gemini-3.1" in blob or "gemini 3.1" in blob ) def resolve_digest_model_config(preferred_id=None): """视频提炼用的多模态文本模型:Gemini 3.1 Pro 官转。找不到不回落默认语言模型。 前端可传 model_config_id(跟生成脚本同一套下拉)。只有会看图的 Gemini 3.1 才认, 选了豆包等纯文本模型仍钉回官转,避免拆帧直接失败。 """ from apps.ai.models import ModelConfig qs = ( ModelConfig.objects.select_related("provider") .filter( capability=ModelConfig.Capability.TEXT, status=ModelConfig.Status.ACTIVE, provider__status="active", ) ) if preferred_id: chosen = qs.filter(pk=preferred_id).first() if chosen is not None and _is_digest_vision_model(chosen): return chosen def _blob(model) -> str: return " ".join( filter( None, [ model.name, model.display_name, getattr(model.provider, "name", ""), getattr(model.provider, "display_name", ""), ], ) ) ranked = [] for model in qs: if not _is_digest_vision_model(model): continue blob = _blob(model) # 官转 > 精确模型名 > 其它 Gemini 3.1 score = 0 if "官转" in blob: score += 100 if model.name == DIGEST_VISION_MODEL_NAME: score += 20 if getattr(model.provider, "name", "") == "yunqi_gemini": score += 5 ranked.append((score, model.created_at, model)) if not ranked: return None ranked.sort(key=lambda item: (-item[0], item[1])) return ranked[0][2] # --------------------------------------------------------------------------- # # 入口:一次真实的计费调用 # --------------------------------------------------------------------------- # def digest_project_video(*, project, user, upload, model_config_id=None) -> dict: """上传视频 → 分镜稿。抽帧在建任务之前做,文件不合格不占积分。""" product = getattr(project, "product", None) return _digest_video( team=project.team, user=user, upload=upload, project=project, product_hint=" · ".join( filter(None, [getattr(product, "title", ""), getattr(product, "category", "")]) ), model_config_id=model_config_id, ) def digest_team_video(*, team, user, upload, model_config_id=None) -> dict: """视频提炼页:不绑项目,计费挂当前团队。""" return _digest_video( team=team, user=user, upload=upload, project=None, product_hint="", model_config_id=model_config_id, ) def _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) class _FileFromPath: def __init__(self, path: str, name: str): self.name = name self.size = Path(path).stat().st_size self._path = path def chunks(self, chunk_size=1024 * 1024): with Path(self._path).open("rb") as handle: while True: data = handle.read(chunk_size) if not data: break yield data def _store_raw_source(*, team, path: str, suffix: str) -> tuple[str, str]: ext = suffix if str(suffix).startswith(".") else f".{suffix or 'mp4'}" mime = _SUFFIX_MIME.get(ext.lower(), "video/mp4") key = f"teams/{team.id}/video-digest/{uuid.uuid4()}{ext.lower()}" from apps.assets.storage import TosStorage storage = TosStorage() with Path(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) def _download_source_to_temp(object_key: str, suffix: str, *, bucket: str = "") -> str: from apps.assets.storage import TosStorage ext = suffix if str(suffix).startswith(".") else f".{suffix or 'mp4'}" storage = TosStorage() body = storage.client.get_object(Bucket=bucket or storage.bucket, Key=object_key)["Body"].read() tmp = tempfile.NamedTemporaryFile(suffix=ext.lower(), delete=False) try: tmp.write(body) tmp.flush() finally: tmp.close() return tmp.name def serialize_digest_job(task) -> dict: from apps.ai.generation_errors import public_error_for_task 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() inflight = task.status in { AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED, AITask.Status.POLLING, AITask.Status.POSTPROCESSING, } if inflight: job_status = "processing" elif task.status == AITask.Status.SUCCEEDED: job_status = "succeeded" elif task.status == AITask.Status.CANCELLED: job_status = "cancelled" else: job_status = "failed" duration = float(req.get("duration_seconds") or 0) file_name = str(req.get("file_name") or "") or "参考视频.mp4" public_error = public_error_for_task(task, operation="video_digest") if job_status == "failed" else None video_url = _media_url_from_payload(req, "video_url", "video_key", signed=True) or _media_url_from_payload( req, "source_url", "source_key", signed=True ) return { "id": str(task.id), "task_id": str(task.id), "status": job_status, "text": prompt, "prompt": prompt, "chars": len(prompt), "duration": round(duration, 1) if duration else 0, "shots": int(req.get("shot_count") or 0) or shot_count(prompt), "file_name": file_name, "title": str(req.get("title") or "").strip() or title_from_filename(file_name), "ratio": str(req.get("ratio") or ""), "width": int(req.get("width") or 0), "height": int(req.get("height") or 0), "cover_url": _media_url_from_payload(req, "cover_url", "cover_key"), "video_url": video_url, "estimated_cost": str(task.estimated_cost), "error_message": (public_error.fallback_message if public_error else "") or str(task.error_message or ""), } def get_team_digest_job(*, team, task_id) -> dict | None: from apps.ai.models import AITask 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 return serialize_digest_job(task) _STALE_DIGEST = timedelta(minutes=15) def _task_reservation(task): try: return task.credit_reservation except Exception: # noqa: BLE001 — 无预留行 return None def cancel_team_digest(*, team, task_id, reason: str = "用户取消") -> dict | None: """取消进行中的提炼:停 UI 轮询、退预留积分。worker 跑到一半会看见 CANCELLED 不再落成功。""" from django.db import transaction from django.utils import timezone from apps.ai.models import AITask from apps.billing.services.ledger import release_credit with transaction.atomic(): task = ( AITask.objects.select_for_update() .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 if task.status == AITask.Status.SUCCEEDED: return serialize_digest_job(task) if task.status in {AITask.Status.FAILED, AITask.Status.CANCELLED}: return serialize_digest_job(task) reservation = _task_reservation(task) task.status = AITask.Status.CANCELLED task.error_message = (reason or "用户取消")[:2000] task.completed_at = timezone.now() task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) if reservation is not None: try: release_credit(reservation=reservation, reason=(reason or "用户取消")[:200]) except Exception: # noqa: BLE001 logger.warning("cancel digest release_credit failed for %s", task_id, exc_info=True) return serialize_digest_job(task) def expire_stale_team_digests(*, team) -> None: """卡住超过 15 分钟的提炼自动取消并退费,避免进页永远「正在拆解」。""" from django.utils import timezone from apps.ai.models import AITask stale = AITask.objects.filter( team=team, task_type=AITask.Type.VIDEO_DIGEST, project__isnull=True, is_deleted=False, purged_at__isnull=True, status__in={ AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED, AITask.Status.POLLING, AITask.Status.POSTPROCESSING, }, created_at__lt=timezone.now() - _STALE_DIGEST, ) for task in stale: cancel_team_digest(team=team, task_id=task.id, reason="提炼超时已自动取消") def get_inflight_team_digest(*, team) -> dict | None: from apps.ai.models import AITask task = ( AITask.objects.filter( team=team, task_type=AITask.Type.VIDEO_DIGEST, project__isnull=True, is_deleted=False, purged_at__isnull=True, status__in={ AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED, AITask.Status.POLLING, AITask.Status.POSTPROCESSING, }, ) .order_by("-created_at") .first() ) if task is None: return None return serialize_digest_job(task) def submit_team_digest(*, team, user, upload=None, reuse_task_id=None, model_config_id=None) -> dict: """秒回任务 id。慢活(抽帧 + Gemini)交给 worker,离开页面也不中断。 取消后再点生成时,可带 reuse_task_id 复用 TOS 上已有的参考视频,不用重新上传。 """ from django.db import transaction from apps.ai.models import AITask from apps.ai.tasks import run_video_digest_task from apps.billing.pricing import quote_video_digest from apps.billing.services.ledger import reserve_credit # 单飞闸:一个团队同时只允许一条提炼在跑。 # 刷新页面时前端要异步拉状态,这个空档用户很容易以为「没任务」再传一次 —— 前端加载态 # 只是治标,真正兜底在这里。先跑过期回收,死任务不会把人永久锁住。 expire_stale_team_digests(team=team) running = get_inflight_team_digest(team=team) if running: raise VideoDigestInProgress(running) model_config = resolve_digest_model_config(preferred_id=model_config_id) if model_config is None: raise VideoDigestError("视频提炼需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员") if upload is not None: path, suffix, size, duration = _materialize_upload(upload) file_name = Path(getattr(upload, "name", "") or "参考视频.mp4").name or "参考视频.mp4" width = height = 0 source_key = source_url = cover_key = cover_url = "" try: width, height = probe_video_size(path) cover_key, cover_url = _store_digest_cover(team=team, jpeg=extract_cover_jpeg(path, duration)) source_key, source_url = _store_raw_source(team=team, path=path, suffix=suffix) finally: Path(path).unlink(missing_ok=True) if not source_key: raise VideoDigestError("参考视频上传失败,请重试") request_payload = { "model": model_config.name, "endpoint": model_config.endpoint, "feature": "video_remix", "duration_seconds": round(duration, 2), "file_name": file_name, "title": title_from_filename(file_name), "file_size": size, "width": width, "height": height, "ratio": ratio_label(width, height), "suffix": suffix, "source_key": source_key, "source_url": source_url, "cover_key": cover_key, "cover_url": cover_url, } else: request_payload = _payload_from_existing_source( team=team, reuse_task_id=reuse_task_id, model_config=model_config, ) 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"]} try: with transaction.atomic(): task = AITask.objects.create( team=team, created_by=user, project=None, task_type=AITask.Type.VIDEO_DIGEST, status=AITask.Status.CREATED, model_config=model_config, idempotency_key=f"video_digest:{team.id}:{uuid.uuid4()}", request_payload=request_payload, estimated_cost=quote.points, base_cost=quote.base_cost_yuan, ) reserve_credit(team=team, user=user, task=task, amount=quote.points) task.status = AITask.Status.RESERVED task.save(update_fields=["status", "updated_at"]) except ValueError as exc: if "insufficient credit" in str(exc).lower(): raise VideoDigestError("团队余额不足,请充值后重试") from exc raise VideoDigestError(str(exc)) from exc run_video_digest_task.delay(str(task.id)) return serialize_digest_job(task) def _payload_from_existing_source(*, team, reuse_task_id, model_config) -> dict: from django.core.exceptions import ValidationError from apps.ai.models import AITask task_id = str(reuse_task_id or "").strip() if not task_id: raise VideoDigestError("请先上传参考视频") try: previous = 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() except (ValueError, ValidationError, TypeError): raise VideoDigestError("请先上传参考视频") from None if previous is None: raise VideoDigestError("请先上传参考视频") req = previous.request_payload or {} source_key = str(req.get("source_key") or "").strip() if not source_key: raise VideoDigestError("参考视频已失效,请重新上传") file_name = str(req.get("file_name") or "") or "参考视频.mp4" return { "model": model_config.name, "endpoint": model_config.endpoint, "feature": "video_remix", "duration_seconds": float(req.get("duration_seconds") or 0), "file_name": file_name, "title": str(req.get("title") or "").strip() or title_from_filename(file_name), "file_size": int(req.get("file_size") or 0), "width": int(req.get("width") or 0), "height": int(req.get("height") or 0), "ratio": str(req.get("ratio") or ""), "suffix": str(req.get("suffix") or ".mp4"), "source_key": source_key, "source_url": str(req.get("source_url") or ""), "cover_key": str(req.get("cover_key") or ""), "cover_url": str(req.get("cover_url") or ""), "video_key": str(req.get("video_key") or ""), "video_url": str(req.get("video_url") or ""), } def run_team_digest_task(*, task_id: str) -> None: """Worker:从 TOS 取参考视频,跑抽帧 + Gemini,失败退费。""" from django.db import transaction from django.utils import timezone from apps.ai.models import AITask task = AITask.objects.select_related("team", "created_by", "model_config", "credit_reservation").filter( id=task_id, task_type=AITask.Type.VIDEO_DIGEST, project__isnull=True, ).first() if task is None: return if task.status == AITask.Status.SUCCEEDED: return if task.status not in {AITask.Status.RESERVED, AITask.Status.SUBMITTED}: return with transaction.atomic(): locked = AITask.objects.select_for_update().get(id=task.id) if locked.status == AITask.Status.SUCCEEDED: return if locked.status not in {AITask.Status.RESERVED, AITask.Status.SUBMITTED}: return locked.status = AITask.Status.SUBMITTED locked.submitted_at = locked.submitted_at or timezone.now() locked.save(update_fields=["status", "submitted_at", "updated_at"]) task = locked req = task.request_payload or {} source_key = str(req.get("source_key") or "") suffix = str(req.get("suffix") or ".mp4") file_name = str(req.get("file_name") or "参考视频.mp4") if not source_key: reservation = getattr(task, "credit_reservation", None) _fail_digest_task(task, reservation, "参考视频丢失,请重新上传") return local_path = "" try: local_path = _download_source_to_temp(source_key, suffix) upload = _FileFromPath(local_path, file_name) _digest_video( team=task.team, user=task.created_by, upload=upload, project=None, product_hint="", model_config_id=str(task.model_config_id) if task.model_config_id else None, existing_task=task, ) except Exception as exc: # noqa: BLE001 logger.exception("async video digest failed for %s", task.id) task.refresh_from_db() if task.status not in {AITask.Status.SUCCEEDED, AITask.Status.FAILED}: reservation = getattr(task, "credit_reservation", None) _fail_digest_task(task, reservation, str(exc)) finally: if local_path: Path(local_path).unlink(missing_ok=True) def _digest_video(*, team, user, upload, project=None, product_hint="", model_config_id=None, existing_task=None) -> dict: """上传视频 → 分镜稿。抽帧在建任务之前做,文件不合格不占积分。""" from django.db import transaction from django.utils import timezone from apps.ai.models import AITask from apps.ai.services import create_ai_task, execute_routed_text_request from apps.billing.pricing import quote_video_digest from apps.billing.services.ledger import charge_reserved_credit, reserve_credit 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 = ( existing_task.model_config if existing_task is not None and existing_task.model_config_id else 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", 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, video=video, aspect_ratio=ratio_label(width, height), file_title=title_from_filename(file_name), ) 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], "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) if quote.meta.get("rate"): request_payload = {**request_payload, "points_per_yuan_snapshot": quote.meta["rate"]} if existing_task is not None: task = existing_task existing_req = dict(task.request_payload or {}) request_payload = {**existing_req, **request_payload} task.request_payload = request_payload task.save(update_fields=["request_payload", "updated_at"]) elif project is not None: task = create_ai_task( project=project, user=user, task_type=AITask.Type.VIDEO_DIGEST, model_config=model_config, # 帧是几百 KB base64,绝不进 request_payload(会把 AITask 表撑爆),只记形状 request_payload=request_payload, quote=quote, ) else: try: with transaction.atomic(): task = AITask.objects.create( team=team, created_by=user, project=None, task_type=AITask.Type.VIDEO_DIGEST, status=AITask.Status.CREATED, model_config=model_config, idempotency_key=f"video_digest:{team.id}:{uuid.uuid4()}", request_payload=request_payload, estimated_cost=quote.points, base_cost=quote.base_cost_yuan, ) reserve_credit(team=team, user=user, task=task, amount=quote.points) task.status = AITask.Status.RESERVED task.save(update_fields=["status", "updated_at"]) except ValueError as exc: if 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 reservation = task.credit_reservation try: task.refresh_from_db() if task.status == AITask.Status.CANCELLED: if source_path: Path(source_path).unlink(missing_ok=True) return serialize_digest_job(task) task.status = AITask.Status.SUBMITTED task.submitted_at = timezone.now() task.save(update_fields=["status", "submitted_at", "updated_at"]) routed = execute_routed_text_request( task=task, primary_model=model_config, messages=messages, streaming=True, structured_output=False, business_operation="video_digest", temperature=0.4, 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, ) _text, _response, digest = routed.value except Exception as exc: # noqa: BLE001 if source_path: Path(source_path).unlink(missing_ok=True) task.refresh_from_db() if task.status == AITask.Status.CANCELLED: return serialize_digest_job(task) _fail_digest_task(task, reservation, str(exc)) raise existing_req = dict(task.request_payload or {}) cover_key = str(existing_req.get("cover_key") or "") cover_url = str(existing_req.get("cover_url") or "") video_key = str(existing_req.get("video_key") or "") video_url = str(existing_req.get("video_url") or "") if project is None: if not cover_key: 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: stored_key, stored_url = _store_digest_video( team=team, path=source_path, suffix=str(extras.get("suffix") or ".mp4"), ) if stored_key: video_key, video_url = stored_key, stored_url 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 = { **existing_req, **request_payload, "shot_count": shots, "cover_key": cover_key or existing_req.get("cover_key") or "", "cover_url": cover_url or existing_req.get("cover_url") or "", "video_key": video_key or existing_req.get("video_key") or "", "video_url": video_url or existing_req.get("video_url") or "", } with transaction.atomic(): locked = AITask.objects.select_for_update().get(id=task.id) if locked.status == AITask.Status.CANCELLED: return serialize_digest_job(locked) task = locked task.status = AITask.Status.SUCCEEDED 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", "request_payload", "response_payload", "actual_cost", "completed_at", "updated_at", ] ) charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) return { "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), "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, } def _fail_digest_task(task, reservation, message: str) -> None: from django.utils import timezone from apps.ai.models import AITask from apps.billing.services.ledger import release_credit try: task.status = AITask.Status.FAILED task.error_message = message[:2000] task.completed_at = timezone.now() task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) finally: try: release_credit(reservation=reservation, reason=message[:200]) except Exception: # noqa: BLE001 pass # --------------------------------------------------------------------------- # # 复用入口:视频复刻·商品 拿分镜稿 # --------------------------------------------------------------------------- # def digest_asset_video(*, asset, task, model_config_id=None) -> tuple[str, dict]: """把一条已入库的参考视频拆成中文分镜稿,供「视频复刻·商品」当提示词骨架。 与「提炼提示词」页共用同一份 SKILL.md、同一个 Gemini 3.1 Pro 和同一套质检, 保证两处产出一致;区别只在于这里不另建 VIDEO_DIGEST 任务、也不单独扣积分—— 复刻本来就是一次收费,拆解是它的内部工序。模型调用的审计仍记在传入的复刻 task 上(AIModelAttempt),出问题查得到。 """ import time from apps.ai.models import AIModelAttempt from apps.ai.services import _collect_extract_text, get_text_provider primary = asset.files.filter(is_primary=True).first() or asset.files.first() if primary is None or not primary.object_key: raise VideoDigestError("参考视频没有可用文件,请重新上传") suffix = Path(primary.object_key).suffix.lower() if suffix not in ALLOWED_SUFFIXES: suffix = ".mp4" model_config = resolve_digest_model_config(preferred_id=model_config_id) if model_config is None: raise VideoDigestError("视频复刻需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员") local_path = "" try: local_path = _download_source_to_temp(primary.object_key, suffix, bucket=primary.bucket or "") base_name = (asset.name or "参考视频").rsplit(".", 1)[0] upload = _FileFromPath(local_path, f"{base_name}{suffix}") video, frames, duration, extras = digest_input_from_upload(upload) extras = extras or {} logger.info( "replace digest using %s:%s input=%s duration=%.1fs frames=%s", model_config.provider.name, model_config.name, "native_video" if video is not None else "frames", duration, len(frames), ) # ⚠️ 这里必须和「提炼提示词」页(_digest_video)构造出完全相同的 messages: # 同一份 SKILL.md 作 system、同一段 user 引导语、同样的 temperature / max_tokens。 # 绝对不要在这里追加商品信息(product_hint 保持默认空)——两处提炼稿一旦不同, # 用户在提炼页看到的分镜和复刻实际用的分镜就对不上,排查会乱。 # test_video_replace.test_replace_digest_prompt_matches_standalone 会守住这一点。 messages = build_digest_messages( frames, duration, video=video, aspect_ratio=ratio_label(int(extras.get("width") or 0), int(extras.get("height") or 0)), file_title=title_from_filename(str(extras.get("file_name") or "")), ) # 这里不能走 execute_routed_text_request:它硬性要求 task.model_config 就是本次主模型, # 而复刻任务的 model_config 是 Seedance(视频模型),传进去必抛 # 「AITask.model_config 必须保持为用户选择或系统默认的主模型」。 # 改为直连脚本/提炼同一条流式通道,并自己做「同模型重试」——绝不 fallback 到别的 # 文本模型,它们看不了视频,换过去必然废稿。 provider = get_text_provider(model_config) expected_frames = len(frames) or (24 if video else 0) digest = "" last_error: Exception | None = None for attempt_no in range(1, DIGEST_MAX_ATTEMPTS + 1): # 这一步不走 execute_model_call(它要求 task.model_config 就是本次主模型, # 而复刻任务的主模型是 Seedance),所以尝试记录得自己落 —— 不落的话 # 运营后台的任务详情看不到这次 Gemini 调用,出问题无从查起。 record = _open_digest_attempt(task, model_config, attempt_no, duration, expected_frames, video) started = time.monotonic() try: text, _payload = _collect_extract_text( provider, model_config, messages, temperature=0.4, extra_body={"max_tokens": DIGEST_MAX_TOKENS}, ) digest = validate_digest_text(text, duration=duration, frame_count=expected_frames) _close_digest_attempt( record, AIModelAttempt.Status.SUCCEEDED, started, summary={"chars": len(digest), "shots": shot_count(digest)}, ) break except Exception as exc: # noqa: BLE001 — 空文/废稿/网络抖动都值得再来一次 last_error = exc _close_digest_attempt( record, AIModelAttempt.Status.FAILED, started, error=str(exc), ) logger.warning( "replace digest attempt %s/%s failed for task %s: %s", attempt_no, DIGEST_MAX_ATTEMPTS, task.id, exc, ) if not digest: raise VideoDigestError(f"参考视频拆解失败:{last_error}") meta = { "digest_model": model_config.name, "digest_input": "native_video" if video is not None else "frames", "digest_frames": len(frames), "digest_duration": round(duration, 2), "digest_shots": shot_count(digest), "digest_ratio": ratio_label(int(extras.get("width") or 0), int(extras.get("height") or 0)), } return digest, meta finally: if local_path: Path(local_path).unlink(missing_ok=True) def _open_digest_attempt(task, model_config, attempt_no: int, duration: float, frames: int, video): """给复刻任务补一条「提炼」尝试记录,让运营后台的尝试链能看到这次 Gemini 调用。 审计失败绝不能拖垮拆解本身,所以整段吞异常、返回 None。 """ from django.db import transaction from django.db.models import Max from django.utils import timezone from apps.ai.models import AIModelAttempt, AITask try: with transaction.atomic(): AITask.objects.select_for_update().only("id").get(pk=task.pk) sequence = ( AIModelAttempt.objects.filter(task_id=task.pk).aggregate(m=Max("sequence"))["m"] or 0 ) + 1 provider = model_config.provider return AIModelAttempt.objects.create( task_id=task.pk, sequence=sequence, provider=provider, model_config=model_config, provider_name=provider.name, provider_display_name=provider.display_name, model_name=model_config.name, model_display_name=model_config.display_name, public_model_name=model_config.display_name or model_config.name, capability="text", operation="video_digest", status=AIModelAttempt.Status.STARTED, is_retry=attempt_no > 1, started_at=timezone.now(), request_summary={ "for": "video_replace", "duration_seconds": round(float(duration or 0), 2), "frame_count": frames, "input": "native_video" if video is not None else "frames", }, ) except Exception: # noqa: BLE001 — 审计写失败不影响出片 logger.warning("video replace digest attempt record failed", exc_info=True) return None def _close_digest_attempt(record, status, started_at: float, *, summary: dict | None = None, error: str = ""): import time from django.utils import timezone if record is None: return try: record.status = status record.finished_at = timezone.now() record.duration_ms = max(0, round((time.monotonic() - started_at) * 1000)) if summary: record.response_summary = summary if error: record.error_type = "processing_failed" record.raw_error = error[:2000] record.safe_error_summary = "参考视频拆解未通过校验" record.save(update_fields=[ "status", "finished_at", "duration_ms", "response_summary", "error_type", "raw_error", "safe_error_summary", "updated_at", ]) except Exception: # noqa: BLE001 logger.warning("video replace digest attempt close failed", exc_info=True)