動画自動「沈黙CUT」と「テロップ起こし」のコードを整理してみた【動画編集らくらくシリーズ④】

動画編集らくらくシリーズ①~③で作成したコードを、ワンランク上げようと思います!
と、いうことで今回は、”機能ごと”各モジュールとメソッドに分けてみました!!!
実際にモジュール、メソッドを分ける事で、
検証がやりやりやすくなる
・パーツ化しているので、他での流用可能になる
といったメリットを実感できました。

さらに、UIをアップするため、自動ディレクトリ作成や作業ディレクトリの削除、編集対象のファイル選択機能なども追加しましたので、併せて記載します。

今回対象となるコードの元は以下の3つの記事になります。
今回の記事だけでも完結しますが、ご興味のある方は、ぜひご覧くださいm(__)m
読んでくださるとうれしいですw

スポンサーリンク

開発環境

OS:Windows10
Python 3.7.4
環境:Anaconda
エディタ:VScode

ディレクトリ構造

└─program
controller.py
entity.py
service.py

コード全体

entity class

entity.pyでは、controllerで使用する変数や値の定義を指定しています。

【2021年3月25日追記】
entityクラスは必要ないことが分かりました>< entityクラスの中で定義した項目は、controllerクラスの上部にコピペしてくださいm(__)m
勉強の履歴として、entityクラスの記述は残しておきます。日々勉強ですね!

"""
controllerで使用する値の定義
"""
import os

# 実行中のディレクトリを取得し、定数に挿入
WORK_DIRECTORY = os.getcwd()

# mainとなる最上位フォルダ
work_folder = WORK_DIRECTORY + "/" + "material"

"""
動画関連
"""
# 沈黙削除された動画が格納される場所
movie_junp_cut = work_folder + "/" + "JumpCut"
# 沈黙カットされたパーツの拡張子
movie_cut_files = movie_junp_cut + "/*.mp4"

# 沈黙カット後の結合ファイル名
out_path = "join_out.mp4"

"""
音声関連
"""
# 音声文字起こしの作業スペース
audio_work_space = work_folder + "/" + "AudioText"

# 文字起こし対象の元動画
text_video_path = work_folder + "/" + out_path
# mp3の格納場所
audio_mp3 = audio_work_space + "/" + "audio.mp3"
# wavの格納場所
audio_wav = audio_work_space + "/" + "audio.wav"

# wavの分割間隔(単位[sec])
time = 60
# wav分割後ファイルの格納場所
wav_cut_dir = audio_work_space + "/" + "wav_cut/"

"""
テキスト関連
"""
# txtの格納場所
text_date = audio_work_space + "/" + "telop.txt"
# csvの格納場所
csv_date = work_folder + "/" + "telop.csv"

"""
完了設定
ビープ音&アラート
"""
hz = 2000  # ビープ音の周波数
sec = 500  # ビープ音の継続時間(単位[ms])
window_title = "動画編集完了のお知らせ"  # alert title
# alert content
window_connent = (
    "沈黙の削除とテロップ起こしが終わりました。"
    + "\n"
    + "【完成物のパス】"
    + "\n"
    + "結合動画:"
    + text_video_path
    + "\n"
    + "テロップCSV:"
    + csv_date
    + "\n"
    + "【作業スペースを削除しますか?】"
    + "\n"
    + "※「はい」を選択すると上記素材も削除されます。安全な場所に移してからバルスしてください。"
)

service class

service.pypでは、関数を作成します。

"""
関数の定義
"""
import tkinter.filedialog, tkinter.messagebox  # ファイルダイアログ
import shutil  # 対象動画のコピー

import subprocess
import os
import glob

import speech_recognition as sr  # 音声認識

# wav分割
import wave
import struct
from scipy import fromstring, int16
import numpy as np
import math

import pandas as pd  # csv化
import winsound  # ビープ音
from tkinter import messagebox  # aleart

"""
ファイルダイアログ
"""
def target_movie(work_folder):
    # 動画の選択
    file_type = [("", "*.mp4")]
    file_dir = os.path.abspath(os.path.dirname(__file__))
    tkinter.messagebox.showinfo("動画編集らくらくツール", "対象のmp4ファイルを選択してください。")
    file_path = tkinter.filedialog.askopenfilename(
        filetypes=file_type, initialdir=file_dir
    )
    print(file_path)

    # 指定動画を作業フォルダにコピー
    shutil.copy(file_path, work_folder + "/" + "sample.mp4")


"""
動画の沈黙部分カット
"""
# 動画素材の取得
def mk_movieList(work_folder):
    files = os.listdir(work_folder)
    files = [x for x in files if x[-4:] == ".mp4"]
    files = [x for x in files if x[0] != "."]
    return files


# 動画沈黙の算出
def mk_starts_ends(wk_dir, movie):
    os.chdir(wk_dir)
    output = subprocess.run(
        [
            "ffmpeg",
            "-i",
            movie,
            "-af",
            "silencedetect=noise=-33dB:d=0.6",
            "-f",
            "null",
            "-",
        ],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )
    print(output)
    s = str(output)
    lines = s.split("\\n")
    time_list = []
    for line in lines:
        if "silencedetect" in line:
            words = line.split(" ")
            for i in range(len(words)):
                if "silence_start" in words[i]:
                    time_list.append(float((words[i + 1]).replace("\\r", "")))
                if "silence_end" in words[i]:
                    time_list.append(float((words[i + 1]).replace("\\r", "")))
    print(time_list)
    starts_ends = list(zip(*[iter(time_list)] * 2))
    return starts_ends


# 動画ファイルの分割書き出し
def mk_jumpcut(wk_dir, movie, starts_ends):
    os.chdir(wk_dir)
    for i in range(len(starts_ends) - 1):
        movie_name = movie.split(".")
        splitfile = "./JumpCut/" + movie_name[0] + "_" + str(i) + ".mp4"
        print(splitfile)
        output = subprocess.run(
            [
                "ffmpeg",
                "-i",
                movie,
                "-ss",
                str(starts_ends[i][1]),
                "-t",
                str(starts_ends[i + 1][0] - starts_ends[i][1]),
                splitfile,
            ],
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
        )
        print(output)


# 分割後の動画結合
def join_movie(movie_cut_files, out_path):
    videos = glob.glob(movie_cut_files)
    print(videos)
    # join対象のmovie list
    with open("JumpCut/temp.txt", "w") as fp:
        lines = [f"file '{os.path.split(line)[1]}'" for line in videos]
        # 1,10,11,~のようになってしまうのを防止。並び替え
        lineList = sorted(lines, key=len)
        fp.write("\n".join(lineList))

    output = subprocess.run(
        ["ffmpeg", "-f", "concat", "-i", "JumpCut/temp.txt", "-c", "copy", out_path],
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )
    print(output)


"""
音声自動起こし
"""
# mp4からmp3への変換
def chage_mp3(text_video_path, audio_mp3):
    out_audio = subprocess.run(
        [
            "ffmpeg",
            "-i",
            text_video_path,
            "-acodec",
            "libmp3lame",
            "-ab",
            "256k",
            audio_mp3,
        ]
    )
    print(out_audio)
    return "success"


# mp3からwavファイルへの変換
def change_wav(audio_mp3, audio_wav):
    transcript = subprocess.run(
        [
            "ffmpeg",
            "-i",
            audio_mp3,
            "-vn",
            "-ac",
            "1",
            "-ar",
            "44100",
            "-acodec",
            "pcm_s16le",
            "-f",
            "wav",
            audio_wav,
        ]
    )
    print(transcript)
    return "success"


# wavファイルを指定の間隔に分割
def cut_wav_info(audio_wav, time, wav_cut_dir):
    # wav read
    wr = wave.open(audio_wav, "r")

    # wav情報を取得
    ch = wr.getnchannels()
    width = wr.getsampwidth()
    fr = wr.getframerate()
    fn = wr.getnframes()
    total_time = 1.0 * fn / fr
    integer = math.floor(total_time)
    t = int(time)
    frames = int(ch * fr * t)
    # 小数点切り上げ(1分に満たない最後のシーンを出力するため)
    num_cut = int(math.ceil(integer / t))
    data = wr.readframes(wr.getnframes())
    wr.close()

    X = np.frombuffer(data, dtype=int16)
    for i in range(num_cut):
        print(i)
        outf = wav_cut_dir + str(i) + ".wav"
        start_cut = int(i * frames)
        end_cut = int(i * frames + frames)
        print(start_cut)
        print(end_cut)
        Y = X[start_cut:end_cut]
        outd = struct.pack("h" * len(Y), *Y)

        # 書き出し
        ww = wave.open(outf, "w")
        ww.setnchannels(ch)
        ww.setsampwidth(width)
        ww.setframerate(fr)
        ww.writeframes(outd)
        ww.close()
    return num_cut


# 分割したwavから文字起こし
def auto_transcription(num_cut, wav_cut_dir, text_date):
    r = sr.Recognizer()
    for i in range(num_cut):
        with sr.AudioFile(wav_cut_dir + str(i) + ".wav") as source:
            audio = r.record(source)
            # exceptionをthrowさせる
            try:
                text = r.recognize_google(audio, language="ja-JP").replace(" ", "\n")
                print(text)
            except sr.RequestError:
                text = "error wav No." + str(i) + "API unavailable" + "\n"
                print(text)
            except sr.UnknownValueError:
                text = "error wav No." + str(i) + "Unable to recognize speech" + "\n"
                print(text)
        # txt抽出
        open_text = open(text_date, "a", encoding="utf-8")
        if i == 0:
            first_text = "telop" + "\n" + text
            open_text.write(first_text)
            open_text.close()
        else:
            open_text.write(text)
            open_text.close()


# txtをCSVに変換
def change_csv(text_date, csv_date):
    read_text = pd.read_csv(text_date)
    read_text.to_csv(csv_date, index=True, index_label="id")


"""
完了の処理
ビープ音・アラート・ファイル一括削除
"""


def complete(hz, sec, window_title, window_connent, work_folder):
    winsound.Beep(hz, sec)
    res = messagebox.askquestion(window_title, window_connent)
    if res == "yes":
        shutil.rmtree(work_folder)
    else:
        messagebox.showinfo("処理結果", "作業フォルダの削除を中止しました。" + "\n" + "自分で消してね!")

controller class

controller.pypでは、関数を実行させます。

"""
実行ファイル
"""

import os
import entity  # entityモジュールの呼び出し
import service  # serviceモジュールの呼び出し


# 1 作業フォルダの自動作成
if not os.path.exists(entity.work_folder):
    os.makedirs(entity.work_folder)

if not os.path.exists(entity.movie_junp_cut):
    os.makedirs(entity.movie_junp_cut)

if not os.path.exists(entity.audio_work_space):
    os.makedirs(entity.audio_work_space)

if not os.path.exists(entity.wav_cut_dir):
    os.makedirs(entity.wav_cut_dir)

# 2 動画ファイルの選択
service.target_movie(entity.work_folder)

# 3 動画の沈黙削除
os.chdir(entity.work_folder)
wk_dir = os.path.abspath(".")
movie_list = service.mk_movieList(entity.work_folder)
for movie in movie_list:
    print(movie)
    starts_ends = service.mk_starts_ends(wk_dir, movie)
    print(starts_ends)
    service.mk_jumpcut(wk_dir, movie, starts_ends)
    service.join_movie(entity.movie_cut_files, entity.out_path)
    print(entity.movie_cut_files, entity.out_path)

# 4 動画から音声ファイルへの変換
proces_level_mp3 = service.chage_mp3(entity.text_video_path, entity.audio_mp3)
while proces_level_mp3 == ("success"):  # mp3生成のタイムラグ対処
    proces_level_wav = service.change_wav(entity.audio_mp3, entity.audio_wav)
    print("change wav complete")
    if proces_level_wav == "success":
        print("break")
        break

# 5 wavファイルの分割とテキスト起こし
num_cut = service.cut_wav_info(entity.audio_wav, entity.time, entity.wav_cut_dir)
service.auto_transcription(num_cut, entity.wav_cut_dir, entity.text_date)

# 6 txtをcsvに変換
service.change_csv(entity.text_date, entity.csv_date)

# 7 完了の処理
service.complete(
    entity.hz,
    entity.sec,
    entity.window_title,
    entity.window_connent,
    entity.work_folder,
)

追加機能・初期コードから変更した箇所

こまごましたところでは、変数名を識別しやすいようにリネームしたり、戻り値を追加したりしているのですが、そのような細かな部分ではなく、大きく機能が変わったところのみ記載します。

作業フォルダの自動生成

現在のディレクトリを自動的に認識する事により、「現在地の直下」に作業フォルダを生成するようにしました。

現在の位置を絶対パスで取得
WORK_DIRECTORY = os.getcwd()

if not os.path.exists(entity.work_folder):
    os.makedirs(entity.work_folder)

if not os.path.exists(entity.movie_junp_cut):
    os.makedirs(entity.movie_junp_cut)

if not os.path.exists(entity.audio_work_space):
    os.makedirs(entity.audio_work_space)

if not os.path.exists(entity.wav_cut_dir):
    os.makedirs(entity.wav_cut_dir)

編集したい動画の選択

tkinterのfiledialogを使用して、ファイルダイアログを表示させるように変更しました。
ファイルダイアログ上で選択された動画ファイルは、先ほど自動生成した作業用フォルダにコピーされます。

def target_movie(work_folder):
    # 動画の選択
    file_type = [("", "*.mp4")]
    file_dir = os.path.abspath(os.path.dirname(__file__))
    tkinter.messagebox.showinfo("動画編集らくらくツール", "対象のmp4ファイルを選択してください。")
    file_path = tkinter.filedialog.askopenfilename(
        filetypes=file_type, initialdir=file_dir
    )
    print(file_path)

ffmpegの仕様上、日本語のタイトルがついたデータは読み込めないため、動画ファイルをコピーするタイミングで、リネームをします。

shutil.copy(file_path, work_folder + "/" + "sample.mp4")

recognize_googleのExceptionをthrowさせる

動画編集らくらくシリーズ③でwavファイルの分割を実装しました。
いくつか音源を試してみたのですが、ところどころ「本当に日本語か?!話している内容がわからないぜ!」とか「ノイズすごいな。おい、、、」とエラーがでてしまうことが分かりました。
これらを回避するために、throwする記述を追加しました。
参考記事:The Ultimate Guide To Speech Recognition With Python

文字起こしができなかった対象のファイルを特定できるよう、該当ファイル番号のログを残しています。

try:
   text = r.recognize_google(audio, language="ja-JP").replace(" ", "\n")
   print(text)
except sr.RequestError:
    text = "error wav No." + str(i) + "API unavailable"
    print(text)
except sr.UnknownValueError:
    text = "error wav No." + str(i) + "Unable to recognize speech"

まず、前提として使用しているrecognize_googleはテスト用であって正式に使う場合は、GoogleのAPIを取得する事をお勧めします。

全工程完了後に作業フォルダを削除(選択あり)

tkinterのmessagebox「askquestion」を使用して、作業ディレクトリ内のディレクトリとファイルを全て削除するか選択します。

askquestionの戻り値は
はい = yes
いいえ = no
です。

def complete(hz, sec, window_title, window_connent, work_folder):
    winsound.Beep(hz, sec)
    res = messagebox.askquestion(window_title, window_connent)
    if res == "yes":
        shutil.rmtree(work_folder)
    else:
        messagebox.showinfo("処理結果", "作業フォルダの削除を中止しました。" + "\n" + "自分で消してね!")

最後に

無事(?)にpythonを用いて動画の沈黙とテロップ起こしができました!
各メソッドに分けるといった作業では、javaを思い出しましたwww
クラスのつけ方とか、javaぽいかもしれません。。。

自動テロップの精度は素材にもよりますが、最終的には人的リソースが必要そうです。
ライブラリ無料だしね!やむなしです!

次回記事では、プログラムを書かなくても「自動的にテロップ起こしができる方法」を書きたいと思います。
※プログラムまったく関係のない記事となりますm(__)m

タイトルとURLをコピーしました