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.
- panoscribe/__init__.py +10 -0
- panoscribe/acquire/__init__.py +1 -0
- panoscribe/acquire/downloader.py +46 -0
- panoscribe/acquire/photo.py +168 -0
- panoscribe/acquire/platform.py +30 -0
- panoscribe/acquire/playlist.py +105 -0
- panoscribe/api/__init__.py +0 -0
- panoscribe/api/server.py +267 -0
- panoscribe/asr/__init__.py +1 -0
- panoscribe/asr/protocol.py +34 -0
- panoscribe/asr/whisper.py +185 -0
- panoscribe/audio.py +96 -0
- panoscribe/batch.py +357 -0
- panoscribe/cli.py +494 -0
- panoscribe/config.py +326 -0
- panoscribe/device.py +60 -0
- panoscribe/errors.py +18 -0
- panoscribe/eval/__init__.py +1 -0
- panoscribe/eval/funnel.py +49 -0
- panoscribe/eval/junk.py +161 -0
- panoscribe/eval/models.py +36 -0
- panoscribe/eval/scoring.py +291 -0
- panoscribe/merge/__init__.py +6 -0
- panoscribe/merge/llm_cleanup.py +398 -0
- panoscribe/ocr/__init__.py +1 -0
- panoscribe/ocr/_text_match.py +46 -0
- panoscribe/ocr/bbox_aggregator.py +218 -0
- panoscribe/ocr/deduplicator.py +165 -0
- panoscribe/ocr/frame_sampler.py +210 -0
- panoscribe/ocr/preprocessor.py +42 -0
- panoscribe/ocr/protocol.py +69 -0
- panoscribe/ocr/rapid_ocr.py +440 -0
- panoscribe/ocr/ui_filter.py +223 -0
- panoscribe/output.py +350 -0
- panoscribe/pipeline.py +252 -0
- panoscribe/platforms/__init__.py +19 -0
- panoscribe/platforms/base.py +71 -0
- panoscribe/platforms/instagram.py +36 -0
- panoscribe/platforms/registry.py +39 -0
- panoscribe/platforms/tiktok.py +34 -0
- panoscribe/platforms/youtube.py +25 -0
- panoscribe/py.typed +0 -0
- panoscribe-0.4.0.dist-info/METADATA +382 -0
- panoscribe-0.4.0.dist-info/RECORD +47 -0
- panoscribe-0.4.0.dist-info/WHEEL +4 -0
- panoscribe-0.4.0.dist-info/entry_points.txt +2 -0
- 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
|
panoscribe/api/server.py
ADDED
|
@@ -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
|
+
...
|