Closes #186 - Add app/services/apng_generator.py: scrubs CSS animations via Playwright (negative animation-delay + running override), assembles frames with apng lib - Add GET /{player_id}/{card_type}card/{d}/{variant}/animated endpoint: returns cached .apng for T3/T4 cards, 404 for T0-T2 - T3: 12 frames × 200ms (gold shimmer, 2.5s cycle) - T4: 24 frames × 250ms (prismatic sweep + diamond glow, 6s cycle) - Cache path: storage/cards/cardset-{id}/{type}/{player_id}-{d}-v{variant}.apng - Add apng==3.1.1 to requirements.txt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
126 lines
4.2 KiB
Python
126 lines
4.2 KiB
Python
"""
|
|
APNG animated card generation for T3 and T4 refractor tiers.
|
|
|
|
Captures animation frames by scrubbing CSS animations via Playwright — each
|
|
frame is rendered with a negative animation-delay that freezes the render at a
|
|
specific point in the animation cycle. The captured PNGs are then assembled
|
|
into a looping APNG using the apng library.
|
|
|
|
Cache / S3 path convention:
|
|
Local: storage/cards/cardset-{id}/{card_type}/{player_id}-{date}-v{variant}.apng
|
|
S3: cards/cardset-{id}/{card_type}/{player_id}-{date}-v{variant}.apng
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
|
|
from apng import APNG
|
|
from playwright.async_api import Page
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Animation specs per tier
|
|
# Each entry: list of (css_selector, animation_duration_seconds) pairs that
|
|
# need to be scrubbed, plus the frame count and per-frame display time.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_T3_SPEC = {
|
|
"selectors_and_durations": [("#header::after", 2.5)],
|
|
"num_frames": 12,
|
|
"frame_delay_ms": 200,
|
|
}
|
|
|
|
_T4_SPEC = {
|
|
"selectors_and_durations": [
|
|
("#header::after", 6.0),
|
|
(".tier-diamond.diamond-glow", 2.0),
|
|
],
|
|
"num_frames": 24,
|
|
"frame_delay_ms": 250,
|
|
}
|
|
|
|
ANIM_SPECS = {3: _T3_SPEC, 4: _T4_SPEC}
|
|
|
|
|
|
def apng_cache_path(
|
|
cardset_id: int, card_type: str, player_id: int, d: str, variant: int
|
|
) -> str:
|
|
"""Return the local filesystem cache path for an animated card APNG."""
|
|
return f"storage/cards/cardset-{cardset_id}/{card_type}/{player_id}-{d}-v{variant}.apng"
|
|
|
|
|
|
async def generate_animated_card(
|
|
page: Page,
|
|
html_content: str,
|
|
output_path: str,
|
|
tier: int,
|
|
) -> None:
|
|
"""Generate an animated APNG for a T3 or T4 refractor card.
|
|
|
|
Scrubs each CSS animation by injecting an override <style> tag that sets
|
|
animation-play-state: running and a negative animation-delay, freezing the
|
|
render at evenly-spaced intervals across one animation cycle. The captured
|
|
frames are assembled into a looping APNG at output_path.
|
|
|
|
Args:
|
|
page: An open Playwright page (caller is responsible for lifecycle).
|
|
html_content: Rendered card HTML string (from TemplateResponse.body).
|
|
output_path: Destination path for the .apng file.
|
|
tier: Refractor tier — must be 3 or 4.
|
|
|
|
Raises:
|
|
ValueError: If tier is not 3 or 4.
|
|
"""
|
|
spec = ANIM_SPECS.get(tier)
|
|
if spec is None:
|
|
raise ValueError(
|
|
f"No animation spec for tier {tier}; animated cards are T3 and T4 only"
|
|
)
|
|
|
|
num_frames = spec["num_frames"]
|
|
frame_delay_ms = spec["frame_delay_ms"]
|
|
selectors_and_durations = spec["selectors_and_durations"]
|
|
|
|
frame_paths: list[str] = []
|
|
try:
|
|
for i in range(num_frames):
|
|
progress = i / num_frames # 0.0 .. (N-1)/N, seamless loop
|
|
await page.set_content(html_content)
|
|
|
|
# Inject override CSS: unpauses animation and seeks to frame offset
|
|
css_parts = []
|
|
for selector, duration in selectors_and_durations:
|
|
delay_s = -progress * duration
|
|
css_parts.append(
|
|
f"{selector} {{"
|
|
f" animation-play-state: running !important;"
|
|
f" animation-delay: {delay_s:.4f}s !important;"
|
|
f" }}"
|
|
)
|
|
await page.add_style_tag(content="\n".join(css_parts))
|
|
|
|
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
|
|
tmp.close()
|
|
await page.screenshot(
|
|
path=tmp.name,
|
|
type="png",
|
|
clip={"x": 0.0, "y": 0, "width": 1200, "height": 600},
|
|
)
|
|
frame_paths.append(tmp.name)
|
|
|
|
dir_path = os.path.dirname(output_path)
|
|
if dir_path:
|
|
os.makedirs(dir_path, exist_ok=True)
|
|
|
|
apng_obj = APNG()
|
|
for frame_path in frame_paths:
|
|
# delay/delay_den is the frame display time in seconds as a fraction
|
|
apng_obj.append_file(frame_path, delay=frame_delay_ms, delay_den=1000)
|
|
apng_obj.save(output_path)
|
|
|
|
finally:
|
|
for path in frame_paths:
|
|
try:
|
|
os.unlink(path)
|
|
except OSError:
|
|
pass
|