-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: Add initial working implementation of recording TerminHTML to gif
- Loading branch information
1 parent
798f24b
commit 80fd959
Showing
13 changed files
with
533 additions
and
6 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
from pathlib import Path | ||
|
||
import moviepy.editor as mp | ||
|
||
from terminhtml_recorder.logger import log | ||
|
||
|
||
def convert_video_to_gif(in_path: Path, out_path: Path, delay: float = 1.1) -> None: | ||
clip = mp.VideoFileClip(str(in_path.resolve())) | ||
# Remove first portion of clip before terminhtml-js loads | ||
clip = clip.subclip(delay, clip.duration).resize(0.7) | ||
clip.write_gif(str(out_path.resolve()), program="ffmpeg", fps=10) | ||
log.info(f"Demo output gif saved to {out_path}") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
from enum import Enum | ||
|
||
|
||
class OutputFormat(str, Enum): | ||
WEBM = "webm" | ||
GIF = "gif" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
import logging | ||
from enum import Enum | ||
|
||
from pydantic import BaseSettings, validator | ||
from rich.logging import RichHandler | ||
|
||
|
||
class LogLevel(str, Enum): | ||
INFO = "INFO" | ||
DEBUG = "DEBUG" | ||
|
||
|
||
class LoggingConfig(BaseSettings): | ||
level: LogLevel = LogLevel.INFO | ||
|
||
class Config: | ||
env_prefix = "TERMINHTML_RECORDER_LOG_" | ||
|
||
@validator("level", pre=True) | ||
def cast_log_level(cls, v): | ||
if isinstance(v, LogLevel): | ||
return v | ||
level = str(v).casefold().strip() | ||
if level == "info": | ||
return LogLevel.INFO | ||
elif level == "debug": | ||
return LogLevel.DEBUG | ||
raise ValueError(f"invalid log level {level}") | ||
|
||
|
||
LOGGING_CONFIG = LoggingConfig() | ||
|
||
logging.basicConfig( | ||
level=LOGGING_CONFIG.level.value, | ||
format="%(message)s", | ||
datefmt="[%X]", | ||
handlers=[RichHandler(rich_tracebacks=True)], | ||
) | ||
|
||
log = logging.getLogger("terminhtml-recorder") | ||
|
||
if __name__ == "__main__": | ||
log.info("info level") | ||
log.debug("debug level") | ||
try: | ||
raise ValueError("exception") | ||
except ValueError as e: | ||
log.exception(e) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
import shutil | ||
import time | ||
from dataclasses import dataclass | ||
from pathlib import Path | ||
from typing import Callable, Union | ||
|
||
from playwright.sync_api import Locator, Page, sync_playwright | ||
|
||
from terminhtml_recorder.converter import convert_video_to_gif | ||
from terminhtml_recorder.formats import OutputFormat | ||
from terminhtml_recorder.logger import log | ||
from terminhtml_recorder.temp_path import create_temp_path | ||
|
||
|
||
@dataclass | ||
class Recording: | ||
path: Path | ||
format: OutputFormat | ||
|
||
def convert_to( | ||
self, new_format: OutputFormat, out_path: Path, delay: float = 1.1 | ||
) -> "Recording": | ||
if new_format == self.format: | ||
shutil.copy(self.path, out_path) | ||
# TODO: need to trim for delay in webm output | ||
return Recording(path=out_path, format=new_format) | ||
elif new_format == OutputFormat.GIF: | ||
convert_video_to_gif(self.path, out_path, delay=delay) | ||
return Recording(path=out_path, format=new_format) | ||
raise NotImplementedError(f"Conversion from {self.format} to {new_format} not implemented") | ||
|
||
|
||
@dataclass | ||
class PageLocators: | ||
speed_up: Locator | ||
speed_down: Locator | ||
restart: Locator | ||
|
||
@classmethod | ||
def from_page(cls, page: Page) -> "PageLocators": | ||
return cls( | ||
speed_up=page.locator("text=►"), | ||
speed_down=page.locator("text=◄"), | ||
restart=page.locator("text=restart ↻"), | ||
) | ||
|
||
|
||
PageInteractor = Callable[[PageLocators], None] | ||
|
||
|
||
def default_page_interactor(page_locators: PageLocators) -> None: | ||
time.sleep(1) | ||
page_locators.speed_up.click() | ||
time.sleep(1) | ||
page_locators.speed_up.click() | ||
time.sleep(0.5) | ||
page_locators.speed_down.click() | ||
page_locators.restart.wait_for() | ||
|
||
|
||
class TerminHTMLRecorder: | ||
def __init__(self, html: str, interactor: PageInteractor = default_page_interactor): | ||
self.html = html | ||
self.interactor = interactor | ||
|
||
def record( | ||
self, | ||
out_path: Union[str, Path], | ||
format: OutputFormat = OutputFormat.GIF, | ||
delay: float = 1.1, | ||
) -> Recording: | ||
with create_temp_path() as temp_path: | ||
log.info(f"Creating recording in {temp_path}") | ||
html_path = temp_path / "termin.html" | ||
html_path.write_text(self.html) | ||
|
||
with sync_playwright() as p: | ||
browser = p.chromium.launch() | ||
dimensions = dict(width=800, height=530) | ||
context = browser.new_context( | ||
viewport=dimensions, | ||
record_video_dir=str(temp_path.resolve()), | ||
record_video_size=dimensions, | ||
) | ||
page = context.new_page() | ||
page.goto(html_path.as_uri()) | ||
locators = PageLocators.from_page(page) | ||
self.interactor(locators) | ||
context.close() | ||
browser.close() | ||
|
||
for video in temp_path.glob("*.webm"): | ||
return Recording( | ||
path=video, | ||
format=OutputFormat.WEBM, | ||
).convert_to(format, Path(out_path), delay=delay) | ||
|
||
raise ValueError(f"No video found in temp_path {temp_path}") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import contextlib | ||
import shutil | ||
import tempfile | ||
from pathlib import Path | ||
from typing import Iterator | ||
|
||
|
||
@contextlib.contextmanager | ||
def create_temp_path() -> Iterator[Path]: | ||
""" | ||
Returns a temporary folder path | ||
Use this instead of tempfile.TemporaryDirectory because: | ||
1. That returns a string and not a path | ||
2. On MacOS, the temp directory has a symlink. This resolves the symlink so that | ||
there won't be any mismatch in resolved paths. | ||
3. On Windows, the temp directory can fail to delete with a PermissionError. This function | ||
will try to delete the temp directory, but if it fails with an error it will just ignore it. | ||
""" | ||
temp_dir = tempfile.TemporaryDirectory() | ||
temp_path = Path(temp_dir.name).resolve() | ||
yield temp_path | ||
shutil.rmtree(temp_path, ignore_errors=True) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
from pathlib import Path | ||
|
||
TESTS_DIR = Path(__file__).parent | ||
INPUT_FILES_DIR = TESTS_DIR / "input_files" | ||
|
||
TERMINHTML_DEMO_GIF = INPUT_FILES_DIR / "terminhtml-demo.gif" |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,28 @@ | ||
""" | ||
Run this script to generate the files in input_files so that | ||
they can be manually QA'ed. | ||
""" | ||
|
||
from pathlib import Path | ||
|
||
from terminhtml.main import TerminHTML | ||
|
||
from terminhtml_recorder.formats import OutputFormat | ||
from terminhtml_recorder.recorder import Recording, TerminHTMLRecorder | ||
from tests.config import TERMINHTML_DEMO_GIF | ||
|
||
|
||
def create_terminhtml_demo_gif(out_path: Path = TERMINHTML_DEMO_GIF) -> Recording: | ||
term = TerminHTML.from_commands( | ||
["python -m terminhtml.demo_output"], | ||
prompt_matchers=["\\[0m: "], | ||
input=["Nick DeRobertis"], | ||
) | ||
text = term.to_html() | ||
recorder = TerminHTMLRecorder(text) | ||
recording = recorder.record(out_path, OutputFormat.GIF) | ||
return recording | ||
|
||
|
||
if __name__ == "__main__": | ||
create_terminhtml_demo_gif() |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
from terminhtml_recorder.formats import OutputFormat | ||
from terminhtml_recorder.temp_path import create_temp_path | ||
from tests.generate import create_terminhtml_demo_gif | ||
|
||
|
||
def test_recorder(): | ||
with create_temp_path() as temp_folder: | ||
gif = create_terminhtml_demo_gif(temp_folder / "terminhtml-demo.gif") | ||
# TODO: Verify GIF output in tests | ||
assert gif.format == OutputFormat.GIF | ||
assert gif.path.exists() | ||
assert gif.path.is_file() | ||
assert gif.path.stat().st_size > 0 | ||
assert gif.path.parent == temp_folder |