diff --git a/lib/python/advancedav.py b/lib/python/advancedav.py index 3ee26c0..6bbdf57 100644 --- a/lib/python/advancedav.py +++ b/lib/python/advancedav.py @@ -37,7 +37,7 @@ from pathlib import Path, PurePath __all__ = "AdvancedAVError", "AdvancedAV", "SimpleAV", "MultiAV" -version_info = 2, 99, 5 +version_info = 2, 99, 6 # Constants DEFAULT_CONTAINER = "matroska" @@ -56,21 +56,77 @@ class AdvancedAVError(Exception): # == Helpers == -def ffmpeg_int(no: str) -> int: - if isinstance(no, str): - factor = 1 - base = 1000 - if no[-1].lower() == "b": - factor *= 8 - no = no[:-1] - if no[-1].lower() == "i": - base = 1024 - no = no[:-1] - if not no[-1].isdigit(): - factor *= base ** (["k", "m", "g"].index(no[-1].lower()) + 1) - no = no[:-1] - return int(no) * factor - return int(no) +class FFmpeg: + @staticmethod + def int(no: str) -> int: + """ + Parse a ffmpeg number. + See https://ffmpeg.org/ffmpeg.html#Options + """ + if isinstance(no, str): + factor = 1 + base = 1000 + if no[-1].lower() == "b": + factor *= 8 + no = no[:-1] + if no[-1].lower() == "i": + base = 1024 + no = no[:-1] + if not no[-1].isdigit(): + factor *= base ** (["k", "m", "g"].index(no[-1].lower()) + 1) + no = no[:-1] + return int(no) * factor + return int(no) + + # Commandline generation + @staticmethod + def argv_options(options: Mapping, qualifier: str=None) -> Iterator: + """ Yield arbitrary options + + :type options: Mapping[str, str] + :rtype: Iterator[str] + """ + if qualifier is None: + opt_fmt = "-%s" + else: + opt_fmt = "-%%s:%s" % qualifier + for option, value in options.items(): + yield opt_fmt % option + if isinstance(value, (tuple, list)): + yield str(value[0]) + for x in value[1:]: + yield opt_fmt % option + yield str(x) + elif value is not True and value is not None: + yield str(value) + + @staticmethod + def argv_metadata(metadata: Mapping, qualifier: str=None) -> Iterator: + """ Yield arbitrary metadata + + :type metadata: Mapping[str, str] + :rtype: Iterator[str] + """ + if qualifier is None: + opt = "-metadata" + else: + opt = "-metadata:%s" % qualifier + for meta in metadata.items(): + yield opt + yield "%s=%s" % meta + + # Stream types + stype_by_ctype = { + "audio": S_AUDIO, + "video": S_VIDEO, + "subtitle": S_SUBTITLE, + "attachment": S_ATTACHMENT, + "data": S_DATA + } + + @classmethod + def stype_from_ctype(ffmpeg, ctype): + return ffmpeg.stype_by_ctype.get(ctype, S_UNKNOWN) class Future: @@ -333,12 +389,13 @@ class InputStream(Stream): @property def type(self): - return self.information["codec_type"][0] + return FFmpeg.stype_from_ctype(self.codec_type) index = InformationProperty("index", type=int) codec = InformationProperty("codec_name") codec_name = InformationProperty("codec_long_name") + codec_type = InformationProperty("codec_type") profile = InformationProperty("profile") duration = InformationProperty("duration", type=float) @@ -364,7 +421,7 @@ class InputAudioStream(InputStream): __slots__ = () def __init__(self, file: "InputFile", info: dict, pertype_index: int=None): - if info["codec_type"][0] != S_AUDIO: + if info["codec_type"] != "audio": raise ValueError("Cannot create %s from stream info of type %s" % (type(self).__name__, info["codec_type"])) super().__init__(file, info) @@ -380,9 +437,21 @@ class InputAudioStream(InputStream): channel_layout = InformationProperty("channel_layout") +class InputAttachmentStream(InputStream): + __slots__ = () + + @property + def type(self): + return S_ATTACHMENT + + og_filename = InformationProperty("tags", "filename") + mimetype = InformationProperty("tags", "mimetype") + + def input_stream_factory(file, info, pertype_index=None): return { "audio": InputAudioStream, + "attachment": InputAttachmentStream, }.get(info["codec_type"], InputStream)(file, info, pertype_index) @@ -414,7 +483,7 @@ class OutputStream(Stream, ObjectWithOptions, ObjectWithMetadata): codec = OptionProperty("codec", "c") - bitrate = OptionProperty("b", type=ffmpeg_int) + bitrate = OptionProperty("b", type=FFmpeg.int) class OutputAudioStream(OutputStream): @@ -436,22 +505,16 @@ def output_stream_factory(file, source, *args, **more): # === File Classes === -class File(ObjectWithOptions): - """ - ABC for Input- and Output-Files - """ - __slots__ = "_streams", "_streams_by_type", "options", "path" +class BaseFile: + __slots__ = "path", - def __init__(self, path: Path, options: dict=None, **more): - super().__init__(options=options, **more) + def __init__(self, path: Path, **more): + super().__init__(**more) self.path = Path(path) - self._streams = [] - """ :type: list[Stream] """ - - self._streams_by_type = collections.defaultdict(list) - """ :type: dict[str, list[Stream]] """ + def generate_args(self): + raise NotImplementedError("generate_args not implemented on base file") # Filename @property @@ -477,6 +540,29 @@ class File(ObjectWithOptions): def filename(self, value): self.path = Path(value) + # Streams + @property + def streams(self) -> Sequence: + return () + + video_streams = audio_streams = subtitle_streams = attachment_streams = data_streams = streams + + +class File(BaseFile, ObjectWithOptions): + """ + ABC for Input- and Output-Files + """ + __slots__ = "_streams", "_streams_by_type", "options" + + def __init__(self, path: Path, options: dict=None, **more): + super().__init__(path=path, options=options, **more) + + self._streams = [] + """ :type: list[Stream] """ + + self._streams_by_type = collections.defaultdict(list) + """ :type: dict[str, list[Stream]] """ + # Streams def _add_stream(self, stream: Stream): """ Add a stream """ @@ -577,6 +663,14 @@ class InputFile(File): self._initialize_info() return self._information + def generate_args(self) -> Iterator: + # Input options + yield from FFmpeg.argv_options(self.options) + + # Add Input + yield "-i" + yield self.filename if self.filename[0] != "-" else "./" + self.filename + # -- Initialize ffprobe_args = "-show_format", "-show_streams", "-show_chapters", "-print_format", "json" @@ -589,7 +683,7 @@ class InputFile(File): The locale of the probe output in \param probe should be C! """ for sinfo in self.information["streams"]: - stype = sinfo["codec_type"][0] + stype = FFmpeg.stype_from_ctype(sinfo["codec_type"]) stream = self.stream_factory(self, sinfo, len(self._streams_by_type[stype])) self._streams.append(stream) self._streams_by_type[stype].append(stream) @@ -698,6 +792,33 @@ class OutputFile(File, ObjectWithMetadata): self._mapped_sources = set() """ :type: set[InputStream] """ + def generate_args(self) -> Iterator: + # Global Metadata & Additional Options + yield from FFmpeg.argv_metadata(self.metadata) + yield from FFmpeg.argv_options(self.options) + + # Map Streams, sorted by type + self.reorder_streams() + + for stream in self.streams: + yield "-map" + yield self.task.qualified_input_stream_spec(stream.source) + + if stream.codec is not None: + yield "-c:%s" % stream.stream_spec + yield stream.codec + + yield from FFmpeg.argv_metadata(stream.metadata, stream.stream_spec) + yield from FFmpeg.argv_options(stream.options, stream.stream_spec) + + # Container + if self.container is not None: + yield "-f" + yield self.container + + # Output Filename, prevent it from being interpreted as option + yield self.filename if self.filename[0] != "-" else "./" + self.filename + # -- Map Streams def map_stream_(self, stream: InputStream, codec: str=None, options: Mapping=None) -> OutputStream: """ map_stream() minus add_input_file @@ -785,6 +906,45 @@ class OutputFile(File, ObjectWithMetadata): return self +# === Dump Attachments === +# see also Task.generate_args() +class AttachmentOutputStream(Stream): + __slots__ = () + + def __init__(self, file): + super().__init__(file=file) + + @property + def source(self): + return self.file.source + + @property + def type(self): + return S_ATTACHMENT + + +class AttachmentOutputFile(BaseFile): + __slots__ = "source" + + def __init__(self, source: InputAttachmentStream, path: Path=None): + if path is None: + path = source.og_filename + + super().__init__(path=path) + + self.source = source + + def generate_args(self): + yield "-dump_attachment:%s" % self.source.stream_spec + yield self.filename if self.filename[0] != "-" else "./" + self.filename + + @property + def attachment_streams(self): + return (AttachmentOutputStream(self),) + + streams = attachment_streams + + # === Task Classes === class BaseTask: """ @@ -844,88 +1004,29 @@ class BaseTask: # outputs: Sequence[OutputFile] # -- FFmpeg - @staticmethod - def argv_options(options: Mapping, qualifier: str=None) -> Iterator[str]: - """ Yield arbitrary options - - :type options: Mapping[str, str] - :rtype: Iterator[str] - """ - if qualifier is None: - opt_fmt = "-%s" - else: - opt_fmt = "-%%s:%s" % qualifier - for option, value in options.items(): - yield opt_fmt % option - if isinstance(value, (tuple, list)): - yield str(value[0]) - for x in value[1:]: - yield opt_fmt % option - yield str(x) - elif value is not True and value is not None: - yield str(value) - - @staticmethod - def argv_metadata(metadata: Mapping, qualifier: str=None) -> Iterator[str]: - """ Yield arbitrary metadata - - :type metadata: Mapping[str, str] - :rtype: Iterator[str] - """ - if qualifier is None: - opt = "-metadata" - else: - opt = "-metadata:%s" % qualifier - for meta in metadata.items(): - yield opt - yield "%s=%s" % meta - def generate_args(self) -> Iterator[str]: """ Generate the ffmpeg commandline for this task :rtype: Iterator[str] """ + # Dump attachments. this is stupid, ffmpeg! + # dumping attachments is inherently creating output files + # and shouldn't be done by an input option + # This HACK may or may not stay in final v3.... + attachment_dumps = [o for o in self.outputs if isinstance(o, AttachmentOutputFile)] + # Inputs for input_ in self.inputs: - # Input options - yield from self.argv_options(input_.options) - - # Add Input - yield "-i" - filename = input_.filename - if filename[0] == '-': - yield "./" + filename - else: - yield filename + for output in attachment_dumps: + if output.source.file is input_: + yield from output.generate_args() + + yield from input_.generate_args() # Outputs for output in self.outputs: - # Global Metadata & Additional Options - yield from self.argv_metadata(output.metadata) - yield from self.argv_options(output.options) - - # Map Streams, sorted by type - output.reorder_streams() - - for stream in output.streams: - yield "-map" - yield self.qualified_input_stream_spec(stream.source) - - if stream.codec is not None: - yield "-c:%s" % stream.stream_spec - yield stream.codec - - yield from self.argv_metadata(stream.metadata, stream.stream_spec) - yield from self.argv_options(stream.options, stream.stream_spec) - - # Container - if output.container is not None: - yield "-f" - yield output.container - - # Output Filename, prevent it from being interpreted as option - out_fn = output.filename - yield out_fn if out_fn[0] != "-" else "./" + out_fn + if output not in attachment_dumps: + yield from output.generate_args() def commit(self, additional_args: Iterable=(), immediate=True, **args): """ @@ -1053,6 +1154,18 @@ class Task(BaseTask): self.outputs.append(outfile) return outfile + # -- Attachment Shenanigans + def dump_attachment(self, attachment: InputAttachmentStream, filename: str=None) -> AttachmentOutputFile: + for outfile in self.outputs: + if outfile.filename == filename: + raise AdvancedAVError("Output file '%s' already added." % file) + else: + if attachment.type != S_ATTACHMENT: + raise AdvancedAVError("Stream %r not an attachment!" % attachment) + outfile = AttachmentOutputFile(attachment, filename) + self.outputs.append(outfile) + return outfile + class SimpleTask(Task): """ @@ -1223,7 +1336,7 @@ class SimpleAV(AdvancedAV): return out.decode("utf-8", "replace") def probe_file(self, file, *, ffprobe_args_hint=None): - probe = self.call_probe(tuple(BaseTask.argv_options(file.options)) + probe = self.call_probe(tuple(FFmpeg.argv_options(file.options)) + ffprobe_args_hint + ("-i", file.filename)) return json.loads(probe)