mediacms/files/helpers.py
Markos Gogoulos e8d3ff25be
Disable encoding and show only original file (#829)
Disable encoding and show only original file #829
2023-11-10 14:25:10 +02:00

796 lines
22 KiB
Python

# Kudos to Werner Robitza, AVEQ GmbH, for helping with ffmpeg
# related content
import hashlib
import json
import os
import random
import shutil
import subprocess
import tempfile
from fractions import Fraction
import filetype
from django.conf import settings
CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
CRF_ENCODING_NUM_SECONDS = 2 # 0 * 60 # videos with greater duration will get
# CRF encoding and not two-pass
# Encoding individual chunks may yield quality variations if you use a
# too low bitrate, so if you go for the chunk-based variant
# you should use CRF encoding.
MAX_RATE_MULTIPLIER = 1.5
MIN_RATE_MULTIPLIER = 0.5
BUF_SIZE_MULTIPLIER = 1.5
# in seconds, anything between 2 and 6 makes sense
KEYFRAME_DISTANCE = 4
KEYFRAME_DISTANCE_MIN = 2
# speed presets
# see https://trac.ffmpeg.org/wiki/Encode/H.264
X26x_PRESET = "medium" # "medium"
X265_PRESET = "medium"
X26x_PRESET_BIG_HEIGHT = "faster"
# VP9_SPEED = 1 # between 0 and 4, lower is slower
VP9_SPEED = 2
VIDEO_CRFS = {
"h264_baseline": 23,
"h264": 23,
"h265": 28,
"vp9": 32,
}
# video rates for 25 or 60 fps input, for different codecs, in kbps
VIDEO_BITRATES = {
"h264": {
25: {
240: 300,
360: 500,
480: 1000,
720: 2500,
1080: 4500,
1440: 9000,
2160: 18000,
},
60: {720: 3500, 1080: 7500, 1440: 18000, 2160: 40000},
},
"h265": {
25: {
240: 150,
360: 275,
480: 500,
720: 1024,
1080: 1800,
1440: 4500,
2160: 10000,
},
60: {720: 1800, 1080: 3000, 1440: 8000, 2160: 18000},
},
"vp9": {
25: {
240: 150,
360: 275,
480: 500,
720: 1024,
1080: 1800,
1440: 4500,
2160: 10000,
},
60: {720: 1800, 1080: 3000, 1440: 8000, 2160: 18000},
},
}
AUDIO_ENCODERS = {"h264": "aac", "h265": "aac", "vp9": "libopus"}
AUDIO_BITRATES = {"h264": 128, "h265": 128, "vp9": 96}
EXTENSIONS = {"h264": "mp4", "h265": "mp4", "vp9": "webm"}
VIDEO_PROFILES = {"h264": "main", "h265": "main"}
def get_portal_workflow():
return settings.PORTAL_WORKFLOW
def get_default_state(user=None):
# possible states given the portal workflow setting
state = "private"
if settings.PORTAL_WORKFLOW == "public":
state = "public"
if settings.PORTAL_WORKFLOW == "unlisted":
state = "unlisted"
if settings.PORTAL_WORKFLOW == "private_verified":
if user and user.advancedUser:
state = "unlisted"
return state
def get_file_name(filename):
return filename.split("/")[-1]
def get_file_type(filename):
if not os.path.exists(filename):
return None
file_type = None
kind = filetype.guess(filename)
if kind is not None:
if kind.mime.startswith("video"):
file_type = "video"
elif kind.mime.startswith("image"):
file_type = "image"
elif kind.mime.startswith("audio"):
file_type = "audio"
elif "pdf" in kind.mime:
file_type = "pdf"
else:
# TODO: do something for files not supported by filetype lib
pass
return file_type
def rm_file(filename):
if os.path.isfile(filename):
try:
os.remove(filename)
return True
except OSError:
pass
return False
def rm_files(filenames):
if isinstance(filenames, list):
for filename in filenames:
rm_file(filename)
return True
def rm_dir(directory):
if os.path.isdir(directory):
# refuse to delete a dir inside project BASE_DIR
if directory.startswith(settings.BASE_DIR):
try:
shutil.rmtree(directory)
return True
except (FileNotFoundError, PermissionError):
pass
return False
def url_from_path(filename):
# TODO: find a way to preserver http - https ...
return "{0}{1}".format(settings.MEDIA_URL, filename.replace(settings.MEDIA_ROOT, ""))
def create_temp_file(suffix=None, dir=settings.TEMP_DIRECTORY):
tf = tempfile.NamedTemporaryFile(delete=False, suffix=suffix, dir=dir)
return tf.name
def create_temp_dir(suffix=None, dir=settings.TEMP_DIRECTORY):
td = tempfile.mkdtemp(dir=dir)
return td
def produce_friendly_token(token_len=settings.FRIENDLY_TOKEN_LEN):
token = ""
while len(token) != token_len:
token += CHARS[random.randint(0, len(CHARS) - 1)]
return token
def clean_friendly_token(token):
# cleans token
for char in token:
if char not in CHARS:
token.replace(char, "")
return token
def mask_ip(ip_address):
return hashlib.md5(ip_address.encode("utf-8")).hexdigest()
def run_command(cmd, cwd=None):
"""
Run a command directly
"""
if isinstance(cmd, str):
cmd = cmd.split()
ret = {}
if cwd:
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=cwd)
else:
process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
stdout, stderr = process.communicate()
# TODO: catch unicodedecodeerrors here...
if process.returncode == 0:
try:
ret["out"] = stdout.decode("utf-8")
except BaseException:
ret["out"] = ""
try:
ret["error"] = stderr.decode("utf-8")
except BaseException:
ret["error"] = ""
else:
try:
ret["error"] = stderr.decode("utf-8")
except BaseException:
ret["error"] = ""
return ret
def media_file_info(input_file):
"""
Get the info about an input file, as determined by ffprobe
Returns a dict, with the keys:
- `filename`: Filename
- `file_size`: Size of the file in bytes
- `video_duration`: Duration of the video in `s.msec`
- `video_frame_rate_d`: Framerate franction denominator
- `video_frame_rate_n`: Framerate fraction nominator
- `video_bitrate`: Bitrate of the video stream in kBit/s
- `video_width`: Width in pixels
- `video_height`: Height in pixels
- `interlaced` : True if the video is interlaced
- `video_codec`: Video codec
- `audio_duration`: Duration of the audio in `s.msec`
- `audio_sample_rate`: Audio sample rate in Hz
- `audio_codec`: Audio codec name (`aac`)
- `audio_bitrate`: Bitrate of the video stream in kBit/s
Also returns the video and audio info raw from ffprobe.
"""
ret = {}
if not os.path.isfile(input_file):
ret["fail"] = True
return ret
video_info = {}
audio_info = {}
cmd = ["stat", "-c", "%s", input_file]
stdout = run_command(cmd).get("out")
if stdout:
file_size = int(stdout.strip())
else:
ret["fail"] = True
return ret
cmd = ["md5sum", input_file]
stdout = run_command(cmd).get("out")
if stdout:
md5sum = stdout.split()[0]
else:
md5sum = ""
cmd = [
settings.FFPROBE_COMMAND,
"-loglevel",
"error",
"-show_streams",
"-show_entries",
"format=format_name",
"-of",
"json",
input_file,
]
stdout = run_command(cmd).get("out")
try:
info = json.loads(stdout)
except TypeError:
ret["fail"] = True
return ret
has_video = False
has_audio = False
for stream_info in info["streams"]:
if stream_info["codec_type"] == "video":
video_info = stream_info
has_video = True
if info.get("format") and info["format"].get("format_name", "") in [
"tty",
"image2",
"image2pipe",
"bin",
"png_pipe",
"gif",
]:
ret["fail"] = True
return ret
elif stream_info["codec_type"] == "audio":
audio_info = stream_info
has_audio = True
if not has_video:
ret["is_video"] = False
ret["is_audio"] = has_audio
ret["audio_info"] = audio_info
return ret
if "duration" in video_info.keys():
video_duration = float(video_info["duration"])
elif "tags" in video_info.keys() and "DURATION" in video_info["tags"]:
duration_str = video_info["tags"]["DURATION"]
try:
hms, msec = duration_str.split(".")
except ValueError:
hms, msec = duration_str.split(",")
total_dur = sum(int(x) * 60**i for i, x in enumerate(reversed(hms.split(":"))))
video_duration = total_dur + float("0." + msec)
else:
# fallback to format, eg for webm
cmd = [
settings.FFPROBE_COMMAND,
"-loglevel",
"error",
"-show_format",
"-of",
"json",
input_file,
]
stdout = run_command(cmd).get("out")
format_info = json.loads(stdout)["format"]
try:
video_duration = float(format_info["duration"])
except KeyError:
ret["fail"] = True
return ret
if "bit_rate" in video_info.keys():
video_bitrate = round(float(video_info["bit_rate"]) / 1024.0, 2)
else:
cmd = [
settings.FFPROBE_COMMAND,
"-loglevel",
"error",
"-select_streams",
"v",
"-show_entries",
"packet=size",
"-of",
"compact=p=0:nk=1",
input_file,
]
stdout = run_command(cmd).get("out")
stream_size = sum([int(line) for line in stdout.split("\n") if line != ""])
video_bitrate = round((stream_size * 8 / 1024.0) / video_duration, 2)
if "r_frame_rate" in video_info.keys():
video_frame_rate = video_info["r_frame_rate"].partition("/")
video_frame_rate_n = video_frame_rate[0]
video_frame_rate_d = video_frame_rate[2]
interlaced = False
if video_info.get("field_order") in ("tt", "tb", "bt", "bb"):
interlaced = True
ret = {
"filename": input_file,
"file_size": file_size,
"video_duration": video_duration,
"video_frame_rate_n": video_frame_rate_n,
"video_frame_rate_d": video_frame_rate_d,
"video_bitrate": video_bitrate,
"video_width": video_info["width"],
"video_height": video_info["height"],
"video_codec": video_info["codec_name"],
"has_video": has_video,
"has_audio": has_audio,
"color_range": video_info.get("color_range"),
"color_space": video_info.get("color_space"),
"color_transfer": video_info.get("color_space"),
"color_primaries": video_info.get("color_primaries"),
"interlaced": interlaced,
"display_aspect_ratio": video_info.get("display_aspect_ratio"),
"sample_aspect_ratio": video_info.get("sample_aspect_ratio"),
}
if has_audio:
if "duration" in audio_info.keys():
audio_duration = float(audio_info["duration"])
elif "tags" in audio_info.keys() and "DURATION" in audio_info["tags"]:
duration_str = audio_info["tags"]["DURATION"]
try:
hms, msec = duration_str.split(".")
except ValueError:
hms, msec = duration_str.split(",")
total_dur = sum(int(x) * 60**i for i, x in enumerate(reversed(hms.split(":"))))
audio_duration = total_dur + float("0." + msec)
else:
# fallback to format, eg for webm
cmd = [
settings.FFPROBE_COMMAND,
"-loglevel",
"error",
"-show_format",
"-of",
"json",
input_file,
]
stdout = run_command(cmd).get("out")
format_info = json.loads(stdout)["format"]
audio_duration = float(format_info["duration"])
if "bit_rate" in audio_info.keys():
audio_bitrate = round(float(audio_info["bit_rate"]) / 1024.0, 2)
else:
# fall back to calculating from accumulated frame duration
cmd = [
settings.FFPROBE_COMMAND,
"-loglevel",
"error",
"-select_streams",
"a",
"-show_entries",
"packet=size",
"-of",
"compact=p=0:nk=1",
input_file,
]
stdout = run_command(cmd).get("out")
# ffprobe appends a pipe at the end of the output, thus we have to remove it
stream_size = sum([int(line.replace("|", "")) for line in stdout.split("\n") if line != ""])
audio_bitrate = round((stream_size * 8 / 1024.0) / audio_duration, 2)
ret.update(
{
"audio_duration": audio_duration,
"audio_sample_rate": audio_info["sample_rate"],
"audio_codec": audio_info["codec_name"],
"audio_bitrate": audio_bitrate,
"audio_channels": audio_info["channels"],
}
)
ret["video_info"] = video_info
ret["audio_info"] = audio_info
ret["is_video"] = True
ret["md5sum"] = md5sum
return ret
def calculate_seconds(duration):
# returns seconds, given a ffmpeg extracted string
ret = 0
if isinstance(duration, str):
duration = duration.split(":")
if len(duration) != 3:
return ret
else:
return ret
ret += int(float(duration[2]))
ret += int(float(duration[1])) * 60
ret += int(float(duration[0])) * 60 * 60
return ret
def show_file_size(size):
if size:
size = size / 1000000
size = round(size, 1)
size = "{0}MB".format(str(size))
return size
def get_base_ffmpeg_command(
input_file,
output_file,
has_audio,
codec,
encoder,
audio_encoder,
target_fps,
interlaced,
target_height,
target_rate,
target_rate_audio,
pass_file,
pass_number,
enc_type,
chunk,
):
"""Get the base command for a specific codec, height/rate, and pass
Arguments:
input_file {str} -- input file name
output_file {str} -- output file name
has_audio {bool} -- does the input have audio?
codec {str} -- video codec
encoder {str} -- video encoder
audio_encoder {str} -- audio encoder
target_fps {fractions.Fraction} -- target FPS
interlaced {bool} -- true if interlaced
target_height {int} -- height
target_rate {int} -- target bitrate in kbps
target_rate_audio {int} -- audio target bitrate
pass_file {str} -- path to temp pass file
pass_number {int} -- number of passes
enc_type {str} -- encoding type (twopass or crf)
"""
# avoid very high frame rates
while target_fps > 60:
target_fps = target_fps / 2
if target_fps < 1:
target_fps = 1
filters = []
if interlaced:
filters.append("yadif")
target_width = round(target_height * 16 / 9)
scale_filter_opts = [
f"if(lt(iw\\,ih)\\,{target_height}\\,{target_width})", # noqa
f"if(lt(iw\\,ih)\\,{target_width}\\,{target_height})", # noqa
"force_original_aspect_ratio=decrease",
"force_divisible_by=2",
"flags=lanczos",
]
scale_filter_str = "scale=" + ":".join(scale_filter_opts)
filters.append(scale_filter_str)
fps_str = f"fps=fps={target_fps}"
filters.append(fps_str)
filters_str = ",".join(filters)
base_cmd = [
settings.FFMPEG_COMMAND,
"-y",
"-i",
input_file,
"-c:v",
encoder,
"-filter:v",
filters_str,
"-pix_fmt",
"yuv420p",
]
if enc_type == "twopass":
base_cmd.extend(["-b:v", str(target_rate) + "k"])
elif enc_type == "crf":
base_cmd.extend(["-crf", str(VIDEO_CRFS[codec])])
if encoder == "libvpx-vp9":
base_cmd.extend(["-b:v", str(target_rate) + "k"])
if has_audio:
base_cmd.extend(
[
"-c:a",
audio_encoder,
"-b:a",
str(target_rate_audio) + "k",
# stereo audio only, see https://trac.ffmpeg.org/ticket/5718
"-ac",
"2",
]
)
# get keyframe distance in frames
keyframe_distance = int(target_fps * KEYFRAME_DISTANCE)
# start building the command
cmd = base_cmd[:]
# preset settings
if encoder == "libvpx-vp9":
if pass_number == 1:
speed = 4
else:
speed = VP9_SPEED
elif encoder in ["libx264"]:
preset = X26x_PRESET
elif encoder in ["libx265"]:
preset = X265_PRESET
if target_height >= 720:
preset = X26x_PRESET_BIG_HEIGHT
if encoder == "libx264":
level = "4.2" if target_height <= 1080 else "5.2"
x264_params = [
"keyint=" + str(keyframe_distance * 2),
"keyint_min=" + str(keyframe_distance),
]
cmd.extend(
[
"-maxrate",
str(int(int(target_rate) * MAX_RATE_MULTIPLIER)) + "k",
"-bufsize",
str(int(int(target_rate) * BUF_SIZE_MULTIPLIER)) + "k",
"-force_key_frames",
"expr:gte(t,n_forced*" + str(KEYFRAME_DISTANCE) + ")",
"-x264-params",
":".join(x264_params),
"-preset",
preset,
"-profile:v",
VIDEO_PROFILES[codec],
"-level",
level,
]
)
if enc_type == "twopass":
cmd.extend(["-passlogfile", pass_file, "-pass", pass_number])
elif encoder == "libx265":
x265_params = [
"vbv-maxrate=" + str(int(int(target_rate) * MAX_RATE_MULTIPLIER)),
"vbv-bufsize=" + str(int(int(target_rate) * BUF_SIZE_MULTIPLIER)),
"keyint=" + str(keyframe_distance * 2),
"keyint_min=" + str(keyframe_distance),
]
if enc_type == "twopass":
x265_params.extend(["stats=" + str(pass_file), "pass=" + str(pass_number)])
cmd.extend(
[
"-force_key_frames",
"expr:gte(t,n_forced*" + str(KEYFRAME_DISTANCE) + ")",
"-x265-params",
":".join(x265_params),
"-preset",
preset,
"-profile:v",
VIDEO_PROFILES[codec],
]
)
elif encoder == "libvpx-vp9":
cmd.extend(
[
"-g",
str(keyframe_distance),
"-keyint_min",
str(keyframe_distance),
"-maxrate",
str(int(int(target_rate) * MAX_RATE_MULTIPLIER)) + "k",
"-minrate",
str(int(int(target_rate) * MIN_RATE_MULTIPLIER)) + "k",
"-bufsize",
str(int(int(target_rate) * BUF_SIZE_MULTIPLIER)) + "k",
"-speed",
speed,
# '-deadline', 'realtime',
]
)
if enc_type == "twopass":
cmd.extend(["-passlogfile", pass_file, "-pass", pass_number])
cmd.extend(
[
"-strict",
"-2",
]
)
# end of the command
if pass_number == 1:
cmd.extend(["-an", "-f", "null", "/dev/null"])
elif pass_number == 2:
if output_file.endswith("mp4") and chunk:
cmd.extend(["-movflags", "+faststart"])
cmd.extend([output_file])
return cmd
def produce_ffmpeg_commands(media_file, media_info, resolution, codec, output_filename, pass_file, chunk=False):
try:
media_info = json.loads(media_info)
except BaseException:
media_info = {}
if codec == "h264":
encoder = "libx264"
# ext = "mp4"
elif codec in ["h265", "hevc"]:
encoder = "libx265"
# ext = "mp4"
elif codec == "vp9":
encoder = "libvpx-vp9"
# ext = "webm"
else:
return False
target_fps = Fraction(int(media_info.get("video_frame_rate_n", 30)), int(media_info.get("video_frame_rate_d", 1)))
if target_fps <= 30:
target_rate = VIDEO_BITRATES[codec][25].get(resolution)
else:
target_rate = VIDEO_BITRATES[codec][60].get(resolution)
if not target_rate: # INVESTIGATE MORE!
target_rate = VIDEO_BITRATES[codec][25].get(resolution)
if not target_rate:
return False
if media_info.get("video_height") < resolution:
if resolution not in [240, 360]: # always get these two
return False
# if codec == "h264_baseline":
# target_fps = 25
# else:
if media_info.get("video_duration") > CRF_ENCODING_NUM_SECONDS:
enc_type = "crf"
else:
enc_type = "twopass"
if enc_type == "twopass":
passes = [1, 2]
elif enc_type == "crf":
passes = [2]
interlaced = media_info.get("interlaced")
cmds = []
for pass_number in passes:
cmds.append(
get_base_ffmpeg_command(
media_file,
output_file=output_filename,
has_audio=media_info.get("has_audio"),
codec=codec,
encoder=encoder,
audio_encoder=AUDIO_ENCODERS[codec],
target_fps=target_fps,
interlaced=interlaced,
target_height=resolution,
target_rate=target_rate,
target_rate_audio=AUDIO_BITRATES[codec],
pass_file=pass_file,
pass_number=pass_number,
enc_type=enc_type,
chunk=chunk,
)
)
return cmds
def clean_query(query):
"""This is used to clear text in order to comply with SearchQuery
known exception cases
:param query: str - the query text that we want to clean
:return:
"""
if not query:
return ""
chars = ["^", "{", "}", "&", "|", "<", ">", '"', ")", "(", "!", ":", ";", "'", "#"]
for char in chars:
query = query.replace(char, "")
return query.lower()
def get_alphanumeric_only(string):
"""Returns a query that contains only alphanumeric characters
This include characters other than the English alphabet too
"""
string = "".join([char for char in string if char.isalnum()])
return string.lower()