Last active
June 26, 2025 10:52
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
-- 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