--[[ Workspace > Scripts > Qawlak Import -- DaVinci Resolve menu script in Lua (free edition 21.x). Resolve 21.1 free lists only Lua menu scripts and its console runs no Python. docs/plugins/resolve-helper/install.cmd copies this file and "Qawlak Round-trip.lua" to: %APPDATA%\Blackmagic Design\DaVinci Resolve\Support\Fusion\Scripts\Utility\ Open a project, then Workspace > Scripts > Qawlak Import. The newest ZIP in Downloads that holds captions-both.srt is extracted next to itself (Windows' own tar.exe; unzip on macOS), its captioned video (else the source) becomes a new timeline, and the captions go on a subtitle track. No plugin key is needed. Messages go to Workspace > Console. Resolve's embedded LuaJIT (measured live in free 21.1, 15 Sep 2026) has no io, os.execute, os.remove, require or package; it does have os.getenv, loadfile, dofile, ffi and bmd.fileexists/readstring/writestring. So files and commands go through M.run / M.read / M.write / M.remove below: plain Lua io when it exists (macOS, tests), else Windows kernel32 via ffi. Loaded with the argument 'qawlak-lib' (or QAWLAK_LIB = true) it only returns its helpers (Qawlak Round-trip.lua and tests/test_resolve_lua.py use them). --]] local M = {} -- Qawlak Round-trip.lua passes 'qawlak-lib': a menu script's own globals live in its own environment, so a QAWLAK_LIB it sets -- never reaches the chunk it loads (live 16 Sep 2026: Round-trip ran this file's Import instead). local AS_LIBRARY = (...) == 'qawlak-lib' local HAS_IO = rawget(_G, 'io') ~= nil and io.open ~= nil and io.popen ~= nil local ffi = rawget(_G, 'ffi') -- Windows is read from the environment first: Resolve's Lua has no package table to ask. local function is_windows() local os_name = os and os.getenv and os.getenv('OS') if os_name then return os_name == 'Windows_NT' end local pkg = rawget(_G, 'package') return pkg ~= nil and pkg.config ~= nil and pkg.config:sub(1, 1) == '\\' end local WINDOWS = is_windows() local SEP = WINDOWS and '\\' or '/' local VIDEO_PRIORITY = {'captioned.mp4', 'source.mp4', 'clip.mp4'} M.WINDOWS, M.SEP, M.HAS_IO = WINDOWS, SEP, HAS_IO -- kernel32 through ffi, declared once per Lua state (Round-trip loads this file again as a library). local K local function kernel() if K then return K end if not (ffi and WINDOWS) then error('this Resolve has neither Lua io nor ffi on Windows; use File > Import instead', 0) end -- One pointer per declaration: `void* a, b` would make b a plain void and the whole cdef fail. local ok, err = pcall(ffi.cdef, [[ typedef struct { uint32_t cb; void* r1; void* desktop; void* title; uint32_t x, y, xs, ys, xc, yc, fill, flags; uint16_t show, r2; void* r3; void* in_; void* out_; void* err_; } QW_STARTUPINFOW; typedef struct { void* process; void* thread; uint32_t pid, tid; } QW_PROCESS_INFORMATION; int MultiByteToWideChar(unsigned int cp, uint32_t flags, const char* s, int n, uint16_t* w, int wn); int CreateProcessW(const uint16_t* app, uint16_t* cmd, void* pa, void* ta, int inherit, uint32_t flags, void* env, const uint16_t* dir, QW_STARTUPINFOW* si, QW_PROCESS_INFORMATION* pi); uint32_t WaitForSingleObject(void* h, uint32_t ms); int GetExitCodeProcess(void* h, uint32_t* code); int CloseHandle(void* h); void* CreateFileW(const uint16_t* path, uint32_t access, uint32_t share, void* sa, uint32_t disp, uint32_t attrs, void* tmpl); int ReadFile(void* h, void* buf, uint32_t n, uint32_t* read, void* ov); int WriteFile(void* h, const void* buf, uint32_t n, uint32_t* written, void* ov); int SetFilePointerEx(void* h, int64_t dist, int64_t* newpos, uint32_t method); int GetFileSizeEx(void* h, int64_t* size); int DeleteFileW(const uint16_t* path); uint32_t GetFileAttributesW(const uint16_t* path); ]]) if not ok and not tostring(err):find('redefin') then error('ffi declarations failed: ' .. tostring(err), 0) end K = ffi.load('kernel32') return K end local function wide(s) local k = kernel() local n = k.MultiByteToWideChar(65001, 0, s, -1, nil, 0) local w = ffi.new('uint16_t[?]', n) k.MultiByteToWideChar(65001, 0, s, -1, w, n) return w end local function open_handle(path, write) local k = kernel() -- GENERIC_WRITE + CREATE_ALWAYS, or GENERIC_READ + OPEN_EXISTING; share read/write; normal attributes local h = k.CreateFileW(wide(path), write and 0x40000000 or 0x80000000, 3, nil, write and 2 or 3, 0x80, nil) if ffi.cast('intptr_t', h) == -1 then return nil end return h end function M.lines(text) local out = {} for line in (text or ''):gmatch('[^\r\n]+') do out[#out + 1] = line end return out end function M.q(s) return '"' .. s .. '"' end function M.read(path, offset, count) -- the file's bytes (or count bytes from offset), nil when it can't be read if HAS_IO then local f = io.open(path, 'rb') if not f then return nil end if offset then f:seek('set', offset) end local data = f:read(count or '*a') f:close() return data or '' end local k, h = kernel(), open_handle(path, false) if not h then return nil end local size = ffi.new('int64_t[1]') k.GetFileSizeEx(h, size) local start = offset or 0 local want = math.max(0, math.min(count or math.huge, tonumber(size[0]) - start)) if start > 0 then k.SetFilePointerEx(h, start, nil, 0) end local buf, got = ffi.new('char[?]', math.max(want, 1)), ffi.new('uint32_t[1]') local ok = want == 0 or k.ReadFile(h, buf, want, got, nil) ~= 0 k.CloseHandle(h) return ok and ffi.string(buf, want == 0 and 0 or got[0]) or nil end function M.write(path, data) if HAS_IO then local f = assert(io.open(path, 'wb')) f:write(data) f:close() return true end local k, h = kernel(), open_handle(path, true) if not h then error('cannot write ' .. path, 0) end local done = ffi.new('uint32_t[1]') local ok = #data == 0 or k.WriteFile(h, data, #data, done, nil) ~= 0 k.CloseHandle(h) if not ok then error('cannot write ' .. path, 0) end return true end function M.size(path) if HAS_IO then local f = io.open(path, 'rb') if not f then return nil end local n = f:seek('end') f:close() return n end local k, h = kernel(), open_handle(path, false) if not h then return nil end local size = ffi.new('int64_t[1]') k.GetFileSizeEx(h, size) k.CloseHandle(h) return tonumber(size[0]) end function M.remove(path) if HAS_IO and os.remove then return os.remove(path) end if WINDOWS and ffi then return kernel().DeleteFileW(wide(path)) ~= 0 end end function M.exists(path) if HAS_IO then local f = io.open(path, 'rb') if f then f:close() end return f ~= nil end if bmd and bmd.fileexists then return bmd.fileexists(path) and true or false end return kernel().GetFileAttributesW(wide(path)) ~= 0xFFFFFFFF end -- stdout+stderr of a shell command. On Windows it runs under cmd.exe, which strips a leading quote, so a command must -- start with a bare word (dir, mkdir, a full exe path without spaces). Without io it is a hidden cmd.exe via -- CreateProcessW writing to a temp file, waited for, then read back. function M.run(command) if HAS_IO then local pipe = io.popen(command .. ' 2>&1') local text = pipe:read('*a') pipe:close() return text end local k = kernel() local out = (os.getenv('TEMP') or 'C:\\Windows\\Temp') .. '\\qawlak-run-' .. tostring(os.time()) .. '-' .. tostring(math.random(1e6)) .. '.txt' local line = 'cmd.exe /d /s /c "' .. command .. ' > "' .. out .. '" 2>&1"' local si, pi = ffi.new('QW_STARTUPINFOW'), ffi.new('QW_PROCESS_INFORMATION') si.cb = ffi.sizeof('QW_STARTUPINFOW') if k.CreateProcessW(nil, wide(line), nil, nil, 0, 0x08000000, nil, nil, si, pi) == 0 then -- CREATE_NO_WINDOW error('could not start: ' .. command, 0) end k.WaitForSingleObject(pi.process, 0xFFFFFFFF) k.CloseHandle(pi.thread) k.CloseHandle(pi.process) local text = M.read(out) or '' M.remove(out) return text end function M.system_exe(name) -- C:\Windows\System32\tar.exe, not a Git-for-Windows copy earlier on PATH return WINDOWS and ((os.getenv('SystemRoot') or 'C:\\Windows') .. '\\System32\\' .. name .. '.exe') or name end -- The first ZIP, newest first, whose listing names captions-both.srt (a Qawlak project export). function M.newest_export(zips, listing) for _, zip in ipairs(zips) do for _, name in ipairs(M.lines(listing(zip))) do if name == 'captions-both.srt' then return zip end end end end -- The first "key": "string" in a JSON text: enough for Qawlak's flat answers and resolve.json. function M.field(json, key) return (json or ''):match('"' .. key .. '"%s*:%s*"(.-)"') end function M.parse_config(text) local language = M.field(text, 'language') or 'ar' return {key = M.field(text, 'key'), url = M.field(text, 'url'), language = language:match('^%a%a%a?$') or 'ar', alpha = (text or ''):match('"alpha"%s*:%s*true') ~= nil} end -- P15-43: the server URL joined to a relative link ("/pricing.html#packs"); an absolute link is kept. function M.join_url(base, path) if path:match('^https?://') then return path end return (base or ''):gsub('/+$', '') .. path end local ARABIC_DIGITS = {['0'] = '٠', ['1'] = '١', ['2'] = '٢', ['3'] = '٣', ['4'] = '٤', ['5'] = '٥', ['6'] = '٦', ['7'] = '٧', ['8'] = '٨', ['9'] = '٩', ['.'] = '٫'} local function minutes_text(minutes, language) local text = tostring(minutes) return language == 'ar' and (text:gsub('[%d%.]', ARABIC_DIGITS)) or text end -- D15-10: the one minutes rule the Premiere panels share. Needed rounds up, left rounds down; under 10 min one decimal, else whole. function M.minutes(seconds, up) local m = (tonumber(seconds) or 0) / 60 if m >= 10 then return up and math.ceil(m - 1e-9) or math.floor(m + 1e-9) end return (up and math.ceil(m * 10 - 1e-9) or math.floor(m * 10 + 1e-9)) / 10 end -- D15-10: "دقيقة واحدة", "دقيقتان", "٣ دقائق" to "١٠ دقائق", "١١ دقيقة" and any decimal "٤٫٤ دقيقة" (nominative); "N min" in English. function M.minutes_phrase(value, language) if language ~= 'ar' then return tostring(value) .. ' min' end if value == 1 then return 'دقيقة واحدة' end if value == 2 then return 'دقيقتان' end if value == math.floor(value) and value >= 3 and value <= 10 then return minutes_text(value, 'ar') .. ' دقائق' end return minutes_text(value, 'ar') .. ' دقيقة' end -- P15-43: "Qawlak: N min left · buy a pack at " (Arabic when the key file's language is ar) when /api/plugin/ping says -- low_balance, else nil. D15-10: seconds_left rounded down when the server sends it (older servers: minutes_left as given). function M.low_balance_message(ping, base, language) if not (ping or ''):match('"low_balance"%s*:%s*true') then return nil end local seconds = ping:match('"seconds_left"%s*:%s*(%d+)') local left = seconds and M.minutes(seconds, false) or tonumber(ping:match('"minutes_left"%s*:%s*([%d%.]+)') or '0') local url = M.join_url(base, M.field(ping, 'buy_url') or '/pricing.html#packs') if language == 'ar' then return ('قولك: المتبقي %s · اشترِ باقة من %s'):format(M.minutes_phrase(left, 'ar'), url) end return ('Qawlak: %s left · buy a pack at %s'):format(M.minutes_phrase(left, 'en'), url) end -- P15-43: the 402 credit wall ({"needed_seconds", "available_seconds", "buy_url"}) as a message an editor can act on. function M.credit_wall_message(body, base, language) local needed = tonumber((body or ''):match('"needed_seconds"%s*:%s*(%d+)') or '0') local available = tonumber((body or ''):match('"available_seconds"%s*:%s*(%d+)') or '0') local url = M.join_url(base, M.field(body, 'buy_url') or '/pricing.html#packs') local need, have = M.minutes(needed, true), M.minutes(available, false) if language == 'ar' then return ('قولك: الرصيد لا يكفي لهذا الملف: المطلوب %s والمتبقي %s · اشترِ باقة من %s'):format(M.minutes_phrase(need, 'ar'), M.minutes_phrase(have, 'ar'), url) end return ('Qawlak: not enough minutes for this file: %s needed, %s left · buy a pack at %s'):format(M.minutes_phrase(need, 'en'), M.minutes_phrase(have, 'en'), url) end -- P15-45: a refusal's {"detail": "Arabic. / English."} as the half in the key file's language (the whole detail when it has -- one language), or nil when the answer carries no detail. function M.refusal_message(body, language) local detail = M.field(body, 'detail') if not detail then return nil end local ar, en = detail:match('^(.-) / (.+)$') if not ar then return 'Qawlak: ' .. detail end return language == 'ar' and ('قولك: ' .. ar) or ('Qawlak: ' .. en) end function M.downloads() return (os.getenv(WINDOWS and 'USERPROFILE' or 'HOME') or '') .. SEP .. 'Downloads' end function M.find_export(folder) local names = WINDOWS and M.run('dir /b /a-d /o-d ' .. M.q(folder .. '\\*.zip')) or M.run('ls -t ' .. M.q(folder)) local zips = {} for _, name in ipairs(M.lines(names)) do if name:lower():match('%.zip$') then zips[#zips + 1] = folder .. SEP .. name end end return M.newest_export(zips, function(zip) return M.run((WINDOWS and M.system_exe('tar') .. ' -tf ' or 'unzip -Z1 ') .. M.q(zip)) end) end function M.extract(zip) local dir = zip:gsub('%.[zZ][iI][pP]$', '') if WINDOWS then M.run('mkdir ' .. M.q(dir)) M.run(M.system_exe('tar') .. ' -xf ' .. M.q(zip) .. ' -C ' .. M.q(dir)) else M.run('unzip -o ' .. M.q(zip) .. ' -d ' .. M.q(dir)) end return dir end -- [menu path] P15-46. Everything from here to the closing tag runs from Workspace > Scripts in free Resolve, which has no -- ffi, io, os.execute or require, and where bmd's file calls stop the script dead (measured live, 15 Sep 2026). So only -- Resolve's own API, os.getenv and os.time live here; tests/test_resolve_lua.py fails the build if that changes. Files -- and the network are the Qawlak helper's job (docs/plugins/resolve-helper): it answers a render with .srt. M.LANGUAGE = 'ar' -- the status messages' language; set 'en' for English (a menu script cannot read resolve.json) M.WINDOW = false -- Fusion status window. OFF: free Resolve 21.1 answers fu.UIManager with the "limitation, buy Studio" dialog and the script stops (measured live 15 Sep 23:27). Markers carry the status; true only for Resolve Studio. local TEXT = { ar = { rendering = 'قولك: يُصدَّر صوت الخط الزمني…', waiting = 'قولك: بانتظار الترجمة من مساعد قولك. شغّل المساعد إن لم يكن يعمل.', placed = 'قولك: وُضعت الترجمة على مسار الترجمة ١ (%s مقطعاً، أولها بعد %s ثانية من البداية).', misplaced = 'قولك: وضع Resolve الترجمة بعد نهاية الخط الزمني (بعد %s ثانية من البداية) فأُزيلت. تجدها في Media Pool: اسحبها إلى مسار الترجمة عند البداية.', nested = 'قولك: لا يضع Resolve المجاني الترجمة في بداية خط زمني فيه مقاطع، فأُنشئ الخط الزمني "%s": المونتاج مقطع واحد متداخل، والترجمة على مسار الترجمة ١ (%s مقطعاً، أولها بعد %s ثانية من البداية).', reused = 'قولك: هذا التصدير موجود في الخط الزمني "%s"، وفُتح.', not_placed = 'قولك: الترجمة في Media Pool ولم تُوضع على الخط الزمني. اسحبها إليه أو استخدم File > Import > Subtitle.', not_imported = 'قولك: لم يستورد Resolve ملف الترجمة. استخدم File > Import > Subtitle.', helper_failed = 'قولك: لم تكتمل الترجمة. السبب في إشعار مساعد قولك وفي ملف resolve-helper.log.', timeout = 'قولك: لم تصل الترجمة خلال ٣٠ دقيقة. هل مساعد قولك يعمل؟', render_failed = 'قولك: لم يُصدِّر Resolve الصوت: %s', no_timeline = 'قولك: افتح مشروعاً وخطاً زمنياً أولاً.', no_audio = 'قولك: لا صوت في هذا الخط الزمني، فلا شيء يُفرَّغ. أضف مقطعاً فيه صوت ثم أعد المحاولة.', no_queue = 'قولك: تعذّر العثور على مجلد قولك (APPDATA).', no_export = 'قولك: لا يوجد تصدير جاهز. نزّل ملف ZIP من قولك ومساعد قولك يعمل، ثم أعد المحاولة.', no_timeline_made = 'قولك: تعذّر إنشاء خط زمني من المقطع المستورد.', created = 'قولك: أُنشئ الخط الزمني "%s" والترجمة على مسار الترجمة ١.', created_burned = 'قولك: أُنشئ الخط الزمني "%s". الترجمة مطبوعة في الفيديو، ونسخة قابلة للتعديل على مسار الترجمة ١ وهو مُطفأ؛ شغّله إن استبدلت الفيديو بالأصل.', rendering_range = 'قولك: يُصدَّر صوت المقطع بين علامتي الدخول والخروج فقط…', cancel_sent = 'قولك: طُلب الإلغاء. يوقف مساعد قولك المهمة الجارية ويُعاد رصيدها.', cancel_failed = 'قولك: تعذّر إرسال طلب الإلغاء إلى مساعد قولك.', }, en = { rendering = 'Qawlak: rendering the timeline audio…', waiting = 'Qawlak: waiting for the captions from the Qawlak helper. Start the helper if it is not running.', placed = 'Qawlak: captions on subtitle track 1 (%s cues, the first %s s after the start).', misplaced = 'Qawlak: Resolve put the captions after the end of the timeline (%s s from the start), so they were taken off. They are in the Media Pool: drag them onto the subtitle track at the start.', nested = 'Qawlak: free Resolve cannot put captions at the start of a timeline that already has clips, so timeline "%s" was made: your cut as one nested clip, with the captions on subtitle track 1 (%s cues, the first %s s after the start).', reused = 'Qawlak: this export is already on timeline "%s"; opened it.', not_placed = 'Qawlak: the captions are in the Media Pool but not on the timeline. Drag them on, or use File > Import > Subtitle.', not_imported = 'Qawlak: Resolve did not import the captions. Use File > Import > Subtitle.', helper_failed = 'Qawlak: captioning did not finish. The Qawlak helper notification and resolve-helper.log say why.', timeout = 'Qawlak: no captions after 30 minutes. Is the Qawlak helper running?', render_failed = 'Qawlak: Resolve did not render the audio: %s', no_timeline = 'Qawlak: open a project and a timeline first.', no_audio = 'Qawlak: this timeline has no audio to caption. Add a clip with sound, then try again.', no_queue = 'Qawlak: cannot find the Qawlak folder (APPDATA).', no_export = 'Qawlak: no export is ready. Download an export ZIP from Qawlak with the Qawlak helper running, then try again.', no_timeline_made = 'Qawlak: could not create a timeline from the imported clip.', created = 'Qawlak: created timeline "%s" with the captions on subtitle track 1.', created_burned = 'Qawlak: created timeline "%s". The video already shows the captions, so the editable copy on subtitle track 1 is turned off; turn it on if you swap in the source video.', rendering_range = 'Qawlak: rendering the audio between your In and Out marks only…', cancel_sent = 'Qawlak: cancel requested. The Qawlak helper stops the running job and its minutes are refunded.', cancel_failed = 'Qawlak: could not send the cancel request to the Qawlak helper.', }, } local function text(language, key) return (TEXT[language] or TEXT.ar)[key] end function M.pause(seconds) -- no bmd.wait (untested from the menu) and no shell: a plain clock loop local t = os.time() while os.time() - t < seconds do end end function M.queue_dir() local ok, dir = pcall(function() local appdata = os.getenv('APPDATA') if appdata then return appdata .. '\\Qawlak\\resolve-queue' end return os.getenv('HOME') .. '/Library/Application Support/Qawlak/resolve-queue' end) return ok and dir or nil end -- The render formats to try, in order. Free 21.1 (measured live) lists a "Wave" format with no codec (15 Sep), and on 16 Sep -- StartRendering refused a job in MP4 H.264 after the editor had used QuickTime H.264, while MP4 H.265 and QuickTime H.264 -- rendered at once. So WAV comes first when it has a codec, then MP4 H.264, MP4 H.265, QuickTime H.264, then any other -- MP4 codec, and the next one is tried whenever Resolve will not start a job. function M.render_formats(project) local function codecs(fmt) local list = {} for _, c in pairs(project:GetRenderCodecs(fmt) or {}) do if type(c) == 'string' then list[#list + 1] = c end -- Resolve's tables also carry __flags = end table.sort(list) return list end local out, seen = {}, {} local function add(fmt, codec) if not seen[fmt .. codec] then seen[fmt .. codec] = true; out[#out + 1] = {fmt, codec} end end local listed = {} for _, fmt in ipairs({'wav', 'mp4', 'mov'}) do listed[fmt] = {} for _, c in ipairs(codecs(fmt)) do listed[fmt][c] = true end end for _, c in ipairs(codecs('wav')) do add('wav', c) end for _, fc in ipairs({{'mp4', 'H264'}, {'mp4', 'H265'}, {'mov', 'H264'}}) do if listed[fc[1]][fc[2]] then add(fc[1], fc[2]) end end for _, c in ipairs(codecs('mp4')) do add('mp4', c) end return out end -- Render the timeline's audio to dir/name. and wait for it; returns ext. Proven from the menu (15 Sep 2026, 23:06). -- The Deliver page keeps whatever a script sets, so the editor's settings are saved as a temporary preset first and loaded -- back afterwards (R29: the next real render went audio-only into the queue folder). The settings the helper's file naming -- depends on are set explicitly: a single clip, no unique-filename prefix or suffix, and no subtitle file, which would -- land in the queue as .srt and be imported as if the helper had answered. -- The Deliver page as the editor left it. The API has no settings getter, so a job is added and deleted at once and read back -- from GetRenderJobList (live 16 Sep 2026: LoadRenderPreset of a preset saved with SaveAsNewRenderPreset restored nothing in -- free 21.1, and the editor's next render went audio-only into the queue folder). No job can be added while the Location is -- empty; then only the format, codec, mode and "Export Video" on are put back. function M.deliver_state(project) local s = {} pcall(function() local fc = project:GetCurrentRenderFormatAndCodec(); s.format, s.codec = fc.format, fc.codec end) pcall(function() s.mode = project:GetCurrentRenderMode() end) pcall(function() local job = project:AddRenderJob() if not job or job == '' then return end for _, j in ipairs(project:GetRenderJobList() or {}) do if j.JobId == job then s.job = j end end project:DeleteRenderJob(job) end) return s end -- ponytail: "Use unique filenames" and "Export subtitle" cannot be read back, so they stay off after a round trip. function M.restore_deliver(project, s) if s.format and s.codec then pcall(function() project:SetCurrentRenderFormatAndCodec(s.format, s.codec) end) end if s.mode then pcall(function() project:SetCurrentRenderMode(s.mode) end) end local j, settings = s.job, {{ExportVideo = true}, {ExportAudio = true}} if j then settings = {{ExportVideo = j.IsExportVideo == true}, {ExportAudio = j.IsExportAudio == true}} if s.whole then settings[#settings + 1] = {SelectAllFrames = true} -- "Entire Timeline", not an In/Out range that happens to match else settings[#settings + 1] = {MarkIn = j.MarkIn}; settings[#settings + 1] = {MarkOut = j.MarkOut} end if (j.TargetDir or '') ~= '' then settings[#settings + 1] = {TargetDir = j.TargetDir} end local custom = (j.OutputFilename or ''):gsub('%.[^.]*$', '') if custom ~= '' and custom ~= j.TimelineName then settings[#settings + 1] = {CustomName = custom} end end for _, one in ipairs(settings) do pcall(function() project:SetRenderSettings(one) end) end -- one by one: a refused key sinks only itself end -- K31 (Karan: bill In/Out only): the editor's In/Out marks as absolute record frames {first, last}, or nil for the whole -- timeline (no marks, or marks that cover all of it). Timeline:GetMarkInOut answers frames relative to the timeline start -- (Resolve 21 scripting API, DaVinciResolveScript.pyi); render MarkIn/MarkOut are absolute, like GetStartFrame. Video marks -- first, then audio; a lone In runs to the end, a lone Out from the start. -- K31 live question: free 21.1 has never been measured. A build that answers absolute record frames instead would be -- shifted a second time and the render's -in offset would be wrong, so a mark that cannot be an offset (it is at or -- past the timeline's own length) is taken as already absolute. -- ponytail: ambiguous only for a timeline longer than its own start frame (a cut over an hour starting at 01:00:00:00); -- if that ever matters, read the start timecode instead. local function absolute_frame(v, start, length) if v == nil then return nil end return v >= length and v or start + v end function M.mark_range(timeline) local ok, marks = pcall(function() return timeline:GetMarkInOut() end) if not ok or type(marks) ~= 'table' then return nil end for _, kind in ipairs({'video', 'audio'}) do local r = type(marks[kind]) == 'table' and marks[kind] or {} local a, b = tonumber(r['in']), tonumber(r['out']) if a or b then local start, last = timeline:GetStartFrame(), timeline:GetEndFrame() - 1 local length = last - start + 1 local first = math.max(start, absolute_frame(a, start, length) or start) local final = math.min(last, absolute_frame(b, start, length) or last) if final <= first or (first == start and final == last) then return nil end return {first, final} end end end -- range: {first, last} absolute frames to render instead of the whole timeline (M.mark_range). function M.render_job(resolve, project, timeline, dir, name, pause, range) project:SetCurrentTimeline(timeline) pcall(function() resolve:OpenPage('deliver') end) local before = M.deliver_state(project) pcall(function() before.whole = before.job.MarkIn == timeline:GetStartFrame() and before.job.MarkOut == timeline:GetEndFrame() - 1 end) local preset = 'Qawlak restore ' .. name local called, saved = pcall(function() return project:SaveAsNewRenderPreset(preset) end) saved = called and saved == true local function restore() if saved then pcall(function() project:LoadRenderPreset(preset) end); pcall(function() project:DeleteRenderPreset(preset) end) end M.restore_deliver(project, before) pcall(function() resolve:OpenPage('edit') end) end local job, ext for _, fc in ipairs(M.render_formats(project)) do if project:SetCurrentRenderFormatAndCodec(fc[1], fc[2]) then pcall(function() project:SetCurrentRenderMode(1) end) for _, video in ipairs({false, true}) do -- audio only first; AddRenderJob answers "" (truthy) when it adds nothing local settings = {SelectAllFrames = range == nil, TargetDir = dir, CustomName = name, ExportVideo = video, ExportAudio = true, UseUniqueFilenames = false, ExportSubtitle = false} if range then settings.MarkIn, settings.MarkOut = range[1], range[2] end project:SetRenderSettings(settings) local id = project:AddRenderJob() if id and id ~= '' then if project:StartRendering(id) then job, ext = id, fc[1]; break end pcall(function() project:DeleteRenderJob(id) end) -- refused: never left in the editor's Render Queue end end end if job then break end end if not job then restore() error('Resolve refused the render job in every format it offers (WAV, MP4, QuickTime).', 0) end local waited = 0 while project:IsRenderingInProgress() and waited < 3600 do (pause or M.pause)(1); waited = waited + 1 end local status = project:GetRenderJobStatus(job) or {} pcall(function() project:DeleteRenderJob(job) end) -- leave the Render Queue as the editor had it restore() if status.JobStatus ~= 'Complete' then error('the render did not complete: ' .. tostring(status.JobStatus), 0) end return ext end -- Resolve's API lists are arrays plus a "__flags" number (measured live 16 Sep 2026), so they are walked with ipairs only. local function pick_subtitle(items) local first for _, item in ipairs(items or {}) do first = first or item local ok, kind = pcall(function() return item:GetClipProperty('Type') end) if ok and kind == 'Subtitle' then return item end end return first end local function digits(value, language) local s = tostring(value) return language == 'ar' and (s:gsub('[%d%.]', ARABIC_DIGITS)) or s end local function track_span(timeline, kind) -- first start, last end and item count on track 1 local first, last, n = nil, nil, 0 for _, it in ipairs(timeline:GetItemListInTrack(kind, 1) or {}) do n = n + 1 local s, e = it:GetStart(), it:GetEnd() if not first or s < first then first = s end if not last or e > last then last = e end end return first, last, n end local function fps_of(timeline) return tonumber(timeline:GetSetting('timelineFrameRate')) or 25 end local function seconds(frames, fps, language) return digits(math.floor(frames / fps * 10 + 0.5) / 10, language) end -- Captions starting here or later "landed after the end": speech cannot begin in the last second (or last tenth) of the -- media it was transcribed from. Free Resolve 21.1 puts them right after the video (measured live 15 Sep 23:28, ~11.7 s). local function late_cutoff(start, content_end, fps) return content_end - math.min(fps, math.floor((content_end - start) / 10)) end -- The subtitle clip info. startFrame/endFrame ride along in case Resolve honours recordFrame only with a source range. local function subtitle_info(item, start) local info = {mediaPoolItem = item, trackIndex = 1, recordFrame = start} local ok, frames = pcall(function() return tonumber(item:GetClipProperty('Frames')) end) if ok and frames and frames > 0 then info.startFrame, info.endFrame = 0, frames - 1 end return info end -- Put the .srt on subtitle track 1 of an EXISTING timeline at its start. Free Resolve 21.1 ignores recordFrame for -- subtitle items and appends them after the timeline's end (measured live 15 Sep 23:28), so the placement is checked: -- cues that landed after the end are taken back off and the editor is told, never told "placed". -- items: what ImportMedia already returned (the menu round trip polls with it). Returns placed, message. -- ponytail: with cues already on track 1 the first-cue check reads the earliest of all of them. Upgrade path: -- compare TimelineItem:GetUniqueId() before and after. function M.add_subtitles(resolve, project, timeline, srt, items, language) language = language or M.LANGUAGE local pool = project:GetMediaPool() project:SetCurrentTimeline(timeline) pcall(function() resolve:OpenPage('edit') end) local item = pick_subtitle(items or pool:ImportMedia({srt})) if not item then return false, text(language, 'not_imported') end if (timeline:GetTrackCount('subtitle') or 0) < 1 then timeline:AddTrack('subtitle') end local start, content_end = timeline:GetStartFrame(), timeline:GetEndFrame() local _, _, before = track_span(timeline, 'subtitle') pcall(function() return pool:AppendToTimeline({subtitle_info(item, start)}) end) local first, _, n = track_span(timeline, 'subtitle') local placed, fps = n - before, fps_of(timeline) if placed <= 0 then return false, text(language, 'not_placed') end local cutoff = late_cutoff(start, content_end, fps) if content_end > start and first >= cutoff then local late = {} for _, cue in ipairs(timeline:GetItemListInTrack('subtitle', 1) or {}) do if cue:GetStart() >= cutoff then late[#late + 1] = cue end end pcall(function() timeline:DeleteClips(late, false) end) return false, text(language, 'misplaced'):format(seconds(first - start, fps, language)), true end return true, text(language, 'placed'):format(digits(placed, language), seconds(first - start, fps, language)) end -- A NEW captioned timeline: captions first, while the timeline is empty (so "after the end" is the start), then the video -- at recordFrame. Kept only if both really start at the timeline start; otherwise deleted and nil is returned. -- start_tc: the timecode the new timeline starts at (the round trip's copy keeps the cut's clock; a new timeline otherwise -- starts at the project default, 01:00:00:00). local function captioned_timeline(project, pool, name, clip, sub, start_tc) local timeline = pool:CreateEmptyTimeline(name) or pool:CreateEmptyTimeline(name .. ' ' .. os.time()) if not timeline then return nil end project:SetCurrentTimeline(timeline) if start_tc then pcall(function() timeline:SetStartTimecode(start_tc) end) end timeline:AddTrack('subtitle') local start = timeline:GetStartFrame() pcall(function() return pool:AppendToTimeline({subtitle_info(sub, start)}) end) pcall(function() return pool:AppendToTimeline({{mediaPoolItem = clip, trackIndex = 1, recordFrame = start}}) end) local v_first, v_end = track_span(timeline, 'video') local c_first, _, n = track_span(timeline, 'subtitle') if v_first == start and n > 0 and c_first < late_cutoff(start, v_end, fps_of(timeline)) then return timeline end pcall(function() pool:DeleteTimelines({timeline}) end) end -- The round trip on an existing cut. Free 21.1 puts subtitle items after its end whatever the call (R29 placement probe, live -- 16 Sep 2026: append with recordFrame and from the playhead both land +49 s, ImportIntoTimeline places none), while a new -- timeline built captions-first with the cut nested at the start puts the cues where they belong. So " (Qawlak)" is made -- and the cut itself is left as it was. ponytail: the copy takes the project's frame rate and size; a cut with its own -- timeline settings would need them copied (Timeline:GetSetting/SetSetting) first. function M.nested_copy(project, pool, cut, sub) local ok, clip = pcall(function() return cut:GetMediaPoolItem() end) if not (ok and clip and sub) then return nil end local tc_ok, start_tc = pcall(function() return cut:GetStartTimecode() end) return captioned_timeline(project, pool, cut:GetName() .. ' (Qawlak)', clip, sub, tc_ok and start_tc or nil) end -- A timeline this export already made: same name (or name + a stamp), same source file and length, captions on it. -- Each export has its own folder (K31), so its path tells exports apart; only the fixed-name fallback can still reopen an -- older timeline for a newer export of the same length. local function find_imported(project, name, clip) local path, frames = clip:GetClipProperty('File Path'), clip:GetClipProperty('Frames') for i = 1, project:GetTimelineCount() or 0 do local tl = project:GetTimelineByIndex(i) local tname = tl and tl:GetName() or '' if tname == name or tname:sub(1, #name + 1) == name .. ' ' then local v = (tl:GetItemListInTrack('video', 1) or {})[1] local media = v and v:GetMediaPoolItem() local _, _, cues = track_span(tl, 'subtitle') if media and cues > 0 and media:GetClipProperty('File Path') == path and media:GetClipProperty('Frames') == frames then return tl end end end end -- Progress for the editor: a Fusion window when fu.UIManager works from the menu (untested live), and always a marker at -- the timeline start whose note is the message (customData "qawlak", so the editor's own markers are never touched). function M.status(timeline, window) local s = {timeline = timeline} if window then pcall(function() local ui = fu.UIManager local disp = bmd.UIDispatcher(ui) local win = disp:AddWindow({ID = 'QawlakStatus', WindowTitle = 'Qawlak', Geometry = {160, 160, 520, 150}}, ui:VGroup({ui:Label({ID = 'Message', Text = '', WordWrap = true})})) win.On.QawlakStatus.Close = function() disp:ExitLoop() end win:Show() s.win, s.disp, s.label = win, disp, win:GetItems().Message end) end function s.say(message, color) pcall(function() s.timeline:DeleteMarkerByCustomData('qawlak') s.timeline:AddMarker(0, color or 'Yellow', 'Qawlak', message, 1, 'qawlak') end) if s.label then pcall(function() s.label.Text = message end) end end function s.finish(message, ok) s.say(message, ok and 'Green' or 'Red') if s.disp then if ok then pcall(function() s.timeline:DeleteMarkerByCustomData('qawlak') end) end -- the window says it; no clutter pcall(function() s.disp:RunLoop() end) -- until the editor closes the window pcall(function() s.win:Hide() end) end return ok, message end return s end local function has_items(items) return items ~= nil and items[1] ~= nil end -- an empty answer still holds __flags -- ImportMedia of an .srt that does not exist yet does not fail in free 21.1: it adds an offline subtitle clip with a zero -- Duration, and importing the same path once the file exists hands back that same empty clip (measured live 16 Sep 2026). -- So a zero-length answer is deleted again and counts as "not there yet"; the next poll then imports the real file. function M.import_ready(pool, path) local items = pool:ImportMedia({path}) if not has_items(items) then return nil end for _, item in ipairs(items) do local ok, duration = pcall(function() return item:GetClipProperty('Duration') end) if not ok or not duration or tostring(duration):match('[1-9]') then return items end end pcall(function() pool:DeleteClips(items) end) end -- ImportMedia hands back the Media Pool clip it made from a path before, even after the file changed (live 16 Sep 2026: a second -- export's Import paired the new video with the first export's captions). Cues already on a timeline survive deleting that -- clip, so it is deleted first and the file is read as it is now. function M.import_fresh(pool, path) local stale, seen = {}, {} for _, folder in ipairs({pool:GetRootFolder(), pool:GetCurrentFolder()}) do for _, clip in ipairs(folder and folder:GetClipList() or {}) do local ok, file = pcall(function() return clip:GetClipProperty('File Path') end) if ok and file == path and not seen[tostring(clip)] then seen[tostring(clip)] = true; stale[#stale + 1] = clip end end end if stale[1] then pcall(function() pool:DeleteClips(stale) end) end return M.import_ready(pool, path) end -- Workspace > Scripts > Qawlak Round-trip from the menu: render into the queue, then poll ImportMedia for the helper's -- answer (it returns nothing until the file exists; the helper writes it by rename, so it is never half there). -- opts: choice (a menu entry's language/dialect, e.g. 'gulf', 'english'; nil = resolve.json's), and for tests pause, queue, -- window, language, wait_seconds. -- The render's name is the only thing the helper learns from Resolve, so it carries the run's settings (K31): -- -[-inms][-x] -in: the In mark's offset from the timeline start, so the helper shifts the -- cues onto the timeline's clock; -x: the menu entry's language/dialect. function M.menu_roundtrip(resolve, opts) opts = opts or {} local language, pause = opts.language or M.LANGUAGE, opts.pause or M.pause local project = resolve:GetProjectManager():GetCurrentProject() local timeline = project and project:GetCurrentTimeline() local status = M.status(timeline, opts.window ~= false and M.WINDOW) if not timeline then return status.finish(text(language, 'no_timeline'), false) end local dir = opts.queue or M.queue_dir() if not dir then return status.finish(text(language, 'no_queue'), false) end local audio = 0 -- live R29: a timeline with no audio made Resolve refuse the render in every format, which said nothing useful for track = 1, timeline:GetTrackCount('audio') or 0 do audio = audio + #(timeline:GetItemListInTrack('audio', track) or {}) end if audio == 0 then return status.finish(text(language, 'no_audio'), false) end local name = (tostring(timeline:GetName() or ''):gsub('[^%w%-_]', '')):sub(1, 40) name = (name == '' and 'timeline' or name) .. '-' .. os.time() local range = M.mark_range(timeline) if range then name = name .. '-in' .. math.floor((range[1] - timeline:GetStartFrame()) / fps_of(timeline) * 1000 + 0.5) .. 'ms' end if type(opts.choice) == 'string' and opts.choice:match('^%l+$') then name = name .. '-x' .. opts.choice end status.say(text(language, range and 'rendering_range' or 'rendering')) local ok, err = pcall(M.render_job, resolve, project, timeline, dir, name, pause, range) if not ok then return status.finish(text(language, 'render_failed'):format(tostring(err)), false) end status.say(text(language, 'waiting')) local pool = project:GetMediaPool() local srt, refused = dir .. SEP .. name .. '.srt', dir .. SEP .. name .. '.error.srt' for _ = 1, math.ceil((opts.wait_seconds or 1800) / 2) do local items = M.import_ready(pool, srt) if items then local placed, message, late = M.add_subtitles(resolve, project, timeline, srt, items, language) if late then -- the R29 probe's method D: a new timeline, captions first, then the whole cut nested at the start local copy = M.nested_copy(project, pool, timeline, pick_subtitle(items)) if copy then local first, _, n = track_span(copy, 'subtitle') placed = true message = text(language, 'nested'):format(copy:GetName(), digits(n, language), seconds(first - copy:GetStartFrame(), fps_of(copy), language)) status.say(message, 'Green') -- the note stays on the cut too, so the editor finds the new timeline status.timeline = copy else project:SetCurrentTimeline(timeline) end end return status.finish(message, placed) end local bad = M.import_ready(pool, refused) if bad then pcall(function() pool:DeleteClips(bad) end) return status.finish(text(language, 'helper_failed'), false) end pause(2) end return status.finish(text(language, 'timeout'), false) end -- Workspace > Scripts > Qawlak Cancel (K31). A menu script cannot write a file, but Timeline:Export can: an EDL named -- cancel-.edl in the queue is the request. The helper cancels the job of every render named before that time -- (POST /api/jobs/{id}/cancel, refunded) and answers .error.srt, so a waiting Round-trip stops. function M.menu_cancel(resolve, opts) opts = opts or {} local language = opts.language or M.LANGUAGE local project = resolve:GetProjectManager():GetCurrentProject() local timeline = project and project:GetCurrentTimeline() local status = M.status(timeline, false) if not timeline then return status.finish(text(language, 'no_timeline'), false) end local dir = opts.queue or M.queue_dir() if not dir then return status.finish(text(language, 'no_queue'), false) end local ok, done = pcall(function() return timeline:Export(dir .. SEP .. 'cancel-' .. os.time() .. '.edl', resolve.EXPORT_EDL, resolve.EXPORT_NONE) end) if not (ok and done) then return status.finish(text(language, 'cancel_failed'), false) end return status.finish(text(language, 'cancel_sent'), true) end -- K31: the helper unpacks every export into its own folder \import\ (so a newer export never replaces the file -- an earlier imported timeline uses). A menu script cannot list a folder, but Resolve's Media Storage can. The biggest number is -- the newest; nil when the listing gives nothing, and then the fixed names the helper still keeps in import\ are used. function M.newest_import(resolve, dir) local ok, list = pcall(function() return resolve:GetMediaStorage():GetSubFolderList(dir .. SEP .. 'import') end) local best, path for _, one in ipairs(ok and type(list) == 'table' and list or {}) do local folder = tostring(one):gsub('[\\/]+$', '') local stamp = tonumber(folder:match('[\\/](%d+)$')) if stamp and (not best or stamp > best) then best, path = stamp, folder end end return path end -- Workspace > Scripts > Qawlak Import from the menu: the helper unpacks the newest export ZIP in Downloads into -- \import\ under fixed names (a menu script cannot list a folder), and this imports those. function M.menu_import(resolve, opts) opts = opts or {} local language = opts.language or M.LANGUAGE local project = resolve:GetProjectManager():GetCurrentProject() local status = M.status(project and project:GetCurrentTimeline(), opts.window ~= false and M.WINDOW) if not project then return status.finish(text(language, 'no_timeline'), false) end local dir = opts.queue or M.queue_dir() if not dir then return status.finish(text(language, 'no_queue'), false) end local folder, pool, clip, video = M.newest_import(resolve, dir) or (dir .. SEP .. 'import'), project:GetMediaPool(), nil, nil for _, file in ipairs(VIDEO_PRIORITY) do local items = pool:ImportMedia({folder .. SEP .. file}) if has_items(items) then clip, video = items[1], file; break end end if not clip then return status.finish(text(language, 'no_export'), false) end local name = 'Qawlak - ' .. video:match('^(.-)%.%w+$') local found, existing = pcall(find_imported, project, name, clip) if found and existing then -- a second click, or the same export again: open what it already made project:SetCurrentTimeline(existing) status.timeline = existing return status.finish(text(language, 'reused'):format(existing:GetName()), true) end local srt = folder .. SEP .. 'captions-both.srt' local subs = M.import_fresh(pool, srt) local sub = pick_subtitle(subs) local timeline = sub and captioned_timeline(project, pool, name, clip, sub) if timeline then status.timeline = timeline -- captioned.mp4 has the captions burned in (live 16 Sep 2026: two lines on screen), so the subtitle track starts off if video == 'captioned.mp4' and pcall(function() assert(timeline:SetTrackEnable('subtitle', 1, false)) end) then return status.finish(text(language, 'created_burned'):format(timeline:GetName()), true) end return status.finish(text(language, 'created'):format(timeline:GetName()), true) end -- The captions-first build did not hold: the plain timeline, and add_subtitles says honestly where the captions went. timeline = pool:CreateTimelineFromClips(name, {clip}) or pool:CreateTimelineFromClips(name .. ' ' .. os.time(), {clip}) if not timeline then return status.finish(text(language, 'no_timeline_made'), false) end status.timeline = timeline local placed, message = M.add_subtitles(resolve, project, timeline, srt, has_items(subs) and subs or nil, language) return status.finish(placed and text(language, 'created'):format(timeline:GetName()) or message, placed) end -- [/menu path] -- Console (F6) path: it has ffi, so it keeps reading Downloads itself. function M.import_export(resolve) local zip = M.find_export(M.downloads()) if not zip then return print('Qawlak: no Qawlak export ZIP (with captions-both.srt) in ' .. M.downloads()) end print('Qawlak: importing ' .. zip) local dir = M.extract(zip) local video for _, name in ipairs(VIDEO_PRIORITY) do if M.exists(dir .. SEP .. name) then video = dir .. SEP .. name; break end end if not video then return print('Qawlak: no video in the export; render the project before exporting.') end local project = resolve:GetProjectManager():GetCurrentProject() if not project then return print('Qawlak: no project is open in Resolve.') end local pool = project:GetMediaPool() local imported = pool:ImportMedia({video}) if not imported or not imported[1] then return print('Qawlak: Resolve could not import ' .. video) end local name = 'Qawlak - ' .. video:match('([^\\/]+)%.%w+$') local timeline = pool:CreateTimelineFromClips(name, {imported[1]}) or pool:CreateTimelineFromClips(name .. ' ' .. os.date('%H%M%S'), {imported[1]}) -- a timeline of that name exists if not timeline then return print('Qawlak: could not create a timeline from the imported clip.') end local srt = dir .. SEP .. 'captions-both.srt' local message = M.exists(srt) and select(2, M.add_subtitles(resolve, project, timeline, srt, nil, 'en')) or 'no captions in the export' print(('Qawlak: "%s" with %s. %s'):format(timeline:GetName(), video, message)) end if QAWLAK_LIB or AS_LIBRARY then return M end local app = resolve or (Resolve and Resolve()) if not app then return print('Qawlak: run this from Workspace > Scripts inside DaVinci Resolve.') end if ffi then local ok, err = pcall(M.import_export, app) if not ok then print('Qawlak: ' .. tostring(err)) end else M.menu_import(app) end