feat: implement vertical crop, local LLM deepseek-v4-flash, multi-threading, word-level pink box highlight subtitles, and Nextcloud scan sync

This commit is contained in:
Renato
2026-07-02 16:12:23 +02:00
parent 50bdaedebb
commit c7c3c4b74a
154 changed files with 36256 additions and 3 deletions
Submodule brainrotinator deleted from 76983b03b7
+47
View File
@@ -0,0 +1,47 @@
#ignore these directories and files
#done_split/
#to_split/
__pycache__/
#subtitles/
Python upload bot client secrets/
Python2 client secrets/
#profile/cookies #docker does not support gui dont want to setup server
#vosk-model-en-us-0.42-gigaspeech/
vosk-model-en-us-0.42-gigaspeech.zip
#ignore files ending in .mp4 in done_split folder
done_split/*.mp4
done_split/uploaded/*.mp4
done_split/temp/*.mp4
done_split/vosk vs whisper/
done_split/Unuploaded Before Reworks/
to_split/*.mp4
to_split/edited/*.mp4
subtitles/*
to_split/edited/*
to_split/video/*
to_split/audio/*
#Python upload bot client secrets/
#Python2 client secrets/
#ignore these files
client_secrets.json
main.py-oauth2.json
#main.py-oauth2.json
geckodriver.log
geckodriver
#ignore this type of file
*.mp3
*.mp4
# AI Models and Data Folders (We map these with volumes instead of baking into img)
models/
to_split/
done_split/
subtitles/
.git/
+49
View File
@@ -0,0 +1,49 @@
#ignore these directories and files
#done_split/
#to_split/
__pycache__/
#subtitles/
Python upload bot client secrets/
Python2 client secrets/
profile/cookies
TiktokAutoUploader/
vosk-model-en-us-0.42-gigaspeech/
vosk-model-en-us-0.42-gigaspeech.zip
#ignore files ending in .mp4 in done_split folder
done_split/*.mp4
done_split/uploaded/*.mp4
done_split/temp/*.mp4
done_split/vosk vs whisper/
done_split/Unuploaded Before Reworks/
to_split/*.mp4
to_split/edited/*.mp4
subtitles/*
to_split/edited/*
to_split/video/*
to_split/audio/*
#Python upload bot client secrets/
#Python2 client secrets/
#ignore these files
client_secrets.json
main.py-oauth2.json
#main.py-oauth2.json
geckodriver.log
geckodriver
#ignore this type of file
*.mp3
*.mp4
models--TinyLlama--TinyLlama-1.1B-Chat-v1.0
profile/
.mypy_cache/
.claude/
.claude/*
.claude
+47
View File
@@ -0,0 +1,47 @@
# Use an official Python runtime as a base image
FROM python:3.12.2
# Set the working directory in the container
WORKDIR /app
# System deps: git for pip-from-git, ffmpeg (with libass for subtitle burn-in), fonts, debugging tools.
RUN apt-get update && apt-get install -y --no-install-recommends \
git bash nano wget ca-certificates \
ffmpeg fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# Verify libass is built into ffmpeg (subtitle burn-in needs it).
RUN ffmpeg -hide_banner -filters 2>/dev/null | grep -q "ass " || (echo "ffmpeg lacks ass filter" && exit 1)
# Upgrade pip and pin setuptools < 71 (newer versions break legacy setup.py
# packages like openai-whisper that import pkg_resources at module top-level).
RUN pip install --upgrade pip "setuptools<71" wheel
# Copy requirements first so Docker can cache the installed packages layer
COPY requirements.txt /app/
# Install Python deps. --no-build-isolation lets setup.py-based packages reuse
# the setuptools we just pinned, instead of pip pulling latest into an isolated env.
RUN pip install --no-cache-dir --no-build-isolation -r requirements.txt
# Copy the rest of the application code
COPY . /app
# Geckodriver for the (optional) selenium uploaders.
RUN wget -q https://github.com/mozilla/geckodriver/releases/download/v0.32.0/geckodriver-v0.32.0-linux64.tar.gz \
&& tar -xzf geckodriver-v0.32.0-linux64.tar.gz \
&& mv geckodriver /usr/local/bin/ \
&& rm geckodriver-v0.32.0-linux64.tar.gz \
&& geckodriver --version
# Firefox for selenium uploaders (optional). firefox-esr ships in Debian stable
# so we don't need to add the `unstable` repo (which causes openssl conflicts).
RUN apt-get update \
&& apt-get install -y --no-install-recommends firefox-esr \
&& rm -rf /var/lib/apt/lists/*
# Gradio default port
EXPOSE 7860
# Default: launch the Gradio UI. Override with `docker run ... bash` for the CLI.
CMD ["python", "app.py"]
+371
View File
@@ -0,0 +1,371 @@
"""Gradio UI for brainrotinator.
Tabs:
- Edit: upload a file or YouTube URL, run the splitter, watch logs stream.
- Library: list / preview / delete clips in done_split/.
- Settings: read/write config.json.
The CLI (`main.py -e/-u`) still works; this UI is additive.
"""
from __future__ import annotations
import io
import json
import logging
import os
import re
import shutil
import sys
import threading
import time
from contextlib import redirect_stdout
from pathlib import Path
import gradio as gr
from brainrotinator.video_editor import VideoEditor
from config import Config
from downloader.downloadVid import download_vid
PROJECT_ROOT = Path(__file__).resolve().parent
TO_SPLIT = PROJECT_ROOT / "to_split"
DONE_SPLIT = PROJECT_ROOT / "done_split"
EDITED = TO_SPLIT / "edited"
class _StreamCapture(io.TextIOBase):
"""Tee writes into a thread-safe buffer so a Gradio generator can drain it."""
def __init__(self):
self._buf: list[str] = [""]
self._lock = threading.Lock()
self._ansi_escape = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])')
def write(self, s: str) -> int:
with self._lock:
# Strip ANSI color codes so the Gradio textbox is readable
clean_s = self._ansi_escape.sub('', s)
# Handle carriage returns so tqdm progress bars don't spam multiple lines
if '\r' in clean_s:
parts = clean_s.split('\r')
if parts[0]:
self._buf[-1] += parts[0]
for p in parts[1:]:
if p:
self._buf[-1] = p
else:
if '\n' in clean_s:
lines = clean_s.split('\n')
self._buf[-1] += lines[0]
self._buf.extend(lines[1:])
else:
self._buf[-1] += clean_s
return len(s)
def drain(self) -> str:
with self._lock:
# We join the buffer but we don't clear it.
# This allows Gradio updates to be built cleanly over carriage returns without duplicates.
return "\n".join(self._buf).strip()
ACTIVE_EDITOR = None
def _list_to_split() -> list[str]:
if not TO_SPLIT.exists():
return []
return sorted(p.name for p in TO_SPLIT.glob("*.*") if p.suffix.lower() in [".mp4", ".mov", ".mkv"])
def _run_edit_job(
uploaded_file: str | None,
youtube_url: str,
existing_file: str | None,
chunk_duration: int,
blur: bool,
use_whisper: bool,
filter_profanity: bool,
subtitle_font_size: int,
subtitle_margin_v: int,
):
"""Generator that yields cumulative log text as the edit job runs."""
global ACTIVE_EDITOR
TO_SPLIT.mkdir(exist_ok=True)
DONE_SPLIT.mkdir(exist_ok=True)
EDITED.mkdir(exist_ok=True)
cfg = Config.load()
capture = _StreamCapture()
def get_state(final=False):
if final:
return gr.update(value="Run", interactive=True), gr.update(value="Stop", interactive=False)
return gr.update(value="Running...", interactive=False), gr.update(value="Stop", interactive=True)
def emit_sys(line: str, final=False):
# Directly write system log changes to the stream capture buffer
capture.write(line)
st = get_state(final)
return capture.drain(), st[0], st[1]
yield emit_sys("Preparing input...\n")
# Resolve input.
if uploaded_file:
dest = TO_SPLIT / Path(uploaded_file).name
shutil.copy(uploaded_file, dest)
input_path = dest
yield emit_sys(f"Copied uploaded file to {dest}\n")
elif existing_file:
input_path = TO_SPLIT / existing_file
yield emit_sys(f"Using existing file: {input_path}\n")
elif youtube_url.strip():
yield emit_sys(f"Downloading {youtube_url}...\n")
try:
with redirect_stdout(capture):
download_vid(youtube_url.strip(), str(TO_SPLIT))
st = get_state()
yield capture.drain(), st[0], st[1]
except Exception as e:
yield emit_sys(f"Download failed: {e}\n", final=True)
return
# Pick the newest mp4 in to_split that isn't in edited/.
mp4s = sorted(
[p for p in TO_SPLIT.glob("*.mp4") if p.is_file()],
key=lambda p: p.stat().st_mtime,
reverse=True,
)
if not mp4s:
yield emit_sys("No mp4 found after download.\n", final=True)
return
input_path = mp4s[0]
else:
yield emit_sys("Provide a file or a YouTube URL.\n", final=True)
return
yield emit_sys(f"Editing: {input_path}\n")
editor = VideoEditor(
input_path=str(input_path),
output_folder=str(DONE_SPLIT),
chunk_duration=int(chunk_duration),
name=input_path.stem,
useWhisper=use_whisper,
filterProfanityInSubtitles=filter_profanity,
voskModelDir=cfg.voskModelDir,
tinyLlamaDir=cfg.tinyLlamaDir,
subtitleFontSize=int(subtitle_font_size),
subtitleMarginV=int(subtitle_margin_v),
)
ACTIVE_EDITOR = editor
# Run the splitter on a background thread so we can stream prints.
error_box: list[BaseException] = []
def _runner():
try:
with redirect_stdout(capture):
if blur:
editor.split_video_into_chunks_blur()
else:
editor.split_video_into_chunks()
except BaseException as e:
error_box.append(e)
t = threading.Thread(target=_runner, daemon=True)
t.start()
while t.is_alive():
time.sleep(0.4)
chunk = capture.drain()
if chunk:
st = get_state()
yield chunk, st[0], st[1]
chunk = capture.drain()
if chunk:
st = get_state()
yield chunk, st[0], st[1]
if error_box:
yield emit_sys(f"\nERROR: {error_box[0]}\n", final=True)
ACTIVE_EDITOR = None
return
# Move source to edited/.
try:
shutil.move(str(input_path), str(EDITED / input_path.name))
yield emit_sys(f"\nMoved source to {EDITED / input_path.name}\n")
except Exception as e:
yield emit_sys(f"\nCouldn't move source: {e}\n")
yield emit_sys("\nDone.\n", final=True)
ACTIVE_EDITOR = None
def _list_clips() -> list[str]:
if not DONE_SPLIT.exists():
return []
return sorted(str(p) for p in DONE_SPLIT.glob("*.mp4"))
def _delete_clip(path: str) -> tuple[list[str], str]:
if path and os.path.exists(path):
os.remove(path)
return _list_clips(), f"Deleted {path}"
return _list_clips(), "Nothing to delete."
def _update_preview(font_size: int, margin_v: int) -> str:
"""Returns HTML for a phone-framed 9:16 preview matching the ASS subtitle render."""
# ASS renders font_size on a 1080x1920 canvas.
# 1% cqw = container_width/100; font occupies font_size/1080 of canvas width.
font_size_pct = (font_size / 1080) * 100
# margin_v is pixels from top on a 1920px canvas.
top_pct = (margin_v / 1920) * 100
# ASS outline=3 at font_size=100 → ~3% of font size; 0.04em is a reasonable approximation.
shadow = "0.04em 0.04em 0 #000, -0.04em -0.04em 0 #000, 0.04em -0.04em 0 #000, -0.04em 0.04em 0 #000, 0 0.04em 0 #000, 0 -0.04em 0 #000, 0.04em 0 0 #000, -0.04em 0 0 #000"
return f'''
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Bangers&display=swap" rel="stylesheet">
<div style="display:flex; justify-content:center; padding:16px 8px;">
<!-- Phone outer shell -->
<div style="position:relative; width:200px; background:#111; border-radius:38px; padding:10px;
box-shadow: 0 0 0 1px #3a3a3a, inset 0 0 0 1px #222, 0 24px 64px rgba(0,0,0,0.7);
border: 2px solid #444;">
<!-- Volume buttons (left) -->
<div style="position:absolute; left:-4px; top:70px; width:4px; height:26px; background:#333; border-radius:3px 0 0 3px;"></div>
<div style="position:absolute; left:-4px; top:106px; width:4px; height:44px; background:#333; border-radius:3px 0 0 3px;"></div>
<div style="position:absolute; left:-4px; top:160px; width:4px; height:44px; background:#333; border-radius:3px 0 0 3px;"></div>
<!-- Power button (right) -->
<div style="position:absolute; right:-4px; top:110px; width:4px; height:60px; background:#333; border-radius:0 3px 3px 0;"></div>
<!-- Screen -->
<div style="width:100%; aspect-ratio:9/16; border-radius:28px; overflow:hidden;
position:relative; container-type:inline-size;">
<!-- Blurred video-like background -->
<div style="position:absolute; inset:-10%; width:120%; height:120%;
background: linear-gradient(160deg,
#0d0d0d 0%, #1a1a2e 15%, #16213e 30%,
#0f3460 45%, #533483 55%,
#0f3460 65%, #16213e 78%,
#1a1a2e 90%, #0d0d0d 100%);
filter:blur(18px);"></div>
<!-- Darkening overlay for contrast -->
<div style="position:absolute; inset:0; background:rgba(0,0,0,0.35);"></div>
<!-- Dynamic island -->
<div style="position:absolute; top:8px; left:50%; transform:translateX(-50%);
width:72px; height:22px; background:#000; border-radius:11px; z-index:10;"></div>
<!-- Subtitle text -->
<div style="position:absolute; top:{top_pct}%; left:50%; transform:translateX(-50%);
color:#fff; font-family:'Bangers', Impact, sans-serif; font-weight:700;
font-size:{font_size_pct}cqw; text-align:center;
text-shadow:{shadow};
width:90%; word-wrap:break-word; line-height:1.15; z-index:5;
letter-spacing:0.02em;">
I finally found a use case...
</div>
</div>
</div>
</div>
'''
def _stop_job():
global ACTIVE_EDITOR
if ACTIVE_EDITOR:
ACTIVE_EDITOR.abort_flag = True
from brainrotinator import ffmpeg_ops
ffmpeg_ops.cancel()
return gr.update(value="Run", interactive=True), gr.update(value="Stop", interactive=False)
def build_ui() -> gr.Blocks:
cfg = Config.load()
with gr.Blocks(title="Brainrotinator") as demo:
gr.Markdown("# Brainrotinator\nPodcast clip automation — FFmpeg edition.")
with gr.Tab("Edit"):
with gr.Row():
with gr.Column(scale=1):
gr.Markdown("### Input Video")
file_in = gr.File(label="Upload mp4", file_types=[".mp4"], type="filepath")
url_in = gr.Textbox(label="...or YouTube URL")
with gr.Row():
to_split_in = gr.Dropdown(label="...or existing file in to_split/", choices=[None] + _list_to_split(), value=None)
refresh_files_btn = gr.Button("", size="sm")
gr.Markdown("### Edit Settings")
chunk_dur = gr.Slider(15, 120, value=cfg.chunkDuration, step=1, label="Chunk duration (s)")
blur = gr.Checkbox(value=cfg.blurTopBottomOfClip, label="Blur top/bottom (letterbox)")
whisper = gr.Checkbox(value=cfg.useWhisperForTranscription, label="Use Whisper (else Vosk)")
filt = gr.Checkbox(value=cfg.filterProfanityInSubtitles, label="Filter profanity in burned subtitles")
gr.Markdown("### Subtitle Settings")
sub_size = gr.Slider(10, 200, value=cfg.subtitleFontSize, step=1, label="Font Size")
sub_margin = gr.Slider(0, 1920, value=cfg.subtitleMarginV, step=10, label="Margin From Top (px)")
with gr.Row():
run_btn = gr.Button("Run", variant="primary")
stop_btn = gr.Button("Stop", variant="stop", interactive=False)
with gr.Column(scale=1):
gr.Markdown("### Subtitle Preview")
preview_html = gr.HTML(value=_update_preview(cfg.subtitleFontSize, cfg.subtitleMarginV))
log_box = gr.Textbox(label="Logs", lines=20, max_lines=20, autoscroll=True)
sub_size.change(_update_preview, inputs=[sub_size, sub_margin], outputs=preview_html)
sub_margin.change(_update_preview, inputs=[sub_size, sub_margin], outputs=preview_html)
refresh_files_btn.click(lambda: gr.update(choices=[None] + _list_to_split(), value=None), outputs=to_split_in)
run_click_event = run_btn.click(
_run_edit_job,
inputs=[file_in, url_in, to_split_in, chunk_dur, blur, whisper, filt, sub_size, sub_margin],
outputs=[log_box, run_btn, stop_btn],
)
stop_btn.click(_stop_job, outputs=[run_btn, stop_btn], cancels=[run_click_event])
with gr.Tab("Library"):
gr.Markdown("> **Tip:** Click any filename in the list below to download it to your computer.")
gallery = gr.Files(label="Clips in done_split/", value=_list_clips())
refresh = gr.Button("Refresh")
with gr.Row():
to_delete = gr.Textbox(label="Path to delete")
delete_btn = gr.Button("Delete", variant="stop")
delete_status = gr.Textbox(label="Status", interactive=False)
refresh.click(lambda: _list_clips(), outputs=gallery)
delete_btn.click(_delete_clip, inputs=to_delete, outputs=[gallery, delete_status])
with gr.Tab("Settings"):
gr.Markdown("Edit `config.json`. Uploader fields are still consumed by the CLI uploader.")
settings_box = gr.Code(value=json.dumps(cfg.model_dump(), indent=4), language="json", label="config.json")
save_btn = gr.Button("Save")
save_status = gr.Textbox(label="Status", interactive=False)
def _save(text: str) -> str:
try:
new_cfg = Config.model_validate_json(text)
except Exception as e:
return f"Invalid: {e}"
new_cfg.save()
return "Saved."
save_btn.click(_save, inputs=settings_box, outputs=save_status)
return demo
if __name__ == "__main__":
ui = build_ui()
ui.queue().launch(server_name="0.0.0.0", server_port=7860)
Binary file not shown.
Binary file not shown.
+722
View File
@@ -0,0 +1,722 @@
absofuckinglutely
anal
arse
arse-hole
arse-holes
arsehole
arseholes
arses
ass-hat
ass-hole
ass-holes
ass-pirate
assbag
assbandit
assbanger
assbite
assclown
asscock
asscracker
asses
assface
assfuck
assfucker
assfuckers
assfucks
assgoblin
asshat
asshats
asshead
assholes
asshole
asshopper
assjacker
asskiss
asskisser
asskissers
asslick
asslicker
asslickers
asslicks
asslover
asslovers
assmonkey
assmunch
assmuncher
assnigger
asspirate
assshit
assshole
asssucker
asswad
asswipe
ass
ballsack
ballsacks
bastards
bastard
batshit
batshits
beshitted
birdshit
birdshits
bitchass
bitches
bitchin
bitching
bitchings
bitchslap
bitchslaps
bitchtits
bitchy
bitch
blow job
blow jobs
blowjob
blowjobs
bogshit
boner
boshits
brotherfucker
bugfuck
bugfucker
bugfuckers
bugfucking
bugfucks
bugshit
bugshits
bullshits
bullshitted
bullshitting
bullshittings
bullshit
bumblefuck
bumblefucks
bumfuck
bumfucks
butt plug
butt-fucker
butt-fuckers
butt-pirate
buttfuck
buttfucka
buttfucker
buttfuckers
buttfucks
carpetmuncher
carpetmunchers
catshit
catshits
chickenshit
chickenshits
clit
clitface
clitfuck
clitoris
clits
clusterfuck
cock
cock sucker
cock suckers
cock-sucker
cock-suckers
cockass
cockbite
cockblock
cockblocker
cockblockers
cockblocks
cockburger
cockface
cockfucker
cockhead
cockjockey
cockknoker
cocklicker
cocklickers
cocklover
cocklovers
cockmaster
cockmongler
cockmongruel
cockmonkey
cockmuncher
cocknose
cocknugget
cocks
cockshit
cocksmith
cocksmoker
cocksuck
cocksucked
cocksucker
cocksuckers
cocksucking
cocksuckings
cocksucks
cocktease
cockteases
coochie
coochy
cooter
cornhole
cornholes
cowshit
cowshits
cum
cumbubble
cumdumpster
cumfest
cumfests
cumguzzler
cumjockey
cumjockeys
cumming
cummings
cumquat
cumquats
cumshot
cumshots
cumslut
cumtart
cunilingus
cunillingus
cunnie
cunnilingus
cunt
cuntface
cuntfuck
cuntfucker
cuntfuckers
cuntfucks
cunthole
cuntlick
cuntlicker
cuntlickers
cuntlicking
cuntlickings
cuntlicks
cuntrag
cunts
cuntslut
cuntsucker
cuntsuckers
dafuck
dammit
damned
damnit
damn
dickbag
dickbeaters
dickbrain
dickbrains
dickface
dickforbrains
dickfuck
dickfucker
dickhead
dickheads
dickhole
dickjuice
dickless
dicklick
dicklicker
dicklickers
dicklicks
dickmilk
dickmonger
dickslap
dicksucker
dicks
dickwad
dickwads
dickweasel
dickweed
dickweeds
dickwod
dick
diddlyfuck
diddlyshit
diddlyshits
dildo
dildos
dipshit
dipshits
dipstick
dipsticks
dogfuck
dogfucked
dogfucker
dogfucks
doggiestyle
doggystyle
donkyshit
donkyshits
doochbag
doodlyfuck
doodlyfucks
douche
douche-fag
douchebag
douchewaffle
dumass
dumb ass
dumbass
dumbbitch
dumbbitches
dumbfuck
dumbfucks
dumbshit
dumshit
dyke
dykes
everfucking
facefucker
facefuckers
fag
fagbag
fagfucker
faggit
faggot
faggotcock
fags
fagtard
fannyfucker
fannyfuckers
fat-ass
fat-assed
fatass
fatassed
fatfuck
fatfucker
fatfuckers
fatfucks
fellatio
feltch
fiddlyfuck
fiddlyfucker
fiddlyfucking
fiddlyfucks
fingerfuck
fingerfucked
fingerfucker
fingerfuckers
fingerfucking
fingerfuckings
fingerfucks
fistfuck
fistfucked
fistfucker
fistfuckers
fistfucking
fistfuckings
fistfucks
flamer
footfuck
footfucker
footfuckers
footfucks
footlicker
footlickers
freakfuck
freakfucks
freakyfucker
freakyfuckers
fucck
fuccks
fuck
fucka
fuckable
fuckables
fuckadelic
fuckah
fuckahs
fuckar
fuckaree
fuckarees
fuckaroo
fuckaroos
fuckaround
fuckarounds
fuckarow
fuckarows
fuckars
fuckas
fuckass
fuckathon
fuckathons
fuckbag
fuckbags
fuckbook
fuckbooks
fuckboy
fuckbrain
fuckbuddies
fuckbuddy
fuckbutt
fuckdollies
fuckdolly
fuckdub
fuckdubs
fucked
fuckedup
fuckedups
fuckem
fucker
fuckers
fuckersucker
fuckery
fuckface
fuckfaces
fuckfest
fuckfests
fuckfinger
fuckfingers
fuckfreak
fuckfreaks
fuckfriend
fuckfriends
fuckhea
fuckhead
fuckheads
fuckher
fuckhers
fuckhole
fuckies
fuckin
fuckina
fuckinas
fucking
fuckingbitch
fuckingbitchs
fuckings
fuckink
fuckinnuts
fuckinright
fuckinrights
fuckins
fuckit
fuckits
fuckknob
fuckknobs
fuckload
fuckloads
fuckme
fuckmehard
fuckmehards
fuckmes
fuckmonkey
fuckmonkeys
fuckmovie
fuckmovies
fucknut
fucknuts
fucknutt
fucko
fuckoff
fuckoffs
fuckos
fuckpig
fuckpigs
fuckpuppet
fuckpuppets
fucks
fucksome
fuckstick
fucksticks
fucktard
fucktards
fuckton
fucktons
fuckum
fuckup
fuckups
fuckwad
fuckwads
fuckwhore
fuckwhores
fuckwit
fuckwits
fuckwitt
fucky
fuckya
fuckyas
fuckybrook
fuckybrooks
fuckyou
fuckyous
fudgepacker
fudgepackers
fuuck
fuucks
gayass
gaybob
gaydo
gayfuck
gayfuckist
gaylord
gaytard
gaywad
getthefuckout
goddamnmuthafucker
goddamn
godshit
godshits
handjob
handjobs
hardon
headfuck
headfucks
henshit
henshits
homo
homodumbshit
honkey
horseshit
horseshits
hotdamns
humping
jackass
jackoff
jackoffs
jigaboo
jizz
joyshit
joyshits
kickass
kick-ass
kitchenshit
kitchenshits
kooch
kootch
kunt
labia
lezzie
mcfagget
mcfucking
Mifuckinlady
mindfuck
mindfucked
mindfucking
mindfuckings
mindfucks
minge
monsterfucker
monsterfuckers
mothafuck
mothafucka
mothafuckah
mothafuckahs
mothafuckas
mothafuckaz
mothafuckazs
mothafucked
mothafucker
mothafuckers
mothafuckin
mothafucking
mothafuckings
mothafuckins
mothafucks
motherfuck
motherfucked
motherfucker
motherfuckers
motherfuckin
motherfucking
motherfuckings
motherfuckins
motherfucks
muff
muffdive
muffdiver
muffdivers
muffdives
muffindiver
muffindivers
munging
muthafuck
muthafucka
muthafuckah
muthafuckahs
muthafuckas
muthafucks
numbfuck
numbfucks
nunfucker
nunfuckers
nut sack
nutsack
ohfuck
oldfuck
oldfucker
oldfuckers
oldfucks
omigod
omygod
overfuckingjoyed
papeshit
papeshits
pecker
peckerhead
penis
penisfucker
penispuffer
pigshit
pigshits
pissflaps
polesmoker
pollock
poon
poonani
poonany
poontang
punanny
pussies
pussy
pussylicking
puto
queef
queer
queerbait
queerhole
ratfuck
ratfucks
ratshit
ratshits
richfuck
richfucks
rimjob
rimjobs
scrote
sex
shita
shitas
shitass
shitasses
shitbag
shitbagger
shitbox
shitboxes
shitbrains
shitbreath
shitbug
shitbugs
shitcan
shitcans
shitcunt
shitdick
shitdicks
shite
shiteater
shiteaters
shited
shites
shitface
shitfaced
shitfaces
shitfire
shitfired
shitfires
shitforbrains
shitfuck
shitfucker
shitfuckers
shitfucks
shitfull
shithead
shitheads
shithole
shithouse
shithouses
shitless
shitlicker
shitlickers
shitload
shitloads
shitmonkey
shitmonkeys
shitmouse
shitmouses
shitpot
shitpots
shits
shitspitter
shitsplat
shitsplats
shitstain
shitstains
shitted
shitter
shitters
shittier
shittiest
shittin
shitting
shittings
shitty
shit
poop
skank
skullfuck
slutbag
smeg
sonofabitch
sonofbitch
sonsofbitches
splooge
spooge
strapon
strapons
stupidfuck
stupidfucker
stupidfuckers
stupidfucks
sweetfucked
sweetfucker
sweetfuckers
porn
porno
tard
testicle
thefuck
thundercunt
tit
titfuck
titfucker
titfuckers
titfuckin
titfucks
tits
tittie
titties
titty
tittyfuck
tittys
twat
twatlips
twats
twatwaffle
unclefucker
unfuck
unfucked
va-j-j
vag
vagina
vjayjay
wakethefuckup
wank
wanker
wankers
wanking
wankings
wanks
whafuck
whorebag
whoreface
whorefucker
whorefuckers
whore
harlot
wormshit
wormshits
+12
View File
@@ -0,0 +1,12 @@
_______ __ __ __ __
/ \ / | / | / | / |
$$$$$$$ | ______ ______ $$/ _______ ______ ______ _$$ |_ $$/ _______ ______ _$$ |_ ______ ______
$$ |__$$ | / \ / \ / |/ \ / \ / \ / $$ | / |/ \ / \ / $$ | / \ / \
$$ $$< /$$$$$$ |$$$$$$ |$$ |$$$$$$$ |/$$$$$$ |/$$$$$$ |$$$$$$/ $$ |$$$$$$$ | $$$$$$ |$$$$$$/ /$$$$$$ |/$$$$$$ |
$$$$$$$ |$$ | $$/ / $$ |$$ |$$ | $$ |$$ | $$/ $$ | $$ | $$ | __ $$ |$$ | $$ | / $$ | $$ | __ $$ | $$ |$$ | $$/
$$ |__$$ |$$ | /$$$$$$$ |$$ |$$ | $$ |$$ | $$ \__$$ | $$ |/ |$$ |$$ | $$ |/$$$$$$$ | $$ |/ |$$ \__$$ |$$ |
$$ $$/ $$ | $$ $$ |$$ |$$ | $$ |$$ | $$ $$/ $$ $$/ $$ |$$ | $$ |$$ $$ | $$ $$/ $$ $$/ $$ |
$$$$$$$/ $$/ $$$$$$$/ $$/ $$/ $$/ $$/ $$$$$$/ $$$$/ $$/ $$/ $$/ $$$$$$$/ $$$$/ $$$$$$/ $$/
+171
View File
@@ -0,0 +1,171 @@
"""Thin wrappers around ffmpeg / ffprobe.
Replaces the moviepy + ImageMagick + cleanvid stack with direct FFmpeg calls.
Each helper builds a command list and runs it; callers handle orchestration.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from typing import Iterable
_current_proc: subprocess.Popen | None = None
_cancelled: bool = False
def cancel() -> None:
"""Kill the ffmpeg subprocess currently running in _run(), if any."""
global _current_proc, _cancelled
if _current_proc is not None:
_cancelled = True
_current_proc.kill()
def _run(cmd: list[str], quiet: bool = False) -> None:
global _current_proc, _cancelled
_cancelled = False
if not quiet:
print("[ffmpeg] " + " ".join(cmd))
# Use PIPE so subprocess never calls fileno() on sys.stdout/stderr —
# Gradio replaces them with StringIO wrappers that don't have real fds.
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
_current_proc = proc
out, err = proc.communicate()
_current_proc = None
if out:
sys.stdout.write(out.decode(errors="replace"))
if err:
sys.stderr.write(err.decode(errors="replace"))
if proc.returncode != 0 and not _cancelled:
raise subprocess.CalledProcessError(proc.returncode, cmd)
def probe_duration(path: str) -> float:
out = subprocess.check_output([
"ffprobe", "-v", "error",
"-show_entries", "format=duration",
"-of", "json", path,
])
return float(json.loads(out)["format"]["duration"])
def probe_dimensions(path: str) -> tuple[int, int]:
out = subprocess.check_output([
"ffprobe", "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "json", path,
])
s = json.loads(out)["streams"][0]
return int(s["width"]), int(s["height"])
def extract_audio_wav(input_path: str, output_wav: str, sample_rate: int = 16000) -> None:
"""Extract mono PCM wav. Vosk + Whisper both accept wav directly."""
_run([
"ffmpeg", "-y", "-loglevel", "error",
"-i", input_path,
"-vn", "-ac", "1", "-ar", str(sample_rate),
"-f", "wav", output_wav,
])
def cut_crop_scale_burn(
input_path: str,
output_path: str,
start: float,
end: float,
target_w: int,
target_h: int,
ass_path: str | None,
blur_letterbox: bool,
mute_ranges: Iterable[tuple[float, float]] = (),
fonts_dir: str | None = None,
) -> None:
"""One-pass cut + format + (optional) blur + (optional) subtitle burn + (optional) mute.
`start`/`end` are absolute seconds in the source.
`mute_ranges` are absolute seconds too — converted to chunk-relative below.
"""
src_w, src_h = probe_dimensions(input_path)
duration = end - start
if blur_letterbox:
# Background: blur+scale to target. Foreground: scaled source centered.
fg_h = target_h
fg_w = int(round(src_w * (fg_h / src_h)))
if fg_w > target_w:
fg_w = target_w
fg_h = int(round(src_h * (fg_w / src_w)))
vf = (
f"[0:v]split=2[bg][fg];"
f"[bg]scale={target_w}:{target_h}:force_original_aspect_ratio=increase,"
f"crop={target_w}:{target_h},gblur=sigma=20[bgb];"
f"[fg]scale={fg_w}:{fg_h}[fgs];"
f"[bgb][fgs]overlay=(W-w)/2:(H-h)/2[v]"
)
else:
# 9:16 center crop, then scale.
target_ar = target_w / target_h
src_ar = src_w / src_h
if src_ar > target_ar:
crop_h = src_h
crop_w = int(round(src_h * target_ar))
else:
crop_w = src_w
crop_h = int(round(src_w / target_ar))
vf = (
f"[0:v]crop={crop_w}:{crop_h}:(in_w-{crop_w})/2:(in_h-{crop_h})/2,"
f"scale={target_w}:{target_h}[v]"
)
if ass_path:
ass_for_filter = _escape_ass_path(ass_path)
ass_arg = ass_for_filter
if fonts_dir:
ass_arg += f":fontsdir={_escape_ass_path(fonts_dir)}"
vf = vf.replace("[v]", f"[vbase];[vbase]ass={ass_arg}[v]")
# Audio mute filter — convert absolute ranges to chunk-relative.
af_parts = []
for ms_start, ms_end in mute_ranges:
rel_start = max(0.0, ms_start - start)
rel_end = max(0.0, ms_end - start)
if rel_end <= 0 or rel_start >= duration:
continue
rel_end = min(rel_end, duration)
af_parts.append(
f"volume=enable='between(t,{rel_start:.3f},{rel_end:.3f})':volume=0"
)
af = ",".join(af_parts) if af_parts else None
cmd = [
"ffmpeg", "-y",
"-ss", f"{start:.3f}", "-to", f"{end:.3f}",
"-i", input_path,
"-filter_complex", vf,
"-map", "[v]",
"-map", "0:a?",
]
if af:
cmd += ["-af", af]
cmd += [
"-c:v", "libx264", "-preset", "medium", "-crf", "20",
"-c:a", "aac", "-b:a", "192k",
"-movflags", "+faststart",
output_path,
]
_run(cmd)
def _escape_ass_path(path: str) -> str:
"""ffmpeg's filtergraph parser needs Windows backslashes and `:` (drive letter) escaped."""
p = path.replace("\\", "/")
# Escape drive-letter colon for filter syntax: C:/foo -> C\:/foo
if len(p) >= 2 and p[1] == ":":
p = p[0] + "\\:" + p[2:]
return p
@@ -0,0 +1,92 @@
"""A Python library to check for (and clean) profanity in strings.
Forked from: https://github.com/ben174/profanity
All credit goes to them
I just edited line 70 so that if we have "pass" it doesnt become "p@@@"
so now it only filters whole words
"""
import os
import random
import re
lines = None
words = None
_censor_chars = '@#$%!'
_censor_pool = []
_ROOT = os.path.abspath(os.path.dirname(__file__))
def get_data(path):
return os.path.join(_ROOT, 'data', path)
def get_words():
if not words:
load_words()
return words
def get_censor_char():
"""Plucks a letter out of the censor_pool. If the censor_pool is empty,
replenishes it. This is done to ensure all censor chars are used before
grabbing more (avoids ugly duplicates).
"""
global _censor_pool
if not _censor_pool:
# censor pool is empty. fill it back up.
_censor_pool = list(_censor_chars)
return _censor_pool.pop(random.randrange(len(_censor_pool)))
def set_censor_characters(censor_chars):
"""Sets the pool of censor characters. Input should be a single string
containing all the censor charcters you'd like to use.
Example: "@#$%^"
"""
global _censor_chars
_censor_chars = censor_chars
def contains_profanity(input_text):
"""Checks the input_text for any profanity and returns True if it does.
Otherwise, returns False.
"""
return input_text != censor(input_text)
def censor(input_text, filterAnyOccurance:bool = False):
""" Returns the input string with profanity replaced with a random string
of characters plucked from the censor_characters pool.
"""
ret = input_text
words = get_words()
for word in words:
if filterAnyOccurance:
curse_word = re.compile(re.escape(word), re.IGNORECASE)
#old implementation, filters any occurance of the word even if its part of another word
curse_word = re.compile(r'\b' + re.escape(word) + r'\b', re.IGNORECASE)
cen = "".join(get_censor_char() for i in list(word))
ret = curse_word.sub(cen, ret)
return ret
def load_words(wordlist=None):
""" Loads and caches the profanity word list. Input file (if provided)
should be a flat text file with one profanity entry per line.
"""
global words
if not wordlist:
# no wordlist was provided, load the wordlist from the local store
filename = get_data('wordlist.txt')
f = open(filename)
wordlist = f.readlines()
wordlist = [w.strip() for w in wordlist if w]
words = wordlist
+103
View File
@@ -0,0 +1,103 @@
"""Subtitle helpers: SRT -> styled ASS, plus profanity mute-range extraction.
Replaces moviepy's TextClip/SubtitlesClip and the cleanvid subprocess.
"""
from __future__ import annotations
import os
import re
from typing import Iterable
import pysrt
import pysubs2
def srt_to_ass(
srt_path: str,
ass_path: str,
font_name: str = "Bangers",
font_size: int = 90,
primary_color: str = "&H00FFFFFF", # white (AABBGGRR)
outline_color: str = "&H00000000", # black
outline: int = 3,
shadow: int = 0,
alignment: int = 8, # top-center; 2 = bottom-center, 5 = middle-center
margin_v: int = 480, # vertical offset; tuned for 1920px tall video
play_res_x: int = 1080,
play_res_y: int = 1920,
) -> None:
"""Convert SRT to ASS with a styled `Default` style.
Designed for vertical 1080x1920 output. Caller still needs the .ttf font
available to libass — either installed system-wide or referenced via
`fontsdir=...` in the ffmpeg `ass=` filter.
"""
subs = pysubs2.load(srt_path, encoding="utf-8")
subs.info["PlayResX"] = str(play_res_x)
subs.info["PlayResY"] = str(play_res_y)
subs.info["ScaledBorderAndShadow"] = "yes"
style = subs.styles["Default"]
style.fontname = font_name
style.fontsize = font_size
style.primarycolor = pysubs2.Color(255, 255, 255, 0)
style.outlinecolor = pysubs2.Color(0, 0, 0, 0)
style.bold = True
style.outline = outline
style.shadow = shadow
style.alignment = alignment
style.marginv = margin_v
subs.save(ass_path, format_="ass")
def load_swears(swears_path: str) -> list[str]:
with open(swears_path, "r", encoding="utf-8") as f:
return [w.strip().lower() for w in f if w.strip()]
def mute_ranges_from_srt(
srt_path: str,
swears: Iterable[str],
pad_seconds: float = 0.05,
) -> list[tuple[float, float]]:
"""Return (start, end) seconds for every subtitle line containing a swear.
Uses word-boundary matching so 'ass' doesn't match 'class'.
Pads each range by `pad_seconds` on both sides to account for transcription drift.
"""
swear_list = [s for s in swears if s]
if not swear_list:
return []
pattern = re.compile(
r"\b(" + "|".join(re.escape(w) for w in swear_list) + r")\b",
re.IGNORECASE,
)
ranges: list[tuple[float, float]] = []
for sub in pysrt.open(srt_path, encoding="utf-8"):
if not pattern.search(sub.text):
continue
start_s = _pysrt_to_seconds(sub.start) - pad_seconds
end_s = _pysrt_to_seconds(sub.end) + pad_seconds
ranges.append((max(0.0, start_s), end_s))
return _merge_overlapping(ranges)
def _pysrt_to_seconds(t) -> float:
return t.hours * 3600 + t.minutes * 60 + t.seconds + t.milliseconds / 1000.0
def _merge_overlapping(ranges: list[tuple[float, float]]) -> list[tuple[float, float]]:
if not ranges:
return []
ranges = sorted(ranges)
merged = [ranges[0]]
for s, e in ranges[1:]:
ps, pe = merged[-1]
if s <= pe:
merged[-1] = (ps, max(pe, e))
else:
merged.append((s, e))
return merged
+339
View File
@@ -0,0 +1,339 @@
from io import BytesIO
import re
import subprocess
import requests
from tqdm import tqdm
import torch
from vosk import Model, KaldiRecognizer, SetLogLevel
import os
from huggingface_hub import snapshot_download
from termcolor import colored
from . import profanity
import whisper
from whisper.utils import get_writer
from transformers import AutoModelForCausalLM, AutoTokenizer, pipeline
import zipfile
class Transcribe:
#@param output_path - path to the output srt file
def __init__(self, audioPath, output_path, name, filterProfanityInSubtitles: bool, voskModelDir, tinyLlamaDir):
self.audioPath = audioPath #path for where the mp3 file is
self.output_path = output_path #subtitles save path
self.name = name #name of the video (so we can name the srt correctly)
self.filterProfanityInSubtitles = filterProfanityInSubtitles #if true, then we filter out profanity in the subtitles
self.tinyLlamaDir = tinyLlamaDir
self.voskModelDir = voskModelDir
def transcribeVideoVosk(self) -> None:
#transcribe the video to srt file
print(colored("Transcribing video...", "green"))
unFilteredSrtFileName = os.path.join(self.output_path, f"{self.name}_notClean.srt")
print(colored(f"File name: {unFilteredSrtFileName}", "yellow"))
#check if the file already exists, if it does then return
if os.path.exists(unFilteredSrtFileName):
print(colored("SRT file already exists. Skipping...", "red"))
return
currentWorkingDirectory = os.getcwd()
#vosk model
#------------------------------------------------------------------------------------
#if model does not exist, download it
#RUN apt-get install -y unzip \
#if voskmodel directory is empty string, not provided used working diretory
voskDir = ''
if not self.voskModelDir:
voskDir= currentWorkingDirectory
else:
voskDir = self.voskModelDir
modelPath = os.path.join(voskDir, "vosk-model-en-us-0.42-gigaspeech")
url = "https://alphacephei.com/vosk/models/vosk-model-en-us-0.42-gigaspeech.zip"
zip_filename = "vosk-model-en-us-0.42-gigaspeech.zip"
if not os.path.exists(modelPath):
print(f"Downloading Vosk model from {url}...")
# Streamed download with a tqdm progress bar so users can see progress on slow links.
with requests.get(url, stream=True, timeout=60) as response:
response.raise_for_status()
total = int(response.headers.get("content-length", 0))
with open(zip_filename, "wb") as f, tqdm(
total=total, unit="B", unit_scale=True, desc="Vosk model"
) as bar:
for chunk in response.iter_content(chunk_size=1024 * 1024):
if chunk:
f.write(chunk)
bar.update(len(chunk))
print("Extracting...")
with zipfile.ZipFile(zip_filename, "r") as zip_ref:
zip_ref.extractall(voskDir)
os.remove(zip_filename)
else:
print(f"Model already exists at {modelPath}.")
SAMPLE_RATE = 16000
SetLogLevel(-1)
print("Vosk model path: ", modelPath)
subtitles = ''
model = Model(model_path=modelPath)
rec = KaldiRecognizer(model, SAMPLE_RATE)
rec.SetWords(True)
with subprocess.Popen(["ffmpeg", "-loglevel", "quiet", "-i",
self.audioPath,
"-ar", str(SAMPLE_RATE) , "-ac", "1", "-f", "s16le", "-"],
stdout=subprocess.PIPE).stdout as stream:
data = stream.read()
stream = BytesIO(data) #have to copy the stream into a buffer in memory
subtitles = rec.SrtResult(stream, words_per_line=1) #srt save has to be first read from stream or the timestamp will be wrong
print(subtitles)
result : str = extract_words_from_srt(None, subtitles)
print(result)
stream.close()
f= open(unFilteredSrtFileName, "w")
f.write(subtitles)
f.close()
print(colored(f"Subtitles saved to {unFilteredSrtFileName}", "yellow"))
transcriptionPath = os.path.join(self.output_path, f"{self.name}_transcription.txt")
with open(transcriptionPath, "w") as f:
f.write(result)
f.close()
#------------------------------------------------------------------------------------
#Filter out profanity in the subtitles, then save
#if profanity filter off do not filter
cleanSrtFileName = os.path.join(self.output_path, f"{self.name}.srt")
f = open(cleanSrtFileName, "w")
if self.filterProfanityInSubtitles:
subtitles = self.filterProfanity(subtitles)
f.write(subtitles)
f.close()
print(colored(f"Subtitles saved to {cleanSrtFileName}", "yellow"))
#------------------------------------------------------------------------------------
#get the Ai summary
summary :str = self.llmSummarize(result)
print(colored(f"Summary before: {summary}", "yellow"))
summary = self.filterProfanity(summary) #filter out profanity
summary = summary.replace("\"", "") #regex any " within summary
print(colored(f"Summary after all filters: {summary}", "green"))
summary_save_path = os.path.join(self.output_path, f"{self.name}_summary.txt")
f = open(summary_save_path, "w")
f.write(summary)
f.close()
print(colored(f"Summary saved to {summary_save_path}", "yellow"))
print(colored("Transcription complete.", "green"))
def transcribeVideoWhisper(self) -> None:
#transcribe the video to srt file
print(colored("Transcribing video...", "green"))
unFilteredSrtFileName = os.path.join(self.output_path, f"{self.name}_notClean.srt")
print(colored(f"File name: {unFilteredSrtFileName}", "yellow"))
if os.path.exists(unFilteredSrtFileName):
print(colored("SRT file already exists. Skipping...", "red"))
return
currentWorkingDirectory = os.getcwd()
#Whisper model
#------------------------------------------------------------------------------------
subtitles = ''
model_name = "large-v3"
device = "cuda" if torch.cuda.is_available() else "cpu"
model = whisper.load_model(model_name).to(device)
filename =self.name + "_notClean.srt"
language = "en" if model_name.endswith(".en") else None
subtitles : dict = model.transcribe(self.audioPath, language=language, temperature=0.0, word_timestamps=True)
transcript = str(subtitles["text"])
print(subtitles)
print(colored(f"Transcript: {transcript}", "yellow"))
transcriptionPath = os.path.join(self.output_path, f"{self.name}_transcription.txt")
with open(transcriptionPath, "w") as f:
f.write(transcript)
f.close()
writer = get_writer("srt", output_dir=self.output_path) #returns a writer object
writer(subtitles, filename, max_words_per_line=1 ) #writes the result to the file, specify keyword argument (kwargs) to set max words per line
del model
torch.cuda.empty_cache()
#------------------------------------------------------------------------------------
with open(unFilteredSrtFileName, "r") as f:
subtitles = f.read()
f.close()
print(subtitles)
#filter out profanity, then save
#we use this srt for the final video, and the unfiltered srt for detecting when to mute profanity
cleanSrtFileName = os.path.join(self.output_path, f"{self.name}.srt")
f = open(cleanSrtFileName, "w")
if self.filterProfanityInSubtitles:
subtitles = self.filterProfanity(subtitles)
f.write(subtitles)
f.close()
print(colored(f"Subtitles saved to {cleanSrtFileName}", "yellow"))
#------------------------------------------------------------------------------------
#Get the Ai summary
summary :str = self.llmSummarize(transcript)
print(colored(f"Summary before: {summary}", "yellow"))
#filter out profanity in the summary
summary = self.filterProfanity(summary)
summary = summary.replace("\"", "") #regex any " within summary
print(colored(f"Summary after all filters: {summary}", "green"))
summary_save_path = os.path.join(self.output_path, f"{self.name}_summary.txt")
f = open(summary_save_path, "w")
f.write(summary)
f.close()
print(colored(f"Summary saved to {summary_save_path}", "yellow"))
print(colored("Transcription complete.", "green"))
def filterProfanity (self, input: str) -> str :
print(colored("Filtering profanity...", "green"))
profanity.set_censor_characters("*#@!")
#Set swearWords to the contents of the file
swearsFileLocation = os.path.join(os.getcwd(), "assets", "swears.txt")
print(colored(f"swearsFileLocation: {swearsFileLocation}", "yellow"))
f = open(swearsFileLocation, "r")
swearWords = f.readlines()
swearWords = [w.strip() for w in swearWords if w]
f.close()
profanity.load_words(swearWords)
#print(swearWords)
return profanity.censor(input)
def llmSummarize(self, input:str) -> None:
print(colored("Summarizing text using Tinyllama...", "green"))
llamaDir = ''
if not self.tinyLlamaDir: #if tinyLlamaDir is empty string, use current working directory
llamaDir= os.getcwd()
else:
llamaDir = self.tinyLlamaDir
localModelPath = os.path.join(llamaDir, "models--TinyLlama--TinyLlama-1.1B-Chat-v1.0")
model_name = "TinyLlama/TinyLlama-1.1B-Chat-v1.0"
if not os.path.exists(localModelPath):
print(f"Downloading {model_name} -> {localModelPath} (resumable)...")
# snapshot_download is resumable and shows progress, unlike from_pretrained's silent download.
snapshot_download(
repo_id=model_name,
local_dir=localModelPath,
local_dir_use_symlinks=False,
)
else:
print(f"Model already exists at {localModelPath}.")
pipe = pipeline("text-generation", model=localModelPath, torch_dtype=torch.bfloat16, device_map="auto")
input = input + "\n Summarize the above transcript into a catchy title for youtube. Do not include any additional text before or after the title. Make the title one sentence long."
# We use the tokenizer's chat template to format each message - see https://huggingface.co/docs/transformers/main/en/chat_templating
messages = [
{
"role": "system",
"content": "You always respond with one catchy youtube title, no longer than one sentence. Do not include any additional text before or after the title.",
},
{"role": "user", "content": input},
]
prompt = pipe.tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
outputs = pipe(prompt, max_new_tokens=256, do_sample=True, temperature=0.7, top_k=50, top_p=0.95)
generatedText = outputs[0]["generated_text"]
print(generatedText)
#keep anything after <|assistant|> in the output
generatedText = generatedText.split("<|assistant|>")[1]
print(colored(f"{generatedText}", "yellow"))
del pipe
torch.cuda.empty_cache()
#filter out any whitespace before
generatedText = generatedText.strip()
#only include the /line in string
generatedText = generatedText.split("\n")[0]
#filter out any mention of catchy youube title that chatbot sometimes includes
generatedText = re.sub(r'(?i)Catchy YouTube title', '', generatedText)
generatedText = re.sub(r'(?i)YouTube Title', '', generatedText)
generatedText = re.sub(r'(?i)Chatbot', '', generatedText)
generatedText = re.sub(r'(?i)one sentence', '', generatedText)
print(colored(f"Ai title after filter out catchy youtube title: \n{generatedText}", "green"))
#filter any whitespace after
generatedText = generatedText.strip()
print(colored(f"Ai title after filter whitespace & only include first line: \n{generatedText}", "green"))
#truncate the text to 100 characters (youtbe limits titles to 100 chars)
generatedText = generatedText[:100]
print(colored(f" \n Ai title after trunkate to 100 chars: \n {generatedText} \n", "yellow"))
return generatedText
def extract_words_from_srt(srt_file = None, input:str = None):
content =''
if srt_file:
with open(srt_file, 'r', encoding='utf-8') as file:
content = file.read()
if input:
content = input
# Remove numbers (sequence numbers), timestamps, and extra lines
cleaned_text = re.sub(r'\d+\n\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}', '', content)
# Remove any remaining empty lines or numbers
cleaned_text = re.sub(r'\n\d+\n', '\n', cleaned_text)
# Remove any leftover newlines that appear more than twice in a row
cleaned_text = re.sub(r'\n+', '\n', cleaned_text).strip()
#merge all lines into one line
cleaned_text = cleaned_text.replace("\n", " ")
return cleaned_text
@@ -0,0 +1,153 @@
"""Video editor — FFmpeg-only.
Replaces the old moviepy/ImageMagick/cleanvid stack. For each chunk we do:
1. Extract wav for transcription (via ffmpeg).
2. Run Vosk or Whisper -> {name}_notClean.srt + {name}.srt + {name}_summary.txt.
3. Convert {name}.srt -> {name}.ass with our subtitle style.
4. Build mute ranges from {name}_notClean.srt against swears.txt.
5. Single ffmpeg pass: cut + crop/scale (or blur letterbox) + burn ASS + mute.
"""
from __future__ import annotations
import os
from termcolor import colored
from . import ffmpeg_ops
from . import subtitles as subs_mod
from .transcribe import Transcribe
TARGET_W = 1080
TARGET_H = 1920
class VideoEditor:
def __init__(
self,
input_path: str,
output_folder: str,
chunk_duration: int,
name: str,
useWhisper: bool,
filterProfanityInSubtitles: bool,
voskModelDir: str,
tinyLlamaDir: str,
subtitleFontSize: int = 100,
subtitleMarginV: int = 400,
):
self.input_path = input_path
self.output_folder = output_folder
self.chunk_duration = chunk_duration
self.name = name
self.useWhisper = useWhisper
self.filterProfanityInSubtitles = filterProfanityInSubtitles
self.voskModelDir = voskModelDir
self.tinyLlamaDir = tinyLlamaDir
self.subtitleFontSize = subtitleFontSize
self.subtitleMarginV = subtitleMarginV
self.abort_flag = False
def split_video_into_chunks(self):
self._split(blur_letterbox=False)
def split_video_into_chunks_blur(self):
self._split(blur_letterbox=True)
def _split(self, blur_letterbox: bool) -> None:
print(colored(f"\n Splitting video: {self.input_path}", "green"))
duration = int(ffmpeg_ops.probe_duration(self.input_path))
os.makedirs(self.output_folder, exist_ok=True)
project_root = os.getcwd()
subtitles_path = os.path.join(project_root, "subtitles")
os.makedirs(subtitles_path, exist_ok=True)
assets_dir = os.path.join(project_root, "assets")
fonts_dir = os.path.join(assets_dir, "fonts")
font_path = os.path.join(fonts_dir, "Bangers.ttf")
swears_path = os.path.join(assets_dir, "swears.txt")
swears = subs_mod.load_swears(swears_path) if os.path.exists(swears_path) else []
for start in range(0, duration, self.chunk_duration):
if self.abort_flag:
print(colored("\n[!] Editing aborted by user.", "red"))
break
end = min(start + self.chunk_duration, duration)
num = int(end / 60)
chunk_name = f"{self.name}_{num}"
output_video = os.path.join(self.output_folder, f"{chunk_name}.mp4")
if os.path.exists(output_video):
print(colored(f"File {chunk_name} already exists. Skipping...", "yellow"))
continue
wav_path = os.path.join(subtitles_path, f"{chunk_name}.wav")
chunk_for_transcription = os.path.join(subtitles_path, f"{chunk_name}_src.mp4")
# 1. Cut a temp source clip + extract wav for transcription.
# (We re-encode the final clip in step 5; this temp is just for audio.)
ffmpeg_ops._run([
"ffmpeg", "-y", "-loglevel", "error",
"-ss", str(start), "-to", str(end),
"-i", self.input_path,
"-c", "copy",
chunk_for_transcription,
])
ffmpeg_ops.extract_audio_wav(chunk_for_transcription, wav_path)
# 2. Transcribe.
print(colored(f"Transcribing: {wav_path}", "yellow"))
transcribe = Transcribe(
wav_path,
subtitles_path,
name=chunk_name,
filterProfanityInSubtitles=self.filterProfanityInSubtitles,
voskModelDir=self.voskModelDir,
tinyLlamaDir=self.tinyLlamaDir,
)
if self.useWhisper:
transcribe.transcribeVideoWhisper()
else:
transcribe.transcribeVideoVosk()
os.remove(wav_path)
os.remove(chunk_for_transcription)
srt_clean = os.path.join(subtitles_path, f"{chunk_name}.srt")
srt_unfiltered = os.path.join(subtitles_path, f"{chunk_name}_notClean.srt")
ass_path = os.path.join(subtitles_path, f"{chunk_name}.ass")
# 3. Build styled ASS for burn-in.
subs_mod.srt_to_ass(
srt_clean,
ass_path,
font_name="Bangers",
font_size=self.subtitleFontSize,
margin_v=self.subtitleMarginV,
)
# 4. Compute mute ranges from the unfiltered SRT (absolute seconds in source = chunk-relative + start).
chunk_relative_ranges = subs_mod.mute_ranges_from_srt(srt_unfiltered, swears)
absolute_ranges = [(s + start, e + start) for s, e in chunk_relative_ranges]
if absolute_ranges:
print(colored(f"Muting {len(absolute_ranges)} profanity range(s)", "yellow"))
# 5. Final single-pass render.
print(colored(f"Rendering: {output_video}", "green"))
ffmpeg_ops.cut_crop_scale_burn(
input_path=self.input_path,
output_path=output_video,
start=float(start),
end=float(end),
target_w=TARGET_W,
target_h=TARGET_H,
ass_path=ass_path,
blur_letterbox=blur_letterbox,
mute_ranges=absolute_ranges,
fonts_dir=fonts_dir,
)
print(colored(f"Saved: {output_video}", "green"))
print(colored("-------------------------------------------", "green"))
+30
View File
@@ -0,0 +1,30 @@
{
"tags": [
"chuckle Sandwich",
"jschlatt",
"ted nivison",
"slimecicle",
"gaming",
"comedy",
"tucker",
"jambo"
],
"description": "#shorts",
"howManyUploads": 1,
"howManyHoursBetweenSchedule": 0,
"howManyMinsBetweenUpload": 5,
"howManyHoursLongToSleep": 23,
"sleepXMinsBeforeStartingUploader": 0,
"chunkDuration": 58,
"uploadToYoutube": true,
"uploadToInstagram": true,
"uploadToTiktok": false,
"blurTopBottomOfClip": true,
"useWhisperForTranscription": false,
"filterProfanityInSubtitles": false,
"subtitleFontSize": 100,
"subtitleMarginV": 480,
"firefoxHeadless": true,
"voskModelDir": "models",
"tinyLlamaDir": "models"
}
+59
View File
@@ -0,0 +1,59 @@
"""Typed config for brainrotinator.
Replaces the old `global` block in main.py and the dict access scattered
across the codebase. Loaded from `config.json` at the project root.
"""
from __future__ import annotations
import json
from pathlib import Path
from pydantic import BaseModel, Field
CONFIG_PATH = Path(__file__).resolve().parent / "config.json"
class Config(BaseModel):
# Upload metadata
tags: list[str] = Field(default_factory=list)
description: str = "#shorts"
# Upload scheduling
howManyUploads: int = 1
howManyHoursBetweenSchedule: int = 0
howManyMinsBetweenUpload: int = 5
howManyHoursLongToSleep: int = 23
sleepXMinsBeforeStartingUploader: int = 0
# Editing
chunkDuration: int = 58
blurTopBottomOfClip: bool = True
useWhisperForTranscription: bool = False
filterProfanityInSubtitles: bool = False
# Subtitles
subtitleFontSize: int = 100
subtitleMarginV: int = 480
# Upload targets
uploadToYoutube: bool = True
uploadToInstagram: bool = True
uploadToTiktok: bool = False
# Selenium
firefoxHeadless: bool = True
# Model dirs (empty = use cwd)
voskModelDir: str = "models"
tinyLlamaDir: str = "models"
@classmethod
def load(cls, path: Path | str = CONFIG_PATH) -> "Config":
with open(path) as f:
return cls.model_validate(json.load(f))
def save(self, path: Path | str = CONFIG_PATH) -> None:
with open(path, "w") as f:
json.dump(self.model_dump(), f, indent=4)
+16
View File
@@ -0,0 +1,16 @@
version: '3.8'
services:
brainrotinator:
build: .
image: brainrotinator:latest
ports:
- "7860:7860"
volumes:
# Video input and output
- ./to_split:/app/to_split
- ./done_split:/app/done_split
# AI Models (so they aren't re-downloaded on container restart)
- ./models:/app/models
- ./models/whisper:/root/.cache/whisper
command: python app.py
@@ -0,0 +1,30 @@
import os
import subprocess
"""
Function to combine the audio and video of a video using ffmpeg-python
@param videoPath: the path to the video
@param audioPath: the path to the audio
@param outputPath: the path to save the output to
@param outputName: the name of the output file
"""
def CombineAudioVideo(videoPath, audioPath, outputPath, outputName):
try:
outputFilePath = os.path.join(outputPath, outputName)
subprocess_command = [
'ffmpeg',
'-y', # Overwrite output file without asking
'-i', videoPath, # Input video
'-i', audioPath, # Input audio
'-c', 'copy', # Codec copy (no re-encoding)
outputFilePath # Output path
]
subprocess.run(subprocess_command, check=True)
print(f"Combined audio and video saved to: {outputFilePath}")
except Exception as e:
print(f"Error combining audio and video: {e}")
+63
View File
@@ -0,0 +1,63 @@
import sys
import os
import subprocess
import re
from termcolor import colored
def _sanitize_title(title: str) -> str:
return re.sub(r'[^\w\-]', '_', title)[:100]
def download_vid(url: str, path: str) -> None:
os.makedirs(path, exist_ok=True)
# Probe the video title first so we can predict the output filename.
title_result = subprocess.run(
["yt-dlp", "--print", "title", "--no-playlist", url],
capture_output=True, text=True, check=True,
)
raw_title = title_result.stdout.strip()
safe_title = _sanitize_title(raw_title)
output_path = os.path.join(path, safe_title + ".mp4")
print(colored(f"Downloading: {raw_title}", "green"))
print(colored(f"Output: {output_path}", "blue"))
proc = subprocess.Popen(
[
"yt-dlp",
"--no-playlist",
"-f", "bestvideo[ext=mp4]+bestaudio[ext=m4a]/bestvideo+bestaudio/best",
"--merge-output-format", "mp4",
"-o", output_path,
url,
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
out, err = proc.communicate()
if out:
sys.stdout.write(out.decode(errors="replace"))
if err:
sys.stderr.write(err.decode(errors="replace"))
if proc.returncode != 0:
raise subprocess.CalledProcessError(proc.returncode, ["yt-dlp"])
print(colored(f"Saved to: {output_path}", "green"))
return output_path
def main():
if len(sys.argv) < 3:
print("Usage: python downloadVid.py <url> <path>")
sys.exit(1)
url = sys.argv[1]
path = sys.argv[2]
print(f"URL: {url}\nPath: {path}")
download_vid(url, path)
if __name__ == "__main__":
main()
+426
View File
@@ -0,0 +1,426 @@
from datetime import datetime, timedelta
import os
import time
import argparse
import random
import shutil
import json
from brainrotinator.video_editor import VideoEditor
from uploaders.uploader_selenium import upload_video_youtube, upload_video_Instagram
from uploaders.upload_tiktok import upload_video_Tiktok
from downloader.downloadVid import download_vid
from config import Config
class Color:
PURPLE = '\033[95m'
CYAN = '\033[96m'
DARKCYAN = '\033[36m'
BLUE = '\033[94m'
GREEN = '\033[92m'
YELLOW = '\033[93m'
RED = '\033[91m'
BOLD = '\033[1m'
UNDERLINE = '\033[4m'
END = '\033[0m'
"""
Function to upload the chunks to YouTube
Uploads videos from the done_split folder, uploads until reaches howManyUploads, then sleeps for 23 hours
time between uploads = howManyMinsBetweenUpload + random wait of 0-5 mins
@param output_directory - path to the output directory(folder with the clips of our videos)
@param howManyUploads - how many times to upload before sleeping for 24 hours
@param howManyHoursBetweenUpload - how many hours delay between each upload scheduled to be uploaded
@param howManyMinsBetweenUpload - how many minutes to wait between each upload
@param howManyHoursLongToSleep - how many hours to sleep after uploading howManyUploads times
@param tags - list of tags to add to the video
@param description_video - description of the video
"""
def uploadVideos(doneVideosDirectory : str,
cfg: Config,
) -> None:
howManyUploads = cfg.howManyUploads
howManyHoursBetweenSchedule = cfg.howManyHoursBetweenSchedule
howManyMinsBetweenUpload = cfg.howManyMinsBetweenUpload
howManyHoursLongToSleep = cfg.howManyHoursLongToSleep
tags = cfg.tags
description_video = cfg.description
print(Color.GREEN + "Done Videos directory: " + doneVideosDirectory + Color.END)
print(Color.BLUE + "Video Upload Order order: \n" + str(os.listdir(doneVideosDirectory)) + "\n\n" + Color.END)
i : int = 0
for videoFileName in os.listdir(doneVideosDirectory):
print(Color.BLUE+ "Video file: " + videoFileName + Color.END)
if videoFileName.endswith(".mp4"):
print(Color.GREEN + f"i = {i} \n" + videoFileName + Color.END)
#if we have uploaded howmanyUploads times and is not first upload since starting program, sleep for howManyHoursLongToSleep
if(i % howManyUploads == 0 and i != 0) :
timeToSleepInHours = (howManyHoursLongToSleep*60*60)
print(Color.GREEN + f"Sleeping for {howManyHoursLongToSleep} hrs since uploaded {howManyUploads} vids... \n calculated: {timeToSleepInHours/60/60} hrs " + Color.END)
nextUploadTime = datetime.now() + timedelta(seconds=timeToSleepInHours)
print(Color.BLUE+ f" => next upload at {nextUploadTime}" + Color.END)
#Sleep for howManyHoursLongToSleep hours
time.sleep(timeToSleepInHours)
i = 0 #reset the counter to 0 after sleeping for 24 hours
#if we are not on the first video, sleep for hour since we have uploaded a video
if(i != 0) :
print(Color.GREEN + "Time now: " + str(datetime.now()) + Color.END)
randomSleepSeconds = random.randint(0, 300)
sleepTimeSeconds = (howManyMinsBetweenUpload * 60) + randomSleepSeconds
now = datetime.now()
nextUploadTime = now + timedelta(seconds=sleepTimeSeconds)
print(Color.BLUE+ f" => next upload at {nextUploadTime}" + Color.END)
print(Color.GREEN + "Sleeping for:"+str(sleepTimeSeconds /60) + "minutes" + Color.END)
time.sleep(sleepTimeSeconds) #sleep for 1 hour before uploading the next video
print(Color.GREEN+ "\n ---------------------------------------------------------------------" + Color.END)
if cfg.uploadToYoutube:
print(Color.GREEN+ "Uploading video to YouTube..." + Color.END)
videoFilePath :str = os.path.join(doneVideosDirectory, videoFileName)
print(Color.GREEN+"Uploading video from: \n" + videoFilePath + Color.END)
videoFileNameWithoutMP4ForSummary :str = os.path.splitext(videoFileName)[0]
print(Color.GREEN + "Base name: " + videoFileNameWithoutMP4ForSummary + Color.END)
#if "_filtered" in videoFileNameWithoutMP4: remove the "_filtered" from the name
#ai summary file is named without the "_filtered" so we need to remove it from the name
if "_filtered" in videoFileNameWithoutMP4ForSummary:
videoFileNameWithoutMP4ForSummary = videoFileNameWithoutMP4ForSummary.replace("_filtered", "")
print(Color.GREEN + "Base name after removing '_filtered': " + videoFileNameWithoutMP4ForSummary + Color.END)
project_root :str = os.getcwd()
subtitles_path :str = os.path.join(project_root, "subtitles")
print(Color.YELLOW + "Subtitles path: " + subtitles_path + Color.END)
AISummaryPath: str = os.path.join(subtitles_path, f"{videoFileNameWithoutMP4ForSummary}_summary.txt")
print(Color.YELLOW + "Summary path: " + AISummaryPath + Color.END)
AiTitle :str = ""
try:
with open(AISummaryPath, "r") as f:
AiTitle = f.read()
except FileNotFoundError:
print(Color.RED + "Summary file not found: " + AISummaryPath + Color.END)
AiTitle = "default"
print(Color.YELLOW + "Ai Title: " + AiTitle + Color.END)
print(Color.BLUE+ f" => Scheduling video to be uploaded in {i * howManyHoursBetweenSchedule} hours" + Color.END)
# Calculate the scheduled time based on the number of iterations
now = datetime.now()
random_mins = random.randint(0, 10)
howManyHoursLaterToSchedule = now + timedelta(hours=(i * howManyHoursBetweenSchedule), minutes=random_mins) #hr+rand mins to avoid getting banned
# Format the time in ISO 8601 format
formatted_time = howManyHoursLaterToSchedule.strftime("%m/%d/%Y, %H:%M")
print("Current time:", now)
print(f"{i*howManyHoursBetweenSchedule} hours later:", howManyHoursLaterToSchedule)
print("ISO format:", formatted_time)
#initialize the json object
upload_json = {}
#if we are on the first video, upload immediately or if we are not scheduling the video
if (i == 0 or howManyHoursBetweenSchedule == 0):
print(Color.GREEN + "Uploading immediately..." + Color.END)
upload_json = {
"title": AiTitle,
"description": description_video,
"tags": tags,
}
#if we are not on the first video, schedule the video to be uploaded in future
else :
print(Color.GREEN + "Scheduling video to be uploaded in the future..." + Color.END)
upload_json = {
"title": AiTitle,
"description": description_video,
"tags": tags,
"schedule": formatted_time
}
json_file_path :str = os.path.join(doneVideosDirectory, "upload.json")
with open(json_file_path, "w") as json_file:
json.dump(upload_json, json_file)
print(Color.GREEN + "JSON file path: " + json_file_path + Color.END)
if cfg.uploadToYoutube:
upload_video_youtube(videoFilePath, json_file_path, headless=cfg.firefoxHeadless)
print(Color.GREEN+ "Uploaded video to youtube: " + videoFileName + Color.END)
print(Color.GREEN+ "\n ---------------------------------------------------------------------" + Color.END)
copyOfTags = tags.copy() #copy the tags so we dont modify the original list
for x in range(len(copyOfTags)):
copyOfTags[x] = "#" + copyOfTags[x].replace(" ", "")
AiTitle = AiTitle + " " + " ".join(copyOfTags)
print(Color.GREEN + "Title for TikTok+Instagram: " + AiTitle + Color.END)
upload_json = {
"title": AiTitle,
"description": AiTitle,
}
with open(json_file_path, "w") as json_file:
json.dump(upload_json, json_file)
if cfg.uploadToInstagram:
print(Color.GREEN+ "Uploading video to Instagram..." + Color.END)
upload_video_Instagram(videoFilePath, json_file_path, headless=cfg.firefoxHeadless)
print(Color.GREEN + "Uploaded to Instagram " + Color.END)
print(Color.GREEN+ "\n ---------------------------------------------------------------------" + Color.END)
cookies : str = os.path.join(os.getcwd(), "cookies-tiktok.txt")
if cfg.uploadToTiktok:
print(Color.GREEN + "Uploading to TikTok..." + Color.END)
upload_video_Tiktok(video_path=videoFilePath, description= AiTitle, cookies = cookies)
print(Color.GREEN + "Uploaded to TikTok " + Color.END)
#-----------------------------------------------------------------------------
move_files_to_uploaded(videoFileName, doneVideosDirectory, videoFilePath) #move the uploaded video to the uploaded folder
i += 1 #increment the counter (only if we just uploaded a video, if not then dont increment hour time)
# Move the uploaded video to the "uploaded" directory, so we don't upload it again if the script crashes
def move_files_to_uploaded(videoFileName: str, doneVideoDirectory: str, videoFilePath) -> None:
uploaded_directory = os.path.join(doneVideoDirectory, "uploaded")
print(Color.GREEN + "Uploaded directory: " + uploaded_directory + Color.END)
newVideoFilePath = os.path.join(uploaded_directory, videoFileName)
shutil.move(videoFilePath, newVideoFilePath)
print(Color.GREEN + f"Moved uploaded video to: {newVideoFilePath}" + Color.END)
"""
Function that goes through the to_split folder and splits each video into chunks
If no mp4 files in the folder, prompts user to add mp4 files to the folder, or exit editing
Only splits mp4 files
@param folder_path - the path to the folder with the video
@param output_directory - the path to the output folder
@param chunk_duration - the duration of each chunk in seconds
@param edited_directory - the path to the edited folder
@param gui - boolean to check if the program is running in GUI mode
@param splitOneVideo - boolean to check if the program is splitting one video
"""
def splitVideos(folder_path : str,
output_directory : str,
cfg: Config,
edited_directory: str,
gui: bool = False,
splitOneVideo: bool = False) -> None:
chunk_duration = cfg.chunkDuration
print(Color.GREEN+ "\n ---------------------------------------------------------------------" + Color.END)
if(splitOneVideo):
print(Color.GREEN + "Splitting one video..." + Color.END)
else:
print(Color.GREEN + "Splitting all videos in to_Split folder..." + Color.END)
#for each video in the folder, split the video into 60 second chunks
for video_file in os.listdir(folder_path):
print(Color.BLUE + "Video file: " + video_file + Color.END )
if video_file.endswith(".mp4"):
input_video_path = os.path.join(folder_path, video_file)
print("Input video path: " + input_video_path)
base_name = os.path.splitext(video_file)[0] # Get the base name without .mp4
print(Color.GREEN + "Base name: " + base_name + Color.END)
#initialize the class with (video_path, output_folder, chunk_duration, vname)
video_editor = VideoEditor(input_path=input_video_path,
output_folder=output_directory,
chunk_duration=chunk_duration,
name=base_name,
useWhisper=cfg.useWhisperForTranscription,
filterProfanityInSubtitles=cfg.filterProfanityInSubtitles,
voskModelDir=cfg.voskModelDir,
tinyLlamaDir=cfg.tinyLlamaDir)
if cfg.blurTopBottomOfClip:
print (Color.RED + "Split the video into chunks using blur" + Color.END)
video_editor.split_video_into_chunks_blur()
else :
print (Color.RED + "Split the video into chunks without blur" + Color.END)
video_editor.split_video_into_chunks()
print (Color.GREEN + "Done editing the video into chunks" + Color.END)
# Move the original video to the "edited" directory
new_file_path = os.path.join(edited_directory, video_file)
print("Input video path: " + input_video_path)
print("New file path: " + new_file_path)
while(True):
try:
shutil.move(input_video_path, new_file_path)
print(Color.GREEN + f"Moved original video to: {new_file_path}" + Color.END)
break
except Exception as e:
print(Color.RED + "Error moving file: " + str(e) + Color.END)
time.sleep(5)
#if we are only splitting one video, break out of the loop
if splitOneVideo:
return
#if reach here then no mp4 files in the folder (since would have broken out of the loop if there was a mp4 file)
#prompt user to give url to download new youtube video
if(gui):
print("GUI mode")
#return ui.promptUserToGiveVideoPath(folder_path, output_directory, chunk_duration, edited_directory)
return
else:
print("CLI mode")
return promptUserForURLCLI(folder_path, output_directory, cfg, edited_directory)
def promptUserForURLCLI(folder_path : str,
output_directory : str,
cfg: Config,
edited_directory: str) -> None:
print(Color.RED + "No mp4 files in the folder. Please add mp4 files to the folder." + Color.END)
#wait for user input
waiting : bool = True
while (waiting):
answer: str = input(Color.YELLOW + "Enter a url for a youtube video to download to edit \n or type 'exit' to stop editing \n or type 'yes' if you added a file to the 'to_split' folder \n" + Color.END)
if(answer == "exit"):
print(Color.GREEN + "exiting video editing..." + Color.END)
return
if (answer == "yes"):
waiting = False
print(Color.GREEN + "Continuing program..." + Color.END)
return splitVideos(folder_path, output_directory, cfg, edited_directory) #call the function again to split the video into chunks
#check if answer is url
else :
try:
print(Color.GREEN + "Downloading video..." + Color.END)
#print url and folder path we are dolownloading to
print(Color.BLUE + "URL: " + answer + Color.END)
print(Color.BLUE + "Save Path: " + folder_path + Color.END)
print(Color.GREEN + "\n--------------------------------------------" + Color.END)
#call the download_vid function to download the video
#downloads the highest quality audio and video
#saves it to the to_split folder
download_vid(answer, folder_path)
#call the function again to split the video, checks if there are mp4 files in the folder, should be since we just downloaded a video
return splitVideos(folder_path, output_directory, cfg, edited_directory=edited_directory)
except Exception as e:
print(Color.RED + "Error downloading video: " + str(e) + Color.END)
waiting = True
#default to none if no arguments provided
def main(editBool = None, uploadBool = None, gui: bool = False) -> None:
# Get the current working directory (where your Python script is located)
project_root = os.getcwd()
#read title.txt file to print title
with open(os.path.join('assets', 'title.txt'), 'r') as file:
title = file.read().strip()
print(Color.RED + title + Color.END)
time.sleep(2)
# Define relative paths from the project root
folder_path = os.path.join(project_root, "to_split")
output_directory = os.path.join(project_root, "done_split")
edited_directory = os.path.join(folder_path, "edited")
print(Color.GREEN + "Folder path: " + folder_path + Color.END)
print(Color.GREEN + "Output directory: " + output_directory + Color.END)
print(Color.GREEN + "Edited directory: " + edited_directory + Color.END)
# Load the config file
cfg = Config.load()
MinsBefore = cfg.sleepXMinsBeforeStartingUploader
print(Color.GREEN + "Config: " + str(cfg.model_dump()) + Color.END)
#if a flag is provided, run the program only for that flag
if(editBool or uploadBool):
# Split one of the videos into chunks, move into edited folder
if editBool:
splitVideos(folder_path, output_directory, cfg, edited_directory=edited_directory, gui=gui)
return
# Upload the chunks to YouTube within the done_split folder
# do this until there are no more videos to upload, then go back to editing
if uploadBool:
print(Color.BLUE + "Sleeping for " + str(MinsBefore) + " minutes" + Color.END)
time.sleep(MinsBefore * 60)
uploadVideos(output_directory, cfg)
return
#else run the program for both flags, edit one vid -> upload -> edit another vid -> upload -> etc
i : int = 0
while(True):
# Split one of the videos into chunks, move into edited folder
splitVideos(folder_path, output_directory, cfg, edited_directory=edited_directory, gui=gui, splitOneVideo=True)
#Upload the chunks to YouTube within the done_split folder
#if we are on the first video, sleep for MinsBefore minutes in order to delay the first upload
if(i == 0):
print(Color.BLUE + "Sleeping for " + str(MinsBefore) + " minutes" + Color.END)
#print when next upload will be
next_upload = datetime.now() + timedelta(seconds=MinsBefore * 60)
print(Color.BLUE+ f" => next upload at {next_upload}" + Color.END)
time.sleep(MinsBefore * 60)
uploadVideos(output_directory, cfg)
i += 1
# Run the main function if the name of the module is __main__
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Process videos for YouTube.')
parser.add_argument('--edit', '-e', action='store_true', help='Split videos into chunks. \n \
Edits all mp4 files within the to_split folder. \n \
Without uploading the videos after editing.')
parser.add_argument('--upload', '-u', action='store_true', help='Upload video chunks to YouTube. \n \
Uploads all mp4 files within the done_split folder.')
args = parser.parse_args() #parse the arguments, so we can use them in the program, shows up in help message for functions
if not args.edit and not args.upload:
print(Color.GREEN+ "\n ---------------------------------------------------------------------" + Color.END)
print(Color.YELLOW + "\n No arguments provided. \n \
Defualt functionality is to edit one video into chunks, then upload the chunks to YouTube, and continue edit-upload as a loop. \n \
Please use --edit to only edit videos from to_split folder, or --upload to only upload videos from done_split folder. \n" + Color.END)
main(editBool=args.edit, uploadBool=args.upload) #call the main function with the arguments provided
+427
View File
@@ -0,0 +1,427 @@
<a id="readme-top"></a>
<!-- PROJECT SHIELDS -->
<!--
*** I'm using markdown "reference style" links for readability.
*** Reference links are enclosed in brackets [ ] instead of parentheses ( ).
*** See the bottom of this document for the declaration of the reference variables
*** for contributors-url, forks-url, etc. This is an optional, concise syntax you may use.
*** https://www.markdownguide.org/basic-syntax/#reference-style-links
-->
[![LinkedIn][linkedin-shield]][linkedin-url]
<!-- PROJECT LOGO -->
<br />
<div align="center">
<img src="demo_and_images/logo.png" alt="Logo" width="400" height="80">
</div>
<div>
<h3 align="center">Brainrotinator</h3>
### Podcast Clip Automation Using AI
- Edits long form content into clips with subtitles using FFmpeg (libass for burn-in)
- Web UI built with Gradio for editing — CLI still works for headless / cron use
- Transcribes audio using Vosk or Whisper Models (your choice)
- Mutes audio where profanity is detected using FFmpeg's `volume` filter driven by SRT timestamps
- Uses TinyLlamma LLM to generate titles based on transcription for YouTube and Instagram.
- Automatically uploads to YouTube, Instagram, and Tiktok based on schedule given in config file using Selenium Firefox.
- Downloads videos from youtube using given URL using Pytube
- Thank you Timofei for the inspiration and name of the project.
</div>
<!-- TABLE OF CONTENTS -->
<details>
<summary>Table of Contents</summary>
<ol>
<li><a href="#about-the-project">About The Project</a></li>
<li><a href="#architecture">Architecture</a></li>
<li><a href="#getting-started">Getting Started</a>
<ul>
<li><a href="#install-without-docker">Install without Docker</a></li>
<li><a href="#install-with-docker">Install with Docker</a></li>
</ul>
</li>
<li><a href="#running-the-program">Running the Program</a>
<ul>
<li><a href="#gradio-ui">Gradio UI</a></li>
<li><a href="#cli">CLI</a></li>
<li><a href="#config">Config</a></li>
</ul>
</li>
<li><a href="#things-to-note">Things to note</a></li>
<li><a href="#vosk-or-whisper">Vosk or Whisper</a></li>
<li><a href="#license">License</a></li>
<li><a href="#contact">Contact</a></li>
<li><a href="#acknowledgments">Acknowledgments</a></li>
</ol>
</details>
<!-- ABOUT THE PROJECT -->
## About The Project
### Video Created and Uploaded Using Brainrotinator
https://github.com/user-attachments/assets/36b5d927-ecde-4099-b9f5-687d2b0108e5
[Watch the demo on youtube](https://www.youtube.com/shorts/p__GGpKI9-w)
[Original Video](https://www.youtube.com/watch?v=Ue_jnmeBO_I)
I initially made this as a joke.
I have been editing youtube videos myself for around 10 years now. I wanted to see if I could automate the horrible podcast clips I see on youtube shorts using python.
It was really fun working with AI models to make some cool features for this project
## New Gradio Layout:
<img width="3085" height="1710" alt="image" src="https://github.com/user-attachments/assets/bdd28f0d-db84-4953-8147-0bc8afaaf1e9" />
<!-- ARCHITECTURE -->
## Architecture
The editor is **FFmpeg-only** as of the v2 rewrite. moviepy, ImageMagick, and cleanvid have all been removed. Setup is dramatically simpler — `pip install -r requirements.txt` and a working `ffmpeg` binary is enough to edit videos.
### Application Flow
```mermaid
flowchart TD
subgraph Input
A1[Upload MP4]
A2[YouTube URL\nyt-dlp download]
A3[Existing file\nin to_split/]
end
subgraph UI["Entry Points"]
B1[app.py\nGradio Web UI]
B2[main.py\nCLI]
end
subgraph Editor["brainrotinator/ — VideoEditor"]
C1[Split into chunks\nvideo_editor.py]
C2{Blur mode?}
C3[Blur letterbox\nffmpeg_ops.py]
C4[Center crop 9:16\nffmpeg_ops.py]
C5[Transcribe audio\ntranscribe.py]
C6{Whisper\nor Vosk?}
C7[Whisper model]
C8[Vosk model]
C9[Generate SRT\nsubtitles.py]
C10[Detect profanity\nprofanity.py / swears.txt]
C11[Burn subtitles\nlibass / ffmpeg_ops.py]
C12[Mute profanity\nFFmpeg volume filter]
C13[Generate title\nTinyLlama LLM]
end
subgraph Output["done_split/"]
D1[Final MP4 clips\nwith subtitles]
end
subgraph Uploaders
E1[YouTube\nyoutube_uploader_selenium]
E2[Instagram\nInstagram_Uploader]
E3[TikTok\nupload_tiktok.py]
end
A1 & A2 & A3 --> B1
A1 & A2 & A3 --> B2
B1 & B2 --> C1
C1 --> C2
C2 -->|Yes| C3
C2 -->|No| C4
C3 & C4 --> C5
C5 --> C6
C6 -->|Whisper| C7
C6 -->|Vosk| C8
C7 & C8 --> C9
C9 --> C10
C10 --> C11
C10 --> C12
C11 & C12 --> C13
C13 --> D1
D1 --> E1 & E2 & E3
```
### Repo Layout
```text
brainrotinator/ # Editor package — pure FFmpeg
ffmpeg_ops.py # Cut, crop, blur, burn-in, mute wrappers
subtitles.py # SRT → styled ASS, profanity → mute-range list
transcribe.py # Vosk / Whisper / TinyLlama (lazy, resumable)
video_editor.py # Per-chunk orchestration
profanity.py # Text-profanity censor
uploaders/ # Selenium-based uploaders
login.py, uploader_selenium.py, upload_tiktok.py
Instagram_Uploader/, youtube_uploader_selenium/
downloader/ # yt-dlp downloader module
downloadVid.py, combineAudioVideo.py
assets/ # Static files
fonts/, swears.txt, title.txt
to_split/, done_split/, subtitles/ # Runtime media directories
models/ # Persistent AI models storage (Vosk/TinyLllama/Whisper)
app.py # Gradio Web UI entrypoint
main.py # CLI entrypoint
config.py / config.json # Pydantic configuration model
Dockerfile / docker-compose.yml # Containerization setup
```
<p align="right">(<a href="#readme-top">back to top</a>)</p>
<!-- GETTING STARTED -->
## Getting Started
Editing requires only Python, FFmpeg (with libass), and ~8 GB of disk for models on first run. The selenium uploaders additionally need Firefox + geckodriver and per-platform cookies.
### Install with Docker
The best way to run Brainrotinator with Docker is using **Docker Compose**. This automatically handles mounting the `to_split`, `done_split`, and `models` directories so your files and AI models are saved locally on your machine, working seamlessly across Windows, Mac, and Linux without complex path variables.
1. Ensure your `config.json` points the AI models to the synced `models` folder:
```json
"voskModelDir": "models",
"tinyLlamaDir": "models"
```
2. Build and start the container in the background. (Use `--build` the first time you run this, or after pulling in new code updates):
```sh
docker compose up -d --build
```
*Note: On subsequent runs, you can just use `docker compose up -d` to start the container instantly without docker checking for build updates.*
Then open http://localhost:7860.
To view logs or access the container shell:
* **Logs:** `docker compose logs -f`
* **Shell:** `docker compose exec brainrotinator bash`
If you'll use the uploader, run `python login.py` **on your host** first (it needs a GUI) so cookies are present in the mounted volume before the container starts.
#### Save output locally without Gradio
You can also run the CLI editor directly via Docker Compose (bypassing the web UI). Finished clips land in `done_split/` on your host machine, so no browser download is needed:
```sh
docker compose run --rm brainrotinator python main.py -e
```
Drop your source mp4s into `to_split/` before running.
<p align="right">(<a href="#readme-top">back to top</a>)</p>
### Install without Docker
**Prerequisites**
* Python 3.10+
* FFmpeg with libass (`ffmpeg -filters | grep " ass "` should list it; most distro packages and the official Windows builds include it)
* ~10 GB VRAM if you'll use Whisper; CPU is fine for Vosk
* ~4 GB for the TinyLlama model, ~4 GB for the Vosk model (downloaded automatically on first use)
* Firefox + geckodriver — only if you'll use the uploader
**Steps**
1. Clone the repo and `cd` in.
2. Install Python deps:
```sh
pip install -r requirements.txt
```
3. Make sure `ffmpeg` is on your `PATH`. No `IMAGEMAGICK_BINARY` / `FFMPEG_BINARY` env vars are needed anymore.
4. *(Uploader only)* install geckodriver v0.32.0 and put it on your `PATH`, then run `python login.py` once on a machine with a GUI to capture cookies.
5. Launch:
* Gradio UI: `python app.py` → http://localhost:7860
* Or CLI: `python main.py` (see [CLI](#cli) below)
## Running the Program
### Gradio UI
```sh
python app.py
```
Tabs:
* **Edit** — upload an mp4 or paste a YouTube URL, set chunk length / blur / Vosk-vs-Whisper / profanity filter, watch logs stream as the splitter runs.
* **Library** — list everything in `done_split/`. **Click a filename to download it** to your computer. Delete clips you don't want.
* **Settings** — edit `config.json` in-browser, validated against the pydantic schema before save.
### CLI
```sh
python main.py # default loop: edit one video → upload from done_split → repeat
python main.py -e # edit only (consume to_split/, write to done_split/)
python main.py -u # upload only (consume done_split/ on the schedule in config.json)
```
When the editor runs out of videos in `to_split/`, the CLI prompts for a YouTube URL and downloads it via `yt-dlp`.
### Config
`config.json` is now validated by `config.Config` (`config.py`). Defaults are filled in for any missing keys.
```json
{
"tags": ["chuckle Sandwich", "jschlatt", "ted nivison", "slimecicle", "gaming", "comedy"],
"description": "#shorts",
"howManyUploads": 1,
"howManyHoursBetweenSchedule": 0,
"howManyMinsBetweenUpload": 5,
"howManyHoursLongToSleep": 23,
"sleepXMinsBeforeStartingUploader": 0,
"chunkDuration": 58,
"blurTopBottomOfClip": true,
"useWhisperForTranscription": false,
"filterProfanityInSubtitles": false,
"uploadToYoutube": true,
"uploadToInstagram": true,
"uploadToTiktok": false,
"firefoxHeadless": true,
"voskModelDir": "",
"tinyLlamaDir": ""
}
```
| Key | Meaning |
|---|---|
| `tags` | YouTube tags; also used as #hashtags appended to the IG/TikTok caption |
| `description` | YouTube description; prepended before tags for IG/TikTok |
| `howManyUploads` | Uploads per cycle before sleeping `howManyHoursLongToSleep` |
| `howManyHoursBetweenSchedule` | Hours between each scheduled upload (YouTube/TikTok only — IG ignores) |
| `howManyMinsBetweenUpload` | Base delay between uploads, plus a 05 min jitter |
| `chunkDuration` | Clip length in seconds |
| `blurTopBottomOfClip` | `true` = blurred letterbox; `false` = center-crop to 9:16 |
| `useWhisperForTranscription` | `true` = Whisper, `false` = Vosk |
| `filterProfanityInSubtitles` | Censor swears in burned-in subtitles (audio is muted regardless) |
| `firefoxHeadless` | Must be `true` inside Docker (no display) |
| `voskModelDir`, `tinyLlamaDir` | Where to cache models. Empty = current working directory |
<p align="right">(<a href="#readme-top">back to top</a>)</p>
## Things to note
* **Editor vs uploader**: the editor is FFmpeg-only and should keep working indefinitely. The selenium uploaders depend on YouTube/IG/TikTok DOM layout and **will break** when those sites change. I am not maintaining them.
* **`swears.txt`** is the source of truth for what gets muted. Add or remove words to taste. Matching is word-bounded so `ass` won't match `class`.
* **TikTok uploads** require cookies from https://github.com/wkaisertexas/tiktok-uploader — and you will hit captchas. A paid solver like sadcaptcha can fix it; this repo doesn't include one.
* **Headless selenium**: cookies must already exist or the upload will crash. Run `login.py` on a machine with a GUI first.
* **Models** download lazily on first transcription. Vosk shows a `tqdm` progress bar; TinyLlama uses `huggingface_hub.snapshot_download` (resumable).
* **libass fonts**: the burn-in filter is invoked with `fontsdir=fonts/`, so any `.ttf` you drop in `fonts/` is available. Default is `Bangers.ttf`.
## Vosk or whisper
### Whisper
**Pros:**
- Really accurate
- Better profanity filter due to accuracy
**Cons:**
- Subtitles linger/timing is bad
- Late/early profanity mute due to timing
### Vosk
**Pros:**
- Timing is really good
- Timing of muting profanity very good
**Cons:**
- Not very accurate, so words might not get filtered
- A lot of the words are not accurate
### Notes
Maybe adapt whisper to use https://github.com/m-bain/whisperX for better timing
* I used vosk for the example video in readme
* Vosk vs whisper comparison in the demo_and_images folder
<p align="right">(<a href="#readme-top">back to top</a>)</p>
<!-- LICENSE -->
## License
Do not Sell this program. Do not use it for your own cloud service you are selling like [this](https://www.opus.pro/).
Other than that do what you like with it.
<!-- ACKNOWLEDGMENTS -->
## Acknowledgments
Thank you to the following projects for making this possible.
* [Vosk](https://alphacephei.com/vosk/)
* [Whisper](https://github.com/openai/whisper)
* [TinyLlama](https://huggingface.co/TinyLlama)
* [Text Profanity Filter](https://github.com/ben174/profanity)
* [CleanVid (mute profanity in audio)](https://github.com/mmguero/cleanvid)
* [FFmpeg](https://ffmpeg.org/) + [libass](https://github.com/libass/libass)
* [pysubs2](https://github.com/tkarabela/pysubs2) (SRT → ASS conversion)
* [Gradio](https://www.gradio.app/)
* [Youtube selenium uploader (also what I used to make the instagram uploader)](https://github.com/linouk23/youtube_uploader_selenium)
* [yt-dlp for downloading youtube videos](https://github.com/ytdl-org/youtube-dl)
* [TiktokUploader](https://github.com/wkaisertexas/tiktok-uploader)
* [readme Template](https://github.com/othneildrew/Best-README-Template/blob/main/README.md)
### TODO
- Add support for different models (current ones are outdated)
- Change preview of subtitles, maybe run subtitles through llm to get emojis or custom colors per line
- Color param for text
- CV to focus crop around face/person talking
<p align="right">(<a href="#readme-top">back to top</a>)</p>
<!-- MARKDOWN LINKS & IMAGES -->
<!-- https://www.markdownguide.org/basic-syntax/#reference-style-links -->
[contributors-shield]: https://img.shields.io/github/contributors/othneildrew/Best-README-Template.svg?style=for-the-badge
[contributors-url]: https://github.com/othneildrew/Best-README-Template/graphs/contributors
[forks-shield]: https://img.shields.io/github/forks/othneildrew/Best-README-Template.svg?style=for-the-badge
[forks-url]: https://github.com/othneildrew/Best-README-Template/network/members
[stars-shield]: https://img.shields.io/github/stars/othneildrew/Best-README-Template.svg?style=for-the-badge
[stars-url]: https://github.com/othneildrew/Best-README-Template/stargazers
[issues-shield]: https://img.shields.io/github/issues/othneildrew/Best-README-Template.svg?style=for-the-badge
[issues-url]: https://github.com/othneildrew/Best-README-Template/issues
[license-shield]: https://img.shields.io/github/license/othneildrew/Best-README-Template.svg?style=for-the-badge
[license-url]: https://github.com/othneildrew/Best-README-Template/blob/master/LICENSE.txt
[linkedin-shield]: https://img.shields.io/badge/-LinkedIn-black.svg?style=for-the-badge&logo=linkedin&colorB=555
[linkedin-url]: https://www.linkedin.com/in/luke-sorvik/
[product-screenshot]: images/screenshot.png
[Next.js]: https://img.shields.io/badge/next.js-000000?style=for-the-badge&logo=nextdotjs&logoColor=white
[Next-url]: https://nextjs.org/
[React.js]: https://img.shields.io/badge/React-20232A?style=for-the-badge&logo=react&logoColor=61DAFB
[React-url]: https://reactjs.org/
[Vue.js]: https://img.shields.io/badge/Vue.js-35495E?style=for-the-badge&logo=vuedotjs&logoColor=4FC08D
[Vue-url]: https://vuejs.org/
[Angular.io]: https://img.shields.io/badge/Angular-DD0031?style=for-the-badge&logo=angular&logoColor=white
[Angular-url]: https://angular.io/
[Svelte.dev]: https://img.shields.io/badge/Svelte-4A4A55?style=for-the-badge&logo=svelte&logoColor=FF3E00
[Svelte-url]: https://svelte.dev/
[Laravel.com]: https://img.shields.io/badge/Laravel-FF2D20?style=for-the-badge&logo=laravel&logoColor=white
[Laravel-url]: https://laravel.com
[Bootstrap.com]: https://img.shields.io/badge/Bootstrap-563D7C?style=for-the-badge&logo=bootstrap&logoColor=white
[Bootstrap-url]: https://getbootstrap.com
[JQuery.com]: https://img.shields.io/badge/jQuery-0769AD?style=for-the-badge&logo=jquery&logoColor=white
[JQuery-url]: https://jquery.com
+89
View File
@@ -0,0 +1,89 @@
accelerate==0.34.2
attrs==24.2.0
beautifulsoup4==4.12.3
certifi==2024.8.30
cffi==1.17.1
charset-normalizer==3.3.2
click==8.1.7
filelock==3.16.1
fsspec==2024.9.0
geckodriver-autoinstaller==0.1.0
gradio>=4.44.0
pydantic>=2.0
huggingface-hub>=0.28.1
idna==3.10
Jinja2==3.1.4
jsoncodable==0.1.7
jsonpickle==3.3.0
k-selenium-cookies==0.0.4
llvmlite==0.43.0
MarkupSafe==2.1.5
more-itertools==10.5.0
mpmath==1.3.0
networkx==3.3
noraise==0.0.16
numba==0.60.0
numpy==2.0.2
nvidia-cublas-cu12==12.1.3.1
nvidia-cuda-cupti-cu12==12.1.105
nvidia-cuda-nvrtc-cu12==12.1.105
nvidia-cuda-runtime-cu12==12.1.105
nvidia-cudnn-cu12==8.9.2.26
nvidia-cufft-cu12==11.0.2.54
nvidia-curand-cu12==10.3.2.106
nvidia-cusolver-cu12==11.4.5.107
nvidia-cusparse-cu12==12.1.0.106
nvidia-nccl-cu12==2.20.5
nvidia-nvjitlink-cu12==12.6.68
nvidia-nvtx-cu12==12.1.105
openai-whisper==20240930
outcome==1.3.0.post0
packaging==24.1
pillow==10.4.0
platformdirs==4.3.6
psutil==6.0.0
pycparser==2.22
pyperclip==1.9.0
PySocks==1.7.1
pysrt==1.1.2
pysubs2==1.7.3
python-dateutil==2.9.0.post0
python-dotenv==1.0.1
yt-dlp>=2024.1.0
pytz==2024.2
PyYAML==6.0.2
regex==2024.9.11
requests==2.32.3
safetensors==0.4.5
selenium>=4.15.0
selenium-browser==0.0.15
selenium-firefox==2.0.8
setuptools==69.1.1
six==1.16.0
sniffio==1.3.1
sortedcontainers==2.4.0
soupsieve==2.6
srt==3.5.3
sympy==1.13.3
termcolor==2.4.0
tiktok-uploader==1.0.16
tiktoken==0.7.0
tldextract==5.1.2
tokenizers==0.19.1
toml==0.10.2
tomli==2.0.1
torch==2.3.1
tqdm==4.66.5
transformers==4.44.2
trio==0.26.2
trio-websocket==0.11.1
triton==2.3.1
typing_extensions==4.12.2
urllib3>=2.0
vosk==0.3.45
webdriver-manager==4.0.2
websocket-client==1.8.0
websockets==13.0.1
wheel==0.43.0
wsproto==1.2.0
xpath-utils==0.0.3
+3
View File
@@ -0,0 +1,3 @@
selenium==4.9.0
selenium-browser==0.0.15
selenium-firefox==2.0.8
View File
@@ -0,0 +1,54 @@
class Constant:
"""A class for storing constants for YoutubeUploader class"""
YOUTUBE_URL = 'https://www.youtube.com'
YOUTUBE_STUDIO_URL = 'https://studio.youtube.com'
YOUTUBE_UPLOAD_URL = 'https://www.youtube.com/upload'
USER_WAITING_TIME = 1
VIDEO_TITLE = 'title'
VIDEO_DESCRIPTION = 'description'
VIDEO_EDIT = 'edit'
VIDEO_TAGS = 'tags'
TEXTBOX_ID = 'textbox'
TEXT_INPUT = 'text-input'
RADIO_LABEL = 'radioLabel'
UPLOADING_STATUS_CONTAINER = '/html/body/ytcp-uploads-dialog/tp-yt-paper-dialog/div/ytcp-animatable[2]/div/div[1]/ytcp-video-upload-progress[@uploading=""]'
NOT_MADE_FOR_KIDS_LABEL = 'VIDEO_MADE_FOR_KIDS_NOT_MFK'
Click_Add = '//*[@id="mount_0_0_7N"]/div/div/div[2]/div/div/div[1]/div[1]/div[2]/div/div/div/div/div[2]/div[7]/div/span/div/a/div/div[1]/div/div/svg'
UPLOAD_DIALOG = '//ytcp-uploads-dialog'
ADVANCED_BUTTON_ID = 'toggle-button'
TAGS_CONTAINER_ID = 'tags-container'
TAGS_INPUT = 'text-input'
NEXT_BUTTON = 'next-button'
PUBLIC_BUTTON = 'PUBLIC'
VIDEO_URL_CONTAINER = "//span[@class='video-url-fadeable style-scope ytcp-video-info']"
VIDEO_URL_ELEMENT = "//a[@class='style-scope ytcp-video-info']"
HREF = 'href'
ERROR_CONTAINER = '//*[@id="error-message"]'
VIDEO_NOT_FOUND_ERROR = 'Could not find video_id'
DONE_BUTTON = 'done-button'
INPUT_FILE_VIDEO = "//input[@type='file']"
INPUT_FILE_THUMBNAIL = "//input[@id='file-loader']"
# Playlist
VIDEO_PLAYLIST = 'playlist_title'
PL_DROPDOWN_CLASS = 'ytcp-video-metadata-playlists'
PL_SEARCH_INPUT_ID = 'search-input'
PL_ITEMS_CONTAINER_ID = 'items'
PL_ITEM_CONTAINER = '//span[text()="{}"]'
PL_NEW_BUTTON_CLASS = 'new-playlist-button'
PL_CREATE_PLAYLIST_CONTAINER_ID = 'create-playlist-form'
PL_CREATE_BUTTON_CLASS = 'create-playlist-button'
PL_DONE_BUTTON_CLASS = 'done-button'
#Schedule
VIDEO_SCHEDULE = 'schedule'
SCHEDULE_CONTAINER_ID = 'second-container-expand-button'
SCHEDULE_DATE_ID = 'datepicker-trigger'
SCHEDULE_DATE_TEXTBOX = '/html/body/ytcp-date-picker/tp-yt-paper-dialog/div/form/tp-yt-paper-input/tp-yt-paper-input-container/div[2]/div/iron-input/input'
SCHEDULE_TIME = '//*[@id="input-1"]/input'
@@ -0,0 +1,221 @@
"""This module implements uploading videos on YouTube via Selenium using metadata JSON file
to extract its title, description etc."""
from typing import DefaultDict, Optional, Tuple
from selenium_firefox.firefox import Firefox
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from collections import defaultdict
from datetime import datetime
import json
import time
from .Constant import *
from pathlib import Path
import logging
import platform
logging.basicConfig()
import random
#used to generate a random wait time to perform the actions on the browser
def random_time():
rand : int = random.randint(1, 3)
print("Random time: ", rand)
return rand
def load_metadata(metadata_json_path: Optional[str] = None) -> DefaultDict[str, str]:
if metadata_json_path is None:
return defaultdict(str)
with open(metadata_json_path, encoding='utf-8') as metadata_json_file:
return defaultdict(str, json.load(metadata_json_file))
class InstagramUploader:
"""A class for uploading videos on Instagram via Selenium using metadata JSON file
to extract its title, description etc"""
def __init__(self, video_path: str, metadata_json_path: Optional[str] = None,
thumbnail_path: Optional[str] = None,
profile_path: Optional[str] = str(Path.cwd()) + "/profile",
headless : bool = True) -> None:
self.video_path = video_path
self.thumbnail_path = thumbnail_path
self.metadata_dict = load_metadata(metadata_json_path)
self.browser = Firefox(profile_path=profile_path, pickle_cookies=True, full_screen=False, headless=headless)
self.logger = logging.getLogger(__name__)
self.logger.setLevel(logging.DEBUG)
self.__validate_inputs()
print("headless for instagram =" +str(headless))
self.is_mac = False
if not any(os_name in platform.platform() for os_name in ["Windows", "Linux"]):
self.is_mac = True
self.logger.debug("Use profile path: {}".format(self.browser.source_profile_path))
def __validate_inputs(self):
if not self.metadata_dict[Constant.VIDEO_TITLE]:
self.logger.warning(
"The video title was not found in a metadata file")
self.metadata_dict[Constant.VIDEO_TITLE] = Path(
self.video_path).stem
self.logger.warning("The video title was set to {}".format(
Path(self.video_path).stem))
if not self.metadata_dict[Constant.VIDEO_DESCRIPTION]:
self.logger.warning(
"The video description was not found in a metadata file")
def upload(self):
try:
self.login()
return self.__upload()
except Exception as e:
print(e)
self.__quit()
raise
def login(self):
self.browser.get("https://www.instagram.com/")
time.sleep(1)
if self.browser.has_cookies_for_current_website():
self.browser.load_cookies()
self.logger.debug("Loaded cookies from {}".format(self.browser.cookies_folder_path))
time.sleep(1)
self.browser.refresh()
else:
self.logger.info('Please sign in and then press enter')
input()
self.browser.get("https://www.instagram.com/")
time.sleep(3)
self.browser.save_cookies()
self.logger.debug("Saved cookies to {}".format(self.browser.cookies_folder_path))
def __clear_field(self, field):
field.click()
time.sleep(3)
if self.is_mac:
field.send_keys(Keys.COMMAND + 'a')
else:
field.send_keys(Keys.CONTROL + 'a')
time.sleep(2)
field.send_keys(Keys.BACKSPACE)
def __write_in_field(self, field, string, select_all=False):
if select_all:
self.__clear_field(field)
else:
field.click()
time.sleep(random_time())
field.send_keys(string)
#find element, if not found, wait 3 seconds and try again
#by - the type of element to find
#value - the value of the element to find
#name - the name of the element to find
#return - the element found
def find_element(self, by, value: str, name: str):
element = None
startTime = time.time()
while element is None:
element = self.browser.find(by, value)
time.sleep(3)
print(f"{name} not found")
#if 1 minute has passed, break the loop
if time.time() - startTime > 60:
break
print(f"{name} found")
return element
def __upload(self) -> bool:
self.browser.get("https://www.instagram.com/?next=%2F")
uploading_status_container = InstagramUploader.find_element(self,By.CSS_SELECTOR, 'button._a9--:nth-child(2)', "uploading status container")
uploading_status_container.click()
print("clicked no updates")
time.sleep(2)
upload_button = None
upload_button = InstagramUploader.find_element(self,By.XPATH, '/html/body/div[1]/div/div/div[2]/div/div/div[1]/div[1]/div[2]/div/div/div/div/div[2]/div[7]/div/span/div/a', "upload button")
upload_button.click()
print("clicked create")
time.sleep(3)
self.browser.find(By.XPATH, '/html/body/div[1]/div/div/div[2]/div/div/div[1]/div[1]/div[2]/div/div/div/div/div[2]/div[7]/div/span/div/div/div/div[1]/a[1]').click()
print("clicked post")
time.sleep(3)
absolute_video_path = str(Path.cwd() / self.video_path)
videoUpload = InstagramUploader.find_element(self,By.CSS_SELECTOR,'._ac69', "video upload")
videoUpload.send_keys(absolute_video_path)
print("gave video path: ", absolute_video_path)
time.sleep(3)
next = InstagramUploader.find_element(self,By.CSS_SELECTOR, '._acap', "next")
next.click()
print("clicked next")
time.sleep(3)
size = InstagramUploader.find_element(self,By.CSS_SELECTOR, 'div.xnz67gz > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(2) > div:nth-child(1) > button:nth-child(1)', "size")
size.click()
print("clicked size")
time.sleep(3)
phone_resolution = InstagramUploader.find_element(self,By.CSS_SELECTOR, 'div.x1i10hfl:nth-child(5)', "phone resolution")
phone_resolution.click()
print("clicked phone resolution")
clickOff = InstagramUploader.find_element(self,By.CSS_SELECTOR, 'div.xnz67gz > div:nth-child(1) > div:nth-child(3)', "click off")
clickOff.click()
time.sleep(3)
next = InstagramUploader.find_element(self,By.CSS_SELECTOR, '.x1f6kntn', "next")
next.click()
print("clicked next")
time.sleep(3)
next = InstagramUploader.find_element(self,By.CSS_SELECTOR, '.xyamay9 > div:nth-child(1)', "next")
next.click()
print("clicked next")
time.sleep(3)
description_field = self.browser.find(By.XPATH, '/html/body/div[6]/div[1]/div/div[3]/div/div/div/div/div/div/div/div[2]/div[2]/div/div/div/div[1]/div[2]/div/div[1]/div[1]')
video_description :str = self.metadata_dict[Constant.VIDEO_DESCRIPTION]
video_description = video_description.replace("\n", Keys.ENTER)
if video_description:
description_field = self.browser.find(By.CSS_SELECTOR, '.x1hnll1o')
print("Video Description: ", video_description)
description_field.click()
time.sleep(2)
[description_field.send_keys(c) for c in video_description] #send_keys(video_description)
print("Video description added")
time.sleep(2)
share = InstagramUploader.find_element(self,By.CSS_SELECTOR, '.x1f6kntn', "share")
share.click()
print("clicked share")
#uploading_status_container = self.browser.find(By.XPATH, Constant.UPLOADING_STATUS_CONTAINER)
uploading_status_container_done = InstagramUploader.find_element(self,By.CSS_SELECTOR, 'div.x5yr21d:nth-child(1) > div:nth-child(1) > div:nth-child(2)', "uploading status container done")
self.__quit()
return True
def __get_video_id(self) -> Optional[str]:
video_id = None
try:
video_url_container = self.browser.find(
By.XPATH, Constant.VIDEO_URL_CONTAINER)
video_url_element = self.browser.find(By.XPATH, Constant.VIDEO_URL_ELEMENT, element=video_url_container)
video_id = video_url_element.get_attribute(
Constant.HREF).split('/')[-1]
except:
self.logger.warning(Constant.VIDEO_NOT_FOUND_ERROR)
pass
return video_id
def __quit(self):
self.browser.driver.quit()
+23
View File
@@ -0,0 +1,23 @@
import os
from .youtube_uploader_selenium import YouTubeUploader
from .Instagram_Uploader.instagramUploader import InstagramUploader
from termcolor import colored
def login_youtube(metadata:str) -> None:
print(colored("Logging in to Youtube", "blue"))
uploader = YouTubeUploader("dummy", metadata, headless= False)
uploader.login()
def login_instagram(metadata:str) -> None:
print(colored("Logging in to Instagram", "magenta"))
uploader = InstagramUploader("dummy",metadata, headless= False)
uploader.login()
if __name__ == "__main__":
print(colored("Logging in to Youtube and Instagram to save cookies for future use", "green"))
print(colored("Firefox browser required", "yellow"))
outputDirectory :str = os.path.join(os.getcwd(), "done_split")
metadata_path :str = os.path.join(outputDirectory, "upload.json")
login_youtube(metadata_path)
login_instagram(metadata_path)
print(colored("Cookies saved successfully. Now you can upload videos without logging in again", "cyan"))
+16
View File
@@ -0,0 +1,16 @@
from tiktok_uploader.upload import upload_video, upload_videos
from tiktok_uploader.auth import AuthBackend
#https://github.com/wkaisertexas/tiktok-uploader?tab=readme-ov-file
def upload_video_Tiktok(video_path :str, description:str, cookies:str) -> None:
username = 'your username here'
password = 'password here'
upload_video(filename=video_path, description=description, cookies= cookies, username=username, password=password)
@@ -0,0 +1,31 @@
from .youtube_uploader_selenium import YouTubeUploader
#from https://github.com/linouk23/youtube_uploader_selenium
from .Instagram_Uploader.instagramUploader import InstagramUploader
def upload_video_youtube(video_path: str, metadata_path: str, headless: bool, i : int = 0) -> None:
uploader = YouTubeUploader(video_path, metadata_path, headless=headless)
try:
uploader.upload()
except Exception as e:
print(f"Error uploading video: {e}")
print("Retrying upload...")
if i < 5:
print(f"Retry number {i}")
i += 1
upload_video_youtube(video_path, metadata_path, headless, i) # retry the upload recursively
def upload_video_Instagram(video_path: str, metadata_path: str, headless: bool, i: int = 0) -> None:
uploader = InstagramUploader(video_path, metadata_path, headless=headless)
try:
uploader.upload()
except Exception as e:
print(f"Error uploading video: {e}")
print("Retrying upload...")
if(i < 5):
print(f"Retry number {i}")
i += 1
upload_video_Instagram(video_path, metadata_path,headless, i) # retry the upload recursively
@@ -0,0 +1,50 @@
class Constant:
"""A class for storing constants for YoutubeUploader class"""
YOUTUBE_URL = 'https://www.youtube.com'
YOUTUBE_STUDIO_URL = 'https://studio.youtube.com'
YOUTUBE_UPLOAD_URL = 'https://www.youtube.com/upload'
USER_WAITING_TIME = 1
VIDEO_TITLE = 'title'
VIDEO_DESCRIPTION = 'description'
VIDEO_EDIT = 'edit'
VIDEO_TAGS = 'tags'
TEXTBOX_ID = 'textbox'
TEXT_INPUT = 'text-input'
RADIO_LABEL = 'radioLabel'
UPLOADING_STATUS_CONTAINER = '/html/body/ytcp-uploads-dialog/tp-yt-paper-dialog/div/ytcp-animatable[2]/div/div[1]/ytcp-video-upload-progress[@uploading=""]'
NOT_MADE_FOR_KIDS_LABEL = 'VIDEO_MADE_FOR_KIDS_NOT_MFK'
UPLOAD_DIALOG = '//ytcp-uploads-dialog'
ADVANCED_BUTTON_ID = 'toggle-button'
TAGS_CONTAINER_ID = 'tags-container'
TAGS_INPUT = 'text-input'
NEXT_BUTTON = 'next-button'
PUBLIC_BUTTON = 'PUBLIC'
VIDEO_URL_CONTAINER = "//span[@class='video-url-fadeable style-scope ytcp-video-info']"
VIDEO_URL_ELEMENT = "//a[@class='style-scope ytcp-video-info']"
HREF = 'href'
ERROR_CONTAINER = '//*[@id="error-message"]'
VIDEO_NOT_FOUND_ERROR = 'Could not find video_id'
DONE_BUTTON = 'done-button'
INPUT_FILE_VIDEO = "//input[@type='file']"
INPUT_FILE_THUMBNAIL = "//input[@id='file-loader']"
# Playlist
VIDEO_PLAYLIST = 'playlist_title'
PL_DROPDOWN_CLASS = 'ytcp-video-metadata-playlists'
PL_SEARCH_INPUT_ID = 'search-input'
PL_ITEMS_CONTAINER_ID = 'items'
PL_ITEM_CONTAINER = '//span[text()="{}"]'
PL_NEW_BUTTON_CLASS = 'new-playlist-button'
PL_CREATE_PLAYLIST_CONTAINER_ID = 'create-playlist-form'
PL_CREATE_BUTTON_CLASS = 'create-playlist-button'
PL_DONE_BUTTON_CLASS = 'done-button'
#Schedule
VIDEO_SCHEDULE = 'schedule'
SCHEDULE_CONTAINER_ID = 'second-container-expand-button'
SCHEDULE_DATE_ID = 'datepicker-trigger'
SCHEDULE_DATE_TEXTBOX = '/html/body/ytcp-date-picker/tp-yt-paper-dialog/div/form/tp-yt-paper-input/tp-yt-paper-input-container/div[2]/div/iron-input/input'
SCHEDULE_TIME = '//*[@id="input-1"]/input'
@@ -0,0 +1,296 @@
"""This module implements uploading videos on YouTube via Selenium using metadata JSON file
to extract its title, description etc."""
from typing import DefaultDict, Optional, Tuple
from selenium_firefox.firefox import Firefox
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from collections import defaultdict
from datetime import datetime
import json
import time
from .Constant import *
from pathlib import Path
import logging
import platform
logging.basicConfig()
import random
#used to generate a random wait time to perform the actions on the browser
def random_time():
rand : int = random.randint(1, 3)
print("Random time: ", rand)
return rand
def load_metadata(metadata_json_path: Optional[str] = None) -> DefaultDict[str, str]:
if metadata_json_path is None:
return defaultdict(str)
with open(metadata_json_path, encoding='utf-8') as metadata_json_file:
return defaultdict(str, json.load(metadata_json_file))
class YouTubeUploader:
"""A class for uploading videos on YouTube via Selenium using metadata JSON file
to extract its title, description etc"""
def __init__(self, video_path: str, metadata_json_path: Optional[str] = None,
thumbnail_path: Optional[str] = None,
profile_path: Optional[str] = str(Path.cwd()) + "/profile",
headless : bool = True) -> None:
self.video_path = video_path
self.thumbnail_path = thumbnail_path
self.metadata_dict = load_metadata(metadata_json_path)
self.browser = Firefox(profile_path=profile_path, pickle_cookies=True, full_screen=False, headless=headless)
self.logger = logging.getLogger(__name__)
self.logger.setLevel(logging.DEBUG)
self.__validate_inputs()
print("headless for youtube =" + str(headless))
self.is_mac = False
if not any(os_name in platform.platform() for os_name in ["Windows", "Linux"]):
self.is_mac = True
self.logger.debug("Use profile path: {}".format(self.browser.source_profile_path))
def __validate_inputs(self):
if not self.metadata_dict[Constant.VIDEO_TITLE]:
self.logger.warning(
"The video title was not found in a metadata file")
self.metadata_dict[Constant.VIDEO_TITLE] = Path(
self.video_path).stem
self.logger.warning("The video title was set to {}".format(
Path(self.video_path).stem))
if not self.metadata_dict[Constant.VIDEO_DESCRIPTION]:
self.logger.warning(
"The video description was not found in a metadata file")
def upload(self):
try:
self.login()
return self.__upload()
except Exception as e:
print(e)
self.__quit()
raise
def login(self):
self.browser.get(Constant.YOUTUBE_URL)
time.sleep(random_time())
if self.browser.has_cookies_for_current_website():
self.browser.load_cookies()
self.logger.debug("Loaded cookies from {}".format(self.browser.cookies_folder_path))
time.sleep(random_time())
self.browser.refresh()
else:
self.logger.info('Please sign in and then press enter')
input()
self.browser.get(Constant.YOUTUBE_URL)
time.sleep(random_time())
self.browser.save_cookies()
self.logger.debug("Saved cookies to {}".format(self.browser.cookies_folder_path))
def __clear_field(self, field):
field.click()
time.sleep(random_time())
if self.is_mac:
field.send_keys(Keys.COMMAND + 'a')
else:
field.send_keys(Keys.CONTROL + 'a')
time.sleep(random_time())
field.send_keys(Keys.BACKSPACE)
def __write_in_field(self, field, string, select_all=False):
if select_all:
self.__clear_field(field)
else:
field.click()
time.sleep(random_time())
field.send_keys(string)
def __upload(self) -> Tuple[bool, Optional[str]]:
self.browser.get(Constant.YOUTUBE_URL)
time.sleep(random_time())
self.browser.get(Constant.YOUTUBE_UPLOAD_URL)
time.sleep(random_time())
absolute_video_path = str(Path.cwd() / self.video_path)
self.browser.find(By.XPATH, Constant.INPUT_FILE_VIDEO).send_keys(
absolute_video_path)
self.logger.debug('Attached video {}'.format(self.video_path))
# Find status container
uploading_status_container = None
while uploading_status_container is None:
time.sleep(0.5) #bug where slept too long, missed finding the element got soft locked
uploading_status_container = self.browser.find(By.XPATH, Constant.UPLOADING_STATUS_CONTAINER)
if self.thumbnail_path is not None:
absolute_thumbnail_path = str(Path.cwd() / self.thumbnail_path)
self.browser.find(By.XPATH, Constant.INPUT_FILE_THUMBNAIL).send_keys(
absolute_thumbnail_path)
change_display = "document.getElementById('file-loader').style = 'display: block! important'"
self.browser.driver.execute_script(change_display)
self.logger.debug(
'Attached thumbnail {}'.format(self.thumbnail_path))
title_field, description_field = self.browser.find_all(By.ID, Constant.TEXTBOX_ID, timeout=15)
self.__write_in_field(
title_field, self.metadata_dict[Constant.VIDEO_TITLE], select_all=True)
self.logger.debug('The video title was set to \"{}\"'.format(
self.metadata_dict[Constant.VIDEO_TITLE]))
video_description = self.metadata_dict[Constant.VIDEO_DESCRIPTION]
video_description = video_description.replace("\n", Keys.ENTER)
if video_description:
self.__write_in_field(description_field, video_description, select_all=True)
self.logger.debug('Description filled.')
kids_section = self.browser.find(By.NAME, Constant.NOT_MADE_FOR_KIDS_LABEL)
kids_section.location_once_scrolled_into_view
time.sleep(random_time())
self.browser.find(By.ID, Constant.RADIO_LABEL, kids_section).click()
self.logger.debug('Selected \"{}\"'.format(Constant.NOT_MADE_FOR_KIDS_LABEL))
# Playlist
playlist = self.metadata_dict[Constant.VIDEO_PLAYLIST]
if playlist:
self.browser.find(By.CLASS_NAME, Constant.PL_DROPDOWN_CLASS).click()
time.sleep(random_time())
search_field = self.browser.find(By.ID, Constant.PL_SEARCH_INPUT_ID)
self.__write_in_field(search_field, playlist)
time.sleep(random_time() * 2)
playlist_items_container = self.browser.find(By.ID, Constant.PL_ITEMS_CONTAINER_ID)
# Try to find playlist
self.logger.debug('Playlist xpath: "{}".'.format(Constant.PL_ITEM_CONTAINER.format(playlist)))
playlist_item = self.browser.find(By.XPATH, Constant.PL_ITEM_CONTAINER.format(playlist), playlist_items_container)
if playlist_item:
self.logger.debug('Playlist found.')
playlist_item.click()
time.sleep(random_time())
else:
self.logger.debug('Playlist not found. Creating')
self.__clear_field(search_field)
time.sleep(random_time())
new_playlist_button = self.browser.find(By.CLASS_NAME, Constant.PL_NEW_BUTTON_CLASS)
new_playlist_button.click()
create_playlist_container = self.browser.find(By.ID, Constant.PL_CREATE_PLAYLIST_CONTAINER_ID)
playlist_title_textbox = self.browser.find(By.XPATH, "//textarea", create_playlist_container)
self.__write_in_field(playlist_title_textbox, playlist)
time.sleep(random_time())
create_playlist_button = self.browser.find(By.CLASS_NAME, Constant.PL_CREATE_BUTTON_CLASS)
create_playlist_button.click()
time.sleep(random_time())
done_button = self.browser.find(By.CLASS_NAME, Constant.PL_DONE_BUTTON_CLASS)
done_button.click()
# Advanced options
self.browser.find(By.ID, Constant.ADVANCED_BUTTON_ID).click()
self.logger.debug('Clicked MORE OPTIONS')
time.sleep(random_time())
#click not ai (added myself by inspecting element and right clicking copy by xpath)
not_ai = self.browser.find(By.XPATH, "/html/body/ytcp-uploads-dialog/tp-yt-paper-dialog/div/ytcp-animatable[1]/ytcp-ve/ytcp-video-metadata-editor/div/ytcp-video-metadata-editor-advanced/div[2]/ytkp-altered-content-select/div[2]/tp-yt-paper-radio-group/tp-yt-paper-radio-button[2]/div[1]/div[1]")
not_ai.click()
time.sleep(random_time())
# Tags
tags = self.metadata_dict[Constant.VIDEO_TAGS]
if tags:
tags_container = self.browser.find(By.ID, Constant.TAGS_CONTAINER_ID)
tags_field = self.browser.find(By.ID, Constant.TAGS_INPUT, tags_container)
self.__write_in_field(tags_field, ','.join(tags))
self.logger.debug('The tags were set to \"{}\"'.format(tags))
self.browser.find(By.ID, Constant.NEXT_BUTTON).click()
self.logger.debug('Clicked {} one'.format(Constant.NEXT_BUTTON))
self.browser.find(By.ID, Constant.NEXT_BUTTON).click()
self.logger.debug('Clicked {} two'.format(Constant.NEXT_BUTTON))
self.browser.find(By.ID, Constant.NEXT_BUTTON).click()
self.logger.debug('Clicked {} three'.format(Constant.NEXT_BUTTON))
schedule = self.metadata_dict[Constant.VIDEO_SCHEDULE]
#Schedule
if schedule:
upload_time_object = datetime.strptime(schedule, "%m/%d/%Y, %H:%M")
self.browser.find(By.ID, Constant.SCHEDULE_CONTAINER_ID).click() #click the schedule dropdown
time.sleep(1) #make sure there is time for the date picker to load
schedule2 = self.browser.find(By.ID, Constant.SCHEDULE_DATE_ID) #find the date picker
time.sleep(1) #wait for schedule2 to load
schedule2.click() #click to open calendar
time.sleep(1) #wait for the date picker to load
self.browser.find(By.XPATH, Constant.SCHEDULE_DATE_TEXTBOX).clear() #clear the date text box
self.browser.find(By.XPATH, Constant.SCHEDULE_DATE_TEXTBOX).send_keys(
datetime.strftime(upload_time_object, "%b %e, %Y"))
self.browser.find(By.XPATH, Constant.SCHEDULE_DATE_TEXTBOX).send_keys(Keys.ENTER)
time.sleep(1) #wait for the date picker to load
self.browser.find(By.XPATH, Constant.SCHEDULE_TIME).click()
self.browser.find(By.XPATH, Constant.SCHEDULE_TIME).clear()
time.sleep(1) #wait for the date picker to load
self.browser.find(By.XPATH, Constant.SCHEDULE_TIME).send_keys(
datetime.strftime(upload_time_object, "%H:%M"))
self.browser.find(By.XPATH, Constant.SCHEDULE_TIME).send_keys(Keys.ENTER)
self.logger.debug(f"Scheduled the video for {schedule}")
else:
public_main_button = self.browser.find(By.NAME, Constant.PUBLIC_BUTTON)
self.browser.find(By.ID, Constant.RADIO_LABEL, public_main_button).click()
self.logger.debug('Made the video {}'.format(Constant.PUBLIC_BUTTON))
video_id = self.__get_video_id()
# Check status container and upload progress
uploading_status_container = self.browser.find(By.XPATH, Constant.UPLOADING_STATUS_CONTAINER)
while uploading_status_container is not None:
uploading_progress = uploading_status_container.get_attribute('value')
self.logger.debug('Upload video progress: {}%'.format(uploading_progress))
time.sleep(random_time() * 5)
uploading_status_container = self.browser.find(By.XPATH, Constant.UPLOADING_STATUS_CONTAINER)
self.logger.debug('Upload container gone.')
done_button = self.browser.find(By.ID, Constant.DONE_BUTTON)
# Catch such error as
# "File is a duplicate of a video you have already uploaded"
if done_button.get_attribute('aria-disabled') == 'true':
error_message = self.browser.find(By.XPATH, Constant.ERROR_CONTAINER).text
self.logger.error(error_message)
return False, None
done_button.click()
self.logger.debug(
"Published the video with video_id = {}".format(video_id))
time.sleep(random_time())
self.browser.get(Constant.YOUTUBE_URL)
self.__quit()
return True, video_id
def __get_video_id(self) -> Optional[str]:
video_id = None
try:
video_url_container = self.browser.find(
By.XPATH, Constant.VIDEO_URL_CONTAINER)
video_url_element = self.browser.find(By.XPATH, Constant.VIDEO_URL_ELEMENT, element=video_url_container)
video_id = video_url_element.get_attribute(
Constant.HREF).split('/')[-1]
except:
self.logger.warning(Constant.VIDEO_NOT_FOUND_ERROR)
pass
return video_id
def __quit(self):
self.browser.driver.quit()