2016년 6월 30일 목요일

엑기스 동영상 파일을 ffmpeg, python, 다음팟 플레이어를 활용해서 만들기

영상에서 원하는 부분은 항상 한정되어 있고, 그 부분만 보기 위한 방법은 항상 불편한데다가 용량또한 부담스럽기 마련이었다. 그래서 HDTVtoMPEG같은 것을 써봤지만 불만스러운 부분이 있었고, 그것을 해결하기 위해 찾아낸 방법은 ffmpeg와 다음팟 플레이어, 그리고 python을 사용하는 것이었다.

평소 유용하게 사용하고 있던 다음팟 플레이어의 구간반복기능으로 잘라낼 범위를 정하고 확인한 뒤 Python으로 ffmpeg에 전달할 명령어를 만들어서 처리하니 시간적인 단축은 물론이고 번거로움까지 해결되었기에 혹여 필요한 사람들이 있으면 참고하라는 뜻에서 글을 남기게 되었다.

아래 내용은 Python 3.5.1을 기준으로 작성되었다.




처리과정을 간략히 설명하자면 다음팟 플레이어로 잘라낼 범위를 정한 후 pbf파일을 생성하고, ffprobe로 코덱 정보를 받은 뒤, 다음팟으로 만든 pbf파일 내부의 구간반복 범위에 대한 정보를 토대로 ffmpeg에 적당한 명령어를 보내는 것이다.

  1. 다음팟 플레이어로 범위설정 후 pbf파일 생성
    해당 내용에 대한 것은 Daum 팟플레이어 - 엑기스 파일을 만들 필요없게 만드는 팁을 읽어보기 바란다.
  2. ffprobe로 코덱정보 받기
    여기서는 ffprobe 파일명 -show_streams를 통해 출력된 정보 중 codec_name을 정규표현식으로 추출하는 것이다. ffprobe는 Python의 subprocess.Popen을 통해서 실행한다.

    import subprocess
    import re

    def codec_name(path):
        cmd = 'c:/ffmpeg/bin/ffprobe.exe "%s" -show_streams'%path

        popen = subprocess.Popen(cmd,stdout=subprocess.PIPE)
        stdout = popen.communicate()[0].decode('utf-8')
        codec = re.findall(r'codec_name=(.*?)(?:\s)',stdout)
        return codec

  3. 이 함수를 통해서 받게 되는 정보는 ['h264', 'aac'] 같은 형태가 된다.

  4. 구간반복에 대한 정보가 담겨있는 pbf파일을 읽기
    pbf파일을 읽어서 구간반복 시작점과 길이, 반복횟수, 설정한 이름을 읽어들이는 것에 대한 부분이다. 나같은 경우에 그냥 파일을 읽어들였을 때 cp949 에러가 발생해서 codecs를 써서 UTF-16으로 지정해야 파일을 열 수 있었다.

    pbf파일의 구조는 메모장을 열어서 확인하면 알 수 있지만, 시작부분은 항상 북마크에 대한 부분을 담고 있는 부분으로써 [Bookmark]로 시작하고, 구간반복에 대한 부분은 [PlayRepeat]으로 시작한다.
    반복에 대한 정보는 번호=시작시간*지속시간*반복횟수*해당구간명 의 형태로 되어있으며, 마지막엔 아무 내용도 없이 번호만 달려있는 부분으로 구성되어있다.

  5. import codecs
    import re

    def pbfstuff(path):
        pbf = []

        with codecs.open(path,'r','UTF-16') as f:      
            temp = f.readlines()
            f.close()

        addPBF = False
        for line in temp:
            if re.match(r'\[PlayRepeat\]?(?:\S)',line):
                addPBF = True
            if addPBF and '=' in line and '*' in line:
                pbf.append(line.split('=',1)[1].replace('\r\n','').split('*',3))

        return pbf


    읽어들인 파일을 readlines()를 통해서 각 줄을 리스트로 만들고, [PlayRepeat] 부분이 되면 addPBF를 True로 바꾼다. 그 후 해당 줄에 '='와 '*'이 있으면 split()을 써서 '=' 기준으로 한번 자른 후, 그 뒷부분에 replace()를 통해서 끝부분에 붙어있는 '\r\n'을 떼어내고, 다시 그 내용을 split()을 사용해서 '*'을 기준으로 세번 잘라서 4등분 시킨 리스트로 만든 뒤 그 내용을 앞서 선언해놓았던 pbf에 append()로 추가한다.

    이를 통해 만들어지는 pbf는 [[시작시간, 지속시간, 반복횟수, 이름], [시작시간, 지속시간, 반복횟수, 이름], ......]같은 형태이다.

  6. ffmpeg에 명령어보내고 내용 받아보기
    이제 할일은 popen()을 통해서 ffmpeg에 분할과 병합에 맞춰서 적당한 명령어를 보내서 실행시키고 그 과정을 지켜보는 것이다.

    초반의 ffprobe와 pbf는 2번의 소스를 ffprobe.py로, 3번의 소스를 pbf.py로 저장했던 것을 불러온 것이다.

    import os
    import datetime
    import subprocess
    from ffprobe import * 
    from pbf import * 

    def splitvideo(ffmpeg,path,destination):
        name,ext = os.path.splitext(path)
        basename = os.path.splitext(os.path.basename(path))[0]
        codec = codec_name(path)

        i = 0
        for x in pbfstuff(name+".pbf"):
            time = str(datetime.timedelta(milliseconds=int(x[0])))
            duration = str(datetime.timedelta(milliseconds=int(x[1])))

            newname = "%s/%s-%04d%s"%(destination,basename,i,ext)
            i+=1

            splitcmd(ffmpeg,path,time,duration,newname,codec)

    def splitcmd(ffmpeg,path,time,duration,name,codec):
        if 'h264' in codec and 'aac' in codec:
            cmd = "%s -ss %s -i \"%s\" -c copy -bsf:v h264_mp4toannexb -f mpegts -t %s \"%s\""%(ffmpeg,time,path,duration,name)
        else:
            cmd = "%s -ss %s -i \"%s\" -c copy -t %s \"%s\""%(ffmpeg,time,path,duration,name)

        cmdprocess(cmd)

    def concatvideo(ffmpeg,path,destination):
        name,ext = os.path.splitext(path)
        basename = os.path.splitext(os.path.basename(path))[0]
        codec = codec_name(path)

        i = -1
        files = []
        for x in pbfstuff(name+".pbf"):
            time = str(datetime.timedelta(milliseconds=int(x[0])))
            duration = str(datetime.timedelta(milliseconds=int(x[1])))

            i+=1
            newname = "%s-%04d.ts"%(basename,i)
            for y in range(0,int(x[2])):
                files.append(newname)        

            splitcmd(ffmpeg,path,time,duration,newname,codec)

        concat = "concat:" + '|'.join(files)

        newname = "%s/%s_concat%s"%(destination,basename,ext)
        concatcmd(ffmpeg,concat,newname,codec)

        for x in files:
            if os.path.isfile(x):
                os.remove(x)


    def concatcmd(ffmpeg,concat,name,codec):
        if 'h264' in codec and 'aac' in codec:
            #[mpegts @ 0000000002d000a0] AAC bitstream not in ADTS format and extradata missings
            #cmd = "%s -i \"%s\" -c copy -bsf:a aac_adtstoasc -f mpegts \"%s\""%(ffmpeg,concat,name)
            cmd = "%s -i \"%s\" -c copy -c:a aac -f mpegts \"%s\""%(ffmpeg,concat,name)        
        else:
            cmd = "%s -i \"%s\" -c copy \"%s\""%(ffmpeg,concat,name)

        cmdprocess(cmd)

    def cmdprocess(cmd):
        popen = subprocess.Popen(cmd,stdout=subprocess.PIPE,bufsize=1)
        
        for line in popen.stdout.readline():
            string = line.decode('utf-8')
            print(string)
            
        popen.terminate()

    분할을 원할 경우 splitvideo()에서 만들어놓은 codec_name()으로 코덱정보를 받고, pbfstuff()로 범위정보를 받은 뒤, 1/1000초 단위로 되어있는 각각의 시간을 datetime.timedelta를 통해서 변환한 뒤 time과 duration으로 받는다. 그 후 newname에 새로 생성될 파일명을 지정한 후 splitcmd()를 실행시킨다.
    거기서 코덱이 h264/aac의 경우 무손실 분할 옵션을 포함해서 명령어를 아래와 같이 만들고 cmdprocess()를 통해서 실행시키고 출력한다.

    ffmpeg경로 -ss 시작시간 -i "원본경로" -c copy -bsf:v h264_mp4toannexb -f mpegts -t 진행시간 "생성파일"

    h264/aac가 아닐 경우엔 ffmpeg경로 -ss 시작시간 -i "원본경로" -c copy -t 진행시간 "생성파일" 형태로 만들어준다.
    주의해야할 점은 반드시 -ss 시작시간이 맨 앞으로 와야한다는 것인데, 그렇지 않으면 화면이 멈춘채로 시작하는 파일이 될 것이다. 물론 이 때문에 지정해놓은 범위보다 조금 먼저 영상이 시작되지만 그래도 멈춰있는 화면을 보는 것 보다는 나을것이다.

    병합의 경우 pbf파일을 통해서 time과 duration을 설정한 뒤에 정해져있던 반복횟수만큼 임시파일을 임시파일목록에 넣은 후, splitcmd()를 통해서 파일이 만들어진다. 그 후, 임시파일 목록인 files로 concat:파일1|파일2|.... 형태의 문장을 만들어서 새로 만들 파일명과 함께 concatcmd()에 전달한다.

    여기서 코덱이 h264/aac일 경우에
    ffmpeg경로 -i "concat명령어" -c copy -c:a aac -f mpegts "생성파일"
    형태로 명령어를 만든다. 원래는 -c copy -bsf:a aac_adtstoasc -f mpegts로 해야한다고 하는데, 에러가 발생하면서 소리가 나지 않아서 -c copy -c:a aac -f mpegts로 했다.

    이후에 이 명령어로 cmdprocess()를 실행하고, files에 기록되어있는 임시파일들을 os.remove()로 삭제하는 것으로 마무리된다.
  7. TkInter로 대략적인 인터페이스만들기
    파일선택과 명령정도만 실행할 수 있는 가장 단순한 인터페이스를 TkInter로 만들면 다음과 같다. 참고로 work은 4번의 소스를 work.py로 저장한 것이다.

    try:
        from Tkinter import *
    except ImportError:
        from tkinter import *

    from tkinter import filedialog
    from work import *
    import os

    class App:
        def __init__(self,master):
            frame = Frame(master)
            frame.pack(side="top",fill='both',expand=True)

            self.label = Label(frame)
            self.label.pack(side='left',fill='x',expand=True)

            self.button1 = Button(frame,text="Select File",command=self.fileselect)
            self.button1.pack(side="right")
            
            self.f1 = Frame(master)
            self.f1.pack(side='bottom')

            self.splitbtn = Button(self.f1,text="Split",command=self.split)
            self.splitbtn.pack(side='left')
            
            self.concatbtn = Button(self.f1,text="Concatenate",command=self.concat)
            self.concatbtn.pack(side='right')

            self.pathlist = None
            self.ffmpeg = "c:/ffmpeg/bin/ffmpeg.exe"

        def fileselect(self):
            self.pathlist = filedialog.askopenfilenames()

            path = '\n'.join(self.pathlist)
            
            self.label.config(text=path)

        def split(self):
            if not self.pathlist:
                return False

            for path in self.pathlist:
                splitvideo(self.ffmpeg, path, os.path.dirname(path))

        def concat(self):
            if not self.pathlist:
                return False

            for path in self.pathlist:
                concatvideo(self.ffmpeg, path, os.path.dirname(path))
            
    root = Tk()
    app = App(root)

    if __name__ == "__main__":
        root.mainloop()
        root.destroy()

    우선은 Tk의 프레임을 위아래로 나눈 뒤, 윗 프레임에는 라벨과 파일선택용 버튼을 넣고, 아랫 프레임에는 분할과 병합을 실행할 버튼을 각각 프레임의 좌우에 배치했다.

    파일선택은 self.button1을 통해서 self.fileselect()를 호출한 뒤,
    filedialog.askopenfilenames()를 통해서 여러 파일이 선택되도록 한다.
    그 선택된 내용은 self.pathlist에 담기고, 그 내용을 join을 통해서 한줄로 만들어 self.label에 변경하도록 한다.

    분할과 병합 명령은
    self.splitbtnself.concatbtn을 통해서 각각 self.split()self.concat()을 호출한 후, self.pathlist에 내용이 있을 경우 그 내용들을 분할의 경우엔 splitvideo()에, 병합의 경우엔 concatvideo()에 전달해준다.

    만약 경로를 따로 지정하고 싶다면 버튼을 하나 더 추가한 후
    filedialog.askdirectory()를 통해서 self.concat()과 self.split() 속 os.path.dirname(path) 대신에 집어넣으면 될 것이다.
  8. 맺음말
    별것 아닌 내용이지만 내가 이것이 필요하다고 느낀 뒤 삽질했던 것만큼, 나처럼 취미로 이런 것을 하는 사람들 중에도 같은 삽질을 하는 사람이 있을테니, 그런 사람들에게 이것이 약간의 도움이라도 됐으면 좋겠다.

    포스팅을 위해 다시 만들면서 그때 그때 적당히 수정했기 때문에 일관성이 없는 부분이 많이 보일 것이다. 특히 TkInter부분.

    참고로 TkInter에 ffmpeg의 진행상황을 표시하기 위해서 해본 몇몇 시도중에서 popen의 옵션에 universal_newlines=True 를 설정하는 것이 제대로 동작한 유일한 방법이었지만, 속도문제가 생기기 때문에 추가하지 않았다.

    끝으로, 이 허접한 글을 끝까지 읽어준 사람이 있다면 감사하다는 말을 전하고 싶다.

댓글 1개:

  1. 좋은 자료 잘 보고 갑니다 큰 도움이 되엇네요

    답글삭제