├── .github └── ISSUE_TEMPLATE │ └── bug-report---dynamic-crop-lua.md ├── LICENSE ├── README.md ├── patch └── ffmpeg │ └── 0001-avfilter-add-vf_dummysync.patch └── dynamic-crop.lua /.github/ISSUE_TEMPLATE/bug-report---dynamic-crop-lua.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report - dynamic-crop.lua 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: dynamic-crop 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Setup (please complete the following information):** 11 | - OS: [e.g. Windows, Android] 12 | - Player: [e.g. mpv-android, jellyfin-media-player] 13 | - Player Version: [e.g. 0.34.0, 1.6.1] 14 | - Video Title: 15 | - Video Quality: [e.g. 1080p H.264] 16 | 17 | **Additional context** 18 | MPV configuration and other scripts / shaders used. 19 | 20 | **Describe the bug** 21 | A clear and concise description of what the bug is. 22 | 23 | **Expected behavior** 24 | A clear and concise description of what you expected to happen. 25 | 26 | **Screenshots** 27 | If applicable, add screenshots to help explain your problem. 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ashyni 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dynamic-crop.lua 2 | 3 | Script to "cropping" dynamically, hard-coded black bars detected with lavfi-cropdetect filter for Ultra Wide Screen or any screen (Smartphone/Tablet). 4 | 5 | ## Status 6 | 7 | It's now really stable, but can probably be improved to handle more case. 8 | 9 | ## Usage 10 | 11 | Save `dynamic-crop.lua` in `~/.config/.mpv/scripts/` (Linux/macOS) or `%AppData%\mpv\scripts\` (Windows). 12 | 13 | Or edit your `mpv.conf` file to add `script=`, use absolute path and don't add it if you already put it in directory `scripts`: 14 | 15 | ``` 16 | ## Example 17 | # Linux/macOS: 18 | script=/home///dynamic-crop.lua 19 | # Windows: 20 | script=C:\Users\\\dynamic-crop.lua 21 | # Android mpv-android: 22 | script=/storage/emulated/0//dynamic-crop.lua 23 | ``` 24 | 25 | ## Features 26 | 27 | - 4 modes available: 0 disable, 1 on-demand, 2 one-shot, 3 dynamic-manual, 4 dynamic-auto. 28 | - Support hardware decoding with *-copy variant only (read_ahead_mode=1/2 required ffmpeg patch to avoid color issue) 29 | - Correction with trusted metadata for fast change in dark/ambiguous scene. 30 | - Support asymmetric offset (Re-center video). 31 | - Auto adjust black threshold (cropdetect=limit). 32 | - Ability to prevent aspect ratio change during a certain time. 33 | - Allows the segmentation of normally continuous data required to approve a new metadata. 34 | - Handle seeking/loading and any change of speed handled by MPV. 35 | - Read ahead cropdetect filter metadata, useful for videos with multiple aspect ratio changes (require ffmpeg master/6+). 36 | 37 | ## Shortcut 38 | 39 | SHIFT+C do: 40 | 41 | Cycle between ENABLE / DISABLE_WITH_CROP / DISABLE 42 | 43 | ## To-Do 44 | 45 | - Improve documentation. 46 | - Improve read_ahead. 47 | 48 | ## Troubleshooting 49 | 50 | To collect the log, add to mpv.conf, `log-file=` 51 | 52 | If the script doesn't work, make sure mpv is build with the libavfilter `crop` and `cropdetect` by starting mpv with `./mpv --vf=help` or by adding at the #1 line in mpv.conf `vf=help` and check the log for `Available libavfilter filters:`. 53 | 54 | Make sure mpv option `hwdec=` is `no`(default) or any `*-copy` ([doc](https://mpv.io/manual/stable/#options-hwdec)), otherwise the script will fail. 55 | 56 | Performance issue with mpv client or specific gpu-api (solved with [patch](https://github.com/FFmpeg/FFmpeg/commit/69c060bea21d3b4ce63b5fff40d37e98c70ab88f)): 57 | - mpv.conf: Try different value for `gpu-api=` ([doc](https://mpv.io/manual/master/#options-gpu-api)). 58 | - Script settings: Increase option `limit_timer` to slow down limit change, main source of performance issue depending on gpu-api used. 59 | - JellyfinMediaPlayer settings: Try with `UseOpenGL`. 60 | 61 | ## Download on phone 62 | 63 | Use the Desktop mode with a navigator on this page to access the button `Code > Download Zip`. 64 | Or transfer it from a computer or any other device. 65 | -------------------------------------------------------------------------------- /patch/ffmpeg/0001-avfilter-add-vf_dummysync.patch: -------------------------------------------------------------------------------- 1 | From bf069ea9d3bff1c1cc9901f5634df156afb1e034 Mon Sep 17 00:00:00 2001 2 | From: Ashyni 3 | Date: Thu, 9 Feb 2023 23:58:18 +0100 4 | Subject: [PATCH] avfilter: add vf_dummysync 5 | 6 | Signed-off-by: Ashyni 7 | --- 8 | doc/filters.texi | 15 +++++ 9 | libavfilter/Makefile | 1 + 10 | libavfilter/allfilters.c | 1 + 11 | libavfilter/vf_dummysync.c | 123 +++++++++++++++++++++++++++++++++++++ 12 | 4 files changed, 140 insertions(+) 13 | create mode 100644 libavfilter/vf_dummysync.c 14 | 15 | diff --git a/doc/filters.texi b/doc/filters.texi 16 | index 347103c04f..8eb15e91e8 100644 17 | --- a/doc/filters.texi 18 | +++ b/doc/filters.texi 19 | @@ -13052,6 +13052,21 @@ For more information about libfribidi, check: 20 | For more information about libharfbuzz, check: 21 | @url{https://github.com/harfbuzz/harfbuzz}. 22 | 23 | +@section dummysync 24 | + 25 | +This filter takes in input two input videos, the first input is considered 26 | +the "main" source and is passed unchanged to the output. 27 | + 28 | +@subsection Examples 29 | +@itemize 30 | +@item 31 | +Read ahead cropdetect metadata with [dummy] pts set 1sec in advance over [main] 32 | +and sync without touching the "main" source. 33 | +@example 34 | +[in]split[main][dummy];[dummy]setpts=PTS-1/TB,cropdetect[dummy]; 35 | +[main][dummy]dummysync[out] 36 | +@end example 37 | + 38 | @section edgedetect 39 | 40 | Detect and draw edges. The filter uses the Canny Edge Detection algorithm. 41 | diff --git a/libavfilter/Makefile b/libavfilter/Makefile 42 | index 5992fd161f..3f90463915 100644 43 | --- a/libavfilter/Makefile 44 | +++ b/libavfilter/Makefile 45 | @@ -292,6 +292,7 @@ OBJS-$(CONFIG_DRAWBOX_FILTER) += vf_drawbox.o 46 | OBJS-$(CONFIG_DRAWGRAPH_FILTER) += f_drawgraph.o 47 | OBJS-$(CONFIG_DRAWGRID_FILTER) += vf_drawbox.o 48 | OBJS-$(CONFIG_DRAWTEXT_FILTER) += vf_drawtext.o textutils.o 49 | +OBJS-$(CONFIG_DUMMYSYNC_FILTER) += vf_dummysync.o framesync.o 50 | OBJS-$(CONFIG_EDGEDETECT_FILTER) += vf_edgedetect.o edge_common.o 51 | OBJS-$(CONFIG_ELBG_FILTER) += vf_elbg.o 52 | OBJS-$(CONFIG_ENTROPY_FILTER) += vf_entropy.o 53 | diff --git a/libavfilter/allfilters.c b/libavfilter/allfilters.c 54 | index c532682fc2..bcf3ae7159 100644 55 | --- a/libavfilter/allfilters.c 56 | +++ b/libavfilter/allfilters.c 57 | @@ -268,6 +268,7 @@ extern const AVFilter ff_vf_drawbox; 58 | extern const AVFilter ff_vf_drawgraph; 59 | extern const AVFilter ff_vf_drawgrid; 60 | extern const AVFilter ff_vf_drawtext; 61 | +extern const AVFilter ff_vf_dummysync; 62 | extern const AVFilter ff_vf_edgedetect; 63 | extern const AVFilter ff_vf_elbg; 64 | extern const AVFilter ff_vf_entropy; 65 | diff --git a/libavfilter/vf_dummysync.c b/libavfilter/vf_dummysync.c 66 | new file mode 100644 67 | index 0000000000..a0fdf97cea 68 | --- /dev/null 69 | +++ b/libavfilter/vf_dummysync.c 70 | @@ -0,0 +1,123 @@ 71 | +/* 72 | + * Copyright (c) 2023 Jeffrey Chapuis 73 | + * 74 | + * This file is part of FFmpeg. 75 | + * 76 | + * FFmpeg is free software; you can redistribute it and/or 77 | + * modify it under the terms of the GNU Lesser General Public 78 | + * License as published by the Free Software Foundation; either 79 | + * version 2.1 of the License, or (at your option) any later version. 80 | + * 81 | + * FFmpeg is distributed in the hope that it will be useful, 82 | + * but WITHOUT ANY WARRANTY; without even the implied warranty of 83 | + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 84 | + * Lesser General Public License for more details. 85 | + * 86 | + * You should have received a copy of the GNU Lesser General Public 87 | + * License along with FFmpeg; if not, write to the Free Software 88 | + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 89 | + */ 90 | + 91 | +/** 92 | + * @file 93 | + * Sync two video streams, output main video stream unchanged. 94 | + */ 95 | + 96 | +#include "framesync.h" 97 | +#include "internal.h" 98 | + 99 | +typedef struct DummySyncContext { 100 | + FFFrameSync fs; 101 | +} DummySyncContext; 102 | + 103 | +static int do_dummysync(FFFrameSync *fs) 104 | +{ 105 | + AVFilterContext *ctx = fs->parent; 106 | + AVFrame *main, *dummy; 107 | + int ret; 108 | + 109 | + if ((ret = ff_framesync_dualinput_get(fs, &main, &dummy)) < 0) 110 | + return ret; 111 | + 112 | + return ff_filter_frame(ctx->outputs[0], main); 113 | +} 114 | + 115 | +static av_cold int init(AVFilterContext *ctx) 116 | +{ 117 | + DummySyncContext *s = ctx->priv; 118 | + 119 | + s->fs.on_event = do_dummysync; 120 | + return 0; 121 | +} 122 | + 123 | +static int config_output(AVFilterLink *outlink) 124 | +{ 125 | + AVFilterContext *ctx = outlink->src; 126 | + DummySyncContext *s = ctx->priv; 127 | + AVFilterLink *mainlink = ctx->inputs[0]; 128 | + int ret; 129 | + 130 | + if ((ret = ff_framesync_init_dualinput(&s->fs, ctx)) < 0) 131 | + return ret; 132 | + 133 | + outlink->w = mainlink->w; 134 | + outlink->h = mainlink->h; 135 | + outlink->time_base = mainlink->time_base; 136 | + outlink->sample_aspect_ratio = mainlink->sample_aspect_ratio; 137 | + outlink->frame_rate = mainlink->frame_rate; 138 | + if ((ret = ff_framesync_configure(&s->fs)) < 0) 139 | + return ret; 140 | + 141 | + outlink->time_base = s->fs.time_base; 142 | + if (av_cmp_q(mainlink->time_base, outlink->time_base) || 143 | + av_cmp_q(ctx->inputs[1]->time_base, outlink->time_base)) 144 | + av_log(ctx, AV_LOG_WARNING, "not matching timebases found between first input: %d/%d and second input %d/%d, results may be incorrect!\n", 145 | + mainlink->time_base.num, mainlink->time_base.den, 146 | + ctx->inputs[1]->time_base.num, ctx->inputs[1]->time_base.den); 147 | + 148 | + return 0; 149 | +} 150 | + 151 | +static int activate(AVFilterContext *ctx) 152 | +{ 153 | + DummySyncContext *s = ctx->priv; 154 | + return ff_framesync_activate(&s->fs); 155 | +} 156 | + 157 | +static av_cold void uninit(AVFilterContext *ctx) 158 | +{ 159 | + DummySyncContext *s = ctx->priv; 160 | + ff_framesync_uninit(&s->fs); 161 | +} 162 | + 163 | +static const AVFilterPad dummysync_inputs[] = { 164 | + { 165 | + .name = "main", 166 | + .type = AVMEDIA_TYPE_VIDEO, 167 | + },{ 168 | + .name = "dummy", 169 | + .type = AVMEDIA_TYPE_VIDEO, 170 | + }, 171 | +}; 172 | + 173 | +static const AVFilterPad dummysync_outputs[] = { 174 | + { 175 | + .name = "default", 176 | + .type = AVMEDIA_TYPE_VIDEO, 177 | + .config_props = config_output, 178 | + }, 179 | +}; 180 | + 181 | +const AVFilter ff_vf_dummysync = { 182 | + .name = "dummysync", 183 | + .description = NULL_IF_CONFIG_SMALL("Sync two video streams, output main video stream unchanged."), 184 | + .init = init, 185 | + .uninit = uninit, 186 | + .activate = activate, 187 | + .priv_size = sizeof(DummySyncContext), 188 | + FILTER_INPUTS(dummysync_inputs), 189 | + FILTER_OUTPUTS(dummysync_outputs), 190 | + .flags = AVFILTER_FLAG_SUPPORT_TIMELINE_INTERNAL | 191 | + AVFILTER_FLAG_SLICE_THREADS | 192 | + AVFILTER_FLAG_METADATA_ONLY, 193 | +}; 194 | -- 195 | 2.45.2 196 | 197 | -------------------------------------------------------------------------------- /dynamic-crop.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | This script uses the lavfi cropdetect filter to automatically insert a crop filter with appropriate parameters 3 | for the currently playing video, the script run continuously by default (mode 4). 4 | 5 | To use this script, "hwdec=no" (mpv default/recommended) or any "-copy" variant like "hwdec=auto-copy" is required, 6 | consider editing "mpv.conf" to an appropriate value. 7 | 8 | The workflow is as follows: We observe ffmpeg log to collect metadata and process it. 9 | Collected metadata are stored sequentially in s.buffer, then process to check and 10 | store trusted values to speed up future change for the current video. 11 | It will automatically crop the video as soon as a change is validated. 12 | 13 | The default options can be overridden by adding a line into "mpv.conf" with: 14 | script-opts-append=-= 15 | script-opts-append=dynamic_crop-mode=0 16 | script-opts-append=dynamic_crop-ratios=2.4 2.39 2 4/3 (quotes aren't needed like below) 17 | 18 | Extended descriptions for some parameters (For default values, see ): 19 | 20 | mode: [0-4] 0 disable, 1 on-demand, 2 one-shot, 3 dynamic-manual, 4 dynamic-auto 21 | Mode 1 and 3 requires using the shortcut to start, 2 and 4 have an automatic start. 22 | 23 | Shortcut "C" (shift+c) to control the script. 24 | Cycle between ENABLE / DISABLE_WITH_CROP / DISABLE 25 | 26 | prevent_change_mode: [0-3] 0 disable 1 keep-largest, 2 keep-lowest, 3 keep-latest 27 | The prevent_change_timer is trigger after a change. 28 | 29 | fix_windowed_behavior: [0-3] Avoid the default behavior that resizes the window to the source size 30 | when the crop filter changes in windowed/maximized mode by adjusting geometry. 31 | 32 | limit_timer: Only used if the cropdetect filter doesn't handle limit changes with a command (patch 01/2023). 33 | Extend the time between each limit change to reduce the impact on performance caused by re-initializing the 34 | full filter. 35 | 36 | read_ahead_mode: Linked to the associated timer and tells how much time in advance to collect the metadata. 37 | This feature is useful for videos with multiple aspect ratio changes for "fast_change_timer". 38 | Note: because this function is in sync with the playback, a delay equivalent to the timer used is 39 | added/reset every time you seek before you get a reaction, so setting 1 is recommanded. 40 | Required at least https://github.com/FFmpeg/FFmpeg/commit/69c060bea21d3b4ce63b5fff40d37e98c70ab88f 41 | and optionally https://github.com/mpv-player/mpv/pull/11182, until mpv patch is being merged to master, 42 | considered this feature experimental because of the errors generated in logs/console by vf-command and 43 | the filter used to sync the filter chain (psnr). 44 | 45 | read_ahead_sync: Compensates for the delay when applying the crop filter and the visible result. 46 | Must be adjusted to your tastes and each MPV client depending on their reaction time. 47 | Note: Perfect adjustment is not really possible but generally <= 1 frame, sometimes more in 48 | dark/ambiguous scenes. 49 | 50 | segmentation: e.g. 0.5 for 50% - Extra time to allow new metadata to be segmented instead of being continuous. 51 | This is used with ratio_timer, offset_timer and fallback_timer. 52 | e.g. ratio_timer is validated with 5 sec accumulated over 7.5 sec elapsed. 53 | ]] -- 54 | require "mp.options" 55 | 56 | -- options 57 | local options = { 58 | -- behavior 59 | mode = 4, -- [0-4] more details above 60 | start_delay = 0, -- delay in seconds used to skip intro (usefull with mode 2) 61 | prevent_change_timer = 30, -- seconds 62 | prevent_change_mode = 0, -- [0-3], more details above 63 | fix_windowed_behavior = 1, -- [0-3], 0 no-fix, 1 fix-no-resize, 2 fix-keep-width, 3 fix-keep-height 64 | limit_timer = 0.5, -- seconds, 0 disable, more details above 65 | fast_change_timer = 0.2, -- seconds, recommanded to keep default or > 0 if read_ahead is supported by mpv 66 | ratio_timer = 2, -- seconds, meta in ratios list 67 | offset_timer = 20, -- seconds, >= 'ratio_timer', new offset for asymmetric video 68 | fallback_timer = 40, -- seconds, >= 'offset_timer', not in ratios list and possibly with new offset 69 | linked_tolerance = 2, -- int, scale with detect_round to match against source width/height 70 | ratios = "2.76 2.55 24/9 2.4 2.39 2.35 2.2 2.1 2 1.9 1.85 16/9 5/3 1.5 1.43 4/3 1.25 9/16", -- list 71 | ratio_tolerance = 2, -- int (even number), adjust in order to match more easly the ratios list 72 | read_ahead_mode = 0, -- [0-2], 0 disable, 1 fast_change_timer, 2 ratio_timer, more details above 73 | read_ahead_sync = 0, -- int/frame, increase for advance, more details above 74 | segmentation = 0.5, -- [0.0-1] %, 0 will approved only a continuous metadata (strict) 75 | crop_method = 1, -- 0 lavfi-crop (ffmpeg/filter), 1 video-crop (mpv/VO) 76 | -- filter, see https://ffmpeg.org/ffmpeg-filters.html#cropdetect for details 77 | detect_limit = 26, -- is the maximum use, increase it slowly if lighter black are present 78 | detect_round = 2, -- even number 79 | -- verbose 80 | debug = false 81 | } 82 | read_options(options) 83 | 84 | if options.mode == 0 then 85 | mp.msg.info("mode = 0, disable script.") 86 | return 87 | end 88 | 89 | -- forward declarations 90 | local cleanup, on_toggle 91 | local s = {} 92 | 93 | -- labels 94 | local label_prefix = mp.get_script_name() 95 | local labels = { 96 | crop = string.format("%s-crop", label_prefix), cropdetect = string.format("%s-cropdetect", label_prefix) 97 | } 98 | 99 | -- shifting decimal to 100 | local LEFT, RIGHT = true, false 101 | local function shifting_to(left, value) 102 | local shift = 1e3 103 | return left and (value / shift) or value >= 1 and math.ceil(value * shift) or value * shift 104 | end 105 | 106 | -- options: compute timer and other stuff 107 | for k, v in pairs(options) do 108 | local timer = string.match(tostring(k), "_timer") 109 | if timer then options[k] = shifting_to(RIGHT, v) end 110 | end 111 | options.read_ahead_timer = 112 | options.read_ahead_mode == 1 and options.fast_change_timer or options.read_ahead_mode == 2 and options.ratio_timer * 113 | (1 + options.segmentation) or nil 114 | options.read_ahead_cropdetect = options.read_ahead_timer and shifting_to(LEFT, options.read_ahead_timer) or nil 115 | options.reverse_segmentation = 1 / (1 * (1 + options.segmentation)) 116 | options.crop_method_sync = options.crop_method == 0 and 1 or 0 -- lavfi-crop is slower, so give it some advance for read_ahead 117 | 118 | local function print_debug(msg_type, meta, label) 119 | if not options.debug then 120 | return 121 | elseif msg_type == "pre_format" then 122 | mp.msg.info(meta) 123 | elseif msg_type == "metadata" then 124 | mp.msg.info(string.format("%s, %-29s | offX:%3s offY:%3s | limit:%-2s", label, meta.whxy, meta.offset.x, 125 | meta.offset.y, s.limit.current)) 126 | elseif msg_type == "buffer" and s.stats.buffer then 127 | mp.msg.info("Buffer stats:") 128 | for whxy, ref in pairs(s.stats.buffer) do 129 | mp.msg.info(string.format( 130 | "\\ %-29s | offX=%4s offY=%4s | time=%6ss linked_source=%-4s known_ratio=%-4s trusted_offsets=%s", whxy, 131 | ref.offset.x, ref.offset.y, shifting_to(LEFT, ref.time.buffer), ref.is_linked_to_source or false, 132 | ref.is_known_ratio or false, ref.is_trusted_offsets)) 133 | end 134 | mp.msg.info("Buffer list:") 135 | for i, v in ipairs(s.buffer.indexed_list) do 136 | local new_ref = v.new_ref and v.new_ref.whxy or "" 137 | local pts = shifting_to(RIGHT, v.pts) 138 | mp.msg.info(string.format("\\ %3s %-29s %4sms pts:%d new_ref:%s", i, v.ref.whxy, v.t_elapsed, pts, new_ref)) 139 | end 140 | mp.msg.info("i_fallback", s.candidate.i_fallback) 141 | mp.msg.info("i_offset", s.candidate.i_offset) 142 | elseif msg_type == "applied" and s.stats.indexed_applied then 143 | mp.msg.info("Applied list:") 144 | for i, v in ipairs(s.stats.indexed_applied) do 145 | mp.msg.info(string.format("\\ %3s %-29s pts:%d", i, v.ref.whxy, shifting_to(RIGHT, v.pts))) 146 | end 147 | end 148 | end 149 | 150 | local function print_stats() 151 | if not s.stats and not s.stats.trusted then return end 152 | mp.msg.info("Meta Stats:") 153 | local offsets_list = {x = "", y = ""} 154 | for axis, _ in pairs(offsets_list) do 155 | for _, v in pairs(s.stats.trusted_offset[axis]) do offsets_list[axis] = offsets_list[axis] .. v .. " " end 156 | end 157 | mp.msg.info( 158 | string.format("Limit - min/max: %s/%s | counter: %s", s.limit.min, options.detect_limit, s.limit.counter)) 159 | mp.msg.info(string.format("Trusted - unique: %s | offset: X:%sY:%s", s.stats.trusted_unique, offsets_list.x, 160 | offsets_list.y)) 161 | for whxy, ref in pairs(s.stats.trusted) do 162 | if s.stats.trusted[whxy] then 163 | mp.msg.info(string.format("\\ %-29s | offX=%3s offY=%3s | applied=%s overall=%ss accumulated=%ss", whxy, 164 | ref.offset.x, ref.offset.y, ref.applied, shifting_to(LEFT, ref.time.overall), 165 | shifting_to(LEFT, ref.time.accumulated))) 166 | end 167 | end 168 | mp.msg.info("Buffer - unique: " .. s.stats.buffer_unique .. " | total: " .. s.buffer.i_total, 169 | shifting_to(LEFT, s.buffer.t_total) .. "s | known_ratio:", s.buffer.i_ratio, 170 | shifting_to(LEFT, s.buffer.t_ratio) .. "s") 171 | end 172 | 173 | local function is_trusted_offset(offset, axis) 174 | local trusted_offset = s.stats.trusted_offset[axis] 175 | for _, v in ipairs(trusted_offset) do if math.abs(offset - v) <= 1 then return true end end 176 | return false 177 | end 178 | 179 | local function is_cropable() 180 | for _, track in pairs(mp.get_property_native('track-list')) do 181 | if track.type == 'video' and track.selected then return not track.albumart end 182 | end 183 | return false 184 | end 185 | 186 | local function filter_state(label, key, value) 187 | local filters = mp.get_property_native("vf") 188 | for _, filter in pairs(filters) do 189 | if filter["label"] == label and 190 | ((not key or key ~= "graph" and filter[key] == value or key == "graph" and 191 | string.find(filter.params.graph, value))) then return true end 192 | end 193 | return false 194 | end 195 | 196 | local function command_filter(label, command, argument, target) 197 | if not s.f_vfcommand then 198 | local res, reason = mp.commandv("vf-command", label, command, argument, target) 199 | if not res and reason == "invalid parameter" then 200 | s.f_vfcommand = true -- if mpv doesn't handle target parameter 201 | end 202 | end 203 | if s.f_vfcommand then 204 | -- fallback and send to all filters inside the graph 205 | mp.commandv("vf-command", label, command, argument) 206 | end 207 | end 208 | 209 | local function insert_cropdetect_filter(limit, change) 210 | if s.toggled > 1 or s.paused then return end 211 | local function insert_filter() 212 | local cropdetect = string.format("cropdetect@dyn_cd=limit=%d/255:round=%d:reset=1", limit, options.detect_round) 213 | if s.f_limit_runtime and change then 214 | command_filter(labels.cropdetect, "limit", string.format("%d/255", limit), "cropdetect") 215 | return true 216 | elseif s.f_limit_runtime and options.read_ahead_mode > 0 then 217 | return mp.commandv("vf", "pre", 218 | string.format("@%s:lavfi=[split[a][b];[b]setpts=PTS-%s/TB,%s[b];%s]", labels.cropdetect, 219 | options.read_ahead_cropdetect, cropdetect, s.f_sync)) 220 | else 221 | return mp.commandv("vf", "pre", string.format("@%s:lavfi=[split[a][b];[b]%s,nullsink;[a]null]", 222 | labels.cropdetect, cropdetect)) 223 | end 224 | end 225 | if not insert_filter() then 226 | mp.msg.error("Does vf=help as #1 line in mvp.conf return libavfilter list with crop/cropdetect in log?") 227 | s.f_missing = true 228 | cleanup() 229 | return 230 | end 231 | if not s.f_limit_runtime then 232 | s.f_inserted = true -- skip process and wait for new s.collected 233 | end 234 | s.f_limit_change = change -- filter is updated for limit change 235 | end 236 | 237 | local function apply_crop(ref, pts) 238 | -- osd size change 239 | -- TODO add auto/smart mode 240 | local prop_fullscreen = mp.get_property("fullscreen") 241 | if prop_fullscreen ~= "yes" and options.fix_windowed_behavior ~= 0 then 242 | local prop_maximized = mp.get_property("window-maximized") 243 | local osd = mp.get_property_native("osd-dimensions") 244 | local prop_auto_window_resize = mp.get_property("auto-window-resize") 245 | if prop_auto_window_resize == "yes" and options.fix_windowed_behavior == 1 then 246 | -- disable auto resize to avoid resizing at the original size of the video 247 | mp.set_property("auto-window-resize", "no") 248 | end 249 | if prop_maximized ~= "yes" then 250 | if options.fix_windowed_behavior == 2 then 251 | mp.set_property("geometry", string.format("%s", osd.w)) 252 | elseif options.fix_windowed_behavior == 3 then 253 | mp.set_property("geometry", string.format("x%s", osd.h)) 254 | end 255 | end 256 | end 257 | 258 | -- crop filter insertion/update 259 | if s.f_video_crop then 260 | mp.set_property("video-crop", string.format("%sx%s+%s+%s", ref.w, ref.h, ref.x, ref.y)) 261 | elseif filter_state(labels.crop) and not s.seeking then 262 | for _, axis in ipairs({"w", "x", "h", "y"}) do -- "w""x" then "h""y" to reduce visual glitch 263 | if s.applied[axis] ~= ref[axis] then command_filter(labels.crop, axis, ref[axis], "crop") end 264 | end 265 | else 266 | mp.commandv("vf", "append", string.format("@%s:lavfi-crop=%s", labels.crop, ref.whxy)) 267 | end 268 | ref.applied = ref.applied + 1 269 | s.applied = ref 270 | 271 | print_debug("pre_format", string.format("- Apply: %s", ref.whxy)) 272 | if options.debug and pts then table.insert(s.stats.indexed_applied, {ref = ref, pts = pts}) end 273 | end 274 | 275 | local function compute_metadata(meta) 276 | meta.whxy = string.format("w=%s:h=%s:x=%s:y=%s", meta.w, meta.h, meta.x, meta.y) 277 | meta.offset = {x = meta.x - (s.source.w - meta.w) / 2, y = meta.y - (s.source.h - meta.h) / 2} 278 | meta.mt = meta.y 279 | meta.mb = s.source.h - meta.h - meta.y 280 | meta.ml = meta.x 281 | meta.mr = s.source.w - meta.w - meta.x 282 | meta.is_source = meta.whxy == s.source.whxy 283 | meta.is_invalid = meta.h < 0 or meta.w < 0 284 | meta.is_trusted_offsets = is_trusted_offset(meta.offset.x, "x") and is_trusted_offset(meta.offset.y, "y") 285 | meta.time = {buffer = 0, overall = 0} 286 | if options.read_ahead_mode > 0 then meta.pts = {} end 287 | local margin = options.detect_round * options.linked_tolerance 288 | meta.is_linked_to_source = meta.mt <= margin and meta.mb <= margin or meta.ml <= margin and meta.mr <= margin 289 | if meta.is_linked_to_source and not meta.is_invalid and s.ratios.w[meta.w] or s.ratios.h[meta.h] then 290 | meta.is_known_ratio = true 291 | end 292 | return meta 293 | end 294 | 295 | local function generate_ratios(list) 296 | for ratio in string.gmatch(list, "%S+%s?") do 297 | for a, b in string.gmatch(tostring(ratio), "(%d+)/(%d+)") do ratio = a / b end 298 | local w, h = math.floor((s.source.h * ratio)), math.floor((s.source.w / ratio)) 299 | local margin = options.ratio_tolerance 300 | for k, v in pairs({w = w, h = h}) do 301 | if v < s.source[k] - options.linked_tolerance then 302 | if v % 2 == 1 then 303 | s.ratios[k][v + 1], s.ratios[k][v - 1] = true, true 304 | if margin > 0 then 305 | s.ratios[k][v + 1 + margin], s.ratios[k][v - 1 - margin] = true, true 306 | end 307 | else 308 | s.ratios[k][v] = true 309 | if margin > 0 then s.ratios[k][v + margin], s.ratios[k][v - margin] = true, true end 310 | end 311 | end 312 | end 313 | end 314 | end 315 | 316 | local function switch_hwdec(id, hwdec, error) 317 | if hwdec ~= "no" and not string.match(hwdec, "-copy") then 318 | local msg = "Switch to SW decoding or HW -copy variant." 319 | mp.msg.info(msg) 320 | mp.osd_message(string.format("%s: %s", label_prefix, msg), 5) 321 | end 322 | if s.hwdec and hwdec ~= s.hwdec and s.hwdec ~= "no" and not string.match(s.hwdec, "-copy") and 323 | filter_state(labels.cropdetect) then mp.commandv("vf", "remove", string.format("@%s", labels.cropdetect)) end 324 | s.hwdec = hwdec 325 | end 326 | 327 | local function process_metadata(collected, timestamp, elapsed_time) 328 | s.in_progress = true -- prevent event race 329 | print_debug("metadata", collected, "Collected") 330 | 331 | local function cleanup_stat(whxy, ref, ref_i, index) 332 | if ref[whxy] then 333 | ref[whxy] = nil 334 | ref_i[index] = ref_i[index] - 1 335 | end 336 | end 337 | 338 | -- buffer: init 339 | if not s.stats.buffer[collected.whxy] then 340 | s.stats.buffer[collected.whxy] = collected 341 | s.stats.buffer_unique = s.stats.buffer_unique + 1 342 | end 343 | 344 | -- buffer: add collected or increase it's timer 345 | if s.buffer.i_total == 0 or s.buffer.indexed_list[s.buffer.i_total].ref ~= collected then 346 | s.buffer.i_total = s.buffer.i_total + 1 347 | s.buffer.i_ratio = s.buffer.i_ratio + 1 348 | s.buffer.indexed_list[s.buffer.i_total] = {ref = collected, pts = timestamp, t_elapsed = elapsed_time} 349 | if options.read_ahead_mode > 0 then table.insert(collected.pts, timestamp) end 350 | elseif s.last_collected == collected then 351 | s.buffer.indexed_list[s.buffer.i_total].t_elapsed = s.buffer.indexed_list[s.buffer.i_total].t_elapsed + 352 | elapsed_time 353 | end 354 | collected.time.overall = collected.time.overall + elapsed_time 355 | collected.time.buffer = collected.time.buffer + elapsed_time 356 | s.buffer.t_total = s.buffer.t_total + elapsed_time 357 | if s.buffer.i_ratio > 0 then s.buffer.t_ratio = s.buffer.t_ratio + elapsed_time end 358 | 359 | -- candidate offset/fallback to later extend buffer size 360 | if not s.stats.trusted[collected.whxy] and collected.time.buffer > options.ratio_timer and 361 | collected.is_linked_to_source then 362 | if not s.candidate.offset[collected.whxy] and not collected.is_trusted_offsets and collected.is_known_ratio then 363 | s.candidate.offset[collected.whxy] = collected 364 | s.candidate.i_offset = s.candidate.i_offset + 1 365 | elseif not collected.is_known_ratio and not s.candidate.offset[collected.whxy] and 366 | not s.candidate.fallback[collected.whxy] then 367 | s.candidate.fallback[collected.whxy] = collected 368 | s.candidate.i_fallback = s.candidate.i_fallback + 1 369 | end 370 | end 371 | 372 | -- add new fallback ratio to the ratio list 373 | if s.candidate.fallback[collected.whxy] and collected.time.buffer >= options.fallback_timer then 374 | -- TODO eventually re-check the buffer list with new ratio 375 | generate_ratios(collected.w .. "/" .. collected.h) 376 | collected.is_known_ratio = true 377 | cleanup_stat(collected.whxy, s.candidate.fallback, s.candidate, "i_fallback") 378 | end 379 | 380 | -- add new offset to the trusted_offsets list 381 | if s.candidate.offset[collected.whxy] and collected.is_known_ratio and collected.is_linked_to_source and 382 | collected.time.buffer >= options.offset_timer then 383 | for _, axis in ipairs({"x", "y"}) do 384 | if not is_trusted_offset(collected.offset[axis], axis) then 385 | table.insert(s.stats.trusted_offset[axis], collected.offset[axis]) 386 | end 387 | end 388 | cleanup_stat(collected.whxy, s.candidate.offset, s.candidate, "i_offset") 389 | collected.is_trusted_offsets = true 390 | end 391 | 392 | -- add collected ready to the trusted list 393 | local new_ready = 394 | not s.stats.trusted[collected.whxy] and collected.is_trusted_offsets and not collected.is_invalid and 395 | collected.is_linked_to_source and collected.is_known_ratio and collected.time.buffer >= options.ratio_timer 396 | if new_ready then 397 | s.stats.trusted[collected.whxy] = collected 398 | s.stats.trusted_unique = s.stats.trusted_unique + 1 399 | collected.applied = 0 400 | collected.time.accumulated = collected.time.buffer 401 | end 402 | 403 | -- use current as main metadata, override by corrected or stabilized if needed 404 | local current = collected 405 | 406 | -- correction with trusted metadata for fast change in dark/ambiguous scene 407 | local corrected = {} 408 | if not current.is_invalid and s.stats.trusted_unique > 1 and not s.stats.trusted[current.whxy] then 409 | -- is_bigger than applied meta 410 | corrected.is_bigger = current.mt < s.approved.mt or current.mb < s.approved.mb or current.ml < s.approved.ml or 411 | current.mr < s.approved.mr 412 | -- find closest trusted metadata 413 | local closest = {} 414 | local margin = options.detect_round * options.linked_tolerance 415 | for _, ref in pairs(s.stats.trusted) do 416 | local diff = {ref = ref, vs_current = 0, vs_applied = 0, total = 0} 417 | for _, side in ipairs({"mt", "mb", "ml", "mr"}) do 418 | diff[side] = current[side] - ref[side] 419 | diff.total = diff.total + math.abs(diff[side]) 420 | if diff[side] > margin or diff[side] < -margin then diff.vs_current = diff.vs_current + 1 end 421 | if ref[side] ~= s.approved[side] then diff.vs_applied = diff.vs_applied + 1 end 422 | end 423 | -- is_inside this trusted meta with tiny tolerance for being outside 424 | diff.is_inside = not (diff.mt < -margin or diff.mb < -margin or diff.ml < -margin or diff.mr < -margin) 425 | local pattern = diff.is_inside and 426 | (diff.vs_current <= 1 or diff.vs_current == 2 and diff.vs_applied <= 2 or 427 | diff.vs_current > 2 and corrected.is_bigger) 428 | local set = closest.ref and 429 | (diff.vs_current < closest.vs_current or diff.vs_current == closest.vs_current and 430 | diff.vs_applied < closest.vs_applied or diff.vs_current == closest.vs_current and 431 | diff.vs_applied == closest.vs_applied and diff.total < closest.total) 432 | -- mp.msg.info(string.format("\\ %-5s %-29s curr:%s appl:%s | %-3s %-3s %-3s %-3s %-4s | is_in:%s ", 433 | -- pattern and (not closest.ref or set), ref.whxy, diff.vs_current, diff.vs_applied, diff.mt, diff.mb, 434 | -- diff.ml, diff.mr, diff.total, diff.is_inside)) 435 | if pattern and (not closest.ref or set) then closest = diff end 436 | end 437 | -- replace current with corrected 438 | if closest.ref then 439 | current = closest.ref 440 | corrected.ref = closest.ref 441 | s.buffer.indexed_list[s.buffer.i_total].new_ref = current 442 | print_debug("metadata", current, "\\ Corrected") 443 | else 444 | print_debug("pre_format", "\\ Uncorrected") 445 | end 446 | end 447 | 448 | -- stabilization of odd/unstable meta 449 | local stabilized 450 | if options.detect_round <= 4 and s.stats.trusted[current.whxy] then 451 | local margin = options.detect_round * 4 452 | local applied_in_margin = math.abs(current.w - s.approved.w) <= margin and math.abs(current.h - s.approved.h) <= 453 | margin 454 | for _, ref in pairs(s.stats.trusted) do 455 | local in_margin = math.abs(current.w - ref.w) <= margin and math.abs(current.h - ref.h) <= margin 456 | if in_margin then 457 | local gt_applied = applied_in_margin and ref ~= s.approved and ref.time.overall > 458 | s.approved.time.overall * 2 459 | local applied_gt = applied_in_margin and ref == s.approved and ref.time.overall * 2 > 460 | current.time.overall 461 | local pattern = not applied_in_margin and ref.time.overall > current.time.overall or gt_applied or 462 | applied_gt 463 | local set = stabilized and ref.time.overall > stabilized.time.overall 464 | -- mp.msg.info("\\", ref.whxy, ref.time.overall, current.time.overall, s.approved.time.overall) 465 | if ref ~= current and pattern and (not stabilized or set) then stabilized = ref end 466 | end 467 | end 468 | if stabilized then 469 | current = stabilized 470 | s.buffer.indexed_list[s.buffer.i_total].new_ref = current 471 | print_debug("metadata", current, "\\ Stabilized") 472 | end 473 | end 474 | 475 | -- cycle time.accumulated for fast_change_timer (reset if uncorrected) 476 | for whxy, ref in pairs(s.stats.trusted) do 477 | ref.time.accumulated = whxy ~= current.whxy and 0 or ref.time.accumulated < 0 and 0 + elapsed_time or 478 | not new_ready and ref.time.accumulated + elapsed_time or ref.time.accumulated 479 | end 480 | 481 | -- crop: final validation then store or apply it 482 | local detect_source = current == s.last_current and (current.is_source or collected.is_source) and s.limit.target >= 483 | 0 484 | local confirmation = not current.is_source and s.stats.trusted[current.whxy] and current.time.accumulated >= 485 | options.fast_change_timer and (not corrected.ref or current == s.last_current) 486 | local crop_filter = s.approved ~= current and (confirmation or detect_source) 487 | if crop_filter and (not s.timestamps.prevent or timestamp >= s.timestamps.prevent) then 488 | s.approved = current -- reflect s.applied for read_head 489 | if s.limit.current < s.limit.min then 490 | s.limit.min = s.limit.current -- store minimum limit 491 | end 492 | if s.f_limit_runtime and options.read_ahead_mode > 0 then 493 | local pts = current.time.accumulated < options.ratio_timer and timestamp - current.time.accumulated or 494 | current.pts[1] 495 | table.insert(s.indexed_read_ahead, {ref = current, pts = pts}) 496 | s.timestamps.read_ahead = nil 497 | else 498 | apply_crop(current, timestamp) 499 | end 500 | if options.prevent_change_mode > 0 then 501 | s.timestamps.prevent = nil 502 | if (options.prevent_change_mode == 1 and (current.w > s.approved.w or current.h > s.approved.h) or 503 | options.prevent_change_mode == 2 and (current.w < s.approved.w or current.h < s.approved.h) or 504 | options.prevent_change_mode == 3) then 505 | s.timestamps.prevent = timestamp + options.prevent_change_timer 506 | end 507 | end 508 | if options.mode <= 2 then on_toggle(true) end 509 | end 510 | 511 | local function is_time_to_cleanup_buffer(time, target_time) 512 | return time > target_time * (1 + options.segmentation) 513 | end 514 | 515 | -- buffer: reduce size of known ratio stats 516 | while is_time_to_cleanup_buffer(s.buffer.t_ratio, options.ratio_timer) do 517 | local i = (s.buffer.i_total + 1) - s.buffer.i_ratio 518 | s.buffer.t_ratio = s.buffer.t_ratio - s.buffer.indexed_list[i].t_elapsed 519 | s.buffer.i_ratio = s.buffer.i_ratio - 1 520 | end 521 | 522 | -- buffer: check for candidate to extend it 523 | local buffer_timer = s.candidate.i_offset > 0 and options.offset_timer or s.candidate.i_fallback > 0 and 524 | options.fallback_timer or options.ratio_timer 525 | 526 | -- buffer: cleanup fake candidate 527 | local function is_proactive_cleanup_needed() 528 | local test 529 | if is_time_to_cleanup_buffer(s.buffer.t_total, options.ratio_timer) then 530 | for _, cat in ipairs({"offset", "fallback"}) do 531 | if s.candidate["i_" .. cat] > 0 then 532 | test = true 533 | for whxy, ref in pairs(s.candidate[cat]) do 534 | if ref.time.buffer > s.buffer.t_total * options.reverse_segmentation then 535 | return false -- if at least one is a proper candidate 536 | end 537 | end 538 | end 539 | end 540 | end 541 | return test 542 | end 543 | 544 | -- buffer: reduce total size 545 | while is_time_to_cleanup_buffer(s.buffer.t_total, buffer_timer) or is_proactive_cleanup_needed() do 546 | s.buffer.i_to_shift = s.buffer.i_to_shift + 1 547 | local entry = s.buffer.indexed_list[s.buffer.i_to_shift] 548 | entry.ref.time.buffer = entry.ref.time.buffer - entry.t_elapsed 549 | if options.read_ahead_mode > 0 then table.remove(entry.ref.pts, 1) end 550 | if s.stats.buffer[entry.ref.whxy] and entry.ref.time.buffer == 0 then 551 | cleanup_stat(entry.ref.whxy, s.stats.buffer, s.stats, "buffer_unique") 552 | cleanup_stat(entry.ref.whxy, s.candidate.offset, s.candidate, "i_offset") 553 | cleanup_stat(entry.ref.whxy, s.candidate.fallback, s.candidate, "i_fallback") 554 | end 555 | s.buffer.t_total = s.buffer.t_total - entry.t_elapsed 556 | end 557 | 558 | -- buffer: shift the list to overwrite unused data 559 | if s.buffer.i_to_shift >= 20 or s.buffer.i_to_shift == s.buffer.i_total then 560 | for i = s.buffer.i_to_shift + 1, s.buffer.i_total do 561 | s.buffer.indexed_list[i - s.buffer.i_to_shift] = s.buffer.indexed_list[i] 562 | end 563 | for i = 0, s.buffer.i_to_shift - 1 do s.buffer.indexed_list[s.buffer.i_total - i] = nil end 564 | s.buffer.i_total = s.buffer.i_total - s.buffer.i_to_shift 565 | s.buffer.i_to_shift = 0 566 | collectgarbage("step") 567 | end 568 | 569 | -- limit: automatic adjustment 570 | s.last_limit = s.limit.current 571 | if s.f_limit_runtime or timestamp >= s.limit.timer then 572 | s.limit.last_target = s.limit.target 573 | if collected.is_source or current.is_source or corrected.is_bigger then 574 | -- increase limit 575 | s.limit.target = 1 576 | if s.limit.current + s.limit.step * s.limit.up <= options.detect_limit then 577 | s.limit.current = s.limit.current + s.limit.step * s.limit.up 578 | else 579 | s.limit.current = options.detect_limit 580 | end 581 | elseif not current.is_invalid and 582 | (collected.is_trusted_offsets or collected == s.last_collected or current == s.last_current) then 583 | -- stable limit 584 | s.limit.target = 0 585 | -- reset limit to help with different dark color 586 | if not current.is_trusted_offsets then s.limit.current = options.detect_limit end 587 | elseif s.limit.current > 0 then 588 | -- decrease limit 589 | s.limit.target = -1 590 | if s.limit.min < s.limit.current and s.limit.last_target == -1 then 591 | s.limit.current = s.limit.min 592 | elseif s.limit.current - s.limit.step >= 0 then 593 | s.limit.current = s.limit.current - s.limit.step 594 | else 595 | s.limit.current = 0 596 | end 597 | end 598 | end 599 | 600 | -- store for next process 601 | s.last_current = current 602 | s.last_collected = collected 603 | s.last_timestamp = timestamp 604 | 605 | -- limit: apply change 606 | if s.last_limit ~= s.limit.current then 607 | if not s.f_limit_runtime and options.limit_timer > 0 then s.limit.timer = timestamp + options.limit_timer end 608 | s.limit.counter = s.limit.counter + 1 609 | insert_cropdetect_filter(s.limit.current, true) 610 | end 611 | 612 | s.in_progress = false 613 | end 614 | 615 | local function time_pos(event, value, err) 616 | if value and s.indexed_read_ahead[1] then 617 | local time_pos = shifting_to(RIGHT, value) 618 | local deviation = math.abs(time_pos - s.pts) 619 | local crop_sync = s.frametime * (options.read_ahead_sync + options.crop_method_sync) 620 | local time_pos_read_ahead = time_pos - (options.read_ahead_timer - deviation - crop_sync) 621 | if time_pos_read_ahead >= s.indexed_read_ahead[1].pts then 622 | apply_crop(s.indexed_read_ahead[1].ref, s.indexed_read_ahead[1].pts) 623 | table.remove(s.indexed_read_ahead, 1) 624 | end 625 | end 626 | end 627 | 628 | local function collect_metadata(event) 629 | if event.prefix == "ffmpeg" and event.level == "v" and string.find(event.text, "^.*dyn_cd: ") and 630 | not (s.seeking or s.paused or s.toggled > 1) then 631 | local tmp = {} 632 | for k, v in string.gmatch(event.text, "(%w+):(%-?%d+%.?%d* )") do tmp[k] = tonumber(v) end 633 | tmp.whxy = string.format("w=%d:h=%d:x=%d:y=%d", tmp.w, tmp.h, tmp.x, tmp.y) 634 | s.pts = shifting_to(LEFT, tmp.pts) 635 | if tmp.whxy ~= s.collected.whxy then 636 | s.collected = s.stats.trusted[tmp.whxy] or s.stats.buffer[tmp.whxy] or compute_metadata(tmp) 637 | end 638 | 639 | s.limit.last_collect = s.limit.collect 640 | s.limit.collect = tmp.limit or s.limit.collect 641 | s.f_limit_runtime = tmp.limit ~= nil -- if ffmpeg is patch for limit change at runtime 642 | 643 | s.timestamps.previous = s.timestamps.current 644 | s.timestamps.current = s.pts 645 | 646 | local wait_limit = s.f_limit_runtime and s.f_limit_change and s.limit.collect == s.limit.last_collect 647 | if not wait_limit then s.f_limit_change = false end 648 | 649 | if s.in_progress or not s.timestamps.previous or wait_limit or s.f_inserted or s.timestamps.current < 650 | options.start_delay then 651 | s.f_inserted = false 652 | return 653 | end 654 | 655 | local elapsed_time = s.timestamps.current - s.timestamps.previous 656 | if not s.frametime or elapsed_time < s.frametime and elapsed_time > 0 then s.frametime = elapsed_time end 657 | 658 | process_metadata(s.collected, s.timestamps.current, elapsed_time) 659 | end 660 | end 661 | 662 | local function seek(event) 663 | if s.seek_done then return end 664 | print_debug("pre_format", string.format("Stop by %s event.", event)) 665 | if event == "seek" or event == "toggle" then 666 | s.timestamps = {} 667 | s.limit.timer = 0 668 | s.approved = s.applied -- re-sync 669 | if event == "seek" then 670 | if s.f_limit_runtime then insert_cropdetect_filter(s.limit.current) end 671 | if not s.f_video_crop and 672 | (filter_state(labels.crop, "enabled", true) or not filter_state(labels.crop) and s.applied ~= s.source) then 673 | apply_crop(s.applied) 674 | end 675 | end 676 | if s.f_limit_runtime then 677 | s.indexed_read_ahead = {} 678 | s.collected = {} 679 | end 680 | s.seek_done = true -- avoid seek() in loop until we resume() 681 | end 682 | end 683 | 684 | local function resume(event) 685 | s.seek_done = false 686 | print_debug("pre_format", string.format("Resume by %s event.", event)) 687 | if event == "toggle" and s.f_limit_runtime or not filter_state(labels.cropdetect) then 688 | insert_cropdetect_filter(s.limit.current) 689 | end 690 | end 691 | 692 | local function playback_events(t, id, error) 693 | if t.event == "seek" then 694 | s.seeking = true 695 | seek(t.event) 696 | else 697 | if not s.paused then resume(t.event) end 698 | s.seeking = false 699 | end 700 | end 701 | 702 | local ENABLE, DISABLE_WITH_CROP, DISABLE = 1, 2, 3 703 | function on_toggle(auto) 704 | if s.f_missing then 705 | mp.osd_message("Libavfilter cropdetect missing", 3) 706 | return 707 | end 708 | local EVENT = "toggle" 709 | if s.toggled == ENABLE then 710 | s.toggled = DISABLE_WITH_CROP 711 | if filter_state(labels.cropdetect, "enabled", true) then 712 | mp.commandv("vf", EVENT, string.format("@%s", labels.cropdetect)) 713 | end 714 | seek(EVENT) 715 | if not auto then mp.osd_message(string.format("%s: disabled, crop remains.", label_prefix), 3) end 716 | elseif s.toggled == DISABLE_WITH_CROP then 717 | s.toggled = DISABLE 718 | if filter_state(labels.cropdetect, "enabled", false) then 719 | if s.f_video_crop then 720 | mp.set_property("video-crop", "") 721 | elseif filter_state(labels.crop, "enabled", true) then 722 | mp.commandv("vf", EVENT, string.format("@%s", labels.crop)) 723 | end 724 | end 725 | if not auto then mp.osd_message(string.format("%s: crop removed.", label_prefix), 3) end 726 | else -- s.toggled == DISABLE 727 | s.toggled = ENABLE 728 | if filter_state(labels.cropdetect, "enabled", false) then 729 | mp.commandv("vf", EVENT, string.format("@%s", labels.cropdetect)) 730 | end 731 | if s.f_video_crop then 732 | apply_crop(s.applied) 733 | elseif filter_state(labels.crop, "enabled", false) then 734 | mp.commandv("vf", EVENT, string.format("@%s", labels.crop)) 735 | end 736 | resume(EVENT) 737 | if not auto then mp.osd_message(string.format("%s: enabled.", label_prefix), 3) end 738 | end 739 | end 740 | 741 | local function pause(event, is_paused) 742 | s.paused = is_paused 743 | if is_paused then 744 | seek(event) 745 | print_stats() 746 | print_debug("buffer") 747 | print_debug("applied") 748 | print_debug("pre_format", "s.approved: " .. s.approved.whxy) 749 | print_debug("pre_format", "s.applied: " .. s.applied.whxy) 750 | if s.indexed_read_ahead[1] then 751 | print_debug("pre_format", "s.indexed_read_ahead[1]: " .. s.indexed_read_ahead[1].ref.whxy) 752 | end 753 | else 754 | if s.toggled == 1 then resume(event) end 755 | end 756 | end 757 | 758 | function cleanup() 759 | if not s.started then return end 760 | if not s.paused then print_stats() end 761 | mp.msg.info("Cleanup...") 762 | mp.set_property("auto-window-resize", s.user_auto_window_resize) 763 | mp.unregister_event(playback_events) 764 | mp.unregister_event(collect_metadata) 765 | mp.unobserve_property(time_pos) 766 | mp.unobserve_property(switch_hwdec) 767 | mp.unobserve_property(pause) 768 | for _, label in pairs(labels) do 769 | if filter_state(label) then mp.commandv("vf", "remove", string.format("@%s", label)) end 770 | end 771 | if s.f_video_crop then mp.set_property("video-crop", "") end 772 | mp.msg.info("Done.") 773 | s.started = false 774 | end 775 | 776 | local function on_start() 777 | mp.msg.info("File loaded.") 778 | if not is_cropable() then 779 | mp.msg.warn("Exit, only works for videos.") 780 | return 781 | end 782 | s.user_geometry = mp.get_property("geometry") 783 | s.user_auto_window_resize = mp.get_property("auto-window-resize") 784 | if options.fix_windowed_behavior == 1 and s.user_auto_window_resize == "yes" then 785 | mp.set_property("auto-window-resize", "no") 786 | end 787 | -- init/re-init stored data 788 | s.buffer = {i_to_shift = 0, i_total = 0, i_ratio = 0, indexed_list = {}, t_total = 0, t_ratio = 0} 789 | s.candidate = {i_fallback = 0, i_offset = 0, fallback = {}, offset = {}} 790 | s.collected = {} 791 | s.indexed_read_ahead = {} 792 | s.limit = { 793 | counter = 0, current = options.detect_limit, min = options.detect_limit, step = 2, target = 0, timer = 0, up = 2 794 | } 795 | s.stats = {applied = {}, buffer = {}, buffer_unique = 0, trusted = {}, trusted_offset = {}, trusted_unique = 1} 796 | s.stats.indexed_applied = {} 797 | s.source = {w_untouched = mp.get_property_number("width"), h_untouched = mp.get_property_number("height")} 798 | s.source.w = math.floor(s.source.w_untouched / options.detect_round) * options.detect_round 799 | s.source.h = math.floor(s.source.h_untouched / options.detect_round) * options.detect_round 800 | s.source.x = math.floor((s.source.w_untouched - s.source.w) / 2) 801 | s.source.y = math.floor((s.source.h_untouched - s.source.h) / 2) 802 | s.stats.trusted_offset = {x = {s.source.x}, y = {s.source.y}} 803 | s.ratios = {w = {}, h = {}} 804 | generate_ratios(options.ratios) 805 | s.source = compute_metadata(s.source) 806 | s.stats.trusted[s.source.whxy] = s.source 807 | s.source.applied = 1 808 | s.source.time.accumulated = 0 809 | s.applied = s.source 810 | s.approved = s.source 811 | s.timestamps = {} 812 | if options.read_ahead_mode > 0 then 813 | -- assume cropdetect is patch for command "limit", fallback at the first collected metadata otherwise. 814 | s.f_limit_runtime = true 815 | -- quick test for dummysync filter 816 | s.f_sync = mp.commandv("vf", "add", string.format("@%s:lavfi=[split[a][b];[a][b]dummysync]", label_prefix)) and 817 | mp.commandv("vf", "remove", string.format("@%s", label_prefix)) and "[a][b]dummysync" or 818 | "[a][b]psnr=eof_action=pass" 819 | end 820 | s.f_video_crop = options.crop_method == 1 and mp.get_property("video-crop") ~= nil -- true if supported 821 | -- register events 822 | mp.register_event("seek", playback_events) 823 | mp.register_event("playback-restart", playback_events) 824 | mp.observe_property("time-pos", "number", time_pos) 825 | mp.observe_property("hwdec", "string", switch_hwdec) 826 | mp.observe_property("pause", "bool", pause) 827 | mp.enable_messages('v') 828 | mp.register_event("log-message", collect_metadata) 829 | s.toggled = (options.mode % 2 == 1) and DISABLE or ENABLE 830 | s.started = true -- everything ready 831 | end 832 | 833 | mp.add_key_binding("C", "toggle_crop", on_toggle) 834 | mp.register_event("end-file", cleanup) 835 | mp.register_event("file-loaded", on_start) 836 | --------------------------------------------------------------------------------