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 ..db_helpers import upsert_players
|
||||||
from ..dependencies import oauth2_scheme, valid_token
|
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.refractor_boost import compute_variant_hash
|
||||||
from ..services.apng_generator import apng_cache_path, generate_animated_card
|
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")
|
@router.get("/{player_id}/{card_type}card/{d}/{variant}/animated")
|
||||||
async def get_animated_card(
|
async def get_animated_card(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
background_tasks: BackgroundTasks,
|
||||||
player_id: int,
|
player_id: int,
|
||||||
card_type: Literal["batting", "pitching"],
|
card_type: Literal["batting", "pitching"],
|
||||||
variant: int,
|
variant: int,
|
||||||
@ -860,6 +861,16 @@ async def get_animated_card(
|
|||||||
finally:
|
finally:
|
||||||
await page.close()
|
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)
|
return FileResponse(path=cache_path, media_type="image/apng", headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,10 @@ get_s3_client()
|
|||||||
(environment variables or instance profile).
|
(environment variables or instance profile).
|
||||||
|
|
||||||
build_s3_key(cardset_id, player_id, variant, card_type)
|
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)
|
build_s3_url(s3_key, render_date)
|
||||||
Return the full HTTPS S3 URL with a cache-busting date query param.
|
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_card_to_s3(s3_client, png_bytes, s3_key)
|
||||||
Upload raw PNG bytes to S3 with correct ContentType and CacheControl headers.
|
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)
|
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
|
End-to-end: read PNG from disk, upload to S3, update BattingCard or
|
||||||
PitchingCard.image_url in the database. All exceptions are caught and
|
PitchingCard.image_url in the database. All exceptions are caught and
|
||||||
logged; this function never raises (safe to call as a background task).
|
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
|
Design notes
|
||||||
------------
|
------------
|
||||||
- S3 credentials are resolved from the environment by boto3 at call time;
|
- 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}"
|
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:
|
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.
|
"""Upload raw PNG bytes to S3 with the standard card image headers.
|
||||||
|
|
||||||
@ -196,3 +230,81 @@ def backfill_variant_image_url(
|
|||||||
variant,
|
variant,
|
||||||
card_type,
|
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