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. */}
+