merge: resolve requirements.txt conflict — include both apng and boto3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
20f7ac5958
@ -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}")
|
||||
|
||||
@ -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
|
||||
|
||||
125
app/services/apng_generator.py
Normal file
125
app/services/apng_generator.py
Normal file
@ -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 <style> tag that sets
|
||||
animation-play-state: running and a negative animation-delay, freezing the
|
||||
render at evenly-spaced intervals across one animation cycle. The captured
|
||||
frames are assembled into a looping APNG at output_path.
|
||||
|
||||
Args:
|
||||
page: An open Playwright page (caller is responsible for lifecycle).
|
||||
html_content: Rendered card HTML string (from TemplateResponse.body).
|
||||
output_path: Destination path for the .apng file.
|
||||
tier: Refractor tier — must be 3 or 4.
|
||||
|
||||
Raises:
|
||||
ValueError: If tier is not 3 or 4.
|
||||
"""
|
||||
spec = ANIM_SPECS.get(tier)
|
||||
if spec is None:
|
||||
raise ValueError(
|
||||
f"No animation spec for tier {tier}; animated cards are T3 and T4 only"
|
||||
)
|
||||
|
||||
num_frames = spec["num_frames"]
|
||||
frame_delay_ms = spec["frame_delay_ms"]
|
||||
selectors_and_durations = spec["selectors_and_durations"]
|
||||
|
||||
frame_paths: list[str] = []
|
||||
try:
|
||||
for i in range(num_frames):
|
||||
progress = i / num_frames # 0.0 .. (N-1)/N, seamless loop
|
||||
await page.set_content(html_content)
|
||||
|
||||
# Inject override CSS: unpauses animation and seeks to frame offset
|
||||
css_parts = []
|
||||
for selector, duration in selectors_and_durations:
|
||||
delay_s = -progress * duration
|
||||
css_parts.append(
|
||||
f"{selector} {{"
|
||||
f" animation-play-state: running !important;"
|
||||
f" animation-delay: {delay_s:.4f}s !important;"
|
||||
f" }}"
|
||||
)
|
||||
await page.add_style_tag(content="\n".join(css_parts))
|
||||
|
||||
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
|
||||
tmp.close()
|
||||
await page.screenshot(
|
||||
path=tmp.name,
|
||||
type="png",
|
||||
clip={"x": 0.0, "y": 0, "width": 1200, "height": 600},
|
||||
)
|
||||
frame_paths.append(tmp.name)
|
||||
|
||||
dir_path = os.path.dirname(output_path)
|
||||
if dir_path:
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
|
||||
apng_obj = APNG()
|
||||
for frame_path in frame_paths:
|
||||
# delay/delay_den is the frame display time in seconds as a fraction
|
||||
apng_obj.append_file(frame_path, delay=frame_delay_ms, delay_den=1000)
|
||||
apng_obj.save(output_path)
|
||||
|
||||
finally:
|
||||
for path in frame_paths:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
@ -12,4 +12,5 @@ requests==2.32.3
|
||||
html2image==2.0.6
|
||||
jinja2==3.1.4
|
||||
playwright==1.45.1
|
||||
apng==3.1.1
|
||||
boto3==1.42.65
|
||||
|
||||
Loading…
Reference in New Issue
Block a user