fix(fam-edge): 整视频上传方式修正 - Gemini 改 resumable 可续传协议(370MB 不再 raw 单发超时);NVIDIA 改 Assets API 上传拿 asset_id(video_url 引用),规避 25MB payload 上限
This commit is contained in:
@@ -122,24 +122,52 @@ class GeminiAdapter(BaseModelAdapter):
|
|||||||
self._delete_file(file_uri)
|
self._delete_file(file_uri)
|
||||||
|
|
||||||
def _upload_file(self, video_path: str) -> Optional[str]:
|
def _upload_file(self, video_path: str) -> Optional[str]:
|
||||||
"""用 Files API 上传完整视频,返回可引用 URI。"""
|
"""用 Files API resumable 可续传协议上传完整视频,返回可引用 URI。"""
|
||||||
name = os.path.basename(video_path)
|
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:
|
try:
|
||||||
with open(video_path, 'rb') as f:
|
with open(video_path, 'rb') as f:
|
||||||
data = f.read()
|
resp = requests.post(
|
||||||
except OSError as e:
|
session_url,
|
||||||
logger.error(f"读取视频失败 {video_path}: {e}")
|
headers={
|
||||||
return None
|
"Content-Length": str(size),
|
||||||
headers = {
|
"X-Goog-Upload-Command": "upload, finalize",
|
||||||
"X-Goog-Upload-Protocol": "raw",
|
"X-Goog-Upload-Offset": "0",
|
||||||
"X-Goog-Upload-File-Name": name,
|
|
||||||
"Content-Type": "video/mp4",
|
"Content-Type": "video/mp4",
|
||||||
}
|
},
|
||||||
try:
|
data=f,
|
||||||
resp = requests.post(upload_url, headers=headers, data=data, timeout=300)
|
timeout=upload_timeout,
|
||||||
|
)
|
||||||
except requests.Timeout:
|
except requests.Timeout:
|
||||||
logger.warning("Gemini 文件上传超时 (300s)")
|
logger.warning(f"Gemini 文件上传超时 ({upload_timeout}s)")
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Gemini 文件上传异常: {e}")
|
logger.error(f"Gemini 文件上传异常: {e}")
|
||||||
|
|||||||
@@ -5,12 +5,13 @@ provider_name = "nvidia"
|
|||||||
模型: nvidia/nemotron-nano-12b-v2-vl(NIM 官方支持整视频 video_url 输入,内部自行采样帧)
|
模型: nvidia/nemotron-nano-12b-v2-vl(NIM 官方支持整视频 video_url 输入,内部自行采样帧)
|
||||||
角色: vision (整视频直出结构化 JSON) + 智能问答
|
角色: vision (整视频直出结构化 JSON) + 智能问答
|
||||||
SDK: openai (NIM 兼容 OpenAI API 规范)
|
SDK: openai (NIM 兼容 OpenAI API 规范)
|
||||||
整视频分析: 整视频 base64 经 video_url 单次调用 —— 本地不切片、不抽帧
|
整视频分析: 先经 NVIDIA Assets API 上传完整视频拿 asset_id,再以 video_url 引用单次调用
|
||||||
|
—— 本地不切片、不抽帧(request payload 有 25MB 上限,base64 直塞不可行,必须走 Assets API)
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import base64
|
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import requests
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from .base_adapter import BaseModelAdapter
|
from .base_adapter import BaseModelAdapter
|
||||||
@@ -67,8 +68,59 @@ class NvidiaVisionAdapter(BaseModelAdapter):
|
|||||||
return False
|
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,
|
def analyze_video(self, video_path: str,
|
||||||
known_members_context: str,
|
known_members_context: str,
|
||||||
event_start_time: str = '') -> Optional[Dict]:
|
event_start_time: str = '') -> Optional[Dict]:
|
||||||
@@ -82,11 +134,9 @@ class NvidiaVisionAdapter(BaseModelAdapter):
|
|||||||
logger.warning(f"NVIDIA 视频文件不存在: {video_path}")
|
logger.warning(f"NVIDIA 视频文件不存在: {video_path}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
asset_id = self._upload_asset(video_path)
|
||||||
with open(video_path, 'rb') as f:
|
if not asset_id:
|
||||||
b64 = base64.b64encode(f.read()).decode('utf-8')
|
self._cb.record_failure()
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"NVIDIA 读取视频失败: {e}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
prompt = self._build_video_prompt(known_members_context, event_start_time)
|
prompt = self._build_video_prompt(known_members_context, event_start_time)
|
||||||
@@ -96,7 +146,7 @@ class NvidiaVisionAdapter(BaseModelAdapter):
|
|||||||
messages=[{"role": "user", "content": [
|
messages=[{"role": "user", "content": [
|
||||||
{"type": "text", "text": prompt},
|
{"type": "text", "text": prompt},
|
||||||
{"type": "video_url", "video_url": {
|
{"type": "video_url", "video_url": {
|
||||||
"url": f"data:video/mp4;base64,{b64}"}}
|
"url": f"data:video/mp4;asset_id={asset_id}"}}
|
||||||
]}],
|
]}],
|
||||||
temperature=0.2,
|
temperature=0.2,
|
||||||
max_tokens=4096,
|
max_tokens=4096,
|
||||||
|
|||||||
Reference in New Issue
Block a user