diff --git a/app/routers_v2/players.py b/app/routers_v2/players.py index 6579aae..34fefe1 100644 --- a/app/routers_v2/players.py +++ b/app/routers_v2/players.py @@ -42,6 +42,7 @@ from ..db_helpers import upsert_players from ..dependencies import oauth2_scheme, valid_token from ..services.card_storage import backfill_variant_image_url from ..services.refractor_boost import compute_variant_hash +from ..services.apng_generator import apng_cache_path, generate_animated_card # --------------------------------------------------------------------------- # Persistent browser instance (WP-02) @@ -736,6 +737,132 @@ async def get_one_player(player_id: int, csv: Optional[bool] = False): 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/{d}") @router.get("/{player_id}/{card_type}card/{d}/{variant}") diff --git a/app/routers_v2/refractor.py b/app/routers_v2/refractor.py index d680a32..cb5d06a 100644 --- a/app/routers_v2/refractor.py +++ b/app/routers_v2/refractor.py @@ -229,7 +229,7 @@ async def list_card_states( if evaluated_only: query = query.where(RefractorCardState.last_evaluated_at.is_null(False)) - total = query.count() + total = query.count() or 0 items = [] for state in query.offset(offset).limit(limit): player_name = None diff --git a/app/services/apng_generator.py b/app/services/apng_generator.py new file mode 100644 index 0000000..2bc7237 --- /dev/null +++ b/app/services/apng_generator.py @@ -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