From 4de44a7df6c25572103a729824a81d270a3b8059 Mon Sep 17 00:00:00 2001 From: Sylvain Munaut Date: Tue, 21 Feb 2023 11:49:22 +0100 Subject: [PATCH] Initial import This is the current state as it was used for the 2023-01-18 osmodevcall and previous 2023 talks. Signed-off-by: Sylvain Munaut --- bbb-download.py | 146 +++++++++++++++++++++++++++++ bbb-fusion-animate-cursor.py | 103 +++++++++++++++++++++ bbb-to-edl.py | 172 +++++++++++++++++++++++++++++++++++ bbb.py | 75 +++++++++++++++ odc-download.sh | 45 +++++++++ odc-encode.sh | 64 +++++++++++++ slides-alpha.py | 167 ++++++++++++++++++++++++++++++++++ 7 files changed, 772 insertions(+) create mode 100755 bbb-download.py create mode 100644 bbb-fusion-animate-cursor.py create mode 100755 bbb-to-edl.py create mode 100644 bbb.py create mode 100755 odc-download.sh create mode 100755 odc-encode.sh create mode 100755 slides-alpha.py diff --git a/bbb-download.py b/bbb-download.py new file mode 100755 index 0000000..4260677 --- /dev/null +++ b/bbb-download.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 + +# +# bbb-download.py +# +# BBB recording downloader +# Tries to record all files that are part of a BBB recording "as-is" to disk +# +# Copyright (c) 2023 Sylvain Munaut +# SPDX-License-Identifier: MIT +# + +import argparse +import json +import pathlib +import re +import requests +import urllib.parse + +import bbb + + +def bbb_slides_dl(fh): + + rv = [] + doc = json.load(fh) + + for fid,fc in doc.items(): + # Validate file id + if not re.match(r'^[0-9a-fA-F]{40,64}-[0-9]+$', fid): + raise RuntimeError('Unknown document ID format in presentation_text.json') + + # Iterate pages + for fp in fc.keys(): + # Validate it's a slide + if not fp.startswith('slide-'): + raise RuntimeError('Unknown document page type in presentation_text.json') + + # Add to the list of "to download" + rv.append((f'presentation/{fid:s}/{fp:s}.png', ['presentation', fid, f'{fp:s}.png'])) + + return rv + + +def bbb_shapes_svg_check(fh): + print(bbb.parse_shapes_svg(fh)) + return [] + + +BASE_DL = [ + # URL Path Local Path Handler + ( 'captions.json', 'captions.json', ), + ( 'cursor.xml', 'cursor.xml', ), + ( 'deskshare.xml', 'deskshare.xml', ), + ( 'deskshare/deskshare.webm', 'deskshare.webm', ), + ( 'external_videos.json', 'external_videos.json', ), + ( 'metadata.xml', 'metadata.xml', ), + ( 'notes.xml', 'notes.xml', ), + ( 'panzooms.xml', 'panzooms.xml', ), + ( 'polls.json', 'polls.json', ), + ( 'presentation_text.json', 'presentation_text.json', bbb_slides_dl ), + ( 'shapes.svg', 'shapes.svg', bbb_shapes_svg_check ), + ( 'slides_new.xml', 'slides_new.xml', ), + ( 'video/webcams.webm', 'webcams.webm', ), +] + + +def download_file(url, filename): + with requests.get(url, stream=True) as r: + r.raise_for_status() + with open(filename, 'wb') as f: + for chunk in r.iter_content(chunk_size=128*1024): + f.write(chunk) + + +def download_all_recursive(opts): + + # Create the base directory + p_base = opts.dest.absolute() + p_base.mkdir(parents=True) + + # Create the base url + u_base = opts.url + u_base = urllib.parse.urljoin(u_base, f'presentation/{opts.meeting:s}/') + + # Loop init + to_dl = list(BASE_DL) + i = 0 + + # Iterate until we're done + while i < len(to_dl): + # Entry to fetch + e = to_dl[i] + + e_url = e[0] + e_path = e[1] + e_handler = e[2] if len(e) > 2 else None + + # Source URI + e_url = urllib.parse.urljoin(u_base, e_url) + + # Destination path + if not isinstance(e_path, list): + e_path = [e_path] + e_path = pathlib.Path(p_base, *e_path) + e_path.parent.mkdir(parents=True, exist_ok=True) + + # Trigger fetch + print(e_url, e_path) + try: + download_file(e_url, e_path) + except: + pass + + # If we have a handler, call it + if callable(e_handler): + with open(e_path, 'rb') as fh: + to_dl.extend(e_handler(fh)) + + # Next + i = i + 1 + + +def parse_opt(): + parser = argparse.ArgumentParser( + prog = 'bbb-download.py', + description = 'BBB recording downloader', + ) + + parser.add_argument('-u', '--url', required=True, + help="BBB instance base URL") + parser.add_argument('-m', '--meeting', required=True, + help="Meeting ID") + parser.add_argument('-d', '--dest', required=True, type=pathlib.Path, + help="Destination directory") + + return parser.parse_args() + + +def main(): + opts = parse_opt() + download_all_recursive(opts) + + +if __name__ == '__main__': + main() diff --git a/bbb-fusion-animate-cursor.py b/bbb-fusion-animate-cursor.py new file mode 100644 index 0000000..831fd95 --- /dev/null +++ b/bbb-fusion-animate-cursor.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python + +# +# bbb-fusion-animate-cursor.py +# +# Script meant to be executed from Fusion console to automatically import +# cursors.xml movements as XOffset/Yoffset on a Shape Transform node. +# +# Copyright (c) 2023 Sylvain Munaut +# SPDX-License-Identifier: MIT +# + +import bbb + + +def gen_keyframes(cursor, framerate=24, res_x=1280, res_y=720): + mr = max(res_x, res_y) + kx = {} + ky = {} + hidden = None + lf = None + + for t,x,y in cursor: + # Frame number + f = round(t * framerate) + + # Hide event ? + if (x < 0) or (y < 0): + if lf is not None: + kx[f-1] = { 1: lx } + ky[f-1] = { 1: ly } + kx[f] = { 1: -1 } + ky[f] = { 1: -1 } + hidden = True + continue + + # Coordinate mapping + x = ((x - 0.5) * res_x / mr) + y = -((y - 0.5) * res_y / mr) + + # If there was no movement for 2 seconds, hide + if (lf is not None) and ((f - lf) >= (2 * framerate)) and not hidden: + kx[lf + framerate - 1] = { 1: lx } + ky[lf + framerate - 1] = { 1: ly } + kx[lf + framerate ] = { 1: -1 } + ky[lf + framerate ] = { 1: -1 } + kx[f - framerate ] = { 1: -1 } + ky[f - framerate ] = { 1: -1 } + kx[f - framerate + 1] = { 1: lx } + ky[f - framerate + 1] = { 1: ly } + + # If we were hidden, add keyframe just before + if hidden: + kx[f-1] = { 1: -1 } + ky[f-1] = { 1: -1 } + hidden = False + + # Add keyframes + kx[f] = { 1: x } + ky[f] = { 1: y } + + # Save last coord + lx = x + ly = y + lf = f + + return kx, ky + + +def animate_cursor(cursor_file, shape_xform, width=None, height=None, framerate=None): + # Get data + if width is None: + width = comp.GetPrefs("Comp.FrameFormat.Width") + + if height is None: + height = comp.GetPrefs("Comp.FrameFormat.Height") + + if framerate is None: + framerate = comp.GetPrefs("Comp.FrameFormat.Rate") + + # Load cursor events + ce = bbb.parse_cursor_xml(cursor_file) + + # Generate keyframes + kx, ky = gen_keyframes(ce, res_x=width, res_y=height, framerate=framerate) + + # Get Shape Transform and apply keyframes + tool = comp.FindTool(shape_xform) + + bx = comp.BezierSpline() + bx.SetKeyFrames(kx) + + by = comp.BezierSpline() + by.SetKeyFrames(ky) + + tool.XOffset = bx + tool.YOffset = by + + +# Edit the following as needed and run using comp.RunScript in the Fusion console +print("Running ...") +animate_cursor("cursor.xml", "CursorPos") +print("Done") diff --git a/bbb-to-edl.py b/bbb-to-edl.py new file mode 100755 index 0000000..239d9ad --- /dev/null +++ b/bbb-to-edl.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python + +# +# bbb-to-edl.py +# +# BBB EDL generator +# Creates an EDL files from a BBB recording shapes.svg +# +# Copyright (c) 2023 Sylvain Munaut +# SPDX-License-Identifier: MIT +# + +import argparse +import math + +import bbb + + +class TimeCode: + + def __init__(self, framerate, frames): + self._rate = framerate + self._aframes = frames + + @property + def hours(self): + return self._aframes // (self._rate * 3600) + + @property + def minutes(self): + tm = self._aframes // (self._rate * 60) + return tm % 60 + + @property + def seconds(self): + ts = self._aframes // self._rate + return ts % 60 + + @property + def frames(self): + return self._aframes % self._rate + + @property + def framerate(self): + return self._rate + + def __str__(self): + return f"{self.hours:02d}:{self.minutes:02d}:{self.seconds:02d}:{self.frames:02d}" + + def __int__(self): + return self._aframes + + def __add__(self, other): + if isinstance(other, int): + fo = other + elif isinstance(other, TimeCode): + if other._rate != self._rate: + raise ValueError("Can't combine timecode of different framerate") + fo = other._aframes + return TimeCode(self._rate, self._aframes + fo) + + def __sub__(self, other): + if isinstance(other, int): + fo = other + elif isinstance(other, TimeCode): + if other._rate != self._rate: + raise ValueError("Can't combine timecode of different framerate") + fo = other._aframes + return TimeCode(self._rate, self._aframes - fo) + + @classmethod + def parse(self, framerate, s): + # HH:MM:SS:FF + m = re.match('^([0-9]{2}):([0-9]{2}):([0-9]{2}):([0-9]{2})$', s) + if m: + h = int(m.group(1)) + m = int(m.group(2)) + s = int(m.group(3)) + f = int(m.group(4)) + return TimeCode(framerate, ((((h*60)+m)*60)+s)*framerate+f) + + # [[HH:]MM:]SS.s + m = re.match('^((([0-9]{1,2}):)?([0-9]{1,2}):)?([0-9]{1,2}(.[0-9]+)?)$', s) + if m: + h = int(m.group(3)) + m = int(m.group(4)) + s = float(m.group(5)) + f = int(round((((h*60)+m)*60+s)*framerate)) + return TimeCode(framerate, f) + + # SSS.s + m = re.match('^[0-9]+(.[0-9]+)?)$', s) + if m: + s = float(s) + f = int(round(s*framerate)) + return TimeCode(framerate, f) + + # No match + raise ValueError("Unable to parse timecode") + + +def generate_edl(opts): + # Load shapes + data = bbb.parse_shapes_svg(opts.input) + + # Scan events + out = opts.output + i = 1 + + for t_start, t_stop, desc in data: + # Is this a deskshare segment ? + if desc is None: + desc = opts.deskshare + is_still = False + else: + desc = f'{opts.presentation:s}/{desc[0]:s}/slide-{desc[1]:02d}.png' + is_still = True + + # Event times + t_start = math.floor(t_start * opts.framerate) + t_stop = math.floor(t_stop * opts.framerate) + + # Source times + if is_still: + t_src_start = TimeCode(opts.framerate, 0) + t_src_stop = TimeCode(opts.framerate, t_stop - t_start) + else: + t_src_start = TimeCode(opts.framerate, t_start) + t_src_stop = TimeCode(opts.framerate, t_stop) + + # Recorder times + t_rec_start = TimeCode(opts.framerate, t_start) + t_rec_stop = TimeCode(opts.framerate, t_stop) + + # EDL gen + out.write(f"{i:03d} AX V C {str(t_src_start):s} {str(t_src_stop):s} {str(t_rec_start):s} {str(t_rec_stop):s}\n") + if is_still: + out.write(f"M2 AX 000.0 00:00:00:00\n") + out.write(f"* FROM CLIP NAME: {desc:s}\n") + out.write("\n") + + # Next + i = i + 1 + + +def parse_opt(): + parser = argparse.ArgumentParser( + prog = 'bbb-to-edl.py', + description = 'BBB recording EDL generator', + ) + + parser.add_argument('-i', '--input', required=True, type=argparse.FileType('r'), + help="Path to the shapes.svg file from BBB recording") + parser.add_argument('-o', '--output', required=True, type=argparse.FileType('w'), + help="Path to the output EDL file") + parser.add_argument('-d', '--deskshare', required=True, + help="Path to use for the deskshare segments") + parser.add_argument('-p', '--presentation', required=True, + help="Prefix Path for the presentation slides segments") + parser.add_argument('-f', '--framerate', type=int, default=24, + help="Framerate of the project") + + return parser.parse_args() + + +def main(): + opts = parse_opt() + generate_edl(opts) + + +if __name__ == '__main__': + main() diff --git a/bbb.py b/bbb.py new file mode 100644 index 0000000..1dfaf37 --- /dev/null +++ b/bbb.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 + +# +# bbb.py +# +# Utilities to parse BBB recording components +# +# Copyright (c) 2023 Sylvain Munaut +# SPDX-License-Identifier: MIT +# + +__all__ = [ + 'parse_cursor_xml', + 'parse_deskshare_xml', + 'parse_shapes_svg', +] + + +import re + +import lxml.etree + + +def parse_cursor_xml(filename): + rv = [] + xml_doc = lxml.etree.parse(filename) + for element in xml_doc.xpath("//recording/event"): + px, py = [float(x) for x in element.getchildren()[0].text.split()] + rv.append(( + float(element.get("timestamp")), + px, + py, + )) + return rv + + +def parse_deskshare_xml(filename): + rv = [] + xml_doc = lxml.etree.parse(filename) + for element in xml_doc.xpath("//recording/event"): + st = float(element.get("start_timestamp")) + et = float(element.get("stop_timestamp")) + rv.append( (st, et, None) ) + return rv + + +def parse_shapes_svg(filename): + + rv = [] + xml_doc = lxml.etree.parse(filename) + ns = { + 'svg': 'http://www.w3.org/2000/svg', + 'xlink': 'http://www.w3.org/1999/xlink', + } + + for element in xml_doc.xpath("//svg:svg/svg:image", namespaces=ns): + # Parse/Validate href + href = element.get('{http://www.w3.org/1999/xlink}href') + + if href == "presentation/deskshare.png": + slide = None + else: + m = re.match('^presentation/([0-9a-fA-F]{40,64}-[0-9]+)/slide-([0-9]+).png$', href) + if not m: + raise RuntimeError('Invalid presentation page link') + slide = (m.group(1), int(m.group(2))) + + # Append + rv.append(( + float(element.get('in')), + float(element.get('out')), + slide + )) + + return rv diff --git a/odc-download.sh b/odc-download.sh new file mode 100755 index 0000000..dd92a7a --- /dev/null +++ b/odc-download.sh @@ -0,0 +1,45 @@ +#!/bin/bash + +set -e + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + +CONFIG="${SCRIPT_DIR}/odc-config.sh" +if [ ! -f "${CONFIG}" ]; then + echo "[!] Missing config file odc-config.sh" +fi +source "${CONFIG}" + +SLUG="$1" +MEETING_ID="$2" + +# Param check +if [ "${SLUG}" == "" ]; then + echo "[!] Missing slug" + exit 1; +fi + +if [ "${MEETING_ID}" == "" ]; then + echo "[!] Missing meeting ID" + exit 1; +fi + +if [ "${ODC_BASE_PATH}" == "" ]; then + echo "[!] Missing base directory" + exit 1; +fi + +# Raw BBB recording data +"${SCRIPT_DIR}/bbb-download.py" -u "https://meeting5.franken.de" -m "${MEETING_ID}" -d "${ODC_BASE_PATH}/${SLUG}/bbb.raw/" + +# Process video/audio into formats for resolve +mkdir "${ODC_BASE_PATH}/${SLUG}/bbb.proc/" + +if [ -e "${ODC_BASE_PATH}/${SLUG}/bbb.raw/webcams.webm" ]; then + ffmpeg -i "${ODC_BASE_PATH}/${SLUG}/bbb.raw/webcams.webm" -vn -c:a flac -sample_fmt s16 "${ODC_BASE_PATH}/${SLUG}/bbb.proc/audio.flac" + ffmpeg -i "${ODC_BASE_PATH}/${SLUG}/bbb.raw/webcams.webm" -an -c:v copy "${ODC_BASE_PATH}/${SLUG}/bbb.proc/webcams-vp9.mkv" +fi + +if [ -e "${ODC_BASE_PATH}/${SLUG}/bbb.raw/deskshare.webm" ]; then + ffmpeg -i "${ODC_BASE_PATH}/${SLUG}/bbb.raw/deskshare.webm" -an -c:v copy "${ODC_BASE_PATH}/${SLUG}/bbb.proc/deskshare-vp9.mkv" +fi diff --git a/odc-encode.sh b/odc-encode.sh new file mode 100755 index 0000000..49748c4 --- /dev/null +++ b/odc-encode.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +set -e + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + +CONFIG="${SCRIPT_DIR}/odc-config.sh" +if [ ! -e "${CONFIG}" ]; then + echo "[!] Missing config file odc-config.sh" +fi +source "${CONFIG}" + +ODC_TYPE="$1" +ODC_SLUG="$2" + +if [ "${ODC_TYPE}" == "" ]; then + echo "[!] Missing call type (osmodevcall or retronetcall)" + exit 1; +fi + +if [ "${ODC_SLUG}" == "" ]; then + echo "[!] Missing ODC slug" + exit 1; +fi + +if [ "${ODC_RENDER_PATH}" == "" ]; then + echo "[!] Missing render directory" + exit 1; +fi + +ODC_RENDER_MASTER="${ODC_RENDER_PATH}/${ODC_TYPE}-${ODC_SLUG}_master.mov" + +if [ ! -f "${ODC_RENDER_MASTER}" ]; then + echo "[!] Missing master render" + exit 1; +fi + +: "${FFMPEG:=ffmpeg}" + +"${FFMPEG}" \ + -hwaccel cuda -hwaccel_output_format cuda \ + -i "${ODC_RENDER_MASTER}" \ + -c:v h264_nvenc -b:v 1M \ + -c:a aac -b:a 96k \ + "${ODC_RENDER_PATH}/${ODC_TYPE}-${ODC_SLUG}_h264_420.mp4" + +"${FFMPEG}" \ + -hwaccel cuda -hwaccel_output_format cuda \ + -i "${ODC_RENDER_MASTER}" \ + -c:v hevc_nvenc -b:v 512k \ + -c:a aac -b:a 96k \ + "${ODC_RENDER_PATH}/${ODC_TYPE}-${ODC_SLUG}_h265_420.mp4" + +#"${FFMPEG}" \ +# -i "${ODC_RENDER_MASTER}" \ +# -c:v libvpx-vp9 -b:v 400k \ +# -c:a libopus -b:a 80k \ +# "${ODC_RENDER_PATH}/${ODC_TYPE}-${ODC_SLUG}_vp9.webm" + +"${FFMPEG}" \ + -i "${ODC_RENDER_MASTER}" \ + -c:v libsvtav1 -crf 30 -b:v 2M -g 240 -svtav1-params "fast-decode=1:tune=0" \ + -c:a libopus -b:a 80k \ + "${ODC_RENDER_PATH}/${ODC_TYPE}-${ODC_SLUG}_av1.webm" diff --git a/slides-alpha.py b/slides-alpha.py new file mode 100755 index 0000000..4b28959 --- /dev/null +++ b/slides-alpha.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 + +# +# slides-alpha.py +# +# Generates an alpha layer for slides (in PNG format), trying to replace the +# white background while leaving a small border around any element so they are +# still visible / readable over anything dark +# +# Copyright (c) 2023 Sylvain Munaut +# SPDX-License-Identifier: MIT +# + +import argparse +import os +import pathlib +import re + +from collections import namedtuple + +from PIL import Image + +import numpy as np + +import scipy.ndimage +import scipy.signal + + +CropSpec = namedtuple("CropSpec", "w h x y") + +def croptype(s): + m = re.match("^([0-9]+)x([0-9]+)(\+([0-9]+)\+([0-9]+))?$", s) + if not m: + raise argparse.ArgumentTypeError(f"{s} is not a valid crop specification") + + return CropSpec( + int(m.group(1)), + int(m.group(2)), + int(m.group(4) or 0), + int(m.group(5) or 0), + ) + + +def slide_process_single(in_file, out_file, opts): + # Debug + print(f"Processing {str(in_file):s} -> {str(out_file):s}") + + # Open image + img = Image.open(in_file) + + # Convert to NP array + ia = np.array(img.convert('RGBA')) + + # Crop + if opts.crop: + c = opts.crop + ia = ia[c.y:c.y+c.h,c.x:c.x+c.w] + + # Create alpha channel + iaf = ia.astype(np.float32) / 255.0 + x = 1.0 - (iaf[:,:,0] + iaf[:,:,1] + iaf[:,:,2]) / 3.0 + y = np.where(x > 0.1, 1.0, 0.0) + + # Borders + if opts.top_border: + y[0:opts.top_border, :] = np.zeros([opts.top_border, y.shape[1]], dtype=np.float32) + + if opts.bottom_border: + y[-opts.bottom_border:, :] = np.zeros([opts.bottom_border, y.shape[1]], dtype=np.float32) + + # Expand outward + ye = scipy.signal.convolve2d(y, np.ones([5,5]), mode='same', boundary='fill', fillvalue=0) + ye = ye.clip(0.0, 1.0) + + # Blue it + ye = scipy.ndimage.gaussian_filter(ye, 1.5, mode='nearest') + + # Recombine + y = (y + ye).clip(0.0, 1.0) + #y = np.maximum(y, x) + + # Borders + if opts.top_border: + y[0:opts.top_border, :] = np.ones([opts.top_border, y.shape[1]], dtype=np.float32) + + if opts.bottom_border: + y[-opts.bottom_border:, :] = np.ones([opts.bottom_border, y.shape[1]], dtype=np.float32) + + # Inject in original image + ia[:,:,3] = (y * 255).astype(np.uint8) + + # Save + img = Image.fromarray(ia) + img.save(out_file) + + +def slide_process_directory(in_path, out_path, opts): + for spe in os.listdir(in_path): + # Create in/out path + spi = in_path / spe + spo = out_path / spe + + # Skip hidden files + if spe.startswith('.'): + continue + + # Is this a PNG file ? + if spe.endswith('.png') and spi.is_file(): + slide_process_single(spi, spo, opts) + + # Or a sub directory ? + if spi.is_dir(): + spo.mkdir(exist_ok=opts.force) + slide_process_directory(spi, spo, opts) + + +def parse_opt(): + parser = argparse.ArgumentParser( + prog = 'slides-alpha.py', + description = 'Alpha channel generator for slides', + ) + + parser.add_argument('-i', '--input', required=True, type=pathlib.Path, + help="Path to either input file or input directory to scan") + parser.add_argument('-o', '--output', required=True, type=pathlib.Path, + help="Path to either output file or output dirctory to write results to") + parser.add_argument('-c', '--crop', type=croptype, + help="Crop (WxH[+X+Y])") + parser.add_argument('-t', '--top-border', type=int, default=0, + help="Size in pixel of the solid top border") + parser.add_argument('-b', '--bottom-border', type=int, default=0, + help="Size in pixel of the solid bottom border") + parser.add_argument('-f', '--force', action='store_true', + help="Overwrite destination if it exists") + + return parser.parse_args() + + +def main(): + # Parse option + opts = parse_opt() + + # Check single or recurse mode + if opts.input.is_dir(): + # Directory + if not opts.output.is_dir(): + raise RuntimeError("Output directory is not valid") + + slide_process_directory(opts.input, opts.output, opts) + + elif opts.input.is_file(): + # Single file + if p.exists(): + if not p.is_file(): + raise RuntimeError("Output path already exists and is not a file") + elif not opts.force: + raise RuntimeError("Output path already exists") + + slide_process_single(opts.input, opts.output, opts) + + else: + # Huh ??? + raise RuntimeError("No input file ?") + + +if __name__ == '__main__': + main()