mirror of
https://github.com/jeertmans/manim-slides.git
synced 2025-07-15 00:52:15 +08:00
Merge branch 'main' into reorganize
This commit is contained in:
@ -2,5 +2,72 @@
|
||||
|
||||
from .cli.commands import main
|
||||
|
||||
from .__version__ import __version__
|
||||
from .checkhealth import checkhealth
|
||||
from .convert import convert
|
||||
from .logger import logger
|
||||
from .present import list_scenes, present
|
||||
from .render import render
|
||||
from .wizard import init, wizard
|
||||
|
||||
|
||||
@click.group(cls=DefaultGroup, default="present", default_if_no_args=True)
|
||||
@click.option(
|
||||
"--notify-outdated-version/--silent",
|
||||
" /-S",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Check if a new version of Manim Slides is available.",
|
||||
)
|
||||
@click.version_option(__version__, "-v", "--version")
|
||||
@click.help_option("-h", "--help")
|
||||
def cli(notify_outdated_version: bool) -> None:
|
||||
"""
|
||||
Manim Slides command-line utilities.
|
||||
|
||||
If no command is specified, defaults to `present`.
|
||||
"""
|
||||
# Code below is mostly a copy from:
|
||||
# https://github.com/ManimCommunity/manim/blob/main/manim/cli/render/commands.py
|
||||
if notify_outdated_version:
|
||||
manim_info_url = "https://pypi.org/pypi/manim-slides/json"
|
||||
warn_prompt = "Cannot check if latest release of Manim Slides is installed"
|
||||
try:
|
||||
req_info: requests.models.Response = requests.get(manim_info_url, timeout=2)
|
||||
req_info.raise_for_status()
|
||||
stable = req_info.json()["info"]["version"]
|
||||
if stable != __version__:
|
||||
click.echo(
|
||||
"You are using Manim Slides version "
|
||||
+ click.style(f"v{__version__}", fg="red")
|
||||
+ ", but version "
|
||||
+ click.style(f"v{stable}", fg="green")
|
||||
+ " is available."
|
||||
)
|
||||
click.echo(
|
||||
"You should consider upgrading via "
|
||||
+ click.style("pip install -U manim-slides", fg="yellow")
|
||||
)
|
||||
except requests.exceptions.HTTPError:
|
||||
logger.debug(f"HTTP Error: {warn_prompt}")
|
||||
except requests.exceptions.ConnectionError:
|
||||
logger.debug(f"Connection Error: {warn_prompt}")
|
||||
except requests.exceptions.Timeout:
|
||||
logger.debug(f"Timed Out: {warn_prompt}")
|
||||
except json.JSONDecodeError:
|
||||
logger.debug(warn_prompt)
|
||||
logger.debug(f"Error decoding JSON from {manim_info_url}")
|
||||
except Exception:
|
||||
logger.debug(f"Something went wrong: {warn_prompt}")
|
||||
|
||||
|
||||
cli.add_command(convert)
|
||||
cli.add_command(checkhealth)
|
||||
cli.add_command(init)
|
||||
cli.add_command(list_scenes)
|
||||
cli.add_command(present)
|
||||
cli.add_command(render)
|
||||
cli.add_command(wizard)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
@ -1,3 +1,3 @@
|
||||
"""Manim Slides' version."""
|
||||
|
||||
__version__ = "5.1.7"
|
||||
__version__ = "5.4.2"
|
||||
|
37
manim_slides/checkhealth.py
Normal file
37
manim_slides/checkhealth.py
Normal file
@ -0,0 +1,37 @@
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
from .__version__ import __version__
|
||||
|
||||
|
||||
@click.command()
|
||||
def checkhealth() -> None:
|
||||
"""Check Manim Slides' installation."""
|
||||
click.echo(f"Manim Slides version: {__version__}")
|
||||
click.echo(f"Python executable: {sys.executable}")
|
||||
click.echo("Manim bindings:")
|
||||
|
||||
try:
|
||||
from manim import __version__ as manimce_version
|
||||
|
||||
click.echo(f"\tmanim (version: {manimce_version})")
|
||||
except ImportError:
|
||||
click.secho("\tmanim not found", bold=True)
|
||||
|
||||
try:
|
||||
from manimlib import __version__ as manimlib_version
|
||||
|
||||
click.echo(f"\tmanimgl (version: {manimlib_version})")
|
||||
except ImportError:
|
||||
click.secho("\tmanimgl not found", bold=True)
|
||||
|
||||
try:
|
||||
from qtpy import API, QT_VERSION
|
||||
|
||||
click.echo(f"Qt API: {API} (version: {QT_VERSION})")
|
||||
except ImportError:
|
||||
click.secho(
|
||||
"No Qt API found, some Manim Slides commands will not be available",
|
||||
bold=True,
|
||||
)
|
@ -1,8 +1,11 @@
|
||||
import mimetypes
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import textwrap
|
||||
import warnings
|
||||
import webbrowser
|
||||
from base64 import b64encode
|
||||
from collections import deque
|
||||
@ -14,6 +17,8 @@ from typing import Any, Callable, Optional, Union
|
||||
import av
|
||||
import click
|
||||
import pptx
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from click import Context, Parameter
|
||||
from jinja2 import Template
|
||||
from lxml import etree
|
||||
@ -27,7 +32,6 @@ from pydantic import (
|
||||
PositiveFloat,
|
||||
PositiveInt,
|
||||
ValidationError,
|
||||
conlist,
|
||||
)
|
||||
from pydantic_core import CoreSchema, core_schema
|
||||
from pydantic_extra_types.color import Color
|
||||
@ -103,9 +107,12 @@ def read_image_from_video_file(file: Path, frame_index: "FrameIndex") -> Image:
|
||||
|
||||
|
||||
class Converter(BaseModel): # type: ignore
|
||||
presentation_configs: conlist(PresentationConfig, min_length=1) # type: ignore[valid-type]
|
||||
assets_dir: str = "{basename}_assets"
|
||||
template: Optional[Path] = None
|
||||
presentation_configs: list[PresentationConfig]
|
||||
assets_dir: str = Field(
|
||||
"{basename}_assets",
|
||||
description="Assets folder.\nThis is a template string that accepts 'dirname', 'basename', and 'ext' as variables.\nThose variables are obtained from the output filename.",
|
||||
)
|
||||
template: Optional[Path] = Field(None, description="Custom template file to use.")
|
||||
|
||||
def convert_to(self, dest: Path) -> None:
|
||||
"""Convert self, i.e., a list of presentations, into a given format."""
|
||||
@ -119,9 +126,9 @@ class Converter(BaseModel): # type: ignore
|
||||
"""
|
||||
return ""
|
||||
|
||||
def open(self, file: Path) -> Any:
|
||||
def open(self, file: Path) -> None:
|
||||
"""Open a file, generated with converter, using appropriate application."""
|
||||
raise NotImplementedError
|
||||
open_with_default(file)
|
||||
|
||||
@classmethod
|
||||
def from_string(cls, s: str) -> type["Converter"]:
|
||||
@ -130,6 +137,7 @@ class Converter(BaseModel): # type: ignore
|
||||
"html": RevealJS,
|
||||
"pdf": PDF,
|
||||
"pptx": PowerPoint,
|
||||
"zip": HtmlZip,
|
||||
}[s]
|
||||
|
||||
|
||||
@ -290,49 +298,153 @@ class RevealTheme(str, StrEnum):
|
||||
|
||||
|
||||
class RevealJS(Converter):
|
||||
# Export option: use data-uri
|
||||
data_uri: bool = False
|
||||
"""
|
||||
RevealJS options.
|
||||
|
||||
Please check out https://revealjs.com/config/ for more details.
|
||||
"""
|
||||
|
||||
# Export option:
|
||||
one_file: bool = Field(
|
||||
False, description="Embed all assets (e.g., animations) inside the HTML."
|
||||
)
|
||||
offline: bool = Field(
|
||||
False, description="Download remote assets for offline presentation."
|
||||
)
|
||||
# Presentation size options from RevealJS
|
||||
width: Union[Str, int] = Str("100%")
|
||||
height: Union[Str, int] = Str("100%")
|
||||
margin: float = 0.04
|
||||
min_scale: float = 0.2
|
||||
max_scale: float = 2.0
|
||||
width: Union[Str, int] = Field(
|
||||
Str("100%"), description="Width of the presentation."
|
||||
)
|
||||
height: Union[Str, int] = Field(
|
||||
Str("100%"), description="Height of the presentation."
|
||||
)
|
||||
margin: float = Field(0.04, description="Margin to use around the content.")
|
||||
min_scale: float = Field(
|
||||
0.2, description="Bound for smallest possible scale to apply to content."
|
||||
)
|
||||
max_scale: float = Field(
|
||||
2.0, description="Bound for large possible scale to apply to content."
|
||||
)
|
||||
# Configuration options from RevealJS
|
||||
controls: JsBool = JsBool.false
|
||||
controls_tutorial: JsBool = JsBool.true
|
||||
controls_layout: ControlsLayout = ControlsLayout.bottom_right
|
||||
controls_back_arrows: ControlsBackArrows = ControlsBackArrows.faded
|
||||
progress: JsBool = JsBool.false
|
||||
slide_number: SlideNumber = SlideNumber.false
|
||||
show_slide_number: Union[ShowSlideNumber, Function] = ShowSlideNumber.all
|
||||
hash_one_based_index: JsBool = JsBool.false
|
||||
hash: JsBool = JsBool.false
|
||||
respond_to_hash_changes: JsBool = JsBool.false
|
||||
history: JsBool = JsBool.false
|
||||
keyboard: JsBool = JsBool.true
|
||||
keyboard_condition: Union[KeyboardCondition, Function] = KeyboardCondition.null
|
||||
disable_layout: JsBool = JsBool.false
|
||||
overview: JsBool = JsBool.true
|
||||
center: JsBool = JsBool.true
|
||||
touch: JsBool = JsBool.true
|
||||
loop: JsBool = JsBool.false
|
||||
rtl: JsBool = JsBool.false
|
||||
navigation_mode: NavigationMode = NavigationMode.default
|
||||
shuffle: JsBool = JsBool.false
|
||||
fragments: JsBool = JsBool.true
|
||||
fragment_in_url: JsBool = JsBool.true
|
||||
embedded: JsBool = JsBool.false
|
||||
help: JsBool = JsBool.true
|
||||
pause: JsBool = JsBool.true
|
||||
show_notes: JsBool = JsBool.false
|
||||
auto_play_media: AutoPlayMedia = AutoPlayMedia.null
|
||||
preload_iframes: PreloadIframes = PreloadIframes.null
|
||||
auto_animate: JsBool = JsBool.true
|
||||
auto_animate_matcher: Union[AutoAnimateMatcher, Function] = AutoAnimateMatcher.null
|
||||
auto_animate_easing: AutoAnimateEasing = AutoAnimateEasing.ease
|
||||
auto_animate_duration: float = 1.0
|
||||
auto_animate_unmatched: JsBool = JsBool.true
|
||||
controls: JsBool = Field(
|
||||
JsBool.false, description="Display presentation control arrows."
|
||||
)
|
||||
controls_tutorial: JsBool = Field(
|
||||
JsBool.true, description="Help the user learn the controls by providing hints."
|
||||
)
|
||||
controls_layout: ControlsLayout = Field(
|
||||
ControlsLayout.bottom_right, description="Determine where controls appear."
|
||||
)
|
||||
controls_back_arrows: ControlsBackArrows = Field(
|
||||
ControlsBackArrows.faded,
|
||||
description="Visibility rule for backwards navigation arrows.",
|
||||
)
|
||||
progress: JsBool = Field(
|
||||
JsBool.false, description="Display a presentation progress bar."
|
||||
)
|
||||
slide_number: SlideNumber = Field(
|
||||
SlideNumber.false, description="Display the page number of the current slide."
|
||||
)
|
||||
show_slide_number: Union[ShowSlideNumber, Function] = Field(
|
||||
ShowSlideNumber.all,
|
||||
description="Can be used to limit the contexts in which the slide number appears.",
|
||||
)
|
||||
hash_one_based_index: JsBool = Field(
|
||||
JsBool.false,
|
||||
description="Use 1 based indexing for # links to match slide number (default is zero based).",
|
||||
)
|
||||
hash: JsBool = Field(
|
||||
JsBool.false,
|
||||
description="Add the current slide number to the URL hash so that reloading the page/copying the URL will return you to the same slide.",
|
||||
)
|
||||
respond_to_hash_changes: JsBool = Field(
|
||||
JsBool.false,
|
||||
description="Flags if we should monitor the hash and change slides accordingly.",
|
||||
)
|
||||
jump_to_slide: JsBool = Field(
|
||||
JsBool.true,
|
||||
description="Enable support for jump-to-slide navigation shortcuts.",
|
||||
)
|
||||
history: JsBool = Field(
|
||||
JsBool.false,
|
||||
description="Push each slide change to the browser history. Implies `hash: true`.",
|
||||
)
|
||||
keyboard: JsBool = Field(
|
||||
JsBool.true, description="Enable keyboard shortcuts for navigation."
|
||||
)
|
||||
keyboard_condition: Union[KeyboardCondition, Function] = Field(
|
||||
KeyboardCondition.null,
|
||||
description="Optional function that blocks keyboard events when retuning false.",
|
||||
)
|
||||
disable_layout: JsBool = Field(
|
||||
JsBool.false,
|
||||
description="Disable the default reveal.js slide layout (scaling and centering) so that you can use custom CSS layout.",
|
||||
)
|
||||
overview: JsBool = Field(JsBool.true, description="Enable the slide overview mode.")
|
||||
center: JsBool = Field(JsBool.true, description="Vertical centering of slides.")
|
||||
touch: JsBool = Field(
|
||||
JsBool.true, description="Enable touch navigation on devices with touch input."
|
||||
)
|
||||
loop: JsBool = Field(JsBool.false, description="Loop the presentation.")
|
||||
rtl: JsBool = Field(
|
||||
JsBool.false, description="Change the presentation direction to be RTL."
|
||||
)
|
||||
navigation_mode: NavigationMode = Field(
|
||||
NavigationMode.default,
|
||||
description="Change the behavior of our navigation directions.",
|
||||
)
|
||||
shuffle: JsBool = Field(
|
||||
JsBool.false,
|
||||
description="Randomize the order of slides each time the presentation loads.",
|
||||
)
|
||||
fragments: JsBool = Field(
|
||||
JsBool.true, description="Turns fragment on and off globally."
|
||||
)
|
||||
fragment_in_url: JsBool = Field(
|
||||
JsBool.true,
|
||||
description="Flag whether to include the current fragment in the URL, so that reloading brings you to the same fragment position.",
|
||||
)
|
||||
embedded: JsBool = Field(
|
||||
JsBool.false,
|
||||
description="Flag if the presentation is running in an embedded mode, i.e. contained within a limited portion of the screen.",
|
||||
)
|
||||
help: JsBool = Field(
|
||||
JsBool.true,
|
||||
description="Flag if we should show a help overlay when the question-mark key is pressed.",
|
||||
)
|
||||
pause: JsBool = Field(
|
||||
JsBool.true,
|
||||
description="Flag if it should be possible to pause the presentation (blackout).",
|
||||
)
|
||||
show_notes: JsBool = Field(
|
||||
JsBool.false,
|
||||
description="Flag if speaker notes should be visible to all viewers.",
|
||||
)
|
||||
auto_play_media: AutoPlayMedia = Field(
|
||||
AutoPlayMedia.null,
|
||||
description="Global override for autolaying embedded media (video/audio/iframe).",
|
||||
)
|
||||
preload_iframes: PreloadIframes = Field(
|
||||
PreloadIframes.null,
|
||||
description="Global override for preloading lazy-loaded iframes.",
|
||||
)
|
||||
auto_animate: JsBool = Field(
|
||||
JsBool.true, description="Can be used to globally disable auto-animation."
|
||||
)
|
||||
auto_animate_matcher: Union[AutoAnimateMatcher, Function] = Field(
|
||||
AutoAnimateMatcher.null,
|
||||
description="Optionally provide a custom element matcher that will be used to dictate which elements we can animate between.",
|
||||
)
|
||||
auto_animate_easing: AutoAnimateEasing = Field(
|
||||
AutoAnimateEasing.ease,
|
||||
description="Default settings for our auto-animate transitions, can be overridden per-slide or per-element via data arguments.",
|
||||
)
|
||||
auto_animate_duration: float = Field(
|
||||
1.0, description="See 'auto_animate_easing' documentation."
|
||||
)
|
||||
auto_animate_unmatched: JsBool = Field(
|
||||
JsBool.true, description="See 'auto_animate_easing' documentation."
|
||||
)
|
||||
auto_animate_styles: list[str] = Field(
|
||||
default_factory=lambda: [
|
||||
"opacity",
|
||||
@ -347,34 +459,88 @@ class RevealJS(Converter):
|
||||
"border-radius",
|
||||
"outline",
|
||||
"outline-offset",
|
||||
]
|
||||
],
|
||||
description="CSS properties that can be auto-animated.",
|
||||
)
|
||||
auto_slide: AutoSlide = Field(
|
||||
0, description="Control automatic progression to the next slide."
|
||||
)
|
||||
auto_slide_stoppable: JsBool = Field(
|
||||
JsBool.true, description="Stop auto-sliding after user input."
|
||||
)
|
||||
auto_slide_method: Union[AutoSlideMethod, Function] = Field(
|
||||
AutoSlideMethod.null,
|
||||
description="Use this method for navigation when auto-sliding (defaults to navigateNext).",
|
||||
)
|
||||
default_timing: Union[JsNull, int] = Field(
|
||||
JsNull.null,
|
||||
description="Specify the average time in seconds that you think you will spend presenting each slide.",
|
||||
)
|
||||
mouse_wheel: JsBool = Field(
|
||||
JsBool.false, description="Enable slide navigation via mouse wheel."
|
||||
)
|
||||
preview_links: JsBool = Field(
|
||||
JsBool.false, description="Open links in an iframe preview overlay."
|
||||
)
|
||||
post_message: JsBool = Field(
|
||||
JsBool.true, description="Expose the reveal.js API through window.postMessage."
|
||||
)
|
||||
post_message_events: JsBool = Field(
|
||||
JsBool.false,
|
||||
description="Dispatch all reveal.js events to the parent window through postMessage.",
|
||||
)
|
||||
focus_body_on_page_visibility_change: JsBool = Field(
|
||||
JsBool.true,
|
||||
description="Focus body when page changes visibility to ensure keyboard shortcuts work.",
|
||||
)
|
||||
transition: Transition = Field(Transition.none, description="Transition style.")
|
||||
transition_speed: TransitionSpeed = Field(
|
||||
TransitionSpeed.default, description="Transition speed."
|
||||
)
|
||||
background_size: BackgroundSize = Field(
|
||||
BackgroundSize.contain, description="Background size attribute for each video."
|
||||
) # Not in RevealJS
|
||||
background_transition: BackgroundTransition = Field(
|
||||
BackgroundTransition.none,
|
||||
description="Transition style for full page slide backgrounds.",
|
||||
)
|
||||
pdf_max_pages_per_slide: Union[int, str] = Field(
|
||||
"Number.POSITIVE_INFINITY",
|
||||
description="The maximum number of pages a single slide can expand onto when printing to PDF, unlimited by default.",
|
||||
)
|
||||
pdf_separate_fragments: JsBool = Field(
|
||||
JsBool.true, description="Print each fragment on a separate slide."
|
||||
)
|
||||
pdf_page_height_offset: int = Field(
|
||||
-1,
|
||||
description="Offset used to reduce the height of content within exported PDF pages.",
|
||||
)
|
||||
view_distance: int = Field(
|
||||
3, description="Number of slides away from the current that are visible."
|
||||
)
|
||||
mobile_view_distance: int = Field(
|
||||
2,
|
||||
description="Number of slides away from the current that are visible on mobile devices.",
|
||||
)
|
||||
display: Display = Field(
|
||||
Display.block, description="The display mode that will be used to show slides."
|
||||
)
|
||||
hide_inactive_cursor: JsBool = Field(
|
||||
JsBool.true, description="Hide cursor if inactive."
|
||||
)
|
||||
hide_cursor_time: int = Field(
|
||||
5000, description="Time before the cursor is hidden (in ms)."
|
||||
)
|
||||
auto_slide: AutoSlide = 0
|
||||
auto_slide_stoppable: JsBool = JsBool.true
|
||||
auto_slide_method: Union[AutoSlideMethod, Function] = AutoSlideMethod.null
|
||||
default_timing: Union[JsNull, int] = JsNull.null
|
||||
mouse_wheel: JsBool = JsBool.false
|
||||
preview_links: JsBool = JsBool.false
|
||||
post_message: JsBool = JsBool.true
|
||||
post_message_events: JsBool = JsBool.false
|
||||
focus_body_on_page_visibility_change: JsBool = JsBool.true
|
||||
transition: Transition = Transition.none
|
||||
transition_speed: TransitionSpeed = TransitionSpeed.default
|
||||
background_size: BackgroundSize = BackgroundSize.contain # Not in RevealJS
|
||||
background_transition: BackgroundTransition = BackgroundTransition.none
|
||||
pdf_max_pages_per_slide: Union[int, str] = "Number.POSITIVE_INFINITY"
|
||||
pdf_separate_fragments: JsBool = JsBool.true
|
||||
pdf_page_height_offset: int = -1
|
||||
view_distance: int = 3
|
||||
mobile_view_distance: int = 2
|
||||
display: Display = Display.block
|
||||
hide_inactive_cursor: JsBool = JsBool.true
|
||||
hide_cursor_time: int = 5000
|
||||
# Appearance options from RevealJS
|
||||
background_color: Color = "black"
|
||||
reveal_version: str = "5.1.0"
|
||||
reveal_theme: RevealTheme = RevealTheme.black
|
||||
title: str = "Manim Slides"
|
||||
background_color: Color = Field(
|
||||
"black",
|
||||
description="Background color used in slides, not relevant if videos fill the whole area.",
|
||||
)
|
||||
reveal_version: str = Field("5.1.0", description="RevealJS version.")
|
||||
reveal_theme: RevealTheme = Field(
|
||||
RevealTheme.black, description="RevealJS version."
|
||||
)
|
||||
title: str = Field("Manim Slides", description="Presentation title.")
|
||||
# Pydantic options
|
||||
model_config = ConfigDict(use_enum_values=True, extra="forbid")
|
||||
|
||||
@ -385,35 +551,32 @@ class RevealJS(Converter):
|
||||
|
||||
return resources.files(templates).joinpath("revealjs.html").read_text()
|
||||
|
||||
def open(self, file: Path) -> bool:
|
||||
def open(self, file: Path) -> None:
|
||||
"""
|
||||
Open the HTML file inside a web browser.
|
||||
|
||||
:param path: The path to the HTML file.
|
||||
"""
|
||||
return webbrowser.open(file.absolute().as_uri())
|
||||
webbrowser.open(file.absolute().as_uri())
|
||||
|
||||
def convert_to(self, dest: Path) -> None:
|
||||
def convert_to(self, dest: Path) -> None: # noqa: C901
|
||||
"""
|
||||
Convert this configuration into a RevealJS HTML presentation, saved to
|
||||
DEST.
|
||||
"""
|
||||
if self.data_uri:
|
||||
assets_dir = Path("") # Actually we won't care.
|
||||
else:
|
||||
dirname = dest.parent
|
||||
basename = dest.stem
|
||||
ext = dest.suffix
|
||||
dirname = dest.parent
|
||||
basename = dest.stem
|
||||
ext = dest.suffix
|
||||
|
||||
assets_dir = Path(
|
||||
self.assets_dir.format(dirname=dirname, basename=basename, ext=ext)
|
||||
)
|
||||
full_assets_dir = dirname / assets_dir
|
||||
assets_dir = Path(
|
||||
self.assets_dir.format(dirname=dirname, basename=basename, ext=ext)
|
||||
)
|
||||
full_assets_dir = dirname / assets_dir
|
||||
|
||||
if not self.one_file or self.offline:
|
||||
logger.debug(f"Assets will be saved to: {full_assets_dir}")
|
||||
|
||||
full_assets_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if not self.one_file:
|
||||
num_presentation_configs = len(self.presentation_configs)
|
||||
|
||||
if num_presentation_configs > 1:
|
||||
@ -432,6 +595,7 @@ class RevealJS(Converter):
|
||||
def prefix(i: int) -> str:
|
||||
return ""
|
||||
|
||||
full_assets_dir.mkdir(parents=True, exist_ok=True)
|
||||
for i, presentation_config in enumerate(self.presentation_configs):
|
||||
presentation_config.copy_to(
|
||||
full_assets_dir, include_reversed=False, prefix=prefix(i)
|
||||
@ -440,10 +604,14 @@ class RevealJS(Converter):
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(dest, "w") as f:
|
||||
revealjs_template = Template(self.load_template())
|
||||
revealjs_template = Template(
|
||||
self.load_template(), trim_blocks=True, lstrip_blocks=True
|
||||
)
|
||||
|
||||
options = self.dict()
|
||||
options["assets_dir"] = assets_dir
|
||||
options = self.model_dump()
|
||||
|
||||
if assets_dir is not None:
|
||||
options["assets_dir"] = assets_dir
|
||||
|
||||
has_notes = any(
|
||||
slide_config.notes != ""
|
||||
@ -456,25 +624,89 @@ class RevealJS(Converter):
|
||||
get_duration_ms=get_duration_ms,
|
||||
has_notes=has_notes,
|
||||
env=os.environ,
|
||||
prefix=prefix if not self.one_file else None,
|
||||
**options,
|
||||
)
|
||||
# If not offline, write the content to the file
|
||||
if not self.offline:
|
||||
f.write(content)
|
||||
return
|
||||
|
||||
# If offline, download remote assets and store them in the assets folder
|
||||
soup = BeautifulSoup(content, "html.parser")
|
||||
session = requests.Session()
|
||||
|
||||
for tag, inner in [("link", "href"), ("script", "src")]:
|
||||
for item in soup.find_all(tag):
|
||||
if item.has_attr(inner) and (link := item[inner]).startswith(
|
||||
"http"
|
||||
):
|
||||
asset_name = link.rsplit("/", 1)[1]
|
||||
asset = session.get(link)
|
||||
if self.one_file:
|
||||
# If it is a CSS file, inline it
|
||||
if tag == "link" and "stylesheet" in item["rel"]:
|
||||
item.decompose()
|
||||
style = soup.new_tag("style")
|
||||
style.string = asset.text
|
||||
soup.head.append(style)
|
||||
# If it is a JS file, inline it
|
||||
elif tag == "script":
|
||||
item.decompose()
|
||||
script = soup.new_tag("script")
|
||||
script.string = asset.text
|
||||
soup.head.append(script)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unable to inline {tag} asset: {link}"
|
||||
)
|
||||
else:
|
||||
full_assets_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(full_assets_dir / asset_name, "wb") as asset_file:
|
||||
asset_file.write(asset.content)
|
||||
|
||||
item[inner] = str(assets_dir / asset_name)
|
||||
|
||||
content = str(soup)
|
||||
f.write(content)
|
||||
|
||||
|
||||
class HtmlZip(RevealJS):
|
||||
def open(self, file: Path) -> None:
|
||||
super(RevealJS, self).open(file) # Override opening with web browser
|
||||
|
||||
def convert_to(self, dest: Path) -> None:
|
||||
"""
|
||||
Convert this configuration into a zipped RevealJS HTML presentation, saved to
|
||||
DEST.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as directory_name:
|
||||
directory = Path(directory_name)
|
||||
|
||||
html_file = directory / dest.with_suffix(".html").name
|
||||
|
||||
super().convert_to(html_file)
|
||||
shutil.make_archive(str(dest.with_suffix("")), "zip", directory_name)
|
||||
|
||||
|
||||
class FrameIndex(str, Enum):
|
||||
first = "first"
|
||||
last = "last"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class PDF(Converter):
|
||||
frame_index: FrameIndex = FrameIndex.last
|
||||
resolution: PositiveFloat = 100.0
|
||||
frame_index: FrameIndex = Field(
|
||||
FrameIndex.last,
|
||||
description="What frame (first or last) is used to represent each slide.",
|
||||
)
|
||||
resolution: PositiveFloat = Field(
|
||||
100.0, description="Image resolution use for saving frames."
|
||||
)
|
||||
model_config = ConfigDict(use_enum_values=True, extra="forbid")
|
||||
|
||||
def open(self, file: Path) -> None:
|
||||
return open_with_default(file)
|
||||
|
||||
def convert_to(self, dest: Path) -> None:
|
||||
"""Convert this configuration into a PDF presentation, saved to DEST."""
|
||||
images = []
|
||||
@ -501,17 +733,30 @@ class PDF(Converter):
|
||||
|
||||
|
||||
class PowerPoint(Converter):
|
||||
left: PositiveInt = 0
|
||||
top: PositiveInt = 0
|
||||
width: PositiveInt = 1280
|
||||
height: PositiveInt = 720
|
||||
auto_play_media: bool = True
|
||||
poster_frame_image: Optional[FilePath] = None
|
||||
left: PositiveInt = Field(
|
||||
0, description="Horizontal offset where the video is placed from left border."
|
||||
)
|
||||
top: PositiveInt = Field(
|
||||
0, description="Vertical offset where the video is placed from top border."
|
||||
)
|
||||
width: PositiveInt = Field(
|
||||
1280,
|
||||
description="Width of the slides.\nThis should match the resolution of the presentation.",
|
||||
)
|
||||
height: PositiveInt = Field(
|
||||
720,
|
||||
description="Height of the slides.\nThis should match the resolution of the presentation.",
|
||||
)
|
||||
auto_play_media: bool = Field(
|
||||
True, description="Automatically play animations when changing slide."
|
||||
)
|
||||
poster_frame_image: Optional[FilePath] = Field(
|
||||
None,
|
||||
description="Optional image to use when animations are not playing.\n"
|
||||
"By default, the first frame of each animation is used.\nThis is important to avoid blinking effects between slides.",
|
||||
)
|
||||
model_config = ConfigDict(use_enum_values=True, extra="forbid")
|
||||
|
||||
def open(self, file: Path) -> None:
|
||||
return open_with_default(file)
|
||||
|
||||
def convert_to(self, dest: Path) -> None:
|
||||
"""Convert this configuration into a PowerPoint presentation, saved to DEST."""
|
||||
prs = pptx.Presentation()
|
||||
@ -586,22 +831,39 @@ class PowerPoint(Converter):
|
||||
|
||||
|
||||
def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Wrap a function to add a `--show-config` option."""
|
||||
"""Wrap a function to add a '--show-config' option."""
|
||||
|
||||
def callback(ctx: Context, param: Parameter, value: bool) -> None:
|
||||
def callback(ctx: Context, _param: Parameter, value: bool) -> None:
|
||||
if not value or ctx.resilient_parsing:
|
||||
return
|
||||
|
||||
to = ctx.params.get("to", "html")
|
||||
if "to" in ctx.params:
|
||||
to = ctx.params["to"]
|
||||
cls = Converter.from_string(to)
|
||||
elif "dest" in ctx.params:
|
||||
dest = Path(ctx.params["dest"])
|
||||
fmt = dest.suffix[1:].lower()
|
||||
try:
|
||||
cls = Converter.from_string(fmt)
|
||||
except KeyError:
|
||||
logger.warning(
|
||||
f"Could not guess conversion format from {dest!s}, defaulting to HTML."
|
||||
)
|
||||
cls = RevealJS
|
||||
else:
|
||||
cls = RevealJS
|
||||
|
||||
converter = Converter.from_string(to)
|
||||
if doc := getattr(cls, "__doc__", ""):
|
||||
click.echo(textwrap.dedent(doc))
|
||||
|
||||
for key, field in converter.model_fields.items():
|
||||
for key, field in cls.model_fields.items():
|
||||
if field.is_required():
|
||||
continue
|
||||
|
||||
default = field.get_default(call_default_factory=True)
|
||||
click.echo(f"{key}: {default}")
|
||||
click.echo(click.style(key, bold=True) + f": {default}")
|
||||
if description := field.description:
|
||||
click.secho(textwrap.indent(description, prefix="# "), dim=True)
|
||||
|
||||
ctx.exit()
|
||||
|
||||
@ -617,18 +879,31 @@ def show_config_options(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
|
||||
|
||||
def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""Wrap a function to add a `--show-template` option."""
|
||||
"""Wrap a function to add a '--show-template' option."""
|
||||
|
||||
def callback(ctx: Context, param: Parameter, value: bool) -> None:
|
||||
if not value or ctx.resilient_parsing:
|
||||
return
|
||||
|
||||
to = ctx.params.get("to", "html")
|
||||
if "to" in ctx.params:
|
||||
to = ctx.params["to"]
|
||||
cls = Converter.from_string(to)
|
||||
elif "dest" in ctx.params:
|
||||
dest = Path(ctx.params["dest"])
|
||||
fmt = dest.suffix[1:].lower()
|
||||
try:
|
||||
cls = Converter.from_string(fmt)
|
||||
except KeyError:
|
||||
logger.warning(
|
||||
f"Could not guess conversion format from {dest!s}, defaulting to HTML."
|
||||
)
|
||||
cls = RevealJS
|
||||
else:
|
||||
cls = RevealJS
|
||||
|
||||
template = ctx.params.get("template", None)
|
||||
|
||||
converter = Converter.from_string(to)(
|
||||
presentation_configs=[PresentationConfig()], template=template
|
||||
)
|
||||
converter = cls(presentation_configs=[], template=template)
|
||||
click.echo(converter.load_template())
|
||||
|
||||
ctx.exit()
|
||||
@ -650,7 +925,7 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
@click.argument("dest", type=click.Path(dir_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
"--to",
|
||||
type=click.Choice(["auto", "html", "pdf", "pptx"], case_sensitive=False),
|
||||
type=click.Choice(["auto", "html", "pdf", "pptx", "zip"], case_sensitive=False),
|
||||
metavar="FORMAT",
|
||||
default="auto",
|
||||
show_default=True,
|
||||
@ -662,7 +937,6 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
is_flag=True,
|
||||
help="Open the newly created file using the appropriate application.",
|
||||
)
|
||||
@click.option("-f", "--force", is_flag=True, help="Overwrite any existing file.")
|
||||
@click.option(
|
||||
"-c",
|
||||
"--config",
|
||||
@ -670,7 +944,7 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
multiple=True,
|
||||
callback=validate_config_option,
|
||||
help="Configuration options passed to the converter. "
|
||||
"E.g., pass ``-cslide_number=true`` to display slide numbers.",
|
||||
"E.g., pass '-cslide_number=true' to display slide numbers.",
|
||||
)
|
||||
@click.option(
|
||||
"--use-template",
|
||||
@ -678,7 +952,19 @@ def show_template_option(function: Callable[..., Any]) -> Callable[..., Any]:
|
||||
metavar="FILE",
|
||||
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
||||
help="Use the template given by FILE instead of default one. "
|
||||
"To echo the default template, use ``--show-template``.",
|
||||
"To echo the default template, use '--show-template'.",
|
||||
)
|
||||
@click.option(
|
||||
"--one-file",
|
||||
is_flag=True,
|
||||
help="Embed all local assets (e.g., video files) in the output file. "
|
||||
"The is a convenient alias to '-cone_file=true'.",
|
||||
)
|
||||
@click.option(
|
||||
"--offline",
|
||||
is_flag=True,
|
||||
help="Download any remote content and store it in the assets folder. "
|
||||
"The is a convenient alias to '-coffline=true'.",
|
||||
)
|
||||
@show_template_option
|
||||
@show_config_options
|
||||
@ -689,9 +975,10 @@ def convert(
|
||||
dest: Path,
|
||||
to: str,
|
||||
open_result: bool,
|
||||
force: bool,
|
||||
config_options: dict[str, str],
|
||||
template: Optional[Path],
|
||||
offline: bool,
|
||||
one_file: bool,
|
||||
) -> None:
|
||||
"""Convert SCENE(s) into a given format and writes the result in DEST."""
|
||||
presentation_configs = [PresentationConfig.from_file(scene) for scene in scenes]
|
||||
@ -702,13 +989,42 @@ def convert(
|
||||
try:
|
||||
cls = Converter.from_string(fmt)
|
||||
except KeyError:
|
||||
logger.warn(
|
||||
logger.warning(
|
||||
f"Could not guess conversion format from {dest!s}, defaulting to HTML."
|
||||
)
|
||||
cls = RevealJS
|
||||
else:
|
||||
cls = Converter.from_string(to)
|
||||
|
||||
if (
|
||||
one_file
|
||||
and issubclass(cls, (RevealJS, HtmlZip))
|
||||
and "one_file" not in config_options
|
||||
):
|
||||
config_options["one_file"] = "true"
|
||||
|
||||
# Change data_uri to one_file and print a warning if present
|
||||
if "data_uri" in config_options:
|
||||
warnings.warn(
|
||||
"The 'data_uri' configuration option is deprecated and will be "
|
||||
"removed in the next major version. "
|
||||
"Use 'one_file' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
config_options["one_file"] = (
|
||||
config_options["one_file"]
|
||||
if "one_file" in config_options
|
||||
else config_options.pop("data_uri")
|
||||
)
|
||||
|
||||
if (
|
||||
offline
|
||||
and issubclass(cls, (RevealJS, HtmlZip))
|
||||
and "offline" not in config_options
|
||||
):
|
||||
config_options["offline"] = "true"
|
||||
|
||||
converter = cls(
|
||||
presentation_configs=presentation_configs,
|
||||
template=template,
|
||||
|
@ -12,89 +12,81 @@
|
||||
<!-- Theme used for syntax highlighting of code -->
|
||||
<!-- <link rel="stylesheet" href="lib/css/zenburn.css"> -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.13.1/styles/zenburn.min.css">
|
||||
|
||||
<!-- <link rel="stylesheet" href="index.css"> -->
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="reveal">
|
||||
<div class="slides">
|
||||
{%- for presentation_config in presentation_configs -%}
|
||||
{% set outer_loop = loop %}
|
||||
{%- for slide_config in presentation_config.slides -%}
|
||||
{%- if data_uri -%}
|
||||
{% for presentation_config in presentation_configs -%}
|
||||
{%- set outer_loop = loop %}
|
||||
{% for slide_config in presentation_config.slides %}
|
||||
{% if one_file %}
|
||||
{% set file = file_to_data_uri(slide_config.file) %}
|
||||
{%- else -%}
|
||||
{% set file = assets_dir / slide_config.file.name %}
|
||||
{%- endif -%}
|
||||
<section
|
||||
data-background-size={{ background_size }}
|
||||
data-background-color="{{ presentation_config.background_color }}"
|
||||
data-background-video="{{ file }}"
|
||||
{% if loop.index == 1 and outer_loop.index == 1 -%}
|
||||
data-background-video-muted
|
||||
{%- endif %}
|
||||
{% if slide_config.loop -%}
|
||||
data-background-video-loop
|
||||
{%- endif -%}
|
||||
{% if slide_config.auto_next -%}
|
||||
data-autoslide="{{ get_duration_ms(slide_config.file) }}"
|
||||
{%- endif -%}>
|
||||
{% if slide_config.notes != "" -%}
|
||||
<aside class="notes" data-markdown>{{ slide_config.notes }}</aside>
|
||||
{%- endif %}
|
||||
</section>
|
||||
{%- endfor -%}
|
||||
{%- endfor -%}
|
||||
{% else %}
|
||||
{% set file = assets_dir / (prefix(outer_loop.index0) + slide_config.file.name) %}
|
||||
{% endif %}
|
||||
<section
|
||||
data-background-size={{ background_size }}
|
||||
data-background-color="{{ presentation_config.background_color }}"
|
||||
data-background-video="{{ file }}"
|
||||
{% if loop.index == 1 and outer_loop.index == 1 %}
|
||||
data-background-video-muted
|
||||
{% endif %}
|
||||
{% if slide_config.loop %}
|
||||
data-background-video-loop
|
||||
{% endif %}
|
||||
{% if slide_config.auto_next %}
|
||||
data-autoslide="{{ get_duration_ms(slide_config.file) }}"
|
||||
{% endif %}
|
||||
>
|
||||
{% if slide_config.notes != "" %}
|
||||
<aside class="notes" data-markdown>{{ slide_config.notes }}</aside>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/reveal.min.js"></script>
|
||||
|
||||
<!-- To include plugins, see: https://revealjs.com/plugins/ -->
|
||||
|
||||
{% if has_notes -%}
|
||||
{% if has_notes %}
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/markdown/markdown.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/reveal.js/{{ reveal_version }}/plugin/notes/notes.min.js"></script>
|
||||
{%- endif -%}
|
||||
{% endif %}
|
||||
|
||||
<!-- <script src="index.js"></script> -->
|
||||
<script>
|
||||
Reveal.initialize({
|
||||
{% if has_notes -%}
|
||||
plugins: [ RevealMarkdown, RevealNotes ],
|
||||
{%- endif %}
|
||||
{% if has_notes %}
|
||||
/// The list of RevealJS plugins.
|
||||
plugins: [ RevealMarkdown, RevealNotes ],
|
||||
{% endif %}
|
||||
// The "normal" size of the presentation, aspect ratio will
|
||||
// be preserved when the presentation is scaled to fit different
|
||||
// resolutions. Can be specified using percentage units.
|
||||
width: {{ width }},
|
||||
height: {{ height }},
|
||||
|
||||
// Factor of the display size that should remain empty around
|
||||
// the content
|
||||
margin: {{ margin }},
|
||||
|
||||
// Bounds for smallest/largest possible scale to apply to content
|
||||
minScale: {{ min_scale }},
|
||||
maxScale: {{ max_scale }},
|
||||
|
||||
// Display presentation control arrows
|
||||
controls: {{ controls }},
|
||||
|
||||
// Help the user learn the controls by providing hints, for example by
|
||||
// bouncing the down arrow when they first encounter a vertical slide
|
||||
controlsTutorial: {{ controls_tutorial }},
|
||||
|
||||
// Determines where controls appear, "edges" or "bottom-right"
|
||||
controlsLayout: {{ controls_layout }},
|
||||
|
||||
// Visibility rule for backwards navigation arrows; "faded", "hidden"
|
||||
// or "visible"
|
||||
controlsBackArrows: {{ controls_back_arrows }},
|
||||
|
||||
// Display a presentation progress bar
|
||||
progress: {{ progress }},
|
||||
|
||||
// Display the page number of the current slide
|
||||
// - true: Show slide number
|
||||
// - false: Hide slide number
|
||||
@ -110,55 +102,43 @@
|
||||
// object and return an array with one string [slideNumber] or
|
||||
// three strings [n1,delimiter,n2]. See #formatSlideNumber().
|
||||
slideNumber: {{ slide_number }},
|
||||
|
||||
// Can be used to limit the contexts in which the slide number appears
|
||||
// - "all": Always show the slide number
|
||||
// - "print": Only when printing to PDF
|
||||
// - "speaker": Only in the speaker view
|
||||
showSlideNumber: {{ show_slide_number }},
|
||||
|
||||
// Use 1 based indexing for # links to match slide number (default is zero
|
||||
// based)
|
||||
hashOneBasedIndex: {{ hash_one_based_index }},
|
||||
|
||||
// Add the current slide number to the URL hash so that reloading the
|
||||
// page/copying the URL will return you to the same slide
|
||||
hash: {{ hash }},
|
||||
|
||||
// Flags if we should monitor the hash and change slides accordingly
|
||||
respondToHashChanges: {{ respond_to_hash_changes }},
|
||||
|
||||
// Enable support for jump-to-slide navigation shortcuts
|
||||
jumpToSlide: {{ jump_to_slide }},
|
||||
// Push each slide change to the browser history. Implies `hash: true`
|
||||
history: {{ history }},
|
||||
|
||||
// Enable keyboard shortcuts for navigation
|
||||
keyboard: {{ keyboard }},
|
||||
|
||||
// Optional function that blocks keyboard events when retuning false
|
||||
//
|
||||
// If you set this to 'focused', we will only capture keyboard events
|
||||
// for embedded decks when they are in focus
|
||||
keyboardCondition: {{ keyboard_condition }},
|
||||
|
||||
// Disables the default reveal.js slide layout (scaling and centering)
|
||||
// so that you can use custom CSS layout
|
||||
disableLayout: {{ disable_layout }},
|
||||
|
||||
// Enable the slide overview mode
|
||||
overview: {{ overview }},
|
||||
|
||||
// Vertical centering of slides
|
||||
center: {{ center }},
|
||||
|
||||
// Enables touch navigation on devices with touch input
|
||||
touch: {{ touch }},
|
||||
|
||||
// Loop the presentation
|
||||
loop: {{ loop }},
|
||||
|
||||
// Change the presentation direction to be RTL
|
||||
rtl: {{ rtl }},
|
||||
|
||||
// Changes the behavior of our navigation directions.
|
||||
//
|
||||
// "default"
|
||||
@ -184,168 +164,154 @@
|
||||
// from 1.3 -> 2.1. If "grid" is used, the same navigation takes you
|
||||
// from 1.3 -> 2.3.
|
||||
navigationMode: {{ navigation_mode }},
|
||||
|
||||
// Randomizes the order of slides each time the presentation loads
|
||||
shuffle: {{ shuffle }},
|
||||
|
||||
// Turns fragments on and off globally
|
||||
fragments: {{ fragments }},
|
||||
|
||||
// Flags whether to include the current fragment in the URL,
|
||||
// so that reloading brings you to the same fragment position
|
||||
fragmentInURL: {{ fragment_in_url }},
|
||||
|
||||
// Flags if the presentation is running in an embedded mode,
|
||||
// i.e. contained within a limited portion of the screen
|
||||
embedded: {{ embedded }},
|
||||
|
||||
// Flags if we should show a help overlay when the question-mark
|
||||
// key is pressed
|
||||
help: {{ help }},
|
||||
|
||||
// Flags if it should be possible to pause the presentation (blackout)
|
||||
pause: {{ pause }},
|
||||
|
||||
// Flags if speaker notes should be visible to all viewers
|
||||
showNotes: {{ show_notes }},
|
||||
|
||||
// Global override for autolaying embedded media (video/audio/iframe)
|
||||
// - null: Media will only autoplay if data-autoplay is present
|
||||
// - true: All media will autoplay, regardless of individual setting
|
||||
// - false: No media will autoplay, regardless of individual setting
|
||||
autoPlayMedia: {{ auto_play_media }},
|
||||
|
||||
// Global override for preloading lazy-loaded iframes
|
||||
// - null: Iframes with data-src AND data-preload will be loaded when within
|
||||
// the viewDistance, iframes with only data-src will be loaded when visible
|
||||
// - true: All iframes with data-src will be loaded when within the viewDistance
|
||||
// - false: All iframes with data-src will be loaded only when visible
|
||||
preloadIframes: {{ preload_iframes }},
|
||||
|
||||
// Can be used to globally disable auto-animation
|
||||
autoAnimate: {{ auto_animate }},
|
||||
|
||||
// Optionally provide a custom element matcher that will be
|
||||
// used to dictate which elements we can animate between.
|
||||
autoAnimateMatcher: {{ auto_animate_matcher }},
|
||||
|
||||
// Default settings for our auto-animate transitions, can be
|
||||
// overridden per-slide or per-element via data arguments
|
||||
autoAnimateEasing: {{ auto_animate_easing }},
|
||||
autoAnimateDuration: {{ auto_animate_duration }},
|
||||
autoAnimateUnmatched: {{ auto_animate_unmatched }},
|
||||
|
||||
// CSS properties that can be auto-animated. Position & scale
|
||||
// is matched separately so there's no need to include styles
|
||||
// like top/right/bottom/left, width/height or margin.
|
||||
autoAnimateStyles: {{ auto_animate_styles }},
|
||||
|
||||
// Controls automatic progression to the next slide
|
||||
// - 0: Auto-sliding only happens if the data-autoslide HTML attribute
|
||||
// is present on the current slide or fragment
|
||||
// - 1+: All slides will progress automatically at the given interval
|
||||
// - false: No auto-sliding, even if data-autoslide is present
|
||||
autoSlide: {{ auto_slide }},
|
||||
|
||||
// Stop auto-sliding after user input
|
||||
autoSlideStoppable: {{ auto_slide_stoppable }},
|
||||
|
||||
// Use this method for navigation when auto-sliding (defaults to navigateNext)
|
||||
autoSlideMethod: {{ auto_slide_method }},
|
||||
|
||||
// Specify the average time in seconds that you think you will spend
|
||||
// presenting each slide. This is used to show a pacing timer in the
|
||||
// speaker view
|
||||
defaultTiming: {{ default_timing }},
|
||||
|
||||
// Enable slide navigation via mouse wheel
|
||||
mouseWheel: {{ mouse_wheel }},
|
||||
|
||||
// Opens links in an iframe preview overlay
|
||||
// Add `data-preview-link` and `data-preview-link="false"` to customize each link
|
||||
// individually
|
||||
previewLinks: {{ preview_links }},
|
||||
|
||||
// Exposes the reveal.js API through window.postMessage
|
||||
postMessage: {{ post_message }},
|
||||
|
||||
// Dispatches all reveal.js events to the parent window through postMessage
|
||||
postMessageEvents: {{ post_message_events }},
|
||||
|
||||
// Focuses body when page changes visibility to ensure keyboard shortcuts work
|
||||
focusBodyOnPageVisibilityChange: {{ focus_body_on_page_visibility_change }},
|
||||
|
||||
// Transition style
|
||||
transition: {{ transition }}, // none/fade/slide/convex/concave/zoom
|
||||
|
||||
// Transition speed
|
||||
transitionSpeed: {{ transition_speed }}, // default/fast/slow
|
||||
|
||||
// Transition style for full page slide backgrounds
|
||||
backgroundTransition: {{ background_transition }}, // none/fade/slide/convex/concave/zoom
|
||||
|
||||
// The maximum number of pages a single slide can expand onto when printing
|
||||
// to PDF, unlimited by default
|
||||
pdfMaxPagesPerSlide: {{ pdf_max_pages_per_slide }},
|
||||
|
||||
// Prints each fragment on a separate slide
|
||||
pdfSeparateFragments: {{ pdf_separate_fragments }},
|
||||
|
||||
// Offset used to reduce the height of content within exported PDF pages.
|
||||
// This exists to account for environment differences based on how you
|
||||
// print to PDF. CLI printing options, like phantomjs and wkpdf, can end
|
||||
// on precisely the total height of the document whereas in-browser
|
||||
// printing has to end one pixel before.
|
||||
pdfPageHeightOffset: {{ pdf_page_height_offset }},
|
||||
|
||||
// Number of slides away from the current that are visible
|
||||
viewDistance: {{ view_distance }},
|
||||
|
||||
// Number of slides away from the current that are visible on mobile
|
||||
// devices. It is advisable to set this to a lower number than
|
||||
// viewDistance in order to save resources.
|
||||
mobileViewDistance: {{ mobile_view_distance }},
|
||||
|
||||
// The display mode that will be used to show slides
|
||||
display: {{ display }},
|
||||
|
||||
// Hide cursor if inactive
|
||||
hideInactiveCursor: {{ hide_inactive_cursor }},
|
||||
|
||||
// Time before the cursor is hidden (in ms)
|
||||
hideCursorTime: {{ hide_cursor_time }}
|
||||
});
|
||||
{% if one_file %}
|
||||
// Fix found by @t-fritsch and @Rapsssito on GitHub
|
||||
// see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-11733074.
|
||||
function setVideoBase64(video) {
|
||||
const sources = video.querySelectorAll('source');
|
||||
// Update the source of the video
|
||||
sources.forEach((source, i) => {
|
||||
const src = source.getAttribute('src');
|
||||
if(src.match(/^data:video.*;base64$/)) {
|
||||
const nextSrc = sources[i+1]?.getAttribute('src');
|
||||
video.setAttribute('src', `${src},${nextSrc}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{% if data_uri -%}
|
||||
// Fix found by @t-fritsch on GitHub
|
||||
// see: https://github.com/hakimel/reveal.js/discussions/3362#discussioncomment-6651475.
|
||||
function fixBase64VideoBackground(event) {
|
||||
// event.previousSlide, event.currentSlide, event.indexh, event.indexv
|
||||
if (event.currentSlide.getAttribute('data-background-video')) {
|
||||
const background = Reveal.getSlideBackground(event.indexh, event.indexv),
|
||||
video = background.querySelector('video'),
|
||||
sources = video.querySelectorAll('source');
|
||||
|
||||
sources.forEach((source, i) => {
|
||||
const src = source.getAttribute('src');
|
||||
if(src.match(/^data:video.*;base64$/)) {
|
||||
const nextSrc = sources[i+1]?.getAttribute('src');
|
||||
video.setAttribute('src', `${src},${nextSrc}`);
|
||||
}
|
||||
});
|
||||
// Analyze all slides backgrounds
|
||||
for (const slide of Reveal.getBackgroundsElement().querySelectorAll('.slide-background')) {
|
||||
// Get the slide video and its sources for each background
|
||||
const video = slide.querySelector('video');
|
||||
if (video) {
|
||||
setVideoBase64(video);
|
||||
} else {
|
||||
// Listen to the creation of the video element
|
||||
const observer = new MutationObserver((mutationsList) => {
|
||||
for (const mutation of mutationsList) {
|
||||
if (mutation.type === 'childList') {
|
||||
for (const addedNode of mutation.addedNodes) {
|
||||
if (addedNode.tagName === 'VIDEO') {
|
||||
setVideoBase64(addedNode);
|
||||
observer.disconnect(); // Stop observing once the video is handled
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(slide, { childList: true, subtree: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
// Setup base64 videos
|
||||
Reveal.on( 'ready', fixBase64VideoBackground );
|
||||
Reveal.on( 'slidechanged', fixBase64VideoBackground );
|
||||
{%- endif %}
|
||||
{% endif %}
|
||||
</script>
|
||||
|
||||
{% if env['READTHEDOCS'] -%}
|
||||
<style>
|
||||
readthedocs-flyout, readthedocs-notification {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
{%- endif %}
|
||||
{% if env['READTHEDOCS'] %}
|
||||
<style>
|
||||
readthedocs-flyout, readthedocs-notification {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
|
@ -1,7 +1,7 @@
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Literal, Optional
|
||||
|
||||
import click
|
||||
from click import Context, Parameter
|
||||
@ -16,23 +16,6 @@ from ..commons import (
|
||||
verbosity_option,
|
||||
)
|
||||
|
||||
PREFERRED_QT_VERSIONS = ("6.5.1", "6.5.2")
|
||||
|
||||
|
||||
def warn_if_non_desirable_pyside6_version() -> None:
|
||||
from qtpy import API, QT_VERSION
|
||||
|
||||
if sys.version_info < (3, 12) and (
|
||||
API != "pyside6" or QT_VERSION not in PREFERRED_QT_VERSIONS
|
||||
):
|
||||
logger.warn(
|
||||
f"You are using {API = }, {QT_VERSION = }, "
|
||||
"but we recommend installing 'PySide6==6.5.2', mainly to avoid "
|
||||
"flashing screens between slides, "
|
||||
"see issue https://github.com/jeertmans/manim-slides/issues/293. "
|
||||
"You can do so with `pip install 'manim-slides[pyside6]'`."
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@folder_path_option
|
||||
@ -46,6 +29,81 @@ def list_scenes(folder: Path) -> None:
|
||||
click.secho(f"{i:{num_digits}d}: {scene_name}", fg="green")
|
||||
|
||||
|
||||
def _list_scenes(folder: Path) -> list[str]:
|
||||
"""List available scenes in given directory."""
|
||||
scenes = []
|
||||
|
||||
for filepath in folder.glob("*.json"):
|
||||
try:
|
||||
_ = PresentationConfig.from_file(filepath)
|
||||
scenes.append(filepath.stem)
|
||||
except (
|
||||
Exception
|
||||
) as e: # Could not parse this file as a proper presentation config
|
||||
logger.warning(
|
||||
f"Something went wrong with parsing presentation config `{filepath}`: {e}"
|
||||
)
|
||||
|
||||
logger.debug(f"Found {len(scenes)} valid scene configuration files in `{folder}`.")
|
||||
|
||||
return scenes
|
||||
|
||||
|
||||
def prompt_for_scenes(folder: Path) -> list[str]:
|
||||
"""Prompt the user to select scenes within a given folder."""
|
||||
scene_choices = dict(enumerate(_list_scenes(folder), start=1))
|
||||
|
||||
for i, scene in scene_choices.items():
|
||||
click.secho(f"{i}: {scene}", fg="green")
|
||||
|
||||
click.echo()
|
||||
|
||||
click.echo("Choose number corresponding to desired scene/arguments.")
|
||||
click.echo("(Use comma separated list for multiple entries)")
|
||||
|
||||
def value_proc(value: Optional[str]) -> list[str]:
|
||||
indices = list(map(int, (value or "").strip().replace(" ", "").split(",")))
|
||||
|
||||
if not all(0 < i <= len(scene_choices) for i in indices):
|
||||
raise click.UsageError("Please only enter numbers displayed on the screen.")
|
||||
|
||||
return [scene_choices[i] for i in indices]
|
||||
|
||||
if len(scene_choices) == 0:
|
||||
raise click.UsageError(
|
||||
"No scenes were found, are you in the correct directory?"
|
||||
)
|
||||
|
||||
while True:
|
||||
try:
|
||||
scenes = click.prompt("Choice(s)", value_proc=value_proc)
|
||||
return scenes # type: ignore
|
||||
except ValueError as e:
|
||||
raise click.UsageError(str(e)) from None
|
||||
|
||||
|
||||
def get_scenes_presentation_config(
|
||||
scenes: list[str], folder: Path
|
||||
) -> list[PresentationConfig]:
|
||||
"""Return a list of presentation configurations based on the user input."""
|
||||
if len(scenes) == 0:
|
||||
scenes = prompt_for_scenes(folder)
|
||||
|
||||
presentation_configs = []
|
||||
for scene in scenes:
|
||||
config_file = folder / f"{scene}.json"
|
||||
if not config_file.exists():
|
||||
raise click.UsageError(
|
||||
f"File {config_file} does not exist, check the scene name and make sure to use Slide as your scene base class"
|
||||
)
|
||||
try:
|
||||
presentation_configs.append(PresentationConfig.from_file(config_file))
|
||||
except ValidationError as e:
|
||||
raise click.UsageError(str(e)) from None
|
||||
|
||||
return presentation_configs
|
||||
|
||||
|
||||
def start_at_callback(
|
||||
ctx: Context, param: Parameter, values: str
|
||||
) -> tuple[Optional[int], ...]:
|
||||
@ -171,8 +229,14 @@ def start_at_callback(
|
||||
)
|
||||
@click.option(
|
||||
"--hide-info-window",
|
||||
is_flag=True,
|
||||
help="Hide info window.",
|
||||
flag_value="always",
|
||||
help="Hide info window. By default, hide the info window if there is only one screen.",
|
||||
)
|
||||
@click.option(
|
||||
"--show-info-window",
|
||||
"hide_info_window",
|
||||
flag_value="never",
|
||||
help="Force to show info window.",
|
||||
)
|
||||
@click.option(
|
||||
"--info-window-screen",
|
||||
@ -180,12 +244,14 @@ def start_at_callback(
|
||||
metavar="NUMBER",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Put info window on the given screen (a.k.a. display).",
|
||||
help="Put info window on the given screen (a.k.a. display). "
|
||||
"If there is more than one screen, it will by default put the info window "
|
||||
"on a different screen than the main player.",
|
||||
)
|
||||
@click.help_option("-h", "--help")
|
||||
@verbosity_option
|
||||
def present(
|
||||
scenes: list[Path],
|
||||
def present( # noqa: C901
|
||||
scenes: list[str],
|
||||
config_path: Path,
|
||||
folder: Path,
|
||||
start_paused: bool,
|
||||
@ -200,7 +266,7 @@ def present(
|
||||
screen_number: Optional[int],
|
||||
playback_rate: float,
|
||||
next_terminates_loop: bool,
|
||||
hide_info_window: bool,
|
||||
hide_info_window: Optional[Literal["always", "never"]],
|
||||
info_window_screen_number: Optional[int],
|
||||
) -> None:
|
||||
"""
|
||||
@ -234,8 +300,6 @@ def present(
|
||||
if start_at[1]:
|
||||
start_at_slide_number = start_at[1]
|
||||
|
||||
warn_if_non_desirable_pyside6_version()
|
||||
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtGui import QScreen
|
||||
|
||||
@ -245,22 +309,36 @@ def present(
|
||||
app = qapp()
|
||||
app.setApplicationName("Manim Slides")
|
||||
|
||||
screens = app.screens()
|
||||
|
||||
def get_screen(number: int) -> Optional[QScreen]:
|
||||
try:
|
||||
return app.screens()[number]
|
||||
return screens[number]
|
||||
except IndexError:
|
||||
logger.error(
|
||||
f"Invalid screen number {number}, "
|
||||
f"allowed values are from 0 to {len(app.screens())-1} (incl.)"
|
||||
f"allowed values are from 0 to {len(screens) - 1} (incl.)"
|
||||
)
|
||||
return None
|
||||
|
||||
should_hide_info_window = False
|
||||
|
||||
if hide_info_window is None:
|
||||
should_hide_info_window = len(screens) == 1
|
||||
elif hide_info_window == "always":
|
||||
should_hide_info_window = True
|
||||
|
||||
if should_hide_info_window and info_window_screen_number is not None:
|
||||
logger.warning(
|
||||
f"Ignoring `--info-window-screen` because `--hide-info-window` is set to `{hide_info_window}`."
|
||||
)
|
||||
|
||||
if screen_number is not None:
|
||||
screen = get_screen(screen_number)
|
||||
else:
|
||||
screen = None
|
||||
|
||||
if info_window_screen_number is not None:
|
||||
if info_window_screen_number is not None and not should_hide_info_window:
|
||||
info_window_screen = get_screen(info_window_screen_number)
|
||||
else:
|
||||
info_window_screen = None
|
||||
@ -284,11 +362,11 @@ def present(
|
||||
screen=screen,
|
||||
playback_rate=playback_rate,
|
||||
next_terminates_loop=next_terminates_loop,
|
||||
hide_info_window=hide_info_window,
|
||||
hide_info_window=should_hide_info_window,
|
||||
info_window_screen=info_window_screen,
|
||||
)
|
||||
|
||||
player.show()
|
||||
player.show(screens)
|
||||
|
||||
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||||
sys.exit(app.exec())
|
||||
|
@ -4,7 +4,7 @@ from typing import Optional
|
||||
|
||||
from qtpy.QtCore import Qt, QTimer, QUrl, Signal, Slot
|
||||
from qtpy.QtGui import QCloseEvent, QIcon, QKeyEvent, QScreen
|
||||
from qtpy.QtMultimedia import QAudioOutput, QMediaPlayer
|
||||
from qtpy.QtMultimedia import QAudioOutput, QMediaPlayer, QVideoFrame
|
||||
from qtpy.QtMultimediaWidgets import QVideoWidget
|
||||
from qtpy.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
@ -28,7 +28,6 @@ class Info(QWidget): # type: ignore[misc]
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
full_screen: bool,
|
||||
aspect_ratio_mode: Qt.AspectRatioMode,
|
||||
screen: Optional[QScreen],
|
||||
) -> None:
|
||||
@ -38,9 +37,6 @@ class Info(QWidget): # type: ignore[misc]
|
||||
self.setScreen(screen)
|
||||
self.move(screen.geometry().topLeft())
|
||||
|
||||
if full_screen:
|
||||
self.setWindowState(Qt.WindowFullScreen)
|
||||
|
||||
layout = QHBoxLayout()
|
||||
|
||||
# Current slide view
|
||||
@ -226,6 +222,8 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
self.icon = QIcon(":/icon.png")
|
||||
self.setWindowIcon(self.icon)
|
||||
|
||||
self.frame = QVideoFrame()
|
||||
|
||||
self.audio_output = QAudioOutput()
|
||||
self.video_widget = QVideoWidget()
|
||||
self.video_sink = self.video_widget.videoSink()
|
||||
@ -241,15 +239,12 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
self.slide_changed.connect(self.slide_changed_callback)
|
||||
|
||||
self.info = Info(
|
||||
full_screen=full_screen,
|
||||
aspect_ratio_mode=aspect_ratio_mode,
|
||||
screen=info_window_screen,
|
||||
)
|
||||
self.info.close_event.connect(self.closeEvent)
|
||||
self.info.key_press_event.connect(self.keyPressEvent)
|
||||
self.video_sink.videoFrameChanged.connect(
|
||||
lambda frame: self.info.video_sink.setVideoFrame(frame)
|
||||
)
|
||||
self.video_sink.videoFrameChanged.connect(self.frame_changed)
|
||||
self.hide_info_window = hide_info_window
|
||||
|
||||
# Connecting key callbacks
|
||||
@ -319,7 +314,7 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
elif -self.presentations_count <= index < 0:
|
||||
self.__current_presentation_index = index + self.presentations_count
|
||||
else:
|
||||
logger.warn(f"Could not set presentation index to {index}.")
|
||||
logger.warning(f"Could not set presentation index to {index}.")
|
||||
return
|
||||
|
||||
self.presentation_changed.emit()
|
||||
@ -343,7 +338,7 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
elif -self.current_slides_count <= index < 0:
|
||||
self.__current_slide_index = index + self.current_slides_count
|
||||
else:
|
||||
logger.warn(f"Could not set slide index to {index}.")
|
||||
logger.warning(f"Could not set slide index to {index}.")
|
||||
return
|
||||
|
||||
self.slide_changed.emit()
|
||||
@ -468,13 +463,13 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
def presentation_changed_callback(self) -> None:
|
||||
index = self.current_presentation_index
|
||||
count = self.presentations_count
|
||||
self.info.scene_label.setText(f"{index+1:4d}/{count:4<d}")
|
||||
self.info.scene_label.setText(f"{index + 1:4d}/{count:4<d}")
|
||||
|
||||
@Slot()
|
||||
def slide_changed_callback(self) -> None:
|
||||
index = self.current_slide_index
|
||||
count = self.current_slides_count
|
||||
self.info.slide_label.setText(f"{index+1:4d}/{count:4<d}")
|
||||
self.info.slide_label.setText(f"{index + 1:4d}/{count:4<d}")
|
||||
self.info.slide_notes.setText(self.current_slide_config.notes)
|
||||
self.preview_next_slide()
|
||||
|
||||
@ -484,11 +479,28 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
self.info.next_media_player.setSource(url)
|
||||
self.info.next_media_player.play()
|
||||
|
||||
def show(self) -> None:
|
||||
def show(self, screens: list[QScreen]) -> None:
|
||||
"""Screens is necessary to prevent the info window from being shown on the same screen as the main window (especially in full screen mode)."""
|
||||
super().show()
|
||||
|
||||
if not self.hide_info_window:
|
||||
self.info.show()
|
||||
if len(screens) > 1 and self.isFullScreen():
|
||||
self.ensure_different_screens(screens)
|
||||
|
||||
if self.isFullScreen():
|
||||
self.info.showFullScreen()
|
||||
else:
|
||||
self.info.show()
|
||||
|
||||
if (
|
||||
len(screens) > 1 and self.info.screen() == self.screen()
|
||||
): # It is better when Qt assigns the location, but if it fails to, this is a fallback
|
||||
self.ensure_different_screens(screens)
|
||||
|
||||
def ensure_different_screens(self, screens: list[QScreen]) -> None:
|
||||
target_screen = screens[1] if self.screen() == screens[0] else screens[0]
|
||||
self.info.setScreen(target_screen)
|
||||
self.info.move(target_screen.geometry().topLeft())
|
||||
|
||||
@Slot()
|
||||
def close(self) -> None:
|
||||
@ -515,6 +527,9 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
|
||||
@Slot()
|
||||
def reverse(self) -> None:
|
||||
if self.playing_reversed_slide and self.current_slide_index >= 1:
|
||||
self.current_slide_index -= 1
|
||||
|
||||
self.load_reversed_slide()
|
||||
self.preview_next_slide()
|
||||
|
||||
@ -535,8 +550,10 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
def full_screen(self) -> None:
|
||||
if self.windowState() == Qt.WindowFullScreen:
|
||||
self.setWindowState(Qt.WindowNoState)
|
||||
self.info.setWindowState(Qt.WindowNoState)
|
||||
else:
|
||||
self.setWindowState(Qt.WindowFullScreen)
|
||||
self.info.setWindowState(Qt.WindowFullScreen)
|
||||
|
||||
@Slot()
|
||||
def hide_mouse(self) -> None:
|
||||
@ -545,6 +562,34 @@ class Player(QMainWindow): # type: ignore[misc]
|
||||
else:
|
||||
self.setCursor(Qt.BlankCursor)
|
||||
|
||||
def frame_changed(self, frame: QVideoFrame) -> None:
|
||||
"""
|
||||
Slot to handle possibly invalid frames.
|
||||
|
||||
This slot cannot be decorated with ``@Slot`` as
|
||||
the video sinks are handled in different threads.
|
||||
|
||||
As of Qt>=6.5.3, the last frame of every video is "flushed",
|
||||
resulting in a short black screen between each slide.
|
||||
|
||||
To avoid this issue, we check every frame, and avoid playing
|
||||
invalid ones.
|
||||
|
||||
References
|
||||
----------
|
||||
1. https://github.com/jeertmans/manim-slides/issues/293
|
||||
2. https://github.com/jeertmans/manim-slides/pull/464
|
||||
|
||||
:param frame: The most recent frame.
|
||||
|
||||
"""
|
||||
if frame.isValid():
|
||||
self.frame = frame
|
||||
else:
|
||||
self.video_sink.setVideoFrame(self.frame) # Reuse previous frame
|
||||
|
||||
self.info.video_sink.setVideoFrame(self.frame)
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802
|
||||
self.close()
|
||||
|
||||
|
@ -48,7 +48,7 @@ def render(ce: bool, gl: bool, args: tuple[str, ...]) -> None:
|
||||
if ce and gl:
|
||||
raise click.UsageError("You cannot specify both --CE and --GL renderers.")
|
||||
if gl:
|
||||
subprocess.run([sys.executable, "-m", "manimlib", *args])
|
||||
subprocess.run([sys.executable, "-m", "manimlib", "-w", *args])
|
||||
else:
|
||||
from manim.cli.render.commands import render as render_ce
|
||||
|
||||
|
@ -63,7 +63,7 @@ class Wizard(QWidget): # type: ignore
|
||||
|
||||
self.layout = QGridLayout()
|
||||
|
||||
for i, (key, value) in enumerate(self.config.keys.dict().items()):
|
||||
for i, (key, value) in enumerate(self.config.keys.model_dump().items()):
|
||||
# Create label for key name information
|
||||
label = QLabel()
|
||||
key_info = value["name"] or key
|
||||
@ -97,7 +97,7 @@ class Wizard(QWidget): # type: ignore
|
||||
|
||||
def save_config(self) -> None:
|
||||
try:
|
||||
Config.model_validate(self.config.dict())
|
||||
Config.model_validate(self.config.model_dump())
|
||||
except ValueError:
|
||||
msg = QMessageBox()
|
||||
msg.setIcon(QMessageBox.Critical)
|
||||
|
@ -16,6 +16,8 @@ from pydantic import (
|
||||
PositiveInt,
|
||||
PrivateAttr,
|
||||
ValidationError,
|
||||
conset,
|
||||
field_serializer,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
@ -76,20 +78,13 @@ def key_id(name: str) -> PositiveInt:
|
||||
class Key(BaseModel): # type: ignore[misc]
|
||||
"""Represent a list of key codes, with optionally a name."""
|
||||
|
||||
ids: list[PositiveInt] = Field(unique=True)
|
||||
ids: conset(PositiveInt, min_length=1) # type: ignore[valid-type]
|
||||
name: Optional[str] = None
|
||||
|
||||
__signal: Signal = PrivateAttr(default_factory=Signal)
|
||||
|
||||
@field_validator("ids")
|
||||
@classmethod
|
||||
def ids_is_non_empty_set(cls, ids: set[Any]) -> set[Any]:
|
||||
if len(ids) <= 0:
|
||||
raise ValueError("Key's ids must be a non-empty set")
|
||||
return ids
|
||||
|
||||
def set_ids(self, *ids: int) -> None:
|
||||
self.ids = list(set(ids))
|
||||
self.ids = set(ids)
|
||||
|
||||
def match(self, key_id: int) -> bool:
|
||||
"""Return whether a given key id matches this key."""
|
||||
@ -107,6 +102,10 @@ class Key(BaseModel): # type: ignore[misc]
|
||||
def connect(self, function: Receiver) -> None:
|
||||
self.__signal.connect(function)
|
||||
|
||||
@field_serializer("ids")
|
||||
def serialize_dt(self, ids: set[int]) -> list[int]:
|
||||
return list(self.ids)
|
||||
|
||||
|
||||
class Keys(BaseModel): # type: ignore[misc]
|
||||
QUIT: Key = Field(default_factory=lambda: Key(ids=[key_id("Q")], name="QUIT"))
|
||||
@ -203,6 +202,8 @@ class BaseSlideConfig(BaseModel): # type: ignore
|
||||
"""The notes attached to this slide."""
|
||||
dedent_notes: bool = True
|
||||
"""Whether to automatically remove any leading indentation in the notes."""
|
||||
skip_animations: bool = False
|
||||
src: Optional[FilePath] = None
|
||||
|
||||
@classmethod
|
||||
def wrapper(cls, arg_name: str) -> Callable[..., Any]:
|
||||
@ -226,7 +227,7 @@ class BaseSlideConfig(BaseModel): # type: ignore
|
||||
fun_kwargs = {
|
||||
key: value
|
||||
for key, value in kwargs.items()
|
||||
if key not in cls.__fields__
|
||||
if key not in cls.model_fields
|
||||
}
|
||||
fun_kwargs[arg_name] = cls(**kwargs)
|
||||
return fun(*args, **fun_kwargs)
|
||||
@ -240,7 +241,7 @@ class BaseSlideConfig(BaseModel): # type: ignore
|
||||
default=field_info.default,
|
||||
annotation=field_info.annotation,
|
||||
)
|
||||
for field_name, field_info in cls.__fields__.items()
|
||||
for field_name, field_info in cls.model_fields.items()
|
||||
]
|
||||
|
||||
sig = sig.replace(parameters=parameters)
|
||||
@ -251,20 +252,18 @@ class BaseSlideConfig(BaseModel): # type: ignore
|
||||
return _wrapper_
|
||||
|
||||
@model_validator(mode="after")
|
||||
@classmethod
|
||||
def apply_dedent_notes(
|
||||
cls, base_slide_config: "BaseSlideConfig"
|
||||
self,
|
||||
) -> "BaseSlideConfig":
|
||||
"""
|
||||
Remove indentation from notes, if specified.
|
||||
|
||||
:param base_slide_config: The current config.
|
||||
:return: The config, optionally modified.
|
||||
"""
|
||||
if base_slide_config.dedent_notes:
|
||||
base_slide_config.notes = dedent(base_slide_config.notes)
|
||||
if self.dedent_notes:
|
||||
self.notes = dedent(self.notes)
|
||||
|
||||
return base_slide_config
|
||||
return self
|
||||
|
||||
|
||||
class PreSlideConfig(BaseSlideConfig):
|
||||
@ -292,7 +291,7 @@ class PreSlideConfig(BaseSlideConfig):
|
||||
return cls(
|
||||
start_animation=start_animation,
|
||||
end_animation=end_animation,
|
||||
**base_slide_config.dict(),
|
||||
**base_slide_config.model_dump(),
|
||||
)
|
||||
|
||||
@field_validator("start_animation", "end_animation")
|
||||
@ -309,31 +308,37 @@ class PreSlideConfig(BaseSlideConfig):
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
@classmethod
|
||||
def start_animation_is_before_end(
|
||||
cls, pre_slide_config: "PreSlideConfig"
|
||||
self,
|
||||
) -> "PreSlideConfig":
|
||||
"""
|
||||
Validate that start and end animation indices satisfy `start < end`.
|
||||
Validate that start and end animation indices satisfy 'start < end'.
|
||||
|
||||
:param pre_slide_config: The current config.
|
||||
:return: The config, if indices are valid.
|
||||
"""
|
||||
if pre_slide_config.start_animation >= pre_slide_config.end_animation:
|
||||
if pre_slide_config.start_animation == pre_slide_config.end_animation == 0:
|
||||
raise ValueError(
|
||||
"You have to play at least one animation (e.g., `self.wait()`) "
|
||||
"before pausing. If you want to start paused, use the appropriate "
|
||||
"command-line option when presenting. "
|
||||
"IMPORTANT: when using ManimGL, `self.wait()` is not considered "
|
||||
"to be an animation, so prefer to directly use `self.play(...)`."
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
"Start animation index must be strictly lower than end animation index"
|
||||
)
|
||||
return self
|
||||
|
||||
return pre_slide_config
|
||||
@model_validator(mode="after")
|
||||
def has_src_or_more_than_zero_animations(
|
||||
self,
|
||||
) -> "PreSlideConfig":
|
||||
if self.src is not None and self.start_animation != self.end_animation:
|
||||
raise ValueError(
|
||||
"A slide cannot have 'src=...' and more than zero animations at the same time."
|
||||
)
|
||||
elif self.src is None and self.start_animation == self.end_animation:
|
||||
raise ValueError(
|
||||
"You have to play at least one animation (e.g., 'self.wait()') "
|
||||
"before pausing. If you want to start paused, use the appropriate "
|
||||
"command-line option when presenting. "
|
||||
"IMPORTANT: when using ManimGL, 'self.wait()' is not considered "
|
||||
"to be an animation, so prefer to directly use 'self.play(...)'."
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
@property
|
||||
def slides_slice(self) -> slice:
|
||||
@ -352,7 +357,7 @@ class SlideConfig(BaseSlideConfig):
|
||||
def from_pre_slide_config_and_files(
|
||||
cls, pre_slide_config: PreSlideConfig, file: Path, rev_file: Path
|
||||
) -> "SlideConfig":
|
||||
return cls(file=file, rev_file=rev_file, **pre_slide_config.dict())
|
||||
return cls(file=file, rev_file=rev_file, **pre_slide_config.model_dump())
|
||||
|
||||
|
||||
class PresentationConfig(BaseModel): # type: ignore[misc]
|
||||
@ -402,16 +407,15 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
|
||||
use_cached: bool = True,
|
||||
include_reversed: bool = True,
|
||||
prefix: str = "",
|
||||
) -> "PresentationConfig":
|
||||
) -> None:
|
||||
"""
|
||||
Copy the files to a given directory and return the corresponding configuration.
|
||||
|
||||
|
||||
:param folder: The folder that will contain the animation files.
|
||||
:param use_cached: Whether caching should be used to avoid copies when possible.
|
||||
:param include_reversed: Whether to also copy reversed animation to the folder.
|
||||
:param prefix: Optional prefix added to each file name.
|
||||
"""
|
||||
slides = []
|
||||
for slide_config in self.slides:
|
||||
file = slide_config.file
|
||||
rev_file = slide_config.rev_file
|
||||
@ -419,8 +423,6 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
|
||||
dest = folder / f"{prefix}{file.name}"
|
||||
rev_dest = folder / f"{prefix}{rev_file.name}"
|
||||
|
||||
slides.append(slide_config.model_copy(file=dest, rev_file=rev_dest))
|
||||
|
||||
if not use_cached or not dest.exists():
|
||||
shutil.copy(file, dest)
|
||||
|
||||
@ -428,33 +430,3 @@ class PresentationConfig(BaseModel): # type: ignore[misc]
|
||||
# TODO: if include_reversed is False, then rev_dev will likely not exist
|
||||
# and this will cause an issue when decoding.
|
||||
shutil.copy(rev_file, rev_dest)
|
||||
|
||||
return self.model_copy(slides=slides)
|
||||
|
||||
|
||||
def list_presentation_configs(folder: Path) -> list[Path]:
|
||||
"""
|
||||
List all presentation configs in a given folder.
|
||||
|
||||
:param folder: The folder to search the presentation configs.
|
||||
:return: The list of paths that map to valid presentation configs.
|
||||
"""
|
||||
paths = []
|
||||
|
||||
for filepath in folder.glob("*.json"):
|
||||
try:
|
||||
_ = PresentationConfig.from_file(filepath)
|
||||
paths.append(filepath)
|
||||
except (
|
||||
ValidationError,
|
||||
json.JSONDecodeError,
|
||||
) as e: # Could not parse this file as a proper presentation config
|
||||
logger.warn(
|
||||
f"Something went wrong with parsing presentation config `{filepath}`: {e}."
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Found {len(paths)} valid presentation configuration files in `{folder}`."
|
||||
)
|
||||
|
||||
return paths
|
||||
|
@ -1,16 +1,23 @@
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from collections.abc import Iterator
|
||||
from multiprocessing import Pool
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import av
|
||||
from tqdm import tqdm
|
||||
|
||||
from .logger import logger
|
||||
|
||||
|
||||
def concatenate_video_files(files: list[Path], dest: Path) -> None:
|
||||
"""Concatenate multiple video files into one."""
|
||||
if len(files) == 1:
|
||||
shutil.copy(files[0], dest)
|
||||
return
|
||||
|
||||
def _filter(files: list[Path]) -> Iterator[Path]:
|
||||
"""Patch possibly empty video files."""
|
||||
@ -19,7 +26,7 @@ def concatenate_video_files(files: list[Path], dest: Path) -> None:
|
||||
if len(container.streams.video) > 0:
|
||||
yield file
|
||||
else:
|
||||
logger.warn(
|
||||
logger.warning(
|
||||
f"Skipping video file {file} because it does "
|
||||
"not contain any video stream. "
|
||||
"This is probably caused by Manim, see: "
|
||||
@ -89,8 +96,9 @@ def link_nodes(*nodes: av.filter.context.FilterContext) -> None:
|
||||
c.link_to(n)
|
||||
|
||||
|
||||
def reverse_video_file(src: Path, dest: Path) -> None:
|
||||
def reverse_video_file_in_one_chunk(src_and_dest: tuple[Path, Path]) -> None:
|
||||
"""Reverses a video file, writing the result to `dest`."""
|
||||
src, dest = src_and_dest
|
||||
with (
|
||||
av.open(str(src)) as input_container,
|
||||
av.open(str(dest), mode="w") as output_container,
|
||||
@ -120,8 +128,70 @@ def reverse_video_file(src: Path, dest: Path) -> None:
|
||||
|
||||
for _ in range(frames_count):
|
||||
frame = graph.pull()
|
||||
frame.pict_type = 5 # Otherwise we get a warning saying it is changed
|
||||
frame.pict_type = "NONE" # Otherwise we get a warning saying it is changed
|
||||
output_container.mux(output_stream.encode(frame))
|
||||
|
||||
for packet in output_stream.encode():
|
||||
output_container.mux(packet)
|
||||
|
||||
|
||||
def reverse_video_file(
|
||||
src: Path,
|
||||
dest: Path,
|
||||
max_segment_duration: Optional[float] = 4.0,
|
||||
num_processes: Optional[int] = None,
|
||||
**tqdm_kwargs: Any,
|
||||
) -> None:
|
||||
"""Reverses a video file, writing the result to `dest`."""
|
||||
with av.open(str(src)) as input_container: # Fast path if file is short enough
|
||||
input_stream = input_container.streams.video[0]
|
||||
if max_segment_duration is None:
|
||||
return reverse_video_file_in_one_chunk((src, dest))
|
||||
elif input_stream.duration:
|
||||
if (
|
||||
float(input_stream.duration * input_stream.time_base)
|
||||
<= max_segment_duration
|
||||
):
|
||||
return reverse_video_file_in_one_chunk((src, dest))
|
||||
else: # pragma: no cover
|
||||
logger.debug(
|
||||
f"Could not determine duration of {src}, falling back to segmentation."
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdirname:
|
||||
tmpdir = Path(tmpdirname)
|
||||
with av.open(
|
||||
str(tmpdir / f"%04d.{src.suffix}"),
|
||||
"w",
|
||||
format="segment",
|
||||
options={"segment_time": str(max_segment_duration)},
|
||||
) as output_container:
|
||||
output_stream = output_container.add_stream(
|
||||
template=input_stream,
|
||||
)
|
||||
|
||||
for packet in input_container.demux(input_stream):
|
||||
if packet.dts is None:
|
||||
continue
|
||||
|
||||
packet.stream = output_stream
|
||||
output_container.mux(packet)
|
||||
|
||||
src_files = list(tmpdir.iterdir())
|
||||
rev_files = [
|
||||
src_file.with_stem("rev_" + src_file.stem) for src_file in src_files
|
||||
]
|
||||
|
||||
with Pool(num_processes, maxtasksperchild=1) as pool:
|
||||
for _ in tqdm(
|
||||
pool.imap_unordered(
|
||||
reverse_video_file_in_one_chunk, zip(src_files, rev_files)
|
||||
),
|
||||
desc="Reversing large file by cutting it in segments",
|
||||
total=len(src_files),
|
||||
unit=" files",
|
||||
**tqdm_kwargs,
|
||||
):
|
||||
pass # We just consume the iterator
|
||||
|
||||
concatenate_video_files(rev_files[::-1], dest)
|
||||
|
@ -283,8 +283,7 @@ class ManimSlidesDirective(Directive):
|
||||
# Rendering is skipped if the tag skip-manim is present,
|
||||
# or if we are making the pot-files
|
||||
should_skip = (
|
||||
"skip-manim-slides"
|
||||
in self.state.document.settings.env.app.builder.tags.tags
|
||||
self.state.document.settings.env.app.builder.tags.has("skip-manim-slides")
|
||||
or self.state.document.settings.env.app.builder.name == "gettext"
|
||||
or "SKIP_MANIM_SLIDES" in os.environ
|
||||
)
|
||||
@ -340,7 +339,7 @@ class ManimSlidesDirective(Directive):
|
||||
ref_block = ""
|
||||
|
||||
if "quality" in self.options:
|
||||
quality = f'{self.options["quality"]}_quality'
|
||||
quality = f"{self.options['quality']}_quality"
|
||||
else:
|
||||
quality = "example_quality"
|
||||
frame_rate = QUALITIES[quality]["frame_rate"]
|
||||
@ -484,7 +483,7 @@ def _log_rendering_times(*args):
|
||||
)
|
||||
for row in group:
|
||||
print( # noqa: T201
|
||||
f"{' '*(max_file_length)} {row[2].rjust(7)}s {row[1]}"
|
||||
f"{' ' * (max_file_length)} {row[2].rjust(7)}s {row[1]}"
|
||||
)
|
||||
print("") # noqa: T201
|
||||
|
||||
|
@ -125,7 +125,7 @@ class ManimSlidesMagic(Magics): # type: ignore
|
||||
in a cell and evaluate it. Then, a typical Jupyter notebook cell for Manim Slides
|
||||
could look as follows::
|
||||
|
||||
%%manim_slides -v WARNING --progress_bar None MySlide --manim-slides controls=true data_uri=true
|
||||
%%manim_slides -v WARNING --progress_bar None MySlide --manim-slides controls=true one_file=true
|
||||
|
||||
class MySlide(Slide):
|
||||
def construct(self):
|
||||
@ -222,17 +222,29 @@ class ManimSlidesMagic(Magics): # type: ignore
|
||||
|
||||
kwargs = dict(arg.split("=", 1) for arg in manim_slides_args)
|
||||
|
||||
if embed: # Embedding implies data-uri
|
||||
kwargs["data_uri"] = "true"
|
||||
# If data_uri is set, raise a warning
|
||||
if "data_uri" in kwargs:
|
||||
logger.warning(
|
||||
"'data_uri' configuration option is deprecated and will be removed in a future release. "
|
||||
"Please use 'one_file' instead."
|
||||
)
|
||||
kwargs["one_file"] = (
|
||||
kwargs["one_file"]
|
||||
if "one_file" in kwargs
|
||||
else kwargs.pop("data_uri")
|
||||
)
|
||||
|
||||
if embed: # Embedding implies one_file
|
||||
kwargs["one_file"] = "true"
|
||||
|
||||
# TODO: FIXME
|
||||
# Seems like files are blocked so date-uri is the only working option...
|
||||
if kwargs.get("data_uri", "false").lower().strip() == "false":
|
||||
logger.warn(
|
||||
"data_uri option is currently automatically enabled, "
|
||||
# Seems like files are blocked so one_file is the only working option...
|
||||
if kwargs.get("one_file", "false").lower().strip() == "false":
|
||||
logger.warning(
|
||||
"one_file option is currently automatically enabled, "
|
||||
"because using local video files does not seem to work properly."
|
||||
)
|
||||
kwargs["data_uri"] = "true"
|
||||
kwargs["one_file"] = "true"
|
||||
|
||||
presentation_configs = get_scenes_presentation_config(
|
||||
[clsname], Path("./slides")
|
||||
|
@ -1,9 +1,9 @@
|
||||
"""Slides module with logic to either import ManimCE or ManimGL."""
|
||||
|
||||
__all__ = (
|
||||
"API_NAME",
|
||||
"MANIM",
|
||||
"MANIMGL",
|
||||
"API_NAME",
|
||||
"Slide",
|
||||
"ThreeDSlide",
|
||||
)
|
||||
@ -40,7 +40,7 @@ API: str = os.environ.get(MANIM_API, "manim").lower()
|
||||
|
||||
if API not in API_NAMES:
|
||||
raise ImportError(
|
||||
f"Specified MANIM_API={API!r} is not in valid options: " f"{API_NAMES}",
|
||||
f"Specified MANIM_API={API!r} is not in valid options: {API_NAMES}",
|
||||
)
|
||||
|
||||
API_NAME = API_NAMES[API]
|
||||
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
__all__ = ("BaseSlide",)
|
||||
|
||||
import platform
|
||||
import shutil
|
||||
from abc import abstractmethod
|
||||
from collections.abc import MutableMapping, Sequence, ValuesView
|
||||
from pathlib import Path
|
||||
@ -39,6 +40,12 @@ LEFT: np.ndarray = np.array([-1.0, 0.0, 0.0])
|
||||
|
||||
|
||||
class BaseSlide:
|
||||
disable_caching: bool = False
|
||||
flush_cache: bool = False
|
||||
skip_reversing: bool = False
|
||||
max_duration_before_split_reverse: float | None = 4.0
|
||||
num_processes: int | None = None
|
||||
|
||||
def __init__(
|
||||
self, *args: Any, output_folder: Path = FOLDER_PATH, **kwargs: Any
|
||||
) -> None:
|
||||
@ -51,6 +58,7 @@ class BaseSlide:
|
||||
self._start_animation = 0
|
||||
self._canvas: MutableMapping[str, Mobject] = {}
|
||||
self._wait_time_between_slides = 0.0
|
||||
self._skip_animations = False
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
@ -177,11 +185,23 @@ class BaseSlide:
|
||||
animations. You must still call :code:`self.add` or
|
||||
play some animation that introduces each Mobject for
|
||||
it to appear. The same applies when removing objects.
|
||||
|
||||
.. seealso::
|
||||
|
||||
:attr:`canvas` for usage examples.
|
||||
"""
|
||||
self._canvas.update(objects)
|
||||
|
||||
def remove_from_canvas(self, *names: str) -> None:
|
||||
"""Remove objects from the canvas."""
|
||||
"""
|
||||
Remove objects from the canvas.
|
||||
|
||||
:param names: The names of objects to remove.
|
||||
|
||||
.. seealso::
|
||||
|
||||
:attr:`canvas` for usage examples.
|
||||
"""
|
||||
for name in names:
|
||||
self._canvas.pop(name)
|
||||
|
||||
@ -193,8 +213,12 @@ class BaseSlide:
|
||||
@property
|
||||
def mobjects_without_canvas(self) -> Sequence[Mobject]:
|
||||
"""
|
||||
Return the list of objects contained in the scene, minus those present in
|
||||
Return the list of Mobjects contained in the scene, minus those present in
|
||||
the canvas.
|
||||
|
||||
.. seealso::
|
||||
|
||||
:attr:`canvas` for usage examples.
|
||||
"""
|
||||
return [
|
||||
mobject
|
||||
@ -263,7 +287,7 @@ class BaseSlide:
|
||||
self._wait_time_between_slides = max(wait_time, 0.0)
|
||||
|
||||
def play(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Overload `self.play` and increment animation count."""
|
||||
"""Overload 'self.play' and increment animation count."""
|
||||
super().play(*args, **kwargs) # type: ignore[misc]
|
||||
self._current_animation += 1
|
||||
|
||||
@ -282,36 +306,62 @@ class BaseSlide:
|
||||
next slide is played. By default, this is the right arrow key.
|
||||
|
||||
:param args:
|
||||
Positional arguments to be passed to
|
||||
Positional arguments passed to
|
||||
:meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,
|
||||
or ignored if `manimlib` API is used.
|
||||
:param skip_animations:
|
||||
Exclude the next slide from the output.
|
||||
|
||||
If `manim` is used, this is also passed to :meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,
|
||||
which will avoid rendering the corresponding animations.
|
||||
|
||||
.. seealso::
|
||||
|
||||
:meth:`start_skip_animations`
|
||||
:meth:`stop_skip_animations`
|
||||
:param loop:
|
||||
If set, next slide will be looping.
|
||||
:param auto_next:
|
||||
If set, next slide will play immediately play the next slide
|
||||
upon terminating.
|
||||
|
||||
Note that this is only supported by ``manim-slides present``
|
||||
and ``manim-slides convert --to=html``.
|
||||
.. warning::
|
||||
|
||||
Only supported by ``manim-slides present``
|
||||
and ``manim-slides convert --to=html``.
|
||||
:param playback_rate:
|
||||
Playback rate at which the video is played.
|
||||
|
||||
Note that this is only supported by ``manim-slides present``.
|
||||
.. warning::
|
||||
|
||||
Only supported by ``manim-slides present``.
|
||||
:param reversed_playback_rate:
|
||||
Playback rate at which the reversed video is played.
|
||||
|
||||
Note that this is only supported by ``manim-slides present``.
|
||||
.. warning::
|
||||
|
||||
Only supported by ``manim-slides present``.
|
||||
:param notes:
|
||||
Presenter notes, in Markdown format.
|
||||
|
||||
Note that PowerPoint does not support Markdown.
|
||||
.. note::
|
||||
PowerPoint does not support Markdown formatting,
|
||||
so the text will be displayed as is.
|
||||
|
||||
Note that this is only supported by ``manim-slides present``
|
||||
and ``manim-slides convert --to=html/pptx``.
|
||||
.. warning::
|
||||
|
||||
Only supported by ``manim-slides present``,
|
||||
``manim-slides convert --to=html`` and
|
||||
``manim-slides convert --to=pptx``.
|
||||
:param dedent_notes:
|
||||
If set, apply :func:`textwrap.dedent` to notes.
|
||||
:param pathlib.Path src:
|
||||
An optional path to a video file to include as next slide.
|
||||
|
||||
The video will be copied into the output folder, but no rescaling
|
||||
is applied.
|
||||
:param kwargs:
|
||||
Keyword arguments to be passed to
|
||||
Keyword arguments passed to
|
||||
:meth:`Scene.next_section<manim.scene.scene.Scene.next_section>`,
|
||||
or ignored if `manimlib` API is used.
|
||||
|
||||
@ -433,6 +483,21 @@ class BaseSlide:
|
||||
|
||||
self._current_slide += 1
|
||||
|
||||
if base_slide_config.src is not None:
|
||||
self._slides.append(
|
||||
PreSlideConfig.from_base_slide_config_and_animation_indices(
|
||||
base_slide_config,
|
||||
self._current_animation,
|
||||
self._current_animation,
|
||||
)
|
||||
)
|
||||
|
||||
base_slide_config = BaseSlideConfig() # default
|
||||
self._current_slide += 1
|
||||
|
||||
if self._skip_animations:
|
||||
base_slide_config.skip_animations = True
|
||||
|
||||
self._base_slide_config = base_slide_config
|
||||
self._start_animation = self._current_animation
|
||||
|
||||
@ -452,11 +517,17 @@ class BaseSlide:
|
||||
)
|
||||
)
|
||||
|
||||
def _save_slides(self, use_cache: bool = True) -> None:
|
||||
def _save_slides( # noqa: C901
|
||||
self,
|
||||
use_cache: bool = True,
|
||||
flush_cache: bool = False,
|
||||
skip_reversing: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Save slides, optionally using cached files.
|
||||
|
||||
Note that cached files only work with Manim.
|
||||
.. warning:
|
||||
Caching files only work with Manim.
|
||||
"""
|
||||
self._add_last_slide()
|
||||
|
||||
@ -465,6 +536,9 @@ class BaseSlide:
|
||||
scene_name = str(self)
|
||||
scene_files_folder = files_folder / scene_name
|
||||
|
||||
if flush_cache and scene_files_folder.exists():
|
||||
shutil.rmtree(scene_files_folder)
|
||||
|
||||
scene_files_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
files: list[Path] = self._partial_movie_files
|
||||
@ -482,14 +556,25 @@ class BaseSlide:
|
||||
|
||||
for pre_slide_config in tqdm(
|
||||
self._slides,
|
||||
desc=f"Concatenating animation files to '{scene_files_folder}' and generating reversed animations",
|
||||
desc=f"Concatenating animations to '{scene_files_folder}' and generating reversed animations",
|
||||
leave=self._leave_progress_bar,
|
||||
ascii=True if platform.system() == "Windows" else None,
|
||||
disable=not self._show_progress_bar,
|
||||
unit=" slides",
|
||||
):
|
||||
slide_files = files[pre_slide_config.slides_slice]
|
||||
if pre_slide_config.skip_animations:
|
||||
continue
|
||||
if pre_slide_config.src:
|
||||
slide_files = [pre_slide_config.src]
|
||||
else:
|
||||
slide_files = files[pre_slide_config.slides_slice]
|
||||
|
||||
file = merge_basenames(slide_files)
|
||||
try:
|
||||
file = merge_basenames(slide_files)
|
||||
except ValueError as e:
|
||||
raise ValueError(
|
||||
f"Failed to merge basenames of files for slide: {pre_slide_config!r}"
|
||||
) from e
|
||||
dst_file = scene_files_folder / file.name
|
||||
rev_file = scene_files_folder / f"{file.stem}_reversed{file.suffix}"
|
||||
|
||||
@ -499,7 +584,18 @@ class BaseSlide:
|
||||
|
||||
# We only reverse video if it was not present
|
||||
if not use_cache or not rev_file.exists():
|
||||
reverse_video_file(dst_file, rev_file)
|
||||
if skip_reversing:
|
||||
rev_file = dst_file
|
||||
else:
|
||||
reverse_video_file(
|
||||
dst_file,
|
||||
rev_file,
|
||||
max_segment_duration=self.max_duration_before_split_reverse,
|
||||
num_processes=self.num_processes,
|
||||
leave=self._leave_progress_bar,
|
||||
ascii=True if platform.system() == "Windows" else None,
|
||||
disable=not self._show_progress_bar,
|
||||
)
|
||||
|
||||
slides.append(
|
||||
SlideConfig.from_pre_slide_config_and_files(
|
||||
@ -523,6 +619,22 @@ class BaseSlide:
|
||||
f"Slide '{scene_name}' configuration written in '{slide_path.absolute()}'"
|
||||
)
|
||||
|
||||
def start_skip_animations(self) -> None:
|
||||
"""
|
||||
Start skipping animations.
|
||||
|
||||
This automatically applies ``skip_animations=True``
|
||||
to all subsequent calls to :meth:`next_slide`.
|
||||
|
||||
This is useful when you want to skip animations from multiple slides in a row,
|
||||
without having to manually set ``skip_animations=True``.
|
||||
"""
|
||||
self._skip_animations = True
|
||||
|
||||
def stop_skip_animations(self) -> None:
|
||||
"""Stop skipping animations."""
|
||||
self._skip_animations = False
|
||||
|
||||
def wipe(
|
||||
self,
|
||||
*args: Any,
|
||||
|
@ -13,10 +13,43 @@ from .base import BaseSlide
|
||||
|
||||
class Slide(BaseSlide, Scene): # type: ignore[misc]
|
||||
"""
|
||||
Inherits from :class:`Scene<manim.scene.scene.Scene>` and provide necessary tools
|
||||
Inherits from :class:`Scene<manim.scene.scene.Scene>` and provides necessary tools
|
||||
for slides rendering.
|
||||
|
||||
:param args: Positional arguments passed to scene object.
|
||||
:param pathlib.Path output_folder: Where the slide animation files should be written.
|
||||
:param kwargs: Keyword arguments passed to scene object.
|
||||
:cvar bool disable_caching: :data:`False`: Whether to disable the use of
|
||||
cached animation files.
|
||||
:cvar bool flush_cache: :data:`False`: Whether to flush the cache.
|
||||
Unlike with Manim, flushing is performed before rendering.
|
||||
:cvar bool skip_reversing: :data:`False`: Whether to generate reversed animations.
|
||||
If set to :data:`False`, and no cached reversed animation
|
||||
exists (or caching is disabled) for a given slide,
|
||||
then the reversed animation will be simply the same
|
||||
as the original one, i.e., ``rev_file = file``,
|
||||
for the current slide config.
|
||||
:cvar typing.Optional[float] max_duration_before_split_reverse: :data:`4.0`: Maximum duration
|
||||
before of a video animation before it is reversed by splitting the file into smaller chunks.
|
||||
Generating reversed animations can require an important amount of
|
||||
memory (because the whole video needs to be kept in memory),
|
||||
and splitting the video into multiple chunks usually speeds
|
||||
up the process (because it can be done in parallel) while taking
|
||||
less memory.
|
||||
Set this to :data:`None` to disable splitting the file into chunks.
|
||||
:cvar typing.Optional[int] num_processes: :data:`None`: Number of processes
|
||||
to use for parallelizable operations.
|
||||
If :data:`None`, defaults to :func:`os.process_cpu_count`.
|
||||
This is currently used when generating reversed animations, and can
|
||||
increase memory consumption.
|
||||
"""
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
# OpenGL renderer disables 'write_to_movie' by default
|
||||
# which is required for saving the animations
|
||||
config["write_to_movie"] = True
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def _frame_shape(self) -> tuple[float, float]:
|
||||
if isinstance(self.renderer, OpenGLRenderer):
|
||||
@ -75,6 +108,15 @@ class Slide(BaseSlide, Scene): # type: ignore[misc]
|
||||
def _start_at_animation_number(self) -> Optional[int]:
|
||||
return config["from_animation_number"] # type: ignore
|
||||
|
||||
def play(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Overload 'self.play' and increment animation count."""
|
||||
super().play(*args, **kwargs)
|
||||
|
||||
if self._base_slide_config.skip_animations:
|
||||
# Manim will not render the animations, so we reset the animation
|
||||
# counter to the previous value
|
||||
self._current_animation -= 1
|
||||
|
||||
def next_section(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""
|
||||
Alias to :meth:`next_slide`.
|
||||
@ -97,23 +139,41 @@ class Slide(BaseSlide, Scene): # type: ignore[misc]
|
||||
base_slide_config: BaseSlideConfig,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
Scene.next_section(self, *args, **kwargs)
|
||||
Scene.next_section(
|
||||
self,
|
||||
*args,
|
||||
skip_animations=base_slide_config.skip_animations | self._skip_animations,
|
||||
**kwargs,
|
||||
)
|
||||
BaseSlide.next_slide.__wrapped__(
|
||||
self,
|
||||
base_slide_config=base_slide_config,
|
||||
)
|
||||
|
||||
def render(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""MANIM render."""
|
||||
"""MANIM renderer."""
|
||||
# We need to disable the caching limit since we rely on intermediate files
|
||||
max_files_cached = config["max_files_cached"]
|
||||
config["max_files_cached"] = float("inf")
|
||||
|
||||
flush_manim_cache = config["flush_cache"]
|
||||
|
||||
if flush_manim_cache:
|
||||
# We need to postpone flushing *after* we saved slides
|
||||
config["flush_cache"] = False
|
||||
|
||||
super().render(*args, **kwargs)
|
||||
|
||||
config["max_files_cached"] = max_files_cached
|
||||
|
||||
self._save_slides()
|
||||
self._save_slides(
|
||||
use_cache=not (config["disable_caching"] or self.disable_caching),
|
||||
flush_cache=(config["flush_cache"] or self.flush_cache),
|
||||
skip_reversing=self.skip_reversing,
|
||||
)
|
||||
|
||||
if flush_manim_cache:
|
||||
self.renderer.file_writer.flush_cache_directory()
|
||||
|
||||
|
||||
class ThreeDSlide(Slide, ThreeDScene): # type: ignore[misc]
|
||||
|
@ -4,7 +4,6 @@ from pathlib import Path
|
||||
from typing import Any, ClassVar, Optional
|
||||
|
||||
from manimlib import Scene, ThreeDCamera
|
||||
from manimlib.utils.file_ops import get_sorted_integer_files
|
||||
|
||||
from .base import BaseSlide
|
||||
|
||||
@ -12,42 +11,40 @@ from .base import BaseSlide
|
||||
class Slide(BaseSlide, Scene): # type: ignore[misc]
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
kwargs.setdefault("file_writer_config", {}).update(
|
||||
skip_animations=True,
|
||||
break_into_partial_movies=True,
|
||||
write_to_movie=True,
|
||||
subdivide_output=True,
|
||||
)
|
||||
|
||||
kwargs["preview"] = False # Avoid opening a preview window
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def _frame_height(self) -> float:
|
||||
return self.camera.frame.get_height() # type: ignore
|
||||
return float(self.camera.get_frame_height())
|
||||
|
||||
@property
|
||||
def _frame_width(self) -> float:
|
||||
return self.camera.frame.get_width() # type: ignore
|
||||
return float(self.camera.get_frame_width())
|
||||
|
||||
@property
|
||||
def _background_color(self) -> str:
|
||||
return self.camera_config["background_color"].hex # type: ignore
|
||||
rgba = self.camera.background_rgba
|
||||
r = int(255 * rgba[0])
|
||||
g = int(255 * rgba[1])
|
||||
b = int(255 * rgba[2])
|
||||
if rgba[3] == 1.0:
|
||||
return f"#{r:02x}{g:02x}{b:02x}"
|
||||
|
||||
a = int(255 * rgba[3])
|
||||
return f"#{r:02x}{g:02x}{b:02x}{a:02x}"
|
||||
|
||||
@property
|
||||
def _resolution(self) -> tuple[int, int]:
|
||||
return self.camera_config["pixel_width"], self.camera_config["pixel_height"]
|
||||
return self.camera.get_pixel_width(), self.camera.get_pixel_height()
|
||||
|
||||
@property
|
||||
def _partial_movie_files(self) -> list[Path]:
|
||||
kwargs = {
|
||||
"remove_non_integer_files": True,
|
||||
"extension": self.file_writer.movie_file_extension,
|
||||
}
|
||||
return [
|
||||
Path(file)
|
||||
for file in get_sorted_integer_files(
|
||||
self.file_writer.partial_movie_directory, **kwargs
|
||||
)
|
||||
]
|
||||
partial_movie_directory = self.file_writer.partial_movie_directory
|
||||
extension = self.file_writer.movie_file_extension
|
||||
return sorted(partial_movie_directory.glob(f"*{extension}"))
|
||||
|
||||
@property
|
||||
def _show_progress_bar(self) -> bool:
|
||||
@ -64,7 +61,11 @@ class Slide(BaseSlide, Scene): # type: ignore[misc]
|
||||
def run(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""MANIMGL renderer."""
|
||||
super().run(*args, **kwargs)
|
||||
self._save_slides(use_cache=False)
|
||||
self._save_slides(
|
||||
use_cache=False,
|
||||
flush_cache=self.flush_cache,
|
||||
skip_reversing=self.skip_reversing,
|
||||
)
|
||||
|
||||
|
||||
class ThreeDSlide(Slide):
|
||||
|
Reference in New Issue
Block a user