explainroo
explainroo is a free, open source tool that lets your AI agent make explainer videos with a voice. It runs on your own computer and needs no API key.
Streamlining media publishing: prepare, upload, and edit videos (YouTube) and podcasts
Translation pending
MediaPythonGitHub @reeleehqOnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
The maker's introduction consists of the line above. Read the repository README for more details. Repository README ↗
Loading the full README.
Read the full README ↗Streamline media publishing: prepare a piece of media once, publish it anywhere — to YouTube (upload + edit) or as a podcast (show notes, chapters, RSS) — plus a download module for pulling videos back down.
import yb
# Download a video (-> ~/Downloads, named "Title (id).mp4")
r = yb.download_youtube_video("https://youtu.be/PRa9ciOe-us")
# One call: transcribe, write title/description/keywords, detect chapters,
# render a thumbnail, and upload to YouTube (unlisted) with captions.
yb.prepare_and_publish(
r.path,
language="English",
language_code="en",
audio_language_code="en",
privacy_status="unlisted",
)
yb separates what you publish from where you publish it. A media file
is turned into a platform-neutral PublicationContent — title,
description, keywords, chapters, captions, thumbnail — and thin adapters map
that onto each destination's API. The media work itself (transcription, chapter
detection, thumbnails, dubbing/translation) lives in the
mixing package; yb is the
publication layer on top.
pip install yb # core (needs `mixing`)
pip install 'yb[youtube]' # YouTube upload/edit (Google API client)
pip install 'yb[download]' # yt-dlp downloader
pip install 'yb[podcast]' # podcast chapters (ID3/PSC) + RSS
pip install 'yb[llm]' # LLM metadata/chapter copy (via `aix`)
pip install 'yb[all]' # everything
Everything heavy is an optional extra, and import yb never fails for a missing
one — the error only surfaces when you actually call that feature. You also need
ffmpeg on PATH for media work, and an ELEVENLABS_API_KEY for transcription.
No video formats found!. yb[download] depends on yt-dlp[default], which brings the solver scripts; the runtime you install yourself: curl -fsSL https://deno.land/install.sh | sh (or brew install deno, or pip install 'yt-dlp[deno]' into the same environment). Deno is the runtime yt-dlp enables by default; to use another (node, bun), enable it yourself with extra_opts={'js_runtimes': {'node': {}}}. The installer puts Deno in ~/.deno/bin and adds it to PATH only for interactive shells; yb also looks there (and next to the running Python), and passes yt-dlp the path, so cron jobs and services work too. yb check also runs deno --version and reports a Deno older than yt-dlp's minimum (read from yt-dlp itself) with the deno upgrade command. To put it on PATH anyway: export PATH="$HOME/.deno/bin:$PATH" in ~/.profile. See yt-dlp's EJS notes.Run yb check (or yb.download.check_requirements()) to see what is missing and how to install it. The first download in a process runs the same check and warns; it never refuses to try.
from yb.download import (
download_youtube_video,
download_youtube_audio,
youtube_video_info,
search_youtube,
)
youtube_video_info(url) # metadata only, no download
search_youtube(
"song name", limit=5
) # -> [SearchHit(id, title, duration, channel, url, view_count)]
search_youtube(
"house dance footwork", limit=20, creative_commons=True, min_s=45, max_s=900
) # YouTube's Creative Commons filter + a duration window
download_audio_bounded(
url, download_dir=tmp, max_bytes=..., max_duration_s=..., validate_target=...
) # mp3 + caps + copyright notice, for services (see below)
download_youtube_video(url) # best quality -> ~/Downloads/"Title (id).mp4"
download_youtube_video(
url,
download_dir="~/clips",
write_info_json=True,
write_subtitles=True,
subtitle_langs=("en", "fr"),
)
download_youtube_subtitles(url, download_dir="~/clips") # subtitles only, no media
download_youtube_audio(url) # audio only, source format -> "Title (id).webm"
download_youtube_audio(url, audio_format="mp3") # ...converted (needs ffmpeg)
download_youtube_audio(url, audio_format="mp3", bitrate="320k")
Links from untrusted users. download_audio_bounded is the one to call from a service: validate_target (your SSRF policy; the default only requires http(s)) is applied to every URL yt-dlp requests, including the media a page points at and where a redirect chain ends (the hops in between are requested blind, their content never returned); the byte cap and the timeout_s wall-clock budget hold even when the size is unknown; formats needing an external downloader are never selected; no cookies are sent unless you pass cookies_file=. It raises ValueError subclasses for every refusal (AudioTargetError, AudioLimitError, AudioSourceError), RateLimitedError when the source pushes back, and ImportError (with the install command) when yt-dlp is missing.
Subtitles are best-effort (download_youtube_video, download_youtube_audio, download_youtube_playlist). YouTube rate-limits its captions endpoint before the video one, so when only the subtitle fetch fails the video is downloaded without them: result.subtitles_error says why, a warning is issued, and download_youtube_subtitles fetches them later. The retry is in place (same item, same name, nothing extracted twice), in a playlist too. Pass on_subtitle_error="raise" when the subtitles are not optional (a playlist then raises PlaylistSubtitleError, carrying the items that did download).
The destination defaults to $YB_DOWNLOAD_DIR (else ~/Downloads). Every knob
— format, filename template, which sidecar metadata to also save — is
overridable, and any raw yt-dlp option can be passed via extra_opts=.
Cookies (headless servers). YouTube bot-blocks anonymous downloads from many
servers. Export a Netscape-format cookies file from a logged-in browser elsewhere
and point yb at it with YB_COOKIES_FILE=/path/cookies.txt (or just put it at
~/.config/yb/youtube-cookies.txt); every download and info call then uses it,
with no extra argument. Pass cookies_file= to choose one per call (False
turns the default off). An explicit cookies_from_browser= or a cookiefile /
cookiesfrombrowser in extra_opts= always wins. With no file and no env var,
nothing changes. Never commit the file. yt-dlp rewrites the file on exit, so a
file that is not writable (read-only mount) is handed over as a private temporary
copy automatically; set YB_COOKIES_PRIVATE_COPY=1 to always do that (parallel
workers sharing one file; every worker must set it). Refreshed cookies are then
not saved back.
When cookies stop working (servers). YouTube rotates the cookies of a browser session that stays open, and from then on every request gets "Sign in to confirm you're not a bot". yb check --online checks that the cookies file holds a logged-in session, reads one public video with it (on a copy, so the check never rewrites the file), and says when it was rotated or refused. Export cookies that do not rotate: open a private / incognito window, log in to YouTube, go to https://www.youtube.com/robots.txt in that same tab, export the youtube.com cookies (Netscape format), then close the private window right away and never reopen that session (yt-dlp's cookie guide).
HTTP 403 on the video data: PO tokens. Some servers pass the bot check (metadata loads) but get HTTP 403 on the media, and automatic captions are missing "because a PO token was not provided". yt-dlp then needs a PO-token provider: pip install 'yb[pot]' (the bgutil-ytdlp-pot-provider plugin, picked up by yt-dlp automatically) and run its token server, a system dependency: docker run -d -p 127.0.0.1:4416:4416 --init brainicism/bgutil-ytdlp-pot-provider (or its Node script; keep it bound to localhost). yb check says whether the plugin is installed and yb check --online whether its server answers. While the media is refused, the subtitles alone may still come down: download_youtube_subtitles(url).
Audio format. download_youtube_audio keeps whatever the source offers by
default (YouTube's best audio is normally Opus in a .webm container). That
default needs no ffmpeg and avoids re-encoding one lossy format into another.
Pass audio_format= when you need a specific one — it's a no-op if the download
already is that format, so it's safe to pass unconditionally. If conversion
can't happen (no ffmpeg) it raises, and the download is always left on disk;
pass on_error="warn" to fall back to the unconverted file instead.
A time range. To source a sound or a clip without downloading the whole video, pass start= and/or end= to download_youtube_video or download_youtube_audio (seconds, or "mm:ss" / "hh:mm:ss"):
clip = download_youtube_audio(url, start="0:07.9", end="0:08.75")
clip.info["download_range"] # {"start": 7.9, "end": 8.75}, for provenance
The range is cut by ffmpeg (so it needs one; to use a specific build pass extra_opts={"ffmpeg_location": "/path/to/ffmpeg"}, e.g. when the system ffmpeg lacks what yt-dlp asks of it). Cuts land on keyframes; precise_cuts=True re-encodes for frame-accurate bounds. With the default filename template the range goes in the name (Title (id) [7.9-8.75].webm), so a clip never collides with the full download (a precise_cuts clip is named apart from a keyframe cut too).
A long playlist, politely. download_youtube_playlist takes pace= ("polite" sleeps 20-60 s between items and 1 s between requests, and caps the rate at 4 MB/s; or pass a Pace(...)), download_archive= (finished ids are skipped on re-run), playlist_items="1-42" and title_reject= for selection, and item_folders=True for one 001_<id>/ folder per item with the media and its sidecars side by side. For audio only, keep the source stream with fmt="bestaudio/best", merge_to=None:
from yb.download import download_youtube_playlist
download_youtube_playlist(
url,
download_dir="~/tapes",
fmt="bestaudio/best", # audio only, not re-encoded (.m4a)
merge_to=None,
pace="polite",
download_archive="~/tapes/archive.txt", # re-run to resume
playlist_items="1-42",
item_folders=True, # ~/tapes/001_<id>/<id>.m4a, .info.json, ...
write_description=True,
write_thumbnail=True,
write_subtitles=True,
embed_metadata=True,
)
When YouTube answers HTTP 429 or "confirm you're not a bot", yb raises yb.download.RateLimitedError with a backoff hint (wait, then retry with pace="polite", an archive, cookies and fewer items per run). pace also works on download_youtube_video and download_youtube_audio; the default None changes nothing, and raw extra_opts still win.
The same conversion is available standalone for files you already have:
from yb.audio_convert import convert_audio
convert_audio("talk.webm", "mp3") # -> talk.mp3
convert_audio("talk.webm", "wav") # lossless: bitrate not applied
from yb.youtube import prepare_and_publish, VideoMetadata, publish_video, CaptionTrack
# High level: prepare assets from the media file, then upload.
result = prepare_and_publish(
"promo.fr.mp4",
language="French",
language_code="fr",
audio_language_code="fr",
privacy_status="unlisted",
)
print(result["url"])
# Lower level: you supply the metadata and captions.
meta = VideoMetadata(
title="...",
description="...",
tags=[...],
default_language="en",
default_audio_language="en",
)
publish_video(
"promo.en.mp4",
meta,
privacy_status="unlisted",
captions=[CaptionTrack("promo.en.srt", "en", "English")],
thumbnail="thumb.jpg",
)
If captions, the thumbnail or the playlist fail after the upload (a 403 quotaExceeded, say), PartialPublish is raised with the new video's id in err.result. Call again with resume=err.result to finish without uploading a second copy.
PartialPublish is a RuntimeError and replaces the original error, which stays reachable as err.__cause__. Code that caught googleapiclient.errors.HttpError around a publish call no longer catches a failure that happens after the upload: catch PartialPublish too (or inspect err.__cause__).
Chapters are on by default: when the media is long enough to host them
(YouTube requires ≥3 markers, each ≥10 s, first at 0:00), they are detected
from the transcript and embedded in the description so YouTube renders
interactive chapters. Short clips simply get none.
from yb.youtube import update_video_fields, upsert_caption, set_chapters, set_thumbnail
from mixing.chapters import Chapter
update_video_fields("VIDEO_ID", title="New title", tags=["a", "b"])
upsert_caption(
"VIDEO_ID", "subs.fr.srt", language="fr", name="Français"
) # replaces same-lang track
set_chapters("VIDEO_ID", [Chapter(0, "Intro"), Chapter(45, "Demo")])
set_thumbnail("VIDEO_ID", "thumb.jpg")
Privacy, and replacing a published video with a re-cut (YouTube cannot swap the file):
from yb.youtube import update_video_status, supersede
update_video_status("VIDEO_ID", privacy_status="public") # other status fields kept
supersede(
"OLD_ID", "NEW_ID", new_privacy="public"
) # new takes old's playlist spots; old goes private
supersede does not change the new video's privacy unless you pass new_privacy=, and uploads are unlisted by default. So superseding a public video with a fresh upload leaves the public with neither: the old one goes private and the re-cut stays unlisted. When that happens, supersede emits a VisibilityDropWarning before it changes anything. Pass new_privacy="public" to publish the re-cut, or make the warning an error to refuse such calls:
import warnings
from yb.youtube import VisibilityDropWarning
warnings.simplefilter(
"error", VisibilityDropWarning
) # supersede now raises, changing nothing
from yb.youtube import video_metadata, FIELD_GROUPS
video_metadata(
"VIDEO_ID", group="engagement"
) # dict: views, likes, dislikes, comments, ...
print(
video_metadata("VIDEO_ID", group="engagement", as_table=True)
) # readable ASCII table
video_metadata(
"VIDEO_ID", fields=["title", "views", "likes"]
) # pick & order exact fields
print(
video_metadata(["ID1", "ID2"], group="engagement", as_table=True)
) # compare videos, one row each
One request returns a flat, typed view of a video's live numbers plus content
and status details. With no group/fields you get every field; fields
overrides group. Named groups (FIELD_GROUPS): engagement, overview,
content, status, identity. dislikes is returned only to the video's
owner (else None); watch-time/retention/shares live in the YouTube
Analytics API, not here.
You have a song and its cover; YouTube wants a video. yb.music renders one
(through muvid.visualize) and publishes
it through the same machinery as everything else. Needs pip install 'yb[music]'
(pulls muvid, which needs ffmpeg); uploading also needs yb[youtube].
from yb.music import prepare_music_video, publish_music, publish_folder
# Simplest: the cover on a 16:9 canvas, held for the song's length.
mv = prepare_music_video("song.wav", image="cover.png")
mv.video, mv.thumbnail # -> song.mp4, song.thumb.jpg
# Render and upload in one call.
publish_music("song.wav", image="cover.png", privacy_status="unlisted")
A whole folder → an album. Drop (song.wav, song.jpeg) pairs in a folder —
the filename is the title, covers match by name — and publish them all, each
song rendered with the next visual in a rotation:
# Test the first song (unlisted) before committing the set…
publish_folder("~/album", limit=1)
# …then do the rest, collected into one playlist.
publish_folder("~/album", playlist="My Album")
By default the rotation is waves → cqt → spectrum → bars → scope (override with
cycle=), so a set varies method to method yet reads as one release — reinforced
by a consistent teal accent and one loudness target across every video.
prepare_folder(...) renders without uploading, for review first.
Pick a single visual with visual=: "still" (default when there's an
image), "ken_burns" (a slow pan/zoom), or an audio-reactive one — "cqt"
(pitch-aligned bars, the most musical), "bars", "spectrum", "waves",
"scope". All the reactive ones are pure ffmpeg. You can also pass your own
callable (see muvid.visualize.register_visual).
What you get for free, because it's what makes a set of songs feel like a release:
normalize=False to opt out.Check what you made before you ship it:
from muvid.visualize import verify_video, report
print(report(verify_video("song.mp4", audio="song.wav", thumbnail="song.thumb.jpg")))
See the music2video skill for the visualizer menu and the full quality
checklist.
from yb.podcast import (
prepare_podcast_episode,
PodcastChannel,
EpisodeFeedItem,
build_feed,
)
ep = prepare_podcast_episode(
"episode.mp3", "out/", cover_image="cover.jpg", make_cover_video=True
)
# -> out/episode.mp3 (with ID3 chapters), .shownotes.txt, .psc.xml,
# .chapters.json, and .cover.mp4
# Assemble an RSS feed (delivery to Spotify/Apple is via your podcast host).
A podcast episode and a YouTube video are built from the same
PublicationContent: an episode "could just be audio with a cover image" (or a
Ken Burns video), which is exactly what prepare_podcast_episode can render.
YouTube upload needs a Google OAuth Desktop client (one-time). gcloud is
not required — the console path works. See the yb-setup skill for the
full walkthrough (enable the API, create the OAuth client, run consent, test).
Point yb at the client JSON via $YOUTUBE_CLIENT_SECRETS_FILE.
Publish the OAuth consent screen (Audience → Publish app) before consenting:
tokens minted while it is in Testing expire after 7 days. With no browser or
terminal on the machine (a phone-driven session), consent by paste-back:
yb auth, open the URL anywhere, then yb auth --paste '<redirected URL>'.
Several channels (profiles). Default stays your first account. For another channel add a section under "profiles" in ~/.config/yb/config.json, consent once with yb auth --profile NAME, check with yb whoami --profile NAME (prints the channel the token uploads to), then publish with profile="NAME" or YB_PROFILE=NAME. Each profile has its own token (youtube_token.NAME.json) and settings; an optional channel_id makes publish_* refuse a token for the wrong channel, and an optional login_hint (the account's email) makes the consent page preselect the right Google account. A redirect pasted without --profile is matched to the profile that started it, and yb says which.
Note: until your Google Cloud project passes YouTube's one-time API compliance audit, uploads may be forced to private even when you request
unlisted/public.
| Module | Role | Extra |
|---|---|---|
yb.content | platform-neutral content prep (metadata, chapters, thumbnail) | core |
yb.music | songs → music videos, published as an album | music (+youtube to upload) |
yb.youtube | upload, edit + read stats via YouTube Data API v3 | youtube |
yb.podcast | show notes, ID3/PSC chapters, cover video, RSS | podcast (+music for cover video) |
yb.download | yt-dlp downloader (video, audio-only, playlists, metadata) | download |
yb.audio_convert | audio format conversion via ffmpeg | core (needs ffmpeg) |
The audio→video rendering itself lives in
muvid.visualize (canvas, visual
strategies, loudness, verification); yb.music is the publish-facing facade over
it, and the music extra pulls muvid.