mirror of
https://github.com/Paillat-dev/flagger.git
synced 2026-01-02 01:06:21 +00:00
✨ Add loading events and progress bar (#17)
This commit is contained in:
@@ -6,10 +6,11 @@ readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"aiofiles>=25.1.0",
|
||||
"discord-progress-bar>=0.1.2",
|
||||
"moviepy>=2.2.1",
|
||||
"playwright>=1.56.0",
|
||||
"py-cord==2.7.0rc2",
|
||||
"pycord-rest-bot>=0.1.4",
|
||||
"py-cord>=2.7.0rc2",
|
||||
"pycord-rest-bot>=0.2.0",
|
||||
"pydantic>=2.12.5",
|
||||
]
|
||||
|
||||
@@ -68,4 +69,4 @@ extend-ignore = [
|
||||
pydocstyle.convention = "google"
|
||||
|
||||
[tool.ty.rules]
|
||||
unused-ignore-comment = "warn"
|
||||
unused-ignore-comment = "warn"
|
||||
|
||||
@@ -33,6 +33,7 @@ app = App(
|
||||
discord.InteractionContextType.private_channel,
|
||||
},
|
||||
default_command_integration_types={discord.IntegrationType.guild_install, discord.IntegrationType.user_install},
|
||||
cache_app_emojis=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
# Copyright (c) Paillat-dev
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
import discord
|
||||
from discord import ui
|
||||
from discord.ext.commands import BucketType, cooldown
|
||||
from discord_progress_bar import ProgressBar, ProgressBarManager
|
||||
|
||||
from config import CONFIG
|
||||
from renderer.flag import Flag
|
||||
from renderer.progress import LoadingStep, ProgressReporter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pycord_rest import App
|
||||
@@ -31,17 +32,62 @@ class FlagDisplayView(ui.DesignerView):
|
||||
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):
|
||||
def __init__(self, app: "App", manager: "RendererManager", renderer: "FlagRenderer") -> None:
|
||||
self.app: App = app
|
||||
self.manager: RendererManager = manager
|
||||
self.renderer: FlagRenderer = renderer
|
||||
self.progress_manager: ProgressBarManager = ProgressBarManager(app)
|
||||
self._progress_bar: ProgressBar | None = None
|
||||
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 with self.manager.render_context_manager(self.renderer.render, Flag(image_url)) as gif_path: # ty: ignore[invalid-argument-type]
|
||||
file = discord.File(gif_path, filename=gif_path.name) # ty:ignore[invalid-argument-type, unresolved-attribute]
|
||||
await ctx.respond(view=FlagDisplayView(file), files=[file])
|
||||
initial_view = LoadingProgressView(self.progress_bar, LoadingStep("Queued", "Waiting to start rendering", 0.0))
|
||||
await ctx.respond(view=initial_view)
|
||||
|
||||
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")
|
||||
@cooldown(**COOLDOWN_ARGS) # ty:ignore[invalid-argument-type]
|
||||
|
||||
@@ -16,6 +16,7 @@ import playwright.async_api
|
||||
from aiofiles import tempfile
|
||||
|
||||
from .manager import RendererManager
|
||||
from .progress import LoadingStep, ProgressReporter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .flag import Flag
|
||||
@@ -72,6 +73,7 @@ class FlagRenderer:
|
||||
wait_for_selector: str | None = None,
|
||||
duration: float = 6.0,
|
||||
exec_page: Callable[[playwright.async_api.Page], Coroutine[None, None, None]] | None = None,
|
||||
progress_reporter: ProgressReporter | None = None,
|
||||
) -> 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.$
|
||||
duration: The duration of the video to capture.
|
||||
exec_page: A coroutine to execute on the page before rendering.
|
||||
progress_reporter: An optional progress reporter to report loading steps.
|
||||
|
||||
Returns:
|
||||
The path to the rendered gif.
|
||||
@@ -93,6 +96,11 @@ class FlagRenderer:
|
||||
if not self.renderer_manager.browser:
|
||||
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(
|
||||
viewport=viewport, # ty:ignore[invalid-argument-type]
|
||||
device_scale_factor=device_scale_factor,
|
||||
@@ -103,14 +111,25 @@ class FlagRenderer:
|
||||
try:
|
||||
page = await context.new_page()
|
||||
try:
|
||||
if progress_reporter:
|
||||
await progress_reporter.report_step(LoadingStep("Loading", "Loading flagwaver page", 0.25))
|
||||
|
||||
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]
|
||||
if wait_for_selector:
|
||||
await page.wait_for_selector(wait_for_selector, timeout=5000)
|
||||
|
||||
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:
|
||||
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
|
||||
logger.debug(f"Page loaded in {load_time:.2f} seconds")
|
||||
await asyncio.sleep(duration + LOAD_TIME_BONUS)
|
||||
@@ -118,8 +137,19 @@ class FlagRenderer:
|
||||
await page.close()
|
||||
finally:
|
||||
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]
|
||||
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]:
|
||||
"""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())
|
||||
|
||||
@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:
|
||||
yield await self._render_url_to_video(
|
||||
flag.to_url_params(),
|
||||
temp_dir=temp_dir,
|
||||
exec_page=self._setup_ui,
|
||||
viewport={"width": 960, "height": 540},
|
||||
progress_reporter=progress_reporter,
|
||||
)
|
||||
|
||||
27
src/renderer/progress.py
Normal file
27
src/renderer/progress.py
Normal 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
49
uv.lock
generated
@@ -2,6 +2,18 @@ version = 1
|
||||
revision = 3
|
||||
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]]
|
||||
name = "aiofiles"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "cffi"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "fastapi"
|
||||
version = "0.124.2"
|
||||
@@ -235,6 +272,7 @@ version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
{ name = "discord-progress-bar" },
|
||||
{ name = "moviepy" },
|
||||
{ name = "playwright" },
|
||||
{ name = "py-cord" },
|
||||
@@ -253,10 +291,11 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "aiofiles", specifier = ">=25.1.0" },
|
||||
{ name = "discord-progress-bar", specifier = ">=0.1.2" },
|
||||
{ name = "moviepy", specifier = ">=2.2.1" },
|
||||
{ name = "playwright", specifier = ">=1.56.0" },
|
||||
{ name = "py-cord", specifier = "==2.7.0rc2" },
|
||||
{ name = "pycord-rest-bot", specifier = ">=0.1.4" },
|
||||
{ name = "py-cord", specifier = ">=2.7.0rc2" },
|
||||
{ name = "pycord-rest-bot", specifier = ">=0.2.0" },
|
||||
{ name = "pydantic", specifier = ">=2.12.5" },
|
||||
]
|
||||
|
||||
@@ -776,7 +815,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pycord-rest-bot"
|
||||
version = "0.1.4"
|
||||
version = "0.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
@@ -785,9 +824,9 @@ dependencies = [
|
||||
{ name = "pynacl" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
|
||||
Reference in New Issue
Block a user