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>master
commit
4de44a7df6
@ -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()
|
@ -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")
|
@ -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()
|
@ -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
|
@ -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
|
@ -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"
|
@ -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()
|
Loading…
Reference in new issue