From 4e5a84e96a835689e10f68d88085340c74e4d32b Mon Sep 17 00:00:00 2001 From: j Date: Wed, 14 Jan 2026 01:46:55 +0000 Subject: [PATCH 1/3] cut detection based on fps of source material --- pandora/archive/cutdetection.py | 89 +++++++++++++++++++++++++++++++++ pandora/archive/extract.py | 23 +-------- pandora/archive/utils.py | 25 +++++++++ 3 files changed, 115 insertions(+), 22 deletions(-) create mode 100644 pandora/archive/cutdetection.py create mode 100644 pandora/archive/utils.py diff --git a/pandora/archive/cutdetection.py b/pandora/archive/cutdetection.py new file mode 100644 index 00000000..fb78a75f --- /dev/null +++ b/pandora/archive/cutdetection.py @@ -0,0 +1,89 @@ +import subprocess + +import numpy as np +import ox + +from django.config import settings + +from .utils import AspectRatio + +def _get_distance(data0, data1): + diff = data0.astype(np.float32) - data1.astype(np.float32) + per_pixel_distance = np.linalg.norm(diff, axis=2) + total_distance = per_pixel_distance.sum() + num_pixels = data0.shape[0] * data0.shape[1] + max_distance = num_pixels * np.sqrt(3 * (255 ** 2)) + return total_distance / max_distance + +def detect_cuts(path, seconds=True): + depth = 3 + info = ox.avinfo(path) + dar = AspectRatio(info['video'][0]['display_aspect_ratio']) + fps = AspectRatio(info['video'][0]['framerate']) + height = 96 + width = int(dar * height) + width += width % 2 + nbytes = depth * width * height + bufsize = nbytes + 100 + cmd = [ + settings.FFMPEG, + '-hide_banner', + '-loglevel', 'error', + '-i', path, + '-threads', '4', + '-f', 'rawvideo', + '-pix_fmt', 'rgb24', + '-vcodec', 'rawvideo', + '-vf', 'scale=%d:%d' % (width, height), + '-aspect', '%d:%d' % (width, height), + '-' + ] + #print(' '.join(cmd)) + p = subprocess.Popen(cmd, + bufsize=bufsize, + stdout=subprocess.PIPE, + close_fds=True) + first = True + previous_frame = None + cuts = [] + detect_cuts = True + short_cut = None + cut_frames = [] + + frame_i = 0 + previous_distance = 0 + + while True: + data = p.stdout.read(nbytes) + if len(data) != nbytes: + if first: + raise IOError("ERROR: could not open file %s" % path) + else: + break + else: + first = False + frame = np.frombuffer(data, dtype=np.uint8).reshape((height, width, depth)) + frame_data = np.asarray(frame) + if frame_i == 0: + is_cut = False + previous_distance = 0 + else: + if short_cut and frame_i - short_cut > 2: + cuts.append(short_cut) + short_cut = None + distance = _get_distance(previous_frame_data, frame_data) + is_cut = distance > 0.1 or (distance > 0.2 and abs(distance - previous_distance) > 0.2) + if is_cut: + if frame_i - (0 if not cuts else short_cut or cuts[-1]) < 3: + is_cut = False + short_cut = frame_i + else: + cuts.append(frame_i) + previous_distance = distance + previous_frame_data = frame_data + frame_i += 1 + if seconds: + return [float('%0.3f' % float(c/fps)) for c in cuts] + else: + return cuts + diff --git a/pandora/archive/extract.py b/pandora/archive/extract.py index 2b9209a6..8515192f 100644 --- a/pandora/archive/extract.py +++ b/pandora/archive/extract.py @@ -23,6 +23,7 @@ import pillow_avif from pillow_heif import register_heif_opener from .chop import Chop, make_keyframe_index +from .utils import AspectRatio register_heif_opener() @@ -33,28 +34,6 @@ img_extension = 'jpg' MAX_DISTANCE = math.sqrt(3 * pow(255, 2)) -class AspectRatio(fractions.Fraction): - - def __new__(cls, numerator, denominator=None): - if not denominator: - ratio = list(map(int, numerator.split(':'))) - if len(ratio) == 1: - ratio.append(1) - numerator = ratio[0] - denominator = ratio[1] - # if its close enough to the common aspect ratios rather use that - if abs(numerator/denominator - 4/3) < 0.03: - numerator = 4 - denominator = 3 - elif abs(numerator/denominator - 16/9) < 0.02: - numerator = 16 - denominator = 9 - return super(AspectRatio, cls).__new__(cls, numerator, denominator) - - @property - def ratio(self): - return "%d:%d" % (self.numerator, self.denominator) - def supported_formats(): if not find_executable(settings.FFMPEG): return None diff --git a/pandora/archive/utils.py b/pandora/archive/utils.py new file mode 100644 index 00000000..b5659afa --- /dev/null +++ b/pandora/archive/utils.py @@ -0,0 +1,25 @@ +import fractions +import math + +class AspectRatio(fractions.Fraction): + + def __new__(cls, numerator, denominator=None): + if not denominator: + ratio = list(map(int, numerator.split(':'))) + if len(ratio) == 1: + ratio.append(1) + numerator = ratio[0] + denominator = ratio[1] + # if its close enough to the common aspect ratios rather use that + if abs(numerator/denominator - 4/3) < 0.03: + numerator = 4 + denominator = 3 + elif abs(numerator/denominator - 16/9) < 0.02: + numerator = 16 + denominator = 9 + return super(AspectRatio, cls).__new__(cls, numerator, denominator) + + @property + def ratio(self): + return "%d:%d" % (self.numerator, self.denominator) + From 6deaca816170f35c484d07db835749c65dd80583 Mon Sep 17 00:00:00 2001 From: j Date: Wed, 14 Jan 2026 02:04:15 +0000 Subject: [PATCH 2/3] use .3 for timecodes --- pandora/item/timelines.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pandora/item/timelines.py b/pandora/item/timelines.py index a39ceee6..68b660ac 100644 --- a/pandora/item/timelines.py +++ b/pandora/item/timelines.py @@ -217,7 +217,7 @@ def join_tiles(source_paths, durations, target_path): offset += durations[i] with open(os.path.join(target_path, 'cuts.json'), 'w') as f: # avoid float rounding artefacts - f.write('[' + ', '.join(map(lambda x: '%.2f' % x, cuts)) + ']') + f.write('[' + ', '.join(map(lambda x: '%.3f' % x, cuts)) + ']') def split_tiles(path, paths, durations): From 11915abcf5270edee17defa2bed7dd02c4caa9a9 Mon Sep 17 00:00:00 2001 From: j Date: Wed, 14 Jan 2026 02:05:34 +0000 Subject: [PATCH 3/3] settings flag to use new cut detection --- pandora/archive/extract.py | 16 +++++++++++++--- pandora/settings.py | 2 ++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/pandora/archive/extract.py b/pandora/archive/extract.py index 8515192f..7b6bf906 100644 --- a/pandora/archive/extract.py +++ b/pandora/archive/extract.py @@ -23,6 +23,7 @@ import pillow_avif from pillow_heif import register_heif_opener from .chop import Chop, make_keyframe_index +from .cutdetection import detect_cuts from .utils import AspectRatio @@ -469,17 +470,26 @@ def timeline(video, prefix, modes=None, size=None): size = [64, 16] if isinstance(video, str): video = [video] + if settings.CUT_DETECTION == 'oxtimelines': + cuts = [ + '-c', os.path.join(prefix, 'cuts.json'), + ] + else: + cuts = [] + cmd = [os.path.normpath(os.path.join(settings.BASE_DIR, '../bin/oxtimelines')), '-s', ','.join(map(str, reversed(sorted(size)))), '-m', ','.join(modes), '-o', prefix, - '-c', os.path.join(prefix, 'cuts.json'), - ] + video + ] + cuts + video # print(cmd) p = subprocess.Popen(cmd, stdin=subprocess.PIPE, close_fds=True) p.wait() - + if settings.CUT_DETECTION != 'oxtimelines': + cuts = detect_cuts(video) + with open(os.path.join(prefix, 'cuts.json'), "w") as fd: + json.dump(cuts, fd) def average_color(prefix, start=0, end=0, mode='antialias'): height = 64 diff --git a/pandora/settings.py b/pandora/settings.py index 44ae9b0d..886719bd 100644 --- a/pandora/settings.py +++ b/pandora/settings.py @@ -222,6 +222,8 @@ USE_VP9 = True FFMPEG_SUPPORTS_VP9 = True FFMPEG_DEBUG = False +CUT_DETECTION = "oxtimelines" + #========================================================================= #Pan.do/ra related settings settings #to customize, create local_settings.py and overwrite keys