diff --git a/backend/api/cl2k_maker.py b/backend/api/cl2k_maker.py index f81ffbe7..db41e89d 100644 --- a/backend/api/cl2k_maker.py +++ b/backend/api/cl2k_maker.py @@ -268,14 +268,25 @@ def _save_response(logger: Any, *, done: str, what: str, run) -> JSONResponse: ) -def _crop_tuple(req: Any): - """Assemble the (x, y, w, h) fit crop from a request, or None if unset. +def _framing(req: Any) -> geo.Framing: + """Flat request fields -> the renderer's framing bundle. - Works for any request carrying ``crop_x/y/w/h`` (GenerateRequest, SeasonsRequest). - Only used in ``fit`` mode; all four fields must be present for a crop to apply - (a partial crop is ignored so the whole backdrop is fitted).""" - parts = (req.crop_x, req.crop_y, req.crop_w, req.crop_h) - return tuple(parts) if all(p is not None for p in parts) else None + ``crop_*`` is poster-models-only, and a PARTIAL crop is ignored (whole backdrop). + """ + parts = ( + getattr(req, "crop_x", None), + getattr(req, "crop_y", None), + getattr(req, "crop_w", None), + getattr(req, "crop_h", None), + ) + return geo.Framing( + focus_x=req.focus_x, + fit_mode=req.fit_mode, + v_pos=req.v_pos, + zoom=req.zoom, + mirror=req.mirror, + crop=tuple(parts) if all(p is not None for p in parts) else None, + ) class LogoFetchError(Exception): @@ -659,12 +670,7 @@ def preview( imdb_id=req.imdb_id, mask_bytes=mask_bytes, apply_ai=req.remove_text, - focus_x=req.focus_x, - fit_mode=req.fit_mode, - crop=_crop_tuple(req), - v_pos=req.v_pos, - zoom=req.zoom, - mirror=req.mirror, + framing=_framing(req), band_label=req.band_label, logo_scale=req.logo_scale, logo_y_offset=req.logo_y_offset, @@ -722,12 +728,7 @@ def generate( custom_logo_bytes=_b64_to_bytes(req.logo_b64), mask_bytes=mask_bytes, apply_ai=req.remove_text, - focus_x=req.focus_x, - fit_mode=req.fit_mode, - crop=_crop_tuple(req), - v_pos=req.v_pos, - zoom=req.zoom, - mirror=req.mirror, + framing=_framing(req), band_label=req.band_label, logo_scale=req.logo_scale, logo_y_offset=req.logo_y_offset, @@ -869,11 +870,7 @@ def square_preview( req, lambda raw: render_square_art( backdrop_bytes=raw, - focus_x=req.focus_x, - fit_mode=req.fit_mode, - v_pos=req.v_pos, - zoom=req.zoom, - mirror=req.mirror, + framing=_framing(req), ), ) @@ -905,11 +902,7 @@ def square_generate( imdb_id=req.imdb_id, backdrop_path=req.backdrop_path, backdrop_bytes=_b64_to_bytes(req.backdrop_b64), - focus_x=req.focus_x, - fit_mode=req.fit_mode, - v_pos=req.v_pos, - zoom=req.zoom, - mirror=req.mirror, + framing=_framing(req), season_number=req.season_number, save_local=req.save_local, upload_gdrive=req.upload_gdrive, @@ -959,11 +952,7 @@ def background_preview( backdrop_bytes=raw, width=1920, height=1080, - focus_x=req.focus_x, - fit_mode=req.fit_mode, - v_pos=req.v_pos, - zoom=req.zoom, - mirror=req.mirror, + framing=_framing(req), ), ) @@ -995,11 +984,7 @@ def background_generate( imdb_id=req.imdb_id, backdrop_path=req.backdrop_path, backdrop_bytes=_b64_to_bytes(req.backdrop_b64), - focus_x=req.focus_x, - fit_mode=req.fit_mode, - v_pos=req.v_pos, - zoom=req.zoom, - mirror=req.mirror, + framing=_framing(req), resolution=req.resolution, season_number=req.season_number, save_local=req.save_local, @@ -1182,12 +1167,7 @@ def psd_export( logo_y_offset=req.logo_y_offset, logo_flip_bytes=_b64_to_bytes(req.logo_flip_b64), logo_erase_bytes=_b64_to_bytes(req.logo_erase_b64), - focus_x=req.focus_x, - fit_mode=req.fit_mode, - crop=_crop_tuple(req), - v_pos=req.v_pos, - zoom=req.zoom, - mirror=req.mirror, + framing=_framing(req), whiten=req.whiten, flat_white=req.flat_white, logo_3d=req.logo_3d, @@ -1382,12 +1362,7 @@ def _progress(entry: Dict[str, Any]) -> None: backdrop_bytes=backdrop_bytes, logo_path=req.logo_path, custom_logo_bytes=logo_bytes, - fit_mode=req.fit_mode, - focus_x=req.focus_x, - crop=_crop_tuple(req), - v_pos=req.v_pos, - zoom=req.zoom, - mirror=req.mirror, + framing=_framing(req), logo_scale=req.logo_scale, logo_y_offset=req.logo_y_offset, whiten=req.whiten, diff --git a/backend/modules/cl2k_maker.py b/backend/modules/cl2k_maker.py index 613e0d82..2f06e811 100644 --- a/backend/modules/cl2k_maker.py +++ b/backend/modules/cl2k_maker.py @@ -3,6 +3,7 @@ import os import shutil import tempfile +from dataclasses import replace from typing import Any, Callable, Dict, List, Optional, Tuple from backend.util.cl2k import color @@ -197,12 +198,7 @@ def _resolve_and_render( mask_bytes: Optional[bytes] = None, backdrop_bytes: Optional[bytes] = None, apply_ai: bool = False, - focus_x: float = 0.5, - fit_mode: str = "cover", - crop: Optional[Tuple[float, float, float, float]] = None, - v_pos: float = 0.0, - zoom: float = 1.0, - mirror: bool = False, + framing: geo.Framing = geo.Framing(), band_label: str = "", logo_scale: float = 1.0, logo_y_offset: int = 0, @@ -265,9 +261,9 @@ def _resolve_and_render( # extend). The fill happens here, before the gradient/logo, so the AI only sees # the backdrop; the resulting canvas is already 1000×1500, so it renders as a # straight cover (identity). - if fit_mode == "extend": + if framing.fit_mode == "extend": canvas_bytes, extend_mask = renderer.fit_extend_canvas( - backdrop_bytes, crop, zoom=zoom, v_pos=v_pos + backdrop_bytes, framing.crop, zoom=framing.zoom, v_pos=framing.v_pos ) # AI runs only on a real generate (allow_ai_extend) AND when the provider # is fully configured — unavailable_reason(), not is_enabled(): a provider @@ -291,7 +287,7 @@ def _resolve_and_render( # CANVAS_W x CANVAS_H with zoom/v_pos baked in by fit_extend_canvas. # Re-applying them re-scales the finished image and, for v_pos > 0, # crops rows off the top and fades a black band over the fill. - fit_mode, crop, zoom, v_pos = "cover", None, 1.0, 0.0 + framing = replace(framing, fit_mode="cover", crop=None, zoom=1.0, v_pos=0.0) else: if extend_mask is not None and logger: reason = ( @@ -305,7 +301,7 @@ def _resolve_and_render( logger.info( f"cl2k: extend — {reason}; using the free edge-extend fit instead" ) - fit_mode = "fit" + framing = replace(framing, fit_mode="fit") # Only run AI removal when explicitly requested (a brushed mask, or the # apply_ai flag for OpenAI's maskless mode) — never on every auto-render. @@ -388,12 +384,7 @@ def _resolve_and_render( flat_white=flat_white, logo_3d=logo_3d, invert=invert, - focus_x=focus_x, - fit_mode=fit_mode, - crop=crop, - v_pos=v_pos, - zoom=zoom, - mirror=mirror, + framing=framing, band_label=band_label, place_logo=place_logo, text_logo_stroke=cfg.text_logo_stroke, @@ -432,12 +423,7 @@ def generate_for_item( mask_bytes: Optional[bytes] = None, backdrop_bytes: Optional[bytes] = None, apply_ai: bool = False, - focus_x: float = 0.5, - fit_mode: str = "cover", - crop: Optional[Tuple[float, float, float, float]] = None, - v_pos: float = 0.0, - zoom: float = 1.0, - mirror: bool = False, + framing: geo.Framing = geo.Framing(), band_label: str = "", logo_scale: float = 1.0, logo_y_offset: int = 0, @@ -505,12 +491,7 @@ def generate_for_item( mask_bytes=mask_bytes, backdrop_bytes=backdrop_bytes, apply_ai=apply_ai, - focus_x=focus_x, - fit_mode=fit_mode, - crop=crop, - v_pos=v_pos, - zoom=zoom, - mirror=mirror, + framing=framing, band_label=band_label, logo_scale=logo_scale, logo_y_offset=logo_y_offset, @@ -560,11 +541,7 @@ def generate_square_art( imdb_id: Optional[str] = None, backdrop_path: Optional[str] = None, backdrop_bytes: Optional[bytes] = None, - focus_x: float = 0.5, - fit_mode: str = "cover", - v_pos: float = 0.0, - zoom: float = 1.0, - mirror: bool = False, + framing: geo.Framing = geo.Framing(), season_number: Optional[int] = None, save_local: bool = True, upload_gdrive: Optional[bool] = None, @@ -601,11 +578,7 @@ def generate_square_art( backdrop_bytes = image_fetch.download(backdrop_path) blob = renderer.render_square_art( backdrop_bytes=backdrop_bytes, - focus_x=focus_x, - fit_mode=fit_mode, - v_pos=v_pos, - zoom=zoom, - mirror=mirror, + framing=framing, ) return _persist_poster( db, @@ -645,11 +618,7 @@ def generate_background_art( imdb_id: Optional[str] = None, backdrop_path: Optional[str] = None, backdrop_bytes: Optional[bytes] = None, - focus_x: float = 0.5, - fit_mode: str = "cover", - v_pos: float = 0.0, - zoom: float = 1.0, - mirror: bool = False, + framing: geo.Framing = geo.Framing(), resolution: str = "1080p", season_number: Optional[int] = None, save_local: bool = True, @@ -692,11 +661,7 @@ def generate_background_art( backdrop_bytes=backdrop_bytes, width=width, height=height, - focus_x=focus_x, - fit_mode=fit_mode, - v_pos=v_pos, - zoom=zoom, - mirror=mirror, + framing=framing, ) return _persist_poster( db, @@ -1447,12 +1412,7 @@ def generate_seasons( year: Optional[int] = None, tvdb_id: Optional[int] = None, imdb_id: Optional[str] = None, - fit_mode: str = "cover", - focus_x: float = 0.5, - crop: Optional[Tuple[float, float, float, float]] = None, - v_pos: float = 0.0, - zoom: float = 1.0, - mirror: bool = False, + framing: geo.Framing = geo.Framing(), logo_scale: float = 1.0, logo_y_offset: int = 0, whiten: Optional[bool] = None, # None = module config (whiten_logo) @@ -1503,12 +1463,7 @@ def generate_seasons( backdrop_bytes=backdrop_bytes, logo_path=logo_path, custom_logo_bytes=custom_logo_bytes, - fit_mode=fit_mode, - focus_x=focus_x, - crop=crop, - v_pos=v_pos, - zoom=zoom, - mirror=mirror, + framing=framing, logo_scale=logo_scale, logo_y_offset=logo_y_offset, whiten=whiten, @@ -1557,12 +1512,7 @@ def psd_for_item( logo_y_offset: int = 0, logo_flip_bytes: Optional[bytes] = None, # B/W touch-up regions (mask PNG) logo_erase_bytes: Optional[bytes] = None, # erase regions (mask PNG, white=erase) - focus_x: float = 0.5, - fit_mode: str = "cover", - crop: Optional[Tuple[float, float, float, float]] = None, - v_pos: float = 0.0, - zoom: float = 1.0, - mirror: bool = False, + framing: geo.Framing = geo.Framing(), whiten: Optional[bool] = None, # None = module config (whiten_logo) flat_white: bool = False, # paint the logo a flat pure-white silhouette logo_3d: bool = False, # extruded art -> flat-white lit face; wins over flat_white @@ -1605,12 +1555,7 @@ def psd_for_item( backdrop_bytes = image_fetch.download(backdrop_path) framed = renderer.frame_backdrop( backdrop_bytes=backdrop_bytes, - focus_x=focus_x, - fit_mode=fit_mode, - crop=crop, - v_pos=v_pos, - zoom=zoom, - mirror=mirror, + framing=framing, ) logo_bytes = custom_logo_bytes if logo_bytes is None and logo_path: diff --git a/backend/util/cl2k/geometry.py b/backend/util/cl2k/geometry.py index ba368513..9e202a86 100644 --- a/backend/util/cl2k/geometry.py +++ b/backend/util/cl2k/geometry.py @@ -13,8 +13,9 @@ from __future__ import annotations +from dataclasses import dataclass from pathlib import Path -from typing import Optional +from typing import Optional, Tuple # ----- canvas ---------------------------------------------------------------- CANVAS_W = 1000 @@ -126,6 +127,23 @@ def auto_logo_size( # source only, positive pans down and may edge-extend into the gradient zone. V_POS_MIN, V_POS_MAX = -1.0, 1.0 + +@dataclass(frozen=True) +class Framing: + """How a backdrop is fitted to the canvas; one bundle for every art path. + + The API wire format stays FLAT — build one of these at the endpoint boundary. + """ + + focus_x: float = 0.5 # 0..1 horizontal focal point; 0.5 = centre + fit_mode: str = "cover" # cover (crop to fill) | fit (contain) | extend (AI) + v_pos: float = 0.0 # -1..1, 0 = centred (fit/extend read it 0..1 top-anchored) + zoom: float = 1.0 # scale relative to the cover/fit baseline + mirror: bool = False # flip the artwork horizontally, at the end of framing + # fit/extend only: isolates the subject region before the fit. render_framed_art + # (square + background art) has no crop stage and ignores this. + crop: Optional[Tuple[float, float, float, float]] = None + # ----- logo whitening (CL2K two-tone) ----------------------------------------- # Real CL2K logos are black & white, not flat white silhouettes: coloured/bright # fills go pure white while the artwork's dark keylines and interior accents stay diff --git a/backend/util/cl2k/renderer.py b/backend/util/cl2k/renderer.py index 374c1bc8..64c2ac3e 100644 --- a/backend/util/cl2k/renderer.py +++ b/backend/util/cl2k/renderer.py @@ -943,14 +943,12 @@ def render_framed_art( backdrop_bytes: bytes, width: int, height: int, - focus_x: float = 0.5, - fit_mode: str = "cover", - v_pos: float = 0.0, - zoom: float = 1.0, - mirror: bool = False, + framing: geo.Framing = geo.Framing(), ) -> bytes: """Render plain framed artwork at ``width``×``height`` — no gradient/logo/label. + Framing knobs below are :class:`geometry.Framing` fields. + ``fit_mode`` ``"cover"`` fills the canvas (cropping the overflowing edges); ``"fit"`` contains the whole image on black (letterbox). ``zoom`` (0.5–3.0) scales from that baseline — raise it in ``fit`` to punch in from contain toward @@ -960,7 +958,9 @@ def render_framed_art( gradient here to hide an extended band, so ``v_pos`` is source-bounded both ways. ``mirror`` flips the finished frame horizontally. Encoded at CL2K quality. """ - zoom = max(geo.ZOOM_MIN, min(float(zoom or 1.0), geo.ZOOM_MAX)) + focus_x, fit_mode = framing.focus_x, framing.fit_mode + v_pos, mirror = framing.v_pos, framing.mirror + zoom = max(geo.ZOOM_MIN, min(float(framing.zoom or 1.0), geo.ZOOM_MAX)) with Image(blob=backdrop_bytes) as img: base = ( min(width / img.width, height / img.height) @@ -987,35 +987,18 @@ def render_square_art( *, backdrop_bytes: bytes, size: int = 1000, - focus_x: float = 0.5, - fit_mode: str = "cover", - v_pos: float = 0.0, - zoom: float = 1.0, - mirror: bool = False, + framing: geo.Framing = geo.Framing(), ) -> bytes: """Render square (1:1) art from a backdrop/poster — just the framed artwork.""" return render_framed_art( backdrop_bytes=backdrop_bytes, width=size, height=size, - focus_x=focus_x, - fit_mode=fit_mode, - v_pos=v_pos, - zoom=zoom, - mirror=mirror, + framing=framing, ) -def _framed_inset_base( - backdrop_bytes: bytes, - *, - focus_x: float, - fit_mode: str, - crop: Optional[Tuple[float, float, float, float]], - v_pos: float, - zoom: float, - mirror: bool, -) -> Image: +def _framed_inset_base(backdrop_bytes: bytes, framing: geo.Framing) -> Image: """Frame the backdrop FULL-BLEED and return a full CANVAS image. The template's stroke is Style=Inside on a full-canvas layer, so it paints @@ -1032,34 +1015,29 @@ def _framed_inset_base( render_cl2k and frame_backdrop both go through here, so they stay pixel-identical (the PSD POSTER-layer parity the exporter relies on). - ``mirror`` flips the framed artwork horizontally. It lands HERE, at the end - of framing, rather than on the source bytes: the AI text-removal mask and the - extend outpaint (modules.cl2k_maker) are brushed/built in source space, and - the logo/label the caller composites next must never come out backwards. + ``mirror`` flips the artwork HERE, at the end of framing — never on the source + bytes: the AI mask and extend outpaint are built in source space. """ base = Image( width=geo.CANVAS_W, height=geo.CANVAS_H, background=Color(geo.BORDER_COLOR) ) with Image(blob=backdrop_bytes) as art: - if fit_mode == "fit": - _fit_resize(art, geo.CANVAS_W, geo.CANVAS_H, crop, v_pos, zoom) + if framing.fit_mode == "fit": + _fit_resize( + art, geo.CANVAS_W, geo.CANVAS_H, framing.crop, framing.v_pos, framing.zoom + ) else: - _cover_resize(art, geo.CANVAS_W, geo.CANVAS_H, focus_x, v_pos, zoom) + _cover_resize( + art, geo.CANVAS_W, geo.CANVAS_H, framing.focus_x, framing.v_pos, framing.zoom + ) base.composite(art, left=0, top=0) - if mirror: + if framing.mirror: base.flop() return base def frame_backdrop( - *, - backdrop_bytes: bytes, - focus_x: float = 0.5, - fit_mode: str = "cover", - crop: Optional[Tuple[float, float, float, float]] = None, - v_pos: float = 0.0, - zoom: float = 1.0, - mirror: bool = False, + *, backdrop_bytes: bytes, framing: geo.Framing = geo.Framing() ) -> bytes: """Frame a backdrop to the 2:3 canvas exactly as :func:`render_cl2k` would and return PNG bytes. @@ -1069,15 +1047,7 @@ def frame_backdrop( (edge-extend fills, seam blending) live only in this module and must not be re-implemented elsewhere. """ - with _framed_inset_base( - backdrop_bytes, - focus_x=focus_x, - fit_mode=fit_mode, - crop=crop, - v_pos=v_pos, - zoom=zoom, - mirror=mirror, - ) as base: + with _framed_inset_base(backdrop_bytes, framing) as base: base.format = "png" return base.make_blob() @@ -1099,12 +1069,7 @@ def render_cl2k( logo_3d: bool = False, # extruded art -> flat-white lit face invert: bool = False, # plate logo -> clearlogo (white->transparent, black->white) font_path: Optional[str] = None, - focus_x: float = 0.5, - fit_mode: str = "cover", - crop: Optional[Tuple[float, float, float, float]] = None, - v_pos: float = 0.0, - zoom: float = 1.0, - mirror: bool = False, + framing: geo.Framing = geo.Framing(), band_label: str = "", place_logo: bool = True, text_logo_stroke: int = 0, @@ -1119,7 +1084,7 @@ def render_cl2k( ``COMPLETE LIMITED SERIES`` or ``SPECIALS``), overriding the automatic COLLECTION / season label. Long strings use the tighter PSD tracking. - ``fit_mode`` controls how the backdrop fills the 2:3 canvas: + ``framing`` controls how the backdrop fills the 2:3 canvas: - ``"cover"`` (default): scale up and crop to fill; ``focus_x`` (0..1) and ``v_pos`` (-1..1) choose which part is kept (0.5/0 = centre). Best when the @@ -1139,15 +1104,7 @@ def render_cl2k( label_font = font_path or geo.resolve_font(bold=False) title_font = font_path or geo.resolve_font(bold=True) - with _framed_inset_base( - backdrop_bytes, - focus_x=focus_x, - fit_mode=fit_mode, - crop=crop, - v_pos=v_pos, - zoom=zoom, - mirror=mirror, - ) as base: + with _framed_inset_base(backdrop_bytes, framing) as base: with Image(filename=str(geo.GRADIENT_PNG)) as grad: base.composite(grad, left=0, top=0) @@ -1383,7 +1340,7 @@ def main() -> None: season_text=args.season_text, logo_max_width=args.width, whiten=not args.no_whiten, - mirror=args.mirror, + framing=geo.Framing(mirror=args.mirror), font_path=args.font, ) with open(args.out, "wb") as fh: diff --git a/tests/test_cl2k_maker.py b/tests/test_cl2k_maker.py index 53051328..1a3b6395 100644 --- a/tests/test_cl2k_maker.py +++ b/tests/test_cl2k_maker.py @@ -17,6 +17,7 @@ from wand.image import Image # noqa: E402 import backend.modules.cl2k_maker as maker # noqa: E402 +from backend.util.cl2k import geometry as geo # noqa: E402 def _logo_png(width, height): @@ -162,12 +163,15 @@ def test_a_successful_ai_extend_is_not_reframed_again(env, monkeypatch): tmdb_id=221230, backdrop_bytes=_backdrop_png(), logo_path="/iMuXNXkPq9OCKw5jljoIxkv8IRV.png", - fit_mode="extend", - v_pos=0.4, - zoom=1.5, + framing=geo.Framing(fit_mode="extend", v_pos=0.4, zoom=1.5, mirror=True), ) - assert seen["fit_mode"] == "cover" - assert seen["crop"] is None - assert seen["v_pos"] == 0.0, "v_pos was already baked into the extended canvas" - assert seen["zoom"] == 1.0, "zoom was already baked into the extended canvas" + framing = seen["framing"] + assert framing.fit_mode == "cover" + assert framing.crop is None + assert framing.v_pos == 0.0, "v_pos was already baked into the extended canvas" + assert framing.zoom == 1.0, "zoom was already baked into the extended canvas" + # The reset is scoped to what fit_extend_canvas baked in. mirror is applied + # later, at the end of framing, so clearing it here would silently un-flip + # every extended poster. + assert framing.mirror is True diff --git a/tests/test_cl2k_mirror.py b/tests/test_cl2k_mirror.py index 183f546e..97aca248 100644 --- a/tests/test_cl2k_mirror.py +++ b/tests/test_cl2k_mirror.py @@ -14,6 +14,7 @@ import base64 import types +from dataclasses import replace import pytest @@ -66,6 +67,12 @@ def _strip(blob, top, height): return img.make_blob("RGB") +# What every hop must receive: mirror on, the rest defaulted. Comparing the whole +# bundle (not just .mirror) is the point of the refactor — a layer that rebuilds +# the object field by field and drops one now fails here. +_FRAMING = geo.Framing(mirror=True) + + def _logger(): return types.SimpleNamespace( info=lambda *a, **k: None, @@ -85,21 +92,25 @@ def test_framed_art_mirror_swaps_the_sides(size): width, height = size kw = dict(backdrop_bytes=_two_tone(), width=width, height=height) plain_l, plain_r = _sides(render_framed_art(**kw)) - mirror_l, mirror_r = _sides(render_framed_art(**kw, mirror=True)) + mirror_l, mirror_r = _sides(render_framed_art(**kw, framing=geo.Framing(mirror=True))) assert plain_l.red > 0.9 and plain_r.blue > 0.9 assert mirror_l.blue > 0.9 and mirror_r.red > 0.9 def test_square_art_forwards_mirror(): - left, right = _sides(render_square_art(backdrop_bytes=_two_tone(), mirror=True)) + left, right = _sides( + render_square_art(backdrop_bytes=_two_tone(), framing=geo.Framing(mirror=True)) + ) assert left.blue > 0.9 and right.red > 0.9 def test_frame_backdrop_mirrors_the_poster_frame(): # The .psd POSTER layer is this function's output, so an unmirrored frame here # would export a document that disagrees with the poster beside it. - left, right = _sides(frame_backdrop(backdrop_bytes=_two_tone(), mirror=True)) + left, right = _sides( + frame_backdrop(backdrop_bytes=_two_tone(), framing=geo.Framing(mirror=True)) + ) assert left.blue > 0.9 and right.red > 0.9 @@ -108,14 +119,12 @@ def test_mirror_composes_with_the_framing_it_does_not_replace_it(fit_mode): # Off-centre framing, so a flip applied to the SOURCE instead of the framed # result would land the crop window on the other side and fail this. # frame_backdrop returns PNG, so the comparison is lossless and exact. - kw = dict( - backdrop_bytes=_two_tone(), - fit_mode=fit_mode, - focus_x=0.2, - v_pos=0.3, - zoom=1.6, + off = geo.Framing(fit_mode=fit_mode, focus_x=0.2, v_pos=0.3, zoom=1.6) + on = replace(off, mirror=True) + art = _two_tone() + assert _sig(frame_backdrop(backdrop_bytes=art, framing=on)) == _sig( + frame_backdrop(backdrop_bytes=art, framing=off), flop=True ) - assert _sig(frame_backdrop(**kw, mirror=True)) == _sig(frame_backdrop(**kw), flop=True) def test_render_cl2k_mirrors_the_artwork_and_never_the_label(): @@ -126,7 +135,8 @@ def test_render_cl2k_mirrors_the_artwork_and_never_the_label(): title="Mirror Test", season_text="Season one", ) - plain, mirrored = render_cl2k(**kw), render_cl2k(**kw, mirror=True) + plain = render_cl2k(**kw) + mirrored = render_cl2k(**kw, framing=geo.Framing(mirror=True)) left, right = _sides(mirrored) assert left.blue > 0.9 and right.red > 0.9 # artwork flipped @@ -175,7 +185,7 @@ def test_an_endpoint_forwards_mirror_to_its_maker(route, target, model, tasks, m # Split, because these endpoints answer a raising stub with a clean 4xx — # "never called" would otherwise read as "called without mirror". assert seen, f"{route} never reached {target}" - assert seen.get("mirror") is True, f"{route} dropped mirror on the way to {target}" + assert seen["framing"] == _FRAMING, f"{route} mangled the framing reaching {target}" @pytest.mark.parametrize("route,model", [("square_preview", api.SquareArtRequest), @@ -189,7 +199,7 @@ def test_an_art_preview_forwards_mirror_to_the_renderer(route, model, monkeypatc getattr(api, route)(req, db=object(), logger=_logger()) - assert seen.get("mirror") is True + assert seen["framing"] == _FRAMING def test_the_seasons_job_carries_mirror_into_every_season(monkeypatch): @@ -202,7 +212,7 @@ def test_the_seasons_job_carries_mirror_into_every_season(monkeypatch): api._run_seasons_job(api._new_season_job(1, "T"), object(), _logger(), req) - assert seen.get("mirror") is True + assert seen["framing"] == _FRAMING @pytest.mark.parametrize( @@ -228,10 +238,10 @@ def test_an_asset_maker_forwards_mirror_to_the_renderer(maker_fn, renderer_fn, m title="T", tmdb_id=7, backdrop_bytes=b"ART", - mirror=True, + framing=_FRAMING, ) - assert seen.get("mirror") is True + assert seen["framing"] == _FRAMING def test_resolve_and_render_forwards_mirror_to_the_poster_renderer(monkeypatch): @@ -257,10 +267,10 @@ def test_resolve_and_render_forwards_mirror_to_the_poster_renderer(monkeypatch): tmdb_id=7, backdrop_bytes=b"ART", custom_logo_bytes=b"LOGO", - mirror=True, + framing=_FRAMING, ) - assert seen.get("mirror") is True + assert seen["framing"] == _FRAMING def test_generate_seasons_carries_mirror_into_each_poster(monkeypatch): @@ -276,10 +286,10 @@ def test_generate_seasons_carries_mirror_into_each_poster(monkeypatch): title="T", tmdb_id=7, seasons=[1], - mirror=True, + framing=_FRAMING, ) - assert seen.get("mirror") is True + assert seen["framing"] == _FRAMING @pytest.mark.parametrize( diff --git a/tests/test_cl2k_renderer.py b/tests/test_cl2k_renderer.py index d4fe1b3a..73a45925 100644 --- a/tests/test_cl2k_renderer.py +++ b/tests/test_cl2k_renderer.py @@ -321,7 +321,10 @@ def test_render_framed_art_dims_and_format(): def test_render_framed_art_fit_letterboxes_on_black(): # A square source contained in a 16:9 frame leaves black pillarboxes. blob = render_framed_art( - backdrop_bytes=_backdrop(w=500, h=500), width=1920, height=1080, fit_mode="fit" + backdrop_bytes=_backdrop(w=500, h=500), + width=1920, + height=1080, + framing=geo.Framing(fit_mode="fit"), ) with Image(blob=blob) as img: bar = img[10, 540] # left pillarbox @@ -374,7 +377,8 @@ def test_psd_poster_layer_matches_renderer_framing(): from backend.util.cl2k.psd_export import export_psd framed = frame_backdrop( - backdrop_bytes=_backdrop(), fit_mode="fit", v_pos=0.2, zoom=1.4 + backdrop_bytes=_backdrop(), + framing=geo.Framing(fit_mode="fit", v_pos=0.2, zoom=1.4), ) blob = export_psd(backdrop_bytes=framed, kind="movie", title="X") psd = PSDImage.open(io.BytesIO(blob))