Video utils
Video Subtitles
This tutorial explains how to add subtitles to the video files.
Prepare
Install:
sudo apt install qnapi uchardet ffmpeg recode
Batch Normalize filenames
uvx regex-rename "S01E(\d+).*mp4" "Foundation S01E\1.mp4" #--rename
TV Shows/
└── Series Name (Year)/
├── Season 01/
│ ├── Series Name S01E01.mkv
│ ├── Series Name S01E01.en.srt
└── Season 02/
├── Series Name S02E01.mkv
│ ├── Series Name S02E01.en.srt
└── Series Name S02E02-E03.mkv
│ ├── Series Name S02E02-E03.en.srt
Movies/
├── Movie Name (Year)/
│ ├── Movie Name (Year).mkv
│ ├── Movie Name (Year).en.srt
Download subtitles for your language
Subliminal
uvx subliminal download -l pl *.mkv
uvx subliminal download -l en *.mp4
Qnapi
qnapi -l pl *.mp4
qnapi -l pl *.mkv
VLsub
or open with VLC, with VLsub extension.
Detect encoding
uchardet *.txt
UTF-8, Windows-1250 or UTF-16.
Verify that converted encoding is right
cat *.txt | recode cp1250..utf8
cat *.txt | recode utf16..utf8
Available encodings:
recode --list
Recode to UTF8
recode cp1250..utf8 *.txt
# or
recode utf16..utf8 *.txt
Convert to SRT
Convert TXT to SRT, add .default.srt suffix for Jellyfin:
ffmpeg -i 'S01E03.txt' 'S01E03.default.srt'
ls -1 *.txt | sed -e 's/\.txt$//g' | xargs -d '\n' -I %s echo 'ffmpeg -i "%s.txt" "%s.default.srt"'
Synchronize subtitles to movie
Install alass:
cargo install alass-cli
Synchronize subtitles to audio:
alass-cli movie.mp4 incorrect_subtitle.srt output.srt
# Batch synchronize (generates .new.srt)
ls -1 *.srt | sed -e 's/\.en.srt$//g' | xargs -d '\n' -I %s echo 'alass-cli "%s.mp4" "%s.en.srt" "%s.new.srt"'
# overwrite original .srt
ls -1 *.new.srt | sed 's/\.new\.srt$//' | xargs -d '\n' -I %s echo 'mv -f "%s.new.srt" "%s.en.srt"'
Appendix
Do the ffmpeg conversion for all TXT files in the current directory:
from pathlib import Path
from nuclear import shell
for path in Path('.').iterdir():
if path.name.endswith('.txt'):
stem = path.name[:-4]
print(f'converting {path.name}')
shell(f"ffmpeg -i '{stem}.txt' '{stem}.default.srt'")
Download YouTube video as MP4
pip3 install --upgrade yt-dlp --break-system-packages
yt-dlp -f 'bestvideo[height<=1080][ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best' URL
# Or (newer):
yt-dlp -f "bestvideo[height<=1080]+bestaudio/best" --merge-output-format mp4 --no-playlist --remote-components ejs:github URL
# Download MP4 with normalized audio
yt-dlp -f 'bestvideo[height<=1080]+bestaudio/best' \
--merge-output-format mp4 \
--no-playlist --remote-components ejs:github \
--postprocessor-args "Merger:-filter:a 'pan=stereo|FL < 1.0*FL + 0.707*FC + 0.707*BL|FR < 1.0*FR + 0.707*FC + 0.707*BR,aresample=matrix_encoding=dplii,dynaudnorm=maxgain=40:framelen=400:gausssize=5:altboundary=1' -c:v copy -c:a aac -ac 2" \
-o "{target}" \
"{yt_url}"
Download YouTube video as normalized MP3
pip3 install --upgrade yt-dlp --break-system-packages
# Download MP3 with normalized audio
yt-dlp -x --audio-format mp3 --audio-quality 0 \
--no-playlist --remote-components ejs:github \
--postprocessor-args "ExtractAudio:-filter:a 'dynaudnorm=maxgain=40:framelen=400:gausssize=5:altboundary=1'" \
-o "{target}.mp3" \
"{yt_url}"
Dynamic Audio Normalizer / Night Mode / Dynamic range compression
This allows for applying extra gain to the "quiet" sections of the audio while avoiding distortions or clipping the "loud" sections. In other words: The Dynamic Audio Normalizer will "even out" the volume of quiet and loud sections, in the sense that the volume of each section is brought to the same target level.
ffmpeg -i "$INPUT.mkv" -c:v copy -af "dynaudnorm=maxgain=40:framelen=400:gausssize=5:altboundary=1" -c:a aac -b:a 192k "$INPUT.norm.mkv"
ls -1 *.mkv | sed -e 's/\.mkv$//g' | xargs -d '\n' -I %s echo 'ffmpeg -i "%s.mkv" -c:v copy -af "dynaudnorm=maxgain=40:framelen=400:gausssize=5:altboundary=1" -c:a aac -b:a 192k "%s.norm.mkv"'
ls -1 *.mp4 | sed -e 's/\.mp4$//g' | xargs -d '\n' -I %s echo 'ffmpeg -i "%s.mp4" -c:v copy -af "dynaudnorm=maxgain=40:framelen=400:gausssize=5:altboundary=1" -c:a aac -b:a 192k "%s.norm.mp4"'
Extract normalized audio from video:
ffmpeg -i input.mkv -map a \
-filter:a "pan=stereo|FL < 1.0*FL + 0.707*FC + 0.707*BL|FR < 1.0*FR + 0.707*FC + 0.707*BR,aresample=matrix_encoding=dplii,dynaudnorm=maxgain=40:framelen=400:gausssize=5:altboundary=1" \
-ac 2 -q:a 0 \
output.mp3
nukefile.py
Python script executing commands for batch of files:
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = [
# "nuclear>=2.8.1",
# ]
# ///
from pathlib import Path
from nuclear import nuke, logger
class Config:
dry: bool = False
sources: list[tuple] = [
('NUM', 'YT_URL', 'TITLE'),
]
config, sh = nuke.init(Config)
def mkv_to_mp3():
sources: list[Path] = list(Path('.').glob('*.mkv'))
for file in sources:
"""
map 0:a:1 - From input #0 select audio stream index #1 (second)
pan=stereo - downmix 5.1 to stereo
aresample=matrix_encoding=dplii - Dolby Pro Logic II matrix
dynaudnorm - Dynamic Audio Normalizer
-ac 2 -q:a 0 - output 2 channels, variable bitrate
"""
target = f'{file.stem}.mp3'
if Path(target).exists():
logger.debug(f'{target} already exists - skipping')
continue
cmd = f'''ffmpeg -i "{f.absolute()}"
-map 0:a:1
-filter:a "pan=stereo|FL < 1.0*FL + 0.707*FC + 0.707*BL|FR < 1.0*FR + 0.707*FC + 0.707*BR,
aresample=matrix_encoding=dplii,
dynaudnorm=maxgain=40:framelen=400:gausssize=5:altboundary=1"
-ac 2 -q:a 0
"{target}"'''.replace('\n', '')
sh<<cmd
def yt_to_mp3():
for id, yt_url, title in config.sources:
target = f'{id} {title}.mp4'
if Path(target).exists():
logger.debug('target already exists - skipping', target=target)
continue
sh<<f'''yt-dlp -f 'bestvideo[height<=1080]+bestaudio/best' \
--merge-output-format mp4 \
--no-playlist --remote-components ejs:github \
--postprocessor-args "Merger:-filter:a 'pan=stereo|FL < 1.0*FL + 0.707*FC + 0.707*BL|FR < 1.0*FR + 0.707*FC + 0.707*BR,aresample=matrix_encoding=dplii,dynaudnorm=maxgain=40:framelen=400:gausssize=5:altboundary=1' -c:v copy -c:a aac -ac 2" \
-o "{target}" \
"{yt_url}"'''
if __name__ == '__main__':
nuke.run()
Split video into chunks
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = [
# "nuclear>=2.8.1",
# ]
# ///
from pathlib import Path
from nuclear import shell, logger
src_path = '[INPUT_PATH].mkv'
dst_suffix = '-[TITLE].mkv'
chunk_duration = 10 # in minutes
def format_duration_m(minutes: int) -> str:
return f"{minutes // 60}:{minutes % 60}:00"
src_duration_s = float(shell(f'ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "{src_path}"').strip())
parts = list(range(0, int(src_duration_s // 60), chunk_duration))
logger.debug('Creating chunks', parts=parts, count=len(parts))
for index, part in enumerate(parts):
minutes_from = part
minutes_to = part + chunk_duration
out_path = f'{str(index+1).zfill(3)}{dst_suffix}'
shell(
f'ffmpeg'
f' -i "{src_path}"'
f' -ss {format_duration_m(minutes_from)} -to {format_duration_m(minutes_to)}'
f' -c:v copy'
f' -filter:a "pan=stereo|FL < 1.0*FL + 0.707*FC + 0.707*BL|FR < 1.0*FR + 0.707*FC + 0.707*BR,aresample=matrix_encoding=dplii,dynaudnorm=maxgain=40:framelen=400:gausssize=5:altboundary=1"' # Downmix to stereo, Dolby Pro Logic II, dynamic audio normalization
f' -ac 2 -c:a aac -b:a 192k'
f' "{out_path}"'
)
logger.info(f'Created: {out_path}')
Video to audiobook
ffmpeg -i "input.mkv" \
-ss 00:24:55 -to 00:31:00 \
-filter:a "pan=stereo|FL < 1.0*FL + 0.707*FC + 0.707*BL|FR < 1.0*FR + 0.707*FC + 0.707*BR,aresample=matrix_encoding=dplii,dynaudnorm=maxgain=40:framelen=400:gausssize=5:altboundary=1" \
-ac 2 -q:a 0 \
"output.mp3"
#!/usr/bin/env -S uv run --script
# /// script
# dependencies = [
# "nuclear>=2.8.1",
# "unidecode",
# ]
# ///
from pathlib import Path
from nuclear import nuke, logger
from unidecode import unidecode
class Config:
dry: bool = False
bluey_sources: list[str] = [
'/opt/dump/movies-series/Bluey/S01/S01E22 Bluey - Podaj Paczkę.mkv',
]
bluey_offset: int = 200
config: Config = nuke.load_config(Config)
sh = nuke.sh(raw_output=True, print_log=True, dry=config.dry)
def push():
sh << "rsync -avh --delete --size-only --info=progress2 '/opt/dump/alilo/custom/' '/media/igrek/USB DRIVE/CUSTOM/'"
sh << "rsync -avh --delete --size-only --info=progress2 '/opt/dump/alilo/storytales/' '/media/igrek/USB DRIVE/STORY/'"
def fatsort():
sh<<"sync"
sh<<"sudo fdisk -l"
partition = '/dev/sda1'
input(f"\nPresss enter to confirm writing to partition {partition}...")
try:
sh<<f"umount {partition}"
except CommandError:
pass
sh<<f"sudo fsck.vfat -r {partition}"
sh<<f"sudo fatsort -n {partition}"
def bluey():
sources: list[Path] = nuke.validate_sources(config.bluey_sources)
for i, f in enumerate(sources):
filename = unidecode(f.stem[7:])
target = f'storytales/{config.bluey_offset + i} {filename}.mp3'
if Path(target).exists():
logger.debug('target already exists - skipping', target=target)
continue
sh(
f'ffmpeg -i "{f.absolute()}"'
f' -map 0:a:1' # From input #0 select audio stream index #1 (second)
f' -ss 00:00:25'
f' -filter:a "pan=stereo|FL < 1.0*FL + 0.707*FC + 0.707*BL|FR < 1.0*FR + 0.707*FC + 0.707*BR,' # downmix 5.1 to stereo
f'aresample=matrix_encoding=dplii,' # Dolby Pro Logic II matrix
f'dynaudnorm=maxgain=40:framelen=400:gausssize=5:altboundary=1"' # Dynamic Audio Normalizer
f' -ac 2 -q:a 0' # output 2 channels, variable bitrate
f' "{target}"'
)
if __name__ == '__main__':
nuke.run()
FFmpeg CLI reference
-i "input.mkv"- input file-
-ss 00:10:00 -to 00:20:00- trim from 10:00 (10 minutes) to 20:00 -
-map a- select all audio streams from all inputs -map 0:a:1- from input #0 select audio stream index #1 (the second audio track)-c:v copy- copy video stream without re-encoding (fast, no quality loss)-
-c:a aac- encode audio to AAC -
-filter:a "pan=stereo\|FL < 1.0*FL + 0.707*FC + 0.707*BL\|FR < 1.0*FR + 0.707*FC + 0.707*BR"- audio filter: Downmix 5.1 surround to stereo with center/rear mixing -filter:a "aresample=matrix_encoding=dplii"- audio filter: Dolby Pro Logic II matrix encoding for surround-encoded stereo-filter:a "dynaudnorm=maxgain=40:framelen=400:gausssize=5:altboundary=1"- audio filter: Dynamic Audio Normalizer - evens out quiet and loud sections-
-filter:a -filter:a "pan=stereo|FL < 1.0*FL + 0.707*FC + 0.707*BL|FR < 1.0*FR + 0.707*FC + 0.707*BR,aresample=matrix_encoding=dplii,dynaudnorm=maxgain=40:framelen=400:gausssize=5:altboundary=1"- apply audio filter chain (filters separated by commas) -
-b:a 192k- audio bitrate (CBR) -q:a 0- audio quality (VBR), 0 = best, 9 = worst-ac 2- output 2 audio channels (stereo)