From 04ae9dd6950705d1b8773a7c549f7b0decd280ee Mon Sep 17 00:00:00 2001 From: Mike Lang Date: Wed, 7 Jul 2021 21:54:16 +1000 Subject: [PATCH] Finish adapting cut_review for restreamer --- cut_review.py | 2 +- restreamer/restreamer/main.py | 35 +++++++++++- restreamer/restreamer/review.py | 98 ++++++++++++++++++++++----------- 3 files changed, 101 insertions(+), 34 deletions(-) diff --git a/cut_review.py b/cut_review.py index df204ff..eb78216 100644 --- a/cut_review.py +++ b/cut_review.py @@ -146,7 +146,7 @@ def main(match_id, race_number, else: print "Unable to detect start (expected 1 black interval, but found {}).".format(len(lines)) print "Cutting file {} for manual detection.".format(start_path) - cut_to_file(start_path, base_dir, racer, start, start + datetime.timedelta(seconds=5), frame_counter=True) + cut_to_file(start_path, base_dir, racer, start_start, start_end, frame_counter=True) time_offset = float(raw_input("What timestamp of this video do we start at? ")) time_offset = datetime.timedelta(seconds=time_offset) diff --git a/restreamer/restreamer/main.py b/restreamer/restreamer/main.py index 39436c2..2d56a16 100644 --- a/restreamer/restreamer/main.py +++ b/restreamer/restreamer/main.py @@ -10,13 +10,14 @@ import signal import gevent import gevent.backdoor import prometheus_client as prom -from flask import Flask, url_for, request, abort, Response +from flask import Flask, url_for, request, abort, redirect, Response from gevent.pywsgi import WSGIServer from common import dateutil, get_best_segments, rough_cut_segments, smart_cut_segments, fast_cut_segments, full_cut_segments, PromLogCountsHandler, install_stacksampler from common.flask_stats import request_stats, after_request import generate_hls +from .review import review, NoSegments, RaceNotFound, CantFindStart app = Flask('restreamer', static_url_path='/segments') @@ -355,8 +356,38 @@ def generate_videos(channel, quality): write_file() -def main(host='0.0.0.0', port=8000, base_dir='.', backdoor_port=0): +@app.route('/review//') +@request_stats +def review_race(match_id, race_number): + """Cut a condor race review for given match id and race number. + Params: + start_range: Two numbers, comma-seperated. How long before and after the nominal + 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. + """ + 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', '0,5').split(',')) + try: + review_path = review(match_id, race_number, app.static_folder, app.condor_db, start_range, finish_range) + 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 + + relative_path = os.path.relpath(review_path, app.static_folder) + review_url = os.path.join(app.static_url_path, relative_path) + return redirect(review_url) + + +def main(host='0.0.0.0', port=8000, base_dir='.', backdoor_port=0, condor_db=None): app.static_folder = base_dir + app.condor_db = condor_db server = WSGIServer((host, port), cors(app)) def stop(): diff --git a/restreamer/restreamer/review.py b/restreamer/restreamer/review.py index 5823fea..559c38d 100644 --- a/restreamer/restreamer/review.py +++ b/restreamer/restreamer/review.py @@ -3,6 +3,7 @@ import datetime import logging import os import subprocess +from urlparse import urlparse import mysql.connector @@ -14,14 +15,33 @@ class NoSegments(Exception): pass -def cut_to_file(filename, base_dir, stream, start, end, variant='source', frame_counter=False): - logging.info("Cutting {}".format(filename)) +class RaceNotFound(Exception): + pass + + +class CantFindStart(Exception): + def __init__(self, racer, found): + self.racer = racer + self.found = found + def __str__(self): + if self.found > 0: + return "Found multiple ({}) possible start points for {}".format(self.found, self.racer) + else: + return "Failed to find start point for {}".format(self.racer) + + +def ts(dt): + return dt.strftime("%FT%T") + + +def cut_to_file(logger, filename, base_dir, stream, start, end, variant='source', frame_counter=False): + logger.info("Cutting {}".format(filename)) segments = get_best_segments( os.path.join(base_dir, stream, variant).lower(), start, end, ) if None in segments: - logging.warning("Cutting {} ({} to {}) but it contains holes".format(filename, ts(start), ts(end))) + logger.warning("Cutting {} ({} to {}) but it contains holes".format(filename, ts(start), ts(end))) if not segments or set(segments) == {None}: raise NoSegments("Can't cut {} ({} to {}): No segments".format(filename, ts(start), ts(end))) filter_args = [] @@ -33,7 +53,7 @@ def cut_to_file(filename, base_dir, stream, start, end, variant='source', frame_ "fontfile=DejaVuSansMono.ttf" ":fontcolor=white" ":text='%{e\:t}'" - ":x=(w-tw)/2" + ":x=(w-tw)/2+100" ":y=h-(2*lh)", ] encoding_args = ['-c:v', 'libx264', '-preset', 'ultrafast', '-crf', '0', '-f', 'mp4'] @@ -42,20 +62,24 @@ def cut_to_file(filename, base_dir, stream, start, end, variant='source', frame_ f.write(chunk) +def add_range(base, range): + return [base + datetime.timedelta(seconds=n) for n in range] -def review(match_id, race_number, host='condor.live', user='necrobot-read', password=None, database='condor_x2', base_dir='/srv/wubloader', output_dir='/tmp'): - logging.basicConfig(level=logging.INFO) +def conn_from_url(url): + args = urlparse(url) + return mysql.connector.connect( + user=args.username, + password=args.password, + host=args.hostname, + database=args.path.lstrip('/'), + ) - match_id = int(match_id) - race_number = int(race_number) - if password is None: - password = getpass("Password? ") - conn = mysql.connector.connect( - host=host, user=user, password=password, database=database, - ) +def review(match_id, race_number, base_dir, db_url, start_range=(0, 5), finish_range=(-5, 10)): + logger = logging.getLogger("review").getChild("{}-{}".format(match_id, race_number)) + conn = conn_from_url(db_url) cur = conn.cursor() cur.execute(""" SELECT @@ -80,19 +104,30 @@ def review(match_id, race_number, host='condor.live', user='necrobot-read', pass ] if not data: - raise Exception("No such race") + raise RaceNotFound("Could not find race number {} of match {}".format(match_id, race_number)) assert len(data) == 1, repr(data) (racer1, racer2, start, duration), = data end = start + datetime.timedelta(seconds=duration/100.) + output_name = "{}-{}-{}-{}".format(match_id, racer1, racer2, race_number) + output_dir = os.path.join(base_dir, "reviews", output_name) + result_name = "review.{}.{}.mp4".format(*finish_range) + 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)) + return result_path + finish_paths = [] - for racer in (racer1, racer2): - start_path = os.path.join(output_dir, "start-{}.mp4".format(racer)) + for racer_number, racer in enumerate((racer1, racer2)): + start_path = os.path.join(output_dir, "start-{}.mp4".format(racer_number)) - cut_to_file(start_path, base_dir, racer, start, start + datetime.timedelta(seconds=5)) + 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, @@ -113,32 +148,33 @@ def review(match_id, race_number, host='condor.live', user='necrobot-read', pass assert black_end.startswith('black_end:') time_offset = float(black_end.split(':')[1]) else: - print "Unable to detect start (expected 1 black interval, but found {}).".format(len(lines)) - print "Cutting file {} for manual detection.".format(start_path) - cut_to_file(start_path, base_dir, racer, start, start + datetime.timedelta(seconds=5), frame_counter=True) - time_offset = float(raw_input("What timestamp of this video do we start at? ")) + 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) # start each racer's finish video at TIME_OFFSET later, so they are the same # time since their actual start. - finish_start = end - datetime.timedelta(seconds=5) + time_offset - finish_path = os.path.join(output_dir, "finish-{}.mp4".format(racer)) + finish_base = end + time_offset + finish_start, finish_end = add_range(finish_base, finish_range) + finish_path = os.path.join(output_dir, "finish-{}.mp4".format(racer_number)) finish_paths.append(finish_path) - cut_to_file(finish_path, base_dir, racer, finish_start, finish_start + datetime.timedelta(seconds=5)) + logger.info("Got time offset of {}, cutting finish at finish_base {}".format(time_offset, finish_base)) + cut_to_file(logger, finish_path, base_dir, racer, finish_start, finish_end) - output_path = os.path.join(output_dir, "result.mp4") + temp_path = "{}.tmp.mp4".format(result_path) args = ['ffmpeg'] for path in finish_paths: args += ['-i', path] args += [ '-r', '60', '-filter_complex', 'hstack', - '-y', output_path, + '-y', temp_path, ] + logger.info("Cutting final result") subprocess.check_call(args) - print "Review cut to file {}".format(output_path) - - -if __name__ == '__main__': - argh.dispatch_command(main) + # atomic rename so that if result_path exists at all, we know it is complete and correct + os.rename(temp_path, result_path) + logger.info("Review done") + return result_path