From b29450e7d64198ed2b129dfd02f833335762cf83 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 8 Apr 2026 10:04:21 -0500 Subject: [PATCH] feat: S3 upload pipeline for APNG animated cards (#198) Extends card_storage.py with build_apng_s3_key, upload_apng_to_s3, and upload_variant_apng to handle animated card uploads. Wires get_animated_card to trigger a background S3 upload on each new render (cache miss, non-preview). Closes #198 Co-Authored-By: Claude Sonnet 4.6 --- app/routers_v2/players.py | 13 +++- app/services/card_storage.py | 114 ++++++++++++++++++++++++++++++++++- 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/app/routers_v2/players.py b/app/routers_v2/players.py index 0175bab..faa9eaa 100644 --- a/app/routers_v2/players.py +++ b/app/routers_v2/players.py @@ -40,7 +40,7 @@ from ..db_engine import ( ) from ..db_helpers import upsert_players from ..dependencies import oauth2_scheme, valid_token -from ..services.card_storage import backfill_variant_image_url +from ..services.card_storage import backfill_variant_image_url, upload_variant_apng from ..services.refractor_boost import compute_variant_hash from ..services.apng_generator import apng_cache_path, generate_animated_card @@ -740,6 +740,7 @@ async def get_one_player(player_id: int, csv: Optional[bool] = False): @router.get("/{player_id}/{card_type}card/{d}/{variant}/animated") async def get_animated_card( request: Request, + background_tasks: BackgroundTasks, player_id: int, card_type: Literal["batting", "pitching"], variant: int, @@ -860,6 +861,16 @@ async def get_animated_card( finally: await page.close() + if tier is None: + background_tasks.add_task( + upload_variant_apng, + player_id=player_id, + variant=variant, + card_type=card_type, + cardset_id=this_player.cardset.id, + apng_path=cache_path, + ) + return FileResponse(path=cache_path, media_type="image/apng", headers=headers) diff --git a/app/services/card_storage.py b/app/services/card_storage.py index 1d68962..fd74f09 100644 --- a/app/services/card_storage.py +++ b/app/services/card_storage.py @@ -8,7 +8,10 @@ get_s3_client() (environment variables or instance profile). build_s3_key(cardset_id, player_id, variant, card_type) - Construct the S3 object key for a variant card image. + Construct the S3 object key for a variant card PNG image. + +build_apng_s3_key(cardset_id, player_id, variant, card_type) + Construct the S3 object key for a variant animated card APNG. build_s3_url(s3_key, render_date) Return the full HTTPS S3 URL with a cache-busting date query param. @@ -16,11 +19,19 @@ build_s3_url(s3_key, render_date) upload_card_to_s3(s3_client, png_bytes, s3_key) Upload raw PNG bytes to S3 with correct ContentType and CacheControl headers. +upload_apng_to_s3(s3_client, apng_bytes, s3_key) + Upload raw APNG bytes to S3 with correct ContentType and CacheControl headers. + backfill_variant_image_url(player_id, variant, card_type, cardset_id, png_path) End-to-end: read PNG from disk, upload to S3, update BattingCard or PitchingCard.image_url in the database. All exceptions are caught and logged; this function never raises (safe to call as a background task). +upload_variant_apng(player_id, variant, card_type, cardset_id, apng_path) + End-to-end: read APNG from disk and upload to S3. No DB update (no + animated_url column exists yet). All exceptions are caught and logged; + this function never raises (safe to call as a background task). + Design notes ------------ - S3 credentials are resolved from the environment by boto3 at call time; @@ -97,6 +108,29 @@ def build_s3_url(s3_key: str, render_date: date) -> str: return f"{base_url}/{s3_key}?d={date_str}" +def build_apng_s3_key( + cardset_id: int, player_id: int, variant: int, card_type: str +) -> str: + """Construct the S3 object key for a variant animated card APNG. + + Key format: + cards/cardset-{csid:03d}/player-{pid}/v{variant}/{card_type}card.apng + + Args: + cardset_id: Numeric cardset ID (zero-padded to 3 digits). + player_id: Player ID. + variant: Variant number (1-4 = refractor tiers). + card_type: Either "batting" or "pitching". + + Returns: + The S3 object key string. + """ + return ( + f"cards/cardset-{cardset_id:03d}/player-{player_id}" + f"/v{variant}/{card_type}card.apng" + ) + + def upload_card_to_s3(s3_client, png_bytes: bytes, s3_key: str) -> None: """Upload raw PNG bytes to S3 with the standard card image headers. @@ -196,3 +230,81 @@ def backfill_variant_image_url( variant, card_type, ) + + +def upload_apng_to_s3(s3_client, apng_bytes: bytes, s3_key: str) -> None: + """Upload raw APNG bytes to S3 with the standard animated card headers. + + Sets ContentType=image/apng and CacheControl=public, max-age=86400 (1 day) + matching the animated endpoint's own Cache-Control header. + + Args: + s3_client: A boto3 S3 client (from get_s3_client). + apng_bytes: Raw APNG image bytes. + s3_key: S3 object key (from build_apng_s3_key). + + Returns: + None + """ + s3_client.put_object( + Bucket=S3_BUCKET, + Key=s3_key, + Body=apng_bytes, + ContentType="image/apng", + CacheControl="public, max-age=86400", + ) + + +def upload_variant_apng( + player_id: int, + variant: int, + card_type: str, + cardset_id: int, + apng_path: str, +) -> None: + """Read a rendered APNG from disk and upload it to S3. + + Intended to be called as a background task after a new animated card is + rendered. No DB update is performed (no animated_url column exists yet). + + All exceptions are caught and logged — this function is intended to be + called as a background task and must never propagate exceptions. + + Args: + player_id: Player ID used for the S3 key. + variant: Variant number (matches the refractor tier variant). + card_type: "batting" or "pitching" — selects the S3 key. + cardset_id: Cardset ID used for the S3 key. + apng_path: Absolute path to the rendered APNG file on disk. + + Returns: + None + """ + try: + with open(apng_path, "rb") as f: + apng_bytes = f.read() + + s3_key = build_apng_s3_key( + cardset_id=cardset_id, + player_id=player_id, + variant=variant, + card_type=card_type, + ) + s3_client = get_s3_client() + upload_apng_to_s3(s3_client, apng_bytes, s3_key) + + logger.info( + "upload_variant_apng: uploaded %s animated card player=%s variant=%s key=%s", + card_type, + player_id, + variant, + s3_key, + ) + + except Exception: + logger.exception( + "upload_variant_apng: failed for player=%s variant=%s card_type=%s", + player_id, + variant, + card_type, + )