--------- Current Status ------- -- Outdated with the appearance of the mpv2anki add-on - https://ankiweb.net/shared/info/1213145732 -------------------------------- --------- Installation --------- -- 1. Install Anki (https://apps.ankiweb.net) & mpv (https://mpv.io) video player (or IINA for macOS). -- 2. Install FFmpeg. -- FFmpeg for Windows can be downloaded from http://ffmpeg.zeranoe.com -- FFmpeg for macOS can be installed using brew (https://brew.sh): brew install ffmpeg --with-libvpx --with-opus -- 3. Install Python 2.7 (it's probably already installed in macOS & Linux) and 'requests' module using pip: pip install requests -- 3. Install Anki Connect add-on - https://ankiweb.net/shared/info/2055492159 -- 4. Import subs2srs-sample.apkg in Anki. -- 5. Copy this file (subs2srs.lua) and subs2srs.py in the 'scripts' subdirectory of the mpv configuration directory. -- Default path: -- - Linux & macOS: ~/.config/mpv/scripts -- - Windows: {mpv installation folder}/scripts (create it if it doesn't exist) -- 6. And update at least the following settings: -- LUA_SCRIPTS_DIRECTORY -- COLLECTION_MEDIA_DIR -- For Windows also update these settings if python.exe and ffmpeg.exe aren't in PATH environment variable: -- FFMPEG_PATH -- PYTHON_PATH -------------------------------- ------------ Usage ------------- -- 1. Open Anki. -- 2. Open any video file with .srt subtitles alongside it in mpv video player. -- 3. Navigate to the scene with subtitles. -- 4. Press 'b' to add new card in Anki. -------------------------------- -------- Sample Deck ----------- -- It contains two note types (subs2srs and subs2srs-video) and 3 types of cards (audio, cloze, video). -- Audio & Video card template is from substudy (http://www.randomhacks.net/substudy), except there's no subtitles in the native language. -- Note: subs2srs-video note type requires Anki Beta, NOTE_TYPE updated, EXPORT_VIDEO to be set to true. -- By default video tag in subs2srs-video card template doesn't contain controls attribute and the video can be replayed only by -- clicking inside the card (setting focus) and presssing Shift + R or Ctrl + R. -- That's why it's better to install "Refocus Card when Reviewing" Anki add-on, but Anki Beta won't install it from AnkiWeb. -- So download it using a stable version of Anki or using this link - https://gist.github.com/kelciour/a3329225d2f7fce48bf24af92af5f4fe -- and install it using this manual - https://apps.ankiweb.net/docs/addons21.html#single-.py-add-ons-need-their-own-folder -------------------------------- ------- Default Hotkeys -------- -- b - add new card (using current subtitle timing or start & end time if they are set) -- w - set start time (and automatically set end time if it's not set) -- e - set end time (and automatically set start time if it's not set) -- Ctrl + w - preview the card (and set start & end time if it's necessary) -- Ctrl + e - replay the last n seconds of the card (and set start & end time if it's necessary) -- Ctrl + b - reset start & end time to the current subtitle timing -- To change default script hotkeys update the bottom of this script. -- Some of mpv default keybindings: -- Left and Right - seek backward/forward 5 seconds. -- Shift + Left and Shift + Right - seek backward/forward 1 second. -- , and . - step backward/forward 1 frame (probably works only if current keyboard layout is English). -- For more info: https://mpv.io/manual/master/#keyboard-control -------------------------------- ------- Script Settings -------- -- Subtitles in the target language. SRT_FILE_EXTENSIONS = {".srt", ".en.srt", ".eng.srt"} -- Path to the mpv 'scripts' folder. Default: ~/.config/mpv/scripts LUA_SCRIPTS_DIRECTORY = [[C:\Programs\mpv\scripts]] -- Path to the subs2srs.py. PYTHON_HELPER_PATH = LUA_SCRIPTS_DIRECTORY .. "/" .. "subs2srs.py" -- Pad the start and end times when generating an audio clip. -- For example, setting the padding to 0.25 means that the audio clip -- will start 250 milliseconds sooner and will end 250 milliseconds later than it would normally. AUDIO_CLIP_PADDING = 1.25 -- Fade the start and the end of the audio when generating an audio clip. AUDIO_CLIP_FADE = 0.2 -- It's better to set it false in case of non-latin subtitles. JOIN_SUBTITLES_INTO_SENTENCES = true -- Replay the last n seconds of the anki card. REPLAY_TIME = 1.5 -------------------------------- -------- Anki Settings --------- -- Path to the collection.media folder. How to find it - https://apps.ankiweb.net/docs/manual.html#files COLLECTION_MEDIA_DIR = [[C:\Users\Nickolay\AppData\Roaming\Anki2\subs2srs\collection.media]] -- Name of the note type that contains fields Sound, Time, Source, Image, Target: line, Target: line before, Target: line after. NOTE_TYPE = [[subs2srs]] -- Name of the deck where cards will be added. If the deck doesn't exist it will be created. DEFAULT_DECK = [[subs2srs]] -- If this option is true then cards will be added in the subdeck with the same name as the name of the video. CREATE_SUBDECKS_FOR_CARDS = true -------------------------------- -------- Image Settings -------- -- Width and Height of the generated snapshots (in pixels). -- To keep the aspect ratio specify only one component, either width or height, and set the other component to -1. -- If both width and height set to -1 the generated snapshots won't be resized. IMAGE_WIDTH = 400 IMAGE_HEIGHT = -1 -- Save the video image with subtitles (if they're visible), in its original resolution, -- i.e. options IMAGE_WIDTH and IMAGE_HEIGHT won't be applied. IMAGE_WITH_SUBTITLES = false -------------------------------- -------- Video Settings -------- VIDEO_WIDTH = 480 VIDEO_HEIGHT = -2 VIDEO_FORMAT_HTML5 = true EXPORT_VIDEO = false -------------------------------- -------- Other Settings -------- -- For Windows either update PATH environment variable or replace with -- the absolute path to the ffmpeg.exe and python.exe, for example, [[C:\Programs\ffmpeg\bin\ffmpeg.exe]] FFMPEG_PATH = [[ffmpeg]] PYTHON_PATH = [[python]] -------------------------------- utils = require 'mp.utils' function srt_time_to_seconds(time) major, minor = time:match("(%d%d:%d%d:%d%d),(%d%d%d)") hours, mins, secs = major:match("(%d%d):(%d%d):(%d%d)") return hours * 3600 + mins * 60 + secs + minor / 1000 end function seconds_to_time(time, delimiter) hours = math.floor(time / 3600) mins = math.floor(time / 60) % 60 secs = math.floor(time % 60) milliseconds = (time * 1000) % 1000 return string.format("%02d:%02d:%02d%s%03d", hours, mins, secs, delimiter, milliseconds) end function seconds_to_ffmpeg_time(time) return seconds_to_time(time, '.') end function set_start_timestamp() start_timestamp = mp.get_property_number("time-pos") + AUDIO_CLIP_PADDING if end_timestamp == nil then local sub_start, sub_end = get_current_sub_timing() end_timestamp = sub_end + AUDIO_CLIP_PADDING end mp.osd_message("Start: " .. seconds_to_ffmpeg_time(start_timestamp)) end function set_end_timestamp() end_timestamp = mp.get_property_number("time-pos") - AUDIO_CLIP_PADDING if start_timestamp == nil then local sub_start, sub_end = get_current_sub_timing() start_timestamp = sub_start - AUDIO_CLIP_PADDING end mp.osd_message("End: " .. seconds_to_ffmpeg_time(end_timestamp)) end function get_current_sub_timing() local time_pos = mp.get_property_number("time-pos") local sub_start, sub_end for i, sub_text in ipairs(sentences) do sub_start = sentences_start[i] sub_end = sentences_end[i] if sub_start <= time_pos and time_pos <= sub_end then break end if sentences_start[i] > time_pos then break end end return sub_start, sub_end end function set_start_end_timestamps() local time_pos = mp.get_property_number("time-pos") for i, sub_text in ipairs(sentences) do start_timestamp = sentences_start[i] end_timestamp = sentences_end[i] if i < #sentences and sentences_start[i+1] > time_pos then break end end mp.osd_message(string.format("%s - %s", seconds_to_ffmpeg_time(start_timestamp), seconds_to_ffmpeg_time(end_timestamp))) end function pause_player() mp.set_property("pause", "yes") end function replay_anki_card() if start_timestamp == nil and end_timestamp == nil then local subtitles_fragment, subtitles_fragment_start_time, subtitles_fragment_end_time, subtitles_fragment_prev, subtitles_fragment_next = get_subtitles_fragment(sentences, sentences_start, sentences_end) if subtitles_fragment == "" then mp.osd_message("no text") return else start_timestamp = subtitles_fragment_start_time end_timestamp = subtitles_fragment_end_time end end mp.commandv("seek", start_timestamp - AUDIO_CLIP_PADDING, "absolute+exact") mp.set_property("pause", "no") player_state = "replay from the start" end function replay_the_last_seconds_of_anki_card() if start_timestamp == nil and end_timestamp == nil then local subtitles_fragment, subtitles_fragment_start_time, subtitles_fragment_end_time, subtitles_fragment_prev, subtitles_fragment_next = get_subtitles_fragment(sentences, sentences_start, sentences_end) if subtitles_fragment == "" then mp.osd_message("no text") return else start_timestamp = subtitles_fragment_start_time end_timestamp = subtitles_fragment_end_time end end mp.commandv("seek", end_timestamp - REPLAY_TIME, "absolute+exact") mp.set_property("pause", "no") player_state = "replay from the end" end function on_playback_restart() if timer ~= nil then timer:kill() end if player_state == "replay from the end" then timer = mp.add_timeout(REPLAY_TIME + AUDIO_CLIP_PADDING, pause_player) player_state = nil elseif player_state == "replay from the start" then timer = mp.add_timeout(end_timestamp - start_timestamp + 2 * AUDIO_CLIP_PADDING, pause_player) player_state = nil end end function open_subtitles_file(srt_file_exts) local srt_filename = mp.get_property("working-directory") .. "/" .. mp.get_property("filename/no-ext") for i, ext in ipairs(srt_file_exts) do local f, err = io.open(srt_filename .. ext, "r") if f then return f end end return false end function read_subtitles(srt_file_exts) local f = open_subtitles_file(srt_file_exts) if not f then return false end local data = f:read("*all") data = string.gsub(data, "\r\n", "\n") f:close() subs = {} subs_start = {} subs_end = {} for start_time, end_time, text in string.gmatch(data, "(%d%d:%d%d:%d%d,%d%d%d) %-%-> (%d%d:%d%d:%d%d,%d%d%d)\n(.-)\n\n") do table.insert(subs, text) table.insert(subs_start, srt_time_to_seconds(start_time)) table.insert(subs_end, srt_time_to_seconds(end_time)) end return true end function convert_into_sentences() sentences = {} sentences_start = {} sentences_end = {} for i, sub_text in ipairs(subs) do sub_start = subs_start[i] sub_end = subs_end[i] sub_text = string.gsub(sub_text, "<[^>]+>", "") if sub_text:find("^- ") ~= nil and sub_text:sub(3,3) ~= sub_text:sub(3,3):upper() then sub_text = string.gsub(sub_text, "^- ", "") end if #sentences > 0 then prev_sub_start = sentences_start[#sentences] prev_sub_end = sentences_end[#sentences] prev_sub_text = sentences[#sentences] if JOIN_SUBTITLES_INTO_SENTENCES and (sub_start - prev_sub_end) <= 2 and sub_text:sub(1,1) ~= '-' and sub_text:sub(1,1) ~= '"' and sub_text:sub(1,1) ~= "'" and sub_text:sub(1,1) ~= '(' and (prev_sub_text:sub(prev_sub_text:len()) ~= "." or prev_sub_text:sub(prev_sub_text:len()-2) == "...") and prev_sub_text:sub(prev_sub_text:len()) ~= "?" and prev_sub_text:sub(prev_sub_text:len()) ~= "!" and (sub_text:sub(1,1) == sub_text:sub(1,1):lower() or prev_sub_text:sub(prev_sub_text:len()) == ",") then local text = prev_sub_text .. " " .. sub_text text = string.gsub(text, "\n", "#") text = string.gsub(text, "%.%.%. %.%.%.", " ") text = string.gsub(text, "#%-", "\n-") text = string.gsub(text, "#", " ") if text:match("\n%-") ~= nil and text:match("^%-") == nil then text = "- " .. text end sentences[#sentences] = text sentences_end[#sentences] = sub_end else table.insert(sentences, sub_text) table.insert(sentences_start, sub_start) table.insert(sentences_end, sub_end) end else table.insert(sentences, sub_text) table.insert(sentences_start, sub_start) table.insert(sentences_end, sub_end) end end end function export_video_fragment(start_timestamp, end_timestamp) if EXPORT_VIDEO == false then return "" end local video_input = mp.get_property("working-directory") .. "/" .. mp.get_property("filename") local video_filename = mp.get_property("filename/no-ext") local ff_aid = 0 local tracks_count = mp.get_property_number("track-list/count") for i = 1, tracks_count do local track_type = mp.get_property(string.format("track-list/%d/type", i)) local track_index = mp.get_property_number(string.format("track-list/%d/ff-index", i)) local track_selected = mp.get_property(string.format("track-list/%d/selected", i)) if track_type == "audio" and track_selected == "yes" then ff_aid = track_index - 1 break end end start_timestamp = start_timestamp - AUDIO_CLIP_PADDING end_timestamp = end_timestamp + AUDIO_CLIP_PADDING d = AUDIO_CLIP_FADE t = end_timestamp - start_timestamp local clip_filename = table.concat{ video_filename, "_", string.format("%.3f", start_timestamp), "-", string.format("%.3f", end_timestamp) } local clip_output, args if VIDEO_FORMAT_HTML5 ~= true then clip_filename = clip_filename .. ".mp4" clip_output = COLLECTION_MEDIA_DIR .. "/" .. clip_filename args = { FFMPEG_PATH, "-y", "-ss", start_timestamp, "-i", video_input, "-t", string.format("%.3f", t), "-map", "0:v:0", "-map", string.format("0:a:%d", ff_aid), "-af", string.format("afade=t=in:curve=ipar:st=%.3f:d=%.3f,afade=t=out:curve=ipar:st=%.3f:d=%.3f", 0, d, t - d, d), "-c:v", "libx264", "-preset", "medium", "-c:a", "aac", "-ac", "2", clip_output } else clip_filename = clip_filename .. ".webm" clip_output = COLLECTION_MEDIA_DIR .. "/" .. clip_filename args = { FFMPEG_PATH, "-y", "-ss", start_timestamp, "-i", video_input, "-t", string.format("%.3f", t), "-map", "0:v:0", "-map", string.format("0:a:%d", ff_aid), "-af", string.format("afade=t=in:curve=ipar:st=%.3f:d=%.3f,afade=t=out:curve=ipar:st=%.3f:d=%.3f", 0, d, t - d, d), "-c:v", "libvpx-vp9", "-b:v", "1400K", "-threads", "8", "-speed", "2", "-crf", "23", "-c:a", "libopus", "-b:a", "64k", "-ac", "2", clip_output } end if VIDEO_WIDTH == -1 then VIDEO_WIDTH = -2 end if VIDEO_HEIGHT == -1 then VIDEO_HEIGHT = -2 end if VIDEO_WIDTH ~= -2 or VIDEO_HEIGHT ~= -2 then table.insert(args, #args, "-vf") table.insert(args, #args, string.format('scale=%d:%d', VIDEO_WIDTH, VIDEO_HEIGHT)) end utils.subprocess_detached({ args = args }) return clip_filename end function export_audio_fragment(start_timestamp, end_timestamp) local video_input = mp.get_property("working-directory") .. "/" .. mp.get_property("filename") local video_filename = mp.get_property("filename/no-ext") local ff_aid = 0 local tracks_count = mp.get_property_number("track-list/count") for i = 1, tracks_count do local track_type = mp.get_property(string.format("track-list/%d/type", i)) local track_index = mp.get_property_number(string.format("track-list/%d/ff-index", i)) local track_selected = mp.get_property(string.format("track-list/%d/selected", i)) if track_type == "audio" and track_selected == "yes" then ff_aid = track_index - 1 break end end start_timestamp = start_timestamp - AUDIO_CLIP_PADDING end_timestamp = end_timestamp + AUDIO_CLIP_PADDING d = AUDIO_CLIP_FADE t = end_timestamp - start_timestamp local audio_filename = table.concat{ video_filename, "_", string.format("%.3f", start_timestamp), "-", string.format("%.3f", end_timestamp), ".mp3" } local audio_output = COLLECTION_MEDIA_DIR .. "/" .. audio_filename local args = { FFMPEG_PATH, "-y", "-ss", start_timestamp, "-i", video_input, "-t", string.format("%.3f", t), "-map", string.format("0:a:%d", ff_aid), "-af", string.format("afade=t=in:curve=ipar:st=%.3f:d=%.3f,afade=t=out:curve=ipar:st=%.3f:d=%.3f", 0, d, t - d, d), audio_output } utils.subprocess_detached({ args = args }) return audio_filename end function export_screenshot() local time_pos = mp.get_property_number("time-pos") local video_input = mp.get_property("working-directory") .. "/" .. mp.get_property("filename") local image_filename = string.format("%s_%.3f.jpg", mp.get_property("filename/no-ext"), time_pos) local image_output = COLLECTION_MEDIA_DIR .. "/" .. image_filename if IMAGE_WITH_SUBTITLES then mp.commandv("screenshot-to-file", image_output) else local args = {FFMPEG_PATH, '-y', '-ss', seconds_to_ffmpeg_time(time_pos), '-i', video_input, "-vframes", "1", "-q:v", "2", image_output} if IMAGE_WIDTH ~= -1 or IMAGE_HEIGHT ~= -1 then table.insert(args, #args, "-vf") table.insert(args, #args, string.format('scale=%d:%d', IMAGE_WIDTH, IMAGE_HEIGHT)) end utils.subprocess_detached({args = args}) end return image_filename end function get_subtitles_fragment(subtitles, subtitles_start, subtitles_end) local time_pos = mp.get_property_number("time-pos") local subtitles_fragment = {} local subtitles_fragment_start_time = nil local subtitles_fragment_end_time = nil local subtitles_fragment_prev = " " local subtitles_fragment_next = " " if start_timestamp ~= nil then start_timestamp = start_timestamp - AUDIO_CLIP_PADDING end_timestamp = end_timestamp + AUDIO_CLIP_PADDING end for i, sub_text in ipairs(subtitles) do local sub_start = subtitles_start[i] local sub_end = subtitles_end[i] if (start_timestamp ~= nil and (start_timestamp <= sub_start and sub_end <= end_timestamp)) or (start_timestamp == nil and (sub_start <= time_pos and time_pos <= sub_end)) then table.insert(subtitles_fragment, sub_text) if subtitles_fragment_start_time == nil then subtitles_fragment_start_time = sub_start if i ~= 1 then subtitles_fragment_prev = subtitles[i-1] end end subtitles_fragment_end_time = sub_end if i ~= #subtitles then subtitles_fragment_next = subtitles[i+1] end end if end_timestamp ~= nil then if sub_start > end_timestamp then break end elseif sub_start > time_pos then break end end if start_timestamp ~= nil then start_timestamp = start_timestamp + AUDIO_CLIP_PADDING end_timestamp = end_timestamp - AUDIO_CLIP_PADDING end if start_timestamp ~= nil then subtitles_fragment_start_time = start_timestamp subtitles_fragment_end_time = end_timestamp end return table.concat(subtitles_fragment, "
"), subtitles_fragment_start_time, subtitles_fragment_end_time, subtitles_fragment_prev, subtitles_fragment_next end function create_anki_card() local subtitles_fragment, subtitles_fragment_start_time, subtitles_fragment_end_time, subtitles_fragment_prev, subtitles_fragment_next = get_subtitles_fragment(sentences, sentences_start, sentences_end) if subtitles_fragment == "" then mp.osd_message("no text") return end local video_filename = mp.get_property("filename/no-ext") local audio_filename = export_audio_fragment(subtitles_fragment_start_time, subtitles_fragment_end_time) local clip_filename = export_video_fragment(subtitles_fragment_start_time, subtitles_fragment_end_time) local image_filename = export_screenshot() local deck = DEFAULT_DECK if CREATE_SUBDECKS_FOR_CARDS then deck = deck .. "::" .. video_filename end local sound = "[sound:" .. audio_filename .. "]" local video = clip_filename local time = seconds_to_ffmpeg_time(subtitles_fragment_start_time) local source = video_filename local image = "" local target_line = subtitles_fragment local target_line_before = subtitles_fragment_prev local target_line_after = subtitles_fragment_next local fields = {} fields["Sound"] = sound fields["Time"] = time fields["Source"] = source fields["Image"] = image fields["Target: line"] = target_line fields["Target: line before"] = target_line_before fields["Target: line after"] = target_line_after if EXPORT_VIDEO then fields["Video"] = video end local args = {PYTHON_PATH, PYTHON_HELPER_PATH, deck, NOTE_TYPE, utils.format_json(fields)} ret = utils.subprocess({args = args}) if ret["status"] == 0 then mp.osd_message("✔") else mp.osd_message("error") end player_state = nil start_timestamp = nil end_timestamp = nil end function init() ret = read_subtitles(SRT_FILE_EXTENSIONS) if ret == false or #subs == 0 then return end convert_into_sentences() mp.add_key_binding("b", "create-anki-card", create_anki_card) mp.add_key_binding("w", "set-start-timestamp", set_start_timestamp) mp.add_key_binding("e", "set-end-timestamp", set_end_timestamp) mp.add_key_binding("ctrl+w", "replay_anki_card", replay_anki_card) mp.add_key_binding("ctrl+e", "replay-the-last-seconds-of-anki-card", replay_the_last_seconds_of_anki_card) mp.add_key_binding("ctrl+b", "set-start-end-timestamps", set_start_end_timestamps) mp.register_event("playback-restart", on_playback_restart) end mp.register_event("file-loaded", init)