Compare commits

...

6 Commits

Author SHA1 Message Date
cal
d83a4bdbb7 Merge pull request 'feat: S3 upload pipeline for APNG animated cards (#198)' (#210) from issue/198-feat-s3-upload-pipeline-for-apng-animated-cards into main 2026-04-08 15:25:40 +00:00
Cal Corum
b29450e7d6 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>
2026-04-08 10:04:21 -05:00
cal
5ff11759f9 Merge pull request 'feat: add REFRACTOR_START_SEASON floor to evaluator queries (#195)' (#209) from issue/195-docs-document-cross-season-stat-accumulation-decis into main 2026-04-08 13:25:48 +00:00
Cal Corum
fd2cc6534a feat: add REFRACTOR_START_SEASON floor to evaluator queries (#195)
Adds REFRACTOR_START_SEASON constant (default 11, overridable via env var)
to db_engine.py and applies it as a season filter in both BattingSeasonStats
and PitchingSeasonStats queries in refractor_evaluator.py, ensuring pre-Season
11 stats are excluded from refractor progress accumulation.

Closes #195

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 08:03:07 -05:00
cal
7701777273 Merge pull request 'feat: include animated_url in tier-up response for T3/T4 (#201)' (#208) from issue/201-feat-include-animated-url-in-tier-up-response-for into main 2026-04-08 10:25:53 +00:00
Cal Corum
4028a24ef9 feat: include animated_url in tier-up response for T3/T4 (#201)
Closes #201

Add animated_url to evaluate-game tier-up entries when new_tier >= 3.
URL is constructed from API_BASE_URL env var + the /animated endpoint
path, using today's date as the cache-bust segment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 04:35:55 -05:00
5 changed files with 144 additions and 7 deletions

View File

@ -44,6 +44,10 @@ else:
pragmas={"journal_mode": "wal", "cache_size": -1 * 64000, "synchronous": 0},
)
# Refractor stat accumulation starts at this season — stats from earlier seasons
# are excluded from evaluation queries. Override via REFRACTOR_START_SEASON env var.
REFRACTOR_START_SEASON = int(os.environ.get("REFRACTOR_START_SEASON", "11"))
# 2025, 2005
ranked_cardsets = [24, 25, 26, 27, 28, 29]
LIVE_CARDSET_ID = 27

View File

@ -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)

View File

@ -1,4 +1,5 @@
import os
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query
import logging
@ -476,9 +477,15 @@ async def evaluate_game(game_id: int, token: str = Depends(oauth2_scheme)):
# Non-breaking addition: include boost info when available.
if boost_result:
tier_up_entry["variant_created"] = boost_result.get(
"variant_created"
)
variant_num = boost_result.get("variant_created")
tier_up_entry["variant_created"] = variant_num
if computed_tier >= 3 and variant_num and card_type:
d = date.today().strftime("%Y-%m-%d")
api_base = os.environ.get("API_BASE_URL", "").rstrip("/")
tier_up_entry["animated_url"] = (
f"{api_base}/api/v2/players/{player_id}/{card_type}card"
f"/{d}/{variant_num}/animated"
)
tier_ups.append(tier_up_entry)

View File

@ -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,
)

View File

@ -148,10 +148,11 @@ def evaluate_card(
strikeouts=sum(r.strikeouts for r in rows),
)
else:
from app.db_engine import (
from app.db_engine import ( # noqa: PLC0415
BattingSeasonStats,
PitchingSeasonStats,
) # noqa: PLC0415
REFRACTOR_START_SEASON,
)
card_type = card_state.track.card_type
if card_type == "batter":
@ -159,6 +160,7 @@ def evaluate_card(
BattingSeasonStats.select().where(
(BattingSeasonStats.player == player_id)
& (BattingSeasonStats.team == team_id)
& (BattingSeasonStats.season >= REFRACTOR_START_SEASON)
)
)
totals = _CareerTotals(
@ -175,6 +177,7 @@ def evaluate_card(
PitchingSeasonStats.select().where(
(PitchingSeasonStats.player == player_id)
& (PitchingSeasonStats.team == team_id)
& (PitchingSeasonStats.season >= REFRACTOR_START_SEASON)
)
)
totals = _CareerTotals(