Skip to content

Instantly share code, notes, and snippets.

@MikuroXina
Last active June 26, 2025 10:52
Show Gist options
  • Save MikuroXina/7d9369a32cd96b9c4fb72256454f4403 to your computer and use it in GitHub Desktop.
Save MikuroXina/7d9369a32cd96b9c4fb72256454f4403 to your computer and use it in GitHub Desktop.
Subtitle Text+ clips auto-placer for DaVinci Resolve. It needs to place audio clips manually, but automates corresponding subtitles from your template caption Text+ clip.
-- Original: https://zenn.dev/kirimin/articles/3abcada2b1646f by kirimin https://github.com/kirimin
-- convention of voice/text file: "num_character_content" + ".wav" or ".txt"
-- Replace configs here
local TEXT_DIR = "/Users/mikuroxina/Documents/Projects/Scripts/pencil-puzzles/05/voices/" -- folder contains txt and wav
local AUDIO_TRACK = 2 -- number of audio track placed character voices
local CHARACTERS = { "中国うさぎ" } -- names of character, used in file name
-- --------------------------------------------------
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)
if p == nil then
error("failed to open pipe to ls command")
end
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")
if f == nil then
error(string.format("failed to open file: %s", path))
end
local t = f:read("*all"):gsub("\r?\n", " ")
f:close()
return t
end
-- find clips by name in the media pool
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 "--unknown--"
print("clip name: " .. clipName .. ", type: " .. 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
-- initialize Resolve API
resolve = Resolve()
projectManager = resolve:GetProjectManager()
project = projectManager:GetCurrentProject()
mediaPool = project:GetMediaPool()
folder = mediaPool:GetCurrentFolder()
tl = project:GetCurrentTimeline()
-- get Text+ clips for each character from the media pool
local textPlusClips = {}
for _, char in ipairs(CHARACTERS) do
local clip = find_clip_by_name(folder, char)
if not clip then
error("clip not found in the media pool - " .. char)
end
textPlusClips[char] = clip
end
-- store of subtitle data
local subDict = {}
-- load text files in TEXT_DIR and store into subDict
local files = list_files(TEXT_DIR, "txt")
for _, file in ipairs(files) do
-- use file basename as key
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("invalid file name - " .. file)
end
end
-- add new video track to the timeline
tl:AddTrack("video")
local subIdx = tl:GetTrackCount("video")
-- get all clips in the audio track
local clips = tl:GetItemListInTrack("audio", AUDIO_TRACK)
for _, c in ipairs(clips) do
-- get basename from the audio clip name
local base = c:GetName():gsub("%.wav$", "")
-- get metadata from subDict
local meta = subDict[base]
if not meta then
error("not found subtitle txt file for - " .. base)
end
-- get template Text+ clip for the character
local textPlusClip = textPlusClips[meta.char]
if not textPlusClip then
error("not found character Text+ clip for - " .. meta.char)
end
print("found Text+ clip:", textPlusClip)
local timelineItem = nil
-- add a new Text+ clip to the timeline
local s = c:GetStart() -- start frame
local e = s + c:GetDuration() -- end frame
local timeline_fps = tonumber(project:GetSetting("timelineFrameRate"))
local clip_fps = textPlusClip:GetClipProperty("FPS")
print("audio clip's start frame:", s, "end frame:", e)
print(e - s, "length of frames")
-- configuration of a new clip
local item = {
["mediaPoolItem"] = textPlusClip,
["startFrame"] = 0, -- seek position of source media, so it should be zero
["endFrame"] = math.floor((e - s) * clip_fps / timeline_fps), -- convert to frames in the timeline
["startTimecode"] = s,
["endTimecode"] = e,
["trackType"] = "video",
["trackIndex"] = subIdx,
["recordFrame"] = math.floor(s) -- starting position
}
local result = mediaPool:AppendToTimeline({ item }) -- note: pass item in an array
print("AppendToTimeline result:", result)
for _, to_place in ipairs(result) do
timelineItem = to_place
end
if not timelineItem then
error("failed to add a clip to the timeline - " .. meta.char)
end
-- get Fusion composition to style
local fusionComp = timelineItem:GetFusionCompByIndex(1)
if not fusionComp then
error("expected Fusion composition")
end
local textNode = fusionComp:FindTool("Template")
if not textNode then
error("expected Text+ template")
end
-- apply Text+ character subtitle style
textNode:SetInput("StyledText", meta.text)
end
print("subtitle placing complete")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment