#!python
# coding: utf-8

"""Transcodes files into "proxy" files which, in video-editing jargon,
are intermediate, lower-resolution files that are faster to process
for editing. The SOURCE path gets simply transcoded into the TARGET
path if it's a file. If it is a directory, the SOURCE path gets
*REPLACED* by the TARGET path, keeping the directory structure. So,
for example, calling `video-proxy-magic.py FOO/ PROXIES/FOO/` will
transcode a file in `FOO/BAR/baz.MOV` into
`PROXIES/FOO/BAR/baz_proxy.mp4`.
"""

# Copyright (C) 2020 Antoine Beaupré <anarcat@debian.org>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

from __future__ import division, absolute_import
from __future__ import print_function, unicode_literals

import argparse
import fnmatch
import logging
import os.path
import shlex

try:
    # Python 3.8
    from shlex import join as shlex_join
except ImportError:

    def shlex_join(split_command):  # type: ignore
        """Return a shell-escaped string from *split_command*."""
        return " ".join(shlex.quote(arg) for arg in split_command)


import subprocess
import sys


def parse_args(args=sys.argv[1:]):
    parser = argparse.ArgumentParser(
        description="generate proxy files for video editing with ffmpeg", epilog=__doc__
    )
    parser.add_argument(
        "--verbose",
        "-v",
        dest="log_level",
        action="store_const",
        const="info",
        default="warning",
    )
    parser.add_argument(
        "--debug",
        "-d",
        dest="log_level",
        action="store_const",
        const="debug",
        default="warning",
    )
    parser.add_argument(
        "--exclude",
        nargs="+",
        default=[],
        help="exclude files matching glob pattern (e.g. *.MXF)",
    )
    parser.add_argument(
        "--include",
        nargs="+",
        default=[],
        help="include files matching glob pattern (e.g. *.MOV), defaults to all",
    )
    parser.add_argument(
        "--suffix",
        default="_proxy",
        help="suffix to replace the current file extension with, default: %(default)s",
    )
    parser.add_argument(
        "--format",
        choices=("prores", "h264"),
        help="file format",
    )

    # by default we rescale to 720p using the scale filter:
    # https://ffmpeg.org/ffmpeg-filters.html#scale-1

    # This is a rather convoluted ffmpeg expression, as per:
    #
    # https://ffmpeg.org/ffmpeg-utils.html#Expression-Evaluation
    #
    # to break it down:
    #
    # # the first "if()" block is for "x" (vertical)
    #
    # # if height is larger than width, the "phone", "portrait" mode
    #   # then scale the width to 720 pixels
    #   # otherwise we're in "landscape" mode and do not toucht the
    #   # width (-1)
    # if(gt(ih,iw),720,-1)
    #
    # # then we switch to "y" (vertical)
    # :
    #
    # # if height is larger than width
    #   # do not touch the width (-1)
    #   # otherwise, that is if width is larger than height, which is
    #   # the "normal" or "landscape" mode
    #   # then scale the height to 720 pixels, which is equivalent to 720p
    # if(gt(ih,iw),-1,720)
    #
    # i believe having single quotes around the expression matter too
    parser.add_argument(
        "--scale",
        default="'if(gt(ih,iw),720,-1):if(gt(ih,iw),-1,720)'",
        help="""force the given scale and aspect ratio, e.g. 1280x720
        or hd720, see https://ffmpeg.org/ffmpeg-utils.html#Video-size
        for valid shortscuts and
        https://trac.ffmpeg.org/wiki/FilteringGuide for other
        examples. default to 720p, preserving aspect ratio and
        orientation, represented by the formula %(default)s"""
    )

    parser.add_argument(
        "--extra", help="extra args to pass to ffmpeg, e.g. -ss 10 -t 10", default=""
    )
    parser.add_argument("--no-timecode", action="store_true", help="disable timecode")
    parser.add_argument("--dryrun", "-n", action="store_true", help="do nothing")
    parser.add_argument("source", help="source path")
    parser.add_argument("target", help="target (proxy) path")
    return parser.parse_args(args=args)


def fnmatch_patterns(name, patterns):
    for pattern in patterns:
        if fnmatch.fnmatch(name, pattern):
            yield pattern


def main(args):
    if args.extra:
        args.extra = shlex.split(args.extra)
    if os.path.isdir(args.source):
        for root, dirs, files in os.walk(args.source, onerror=walk_handler):
            for name in files:
                source = os.path.join(root, name)
                if name.startswith("."):
                    logging.info("skipping hidden file: %s", source)
                    continue
                if args.include:
                    matched = list(fnmatch_patterns(name, args.include))
                    if not matched:
                        logging.info(
                            "skipping file %s because it does not match include patterns %s",
                            source,
                            args.include,
                        )
                        continue
                matched = list(fnmatch_patterns(name, args.exclude))
                if matched:
                    logging.info(
                        "skipping excluded file %s from patterns %s", source, matched
                    )
                    continue
                target = source.replace(args.source, args.target, 1)
                # add suffix
                if args.format == "prores":
                    target = os.path.splitext(target)[0] + args.suffix + ".mov"
                else:
                    target = os.path.splitext(target)[0] + args.suffix + ".mp4"
                logging.info("# transcoding %s to %s", source, target)

                if os.path.exists(target):
                    logging.warning(
                        "skipping already processed file: %s (source: %s)",
                        target,
                        source,
                    )
                    continue
                dir = os.path.dirname(target)
                if dir and not args.dryrun:
                    os.makedirs(dir, exist_ok=True)
                # TODO: trim canon shit
                xcode_path(source, target, args.scale, not args.no_timecode,args.dryrun, args.extra)
    else:
        logging.info("# transcoding %s to %s",  args.source, args.target)
        xcode_path(args.source, args.target, args.scale, not args.no_timecode, args.dryrun, args.extra)


# next time you add a argument to this function, clean it up, it's
# getting ridiculous. for example, dryrun could be taken out by making
# this a function that just generates a command
def xcode_path(source, target, scale, timecode, dryrun=True, extras=""):
    basename = os.path.basename(source)
    # see also https://ffmpeg.org/ffmpeg-filters.html#drawtext-1
    drawtext_style = (
        "fontsize=16 : fontcolor=white : box=1: boxcolor=black@0.6 : font=Monospace"
    )
    # fmt: off
    command = [
        "ffmpeg",
        "-loglevel", "warning",
        "-stats",
        # read from source file
        "-i", source,
    ]
    # transcoding
    if args.format == "prores":
        # canon files are weird 4-channel audio shit
        command += [
            # transcode to prores: https://trac.ffmpeg.org/wiki/Encode/VFX#Prores
            #
            # "-profile:v 0" is "proxy": https://en.wikipedia.org/wiki/Apple_ProRes#ProRes-Overview
            #
            # "-vendor apl0" tricks quicktime and Final Cut Pro into
            # thinking that the movie was generated on using a
            # quicktime prores encoder
            #
            # "-qscale:v 11" is magic, described as a "good bet" in the ffmpeg docs
            #
            # "-pix_fmt yuv422p10le" is even more magic, taken from
            # the ffprobe of a Final Cut Pro generated proxy
            "-c:v", "prores_ks", "-profile:v", "0", "-vendor", "apl0", "-qscale:v", "11", "-pix_fmt", "yuv422p10le",
            # make raw audio, 24 bits little endian, 48KHz, 4 channels
            "-c:a", "pcm_s24le", "-ar", "48000", "-ac", "4",
        ]
    else:
        command += [
            # transcode to h264, max 6mbps, see https://trac.ffmpeg.org/wiki/Encode/H.264
            "-c:v", "libx264", "-crf", "23", "-maxrate", "6M", "-bufsize", "2M",
            # make AAC audio, defaults to 128kbps
            "-c:a", "aac",
        ]
    # filters
    # start filter definition, see the --scale option usage for more documentation
    filters = "scale=" + scale
    if timecode:
        # add timecode overlay, to the right, stolen from
        #
        # https://ottverse.com/ffmpeg-drawtext-filter-dynamic-overlays-timecode-scrolling-text-credits/
        #
        # we calculate `x` with an expression here too because we
        # don't actually know the final widt
        #
        # w is the actual frame width, text_w is the width of the
        # produced text, and -10 is an extra padding, same as the `y`
        # padding
        filters += ", drawtext=text='%%{pts\\:hms}' : x=(w-text_w-10) : y=10 : %s" % drawtext_style
        # add filename overlay, to the left, 10 pixels padding
        filters += ", drawtext=text='%s' : x=10: y=10 : %s" % (basename, drawtext_style)
    command += ["-vf", filters]
    # fmt: on
    # extras need to happen before the output file
    command += extras
    # finish with the output file
    command.append(target)
    logging.debug("running %s", shlex_join(command))
    if not args.dryrun:
        subprocess.check_call(command)


def walk_handler(exc):
    logging.warning("scandir failed on %s: %s", exc.filename, exc)


if __name__ == "__main__":
    args = parse_args()
    logging.basicConfig(format="%(message)s", level=args.log_level.upper())
    main(args)
