--[[
Krabby Batch Timeline Export

Created by Krabby Paddy
https://krabbypaddy.com

Queues or renders every timeline selected in the current Media Pool bin using
a saved user render preset. Built-in Quick Export options are intentionally not
included; create a custom preset on Resolve's Deliver page first.

Install:
  macOS:
    ~/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Utility/

Run from Resolve:
  Workspace > Scripts > Utility > KrabbyBatchTimelineExport
]]

local WINDOW_ID = "KrabbyBatchTimelineExportWindow"
local DEFAULT_EXPORT_FOLDER = (os.getenv("HOME") or ".") .. "/Desktop"

local resolve = nil
if Resolve then
  resolve = Resolve()
end

if not resolve and bmd then
  resolve = bmd.scriptapp("Resolve")
end

if not resolve then
  error("Could not connect to DaVinci Resolve.")
end

local fusion = resolve:Fusion()
if not fusion and bmd then
  fusion = bmd.scriptapp("Fusion")
end

if not fusion then
  error("Could not connect to Fusion UI Manager.")
end

local ui = fusion.UIManager
local dispatcher = bmd.UIDispatcher(ui)
local win = nil
local presetNames = {}
local renderStatusTimer = nil
local activeRender = nil

local function trim(value)
  if value == nil then
    return ""
  end

  return tostring(value):match("^%s*(.-)%s*$")
end

local function lower(value)
  return string.lower(trim(value))
end

local function item(id)
  return win and win:Find(id) or nil
end

local function setItemText(id, text)
  local target = item(id)

  if not target then
    return
  end

  target.PlainText = text or ""
  target.Text = text or ""
end

local function getItemText(id)
  local target = item(id)

  if not target then
    return ""
  end

  return trim(target.Text or target.PlainText)
end

local function isChecked(id)
  local target = item(id)
  return target and target.Checked or false
end

local function rawCount(source)
  local count = 0

  for _ in pairs(source or {}) do
    count = count + 1
  end

  return count
end

local function sortedUnique(values)
  local result = {}
  local seen = {}

  for _, value in ipairs(values or {}) do
    local clean = trim(value)
    local key = lower(clean)

    if clean ~= "" and not seen[key] then
      seen[key] = true
      table.insert(result, clean)
    end
  end

  table.sort(result, function(a, b)
    return lower(a) < lower(b)
  end)

  return result
end

local function getContext()
  local projectManager = resolve:GetProjectManager()
  local project = projectManager and projectManager:GetCurrentProject() or nil

  if not project then
    error("Open a Resolve project before running this script.")
  end

  local mediaPool = project:GetMediaPool()

  if not mediaPool then
    error("Could not access the project Media Pool.")
  end

  return projectManager, project, mediaPool
end

local function safeCall(object, methodName)
  if not object or not object[methodName] then
    return nil
  end

  local ok, result = pcall(function()
    return object[methodName](object)
  end)

  if ok then
    return result
  end

  return nil
end

local function markValue(mark, key)
  if type(mark) ~= "table" then
    return nil
  end

  local value = mark[key]

  if value == nil then
    value = mark[string.upper(string.sub(key, 1, 1)) .. string.sub(key, 2)]
  end

  if value == nil and key == "in" then
    value = mark.markIn or mark.MarkIn
  end

  if value == nil and key == "out" then
    value = mark.markOut or mark.MarkOut
  end

  value = tonumber(value)

  if value then
    return math.floor(value)
  end

  return nil
end

local function markRangeFrom(mark)
  local markTypeOrder = { "all", "video", "audio" }

  for _, markType in ipairs(markTypeOrder) do
    local typedMark = type(mark) == "table" and mark[markType] or nil
    local markIn = markValue(typedMark, "in")
    local markOut = markValue(typedMark, "out")

    if markIn and markOut and markOut >= markIn then
      return markIn, markOut, markType
    end
  end

  local markIn = markValue(mark, "in")
  local markOut = markValue(mark, "out")

  if markIn and markOut and markOut >= markIn then
    return markIn, markOut, "timeline"
  end

  return nil, nil, nil
end

local function getTimelineMarkRange(timeline)
  if not timeline or not timeline.GetMarkInOut then
    return nil, nil, nil
  end

  local ok, mark = pcall(function()
    return timeline:GetMarkInOut()
  end)

  if not ok then
    return nil, nil, nil
  end

  return markRangeFrom(mark)
end

local function objectId(object)
  local id = safeCall(object, "GetUniqueId")

  if trim(id) ~= "" then
    return trim(id)
  end

  id = safeCall(object, "GetMediaId")

  if trim(id) ~= "" then
    return trim(id)
  end

  return tostring(object)
end

local function collectObjects(source)
  local objects = {}
  local seen = {}

  local function add(candidate)
    local candidateType = type(candidate)

    if candidate == nil or candidateType == "number" or candidateType == "string" or candidateType == "boolean" then
      return
    end

    local key = objectId(candidate)

    if seen[key] then
      return
    end

    seen[key] = true
    table.insert(objects, candidate)
  end

  for key, value in pairs(source or {}) do
    add(value)
    add(key)
  end

  return objects
end

local function selectedTimelines(project, mediaPool)
  local selectedItems = collectObjects(mediaPool:GetSelectedClips() or {})
  local selectedIds = {}

  for _, selectedItem in ipairs(selectedItems) do
    selectedIds[objectId(selectedItem)] = true
  end

  local timelines = {}
  local timelineCount = project:GetTimelineCount() or 0

  for index = 1, timelineCount do
    local timeline = project:GetTimelineByIndex(index)

    if timeline then
      local mediaPoolItem = safeCall(timeline, "GetMediaPoolItem")

      if mediaPoolItem and selectedIds[objectId(mediaPoolItem)] then
        table.insert(timelines, timeline)
      end
    end
  end

  return timelines, #selectedItems
end

local function addPresetName(names, candidate)
  if type(candidate) == "string" or type(candidate) == "number" then
    table.insert(names, tostring(candidate))
    return
  end

  if type(candidate) ~= "table" then
    return
  end

  local fields = {
    "Name",
    "name",
    "PresetName",
    "presetName",
    "Preset Name",
    "preset_name",
  }

  for _, field in ipairs(fields) do
    if trim(candidate[field]) ~= "" then
      table.insert(names, candidate[field])
      return
    end
  end
end

local function getUserRenderPresetNames(project)
  local rawPresets = project:GetRenderPresetList() or {}
  local names = {}

  for key, value in pairs(rawPresets) do
    if type(value) == "table" then
      local previousCount = #names
      addPresetName(names, value)

      if #names == previousCount and type(key) == "string" then
        addPresetName(names, key)
      end
    elseif type(key) == "string" then
      addPresetName(names, key)
    else
      addPresetName(names, value)
    end
  end

  return sortedUnique(names), rawCount(rawPresets)
end

local function getSelectedPresetName()
  local combo = item("preset_combo")

  if not combo then
    return ""
  end

  local index = (tonumber(combo.CurrentIndex) or -1) + 1
  return presetNames[index] or trim(combo.CurrentText)
end

local function chooseFolderDialog()
  local currentPath = getItemText("export_folder")

  if currentPath == "" then
    currentPath = DEFAULT_EXPORT_FOLDER
  end

  local attempts = {
    function()
      return fusion:RequestDir(currentPath)
    end,
    function()
      return fusion:RequestDir()
    end,
  }

  for _, attempt in ipairs(attempts) do
    local ok, result = pcall(attempt)

    if ok and trim(result) ~= "" then
      return trim(result)
    end
  end

  return ""
end

local function timelineNames(timelines)
  local names = {}

  for _, timeline in ipairs(timelines or {}) do
    table.insert(names, trim(timeline:GetName()))
  end

  return names
end

local function refreshSelectionSummary()
  local ok, message = pcall(function()
    local _, project, mediaPool = getContext()
    local timelines, selectedItemCount = selectedTimelines(project, mediaPool)
    local lines = {
      string.format(
        "%d selected timeline%s found from %d selected Media Pool item%s.",
        #timelines,
        #timelines == 1 and "" or "s",
        selectedItemCount,
        selectedItemCount == 1 and "" or "s"
      ),
    }

    for _, name in ipairs(timelineNames(timelines)) do
      table.insert(lines, "- " .. name)
    end

    if #timelines == 0 then
      table.insert(lines, "Select timeline items in a Media Pool bin, then click Refresh.")
    end

    return table.concat(lines, "\n")
  end)

  setItemText("selection_summary", ok and message or tostring(message))
end

local function refreshPresets()
  local combo = item("preset_combo")

  if not combo then
    return
  end

  local previousName = getSelectedPresetName()
  combo:Clear()
  presetNames = {}

  local ok, names, rawPresetCount = pcall(function()
    local _, project = getContext()
    local result, rawCountValue = getUserRenderPresetNames(project)
    return result, rawCountValue
  end)

  if not ok then
    setItemText("preset_note", "Could not read saved render presets. " .. tostring(names))
    return
  end

  presetNames = names
  local selectedIndex = 0

  for index, name in ipairs(presetNames) do
    combo:AddItem(name)

    if name == previousName then
      selectedIndex = index - 1
    end
  end

  if #presetNames > 0 then
    combo.CurrentIndex = selectedIndex
    setItemText(
      "preset_note",
      string.format(
        "%d saved user preset%s available. Built-in Quick Export options are excluded.",
        #presetNames,
        #presetNames == 1 and "" or "s"
      )
    )
  else
    setItemText(
      "preset_note",
      string.format(
        "No saved user render presets found (%d raw preset entries). Create one on the Deliver page, then click Refresh presets.",
        rawPresetCount or 0
      )
    )
  end
end

local function renderStatusDetails(project, jobId)
  local ok, status = pcall(function()
    return project:GetRenderJobStatus(jobId)
  end)

  if not ok or type(status) ~= "table" then
    return "", 0
  end

  local statusText = lower(
    status.JobStatus or status.Status or status.status or status.jobStatus
  )
  local completion = tonumber(
    status.CompletionPercentage
      or status.Completion
      or status.PercentComplete
      or status.completionPercentage
  ) or 0

  return statusText, math.max(0, math.min(100, completion))
end

local function statusIsComplete(statusText)
  return statusText == "complete"
    or statusText == "completed"
    or statusText == "success"
    or statusText == "succeeded"
end

local function statusIsFailed(statusText)
  return statusText:find("fail", 1, true) ~= nil
    or statusText:find("cancel", 1, true) ~= nil
    or statusText:find("error", 1, true) ~= nil
end

local function finishRenderStatus(message)
  if renderStatusTimer then
    renderStatusTimer:Stop()
  end

  activeRender = nil

  local runButton = item("run_export")
  if runButton then
    runButton.Enabled = true
  end

  setItemText("result_summary", message)
end

local function updateRenderStatus()
  if not activeRender then
    return
  end

  local project = activeRender.project
  local jobs = activeRender.jobs
  local completedCount = 0
  local failedCount = 0
  local activeIndex = nil
  local activePercent = 0

  for index, job in ipairs(jobs) do
    local statusText, completion = renderStatusDetails(project, job.id)

    if statusIsComplete(statusText) then
      completedCount = completedCount + 1
    elseif statusIsFailed(statusText) then
      failedCount = failedCount + 1
    elseif statusText:find("render", 1, true) or statusText:find("running", 1, true) then
      activeIndex = activeIndex or index
      activePercent = completion
    elseif not activeIndex then
      activeIndex = index
      activePercent = completion
    end
  end

  if completedCount == #jobs then
    finishRenderStatus(
      string.format(
        "Completed: %d timeline%s rendered successfully.",
        #jobs,
        #jobs == 1 and "" or "s"
      )
    )
    return
  end

  local rendering = safeCall(project, "IsRenderingInProgress") == true

  if rendering then
    activeRender.sawRendering = true
    activeIndex = activeIndex or math.min(completedCount + failedCount + 1, #jobs)
    local activeJob = jobs[activeIndex]
    local progressText = string.format(
      "Rendering %d of %d: %s",
      activeIndex,
      #jobs,
      activeJob.name
    )

    if activePercent > 0 then
      progressText = progressText .. string.format(" (%d%%)", math.floor(activePercent + 0.5))
    end

    setItemText("result_summary", progressText)
    return
  end

  activeRender.idlePolls = activeRender.idlePolls + 1

  if not activeRender.sawRendering and activeRender.idlePolls <= 4 then
    setItemText(
      "result_summary",
      string.format(
        "Starting render of %d timeline%s...",
        #jobs,
        #jobs == 1 and "" or "s"
      )
    )
    return
  end

  if failedCount > 0 then
    finishRenderStatus(
      string.format(
        "Finished with errors: %d completed, %d failed or cancelled.",
        completedCount,
        failedCount
      )
    )
    return
  end

  if activeRender.sawRendering then
    finishRenderStatus(
      string.format(
        "Completed: %d timeline%s rendered successfully.",
        #jobs,
        #jobs == 1 and "" or "s"
      )
    )
    return
  end

  finishRenderStatus(
    string.format(
      "Render stopped before completion: %d of %d timelines completed.",
      completedCount,
      #jobs
    )
  )
end

local function queueSelectedTimelines()
  local ok, summary = pcall(function()
    local _, project, mediaPool = getContext()
    local timelines = selectedTimelines(project, mediaPool)
    local presetName = getSelectedPresetName()
    local exportFolder = getItemText("export_folder")
    local startNow = isChecked("start_rendering")

    if #timelines == 0 then
      error("No selected timelines found. Select timeline items in the Media Pool and click Refresh.")
    end

    if presetName == "" then
      error("Choose a saved user render preset. Create one on the Deliver page if the list is empty.")
    end

    if exportFolder == "" then
      error("Choose an output folder.")
    end

    if project:IsRenderingInProgress() then
      error("Resolve is already rendering. Wait for it to finish before starting another batch.")
    end

    local originalTimeline = project:GetCurrentTimeline()
    local jobIds = {}
    local jobs = {}

    resolve:OpenPage("deliver")

    for _, timeline in ipairs(timelines) do
      local timelineName = trim(timeline:GetName())

      if not project:SetCurrentTimeline(timeline) then
        error("Could not make timeline current: " .. timelineName)
      end

      if not project:LoadRenderPreset(presetName) then
        error("Could not load render preset: " .. presetName)
      end

      local markIn, markOut = getTimelineMarkRange(timeline)

      if not markIn or not markOut then
        error("No Mark In/Out range found for: " .. timelineName .. ". Set In and Out points on each selected timeline before exporting.")
      end

      if not project:SetRenderSettings({
        SelectAllFrames = false,
        MarkIn = markIn,
        MarkOut = markOut,
        TargetDir = exportFolder,
        CustomName = timelineName,
      }) then
        error("Could not apply render settings for: " .. timelineName)
      end

      local jobId = project:AddRenderJob()

      if not jobId or trim(jobId) == "" then
        error("Could not add render job for: " .. timelineName)
      end

      table.insert(jobIds, jobId)
      table.insert(jobs, { id = jobId, name = timelineName, markIn = markIn, markOut = markOut })
    end

    if originalTimeline then
      project:SetCurrentTimeline(originalTimeline)
    end

    local started = false

    if startNow then
      started = project:StartRendering(jobIds, true)

      if not started then
        error("The jobs were queued, but Resolve could not start rendering them.")
      end
    end

    if started then
      activeRender = {
        project = project,
        jobs = jobs,
        idlePolls = 0,
        sawRendering = false,
      }

      local runButton = item("run_export")
      if runButton then
        runButton.Enabled = false
      end

      if renderStatusTimer then
        renderStatusTimer:Start()
      end

      return string.format(
        "Starting render of %d timeline%s...",
        #jobIds,
        #jobIds == 1 and "" or "s"
      )
    end

    return string.format(
      "%d timeline%s added to the render queue using marked In/Out ranges.",
      #jobIds,
      #jobIds == 1 and "" or "s"
    )
  end)

  if ok then
    setItemText("result_summary", summary)
  else
    setItemText("result_summary", "Could not export: " .. tostring(summary))
  end
end

local existingWindow = ui:FindWindow(WINDOW_ID)
if existingWindow then
  existingWindow:Show()
  existingWindow:Raise()
  return
end

win = dispatcher:AddWindow(
  {
    ID = WINDOW_ID,
    Geometry = { 220, 45, 780, 840 },
    MinimumSize = { 760, 820 },
    WindowTitle = "Krabby Batch Timeline Export",
  },
  ui:VGroup {
    Spacing = 18,

    ui:Label {
      Text = "Krabby Batch Timeline Export",
      Font = ui:Font { PixelSize = 22, Bold = true },
      StyleSheet = "color: #f0f0f0;",
      Weight = 0,
    },

    ui:Label {
      Text = "Export timeline items selected in the Media Pool with a saved user render preset.",
      WordWrap = true,
      StyleSheet = "color: #b8b8b8;",
      Weight = 0,
    },

    ui:Label {
      Text = "",
      MinimumSize = { 0, 1 },
      MaximumSize = { 16777215, 1 },
      StyleSheet = "background-color: #3a3a3a;",
      Weight = 0,
    },

    ui:VGroup {
      Spacing = 12,
      Weight = 0,

      ui:HGroup {
        Weight = 0,
        ui:Label {
          Text = "SELECTED TIMELINES",
          Font = ui:Font { Bold = true },
          StyleSheet = "color: #d5d5d5;",
          Weight = 1,
        },
        ui:Button { ID = "refresh_selection", Text = "Refresh", Weight = 0 },
      },

      ui:TextEdit {
        ID = "selection_summary",
        ReadOnly = true,
        MinimumSize = { 0, 140 },
        MaximumSize = { 16777215, 180 },
        Weight = 0,
      },
    },

    ui:Label {
      Text = "",
      MinimumSize = { 0, 1 },
      MaximumSize = { 16777215, 1 },
      StyleSheet = "background-color: #3a3a3a;",
      Weight = 0,
    },

    ui:VGroup {
      Spacing = 12,
      Weight = 0,

      ui:Label {
        Text = "EXPORT PRESET",
        Font = ui:Font { Bold = true },
        StyleSheet = "color: #d5d5d5;",
        Weight = 0,
      },

      ui:HGroup {
        Weight = 0,
        ui:ComboBox { ID = "preset_combo", Weight = 1 },
        ui:Button { ID = "refresh_presets", Text = "Refresh presets", Weight = 0 },
      },

      ui:Label {
        ID = "preset_note",
        Text = "Saved custom presets from the Deliver page appear here.",
        WordWrap = true,
        StyleSheet = "color: #9f9f9f;",
        Weight = 0,
      },
    },

    ui:Label {
      Text = "",
      MinimumSize = { 0, 1 },
      MaximumSize = { 16777215, 1 },
      StyleSheet = "background-color: #3a3a3a;",
      Weight = 0,
    },

    ui:VGroup {
      Spacing = 12,
      Weight = 0,

      ui:Label {
        Text = "DESTINATION & OPTIONS",
        Font = ui:Font { Bold = true },
        StyleSheet = "color: #d5d5d5;",
        Weight = 0,
      },

      ui:HGroup {
        Weight = 0,
        ui:LineEdit { ID = "export_folder", Text = DEFAULT_EXPORT_FOLDER, Weight = 1 },
        ui:Button { ID = "choose_folder", Text = "Choose folder...", Weight = 0 },
      },

      ui:CheckBox {
        ID = "start_rendering",
        Text = "Start rendering immediately after adding the jobs",
        Checked = true,
        Weight = 0,
      },

      ui:Label {
        Text = "The preset controls format, codec, resolution, audio, and render mode. The script uses each timeline's marked In/Out range and timeline name.",
        WordWrap = true,
        StyleSheet = "color: #9f9f9f;",
        Weight = 0,
      },
    },

    ui:Label {
      Text = "",
      MinimumSize = { 0, 1 },
      MaximumSize = { 16777215, 1 },
      StyleSheet = "background-color: #3a3a3a;",
      Weight = 0,
    },

    ui:VGroup {
      Spacing = 14,
      Weight = 0,

      ui:Button {
        ID = "run_export",
        Text = "Export selected timelines",
        MinimumSize = { 0, 46 },
        Font = ui:Font { Bold = true },
        StyleSheet = "QPushButton { background-color: #4f63d9; color: #ffffff; border: 1px solid #6f80e8; border-radius: 4px; padding: 8px 14px; } QPushButton:hover { background-color: #5d70e6; } QPushButton:pressed { background-color: #4052bd; }",
        Weight = 0,
      },

      ui:Label {
        ID = "result_summary",
        Text = "Ready.",
        WordWrap = true,
        Font = ui:Font { Bold = true },
        StyleSheet = "color: #c7cffc; padding-top: 5px; padding-bottom: 5px;",
        Weight = 0,
      },
    },

    ui:Label {
      Text = "",
      MinimumSize = { 0, 1 },
      MaximumSize = { 16777215, 1 },
      StyleSheet = "background-color: #3a3a3a;",
      Weight = 0,
    },

    ui:HGroup {
      Weight = 0,
      ui:Label {
        Text = "Created by Krabby Paddy  |  Made for Joe  |  krabbypaddy.com  |  Copyright (c) 2026",
        StyleSheet = "color: #777777;",
        Weight = 1,
      },
      ui:Button {
        ID = "close_window",
        Text = "Close",
        MinimumSize = { 100, 34 },
        Weight = 0,
      },
    },
  }
)

renderStatusTimer = ui:Timer {
  ID = "render_status_timer",
  Interval = 500,
  SingleShot = false,
}

win.On.KrabbyBatchTimelineExportWindow.Close = function()
  if renderStatusTimer then
    renderStatusTimer:Stop()
  end
  dispatcher:ExitLoop()
end

win.On.close_window.Clicked = function()
  if renderStatusTimer then
    renderStatusTimer:Stop()
  end
  dispatcher:ExitLoop()
end

win.On.refresh_selection.Clicked = function()
  refreshSelectionSummary()
end

win.On.refresh_presets.Clicked = function()
  refreshPresets()
end

win.On.choose_folder.Clicked = function()
  local selectedPath = chooseFolderDialog()

  if selectedPath ~= "" then
    setItemText("export_folder", selectedPath)
  end
end

win.On.run_export.Clicked = function()
  queueSelectedTimelines()
end

function dispatcher.On.Timeout()
  updateRenderStatus()
end

refreshSelectionSummary()
refreshPresets()

win:Show()
dispatcher:RunLoop()
win:Hide()
