English | 简体中文
Porter Workflow is an automated video localization pipeline: give it a video URL, get back a standardised asset bundle plus hard-subbed release videos with bilingual and Chinese-only subtitles.
It ships as one engine with four frontends:
| Frontend | Entry point | For |
|---|---|---|
porter (CLI) |
porter "<URL>" |
Humans and shell scripts |
porter-mcp |
uvx --from "porter-workflow[mcp]" porter-mcp |
AI agents via Model Context Protocol |
porter-web (WebUI) |
uvx --from "porter-workflow[web]" porter-web |
Humans and agents via interactive HITL workbench |
porter-skill |
npx skills add RolinShmily/porter-workflow |
Agents that use the Agent Skills spec |
On Windows or workspace-write-only desktop hosts, use the skill's native PowerShell launcher and opt-in workspace state directory. An installer spawn EPERM is not a reason to disable the sandbox by default; see the restricted-host guide.
All four call the same porter library. The engine contains no argument
parsing and never writes to stdout — a requirement, because in an MCP stdio
server stdout is the JSON-RPC channel.
Status:
v0.3.xis the current development line onmain. The v0.3 release — one engine, four frontends (CLI, MCP, WebUI workbench, and Agent Skill) — has landed. The v0.1 implementation is preserved in git history at commitb5fd577.
Requires Python ≥ 3.11.
# CLI, minimal install (pure-Python; translation works without a key, but
# transcription needs the [asr-local] extra or an API key -- see below)
uvx porter-workflow "<URL>"
# CLI with every optional backend
uvx --from "porter-workflow[all]" porter "<URL>"
# WebUI, interactive workbench (Gradio)
uvx --from "porter-workflow[web]" porter-web
# Persistent install
pipx install "porter-workflow[all]"These are not Python packages and must be on PATH:
| Dependency | Needed for | Notes |
|---|---|---|
| FFmpeg + ffprobe | Everything | Must be built with libass for hardsubbing. Verify: ffmpeg -filters | grep subtitles |
| Deno (recommended) | YouTube downloads | yt-dlp ≥ 2025.11.12 needs an external JS runtime to solve YouTube's JS challenges. Node ≥ 20 also works. |
Check everything at once:
porter doctorLocal Whisper runs on your own machine: no key, no network after the first
run. Measured 2026-09-24 on real speech -- 18 s of audio became 3 cues in
7.2 s on CPU with the default small model:
| ASR backend | Needs | Status |
|---|---|---|
whisper-local |
the [asr-local] extra |
Works. Key-free; offline once the model is cached |
| Whisper API | OPENAI_API_KEY (or a compatible endpoint) |
Works |
| VideoCaptioner CLI | the videocaptioner package, installed separately |
Works |
| Bcut | nothing | Host answers, but returns zero utterances |
Google Web ([stt]) |
nothing | Returns the empty result {"result":[]} for every request |
whisper-local is tried first, so it is what a run uses whenever it is
installed. It sits behind an extra because faster-whisper and ctranslate2
are heavy, and CTranslate2-backed rather than PyTorch:
# Key-free and offline. This is the recommended install.
uvx --from "porter-workflow[asr-local]" porter "<URL>"
# Or everything at once -- [all] includes [asr-local]
uvx --from "porter-workflow[all]" porter "<URL>"The first run downloads the model from Hugging Face (464 MB for the default
small); after that it is fully offline. Device and precision are chosen for
you -- CUDA if it loads, CPU otherwise -- and can be pinned:
porter config set asr.whisper_local_model=medium
porter config set asr.whisper_local_device=cuda
porter config set asr.whisper_local_compute_type=float16A bare uvx porter-workflow "<URL>" still cannot transcribe, because no ASR
backend is in a minimal install: it downloads and standardises the video and
then fails with every speech-to-text backend failed. The two key-free
endpoints are the ones that are dead -- both are reverse-engineered, and
Google Web's response is malformed at the HTTP level, so the read fails outright
rather than merely returning nothing. Bcut's failure is the more ambiguous of
the two -- the host answers, so it could be a quota or a changed field rather
than a dead endpoint -- which is why it keeps an "unverified" label in the
source.
So to get subtitles, do one of:
# Option A (recommended): local, key-free, offline
uvx --from "porter-workflow[asr-local]" porter "<URL>" --burn skip
# Option B: an LLM key (also improves translation quality a lot)
export OPENAI_API_KEY=sk-...
porter "<URL>" --burn skip
# Option C: install VideoCaptioner and use its engines
pip install videocaptioner
porter "<URL>" --asr-engine bijian --burn skipTranslation is unaffected. Bing, Google and MyMemory all still work without a key; each was probed against its live endpoint and returned real Chinese. Only transcription ever needed credentials.
porter doctor reports which route a job will actually take before you start it.
| Extra | Adds | Enables |
|---|---|---|
[asr-local] |
faster-whisper |
Local Whisper ASR — key-free and offline, see above |
[llm] |
openai, json-repair |
LLM translation + Whisper API ASR |
[stt] |
SpeechRecognition |
Google Web STT — measured non-functional, see above |
[images] |
pillow |
Cover image handling |
[mcp] |
fastmcp |
The porter-mcp server |
[all] |
all of the above | — |
videocaptioner is deliberately not a dependency. It is GPL-3.0 (this
project is MIT) and pins python<3.13. Porter detects it at runtime and, if
present, uses it as an extra ASR/translation backend across a process boundary.
Install it yourself if you want those engines:
pip install videocaptionerBoth of porter's downloads default to endpoints that are painful from mainland
China: the packages come from PyPI, and the Whisper weights from Hugging Face. On
a machine that looks Chinese -- by timezone, by a UTC+8 offset, or by a zh
locale -- porter switches to domestic mirrors on its own:
| What | Default elsewhere | Default in China | Change it with |
|---|---|---|---|
| Python packages | PyPI | USTC | UV_DEFAULT_INDEX |
| Whisper weights | Hugging Face | ModelScope | HF_ENDPOINT |
The faster-whisper-* repositories on ModelScope hold the official CTranslate2
weights rather than a re-conversion: their config.json is byte-identical to
Systran/faster-whisper-small's. Each model size is downloaded from there once.
To force a direction, or to use a mirror of your own choosing:
PORTER_MIRROR=cn # always prefer the mirrors
PORTER_MIRROR=off # never; always use the official sourcesAn index you set yourself always wins and is never overwritten. Setting
UV_DEFAULT_INDEX (or the older UV_INDEX_URL) is enough on its own.
PIP_INDEX_URL is honoured as well, and is copied across to uv -- because uv
ignores PIP_INDEX_URL by itself, so pip config set global.index-url <mirror>
alone would silently do nothing at all.
# Inspect a link without downloading anything
porter inspect "<URL>"
# Full pipeline: download → transcribe → translate → burn
porter "<URL>" -o ./porter_output
# Subtitles only, no video encoding
porter "<URL>" --burn skip
# Diagnose the environment
porter doctor
# Show resolved configuration (secrets masked)
porter config list<output_dir>/<video_id>_<safe_title>/
├── raw/
│ ├── video.mp4 H.264 + AAC + faststart master
│ ├── audio.wav 16 kHz mono reference track
│ ├── audio_enhanced.wav denoised track used only for ASR
│ ├── transcript.json/.txt reconstructed sentence script book
│ ├── subtitle.srt platform-provided source subtitles, if any
│ ├── cover.jpg
│ └── metadata.json
└── cooked/
├── subtitle_bilingual.{srt,ass}
├── subtitle_zh.{srt,ass}
├── video_bilingual.mp4
└── video_zh.mp4
Resolved in this order, highest priority first:
--config <path>$PORTER_CONFIG- Environment variables (
OPENAI_API_KEY,PORTER_ASR_ENGINE, …) - Project-level
./porter.json - User-level config directory (
platformdirs) - Built-in defaults
{
"mcpServers": {
"porter": {
"command": "uvx",
"args": ["--from", "porter-workflow[mcp]", "porter-mcp"]
}
}
}Long jobs are exposed as a start/status/result/cancel API rather than one blocking call, because encoding a 1080p video can take tens of minutes.
uv sync --extra all --extra dev # installs exactly what uv.lock pins
ruff check src # includes T20: the engine must not print()
mypy src
lint-imports # enforces the engine/frontend boundary
pytestCONTRIBUTING.md documents the full gate, the architecture
rules those tools enforce, and the licence rules for new dependencies.
Repository layout:
src/porter/ engine (library) — all business logic lives here
src/porter_cli/ CLI frontend
src/porter_mcp/ MCP frontend
src/porter_web/ Gradio WebUI frontend (HITL workbench)
skills/porter-skill/ Agent Skill assets (SKILL.md, scripts, references)
tests/{unit,regression,integration}/
MIT. See LICENSE.
Porter is built on other people's work, and the debt is worth stating plainly:
- yt-dlp — downloading from five platforms is a solved problem rather than five scrapers, because of this.
- VideoCaptioner (GPL-3.0) —
its sentence segmentation, alignment and subtitle-processing design shaped the
approach in
porter.subtitlesand the ASR chain. Ideas only: no code was copied, and it is never imported — only run as an external process, which is why porter can stay MIT. - FFmpeg and libass — the entire media layer: standardisation, denoising and professional ASS rendering.
- Gradio — the interactive WebUI frontend and Human-in-the-Loop visualization workbench.
THIRD_PARTY_NOTICES.md records every component,
its licence, and whether porter depends on it, optionally installs it, or only
spawns it as a subprocess.
Contributions are welcome. CONTRIBUTING.md covers the
development setup and the gate every change must pass; SECURITY.md
explains how to report a vulnerability privately; release history lives in
CHANGELOG.md.
This project follows the Contributor Covenant.