diff --git a/backend/api/cl2k_maker.py b/backend/api/cl2k_maker.py index 80a24f3e..f81ffbe7 100644 --- a/backend/api/cl2k_maker.py +++ b/backend/api/cl2k_maker.py @@ -142,6 +142,9 @@ class GenerateRequest(BaseModel): # fit (sides crop) so a wide backdrop isn't shrunk to a tiny strip; in cover # ("Fill"), <1 shrinks the art below the fill onto black. 1.0 = plain fit/cover. zoom: float = Field(1.0, ge=geo.ZOOM_MIN, le=geo.ZOOM_MAX) + # Mirror: flip the artwork horizontally. Applied at the END of framing, so + # the crop/focal point, the AI mask and the logo/label are unaffected. + mirror: bool = False # Explicit bottom banner (e.g. "COMPLETE LIMITED SERIES"); overrides the auto # COLLECTION / season label when set. band_label: str = "" @@ -661,6 +664,7 @@ def preview( crop=_crop_tuple(req), v_pos=req.v_pos, zoom=req.zoom, + mirror=req.mirror, band_label=req.band_label, logo_scale=req.logo_scale, logo_y_offset=req.logo_y_offset, @@ -723,6 +727,7 @@ def generate( crop=_crop_tuple(req), v_pos=req.v_pos, zoom=req.zoom, + mirror=req.mirror, band_label=req.band_label, logo_scale=req.logo_scale, logo_y_offset=req.logo_y_offset, @@ -779,6 +784,7 @@ class SquareArtRequest(BaseModel): v_pos: float = Field(0.0, ge=geo.V_POS_MIN, le=geo.V_POS_MAX) fit_mode: str = "cover" # cover (focal crop) | fit (contain on black) zoom: float = Field(1.0, ge=geo.ZOOM_MIN, le=geo.ZOOM_MAX) + mirror: bool = False # flip the artwork horizontally save_local: bool = True upload_gdrive: Optional[bool] = None @@ -867,6 +873,7 @@ def square_preview( fit_mode=req.fit_mode, v_pos=req.v_pos, zoom=req.zoom, + mirror=req.mirror, ), ) @@ -902,6 +909,7 @@ def square_generate( fit_mode=req.fit_mode, v_pos=req.v_pos, zoom=req.zoom, + mirror=req.mirror, season_number=req.season_number, save_local=req.save_local, upload_gdrive=req.upload_gdrive, @@ -927,6 +935,7 @@ class BackgroundArtRequest(BaseModel): v_pos: float = Field(0.0, ge=geo.V_POS_MIN, le=geo.V_POS_MAX) fit_mode: str = "cover" # cover (focal crop) | fit (contain on black) zoom: float = Field(1.0, ge=geo.ZOOM_MIN, le=geo.ZOOM_MAX) + mirror: bool = False # flip the artwork horizontally resolution: str = "1080p" # 1080p (1920x1080) | 4k (3840x2160), per Plex dims save_local: bool = True upload_gdrive: Optional[bool] = None @@ -954,6 +963,7 @@ def background_preview( fit_mode=req.fit_mode, v_pos=req.v_pos, zoom=req.zoom, + mirror=req.mirror, ), ) @@ -989,6 +999,7 @@ def background_generate( fit_mode=req.fit_mode, v_pos=req.v_pos, zoom=req.zoom, + mirror=req.mirror, resolution=req.resolution, season_number=req.season_number, save_local=req.save_local, @@ -1176,6 +1187,7 @@ def psd_export( crop=_crop_tuple(req), v_pos=req.v_pos, zoom=req.zoom, + mirror=req.mirror, whiten=req.whiten, flat_white=req.flat_white, logo_3d=req.logo_3d, @@ -1219,6 +1231,7 @@ class SeasonsRequest(BaseModel): crop_h: Optional[float] = None v_pos: float = Field(0.0, ge=geo.V_POS_MIN, le=geo.V_POS_MAX) zoom: float = Field(1.0, ge=geo.ZOOM_MIN, le=geo.ZOOM_MAX) + mirror: bool = False # flip the artwork horizontally logo_scale: float = Field(1.0, ge=geo.LOGO_SCALE_MIN, le=geo.LOGO_SCALE_MAX) logo_y_offset: int = Field(0, ge=geo.LOGO_Y_OFFSET_MIN, le=geo.LOGO_Y_OFFSET_MAX) whiten: Optional[bool] = None # None = module config (whiten_logo) @@ -1374,6 +1387,7 @@ def _progress(entry: Dict[str, Any]) -> None: crop=_crop_tuple(req), v_pos=req.v_pos, zoom=req.zoom, + mirror=req.mirror, 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 4a44bbe6..613e0d82 100644 --- a/backend/modules/cl2k_maker.py +++ b/backend/modules/cl2k_maker.py @@ -202,6 +202,7 @@ def _resolve_and_render( crop: Optional[Tuple[float, float, float, float]] = None, v_pos: float = 0.0, zoom: float = 1.0, + mirror: bool = False, band_label: str = "", logo_scale: float = 1.0, logo_y_offset: int = 0, @@ -392,6 +393,7 @@ def _resolve_and_render( crop=crop, v_pos=v_pos, zoom=zoom, + mirror=mirror, band_label=band_label, place_logo=place_logo, text_logo_stroke=cfg.text_logo_stroke, @@ -435,6 +437,7 @@ def generate_for_item( crop: Optional[Tuple[float, float, float, float]] = None, v_pos: float = 0.0, zoom: float = 1.0, + mirror: bool = False, band_label: str = "", logo_scale: float = 1.0, logo_y_offset: int = 0, @@ -507,6 +510,7 @@ def generate_for_item( crop=crop, v_pos=v_pos, zoom=zoom, + mirror=mirror, band_label=band_label, logo_scale=logo_scale, logo_y_offset=logo_y_offset, @@ -560,6 +564,7 @@ def generate_square_art( fit_mode: str = "cover", v_pos: float = 0.0, zoom: float = 1.0, + mirror: bool = False, season_number: Optional[int] = None, save_local: bool = True, upload_gdrive: Optional[bool] = None, @@ -600,6 +605,7 @@ def generate_square_art( fit_mode=fit_mode, v_pos=v_pos, zoom=zoom, + mirror=mirror, ) return _persist_poster( db, @@ -643,6 +649,7 @@ def generate_background_art( fit_mode: str = "cover", v_pos: float = 0.0, zoom: float = 1.0, + mirror: bool = False, resolution: str = "1080p", season_number: Optional[int] = None, save_local: bool = True, @@ -689,6 +696,7 @@ def generate_background_art( fit_mode=fit_mode, v_pos=v_pos, zoom=zoom, + mirror=mirror, ) return _persist_poster( db, @@ -1444,6 +1452,7 @@ def generate_seasons( crop: Optional[Tuple[float, float, float, float]] = None, v_pos: float = 0.0, zoom: float = 1.0, + mirror: bool = False, logo_scale: float = 1.0, logo_y_offset: int = 0, whiten: Optional[bool] = None, # None = module config (whiten_logo) @@ -1499,6 +1508,7 @@ def generate_seasons( crop=crop, v_pos=v_pos, zoom=zoom, + mirror=mirror, logo_scale=logo_scale, logo_y_offset=logo_y_offset, whiten=whiten, @@ -1552,6 +1562,7 @@ def psd_for_item( crop: Optional[Tuple[float, float, float, float]] = None, v_pos: float = 0.0, zoom: float = 1.0, + mirror: bool = False, 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 @@ -1599,6 +1610,7 @@ def psd_for_item( crop=crop, v_pos=v_pos, zoom=zoom, + mirror=mirror, ) logo_bytes = custom_logo_bytes if logo_bytes is None and logo_path: diff --git a/backend/util/cl2k/renderer.py b/backend/util/cl2k/renderer.py index 2e57c558..374c1bc8 100644 --- a/backend/util/cl2k/renderer.py +++ b/backend/util/cl2k/renderer.py @@ -947,6 +947,7 @@ def render_framed_art( fit_mode: str = "cover", v_pos: float = 0.0, zoom: float = 1.0, + mirror: bool = False, ) -> bytes: """Render plain framed artwork at ``width``×``height`` — no gradient/logo/label. @@ -957,7 +958,7 @@ def render_framed_art( horizontally and ``v_pos`` (-1..1, 0 = centred) vertically, where the image overflows the canvas; plain black letterbox where it doesn't. There is no gradient here to hide an extended band, so ``v_pos`` is source-bounded both - ways. Encoded at CL2K quality. + 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)) with Image(blob=backdrop_bytes) as img: @@ -977,6 +978,8 @@ def render_framed_art( oy = -_v_pos_top(nh, height, v_pos) if nh >= height else (height - nh) // 2 with Image(width=width, height=height, background=Color("black")) as canvas: canvas.composite(img, left=ox, top=oy) + if mirror: + canvas.flop() return _encode_jpeg(canvas) @@ -988,6 +991,7 @@ def render_square_art( fit_mode: str = "cover", v_pos: float = 0.0, zoom: float = 1.0, + mirror: bool = False, ) -> bytes: """Render square (1:1) art from a backdrop/poster — just the framed artwork.""" return render_framed_art( @@ -998,6 +1002,7 @@ def render_square_art( fit_mode=fit_mode, v_pos=v_pos, zoom=zoom, + mirror=mirror, ) @@ -1009,6 +1014,7 @@ def _framed_inset_base( crop: Optional[Tuple[float, float, float, float]], v_pos: float, zoom: float, + mirror: bool, ) -> Image: """Frame the backdrop FULL-BLEED and return a full CANVAS image. @@ -1025,6 +1031,11 @@ 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. """ base = Image( width=geo.CANVAS_W, height=geo.CANVAS_H, background=Color(geo.BORDER_COLOR) @@ -1035,6 +1046,8 @@ def _framed_inset_base( else: _cover_resize(art, geo.CANVAS_W, geo.CANVAS_H, focus_x, v_pos, zoom) base.composite(art, left=0, top=0) + if mirror: + base.flop() return base @@ -1046,6 +1059,7 @@ def frame_backdrop( crop: Optional[Tuple[float, float, float, float]] = None, v_pos: float = 0.0, zoom: float = 1.0, + mirror: bool = False, ) -> bytes: """Frame a backdrop to the 2:3 canvas exactly as :func:`render_cl2k` would and return PNG bytes. @@ -1062,6 +1076,7 @@ def frame_backdrop( crop=crop, v_pos=v_pos, zoom=zoom, + mirror=mirror, ) as base: base.format = "png" return base.make_blob() @@ -1089,6 +1104,7 @@ def render_cl2k( crop: Optional[Tuple[float, float, float, float]] = None, v_pos: float = 0.0, zoom: float = 1.0, + mirror: bool = False, band_label: str = "", place_logo: bool = True, text_logo_stroke: int = 0, @@ -1114,6 +1130,9 @@ def render_cl2k( optionally isolates the subject region first; the black bottom band is the gradient/logo zone. ``v_pos`` applies here too, but on :func:`_fit_resize`'s 0..1 top-anchored scale (0 = top), not cover's -1..1. + + ``mirror`` flips the ARTWORK horizontally; the logo and label are composited + afterwards and stay the right way round. """ kind = kind.lower() baseline = geo.logo_baseline(kind) @@ -1127,6 +1146,7 @@ def render_cl2k( crop=crop, v_pos=v_pos, zoom=zoom, + mirror=mirror, ) as base: with Image(filename=str(geo.GRADIENT_PNG)) as grad: base.composite(grad, left=0, top=0) @@ -1341,6 +1361,9 @@ def main() -> None: ap.add_argument( "--no-whiten", action="store_true", help="keep the logo's original colours" ) + ap.add_argument( + "--mirror", action="store_true", help="flip the artwork horizontally" + ) ap.add_argument("--font", help="font file for text") ap.add_argument("--out", required=True, help="output .jpg path") args = ap.parse_args() @@ -1360,6 +1383,7 @@ def main() -> None: season_text=args.season_text, logo_max_width=args.width, whiten=not args.no_whiten, + mirror=args.mirror, font_path=args.font, ) with open(args.out, "wb") as fh: diff --git a/frontend/src/pages/poster/Cl2kMakerPage.jsx b/frontend/src/pages/poster/Cl2kMakerPage.jsx index 7e2e9a80..9467f033 100644 --- a/frontend/src/pages/poster/Cl2kMakerPage.jsx +++ b/frontend/src/pages/poster/Cl2kMakerPage.jsx @@ -1294,6 +1294,10 @@ const Builder = ({ item, config, uploadStatus, onReset, onItemChange, toast }) = // wide backdrop isn't shrunk to a tiny strip. const [zoom, setZoom] = useState(saved.zoom ?? 1); const [focusX, setFocusX] = useState(saved.focusX ?? 0.5); + // Mirror flips the ARTWORK only, at the end of the backend's framing — the + // crop box, focal point and AI mask all stay in unmirrored source space, so + // nothing here re-maps and the framer keeps showing the source as it is. + const [mirror, setMirror] = useState(saved.mirror ?? false); // Measured by CropFramer's ; owned here because the Vertical // position slider's real range depends on it. null until measured. const [backdropRatio, setBackdropRatio] = useState(null); @@ -1340,6 +1344,7 @@ const Builder = ({ item, config, uploadStatus, onReset, onItemChange, toast }) = setVPos(0); setZoom(1); setFocusX(0.5); + setMirror(false); // which way a subject faces is a property of THAT image setBackdropRatio(null); // re-measured by the new image's onLoad }, []); // fitMode is deliberately NOT reset — it's a per-user way of working @@ -1419,6 +1424,7 @@ const Builder = ({ item, config, uploadStatus, onReset, onItemChange, toast }) = vPos, zoom, focusX, + mirror, logoScale, logoYOffset, whitenLogo, @@ -1441,6 +1447,7 @@ const Builder = ({ item, config, uploadStatus, onReset, onItemChange, toast }) = vPos, zoom, focusX, + mirror, logoScale, logoYOffset, whitenLogo, @@ -1681,6 +1688,7 @@ const Builder = ({ item, config, uploadStatus, onReset, onItemChange, toast }) = // v_pos applies to every mode now (Fill pans up; fit/extend position). v_pos: vPos, zoom: zoom, + mirror, // Banner overrides the auto COLLECTION / SEASON label — e.g. a season // poster drawing COMPLETE LIMITED SERIES in place of SEASON N. band_label: bandLabel, @@ -1710,6 +1718,7 @@ const Builder = ({ item, config, uploadStatus, onReset, onItemChange, toast }) = vPos, zoom, focusX, + mirror, bandLabel, saveTargets.saveLocal, saveTargets.uploadGdrive, @@ -1749,6 +1758,7 @@ const Builder = ({ item, config, uploadStatus, onReset, onItemChange, toast }) = vp: vPos, zm: zoom, fx: focusX, + mi: mirror, bl: bandLabel, pl: !hasLogo, ti: hasLogo ? null : item.title, @@ -1769,6 +1779,7 @@ const Builder = ({ item, config, uploadStatus, onReset, onItemChange, toast }) = vPos, zoom, focusX, + mirror, bandLabel, hasLogo, logoScale, @@ -1918,6 +1929,7 @@ const Builder = ({ item, config, uploadStatus, onReset, onItemChange, toast }) = crop_h: (fitMode === 'fit' || fitMode === 'extend') && crop ? crop.h : null, v_pos: vPos, zoom: zoom, + mirror, logo_scale: logoScale, logo_y_offset: logoYOffset, save_local: saveTargets.saveLocal, @@ -1958,6 +1970,7 @@ const Builder = ({ item, config, uploadStatus, onReset, onItemChange, toast }) = crop, vPos, zoom, + mirror, logoScale, logoYOffset, toast, @@ -2089,6 +2102,8 @@ const Builder = ({ item, config, uploadStatus, onReset, onItemChange, toast }) = setVPos={setVPos} zoom={zoom} setZoom={setZoomClamped} + mirror={mirror} + setMirror={setMirror} vPosLimits={vPosLimits} backdropRatio={backdropRatio} onBackdropRatio={onBackdropRatio} @@ -2259,6 +2274,8 @@ const RenderPanel = ({ setVPos, zoom, setZoom, + mirror, + setMirror, vPosLimits, backdropRatio, onBackdropRatio, @@ -3208,6 +3225,8 @@ const RenderPanel = ({ focusX={focusX} vPos={vPos} zoom={zoom} + mirror={mirror} + setMirror={setMirror} ratio={backdropRatio} onRatio={onBackdropRatio} onChange={onFocusChange} @@ -4270,6 +4289,8 @@ const CropFramer = ({ focusX, vPos, zoom, + mirror, + setMirror, onChange, // Natural aspect ratio (h/w) of the backdrop, owned by the parent: the // Vertical position slider's real travel depends on it, and it must not be @@ -4424,6 +4445,18 @@ const CropFramer = ({ > Extend (AI) + {/* The photo below stays unmirrored on purpose: the crop box and + the AI mask are both in source space. Only the render flips. */} + {/* Span wrapper carries the tooltip: a disabled + {/* The photo below stays unmirrored: the drag picks a region of + the SOURCE, and only the rendered art is flipped. */} +