mirror of
https://github.com/Steffo99/bbbdl.git
synced 2024-11-22 07:44:18 +00:00
🐞 Fix slides scaling (#1)
This commit is contained in:
parent
b8a2656997
commit
85cecfcc40
4 changed files with 88 additions and 47 deletions
|
@ -30,14 +30,14 @@ def download(input_url, output_file, overwrite=False, verbose_ffmpeg=False, debu
|
||||||
meeting = Meeting.from_url(input_url)
|
meeting = Meeting.from_url(input_url)
|
||||||
|
|
||||||
click.secho(f"Downloading: {input_url} -> {output_file}", err=True, fg="green")
|
click.secho(f"Downloading: {input_url} -> {output_file}", err=True, fg="green")
|
||||||
streams = compose_lesson(meeting)
|
tracks = compose_lesson(meeting, 1280, 720)
|
||||||
|
|
||||||
output = ffmpeg.output(*streams, output_file)
|
output = ffmpeg.output(tracks.video, tracks.audio, output_file)
|
||||||
|
|
||||||
if debug:
|
if debug:
|
||||||
click.echo(" ".join(output.compile()))
|
click.echo(" ".join(output.compile()))
|
||||||
else:
|
|
||||||
output.run(quiet=not verbose_ffmpeg, overwrite_output=True)
|
output.run(quiet=not verbose_ffmpeg, overwrite_output=True)
|
||||||
|
|
||||||
|
|
||||||
@main.command()
|
@main.command()
|
||||||
|
|
|
@ -1,24 +1,30 @@
|
||||||
from typing import *
|
from typing import *
|
||||||
import ffmpeg
|
import ffmpeg
|
||||||
from .resources import Meeting
|
from .resources import Meeting
|
||||||
|
from .tracks import Tracks
|
||||||
|
|
||||||
|
|
||||||
def compose_screensharing(meeting: Meeting) -> Tuple[ffmpeg.Stream, ffmpeg.Stream]:
|
def compose_screensharing(meeting: Meeting, width: int, height: int) -> Tracks:
|
||||||
"""Keep the deskshare video and the webcam audio, while discarding the rest."""
|
tracks = Tracks()
|
||||||
|
|
||||||
return (
|
if meeting.webcams:
|
||||||
meeting.deskshare.as_stream().video,
|
tracks.overlay(meeting.webcams.get_video().filter("scale", width, height).filter("setsar", 1, 1))
|
||||||
meeting.webcams.as_stream().audio,
|
tracks.amerge(meeting.webcams.get_audio())
|
||||||
)
|
|
||||||
|
if meeting.deskshare:
|
||||||
|
tracks.overlay(meeting.deskshare.get_video().filter("scale", width, height).filter("setsar", 1, 1))
|
||||||
|
|
||||||
|
return tracks
|
||||||
|
|
||||||
|
|
||||||
def compose_lesson(meeting: Meeting) -> Tuple[ffmpeg.Stream, ffmpeg.Stream]:
|
def compose_lesson(meeting: Meeting, width: int, height: int) -> Tracks:
|
||||||
"""Keep slides, deskshare video and webcam audio, while discarding the rest."""
|
tracks = compose_screensharing(meeting, width, height)
|
||||||
|
|
||||||
video_stream, audio_stream = compose_screensharing(meeting)
|
|
||||||
|
|
||||||
for shape in meeting.shapes:
|
for shape in meeting.shapes:
|
||||||
video_stream = ffmpeg.overlay(video_stream, shape.resource.as_stream().video.filter("scale", 1280, 720),
|
scaled_split_shape = shape.resource.get_video().filter("scale", width, height).filter("setsar", 1, 1).split()
|
||||||
enable=f"between(t, {shape.start}, {shape.end})")
|
count = 0
|
||||||
|
for enable in shape.enables:
|
||||||
|
count += 1
|
||||||
|
tracks.overlay(scaled_split_shape.stream(count), enable=f"between(t, {enable[0]}, {enable[1]})")
|
||||||
|
|
||||||
return video_stream, audio_stream
|
return tracks
|
||||||
|
|
|
@ -7,35 +7,42 @@ import ffmpeg
|
||||||
from .urlhandler import playback_to_data
|
from .urlhandler import playback_to_data
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass()
|
|
||||||
class Resource:
|
class Resource:
|
||||||
href: str
|
def __init__(self, href: str):
|
||||||
|
self.href: str = href
|
||||||
|
self._video: Optional[ffmpeg.Stream] = None
|
||||||
|
self._video_count: int = 0
|
||||||
|
self._audio: Optional[ffmpeg.Stream] = None
|
||||||
|
self._audio_count: int = 0
|
||||||
|
|
||||||
def as_stream(self, **kwargs) -> ffmpeg.Stream:
|
def __repr__(self):
|
||||||
return ffmpeg.input(self.href, **kwargs)
|
return f"<{self.__class__.__qualname__} href={self.href}>"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def check_and_create(cls, href: str) -> Optional[Resource]:
|
||||||
|
"""Check if the resource exists, and create it if it does."""
|
||||||
|
r = requests.head(href)
|
||||||
|
if not (200 <= r.status_code < 400):
|
||||||
|
return None
|
||||||
|
return cls(href=href)
|
||||||
|
|
||||||
|
def get_audio(self) -> ffmpeg.nodes.FilterableStream:
|
||||||
|
if self._audio is None:
|
||||||
|
self._audio = ffmpeg.input(self.href).audio.asplit()
|
||||||
|
self._audio_count += 1
|
||||||
|
return self._audio.stream(self._audio_count)
|
||||||
|
|
||||||
|
def get_video(self) -> ffmpeg.nodes.FilterableStream:
|
||||||
|
if self._video is None:
|
||||||
|
self._video = ffmpeg.input(self.href).video.split()
|
||||||
|
self._video_count += 1
|
||||||
|
return self._video.stream(self._video_count)
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass()
|
@dataclasses.dataclass()
|
||||||
class Shape:
|
class Shape:
|
||||||
resource: Resource
|
resource: Resource
|
||||||
start: float
|
enables: List[Tuple[float, float]]
|
||||||
end: float
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_tag(cls, tag: bs4.Tag, *, base_url: str) -> Shape:
|
|
||||||
# No, `"in" not in tag` does not work
|
|
||||||
if not tag["in"]:
|
|
||||||
raise ValueError("Tag has no 'in' parameter")
|
|
||||||
if not tag["out"]:
|
|
||||||
raise ValueError("Tag has no 'out' parameter")
|
|
||||||
if not tag["xlink:href"]:
|
|
||||||
raise ValueError("Tag has no 'xlink:href' parameter")
|
|
||||||
|
|
||||||
return cls(
|
|
||||||
resource=Resource(href=f"{base_url}/{tag['xlink:href']}"),
|
|
||||||
start=float(tag["in"]),
|
|
||||||
end=float(tag["out"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass()
|
@dataclasses.dataclass()
|
||||||
|
@ -49,22 +56,30 @@ class Meeting:
|
||||||
r = requests.get(f"{base_url}/presentation/{meeting_id}/metadata.xml")
|
r = requests.get(f"{base_url}/presentation/{meeting_id}/metadata.xml")
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
deskshare = Resource(href=f"{base_url}/presentation/{meeting_id}/deskshare/deskshare.webm")
|
deskshare = Resource.check_and_create(href=f"{base_url}/presentation/{meeting_id}/deskshare/deskshare.mp4")
|
||||||
webcams = Resource(href=f"{base_url}/presentation/{meeting_id}/video/webcams.mp4")
|
webcams = Resource.check_and_create(href=f"{base_url}/presentation/{meeting_id}/video/webcams.mp4")
|
||||||
|
|
||||||
shape_soup = bs4.BeautifulSoup(requests.get(f"{base_url}/presentation/{meeting_id}/shapes.svg").text,
|
shape_soup = bs4.BeautifulSoup(requests.get(f"{base_url}/presentation/{meeting_id}/shapes.svg").text,
|
||||||
"lxml")
|
"lxml")
|
||||||
shapes: List[Shape] = []
|
|
||||||
|
shapes: Dict[str, Shape] = {}
|
||||||
for tag in shape_soup.find_all("image"):
|
for tag in shape_soup.find_all("image"):
|
||||||
try:
|
if not tag["in"]:
|
||||||
shapes.append(Shape.from_tag(tag, base_url=f"{base_url}/presentation/{meeting_id}"))
|
raise ValueError("Tag has no 'in' parameter")
|
||||||
except ValueError:
|
if not tag["out"]:
|
||||||
continue
|
raise ValueError("Tag has no 'out' parameter")
|
||||||
|
if not tag["xlink:href"]:
|
||||||
|
raise ValueError("Tag has no 'xlink:href' parameter")
|
||||||
|
|
||||||
|
url = tag["xlink:href"]
|
||||||
|
if url not in shapes:
|
||||||
|
shapes[url] = Shape(resource=Resource(f"{base_url}/presentation/{meeting_id}/{url}"), enables=[])
|
||||||
|
shapes[url].enables.append((tag["in"], tag["out"]))
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
deskshare=deskshare,
|
deskshare=deskshare,
|
||||||
webcams=webcams,
|
webcams=webcams,
|
||||||
shapes=shapes
|
shapes=list(shapes.values())
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|
20
bbbdl/tracks.py
Normal file
20
bbbdl/tracks.py
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
from typing import Optional
|
||||||
|
import ffmpeg.nodes
|
||||||
|
|
||||||
|
|
||||||
|
class Tracks:
|
||||||
|
def __init__(self):
|
||||||
|
self.video: Optional[ffmpeg.nodes.FilterableStream] = None
|
||||||
|
self.audio: Optional[ffmpeg.nodes.FilterableStream] = None
|
||||||
|
|
||||||
|
def overlay(self, other: ffmpeg.nodes.FilterableStream, **kwargs):
|
||||||
|
if self.video is None:
|
||||||
|
self.video = other
|
||||||
|
else:
|
||||||
|
self.video = self.video.overlay(other, **kwargs)
|
||||||
|
|
||||||
|
def amerge(self, other: ffmpeg.nodes.FilterableStream, **kwargs):
|
||||||
|
if self.audio is None:
|
||||||
|
self.audio = other
|
||||||
|
else:
|
||||||
|
self.audio = ffmpeg.filter((self.audio, other), "amerge", **kwargs)
|
Loading…
Reference in a new issue