abstractcore 2.9.1__py3-none-any.whl → 2.11.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- abstractcore/__init__.py +7 -27
- abstractcore/apps/extractor.py +33 -100
- abstractcore/apps/intent.py +19 -0
- abstractcore/apps/judge.py +20 -1
- abstractcore/apps/summarizer.py +20 -1
- abstractcore/architectures/detection.py +34 -1
- abstractcore/architectures/response_postprocessing.py +313 -0
- abstractcore/assets/architecture_formats.json +38 -8
- abstractcore/assets/model_capabilities.json +781 -160
- abstractcore/compression/__init__.py +1 -2
- abstractcore/compression/glyph_processor.py +6 -4
- abstractcore/config/main.py +31 -19
- abstractcore/config/manager.py +389 -11
- abstractcore/config/vision_config.py +5 -5
- abstractcore/core/interface.py +151 -3
- abstractcore/core/session.py +16 -10
- abstractcore/download.py +1 -1
- abstractcore/embeddings/manager.py +20 -6
- abstractcore/endpoint/__init__.py +2 -0
- abstractcore/endpoint/app.py +458 -0
- abstractcore/mcp/client.py +3 -1
- abstractcore/media/__init__.py +52 -17
- abstractcore/media/auto_handler.py +42 -22
- abstractcore/media/base.py +44 -1
- abstractcore/media/capabilities.py +12 -33
- abstractcore/media/enrichment.py +105 -0
- abstractcore/media/handlers/anthropic_handler.py +19 -28
- abstractcore/media/handlers/local_handler.py +124 -70
- abstractcore/media/handlers/openai_handler.py +19 -31
- abstractcore/media/processors/__init__.py +4 -2
- abstractcore/media/processors/audio_processor.py +57 -0
- abstractcore/media/processors/office_processor.py +8 -3
- abstractcore/media/processors/pdf_processor.py +46 -3
- abstractcore/media/processors/text_processor.py +22 -24
- abstractcore/media/processors/video_processor.py +58 -0
- abstractcore/media/types.py +97 -4
- abstractcore/media/utils/image_scaler.py +20 -2
- abstractcore/media/utils/video_frames.py +219 -0
- abstractcore/media/vision_fallback.py +136 -22
- abstractcore/processing/__init__.py +32 -3
- abstractcore/processing/basic_deepsearch.py +15 -10
- abstractcore/processing/basic_intent.py +3 -2
- abstractcore/processing/basic_judge.py +3 -2
- abstractcore/processing/basic_summarizer.py +1 -1
- abstractcore/providers/__init__.py +3 -1
- abstractcore/providers/anthropic_provider.py +95 -8
- abstractcore/providers/base.py +1516 -81
- abstractcore/providers/huggingface_provider.py +546 -69
- abstractcore/providers/lmstudio_provider.py +35 -923
- abstractcore/providers/mlx_provider.py +382 -35
- abstractcore/providers/model_capabilities.py +5 -1
- abstractcore/providers/ollama_provider.py +99 -15
- abstractcore/providers/openai_compatible_provider.py +406 -180
- abstractcore/providers/openai_provider.py +188 -44
- abstractcore/providers/openrouter_provider.py +76 -0
- abstractcore/providers/registry.py +61 -5
- abstractcore/providers/streaming.py +138 -33
- abstractcore/providers/vllm_provider.py +92 -817
- abstractcore/server/app.py +461 -13
- abstractcore/server/audio_endpoints.py +139 -0
- abstractcore/server/vision_endpoints.py +1319 -0
- abstractcore/structured/handler.py +316 -41
- abstractcore/tools/common_tools.py +5501 -2012
- abstractcore/tools/comms_tools.py +1641 -0
- abstractcore/tools/core.py +37 -7
- abstractcore/tools/handler.py +4 -9
- abstractcore/tools/parser.py +49 -2
- abstractcore/tools/tag_rewriter.py +2 -1
- abstractcore/tools/telegram_tdlib.py +407 -0
- abstractcore/tools/telegram_tools.py +261 -0
- abstractcore/utils/cli.py +1085 -72
- abstractcore/utils/token_utils.py +2 -0
- abstractcore/utils/truncation.py +29 -0
- abstractcore/utils/version.py +3 -4
- abstractcore/utils/vlm_token_calculator.py +12 -2
- abstractcore-2.11.2.dist-info/METADATA +562 -0
- abstractcore-2.11.2.dist-info/RECORD +133 -0
- {abstractcore-2.9.1.dist-info → abstractcore-2.11.2.dist-info}/WHEEL +1 -1
- {abstractcore-2.9.1.dist-info → abstractcore-2.11.2.dist-info}/entry_points.txt +1 -0
- abstractcore-2.9.1.dist-info/METADATA +0 -1190
- abstractcore-2.9.1.dist-info/RECORD +0 -119
- {abstractcore-2.9.1.dist-info → abstractcore-2.11.2.dist-info}/licenses/LICENSE +0 -0
- {abstractcore-2.9.1.dist-info → abstractcore-2.11.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Video processor for AbstractCore media handling.
|
|
3
|
+
|
|
4
|
+
v0 goals:
|
|
5
|
+
- Treat video as a first-class media type (MediaType.VIDEO) in the media pipeline.
|
|
6
|
+
- Keep processing lightweight and dependency-free by default (store as a file ref).
|
|
7
|
+
|
|
8
|
+
Higher-level semantic handling (native video models, frame sampling, captioning)
|
|
9
|
+
is handled by policy and capability layers (see planned video policy backlog).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import mimetypes
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ..base import BaseMediaHandler, MediaProcessingError
|
|
18
|
+
from ..types import ContentFormat, MediaCapabilities, MediaContent, MediaType
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class VideoProcessor(BaseMediaHandler):
|
|
22
|
+
"""Lightweight video processor that stores a video file reference."""
|
|
23
|
+
|
|
24
|
+
def __init__(self, **kwargs):
|
|
25
|
+
super().__init__(**kwargs)
|
|
26
|
+
|
|
27
|
+
self.capabilities = MediaCapabilities(
|
|
28
|
+
vision_support=False,
|
|
29
|
+
audio_support=False,
|
|
30
|
+
video_support=True,
|
|
31
|
+
document_support=False,
|
|
32
|
+
max_file_size=self.max_file_size,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def _process_internal(self, file_path: Path, media_type: MediaType, **kwargs) -> MediaContent:
|
|
36
|
+
if media_type != MediaType.VIDEO:
|
|
37
|
+
raise MediaProcessingError(f"VideoProcessor only handles video, got {media_type}")
|
|
38
|
+
|
|
39
|
+
mime_type, _enc = mimetypes.guess_type(str(file_path))
|
|
40
|
+
mime_type = mime_type or "application/octet-stream"
|
|
41
|
+
|
|
42
|
+
metadata = {
|
|
43
|
+
"file_name": file_path.name,
|
|
44
|
+
"file_path": str(file_path),
|
|
45
|
+
"file_size": file_path.stat().st_size if file_path.exists() else None,
|
|
46
|
+
"processor": self.__class__.__name__,
|
|
47
|
+
}
|
|
48
|
+
metadata.update(kwargs.get("metadata", {}) if isinstance(kwargs.get("metadata"), dict) else {})
|
|
49
|
+
|
|
50
|
+
return MediaContent(
|
|
51
|
+
media_type=MediaType.VIDEO,
|
|
52
|
+
content=str(file_path),
|
|
53
|
+
content_format=ContentFormat.FILE_PATH,
|
|
54
|
+
mime_type=mime_type,
|
|
55
|
+
file_path=str(file_path),
|
|
56
|
+
metadata=metadata,
|
|
57
|
+
)
|
|
58
|
+
|
abstractcore/media/types.py
CHANGED
|
@@ -12,7 +12,7 @@ from pathlib import Path
|
|
|
12
12
|
from typing import Union, Dict, Any, Optional, List, Literal
|
|
13
13
|
from enum import Enum
|
|
14
14
|
|
|
15
|
-
from pydantic import BaseModel, Field,
|
|
15
|
+
from pydantic import BaseModel, Field, field_validator
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
class MediaType(Enum):
|
|
@@ -63,6 +63,98 @@ class MediaContent:
|
|
|
63
63
|
elif self.content_format == ContentFormat.TEXT and isinstance(self.content, bytes):
|
|
64
64
|
self.content = self.content.decode('utf-8')
|
|
65
65
|
|
|
66
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
67
|
+
"""Return a JSON-safe dict representation (best-effort)."""
|
|
68
|
+
content: Any = self.content
|
|
69
|
+
if isinstance(content, (bytes, bytearray)):
|
|
70
|
+
content = base64.b64encode(bytes(content)).decode("utf-8")
|
|
71
|
+
return {
|
|
72
|
+
"media_type": self.media_type.value if isinstance(self.media_type, MediaType) else str(self.media_type),
|
|
73
|
+
"content": content,
|
|
74
|
+
"content_format": self.content_format.value
|
|
75
|
+
if isinstance(self.content_format, ContentFormat)
|
|
76
|
+
else str(self.content_format),
|
|
77
|
+
"mime_type": str(self.mime_type or "application/octet-stream"),
|
|
78
|
+
"file_path": self.file_path,
|
|
79
|
+
"metadata": dict(self.metadata or {}),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_dict(cls, data: Dict[str, Any]) -> "MediaContent":
|
|
84
|
+
"""Parse a dict into a MediaContent (best-effort, tolerant of common aliases)."""
|
|
85
|
+
if not isinstance(data, dict):
|
|
86
|
+
raise TypeError("MediaContent.from_dict expects a dict")
|
|
87
|
+
|
|
88
|
+
media_type_raw = data.get("media_type")
|
|
89
|
+
if media_type_raw is None:
|
|
90
|
+
media_type_raw = data.get("mediaType")
|
|
91
|
+
|
|
92
|
+
mime_type_raw = data.get("mime_type")
|
|
93
|
+
if mime_type_raw is None:
|
|
94
|
+
mime_type_raw = data.get("mimeType")
|
|
95
|
+
if mime_type_raw is None:
|
|
96
|
+
mime_type_raw = data.get("mime")
|
|
97
|
+
|
|
98
|
+
mime_type = str(mime_type_raw or "application/octet-stream")
|
|
99
|
+
|
|
100
|
+
if isinstance(media_type_raw, MediaType):
|
|
101
|
+
media_type = media_type_raw
|
|
102
|
+
elif isinstance(media_type_raw, str) and media_type_raw.strip():
|
|
103
|
+
media_type = MediaType(media_type_raw.strip())
|
|
104
|
+
else:
|
|
105
|
+
# Infer from MIME type when missing.
|
|
106
|
+
mt = mime_type.lower()
|
|
107
|
+
if mt.startswith("image/"):
|
|
108
|
+
media_type = MediaType.IMAGE
|
|
109
|
+
elif mt.startswith("audio/"):
|
|
110
|
+
media_type = MediaType.AUDIO
|
|
111
|
+
elif mt.startswith("video/"):
|
|
112
|
+
media_type = MediaType.VIDEO
|
|
113
|
+
elif mt.startswith("text/"):
|
|
114
|
+
media_type = MediaType.TEXT
|
|
115
|
+
else:
|
|
116
|
+
media_type = MediaType.DOCUMENT
|
|
117
|
+
|
|
118
|
+
content_format_raw = data.get("content_format")
|
|
119
|
+
if content_format_raw is None:
|
|
120
|
+
content_format_raw = data.get("contentFormat")
|
|
121
|
+
if content_format_raw is None:
|
|
122
|
+
content_format_raw = data.get("format")
|
|
123
|
+
|
|
124
|
+
file_path_raw = data.get("file_path")
|
|
125
|
+
if file_path_raw is None:
|
|
126
|
+
file_path_raw = data.get("filePath")
|
|
127
|
+
|
|
128
|
+
content = data.get("content")
|
|
129
|
+
|
|
130
|
+
if isinstance(content_format_raw, ContentFormat):
|
|
131
|
+
content_format = content_format_raw
|
|
132
|
+
elif isinstance(content_format_raw, str) and content_format_raw.strip():
|
|
133
|
+
content_format = ContentFormat(content_format_raw.strip())
|
|
134
|
+
else:
|
|
135
|
+
if isinstance(file_path_raw, str) and file_path_raw.strip():
|
|
136
|
+
content_format = ContentFormat.FILE_PATH
|
|
137
|
+
elif isinstance(content, (bytes, bytearray)):
|
|
138
|
+
content_format = ContentFormat.BINARY
|
|
139
|
+
elif isinstance(content, str):
|
|
140
|
+
content_format = ContentFormat.TEXT
|
|
141
|
+
else:
|
|
142
|
+
content_format = ContentFormat.AUTO
|
|
143
|
+
|
|
144
|
+
metadata_raw = data.get("metadata")
|
|
145
|
+
metadata = dict(metadata_raw) if isinstance(metadata_raw, dict) else {}
|
|
146
|
+
|
|
147
|
+
file_path = str(file_path_raw).strip() if isinstance(file_path_raw, str) and file_path_raw.strip() else None
|
|
148
|
+
|
|
149
|
+
return cls(
|
|
150
|
+
media_type=media_type,
|
|
151
|
+
content=content,
|
|
152
|
+
content_format=content_format,
|
|
153
|
+
mime_type=mime_type,
|
|
154
|
+
file_path=file_path,
|
|
155
|
+
metadata=metadata,
|
|
156
|
+
)
|
|
157
|
+
|
|
66
158
|
|
|
67
159
|
class MultimodalMessage(BaseModel):
|
|
68
160
|
"""
|
|
@@ -78,8 +170,9 @@ class MultimodalMessage(BaseModel):
|
|
|
78
170
|
)
|
|
79
171
|
metadata: Dict[str, Any] = Field(default_factory=dict)
|
|
80
172
|
|
|
81
|
-
@
|
|
82
|
-
|
|
173
|
+
@field_validator("role")
|
|
174
|
+
@classmethod
|
|
175
|
+
def validate_role(cls, v: str) -> str:
|
|
83
176
|
valid_roles = {'user', 'assistant', 'system', 'tool'}
|
|
84
177
|
if v not in valid_roles:
|
|
85
178
|
raise ValueError(f"Role must be one of {valid_roles}")
|
|
@@ -454,4 +547,4 @@ def create_media_content(
|
|
|
454
547
|
'file_name': path.name,
|
|
455
548
|
'file_extension': path.suffix
|
|
456
549
|
}
|
|
457
|
-
)
|
|
550
|
+
)
|
|
@@ -5,11 +5,19 @@ Provides intelligent image scaling based on model-specific requirements
|
|
|
5
5
|
and capabilities for vision models.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
from __future__ import annotations # PEP 563 - avoid hard PIL dependency at import time
|
|
9
|
+
|
|
8
10
|
from typing import Tuple, Optional, Union, Dict, Any
|
|
9
11
|
from enum import Enum
|
|
10
12
|
from pathlib import Path
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
try:
|
|
15
|
+
from PIL import Image, ImageOps
|
|
16
|
+
PIL_AVAILABLE = True
|
|
17
|
+
except ImportError: # pragma: no cover
|
|
18
|
+
Image = None
|
|
19
|
+
ImageOps = None
|
|
20
|
+
PIL_AVAILABLE = False
|
|
13
21
|
|
|
14
22
|
from ..base import MediaProcessingError
|
|
15
23
|
from ...utils.structured_logging import get_logger
|
|
@@ -132,6 +140,11 @@ class ModelOptimizedScaler:
|
|
|
132
140
|
Returns:
|
|
133
141
|
Scaled PIL Image
|
|
134
142
|
"""
|
|
143
|
+
if not PIL_AVAILABLE:
|
|
144
|
+
raise MediaProcessingError(
|
|
145
|
+
"PIL/Pillow is required for image scaling. "
|
|
146
|
+
"Install with: pip install \"abstractcore[media]\""
|
|
147
|
+
)
|
|
135
148
|
target_width, target_height = target_size
|
|
136
149
|
|
|
137
150
|
if mode == ScalingMode.FIT:
|
|
@@ -278,6 +291,11 @@ def scale_image_for_model(image: Union[Image.Image, str, Path],
|
|
|
278
291
|
Returns:
|
|
279
292
|
Optimally scaled PIL Image
|
|
280
293
|
"""
|
|
294
|
+
if not PIL_AVAILABLE:
|
|
295
|
+
raise MediaProcessingError(
|
|
296
|
+
"PIL/Pillow is required for image scaling. "
|
|
297
|
+
"Install with: pip install \"abstractcore[media]\""
|
|
298
|
+
)
|
|
281
299
|
if isinstance(image, (str, Path)):
|
|
282
300
|
image = Image.open(image)
|
|
283
301
|
|
|
@@ -296,4 +314,4 @@ def get_optimal_size_for_model(model_name: str, original_size: Tuple[int, int])
|
|
|
296
314
|
Optimal target size (width, height)
|
|
297
315
|
"""
|
|
298
316
|
scaler = get_scaler()
|
|
299
|
-
return scaler.get_optimal_resolution(model_name, original_size)
|
|
317
|
+
return scaler.get_optimal_resolution(model_name, original_size)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Video frame extraction utilities (v0).
|
|
3
|
+
|
|
4
|
+
This module provides a small, dependency-light wrapper around ffmpeg/ffprobe
|
|
5
|
+
to sample a bounded number of frames from a video for downstream analysis.
|
|
6
|
+
|
|
7
|
+
Design goals:
|
|
8
|
+
- deterministic sampling (timestamp-based)
|
|
9
|
+
- bounded output (max_frames)
|
|
10
|
+
- actionable errors when ffmpeg/ffprobe are unavailable
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import tempfile
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import List, Optional, Tuple
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class VideoToolUnavailableError(RuntimeError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _which(cmd: str) -> Optional[str]:
|
|
27
|
+
try:
|
|
28
|
+
return shutil.which(cmd)
|
|
29
|
+
except Exception:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def probe_duration_s(video_path: Path) -> Optional[float]:
|
|
34
|
+
"""Return best-effort duration (seconds) using ffprobe, or None."""
|
|
35
|
+
ffprobe = _which("ffprobe")
|
|
36
|
+
if not ffprobe:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
out = subprocess.check_output(
|
|
41
|
+
[
|
|
42
|
+
ffprobe,
|
|
43
|
+
"-v",
|
|
44
|
+
"error",
|
|
45
|
+
"-show_entries",
|
|
46
|
+
"format=duration",
|
|
47
|
+
"-of",
|
|
48
|
+
"default=nk=1:nw=1",
|
|
49
|
+
str(video_path),
|
|
50
|
+
],
|
|
51
|
+
text=True,
|
|
52
|
+
stderr=subprocess.STDOUT,
|
|
53
|
+
).strip()
|
|
54
|
+
if not out:
|
|
55
|
+
return None
|
|
56
|
+
return float(out)
|
|
57
|
+
except Exception:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def probe_keyframe_timestamps_s(video_path: Path) -> List[float]:
|
|
62
|
+
"""Return keyframe timestamps (seconds) using ffprobe when available."""
|
|
63
|
+
ffprobe = _which("ffprobe")
|
|
64
|
+
if not ffprobe:
|
|
65
|
+
return []
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
out = subprocess.check_output(
|
|
69
|
+
[
|
|
70
|
+
ffprobe,
|
|
71
|
+
"-v",
|
|
72
|
+
"error",
|
|
73
|
+
"-skip_frame",
|
|
74
|
+
"nokey",
|
|
75
|
+
"-select_streams",
|
|
76
|
+
"v:0",
|
|
77
|
+
"-show_entries",
|
|
78
|
+
"frame=pkt_pts_time",
|
|
79
|
+
"-of",
|
|
80
|
+
"csv=p=0",
|
|
81
|
+
str(video_path),
|
|
82
|
+
],
|
|
83
|
+
text=True,
|
|
84
|
+
stderr=subprocess.STDOUT,
|
|
85
|
+
)
|
|
86
|
+
except Exception:
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
timestamps: List[float] = []
|
|
90
|
+
for line in (out or "").splitlines():
|
|
91
|
+
s = line.strip()
|
|
92
|
+
if not s:
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
t = float(s)
|
|
96
|
+
except Exception:
|
|
97
|
+
continue
|
|
98
|
+
if t < 0:
|
|
99
|
+
continue
|
|
100
|
+
timestamps.append(t)
|
|
101
|
+
|
|
102
|
+
# Deduplicate while preserving order (ffprobe can sometimes repeat values).
|
|
103
|
+
seen = set()
|
|
104
|
+
uniq: List[float] = []
|
|
105
|
+
for t in timestamps:
|
|
106
|
+
if t in seen:
|
|
107
|
+
continue
|
|
108
|
+
seen.add(t)
|
|
109
|
+
uniq.append(t)
|
|
110
|
+
return uniq
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _build_timestamps(duration_s: Optional[float], max_frames: int) -> List[float]:
|
|
114
|
+
n = max(1, int(max_frames))
|
|
115
|
+
if duration_s is None or duration_s <= 0:
|
|
116
|
+
return [0.0]
|
|
117
|
+
# Sample away from the extreme endpoints to avoid decode edge-cases.
|
|
118
|
+
return [duration_s * (i + 1) / (n + 1) for i in range(n)]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _pick_evenly_spaced(values: List[float], k: int) -> List[float]:
|
|
122
|
+
if not values:
|
|
123
|
+
return []
|
|
124
|
+
n = len(values)
|
|
125
|
+
k = max(1, int(k))
|
|
126
|
+
if n <= k:
|
|
127
|
+
return list(values)
|
|
128
|
+
# Evenly spaced indices (include ends).
|
|
129
|
+
idxs = [round(i * (n - 1) / (k - 1)) for i in range(k)] if k > 1 else [round((n - 1) / 2)]
|
|
130
|
+
out: List[float] = []
|
|
131
|
+
last = None
|
|
132
|
+
for i in idxs:
|
|
133
|
+
i = max(0, min(n - 1, int(i)))
|
|
134
|
+
v = values[i]
|
|
135
|
+
if last is not None and v == last:
|
|
136
|
+
continue
|
|
137
|
+
out.append(v)
|
|
138
|
+
last = v
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def extract_video_frames(
|
|
143
|
+
video_path: Path,
|
|
144
|
+
*,
|
|
145
|
+
max_frames: int = 3,
|
|
146
|
+
frame_format: str = "jpg",
|
|
147
|
+
sampling_strategy: str = "uniform",
|
|
148
|
+
max_side: Optional[int] = None,
|
|
149
|
+
output_dir: Optional[Path] = None,
|
|
150
|
+
) -> Tuple[List[Path], List[float]]:
|
|
151
|
+
"""
|
|
152
|
+
Extract up to max_frames as image files and return (frame_paths, timestamps_s).
|
|
153
|
+
|
|
154
|
+
Uses ffmpeg for extraction and ffprobe for duration (best-effort).
|
|
155
|
+
"""
|
|
156
|
+
ffmpeg = _which("ffmpeg")
|
|
157
|
+
if not ffmpeg:
|
|
158
|
+
raise VideoToolUnavailableError("ffmpeg is required for video frame extraction. Install ffmpeg and ensure it is on PATH.")
|
|
159
|
+
|
|
160
|
+
if not isinstance(video_path, Path):
|
|
161
|
+
video_path = Path(video_path)
|
|
162
|
+
if not video_path.exists():
|
|
163
|
+
raise FileNotFoundError(str(video_path))
|
|
164
|
+
|
|
165
|
+
fmt = str(frame_format or "jpg").strip().lower()
|
|
166
|
+
if fmt == "jpeg":
|
|
167
|
+
fmt = "jpg"
|
|
168
|
+
if fmt not in {"jpg", "png"}:
|
|
169
|
+
fmt = "jpg"
|
|
170
|
+
|
|
171
|
+
out_dir = Path(output_dir) if output_dir is not None else Path(tempfile.mkdtemp(prefix="abstractcore_video_frames_"))
|
|
172
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
173
|
+
|
|
174
|
+
duration_s = probe_duration_s(video_path)
|
|
175
|
+
strategy = str(sampling_strategy or "uniform").strip().lower()
|
|
176
|
+
if strategy == "keyframes":
|
|
177
|
+
keyframes = probe_keyframe_timestamps_s(video_path)
|
|
178
|
+
timestamps = _pick_evenly_spaced(keyframes, int(max_frames)) if keyframes else _build_timestamps(duration_s, max_frames=max_frames)
|
|
179
|
+
else:
|
|
180
|
+
timestamps = _build_timestamps(duration_s, max_frames=max_frames)
|
|
181
|
+
|
|
182
|
+
frames: List[Path] = []
|
|
183
|
+
for idx, ts in enumerate(timestamps):
|
|
184
|
+
out_path = out_dir / f"frame_{idx+1:02d}.{fmt}"
|
|
185
|
+
cmd = [
|
|
186
|
+
ffmpeg,
|
|
187
|
+
"-hide_banner",
|
|
188
|
+
"-loglevel",
|
|
189
|
+
"error",
|
|
190
|
+
"-ss",
|
|
191
|
+
f"{ts:.3f}",
|
|
192
|
+
"-i",
|
|
193
|
+
str(video_path),
|
|
194
|
+
"-frames:v",
|
|
195
|
+
"1",
|
|
196
|
+
]
|
|
197
|
+
if isinstance(max_side, int) and max_side > 0:
|
|
198
|
+
ms = int(max_side)
|
|
199
|
+
# Preserve aspect ratio, never upscale.
|
|
200
|
+
# If width >= height: clamp width to ms, derive height. Else clamp height.
|
|
201
|
+
vf = (
|
|
202
|
+
f"scale="
|
|
203
|
+
f"if(gt(iw\\,ih)\\,min(iw\\,{ms})\\,-2):"
|
|
204
|
+
f"if(gt(iw\\,ih)\\,-2\\,min(ih\\,{ms}))"
|
|
205
|
+
)
|
|
206
|
+
cmd.extend(["-vf", vf])
|
|
207
|
+
if fmt == "jpg":
|
|
208
|
+
cmd.extend(["-q:v", "2"])
|
|
209
|
+
cmd.append(str(out_path))
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
213
|
+
except subprocess.CalledProcessError:
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
if out_path.exists() and out_path.stat().st_size > 0:
|
|
217
|
+
frames.append(out_path)
|
|
218
|
+
|
|
219
|
+
return frames, timestamps
|