You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
243 lines
8.5 KiB
243 lines
8.5 KiB
import json
|
|
import re
|
|
from typing import Any, Dict, Iterable, List, NamedTuple, Optional
|
|
from config_local import Config
|
|
from subprocess import Popen, PIPE
|
|
from pathlib import Path
|
|
from io import BytesIO, TextIOWrapper
|
|
from pytimeparse.timeparse import timeparse
|
|
from datetime import timedelta
|
|
from fractions import Fraction
|
|
|
|
|
|
class Translation(NamedTuple):
|
|
start: int
|
|
end: int
|
|
multiplier: float
|
|
min_br: int
|
|
max_br: int
|
|
|
|
|
|
class Resolution:
|
|
def __init__(self, width: int, height: int):
|
|
self.width = width
|
|
self.height = height
|
|
self.ratio = self.width / self.height
|
|
|
|
def __repr__(self) -> str:
|
|
return f"Resolution(width={self.width},height={self.height})"
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.width}x{self.height}"
|
|
|
|
def __eq__(self, o: object) -> bool:
|
|
return self.width == o.width and self.height == o.height
|
|
|
|
|
|
class Ratio:
|
|
def __init__(self, x: int, y: int):
|
|
self.x = x
|
|
self.y = y
|
|
self.ratio = x / y
|
|
|
|
def __repr__(self) -> str:
|
|
return f"Ratio(x={self.x},y={self.y})"
|
|
|
|
def __str__(self) -> str:
|
|
return f"{self.x}x{self.y}"
|
|
|
|
def __eq__(self, o: object) -> bool:
|
|
return self.ratio == o.ratio
|
|
|
|
class AudioInfo:
|
|
def __init__(self, audio_dict: Dict[str, str]):
|
|
self.data = audio_dict
|
|
|
|
@property
|
|
def language(self) -> str:
|
|
return self.data.get("Language", "Unknown")
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return self.data.get("Title", "Unknown")
|
|
|
|
|
|
class MenuItem:
|
|
TIME_RE = re.compile(r"(\d{2})[^\d]+(\d{2})[^\d]+(\d{2})(?:[^\d]+(\d+))")
|
|
def __init__(self, *, start: str, duration: str, name: str):
|
|
self.start = start
|
|
self.duration = duration
|
|
self.name = name
|
|
|
|
@property
|
|
def str_start(self) -> timedelta:
|
|
match = self.TIME_RE.search(self.start)
|
|
hours, minutes, seconds, millis = tuple(map(int, (match.group(i + 1) for i in range(4))))
|
|
return timedelta(hours=hours, minutes=minutes, seconds=seconds, milliseconds=millis)
|
|
|
|
def __str__(self) -> str:
|
|
return f"'{self.name}' at {self.str_start}, duration {self.duration}"
|
|
|
|
def __repr__(self) -> str:
|
|
return f"<MenuItem '{self.name}' at {self.start}, duration {self.duration}>"
|
|
|
|
|
|
class MenuInfo:
|
|
REGEX = re.compile(r"^_\d{2}_\d{2}_\d{2}")
|
|
|
|
def __init__(self, menu_dict: Dict[str, str], duration: str = None):
|
|
self.data = menu_dict
|
|
self.duration: Optional[float] = timeparse(duration) if duration else None
|
|
|
|
@property
|
|
def items(self) -> List[MenuItem]:
|
|
if not "extra" in self.data:
|
|
duration = str(timedelta(seconds=round(timeparse(list(self.data.keys())[0]))))
|
|
return [MenuItem(start="00:00:00", duration=f"{duration:0>8}", name='Chapter 1')]
|
|
keys = sorted(key for key in self.data["extra"] if type(self).REGEX.match(key))
|
|
td0 = timedelta(seconds=0)
|
|
td = None
|
|
result = []
|
|
for key in keys:
|
|
td = timedelta(
|
|
hours=int(key[1:3]),
|
|
minutes=int(key[4:6]),
|
|
seconds=int(key[7:9]),
|
|
milliseconds=float(key[10:])
|
|
)
|
|
dur_td = timedelta(seconds=round((td - td0).total_seconds()))
|
|
duration = str(dur_td)
|
|
duration = f"{duration:0>8}"
|
|
td0 = td
|
|
name = self.data["extra"][key].replace(" ", " ")
|
|
if result:
|
|
result[-1].duration = duration
|
|
result.append(MenuItem(start=key, duration="", name=name))
|
|
if result:
|
|
td = timedelta(seconds=float(self.data.get("Duration", self.duration)))
|
|
dur_td = timedelta(seconds=round((td - td0).total_seconds()))
|
|
duration = str(dur_td)
|
|
duration = f"{duration:0>8}"
|
|
result[-1].duration = duration
|
|
return result
|
|
|
|
|
|
class MediaInfo:
|
|
def __init__(self):
|
|
self.path: Path
|
|
self.general: Optional[Dict[str, Any]] = None
|
|
self.video: Optional[Dict[str, Any]] = None
|
|
self.audio: List[Dict[str, Any]] = []
|
|
self.menu_data: Optional[Dict[str, Any]] = None
|
|
|
|
self.codec: str
|
|
self.bitrate: int
|
|
self.framerate: float
|
|
self.duration: str
|
|
|
|
self.resolution: Resolution
|
|
self.display_aspect_ratio: Ratio
|
|
self.keep_display_aspect: bool
|
|
self.display_width: float
|
|
self.pixel_aspect_ratio: Ratio
|
|
|
|
def load_file(self, path: Path):
|
|
self.path = path
|
|
procinfo = Popen([Config.MEDIAINFO_BINARY, path, "--Output=JSON"], stdout=PIPE)
|
|
stdout, _ = procinfo.communicate()
|
|
data = json.loads(stdout.decode('utf-8'))["media"]["track"]
|
|
for track in data:
|
|
if track["@type"] == "General":
|
|
self.general = track
|
|
elif track["@type"] == "Video":
|
|
self.video = track
|
|
elif track["@type"] == "Audio":
|
|
self.audio.append(track)
|
|
elif track["@type"] == "Menu":
|
|
self.menu_data = track
|
|
|
|
self.determine_information_properties()
|
|
self.determine_aspect_properties()
|
|
|
|
def determine_information_properties(self):
|
|
self.codec = self.video["Format"]
|
|
|
|
try:
|
|
bitrate = self.video["BitRate"]
|
|
except KeyError:
|
|
bitrate = self.general["OverallBitRate"]
|
|
self.bitrate = int(bitrate) // 1000
|
|
|
|
keys = ["FrameRate_Maximum", "FrameRate", "FrameRate_Nominal"]
|
|
for key in keys:
|
|
if key in self.video:
|
|
self.framerate = float(self.video[key])
|
|
if not self.framerate:
|
|
print(json.dumps(self.general, indent=2))
|
|
print(json.dumps(self.video, indent=2))
|
|
raise Exception("No frame rate for video " + str(self.path))
|
|
|
|
places_to_look = [self.video, self.general]
|
|
for place in places_to_look:
|
|
if "Duration" in place:
|
|
seconds = float(place["Duration"])
|
|
self.duration = str(timedelta(seconds=seconds))
|
|
if not self.duration:
|
|
raise Exception("No duration for video " + str(self.path))
|
|
|
|
def determine_aspect_properties(self):
|
|
width = int(self.video["Width"])
|
|
height = int(self.video["Height"])
|
|
self.resolution = Resolution(width=width, height=height)
|
|
|
|
ratio = [float(x) for x in str(self.video["DisplayAspectRatio"]).split(':')]
|
|
if len(ratio) != 2:
|
|
if -0.01 <= ratio[0] - self.resolution.ratio <= 0.01:
|
|
frac = Fraction(self.resolution.width, self.resolution.height)
|
|
else:
|
|
new_width = round(self.resolution.height * ratio[0])
|
|
frac = Fraction(new_width, self.resolution.height)
|
|
ratio = (frac.numerator, frac.denominator)
|
|
self.display_aspect_ratio = Ratio(x=ratio[0], y=ratio[1])
|
|
|
|
self.keep_display_aspect = 0.95 < self.resolution.ratio / self.display_aspect_ratio.ratio < 1.05
|
|
|
|
if self.keep_display_aspect:
|
|
self.display_width = float(self.resolution.width)
|
|
self.pixel_aspect_ratio = Ratio(1, 1)
|
|
else:
|
|
self.display_width = self.resolution.height * self.display_aspect_ratio.ratio
|
|
self.pixel_aspect_ratio = self.display_aspect_ratio
|
|
|
|
@property
|
|
def tracks(self) -> List[AudioInfo]:
|
|
result = []
|
|
for audio in self.audio:
|
|
track = AudioInfo(audio)
|
|
result.append(track)
|
|
return result
|
|
|
|
@property
|
|
def menu(self) -> MenuInfo:
|
|
return MenuInfo(self.menu_data or { self.duration: "Chapter 1" }, duration=self.duration)
|
|
|
|
@property
|
|
def output_bitrate(self) -> int:
|
|
if self.codec == "MPEG-4 Visual":
|
|
translations = [
|
|
Translation(0, 800, 0.5, 200, 600),
|
|
Translation(800, 1500, 0.4, 400, 800),
|
|
Translation(1500, 99999999999, 0.33, 600, 2500),
|
|
]
|
|
else:
|
|
translations = [
|
|
Translation(0, 800, 0.7, 450, 700),
|
|
Translation(800, 1500, 0.6, 550, 1000),
|
|
Translation(1500, 4000, 0.45, 800, 2500),
|
|
Translation(4000, 99999999999, 0.33, 2000, 5000),
|
|
]
|
|
bitrate = self.bitrate
|
|
for t in translations:
|
|
if t.start <= bitrate < t.end:
|
|
return int(min(t.max_br, max(t.min_br, bitrate * t.multiplier)))
|