From 0b88ca251f47fb39dac301fd74a858717e71a546 Mon Sep 17 00:00:00 2001 From: chodeus <190988615+chodeus@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:10:01 +0800 Subject: [PATCH] feat(cl2k): mirror the artwork horizontally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Mirror toggle on the poster, square-art and background-art makers flips the artwork left-to-right — for backdrops whose subject faces out of frame. The flip lands at the END of framing, not on the source bytes, and that placement is the whole design. The AI text-removal mask and the extend outpaint are built in source space, so flipping the source first would put every brush stroke on the wrong side of the picture; the logo and label are composited after the artwork, so flipping the finished poster would render the season band backwards. _framed_inset_base and render_framed_art are the two points where framing is finished and nothing else is drawn yet, so both flop there. Applying it last also means no coordinate is re-mapped: the framer keeps showing the source as it is, and toggling Mirror leaves the crop box and focal point exactly where they were. The .psd export follows the poster (both go through frame_backdrop) and the season batch carries the flag, so a bulk run matches its preview. --- backend/api/cl2k_maker.py | 14 + backend/modules/cl2k_maker.py | 12 + backend/util/cl2k/renderer.py | 26 +- frontend/src/pages/poster/Cl2kMakerPage.jsx | 105 +++++- tests/test_cl2k_mirror.py | 336 ++++++++++++++++++++ 5 files changed, 485 insertions(+), 8 deletions(-) create mode 100644 tests/test_cl2k_mirror.py 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. */} +