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 <tnt@246tNt.com>
This commit is contained in:
Sylvain Munaut 2023-02-21 11:49:22 +01:00
commit 4de44a7df6
7 changed files with 772 additions and 0 deletions

146
bbb-download.py Executable file
View File

@ -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 <tnt@246tNt.com>
# 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()

View File

@ -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 <tnt@246tNt.com>
# 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")

172
bbb-to-edl.py Executable file
View File

@ -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 <tnt@246tNt.com>
# 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()

75
bbb.py Normal file
View File

@ -0,0 +1,75 @@
#!/usr/bin/env python3
#
# bbb.py
#
# Utilities to parse BBB recording components
#
# Copyright (c) 2023 Sylvain Munaut <tnt@246tNt.com>
# 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

45
odc-download.sh Executable file
View File

@ -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

64
odc-encode.sh Executable file
View File

@ -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"

167
slides-alpha.py Executable file
View File

@ -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 <tnt@246tNt.com>
# 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()