mirror of
https://github.com/Findus23/BBBtoVideo.git
synced 2024-09-19 14:03:44 +02:00
initial version
This commit is contained in:
commit
7064bf4460
7 changed files with 225 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
.idea/
|
||||||
|
*.mp4
|
||||||
|
data/
|
||||||
|
config.py
|
21
config.sample.py
Normal file
21
config.sample.py
Normal file
|
@ -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"
|
31
cursor.py
Normal file
31
cursor.py
Normal file
|
@ -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)
|
28
download.py
Normal file
28
download.py
Normal file
|
@ -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
|
81
main.py
Normal file
81
main.py
Normal file
|
@ -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)
|
14
metadata.py
Normal file
14
metadata.py
Normal file
|
@ -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
|
||||||
|
|
46
shapes.py
Normal file
46
shapes.py
Normal file
|
@ -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)
|
Loading…
Reference in a new issue