Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 26 additions & 51 deletions backend/api/cl2k_maker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
),
)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
),
)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
89 changes: 17 additions & 72 deletions backend/modules/cl2k_maker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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 = (
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 19 additions & 1 deletion backend/util/cl2k/geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading