diff --git a/fam-edge/src/fam_edge/model_adapters/gemini_adapter.py b/fam-edge/src/fam_edge/model_adapters/gemini_adapter.py index 49ff714..9efe515 100644 --- a/fam-edge/src/fam_edge/model_adapters/gemini_adapter.py +++ b/fam-edge/src/fam_edge/model_adapters/gemini_adapter.py @@ -122,24 +122,52 @@ class GeminiAdapter(BaseModelAdapter): self._delete_file(file_uri) def _upload_file(self, video_path: str) -> Optional[str]: - """用 Files API 上传完整视频,返回可引用 URI。""" + """用 Files API resumable 可续传协议上传完整视频,返回可引用 URI。""" name = os.path.basename(video_path) - upload_url = f"{self._base_url}/files?key={self.api_key}" + size = os.path.getsize(video_path) + upload_timeout = max(self.timeout, 900) + base = f"{self._base_url}/files?key={self.api_key}" + # 1) 创建可续传上传会话 + try: + r0 = requests.post( + base, + headers={ + "X-Goog-Upload-Protocol": "resumable", + "X-Goog-Upload-Command": "start", + "X-Goog-Upload-Header-Content-Length": str(size), + "X-Goog-Upload-Header-Content-Type": "video/mp4", + "X-Goog-Upload-File-Name": name, + "Content-Type": "application/json", + }, + json={"file": {"display_name": name}}, + timeout=60, + ) + except Exception as e: + logger.error(f"Gemini 创建上传会话异常: {e}") + return None + if r0.status_code not in (200, 201): + logger.warning(f"Gemini 创建上传会话失败 HTTP {r0.status_code}: {r0.text[:200]}") + return None + session_url = r0.headers.get('X-Goog-Upload-URL') + if not session_url: + logger.warning("Gemini 上传响应缺少 X-Goog-Upload-URL") + return None + # 2) 上传文件体(流式) try: with open(video_path, 'rb') as f: - data = f.read() - except OSError as e: - logger.error(f"读取视频失败 {video_path}: {e}") - return None - headers = { - "X-Goog-Upload-Protocol": "raw", - "X-Goog-Upload-File-Name": name, - "Content-Type": "video/mp4", - } - try: - resp = requests.post(upload_url, headers=headers, data=data, timeout=300) + resp = requests.post( + session_url, + headers={ + "Content-Length": str(size), + "X-Goog-Upload-Command": "upload, finalize", + "X-Goog-Upload-Offset": "0", + "Content-Type": "video/mp4", + }, + data=f, + timeout=upload_timeout, + ) except requests.Timeout: - logger.warning("Gemini 文件上传超时 (300s)") + logger.warning(f"Gemini 文件上传超时 ({upload_timeout}s)") return None except Exception as e: logger.error(f"Gemini 文件上传异常: {e}") diff --git a/fam-edge/src/fam_edge/model_adapters/nvidia_adapter.py b/fam-edge/src/fam_edge/model_adapters/nvidia_adapter.py index 6542d30..113d79c 100644 --- a/fam-edge/src/fam_edge/model_adapters/nvidia_adapter.py +++ b/fam-edge/src/fam_edge/model_adapters/nvidia_adapter.py @@ -5,12 +5,13 @@ provider_name = "nvidia" 模型: nvidia/nemotron-nano-12b-v2-vl(NIM 官方支持整视频 video_url 输入,内部自行采样帧) 角色: vision (整视频直出结构化 JSON) + 智能问答 SDK: openai (NIM 兼容 OpenAI API 规范) -整视频分析: 整视频 base64 经 video_url 单次调用 —— 本地不切片、不抽帧 +整视频分析: 先经 NVIDIA Assets API 上传完整视频拿 asset_id,再以 video_url 引用单次调用 + —— 本地不切片、不抽帧(request payload 有 25MB 上限,base64 直塞不可行,必须走 Assets API) """ import os -import base64 import json import re +import requests from typing import Dict, List, Optional from .base_adapter import BaseModelAdapter @@ -67,8 +68,59 @@ class NvidiaVisionAdapter(BaseModelAdapter): return False # ------------------------------------------------------------------ - # 整视频分析:base64 整视频 -> video_url 单次调用 + # 整视频分析:Assets API 上传 -> video_url(asset_id) 单次调用 # ------------------------------------------------------------------ + ASSET_API = "https://api.nvcf.nvidia.com/v2/nvcf/assets" + + def _upload_asset(self, video_path: str) -> Optional[str]: + """用 NVIDIA Assets API 上传大视频文件,返回 asset_id 供 video_url 引用。""" + upload_timeout = max(self.timeout, 900) + try: + r = requests.post( + self.ASSET_API, + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json={"contentType": "video/mp4", "description": "fam-edge video asset"}, + timeout=60, + ) + except Exception as e: + logger.warning(f"NVIDIA 创建 asset 异常: {e}") + return None + if r.status_code not in (200, 201): + logger.warning(f"NVIDIA 创建 asset 失败 HTTP {r.status_code}: {r.text[:200]}") + return None + try: + j = r.json() + asset_id = j.get("assetId") + upload_url = j.get("uploadUrl") + except ValueError: + logger.warning("NVIDIA asset 响应解析失败") + return None + if not asset_id or not upload_url: + logger.warning("NVIDIA asset 响应缺少 assetId/uploadUrl") + return None + try: + with open(video_path, 'rb') as f: + up = requests.put( + upload_url, + data=f, + headers={"Content-Type": "application/octet-stream"}, + timeout=upload_timeout, + ) + except requests.Timeout: + logger.warning(f"NVIDIA 上传 asset 超时 ({upload_timeout}s)") + return None + except Exception as e: + logger.warning(f"NVIDIA 上传 asset 异常: {e}") + return None + if up.status_code not in (200, 201): + logger.warning(f"NVIDIA 上传 asset 失败 HTTP {up.status_code}: {up.text[:200]}") + return None + logger.info(f"NVIDIA asset 上传成功: {asset_id}") + return asset_id + def analyze_video(self, video_path: str, known_members_context: str, event_start_time: str = '') -> Optional[Dict]: @@ -82,11 +134,9 @@ class NvidiaVisionAdapter(BaseModelAdapter): logger.warning(f"NVIDIA 视频文件不存在: {video_path}") return None - try: - with open(video_path, 'rb') as f: - b64 = base64.b64encode(f.read()).decode('utf-8') - except Exception as e: - logger.warning(f"NVIDIA 读取视频失败: {e}") + asset_id = self._upload_asset(video_path) + if not asset_id: + self._cb.record_failure() return None prompt = self._build_video_prompt(known_members_context, event_start_time) @@ -96,7 +146,7 @@ class NvidiaVisionAdapter(BaseModelAdapter): messages=[{"role": "user", "content": [ {"type": "text", "text": prompt}, {"type": "video_url", "video_url": { - "url": f"data:video/mp4;base64,{b64}"}} + "url": f"data:video/mp4;asset_id={asset_id}"}} ]}], temperature=0.2, max_tokens=4096,