porter-workflow 0.2.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.
- porter/__init__.py +143 -0
- porter/asr/__init__.py +59 -0
- porter/asr/base.py +169 -0
- porter/asr/bcut.py +304 -0
- porter/asr/chain.py +442 -0
- porter/asr/google_web.py +296 -0
- porter/asr/platform_subs.py +123 -0
- porter/asr/videocaptioner.py +241 -0
- porter/asr/whisper_api.py +193 -0
- porter/asr/whisper_local.py +360 -0
- porter/config.py +398 -0
- porter/context.py +105 -0
- porter/doctor/__init__.py +55 -0
- porter/doctor/guides.py +166 -0
- porter/doctor/probes.py +673 -0
- porter/errors.py +138 -0
- porter/events.py +194 -0
- porter/jobs/__init__.py +44 -0
- porter/jobs/records.py +695 -0
- porter/jobs/store.py +332 -0
- porter/logging.py +157 -0
- porter/media/__init__.py +75 -0
- porter/media/burn.py +479 -0
- porter/media/encode.py +472 -0
- porter/media/enhance.py +102 -0
- porter/media/ffmpeg.py +367 -0
- porter/media/prepare.py +210 -0
- porter/media/probe.py +189 -0
- porter/media/standardize.py +199 -0
- porter/models/__init__.py +25 -0
- porter/models/inspection.py +116 -0
- porter/models/materials.py +128 -0
- porter/models/metadata.py +47 -0
- porter/models/plan.py +113 -0
- porter/models/request.py +165 -0
- porter/models/subtitle.py +120 -0
- porter/pipeline.py +505 -0
- porter/plan.py +373 -0
- porter/platforms/__init__.py +60 -0
- porter/platforms/base.py +746 -0
- porter/platforms/bilibili.py +57 -0
- porter/platforms/downloader.py +89 -0
- porter/platforms/inspector.py +280 -0
- porter/platforms/instagram.py +49 -0
- porter/platforms/local.py +282 -0
- porter/platforms/registry.py +231 -0
- porter/platforms/spec.py +158 -0
- porter/platforms/tiktok.py +43 -0
- porter/platforms/titles.py +132 -0
- porter/platforms/urls.py +223 -0
- porter/platforms/x.py +37 -0
- porter/platforms/ydl.py +431 -0
- porter/platforms/youtube.py +47 -0
- porter/ports.py +158 -0
- porter/py.typed +1 -0
- porter/subtitles/__init__.py +71 -0
- porter/subtitles/ass.py +274 -0
- porter/subtitles/phrasing.py +765 -0
- porter/subtitles/srt.py +432 -0
- porter/subtitles/transcript.py +286 -0
- porter/translate/__init__.py +67 -0
- porter/translate/base.py +199 -0
- porter/translate/bing.py +467 -0
- porter/translate/chain.py +477 -0
- porter/translate/google.py +354 -0
- porter/translate/llm.py +324 -0
- porter/translate/mymemory.py +165 -0
- porter/translate/reuse.py +176 -0
- porter/translate/videocaptioner.py +273 -0
- porter/utils/__init__.py +17 -0
- porter/utils/text.py +61 -0
- porter/utils/time.py +53 -0
- porter_cli/__init__.py +13 -0
- porter_cli/__main__.py +17 -0
- porter_cli/app.py +131 -0
- porter_cli/commands/__init__.py +14 -0
- porter_cli/commands/config.py +131 -0
- porter_cli/commands/doctor.py +130 -0
- porter_cli/commands/inspect.py +86 -0
- porter_cli/commands/jobs.py +216 -0
- porter_cli/commands/plan.py +198 -0
- porter_cli/commands/run.py +277 -0
- porter_cli/render.py +226 -0
- porter_mcp/__init__.py +22 -0
- porter_mcp/limits.py +34 -0
- porter_mcp/server.py +90 -0
- porter_mcp/shutdown.py +204 -0
- porter_mcp/stdout_guard.py +153 -0
- porter_mcp/tools/__init__.py +49 -0
- porter_mcp/tools/config.py +99 -0
- porter_mcp/tools/docs.py +282 -0
- porter_mcp/tools/doctor.py +118 -0
- porter_mcp/tools/inspect.py +123 -0
- porter_mcp/tools/jobs.py +435 -0
- porter_mcp/tools/meta.py +40 -0
- porter_mcp/tools/plan.py +106 -0
- porter_mcp/tools/stages.py +357 -0
- porter_workflow-0.2.0.dist-info/METADATA +309 -0
- porter_workflow-0.2.0.dist-info/RECORD +103 -0
- porter_workflow-0.2.0.dist-info/WHEEL +4 -0
- porter_workflow-0.2.0.dist-info/entry_points.txt +3 -0
- porter_workflow-0.2.0.dist-info/licenses/LICENSE +21 -0
- porter_workflow-0.2.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +175 -0
porter/__init__.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""porter — automated video localization engine.
|
|
2
|
+
|
|
3
|
+
This package is the **library** layer. It contains no command-line parsing and
|
|
4
|
+
never writes to stdout: two thin frontends consume it.
|
|
5
|
+
|
|
6
|
+
* ``porter_cli`` — the ``porter`` console script, for humans.
|
|
7
|
+
* ``porter_mcp`` — the ``porter-mcp`` MCP server, for AI agents.
|
|
8
|
+
|
|
9
|
+
Architectural rules (enforced by ``lint-imports`` and a test that scans for
|
|
10
|
+
``print`` calls):
|
|
11
|
+
|
|
12
|
+
1. This package must never import ``porter_cli`` or ``porter_mcp``.
|
|
13
|
+
2. Nothing under this package may call ``print()`` — in an MCP stdio server,
|
|
14
|
+
stdout *is* the JSON-RPC channel. Use :func:`porter.logging.get_logger`.
|
|
15
|
+
3. Nothing in this package may assume a deployment layout (agent skill
|
|
16
|
+
directories, caller-supplied config paths, ...). Configuration is resolved
|
|
17
|
+
from explicit arguments, environment variables and platform user dirs.
|
|
18
|
+
|
|
19
|
+
Importing this module is cheap and dependency-free. The heavier symbols
|
|
20
|
+
(``JobOptions``, ``Pipeline``, ...) are resolved lazily on first attribute
|
|
21
|
+
access via :pep:`562` — so ``import porter; porter.__version__`` needs nothing
|
|
22
|
+
but the standard library.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from typing import TYPE_CHECKING, Any
|
|
28
|
+
|
|
29
|
+
from porter.errors import (
|
|
30
|
+
AsrError,
|
|
31
|
+
CapabilityMissingError,
|
|
32
|
+
ConfigError,
|
|
33
|
+
ExtractionError,
|
|
34
|
+
JobCancelled,
|
|
35
|
+
MediaError,
|
|
36
|
+
PorterError,
|
|
37
|
+
RenderError,
|
|
38
|
+
SubtitleError,
|
|
39
|
+
TranslationError,
|
|
40
|
+
UnsupportedPlatformError,
|
|
41
|
+
)
|
|
42
|
+
from porter.logging import configure as configure_logging
|
|
43
|
+
from porter.logging import get_logger
|
|
44
|
+
|
|
45
|
+
__version__ = "0.2.0"
|
|
46
|
+
|
|
47
|
+
#: Public API, resolved lazily. Maps attribute name -> defining module.
|
|
48
|
+
_LAZY_EXPORTS: dict[str, str] = {
|
|
49
|
+
# context
|
|
50
|
+
"RunContext": "porter.context",
|
|
51
|
+
# events
|
|
52
|
+
"ArtifactKind": "porter.events",
|
|
53
|
+
"Event": "porter.events",
|
|
54
|
+
"EventSink": "porter.events",
|
|
55
|
+
"JobState": "porter.events",
|
|
56
|
+
"Phase": "porter.events",
|
|
57
|
+
# models
|
|
58
|
+
"BurnMode": "porter.models",
|
|
59
|
+
"BurnResult": "porter.models",
|
|
60
|
+
"JobOptions": "porter.models",
|
|
61
|
+
"JobRequest": "porter.models",
|
|
62
|
+
"JobResult": "porter.models",
|
|
63
|
+
"RawMaterials": "porter.models",
|
|
64
|
+
"SubtitleItem": "porter.models",
|
|
65
|
+
"SubtitleSet": "porter.models",
|
|
66
|
+
"TaskLayout": "porter.models",
|
|
67
|
+
"TranscriptSentence": "porter.models",
|
|
68
|
+
"VideoMetadata": "porter.models",
|
|
69
|
+
# orchestration
|
|
70
|
+
"Pipeline": "porter.pipeline",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if TYPE_CHECKING: # pragma: no cover - import-time only, for type checkers
|
|
74
|
+
from porter.context import RunContext
|
|
75
|
+
from porter.events import ArtifactKind, Event, EventSink, JobState, Phase
|
|
76
|
+
from porter.models import (
|
|
77
|
+
BurnMode,
|
|
78
|
+
BurnResult,
|
|
79
|
+
JobOptions,
|
|
80
|
+
JobRequest,
|
|
81
|
+
JobResult,
|
|
82
|
+
RawMaterials,
|
|
83
|
+
SubtitleItem,
|
|
84
|
+
SubtitleSet,
|
|
85
|
+
TaskLayout,
|
|
86
|
+
TranscriptSentence,
|
|
87
|
+
VideoMetadata,
|
|
88
|
+
)
|
|
89
|
+
from porter.pipeline import Pipeline
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def __getattr__(name: str) -> Any:
|
|
93
|
+
"""Resolve the lazily-exported public API (PEP 562)."""
|
|
94
|
+
module_path = _LAZY_EXPORTS.get(name)
|
|
95
|
+
if module_path is None:
|
|
96
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
97
|
+
|
|
98
|
+
import importlib
|
|
99
|
+
|
|
100
|
+
module = importlib.import_module(module_path)
|
|
101
|
+
value = getattr(module, name)
|
|
102
|
+
globals()[name] = value # cache so subsequent lookups skip __getattr__
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def __dir__() -> list[str]:
|
|
107
|
+
return sorted({*globals(), *_LAZY_EXPORTS})
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
__all__ = [
|
|
111
|
+
"ArtifactKind",
|
|
112
|
+
"AsrError",
|
|
113
|
+
"BurnMode",
|
|
114
|
+
"BurnResult",
|
|
115
|
+
"CapabilityMissingError",
|
|
116
|
+
"ConfigError",
|
|
117
|
+
"Event",
|
|
118
|
+
"EventSink",
|
|
119
|
+
"ExtractionError",
|
|
120
|
+
"JobCancelled",
|
|
121
|
+
"JobOptions",
|
|
122
|
+
"JobRequest",
|
|
123
|
+
"JobResult",
|
|
124
|
+
"JobState",
|
|
125
|
+
"MediaError",
|
|
126
|
+
"Phase",
|
|
127
|
+
"Pipeline",
|
|
128
|
+
"PorterError",
|
|
129
|
+
"RawMaterials",
|
|
130
|
+
"RenderError",
|
|
131
|
+
"RunContext",
|
|
132
|
+
"SubtitleError",
|
|
133
|
+
"SubtitleItem",
|
|
134
|
+
"SubtitleSet",
|
|
135
|
+
"TaskLayout",
|
|
136
|
+
"TranscriptSentence",
|
|
137
|
+
"TranslationError",
|
|
138
|
+
"UnsupportedPlatformError",
|
|
139
|
+
"VideoMetadata",
|
|
140
|
+
"__version__",
|
|
141
|
+
"configure_logging",
|
|
142
|
+
"get_logger",
|
|
143
|
+
]
|
porter/asr/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Speech-to-text backends and the fallback chain.
|
|
2
|
+
|
|
3
|
+
``chain.py``
|
|
4
|
+
:class:`AsrChain` — implements :class:`~porter.ports.Transcriber`. Takes the
|
|
5
|
+
platform's own subtitle track when PREPARE fetched one, and otherwise walks
|
|
6
|
+
the engine chain, skipping backends whose ``available()`` is ``False``.
|
|
7
|
+
``platform_subs.py``
|
|
8
|
+
Reads that fetched track. Deliberately **not** a backend: it is a file, with
|
|
9
|
+
no engine to probe and no ordering question.
|
|
10
|
+
``whisper_api.py``
|
|
11
|
+
OpenAI-compatible Whisper endpoint. Requires the ``[llm]`` extra.
|
|
12
|
+
``whisper_local.py``
|
|
13
|
+
★ Local Whisper through ``faster-whisper`` (MIT, no PyTorch). Requires the
|
|
14
|
+
``[asr-local]`` extra. The only backend that needs no key, no network and no
|
|
15
|
+
third-party service, and the only one whose ``endpoint_verified`` is true by
|
|
16
|
+
construction rather than by measurement.
|
|
17
|
+
``bcut.py``
|
|
18
|
+
Bilibili Bcut. Key-free, chunked upload, its own session so a configured proxy
|
|
19
|
+
cannot stall the upload. **Unverified endpoint.**
|
|
20
|
+
``google_web.py``
|
|
21
|
+
Google Web STT with recursive VAD slicing, so long dialogue does not lose its
|
|
22
|
+
tail. Requires the ``[stt]`` extra. **Unverified endpoint.**
|
|
23
|
+
``videocaptioner.py``
|
|
24
|
+
★ Optional adapter around the **external** ``videocaptioner`` CLI.
|
|
25
|
+
GPL-3.0 and ``python<3.13``, therefore never a declared dependency and only
|
|
26
|
+
ever reached through ``subprocess``. An absent binary drops the backend from
|
|
27
|
+
the chain without a user-visible warning.
|
|
28
|
+
``base.py``
|
|
29
|
+
The backend protocol, the shared :class:`~porter.asr.base.AsrOutcome`, and the
|
|
30
|
+
cue helpers every backend needs.
|
|
31
|
+
|
|
32
|
+
"Unverified endpoint" is a claim about the *wire format*, not the structure. Those
|
|
33
|
+
two modules port v0.1's request/response handling for services that were
|
|
34
|
+
reverse-engineered rather than documented; they cannot be exercised in an offline
|
|
35
|
+
test suite and may already be dead. Their availability probes, error mapping,
|
|
36
|
+
timeouts and cancellation handling are deliberate and tested.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from porter.asr.base import AsrBackend, AsrBackendError, AsrOutcome
|
|
40
|
+
from porter.asr.bcut import BcutBackend
|
|
41
|
+
from porter.asr.chain import AsrChain
|
|
42
|
+
from porter.asr.google_web import GoogleWebBackend
|
|
43
|
+
from porter.asr.platform_subs import load_platform_subtitles
|
|
44
|
+
from porter.asr.videocaptioner import VideoCaptionerBackend
|
|
45
|
+
from porter.asr.whisper_api import WhisperApiBackend
|
|
46
|
+
from porter.asr.whisper_local import WhisperLocalBackend
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"AsrBackend",
|
|
50
|
+
"AsrBackendError",
|
|
51
|
+
"AsrChain",
|
|
52
|
+
"AsrOutcome",
|
|
53
|
+
"BcutBackend",
|
|
54
|
+
"GoogleWebBackend",
|
|
55
|
+
"VideoCaptionerBackend",
|
|
56
|
+
"WhisperApiBackend",
|
|
57
|
+
"WhisperLocalBackend",
|
|
58
|
+
"load_platform_subtitles",
|
|
59
|
+
]
|
porter/asr/base.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""The narrow contract every ASR engine implements.
|
|
2
|
+
|
|
3
|
+
:class:`~porter.ports.AsrBackend` in ``porter.ports`` is the *pipeline's* view;
|
|
4
|
+
this module is the *backend author's* view, plus the shared error type and the
|
|
5
|
+
one helper every backend needs.
|
|
6
|
+
|
|
7
|
+
## Why a separate error type
|
|
8
|
+
|
|
9
|
+
The chain has to answer "should I try the next backend?" and it cannot do that
|
|
10
|
+
from an arbitrary exception. A backend that returns an empty list, one that
|
|
11
|
+
raises ``TimeoutError``, and one that parses a response into nothing all look the
|
|
12
|
+
same from outside, and the difference decides whether falling through is right.
|
|
13
|
+
|
|
14
|
+
So backends raise :class:`AsrBackendError` for *expected* failure — endpoint
|
|
15
|
+
down, quota exhausted, malformed response — and let genuinely unexpected
|
|
16
|
+
exceptions (a bug in our own code) propagate. The chain catches the first and
|
|
17
|
+
logs the second, which keeps a typo from being silently swallowed by a fallback
|
|
18
|
+
loop.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Protocol, runtime_checkable
|
|
26
|
+
|
|
27
|
+
from porter.context import RunContext
|
|
28
|
+
from porter.models.subtitle import SubtitleItem
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"CHUNK_SECONDS",
|
|
32
|
+
"MAX_AUDIO_BYTES",
|
|
33
|
+
"AsrBackend",
|
|
34
|
+
"AsrBackendError",
|
|
35
|
+
"AsrOutcome",
|
|
36
|
+
"coerce_items",
|
|
37
|
+
"parse_srt_items",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
#: Upload chunk ceiling used by the cloud backends. Bilibili's endpoint rejects
|
|
41
|
+
#: anything larger; the others are happy with it.
|
|
42
|
+
MAX_AUDIO_BYTES = 100 * 1024 * 1024
|
|
43
|
+
|
|
44
|
+
#: Long audio is sliced rather than uploaded whole, because the free endpoints
|
|
45
|
+
#: time out on a 40-minute file and the failure looks like a network problem.
|
|
46
|
+
CHUNK_SECONDS = 480.0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class AsrBackendError(Exception):
|
|
50
|
+
"""An expected backend failure. The chain moves on to the next engine.
|
|
51
|
+
|
|
52
|
+
Carries ``backend`` so the log line names the engine that failed rather than
|
|
53
|
+
reporting a bare message, which matters when five backends have each failed
|
|
54
|
+
for a different reason.
|
|
55
|
+
|
|
56
|
+
## The rule every backend must obey
|
|
57
|
+
|
|
58
|
+
**Every expected failure becomes an ``AsrBackendError``.** The chain catches
|
|
59
|
+
exactly ``AsrBackendError`` and ``PorterError``; anything else propagates and
|
|
60
|
+
kills the process with a traceback.
|
|
61
|
+
|
|
62
|
+
That is deliberate -- a chain-wide ``except Exception`` would swallow real
|
|
63
|
+
bugs (a typo, a bad unpack) and report them as "backend unavailable", which is
|
|
64
|
+
the hardest kind of failure to diagnose. But it puts the burden on the
|
|
65
|
+
backend, and transport layers are where it gets missed: ``requests`` raises
|
|
66
|
+
``RequestException``, ``urllib`` raises ``http.client.HTTPException`` (a
|
|
67
|
+
truncated chunked response is ``IncompleteRead``, which is neither an
|
|
68
|
+
``OSError`` nor a ``RequestError``), and a library may raise something else
|
|
69
|
+
entirely. Map them all.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(self, backend: str, message: str, /, **details: object) -> None:
|
|
73
|
+
super().__init__(message)
|
|
74
|
+
self.backend = backend
|
|
75
|
+
self.message = message
|
|
76
|
+
self.details = details
|
|
77
|
+
|
|
78
|
+
def __str__(self) -> str:
|
|
79
|
+
base = f"[{self.backend}] {self.message}"
|
|
80
|
+
if self.details:
|
|
81
|
+
rendered = ", ".join(f"{key}={value!r}" for key, value in self.details.items())
|
|
82
|
+
return f"{base} ({rendered})"
|
|
83
|
+
return base
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class AsrOutcome:
|
|
88
|
+
"""What a backend produced. Cues plus an optional provenance note.
|
|
89
|
+
|
|
90
|
+
``used_asr`` is on the outcome rather than inferred by the chain because only
|
|
91
|
+
the backend knows: a platform-subtitle backend did no recognition at all, and
|
|
92
|
+
reporting that as "ASR was used" would mislabel the source for the operator.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
items: list[SubtitleItem]
|
|
96
|
+
used_asr: bool = True
|
|
97
|
+
#: Free-text provenance for the log and the job report, e.g. "whisper-1".
|
|
98
|
+
origin: str = ""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@runtime_checkable
|
|
102
|
+
class AsrBackend(Protocol):
|
|
103
|
+
"""One speech-to-text engine.
|
|
104
|
+
|
|
105
|
+
``available()`` must be cheap and must never raise — it is called for every
|
|
106
|
+
backend on every job to decide the chain, so a backend that raises there
|
|
107
|
+
breaks the chain's ability to skip it.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
name: str
|
|
111
|
+
|
|
112
|
+
#: Whether this engine's wire format has been verified against the live
|
|
113
|
+
#: service.
|
|
114
|
+
#:
|
|
115
|
+
#: ``available()`` cannot answer this: it probes *local* facts (a key is set, a
|
|
116
|
+
#: binary is on PATH), which say nothing about whether the remote endpoint
|
|
117
|
+
#: still speaks the protocol this code was written for. ``bcut`` and
|
|
118
|
+
#: ``google_web`` are reverse-engineered and answered with empty results on
|
|
119
|
+
#: every probe made while building this port, so they report ``available`` and
|
|
120
|
+
#: still cannot transcribe.
|
|
121
|
+
#:
|
|
122
|
+
#: ``False`` is not "broken" -- it is "nobody has checked", which is exactly
|
|
123
|
+
#: what a caller needs to know before promising a user that a job will finish.
|
|
124
|
+
endpoint_verified: bool
|
|
125
|
+
|
|
126
|
+
def available(self, ctx: RunContext) -> bool:
|
|
127
|
+
"""Whether this engine can run right now. Never raises."""
|
|
128
|
+
...
|
|
129
|
+
|
|
130
|
+
def transcribe(self, audio: Path, ctx: RunContext) -> AsrOutcome:
|
|
131
|
+
"""Recognise ``audio``.
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
AsrBackendError: For expected failure. The chain tries the next
|
|
135
|
+
backend.
|
|
136
|
+
"""
|
|
137
|
+
...
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def coerce_items(items: list[SubtitleItem]) -> list[SubtitleItem]:
|
|
141
|
+
"""Renumber cues 1..n and drop empties.
|
|
142
|
+
|
|
143
|
+
Every backend needs this and each v0.1 copy did it slightly differently — one
|
|
144
|
+
kept the original indices, one renumbered, one left blank cues in. Renumbering
|
|
145
|
+
is the correct choice: the generators write ``index`` into the SRT, and a
|
|
146
|
+
duplicated or gapped index produces a file that some players refuse.
|
|
147
|
+
"""
|
|
148
|
+
cleaned = [
|
|
149
|
+
item
|
|
150
|
+
for item in items
|
|
151
|
+
if item.source_text.strip() and item.end_ms > item.start_ms
|
|
152
|
+
]
|
|
153
|
+
for position, item in enumerate(cleaned, start=1):
|
|
154
|
+
item.index = position
|
|
155
|
+
return cleaned
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def parse_srt_items(text: str) -> list[SubtitleItem]:
|
|
159
|
+
"""Parse SRT into cues. Returns ``[]`` for anything unparseable.
|
|
160
|
+
|
|
161
|
+
Deliberately total: a backend that returns a malformed body should fall
|
|
162
|
+
through the chain, not raise a parse error that reads like a bug in porter.
|
|
163
|
+
"""
|
|
164
|
+
from porter.subtitles.srt import parse_srt
|
|
165
|
+
|
|
166
|
+
try:
|
|
167
|
+
return coerce_items(parse_srt(text))
|
|
168
|
+
except (ValueError, IndexError): # pragma: no cover - parse_srt is total
|
|
169
|
+
return []
|
porter/asr/bcut.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
"""UNVERIFIED ENDPOINT. This ports v0.1's request/response handling for a
|
|
2
|
+
key-free endpoint that was reverse-engineered, not documented. It cannot be
|
|
3
|
+
exercised in an offline test suite and may already be dead. The structure
|
|
4
|
+
(availability probe, error mapping, cancellation, timeout) is deliberate and
|
|
5
|
+
tested; the wire format is best-effort and unvalidated.
|
|
6
|
+
|
|
7
|
+
Measured 2026-09-22: the API host answers (``resource/create`` returns HTTP 200),
|
|
8
|
+
but a real 10-minute transcription produced no usable utterances, so this path is
|
|
9
|
+
not working either. Unlike Google Web, the failure here is ambiguous -- it could
|
|
10
|
+
be a quota, a changed field, or a rejected upload -- so this module keeps its
|
|
11
|
+
"unverified" label rather than claiming the endpoint is dead.
|
|
12
|
+
|
|
13
|
+
Bilibili's Bcut speech-to-text. It requires no key and no account, which is why
|
|
14
|
+
it sits in the free end of the fallback chain, but the ``rubick-interface`` API
|
|
15
|
+
is undocumented: the field names (``in_boss_key``, ``ResourceFileType``,
|
|
16
|
+
``Etags``) are Bilibili's own capitalisation, and the polling ``model_id`` is
|
|
17
|
+
``7`` while every other step sends ``8``. Both quirks are preserved verbatim
|
|
18
|
+
because "fixing" an undocumented protocol is how you get an endpoint that
|
|
19
|
+
returns 200 and does nothing.
|
|
20
|
+
|
|
21
|
+
The session deliberately sets ``trust_env = False``. Bilibili's object storage
|
|
22
|
+
is domestic (China), and a developer's corporate or VPN proxy turns a 3-second
|
|
23
|
+
chunk upload into a stall that looks like an endpoint outage.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
import requests
|
|
33
|
+
|
|
34
|
+
from porter.asr.base import AsrBackendError, AsrOutcome, coerce_items
|
|
35
|
+
from porter.context import RunContext
|
|
36
|
+
from porter.logging import get_logger
|
|
37
|
+
from porter.models.subtitle import SubtitleItem
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"API_BASE",
|
|
41
|
+
"MODEL_ID",
|
|
42
|
+
"POLL_ATTEMPTS",
|
|
43
|
+
"POLL_INTERVAL",
|
|
44
|
+
"BcutBackend",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
_logger = get_logger(__name__)
|
|
48
|
+
|
|
49
|
+
NAME = "bcut"
|
|
50
|
+
|
|
51
|
+
#: v0.1's endpoint, model id, retry budget and interval, unchanged.
|
|
52
|
+
API_BASE = "https://member.bilibili.com/x/bcut/rubick-interface"
|
|
53
|
+
MODEL_ID = "8"
|
|
54
|
+
POLL_ATTEMPTS = 60
|
|
55
|
+
POLL_INTERVAL = 2.0
|
|
56
|
+
|
|
57
|
+
#: v0.1's per-request timeouts, unchanged: the small control calls get 15-20s
|
|
58
|
+
#: and the chunk upload gets 60s.
|
|
59
|
+
TIMEOUT_CREATE = 15.0
|
|
60
|
+
TIMEOUT_UPLOAD = 60.0
|
|
61
|
+
TIMEOUT_COMMIT = 20.0
|
|
62
|
+
TIMEOUT_TASK = 20.0
|
|
63
|
+
TIMEOUT_RESULT = 15.0
|
|
64
|
+
|
|
65
|
+
_HEADERS = {
|
|
66
|
+
"User-Agent": "Bilibili/1.0.0 (https://www.bilibili.com)",
|
|
67
|
+
"Content-Type": "application/json",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _data_object(response: requests.Response, what: str) -> dict[str, Any]:
|
|
72
|
+
"""Extract the ``data`` object from a Bilibili envelope.
|
|
73
|
+
|
|
74
|
+
Bilibili answers ``{"code": 0, "data": {...}}`` and encodes failures inside
|
|
75
|
+
``code`` even at HTTP 200, so a missing ``data`` is a real failure rather
|
|
76
|
+
than a reason to read a default.
|
|
77
|
+
"""
|
|
78
|
+
try:
|
|
79
|
+
body = response.json()
|
|
80
|
+
except ValueError as exc:
|
|
81
|
+
raise AsrBackendError(NAME, f"{what} returned malformed JSON: {exc}") from exc
|
|
82
|
+
if not isinstance(body, dict):
|
|
83
|
+
raise AsrBackendError(NAME, f"{what} returned a non-object JSON body")
|
|
84
|
+
data = body.get("data")
|
|
85
|
+
if not isinstance(data, dict):
|
|
86
|
+
raise AsrBackendError(NAME, f"{what} response is missing the data object")
|
|
87
|
+
return data
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class BcutBackend:
|
|
91
|
+
"""Bilibili Bcut. Chunked upload, then poll for the transcript."""
|
|
92
|
+
|
|
93
|
+
name = NAME
|
|
94
|
+
endpoint_verified = False
|
|
95
|
+
|
|
96
|
+
def available(self, ctx: RunContext) -> bool:
|
|
97
|
+
"""Whether ``requests`` is importable. Never raises.
|
|
98
|
+
|
|
99
|
+
Bcut needs no key, so there is nothing else to probe. The check is
|
|
100
|
+
written against the module attribute rather than an import statement so
|
|
101
|
+
a host (or a test) that has torn the dependency out degrades to
|
|
102
|
+
``False`` instead of raising.
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
return bool(getattr(requests, "Session", None))
|
|
106
|
+
except Exception: # available() must never raise; log and degrade.
|
|
107
|
+
_logger.error("could not inspect the requests module; Bcut unavailable", exc_info=True)
|
|
108
|
+
return False
|
|
109
|
+
|
|
110
|
+
def transcribe(self, audio: Path, ctx: RunContext) -> AsrOutcome:
|
|
111
|
+
"""Upload ``audio`` to Bcut and return its utterances as cues.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
AsrBackendError: A missing/empty audio file, a transport or HTTP
|
|
115
|
+
failure, a malformed envelope, or an exhausted poll budget.
|
|
116
|
+
"""
|
|
117
|
+
ctx.check_cancelled()
|
|
118
|
+
if getattr(requests, "Session", None) is None:
|
|
119
|
+
raise AsrBackendError(
|
|
120
|
+
self.name,
|
|
121
|
+
"the 'requests' package is required for the Bcut backend",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
if not audio.exists():
|
|
125
|
+
raise AsrBackendError(self.name, f"audio file does not exist: {audio}")
|
|
126
|
+
try:
|
|
127
|
+
audio_data = audio.read_bytes()
|
|
128
|
+
except OSError as exc:
|
|
129
|
+
raise AsrBackendError(self.name, f"audio file could not be read: {exc}") from exc
|
|
130
|
+
if not audio_data:
|
|
131
|
+
raise AsrBackendError(self.name, "audio file is empty", path=str(audio))
|
|
132
|
+
|
|
133
|
+
# trust_env=False: see the module docstring. A configured proxy stalls on
|
|
134
|
+
# Bilibili's domestic object storage.
|
|
135
|
+
session = requests.Session()
|
|
136
|
+
session.trust_env = False
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
download_url = self._upload(session, audio, audio_data)
|
|
140
|
+
task_id = self._create_task(session, download_url)
|
|
141
|
+
result = self._poll(session, task_id, ctx)
|
|
142
|
+
except requests.RequestException as exc:
|
|
143
|
+
raise AsrBackendError(self.name, f"Bcut request failed: {exc}") from exc
|
|
144
|
+
except ValueError as exc:
|
|
145
|
+
raise AsrBackendError(self.name, f"Bcut returned malformed JSON: {exc}") from exc
|
|
146
|
+
|
|
147
|
+
return self._outcome(result)
|
|
148
|
+
|
|
149
|
+
# -- steps --------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
def _upload(self, session: Any, audio: Path, audio_data: bytes) -> str:
|
|
152
|
+
"""Steps 1-3: create the resource, PUT each chunk, commit the upload."""
|
|
153
|
+
payload = json.dumps(
|
|
154
|
+
{
|
|
155
|
+
"type": 2,
|
|
156
|
+
"name": audio.name,
|
|
157
|
+
"size": len(audio_data),
|
|
158
|
+
"ResourceFileType": audio.suffix.lstrip(".") or "wav",
|
|
159
|
+
"model_id": MODEL_ID,
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
resp = session.post(
|
|
163
|
+
f"{API_BASE}/resource/create",
|
|
164
|
+
data=payload,
|
|
165
|
+
headers=_HEADERS,
|
|
166
|
+
timeout=TIMEOUT_CREATE,
|
|
167
|
+
)
|
|
168
|
+
resp.raise_for_status()
|
|
169
|
+
data = _data_object(resp, "resource/create")
|
|
170
|
+
if "upload_urls" not in data:
|
|
171
|
+
raise AsrBackendError(self.name, "resource/create did not return upload_urls")
|
|
172
|
+
|
|
173
|
+
upload_urls = data["upload_urls"]
|
|
174
|
+
per_size = data["per_size"]
|
|
175
|
+
clips = len(upload_urls)
|
|
176
|
+
|
|
177
|
+
etags: list[str] = []
|
|
178
|
+
for clip in range(clips):
|
|
179
|
+
start = clip * per_size
|
|
180
|
+
end = (clip + 1) * per_size
|
|
181
|
+
part_resp = session.put(
|
|
182
|
+
upload_urls[clip],
|
|
183
|
+
data=audio_data[start:end],
|
|
184
|
+
headers=_HEADERS,
|
|
185
|
+
timeout=TIMEOUT_UPLOAD,
|
|
186
|
+
)
|
|
187
|
+
part_resp.raise_for_status()
|
|
188
|
+
etag = part_resp.headers.get("Etag")
|
|
189
|
+
if etag:
|
|
190
|
+
etags.append(etag)
|
|
191
|
+
|
|
192
|
+
commit_data = json.dumps(
|
|
193
|
+
{
|
|
194
|
+
"InBossKey": data["in_boss_key"],
|
|
195
|
+
"ResourceId": data["resource_id"],
|
|
196
|
+
"Etags": ",".join(etags),
|
|
197
|
+
"UploadId": data["upload_id"],
|
|
198
|
+
"model_id": MODEL_ID,
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
commit_resp = session.post(
|
|
202
|
+
f"{API_BASE}/resource/create/complete",
|
|
203
|
+
data=commit_data,
|
|
204
|
+
headers=_HEADERS,
|
|
205
|
+
timeout=TIMEOUT_COMMIT,
|
|
206
|
+
)
|
|
207
|
+
commit_resp.raise_for_status()
|
|
208
|
+
download_url = _data_object(commit_resp, "resource/create/complete").get("download_url")
|
|
209
|
+
if not download_url:
|
|
210
|
+
raise AsrBackendError(self.name, "resource/create/complete did not return a download_url")
|
|
211
|
+
return str(download_url)
|
|
212
|
+
|
|
213
|
+
def _create_task(self, session: Any, download_url: str) -> str:
|
|
214
|
+
"""Step 4: submit the transcription task."""
|
|
215
|
+
task_resp = session.post(
|
|
216
|
+
f"{API_BASE}/task",
|
|
217
|
+
data=json.dumps({"resource": download_url, "model_id": MODEL_ID}),
|
|
218
|
+
headers=_HEADERS,
|
|
219
|
+
timeout=TIMEOUT_TASK,
|
|
220
|
+
)
|
|
221
|
+
task_resp.raise_for_status()
|
|
222
|
+
task_id = _data_object(task_resp, "task").get("task_id")
|
|
223
|
+
if not task_id:
|
|
224
|
+
raise AsrBackendError(self.name, "task creation did not return a task_id")
|
|
225
|
+
return str(task_id)
|
|
226
|
+
|
|
227
|
+
def _poll(self, session: Any, task_id: str, ctx: RunContext) -> dict[str, Any]:
|
|
228
|
+
"""Step 5: poll until ``state == 4``.
|
|
229
|
+
|
|
230
|
+
``ctx.cancel.wait`` replaces v0.1's bare ``time.sleep(2)`` so that Ctrl-C
|
|
231
|
+
interrupts the 120-second budget on the next tick instead of after it.
|
|
232
|
+
``model_id`` is ``7`` here, matching v0.1 and the endpoint, not the
|
|
233
|
+
``8`` used everywhere else.
|
|
234
|
+
"""
|
|
235
|
+
for _ in range(POLL_ATTEMPTS):
|
|
236
|
+
if ctx.cancel.wait(POLL_INTERVAL):
|
|
237
|
+
ctx.check_cancelled()
|
|
238
|
+
ctx.check_cancelled()
|
|
239
|
+
|
|
240
|
+
q_resp = session.get(
|
|
241
|
+
f"{API_BASE}/task/result",
|
|
242
|
+
params={"model_id": 7, "task_id": task_id},
|
|
243
|
+
headers=_HEADERS,
|
|
244
|
+
timeout=TIMEOUT_RESULT,
|
|
245
|
+
)
|
|
246
|
+
q_resp.raise_for_status()
|
|
247
|
+
q_data = _data_object(q_resp, "task/result")
|
|
248
|
+
if q_data.get("state") == 4:
|
|
249
|
+
result = q_data.get("result", "{}")
|
|
250
|
+
parsed = json.loads(result) if isinstance(result, str) else result
|
|
251
|
+
if not isinstance(parsed, dict):
|
|
252
|
+
raise AsrBackendError(self.name, "task/result result is not a JSON object")
|
|
253
|
+
return parsed
|
|
254
|
+
|
|
255
|
+
raise AsrBackendError(
|
|
256
|
+
self.name,
|
|
257
|
+
f"Bcut did not finish within {POLL_ATTEMPTS * POLL_INTERVAL:.0f}s",
|
|
258
|
+
task_id=task_id,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
# -- result -------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
def _outcome(self, result: dict[str, Any]) -> AsrOutcome:
|
|
264
|
+
"""Turn the ``utterances`` array into cues.
|
|
265
|
+
|
|
266
|
+
v0.1 built an SRT string here and returned ``False`` for an empty result.
|
|
267
|
+
Returning ``AsrOutcome`` with no items is not possible: the chain reads
|
|
268
|
+
an empty list as "this backend produced nothing", which loses *why*, so
|
|
269
|
+
an empty or malformed payload is an explicit failure.
|
|
270
|
+
"""
|
|
271
|
+
utterances = result.get("utterances")
|
|
272
|
+
if not isinstance(utterances, list):
|
|
273
|
+
raise AsrBackendError(self.name, "Bcut result is missing the utterances array")
|
|
274
|
+
|
|
275
|
+
items: list[SubtitleItem] = []
|
|
276
|
+
for utterance in utterances:
|
|
277
|
+
if not isinstance(utterance, dict):
|
|
278
|
+
continue
|
|
279
|
+
text = str(utterance.get("transcript", "")).strip()
|
|
280
|
+
if not text:
|
|
281
|
+
continue
|
|
282
|
+
try:
|
|
283
|
+
start_ms = int(utterance.get("start_time", 0))
|
|
284
|
+
end_ms = int(utterance.get("end_time", 0))
|
|
285
|
+
except (TypeError, ValueError) as exc:
|
|
286
|
+
raise AsrBackendError(
|
|
287
|
+
self.name,
|
|
288
|
+
f"Bcut utterance has non-numeric timing: {exc}",
|
|
289
|
+
) from exc
|
|
290
|
+
items.append(
|
|
291
|
+
SubtitleItem(
|
|
292
|
+
index=len(items) + 1,
|
|
293
|
+
start_ms=start_ms,
|
|
294
|
+
end_ms=end_ms,
|
|
295
|
+
source_text=text,
|
|
296
|
+
target_text="",
|
|
297
|
+
)
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
cleaned = coerce_items(items)
|
|
301
|
+
if not cleaned:
|
|
302
|
+
raise AsrBackendError(self.name, "Bcut returned no usable utterances")
|
|
303
|
+
|
|
304
|
+
return AsrOutcome(items=cleaned, used_asr=True, origin="bcut")
|