From 0e02ff2da63c22429f5a95f5c666fc2b8d39f124 Mon Sep 17 00:00:00 2001 From: chodeus <190988615+chodeus@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:47:33 +0800 Subject: [PATCH 1/2] refactor(cl2k): bundle the six framing knobs into one Framing object --- backend/api/cl2k_maker.py | 83 ++++++++++++-------------------- backend/modules/cl2k_maker.py | 89 +++++++---------------------------- backend/util/cl2k/geometry.py | 27 ++++++++++- backend/util/cl2k/renderer.py | 87 +++++++++------------------------- tests/test_cl2k_maker.py | 18 ++++--- tests/test_cl2k_mirror.py | 50 ++++++++++++-------- tests/test_cl2k_renderer.py | 8 +++- 7 files changed, 144 insertions(+), 218 deletions(-) diff --git a/backend/api/cl2k_maker.py b/backend/api/cl2k_maker.py index f81ffbe7..156ee91a 100644 --- a/backend/api/cl2k_maker.py +++ b/backend/api/cl2k_maker.py @@ -268,14 +268,29 @@ 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. - - 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 +def _framing(req: Any) -> geo.Framing: + """Build the renderer's framing bundle from a request's flat wire fields. + + The wire format stays flat because the frontend posts scalars; this is the + one place that shape becomes the object every layer below passes on untouched. + + ``crop_*`` exists only on the poster models (GenerateRequest, SeasonsRequest) + — square/background art has no crop stage — and all four parts must be present + for a crop to apply, so a partial crop fits the whole backdrop instead.""" + 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 +674,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 +732,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 +874,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 +906,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 +956,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 +988,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 +1171,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 +1366,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..a27680e0 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,30 @@ 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. + + These travel together and are only meaningful together: the identical set + crosses api -> module -> renderer untouched. Bundled because they were + threaded as six separate parameters through four layers, so each new knob + cost a signature edit at ~29 sites; add the seventh here instead. + + The API wire format stays FLAT (the frontend posts focus_x/zoom/... as + scalars) — the request models keep their own fields and 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..32f2a1ba 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 @@ -1041,25 +1024,22 @@ def _framed_inset_base( 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 +1049,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 +1071,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 +1086,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 +1106,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 +1342,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)) From 33da34cbbd009f54b709179628296d867fa9d70d Mon Sep 17 00:00:00 2001 From: chodeus <190988615+chodeus@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:36:59 +0800 Subject: [PATCH 2/2] docs(cl2k): trim the framing docstrings to the comment cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit, against the repo's own path instructions: these carried implementation history and rationale rather than a one-or-two-line what/gotcha. The "why we bundled it" argument belongs in the PR body, where it already is. Kept the gotchas that stop someone breaking things — the wire format stays flat, a partial crop is ignored, and mirror lands at the end of framing because the AI mask is built in source space. Dropped the essays around them. Also trims the same violation in _framed_inset_base, which I wrote in #579 and which the diff-scoped review therefore never saw. --- backend/api/cl2k_maker.py | 10 +++------- backend/util/cl2k/geometry.py | 11 ++--------- backend/util/cl2k/renderer.py | 6 ++---- 3 files changed, 7 insertions(+), 20 deletions(-) diff --git a/backend/api/cl2k_maker.py b/backend/api/cl2k_maker.py index 156ee91a..db41e89d 100644 --- a/backend/api/cl2k_maker.py +++ b/backend/api/cl2k_maker.py @@ -269,14 +269,10 @@ def _save_response(logger: Any, *, done: str, what: str, run) -> JSONResponse: def _framing(req: Any) -> geo.Framing: - """Build the renderer's framing bundle from a request's flat wire fields. + """Flat request fields -> the renderer's framing bundle. - The wire format stays flat because the frontend posts scalars; this is the - one place that shape becomes the object every layer below passes on untouched. - - ``crop_*`` exists only on the poster models (GenerateRequest, SeasonsRequest) - — square/background art has no crop stage — and all four parts must be present - for a crop to apply, so a partial crop fits the whole backdrop instead.""" + ``crop_*`` is poster-models-only, and a PARTIAL crop is ignored (whole backdrop). + """ parts = ( getattr(req, "crop_x", None), getattr(req, "crop_y", None), diff --git a/backend/util/cl2k/geometry.py b/backend/util/cl2k/geometry.py index a27680e0..9e202a86 100644 --- a/backend/util/cl2k/geometry.py +++ b/backend/util/cl2k/geometry.py @@ -130,16 +130,9 @@ def auto_logo_size( @dataclass(frozen=True) class Framing: - """How a backdrop is fitted to the canvas — one bundle for every art path. + """How a backdrop is fitted to the canvas; one bundle for every art path. - These travel together and are only meaningful together: the identical set - crosses api -> module -> renderer untouched. Bundled because they were - threaded as six separate parameters through four layers, so each new knob - cost a signature edit at ~29 sites; add the seventh here instead. - - The API wire format stays FLAT (the frontend posts focus_x/zoom/... as - scalars) — the request models keep their own fields and build one of these - at the endpoint boundary. + 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 diff --git a/backend/util/cl2k/renderer.py b/backend/util/cl2k/renderer.py index 32f2a1ba..64c2ac3e 100644 --- a/backend/util/cl2k/renderer.py +++ b/backend/util/cl2k/renderer.py @@ -1015,10 +1015,8 @@ def _framed_inset_base(backdrop_bytes: bytes, framing: geo.Framing) -> Image: 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)