Skip to content

Commit

Permalink
Version 3.2.0 (#86)
Browse files Browse the repository at this point in the history
* Adding #75 burn-in subtitle support (thanks to Trevbams)
* Adding #81 auto crop feature (thanks to HannesJo0139)
* Adding #84 pause / resume functionality (thanks to loungebob)
* Adding hover info for Audio and Subtitle tracks
* Adding confirm overwrite dialog if file already exists and is not empty
* Adding linking to issues in changelog file
* Changing to explicitly set no-slow-firstpass for x265 bitrate runs
* Changing FFmpeg to download latest available
* Fixing AVC always copied chapters
* Fixing how aspect ratio interacted with crop
* Fixing HEVC would copy HDR10 details on 8-bit videos
  • Loading branch information
cdgriffith authored Oct 23, 2020
1 parent cffd852 commit e652a22
Show file tree
Hide file tree
Showing 24 changed files with 439 additions and 130 deletions.
14 changes: 14 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# Changelog

## Version 3.2.0

* Adding #75 burn-in subtitle support (thanks to Trevbams)
* Adding #81 auto crop feature (thanks to HannesJo0139)
* Adding #84 pause / resume functionality (thanks to loungebob)
* Adding hover info for Audio and Subtitle tracks
* Adding confirm overwrite dialog if file already exists and is not empty
* Adding linking to issues in changelog file
* Changing to explicitly set no-slow-firstpass for x265 bitrate runs
* Changing FFmpeg to download latest available
* Fixing AVC always copied chapters
* Fixing how aspect ratio interacted with crop
* Fixing HEVC would copy HDR10 details on 8-bit videos

## Version 3.1.0

* Adding support for movie title
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ FastFlix keeps HDR10 metadata for x265, which will be expanded to AV1 libraries

It needs `FFmpeg` (version 4.3 or greater) under the hood for the heavy lifting, and can work with a variety of encoders.

Check out [the FastFlix github wiki](https://github.com/cdgriffith/FastFlix/wiki) for help or more details!

# Encoders

FastFlix supports the following encoders when their required libraries are found in FFmpeg:
Expand Down
6 changes: 3 additions & 3 deletions fastflix/encoders/av1_aom/command_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ def build(
usage="good",
**kwargs,
):
filters = generate_filters(**kwargs)
audio = build_audio(audio_tracks)
subtitles = build_subtitle(subtitle_tracks)
subtitles, burn_in_track = build_subtitle(subtitle_tracks)
filters = generate_filters(video_track=video_track, disable_hdr=disable_hdr, burn_in_track=burn_in_track, **kwargs)
ending = generate_ending(audio=audio, subtitles=subtitles, cover=attachments, output_video=output_video, **kwargs)
beginning = generate_ffmpeg_start(
source=source,
Expand All @@ -54,7 +54,7 @@ def build(
if row_mt is not None:
beginning += f"-row-mt {row_mt} "

if not disable_hdr and pix_fmt == "yuv420p10le":
if not disable_hdr and pix_fmt in ("yuv420p10le", "yuv420p12le"):

if side_data and side_data.get("color_primaries") == "bt2020":
beginning += "-color_primaries bt2020 -color_trc smpte2084 -colorspace bt2020nc"
Expand Down
13 changes: 5 additions & 8 deletions fastflix/encoders/avc_x264/command_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,9 @@ def build(
attachments="",
**kwargs,
):
filters = generate_filters(disable_hdr=disable_hdr, **kwargs)
audio = build_audio(audio_tracks)
subtitles = build_subtitle(subtitle_tracks)
subtitles, burn_in_track = build_subtitle(subtitle_tracks)
filters = generate_filters(video_track=video_track, disable_hdr=disable_hdr, burn_in_track=burn_in_track, **kwargs)
ending = generate_ending(audio=audio, subtitles=subtitles, cover=attachments, output_video=output_video, **kwargs)

if not side_data:
Expand All @@ -52,25 +52,22 @@ def build(
if profile and profile != "default":
beginning += f"-profile {profile} "

if not disable_hdr and pix_fmt == "yuv420p10le":
if not disable_hdr and pix_fmt in ("yuv420p10le", "yuv420p12le"):

if side_data and side_data.get("color_primaries") == "bt2020":
beginning += "-color_primaries bt2020 -color_trc smpte2084 -colorspace bt2020nc"

if side_data.cll:
pass

extra_data = "-map_chapters 0 " # -map_metadata 0 # safe to do for rotation?
pass_log_file = Path(temp_dir) / f"pass_log_file_{secrets.token_hex(10)}.log"

if bitrate:
command_1 = (
f"{beginning} -pass 1 "
f'-passlogfile "{pass_log_file}" -b:v {bitrate} -preset {preset} -an -sn -dn -f mp4 {null}'
)
command_2 = (
f'{beginning} -pass 2 -passlogfile "{pass_log_file}" ' f"-b:v {bitrate} -preset {preset} {extra_data}"
) + ending
command_2 = (f'{beginning} -pass 2 -passlogfile "{pass_log_file}" ' f"-b:v {bitrate} -preset {preset}") + ending
return [
Command(
re.sub("[ ]+", " ", command_1), ["ffmpeg", "output"], False, name="First pass bitrate", exe="ffmpeg"
Expand All @@ -81,7 +78,7 @@ def build(
]

elif crf:
command = (f"{beginning} -crf {crf} " f"-preset {preset} {extra_data}") + ending
command = (f"{beginning} -crf {crf} " f"-preset {preset} ") + ending
return [
Command(re.sub("[ ]+", " ", command), ["ffmpeg", "output"], False, name="Single pass CRF", exe="ffmpeg")
]
Expand Down
52 changes: 38 additions & 14 deletions fastflix/encoders/common/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ def generate_ffmpeg_start(
max_mux="default",
fast_time=True,
video_title="",
custom_map=False,
**_,
):
time_settings = f'{f"-ss {start_time}" if start_time else ""} {f"-to {end_time}" if end_time else ""} '
Expand All @@ -57,10 +58,10 @@ def generate_ffmpeg_start(
f" {time_two} "
f"{title} "
f"{f'-max_muxing_queue_size {max_mux}' if max_mux != 'default' else ''} "
f"-map 0:{video_track} "
f"-c:v:0 {encoder} "
f'{f"-map 0:{video_track}" if not filters else ""} '
f'{filters if filters else ""} '
f"-c:v {encoder} "
f"-pix_fmt {pix_fmt} "
f'{f"-vf {filters}" if filters else ""} '
)


Expand Down Expand Up @@ -88,16 +89,21 @@ def generate_ending(
return ending


def generate_filters(**kwargs):
crop = kwargs.get("crop")
scale = kwargs.get("scale")
scale_filter = kwargs.get("scale_filter", "lanczos")
scale_width = kwargs.get("scale_width")
scale_height = kwargs.get("scale_height")
disable_hdr = kwargs.get("disable_hdr")
rotate = kwargs.get("rotate")
vflip = kwargs.get("v_flip")
hflip = kwargs.get("h_flip")
def generate_filters(
video_track,
crop=None,
scale=None,
scale_filter="lanczos",
scale_width=None,
scale_height=None,
disable_hdr=False,
rotate=None,
vflip=None,
hflip=None,
burn_in_track=None,
custom_filters=None,
**_,
):

filter_list = []
if crop:
Expand All @@ -124,4 +130,22 @@ def generate_filters(**kwargs):
"zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
)

return ",".join(filter_list)
filters = ",".join(filter_list)
if filters and custom_filters:
filters = f"{filters},{custom_filters}"
elif not filters and custom_filters:
filters = custom_filters

if burn_in_track is not None:
if filters:
# You have to overlay first for it to work when scaled
return f' -filter_complex "[0:{video_track}][0:{burn_in_track}]overlay[subbed];[subbed]{filters}[v]" -map "[v]" '
else:
return f' -filter_complex "[0:{video_track}][0:{burn_in_track}]overlay[v]" -map "[v]" '
elif filters:
return f' -filter_complex "[0:{video_track}]{filters}[v]" -map "[v]" '
return None

# # TODO also support disable HDR and burn in

# return ",".join(filter_list)
13 changes: 9 additions & 4 deletions fastflix/encoders/common/subtitles.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@

def build_subtitle(subtitle_tracks, subtitle_file_index=0):
command_list = []
burn_in_track = None
for track in subtitle_tracks:
command_list.append(f"-map {subtitle_file_index}:{track.index} -c:{track.outdex} copy ")
command_list.append(f"-disposition:s:{track.outdex} {track.disposition}")
command_list.append(f"-metadata:s:{track.outdex} language={track.language}")
if track.burn_in:
burn_in_track = track.index
else:
outdex = track.outdex - (1 if burn_in_track else 0)
command_list.append(f"-map {subtitle_file_index}:{track.index} -c:{outdex} copy ")
command_list.append(f"-disposition:s:{outdex} {track.disposition}")
command_list.append(f"-metadata:s:{outdex} language={track.language}")

return " ".join(command_list)
return " ".join(command_list), burn_in_track
2 changes: 1 addition & 1 deletion fastflix/encoders/gif/command_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def build(
end_time=None,
**kwargs,
):
filters = generate_filters(**kwargs)
filters = generate_filters(video_track=video_track, **kwargs)

beginning = (
f'"{ffmpeg}" -y '
Expand Down
9 changes: 5 additions & 4 deletions fastflix/encoders/hevc_x265/command_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ def build(
attachments="",
**kwargs,
):
filters = generate_filters(disable_hdr=disable_hdr, **kwargs)

audio = build_audio(audio_tracks)
subtitles = build_subtitle(subtitle_tracks)
subtitles, burn_in_track = build_subtitle(subtitle_tracks)
filters = generate_filters(video_track=video_track, disable_hdr=disable_hdr, burn_in_track=burn_in_track, **kwargs)
ending = generate_ending(audio=audio, subtitles=subtitles, cover=attachments, output_video=output_video, **kwargs)

if not side_data:
Expand All @@ -57,7 +58,7 @@ def build(
if not x265_params:
x265_params = []

if not disable_hdr:
if not disable_hdr and pix_fmt in ("yuv420p10le", "yuv420p12le"):
if side_data and side_data.get("color_primaries") == "bt2020":
x265_params.extend(
["hdr-opt=1", "repeat-headers=1", "colorprim=bt2020", "transfer=smpte2084", "colormatrix=bt2020nc"]
Expand Down Expand Up @@ -92,7 +93,7 @@ def get_x265_params(params=()):

if bitrate:
command_1 = (
f'{beginning} {get_x265_params(["pass=1"])} '
f'{beginning} {get_x265_params(["pass=1", "no-slow-firstpass=1"])} '
f'-passlogfile "{pass_log_file}" -b:v {bitrate} -preset {preset} -an -sn -dn -f mp4 {null}'
)
command_2 = (
Expand Down
6 changes: 3 additions & 3 deletions fastflix/encoders/rav1e/command_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ def build(
attachments="",
**kwargs,
):
filters = generate_filters(disable_hdr=disable_hdr, **kwargs)
audio = build_audio(audio_tracks)
subtitles = build_subtitle(subtitle_tracks)
subtitles, burn_in_track = build_subtitle(subtitle_tracks)
filters = generate_filters(video_track=video_track, disable_hdr=disable_hdr, burn_in_track=burn_in_track, **kwargs)
ending = generate_ending(audio=audio, subtitles=subtitles, cover=attachments, output_video=output_video, **kwargs)

beginning = generate_ffmpeg_start(
Expand All @@ -71,7 +71,7 @@ def build(
pass_log_file = Path(temp_dir) / f"pass_log_file_{secrets.token_hex(10)}.log"
beginning += f'-passlogfile "{pass_log_file}" '

if not disable_hdr and pix_fmt == "yuv420p10le":
if not disable_hdr and pix_fmt in ("yuv420p10le", "yuv420p12le"):

if side_data and side_data.get("color_primaries") == "bt2020":
beginning += "-color_primaries bt2020 -color_trc smpte2084 -colorspace bt2020nc"
Expand Down
6 changes: 3 additions & 3 deletions fastflix/encoders/svt_av1/command_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ def build(
attachments="",
**kwargs,
):
filters = generate_filters(disable_hdr=disable_hdr, **kwargs)
audio = build_audio(audio_tracks)
subtitles = build_subtitle(subtitle_tracks)
subtitles, burn_in_track = build_subtitle(subtitle_tracks)
filters = generate_filters(video_track=video_track, disable_hdr=disable_hdr, burn_in_track=burn_in_track, **kwargs)
ending = generate_ending(audio=audio, subtitles=subtitles, cover=attachments, output_video=output_video, **kwargs)

beginning = generate_ffmpeg_start(
Expand All @@ -73,7 +73,7 @@ def build(
pass_log_file = Path(temp_dir) / f"pass_log_file_{secrets.token_hex(10)}.log"
beginning += f'-passlogfile "{pass_log_file}" '

if not disable_hdr and pix_fmt == "yuv420p10le":
if not disable_hdr and pix_fmt in ("yuv420p10le", "yuv420p12le"):

if side_data and side_data.get("color_primaries") == "bt2020":
beginning += "-color_primaries bt2020 -color_trc smpte2084 -colorspace bt2020nc"
Expand Down
6 changes: 3 additions & 3 deletions fastflix/encoders/vp9/command_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ def build(
side_data=None,
**kwargs,
):
filters = generate_filters(disable_hdr=disable_hdr, **kwargs)
audio = build_audio(audio_tracks)
subtitles = build_subtitle(subtitle_tracks)
subtitles, burn_in_track = build_subtitle(subtitle_tracks)
filters = generate_filters(video_track=video_track, disable_hdr=disable_hdr, burn_in_track=burn_in_track, **kwargs)
ending = generate_ending(audio=audio, subtitles=subtitles, cover=attachments, output_video=output_video, **kwargs)
beginning = generate_ffmpeg_start(
source=source,
Expand All @@ -48,7 +48,7 @@ def build(
pass_log_file = Path(temp_dir) / f"pass_log_file_{secrets.token_hex(10)}.log"
beginning += f'-passlogfile "{pass_log_file}" '

if not disable_hdr and pix_fmt == "yuv420p10le":
if not disable_hdr and pix_fmt in ("yuv420p10le", "yuv420p12le"):

if side_data and side_data.get("color_primaries") == "bt2020":
beginning += "-color_primaries bt2020 -color_trc smpte2084 -colorspace bt2020nc"
Expand Down
44 changes: 34 additions & 10 deletions fastflix/flix.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# -*- coding: utf-8 -*-
import logging
import os
from multiprocessing.pool import ThreadPool
from subprocess import PIPE, STDOUT, Popen, run
from multiprocessing.pool import Pool
from subprocess import PIPE, STDOUT, run
import shlex

from box import Box, BoxError

Expand Down Expand Up @@ -79,7 +80,6 @@ def guess_bit_depth(pix_fmt, color_primaries):

class Flix:
def __init__(self, ffmpeg="ffmpeg", ffprobe="ffprobe"):
self.tp = ThreadPool(processes=4)
self.update(ffmpeg, ffprobe)

def update(self, ffmpeg, ffprobe):
Expand Down Expand Up @@ -125,7 +125,9 @@ def ffmpeg_configuration(self):

def extract_attachment(self, args):
file, stream, work_dir, file_name = args
self.execute(f'{self.ffmpeg} -y -i "{file}" -map 0:{stream} -c copy "{file_name}"', work_dir=work_dir)
self.execute(
f'"{self.ffmpeg}" -y -i "{file}" -map 0:{stream} -c copy "{file_name}"', work_dir=work_dir, timeout=5
)

def parse(self, file, work_dir=None, extract_covers=False):
data = self.probe(file)
Expand All @@ -146,7 +148,8 @@ def parse(self, file, work_dir=None, extract_covers=False):
logger.error(f"Unknown codec: {track.codec_type}")

if extract_covers:
self.tp.map(self.extract_attachment, covers)
with Pool(processes=4) as pool:
pool.map(self.extract_attachment, covers)

for stream in streams.video:
if "bits_per_raw_sample" in stream:
Expand Down Expand Up @@ -177,21 +180,42 @@ def generate_filters(

return ",".join(filter_list)

def generate_thumbnail_command(self, source, output, video_track, start_time=0, filters=None):
def generate_thumbnail_command(self, source, output, filters, start_time=0):
start = ""
if start_time:
start = f"-ss {start_time}"
return (
f'"{self.ffmpeg}" {start} -loglevel error -i "{source}" '
f' -vf {filters + "," if filters else ""}scale="min(320\\,iw):-1" '
f"-map 0:{video_track} -an -y -map_metadata -1 "
f" {filters} -an -y -map_metadata -1 "
f'-vframes 1 "{output}"'
)

def get_auto_crop(self, source, video_width, video_height, start_time):
command = f'"{self.ffmpeg}" -ss {start_time} -hide_banner -i "{source}" -vf cropdetect -vframes 10 -f null - '
output = self.execute(command)

width, height, x_crop, y_crop = None, None, None, None
for line in output.stderr.decode("utf-8").splitlines():
if line.startswith("[Parsed_cropdetect"):
w, h, x, y = [int(x) for x in line.rsplit("=")[1].split(":")]
if not width or (width and w < width):
width = w
if not height or (height and h < height):
height = h
if not x_crop or (x_crop and x > x_crop):
x_crop = x
if not y_crop or (y_crop and y > y_crop):
y_crop = y

if None in (width, height, x_crop, y_crop):
return 0, 0, 0, 0

return video_width - width - x_crop, video_height - height - y_crop, x_crop, y_crop

@staticmethod
def execute(command, work_dir=None):
def execute(command, work_dir=None, timeout=None):
logger.debug(f"running command: {command}")
return run(command, stdout=PIPE, stderr=PIPE, stdin=PIPE, shell=True, cwd=work_dir)
return run(shlex.split(command), stdout=PIPE, stderr=PIPE, stdin=PIPE, cwd=work_dir, timeout=timeout)

def get_audio_encoders(self):
cmd = run(
Expand Down
Loading

0 comments on commit e652a22

Please sign in to comment.