panoscribe 0.4.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 (47) hide show
  1. panoscribe/__init__.py +10 -0
  2. panoscribe/acquire/__init__.py +1 -0
  3. panoscribe/acquire/downloader.py +46 -0
  4. panoscribe/acquire/photo.py +168 -0
  5. panoscribe/acquire/platform.py +30 -0
  6. panoscribe/acquire/playlist.py +105 -0
  7. panoscribe/api/__init__.py +0 -0
  8. panoscribe/api/server.py +267 -0
  9. panoscribe/asr/__init__.py +1 -0
  10. panoscribe/asr/protocol.py +34 -0
  11. panoscribe/asr/whisper.py +185 -0
  12. panoscribe/audio.py +96 -0
  13. panoscribe/batch.py +357 -0
  14. panoscribe/cli.py +494 -0
  15. panoscribe/config.py +326 -0
  16. panoscribe/device.py +60 -0
  17. panoscribe/errors.py +18 -0
  18. panoscribe/eval/__init__.py +1 -0
  19. panoscribe/eval/funnel.py +49 -0
  20. panoscribe/eval/junk.py +161 -0
  21. panoscribe/eval/models.py +36 -0
  22. panoscribe/eval/scoring.py +291 -0
  23. panoscribe/merge/__init__.py +6 -0
  24. panoscribe/merge/llm_cleanup.py +398 -0
  25. panoscribe/ocr/__init__.py +1 -0
  26. panoscribe/ocr/_text_match.py +46 -0
  27. panoscribe/ocr/bbox_aggregator.py +218 -0
  28. panoscribe/ocr/deduplicator.py +165 -0
  29. panoscribe/ocr/frame_sampler.py +210 -0
  30. panoscribe/ocr/preprocessor.py +42 -0
  31. panoscribe/ocr/protocol.py +69 -0
  32. panoscribe/ocr/rapid_ocr.py +440 -0
  33. panoscribe/ocr/ui_filter.py +223 -0
  34. panoscribe/output.py +350 -0
  35. panoscribe/pipeline.py +252 -0
  36. panoscribe/platforms/__init__.py +19 -0
  37. panoscribe/platforms/base.py +71 -0
  38. panoscribe/platforms/instagram.py +36 -0
  39. panoscribe/platforms/registry.py +39 -0
  40. panoscribe/platforms/tiktok.py +34 -0
  41. panoscribe/platforms/youtube.py +25 -0
  42. panoscribe/py.typed +0 -0
  43. panoscribe-0.4.0.dist-info/METADATA +382 -0
  44. panoscribe-0.4.0.dist-info/RECORD +47 -0
  45. panoscribe-0.4.0.dist-info/WHEEL +4 -0
  46. panoscribe-0.4.0.dist-info/entry_points.txt +2 -0
  47. panoscribe-0.4.0.dist-info/licenses/LICENSE +21 -0
panoscribe/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """panoscribe — transcribe any video with speech + on-screen text."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("panoscribe")
7
+ except PackageNotFoundError: # pragma: no cover — editable install without metadata
8
+ __version__ = "0.0.0+unknown"
9
+
10
+ __all__ = ["__version__"]
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,46 @@
1
+ """Video acquisition — local passthrough or yt-dlp download."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from pathlib import Path
8
+
9
+ from yt_dlp import YoutubeDL
10
+ from yt_dlp.utils import DownloadError
11
+
12
+ from panoscribe.errors import PanoScribeError
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ _URL_RE = re.compile(r"^https?://", re.IGNORECASE)
17
+
18
+
19
+ def download_video(source: str, temp_dir: Path) -> Path:
20
+ """Return a local path to the video referenced by ``source``.
21
+
22
+ If ``source`` is an existing local file, it is returned unchanged.
23
+ If it is an http(s) URL, yt-dlp downloads it into ``temp_dir`` and the
24
+ resulting path is returned. Any yt-dlp failure — as well as an unrecognised
25
+ source string — is raised as :class:`PanoScribeError` with a single-line
26
+ message (no traceback chain) so the CLI can surface it cleanly.
27
+ """
28
+ if Path(source).is_file():
29
+ logger.debug("Using local file passthrough: %s", source)
30
+ return Path(source)
31
+
32
+ if _URL_RE.match(source):
33
+ temp_dir.mkdir(parents=True, exist_ok=True)
34
+ ydl_opts = {
35
+ "outtmpl": str(temp_dir / "%(id)s.%(ext)s"),
36
+ "quiet": True,
37
+ "no_warnings": True,
38
+ }
39
+ try:
40
+ with YoutubeDL(ydl_opts) as ydl:
41
+ info = ydl.extract_info(source, download=True)
42
+ return Path(ydl.prepare_filename(info))
43
+ except DownloadError as e:
44
+ raise PanoScribeError(f"Download failed: {e}") from None
45
+
46
+ raise PanoScribeError(f"Invalid source: {source!r} is neither a file nor an http(s) URL")
@@ -0,0 +1,168 @@
1
+ """Photo-post acquisition -- gallery-dl download of image slideshows + audio.
2
+
3
+ TikTok ``/photo/`` posts are image slideshows with optional audio. yt-dlp cannot
4
+ download them; gallery-dl can. This module wraps gallery-dl for that purpose and
5
+ provides local-directory scanning as an alternative input path.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ from dataclasses import dataclass
15
+ from typing import TYPE_CHECKING
16
+
17
+ from panoscribe.errors import PanoScribeError
18
+
19
+ if TYPE_CHECKING:
20
+ from pathlib import Path
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _IMAGE_EXTS = frozenset({".jpg", ".jpeg", ".png", ".webp"})
25
+ _AUDIO_EXTS = frozenset({".mp3", ".m4a", ".aac", ".wav"})
26
+
27
+
28
+ def is_photo_post(source: str) -> bool:
29
+ """Return True if *source* looks like a TikTok photo-post URL.
30
+
31
+ Checks for ``/photo/`` in the URL path. Must be False for ``/video/`` URLs,
32
+ other platforms, and local file paths.
33
+ """
34
+ lower = source.lower()
35
+ if "tiktok.com" not in lower:
36
+ return False
37
+ return "/photo/" in lower
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class PhotoPost:
42
+ """Represents a downloaded photo post.
43
+
44
+ Attributes
45
+ ----------
46
+ image_paths:
47
+ Slide image file paths, sorted in presentation order (gallery-dl
48
+ zero-pads slide numbers, so lexical sort is correct).
49
+ audio_path:
50
+ Optional audio track (.mp3, .m4a, .aac, or .wav). ``None`` when the
51
+ post has no audio.
52
+ """
53
+
54
+ image_paths: tuple[Path, ...]
55
+ audio_path: Path | None
56
+
57
+
58
+ def _run_gallery_dl(dest: Path, source: str) -> None:
59
+ """Run gallery-dl as a subprocess, trying module invocation first.
60
+
61
+ Tries ``python -m gallery_dl`` first, then falls back to ``gallery-dl``
62
+ script. Raises :class:`PanoScribeError` with an install hint if both fail.
63
+ """
64
+ # Try module invocation first.
65
+ try:
66
+ result = subprocess.run(
67
+ [sys.executable, "-m", "gallery_dl", "-D", str(dest), source],
68
+ capture_output=True,
69
+ timeout=300,
70
+ )
71
+ if result.returncode == 0:
72
+ return
73
+ # Check module-specific failure immediately (result still bound).
74
+ stderr = result.stderr.decode(errors="replace") if result.stderr else ""
75
+ if "No module named" in stderr or "ModuleNotFoundError" in stderr:
76
+ raise PanoScribeError(
77
+ "photo posts need the optional extra - "
78
+ "`uv sync --extra photo` (or `pip install panoscribe[photo]`)"
79
+ )
80
+ except FileNotFoundError:
81
+ pass
82
+
83
+ # Fall back to script name.
84
+ gallery_dl_path = shutil.which("gallery-dl")
85
+ if gallery_dl_path is not None:
86
+ try:
87
+ result = subprocess.run(
88
+ [gallery_dl_path, "-D", str(dest), source],
89
+ capture_output=True,
90
+ timeout=300,
91
+ )
92
+ if result.returncode == 0:
93
+ return
94
+ except FileNotFoundError:
95
+ pass
96
+
97
+ hint = (
98
+ "photo posts need the optional extra - "
99
+ "`uv sync --extra photo` (or `pip install panoscribe[photo]`)"
100
+ )
101
+ raise PanoScribeError(f"gallery-dl failed - {hint}")
102
+
103
+
104
+ def download_photo_post(source: str, temp_dir: Path) -> PhotoPost:
105
+ """Download a TikTok photo-post from *source* into *temp_dir*.
106
+
107
+ gallery-dl may create extractor subdirectories under the base dir, so the
108
+ result is scanned recursively.
109
+
110
+ Parameters
111
+ ----------
112
+ source:
113
+ TikTok ``/photo/`` URL.
114
+ temp_dir:
115
+ Parent directory for downloaded files. A ``slides/`` subdirectory is
116
+ created inside it.
117
+
118
+ Returns
119
+ -------
120
+ PhotoPost
121
+ Sorted image paths (slide order) and optional audio file.
122
+ """
123
+ dest = temp_dir / "slides"
124
+ dest.mkdir(parents=True, exist_ok=True)
125
+ _run_gallery_dl(dest, source)
126
+
127
+ images = sorted(p for p in dest.rglob("*") if p.suffix.lower() in _IMAGE_EXTS)
128
+ if not images:
129
+ raise PanoScribeError("no slides downloaded - is this a photo post?")
130
+
131
+ audio: Path | None = None
132
+ for p in dest.rglob("*"):
133
+ if p.suffix.lower() in _AUDIO_EXTS:
134
+ audio = p
135
+ break
136
+
137
+ return PhotoPost(image_paths=tuple(images), audio_path=audio)
138
+
139
+
140
+ def scan_photo_dir(directory: Path) -> PhotoPost:
141
+ """Scan a user-provided directory for photo-post content.
142
+
143
+ Scans the top level of *directory* only (non-recursive) for images and
144
+ optional audio, returning a :class:`PhotoPost` in image-sort order.
145
+
146
+ Parameters
147
+ ----------
148
+ directory:
149
+ Local directory containing slide images and optionally an audio file.
150
+
151
+ Returns
152
+ -------
153
+ PhotoPost
154
+ Sorted image paths and optional audio file.
155
+ """
156
+ images = sorted(
157
+ p for p in directory.iterdir() if p.is_file() and p.suffix.lower() in _IMAGE_EXTS
158
+ )
159
+ if not images:
160
+ raise PanoScribeError(f"no image files found in {directory}")
161
+
162
+ audio: Path | None = None
163
+ for p in directory.iterdir():
164
+ if p.is_file() and p.suffix.lower() in _AUDIO_EXTS:
165
+ audio = p
166
+ break
167
+
168
+ return PhotoPost(image_paths=tuple(images), audio_path=audio)
@@ -0,0 +1,30 @@
1
+ """Platform detection from source URLs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+
8
+ class Platform(StrEnum):
9
+ """Known source platforms for video acquisition."""
10
+
11
+ TIKTOK = "tiktok"
12
+ YOUTUBE = "youtube"
13
+ INSTAGRAM = "instagram"
14
+ UNKNOWN = "unknown"
15
+ GENERIC = "generic"
16
+
17
+
18
+ def detect_platform(source: str) -> Platform:
19
+ """Infer the source platform from a URL or file path.
20
+
21
+ Returns :class:`Platform.UNKNOWN` for local files and unrecognized URLs.
22
+ """
23
+ s = source.lower()
24
+ if "tiktok.com" in s:
25
+ return Platform.TIKTOK
26
+ if "youtube.com" in s or "youtu.be" in s:
27
+ return Platform.YOUTUBE
28
+ if "instagram.com" in s:
29
+ return Platform.INSTAGRAM
30
+ return Platform.UNKNOWN
@@ -0,0 +1,105 @@
1
+ """Playlist / channel URL expansion via yt-dlp metadata-only extraction.
2
+
3
+ Sprint 8.1: feeds the per-line URL list of ``transcribe-many`` through this
4
+ module before reconcile so that a single playlist URL becomes the list of its
5
+ constituent video URLs, in feed order.
6
+
7
+ The single public entry point :func:`expand_playlist` returns:
8
+
9
+ - ``list[str]``: one URL per video for a recognised playlist / channel,
10
+ in yt-dlp's natural feed order.
11
+ - ``None``: input is a single-video URL, a local file path / non-URL string,
12
+ or any extraction error (DownloadError, ExtractorError, network /
13
+ SSL-level failure, malformed response). Callers treat ``None`` as
14
+ "not a playlist — keep the original line".
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+
21
+ from yt_dlp import YoutubeDL
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ _YDL_OPTS: dict[str, object] = {
26
+ "quiet": True,
27
+ "no_warnings": True,
28
+ "extract_flat": True,
29
+ "skip_download": True,
30
+ }
31
+
32
+
33
+ def _entry_to_url(entry: dict) -> str | None:
34
+ """Resolve one playlist entry to a URL via the locked fallback chain.
35
+
36
+ Order: ``entry["url"]`` → ``entry["webpage_url"]`` → reconstructed
37
+ ``https://www.youtube.com/watch?v={id}`` if ``entry["id"]`` is present.
38
+ Returns ``None`` when all three are falsy / missing.
39
+ """
40
+ return (
41
+ entry.get("url")
42
+ or entry.get("webpage_url")
43
+ or (f"https://www.youtube.com/watch?v={entry['id']}" if entry.get("id") else None)
44
+ )
45
+
46
+
47
+ def expand_playlist(url: str) -> list[str] | None:
48
+ """Return expanded video URLs for a playlist/channel URL, else ``None``.
49
+
50
+ Behaviour:
51
+
52
+ - Local-file / non-URL detection is scheme-based: any ``url`` that does
53
+ not start with ``http://`` or ``https://`` returns ``None`` without
54
+ touching yt-dlp (deterministic on Windows backslash paths and on
55
+ Linux absolute paths that may or may not exist).
56
+ - For http(s) URLs, runs ``YoutubeDL.extract_info`` with ``extract_flat``
57
+ so only metadata is fetched (no video download).
58
+ - If the response's ``_type`` is not exactly ``"playlist"``, returns
59
+ ``None`` — single-video URLs flow through the per-video pipeline
60
+ unchanged.
61
+ - Entries are coerced via ``list(info.get("entries") or [])`` to handle
62
+ yt-dlp's ``LazyList`` return shape.
63
+ - Each entry is resolved via :func:`_entry_to_url`. Entries without any
64
+ resolvable URL are dropped + logged at WARNING.
65
+ - Empty playlists return ``[]`` (caller will splice nothing in place).
66
+ - Any exception during extraction → WARNING log + ``None``. Returning
67
+ ``None`` rather than raising lets the caller treat extraction failures
68
+ identically to "not a playlist": the original URL passes through to
69
+ the per-video pipeline, which surfaces the actual extractor error.
70
+ - One level of expansion only: nested ``_type == "playlist"`` entries are
71
+ treated as opaque single URLs (no recursion).
72
+ """
73
+ if not url.startswith(("http://", "https://")):
74
+ return None
75
+
76
+ try:
77
+ with YoutubeDL(_YDL_OPTS) as ydl:
78
+ info = ydl.extract_info(url, download=False, process=False)
79
+ except Exception as e:
80
+ # yt-dlp raises a wide hierarchy (DownloadError, ExtractorError, ...)
81
+ # plus network/SSL-level errors; treat all as "not a playlist".
82
+ logger.warning("Playlist expansion failed for %s (%s: %s)", url, type(e).__name__, e)
83
+ return None
84
+
85
+ if not isinstance(info, dict):
86
+ return None
87
+
88
+ if info.get("_type") != "playlist":
89
+ return None
90
+
91
+ entries = list(info.get("entries") or [])
92
+
93
+ out: list[str] = []
94
+ for entry in entries:
95
+ if not isinstance(entry, dict):
96
+ logger.warning("Skipping non-dict playlist entry: %r", entry)
97
+ continue
98
+ resolved = _entry_to_url(entry)
99
+ if not resolved:
100
+ logger.warning("Skipping playlist entry with no resolvable URL: %r", entry)
101
+ continue
102
+ out.append(resolved)
103
+
104
+ logger.debug("Expanded %s to %d video URL(s)", url, len(out))
105
+ return out
File without changes
@@ -0,0 +1,267 @@
1
+ """FastAPI server providing an HTTP API for panoscribe transcription.
2
+
3
+ v1 non-goals
4
+ ------------
5
+ - No persistence: restarting the server loses all jobs.
6
+ - No cancellation / job deletion.
7
+ - No authentication.
8
+ - No result formats other than JSON (output_format is hard-coded to "json").
9
+ - No file upload --- sources are URLs or local paths.
10
+ - No SSE / webhooks --- clients poll GET /jobs/{id}.
11
+ - Shutdown hang: the executor uses non-daemon threads, so Ctrl+C/uvicorn
12
+ shutdown blocks until the in-flight job finishes. Acceptable v1 behaviour.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import threading
20
+ from concurrent.futures import ThreadPoolExecutor
21
+ from datetime import UTC, datetime
22
+ from enum import StrEnum
23
+ from pathlib import Path # noqa: TC003 — runtime use in file ops
24
+ from typing import TYPE_CHECKING, Any, Protocol
25
+ from uuid import uuid4
26
+
27
+ from fastapi import FastAPI, HTTPException
28
+ from pydantic import BaseModel
29
+
30
+ from panoscribe import __version__
31
+ from panoscribe.config import PanoScribeConfig
32
+ from panoscribe.errors import PanoScribeError
33
+ from panoscribe.pipeline import process_single_video
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import Callable
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ class JobStatus(StrEnum):
42
+ """Status of a transcription job."""
43
+
44
+ queued = "queued"
45
+ running = "running"
46
+ done = "done"
47
+ failed = "failed"
48
+
49
+
50
+ class JobRequest(BaseModel):
51
+ """Request body for POST /jobs."""
52
+
53
+ source: str
54
+ language: str | None = None
55
+ translate: bool | None = None
56
+ ocr: bool | None = None
57
+ ocr_language: str | None = None
58
+ platform: str | None = None
59
+
60
+
61
+ class Job(BaseModel):
62
+ """Represents a transcription job."""
63
+
64
+ id: str
65
+ source: str
66
+ status: JobStatus
67
+ created_at: datetime
68
+ error: str | None = None
69
+ result: dict[str, Any] | None = None
70
+
71
+
72
+ class _JobStore:
73
+ """Thread-safe in-memory job store."""
74
+
75
+ def __init__(self) -> None:
76
+ self._jobs: dict[str, Job] = {}
77
+ self._lock = threading.Lock()
78
+
79
+ def add(self, job: Job) -> None:
80
+ with self._lock:
81
+ self._jobs[job.id] = job
82
+
83
+ def get(self, job_id: str) -> Job | None:
84
+ with self._lock:
85
+ return self._jobs.get(job_id)
86
+
87
+ def all(self) -> list[Job]:
88
+ with self._lock:
89
+ return list(self._jobs.values())
90
+
91
+ def update(
92
+ self,
93
+ job_id: str,
94
+ *,
95
+ status: JobStatus | None = None,
96
+ error: str | None = None,
97
+ result: dict[str, Any] | None = None,
98
+ ) -> None:
99
+ """Atomically update a job's fields."""
100
+ with self._lock:
101
+ job = self._jobs.get(job_id)
102
+ if job is None:
103
+ return
104
+ if status is not None:
105
+ job.status = status
106
+ if error is not None:
107
+ job.error = error
108
+ if result is not None:
109
+ job.result = result
110
+
111
+
112
+ class _Executor(Protocol):
113
+ """Minimal executor protocol for dependency injection.
114
+
115
+ Tests pass a synchronous executor so job state is deterministic at
116
+ POST-return time; production uses ``ThreadPoolExecutor(max_workers=1)``.
117
+ """
118
+
119
+ def submit(
120
+ self,
121
+ fn: Callable[..., Any],
122
+ /,
123
+ *args: Any,
124
+ **kwargs: Any,
125
+ ) -> Any: ...
126
+ def shutdown(self, wait: bool = True) -> None: ...
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Worker
131
+ # ---------------------------------------------------------------------------
132
+
133
+
134
+ def _run_job(
135
+ job_id: str,
136
+ source: str,
137
+ request: JobRequest,
138
+ base_config: PanoScribeConfig,
139
+ base_temp: Path,
140
+ store: _JobStore,
141
+ ) -> None:
142
+ """Run the transcription pipeline and store the result.
143
+
144
+ Single-worker design: the executor's ``max_workers=1`` guarantees that
145
+ only one job runs at a time, which matches GPU serialisation and avoids
146
+ temp-dir clobber (each job gets a unique ``temp_dir`` below).
147
+ """
148
+ # Per-job temp_dir so concurrent jobs (even with max_workers=1 the
149
+ # guarantee is architectural, not accidental) do not share workdirs.
150
+ updates: dict[str, Any] = {"temp_dir": base_temp / job_id}
151
+ if request.language is not None:
152
+ updates["whisper_language"] = request.language
153
+ if request.translate is not None:
154
+ updates["whisper_task"] = "translate" if request.translate else "transcribe"
155
+ if request.ocr_language is not None:
156
+ updates["ocr_language"] = request.ocr_language
157
+ if request.platform is not None:
158
+ updates["platform_profile"] = request.platform
159
+
160
+ cfg = base_config.model_copy(update=updates)
161
+ ocr_active = request.ocr if request.ocr is not None else cfg.ocr_enabled
162
+
163
+ # Output goes to a dedicated api-results subdirectory under the base
164
+ # temp dir so it survives per-job temp_dir cleanup (process_single_video
165
+ # deletes cfg.temp_dir in its finally block).
166
+ output_dir = base_temp / "api-results"
167
+ output_dir.mkdir(parents=True, exist_ok=True)
168
+ output_path = output_dir / f"{job_id}.json"
169
+
170
+ store.update(job_id, status=JobStatus.running)
171
+
172
+ try:
173
+ process_single_video(
174
+ source,
175
+ cfg,
176
+ output_path,
177
+ ocr_active=ocr_active,
178
+ output_format="json",
179
+ )
180
+ except PanoScribeError as e:
181
+ store.update(job_id, status=JobStatus.failed, error=str(e))
182
+ return
183
+ except Exception:
184
+ logger.exception("Unexpected error processing job %s", job_id)
185
+ store.update(job_id, status=JobStatus.failed, error="Internal pipeline error")
186
+ return
187
+
188
+ try:
189
+ result_data: dict[str, Any] = json.loads(output_path.read_text(encoding="utf-8"))
190
+ except Exception:
191
+ logger.exception("Failed to read pipeline output for job %s", job_id)
192
+ store.update(job_id, status=JobStatus.failed, error="Failed to read pipeline output")
193
+ return
194
+
195
+ store.update(job_id, status=JobStatus.done, result=result_data)
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # App factory
200
+ # ---------------------------------------------------------------------------
201
+
202
+
203
+ def create_app(
204
+ config: PanoScribeConfig | None = None,
205
+ *,
206
+ executor: _Executor | None = None,
207
+ ) -> FastAPI:
208
+ """Create a FastAPI application for the panoscribe API.
209
+
210
+ Parameters
211
+ ----------
212
+ config:
213
+ Optional config override. Defaults to ``PanoScribeConfig()`` (env-only).
214
+ executor:
215
+ Optional executor override. Production default is
216
+ ``ThreadPoolExecutor(max_workers=1)`` --- single worker because GPU
217
+ serialisation and the per-job temp-dir contract both demand it.
218
+ Pass a synchronous executor in tests so job state is final at
219
+ POST-return time without polling.
220
+ """
221
+ if config is None:
222
+ config = PanoScribeConfig()
223
+ if executor is None:
224
+ executor = ThreadPoolExecutor(max_workers=1)
225
+
226
+ store = _JobStore()
227
+ # Resolve once so all per-job temp_dir values are absolute.
228
+ base_temp = config.temp_dir.resolve()
229
+
230
+ app = FastAPI(title="panoscribe API", version=__version__)
231
+
232
+ @app.get("/healthz")
233
+ def _healthz() -> dict[str, str]:
234
+ return {"status": "ok", "version": __version__}
235
+
236
+ @app.post("/jobs", status_code=202)
237
+ def _submit_job(request: JobRequest) -> dict[str, str]:
238
+ job = Job(
239
+ id=uuid4().hex,
240
+ source=request.source,
241
+ status=JobStatus.queued,
242
+ created_at=datetime.now(UTC),
243
+ )
244
+ store.add(job)
245
+ executor.submit(_run_job, job.id, request.source, request, config, base_temp, store)
246
+ return {"job_id": job.id}
247
+
248
+ @app.get("/jobs")
249
+ def _list_jobs() -> list[dict[str, str]]:
250
+ return [
251
+ {
252
+ "id": j.id,
253
+ "source": j.source,
254
+ "status": j.status.value,
255
+ "created_at": j.created_at.isoformat(),
256
+ }
257
+ for j in store.all()
258
+ ]
259
+
260
+ @app.get("/jobs/{job_id}")
261
+ def _get_job(job_id: str) -> Job:
262
+ job = store.get(job_id)
263
+ if job is None:
264
+ raise HTTPException(status_code=404, detail="Job not found")
265
+ return job
266
+
267
+ return app
@@ -0,0 +1 @@
1
+ """ASR (Automatic Speech Recognition) subpackage."""
@@ -0,0 +1,34 @@
1
+ """ASR engine protocol — structural subtyping for swappable backends.
2
+
3
+ Uses :class:`typing.Protocol` (not ABC) so a future remote or alternative
4
+ transcription backend satisfies the interface by having the right method,
5
+ without needing to inherit from a base class. Marked ``@runtime_checkable``
6
+ so ``isinstance(engine, AsrEngine)`` works at runtime.
7
+
8
+ The method signature mirrors :class:`panoscribe.asr.whisper.WhisperTranscriber`
9
+ exactly — every backend must expose:
10
+ * ``transcribe(audio_path)`` -> ``(segments, detected_language)``
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
16
+
17
+ if TYPE_CHECKING:
18
+ from pathlib import Path
19
+
20
+ from panoscribe.output import TranscriptSegment
21
+
22
+
23
+ @runtime_checkable
24
+ class AsrEngine(Protocol):
25
+ """Structural protocol for ASR (speech-to-text) backends.
26
+
27
+ Any object whose methods match this protocol — whether via inheritance,
28
+ duck typing, or explicit implementation — is accepted as an ``AsrEngine``
29
+ at runtime via ``isinstance(engine, AsrEngine)``.
30
+ """
31
+
32
+ def transcribe(self, audio_path: Path) -> tuple[list[TranscriptSegment], str]:
33
+ """Run ASR on ``audio_path``; return (segments, detected_language)."""
34
+ ...