From 53cff753b6ef374231fd4ef1b92704c01e8144a4 Mon Sep 17 00:00:00 2001 From: Eva Date: Sat, 5 Apr 2025 01:15:14 +0200 Subject: [PATCH 01/28] preliminary storyboard thumbnail implementation last atlas index still has incorrectly sized thumbnails --- thumbfast.lua | 224 +++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 211 insertions(+), 13 deletions(-) diff --git a/thumbfast.lua b/thumbfast.lua index 2e2eb22..7428901 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -39,7 +39,7 @@ local options = { quit_after_inactivity = 0, -- Enable on network playback - network = false, + network = true, -- Enable on audio playback audio = false, @@ -68,7 +68,7 @@ function subprocess(args, async, callback) if not pre_0_30_0 then if async then - return mp.command_native_async({name = "subprocess", playback_only = true, args = args, env = "PATH="..os.getenv("PATH")}, callback) + return mp.command_native_async({name = "subprocess", playback_only = true, capture_stdout=true, args = args, env = "PATH="..os.getenv("PATH")}, callback) else return mp.command_native({name = "subprocess", playback_only = false, capture_stdout = true, args = args, env = "PATH="..os.getenv("PATH")}) end @@ -141,6 +141,7 @@ local force_disabled = false local spawn_waiting = false local spawn_working = false local script_written = false +local thumbnail_delta = 1 local dirty = false @@ -262,6 +263,8 @@ local unique = mp.utils.getpid() options.socket = options.socket .. unique options.thumbnail = options.thumbnail .. unique +local thumbnail_path = options.thumbnail + if options.direct_io then if os_name == "windows" then winapi.socket_wc = winapi.MultiByteToWideChar("\\\\.\\pipe\\" .. options.socket) @@ -413,7 +416,7 @@ local function info(w, h) info_timer = mp.add_timeout(0.05, function() info(w, h) end) end - local json, err = mp.utils.format_json({width=w * options.scale_factor, height=h * options.scale_factor, scale_factor=options.scale_factor, disabled=disabled, available=true, socket=options.socket, thumbnail=options.thumbnail, overlay_id=options.overlay_id}) + local json, err = mp.utils.format_json({width=w * options.scale_factor, height=h * options.scale_factor, scale_factor=options.scale_factor, disabled=disabled, available=true, socket=options.socket, thumbnail=options.thumbnail, overlay_id=options.overlay_id}) -- TODO: add storyboard info if pre_0_30_0 then mp.command_native({"script-message", "thumbfast-info", json}) else @@ -427,8 +430,8 @@ local function remove_thumbnail_files() file = nil file_bytes = 0 end - os.remove(options.thumbnail) - os.remove(options.thumbnail..".bgra") + os.remove(thumbnail_path) + os.remove(thumbnail_path..".bgra") end local activity_timer @@ -453,21 +456,28 @@ local function spawn(time) end remove_thumbnail_files() + thumbnail_path = options.thumbnail + -- are we spawning a real thumbnail process for yt vids? I hope not local vid = properties["vid"] has_vid = vid or 0 + -- TODO: add filtered ytdl-raw-options, especially for 'cookies' option + local args = { mpv_path, "--no-config", "--msg-level=all=no", "--idle", "--pause", "--keep-open=always", "--really-quiet", "--no-terminal", "--load-scripts=no", "--osc=no", "--ytdl=no", "--load-stats-overlay=no", "--load-osd-console=no", "--load-auto-profiles=no", "--edition="..(properties["edition"] or "auto"), "--vid="..(vid or "auto"), "--no-sub", "--no-audio", "--start="..time, allow_fast_seek and "--hr-seek=no" or "--hr-seek=yes", "--ytdl-format=worst", "--demuxer-readahead-secs=0", "--demuxer-max-bytes=128KiB", + "--http-header-fields="..(properties["http-header-fields"] or ""), -- does this actually work well with SVP? + "--cookies="..(properties["cookies"] or "no"), + "--cookies-file="..(properties["cookies-file"] or ""), "--vd-lavc-skiploopfilter=all", "--vd-lavc-software-fallback=1", "--vd-lavc-fast", "--vd-lavc-threads=2", "--hwdec="..(options.hwdec and "auto" or "no"), "--vf="..vf_string(filters_all, true), "--sws-scaler=fast-bilinear", "--video-rotate="..last_rotate, - "--ovc=rawvideo", "--of=image2", "--ofopts=update=1", "--o="..options.thumbnail + "--ovc=rawvideo", "--of=image2", "--ofopts=update=1", "--o="..thumbnail_path } if not pre_0_30_0 then @@ -593,16 +603,17 @@ local function run(command) end local function draw(w, h, script) + print("wwwwwww", w, show_thumbnail, thumbnail_path..".bgra") if not w or not show_thumbnail then return end if x ~= nil then local scale_w, scale_h = options.scale_factor ~= 1 and (w * options.scale_factor) or nil, options.scale_factor ~= 1 and (h * options.scale_factor) or nil if pre_0_30_0 then - mp.command_native({"overlay-add", options.overlay_id, x, y, options.thumbnail..".bgra", 0, "bgra", w, h, (4*w), scale_w, scale_h}) + mp.command_native({"overlay-add", options.overlay_id, x, y, thumbnail_path..".bgra", 0, "bgra", w, h, (4*w), scale_w, scale_h}) else - mp.command_native_async({"overlay-add", options.overlay_id, x, y, options.thumbnail..".bgra", 0, "bgra", w, h, (4*w), scale_w, scale_h}, function() end) + mp.command_native_async({"overlay-add", options.overlay_id, x, y, thumbnail_path..".bgra", 0, "bgra", w, h, (4*w), scale_w, scale_h}, function() end) end elseif script then - local json, err = mp.utils.format_json({width=w, height=h, scale_factor=options.scale_factor, x=x, y=y, socket=options.socket, thumbnail=options.thumbnail, overlay_id=options.overlay_id}) + local json, err = mp.utils.format_json({width=w, height=h, scale_factor=options.scale_factor, x=x, y=y, socket=options.socket, thumbnail=thumbnail_path, overlay_id=options.overlay_id}) mp.commandv("script-message-to", script, "thumbfast-render", json) end end @@ -682,14 +693,14 @@ local function check_new_thumb() -- validity but before actually moving the file, so move to a temporary -- location before validity check to make sure everything stays consistant -- and valid thumbnails don't get overwritten by invalid ones - local tmp = options.thumbnail..".tmp" - move_file(options.thumbnail, tmp) + local tmp = thumbnail_path..".tmp" + move_file(thumbnail_path, tmp) local finfo = mp.utils.file_info(tmp) if not finfo then return false end spawn_waiting = false local w, h = real_res(effective_w, effective_h, finfo.size) if w then -- only accept valid thumbnails - move_file(tmp, options.thumbnail..".bgra") + move_file(tmp, thumbnail_path..".bgra") real_w, real_h = w, h if real_w and (real_w ~= last_real_w or real_h ~= last_real_h) then @@ -764,6 +775,7 @@ local function thumb(time, r_x, r_y, script) if last_x ~= x or last_y ~= y or not show_thumbnail then show_thumbnail = true last_x, last_y = x, y + print("drrrr", time) draw(real_w, real_h, script) end @@ -776,7 +788,10 @@ local function thumb(time, r_x, r_y, script) if time == last_seek_time then return end last_seek_time = time - if not spawned then spawn(time) end + thumb_index = math.floor(time / thumbnail_delta) + thumbnail_path = options.thumbnail .. ".ytdl-thumbx" .. tostring(thumb_index) + print("thumbnail_path", thumbnail_path) + if not spawned then spawn(time) end -- TODO: skip when ytdl on? request_seek() if not file_timer:is_enabled() then file_timer:resume() end end @@ -888,6 +903,87 @@ local function sync_changes(prop, val) dirty = true end +function output_name(idx, storyboard, atlas_idx) + --local thumb_idx = idx + --local atlas_idx = math.floor(thumb_idx * storyboard.divisor /(storyboard.cols*storyboard.rows)) + --print("idxidxidx", idx) + local thumb_idx = (atlas_idx - 1) * storyboard.cols * storyboard.rows + idx + print("thumb_idx", thumb_idx) + if thumb_idx % storyboard.divisor ~= 0 then + return nil + end + return options.thumbnail .. ".ytdl-thumbx" .. tostring(math.floor(thumb_idx / storyboard.divisor)) .. ".bgra" +end + +local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, total) + local atlas = io.open(atlas_path, "rb") + local atlas_filesize = atlas:seek("end") + local atlas_pictures = math.floor(atlas_filesize / (4 * thumbnail_size.w * thumbnail_size.h)) + local stride = 4 * thumbnail_size.w * math.min(storyboard.cols, atlas_pictures) + for pic = 0, atlas_pictures-1 do + local x_start = (pic % storyboard.cols) * thumbnail_size.w + local y_start = math.floor(pic / storyboard.cols) * thumbnail_size.h + print("pic", pic, "atlas_idx", atlas_idx) + local filename = output_name(pic, storyboard, atlas_idx) + if filename ~= nil then + local thumb_file = io.open(filename, "wb") + for line = 0, thumbnail_size.h - 1 do + atlas:seek("set", 4 * x_start + (y_start + line) * stride) + local data = atlas:read(thumbnail_size.w * 4) + if data ~= nil then + thumb_file:write(data) + end + end + total = total + 1 + thumb_file:close() + if atlas_idx == 1 then + mp.command_native({"overlay-add", pic, pic*60, pic*60, filename, 0, "bgra", thumbnail_size.w, thumbnail_size.h, (4*thumbnail_size.w)}) + end + end + end + atlas:close() + print("get_thumb end") + print("total", total) + return total +end + +local function fetch_fragment(storyboard, i, thumbnail_size, total) + print("FRAGGGG", i) + print("graggg", mp.utils.format_json(storyboard.fragments), storyboard.fragments[i]) + if not storyboard.fragments[i] then return end + + -- TODO: scale to desired thumbnail size + local args = { + mpv_path, storyboard.fragments[i].url, "--no-config", "--msg-level=all=no", "--really-quiet", "--no-terminal", "--vo=null", + "--frames=1", + "--no-sub", "--no-audio", "--hr-seek=no", "--sub-font-provider=none", "--embeddedfonts=no", + "--no-ytdl", "--demuxer-readahead-secs=0", "--demuxer-max-bytes=128KiB", + "--vd-lavc-skiploopfilter=all", "--vd-lavc-software-fallback=1", "--vd-lavc-fast", "--vd-lavc-threads=2", --"--hwdec="..(options.hwdec and "auto" or "no"), + --"--vf="..vf_string(filters_all, true), + "--sws-allow-zimg=no", "--sws-fast=yes", "--sws-scaler=fast-bilinear", + --"--video-rotate="..last_rotate, + "--vf-add=format=bgra,scale="..(thumbnail_size.w * storyboard.rows)..":"..(thumbnail_size.h * storyboard.cols), + "--ovc=rawvideo", "--of=rawvideo", "--ofopts=update=1", "--o="..options.thumbnail..".ytdl" + } + -- TODO: use stdout? + + if os_name == "Mac" then + table.insert(args, "--macos-app-activation-policy=prohibited") + end + + subprocess(args, true, + function(success, result) + if success == false or result.status ~= 0 then + mp.msg.error("mpv thumbnail download failed") + else + print("mpv thumbnail download success") + total = get_thumb(options.thumbnail..".ytdl", i, storyboard, thumbnail_size, total) + fetch_fragment(storyboard, i+1, thumbnail_size, total) + end + end + ) +end + local function file_load() clear() spawned = false @@ -902,6 +998,105 @@ local function file_load() calc_dimensions() info(effective_w, effective_h) + if disabled then return end + + print("network", options.network) + if options.network then + print("headers", properties["http-header-fields"] or "") + -- TODO: support more than just youtube... this should also work out of the box for twitch vods? + local video_path = properties["path"] or "" + local video_referer = string.match(properties["http-header-fields"] or "", "Referer:([^,]+)") or "" + local urls = { + "^ytdl://([%w-_]+)", + "^https?://youtu%.be/([%w-_]+)", + "^https?://w?w?w?%.?youtube%.com/v/([%w-_]+)", + "/watch.*[?&]v=([%w-_]+)", + "/embed/([%w-_]+)" + } + local youtube_id = nil + for i, url in ipairs(urls) do + youtube_id = youtube_id or string.match(video_path, url) or string.match(video_referer, url) + if youtube_id then break end + end + + --print("youtube_id", youtube_id) + if youtube_id and string.len(youtube_id) >= 11 then + youtube_id = string.sub(youtube_id, 1, 11) + -- TODO: find yt-dlp path + local sb_cmd = {"yt-dlp", "--format", "sb0", "--dump-json", "--no-playlist", + "--extractor-args", "youtube:skip=hls,dash,translated_subs", -- yt speedup + "--", "https://www.youtube.com/watch?v="..youtube_id} + + subprocess(sb_cmd, true, function(success, sb_json) + print("resp1") + if success and sb_json.status == 0 then + local sb = mp.utils.parse_json(sb_json.stdout) + print("resp2") + if sb ~= nil and sb.duration and sb.width and sb.height and sb.fragments and #sb.fragments > 0 then + print("resp3") + local storyboard = {} + local thumbnail_count = 0 + local thumbnail_size = {w=0, h=0} + storyboard.fragments = sb.fragments + storyboard.fragment_base_url = sb.fragment_base_url + storyboard.rows = sb.rows or 5 + storyboard.cols = sb.columns or 5 + + if sb.fps then + thumbnail_count = math.floor(sb.fps * sb.duration + 0.5) -- round + -- hack: youtube always adds 1 black frame at the end... + if sb.extractor == "youtube" then + thumbnail_count = thumbnail_count - 1 + end + else + -- estimate the count of thumbnails + -- assume first atlas is always full + thumbnail_delta = sb.fragments[1].duration / (storyboard.rows*storyboard.cols) + thumbnail_count = math.floor(sb.duration / thumbnail_delta) + end + + -- Storyboard upscaling factor + local scale = properties["display-hidpi-scale"] or 1 + if sb.width / sb.height > options.max_width / options.max_height then + real_w = math.floor(options.max_width * scale + 0.5) + real_h = math.floor(sb.height / sb.width * real_w + 0.5) + else + real_h = math.floor(options.max_height * scale + 0.5) + real_w = math.floor(sb.width / sb.height * real_h + 0.5) + end + local storyboard_scale = {w=real_w/sb.width, h=real_h/sb.height} + info(real_w, real_h) + print("real_wreal_w", real_w) + storyboard.scale = scale + + local divisor = 1 -- only save every n-th thumbnail + if options.storyboard_max_thumbnail_count then + divisor = math.ceil(thumbnail_count / options.storyboard_max_thumbnail_count) + end + storyboard.divisor = divisor + thumbnail_count = math.floor(thumbnail_count / divisor) + thumbnail_delta = sb.duration / thumbnail_count + + print("Storyboard info acquired! " .. thumbnail_count) + print("thumbnail_delta", thumbnail_delta) + for k,v in pairs(storyboard.fragments[1]) do + print(k,v) + end + fetch_fragment(storyboard, 1, {w=real_w, h=real_h}, 0) + end + end + --callback() + print("done") + end) + + end + end + + spawned = false + if options.spawn_first then + spawn(mp.get_property_number("time-pos", 0)) + first_file = true + end end local function shutdown() @@ -932,6 +1127,9 @@ mp.observe_property("video-params", "native", update_property_dirty) mp.observe_property("vf", "native", update_property_dirty) mp.observe_property("tone-mapping", "native", update_property_dirty) mp.observe_property("demuxer-via-network", "native", update_property) +mp.observe_property("http-header-fields", "string", update_property) +mp.observe_property("cookies", "string", update_property) +mp.observe_property("cookies-file", "string", update_property) mp.observe_property("stream-open-filename", "native", update_property) mp.observe_property("macos-app-activation-policy", "native", update_property) mp.observe_property("current-vo", "native", update_property) From 4b45873527b3dd8d6832dc27d0acd54476e5fcb9 Mon Sep 17 00:00:00 2001 From: Eva Date: Sat, 5 Apr 2025 01:18:10 +0200 Subject: [PATCH 02/28] attempt at relative scaling has wrong stride? wrong estimated number of thumbnails? --- thumbfast.lua | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/thumbfast.lua b/thumbfast.lua index 7428901..b84e86a 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -915,14 +915,16 @@ function output_name(idx, storyboard, atlas_idx) return options.thumbnail .. ".ytdl-thumbx" .. tostring(math.floor(thumb_idx / storyboard.divisor)) .. ".bgra" end -local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, total) +local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, total, storyboard_scale) local atlas = io.open(atlas_path, "rb") local atlas_filesize = atlas:seek("end") - local atlas_pictures = math.floor(atlas_filesize / (4 * thumbnail_size.w * thumbnail_size.h)) + local atlas_pictures = math.floor(atlas_filesize / (4 * thumbnail_size.w * thumbnail_size.h)) --wrong number of thumbnails? local stride = 4 * thumbnail_size.w * math.min(storyboard.cols, atlas_pictures) + print("stride", stride) + print("atlas_pictures", atlas_pictures) for pic = 0, atlas_pictures-1 do local x_start = (pic % storyboard.cols) * thumbnail_size.w - local y_start = math.floor(pic / storyboard.cols) * thumbnail_size.h + local y_start = math.floor(pic / storyboard.rows) * thumbnail_size.h --rows or cols here? print("pic", pic, "atlas_idx", atlas_idx) local filename = output_name(pic, storyboard, atlas_idx) if filename ~= nil then @@ -947,7 +949,7 @@ local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, tota return total end -local function fetch_fragment(storyboard, i, thumbnail_size, total) +local function fetch_fragment(storyboard, i, thumbnail_size, total, storyboard_scale) print("FRAGGGG", i) print("graggg", mp.utils.format_json(storyboard.fragments), storyboard.fragments[i]) if not storyboard.fragments[i] then return end @@ -962,7 +964,7 @@ local function fetch_fragment(storyboard, i, thumbnail_size, total) --"--vf="..vf_string(filters_all, true), "--sws-allow-zimg=no", "--sws-fast=yes", "--sws-scaler=fast-bilinear", --"--video-rotate="..last_rotate, - "--vf-add=format=bgra,scale="..(thumbnail_size.w * storyboard.rows)..":"..(thumbnail_size.h * storyboard.cols), + "--vf-add=format=bgra,scale=iw*"..(storyboard_scale.w)..":ih*"..(storyboard_scale.h), "--ovc=rawvideo", "--of=rawvideo", "--ofopts=update=1", "--o="..options.thumbnail..".ytdl" } -- TODO: use stdout? @@ -977,8 +979,8 @@ local function fetch_fragment(storyboard, i, thumbnail_size, total) mp.msg.error("mpv thumbnail download failed") else print("mpv thumbnail download success") - total = get_thumb(options.thumbnail..".ytdl", i, storyboard, thumbnail_size, total) - fetch_fragment(storyboard, i+1, thumbnail_size, total) + total = get_thumb(options.thumbnail..".ytdl", i, storyboard, thumbnail_size, total, storyboard_scale) + fetch_fragment(storyboard, i+1, thumbnail_size, total, storyboard_scale) end end ) @@ -1082,7 +1084,7 @@ local function file_load() for k,v in pairs(storyboard.fragments[1]) do print(k,v) end - fetch_fragment(storyboard, 1, {w=real_w, h=real_h}, 0) + fetch_fragment(storyboard, 1, {w=real_w, h=real_h}, 0, storyboard_scale) end end --callback() From 1ac3d1c0210185acacf193a739219cdad58c681e Mon Sep 17 00:00:00 2001 From: Eva Date: Sat, 5 Apr 2025 22:24:50 +0200 Subject: [PATCH 03/28] fix scaling --- thumbfast.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/thumbfast.lua b/thumbfast.lua index b84e86a..ccfe509 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -964,7 +964,7 @@ local function fetch_fragment(storyboard, i, thumbnail_size, total, storyboard_s --"--vf="..vf_string(filters_all, true), "--sws-allow-zimg=no", "--sws-fast=yes", "--sws-scaler=fast-bilinear", --"--video-rotate="..last_rotate, - "--vf-add=format=bgra,scale=iw*"..(storyboard_scale.w)..":ih*"..(storyboard_scale.h), + "--vf-add=format=bgra,scale=trunc(iw*"..(storyboard_scale.w).."+0.5):trunc(ih*"..(storyboard_scale.h).."+0.5)", "--ovc=rawvideo", "--of=rawvideo", "--ofopts=update=1", "--o="..options.thumbnail..".ytdl" } -- TODO: use stdout? From 44ad584f6187bb67822c93e60c5a447c22e7f8d9 Mon Sep 17 00:00:00 2001 From: Eva Date: Sat, 5 Apr 2025 22:25:06 +0200 Subject: [PATCH 04/28] re-enable loop filter for storyboards --- thumbfast.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/thumbfast.lua b/thumbfast.lua index ccfe509..58b5052 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -960,7 +960,7 @@ local function fetch_fragment(storyboard, i, thumbnail_size, total, storyboard_s "--frames=1", "--no-sub", "--no-audio", "--hr-seek=no", "--sub-font-provider=none", "--embeddedfonts=no", "--no-ytdl", "--demuxer-readahead-secs=0", "--demuxer-max-bytes=128KiB", - "--vd-lavc-skiploopfilter=all", "--vd-lavc-software-fallback=1", "--vd-lavc-fast", "--vd-lavc-threads=2", --"--hwdec="..(options.hwdec and "auto" or "no"), + "--vd-lavc-software-fallback=1", "--vd-lavc-fast", "--vd-lavc-threads=2", --"--hwdec="..(options.hwdec and "auto" or "no"), --"--vf="..vf_string(filters_all, true), "--sws-allow-zimg=no", "--sws-fast=yes", "--sws-scaler=fast-bilinear", --"--video-rotate="..last_rotate, From a5f4e4510e73a3494c4cc1223e6290061dce833f Mon Sep 17 00:00:00 2001 From: Eva Date: Sun, 6 Apr 2025 00:23:33 +0200 Subject: [PATCH 05/28] fix y_start calc --- thumbfast.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/thumbfast.lua b/thumbfast.lua index 58b5052..bf8e088 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -924,7 +924,7 @@ local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, tota print("atlas_pictures", atlas_pictures) for pic = 0, atlas_pictures-1 do local x_start = (pic % storyboard.cols) * thumbnail_size.w - local y_start = math.floor(pic / storyboard.rows) * thumbnail_size.h --rows or cols here? + local y_start = math.floor(pic / storyboard.cols) * thumbnail_size.h print("pic", pic, "atlas_idx", atlas_idx) local filename = output_name(pic, storyboard, atlas_idx) if filename ~= nil then From 5f277a474183bba0fd044b79bd954334756f7b5b Mon Sep 17 00:00:00 2001 From: Eva Date: Sun, 6 Apr 2025 03:08:09 +0200 Subject: [PATCH 06/28] remove debug prints --- thumbfast.lua | 51 ++++++--------------------------------------------- 1 file changed, 6 insertions(+), 45 deletions(-) diff --git a/thumbfast.lua b/thumbfast.lua index bf8e088..40a41b6 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -603,7 +603,6 @@ local function run(command) end local function draw(w, h, script) - print("wwwwwww", w, show_thumbnail, thumbnail_path..".bgra") if not w or not show_thumbnail then return end if x ~= nil then local scale_w, scale_h = options.scale_factor ~= 1 and (w * options.scale_factor) or nil, options.scale_factor ~= 1 and (h * options.scale_factor) or nil @@ -775,7 +774,6 @@ local function thumb(time, r_x, r_y, script) if last_x ~= x or last_y ~= y or not show_thumbnail then show_thumbnail = true last_x, last_y = x, y - print("drrrr", time) draw(real_w, real_h, script) end @@ -790,7 +788,6 @@ local function thumb(time, r_x, r_y, script) last_seek_time = time thumb_index = math.floor(time / thumbnail_delta) thumbnail_path = options.thumbnail .. ".ytdl-thumbx" .. tostring(thumb_index) - print("thumbnail_path", thumbnail_path) if not spawned then spawn(time) end -- TODO: skip when ytdl on? request_seek() if not file_timer:is_enabled() then file_timer:resume() end @@ -904,15 +901,8 @@ local function sync_changes(prop, val) end function output_name(idx, storyboard, atlas_idx) - --local thumb_idx = idx - --local atlas_idx = math.floor(thumb_idx * storyboard.divisor /(storyboard.cols*storyboard.rows)) - --print("idxidxidx", idx) local thumb_idx = (atlas_idx - 1) * storyboard.cols * storyboard.rows + idx - print("thumb_idx", thumb_idx) - if thumb_idx % storyboard.divisor ~= 0 then - return nil - end - return options.thumbnail .. ".ytdl-thumbx" .. tostring(math.floor(thumb_idx / storyboard.divisor)) .. ".bgra" + return options.thumbnail .. ".ytdl-thumbx" .. tostring(thumb_idx) .. ".bgra" end local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, total, storyboard_scale) @@ -920,12 +910,9 @@ local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, tota local atlas_filesize = atlas:seek("end") local atlas_pictures = math.floor(atlas_filesize / (4 * thumbnail_size.w * thumbnail_size.h)) --wrong number of thumbnails? local stride = 4 * thumbnail_size.w * math.min(storyboard.cols, atlas_pictures) - print("stride", stride) - print("atlas_pictures", atlas_pictures) for pic = 0, atlas_pictures-1 do local x_start = (pic % storyboard.cols) * thumbnail_size.w local y_start = math.floor(pic / storyboard.cols) * thumbnail_size.h - print("pic", pic, "atlas_idx", atlas_idx) local filename = output_name(pic, storyboard, atlas_idx) if filename ~= nil then local thumb_file = io.open(filename, "wb") @@ -944,17 +931,12 @@ local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, tota end end atlas:close() - print("get_thumb end") - print("total", total) return total end local function fetch_fragment(storyboard, i, thumbnail_size, total, storyboard_scale) - print("FRAGGGG", i) - print("graggg", mp.utils.format_json(storyboard.fragments), storyboard.fragments[i]) if not storyboard.fragments[i] then return end - -- TODO: scale to desired thumbnail size local args = { mpv_path, storyboard.fragments[i].url, "--no-config", "--msg-level=all=no", "--really-quiet", "--no-terminal", "--vo=null", "--frames=1", @@ -964,10 +946,9 @@ local function fetch_fragment(storyboard, i, thumbnail_size, total, storyboard_s --"--vf="..vf_string(filters_all, true), "--sws-allow-zimg=no", "--sws-fast=yes", "--sws-scaler=fast-bilinear", --"--video-rotate="..last_rotate, - "--vf-add=format=bgra,scale=trunc(iw*"..(storyboard_scale.w).."+0.5):trunc(ih*"..(storyboard_scale.h).."+0.5)", + "--vf-add=format=bgra,scale=round(iw*"..storyboard_scale.w.."):round(ih*"..storyboard_scale.h..")", "--ovc=rawvideo", "--of=rawvideo", "--ofopts=update=1", "--o="..options.thumbnail..".ytdl" } - -- TODO: use stdout? if os_name == "Mac" then table.insert(args, "--macos-app-activation-policy=prohibited") @@ -978,7 +959,6 @@ local function fetch_fragment(storyboard, i, thumbnail_size, total, storyboard_s if success == false or result.status ~= 0 then mp.msg.error("mpv thumbnail download failed") else - print("mpv thumbnail download success") total = get_thumb(options.thumbnail..".ytdl", i, storyboard, thumbnail_size, total, storyboard_scale) fetch_fragment(storyboard, i+1, thumbnail_size, total, storyboard_scale) end @@ -1002,9 +982,7 @@ local function file_load() info(effective_w, effective_h) if disabled then return end - print("network", options.network) if options.network then - print("headers", properties["http-header-fields"] or "") -- TODO: support more than just youtube... this should also work out of the box for twitch vods? local video_path = properties["path"] or "" local video_referer = string.match(properties["http-header-fields"] or "", "Referer:([^,]+)") or "" @@ -1021,7 +999,6 @@ local function file_load() if youtube_id then break end end - --print("youtube_id", youtube_id) if youtube_id and string.len(youtube_id) >= 11 then youtube_id = string.sub(youtube_id, 1, 11) -- TODO: find yt-dlp path @@ -1030,15 +1007,11 @@ local function file_load() "--", "https://www.youtube.com/watch?v="..youtube_id} subprocess(sb_cmd, true, function(success, sb_json) - print("resp1") if success and sb_json.status == 0 then local sb = mp.utils.parse_json(sb_json.stdout) - print("resp2") if sb ~= nil and sb.duration and sb.width and sb.height and sb.fragments and #sb.fragments > 0 then - print("resp3") local storyboard = {} local thumbnail_count = 0 - local thumbnail_size = {w=0, h=0} storyboard.fragments = sb.fragments storyboard.fragment_base_url = sb.fragment_base_url storyboard.rows = sb.rows or 5 @@ -1053,7 +1026,7 @@ local function file_load() else -- estimate the count of thumbnails -- assume first atlas is always full - thumbnail_delta = sb.fragments[1].duration / (storyboard.rows*storyboard.cols) + thumbnail_delta = sb.fragments[1].duration / (storyboard.rows * storyboard.cols) thumbnail_count = math.floor(sb.duration / thumbnail_delta) end @@ -1067,28 +1040,16 @@ local function file_load() real_w = math.floor(sb.width / sb.height * real_h + 0.5) end local storyboard_scale = {w=real_w/sb.width, h=real_h/sb.height} + local thumbnail_size = {w=real_w, h=real_h} info(real_w, real_h) - print("real_wreal_w", real_w) + storyboard.scale = scale - local divisor = 1 -- only save every n-th thumbnail - if options.storyboard_max_thumbnail_count then - divisor = math.ceil(thumbnail_count / options.storyboard_max_thumbnail_count) - end - storyboard.divisor = divisor - thumbnail_count = math.floor(thumbnail_count / divisor) thumbnail_delta = sb.duration / thumbnail_count - print("Storyboard info acquired! " .. thumbnail_count) - print("thumbnail_delta", thumbnail_delta) - for k,v in pairs(storyboard.fragments[1]) do - print(k,v) - end - fetch_fragment(storyboard, 1, {w=real_w, h=real_h}, 0, storyboard_scale) + fetch_fragment(storyboard, 1, thumbnail_size, 0, storyboard_scale) end end - --callback() - print("done") end) end From 298f5a14fd635c267b78174780204b6f37e77952 Mon Sep 17 00:00:00 2001 From: Eva Date: Sun, 6 Apr 2025 09:08:09 +0200 Subject: [PATCH 07/28] split stuff up better --- thumbfast.lua | 198 ++++++++++++++++++++++++++++++-------------------- 1 file changed, 118 insertions(+), 80 deletions(-) diff --git a/thumbfast.lua b/thumbfast.lua index 40a41b6..0f692ca 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -434,6 +434,10 @@ local function remove_thumbnail_files() os.remove(thumbnail_path..".bgra") end +local function remove_storyboard_files() + -- TODO +end + local activity_timer local function spawn(time) @@ -450,12 +454,13 @@ local function spawn(time) end local open_filename = properties["stream-open-filename"] - local ytdl = open_filename and properties["demuxer-via-network"] and path ~= open_filename - if ytdl then + local forced_path = open_filename and path ~= open_filename + if forced_path then path = open_filename end remove_thumbnail_files() + remove_storyboard_files() thumbnail_path = options.thumbnail -- are we spawning a real thumbnail process for yt vids? I hope not @@ -463,6 +468,7 @@ local function spawn(time) has_vid = vid or 0 -- TODO: add filtered ytdl-raw-options, especially for 'cookies' option + -- TODO: use native property for cookies and cookies-file?? local args = { mpv_path, "--no-config", "--msg-level=all=no", "--idle", "--pause", "--keep-open=always", "--really-quiet", "--no-terminal", @@ -605,6 +611,7 @@ end local function draw(w, h, script) if not w or not show_thumbnail then return end if x ~= nil then + print("thumbnail_path..", thumbnail_path) local scale_w, scale_h = options.scale_factor ~= 1 and (w * options.scale_factor) or nil, options.scale_factor ~= 1 and (h * options.scale_factor) or nil if pre_0_30_0 then mp.command_native({"overlay-add", options.overlay_id, x, y, thumbnail_path..".bgra", 0, "bgra", w, h, (4*w), scale_w, scale_h}) @@ -770,6 +777,12 @@ local function thumb(time, r_x, r_y, script) x, y = math.floor(r_x + 0.5), math.floor(r_y + 0.5) end + if thumbnail_delta then + -- TODO: do we need to do something special if the thumbnail doesn't exist? + thumb_index = math.floor(time / thumbnail_delta) + thumbnail_path = options.thumbnail .. ".ytdl-thumbx" .. tostring(thumb_index) + end + script_name = script if last_x ~= x or last_y ~= y or not show_thumbnail then show_thumbnail = true @@ -786,8 +799,6 @@ local function thumb(time, r_x, r_y, script) if time == last_seek_time then return end last_seek_time = time - thumb_index = math.floor(time / thumbnail_delta) - thumbnail_path = options.thumbnail .. ".ytdl-thumbx" .. tostring(thumb_index) if not spawned then spawn(time) end -- TODO: skip when ytdl on? request_seek() if not file_timer:is_enabled() then file_timer:resume() end @@ -936,6 +947,7 @@ end local function fetch_fragment(storyboard, i, thumbnail_size, total, storyboard_scale) if not storyboard.fragments[i] then return end + print("spawn", i) local args = { mpv_path, storyboard.fragments[i].url, "--no-config", "--msg-level=all=no", "--really-quiet", "--no-terminal", "--vo=null", @@ -947,7 +959,7 @@ local function fetch_fragment(storyboard, i, thumbnail_size, total, storyboard_s "--sws-allow-zimg=no", "--sws-fast=yes", "--sws-scaler=fast-bilinear", --"--video-rotate="..last_rotate, "--vf-add=format=bgra,scale=round(iw*"..storyboard_scale.w.."):round(ih*"..storyboard_scale.h..")", - "--ovc=rawvideo", "--of=rawvideo", "--ofopts=update=1", "--o="..options.thumbnail..".ytdl" + "--ovc=rawvideo", "--of=rawvideo", "--ofopts=update=1", "--o="..options.thumbnail..".ytdl"..tostring(i) } if os_name == "Mac" then @@ -959,11 +971,107 @@ local function fetch_fragment(storyboard, i, thumbnail_size, total, storyboard_s if success == false or result.status ~= 0 then mp.msg.error("mpv thumbnail download failed") else - total = get_thumb(options.thumbnail..".ytdl", i, storyboard, thumbnail_size, total, storyboard_scale) - fetch_fragment(storyboard, i+1, thumbnail_size, total, storyboard_scale) + total = get_thumb(options.thumbnail..".ytdl"..tostring(i), i, storyboard, thumbnail_size, total, storyboard_scale) end end ) + -- TODO: set max n of active processes? I'm scared of spawning a lot of mpv processes when playing a 10 hour stream VOD... yeah this vid is 63 processes that's already too much https://www.youtube.com/watch?v=fKM2NYABFFc + -- I need some sort of a queue function. + -- create a list that will contain a representation of each subprocess call to be made + -- when a user hovers on the timeline, look up on a copy of the table which value corresponds to the requested index. stick that value at the start of the real table, and deduplicate it. this lets us prioritize fragments the user wants to see. + -- needs to be tested on a really long video, or with an artificially introduced delay, so we can check that the prioritization actually works. we also need to be sure that hovering on the timeline at all WORKS and lets us display thumbnails even though not all have been fetched yet. + -- when a fragment has been fetched, it sets itself to nil in the list copy, and is removed from the processing pile. + fetch_fragment(storyboard, i+1, thumbnail_size, total, storyboard_scale) +end + +local function setup_storyboards() + if not options.network then return end + + local path = properties["path"] + if path == nil then return end + + local open_filename = properties["stream-open-filename"] + local forced_path = open_filename and path ~= open_filename -- and properties["demuxer-via-network"] + if not forced_path then return end + + remove_thumbnail_files() + remove_storyboard_files() + + -- TODO: support more than just youtube... this should also work out of the box for twitch vods? + local referer = string.match(properties["http-header-fields"] or "", "Referer:([^,]+)") or "" -- TODO: use native property here + -- TODO: youtube shorts pattern + -- it may be possible to run the subprocess synchronously so that we can let yt-dlp decide if storyboards are supported at all??? I think this is the best option. I think I may have to call info() with 0 dimensions to make the thumbnail get disabled in the meantime tho. + -- something similar to check_new_thumb() may be needed for when we're writing the rgba files? most likely not though, since it's happening in the lua main loop so everything should be done writing already when we reach overlay-add + local urls = { + "^ytdl://([%w-_]+)", + "^https?://youtu%.be/([%w-_]+)", + "^https?://w?w?w?%.?youtube%.com/v/([%w-_]+)", + "/watch.*[?&]v=([%w-_]+)", + "/embed/([%w-_]+)" + } + local youtube_id = nil + for i, url in ipairs(urls) do + youtube_id = youtube_id or string.match(path, url) or string.match(referer, url) + if youtube_id then break end + end + + if youtube_id and string.len(youtube_id) >= 11 then + youtube_id = string.sub(youtube_id, 1, 11) + -- TODO: find yt-dlp path + local sb_cmd = {"yt-dlp", "--format", "sb0", "--dump-json", "--no-playlist", + "--extractor-args", "youtube:skip=hls,dash,translated_subs", -- yt speedup + "--", "https://www.youtube.com/watch?v="..youtube_id} + + subprocess(sb_cmd, true, function(success, sb_json) + if success and sb_json.status == 0 then + local sb = mp.utils.parse_json(sb_json.stdout) + if sb ~= nil and sb.duration and sb.width and sb.height and sb.fragments and #sb.fragments > 0 then + local storyboard = {} + local thumbnail_count = 0 + storyboard.fragments = sb.fragments + storyboard.fragment_base_url = sb.fragment_base_url + storyboard.rows = sb.rows or 5 + storyboard.cols = sb.columns or 5 + + if sb.fps then + thumbnail_count = math.floor(sb.fps * sb.duration + 0.5) -- round + -- hack: youtube always adds 1 black frame at the end... --is this even true? + if sb.extractor == "youtube" then + thumbnail_count = thumbnail_count - 1 + end + else + -- estimate the count of thumbnails + -- assume first atlas is always full + thumbnail_delta = sb.fragments[1].duration / (storyboard.rows * storyboard.cols) + thumbnail_count = math.floor(sb.duration / thumbnail_delta) + end + + -- Storyboard upscaling factor + -- TODO: shouldn't we set effective_w and effective_h here? that's what calc_dimensions does... look into it. + -- we need to run info() + local scale = properties["display-hidpi-scale"] or 1 + if sb.width / sb.height > options.max_width / options.max_height then + real_w = math.floor(options.max_width * scale + 0.5) + real_h = math.floor(sb.height / sb.width * real_w + 0.5) + else + real_h = math.floor(options.max_height * scale + 0.5) + real_w = math.floor(sb.width / sb.height * real_h + 0.5) + end + local storyboard_scale = {w=real_w/sb.width, h=real_h/sb.height} + local thumbnail_size = {w=real_w, h=real_h} + info(real_w, real_h) + + storyboard.scale = scale + + thumbnail_delta = sb.duration / thumbnail_count + + fetch_fragment(storyboard, 1, thumbnail_size, 0, storyboard_scale) + end + end + end) + -- we are in a state where we decided yeah let's use the storyboards. + return true + end end local function file_load() @@ -978,83 +1086,12 @@ local function file_load() info_timer = nil end + if setup_storyboards() then return end + calc_dimensions() info(effective_w, effective_h) if disabled then return end - if options.network then - -- TODO: support more than just youtube... this should also work out of the box for twitch vods? - local video_path = properties["path"] or "" - local video_referer = string.match(properties["http-header-fields"] or "", "Referer:([^,]+)") or "" - local urls = { - "^ytdl://([%w-_]+)", - "^https?://youtu%.be/([%w-_]+)", - "^https?://w?w?w?%.?youtube%.com/v/([%w-_]+)", - "/watch.*[?&]v=([%w-_]+)", - "/embed/([%w-_]+)" - } - local youtube_id = nil - for i, url in ipairs(urls) do - youtube_id = youtube_id or string.match(video_path, url) or string.match(video_referer, url) - if youtube_id then break end - end - - if youtube_id and string.len(youtube_id) >= 11 then - youtube_id = string.sub(youtube_id, 1, 11) - -- TODO: find yt-dlp path - local sb_cmd = {"yt-dlp", "--format", "sb0", "--dump-json", "--no-playlist", - "--extractor-args", "youtube:skip=hls,dash,translated_subs", -- yt speedup - "--", "https://www.youtube.com/watch?v="..youtube_id} - - subprocess(sb_cmd, true, function(success, sb_json) - if success and sb_json.status == 0 then - local sb = mp.utils.parse_json(sb_json.stdout) - if sb ~= nil and sb.duration and sb.width and sb.height and sb.fragments and #sb.fragments > 0 then - local storyboard = {} - local thumbnail_count = 0 - storyboard.fragments = sb.fragments - storyboard.fragment_base_url = sb.fragment_base_url - storyboard.rows = sb.rows or 5 - storyboard.cols = sb.columns or 5 - - if sb.fps then - thumbnail_count = math.floor(sb.fps * sb.duration + 0.5) -- round - -- hack: youtube always adds 1 black frame at the end... - if sb.extractor == "youtube" then - thumbnail_count = thumbnail_count - 1 - end - else - -- estimate the count of thumbnails - -- assume first atlas is always full - thumbnail_delta = sb.fragments[1].duration / (storyboard.rows * storyboard.cols) - thumbnail_count = math.floor(sb.duration / thumbnail_delta) - end - - -- Storyboard upscaling factor - local scale = properties["display-hidpi-scale"] or 1 - if sb.width / sb.height > options.max_width / options.max_height then - real_w = math.floor(options.max_width * scale + 0.5) - real_h = math.floor(sb.height / sb.width * real_w + 0.5) - else - real_h = math.floor(options.max_height * scale + 0.5) - real_w = math.floor(sb.width / sb.height * real_h + 0.5) - end - local storyboard_scale = {w=real_w/sb.width, h=real_h/sb.height} - local thumbnail_size = {w=real_w, h=real_h} - info(real_w, real_h) - - storyboard.scale = scale - - thumbnail_delta = sb.duration / thumbnail_count - - fetch_fragment(storyboard, 1, thumbnail_size, 0, storyboard_scale) - end - end - end) - - end - end - spawned = false if options.spawn_first then spawn(mp.get_property_number("time-pos", 0)) @@ -1065,6 +1102,7 @@ end local function shutdown() run("quit") remove_thumbnail_files() + remove_storyboard_files() if os_name ~= "windows" then os.remove(options.socket) os.remove(options.socket..".run") From f848d19e73fb397feb7322f3099a697e91c9cb7d Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 7 Apr 2025 00:58:50 +0200 Subject: [PATCH 08/28] add prioritized process queue --- thumbfast.lua | 83 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 68 insertions(+), 15 deletions(-) diff --git a/thumbfast.lua b/thumbfast.lua index 0f692ca..9ea9f20 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -81,6 +81,60 @@ function subprocess(args, async, callback) end end +local all_processes = {} +local process_queue = {} +local active_processes = 0 +local max_processes = 5 + +local function spawn_one(args, callback) + if active_processes < max_processes then + active_processes = active_processes + 1 + subprocess(args, true, callback) + for i, process in ipairs(process_queue) do + if process.args == args then + table.remove(process_queue, i) + break + end + end + for i, process in ipairs(all_processes) do + if process.args == args then + all_processes[i] = nil + break + end + end + end +end + +local function spawn_queued_process(args, callback) + local function wrap_callback(args, callback) + return function(...) + callback(...) + active_processes = active_processes - 1 + if #process_queue > 0 then + spawn_one(process_queue[1].args, process_queue[1].callback) + end + end + end + local wrapped_callback = wrap_callback(args, callback) + local process = {args=args, callback=wrapped_callback} + table.insert(process_queue, process) + table.insert(all_processes, process) + spawn_one(args, wrapped_callback) +end + +local function prioritize_process(atlas_index) + local target_process = all_processes[atlas_index] + if not target_process then return end + + for i, process in ipairs(process_queue) do + if process.args == target_process.args then + table.remove(process_queue, i) + table.insert(process_queue, 1, process) + break + end + end +end + local winapi = {} if options.direct_io then local ffi_loaded, ffi = pcall(require, "ffi") @@ -142,6 +196,7 @@ local spawn_waiting = false local spawn_working = false local script_written = false local thumbnail_delta = 1 +local thumb_count_per_storyboard = 1 local dirty = false @@ -611,7 +666,6 @@ end local function draw(w, h, script) if not w or not show_thumbnail then return end if x ~= nil then - print("thumbnail_path..", thumbnail_path) local scale_w, scale_h = options.scale_factor ~= 1 and (w * options.scale_factor) or nil, options.scale_factor ~= 1 and (h * options.scale_factor) or nil if pre_0_30_0 then mp.command_native({"overlay-add", options.overlay_id, x, y, thumbnail_path..".bgra", 0, "bgra", w, h, (4*w), scale_w, scale_h}) @@ -780,6 +834,8 @@ local function thumb(time, r_x, r_y, script) if thumbnail_delta then -- TODO: do we need to do something special if the thumbnail doesn't exist? thumb_index = math.floor(time / thumbnail_delta) + atlas_index = math.ceil(thumb_index / thumb_count_per_storyboard) + prioritize_process(atlas_index) thumbnail_path = options.thumbnail .. ".ytdl-thumbx" .. tostring(thumb_index) end @@ -916,10 +972,10 @@ function output_name(idx, storyboard, atlas_idx) return options.thumbnail .. ".ytdl-thumbx" .. tostring(thumb_idx) .. ".bgra" end -local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, total, storyboard_scale) +local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, storyboard_scale) local atlas = io.open(atlas_path, "rb") local atlas_filesize = atlas:seek("end") - local atlas_pictures = math.floor(atlas_filesize / (4 * thumbnail_size.w * thumbnail_size.h)) --wrong number of thumbnails? + local atlas_pictures = math.floor(atlas_filesize / (4 * thumbnail_size.w * thumbnail_size.h)) local stride = 4 * thumbnail_size.w * math.min(storyboard.cols, atlas_pictures) for pic = 0, atlas_pictures-1 do local x_start = (pic % storyboard.cols) * thumbnail_size.w @@ -934,20 +990,15 @@ local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, tota thumb_file:write(data) end end - total = total + 1 thumb_file:close() - if atlas_idx == 1 then - mp.command_native({"overlay-add", pic, pic*60, pic*60, filename, 0, "bgra", thumbnail_size.w, thumbnail_size.h, (4*thumbnail_size.w)}) - end end end atlas:close() - return total + return end -local function fetch_fragment(storyboard, i, thumbnail_size, total, storyboard_scale) +local function fetch_fragment(storyboard, i, thumbnail_size, storyboard_scale) if not storyboard.fragments[i] then return end - print("spawn", i) local args = { mpv_path, storyboard.fragments[i].url, "--no-config", "--msg-level=all=no", "--really-quiet", "--no-terminal", "--vo=null", @@ -966,22 +1017,23 @@ local function fetch_fragment(storyboard, i, thumbnail_size, total, storyboard_s table.insert(args, "--macos-app-activation-policy=prohibited") end - subprocess(args, true, + spawn_queued_process(args, function(success, result) if success == false or result.status ~= 0 then - mp.msg.error("mpv thumbnail download failed") + mp.msg.error("thumbfast: storyboard download failed", i) else - total = get_thumb(options.thumbnail..".ytdl"..tostring(i), i, storyboard, thumbnail_size, total, storyboard_scale) + get_thumb(options.thumbnail..".ytdl"..tostring(i), i, storyboard, thumbnail_size, storyboard_scale) end end ) + -- TODO: set max n of active processes? I'm scared of spawning a lot of mpv processes when playing a 10 hour stream VOD... yeah this vid is 63 processes that's already too much https://www.youtube.com/watch?v=fKM2NYABFFc -- I need some sort of a queue function. -- create a list that will contain a representation of each subprocess call to be made -- when a user hovers on the timeline, look up on a copy of the table which value corresponds to the requested index. stick that value at the start of the real table, and deduplicate it. this lets us prioritize fragments the user wants to see. -- needs to be tested on a really long video, or with an artificially introduced delay, so we can check that the prioritization actually works. we also need to be sure that hovering on the timeline at all WORKS and lets us display thumbnails even though not all have been fetched yet. -- when a fragment has been fetched, it sets itself to nil in the list copy, and is removed from the processing pile. - fetch_fragment(storyboard, i+1, thumbnail_size, total, storyboard_scale) + fetch_fragment(storyboard, i+1, thumbnail_size, storyboard_scale) end local function setup_storyboards() @@ -1032,6 +1084,7 @@ local function setup_storyboards() storyboard.fragment_base_url = sb.fragment_base_url storyboard.rows = sb.rows or 5 storyboard.cols = sb.columns or 5 + thumb_count_per_storyboard = storyboard.rows * storyboard.cols if sb.fps then thumbnail_count = math.floor(sb.fps * sb.duration + 0.5) -- round @@ -1065,7 +1118,7 @@ local function setup_storyboards() thumbnail_delta = sb.duration / thumbnail_count - fetch_fragment(storyboard, 1, thumbnail_size, 0, storyboard_scale) + fetch_fragment(storyboard, 1, thumbnail_size, storyboard_scale) end end end) From a0be5a9f0080527e66d9422816b1f7d0c2824224 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 7 Apr 2025 02:48:49 +0200 Subject: [PATCH 09/28] queued process cancellation, skip regular thumbnailer, fix stuck thumbs --- thumbfast.lua | 83 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 50 insertions(+), 33 deletions(-) diff --git a/thumbfast.lua b/thumbfast.lua index 9ea9f20..d138dae 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -89,7 +89,7 @@ local max_processes = 5 local function spawn_one(args, callback) if active_processes < max_processes then active_processes = active_processes + 1 - subprocess(args, true, callback) + local abort = subprocess(args, true, callback) for i, process in ipairs(process_queue) do if process.args == args then table.remove(process_queue, i) @@ -98,7 +98,7 @@ local function spawn_one(args, callback) end for i, process in ipairs(all_processes) do if process.args == args then - all_processes[i] = nil + all_processes[i] = abort break end end @@ -124,17 +124,29 @@ end local function prioritize_process(atlas_index) local target_process = all_processes[atlas_index] - if not target_process then return end + if not target_process or not target_process.args then return end for i, process in ipairs(process_queue) do if process.args == target_process.args then - table.remove(process_queue, i) - table.insert(process_queue, 1, process) + if i ~= 1 then + table.remove(process_queue, i) + table.insert(process_queue, 1, process) + end break end end end +local function cancel_queued_processes() + for i, process in ipairs(all_processes) do + if process and not process.args then + mp.abort_async_command(process) + end + end + all_processes = {} + process_queue = {} +end + local winapi = {} if options.direct_io then local ffi_loaded, ffi = pcall(require, "ffi") @@ -195,8 +207,9 @@ local force_disabled = false local spawn_waiting = false local spawn_working = false local script_written = false -local thumbnail_delta = 1 +local thumbnail_delta = nil local thumb_count_per_storyboard = 1 +local storyboard_thumbnails = {} local dirty = false @@ -664,7 +677,7 @@ local function run(command) end local function draw(w, h, script) - if not w or not show_thumbnail then return end + if not w or not show_thumbnail or not thumbnail_path then return end if x ~= nil then local scale_w, scale_h = options.scale_factor ~= 1 and (w * options.scale_factor) or nil, options.scale_factor ~= 1 and (h * options.scale_factor) or nil if pre_0_30_0 then @@ -753,6 +766,7 @@ local function check_new_thumb() -- validity but before actually moving the file, so move to a temporary -- location before validity check to make sure everything stays consistant -- and valid thumbnails don't get overwritten by invalid ones + if not thumbnail_path then return end local tmp = thumbnail_path..".tmp" move_file(thumbnail_path, tmp) local finfo = mp.utils.file_info(tmp) @@ -832,15 +846,16 @@ local function thumb(time, r_x, r_y, script) end if thumbnail_delta then - -- TODO: do we need to do something special if the thumbnail doesn't exist? thumb_index = math.floor(time / thumbnail_delta) atlas_index = math.ceil(thumb_index / thumb_count_per_storyboard) prioritize_process(atlas_index) - thumbnail_path = options.thumbnail .. ".ytdl-thumbx" .. tostring(thumb_index) + if storyboard_thumbnails[thumb_index] then + thumbnail_path = storyboard_thumbnails[thumb_index] + end end script_name = script - if last_x ~= x or last_y ~= y or not show_thumbnail then + if last_x ~= x or last_y ~= y or not show_thumbnail or (thumbnail_delta and time ~= last_seek_time) then show_thumbnail = true last_x, last_y = x, y draw(real_w, real_h, script) @@ -855,7 +870,8 @@ local function thumb(time, r_x, r_y, script) if time == last_seek_time then return end last_seek_time = time - if not spawned then spawn(time) end -- TODO: skip when ytdl on? + if thumbnail_delta then return end -- TODO: better check for when storyboards are in use + if not spawned then spawn(time) end request_seek() if not file_timer:is_enabled() then file_timer:resume() end end @@ -967,10 +983,6 @@ local function sync_changes(prop, val) dirty = true end -function output_name(idx, storyboard, atlas_idx) - local thumb_idx = (atlas_idx - 1) * storyboard.cols * storyboard.rows + idx - return options.thumbnail .. ".ytdl-thumbx" .. tostring(thumb_idx) .. ".bgra" -end local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, storyboard_scale) local atlas = io.open(atlas_path, "rb") @@ -980,17 +992,23 @@ local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, stor for pic = 0, atlas_pictures-1 do local x_start = (pic % storyboard.cols) * thumbnail_size.w local y_start = math.floor(pic / storyboard.cols) * thumbnail_size.h - local filename = output_name(pic, storyboard, atlas_idx) - if filename ~= nil then - local thumb_file = io.open(filename, "wb") - for line = 0, thumbnail_size.h - 1 do - atlas:seek("set", 4 * x_start + (y_start + line) * stride) - local data = atlas:read(thumbnail_size.w * 4) - if data ~= nil then - thumb_file:write(data) - end + local thumb_idx = (atlas_idx - 1) * storyboard.cols * storyboard.rows + pic + local filename = options.thumbnail .. ".ytdl-thumbx" .. tostring(thumb_idx) + local thumb_file = io.open(filename .. ".bgra", "wb") + for line = 0, thumbnail_size.h - 1 do + atlas:seek("set", 4 * x_start + (y_start + line) * stride) + local data = atlas:read(thumbnail_size.w * 4) + if data ~= nil then + thumb_file:write(data) + end + end + thumb_file:close() + storyboard_thumbnails[thumb_idx] = filename + if last_seek_time then + local last_thumb_idx = math.floor(last_seek_time / thumbnail_delta) + if last_thumb_idx == thumb_idx then + last_seek_time = nil end - thumb_file:close() end end atlas:close() @@ -1020,19 +1038,15 @@ local function fetch_fragment(storyboard, i, thumbnail_size, storyboard_scale) spawn_queued_process(args, function(success, result) if success == false or result.status ~= 0 then - mp.msg.error("thumbfast: storyboard download failed", i) + if not result.killed_by_us then + mp.msg.error("thumbfast: storyboard download failed", "atlas:", i, "status:", result.status) + end else get_thumb(options.thumbnail..".ytdl"..tostring(i), i, storyboard, thumbnail_size, storyboard_scale) end end ) - -- TODO: set max n of active processes? I'm scared of spawning a lot of mpv processes when playing a 10 hour stream VOD... yeah this vid is 63 processes that's already too much https://www.youtube.com/watch?v=fKM2NYABFFc - -- I need some sort of a queue function. - -- create a list that will contain a representation of each subprocess call to be made - -- when a user hovers on the timeline, look up on a copy of the table which value corresponds to the requested index. stick that value at the start of the real table, and deduplicate it. this lets us prioritize fragments the user wants to see. - -- needs to be tested on a really long video, or with an artificially introduced delay, so we can check that the prioritization actually works. we also need to be sure that hovering on the timeline at all WORKS and lets us display thumbnails even though not all have been fetched yet. - -- when a fragment has been fetched, it sets itself to nil in the list copy, and is removed from the processing pile. fetch_fragment(storyboard, i+1, thumbnail_size, storyboard_scale) end @@ -1085,6 +1099,7 @@ local function setup_storyboards() storyboard.rows = sb.rows or 5 storyboard.cols = sb.columns or 5 thumb_count_per_storyboard = storyboard.rows * storyboard.cols + thumbnail_path = nil if sb.fps then thumbnail_count = math.floor(sb.fps * sb.duration + 0.5) -- round @@ -1139,7 +1154,9 @@ local function file_load() info_timer = nil end - if setup_storyboards() then return end + cancel_queued_processes() + + if setup_storyboards() then return end -- TODO calc_dimensions() info(effective_w, effective_h) From a1a6f18ffaf76523a0fef9cfaa3950cce7656dbd Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 7 Apr 2025 03:31:21 +0200 Subject: [PATCH 10/28] reset thumbnail_delta --- thumbfast.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/thumbfast.lua b/thumbfast.lua index d138dae..1e8e088 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -535,7 +535,7 @@ local function spawn(time) local vid = properties["vid"] has_vid = vid or 0 - -- TODO: add filtered ytdl-raw-options, especially for 'cookies' option + -- TODO: add filtered ytdl-raw-options, especially for 'cookies' option, and maybe 'extractor-args' too -- TODO: use native property for cookies and cookies-file?? local args = { @@ -1153,6 +1153,7 @@ local function file_load() info_timer:kill() info_timer = nil end + thumbnail_delta = nil cancel_queued_processes() From f01ca2336361c2d9a730016fe6c71ccceccada30 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 8 Apr 2025 05:08:58 +0200 Subject: [PATCH 11/28] twitch vod support, refactored site matching, find ytdl path --- thumbfast.lua | 219 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 177 insertions(+), 42 deletions(-) diff --git a/thumbfast.lua b/thumbfast.lua index 1e8e088..50386e8 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -106,7 +106,7 @@ local function spawn_one(args, callback) end local function spawn_queued_process(args, callback) - local function wrap_callback(args, callback) + local function wrap_callback(callback) return function(...) callback(...) active_processes = active_processes - 1 @@ -115,7 +115,7 @@ local function spawn_queued_process(args, callback) end end end - local wrapped_callback = wrap_callback(args, callback) + local wrapped_callback = wrap_callback(callback) local process = {args=args, callback=wrapped_callback} table.insert(process_queue, process) table.insert(all_processes, process) @@ -530,7 +530,6 @@ local function spawn(time) remove_thumbnail_files() remove_storyboard_files() thumbnail_path = options.thumbnail - -- are we spawning a real thumbnail process for yt vids? I hope not local vid = properties["vid"] has_vid = vid or 0 @@ -988,11 +987,11 @@ local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, stor local atlas = io.open(atlas_path, "rb") local atlas_filesize = atlas:seek("end") local atlas_pictures = math.floor(atlas_filesize / (4 * thumbnail_size.w * thumbnail_size.h)) - local stride = 4 * thumbnail_size.w * math.min(storyboard.cols, atlas_pictures) + local stride = 4 * thumbnail_size.w * math.min(storyboard.columns, atlas_pictures) for pic = 0, atlas_pictures-1 do - local x_start = (pic % storyboard.cols) * thumbnail_size.w - local y_start = math.floor(pic / storyboard.cols) * thumbnail_size.h - local thumb_idx = (atlas_idx - 1) * storyboard.cols * storyboard.rows + pic + local x_start = (pic % storyboard.columns) * thumbnail_size.w + local y_start = math.floor(pic / storyboard.columns) * thumbnail_size.h + local thumb_idx = (atlas_idx - 1) * storyboard.columns * storyboard.rows + pic local filename = options.thumbnail .. ".ytdl-thumbx" .. tostring(thumb_idx) local thumb_file = io.open(filename .. ".bgra", "wb") for line = 0, thumbnail_size.h - 1 do @@ -1036,7 +1035,7 @@ local function fetch_fragment(storyboard, i, thumbnail_size, storyboard_scale) end spawn_queued_process(args, - function(success, result) + function(success, result, err) if success == false or result.status ~= 0 then if not result.killed_by_us then mp.msg.error("thumbfast: storyboard download failed", "atlas:", i, "status:", result.status) @@ -1050,6 +1049,155 @@ local function fetch_fragment(storyboard, i, thumbnail_size, storyboard_scale) fetch_fragment(storyboard, i+1, thumbnail_size, storyboard_scale) end +local function anycase(s) + return string.gsub(s, "%a", function (c) + return string.format("[%s%s]", c:lower(), c:upper()) + end) +end + +local http_prefix = anycase("^https?://") +local ytdl_prefix = "^ytdl://(.+)" +local subdomains = "[%w-.]*" +local naked_ytdl_id = "^ytdl://([%w-_]+)$" +local youtube_id = "[%w-_]+.*" +local twitch_id = "%d+.*" +local ytdl_opts = {try_ytdl_first = false, ytdl_path = ""} +mp.options.read_options(ytdl_opts, "ytdl_hook") + +local youtube_patterns_free = { + -- youtube.com/watch?v=abcdef01234 or any invidious/piped site + anycase(".*/watch.*[?&]v=")..youtube_id, + + -- youtube.com/embed/abcdef01234 or any invidious/piped site + anycase(".*/embed/")..youtube_id, +} +local youtube_patterns = { + -- youtu.be/abcdef01234 + "^"..anycase("youtu%.be/")..youtube_id, + + -- youtube.com/v/abcdef01234 + "^"..subdomains..anycase("youtube%.com/[^/]+/")..youtube_id, +} +local twitch_base = subdomains..anycase("twitch%.tv/") +local twitch_patterns = { + -- twitch.tv/user/v/123456 + "^"..subdomains..anycase("twitch%.tv/[^/]+/v/")..twitch_id, + + -- twitch.tv/user/video/123456 + "^"..subdomains..anycase("twitch%.tv/[^/]+/video/")..twitch_id, + + -- twitch.tv/videos/123456 + "^"..subdomains..anycase("twitch%.tv/videos/")..twitch_id, + + -- twitch.tv/user/schedule?vodID=123456 + "^"..subdomains..anycase("twitch%.tv/[^/]+/schedule%?vodID=")..twitch_id, + + -- player.twitch.tv/?video=v123456 or player.twitch.tv/?video=123456 + "^"..anycase("player%.twitch%.tv/.*[?&]video=v?")..twitch_id, +} + +local function storyboard_supported_url(path, referer) + local video_url = string.match(path, naked_ytdl_id) or string.match(referer, naked_ytdl_id) + if video_url then + return video_url + end + + path_ytdl, path_has_ytdl_prefix = string.gsub(path, ytdl_prefix, "%1") + path, path_has_http_prefix = string.gsub(path_ytdl, http_prefix, "") + path_has_prefix = path_has_ytdl_prefix or path_has_http_prefix or ytdl_opts.try_ytdl_first + + referer_ytdl, referer_has_ytdl_prefix = string.gsub(referer, ytdl_prefix, "%1") + referer, referer_has_http_prefix = string.gsub(referer_ytdl, http_prefix, "") + referer_has_prefix = referer ~= "" and (referer_has_ytdl_prefix or referer_has_http_prefix or ytdl_opts.try_ytdl_first) + + local checks = { + {input = path_ytdl, patterns = youtube_patterns_free, condition = function(input) return true end}, + {input = path, patterns = youtube_patterns, condition = function(input) return path_has_prefix end}, + {input = path, patterns = twitch_patterns, condition = function(input) return path_has_prefix and string.match(input, twitch_base) end}, + {input = referer_ytdl, patterns = youtube_patterns_free, condition = function(input) return input ~= "" end}, + {input = referer, patterns = youtube_patterns, condition = function(input) return referer_has_prefix end}, + {input = referer, patterns = twitch_patterns, condition = function(input) return referer_has_prefix and string.match(input, twitch_base) end}, + } + + for _, check in ipairs(checks) do + if check.condition(check.input) then + for _, pattern in ipairs(check.patterns) do + video_url = string.match(check.input, pattern) + if video_url then + return video_url + end + end + end + end +end + +local ytdl_paths_to_search = {"yt-dlp", "yt-dlp_x86", "youtube-dl"} +local ytdl_path = nil +local function find_ytdl_path() + if ytdl_path ~= nil then return ytdl_path end + ytdl_path = mp.get_property_native("user-data/mpv/ytdl/path") + if ytdl_path == "" then + -- TODO: logging + ytdl_path = false + end + if ytdl_path ~= nil then + return ytdl_path + end + + -- logic from ytdl_hook.lua for mpv = #ytdl_paths_to_search then + -- TODO: logging + ytdl_path = false + return + end + args[1] = ytdl_paths_to_search[ytdl_path] + callback = wrap_callback(callback) + end + return subprocess(args, async, callback) +end + local function setup_storyboards() if not options.network then return end @@ -1063,46 +1211,31 @@ local function setup_storyboards() remove_thumbnail_files() remove_storyboard_files() - -- TODO: support more than just youtube... this should also work out of the box for twitch vods? local referer = string.match(properties["http-header-fields"] or "", "Referer:([^,]+)") or "" -- TODO: use native property here - -- TODO: youtube shorts pattern -- it may be possible to run the subprocess synchronously so that we can let yt-dlp decide if storyboards are supported at all??? I think this is the best option. I think I may have to call info() with 0 dimensions to make the thumbnail get disabled in the meantime tho. - -- something similar to check_new_thumb() may be needed for when we're writing the rgba files? most likely not though, since it's happening in the lua main loop so everything should be done writing already when we reach overlay-add - local urls = { - "^ytdl://([%w-_]+)", - "^https?://youtu%.be/([%w-_]+)", - "^https?://w?w?w?%.?youtube%.com/v/([%w-_]+)", - "/watch.*[?&]v=([%w-_]+)", - "/embed/([%w-_]+)" - } - local youtube_id = nil - for i, url in ipairs(urls) do - youtube_id = youtube_id or string.match(path, url) or string.match(referer, url) - if youtube_id then break end - end - if youtube_id and string.len(youtube_id) >= 11 then - youtube_id = string.sub(youtube_id, 1, 11) - -- TODO: find yt-dlp path - local sb_cmd = {"yt-dlp", "--format", "sb0", "--dump-json", "--no-playlist", + local video_url = storyboard_supported_url(path, referer) + + if video_url then + find_ytdl_path() + if not ytdl_path then return end + + local sb_cmd = {ytdl_path, "--format", "sb0", "--dump-json", "--no-playlist", "--extractor-args", "youtube:skip=hls,dash,translated_subs", -- yt speedup - "--", "https://www.youtube.com/watch?v="..youtube_id} + "--", path} - subprocess(sb_cmd, true, function(success, sb_json) + ytdl_subprocess(sb_cmd, true, function(success, sb_json, err) if success and sb_json.status == 0 then local sb = mp.utils.parse_json(sb_json.stdout) if sb ~= nil and sb.duration and sb.width and sb.height and sb.fragments and #sb.fragments > 0 then - local storyboard = {} local thumbnail_count = 0 - storyboard.fragments = sb.fragments - storyboard.fragment_base_url = sb.fragment_base_url - storyboard.rows = sb.rows or 5 - storyboard.cols = sb.columns or 5 - thumb_count_per_storyboard = storyboard.rows * storyboard.cols + sb.rows = sb.rows or 5 + sb.columns = sb.columns or 5 + thumb_count_per_storyboard = sb.rows * sb.columns thumbnail_path = nil if sb.fps then - thumbnail_count = math.floor(sb.fps * sb.duration + 0.5) -- round + thumbnail_count = math.floor(sb.fps * sb.duration + 0.5) -- hack: youtube always adds 1 black frame at the end... --is this even true? if sb.extractor == "youtube" then thumbnail_count = thumbnail_count - 1 @@ -1110,13 +1243,11 @@ local function setup_storyboards() else -- estimate the count of thumbnails -- assume first atlas is always full - thumbnail_delta = sb.fragments[1].duration / (storyboard.rows * storyboard.cols) + thumbnail_delta = sb.fragments[1].duration / (sb.rows * sb.columns) thumbnail_count = math.floor(sb.duration / thumbnail_delta) end -- Storyboard upscaling factor - -- TODO: shouldn't we set effective_w and effective_h here? that's what calc_dimensions does... look into it. - -- we need to run info() local scale = properties["display-hidpi-scale"] or 1 if sb.width / sb.height > options.max_width / options.max_height then real_w = math.floor(options.max_width * scale + 0.5) @@ -1127,17 +1258,20 @@ local function setup_storyboards() end local storyboard_scale = {w=real_w/sb.width, h=real_h/sb.height} local thumbnail_size = {w=real_w, h=real_h} + effective_w, effective_h = real_w, real_h info(real_w, real_h) - storyboard.scale = scale + sb.scale = scale thumbnail_delta = sb.duration / thumbnail_count - fetch_fragment(storyboard, 1, thumbnail_size, storyboard_scale) + fetch_fragment(sb, 1, thumbnail_size, storyboard_scale) + return end end + -- TODO: we need to fall back to regular thumbnailing if we reach this point end) - -- we are in a state where we decided yeah let's use the storyboards. + -- we are in a state where we decided yeah let's try storyboards return true end end @@ -1203,6 +1337,7 @@ mp.observe_property("http-header-fields", "string", update_property) mp.observe_property("cookies", "string", update_property) mp.observe_property("cookies-file", "string", update_property) mp.observe_property("stream-open-filename", "native", update_property) +mp.observe_property("user-data/mpv/ytdl/json-subprocess-result", "native", update_property) mp.observe_property("macos-app-activation-policy", "native", update_property) mp.observe_property("current-vo", "native", update_property) mp.observe_property("video-rotate", "native", update_property) From ee15d986d6d72ec999e6383d90640d7ab4df4052 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 8 Apr 2025 05:10:16 +0200 Subject: [PATCH 12/28] observe ytdl path --- thumbfast.lua | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/thumbfast.lua b/thumbfast.lua index 50386e8..a8327b5 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -1135,7 +1135,7 @@ local ytdl_paths_to_search = {"yt-dlp", "yt-dlp_x86", "youtube-dl"} local ytdl_path = nil local function find_ytdl_path() if ytdl_path ~= nil then return ytdl_path end - ytdl_path = mp.get_property_native("user-data/mpv/ytdl/path") + ytdl_path = properties["user-data/mpv/ytdl/path"] if ytdl_path == "" then -- TODO: logging ytdl_path = false @@ -1337,6 +1337,7 @@ mp.observe_property("http-header-fields", "string", update_property) mp.observe_property("cookies", "string", update_property) mp.observe_property("cookies-file", "string", update_property) mp.observe_property("stream-open-filename", "native", update_property) +mp.observe_property("user-data/mpv/ytdl/path", "native", update_property) mp.observe_property("user-data/mpv/ytdl/json-subprocess-result", "native", update_property) mp.observe_property("macos-app-activation-policy", "native", update_property) mp.observe_property("current-vo", "native", update_property) From 9d757054584bfdf5b056ebf8a04c8aeb9ac94205 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 8 Apr 2025 05:27:48 +0200 Subject: [PATCH 13/28] cancellable ytdl storyboard json process and safety checks --- thumbfast.lua | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/thumbfast.lua b/thumbfast.lua index a8327b5..5f54d93 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -85,6 +85,7 @@ local all_processes = {} local process_queue = {} local active_processes = 0 local max_processes = 5 +local ytdl_subprocess_cancel = nil local function spawn_one(args, callback) if active_processes < max_processes then @@ -145,6 +146,10 @@ local function cancel_queued_processes() end all_processes = {} process_queue = {} + if ytdl_subprocess_cancel ~= nil then + ytdl_subprocess_cancel = nil + mp.abort_async_command(ytdl_subprocess_cancel) + end end local winapi = {} @@ -593,7 +598,7 @@ local function spawn(time) subprocess(args, true, function(success, result) - if spawn_waiting and (success == false or (result.status ~= 0 and result.status ~= -2)) then + if spawn_waiting and (success == false or not result or (result.status ~= 0 and result.status ~= -2)) then spawned = false spawn_waiting = false options.tone_mapping = "no" @@ -625,7 +630,7 @@ local function spawn(time) mp.commandv("script-message-to", "implay", "show-message", "thumbfast", "Set mpv_path=PATH_TO_ImPlay in thumbfast config:\n" .. string.gsub(mp.command_native({"expand-path", "~~/script-opts/thumbfast.conf"}), "[/\\]", path_separator).."\nand restart ImPlay") end end - elseif success == true and (result.status == 0 or result.status == -2) then + elseif success == true and result and (result.status == 0 or result.status == -2) then if not spawn_working and properties["current-vo"] == "libmpv" and options.mpv_path ~= mpv_path then mp.commandv("script-message-to", "implay", "show-message", "thumbfast initial setup", "Set mpv_path=ImPlay in thumbfast config:\n" .. string.gsub(mp.command_native({"expand-path", "~~/script-opts/thumbfast.conf"}), "[/\\]", path_separator).."\nand restart ImPlay") end @@ -1036,7 +1041,7 @@ local function fetch_fragment(storyboard, i, thumbnail_size, storyboard_scale) spawn_queued_process(args, function(success, result, err) - if success == false or result.status ~= 0 then + if success == false or (not result or result.status ~= 0) then if not result.killed_by_us then mp.msg.error("thumbfast: storyboard download failed", "atlas:", i, "status:", result.status) end @@ -1134,15 +1139,15 @@ end local ytdl_paths_to_search = {"yt-dlp", "yt-dlp_x86", "youtube-dl"} local ytdl_path = nil local function find_ytdl_path() - if ytdl_path ~= nil then return ytdl_path end + if ytdl_path ~= nil then return end + ytdl_path = properties["user-data/mpv/ytdl/path"] if ytdl_path == "" then -- TODO: logging ytdl_path = false end - if ytdl_path ~= nil then - return ytdl_path - end + + if ytdl_path ~= nil then return end -- logic from ytdl_hook.lua for mpv Date: Tue, 8 Apr 2025 06:48:52 +0200 Subject: [PATCH 14/28] storyboard file deletion, regular thumbnailing fallback, hide stderr --- thumbfast.lua | 41 +++++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/thumbfast.lua b/thumbfast.lua index 5f54d93..a05294c 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -68,9 +68,9 @@ function subprocess(args, async, callback) if not pre_0_30_0 then if async then - return mp.command_native_async({name = "subprocess", playback_only = true, capture_stdout=true, args = args, env = "PATH="..os.getenv("PATH")}, callback) + return mp.command_native_async({name = "subprocess", playback_only = true, capture_stdout = true, capture_stderr = true, args = args, env = "PATH="..os.getenv("PATH")}, callback) else - return mp.command_native({name = "subprocess", playback_only = false, capture_stdout = true, args = args, env = "PATH="..os.getenv("PATH")}) + return mp.command_native({name = "subprocess", playback_only = false, capture_stdout = true, capture_stderr = true, args = args, env = "PATH="..os.getenv("PATH")}) end else if async then @@ -147,8 +147,8 @@ local function cancel_queued_processes() all_processes = {} process_queue = {} if ytdl_subprocess_cancel ~= nil then - ytdl_subprocess_cancel = nil mp.abort_async_command(ytdl_subprocess_cancel) + ytdl_subprocess_cancel = nil end end @@ -503,12 +503,20 @@ local function remove_thumbnail_files() file = nil file_bytes = 0 end - os.remove(thumbnail_path) - os.remove(thumbnail_path..".bgra") + os.remove(options.thumbnail) + os.remove(options.thumbnail..".bgra") end local function remove_storyboard_files() - -- TODO + local atlas = 0 + for thumb_index, thumb_filename in pairs(storyboard_thumbnails) do + atlas_index = math.ceil(thumb_index / thumb_count_per_storyboard) + if atlas_index > atlas then + atlas = atlas_index + os.remove(options.thumbnail..".ytdl"..tostring(atlas_index)) + end + os.remove(thumb_filename..".bgra") + end end local activity_timer @@ -1025,6 +1033,7 @@ local function fetch_fragment(storyboard, i, thumbnail_size, storyboard_scale) local args = { mpv_path, storyboard.fragments[i].url, "--no-config", "--msg-level=all=no", "--really-quiet", "--no-terminal", "--vo=null", "--frames=1", + --"--load-scripts=no", "--osc=no", "--ytdl=no", "--load-stats-overlay=no", "--load-osd-console=no", "--load-auto-profiles=no", "--no-sub", "--no-audio", "--hr-seek=no", "--sub-font-provider=none", "--embeddedfonts=no", "--no-ytdl", "--demuxer-readahead-secs=0", "--demuxer-max-bytes=128KiB", "--vd-lavc-software-fallback=1", "--vd-lavc-fast", "--vd-lavc-threads=2", --"--hwdec="..(options.hwdec and "auto" or "no"), @@ -1277,14 +1286,17 @@ local function setup_storyboards() return end end - -- TODO: we need to fall back to regular thumbnailing if we reach this point + + -- fall back to regular thumbnailing + file_load1() + file_load2() end) -- we are in a state where we decided yeah let's try storyboards return true end end -local function file_load() +function file_load1() clear() spawned = false real_w, real_h = nil, nil @@ -1296,11 +1308,12 @@ local function file_load() info_timer = nil end thumbnail_delta = nil + thumbnail_path = nil cancel_queued_processes() +end - if setup_storyboards() then return end -- TODO - +function file_load2() calc_dimensions() info(effective_w, effective_h) if disabled then return end @@ -1312,6 +1325,14 @@ local function file_load() end end +local function file_load() + file_load1() + + if setup_storyboards() then return end + + file_load2() +end + local function shutdown() run("quit") remove_thumbnail_files() From 3008911983a735df1609ff9ae0cca9fbad03697b Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 8 Apr 2025 07:03:35 +0200 Subject: [PATCH 15/28] proper storyboard usage check --- thumbfast.lua | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/thumbfast.lua b/thumbfast.lua index a05294c..d217b06 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -212,6 +212,7 @@ local force_disabled = false local spawn_waiting = false local spawn_working = false local script_written = false +local using_storyboards = false local thumbnail_delta = nil local thumb_count_per_storyboard = 1 local storyboard_thumbnails = {} @@ -857,7 +858,7 @@ local function thumb(time, r_x, r_y, script) x, y = math.floor(r_x + 0.5), math.floor(r_y + 0.5) end - if thumbnail_delta then + if using_storyboards and thumbnail_delta then thumb_index = math.floor(time / thumbnail_delta) atlas_index = math.ceil(thumb_index / thumb_count_per_storyboard) prioritize_process(atlas_index) @@ -867,7 +868,7 @@ local function thumb(time, r_x, r_y, script) end script_name = script - if last_x ~= x or last_y ~= y or not show_thumbnail or (thumbnail_delta and time ~= last_seek_time) then + if last_x ~= x or last_y ~= y or not show_thumbnail or (using_storyboards and thumbnail_delta and time ~= last_seek_time) then show_thumbnail = true last_x, last_y = x, y draw(real_w, real_h, script) @@ -882,7 +883,7 @@ local function thumb(time, r_x, r_y, script) if time == last_seek_time then return end last_seek_time = time - if thumbnail_delta then return end -- TODO: better check for when storyboards are in use + if using_storyboards then return end if not spawned then spawn(time) end request_seek() if not file_timer:is_enabled() then file_timer:resume() end @@ -1237,6 +1238,8 @@ local function setup_storyboards() find_ytdl_path() if not ytdl_path then return end + using_storyboards = true + local sb_cmd = {ytdl_path, "--format", "sb0", "--dump-json", "--no-playlist", "--extractor-args", "youtube:skip=hls,dash,translated_subs", -- yt speedup "--", path} @@ -1307,6 +1310,7 @@ function file_load1() info_timer:kill() info_timer = nil end + using_storyboards = false thumbnail_delta = nil thumbnail_path = nil From e9536173b18a9e1a43e753d1a3b0033fe8697293 Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 8 Apr 2025 08:38:40 +0200 Subject: [PATCH 16/28] recalculate storyboard thumbnail width --- thumbfast.lua | 34 +++++++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/thumbfast.lua b/thumbfast.lua index d217b06..50dc168 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -216,6 +216,7 @@ local using_storyboards = false local thumbnail_delta = nil local thumb_count_per_storyboard = 1 local storyboard_thumbnails = {} +local real_storyboard_w = nil local dirty = false @@ -996,21 +997,38 @@ local function sync_changes(prop, val) dirty = true end - local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, storyboard_scale) local atlas = io.open(atlas_path, "rb") local atlas_filesize = atlas:seek("end") - local atlas_pictures = math.floor(atlas_filesize / (4 * thumbnail_size.w * thumbnail_size.h)) - local stride = 4 * thumbnail_size.w * math.min(storyboard.columns, atlas_pictures) + local advertised_width = thumbnail_size.w + if real_storyboard_w == nil then + -- youtube sometimes reports an incorrectly rounded width, recalculate it from the height + local total_pixels = atlas_filesize / 4 / storyboard_scale.h + local num_thumbnails = storyboard.columns * storyboard.rows + local thumb_area = total_pixels / num_thumbnails + local thumb_og_width = math.floor(thumb_area / thumbnail_size.h + 0.5) + real_storyboard_w = math.floor(thumb_og_width * storyboard_scale.w + 0.5) + -- check accuracy of new value + local recalculated_height = math.floor(atlas_filesize / 4 / storyboard.columns / storyboard.rows / real_storyboard_w + 0.5) + if recalculated_height == thumbnail_size.h then + real_w = real_storyboard_w + effective_w = real_w + info(real_w, real_h) + else + real_storyboard_w = thumbnail_size.w + end + end + local atlas_pictures = math.floor(atlas_filesize / (4 * real_storyboard_w * thumbnail_size.h)) + local stride = 4 * (real_storyboard_w * math.min(storyboard.columns, atlas_pictures) + real_storyboard_w - thumbnail_size.w) for pic = 0, atlas_pictures-1 do - local x_start = (pic % storyboard.columns) * thumbnail_size.w + local x_start = (pic % storyboard.columns) * real_storyboard_w local y_start = math.floor(pic / storyboard.columns) * thumbnail_size.h local thumb_idx = (atlas_idx - 1) * storyboard.columns * storyboard.rows + pic local filename = options.thumbnail .. ".ytdl-thumbx" .. tostring(thumb_idx) local thumb_file = io.open(filename .. ".bgra", "wb") for line = 0, thumbnail_size.h - 1 do atlas:seek("set", 4 * x_start + (y_start + line) * stride) - local data = atlas:read(thumbnail_size.w * 4) + local data = atlas:read(real_storyboard_w * 4) if data ~= nil then thumb_file:write(data) end @@ -1025,7 +1043,6 @@ local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, stor end end atlas:close() - return end local function fetch_fragment(storyboard, i, thumbnail_size, storyboard_scale) @@ -1277,12 +1294,10 @@ local function setup_storyboards() real_w = math.floor(sb.width / sb.height * real_h + 0.5) end local storyboard_scale = {w=real_w/sb.width, h=real_h/sb.height} - local thumbnail_size = {w=real_w, h=real_h} + local thumbnail_size = {w=real_w, h=real_h, ow=sb.width, oh=sb.width} effective_w, effective_h = real_w, real_h info(real_w, real_h) - sb.scale = scale - thumbnail_delta = sb.duration / thumbnail_count fetch_fragment(sb, 1, thumbnail_size, storyboard_scale) @@ -1303,6 +1318,7 @@ function file_load1() clear() spawned = false real_w, real_h = nil, nil + real_storyboard_w = nil last_real_w, last_real_h = nil, nil last_tone_mapping = nil last_seek_time = nil From 05d55985387ffc08494d858583e61ba6a286da0b Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 8 Apr 2025 09:58:26 +0200 Subject: [PATCH 17/28] exit out of watch_changes --- thumbfast.lua | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/thumbfast.lua b/thumbfast.lua index 50dc168..5ea98ad 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -894,6 +894,11 @@ local function watch_changes() if not dirty or not properties["video-out-params"] then return end dirty = false + if using_storyboards then + -- TODO: handle rotation, flipping, cropping + return + end + local old_w = effective_w local old_h = effective_h From 92cb06759193635bde1f84346ab77d066a07b27a Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 9 Apr 2025 15:07:34 +0200 Subject: [PATCH 18/28] remove vo=null --- thumbfast.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/thumbfast.lua b/thumbfast.lua index 5ea98ad..cfb1fc3 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -1054,7 +1054,7 @@ local function fetch_fragment(storyboard, i, thumbnail_size, storyboard_scale) if not storyboard.fragments[i] then return end local args = { - mpv_path, storyboard.fragments[i].url, "--no-config", "--msg-level=all=no", "--really-quiet", "--no-terminal", "--vo=null", + mpv_path, storyboard.fragments[i].url, "--no-config", "--msg-level=all=no", "--really-quiet", "--no-terminal", "--frames=1", --"--load-scripts=no", "--osc=no", "--ytdl=no", "--load-stats-overlay=no", "--load-osd-console=no", "--load-auto-profiles=no", "--no-sub", "--no-audio", "--hr-seek=no", "--sub-font-provider=none", "--embeddedfonts=no", From 6d1fc080e35b98f73787ea4d2d16b1ca755eaf1b Mon Sep 17 00:00:00 2001 From: Eva Date: Fri, 11 Apr 2025 15:46:25 +0200 Subject: [PATCH 19/28] proper fix for miscalculated storyboard tile dimensions --- thumbfast.lua | 42 ++++++++++++------------------------------ 1 file changed, 12 insertions(+), 30 deletions(-) diff --git a/thumbfast.lua b/thumbfast.lua index cfb1fc3..6ea302b 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -216,7 +216,6 @@ local using_storyboards = false local thumbnail_delta = nil local thumb_count_per_storyboard = 1 local storyboard_thumbnails = {} -local real_storyboard_w = nil local dirty = false @@ -1005,35 +1004,17 @@ end local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, storyboard_scale) local atlas = io.open(atlas_path, "rb") local atlas_filesize = atlas:seek("end") - local advertised_width = thumbnail_size.w - if real_storyboard_w == nil then - -- youtube sometimes reports an incorrectly rounded width, recalculate it from the height - local total_pixels = atlas_filesize / 4 / storyboard_scale.h - local num_thumbnails = storyboard.columns * storyboard.rows - local thumb_area = total_pixels / num_thumbnails - local thumb_og_width = math.floor(thumb_area / thumbnail_size.h + 0.5) - real_storyboard_w = math.floor(thumb_og_width * storyboard_scale.w + 0.5) - -- check accuracy of new value - local recalculated_height = math.floor(atlas_filesize / 4 / storyboard.columns / storyboard.rows / real_storyboard_w + 0.5) - if recalculated_height == thumbnail_size.h then - real_w = real_storyboard_w - effective_w = real_w - info(real_w, real_h) - else - real_storyboard_w = thumbnail_size.w - end - end - local atlas_pictures = math.floor(atlas_filesize / (4 * real_storyboard_w * thumbnail_size.h)) - local stride = 4 * (real_storyboard_w * math.min(storyboard.columns, atlas_pictures) + real_storyboard_w - thumbnail_size.w) + local atlas_pictures = math.floor(atlas_filesize / (4 * thumbnail_size.w * thumbnail_size.h) + 0.5) + local stride = 4 * (thumbnail_size.w * math.min(storyboard.columns, atlas_pictures)) for pic = 0, atlas_pictures-1 do - local x_start = (pic % storyboard.columns) * real_storyboard_w + local x_start = (pic % storyboard.columns) * thumbnail_size.w local y_start = math.floor(pic / storyboard.columns) * thumbnail_size.h local thumb_idx = (atlas_idx - 1) * storyboard.columns * storyboard.rows + pic local filename = options.thumbnail .. ".ytdl-thumbx" .. tostring(thumb_idx) local thumb_file = io.open(filename .. ".bgra", "wb") for line = 0, thumbnail_size.h - 1 do atlas:seek("set", 4 * x_start + (y_start + line) * stride) - local data = atlas:read(real_storyboard_w * 4) + local data = atlas:read(thumbnail_size.w * 4) if data ~= nil then thumb_file:write(data) end @@ -1063,7 +1044,7 @@ local function fetch_fragment(storyboard, i, thumbnail_size, storyboard_scale) --"--vf="..vf_string(filters_all, true), "--sws-allow-zimg=no", "--sws-fast=yes", "--sws-scaler=fast-bilinear", --"--video-rotate="..last_rotate, - "--vf-add=format=bgra,scale=round(iw*"..storyboard_scale.w.."):round(ih*"..storyboard_scale.h..")", + "--vf-add=format=bgra,scale=round(iw*"..storyboard_scale.w.."/"..thumbnail_size.w..")*"..thumbnail_size.w..":round(ih*"..storyboard_scale.h.."/"..thumbnail_size.h..")*"..thumbnail_size.h, "--ovc=rawvideo", "--of=rawvideo", "--ofopts=update=1", "--o="..options.thumbnail..".ytdl"..tostring(i) } @@ -1292,14 +1273,16 @@ local function setup_storyboards() -- Storyboard upscaling factor local scale = properties["display-hidpi-scale"] or 1 if sb.width / sb.height > options.max_width / options.max_height then - real_w = math.floor(options.max_width * scale + 0.5) - real_h = math.floor(sb.height / sb.width * real_w + 0.5) + real_w = options.max_width * scale + real_h = math.floor(sb.height / sb.width * real_w) + real_w = math.floor(real_w) else - real_h = math.floor(options.max_height * scale + 0.5) - real_w = math.floor(sb.width / sb.height * real_h + 0.5) + real_h = options.max_height * scale + real_w = math.floor(sb.width / sb.height * real_h) + real_h = math.floor(real_h) end local storyboard_scale = {w=real_w/sb.width, h=real_h/sb.height} - local thumbnail_size = {w=real_w, h=real_h, ow=sb.width, oh=sb.width} + local thumbnail_size = {w=real_w, h=real_h} effective_w, effective_h = real_w, real_h info(real_w, real_h) @@ -1323,7 +1306,6 @@ function file_load1() clear() spawned = false real_w, real_h = nil, nil - real_storyboard_w = nil last_real_w, last_real_h = nil, nil last_tone_mapping = nil last_seek_time = nil From b937292005f716b5ceb4334604d3bf7fbf8fcb0e Mon Sep 17 00:00:00 2001 From: Eva Date: Fri, 11 Apr 2025 16:35:45 +0200 Subject: [PATCH 20/28] re-enable ytdl format selection for regular network thumbnailing --- thumbfast.lua | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/thumbfast.lua b/thumbfast.lua index 6ea302b..a675307 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -535,12 +535,6 @@ local function spawn(time) activity_timer:resume() end - local open_filename = properties["stream-open-filename"] - local forced_path = open_filename and path ~= open_filename - if forced_path then - path = open_filename - end - remove_thumbnail_files() remove_storyboard_files() thumbnail_path = options.thumbnail @@ -553,7 +547,7 @@ local function spawn(time) local args = { mpv_path, "--no-config", "--msg-level=all=no", "--idle", "--pause", "--keep-open=always", "--really-quiet", "--no-terminal", - "--load-scripts=no", "--osc=no", "--ytdl=no", "--load-stats-overlay=no", "--load-osd-console=no", "--load-auto-profiles=no", + "--load-scripts=no", "--osc=no", "--load-stats-overlay=no", "--load-osd-console=no", "--load-auto-profiles=no", "--edition="..(properties["edition"] or "auto"), "--vid="..(vid or "auto"), "--no-sub", "--no-audio", "--start="..time, allow_fast_seek and "--hr-seek=no" or "--hr-seek=yes", "--ytdl-format=worst", "--demuxer-readahead-secs=0", "--demuxer-max-bytes=128KiB", From c49a77d4391c3e37321eb7aef8e63f02262d68e8 Mon Sep 17 00:00:00 2001 From: Eva Date: Fri, 11 Apr 2025 17:23:12 +0200 Subject: [PATCH 21/28] use ytdl_hook json when available --- thumbfast.lua | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/thumbfast.lua b/thumbfast.lua index a675307..ffb1b77 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -1209,6 +1209,12 @@ local function ytdl_subprocess(args, async, cb) end args[1] = ytdl_paths_to_search[ytdl_path] callback = wrap_callback(callback) + else + local ytdl_hook_subprocess = properties["user-data/mpv/ytdl/json-subprocess-result"] + if ytdl_hook_subprocess ~= nil then + callback(true, ytdl_hook_subprocess, nil) + return + end end ytdl_subprocess_cancel = subprocess(args, async, callback) end @@ -1227,7 +1233,6 @@ local function setup_storyboards() remove_storyboard_files() local referer = string.match(properties["http-header-fields"] or "", "Referer:([^,]+)") or "" -- TODO: use native property here - -- it may be possible to run the subprocess synchronously so that we can let yt-dlp decide if storyboards are supported at all??? I think this is the best option. I think I may have to call info() with 0 dimensions to make the thumbnail get disabled in the meantime tho. local video_url = storyboard_supported_url(path, referer) @@ -1243,8 +1248,10 @@ local function setup_storyboards() ytdl_subprocess(sb_cmd, true, function(success, sb_json, err) if success and sb_json.status == 0 then - local sb = mp.utils.parse_json(sb_json.stdout) - if sb ~= nil and sb.duration and sb.width and sb.height and sb.fragments and #sb.fragments > 0 then + local sb_j = mp.utils.parse_json(sb_json.stdout) + if sb_j and sb_j.formats then + for _, sb in ipairs(sb_j.formats) do + if sb and sb.format_id == "sb0" and sb_j.duration and sb.width and sb.height and sb.fragments and #sb.fragments > 0 then local thumbnail_count = 0 sb.rows = sb.rows or 5 sb.columns = sb.columns or 5 @@ -1252,7 +1259,7 @@ local function setup_storyboards() thumbnail_path = nil if sb.fps then - thumbnail_count = math.floor(sb.fps * sb.duration + 0.5) + thumbnail_count = math.floor(sb.fps * sb_j.duration + 0.5) -- hack: youtube always adds 1 black frame at the end... --is this even true? if sb.extractor == "youtube" then thumbnail_count = thumbnail_count - 1 @@ -1261,7 +1268,7 @@ local function setup_storyboards() -- estimate the count of thumbnails -- assume first atlas is always full thumbnail_delta = sb.fragments[1].duration / (sb.rows * sb.columns) - thumbnail_count = math.floor(sb.duration / thumbnail_delta) + thumbnail_count = math.floor(sb_j.duration / thumbnail_delta) end -- Storyboard upscaling factor @@ -1280,11 +1287,13 @@ local function setup_storyboards() effective_w, effective_h = real_w, real_h info(real_w, real_h) - thumbnail_delta = sb.duration / thumbnail_count + thumbnail_delta = sb_j.duration / thumbnail_count fetch_fragment(sb, 1, thumbnail_size, storyboard_scale) return end + end + end end -- fall back to regular thumbnailing From 637fbd3d034689a2e49cad10ecff90e64e122aa8 Mon Sep 17 00:00:00 2001 From: Eva Date: Fri, 11 Apr 2025 17:28:47 +0200 Subject: [PATCH 22/28] use extracted ytdl-compatible url --- thumbfast.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/thumbfast.lua b/thumbfast.lua index ffb1b77..5faa519 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -1244,7 +1244,7 @@ local function setup_storyboards() local sb_cmd = {ytdl_path, "--format", "sb0", "--dump-json", "--no-playlist", "--extractor-args", "youtube:skip=hls,dash,translated_subs", -- yt speedup - "--", path} + "--", video_url} ytdl_subprocess(sb_cmd, true, function(success, sb_json, err) if success and sb_json.status == 0 then From e71d9f6b3cfe2d99fa49e8cd48dd6344f89543b7 Mon Sep 17 00:00:00 2001 From: Eva Date: Fri, 11 Apr 2025 17:49:53 +0200 Subject: [PATCH 23/28] fix ytdl process fallback and make it work in new mpv versions --- thumbfast.lua | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/thumbfast.lua b/thumbfast.lua index 5faa519..530ce3d 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -1181,23 +1181,23 @@ end local function ytdl_subprocess(args, async, cb) local callback = cb - -- TODO: if available, check if properties["user-data/mpv/ytdl/json-subprocess-result"] has everything we need AND matches the current file. if yes, then call our callback prematurely. local function wrap_callback(callback) return function(success, result, err) - callback(success, result, err) if result and result.killed_by_us then ytdl_path = ytdl_path - 1 return end - if err == "init" then + if err == "init" or (result and result.error_string == "init") then ytdl_subprocess(args, async, cb) - elseif err ~= nil or not success then + return + elseif (err or "") ~= "" or not success then -- TODO: logging ytdl_path = false else -- we found ytdl ytdl_path = args[1] end + callback(success, result, err) end end if type(ytdl_path) == "number" then @@ -1205,6 +1205,7 @@ local function ytdl_subprocess(args, async, cb) if ytdl_path >= #ytdl_paths_to_search then -- TODO: logging ytdl_path = false + callback(false, nil, nil) return end args[1] = ytdl_paths_to_search[ytdl_path] From 3c7598aff9ff1957924966ff3f82e77869501b89 Mon Sep 17 00:00:00 2001 From: Eva Date: Fri, 11 Apr 2025 18:07:14 +0200 Subject: [PATCH 24/28] use closest available thumbnail index --- thumbfast.lua | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/thumbfast.lua b/thumbfast.lua index 530ce3d..e5b06ca 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -152,6 +152,24 @@ local function cancel_queued_processes() end end +local function find_closest_index(tbl, target) + local lower = target - 1 + local upper = target + 1 + + while lower >= 1 or upper <= #tbl do + if tbl[lower] ~= nil then + return lower + end + if tbl[upper] ~= nil then + return upper + end + lower = lower - 1 + upper = upper + 1 + end + + return nil +end + local winapi = {} if options.direct_io then local ffi_loaded, ffi = pcall(require, "ffi") @@ -858,6 +876,11 @@ local function thumb(time, r_x, r_y, script) prioritize_process(atlas_index) if storyboard_thumbnails[thumb_index] then thumbnail_path = storyboard_thumbnails[thumb_index] + else + local closest = find_closest_index(storyboard_thumbnails, thumb_index) + if closest then + thumbnail_path = storyboard_thumbnails[closest] + end end end From 5e748119fdc823c7b78af93d3964ffa187b44d32 Mon Sep 17 00:00:00 2001 From: Eva Date: Sat, 12 Apr 2025 18:54:32 +0200 Subject: [PATCH 25/28] initial support for rotation and flipping messy, and doesn't auto-update in all cases yet crop handling is still missing it's progress (: --- thumbfast.lua | 243 ++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 188 insertions(+), 55 deletions(-) diff --git a/thumbfast.lua b/thumbfast.lua index e5b06ca..c63bad9 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -257,6 +257,9 @@ local filters_all = {["hflip"]=true, ["vflip"]=true, ["lavfi-crop"]=true, ["crop local tone_mappings = {["none"]=true, ["clip"]=true, ["linear"]=true, ["gamma"]=true, ["reinhard"]=true, ["hable"]=true, ["mobius"]=true} local last_tone_mapping +local lavfi_crop = {} +local storyboard_transpose = false + local last_vf_reset = "" local last_vf_runtime = "" @@ -412,19 +415,9 @@ local function vo_tone_mapping() end end -local function vf_string(filters, full) - local vf = "" +local function vf_string_simple(filters, vf) local vf_table = properties["vf"] - if (properties["video-crop"] or "") ~= "" then - vf = "lavfi-crop="..string.gsub(properties["video-crop"], "(%d*)x?(%d*)%+(%d+)%+(%d+)", "w=%1:h=%2:x=%3:y=%4").."," - local width = properties["video-out-params"] and properties["video-out-params"]["dw"] - local height = properties["video-out-params"] and properties["video-out-params"]["dh"] - if width and height then - vf = string.gsub(vf, "w=:h=:", "w="..width..":h="..height..":") - end - end - if vf_table and #vf_table > 0 then for i = #vf_table, 1, -1 do if filters[vf_table[i].name] then @@ -440,6 +433,24 @@ local function vf_string(filters, full) end end + return vf +end + +local function vf_string(filters, full) + local vf = "" + + if (properties["video-crop"] or "") ~= "" then + vf = "lavfi-crop="..string.gsub(properties["video-crop"], "(%d*)x?(%d*)%+(%d+)%+(%d+)", "w=%1:h=%2:x=%3:y=%4").."," + local width = properties["video-out-params"] and properties["video-out-params"]["dw"] + local height = properties["video-out-params"] and properties["video-out-params"]["dh"] + if width and height then + vf = string.gsub(vf, "w=:h=:", "w="..width..":h="..height..":") + end + end + + vf = vf_string_simple(filters, vf) + + -- TODO: don't apply to storyboards??? if (full and options.tone_mapping ~= "no") or options.tone_mapping == "auto" then if properties["video-params"] and properties["video-params"]["primaries"] == "bt.2020" then local tone_mapping = options.tone_mapping @@ -835,6 +846,7 @@ local function clear() show_thumbnail = false last_x = nil last_y = nil + thumbnail_path = nil if script_name then return end if pre_0_30_0 then mp.command_native({"overlay-remove", options.overlay_id}) @@ -906,12 +918,46 @@ local function thumb(time, r_x, r_y, script) if not file_timer:is_enabled() then file_timer:resume() end end +local function parse_lavfi_crop(filters) + lavfi_crop = {} + local crop = string.match(filters, "lavfi%-crop=([^,]+)") -- TODO: do we also have to handle non-lavfi "crop"? + if crop then + for coord in crop:gmatch("[^:]+") do + local key, val = string.match(coord, "^([^=]+)=(.*)") + if key then + lavfi_crop[key] = tonumber(val) + end + end + end +end + local function watch_changes() if not dirty or not properties["video-out-params"] then return end dirty = false + local vf_reset = vf_string(filters_reset) + local rotate = properties["video-rotate"] or 0 + + local resized_storyboard = last_vf_reset ~= vf_reset or + last_rotate ~= rotate or + last_crop ~= properties["video-crop"] + -- TODO: add flipping detection + + if resized_storyboard then + storyboard_transpose = rotate % 180 == 90 + parse_lavfi_crop(vf_reset) + -- TODO: honor options.spawn_first, where we only start fetching thumbnails on hover? + clear() -- TODO: be smarter about this? + setup_storyboards() + end + if using_storyboards then - -- TODO: handle rotation, flipping, cropping + if resized_storyboard then + -- TODO: respawn + last_vf_reset = vf_reset + last_rotate = rotate + last_crop = properties["video-crop"] + end return end @@ -920,14 +966,12 @@ local function watch_changes() calc_dimensions() - local vf_reset = vf_string(filters_reset) - local rotate = properties["video-rotate"] or 0 - local resized = old_w ~= effective_w or old_h ~= effective_h or last_vf_reset ~= vf_reset or (last_rotate % 180) ~= (rotate % 180) or - par ~= last_par or last_crop ~= properties["video-crop"] + last_crop ~= properties["video-crop"] or + par ~= last_par if resized then last_rotate = rotate @@ -1018,26 +1062,90 @@ local function sync_changes(prop, val) dirty = true end -local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, storyboard_scale) +local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, rotation, crop, hflip, vflip) local atlas = io.open(atlas_path, "rb") + if not atlas then + print("could not open atlas file", atlas_path) + return + end + local atlas_filesize = atlas:seek("end") - local atlas_pictures = math.floor(atlas_filesize / (4 * thumbnail_size.w * thumbnail_size.h) + 0.5) - local stride = 4 * (thumbnail_size.w * math.min(storyboard.columns, atlas_pictures)) - for pic = 0, atlas_pictures-1 do - local x_start = (pic % storyboard.columns) * thumbnail_size.w - local y_start = math.floor(pic / storyboard.columns) * thumbnail_size.h - local thumb_idx = (atlas_idx - 1) * storyboard.columns * storyboard.rows + pic + local total_thumb_pixels = 4 * thumbnail_size.w * thumbnail_size.h + local num_thumbs = math.floor(atlas_filesize / total_thumb_pixels + 0.5) + local num_per_atlas = storyboard.columns * storyboard.rows + + local logical_columns = math.min(storyboard.columns, num_thumbs) + local logical_rows = math.ceil(num_thumbs / logical_columns) + + local t_width, t_height, physical_atlas_width, physical_atlas_height + + if rotation == 0 or rotation == 180 then + t_width = thumbnail_size.w + t_height = thumbnail_size.h + physical_atlas_width = logical_columns * t_width + physical_atlas_height = logical_rows * t_height + elseif rotation == 90 or rotation == 270 then + t_width = thumbnail_size.h + t_height = thumbnail_size.w + physical_atlas_width = logical_rows * t_width + physical_atlas_height = logical_columns * t_height + end + + local stride = 4 * physical_atlas_width + + -- TODO: handle cropping + + for pic = 0, num_thumbs - 1 do + local logical_col = pic % logical_columns + local logical_row = math.floor(pic / logical_columns) + + local x_start, y_start + + if rotation == 0 then + x_start = logical_col * t_width + y_start = logical_row * t_height + elseif rotation == 180 then + x_start = physical_atlas_width - (logical_col + 1) * t_width + y_start = physical_atlas_height - (logical_row + 1) * t_height + elseif rotation == 90 then + local phys_col = logical_rows - 1 - logical_row + local phys_row = logical_col + x_start = phys_col * t_width + y_start = phys_row * t_height + elseif rotation == 270 then + local phys_col = logical_row + local phys_row = logical_columns - 1 - logical_col + x_start = phys_col * t_width + y_start = phys_row * t_height + end + + if hflip then + x_start = (physical_atlas_width - t_width) - x_start + end + if vflip then + y_start = (physical_atlas_height - t_height) - y_start + end + + local thumb_idx = (atlas_idx - 1) * num_per_atlas + pic local filename = options.thumbnail .. ".ytdl-thumbx" .. tostring(thumb_idx) local thumb_file = io.open(filename .. ".bgra", "wb") - for line = 0, thumbnail_size.h - 1 do + if not thumb_file then + atlas:close() + print("storyboard thumbnail write failed", filename) + return + end + + for line = 0, t_height - 1 do atlas:seek("set", 4 * x_start + (y_start + line) * stride) - local data = atlas:read(thumbnail_size.w * 4) - if data ~= nil then + local data = atlas:read(t_width * 4) + if data then thumb_file:write(data) end end + thumb_file:close() storyboard_thumbnails[thumb_idx] = filename + if last_seek_time then local last_thumb_idx = math.floor(last_seek_time / thumbnail_delta) if last_thumb_idx == thumb_idx then @@ -1045,10 +1153,11 @@ local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, stor end end end + atlas:close() end -local function fetch_fragment(storyboard, i, thumbnail_size, storyboard_scale) +local function fetch_fragment(storyboard, i, thumbnail_size, storyboard_scale, scale_formula, video_filters, crop, hflip, vflip) if not storyboard.fragments[i] then return end local args = { @@ -1058,10 +1167,10 @@ local function fetch_fragment(storyboard, i, thumbnail_size, storyboard_scale) "--no-sub", "--no-audio", "--hr-seek=no", "--sub-font-provider=none", "--embeddedfonts=no", "--no-ytdl", "--demuxer-readahead-secs=0", "--demuxer-max-bytes=128KiB", "--vd-lavc-software-fallback=1", "--vd-lavc-fast", "--vd-lavc-threads=2", --"--hwdec="..(options.hwdec and "auto" or "no"), - --"--vf="..vf_string(filters_all, true), + "--vf="..video_filters, "--sws-allow-zimg=no", "--sws-fast=yes", "--sws-scaler=fast-bilinear", --"--video-rotate="..last_rotate, - "--vf-add=format=bgra,scale=round(iw*"..storyboard_scale.w.."/"..thumbnail_size.w..")*"..thumbnail_size.w..":round(ih*"..storyboard_scale.h.."/"..thumbnail_size.h..")*"..thumbnail_size.h, + "--vf-add=format=bgra,scale="..scale_formula, "--ovc=rawvideo", "--of=rawvideo", "--ofopts=update=1", "--o="..options.thumbnail..".ytdl"..tostring(i) } @@ -1076,12 +1185,12 @@ local function fetch_fragment(storyboard, i, thumbnail_size, storyboard_scale) mp.msg.error("thumbfast: storyboard download failed", "atlas:", i, "status:", result.status) end else - get_thumb(options.thumbnail..".ytdl"..tostring(i), i, storyboard, thumbnail_size, storyboard_scale) + get_thumb(options.thumbnail..".ytdl"..tostring(i), i, storyboard, thumbnail_size, math.floor((properties["video-rotate"] or 0) / 90 + 0.5) * 90, crop, hflip, vflip) end end ) - fetch_fragment(storyboard, i+1, thumbnail_size, storyboard_scale) + fetch_fragment(storyboard, i+1, thumbnail_size, storyboard_scale, scale_formula, video_filters, crop, hflip, vflip) end local function anycase(s) @@ -1243,7 +1352,7 @@ local function ytdl_subprocess(args, async, cb) ytdl_subprocess_cancel = subprocess(args, async, callback) end -local function setup_storyboards() +function setup_storyboards() if not options.network then return end local path = properties["path"] @@ -1275,19 +1384,13 @@ local function setup_storyboards() local sb_j = mp.utils.parse_json(sb_json.stdout) if sb_j and sb_j.formats then for _, sb in ipairs(sb_j.formats) do - if sb and sb.format_id == "sb0" and sb_j.duration and sb.width and sb.height and sb.fragments and #sb.fragments > 0 then + if sb and sb.format_id == "sb0" and sb_j.duration and sb.width and sb.height and sb.rows and sb.columns and sb.fragments and #sb.fragments > 0 then local thumbnail_count = 0 - sb.rows = sb.rows or 5 - sb.columns = sb.columns or 5 thumb_count_per_storyboard = sb.rows * sb.columns thumbnail_path = nil if sb.fps then thumbnail_count = math.floor(sb.fps * sb_j.duration + 0.5) - -- hack: youtube always adds 1 black frame at the end... --is this even true? - if sb.extractor == "youtube" then - thumbnail_count = thumbnail_count - 1 - end else -- estimate the count of thumbnails -- assume first atlas is always full @@ -1307,13 +1410,52 @@ local function setup_storyboards() real_h = math.floor(real_h) end local storyboard_scale = {w=real_w/sb.width, h=real_h/sb.height} - local thumbnail_size = {w=real_w, h=real_h} + local thumbnail_size = {w=real_w, h=real_h, ow=sb.width, oh=sb.height, cw=real_w, ch=real_h} effective_w, effective_h = real_w, real_h + if storyboard_transpose then + real_w, real_h = real_h, real_w + end + local crop = {x=0, y=0, w=0, h=0} + local vf_reset = vf_string(filters_reset) + parse_lavfi_crop(vf_reset) + if lavfi_crop.x and lavfi_crop.y and lavfi_crop.w and lavfi_crop.h then + local width, height = properties["width"], properties["height"] + if width and height then + -- TODO: crop handling is unfinished + local cropped_from_width = width - lavfi_crop.w + local cropped_from_height = height - lavfi_crop.h + thumbnail_size.cw = math.floor(thumbnail_size.w * (width / lavfi_crop.w) + 0.5) + thumbnail_size.ch = math.floor(thumbnail_size.h * (height / lavfi_crop.h) + 0.5) + local scale_x = thumbnail_size.cw / thumbnail_size.w + local scale_y = thumbnail_size.ch / thumbnail_size.h + crop.x = math.floor(lavfi_crop.x / scale_x + 0.5) -- TODO: math.min(crop.x + thumbnail_size.w, thumbnail_size.cw) - thumbnail_size.w + crop.y = math.floor(lavfi_crop.y / scale_y + 0.5) -- TODO: math.min(crop.y + thumbnail_size.h, thumbnail_size.ch) - thumbnail_size.h + crop.w = math.floor(lavfi_crop.w / scale_x + 0.5) -- TODO: thumbnail_size.w + crop.h = math.floor(lavfi_crop.h / scale_y + 0.5) -- TODO: thumbnail_size.h + real_w = crop.w -- unnecessary + real_h = crop.h -- unnecessary + storyboard_scale = {w=thumbnail_size.cw/thumbnail_size.ow, h=thumbnail_size.ch/thumbnail_size.oh} + end + end info(real_w, real_h) + local transpose = string.rep("transpose=1,", math.floor(properties["video-rotate"] or 0) / 90 % 4) + local vf = vf_string_simple(filters_runtime, "") + local video_filters = (vf .. transpose):sub(1, -2) + local width_formula = "round(iw*"..storyboard_scale.w.."/"..thumbnail_size.cw..")*"..thumbnail_size.cw + local height_formula = "round(ih*"..storyboard_scale.h.."/"..thumbnail_size.ch..")*"..thumbnail_size.ch + if storyboard_transpose then + width_formula = "round(iw*"..storyboard_scale.w.."/"..thumbnail_size.ch..")*"..thumbnail_size.ch + height_formula = "round(ih*"..storyboard_scale.h.."/"..thumbnail_size.cw..")*"..thumbnail_size.cw + end + local scale_formula = width_formula..":"..height_formula + + -- TODO: account for when hflip or vflip get cancelled out by multiple invocations... and use the actual vf table instead of working on strings + local hflip, vflip = string.match(vf, "hflip"), string.match(vf, "vflip") + thumbnail_delta = sb_j.duration / thumbnail_count - fetch_fragment(sb, 1, thumbnail_size, storyboard_scale) + fetch_fragment(sb, 1, thumbnail_size, storyboard_scale, scale_formula, video_filters, lavfi_crop, hflip, vflip) return end end @@ -1321,15 +1463,14 @@ local function setup_storyboards() end -- fall back to regular thumbnailing - file_load1() - file_load2() + file_load() end) -- we are in a state where we decided yeah let's try storyboards return true end end -function file_load1() +local function file_load() clear() spawned = false real_w, real_h = nil, nil @@ -1345,28 +1486,18 @@ function file_load1() thumbnail_path = nil cancel_queued_processes() -end -function file_load2() calc_dimensions() info(effective_w, effective_h) if disabled then return end spawned = false - if options.spawn_first then + if options.spawn_first then -- TODO: skip if matches storyboard stuff spawn(mp.get_property_number("time-pos", 0)) first_file = true end end -local function file_load() - file_load1() - - if setup_storyboards() then return end - - file_load2() -end - local function shutdown() run("quit") remove_thumbnail_files() @@ -1407,6 +1538,8 @@ mp.observe_property("current-vo", "native", update_property) mp.observe_property("video-rotate", "native", update_property) mp.observe_property("video-crop", "native", update_property) mp.observe_property("path", "native", update_property) +mp.observe_property("width", "native", update_property) +mp.observe_property("height", "native", update_property) mp.observe_property("vid", "native", sync_changes) mp.observe_property("edition", "native", sync_changes) mp.observe_property("duration", "native", on_duration) From ddb0d41cb86c5c19ceba70f4650b2d026e8dce62 Mon Sep 17 00:00:00 2001 From: Eva Date: Sun, 13 Apr 2025 22:27:21 +0200 Subject: [PATCH 26/28] minor cleanup --- thumbfast.lua | 42 +++++++++++++++++++----------------------- 1 file changed, 19 insertions(+), 23 deletions(-) diff --git a/thumbfast.lua b/thumbfast.lua index c63bad9..c52c3dc 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -152,22 +152,21 @@ local function cancel_queued_processes() end end -local function find_closest_index(tbl, target) - local lower = target - 1 - local upper = target + 1 +local function closest_thumbnail(tbl, target) + if tbl[target] then + return tbl[target] + end - while lower >= 1 or upper <= #tbl do - if tbl[lower] ~= nil then - return lower + local offset = 1 + while target - offset >= 1 or target + offset <= #tbl do + if tbl[target - offset] then + return tbl[target - offset] end - if tbl[upper] ~= nil then - return upper + if tbl[target + offset] then + return tbl[target + offset] end - lower = lower - 1 - upper = upper + 1 + offset = offset + 1 end - - return nil end local winapi = {} @@ -883,16 +882,13 @@ local function thumb(time, r_x, r_y, script) end if using_storyboards and thumbnail_delta then - thumb_index = math.floor(time / thumbnail_delta) - atlas_index = math.ceil(thumb_index / thumb_count_per_storyboard) - prioritize_process(atlas_index) - if storyboard_thumbnails[thumb_index] then - thumbnail_path = storyboard_thumbnails[thumb_index] + local thumb_index = math.floor(time / thumbnail_delta) + local closest = closest_thumbnail(storyboard_thumbnails, thumb_index) + if closest ~= nil then + thumbnail_path = closest else - local closest = find_closest_index(storyboard_thumbnails, thumb_index) - if closest then - thumbnail_path = storyboard_thumbnails[closest] - end + local atlas_index = math.ceil(thumb_index / thumb_count_per_storyboard) + prioritize_process(atlas_index) end end @@ -1203,7 +1199,7 @@ local http_prefix = anycase("^https?://") local ytdl_prefix = "^ytdl://(.+)" local subdomains = "[%w-.]*" local naked_ytdl_id = "^ytdl://([%w-_]+)$" -local youtube_id = "[%w-_]+.*" +local youtube_id = ".+" local twitch_id = "%d+.*" local ytdl_opts = {try_ytdl_first = false, ytdl_path = ""} mp.options.read_options(ytdl_opts, "ytdl_hook") @@ -1219,7 +1215,7 @@ local youtube_patterns = { -- youtu.be/abcdef01234 "^"..anycase("youtu%.be/")..youtube_id, - -- youtube.com/v/abcdef01234 + -- youtube.com/v/abcdef01234 or youtube.com/shorts/abcdef01234 "^"..subdomains..anycase("youtube%.com/[^/]+/")..youtube_id, } local twitch_base = subdomains..anycase("twitch%.tv/") From 749dee2f723955a4613e97ecbebbaee872eb8e3a Mon Sep 17 00:00:00 2001 From: Eva Date: Sun, 13 Apr 2025 23:47:08 +0200 Subject: [PATCH 27/28] only fallback to preceeding thumbnail to avoid spoilers --- thumbfast.lua | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/thumbfast.lua b/thumbfast.lua index c52c3dc..f86f1c5 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -152,20 +152,11 @@ local function cancel_queued_processes() end end -local function closest_thumbnail(tbl, target) - if tbl[target] then - return tbl[target] - end - - local offset = 1 - while target - offset >= 1 or target + offset <= #tbl do +local function closest_preceeding_thumbnail(tbl, target) + for offset = 0, target - 1 do if tbl[target - offset] then return tbl[target - offset] end - if tbl[target + offset] then - return tbl[target + offset] - end - offset = offset + 1 end end @@ -883,13 +874,12 @@ local function thumb(time, r_x, r_y, script) if using_storyboards and thumbnail_delta then local thumb_index = math.floor(time / thumbnail_delta) - local closest = closest_thumbnail(storyboard_thumbnails, thumb_index) + local closest = closest_preceeding_thumbnail(storyboard_thumbnails, thumb_index) if closest ~= nil then thumbnail_path = closest - else - local atlas_index = math.ceil(thumb_index / thumb_count_per_storyboard) - prioritize_process(atlas_index) end + local atlas_index = math.ceil(thumb_index / thumb_count_per_storyboard) + prioritize_process(atlas_index) end script_name = script From 6e78db330b25e47bd405edc4c92c8aaa903dc51e Mon Sep 17 00:00:00 2001 From: Eva Date: Sun, 13 Apr 2025 23:58:56 +0200 Subject: [PATCH 28/28] staggered initial load for storyboard fragments --- thumbfast.lua | 58 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/thumbfast.lua b/thumbfast.lua index f86f1c5..4849b5f 100644 --- a/thumbfast.lua +++ b/thumbfast.lua @@ -106,7 +106,7 @@ local function spawn_one(args, callback) end end -local function spawn_queued_process(args, callback) +local function spawn_queued_process(index, args, callback) local function wrap_callback(callback) return function(...) callback(...) @@ -119,7 +119,7 @@ local function spawn_queued_process(args, callback) local wrapped_callback = wrap_callback(callback) local process = {args=args, callback=wrapped_callback} table.insert(process_queue, process) - table.insert(all_processes, process) + all_processes[index] = process spawn_one(args, wrapped_callback) end @@ -152,7 +152,7 @@ local function cancel_queued_processes() end end -local function closest_preceeding_thumbnail(tbl, target) +local function closest_thumbnail(tbl, target) for offset = 0, target - 1 do if tbl[target - offset] then return tbl[target - offset] @@ -874,7 +874,7 @@ local function thumb(time, r_x, r_y, script) if using_storyboards and thumbnail_delta then local thumb_index = math.floor(time / thumbnail_delta) - local closest = closest_preceeding_thumbnail(storyboard_thumbnails, thumb_index) + local closest = closest_thumbnail(storyboard_thumbnails, thumb_index) if closest ~= nil then thumbnail_path = closest end @@ -1144,15 +1144,13 @@ local function get_thumb(atlas_path, atlas_idx, storyboard, thumbnail_size, rota end local function fetch_fragment(storyboard, i, thumbnail_size, storyboard_scale, scale_formula, video_filters, crop, hflip, vflip) - if not storyboard.fragments[i] then return end - local args = { mpv_path, storyboard.fragments[i].url, "--no-config", "--msg-level=all=no", "--really-quiet", "--no-terminal", "--frames=1", --"--load-scripts=no", "--osc=no", "--ytdl=no", "--load-stats-overlay=no", "--load-osd-console=no", "--load-auto-profiles=no", "--no-sub", "--no-audio", "--hr-seek=no", "--sub-font-provider=none", "--embeddedfonts=no", "--no-ytdl", "--demuxer-readahead-secs=0", "--demuxer-max-bytes=128KiB", - "--vd-lavc-software-fallback=1", "--vd-lavc-fast", "--vd-lavc-threads=2", --"--hwdec="..(options.hwdec and "auto" or "no"), + "--ao=null", "--ao-null-untimed", "--vd-lavc-software-fallback=1", "--vd-lavc-fast", "--vd-lavc-threads=2", --"--hwdec="..(options.hwdec and "auto" or "no"), "--vf="..video_filters, "--sws-allow-zimg=no", "--sws-fast=yes", "--sws-scaler=fast-bilinear", --"--video-rotate="..last_rotate, @@ -1164,7 +1162,7 @@ local function fetch_fragment(storyboard, i, thumbnail_size, storyboard_scale, s table.insert(args, "--macos-app-activation-policy=prohibited") end - spawn_queued_process(args, + spawn_queued_process(i, args, function(success, result, err) if success == false or (not result or result.status ~= 0) then if not result.killed_by_us then @@ -1175,8 +1173,48 @@ local function fetch_fragment(storyboard, i, thumbnail_size, storyboard_scale, s end end ) +end + +local function fetch_reordered_fragments(storyboard, ...) + local n = #storyboard.fragments + local order = {} + local intervals = {} + + local function interval_cmp(a, b) + local gapA = a.high - a.low + local gapB = b.high - b.low + if gapA == gapB then + return a.low < b.low + end + return gapA > gapB + end - fetch_fragment(storyboard, i+1, thumbnail_size, storyboard_scale, scale_formula, video_filters, crop, hflip, vflip) + if n >= 1 then + table.insert(order, 1) + end + if n >= 2 then + table.insert(order, n) + table.insert(intervals, {low = 1, high = n}) + end + + while #intervals > 0 do + table.sort(intervals, interval_cmp) + local current = table.remove(intervals, 1) + if current.high - current.low > 1 then + local mid = math.floor((current.low + current.high) / 2) + table.insert(order, mid) + if mid - current.low > 1 then + table.insert(intervals, {low = current.low, high = mid}) + end + if current.high - mid > 1 then + table.insert(intervals, {low = mid, high = current.high}) + end + end + end + + for _, index in ipairs(order) do + fetch_fragment(storyboard, index, ...) + end end local function anycase(s) @@ -1441,7 +1479,7 @@ function setup_storyboards() thumbnail_delta = sb_j.duration / thumbnail_count - fetch_fragment(sb, 1, thumbnail_size, storyboard_scale, scale_formula, video_filters, lavfi_crop, hflip, vflip) + fetch_reordered_fragments(sb, thumbnail_size, storyboard_scale, scale_formula, video_filters, lavfi_crop, hflip, vflip) return end end