commit 7064bf4460a1d65df4266bb64b5c6730a9328192 Author: Lukas Winkler Date: Thu Nov 5 19:49:59 2020 +0100 initial version diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b993743 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea/ +*.mp4 +data/ +config.py diff --git a/config.sample.py b/config.sample.py new file mode 100644 index 0000000..851445e --- /dev/null +++ b/config.sample.py @@ -0,0 +1,21 @@ +from pathlib import Path + +playback_url = "https://bbb.example.com/" + +meeting_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-aaaaaaaaaaaaa" + +data_dir = Path("./data") + +fps = 5 # frames per second + +pointer_size = 100 + +start = 10 # in seconds, set to None to start from the beginning +end = 100 # in seconds, set to None to convert until the end of the video + +hide_pointer_if_offscreen = False + +# set to "previous" or "next" for raw position data +# set to "linear" for linear interpolation +# set to "quadratic" for quadratic interpolation (needs hide_pointer_if_offscreen = False) +interpolation_method = "quadratic" diff --git a/cursor.py b/cursor.py new file mode 100644 index 0000000..f088db8 --- /dev/null +++ b/cursor.py @@ -0,0 +1,31 @@ +from pathlib import Path +from xml.etree import ElementTree +from xml.etree.ElementTree import Element + +from scipy.interpolate import interp1d + +from config import interpolation_method, hide_pointer_if_offscreen + + +class Cursor: + def __init__(self, xml_file: Path): + tree = ElementTree.parse(xml_file) + root = tree.getroot() + self.timestamps = [] + self.xs = [] + self.ys = [] + child: Element + for child in root: + self.timestamps.append(float(child.attrib["timestamp"])) + cursor_text = child.find("cursor").text + x, y = list(map(float, cursor_text.split())) + if hide_pointer_if_offscreen: + if x < 0: + x = None + if y < 0: + y = None + self.xs.append(x) + self.ys.append(y) + + self.xspline = interp1d(self.timestamps, self.xs, kind=interpolation_method) + self.yspline = interp1d(self.timestamps, self.ys, kind=interpolation_method) diff --git a/download.py b/download.py new file mode 100644 index 0000000..706e700 --- /dev/null +++ b/download.py @@ -0,0 +1,28 @@ +from pathlib import Path + +import requests + +from config import data_dir, meeting_id, playback_url + + +def fetch_file(path: str, show_progress=False) -> Path: + local_file = data_dir / meeting_id / path + if local_file.exists(): + return local_file + else: + print("downloading", path) + data_url = f"{playback_url}presentation/{meeting_id}/{path}" + local_file.parent.mkdir(parents=True, exist_ok=True) + r = requests.get(data_url) + r.raise_for_status() + file_size = int(r.headers.get("content-length")) + progress = 0 + with local_file.open("wb") as fd: + for chunk in r.iter_content(chunk_size=128): + fd.write(chunk) + progress += 128 + if show_progress: + print(f"Progress: {progress / file_size * 100:.2f}%", end="\r", flush=True) + if show_progress: + print() # flush new line + return local_file diff --git a/main.py b/main.py new file mode 100644 index 0000000..93788bd --- /dev/null +++ b/main.py @@ -0,0 +1,81 @@ +import subprocess +from pathlib import Path +from typing import Optional + +import cv2 +import numpy as np + +from config import fps, pointer_size, start, end +from cursor import Cursor +from download import fetch_file +from metadata import Metadata +from shapes import Shapes + +metadata = Metadata(fetch_file("metadata.xml")) + +print(f'found "{metadata.meetingName}"') +print(f"starting on {metadata.starttime}") + +cursor = Cursor(fetch_file("cursor.xml")) + +shapes = Shapes(fetch_file("shapes.svg")) +for slide in shapes.slides: + a = slide.file # pre-download slide images + +audio = fetch_file("video/webcams.webm", show_progress=True) + +print("start generating video") +if start is None: + time = 0 +else: + time = start +if end is None: + end = metadata.duration + print(end) +frame_len = 1 / fps +slide_id = -1 +video_file = Path("without_audio.mp4") +fourcc = cv2.VideoWriter_fourcc(*'MP4V') +print(shapes.maxwidth, shapes.maxheight) +out = cv2.VideoWriter(str(video_file), fourcc, fps, (shapes.maxwidth, shapes.maxheight)) +slide = shapes.slides[0] +image: Optional[np.ndarray] = None +print() + +while time <= end: + frame = np.zeros((shapes.maxheight, shapes.maxwidth, 3)) + + while time > slide.end or image is None: + slide_id += 1 + slide = shapes.slides[slide_id] + image: np.ndarray = cv2.imread(str(slide.file)) + frame[0:slide.height, 0:slide.width] = image + x_frac = cursor.xspline(time) + y_frac = cursor.yspline(time) + if not (np.isnan(x_frac) or np.isnan(y_frac)): + cursor_x = int(round(x_frac * slide.width)) + cursor_y = int(round(y_frac * slide.height)) + if -pointer_size <= cursor_x <= slide.width + pointer_size and -pointer_size <= cursor_y <= slide.height + pointer_size: + frame[cursor_y - pointer_size:cursor_y + pointer_size, + cursor_x - pointer_size:cursor_x + pointer_size] = np.array([0, 0, 255], dtype=np.uint8) + else: + cursor_x = None + cursor_y = None + print(f"{time:.2f}/{end:.2f} {slide_id} {cursor_x} {cursor_y} ", end="\r", flush=True) + + out.write(frame.astype(np.uint8)) + + time += frame_len + +out.release() +print() + +command = [ + "ffmpeg", "-i", str(video_file), "-ss", str(start), "-to", str(end), "-i", str(audio), "-ss", str(0), "-c", + "copy", + "output.mp4", + "-y" +] +print("merge video with audio") +print(" ".join(command)) +subprocess.run(command) diff --git a/metadata.py b/metadata.py new file mode 100644 index 0000000..6e5da59 --- /dev/null +++ b/metadata.py @@ -0,0 +1,14 @@ +from datetime import datetime +from pathlib import Path +from xml.etree import ElementTree + + +class Metadata: + def __init__(self, xml_file: Path): + tree = ElementTree.parse(xml_file) + root = tree.getroot() + + self.meetingName = root.find("./meta/meetingName").text + self.starttime = datetime.fromtimestamp(int(root.find("./start_time").text) / 1000) + self.duration = int(int(root.find("./playback/duration").text)) / 1000 + diff --git a/shapes.py b/shapes.py new file mode 100644 index 0000000..5422401 --- /dev/null +++ b/shapes.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import List +from xml.etree import ElementTree +from xml.etree.ElementTree import Element + +from download import fetch_file + + +@dataclass +class Slide: + id: str + start: float + end: float + filename: str + width: float + height: float + + @property + def file(self): + return fetch_file(self.filename) + + +class Shapes: + def __init__(self, xml_file: Path): + tree = ElementTree.parse(xml_file) + root = tree.getroot() + image: Element + self.slides: List[Slide] = [] + self.maxwidth = 0 + self.maxheight = 0 + for image in root: + data = image.attrib + slide = Slide( + id=data["id"], + start=float(data["in"]), + end=float(data["out"]), + filename=data["{http://www.w3.org/1999/xlink}href"], + width=int(data["width"]), + height=int(data["height"]), + ) + if slide.width > self.maxwidth: + self.maxwidth = slide.width + if slide.height > self.maxheight: + self.maxheight = slide.height + self.slides.append(slide)