Skip to content

Commit

Permalink
move to playwright
Browse files Browse the repository at this point in the history
The dashboard now spawns only one instance of Chromium
which is re-used for each new image generation. This
should fix memory issues (hopefuly).
  • Loading branch information
ugomeda committed Jan 31, 2025
1 parent 20cb199 commit 9936423
Show file tree
Hide file tree
Showing 7 changed files with 210 additions and 93 deletions.
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ RUN apt-get update \
WORKDIR /app

COPY --from=builder /app/venv/ /app/venv/
RUN ["/app/venv/bin/python3", "-m", "playwright", "install", "chromium", "--no-shell"]

COPY inkplate_dashboard /app/inkplate_dashboard

CMD ["/app/venv/bin/python3", "-m", "uvicorn", "inkplate_dashboard.app:app", "--host", "0.0.0.0"]
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ from an RSS feed and the current weather, provided by the

## Server

The server generates the dashboard as an HTML page, and uses Chrome to
The server generates the dashboard as an HTML page, and uses Chromium to
screenshot it and generate a PNG which can be sent to the Inkplate 10.

### Docker quickstart
Expand All @@ -29,19 +29,18 @@ Then access:

### Run locally

For a local setup, you will need Google Chrome or Chromium installed
on your machine.

To run the server:

- Create a stub configuration by copying `config.example.toml` to `config.toml`
- Setup your poetry environment
- Install Chromium
- Run the application

```
cp config.exemple.toml config.toml
poetry shell
poetry install
playwright install chromium --no-shell
uvicorn inkplate_dashboard.app:app
```

Expand Down
7 changes: 5 additions & 2 deletions inkplate_dashboard/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles

from inkplate_dashboard.chrome import chrome_lifespan
from inkplate_dashboard.config import parse_config
from inkplate_dashboard.web import (
DisplayHtmlEndpoint,
Expand All @@ -16,8 +17,10 @@
Mount("/static", app=StaticFiles(packages=["inkplate_dashboard"]), name="static"),
]

app = Starlette(routes=routes)
app = Starlette(routes=routes, lifespan=chrome_lifespan)

with open("config.toml", "rb") as fd:
config = parse_config(fd)
app.display = config.display # type: ignore[attr-defined]


app.display = config.display # type: ignore[attr-defined]
114 changes: 52 additions & 62 deletions inkplate_dashboard/chrome.py
Original file line number Diff line number Diff line change
@@ -1,77 +1,67 @@
import asyncio
import contextlib
import hashlib
import io
import itertools
import os
import subprocess
import tempfile
from collections.abc import AsyncIterator
from dataclasses import dataclass

from PIL import Image
from playwright.async_api import Browser, async_playwright
from starlette.applications import Starlette

GOOGLE_CHROME_PATHS = [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/usr/bin/chromium",
]

def _convert_image(screenshot: bytes) -> tuple[bytes, str]:
# Reduce the palette to 8 colors to reduce file size and control
# dithering
palette = list(
itertools.chain(
*[
[
round((i + 1 / 2) * (256 / 8)),
round((i + 1 / 2) * (256 / 8)),
round((i + 1 / 2) * (256 / 8)),
]
for i in range(8)
]
)
)
palette_img = Image.new("P", (1, 1))
palette_img.putpalette(palette * 32)
img = Image.open(io.BytesIO(screenshot)).convert("RGB")
img = img.crop((0, 0, 825, 1200))
img = img.quantize(kmeans=0, palette=palette_img).convert("L")
img = img.rotate(90, expand=1)

def get_chrome_path() -> str:
for chrome_path in GOOGLE_CHROME_PATHS:
if os.path.exists(chrome_path):
return chrome_path
hash = hashlib.sha256(img.tobytes()).hexdigest()

raise Exception("Could not find Chrome/Chromium path")
output = io.BytesIO()
img.save(output, "png")

return output.getvalue(), hash

def screenshot_display() -> tuple[bytes, str]:
"""Makes a screenshot of the html view and return the PNG
image and a hash.
"""
with tempfile.TemporaryDirectory() as tmp_dir:
screenshot_path = os.path.join(tmp_dir, "screenshot.png")
subprocess.run(
[
get_chrome_path(),
"--headless=new",
"--disable-gpu",
"--high-dpi-support=1",
"--no-sandbox",
"--force-device-scale-factor=1",
"--disable-lcd-text", # B&W display
f"--screenshot={screenshot_path}",
# chromium seems to eat the bottom of the page,
# we add some padding which is cut later
"--window-size=825,1500",
"--virtual-time-budget=10000",
"--timeout=5000",
"http://127.0.0.1:8000/live/html",
],
check=True,
timeout=10,
)

# Reduce the palette to 8 colors to reduce file size and control
# dithering
palette = list(
itertools.chain(
*[
[
round((i + 1 / 2) * (256 / 8)),
round((i + 1 / 2) * (256 / 8)),
round((i + 1 / 2) * (256 / 8)),
]
for i in range(8)
]
)
)
palette_img = Image.new("P", (1, 1))
palette_img.putpalette(palette * 32)
img = Image.open(screenshot_path).convert("RGB")
img = img.crop((0, 0, 825, 1200))
img = img.quantize(kmeans=0, palette=palette_img).convert("L")
img = img.rotate(90, expand=1)
@dataclass
class ChromiumInstance:
browser: Browser

async def screenshot(self) -> tuple[bytes, str]:
page = await self.browser.new_page(viewport={"width": 825, "height": 1200})
try:
await page.goto("http://127.0.0.1:8000/live/html", timeout=5000)
screenshot = await page.screenshot()
finally:
await page.close()

hash = hashlib.sha256(img.tobytes()).hexdigest()
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, _convert_image, screenshot)

output = io.BytesIO()
img.save(output, "png")

return output.getvalue(), hash
@contextlib.asynccontextmanager
async def chrome_lifespan(app: Starlette) -> AsyncIterator[dict[str, ChromiumInstance]]:
async with async_playwright() as playwright:
browser = await playwright.chromium.launch(
channel="chromium",
args=["--disable-lcd-text", "--high-dpi-support=1", "--disable-gpu"],
)
yield {"chromium": ChromiumInstance(browser)}
3 changes: 1 addition & 2 deletions inkplate_dashboard/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from starlette.requests import Request
from starlette.responses import HTMLResponse, PlainTextResponse

from inkplate_dashboard.chrome import screenshot_display
from inkplate_dashboard.display import generate_html


Expand All @@ -30,5 +29,5 @@ async def get(self, request: Request) -> HTMLResponse:

class DisplayPngEndpoint(HTTPEndpoint):
async def get(self, request: Request) -> PlainTextResponse:
image, _hash = await run_in_threadpool(screenshot_display)
image, _hash = await request.state.chromium.screenshot()
return PlainTextResponse(image, media_type="image/png")
Loading

0 comments on commit 9936423

Please sign in to comment.