{"id":257,"date":"2026-07-12T18:03:18","date_gmt":"2026-07-12T18:03:18","guid":{"rendered":"https:\/\/www.visitnyc.store\/blog\/?p=257"},"modified":"2026-07-13T17:51:12","modified_gmt":"2026-07-13T17:51:12","slug":"davinci-resolve-studio-script-to-upload-to-bunny-cdn","status":"publish","type":"post","link":"https:\/\/www.visitnyc.store\/blog\/davinci-resolve-studio-script-to-upload-to-bunny-cdn\/","title":{"rendered":"Davinci Resolve Studio script to upload to Bunny CDN"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">There&#8217;s an option to trigger a script after the end of a render in the Delivery page in resolve studio for Mac OS.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Copy the file onto the Mac running DaVinci Resolve Studio. It should go in&nbsp;~\/Library\/Application Support\/Blackmagic Design\/DaVinci Resolve\/Fusion\/Scripts\/Deliver\/Bunny_Upload.lua<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It&#8217;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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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 &#8220;YOUR_USER_NAME&#8221; to get the logs written so you can debug and the values for STORAGE_ZONE, BUNNY_PASSWORD and REMOTE_FOLDER.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>cat ~\/Library\/Logs\/ResolvedBunnyUpload.log<\/code><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">for reading the logs in case it errors.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">And here&#8217;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.<\/p>\n\n\n\n<pre class=\"wp-block-code has-small-font-size\"><code>-- Bunny_Upload.lua \u2014 DaVinci Resolve Deliver trigger for macOS\n\n-- ----- EDIT THESE FOUR VALUES -----\nlocal BUNNY_ENDPOINT = \"ny.storage.bunnycdn.com\" -- Exact host from Bunny Storage Zone &gt; FTP &amp; API Access\nlocal STORAGE_ZONE   = \"xxxxxxxxx\"\nlocal BUNNY_PASSWORD = \"xxxxxxxxx\"\nlocal REMOTE_FOLDER  = \"xxxxxxxxx\"\n-- ----------------------------------\n\nlocal LOG_FILE = \"\/Users\/YOUR_USER_NAME\/Library\/Logs\/ResolveBunnyUpload.log\"\n\nlocal function log(message)\n    local file = io.open(LOG_FILE, \"a\")\n    if file then\n        file:write(os.date(\"%Y-%m-%d %H:%M:%S\") .. \"  \" .. message .. \"\\n\")\n        file:close()\n    end\n    print(message)\nend\n\n\n-- Safely wraps text used in a shell command, including filenames with spaces.\nlocal function shell_quote(value)\n    return \"'\" .. tostring(value):gsub(\"'\", \"'\\\\''\") .. \"'\"\nend\n\n\nlocal function is_file(path)\n    local file = io.open(path, \"rb\")\n    if file then\n        file:close()\n        return true\n    end\n    return false\nend\n\n\nlocal function file_mtime(path)\n    local handle = io.popen(\"\/usr\/bin\/stat -f %m \" .. shell_quote(path))\n    if not handle then\n        return 0\n    end\n\n    local value = handle:read(\"*l\")\n    handle:close()\n    return tonumber(value) or 0\nend\n\n\nlocal function url_encode(value)\n    return (tostring(value):gsub(\"(&#91;^%w%-_%.~])\", function(character)\n        return string.format(\"%%%02X\", string.byte(character))\n    end))\nend\n\n\nlocal function url_encode_path(path)\n    local parts = {}\n    for part in tostring(path):gmatch(\"&#91;^\/]+\") do\n        table.insert(parts, url_encode(part))\n    end\n    return table.concat(parts, \"\/\")\nend\n\n\nlocal function filename(path)\n    return path:match(\"(&#91;^\/]+)$\") or path\nend\n\n\nlocal function find_finished_export()\n    local resolve = app:GetResolve()\n    local project = resolve:GetProjectManager():GetCurrentProject()\n    local jobs = project:GetRenderJobList() or {}\n\n    local selected_file = nil\n    local matching_files = 0\n\n    for index, job in pairs(jobs) do\n        -- Resolve includes non-job metadata in this table. Ignore it.\n        if type(job) == \"table\" then\n            local target_dir = job&#91;\"TargetDir\"]\n            local output_name = job&#91;\"OutputFilename\"]\n\n            log(\"Job \" .. tostring(index) ..\n                \" | TargetDir=\" .. tostring(target_dir) ..\n                \" | OutputFilename=\" .. tostring(output_name))\n\n            if target_dir and output_name then\n                local candidate = target_dir .. \"\/\" .. output_name\n                local exists = is_file(candidate)\n\n                log(\"Candidate: \" .. candidate .. \" | exists=\" .. tostring(exists))\n\n                if exists then\n                    matching_files = matching_files + 1\n\n                    if not selected_file then\n                        selected_file = candidate\n                        log(\"Selected export: \" .. selected_file)\n                    end\n                end\n            end\n        else\n            log(\n                \"Ignoring non-job render-list metadata\" ..\n                \" | key=\" .. tostring(index) ..\n                \" | value=\" .. tostring(job)\n            )\n        end\n    end\n\n    if not selected_file then\n        return nil, \"Could not find a completed local single-file render.\"\n    end\n\n    if matching_files &gt; 1 then\n        log(\"WARNING: Multiple rendered files were found. Uploading: \" .. selected_file)\n    end\n\n    log(\"find_finished_export: returning selected path to main\")\n    return selected_file, nil\nend\n\n\nlocal function upload_file(source)\n    local path_parts = { url_encode(STORAGE_ZONE) }\n\n    if REMOTE_FOLDER ~= \"\" then\n        table.insert(path_parts, url_encode_path(REMOTE_FOLDER))\n    end\n\n    table.insert(path_parts, url_encode(filename(source)))\n\n    local url = \"https:\/\/\" .. BUNNY_ENDPOINT .. \"\/\" .. table.concat(path_parts, \"\/\")\n\n    local command =\n        \"\/usr\/bin\/curl --fail --silent --show-error --retry 3 --request PUT\" ..\n        \" -H \" .. shell_quote(\"AccessKey: \" .. BUNNY_PASSWORD) ..\n        \" -H \" .. shell_quote(\"Content-Type: application\/octet-stream\") ..\n        \" --connect-timeout 30\" ..\n        \" --upload-file \" .. shell_quote(source) ..\n        \" \" .. shell_quote(url) ..\n        \" --write-out \" .. shell_quote(\n            \"\\nCURL RESULT: http=%{http_code} uploaded=%{size_upload}B time=%{time_total}s\\n\"\n        ) ..\n        \" &gt;&gt; \" .. shell_quote(LOG_FILE) .. \" 2&gt;&amp;1\"\n\n    log(\"Uploading: \" .. source)\n    log(\"Destination: \" .. url)\n\n    local result = os.execute(command)\n\n    if result == true or result == 0 then\n        log(\"UPLOADED: \" .. source)\n        return true, nil\n    end\n\n    return false, \"Bunny upload failed; curl exit status: \" .. tostring(result)\nend\n\n\nlocal function traceback_handler(err)\n    local message = \"UNHANDLED LUA ERROR: \" .. tostring(err)\n\n    if type(debug) == \"table\" and type(debug.traceback) == \"function\" then\n        message = debug.traceback(message, 2)\n    end\n\n    log(message)\n    return message\nend\n\n\nlocal function main()\n    log(\"MAIN: entered\")\n    log(\"MAIN: about to call find_finished_export\")\n\n    local find_call_ok, source, find_error = pcall(find_finished_export)\n\n    log(\n        \"MAIN: find_finished_export returned\" ..\n        \" | pcall_ok=\" .. tostring(find_call_ok) ..\n        \" | source=\" .. tostring(source) ..\n        \" | detail=\" .. tostring(find_error)\n    )\n\n    if not find_call_ok then\n        log(\"ERROR: find_finished_export threw a Lua error: \" .. tostring(source))\n        return false\n    end\n\n    if type(source) ~= \"string\" or source == \"\" then\n        log(\"ERROR: no usable render path returned: \" .. tostring(find_error))\n        return false\n    end\n\n    log(\"MAIN: confirmed source file exists in Resolve: \" .. source)\n    log(\"MAIN: about to call upload_file\")\n\n    local upload_call_ok, uploaded, upload_error = pcall(upload_file, source)\n\n    log(\n        \"MAIN: upload_file returned\" ..\n        \" | pcall_ok=\" .. tostring(upload_call_ok) ..\n        \" | uploaded=\" .. tostring(uploaded) ..\n        \" | detail=\" .. tostring(upload_error)\n    )\n\n    if not upload_call_ok then\n        log(\"ERROR: upload_file threw a Lua error: \" .. tostring(uploaded))\n        return false\n    end\n\n    if not uploaded then\n        log(\"ERROR: Bunny upload did not succeed: \" .. tostring(upload_error))\n        return false\n    end\n\n    log(\"MAIN: upload completed successfully\")\n    return true\nend\n\n\nlog(\"TOP: starting Bunny_Upload through xpcall\")\n\nlocal top_ok, top_result = xpcall(main, traceback_handler)\n\nlog(\n    \"TOP: xpcall returned\" ..\n    \" | success=\" .. tostring(top_ok) ..\n    \" | result=\" .. tostring(top_result)\n)\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<p class=\"wp-block-paragraph\">here&#8217;s the upload all processed files in the render script:<\/p>\n\n\n\n<pre class=\"wp-block-code has-small-font-size\"><code>-- Bunny_Upload.lua \u2014 DaVinci Resolve Deliver trigger for macOS\n\n-- ----- EDIT THESE FOUR VALUES -----\nlocal BUNNY_ENDPOINT = \"ny.storage.bunnycdn.com\" -- Exact host from Bunny Storage Zone > FTP &amp; API Access\nlocal STORAGE_ZONE   = \"xxxxxxxxx\"\nlocal BUNNY_PASSWORD = \"xxxxxxxxx\"\nlocal REMOTE_FOLDER  = \"xxxxxxxxx\"\n-- ----------------------------------\n\nlocal LOG_FILE = \"\/Users\/itunes\/Library\/Logs\/ResolveBunnyUpload.log\"\n\nlocal function log(message)\n    local file = io.open(LOG_FILE, \"a\")\n    if file then\n        file:write(os.date(\"%Y-%m-%d %H:%M:%S\") .. \"  \" .. message .. \"\\n\")\n        file:close()\n    end\n    print(message)\nend\n\n\n-- Safely wraps text used in a shell command, including filenames with spaces.\nlocal function shell_quote(value)\n    return \"'\" .. tostring(value):gsub(\"'\", \"'\\\\''\") .. \"'\"\nend\n\n\nlocal function is_file(path)\n    local file = io.open(path, \"rb\")\n    if file then\n        file:close()\n        return true\n    end\n    return false\nend\n\n\nlocal function file_mtime(path)\n    local handle = io.popen(\"\/usr\/bin\/stat -f %m \" .. shell_quote(path))\n    if not handle then\n        return 0\n    end\n\n    local value = handle:read(\"*l\")\n    handle:close()\n    return tonumber(value) or 0\nend\n\n\nlocal function url_encode(value)\n    return (tostring(value):gsub(\"(&#91;^%w%-_%.~])\", function(character)\n        return string.format(\"%%%02X\", string.byte(character))\n    end))\nend\n\n\nlocal function url_encode_path(path)\n    local parts = {}\n    for part in tostring(path):gmatch(\"&#91;^\/]+\") do\n        table.insert(parts, url_encode(part))\n    end\n    return table.concat(parts, \"\/\")\nend\n\n\nlocal function filename(path)\n    return path:match(\"(&#91;^\/]+)$\") or path\nend\n\n\n-- Returns every existing single-file output referenced by the current render\n-- job list. Resolve may run this delivery trigger once after a queue finishes,\n-- so selecting only one output here would leave the other completed jobs out.\nlocal function find_finished_exports()\n    local resolve = app:GetResolve()\n    local project = resolve:GetProjectManager():GetCurrentProject()\n    local jobs = project:GetRenderJobList() or {}\n\n    local exports = {}\n    local seen = {}\n\n    for index, job in pairs(jobs) do\n        -- Resolve includes non-job metadata in this table. Ignore it.\n        if type(job) == \"table\" then\n            local target_dir = job&#91;\"TargetDir\"]\n            local output_name = job&#91;\"OutputFilename\"]\n\n            log(\"Job \" .. tostring(index) ..\n                \" | TargetDir=\" .. tostring(target_dir) ..\n                \" | OutputFilename=\" .. tostring(output_name))\n\n            if target_dir and output_name then\n                local candidate = target_dir .. \"\/\" .. output_name\n                local exists = is_file(candidate)\n\n                log(\"Candidate: \" .. candidate .. \" | exists=\" .. tostring(exists))\n\n                if exists then\n                    -- Do not upload a file twice if two queue jobs reference\n                    -- the same destination and output filename.\n                    if not seen&#91;candidate] then\n                        seen&#91;candidate] = true\n                        table.insert(exports, candidate)\n                        log(\"Queued export \" .. tostring(#exports) .. \": \" .. candidate)\n                    else\n                        log(\"Skipping duplicate export: \" .. candidate)\n                    end\n                end\n            end\n        else\n            log(\n                \"Ignoring non-job render-list metadata\" ..\n                \" | key=\" .. tostring(index) ..\n                \" | value=\" .. tostring(job)\n            )\n        end\n    end\n\n    if #exports == 0 then\n        return nil, \"Could not find any completed local single-file renders.\"\n    end\n\n    log(\"find_finished_exports: returning \" .. tostring(#exports) .. \" path(s) to main\")\n    return exports, nil\nend\n\n\nlocal function upload_file(source)\n    local path_parts = { url_encode(STORAGE_ZONE) }\n\n    if REMOTE_FOLDER ~= \"\" then\n        table.insert(path_parts, url_encode_path(REMOTE_FOLDER))\n    end\n\n    table.insert(path_parts, url_encode(filename(source)))\n\n    local url = \"https:\/\/\" .. BUNNY_ENDPOINT .. \"\/\" .. table.concat(path_parts, \"\/\")\n\n    local command =\n        \"\/usr\/bin\/curl --fail --silent --show-error --retry 3 --request PUT\" ..\n        \" -H \" .. shell_quote(\"AccessKey: \" .. BUNNY_PASSWORD) ..\n        \" -H \" .. shell_quote(\"Content-Type: application\/octet-stream\") ..\n        \" --connect-timeout 30\" ..\n        \" --upload-file \" .. shell_quote(source) ..\n        \" \" .. shell_quote(url) ..\n        \" --write-out \" .. shell_quote(\n            \"\\nCURL RESULT: http=%{http_code} uploaded=%{size_upload}B time=%{time_total}s\\n\"\n        ) ..\n        \" >> \" .. shell_quote(LOG_FILE) .. \" 2>&amp;1\"\n\n    log(\"Uploading: \" .. source)\n    log(\"Destination: \" .. url)\n\n    local result = os.execute(command)\n\n    if result == true or result == 0 then\n        log(\"UPLOADED: \" .. source)\n        return true, nil\n    end\n\n    return false, \"Bunny upload failed; curl exit status: \" .. tostring(result)\nend\n\n\nlocal function traceback_handler(err)\n    local message = \"UNHANDLED LUA ERROR: \" .. tostring(err)\n\n    if type(debug) == \"table\" and type(debug.traceback) == \"function\" then\n        message = debug.traceback(message, 2)\n    end\n\n    log(message)\n    return message\nend\n\n\nlocal function main()\n    log(\"MAIN: entered\")\n    log(\"MAIN: about to call find_finished_exports\")\n\n    local find_call_ok, sources, find_error = pcall(find_finished_exports)\n\n    log(\n        \"MAIN: find_finished_exports returned\" ..\n        \" | pcall_ok=\" .. tostring(find_call_ok) ..\n        \" | source_count=\" .. tostring(type(sources) == \"table\" and #sources or sources) ..\n        \" | detail=\" .. tostring(find_error)\n    )\n\n    if not find_call_ok then\n        log(\"ERROR: find_finished_exports threw a Lua error: \" .. tostring(sources))\n        return false\n    end\n\n    if type(sources) ~= \"table\" or #sources == 0 then\n        log(\"ERROR: no usable render paths returned: \" .. tostring(find_error))\n        return false\n    end\n\n    local failed_uploads = 0\n\n    for index, source in ipairs(sources) do\n        log(\n            \"MAIN: processing export \" .. tostring(index) ..\n            \" of \" .. tostring(#sources) ..\n            \" | source=\" .. source\n        )\n        log(\"MAIN: about to call upload_file\")\n\n        local upload_call_ok, uploaded, upload_error = pcall(upload_file, source)\n\n        log(\n            \"MAIN: upload_file returned\" ..\n            \" | pcall_ok=\" .. tostring(upload_call_ok) ..\n            \" | uploaded=\" .. tostring(uploaded) ..\n            \" | detail=\" .. tostring(upload_error)\n        )\n\n        if not upload_call_ok then\n            failed_uploads = failed_uploads + 1\n            log(\"ERROR: upload_file threw a Lua error: \" .. tostring(uploaded))\n        elseif not uploaded then\n            failed_uploads = failed_uploads + 1\n            log(\"ERROR: Bunny upload did not succeed: \" .. tostring(upload_error))\n        end\n    end\n\n    if failed_uploads > 0 then\n        log(\n            \"MAIN: upload batch finished with \" .. tostring(failed_uploads) ..\n            \" failed file(s) out of \" .. tostring(#sources)\n        )\n        return false\n    end\n\n    log(\"MAIN: upload batch completed successfully | files=\" .. tostring(#sources))\n    return true\nend\n\n\nlog(\"TOP: starting Bunny_Upload through xpcall\")\n\nlocal top_ok, top_result = xpcall(main, traceback_handler)\n\nlog(\n    \"TOP: xpcall returned\" ..\n    \" | success=\" .. tostring(top_ok) ..\n    \" | result=\" .. tostring(top_result)\n)<\/code><\/pre>\n","protected":false},"excerpt":{"rendered":"<p>There&#8217;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&nbsp;~\/Library\/Application Support\/Blackmagic Design\/DaVinci Resolve\/Fusion\/Scripts\/Deliver\/Bunny_Upload.lua It&#8217;s a lua script and not python for whatever reason this Mac does not like [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[],"class_list":["post-257","post","type-post","status-publish","format-standard","hentry","category-uncategorized"],"_links":{"self":[{"href":"https:\/\/www.visitnyc.store\/blog\/wp-json\/wp\/v2\/posts\/257","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.visitnyc.store\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.visitnyc.store\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.visitnyc.store\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.visitnyc.store\/blog\/wp-json\/wp\/v2\/comments?post=257"}],"version-history":[{"count":3,"href":"https:\/\/www.visitnyc.store\/blog\/wp-json\/wp\/v2\/posts\/257\/revisions"}],"predecessor-version":[{"id":261,"href":"https:\/\/www.visitnyc.store\/blog\/wp-json\/wp\/v2\/posts\/257\/revisions\/261"}],"wp:attachment":[{"href":"https:\/\/www.visitnyc.store\/blog\/wp-json\/wp\/v2\/media?parent=257"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.visitnyc.store\/blog\/wp-json\/wp\/v2\/categories?post=257"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.visitnyc.store\/blog\/wp-json\/wp\/v2\/tags?post=257"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}