
Yt2bb
- 3 installs
- 46 repo stars
- Updated May 5, 2026
- agents365-ai/yt2bb
yt2bb is a Claude Code skill that repurposes a YouTube video for Bilibili with burned-in bilingual EN/ZH subtitles and auto-generated publish metadata.
About
yt2bb is a Claude Code skill that repurposes a YouTube video for Bilibili by downloading it, transcribing with Whisper, translating to Chinese, merging bilingual subtitles, and burning them in with FFmpeg. A developer uses it to localize English video content for a Chinese audience with hardcoded EN/ZH subtitles. It also generates a publish_info.md with Bilibili upload metadata.
- Six-step pipeline: download, transcribe, translate, merge, burn subtitles, generate publish info
- Repurposes a YouTube video into a Bilibili-ready MP4 with hardcoded bilingual EN/ZH subtitles
- Uses yt-dlp, Whisper, and FFmpeg; three subtitle style presets (netflix/clean/glow)
Yt2bb by the numbers
- 3 all-time installs (skills.sh)
- Ranked #1,153 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Jul 28, 2026 (Skillselion catalog sync)
yt2bb capabilities & compatibility
Free; uses local yt-dlp, Whisper, and FFmpeg with no API keys.
- Capabilities
- video localization · subtitle generation · transcription · translation
- Use cases
- transcription · translation · video generation
- Platforms
- macOS · Linux · Windows
- Pricing
- Free
What yt2bb says it does
Produces a video with hardcoded bilingual (EN/ZH) subtitles and a `publish_info.md` with Bilibili upload metadata.
Requires Python 3, ffmpeg, yt-dlp, whisper (openai-whisper) on PATH.
npx skills add https://github.com/agents365-ai/yt2bb --skill yt2bbAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 46 |
| Last updated | May 5, 2026 |
| Repository | agents365-ai/yt2bb ↗ |
What it does
Repurpose a YouTube video into a Bilibili-ready MP4 with burned-in bilingual EN/ZH subtitles and publish metadata.
Who is it for?
Localizing English YouTube videos for a Chinese audience with hardcoded bilingual subtitles.
When should I use this skill?
The user wants to repurpose a YouTube video for Bilibili, add bilingual EN-ZH subtitles, or create hardcoded subtitle versions for Chinese platforms.
What you get
A Bilibili-ready MP4 with bilingual EN/ZH hardcoded subtitles and a publish_info.md.
- A Bilibili-ready MP4 with burned-in bilingual subtitles
- publish_info.md with upload metadata
By the numbers
- 6-step pipeline
- 3 subtitle style presets (netflix/clean/glow)
Files
yt2bb — YouTube to Bilibili Video Repurposing
Overview
Six-step pipeline: download → transcribe → translate → merge → burn subtitles → generate publish info. Produces a video with hardcoded bilingual (EN/ZH) subtitles and a publish_info.md with Bilibili upload metadata.
When to Use
- User provides a YouTube URL (single video or playlist) and wants a Bilibili-ready version
- User needs bilingual EN-ZH subtitles burned into video
- User wants to repurpose English video content for Chinese audience
Quick Reference
| Step | Tool | Command | Output |
|---|---|---|---|
| 0. Update | git | Auto-check for skill updates | — |
| 1. Download | yt-dlp | yt-dlp --cookies-from-browser chrome -f ... -o ... | {slug}.mp4 |
| 2. Transcribe | whisper* | srt_utils.py check-whisper then transcribe | {slug}_{lang}.srt |
| 2.5 Validate | srt_utils.py | srt_utils.py validate / fix | {slug}_{lang}.srt (fixed) |
| 3. Translate | AI | SRT-aware batch translation | {slug}_zh.srt |
| 4. Merge | srt_utils.py | srt_utils.py merge ... | {slug}_bilingual.srt |
| 4.5 Style | srt_utils.py | `srt_utils.py to_ass --preset netflix\ | clean\ |
| 5. Burn | ffmpeg | ffmpeg -c:v libx264 -vf ass=... | {slug}_bilingual.mp4 |
| 6. Publish | AI | Analyze content, generate metadata | publish_info.md |
Pre-flight: Auto Update
Run this BEFORE any pipeline step. Locates the skill directory and checks for updates. The SKILL_DIR variable is reused by later steps for script paths.
# Find skill directory (works across Claude Code, OpenClaw, Hermes, Pi)
SKILL_DIR="$(find ~/.claude/skills ~/.openclaw/skills ~/.hermes/skills ~/.pi/agent/skills ~/.agents/skills ~/myagents/myskills -maxdepth 2 -name 'yt2bb' -type d 2>/dev/null | head -1)"
echo "yt2bb: SKILL_DIR=$SKILL_DIR"
if [ -n "$SKILL_DIR" ] && [ -d "$SKILL_DIR/.git" ]; then
git -C "$SKILL_DIR" fetch --quiet origin main 2>/dev/null
LOCAL=$(git -C "$SKILL_DIR" rev-parse HEAD)
REMOTE=$(git -C "$SKILL_DIR" rev-parse origin/main 2>/dev/null)
if [ "$LOCAL" != "$REMOTE" ]; then
echo "yt2bb: new version available. Run: git -C $SKILL_DIR pull origin main"
else
echo "yt2bb: up to date."
fi
fiNote: Does not auto-pull — the current session already loaded the old SKILL.md. Notify the user and let them update between sessions.
Pipeline Details
Step 1: Download
Single video:
slug="video-name" # or: slug=$(python3 "$SKILL_DIR/srt_utils.py" slugify "Video Title")
mkdir -p "${slug}"
yt-dlp --cookies-from-browser chrome \
-f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]" \
-o "${slug}/${slug}.mp4" "https://www.youtube.com/watch?v=VIDEO_ID"Playlist / series:
yt-dlp --cookies-from-browser chrome \
-f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]" \
-o "%(playlist_index)03d-%(title)s/%(playlist_index)03d-%(title)s.mp4" \
"https://www.youtube.com/playlist?list=PLAYLIST_ID"After downloading, rename each folder to a clean slug and run Steps 2–6 for each video sequentially.
-f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]": ensure mp4 output, avoid webm%(playlist_index)03d: zero-padded index to preserve playlist order- If
--cookies-from-browserfails, export cookies first — see Troubleshooting
Step 2: Transcribe
First run the environment check to detect your platform and get a tailored whisper command:
python3 "$SKILL_DIR/srt_utils.py" check-whisperThis auto-detects OS, GPU (CUDA/Metal/CPU), memory, and installed backends, then recommends the best backend + model for your hardware. If memory detection is unavailable, it falls back conservatively instead of assuming a low-memory machine. Use the command it prints.
Manual fallback (openai-whisper, works everywhere):
src_lang="en" # Change to ja/ko/es/etc. based on source video
whisper_model="medium" # check-whisper recommends the best model for your hardware
whisper "${slug}/${slug}.mp4" \
--model "$whisper_model" \
--language "$src_lang" \
--word_timestamps True \
--condition_on_previous_text False \
--output_format srt \
--max_line_width 40 --max_line_count 1 \
--output_dir "${slug}"
mv "${slug}/${slug}.srt" "${slug}/${slug}_${src_lang}.srt"Supported backends:
| Backend | Best for | Install |
|---|---|---|
mlx-whisper | macOS Apple Silicon (fastest) | pip install mlx-whisper |
whisper-ctranslate2 | Windows/Linux CUDA, or CPU (~4x faster) | pip install whisper-ctranslate2 |
openai-whisper | Universal fallback | pip install openai-whisper |
Model selection (auto-recommended by check-whisper):
tiny— fast draft, low accuracy, CPU-friendly (~1 GB)medium— default, good balance (~5 GB)large-v3— best accuracy, recommended for JA/KO/ZH source (~10 GB)
Notes:
--language: explicitly set to avoid misdetection; supportsen,ja,ko,es, etc.--word_timestamps True: more precise subtitle timing--condition_on_previous_text False: prevent hallucination loops- If output is garbled or repeated, add anti-hallucination flags — see Troubleshooting
Step 2.5: Validate & Fix (optional)
python3 "$SKILL_DIR/srt_utils.py" validate "${slug}/${slug}_${src_lang}.srt"
# If issues found:
python3 "$SKILL_DIR/srt_utils.py" fix "${slug}/${slug}_${src_lang}.srt" "${slug}/${slug}_${src_lang}.srt"Step 3: Translate
Read {slug}_{src_lang}.srt and translate to Chinese. Critical rules:
These rules are modeled on the Netflix Simplified Chinese Timed Text Style Guide; follow them to produce broadcast-grade subtitles.
1. Keep SRT format intact — preserve index numbers, timestamps (--> lines) exactly as-is 2. 1:1 entry mapping — every source entry must produce exactly one translated entry (same count) 3. Optimize for bottom subtitles — keep each Chinese entry to 1 line whenever possible so the final bilingual subtitle stays compact near the bottom of the frame 4. Max 16 full-width characters per line (Netflix SC spec). Prefer 12–16; if a cue is very short (< 1 s) compress further so reading speed stays ≤ 9 characters/second 5. Shorten with judgment, not mechanically — remove filler words, repeated subjects, weak interjections, and redundant politeness before dropping key meaning 6. Match subtitle duration — the line must feel readable within the time on screen; if the cue is very short, compress more aggressively 7. No trailing punctuation on Chinese cues — drop ending 。, !, ?; keep mid-sentence ,, 、, ; only when they add clarity 8. Use full-width Chinese punctuation inside cues (,。!?、;:); use 「」 for inner quotes, not "" or '' 9. Half-width digits and Latin — numbers, units, product names, and code identifiers stay half-width (GPT-4, 30fps, 2026); only punctuation is full-width 10. Line-break discipline — never break after function words (的, 了, 吗, 呢, 吧, 啊); never split an English phrasal unit across a line break; keep modifiers with their heads 11. Keep terminology consistent — technical terms, names, product names, and recurring phrases should be translated the same way across batches. Maintain an inline glossary if needed 12. Adapt, don't transliterate — preserve register, tone, and intent over literal word matching; idioms become natural Chinese equivalents 13. Translate in batches of 10 entries — output each batch in valid SRT format, then continue 14. Do NOT merge or split entries — maintain original segmentation 15. Save as {slug}/{slug}_zh.srt
Step 4: Merge
python3 "$SKILL_DIR/srt_utils.py" merge \
"${slug}/${slug}_${src_lang}.srt" "${slug}/${slug}_zh.srt" "${slug}/${slug}_bilingual.srt"Step 4.25: Netflix Lint (recommended)
Run lint on the merged bilingual SRT to catch Netflix Timed Text Style Guide violations that validate doesn't cover — reading speed (CPS), per-line length, inter-cue gaps, and line count.
python3 "$SKILL_DIR/srt_utils.py" lint "${slug}/${slug}_bilingual.srt"Defaults (all overridable via flags):
| Rule | Threshold | Flag |
|---|---|---|
| Reading speed (English) | ≤ 17 CPS | --max-cps-en |
| Reading speed (Simplified Chinese) | ≤ 9 CPS | --max-cps-zh |
| Min cue duration | 833 ms (5/6 s) | --min-duration-ms |
| Max cue duration | 7000 ms | --max-duration-ms |
| Min inter-cue gap | 83 ms (2 frames @ 24 fps) | --min-gap-ms |
| Max chars/line (English) | 42 | --max-chars-en |
| Max chars/line (Chinese, full-width) | 16 | --max-chars-zh |
| Max lines per cue | 2 | — |
Severity model:
- Errors (exit code 2): duration out of bounds, CPS over limit, > 2 lines per cue. These break Netflix acceptance and should be fixed before burning.
- Warnings (exit 0 unless errors also exist): per-line length, tight gaps. These are recommendations — address if feasible, but they don't block delivery.
When CPS errors fire, the fix is almost always upstream — go back to Step 3 and rewrite the offending Chinese entry to fit the time window. Do not solve CPS by extending the cue past the source's spoken duration.
Agent-friendly output:
python3 "$SKILL_DIR/srt_utils.py" lint "${slug}/${slug}_bilingual.srt" --format jsonReturns {ok, error_count, warning_count, issues: [{index, code, severity, message}, ...]} for programmatic filtering.
Step 4.5: Style — Convert to ASS
Convert the bilingual SRT to an ASS file. ASS enables per-line color, font size, and glow effects that are impossible with SRT force_style. Layout rule: subtitles always stay at the bottom. Default stack: ZH on the upper line of the bottom stack, EN on the lower line. The presets are tuned to keep the block readable while reducing overlap risk with lower-screen content.
IMPORTANT — Ask before proceeding. Present the preset table below to the user and ask which style they prefer. Do NOT silently pick a default. If the user has no preference, use clean.Available presets:
| Preset | Look | Best for |
|---|---|---|
netflix | Pure white text, thin black outline, soft drop shadow, no box — modeled on the Netflix Timed Text Style Guide | Professional, broadcast-grade look. Best default for documentaries, interviews, long-form content, and anything that should feel "streaming-platform native". Use with --font "Source Han Sans SC" on Linux / "PingFang SC" on macOS for closest Netflix Sans feel |
clean | Yellow text on gray box — golden ZH + light yellow EN, semi-transparent light gray background | Readability safety net for busy or mixed-brightness footage where netflix's outline-only text could get visually lost. The gray box guarantees a readable contrast pad |
glow | Yellow ZH + white EN with colored glow — bright yellow ZH + white EN, blurred outer glow, no background box | Entertainment, vlogs, energetic edits. Most eye-catching, but weakest on bright or busy backgrounds |
Example prompt to user:
字幕有三套样式可选:
1. netflix — 纯白字体 + 细黑描边 + 柔和阴影(默认推荐,Netflix 专业观感,适合纪录片/访谈/长内容)2. clean — 黄色字体 + 灰色半透明底框(亮背景或花背景的兜底选项,底框保证对比度)3. glow — 黄色/白色字体 + 彩色外发光(更抢眼,适合娱乐/Vlog)4. 自定义 — 提供 .ass 样式文件,完全控制字体、颜色、大小(可用 Aegisub 可视化编辑)>
选哪个?默认推荐netflix;如果画面特别花哨或底部信息多,可改用clean。
# Netflix-grade default (white + outline + soft shadow), ZH on top
python3 "$SKILL_DIR/srt_utils.py" to_ass \
"${slug}/${slug}_bilingual.srt" "${slug}/${slug}_bilingual.ass" \
--preset netflix
# Gray-box fallback for busy backgrounds, EN on top
python3 "$SKILL_DIR/srt_utils.py" to_ass \
"${slug}/${slug}_bilingual.srt" "${slug}/${slug}_bilingual.ass" \
--preset clean --top en
# Vibrant glow (B站 entertainment style)
python3 "$SKILL_DIR/srt_utils.py" to_ass \
"${slug}/${slug}_bilingual.srt" "${slug}/${slug}_bilingual.ass" \
--preset glowCustom style file — for full control, provide an external .ass file with your own [V4+ Styles] section. It must contain styles named EN and ZH, or to_ass will fail early with a validation error. You can design styles visually with Aegisub and export.
python3 "$SKILL_DIR/srt_utils.py" to_ass \
"${slug}/${slug}_bilingual.srt" "${slug}/${slug}_bilingual.ass" \
--style-file my_styles.assOptionally add ; en_tag= and ; zh_tag={\blur5} comment lines in the .ass file to inject ASS override tags per language.
Font by platform (pass with --font, ignored when using --style-file):
| Platform | Flag |
|---|---|
| macOS | --font "PingFang SC" (default) |
| Linux | --font "Noto Sans CJK SC" |
| Windows | --font "Microsoft YaHei" |
Other options:
--top zh|en— which language on the upper line of the bottom stack (default:zh)--res WxH— video resolution (default:1920x1080)
Readability notes for all presets:
- Presets stay bottom-aligned at all times; they do not move to the top automatically
- Font size, outline, and vertical margins scale with
--resso 720p and 1080p keep similar visual balance cleanis the safest choice when you must keep subtitles at the bottom in every shot
Step 5: Burn Subtitles
Use the ass= filter (not subtitles=) — all styling comes from the ASS file.
ffmpeg -i "${slug}/${slug}.mp4" \
-vf "ass='${slug}/${slug}_bilingual.ass'" \
-c:v libx264 -crf 23 -preset medium \
-c:a copy "${slug}/${slug}_bilingual.mp4"-c:v libx264 -crf 23: good quality with reasonable file size-preset medium: balance between speed and compression (usefastfor quicker encode)- No
force_styleneeded — styles are embedded in the ASS file
Step 6: Generate Publish Info
Based on the video content (from {slug}_{src_lang}.srt and {slug}_zh.srt), generate {slug}/publish_info.md.
All output in this file must be in Chinese (targeting Bilibili audience).
# Publish Info
## Source
{YouTube URL}
## Titles (5 variants)
1. {Suspense/question style — spark curiosity}
2. {Data/achievement driven — emphasize results}
3. {Controversial/opinion style — spark discussion}
4. {Tutorial/practical style — emphasize utility}
5. {Emotional/relatable style — connect with audience}
## Tags
{~10 comma-separated keywords covering topic, technology, domain}
## Description
{3-5 sentences summarizing core content and highlights}
## Chapter Timestamps
00:00 {chapter name}
...Generation rules:
- Title style must match Bilibili conventions: conversational tone, suspense hooks, liberal use of symbols (【】, ?, !)
- Tags should cover both Chinese and English keywords for discoverability
- Timestamps extracted from
{slug}_bilingual.srtat topic transition points - Description needs a strong hook — first two sentences determine whether users expand to read
Output Structure
{slug}/
├── {slug}.mp4 # Source video
├── {slug}_{src_lang}.srt # Source language subtitles
├── {slug}_zh.srt # Chinese subtitles
├── {slug}_bilingual.srt # Merged bilingual
├── {slug}_bilingual.mp4 # Final output
└── publish_info.md # Bilibili upload metadataUtility: srt_utils.py
python3 "$SKILL_DIR/srt_utils.py" merge en.srt zh.srt output.srt # Merge bilingual
python3 "$SKILL_DIR/srt_utils.py" merge --dry-run en.srt zh.srt output.srt # Pre-check without writing
python3 "$SKILL_DIR/srt_utils.py" validate input.srt # Check timing issues
python3 "$SKILL_DIR/srt_utils.py" fix input.srt output.srt # Fix timing/overlaps (multi-pass)
python3 "$SKILL_DIR/srt_utils.py" slugify "Video Title" # Generate slug
python3 "$SKILL_DIR/srt_utils.py" to_ass input.srt output.ass # Convert to styled ASS (default: clean, ZH on top)
python3 "$SKILL_DIR/srt_utils.py" to_ass --dry-run input.srt output.ass # Pre-check without writing
python3 "$SKILL_DIR/srt_utils.py" to_ass input.srt output.ass --preset glow --top en
python3 "$SKILL_DIR/srt_utils.py" to_ass input.srt output.ass --style-file custom.ass # User-defined styles
python3 "$SKILL_DIR/srt_utils.py" check-whisper # Detect platform, recommend whisper backend + modelCommon Mistakes
- Mismatched entry counts: Merge fails by default — fix translation or use
--pad-missingto pad - Font not found: Ensure PingFang SC is installed (macOS default) or substitute (see Troubleshooting)
Troubleshooting
yt-dlp: Cookie Auth Failure
--cookies-from-browser chrome requires Chrome to be closed (or uses a snapshot of the profile). If it fails:
# Export cookies once, then reuse the file
yt-dlp --cookies-from-browser chrome --cookies cookies.txt --skip-download "URL"
yt-dlp --cookies cookies.txt -f "bv*[ext=mp4]+ba[ext=m4a]/b[ext=mp4]" -o "${slug}/${slug}.mp4" "URL"For 429 / rate-limit errors, add --sleep-interval 3 --max-sleep-interval 8.
whisper: Wrong Language or Hallucination Loops
Symptoms: repeated phrases, garbled characters, or near-empty SRT despite clear audio.
whisper "${slug}/${slug}.mp4" \
--model medium \
--language "$src_lang" \
--condition_on_previous_text False \
--no_speech_threshold 0.6 \
--logprob_threshold -1.0 \
--compression_ratio_threshold 2.0 \
--output_format srt \
--output_dir "${slug}"If language is still misdetected, the audio likely has long silence or non-speech segments — add --vad_filter True to suppress them.
ffmpeg: Font Not Found / CJK Boxes
Pass the correct font via --font in the to_ass step (Step 4.5). The ASS file embeds the font name, so ffmpeg needs it installed at burn time.
| Platform | Font | Install |
|---|---|---|
| macOS | PingFang SC | pre-installed |
| Linux | Noto Sans CJK SC | sudo apt install fonts-noto-cjk |
| Linux (alt) | WenQuanYi Micro Hei | sudo apt install fonts-wqy-microhei |
| Windows | Microsoft YaHei | pre-installed |
Regenerate the ASS file with the correct --font flag, then re-run the burn step.
Privacy & Data Flow
- Browser cookies: Step 1 uses
yt-dlp --cookies-from-browser chrometo access age-gated or private videos. This reads Chrome cookies locally — no cookies are transmitted beyond YouTube's own servers. To avoid this, export cookies to a file first (see Troubleshooting above). - Transcripts & translation: Step 3 (translate) and Step 6 (publish info) are performed by the AI agent in the conversation. Transcripts are sent to whatever model/service the agent uses (e.g. Claude API). If the video contains sensitive content, use a local model for those steps.
- Auto-update check: The pre-flight step runs
git fetchto check for skill updates. It does not auto-pull or execute remote code. - No telemetry:
srt_utils.pymakes no network requests. All processing (SRT parsing, merging, ASS generation, hardware detection) is fully local.
CLAUDE.md
__pycache__/
*.pyc
*.pyo
.DS_Store
*.mp4
*.srt
publish_info.md
.tmp/
.yt2bb_status.json
test_srt_utils.py
docs/*
# Committed preset previews and fixtures (override docs/* and *.srt rules above)
!docs/presets/
!docs/presets/**
interface:
display_name: "yt2bb — YouTube to Bilibili"
short_description: "Repurpose YouTube videos for Bilibili with burned-in bilingual (EN/ZH) subtitles and auto-generated publish metadata"
brand_color: "#FB7299"
policy:
allow_implicit_invocation: true
capabilities:
- Download YouTube videos or playlists via yt-dlp (Chrome cookie auth)
- Transcribe speech to source-language SRT using openai-whisper (supports EN, ZH, JA, KO, ES, and more)
- Translate source-language subtitles to Simplified Chinese
- Merge bilingual subtitles into a single dual-language SRT
- Burn hardcoded bilingual subtitles into MP4 via ffmpeg
- Generate `publish_info.md` with Bilibili-ready title, tags, and description
- Support single videos, playlists, and series batch processing
prerequisites:
- Python 3
- ffmpeg on PATH
- yt-dlp on PATH
- openai-whisper (`whisper` CLI) on PATH
- Chrome browser logged into YouTube (for cookie extraction)
#!/usr/bin/env bash
# Regenerate the preset preview images in docs/presets/.
#
# Renders a neutral gradient background, burns each ASS preset onto it,
# and writes one PNG per preset. Run from the repo root:
#
# bash docs/presets/render_previews.sh
#
# Requires: ffmpeg (with the `ass` filter), python3.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
OUT_DIR="$REPO_ROOT/docs/presets"
SAMPLE_SRT="$OUT_DIR/sample.srt"
TMP_BG="$(mktemp -t yt2bb_preview_bg.XXXXXX).png"
trap 'rm -f "$TMP_BG"' EXIT
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "ffmpeg not found on PATH" >&2
exit 1
fi
# Neutral mid-tone gradient — dark blue at the top, lighter teal at the
# bottom where the subtitle block lives. Chosen so:
# * netflix outline+shadow is clearly readable
# * clean gray box is visibly distinct from the background
# * glow color pop is still legible
ffmpeg -y -hide_banner -loglevel error \
-f lavfi -i "gradients=size=1920x1080:c0=0x0a1420:c1=0x1e3a5f:c2=0x3a7a9b:n=3:x0=960:y0=0:x1=960:y1=1080:duration=1" \
-frames:v 1 "$TMP_BG"
render_preset () {
local preset="$1"
local ass_path="$OUT_DIR/.${preset}.ass"
local png_path="$OUT_DIR/${preset}.png"
python3 "$REPO_ROOT/srt_utils.py" to_ass \
"$SAMPLE_SRT" "$ass_path" \
--preset "$preset" >/dev/null
# Loop the background as a 2 s video so the subtitle cue is active,
# then extract a single frame at t=1 s where the subtitle is on screen.
ffmpeg -y -hide_banner -loglevel error \
-loop 1 -t 2 -framerate 24 -i "$TMP_BG" \
-vf "ass='$ass_path'" \
-ss 1 -frames:v 1 \
"$png_path"
rm -f "$ass_path"
echo " rendered $png_path"
}
echo "Rendering preset previews:"
render_preset netflix
render_preset clean
render_preset glow
echo "Done. Commit docs/presets/*.png alongside any preset changes."
1
00:00:00,500 --> 00:00:03,500
Welcome to the show, today we talk AI.
欢迎来到节目,今天聊聊 AI。
MIT License
Copyright (c) 2025 Agents365-ai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
{
"openclaw": {
"requires": {
"bins": [
"python3",
"ffmpeg",
"yt-dlp",
"whisper"
]
},
"emoji": "🎬",
"os": [
"darwin",
"linux",
"win32"
],
"install": [
{
"id": "brew-ffmpeg",
"kind": "brew",
"formula": "ffmpeg",
"bins": [
"ffmpeg"
],
"label": "Install ffmpeg via Homebrew",
"os": [
"darwin"
]
},
{
"id": "apt-ffmpeg",
"kind": "apt",
"package": "ffmpeg",
"bins": [
"ffmpeg"
],
"label": "Install ffmpeg via apt",
"os": [
"linux"
]
},
{
"id": "brew-ytdlp",
"kind": "brew",
"formula": "yt-dlp",
"bins": [
"yt-dlp"
],
"label": "Install yt-dlp via Homebrew",
"os": [
"darwin"
]
},
{
"id": "pip-ytdlp",
"kind": "pip",
"package": "yt-dlp",
"bins": [
"yt-dlp"
],
"label": "Install yt-dlp via pip",
"os": [
"linux",
"win32"
]
},
{
"id": "pip-whisper",
"kind": "pip",
"package": "openai-whisper",
"bins": [
"whisper"
],
"label": "Install openai-whisper via pip"
}
]
},
"clawhub": {
"requires": {
"bins": [
"python3",
"ffmpeg",
"yt-dlp",
"whisper"
]
},
"category": "media",
"install": [
{
"id": "brew-ffmpeg",
"kind": "brew",
"formula": "ffmpeg",
"bins": [
"ffmpeg"
],
"label": "Install ffmpeg via Homebrew",
"os": [
"darwin"
]
},
{
"id": "apt-ffmpeg",
"kind": "apt",
"package": "ffmpeg",
"bins": [
"ffmpeg"
],
"label": "Install ffmpeg via apt",
"os": [
"linux"
]
},
{
"id": "brew-ytdlp",
"kind": "brew",
"formula": "yt-dlp",
"bins": [
"yt-dlp"
],
"label": "Install yt-dlp via Homebrew",
"os": [
"darwin"
]
},
{
"id": "pip-ytdlp",
"kind": "pip",
"package": "yt-dlp",
"bins": [
"yt-dlp"
],
"label": "Install yt-dlp via pip",
"os": [
"linux",
"win32"
]
},
{
"id": "pip-whisper",
"kind": "pip",
"package": "openai-whisper",
"bins": [
"whisper"
],
"label": "Install openai-whisper via pip"
}
]
},
"hermes": {
"tags": [
"youtube",
"bilibili",
"subtitles",
"bilingual",
"video",
"localization",
"whisper",
"yt-dlp"
],
"category": "media",
"requires_tools": [
"python3",
"ffmpeg",
"yt-dlp",
"whisper"
],
"related_skills": [
"ffmpeg",
"video-podcast-maker"
]
},
"codex": {
"requires": {
"bins": [
"python3",
"ffmpeg",
"yt-dlp",
"whisper"
]
},
"allowed-tools": [
"bash",
"read",
"write",
"edit"
]
},
"claude-code": {
"allowed-tools": "Bash(python3:*) Bash(ffmpeg:*) Bash(whisper:*) Bash(yt-dlp:*) Bash(git:*) Read Write Edit"
},
"pi": {
"requires": {
"bins": [
"python3",
"ffmpeg",
"yt-dlp",
"whisper"
]
},
"allowed-tools": [
"bash",
"read",
"write",
"edit"
]
},
"skillsmp": {
"topics": [
"claude-code",
"claude-code-skill",
"claude-skills",
"agent-skills",
"skillsmp",
"openclaw",
"openclaw-skills",
"skill-md",
"pi-coding-agent",
"youtube",
"bilibili",
"subtitles",
"video"
]
},
"author": "Agents365-ai",
"version": "2.5.0"
}
yt2bb - YouTube 视频转 Bilibili
English
一个 Claude Code 技能,将 YouTube 视频转制成带双语(中英)硬字幕的 Bilibili 视频。
兼容 Claude Code、OpenClaw、Hermes Agent、Pi (pi-mono),并可被 SkillsMP 索引。
工作流程
YouTube → yt-dlp → whisper → 校验 → 翻译 → 合并 → ffmpeg → 发布信息 → Bilibili| 步骤 | 工具 | 输出 |
|---|---|---|
| 下载 | yt-dlp | .mp4 |
| 转录 | whisper | _{lang}.srt |
| 校验修复 | srt_utils.py | _{lang}.srt(修复) |
| 翻译 | AI | _zh.srt |
| 合并 | srt_utils.py | _bilingual.srt |
| 烧录 | ffmpeg | _bilingual.mp4 |
| 发布信息 | AI | publish_info.md |
使用方法
/yt2bb https://www.youtube.com/watch?v=VIDEO_ID安装
Claude Code
git clone https://github.com/Agents365-ai/yt2bb.git ~/.claude/skills/yt2bbOpenClaw
git clone https://github.com/Agents365-ai/yt2bb.git ~/.openclaw/skills/yt2bbHermes Agent
git clone https://github.com/Agents365-ai/yt2bb.git ~/.hermes/skills/media/yt2bbPi (pi-mono)
git clone https://github.com/Agents365-ai/yt2bb.git ~/.pi/agent/skills/yt2bb前置依赖
- Python 3
- ffmpeg
- yt-dlp
- openai-whisper
- Chrome 浏览器需登录 YouTube 帐号(yt-dlp 自动提取 cookies)
工具脚本
# 检测平台并推荐 whisper 后端和模型
python3 srt_utils.py check-whisper
# 合并英文和中文字幕
python3 srt_utils.py merge en.srt zh.srt output.srt
# 校验时间轴问题
python3 srt_utils.py validate input.srt
# 按 Netflix Timed Text Style Guide 做 lint(阅读速度 / 时长 / 行长 / 间隔)
python3 srt_utils.py lint bilingual.srt
# 修复时间轴重叠
python3 srt_utils.py fix input.srt output.srt
# 转为带样式的 ASS 字幕(预设: netflix, clean, glow)
# 预设始终贴底显示,并会随分辨率自适应字号和边距
# `netflix` = 广电级:纯白字体 + 细黑描边 + 柔和阴影,无底框
python3 srt_utils.py to_ass bilingual.srt bilingual.ass --preset netflix
python3 srt_utils.py to_ass bilingual.srt bilingual.ass --style-file custom.ass
# 从标题生成 slug
python3 srt_utils.py slugify "视频标题"所有子命令支持 --format json 输出结构化数据,方便 AI Agent 调用。merge 和 to_ass 支持 --dry-run 预检输入而不写入文件。
字幕预设效果预览
三套预设都在同一张 1920×1080 背景上渲染,方便直接对比字体、布局和对比度。
| 预设 | 效果图 | 适用场景 |
|---|---|---|
netflix | !netflix 预设预览 | 专业内容首选。 纯白字体 + 细黑描边 + 柔和阴影,无底框。基于 Netflix Timed Text Style Guide。纪录片、访谈、长视频、所有"流媒体平台感"的内容都用它。 |
clean | !clean 预设预览 | 可读性兜底。 金黄色字体 + 半透明灰底框。当 netflix 的描边可能被花背景吃掉时选它——底框保证对比度。 |
glow | !glow 预设预览 | 娱乐 / Vlog。 黄色中文 + 白色英文 + 彩色外发光。最抢眼,也最不克制——适合高能剪辑和 B 站风格内容。 |
修改预设后,可通过以下命令重新生成预览图:
bash docs/presets/render_previews.sh脚本会以 docs/presets/sample.srt 为样例在中性渐变背景上渲染每个预设,输出到 docs/presets/{preset}.png。
许可证
MIT 许可证
支持
如果这个项目对你有帮助,欢迎支持作者:
<table> <tr> <td align="center"> <img src="https://raw.githubusercontent.com/Agents365-ai/images_payment/main/qrcode/wechat-pay.png" width="180" alt="微信支付"> <br> <b>微信支付</b> </td> <td align="center"> <img src="https://raw.githubusercontent.com/Agents365-ai/images_payment/main/qrcode/alipay.png" width="180" alt="支付宝"> <br> <b>支付宝</b> </td> <td align="center"> <img src="https://raw.githubusercontent.com/Agents365-ai/images_payment/main/qrcode/buymeacoffee.png" width="180" alt="Buy Me a Coffee"> <br> <b>Buy Me a Coffee</b> </td> </tr> </table>
---
探索未至之境
 
yt2bb - YouTube to Bilibili Video Repurposing
中文文档
A Claude Code skill that repurposes YouTube videos for Bilibili with bilingual (EN/ZH) hardcoded subtitles.
Compatible with Claude Code, OpenClaw, Hermes Agent, Pi (pi-mono), and indexed by SkillsMP.
Workflow
YouTube → yt-dlp → whisper → validate → translate → merge → ffmpeg → publish_info → Bilibili| Step | Tool | Output |
|---|---|---|
| Download | yt-dlp | .mp4 |
| Transcribe | whisper | _{lang}.srt |
| Validate/Fix | srt_utils.py | _{lang}.srt (fixed) |
| Translate | AI | _zh.srt |
| Merge | srt_utils.py | _bilingual.srt |
| Burn | ffmpeg | _bilingual.mp4 |
| Publish Info | AI | publish_info.md |
Usage
/yt2bb https://www.youtube.com/watch?v=VIDEO_IDInstallation
Claude Code
git clone https://github.com/Agents365-ai/yt2bb.git ~/.claude/skills/yt2bbOpenClaw
git clone https://github.com/Agents365-ai/yt2bb.git ~/.openclaw/skills/yt2bbHermes Agent
git clone https://github.com/Agents365-ai/yt2bb.git ~/.hermes/skills/media/yt2bbPi (pi-mono)
git clone https://github.com/Agents365-ai/yt2bb.git ~/.pi/agent/skills/yt2bbPrerequisites
- Python 3
- ffmpeg
- yt-dlp
- openai-whisper
- YouTube account logged in via Chrome browser (yt-dlp extracts cookies automatically)
Utility Script
# Detect platform and recommend whisper backend + model
python3 srt_utils.py check-whisper
# Merge EN and ZH subtitles
python3 srt_utils.py merge en.srt zh.srt output.srt
# Validate timing issues
python3 srt_utils.py validate input.srt
# Lint against Netflix Timed Text Style Guide (CPS, duration, line length, gaps)
python3 srt_utils.py lint bilingual.srt
# Fix timing overlaps
python3 srt_utils.py fix input.srt output.srt
# Convert to styled ASS (presets: netflix, clean, glow)
# Presets stay bottom-aligned and scale with resolution
# `netflix` = broadcast-grade: white text, thin outline, soft shadow, no box
python3 srt_utils.py to_ass bilingual.srt bilingual.ass --preset netflix
python3 srt_utils.py to_ass bilingual.srt bilingual.ass --style-file custom.ass
# Generate slug from title
python3 srt_utils.py slugify "Video Title"All subcommands support --format json for structured agent-friendly output. merge and to_ass support --dry-run to validate inputs without writing files.
Subtitle Preset Previews
All three presets rendered on the same 1920×1080 background, so you can compare typography, layout, and contrast at a glance.
| Preset | Preview | Use case |
|---|---|---|
netflix | !netflix preset preview | Default for professional content. Pure white text, thin black outline, soft drop shadow, no box. Modeled on the Netflix Timed Text Style Guide. Best for documentaries, interviews, and long-form video. |
clean | !clean preset preview | Readability safety net. Golden-yellow text on a semi-transparent gray box. Use when netflix's outline could get visually lost on busy or bright-heavy footage — the box guarantees a contrast pad. |
glow | !glow preset preview | Entertainment / vlog. Yellow ZH + white EN with a colored outer glow. Most eye-catching, least subtle — best for high-energy edits and B站-style content. |
To regenerate these images after changing a preset, run:
bash docs/presets/render_previews.shThe script renders each preset against a neutral gradient background using the committed docs/presets/sample.srt fixture and writes docs/presets/{preset}.png.
License
MIT License
Support
If this project helps you, consider supporting the author:
<table> <tr> <td align="center"> <img src="https://raw.githubusercontent.com/Agents365-ai/images_payment/main/qrcode/wechat-pay.png" width="180" alt="WeChat Pay"> <br> <b>WeChat Pay</b> </td> <td align="center"> <img src="https://raw.githubusercontent.com/Agents365-ai/images_payment/main/qrcode/alipay.png" width="180" alt="Alipay"> <br> <b>Alipay</b> </td> <td align="center"> <img src="https://raw.githubusercontent.com/Agents365-ai/images_payment/main/qrcode/buymeacoffee.png" width="180" alt="Buy Me a Coffee"> <br> <b>Buy Me a Coffee</b> </td> </tr> </table>
---
探索未至之境
 
#!/usr/bin/env python3
"""SRT utilities for yt2bb - merge bilingual subtitles.
Agent-native CLI: all subcommands support --format json for structured output.
Exit codes: 0=success, 1=runtime error, 2=validation/data error.
"""
import argparse
import hashlib
import json
import platform
import re
import shutil
import subprocess
import sys
import unicodedata
from pathlib import Path
# Exit codes
EXIT_OK = 0
EXIT_RUNTIME = 1
EXIT_VALIDATION = 2
def _emit(result, fmt='text', text_fn=None):
"""Emit result as JSON or human-readable text."""
if fmt == 'json':
print(json.dumps(result, ensure_ascii=False))
elif text_fn:
text_fn(result)
def parse_srt(path):
"""Parse SRT file into list of entry dicts. Warns on skipped malformed blocks."""
content = sys.stdin.read() if path == '-' else Path(path).read_text(encoding='utf-8')
entries = []
skipped = 0
for block in re.split(r'\n\n+', content.strip()):
lines = block.strip().split('\n')
if len(lines) < 2:
skipped += 1
continue
m = re.match(r'(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})', lines[1])
if not m:
skipped += 1
continue
entries.append({'index': int(lines[0]), 'start': m.group(1), 'end': m.group(2),
'text': '\n'.join(lines[2:]) if len(lines) > 2 else ''})
if skipped:
print(f"Warning: skipped {skipped} malformed block(s) during parsing", file=sys.stderr)
return entries
def write_srt(entries, path):
"""Write list of entry dicts to SRT file."""
lines = []
for i, e in enumerate(entries, 1):
lines.extend([str(i), f"{e['start']} --> {e['end']}", e['text'], ''])
output = '\n'.join(lines)
if path == '-':
print(output)
else:
Path(path).write_text(output, encoding='utf-8')
def merge_bilingual(en_entries, zh_entries, pad_missing=False):
"""Merge EN and ZH entries into bilingual format (EN on top, ZH below).
Raises ValueError on count mismatch unless pad_missing=True.
"""
en_count, zh_count = len(en_entries), len(zh_entries)
if en_count != zh_count:
if not pad_missing:
raise ValueError(
f"Subtitle count mismatch: EN={en_count}, ZH={zh_count}. "
f"Use --pad-missing to pad the shorter list instead of failing."
)
print(f"Warning: EN={en_count}, ZH={zh_count}. Padding shorter list.", file=sys.stderr)
if en_count > zh_count:
for i in range(zh_count, en_count):
zh_entries.append({'index': i+1, 'start': en_entries[i]['start'],
'end': en_entries[i]['end'], 'text': '[翻译缺失]'})
else:
for i in range(en_count, zh_count):
en_entries.append({'index': i+1, 'start': zh_entries[i]['start'],
'end': zh_entries[i]['end'], 'text': '[Translation missing]'})
return [{'index': i, 'start': en['start'], 'end': en['end'],
'text': f"{en['text']}\n{zh['text']}"}
for i, (en, zh) in enumerate(zip(en_entries, zh_entries), 1)]
def time_to_ms(ts):
"""Convert SRT timestamp to milliseconds."""
h, m, rest = ts.split(':')
s, ms = rest.split(',')
return int(h) * 3600000 + int(m) * 60000 + int(s) * 1000 + int(ms)
def ms_to_time(ms):
"""Convert milliseconds to SRT timestamp."""
if ms < 0:
ms = 0
h = ms // 3600000
m = (ms % 3600000) // 60000
s = (ms % 60000) // 1000
ms_part = ms % 1000
return f"{h:02d}:{m:02d}:{s:02d},{ms_part:03d}"
def fix_srt(entries, min_duration_ms=500, min_gap_ms=83, max_passes=3):
"""Fix common SRT issues: short durations, overlaps, tiny gaps.
Runs multiple passes (up to max_passes) to resolve cascading overlaps
where fixing one entry pushes timing into the next.
"""
fixed = [e.copy() for e in entries]
for _ in range(max_passes):
changed = False
# Enforce minimum duration
for e in fixed:
start_ms = time_to_ms(e['start'])
end_ms = time_to_ms(e['end'])
if end_ms - start_ms < min_duration_ms:
e['end'] = ms_to_time(start_ms + min_duration_ms)
changed = True
# Resolve overlaps and enforce minimum gap
for i in range(1, len(fixed)):
prev_start = time_to_ms(fixed[i-1]['start'])
prev_end = time_to_ms(fixed[i-1]['end'])
curr_start = time_to_ms(fixed[i]['start'])
if prev_end > curr_start - min_gap_ms:
new_end = curr_start - min_gap_ms
if new_end >= prev_start + min_duration_ms:
fixed[i-1]['end'] = ms_to_time(new_end)
else:
new_end = prev_start + min_duration_ms
fixed[i-1]['end'] = ms_to_time(new_end)
new_start = new_end + min_gap_ms
fixed[i]['start'] = ms_to_time(new_start)
curr_end_ms = time_to_ms(fixed[i]['end'])
if curr_end_ms < new_start + min_duration_ms:
fixed[i]['end'] = ms_to_time(new_start + min_duration_ms)
changed = True
if not changed:
break
return fixed
def validate_srt(entries):
"""Validate SRT entries for timing issues."""
issues = []
for e in entries:
start_ms = time_to_ms(e['start'])
end_ms = time_to_ms(e['end'])
duration = end_ms - start_ms
if duration < 500:
issues.append(f"#{e['index']}: duration {duration}ms < 500ms")
if duration > 7000:
issues.append(f"#{e['index']}: duration {duration}ms > 7000ms")
for i in range(1, len(entries)):
prev_end = time_to_ms(entries[i-1]['end'])
curr_start = time_to_ms(entries[i]['start'])
if prev_end > curr_start:
issues.append(f"#{entries[i]['index']}: overlaps previous by {prev_end - curr_start}ms")
return issues
# ---------------------------------------------------------------------------
# Netflix-spec lint
# ---------------------------------------------------------------------------
# CJK Unified Ideographs + Extension A. A line containing any of these is
# treated as a Chinese line for CPS and per-line length checks.
_CJK_RE = re.compile(r'[\u4e00-\u9fff\u3400-\u4dbf]')
_HTML_TAG_RE = re.compile(r'<[^>]+>')
def _strip_html_tags(text):
"""Strip HTML-style tags like <i>, <b> from subtitle text."""
return _HTML_TAG_RE.sub('', text)
def _is_cjk_line(line):
"""True if the line contains any CJK Unified Ideographs."""
return bool(_CJK_RE.search(line))
def _visible_length(line):
"""Visible character count for Netflix line-length checks.
CJK full-width chars and Latin chars both count as 1. HTML tags are
stripped so `<i>hi</i>` counts as 2, not 9. Trailing whitespace is
ignored.
"""
return len(_strip_html_tags(line).rstrip())
def lint_srt(entries, *,
min_duration_ms=833, max_duration_ms=7000,
min_gap_ms=83,
max_cps_en=17.0, max_cps_zh=9.0,
max_chars_en=42, max_chars_zh=16,
max_lines=2):
"""Lint SRT entries against the Netflix Timed Text Style Guide.
Checks hard readability rules (duration, CPS, line count) as errors
and soft typography rules (per-line length, inter-cue gap) as
warnings. Returns a list of issue dicts sorted by cue index; each
issue has ``index``, ``code``, ``severity`` ('error' or 'warning'),
and ``message`` fields.
Netflix defaults (all overridable):
* duration: 833 ms <= cue <= 7000 ms
* reading speed: <= 17 CPS English, <= 9 CPS Simplified Chinese
* lines: <= 2 per cue
* line length: <= 42 chars English, <= 16 full-width chars Chinese
* inter-cue gap: >= 2 frames @ 24 fps (83 ms)
"""
issues = []
for e in entries:
idx = e['index']
start_ms = time_to_ms(e['start'])
end_ms = time_to_ms(e['end'])
duration_ms = end_ms - start_ms
duration_s = duration_ms / 1000 if duration_ms > 0 else 0
if duration_ms < min_duration_ms:
issues.append({
'index': idx, 'code': 'duration_too_short', 'severity': 'error',
'message': f"#{idx}: duration {duration_ms}ms < {min_duration_ms}ms (Netflix min)",
})
if duration_ms > max_duration_ms:
issues.append({
'index': idx, 'code': 'duration_too_long', 'severity': 'error',
'message': f"#{idx}: duration {duration_ms}ms > {max_duration_ms}ms (Netflix max)",
})
text = _strip_html_tags(e['text']).strip()
if not text:
continue
lines = [ln for ln in text.split('\n') if ln.strip()]
if len(lines) > max_lines:
issues.append({
'index': idx, 'code': 'too_many_lines', 'severity': 'error',
'message': f"#{idx}: {len(lines)} lines > {max_lines} (Netflix max per cue)",
})
for line in lines:
visible = _visible_length(line)
if visible == 0:
continue
if _is_cjk_line(line):
if visible > max_chars_zh:
issues.append({
'index': idx, 'code': 'line_too_long_zh', 'severity': 'warning',
'message': (f"#{idx}: ZH line {visible} chars > {max_chars_zh} "
f"(Netflix SC max)"),
})
if duration_s > 0:
cps = visible / duration_s
if cps > max_cps_zh:
issues.append({
'index': idx, 'code': 'cps_zh_too_fast', 'severity': 'error',
'message': (f"#{idx}: ZH reading speed {cps:.1f} CPS "
f"> {max_cps_zh} (Netflix SC max)"),
})
else:
if visible > max_chars_en:
issues.append({
'index': idx, 'code': 'line_too_long_en', 'severity': 'warning',
'message': (f"#{idx}: EN line {visible} chars > {max_chars_en} "
f"(Netflix max)"),
})
if duration_s > 0:
cps = visible / duration_s
if cps > max_cps_en:
issues.append({
'index': idx, 'code': 'cps_en_too_fast', 'severity': 'error',
'message': (f"#{idx}: EN reading speed {cps:.1f} CPS "
f"> {max_cps_en} (Netflix max)"),
})
for i in range(1, len(entries)):
prev_end = time_to_ms(entries[i-1]['end'])
curr_start = time_to_ms(entries[i]['start'])
if prev_end <= curr_start:
gap = curr_start - prev_end
if gap < min_gap_ms:
issues.append({
'index': entries[i]['index'], 'code': 'gap_too_small', 'severity': 'warning',
'message': (f"#{entries[i]['index']}: gap {gap}ms < {min_gap_ms}ms "
f"(Netflix min 2 frames @ 24fps)"),
})
return issues
def slugify(title):
"""Convert title to URL-safe slug, preserving CJK and other Unicode scripts.
Uses unidecode for transliteration when available; otherwise keeps
Unicode letters/digits so that CJK titles produce readable slugs
instead of opaque hashes.
"""
try:
from unidecode import unidecode
title = unidecode(title)
except ImportError:
pass
normalized = unicodedata.normalize('NFKC', title)
# Allow Unicode word characters (letters + digits) — covers CJK, Cyrillic, etc.
slug = re.sub(r'[^\w]+', '-', normalized.lower(), flags=re.UNICODE).strip('-')
# Remove lone underscores left by \w matching _
slug = slug.replace('_', '-')
slug = re.sub(r'-{2,}', '-', slug).strip('-')
if not slug:
slug = 'video-' + hashlib.md5(title.encode()).hexdigest()[:8]
return slug
# ---------------------------------------------------------------------------
# Whisper environment detection
# ---------------------------------------------------------------------------
def _run_quiet(cmd):
"""Run a command and return stdout, or None on failure."""
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
return r.stdout.strip() if r.returncode == 0 else None
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
def _detect_memory_gb():
"""Detect total system memory in GB."""
system = platform.system()
try:
if system == 'Darwin':
out = _run_quiet(['sysctl', '-n', 'hw.memsize'])
return int(out) / (1024 ** 3) if out else None
elif system == 'Linux':
with open('/proc/meminfo') as f:
for line in f:
if line.startswith('MemTotal:'):
return int(line.split()[1]) / (1024 ** 2)
return None
elif system == 'Windows':
out = _run_quiet(['wmic', 'computersystem', 'get',
'TotalPhysicalMemory', '/value'])
if out:
for line in out.splitlines():
if 'TotalPhysicalMemory' in line:
return int(line.split('=')[1]) / (1024 ** 3)
return None
except (ValueError, OSError):
return None
def _detect_gpu():
"""Detect GPU info. Returns dict with 'type', 'name', 'vram_gb'."""
system = platform.system()
machine = platform.machine()
out = _run_quiet(['nvidia-smi', '--query-gpu=name,memory.total',
'--format=csv,noheader,nounits'])
if out:
parts = out.splitlines()[0].split(', ')
name = parts[0].strip()
vram_mb = int(parts[1].strip()) if len(parts) > 1 else 0
return {'type': 'cuda', 'name': name, 'vram_gb': round(vram_mb / 1024, 1)}
if system == 'Darwin' and machine == 'arm64':
chip = _run_quiet(['sysctl', '-n', 'machdep.cpu.brand_string']) or 'Apple Silicon'
mem = _detect_memory_gb()
return {'type': 'mps', 'name': chip, 'vram_gb': round(mem, 1) if mem else None}
return {'type': 'cpu', 'name': 'CPU only', 'vram_gb': None}
def _detect_whisper_backends():
"""Check which whisper CLI backends are installed."""
backends = {}
for name, cmd in [('openai-whisper', 'whisper'),
('mlx-whisper', 'mlx_whisper'),
('whisper-ctranslate2', 'whisper-ctranslate2')]:
backends[name] = shutil.which(cmd) is not None
return backends
_WHISPER_MODELS = ['tiny', 'base', 'small', 'medium', 'large-v3']
_MODEL_SIZE_GB = {'tiny': 0.07, 'base': 0.14, 'small': 0.5, 'medium': 1.5, 'large-v3': 3.0}
def _detect_whisper_models():
"""Detect locally cached whisper models across all backends.
Returns dict mapping model name to list of backends that have it cached.
"""
home = Path.home()
cached = {m: [] for m in _WHISPER_MODELS}
# openai-whisper: ~/.cache/whisper/{model}.pt
ow_cache = home / '.cache' / 'whisper'
if ow_cache.is_dir():
for m in _WHISPER_MODELS:
# openai-whisper uses "large-v3.pt" or "large-v3.en.pt"
if (ow_cache / f'{m}.pt').exists():
cached[m].append('openai-whisper')
# mlx-whisper: ~/.cache/huggingface/hub/models--mlx-community--whisper-{model}-mlx/
hf_cache = home / '.cache' / 'huggingface' / 'hub'
if hf_cache.is_dir():
for m in _WHISPER_MODELS:
slug = m.replace('-', '-') # large-v3 stays large-v3
mlx_dir = hf_cache / f'models--mlx-community--whisper-{slug}-mlx'
if mlx_dir.is_dir():
cached[m].append('mlx-whisper')
# whisper-ctranslate2 / faster-whisper: Systran--faster-whisper-{model}
ct2_dir = hf_cache / f'models--Systran--faster-whisper-{slug}'
if ct2_dir.is_dir():
cached[m].append('whisper-ctranslate2')
return cached
def check_whisper():
"""Detect platform/GPU/memory and recommend whisper backend + model.
Returns a structured dict with platform info, installed backends,
cached models, and recommendation.
"""
system = platform.system()
machine = platform.machine()
is_apple_silicon = system == 'Darwin' and machine == 'arm64'
mem_gb = _detect_memory_gb()
gpu = _detect_gpu()
backends = _detect_whisper_backends()
# Model recommendation based on available memory.
# Treat unknown memory as unknown, not as 0 GB.
avail_gb = gpu['vram_gb'] if gpu['vram_gb'] is not None else mem_gb
if avail_gb is not None and avail_gb >= 10:
rec_model = 'large-v3'
model_reason = f'{avail_gb:.0f} GB available'
elif avail_gb is not None and avail_gb >= 5:
rec_model = 'medium'
model_reason = f'{avail_gb:.0f} GB available (large-v3 needs ~10 GB)'
elif avail_gb is None:
rec_model = 'medium' if is_apple_silicon or gpu['type'] == 'cuda' else 'tiny'
model_reason = 'Memory unknown; using a safe default for this platform'
else:
rec_model = 'tiny'
model_reason = f'{avail_gb:.0f} GB available (medium needs ~5 GB)'
# Backend recommendation
if is_apple_silicon:
rec_backend = 'mlx-whisper'
rec_reason = 'Apple Silicon native (MLX), fastest on this platform'
install_cmd = 'pip install mlx-whisper'
model_flag = f'mlx-community/whisper-{rec_model}-mlx'
example = (f'mlx_whisper "${{slug}}/${{slug}}.mp4" '
f'--model {model_flag} '
f'--language "$src_lang" '
f'--output-format srt --output-dir "${{slug}}"')
elif gpu['type'] == 'cuda':
rec_backend = 'whisper-ctranslate2'
rec_reason = f'CTranslate2 + CUDA ({gpu["name"]}), ~4x faster than openai-whisper'
install_cmd = 'pip install whisper-ctranslate2'
model_flag = rec_model
example = (f'whisper-ctranslate2 "${{slug}}/${{slug}}.mp4" '
f'--model {model_flag} '
f'--language "$src_lang" '
f'--output_format srt --output_dir "${{slug}}"')
else:
rec_backend = 'whisper-ctranslate2'
rec_reason = 'CTranslate2, ~4x faster than openai-whisper on CPU'
install_cmd = 'pip install whisper-ctranslate2'
model_flag = rec_model
example = (f'whisper-ctranslate2 "${{slug}}/${{slug}}.mp4" '
f'--model {model_flag} '
f'--language "$src_lang" '
f'--output_format srt --output_dir "${{slug}}"')
# Detect cached models
models = _detect_whisper_models()
# Check if recommended model is cached for recommended backend
rec_model_cached = rec_backend in models.get(rec_model, [])
rec_model_size = _MODEL_SIZE_GB.get(rec_model, 0)
# Find best already-cached model (largest that fits in memory)
best_cached = None
for m in reversed(_WHISPER_MODELS): # large-v3 first
if models[m]: # cached by any backend
best_cached = m
break
# Fallback command
fallback = None
rec_installed = backends.get(rec_backend, False)
if not rec_installed and backends.get('openai-whisper'):
fallback = (f'whisper "${{slug}}/${{slug}}.mp4" --model {rec_model} '
f'--language "$src_lang" --output_format srt --output_dir "${{slug}}"')
os_label = {'Darwin': 'macOS', 'Windows': 'Windows', 'Linux': 'Linux'}.get(system, system)
return {
'ok': True,
'command': 'check-whisper',
'platform': {
'os': os_label,
'arch': machine,
'apple_silicon': is_apple_silicon,
'memory_gb': round(mem_gb, 1) if mem_gb else None,
},
'gpu': gpu,
'backends': backends,
'models': {m: cached for m, cached in models.items() if cached},
'recommendation': {
'backend': rec_backend,
'reason': rec_reason,
'model': rec_model,
'model_reason': model_reason,
'model_cached': rec_model_cached,
'model_download_gb': rec_model_size if not rec_model_cached else 0,
'installed': rec_installed,
'install': install_cmd if not rec_installed else None,
'command': example,
},
'best_cached_model': best_cached,
'fallback': fallback,
}
def _print_check_whisper_text(result):
"""Human-readable output for check-whisper."""
p = result['platform']
gpu = result['gpu']
rec = result['recommendation']
arch_note = ' (Apple Silicon)' if p['apple_silicon'] else ''
print(f'=== yt2bb Whisper Environment Check ===\n')
print(f'Platform: {p["os"]} {p["arch"]}{arch_note}')
if p['memory_gb']:
print(f'Memory: {p["memory_gb"]:.0f} GB')
else:
print(f'Memory: unknown')
vram_note = f' ({gpu["vram_gb"]:.0f} GB VRAM)' if gpu['type'] == 'cuda' else ''
print(f'GPU: {gpu["name"]}{vram_note}')
print()
print('Installed backends:')
for name, installed in result['backends'].items():
mark = '+' if installed else '-'
print(f' [{mark}] {name}')
print()
print('Cached models:')
models = result.get('models', {})
if models:
for m, cached_by in models.items():
size = _MODEL_SIZE_GB.get(m, 0)
print(f' [+] {m} ({size:.1f} GB) — via {", ".join(cached_by)}')
else:
print(' (none)')
print()
download_note = ''
if rec['model_download_gb'] > 0:
download_note = f' — needs ~{rec["model_download_gb"]:.1f} GB download'
print(f'Recommended:')
print(f' Backend: {rec["backend"]} — {rec["reason"]}')
print(f' Model: {rec["model"]} ({rec["model_reason"]}){download_note}')
if rec['install']:
print(f' Install: {rec["install"]}')
print()
print(f'Command:')
print(f' {rec["command"]}')
best_cached = result.get('best_cached_model')
if best_cached and best_cached != rec['model'] and rec['model_download_gb'] > 0:
print()
print(f'Tip: {best_cached} is already cached and can be used immediately.')
if result['fallback']:
print()
print(f'Note: openai-whisper is already installed. You can use it as a fallback:')
print(f' {result["fallback"]}')
# ---------------------------------------------------------------------------
# ASS subtitle generation
# ---------------------------------------------------------------------------
def _srt_time_to_ass(ts):
"""Convert SRT timestamp (HH:MM:SS,mmm) to ASS format (H:MM:SS.cc)."""
h, m, rest = ts.split(':')
s, ms = rest.split(',')
cs = int(ms) // 10
return f"{int(h)}:{m}:{s}.{cs:02d}"
def _ass_escape(text):
"""Escape characters that have special meaning in ASS dialogue text."""
return text.replace('{', r'\{').replace('}', r'\}')
# ASS color format: &HAABBGGRR (alpha=00 is fully opaque, FF is fully
# transparent). For BorderStyle=3 (opaque box), libass uses OutlineColour
# as the box fill — so the semi-transparent gray lives in `outline_color`,
# not `back_color`.
_PRESET_CLEAN = {
'name': 'Professional Clean',
'box': True,
'top_margin': 105,
'bottom_margin': 55,
'styles': {
'EN': {
'fontsize': 44,
'primary': '&H0000EFFF', # yellow text
'secondary': '&H000000FF',
'outline_color': '&H96C8C8C8', # semi-transparent light gray box fill
'back_color': '&H80000000', # soft black drop shadow
'border_style': 3,
'outline': 6, # box padding around the text
'shadow': 0,
},
'ZH': {
'fontsize': 56,
'primary': '&H0000D4FF', # golden yellow
'secondary': '&H000000FF',
'outline_color': '&H96C8C8C8',
'back_color': '&H80000000',
'border_style': 3,
'outline': 6,
'shadow': 0,
},
},
'en_tag': '',
'zh_tag': '',
}
_PRESET_NETFLIX = {
'name': 'Netflix Clean',
# Modeled on Netflix's Timed Text Style Guide: pure white text,
# thin black outline, soft drop shadow, no background box, slightly
# inset from the bottom edge so the block sits in the safe area.
'box': False,
'top_margin': 145,
'bottom_margin': 72,
'styles': {
'EN': {
'fontsize': 52,
'primary': '&H00FFFFFF', # pure white
'secondary': '&H000000FF',
'outline_color': '&H00000000', # black outline
'back_color': '&H80000000', # soft black drop shadow
'border_style': 1, # outline + shadow (no box)
'outline': 3,
'shadow': 2,
},
'ZH': {
'fontsize': 58,
'primary': '&H00FFFFFF',
'secondary': '&H000000FF',
'outline_color': '&H00000000',
'back_color': '&H80000000',
'border_style': 1,
'outline': 3,
'shadow': 2,
},
},
'en_tag': '',
'zh_tag': '',
}
_PRESET_GLOW = {
'name': 'Vibrant Glow',
'box': False,
'top_margin': 105,
'bottom_margin': 55,
'styles': {
'EN': {
'fontsize': 44,
'primary': '&H00FFFFFF',
'secondary': '&H000000FF',
'outline_color': '&H000080FF',
'back_color': '&H00000000',
'border_style': 1,
'outline': 5,
'shadow': 0,
},
'ZH': {
'fontsize': 56,
'primary': '&H0000FFFF',
'secondary': '&H000000FF',
'outline_color': '&H00003080',
'back_color': '&H00000000',
'border_style': 1,
'outline': 5,
'shadow': 0,
},
},
'en_tag': r'{\blur5}',
'zh_tag': r'{\blur5}',
}
ASS_PRESETS = {
'clean': _PRESET_CLEAN,
'netflix': _PRESET_NETFLIX,
'glow': _PRESET_GLOW,
}
_ASS_STYLE_FORMAT = (
'Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, '
'OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, '
'ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, '
'Alignment, MarginL, MarginR, MarginV, Encoding'
)
def _scaled_ass_metric(value, scale, minimum=1):
"""Scale ASS metrics by resolution while keeping values usable on smaller videos."""
return max(minimum, int(round(value * scale)))
def _build_preset_style_lines(preset, font, top_lang, resolution):
"""Build ASS style lines for a preset with resolution-aware sizing."""
_, height = resolution
scale = height / 1080
top_margin = _scaled_ass_metric(preset['top_margin'], scale, minimum=24)
bottom_margin = _scaled_ass_metric(preset['bottom_margin'], scale, minimum=18)
lang_margins = {
'EN': top_margin if top_lang == 'en' else bottom_margin,
'ZH': top_margin if top_lang == 'zh' else bottom_margin,
}
# Style field order (ASS v4+):
# Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour,
# BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing,
# Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR,
# MarginV, Encoding
style_lines = []
for lang in ('EN', 'ZH'):
style = preset['styles'][lang]
style_lines.append(
'Style: {lang},{font},{fontsize},{primary},{secondary},{outline_color},'
'{back_color},-1,0,0,0,100,100,0,0,{border_style},{outline},{shadow},'
'2,15,15,{margin_v},1'.format(
lang=lang,
font=font,
fontsize=_scaled_ass_metric(style['fontsize'], scale, minimum=18),
primary=style['primary'],
secondary=style['secondary'],
outline_color=style['outline_color'],
back_color=style['back_color'],
border_style=style['border_style'],
outline=_scaled_ass_metric(style['outline'], scale, minimum=1),
shadow=_scaled_ass_metric(style['shadow'], scale, minimum=0),
margin_v=lang_margins[lang],
)
)
return style_lines
def _parse_ass_styles(path):
"""Extract style lines and override tags from an external ASS file."""
content = Path(path).read_text(encoding='utf-8')
style_lines = []
style_names = set()
in_styles = False
en_tag, zh_tag = '', ''
for line in content.splitlines():
stripped = line.strip()
if stripped.startswith('[V4+ Styles]') or stripped.startswith('[V4 Styles]'):
in_styles = True
continue
if stripped.startswith('[') and in_styles:
in_styles = False
if in_styles and stripped.startswith('Style:'):
style_lines.append(stripped)
parts = stripped.split(':', 1)[1].split(',', 1)
if parts:
style_names.add(parts[0].strip())
if stripped.startswith('; en_tag='):
en_tag = stripped.split('=', 1)[1].strip()
if stripped.startswith('; zh_tag='):
zh_tag = stripped.split('=', 1)[1].strip()
if not style_lines:
raise ValueError(f"No Style lines found in {path}")
missing = {'EN', 'ZH'} - style_names
if missing:
raise ValueError(
f"Style file {path} must define styles named EN and ZH; missing: {', '.join(sorted(missing))}"
)
return style_lines, en_tag, zh_tag
def to_ass(entries, preset='clean', font='PingFang SC', resolution=(1920, 1080),
top_lang='zh', style_file=None):
"""Convert bilingual SRT entries to a styled ASS file.
Each bilingual entry (EN\\nZH text) is split into two separate ASS
Dialogue lines with independent styles, enabling per-line color and
glow effects not possible with SRT force_style.
"""
w, h = resolution
if style_file:
style_lines, en_tag, zh_tag = _parse_ass_styles(style_file)
title = f'yt2bb bilingual — custom ({Path(style_file).stem})'
else:
p = ASS_PRESETS[preset]
en_tag, zh_tag = p['en_tag'], p['zh_tag']
title = f'yt2bb bilingual — {p["name"]}'
style_lines = _build_preset_style_lines(p, font, top_lang, resolution)
header = '\n'.join([
'[Script Info]',
f'Title: {title}',
'ScriptType: v4.00+',
'WrapStyle: 0',
f'PlayResX: {w}',
f'PlayResY: {h}',
'ScaledBorderAndShadow: yes',
'',
'[V4+ Styles]',
_ASS_STYLE_FORMAT,
'\n'.join(style_lines),
'',
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
])
dialogue_lines = []
for e in entries:
start = _srt_time_to_ass(e['start'])
end = _srt_time_to_ass(e['end'])
parts = e['text'].rsplit('\n', 1)
en_text = _ass_escape(parts[0]) if parts else ''
zh_text = _ass_escape(parts[1]) if len(parts) > 1 else ''
if en_text:
dialogue_lines.append(
f"Dialogue: 0,{start},{end},EN,,0,0,0,,{en_tag}{en_text}"
)
if zh_text:
dialogue_lines.append(
f"Dialogue: 0,{start},{end},ZH,,0,0,0,,{zh_tag}{zh_text}"
)
return header + '\n' + '\n'.join(dialogue_lines) + '\n'
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
if __name__ == '__main__':
parser = argparse.ArgumentParser(
prog='srt_utils.py',
description='SRT utilities for yt2bb. Use --format json for agent-friendly output.',
)
parser.add_argument('--version', action='version', version='%(prog)s 2.5.0')
sub = parser.add_subparsers(dest='cmd', required=True)
# Shared --format flag
fmt_parent = argparse.ArgumentParser(add_help=False)
fmt_parent.add_argument('--format', choices=['text', 'json'], default='text',
dest='output_format',
help='Output format: text (human) or json (agent)')
p_merge = sub.add_parser('merge', parents=[fmt_parent],
help='Merge EN and ZH SRT into bilingual SRT')
p_merge.add_argument('en_srt')
p_merge.add_argument('zh_srt')
p_merge.add_argument('output_srt')
p_merge.add_argument('--pad-missing', action='store_true',
help='Pad shorter list instead of failing on count mismatch')
p_merge.add_argument('--dry-run', action='store_true',
help='Validate inputs and report what would happen without writing')
p_validate = sub.add_parser('validate', parents=[fmt_parent],
help='Validate SRT timing')
p_validate.add_argument('input_srt')
p_lint = sub.add_parser('lint', parents=[fmt_parent],
help='Lint SRT against Netflix Timed Text Style Guide')
p_lint.add_argument('input_srt')
p_lint.add_argument('--max-cps-en', type=float, default=17.0,
help='Max English chars/sec (default: 17)')
p_lint.add_argument('--max-cps-zh', type=float, default=9.0,
help='Max Chinese chars/sec (default: 9)')
p_lint.add_argument('--min-duration-ms', type=int, default=833,
help='Min cue duration in ms (default: 833 = 5/6 s)')
p_lint.add_argument('--max-duration-ms', type=int, default=7000,
help='Max cue duration in ms (default: 7000)')
p_lint.add_argument('--min-gap-ms', type=int, default=83,
help='Min gap between cues in ms (default: 83 = 2 frames @ 24fps)')
p_lint.add_argument('--max-chars-en', type=int, default=42,
help='Max English chars per line (default: 42)')
p_lint.add_argument('--max-chars-zh', type=int, default=16,
help='Max Chinese full-width chars per line (default: 16)')
p_fix = sub.add_parser('fix', parents=[fmt_parent],
help='Fix SRT timing issues')
p_fix.add_argument('input_srt')
p_fix.add_argument('output_srt')
p_slug = sub.add_parser('slugify', parents=[fmt_parent],
help='Convert title to URL-safe slug')
p_slug.add_argument('title', nargs='+')
p_ass = sub.add_parser('to_ass', parents=[fmt_parent],
help='Convert bilingual SRT to styled ASS (supports glow)')
p_ass.add_argument('input_srt')
p_ass.add_argument('output_ass')
p_ass.add_argument('--preset', choices=['clean', 'netflix', 'glow'], default='clean',
help='Subtitle style preset (default: clean)')
p_ass.add_argument('--font', default='PingFang SC',
help='Font family name (default: PingFang SC)')
p_ass.add_argument('--res', default='1920x1080',
help='Video resolution WxH (default: 1920x1080)')
p_ass.add_argument('--top', choices=['zh', 'en'], default='zh',
help='Which language on top (default: zh)')
p_ass.add_argument('--style-file', default=None,
help='External .ass file with custom [V4+ Styles] (overrides --preset/--font/--top)')
p_ass.add_argument('--dry-run', action='store_true',
help='Validate inputs and report what would happen without writing')
sub.add_parser('check-whisper', parents=[fmt_parent],
help='Detect platform/GPU and recommend whisper backend + model')
args = parser.parse_args()
fmt = args.output_format
# --- merge ---
if args.cmd == 'merge':
try:
en_entries = parse_srt(args.en_srt)
zh_entries = parse_srt(args.zh_srt)
except OSError as e:
_emit(
{'ok': False, 'command': 'merge',
'error': {'code': 'io_error', 'message': str(e), 'retryable': False}},
fmt,
lambda r: print(f"Error: {r['error']['message']}", file=sys.stderr),
)
sys.exit(EXIT_RUNTIME)
if args.dry_run:
match = len(en_entries) == len(zh_entries)
_emit(
{'ok': True, 'command': 'merge', 'dry_run': True,
'en_entries': len(en_entries), 'zh_entries': len(zh_entries),
'counts_match': match, 'output': args.output_srt},
fmt,
lambda r: print(
f"Dry run: EN={r['en_entries']}, ZH={r['zh_entries']}, "
f"match={'yes' if r['counts_match'] else 'NO'} -> {r['output']}"),
)
sys.exit(EXIT_OK)
try:
merged = merge_bilingual(en_entries, zh_entries, pad_missing=args.pad_missing)
except ValueError as e:
_emit(
{'ok': False, 'command': 'merge',
'error': {'code': 'count_mismatch', 'message': str(e), 'retryable': False}},
fmt,
lambda r: print(f"Error: {r['error']['message']}", file=sys.stderr),
)
sys.exit(EXIT_VALIDATION)
write_srt(merged, args.output_srt)
_emit(
{'ok': True, 'command': 'merge', 'entries': len(merged), 'output': args.output_srt},
fmt,
lambda r: print(f"Merged {r['entries']} entries -> {r['output']}"),
)
# --- validate ---
elif args.cmd == 'validate':
try:
entries = parse_srt(args.input_srt)
except OSError as e:
_emit(
{'ok': False, 'command': 'validate',
'error': {'code': 'io_error', 'message': str(e), 'retryable': False}},
fmt,
lambda r: print(f"Error: {r['error']['message']}", file=sys.stderr),
)
sys.exit(EXIT_RUNTIME)
issues = validate_srt(entries)
if issues:
_emit(
{'ok': False, 'command': 'validate', 'file': args.input_srt,
'entries': len(entries), 'issue_count': len(issues), 'issues': issues},
fmt,
lambda r: (
print(f"Found {r['issue_count']} issues in {r['file']}:"),
[print(f" {i}") for i in r['issues']],
),
)
sys.exit(EXIT_VALIDATION)
else:
_emit(
{'ok': True, 'command': 'validate', 'file': args.input_srt,
'entries': len(entries), 'issue_count': 0, 'issues': []},
fmt,
lambda r: print(f"OK: {r['entries']} entries, no issues found"),
)
# --- lint (Netflix spec) ---
elif args.cmd == 'lint':
try:
entries = parse_srt(args.input_srt)
except OSError as e:
_emit(
{'ok': False, 'command': 'lint',
'error': {'code': 'io_error', 'message': str(e), 'retryable': False}},
fmt,
lambda r: print(f"Error: {r['error']['message']}", file=sys.stderr),
)
sys.exit(EXIT_RUNTIME)
issues = lint_srt(
entries,
min_duration_ms=args.min_duration_ms,
max_duration_ms=args.max_duration_ms,
min_gap_ms=args.min_gap_ms,
max_cps_en=args.max_cps_en,
max_cps_zh=args.max_cps_zh,
max_chars_en=args.max_chars_en,
max_chars_zh=args.max_chars_zh,
)
errors = [i for i in issues if i['severity'] == 'error']
warnings = [i for i in issues if i['severity'] == 'warning']
result = {
'ok': len(errors) == 0,
'command': 'lint',
'file': args.input_srt,
'entries': len(entries),
'thresholds': {
'min_duration_ms': args.min_duration_ms,
'max_duration_ms': args.max_duration_ms,
'min_gap_ms': args.min_gap_ms,
'max_cps_en': args.max_cps_en,
'max_cps_zh': args.max_cps_zh,
'max_chars_en': args.max_chars_en,
'max_chars_zh': args.max_chars_zh,
},
'error_count': len(errors),
'warning_count': len(warnings),
'issues': issues,
}
def _print_lint(r):
if r['error_count'] == 0 and r['warning_count'] == 0:
print(f"OK: {r['entries']} entries, no Netflix-spec issues")
return
print(f"{r['file']}: {r['error_count']} error(s), "
f"{r['warning_count']} warning(s) across {r['entries']} entries")
for issue in r['issues']:
tag = 'ERROR' if issue['severity'] == 'error' else 'WARN '
print(f" [{tag}] {issue['message']}")
_emit(result, fmt, _print_lint)
if errors:
sys.exit(EXIT_VALIDATION)
# --- fix ---
elif args.cmd == 'fix':
try:
entries = parse_srt(args.input_srt)
except OSError as e:
_emit(
{'ok': False, 'command': 'fix',
'error': {'code': 'io_error', 'message': str(e), 'retryable': False}},
fmt,
lambda r: print(f"Error: {r['error']['message']}", file=sys.stderr),
)
sys.exit(EXIT_RUNTIME)
before = len(validate_srt(entries))
fixed = fix_srt(entries)
write_srt(fixed, args.output_srt)
after = len(validate_srt(fixed))
_emit(
{'ok': True, 'command': 'fix', 'entries': len(fixed), 'output': args.output_srt,
'issues_before': before, 'issues_after': after},
fmt,
lambda r: print(f"Fixed {r['entries']} entries -> {r['output']} "
f"(issues: {r['issues_before']} -> {r['issues_after']})"),
)
# --- slugify ---
elif args.cmd == 'slugify':
title = ' '.join(args.title)
slug = slugify(title)
_emit(
{'ok': True, 'command': 'slugify', 'title': title, 'slug': slug},
fmt,
lambda r: print(r['slug']),
)
# --- check-whisper ---
elif args.cmd == 'check-whisper':
result = check_whisper()
_emit(result, fmt, _print_check_whisper_text)
# --- to_ass ---
elif args.cmd == 'to_ass':
try:
w, h = map(int, args.res.lower().split('x'))
except ValueError:
_emit(
{'ok': False, 'command': 'to_ass',
'error': {'code': 'bad_resolution', 'message': f"--res must be WxH, got '{args.res}'",
'retryable': False}},
fmt,
lambda r: print(f"Error: {r['error']['message']}", file=sys.stderr),
)
sys.exit(EXIT_VALIDATION)
try:
entries = parse_srt(args.input_srt)
if args.style_file:
_parse_ass_styles(args.style_file) # validate early
except (OSError, ValueError) as e:
_emit(
{'ok': False, 'command': 'to_ass',
'error': {'code': 'runtime_error', 'message': str(e), 'retryable': False}},
fmt,
lambda r: print(f"Error: {r['error']['message']}", file=sys.stderr),
)
sys.exit(EXIT_RUNTIME)
preset_name = args.preset if not args.style_file else f'custom ({Path(args.style_file).stem})'
if args.dry_run:
_emit(
{'ok': True, 'command': 'to_ass', 'dry_run': True,
'entries': len(entries), 'preset': preset_name,
'top_lang': args.top, 'resolution': f'{w}x{h}',
'output': args.output_ass},
fmt,
lambda r: print(
f"Dry run: [{r['preset']}] {r['entries']} entries, "
f"{r['resolution']}, top={r['top_lang']} -> {r['output']}"),
)
sys.exit(EXIT_OK)
try:
ass_content = to_ass(entries, preset=args.preset, font=args.font,
resolution=(w, h), top_lang=args.top,
style_file=args.style_file)
except (OSError, ValueError) as e:
_emit(
{'ok': False, 'command': 'to_ass',
'error': {'code': 'runtime_error', 'message': str(e), 'retryable': False}},
fmt,
lambda r: print(f"Error: {r['error']['message']}", file=sys.stderr),
)
sys.exit(EXIT_RUNTIME)
Path(args.output_ass).write_text(ass_content, encoding='utf-8')
_emit(
{'ok': True, 'command': 'to_ass', 'entries': len(entries), 'output': args.output_ass,
'preset': preset_name, 'top_lang': args.top},
fmt,
lambda r: print(f"[{r['preset']}] {r['entries']} entries -> {r['output']}"),
)
Related skills
FAQ
What tools does yt2bb require?
Python 3, ffmpeg, yt-dlp, and whisper (openai-whisper) on PATH.
What does yt2bb produce?
A video with hardcoded bilingual EN/ZH subtitles plus a publish_info.md containing Bilibili upload metadata.