--[[ SOURCE_ https://github.com/deus0ww/mpv-conf/blob/master/scripts/Thumbnailer_Worker.lua COMMIT_ 20210716 91ae987 搭配osc_lazy的缩略图脚本(2)/(2) ]]-- local ipairs,loadfile,pairs,pcall,tonumber,tostring = ipairs,loadfile,pairs,pcall,tonumber,tostring local debug,io,math,os,string,table,utf8 = debug,io,math,os,string,table,utf8 local min,max,floor,ceil,huge,sqrt = math.min,math.max,math.floor,math.ceil,math.huge,math.sqrt local mp = require 'mp' local msg = require 'mp.msg' local opt = require 'mp.options' local utils = require 'mp.utils' local script_name = mp.get_script_name() local message = { worker = { registration = 'tn_worker_registration', reset = 'tn_worker_reset', queue = 'tn_worker_queue', start = 'tn_worker_start', progress = 'tn_worker_progress', finish = 'tn_worker_finish', }, debug = 'Thumbnailer-debug', queued = 1, processing = 2, ready = 3, failed = 4, } -------------------- -- Data Structure -- -------------------- local state local worker_data local worker_options local worker_extra local worker_stats local work_queue local thumbnail_map_buffer = {} local thumbnail_map_buffer_size = 0 local function reset_all() state = nil worker_data = {} worker_options = {} worker_extra = {} worker_stats = {} worker_stats.name = script_name worker_stats.queued = 0 worker_stats.existing = 0 worker_stats.failed = 0 worker_stats.success = 0 work_queue = {} thumbnail_map_buffer = {} thumbnail_map_buffer_size = 0 end reset_all() ----------- -- Utils -- ----------- local OS_MAC, OS_WIN, OS_NIX = 'MAC', 'WIN', 'NIX' local function get_os() if jit and jit.os then if jit.os == 'Windows' then return OS_WIN elseif jit.os == 'OSX' then return OS_MAC else return OS_NIX end end if (package.config:sub(1,1) ~= '/') then return OS_WIN end local res = mp.command_native({ name = 'subprocess', args = {'uname', '-s'}, playback_only = false, capture_stdout = true, capture_stderr = true, }) return (res and res.stdout and res.stdout:lower():find('darwin') ~= nil) and OS_MAC or OS_NIX end local OPERATING_SYSTEM = get_os() local function format_json(tab) local json, err = utils.format_json(tab) if err then msg.error('Formatting JSON failed:', err) end if json then return json else return '' end end local function parse_json(json) local tab, err = utils.parse_json(json, true) if err then msg.error('Parsing JSON failed:', err) end if tab then return tab else return {} end end local function is_empty(...) -- Not for tables if ... == nil then return true end for _, v in ipairs({...}) do if (v == nil) or (v == '') or (v == 0) then return true end end return false end ------------------------------------- -- External Process and Filesystem -- ------------------------------------- local function subprocess_result(sub_success, result, mpv_error, subprocess_name, start_time) local cmd_status, cmd_stdout, cmd_stderr, cmd_error, cmd_killed if result then cmd_status, cmd_stdout, cmd_stderr, cmd_error, cmd_killed = result.status, result.stdout, result.stderr, result.error_string, result.killed_by_us end local cmd_status_success, cmd_status_string, cmd_err_success, cmd_err_string, success if cmd_status == 0 then cmd_status_success, cmd_status_string = true, 'ok' elseif is_empty(cmd_status) then cmd_status_success, cmd_status_string = true, '_' elseif cmd_status == 124 or cmd_status == 137 or cmd_status == 143 then -- timer: timed-out(124), killed(128+9), or terminated(128+15) cmd_status_success, cmd_status_string = false, 'timed out' else cmd_status_success, cmd_status_string = false, ('%d'):format(cmd_status) end if is_empty(cmd_error) then cmd_err_success, cmd_err_string = true, '_' elseif cmd_error == 'init' then cmd_err_success, cmd_err_string = false, 'failed to initialize' elseif cmd_error == 'killed' then cmd_err_success, cmd_err_string = false, cmd_killed and 'killed by us' or 'killed, but not by us' else cmd_err_success, cmd_err_string = false, cmd_error end if is_empty(cmd_stdout) then cmd_stdout = '_' end if is_empty(cmd_stderr) then cmd_stderr = '_' end subprocess_name = subprocess_name or '_' start_time = start_time or os.time() success = (sub_success == nil or sub_success) and is_empty(mpv_error) and cmd_status_success and cmd_err_success if success then msg.debug('Subprocess', subprocess_name, 'succeeded. | Status:', cmd_status_string, '| Time:', ('%ds'):format(os.difftime(os.time(), start_time))) else msg.error('Subprocess', subprocess_name, 'failed. | Status:', cmd_status_string, '| MPV Error:', mpv_error or 'n/a', '| Subprocess Error:', cmd_err_string, '| Stdout:', cmd_stdout, '| Stderr:', cmd_stderr, '| Time:', ('%ds'):format(os.difftime(os.time(), start_time))) end return success, cmd_status_string, cmd_err_string, cmd_stdout, cmd_stderr end local function run_subprocess(command, name) if not command then return false end local subprocess_name, start_time = name or command[1], os.time() msg.debug('Subprocess', subprocess_name, 'Starting...', utils.to_string(command)) local result, mpv_error = mp.command_native( {name='subprocess', args=command, playback_only=false} ) local success, _, _, _ = subprocess_result(nil, result, mpv_error, subprocess_name, start_time) return success end local function run_subprocess_async(command, name) if not command then return false end local subprocess_name, start_time = name or command[1], os.time() msg.debug('Subprocess', subprocess_name, 'Starting (async)...') mp.command_native_async( {name='subprocess', args=command, playback_only=false}, function(s, r, e) subprocess_result(s, r, e, subprocess_name, start_time) end ) return nil end local function join_paths(...) local sep = OPERATING_SYSTEM == OS_WIN and '\\' or '/' local result = '' for _, p in ipairs({...}) do result = (result == '') and p or result .. sep .. p end return result end local function clean_path(path) if OPERATING_SYSTEM == OS_WIN and utf8 ~= nil then for uchar in string.gmatch(path, '\\u([0-9a-f][0-9a-f][0-9a-f][0-9a-f])') do path = path:gsub('\\u' .. uchar, utf8.char('0x' .. uchar)) end path = path:gsub('[\\/]', '\\\\') end return path end ------------ -- Worker -- ------------ mp.command_native({'script-message', message.worker.registration, format_json({name = script_name, script_path = debug.getinfo(1).source})}) local function stop_file_exist() local file = io.open(join_paths(state.cache_dir, 'stop'), 'r') if not file then return false end file:close() return true end local function check_existing(thumbnail_path) local thumbnail_file = io.open(thumbnail_path, 'rb') if thumbnail_file and thumbnail_file:seek('end') >= worker_extra.filesize then thumbnail_file:close() return true end return false end local function pad_file(thumbnail_path) local thumbnail_file = io.open(thumbnail_path, 'rb') if thumbnail_file then -- Check the size of the generated file local thumbnail_file_size = thumbnail_file:seek('end') thumbnail_file:close() -- Check if the file is big enough local missing_bytes = max(0, worker_extra.filesize - thumbnail_file_size) if thumbnail_file_size ~= 0 and missing_bytes > 0 then msg.warn(('Thumbnail missing %d bytes (expected %d, had %d), padding %s'):format(missing_bytes, worker_extra.filesize, thumbnail_file_size, thumbnail_path)) thumbnail_file = io.open(thumbnail_path, 'ab') thumbnail_file:write(string.rep(string.char(0), missing_bytes)) thumbnail_file:close() end end end local function concat_args(args, ...) local arg = '' for _, option in ipairs({...}) do if is_empty(option) then return #args end arg = arg .. tostring(option) end if arg ~= '' then args[#args+1] = arg end return #args end local function add_args(args, ...) for _, option in ipairs({...}) do if is_empty(option) then return #args end end for _, option in ipairs({...}) do args[#args+1] = tostring(option) end return #args end local function add_timeout(args) local timeout = worker_options.worker_timeout and worker_options.worker_timeout or 0 if timeout == 0 then return #args end if OPERATING_SYSTEM == OS_MAC then add_args(args, worker_options.exec_path .. 'gtimeout', ('--kill-after=%d'):format(timeout + 1), ('%d'):format(timeout + 3)) elseif OPERATING_SYSTEM == OS_NIX then add_args(args, worker_options.exec_path .. 'timeout', ('--kill-after=%d'):format(timeout + 1), ('%d'):format(timeout + 3)) elseif OPERATING_SYSTEM == OS_WIN then -- unimplemented end return #args end local function add_nice(args) if OPERATING_SYSTEM == OS_MAC then add_args(args, worker_options.exec_path .. 'gnice', '-19') elseif OPERATING_SYSTEM == OS_NIX then add_args(args, worker_options.exec_path .. 'nice', '-n', '19') elseif OPERATING_SYSTEM == OS_WIN then -- unimplemented end end local pix_fmt = 'bgra' local scale_ff = 'scale=w=%d:h=%d:sws_flags=%s:dst_format=' .. pix_fmt local scale_mpv = 'scale=w=%d:h=%d:flags=%s' local vf_format = ',format=fmt=' .. pix_fmt local transpose = { [-360] = '', [-270] = ',transpose=1', [-180] = ',transpose=2,transpose=2', [ -90] = ',transpose=2', [ 0] = '', [ 90] = ',transpose=1', [ 180] = ',transpose=1,transpose=1', [ 270] = ',transpose=2', [ 360] = '', } local function create_mpv_command(time, output, force_accurate_seek) local state, worker_extra, args = state, worker_extra, worker_extra.args local is_last_thumbnail = (state.duration - time) < state.delta local accurate_seek = force_accurate_seek or worker_options.accurate_seek or is_last_thumbnail or state.delta < 3 if args then args[worker_extra.index_log] = '--log-file=' .. output .. '.log' args[worker_extra.index_fastseek] = '--demuxer-lavf-o-set=fflags=' .. (accurate_seek and '+discardcorrupt+nobuffer' or '+fastseek+discardcorrupt+nobuffer') args[worker_extra.index_accurate] = '--hr-seek=' .. (accurate_seek and 'yes' or 'no') args[worker_extra.index_skip_loop] = '--vd-lavc-skiploopfilter=' .. (accurate_seek and 'nonref' or 'nonkey') args[worker_extra.index_skip_idct] = '--vd-lavc-skipidct=' .. (accurate_seek and 'nonref' or 'nonkey') args[worker_extra.index_skip_frame] = '--vd-lavc-skipframe=' .. (accurate_seek and 'nonref' or 'nonkey') args[worker_extra.index_time] = '--start=' .. tostring(is_last_thumbnail and floor(time) or time) args[worker_extra.index_output] = '--o=' .. output else local width, height = state.width, state.height local vf_scale = (scale_mpv):format(width, height, worker_options.ffmpeg_scaler) local vf_transpose = state.rotate and transpose[tonumber(state.rotate % 360)] or '' local filter_threads = (':o="threads=%d"'):format(worker_options.ffmpeg_threads) local video_filters = '--vf=lavfi="' .. vf_scale .. vf_transpose .. '"' .. filter_threads .. vf_format local worker_options = worker_options local header_fields_arg = nil local header_fields = mp.get_property_native('http-header-fields', {}) if #header_fields > 0 then header_fields_arg = '--http-header-fields=' .. table.concat(header_fields, ',') end worker_extra.args = {} args = worker_extra.args -- https://mpv.io/manual/master/ add_timeout(args) add_nice(args) worker_extra.index_name = concat_args(args, worker_options.exec_path .. 'mpv') -- General concat_args(args, '--no-config') concat_args(args, '--msg-level=all=no') worker_extra.index_log = concat_args(args, '--log-file=', output .. '.log') concat_args(args, '--osc=no') concat_args(args, '--load-stats-overlay=no') -- Remote concat_args(args, (worker_extra.ytdl and '--ytdl' or '--no-ytdl')) concat_args(args, header_fields_arg) concat_args(args, '--user-agent=', mp.get_property_native('user-agent')) concat_args(args, '--referrer=', mp.get_property_native('referrer')) -- Input concat_args(args, '--vd-lavc-fast') concat_args(args, '--vd-lavc-threads=', worker_options.ffmpeg_threads) concat_args(args, '--demuxer-lavf-analyzeduration=0.1') concat_args(args, '--demuxer-lavf-probesize=500000') concat_args(args, '--demuxer-lavf-probe-info=nostreams') worker_extra.index_fastseek = concat_args(args, '--demuxer-lavf-o-set=fflags=', accurate_seek and '+discardcorrupt+nobuffer' or '+fastseek+discardcorrupt+nobuffer') worker_extra.index_accurate = concat_args(args, '--hr-seek=', accurate_seek and 'yes' or 'no') worker_extra.index_skip_loop = concat_args(args, '--vd-lavc-skiploopfilter=', accurate_seek and 'nonref' or 'nonkey') worker_extra.index_skip_idct = concat_args(args, '--vd-lavc-skipidct=', accurate_seek and 'nonref' or 'nonkey') worker_extra.index_skip_frame = concat_args(args, '--vd-lavc-skipframe=', accurate_seek and 'nonref' or 'nonkey') concat_args(args, '--hwdec=', worker_options.mpv_hwdec) concat_args(args, '--hdr-compute-peak=no') concat_args(args, '--vd-lavc-dr=no') concat_args(args, '--aid=no') concat_args(args, '--sid=no') concat_args(args, '--sub-auto=no') worker_extra.index_time = concat_args(args, '--start=', tostring(is_last_thumbnail and floor(time) or time)) concat_args(args, '--frames=1') concat_args(args, state.input_fullpath) -- Filters concat_args(args, '--sws-scaler=', worker_options.mpv_scaler) concat_args(args, video_filters) -- Output concat_args(args, '--of=rawvideo') concat_args(args, '--ovcopts=pixel_format=', pix_fmt) concat_args(args, '--ocopy-metadata=no') worker_extra.index_output = concat_args(args, '--o=' .. output) end return args, args[worker_extra.index_name] end local function create_ffmpeg_command(time, output, force_accurate_seek) local state, worker_extra, args = state, worker_extra, worker_extra.args local is_last_thumbnail = (state.duration - time) < state.delta local accurate_seek = force_accurate_seek or worker_options.accurate_seek or is_last_thumbnail or state.delta < 3 if args then args[worker_extra.index_fastseek] = accurate_seek and '+discardcorrupt+nobuffer' or '+fastseek+discardcorrupt+nobuffer' args[worker_extra.index_accurate] = accurate_seek and '-accurate_seek' or '-noaccurate_seek' args[worker_extra.index_skip_loop] = accurate_seek and 'noref' or 'nokey' args[worker_extra.index_skip_idct] = accurate_seek and 'noref' or 'nokey' args[worker_extra.index_skip_frame] = accurate_seek and 'noref' or 'nokey' args[worker_extra.index_time] = tostring(is_last_thumbnail and floor(time) or time) args[worker_extra.index_output] = output else local width, height = state.width, state.height if state.meta_rotated then width, height = height, width end local vf_scale = (scale_ff):format(width, height, worker_options.ffmpeg_scaler) local vf_transpose = state.rotate and transpose[tonumber(state.rotate % 360)] or '' local video_filters = vf_scale .. vf_transpose local worker_options = worker_options worker_extra.args = {} args = worker_extra.args -- https://ffmpeg.org/ffmpeg.html#Main-options -- General add_timeout(args) add_nice(args) worker_extra.index_name = add_args(args, worker_options.exec_path .. 'ffmpeg') add_args(args, '-hide_banner') add_args(args, '-nostats') add_args(args, '-loglevel', 'warning') if not (worker_options.ffmpeg_hwaccel == 'none') then add_args(args, '-hwaccel', worker_options.ffmpeg_hwaccel) add_args(args, '-hwaccel_device', worker_options.ffmpeg_hwaccel_device) end -- Input add_args(args, '-threads', worker_options.ffmpeg_threads) add_args(args, '-fflags', 'fastseek') add_args(args, '-flags2', 'fast') if OPERATING_SYSTEM ~= OS_WIN and worker_options.worker_timeout > 0 then add_args(args, '-timelimit', ceil(worker_options.worker_timeout)) end add_args(args, '-analyzeduration', '500000') -- Default: 5000000 add_args(args, '-probesize', '500000') -- Default: 5000000 worker_extra.index_fastseek = add_args(args, '-fflags', accurate_seek and '+discardcorrupt+nobuffer' or '+fastseek+discardcorrupt+nobuffer') worker_extra.index_accurate = add_args(args, accurate_seek and '-accurate_seek' or '-noaccurate_seek') worker_extra.index_skip_loop = add_args(args, '-skip_loop_filter', accurate_seek and 'noref' or 'nokey') worker_extra.index_skip_idct = add_args(args, '-skip_idct', accurate_seek and 'noref' or 'nokey') worker_extra.index_skip_frame = add_args(args, '-skip_frame', accurate_seek and 'noref' or 'nokey') worker_extra.index_time = add_args(args, '-ss', tostring(is_last_thumbnail and floor(time) or time)) add_args(args, '-guess_layout_max', '0') add_args(args, '-an', '-sn') add_args(args, '-i', state.input_fullpath) add_args(args, '-map_metadata', '-1') add_args(args, '-map_chapters', '-1') add_args(args, '-frames:v', '1') -- Filters add_args(args, '-filter_threads', worker_options.ffmpeg_threads) add_args(args, '-vf', video_filters) add_args(args, '-sws_flags', worker_options.ffmpeg_scaler) add_args(args, '-pix_fmt', pix_fmt) -- Output add_args(args, '-f', 'rawvideo') add_args(args, '-threads', worker_options.ffmpeg_threads) add_args(args, '-y') worker_extra.index_output = add_args(args, output) end return args, args[worker_extra.index_name] end -- From https://github.com/TheAMM/mpv_thumbnail_script local function hack_input() msg.debug('Hacking Input...') local file_path = mp.get_property_native('stream-path') local playlist_filename = join_paths(state.cache_dir, 'playlist.txt') worker_extra.ytdl = false if #file_path > 8000 then -- Path is too long for a playlist - just pass the original URL to workers and allow ytdl worker_extra.ytdl = true file_path = state.input_fullpath msg.warn('Falling back to original URL and ytdl due to LONG source path. This will be slow.') elseif #file_path > 1024 then local playlist_file = io.open(playlist_filename, 'wb') if not playlist_file then msg.error(('Tried to write a playlist to %s but could not!'):format(playlist_file)) return false end playlist_file:write(file_path .. '\n') playlist_file:close() file_path = '--playlist=' .. playlist_filename msg.warn('Using playlist workaround due to long source path') end state.input_fullpath = file_path end local function report_progress_table(thumbnail_map) local progress_report = { name = script_name, input_filename = state.input_filename, thumbnail_map = thumbnail_map } mp.command_native_async({'script-message', message.worker.progress, format_json(progress_report)}, function() end) end local function report_progress(index, new_status) local index_string = index and (state.cache_format):format(index) or '' if index ~= nil and thumbnail_map_buffer[index_string] == nil then thumbnail_map_buffer_size = thumbnail_map_buffer_size + 1 end thumbnail_map_buffer[index_string] = new_status if index == nil or thumbnail_map_buffer_size >= state.worker_buffer then report_progress_table(thumbnail_map_buffer) thumbnail_map_buffer = {} thumbnail_map_buffer_size = 0 end end local function set_encoder(encoder) if encoder == 'ffmpeg' then worker_extra.create_command = create_ffmpeg_command else worker_extra.create_command = create_mpv_command if state.is_remote then hack_input() end end worker_extra.args = nil end local function create_thumbnail(time, fullpath) return (run_subprocess(worker_extra.create_command(time, fullpath, false)) and check_existing(fullpath)) or (run_subprocess(worker_extra.create_command(time, fullpath, true)) and check_existing(fullpath)) end local function process_thumbnail() if #work_queue == 0 then return end local worker_stats = worker_stats local status = message.processing local time = table.remove(work_queue, 1) local output = (state.cache_format):format(time) local fullpath = join_paths(state.cache_dir, output) .. state.cache_extension report_progress (time, status) -- Check for existing thumbnail to avoid generation if check_existing(fullpath) then worker_stats.existing = worker_stats.existing + 1 worker_stats.queued = worker_stats.queued - 1 report_progress (time, message.ready) return end -- Generate the thumbnail if create_thumbnail(time, fullpath) then worker_stats.success = worker_stats.success + 1 worker_stats.queued = worker_stats.queued - 1 report_progress (time, message.ready) return end -- Switch to MPV when FFMPEG fails -- if worker_options.encoder == 'ffmpeg' then -- set_encoder('mpv') -- if create_thumbnail(time, fullpath) then -- worker_stats.success = worker_stats.success + 1 -- worker_stats.queued = worker_stats.queued - 1 -- report_progress (time, message.ready) -- return -- end -- end -- If the thumbnail is incomplete, pad it if not check_existing(fullpath) then pad_file(fullpath) end -- Final check if check_existing(fullpath) then worker_stats.success = worker_stats.success + 1 worker_stats.queued = worker_stats.queued - 1 report_progress (time, message.ready) else worker_stats.failed = worker_stats.failed + 1 worker_stats.queued = worker_stats.queued - 1 report_progress (time, message.failed) end end local function process_queue() if not work_queue then return end for _ = 1, #work_queue do if stop_file_exist() then report_progress() break end process_thumbnail() end report_progress() if #work_queue == 0 then mp.command_native({'script-message', message.worker.finish, format_json(worker_stats)}) end end local function create_queue() set_encoder(worker_options.encoder) work_queue = {} local worker_data, work_queue, worker_stats = worker_data, work_queue, worker_stats local time, output, report_queue, used_frames = 0, '', {}, {} for x = 8, 0, -1 do local nth = (2^x) for y = 0, (ceil(state.tn_per_worker) - 1), nth do if not used_frames[y + 1] then time = (worker_data.start_time_index + y) * state.delta output = (state.cache_format):format(time) if check_existing(join_paths(state.cache_dir, output)) then worker_stats.existing = worker_stats.existing + 1 report_queue[(state.cache_format):format(time)] = message.ready elseif time <= state.duration then work_queue[#work_queue + 1] = time worker_stats.queued = worker_stats.queued + 1 report_queue[(state.cache_format):format(time)] = message.queued end used_frames[y + 1] = true end end end report_progress_table(report_queue) end --------------- -- Listeners -- --------------- mp.register_script_message(message.worker.reset, reset_all) mp.register_script_message(message.worker.queue, function(json) local new_data = parse_json(json) if new_data.state then state = new_data.state end if new_data.worker_options then worker_options = new_data.worker_options end if new_data.start_time_index then worker_data.start_time_index = new_data.start_time_index end if not worker_extra.filesize then worker_extra.filesize = (state.width * state.height * 4) end create_queue() end) mp.register_script_message(message.worker.start, process_queue) mp.register_script_message(message.debug, function() msg.info('Thumbnailer Worker Internal States:') msg.info('state:', state and utils.to_string(state) or 'nil') msg.info('worker_data:', worker_data and utils.to_string(worker_data) or 'nil') msg.info('worker_options:', worker_options and utils.to_string(worker_options) or 'nil') msg.info('worker_extra:', worker_extra and utils.to_string(worker_extra) or 'nil') msg.info('worker_stats:', worker_stats and utils.to_string(worker_stats) or 'nil') end)