|
@@ -22,7 +22,7 @@ from typing import Any, Optional
|
|
|
|
|
|
|
|
import httpx
|
|
import httpx
|
|
|
from dotenv import load_dotenv
|
|
from dotenv import load_dotenv
|
|
|
-from openai import APIStatusError, OpenAI
|
|
|
|
|
|
|
+from openai import APIStatusError, APITimeoutError, OpenAI
|
|
|
|
|
|
|
|
from supply_agent.paths import find_project_root
|
|
from supply_agent.paths import find_project_root
|
|
|
from supply_agent.tools import tool
|
|
from supply_agent.tools import tool
|
|
@@ -39,6 +39,8 @@ DEFAULT_FPS = 2.0
|
|
|
DEFAULT_TIMEOUT = 300.0
|
|
DEFAULT_TIMEOUT = 300.0
|
|
|
DEFAULT_DOWNLOAD_TIMEOUT = 120.0
|
|
DEFAULT_DOWNLOAD_TIMEOUT = 120.0
|
|
|
DEFAULT_MAX_DURATION_SECONDS = 180.0
|
|
DEFAULT_MAX_DURATION_SECONDS = 180.0
|
|
|
|
|
+FFPROBE_TIMEOUT_SECONDS = 30.0
|
|
|
|
|
+FFMPEG_TIMEOUT_SECONDS = 300.0
|
|
|
|
|
|
|
|
_DURATION_RE = re.compile(
|
|
_DURATION_RE = re.compile(
|
|
|
r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)",
|
|
r"Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)",
|
|
@@ -48,6 +50,10 @@ _DURATION_RE = re.compile(
|
|
|
_env_loaded = False
|
|
_env_loaded = False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+class ToolTimeoutError(RuntimeError):
|
|
|
|
|
+ """A bounded local tool operation exceeded its deadline."""
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
def _ensure_env_loaded() -> None:
|
|
def _ensure_env_loaded() -> None:
|
|
|
"""从项目根目录加载 .env,使 os.getenv 能读到其中的变量。"""
|
|
"""从项目根目录加载 .env,使 os.getenv 能读到其中的变量。"""
|
|
|
global _env_loaded
|
|
global _env_loaded
|
|
@@ -109,35 +115,53 @@ def _parse_duration_text(text: str) -> float | None:
|
|
|
def _probe_duration(ffmpeg: str, target: str) -> float | None:
|
|
def _probe_duration(ffmpeg: str, target: str) -> float | None:
|
|
|
ffprobe = _resolve_ffprobe()
|
|
ffprobe = _resolve_ffprobe()
|
|
|
if ffprobe:
|
|
if ffprobe:
|
|
|
- result = subprocess.run(
|
|
|
|
|
- [
|
|
|
|
|
- ffprobe,
|
|
|
|
|
- "-v",
|
|
|
|
|
- "error",
|
|
|
|
|
- "-show_entries",
|
|
|
|
|
- "format=duration",
|
|
|
|
|
- "-of",
|
|
|
|
|
- "default=noprint_wrappers=1:nokey=1",
|
|
|
|
|
|
|
+ try:
|
|
|
|
|
+ result = subprocess.run(
|
|
|
|
|
+ [
|
|
|
|
|
+ ffprobe,
|
|
|
|
|
+ "-v",
|
|
|
|
|
+ "error",
|
|
|
|
|
+ "-show_entries",
|
|
|
|
|
+ "format=duration",
|
|
|
|
|
+ "-of",
|
|
|
|
|
+ "default=noprint_wrappers=1:nokey=1",
|
|
|
|
|
+ target,
|
|
|
|
|
+ ],
|
|
|
|
|
+ capture_output=True,
|
|
|
|
|
+ text=True,
|
|
|
|
|
+ check=False,
|
|
|
|
|
+ timeout=FFPROBE_TIMEOUT_SECONDS,
|
|
|
|
|
+ )
|
|
|
|
|
+ except subprocess.TimeoutExpired:
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "ffprobe timed out after %.0fs: %s",
|
|
|
|
|
+ FFPROBE_TIMEOUT_SECONDS,
|
|
|
target,
|
|
target,
|
|
|
- ],
|
|
|
|
|
|
|
+ )
|
|
|
|
|
+ else:
|
|
|
|
|
+ if result.returncode == 0:
|
|
|
|
|
+ raw = result.stdout.strip()
|
|
|
|
|
+ if raw:
|
|
|
|
|
+ try:
|
|
|
|
|
+ return float(raw)
|
|
|
|
|
+ except ValueError:
|
|
|
|
|
+ pass
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ result = subprocess.run(
|
|
|
|
|
+ [ffmpeg, "-i", target],
|
|
|
capture_output=True,
|
|
capture_output=True,
|
|
|
text=True,
|
|
text=True,
|
|
|
check=False,
|
|
check=False,
|
|
|
|
|
+ timeout=FFPROBE_TIMEOUT_SECONDS,
|
|
|
)
|
|
)
|
|
|
- if result.returncode == 0:
|
|
|
|
|
- raw = result.stdout.strip()
|
|
|
|
|
- if raw:
|
|
|
|
|
- try:
|
|
|
|
|
- return float(raw)
|
|
|
|
|
- except ValueError:
|
|
|
|
|
- pass
|
|
|
|
|
-
|
|
|
|
|
- result = subprocess.run(
|
|
|
|
|
- [ffmpeg, "-i", target],
|
|
|
|
|
- capture_output=True,
|
|
|
|
|
- text=True,
|
|
|
|
|
- check=False,
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ except subprocess.TimeoutExpired:
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "ffmpeg duration probe timed out after %.0fs: %s",
|
|
|
|
|
+ FFPROBE_TIMEOUT_SECONDS,
|
|
|
|
|
+ target,
|
|
|
|
|
+ )
|
|
|
|
|
+ return None
|
|
|
return _parse_duration_text(result.stderr)
|
|
return _parse_duration_text(result.stderr)
|
|
|
|
|
|
|
|
|
|
|
|
@@ -161,13 +185,31 @@ def _truncate_video(
|
|
|
"+faststart",
|
|
"+faststart",
|
|
|
str(output_path),
|
|
str(output_path),
|
|
|
]
|
|
]
|
|
|
- result = subprocess.run(copy_cmd, capture_output=True, text=True, check=False)
|
|
|
|
|
- if result.returncode == 0 and output_path.is_file() and output_path.stat().st_size > 0:
|
|
|
|
|
|
|
+ try:
|
|
|
|
|
+ result = subprocess.run(
|
|
|
|
|
+ copy_cmd,
|
|
|
|
|
+ capture_output=True,
|
|
|
|
|
+ text=True,
|
|
|
|
|
+ check=False,
|
|
|
|
|
+ timeout=FFMPEG_TIMEOUT_SECONDS,
|
|
|
|
|
+ )
|
|
|
|
|
+ except subprocess.TimeoutExpired:
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "ffmpeg stream copy timed out after %.0fs",
|
|
|
|
|
+ FFMPEG_TIMEOUT_SECONDS,
|
|
|
|
|
+ )
|
|
|
|
|
+ result = None
|
|
|
|
|
+ if (
|
|
|
|
|
+ result is not None
|
|
|
|
|
+ and result.returncode == 0
|
|
|
|
|
+ and output_path.is_file()
|
|
|
|
|
+ and output_path.stat().st_size > 0
|
|
|
|
|
+ ):
|
|
|
return
|
|
return
|
|
|
|
|
|
|
|
logger.warning(
|
|
logger.warning(
|
|
|
"ffmpeg stream copy failed, fallback to re-encode: %s",
|
|
"ffmpeg stream copy failed, fallback to re-encode: %s",
|
|
|
- (result.stderr or result.stdout)[-500:],
|
|
|
|
|
|
|
+ ((result.stderr or result.stdout)[-500:] if result is not None else "timeout"),
|
|
|
)
|
|
)
|
|
|
encode_cmd = [
|
|
encode_cmd = [
|
|
|
ffmpeg,
|
|
ffmpeg,
|
|
@@ -186,12 +228,18 @@ def _truncate_video(
|
|
|
"+faststart",
|
|
"+faststart",
|
|
|
str(output_path),
|
|
str(output_path),
|
|
|
]
|
|
]
|
|
|
- encode_result = subprocess.run(
|
|
|
|
|
- encode_cmd,
|
|
|
|
|
- capture_output=True,
|
|
|
|
|
- text=True,
|
|
|
|
|
- check=False,
|
|
|
|
|
- )
|
|
|
|
|
|
|
+ try:
|
|
|
|
|
+ encode_result = subprocess.run(
|
|
|
|
|
+ encode_cmd,
|
|
|
|
|
+ capture_output=True,
|
|
|
|
|
+ text=True,
|
|
|
|
|
+ check=False,
|
|
|
|
|
+ timeout=FFMPEG_TIMEOUT_SECONDS,
|
|
|
|
|
+ )
|
|
|
|
|
+ except subprocess.TimeoutExpired:
|
|
|
|
|
+ raise ToolTimeoutError(
|
|
|
|
|
+ f"ffmpeg 截断视频超时({FFMPEG_TIMEOUT_SECONDS:.0f}秒)"
|
|
|
|
|
+ ) from None
|
|
|
if encode_result.returncode != 0 or not output_path.is_file():
|
|
if encode_result.returncode != 0 or not output_path.is_file():
|
|
|
detail = (encode_result.stderr or encode_result.stdout)[-500:]
|
|
detail = (encode_result.stderr or encode_result.stdout)[-500:]
|
|
|
raise RuntimeError(f"ffmpeg 截断视频失败: {detail}")
|
|
raise RuntimeError(f"ffmpeg 截断视频失败: {detail}")
|
|
@@ -419,6 +467,7 @@ def verify_truncation_runtime(*, check_oss: bool = False) -> dict[str, Any]:
|
|
|
check=True,
|
|
check=True,
|
|
|
capture_output=True,
|
|
capture_output=True,
|
|
|
text=True,
|
|
text=True,
|
|
|
|
|
+ timeout=FFMPEG_TIMEOUT_SECONDS,
|
|
|
)
|
|
)
|
|
|
_truncate_video(ffmpeg, source_path, clipped_path, 1.0)
|
|
_truncate_video(ffmpeg, source_path, clipped_path, 1.0)
|
|
|
if not clipped_path.is_file() or clipped_path.stat().st_size <= 0:
|
|
if not clipped_path.is_file() or clipped_path.stat().st_size <= 0:
|
|
@@ -505,9 +554,31 @@ async def qwen_video_analyze(
|
|
|
extra=truncate_meta,
|
|
extra=truncate_meta,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
+ except ToolTimeoutError as e:
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "qwen_video_analyze local operation timed out: video_url=%s error=%s",
|
|
|
|
|
+ video_url,
|
|
|
|
|
+ e,
|
|
|
|
|
+ )
|
|
|
|
|
+ return _error_result(
|
|
|
|
|
+ str(e),
|
|
|
|
|
+ error_code="tool_timeout",
|
|
|
|
|
+ retryable=True,
|
|
|
|
|
+ )
|
|
|
except ValueError as e:
|
|
except ValueError as e:
|
|
|
logger.error("qwen_video_analyze config error: %s", e)
|
|
logger.error("qwen_video_analyze config error: %s", e)
|
|
|
return _error_result(str(e))
|
|
return _error_result(str(e))
|
|
|
|
|
+ except httpx.TimeoutException as e:
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "qwen_video_analyze download timed out: video_url=%s error=%s",
|
|
|
|
|
+ video_url,
|
|
|
|
|
+ e,
|
|
|
|
|
+ )
|
|
|
|
|
+ return _error_result(
|
|
|
|
|
+ f"下载视频超时: {e}",
|
|
|
|
|
+ error_code="tool_timeout",
|
|
|
|
|
+ retryable=True,
|
|
|
|
|
+ )
|
|
|
except httpx.HTTPError as e:
|
|
except httpx.HTTPError as e:
|
|
|
logger.error(
|
|
logger.error(
|
|
|
"qwen_video_analyze download error: video_url=%s error=%s",
|
|
"qwen_video_analyze download error: video_url=%s error=%s",
|
|
@@ -516,6 +587,17 @@ async def qwen_video_analyze(
|
|
|
exc_info=True,
|
|
exc_info=True,
|
|
|
)
|
|
)
|
|
|
return _error_result(f"下载视频失败: {e}")
|
|
return _error_result(f"下载视频失败: {e}")
|
|
|
|
|
+ except APITimeoutError as e:
|
|
|
|
|
+ logger.warning(
|
|
|
|
|
+ "qwen_video_analyze model request timed out: video_url=%s error=%s",
|
|
|
|
|
+ video_url,
|
|
|
|
|
+ e,
|
|
|
|
|
+ )
|
|
|
|
|
+ return _error_result(
|
|
|
|
|
+ f"视频模型请求超时: {e}",
|
|
|
|
|
+ error_code="tool_timeout",
|
|
|
|
|
+ retryable=True,
|
|
|
|
|
+ )
|
|
|
except APIStatusError as e:
|
|
except APIStatusError as e:
|
|
|
error_code, title, message, retryable = _classify_api_error(e)
|
|
error_code, title, message, retryable = _classify_api_error(e)
|
|
|
logger.warning(
|
|
logger.warning(
|