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.
Files changed (126) hide show
  1. smartpipe/__init__.py +6 -0
  2. smartpipe/__main__.py +8 -0
  3. smartpipe/assets/probe.png +0 -0
  4. smartpipe/assets/probe.txt +1 -0
  5. smartpipe/assets/probe.wav +0 -0
  6. smartpipe/cli/__init__.py +5 -0
  7. smartpipe/cli/auth_cmd.py +78 -0
  8. smartpipe/cli/cache_cmd.py +60 -0
  9. smartpipe/cli/chart_cmd.py +60 -0
  10. smartpipe/cli/cite_cmd.py +26 -0
  11. smartpipe/cli/cluster_cmd.py +102 -0
  12. smartpipe/cli/completions.py +91 -0
  13. smartpipe/cli/config_cmd.py +234 -0
  14. smartpipe/cli/diff_cmd.py +100 -0
  15. smartpipe/cli/distinct_cmd.py +94 -0
  16. smartpipe/cli/doctor_cmd.py +207 -0
  17. smartpipe/cli/echo_cmd.py +44 -0
  18. smartpipe/cli/embed_cmd.py +80 -0
  19. smartpipe/cli/extend_cmd.py +138 -0
  20. smartpipe/cli/filter_cmd.py +87 -0
  21. smartpipe/cli/getschema_cmd.py +31 -0
  22. smartpipe/cli/input_options.py +113 -0
  23. smartpipe/cli/interrupts.py +92 -0
  24. smartpipe/cli/join_cmd.py +162 -0
  25. smartpipe/cli/map_cmd.py +150 -0
  26. smartpipe/cli/outliers_cmd.py +82 -0
  27. smartpipe/cli/probe_cmd.py +223 -0
  28. smartpipe/cli/reduce_cmd.py +129 -0
  29. smartpipe/cli/root.py +281 -0
  30. smartpipe/cli/run_cmd.py +136 -0
  31. smartpipe/cli/sample_cmd.py +35 -0
  32. smartpipe/cli/schema_cmd.py +75 -0
  33. smartpipe/cli/screens.py +231 -0
  34. smartpipe/cli/sem_file.py +453 -0
  35. smartpipe/cli/sort_cmd.py +33 -0
  36. smartpipe/cli/split_cmd.py +76 -0
  37. smartpipe/cli/summarize_cmd.py +37 -0
  38. smartpipe/cli/top_k_cmd.py +97 -0
  39. smartpipe/cli/usage_cmd.py +66 -0
  40. smartpipe/cli/where_cmd.py +36 -0
  41. smartpipe/config/__init__.py +5 -0
  42. smartpipe/config/credentials.py +118 -0
  43. smartpipe/config/display.py +70 -0
  44. smartpipe/config/doctor.py +58 -0
  45. smartpipe/config/paths.py +38 -0
  46. smartpipe/config/store.py +252 -0
  47. smartpipe/container.py +439 -0
  48. smartpipe/core/__init__.py +5 -0
  49. smartpipe/core/errors.py +57 -0
  50. smartpipe/core/jsontools.py +56 -0
  51. smartpipe/engine/__init__.py +9 -0
  52. smartpipe/engine/aggregate.py +234 -0
  53. smartpipe/engine/blocking.py +44 -0
  54. smartpipe/engine/chart.py +143 -0
  55. smartpipe/engine/chunking.py +161 -0
  56. smartpipe/engine/clustering.py +94 -0
  57. smartpipe/engine/predicate.py +330 -0
  58. smartpipe/engine/prompts.py +601 -0
  59. smartpipe/engine/ranking.py +62 -0
  60. smartpipe/engine/runner.py +175 -0
  61. smartpipe/engine/schema.py +208 -0
  62. smartpipe/engine/schema_dsl.py +144 -0
  63. smartpipe/engine/tally.py +53 -0
  64. smartpipe/engine/timebin.py +67 -0
  65. smartpipe/engine/units.py +43 -0
  66. smartpipe/engine/windows.py +78 -0
  67. smartpipe/io/__init__.py +9 -0
  68. smartpipe/io/diagnostics.py +148 -0
  69. smartpipe/io/inputs.py +44 -0
  70. smartpipe/io/items.py +149 -0
  71. smartpipe/io/leaderboard.py +52 -0
  72. smartpipe/io/metering.py +180 -0
  73. smartpipe/io/progress.py +140 -0
  74. smartpipe/io/readers.py +455 -0
  75. smartpipe/io/text.py +40 -0
  76. smartpipe/io/tty.py +88 -0
  77. smartpipe/io/usage.py +214 -0
  78. smartpipe/io/writers.py +340 -0
  79. smartpipe/models/__init__.py +5 -0
  80. smartpipe/models/anthropic_adapter.py +149 -0
  81. smartpipe/models/base.py +170 -0
  82. smartpipe/models/budget.py +94 -0
  83. smartpipe/models/cache.py +132 -0
  84. smartpipe/models/gemini_native.py +196 -0
  85. smartpipe/models/http_support.py +77 -0
  86. smartpipe/models/jina.py +98 -0
  87. smartpipe/models/local_embed.py +76 -0
  88. smartpipe/models/ollama.py +204 -0
  89. smartpipe/models/openai_codex.py +237 -0
  90. smartpipe/models/openai_compat.py +328 -0
  91. smartpipe/models/openai_oauth.py +366 -0
  92. smartpipe/models/resolve.py +78 -0
  93. smartpipe/models/retry.py +69 -0
  94. smartpipe/models/stt.py +80 -0
  95. smartpipe/models/windows.py +116 -0
  96. smartpipe/parsing/__init__.py +10 -0
  97. smartpipe/parsing/detect.py +178 -0
  98. smartpipe/parsing/extract.py +582 -0
  99. smartpipe/py.typed +0 -0
  100. smartpipe/verbs/__init__.py +5 -0
  101. smartpipe/verbs/chart.py +153 -0
  102. smartpipe/verbs/cluster.py +220 -0
  103. smartpipe/verbs/common.py +468 -0
  104. smartpipe/verbs/convert.py +251 -0
  105. smartpipe/verbs/diff.py +206 -0
  106. smartpipe/verbs/distinct.py +164 -0
  107. smartpipe/verbs/embed.py +166 -0
  108. smartpipe/verbs/extend.py +180 -0
  109. smartpipe/verbs/filter.py +191 -0
  110. smartpipe/verbs/getschema.py +135 -0
  111. smartpipe/verbs/join.py +413 -0
  112. smartpipe/verbs/map.py +315 -0
  113. smartpipe/verbs/outliers.py +119 -0
  114. smartpipe/verbs/reduce.py +428 -0
  115. smartpipe/verbs/sample.py +52 -0
  116. smartpipe/verbs/sortverb.py +63 -0
  117. smartpipe/verbs/split.py +333 -0
  118. smartpipe/verbs/summarize.py +60 -0
  119. smartpipe/verbs/top_k.py +318 -0
  120. smartpipe/verbs/where.py +47 -0
  121. smartpipe_cli-1.3.0.dist-info/METADATA +192 -0
  122. smartpipe_cli-1.3.0.dist-info/RECORD +126 -0
  123. smartpipe_cli-1.3.0.dist-info/WHEEL +4 -0
  124. smartpipe_cli-1.3.0.dist-info/entry_points.txt +3 -0
  125. smartpipe_cli-1.3.0.dist-info/licenses/LICENSE +201 -0
  126. 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
@@ -0,0 +1,5 @@
1
+ """Per-verb orchestration: the async glue between readers, the engine, and models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__: list[str] = []