mirror of
https://github.com/Findus23/BBBtoVideo.git
synced 2024-09-18 12:53:45 +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