🔖

Davinci Resolveでボイロ動画の字幕を自動で追加するスクリプト

に公開
6

まえがき

ボイロ劇場やボイロ実況動画を編集するのに自分はDavinci Resolveというソフトウェアを使用しているのですが、おそらくニコニコ投稿者でDavinci Resolveを使用している人はかなり少数派で、日本語での情報もあまりないため、動画に字幕を自動で追加するスクリプトを書いてみました。

APIがあるっぽかったので簡単に出来るかと思いきや、詳細な情報が見つからなかったり何故か上手く動かなかったりすることが多くて結構たいへんだったので、書いたものを共有しておこうと思います。

こんな感じで音声クリップが大量にあってもわりと一瞬で終わります。

前提

ボイロ動画に字幕をつけるのが目的なので、各音声合成ソフトの仕様に合わせて考えます。
自分が主に使っている合成音声ソフトはA.I.VOICE2、VOICEPEAK、CeVIO AIです。いずれのソフトも音声出力時にテキストファイルの同時出力に対応しています。

それらのソフトで確認したところ、以下のような命名規約なら共通で設定出来そうでした。
{連番}-{キャラクター名}-{本文}.wav
{連番}-{キャラクター名}-{本文}.txt

出力されるこの対になるwavとtxtを元に字幕をタイムライン上に自動配置します。
事前にwavをタイムライン上に配置し終わっている状態を想定しています。

完成形

-- 音声ファイル/テキストファイルの命名規約: "番号-キャラクター名-任意の文字列(通常は本文)"

-- ★ 設定項目
local AUDIO_TRACK = 1 -- 音声を置いているオーディオトラック番号

-- --------------------------------------------------
local is_win = package.config:sub(1,1) == "\\"

local function list_files(dir, ext)
    local cmd = is_win
        and string.format('dir /b "%s\\*.%s"', dir, ext)
        or string.format('ls -1 "%s" | grep "\\.%s$"', dir, ext)
    local p   = io.popen(cmd)
    local out = {}
    for line in p:lines() do table.insert(out, line) end
    p:close()
    return out
end

local function read_text(path)
    local f = io.open(path, "r")
    local t = f:read("*all"):gsub("\r?\n"," ")
    f:close(); return t
end

-- メディアプール内のクリップを検索
local function find_clip_by_name(folder, name)
    for _, clip in ipairs(folder:GetClipList()) do
        local clipName = clip:GetClipProperty("Clip Name")
        local clipType = clip:GetClipProperty("Type") or "不明"
        print("クリップ名: " .. clipName .. ", 種類: " .. clipType)
        if clipName == name then
            return clip
        end
    end
    for _, subfolder in ipairs(folder:GetSubFolderList()) do
        local clip = find_clip_by_name(subfolder, name)
        if clip then
            return clip
        end
    end
    return nil
end

-- Resolve APIの初期化
resolve = Resolve()
projectManager = resolve:GetProjectManager()
project = projectManager:GetCurrentProject()
mediaPool = project:GetMediaPool()
folder = mediaPool:GetCurrentFolder()
tl = project:GetCurrentTimeline()

-- タイムラインのオーディオトラックからディレクトリを取得
local TEXT_DIR = nil
local audioClips = tl:GetItemListInTrack("audio", AUDIO_TRACK)
if #audioClips > 0 then
    local firstClip = audioClips[1]
    local mediaPoolItem = firstClip:GetMediaPoolItem()
    if mediaPoolItem then
        local filePath = mediaPoolItem:GetClipProperty("File Path")
        if filePath then
            -- ファイルパスからディレクトリ部分を抽出
            TEXT_DIR = filePath:match("(.*[/\\])")
            -- パスの区切り文字を統一(Windowsでも/を使用)
            TEXT_DIR = TEXT_DIR:gsub("\\", "/")
            print("作業ディレクトリを設定しました: " .. TEXT_DIR)
        end
    end
end

if not TEXT_DIR then
    print("エラー: オーディオクリップからディレクトリを取得できませんでした")
    return
end

-- オーディオクリップからキャラクター名を抽出して一意なリストを作成
local characters = {}
local characterSet = {} -- 重複を避けるためのセット

for _, clip in ipairs(audioClips) do
    local name = clip:GetName()
    local char = name:match("^%d+%-(.-)-.*$") -- 番号-キャラクター名-任意の文字列 の形式からキャラクター名を抽出
    if char and not characterSet[char] then
        characterSet[char] = true
        table.insert(characters, char)
        print("キャラクターを検出しました: " .. char)
    end
end

if #characters == 0 then
    print("エラー: オーディオクリップからキャラクター名を抽出できませんでした")
    return
end

-- メディアプールからキャラクターごとのText+クリップを取得
local textPlusClips = {}
for _, char in ipairs(characters) do
    local clip = find_clip_by_name(folder, char)
    if clip then
        textPlusClips[char] = clip
        print("Text+クリップを見つけました: " .. char)
    else
        print("警告: メディアプール内に「" .. char .. "」のクリップが見つかりません")
    end
end

-- 字幕データを格納するテーブル
local subDict = {}

-- TEXT_DIR 内のテキストファイルを読み込み、subDict に格納
local files = list_files(TEXT_DIR, "txt")
for _, file in ipairs(files) do
    -- ファイル名から拡張子を除いた全体をキーとして使用
    local base = file:gsub("%.txt$", "")
    local char = file:match("^%d+%-(.-)-.*%.txt$")
    if base and char then
        local path = TEXT_DIR .. "/" .. file
        local text = read_text(path)
        print(string.format("base: %s, char: %s, text: %s", base, char, text))
        subDict[base] = { char = char, text = text }
    else
        print("警告: ファイル名が無効です - " .. file)
    end
end

-- タイムラインに新しいビデオトラックを追加
tl:AddTrack("video")
local subIdx = tl:GetTrackCount("video")

-- オーディオトラック内のクリップを取得
local clips = tl:GetItemListInTrack("audio", AUDIO_TRACK)
for _, c in ipairs(clips) do
    -- オーディオクリップ名から拡張子を除いたベース名を取得
    local base = c:GetName():gsub("%.wav$", "")
    -- subDict からメタデータを取得
    local meta = subDict[base]
    if meta and textPlusClips[meta.char] then
        -- キャラクター名に対応する Text+ クリップを取得
        local textPlusClip = textPlusClips[meta.char]
        if not textPlusClip then
            print("エラー: Text+クリップが見つかりません - " .. meta.char)
        else
            print("Text+クリップが見つかりました:", textPlusClip)
        end

        local timelineItem = nil
        -- Text+クリップをタイムラインに配置
        if textPlusClip then
            local s = c:GetStart()  -- オーディオクリップの開始フレーム
            local e = s + c:GetDuration()  -- オーディオクリップの終了フレーム

            local timeline_fps = tonumber(project:GetSetting("timelineFrameRate"))
            local clip_fps = textPlusClip:GetClipProperty("FPS")

            print("オーディオクリップの開始フレーム:", s, "終了フレーム:", e)
            print(e - s, "フレームの長さ")
            -- クリップの配置設定
            local item = {
                ["mediaPoolItem"] = textPlusClip,
                ["startFrame"] = 0,
                ["endFrame"] = math.floor((e - s) * clip_fps / timeline_fps),  -- タイムラインのフレーム数に変換
                ["startTimecode"] = s,  -- オーディオクリップの開始位置
                ["endTimecode"] = e,  -- オーディオクリップの終了位置
                ["trackType"] = "video",  -- トラックの種類
                ["trackIndex"] = subIdx,  -- 新しく追加したトラック
                ["recordFrame"] = math.floor(s)  -- オーディオクリップの開始位置
            }
            
            local result = mediaPool:AppendToTimeline({item})  -- 注意: 配列に入れて渡す
            print("AppendToTimeline result:", result)
            for _, item in ipairs(result) do
                timelineItem = item
            end
        end

        if timelineItem then

            -- Fusion コンポジションを取得
            local fusionComp = timelineItem:GetFusionCompByIndex(1)
            if fusionComp then
                local textNode = fusionComp:FindTool("Template")
                if textNode then
                    -- Text+のテキストを変更
                    textNode:SetInput("StyledText", meta.text)
                else
                    print("警告: Text+ ノードが見つかりません")
                end
            else
                print("警告: Fusion コンポジションが見つかりません")
            end
        else
            print("エラー: タイムラインにクリップを追加できませんでした - " .. meta.char)
        end
    else
        print("警告: メタデータまたはText+クリップが見つかりません - " .. base)
    end
end

print("字幕の自動追加が完了しました")

使い方

上記のコードを.luaファイルとして保存し、以下のフォルダに配置すればDavinciResolve上からワークスペース→スクリプトを選択し実行出来ます。ちなみにprintログはコンソールに出力されます。
"C:\ProgramData\Blackmagic Design\DaVinci Resolve\Fusion\Scripts\Utility\"

実行する前に以下の準備が必要です。

  1. 対応するセリフのwavファイルをタイムライン上に配置する。
  2. メディアプールに配置したい字幕の雛形となるText+クリップを置き、クリップ名を命名規約に使用したのと同じキャラクター名に変更する。
  3. Text+クリップを置いたディレクトリをメディアプール欄で表示した状態にしておく。
  4. コード先頭のAUDIO_TRACKの数値を書き換える。(書き換えたらDavinciResolveを再起動する)
  5. スクリプトを実行する。

これで多分動くと思います。(適当)


■追記
あ、そんなに新しくないWindowsの場合は設定でファイル名などの文字コードをUTF-8に変更しないと動かないかもしれません。

参考記事
https://www.momopoem.com/?p=919

説明

PythonじゃなくてLuaを使用したのは、Pythonで書いたら最初のインスタンスを取得するところが上手く動かなかったからです。調べたらPythonスクリプトは有償版じゃないと動かせないとかいう情報が出てきましたが、同じようなコードで普通に動くスクリプトもあったのでよくわかりません。

Luaは書いたことがなかったのでコードはわりと適当です。変な部分が色々あるかもしれません。
処理の詳細についてはコードとコード内のコメントがすべてという感じです。

紹介動画
https://www.nicovideo.jp/watch/sm44988673

以下の情報を主に参考にさせていただきました。
https://deric.github.io/DaVinciResolve-API-Docs/

https://note.com/hitsugi_yukana/n/nd740a58a5bb7

Discussion

きりみんきりみん

VOICEPEAKだと生成されるファイルの後ろにプロジェクト名が付き
001-東北きりたん-きりたんぽ-新規プロジェクト.txt
のようになってしまうため、ファイル名からキャラクター名を抽出する正規表現が失敗していたので修正しました。

きりみんきりみん

Windowsでのファイル名の文字コード問題について追記しました。

火注ゆかな火注ゆかな

こんにちは。
スクリプトの最初の方でテキストファイルフォルダを指定されていますが、下記コードでタイムラインに配置されたクリップのファイルパスを取得できます。
TimelineItem:GetMediaPoolItem():GetClipProperty("File Path")
取得したファイルパスの拡張子を.wav.txtに変更すればテキストフォルダパスの書き換えを省略できるので、使いまわしやすくなるかと思います。

それとPython のスクリプトが有償版で動かせる動かせないについてですが、Python がインストールして環境変数の設定が適切なら無償版でも動きます。有償版が必要になるのは外部アプリケーションから DaVinci Resolve を操作しようとする場合ですね。
Python だとDaVinciResolveScriptモジュールを読み込むことで外部から DaVinci Resolve を操作するアプリケーションを記述できるようになるので、その辺の情報は混同されがちかもしれません。
https://note.com/hitsugi_yukana/n/n7eb9cabd90c9#911b1cac-7d26-4fef-91ff-6c83acc2054e

きりみんきりみん

ありがとうございます!!!記事の方もたくさん参考にさせていただきました!!!

きりみんきりみん

コメントのアドバイスを参考に作業ディレクトリを定数で指定しなくても動的に取得出来るように改良しました。ついでにtext+を検索するために使用していたキャラクター名の定数もファイル名から動的に取得することで定義が不要になりました。