Davinci Resolveでボイロ動画の字幕を自動で追加するスクリプト
まえがき
ボイロ劇場やボイロ実況動画を編集するのに自分は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\"
実行する前に以下の準備が必要です。
- 対応するセリフのwavファイルをタイムライン上に配置する。
- メディアプールに配置したい字幕の雛形となるText+クリップを置き、クリップ名を命名規約に使用したのと同じキャラクター名に変更する。
- Text+クリップを置いたディレクトリをメディアプール欄で表示した状態にしておく。
- コード先頭のAUDIO_TRACKの数値を書き換える。(書き換えたらDavinciResolveを再起動する)
- スクリプトを実行する。
これで多分動くと思います。(適当)
■追記
あ、そんなに新しくないWindowsの場合は設定でファイル名などの文字コードをUTF-8に変更しないと動かないかもしれません。
参考記事
説明
PythonじゃなくてLuaを使用したのは、Pythonで書いたら最初のインスタンスを取得するところが上手く動かなかったからです。調べたらPythonスクリプトは有償版じゃないと動かせないとかいう情報が出てきましたが、同じようなコードで普通に動くスクリプトもあったのでよくわかりません。
Luaは書いたことがなかったのでコードはわりと適当です。変な部分が色々あるかもしれません。
処理の詳細についてはコードとコード内のコメントがすべてという感じです。
紹介動画
以下の情報を主に参考にさせていただきました。
Discussion
VOICEPEAKだと生成されるファイルの後ろにプロジェクト名が付き
001-東北きりたん-きりたんぽ-新規プロジェクト.txt
のようになってしまうため、ファイル名からキャラクター名を抽出する正規表現が失敗していたので修正しました。
Windowsでのファイル名の文字コード問題について追記しました。
こんにちは。
スクリプトの最初の方でテキストファイルフォルダを指定されていますが、下記コードでタイムラインに配置されたクリップのファイルパスを取得できます。
TimelineItem:GetMediaPoolItem():GetClipProperty("File Path")
取得したファイルパスの拡張子を
.wav
→.txt
に変更すればテキストフォルダパスの書き換えを省略できるので、使いまわしやすくなるかと思います。それとPython のスクリプトが有償版で動かせる動かせないについてですが、Python がインストールして環境変数の設定が適切なら無償版でも動きます。有償版が必要になるのは外部アプリケーションから DaVinci Resolve を操作しようとする場合ですね。
Python だと
DaVinciResolveScript
モジュールを読み込むことで外部から DaVinci Resolve を操作するアプリケーションを記述できるようになるので、その辺の情報は混同されがちかもしれません。ありがとうございます!!!記事の方もたくさん参考にさせていただきました!!!
こちらこそ開発の一助になったようで嬉しいです。
コメントのアドバイスを参考に作業ディレクトリを定数で指定しなくても動的に取得出来るように改良しました。ついでにtext+を検索するために使用していたキャラクター名の定数もファイル名から動的に取得することで定義が不要になりました。