smartpipe-cli 1.3.0__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.
- smartpipe/__init__.py +6 -0
- smartpipe/__main__.py +8 -0
- smartpipe/assets/probe.png +0 -0
- smartpipe/assets/probe.txt +1 -0
- smartpipe/assets/probe.wav +0 -0
- smartpipe/cli/__init__.py +5 -0
- smartpipe/cli/auth_cmd.py +78 -0
- smartpipe/cli/cache_cmd.py +60 -0
- smartpipe/cli/chart_cmd.py +60 -0
- smartpipe/cli/cite_cmd.py +26 -0
- smartpipe/cli/cluster_cmd.py +102 -0
- smartpipe/cli/completions.py +91 -0
- smartpipe/cli/config_cmd.py +234 -0
- smartpipe/cli/diff_cmd.py +100 -0
- smartpipe/cli/distinct_cmd.py +94 -0
- smartpipe/cli/doctor_cmd.py +207 -0
- smartpipe/cli/echo_cmd.py +44 -0
- smartpipe/cli/embed_cmd.py +80 -0
- smartpipe/cli/extend_cmd.py +138 -0
- smartpipe/cli/filter_cmd.py +87 -0
- smartpipe/cli/getschema_cmd.py +31 -0
- smartpipe/cli/input_options.py +113 -0
- smartpipe/cli/interrupts.py +92 -0
- smartpipe/cli/join_cmd.py +162 -0
- smartpipe/cli/map_cmd.py +150 -0
- smartpipe/cli/outliers_cmd.py +82 -0
- smartpipe/cli/probe_cmd.py +223 -0
- smartpipe/cli/reduce_cmd.py +129 -0
- smartpipe/cli/root.py +281 -0
- smartpipe/cli/run_cmd.py +136 -0
- smartpipe/cli/sample_cmd.py +35 -0
- smartpipe/cli/schema_cmd.py +75 -0
- smartpipe/cli/screens.py +231 -0
- smartpipe/cli/sem_file.py +453 -0
- smartpipe/cli/sort_cmd.py +33 -0
- smartpipe/cli/split_cmd.py +76 -0
- smartpipe/cli/summarize_cmd.py +37 -0
- smartpipe/cli/top_k_cmd.py +97 -0
- smartpipe/cli/usage_cmd.py +66 -0
- smartpipe/cli/where_cmd.py +36 -0
- smartpipe/config/__init__.py +5 -0
- smartpipe/config/credentials.py +118 -0
- smartpipe/config/display.py +70 -0
- smartpipe/config/doctor.py +58 -0
- smartpipe/config/paths.py +38 -0
- smartpipe/config/store.py +252 -0
- smartpipe/container.py +439 -0
- smartpipe/core/__init__.py +5 -0
- smartpipe/core/errors.py +57 -0
- smartpipe/core/jsontools.py +56 -0
- smartpipe/engine/__init__.py +9 -0
- smartpipe/engine/aggregate.py +234 -0
- smartpipe/engine/blocking.py +44 -0
- smartpipe/engine/chart.py +143 -0
- smartpipe/engine/chunking.py +161 -0
- smartpipe/engine/clustering.py +94 -0
- smartpipe/engine/predicate.py +330 -0
- smartpipe/engine/prompts.py +601 -0
- smartpipe/engine/ranking.py +62 -0
- smartpipe/engine/runner.py +175 -0
- smartpipe/engine/schema.py +208 -0
- smartpipe/engine/schema_dsl.py +144 -0
- smartpipe/engine/tally.py +53 -0
- smartpipe/engine/timebin.py +67 -0
- smartpipe/engine/units.py +43 -0
- smartpipe/engine/windows.py +78 -0
- smartpipe/io/__init__.py +9 -0
- smartpipe/io/diagnostics.py +148 -0
- smartpipe/io/inputs.py +44 -0
- smartpipe/io/items.py +149 -0
- smartpipe/io/leaderboard.py +52 -0
- smartpipe/io/metering.py +180 -0
- smartpipe/io/progress.py +140 -0
- smartpipe/io/readers.py +455 -0
- smartpipe/io/text.py +40 -0
- smartpipe/io/tty.py +88 -0
- smartpipe/io/usage.py +214 -0
- smartpipe/io/writers.py +340 -0
- smartpipe/models/__init__.py +5 -0
- smartpipe/models/anthropic_adapter.py +149 -0
- smartpipe/models/base.py +170 -0
- smartpipe/models/budget.py +94 -0
- smartpipe/models/cache.py +132 -0
- smartpipe/models/gemini_native.py +196 -0
- smartpipe/models/http_support.py +77 -0
- smartpipe/models/jina.py +98 -0
- smartpipe/models/local_embed.py +76 -0
- smartpipe/models/ollama.py +204 -0
- smartpipe/models/openai_codex.py +237 -0
- smartpipe/models/openai_compat.py +328 -0
- smartpipe/models/openai_oauth.py +366 -0
- smartpipe/models/resolve.py +78 -0
- smartpipe/models/retry.py +69 -0
- smartpipe/models/stt.py +80 -0
- smartpipe/models/windows.py +116 -0
- smartpipe/parsing/__init__.py +10 -0
- smartpipe/parsing/detect.py +178 -0
- smartpipe/parsing/extract.py +582 -0
- smartpipe/py.typed +0 -0
- smartpipe/verbs/__init__.py +5 -0
- smartpipe/verbs/chart.py +153 -0
- smartpipe/verbs/cluster.py +220 -0
- smartpipe/verbs/common.py +468 -0
- smartpipe/verbs/convert.py +251 -0
- smartpipe/verbs/diff.py +206 -0
- smartpipe/verbs/distinct.py +164 -0
- smartpipe/verbs/embed.py +166 -0
- smartpipe/verbs/extend.py +180 -0
- smartpipe/verbs/filter.py +191 -0
- smartpipe/verbs/getschema.py +135 -0
- smartpipe/verbs/join.py +413 -0
- smartpipe/verbs/map.py +315 -0
- smartpipe/verbs/outliers.py +119 -0
- smartpipe/verbs/reduce.py +428 -0
- smartpipe/verbs/sample.py +52 -0
- smartpipe/verbs/sortverb.py +63 -0
- smartpipe/verbs/split.py +333 -0
- smartpipe/verbs/summarize.py +60 -0
- smartpipe/verbs/top_k.py +318 -0
- smartpipe/verbs/where.py +47 -0
- smartpipe_cli-1.3.0.dist-info/METADATA +192 -0
- smartpipe_cli-1.3.0.dist-info/RECORD +126 -0
- smartpipe_cli-1.3.0.dist-info/WHEEL +4 -0
- smartpipe_cli-1.3.0.dist-info/entry_points.txt +3 -0
- smartpipe_cli-1.3.0.dist-info/licenses/LICENSE +201 -0
- smartpipe_cli-1.3.0.dist-info/licenses/NOTICE +5 -0
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
"""Turn a file into text (spec §3.1, D08). Text kinds read directly; documents and
|
|
2
|
+
audio go through the lazily-imported markitdown bridge; images carry their bytes to
|
|
3
|
+
a vision model instead of being parsed.
|
|
4
|
+
|
|
5
|
+
Failure modes are typed: ``MissingExtra`` (an optional dependency isn't installed —
|
|
6
|
+
the reader shows a one-time install screen and skips), ``ItemError`` (this file
|
|
7
|
+
couldn't be parsed — skip with a warning).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING, assert_never
|
|
15
|
+
|
|
16
|
+
from smartpipe.core.errors import ItemError
|
|
17
|
+
from smartpipe.core.jsontools import as_items, as_record
|
|
18
|
+
from smartpipe.models.base import AudioData, ImageData, VideoData
|
|
19
|
+
from smartpipe.parsing.detect import FileKind, route
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from collections.abc import Mapping
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"EmbeddedImage",
|
|
27
|
+
"EmbeddedMedia",
|
|
28
|
+
"Extracted",
|
|
29
|
+
"ImageData",
|
|
30
|
+
"MissingExtra",
|
|
31
|
+
"VideoParts",
|
|
32
|
+
"embedded_images",
|
|
33
|
+
"extract",
|
|
34
|
+
"ffmpeg_exe",
|
|
35
|
+
"pdf_page_texts",
|
|
36
|
+
"slice_audio",
|
|
37
|
+
"slice_video",
|
|
38
|
+
"transcribe_audio",
|
|
39
|
+
"video_to_parts",
|
|
40
|
+
"whisper_size",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
_IMAGE_MIME: dict[str, str] = {
|
|
44
|
+
".png": "image/png",
|
|
45
|
+
".jpg": "image/jpeg",
|
|
46
|
+
".jpeg": "image/jpeg",
|
|
47
|
+
".gif": "image/gif",
|
|
48
|
+
".webp": "image/webp",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class MissingExtra(Exception):
|
|
53
|
+
"""An optional dependency needed to parse this file isn't installed."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, extra: str, guidance: str) -> None:
|
|
56
|
+
super().__init__(guidance)
|
|
57
|
+
self.extra = extra
|
|
58
|
+
self.guidance = guidance
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class Extracted:
|
|
63
|
+
text: str
|
|
64
|
+
image: ImageData | None = None
|
|
65
|
+
warning: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def extract(path: Path, kind: FileKind) -> Extracted:
|
|
69
|
+
match route(kind):
|
|
70
|
+
case "text":
|
|
71
|
+
return _read_text(path)
|
|
72
|
+
case "doc":
|
|
73
|
+
return Extracted(text=_via_markitdown(path, extra="files", noun="documents"))
|
|
74
|
+
case "audio":
|
|
75
|
+
return Extracted(text=_via_markitdown(path, extra="audio", noun="audio"))
|
|
76
|
+
case "video":
|
|
77
|
+
raise ItemError(
|
|
78
|
+
"video reaches text extraction unconverted — this is a smartpipe bug"
|
|
79
|
+
) # readers hand video BYTES to the verbs; conversion is per-verb (D27)
|
|
80
|
+
case "image":
|
|
81
|
+
return Extracted(text="", image=_read_image(path))
|
|
82
|
+
case "skip":
|
|
83
|
+
raise ItemError("unsupported format")
|
|
84
|
+
case _ as unreachable: # pragma: no cover
|
|
85
|
+
assert_never(unreachable)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _read_text(path: Path) -> Extracted:
|
|
89
|
+
raw = path.read_bytes()
|
|
90
|
+
text = raw.decode("utf-8", errors="replace")
|
|
91
|
+
if "�" in text and b"\xef\xbf\xbd" not in raw:
|
|
92
|
+
return Extracted(text=text, warning="not valid UTF-8; some bytes were replaced")
|
|
93
|
+
return Extracted(text=text)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _read_image(path: Path) -> ImageData:
|
|
97
|
+
mime = _IMAGE_MIME.get(path.suffix.lower(), "image/png")
|
|
98
|
+
return ImageData(data=path.read_bytes(), mime=mime)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def pdf_page_texts(path: Path) -> list[str]:
|
|
102
|
+
"""Per-page text of a PDF (D26 rich split). Needs the [files] extra."""
|
|
103
|
+
try:
|
|
104
|
+
from pdfminer.high_level import extract_text
|
|
105
|
+
from pdfminer.pdfpage import PDFPage
|
|
106
|
+
except ImportError as exc:
|
|
107
|
+
raise MissingExtra(
|
|
108
|
+
"files",
|
|
109
|
+
"error: the document parser is unavailable — reinstall smartpipe\n"
|
|
110
|
+
" (pdfminer ships in the box; a broken environment is the only way here)",
|
|
111
|
+
) from exc
|
|
112
|
+
try:
|
|
113
|
+
with path.open("rb") as handle:
|
|
114
|
+
count = sum(1 for _ in PDFPage.get_pages(handle))
|
|
115
|
+
return [extract_text(str(path), page_numbers={number}) or "" for number in range(count)]
|
|
116
|
+
except MissingExtra: # pragma: no cover — nothing above raises it here
|
|
117
|
+
raise
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
raise ItemError(f"{path.name} couldn't be parsed as a PDF ({exc})") from exc
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def slice_audio(audio: AudioData, *, seconds: int) -> list[AudioData]:
|
|
123
|
+
"""Duration slices of an audio payload (D27): wav natively, ffmpeg otherwise.
|
|
124
|
+
|
|
125
|
+
Slicing is lossless re-segmentation — every slice is a valid standalone file
|
|
126
|
+
of the same kind, so each can ride the native-hearing wire on its own.
|
|
127
|
+
"""
|
|
128
|
+
if audio.mime in ("audio/wav", "audio/x-wav"):
|
|
129
|
+
return _slice_wav(audio, seconds=seconds)
|
|
130
|
+
return _slice_via_ffmpeg(audio, seconds=seconds)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _slice_wav(audio: AudioData, *, seconds: int) -> list[AudioData]:
|
|
134
|
+
import io
|
|
135
|
+
import wave
|
|
136
|
+
|
|
137
|
+
try:
|
|
138
|
+
with wave.open(io.BytesIO(audio.data)) as reader:
|
|
139
|
+
params = reader.getparams()
|
|
140
|
+
frames_per_slice = params.framerate * seconds
|
|
141
|
+
slices: list[AudioData] = []
|
|
142
|
+
while True:
|
|
143
|
+
frames = reader.readframes(frames_per_slice)
|
|
144
|
+
if not frames:
|
|
145
|
+
break
|
|
146
|
+
buffer = io.BytesIO()
|
|
147
|
+
with wave.open(buffer, "wb") as writer:
|
|
148
|
+
writer.setnchannels(params.nchannels)
|
|
149
|
+
writer.setsampwidth(params.sampwidth)
|
|
150
|
+
writer.setframerate(params.framerate)
|
|
151
|
+
writer.writeframes(frames)
|
|
152
|
+
slices.append(AudioData(data=buffer.getvalue(), mime="audio/wav"))
|
|
153
|
+
return slices or [audio]
|
|
154
|
+
except ItemError: # pragma: no cover — nothing above raises it
|
|
155
|
+
raise
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
raise ItemError(f"audio couldn't be sliced ({exc})") from exc
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _slice_via_ffmpeg(audio: AudioData, *, seconds: int) -> list[AudioData]:
|
|
161
|
+
import shutil
|
|
162
|
+
|
|
163
|
+
if shutil.which("ffmpeg") is None:
|
|
164
|
+
raise ItemError(
|
|
165
|
+
"slicing this format needs ffmpeg on PATH (wav slices natively)\n"
|
|
166
|
+
" Install ffmpeg, or convert first: ffmpeg -i in.mp3 out.wav"
|
|
167
|
+
)
|
|
168
|
+
import contextlib
|
|
169
|
+
import os
|
|
170
|
+
import subprocess
|
|
171
|
+
import tempfile
|
|
172
|
+
|
|
173
|
+
workdir = tempfile.mkdtemp(prefix="smartpipe-slice-")
|
|
174
|
+
source = os.path.join(workdir, "source")
|
|
175
|
+
pattern = os.path.join(workdir, "slice-%04d.wav")
|
|
176
|
+
try:
|
|
177
|
+
with open(source, "wb") as handle:
|
|
178
|
+
handle.write(audio.data)
|
|
179
|
+
subprocess.run(
|
|
180
|
+
[
|
|
181
|
+
"ffmpeg",
|
|
182
|
+
"-loglevel",
|
|
183
|
+
"error",
|
|
184
|
+
"-i",
|
|
185
|
+
source,
|
|
186
|
+
"-f",
|
|
187
|
+
"segment",
|
|
188
|
+
"-segment_time",
|
|
189
|
+
str(seconds),
|
|
190
|
+
"-c:a",
|
|
191
|
+
"pcm_s16le",
|
|
192
|
+
pattern,
|
|
193
|
+
],
|
|
194
|
+
check=True,
|
|
195
|
+
capture_output=True,
|
|
196
|
+
)
|
|
197
|
+
names = sorted(name for name in os.listdir(workdir) if name.startswith("slice-"))
|
|
198
|
+
return [
|
|
199
|
+
AudioData(data=open(os.path.join(workdir, name), "rb").read(), mime="audio/wav")
|
|
200
|
+
for name in names
|
|
201
|
+
] or [audio]
|
|
202
|
+
except subprocess.CalledProcessError as exc:
|
|
203
|
+
detail = exc.stderr.decode(errors="replace").strip().splitlines()
|
|
204
|
+
raise ItemError(
|
|
205
|
+
f"ffmpeg couldn't slice it ({detail[-1] if detail else 'unknown'})"
|
|
206
|
+
) from exc
|
|
207
|
+
finally:
|
|
208
|
+
with contextlib.suppress(OSError):
|
|
209
|
+
shutil.rmtree(workdir)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
_MEDIA_FLOOR_BYTES = 4_096 # icons, bullets, rules — decoration, not content
|
|
213
|
+
_OFFICE_MEDIA_DIRS = {".docx": "word/media/", ".pptx": "ppt/media/", ".xlsx": "xl/media/"}
|
|
214
|
+
_IMAGE_MIME_BY_NAME = {
|
|
215
|
+
".png": "image/png",
|
|
216
|
+
".jpg": "image/jpeg",
|
|
217
|
+
".jpeg": "image/jpeg",
|
|
218
|
+
".gif": "image/gif",
|
|
219
|
+
".webp": "image/webp",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass(frozen=True, slots=True)
|
|
224
|
+
class EmbeddedImage:
|
|
225
|
+
image: ImageData
|
|
226
|
+
where: str # "p.7 img.2" (PDF) / "img.3" (office zip)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass(frozen=True, slots=True)
|
|
230
|
+
class EmbeddedMedia:
|
|
231
|
+
images: tuple[EmbeddedImage, ...]
|
|
232
|
+
dropped_small: int # under the floor — counted, disclosed once
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def embedded_images(path: Path) -> EmbeddedMedia:
|
|
236
|
+
"""Images embedded inside a document (D29): office zips via the stdlib,
|
|
237
|
+
PDFs by walking XObjects for JPEG (DCTDecode) streams — passed through
|
|
238
|
+
byte-identical, never re-encoded."""
|
|
239
|
+
suffix = path.suffix.lower()
|
|
240
|
+
if suffix in _OFFICE_MEDIA_DIRS:
|
|
241
|
+
return _office_zip_images(path, _OFFICE_MEDIA_DIRS[suffix])
|
|
242
|
+
if suffix == ".pdf":
|
|
243
|
+
return _pdf_images(path)
|
|
244
|
+
raise ItemError(f"{path.name} isn't a document with embedded media (pdf/docx/pptx/xlsx)")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _office_zip_images(path: Path, media_dir: str) -> EmbeddedMedia:
|
|
248
|
+
import zipfile
|
|
249
|
+
from pathlib import Path
|
|
250
|
+
|
|
251
|
+
images: list[EmbeddedImage] = []
|
|
252
|
+
dropped = 0
|
|
253
|
+
try:
|
|
254
|
+
with zipfile.ZipFile(path) as archive:
|
|
255
|
+
names = sorted(n for n in archive.namelist() if n.startswith(media_dir))
|
|
256
|
+
for position, name in enumerate(names, start=1):
|
|
257
|
+
mime = _IMAGE_MIME_BY_NAME.get(Path(name).suffix.lower())
|
|
258
|
+
if mime is None:
|
|
259
|
+
continue # emf/wmf and friends — no model reads them
|
|
260
|
+
payload = archive.read(name)
|
|
261
|
+
if len(payload) < _MEDIA_FLOOR_BYTES:
|
|
262
|
+
dropped += 1
|
|
263
|
+
continue
|
|
264
|
+
images.append(EmbeddedImage(ImageData(payload, mime), f"img.{position}"))
|
|
265
|
+
except ItemError: # pragma: no cover — nothing above raises it
|
|
266
|
+
raise
|
|
267
|
+
except Exception as exc:
|
|
268
|
+
raise ItemError(f"{path.name} couldn't be opened as an office document ({exc})") from exc
|
|
269
|
+
return EmbeddedMedia(tuple(images), dropped)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _pdf_images(path: Path) -> EmbeddedMedia:
|
|
273
|
+
try:
|
|
274
|
+
from pypdf import PdfReader
|
|
275
|
+
except ImportError as exc:
|
|
276
|
+
raise MissingExtra(
|
|
277
|
+
"files",
|
|
278
|
+
"error: the document parser is unavailable — reinstall smartpipe\n"
|
|
279
|
+
" (pdfminer ships in the box; a broken environment is the only way here)",
|
|
280
|
+
) from exc
|
|
281
|
+
images: list[EmbeddedImage] = []
|
|
282
|
+
dropped = 0
|
|
283
|
+
try:
|
|
284
|
+
reader = PdfReader(str(path))
|
|
285
|
+
for page_number, page in enumerate(reader.pages, start=1):
|
|
286
|
+
xobjects = as_record(_pdf_lookup(_pdf_lookup(page, "/Resources"), "/XObject"))
|
|
287
|
+
if xobjects is None:
|
|
288
|
+
continue
|
|
289
|
+
position = 0
|
|
290
|
+
for key in sorted(xobjects):
|
|
291
|
+
stream = _pdf_resolve(xobjects.get(key))
|
|
292
|
+
if _pdf_lookup(stream, "/Subtype") != "/Image":
|
|
293
|
+
continue
|
|
294
|
+
filters = as_items(_pdf_lookup(stream, "/Filter"))
|
|
295
|
+
names = (
|
|
296
|
+
[str(entry) for entry in filters]
|
|
297
|
+
if filters is not None
|
|
298
|
+
else [str(_pdf_lookup(stream, "/Filter"))]
|
|
299
|
+
)
|
|
300
|
+
if "/DCTDecode" not in names:
|
|
301
|
+
continue # only JPEG streams pass through without re-encoding
|
|
302
|
+
position += 1
|
|
303
|
+
payload = getattr(stream, "_data", b"")
|
|
304
|
+
if not isinstance(payload, bytes):
|
|
305
|
+
continue
|
|
306
|
+
if len(payload) < _MEDIA_FLOOR_BYTES:
|
|
307
|
+
dropped += 1
|
|
308
|
+
continue
|
|
309
|
+
images.append(
|
|
310
|
+
EmbeddedImage(
|
|
311
|
+
ImageData(payload, "image/jpeg"), f"p.{page_number} img.{position}"
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
except MissingExtra:
|
|
315
|
+
raise
|
|
316
|
+
except Exception as exc:
|
|
317
|
+
raise ItemError(f"{path.name} couldn't be scanned for images ({exc})") from exc
|
|
318
|
+
return EmbeddedMedia(tuple(images), dropped)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _pdf_resolve(value: object) -> object:
|
|
322
|
+
"""Follow an IndirectObject reference; anything else passes through."""
|
|
323
|
+
resolver = getattr(value, "get_object", None)
|
|
324
|
+
return resolver() if callable(resolver) else value
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _pdf_lookup(mapping: object, key: str) -> object:
|
|
328
|
+
"""Duck lookup on pypdf's dict-like objects, reference-chased, None-safe."""
|
|
329
|
+
resolved = _pdf_resolve(mapping)
|
|
330
|
+
record = as_record(resolved)
|
|
331
|
+
if record is not None:
|
|
332
|
+
return _pdf_resolve(record.get(key))
|
|
333
|
+
getter = getattr(resolved, "get", None)
|
|
334
|
+
if not callable(getter):
|
|
335
|
+
return None
|
|
336
|
+
looked: object = getter(key)
|
|
337
|
+
return _pdf_resolve(looked)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
_VIDEO_NEEDS_FFMPEG = (
|
|
341
|
+
"ffmpeg is unavailable — reinstall smartpipe\n"
|
|
342
|
+
" (a static ffmpeg ships in the box; PATH ffmpeg also works)"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def ffmpeg_exe() -> str:
|
|
347
|
+
try:
|
|
348
|
+
from imageio_ffmpeg import get_ffmpeg_exe
|
|
349
|
+
|
|
350
|
+
return get_ffmpeg_exe()
|
|
351
|
+
except ImportError:
|
|
352
|
+
import shutil
|
|
353
|
+
|
|
354
|
+
exe = shutil.which("ffmpeg")
|
|
355
|
+
if exe is None:
|
|
356
|
+
raise ItemError(_VIDEO_NEEDS_FFMPEG) from None
|
|
357
|
+
return exe
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _ffprobe_duration(exe: str, source: str) -> float:
|
|
361
|
+
"""Parse "Duration: HH:MM:SS.cc" from ffmpeg's banner (no ffprobe needed)."""
|
|
362
|
+
import re
|
|
363
|
+
import subprocess
|
|
364
|
+
|
|
365
|
+
result = subprocess.run([exe, "-i", source], capture_output=True, check=False)
|
|
366
|
+
match = re.search(rb"Duration: (\d+):(\d+):(\d+\.?\d*)", result.stderr)
|
|
367
|
+
if match is None:
|
|
368
|
+
raise ItemError("ffmpeg couldn't read the video's duration")
|
|
369
|
+
hours, minutes, seconds = match.groups()
|
|
370
|
+
return int(hours) * 3600 + int(minutes) * 60 + float(seconds)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@dataclass(frozen=True, slots=True)
|
|
374
|
+
class VideoParts:
|
|
375
|
+
frames: tuple[ImageData, ...]
|
|
376
|
+
track: AudioData | None # None when the video is silent
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def video_to_parts(
|
|
380
|
+
video: VideoData, *, max_frames: int = 24, every_seconds: float | None = None
|
|
381
|
+
) -> VideoParts:
|
|
382
|
+
"""Frames + the audio track (D27/D36/D43). Default: 1 frame per second up
|
|
383
|
+
to ``max_frames``, evenly spread past the cap. ``every_seconds`` is a
|
|
384
|
+
DENSITY GUARANTEE — one frame per period, and the default cap lifts
|
|
385
|
+
(callers pass their own ``max_frames`` to keep a budget; the smaller wins).
|
|
386
|
+
|
|
387
|
+
Free and local (ffmpeg). Blocking — callers run it in a thread.
|
|
388
|
+
"""
|
|
389
|
+
import contextlib
|
|
390
|
+
import os
|
|
391
|
+
import shutil
|
|
392
|
+
import subprocess
|
|
393
|
+
import tempfile
|
|
394
|
+
from pathlib import Path
|
|
395
|
+
|
|
396
|
+
exe = ffmpeg_exe()
|
|
397
|
+
workdir = tempfile.mkdtemp(prefix="smartpipe-video-")
|
|
398
|
+
source = os.path.join(workdir, "source")
|
|
399
|
+
try:
|
|
400
|
+
with open(source, "wb") as handle:
|
|
401
|
+
handle.write(video.data)
|
|
402
|
+
duration = _ffprobe_duration(exe, source)
|
|
403
|
+
if every_seconds is not None:
|
|
404
|
+
# D43: the density guarantee — one frame per period, cap honored
|
|
405
|
+
rate = 1.0 / every_seconds
|
|
406
|
+
if duration > 0:
|
|
407
|
+
wanted = max(1, math.ceil(duration / every_seconds))
|
|
408
|
+
max_frames = min(max_frames, wanted) if max_frames else wanted
|
|
409
|
+
else:
|
|
410
|
+
# 1 fps for clips up to the cap; longer clips spread the cap evenly (D36)
|
|
411
|
+
rate = 1.0 if 0 < duration <= max_frames else max(max_frames / duration, 0.01)
|
|
412
|
+
subprocess.run(
|
|
413
|
+
[
|
|
414
|
+
exe,
|
|
415
|
+
"-loglevel",
|
|
416
|
+
"error",
|
|
417
|
+
"-i",
|
|
418
|
+
source,
|
|
419
|
+
"-vf",
|
|
420
|
+
f"fps={rate}",
|
|
421
|
+
"-frames:v",
|
|
422
|
+
str(max_frames),
|
|
423
|
+
os.path.join(workdir, "frame-%03d.jpg"),
|
|
424
|
+
],
|
|
425
|
+
check=True,
|
|
426
|
+
capture_output=True,
|
|
427
|
+
)
|
|
428
|
+
names = sorted(n for n in os.listdir(workdir) if n.startswith("frame-"))
|
|
429
|
+
images = tuple(
|
|
430
|
+
ImageData(Path(os.path.join(workdir, name)).read_bytes(), "image/jpeg")
|
|
431
|
+
for name in names
|
|
432
|
+
)
|
|
433
|
+
track_path = os.path.join(workdir, "track.wav")
|
|
434
|
+
probe = subprocess.run(
|
|
435
|
+
[
|
|
436
|
+
exe,
|
|
437
|
+
"-loglevel",
|
|
438
|
+
"error",
|
|
439
|
+
"-i",
|
|
440
|
+
source,
|
|
441
|
+
"-vn",
|
|
442
|
+
"-acodec",
|
|
443
|
+
"pcm_s16le",
|
|
444
|
+
"-ar",
|
|
445
|
+
"16000",
|
|
446
|
+
track_path,
|
|
447
|
+
],
|
|
448
|
+
check=False,
|
|
449
|
+
capture_output=True,
|
|
450
|
+
)
|
|
451
|
+
track = None
|
|
452
|
+
if probe.returncode == 0 and os.path.exists(track_path):
|
|
453
|
+
payload = Path(track_path).read_bytes()
|
|
454
|
+
if len(payload) > 44: # more than a bare wav header — a real track
|
|
455
|
+
track = AudioData(payload, "audio/wav")
|
|
456
|
+
if not images:
|
|
457
|
+
raise ItemError("ffmpeg produced no frames from this video")
|
|
458
|
+
return VideoParts(frames=images, track=track)
|
|
459
|
+
except ItemError:
|
|
460
|
+
raise
|
|
461
|
+
except subprocess.CalledProcessError as exc:
|
|
462
|
+
detail = exc.stderr.decode(errors="replace").strip().splitlines()
|
|
463
|
+
raise ItemError(
|
|
464
|
+
f"ffmpeg couldn't read this video ({detail[-1] if detail else 'unknown'})"
|
|
465
|
+
) from exc
|
|
466
|
+
except Exception as exc:
|
|
467
|
+
raise ItemError(f"video couldn't be converted ({exc})") from exc
|
|
468
|
+
finally:
|
|
469
|
+
with contextlib.suppress(OSError):
|
|
470
|
+
shutil.rmtree(workdir)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def slice_video(video: VideoData, *, seconds: int) -> list[VideoData]:
|
|
474
|
+
"""Duration slices of a video, stream-copied (fast, lossless) via ffmpeg."""
|
|
475
|
+
import contextlib
|
|
476
|
+
import os
|
|
477
|
+
import shutil
|
|
478
|
+
import subprocess
|
|
479
|
+
import tempfile
|
|
480
|
+
from pathlib import Path
|
|
481
|
+
|
|
482
|
+
exe = ffmpeg_exe()
|
|
483
|
+
workdir = tempfile.mkdtemp(prefix="smartpipe-vslice-")
|
|
484
|
+
source = os.path.join(workdir, "source.mp4")
|
|
485
|
+
pattern = os.path.join(workdir, "slice-%04d.mp4")
|
|
486
|
+
try:
|
|
487
|
+
with open(source, "wb") as handle:
|
|
488
|
+
handle.write(video.data)
|
|
489
|
+
subprocess.run(
|
|
490
|
+
[
|
|
491
|
+
exe,
|
|
492
|
+
"-loglevel",
|
|
493
|
+
"error",
|
|
494
|
+
"-i",
|
|
495
|
+
source,
|
|
496
|
+
"-f",
|
|
497
|
+
"segment",
|
|
498
|
+
"-segment_time",
|
|
499
|
+
str(seconds),
|
|
500
|
+
"-c",
|
|
501
|
+
"copy",
|
|
502
|
+
"-reset_timestamps",
|
|
503
|
+
"1",
|
|
504
|
+
pattern,
|
|
505
|
+
],
|
|
506
|
+
check=True,
|
|
507
|
+
capture_output=True,
|
|
508
|
+
)
|
|
509
|
+
names = sorted(n for n in os.listdir(workdir) if n.startswith("slice-"))
|
|
510
|
+
return [
|
|
511
|
+
VideoData(Path(os.path.join(workdir, name)).read_bytes(), "video/mp4") for name in names
|
|
512
|
+
] or [video]
|
|
513
|
+
except subprocess.CalledProcessError as exc:
|
|
514
|
+
detail = exc.stderr.decode(errors="replace").strip().splitlines()
|
|
515
|
+
raise ItemError(
|
|
516
|
+
f"ffmpeg couldn't slice this video ({detail[-1] if detail else 'unknown'})"
|
|
517
|
+
) from exc
|
|
518
|
+
finally:
|
|
519
|
+
with contextlib.suppress(OSError):
|
|
520
|
+
shutil.rmtree(workdir)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def whisper_size(environ: Mapping[str, str]) -> str:
|
|
524
|
+
"""The local whisper variant: ``SMARTPIPE_WHISPER_MODEL`` or the tiny default."""
|
|
525
|
+
return environ.get("SMARTPIPE_WHISPER_MODEL", "tiny")
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
_WHISPER_CACHE: dict[str, object] = {} # one loaded model per size, per process
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def transcribe_audio(audio: AudioData) -> str:
|
|
532
|
+
"""In-memory audio → transcript, locally, via faster-whisper (D20 rung 2).
|
|
533
|
+
|
|
534
|
+
The audio bytes never leave the machine; the first use of a model size
|
|
535
|
+
downloads its weights once (~75 MB for tiny). Blocking — callers run it in
|
|
536
|
+
a thread. ``MissingExtra`` propagates so the verb layer can name both fixes.
|
|
537
|
+
"""
|
|
538
|
+
import io
|
|
539
|
+
import os
|
|
540
|
+
|
|
541
|
+
try:
|
|
542
|
+
from faster_whisper import WhisperModel
|
|
543
|
+
except ImportError as exc:
|
|
544
|
+
raise MissingExtra( # whisper ships in core (D44); only a broken env lands here
|
|
545
|
+
"audio", "local transcription is unavailable — reinstall smartpipe"
|
|
546
|
+
) from exc
|
|
547
|
+
|
|
548
|
+
size = whisper_size(os.environ)
|
|
549
|
+
model = _WHISPER_CACHE.get(size)
|
|
550
|
+
if model is None:
|
|
551
|
+
from smartpipe.io import diagnostics
|
|
552
|
+
|
|
553
|
+
diagnostics.note(f"loading local whisper ({size}) — first use downloads the model")
|
|
554
|
+
model = WhisperModel(size, device="cpu", compute_type="int8")
|
|
555
|
+
_WHISPER_CACHE[size] = model
|
|
556
|
+
assert isinstance(model, WhisperModel)
|
|
557
|
+
try:
|
|
558
|
+
segments, _info = model.transcribe(io.BytesIO(audio.data))
|
|
559
|
+
return " ".join(segment.text.strip() for segment in segments).strip()
|
|
560
|
+
except MissingExtra: # pragma: no cover — nothing below raises it
|
|
561
|
+
raise
|
|
562
|
+
except Exception as exc:
|
|
563
|
+
raise ItemError(f"audio couldn't be transcribed ({exc})") from exc
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _via_markitdown(path: Path, *, extra: str, noun: str) -> str:
|
|
567
|
+
try:
|
|
568
|
+
from markitdown import MarkItDown
|
|
569
|
+
except ImportError as exc:
|
|
570
|
+
import sys
|
|
571
|
+
|
|
572
|
+
reason = (
|
|
573
|
+
"waiting on upstream Python 3.14 wheels — use Python 3.11-3.13 for these"
|
|
574
|
+
if sys.version_info >= (3, 14)
|
|
575
|
+
else "it ships in the box on this Python — reinstall smartpipe"
|
|
576
|
+
)
|
|
577
|
+
raise MissingExtra(extra, f"error: the {noun} parser is unavailable\n ({reason})") from exc
|
|
578
|
+
try:
|
|
579
|
+
result = MarkItDown().convert(str(path))
|
|
580
|
+
except Exception as exc: # markitdown raises many types; any of them is a parse failure
|
|
581
|
+
raise ItemError("parse error") from exc
|
|
582
|
+
return result.text_content
|
smartpipe/py.typed
ADDED
|
File without changes
|