local is_auto = reaper.GetExtState("Voxly", "AutomationMode") == "1" if is_auto then reaper.SetExtState("Voxly", "AutomationMode", "0", false) end local msg = [[ Copyright (C) 2025 团子 (原作者 / Original Author) 版权所有 / All rights reserved 本脚本按“原样”提供,不提供任何形式的保证。 This script is provided "as-is", without any warranty of any kind. 欢迎个人使用、学习、修改或分享,但请务必保留本版权声明,并注明原作者。 You are welcome to use, modify, or share this script freely, but please keep this copyright notice intact and acknowledge the original author. 作者不对因使用本脚本而产生的任何问题负责。 The author is not responsible for any issues arising from the use of this script. 点击“确定”继续运行脚本。 Click "OK" to continue. ]] if not is_auto then local user_choice = reaper.ShowMessageBox(msg, "版权声明 / Copyright Notice", 1) if user_choice == 2 then return end end local lang = { ["zh-CN"] = { langPrompt = "选择语言:1=简体中文 2=繁体中文 3=英语", langTitle = "界面语言", invalidInput = "输入无效,使用简体中文", errorOpenFile = "无法打开文件: ", errorInvalidFormat = "文件格式错误或无有效字幕", warningInvalidEntries = "警告:%d条无效,%d条有效。继续?", formatWarning = "格式警告", settings = "设置", targetTrack = "目标轨道 (1=选中,0=新建)", textStorage = "文本存放 (1=Take名,2=备注,3=两者)", screenMode = "屏幕模式 (0=横,1=竖)", verticalLayout = "竖屏排版 (0=横,1=竖)", captionPosition = "字幕位置 (0上,1中,2下,3自定义)", bgStyle = "背景样式 (0透明,1黑底白字,2白底黑字,3灰底白字)", minLength = "最短长度(秒,0保持)", maxChars = "最大字符(0无限)", overflowHandling = "超长处理 (0截断,1换行)", customPosSize = "自定义位置和大小", yPos = "Y位置 (0顶,1底)", xPos = "X位置 (0左,1右)", fontSize = "字体大小 (0.01-0.3)", startImport = "开始导入字幕...", progress = "进度: %d/%d (已导入:%d)", importComplete = "导入完成!", importSuccess = "成功: %d条", skippedInvalid = "跳过: %d条", totalProcessed = "总计: %d条", fxStatus = "效果器状态:", fxSuccess = "成功: %d", fxFailed = "失败: %d", verticalMode = "竖屏模式: %s", enabled = "已启用", disabled = "未启用", textLayout = "排版: %s", vertical = "竖排", horizontal = "横排", charLimit = "字符限制: %d (%s)", wrap = "换行", truncate = "截断", warning = "警告: ", undoAction = "导入字幕到视频轨道", noText = "(无文本)", cannotAddFx = "无法添加Video processor效果器", usingDefault = "使用默认参数", selectSrtTitle = "选择SRT字幕文件", selectOperation = "操作类型:1=导入 2=导出", operationTitle = "操作类型", invalidOperation = "输入无效,默认导入" }, ["zh-TW"] = { langPrompt = "選擇語言:1=簡體 2=繁體 3=英文", langTitle = "介面語言", invalidInput = "輸入無效,使用簡體中文", errorOpenFile = "無法開啟檔案: ", errorInvalidFormat = "檔案格式錯誤或無有效字幕", warningInvalidEntries = "警告:%d無效,%d有效。繼續?", formatWarning = "格式警告", settings = "設定", targetTrack = "目標軌道 (1選中,0新建)", textStorage = "文字存放 (1Take名,2備註,3兩者)", screenMode = "螢幕模式 (0橫,1直)", verticalLayout = "直向排版 (0橫,1直)", captionPosition = "字幕位置 (0上,1中,2下,3自訂)", bgStyle = "背景樣式 (0透明,1黑白,2白黑,3灰白)", minLength = "最短長度(秒,0保持)", maxChars = "最大字元(0不限)", overflowHandling = "超長處理 (0截斷,1換行)", customPosSize = "自訂位置和大小", yPos = "Y位置 (0頂,1底)", xPos = "X位置 (0左,1右)", fontSize = "字型大小 (0.01-0.3)", startImport = "開始匯入字幕...", progress = "進度: %d/%d (已匯入:%d)", importComplete = "匯入完成!", importSuccess = "成功: %d筆", skippedInvalid = "跳過: %d筆", totalProcessed = "總計: %d筆", fxStatus = "效果器狀態:", fxSuccess = "成功: %d", fxFailed = "失敗: %d", verticalMode = "直向模式: %s", enabled = "已啟用", disabled = "未啟用", textLayout = "排版: %s", vertical = "直排", horizontal = "橫排", charLimit = "字元限制: %d (%s)", wrap = "換行", truncate = "截斷", warning = "警告: ", undoAction = "匯入字幕到影片軌道", noText = "(無文字)", cannotAddFx = "無法新增Video processor效果器", usingDefault = "使用預設參數", selectSrtTitle = "選擇SRT字幕檔", selectOperation = "操作類型:1=匯入 2=匯出", operationTitle = "操作類型", invalidOperation = "輸入無效,預設匯入" }, ["en"] = { langPrompt = "Select language: 1=Simplified 2=Traditional 3=English", langTitle = "Interface Language", invalidInput = "Invalid input, using Simplified Chinese", errorOpenFile = "Cannot open file: ", errorInvalidFormat = "Invalid format or no valid subtitles", warningInvalidEntries = "Warning: %d invalid, %d valid. Continue?", formatWarning = "Format Warning", settings = "Settings", targetTrack = "Target track (1=selected,0=new)", textStorage = "Text storage (1=take,2=note,3=both)", screenMode = "Screen mode (0=horizontal,1=vertical)", verticalLayout = "Vertical layout (0=horizontal,1=vertical)", captionPosition = "Caption pos (0top,1mid,2bottom,3custom)", bgStyle = "BG style (0transparent,1b/w,2w/b,3gray/white)", minLength = "Min length(sec,0=keep)", maxChars = "Max chars(0=unlimited)", overflowHandling = "Overflow (0truncate,1wrap)", customPosSize = "Custom pos & size", yPos = "Y pos (0top,1bottom)", xPos = "X pos (0left,1right)", fontSize = "Font size (0.01-0.3)", startImport = "Starting import...", progress = "Progress: %d/%d (Imported:%d)", importComplete = "Import completed!", importSuccess = "Success: %d", skippedInvalid = "Skipped: %d", totalProcessed = "Total: %d", fxStatus = "FX status:", fxSuccess = "Success: %d", fxFailed = "Failed: %d", verticalMode = "Vertical mode: %s", enabled = "Enabled", disabled = "Disabled", textLayout = "Layout: %s", vertical = "Vertical", horizontal = "Horizontal", charLimit = "Char limit: %d (%s)", wrap = "Wrap", truncate = "Truncate", warning = "Warning: ", undoAction = "Import subtitles to video track", noText = "(no text)", cannotAddFx = "Cannot add Video processor FX", usingDefault = "Using default", selectSrtTitle = "Select SRT file", selectOperation = "Operation: 1=Import 2=Export", operationTitle = "Operation", invalidOperation = "Invalid input, default=Import" } } local currentLang = "zh-CN" local operation = "import" if not is_auto then local defaultLangTip = lang["zh-CN"] local ok, langInput = reaper.GetUserInputs( defaultLangTip.langTitle, 1, defaultLangTip.langPrompt, "" ) if ok then local langNum = tonumber(langInput) if langNum == 2 then currentLang = "zh-TW" elseif langNum == 3 then currentLang = "en" elseif langNum ~= 1 then reaper.ShowMessageBox(defaultLangTip.invalidInput, defaultLangTip.langTitle, 0) end else return end local L_op = lang[currentLang] local okOp, opInput = reaper.GetUserInputs( L_op.operationTitle, 1, L_op.selectOperation, "1" ) if okOp then local opNum = tonumber(opInput) if opNum == 2 then operation = "export" elseif opNum ~= 1 then reaper.ShowMessageBox(L_op.invalidOperation, L_op.operationTitle, 0) end else return end end local L = lang[currentLang] local function MsgBox(str) if not is_auto then reaper.ShowMessageBox(str, L.settings, 0) end end local function trim(s) return (s:gsub("^%s*(.-)%s*$", "%1")) end local function time_str_to_seconds(t) if not t then return nil end local h, m, s, frac = t:match("^(%d+):(%d+):(%d+)[%.,]?(%d*)$") if not h then return nil end h = tonumber(h); m = tonumber(m); s = tonumber(s) local ms = 0 if frac and frac ~= "" then if #frac == 1 then ms = tonumber(frac) * 100 elseif #frac == 2 then ms = tonumber(frac) * 10 else ms = tonumber(frac:sub(1,3)) if #frac < 3 then ms = ms * (10^(3-#frac)) end end end return h*3600 + m*60 + s + ms/1000 end local function split_csv(s) local t = {} local pattern = "([^,]*)," for part in (s .. ","):gmatch(pattern) do table.insert(t, trim(part)) end return t end local function utf8_len(s) local len = 0 for c in s:gmatch("[%z\1-\127\194-\244][\128-\191]*") do len = len + 1 end return len end local function hex_to_rgb(hex) hex = hex:gsub("#", "") if #hex == 6 then local r = tonumber("0x" .. hex:sub(1, 2)) / 255 local g = tonumber("0x" .. hex:sub(3, 4)) / 255 local b = tonumber("0x" .. hex:sub(5, 6)) / 255 return r, g, b end return nil, nil, nil end local function process_text(text, max_chars, auto_wrap, is_vertical) if not text or text == "" then return L.noText end text = trim(text) if is_vertical then local lines = {} for line in text:gmatch("[^\n]+") do local chars = {} for c in line:gmatch("[%z\1-\127\194-\244][\128-\191]*") do table.insert(chars, c) end table.insert(lines, table.concat(chars, "\n")) end return table.concat(lines, "\n\n") end if max_chars > 0 and utf8_len(text) > max_chars then if auto_wrap then local chars = {} for c in text:gmatch("[%z\1-\127\194-\244][\128-\191]*") do table.insert(chars, c) end local lines = {} local current_line = {} local char_count = 0 local last_space_idx = -1 for i, char in ipairs(chars) do table.insert(current_line, char) char_count = char_count + 1 if char == " " then last_space_idx = char_count end if char_count >= max_chars and i < #chars then if last_space_idx > 0 and last_space_idx < char_count then local line_to_add = table.concat(current_line, "", 1, last_space_idx - 1) table.insert(lines, trim(line_to_add)) local new_current_line = {} for j = last_space_idx + 1, char_count do table.insert(new_current_line, current_line[j]) end current_line = new_current_line char_count = #current_line last_space_idx = -1 else table.insert(lines, trim(table.concat(current_line, ""))) current_line = {} char_count = 0 last_space_idx = -1 end end end if #current_line > 0 then table.insert(lines, trim(table.concat(current_line, ""))) end text = table.concat(lines, "\n") else local chars = {} local count = 0 for c in text:gmatch("[%z\1-\127\194-\244][\128-\191]*") do if count >= max_chars - 3 then break end table.insert(chars, c) count = count + 1 end text = table.concat(chars, "") .. "..." end end return text end local function validate_srt_content(content) local valid_blocks = 0 local total_blocks = 0 for block in content:gmatch("(.-)\n\n") do block = trim(block) if block ~= "" then total_blocks = total_blocks + 1 local lines = {} for l in block:gmatch("([^\n]+)") do table.insert(lines, l) end if #lines >= 2 then local sstr, estr = lines[2]:match("([^%s]+)%s*%-%-%>%s*([^%s]+)") if sstr and estr then local start_sec = time_str_to_seconds(sstr) local end_sec = time_str_to_seconds(estr) if start_sec and end_sec and end_sec > start_sec then valid_blocks = valid_blocks + 1 end end end end end return valid_blocks, total_blocks end local voxly_vp_code = [[ // === Voxly Enhanced Subtitle Overlay === //@param1:size 'text height' 0.05 0.01 0.3 0.05 0.001 //@param2:ypos 'y position' 0.88 0.0 1.0 0.5 0.01 //@param3:xpos 'x position' 0.5 0.0 1.0 0.5 0.01 //@param4:border 'bg pad' 0.15 0 1 0.1 0.01 //@param5:txt_r 'text R' 1.0 0.0 1.0 0.5 0.01 //@param6:txt_g 'text G' 1.0 0.0 1.0 0.5 0.01 //@param7:txt_b 'text B' 1.0 0.0 1.0 0.5 0.01 //@param8:txt_a 'text alpha' 1.0 0.0 1.0 0.5 0.01 //@param9:bg_r 'bg R' 0.0 0.0 1.0 0.0 0.01 //@param10:bg_g 'bg G' 0.0 0.0 1.0 0.0 0.01 //@param11:bg_b 'bg B' 0.0 0.0 1.0 0.0 0.01 //@param12:bg_a 'bg alpha' 0.5 0.0 1.0 0.5 0.01 //@param13:outline 'outline size' 2 0 10 2 1 #text=""; font="Microsoft YaHei"; input = 0; project_wh_valid===0 ? input_info(input,project_w,project_h); gfx_a2=0; gfx_blit(input,1); strcmp(#text,"")==0 ? input_get_name(-1,#text); gfx_setfont(size*project_h, font, 'B'); gfx_str_measure(#text,txtw,txth); b = (border*txth)|0; yt = ((project_h - txth - b*2)*ypos)|0; xp = (xpos * (project_w-txtw))|0; bg_a>0 ? ( gfx_set(bg_r, bg_g, bg_b, bg_a); gfx_fillrect(xp-b, yt, txtw+b*2, txth+b*2); ); outline>0 ? ( gfx_set(0, 0, 0, txt_a); o = outline; oy = -o; while (oy <= o) ( ox = -o; while (ox <= o) ( (ox != 0 || oy != 0) ? gfx_str_draw(#text, xp+ox, yt+b+oy); ox += 1; ); oy += 1; ); ); gfx_set(txt_r, txt_g, txt_b, txt_a); gfx_str_draw(#text, xp, yt+b); ]] local function setup_video_processor_fx(item, take, ypos, xpos, text_height, fontR, fontG, fontB, bg_mode) local fx_index = reaper.TakeFX_AddByName(take, "Video processor", 1) if fx_index == -1 then return false, L.cannotAddFx end reaper.TakeFX_SetPreset(take, fx_index, "Overlay: Text/Timecode") local bg_r, bg_g, bg_b, bg_a = 0, 0, 0, 0 if bg_mode == 1 then bg_r, bg_g, bg_b, bg_a = 0, 0, 0, 0.6 elseif bg_mode == 2 then bg_r, bg_g, bg_b, bg_a = 1, 1, 1, 0.6 elseif bg_mode == 3 then bg_r, bg_g, bg_b, bg_a = 0.5, 0.5, 0.5, 0.6 end local dynamic_eel = string.format([[ // === Voxly Enhanced Subtitle Overlay === //@param1:size 'text height' 0.05 0.01 0.3 0.05 0.001 //@param2:ypos 'y position' 0.88 0.0 1.0 0.5 0.01 //@param3:xpos 'x position' 0.5 0.0 1.0 0.5 0.01 //@param4:border 'bg pad' 0.15 0 1 0.1 0.01 #text=""; font="Microsoft YaHei"; input = 0; project_wh_valid===0 ? input_info(input,project_w,project_h); gfx_a2=0; gfx_blit(input,1); strcmp(#text,"")==0 ? input_get_name(-1,#text); gfx_setfont(size*project_h, font, 'B'); gfx_str_measure(#text,txtw,txth); b = (border*txth)|0; yt = ((project_h - txth - b*2)*ypos)|0; xp = (xpos * (project_w-txtw))|0; bg_a = %f; bg_a>0 ? ( gfx_set(%f, %f, %f, bg_a); gfx_fillrect(xp-b, yt, txtw+b*2, txth+b*2); ); outline = 2; outline>0 ? ( gfx_set(0, 0, 0, 1.0); o = outline; oy = -o; while (oy <= o) ( ox = -o; while (ox <= o) ( (ox != 0 || oy != 0) ? gfx_str_draw(#text, xp+ox, yt+b+oy); ox += 1; ); oy += 1; ); ); gfx_set(%f, %f, %f, 1.0); gfx_str_draw(#text, xp, yt+b); ]], bg_a, bg_r, bg_g, bg_b, fontR, fontG, fontB) local retval, chunk = reaper.GetItemStateChunk(item, "", false) if retval then local encoded_code = "\n" local start_idx = chunk:find("\n", start_idx) if end_idx then local new_chunk = chunk:sub(1, start_idx - 1) .. encoded_code .. chunk:sub(end_idx + 3) reaper.SetItemStateChunk(item, new_chunk, false) take = reaper.GetActiveTake(item) end end end if take then reaper.TakeFX_SetParam(take, fx_index, 0, text_height) reaper.TakeFX_SetParam(take, fx_index, 1, ypos) reaper.TakeFX_SetParam(take, fx_index, 2, xpos) reaper.TakeFX_SetParam(take, fx_index, 3, 0.15) end return true, "Voxly Enhanced Overlay" end local function import_subtitles() local srt_path = "" local media_path = "" if is_auto then srt_path = reaper.GetExtState("Voxly", "ImportSRT") media_path = reaper.GetExtState("Voxly", "ImportMedia") else local retval, file_path = reaper.GetUserFileNameForRead("", L.selectSrtTitle, "srt") if not retval or file_path == "" then return end srt_path = file_path end if media_path and media_path ~= "" then local test_f = io.open(media_path, "rb") if test_f then test_f:close() reaper.SetEditCurPos(0, true, false) reaper.InsertMedia(media_path, 0) end end local f = io.open(srt_path, "rb") if not f then MsgBox(L.errorOpenFile .. srt_path) return end local content = f:read("*a") f:close() if content:sub(1,3) == "\239\187\191" then content = content:sub(4) end content = content:gsub("\r\n", "\n"):gsub("\r", "\n") if not content:match("\n\n$") then content = content .. "\n\n" end local valid_count, total_count = validate_srt_content(content) if valid_count == 0 then MsgBox(L.errorInvalidFormat) return elseif valid_count < total_count and not is_auto then local continue = reaper.ShowMessageBox( string.format(L.warningInvalidEntries, total_count - valid_count, valid_count), L.formatWarning, 1 ) if continue ~= 1 then return end end local opts = {} local pos_opts = {} if is_auto then local user_csv = reaper.GetExtState("Voxly", "SettingsCSV") local custom_csv = reaper.GetExtState("Voxly", "CustomPosCSV") if user_csv == "" then user_csv = "0,1,0,0,2,1,0,20,1" end opts = split_csv(user_csv) if custom_csv ~= "" then pos_opts = split_csv(custom_csv) end else local captions = table.concat({ L.targetTrack, L.textStorage, L.screenMode, L.verticalLayout, L.captionPosition, L.bgStyle, L.minLength, L.maxChars, L.overflowHandling }, ",") local defaults = "0,1,0,0,2,1,0,20,1" local ok, user_csv = reaper.GetUserInputs(L.settings, 9, captions, defaults) if not ok then return end opts = split_csv(user_csv) end local use_selected_track = (tonumber(opts[1]) or 1) ~= 0 local text_mode = tonumber(opts[2]) or 1 local vertical_screen = (tonumber(opts[3]) or 0) ~= 0 local vertical_text = (tonumber(opts[4]) or 0) ~= 0 local pos_mode = tonumber(opts[5]) or 2 local bg_mode = tonumber(opts[6]) or 1 local min_len = tonumber(opts[7]) or 0 local max_chars = tonumber(opts[8]) or 20 local auto_wrap = (tonumber(opts[9]) or 1) ~= 0 local text_height = vertical_screen and 0.039 or 0.106 local xpos = 0.5 local ypos = 0.85 local fontR, fontG, fontB = 1.0, 1.0, 1.0 if is_auto and #pos_opts >= 6 then fontR = tonumber(pos_opts[4]) or 1.0 fontG = tonumber(pos_opts[5]) or 1.0 fontB = tonumber(pos_opts[6]) or 1.0 end local ypos_map = { [0]=0.15, [1]=0.50, [2]=0.85 } if pos_mode >= 0 and pos_mode <= 2 then ypos = ypos_map[pos_mode] elseif pos_mode == 3 then if is_auto and #pos_opts >= 6 then ypos = tonumber(pos_opts[1]) or 0.85 xpos = tonumber(pos_opts[2]) or 0.5 text_height = tonumber(pos_opts[3]) or 0.05 else local ok2, vals = reaper.GetUserInputs( L.customPosSize, 3, L.yPos .. "," .. L.xPos .. "," .. L.fontSize, "0.5,0.5," .. tostring(text_height) ) if ok2 then local manual_pos = split_csv(vals) local user_y = math.max(0, math.min(1, tonumber(manual_pos[1]) or 0.5)) xpos = math.max(0, math.min(1, tonumber(manual_pos[2]) or 0.5)) text_height = math.max(0.01, math.min(0.3, tonumber(manual_pos[3]) or text_height)) ypos = 0.15 + 0.7 * user_y end end end local track if use_selected_track and reaper.CountSelectedTracks(0) > 0 then track = reaper.GetSelectedTrack(0, 0) local retval, track_name = reaper.GetSetMediaTrackInfo_String(track, "P_NAME", "", false) if track_name == "" then reaper.GetSetMediaTrackInfo_String(track, "P_NAME", "srt", true) end else local idx = 0 reaper.InsertTrackAtIndex(idx, true) reaper.TrackList_AdjustWindows(false) track = reaper.GetTrack(0, idx) local _, name = reaper.GetSetMediaTrackInfo_String(track, "P_NAME", "", false) if name == "" then local trackName = (currentLang == "zh-CN" or currentLang == "zh-TW") and "Voxly 字幕" or "Voxly Subtitles" reaper.GetSetMediaTrackInfo_String(track, "P_NAME", trackName, true) end end reaper.Undo_BeginBlock() local imported = 0 local skipped = 0 local processed = 0 local fx_success_count = 0 local fx_fail_count = 0 if not is_auto then reaper.ShowConsoleMsg(L.startImport .. "\n") end for block in content:gmatch("(.-)\n\n") do block = trim(block) if block ~= "" then processed = processed + 1 if not is_auto and (processed % 20 == 0 or processed <= 5) then reaper.ShowConsoleMsg(string.format(L.progress, processed, total_count, imported) .. "\n") end local lines = {} for l in block:gmatch("([^\n]+)") do table.insert(lines, trim(l)) end if #lines >= 2 then local sstr, estr = lines[2]:match("([^%s]+)%s*%-%-%>%s*([^%s]+)") if sstr and estr then local start_sec = time_str_to_seconds(sstr) local end_sec = time_str_to_seconds(estr) if start_sec and end_sec and end_sec > start_sec then local text = "" if #lines > 2 then for i = 3, #lines do if lines[i] ~= "" then text = text .. (text=="" and "" or " ") .. lines[i] end end end local current_R, current_G, current_B = fontR, fontG, fontB local color_match = text:match('') if color_match then local r, g, b = hex_to_rgb(color_match) if r and g and b then current_R, current_G, current_B = r, g, b end text = text:gsub('', '') text = text:gsub('', '') end text = process_text(text, max_chars, auto_wrap, vertical_screen and vertical_text) local len = end_sec - start_sec if min_len > 0 and len < min_len then len = min_len end local item = reaper.AddMediaItemToTrack(track) if item then reaper.SetMediaItemInfo_Value(item, "D_POSITION", start_sec) reaper.SetMediaItemInfo_Value(item, "D_LENGTH", len) local r_int = math.floor(current_R * 255 + 0.5) local g_int = math.floor(current_G * 255 + 0.5) local b_int = math.floor(current_B * 255 + 0.5) reaper.SetMediaItemInfo_Value(item, "I_CUSTOMCOLOR", reaper.ColorToNative(r_int, g_int, b_int)|0x1000000) local take = reaper.AddTakeToMediaItem(item) if take and (text_mode == 1 or text_mode == 3) then reaper.GetSetMediaItemTakeInfo_String(take, "P_NAME", text, true) end if (text_mode == 2 or text_mode == 3) then if reaper.ULT_SetMediaItemNote then reaper.ULT_SetMediaItemNote(item, text) elseif reaper.GetSetMediaItemInfo_String then reaper.GetSetMediaItemInfo_String(item, "P_NOTES", text, true) end end if take then local fx_success, fx_msg = setup_video_processor_fx(item, take, ypos, xpos, text_height, current_R, current_G, current_B, bg_mode) if fx_success then fx_success_count = fx_success_count + 1 else fx_fail_count = fx_fail_count + 1 if not is_auto then reaper.ShowConsoleMsg(L.warning .. fx_msg .. " (item #" .. imported+1 .. ")\n") end end end imported = imported + 1 else skipped = skipped + 1 end else skipped = skipped + 1 end else skipped = skipped + 1 end else skipped = skipped + 1 end end end reaper.UpdateArrange() reaper.Undo_EndBlock(L.undoAction, -1) if not is_auto then local result_msg = string.format( L.importComplete .. "\n\n" .. L.importSuccess .. "\n" .. L.skippedInvalid .. "\n" .. L.totalProcessed .. "\n\n" .. L.fxStatus .. "\n" .. L.fxSuccess .. "\n" .. L.fxFailed, imported, skipped, processed, fx_success_count, fx_fail_count ) if vertical_screen then result_msg = result_msg .. string.format( "\n\n" .. L.verticalMode .. "\n" .. L.textLayout, L.enabled, vertical_text and L.vertical or L.horizontal ) else result_msg = result_msg .. "\n\n" .. string.format(L.verticalMode, L.disabled) end if max_chars > 0 then result_msg = result_msg .. string.format("\n\n" .. L.charLimit, max_chars, auto_wrap and L.wrap or L.truncate) end MsgBox(result_msg) reaper.ShowConsoleMsg(L.importComplete .. "\n") end end local function export_subtitles() local LANG = { ["zh-CN"] = { title = "导出字幕", target_track = "目标轨道 (0=自动,1=选中):", text_source = "字幕来源 (1=Take名,2=备注,3=优先备注):", time_format = "时间格式 (0=逗号,1=点号):", sort_mode = "排序方式 (0=时间,1=轨道):", open_folder = "导出后打开文件夹 (0=否,1=是):", no_subtitles = "未找到字幕项目", export_success = "导出成功!\n文件: %s\n共 %d 条", file_error = "无法创建文件: %s", js_required = "需安装 ReaScript JS 扩展才能保存文件。\n是否打开下载页面?", dialog_title = "保存字幕文件", default_name = "字幕.srt" }, ["zh-TW"] = { title = "匯出字幕", target_track = "目標軌道 (0=自動,1=選中):", text_source = "字幕來源 (1=Take名,2=備註,3優先備註):", time_format = "時間格式 (0=逗號,1=點號):", sort_mode = "排序方式 (0時間,1軌道):", open_folder = "匯出後打開資料夾 (0否,1是):", no_subtitles = "未找到字幕項目", export_success = "匯出成功!\n檔案: %s\n共 %d 筆", file_error = "無法建立檔案: %s", js_required = "需安裝 ReaScript JS 擴展才能保存檔案。\n是否打開下載頁面?", dialog_title = "保存字幕檔案", default_name = "字幕.srt" }, ["en"] = { title = "Export Subtitles", target_track = "Target Track (0=Auto,1=Selected):", text_source = "Text Source (1=Take,2=Notes,3=Prefer Notes):", time_format = "Time Format (0=Comma,1=Dot):", sort_mode = "Sort Mode (0=By Time,1=By Track):", open_folder = "Open Folder After Export (0=No,1=Yes):", no_subtitles = "No subtitle items found", export_success = "Export successful!\nFile: %s\nTotal %d items", file_error = "Cannot create file: %s", js_required = "JS Extension required for file save.\nOpen download page?", dialog_title = "Save Subtitle File", default_name = "subtitle.srt" } } local L_ex = LANG[currentLang] if not reaper.JS_Dialog_BrowseForSaveFile then local install_js = reaper.ShowMessageBox(L_ex.js_required, L_ex.title, 4) if install_js == 6 then reaper.CF_ShellExecute("https://github.com/juliansader/ReaExtensions/tree/master/js_ReaScriptAPI/") end return end local ret, user_input = reaper.GetUserInputs(L_ex.title, 5, table.concat({L_ex.target_track, L_ex.text_source, L_ex.time_format, L_ex.sort_mode, L_ex.open_folder}, ","), "1,1,1,0,1") if not ret then return end local target_mode, text_source, time_format, sort_mode, open_folder = user_input:match("([^,]+),([^,]+),([^,]+),([^,]+),([^,]+)") target_mode = tonumber(target_mode) or 1 text_source = tonumber(text_source) or 1 time_format = tonumber(time_format) or 1 sort_mode = tonumber(sort_mode) or 0 open_folder = tonumber(open_folder) or 1 local subtitle_items = {} local project = 0 local item_count = reaper.CountMediaItems(project) for i = 0, item_count - 1 do local item = reaper.GetMediaItem(project, i) local track = reaper.GetMediaItemTrack(item) local track_valid = false if target_mode == 1 then track_valid = reaper.IsTrackSelected(track) else local _, track_name = reaper.GetTrackName(track, "") track_name = track_name:lower() if track_name:find("subtitle") or track_name:find("字幕") or track_name:find("caption") or track_name:find("text") then track_valid = true else local item_count_on_track = reaper.CountTrackMediaItems(track) for j = 0, item_count_on_track - 1 do local track_item = reaper.GetTrackMediaItem(track, j) local take = reaper.GetActiveTake(track_item) if take then local _, take_name = reaper.GetSetMediaItemTakeInfo_String(take, "P_NAME", "", false) local _, item_notes = reaper.GetSetMediaItemInfo_String(track_item, "P_NOTES", "", false) if #take_name > 0 or #item_notes > 0 then track_valid = true break end end end end end if track_valid then local take = reaper.GetActiveTake(item) if take then local position = reaper.GetMediaItemInfo_Value(item, "D_POSITION") local length = reaper.GetMediaItemInfo_Value(item, "D_LENGTH") local end_time = position + length local text = "" local _, take_name = reaper.GetSetMediaItemTakeInfo_String(take, "P_NAME", "", false) local _, item_notes = reaper.GetSetMediaItemInfo_String(item, "P_NOTES", "", false) take_name = take_name:gsub("^%s+", ""):gsub("%s+$", ""):gsub("%s+", " ") item_notes = item_notes:gsub("^%s+", ""):gsub("%s+$", ""):gsub("%s+", " ") if text_source == 1 then text = take_name elseif text_source == 2 then text = item_notes else text = (#item_notes > 0) and item_notes or take_name end if #text > 0 then table.insert(subtitle_items, { position = position, end_time = end_time, text = text, track_number = reaper.GetMediaTrackInfo_Value(track, "IP_TRACKNUMBER"), item_index = i }) end end end end if #subtitle_items == 0 then reaper.ShowMessageBox(L_ex.no_subtitles, "提示", 0) return end if sort_mode == 0 then table.sort(subtitle_items, function(a, b) return a.position < b.position end) else table.sort(subtitle_items, function(a, b) if a.track_number == b.track_number then return a.position < b.position end return a.track_number < b.track_number end) end local srt_content = "" local decimal_separator = (time_format == 0) and "," or "." for i, item in ipairs(subtitle_items) do srt_content = srt_content .. i .. "\n" local hours_start = math.floor(item.position / 3600) local mins_start = math.floor((item.position % 3600) / 60) local secs_start = item.position % 60 local ms_start = math.floor((secs_start - math.floor(secs_start)) * 1000 + 0.5) local start_timecode = string.format("%02d:%02d:%02d%s%03d", hours_start, mins_start, math.floor(secs_start), decimal_separator, ms_start) local hours_end = math.floor(item.end_time / 3600) local mins_end = math.floor((item.end_time % 3600) / 60) local secs_end = item.end_time % 60 local ms_end = math.floor((secs_end - math.floor(secs_end)) * 1000 + 0.5) local end_timecode = string.format("%02d:%02d:%02d%s%03d", hours_end, mins_end, math.floor(secs_end), decimal_separator, ms_end) srt_content = srt_content .. start_timecode .. " --> " .. end_timecode .. "\n" srt_content = srt_content .. item.text .. "\n\n" end local project_path = reaper.GetProjectPath("") local project_name = reaper.GetProjectName(project, "") project_name = project_name:gsub("%.rpp$", "") local default_filename = project_path .. "/" .. L_ex.default_name if #project_name > 0 then default_filename = project_path .. "/" .. project_name .. " " .. L_ex.default_name end local retval, filename = reaper.JS_Dialog_BrowseForSaveFile(L_ex.dialog_title, project_path, L_ex.default_name, "SRT files (*.srt)\0*.srt\0") if not retval then return end if not filename:lower():match("%.srt$") then filename = filename .. ".srt" end local file = io.open(filename, "w") if file then file:write(srt_content) file:close() reaper.ShowMessageBox(string.format(L_ex.export_success, filename, #subtitle_items), "完成", 0) if open_folder == 1 then local os_name = reaper.GetOS():lower() if os_name:find("windows") then local win_path = filename:gsub("/", "\\") reaper.ExecProcess('explorer /select,"' .. win_path .. '"', 0) elseif os_name:find("mac") then os.execute('open -R "' .. filename .. '"') else local folder_path = filename:match("(.*[/\\])") if folder_path then reaper.CF_ShellExecute(folder_path) end end end else reaper.ShowMessageBox(string.format(L_ex.file_error, filename), "错误", 0) end end if operation == "import" then import_subtitles() elseif operation == "export" then export_subtitles() end