-
Notifications
You must be signed in to change notification settings - Fork 6
/
dynamic-crop.lua
835 lines (765 loc) · 39.8 KB
/
dynamic-crop.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
--[[
This script uses the lavfi cropdetect filter to automatically insert a crop filter with appropriate parameters
for the currently playing video, the script run continuously by default (mode 4).
To use this script, "hwdec=no" (mpv default/recommended) or any "-copy" variant like "hwdec=auto-copy" is required,
consider editing "mpv.conf" to an appropriate value.
The workflow is as follows: We observe ffmpeg log to collect metadata and process it.
Collected metadata are stored sequentially in s.buffer, then process to check and
store trusted values to speed up future change for the current video.
It will automatically crop the video as soon as a change is validated.
The default options can be overridden by adding a line into "mpv.conf" with:
script-opts-append=<script_name>-<parameter>=<value>
script-opts-append=dynamic_crop-mode=0
script-opts-append=dynamic_crop-ratios=2.4 2.39 2 4/3 (quotes aren't needed like below)
Extended descriptions for some parameters (For default values, see <options>):
mode: [0-4] 0 disable, 1 on-demand, 2 one-shot, 3 dynamic-manual, 4 dynamic-auto
Mode 1 and 3 requires using the shortcut to start, 2 and 4 have an automatic start.
Shortcut "C" (shift+c) to control the script.
Cycle between ENABLE / DISABLE_WITH_CROP / DISABLE
prevent_change_mode: [0-3] 0 disable 1 keep-largest, 2 keep-lowest, 3 keep-latest
The prevent_change_timer is trigger after a change.
fix_windowed_behavior: [0-3] Avoid the default behavior that resizes the window to the source size
when the crop filter changes in windowed/maximized mode by adjusting geometry.
limit_timer: Only used if the cropdetect filter doesn't handle limit changes with a command (patch 01/2023).
Extend the time between each limit change to reduce the impact on performance caused by re-initializing the
full filter.
read_ahead_mode: Linked to the associated timer and tells how much time in advance to collect the metadata.
This feature is useful for videos with multiple aspect ratio changes for "fast_change_timer".
Note: because this function is in sync with the playback, a delay equivalent to the timer used is
added/reset every time you seek before you get a reaction, so setting 1 is recommanded.
Required at least https://github.com/FFmpeg/FFmpeg/commit/69c060bea21d3b4ce63b5fff40d37e98c70ab88f
and optionally https://github.com/mpv-player/mpv/pull/11182, until mpv patch is being merged to master,
considered this feature experimental because of the errors generated in logs/console by vf-command and
the filter used to sync the filter chain (psnr).
read_ahead_sync: Compensates for the delay when applying the crop filter and the visible result.
Must be adjusted to your tastes and each MPV client depending on their reaction time.
Note: Perfect adjustment is not really possible but generally <= 1 frame, sometimes more in
dark/ambiguous scenes.
segmentation: e.g. 0.5 for 50% - Extra time to allow new metadata to be segmented instead of being continuous.
This is used with ratio_timer, offset_timer and fallback_timer.
e.g. ratio_timer is validated with 5 sec accumulated over 7.5 sec elapsed.
]] --
require "mp.options"
-- options
local options = {
-- behavior
mode = 4, -- [0-4] more details above
start_delay = 0, -- delay in seconds used to skip intro (usefull with mode 2)
prevent_change_timer = 30, -- seconds
prevent_change_mode = 0, -- [0-3], more details above
fix_windowed_behavior = 1, -- [0-3], 0 no-fix, 1 fix-no-resize, 2 fix-keep-width, 3 fix-keep-height
limit_timer = 0.5, -- seconds, 0 disable, more details above
fast_change_timer = 0.2, -- seconds, recommanded to keep default or > 0 if read_ahead is supported by mpv
ratio_timer = 2, -- seconds, meta in ratios list
offset_timer = 20, -- seconds, >= 'ratio_timer', new offset for asymmetric video
fallback_timer = 40, -- seconds, >= 'offset_timer', not in ratios list and possibly with new offset
linked_tolerance = 2, -- int, scale with detect_round to match against source width/height
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
ratio_tolerance = 2, -- int (even number), adjust in order to match more easly the ratios list
read_ahead_mode = 0, -- [0-2], 0 disable, 1 fast_change_timer, 2 ratio_timer, more details above
read_ahead_sync = 0, -- int/frame, increase for advance, more details above
segmentation = 0.5, -- [0.0-1] %, 0 will approved only a continuous metadata (strict)
crop_method = 1, -- 0 lavfi-crop (ffmpeg/filter), 1 video-crop (mpv/VO)
-- filter, see https://ffmpeg.org/ffmpeg-filters.html#cropdetect for details
detect_limit = 26, -- is the maximum use, increase it slowly if lighter black are present
detect_round = 2, -- even number
-- verbose
debug = false
}
read_options(options)
if options.mode == 0 then
mp.msg.info("mode = 0, disable script.")
return
end
-- forward declarations
local cleanup, on_toggle
local s = {}
-- labels
local label_prefix = mp.get_script_name()
local labels = {
crop = string.format("%s-crop", label_prefix), cropdetect = string.format("%s-cropdetect", label_prefix)
}
-- shifting decimal to
local LEFT, RIGHT = true, false
local function shifting_to(left, value)
local shift = 1e3
return left and (value / shift) or value >= 1 and math.ceil(value * shift) or value * shift
end
-- options: compute timer and other stuff
for k, v in pairs(options) do
local timer = string.match(tostring(k), "_timer")
if timer then options[k] = shifting_to(RIGHT, v) end
end
options.read_ahead_timer =
options.read_ahead_mode == 1 and options.fast_change_timer or options.read_ahead_mode == 2 and options.ratio_timer *
(1 + options.segmentation) or nil
options.read_ahead_cropdetect = options.read_ahead_timer and shifting_to(LEFT, options.read_ahead_timer) or nil
options.reverse_segmentation = 1 / (1 * (1 + options.segmentation))
options.crop_method_sync = options.crop_method == 0 and 1 or 0 -- lavfi-crop is slower, so give it some advance for read_ahead
local function print_debug(msg_type, meta, label)
if not options.debug then
return
elseif msg_type == "pre_format" then
mp.msg.info(meta)
elseif msg_type == "metadata" then
mp.msg.info(string.format("%s, %-29s | offX:%3s offY:%3s | limit:%-2s", label, meta.whxy, meta.offset.x,
meta.offset.y, s.limit.current))
elseif msg_type == "buffer" and s.stats.buffer then
mp.msg.info("Buffer stats:")
for whxy, ref in pairs(s.stats.buffer) do
mp.msg.info(string.format(
"\\ %-29s | offX=%4s offY=%4s | time=%6ss linked_source=%-4s known_ratio=%-4s trusted_offsets=%s", whxy,
ref.offset.x, ref.offset.y, shifting_to(LEFT, ref.time.buffer), ref.is_linked_to_source or false,
ref.is_known_ratio or false, ref.is_trusted_offsets))
end
mp.msg.info("Buffer list:")
for i, v in ipairs(s.buffer.indexed_list) do
local new_ref = v.new_ref and v.new_ref.whxy or ""
local pts = shifting_to(RIGHT, v.pts)
mp.msg.info(string.format("\\ %3s %-29s %4sms pts:%d new_ref:%s", i, v.ref.whxy, v.t_elapsed, pts, new_ref))
end
mp.msg.info("i_fallback", s.candidate.i_fallback)
mp.msg.info("i_offset", s.candidate.i_offset)
elseif msg_type == "applied" and s.stats.indexed_applied then
mp.msg.info("Applied list:")
for i, v in ipairs(s.stats.indexed_applied) do
mp.msg.info(string.format("\\ %3s %-29s pts:%d", i, v.ref.whxy, shifting_to(RIGHT, v.pts)))
end
end
end
local function print_stats()
if not s.stats and not s.stats.trusted then return end
mp.msg.info("Meta Stats:")
local offsets_list = {x = "", y = ""}
for axis, _ in pairs(offsets_list) do
for _, v in pairs(s.stats.trusted_offset[axis]) do offsets_list[axis] = offsets_list[axis] .. v .. " " end
end
mp.msg.info(
string.format("Limit - min/max: %s/%s | counter: %s", s.limit.min, options.detect_limit, s.limit.counter))
mp.msg.info(string.format("Trusted - unique: %s | offset: X:%sY:%s", s.stats.trusted_unique, offsets_list.x,
offsets_list.y))
for whxy, ref in pairs(s.stats.trusted) do
if s.stats.trusted[whxy] then
mp.msg.info(string.format("\\ %-29s | offX=%3s offY=%3s | applied=%s overall=%ss accumulated=%ss", whxy,
ref.offset.x, ref.offset.y, ref.applied, shifting_to(LEFT, ref.time.overall),
shifting_to(LEFT, ref.time.accumulated)))
end
end
mp.msg.info("Buffer - unique: " .. s.stats.buffer_unique .. " | total: " .. s.buffer.i_total,
shifting_to(LEFT, s.buffer.t_total) .. "s | known_ratio:", s.buffer.i_ratio,
shifting_to(LEFT, s.buffer.t_ratio) .. "s")
end
local function is_trusted_offset(offset, axis)
local trusted_offset = s.stats.trusted_offset[axis]
for _, v in ipairs(trusted_offset) do if math.abs(offset - v) <= 1 then return true end end
return false
end
local function is_cropable()
for _, track in pairs(mp.get_property_native('track-list')) do
if track.type == 'video' and track.selected then return not track.albumart end
end
return false
end
local function filter_state(label, key, value)
local filters = mp.get_property_native("vf")
for _, filter in pairs(filters) do
if filter["label"] == label and
((not key or key ~= "graph" and filter[key] == value or key == "graph" and
string.find(filter.params.graph, value))) then return true end
end
return false
end
local function command_filter(label, command, argument, target)
if not s.f_vfcommand then
local res, reason = mp.commandv("vf-command", label, command, argument, target)
if not res and reason == "invalid parameter" then
s.f_vfcommand = true -- if mpv doesn't handle target parameter
end
end
if s.f_vfcommand then
-- fallback and send to all filters inside the graph
mp.commandv("vf-command", label, command, argument)
end
end
local function insert_cropdetect_filter(limit, change)
if s.toggled > 1 or s.paused then return end
local function insert_filter()
local cropdetect = string.format("cropdetect@dyn_cd=limit=%d/255:round=%d:reset=1", limit, options.detect_round)
if s.f_limit_runtime and change then
command_filter(labels.cropdetect, "limit", string.format("%d/255", limit), "cropdetect")
return true
elseif s.f_limit_runtime and options.read_ahead_mode > 0 then
return mp.commandv("vf", "pre",
string.format("@%s:lavfi=[split[a][b];[b]setpts=PTS-%s/TB,%s[b];%s]", labels.cropdetect,
options.read_ahead_cropdetect, cropdetect, s.f_sync))
else
return mp.commandv("vf", "pre", string.format("@%s:lavfi=[split[a][b];[b]%s,nullsink;[a]null]",
labels.cropdetect, cropdetect))
end
end
if not insert_filter() then
mp.msg.error("Does vf=help as #1 line in mvp.conf return libavfilter list with crop/cropdetect in log?")
s.f_missing = true
cleanup()
return
end
if not s.f_limit_runtime then
s.f_inserted = true -- skip process and wait for new s.collected
end
s.f_limit_change = change -- filter is updated for limit change
end
local function apply_crop(ref, pts)
-- osd size change
-- TODO add auto/smart mode
local prop_fullscreen = mp.get_property("fullscreen")
if prop_fullscreen ~= "yes" and options.fix_windowed_behavior ~= 0 then
local prop_maximized = mp.get_property("window-maximized")
local osd = mp.get_property_native("osd-dimensions")
local prop_auto_window_resize = mp.get_property("auto-window-resize")
if prop_auto_window_resize == "yes" and options.fix_windowed_behavior == 1 then
-- disable auto resize to avoid resizing at the original size of the video
mp.set_property("auto-window-resize", "no")
end
if prop_maximized ~= "yes" then
if options.fix_windowed_behavior == 2 then
mp.set_property("geometry", string.format("%s", osd.w))
elseif options.fix_windowed_behavior == 3 then
mp.set_property("geometry", string.format("x%s", osd.h))
end
end
end
-- crop filter insertion/update
if s.f_video_crop then
mp.set_property("video-crop", string.format("%sx%s+%s+%s", ref.w, ref.h, ref.x, ref.y))
elseif filter_state(labels.crop) and not s.seeking then
for _, axis in ipairs({"w", "x", "h", "y"}) do -- "w""x" then "h""y" to reduce visual glitch
if s.applied[axis] ~= ref[axis] then command_filter(labels.crop, axis, ref[axis], "crop") end
end
else
mp.commandv("vf", "append", string.format("@%s:lavfi-crop=%s", labels.crop, ref.whxy))
end
ref.applied = ref.applied + 1
s.applied = ref
print_debug("pre_format", string.format("- Apply: %s", ref.whxy))
if options.debug and pts then table.insert(s.stats.indexed_applied, {ref = ref, pts = pts}) end
end
local function compute_metadata(meta)
meta.whxy = string.format("w=%s:h=%s:x=%s:y=%s", meta.w, meta.h, meta.x, meta.y)
meta.offset = {x = meta.x - (s.source.w - meta.w) / 2, y = meta.y - (s.source.h - meta.h) / 2}
meta.mt = meta.y
meta.mb = s.source.h - meta.h - meta.y
meta.ml = meta.x
meta.mr = s.source.w - meta.w - meta.x
meta.is_source = meta.whxy == s.source.whxy
meta.is_invalid = meta.h < 0 or meta.w < 0
meta.is_trusted_offsets = is_trusted_offset(meta.offset.x, "x") and is_trusted_offset(meta.offset.y, "y")
meta.time = {buffer = 0, overall = 0}
if options.read_ahead_mode > 0 then meta.pts = {} end
local margin = options.detect_round * options.linked_tolerance
meta.is_linked_to_source = meta.mt <= margin and meta.mb <= margin or meta.ml <= margin and meta.mr <= margin
if meta.is_linked_to_source and not meta.is_invalid and s.ratios.w[meta.w] or s.ratios.h[meta.h] then
meta.is_known_ratio = true
end
return meta
end
local function generate_ratios(list)
for ratio in string.gmatch(list, "%S+%s?") do
for a, b in string.gmatch(tostring(ratio), "(%d+)/(%d+)") do ratio = a / b end
local w, h = math.floor((s.source.h * ratio)), math.floor((s.source.w / ratio))
local margin = options.ratio_tolerance
for k, v in pairs({w = w, h = h}) do
if v < s.source[k] - options.linked_tolerance then
if v % 2 == 1 then
s.ratios[k][v + 1], s.ratios[k][v - 1] = true, true
if margin > 0 then
s.ratios[k][v + 1 + margin], s.ratios[k][v - 1 - margin] = true, true
end
else
s.ratios[k][v] = true
if margin > 0 then s.ratios[k][v + margin], s.ratios[k][v - margin] = true, true end
end
end
end
end
end
local function switch_hwdec(id, hwdec, error)
if hwdec ~= "no" and not string.match(hwdec, "-copy") then
local msg = "Switch to SW decoding or HW -copy variant."
mp.msg.info(msg)
mp.osd_message(string.format("%s: %s", label_prefix, msg), 5)
end
if s.hwdec and hwdec ~= s.hwdec and s.hwdec ~= "no" and not string.match(s.hwdec, "-copy") and
filter_state(labels.cropdetect) then mp.commandv("vf", "remove", string.format("@%s", labels.cropdetect)) end
s.hwdec = hwdec
end
local function process_metadata(collected, timestamp, elapsed_time)
s.in_progress = true -- prevent event race
print_debug("metadata", collected, "Collected")
local function cleanup_stat(whxy, ref, ref_i, index)
if ref[whxy] then
ref[whxy] = nil
ref_i[index] = ref_i[index] - 1
end
end
-- buffer: init
if not s.stats.buffer[collected.whxy] then
s.stats.buffer[collected.whxy] = collected
s.stats.buffer_unique = s.stats.buffer_unique + 1
end
-- buffer: add collected or increase it's timer
if s.buffer.i_total == 0 or s.buffer.indexed_list[s.buffer.i_total].ref ~= collected then
s.buffer.i_total = s.buffer.i_total + 1
s.buffer.i_ratio = s.buffer.i_ratio + 1
s.buffer.indexed_list[s.buffer.i_total] = {ref = collected, pts = timestamp, t_elapsed = elapsed_time}
if options.read_ahead_mode > 0 then table.insert(collected.pts, timestamp) end
elseif s.last_collected == collected then
s.buffer.indexed_list[s.buffer.i_total].t_elapsed = s.buffer.indexed_list[s.buffer.i_total].t_elapsed +
elapsed_time
end
collected.time.overall = collected.time.overall + elapsed_time
collected.time.buffer = collected.time.buffer + elapsed_time
s.buffer.t_total = s.buffer.t_total + elapsed_time
if s.buffer.i_ratio > 0 then s.buffer.t_ratio = s.buffer.t_ratio + elapsed_time end
-- candidate offset/fallback to later extend buffer size
if not s.stats.trusted[collected.whxy] and collected.time.buffer > options.ratio_timer and
collected.is_linked_to_source then
if not s.candidate.offset[collected.whxy] and not collected.is_trusted_offsets and collected.is_known_ratio then
s.candidate.offset[collected.whxy] = collected
s.candidate.i_offset = s.candidate.i_offset + 1
elseif not collected.is_known_ratio and not s.candidate.offset[collected.whxy] and
not s.candidate.fallback[collected.whxy] then
s.candidate.fallback[collected.whxy] = collected
s.candidate.i_fallback = s.candidate.i_fallback + 1
end
end
-- add new fallback ratio to the ratio list
if s.candidate.fallback[collected.whxy] and collected.time.buffer >= options.fallback_timer then
-- TODO eventually re-check the buffer list with new ratio
generate_ratios(collected.w .. "/" .. collected.h)
collected.is_known_ratio = true
cleanup_stat(collected.whxy, s.candidate.fallback, s.candidate, "i_fallback")
end
-- add new offset to the trusted_offsets list
if s.candidate.offset[collected.whxy] and collected.is_known_ratio and collected.is_linked_to_source and
collected.time.buffer >= options.offset_timer then
for _, axis in ipairs({"x", "y"}) do
if not is_trusted_offset(collected.offset[axis], axis) then
table.insert(s.stats.trusted_offset[axis], collected.offset[axis])
end
end
cleanup_stat(collected.whxy, s.candidate.offset, s.candidate, "i_offset")
collected.is_trusted_offsets = true
end
-- add collected ready to the trusted list
local new_ready =
not s.stats.trusted[collected.whxy] and collected.is_trusted_offsets and not collected.is_invalid and
collected.is_linked_to_source and collected.is_known_ratio and collected.time.buffer >= options.ratio_timer
if new_ready then
s.stats.trusted[collected.whxy] = collected
s.stats.trusted_unique = s.stats.trusted_unique + 1
collected.applied = 0
collected.time.accumulated = collected.time.buffer
end
-- use current as main metadata, override by corrected or stabilized if needed
local current = collected
-- correction with trusted metadata for fast change in dark/ambiguous scene
local corrected = {}
if not current.is_invalid and s.stats.trusted_unique > 1 and not s.stats.trusted[current.whxy] then
-- is_bigger than applied meta
corrected.is_bigger = current.mt < s.approved.mt or current.mb < s.approved.mb or current.ml < s.approved.ml or
current.mr < s.approved.mr
-- find closest trusted metadata
local closest = {}
local margin = options.detect_round * options.linked_tolerance
for _, ref in pairs(s.stats.trusted) do
local diff = {ref = ref, vs_current = 0, vs_applied = 0, total = 0}
for _, side in ipairs({"mt", "mb", "ml", "mr"}) do
diff[side] = current[side] - ref[side]
diff.total = diff.total + math.abs(diff[side])
if diff[side] > margin or diff[side] < -margin then diff.vs_current = diff.vs_current + 1 end
if ref[side] ~= s.approved[side] then diff.vs_applied = diff.vs_applied + 1 end
end
-- is_inside this trusted meta with tiny tolerance for being outside
diff.is_inside = not (diff.mt < -margin or diff.mb < -margin or diff.ml < -margin or diff.mr < -margin)
local pattern = diff.is_inside and
(diff.vs_current <= 1 or diff.vs_current == 2 and diff.vs_applied <= 2 or
diff.vs_current > 2 and corrected.is_bigger)
local set = closest.ref and
(diff.vs_current < closest.vs_current or diff.vs_current == closest.vs_current and
diff.vs_applied < closest.vs_applied or diff.vs_current == closest.vs_current and
diff.vs_applied == closest.vs_applied and diff.total < closest.total)
-- mp.msg.info(string.format("\\ %-5s %-29s curr:%s appl:%s | %-3s %-3s %-3s %-3s %-4s | is_in:%s ",
-- pattern and (not closest.ref or set), ref.whxy, diff.vs_current, diff.vs_applied, diff.mt, diff.mb,
-- diff.ml, diff.mr, diff.total, diff.is_inside))
if pattern and (not closest.ref or set) then closest = diff end
end
-- replace current with corrected
if closest.ref then
current = closest.ref
corrected.ref = closest.ref
s.buffer.indexed_list[s.buffer.i_total].new_ref = current
print_debug("metadata", current, "\\ Corrected")
else
print_debug("pre_format", "\\ Uncorrected")
end
end
-- stabilization of odd/unstable meta
local stabilized
if options.detect_round <= 4 and s.stats.trusted[current.whxy] then
local margin = options.detect_round * 4
local applied_in_margin = math.abs(current.w - s.approved.w) <= margin and math.abs(current.h - s.approved.h) <=
margin
for _, ref in pairs(s.stats.trusted) do
local in_margin = math.abs(current.w - ref.w) <= margin and math.abs(current.h - ref.h) <= margin
if in_margin then
local gt_applied = applied_in_margin and ref ~= s.approved and ref.time.overall >
s.approved.time.overall * 2
local applied_gt = applied_in_margin and ref == s.approved and ref.time.overall * 2 >
current.time.overall
local pattern = not applied_in_margin and ref.time.overall > current.time.overall or gt_applied or
applied_gt
local set = stabilized and ref.time.overall > stabilized.time.overall
-- mp.msg.info("\\", ref.whxy, ref.time.overall, current.time.overall, s.approved.time.overall)
if ref ~= current and pattern and (not stabilized or set) then stabilized = ref end
end
end
if stabilized then
current = stabilized
s.buffer.indexed_list[s.buffer.i_total].new_ref = current
print_debug("metadata", current, "\\ Stabilized")
end
end
-- cycle time.accumulated for fast_change_timer (reset if uncorrected)
for whxy, ref in pairs(s.stats.trusted) do
ref.time.accumulated = whxy ~= current.whxy and 0 or ref.time.accumulated < 0 and 0 + elapsed_time or
not new_ready and ref.time.accumulated + elapsed_time or ref.time.accumulated
end
-- crop: final validation then store or apply it
local detect_source = current == s.last_current and (current.is_source or collected.is_source) and s.limit.target >=
0
local confirmation = not current.is_source and s.stats.trusted[current.whxy] and current.time.accumulated >=
options.fast_change_timer and (not corrected.ref or current == s.last_current)
local crop_filter = s.approved ~= current and (confirmation or detect_source)
if crop_filter and (not s.timestamps.prevent or timestamp >= s.timestamps.prevent) then
s.approved = current -- reflect s.applied for read_head
if s.limit.current < s.limit.min then
s.limit.min = s.limit.current -- store minimum limit
end
if s.f_limit_runtime and options.read_ahead_mode > 0 then
local pts = current.time.accumulated < options.ratio_timer and timestamp - current.time.accumulated or
current.pts[1]
table.insert(s.indexed_read_ahead, {ref = current, pts = pts})
s.timestamps.read_ahead = nil
else
apply_crop(current, timestamp)
end
if options.prevent_change_mode > 0 then
s.timestamps.prevent = nil
if (options.prevent_change_mode == 1 and (current.w > s.approved.w or current.h > s.approved.h) or
options.prevent_change_mode == 2 and (current.w < s.approved.w or current.h < s.approved.h) or
options.prevent_change_mode == 3) then
s.timestamps.prevent = timestamp + options.prevent_change_timer
end
end
if options.mode <= 2 then on_toggle(true) end
end
local function is_time_to_cleanup_buffer(time, target_time)
return time > target_time * (1 + options.segmentation)
end
-- buffer: reduce size of known ratio stats
while is_time_to_cleanup_buffer(s.buffer.t_ratio, options.ratio_timer) do
local i = (s.buffer.i_total + 1) - s.buffer.i_ratio
s.buffer.t_ratio = s.buffer.t_ratio - s.buffer.indexed_list[i].t_elapsed
s.buffer.i_ratio = s.buffer.i_ratio - 1
end
-- buffer: check for candidate to extend it
local buffer_timer = s.candidate.i_offset > 0 and options.offset_timer or s.candidate.i_fallback > 0 and
options.fallback_timer or options.ratio_timer
-- buffer: cleanup fake candidate
local function is_proactive_cleanup_needed()
local test
if is_time_to_cleanup_buffer(s.buffer.t_total, options.ratio_timer) then
for _, cat in ipairs({"offset", "fallback"}) do
if s.candidate["i_" .. cat] > 0 then
test = true
for whxy, ref in pairs(s.candidate[cat]) do
if ref.time.buffer > s.buffer.t_total * options.reverse_segmentation then
return false -- if at least one is a proper candidate
end
end
end
end
end
return test
end
-- buffer: reduce total size
while is_time_to_cleanup_buffer(s.buffer.t_total, buffer_timer) or is_proactive_cleanup_needed() do
s.buffer.i_to_shift = s.buffer.i_to_shift + 1
local entry = s.buffer.indexed_list[s.buffer.i_to_shift]
entry.ref.time.buffer = entry.ref.time.buffer - entry.t_elapsed
if options.read_ahead_mode > 0 then table.remove(entry.ref.pts, 1) end
if s.stats.buffer[entry.ref.whxy] and entry.ref.time.buffer == 0 then
cleanup_stat(entry.ref.whxy, s.stats.buffer, s.stats, "buffer_unique")
cleanup_stat(entry.ref.whxy, s.candidate.offset, s.candidate, "i_offset")
cleanup_stat(entry.ref.whxy, s.candidate.fallback, s.candidate, "i_fallback")
end
s.buffer.t_total = s.buffer.t_total - entry.t_elapsed
end
-- buffer: shift the list to overwrite unused data
if s.buffer.i_to_shift >= 20 or s.buffer.i_to_shift == s.buffer.i_total then
for i = s.buffer.i_to_shift + 1, s.buffer.i_total do
s.buffer.indexed_list[i - s.buffer.i_to_shift] = s.buffer.indexed_list[i]
end
for i = 0, s.buffer.i_to_shift - 1 do s.buffer.indexed_list[s.buffer.i_total - i] = nil end
s.buffer.i_total = s.buffer.i_total - s.buffer.i_to_shift
s.buffer.i_to_shift = 0
collectgarbage("step")
end
-- limit: automatic adjustment
s.last_limit = s.limit.current
if s.f_limit_runtime or timestamp >= s.limit.timer then
s.limit.last_target = s.limit.target
if collected.is_source or current.is_source or corrected.is_bigger then
-- increase limit
s.limit.target = 1
if s.limit.current + s.limit.step * s.limit.up <= options.detect_limit then
s.limit.current = s.limit.current + s.limit.step * s.limit.up
else
s.limit.current = options.detect_limit
end
elseif not current.is_invalid and
(collected.is_trusted_offsets or collected == s.last_collected or current == s.last_current) then
-- stable limit
s.limit.target = 0
-- reset limit to help with different dark color
if not current.is_trusted_offsets then s.limit.current = options.detect_limit end
elseif s.limit.current > 0 then
-- decrease limit
s.limit.target = -1
if s.limit.min < s.limit.current and s.limit.last_target == -1 then
s.limit.current = s.limit.min
elseif s.limit.current - s.limit.step >= 0 then
s.limit.current = s.limit.current - s.limit.step
else
s.limit.current = 0
end
end
end
-- store for next process
s.last_current = current
s.last_collected = collected
s.last_timestamp = timestamp
-- limit: apply change
if s.last_limit ~= s.limit.current then
if not s.f_limit_runtime and options.limit_timer > 0 then s.limit.timer = timestamp + options.limit_timer end
s.limit.counter = s.limit.counter + 1
insert_cropdetect_filter(s.limit.current, true)
end
s.in_progress = false
end
local function time_pos(event, value, err)
if value and s.indexed_read_ahead[1] then
local time_pos = shifting_to(RIGHT, value)
local deviation = math.abs(time_pos - s.pts)
local crop_sync = s.frametime * (options.read_ahead_sync + options.crop_method_sync)
local time_pos_read_ahead = time_pos - (options.read_ahead_timer - deviation - crop_sync)
if time_pos_read_ahead >= s.indexed_read_ahead[1].pts then
apply_crop(s.indexed_read_ahead[1].ref, s.indexed_read_ahead[1].pts)
table.remove(s.indexed_read_ahead, 1)
end
end
end
local function collect_metadata(event)
if event.prefix == "ffmpeg" and event.level == "v" and string.find(event.text, "^.*dyn_cd: ") and
not (s.seeking or s.paused or s.toggled > 1) then
local tmp = {}
for k, v in string.gmatch(event.text, "(%w+):(%-?%d+%.?%d* )") do tmp[k] = tonumber(v) end
tmp.whxy = string.format("w=%d:h=%d:x=%d:y=%d", tmp.w, tmp.h, tmp.x, tmp.y)
s.pts = shifting_to(LEFT, tmp.pts)
if tmp.whxy ~= s.collected.whxy then
s.collected = s.stats.trusted[tmp.whxy] or s.stats.buffer[tmp.whxy] or compute_metadata(tmp)
end
s.limit.last_collect = s.limit.collect
s.limit.collect = tmp.limit or s.limit.collect
s.f_limit_runtime = tmp.limit ~= nil -- if ffmpeg is patch for limit change at runtime
s.timestamps.previous = s.timestamps.current
s.timestamps.current = s.pts
local wait_limit = s.f_limit_runtime and s.f_limit_change and s.limit.collect == s.limit.last_collect
if not wait_limit then s.f_limit_change = false end
if s.in_progress or not s.timestamps.previous or wait_limit or s.f_inserted or s.timestamps.current <
options.start_delay then
s.f_inserted = false
return
end
local elapsed_time = s.timestamps.current - s.timestamps.previous
if not s.frametime or elapsed_time < s.frametime and elapsed_time > 0 then s.frametime = elapsed_time end
process_metadata(s.collected, s.timestamps.current, elapsed_time)
end
end
local function seek(event)
if s.seek_done then return end
print_debug("pre_format", string.format("Stop by %s event.", event))
if event == "seek" or event == "toggle" then
s.timestamps = {}
s.limit.timer = 0
s.approved = s.applied -- re-sync
if event == "seek" then
if s.f_limit_runtime then insert_cropdetect_filter(s.limit.current) end
if not s.f_video_crop and
(filter_state(labels.crop, "enabled", true) or not filter_state(labels.crop) and s.applied ~= s.source) then
apply_crop(s.applied)
end
end
if s.f_limit_runtime then
s.indexed_read_ahead = {}
s.collected = {}
end
s.seek_done = true -- avoid seek() in loop until we resume()
end
end
local function resume(event)
s.seek_done = false
print_debug("pre_format", string.format("Resume by %s event.", event))
if event == "toggle" and s.f_limit_runtime or not filter_state(labels.cropdetect) then
insert_cropdetect_filter(s.limit.current)
end
end
local function playback_events(t, id, error)
if t.event == "seek" then
s.seeking = true
seek(t.event)
else
if not s.paused then resume(t.event) end
s.seeking = false
end
end
local ENABLE, DISABLE_WITH_CROP, DISABLE = 1, 2, 3
function on_toggle(auto)
if s.f_missing then
mp.osd_message("Libavfilter cropdetect missing", 3)
return
end
local EVENT = "toggle"
if s.toggled == ENABLE then
s.toggled = DISABLE_WITH_CROP
if filter_state(labels.cropdetect, "enabled", true) then
mp.commandv("vf", EVENT, string.format("@%s", labels.cropdetect))
end
seek(EVENT)
if not auto then mp.osd_message(string.format("%s: disabled, crop remains.", label_prefix), 3) end
elseif s.toggled == DISABLE_WITH_CROP then
s.toggled = DISABLE
if filter_state(labels.cropdetect, "enabled", false) then
if s.f_video_crop then
mp.set_property("video-crop", "")
elseif filter_state(labels.crop, "enabled", true) then
mp.commandv("vf", EVENT, string.format("@%s", labels.crop))
end
end
if not auto then mp.osd_message(string.format("%s: crop removed.", label_prefix), 3) end
else -- s.toggled == DISABLE
s.toggled = ENABLE
if filter_state(labels.cropdetect, "enabled", false) then
mp.commandv("vf", EVENT, string.format("@%s", labels.cropdetect))
end
if s.f_video_crop then
apply_crop(s.applied)
elseif filter_state(labels.crop, "enabled", false) then
mp.commandv("vf", EVENT, string.format("@%s", labels.crop))
end
resume(EVENT)
if not auto then mp.osd_message(string.format("%s: enabled.", label_prefix), 3) end
end
end
local function pause(event, is_paused)
s.paused = is_paused
if is_paused then
seek(event)
print_stats()
print_debug("buffer")
print_debug("applied")
print_debug("pre_format", "s.approved: " .. s.approved.whxy)
print_debug("pre_format", "s.applied: " .. s.applied.whxy)
if s.indexed_read_ahead[1] then
print_debug("pre_format", "s.indexed_read_ahead[1]: " .. s.indexed_read_ahead[1].ref.whxy)
end
else
if s.toggled == 1 then resume(event) end
end
end
function cleanup()
if not s.started then return end
if not s.paused then print_stats() end
mp.msg.info("Cleanup...")
mp.set_property("auto-window-resize", s.user_auto_window_resize)
mp.unregister_event(playback_events)
mp.unregister_event(collect_metadata)
mp.unobserve_property(time_pos)
mp.unobserve_property(switch_hwdec)
mp.unobserve_property(pause)
for _, label in pairs(labels) do
if filter_state(label) then mp.commandv("vf", "remove", string.format("@%s", label)) end
end
if s.f_video_crop then mp.set_property("video-crop", "") end
mp.msg.info("Done.")
s.started = false
end
local function on_start()
mp.msg.info("File loaded.")
if not is_cropable() then
mp.msg.warn("Exit, only works for videos.")
return
end
s.user_geometry = mp.get_property("geometry")
s.user_auto_window_resize = mp.get_property("auto-window-resize")
if options.fix_windowed_behavior == 1 and s.user_auto_window_resize == "yes" then
mp.set_property("auto-window-resize", "no")
end
-- init/re-init stored data
s.buffer = {i_to_shift = 0, i_total = 0, i_ratio = 0, indexed_list = {}, t_total = 0, t_ratio = 0}
s.candidate = {i_fallback = 0, i_offset = 0, fallback = {}, offset = {}}
s.collected = {}
s.indexed_read_ahead = {}
s.limit = {
counter = 0, current = options.detect_limit, min = options.detect_limit, step = 2, target = 0, timer = 0, up = 2
}
s.stats = {applied = {}, buffer = {}, buffer_unique = 0, trusted = {}, trusted_offset = {}, trusted_unique = 1}
s.stats.indexed_applied = {}
s.source = {w_untouched = mp.get_property_number("width"), h_untouched = mp.get_property_number("height")}
s.source.w = math.floor(s.source.w_untouched / options.detect_round) * options.detect_round
s.source.h = math.floor(s.source.h_untouched / options.detect_round) * options.detect_round
s.source.x = math.floor((s.source.w_untouched - s.source.w) / 2)
s.source.y = math.floor((s.source.h_untouched - s.source.h) / 2)
s.stats.trusted_offset = {x = {s.source.x}, y = {s.source.y}}
s.ratios = {w = {}, h = {}}
generate_ratios(options.ratios)
s.source = compute_metadata(s.source)
s.stats.trusted[s.source.whxy] = s.source
s.source.applied = 1
s.source.time.accumulated = 0
s.applied = s.source
s.approved = s.source
s.timestamps = {}
if options.read_ahead_mode > 0 then
-- assume cropdetect is patch for command "limit", fallback at the first collected metadata otherwise.
s.f_limit_runtime = true
-- quick test for dummysync filter
s.f_sync = mp.commandv("vf", "add", string.format("@%s:lavfi=[split[a][b];[a][b]dummysync]", label_prefix)) and
mp.commandv("vf", "remove", string.format("@%s", label_prefix)) and "[a][b]dummysync" or
"[a][b]psnr=eof_action=pass"
end
s.f_video_crop = options.crop_method == 1 and mp.get_property("video-crop") ~= nil -- true if supported
-- register events
mp.register_event("seek", playback_events)
mp.register_event("playback-restart", playback_events)
mp.observe_property("time-pos", "number", time_pos)
mp.observe_property("hwdec", "string", switch_hwdec)
mp.observe_property("pause", "bool", pause)
mp.enable_messages('v')
mp.register_event("log-message", collect_metadata)
s.toggled = (options.mode % 2 == 1) and DISABLE or ENABLE
s.started = true -- everything ready
end
mp.add_key_binding("C", "toggle_crop", on_toggle)
mp.register_event("end-file", cleanup)
mp.register_event("file-loaded", on_start)