From 664c573e6d7006163124210f2d3d3e9cbeeaf737 Mon Sep 17 00:00:00 2001 From: kuator Date: Fri, 7 Aug 2026 22:58:32 +0500 Subject: [PATCH 1/2] fix: align overlapping subtitle cues --- mpvacious/subtitles/observer.lua | 15 +++---- mpvacious/subtitles/sub_list.lua | 77 ++++++++++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 12 deletions(-) diff --git a/mpvacious/subtitles/observer.lua b/mpvacious/subtitles/observer.lua index f8d756f..5171484 100644 --- a/mpvacious/subtitles/observer.lua +++ b/mpvacious/subtitles/observer.lua @@ -238,12 +238,7 @@ self.collect_from_all_dialogues = function(n_lines) return Subtitle:new() -- return a default empty new Subtitle to let consumer handle end local text, end_sub = all_dialogs.get_n_text(current_sub, n_lines) - local secondary_text, _ - if current_secondary_sub == nil then - secondary_text = '' - else - secondary_text, _ = all_secondary_dialogs.get_n_text(current_secondary_sub, n_lines) -- we'll use main sub's timing - end + local secondary_text = all_secondary_dialogs.get_overlapping_text(current_sub['start'], end_sub['end']) return Subtitle:new { ['text'] = text, ['secondary'] = secondary_text, @@ -261,11 +256,13 @@ self.collect_from_current = function() if secondary_dialogs.is_empty() then secondary_dialogs.insert(Subtitle:now('secondary')) end + local start_time = self.get_timing('start') + local end_time = self.get_timing('end') return Subtitle:new { ['text'] = dialogs.get_text(), - ['secondary'] = secondary_dialogs.get_text(), - ['start'] = self.get_timing('start'), - ['end'] = self.get_timing('end'), + ['secondary'] = secondary_dialogs.get_overlapping_text(start_time, end_time), + ['start'] = start_time, + ['end'] = end_time, } end diff --git a/mpvacious/subtitles/sub_list.lua b/mpvacious/subtitles/sub_list.lua index 067c526..4f60f03 100644 --- a/mpvacious/subtitles/sub_list.lua +++ b/mpvacious/subtitles/sub_list.lua @@ -14,31 +14,74 @@ local MAX_SUB_GAP_SECONDS = 20 -- stop joining lines separated by a longer gap local new_sub_list = function() local subs_list = {} + local append_text = function(speech, previous_sub, sub) + local lines = {} + local text = sub['text']:gsub('\r\n', '\n'):gsub('\r', '\n') + for line in (text .. '\n'):gmatch('(.-)\n') do + table.insert(lines, line) + end + + local overlap = previous_sub and sub['start'] <= previous_sub['end'] and math.min(#speech, #lines) or 0 + while overlap > 0 do + local matches = true + for i = 1, overlap do + if speech[#speech - overlap + i] ~= lines[i] then + matches = false + break + end + end + if matches then + break + end + overlap = overlap - 1 + end + for i = overlap + 1, #lines do + table.insert(speech, lines[i]) + end + end + local get_time = function(position) local i = position == 'start' and 1 or #subs_list return subs_list[i][position] end local get_text = function() local speech = {} + local previous_sub = nil for _, sub in ipairs(subs_list) do - table.insert(speech, sub['text']) + append_text(speech, previous_sub, sub) + previous_sub = sub end return table.concat(speech, CONCAT_CHR) end local get_n_text = function(sub, n_lines) local speech = {} local end_sub = sub + local previous_sub = nil + local n_subs = 0 for _, v in ipairs(subs_list) do if v['start'] - end_sub['end'] >= MAX_SUB_GAP_SECONDS then break end - if v >= sub and #speech < n_lines then - table.insert(speech, v['text']) + if v >= sub and n_subs < n_lines then + append_text(speech, previous_sub, v) + previous_sub = v end_sub = v + n_subs = n_subs + 1 end end return table.concat(speech, CONCAT_CHR), end_sub end + local get_overlapping_text = function(start_time, end_time) + local speech = {} + local previous_sub = nil + for _, sub in ipairs(subs_list) do + if sub['start'] < end_time and sub['end'] > start_time then + append_text(speech, previous_sub, sub) + previous_sub = sub + end + end + return table.concat(speech, CONCAT_CHR):gsub('%s+', ' '):match('^%s*(.-)%s*$') + end local insert = function(sub) if sub == nil or h.is_empty(sub.text) then return false @@ -71,6 +114,7 @@ local new_sub_list = function() get_time = get_time, get_text = get_text, get_n_text = get_n_text, + get_overlapping_text = get_overlapping_text, insert = insert, is_empty = function() return h.is_empty(subs_list) @@ -208,6 +252,31 @@ local function test_get_subs_list_returns_array_copy() h.assert_equals(subs.get_subs_list()[1]['text'], "First line") end +local function test_text_collection_removes_only_consecutive_line_overlap() + local growing = new_sub_list() + local first = Subtitle:from_text("First line", 0, 1) + growing.insert(first) + growing.insert(Subtitle:from_text("First line\nSecond line", 1, 2)) + h.assert_equals(growing.get_text(), "First line\nSecond line") + h.assert_equals(growing.get_n_text(first, 2), "First line\nSecond line") + + local subs = new_sub_list() + subs.insert(Subtitle:from_text("Yes", 0, 1)) + subs.insert(Subtitle:from_text("No", 1, 2)) + subs.insert(Subtitle:from_text("Yes\nAgain", 2, 3)) + h.assert_equals(subs.get_text(), "Yes\nNo\nYes\nAgain") + h.assert_equals(subs.get_n_text(subs.get_subs_list()[1], 3), "Yes\nNo\nYes\nAgain") +end + +local function test_get_overlapping_text_uses_timing_and_removes_line_overlap() + local subs = new_sub_list() + subs.insert(Subtitle:from_text("Before", 0, 1)) + subs.insert(Subtitle:from_text("First line", 1, 2)) + subs.insert(Subtitle:from_text("First line\nSecond line", 2, 3)) + subs.insert(Subtitle:from_text("After", 3, 4)) + h.assert_equals(subs.get_overlapping_text(1, 3), "First line Second line") +end + local function run_tests() test_insert_rejects_invalid_subs() test_insert_rejects_duplicate_event() @@ -220,6 +289,8 @@ local function run_tests() test_insert_preserves_sorted_order() test_get_time_returns_boundary_times() test_get_subs_list_returns_array_copy() + test_text_collection_removes_only_consecutive_line_overlap() + test_get_overlapping_text_uses_timing_and_removes_line_overlap() end return { From 4b95adb27b45fe47ba6053e761634c936d42963b Mon Sep 17 00:00:00 2001 From: kuator Date: Sun, 9 Aug 2026 01:02:13 +0500 Subject: [PATCH 2/2] fix: include future overlapping secondary cues --- mpvacious/main.lua | 3 + mpvacious/subtitles/full_track.lua | 352 +++++++++++++++++++++++++++++ mpvacious/subtitles/observer.lua | 61 ++++- tests/run.lua | 14 ++ 4 files changed, 428 insertions(+), 2 deletions(-) create mode 100644 mpvacious/subtitles/full_track.lua diff --git a/mpvacious/main.lua b/mpvacious/main.lua index 23efc09..943adf5 100644 --- a/mpvacious/main.lua +++ b/mpvacious/main.lua @@ -57,6 +57,7 @@ local make_new_note_checker = require('anki.new_note_checker') local make_note_exporter = require('anki.note_exporter') local Subtitle = require('subtitles.subtitle') local sub_list = require('subtitles.sub_list') +local full_track = require('subtitles.full_track') local make_release_checker = require('utils.release_checker') local quick_creation_opts = { @@ -572,6 +573,8 @@ local function run_tests() cfg_utils.run_tests() Subtitle.run_tests() sub_list.run_tests() + full_track.run_tests() + subs_observer.run_tests() make_note_exporter.run_tests(note_exporter) end diff --git a/mpvacious/subtitles/full_track.lua b/mpvacious/subtitles/full_track.lua new file mode 100644 index 0000000..4bd4b4e --- /dev/null +++ b/mpvacious/subtitles/full_track.lua @@ -0,0 +1,352 @@ +--[[ +Copyright: Ajatt-Tools and contributors; https://github.com/Ajatt-Tools +License: GNU GPL, version 3 or later; http://www.gnu.org/licenses/gpl.html + +Loads every cue from the active secondary subtitle track. +]] + +local h = require('helpers') +local exec = require('encoder.executables') +local Subtitle = require('subtitles.subtitle') +local sub_list = require('subtitles.sub_list') +local msg = require('mp.msg') + +local self = {} + +local function parse_time(value) + local hours, minutes, seconds, fraction = value:match('^(%d+):(%d+):(%d+)[,.](%d+)$') + if not hours then + return nil + end + return tonumber(hours) * 3600 + tonumber(minutes) * 60 + tonumber(seconds) + + tonumber(fraction) / (10 ^ #fraction) +end + +function self.parse_srt(text) + local subs = sub_list.new() + text = (text or ''):gsub('\r\n', '\n'):gsub('\r', '\n') + + for block in (text .. '\n\n'):gmatch('(.-)\n\n+') do + local lines = {} + for line in block:gmatch('[^\n]+') do + table.insert(lines, line) + end + + local timing_index, start_time, end_time + for index, line in ipairs(lines) do + local start_value, end_value = line:match('^%s*(%d+:%d+:%d+[,.]%d+)%s+%-%->%s+(%d+:%d+:%d+[,.]%d+)') + if start_value then + timing_index = index + start_time = parse_time(start_value) + end_time = parse_time(end_value) + break + end + end + + if timing_index and start_time and end_time then + local cue_lines = {} + for index = timing_index + 1, #lines do + table.insert(cue_lines, (lines[index]:gsub('<[^>]*>', ''))) + end + subs.insert(Subtitle:from_text(table.concat(cue_lines, '\n'), start_time, end_time)) + end + end + + return subs +end + +local function split_ass_fields(value, count) + local fields = {} + local start_index = 1 + for index = 1, count - 1 do + local comma_index = value:find(',', start_index, true) + if not comma_index then + return nil + end + fields[index] = h.trim(value:sub(start_index, comma_index - 1)) + start_index = comma_index + 1 + end + fields[count] = h.trim(value:sub(start_index)) + return fields +end + +function self.parse_ass(text) + local subs = sub_list.new() + local format + local in_events = false + text = (text or ''):gsub('^\239\187\191', ''):gsub('\r\n', '\n'):gsub('\r', '\n') + + for line in text:gmatch('[^\n]+') do + local section = line:match('^%s*(%b[])') + if section then + in_events = section:lower() == '[events]' + elseif in_events then + local name, value = line:match('^%s*([^:]+):%s*(.*)$') + name = name and name:lower() + if name == 'format' then + format = {} + for field in value:gmatch('[^,]+') do + table.insert(format, h.trim(field):lower()) + end + elseif name == 'dialogue' and format then + local fields = split_ass_fields(value, #format) + local event = {} + for index, field_name in ipairs(format) do + event[field_name] = fields and fields[index] + end + local start_time = event.start and parse_time(event.start) + local end_time = event['end'] and parse_time(event['end']) + if start_time and end_time and event.text then + local cue_text = event.text:gsub('{[^}]*}', '') + :gsub('\\[Nn]', '\n') + :gsub('\\h', ' ') + subs.insert(Subtitle:from_text(cue_text, start_time, end_time)) + end + end + end + end + + return subs +end + +local function read_file(path) + local file = io.open(path, 'rb') + if not file then + return nil + end + local contents = file:read('*a') + file:close() + return contents +end + +function self.new(run_subprocess, read_subtitle_file) + run_subprocess = run_subprocess or h.subprocess + read_subtitle_file = read_subtitle_file or read_file + local active_key + local generation = 0 + local loaded_subs + + local function refresh(track_list, media_path) + local secondary_track + for _, track in ipairs(track_list or {}) do + if track.type == 'sub' and track['main-selection'] == 1 then + secondary_track = track + break + end + end + + local input = secondary_track and (secondary_track['external-filename'] or media_path) + local ff_index = secondary_track and secondary_track['ff-index'] + local new_key = input and ff_index and (input .. '\0' .. ff_index) or nil + if new_key == active_key then + return + end + + active_key = new_key + generation = generation + 1 + loaded_subs = nil + if not new_key then + return + end + + local external_filename = secondary_track['external-filename'] + if external_filename then + local extension = external_filename:lower():match('%.([^./]+)$') + local parser = extension == 'srt' and self.parse_srt + or (extension == 'ass' or extension == 'ssa') and self.parse_ass + local contents = parser and read_subtitle_file(external_filename) + if contents then + loaded_subs = parser(contents) + else + msg.warn('Could not read the complete external secondary subtitle track; using observed cues.') + end + return + end + + local request_generation = generation + run_subprocess { + args = { + exec.ffmpeg, '-v', 'error', '-nostdin', '-i', input, + '-map', '0:' .. ff_index, '-f', 'srt', '-', + }, + suppress_log = true, + completion_fn = function(success, result, error) + if request_generation ~= generation then + return + end + if success == true and error == nil and result and result.status == 0 then + loaded_subs = self.parse_srt(result.stdout) + else + msg.warn('Could not load the complete secondary subtitle track; using observed cues.') + end + end, + } + end + + local function get_overlapping_text(start_time, end_time, delay) + if not loaded_subs then + return nil + end + delay = delay or 0 + return loaded_subs.get_overlapping_text(start_time - delay, end_time - delay) + end + + return { + refresh = refresh, + get_overlapping_text = get_overlapping_text, + } +end + +local function test_parse_srt_returns_timed_plain_text() + local subs = self.parse_srt([[ +1 +00:00:01,000 --> 00:00:02,500 +First line + +2 +00:00:02.750 --> 00:00:05,000 +Second line +]]) + h.assert_equals(subs.get_overlapping_text(1, 5), 'First line Second line') +end + +local function test_parse_ass_returns_timed_plain_text() + local subs = self.parse_ass([[ +[Script Info] +Title: Test + +[Events] +Format: Layer, Start, End, Style, Text +Dialogue: 0,0:00:01.00,0:00:02.50,Default,{\i1}First line{\i0} +Dialogue: 0,0:00:02.75,0:00:05.00,Default,Second\Nline +]]) + h.assert_equals(subs.get_overlapping_text(1, 5), 'First line Second line') +end + +local function test_cache_extracts_selected_secondary_track() + local request + local cache = self.new(function(options) + request = options + end) + cache.refresh({ + { type = 'sub', ['main-selection'] = 0, ['ff-index'] = 2 }, + { type = 'sub', ['main-selection'] = 1, ['ff-index'] = 3 }, + }, '/video.mkv') + + h.assert_equals(cache.get_overlapping_text(1, 5, 0), nil) + table.remove(request.args, 1) -- executable path is platform-specific + h.assert_equals(request.args, { + '-v', 'error', '-nostdin', '-i', '/video.mkv', + '-map', '0:3', '-f', 'srt', '-', + }) + + request.completion_fn(true, { + status = 0, + stdout = '1\n00:00:01,000 --> 00:00:05,000\nFuture line\n', + stderr = '', + }, nil) + h.assert_equals(cache.get_overlapping_text(1, 5, 0), 'Future line') +end + +local function test_cache_reads_external_text_subtitles_without_subprocess() + local cases = { + { + path = '/subtitles/english.ass', + contents = [[ +[Events] +Format: Layer, Start, End, Style, Text +Dialogue: 0,0:00:01.00,0:00:05.00,Default,Future line +]], + }, + { + path = '/subtitles/english.srt', + contents = '1\n00:00:01,000 --> 00:00:05,000\nFuture line\n', + }, + } + + for _, case in ipairs(cases) do + local subprocess_called = false + local cache = self.new(function() + subprocess_called = true + end, function(path) + h.assert_equals(path, case.path) + return case.contents + end) + cache.refresh({ + { + type = 'sub', + ['main-selection'] = 1, + ['ff-index'] = 0, + ['external-filename'] = case.path, + }, + }, '/video.mkv') + + h.assert_equals(cache.get_overlapping_text(1, 5, 0), 'Future line') + h.assert_equals(subprocess_called, false) + end +end + +local function test_cache_applies_delay() + local request + local cache = self.new(function(options) + request = options + end) + cache.refresh({ + { + type = 'sub', + ['main-selection'] = 1, + ['ff-index'] = 0, + }, + }, '/video.mkv') + + h.assert_equals(request.args[6], '/video.mkv') + request.completion_fn(true, { + status = 0, + stdout = '1\n00:00:01,000 --> 00:00:02,000\nDelayed line\n', + stderr = '', + }, nil) + h.assert_equals(cache.get_overlapping_text(2, 3, 1), 'Delayed line') +end + +local function test_cache_ignores_results_from_previous_track() + local requests = {} + local cache = self.new(function(options) + table.insert(requests, options) + end) + local function track() + return { + { + type = 'sub', + ['main-selection'] = 1, + ['ff-index'] = 0, + }, + } + end + + cache.refresh(track(), '/first.mkv') + cache.refresh(track(), '/second.mkv') + requests[1].completion_fn(true, { + status = 0, + stdout = '1\n00:00:01,000 --> 00:00:02,000\nStale line\n', + stderr = '', + }, nil) + h.assert_equals(cache.get_overlapping_text(1, 2, 0), nil) + + requests[2].completion_fn(true, { + status = 0, + stdout = '1\n00:00:01,000 --> 00:00:02,000\nCurrent line\n', + stderr = '', + }, nil) + h.assert_equals(cache.get_overlapping_text(1, 2, 0), 'Current line') +end + +function self.run_tests() + test_parse_srt_returns_timed_plain_text() + test_parse_ass_returns_timed_plain_text() + test_cache_extracts_selected_secondary_track() + test_cache_reads_external_text_subtitles_without_subprocess() + test_cache_applies_delay() + test_cache_ignores_results_from_previous_track() +end + +return self diff --git a/mpvacious/subtitles/observer.lua b/mpvacious/subtitles/observer.lua index 5171484..4ebffbe 100644 --- a/mpvacious/subtitles/observer.lua +++ b/mpvacious/subtitles/observer.lua @@ -8,6 +8,7 @@ Observer waits for subtitles to appear on the screen and adds them to a list. local h = require('helpers') local timings = require('utils.timings') local sub_list = require('subtitles.sub_list') +local full_track = require('subtitles.full_track') local Subtitle = require('subtitles.subtitle') local mp = require('mp') local platform = require('platform.init') @@ -20,11 +21,13 @@ local dialogs = sub_list.new() local secondary_dialogs = sub_list.new() local all_dialogs = sub_list.new() local all_secondary_dialogs = sub_list.new() +local full_secondary_track = full_track.new() local user_timings = timings.new() local autoclip_method = new_autoclip_method_selector.new() local append_dialogue = false local autoclip_enabled = false +local select_secondary_text ------------------------------------------------------------ @@ -174,6 +177,18 @@ local function apply_custom_trim(text) return h.trim(text) end +select_secondary_text = function(cache, observed, start_time, end_time, delay) + local text = cache.get_overlapping_text(start_time, end_time, delay) + if text ~= nil then + return text + end + return observed.get_overlapping_text(start_time, end_time) +end + +local function get_subtitle_delay() + return mp.get_property_native('sub-delay') - mp.get_property_native('audio-delay') +end + ------------------------------------------------------------ -- public @@ -238,7 +253,13 @@ self.collect_from_all_dialogues = function(n_lines) return Subtitle:new() -- return a default empty new Subtitle to let consumer handle end local text, end_sub = all_dialogs.get_n_text(current_sub, n_lines) - local secondary_text = all_secondary_dialogs.get_overlapping_text(current_sub['start'], end_sub['end']) + local secondary_text = select_secondary_text( + full_secondary_track, + all_secondary_dialogs, + current_sub['start'], + end_sub['end'], + get_subtitle_delay() + ) return Subtitle:new { ['text'] = text, ['secondary'] = secondary_text, @@ -260,7 +281,13 @@ self.collect_from_current = function() local end_time = self.get_timing('end') return Subtitle:new { ['text'] = dialogs.get_text(), - ['secondary'] = secondary_dialogs.get_overlapping_text(start_time, end_time), + ['secondary'] = select_secondary_text( + full_secondary_track, + secondary_dialogs, + start_time, + end_time, + get_subtitle_delay() + ), ['start'] = start_time, ['end'] = end_time, } @@ -384,6 +411,33 @@ self.has_recorded_dialogs = function() return not dialogs.is_empty() end +local function test_full_track_precedes_observed_secondary_cues() + local observed = sub_list.new() + observed.insert(Subtitle:from_text('Current line', 1, 2)) + local cache = { + get_overlapping_text = function() + return 'Current line Future line' + end, + } + h.assert_equals(select_secondary_text(cache, observed, 1, 5, 0), 'Current line Future line') +end + +local function test_observed_secondary_cues_are_the_fallback() + local observed = sub_list.new() + observed.insert(Subtitle:from_text('Current line', 1, 2)) + local cache = { + get_overlapping_text = function() + return nil + end, + } + h.assert_equals(select_secondary_text(cache, observed, 1, 5, 0), 'Current line') +end + +function self.run_tests() + test_full_track_precedes_observed_secondary_cues() + test_observed_secondary_cues_are_the_fallback() +end + self.init = function(menu, cfg_mgr) cfg_mgr.fail_if_not_ready() self.menu = menu @@ -404,6 +458,9 @@ self.init = function(menu, cfg_mgr) mp.observe_property("sub-text", "string", handle_primary_sub) mp.observe_property("secondary-sub-text", "string", handle_secondary_sub) + mp.observe_property('track-list', 'native', function(_, track_list) + full_secondary_track.refresh(track_list, mp.get_property('path')) + end) end return self diff --git a/tests/run.lua b/tests/run.lua index 838b6c8..4d74964 100644 --- a/tests/run.lua +++ b/tests/run.lua @@ -67,4 +67,18 @@ print("subtitle list tests passed.") ------------------------------------------------------------ +print("Running full subtitle track tests...") +local full_track = require('subtitles.full_track') +full_track.run_tests() +print("full subtitle track tests passed.") + +------------------------------------------------------------ + +print("Running subtitle observer tests...") +local observer = require('subtitles.observer') +observer.run_tests() +print("subtitle observer tests passed.") + +------------------------------------------------------------ + print("ALL TESTS PASSED")