Automated clipping pipeline for CodeAZ: finds CC-BY-licensed tech videos —
Linux, security, self-hosting, dev tooling, hardware — cuts them into shorts with
OpenShorts's clip generator, credits
the original speaker/talk in every caption, and posts directly to YouTube
(OAuth, no middleman) and TikTok (via Buffer). Runs on GitHub Actions,
same shape as mpt's autopilot: cron trigger, posted.json tracks what's
been used so nothing repeats, state gets committed back after every run.
What this is not: a "find whatever's performing well and repost it"
scraper. research.py only pulls videos whose YouTube license flag is
creativeCommon, re-verified per video (not just trusted from search), from
a small allowlist of channels whose CC policy has actually been checked (see
sources.json). Every post carries a mandatory attribution line — title,
speaker/channel, link, license — because that's what CC BY requires, not
because it's nice to have.
OpenShorts' own /api/social/post endpoint is hardcoded server-side to
Upload-Post — there's no config flag to swap it. So this pipeline bypasses
that endpoint entirely: it downloads each rendered clip from OpenShorts, then
posts it itself — straight to the YouTube Data API via an OAuth refresh
token (same pattern mpt uses), and to TikTok via Buffer's GraphQL API
(same as mpt's buffer.py). No Upload-Post account, no 10/month cap from
that service — YouTube's own upload quota (~6/day per Cloud project) and
Buffer's plan limits apply instead.
- Gemini key — aistudio.google.com/app/apikey, free. Powers OpenShorts' clip detection.
- YouTube Data API key — console.cloud.google.com, enable "YouTube Data API v3", create an API key. Used only by
research.pyfor read-only search. - YouTube upload OAuth client — same Cloud project, Credentials → Create Credentials → OAuth client ID → Desktop app type. Gives you
YT_CLIENT_ID/YT_CLIENT_SECRET. Add yourself as a test user on the consent screen. - Mint a refresh token (once, locally, on a machine with a browser):
Sign in as the channel you want this niche to upload to. Save the printed token as
pip install google-auth-oauthlib export YT_CLIENT_ID=... YT_CLIENT_SECRET=... NICHE=codeaz python get_youtube_token.pyYT_REFRESH_TOKEN_CODEAZ(the suffix comes fromsources.jsonniche.env_suffix). - Buffer — publish.buffer.com, connect your TikTok account under Channels, then grab a personal API key from Settings → API. Save as
BUFFER_ACCESS_TOKEN. - Copy
.env.exampleto.envand fill it in for local testing. - Once you push this repo, add every secret from
.env.example(except the two commented-optional ones) under Settings → Secrets and variables → Actions. - Edit
sources.json— see Tuning what gets picked below. Add a channel tocc_channelsonly after you've personally watched one of its videos and confirmed its CC policy;find_channels.pynarrows the field for you but does not vet anything.
This is the step that actually matters — don't skip to pushing and letting Actions post on a schedule before you've watched what comes out. The dry run never touches YouTube or Buffer either.
git clone https://github.com/mutonby/openshorts.git openshorts_app
cd openshorts_app
cp .env.example .env
docker compose up -d backend renderer
cd ..
pip install -r requirements.txt
export GEMINI_API_KEY=...
export YOUTUBE_API_KEY=...
python dry_run.pyThis runs the real research → clip pipeline, downloads every resulting clip
to dry_run_output/, and writes dry_run_output/report.md with each clip's
would-be title and caption. Nothing gets posted anywhere, and
posted.json is not touched — run it as many times as you want while
tuning sources.json.
Watch the clips and specifically check:
- Does the vertical crop keep the speaker in frame (TRACK mode can lose them during slide-heavy segments)?
- Is any on-screen text cut off at the left and right edges? This is the failure that shipped six times: see
content_height_ratiobelow. - Do the subtitles actually match what's said?
- Does the AI-generated hook text overstate or misrepresent what the clip shows?
- Does the clip make sense as a standalone 20–75s piece, or does it need context from earlier in the video?
To dry-run one specific talk instead of letting research pick:
python dry_run.py --url "https://www.youtube.com/watch?v=..."Before going live, also consider setting BUFFER_DRAFT=1 for the first
real run or two — clips get queued in Buffer as drafts you approve by hand
instead of publishing immediately, while you build trust in the pipeline
without a dry run's total isolation.
Everything below lives in sources.json under niche, so a change to any of
it is one commit and no YAML edit.
Topic — topic_terms, exclude_terms, allowed_languages. A candidate
must match at least one topic term (in its title, the first 600 characters of
its description, or its tags), must match no exclude term, and must not
declare a language outside allowed_languages. Exclude terms are checked
first and win.
This check did not exist until 5-sep-2026, and its absence is the whole reason
six consecutive posts on a channel called CodeAZ Tech Shorts were AskReddit
stories about undiagnosed medical conditions. Licence verification answers
"may we repost this"; the channel allowlist answers "does this uploader
license CC". Neither answers "is it about our subject" — and a vetted
channel can change format under you, which is exactly what happened.
allowed_languages is a separate axis because keywords cannot see language: a
Hindi ML lecture matches machine learning perfectly and is still unusable
here.
Clip topic — clip_exclude_terms, checked by clip_verdict after
clipping, against the title/description/hook Gemini wrote for each clip.
This is a second gate because source metadata is a weak proxy that fails in a specific direction: a channel's description says what the channel is, not what this video is. "Her Brother Won't Let Her Play on the Computer" carries a hardware description because it comes from a PC channel — it passes the source filter — and the three clips cut from it were "I realized I was a terrible brother", "Why I gave a stranger a gaming PC" and "The 1% rule that changed my life". All three had to be deleted by hand. The generated title is the honest signal, because it describes the 40 seconds actually being posted.
Rejecting here wastes a render instead of a post, which is the cheaper
mistake. Only exclusions run at this stage, never topic_terms — a legitimate
clip can open on a line that names no technology at all.
Framing — content_height_ratio and layouts.
content_height_ratio becomes the backend's GENERAL_CONTENT_HEIGHT_RATIO:
how much of the frame height the content fills in the blurred-background
layout, paid for by cropping the sides.
There is a hard floor and it is 0.316 — a 16:9 source fills the 1080px
frame width at 608px tall, and 608/1920 = 0.316. Every value above that
discards width. Measured against reframe_v2's own filtergraph on a real
source:
| ratio | content height | width kept |
|---|---|---|
| 0.60 (was hardcoded in both workflows) | 1152px | 53% |
| 0.42 (upstream default) | 806px | 75% |
| 0.31 (now) | 608px | 100% |
At 0.60 a 75s clip of a Reddit thread shipped with every line sliced off both
edges. 0.42 is not enough either — it still cuts a quarter of the width, which
is still mid-sentence. 0.31 sits under the floor, so the max() in
general_filtergraph pins the content to full width and nothing is ever cut.
The cost is real: at full width a 16:9 source is 32% of the frame height with blurred filler above and below, so text is intact but small. That is the better failure — cropping throws information away, small is still readable. If a clip looks like a thumbnail floating in soup, the fix is the source. A video that is a static full-frame text page cannot be made good by any 9:16 layout, at any ratio.
layouts now includes screencast, which turns on OpenShorts'
SCREENCAST_LAYOUT. It is off by default upstream, and its own module
docstring describes our exact failure: "a screen recording that happens to
contain a face gets classified TRACK, the 9:16 crop keeps a centre strip, and
the chart or headline the shot is actually about comes out sliced mid-word."
It measures how much of the frame width the content spans and routes those
scenes to a stacked layout (content over speaker) or to full-width, instead of
cropping them.
Discovery — search_queries, queries_per_run, discovery,
blocked_channels.
Open search across YouTube is the primary arm and runs whether or not a
single channel is listed. cc_channels are seeds: a seed buys a guaranteed
search arm and nothing else.
That ranking used to be the other way round, and the output settled the argument. All three channels removed from the allowlist — an AskReddit storytime channel, a Hindi lecture channel, and a PC channel that publishes sentimental stories — were human-vetted and listed, and every post that had to be deleted came from one of them. Vetting a channel is a one-time judgement about a publisher and goes stale the moment that publisher changes format. Licence re-verification, the topic filter and the clip filter are evaluated fresh per video and apply identically to both arms.
queries_per_run samples the query list rather than running all of it. A
fixed query set returns the same top results every day, posted.json rejects
them as already used, and the pool goes sterile within days. Each sampled
query runs under two orderings — viewCount for what people actually watch,
date for what that ranking will never surface.
The pool maintains itself. autopilot.py tallies what each channel's
clips did into posted.json under channels, and:
promote_after_keeps(2) — a discovered channel whose clips survive the clip filter earns its own search arm, same as a seed.auto_block_after_rejects(4) — a channel whose clips keep getting rejected and are never kept is cut off from future runs.max_promoted_channels(12) — ceiling on promoted arms, so quota goes on finding new sources instead of re-mining the same winners.
Auto-blocking is the check that should have removed Humor Studios and
KristoferYee: from their own output, instead of after someone watched bad
videos go out and edited a config file. Undo one by editing posted.json;
blocked_channels in sources.json is the permanent, manual version.
Source order — research.py interleaves its search arms rather than
concatenating them. A channel-scoped search returns up to 25 videos and
autopilot.py only ever posts candidates[0], so concatenation meant one
arm owned every reachable slot and the rest were unreachable in practice.
export YOUTUBE_API_KEY=...
python find_channels.py --json
python find_channels.py --query "neovim config" --query "proxmox homelab"No API key on the machine? It falls back to the OAuth refresh token this repo
already uses to upload, and --env-file can read it from another project:
python find_channels.py --env-file ../mpt/.env --jsonsearch.list and videos.list are public reads, so either credential
authorizes them. If YouTube answers 403 insufficientPermissions, the token
was minted upload-only — re-mint it with the youtube.readonly scope.
Runs the niche's search queries with YouTube's CC filter, re-verifies every hit's licence per video, drops what the topic filter rejects, and groups the survivors by channel — so what you see is how many long, on-topic, genuinely CC-licensed videos a channel actually has, plus its median view count and a sample of titles.
Read the titles before adding anything. That is the step that would have caught "Humor Studios", listed with the note "Security/hacker story format", as an AskReddit storytime channel before it posted six times.
- Push this repo to GitHub.
- Add the secrets from
.env.example. autopilot.ymlruns daily at 14:00 UTC. Trigger it manually anytime from the Actions tab.dry_run.ymlstays available as a manual-only workflow — run it anytime from Actions to sanity-check output without posting anything, even after you've gone live.
- The pipeline no longer depends on a curated channel list. Open search
runs every time, so an empty
cc_channelsis a valid state — the seeds are a head start, not a requirement. What it does depend on is YouTube's CC filter having something to return for the sampled queries. - The CC-licensed pool for this niche is small, and the topic filter makes
it smaller. Unlike scraping "whatever performs well," a properly-licensed
source pool is genuinely limited — expect this to run out of fresh material
from one channel faster than a general-purpose channel would, and
topic_terms/exclude_termsdeliberately throw away more of what's left. That trade is the right one: the alternative is what shipped before the filter existed. Widentopic_termsor runfind_channels.pyfor more sources; don't lower the bar to keep the pipeline fed. - A channel's note in
sources.jsonis a claim, not a fact. Two of the five originally-listed channels turned out not to match their own notes — one an AskReddit storytime channel described as "security/hacker story format", one Hindi-language lectures described as "AI coding and dev education". Watch a video before you add a channel, and re-check the ones that are already there when their output starts looking wrong. - YouTube's upload quota is per Google Cloud project, not per channel —
roughly 6 uploads/day. At
target_clips_per_video: 3you'll hit that in two runs. Lower the clip count, run less often, or give this niche its own Cloud project + OAuth client if you want to scale past it (same patternmptuses for multi-niche:YT_CLIENT_ID_<NICHE>overrides). - Buffer has its own plan limits and TikTok's own review process — a brand-new TikTok app connected to Buffer may post privately until TikTok approves it for public posting; check Buffer's TikTok connection status if posts aren't showing up publicly.
- OpenShorts is a server app, not a script. Every scheduled run spins up
its Docker containers fresh inside the Actions runner, which is slower and
heavier than
mpt's plain-Python approach. A long conference talk can take a while to transcribe + analyze + render — the workflow's 110-minute timeout should cover one video, but watch actual run times in Actions and adjusttarget_clips_per_videodown if you're cutting it close. - Attribution is enforced in code, not just policy.
attribution.pyalways appends the credit line — if you edit captions downstream, keep that line intact. Removing it turns a licensed repost into an unlicensed one. - A CC license doesn't waive platform ToS or trademark/logo rights on its own — if a talk shows copyrighted slide content, a company logo, or similar within the recording, that's still there regardless of the talk's own CC status. Spot-check dry runs for this, the license check doesn't catch it.