Skip to content

Commit 54e75be

Browse files
committed
Fix issues with black-bar detection and make it more robust
1 parent 1c3f5fb commit 54e75be

4 files changed

Lines changed: 269 additions & 44 deletions

File tree

changelog.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
**<span style="color:#56adda">0.1.8</span>**
2+
- Improvements to black-bar detection
3+
14
**<span style="color:#56adda">0.1.7</span>**
25
- Add support for the STV-AV1 encoder
36

info.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@
1212
"on_worker_process": 1
1313
},
1414
"tags": "video,ffmpeg",
15-
"version": "0.1.7"
15+
"version": "0.1.8"
1616
}

lib/ffmpeg

lib/tools.py

Lines changed: 264 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
import logging
2525
import re
2626
import subprocess
27+
from collections import Counter
28+
from typing import List, Optional, Iterable
2729

2830
from video_transcoder.lib.ffmpeg import StreamMapper
2931

@@ -128,56 +130,276 @@ def get_video_stream_data(streams):
128130

129131
def detect_plack_bars(abspath, probe_data):
130132
"""
131-
Detect if black bars exist
133+
Detect black bars via ffmpeg cropdetect using quorum logic across multiple samples.
132134
133-
Fetch the current video width/height from the file probe
135+
Quorum rules:
136+
- Need at least 2 passes; if first two are identical => stop with that result.
137+
- If first two differ, take a 3rd pass; if 2-of-3 agree => use that.
138+
- If still inconclusive, continue sampling on the cadence until a majority emerges,
139+
or we exhaust feasible windows. 'No crop' is a valid quorum result.
134140
141+
Sampling rules:
142+
- If duration < 60s: one pass over the WHOLE file (no -t window).
143+
- If duration unknown: start at 0s, sample 10s every 30s.
144+
- If 60s ≤ duration ≤ 5min: sample 10s every 60s, starting at 30s.
145+
- If duration > 5min: sample 20s, starting at 60s, every 5 minutes (assumption; see note).
135146
136-
:param abspath:
137-
:param probe_data:
138-
:return:
147+
Returns:
148+
- crop string "w:h:x:y" if a non-trivial crop quorum is reached,
149+
- None if quorum yields 'no crop' or we cannot determine a stable crop.
139150
"""
140151
logger = logging.getLogger("Unmanic.Plugin.video_transcoder")
141152

142-
# Fetch the current video width/height from the file probe
143-
vid_width, vid_height, video_stream_index = get_video_stream_data(probe_data.get('streams'))
144-
145-
# TODO: Detect video duration. Base the ss param off the duration of the video in the probe data
146-
duration = 10
147-
148-
# Run a ffmpeg command to cropdetect
149-
mapper = StreamMapper(logger, ['video', 'audio', 'subtitle', 'data', 'attachment'])
150-
mapper.set_input_file(abspath)
151-
mapper.set_ffmpeg_generic_options(**{"-ss": str(duration)})
152-
mapper.set_ffmpeg_advanced_options(**{"-vframes": '10', '-vf': 'cropdetect'})
153-
mapper.set_output_null()
154-
155-
# Build ffmpeg command for detecting black bars
156-
# TODO: See if we can support hardware decoding here
157-
ffmpeg_args = mapper.get_ffmpeg_args()
158-
ffmpeg_command = ['ffmpeg'] + ffmpeg_args
159-
# Execute ffmpeg
160-
pipe = subprocess.Popen(ffmpeg_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
161-
out, err = pipe.communicate()
162-
raw_results = out.decode("utf-8")
163-
164-
# Parse the output of the ffmpeg command -read the crop value, crop width and crop height into variables
165-
crop_value = None
166-
regex = re.compile(r'\[Parsed_cropdetect.*\].*crop=(\d+:\d+:\d+:\d+)')
167-
findall = re.findall(regex, raw_results)
168-
if findall:
169-
crop_value = findall[-1]
153+
# -------------------------
154+
# Helpers
155+
# -------------------------
156+
def _get_video_duration_seconds_from_probe(_probe) -> Optional[float]:
157+
fmt = _probe.get("format") if isinstance(_probe, dict) else None
158+
if isinstance(fmt, dict):
159+
dur = fmt.get("duration")
160+
if dur is not None:
161+
try:
162+
return float(dur)
163+
except (TypeError, ValueError):
164+
pass
165+
streams = _probe.get("streams") if isinstance(_probe, dict) else None
166+
if isinstance(streams, list):
167+
for s in streams:
168+
if s.get("codec_type") == "video":
169+
dur = s.get("duration")
170+
if dur is not None:
171+
try:
172+
return float(dur)
173+
except (TypeError, ValueError):
174+
pass
175+
if isinstance(fmt, dict) and isinstance(fmt.get("tags"), dict):
176+
t = fmt["tags"]
177+
ts = t.get("DURATION")
178+
if ts and isinstance(ts, str):
179+
parts = ts.split(":")
180+
if len(parts) >= 3:
181+
try:
182+
h = float(parts[0]);
183+
m = float(parts[1]);
184+
s = float(parts[2])
185+
return h * 3600 + m * 60 + s
186+
except (TypeError, ValueError):
187+
pass
188+
return None
189+
190+
def _parse_last_cropdetect(output_text: str) -> Optional[str]:
191+
# Extract the last reported crop=WxH:X:Y
192+
m = re.findall(r'\[Parsed_cropdetect.*\].*crop=(\d+:\d+:\d+:\d+)', output_text)
193+
return m[-1] if m else None
194+
195+
def _ffmpeg_sample(ss: int, t_seconds: Optional[int]) -> str:
196+
"""
197+
Run a sample with cropdetect at a given start time and optional duration.
198+
Returns 'NO_CROP' or a crop string 'w:h:x:y'.
199+
"""
200+
mapper = StreamMapper(logger, ['video', 'audio', 'subtitle', 'data', 'attachment'])
201+
mapper.set_input_file(abspath)
202+
# Seek to the sample start
203+
mapper.set_ffmpeg_generic_options(**{"-ss": str(int(ss))})
204+
205+
# Configure time-based cropdetect filter at sample end timestamp
206+
adv_args = ["-an", "-sn", "-dn"]
207+
adv_kwargs = {"-vf": "cropdetect"}
208+
if t_seconds and t_seconds > 0:
209+
adv_kwargs["-t"] = str(int(t_seconds))
210+
mapper.set_ffmpeg_advanced_options(*adv_args, **adv_kwargs)
211+
mapper.set_output_null()
212+
213+
ffmpeg_command = ['ffmpeg'] + mapper.get_ffmpeg_args()
214+
pipe = subprocess.Popen(ffmpeg_command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
215+
out, _ = pipe.communicate()
216+
raw = out.decode("utf-8", errors="replace")
217+
218+
crop = _parse_last_cropdetect(raw)
219+
return crop if crop else "NO_CROP"
220+
221+
def _gen_starts_known(total: float, first_start: int, step_between_starts: int, window: int, limit: int) -> Iterable[int]:
222+
"""
223+
Generate start times so that each window fits within media (best-effort), up to 'limit' samples.
224+
'step_between_starts' is the distance between window starts, *not* the gap itself.
225+
"""
226+
# Ensure we don't start too close to EOF; keep a 1s buffer
227+
max_start = max(0, int(total) - (window if window else 0) - 1)
228+
s = max(0, int(first_start))
229+
count = 0
230+
while s <= max_start and count < limit:
231+
yield s
232+
s += int(step_between_starts)
233+
count += 1
234+
235+
def _quorum(last_three: List[str]) -> Optional[str]:
236+
"""
237+
Given up to the last 3 observations, return:
238+
- crop string if ≥2 agree on a non-'NO_CROP' value
239+
- None if ≥2 are 'NO_CROP'
240+
- None if no majority yet
241+
"""
242+
if len(last_three) < 2:
243+
return None
244+
if len(last_three) == 2:
245+
a, b = last_three
246+
if a == b:
247+
return None if a == "NO_CROP" else a
248+
return None # need a third to decide
249+
# len == 3
250+
counts = Counter(last_three)
251+
# Prefer a non-trivial crop
252+
for val, cnt in counts.most_common():
253+
if val != "NO_CROP" and cnt >= 2:
254+
return val
255+
if counts.get("NO_CROP", 0) >= 2:
256+
return None
257+
return None
258+
259+
# -------------------------
260+
# Probe & scheduling
261+
# -------------------------
262+
vid_width, vid_height, _ = get_video_stream_data(probe_data.get('streams'))
263+
src_w, src_h = str(vid_width), str(vid_height)
264+
265+
total_duration = _get_video_duration_seconds_from_probe(probe_data)
266+
267+
MAX_SAMPLES = 7
268+
logger.info("[BB Detection] Sampling video file to detect black bars for '%s'", abspath)
269+
270+
# Special case: very short videos (<60s) → single full-file pass
271+
if total_duration is not None and total_duration < 60:
272+
logger.debug("[BB Detection] Duration < 60s. Sampling single full-file pass")
273+
observed = _ffmpeg_sample(ss=0, t_seconds=None)
274+
logger.debug("[BB Detection] Sample #1 @ 0s → %s", observed)
275+
if observed != "NO_CROP":
276+
cw, ch, *_ = observed.split(":")
277+
if cw == src_w and ch == src_h:
278+
return None
279+
logger.debug("[BB Detection] Decision: CROP=%s.", observed)
280+
return observed
281+
return None
282+
283+
# Define sampling parameters
284+
if total_duration is None:
285+
# Unknown duration → 10s every 30s starting at 0s
286+
sample_len = 10
287+
first_start = 0
288+
start_step = 30 # starts at 0,30,60,...
289+
starts_iter = (first_start + i * start_step for i in range(MAX_SAMPLES))
290+
logger.debug("[BB Detection] Unknown video duration. Sampling 10s every 30s starting at 0s (max %d samples)",
291+
MAX_SAMPLES)
292+
293+
elif total_duration <= 5 * 60:
294+
# 60s .. 5min → 10s windows, small gap (~5s) between windows, start at 30s
295+
sample_len = 10
296+
small_gap = 5
297+
first_start = 30
298+
start_step = sample_len + small_gap # 10s window + ~5s gap → next start +15s
299+
starts_iter = _gen_starts_known(total_duration, first_start, start_step, sample_len, MAX_SAMPLES)
300+
logger.debug("[BB Detection] Video duration 60s–5min. Sampling 10s windows, ~5s gap (start step=%ss) starting at 30s",
301+
start_step)
302+
303+
elif total_duration <= 10 * 60:
304+
# 5–10min → 20s windows, ~30s gap, start at 90s (hopefully skip any intros)
305+
sample_len = 20
306+
long_gap = 30
307+
first_start = 90
308+
start_step = sample_len + long_gap # 20 + 30 = 50s between starts
309+
starts_iter = _gen_starts_known(total_duration, first_start, start_step, sample_len, MAX_SAMPLES)
310+
logger.debug("[BB Detection] Video duration 5–10min. Sampling %ss windows, ~%ss gap (start step=%ss) starting at %ss",
311+
sample_len,
312+
long_gap, start_step, first_start)
313+
170314
else:
171-
logger.error("Unable to parse cropdetect from FFmpeg on file %s.", abspath)
315+
# >10min → 20s windows, ~30s gap, start at 5:00 (should skip any intros)
316+
sample_len = 20
317+
long_gap = 90
318+
first_start = 300
319+
start_step = sample_len + long_gap # 20 + 90 = 1:50s between starts
320+
starts_iter = _gen_starts_known(total_duration, first_start, start_step, sample_len, MAX_SAMPLES)
321+
logger.debug("[BB Detection] Video duration >10min. Sampling %ss windows, ~%ss gap (start step=%ss) starting at %ss",
322+
sample_len,
323+
long_gap, start_step, first_start)
324+
325+
# -------------------------
326+
# Rolling quorum loop (last 3)
327+
# -------------------------
328+
last_three: List[str] = []
329+
third_sample_value: Optional[str] = None # for fallback
330+
samples_taken = 0
331+
332+
for ss in starts_iter:
333+
if samples_taken >= MAX_SAMPLES:
334+
break
335+
336+
observed = _ffmpeg_sample(ss=int(ss), t_seconds=sample_len)
172337

173-
if crop_value:
174-
crop_width = crop_value.split(':')[0]
175-
crop_height = crop_value.split(':')[1]
338+
# Normalize native-size crop to NO_CROP
339+
if observed != "NO_CROP":
340+
cw, ch, *_ = observed.split(":")
341+
if cw == src_w and ch == src_h:
342+
logger.debug(
343+
"[BB Detection] Sample @ %ss returned native-sized crop %sx%s; treating as NO_CROP.",
344+
ss, cw, ch
345+
)
346+
observed = "NO_CROP"
176347

177-
# If the crop width and crop height are the same as the current video width/height, return None
178-
if str(crop_width) == str(vid_width) and str(crop_height) == str(vid_height):
179-
# Video is already cropped to the correct resolution
180-
logger.debug("File '%s' is already cropped to the resolution %sx%s.", abspath, crop_width, crop_height)
348+
samples_taken += 1
349+
if samples_taken == 3:
350+
third_sample_value = observed
351+
352+
# Maintain rolling window of last 3
353+
last_three.append(observed)
354+
if len(last_three) > 3:
355+
last_three.pop(0)
356+
357+
logger.debug("[BB Detection] Sample #%d @ %ss → %s (current sample results=%s)",
358+
samples_taken, ss, observed, last_three)
359+
360+
# Early stop after 2 if identical
361+
if len(last_three) == 2 and last_three[0] == last_three[1]:
362+
if last_three[0] == "NO_CROP":
363+
logger.debug("[BB Detection] Decision: NO_CROP (2/2 agreement).")
364+
return None
365+
logger.debug("[BB Detection] Decision: CROP=%s (2/2 agreement).", last_three[0])
366+
return last_three[0]
367+
368+
# From 3 onward: check 2-of-3 quorum on the rolling window
369+
if len(last_three) == 3:
370+
decision = _quorum(last_three)
371+
if decision is not None:
372+
# non-trivial crop has 2-of-3
373+
logger.debug("[BB Detection] Decision: CROP=%s (2/3 majority on %s).",
374+
decision, last_three)
375+
return decision
376+
if last_three.count("NO_CROP") >= 2:
377+
logger.debug("[BB Detection] Decision: NO_CROP (2/3 majority on %s).", last_three)
378+
return None
379+
380+
# -------------------------
381+
# Fallbacks
382+
# -------------------------
383+
# No quorum reached within cap/available windows → use the 3rd sample's result
384+
if third_sample_value is not None:
385+
if third_sample_value == "NO_CROP":
386+
logger.debug("[BB Detection] No quorum after %d sample(s); fallback to 3rd sample → NO_CROP.",
387+
samples_taken)
181388
return None
389+
logger.debug("[BB Detection] No quorum after %d sample(s); fallback to 3rd sample → CROP=%s.",
390+
samples_taken, third_sample_value)
391+
return third_sample_value
392+
393+
# If we never reached 3 samples, use whatever we have.
394+
# NOTE: this would only happen if we hit a video that was not long enough to take 3 samples
395+
if last_three:
396+
# If any non-NO_CROP present, pick the most recent one
397+
for v in reversed(last_three):
398+
if v != "NO_CROP":
399+
logger.debug("[BB Detection] Best-effort fallback after %d sample(s) → CROP=%s.",
400+
samples_taken, v)
401+
return v
182402

183-
return crop_value
403+
logger.debug("[BB Detection] Decision: NO_CROP (no majority, no usable fallback after %d sample(s)).",
404+
samples_taken)
405+
return None

0 commit comments

Comments
 (0)