|
24 | 24 | import logging |
25 | 25 | import re |
26 | 26 | import subprocess |
| 27 | +from collections import Counter |
| 28 | +from typing import List, Optional, Iterable |
27 | 29 |
|
28 | 30 | from video_transcoder.lib.ffmpeg import StreamMapper |
29 | 31 |
|
@@ -128,56 +130,276 @@ def get_video_stream_data(streams): |
128 | 130 |
|
129 | 131 | def detect_plack_bars(abspath, probe_data): |
130 | 132 | """ |
131 | | - Detect if black bars exist |
| 133 | + Detect black bars via ffmpeg cropdetect using quorum logic across multiple samples. |
132 | 134 |
|
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. |
134 | 140 |
|
| 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). |
135 | 146 |
|
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. |
139 | 150 | """ |
140 | 151 | logger = logging.getLogger("Unmanic.Plugin.video_transcoder") |
141 | 152 |
|
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 | + |
170 | 314 | 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) |
172 | 337 |
|
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" |
176 | 347 |
|
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) |
181 | 388 | 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 |
182 | 402 |
|
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