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:
Submodule brainrotinator deleted from 76983b03b7
@@ -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/
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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.
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
_______ __ __ __ __
|
||||
/ \ / | / | / | / |
|
||||
$$$$$$$ | ______ ______ $$/ _______ ______ ______ _$$ |_ $$/ _______ ______ _$$ |_ ______ ______
|
||||
$$ |__$$ | / \ / \ / |/ \ / \ / \ / $$ | / |/ \ / \ / $$ | / \ / \
|
||||
$$ $$< /$$$$$$ |$$$$$$ |$$ |$$$$$$$ |/$$$$$$ |/$$$$$$ |$$$$$$/ $$ |$$$$$$$ | $$$$$$ |$$$$$$/ /$$$$$$ |/$$$$$$ |
|
||||
$$$$$$$ |$$ | $$/ / $$ |$$ |$$ | $$ |$$ | $$/ $$ | $$ | $$ | __ $$ |$$ | $$ | / $$ | $$ | __ $$ | $$ |$$ | $$/
|
||||
$$ |__$$ |$$ | /$$$$$$$ |$$ |$$ | $$ |$$ | $$ \__$$ | $$ |/ |$$ |$$ | $$ |/$$$$$$$ | $$ |/ |$$ \__$$ |$$ |
|
||||
$$ $$/ $$ | $$ $$ |$$ |$$ | $$ |$$ | $$ $$/ $$ $$/ $$ |$$ | $$ |$$ $$ | $$ $$/ $$ $$/ $$ |
|
||||
$$$$$$$/ $$/ $$$$$$$/ $$/ $$/ $$/ $$/ $$$$$$/ $$$$/ $$/ $$/ $$/ $$$$$$$/ $$$$/ $$$$$$/ $$/
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"))
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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 0–5 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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
selenium==4.9.0
|
||||
selenium-browser==0.0.15
|
||||
selenium-firefox==2.0.8
|
||||
@@ -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()
|
||||
@@ -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"))
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user