Merge pull request 'feat: APNG animated card effects for T3/T4 refractor tiers (#186)' (#188) from issue/186-feat-apng-animated-card-effects-for-t3-t4-refracto into main
Reviewed-on: #188
This commit is contained in:
commit
67d1f10455
@ -33,6 +33,7 @@ from ..db_engine import (
|
|||||||
from ..db_helpers import upsert_players
|
from ..db_helpers import upsert_players
|
||||||
from ..dependencies import oauth2_scheme, valid_token
|
from ..dependencies import oauth2_scheme, valid_token
|
||||||
from ..services.refractor_boost import compute_variant_hash
|
from ..services.refractor_boost import compute_variant_hash
|
||||||
|
from ..services.apng_generator import apng_cache_path, generate_animated_card
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Persistent browser instance (WP-02)
|
# Persistent browser instance (WP-02)
|
||||||
@ -727,6 +728,132 @@ async def get_one_player(player_id: int, csv: Optional[bool] = False):
|
|||||||
return return_val
|
return return_val
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{player_id}/{card_type}card/{d}/{variant}/animated")
|
||||||
|
async def get_animated_card(
|
||||||
|
request: Request,
|
||||||
|
player_id: int,
|
||||||
|
card_type: Literal["batting", "pitching"],
|
||||||
|
variant: int,
|
||||||
|
d: str,
|
||||||
|
tier: Optional[int] = Query(
|
||||||
|
None, ge=0, le=4, description="Override refractor tier for preview (dev only)"
|
||||||
|
),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
this_player = Player.get_by_id(player_id)
|
||||||
|
except DoesNotExist:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404, detail=f"No player found with id {player_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
refractor_tier = (
|
||||||
|
tier if tier is not None else resolve_refractor_tier(player_id, variant)
|
||||||
|
)
|
||||||
|
if refractor_tier < 3:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"No animation for tier {refractor_tier}; animated cards require T3 or T4",
|
||||||
|
)
|
||||||
|
|
||||||
|
cache_path = apng_cache_path(
|
||||||
|
this_player.cardset.id, card_type, player_id, d, variant
|
||||||
|
)
|
||||||
|
headers = {"Cache-Control": "public, max-age=86400"}
|
||||||
|
|
||||||
|
if os.path.isfile(cache_path) and tier is None:
|
||||||
|
return FileResponse(path=cache_path, media_type="image/png", headers=headers)
|
||||||
|
|
||||||
|
all_pos = (
|
||||||
|
CardPosition.select()
|
||||||
|
.where(CardPosition.player == this_player)
|
||||||
|
.order_by(CardPosition.innings.desc())
|
||||||
|
)
|
||||||
|
|
||||||
|
if card_type == "batting":
|
||||||
|
this_bc = BattingCard.get_or_none(
|
||||||
|
BattingCard.player == this_player, BattingCard.variant == variant
|
||||||
|
)
|
||||||
|
if this_bc is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Batting card not found for id {player_id}, variant {variant}",
|
||||||
|
)
|
||||||
|
rating_vl = BattingCardRatings.get_or_none(
|
||||||
|
BattingCardRatings.battingcard == this_bc, BattingCardRatings.vs_hand == "L"
|
||||||
|
)
|
||||||
|
rating_vr = BattingCardRatings.get_or_none(
|
||||||
|
BattingCardRatings.battingcard == this_bc, BattingCardRatings.vs_hand == "R"
|
||||||
|
)
|
||||||
|
if None in [rating_vr, rating_vl]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Ratings not found for batting card {this_bc.id}",
|
||||||
|
)
|
||||||
|
card_data = get_batter_card_data(
|
||||||
|
this_player, this_bc, rating_vl, rating_vr, all_pos
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
this_player.description in this_player.cardset.name
|
||||||
|
and this_player.cardset.id not in [23]
|
||||||
|
):
|
||||||
|
card_data["cardset_name"] = this_player.cardset.name
|
||||||
|
else:
|
||||||
|
card_data["cardset_name"] = this_player.description
|
||||||
|
card_data["refractor_tier"] = refractor_tier
|
||||||
|
card_data["request"] = request
|
||||||
|
html_response = templates.TemplateResponse("player_card.html", card_data)
|
||||||
|
|
||||||
|
else:
|
||||||
|
this_pc = PitchingCard.get_or_none(
|
||||||
|
PitchingCard.player == this_player, PitchingCard.variant == variant
|
||||||
|
)
|
||||||
|
if this_pc is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Pitching card not found for id {player_id}, variant {variant}",
|
||||||
|
)
|
||||||
|
rating_vl = PitchingCardRatings.get_or_none(
|
||||||
|
PitchingCardRatings.pitchingcard == this_pc,
|
||||||
|
PitchingCardRatings.vs_hand == "L",
|
||||||
|
)
|
||||||
|
rating_vr = PitchingCardRatings.get_or_none(
|
||||||
|
PitchingCardRatings.pitchingcard == this_pc,
|
||||||
|
PitchingCardRatings.vs_hand == "R",
|
||||||
|
)
|
||||||
|
if None in [rating_vr, rating_vl]:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Ratings not found for pitching card {this_pc.id}",
|
||||||
|
)
|
||||||
|
card_data = get_pitcher_card_data(
|
||||||
|
this_player, this_pc, rating_vl, rating_vr, all_pos
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
this_player.description in this_player.cardset.name
|
||||||
|
and this_player.cardset.id not in [23]
|
||||||
|
):
|
||||||
|
card_data["cardset_name"] = this_player.cardset.name
|
||||||
|
else:
|
||||||
|
card_data["cardset_name"] = this_player.description
|
||||||
|
card_data["refractor_tier"] = refractor_tier
|
||||||
|
card_data["request"] = request
|
||||||
|
html_response = templates.TemplateResponse("player_card.html", card_data)
|
||||||
|
|
||||||
|
browser = await get_browser()
|
||||||
|
page = await browser.new_page(viewport={"width": 1280, "height": 720})
|
||||||
|
try:
|
||||||
|
await generate_animated_card(
|
||||||
|
page,
|
||||||
|
html_response.body.decode("UTF-8"),
|
||||||
|
cache_path,
|
||||||
|
refractor_tier,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await page.close()
|
||||||
|
|
||||||
|
return FileResponse(path=cache_path, media_type="image/png", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{player_id}/{card_type}card")
|
@router.get("/{player_id}/{card_type}card")
|
||||||
@router.get("/{player_id}/{card_type}card/{d}")
|
@router.get("/{player_id}/{card_type}card/{d}")
|
||||||
@router.get("/{player_id}/{card_type}card/{d}/{variant}")
|
@router.get("/{player_id}/{card_type}card/{d}/{variant}")
|
||||||
|
|||||||
125
app/services/apng_generator.py
Normal file
125
app/services/apng_generator.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
@ -12,3 +12,4 @@ requests==2.32.3
|
|||||||
html2image==2.0.6
|
html2image==2.0.6
|
||||||
jinja2==3.1.4
|
jinja2==3.1.4
|
||||||
playwright==1.45.1
|
playwright==1.45.1
|
||||||
|
apng==3.1.1
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user