DaVinci Resolveで字幕の自動追加

追記
後から気づきましたが、この方法だと字幕が後から編集できません・・。
`captionFusionCompTools.StyledText = string.gsub(captionText, '\\n', '\n')` とりあえずこんな感じで改行を事前に入れるようにします。

# 前置き

DaVinci ResolveとVOICEVOXで動画を作っています。作業の流れは以下の通り。

1. スプレッドシートで台本作成
2. 台本を元にVOICEVOXで音声ファイル作成
3. DaVinci Resolveに音声ファイルと字幕を打ち込む

最後のステップについて、音声ファイルは全選択してDaVinci Resolveに放り込めばOKですが、字幕については普通にやると 1) テキストエフェクトを作成して 2) セリフを打ち込んで 3) クリップの長さを音声ファイルと合わせる という作業が必要になりセリフは100程度あるのでやってられません。この手順を自動化したい。

調べたらこちらの方が自動化スクリプトを作成されていました。

https://qiita.com/tetsubo/items/7f20767054708864d698

ただ、「字幕の表示時間を変更できない」という問題が指摘されており、100個ある字幕クリップを音声の長さに合わせる作業は虚無なのでなんとかできないか色々トライしてみたところ、DaVinci Resolve APIのアップデートとハックを組み合わせることで解決できたのでやり方をまとめます。

# スクリプト

```lua
print("LoadCaptionsWithDuration.lua start")

function mysplit(inputstr, sep)
  if sep == nil then
    sep = "%s"
  end
  local t = {}
  for str in string.gmatch(inputstr, "([^"..sep.."]+)") do
    table.insert(t, str)
  end
  return t
end

function getLastElement(t)
  return t[#t]
end

function getIndexNumber(str)
  return tonumber(string.match(str, "^%d+"))
end

function timecodeToFrames(timecode, fps)
  local h, m, s, f = string.match(timecode, "(%d+):(%d+):(%d+):(%d+)")
  return (tonumber(h) * 3600 + tonumber(m) * 60 + tonumber(s)) * fps + tonumber(f)
end

function sleep(n)
  os.execute("sleep " .. tonumber(n))
end

projectManager = resolve:GetProjectManager()
project = projectManager:GetCurrentProject()
timeline = project:GetCurrentTimeline()
mediaPool = project:GetMediaPool()
rootFolder = mediaPool:GetRootFolder()
subfolders = rootFolder:GetSubFolderList()
fps = 30

function getSubFolderByName(folderName)
  for i = 1, #subfolders do
    if subfolders[i]:GetName() == folderName then
      return subfolders[i]
    end
  end
  return nil
end

function getTrackIndexByName(trackType, trackName)
  local trackCount = timeline:GetTrackCount(trackType)
  for i = 1, trackCount do
    if timeline:GetTrackName(trackType, i) == trackName then
      return i
    end
  end
  return nil
end

function insertCaptionsAndVoices(captionList, captionTrackIndex, voiceTrackIndex)
  voicesFolder = getSubFolderByName("Voices")
  if voicesFolder == nil then
    print("Voices folder not found")
    return
  end
  mediaPool:SetCurrentFolder(voicesFolder)
  
  voiceClipList = mediaPool:GetCurrentFolder():GetClipList()
  voiceClipTable = {}
  for i = 1, #voiceClipList do
    voiceClipTable[getIndexNumber(voiceClipList[i]:GetName())] = voiceClipList[i]
  end

  captionsFolder = getSubFolderByName("Captions")
  if captionsFolder == nil then
    captionsFolder = mediaPool:AddSubFolder(rootFolder, "Captions")
  end
  mediaPool:SetCurrentFolder(captionsFolder)

  local offset = timecodeToFrames(timeline:GetStartTimecode(), fps)

  for i = 1, #captionList, 3 do
    local voiceIndex = tonumber(captionList[i])
    local characterName = captionList[i + 1]
    local captionText = captionList[i + 2]

    local captionFusionTitle = timeline:InsertFusionTitleIntoTimeline("Text+")
    local captionFusionCompTools = captionFusionTitle:GetFusionCompByIndex(1):GetToolList()[1]
    captionFusionCompTools.StyledText = captionText
    -- 字幕の属性の設定
    -- captionFusionCompTools.Size = ...

    local captionFusionClip = timeline:CreateFusionClip(captionFusionTitle)
    timeline:DeleteClips({captionFusionClip})

    local captionClipItem = getLastElement(mediaPool:GetCurrentFolder():GetClipList())

    local voiceClip = voiceClipTable[voiceIndex]
    local voiceClipDuration = timecodeToFrames(voiceClip:GetClipProperty()["Duration"], fps)

    mediaPool:AppendToTimeline({{
      mediaPoolItem = captionClipItem,
      startFrame = 0,
      endFrame = voiceClipDuration,
      recordFrame = offset,
      mediaType = 1, -- Video only
      trackIndex = captionTrackIndex
    }})

    mediaPool:AppendToTimeline({{
      mediaPoolItem = voiceClip,
      startFrame = 0,
      endFrame = voiceClipDuration,
      recordFrame = offset,
      mediaType = 2, -- Audio only
      trackIndex = voiceTrackIndex
    }})

    offset = offset + voiceClipDuration
    
    sleep(0.1)
  end
end

-- Show dialog

local ui = fu.UIManager
local disp = bmd.UIDispatcher(ui)
local width,height = 400,200
 
win = disp:AddWindow({
  ID = 'Dialog',
  WindowTitle = 'Load Captions',
  Geometry = { 100, 100, width, height },
  Spacing = 5,
 
  ui:VGroup{
    ID = 'root',
   
    ui:Label{ID = 'L', Text = 'Caption Loader'},

    ui:TextEdit{ID='CaptionList', Text = '', PlaceholderText = 'Character name and Caption text'},

    ui:HGroup{
      ui:Button{ID = 'ButtonOK', Text = 'OK'},
      ui:Button{ID = 'ButtonCancel', Text = 'Cancel'},
    }
  },
})
 
local captionData

itm = win:GetItems()

function win.On.ButtonOK.Clicked(ev)
  captionData = itm.CaptionList.PlainText
  disp:ExitLoop()
end

function win.On.ButtonCancel.Clicked(ev)
  -- Only close the dialog
  disp:ExitLoop()
end

function win.On.Dialog.Close(ev)
  disp:ExitLoop()
end
 
win:Show()
disp:RunLoop()
win:Hide()

-- Insert captions

captionData = string.sub(captionData, 2, -2)
local captionList = mysplit(captionData, "\n")

local captionTrackIndex = getTrackIndexByName("video", "Caption")
if captionTrackIndex == nil then
  timeline:AddTrack("video")
  timeline:SetTrackName("video", timeline:GetTrackCount("video"), "Caption")
  captionTrackIndex = getTrackIndexByName("video", "Caption")
end

local voiceTrackIndex = getTrackIndexByName("audio", "Voice")
if voiceTrackIndex == nil then
  timeline:AddTrack("audio", "mono")
  timeline:SetTrackName("audio", timeline:GetTrackCount("audio"), "Voice")
  voiceTrackIndex = getTrackIndexByName("audio", "Voice")
end

insertCaptionsAndVoices(captionList, captionTrackIndex, voiceTrackIndex)

```

手元の環境(DaVinci Resolve 18.6)でとりあえず動いたものです。UIなど非常に適当、FPSも固定です。

台本はスプレッドシートからコピペしたものを扱えるような感じです。音声ファイルはVoices以下に配置してメディアプールに追加し、さらに各セリフと音声ファイルの対応が取れるようにしておく必要があります。

# 説明

https://note.com/hitsugi_yukana/n/na5f6617532f1 によると、

> 2023年の5月頃のアップデートで、MediaPool:AppendToTimeline()は指定の再生時間へ追加できるようになりました。

とのことなので、これを利用します。ただ、`Timeline:InsertFusionTitleIntoTimeline()`で作った字幕クリップは`TimelineItem`なので`MediaPool:AppendToTimeline()`が使えません。なので、一旦`Timeline:CreateFusionClip()`でMediaPoolに追加してから`MediaPool:AppendToTimeline()`をします。手順としては以下になります。

1. `Timeline:InsertFusionTitleIntoTimeline()`でFusionテキストの作成と字幕属性のセット
2. 作ったFusionテキストを`Timeline:CreateFusionClip()`でFusionClipにしてMediaPoolに追加
3. `Timeline:DeleteClips()`で最初に作ったFusionテキストをタイムラインから削除（不要かつ邪魔なので）
4. MediaPoolに追加したFusionClipを`MediaPool:AppendToTimeline()`でフレーム情報を含めてタイムラインに追加

`MediaPool:AppendToTimeline()`はフレーム情報として`startFrame`、`endFrame`、`recordFrame`の３つを指定できます。正直意味がわかっていませんが、次のように値を与えることでうまくいきました。

```
startFrame = 0
endFrame = 音声ファイルのフレーム数
recordFrame = 字幕クリップを挿入したい位置
```

また、追加したいトラックも指定できるので便利です。

# 感想

謎ハックすぎて良いとか悪いとかよくわかりません。DaVinci ResolveのAPIが物足りないと思いました。

# Reference

情報がネット上に散逸していて開発しにくいDaVinci Resolve APIの利用法ですが、DaVinci Resolveと一緒にインストールされる以下のREADME.txtを見るのが一番良さそうです。

`C:\ProgramData\Blackmagic Design\DaVinci Resolve\Support\Developer\Scripting\README.txt`
`/Library/Application Support/Blackmagic Design/DaVinci Resolve/Developer/Scripting/README.txt`

こちらも参考にしました。ありがとうございます。

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