#!/usr/bin/env python
"""
framerate.py

August 27, 2007

A little utility to convert the framerate of a video clip of arbitrary
format, using the yuvmotionfps and ffmpeg utilities.

The advantages of this script are:
 - it's pretty simple
 - its input and/or output can be piped
 - it eliminates the need for typing manual commands, and creating huge
   yuv files on your disk.

Prerequisites:
  - ffmpeg - should be on your linux distro's feeds
  - python version 2.4 or later - ditto
  - yuvmotionfps - get from http://jcornet.free.fr/linux/yuvmotionfps.html

Installation:
  - rename to a convenient name (I've just called it 'framerate')
  - stick in your PATH
  - change permissions to 744

Usage:
  - run with '-h' for help
  - note that audio gets ditched, and resulting video must be
    re-muxed with audio afterwards

Example:
  - framerate -f mov -vcodec mpeg4 -r 25 -b 2000 infile.avi outfile.mov

Author:
  - David McNab rebirth AT orcon DOT net DOT nz

Date:
  - August 12, 2007

License:
  - GNU Lesser General Public License, version 3 - refer http://www.gnu.org
"""

version = "0.1.3"

import sys, os, time, popen2, commands, thread, threading

# set defaults

inputKbps = 5000              # convert input video to yuv at this bandwidth
inputFps = None               # input framerate, if you need to set explicitly
outputKbps = 5000             # generate output video at this bandwidth
outputFps = 25                # framerate of output video
outputFormat = "avi"          # output container format
outputVcodec = "mpeg4video"   # output video codec

# interpolation defaults

interpBlockSize = 8
interpSearchRadius = 8        # search radius, best not go over 20
interpFrameThreshold = 50     # higher->better
interpSceneThreshold = 8      # 0 = disable scene change detection
interpVerbose = 0             # whether to spit verbose messages

procBufSize = 1024 ** 2

# globals

progname = sys.argv[0]
args = sys.argv[1:]
argc = len(args)


def help():
    """
    spit help messages and exit without error
    """
    print "%s: General framerate converter with motion interpolation" % progname
    print "Usage: %s [options] [infile [outfile]]" % progname
    print "Options:"
    print "  -start ss  Set start point in source vid in seconds, default 0"
    print "  -length ss Set length of video to take from source vid"
    print "  -ib nnnn   Set intermediate kbit/s for input video, default 5000"
    print "  -b nnnn    Set output kbit/s for output video, default 5000"
    print "  -ir nnn    Set input framerate, default 25 - may be unnecessary"
    print "  -r nnn     Set intermediate framerate, default 25"
    print "  -or nnn    Set final output framerate, defaults to -r value"
    print "  -iblk n    Set block size for interpolation, default 8"
    print "             (lower is better, but slower)"
    print "  -irad n    Set search radius for interpolation, default 8"
    print "             (higher is better, but slower, don't exceed 24)"
    print "  -ifrm n    Set frame threshold for interpolation, default 8"
    print "  -iscn n    Set scene detect threshold, default 8, 0=off"
    print "  -dry       Do a dry run - only display the commands"
    print "  -nopipe    Use intermediate files instead of pipes"
    print "  -stages    Only perform given conversion stages - implies"
    print "             -nopipe. Argument is [1-3](,[1-3])* eg 1,2 or 2 or 2,3"
    print "  -v, --verbose Set verbose stderr messages during interpolate"
    print "  -V, --version Print version number and exit"
    print "  -genscript filename.sh - generate a shell script instead"
    print "All other options get handed over to ffmpeg for output encoding"
    print "Infile and outfile, if omitted or '-', will be stdin and stdout respectively"
    print "NOTE: you must put all '-whatever' options before the input"
    print "and output filenames, or else things won't work!"
    print
    sys.exit(0)

def usage(msg=None):
    if msg:
        sys.stderr.write(msg+"\n")
    sys.stderr.write("Usage: %s [options] infile outfile\n" % progname)
    sys.stderr.write("Type '%s -h' for help\n" % progname)
    sys.exit(1)

def logthread(proc, errfile, lock):
    """
    logs from process' error stream to given open file
    """
    while True:
        c = proc.read(1)
        if not c:
            errfile.close()
            lock.release()
            return
        errfile.write(c)

def rateToFraction(rate):
    """
    tries to convert an int or float fps rate to a fraction
    """
    parts = str(rate).split(".")
    fpsInt = int(parts[0])
    if len(parts) > 1:
        fpsDecStr = parts[1]
        fpsDec = int(fpsDecStr)
        mult = 10 ** len(fpsDecStr)
        num = fpsInt * mult + fpsDec
        denom = mult
    else:
        num = fpsInt
        denom = 1
    return num, denom

def convert(infile, outfile, **kw):
    """
    perform conversion

    Arguments:
     - infile - a readable file object, or a pathname, or '-' to use stdin
     - outfile - a writable file object, or a pathname, or '-' to use stdout

    Keywords:
     - as for help()
     - any additional keywords will be passed to ffmpeg during the output conversion
    """
    #print "kw=%s" % kw

    start = kw.pop('start', None)
    if start:
        start = float(start)
    length = kw.pop('length', None)
    if length:
        length = float(length)

    # control options
    dry = kw.pop('dry', False)
    nopipe = kw.pop('nopipe', False)
    stages = kw.pop('stages', None)

    # allow the option to generate a script
    genscript = kw.pop("genscript", None)
    if genscript:
        if not genscript.endswith(".sh"):
            genscript += ".sh"
        nopipe = True

    if stages:
        nopipe = True
        if isinstance(stages, str):
            stages = stages.split(",")
            for stage in stages:
                if str(stage) not in ['1', '2', '3']:
                    usage("Invalid stage argument %s" % repr(stage))
            stages = [int(stage) for stage in stages]
    else:
        stages = [1,2,3]

    # need to invent some filenames for intermediate files
    if nopipe:
        if outfile == '-':
            outfile = "framerate.out"
        fileYuvIn = infile + ".in.yuv"
        fileYuvOut = infile + ".out.yuv"

    # convert fps to fraction
    fpsStr = str(kw.pop('r'))
    fps = float(fpsStr)
    fpsNum, fpsDenom = rateToFraction(fpsStr)

    ofpsStr = kw.pop("or", None)
    if ofpsStr:
        ofpsNum, ofpsDenom = rateToFraction(ofpsStr)
    else:
        ofpsNum, ofpsDenom = fpsNum, fpsDenom


    # create command to convert to yuv
    toYuvCmd = " ".join([
            "ffmpeg",
            "-y",
            ])
    if start:
        toYuvCmd += " -ss %f" % start
    if length:
        toYuvCmd += " -t %f" % length
    fpsIn = kw.pop("ir")
    if fpsIn:
        toYuvCmd += " -r %s" % fpsIn
        
    toYuvCmd += " ".join([
            "",
            "-i %s" % infile,
            "-f yuv4mpegpipe",
            "-b %s" % kw.pop('ib'),
            "-vcodec pgmyuv",
            ])

    if nopipe:
        toYuvCmd += " %s" % fileYuvIn
    else:
        toYuvCmd += " -" # pipe

    # create framerate conversion command
    convertCmd = " ".join([
            "yuvmotionfps",
            "-f",
            "-r %s:%s" % (fpsNum, fpsDenom),
            "-b %s" % kw.pop('iblk'),
            "-p %s" % kw.pop('irad'),
            "-t %s" % kw.pop('ifrm'),
            "-s %s" % kw.pop('iscn'),
            ])
    if kw.pop("v"):
        convertCmd += " -v"

    if nopipe:
        convertCmd += " < %s" % fileYuvIn

    convertCmd += " | yuvfps -r %s:%s -s %s:%s" % (ofpsNum, ofpsDenom, ofpsNum, ofpsDenom)

    if nopipe:
        convertCmd += " > %s" % fileYuvOut

    #print "1a: kw=%s" % kw

    # create ffmpeg output re-encode command
    if nopipe:
        finalInFile = fileYuvOut
    else:
        finalInFile = "-"

    outputCmd = " ".join([
            "ffmpeg",
            "-y",
            "-i %s" % finalInFile,   # piping off yuvmotionfps' output
            ])

    if not kw.get("sameq", False):
        outputCmd += " -b %s" % kw.pop('b')

    #print "2: kw=%s" % kw
    for k,v in kw.items():
        if v == True:
            option = " -%s" % k
        elif v == False:
            continue
        else:
            option = " -%s %s" % (k,v)
        #print "adding option: %s" % option
        outputCmd += option

    isYuvOut = outfile.endswith(".yuv")

    if isYuvOut:
        if nopipe:
            outputCmd = "cat < %s > %s" % (finalInFile, outfile)
        else:
            outputCmd = "cat > %s" % outfile
    else:
        outputCmd += " %s" % outfile

    if dry:
        sys.stderr.write("Dry run commands:\n")
        sys.stderr.write("  Convert to YUV:\n    %s\n" % toYuvCmd)
        sys.stderr.write("  Convert FPS:\n    %s\n" % convertCmd)
        sys.stderr.write("  Output:\n    %s\n" % outputCmd)
        return
    elif genscript:
        f = file(genscript, "w")
        f.write("\n".join([
                    "#!/bin/sh",
                    "",
                    "# generated by framerate.py",
                    "",
                    "# stage 1 - convert input to YUV format",
                    toYuvCmd,
                    "",
                    "# stage 2 - convert framerate",
                    convertCmd,
                    "",
                    "# stage 3 - convert to final output",
                    outputCmd,
                    "",
                    "",
                    ]))
        f.close()
        return

    #fullCmd = "%s | %s | %s" % (toYuvCmd, convertCmd, outputCmd)

    fullCmd1 = "%s | %s" % (toYuvCmd, convertCmd)
    fullCmd2 = "%s" % outputCmd

    sys.stderr.write("process 1 command:\n  %s\n" % fullCmd1)
    sys.stderr.write("process 2 command:\n  %s\n" % fullCmd2)

    #return

    # break up into stages if needed
    if nopipe:
        if 1 in stages: 
            sys.stderr.write("Stage 1 - convert to YUV:\n  %s\n" % toYuvCmd)
            os.system(toYuvCmd)
        if 2 in stages:
            sys.stderr.write("Stage 2 - convert fps:\n  %s\n" % convertCmd)
            os.system(convertCmd)
        if 3 in stages:
            sys.stderr.write("Stage 3 - final output:\n  %s\n" % outputCmd)
            os.system(outputCmd)
        return

    if 0:
        os.system(fullCmd1)
        os.system(fullCmd2)
        return

    # create processes
    proc1 = popen2.Popen3(fullCmd1, True, 16384)
    proc2 = popen2.Popen3(fullCmd2, True, 16384)

    # get file objects
    p1Out = proc1.fromchild
    p1In = proc1.tochild
    p1Err = proc1.childerr

    p2Out = proc2.fromchild
    p2In = proc2.tochild
    p2Err = proc2.childerr

    # start up the error logging thread
    errFile1 = outfile+".err1"
    lock1 = threading.Lock()
    lock1.acquire()

    errFile2 = outfile+".err2"
    lock2 = threading.Lock()
    lock2.acquire()

    thread.start_new_thread(logthread, (p1Err, file(errFile1, "w"), lock1))
    thread.start_new_thread(logthread, (p2Err, file(errFile2, "w"), lock2))

    # now shuffle the data from proc1 to proc2
    done = False
    sys.stderr.write("Converting video: ")
    while not done:
        chunks = []
        size = 0
        sys.stderr.write(".")
        sys.stderr.flush()
        while size < procBufSize:
            #print "Reading %d bytes from cmd1" % (procBufSize-size)
            buf = p1Out.read(procBufSize - size)
            #print "Got it"
            if buf == '':
                done = True
                break
            chunks.append(buf)
            size += len(buf)
        # got our number of chars
        buf = "".join(chunks)
        p2In.write(buf)
    sys.stderr.write("\n")
    
    # proc1 is finished
    #p1err = p1Err.read()
    p1In.close()
    p1Out.close()

    # flush proc2 and wait for completion
    p2In.flush()
    p2In.close()

    # now can display errors
    lock1.acquire()
    sys.stderr.write("____________\nProcess1 Error Output:\n_______________\n")
    sys.stderr.write(file(errFile1).read()+"\n")

    lock2.acquire()
    sys.stderr.write("Process2 Error Output:\n_______________\n")
    sys.stderr.write(file(errFile2).read()+"\n")
                     

def main():
    """
    CLI front-end
    """
    # ensure requisite progs are present
    if commands.getoutput("ffmpeg") == '':
        sys.stderr.write("Sorry, ffmpeg is not installed\n")
        sys.exit(1)
    if commands.getoutput("yuvmotionfps") == '':
        sys.stderr.write("\n".join([
           "Can't find 'yuvmotionfps",
           "Please get it from http://jcornet.free.fr/linux/yuvmotionfps.html",
           ""]))
        sys.exit(1)
                  
    class MissingArg(Exception):
        pass

    def getarg(errstr=None):
        if args:
            arg = args.pop(0)
            if errstr and arg == '-':
                raise MissingArg(errstr)
            return arg
        else:
            if errstr:
                raise MissingArg(errstr)
            return None

    kw = {
        "start" : None,
        "length" : None,
        "ib" : inputKbps,
        "b" : outputKbps,
        "ir": None,
        "r" : outputFps,
        "iblk" : interpBlockSize,
        "irad" : interpSearchRadius,
        "ifrm" : interpFrameThreshold,
        "iscn" : interpSceneThreshold,
        "v" : interpVerbose,
        "dry" : False,
        "nopipe" : False,
        "stages" : None,
        "sameq" : False,
        }

    infile = None
    try:
        while True:
            opt = getarg()
            if opt == '-h':
                help()
            if opt in ('-V', '--version'):
                print "Version %s" % version
                sys.exit(0)
            if not opt:
                break
            if opt.startswith('-') and len(opt) > 1:
                opt = opt[1:]
                if opt in ['v', 'dry', 'nopipe', "sameq"]:
                    kw[opt] = True
                    if opt == 'sameq':
                        kw.pop("b", None)
                else:
                    kw[opt] = getarg(opt)
                    #print "setting %s to %s" % (opt, kw[opt])
            else:
                # gave input filename
                infile = opt
                break

        infile = infile or getarg() or '-'
        outfile = getarg() or '-'
        convert(infile, outfile, **kw)
    except MissingArg, e:
        usage("missing/invalid value for option -%s" % e.args[0])

if __name__ == '__main__':
    main()

