Skip to content

Commit

Permalink
feat: Add initial working implementation of recording TerminHTML to gif
Browse files Browse the repository at this point in the history
  • Loading branch information
nickderobertis committed Jun 11, 2022
1 parent 798f24b commit 80fd959
Show file tree
Hide file tree
Showing 13 changed files with 533 additions and 6 deletions.
4 changes: 4 additions & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,7 @@ flexlate = "*"
myst-parser = "*"
sphinx-material = "*"
nox = "*"
terminhtml = "*"
playwright = "*"
moviepy = "*"
rich = "*"
292 changes: 289 additions & 3 deletions Pipfile.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@
# e.g.
# 'package',
# 'otherpackage>=1,<2'
"moviepy",
"playwright",
"rich",
"terminhtml",
]

# Add any third party packages you use in requirements for optional features of your package here
Expand Down
13 changes: 13 additions & 0 deletions terminhtml_recorder/converter.py
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}")
6 changes: 6 additions & 0 deletions terminhtml_recorder/formats.py
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"
48 changes: 48 additions & 0 deletions terminhtml_recorder/logger.py
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)
98 changes: 98 additions & 0 deletions terminhtml_recorder/recorder.py
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}")
23 changes: 23 additions & 0 deletions terminhtml_recorder/temp_path.py
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)
6 changes: 6 additions & 0 deletions tests/config.py
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"
28 changes: 28 additions & 0 deletions tests/generate.py
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()
Binary file added tests/input_files/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.
3 changes: 0 additions & 3 deletions tests/test_placeholder.py

This file was deleted.

14 changes: 14 additions & 0 deletions tests/test_recorder.py
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

0 comments on commit 80fd959

Please sign in to comment.