Davinci Resolve Studio script to upload to Bunny CDN

There’s an option to trigger a script after the end of a render in the Delivery page in resolve studio for Mac OS.

Copy the file onto the Mac running DaVinci Resolve Studio. It should go in ~/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Deliver/Bunny_Upload.lua

It’s a lua script and not python for whatever reason this Mac does not like running the python script but runs lua fine so lua it is.

We use Bunny CDN for previewing and usually the AIVU gets uploaded there right after manually. Now this script runs and uploads it. Make sure to change “YOUR_USER_NAME” to get the logs written so you can debug and the values for STORAGE_ZONE, BUNNY_PASSWORD and REMOTE_FOLDER.

cat ~/Library/Logs/ResolvedBunnyUpload.log

for reading the logs in case it errors.

And here’s the script for your inspiration. Note if you have multiple renders this only uploads the first one. See after the scroll for a full downloadable one.

-- Bunny_Upload.lua — DaVinci Resolve Deliver trigger for macOS

-- ----- EDIT THESE FOUR VALUES -----
local BUNNY_ENDPOINT = "ny.storage.bunnycdn.com" -- Exact host from Bunny Storage Zone > FTP & API Access
local STORAGE_ZONE   = "xxxxxxxxx"
local BUNNY_PASSWORD = "xxxxxxxxx"
local REMOTE_FOLDER  = "xxxxxxxxx"
-- ----------------------------------

local LOG_FILE = "/Users/YOUR_USER_NAME/Library/Logs/ResolveBunnyUpload.log"

local function log(message)
    local file = io.open(LOG_FILE, "a")
    if file then
        file:write(os.date("%Y-%m-%d %H:%M:%S") .. "  " .. message .. "\n")
        file:close()
    end
    print(message)
end


-- Safely wraps text used in a shell command, including filenames with spaces.
local function shell_quote(value)
    return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
end


local function is_file(path)
    local file = io.open(path, "rb")
    if file then
        file:close()
        return true
    end
    return false
end


local function file_mtime(path)
    local handle = io.popen("/usr/bin/stat -f %m " .. shell_quote(path))
    if not handle then
        return 0
    end

    local value = handle:read("*l")
    handle:close()
    return tonumber(value) or 0
end


local function url_encode(value)
    return (tostring(value):gsub("([^%w%-_%.~])", function(character)
        return string.format("%%%02X", string.byte(character))
    end))
end


local function url_encode_path(path)
    local parts = {}
    for part in tostring(path):gmatch("[^/]+") do
        table.insert(parts, url_encode(part))
    end
    return table.concat(parts, "/")
end


local function filename(path)
    return path:match("([^/]+)$") or path
end


local function find_finished_export()
    local resolve = app:GetResolve()
    local project = resolve:GetProjectManager():GetCurrentProject()
    local jobs = project:GetRenderJobList() or {}

    local selected_file = nil
    local matching_files = 0

    for index, job in pairs(jobs) do
        -- Resolve includes non-job metadata in this table. Ignore it.
        if type(job) == "table" then
            local target_dir = job["TargetDir"]
            local output_name = job["OutputFilename"]

            log("Job " .. tostring(index) ..
                " | TargetDir=" .. tostring(target_dir) ..
                " | OutputFilename=" .. tostring(output_name))

            if target_dir and output_name then
                local candidate = target_dir .. "/" .. output_name
                local exists = is_file(candidate)

                log("Candidate: " .. candidate .. " | exists=" .. tostring(exists))

                if exists then
                    matching_files = matching_files + 1

                    if not selected_file then
                        selected_file = candidate
                        log("Selected export: " .. selected_file)
                    end
                end
            end
        else
            log(
                "Ignoring non-job render-list metadata" ..
                " | key=" .. tostring(index) ..
                " | value=" .. tostring(job)
            )
        end
    end

    if not selected_file then
        return nil, "Could not find a completed local single-file render."
    end

    if matching_files > 1 then
        log("WARNING: Multiple rendered files were found. Uploading: " .. selected_file)
    end

    log("find_finished_export: returning selected path to main")
    return selected_file, nil
end


local function upload_file(source)
    local path_parts = { url_encode(STORAGE_ZONE) }

    if REMOTE_FOLDER ~= "" then
        table.insert(path_parts, url_encode_path(REMOTE_FOLDER))
    end

    table.insert(path_parts, url_encode(filename(source)))

    local url = "https://" .. BUNNY_ENDPOINT .. "/" .. table.concat(path_parts, "/")

    local command =
        "/usr/bin/curl --fail --silent --show-error --retry 3 --request PUT" ..
        " -H " .. shell_quote("AccessKey: " .. BUNNY_PASSWORD) ..
        " -H " .. shell_quote("Content-Type: application/octet-stream") ..
        " --connect-timeout 30" ..
        " --upload-file " .. shell_quote(source) ..
        " " .. shell_quote(url) ..
        " --write-out " .. shell_quote(
            "\nCURL RESULT: http=%{http_code} uploaded=%{size_upload}B time=%{time_total}s\n"
        ) ..
        " >> " .. shell_quote(LOG_FILE) .. " 2>&1"

    log("Uploading: " .. source)
    log("Destination: " .. url)

    local result = os.execute(command)

    if result == true or result == 0 then
        log("UPLOADED: " .. source)
        return true, nil
    end

    return false, "Bunny upload failed; curl exit status: " .. tostring(result)
end


local function traceback_handler(err)
    local message = "UNHANDLED LUA ERROR: " .. tostring(err)

    if type(debug) == "table" and type(debug.traceback) == "function" then
        message = debug.traceback(message, 2)
    end

    log(message)
    return message
end


local function main()
    log("MAIN: entered")
    log("MAIN: about to call find_finished_export")

    local find_call_ok, source, find_error = pcall(find_finished_export)

    log(
        "MAIN: find_finished_export returned" ..
        " | pcall_ok=" .. tostring(find_call_ok) ..
        " | source=" .. tostring(source) ..
        " | detail=" .. tostring(find_error)
    )

    if not find_call_ok then
        log("ERROR: find_finished_export threw a Lua error: " .. tostring(source))
        return false
    end

    if type(source) ~= "string" or source == "" then
        log("ERROR: no usable render path returned: " .. tostring(find_error))
        return false
    end

    log("MAIN: confirmed source file exists in Resolve: " .. source)
    log("MAIN: about to call upload_file")

    local upload_call_ok, uploaded, upload_error = pcall(upload_file, source)

    log(
        "MAIN: upload_file returned" ..
        " | pcall_ok=" .. tostring(upload_call_ok) ..
        " | uploaded=" .. tostring(uploaded) ..
        " | detail=" .. tostring(upload_error)
    )

    if not upload_call_ok then
        log("ERROR: upload_file threw a Lua error: " .. tostring(uploaded))
        return false
    end

    if not uploaded then
        log("ERROR: Bunny upload did not succeed: " .. tostring(upload_error))
        return false
    end

    log("MAIN: upload completed successfully")
    return true
end


log("TOP: starting Bunny_Upload through xpcall")

local top_ok, top_result = xpcall(main, traceback_handler)

log(
    "TOP: xpcall returned" ..
    " | success=" .. tostring(top_ok) ..
    " | result=" .. tostring(top_result)
)

here’s the upload all processed files in the render script:

-- Bunny_Upload.lua — DaVinci Resolve Deliver trigger for macOS

-- ----- EDIT THESE FOUR VALUES -----
local BUNNY_ENDPOINT = "ny.storage.bunnycdn.com" -- Exact host from Bunny Storage Zone > FTP & API Access
local STORAGE_ZONE   = "xxxxxxxxx"
local BUNNY_PASSWORD = "xxxxxxxxx"
local REMOTE_FOLDER  = "xxxxxxxxx"
-- ----------------------------------

local LOG_FILE = "/Users/itunes/Library/Logs/ResolveBunnyUpload.log"

local function log(message)
    local file = io.open(LOG_FILE, "a")
    if file then
        file:write(os.date("%Y-%m-%d %H:%M:%S") .. "  " .. message .. "\n")
        file:close()
    end
    print(message)
end


-- Safely wraps text used in a shell command, including filenames with spaces.
local function shell_quote(value)
    return "'" .. tostring(value):gsub("'", "'\\''") .. "'"
end


local function is_file(path)
    local file = io.open(path, "rb")
    if file then
        file:close()
        return true
    end
    return false
end


local function file_mtime(path)
    local handle = io.popen("/usr/bin/stat -f %m " .. shell_quote(path))
    if not handle then
        return 0
    end

    local value = handle:read("*l")
    handle:close()
    return tonumber(value) or 0
end


local function url_encode(value)
    return (tostring(value):gsub("([^%w%-_%.~])", function(character)
        return string.format("%%%02X", string.byte(character))
    end))
end


local function url_encode_path(path)
    local parts = {}
    for part in tostring(path):gmatch("[^/]+") do
        table.insert(parts, url_encode(part))
    end
    return table.concat(parts, "/")
end


local function filename(path)
    return path:match("([^/]+)$") or path
end


-- Returns every existing single-file output referenced by the current render
-- job list. Resolve may run this delivery trigger once after a queue finishes,
-- so selecting only one output here would leave the other completed jobs out.
local function find_finished_exports()
    local resolve = app:GetResolve()
    local project = resolve:GetProjectManager():GetCurrentProject()
    local jobs = project:GetRenderJobList() or {}

    local exports = {}
    local seen = {}

    for index, job in pairs(jobs) do
        -- Resolve includes non-job metadata in this table. Ignore it.
        if type(job) == "table" then
            local target_dir = job["TargetDir"]
            local output_name = job["OutputFilename"]

            log("Job " .. tostring(index) ..
                " | TargetDir=" .. tostring(target_dir) ..
                " | OutputFilename=" .. tostring(output_name))

            if target_dir and output_name then
                local candidate = target_dir .. "/" .. output_name
                local exists = is_file(candidate)

                log("Candidate: " .. candidate .. " | exists=" .. tostring(exists))

                if exists then
                    -- Do not upload a file twice if two queue jobs reference
                    -- the same destination and output filename.
                    if not seen[candidate] then
                        seen[candidate] = true
                        table.insert(exports, candidate)
                        log("Queued export " .. tostring(#exports) .. ": " .. candidate)
                    else
                        log("Skipping duplicate export: " .. candidate)
                    end
                end
            end
        else
            log(
                "Ignoring non-job render-list metadata" ..
                " | key=" .. tostring(index) ..
                " | value=" .. tostring(job)
            )
        end
    end

    if #exports == 0 then
        return nil, "Could not find any completed local single-file renders."
    end

    log("find_finished_exports: returning " .. tostring(#exports) .. " path(s) to main")
    return exports, nil
end


local function upload_file(source)
    local path_parts = { url_encode(STORAGE_ZONE) }

    if REMOTE_FOLDER ~= "" then
        table.insert(path_parts, url_encode_path(REMOTE_FOLDER))
    end

    table.insert(path_parts, url_encode(filename(source)))

    local url = "https://" .. BUNNY_ENDPOINT .. "/" .. table.concat(path_parts, "/")

    local command =
        "/usr/bin/curl --fail --silent --show-error --retry 3 --request PUT" ..
        " -H " .. shell_quote("AccessKey: " .. BUNNY_PASSWORD) ..
        " -H " .. shell_quote("Content-Type: application/octet-stream") ..
        " --connect-timeout 30" ..
        " --upload-file " .. shell_quote(source) ..
        " " .. shell_quote(url) ..
        " --write-out " .. shell_quote(
            "\nCURL RESULT: http=%{http_code} uploaded=%{size_upload}B time=%{time_total}s\n"
        ) ..
        " >> " .. shell_quote(LOG_FILE) .. " 2>&1"

    log("Uploading: " .. source)
    log("Destination: " .. url)

    local result = os.execute(command)

    if result == true or result == 0 then
        log("UPLOADED: " .. source)
        return true, nil
    end

    return false, "Bunny upload failed; curl exit status: " .. tostring(result)
end


local function traceback_handler(err)
    local message = "UNHANDLED LUA ERROR: " .. tostring(err)

    if type(debug) == "table" and type(debug.traceback) == "function" then
        message = debug.traceback(message, 2)
    end

    log(message)
    return message
end


local function main()
    log("MAIN: entered")
    log("MAIN: about to call find_finished_exports")

    local find_call_ok, sources, find_error = pcall(find_finished_exports)

    log(
        "MAIN: find_finished_exports returned" ..
        " | pcall_ok=" .. tostring(find_call_ok) ..
        " | source_count=" .. tostring(type(sources) == "table" and #sources or sources) ..
        " | detail=" .. tostring(find_error)
    )

    if not find_call_ok then
        log("ERROR: find_finished_exports threw a Lua error: " .. tostring(sources))
        return false
    end

    if type(sources) ~= "table" or #sources == 0 then
        log("ERROR: no usable render paths returned: " .. tostring(find_error))
        return false
    end

    local failed_uploads = 0

    for index, source in ipairs(sources) do
        log(
            "MAIN: processing export " .. tostring(index) ..
            " of " .. tostring(#sources) ..
            " | source=" .. source
        )
        log("MAIN: about to call upload_file")

        local upload_call_ok, uploaded, upload_error = pcall(upload_file, source)

        log(
            "MAIN: upload_file returned" ..
            " | pcall_ok=" .. tostring(upload_call_ok) ..
            " | uploaded=" .. tostring(uploaded) ..
            " | detail=" .. tostring(upload_error)
        )

        if not upload_call_ok then
            failed_uploads = failed_uploads + 1
            log("ERROR: upload_file threw a Lua error: " .. tostring(uploaded))
        elseif not uploaded then
            failed_uploads = failed_uploads + 1
            log("ERROR: Bunny upload did not succeed: " .. tostring(upload_error))
        end
    end

    if failed_uploads > 0 then
        log(
            "MAIN: upload batch finished with " .. tostring(failed_uploads) ..
            " failed file(s) out of " .. tostring(#sources)
        )
        return false
    end

    log("MAIN: upload batch completed successfully | files=" .. tostring(#sources))
    return true
end


log("TOP: starting Bunny_Upload through xpcall")

local top_ok, top_result = xpcall(main, traceback_handler)

log(
    "TOP: xpcall returned" ..
    " | success=" .. tostring(top_ok) ..
    " | result=" .. tostring(top_result)
)