diff --git a/reascripts/ReaSpeech/source/ReaSpeechWidgets.lua b/reascripts/ReaSpeech/source/ReaSpeechWidgets.lua index e1c7a14e..f1c989b0 100644 --- a/reascripts/ReaSpeech/source/ReaSpeechWidgets.lua +++ b/reascripts/ReaSpeech/source/ReaSpeechWidgets.lua @@ -2,486 +2,490 @@ ReaSpeechWidgets.lua - collection of common widgets that ReaSpeech uses -]]-- - +]] -- ReaSpeechWidget = Polo { - HELP_ICON_SIZE = 15, + HELP_ICON_SIZE = 15 } function ReaSpeechWidget:init() - if not self.state then - assert(self.default ~= nil, "default value not provided") - self.state = Storage.memory(self.default) - end - assert(self.renderer, "renderer not provided") - self.ctx = self.ctx or ctx - self.widget_id = self.widget_id or reaper.genGuid() - self.on_set = nil + if not self.state then + assert(self.default ~= nil, "default value not provided") + self.state = Storage.memory(self.default) + end + assert(self.renderer, "renderer not provided") + self.ctx = self.ctx or ctx + self.widget_id = self.widget_id or reaper.genGuid() + self.on_set = nil end function ReaSpeechWidget:render(...) - ImGui.PushID(self.ctx, self.widget_id) - local args = ... - Trap(function() - self.renderer(self, args) - end) - ImGui.PopID(self.ctx) + ImGui.PushID(self.ctx, self.widget_id) + local args = ... + Trap(function() + self.renderer(self, args) + end) + ImGui.PopID(self.ctx) end function ReaSpeechWidget:render_help_icon() - local options = self.options - local size = self.HELP_ICON_SIZE - Widgets.icon(Icons.info, '##help-text', size, size, options.help_text, 0xffffffa0, 0xffffffff) + local options = self.options + local size = self.HELP_ICON_SIZE + Widgets.icon(Icons.info, '##help-text', size, size, options.help_text, 0xffffffa0, 0xffffffff) end function ReaSpeechWidget:render_label(label) - local options = self.options - label = label or options.label + local options = self.options + label = label or options.label - ImGui.Text(self.ctx, label) + ImGui.Text(self.ctx, label) - if label ~= '' and options.help_text then - ImGui.SameLine(self.ctx) - self:render_help_icon() - end + if label ~= '' and options.help_text then + ImGui.SameLine(self.ctx) + self:render_help_icon() + end - ImGui.Dummy(self.ctx, 0, 0) + ImGui.Dummy(self.ctx, 0, 0) end function ReaSpeechWidget:value() - return self.state:get() + return self.state:get() end function ReaSpeechWidget:set(value) - self.state:set(value) - if self.on_set then self:on_set() end + self.state:set(value) + if self.on_set then + self:on_set() + end end -- Widget Implementations ReaSpeechCheckbox = {} -ReaSpeechCheckbox.new = function (options) - options = options or { - label_long = nil, - label_short = nil, - width_threshold = nil, - } - options.default = options.default or false - - options.changed_handler = options.changed_handler or function(_) end - - local o = ReaSpeechWidget.new({ - state = options.state, - default = options.default, - widget_id = options.widget_id, - renderer = ReaSpeechCheckbox.renderer, - options = options, - }) - - options.changed_handler(o:value()) - - return o +ReaSpeechCheckbox.new = function(options) + options = options or { + label_long = nil, + label_short = nil, + width_threshold = nil + } + options.default = options.default or false + + options.changed_handler = options.changed_handler or function(_) + end + + local o = ReaSpeechWidget.new({ + state = options.state, + default = options.default, + widget_id = options.widget_id, + renderer = ReaSpeechCheckbox.renderer, + options = options + }) + + options.changed_handler(o:value()) + + return o end ReaSpeechCheckbox.simple = function(default_value, label, changed_handler) - return ReaSpeechCheckbox.new { - default = default_value, - label_long = label, - label_short = label, - width_threshold = 0, - changed_handler = changed_handler or function() end, - } + return ReaSpeechCheckbox.new { + default = default_value, + label_long = label, + label_short = label, + width_threshold = 0, + changed_handler = changed_handler or function() + end + } end -ReaSpeechCheckbox.renderer = function (self, column) - local options = self.options - local label = options.label_long +ReaSpeechCheckbox.renderer = function(self, column) + local options = self.options + local label = options.label_long - if column and column.width < options.width_threshold then - label = options.label_short - end + if column and column.width < options.width_threshold then + label = options.label_short + end - local rv, value = ImGui.Checkbox(self.ctx, label, self:value()) + local rv, value = ImGui.Checkbox(self.ctx, label, self:value()) - if options.help_text then - ImGui.SameLine(self.ctx) - ImGui.SetCursorPosY(self.ctx, ImGui.GetCursorPosY(self.ctx) + 7) - self:render_help_icon() - end - - if rv then - self:set(value) - options.changed_handler(value) - end + if options.help_text then + ImGui.SameLine(self.ctx) + ImGui.SetCursorPosY(self.ctx, ImGui.GetCursorPosY(self.ctx) + 7) + self:render_help_icon() + end + + if rv then + self:set(value) + options.changed_handler(value) + end end ReaSpeechTextInput = {} -ReaSpeechTextInput.new = function (options) - options = options or { - label = nil, - } - options.default = options.default or '' - - local o = ReaSpeechWidget.new({ - state = options.state, - default = options.default, - widget_id = options.widget_id, - renderer = ReaSpeechTextInput.renderer, - options = options, - }) - - return o +ReaSpeechTextInput.new = function(options) + options = options or { + label = nil + } + options.default = options.default or '' + + local o = ReaSpeechWidget.new({ + state = options.state, + default = options.default, + widget_id = options.widget_id, + renderer = ReaSpeechTextInput.renderer, + options = options + }) + + return o end ReaSpeechTextInput.simple = function(default_value, label) - return ReaSpeechTextInput.new { - default = default_value, - label = label - } + return ReaSpeechTextInput.new { + default = default_value, + label = label + } end -ReaSpeechTextInput.renderer = function (self) - local options = self.options +ReaSpeechTextInput.renderer = function(self) + local options = self.options - self:render_label() + self:render_label() - local imgui_label = ("##%s"):format(options.label) + local imgui_label = ("##%s"):format(options.label) - local rv, value = ImGui.InputText(self.ctx, imgui_label, self:value()) + local rv, value = ImGui.InputText(self.ctx, imgui_label, self:value()) - if rv then - self:set(value) - end + if rv then + self:set(value) + end end ReaSpeechCombo = {} -ReaSpeechCombo.new = function (options) - options = options or {} +ReaSpeechCombo.new = function(options) + options = options or {} - -- nothing is selected by default - options.default = options.default or nil + -- nothing is selected by default + options.default = options.default or nil - -- nil label won't render anything that takes space - options.label = options.label or "" + -- nil label won't render anything that takes space + options.label = options.label or "" - options.items = options.items or {} - options.item_labels = options.item_labels or {} + options.items = options.items or {} + options.item_labels = options.item_labels or {} - local o = ReaSpeechWidget.new({ - state = options.state, - default = options.default, - widget_id = options.widget_id, - renderer = ReaSpeechCombo.renderer, - options = options, - }) + local o = ReaSpeechWidget.new({ + state = options.state, + default = options.default, + widget_id = options.widget_id, + renderer = ReaSpeechCombo.renderer, + options = options + }) - return o + return o end -ReaSpeechCombo.renderer = function (self) - local options = self.options +ReaSpeechCombo.renderer = function(self) + local options = self.options - self:render_label() + self:render_label() - local imgui_label = ("##%s"):format(options.label) - local item_label = options.item_labels[self:value()] or "" - local combo_flags = ImGui.ComboFlags_HeightLarge() + local imgui_label = ("##%s"):format(options.label) + local item_label = options.item_labels[self:value()] or self:value() + local combo_flags = ImGui.ComboFlags_HeightLarge() - if ImGui.BeginCombo(self.ctx, imgui_label, item_label, combo_flags) then - Trap(function() - for _, item in pairs(options.items) do - local is_selected = (item == self:value()) - if ImGui.Selectable(self.ctx, options.item_labels[item], is_selected) then - self:set(item) - end - end - end) - ImGui.EndCombo(self.ctx) - end + if ImGui.BeginCombo(self.ctx, imgui_label, item_label, combo_flags) then + Trap(function() + for _, item in pairs(options.items) do + local is_selected = (item == self:value()) + local display_label = options.item_labels[item] or item + if ImGui.Selectable(self.ctx, display_label, is_selected) then + self:set(item) + end + end + end) + ImGui.EndCombo(self.ctx) + end end ReaSpeechTabBar = {} -ReaSpeechTabBar.new = function (options) - options = options or {} +ReaSpeechTabBar.new = function(options) + options = options or {} - -- nothing is selected by default - options.default = options.default or nil + -- nothing is selected by default + options.default = options.default or nil - options.tabs = options.tabs or {} + options.tabs = options.tabs or {} - local o = ReaSpeechWidget.new({ - default = options.default, - widget_id = options.widget_id, - renderer = ReaSpeechTabBar.renderer, - options = options, - }) + local o = ReaSpeechWidget.new({ + default = options.default, + widget_id = options.widget_id, + renderer = ReaSpeechTabBar.renderer, + options = options + }) - return o + return o end -ReaSpeechTabBar.renderer = function (self) - if ImGui.BeginTabBar(self.ctx, 'TabBar') then - for _, tab in pairs(self.options.tabs) do - if ImGui.BeginTabItem(self.ctx, tab.label) then - Trap(function() - self:set(tab.key) - end) - ImGui.EndTabItem(self.ctx) - end +ReaSpeechTabBar.renderer = function(self) + if ImGui.BeginTabBar(self.ctx, 'TabBar') then + for _, tab in pairs(self.options.tabs) do + if ImGui.BeginTabItem(self.ctx, tab.label) then + Trap(function() + self:set(tab.key) + end) + ImGui.EndTabItem(self.ctx) + end + end + ImGui.EndTabBar(self.ctx) end - ImGui.EndTabBar(self.ctx) - end end ReaSpeechTabBar.tab = function(key, label) - return { - key = key, - label = label - } + return { + key = key, + label = label + } end ReaSpeechButtonBar = {} -ReaSpeechButtonBar.new = function (options) - options = options or {} - - -- nothing is selected by default - options.default = options.default or nil - - -- nil label won't render anything that takes space - options.label = options.label or "" - - options.buttons = options.buttons or {} - options.styles = options.styles or {} - - local o = ReaSpeechWidget.new({ - state = options.state, - default = options.default, - widget_id = options.widget_id, - renderer = ReaSpeechButtonBar.renderer, - options = options, - }) - - local with_button_color = function (selected, f) - if selected then - ImGui.PushStyleColor(o.ctx, ImGui.Col_Button(), Theme.colors.dark_gray_translucent) - Trap(f) - ImGui.PopStyleColor(o.ctx) - else - f() - end - end - - o.layout = ColumnLayout.new { - column_padding = options.column_padding or 0, - margin_bottom = options.margin_bottom or 0, - margin_left = options.margin_left or 0, - margin_right = options.margin_right or 0, - width = options.width or 0, - num_columns = #options.buttons, - - render_column = function (column) - local bar_label = column.num == 1 and options.label or "" - o:render_label(bar_label) - - local button_label, model_name = table.unpack(options.buttons[column.num]) - with_button_color(o:value() == model_name, function () - if ImGui.Button(o.ctx, button_label, column.width) then - o:set(model_name) +ReaSpeechButtonBar.new = function(options) + options = options or {} + + -- nothing is selected by default + options.default = options.default or nil + + -- nil label won't render anything that takes space + options.label = options.label or "" + + options.buttons = options.buttons or {} + options.styles = options.styles or {} + + local o = ReaSpeechWidget.new({ + state = options.state, + default = options.default, + widget_id = options.widget_id, + renderer = ReaSpeechButtonBar.renderer, + options = options + }) + + local with_button_color = function(selected, f) + if selected then + ImGui.PushStyleColor(o.ctx, ImGui.Col_Button(), Theme.colors.dark_gray_translucent) + Trap(f) + ImGui.PopStyleColor(o.ctx) + else + f() end - end) end - } - return o + + o.layout = ColumnLayout.new { + column_padding = options.column_padding or 0, + margin_bottom = options.margin_bottom or 0, + margin_left = options.margin_left or 0, + margin_right = options.margin_right or 0, + width = options.width or 0, + num_columns = #options.buttons, + + render_column = function(column) + local bar_label = column.num == 1 and options.label or "" + o:render_label(bar_label) + + local button_label, model_name = table.unpack(options.buttons[column.num]) + with_button_color(o:value() == model_name, function() + if ImGui.Button(o.ctx, button_label, column.width) then + o:set(model_name) + end + end) + end + } + return o end -ReaSpeechButtonBar.renderer = function (self) - self.layout:render() +ReaSpeechButtonBar.renderer = function(self) + self.layout:render() end ReaSpeechButton = {} ReaSpeechButton.new = function(options) - options = options or {} + options = options or {} - -- nil label won't render anything that takes space - options.label = options.label or "" + -- nil label won't render anything that takes space + options.label = options.label or "" - options.disabled = options.disabled or false + options.disabled = options.disabled or false - if not options.disabled then - assert(options.on_click, "on_click handler not provided") - end + if not options.disabled then + assert(options.on_click, "on_click handler not provided") + end - local o = ReaSpeechWidget.new({ - default = true, - renderer = ReaSpeechButton.renderer, - options = options, - }) + local o = ReaSpeechWidget.new({ + default = true, + renderer = ReaSpeechButton.renderer, + options = options + }) - return o + return o end ReaSpeechButton.renderer = function(self) - local disable_if = ReaUtil.disabler(self.ctx) - local options = self.options + local disable_if = ReaUtil.disabler(self.ctx) + local options = self.options - disable_if(options.disabled, function() - if ImGui.Button(self.ctx, options.label, options.width) then - Trap(options.on_click) - end - end) + disable_if(options.disabled, function() + if ImGui.Button(self.ctx, options.label, options.width) then + Trap(options.on_click) + end + end) end ReaSpeechFileSelector = { - JSREASCRIPT_URL = 'https://forum.cockos.com/showthread.php?t=212174', - has_js_ReaScriptAPI = function() - return reaper.JS_Dialog_BrowseForSaveFile - end + JSREASCRIPT_URL = 'https://forum.cockos.com/showthread.php?t=212174', + has_js_ReaScriptAPI = function() + return reaper.JS_Dialog_BrowseForSaveFile + end } ReaSpeechFileSelector.new = function(options) - options = options or {} - options.default = options.default or '' - local title = options.title or 'Save file' - local folder = options.folder or '' - local file = options.file or '' - local ext = options.ext or '' - local save = options.save or false - local multi = options.multi or false - - local dialog_function - if save then - dialog_function = function() - return reaper.JS_Dialog_BrowseForSaveFile(title, folder, file, ext) - end - else - dialog_function = function() - return reaper.JS_Dialog_BrowseForOpenFiles(title, folder, file, ext, multi) - end - end - - local o = ReaSpeechWidget.new({ - default = options.default, - renderer = ReaSpeechFileSelector.renderer, - options = options, - }) - - options.button = ReaSpeechButton.new({ - label = options.button_label or 'Choose File', - disabled = not ReaSpeechFileSelector.has_js_ReaScriptAPI(), - width = options.button_width, - on_click = function() - local rv, selected_file = dialog_function() - if rv == 1 then - o:set(selected_file) - end + options = options or {} + options.default = options.default or '' + local title = options.title or 'Save file' + local folder = options.folder or '' + local file = options.file or '' + local ext = options.ext or '' + local save = options.save or false + local multi = options.multi or false + + local dialog_function + if save then + dialog_function = function() + return reaper.JS_Dialog_BrowseForSaveFile(title, folder, file, ext) + end + else + dialog_function = function() + return reaper.JS_Dialog_BrowseForOpenFiles(title, folder, file, ext, multi) + end end - }) - return o + local o = ReaSpeechWidget.new({ + default = options.default, + renderer = ReaSpeechFileSelector.renderer, + options = options + }) + + options.button = ReaSpeechButton.new({ + label = options.button_label or 'Choose File', + disabled = not ReaSpeechFileSelector.has_js_ReaScriptAPI(), + width = options.button_width, + on_click = function() + local rv, selected_file = dialog_function() + if rv == 1 then + o:set(selected_file) + end + end + }) + + return o end -- Display a text input for the output filename, with a Browse button if -- the js_ReaScriptAPI extension is available. ReaSpeechFileSelector.renderer = function(self) - local options = self.options + local options = self.options - ImGui.Text(self.ctx, options.label) + ImGui.Text(self.ctx, options.label) - ReaSpeechFileSelector.render_jsapi_notice(self) + ReaSpeechFileSelector.render_jsapi_notice(self) - options.button:render() - ImGui.SameLine(self.ctx) + options.button:render() + ImGui.SameLine(self.ctx) - local w, _ - if not options.input_width then - w, _ = ImGui.GetContentRegionAvail(self.ctx) - else - w = options.input_width - end + local w, _ + if not options.input_width then + w, _ = ImGui.GetContentRegionAvail(self.ctx) + else + w = options.input_width + end - ImGui.SetNextItemWidth(self.ctx, w) - local file_changed, file = ImGui.InputText(self.ctx, '##file', self:value()) - if file_changed then - self:set(file) - end + ImGui.SetNextItemWidth(self.ctx, w) + local file_changed, file = ImGui.InputText(self.ctx, '##file', self:value()) + if file_changed then + self:set(file) + end end ReaSpeechFileSelector.render_jsapi_notice = function(self) - if ReaSpeechFileSelector.has_js_ReaScriptAPI() then - return - end - - local _, spacing_v = ImGui.GetStyleVar(self.ctx, ImGui.StyleVar_ItemSpacing()) - ImGui.PushStyleVar(self.ctx, ImGui.StyleVar_ItemSpacing(), 0, spacing_v) - ImGui.Text(self.ctx, "To enable file selector, ") - ImGui.SameLine(self.ctx) - Widgets.link('install js_ReaScriptAPI', ReaUtil.url_opener(ReaSpeechFileSelector.JSREASCRIPT_URL)) - ImGui.SameLine(self.ctx) - ImGui.Text(self.ctx, ".") - ImGui.PopStyleVar(self.ctx) + if ReaSpeechFileSelector.has_js_ReaScriptAPI() then + return + end + + local _, spacing_v = ImGui.GetStyleVar(self.ctx, ImGui.StyleVar_ItemSpacing()) + ImGui.PushStyleVar(self.ctx, ImGui.StyleVar_ItemSpacing(), 0, spacing_v) + ImGui.Text(self.ctx, "To enable file selector, ") + ImGui.SameLine(self.ctx) + Widgets.link('install js_ReaScriptAPI', ReaUtil.url_opener(ReaSpeechFileSelector.JSREASCRIPT_URL)) + ImGui.SameLine(self.ctx) + ImGui.Text(self.ctx, ".") + ImGui.PopStyleVar(self.ctx) end ReaSpeechListBox = {} ReaSpeechListBox.new = function(options) - options = options or {} + options = options or {} - options = options or {} + options = options or {} - -- nothing is selected by default - options.default = options.default or nil + -- nothing is selected by default + options.default = options.default or nil - options.items = options.items or {} - options.item_labels = options.item_labels or {} + options.items = options.items or {} + options.item_labels = options.item_labels or {} - local o = ReaSpeechWidget.new({ - state = options.state, - default = options.default, - widget_id = options.widget_id, - renderer = ReaSpeechListBox.renderer, - options = options, - }) + local o = ReaSpeechWidget.new({ + state = options.state, + default = options.default, + widget_id = options.widget_id, + renderer = ReaSpeechListBox.renderer, + options = options + }) - Logging.init(o, 'ReaSpeechListBox') + Logging.init(o, 'ReaSpeechListBox') - return o + return o end ReaSpeechListBox.renderer = function(self) - local options = self.options + local options = self.options - self:render_label() + self:render_label() - local imgui_label = ("##%s"):format(options.label) + local imgui_label = ("##%s"):format(options.label) - local needs_update = false - if ImGui.BeginListBox(self.ctx, imgui_label) then - Trap(function() - local current = self:value() - local new_value = {} - for i, item in ipairs(options.items) do - new_value[item] = current[item] or false - local is_selected = current[item] - local label = options.item_labels[item] - ImGui.PushID(self.ctx, 'item' .. i) + local needs_update = false + if ImGui.BeginListBox(self.ctx, imgui_label) then Trap(function() - local result, now_selected = ImGui.Selectable(self.ctx, label, is_selected) - - if result and is_selected ~= now_selected then - needs_update = true - new_value[item] = now_selected - end + local current = self:value() + local new_value = {} + for i, item in ipairs(options.items) do + new_value[item] = current[item] or false + local is_selected = current[item] + local label = options.item_labels[item] + ImGui.PushID(self.ctx, 'item' .. i) + Trap(function() + local result, now_selected = ImGui.Selectable(self.ctx, label, is_selected) + + if result and is_selected ~= now_selected then + needs_update = true + new_value[item] = now_selected + end + end) + ImGui.PopID(self.ctx) + end + + if needs_update then + self:set(new_value) + end end) - ImGui.PopID(self.ctx) - end - - if needs_update then - self:set(new_value) - end - end) - ImGui.EndListBox(self.ctx) - end + ImGui.EndListBox(self.ctx) + end end diff --git a/reascripts/ReaSpeech/source/TranscriptExporter.lua b/reascripts/ReaSpeech/source/TranscriptExporter.lua index d9f4fb2c..37658203 100644 --- a/reascripts/ReaSpeech/source/TranscriptExporter.lua +++ b/reascripts/ReaSpeech/source/TranscriptExporter.lua @@ -2,320 +2,467 @@ TranscriptExporter.lua - Transcript export UI -]]-- - +]] -- TranscriptExporter = Polo { - TITLE = 'Export', - WIDTH = 650, - HEIGHT = 200, - BUTTON_WIDTH = 120, - INPUT_WIDTH = 120, - FILE_WIDTH = 500, + TITLE = 'Export', + WIDTH = 650, + HEIGHT = 200, + BUTTON_WIDTH = 120, + INPUT_WIDTH = 120, + FILE_WIDTH = 500 } function TranscriptExporter:init() - assert(self.transcript, 'missing transcript') - - Logging.init(self, 'TranscriptExporter') - - ToolWindow.init(self, { - title = self.TITLE, - width = self.WIDTH, - height = self.HEIGHT, - window_flags = 0 - | ImGui.WindowFlags_AlwaysAutoResize() - | ImGui.WindowFlags_NoCollapse() - | ImGui.WindowFlags_NoDocking() - }) - - self.export_formats = TranscriptExporterFormats.new { - TranscriptExportFormat.exporter_json(), - TranscriptExportFormat.exporter_srt(), - TranscriptExportFormat.exporter_csv(), - } - self.export_options = {} - self.file_selector = ReaSpeechFileSelector.new({ - label = 'File', - save = true, - button_width = self.BUTTON_WIDTH, - input_width = self.FILE_WIDTH - }) - - self.alert_popup = AlertPopup.new {} + assert(self.transcript, 'missing transcript') + + Logging.init(self, 'TranscriptExporter') + + ToolWindow.init(self, { + title = self.TITLE, + width = self.WIDTH, + height = self.HEIGHT, + window_flags = 0 | ImGui.WindowFlags_AlwaysAutoResize() | ImGui.WindowFlags_NoCollapse() | + ImGui.WindowFlags_NoDocking() + }) + + self.export_formats = TranscriptExporterFormats.new {TranscriptExportFormat.exporter_json(), + TranscriptExportFormat.exporter_srt(), + TranscriptExportFormat.exporter_csv(), + TranscriptExportFormat.exporter_vtt()} + + self.export_options = {} + + self.file_selector = ReaSpeechFileSelector.new({ + label = 'File', + save = true, + button_width = self.BUTTON_WIDTH, + input_width = self.FILE_WIDTH + }) + + self.alert_popup = AlertPopup.new {} + end function TranscriptExporter:show_success() - self.alert_popup.onclose = function () - self.alert_popup.onclose = nil - self:close() - end - self.alert_popup:show('Export Successful', 'Exported ' .. self.export_formats:selected_key() .. ' to: ' .. self.file_selector:value()) + self.alert_popup.onclose = function() + self.alert_popup.onclose = nil + self:close() + end + self.alert_popup:show('Export Successful', + 'Exported ' .. self.export_formats:selected_key() .. ' to: ' .. self.file_selector:value()) end function TranscriptExporter:show_error(msg) - self.alert_popup:show('Export Failed', msg) + self.alert_popup:show('Export Failed', msg) end function TranscriptExporter:render_content() - self.alert_popup:render() + self.alert_popup:render() - self.export_formats:render_combo(self.INPUT_WIDTH) + self.export_formats:render_combo() - ImGui.Spacing(ctx) + ImGui.Spacing(ctx) - self.export_formats:render_format_options(self.export_options) + self.export_formats:render_format_options(self.export_options) - ImGui.Spacing(ctx) + ImGui.Spacing(ctx) - self:render_file_selector() + self:render_file_selector() - self:render_separator() + self:render_separator() - self:render_buttons() + self:render_buttons() end function TranscriptExporter:render_file_selector() - self.file_selector:render() + self.file_selector:render() end function TranscriptExporter:render_buttons() - ReaUtil.disabler(ctx)(self.file_selector:value() == '', function() - if ImGui.Button(ctx, 'Export', self.BUTTON_WIDTH, 0) then - if self:handle_export() then - self:show_success() - end - end - end) + ReaUtil.disabler(ctx)(self.file_selector:value() == '', function() + if ImGui.Button(ctx, 'Export', self.BUTTON_WIDTH, 0) then + if self:handle_export() then + self:show_success() + end + end + end) - ImGui.SameLine(ctx) - if ImGui.Button(ctx, 'Cancel', self.BUTTON_WIDTH, 0) then - self:close() - end + ImGui.SameLine(ctx) + if ImGui.Button(ctx, 'Cancel', self.BUTTON_WIDTH, 0) then + self:close() + end end function TranscriptExporter:handle_export() - if self.file_selector:value() == '' then - self:show_error('Please specify a file name.') - return false - end - local file = io.open(self.file_selector:value(), 'w') - if not file then - self:show_error('Could not open file: ' .. self.file_selector:value()) - return false - end - self.export_formats:write(self.transcript, file, self.export_options) - file:close() - return true + if self.file_selector:value() == '' then + self:show_error('Please specify a file name.') + return false + end + local file = io.open(self.file_selector:value(), 'w') + if not file then + self:show_error('Could not open file: ' .. self.file_selector:value()) + return false + end + self.export_formats:write(self.transcript, file, self.export_options) + file:close() + return true end TranscriptExporterFormats = Polo { - new = function(formatters) - local format_map = {} + new = function(formatters) + local format_map = {} - for i, formatter in ipairs(formatters) do - format_map[formatter.key] = i - end + for i, formatter in ipairs(formatters) do + format_map[formatter.key] = i + end - return { - formatters = formatters, - format_map = format_map, - } - end, + return { + formatters = formatters, + format_map = format_map + } + end } -function TranscriptExporterFormats:render_combo(width) - ImGui.Text(ctx, 'Format') - ImGui.SetNextItemWidth(ctx, width) - if ImGui.BeginCombo(ctx, "##format", self.selected_format_key) then - Trap(function() - for _, format in pairs(self.formatters) do - local is_selected = self.selected_format_key == format.key - if ImGui.Selectable(ctx, format.key, is_selected) then - self.selected_format_key = format.key - end - if is_selected then - ImGui.SetItemDefaultFocus(ctx) +function TranscriptExporterFormats:init() + if not self.format_widget then + local storage = Storage.ExtState.make { + section = 'ReaSpeech.Export.Format', + persist = true + } + + local format_items = {} + for _, format in ipairs(self.formatters) do + table.insert(format_items, format.key) end - end - end) - ImGui.EndCombo(ctx) - end + + self.format_widget = ReaSpeechCombo.new { + state = storage:string('format', self.formatters[1].key), + label = 'Format', + items = format_items + } + end +end + +function TranscriptExporterFormats:render_combo() + self:init() + self.format_widget:render() end function TranscriptExporterFormats:selected_key() - return self:selected_format().key + return self.format_widget:value() end function TranscriptExporterFormats:file_selector_spec() - return self:selected_format():file_selector_spec() + return self:selected_format():file_selector_spec() end function TranscriptExporterFormats:write(transcript, output_file, options) - return self:selected_format().writer(transcript, output_file, options) + return self:selected_format().writer(transcript, output_file, options) end function TranscriptExporterFormats:selected_format() - if not self.selected_format_key then - if not self.formatters or #self.formatters < 1 then - self:debug('no formats to set for default') - return - end - - self.selected_format_key = self.formatters[1].key - end - - local index = self.format_map[self.selected_format_key] - - return self.formatters[index] + local index = self.format_map[self:selected_key()] + return self.formatters[index] end function TranscriptExporterFormats:render_format_options(options) - Trap(function() - local format = self:selected_format() + Trap(function() + local format = self:selected_format() - if format then - format.option_renderer(options) - end - end) + if format then + format.option_renderer(options) + end + end) end TranscriptExportFormat = Polo { - OPTIONS_NOOP = function(_options) end, - - new = function (key, extension, option_renderer, writer_f) - return { - key = key, - extension = extension, - option_renderer = option_renderer, - writer = writer_f, - } - end, + OPTIONS_NOOP = function(_options) + end, + + new = function(key, extension, option_renderer, writer_f) + return { + key = key, + extension = extension, + option_renderer = option_renderer, + writer = writer_f + } + end } function TranscriptExportFormat:file_selector_spec() - local selector_spec = '%s files (*.%s)\0*.%s\0All files (*.*)\0*.*\0\0' - return selector_spec:format(self.key, self.extension, self.extension) + local selector_spec = '%s files (*.%s)\0*.%s\0All files (*.*)\0*.*\0\0' + return selector_spec:format(self.key, self.extension, self.extension) end function TranscriptExportFormat.exporter_json() - return TranscriptExportFormat.new( - 'JSON', 'json', - TranscriptExportFormat.options_json, - TranscriptExportFormat.writer_json - ) + return TranscriptExportFormat.new('JSON', 'json', TranscriptExportFormat.options_json, + TranscriptExportFormat.writer_json) end function TranscriptExportFormat.options_json(options) - local rv, value = ImGui.Checkbox(ctx, 'One Object per Transcript Segment', options.object_per_segment) - if rv then - options.object_per_segment = value - end + if not options.json_widgets then + local storage = Storage.ExtState.make { + section = 'ReaSpeech.Export.JSON', + persist = true + } + + options.json_widgets = { + object_per_segment = ReaSpeechCheckbox.new { + state = storage:boolean('object_per_segment', false), + label_long = 'One Object per Transcript Segment', -- Changed from label to label_long + help_text = [[Each transcript segment is exported a separate JSON object.]] + } + } + end + + Trap(function() + options.json_widgets.object_per_segment:render() + options.object_per_segment = options.json_widgets.object_per_segment:value() + end) end function TranscriptExportFormat.writer_json(transcript, output_file, options) - if options.object_per_segment then - for _, segment in pairs(transcript:get_segments()) do - output_file:write(segment:to_json()) - output_file:write('\n') + if options.object_per_segment then + for _, segment in pairs(transcript:get_segments()) do + output_file:write(segment:to_json()) + output_file:write('\n') + end + else + output_file:write(transcript:to_json()) end - else - output_file:write(transcript:to_json()) - end end function TranscriptExportFormat.exporter_srt() - return TranscriptExportFormat.new( - 'SRT', 'srt', - TranscriptExportFormat.options_srt, - TranscriptExportFormat.writer_srt - ) + return TranscriptExportFormat.new('SRT', 'srt', TranscriptExportFormat.options_srt, + TranscriptExportFormat.writer_srt) end function TranscriptExportFormat.strip_non_numeric(value) - return value:gsub("[^0-9]", ""):gsub("^0+", "") + return value:gsub("[^0-9]", ""):gsub("^0+", "") end -function TranscriptExportFormat.options_srt(options) - local rv, value - - rv, value = ImGui.InputText(ctx, 'X1', options.coords_x1, ImGui.InputTextFlags_CharsDecimal()) - if rv then - options.coords_x1 = TranscriptExportFormat.strip_non_numeric(value) - end +function TranscriptExportFormat.strip_non_numeric_percent(value) + -- Remove any % sign from the end + value = value:gsub("%%$", "") + -- Keep only numbers, decimal points + value = value:gsub("[^0-9%.]", "") + -- Remove leading zeros (but keep single zero) + value = value:gsub("^0+(%d)", "%1") + return value +end - ImGui.SameLine(ctx) +function TranscriptExportFormat.options_srt(options) + if not options.srt_widgets then + local storage = Storage.ExtState.make { + section = 'ReaSpeech.Export.SRT', + persist = true + } + + options.srt_widgets = { + coords_x1 = ReaSpeechTextInput.new { + state = storage:string('coords_x1', ''), + label = 'X1', + help_text = [[Left coordinate of the subtitle box.]], + flags = ImGui.InputTextFlags_CharsDecimal() + }, + coords_y1 = ReaSpeechTextInput.new { + state = storage:string('coords_y1', ''), + label = 'Y1', + help_text = [[Top coordinate of the subtitle box.]], + flags = ImGui.InputTextFlags_CharsDecimal() + }, + coords_x2 = ReaSpeechTextInput.new { + state = storage:string('coords_x2', ''), + label = 'X2', + help_text = [[Right coordinate of the subtitle box.]], + flags = ImGui.InputTextFlags_CharsDecimal() + }, + coords_y2 = ReaSpeechTextInput.new { + state = storage:string('coords_y2', ''), + label = 'Y2', + help_text = [[Bottom coordinate of the subtitle box.]], + flags = ImGui.InputTextFlags_CharsDecimal() + } + } + end - rv, value = ImGui.InputText(ctx, 'Y1', options.coords_y1, ImGui.InputTextFlags_CharsDecimal()) - if rv then - options.coords_y1 = TranscriptExportFormat.strip_non_numeric(value) - end + Trap(function() + ImGui.Text(ctx, "Subtitle Coordinates") - rv, value = ImGui.InputText(ctx, 'X2', options.coords_x2, ImGui.InputTextFlags_CharsDecimal()) - if rv then - options.coords_x2 = TranscriptExportFormat.strip_non_numeric(value) - end + options.srt_widgets.coords_x1:render() + ImGui.SameLine(ctx) + options.srt_widgets.coords_y1:render() - ImGui.SameLine(ctx) + options.srt_widgets.coords_x2:render() + ImGui.SameLine(ctx) + options.srt_widgets.coords_y2:render() - rv, value = ImGui.InputText(ctx, 'Y2', options.coords_y2, ImGui.InputTextFlags_CharsDecimal()) - if rv then - options.coords_y2 = TranscriptExportFormat.strip_non_numeric(value) - end + -- Update options with widget values + options.coords_x1 = TranscriptExportFormat.strip_non_numeric(options.srt_widgets.coords_x1:value()) + options.coords_y1 = TranscriptExportFormat.strip_non_numeric(options.srt_widgets.coords_y1:value()) + options.coords_x2 = TranscriptExportFormat.strip_non_numeric(options.srt_widgets.coords_x2:value()) + options.coords_y2 = TranscriptExportFormat.strip_non_numeric(options.srt_widgets.coords_y2:value()) + end) end function TranscriptExportFormat.writer_srt(transcript, output_file, options) - local writer = SRTWriter.new { file = output_file, options = options } - writer:write(transcript) + local writer = SRTWriter.new { + file = output_file, + options = options + } + writer:write(transcript) end function TranscriptExportFormat.exporter_csv() - return TranscriptExportFormat.new( - 'CSV', 'csv', - TranscriptExportFormat.options_csv, - TranscriptExportFormat.writer_csv - ) + return TranscriptExportFormat.new('CSV', 'csv', TranscriptExportFormat.options_csv, + TranscriptExportFormat.writer_csv) end function TranscriptExportFormat.options_csv(options) - local delimiters = CSVWriter.DELIMITERS - - local selected_delimiter = delimiters[1] + if not options.csv_widgets then + local storage = Storage.ExtState.make { + section = 'ReaSpeech.Export.CSV', + persist = true + } + + local delimiter_items = {} + for _, delimiter in ipairs(CSVWriter.DELIMITERS) do + table.insert(delimiter_items, delimiter.name) + end - for _, delimiter in ipairs(delimiters) do - if delimiter.char == options.delimiter then - selected_delimiter = delimiter - break + options.csv_widgets = { + delimiter = ReaSpeechCombo.new { + state = storage:string('delimiter', CSVWriter.DELIMITERS[1].name), + label = 'Delimiter', + help_text = [[Choose the character that separates fields: +Comma - Standard CSV format +Tab - Tab-separated values (TSV) +Semicolon (;) - Alternative for locales using comma as decimal]], + items = delimiter_items + }, + + include_header = ReaSpeechCheckbox.new { + state = storage:boolean('include_header', true), + label_long = 'Include Header Row', + help_text = [[Adds a header row with column names. +Useful for importing into spreadsheets.]] + } + } end - end - if ImGui.BeginCombo(ctx, 'Delimiter', selected_delimiter.name) then Trap(function() - for _, delimiter in ipairs(delimiters) do - local is_selected = options.delimiter == delimiter.char - if ImGui.Selectable(ctx, delimiter.name, is_selected) then - options.delimiter = delimiter.char + options.csv_widgets.delimiter:render() + ImGui.Spacing(ctx) + options.csv_widgets.include_header:render() + + -- Update options with widget values + local selected_delimiter = CSVWriter.DELIMITERS[1] + for _, delimiter in ipairs(CSVWriter.DELIMITERS) do + if delimiter.name == options.csv_widgets.delimiter:value() then + selected_delimiter = delimiter + break + end end - if is_selected then - ImGui.SetItemDefaultFocus(ctx) - end - end + options.delimiter = selected_delimiter.char + options.include_header_row = options.csv_widgets.include_header:value() end) - ImGui.EndCombo(ctx) - end +end - ImGui.Spacing(ctx) +function TranscriptExportFormat.writer_csv(transcript, output_file, options) + local writer = CSVWriter.new { + file = output_file, + delimiter = options.delimiter, + include_header_row = options.include_header_row + } + writer:write(transcript) +end - local rv, value = ImGui.Checkbox(ctx, 'Include Header Row', options.include_header_row) - if rv then - options.include_header_row = value - end +function TranscriptExportFormat.exporter_vtt() + return TranscriptExportFormat.new('VTT', 'vtt', TranscriptExportFormat.options_vtt, + TranscriptExportFormat.writer_vtt) end -function TranscriptExportFormat.writer_csv(transcript, output_file, options) - local writer = CSVWriter.new { - file = output_file, - delimiter = options.delimiter, - include_header_row = options.include_header_row - } - writer:write(transcript) +function TranscriptExportFormat.options_vtt(options) + if not options.vtt_widgets then + local storage = Storage.ExtState.make { + section = 'ReaSpeech.Export.VTT', + persist = true + } + + options.vtt_widgets = { + text_direction = ReaSpeechCombo.new { + state = storage:string('text_direction', 'Horizontal'), + label = 'Text Direction', + help_text = [[Horizontal: default text +for languages like Japanese that can be written vertically: + Right to Left - Vertical text written right-to-left + Left to Right - Vertical text written left-to-right]], + items = {'Horizontal', 'Right to Left', 'Left to Right'} + }, + + line = ReaSpeechTextInput.new { + state = storage:string('line', ''), + label = 'Line Position', + help_text = [[line position of captions: + Numbers: Place on specific line + - Positive (1,2,3...) counts from top + - Negative (-1,-2,-3...) counts from bottom + Percentages: Position relative to video height + - 0% = top + - 100% = bottom]] + }, + + position = ReaSpeechTextInput.new { + state = storage:string('position', ''), + label = 'Horizontal Position %', + help_text = [[Sets horizontal position: 0% = left edge, 50% = center, 100% = right edge]] + }, + + size = ReaSpeechTextInput.new { + state = storage:string('size', ''), + label = 'Caption Box Size %', + help_text = [[Sets caption box width (0-100% of video width), Default auto-sizes to fit text]] + }, + + align = ReaSpeechCombo.new { + state = storage:string('align', 'Default'), + label = 'Text Alignment', + items = {'Start', 'Center', 'End', 'Left', 'Right'} + } + } + end + + Trap(function() + options.vtt_widgets.text_direction:render() + ImGui.Spacing(ctx) + + options.vtt_widgets.line:render() + ImGui.Spacing(ctx) + + options.vtt_widgets.position:render() + ImGui.Spacing(ctx) + + options.vtt_widgets.size:render() + ImGui.Spacing(ctx) + + options.vtt_widgets.align:render() + ImGui.Spacing(ctx) + + local direction = options.vtt_widgets.text_direction:value() + options.vertical = direction == 'Right to Left' and 'rl' or direction == 'Left to Right' and 'lr' or nil -- Horizontal case + + options.line = options.vtt_widgets.line:value() + options.position = TranscriptExportFormat.strip_non_numeric_percent(options.vtt_widgets.position:value()) + options.size = TranscriptExportFormat.strip_non_numeric_percent(options.vtt_widgets.size:value()) + options.align = options.vtt_widgets.align:value() ~= 'Default' and + string.lower(options.vtt_widgets.align:value()) or nil + end) +end + +function TranscriptExportFormat.writer_vtt(transcript, output_file, options) + local writer = VTTWriter.new { + file = output_file, + options = options + } + writer:write(transcript) end diff --git a/reascripts/ReaSpeech/source/VTTWriter.lua b/reascripts/ReaSpeech/source/VTTWriter.lua new file mode 100644 index 00000000..a26e9c40 --- /dev/null +++ b/reascripts/ReaSpeech/source/VTTWriter.lua @@ -0,0 +1,84 @@ +--[[ + VTTWriter.lua - WebVTT file writer +]] -- +VTTWriter = Polo { + TIME_FORMAT = '%02d:%02d:%02d.%03d' +} + +function VTTWriter:init() + assert(self.file, 'missing file') + self.options = self.options or {} + + -- Map options directly from passed in options + self.vertical = self.options.vertical + self.line = self.options.line + self.position = self.options.position + self.size = self.options.size + self.align = self.options.align +end + +VTTWriter.format_time = function(time) + local milliseconds = math.floor(time * 1000) % 1000 + local seconds = math.floor(time) % 60 + local minutes = math.floor(time / 60) % 60 + local hours = math.floor(time / 3600) + return string.format(VTTWriter.TIME_FORMAT, hours, minutes, seconds, milliseconds) +end + +function VTTWriter:write(transcript) + -- Write the required WEBVTT header + self.file:write('WEBVTT\n\n') + + -- Write each segment + for _, segment in pairs(transcript:get_segments()) do + self:write_segment(segment) + end +end + +function VTTWriter:write_segment(segment) + local start = segment:get('start') + local end_ = segment:get('end') + local text = segment:get('text') + self:write_cue(text, start, end_) +end + +function VTTWriter:write_cue(text, start, end_) + local start_str = VTTWriter.format_time(start) + local end_str = VTTWriter.format_time(end_) + + -- Write timestamp line with positioning if specified + self.file:write(start_str) + self.file:write(' --> ') + self.file:write(end_str) + self.file:write(self:settings()) + self.file:write('\n') + + -- Write cue text + self.file:write(text) + self.file:write('\n\n') +end + +function VTTWriter:settings() + local settings = {} + + if self.vertical then + table.insert(settings, 'vertical:' .. self.vertical) + end + if self.line then + table.insert(settings, 'line:' .. self.line) + end + if self.position then + table.insert(settings, 'position:' .. self.position .. '%') + end + if self.size then + table.insert(settings, 'size:' .. self.size .. '%') + end + if self.align then + table.insert(settings, 'align:' .. self.align) + end + + if #settings == 0 then + return '' + end + return ' ' .. table.concat(settings, ' ') +end \ No newline at end of file