--[[ Script Installer Header for Subaba.lua Original installer pattern by Asher Roland (FusionPixelStudio), Feb 2025 Adapted for Subaba by Claude First run: copies this file into the correct Scripts/Utility/ folder. Subsequent runs: detects it's already installed and skips straight to the main script. Compatible with free DaVinci Resolve users (uses comp:AskUser, not UIManager). ]]-- local scriptsPath = app:MapPath("Scripts:") -- Resolves to the correct OS path automatically, including the Support folder local newPath = "Utility/Subaba.lua" -- Where to install inside the Scripts folder local installPath = scriptsPath .. newPath local currentPath = arg[0] -- Path of THIS currently running file -- Returns true if this script is already running from inside the Scripts folder local function ScriptIsInstalled() local match = currentPath:find(scriptsPath, 1, true) return match ~= nil end local SCRIPT_INSTALLED = ScriptIsInstalled() -- comp is only needed for the installer's AskUser dialogs; main script uses Resolve API + browser UI local fu = fu or Fusion() local comp = comp or (fu and fu.CurrentComp) if not comp and not SCRIPT_INSTALLED then print("Please run Subaba with a Fusion Comp open (or a project loaded).") return end -- Popup dialog using comp:AskUser (works on free Resolve, unlike UIManager which was removed in 19.1) local function askUser(customTitle, msg) if not comp then print("[Subaba] " .. customTitle .. (msg and (": " .. msg) or "")) return nil end local win = {} if msg then win[1] = { "Msg", "Text", Name = "Message: ", ReadOnly = true, Lines = 5, Wrap = true, Default = msg } else win[1] = { "Msg", "Text", Name = "Message: ", ReadOnly = true, Lines = 2, Wrap = true, Default = customTitle } end return comp:AskUser(customTitle, win) end -- Plain text file copy using standard Lua io (no bmd dependency for the copy itself) local function CopyFile(source, target) local src = io.open(source, "r") if not src then print("Installer: Could not open source: " .. tostring(source)) return false end local contents = src:read("*a") src:close() local dst = io.open(target, "w") if not dst then print("Installer: Could not open destination: " .. tostring(target)) return false end dst:write(contents) dst:close() return true end -- Runs the install: copies this file to the Scripts/Utility/ folder local function installScript() print("[Subaba Installer] Starting installation...") print("[Subaba Installer] Source: " .. tostring(currentPath)) print("[Subaba Installer] Destination: " .. tostring(installPath)) if not bmd.fileexists(currentPath) then print("[Subaba Installer] ERROR: Cannot access source file.") askUser("ERROR", "Could not access current file location:\n" .. tostring(currentPath)) return end print("[Subaba Installer] Copying file...") local ok = CopyFile(currentPath, installPath) if not ok then print("[Subaba Installer] ERROR: File copy failed.") askUser("ERROR", "Failed to copy Subaba to:\n" .. installPath .. "\n\nFrom:\n" .. currentPath) return end print("[Subaba Installer] Success! Installed to: " .. installPath) print("") print(" ███████╗██╗ ██╗██████╗ █████╗ ██████╗ █████╗ ") print(" ██╔════╝██║ ██║██╔══██╗██╔══██╗██╔══██╗██╔══██╗") print(" ███████╗██║ ██║██████╔╝███████║██████╔╝███████║") print(" ╚════██║██║ ██║██╔══██╗██╔══██║██╔══██╗██╔══██║") print(" ███████║╚██████╔╝██████╔╝██║ ██║██████╔╝██║ ██║") print(" ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚═╝ ╚═╝") print("") print(" Installed! Restart DaVinci Resolve to use Subaba.") print("") askUser("Subaba Installed!", "Subaba was installed successfully.\n\n" .. "Location: " .. installPath .. "\n\n" .. "Please restart DaVinci Resolve.") end -- Only install if we're NOT already running from inside the Scripts folder if not SCRIPT_INSTALLED then print("[Subaba Installer] Not installed yet — running installer.") installScript() -- Clean up installer variables before handing off to the main script fu = nil; comp = nil; installPath = nil; currentPath = nil newPath = nil; scriptsPath = nil; SCRIPT_INSTALLED = nil collectgarbage("collect") return -- Stop here — user needs to restart Resolve before using Subaba properly end -- ============================================================ -- MAIN SUBABA SCRIPT STARTS BELOW -- ============================================================ -- SubabaExport.lua -- Unified DaVinci Resolve workflow: -- - Browser UI (auth + subtitle settings + Subtitles/CUTS tabs) -- - Audio export from selected timeline tracks -- - Server-side SRT generation via https://sababa.onrender.com/api/generate-srt -- - Subtitle import as Text+ clips (embedded import logic) ---@diagnostic disable: undefined-global -- ============================================================ -- 1. Platform detection + temp paths -- ============================================================ local sep = package.config:sub(1, 1) local isWindows = (sep == "\\") local tempDir = isWindows and (os.getenv("TEMP") or ".") or "/tmp" local htmlPath = tempDir .. sep .. "subaba_export.html" local settingsFile = tempDir .. sep .. "subaba_settings.json" local launcherPath = tempDir .. sep .. "subaba_launch.html" local serverScript = tempDir .. sep .. (isWindows and "subaba_server.ps1" or "subaba_server.pl") local uploadScript = tempDir .. sep .. "subaba_upload.ps1" local apiRawPath = tempDir .. sep .. "subaba_api_raw.txt" local activePortFile = tempDir .. sep .. "subaba_active_port.txt" -- Persistent settings file (caption language, maxWords, maxCharsPerLine) - lives next to the script local persistSettingsDir = isWindows and ((os.getenv("APPDATA") or ".") .. "\\Blackmagic Design\\DaVinci Resolve\\Support\\Fusion\\Scripts\\Utility") or ((os.getenv("HOME") or ".") .. "/Library/Application Support/Blackmagic Design/DaVinci Resolve/Fusion/Scripts/Utility") local persistentSettingsFile = persistSettingsDir .. sep .. "SubabaSettings.json" -- Persistent auth session file (token, username, minutes) - survives port changes local sessionFile = persistSettingsDir .. sep .. "SubabaSession.json" -- Two fixed ports; each launch picks the one NOT used last time local PORT_A = 19847 local PORT_B = 19848 -- PORT/stopFile/liveDataPath/readyFile are upvalues re-assigned in section 12 local PORT = PORT_A local stopFile = tempDir .. sep .. "subaba_stop_" .. PORT local liveDataPath = tempDir .. sep .. "subaba_live_" .. PORT .. ".json" local readyFile = tempDir .. sep .. "subaba_ready_" .. PORT local function busyWait(seconds) local t0 = os.clock() while os.clock() - t0 < seconds do end end local function fileExists(path) local f = io.open(path, "rb") if f then f:close(); return true end return false end local function readAll(path) local f = io.open(path, "rb") if not f then return nil end local v = f:read("*a") f:close() return v end local function writeAll(path, content) -- Atomic write: write to temp file then rename to avoid race conditions -- where readers (PowerShell server) see partial/mixed content local tmpPath = path .. ".tmp" local f = io.open(tmpPath, "wb") if not f then return false end f:write(content or "") f:flush() f:close() os.remove(path) os.rename(tmpPath, path) return true end -- Forward-declared; assigned after JSON is defined below local loadPersistentSettings local function savePersistentSettings(s) writeAll(persistentSettingsFile, jsonEncode(s)) print("[SubabaExport] Saved persistent settings: " .. jsonEncode(s)) end local function psQuote(s) s = tostring(s or "") return '"' .. s:gsub('"', '\\"') .. '"' end local function shQuote(s) s = tostring(s or "") return "'" .. s:gsub("'", "'\\''") .. "'" end local function toFileUrl(path) local p = tostring(path or "") if isWindows then p = p:gsub("\\", "/") if p:match("^[A-Za-z]:/") then return "file:///" .. p:gsub(" ", "%%20") end return "file://" .. p:gsub(" ", "%%20") end return "file://" .. p:gsub(" ", "%%20") end -- ============================================================ -- 2. FFI ShellExecute for zero-flash process launch (Windows) -- ============================================================ local shellExecA if isWindows then pcall(function() local ffi = require("ffi") pcall(function() ffi.cdef[[void* ShellExecuteA(void*,const char*,const char*,const char*,const char*,int);]] end) shellExecA = ffi.load("shell32").ShellExecuteA end) end -- ============================================================ -- 3. Resolve connection -- ============================================================ local function getResolve() if resolve then return resolve end if Resolve then local r = Resolve() if r then return r end end return nil end local resolve = getResolve() if not resolve then print("[SubabaExport] ERROR: No Resolve.") return end local projectManager = resolve:GetProjectManager() local project = projectManager:GetCurrentProject() if not project then print("[SubabaExport] ERROR: No project.") return end local timeline = project:GetCurrentTimeline() if not timeline then print("[SubabaExport] ERROR: No timeline.") return end local mediaPool = project:GetMediaPool() -- ============================================================ -- 4. JSON helpers -- ============================================================ local function jsonEscape(str) return tostring(str) :gsub('\\', '\\\\') :gsub('"', '\\"') :gsub('\n', '\\n') :gsub('\r', '\\r') :gsub('\t', '\\t') end local function jsonEncode(val) local t = type(val) if t == "nil" then return "null" end if t == "boolean" then return val and "true" or "false" end if t == "number" then return tostring(val) end if t == "string" then return '"' .. jsonEscape(val) .. '"' end if t == "table" then local isArray = true local idx = 1 for k, _ in pairs(val) do if k ~= idx then isArray = false break end idx = idx + 1 end local out = {} if isArray then for i = 1, #val do out[#out + 1] = jsonEncode(val[i]) end return "[" .. table.concat(out, ",") .. "]" end for k, v in pairs(val) do out[#out + 1] = jsonEncode(tostring(k)) .. ":" .. jsonEncode(v) end return "{" .. table.concat(out, ",") .. "}" end return "null" end local JSON = {} function JSON.decode(str) local pos = 1 local function skipWS() pos = str:find("[^ \t\r\n]", pos) or (#str + 1) end local parseValue local function parseString() pos = pos + 1 local start = pos local parts = {} while pos <= #str do local c = str:sub(pos, pos) if c == '"' then pos = pos + 1 return table.concat(parts) .. str:sub(start, pos - 2) elseif c == '\\' then parts[#parts + 1] = str:sub(start, pos - 1) pos = pos + 1 local esc = str:sub(pos, pos) if esc == 'n' then parts[#parts + 1] = '\n' elseif esc == 'r' then parts[#parts + 1] = '\r' elseif esc == 't' then parts[#parts + 1] = '\t' elseif esc == '"' then parts[#parts + 1] = '"' elseif esc == '\\' then parts[#parts + 1] = '\\' elseif esc == '/' then parts[#parts + 1] = '/' elseif esc == 'u' then -- Decode \uXXXX to UTF-8 bytes local hex = str:sub(pos + 1, pos + 4) local cp = tonumber(hex, 16) or 0 pos = pos + 4 -- Handle surrogate pairs (\uD800-\uDBFF followed by \uDC00-\uDFFF) if cp >= 0xD800 and cp <= 0xDBFF and str:sub(pos + 1, pos + 2) == '\\u' then local cp2 = tonumber(str:sub(pos + 3, pos + 6), 16) or 0 if cp2 >= 0xDC00 and cp2 <= 0xDFFF then cp = 0x10000 + (cp - 0xD800) * 0x400 + (cp2 - 0xDC00) pos = pos + 6 end end -- Encode code point as UTF-8 if cp < 0x80 then parts[#parts + 1] = string.char(cp) elseif cp < 0x800 then parts[#parts + 1] = string.char(0xC0 + math.floor(cp/0x40), 0x80 + (cp%0x40)) elseif cp < 0x10000 then parts[#parts + 1] = string.char(0xE0 + math.floor(cp/0x1000), 0x80 + math.floor((cp%0x1000)/0x40), 0x80 + (cp%0x40)) else parts[#parts + 1] = string.char(0xF0 + math.floor(cp/0x40000), 0x80 + math.floor((cp%0x40000)/0x1000), 0x80 + math.floor((cp%0x1000)/0x40), 0x80 + (cp%0x40)) end else parts[#parts + 1] = esc end pos = pos + 1 start = pos else pos = pos + 1 end end error("Unterminated JSON string") end local function parseNumber() local start = pos while pos <= #str and str:sub(pos, pos):find("[%d%.eE%+%-]") do pos = pos + 1 end local n = tonumber(str:sub(start, pos - 1)) if n == nil then error("Invalid JSON number") end return n end local function parseArray() pos = pos + 1 local arr = {} skipWS() if str:sub(pos, pos) == "]" then pos = pos + 1; return arr end while true do arr[#arr + 1] = parseValue() skipWS() local c = str:sub(pos, pos) if c == "]" then pos = pos + 1; return arr end if c ~= "," then error("Expected ',' in array") end pos = pos + 1 end end local function parseObject() pos = pos + 1 local obj = {} skipWS() if str:sub(pos, pos) == "}" then pos = pos + 1; return obj end while true do skipWS() if str:sub(pos, pos) ~= '"' then error("Expected object key") end local key = parseString() skipWS() if str:sub(pos, pos) ~= ":" then error("Expected ':'") end pos = pos + 1 obj[key] = parseValue() skipWS() local c = str:sub(pos, pos) if c == "}" then pos = pos + 1; return obj end if c ~= "," then error("Expected ',' in object") end pos = pos + 1 end end parseValue = function() skipWS() local c = str:sub(pos, pos) if c == '"' then return parseString() end if c == "{" then return parseObject() end if c == "[" then return parseArray() end if c == "t" then pos = pos + 4; return true end if c == "f" then pos = pos + 5; return false end if c == "n" then pos = pos + 4; return nil end if c:find("[%d%-]") then return parseNumber() end error("Unexpected JSON token at position " .. tostring(pos)) end return parseValue() end -- Now that JSON is available, define loadPersistentSettings loadPersistentSettings = function() local f = io.open(persistentSettingsFile, "rb") if not f then return {} end local c = f:read("*a"); f:close() local ok, obj = pcall(JSON.decode, c) return (ok and type(obj) == "table") and obj or {} end -- ============================================================ -- 5. Timeline info + live state -- ============================================================ local liveExportStatus = "idle" local liveExportMsg = "Ready" local liveLastPath = "" local function getTimelineInfoTable() project = projectManager:GetCurrentProject() timeline = project and project:GetCurrentTimeline() or nil if not timeline then return { name = "No timeline", frameRate = 0, trackCount = 0, tracks = {}, hasInOut = false, inOutLabel = "No timeline" } end local frameRate = tonumber(timeline:GetSetting("timelineFrameRate")) or 25 local trackCount = timeline:GetTrackCount("audio") or 0 local tracks = {} for i = 1, trackCount do local name = timeline:GetTrackName("audio", i) if not name or name == "" then name = "Track " .. tostring(i) end tracks[#tracks + 1] = { index = i, name = name } end local hasInOut = false local inOutLabel = "__NO_INOUT__" local ok, mio = pcall(function() return timeline:GetMarkInOut() end) if ok and mio then local mIn, mOut = nil, nil if mio["video"] then mIn = mio["video"]["in"]; mOut = mio["video"]["out"] end if (not mIn) and mio["audio"] then mIn = mio["audio"]["in"]; mOut = mio["audio"]["out"] end if mIn and mOut and mOut > mIn then hasInOut = true local dur = (mOut - mIn) / frameRate inOutLabel = string.format("In: %.2fs | Out: %.2fs | Dur: %.2fs", mIn / frameRate, mOut / frameRate, dur) end end return { name = timeline:GetName() or "Untitled", frameRate = frameRate, trackCount = trackCount, tracks = tracks, hasInOut = hasInOut, inOutLabel = inOutLabel } end local liveErrorType = "" local liveApiMessage = "" local function writeLiveData() local t = getTimelineInfoTable() local payload = { hasInOut = t.hasInOut, inOutLabel = t.inOutLabel, exportStatus = liveExportStatus, exportMsg = liveExportMsg, errorType = liveErrorType, apiMessage = liveApiMessage, lastPath = liveLastPath } writeAll(liveDataPath, jsonEncode(payload)) end local function setLive(status, msg, errorType, apiMessage) liveExportStatus = status or liveExportStatus liveExportMsg = msg or liveExportMsg liveErrorType = errorType or "" liveApiMessage = apiMessage or "" writeLiveData() end -- ============================================================ -- 6. Subtitle import logic (embedded from ImportSubtitlesFromJSON) -- ============================================================ -- Convert seconds to SRT timestamp format (HH:MM:SS,mmm) local function secToSrtTime(s) local ms = math.floor((s % 1) * 1000) local ss = math.floor(s) % 60 local mm = math.floor(s / 60) % 60 local hh = math.floor(s / 3600) return string.format("%02d:%02d:%02d,%03d", hh, mm, ss, ms) end local function parseSrtTimestamp(ts) local h, m, s, ms = ts:match("(%d+):(%d+):(%d+),(%d+)") if not h then h, m, s, ms = ts:match("(%d+):(%d+):(%d+)%.(%d+)") end if not h then return nil end return tonumber(h) * 3600 + tonumber(m) * 60 + tonumber(s) + tonumber(ms) / 1000 end local function unescapeUnicode(str) -- Convert \uXXXX or uXXXX sequences to UTF-8 str = str:gsub("\\u(%x%x%x%x)", function(hex) local code = tonumber(hex, 16) if code < 128 then return string.char(code) elseif code < 2048 then return string.char(192 + math.floor(code / 64), 128 + (code % 64)) elseif code < 65536 then return string.char(224 + math.floor(code / 4096), 128 + (math.floor(code / 64) % 64), 128 + (code % 64)) end return "" end) -- Also handle without backslash (in case JSON parsing already removed it) str = str:gsub("u(%x%x%x%x)", function(hex) local code = tonumber(hex, 16) if code < 128 then return string.char(code) elseif code < 2048 then return string.char(192 + math.floor(code / 64), 128 + (code % 64)) elseif code < 65536 then return string.char(224 + math.floor(code / 4096), 128 + (math.floor(code / 64) % 64), 128 + (code % 64)) end return "" end) return str end local function normalizeSrtContent(srt) srt = tostring(srt or "") srt = unescapeUnicode(srt) srt = srt:gsub("\r\n", "\n"):gsub("\r", "\n") srt = srt:gsub("^%s*```[%w]*\n", "") srt = srt:gsub("\n```%s*$", "") return srt end local function parseSrtSegments(srt) local segments = {} srt = normalizeSrtContent(srt) local lines = {} for line in (srt .. "\n"):gmatch("(.-)\n") do lines[#lines + 1] = line end local i = 1 while i <= #lines do local line = lines[i] if line:match("^%s*$") then i = i + 1 else if line:match("^%d+%s*$") and i + 1 <= #lines then i = i + 1 line = lines[i] end local t1, t2 = line:match("^(%d%d:%d%d:%d%d[,%.]%d%d%d)%s*%-%-%>%s*(%d%d:%d%d:%d%d[,%.]%d%d%d)") if t1 and t2 then local startSec = parseSrtTimestamp(t1) local endSec = parseSrtTimestamp(t2) i = i + 1 local textParts = {} while i <= #lines and not lines[i]:match("^%s*$") do textParts[#textParts + 1] = lines[i] i = i + 1 end local text = table.concat(textParts, " "):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") if startSec and endSec and endSec > startSec and text ~= "" then segments[#segments + 1] = { start = startSec, ["end"] = endSec, text = text } end else i = i + 1 end end end return segments end -- Count visible characters (not bytes) - Hebrew/Arabic are 2-byte UTF-8 local function utf8len(s) local len = 0 for i = 1, #s do local b = s:byte(i) if b < 0x80 or b >= 0xC0 then len = len + 1 end -- skip continuation bytes end return len end -- Wrap subtitle text to max chars per line (word-wrap, punctuation stays with word) local function wrapText(text, maxChars) if not maxChars or maxChars <= 0 then return text end local words = {} for w in text:gmatch("%S+") do words[#words+1] = w end if #words == 0 then return text end local lines = {} local currentLine = "" local currentLen = 0 for _, word in ipairs(words) do local wordLen = utf8len(word) if currentLine == "" then -- Always place first word on line, even if longer than maxChars currentLine = word currentLen = wordLen elseif currentLen + 1 + wordLen <= maxChars then currentLine = currentLine .. " " .. word currentLen = currentLen + 1 + wordLen else lines[#lines+1] = currentLine currentLine = word currentLen = wordLen end end if currentLine ~= "" then lines[#lines+1] = currentLine end return table.concat(lines, "\n") end local function importSegments(segments, maxCharsPerLine) project = projectManager:GetCurrentProject() timeline = project and project:GetCurrentTimeline() or nil mediaPool = project and project:GetMediaPool() or nil if not project or not timeline or not mediaPool then return { error = true, message = "No active timeline" } end if not segments or #segments == 0 then return { error = true, message = "No subtitle segments" } end resolve:OpenPage("edit") -- Build SRT content from segments and write to temp file print("[SubabaExport] maxCharsPerLine=" .. tostring(maxCharsPerLine) .. " segments=" .. #segments) local srtLines = {} for i, seg in ipairs(segments) do local wrapped = wrapText(tostring(seg.text or ""), maxCharsPerLine) if i == 1 then print("[SubabaExport] seg1 raw: " .. tostring(seg.text)) print("[SubabaExport] seg1 wrap: " .. wrapped:gsub("\n", " | ")) end srtLines[#srtLines+1] = tostring(i) srtLines[#srtLines+1] = secToSrtTime(seg.start) .. " --> " .. secToSrtTime(seg["end"]) srtLines[#srtLines+1] = wrapped srtLines[#srtLines+1] = "" end -- Unique filename to avoid media pool caching a stale version local srtTempPath = tempDir .. sep .. "subaba_import_" .. os.time() .. ".srt" -- Binary mode: keep \n as-is (text mode converts \n to \r\n on Windows, breaking multi-line subtitles) local f = io.open(srtTempPath, "wb") if not f then return { error = true, message = "Cannot write temp SRT file" } end f:write(table.concat(srtLines, "\n")) f:close() print("[SubabaExport] SRT written: " .. srtTempPath) -- Fresh references to Resolve objects (required for reliable SRT import) local r = resolve or Resolve() local pm = r:GetProjectManager() local proj = pm:GetCurrentProject() local tl = proj:GetCurrentTimeline() local mp = proj:GetMediaPool() local clips = mp:ImportMedia({srtTempPath}) if not clips or #clips == 0 then return { error = true, message = "Failed to import SRT into media pool" } end print("[SubabaExport] SRT imported to media pool (" .. #clips .. " clip(s))") tl:AddTrack("subtitle") local trackIndex = tl:GetTrackCount("subtitle") print("[SubabaExport] Subtitle track count: " .. trackIndex) local result = mp:AppendToTimeline({clips[1]}) -- Do not delete SRT file here - Resolve reads it asynchronously after AppendToTimeline print("[SubabaExport] SRT kept: " .. srtTempPath) if not result or #result == 0 then return { error = true, message = "Failed to place SRT on subtitle track" } end print("[SubabaExport] AppendToTimeline placed " .. #result .. " item(s)") return { success = true, count = #segments, track = trackIndex } end -- ============================================================ -- 7. Audio export -- ============================================================ local function exportAudio(settings) project = projectManager:GetCurrentProject() timeline = project and project:GetCurrentTimeline() or nil if not project or not timeline then return { error = true, message = "No timeline" } end if project:IsRenderingInProgress() then return { error = true, message = "Render already in progress" } end local frameRate = tonumber(timeline:GetSetting("timelineFrameRate")) or 25 local startFrame = timeline:GetStartFrame() or 0 local trackCount = timeline:GetTrackCount("audio") or 0 local selected = {} for _, idx in ipairs(settings.tracks or {}) do selected[idx] = true end local origStates = {} for i = 1, trackCount do origStates[i] = timeline:GetIsTrackEnabled("audio", i) timeline:SetTrackEnable("audio", i, selected[i] == true) end local fileBase = "subaba_audio_" .. os.time() local rs = { TargetDir = tempDir, CustomName = fileBase, RenderMode = "Single clip", IsExportVideo = false, IsExportAudio = true, AudioBitDepth = 24, AudioSampleRate = 48000, } if settings.useInOut then local ok, mio = pcall(function() return timeline:GetMarkInOut() end) if ok and mio then local mIn, mOut = nil, nil if mio["video"] then mIn = mio["video"]["in"]; mOut = mio["video"]["out"] end if (not mIn) and mio["audio"] then mIn = mio["audio"]["in"]; mOut = mio["audio"]["out"] end if mIn and mOut and mOut > mIn then rs.MarkIn = mIn + startFrame rs.MarkOut = mOut + startFrame end end end resolve:OpenPage("deliver") project:LoadRenderPreset("Audio Only") project:SetRenderSettings(rs) local pid = project:AddRenderJob() if not pid then for i = 1, trackCount do timeline:SetTrackEnable("audio", i, origStates[i]) end resolve:OpenPage("edit") return { error = true, message = "Failed to create render job" } end project:StartRendering(pid) while project:IsRenderingInProgress() do busyWait(0.3) end local jobs = project:GetRenderJobList() or {} local last = jobs[#jobs] or {} local outPath = tostring(last["TargetDir"] or tempDir) .. sep .. tostring(last["OutputFilename"] or (fileBase .. ".mp3")) for i = 1, trackCount do timeline:SetTrackEnable("audio", i, origStates[i]) end resolve:OpenPage("edit") if not fileExists(outPath) then return { error = true, message = "Rendered file not found: " .. outPath } end return { success = true, path = outPath } end -- ============================================================ -- 8. Upload audio to Subaba API and get SRT -- ============================================================ local function writeUploadScript() if not isWindows then return true end local script = [=[ param( [string]$AudioPath, [string]$AuthToken, [string]$MaxWords, [string]$StretchCaptions, [string]$IncludePunctuation, [string]$RawCaptionsOnly, [string]$Language, [string]$Version, [string]$OutFile ) $ProgressPreference = 'SilentlyContinue' try { $args = @( '-sS', '--ssl-no-revoke', '-X', 'POST', 'https://sababa.onrender.com/api/generate-srt', '-w', "`n__STATUS__:%{http_code}", '-F', "auth_token=$AuthToken", '-F', "max_words_per_subtitle=$MaxWords", '-F', "stretch_captions=$StretchCaptions", '-F', "include_punctuation=$IncludePunctuation", '-F', "raw_captions_only=$RawCaptionsOnly", '-F', "thinking_budget_multiplier=0", '-F', "language=$Language", '-F', "version=$Version", '-F', "audio_file=@$AudioPath" ) $raw = & curl.exe @args 2>&1 | Out-String [System.IO.File]::WriteAllText($OutFile, $raw, [System.Text.Encoding]::UTF8) } catch { $msg = $_.Exception.Message $fallback = "{""message"":""" + ($msg -replace '"', '\\"') + """}`n__STATUS__:500" [System.IO.File]::WriteAllText($OutFile, $fallback, [System.Text.Encoding]::UTF8) } ]=] return writeAll(uploadScript, script) end local function runApiRequest(audioPath, settings) os.remove(apiRawPath) local authToken = tostring(settings.authToken or "") local maxWords = tostring(math.floor(tonumber(settings.maxWords) or 10)) local stretchCaptions = settings.stretchCaptions and "true" or "false" local includePunctuation = settings.removePunctuation and "false" or "true" local rawCaptionsOnly = settings.rawCaptionsOnly and "true" or "false" local language = tostring(settings.language or "he") local version = tostring(settings.version or "resolve-1.0.0") if isWindows then if not writeUploadScript() then return { error = true, message = "Cannot write upload script" } end local psParams = "-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File " .. psQuote(uploadScript) .. " -AudioPath " .. psQuote(audioPath) .. " -AuthToken " .. psQuote(authToken) .. " -MaxWords " .. psQuote(maxWords) .. " -StretchCaptions " .. psQuote(stretchCaptions) .. " -IncludePunctuation " .. psQuote(includePunctuation) .. " -RawCaptionsOnly " .. psQuote(rawCaptionsOnly) .. " -Language " .. psQuote(language) .. " -Version " .. psQuote(version) .. " -OutFile " .. psQuote(apiRawPath) if shellExecA then shellExecA(nil, "open", "powershell.exe", psParams, nil, 0) -- Progress stages (seconds elapsed -> i18n key) local progressStages = { { t = 0, msg = "stageUploading" }, { t = 8, msg = "stageTranscribing" }, { t = 25, msg = "stageImproving" }, { t = 60, msg = "stageAlmostDone" }, } local stageIdx = 1 local elapsed = 0 local done = false for _ = 1, 108000 do -- up to ~6 hours at 0.2s polling if fileExists(apiRawPath) then done = true break end busyWait(0.2) elapsed = elapsed + 0.2 -- Advance to next stage when threshold is reached while stageIdx < #progressStages and elapsed >= progressStages[stageIdx + 1].t do stageIdx = stageIdx + 1 setLive("generating", progressStages[stageIdx].msg) end end if not done then return { error = true, message = "Timed out waiting for API upload script output", errorType = "timeout" } end else os.execute("powershell " .. psParams) end else local cmd = "curl -sS -X POST " .. shQuote("https://sababa.onrender.com/api/generate-srt") .. " -w " .. shQuote("\\n__STATUS__:%{http_code}") .. " -F " .. shQuote("auth_token=" .. authToken) .. " -F " .. shQuote("max_words_per_subtitle=" .. maxWords) .. " -F " .. shQuote("stretch_captions=" .. stretchCaptions) .. " -F " .. shQuote("include_punctuation=" .. includePunctuation) .. " -F " .. shQuote("raw_captions_only=" .. rawCaptionsOnly) .. " -F " .. shQuote("thinking_budget_multiplier=0") .. " -F " .. shQuote("language=" .. language) .. " -F " .. shQuote("version=" .. version) .. " -F " .. shQuote("audio_file=@" .. audioPath) .. " > " .. shQuote(apiRawPath) .. " 2>&1" os.execute(cmd) end local raw = readAll(apiRawPath) if not raw or raw == "" then return { error = true, message = "Empty API response" } end raw = raw:gsub("^\239\187\191", "") local markerStart, markerEnd, code = raw:find("__STATUS__:(%d%d%d)%s*$") local status = tonumber(code or "500") local body = raw if markerStart then body = raw:sub(1, markerStart - 1):gsub("%s+$", "") end body = body:gsub("^\239\187\191", "") local okJson, respObj = pcall(JSON.decode, body) if not okJson or type(respObj) ~= "table" then if status >= 200 and status < 300 then return { error = true, message = "Invalid JSON from API" } end return { error = true, message = "API error (" .. tostring(status) .. "): " .. body } end -- Detect specific HTTP error types if status == 401 then return { error = true, message = tostring(respObj.message or "Unauthorized"), errorType = "auth" } end if status == 402 or status == 430 then return { error = true, message = tostring(respObj.message or "Not enough minutes"), errorType = "payment" } end if status < 200 or status >= 300 then return { error = true, message = tostring(respObj.message or ("API HTTP " .. tostring(status))), errorType = "server" } end local srt = tostring(respObj.srt_content or "") srt = normalizeSrtContent(srt) if srt == "" then return { error = true, message = tostring(respObj.message or "Empty SRT from API"), errorType = "server" } end return { success = true, srt = srt, message = tostring(respObj.message or ""), remaining = tonumber(respObj.remaining_minutes_seconds) or nil } end -- ============================================================ -- 9. Process export request end-to-end -- ============================================================ local function processRequest(settings) if not settings then return { error = true, message = "Missing settings" } end if not settings.authToken or settings.authToken == "" then return { error = true, message = "Login required (auth token missing)" } end -- If no tracks specified, use all available tracks if not settings.tracks or #settings.tracks == 0 then local trackCount = timeline and timeline:GetTrackCount("audio") or 0 settings.tracks = {} for i = 1, trackCount do settings.tracks[#settings.tracks + 1] = i end end setLive("exporting_audio", "Exporting audio from timeline...") local exp = exportAudio(settings) if exp.error then return exp end liveLastPath = exp.path -- Calculate in-point offset for subtitle positioning local inPointOffset = 0 if settings.useInOut then local ok, mio = pcall(function() return timeline:GetMarkInOut() end) if ok and mio then local mIn = nil if mio["video"] then mIn = mio["video"]["in"] end if (not mIn) and mio["audio"] then mIn = mio["audio"]["in"] end if mIn then local frameRate = tonumber(timeline:GetSetting("timelineFrameRate")) or 25 inPointOffset = mIn / frameRate end end end setLive("generating", "Uploading audio to server...") local api = runApiRequest(exp.path, settings) if api.error then return api end setLive("importing", "Importing subtitles to timeline...") local segments = parseSrtSegments(api.srt) if not segments or #segments == 0 then return { error = true, message = "Generated SRT has no valid subtitle segments" } end -- Offset segments by in-point if inPointOffset > 0 then for i, seg in ipairs(segments) do seg.start = seg.start + inPointOffset seg["end"] = seg["end"] + inPointOffset end end local imp = importSegments(segments, tonumber(settings.maxCharsPerLine) or 0) if imp.error then return imp end local msg = "Imported " .. tostring(imp.count or #segments) .. " subtitles" if api.remaining then msg = msg .. " | Remaining seconds: " .. tostring(math.floor(api.remaining)) end return { success = true, message = msg, apiMessage = api.message or "", count = imp.count, track = imp.track } end -- ============================================================ -- 10. Local HTTP bridge script (PowerShell/Ruby) -- ============================================================ local function writeServerScript() if isWindows then local ps = io.open(serverScript, "w") if not ps then return false end ps:write([=[ param($htmlFile, $settingsFile, $liveFile, $readyFile, $stopFile, $port, $persistFile, $sessionFile) $l = [Net.HttpListener]::new() $l.Prefixes.Add("http://localhost:${port}/") try { $l.Start() } catch { exit 1 } [IO.File]::WriteAllText($readyFile, "ready") function Write-JsonResponse($resp, $statusCode, $jsonText) { $resp.StatusCode = $statusCode $resp.ContentType = "application/json; charset=utf-8" $bytes = [Text.Encoding]::UTF8.GetBytes($jsonText) $resp.OutputStream.Write($bytes, 0, $bytes.Length) } function Handle-AuthProxy($reqBody, $resp) { try { $obj = $reqBody | ConvertFrom-Json } catch { Write-JsonResponse $resp 400 '{"error":"Invalid JSON"}' return } $endpoint = [string]$obj.endpoint if ([string]::IsNullOrWhiteSpace($endpoint) -or -not $endpoint.StartsWith('/')) { Write-JsonResponse $resp 400 '{"error":"Invalid endpoint"}' return } $allowed = @('/api/login','/send-verification-code','/verify-code','/reset-password','/api/get-token-info') if ($allowed -notcontains $endpoint) { Write-JsonResponse $resp 400 '{"error":"Endpoint not allowed"}' return } $payloadJson = '{}' try { if ($null -ne $obj.payload) { $payloadJson = $obj.payload | ConvertTo-Json -Compress -Depth 10 } } catch { } $target = "https://sababa.onrender.com$endpoint" try { $r = Invoke-WebRequest -Uri $target -Method POST -ContentType "application/json" -Body $payloadJson -UseBasicParsing -TimeoutSec 35 $resp.StatusCode = [int]$r.StatusCode $ct = $r.Headers['Content-Type'] if ([string]::IsNullOrWhiteSpace($ct)) { $ct = 'application/json; charset=utf-8' } $resp.ContentType = $ct $bytes = [Text.Encoding]::UTF8.GetBytes($r.Content) $resp.OutputStream.Write($bytes, 0, $bytes.Length) } catch { $webResp = $_.Exception.Response if ($webResp) { try { $resp.StatusCode = [int]$webResp.StatusCode } catch { $resp.StatusCode = 500 } $sr = New-Object IO.StreamReader($webResp.GetResponseStream()) $errBody = $sr.ReadToEnd() $sr.Close() if ([string]::IsNullOrWhiteSpace($errBody)) { $errBody = '{"error":"Upstream error"}' } $resp.ContentType = 'application/json; charset=utf-8' $bytes = [Text.Encoding]::UTF8.GetBytes($errBody) $resp.OutputStream.Write($bytes, 0, $bytes.Length) } else { Write-JsonResponse $resp 500 ('{"error":"' + ($_.Exception.Message -replace '"', '\\"') + '"}') } } } while (-not (Test-Path $stopFile)) { $ar = $l.BeginGetContext($null, $null) while (-not $ar.AsyncWaitHandle.WaitOne(500)) { if (Test-Path $stopFile) { break } } if (Test-Path $stopFile) { break } try { $ctx = $l.EndGetContext($ar) } catch { continue } $req = $ctx.Request $resp = $ctx.Response $resp.AddHeader("Access-Control-Allow-Origin", "*") $u = $req.Url.AbsolutePath try { if ($req.HttpMethod -eq 'GET' -and $u -eq '/') { $b = [IO.File]::ReadAllBytes($htmlFile) $resp.ContentType = 'text/html; charset=utf-8' $resp.OutputStream.Write($b, 0, $b.Length) } elseif ($req.HttpMethod -eq 'GET' -and $u -eq '/live') { if (Test-Path $liveFile) { $b = [IO.File]::ReadAllBytes($liveFile) $resp.ContentType = 'application/json; charset=utf-8' $resp.OutputStream.Write($b, 0, $b.Length) } else { Write-JsonResponse $resp 200 '{"exportStatus":"idle","exportMsg":"Waiting"}' } } elseif ($req.HttpMethod -eq 'POST' -and $u -eq '/export') { $rd = [IO.StreamReader]::new($req.InputStream) [IO.File]::WriteAllText($settingsFile, $rd.ReadToEnd()) Write-JsonResponse $resp 200 '{"ok":true}' } elseif ($req.HttpMethod -eq 'POST' -and $u -eq '/settings') { $rd = [IO.StreamReader]::new($req.InputStream) [IO.File]::WriteAllText($persistFile, $rd.ReadToEnd()) Write-JsonResponse $resp 200 '{"ok":true}' } elseif ($req.HttpMethod -eq 'POST' -and $u -eq '/save-session') { # Persist auth session (token/username/minutes) to survive port changes $rd = [IO.StreamReader]::new($req.InputStream) [IO.File]::WriteAllText($sessionFile, $rd.ReadToEnd()) Write-JsonResponse $resp 200 '{"ok":true}' } elseif ($req.HttpMethod -eq 'POST' -and $u -eq '/auth-proxy') { $rd = [IO.StreamReader]::new($req.InputStream) $body = $rd.ReadToEnd() Handle-AuthProxy $body $resp } else { Write-JsonResponse $resp 404 '{"error":"Not found"}' } } catch { Write-JsonResponse $resp 500 ('{"error":"' + ($_.Exception.Message -replace '"', '\\"') + '"}') } try { $resp.Close() } catch { } } $l.Stop() if (Test-Path $stopFile) { Remove-Item $stopFile -Force } if (Test-Path $readyFile) { Remove-Item $readyFile -Force } ]=]) ps:close() return true else local rb = io.open(serverScript, "w") if not rb then return false end -- Perl server using only macOS core modules (IO::Socket::INET, IO::Select, JSON::PP) -- Replaces Ruby/WEBrick which is removed from stdlib in Ruby 3.0+ (Homebrew) rb:write([=[ #!/usr/bin/perl use strict; use warnings; use IO::Socket::INET; use IO::Select; use JSON::PP; my ($html_file, $settings_file, $live_file, $ready_file, $stop_file, $port, $persist_file, $session_file) = @ARGV; # Bind TCP server on localhost my $server = IO::Socket::INET->new( LocalAddr => '127.0.0.1', LocalPort => $port + 0, Proto => 'tcp', Reuse => 1, Listen => 10, ) or die "Cannot bind port $port: $!\n"; { open my $fh, '>', $ready_file or die; print $fh 'ready'; close $fh; } my $sel = IO::Select->new($server); # Main loop: poll for connections, check stop file while (!-e $stop_file) { my @ready = $sel->can_read(0.5); for my $sock (@ready) { next unless $sock == $server; my $client = $server->accept() or next; $client->autoflush(1); eval { handle_client($client) }; eval { $client->close() }; } } $server->close(); unlink $stop_file if -e $stop_file; unlink $ready_file if -e $ready_file; sub shell_esc { my $s = shift; $s =~ s/'/'\\''/g; return "'$s'"; } sub send_resp { my ($sock, $code, $ct, $body) = @_; my %st = (200=>'OK', 204=>'No Content', 400=>'Bad Request', 404=>'Not Found', 405=>'Method Not Allowed', 500=>'Internal Server Error'); print $sock "HTTP/1.1 $code " . ($st{$code}||'OK') . "\r\n" . "Content-Type: $ct\r\n" . "Access-Control-Allow-Origin: *\r\n" . "Content-Length: " . length($body) . "\r\n" . "Connection: close\r\n\r\n" . $body; } sub json_resp { send_resp($_[0], $_[1], 'application/json; charset=utf-8', $_[2]); } sub handle_client { my ($sock) = @_; my $rl = <$sock>; return unless defined $rl; $rl =~ s/\r?\n//; my ($meth, $path) = $rl =~ /^(\w+)\s+(\S+)/ or return; # Read headers my %h; while (defined(my $ln = <$sock>)) { $ln =~ s/\r?\n//; last if $ln eq ''; $h{lc $1} = $2 if $ln =~ /^([^:]+):\s*(.*)$/; } # CORS preflight if ($meth eq 'OPTIONS') { print $sock "HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: *\r\n" . "Access-Control-Allow-Methods: GET, POST, OPTIONS\r\n" . "Access-Control-Allow-Headers: Content-Type\r\n" . "Content-Length: 0\r\nConnection: close\r\n\r\n"; return; } # Read body if present my $body = ''; read($sock, $body, $h{'content-length'}) if $h{'content-length'} && $h{'content-length'} > 0; # Route requests if ($meth eq 'GET' && $path eq '/') { open(my $fh, '<:raw', $html_file) or return json_resp($sock, 500, '{"error":"HTML unreadable"}'); my $html = do { local $/; <$fh> }; close $fh; send_resp($sock, 200, 'text/html; charset=utf-8', $html); } elsif ($meth eq 'GET' && $path =~ m{^/live}) { my $d = '{"exportStatus":"idle","exportMsg":"Waiting"}'; if (-e $live_file && open(my $fh, '<:raw', $live_file)) { $d = do { local $/; <$fh> }; close $fh; } json_resp($sock, 200, $d); } elsif ($meth eq 'POST' && $path eq '/export') { open(my $fh, '>', $settings_file) or return json_resp($sock, 500, '{"error":"Cannot save"}'); print $fh $body; close $fh; json_resp($sock, 200, '{"ok":true}'); } elsif ($meth eq 'POST' && $path eq '/save-session') { if (defined $session_file && $session_file ne '') { open(my $fh, '>', $session_file) or return json_resp($sock, 500, '{"error":"Cannot save"}'); print $fh $body; close $fh; } json_resp($sock, 200, '{"ok":true}'); } elsif ($meth eq 'POST' && $path eq '/settings') { if (defined $persist_file && $persist_file ne '') { open(my $fh, '>', $persist_file) or return json_resp($sock, 500, '{"error":"Cannot save"}'); print $fh $body; close $fh; } json_resp($sock, 200, '{"ok":true}'); } elsif ($meth eq 'POST' && $path eq '/auth-proxy') { my ($code, $rb) = auth_proxy($body); json_resp($sock, $code, $rb); } else { json_resp($sock, 404, '{"error":"Not found"}'); } } sub auth_proxy { my ($body) = @_; my $req = eval { decode_json($body) }; return (400, '{"error":"Invalid JSON"}') if $@ || ref($req) ne 'HASH'; my $ep = $req->{endpoint} // ''; my %ok = map { $_ => 1 } ('/api/login', '/send-verification-code', '/verify-code', '/reset-password', '/api/get-token-info'); return (400, '{"error":"Endpoint not allowed"}') unless $ep =~ m{^/} && $ok{$ep}; my $pay = encode_json($req->{payload} // {}); my ($ti, $to) = ("/tmp/subaba_prx_in_$$.json", "/tmp/subaba_prx_out_$$.txt"); { open(my $fh, '>', $ti) or return (500, '{"error":"tmp write failed"}'); print $fh $pay; close $fh; } my $url = "https://sababa.onrender.com$ep"; system("curl -sS -X POST " . shell_esc($url) . " -H 'Content-Type: application/json'" . " --data-binary " . shell_esc("\@$ti") . " -w '\\n__STATUS__:%{http_code}'" . " > " . shell_esc($to) . " 2>/dev/null"); unlink $ti; open(my $fh, '<:raw', $to) or do { unlink $to; return (500, '{"error":"no curl output"}') }; my $raw = do { local $/; <$fh> }; close $fh; unlink $to; return (int($2), $1) if $raw =~ /\A(.*)\n__STATUS__:(\d+)\s*\z/s; return (500, '{"error":"curl parse error"}'); } ]=]) rb:close() return true end end local function startServer() os.remove(stopFile) os.remove(readyFile) if not writeServerScript() then print("[SubabaExport] ERROR: Cannot write server script.") return false end if isWindows then local psParams = '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File ' .. '"' .. serverScript .. '" ' .. '"' .. htmlPath .. '" ' .. '"' .. settingsFile .. '" ' .. '"' .. liveDataPath .. '" ' .. '"' .. readyFile .. '" ' .. '"' .. stopFile .. '" ' .. PORT .. ' ' .. '"' .. persistentSettingsFile .. '" ' .. '"' .. sessionFile .. '"' if shellExecA then shellExecA(nil, "open", "powershell.exe", psParams, nil, 0) else os.execute('start "" /b powershell ' .. psParams) end else -- Use system Perl (/usr/bin/perl) with shQuote for safe path handling os.execute('/usr/bin/perl ' .. shQuote(serverScript) .. ' ' .. shQuote(htmlPath) .. ' ' .. shQuote(settingsFile) .. ' ' .. shQuote(liveDataPath) .. ' ' .. shQuote(readyFile) .. ' ' .. shQuote(stopFile) .. ' ' .. PORT .. ' ' .. shQuote(persistentSettingsFile) .. ' ' .. shQuote(sessionFile) .. ' > /dev/null 2>&1 &') end for _ = 1, 30 do if fileExists(readyFile) then os.remove(readyFile) return true end busyWait(0.5) end print("[SubabaExport] WARNING: Server startup timeout.") return true end local function stopServer() local sf = io.open(stopFile, "w") if sf then sf:write("stop"); sf:close() end busyWait(1) end local function writeLauncherPage() local target = "http://localhost:" .. PORT .. "/" local launcherHtml = ([==[