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