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 <noreply@anthropic.com>
This commit is contained in:
parent
5ff11759f9
commit
b29450e7d6
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user