Add loading events and progress bar (#17)

This commit is contained in:
2025-12-10 16:44:43 +01:00
committed by GitHub
parent 9f64ce8ea0
commit ee21d2a534
6 changed files with 159 additions and 14 deletions

View File

@@ -6,10 +6,11 @@ readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"aiofiles>=25.1.0", "aiofiles>=25.1.0",
"discord-progress-bar>=0.1.2",
"moviepy>=2.2.1", "moviepy>=2.2.1",
"playwright>=1.56.0", "playwright>=1.56.0",
"py-cord==2.7.0rc2", "py-cord>=2.7.0rc2",
"pycord-rest-bot>=0.1.4", "pycord-rest-bot>=0.2.0",
"pydantic>=2.12.5", "pydantic>=2.12.5",
] ]

View File

@@ -33,6 +33,7 @@ app = App(
discord.InteractionContextType.private_channel, discord.InteractionContextType.private_channel,
}, },
default_command_integration_types={discord.IntegrationType.guild_install, discord.IntegrationType.user_install}, default_command_integration_types={discord.IntegrationType.guild_install, discord.IntegrationType.user_install},
cache_app_emojis=True,
) )

View File

@@ -1,14 +1,15 @@
# Copyright (c) Paillat-dev # Copyright (c) Paillat-dev
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
from typing import TYPE_CHECKING, Final from typing import TYPE_CHECKING, Final
import discord import discord
from discord import ui from discord import ui
from discord.ext.commands import BucketType, cooldown from discord.ext.commands import BucketType, cooldown
from discord_progress_bar import ProgressBar, ProgressBarManager
from config import CONFIG from config import CONFIG
from renderer.flag import Flag from renderer.flag import Flag
from renderer.progress import LoadingStep, ProgressReporter
if TYPE_CHECKING: if TYPE_CHECKING:
from pycord_rest import App from pycord_rest import App
@@ -31,17 +32,62 @@ class FlagDisplayView(ui.DesignerView):
super().__init__(container, store=False) super().__init__(container, store=False)
class LoadingProgressView(ui.DesignerView):
def __init__(self, progress_bar: ProgressBar, step: LoadingStep) -> None:
container = ui.Container()
container.add_text(f"## {step.step_name}")
container.add_text(f"{step.description}")
container.add_text(progress_bar.partial(step.progress))
super().__init__(container, store=False)
class DiscordProgressReporter(ProgressReporter):
def __init__(self, ctx: discord.ApplicationContext, progress_bar: ProgressBar) -> None:
self.ctx = ctx
self.progress_bar = progress_bar
async def report_step(self, step: LoadingStep) -> None:
view = LoadingProgressView(self.progress_bar, step)
await self.ctx.edit(view=view)
class FlaggerCommands(discord.Cog): class FlaggerCommands(discord.Cog):
def __init__(self, app: "App", manager: "RendererManager", renderer: "FlagRenderer") -> None: def __init__(self, app: "App", manager: "RendererManager", renderer: "FlagRenderer") -> None:
self.app: App = app self.app: App = app
self.manager: RendererManager = manager self.manager: RendererManager = manager
self.renderer: FlagRenderer = renderer self.renderer: FlagRenderer = renderer
self.progress_manager: ProgressBarManager = ProgressBarManager(app)
self._progress_bar: ProgressBar | None = None
super().__init__() super().__init__()
@discord.Cog.listener(once=True)
async def on_connect(self) -> None:
if self.app._connection.cache_app_emojis and self.app._connection.application_id: # noqa: SLF001
data = await self.app._connection.http.get_all_application_emojis(self.app._connection.application_id) # noqa: SLF001
for e in data.get("items", []): # ty:ignore[unresolved-attribute]
self.app._connection.maybe_store_app_emoji(self.app._connection.application_id, e) # noqa: SLF001
await self.progress_manager.load()
self._progress_bar = await self.progress_manager.progress_bar("green", length=10)
@property
def progress_bar(self) -> ProgressBar:
if self._progress_bar is None:
raise RuntimeError("Progress bar manager not loaded yet.")
return self._progress_bar
async def handle_flag_command(self, ctx: discord.ApplicationContext, image_url: str) -> None: async def handle_flag_command(self, ctx: discord.ApplicationContext, image_url: str) -> None:
async with self.manager.render_context_manager(self.renderer.render, Flag(image_url)) as gif_path: # ty: ignore[invalid-argument-type] initial_view = LoadingProgressView(self.progress_bar, LoadingStep("Queued", "Waiting to start rendering", 0.0))
file = discord.File(gif_path, filename=gif_path.name) # ty:ignore[invalid-argument-type, unresolved-attribute] await ctx.respond(view=initial_view)
await ctx.respond(view=FlagDisplayView(file), files=[file])
reporter = DiscordProgressReporter(ctx, self.progress_bar)
async with self.manager.render_context_manager(
self.renderer.render, # ty: ignore[invalid-argument-type]
Flag(image_url), # ty: ignore[invalid-argument-type]
progress_reporter=reporter, # ty: ignore[invalid-argument-type]
) as gif_path:
file = discord.File(gif_path, filename=gif_path.name) # ty:ignore[unresolved-attribute, invalid-argument-type]
await ctx.edit(view=FlagDisplayView(file), files=[file])
@discord.user_command(name="Create a Flag") @discord.user_command(name="Create a Flag")
@cooldown(**COOLDOWN_ARGS) # ty:ignore[invalid-argument-type] @cooldown(**COOLDOWN_ARGS) # ty:ignore[invalid-argument-type]

View File

@@ -16,6 +16,7 @@ import playwright.async_api
from aiofiles import tempfile from aiofiles import tempfile
from .manager import RendererManager from .manager import RendererManager
from .progress import LoadingStep, ProgressReporter
if TYPE_CHECKING: if TYPE_CHECKING:
from .flag import Flag from .flag import Flag
@@ -72,6 +73,7 @@ class FlagRenderer:
wait_for_selector: str | None = None, wait_for_selector: str | None = None,
duration: float = 6.0, duration: float = 6.0,
exec_page: Callable[[playwright.async_api.Page], Coroutine[None, None, None]] | None = None, exec_page: Callable[[playwright.async_api.Page], Coroutine[None, None, None]] | None = None,
progress_reporter: ProgressReporter | None = None,
) -> Path: ) -> Path:
"""Render the HTML content to a gif and return the gif path. """Render the HTML content to a gif and return the gif path.
@@ -85,6 +87,7 @@ class FlagRenderer:
wait_for_selector: The CSS selector to wait for before rendering.$ wait_for_selector: The CSS selector to wait for before rendering.$
duration: The duration of the video to capture. duration: The duration of the video to capture.
exec_page: A coroutine to execute on the page before rendering. exec_page: A coroutine to execute on the page before rendering.
progress_reporter: An optional progress reporter to report loading steps.
Returns: Returns:
The path to the rendered gif. The path to the rendered gif.
@@ -93,6 +96,11 @@ class FlagRenderer:
if not self.renderer_manager.browser: if not self.renderer_manager.browser:
raise RuntimeError("Browser has not been initialized. Call 'start()' on RendererManager first.") raise RuntimeError("Browser has not been initialized. Call 'start()' on RendererManager first.")
if progress_reporter:
await progress_reporter.report_step(
LoadingStep("Initializing", "Creating browser context for rendering", 0.10)
)
context = await self.renderer_manager.browser.new_context( context = await self.renderer_manager.browser.new_context(
viewport=viewport, # ty:ignore[invalid-argument-type] viewport=viewport, # ty:ignore[invalid-argument-type]
device_scale_factor=device_scale_factor, device_scale_factor=device_scale_factor,
@@ -103,14 +111,25 @@ class FlagRenderer:
try: try:
page = await context.new_page() page = await context.new_page()
try: try:
if progress_reporter:
await progress_reporter.report_step(LoadingStep("Loading", "Loading flagwaver page", 0.25))
encoded_url_params = urllib.parse.urlencode(url_params) encoded_url_params = urllib.parse.urlencode(url_params)
await page.goto(f"{self.flagwaver_url}?{encoded_url_params}", wait_until=wait_until) # ty:ignore[invalid-argument-type] await page.goto(f"{self.flagwaver_url}?{encoded_url_params}", wait_until=wait_until) # ty:ignore[invalid-argument-type]
if wait_for_selector: if wait_for_selector:
await page.wait_for_selector(wait_for_selector, timeout=5000) await page.wait_for_selector(wait_for_selector, timeout=5000)
await page.wait_for_timeout(wait_for) await page.wait_for_timeout(wait_for)
if progress_reporter:
await progress_reporter.report_step(LoadingStep("Setting Up", "Configuring UI controls", 0.40))
if exec_page: if exec_page:
await exec_page(page) await exec_page(page)
if progress_reporter:
await progress_reporter.report_step(LoadingStep("Recording", "Capturing flag animation", 0.60))
load_time = (time.time() - start_time) + LOAD_TIME_BONUS load_time = (time.time() - start_time) + LOAD_TIME_BONUS
logger.debug(f"Page loaded in {load_time:.2f} seconds") logger.debug(f"Page loaded in {load_time:.2f} seconds")
await asyncio.sleep(duration + LOAD_TIME_BONUS) await asyncio.sleep(duration + LOAD_TIME_BONUS)
@@ -118,8 +137,19 @@ class FlagRenderer:
await page.close() await page.close()
finally: finally:
await context.close() await context.close()
if progress_reporter:
await progress_reporter.report_step(
LoadingStep("Processing", "Detecting flag bounds and converting to GIF", 0.75)
)
video_path = await page.video.path() # ty:ignore[possibly-missing-attribute] video_path = await page.video.path() # ty:ignore[possibly-missing-attribute]
return await asyncio.to_thread(self._manipulate_video, Path(video_path), trim_time=load_time) result = await asyncio.to_thread(self._manipulate_video, Path(video_path), trim_time=load_time)
if progress_reporter:
await progress_reporter.report_step(LoadingStep("Complete", "Flag ready!", 1.0))
return result
def _detect_flag_bounds(self, frame: np.ndarray, tolerance: int = 35) -> tuple[int, int, int, int]: def _detect_flag_bounds(self, frame: np.ndarray, tolerance: int = 35) -> tuple[int, int, int, int]:
"""Detect the flag boundaries in a frame by scanning for non-greenscreen pixels. """Detect the flag boundaries in a frame by scanning for non-greenscreen pixels.
@@ -252,11 +282,12 @@ class FlagRenderer:
logger.debug("Page content: %s", await page.content()) logger.debug("Page content: %s", await page.content())
@asynccontextmanager @asynccontextmanager
async def render(self, flag: "Flag") -> AsyncIterator[Path]: async def render(self, flag: "Flag", progress_reporter: ProgressReporter | None = None) -> AsyncIterator[Path]:
async with tempfile.TemporaryDirectory() as temp_dir: async with tempfile.TemporaryDirectory() as temp_dir:
yield await self._render_url_to_video( yield await self._render_url_to_video(
flag.to_url_params(), flag.to_url_params(),
temp_dir=temp_dir, temp_dir=temp_dir,
exec_page=self._setup_ui, exec_page=self._setup_ui,
viewport={"width": 960, "height": 540}, viewport={"width": 960, "height": 540},
progress_reporter=progress_reporter,
) )

27
src/renderer/progress.py Normal file
View File

@@ -0,0 +1,27 @@
# Copyright (c) Paillat-dev
# SPDX-License-Identifier: MIT
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class LoadingStep:
"""Represents a step in the rendering process."""
step_name: str
description: str
progress: float # 0.0 to 1.0
class ProgressReporter(ABC):
"""Abstract base class for reporting rendering progress."""
@abstractmethod
async def report_step(self, step: LoadingStep) -> None:
"""Report a rendering step.
Args:
step: The current step being executed.
"""
...

49
uv.lock generated
View File

@@ -2,6 +2,18 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.13" requires-python = ">=3.13"
[[package]]
name = "aiofile"
version = "3.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "caio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" },
]
[[package]] [[package]]
name = "aiofiles" name = "aiofiles"
version = "25.1.0" version = "25.1.0"
@@ -139,6 +151,17 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
] ]
[[package]]
name = "caio"
version = "0.9.24"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/98/04/ec9b6864135032fd454f6cd1d9444e0bb01040196ad0cd776c061fc92c6b/caio-0.9.24.tar.gz", hash = "sha256:5bcdecaea02a9aa8e3acf0364eff8ad9903d57d70cdb274a42270126290a77f1", size = 27174, upload-time = "2025-04-23T16:31:19.191Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/66/35/06e77837fc5455d330c5502460fc3743989d4ff840b61aa79af3a7ec5b19/caio-0.9.24-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1d47ef8d76aca74c17cb07339a441c5530fc4b8dd9222dfb1e1abd7f9f9b814f", size = 42214, upload-time = "2025-04-23T16:31:12.272Z" },
{ url = "https://files.pythonhosted.org/packages/e0/e2/c16aeaea4b2103e04fdc2e7088ede6313e1971704c87fcd681b58ab1c6b4/caio-0.9.24-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:d15fc746c4bf0077d75df05939d1e97c07ccaa8e580681a77021d6929f65d9f4", size = 81557, upload-time = "2025-04-23T16:31:13.526Z" },
{ url = "https://files.pythonhosted.org/packages/78/3b/adeb0cffe98dbe60661f316ec0060037a5209a5ed8be38ac8e79fdbc856d/caio-0.9.24-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9368eae0a9badd5f31264896c51b47431d96c0d46f1979018fb1d20c49f56156", size = 80242, upload-time = "2025-04-23T16:31:14.365Z" },
]
[[package]] [[package]]
name = "cffi" name = "cffi"
version = "2.0.0" version = "2.0.0"
@@ -214,6 +237,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" },
] ]
[[package]]
name = "discord-progress-bar"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofile" },
{ name = "aiohttp" },
{ name = "py-cord" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f0/3d/1301b2ec1a983602e2260f78e865f42e1a3912e22c393adf23b547e59504/discord_progress_bar-0.1.2.tar.gz", hash = "sha256:e3d11828a63378c98683b9d06863f1d8890749630657bb7be1b7cefeea79b2db", size = 9229, upload-time = "2025-12-10T15:38:10.321Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/b4/38371fc553036c8359f891d7d653460eda504602569e26ac7bf99f952e6c/discord_progress_bar-0.1.2-py3-none-any.whl", hash = "sha256:c4034dbb598063350a9ee10d05c087147592622538b51e0648152190ae3c181b", size = 9103, upload-time = "2025-12-10T15:38:09.116Z" },
]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.124.2" version = "0.124.2"
@@ -235,6 +272,7 @@ version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
{ name = "discord-progress-bar" },
{ name = "moviepy" }, { name = "moviepy" },
{ name = "playwright" }, { name = "playwright" },
{ name = "py-cord" }, { name = "py-cord" },
@@ -253,10 +291,11 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "aiofiles", specifier = ">=25.1.0" }, { name = "aiofiles", specifier = ">=25.1.0" },
{ name = "discord-progress-bar", specifier = ">=0.1.2" },
{ name = "moviepy", specifier = ">=2.2.1" }, { name = "moviepy", specifier = ">=2.2.1" },
{ name = "playwright", specifier = ">=1.56.0" }, { name = "playwright", specifier = ">=1.56.0" },
{ name = "py-cord", specifier = "==2.7.0rc2" }, { name = "py-cord", specifier = ">=2.7.0rc2" },
{ name = "pycord-rest-bot", specifier = ">=0.1.4" }, { name = "pycord-rest-bot", specifier = ">=0.2.0" },
{ name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic", specifier = ">=2.12.5" },
] ]
@@ -776,7 +815,7 @@ wheels = [
[[package]] [[package]]
name = "pycord-rest-bot" name = "pycord-rest-bot"
version = "0.1.4" version = "0.2.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },
@@ -785,9 +824,9 @@ dependencies = [
{ name = "pynacl" }, { name = "pynacl" },
{ name = "uvicorn" }, { name = "uvicorn" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/90/de/e3c06a16f17ed9400cbdd0177e3561281e3443db15ace8710a39da70fcda/pycord_rest_bot-0.1.4.tar.gz", hash = "sha256:3669fcdaba1275b6fdce24c4b1ac3fac2e3f1cef0100254ad9d6a1ace8c9d107", size = 10965, upload-time = "2025-12-08T18:11:51.386Z" } sdist = { url = "https://files.pythonhosted.org/packages/a8/66/c021f12026213f392bf3468e2c6f0fb5c2369af36e182fe6e0a6e76790ba/pycord_rest_bot-0.2.0.tar.gz", hash = "sha256:3a6796f103d910e99a0a2d6f7b718a089ea00dfa8d87eec9b29200c7a0894c23", size = 11012, upload-time = "2025-12-10T14:57:40.439Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/36/0d3f7fb65caf02aa7da28e48b47266918a316ff52048843a24ea66c856b2/pycord_rest_bot-0.1.4-py3-none-any.whl", hash = "sha256:7ae0c997e1d8234d2195da6b5d922cec27aa9170ba184d5acc475b1bade24e8f", size = 10619, upload-time = "2025-12-08T18:11:52.102Z" }, { url = "https://files.pythonhosted.org/packages/f8/a0/f324d057f00e530d6bfeb8c1840beaf7018646fd4c989c6bd3e6d4796def/pycord_rest_bot-0.2.0-py3-none-any.whl", hash = "sha256:ea4f8370333e0568e1148fffbd56684be8c3c56cb5a52723bab618934d084dfd", size = 10667, upload-time = "2025-12-10T14:57:39.668Z" },
] ]
[[package]] [[package]]