From 345cb53f4e3a552a7f85f81e39bc77d3a9de465f Mon Sep 17 00:00:00 2001 From: Mike Lang Date: Fri, 9 Jul 2021 01:36:08 +1000 Subject: [PATCH] review: allow manual start selection * return start video * use ubuntu instead of alpine because ffmpeg in alpine lacks drawtext support * only cache if inputs are exactly the same, preventing accidents (prev scheme too weak) * fix a bug where end range is wrong if start_range doesn't start at 0 conceptually, this was a more serious bug as time_offset was wrong, but luckily the bug applied equally to both racers so there was no net effect --- restreamer/Dockerfile | 6 +-- restreamer/restreamer/main.py | 16 +++++- restreamer/restreamer/review.py | 87 +++++++++++++++++++-------------- 3 files changed, 66 insertions(+), 43 deletions(-) diff --git a/restreamer/Dockerfile b/restreamer/Dockerfile index 0522150..6a14350 100644 --- a/restreamer/Dockerfile +++ b/restreamer/Dockerfile @@ -1,7 +1,6 @@ -FROM alpine:3.7 +FROM ubuntu:18.04 # dependencies needed for compiling c extensions -# also busybox-extras for telnet for easier use of backdoor -RUN apk --update add py2-pip gcc python-dev musl-dev file make busybox-extras +RUN chmod 1777 /tmp && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install --yes python-pip python-dev file gcc make ffmpeg # Install gevent so that we don't need to re-install it when common changes RUN pip install gevent @@ -11,7 +10,6 @@ COPY common /tmp/common RUN pip install /tmp/common && rm -r /tmp/common # Install actual application -RUN apk add ffmpeg COPY restreamer /tmp/restreamer RUN pip install /tmp/restreamer && rm -r /tmp/restreamer diff --git a/restreamer/restreamer/main.py b/restreamer/restreamer/main.py index 707d301..2a5487f 100644 --- a/restreamer/restreamer/main.py +++ b/restreamer/restreamer/main.py @@ -365,20 +365,32 @@ def review_race(match_id, race_number): race start time to look for the start signal. Default 0,5. finish_range: As start_range, but how long to make the final review video before/after the nominal duration. Default -5,10. + racer1_start, racer2_start: Explicit start times, as float. """ if app.condor_db is None: return "Reviews are disabled", 501 start_range = map(float, request.args.get('start_range', '0,5').split(',')) finish_range = map(float, request.args.get('finish_range', '-5,10').split(',')) + racer1_start = float(request.args['racer1_start']) if 'racer1_start' in request.args else None + racer2_start = float(request.args['racer2_start']) if 'racer2_start' in request.args else None try: - review_path = review(match_id, race_number, app.static_folder, app.condor_db, start_range, finish_range) + review_path = review( + match_id, race_number, app.static_folder, app.condor_db, start_range, finish_range, + racer1_start, racer2_start, + ) except RaceNotFound as e: return str(e), 404 except NoSegments: logging.warning("Failed review due to no segments", exc_info=True) return "Video content is missing - cannot review automatically", 400 except CantFindStart as e: - return str(e), 400 + return ( + "{}\n" + "Please check start video and adjust start_range or set racer{}_start: {}\n" + "Note timestamps in that video are only valid for the current start_range.\n" + ).format( + e, e.racer_number, os.path.join(app.static_url_path, os.path.relpath(e.path, app.static_folder)) + ), 400 relative_path = os.path.relpath(review_path, app.static_folder) review_url = os.path.join(app.static_url_path, relative_path) diff --git a/restreamer/restreamer/review.py b/restreamer/restreamer/review.py index 7da20c0..79ccc7e 100644 --- a/restreamer/restreamer/review.py +++ b/restreamer/restreamer/review.py @@ -3,6 +3,7 @@ import datetime import logging import os import subprocess +from hashlib import sha256 from urlparse import urlparse from uuid import uuid4 @@ -21,14 +22,16 @@ class RaceNotFound(Exception): class CantFindStart(Exception): - def __init__(self, racer, found): + def __init__(self, racer, racer_number, found, path): self.racer = racer + self.racer_number = racer_number self.found = found + self.path = path def __str__(self): if self.found > 0: - return "Found multiple ({}) possible start points for {}".format(self.found, self.racer) + return "Found multiple ({}) possible start points for racer {} ({})".format(self.found, self.racer_number, self.racer) else: - return "Failed to find start point for {}".format(self.racer) + return "Failed to find start point for racer {} ({})".format(self.racer_number, self.racer) def ts(dt): @@ -77,7 +80,10 @@ def conn_from_url(url): ) -def review(match_id, race_number, base_dir, db_url, start_range=(0, 5), finish_range=(-5, 10)): +def review( + match_id, race_number, base_dir, db_url, start_range=(0, 5), finish_range=(-5, 10), + racer1_start=None, racer2_start=None, +): logger = logging.getLogger("review").getChild("{}-{}".format(match_id, race_number)) conn = conn_from_url(db_url) @@ -111,11 +117,15 @@ def review(match_id, race_number, base_dir, db_url, start_range=(0, 5), finish_r (racer1, racer2, start, duration), = data end = start + datetime.timedelta(seconds=duration/100.) + # cache hash encapsulates all input args + cache_hash = sha256(str((match_id, race_number, start_range, finish_range, racer1_start, racer2_start))) + cache_str = cache_hash.digest().encode('base64')[:12] + output_name = "{}-{}-{}-{}".format(match_id, racer1, racer2, race_number) output_dir = os.path.join(base_dir, "reviews", output_name) if not os.path.exists(output_dir): os.makedirs(output_dir) - result_name = "review_{}_{}.mp4".format(*finish_range) + result_name = "review_{}.mp4".format(cache_str) result_path = os.path.join(output_dir, result_name) if os.path.exists(result_path): logger.info("Result already exists for {}, reusing".format(result_path)) @@ -123,39 +133,42 @@ def review(match_id, race_number, base_dir, db_url, start_range=(0, 5), finish_r finish_paths = [] - for racer_number, racer in enumerate((racer1, racer2)): + for racer_index, (racer, time_offset) in enumerate(((racer1, racer1_start), (racer2, racer2_start))): nonce = str(uuid4()) - start_path = os.path.join(output_dir, "start-{}-{}.mp4".format(racer_number, nonce)) - - logger.info("Cutting start for racer {} ({})".format(racer_number, racer)) - start_start, start_end = add_range(start, start_range) - cut_to_file(logger, start_path, base_dir, racer, start_start, start_end) - - logger.info("Running blackdetect") - args = [ - 'ffmpeg', '-hide_banner', - '-i', start_path, - '-vf', 'blackdetect=d=0.1', - '-f', 'null', '/dev/null' - ] - proc = subprocess.Popen(args, stderr=subprocess.PIPE) - out, err = proc.communicate() - if proc.wait() != 0: - raise Exception("ffmpeg exited {}\n{}".format(proc.wait(), err)) - lines = [ - line for line in err.strip().split('\n') - if line.startswith('[blackdetect @ ') - ] - if len(lines) == 1: - line, = lines - black_end = line.split(' ')[4] - assert black_end.startswith('black_end:') - time_offset = float(black_end.split(':')[1]) - else: - found = len(lines) - logger.warning("Unable to detect start (expected 1 black interval, but found {})".format(found)) - raise CantFindStart(racer, found) - time_offset = datetime.timedelta(seconds=time_offset) + racer_number = racer_index + 1 + + if time_offset is None: + start_path = os.path.join(output_dir, "start-{}-{}.mp4".format(racer_number, nonce)) + logger.info("Cutting start for racer {} ({})".format(racer_number, racer)) + start_start, start_end = add_range(start, start_range) + cut_to_file(logger, start_path, base_dir, racer, start_start, start_end) + + logger.info("Running blackdetect") + args = [ + 'ffmpeg', '-hide_banner', + '-i', start_path, + '-vf', 'blackdetect=d=0.1', + '-f', 'null', '/dev/null' + ] + proc = subprocess.Popen(args, stderr=subprocess.PIPE) + out, err = proc.communicate() + if proc.wait() != 0: + raise Exception("ffmpeg exited {}\n{}".format(proc.wait(), err)) + lines = [ + line for line in err.strip().split('\n') + if line.startswith('[blackdetect @ ') + ] + if len(lines) == 1: + line, = lines + black_end = line.split(' ')[4] + assert black_end.startswith('black_end:') + time_offset = float(black_end.split(':')[1]) + else: + found = len(lines) + logger.warning("Unable to detect start (expected 1 black interval, but found {}), re-cutting with timestamps".format(found)) + cut_to_file(logger, start_path, base_dir, racer, start_start, start_end, frame_counter=True) + raise CantFindStart(racer, racer_number, found, start_path) + time_offset = datetime.timedelta(seconds=time_offset - start_range[0]) # start each racer's finish video at TIME_OFFSET later, so they are the same # time since their actual start.