diff --git a/.github/workflows/codeql-lint.yml b/.github/workflows/codeql-lint.yml
index 9b303259..01e3a165 100644
--- a/.github/workflows/codeql-lint.yml
+++ b/.github/workflows/codeql-lint.yml
@@ -222,87 +222,6 @@ jobs:
run: npm run check:classes
# ---- Branch isolation guard ----
- # Develop-only extensions (e.g. the CL2K poster maker) must never reach main.
- # On main (and PRs targeting main) fail if any extension code is present:
- # the extensions/ folders may hold only the generic loaders, and no file
- # named after a develop-only extension may exist anywhere in the tree.
- branch-isolation-guard:
- name: Branch Isolation Guard
- # Always runs (no job-level if) so it stays a satisfiable needs: dependency for
- # the docker jobs on every ref; the step below no-ops on non-main-bound refs.
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout code
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
-
- - name: Fail if develop-only extension code is present
- run: |
- set -eu
- # Only main-bound refs must be extension-free; pass on any other ref.
- if [ "${GITHUB_REF}" != "refs/heads/main" ] && [ "${GITHUB_BASE_REF:-}" != "main" ]; then
- echo "Not a main-bound ref — branch-isolation check not applicable."
- exit 0
- fi
- # :(icase) — git globs are case-sensitive (else Cl2kMakerPage.jsx slips
- # through); cover both extensions dirs, poster_self_heal, and the fonts.
- leaks=$(git ls-files -- \
- ':(icase)*cl2k*' \
- ':(icase)*poster_self_heal*' ':(icase)*posterselfheal*' ':(icase)*posterheal*' \
- 'backend/extensions/*' ':!backend/extensions/__init__.py' \
- 'frontend/src/extensions/*' ':!frontend/src/extensions/index.js' \
- 'deploy/docker/fonts/*')
- if [ -n "$leaks" ]; then
- echo "::error::Develop-only extension files found on a main-bound ref:"
- echo "$leaks"
- exit 1
- fi
- echo "OK: no develop-only extension code present."
-
- # ---- Develop invariant guard ----
- # Mirror of branch-isolation-guard for the develop half: develop may differ from
- # main ONLY by added files plus an append-only deploy/docker/Dockerfile.
- develop-invariant-guard:
- name: Develop Invariant Guard
- # Always runs (no job-level if) so it stays a satisfiable needs on every ref;
- # the step no-ops on non-develop-bound refs.
- runs-on: ubuntu-latest
- permissions:
- contents: read
-
- steps:
- - name: Checkout code
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- with:
- fetch-depth: 0
-
- - name: Assert develop only adds files (shared files byte-identical to main)
- run: |
- set -eu
- # Only develop-bound refs carry the extension delta; pass on any other ref.
- if [ "${GITHUB_REF}" != "refs/heads/develop" ] && [ "${GITHUB_BASE_REF:-}" != "develop" ]; then
- echo "Not a develop-bound ref — develop-invariant check not applicable."
- exit 0
- fi
- git fetch --quiet origin main
- # Merge-base diff (origin/main...HEAD): main being ahead of an unsynced
- # develop must not false-positive; only develop's own delta is inspected.
- bad=$(git diff origin/main...HEAD --name-status \
- | grep -Ev '^A[[:space:]]' \
- | grep -Ev '^M[[:space:]]+deploy/docker/Dockerfile$' || true)
- if [ -n "$bad" ]; then
- echo "::error::develop diverges from main beyond added files + an insertion-only Dockerfile:"
- echo "$bad"
- exit 1
- fi
- # Pure-insertion hunks: no main line removed or edited. CL2K blocks are
- # inserted MID-FILE (per build stage), so a byte-prefix check would false-fail.
- if git diff origin/main...HEAD -- deploy/docker/Dockerfile | grep -q '^-[^-]'; then
- echo "::error::deploy/docker/Dockerfile removes or edits lines present on main; develop may only insert CL2K blocks."
- exit 1
- fi
- echo "OK: develop differs from main only by added files + an insertion-only Dockerfile."
-
# ---- Docker Build (gated by all quality checks) ----
docker-validate:
name: Docker Validate (PR)
@@ -311,7 +230,7 @@ jobs:
# needs-failure SKIPS this job — which GitHub reports as Success for a
# required check, turning a hard gate into a free pass. Promote
# "Frontend Tests" in branch protection to make it block instead.
- needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint, branch-isolation-guard, develop-invariant-guard]
+ needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint]
runs-on: ubuntu-latest
steps:
@@ -333,11 +252,12 @@ jobs:
- name: Set build number
run: echo "BUILD_NUMBER=$(git rev-list --count HEAD)" >> $GITHUB_ENV
- - name: Build Docker image (validation only)
+ - name: Build lean image (validation only)
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: ./deploy/docker/Dockerfile
+ target: runtime
platforms: linux/amd64
build-args: |
BRANCH=${{ steps.get_branch.outputs.BRANCH_NAME }}
@@ -345,10 +265,23 @@ jobs:
push: false
tags: chub:ci-${{ github.sha }}
+ - name: Build full image (validation only)
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
+ with:
+ context: .
+ file: ./deploy/docker/Dockerfile
+ target: full
+ platforms: linux/amd64
+ build-args: |
+ BRANCH=${{ steps.get_branch.outputs.BRANCH_NAME }}
+ BUILD_NUMBER=${{ env.BUILD_NUMBER }}
+ push: false
+ tags: chub:ci-full-${{ github.sha }}
+
docker-push:
name: Docker Build & Push
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
- needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint, frontend-tests, branch-isolation-guard, develop-invariant-guard]
+ needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint, frontend-tests]
runs-on: ubuntu-latest
timeout-minutes: 45 # see release-please.yml docker-version
permissions:
@@ -417,11 +350,27 @@ jobs:
type=sha,prefix=sha-,format=short
type=raw,value=latest,enable={{is_default_branch}}
- - name: Build and push Docker image
+ # :full metadata mirrors the lean tags with a -full suffix; :develop is a
+ # deprecated alias of :full for existing pulls, dropped after a transition.
+ - name: Extract Docker metadata (full)
+ id: meta_full
+ uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6
+ with:
+ images: ghcr.io/${{ github.repository_owner }}/chub
+ tags: |
+ type=semver,pattern={{version}},suffix=-full
+ type=semver,pattern={{major}}.{{minor}},suffix=-full
+ type=semver,pattern={{major}},suffix=-full
+ type=sha,prefix=sha-,suffix=-full,format=short,enable={{is_default_branch}}
+ type=raw,value=full,enable={{is_default_branch}}
+ type=raw,value=develop,enable={{is_default_branch}}
+
+ - name: Build and push lean image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
with:
context: .
file: ./deploy/docker/Dockerfile
+ target: runtime
platforms: linux/amd64,linux/arm64
build-args: |
BRANCH=${{ steps.get_branch.outputs.BRANCH_NAME }}
@@ -430,8 +379,25 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+ # Branch pushes publish lean only (meta_full has no branch tag) — skip the
+ # second build there instead of pushing a tagless manifest.
+ - name: Build and push full image
+ if: steps.meta_full.outputs.tags != ''
+ uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
+ with:
+ context: .
+ file: ./deploy/docker/Dockerfile
+ target: full
+ platforms: linux/amd64,linux/arm64
+ build-args: |
+ BRANCH=${{ steps.get_branch.outputs.BRANCH_NAME }}
+ BUILD_NUMBER=${{ env.BUILD_NUMBER }}
+ push: true
+ tags: ${{ steps.meta_full.outputs.tags }}
+ labels: ${{ steps.meta_full.outputs.labels }}
+
notify-failure:
- needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint, frontend-tests, branch-isolation-guard, develop-invariant-guard, docker-push]
+ needs: [codeql-python, codeql-javascript, backend-lint, backend-smoke, frontend-lint, frontend-tests, docker-push]
if: failure() && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
uses: chodeus/chodeus-ops/.github/workflows/notify-discord.yml@579feb04e3219248963bd27843298a8c2ea6b2fd # main
with:
diff --git a/.github/workflows/dep-audit.yml b/.github/workflows/dep-audit.yml
index a4d2e4d3..6aca5bd7 100644
--- a/.github/workflows/dep-audit.yml
+++ b/.github/workflows/dep-audit.yml
@@ -14,6 +14,7 @@ on:
# images, so triggering on it would imply base-image coverage it lacks.
paths:
- "requirements.txt"
+ - "requirements-cl2k.txt"
- "frontend/package-lock.json"
- ".github/workflows/dep-audit.yml"
@@ -28,12 +29,13 @@ jobs:
contents: read
security-events: write
with:
- # Only what ships: backend pins + frontend lockfile. refs/ holds vendored
- # reference repos and must not be scanned; requirements-cl2k.txt is
- # develop-only and absent on main, so it cannot be listed here.
+ # Only what ships: backend pins (both images) + frontend lockfile. refs/
+ # holds vendored reference repos and must not be scanned.
scan-args: |-
-L
requirements.txt
-L
+ requirements-cl2k.txt
+ -L
frontend/package-lock.json
fail-on-vuln: true
diff --git a/.github/workflows/sync-develop.yml b/.github/workflows/sync-develop.yml
deleted file mode 100644
index 2a4b07fd..00000000
--- a/.github/workflows/sync-develop.yml
+++ /dev/null
@@ -1,87 +0,0 @@
-name: Sync develop from main
-
-# Open a back-merge PR from main into develop on every push to main, so develop
-# (the public app + develop-only extensions) keeps receiving everything that lands
-# on main — releases, fixes, dependency bumps. The branch model's sync direction
-# is main -> develop and shared files are byte-identical on both EXCEPT
-# deploy/docker/Dockerfile, which develop extends with CL2K layers — so that file
-# is the one EXPECTED merge conflict; resolve it by taking main's FROM/base lines
-# and keeping develop's appended CL2K layers. An already-open base:develop /
-# head:main PR auto-tracks main's tip, so we only create one when none is open.
-#
-# The PR is a NOTIFICATION that develop has drifted, not a mergeable PR: develop
-# requires branches be up to date, which head:main can never be without pulling
-# develop's extension files into main. Sync with `git merge origin/main` on
-# develop and push; GitHub then marks this PR merged on its own.
-
-on:
- push:
- branches: [main]
- workflow_dispatch: {}
-
-permissions:
- contents: read
- pull-requests: write
-
-concurrency:
- group: sync-develop
- cancel-in-progress: false
-
-jobs:
- sync-pr:
- runs-on: ubuntu-latest
- steps:
- - name: Checkout
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- with:
- fetch-depth: 0
-
- - name: Open develop-sync PR if needed
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- set -euo pipefail
- git fetch --quiet origin main develop
-
- if [ "$(gh pr list --base develop --head main --state open --json number --jq 'length')" -gt 0 ]; then
- echo "A main -> develop sync PR is already open; it tracks main automatically."
- exit 0
- fi
-
- if git merge-base --is-ancestor origin/main origin/develop; then
- echo "develop already contains main; nothing to sync."
- exit 0
- fi
-
- # The two guards above race a concurrent develop sync: GitHub can decide
- # there is nothing to open between the ancestry check and this create.
- set +e
- create_out="$(gh pr create \
- --base develop \
- --head main \
- --title "chore: sync develop with main" \
- --body "@coderabbitai ignore
-
- \`develop\` has drifted behind \`main\` (releases, fixes, dependency bumps). **Do not merge this PR** — it reports the drift, it does not fix it.
-
- \`develop\` requires branches be up to date, and \`head:main\` can never satisfy that without pulling develop's extension files into main, which the branch invariant forbids. Squash or rebase would also leave \`main\` unreachable from \`develop\`, so this workflow would just open another PR next push.
-
- Sync locally instead:
-
- \`\`\`
- git checkout develop && git merge origin/main && git push
- \`\`\`
-
- Then verify \`git diff main develop\` is added extension files plus \`deploy/docker/Dockerfile\` only. GitHub marks this PR merged on its own once develop contains main's tip. Opened by the sync-develop workflow." 2>&1)"
- create_rc=$?
- set -e
- printf '%s\n' "$create_out"
- if [ "$create_rc" -ne 0 ]; then
- case "$create_out" in
- *"No commits between"*|*"already exists"*)
- echo "develop was synced concurrently; nothing to open."
- exit 0
- ;;
- esac
- exit "$create_rc"
- fi
diff --git a/README.md b/README.md
index c446003c..9f21e045 100755
--- a/README.md
+++ b/README.md
@@ -77,6 +77,17 @@ Migrating from an older YAML-based version? Drop your `config.yml` into the conf
Full walk-through: **[Wiki → Installation](https://github.com/chodeus/chub/wiki/Installation)**.
+### Image tags
+
+| Tag | What you get |
+| --- | --- |
+| `latest` | Core CHUB, kept deliberately minimal (~210 MB compressed). |
+| `full` | Everything in `latest` plus the extension toolchain: the **CL2K poster maker** (ImageMagick + librsvg rendering, real Arial, layered PSD export) and the **poster self-heal** module (~345 MB). |
+| `vX.Y.Z` / `vX.Y.Z-full` | The same two images pinned to a release. |
+| `develop` | Deprecated alias of `full` — switch to `full`; this alias will stop updating. |
+
+Switching between tags is safe in both directions: the tools live in the image, not your volume. A config written under `full` keeps its extension sections on `latest` (typed, preserved across saves), database tables and generated posters are never touched, and moving back to `full` finds everything as you left it. Leaving `full` for good and want a spotless config? Delete the `cl2k_maker:` and `poster_self_heal:` blocks from `config.yml` — that's all there is.
+
### Other install methods
Single-command Docker, Unraid, and bare-metal options: **[Wiki → Installation](https://github.com/chodeus/chub/wiki/Installation)**.
diff --git a/backend/api/cl2k_maker.py b/backend/api/cl2k_maker.py
new file mode 100644
index 00000000..80a24f3e
--- /dev/null
+++ b/backend/api/cl2k_maker.py
@@ -0,0 +1,2056 @@
+"""CL2K Maker API.
+
+Powers the CL2K Poster Maker page. Entry points (TMDB search, ID/URL paste,
+unmatched-asset links) all resolve to a tmdb_id + kind; the art picker lists
+every logo/backdrop; preview renders without saving; generate writes the poster
+into every configured save location claiming its type (local folders and/or
+Drive uploads; none = downloadable only) and records provenance.
+
+ GET /api/cl2k-maker/search?q=&type= TMDB title search (entry point)
+ GET /api/cl2k-maker/resolve?external_id=&source=&type= tvdb/imdb -> tmdb
+ GET /api/cl2k-maker/images?tmdb_id=&type= all logos + backdrops (picker)
+ POST /api/cl2k-maker/preview render to JPEG, no save
+ POST /api/cl2k-maker/generate render + write + cache + log
+ GET /api/cl2k-maker/generated provenance (recent)
+
+Module settings are read/saved through the generic /api/config endpoints.
+"""
+
+import base64
+import io
+import threading
+from typing import Any, Dict, List, Optional
+
+from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
+from fastapi.responses import JSONResponse, Response
+from pydantic import BaseModel, Field, model_validator
+
+from backend.api.utils import error, get_database, get_module_logger, ok
+from backend.modules.cl2k_maker import (
+ fanart_images,
+ generate_background_art,
+ generate_for_item,
+ generate_logo_asset,
+ generate_seasons,
+ generate_square_art,
+ psd_for_item,
+ render_preview,
+ retext_poster,
+)
+from backend.util.cl2k import geometry as geo, text_removal, tmdb_art
+from backend.util.cl2k.image_fetch import (
+ TMDB_IMAGE_CDN,
+ _is_plex_art_path,
+ download as download_image,
+)
+from backend.util.cl2k.logo_extract import (
+ extract_logo_by_diff,
+ extract_subject_logo,
+ extract_title_logo,
+ tighten_text_mask,
+)
+from backend.util.cl2k.renderer import (
+ process_logo,
+ render_framed_art,
+ render_square_art,
+)
+from backend.util.config import load_config
+from backend.util.database import ChubDB
+from backend.util.database.cl2k_generated import cl2k_generated_for
+from backend.util.tmdb import TMDBClient
+
+router = APIRouter(
+ prefix="/api/cl2k-maker",
+ tags=["CL2K Maker"],
+ responses={500: {"description": "Internal server error"}},
+)
+
+
+def get_cl2k_logger(request: Request) -> Any:
+ """Log on-demand CL2K operations to the cl2k_maker module log (not the general
+ log), so generation/upload activity and errors show up under the Logs page's
+ CL2K Maker section instead of vanishing into the general log."""
+ return get_module_logger(request, "cl2k_maker")
+
+
+def _require_tmdb_or_backdrop(req: Any):
+ """Require a tmdb_id unless the request supplies its own backdrop (path/b64)."""
+ # Art auto-sources from TMDB (list_images) only when no backdrop is given.
+ if req.tmdb_id is None and not (req.backdrop_path or req.backdrop_b64):
+ raise ValueError("tmdb_id is required unless a backdrop is supplied")
+ return req
+
+
+class GenerateRequest(BaseModel):
+ kind: str
+ title: str
+ # Optional so a TVDB/IMDB-only title can render from a supplied backdrop; the
+ # validator below still requires tmdb_id whenever no backdrop is handed over.
+ tmdb_id: Optional[int] = None
+ year: Optional[int] = None
+ tvdb_id: Optional[int] = None
+ imdb_id: Optional[str] = None
+ season_number: Optional[int] = None
+ backdrop_path: Optional[str] = None
+ backdrop_b64: Optional[str] = None # custom-uploaded backdrop (wins over path)
+ logo_path: Optional[str] = None
+ logo_b64: Optional[str] = (
+ None # custom uploaded logo (PNG, base64); wins over logo_path
+ )
+ # Logo size: relaxes the height clamp (the y=1100 zone-top guide) so tall/boxy
+ # logos can render readable; 1.0 = the strict CL2K guide box. Width caps still apply.
+ logo_scale: float = Field(1.0, ge=geo.LOGO_SCALE_MIN, le=geo.LOGO_SCALE_MAX)
+ # Logo position: vertical shift in px from the locked baseline (positive = down).
+ # Size is unaffected; the placement is clamped onto the canvas.
+ logo_y_offset: int = Field(0, ge=geo.LOGO_Y_OFFSET_MIN, le=geo.LOGO_Y_OFFSET_MAX)
+ # Per-render CL2K-whiten override; None falls back to the module config
+ # (whiten_logo). True = two-tone white, False = the original colored logo.
+ whiten: Optional[bool] = None
+ # Flat white: paint the logo a pure-white silhouette (no two-tone keylines) —
+ # for already-stylised/outline logos the two-tone whiten mangles. Wins over whiten.
+ flat_white: bool = False
+ # 3D logo: keep the lit face of extruded/bevelled art, drop the extrusion and
+ # shadow, flat-white the rest. Wins over flat_white.
+ logo_3d: bool = False
+ # Invert logo: plate-style art -> clearlogo (white->transparent, black->white).
+ invert: bool = False
+ # B/W touch-up: regions brushed over the PROCESSED logo whose black/white is
+ # inverted (for interior accents the two-tone keymap can't decide).
+ logo_flip_b64: Optional[str] = None
+ # Eraser: regions brushed over the PROCESSED logo made transparent (clean up
+ # stray extracted/whitened bits a logo shouldn't have).
+ logo_erase_b64: Optional[str] = None
+ mask_b64: Optional[str] = None # user-brushed mask (PNG, white=remove) for AI
+ remove_text: bool = False # run AI text removal (OpenAI can do it mask-less)
+ focus_x: float = 0.5 # crop focal point (0..1); 0.5 = centre (cover mode)
+ # Framing: "cover" scales up + crops to fill (focus_x + v_pos); "fit" scales the
+ # backdrop down to the canvas width and black-pads the bottom, keeping the full
+ # width so spread-out subjects all stay in frame. ``crop_*`` (0..1) optionally
+ # isolates the subject region of a wide backdrop before the fit.
+ fit_mode: str = "cover"
+ crop_x: Optional[float] = None
+ crop_y: Optional[float] = None
+ crop_w: Optional[float] = None
+ crop_h: Optional[float] = None
+ # Vertical position. In cover ("Fill") it is -1..1 centred on 0: positive
+ # slides the framing UP at the same size (real artwork flows down into the
+ # gradient, no AI), negative slides it DOWN and only as far as real source
+ # above the crop allows — no gradient up there to hide an extended band. In
+ # fit/extend it keeps its 0..1 top-anchored meaning (0 = top, ~0.4 = headroom).
+ v_pos: float = Field(0.0, ge=geo.V_POS_MIN, le=geo.V_POS_MAX)
+ # Zoom (0.5-3.0): in fit/extend, >1 enlarges the subject above the full-width
+ # 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)
+ # Explicit bottom banner (e.g. "COMPLETE LIMITED SERIES"); overrides the auto
+ # COLLECTION / season label when set.
+ band_label: str = ""
+ force: bool = False
+ # Preview-only: render the logo-less base (backdrop + gradient + label + border)
+ # so the frontend can overlay a live logo on top — the size/position sliders
+ # then move the logo without a server render per drag. Always True on generate
+ # (the logo is baked into the saved poster).
+ place_logo: bool = True
+ # Save mediums (independent). save_local writes to every local folder that
+ # claims the image type; upload_gdrive=None/True uploads to every claiming
+ # Drive (False skips). Nothing selected/routed = downloadable only.
+ save_local: bool = True
+ upload_gdrive: Optional[bool] = None
+
+ @model_validator(mode="after")
+ def _tmdb_id_or_backdrop(self):
+ """Require a tmdb_id unless a backdrop is supplied (shared rule)."""
+ return _require_tmdb_or_backdrop(self)
+
+
+def _mask_bytes(b64: Optional[str]) -> Optional[bytes]:
+ """Strict decode for brush masks: malformed base64 raises here (validate=True)
+ so the endpoint can 400 with a readable message instead of handing garbage
+ bytes to the renderer to crash on deep inside a render."""
+ return _b64_to_bytes(b64, validate=True)
+
+
+def _b64_to_bytes(
+ b64: Optional[str], validate: bool = False, raster_only: bool = True
+) -> Optional[bytes]:
+ """Decode a base64 image, tolerating a ``data:...;base64,`` URL prefix.
+
+ ``raster_only`` (the default) refuses markup-leading bytes: masks and
+ backdrops must be raster, and SVG smuggled into them would otherwise reach
+ ImageMagick's XML delegates. Only the logo field may carry SVG — the
+ renderer routes that through sandboxed cairosvg."""
+ if not b64:
+ return None
+ data = base64.b64decode(b64.split(",")[-1], validate=validate)
+ if raster_only and data[:1024].lstrip(b"\xef\xbb\xbf \t\r\n\f\v").startswith(b"<"):
+ raise ValueError("markup is not a valid raster image for this field")
+ return data
+
+
+def _ai_config_or_error(logger: Any, what: str):
+ """``(full config, None)`` when an AI erase can run, else ``(None, 400)``.
+
+ Every user-triggered AI route fails loudly rather than returning the image
+ unchanged, which the frontend would report as a successful erase. Read live:
+ a handler holding config from import time goes stale on a settings save.
+ Routes needing more than "a provider is usable" (detect-text wants the
+ sidecar specifically) add that check themselves — this owns only the part
+ all three share.
+ """
+ cfg = load_config()
+ reason = text_removal.unavailable_reason(cfg.cl2k_maker)
+ if reason:
+ logger.warning(f"CL2K {what}: AI unavailable — {reason}")
+ return None, error(reason, "CL2K_AI_UNAVAILABLE")
+ return cfg, None
+
+
+def _failed(
+ logger: Any,
+ what: str,
+ exc: BaseException,
+ code: str,
+ *,
+ status_code: int = 400,
+ trace: bool = False,
+) -> JSONResponse:
+ """Log a failure in full; answer with a stable message that omits ``exc``.
+
+ THE owner of that split for this router. Exception text carries filesystem
+ paths, internal hostnames and provider payloads, so it stays server-side
+ (CodeQL py/stack-trace-exposure); the code and status the caller sees are
+ unchanged, and the reason is one log line away under Logs → CL2K Maker.
+ """
+ if trace:
+ logger.error(f"cl2k: {what} failed: {exc}", exc_info=True)
+ else:
+ logger.warning(f"cl2k: {what} failed: {exc}")
+ return error(
+ f"Could not {what} — see the CL2K Maker log for the reason.",
+ code,
+ status_code=status_code,
+ )
+
+
+# Worker-side twin of _failed's public half: job registries are read back by
+# /seasons-status, so a raw str(exc) there reaches the browser just the same.
+_JOB_FAILED = "failed — see the CL2K Maker log for the reason"
+
+
+def _run_or_error(logger: Any, what: str, code: str, run):
+ """``(value, None)`` from ``run``, else ``(None, 400)`` naming what failed.
+
+ Bad input (truncated data-URL, disallowed art host, provider down) only
+ surfaces deep inside the render/save chain — this is where it becomes a
+ readable 4xx plus a CL2K log line rather than a bare 500 and silence.
+ """
+ try:
+ return run(), None
+ except HTTPException:
+ raise # a real HTTP status (auth, upstream) must not become a 400
+ except Exception as exc:
+ return None, _failed(logger, what, exc, code)
+
+
+def _save_response(logger: Any, *, done: str, what: str, run) -> JSONResponse:
+ """Run one of the save/generate flows and shape its result dict into JSON."""
+ result, bad = _run_or_error(logger, what, "CL2K_GENERATE", run)
+ if bad is not None:
+ return bad
+ if result.get("status") == "generated":
+ pending = result.get("upload_pending")
+ return ok(f"{done} — uploading to Drive" if pending else done, result)
+ return error(
+ result.get("reason", "generation failed"), "CL2K_GENERATE", data=result
+ )
+
+
+def _crop_tuple(req: Any):
+ """Assemble the (x, y, w, h) fit crop from a request, or None if unset.
+
+ 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
+
+
+class LogoFetchError(Exception):
+ """A chosen logo URL could not be downloaded (disallowed host, dead URL,
+ rotated Plex token, …). Endpoints turn this into a clean 4xx instead of an
+ opaque 500."""
+
+
+def _resolve_logo_bytes(
+ logo_path: Optional[str], logo_b64: Optional[str]
+) -> Optional[bytes]:
+ """Bytes for a chosen logo (or None). An uploaded PNG (``logo_b64``) wins
+ over a chosen TMDB/fanart/Plex ``logo_path``, which is fetched via the
+ host-allowlisted image downloader (so a crafted path can't trigger an
+ SSRF). Download failures raise :class:`LogoFetchError`."""
+ if logo_b64:
+ return _b64_to_bytes(logo_b64, raster_only=False)
+ if logo_path:
+ try:
+ return download_image(logo_path)
+ except Exception as exc:
+ raise LogoFetchError(str(exc)) from exc
+ return None
+
+
+def _decorate(items: List[dict]) -> List[dict]:
+ """Add absolute CDN URLs to TMDB image records for the picker thumbnails."""
+ out = []
+ for it in items:
+ path = it.get("file_path")
+ if path:
+ out.append({**it, "url": TMDB_IMAGE_CDN + path})
+ return out
+
+
+@router.get("/search", summary="TMDB title search")
+def search(
+ q: str = Query(..., min_length=1),
+ media_type: str = Query("movie", alias="type"),
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ tmdb = TMDBClient(load_config().tmdb, db, logger)
+ return ok("ok", {"results": tmdb_art.search_titles(tmdb, q, media_type)})
+
+
+@router.get("/resolve", summary="Resolve an external id (tvdb/imdb) to a tmdb id")
+def resolve(
+ external_id: str = Query(...),
+ source: str = Query(..., description="tvdb_id | imdb_id"),
+ media_type: str = Query("movie", alias="type"),
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ tmdb = TMDBClient(load_config().tmdb, db, logger)
+ mt = "movie" if media_type == "movie" else "tv"
+ return ok("ok", {"tmdb_id": tmdb.find_tmdb_id(external_id, source, mt)})
+
+
+def _resolve_tmdb_id(
+ tmdb: TMDBClient,
+ tmdb_id: Optional[int],
+ tvdb_id: Optional[int],
+ imdb_id: Optional[str],
+ media_type: str,
+) -> Optional[int]:
+ """The title's TMDB id, looked up from a tvdb/imdb id when not supplied."""
+ # TMDB art is keyed by tmdb_id, but a title may only carry a tvdb/imdb one
+ # (Sonarr shows especially) — resolve rather than demand.
+ if tmdb_id:
+ return tmdb_id
+ if tvdb_id:
+ return tmdb.find_tmdb_id(str(tvdb_id), "tvdb_id", media_type)
+ if imdb_id:
+ return tmdb.find_tmdb_id(str(imdb_id), "imdb_id", media_type)
+ return None
+
+
+@router.get("/images", summary="All logos + backdrops + posters for the art picker")
+def images(
+ tmdb_id: Optional[int] = Query(None),
+ tvdb_id: Optional[int] = Query(None),
+ imdb_id: Optional[str] = Query(None),
+ media_type: str = Query("movie", alias="type"),
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ tmdb = TMDBClient(load_config().tmdb, db, logger)
+ mt = "movie" if media_type == "movie" else "tv"
+ resolved = _resolve_tmdb_id(tmdb, tmdb_id, tvdb_id, imdb_id, mt)
+ if not resolved:
+ return ok("ok", {"logos": [], "backdrops": [], "posters": []})
+ imgs = tmdb_art.list_images(tmdb, resolved, media_type) or {
+ "logos": [],
+ "backdrops": [],
+ }
+ # Textless (null-language) posters first — pure art that needs no AI text
+ # pass at all. Stable sort keeps TMDB's vote order within each group.
+ posters = sorted(
+ imgs.get("posters", []), key=lambda p: p.get("iso_639_1") is not None
+ )
+ return ok(
+ "ok",
+ {
+ "logos": _decorate(imgs.get("logos", [])),
+ "backdrops": _decorate(imgs.get("backdrops", [])),
+ # Official posters too: often the only quality art for small titles
+ # (documentaries etc.) — pick one, brush the title text, AI-erase.
+ "posters": _decorate(posters),
+ },
+ )
+
+
+@router.get(
+ "/season-images", summary="TMDB season posters (portrait) for the art picker"
+)
+def season_images(
+ tmdb_id: Optional[int] = Query(None),
+ tvdb_id: Optional[int] = Query(None),
+ imdb_id: Optional[str] = Query(None),
+ season_number: int = Query(...),
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ tmdb = TMDBClient(load_config().tmdb, db, logger)
+ resolved = _resolve_tmdb_id(tmdb, tmdb_id, tvdb_id, imdb_id, "tv")
+ if not resolved:
+ return ok("ok", {"posters": []})
+ imgs = tmdb_art.list_season_images(tmdb, resolved, season_number) or {"posters": []}
+ return ok("ok", {"posters": _decorate(imgs.get("posters", []))})
+
+
+@router.get(
+ "/upload-status", summary="Configured CL2K save locations + Drive OAuth state"
+)
+def upload_status(
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ """Feeds the maker page's save-target defaults: whether any local folder /
+ Drive upload actually routes something (an entry with no claimed types is
+ inert), and whether Drive uploads have a usable OAuth token."""
+ from backend.util.cl2k.gdrive_upload import has_upload_token
+
+ cfg = load_config()
+ return ok(
+ "ok",
+ {
+ "local_configured": any(
+ (f.path or "").strip() and f.types for f in cfg.cl2k_maker.local_folders
+ ),
+ "gdrive_configured": any(
+ (d.folder_id or "").strip() and d.types
+ for d in cfg.cl2k_maker.gdrive_uploads
+ ),
+ "token_ok": has_upload_token(cfg.sync_gdrive),
+ },
+ )
+
+
+class TestDriveRequest(BaseModel):
+ gdrive_folder_id: str = ""
+
+
+@router.post("/test-drive", summary="Verify CHUB can upload to a given Drive folder")
+def test_drive(
+ req: TestDriveRequest,
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ """Upload a tiny marker file to ``gdrive_folder_id`` then delete it, proving
+ write access with the Sync GDrive OAuth token. Powers the per-destination
+ Test button in the CL2K settings."""
+ from backend.util.cl2k.gdrive_upload import has_upload_token, test_drive_access
+
+ folder_id = (req.gdrive_folder_id or "").strip()
+ if not folder_id:
+ return error("A Google Drive folder ID is required", "GDRIVE_FOLDER_REQUIRED")
+ cfg = load_config()
+ # Missing token is a config precondition (400), distinct from a genuine
+ # rclone/Drive failure below (502).
+ if not has_upload_token(cfg.sync_gdrive):
+ return error(
+ "No Google Drive OAuth token configured — set one under Sync GDrive "
+ "(a service account cannot own files in a personal Drive).",
+ "GDRIVE_NO_TOKEN",
+ )
+ try:
+ detail = test_drive_access(folder_id, cfg.sync_gdrive, logger)
+ except ValueError as exc:
+ return _failed(logger, "accept that folder ID", exc, "GDRIVE_FOLDER_INVALID")
+ except Exception as exc:
+ return _failed(
+ logger, "test that Drive folder", exc, "GDRIVE_TEST_FAILED", status_code=502
+ )
+ return ok(detail, {"folder_id": folder_id})
+
+
+@router.post(
+ "/gdrive/type-subfolders",
+ summary="Create logos/backgrounds/squareart under a parent Drive folder",
+)
+def gdrive_type_subfolders(
+ req: TestDriveRequest,
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ """Split one parent Drive folder into the community artwork layout.
+
+ Creates (or reuses) the three type subfolders and returns their real ids, so
+ the caller can store one routed destination per type. Purely additive on
+ Drive — nothing is moved, renamed or deleted, and a drive that should stay
+ flat simply never calls this.
+ """
+ from backend.util.cl2k.gdrive_upload import ensure_type_subfolders, has_upload_token
+
+ folder_id = (req.gdrive_folder_id or "").strip()
+ if not folder_id:
+ return error("A Google Drive folder ID is required", "GDRIVE_FOLDER_REQUIRED")
+ cfg = load_config()
+ if not has_upload_token(cfg.sync_gdrive):
+ return error(
+ "No Google Drive OAuth token configured — set one under Sync GDrive "
+ "(a service account cannot own files in a personal Drive).",
+ "GDRIVE_NO_TOKEN",
+ )
+ try:
+ subfolders = ensure_type_subfolders(folder_id, cfg.sync_gdrive, logger)
+ except ValueError as exc:
+ return _failed(logger, "accept that folder ID", exc, "GDRIVE_FOLDER_INVALID")
+ except Exception as exc:
+ return _failed(
+ logger,
+ "create the type subfolders",
+ exc,
+ "GDRIVE_SUBFOLDERS_FAILED",
+ status_code=502,
+ )
+ created = [s["name"] for s in subfolders if s["created"]]
+ detail = (
+ f"Created {', '.join(created)}" if created else "All three subfolders already existed"
+ )
+ return ok(detail, {"folder_id": folder_id, "subfolders": subfolders})
+
+
+@router.get(
+ "/external-ids", summary="TMDB external ids (tvdb_id + imdb_id) for a title"
+)
+def external_ids(
+ tmdb_id: Optional[int] = Query(None),
+ tvdb_id: Optional[int] = Query(None),
+ imdb_id: Optional[str] = Query(None),
+ media_type: str = Query("movie", alias="type"),
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ tmdb = TMDBClient(load_config().tmdb, db, logger)
+ mt = "movie" if media_type == "movie" else "tv"
+ resolved = _resolve_tmdb_id(tmdb, tmdb_id, tvdb_id, imdb_id, mt)
+ if not resolved:
+ return ok("ok", {})
+ return ok("ok", tmdb_art.external_ids(tmdb, resolved, media_type))
+
+
+@router.get("/details", summary="Canonical TMDB title + year for an id")
+def details(
+ tmdb_id: Optional[int] = Query(None),
+ tvdb_id: Optional[int] = Query(None),
+ imdb_id: Optional[str] = Query(None),
+ media_type: str = Query("movie", alias="type"),
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ """Title + release year for an id, so an id-only entry (paste / Edit IDs / deep
+ link) shows the real name in the header instead of bare id tags. Resolves a
+ usable TMDB id in order: ``tmdb_id`` → TVDB → IMDB (matching the save-time
+ backfill), then reads the canonical title/year."""
+ tmdb = TMDBClient(load_config().tmdb, db, logger)
+ mt = "movie" if media_type == "movie" else "tv"
+ resolved = _resolve_tmdb_id(tmdb, tmdb_id, tvdb_id, imdb_id, mt)
+ d = (tmdb.get_details(resolved, mt) if resolved else None) or {}
+ return ok("ok", {"title": d.get("title"), "year": d.get("year")})
+
+
+class LogoProcessRequest(BaseModel):
+ logo_path: Optional[str] = None
+ logo_b64: Optional[str] = None # custom uploaded logo (PNG, base64)
+ # Per-render whiten override so the live overlay matches the Builder toggle;
+ # None falls back to the module config (whiten_logo).
+ whiten: Optional[bool] = None
+ flat_white: bool = (
+ False # pure-white silhouette (no two-tone keylines); wins over whiten
+ )
+ logo_3d: bool = False # extruded art -> flat-white lit face; wins over flat_white
+ invert: bool = False # plate logo -> clearlogo
+ flip_b64: Optional[str] = None # B/W touch-up regions (mask PNG, white=flip)
+ erase_b64: Optional[str] = None # erase regions (mask PNG, white=erase)
+ # Picks the bottom baseline the auto box is fitted against (collection=1319).
+ kind: str = "movie"
+
+
+@router.post("/logo-processed", summary="Trimmed + whitened logo for the live overlay")
+def logo_processed(
+ req: LogoProcessRequest,
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ """Return the trimmed + whitened logo (PNG, base64), its natural size and the
+ placement box the render would give it. The frontend draws these bytes at
+ ``box_w``/``box_h`` so the size/position sliders preview live — matching
+ :func:`render_cl2k`'s placement without a render per drag.
+
+ ``box_w``/``box_h`` come from :func:`geometry.auto_logo_size`, the SAME call
+ :func:`renderer._place_logo` makes (``logo_max_width`` is never passed, so the
+ render always takes its auto branch). Deriving the box here rather than
+ re-deriving it in JS is what keeps the overlay and the generated poster the
+ same size — a flat guide-box width over-sizes wide logos by ~15%."""
+ try:
+ raw = _resolve_logo_bytes(req.logo_path, req.logo_b64)
+ except LogoFetchError as exc:
+ return _failed(logger, "fetch that logo from its source", exc, "LOGO_FETCH")
+ if not raw:
+ return error("No logo provided", "NO_LOGO")
+ cfg = load_config().cl2k_maker
+ try:
+ png, width, height = process_logo(
+ raw,
+ whiten=cfg.whiten_logo if req.whiten is None else req.whiten,
+ flat_white=req.flat_white,
+ logo_3d=req.logo_3d,
+ flip_mask_bytes=_b64_to_bytes(req.flip_b64),
+ erase_mask_bytes=_b64_to_bytes(req.erase_b64),
+ invert=req.invert,
+ )
+ except Exception as exc:
+ logger.warning(f"cl2k: logo processing failed: {exc}")
+ return error("Could not process that logo", "LOGO_PROCESS")
+ box_w, box_h = geo.auto_logo_size(width, height, geo.logo_baseline(req.kind))
+ return ok(
+ "ok",
+ {
+ "b64": base64.b64encode(png).decode(),
+ "width": width,
+ "height": height,
+ "max_width": geo.LOGO_WIDTH_RECOMMENDED,
+ "box_w": box_w,
+ "box_h": box_h,
+ },
+ )
+
+
+@router.post("/preview", summary="Render a CL2K poster without saving")
+def preview(
+ req: GenerateRequest,
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+):
+ """Render a CL2K poster to JPEG and return the bytes, saving nothing."""
+ try:
+ mask_bytes = _mask_bytes(req.mask_b64)
+ except Exception:
+ return error("invalid mask data", "BAD_MASK")
+ cfg = load_config() # outside the guard: a config fault is not a render fault
+ blob, bad = _run_or_error(
+ logger,
+ "render that preview",
+ "PREVIEW_RENDER",
+ lambda: render_preview(
+ db,
+ cfg,
+ logger,
+ kind=req.kind,
+ title=req.title,
+ tmdb_id=req.tmdb_id,
+ season_number=req.season_number,
+ backdrop_path=req.backdrop_path,
+ backdrop_bytes=_b64_to_bytes(req.backdrop_b64),
+ logo_path=req.logo_path,
+ custom_logo_bytes=_b64_to_bytes(req.logo_b64, raster_only=False),
+ tvdb_id=req.tvdb_id,
+ 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,
+ band_label=req.band_label,
+ logo_scale=req.logo_scale,
+ 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),
+ whiten=req.whiten,
+ flat_white=req.flat_white,
+ logo_3d=req.logo_3d,
+ invert=req.invert,
+ place_logo=req.place_logo,
+ ),
+ )
+ if bad is not None:
+ return bad
+ if blob is None:
+ return error("No textless backdrop available", "NO_BACKDROP")
+ return Response(
+ content=blob, media_type="image/jpeg", headers={"Cache-Control": "no-store"}
+ )
+
+
+@router.post("/generate", summary="Generate + save a CL2K poster")
+def generate(
+ req: GenerateRequest,
+ background_tasks: BackgroundTasks,
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ """Render a CL2K poster and file it to every claiming save location."""
+ if (bad := _require_any_id(req)) is not None:
+ return bad
+ try:
+ mask_bytes = _mask_bytes(req.mask_b64)
+ except Exception:
+ return error("invalid mask data", "BAD_MASK")
+ cfg = load_config() # outside the guard: a config fault is not a save fault
+ return _save_response(
+ logger,
+ done="Poster generated",
+ what="save that poster",
+ run=lambda: generate_for_item(
+ db=db,
+ full_config=cfg,
+ logger=logger,
+ kind=req.kind,
+ title=req.title,
+ tmdb_id=req.tmdb_id,
+ year=req.year,
+ tvdb_id=req.tvdb_id,
+ imdb_id=req.imdb_id,
+ season_number=req.season_number,
+ backdrop_path=req.backdrop_path,
+ backdrop_bytes=_b64_to_bytes(req.backdrop_b64),
+ logo_path=req.logo_path,
+ 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,
+ band_label=req.band_label,
+ logo_scale=req.logo_scale,
+ 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),
+ whiten=req.whiten,
+ flat_white=req.flat_white,
+ logo_3d=req.logo_3d,
+ invert=req.invert,
+ force=req.force,
+ save_local=req.save_local,
+ upload_gdrive=req.upload_gdrive,
+ # Every interactive save defers alike (posters and the asset makers);
+ # the batch run() stays inline. A deferred failure notifies — see
+ # _run_uploads.
+ defer_upload=background_tasks.add_task,
+ ),
+ )
+
+
+# ─── Square art + logo asset makers ──────────────────────────────────────────
+# Two additional asset types the maker files separately from posters: square art
+# (1:1 cropped backdrop, `- squareart.jpg`) and a clear-logo asset (`- logo.png`).
+# Both flow into poster_cache so asset_renamerr applies them to Plex
+# (uploadSquareArt / uploadLogo).
+
+
+def _require_any_id(req) -> Optional[JSONResponse]:
+ """Reject art with no id at all — the filename would carry nothing to match
+ on, so asset_renamerr could never bind it to a library item."""
+ if req.tmdb_id or req.tvdb_id or (req.imdb_id or "").strip():
+ return None
+ return error(
+ "Needs at least one of TMDB, TVDB or IMDB id — without one the filename "
+ "has nothing for CHUB or Kometa to match against.",
+ "NO_MEDIA_ID",
+ )
+
+
+class SquareArtRequest(BaseModel):
+ kind: str
+ title: str
+ # Supplied art, so an id is only a filename tag. _require_any_id demands one.
+ tmdb_id: Optional[int] = None
+ year: Optional[int] = None
+ tvdb_id: Optional[int] = None
+ imdb_id: Optional[str] = None
+ # File the art for ONE season of a show (` - Season NN` name; plexapi seasons
+ # accept square art) instead of the show itself. None = show/movie-level.
+ season_number: Optional[int] = None
+ backdrop_path: Optional[str] = None
+ backdrop_b64: Optional[str] = None # custom-uploaded source art (base64)
+ focus_x: float = 0.5
+ 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)
+ save_local: bool = True
+ upload_gdrive: Optional[bool] = None
+
+
+class LogoAssetRequest(BaseModel):
+ kind: str
+ title: str
+ # Supplied art, so an id is only a filename tag. _require_any_id demands one.
+ tmdb_id: Optional[int] = None
+ year: Optional[int] = None
+ tvdb_id: Optional[int] = None
+ imdb_id: Optional[str] = None
+ logo_path: Optional[str] = None
+ logo_b64: Optional[str] = None
+ whiten: bool = False # True = CL2K-whitened; False = original (colored) clear logo
+ flat_white: bool = False # pure-white silhouette (no keylines); wins over whiten
+ logo_3d: bool = False # extruded art -> flat-white lit face; wins over flat_white
+ invert: bool = False # plate logo -> clearlogo (white->transparent, black->white)
+ flip_b64: Optional[str] = None # B/W touch-up regions (mask PNG, white=flip)
+ erase_b64: Optional[str] = None # erase regions (mask PNG, white=erase)
+ save_local: bool = True
+ upload_gdrive: Optional[bool] = None
+
+
+class ExtractLogoRequest(BaseModel):
+ # Source poster: an uploaded image (base64/data-url) wins over a TMDB/fanart/
+ # Plex path that we fetch. Extraction keys the title out of the artwork.
+ image_b64: Optional[str] = None
+ image_path: Optional[str] = None
+ # Brushed region (PNG, white = look here). Confines the key so areas outside
+ # the title can't leak in; without it the whole image is keyed.
+ mask_b64: Optional[str] = None
+ # "white" keys a white/near-white title by brightness; "subject" keys a
+ # coloured title by its colour distance from the local background; "erase"
+ # inpaints the title away and keys whatever changed — the most faithful key,
+ # it catches glows no colour key can, and the only one needing a provider.
+ mode: str = "white"
+ # Smoothstep band, interpreted per mode (white: min-channel 0-255; subject:
+ # ΔE76 0-~150; erase: RGB distance 0-441). None lets each mode use its own
+ # default — white/subject then fit the band to the poster (Otsu).
+ lo: Optional[float] = Field(None, ge=0.0, le=441.0)
+ hi: Optional[float] = Field(None, ge=0.0, le=441.0)
+
+
+def _source_art_bytes(req: Any) -> Optional[bytes]:
+ """Source art for an asset request: an upload wins over a fetched path."""
+ if req.backdrop_b64:
+ return _b64_to_bytes(req.backdrop_b64)
+ if req.backdrop_path:
+ return download_image(req.backdrop_path)
+ return None
+
+
+def _art_preview(logger: Any, req: Any, render):
+ """Fetch the source art and render an asset preview, or a clean 4xx."""
+ raw, bad = _run_or_error(
+ logger, "fetch that source art", "IMAGE_FETCH", lambda: _source_art_bytes(req)
+ )
+ if bad is not None:
+ return bad
+ if not raw:
+ return error("No source art selected", "NO_BACKDROP")
+ blob, bad = _run_or_error(
+ logger, "render that preview", "PREVIEW_RENDER", lambda: render(raw)
+ )
+ if bad is not None:
+ return bad
+ return Response(
+ content=blob, media_type="image/jpeg", headers={"Cache-Control": "no-store"}
+ )
+
+
+@router.post("/square-preview", summary="Render square art (1:1) without saving")
+def square_preview(
+ req: SquareArtRequest,
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+):
+ """Render 1:1 square art and return the JPEG bytes, saving nothing."""
+ return _art_preview(
+ logger,
+ 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,
+ ),
+ )
+
+
+@router.post("/square-generate", summary="Generate + save square art")
+def square_generate(
+ req: SquareArtRequest,
+ background_tasks: BackgroundTasks,
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ """Render square art and file it as the item's ``- squareart.jpg`` asset."""
+ if (bad := _require_any_id(req)) is not None:
+ return bad
+ cfg = load_config() # outside the guard: a config fault is not a save fault
+ return _save_response(
+ logger,
+ done="Square art generated",
+ what="save that square art",
+ run=lambda: generate_square_art(
+ db=db,
+ full_config=cfg,
+ logger=logger,
+ kind=req.kind,
+ title=req.title,
+ tmdb_id=req.tmdb_id,
+ year=req.year,
+ tvdb_id=req.tvdb_id,
+ 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,
+ season_number=req.season_number,
+ save_local=req.save_local,
+ upload_gdrive=req.upload_gdrive,
+ defer_upload=background_tasks.add_task,
+ ),
+ )
+
+
+class BackgroundArtRequest(BaseModel):
+ kind: str
+ title: str
+ # Supplied art, so an id is only a filename tag. _require_any_id demands one.
+ tmdb_id: Optional[int] = None
+ year: Optional[int] = None
+ tvdb_id: Optional[int] = None
+ imdb_id: Optional[str] = None
+ # File the art for ONE season of a show (` - Season NN` name; Plex seasons take
+ # background art, Kometa reads Season##_background). None = show/movie-level.
+ season_number: Optional[int] = None
+ backdrop_path: Optional[str] = None
+ backdrop_b64: Optional[str] = None # custom-uploaded source art (base64)
+ focus_x: float = 0.5
+ 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)
+ resolution: str = "1080p" # 1080p (1920x1080) | 4k (3840x2160), per Plex dims
+ save_local: bool = True
+ upload_gdrive: Optional[bool] = None
+
+
+@router.post(
+ "/background-preview", summary="Render background art (16:9) without saving"
+)
+def background_preview(
+ req: BackgroundArtRequest,
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+):
+ """Render 16:9 background art and return the JPEG bytes, saving nothing."""
+ # Preview at 1080p regardless of the save resolution — same 16:9 frame,
+ # quarter the bytes of a 4K render.
+ return _art_preview(
+ logger,
+ req,
+ lambda raw: render_framed_art(
+ 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,
+ ),
+ )
+
+
+@router.post("/background-generate", summary="Generate + save background art")
+def background_generate(
+ req: BackgroundArtRequest,
+ background_tasks: BackgroundTasks,
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ """Render background art and file it as the ``- background.jpg`` asset."""
+ if (bad := _require_any_id(req)) is not None:
+ return bad
+ cfg = load_config() # outside the guard: a config fault is not a save fault
+ return _save_response(
+ logger,
+ done="Background art generated",
+ what="save that background art",
+ run=lambda: generate_background_art(
+ db=db,
+ full_config=cfg,
+ logger=logger,
+ kind=req.kind,
+ title=req.title,
+ tmdb_id=req.tmdb_id,
+ year=req.year,
+ tvdb_id=req.tvdb_id,
+ 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,
+ resolution=req.resolution,
+ season_number=req.season_number,
+ save_local=req.save_local,
+ upload_gdrive=req.upload_gdrive,
+ defer_upload=background_tasks.add_task,
+ ),
+ )
+
+
+@router.post(
+ "/logo-asset-preview", summary="Processed logo asset (transparent PNG), no save"
+)
+def logo_asset_preview(
+ req: LogoAssetRequest,
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+):
+ """Return the processed clear logo as a transparent PNG, saving nothing."""
+ try:
+ raw = _resolve_logo_bytes(req.logo_path, req.logo_b64)
+ except LogoFetchError as exc:
+ return _failed(logger, "fetch that logo from its source", exc, "LOGO_FETCH")
+ if not raw:
+ return error("No logo selected", "NO_LOGO")
+ png, bad = _run_or_error(
+ logger,
+ "process that logo",
+ "LOGO_PROCESS",
+ lambda: process_logo(
+ raw,
+ whiten=req.whiten,
+ flat_white=req.flat_white,
+ logo_3d=req.logo_3d,
+ flip_mask_bytes=_b64_to_bytes(req.flip_b64),
+ erase_mask_bytes=_b64_to_bytes(req.erase_b64),
+ invert=req.invert,
+ )[0],
+ )
+ if bad is not None:
+ return bad
+ return Response(
+ content=png, media_type="image/png", headers={"Cache-Control": "no-store"}
+ )
+
+
+@router.post("/logo-asset-generate", summary="File a clear logo as a - logo asset")
+def logo_asset_generate(
+ req: LogoAssetRequest,
+ background_tasks: BackgroundTasks,
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ """File a processed clear logo as the item's ``- logo.png`` asset."""
+ if (bad := _require_any_id(req)) is not None:
+ return bad
+ cfg = load_config() # outside the guard: a config fault is not a save fault
+ return _save_response(
+ logger,
+ done="Logo asset filed",
+ what="file that logo asset",
+ run=lambda: generate_logo_asset(
+ db=db,
+ full_config=cfg,
+ logger=logger,
+ kind=req.kind,
+ title=req.title,
+ tmdb_id=req.tmdb_id,
+ year=req.year,
+ tvdb_id=req.tvdb_id,
+ imdb_id=req.imdb_id,
+ logo_path=req.logo_path,
+ logo_bytes=_b64_to_bytes(req.logo_b64),
+ whiten=req.whiten,
+ flat_white=req.flat_white,
+ logo_3d=req.logo_3d,
+ invert=req.invert,
+ flip_mask_bytes=_b64_to_bytes(req.flip_b64),
+ erase_mask_bytes=_b64_to_bytes(req.erase_b64),
+ save_local=req.save_local,
+ upload_gdrive=req.upload_gdrive,
+ defer_upload=background_tasks.add_task,
+ ),
+ )
+
+
+@router.post(
+ "/extract-logo",
+ summary="Key a title out of poster art into a transparent logo PNG",
+)
+def extract_logo(
+ req: ExtractLogoRequest,
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+):
+ """Key a title out of poster art and return it as a transparent logo PNG."""
+ if req.image_b64:
+ try:
+ raw = _b64_to_bytes(req.image_b64)
+ except Exception:
+ return error("invalid image data", "BAD_IMAGE")
+ elif req.image_path:
+ try:
+ raw = download_image(req.image_path)
+ except Exception as exc: # disallowed host / fetch failure
+ return _failed(logger, "fetch that poster", exc, "IMAGE_FETCH")
+ else:
+ raw = None
+ if not raw:
+ return error("No poster image provided", "NO_IMAGE")
+ try:
+ mask = _mask_bytes(req.mask_b64)
+ except Exception:
+ return error("invalid mask data", "BAD_MASK")
+ band = {k: v for k, v in (("lo", req.lo), ("hi", req.hi)) if v is not None}
+ if req.mode == "erase":
+ if not mask:
+ return error(
+ "Brush over the title first — the eraser only fills what is masked",
+ "NO_MASK",
+ )
+ cfg, unavailable = _ai_config_or_error(logger, "extract-logo erase")
+ if unavailable:
+ return unavailable
+ try:
+ cleaned = text_removal.remove_text(
+ raw, config=cfg.cl2k_maker, mask_bytes=mask, logger=logger
+ )
+ except Exception as exc:
+ return _failed(logger, "run the AI erase", exc, "CL2K_AI", trace=True)
+ if cleaned == raw:
+ # By value, not identity: remove_text hands the original object back
+ # when the provider bails, but a provider can also echo an equal
+ # copy. Either way diffing keys nothing, so fail here instead of
+ # returning a blank logo that looks like a bad brush.
+ return error("The AI erase returned the poster unchanged", "CL2K_AI")
+ png = extract_logo_by_diff(raw, cleaned, mask, **band)
+ else:
+ extract = extract_subject_logo if req.mode == "subject" else extract_title_logo
+ png = extract(raw, mask, **band)
+ return Response(
+ content=png, media_type="image/png", headers={"Cache-Control": "no-store"}
+ )
+
+
+@router.get("/generated", summary="Recently generated CL2K posters")
+def generated(
+ limit: int = Query(200, ge=1, le=1000),
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ return ok("ok", {"items": cl2k_generated_for(db).list_recent(limit)})
+
+
+@router.post("/psd-export", summary="Export the CL2K poster as a layered .psd")
+def psd_export(
+ req: GenerateRequest,
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+):
+ """Export the poster the preview shows as a layered .psd (for Photopea)."""
+ cfg = load_config() # outside the guard: a config fault is not an export fault
+ blob, bad = _run_or_error(
+ logger,
+ "export that .psd",
+ "PSD_EXPORT",
+ lambda: psd_for_item(
+ db=db,
+ full_config=cfg,
+ logger=logger,
+ kind=req.kind,
+ title=req.title,
+ tmdb_id=req.tmdb_id,
+ backdrop_path=req.backdrop_path,
+ backdrop_bytes=_b64_to_bytes(req.backdrop_b64),
+ logo_path=req.logo_path,
+ custom_logo_bytes=_b64_to_bytes(req.logo_b64),
+ season_number=req.season_number,
+ band_label=req.band_label,
+ logo_scale=req.logo_scale,
+ 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,
+ whiten=req.whiten,
+ flat_white=req.flat_white,
+ logo_3d=req.logo_3d,
+ invert=req.invert,
+ ),
+ )
+ if bad is not None:
+ return bad
+ if blob is None:
+ return error("No textless backdrop available", "NO_BACKDROP")
+ return Response(
+ content=blob,
+ media_type="image/vnd.adobe.photoshop",
+ headers={"Content-Disposition": 'attachment; filename="cl2k.psd"'},
+ )
+
+
+class SeasonsRequest(BaseModel):
+ # Optional, gated by the validator below (tmdb_id or a supplied backdrop) —
+ # a season batch carrying the show's backdrop over needs no tmdb_id.
+ tmdb_id: Optional[int] = None
+ title: str
+ seasons: List[int]
+ year: Optional[int] = None
+ tvdb_id: Optional[int] = None
+ imdb_id: Optional[str] = None
+ # Art carried over from the show poster the user built in the preview, so every
+ # season reuses the SAME backdrop + logo instead of the backend re-resolving a
+ # fresh auto-pick (which produced "a random poster"). Mirrors GenerateRequest:
+ # an uploaded backdrop/logo (``*_b64``) wins over a chosen TMDB/Plex path.
+ backdrop_path: Optional[str] = None
+ backdrop_b64: Optional[str] = None
+ logo_path: Optional[str] = None
+ logo_b64: Optional[str] = None
+ # Framing carried over from the show poster so every season matches it.
+ fit_mode: str = "cover"
+ focus_x: float = 0.5
+ crop_x: Optional[float] = None
+ crop_y: Optional[float] = None
+ crop_w: Optional[float] = None
+ 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)
+ 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)
+ flat_white: bool = False # pure-white silhouette (no keylines); wins over whiten
+ logo_3d: bool = False # extruded art -> flat-white lit face; wins over flat_white
+ invert: bool = False # plate logo -> clearlogo
+ # The logo edits the preview was built with — every season reuses the SAME
+ # logo, so omitting these silently bulk-generated with an unedited one.
+ logo_flip_b64: Optional[str] = None # B/W touch-up regions (mask PNG)
+ logo_erase_b64: Optional[str] = None # erase regions (mask PNG, white=erase)
+ force: bool = False
+ # Save destinations (mirror GenerateRequest): honour the same targets the
+ # single-poster Generate used. upload_gdrive=None falls back to module config.
+ save_local: bool = True
+ upload_gdrive: Optional[bool] = None
+
+ @model_validator(mode="after")
+ def _tmdb_id_or_backdrop(self):
+ """Require a tmdb_id unless a backdrop is supplied (shared rule)."""
+ return _require_tmdb_or_backdrop(self)
+
+
+# ─── Background season-batch jobs ────────────────────────────────────────────
+# Generating a full show's worth of seasons (download + ImageMagick text-removal
+# + render + Drive upload, per season) easily outlasts a reverse-proxy timeout, so
+# the request returned a false failure even though every poster was written. The
+# batch now runs in a daemon thread and the frontend polls /seasons-status.
+#
+# The CL2K maker runs in a single-process uvicorn (see backend/api/server.py), so
+# this in-process registry is shared by the request handlers and the worker
+# thread — no cross-process store needed. Jobs are ephemeral (lost on restart);
+# the posters themselves persist to disk/Drive + cl2k_generated, so a lost status
+# only loses the progress readout, never the work.
+_season_jobs: Dict[int, Dict[str, Any]] = {}
+_season_jobs_lock = threading.Lock()
+_season_job_seq = 0
+
+
+# Keep a small tail of finished jobs so a poll that arrives just after completion
+# still sees the result, without the registry growing unbounded over long uptime.
+_SEASON_JOB_KEEP = 50
+
+
+def _new_season_job(total: int, title: str) -> int:
+ global _season_job_seq
+ with _season_jobs_lock:
+ # Evict the oldest finished jobs once we're over the cap (the just-created
+ # and any still-running jobs are newest, so they're never pruned).
+ if len(_season_jobs) >= _SEASON_JOB_KEEP:
+ finished = [k for k, v in _season_jobs.items() if v["status"] != "running"]
+ for k in sorted(finished)[: len(_season_jobs) - _SEASON_JOB_KEEP + 1]:
+ _season_jobs.pop(k, None)
+ _season_job_seq += 1
+ jid = _season_job_seq
+ _season_jobs[jid] = {
+ "id": jid,
+ "status": "running",
+ "title": title,
+ "total": total,
+ "done": 0,
+ "results": [],
+ "error": None,
+ }
+ return jid
+
+
+def _season_job_snapshot(jid: int) -> Optional[Dict[str, Any]]:
+ """A self-consistent copy — ``results`` is copied too, never aliased.
+
+ A shallow copy left it pointing at the live list the worker appends to, so a
+ poll could serialise more entries than the ``done`` it read a line earlier."""
+ with _season_jobs_lock:
+ job = _season_jobs.get(jid)
+ return {**job, "results": list(job["results"])} if job else None
+
+
+# A show has nowhere near this many seasons; a huge list is a malformed/abusive
+# request, not a real batch. Rejected visibly, never silently truncated.
+_MAX_SEASONS = 60
+
+
+def _clean_seasons(raw) -> List[int]:
+ """Deduped, ordered, non-negative season numbers from a raw request list."""
+ out = set()
+ for n in raw or []:
+ try:
+ v = int(n)
+ except (TypeError, ValueError):
+ continue
+ if v >= 0:
+ out.add(v)
+ return sorted(out)
+
+
+def _spawn_season_job(
+ jid: int, target, args, *, name: str, logger: Any
+) -> Optional[JSONResponse]:
+ """Start the daemon worker; on RuntimeError mark the job errored (so it can't
+ poll 'running' forever) and return a 503, else None."""
+ try:
+ threading.Thread(target=target, args=args, daemon=True, name=name).start()
+ except RuntimeError as exc:
+ logger.error(f"cl2k: could not start {name}: {exc}")
+ with _season_jobs_lock:
+ job = _season_jobs.get(jid)
+ if job is not None:
+ job["status"] = "error"
+ job["error"] = f"the season job could not be started — {_JOB_FAILED}"
+ return error(
+ "could not start the season job", "CL2K_JOB_START", status_code=503
+ )
+ return None
+
+
+def _run_seasons_job(jid: int, db: ChubDB, logger: Any, req: SeasonsRequest) -> None:
+ """Worker body: render every requested season, updating the registry as each
+ completes. Never raises — a crash is recorded as the job's error so the
+ frontend poll terminates instead of spinning on a stuck "running"."""
+
+ def _progress(entry: Dict[str, Any]) -> None:
+ with _season_jobs_lock:
+ job = _season_jobs.get(jid)
+ if job is not None:
+ job["results"].append(entry)
+ job["done"] += 1
+
+ try:
+ # Hoisted out of the loop, not out of the guard: malformed base64 must
+ # fail the job, and re-decoding multi-MB blobs per season is wasted work.
+ backdrop_bytes = _b64_to_bytes(req.backdrop_b64)
+ logo_bytes = _b64_to_bytes(req.logo_b64)
+ logo_flip_bytes = _b64_to_bytes(req.logo_flip_b64)
+ logo_erase_bytes = _b64_to_bytes(req.logo_erase_b64)
+ for n in req.seasons:
+ # Re-read per season: config is REPLACED on reload.
+ # Inside the guard, so a config fault fails the job, not the thread.
+ generate_seasons(
+ db=db,
+ full_config=load_config(),
+ logger=logger,
+ tmdb_id=req.tmdb_id,
+ title=req.title,
+ seasons=[int(n)],
+ year=req.year,
+ tvdb_id=req.tvdb_id,
+ imdb_id=req.imdb_id,
+ backdrop_path=req.backdrop_path,
+ 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,
+ logo_scale=req.logo_scale,
+ logo_y_offset=req.logo_y_offset,
+ whiten=req.whiten,
+ flat_white=req.flat_white,
+ logo_3d=req.logo_3d,
+ invert=req.invert,
+ logo_flip_bytes=logo_flip_bytes,
+ logo_erase_bytes=logo_erase_bytes,
+ force=req.force,
+ save_local=req.save_local,
+ upload_gdrive=req.upload_gdrive,
+ progress_cb=_progress,
+ )
+ with _season_jobs_lock:
+ job = _season_jobs.get(jid)
+ if job is not None:
+ job["status"] = "done"
+ except Exception as exc: # defensive: never leave a job stuck "running"
+ logger.error(f"cl2k: season batch {jid} crashed: {exc}", exc_info=True)
+ with _season_jobs_lock:
+ job = _season_jobs.get(jid)
+ if job is not None:
+ job["status"] = "error"
+ job["error"] = f"the season batch {_JOB_FAILED}"
+
+
+@router.post("/generate-seasons", summary="Start a background CL2K season batch")
+def generate_seasons_endpoint(
+ req: SeasonsRequest,
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ if (bad := _require_any_id(req)) is not None:
+ return bad
+ seasons = _clean_seasons(req.seasons)
+ if not seasons:
+ return error("No seasons requested", "CL2K_NO_SEASONS")
+ if len(seasons) > _MAX_SEASONS:
+ return error(
+ f"Too many seasons ({len(seasons)}); the maximum is {_MAX_SEASONS}",
+ "CL2K_TOO_MANY_SEASONS",
+ )
+ req.seasons = seasons # the worker iterates req.seasons
+ jid = _new_season_job(len(seasons), req.title)
+ if (
+ failed := _spawn_season_job(
+ jid,
+ _run_seasons_job,
+ (jid, db, logger, req),
+ name=f"cl2k-seasons-{jid}",
+ logger=logger,
+ )
+ ) is not None:
+ return failed
+ logger.info(f"cl2k: season batch {jid} started ({len(seasons)} seasons)")
+ return ok("Season generation started", {"job_id": jid, "total": len(seasons)})
+
+
+@router.get("/seasons-status/{job_id}", summary="Progress of a background season batch")
+def seasons_status(job_id: int) -> JSONResponse:
+ """Progress + outcome of a season batch.
+
+ ``status`` stays the LIFECYCLE (running / done / error) — the page treats any
+ other value as "still going" — and escalates to ``error`` only for a finished
+ batch that produced nothing, which used to toast a green "0/N generated".
+ ``failed`` and ``outcome`` (ok | partial | error) carry the finer verdict."""
+ job = _season_job_snapshot(job_id)
+ if job is None:
+ return error("Unknown season job", "CL2K_NO_JOB", status_code=404)
+ results = job["results"]
+ generated = sum(1 for r in results if r.get("status") == "generated")
+ # A skip ("already generated") is an outcome, not a failure.
+ failed = sum(1 for r in results if r.get("status") not in ("generated", "skipped"))
+ status, detail = job["status"], job["error"]
+ if failed:
+ outcome = "partial" if (generated or status == "running") else "error"
+ else:
+ outcome = "error" if status == "error" else "ok"
+ if outcome == "error" and status == "done":
+ status = "error"
+ detail = detail or f"all {failed} of {job['total']} seasons failed"
+ return ok(
+ "Season job status",
+ {
+ "job_id": job["id"],
+ "status": status,
+ "outcome": outcome,
+ "total": job["total"],
+ "done": job["done"],
+ "generated": generated,
+ "failed": failed,
+ "results": results,
+ "error": detail,
+ },
+ )
+
+
+# ─── File-as-is season batch ─────────────────────────────────────────────────
+# The "File as is" output files ONE finished poster, drawing a band label + border
+# (no logo/reframe). This batches that over a list of seasons: the same source
+# image is re-filed once per season with that season's SEASON-N band, reusing the
+# background-job registry + /seasons-status poll the full-CL2K season batch uses.
+
+
+class RetextSeasonsRequest(BaseModel):
+ # Source poster: uploaded bytes (base64) OR a remote path fetched server-side
+ # (mirrors RetextRequest). The same image is reused for every season.
+ image_b64: Optional[str] = None
+ image_path: Optional[str] = None
+ seasons: List[int]
+ title: str = ""
+ tmdb_id: int = 0
+ year: Optional[int] = None
+ tvdb_id: Optional[int] = None
+ imdb_id: Optional[str] = None
+ text_y: Optional[float] = None # band vertical position, 0..1 (None = CL2K band)
+ border: bool = True
+ save_local: bool = True
+ upload_gdrive: Optional[bool] = None
+
+
+def _run_retext_seasons_job(
+ jid: int, db: ChubDB, logger: Any, image_bytes: bytes, req: RetextSeasonsRequest
+) -> None:
+ """Worker body: re-file the one source poster once per season, drawing that
+ season's SEASON-N band. Never raises — a crash is recorded as the job error so
+ the frontend poll terminates instead of spinning on a stuck "running"."""
+ try:
+ for n in req.seasons:
+ n = int(n)
+ # Re-read per season: config is REPLACED on reload.
+ # Inside the guard, so a config fault fails the job, not the thread.
+ full_config = load_config()
+ try:
+ res = retext_poster(
+ db=db,
+ full_config=full_config,
+ logger=logger,
+ image_bytes=image_bytes,
+ apply_ai=False,
+ # No label_text — retext_poster derives the SEASON-N band from
+ # season_number (single source of truth, season_band_text).
+ text_y_frac=req.text_y,
+ save=True,
+ kind="season",
+ title=req.title,
+ tmdb_id=req.tmdb_id,
+ year=req.year,
+ tvdb_id=req.tvdb_id,
+ imdb_id=req.imdb_id,
+ season_number=n,
+ add_border=req.border,
+ save_local=req.save_local,
+ upload_gdrive=req.upload_gdrive,
+ )
+ except Exception as exc: # one bad season must not sink the rest
+ logger.error(f"cl2k: as-is season {n} failed: {exc}", exc_info=True)
+ res = {"status": "error", "reason": f"season {n} {_JOB_FAILED}"}
+ if not isinstance(res, dict):
+ res = {"status": "generated"}
+ with _season_jobs_lock:
+ job = _season_jobs.get(jid)
+ if job is not None:
+ job["results"].append({"season": n, **res})
+ job["done"] += 1
+ with _season_jobs_lock:
+ job = _season_jobs.get(jid)
+ if job is not None:
+ job["status"] = "done"
+ except Exception as exc: # defensive: never leave a job stuck "running"
+ logger.error(f"cl2k: as-is season batch {jid} crashed: {exc}", exc_info=True)
+ with _season_jobs_lock:
+ job = _season_jobs.get(jid)
+ if job is not None:
+ job["status"] = "error"
+ job["error"] = f"the season batch {_JOB_FAILED}"
+
+
+@router.post("/retext-seasons", summary="Start a background File-as-is season batch")
+def retext_seasons_endpoint(
+ req: RetextSeasonsRequest,
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ """Start the background batch that re-files one poster per season, as is."""
+ if (bad := _require_any_id(req)) is not None:
+ return bad
+ seasons = _clean_seasons(req.seasons)
+ if not seasons:
+ return error("No seasons requested", "CL2K_NO_SEASONS")
+ if len(seasons) > _MAX_SEASONS:
+ return error(
+ f"Too many seasons ({len(seasons)}); the maximum is {_MAX_SEASONS}",
+ "CL2K_TOO_MANY_SEASONS",
+ )
+ req.seasons = seasons # the worker iterates req.seasons
+ if req.image_b64:
+ try:
+ image_bytes = base64.b64decode(req.image_b64.split(",")[-1])
+ except Exception:
+ return error("invalid image data", "CL2K_RETEXT")
+ elif req.image_path:
+ try:
+ image_bytes = download_image(req.image_path)
+ except Exception as exc:
+ return _failed(logger, "fetch the source image", exc, "CL2K_RETEXT")
+ else:
+ return error("no image provided", "CL2K_RETEXT")
+ jid = _new_season_job(len(seasons), req.title)
+ if (
+ failed := _spawn_season_job(
+ jid,
+ _run_retext_seasons_job,
+ (jid, db, logger, image_bytes, req),
+ name=f"cl2k-retext-seasons-{jid}",
+ logger=logger,
+ )
+ ) is not None:
+ return failed
+ logger.info(f"cl2k: as-is season batch {jid} started ({len(seasons)} seasons)")
+ return ok("Season generation started", {"job_id": jid, "total": len(seasons)})
+
+
+@router.get("/fanart-images", summary="fanart.tv logo + background for the art picker")
+def fanart_images_endpoint(
+ # tmdb_id optional: fanart.tv keys shows by tvdb_id, so a TVDB-only title
+ # (no TMDB cross-link) can still pull a logo/background from fanart.
+ tmdb_id: Optional[int] = Query(None),
+ media_type: str = Query("movie", alias="type"),
+ tvdb_id: Optional[int] = Query(None),
+ imdb_id: Optional[str] = Query(None),
+ season_number: Optional[int] = Query(None),
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ res = fanart_images(
+ load_config(),
+ db,
+ logger,
+ kind=media_type,
+ tmdb_id=tmdb_id,
+ tvdb_id=tvdb_id,
+ imdb_id=imdb_id,
+ season_number=season_number,
+ )
+ # Shape like /images so the picker can merge sources. fanart returns absolute
+ # URLs; image_fetch.download and the render request accept those as-is, so
+ # file_path == url here.
+ logos = [{"file_path": res["logo"], "url": res["logo"]}] if res.get("logo") else []
+ backdrops = (
+ [{"file_path": res["background"], "url": res["background"]}]
+ if res.get("background")
+ else []
+ )
+ return ok("ok", {"logos": logos, "backdrops": backdrops})
+
+
+@router.get("/plex-images", summary="Plex artwork (logos + backgrounds + posters)")
+def plex_images_endpoint(
+ tmdb_id: Optional[int] = Query(None),
+ media_type: str = Query("movie", alias="type"),
+ tvdb_id: Optional[int] = Query(None),
+ imdb_id: Optional[str] = Query(None),
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ """Read-only Plex artwork for the picker: resolves the item to a ratingKey
+ via the synced plex_media_cache and returns its clearLogos / backgrounds /
+ posters. ``file_path`` is a tokenless Plex URL the backend downloader re-mints
+ the token for; ``url`` routes the browser through /plex-art so the
+ X-Plex-Token never reaches the client. Never writes to Plex, so it can't
+ affect Poster Cleanarr's in-use set."""
+ from backend.util.cl2k.plex_art import plex_images
+
+ res = plex_images(
+ load_config(),
+ db,
+ logger,
+ kind=media_type,
+ tmdb_id=tmdb_id,
+ tvdb_id=tvdb_id,
+ imdb_id=imdb_id,
+ )
+ return ok("ok", res)
+
+
+# The /plex-art proxy only fetches Plex artwork-key paths — never arbitrary Plex
+# endpoints (a leaked src repointed at /:/prefs leaks the PlexOnlineToken).
+def _valid_plex_art_src(src: str) -> bool:
+ """True only for a clean Plex artwork-key URL — the /plex-art proxy guard."""
+ return _is_plex_art_path(src)
+
+
+def _img_media_type(blob: bytes) -> str:
+ """Content-Type from magic bytes so PNG logos (transparency) aren't
+ mislabeled as JPEG."""
+ if blob[:8] == b"\x89PNG\r\n\x1a\n":
+ return "image/png"
+ if blob[:4] == b"RIFF" and blob[8:12] == b"WEBP":
+ return "image/webp"
+ return "image/jpeg"
+
+
+@router.get(
+ "/plex-art",
+ summary="Proxy Plex artwork server-side (X-Plex-Token stays off the browser)",
+)
+def plex_art_proxy(
+ src: str = Query(..., description="Tokenless Plex image URL from /plex-images"),
+ logger: Any = Depends(get_cl2k_logger),
+) -> Response:
+ """Fetch a Plex ARTWORK image server-side and stream the bytes back, so the
+ browser's never carries the user's long-lived X-Plex-Token. ``src`` is
+ constrained to Plex artwork-key paths (_is_plex_art_path) and ``download_image``
+ re-mints the token + SSRF-gates the host, so a stream token minted to load one
+ poster can't be repointed at other Plex endpoints or hosts. Loaded by
+ with a short-lived stream token in the URL (see the manifest's
+ ``stream_prefixes``)."""
+ if not _valid_plex_art_src(src):
+ raise HTTPException(status_code=400, detail="not a Plex artwork URL")
+ try:
+ blob = download_image(src)
+ except Exception as exc:
+ logger.debug(f"cl2k: plex-art proxy fetch failed: {exc}")
+ raise HTTPException(status_code=404, detail="image unavailable") from exc
+ if not blob:
+ raise HTTPException(status_code=404, detail="image unavailable")
+ return Response(
+ content=blob,
+ media_type=_img_media_type(blob),
+ headers={"Cache-Control": "private, max-age=3600"},
+ )
+
+
+class RetextRequest(BaseModel):
+ # The source poster: uploaded bytes (base64) OR a TMDB/fanart/Plex path we
+ # fetch server-side. Prefer the path for remote art — the browser can't fetch
+ # image.tmdb.org directly (no CORS), so the frontend must not base64 it itself.
+ image_b64: Optional[str] = None # uploaded poster (base64; data-URL prefix allowed)
+ image_path: Optional[str] = None # remote art path/URL, fetched via download_image
+ mask_b64: Optional[str] = None # brushed mask over the old text (white=erase)
+ apply_ai: bool = False # run AI text-removal on the masked region
+ prompt: Optional[str] = None # per-edit AI prompt (defaults to ai_prompt)
+ label_text: str = "" # new label to draw in CL2K font (e.g. "SEASON 2026")
+ text_y: Optional[float] = None # label vertical position, 0..1 fraction
+ kind: str = "movie"
+ title: str = ""
+ tmdb_id: int = 0
+ year: Optional[int] = None
+ tvdb_id: Optional[int] = None
+ imdb_id: Optional[str] = None
+ season_number: Optional[int] = None
+ border: bool = True # composite the default 26px white CL2K border
+ preview: bool = False
+ # Skip the 1000x1500 normalize on previews so the AI-erased image keeps its
+ # original dimensions (used when the result feeds the full CL2K render).
+ keep_size: bool = False
+ save_local: bool = True
+ upload_gdrive: Optional[bool] = None
+
+
+@router.post(
+ "/retext", summary="Re-text a finished poster (AI-erase old text + redraw label)"
+)
+def retext(
+ req: RetextRequest,
+ background_tasks: BackgroundTasks,
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ """Re-text a finished poster: AI-erase the old label, draw the new one."""
+ # Only a save needs a matchable filename; a preview returns bytes to the caller.
+ if not req.preview and (bad := _require_any_id(req)) is not None:
+ return bad
+ if req.image_b64:
+ try:
+ image_bytes = base64.b64decode(req.image_b64.split(",")[-1])
+ except Exception:
+ return error("invalid image data", "CL2K_RETEXT")
+ elif req.image_path:
+ try:
+ image_bytes = download_image(req.image_path)
+ except Exception as exc: # disallowed host / fetch failure
+ return _failed(logger, "fetch the source image", exc, "CL2K_RETEXT")
+ else:
+ return error("no image provided", "CL2K_RETEXT")
+ try:
+ mask_bytes = _mask_bytes(req.mask_b64)
+ except Exception:
+ return error("invalid mask data", "CL2K_RETEXT")
+ logger.info(
+ f"CL2K retext: {'preview' if req.preview else 'save'} "
+ f"(apply_ai={req.apply_ai}, mask={'yes' if req.mask_b64 else 'no'}, "
+ f"label={req.label_text!r})"
+ )
+ # Only the user-triggered apply_ai path is gated; the lenient skip for an
+ # unconfigured provider stays in remove_text.
+ if req.apply_ai:
+ cfg, unavailable = _ai_config_or_error(logger, "retext")
+ if unavailable:
+ return unavailable
+ else:
+ cfg = load_config()
+ try:
+ out = retext_poster(
+ db=db,
+ full_config=cfg,
+ logger=logger,
+ image_bytes=image_bytes,
+ mask_bytes=mask_bytes,
+ apply_ai=req.apply_ai,
+ prompt=req.prompt,
+ label_text=req.label_text,
+ text_y_frac=req.text_y,
+ save=not req.preview,
+ kind=req.kind,
+ title=req.title,
+ tmdb_id=req.tmdb_id,
+ year=req.year,
+ tvdb_id=req.tvdb_id,
+ imdb_id=req.imdb_id,
+ season_number=req.season_number,
+ add_border=req.border,
+ keep_size=req.keep_size,
+ save_local=req.save_local,
+ upload_gdrive=req.upload_gdrive,
+ # A save uploads via rclone, which outruns the request timeout on a slow
+ # link. A preview never persists, so it has nothing to defer.
+ defer_upload=None if req.preview else background_tasks.add_task,
+ )
+ except Exception as exc:
+ # Without this, an AI/timeout failure produced a bare 500 with nothing in
+ # the logs — log it and return a readable error to the client instead.
+ return _failed(logger, "re-text that poster", exc, "CL2K_RETEXT", trace=True)
+ if req.preview:
+ return ok("ok", {"preview_b64": base64.b64encode(out).decode()})
+ if isinstance(out, dict) and out.get("status") == "generated":
+ if out.get("upload_pending"):
+ return ok("Poster saved — uploading to Drive", out)
+ return ok("Poster saved", out)
+ reason = (
+ out.get("reason", "retext failed") if isinstance(out, dict) else "retext failed"
+ )
+ return error(reason, "CL2K_RETEXT", data=out if isinstance(out, dict) else None)
+
+
+class DetectTextRequest(BaseModel):
+ # The poster to scan: uploaded bytes (base64) OR a TMDB/fanart/Plex path we
+ # fetch server-side (mirrors RetextRequest — the browser can't fetch
+ # image.tmdb.org itself, no CORS, so remote art must come through the
+ # host-allowlisted downloader).
+ image_b64: Optional[str] = None # base64; data-URL prefix allowed
+ image_path: Optional[str] = None # remote art path/URL, fetched via download_image
+ min_score: float = Field(0.5, ge=0.0, le=1.0) # detector confidence floor
+
+
+@router.post(
+ "/detect-text", summary="Detect text regions on a poster via the LaMa sidecar"
+)
+def detect_text(
+ req: DetectTextRequest,
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ """Proxy the sidecar's /api/v1/detect so the frontend can pre-fill the erase
+ brush: returns its body verbatim — {regions: [polygons], mask: b64 PNG
+ (white=text)}. Sidecar-only; OpenAI has no detection endpoint."""
+ if req.image_b64:
+ try:
+ image_bytes = base64.b64decode(req.image_b64.split(",")[-1])
+ except Exception:
+ return error("invalid image data", "CL2K_DETECT")
+ elif req.image_path:
+ try:
+ image_bytes = download_image(req.image_path)
+ except Exception as exc: # disallowed host / fetch failure
+ return _failed(logger, "fetch the source image", exc, "CL2K_DETECT")
+ else:
+ return error("no image provided", "CL2K_DETECT")
+ full_config, unavailable = _ai_config_or_error(logger, "detect-text")
+ if unavailable:
+ return unavailable
+ cfg = full_config.cl2k_maker
+ if cfg.ai_provider != "lama_sidecar": # openai has no detection endpoint
+ reason = (
+ "Text detection needs the LaMa sidecar provider — "
+ "select it in Module Settings → CL2K Maker."
+ )
+ logger.warning(f"CL2K detect-text: unavailable — {reason}")
+ return error(reason, "CL2K_AI_UNAVAILABLE")
+ import requests
+
+ # Shared route derivation with inpaint/upscale so a custom endpoint path is
+ # honoured the same way for every sidecar route.
+ url = text_removal._lama_route(cfg.ai_endpoint, "/api/v1/detect")
+ try:
+ resp = requests.post(
+ url,
+ json={
+ "image": base64.b64encode(image_bytes).decode(),
+ "min_score": req.min_score,
+ },
+ headers=text_removal._lama_headers(cfg),
+ timeout=text_removal._timeout(cfg),
+ )
+ resp.raise_for_status()
+ body = resp.json()
+ except Exception as exc:
+ # Mirror /retext: a timeout/5xx/older-sidecar 404 comes back readable,
+ # not as a bare 500 with nothing in the logs.
+ return _failed(logger, "detect the text", exc, "CL2K_DETECT", trace=True)
+ return ok("ok", body)
+
+
+class TightenMaskRequest(BaseModel):
+ # The poster to key against: uploaded bytes (base64) OR a remote art path we
+ # fetch server-side (same source rules as /detect-text and /retext).
+ image_b64: Optional[str] = None
+ image_path: Optional[str] = None
+ mask_b64: Optional[str] = None # the brushed BLOCK mask (white = erase)
+ color_tol: float = Field(33.0, ge=5.0, le=120.0) # ΔE76 title-colour band
+
+
+@router.post(
+ "/tighten-mask",
+ summary="Shrink a brushed block erase-mask down to the title glyph strokes",
+)
+def tighten_mask(
+ req: TightenMaskRequest,
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ """Colour-key the brushed region down to the title strokes so the inpainter
+ fills thin gaps (sharp) instead of one big block (blurry). Pure local
+ compute — no AI provider needed. Returns {tightened: bool, mask: b64 PNG |
+ null}; ``tightened=false`` means no lettering could be isolated, so the
+ frontend keeps the user's block."""
+ if req.image_b64:
+ try:
+ raw = _b64_to_bytes(req.image_b64)
+ except Exception:
+ return error("invalid image data", "CL2K_TIGHTEN")
+ elif req.image_path:
+ try:
+ raw = download_image(req.image_path)
+ except Exception as exc: # disallowed host / fetch failure
+ return _failed(logger, "fetch the source image", exc, "CL2K_TIGHTEN")
+ else:
+ raw = None
+ if not raw:
+ return error("no image provided", "CL2K_TIGHTEN")
+ try:
+ mask = _mask_bytes(req.mask_b64)
+ except Exception:
+ return error("invalid mask data", "CL2K_TIGHTEN")
+ if not mask:
+ return error("no mask provided — brush over the text first", "CL2K_TIGHTEN")
+ try:
+ tightened = tighten_text_mask(raw, mask, color_tol=req.color_tol)
+ except Exception as exc:
+ return _failed(logger, "tighten that mask", exc, "CL2K_TIGHTEN", trace=True)
+ if tightened is None:
+ return ok(
+ "kept",
+ {
+ "tightened": False,
+ "mask": None,
+ "reason": "Couldn't isolate the lettering — kept your mask.",
+ },
+ )
+ return ok("ok", {"tightened": True, "mask": base64.b64encode(tightened).decode()})
+
+
+def _probe_png() -> str:
+ """A 32x32 base64 PNG — the cheapest body the sidecar's detect will accept."""
+ from PIL import Image
+
+ buf = io.BytesIO()
+ Image.new("RGB", (32, 32), (0, 0, 0)).save(buf, "PNG")
+ return base64.b64encode(buf.getvalue()).decode()
+
+
+def _test_lama_sidecar(cfg, logger) -> JSONResponse:
+ """Probe the LaMa sidecar's authenticated route and report what it proves."""
+ import requests
+
+ url = text_removal._lama_route(cfg.ai_endpoint, "/api/v1/detect")
+ body = {"image": _probe_png(), "min_score": 0.5}
+ timeout = min(text_removal._timeout(cfg), 30)
+ try:
+ resp = requests.post(
+ url, json=body, headers=text_removal._lama_headers(cfg), timeout=timeout
+ )
+ except Exception as exc:
+ logger.warning(f"cl2k: test-ai sidecar unreachable — {exc}")
+ return error(
+ f"Couldn't reach the sidecar at {cfg.ai_endpoint} — see the CL2K "
+ "Maker log for the reason.",
+ "CL2K_AI_TEST",
+ status_code=503,
+ )
+ if resp.status_code in (401, 403):
+ if not cfg.client_key:
+ # The asymmetric case: container keyed, CHUB not. Every erase 401s
+ # and nothing else reports it, so name it precisely.
+ return error(
+ "The sidecar requires a key but CHUB's Sidecar API Key is empty "
+ "— copy the container's LAMA_API_KEY into it.",
+ "CL2K_AI_TEST",
+ )
+ return error(
+ "The sidecar rejected the key. CHUB's Sidecar API Key and the "
+ "container's LAMA_API_KEY must match exactly.",
+ "CL2K_AI_TEST",
+ )
+ if not resp.ok:
+ return error(
+ f"The sidecar answered HTTP {resp.status_code}.", "CL2K_AI_TEST"
+ )
+ if not cfg.client_key:
+ return ok("Sidecar reachable. No key set — it is accepting anyone on this network.")
+ # A 200 alone can't tell "key accepted" from "key ignored": a keyless sidecar
+ # ignores the header entirely. Re-probe unauthenticated to tell them apart.
+ try:
+ bare = requests.post(url, json=body, timeout=timeout)
+ except Exception as exc:
+ # An exception proves nothing about enforcement. Say what was actually
+ # verified — the key works — and never claim the part that wasn't.
+ logger.warning(f"cl2k: test-ai unauthenticated re-probe failed — {exc}")
+ return ok(
+ "Sidecar reachable and your key works. The follow-up check — whether "
+ "a keyless call gets blocked — could not run; see the CL2K Maker log."
+ )
+ if bare.status_code not in (401, 403):
+ return ok(
+ "Sidecar reachable and your key works — but it also answers WITHOUT "
+ "a key, so LAMA_API_KEY is not set on the container."
+ )
+ return ok("Sidecar reachable, key accepted, and unauthenticated calls are refused.")
+
+
+def _test_openai(cfg, logger) -> JSONResponse:
+ """Probe OpenAI's authenticated route and report whether the key works."""
+ import requests
+
+ try:
+ resp = requests.get(
+ "https://api.openai.com/v1/models",
+ headers={"Authorization": f"Bearer {cfg.api_key}"},
+ timeout=20,
+ )
+ except Exception as exc:
+ return _failed(logger, "reach OpenAI", exc, "CL2K_AI_TEST", status_code=503)
+ if resp.status_code in (401, 403):
+ return error("OpenAI rejected the API key.", "CL2K_AI_TEST")
+ if not resp.ok:
+ return error(f"OpenAI answered HTTP {resp.status_code}.", "CL2K_AI_TEST")
+ return ok("OpenAI reachable and the key is accepted.")
+
+
+@router.post(
+ "/test-ai", summary="Check the configured AI provider is reachable and authenticated"
+)
+def test_ai(
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_cl2k_logger),
+) -> JSONResponse:
+ """Round-trip the provider's AUTHENTICATED route.
+
+ Deliberately not /health: the sidecar leaves that open, so it answers 200
+ with a wrong key or none at all. Credentials are read server-side — the
+ frontend only ever sees them redacted, so it cannot send them back.
+ """
+ cfg, unavailable = _ai_config_or_error(logger, "test-ai")
+ if unavailable:
+ return unavailable
+ maker = cfg.cl2k_maker
+ if maker.ai_provider == "lama_sidecar":
+ return _test_lama_sidecar(maker, logger)
+ return _test_openai(maker, logger)
diff --git a/backend/api/poster_self_heal.py b/backend/api/poster_self_heal.py
new file mode 100644
index 00000000..821a7ee3
--- /dev/null
+++ b/backend/api/poster_self_heal.py
@@ -0,0 +1,185 @@
+"""Poster Self-Heal API.
+
+Powers the review surface for the poster_self_heal module. The scheduled run
+writes proposals into poster_heal_review; these endpoints list them, apply a
+chosen rename (on the user's Drive via rclone + the local source copy), or
+dismiss it.
+
+ GET /api/poster-self-heal/reviews open proposals + pending picks
+ GET /api/poster-self-heal/count open count (for the badge)
+ GET /api/poster-self-heal/coverage save locations the heal scans
+ POST /api/poster-self-heal/reviews/{id}/apply rename on Drive + locally
+ POST /api/poster-self-heal/reviews/{id}/dismiss mark dismissed
+
+Module settings are read/saved through the generic /api/config endpoints.
+"""
+
+import os
+from typing import Any
+
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import JSONResponse
+
+from backend.api.utils import error, get_database, get_module_logger, ok
+from backend.util.config import load_config
+from backend.util.database import ChubDB
+from backend.util.database.poster_heal_review import poster_heal_review_for
+from backend.util.poster_self_heal.apply import apply_proposal
+from backend.util.poster_self_heal.cache_reconcile import drop_stale_row
+
+router = APIRouter(
+ prefix="/api/poster-self-heal",
+ tags=["Poster Self-Heal"],
+ responses={500: {"description": "Internal server error"}},
+)
+
+
+def get_heal_logger(request: Request) -> Any:
+ """Log review/apply activity under the poster_self_heal module log."""
+ return get_module_logger(request, "poster_self_heal")
+
+
+@router.get("/reviews", summary="Open poster-heal proposals")
+def list_reviews(
+ db: ChubDB = Depends(get_database),
+) -> JSONResponse:
+ reviews = poster_heal_review_for(db)
+ return ok("ok", {"reviews": reviews.list_open()})
+
+
+@router.get("/count", summary="Open review count (badge)")
+def review_count(
+ db: ChubDB = Depends(get_database),
+) -> JSONResponse:
+ reviews = poster_heal_review_for(db)
+ return ok("ok", {"count": reviews.open_count()})
+
+
+@router.get("/coverage", summary="Save locations this heal keeps up to date")
+def coverage() -> JSONResponse:
+ """What the next run will actually scan, derived from the live CL2K config.
+
+ Built from the module's own ``local_dirs_for`` / ``drive_twins`` so the panel
+ can't drift from the scan. Note the scope is WIDER than the maker's routing:
+ a location with no claimed ``types`` saves nothing but is still healed, so
+ filtering on ``types`` here would under-report. Config-only — no rclone.
+ """
+ from backend.modules.poster_self_heal import drive_twins, local_dirs_for
+ from backend.util.cl2k.config import CL2K_IMAGE_TYPES
+
+ cfg = load_config()
+ cl2k = getattr(cfg, "cl2k_maker", None)
+ if cl2k is None:
+ return ok("ok", {"available": False, "folders": [], "drives": []})
+
+ scanned = local_dirs_for(cl2k)
+ drive_ids, twin_of = drive_twins(cl2k)
+ # Which types actually rename INTO each Drive — the twin resolution, not the
+ # claim, so a fallback target shows the types it really receives.
+ heals: dict = {}
+ for image_type in CL2K_IMAGE_TYPES:
+ fid = twin_of(image_type)
+ if fid:
+ heals.setdefault(fid, []).append(image_type)
+
+ # One row per PATH, first claimer wins — two rows sharing a path are a single
+ # scanned location, and the panel keys on path.
+ folders = []
+ emitted: set = set()
+ for f in getattr(cl2k, "local_folders", None) or []:
+ path = (getattr(f, "path", "") or "").strip()
+ if path not in scanned or path in emitted:
+ continue
+ emitted.add(path)
+ folders.append(
+ {
+ "name": (getattr(f, "name", "") or "").strip(),
+ "path": path,
+ "types": list(getattr(f, "types", None) or []),
+ }
+ )
+ seen: set = set()
+ drives = []
+ for d in getattr(cl2k, "gdrive_uploads", None) or []:
+ fid = (getattr(d, "folder_id", "") or "").strip()
+ if not fid or fid in seen:
+ continue
+ seen.add(fid)
+ drives.append(
+ {
+ "name": (getattr(d, "name", "") or "").strip(),
+ "folder_id": fid,
+ "types": list(getattr(d, "types", None) or []),
+ "heals_types": heals.get(fid, []),
+ }
+ )
+
+ return ok(
+ "ok",
+ {
+ "available": True,
+ "style": (getattr(cl2k, "style", "") or "CL2K").strip(),
+ "folders": folders,
+ "drives": drives,
+ "unrouted_types": [t for t in CL2K_IMAGE_TYPES if not twin_of(t)],
+ "scanned_count": len(scanned),
+ "drive_count": len(drive_ids),
+ },
+ )
+
+
+@router.post("/reviews/{review_id}/dismiss", summary="Dismiss a proposal")
+def dismiss_review(
+ review_id: int,
+ db: ChubDB = Depends(get_database),
+) -> JSONResponse:
+ reviews = poster_heal_review_for(db)
+ if not reviews.get(review_id):
+ return error("Review not found", "NOT_FOUND", status_code=404)
+ reviews.set_status(review_id, "dismissed")
+ return ok("Dismissed")
+
+
+@router.post("/reviews/{review_id}/apply", summary="Apply a proposed rename")
+def apply_review(
+ review_id: int,
+ db: ChubDB = Depends(get_database),
+ logger: Any = Depends(get_heal_logger),
+) -> JSONResponse:
+ """Apply one proposed rename on Drive + locally, then close the review."""
+ reviews = poster_heal_review_for(db)
+ row = reviews.get(review_id)
+ if not row:
+ return error("Review not found", "NOT_FOUND", status_code=404)
+ # "failed" is retryable on purpose — an auto-apply that raised is surfaced for
+ # exactly this, and the cause is often transient (Drive listing, token).
+ if row.get("status") not in ("proposed", "failed"):
+ return error("Only an open proposal can be applied", "BAD_STATUS")
+
+ current = row.get("current_filename") or ""
+ proposed = row.get("proposed_filename") or ""
+ if not proposed or proposed == current or row.get("drift_type") == "ambiguous":
+ return error(
+ "This item needs a manual pick — there is no single rename to apply",
+ "NO_PROPOSAL",
+ )
+
+ # Outside the guard: a config fault is not a rename fault — it belongs to
+ # main.py's ConfigError handler (500 CONFIG_INVALID), not a 400 DRIVE_RENAME.
+ sync_cfg = load_config().sync_gdrive
+ try:
+ note = apply_proposal(row, sync_cfg, logger)
+ except Exception as exc:
+ # rclone errors quote paths and Drive ids — keep them out of the response.
+ logger.error(f"poster-self-heal: Drive rename failed: {exc}", exc_info=True)
+ return error(
+ "Google Drive rename failed — see the Poster Self-Heal log for the reason.",
+ "DRIVE_RENAME",
+ )
+
+ reviews.set_status(review_id, "applied")
+ # Drop the row the rename invalidated — same as the scheduled run.
+ stale = row.get("poster_file") or ""
+ if os.path.isabs(stale):
+ drop_stale_row(db, stale, logger)
+ return ok(f"Applied{note}", {"new_filename": proposed})
diff --git a/backend/api/system.py b/backend/api/system.py
index 191354bc..858d863e 100755
--- a/backend/api/system.py
+++ b/backend/api/system.py
@@ -76,7 +76,10 @@ class FolderCreationRequest(BaseModel):
"example": {
"success": True,
"message": "Version retrieved",
- "data": {"version": "3.0.0-alpha"},
+ "data": {
+ "version": "3.0.0-alpha",
+ "extensions": ["cl2k"],
+ },
}
}
},
@@ -91,9 +94,14 @@ async def get_version_endpoint(logger: Any = Depends(get_logger)) -> JSONRespons
in the UI and for API client compatibility checks.
"""
try:
+ from backend.extensions import enabled_extensions
+
version = get_version()
logger.debug(f"Serving GET /api/version: {version}")
- return ok("Version retrieved", {"version": version})
+ return ok(
+ "Version retrieved",
+ {"version": version, "extensions": enabled_extensions()},
+ )
except Exception as e:
logger.error(f"Error getting version: {e}")
return error(
diff --git a/backend/assets/cl2k/gradient.png b/backend/assets/cl2k/gradient.png
new file mode 100644
index 00000000..4e2f8630
Binary files /dev/null and b/backend/assets/cl2k/gradient.png differ
diff --git a/backend/assets/cl2k/inner_glow.png b/backend/assets/cl2k/inner_glow.png
new file mode 100644
index 00000000..560ff911
Binary files /dev/null and b/backend/assets/cl2k/inner_glow.png differ
diff --git a/backend/assets/cl2k/label_tysh.bin b/backend/assets/cl2k/label_tysh.bin
new file mode 100644
index 00000000..10a51027
Binary files /dev/null and b/backend/assets/cl2k/label_tysh.bin differ
diff --git a/backend/extensions/__init__.py b/backend/extensions/__init__.py
index aecbef71..6885bf49 100644
--- a/backend/extensions/__init__.py
+++ b/backend/extensions/__init__.py
@@ -25,16 +25,23 @@
modules such as backend.util.config are still initialising, so a
module-level import of anything heavyweight risks a circular import.
-On a branch with no extension subpackages (e.g. main) every aggregate
-below returns empty, and the callers behave exactly as if this package
-did not exist.
+Functional hooks (routers, modules, stream_prefixes,
+notification_formatters) are gated: the lean image pins
+CHUB_IMAGE_FLAVOR=lean, which turns them all off, and a manifest may
+define ``available() -> bool`` for its own dependency check. Data hooks
+(config_fields, tables) ALWAYS apply, so a config or database written
+under the :full image stays typed and intact under :latest.
"""
import importlib
+import os
import pkgutil
from functools import lru_cache
from typing import Any, Dict, List, Tuple
+# Hooks that shape persisted data; never gated (see module docstring).
+_DATA_HOOKS = frozenset({"config_fields", "tables"})
+
@lru_cache(maxsize=1)
def _manifests() -> Tuple[Any, ...]:
@@ -46,15 +53,42 @@ def _manifests() -> Tuple[Any, ...]:
return tuple(manifests)
+def _flavor_allows_extensions() -> bool:
+ """False only on the lean image (CHUB_IMAGE_FLAVOR=lean)."""
+ return os.environ.get("CHUB_IMAGE_FLAVOR", "") != "lean"
+
+
+def _enabled(manifest: Any) -> bool:
+ """A manifest without available() is unconditionally enabled."""
+ fn = getattr(manifest, "available", None)
+ return bool(fn()) if callable(fn) else True
+
+
def _collect(hook: str) -> List[Any]:
results = []
+ functional = hook not in _DATA_HOOKS
+ if functional and not _flavor_allows_extensions():
+ return results
for manifest in _manifests():
+ if functional and not _enabled(manifest):
+ continue
fn = getattr(manifest, hook, None)
if callable(fn):
results.append(fn())
return results
+def enabled_extensions() -> List[str]:
+ """Names whose functional hooks are live — what the frontend may show."""
+ if not _flavor_allows_extensions():
+ return []
+ return [
+ manifest.__name__.split(".")[-2]
+ for manifest in _manifests()
+ if _enabled(manifest)
+ ]
+
+
def extension_routers() -> List[Any]:
return [router for routers in _collect("routers") for router in routers]
diff --git a/backend/extensions/cl2k/__init__.py b/backend/extensions/cl2k/__init__.py
new file mode 100644
index 00000000..099c7fda
--- /dev/null
+++ b/backend/extensions/cl2k/__init__.py
@@ -0,0 +1,3 @@
+# backend/extensions/cl2k/__init__.py
+# CL2K poster maker — ships in the :full image. Registration: manifest.py;
+# implementation: backend/util/cl2k/, backend/api/cl2k_maker.py.
diff --git a/backend/extensions/cl2k/manifest.py b/backend/extensions/cl2k/manifest.py
new file mode 100644
index 00000000..64f28801
--- /dev/null
+++ b/backend/extensions/cl2k/manifest.py
@@ -0,0 +1,52 @@
+# backend/extensions/cl2k/manifest.py
+"""Self-registration manifest for the CL2K poster maker.
+
+Discovered by backend/extensions/__init__.py. Every hook imports its
+payload lazily — manifests are imported while core modules (config,
+schema) are still initialising, so module-level imports here would risk
+circular imports.
+"""
+
+
+def available():
+ """True only when the renderer's deps import — find_spec can't tell a
+ pip-present wand from one whose libMagickWand is missing."""
+ try:
+ import psd_tools # noqa: F401
+ import wand.image # noqa: F401
+ except ImportError:
+ return False
+ return True
+
+
+def routers():
+ from backend.api.cl2k_maker import router
+
+ return [router]
+
+
+# No modules() hook: the CL2K maker is config-only — generation is on-demand from
+# the maker page (via the API), so there is no batch run to register, schedule, or
+# surface in Jobs. Its config page comes from config_fields() below.
+
+
+def config_fields():
+ from pydantic import Field
+
+ from backend.util.cl2k.config import Cl2kMakerConfig
+
+ return {
+ "cl2k_maker": (Cl2kMakerConfig, Field(default_factory=Cl2kMakerConfig)),
+ }
+
+
+def tables():
+ from backend.util.database.cl2k_generated import cl2k_generated_table
+
+ return [cl2k_generated_table()]
+
+
+def stream_prefixes():
+ # The /plex-art proxy is loaded by , which can only authenticate with a
+ # short-lived stream token in the URL — so its route joins the allowlist.
+ return ("/api/cl2k-maker/plex-art",)
diff --git a/backend/extensions/poster_self_heal/__init__.py b/backend/extensions/poster_self_heal/__init__.py
new file mode 100644
index 00000000..8a32010a
--- /dev/null
+++ b/backend/extensions/poster_self_heal/__init__.py
@@ -0,0 +1,12 @@
+# backend/extensions/poster_self_heal/__init__.py
+"""Poster Self-Heal — ':full'-image extension.
+
+Keeps the user's CL2K poster drive current: re-resolves each generated poster
+against TMDB and proposes rewriting a stale embedded id, a changed title, or a
+missing id. Proposals are applied only after manual review (rclone moveto on the
+user's Drive via cl2k.gdrive_upload, os.replace for the local source copy).
+
+Operates on the CL2K maker's own output: it reads ``cl2k_maker.local_folders``
+and ``cl2k_maker.gdrive_uploads`` from the loaded config rather than defining
+its own source.
+"""
diff --git a/backend/extensions/poster_self_heal/manifest.py b/backend/extensions/poster_self_heal/manifest.py
new file mode 100644
index 00000000..91a6d093
--- /dev/null
+++ b/backend/extensions/poster_self_heal/manifest.py
@@ -0,0 +1,46 @@
+# backend/extensions/poster_self_heal/manifest.py
+"""Self-registration manifest for the poster_self_heal extension.
+
+Discovered by backend/extensions/__init__.py. Every hook imports its payload
+lazily — manifests are imported while core modules (config, schema) are still
+initialising, so module-level imports here would risk circular imports.
+"""
+
+
+def routers():
+ from backend.api.poster_self_heal import router
+
+ return [router]
+
+
+def modules():
+ from backend.modules.poster_self_heal import PosterSelfHeal
+
+ return {"poster_self_heal": PosterSelfHeal}
+
+
+def config_fields():
+ from pydantic import Field
+
+ from backend.util.poster_self_heal.config import PosterSelfHealConfig
+
+ return {
+ "poster_self_heal": (
+ PosterSelfHealConfig,
+ Field(default_factory=PosterSelfHealConfig),
+ ),
+ }
+
+
+def tables():
+ from backend.util.database.poster_heal_review import poster_heal_review_table
+
+ return [poster_heal_review_table()]
+
+
+def notification_formatters():
+ from backend.util.poster_self_heal.notify import format_poster_self_heal
+
+ return {
+ "poster_self_heal": {"formatter": format_poster_self_heal, "type": "embedded"},
+ }
diff --git a/backend/modules/cl2k_maker.py b/backend/modules/cl2k_maker.py
new file mode 100644
index 00000000..4a44bbe6
--- /dev/null
+++ b/backend/modules/cl2k_maker.py
@@ -0,0 +1,1621 @@
+# modules/cl2k_maker.py
+
+import os
+import shutil
+import tempfile
+from typing import Any, Callable, Dict, List, Optional, Tuple
+
+from backend.util.cl2k import color
+from backend.util.cl2k import geometry as geo
+from backend.util.cl2k import image_fetch, text_removal
+from backend.util.cl2k.naming import build_poster_filename
+from backend.util.cl2k import renderer
+from backend.util.cl2k.renderer import logo_is_usable, render_cl2k
+from backend.util.cl2k.tmdb_art import list_images
+from backend.util.database import ChubDB
+from backend.util.database.cl2k_generated import cl2k_generated_for
+from backend.util.fanart import FanartClient
+from backend.util.normalization import normalize_titles
+from backend.util.tmdb import TMDBClient
+
+_VALID_KINDS = ("movie", "show", "collection", "season")
+
+# Prompt for the "extend" framing's AI outpaint. The model must *continue the
+# existing scene downward* — never invent text, logos, or new subjects (those would
+# clash with the CL2K logo/label drawn on top).
+_EXTEND_PROMPT = (
+ "Naturally extend and continue this image downward to fill the empty lower area, "
+ "matching the existing background, colours, lighting and grain. Do not add any "
+ "text, logos, watermarks, borders, or new people or objects."
+)
+
+
+def _fanart_logo(
+ full_config, db, logger, *, kind, tmdb_id, tvdb_id, imdb_id, season_number, lang
+) -> Optional[str]:
+ """Look up a clear-logo URL on fanart.tv (the second logo source). None on miss."""
+ try:
+ asset_type = "movie" if kind in ("movie", "collection") else "show"
+ client = FanartClient(full_config.fanart, db, logger)
+ res = client.get_images(
+ {
+ "asset_type": asset_type,
+ "tmdb_id": tmdb_id,
+ "tvdb_id": tvdb_id,
+ "imdb_id": imdb_id,
+ "season_number": season_number,
+ },
+ language=lang,
+ )
+ return (res or {}).get("logo")
+ except Exception as exc: # fanart is a best-effort fallback, never fatal
+ logger.debug(f"fanart logo lookup failed: {exc}")
+ return None
+
+
+def _backfill_title_year(
+ full_config,
+ db,
+ logger,
+ *,
+ kind: str,
+ tmdb_id: Optional[int],
+ title: str,
+ year: Optional[int],
+ tvdb_id: Optional[int] = None,
+ imdb_id: Optional[str] = None,
+) -> Tuple[str, Optional[int]]:
+ """Fill a blank title/year from TMDB before naming/rendering.
+
+ Items added by id paste or the Edit-IDs panel arrive with no title (the UI
+ only resolves the ids), which would reduce the DAPS filename to bare id tags
+ (``{tmdb-N}.jpg``) and draw an empty text-wordmark fallback. When the title is
+ blank we resolve a usable TMDB id in order — the supplied ``tmdb_id``, else the
+ ``tvdb_id``, else the ``imdb_id`` (TVDB/IMDB matched via
+ :meth:`find_tmdb_id`) — then read the canonical title/year from
+ :meth:`get_details`. A TVDB/IMDB-only title with no TMDB entry keeps whatever
+ the user typed in Edit IDs (worst case: bare id tags). Best-effort and cached;
+ a transient TMDB failure leaves the originals untouched. Collections carry
+ their own title and are skipped.
+ """
+ if kind not in ("movie", "show", "season"):
+ return title, year
+ if (title or "").strip():
+ return title, year
+ mt = "movie" if kind == "movie" else "tv"
+ try:
+ tmdb = TMDBClient(full_config.tmdb, db, logger)
+ # Resolve a usable tmdb id, falling back TVDB → IMDB when none is given.
+ resolved = tmdb_id or None
+ if not resolved and tvdb_id:
+ resolved = tmdb.find_tmdb_id(str(tvdb_id), "tvdb_id", mt)
+ if not resolved and imdb_id:
+ resolved = tmdb.find_tmdb_id(str(imdb_id), "imdb_id", mt)
+ if resolved:
+ details = tmdb.get_details(resolved, mt)
+ if details:
+ title = details.get("title") or title
+ if year is None:
+ year = details.get("year")
+ except Exception as exc: # never block a save on a metadata lookup
+ logger.warning(f"cl2k: title backfill failed (tmdb={tmdb_id}): {exc}")
+ return title, year
+
+
+# CL2K season bands spell the number out ("SEASON ONE", not "SEASON 1"), matching
+# the template convention. Year-numbered seasons stay as digits, though — Formula 1
+# "Season 2026" reads "SEASON 2026", not "SEASON TWO THOUSAND…". A real season count
+# never reaches four digits, so anything >= 1000 is treated as a year and kept as
+# digits (which also serves as the always-produce-a-label fallback).
+_ONES = (
+ "zero one two three four five six seven eight nine ten eleven twelve thirteen "
+ "fourteen fifteen sixteen seventeen eighteen nineteen"
+).split()
+_TENS = (
+ "",
+ "",
+ "twenty",
+ "thirty",
+ "forty",
+ "fifty",
+ "sixty",
+ "seventy",
+ "eighty",
+ "ninety",
+)
+
+
+def _number_to_words(n: int) -> str:
+ """Cardinal number as English words: 1 -> 'one', 21 -> 'twenty-one'."""
+ if not isinstance(n, int) or n < 0 or n >= 1000:
+ return str(n)
+ if n < 20:
+ return _ONES[n]
+ if n < 100:
+ tens, ones = divmod(n, 10)
+ return _TENS[tens] + (f"-{_ONES[ones]}" if ones else "")
+ hundreds, rem = divmod(n, 100)
+ return f"{_ONES[hundreds]} hundred" + (f" {_number_to_words(rem)}" if rem else "")
+
+
+def season_band_text(season_number: int) -> str:
+ """The CL2K season band label for a season number. Season 0 is the Specials
+ season — "Specials" (matching the `- Specials` filename in naming.py), not
+ "Season 0". Other seasons spell the number out per the template ("SEASON ONE",
+ not "SEASON 1"); year-numbered seasons stay as digits (see _number_to_words).
+ The renderer uppercases this."""
+ return (
+ "Specials"
+ if season_number == 0
+ else f"Season {_number_to_words(season_number)}"
+ )
+
+
+def _resolve_default_art(
+ tmdb,
+ tmdb_id: int,
+ kind: str,
+ lang: str,
+ backdrop_path: Optional[str],
+ logo_path: Optional[str],
+ *,
+ need_backdrop: bool = True,
+ need_logo: bool = True,
+) -> Tuple[Optional[str], Optional[str]]:
+ """Fill a default backdrop/logo path from TMDB for whichever is still unset.
+
+ The ONE place CL2K auto-picks art, shared by the render path and the PSD export
+ so they can never drift to different pictures. A caller that already holds
+ uploaded bytes for one input passes ``need_*=False`` to leave it alone. A single
+ list_images call covers both. Returns the (possibly filled) ``(backdrop, logo)``.
+ """
+ if (need_backdrop and backdrop_path is None) or (need_logo and logo_path is None):
+ images = list_images(tmdb, tmdb_id, kind, languages=lang) or {}
+ sel = image_fetch.select_cl2k_inputs(images, lang=lang)
+ if need_backdrop and backdrop_path is None:
+ backdrop_path = sel.get("backdrop")
+ if need_logo and logo_path is None:
+ logo_path = sel.get("logo")
+ return backdrop_path, logo_path
+
+
+def _resolve_and_render(
+ db: ChubDB,
+ full_config,
+ logger,
+ *,
+ kind: str,
+ title: str,
+ tmdb_id: int,
+ season_number: Optional[int] = None,
+ season_text: str = "",
+ backdrop_path: Optional[str] = None,
+ logo_path: Optional[str] = None,
+ custom_logo_bytes: Optional[bytes] = None,
+ tvdb_id: Optional[int] = None,
+ imdb_id: Optional[str] = None,
+ 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,
+ band_label: str = "",
+ logo_scale: float = 1.0,
+ 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)
+ 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
+ invert: bool = False, # plate logo -> clearlogo (white->transparent, black->white)
+ allow_ai_extend: bool = True,
+ place_logo: bool = True,
+) -> Tuple[Optional[bytes], Dict[str, Any]]:
+ """Resolve art (textless backdrop + logo) and render.
+
+ The logo source chain is: ``custom_logo_bytes`` (an uploaded PNG, used as-is)
+ -> ``logo_path`` (a chosen TMDB/fanart logo) -> auto TMDB -> fanart.tv ->
+ generated text wordmark. Returns ``(jpeg_bytes, info)``; ``jpeg_bytes`` is None
+ with ``info['reason']`` set when no textless backdrop is available. Shared by
+ the preview endpoint and :func:`generate_for_item`.
+ """
+ cfg = full_config.cl2k_maker
+ lang = cfg.language or "en"
+ tmdb = TMDBClient(full_config.tmdb, db, logger)
+
+ # Season reuse: a new season inherits the show's existing backdrop (DAPS:
+ # same background across seasons, only the season number changes).
+ if kind == "season" and backdrop_path is None and backdrop_bytes is None:
+ backdrop_path = cl2k_generated_for(db).get_backdrop_for(tmdb_id)
+
+ # Was the logo auto-sourced (no upload, no chosen path)? Captured BEFORE
+ # resolution because the small-logo drop below applies only to auto picks —
+ # a user's explicit choice is always honoured.
+ logo_auto_sourced = custom_logo_bytes is None and logo_path is None
+
+ # Fill a default backdrop/logo from TMDB for anything still unset. Uploaded
+ # bytes (manual-handoff backdrop, custom logo) mean "don't auto-resolve this".
+ backdrop_path, logo_path = _resolve_default_art(
+ tmdb,
+ tmdb_id,
+ kind,
+ lang,
+ backdrop_path,
+ logo_path,
+ need_backdrop=backdrop_bytes is None,
+ need_logo=custom_logo_bytes is None,
+ )
+
+ if backdrop_bytes is None:
+ if not backdrop_path:
+ return None, {
+ "reason": "no textless backdrop available",
+ "logo_source": "none",
+ }
+ backdrop_bytes = image_fetch.download(backdrop_path)
+
+ # EXTEND framing: keep the subjects full-size (fit to width, top-anchored) and
+ # AI-outpaint the empty bottom band — the artist's "extend the bottom, crop the
+ # wasted top" trick. Falls back to the free edge-extend fit when no AI provider
+ # is configured (or the photo already fills the canvas, so there's nothing to
+ # 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":
+ canvas_bytes, extend_mask = renderer.fit_extend_canvas(
+ backdrop_bytes, crop, zoom=zoom, v_pos=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
+ # with a missing endpoint/key silently passes the canvas through, and
+ # covering that would bake the black band into the saved poster. Previews
+ # (allow_ai_extend=False) and unconfigured renders fall back to the free
+ # edge-extend fit, so we never spend AI on every preview.
+ ai_ready = allow_ai_extend and text_removal.unavailable_reason(cfg) is None
+ extended = None
+ if extend_mask is not None and ai_ready:
+ extended = text_removal.remove_text(
+ canvas_bytes,
+ config=cfg,
+ mask_bytes=extend_mask,
+ prompt=_EXTEND_PROMPT,
+ logger=logger,
+ )
+ if extended is not None and extended is not canvas_bytes:
+ backdrop_bytes = extended
+ # Identity framing, not just "cover": the AI canvas is already
+ # 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
+ else:
+ if extend_mask is not None and logger:
+ reason = (
+ "preview"
+ if not allow_ai_extend
+ else (
+ text_removal.unavailable_reason(cfg)
+ or "AI returned the canvas unchanged"
+ )
+ )
+ logger.info(
+ f"cl2k: extend — {reason}; using the free edge-extend fit instead"
+ )
+ 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.
+ if apply_ai or mask_bytes:
+ backdrop_bytes = text_removal.remove_text(
+ backdrop_bytes, config=cfg, mask_bytes=mask_bytes, logger=logger
+ )
+
+ logo_bytes = None
+ logo_source = "text" if cfg.text_logo_fallback else "none"
+ if custom_logo_bytes is not None:
+ logo_bytes = custom_logo_bytes
+ logo_source = "custom"
+ elif logo_path:
+ logo_bytes = image_fetch.download(logo_path)
+ logo_source = "tmdb"
+ else:
+ fa_url = _fanart_logo(
+ full_config,
+ db,
+ logger,
+ kind=kind,
+ tmdb_id=tmdb_id,
+ tvdb_id=tvdb_id,
+ imdb_id=imdb_id,
+ season_number=season_number,
+ lang=lang,
+ )
+ if fa_url:
+ logo_bytes = image_fetch.download(fa_url)
+ logo_source = "fanart"
+
+ # CL2K rule: a clear logo too small to render crisply at the ~600px box is
+ # worse than drawn title text. Reject low-res logos the maker chose ITSELF
+ # (the auto TMDB/fanart fallback, ``need_logo``) so render_cl2k's title-text
+ # fallback takes over. A logo the user picked in the art picker — like a
+ # custom upload — is their explicit choice and is kept as-is: the preview
+ # always shows it, so dropping it on save was a silent surprise (the small
+ # "The Tiny Chef Show" wordmarks are the canonical case). They can size it
+ # with the logo_scale slider if it's soft.
+ if (
+ logo_bytes
+ and logo_auto_sourced
+ and logo_source in ("tmdb", "fanart")
+ and not logo_is_usable(logo_bytes)
+ ):
+ # Rescue a too-small real logo via the sidecar's super-resolution before
+ # surrendering to the typeset wordmark; best-effort — upscale_image
+ # returns None on any failure (older sidecar, timeout, misconfig) and we
+ # fall back exactly as before.
+ rescued = None
+ if getattr(cfg, "ai_logo_upscale", True):
+ rescued = text_removal.upscale_image(logo_bytes, cfg, logger=logger)
+ if rescued and logo_is_usable(rescued):
+ logger.debug(
+ f"cl2k: {logo_source} logo too small — upscaled via the sidecar"
+ )
+ logo_bytes = rescued
+ else:
+ logger.debug(
+ f"cl2k: {logo_source} logo too small for the logo box — using title text"
+ )
+ logo_bytes = None
+ logo_source = "text" if cfg.text_logo_fallback else "none"
+
+ if kind == "season" and not season_text and season_number is not None:
+ season_text = season_band_text(season_number)
+
+ blob = render_cl2k(
+ backdrop_bytes=backdrop_bytes,
+ kind=kind,
+ logo_bytes=logo_bytes,
+ title=title if (cfg.text_logo_fallback or logo_bytes) else "",
+ season_text=season_text,
+ logo_scale=logo_scale,
+ logo_y_offset=logo_y_offset,
+ logo_flip_bytes=logo_flip_bytes,
+ logo_erase_bytes=logo_erase_bytes,
+ whiten=cfg.whiten_logo if whiten is None else whiten,
+ 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,
+ band_label=band_label,
+ place_logo=place_logo,
+ text_logo_stroke=cfg.text_logo_stroke,
+ )
+ return blob, {"backdrop_path": backdrop_path, "logo_source": logo_source}
+
+
+def render_preview(db: ChubDB, full_config, logger, **kwargs) -> Optional[bytes]:
+ """Render a CL2K poster to JPEG bytes WITHOUT saving (live preview).
+
+ Previews never run the AI outpaint (``extend`` falls back to the free
+ edge-extend fit) so a live preview is fast and free; the AI fill is applied
+ only on a real generate.
+ """
+ kwargs.setdefault("allow_ai_extend", False)
+ blob, _info = _resolve_and_render(db, full_config, logger, **kwargs)
+ return blob
+
+
+def generate_for_item(
+ *,
+ db: ChubDB,
+ full_config,
+ logger,
+ kind: str,
+ title: str,
+ tmdb_id: int,
+ year: Optional[int] = None,
+ tvdb_id: Optional[int] = None,
+ imdb_id: Optional[str] = None,
+ season_number: Optional[int] = None,
+ season_text: str = "",
+ backdrop_path: Optional[str] = None,
+ logo_path: Optional[str] = None,
+ custom_logo_bytes: Optional[bytes] = None,
+ 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,
+ band_label: str = "",
+ logo_scale: float = 1.0,
+ 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)
+ 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
+ invert: bool = False, # plate logo -> clearlogo (white->transparent, black->white)
+ force: bool = False,
+ save_local: bool = True,
+ upload_gdrive: Optional[bool] = None,
+ defer_upload: Optional[Callable[[Callable[[], None]], None]] = None,
+) -> Dict[str, Any]:
+ """Render + name + write to the selected destinations + provenance.
+
+ Shared core for the API (on-demand) and run() (batch). ``save_local`` /
+ ``upload_gdrive`` choose the destination(s) (see :func:`_persist_poster`).
+ Returns ``{status, file?, reason?, logo_source?}``.
+ """
+ cfg = full_config.cl2k_maker
+ kind = (kind or "").lower()
+ if kind not in _VALID_KINDS:
+ return {"status": "error", "reason": f"invalid kind {kind!r}"}
+ title, year = _backfill_title_year(
+ full_config,
+ db,
+ logger,
+ kind=kind,
+ tmdb_id=tmdb_id,
+ title=title,
+ year=year,
+ tvdb_id=tvdb_id,
+ imdb_id=imdb_id,
+ )
+
+ if (
+ cfg.skip_existing
+ and not force
+ and cl2k_generated_for(db).exists_for(kind, tmdb_id, season_number)
+ ):
+ return {"status": "skipped", "reason": "already generated"}
+
+ # AI removal requested but the provider is unusable: remove_text would pass the
+ # art through un-erased and we'd save it as "generated" — refuse (as /retext does).
+ if apply_ai or mask_bytes:
+ reason = text_removal.unavailable_reason(cfg)
+ if reason:
+ return {"status": "error", "reason": reason}
+
+ blob, info = _resolve_and_render(
+ db,
+ full_config,
+ logger,
+ kind=kind,
+ title=title,
+ tmdb_id=tmdb_id,
+ season_number=season_number,
+ season_text=season_text,
+ backdrop_path=backdrop_path,
+ logo_path=logo_path,
+ custom_logo_bytes=custom_logo_bytes,
+ tvdb_id=tvdb_id,
+ imdb_id=imdb_id,
+ 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,
+ band_label=band_label,
+ logo_scale=logo_scale,
+ logo_y_offset=logo_y_offset,
+ logo_flip_bytes=logo_flip_bytes,
+ logo_erase_bytes=logo_erase_bytes,
+ whiten=whiten,
+ flat_white=flat_white,
+ logo_3d=logo_3d,
+ invert=invert,
+ )
+ if blob is None:
+ return {"status": "skipped", "reason": info.get("reason", "render failed")}
+ logo_source = info.get("logo_source", "none")
+
+ return _persist_poster(
+ db,
+ cfg,
+ logger,
+ sync_cfg=full_config.sync_gdrive,
+ blob=blob,
+ kind=kind,
+ title=title,
+ year=year,
+ tmdb_id=tmdb_id,
+ tvdb_id=tvdb_id,
+ imdb_id=imdb_id,
+ season_number=season_number,
+ backdrop_path=info.get("backdrop_path"),
+ logo_source=logo_source,
+ save_local=save_local,
+ upload_gdrive=upload_gdrive,
+ defer_upload=defer_upload,
+ full_config=full_config,
+ )
+
+
+def generate_square_art(
+ *,
+ db: ChubDB,
+ full_config,
+ logger,
+ kind: str,
+ title: str,
+ tmdb_id: int,
+ year: Optional[int] = None,
+ tvdb_id: Optional[int] = None,
+ 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,
+ season_number: Optional[int] = None,
+ save_local: bool = True,
+ upload_gdrive: Optional[bool] = None,
+ defer_upload: Optional[Callable[[Callable[[], None]], None]] = None,
+) -> Dict[str, Any]:
+ """Render + file 1:1 square art (``- squareart.jpg``) for a media item.
+
+ Plain cropped artwork (no logo/gradient), filed into poster_cache as
+ ``squareart`` so asset_renamerr applies it to Plex (uploadSquareArt). Always
+ overwrites — a deliberate manual action. ``season_number`` files the art for
+ one season of a show (``… - Season NN - squareart.jpg``; plexapi seasons
+ accept square art) instead of the show itself.
+ """
+ cfg = full_config.cl2k_maker
+ kind = (kind or "").lower()
+ if kind not in _VALID_KINDS:
+ return {"status": "error", "reason": f"invalid kind {kind!r}"}
+ if season_number is not None and kind == "show":
+ kind = "season" # season-suffixed naming; backfill/lookup stays TV-side
+ title, year = _backfill_title_year(
+ full_config,
+ db,
+ logger,
+ kind=kind,
+ tmdb_id=tmdb_id,
+ title=title,
+ year=year,
+ tvdb_id=tvdb_id,
+ imdb_id=imdb_id,
+ )
+ if backdrop_bytes is None:
+ if not backdrop_path:
+ return {"status": "error", "reason": "no source art selected"}
+ 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,
+ )
+ return _persist_poster(
+ db,
+ cfg,
+ logger,
+ sync_cfg=full_config.sync_gdrive,
+ blob=blob,
+ kind=kind,
+ title=title,
+ year=year,
+ tmdb_id=tmdb_id,
+ tvdb_id=tvdb_id,
+ imdb_id=imdb_id,
+ season_number=season_number,
+ backdrop_path=backdrop_path,
+ logo_source="squareart",
+ save_local=save_local,
+ upload_gdrive=upload_gdrive,
+ image_type="squareart",
+ asset_suffix=" - squareart",
+ ext=".jpg",
+ defer_upload=defer_upload,
+ full_config=full_config,
+ )
+
+
+def generate_background_art(
+ *,
+ db: ChubDB,
+ full_config,
+ logger,
+ kind: str,
+ title: str,
+ tmdb_id: int,
+ year: Optional[int] = None,
+ tvdb_id: Optional[int] = None,
+ 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,
+ resolution: str = "1080p",
+ season_number: Optional[int] = None,
+ save_local: bool = True,
+ upload_gdrive: Optional[bool] = None,
+ defer_upload: Optional[Callable[[Callable[[], None]], None]] = None,
+) -> Dict[str, Any]:
+ """Render + file 16:9 background art (``- background.jpg``) for a media item.
+
+ Plex background art per its recommended dimensions: ``resolution`` ``"1080p"``
+ = 1920x1080, ``"4k"`` = 3840x2160. Plain framed artwork (no logo/gradient),
+ filed into poster_cache as ``background`` so asset_renamerr applies it to
+ Plex (uploadArt) / Kometa. Always overwrites — a deliberate manual action.
+ ``season_number`` files the art for one season of a show
+ (``… - Season NN - background.jpg``; Plex seasons take background art and
+ Kometa reads ``Season##_background``) instead of the show itself.
+ """
+ cfg = full_config.cl2k_maker
+ kind = (kind or "").lower()
+ if kind not in _VALID_KINDS:
+ return {"status": "error", "reason": f"invalid kind {kind!r}"}
+ if season_number is not None and kind == "show":
+ kind = "season" # season-suffixed naming; backfill/lookup stays TV-side
+ title, year = _backfill_title_year(
+ full_config,
+ db,
+ logger,
+ kind=kind,
+ tmdb_id=tmdb_id,
+ title=title,
+ year=year,
+ tvdb_id=tvdb_id,
+ imdb_id=imdb_id,
+ )
+ if backdrop_bytes is None:
+ if not backdrop_path:
+ return {"status": "error", "reason": "no source art selected"}
+ backdrop_bytes = image_fetch.download(backdrop_path)
+ width, height = (3840, 2160) if (resolution or "").lower() == "4k" else (1920, 1080)
+ blob = renderer.render_framed_art(
+ backdrop_bytes=backdrop_bytes,
+ width=width,
+ height=height,
+ focus_x=focus_x,
+ fit_mode=fit_mode,
+ v_pos=v_pos,
+ zoom=zoom,
+ )
+ return _persist_poster(
+ db,
+ cfg,
+ logger,
+ sync_cfg=full_config.sync_gdrive,
+ blob=blob,
+ kind=kind,
+ title=title,
+ year=year,
+ tmdb_id=tmdb_id,
+ tvdb_id=tvdb_id,
+ imdb_id=imdb_id,
+ season_number=season_number,
+ backdrop_path=backdrop_path,
+ logo_source="background",
+ save_local=save_local,
+ upload_gdrive=upload_gdrive,
+ image_type="background",
+ asset_suffix=" - background",
+ ext=".jpg",
+ defer_upload=defer_upload,
+ full_config=full_config,
+ )
+
+
+def generate_logo_asset(
+ *,
+ db: ChubDB,
+ full_config,
+ logger,
+ kind: str,
+ title: str,
+ tmdb_id: int,
+ year: Optional[int] = None,
+ tvdb_id: Optional[int] = None,
+ imdb_id: Optional[str] = None,
+ logo_path: Optional[str] = None,
+ logo_bytes: Optional[bytes] = None,
+ whiten: bool = False,
+ 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
+ invert: bool = False, # plate logo -> clearlogo (white->transparent, black->white)
+ flip_mask_bytes: Optional[bytes] = None, # B/W touch-up regions (mask PNG)
+ erase_mask_bytes: Optional[bytes] = None, # erase regions (mask PNG, white=erase)
+ save_local: bool = True,
+ upload_gdrive: Optional[bool] = None,
+ defer_upload: Optional[Callable[[Callable[[], None]], None]] = None,
+) -> Dict[str, Any]:
+ """File a clear logo as its own ``- logo.png`` asset (applied via uploadLogo).
+
+ ``whiten`` exports the CL2K-whitened logo; otherwise the original (colored)
+ clear logo, trimmed. Filed separately from any square art or poster.
+ """
+ cfg = full_config.cl2k_maker
+ kind = (kind or "").lower()
+ if kind not in _VALID_KINDS:
+ return {"status": "error", "reason": f"invalid kind {kind!r}"}
+ title, year = _backfill_title_year(
+ full_config,
+ db,
+ logger,
+ kind=kind,
+ tmdb_id=tmdb_id,
+ title=title,
+ year=year,
+ tvdb_id=tvdb_id,
+ imdb_id=imdb_id,
+ )
+ raw = logo_bytes
+ if raw is None and logo_path:
+ raw = image_fetch.download(logo_path)
+ if not raw:
+ return {"status": "error", "reason": "no logo selected"}
+ png, _w, _h = renderer.process_logo(
+ raw,
+ whiten=whiten,
+ flat_white=flat_white,
+ logo_3d=logo_3d,
+ flip_mask_bytes=flip_mask_bytes,
+ erase_mask_bytes=erase_mask_bytes,
+ invert=invert,
+ )
+ return _persist_poster(
+ db,
+ cfg,
+ logger,
+ sync_cfg=full_config.sync_gdrive,
+ blob=png,
+ kind=kind,
+ title=title,
+ year=year,
+ tmdb_id=tmdb_id,
+ tvdb_id=tvdb_id,
+ imdb_id=imdb_id,
+ season_number=None,
+ backdrop_path=None,
+ logo_source="logo-white" if (whiten or flat_white or logo_3d) else "logo",
+ save_local=save_local,
+ upload_gdrive=upload_gdrive,
+ image_type="logo",
+ asset_suffix=" - logo",
+ ext=".png",
+ defer_upload=defer_upload,
+ full_config=full_config,
+ )
+
+
+def _local_targets(cfg, image_type: str) -> List[str]:
+ """Paths of the configured local folders that claim ``image_type`` (config
+ order, deduped, blank paths skipped). Every claimer receives a copy; an
+ empty result means the type isn't auto-saved locally."""
+ seen, out = set(), []
+ for folder in getattr(cfg, "local_folders", None) or []:
+ path = (getattr(folder, "path", "") or "").strip()
+ if not path or path in seen:
+ continue
+ if image_type in (getattr(folder, "types", None) or []):
+ seen.add(path)
+ out.append(path)
+ return out
+
+
+def _drive_targets(cfg, image_type: str) -> List[str]:
+ """Folder ids of the configured Drive uploads that claim ``image_type``
+ (config order, deduped, blank ids skipped)."""
+ seen, out = set(), []
+ for drive in getattr(cfg, "gdrive_uploads", None) or []:
+ folder_id = (getattr(drive, "folder_id", "") or "").strip()
+ if not folder_id or folder_id in seen:
+ continue
+ if image_type in (getattr(drive, "types", None) or []):
+ seen.add(folder_id)
+ out.append(folder_id)
+ return out
+
+
+def _persist_poster(
+ db: ChubDB,
+ cfg,
+ logger,
+ *,
+ sync_cfg=None,
+ blob: bytes,
+ kind: str,
+ title: str,
+ year: Optional[int],
+ tmdb_id: int,
+ tvdb_id: Optional[int],
+ imdb_id: Optional[str],
+ season_number: Optional[int],
+ backdrop_path: Optional[str],
+ logo_source: str,
+ save_local: bool = True,
+ upload_gdrive: Optional[bool] = None,
+ image_type: str = "poster",
+ asset_suffix: str = "",
+ ext: Optional[str] = None,
+ defer_upload: Optional[Callable[[Callable[[], None]], None]] = None,
+ full_config=None,
+) -> Dict[str, Any]:
+ """Write a finished poster to every claiming save location + provenance.
+
+ ``image_type`` / ``asset_suffix`` / ``ext`` let this same sink file the
+ additional-asset types the maker produces — ``squareart`` (``- squareart.jpg``)
+ and ``logo`` (``- logo.png``) — into poster_cache so asset_renamerr applies
+ them. Only true posters are written to the cl2k_generated provenance table (its
+ exists_for() gate is poster-only, so an asset row must not appear there).
+
+ Shared sink for rendered (:func:`generate_for_item`), uploaded-finished
+ (:func:`save_finished_poster`) and .psd-flattened posters. ``backdrop_path``
+ is None for posters that didn't go through the renderer.
+
+ Routing: the file is written into every ``cfg.local_folders`` entry claiming
+ its ``image_type`` (one poster_cache row per copy) and uploaded to every
+ claiming ``cfg.gdrive_uploads`` folder. Nothing is mandatory — when no
+ location claims the type, nothing is auto-saved and the result carries
+ ``not_routed: True`` (the art stays downloadable from the maker page).
+ ``save_local=False`` skips the local writes; ``upload_gdrive=False`` skips
+ the uploads (``None``/``True`` both mean "upload wherever routed"). An
+ upload with no local copy is staged from a temp file and is recorded only in
+ provenance, NOT in poster_cache (nothing local for CHUB to match).
+ """
+ out_dirs = _local_targets(cfg, image_type) if save_local else []
+ # Explicit False turns Drive upload off for this save; True and None (the
+ # UI/module default) both upload to every configured Drive claiming the type.
+ folder_ids = _drive_targets(cfg, image_type) if upload_gdrive is not False else []
+
+ filename = build_poster_filename(
+ kind=kind,
+ title=title,
+ year=year,
+ tmdb_id=tmdb_id,
+ tvdb_id=tvdb_id,
+ imdb_id=imdb_id,
+ season_number=season_number,
+ ext=ext or geo.OUTPUT_EXT,
+ asset_suffix=asset_suffix,
+ )
+ # build_poster_filename already strips path-illegal chars, but basename makes it
+ # provably impossible for a crafted title to escape the save dirs (path-injection).
+ filename = os.path.basename(filename)
+
+ # A title-less, id-less item collapses to a bare ".jpg" dotfile that overwrites
+ # every save — fail closed on an empty stem (splitext reads ".jpg" as the name).
+ ext_used = ext or geo.OUTPUT_EXT
+ stem = filename[: -len(ext_used)] if filename.endswith(ext_used) else filename
+ if not stem.strip():
+ return {
+ "status": "error",
+ "reason": "cannot build a filename — the item has no title or id",
+ }
+
+ # Nothing claims this type: a valid outcome by design — no auto-save, the
+ # caller offers the art as a download instead.
+ if not out_dirs and not folder_ids:
+ logger.info(
+ f"CL2K generated {filename} — no save location claims "
+ f"'{image_type}', not auto-saved (downloadable only)"
+ )
+ return {
+ "status": "generated",
+ "file": filename,
+ "logo_source": logo_source,
+ "saved_local": False,
+ "uploaded": False,
+ "not_routed": True,
+ }
+
+ written: List[str] = []
+ write_errors: List[str] = []
+ for out_dir in out_dirs:
+ try:
+ os.makedirs(out_dir, exist_ok=True)
+ out_path = os.path.join(out_dir, filename)
+ with open(out_path, "wb") as fh:
+ fh.write(blob)
+ except OSError as exc:
+ write_errors.append(f"{out_dir}: {exc}")
+ logger.warning(f"CL2K local save failed in {out_dir}: {exc}")
+ continue
+ written.append(out_path)
+ # Logged before the DB writes below so a failure there still leaves a
+ # record that the file exists on disk.
+ logger.info(f"CL2K saved {filename} to {out_dir}")
+
+ if written:
+ # poster_cache so CHUB's matching/upload picks it up — one row per copy.
+ db.poster.bulk_upsert(
+ [
+ {
+ "title": title,
+ "normalized_title": normalize_titles(title),
+ "year": year,
+ "tmdb_id": tmdb_id,
+ "tvdb_id": tvdb_id,
+ "imdb_id": imdb_id,
+ "season_number": season_number,
+ "folder": os.path.basename(os.path.dirname(out_path).rstrip("/")),
+ "file": out_path,
+ "style": cfg.style,
+ "priority": cfg.priority,
+ "image_type": image_type,
+ "search_only": 0,
+ }
+ for out_path in written
+ ]
+ )
+
+ # Provenance / "already generated" tracking is poster-only — an asset
+ # (squareart / logo) shares the media's tmdb_id and must not make the batch
+ # poster run think a poster exists for it. One row per generation, keyed
+ # on the first local copy.
+ if image_type == "poster":
+ cl2k_generated_for(db).record(
+ {
+ "kind": kind,
+ "tmdb_id": tmdb_id,
+ "tvdb_id": tvdb_id,
+ "imdb_id": imdb_id,
+ "season_number": season_number,
+ "title": title,
+ "year": year,
+ "file": written[0],
+ "backdrop_path": backdrop_path,
+ "logo_source": logo_source,
+ "uploaded": 0,
+ }
+ )
+
+ uploaded_folders: List[str] = []
+ upload_errors: List[str] = []
+
+ def _report_deferred_failure(errors: List[str], notify_cfg) -> None:
+ """Tell the user about a failure the response can no longer carry.
+
+ The caller was told "queued", so silence here reads as success.
+ """
+ detail = "; ".join(errors)
+ logger.error(f"CL2K deferred Drive upload FAILED for {filename}: {detail}")
+ if notify_cfg is None:
+ logger.error("CL2K could not notify: no usable config")
+ return
+ try:
+ from backend.util.notification import NotificationManager
+
+ NotificationManager(
+ notify_cfg, logger, module_name="cl2k_maker"
+ ).send_notification(
+ {
+ "file": filename,
+ "image_type": image_type,
+ "uploaded": len(uploaded_folders),
+ "failed": errors,
+ },
+ event="failure",
+ )
+ except Exception as exc:
+ logger.error(f"CL2K could not send the upload-failure notice: {exc}")
+
+ def _run_uploads(reload_config: bool = False) -> None:
+ """Upload to every routed Drive folder; deferred runs reload live config."""
+ from backend.util.cl2k.gdrive_upload import upload_file
+
+ targets, upload_cfg = folder_ids, sync_cfg
+ fresh = None
+ if reload_config:
+ # Deferred runs use current routing/credentials.
+ try:
+ from backend.util.config import load_config
+
+ fresh = load_config()
+ targets = _drive_targets(fresh.cl2k_maker, image_type)
+ upload_cfg = fresh.sync_gdrive
+ except Exception as exc:
+ # Nothing gets uploaded, and the response is long gone — report
+ # it rather than returning on a warning.
+ _report_deferred_failure(
+ [f"could not re-read config: {exc}"], full_config
+ )
+ return
+ if not targets:
+ # Also a false success: the response promised an upload and none
+ # happens. Different cause, same silence.
+ _report_deferred_failure(
+ [f"no Drive folder is routed for {image_type} any more"],
+ fresh or full_config,
+ )
+ return
+
+ # rclone needs a real on-disk file named with the DAPS filename. Reuse a
+ # local save when present; otherwise stage a temp copy just for the upload.
+ tmpdir = None
+ try:
+ if written:
+ src_path = written[0]
+ else:
+ tmpdir = tempfile.mkdtemp(prefix="cl2k_")
+ src_path = os.path.join(tmpdir, filename)
+ with open(src_path, "wb") as fh:
+ fh.write(blob)
+ for folder_id in targets:
+ logger.info(f"CL2K uploading {filename} to Drive folder {folder_id}…")
+ try:
+ upload_file(src_path, folder_id, upload_cfg, logger)
+ uploaded_folders.append(folder_id)
+ logger.info(f"CL2K uploaded {filename} to Drive {folder_id}")
+ except Exception as exc:
+ upload_errors.append(f"{folder_id}: {exc}")
+ logger.warning(
+ f"CL2K gdrive upload to {folder_id} failed for {filename}: {exc}"
+ )
+ finally:
+ if tmpdir:
+ shutil.rmtree(tmpdir, ignore_errors=True)
+
+ # A deferred failure has no response left to ride home on — the caller was
+ # told "queued" and would otherwise read silence as success. Inline runs
+ # need none of this: their errors are returned to the caller below.
+ if reload_config and upload_errors:
+ _report_deferred_failure(upload_errors, fresh or full_config)
+ if not uploaded_folders:
+ return
+ # Provenance is bookkeeping; the poster is already on Drive. Never let it
+ # fail the request (inline) or raise inside the task (deferred).
+ try:
+ if written:
+ cl2k_generated_for(db).mark_uploaded(written[0])
+ elif image_type == "poster":
+ # Drive-only: no persistent local file, so record provenance keyed
+ # on the basename (poster_cache is skipped — nothing local to match).
+ # Assets (squareart / logo) stay out of the poster provenance table.
+ cl2k_generated_for(db).record(
+ {
+ "kind": kind,
+ "tmdb_id": tmdb_id,
+ "tvdb_id": tvdb_id,
+ "imdb_id": imdb_id,
+ "season_number": season_number,
+ "title": title,
+ "year": year,
+ "file": filename,
+ "backdrop_path": backdrop_path,
+ "logo_source": logo_source,
+ "uploaded": 1,
+ }
+ )
+ except Exception as exc:
+ logger.warning(
+ f"CL2K uploaded {filename} but could not record provenance: {exc}"
+ )
+
+ def _deferred() -> None:
+ """Run the deferred upload; report anything that escapes _run_uploads."""
+ try:
+ _run_uploads(reload_config=True)
+ except Exception as exc:
+ # Staging (mkdtemp/write) happens before the per-folder guard, so a
+ # raise here would leave the caller on "uploading to Drive" forever.
+ _report_deferred_failure([f"the upload task crashed: {exc}"], full_config)
+
+ # Every caller that supplies a deferral gets one, Drive-only included: rclone
+ # outruns the UI timeout, and _run_uploads stages its own temp copy from `blob`.
+ deferred_upload = False
+ if folder_ids:
+ if defer_upload is not None:
+ defer_upload(_deferred)
+ deferred_upload = True
+ logger.info(
+ f"CL2K queued Drive upload for {filename} "
+ f"({len(folder_ids)} folder(s)) — running after the response"
+ )
+ else:
+ _run_uploads()
+
+ # Nothing landed anywhere => error, not a misleading success. A queued upload
+ # has not failed yet — _report_deferred_failure carries that outcome instead.
+ if not written and not uploaded_folders and not deferred_upload:
+ return {
+ "status": "error",
+ "reason": "; ".join(
+ [f"local save failed: {e}" for e in write_errors]
+ + [f"Drive upload failed: {e}" for e in upload_errors]
+ ),
+ "logo_source": logo_source,
+ }
+
+ logger.info(f"CL2K poster generated: {filename} (logo: {logo_source})")
+ result = {
+ "status": "generated",
+ "file": written[0] if written else filename,
+ "logo_source": logo_source,
+ "saved_local": bool(written),
+ "saved_paths": written,
+ "uploaded": bool(uploaded_folders),
+ "uploaded_folders": uploaded_folders,
+ # Not yet uploaded, not failed. The provenance row's `uploaded` flag
+ # settles it once the background task finishes.
+ "upload_pending": deferred_upload,
+ }
+ # Surface non-fatal failures so the caller can tell the user which targets
+ # missed while the generation still succeeded elsewhere.
+ if upload_errors:
+ result["upload_error"] = "; ".join(upload_errors)
+ if write_errors:
+ result["save_error"] = "; ".join(write_errors)
+ return result
+
+
+def _cover_to_canvas(im):
+ """Cover-resize + center-crop a PIL image to the locked CL2K canvas."""
+ from PIL import Image
+
+ w, h = geo.CANVAS_W, geo.CANVAS_H
+ scale = max(w / im.width, h / im.height)
+ # LANCZOS — sharpest resample for the downscale to canvas (matches the Wand
+ # renderer); PIL's default is BICUBIC, which is softer on fine detail.
+ im = im.resize(
+ (round(im.width * scale), round(im.height * scale)),
+ Image.Resampling.LANCZOS,
+ )
+ left = (im.width - w) // 2
+ top = (im.height - h) // 2
+ return im.crop((left, top, left + w, top + h))
+
+
+def _normalize_poster(image_bytes: bytes) -> bytes:
+ """Force a finished poster to the locked 1000×1500 canvas (JPEG, CL2K quality).
+
+ A poster that is already a 1000×1500 JPEG passes through untouched (no re-encode,
+ so a high-quality source keeps its quality). Anything else — wrong dimensions,
+ wrong aspect, or a non-JPEG container — is center-cropped to 2:3, scaled to the
+ canvas, and re-encoded at the CL2K quality with NO chroma subsampling (4:4:4),
+ matching hand-made posters.
+ """
+ import io
+
+ from PIL import Image
+
+ im = Image.open(io.BytesIO(image_bytes))
+ correct_size = (im.width, im.height) == (geo.CANVAS_W, geo.CANVAS_H)
+ if correct_size and (im.format or "").upper() == "JPEG":
+ return image_bytes
+ im = im.convert("RGB")
+ if not correct_size:
+ im = _cover_to_canvas(im)
+ buf = io.BytesIO()
+ im.save(
+ buf,
+ format="JPEG",
+ quality=geo.OUTPUT_QUALITY,
+ subsampling=0,
+ progressive=geo.JPEG_PROGRESSIVE,
+ icc_profile=color.srgb_icc_bytes(),
+ )
+ return buf.getvalue()
+
+
+def save_finished_poster(
+ *,
+ db: ChubDB,
+ full_config,
+ logger,
+ kind: str,
+ title: str,
+ tmdb_id: int,
+ image_bytes: bytes,
+ year: Optional[int] = None,
+ tvdb_id: Optional[int] = None,
+ imdb_id: Optional[str] = None,
+ season_number: Optional[int] = None,
+ logo_source: str = "upload",
+ add_border: bool = True,
+ logo_bytes: Optional[bytes] = None,
+ logo_scale: float = 1.0,
+ logo_y_offset: int = 0,
+ 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
+ invert: bool = False, # plate logo -> clearlogo (white->transparent, black->white)
+ save_local: bool = True,
+ upload_gdrive: Optional[bool] = None,
+ defer_upload: Optional[Callable[[Callable[[], None]], None]] = None,
+) -> Dict[str, Any]:
+ """File a pre-made poster (no rendering) into the selected destinations.
+
+ Used by the manual finished-poster upload and the G-Drive .psd source (both
+ supply a complete poster). The image is forced to the locked 1000×1500 canvas
+ (cropped if needed), named per DAPS, and registered so the rest of CHUB picks
+ it up. When ``logo_bytes`` is given (a TMDB/fanart/custom clear logo), it is
+ composited at the locked CL2K baseline first, with the same whitening/sizing a
+ fresh render uses. ``add_border`` (default True, per the DAPS rule) composites
+ the default 26px white frame; uncheck it for a poster that already has the
+ required border. ``save_local`` / ``upload_gdrive`` choose the destination(s).
+ """
+ cfg = full_config.cl2k_maker
+ kind = (kind or "").lower()
+ if kind not in _VALID_KINDS:
+ return {"status": "error", "reason": f"invalid kind {kind!r}"}
+ title, year = _backfill_title_year(
+ full_config,
+ db,
+ logger,
+ kind=kind,
+ tmdb_id=tmdb_id,
+ title=title,
+ year=year,
+ tvdb_id=tvdb_id,
+ imdb_id=imdb_id,
+ )
+ blob = _normalize_poster(image_bytes)
+ # The border rides along with the logo composite when both are wanted: two
+ # calls here meant decoding and re-encoding the JPEG between them.
+ if logo_bytes:
+ from backend.util.cl2k.renderer import overlay_logo
+
+ blob = overlay_logo(
+ blob,
+ logo_bytes,
+ kind=kind,
+ logo_scale=logo_scale,
+ logo_y_offset=logo_y_offset,
+ whiten=cfg.whiten_logo if whiten is None else whiten,
+ flat_white=flat_white,
+ logo_3d=logo_3d,
+ invert=invert,
+ add_border=add_border,
+ )
+ elif add_border:
+ from backend.util.cl2k.renderer import apply_border
+
+ blob = apply_border(blob)
+ return _persist_poster(
+ db,
+ cfg,
+ logger,
+ sync_cfg=full_config.sync_gdrive,
+ blob=blob,
+ kind=kind,
+ title=title,
+ year=year,
+ tmdb_id=tmdb_id,
+ tvdb_id=tvdb_id,
+ imdb_id=imdb_id,
+ season_number=season_number,
+ backdrop_path=None,
+ logo_source=logo_source,
+ save_local=save_local,
+ upload_gdrive=upload_gdrive,
+ defer_upload=defer_upload,
+ full_config=full_config,
+ )
+
+
+def fanart_images(
+ full_config,
+ db: ChubDB,
+ logger,
+ *,
+ kind: str,
+ tmdb_id: Optional[int] = None,
+ tvdb_id: Optional[int] = None,
+ imdb_id: Optional[str] = None,
+ season_number: Optional[int] = None,
+) -> Dict[str, Optional[str]]:
+ """Return fanart.tv ``{logo, background}`` URLs for the art picker (None on miss).
+
+ A show can resolve from tvdb_id alone (fanart.tv keys shows by TVDB), so
+ tmdb_id is optional — a TVDB-only title still gets fanart art."""
+ cfg = full_config.cl2k_maker
+ lang = cfg.language or "en"
+ try:
+ asset_type = "movie" if kind in ("movie", "collection") else "show"
+ client = FanartClient(full_config.fanart, db, logger)
+ res = client.get_images(
+ {
+ "asset_type": asset_type,
+ "tmdb_id": tmdb_id,
+ "tvdb_id": tvdb_id,
+ "imdb_id": imdb_id,
+ "season_number": season_number,
+ },
+ language=lang,
+ )
+ res = res or {}
+ return {"logo": res.get("logo"), "background": res.get("background")}
+ except Exception as exc:
+ logger.debug(f"fanart image lookup failed: {exc}")
+ return {"logo": None, "background": None}
+
+
+def retext_poster(
+ *,
+ db: ChubDB,
+ full_config,
+ logger,
+ image_bytes: bytes,
+ mask_bytes: Optional[bytes] = None,
+ apply_ai: bool = False,
+ prompt: Optional[str] = None,
+ label_text: str = "",
+ text_y_frac: Optional[float] = None,
+ save: bool = False,
+ kind: str = "movie",
+ title: str = "",
+ tmdb_id: int = 0,
+ year: Optional[int] = None,
+ tvdb_id: Optional[int] = None,
+ imdb_id: Optional[str] = None,
+ season_number: Optional[int] = None,
+ add_border: bool = True,
+ keep_size: bool = False,
+ save_local: bool = True,
+ upload_gdrive: Optional[bool] = None,
+ defer_upload: Optional[Callable[[Callable[[], None]], None]] = None,
+):
+ """Re-text a finished poster: AI-erase the brushed old text, then draw a new
+ CL2K-style label (e.g. swap a season year).
+
+ ``keep_size`` skips the 1000×1500 normalize on the preview path so the
+ AI-erased image keeps its original dimensions — used when the result feeds
+ the full CL2K render (whose framing must see the uncropped image) instead of
+ being saved as-is. The save path always normalizes (save_finished_poster).
+
+ AI handles only the *erase* (reliable); the new label is drawn deterministically
+ in the CL2K font, so it's always crisp. Returns JPEG bytes when ``save`` is
+ False (preview); otherwise files it via :func:`save_finished_poster` and
+ returns that result dict. ``text_y_frac`` (0..1) places the label vertically
+ (defaults to the CL2K season-label position). ``add_border`` (default True, per
+ the DAPS rule) composites the default 26px white frame onto both the preview and
+ the saved file; uncheck it for a poster that already has the required border.
+ """
+ from backend.util.cl2k.renderer import apply_border, overlay_label
+
+ cfg = full_config.cl2k_maker
+ img = image_bytes if keep_size else _normalize_poster(image_bytes)
+ if apply_ai and mask_bytes:
+ img = text_removal.remove_text(
+ img, config=cfg, mask_bytes=mask_bytes, prompt=prompt, logger=logger
+ )
+ # An explicit label_text (a banner override or a free-text title) wins; otherwise
+ # a season draws its SEASON-N band, derived here so season_band_text is the ONE
+ # source of truth for the on-poster label (the full render path uses it too) —
+ # no caller, frontend included, re-spells the number.
+ label = label_text
+ if not label and kind == "season" and season_number is not None:
+ label = season_band_text(season_number)
+ # The border rides along with the label draw when both are wanted: two calls
+ # here meant decoding and re-encoding the JPEG between them.
+ if label:
+ center_y = None
+ if text_y_frac is not None:
+ center_y = int(max(0.0, min(1.0, text_y_frac)) * geo.CANVAS_H)
+ img = overlay_label(img, label, center_y=center_y, add_border=add_border)
+ elif add_border:
+ img = apply_border(img)
+ if not save:
+ return img
+ # The border is already composited above, so don't add it again on save.
+ return save_finished_poster(
+ db=db,
+ full_config=full_config,
+ logger=logger,
+ kind=kind,
+ title=title,
+ tmdb_id=tmdb_id,
+ year=year,
+ tvdb_id=tvdb_id,
+ imdb_id=imdb_id,
+ season_number=season_number,
+ image_bytes=img,
+ logo_source="retext",
+ add_border=False,
+ save_local=save_local,
+ upload_gdrive=upload_gdrive,
+ defer_upload=defer_upload,
+ )
+
+
+def generate_seasons(
+ *,
+ db: ChubDB,
+ full_config,
+ logger,
+ tmdb_id: int,
+ title: str,
+ 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,
+ logo_scale: float = 1.0,
+ logo_y_offset: int = 0,
+ 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
+ invert: bool = False, # plate logo -> clearlogo (white->transparent, black->white)
+ 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)
+ force: bool = False,
+ backdrop_path: Optional[str] = None,
+ backdrop_bytes: Optional[bytes] = None,
+ logo_path: Optional[str] = None,
+ custom_logo_bytes: Optional[bytes] = None,
+ save_local: bool = True,
+ upload_gdrive: Optional[bool] = None,
+ progress_cb=None,
+) -> Dict[str, Any]:
+ """Generate CL2K season posters for each number in ``seasons``.
+
+ The backdrop and logo the user built in the preview are passed through to
+ EVERY season (``backdrop_path``/``backdrop_bytes`` + ``logo_path``/
+ ``custom_logo_bytes``), so each season is composed from the same art rather
+ than re-resolving a fresh auto-pick server-side. When no backdrop is supplied
+ the season-reuse path in :func:`generate_for_item` still falls back to the
+ show's most-recent stored backdrop. The framing (``fit_mode`` / ``focus`` /
+ ``crop`` / ``logo_scale``) is carried from the show poster so every season is
+ composed identically.
+
+ ``progress_cb`` (optional) is invoked with each season's result dict as it
+ completes, so a background runner can report live progress. A failure on one
+ season is captured as an ``error`` result and never aborts the batch.
+ """
+ results = []
+ for n in seasons:
+ try:
+ res = generate_for_item(
+ db=db,
+ full_config=full_config,
+ logger=logger,
+ kind="season",
+ title=title,
+ tmdb_id=tmdb_id,
+ year=year,
+ tvdb_id=tvdb_id,
+ imdb_id=imdb_id,
+ season_number=int(n),
+ backdrop_path=backdrop_path,
+ 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,
+ logo_scale=logo_scale,
+ logo_y_offset=logo_y_offset,
+ whiten=whiten,
+ flat_white=flat_white,
+ logo_3d=logo_3d,
+ invert=invert,
+ logo_flip_bytes=logo_flip_bytes,
+ logo_erase_bytes=logo_erase_bytes,
+ force=force,
+ save_local=save_local,
+ upload_gdrive=upload_gdrive,
+ )
+ except Exception as exc: # one bad season must not sink the rest
+ logger.error(f"cl2k: season {n} generation failed: {exc}", exc_info=True)
+ # /seasons-status serialises this reason, so it must not carry exc.
+ res = {
+ "status": "error",
+ "reason": f"season {n} failed — see the CL2K Maker log for the reason",
+ }
+ entry = {"season": int(n), **res}
+ results.append(entry)
+ if progress_cb is not None:
+ try:
+ progress_cb(entry)
+ except Exception: # progress reporting is best-effort
+ pass
+ return {"results": results}
+
+
+def psd_for_item(
+ *,
+ db: ChubDB,
+ full_config,
+ logger,
+ kind: str,
+ title: str,
+ tmdb_id: int,
+ backdrop_path: Optional[str] = None,
+ backdrop_bytes: Optional[bytes] = None, # uploaded art; wins over backdrop_path
+ logo_path: Optional[str] = None,
+ custom_logo_bytes: Optional[bytes] = None, # uploaded logo; wins over logo_path
+ season_text: str = "",
+ season_number: Optional[int] = None,
+ band_label: str = "",
+ logo_scale: float = 1.0,
+ 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,
+ 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
+ invert: bool = False, # plate logo -> clearlogo (white->transparent, black->white)
+) -> Optional[bytes]:
+ """Resolve art and return a layered CL2K poster as PSD bytes (for Photopea).
+
+ The backdrop is framed via the renderer's own fit/cover/v_pos machinery so
+ the PSD's POSTER layer is pixel-identical to what /preview and /generate
+ show for the same framing knobs. A season's SEASON-N band is derived from
+ ``season_number`` (via season_band_text — same rule as the render path), and a
+ ``band_label`` override wins over it, so the PSD carries the same label the
+ flattened poster would, unless an explicit ``season_text`` is given.
+
+ The uploaded/brushed inputs (``backdrop_bytes`` / ``custom_logo_bytes`` /
+ ``logo_flip_bytes`` / ``logo_erase_bytes``) take the same precedence they do
+ in :func:`_resolve_and_render`, so the .psd is built from what the preview
+ showed rather than from a fresh TMDB auto-pick.
+ """
+ from backend.util.cl2k.psd_export import export_psd
+
+ cfg = full_config.cl2k_maker
+ lang = cfg.language or "en"
+ if not season_text and kind == "season" and season_number is not None:
+ season_text = season_band_text(season_number)
+ tmdb = TMDBClient(full_config.tmdb, db, logger)
+ backdrop_path, logo_path = _resolve_default_art(
+ tmdb,
+ tmdb_id,
+ kind,
+ lang,
+ backdrop_path,
+ logo_path,
+ need_backdrop=backdrop_bytes is None,
+ need_logo=custom_logo_bytes is None,
+ )
+ if backdrop_bytes is None:
+ if not backdrop_path:
+ return None
+ 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,
+ )
+ logo_bytes = custom_logo_bytes
+ if logo_bytes is None and logo_path:
+ logo_bytes = image_fetch.download(logo_path)
+ return export_psd(
+ backdrop_bytes=framed,
+ kind=kind,
+ logo_bytes=logo_bytes,
+ title=title,
+ season_text=season_text,
+ band_label=band_label,
+ logo_scale=logo_scale,
+ logo_y_offset=logo_y_offset,
+ logo_flip_bytes=logo_flip_bytes,
+ logo_erase_bytes=logo_erase_bytes,
+ whiten=cfg.whiten_logo if whiten is None else whiten,
+ flat_white=flat_white,
+ logo_3d=logo_3d,
+ invert=invert,
+ )
diff --git a/backend/modules/poster_self_heal.py b/backend/modules/poster_self_heal.py
new file mode 100644
index 00000000..89bb7433
--- /dev/null
+++ b/backend/modules/poster_self_heal.py
@@ -0,0 +1,317 @@
+# backend/modules/poster_self_heal.py
+"""poster_self_heal module — scheduled drift detection for CL2K posters.
+
+The run() scans the CL2K maker's OWN posters from two sources — locally-saved
+ones in poster_cache scoped to its ``local_folders`` paths (other owners'
+synced-in CL2K drives share the style tag but are skipped), and live listings
+of every linked ``gdrive_uploads`` folder (the source of truth for a Drive-only
+setup, where posters were never recorded in poster_cache). It re-resolves each
+against
+TMDB (bridging stale ids through media_cache), and upserts proposals into
+poster_heal_review. By default it only DETECTS (proposals wait for manual review
+via backend/api/poster_self_heal.py). With ``auto_apply`` on, confident proposals
+are renamed immediately (Drive + local); ambiguous matches always wait for a
+manual pick. A run sends a Discord notification summarising the outcome.
+"""
+
+import os
+
+from backend.util.base_module import ChubModule
+from backend.util.cl2k.gdrive_upload import list_files
+from backend.util.database import ChubDB
+from backend.util.database.poster_heal_review import poster_heal_review_for
+from backend.util.notification import NotificationManager
+from backend.util.poster_self_heal.apply import apply_proposal
+from backend.util.poster_self_heal.cache_reconcile import drop_stale_row
+from backend.util.poster_self_heal.resolver import (
+ index_media,
+ poster_from_filename,
+ resolve_poster,
+)
+from backend.util.tmdb import TMDBClient
+
+_HEAL_KINDS = ("movie", "show")
+
+
+def _is_under(path: str, base_dir: str) -> bool:
+ """True if ``path`` lives inside ``base_dir`` (or equals it). Scopes the heal
+ to the CL2K maker's own ``local_folders`` so synced-in CL2K posters from other
+ owners' drives (same ``style`` tag, different folder) are left alone."""
+ if not path or not base_dir:
+ return False
+ base = os.path.normpath(base_dir)
+ p = os.path.normpath(path)
+ return p == base or p.startswith(base + os.sep)
+
+
+def local_dirs_for(cl2k) -> list:
+ """Local folders the heal is scoped to: every non-blank ``local_folders``
+ path, deduped in config order. ``types`` is deliberately NOT consulted — an
+ inert routing row is still scanned."""
+ out: list = []
+ for folder in getattr(cl2k, "local_folders", None) or []:
+ path = (getattr(folder, "path", "") or "").strip()
+ if path and path not in out:
+ out.append(path)
+ return out
+
+
+def is_already_healed(row) -> bool:
+ """True when an open row describes a rename that has already happened.
+
+ Requires the PROPOSED name to exist, not just the old one to be missing — on
+ an unmounted share every file looks gone and a missing-only check would prune
+ the whole queue.
+ """
+ old = row.get("poster_file") or ""
+ proposed = row.get("proposed_filename") or ""
+ if not proposed or not os.path.isabs(old):
+ return False
+ # Must be a bare name: an absolute value would make os.path.join discard the
+ # original directory, and a '../' one would resolve outside it — either could
+ # match some unrelated file and delete the row as healed.
+ if os.path.basename(proposed) != proposed:
+ return False
+ new = os.path.join(os.path.dirname(old), proposed)
+ return not os.path.exists(old) and os.path.exists(new)
+
+
+def should_auto_apply(prop, auto: bool, dismissed_files) -> bool:
+ """True when this run may rename ``prop`` unattended. A dismissed
+ poster_file never qualifies — dismissed is terminal."""
+ if not auto or prop.get("status") != "proposed":
+ return False
+ return prop.get("poster_file") not in dismissed_files
+
+
+def drive_twins(cl2k) -> tuple:
+ """``(drive_ids, twin_of)``; ``twin_of(image_type)`` resolves to the first
+ ``gdrive_uploads`` entry claiming that type, else the poster Drive, else the
+ first Drive."""
+ drive_ids: list = []
+ by_type: dict = {}
+ for drive in getattr(cl2k, "gdrive_uploads", None) or []:
+ fid = (getattr(drive, "folder_id", "") or "").strip()
+ if not fid:
+ continue
+ if fid not in drive_ids:
+ drive_ids.append(fid)
+ for image_type in getattr(drive, "types", None) or []:
+ by_type.setdefault(image_type, fid)
+ fallback = by_type.get("poster") or (drive_ids[0] if drive_ids else None)
+
+ def twin_of(image_type):
+ return by_type.get(image_type or "poster", fallback)
+
+ return drive_ids, twin_of
+
+
+class PosterSelfHeal(ChubModule):
+ """Detect stale ids / changed titles / missing ids on CL2K posters."""
+
+ def run(self) -> None:
+ cl2k = getattr(self.full_config, "cl2k_maker", None)
+ if cl2k is None:
+ self.logger.error(
+ "CL2K maker config not found — poster_self_heal needs the CL2K "
+ "extension; nothing to scan."
+ )
+ return
+
+ style = (getattr(cl2k, "style", "") or "CL2K").strip()
+ local_dirs = local_dirs_for(cl2k)
+ drive_ids, twin_of = drive_twins(cl2k)
+ if not local_dirs and not drive_ids:
+ self.logger.error(
+ "CL2K maker has no local folders or Drive uploads configured — "
+ "poster_self_heal heals the CL2K posters it can see: locally-saved "
+ "ones (tracked in poster_cache under a local folder) and/or ones "
+ "in a linked Drive folder. Add at least one under Settings → "
+ "Modules → CL2K Maker. Nothing scanned."
+ )
+ return
+
+ sync_cfg = self.full_config.sync_gdrive
+ auto = bool(getattr(self.config, "auto_apply", False))
+
+ with ChubDB(self.logger) as db:
+ tmdb_client = TMDBClient(self.full_config.tmdb, db, self.logger)
+ if not tmdb_client.enabled:
+ self.logger.error(
+ "TMDB API key not set — poster_self_heal needs TMDB to resolve "
+ "canonical ids/titles. Set it under Settings → Modules → TMDB."
+ )
+ return
+
+ # LOCAL source — the user's OWN CL2K output (any configured local
+ # folder), NOT every poster carrying the shared "CL2K" style tag
+ # (that also covers other owners' "CL2K " drives synced into
+ # the cache).
+ local_posters = (
+ [
+ p
+ for p in db.poster.get_all()
+ if p.get("style") == style
+ and p.get("asset_type") in _HEAL_KINDS
+ and any(_is_under(p.get("file"), d) for d in local_dirs)
+ ]
+ if local_dirs
+ else []
+ )
+ local_names = {os.path.basename(p.get("file") or "") for p in local_posters}
+
+ # LIVE-DRIVE source — posters living in any linked Drive folder. The
+ # source of truth for a Drive-only setup (no local copy in
+ # poster_cache). Skip names a local row already covers (its apply
+ # renames the Drive copy too) and names an earlier folder already
+ # yielded (one proposal per filename). A listing failure logs a
+ # warning and yields []. Each poster keeps the folder it was found
+ # in so apply renames the right Drive copy.
+ drive_posters = []
+ seen_names = set(local_names)
+ for fid in drive_ids:
+ for name in list_files(fid, sync_cfg, self.logger):
+ base = os.path.basename(name)
+ if base in seen_names:
+ continue
+ parsed = poster_from_filename(base)
+ if parsed:
+ seen_names.add(base)
+ drive_posters.append((parsed, fid))
+
+ # Local rows heal the Drive twin that receives their own image_type.
+ posters = [
+ (p, twin_of(p.get("image_type"))) for p in local_posters
+ ] + drive_posters
+ media_index = index_media(db.media.get_all())
+ reviews = poster_heal_review_for(db)
+
+ # Drop open proposals that can no longer be acted on. Live-Drive rows
+ # carry a bare filename, so only absolute (local) rows are candidates.
+ pruned = stale = 0
+ for row in reviews.list_open(limit=1_000_000):
+ pf = row.get("poster_file") or ""
+ if not os.path.isabs(pf):
+ continue
+ # Outside every configured folder — e.g. another owner's synced
+ # posters, or a folder since removed. No folders configured
+ # means nothing is out of scope — never a purge of everything.
+ if local_dirs and not any(_is_under(pf, d) for d in local_dirs):
+ reviews.delete(row["id"])
+ pruned += 1
+ # Already renamed: the row describes history, and its Apply would
+ # fail forever because the target it wants is already there.
+ elif is_already_healed(row):
+ reviews.delete(row["id"])
+ stale += 1
+ if pruned:
+ self.logger.info(
+ f"poster_self_heal: pruned {pruned} out-of-scope proposal(s)"
+ )
+ if stale:
+ self.logger.info(
+ f"poster_self_heal: pruned {stale} already-applied proposal(s)"
+ )
+
+ total = len(posters)
+ self.logger.info(
+ f"poster_self_heal: scanning {total} CL2K posters "
+ f"({len(local_posters)} local"
+ f"{f' under {len(local_dirs)} folder(s)' if local_dirs else ''}, "
+ f"{len(drive_posters)} on Drive)"
+ f"{' [auto-apply ON]' if auto else ''}"
+ )
+ proposed = pending = applied = failed = dismissed = 0
+ # Cheap first filter; re-checked live before each rename below.
+ dismissed_files = reviews.dismissed_files() if auto else set()
+ for idx, (poster, heal_folder_id) in enumerate(posters, 1):
+ if self.is_cancelled():
+ self.logger.info("poster_self_heal cancelled.")
+ break
+ try:
+ prop = resolve_poster(
+ poster, media_index, heal_folder_id, tmdb_client, self.config
+ )
+ except Exception as exc:
+ self.logger.warning(
+ f"poster_self_heal: resolve failed for {poster.get('file')}: {exc}",
+ exc_info=True,
+ )
+ prop = None
+ if prop:
+ self.logger.debug(
+ f"[{prop['drift_type']}] {prop['current_filename']} -> "
+ f"{prop['proposed_filename']}"
+ )
+ # Auto-apply confident proposals when enabled; ambiguous
+ # (pending) ones always wait for a manual pick.
+ apply_error = None
+ skipped = auto and prop["poster_file"] in dismissed_files
+ if not skipped and should_auto_apply(prop, auto, dismissed_files):
+ # Re-check live: the snapshot is from run start and a run
+ # takes minutes, so the user may have dismissed this since.
+ skipped = reviews.is_dismissed(prop["poster_file"])
+ if skipped:
+ dismissed += 1
+ self.logger.debug(
+ f"skipping dismissed {prop['current_filename']}"
+ )
+ elif should_auto_apply(prop, auto, dismissed_files):
+ try:
+ note = apply_proposal(prop, sync_cfg, self.logger)
+ prop["status"] = "applied"
+ applied += 1
+ self.logger.info(
+ f"auto-applied {prop['proposed_filename']}{note}"
+ )
+ except Exception as exc:
+ apply_error = str(exc)
+ failed += 1
+ self.logger.warning(
+ f"auto-apply failed for {prop['current_filename']}: {exc}"
+ " — reopened for manual review"
+ )
+ # Count a failed apply once, as failed; a dismissed skip isn't
+ # open either.
+ if apply_error is None and not skipped:
+ if prop["status"] == "pending":
+ pending += 1
+ elif prop["status"] == "proposed":
+ proposed += 1
+ reviews.upsert(prop)
+ if apply_error is not None:
+ # upsert's CASE keeps a terminal status — force it open.
+ reviews.mark_failed(prop["poster_file"], apply_error)
+ elif prop["status"] == "applied":
+ # Drop the row the rename invalidated, or the next run
+ # re-proposes it and collides with the file we created.
+ stale = prop.get("poster_file") or ""
+ if os.path.isabs(stale):
+ drop_stale_row(db, stale, self.logger)
+ if total:
+ self._report_progress(int(idx / total * 100))
+
+ self.logger.info(
+ f"poster_self_heal done: {applied} auto-applied, {proposed} proposed, "
+ f"{pending} need a manual pick, {failed} failed"
+ f"{f', {dismissed} skipped (dismissed)' if dismissed else ''}; "
+ f"{reviews.open_count()} open for review total"
+ )
+
+ # Failures notify too — a run whose only outcome is failures is
+ # exactly the one worth telling the user about.
+ if applied or proposed or pending or failed:
+ output = {
+ "scanned": total,
+ "applied": applied,
+ "proposed": proposed,
+ "pending": pending,
+ "failed": failed,
+ "open": reviews.open_count(),
+ }
+ try:
+ NotificationManager(
+ self.full_config, self.logger, module_name="poster_self_heal"
+ ).send_notification(output)
+ except Exception as exc:
+ self.logger.error(f"Failed to send notification: {exc}")
diff --git a/backend/modules/sync_gdrive.py b/backend/modules/sync_gdrive.py
index ef453a7c..943489c7 100755
--- a/backend/modules/sync_gdrive.py
+++ b/backend/modules/sync_gdrive.py
@@ -375,6 +375,7 @@ def guarded_progress_cb(pct):
)
# Use service account if configured, otherwise use OAuth token
+ auth_env: dict = {}
sa_path = getattr(self.config, "gdrive_sa_location", None)
if sa_path:
if not self._reject_unsafe_arg(sa_path, "gdrive_sa_location", self.logger):
@@ -382,26 +383,23 @@ def guarded_progress_cb(pct):
return False, counters
cmd.extend(["--drive-service-account-file", sa_path])
else:
- cmd.extend(
- [
- "--drive-client-id",
- self.config.client_id or "",
- "--drive-client-secret",
- self.config.client_secret or "",
- "--drive-token",
- (
- self.config.token
- if isinstance(self.config.token, str)
- else json.dumps(
- self.config.token.model_dump()
- if hasattr(self.config.token, "model_dump")
- else dict(self.config.token)
- )
+ # Env, not argv: /proc//cmdline is world-readable in-container,
+ # and the debug command log below would otherwise print the token.
+ auth_env = {
+ "RCLONE_DRIVE_CLIENT_ID": self.config.client_id or "",
+ "RCLONE_DRIVE_CLIENT_SECRET": self.config.client_secret or "",
+ "RCLONE_DRIVE_TOKEN": (
+ self.config.token
+ if isinstance(self.config.token, str)
+ else json.dumps(
+ self.config.token.model_dump()
+ if hasattr(self.config.token, "model_dump")
+ else dict(self.config.token)
)
- if self.config.token
- else "",
- ]
- )
+ )
+ if self.config.token
+ else "",
+ }
cmd.extend(["posters:", sync_location])
@@ -414,7 +412,11 @@ def guarded_progress_cb(pct):
return False, counters
process = subprocess.Popen(
- cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ env={**os.environ, **auth_env},
)
for line in process.stdout:
if self.is_cancelled():
diff --git a/backend/util/cl2k/__init__.py b/backend/util/cl2k/__init__.py
new file mode 100644
index 00000000..8c361f21
--- /dev/null
+++ b/backend/util/cl2k/__init__.py
@@ -0,0 +1,6 @@
+"""CL2K poster maker — render a CL2K-style poster from a backdrop + clear logo.
+
+Standalone render core (geometry + ImageMagick/Wand renderer). Wired into Chub
+as the ``cl2k_maker`` module in a later phase; nothing here imports the rest of
+the app, so it can be exercised in isolation.
+"""
diff --git a/backend/util/cl2k/color.py b/backend/util/cl2k/color.py
new file mode 100644
index 00000000..72ed21ba
--- /dev/null
+++ b/backend/util/cl2k/color.py
@@ -0,0 +1,24 @@
+"""Shared sRGB ICC profile for CL2K output.
+
+The render inputs (TMDB, fanart.tv) are sRGB and the pipeline never widens the
+gamut, so the output pixels are sRGB. Embedding a standard sRGB ICC profile is a
+truthful self-description of those pixels: it keeps colours correct on colour-
+managed / wide-gamut displays, which can otherwise stretch an *untagged* JPEG
+into the display gamut and render it oversaturated. On ordinary sRGB-assuming
+viewers (Plex, browsers) it changes nothing visible.
+
+The profile is generated locally via littleCMS (no authoring data is copied from
+any source file) and cached, so the embedded bytes are identical every call.
+"""
+
+from __future__ import annotations
+
+from functools import lru_cache
+
+
+@lru_cache(maxsize=1)
+def srgb_icc_bytes() -> bytes:
+ """Return a standard sRGB ICC profile as bytes (cached, ~0.5 KB)."""
+ from PIL import ImageCms
+
+ return ImageCms.ImageCmsProfile(ImageCms.createProfile("sRGB")).tobytes()
diff --git a/backend/util/cl2k/config.py b/backend/util/cl2k/config.py
new file mode 100644
index 00000000..0200d757
--- /dev/null
+++ b/backend/util/cl2k/config.py
@@ -0,0 +1,223 @@
+# backend/util/cl2k/config.py
+"""Pydantic config model for the CL2K maker.
+
+Grafted onto ChubConfig by backend/extensions/cl2k/manifest.py
+(config_fields), so ``load_config().cl2k_maker`` is typed exactly like the
+core module sections. Lives here (not backend/util/config.py) because the
+CL2K maker is part of the :full image.
+"""
+
+from typing import List
+
+from pydantic import BaseModel, Field, model_validator
+
+# Image types the CL2K maker can emit; a save location claims any subset of these.
+CL2K_IMAGE_TYPES = ("poster", "logo", "background", "squareart")
+
+
+class Cl2kLocalFolder(BaseModel):
+ """One named local save target.
+
+ Generated art whose ``image_type`` is in ``types`` is written into ``path``.
+ A type may be claimed by any number of folders (every claimer gets a copy);
+ a type nobody claims isn't auto-saved and stays downloadable from the maker
+ page. Empty ``types`` = an inert row (keeps the path visible, saves nothing).
+ """
+
+ name: str = ""
+ path: str = ""
+ # Any of CL2K_IMAGE_TYPES.
+ types: List[str] = Field(default_factory=list)
+
+
+class Cl2kGdriveUpload(BaseModel):
+ """One named Google Drive upload target (same claim semantics as
+ :class:`Cl2kLocalFolder`). Uploads use the Sync GDrive OAuth token — a
+ service account can't own files in a personal Drive, so there is no
+ per-entry SA option."""
+
+ name: str = ""
+ folder_id: str = ""
+ types: List[str] = Field(default_factory=list)
+
+
+class Cl2kMakerConfig(BaseModel):
+ log_level: str = "info"
+ language: str = "en"
+ whiten_logo: bool = True
+ text_logo_fallback: bool = True # synth a typeset wordmark when no real logo
+ # Outline width (px at the internal render scale) for the text-logo wordmark;
+ # 0 = none (clean white, the CL2K default). A small value (~4) adds legibility
+ # over busy art. The wordmark itself is balance-wrapped to fill the logo box.
+ text_logo_stroke: int = Field(default=0, ge=0, le=20)
+ skip_existing: bool = True
+ style: str = "CL2K" # poster_cache style tag
+ priority: int = 0
+ # Save locations — two routed lists, nothing mandatory. Each entry claims a
+ # subset of CL2K_IMAGE_TYPES; a type may route to any number of locations
+ # (every claimer gets a copy). Zero locations is valid: unrouted types
+ # simply aren't auto-saved and stay downloadable from the maker page.
+ local_folders: List[Cl2kLocalFolder] = Field(default_factory=list)
+ gdrive_uploads: List[Cl2kGdriveUpload] = Field(default_factory=list)
+ # AI text removal (provider-agnostic; off by default = textless-art strategy).
+ # Requires a user-brushed mask. lama_sidecar = free/local; openai = paid.
+ # Firefly/ChatGPT-free have no usable API — use the manual export/import
+ # handoff for those.
+ ai_provider: str = "none" # none | lama_sidecar | openai
+ ai_endpoint: str = "" # lama sidecar URL
+ # openai token. Name is redaction-driven (the core secret list matches exact
+ # leaf keys) — don't re-prefix it to ai_api_key.
+ api_key: str = ""
+ # Sidecar's LAMA_API_KEY, sent as X-API-Key. Own field so both providers'
+ # credentials coexist; same redaction-driven naming.
+ client_key: str = ""
+ ai_model: str = "" # openai model id (default gpt-image-1)
+ # The sidecar's quality passes (snap + native boundary refine, v1.6+) add
+ # roughly one extra inference per erase, which can push a busy CPU box
+ # well past the old 120s.
+ ai_timeout: int = 300
+ # Per-request mask dilation sent to the lama sidecar; -1 = the sidecar's own
+ # default (5). The ghost-fringe knob: raise for glowing/beveled logos, lower
+ # when masks are already generous — tunable here without a container restart.
+ ai_mask_dilate: int = Field(default=-1, ge=-1, le=64)
+ # Rescue auto-sourced logos that are too small for the logo box by 2x/4x
+ # super-resolution on the sidecar (/api/v1/upscale) before falling back to
+ # the typeset text wordmark. Best-effort: any failure keeps old behaviour.
+ ai_logo_upscale: bool = True
+ # OpenAI prompt. OpenAI can remove text from this prompt ALONE (no mask);
+ # a brushed mask, when present, restricts the edit to that region.
+ ai_prompt: str = (
+ "Remove all text, titles, credits, logos and watermarks from this image. "
+ "Seamlessly reconstruct the underlying artwork and background where the "
+ "text was. Do not change anything else."
+ )
+
+ @model_validator(mode="before")
+ @classmethod
+ def _migrate_sidecar_key(cls, data):
+ """Move a pre-split sidecar secret out of the shared ``api_key``."""
+ if not isinstance(data, dict):
+ return data
+ # No-ops once client_key is set, which the first save persists.
+ if data.get("client_key") or data.get("ai_provider") != "lama_sidecar":
+ return data
+ legacy = (data.get("api_key") or "").strip()
+ # Never an openai token left behind by an earlier provider choice.
+ if legacy and not legacy.startswith("sk-"):
+ data = dict(data)
+ data["client_key"], data["api_key"] = legacy, ""
+ return data
+
+ @model_validator(mode="before")
+ @classmethod
+ def _migrate_legacy_save_fields(cls, data):
+ """Migrate the pre-redesign save fields to the two routed lists.
+
+ Old shape: a mandatory ``output_dir``, a single ``upload_to_gdrive`` /
+ ``gdrive_folder_id`` pair, and optional ``destinations`` rows where the
+ FIRST destination claiming an image_type won (per-field fallback to the
+ top-level dir/folder; upload was additive: global switch OR the matched
+ destination's own flag).
+
+ This rewrites that into ``local_folders`` / ``gdrive_uploads`` claims
+ that route each type exactly where the old first-match logic sent it.
+ Paths/folder-ids the old config carried but never routed anywhere (e.g.
+ a folder id with uploads switched off) are kept as inert ``types: []``
+ entries so nothing the user typed is lost.
+
+ Runs on every validate (load and POST /api/config merge), so it must be
+ idempotent: it no-ops as soon as either new list is present-and-truthy,
+ and never resurrects entries the user has since deleted (a post-redesign
+ save strips the legacy keys from disk — ``extra='ignore'`` drops them at
+ validation, so they can't reappear).
+ """
+ if not isinstance(data, dict):
+ return data
+ if data.get("local_folders") or data.get("gdrive_uploads"):
+ return data
+
+ def _get(obj, key):
+ val = obj.get(key) if isinstance(obj, dict) else getattr(obj, key, None)
+ return val
+
+ def _text(obj, key):
+ return (_get(obj, key) or "").strip() if obj is not None else ""
+
+ out_dir = (data.get("output_dir") or "").strip()
+ folder_id = (data.get("gdrive_folder_id") or "").strip()
+ upload_on = bool(data.get("upload_to_gdrive"))
+ dests = data.get("destinations") or []
+ if not (out_dir or folder_id or dests):
+ return data
+
+ def first_dest(image_type):
+ for d in dests:
+ if image_type in (_get(d, "image_types") or []):
+ return d
+ return None
+
+ def claim(entries, id_key, id_value, name, image_type):
+ for entry in entries:
+ if entry[id_key] == id_value:
+ if image_type and image_type not in entry["types"]:
+ entry["types"].append(image_type)
+ return
+ entries.append(
+ {
+ "name": name,
+ id_key: id_value,
+ "types": [image_type] if image_type else [],
+ }
+ )
+
+ folders: List[dict] = []
+ drives: List[dict] = []
+ for image_type in CL2K_IMAGE_TYPES:
+ dest = first_dest(image_type)
+ dest_dir = _text(dest, "output_dir")
+ dest_fid = _text(dest, "gdrive_folder_id")
+ dest_name = _text(dest, "name")
+ eff_dir = dest_dir or out_dir
+ if eff_dir:
+ claim(
+ folders,
+ "path",
+ eff_dir,
+ dest_name if dest_dir else "Output",
+ image_type,
+ )
+ upload_active = upload_on or bool(
+ dest is not None and _get(dest, "upload_to_gdrive")
+ )
+ eff_fid = dest_fid or folder_id
+ if upload_active and eff_fid:
+ claim(
+ drives,
+ "folder_id",
+ eff_fid,
+ dest_name if dest_fid else "Drive",
+ image_type,
+ )
+
+ # Inert leftovers: keep every path / folder id the old config named, even
+ # if the routing above never used it, so nothing silently disappears.
+ if out_dir:
+ claim(folders, "path", out_dir, "Output", None)
+ if folder_id:
+ claim(drives, "folder_id", folder_id, "Drive", None)
+ for d in dests:
+ if _text(d, "output_dir"):
+ claim(folders, "path", _text(d, "output_dir"), _text(d, "name"), None)
+ if _text(d, "gdrive_folder_id"):
+ claim(
+ drives,
+ "folder_id",
+ _text(d, "gdrive_folder_id"),
+ _text(d, "name"),
+ None,
+ )
+
+ data = dict(data)
+ data["local_folders"] = folders
+ data["gdrive_uploads"] = drives
+ return data
diff --git a/backend/util/cl2k/gdrive_upload.py b/backend/util/cl2k/gdrive_upload.py
new file mode 100644
index 00000000..f51588cd
--- /dev/null
+++ b/backend/util/cl2k/gdrive_upload.py
@@ -0,0 +1,515 @@
+"""Upload a generated CL2K poster to Google Drive via rclone copy.
+
+Mirrors sync_gdrive's rclone usage (service-account auth, the ``posters`` remote)
+but in the upload direction: copy a single local file into a Drive folder. Args
+passed to rclone are validated against option-smuggling / null bytes the same way
+sync_gdrive does.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import shutil
+import subprocess
+import tempfile
+import threading
+import uuid
+from collections import defaultdict
+from shutil import which
+from typing import Any, Dict, List, Optional
+
+# Serialises writes per Drive folder: concurrent uploads both list "absent" and
+# both create, and Drive allows duplicate names. Thread lock — single process only.
+_FOLDER_LOCKS_GUARD = threading.Lock()
+_FOLDER_LOCKS: "defaultdict[str, threading.Lock]" = defaultdict(threading.Lock)
+
+
+def _folder_lock(folder_id: str) -> threading.Lock:
+ with _FOLDER_LOCKS_GUARD:
+ return _FOLDER_LOCKS[folder_id]
+
+
+def _rclone_path() -> str:
+ env = os.getenv("RCLONE_PATH")
+ if env:
+ if os.path.isfile(env) and os.access(env, os.X_OK):
+ return env
+ raise FileNotFoundError(f"RCLONE_PATH '{env}' is not an executable file.")
+ path = which("rclone")
+ if path is None:
+ raise FileNotFoundError("rclone not found in PATH; set RCLONE_PATH.")
+ return path
+
+
+def _reject_unsafe(value: str, field: str) -> None:
+ if not isinstance(value, str) or "\x00" in value or value.startswith("-"):
+ raise ValueError(f"Refusing unsafe {field} value: {value!r}")
+
+
+# First char must NOT be '-' (real Drive IDs start alphanumeric), so an ID can't
+# be smuggled in as an rclone option even though '-' is otherwise a valid char.
+_DRIVE_ID_RE = re.compile(r"^[A-Za-z0-9_][A-Za-z0-9_-]*$")
+
+
+def _reject_unsafe_id(value: str, field: str) -> None:
+ """Strict validator for Google Drive folder/file IDs.
+
+ Drive IDs are always ``[A-Za-z0-9_-]``. Restricting to that charset (on top of
+ the list-form subprocess call, which is never shell-interpreted) makes it
+ provably impossible for an ID to smuggle an rclone option or any other token
+ into the command line.
+ """
+ if not isinstance(value, str) or not _DRIVE_ID_RE.fullmatch(value):
+ raise ValueError(f"Refusing unsafe {field} value: {value!r}")
+
+
+# A hung rclone must fail the call loudly, never wedge a worker.
+_RCLONE_TIMEOUT = 600
+
+
+def _run_rclone(
+ cmd: List[str], env: Optional[Dict[str, str]] = None
+) -> "subprocess.CompletedProcess[str]":
+ """Every rclone invocation: credentials ride env (never argv), bounded."""
+ try:
+ return subprocess.run(
+ cmd,
+ check=False,
+ capture_output=True,
+ text=True,
+ env={**os.environ, **(env or {})},
+ timeout=_RCLONE_TIMEOUT,
+ )
+ except subprocess.TimeoutExpired as exc:
+ raise RuntimeError(f"rclone timed out after {_RCLONE_TIMEOUT}s") from exc
+
+
+def _ensure_remote(rclone: str) -> None:
+ """Create the rclone 'posters' remote if missing (idempotent)."""
+ _run_rclone(
+ [rclone, "config", "create", "posters", "drive", "config_is_local=false"]
+ )
+
+
+def _oauth_env(sync_cfg: Any) -> Dict[str, str]:
+ """rclone OAuth flags from the sync_gdrive config; [] when there is no
+ *usable* token, so callers fall back to the service account.
+
+ sync_gdrive stores a placeholder token (often the literal ``"{}"``) when only
+ a service account is configured. That is truthy but NOT a real OAuth token,
+ so we require it to actually carry an access/refresh token before taking the
+ OAuth path — otherwise an SA-only setup would try (and fail) bogus OAuth
+ instead of using its service account.
+ """
+ token = getattr(sync_cfg, "token", "")
+ if token and not isinstance(token, str):
+ token = json.dumps(
+ token.model_dump() if hasattr(token, "model_dump") else dict(token)
+ )
+ token = (token or "").strip()
+ if "access_token" not in token and "refresh_token" not in token:
+ return {}
+ client_id = getattr(sync_cfg, "client_id", "") or ""
+ client_secret = getattr(sync_cfg, "client_secret", "") or ""
+ # Env, not argv: /proc//cmdline is world-readable in-container, so a
+ # token on the command line is exposed to any process and ps output.
+ return {
+ "RCLONE_DRIVE_CLIENT_ID": client_id,
+ "RCLONE_DRIVE_CLIENT_SECRET": client_secret,
+ "RCLONE_DRIVE_TOKEN": token,
+ }
+
+
+def _upload_auth_env(sync_cfg: Any) -> Dict[str, str]:
+ """Auth for WRITING to the user's own drive (upload): the OAuth token ONLY.
+
+ Uploading with the user's OAuth token writes as the user, so files land in
+ their own Drive folder and are owned by them (this is how PosterFlow does it).
+ A service account is intentionally NOT used here: an SA has no storage quota
+ and cannot own files in a personal Drive ("Service Accounts do not have
+ storage quota"), so the SA upload path always fails. ``{}`` when there is no
+ usable OAuth token, so the caller can raise a clear error.
+ """
+ return _oauth_env(sync_cfg)
+
+
+def has_upload_token(sync_cfg: Any) -> bool:
+ """True when a usable OAuth token is configured for CL2K uploads.
+
+ Lets callers (e.g. the maker UI) warn the user that upload is enabled but
+ will fail, without exposing the (redacted) token itself.
+ """
+ return bool(_upload_auth_env(sync_cfg))
+
+
+def upload_file(
+ local_path: str,
+ folder_id: str,
+ sync_cfg: Any,
+ logger,
+) -> None:
+ """Copy a single local poster into the Drive folder ``folder_id``.
+
+ Authenticates as the user via the Sync GDrive OAuth token (so the poster
+ lands in their own Drive, owned by them). Raises on a missing token or a
+ non-zero rclone exit so the caller can record the failure.
+ """
+ _reject_unsafe_id(folder_id, "gdrive_folder_id")
+ _reject_unsafe(local_path, "local_path")
+ auth = _upload_auth_env(sync_cfg)
+ if not auth:
+ raise RuntimeError(
+ "no usable Google Drive OAuth token configured — set a token under "
+ "Sync GDrive (a service account cannot own files in a personal Drive)"
+ )
+ rclone = _rclone_path()
+ _ensure_remote(rclone)
+ cmd = [
+ rclone,
+ "copy",
+ local_path,
+ "posters:",
+ "--drive-root-folder-id",
+ folder_id,
+ "--drive-use-trash=false",
+ "--no-update-modtime",
+ "-v",
+ ]
+ # Serialised per folder: see _FOLDER_LOCKS. rclone replaces a same-named file
+ # correctly on its own — it only duplicates when two runs overlap.
+ with _folder_lock(folder_id):
+ result = _run_rclone(cmd, auth)
+ if result.returncode != 0:
+ raise RuntimeError(
+ f"rclone copy failed: {_rclone_error_detail(result.stderr)}"
+ )
+ _reap_duplicates(os.path.basename(local_path), folder_id, sync_cfg, logger)
+ logger.debug(f"uploaded {os.path.basename(local_path)} to drive {folder_id}")
+
+
+# rclone filter metacharacters. CL2K names carry `{tmdb-…}`, and `{}` is
+# alternation — unescaped it matches the wrong thing.
+_FILTER_META = re.compile(r"([\\*?\[\]{}])")
+
+
+def _filter_literal(name: str) -> str:
+ """``name`` as an rclone filter that matches it and nothing else."""
+ return "/" + _FILTER_META.sub(r"\\\1", name)
+
+
+def _reap_duplicates(name: str, folder_id: str, sync_cfg: Any, logger) -> None:
+ """Collapse same-named copies of ``name``, keeping the newest — ours.
+
+ Scoped to ``name``: an upload of one file must never delete another file's
+ duplicates. Only runs when a duplicate of what we just wrote exists, so the
+ normal path issues no destructive command. Never raises — the upload
+ succeeded, and failing to tidy up must not report it as failed.
+ """
+ try:
+ names = list_files(folder_id, sync_cfg, logger, strict=True)
+ if sum(1 for n in names if n == name) < 2:
+ return
+ logger.warning(
+ f"CL2K drive {folder_id}: {name} exists more than once — keeping the newest"
+ )
+ auth = _upload_auth_env(sync_cfg)
+ rclone = _rclone_path()
+ result = _run_rclone(
+ [
+ rclone,
+ "dedupe",
+ "--dedupe-mode",
+ "newest",
+ "posters:",
+ "--drive-root-folder-id",
+ folder_id,
+ "--drive-use-trash=false",
+ # dedupe honours filters from rclone 1.61.
+ "--include",
+ _filter_literal(name),
+ ],
+ auth,
+ )
+ if result.returncode != 0:
+ logger.warning(
+ f"CL2K drive dedupe failed: {_rclone_error_detail(result.stderr)}"
+ )
+ except Exception as exc:
+ logger.warning(f"CL2K drive duplicate check failed for {name}: {exc}")
+
+
+def move_file(
+ old_name: str,
+ new_name: str,
+ folder_id: str,
+ sync_cfg: Any,
+ logger,
+) -> None:
+ """Rename a file in the Drive folder ``folder_id`` (``old_name`` -> ``new_name``).
+
+ A server-side rename via ``rclone moveto``, authenticated as the user with the
+ Sync GDrive OAuth token (the same write path as :func:`upload_file`) so it can
+ write to the user's own Drive. Both names are relative to ``folder_id`` (the
+ rclone root). Used by the poster healer to rewrite a poster's embedded id /
+ title on the user's Drive. Raises on a missing token or non-zero rclone exit.
+ """
+ _reject_unsafe_id(folder_id, "gdrive_folder_id")
+ _reject_unsafe(old_name, "gdrive_old_name")
+ _reject_unsafe(new_name, "gdrive_new_name")
+ auth = _upload_auth_env(sync_cfg)
+ if not auth:
+ raise RuntimeError(
+ "no usable Google Drive OAuth token configured — set a token under "
+ "Sync GDrive (a service account cannot own files in a personal Drive)"
+ )
+ rclone = _rclone_path()
+ _ensure_remote(rclone)
+ cmd = [
+ rclone,
+ "moveto",
+ f"posters:{old_name}",
+ f"posters:{new_name}",
+ "--drive-root-folder-id",
+ folder_id,
+ "--drive-use-trash=false",
+ "--no-update-modtime",
+ "-v",
+ ]
+ # Upload's lock: this is the folder's other writer, and a rename onto a name
+ # an upload is creating duplicates the same way.
+ with _folder_lock(folder_id):
+ result = _run_rclone(cmd, auth)
+ if result.returncode != 0:
+ raise RuntimeError(
+ f"rclone moveto failed: {_rclone_error_detail(result.stderr)}"
+ )
+ logger.debug(f"renamed {old_name} -> {new_name} in drive {folder_id}")
+
+
+def list_files(folder_id: str, sync_cfg: Any, logger, strict: bool = False) -> List[str]:
+ """List the file names (top level, files only) in the Drive folder
+ ``folder_id`` via ``rclone lsf``.
+
+ Used by the poster healer's live-Drive source so it can heal posters saved
+ straight to Drive (a Drive-only save is never recorded in poster_cache).
+ Authenticated with the Sync GDrive OAuth token, like upload_file/move_file.
+ Returns [] (logging a warning) on a missing token or any rclone failure — a
+ Drive-listing problem must not abort a run that may still have local posters
+ to heal. Names are returned relative to ``folder_id`` (the rclone root), the
+ same form move_file expects.
+
+ ``strict=True`` RAISES instead of returning [] — required by any caller that
+ reads an empty listing as "the name is free". Returning [] on failure would
+ make a transient listing error look like "no collision" and let a destructive
+ rename proceed.
+ """
+ _reject_unsafe_id(folder_id, "gdrive_folder_id")
+ auth = _upload_auth_env(sync_cfg)
+ if not auth:
+ if strict:
+ raise RuntimeError(
+ "no usable Google Drive OAuth token configured — cannot list "
+ "the folder to check for a name collision"
+ )
+ logger.warning(
+ "poster_self_heal: no Google Drive OAuth token — skipping live Drive "
+ "listing (set one under Sync GDrive)"
+ )
+ return []
+ rclone = _rclone_path()
+ _ensure_remote(rclone)
+ cmd = [
+ rclone,
+ "lsf",
+ "posters:",
+ "--drive-root-folder-id",
+ folder_id,
+ "--files-only",
+ ]
+ result = _run_rclone(cmd, auth)
+ if result.returncode != 0:
+ detail = _rclone_error_detail(result.stderr)
+ if strict:
+ raise RuntimeError(f"rclone lsf failed: {detail}")
+ logger.warning(f"poster_self_heal: rclone lsf failed: {detail}")
+ return []
+ return [ln.strip() for ln in result.stdout.splitlines() if ln.strip()]
+
+
+# image_type -> the Drive subfolder the community artwork drives use for it. A
+# FIXED map, never user input, so these never widen the rclone argument surface.
+TYPE_SUBFOLDERS = {
+ "logo": "logos",
+ "background": "backgrounds",
+ "squareart": "squareart",
+}
+
+
+def ensure_type_subfolders(folder_id: str, sync_cfg: Any, logger) -> List[dict]:
+ """Create (or find) ``logos``/``backgrounds``/``squareart`` under ``folder_id``.
+
+ Returns one ``{image_type, name, folder_id, created}`` per subfolder so the
+ caller can store real child ids — the upload path still addresses exactly one
+ Drive folder per destination. Raises on a missing token or rclone failure.
+ """
+ _reject_unsafe_id(folder_id, "gdrive_folder_id")
+ auth = _upload_auth_env(sync_cfg)
+ if not auth:
+ raise RuntimeError(
+ "no usable Google Drive OAuth token configured — set a token under "
+ "Sync GDrive (a service account cannot own files in a personal Drive)"
+ )
+ rclone = _rclone_path()
+ _ensure_remote(rclone)
+
+ def _dir_ids() -> dict:
+ result = _run_rclone(
+ [
+ rclone,
+ "lsjson",
+ "posters:",
+ "--drive-root-folder-id",
+ folder_id,
+ "--dirs-only",
+ ],
+ auth,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(
+ f"rclone lsjson failed: {_rclone_error_detail(result.stderr)}"
+ )
+ # Keep only usable ids: a blank/absent/non-string ID must NOT register the
+ # name, or the completeness check below passes and we hand back a routed
+ # row whose folder_id is empty — which _drive_targets skips silently, so
+ # that art type would just stop uploading with no error anywhere.
+ return {
+ d["Name"]: d["ID"]
+ for d in json.loads(result.stdout or "[]")
+ if isinstance(d.get("ID"), str) and d["ID"].strip()
+ }
+
+ existing = _dir_ids()
+ made = []
+ for name in TYPE_SUBFOLDERS.values():
+ if name in existing:
+ continue
+ result = _run_rclone(
+ [rclone, "mkdir", f"posters:{name}", "--drive-root-folder-id", folder_id],
+ auth,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(
+ f"rclone mkdir '{name}' failed: {_rclone_error_detail(result.stderr)}"
+ )
+ made.append(name)
+
+ # Re-list once so newly created folders come back with their real ids.
+ ids = _dir_ids() if made else existing
+ missing = [n for n in TYPE_SUBFOLDERS.values() if n not in ids]
+ if missing:
+ raise RuntimeError(f"Drive did not return an id for: {', '.join(missing)}")
+ logger.info(
+ f"cl2k: type subfolders under {folder_id} — created {made or 'none'}, "
+ f"reused {[n for n in TYPE_SUBFOLDERS.values() if n not in made]}"
+ )
+ return [
+ {
+ "image_type": image_type,
+ "name": name,
+ "folder_id": ids[name],
+ "created": name in made,
+ }
+ for image_type, name in TYPE_SUBFOLDERS.items()
+ ]
+
+
+def delete_file(name: str, folder_id: str, sync_cfg: Any, logger) -> None:
+ """Delete the single file ``name`` from Drive folder ``folder_id`` via
+ ``rclone deletefile``. ``name`` is relative to ``folder_id`` (the rclone
+ root). Authenticated with the Sync GDrive OAuth token. Raises on a missing
+ token or non-zero rclone exit."""
+ _reject_unsafe_id(folder_id, "gdrive_folder_id")
+ _reject_unsafe(name, "gdrive_name")
+ auth = _upload_auth_env(sync_cfg)
+ if not auth:
+ raise RuntimeError("no usable Google Drive OAuth token configured")
+ rclone = _rclone_path()
+ _ensure_remote(rclone)
+ cmd = [
+ rclone,
+ "deletefile",
+ f"posters:{name}",
+ "--drive-root-folder-id",
+ folder_id,
+ "--drive-use-trash=false",
+ ]
+ result = _run_rclone(cmd, auth)
+ if result.returncode != 0:
+ raise RuntimeError(
+ f"rclone deletefile failed: {_rclone_error_detail(result.stderr)}"
+ )
+
+
+def test_drive_access(folder_id: str, sync_cfg: Any, logger) -> str:
+ """Verify CHUB can UPLOAD to Drive folder ``folder_id``.
+
+ Copies a tiny marker file into the folder (proving write access as the
+ user), then deletes it. Raises ``RuntimeError`` with a clear reason on any
+ failure (no token / bad folder id / auth / rclone error). Returns a short
+ success detail string. The marker is a ``.txt`` (never a poster image type)
+ so a concurrent sync would ignore it even in the rare window before delete.
+ """
+ _reject_unsafe_id(folder_id, "gdrive_folder_id")
+ if not has_upload_token(sync_cfg):
+ raise RuntimeError(
+ "no usable Google Drive OAuth token configured — set a token under "
+ "Sync GDrive (a service account cannot own files in a personal Drive)"
+ )
+ marker = f".chub_upload_test_{uuid.uuid4().hex}.txt"
+ tmpdir = tempfile.mkdtemp(prefix="cl2k_test_")
+ local = os.path.join(tmpdir, marker)
+ try:
+ with open(local, "w", encoding="utf-8") as fh:
+ fh.write("CHUB upload connectivity test — safe to delete.\n")
+ upload_file(local, folder_id, sync_cfg, logger)
+ try:
+ delete_file(marker, folder_id, sync_cfg, logger)
+ return "Uploaded and removed a test file — upload works."
+ except Exception as exc:
+ # Write already succeeded (the real thing we're testing); only the
+ # cleanup failed. Report success but flag the leftover.
+ logger.warning(f"cl2k test-drive: cleanup of {marker} failed: {exc}")
+ return (
+ "Upload works, but the temporary test file could not be removed "
+ f"({marker}) — you may want to delete it manually."
+ )
+ finally:
+ shutil.rmtree(tmpdir, ignore_errors=True)
+
+
+def _rclone_error_detail(stderr: str) -> str:
+ """Pull the meaningful cause out of rclone stderr.
+
+ rclone prints a long Drive-API request URL *before* the real error, so a plain
+ head-truncation (``[:300]``) hides the cause (the 403/404/auth detail). Prefer
+ the explicit Google/OAuth error line; otherwise fall back to the TAIL (where
+ rclone's final summary lives), and strip query strings so a redirect/URL with a
+ token can't leak into the log.
+ """
+ err = (stderr or "").strip()
+ if not err:
+ return "rclone exited non-zero with no stderr"
+ # Drop query strings (may contain tokens) before logging anything.
+ err = re.sub(r"\?[^\s\"']+", "?…", err)
+ m = re.search(
+ r"(googleapi: Error \d+:[^\n]+|Error \d{3}[^\n]*|invalid_grant[^\n]*"
+ r"|oauth2:[^\n]+|couldn't fetch token[^\n]*|insufficient[^\n]*"
+ r"|File not found[^\n]*|not found:[^\n]*)",
+ err,
+ re.IGNORECASE,
+ )
+ if m:
+ return m.group(1).strip()[:300]
+ return err[-300:] # tail: rclone's final error summary
diff --git a/backend/util/cl2k/geometry.py b/backend/util/cl2k/geometry.py
new file mode 100644
index 00000000..ba368513
--- /dev/null
+++ b/backend/util/cl2k/geometry.py
@@ -0,0 +1,299 @@
+"""Locked CL2K poster geometry — the single source of truth for the layout.
+
+Every value here was extracted directly from the community ``CL2K_template.psd``
+(canvas 1000x1500 @ 72dpi): layer bounds, Photoshop ruler guides, and the
+gradient alpha ramp (sampled down the centre column). The DAPS "create posters
+the right way" rules and the CL2K style notes (DAPS gdrives.md) are encoded
+alongside so the renderer and the frontend guideline overlay both read from one
+place and never drift.
+
+Do not hand-tune these values — if the template ever changes, re-extract from
+the PSD. See memory ``cl2k-poster-maker-spec`` for the extraction method.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Optional
+
+# ----- canvas ----------------------------------------------------------------
+CANVAS_W = 1000
+CANVAS_H = 1500
+DPI = 72
+ASPECT = (2, 3)
+
+# ----- logo placement (px on the 1000x1500 canvas) ---------------------------
+CENTER_X = 500
+LOGO_WIDTH_STD = 600 # guide "Main Logo Width" (x200->800). Refs render ~666.
+LOGO_WIDTH_RECOMMENDED = 700 # creator's own extra guide (x150->850) — "the one I
+# use the most for logo width"; the maker's default.
+LOGO_WIDTH_MAX = 800 # guide "Max Logo Width" (x100->900) — hard cap
+# CL2K rule: "only leave the Main-Logo *text* area if the logo is too small or
+# unreadable". A clear logo whose trimmed content is narrower than this would need
+# heavy upscaling to reach the logo box and render fuzzy, so we reject it and draw
+# the title wordmark instead. ~0.57x the default 700px box, i.e. up to ~1.75x
+# plain upscale; below the gate the sidecar's super-resolution rescue
+# (ai_logo_upscale) gets a shot before the text fallback.
+LOGO_MIN_WIDTH = 400
+
+# Logo-trim cutoff: alpha <= this is padding, above is content. An explicit
+# threshold, NOT ImageMagick trim fuzz (HDRI builds cut fuzz at sqrt(2)x).
+LOGO_TRIM_ALPHA = 8
+
+# ----- automatic logo sizing --------------------------------------------------
+# Ported from PosterFlow's Photopea panel (dweagle/posterflow, computeLogoGeometry),
+# which sizes a logo from its own pixel area and projected height instead of a flat
+# width. That matters because a flat LOGO_WIDTH_RECOMMENDED under-sizes wide
+# wordmarks — hand-made CL2K references run ~846-881px — while over-sizing tall
+# or square artwork, which then gets height-clamped anyway. PosterFlow feeds a
+# NEUTRAL density constant rather than measuring ink coverage (aspect and pixel
+# area dominate the result), and this mirrors that; the parameter is kept so a
+# caller can pass a measured value later.
+LOGO_AUTO_CEILING_SMALL = 0.85 # source below 0.2 MP
+LOGO_AUTO_CEILING_MID = 0.84
+LOGO_AUTO_CEILING_LARGE = 0.93 # source above 1.5 MP
+LOGO_AUTO_REF_H = 90 # reference projected height, px on a 1000px-wide canvas
+LOGO_AUTO_FALLOFF = 0.40 # exponent applied to refH / projected height
+LOGO_AUTO_FLOOR = 0.58 # never narrower than this fraction of the canvas
+LOGO_AUTO_DENSITY = 0.30 # the neutral ink fraction the curve is tuned around
+LOGO_AUTO_WIDE_FRAC = 0.60 # past this width the height cap tightens
+LOGO_AUTO_WIDE_MAX_H = 225 # px on a 1500px-tall canvas
+
+
+def auto_logo_size(
+ src_w: int, src_h: int, baseline: int, density: float = LOGO_AUTO_DENSITY
+) -> tuple:
+ """Target ``(width, height)`` for a clear logo, from its own shape.
+
+ Wide artwork is allowed out toward the 800px max guide but capped shorter;
+ tall artwork is pulled narrower by the projected-height falloff so it fits
+ the 1100->baseline zone without being clipped. Aspect ratio is preserved.
+ """
+ if src_w <= 0 or src_h <= 0:
+ return LOGO_WIDTH_RECOMMENDED, 0
+ px = src_w * src_h
+ if px < 200_000:
+ ceiling = LOGO_AUTO_CEILING_SMALL
+ elif px > 1_500_000:
+ ceiling = LOGO_AUTO_CEILING_LARGE
+ else:
+ ceiling = LOGO_AUTO_CEILING_MID
+
+ proj_h = src_h * (LOGO_WIDTH_MAX / src_w) # height if drawn at the max guide
+ ref_h = CANVAS_W * (LOGO_AUTO_REF_H / 1000.0)
+ ratio = ceiling * (ref_h / max(proj_h, ref_h)) ** LOGO_AUTO_FALLOFF
+ floor = LOGO_AUTO_FLOOR + max(0.0, density - LOGO_AUTO_DENSITY) * 0.10
+ target_w = round(CANVAS_W * max(floor, min(ceiling, ratio)))
+
+ zone_h = baseline - LOGO_ZONE_TOP
+ wide = target_w > round(CANVAS_W * LOGO_AUTO_WIDE_FRAC)
+ max_h = round(CANVAS_H * (LOGO_AUTO_WIDE_MAX_H / 1500.0)) if wide else zone_h
+ if density < LOGO_AUTO_DENSITY: # sparse artwork carries a larger box
+ mult = 1.0 + ((LOGO_AUTO_DENSITY - density) / LOGO_AUTO_DENSITY) * 0.15
+ target_w = min(round(target_w * mult), LOGO_WIDTH_MAX)
+ max_h = min(round(max_h * mult), zone_h)
+ elif density > 0.60: # dense artwork reads heavy, so tighten it
+ t = (density - 0.60) / 0.40
+ target_w = round(target_w * (1.0 - t * 0.10))
+ max_h = round(CANVAS_H * (LOGO_AUTO_WIDE_MAX_H / 1500.0) * (1.0 - t * 0.55))
+
+ scale = target_w / src_w
+ if src_h * scale > max_h:
+ scale = max_h / src_h
+ if src_w * scale > LOGO_WIDTH_MAX:
+ scale = LOGO_WIDTH_MAX / src_w
+ return max(1, round(src_w * scale)), max(1, round(src_h * scale))
+# Verified against the PSDs in refs/ (template + 3 finished posters, 2026-06-13):
+# all four embed identical guides — y = 1100 ("Main Logo Height"), 1319
+# ("Collection Logo Bottom"), 1352 ("Main Logo Bottom"), 1375 ("Gradient
+# Darkest") — and every finished poster's LOGO layer bottoms out at EXACTLY
+# 1352 (Wonka's boxy logo fills the full 1100→1352 zone). The old 1300 came
+# from measuring two off-template JPG refs; don't regress to it.
+LOGO_ZONE_TOP = 1100 # "Main Logo Height" — logos must not extend above this y
+MAIN_LOGO_BOTTOM = 1352 # "Main Logo Bottom" — movie/show/season clear-logo bottom
+COLLECTION_LOGO_BOTTOM = 1319 # "Collection Logo Bottom" (COLLECTION label below it)
+
+# ----- interactive control ranges --------------------------------------------
+# One source of truth for the maker's size/position/zoom sliders: the API request
+# models validate against these (pydantic Field/Form ge/le), the renderers clamp
+# to them, and the frontend mirrors them (CONTROL_RANGES in Cl2kMakerPage.jsx —
+# keep both in sync). logo_scale relaxes the height clamp; logo_y_offset shifts the
+# logo off its baseline; zoom enlarges/shrinks the framed backdrop.
+LOGO_SCALE_MIN, LOGO_SCALE_MAX = 0.25, 3.0
+LOGO_Y_OFFSET_MIN, LOGO_Y_OFFSET_MAX = -600, 200
+ZOOM_MIN, ZOOM_MAX = 0.5, 3.0
+# The one vertical framing control; 0 = centred crop. Negative pans up into real
+# source only, positive pans down and may edge-extend into the gradient zone.
+V_POS_MIN, V_POS_MAX = -1.0, 1.0
+
+# ----- 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
+# black (verified against a creator poster: the colored TMDB DBS-Broly logo with
+# exactly this mapping reproduces their white logo, including the black SUPER
+# badge). Two passes over the colored clear logo:
+# 1. key = max(HSL saturation, lightness), leveled to near-binary — saturated or
+# bright pixels white, dark unsaturated keylines black.
+# 2. local-contrast: pixels much darker in luma than their gaussian-blurred
+# neighborhood flip back to black — recovers same-saturation interior details
+# a per-pixel rule can't see (the star inside the Dragon Ball "O").
+# If the result would be mostly black (a dark unsaturated logo would vanish into
+# the gradient) fall back to the flat white silhouette.
+WHITEN_KEY_BLACK = 0.30 # level black point of the max(sat,light) key
+WHITEN_KEY_WHITE = 0.40 # level white point (steep ramp = two-tone)
+# Neighborhood blur sigma (fraction of logo width) for the keyline pass. Kept
+# small so the "darker-than-neighborhood" test resolves a CRISP thin keyline; a
+# wide blur (the old 0.025 ≈ 45px) turned every tonal transition on a busy
+# multicoloured logo — e.g. Dragon Ball GT — into a soft muddy black halo. Wide
+# dark BODIES (which a small blur would leave white-cored) are instead filled by
+# logo_extract.fill_dark_bodies, a shape-based post-pass, not by widening this.
+WHITEN_DETAIL_SIGMA = 0.008
+WHITEN_DETAIL_LO = 0.14 # darker-than-neighborhood ramp start (luma delta)
+WHITEN_DETAIL_HI = 0.22 # ...and full-black point
+WHITEN_FALLBACK_MEAN = 0.30 # opaque-area key mean below this -> flat white
+
+# ----- text bands ------------------------------------------------------------
+# Template type layers (refs/ PSDs): COLLECTION bbox y=1338-1362 → centre 1350;
+# SEASON/SPECIALS/LIMITED bbox y=1428-1452 → centre 1440.
+COLLECTION_LABEL_Y = 1350 # centre of "COLLECTION", just below the collection logo
+SEASON_TEXT_Y = 1440 # centre of the season/specials band
+
+# ----- gradient --------------------------------------------------------------
+# Vertical transparent->black ramp, sampled straight out of the template's own
+# flattened composite (its POSTER group is empty, so above the border the
+# composite alpha IS the gradient). Measured 2026-07-28: fully opaque from
+# y=1374, which agrees with the PSD's "Gradient Darkest Line" guide (y=1375) to
+# within a pixel. The fill layer is dithered, so a single column first lifts off
+# zero at y=1038 while the column-averaged ramp the asset ships does so at
+# y=1037; these constants describe the ASSET, which is what actually renders.
+#
+# An earlier comment here claimed the PSD "blacks out from y~839" and therefore
+# contradicted its own guide, and gradient.png was hand-generated from a
+# smoothstep starting at y=780 to work around that. No reading of the template
+# reproduces y~839; the substitute ramp darkened 240px higher than the template
+# (alpha 79/255 at y=1000 where the template is still perfectly clear). Don't
+# reinstate it. Regenerate with scripts/gen_cl2k_gradient.py.
+GRADIENT_START_Y = 1037
+GRADIENT_FULL_BLACK_Y = 1374
+
+# ----- typography ------------------------------------------------------------
+# Exact values read from the PSD type layers.
+# - Labels (COLLECTION / SEASON / SPECIALS) from CL2K_template.psd: Arial
+# *Regular*, 32px @72dpi, white, centred, tracking 800 (600 for the long
+# "COMPLETE LIMITED SERIES"). Tracking is Photoshop's 1/1000-em unit.
+# - Title fallback from the MM2K poster.psd ("MIDDLE BOTTOM" slot): Arial
+# *Bold*, 97px main / 48px secondary, white, centred, tracking 0.
+# Real Arial is provided in-container by ttf-mscorefonts-installer and is
+# already present on macOS dev. Arial is proprietary — never commit it.
+LABEL_FONT_PX = 32
+LABEL_TRACKING = 800 # COLLECTION / SEASON / SPECIALS
+LABEL_TRACKING_LONG = 600 # "COMPLETE LIMITED SERIES" (longer string)
+LABEL_BANNER_LONG = "COMPLETE LIMITED SERIES" # the one template label at 600
+TITLE_FONT_PX = 97 # main title line (logo-less fallback)
+TITLE_FONT_PX_SMALL = 48 # secondary title line
+TITLE_CENTER_Y = 1319 # centre of the MM2K "MIDDLE BOTTOM" band (1284-1354)
+TEXT_COLOR = "white"
+
+# Real-Arial candidates, first existing wins (mscorefonts in-container, macOS dev).
+# Liberation Sans is Arial-metric-compatible — a guaranteed last resort so a host
+# without mscorefonts doesn't silently render ImageMagick's default typeface.
+_ARIAL_REGULAR_CANDIDATES = (
+ "/usr/share/fonts/truetype/msttcorefonts/Arial.ttf",
+ "/System/Library/Fonts/Supplemental/Arial.ttf",
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
+ "/usr/share/fonts/truetype/liberation2/LiberationSans-Regular.ttf",
+)
+_ARIAL_BOLD_CANDIDATES = (
+ "/usr/share/fonts/truetype/msttcorefonts/Arial_Bold.ttf",
+ "/System/Library/Fonts/Supplemental/Arial Bold.ttf",
+ "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
+ "/usr/share/fonts/truetype/liberation2/LiberationSans-Bold.ttf",
+)
+
+
+def resolve_font(bold: bool = False) -> Optional[str]:
+ """Return the first available real-Arial path (bold or regular), else None.
+
+ None lets ImageMagick fall back to its default font.
+ """
+ for path in _ARIAL_BOLD_CANDIDATES if bold else _ARIAL_REGULAR_CANDIDATES:
+ if Path(path).exists():
+ return path
+ return None
+
+
+def tracking_to_kerning(tracking: int, font_px: int = LABEL_FONT_PX) -> float:
+ """Convert Photoshop tracking (1/1000 em) to ImageMagick pixel kerning."""
+ return tracking / 1000.0 * font_px
+
+
+def label_tracking(text: str) -> int:
+ """Photoshop tracking for a bottom-band label.
+
+ Every type layer in the template is tracked 800 EXCEPT
+ ``COMPLETE LIMITED SERIES``, which drops to 600 so it fits the width. This
+ used to be approximated with ``len(text) > 16``, which is wrong in both
+ directions: the template's own spelled-out season labels are longer than
+ that ("SEASON FIFTY-NINE" is 17), so roughly a third of all seasons were
+ silently tightened to 600. Anything AS LONG AS the template's own long
+ banner falls back to 600 — a different 23-character label is at least as
+ wide as COMPLETE LIMITED SERIES and needs the same relief — so this is a
+ length test, not a name match.
+ """
+ txt = (text or "").upper()
+ if len(txt) >= len(LABEL_BANNER_LONG):
+ return LABEL_TRACKING_LONG
+ return LABEL_TRACKING
+
+
+# ----- border ----------------------------------------------------------------
+# The template's BORDER LAYER is an effects-only layer (fill opacity 0) carrying
+# TWO effects: a white Stroke (Style=Inside, Size=25px, Normal, 100%) and a black
+# Inner Glow (Multiply, 70%, technique Softer, source Edge, choke 50, size 45px,
+# range 50%, contour Linear). The glow is what makes the frame read as a window
+# cut into the art rather than a decal laid on top of it; it ships as a pre-baked
+# alpha field (INNER_GLOW_PNG) because a gaussian fit does not reproduce
+# Photoshop's Softer/choke curve. 26 was borrowed from border_replacerr's own
+# config default, which is unrelated to the CL2K stroke — don't restore it.
+BORDER_WIDTH = 25 # PSD FrFX Stroke: Style=Inside, Size=25px
+BORDER_COLOR = "white"
+GLOW_REACH = 48 # glow alpha reaches 0 exactly 48px in from the canvas edge
+
+# ----- output (DAPS rules) ---------------------------------------------------
+OUTPUT_EXT = ".jpg" # lowercase, per DAPS
+# Real CL2K community posters encode at ~q99 with NO chroma subsampling (4:4:4).
+# The old q88 (+ libjpeg's default 4:2:0) made our output visibly softer and ~3x
+# smaller than a hand-made poster. q99 + 4:4:4 matches the hand-made reference
+# encode exactly.
+OUTPUT_QUALITY = 99 # was 88→95; match hand-made CL2K reference exactly
+JPEG_SAMPLING_FACTOR = "1x1,1x1,1x1" # 4:4:4 — no chroma subsampling (full colour)
+JPEG_PROGRESSIVE = True # progressive scan (SOF2), as hand-made refs encode.
+# Purely the storage byte-order, NOT a quality change (same pixels); matches the
+# reference convention and is often marginally smaller.
+TEXT_UPPERCASE = True # text is ALWAYS all-caps
+
+# ----- bundled assets --------------------------------------------------------
+ASSET_DIR = Path(__file__).resolve().parents[2] / "assets" / "cl2k"
+GRADIENT_PNG = ASSET_DIR / "gradient.png"
+INNER_GLOW_PNG = ASSET_DIR / "inner_glow.png"
+
+# ----- guideline overlay (consumed by the frontend preview) ------------------
+# Each entry: (label, orientation, position). "x" = vertical line, "y" = horizontal.
+GUIDES = (
+ ("Max logo width", "x", 100),
+ ("Recommended logo width 700", "x", 150),
+ ("Logo width 600", "x", 200),
+ ("Centre", "x", CENTER_X),
+ ("Logo width 600", "x", 800),
+ ("Recommended logo width 700", "x", 850),
+ ("Max logo width", "x", 900),
+ ("Logo zone top", "y", LOGO_ZONE_TOP),
+ ("Main logo bottom", "y", MAIN_LOGO_BOTTOM),
+ ("Collection logo bottom", "y", COLLECTION_LOGO_BOTTOM),
+ ("Gradient darkest", "y", GRADIENT_FULL_BLACK_Y),
+)
+
+
+def logo_baseline(kind: str) -> int:
+ """Return the logo bottom baseline for a media kind."""
+ return COLLECTION_LOGO_BOTTOM if kind.lower() == "collection" else MAIN_LOGO_BOTTOM
diff --git a/backend/util/cl2k/image_fetch.py b/backend/util/cl2k/image_fetch.py
new file mode 100644
index 00000000..fc1aea6d
--- /dev/null
+++ b/backend/util/cl2k/image_fetch.py
@@ -0,0 +1,392 @@
+"""Select + download CL2K render inputs (textless backdrop + sharp clear logo).
+
+Logo source chain is TMDB -> fanart.tv -> (caller falls back to a generated
+text-logo via renderer.generate_text_logo). The selection here encodes the two
+hard-won rules:
+
+- **Backdrops must be textless** — prefer language-neutral art (TMDB
+ ``iso_639_1`` null/empty) so we never composite a credits-laden poster.
+- **Logos are chosen by resolution**, not popularity — TMDB's highest-*voted*
+ logo can be a soft low-res upload (a 797px Matrix logo outvoted the sharp
+ 2000px one). Resolution-first keeps logos crisp when scaled to the 600px box.
+
+The selection functions are pure (they take the raw TMDB ``images`` lists), so
+they are unit-testable without network or the TMDB client. ``download`` is a
+thin fetch of the original-resolution CDN asset (no key needed for images).
+"""
+
+from __future__ import annotations
+
+import posixpath
+import re
+import threading
+from collections import OrderedDict
+from typing import Any, Dict, List, Optional
+
+from backend.util.cl2k import geometry as geo
+
+TMDB_IMAGE_CDN = "https://image.tmdb.org/t/p/original"
+
+
+def select_backdrop(
+ backdrops: List[Dict[str, Any]],
+ min_height: int = geo.CANVAS_H,
+) -> Optional[str]:
+ """Return the best *textless* backdrop ``file_path``, or None.
+
+ The backdrop *is* the whole poster background — cover-resized to the 1000×1500
+ canvas — so resolution matters as much as curation. Selection is therefore
+ two-tier:
+
+ 1. Among textless candidates, prefer those tall enough to fill the canvas
+ without upscaling (``height >= min_height``) and rank *those* by vote, so
+ TMDB's curation decides among the sharp options.
+ 2. If none are tall enough, fall back to the highest-resolution candidate so
+ we upscale as little as possible.
+
+ Language-neutral art (no ``iso_639_1``) is strongly preferred; only if none
+ exists do we consider language-tagged backdrops.
+ """
+ if not backdrops:
+ return None
+ textless = [b for b in backdrops if not b.get("iso_639_1")]
+ pool = textless or backdrops
+ sharp = [b for b in pool if b.get("height", 0) >= min_height]
+ if sharp:
+ ranked = sorted(
+ sharp,
+ key=lambda b: (b.get("vote_average", 0), b.get("vote_count", 0)),
+ reverse=True,
+ )
+ else:
+ # Nothing fills the canvas natively — minimise upscaling by taking the
+ # largest available (vote only as a tiebreaker between equal sizes).
+ ranked = sorted(
+ pool,
+ key=lambda b: (
+ b.get("height", 0),
+ b.get("width", 0),
+ b.get("vote_average", 0),
+ ),
+ reverse=True,
+ )
+ return ranked[0].get("file_path")
+
+
+def select_logo(
+ logos: List[Dict[str, Any]],
+ lang: str = "en",
+) -> Optional[str]:
+ """Return the highest-*resolution* logo ``file_path`` (lang preferred).
+
+ Resolution drives sharpness, so vectors outrank everything (an SVG is
+ rasterized at ~2000px content width downstream — sharper than any raster),
+ then the widest PNG (vote as tiebreaker) rather than popularity.
+ """
+ if not logos:
+ return None
+ in_lang = [lg for lg in logos if lg.get("iso_639_1") == lang]
+ base = in_lang or logos
+ svg = [lg for lg in base if str(lg.get("file_path", "")).lower().endswith(".svg")]
+ if svg:
+ svg = sorted(
+ svg,
+ key=lambda lg: (lg.get("vote_average", 0), lg.get("width", 0)),
+ reverse=True,
+ )
+ return svg[0].get("file_path")
+ png = [lg for lg in base if str(lg.get("file_path", "")).lower().endswith(".png")]
+ pool = png or base
+ pool = sorted(
+ pool,
+ key=lambda lg: (lg.get("width", 0), lg.get("vote_average", 0)),
+ reverse=True,
+ )
+ return pool[0].get("file_path")
+
+
+def _plex_netlocs() -> set:
+ """host:port of every configured Plex instance.
+
+ The Plex artwork source returns image URLs on the user's own Plex server, so
+ those endpoints must pass the SSRF allowlist below. Matching the exact
+ ``host:port`` (not just the hostname) keeps the opening as tight as possible
+ — only the Plex server the user configured, not other services on that host.
+ Read from config each call (cheap; config is cached) so a newly-added
+ instance is honoured without a restart."""
+ from urllib.parse import urlparse
+
+ try:
+ from backend.util.config import load_config
+
+ plex = getattr(load_config().instances, "plex", {}) or {}
+ out = set()
+ for cfg in plex.values():
+ nl = (urlparse(getattr(cfg, "url", "") or "").netloc or "").lower()
+ if nl:
+ out.add(nl)
+ return out
+ except Exception:
+ return set()
+
+
+def _plex_origin(url: str) -> tuple:
+ """(scheme, host:port) of a URL — the identity the token is keyed on."""
+ from urllib.parse import urlparse
+
+ p = urlparse(url)
+ return (p.scheme.lower(), (p.netloc or "").lower())
+
+
+def _is_allowed_image_host(url: str) -> bool:
+ """Allow only the known image CDNs (TMDB + fanart.tv + Plex's own CDN) and
+ the user's own configured Plex server(s).
+
+ ``download`` accepts absolute URLs that originate from request data
+ (``backdrop_path`` / ``logo_path``), so without a host allowlist the server
+ could be coerced into fetching arbitrary internal URLs (SSRF — e.g. cloud
+ metadata). Restricting to the hosts the maker legitimately uses closes that;
+ the user's Plex server is matched by exact host:port from config.
+
+ ``*.plex.tv`` is Plex's own infrastructure: the Plex artwork picker returns
+ remote-provider art (tmdb/fanarttv/gracenote) as absolute
+ ``metadata-static.plex.tv`` URLs, which must be fetchable or selecting any
+ non-uploaded Plex logo 500s. The same applies to every other agent CDN Plex
+ hands back as an absolute URL: ``artworks.thetvdb.com`` (TheTVDB, common on
+ shows) and ``m.media-amazon.com`` / ``*.ssl-images-amazon.com`` (IMDb art).
+ A live audit of the artwork picker across movies + shows surfaced exactly
+ these five provider CDNs (tmdb / fanart.tv / plex.tv / thetvdb.com /
+ media-amazon.com) plus the user's own Plex — anything else must be added here
+ or selecting that art 500s ("refusing to fetch image from disallowed host").
+ """
+ from urllib.parse import urlparse
+
+ parsed = urlparse(url)
+ host = (parsed.hostname or "").lower()
+ if (
+ host == "image.tmdb.org"
+ or host == "assets.fanart.tv"
+ or host.endswith(".fanart.tv")
+ or host == "plex.tv"
+ or host.endswith(".plex.tv")
+ or host == "thetvdb.com"
+ or host.endswith(".thetvdb.com")
+ or host.endswith(".media-amazon.com")
+ or host.endswith(".ssl-images-amazon.com")
+ ):
+ return True
+ return (parsed.netloc or "").lower() in _plex_netlocs()
+
+
+# Plex-instance URLs get the admin token minted, so only artwork-key paths may be
+# fetched. Canonical Plex-art-path check — api/cl2k_maker.py's proxy imports this.
+_PLEX_ART_PATH = re.compile(
+ r"^/library/metadata/\d+/"
+ r"(art|thumb|posters?|clearlogos?|banner|theme|composite|image|file)(?:/|$)",
+ re.IGNORECASE,
+)
+_PLEX_PROVIDER_URI = re.compile(r"^(?:upload|metadata|media)://", re.IGNORECASE)
+
+
+def _is_plex_art_path(url: str) -> bool:
+ """True only for a normalized Plex artwork-key URL (rejects ``..`` dot-segments
+ before matching). The ``file`` key needs a Plex provider ``?url=``."""
+ from urllib.parse import parse_qs, unquote, urlparse
+
+ parsed = urlparse(url)
+ path = unquote(parsed.path or "")
+ if ".." in path.split("/") or path != posixpath.normpath(path):
+ return False
+ match = _PLEX_ART_PATH.match(path)
+ if not match:
+ return False
+ if match.group(1).lower() == "file":
+ url_ref = parse_qs(parsed.query).get("url", [""])[0]
+ return bool(_PLEX_PROVIDER_URI.match(url_ref.strip()))
+ return True
+
+
+def _reject_nonart_plex(url: str) -> None:
+ """Raise (hostname-only message; callers surface it) if ``url`` targets a
+ configured Plex instance on a non-artwork path _with_plex_token would mint."""
+ from urllib.parse import urlparse
+
+ if (urlparse(url).netloc or "").lower() in _plex_netlocs() and not _is_plex_art_path(
+ url
+ ):
+ raise ValueError(
+ f"refusing to fetch non-artwork path from Plex host {urlparse(url).hostname!r}"
+ )
+
+
+def strip_plex_token(value: Optional[str]) -> Optional[str]:
+ """Drop the X-Plex-Token query param so the admin token is never persisted at
+ rest (e.g. in cl2k_generated.backdrop_path). download() re-mints it on fetch."""
+ if not isinstance(value, str) or "x-plex-token" not in value.lower():
+ return value
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
+
+ parts = urlsplit(value)
+ kept = [
+ (k, v)
+ for k, v in parse_qsl(parts.query, keep_blank_values=True)
+ if k.lower() != "x-plex-token"
+ ]
+ return urlunsplit(parts._replace(query=urlencode(kept)))
+
+
+def _plex_token_for(origin: tuple) -> Optional[str]:
+ """The X-Plex-Token (Plex instance api key) for the configured Plex server
+ whose ``(scheme, host:port)`` matches ``origin``."""
+ try:
+ from backend.util.config import load_config
+
+ plex = getattr(load_config().instances, "plex", {}) or {}
+ for cfg in plex.values():
+ if _plex_origin(getattr(cfg, "url", "") or "") == origin:
+ return getattr(cfg, "api", None) or None
+ except Exception:
+ return None
+ return None
+
+
+def _with_plex_token(url: str) -> str:
+ """Re-mint the X-Plex-Token for a tokenless Plex-server URL; no-op otherwise.
+ Scheme-matched, so the admin token never rides an ``http://`` URL in cleartext."""
+ if "x-plex-token" in url.lower():
+ return url
+ token = _plex_token_for(_plex_origin(url))
+ if not token:
+ return url
+ return f"{url}{'&' if '?' in url else '?'}X-Plex-Token={token}"
+
+
+def _unwrap_proxy(file_path: str) -> str:
+ """A CL2K plex-art proxy path (``/api/cl2k-maker/plex-art?src=``) is the
+ browser-facing form of local Plex art; when the frontend posts the chosen art
+ back for a render, unwrap ``?src=`` to the real (tokenless) Plex URL so the
+ server-side fetch hits Plex directly (and re-mints the token). No-op otherwise."""
+ if not file_path.startswith("/api/cl2k-maker/plex-art?"):
+ return file_path
+ from urllib.parse import parse_qs, urlparse
+
+ src = parse_qs(urlparse(file_path).query).get("src", [""])[0]
+ # Re-validate: a ?src= aimed at a configured Plex instance must be an artwork
+ # path (download re-checks) — never unwrap a crafted /:/prefs as pre-approved.
+ if (
+ src
+ and (urlparse(src).netloc or "").lower() in _plex_netlocs()
+ and not _is_plex_art_path(src)
+ ):
+ return file_path
+ return src or file_path
+
+
+# Recently downloaded originals, keyed by final URL. Live-preview slider tweaks
+# re-render the same backdrop/logo over and over; without this every /preview
+# request re-pulled the multi-MB original from the CDN, dominating preview
+# latency. Bounded by total bytes (originals are big), LRU eviction.
+_DL_CACHE: OrderedDict[str, bytes] = OrderedDict()
+_DL_CACHE_LOCK = threading.Lock()
+_DL_CACHE_MAX_BYTES = 64 * 1024 * 1024
+
+
+def _dl_cache_get(url: str) -> Optional[bytes]:
+ with _DL_CACHE_LOCK:
+ data = _DL_CACHE.get(url)
+ if data is not None:
+ _DL_CACHE.move_to_end(url)
+ return data
+
+
+def _dl_cache_put(url: str, data: bytes) -> None:
+ if len(data) > _DL_CACHE_MAX_BYTES:
+ return
+ with _DL_CACHE_LOCK:
+ _DL_CACHE[url] = data
+ _DL_CACHE.move_to_end(url)
+ total = sum(len(v) for v in _DL_CACHE.values())
+ while total > _DL_CACHE_MAX_BYTES and _DL_CACHE:
+ _, evicted = _DL_CACHE.popitem(last=False)
+ total -= len(evicted)
+
+
+def download(file_path: str, session=None) -> bytes:
+ """Download an image by TMDB path or absolute URL.
+
+ A bare TMDB ``file_path`` is fetched at original resolution from the CDN (no
+ API key needed); an absolute ``http(s)`` URL (e.g. a fanart.tv logo) is
+ fetched as-is — but only from the allowed image hosts (TMDB / fanart.tv), so a
+ crafted ``logo_path`` / ``backdrop_path`` can't turn this into an SSRF.
+
+ Successful fetches are kept in a small in-memory LRU so consecutive previews
+ of the same art don't re-download it. Calls with an explicit ``session``
+ bypass the cache (they control their own transport/fixtures).
+ """
+ import requests
+
+ from urllib.parse import urlparse
+
+ file_path = _unwrap_proxy(file_path)
+ url = file_path if file_path.startswith("http") else TMDB_IMAGE_CDN + file_path
+ if not _is_allowed_image_host(url):
+ raise ValueError(
+ f"refusing to fetch image from disallowed host: {urlparse(url).hostname!r}"
+ )
+ # Artwork paths only on a configured Plex instance (the token is minted below).
+ _reject_nonart_plex(url)
+ # Cache under the tokenless request URL, snapshotted BEFORE the mint so the
+ # live X-Plex-Token never lands in the module-level cache key.
+ cache_key = url
+ if session is None:
+ cached = _dl_cache_get(cache_key)
+ if cached is not None:
+ return cached
+ # Re-mint the Plex token for a persisted (token-stripped) backdrop_path.
+ url = _with_plex_token(url)
+ getter = session or requests
+ # Don't auto-follow redirects: an allowed host that 3xx-redirects to an internal
+ # address would defeat _is_allowed_image_host. Re-validate the host every hop.
+ resp = getter.get(url, timeout=15, allow_redirects=False)
+ hops = 0
+ while getattr(resp, "is_redirect", False) and hops < 4:
+ from urllib.parse import urljoin
+
+ nxt = urljoin(url, resp.headers.get("Location", ""))
+ if not _is_allowed_image_host(nxt):
+ raise ValueError(
+ f"refusing to follow redirect to disallowed host: {urlparse(nxt).hostname!r}"
+ )
+ _reject_nonart_plex(nxt)
+ url = _with_plex_token(nxt)
+ resp = getter.get(url, timeout=15, allow_redirects=False)
+ hops += 1
+ # A residual redirect here means the hop budget ran out — never cache a 3xx
+ # body as the image (raise_for_status does not treat 3xx as an error).
+ if getattr(resp, "is_redirect", False):
+ raise ValueError("too many redirects while fetching image")
+ # Hostname-only on failure: requests' HTTPError text embeds the full URL,
+ # which carries the minted X-Plex-Token, and callers surface {exc} to clients.
+ if not resp.ok:
+ raise ValueError(
+ f"image fetch failed ({resp.status_code}) for {urlparse(url).hostname!r}"
+ )
+ if session is None:
+ _dl_cache_put(cache_key, resp.content)
+ return resp.content
+
+
+def select_cl2k_inputs(
+ images: Dict[str, Any],
+ lang: str = "en",
+) -> Dict[str, Optional[str]]:
+ """Pick backdrop + logo ``file_path``s from a TMDB ``/images`` payload.
+
+ Returns ``{"backdrop": path|None, "logo": path|None}``. The fanart.tv logo
+ fallback and the generated-text-logo fallback are applied by the caller when
+ ``logo`` is None (they require the fanart client / renderer, wired later).
+ """
+ return {
+ "backdrop": select_backdrop(images.get("backdrops", [])),
+ "logo": select_logo(images.get("logos", []), lang=lang),
+ }
diff --git a/backend/util/cl2k/limits.py b/backend/util/cl2k/limits.py
new file mode 100644
index 00000000..8588a356
--- /dev/null
+++ b/backend/util/cl2k/limits.py
@@ -0,0 +1,52 @@
+"""Decode-size caps for the CL2K image paths — one owner for "how big an image
+may we decode", in Pillow (header-checked ceiling) and ImageMagick (wand limits)."""
+
+from __future__ import annotations
+
+import io
+
+from PIL import Image
+
+# Hard decode ceiling. Real art tops out around 8 MP (a 4K backdrop); posters are
+# well under 10. Anything past this is a bomb, not artwork.
+MAX_MEGAPIXELS = 64
+_MAX_PIXELS = MAX_MEGAPIXELS * 1_000_000
+
+# Process-global IM budget: area/memory/map spill to disk past the cap;
+# width/height are IM's only hard rejections, so they double as the bomb guard.
+_MAGICK_MEMORY = 512 * 1024 * 1024
+_MAGICK_MAP = 1024 * 1024 * 1024
+_MAGICK_AREA = 512 * 1024 * 1024
+_MAGICK_DISK = 4 * 1024 * 1024 * 1024
+_MAGICK_MAX_SIDE = 16384
+
+
+class ImageTooLargeError(ValueError):
+ """An image whose header dimensions exceed ``MAX_MEGAPIXELS``."""
+
+
+def open_bounded(data: bytes, mode: str) -> Image.Image:
+ """Decode ``data`` to ``mode``, rejecting oversized headers before any decode."""
+ try:
+ img = Image.open(io.BytesIO(data)) # lazy — .size is the header, no pixels
+ except Image.DecompressionBombError as exc: # past Pillow's own 2x ceiling
+ raise ImageTooLargeError(str(exc)) from exc
+ w, h = img.size
+ if w * h > _MAX_PIXELS:
+ raise ImageTooLargeError(
+ f"image is {w}x{h} ({w * h / 1e6:.1f} MP), over the "
+ f"{MAX_MEGAPIXELS} MP decode cap"
+ )
+ return img.convert(mode)
+
+
+def apply_magick_limits() -> None:
+ """Cap ImageMagick's pixel-cache and dimension budget for this process."""
+ from wand.resource import limits as magick_limits
+
+ magick_limits["memory"] = _MAGICK_MEMORY
+ magick_limits["map"] = _MAGICK_MAP
+ magick_limits["area"] = _MAGICK_AREA
+ magick_limits["disk"] = _MAGICK_DISK
+ magick_limits["width"] = _MAGICK_MAX_SIDE
+ magick_limits["height"] = _MAGICK_MAX_SIDE
diff --git a/backend/util/cl2k/logo_extract.py b/backend/util/cl2k/logo_extract.py
new file mode 100644
index 00000000..cc34b55e
--- /dev/null
+++ b/backend/util/cl2k/logo_extract.py
@@ -0,0 +1,1013 @@
+"""Extract a title/logo from a poster into a transparent PNG.
+
+Three keys, all confined by a brushed ``mask`` (white = look here), all pure
+Pillow + numpy, all finishing with an area-filter despeckle + trim:
+
+- :func:`extract_title_logo` — for *white* titles: keys bright / near-white pixels
+ via the minimum RGB channel (high only for white/grey). Outputs white pixels.
+- :func:`extract_subject_logo` — for *coloured* titles the brightness key can't
+ catch: keys each pixel by its Lab colour distance from the backdrop (sampled
+ just outside the brush), and keeps the title's ORIGINAL colours so the
+ downstream CL2K whiten pass can recolour it like any fetched logo.
+- :func:`extract_logo_by_diff` — after an AI erase: keys wherever the original
+ and the cleaned result differ inside the erase mask. The most faithful of the
+ three (it catches glows/soft shadows no colour key can), but only usable once
+ an erase has run.
+
+All are best-effort fallbacks for titles with no official clearlogo — a title
+baked into poster art can't be recovered pixel-perfect, so prefer a real
+TMDB/fanart/Plex logo when one exists.
+"""
+
+from __future__ import annotations
+
+import io
+from typing import Optional
+
+import numpy as np
+from PIL import Image
+
+from backend.util.cl2k.limits import open_bounded
+
+
+def _despeckle(alpha: np.ndarray, min_area: int = 12) -> np.ndarray:
+ """Drop isolated specks by connected-component area on the binary alpha.
+
+ A morphological opening erases any stroke under its kernel width — serif
+ hairlines and thin connectors died with the old 3x3 version. Area filtering
+ instead labels the 8-connected components of ``alpha > 40`` (run-based
+ union-find, no scipy/cv2) and zeroes only components under ``min_area`` px:
+ a 1-2px hairline survives as long as it touches its glyph, while isolated
+ speckle dies. Kept components retain their original soft alpha.
+
+ Sorted, disjoint run lists make the row-to-row overlap test a two-pointer
+ merge (see the pairwise oracle in tests for the reference behavior).
+ """
+ binary = alpha > 40
+ h, _w = binary.shape
+ parent: dict = {}
+
+ def find(a):
+ while parent[a] != a:
+ parent[a] = parent[parent[a]]
+ a = parent[a]
+ return a
+
+ def union(a, b):
+ ra, rb = find(a), find(b)
+ if ra != rb:
+ parent[rb] = ra
+
+ next_id = 0
+ prev_runs = [] # (col_start, col_end_inclusive, label)
+ all_runs = [] # (row, col_start, col_end_inclusive, label)
+ for y in range(h):
+ idx = np.flatnonzero(binary[y])
+ if idx.size == 0:
+ prev_runs = []
+ continue
+ breaks = np.flatnonzero(np.diff(idx) > 1)
+ starts = np.concatenate(([0], breaks + 1))
+ ends = np.concatenate((breaks, [idx.size - 1]))
+ cur_runs = []
+ p = 0 # first prev run that can still reach a later cur run
+ for s, e in zip(starts, ends):
+ cs, ce = int(idx[s]), int(idx[e])
+ lbl = next_id
+ next_id += 1
+ parent[lbl] = lbl
+ while p < len(prev_runs) and prev_runs[p][1] < cs - 1:
+ p += 1 # ends left of this run's reach, and cs only grows
+ q = p
+ while q < len(prev_runs) and prev_runs[q][0] <= ce + 1:
+ union(lbl, prev_runs[q][2]) # 8-connectivity: overlap within 1 col
+ q += 1
+ cur_runs.append((cs, ce, lbl))
+ all_runs.append((y, cs, ce, lbl))
+ prev_runs = cur_runs
+
+ area: dict = {}
+ for _y, cs, ce, lbl in all_runs:
+ r = find(lbl)
+ area[r] = area.get(r, 0) + (ce - cs + 1)
+ keep = np.zeros_like(binary)
+ for y, cs, ce, lbl in all_runs:
+ if area[find(lbl)] >= min_area:
+ keep[y, cs : ce + 1] = True
+ return (alpha * keep).astype(np.uint8)
+
+
+def _dilate(mask: np.ndarray, radius: int) -> np.ndarray:
+ """Binary dilation by ``radius`` px (square kernel) via separable shifted
+ ORs — pad + shift, no scipy/cv2."""
+ w = mask.shape[1]
+ padded = np.pad(mask, ((0, 0), (radius, radius)))
+ out = np.zeros_like(mask)
+ for d in range(2 * radius + 1):
+ out |= padded[:, d : d + w]
+ h = mask.shape[0]
+ padded = np.pad(out, ((radius, radius), (0, 0)))
+ out = np.zeros_like(mask)
+ for d in range(2 * radius + 1):
+ out |= padded[d : d + h, :]
+ return out
+
+
+def _load_mask(mask_bytes: Optional[bytes], size) -> Optional[np.ndarray]:
+ """Decode a brush PNG to a bool array (white = keep), resized to ``size``."""
+ if not mask_bytes:
+ return None
+ m = open_bounded(mask_bytes, "L")
+ if m.size != size:
+ m = m.resize(size, Image.NEAREST)
+ return np.asarray(m) > 127
+
+
+def _srgb_to_lab(rgb: np.ndarray) -> np.ndarray:
+ """sRGB (0-255, shape ``[..., 3]``) -> CIE Lab, D65. Euclidean distance in
+ this space is ΔE76 — roughly perceptual, unlike raw RGB distance which
+ over-weights luminance shifts and under-weights hue flips at low light."""
+ c = rgb.astype(np.float32) / 255.0
+ lin = np.where(c > 0.04045, ((c + 0.055) / 1.055) ** 2.4, c / 12.92)
+ m = np.array(
+ [
+ [0.4124564, 0.3575761, 0.1804375],
+ [0.2126729, 0.7151522, 0.0721750],
+ [0.0193339, 0.1191920, 0.9503041],
+ ],
+ dtype=np.float32,
+ )
+ xyz = lin @ m.T
+ xyz = xyz / np.array([0.95047, 1.0, 1.08883], dtype=np.float32) # D65 white
+ f = np.where(xyz > 0.008856, np.cbrt(xyz), 7.787 * xyz + 16.0 / 116.0)
+ lab = np.empty_like(xyz)
+ lab[..., 0] = 116.0 * f[..., 1] - 16.0
+ lab[..., 1] = 500.0 * (f[..., 0] - f[..., 1])
+ lab[..., 2] = 200.0 * (f[..., 1] - f[..., 2])
+ return lab
+
+
+def _otsu(values: np.ndarray) -> Optional[float]:
+ """Otsu threshold over 1-D samples, or None when the split is degenerate —
+ too few samples, a flat histogram, or one class near-empty. None means the
+ brushed region isn't really bimodal and a fixed band is safer."""
+ v = values.astype(np.float32).ravel()
+ if v.size < 256:
+ return None
+ vmin, vmax = float(v.min()), float(v.max())
+ if vmax - vmin < 1e-3:
+ return None
+ hist, edges = np.histogram(v, bins=128, range=(vmin, vmax))
+ p = hist / hist.sum()
+ centers = ((edges[:-1] + edges[1:]) / 2.0).astype(np.float64)
+ omega = np.cumsum(p)
+ mu = np.cumsum(p * centers)
+ denom = omega * (1.0 - omega)
+ denom[denom < 1e-9] = np.inf
+ between = (mu[-1] * omega - mu) ** 2 / denom
+ k = int(np.argmax(between))
+ if not 0.05 <= omega[k] <= 0.95:
+ return None
+ return float(centers[k])
+
+
+def _local_background(arr: np.ndarray, mask: Optional[np.ndarray]) -> np.ndarray:
+ """Median RGB of the backdrop the title sits on — the colour to key against.
+
+ Within the brushed swath the backdrop (sky/plate/scene) covers more area than
+ the thin title strokes, so the per-channel *median* of the brushed pixels
+ lands on the backdrop and shrugs off the title minority. Without a brush (or
+ too few pixels), fall back to the image border.
+ """
+ if mask is not None and int(mask.sum()) >= 16:
+ return np.median(arr[mask].reshape(-1, 3), axis=0)
+ b = 4
+ edges = np.concatenate(
+ [
+ arr[:b].reshape(-1, 3),
+ arr[-b:].reshape(-1, 3),
+ arr[:, :b].reshape(-1, 3),
+ arr[:, -b:].reshape(-1, 3),
+ ],
+ axis=0,
+ )
+ return np.median(edges, axis=0)
+
+
+def _kmeans(pts: np.ndarray, k: int, iters: int = 12):
+ """Tiny deterministic Lloyd's k-means on RGB samples (no RNG, so reproducible).
+
+ Seeds are spread across the luma-sorted samples; empty clusters keep their
+ seed. Returns ``(centroids[k,3], counts[k])``.
+ """
+ order = np.argsort(pts.sum(axis=1))
+ cent = pts[order[np.linspace(0, len(pts) - 1, k).astype(int)]].astype(np.float32)
+ lab = np.zeros(len(pts), dtype=int)
+ for _ in range(iters):
+ d = ((pts[:, None, :] - cent[None]) ** 2).sum(axis=2)
+ lab = d.argmin(axis=1)
+ for j in range(k):
+ m = lab == j
+ if m.any():
+ cent[j] = pts[m].mean(axis=0)
+ return cent, np.bincount(lab, minlength=k)
+
+
+# A near-ring cluster with no colour within this ΔE in the FAR ring is title
+# bleed (grunge spray / glow hugging the glyphs), not backdrop — see _drop_bleed.
+_BLEED_FAR_SUPPORT = 14.0
+
+
+def _far_ring_palette(
+ space: np.ndarray, mask: np.ndarray, near_px: int
+) -> Optional[np.ndarray]:
+ """k-means palette of the 40-80px far ring, in ``space``'s own colour space.
+
+ None when the ring is too small to trust — absolutely, or relative to the
+ near ring (a brush close to the frame edge leaves a one-SIDED far ring that
+ would wrongly condemn colours legitimately present near the other sides).
+ """
+ d40 = _dilate(mask, 40)
+ far = _dilate(d40, 40) & ~d40 # square dilations compose: 40+40 = 80
+ fpts = space[far]
+ if len(fpts) < max(200, near_px // 2):
+ return None
+ fsub = fpts.astype(np.float32)[:: max(1, len(fpts) // 4000)]
+ fcent, fcounts = _kmeans(fsub, 5)
+ return fcent[fcounts > 0]
+
+
+def _drop_bleed(
+ arr: np.ndarray, mask: np.ndarray, cent: np.ndarray, near_px: int
+) -> np.ndarray:
+ """Drop near-ring clusters with no far-ring colour support — title bleed
+ (spray/glow), not backdrop. Keeps all clusters when validation can't run."""
+ fcent = _far_ring_palette(arr, mask, near_px)
+ if fcent is None:
+ return cent
+ diff = _srgb_to_lab(cent[None])[0][:, None] - _srgb_to_lab(fcent[None])[0][None]
+ de = np.sqrt((diff * diff).sum(axis=2)).min(axis=1)
+ keep = de <= _BLEED_FAR_SUPPORT
+ return cent[keep] if keep.any() else cent
+
+
+def _background_colors(arr: np.ndarray, mask: Optional[np.ndarray]) -> np.ndarray:
+ """Backdrop palette (n, 3): the colours the title sits ON.
+
+ Sampled from a ring just *outside* the brush — that's the artwork around the
+ title, which is unambiguously background (the title is inside the brush). A
+ k-means of that ring captures a multi-toned backdrop (cityscape, wood plate)
+ as several colours rather than one muddy average. Sampling outside, not
+ inside, sidesteps having to guess which inside-brush colour is the title — so
+ a title that spans several tones (highlight + shadow) is never mistaken for
+ backdrop and erased. Clusters that are title bleed rather than backdrop are
+ dropped (see :func:`_drop_bleed`). Falls back to a single border colour with
+ no brush.
+ """
+ if mask is not None and mask.any():
+ ring = _dilate(mask, 22) & ~mask
+ pts = arr[ring]
+ if len(pts) >= 50:
+ sub = pts.astype(np.float32)[
+ :: max(1, len(pts) // 4000)
+ ] # subsample, cheap
+ cent, counts = _kmeans(sub, 5)
+ return _drop_bleed(arr, mask, cent[counts > 0], int(ring.sum()))
+ return _local_background(arr, mask)[None, :]
+
+
+def _background_distance(arr: np.ndarray, bg: np.ndarray) -> np.ndarray:
+ """Per-pixel ΔE76 (Lab) to the *nearest* backdrop colour (see
+ :func:`_background_colors`) — small where a pixel matches the backdrop, large
+ on the title. Lab, not raw RGB: a dark-red title on a dark backdrop is a hue
+ flip RGB distance barely scores, while ΔE tracks what the eye separates."""
+ diff = _srgb_to_lab(arr)[..., None, :] - _srgb_to_lab(bg)[None, None]
+ return np.sqrt((diff * diff).sum(axis=-1)).min(axis=-1)
+
+
+# Working cap so the (H×W×N×3) float buffers below stay small. Bombs are rejected
+# before decode by limits.open_bounded; this only bounds the analysis arrays.
+_MAX_SIDE = 3000
+
+# White-union guard: reject when the union would cover more of the brush than a
+# title plausibly does (a pale FIELD keyed white).
+_UNION_MAX_COVER = 0.60
+
+
+def _white_union_alpha(
+ arr: np.ndarray, mask: np.ndarray, color_alpha: np.ndarray
+) -> np.ndarray:
+ """Brightness-key alpha for the WHITE part of a mixed title, or zeros.
+ Guards: border-spill flood (bright content crossing the brush edge is
+ backdrop) and the ``_UNION_MAX_COVER`` cap (a pale field, not letters)."""
+ mn = np.minimum(np.minimum(arr[..., 0], arr[..., 1]), arr[..., 2])
+ split = _otsu(mn[mask])
+ lo = float(np.clip(split, 120.0, 200.0)) if split is not None else 165.0
+ hi = lo + 50.0
+ t = np.clip((mn - lo) / (hi - lo), 0.0, 1.0)
+ walpha = ((t * t * (3.0 - 2.0 * t)) * 255.0).astype(np.uint8)
+ walpha = (walpha * mask).astype(np.uint8)
+
+ add = (walpha > 128) & (color_alpha <= 128)
+ if not add.any():
+ return np.zeros_like(walpha)
+
+ bright_out = (_dilate(mask, 2) & ~mask) & (mn >= lo)
+ spill = _geodesic_flood(add, bright_out)
+ if spill.any():
+ walpha[_dilate(spill, 2)] = 0
+
+ union = (color_alpha > 128) | (walpha > 128)
+ if int(union.sum()) / max(int(mask.sum()), 1) > _UNION_MAX_COVER:
+ return np.zeros_like(walpha)
+ return walpha
+
+
+def _geodesic_flood(add: np.ndarray, seed: np.ndarray) -> np.ndarray:
+ """Geodesic flood of ``seed`` through ``add`` (connected reachability at
+ 4px/pass). Bound = max possible travel; the loop exits early on stability."""
+ spill = add & _dilate(seed, 3)
+ for _ in range((add.shape[0] + add.shape[1]) // 4 + 2):
+ grown = add & _dilate(spill, 4)
+ if int(grown.sum()) == int(spill.sum()):
+ break
+ spill = grown
+ return spill
+
+
+def _anchor_rescue_alpha(
+ arr: np.ndarray, mask: np.ndarray, base_alpha: np.ndarray, bg: np.ndarray
+) -> np.ndarray:
+ """Per-anchor key bands for pale non-white words the fitted band drops
+ (band clamped to ``0.6 x`` backdrop distance, as in :func:`_detect_anchors`).
+ Same border-spill and coverage guards as the white union; zeros if unsafe."""
+ zeros = np.zeros(arr.shape[:2], dtype=np.uint8)
+ pts = arr[mask]
+ if len(pts) < 200:
+ return zeros
+ sub = pts.astype(np.float32)[:: max(1, len(pts) // 4000)]
+ cent, counts = _kmeans(sub, 5)
+ frac = counts / max(1, int(counts.sum()))
+ lab = _srgb_to_lab(arr)
+ cent_lab = _srgb_to_lab(cent[None])[0]
+ bg_lab = _srgb_to_lab(bg[None])[0]
+
+ rim_out = _dilate(mask, 2) & ~mask
+ rescue = zeros.copy()
+ seed = np.zeros(arr.shape[:2], dtype=bool)
+ for c, f in zip(cent_lab, frac):
+ if f < _ANCHOR_MIN_FRAC:
+ continue
+ d = float(np.sqrt(((c - bg_lab) ** 2).sum(axis=1)).min())
+ if d < _BG_NEAR:
+ continue # backdrop-like, or too close to separate safely
+ tol = min(_COLOR_TOL_MAX, max(_BG_SAME, 0.6 * d))
+ de = np.sqrt(((lab - c) ** 2).sum(axis=-1))
+ t = np.clip((tol - de) / max(0.3 * tol, 1.0), 0.0, 1.0)
+ rescue = np.maximum(rescue, (t * t * (3.0 - 2.0 * t) * 255.0).astype(np.uint8))
+ seed |= rim_out & (de < tol)
+ rescue = (rescue * mask).astype(np.uint8)
+
+ add = (rescue > 128) & (base_alpha <= 128)
+ if not add.any():
+ return zeros
+ spill = _geodesic_flood(add, seed)
+ if spill.any():
+ rescue[_dilate(spill, 2)] = 0
+
+ union = (base_alpha > 128) | (rescue > 128)
+ if int(union.sum()) / max(int(mask.sum()), 1) > _UNION_MAX_COVER:
+ return zeros
+ return rescue
+
+
+# The detector must account for at least this share of the keyed area before the
+# zone filter may remove anything — below it, it likely missed the wordmark.
+_ZONE_MIN_KEEP = 0.5
+
+
+def _text_zone_filter(
+ image_bytes: bytes, mask: np.ndarray, alpha: np.ndarray
+) -> np.ndarray:
+ """Drop keyed content with no connection to a detected text line — scene
+ junk the colour key can't tell from title. Fail-safe: no detector, no boxes,
+ or a keep under ``_ZONE_MIN_KEEP`` of the keyed area leaves alpha unchanged."""
+ prob = _sized_probmap(image_bytes, alpha.shape)
+ if prob is None:
+ return alpha
+ width = alpha.shape[1]
+ zone = _dilate((prob > 0.3) & _dilate(mask, 8), max(12, round(0.025 * width)))
+ keyed = alpha > 40
+ if not (zone.any() and keyed.any()):
+ return alpha
+ keep = _geodesic_flood(keyed, keyed & zone)
+ if int(keep.sum()) < _ZONE_MIN_KEEP * int(keyed.sum()):
+ return alpha
+ return (alpha * _dilate(keep, 2)).astype(np.uint8)
+
+
+def _open_rgb_bounded(image_bytes: bytes) -> Image.Image:
+ """Decode to RGB under the bomb ceiling, then downscale to ``_MAX_SIDE``."""
+ img = open_bounded(image_bytes, "RGB")
+ if max(img.size) > _MAX_SIDE:
+ img.thumbnail((_MAX_SIDE, _MAX_SIDE), Image.LANCZOS)
+ return img
+
+
+def extract_subject_logo(
+ image_bytes: bytes,
+ mask_bytes: Optional[bytes] = None,
+ *,
+ lo: float = 40.0,
+ hi: float = 90.0,
+) -> bytes:
+ """Poster bytes -> transparent *original-colour* logo PNG, trimmed to content.
+
+ The companion to :func:`extract_title_logo` for titles the white key can't
+ catch — coloured ones. Instead of brightness, it keys on each pixel's colour
+ *distance from the local background* (see :func:`_background_distance`, which
+ models a multi-toned backdrop as a colour palette), so a red or green title
+ separates from a cityscape or a wood-grain plate while keeping its own
+ colours. A MIXED title (white words + coloured words) additionally unions in
+ the brightness key (see :func:`_white_union_alpha`). The CL2K whiten pass
+ downstream then turns that colour into the two-tone look, exactly as it does
+ for a fetched TMDB/fanart logo — so this must NOT pre-whiten the way the
+ white key does.
+
+ mask_bytes: brush PNG, white = the title region; brush close around the title
+ so the backdrop palette is sampled from real backdrop, not other artwork.
+ lo/hi: ΔE76 smoothstep band; raise lo to reject more background. Left at the
+ defaults, the band is fitted per poster: Otsu over the in-brush distance
+ histogram splits backdrop-like from title-like, and the ramp straddles that
+ split — a degenerate histogram (no real bimodality, or a split outside the
+ plausible ΔE range) falls back to the fixed 40/90.
+ """
+ img = _open_rgb_bounded(image_bytes)
+ arr = np.asarray(img).astype(np.float32)
+ mask = _load_mask(mask_bytes, img.size)
+
+ bg = _background_colors(arr, mask)
+ dist = _background_distance(arr, bg)
+ if (lo, hi) == (40.0, 90.0): # untouched defaults -> fit the band per poster
+ split = _otsu(dist[mask] if mask is not None else dist)
+ if split is not None and 8.0 <= split <= 80.0:
+ lo, hi = 0.7 * split, 1.3 * split
+ t = np.clip((dist - lo) / max(hi - lo, 1.0), 0.0, 1.0)
+ alpha = (t * t * (3.0 - 2.0 * t) * 255.0).astype(np.uint8) # smoothstep soft edges
+
+ if mask is not None:
+ alpha = (alpha * mask).astype(np.uint8)
+ # Mixed titles: union in the white part (see _white_union_alpha) — the
+ # colour key alone drops it when the backdrop palette has a white-ish
+ # tone — then rescue pale non-white words the band-fit dropped.
+ alpha = np.maximum(alpha, _white_union_alpha(arr, mask, alpha))
+ alpha = np.maximum(alpha, _anchor_rescue_alpha(arr, mask, alpha, bg))
+
+ alpha = _despeckle(alpha)
+ if mask is not None:
+ alpha = _text_zone_filter(image_bytes, mask, alpha)
+
+ out = np.zeros((img.height, img.width, 4), dtype=np.uint8)
+ out[..., 0:3] = arr.astype(np.uint8) # keep ORIGINAL colours; whiten happens later
+ out[..., 3] = alpha
+ logo = Image.fromarray(out)
+ bbox = logo.getbbox()
+ if bbox:
+ logo = logo.crop(bbox)
+
+ buf = io.BytesIO()
+ logo.save(buf, "PNG")
+ return buf.getvalue()
+
+
+def extract_title_logo(
+ image_bytes: bytes,
+ mask_bytes: Optional[bytes] = None,
+ *,
+ lo: float = 165.0,
+ hi: float = 215.0,
+) -> bytes:
+ """Poster bytes -> transparent white logo PNG bytes, trimmed to content.
+
+ mask_bytes: optional PNG mask, white = keep region (resized to the image).
+ lo/hi: min-channel smoothstep band; raise lo to reject more background. Left
+ at the defaults, ``lo`` is fitted per poster: Otsu over the brushed pixels'
+ min-channel histogram splits background from title, clamped to 120-200 so a
+ cream/ivory title (min channel below the fixed 165) isn't holed out; a
+ degenerate histogram keeps the fixed 165. Output stays forced-white — the
+ downstream whiten/flip passes and the "original colours" toggle expect this
+ mode to already BE the white logo (subject mode is the keep-colours path).
+ """
+ img = _open_rgb_bounded(image_bytes)
+ arr = np.asarray(img).astype(np.float32)
+ mn = np.minimum(np.minimum(arr[..., 0], arr[..., 1]), arr[..., 2])
+ if (lo, hi) == (165.0, 215.0): # untouched defaults -> fit lo per poster
+ m = _load_mask(mask_bytes, img.size)
+ split = _otsu(mn[m] if m is not None else mn)
+ if split is not None:
+ lo = float(np.clip(split, 120.0, 200.0))
+ hi = lo + 50.0 # keep the band width (edge softness) of 165/215
+ t = np.clip((mn - lo) / max(hi - lo, 1.0), 0.0, 1.0)
+ alpha = (t * t * (3.0 - 2.0 * t) * 255.0).astype(
+ np.uint8
+ ) # smoothstep keeps soft edges
+
+ if mask_bytes:
+ m = open_bounded(mask_bytes, "L")
+ if m.size != img.size:
+ m = m.resize(img.size, Image.NEAREST)
+ alpha = (
+ alpha.astype(np.float32) * (np.asarray(m).astype(np.float32) / 255.0)
+ ).astype(np.uint8)
+
+ alpha = _despeckle(alpha)
+
+ out = np.zeros((img.height, img.width, 4), dtype=np.uint8)
+ out[..., 0:3] = 255
+ out[..., 3] = alpha
+ logo = Image.fromarray(out)
+ bbox = logo.getbbox()
+ if bbox:
+ logo = logo.crop(bbox)
+
+ buf = io.BytesIO()
+ logo.save(buf, "PNG")
+ return buf.getvalue()
+
+
+def extract_logo_by_diff(
+ original_bytes: bytes,
+ cleaned_bytes: bytes,
+ mask_bytes: Optional[bytes] = None,
+ *,
+ lo: float = 12.0,
+ hi: float = 45.0,
+) -> bytes:
+ """Original + AI-erased poster -> transparent original-colour logo PNG.
+
+ Keys wherever the cleaned result differs from the original: the eraser only
+ repaints the title (plus its glow/soft shadow), so the per-pixel RGB
+ distance IS the title's footprint — no brightness or colour model to fool.
+ Confined to the erase mask dilated ~6px (the sidecar dilates its mask too,
+ so the repaint bleeds slightly past the brush); any difference outside that
+ is inpainting drift, not title.
+
+ lo/hi: RGB-distance smoothstep band — below ``lo`` reads as encoder/inpaint
+ jitter (transparent), above ``hi`` is confidently title (opaque). RGB keeps
+ original colours; the CL2K whiten pass recolours downstream, as with
+ :func:`extract_subject_logo`.
+ """
+ orig_img = _open_rgb_bounded(original_bytes)
+ clean_img = _open_rgb_bounded(cleaned_bytes)
+ if clean_img.size != orig_img.size:
+ clean_img = clean_img.resize(orig_img.size, Image.LANCZOS)
+ orig = np.asarray(orig_img).astype(np.float32)
+ clean = np.asarray(clean_img).astype(np.float32)
+ mask = _load_mask(mask_bytes, orig_img.size)
+
+ diff = orig - clean
+ dist = np.sqrt((diff * diff).sum(axis=-1))
+ t = np.clip((dist - lo) / max(hi - lo, 1.0), 0.0, 1.0)
+ alpha = (t * t * (3.0 - 2.0 * t) * 255.0).astype(np.uint8) # smoothstep soft edges
+ if mask is not None:
+ alpha = (alpha * _dilate(mask, 6)).astype(np.uint8)
+
+ alpha = _despeckle(alpha)
+ if mask is not None:
+ # A loose brush makes the eraser repaint scene detail, which diffs as
+ # strongly as the title.
+ alpha = _text_zone_filter(original_bytes, mask, alpha)
+
+ out = np.zeros((orig_img.height, orig_img.width, 4), dtype=np.uint8)
+ out[..., 0:3] = orig.astype(np.uint8) # keep ORIGINAL colours; whiten happens later
+ out[..., 3] = alpha
+ logo = Image.fromarray(out)
+ bbox = logo.getbbox()
+ if bbox:
+ logo = logo.crop(bbox)
+
+ buf = io.BytesIO()
+ logo.save(buf, "PNG")
+ return buf.getvalue()
+
+
+def _sized_probmap(image_bytes, shape):
+ """Detector probmap resized to the working-array ``shape``, or ``None``."""
+ from backend.util.cl2k.text_detect import detect_text_probmap
+
+ prob = detect_text_probmap(image_bytes)
+ if prob is None:
+ return None
+ if prob.shape != shape: # detector works at its own capped size; rescale here
+ prob = (
+ np.asarray(
+ Image.fromarray((np.clip(prob, 0, 1) * 255).astype(np.uint8)).resize(
+ (shape[1], shape[0]), Image.BILINEAR
+ ),
+ dtype=np.float32,
+ )
+ / 255.0
+ )
+ return prob
+
+
+# Anchor/background separation tiers (ΔE76 in Lab, against the DOMINANT outside
+# clusters only — ``_BG_DOMINANT_FRAC`` mirrors _matches_background's min_frac).
+_BG_SAME = 8.0 # closer than this = the background itself, not ink
+_COLOR_TOL_MAX = 33.0 # cap on any per-anchor key band (tighten + rescue paths)
+_BG_NEAR = 20.0 # closer than this = "suspect" ink (white title on a pale field)
+_BG_DOMINANT_FRAC = 0.15
+_ANCHOR_MIN_FRAC = 0.08 # smaller clusters are anti-aliasing blends, not ink
+
+
+def _detect_anchors(prob, lab, block, bg, color_tol):
+ """Title INK anchors ``[(lab, tol, suspect)]`` from the detector box, or ``[]``.
+
+ The detector localises text LINES (colour-agnostic) as filled boxes — it does
+ NOT resolve strokes, so the box holds ink AND inter-glyph background. A
+ k-means over the box pixels separates them, and each cluster is judged by its
+ ΔE to the DOMINANT background clusters sampled just OUTSIDE the user's block
+ (which bounds the title, so outside is real background):
+
+ - closer than ``_BG_SAME``: it IS the background between the glyphs — drop.
+ - ``_BG_SAME``..``_BG_NEAR``: "suspect" — plausibly real ink that merely
+ resembles the field (a white title on pale fog). Kept only when LIGHT
+ (L >= 50): a dark near-background cluster is scenery/shadow inside the box,
+ and keying it swallows the artwork. The caller additionally demands a
+ suspect-built mask stay concentrated in the detector box.
+ - farther: clean ink.
+
+ Every kept cluster becomes an anchor, so a MULTI-COLOUR title (cream "A TO" +
+ red "DAY DIE"), a fill-plus-outline title, or a detect-prefill block spanning
+ differently-coloured elements all key as the union — the old single dominant
+ anchor kept exactly one colour and dropped the rest. Per anchor, the key band
+ is clamped to ``0.6 x`` its background distance (floored at ``_BG_SAME``) so
+ the key can never reach the field it sits on — an unclamped band pulled in
+ most of a pale field between the letters of a white title.
+
+ REMAINING LIMIT: text that is not colour-separable from its own plate (a
+ dark-red badge on a red box) yields no anchor there — those regions keep the
+ user's block, which is also the right erase shape for them.
+ """
+ if prob is None or bg is None:
+ return []
+ box = (prob > 0.3) & block
+ if int(box.sum()) < 100:
+ return []
+ bg_cent, bg_frac = bg
+ dom = bg_cent[bg_frac >= _BG_DOMINANT_FRAC]
+ pts = lab[box].astype(np.float32)
+ pts = pts[:: max(1, len(pts) // 4000)]
+ cent, counts = _kmeans(pts, 5)
+ frac = counts / max(1, int(counts.sum()))
+
+ anchors = []
+ for c, f in zip(cent, frac):
+ if f < _ANCHOR_MIN_FRAC:
+ continue
+ d = float(np.sqrt(((c - dom) ** 2).sum(axis=1)).min()) if len(dom) else np.inf
+ if d < _BG_SAME:
+ continue
+ suspect = d < _BG_NEAR
+ if suspect and c[0] < 50.0:
+ continue
+ tol = min(color_tol, max(_BG_SAME, 0.6 * d))
+ anchors.append((c, tol, suspect))
+ return anchors
+
+
+def _outside_background(lab, block, width):
+ """k-means Lab palette of the background sampled just OUTSIDE the brush.
+
+ The block bounds the title, so a ring outside it is real background at any
+ brush tightness. Returns ``None`` when there's no usable outside (a block that
+ fills the frame). Used to key the title INK against — and to reject an anchor
+ (detector or colour-key) that merely IS the background, i.e. an inversion.
+ Title bleed clusters (spray hugging the glyphs, no far-ring support) are
+ dropped, as in :func:`_drop_bleed`; kept fractions stay un-renormalised so
+ the dominance gates still measure share of the full outside area.
+ """
+ outside = _dilate(block, max(8, round(0.02 * width))) & ~block
+ if int(outside.sum()) < 50:
+ outside = ~block
+ obg = lab[outside]
+ if obg.shape[0] < 50:
+ return None
+ obg = obg.astype(np.float32)[:: max(1, len(obg) // 4000)]
+ bg_cent, bg_counts = _kmeans(obg, 5)
+ keep = bg_counts > 0
+ cent, frac = bg_cent[keep], bg_counts[keep] / int(bg_counts.sum())
+ fcent = _far_ring_palette(lab, block, int(outside.sum()))
+ if fcent is not None:
+ diff = cent[:, None] - fcent[None]
+ de = np.sqrt((diff * diff).sum(axis=2)).min(axis=1)
+ keeps = de <= _BLEED_FAR_SUPPORT
+ if keeps.any():
+ cent, frac = cent[keeps], frac[keeps]
+ return cent, frac
+
+
+def _matches_background(title_lab, bg, tol=20.0, min_frac=0.15):
+ """True if the anchor is within ``tol`` ΔE of a DOMINANT background cluster.
+
+ A plate (an inversion) is a large fraction of the background outside the
+ brush; a title whose colour merely resembles a MINOR background element (a
+ small window reflection, a shadow) is not — so only clusters covering at
+ least ``min_frac`` of the outside area count. ``bg`` is ``(centroids, frac)``
+ from :func:`_outside_background`.
+ """
+ bg_cent, bg_frac = bg
+ de = np.sqrt(((title_lab - bg_cent) ** 2).sum(axis=1))
+ return bool(((de < tol) & (bg_frac >= min_frac)).any())
+
+
+def tighten_text_mask(
+ image_bytes: bytes,
+ mask_bytes: Optional[bytes],
+ *,
+ grow: Optional[int] = None,
+ color_tol: float = _COLOR_TOL_MAX,
+) -> Optional[bytes]:
+ """Shrink a brushed *block* erase-mask down to the title's glyph strokes.
+
+ A filled block hands the inpainter one big contiguous hole it can only fill
+ with low-frequency mush (LaMa blurs the middle of a wide mask). Keying the
+ block down to just the strokes leaves the real backdrop *between* the letters
+ for the inpainter to sample, so the fill stays sharp.
+
+ The title colours are found two ways, best first:
+
+ 1. **DBNet text detector** (:func:`_detect_anchors`) — localises the text by
+ appearance and keys EVERY ink cluster in the detected boxes against the
+ background sampled outside the brush, so white/black/multi-coloured/
+ outlined titles all key (and it never inverts onto a saturated plate).
+ 2. **Fallback colour-key** — when the detector is unavailable/off, finds no
+ text, or can't separate ink from background: anchor the colour from the
+ most-saturated brushed pixels. Works for coloured titles; guarded by a
+ stroke-shape gate so a plate-shaped result (an inversion) is rejected.
+
+ Either way, every brushed pixel within an anchor's key band is kept — a
+ uniform glyph body fills SOLID, a differently-coloured plate drops out —
+ then ``grow`` re-dilates for anti-aliasing. ``color_tol`` caps the band;
+ the detector path narrows it per anchor (see :func:`_detect_anchors`).
+
+ Returns a white-on-black PNG mask (white = remove, image-sized), or ``None``
+ to signal "keep the caller's block": no title could be isolated, or the
+ result was degenerate. It never returns a mask worse than the block.
+ """
+ img = _open_rgb_bounded(image_bytes)
+ width = img.width
+ arr = np.asarray(img).astype(np.float32)
+ block = _load_mask(mask_bytes, img.size)
+ if block is None or int(block.sum()) < 200:
+ return None
+ lab = _srgb_to_lab(arr)
+ bg = _outside_background(lab, block, width)
+ prob = _sized_probmap(image_bytes, block.shape)
+
+ # 1. Ink anchors — detector first (polarity-agnostic, multi-colour). If it
+ # can't isolate any ink (unavailable, no text, or everything matches the
+ # background) fall through to the single-anchor colour-key, which handles
+ # a saturated title offline.
+ anchors = _detect_anchors(prob, lab, block, bg, color_tol)
+ from_detector = bool(anchors)
+ if not from_detector:
+ chroma = np.sqrt(lab[..., 1] ** 2 + lab[..., 2] ** 2)
+ cthr = float(np.quantile(chroma[block], 0.92))
+ if cthr < 18.0:
+ return None # no saturated title to key on — keep the block
+ conf = block & (chroma >= cthr)
+ if int(conf.sum()) < 200:
+ return None
+ title_lab = np.median(lab[conf], axis=0)
+ # A fallback anchor that equals the DOMINANT background outside the brush
+ # IS the plate, not the title — this is how the colour-key inverts on a
+ # light title over saturated colour when the block clips the letters.
+ if bg is not None and _matches_background(title_lab, bg):
+ return None
+ anchors = [(title_lab, color_tol, False)]
+
+ # 2. Colour-distance segmentation — the union over the accepted anchors.
+ letter = np.zeros_like(block)
+ for cent, tol, _suspect in anchors:
+ d = lab - cent
+ letter |= block & (np.sqrt((d * d).sum(axis=-1)) < tol)
+ letter = _despeckle((letter.astype(np.uint8)) * 255, min_area=40) > 0
+ if not letter.any():
+ return None
+
+ # 3. Shape gates. Colour-key fallback: a plate survives erosion, letters
+ # vanish, so reject a plate-shaped result (the detector path skips this —
+ # its anchors are background-validated, and the erosion test false-rejects
+ # legitimately bold titles). Detector path with a SUSPECT anchor (ink that
+ # resembles the field): real ink stays concentrated in the detector box;
+ # a field-key spills across the block, so reject a spilled result.
+ if not from_detector:
+ er = max(3, round(0.006 * width))
+ strokiness = 1.0 - int(_erode(letter, er).sum()) / int(letter.sum())
+ if strokiness < 0.42:
+ return None
+ elif any(suspect for _cent, _tol, suspect in anchors):
+ zone = _dilate(prob > 0.3, max(8, round(0.03 * width)))
+ if int((letter & zone).sum()) / int(letter.sum()) < 0.6:
+ return None
+
+ if grow is None:
+ # Small margin around the keyed strokes to cover anti-aliasing. Kept tight
+ # so the mask hugs the letters (the wider the margin, the less precise);
+ # to swallow a heavy glow/shadow, raise "Mask Dilation" (ai_mask_dilate),
+ # which dilates the mask at erase time.
+ grow = max(2, round(0.004 * width))
+ grown = _dilate(letter, grow) if grow > 0 else letter
+ grown &= block # never mask more than the user brushed
+
+ frac = int(grown.sum()) / int(block.sum())
+ if not (0.03 <= frac <= 0.90):
+ return None # near-empty / near-full -> the model didn't fit; keep block
+
+ out = Image.fromarray((grown.astype(np.uint8)) * 255) # 2-D uint8 -> L PNG
+ buf = io.BytesIO()
+ out.save(buf, "PNG")
+ return buf.getvalue()
+
+
+# --- Colour-edge inking (a whiten helper, called from renderer.process_logo) ---
+_INK_EDGE_THR = 0.16 # normalized Lab-gradient magnitude that reads as a keyline
+_INK_WHITE_MIN = 0.60 # only ink where the whitened result is at least this light
+_INK_BLACK_MAX = 0.35 # whitened luma below this is an EXISTING keyline
+
+
+def _grad_mag(ch: np.ndarray) -> np.ndarray:
+ """Central-difference gradient magnitude (numpy-only, no scipy/cv2)."""
+ gx = np.zeros_like(ch)
+ gy = np.zeros_like(ch)
+ gx[:, 1:-1] = ch[:, 2:] - ch[:, :-2]
+ gy[1:-1, :] = ch[2:, :] - ch[:-2, :]
+ return np.sqrt(gx * gx + gy * gy)
+
+
+def _erode(mask: np.ndarray, radius: int) -> np.ndarray:
+ """Binary erosion = invert, dilate, invert (reuses :func:`_dilate`)."""
+ return ~_dilate(~mask, radius)
+
+
+def ink_color_edges(whitened_png: bytes, original_png: bytes) -> bytes:
+ """Add crisp black keylines at COLOUR boundaries the two-tone whiten missed.
+
+ The two-tone whiten turns every saturated/light region white, so two
+ differently-*coloured* fills with no black outline between them merge into
+ one white blob (a red swoosh under a white "GT"). This keys the ORIGINAL
+ logo's colour edges — a ΔE gradient in Lab, so a hue flip at equal luma still
+ registers — and inks a thin black line wherever (a) there's a strong interior
+ colour edge and (b) the whitened result is currently white there (NO existing
+ keyline). Edges sitting on the logo's own outlines are suppressed, so an
+ already-outlined logo (Dragon Ball GT) is a NO-OP; only outline-less colour
+ boundaries gain a separator.
+
+ Both PNGs must be the same (trimmed) size. Returns the inked whitened PNG,
+ or the input unchanged when there is nothing to add or on any decode failure
+ (fail open — a bad decode must never break the render).
+ """
+ try:
+ white = open_bounded(whitened_png, "RGBA")
+ orig = open_bounded(original_png, "RGBA")
+ except Exception:
+ return whitened_png
+ if white.size != orig.size:
+ white = white.resize(orig.size, Image.LANCZOS)
+ width = orig.width
+
+ lab = _srgb_to_lab(np.asarray(orig.convert("RGB")).astype(np.float32))
+ mag = (
+ _grad_mag(lab[..., 0])
+ + 1.5 * _grad_mag(lab[..., 1])
+ + 1.5 * _grad_mag(lab[..., 2])
+ )
+ peak = float(mag.max())
+ if peak <= 1e-6:
+ return whitened_png # flat original — nothing to separate
+ edge = (mag / peak) > _INK_EDGE_THR
+
+ opaque = np.asarray(orig)[..., 3] > 128
+ interior = _erode(opaque, max(2, round(0.004 * width))) # drop silhouette edge
+
+ warr = np.asarray(white)
+ wl = (0.299 * warr[..., 0] + 0.587 * warr[..., 1] + 0.114 * warr[..., 2]) / 255.0
+ wa = warr[..., 3] > 128
+ # Suppress ink near an existing keyline so an outlined logo stays a no-op.
+ near_keyline = _dilate((wl < _INK_BLACK_MAX) & wa, max(3, round(0.006 * width)))
+
+ add = edge & interior & (wl > _INK_WHITE_MIN) & wa & ~near_keyline
+ add = _dilate(add, max(1, round(0.0015 * width))) & wa & ~near_keyline
+ if not add.any():
+ return whitened_png
+
+ out = warr.copy()
+ out[add, 0:3] = 0 # black keyline; alpha untouched
+ buf = io.BytesIO()
+ Image.fromarray(out).save(buf, "PNG")
+ return buf.getvalue()
+
+
+_DARK_BODY_LUMA = 0.20 # original luma below this is a candidate dark body
+_DARK_BODY_CHROMA = 18.0 # ...and only if NEAR-NEUTRAL (vivid darks stay white)
+
+
+def fill_dark_bodies(whitened_png: bytes, original_png: bytes) -> bytes:
+ """Fill wide DARK bodies black — the companion to the small keyline blur.
+
+ The keyline pass uses a small neighbourhood blur so it stays crisp (no muddy
+ halos), but that means it only blackens dark pixels within a few px of an
+ edge — a WIDE dark shape (a dark star body, a thick dark stroke) keeps a white
+ core, because the sat/light key whitens it and nothing pulls the middle back.
+ This fills genuinely dark ORIGINAL regions that are THICK — a morphological
+ opening drops thin outlines and soft halos, so only real bodies fill. An
+ already-crisp logo whose only darks are thin outlines (Dragon Ball GT) is a
+ no-op. Both PNGs same (trimmed) size; input returned unchanged on any decode
+ failure or when there is nothing thick and dark to fill.
+ """
+ try:
+ white = open_bounded(whitened_png, "RGBA")
+ orig = open_bounded(original_png, "RGBA")
+ except Exception:
+ return whitened_png
+ if white.size != orig.size:
+ white = white.resize(orig.size, Image.LANCZOS)
+ width = orig.width
+
+ o = np.asarray(orig)
+ rgb = o[..., :3].astype(np.float32)
+ luma = (0.299 * rgb[..., 0] + 0.587 * rgb[..., 1] + 0.114 * rgb[..., 2]) / 255.0
+ lab = _srgb_to_lab(rgb)
+ chroma = np.sqrt(lab[..., 1] ** 2 + lab[..., 2] ** 2)
+ # Only NEAR-NEUTRAL dark shapes fill (a shadow/silhouette that reads black). A
+ # wide dark-but-VIVID fill — navy, maroon, deep teal — is a coloured fill the
+ # two-tone key deliberately whitens, so it is spared here.
+ dark = (luma < _DARK_BODY_LUMA) & (chroma < _DARK_BODY_CHROMA) & (o[..., 3] > 128)
+ r = max(4, round(0.0075 * width))
+ thick = _dilate(_erode(dark, r), r) # opening: only bodies thicker than ~2r
+ if not thick.any():
+ return whitened_png
+
+ out = np.asarray(white).copy()
+ out[thick, 0:3] = 0 # black fill; alpha untouched
+ buf = io.BytesIO()
+ Image.fromarray(out).save(buf, "PNG")
+ return buf.getvalue()
+
+
+def finish_two_tone(whitened_png: bytes, original_png: bytes) -> bytes:
+ """The post-whiten two-tone chain: colour-edge keylines, then dark-body fill.
+
+ The ONE owner of that pairing and its order, so no caller can run half of it."""
+ return fill_dark_bodies(ink_color_edges(whitened_png, original_png), original_png)
+
+
+# --- 3D / extruded logos (a whiten MODE, called from renderer.process_logo) -----
+_FACE_SOFT = 0.06 # +/- luma either side of the split, ramped for antialiasing
+_FACE_MIN_FRAC = 0.12 # kept fraction of the opaque area below this -> not a face
+_FACE_MAX_FRAC = 0.95 # ...and above this nothing was dropped -> not a 3D logo
+_FACE_MIN_AREA = 64 # despeckle: drop kept components smaller than this (px)
+
+
+def flatten_3d_logo(logo_png: bytes) -> Optional[bytes]:
+ """Otsu-split an extruded logo's lit face from its extrusion; white-fill the face.
+
+ None = not splittable (flat histogram, or a face too small/large to be the
+ letterforms); the caller falls back to the flat silhouette.
+ """
+ try:
+ src = open_bounded(logo_png, "RGBA")
+ except Exception:
+ return None
+ arr = np.asarray(src).astype(np.float32) / 255.0
+ rgb, alpha = arr[..., :3], arr[..., 3]
+ luma = 0.299 * rgb[..., 0] + 0.587 * rgb[..., 1] + 0.114 * rgb[..., 2]
+ opaque = alpha > 0.5
+ if not opaque.any():
+ return None
+ vals = luma[opaque]
+ split = _otsu(vals)
+ if split is None:
+ return None
+ # _otsu returns the FIRST bin that separates the classes, which on strongly
+ # bimodal art sits on the dark class itself. Re-centre between the class means
+ # so the ramp straddles the valley (one intermeans step).
+ dark, lit = vals[vals <= split], vals[vals > split]
+ if not dark.size or not lit.size:
+ return None
+ d_mean, l_mean = float(dark.mean()), float(lit.mean())
+ if l_mean - d_mean <= 2 * _FACE_SOFT:
+ return None # planes too close to separate — the ramp would swallow both
+ split = (d_mean + l_mean) / 2.0
+ lo, hi = split - _FACE_SOFT, split + _FACE_SOFT
+ ramp = np.clip((luma - lo) / max(hi - lo, 1e-6), 0.0, 1.0)
+ kept = _despeckle((ramp * alpha * 255).astype(np.uint8), min_area=_FACE_MIN_AREA)
+ frac = float((kept > 128).sum()) / float(opaque.sum())
+ if not _FACE_MIN_FRAC <= frac <= _FACE_MAX_FRAC:
+ return None
+ out = np.full(arr.shape, 255, dtype=np.uint8) # white RGB...
+ out[..., 3] = kept # ...shaped by the face alone
+ buf = io.BytesIO()
+ Image.fromarray(out).save(buf, "PNG")
+ return buf.getvalue()
diff --git a/backend/util/cl2k/models/ppocr_v4_det.onnx b/backend/util/cl2k/models/ppocr_v4_det.onnx
new file mode 100644
index 00000000..3046e38f
Binary files /dev/null and b/backend/util/cl2k/models/ppocr_v4_det.onnx differ
diff --git a/backend/util/cl2k/naming.py b/backend/util/cl2k/naming.py
new file mode 100644
index 00000000..e5e87963
--- /dev/null
+++ b/backend/util/cl2k/naming.py
@@ -0,0 +1,96 @@
+"""Build ID-tagged CL2K poster filenames (the "idarr" naming piece).
+
+Reproduces the TPDB / Trash-Guides convention that the DAPS guide requires and
+that real CL2K posters use, e.g.::
+
+ The Matrix (1999) {tmdb-603} {imdb-tt0133093}.jpg
+ The Matrix Collection {tmdb-2344}.jpg
+ Breaking Bad (2008) {tvdb-81189} - Season 01.jpg
+ Breaking Bad (2008) {tvdb-81189} - Specials.jpg
+
+ID tags are space-separated in tmdb -> tvdb -> imdb order (only those present).
+Collections carry no year; seasons append `` - Season {NN}`` — the Kometa/Drazzilb
+" - Season " form that real CL2K community posters use (CHUB's season_number_regex
+also accepts the ``_Season`` variant, but the spaced form matches creators' files).
+Season 0 is written as `` - Specials`` instead of `` - Season 00`` to match the
+community-maker convention (both parse back to season 0).
+Illegal filename characters are stripped via the shared ``illegal_chars_regex``.
+"""
+
+from __future__ import annotations
+
+from typing import Optional
+
+from backend.util.constants import illegal_chars_regex
+
+
+def _safe(title: str) -> str:
+ """Strip filesystem-illegal characters and any leading dots from a title.
+
+ Interior and trailing dots are kept (so "S.W.A.T." / "Mr. Robot" survive
+ intact), but a leading dot is stripped — otherwise a title like ".hack"
+ would yield a hidden dotfile that some tools skip over.
+ """
+ cleaned = illegal_chars_regex.sub("", title or "").strip()
+ return cleaned.lstrip(".").strip()
+
+
+def _id_tags(
+ tmdb_id: Optional[int],
+ tvdb_id: Optional[int],
+ imdb_id: Optional[str],
+) -> str:
+ tags = []
+ if tmdb_id:
+ tags.append(f"{{tmdb-{tmdb_id}}}")
+ if tvdb_id:
+ tags.append(f"{{tvdb-{tvdb_id}}}")
+ if imdb_id:
+ tags.append(f"{{imdb-{imdb_id}}}")
+ return " ".join(tags)
+
+
+def build_poster_filename(
+ *,
+ kind: str,
+ title: str,
+ year: Optional[int] = None,
+ tmdb_id: Optional[int] = None,
+ tvdb_id: Optional[int] = None,
+ imdb_id: Optional[str] = None,
+ season_number: Optional[int] = None,
+ ext: str = ".jpg",
+ asset_suffix: str = "",
+) -> str:
+ """Return the ID-tagged poster filename for a media item.
+
+ ``kind`` is movie / show / collection / season. Collections omit the year;
+ seasons append `` - Season {NN}`` to the show base name (the spaced Kometa/
+ Drazzilb form that matches hand-made CL2K posters). Season 0 is the Specials
+ season and is written as `` - Specials`` instead. Year-based "seasons" (e.g.
+ Formula 1) just pass the year through as the number → `` - Season 2026``.
+
+ ``asset_suffix`` appends an additional-asset tag before the extension (e.g.
+ `` - squareart`` / `` - logo``) so the file is parsed as that asset type by the
+ asset_renamerr naming regex, e.g. ``The Matrix (1999) {tmdb-603} - squareart.jpg``.
+ """
+ title = _safe(title)
+ tags = _id_tags(tmdb_id, tvdb_id, imdb_id)
+
+ if kind == "collection":
+ base = f"{title} {tags}".strip()
+ else:
+ base = f"{title} ({year})" if year else title
+ if tags:
+ base = f"{base} {tags}"
+
+ if kind == "season" and season_number is not None:
+ # Season 0 is the Specials season — write the `- Specials` form that
+ # community CL2K makers use (Kometa/Plex and CHUB's season_number_regex
+ # both read it back as season 0). Numbered seasons keep ` - Season NN`.
+ base = (
+ f"{base} - Specials"
+ if season_number == 0
+ else f"{base} - Season {season_number:02d}"
+ )
+ return f"{base}{asset_suffix}{ext}"
diff --git a/backend/util/cl2k/plex_art.py b/backend/util/cl2k/plex_art.py
new file mode 100644
index 00000000..9b1a1ce7
--- /dev/null
+++ b/backend/util/cl2k/plex_art.py
@@ -0,0 +1,177 @@
+"""Plex artwork source for the CL2K maker (:full-image).
+
+Resolves a media item to its Plex ratingKey via the ``plex_media_cache`` (the
+same snapshot asset_renamerr already syncs) and fetches that item's clearLogos,
+art (backgrounds) and posters through plexapi.
+
+READ-ONLY by design: it never uploads, selects, or deletes anything — so it
+cannot move an asset into or out of the in-use set and therefore can't trigger
+any Poster Cleanarr bloat removal. Kept here rather than in the shared
+``backend/util/plex.py`` because the CL2K maker is part of the :full image and
+shared files must stay byte-identical with main.
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any, Dict, List, Optional, Tuple
+from urllib.parse import quote
+
+# Plex stores TV shows under asset_type "show"; tolerate "tvshow" defensively.
+_TV_TYPES = {"show", "tvshow"}
+
+
+def _coerce_guids(value: Any) -> Dict[str, Any]:
+ """plex_media_cache.guids is a JSON dict ({"tmdb":"123",...}); tolerate a
+ pre-parsed dict or a bad value (→ empty)."""
+ if isinstance(value, dict):
+ return value
+ if isinstance(value, str):
+ try:
+ parsed = json.loads(value)
+ return parsed if isinstance(parsed, dict) else {}
+ except (json.JSONDecodeError, TypeError):
+ return {}
+ return {}
+
+
+def _matches(
+ guids: Dict[str, Any],
+ tmdb_id: Optional[int],
+ tvdb_id: Optional[int],
+ imdb_id: Optional[str],
+) -> bool:
+ if tmdb_id and str(guids.get("tmdb")) == str(tmdb_id):
+ return True
+ if tvdb_id and str(guids.get("tvdb")) == str(tvdb_id):
+ return True
+ if imdb_id and guids.get("imdb") == imdb_id:
+ return True
+ return False
+
+
+def _resolve(
+ full_config,
+ db,
+ *,
+ media_type: str,
+ tmdb_id: Optional[int],
+ tvdb_id: Optional[int],
+ imdb_id: Optional[str],
+) -> Tuple[Optional[Any], Optional[str]]:
+ """Return ``(instance_cfg, rating_key)`` for the first enabled Plex instance
+ whose cache holds the item (guid match, type-gated), else ``(None, None)``.
+
+ Type-gating matters because tmdb movie ids and tv ids are separate
+ namespaces both stored under the ``tmdb`` guid key — without it a movie
+ could resolve when a show was requested, or vice-versa."""
+ want = "movie" if (media_type or "").lower() == "movie" else "show"
+ plex = getattr(full_config.instances, "plex", {}) or {}
+ for name, cfg in plex.items():
+ if not (getattr(cfg, "enabled", True) and cfg.url and cfg.api):
+ continue
+ for row in db.plex.get_by_instance(name) or []:
+ at = (row.get("asset_type") or "").lower()
+ if want == "movie" and at != "movie":
+ continue
+ if want == "show" and at not in _TV_TYPES:
+ continue
+ if _matches(_coerce_guids(row.get("guids")), tmdb_id, tvdb_id, imdb_id):
+ return cfg, row.get("plex_id")
+ return None, None
+
+
+def _proxy_url(src: str) -> str:
+ """Browser-facing URL for a local Plex image: the CL2K proxy fetches it
+ server-side (re-minting the token) so the X-Plex-Token never reaches the
+ client. The appends a short-lived stream token when loading it."""
+ return f"/api/cl2k-maker/plex-art?src={quote(src, safe='')}"
+
+
+def _img_urls(server, key: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
+ """Resolve a plexapi resource ``.key`` to ``(file_path, url)``. Remote-provider
+ keys (themoviedb/gracenote) are already absolute, tokenless https — both are
+ that URL. Local/uploaded keys (``/library/metadata/...``) would leak the
+ X-Plex-Token, so BOTH become the CL2K proxy path carrying the tokenless Plex
+ URL in ``?src=``: the browser loads it token-free (the proxy fetches
+ server-side) and ``image_fetch.download`` unwraps ``?src=`` and re-mints the
+ token. Using one value for both means every consumer — picker , selected
+ art previews, and the backend generate fetch — is handled uniformly."""
+ if not key:
+ return None, None
+ if key.startswith("http://") or key.startswith("https://"):
+ return key, key
+ proxy = _proxy_url(server.url(key, includeToken=False))
+ return proxy, proxy
+
+
+def plex_images(
+ full_config,
+ db,
+ logger,
+ *,
+ kind: str,
+ tmdb_id: Optional[int] = None,
+ tvdb_id: Optional[int] = None,
+ imdb_id: Optional[str] = None,
+) -> Dict[str, Any]:
+ """Return ``{logos, backdrops, posters}`` of Plex artwork for the item.
+
+ Each entry is ``{file_path, url, provider, selected}``. On any miss the lists
+ are empty and a ``reason`` string explains why (no Plex configured, item not
+ in a synced library, Plex unreachable) so the picker can show a clear empty
+ state instead of erroring. Note: Plex art objects carry no dimensions, so
+ width/height are omitted (the picker tolerates that)."""
+ empty: Dict[str, Any] = {"logos": [], "backdrops": [], "posters": []}
+ plex = getattr(full_config.instances, "plex", {}) or {}
+ if not plex:
+ return {**empty, "reason": "No Plex instance is configured."}
+
+ cfg, rating_key = _resolve(
+ full_config,
+ db,
+ media_type=kind,
+ tmdb_id=tmdb_id,
+ tvdb_id=tvdb_id,
+ imdb_id=imdb_id,
+ )
+ if not rating_key:
+ return {**empty, "reason": "Not found in a synced Plex library."}
+
+ try:
+ from plexapi.server import PlexServer
+
+ server = PlexServer(cfg.url, cfg.api)
+ item = server.fetchItem(int(rating_key))
+ except Exception as exc: # connection / fetch failure — degrade gracefully
+ logger.warning(f"cl2k: Plex artwork fetch failed (key={rating_key}): {exc}")
+ return {**empty, "reason": "Could not reach Plex."}
+
+ out: Dict[str, Any] = {"logos": [], "backdrops": [], "posters": []}
+ # plexapi method -> our bucket. arts() are backgrounds.
+ for method, bucket in (
+ ("logos", "logos"),
+ ("arts", "backdrops"),
+ ("posters", "posters"),
+ ):
+ fetch = getattr(item, method, None)
+ if not callable(fetch):
+ continue
+ try:
+ candidates: List[Any] = list(fetch())
+ except Exception as exc:
+ logger.debug(f"cl2k: Plex {method}() failed: {exc}")
+ continue
+ for res in candidates:
+ file_path, url = _img_urls(server, getattr(res, "key", None))
+ if not file_path:
+ continue
+ out[bucket].append(
+ {
+ "file_path": file_path,
+ "url": url,
+ "provider": getattr(res, "provider", None),
+ "selected": bool(getattr(res, "selected", False)),
+ }
+ )
+ return out
diff --git a/backend/util/cl2k/psd_export.py b/backend/util/cl2k/psd_export.py
new file mode 100644
index 00000000..dedd7a01
--- /dev/null
+++ b/backend/util/cl2k/psd_export.py
@@ -0,0 +1,290 @@
+"""Export a CL2K poster as a layered ``.psd`` for editing in Photopea/Photoshop.
+
+Assembles the creator-template structure — POSTER>Main, GRADIENT>live gradient
+fill, LOGO>Layer 1, an editable type layer for the label, and an effects-only
+BORDER LAYER carrying live Stroke + Inner Glow — using Pillow for layout and
+psd-tools to write the file (live pieces built in :mod:`psd_live`). The POSTER
+and LOGO pixels come from the renderer's own framing/logo passes, so the
+document matches the flattened poster. The embedded preview is our own flat
+composite; geometry comes from :mod:`geometry`.
+"""
+
+from __future__ import annotations
+
+import io
+from typing import Optional
+
+from PIL import Image, ImageDraw, ImageFont
+
+from backend.util.cl2k import geometry as geo
+
+
+def _cover(im: Image.Image, w: int, h: int) -> Image.Image:
+ if (im.width, im.height) == (w, h):
+ # Already framed (renderer.frame_backdrop output) — pass through
+ # untouched so the POSTER layer stays pixel-identical to the render.
+ return im
+ scale = max(w / im.width, h / im.height)
+ # LANCZOS — sharpest downscale to canvas; PIL's default BICUBIC is softer.
+ im = im.resize(
+ (round(im.width * scale), round(im.height * scale)),
+ Image.Resampling.LANCZOS,
+ )
+ left = (im.width - w) // 2
+ top = (im.height - h) // 2
+ return im.crop((left, top, left + w, top + h))
+
+
+def _layer_name(text: str, fallback: str) -> str:
+ """A layer name psd-tools can actually write.
+
+ The legacy Pascal layer name is encoded MacRoman, so a label containing
+ anything outside that codec (CJK, Cyrillic, a stray smart quote) raised
+ UnicodeEncodeError inside ``psd.save()`` and 500'd the whole export — while
+ the render path handled the identical input fine. The template names its
+ label layers with stable ASCII identifiers ('SEASON 3', 'SPECIALS') that are
+ independent of the glyphs drawn, so falling back to one loses nothing.
+ """
+ try:
+ text.encode("mac_roman")
+ except (UnicodeEncodeError, LookupError):
+ return fallback
+ return text
+
+
+def _font(bold: bool, px: int) -> ImageFont.FreeTypeFont:
+ path = geo.resolve_font(bold=bold)
+ try:
+ return ImageFont.truetype(path, px) if path else ImageFont.load_default()
+ except Exception:
+ return ImageFont.load_default()
+
+
+def _centered(
+ draw: ImageDraw.ImageDraw, text: str, center_y: int, font, kerning: float = 0.0
+) -> None:
+ """Draw centred white text, with optional CL2K letter tracking.
+
+ PIL has no kerning parameter (Wand's ``text_kerning`` adds ``kerning`` px
+ between every character pair), so tracked text is drawn char-by-char with
+ the same inter-character gaps — keeping the PSD label the same width as the
+ rendered poster's.
+ """
+ box = draw.textbbox((0, 0), text, font=font)
+ h = box[3] - box[1]
+ # textbbox measures the INK relative to the draw origin, and PIL's origin is
+ # the ascender line, not the ink top. Subtracting box[1] is what actually
+ # centres the ink on center_y — without it the label sat ~6px low, below both
+ # the template's band and the flattened render of the same poster.
+ y = center_y - h / 2 - box[1]
+ if kerning <= 0:
+ w = box[2] - box[0]
+ draw.text((geo.CENTER_X - w / 2 - box[0], y), text, font=font, fill="white")
+ return
+ widths = [draw.textlength(ch, font=font) for ch in text]
+ total = sum(widths) + kerning * max(0, len(text) - 1)
+ x = geo.CENTER_X - total / 2
+ for ch, cw in zip(text, widths):
+ draw.text((x, y), ch, font=font, fill="white")
+ x += cw + kerning
+
+
+# The template's own border-plate colour. Never visible: fill opacity is 0 and
+# only the live Stroke + Inner Glow paint — but the plate must stay OPAQUE, the
+# effects key off the layer's alpha shape (a transparent plate draws nothing).
+_BORDER_PLATE_RGBA = (189, 0, 0, 255)
+
+
+def export_psd(
+ *,
+ backdrop_bytes: bytes,
+ kind: str = "movie",
+ logo_bytes: Optional[bytes] = None,
+ title: str = "",
+ season_text: str = "",
+ band_label: str = "",
+ logo_max_width: Optional[int] = None,
+ logo_scale: float = 1.0,
+ 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)
+ whiten: bool = True,
+ flat_white: bool = False,
+ logo_3d: bool = False,
+ invert: bool = False,
+) -> bytes:
+ """Build the CL2K poster as a layered PSD and return its bytes."""
+ from psd_tools import PSDImage
+ from psd_tools.api.layers import PixelLayer
+
+ w, h = geo.CANVAS_W, geo.CANVAS_H
+ kind = kind.lower()
+ baseline = geo.logo_baseline(kind)
+
+ poster = _cover(
+ Image.open(io.BytesIO(backdrop_bytes)).convert("RGB"), w, h
+ ).convert("RGBA")
+ gradient = Image.open(geo.GRADIENT_PNG).convert("RGBA")
+
+ # No clear logo but a title? Typeset the wordmark the flattened render falls
+ # back to, instead of exporting an empty LOGO layer. Reuses the renderer's own
+ # generator (lazy import — this module is otherwise Pillow-only) so the shape
+ # of the wordmark can't drift. NOTE the parity is for DEFAULTS only: render_cl2k
+ # can pass a custom title_font and text_logo_stroke, which export_psd has no
+ # parameters for, so a request setting either gets a .psd wordmark that differs
+ # from its flattened poster.
+ wordmark = False
+ if not logo_bytes and title:
+ from backend.util.cl2k.renderer import generate_text_logo
+
+ logo_bytes = generate_text_logo(title) or None
+ wordmark = logo_bytes is not None
+
+ logo_layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
+ if logo_bytes:
+ from backend.util.cl2k.renderer import process_logo
+
+ # ONE owner for trim + recolour + brush + invert — the render path's own
+ # pass, so the LOGO layer and its embedded preview cannot drift from the
+ # flattened JPEG (Pillow mirrors of it did, missing the two-tone
+ # post-passes). Everything below is placement, which the .psd owns.
+ if wordmark:
+ # Already CL2K white-on-transparent: only the trim applies — whitening
+ # or inverting a wordmark would mangle or erase it.
+ processed, _pw, _ph = process_logo(logo_bytes, whiten=False)
+ else:
+ processed, _pw, _ph = process_logo(
+ logo_bytes,
+ whiten=whiten,
+ flat_white=flat_white,
+ logo_3d=logo_3d,
+ flip_mask_bytes=logo_flip_bytes,
+ erase_mask_bytes=logo_erase_bytes,
+ invert=invert,
+ )
+ lg = Image.open(io.BytesIO(processed)).convert("RGBA")
+ if logo_max_width is None:
+ tw, th = geo.auto_logo_size(lg.width, lg.height, baseline)
+ else:
+ tw = min(logo_max_width, geo.LOGO_WIDTH_MAX)
+ th = round(lg.height * tw / lg.width)
+ max_h = baseline - geo.LOGO_ZONE_TOP
+ if th > max_h:
+ th = max_h
+ tw = round(lg.width * th / lg.height)
+ # Scale the guide-fit box as a whole, canvas-clamped — mirrors
+ # renderer._place_logo so the LOGO layer matches the rendered poster.
+ scale = max(0.25, min(float(logo_scale or 1.0), 3.0))
+ tw = max(1, round(tw * scale))
+ th = max(1, round(th * scale))
+ if tw > w:
+ th = max(1, round(th * w / tw))
+ tw = w
+ if th > h:
+ tw = max(1, round(tw * h / th))
+ th = h
+ lg = lg.resize((tw, th), Image.Resampling.LANCZOS)
+ off = max(
+ geo.LOGO_Y_OFFSET_MIN, min(int(logo_y_offset or 0), geo.LOGO_Y_OFFSET_MAX)
+ )
+ top = max(0, min(baseline - th + off, h - th))
+ logo_layer.alpha_composite(lg, (geo.CENTER_X - tw // 2, top))
+
+ # The bottom label, when there is one, becomes its own self-describing layer
+ # ("COLLECTION" / "SEASON 3") instead of a generic "TEXT" layer — and movies,
+ # which have no label, get no empty layer at all.
+ # Precedence mirrors render_cl2k: an explicit banner wins, else COLLECTION, else
+ # the season band — so the .psd label matches the flattened /generate output.
+ label_text, label_y = "", geo.SEASON_TEXT_Y
+ if band_label:
+ label_text = band_label.upper()
+ elif kind == "collection":
+ label_text, label_y = "COLLECTION", geo.COLLECTION_LABEL_Y
+ elif kind == "season" and season_text:
+ label_text = season_text.upper()
+
+ # Bounds are inclusive in PIL too, so the far edge is w-1 / h-1 and the band
+ # thickness is bw-1 — passing w/bw here painted 26px bands on the bottom and
+ # right but 27px on the top and left. The glow is composited under the stroke
+ # so the exported BORDER LAYER matches the flattened render pixel for pixel.
+ border = Image.new("RGBA", (w, h), (0, 0, 0, 0))
+ if geo.INNER_GLOW_PNG.exists():
+ with Image.open(geo.INNER_GLOW_PNG) as glow:
+ border.alpha_composite(glow.convert("RGBA"))
+ bd = ImageDraw.Draw(border)
+ bw = geo.BORDER_WIDTH
+ bd.rectangle([0, 0, w - 1, bw - 1], fill="white")
+ bd.rectangle([0, h - bw, w - 1, h - 1], fill="white")
+ bd.rectangle([0, 0, bw - 1, h - 1], fill="white")
+ bd.rectangle([w - bw, 0, w - 1, h - 1], fill="white")
+
+ text_layer = None
+ if label_text:
+ text_layer = Image.new("RGBA", (w, h), (0, 0, 0, 0))
+ _centered(
+ ImageDraw.Draw(text_layer),
+ label_text,
+ label_y,
+ _font(False, geo.LABEL_FONT_PX),
+ # Same rule as the renderer, so the .psd label is the same width as
+ # the flattened poster's; a flat LABEL_TRACKING here rendered
+ # COMPLETE LIMITED SERIES ~140px wider, running under the border.
+ kerning=geo.tracking_to_kerning(geo.label_tracking(label_text)),
+ )
+
+ # RGBA document so each layer's transparency lands on its own (native) alpha
+ # channel; psd-tools would push alpha into a per-layer mask in an RGB doc.
+ psd = PSDImage.new(mode="RGBA", size=(w, h))
+ from psd_tools.api.layers import Group
+
+ from backend.util.cl2k import psd_live
+
+ # Creator-template structure: POSTER>Main, GRADIENT>live fill, LOGO>Layer 1.
+ poster_group = Group.new(psd, "POSTER", open_folder=False)
+ PixelLayer.frompil(poster, poster_group, "Main")
+ gradient_group = Group.new(psd, "GRADIENT", open_folder=False)
+ psd_live.make_gradient_layer(gradient_group)
+ logo_group = Group.new(psd, "LOGO", open_folder=False)
+ logo_bbox = logo_layer.getbbox()
+ if logo_bbox:
+ PixelLayer.frompil(
+ logo_layer.crop(logo_bbox), logo_group, "Layer 1",
+ top=logo_bbox[1], left=logo_bbox[0],
+ )
+ if text_layer is not None:
+ ink = text_layer.getbbox()
+ if ink:
+ label_layer = PixelLayer.frompil(
+ text_layer.crop(ink),
+ psd,
+ _layer_name(label_text, "LABEL"),
+ top=ink[1],
+ left=ink[0],
+ )
+ tysh = psd_live.label_type_block(
+ label_text,
+ geo.label_tracking(label_text),
+ label_y,
+ ink[2] - ink[0],
+ )
+ # Donor asset present -> a real editable type layer; absent -> the
+ # raster stays a plain pixel layer (still correct, just not live).
+ if tysh is not None:
+ from psd_tools.constants import Tag
+
+ label_layer.tagged_blocks[Tag.TYPE_TOOL_OBJECT_SETTING] = tysh
+ psd_live.make_border_layer(psd, Image.new("RGBA", (w, h), _BORDER_PLATE_RGBA))
+
+ # The embedded preview must be OUR flat composite: psd-tools cannot render
+ # the live effects (no Inner Glow, broken edge stroke), so letting save()
+ # recomposite would embed garbage for every non-Photoshop viewer.
+ preview = poster.copy()
+ preview.alpha_composite(gradient)
+ preview.alpha_composite(logo_layer)
+ if text_layer is not None:
+ preview.alpha_composite(text_layer)
+ preview.alpha_composite(border)
+
+ buf = io.BytesIO()
+ psd_live.inject_preview(psd, preview, buf)
+ return buf.getvalue()
diff --git a/backend/util/cl2k/psd_live.py b/backend/util/cl2k/psd_live.py
new file mode 100644
index 00000000..2e5d2e15
--- /dev/null
+++ b/backend/util/cl2k/psd_live.py
@@ -0,0 +1,350 @@
+"""Live (editable) Photoshop structures for the CL2K PSD export.
+
+Builds the pieces that make the exported ``.psd`` genuinely editable in
+Photoshop/Photopea instead of a stack of rasters: the BORDER LAYER's live
+Stroke + Inner Glow effects (``lfx2``), the live black gradient fill layer
+(``GdFl``), and a real type layer (``TySh``) for the bottom label.
+
+The effect and gradient descriptors are constructed from scratch, from the
+values extracted out of the community CL2K template (see the constants below —
+they are format facts, the same ones geometry.py records). The type layer's
+text-engine data is the one structure too risky to hand-build (Photoshop
+rejects malformed EngineData), so it is transplanted from a committed donor
+block (``assets/cl2k/label_tysh.bin``, generated by
+``scripts/gen_cl2k_label_tysh.py``) and re-texted per export.
+
+psd-tools' own compositor cannot render these effects (Inner Glow is
+unimplemented; its stroke painter breaks at canvas edges), so exports must
+inject the faithful flat render as the embedded preview via
+:func:`inject_preview` and NEVER let ``PSDImage.save()`` recomposite — it
+would embed garbage for every non-Photoshop viewer.
+"""
+
+from __future__ import annotations
+
+import copy
+import io
+import logging
+from typing import Any, Optional
+
+from backend.util.cl2k import geometry as geo
+
+# The values Photoshop stores for the CL2K border effects and gradient fill,
+# read from the community template's own descriptors. Stroke: 25px Inside,
+# white, 100%. Inner glow: multiply black 70%, Softer, choke 50, size 45,
+# range 50%, source Edge. Gradient: linear 90deg, scale 30%, offset
+# (-19.2%, 32.71328125%), opacity stops 100%@819/4096 and 0%@3909/4096.
+_GLOW_OPACITY = 70.0
+_GLOW_CHOKE = 50.0
+_GLOW_SIZE = 45.0
+_GLOW_RANGE = 50.0
+_GRAD_SCALE = 30.0
+_GRAD_OFFSET = (-19.2, 32.71328125)
+_GRAD_STOP_FULL = 819 # of 4096 — the opacity-100% stop (y=1375.72 on canvas)
+_GRAD_STOP_ZERO = 3909 # the opacity-0% stop (y=1036.24 on canvas)
+
+# The label baseline Photoshop anchors type at, per band (measured off finished
+# creator PSDs; geometry's *_Y values are ink centres, these are baselines).
+_TYPE_BASELINE = {geo.SEASON_TEXT_Y: 1451.0, geo.COLLECTION_LABEL_Y: 1360.5}
+
+LABEL_TYSH_BIN = geo.ASSET_DIR / "label_tysh.bin"
+
+
+def _d(class_id: bytes, items, name: str = "") -> Any:
+ from psd_tools.psd.descriptor import Descriptor
+
+ desc = Descriptor(name=name, classID=class_id)
+ for key, value in items:
+ desc[key] = value
+ return desc
+
+
+def _rgb(r: float, g: float, b: float) -> Any:
+ from psd_tools.psd.descriptor import Double
+
+ return _d(b"RGBC", [(b"Rd ", Double(r)), (b"Grn ", Double(g)), (b"Bl ", Double(b))])
+
+
+def border_effects_block() -> Any:
+ """The BORDER LAYER's ``lfx2`` tagged block: live Stroke + Inner Glow.
+
+ Minimal 5-key form (the template itself ships exactly this set and renders
+ correctly in Photoshop). Every effect sub-descriptor MUST carry
+ ``present=True`` — without it Photoshop/psd-tools treat the effect as
+ absent even with ``enab=True``.
+ """
+ from psd_tools.constants import Tag
+ from psd_tools.psd.descriptor import Bool, Double, Enumerated, Integer, List, String, UnitFloat
+ from psd_tools.psd.tagged_blocks import DescriptorBlock2, TaggedBlock
+ from psd_tools.terminology import Unit
+
+ stroke = _d(
+ b"FrFX",
+ [
+ (b"enab", Bool(True)),
+ (b"present", Bool(True)),
+ (b"showInDialog", Bool(True)),
+ (b"Styl", Enumerated(typeID=b"FStl", enum=b"InsF")), # Inside
+ (b"PntT", Enumerated(typeID=b"FrFl", enum=b"SClr")), # solid colour
+ (b"Md ", Enumerated(typeID=b"BlnM", enum=b"Nrml")),
+ (b"Opct", UnitFloat(value=100.0, unit=Unit.Percent)),
+ (b"Sz ", UnitFloat(value=float(geo.BORDER_WIDTH), unit=Unit.Pixels)),
+ (b"Clr ", _rgb(255.0, 255.0, 255.0)),
+ (b"overprint", Bool(False)),
+ ],
+ )
+ curve = List()
+ for x, y in ((0.0, 0.0), (255.0, 255.0)):
+ curve.append(_d(b"CrPt", [(b"Hrzn", Double(x)), (b"Vrtc", Double(y))]))
+ contour = _d(b"ShpC", [(b"Nm ", String("Linear")), (b"Crv ", curve)])
+ glow = _d(
+ b"IrGl",
+ [
+ (b"enab", Bool(True)),
+ (b"present", Bool(True)),
+ (b"showInDialog", Bool(True)),
+ (b"Md ", Enumerated(typeID=b"BlnM", enum=b"Mltp")),
+ (b"Clr ", _rgb(0.0, 0.0, 0.0)),
+ (b"Opct", UnitFloat(value=_GLOW_OPACITY, unit=Unit.Percent)),
+ (b"GlwT", Enumerated(typeID=b"BETE", enum=b"SfBL")), # Softer
+ (b"Ckmt", UnitFloat(value=_GLOW_CHOKE, unit=Unit.Pixels)),
+ (b"blur", UnitFloat(value=_GLOW_SIZE, unit=Unit.Pixels)),
+ (b"Nose", UnitFloat(value=0.0, unit=Unit.Percent)),
+ (b"ShdN", UnitFloat(value=0.0, unit=Unit.Percent)),
+ (b"AntA", Bool(False)),
+ (b"TrnS", contour),
+ (b"Inpr", UnitFloat(value=_GLOW_RANGE, unit=Unit.Percent)),
+ (b"glwS", Enumerated(typeID=b"IGSr", enum=b"SrcE")), # from Edge
+ ],
+ )
+ root = _d(
+ b"null",
+ [
+ (b"Scl ", UnitFloat(value=100.0, unit=Unit.Percent)),
+ (b"masterFXSwitch", Bool(True)),
+ (b"numModifyingFX", Integer(2)),
+ (b"FrFX", stroke),
+ (b"IrGl", glow),
+ ],
+ )
+ data = DescriptorBlock2(name=root.name, classID=root.classID, version=0, data_version=16)
+ for key, value in root.items():
+ data[key] = value
+ return TaggedBlock(key=Tag.OBJECT_BASED_EFFECTS_LAYER_INFO.value, data=data)
+
+
+def gradient_fill_block() -> Any:
+ """The GRADIENT layer's live ``GdFl`` fill descriptor.
+
+ Photoshop rasterises this to exactly the template's ramp (the baked
+ ``gradient.png`` matches the descriptor's own maths to <2px on the stop
+ positions), so the live layer and the flat render agree.
+ """
+ from psd_tools.constants import Tag
+ from psd_tools.psd.descriptor import Bool, Double, Enumerated, Integer, List, String, UnitFloat
+ from psd_tools.psd.tagged_blocks import DescriptorBlock, TaggedBlock
+ from psd_tools.terminology import Unit
+
+ colors = List()
+ for location in (0, 4096):
+ colors.append(
+ _d(
+ b"Clrt",
+ [
+ (b"Clr ", _rgb(0.0, 0.0, 0.0)),
+ (b"Type", Enumerated(typeID=b"Clry", enum=b"UsrS")),
+ (b"Lctn", Integer(location)),
+ (b"Mdpn", Integer(50)),
+ ],
+ )
+ )
+ opacities = List()
+ for opacity, location, midpoint in (
+ (100.0, _GRAD_STOP_FULL, 50),
+ (0.0, _GRAD_STOP_ZERO, 70),
+ ):
+ opacities.append(
+ _d(
+ b"TrnS",
+ [
+ (b"Opct", UnitFloat(value=opacity, unit=Unit.Percent)),
+ (b"Lctn", Integer(location)),
+ (b"Mdpn", Integer(midpoint)),
+ ],
+ )
+ )
+ gradient = _d(
+ b"Grdn",
+ [
+ (b"Nm ", String("Custom")),
+ (b"GrdF", Enumerated(typeID=b"GrdF", enum=b"CstS")),
+ (b"Intr", Double(4096.0)),
+ (b"Clrs", colors),
+ (b"Trns", opacities),
+ ],
+ name="Gradient",
+ )
+ root = _d(
+ b"null",
+ [
+ (b"Dthr", Bool(True)),
+ (
+ b"gradientsInterpolationMethod",
+ Enumerated(typeID=b"gradientInterpolationMethodType", enum=b"Gcls"),
+ ),
+ (b"Angl", UnitFloat(value=90.0, unit=Unit.Angle)),
+ (b"Type", Enumerated(typeID=b"GrdT", enum=b"Lnr ")),
+ (b"Algn", Bool(False)),
+ (b"Scl ", UnitFloat(value=_GRAD_SCALE, unit=Unit.Percent)),
+ (
+ b"Ofst",
+ _d(
+ b"Pnt ",
+ [
+ (b"Hrzn", UnitFloat(value=_GRAD_OFFSET[0], unit=Unit.Percent)),
+ (b"Vrtc", UnitFloat(value=_GRAD_OFFSET[1], unit=Unit.Percent)),
+ ],
+ ),
+ ),
+ (b"Grad", gradient),
+ (b"Rvrs", Bool(False)),
+ ],
+ )
+ data = DescriptorBlock(name=root.name, classID=root.classID, version=16)
+ for key, value in root.items():
+ data[key] = value
+ return TaggedBlock(key=Tag.GRADIENT_FILL_SETTING.value, data=data)
+
+
+def label_type_block(
+ text: str, tracking: int, label_center_y: int, ink_width: float
+) -> Optional[Any]:
+ """A ``TySh`` block reading ``text``, or None when the donor asset is absent.
+
+ Deep-copies the committed donor and rewrites the string in its three
+ places (descriptor ``Txt``, ``Editor.Text``, both run-length arrays), the
+ tracking, and the anchor transform. Photoshop's centre justification puts
+ the ink ``tracking*size/2000`` px right of the anchor, so the anchor is
+ pre-compensated to land the ink centred on the canvas.
+ """
+ donor = _load_donor_tysh()
+ if donor is None:
+ return None
+ block = copy.deepcopy(donor)
+ tysh = block.data
+
+ tx = geo.CENTER_X - tracking * geo.LABEL_FONT_PX / 2000.0
+ ty = _TYPE_BASELINE.get(label_center_y, float(label_center_y) + 11.0)
+ tysh.transform = (1.0, 0.0, 0.0, 1.0, tx, ty)
+
+ td = tysh.text_data
+ td[b"Txt "].value = text + "\x00"
+ engine = td[b"EngineData"].value["EngineDict"]
+ engine["Editor"]["Text"].value = text + "\r"
+ for run in ("StyleRun", "ParagraphRun"):
+ lengths = engine[run]["RunLengthArray"]
+ item = copy.deepcopy(lengths[0])
+ item.value = len(text) + 1
+ lengths._items[:] = [item]
+ style = engine["StyleRun"]["RunArray"][0]["StyleSheet"]["StyleSheetData"]
+ style["Tracking"].value = tracking
+
+ # Ink extents (points, relative to the anchor) — hit-test box until
+ # Photoshop re-shapes the text on first edit.
+ half = float(ink_width) / 2.0
+ for key in (b"bounds", b"boundingBox"):
+ if key in td:
+ td[key][b"Left"].value = -half
+ td[key][b"Rght"].value = half
+ return block
+
+
+_DONOR_CACHE: dict = {}
+
+
+def _load_donor_tysh() -> Optional[Any]:
+ from psd_tools.psd.tagged_blocks import TaggedBlock
+
+ if "tysh" in _DONOR_CACHE:
+ return _DONOR_CACHE["tysh"]
+ if not LABEL_TYSH_BIN.exists():
+ _DONOR_CACHE["tysh"] = None # genuine not-found: the donor is optional
+ return None
+ try:
+ raw = LABEL_TYSH_BIN.read_bytes()
+ blob = TaggedBlock.read(io.BytesIO(raw), version=1, padding=1)
+ # psd-tools returns None (with only a log line) on bad bytes rather
+ # than raising — normalise that to a failure.
+ if blob is None or not hasattr(getattr(blob, "data", None), "text_data"):
+ raise ValueError("asset is not a TySh tagged block")
+ except Exception:
+ # Transient failure: don't cache — retry next export.
+ logging.getLogger(__name__).warning(
+ "CL2K label donor %s unreadable; label falls back to raster this "
+ "export",
+ LABEL_TYSH_BIN,
+ exc_info=True,
+ )
+ return None
+ _DONOR_CACHE["tysh"] = blob
+ return blob
+
+
+def make_border_layer(psd: Any, plate: Any) -> Any:
+ """The effects-only BORDER LAYER: an opaque plate whose fill is hidden
+ (fill opacity 0) so only the live Stroke + Inner Glow paint."""
+ from psd_tools.api.layers import PixelLayer
+ from psd_tools.constants import Tag
+
+ layer = PixelLayer.frompil(plate, psd, "BORDER LAYER")
+ layer.tagged_blocks[Tag.OBJECT_BASED_EFFECTS_LAYER_INFO] = border_effects_block()
+ layer.tagged_blocks.set_data(Tag.BLEND_FILL_OPACITY, 0)
+ return layer
+
+
+def make_gradient_layer(parent: Any) -> Any:
+ """A live gradient-fill layer named as the template's BLACK GRADIENT."""
+ from PIL import Image
+ from psd_tools.api.layers import PixelLayer
+ from psd_tools.constants import Tag
+
+ stub = Image.new("RGBA", (1, 1), (0, 0, 0, 0))
+ layer = PixelLayer.frompil(stub, parent, "BLACK GRADIENT")
+ layer.tagged_blocks[Tag.GRADIENT_FILL_SETTING] = gradient_fill_block()
+ # Fill layers carry no pixels — extent comes from the canvas. Zero the
+ # record bbox so readers treat it as a true fill layer, not a 1x1 pixel.
+ rec = layer._record
+ rec.top = rec.left = rec.bottom = rec.right = 0
+ return layer
+
+
+def inject_preview(psd: Any, preview: Any, out: io.BytesIO) -> None:
+ """Write ``psd`` with ``preview`` as the embedded flattened image.
+
+ Bypasses ``PSDImage.save()``'s recomposite (psd-tools cannot render the
+ live effects — Inner Glow is unimplemented and its stroke painter drops
+ three of the four frame edges) by setting the image data directly and
+ clearing the dirty flag. ``preview`` must match the document's channel
+ count (RGBA here).
+ """
+ if preview.mode != psd.pil_mode:
+ preview = preview.convert(psd.pil_mode)
+ from psd_tools.constants import Compression
+
+ # PSDImage.new leaves the image-data section RAW (6MB at this canvas).
+ psd._record.image_data.compression = Compression.RLE
+ psd._record.image_data.set_data(
+ [channel.tobytes() for channel in preview.split()], psd._record.header
+ )
+ try:
+ from psd_tools.constants import Resource
+
+ info = psd.image_resources.get_data(Resource.VERSION_INFO)
+ if info is not None:
+ info.has_composite = True
+ except (AttributeError, KeyError, ImportError):
+ # VERSION_INFO is optional metadata — a reader that lacks it still
+ # opens the PSD, so never fail the save over the composite flag.
+ pass
+ psd._updated = False
+ psd.save(out)
diff --git a/backend/util/cl2k/renderer.py b/backend/util/cl2k/renderer.py
new file mode 100644
index 00000000..2e57c558
--- /dev/null
+++ b/backend/util/cl2k/renderer.py
@@ -0,0 +1,1371 @@
+"""CL2K poster renderer (ImageMagick via Wand).
+
+Reproduces the community CL2K template programmatically: a full-bleed textless
+backdrop, the black bottom gradient, a whitened clear logo placed on the locked
+guides, an optional COLLECTION / season label, and the default white border —
+exported as a high-quality JPEG per the DAPS rules. All geometry comes from
+:mod:`backend.util.cl2k.geometry`.
+
+ImageMagick (not Pillow) is used here for gradient compositing, logo whitening,
+and text, matching the wider MM2K/CL2K toolchain. Pillow stays in use elsewhere
+in Chub; this module does not touch it.
+
+Run standalone for a quick visual check::
+
+ python -m backend.util.cl2k.renderer \\
+ --backdrop art.jpg --logo logo.png --kind movie --out poster.jpg
+"""
+
+from __future__ import annotations
+
+import itertools
+import logging
+from typing import List, Optional, Tuple
+
+from wand.color import Color
+from wand.drawing import Drawing
+from wand.image import COMPOSITE_OPERATORS, Image
+
+from backend.util.cl2k import color, geometry as geo
+from backend.util.cl2k.limits import apply_magick_limits
+from backend.util.cl2k.logo_extract import finish_two_tone, flatten_3d_logo
+
+_log = logging.getLogger(__name__)
+
+# ImageMagick ships no resource limits without a policy.xml, which only exists in
+# the container. This is the sole wand entry point, so cap the process here.
+apply_magick_limits()
+
+# ImageMagick 7 renamed CopyOpacity to CopyAlpha; wand exposes whichever the
+# linked library supports (IM6 = Debian/CI runners, IM7 = homebrew dev). Both
+# take the source's intensity as the new alpha when the source has no alpha
+# channel — which is how _whiten/_flip_regions use it.
+_COPY_ALPHA = "copy_alpha" if "copy_alpha" in COMPOSITE_OPERATORS else "copy_opacity"
+
+
+# ----- helpers ---------------------------------------------------------------
+def _v_pos_top(src_h: int, height: int, v_pos: float) -> int:
+ """Crop top for ``v_pos`` (-1..1, 0 = centred) within a source that overflows.
+
+ Plain source-bounded panning — no black band, so callers that cannot hide one
+ (every path except the cover-fill's downward extend) share this.
+ """
+ centre = max(0, min(int(round(0.5 * src_h - height / 2)), src_h - height))
+ v_pos = max(geo.V_POS_MIN, min(float(v_pos or 0.0), geo.V_POS_MAX))
+ span = centre if v_pos <= 0 else (src_h - height) - centre
+ return max(0, min(centre + int(round(v_pos * span)), src_h - height))
+
+
+def _cover_resize(
+ img: Image,
+ width: int,
+ height: int,
+ focus_x: float = 0.5,
+ v_pos: float = 0.0,
+ zoom: float = 1.0,
+) -> None:
+ """Resize + crop ``img`` in place to exactly width×height (cover fill).
+
+ ``focus_x`` (0..1) chooses what stays in frame horizontally: the focal point
+ of the scaled image is centred in the crop, clamped to the edges. 0.5 is the
+ centre crop (the default).
+
+ ``v_pos`` (-1..1) is the ONE vertical control; 0 is the centred crop.
+ Positive pushes the framed image *up* without changing its size: it pans down
+ through any source remaining below the crop and, once that runs out,
+ edge-extends the bottom row faded to black — a band that lands in the CL2K
+ gradient/black zone, so it stays hidden. Negative pans the other way, but only
+ into real source above the crop: the gradient is bottom-only, so an extended
+ band at the top would be plainly visible. A cover-filled 16:9 backdrop is
+ exactly ``height`` tall, so it has no upward travel at all until ``zoom`` > 1
+ — that is the geometry, not a clamp we could lift.
+
+ ``zoom`` (0.5..3.0) scales relative to the cover-fill baseline. 1.0 = plain
+ cover (unchanged). >1 crops tighter (punch in). <1 scales the art *below* the
+ fill so more of a high-resolution source stays visible — the art is then
+ centred on black and the freed bands merge into the CL2K gradient/border.
+ """
+ zoom = max(geo.ZOOM_MIN, min(float(zoom or 1.0), geo.ZOOM_MAX))
+ scale = max(width / img.width, height / img.height) * zoom
+ img.resize(
+ max(1, int(round(img.width * scale))), max(1, int(round(img.height * scale)))
+ )
+
+ # Zoom-out: the scaled art no longer fills the frame. Crop whichever axis
+ # still overflows, then centre on black and pad the deficient axis/axes.
+ if img.width < width or img.height < height:
+ if img.width > width:
+ cx = int(round(focus_x * img.width - width / 2))
+ cx = max(0, min(cx, img.width - width))
+ img.crop(cx, 0, width=width, height=img.height)
+ if img.height > height:
+ cy = _v_pos_top(img.height, height, v_pos)
+ img.crop(0, cy, width=img.width, height=height)
+ img.background_color = Color("black")
+ off_x = -((width - img.width) // 2) if img.width < width else 0
+ off_y = -((height - img.height) // 2) if img.height < height else 0
+ img.extent(width=width, height=height, x=off_x, y=off_y)
+ return
+ left = int(round(focus_x * img.width - width / 2))
+ left = max(0, min(left, img.width - width))
+ centre_top = max(0, min(int(round(0.5 * img.height - height / 2)), img.height - height))
+ v_pos = max(geo.V_POS_MIN, min(float(v_pos or 0.0), geo.V_POS_MAX))
+ if v_pos <= 0:
+ # Up is source-only (see the docstring): scale into whatever sits above.
+ img.crop(
+ left,
+ centre_top + int(round(v_pos * centre_top)),
+ width=width,
+ height=height,
+ )
+ return
+ # Pan down through the source still below the crop, then up to ~30% of the
+ # canvas past its bottom edge (that band sits in the black gradient zone).
+ remaining = img.height - height - centre_top
+ black_allow = int(round(height * 0.30))
+ top = centre_top + int(round(v_pos * (remaining + black_allow)))
+ avail = max(1, min(height, img.height - top))
+ img.crop(left, min(top, img.height - 1), width=width, height=avail)
+ if avail >= height:
+ return
+ # Source ran out: edge-extend the (now bottom) row faded to black and pad.
+ fill_h = height - avail
+ fill = _extend_fill(img, width, fill_h, from_top=False)
+ img.background_color = Color("black")
+ img.extent(width=width, height=height, x=0, y=0)
+ with Image(blob=fill) as b:
+ img.composite(b, left=0, top=avail)
+ _blend_seam(img, width, avail)
+
+
+def _apply_crop(img: Image, crop: Optional[Tuple[float, float, float, float]]) -> None:
+ """Crop ``img`` in place to a normalized ``(x, y, w, h)`` region (0..1), or no-op.
+
+ Clamped to the image bounds. Shared by the fit + extend framings to isolate the
+ subject region of a wide backdrop before scaling.
+ """
+ if not crop:
+ return
+ cx, cy, cw, ch = crop
+ x = max(0, min(int(round(cx * img.width)), img.width - 1))
+ y = max(0, min(int(round(cy * img.height)), img.height - 1))
+ w = max(1, min(int(round(cw * img.width)), img.width - x))
+ h = max(1, min(int(round(ch * img.height)), img.height - y))
+ img.crop(x, y, width=w, height=h)
+
+
+def _extend_fill(img: Image, width: int, fill_h: int, from_top: bool) -> bytes:
+ """Build a ``width``×``fill_h`` edge-extend fill from ``img``'s top or bottom strip.
+
+ BOTH fills sample only a THIN edge strip, so the first fill row matches the
+ photo's edge row and the seam is invisible (C0-continuous). A thick strip
+ would put content from well inside the photo right at the seam — a visible
+ brightness step where the fill meets the photo (the template gradient is only
+ ~70% black at typical seam heights, so it doesn't hide it). A *bottom* fill is
+ additionally faded to black toward the canvas edge so it merges into the CL2K
+ gradient/black; a *top* fill is NOT faded (the CL2K top has no gradient).
+ Returns PNG bytes.
+ """
+ if from_top:
+ strip_h = max(2, min(img.height, 12)) # thin: the sky edge, not the heads
+ src_top = 0
+ else:
+ strip_h = max(2, min(img.height, 24)) # thin: the edge row, not the scene
+ src_top = img.height - strip_h
+ with img.clone() as s:
+ s.crop(0, src_top, width=width, height=strip_h)
+ s.resize(width, fill_h, filter="triangle")
+ s.blur(radius=0, sigma=max(8.0, fill_h / 24.0))
+ if not from_top:
+ # Fade DOWN to black (white at the seam -> black at the canvas edge).
+ with Image(
+ width=width, height=fill_h, pseudo="gradient:white-black"
+ ) as ramp:
+ s.composite(ramp, left=0, top=0, operator="multiply")
+ s.format = "png"
+ return s.make_blob()
+
+
+def _blend_seam(img: Image, width: int, seam_y: int, half: int = 10) -> None:
+ """Soft-blur a thin horizontal band across ``seam_y`` so the photo→fill seam
+ is imperceptible. Even a thin-strip fill lands a few luminance units off the
+ photo's edge row (the blur shifts it), and in the near-black gradient zone a
+ ~3-unit row step still reads as a faint line on a good display."""
+ top = max(0, seam_y - half)
+ band_h = min(2 * half, img.height - top)
+ if band_h <= 2:
+ return
+ with img.clone() as band:
+ band.crop(0, top, width=width, height=band_h)
+ band.blur(radius=0, sigma=half / 2.0)
+ img.composite(band, left=0, top=top)
+
+
+def _zoom_fit(img: Image, width: int, zoom: float) -> int:
+ """Scale ``img`` to ``width`` × ``zoom`` and crop the horizontal overflow back
+ to ``width`` (centred). ``zoom`` 1.0 = plain fit-to-width; >1 enlarges the
+ subject (the sides spill past the canvas and are trimmed). Returns the scaled
+ height. Shared by the fit + extend framings so a wide backdrop's subject isn't
+ forced down to the full-width (tiny) size."""
+ zoom = max(1.0, min(float(zoom or 1.0), 3.0))
+ target_w = int(round(width * zoom))
+ new_h = int(round(img.height * target_w / img.width))
+ img.resize(target_w, new_h, filter="lanczos")
+ if target_w > width:
+ img.crop(int(round((target_w - width) / 2)), 0, width=width, height=new_h)
+ return new_h
+
+
+def _fit_resize(
+ img: Image,
+ width: int,
+ height: int,
+ crop: Optional[Tuple[float, float, float, float]] = None,
+ v_pos: float = 0.0,
+ zoom: float = 1.0,
+) -> None:
+ """Contain-fit ``img`` to ``width`` and place it on a black canvas — the CL2K
+ "fit" framing, in place.
+
+ Unlike :func:`_cover_resize` (which scales up and crops the *sides* to fill the
+ 2:3 canvas — cutting off subjects spread across a wide 16:9 backdrop), this
+ scales the image *down* so its full width is preserved (everyone stays in
+ frame) and fills the empty band(s). This reproduces how a poster artist fits a
+ wide key-art into the 2:3 frame.
+
+ ``v_pos`` (0..1) positions the photo vertically when it's shorter than the
+ canvas: 0 = top-anchored (default), 1 = bottom-anchored, 0.4 ≈ headroom above
+ the subjects. Deliberately NOT :func:`_cover_resize`'s -1..1-centred-on-0
+ scale: here the photo is *anchored*, not panned, so 0 means top and there is
+ nothing for a negative value to name. The freed space is edge-extended —
+ **sky upward** above the photo (no black fade; the CL2K top has no gradient)
+ and **faded to black downward** below it (so it merges into the gradient/logo
+ zone). ``crop`` (``x, y, w, h`` 0..1) optionally isolates the subject region
+ before fitting. ``zoom`` (>=1) enlarges the subject above the full-width fit
+ (sides crop), so a wide backdrop doesn't shrink to a tiny strip.
+ """
+ _apply_crop(img, crop)
+ new_h = _zoom_fit(img, width, zoom)
+ v_pos = max(0.0, min(1.0, v_pos))
+ if new_h >= height:
+ # Taller than the canvas: keep the v_pos-chosen vertical slice.
+ top = int(round(v_pos * (new_h - height)))
+ img.crop(0, top, width=width, height=height)
+ return
+ # Shorter than the canvas: position the photo and edge-extend the freed band(s).
+ gap = height - new_h
+ top_off = int(round(v_pos * gap))
+ bot_h = gap - top_off
+ top_blob = _extend_fill(img, width, top_off, from_top=True) if top_off > 0 else None
+ bot_blob = _extend_fill(img, width, bot_h, from_top=False) if bot_h > 0 else None
+ img.background_color = Color("black")
+ if top_off > 0:
+ img.splice(width=0, height=top_off, x=0, y=0) # push photo down
+ img.extent(width=width, height=height, x=0, y=0) # pad bottom to full height
+ if top_blob:
+ with Image(blob=top_blob) as t:
+ img.composite(t, left=0, top=0)
+ _blend_seam(img, width, top_off)
+ if bot_blob:
+ with Image(blob=bot_blob) as b:
+ img.composite(b, left=0, top=top_off + new_h)
+ _blend_seam(img, width, top_off + new_h)
+
+
+def fit_extend_canvas(
+ backdrop_bytes: bytes,
+ crop: Optional[Tuple[float, float, float, float]] = None,
+ width: int = geo.CANVAS_W,
+ height: int = geo.CANVAS_H,
+ feather: int = 28,
+ zoom: float = 1.0,
+ v_pos: float = 0.0,
+) -> Tuple[bytes, Optional[bytes]]:
+ """Prepare the canvas + mask for AI outpaint ("extend" framing).
+
+ Fits the (optionally cropped) backdrop to the canvas *width* and top-anchors it,
+ leaving the empty bottom band for an AI inpainter to fill so the subjects stay
+ full-size (no shrink, no side-crop) — the artist's "extend the bottom, crop the
+ wasted top" trick. ``zoom`` (>=1) enlarges the subject above the full-width fit
+ (sides crop) so it isn't a tiny strip; the AI then fills only the smaller gap.
+ Returns ``(canvas_png, mask_png)`` where the mask is white (=generate) over the
+ empty band and black (=keep) over the photo, feathered at the seam. Returns
+ ``(canvas_png, None)`` when the fitted photo already fills the height — nothing
+ to extend, the caller should just fit/cover it (``v_pos`` picks the slice).
+ ``v_pos`` is top-anchored 0..1 here, matching :func:`_fit_resize` rather than
+ :func:`_cover_resize`.
+
+ The mask convention matches :mod:`text_removal` (white = fill), so the canvas +
+ mask feed straight into ``text_removal.remove_text`` for any provider.
+ """
+ with Image(blob=backdrop_bytes) as img:
+ _apply_crop(img, crop)
+ new_h = _zoom_fit(img, width, zoom)
+ if new_h >= height:
+ top = int(round(max(0.0, min(1.0, v_pos)) * (new_h - height)))
+ img.crop(0, top, width=width, height=height)
+ img.format = "png"
+ return img.make_blob(), None
+ img.background_color = Color("black")
+ img.extent(width=width, height=height, x=0, y=0)
+ img.format = "png"
+ canvas_png = img.make_blob()
+
+ # Mask: white over the empty band (start a little above the seam so the AI
+ # blends into the photo edge), black over the kept photo, soft-feathered.
+ band_top = max(0, new_h - feather)
+ with Image(width=width, height=height, background=Color("black")) as mask:
+ with Drawing() as draw:
+ draw.fill_color = Color("white")
+ draw.rectangle(left=0, top=band_top, width=width, height=height - band_top)
+ draw(mask)
+ mask.blur(radius=0, sigma=feather / 2.0)
+ mask.format = "png"
+ mask_png = mask.make_blob()
+ return canvas_png, mask_png
+
+
+def _whiten(logo: Image, flat_fallback: bool = True) -> bool:
+ """Recolour the logo to the CL2K two-tone: white fills, black keylines.
+
+ Per-pixel key + a local-contrast pass (constants and rationale in
+ :mod:`geometry`, "logo whitening"). Alpha is preserved throughout; a logo
+ that would come out mostly black falls back to the flat white silhouette
+ (suppressed via ``flat_fallback=False`` when the invert pass follows — a
+ flat silhouette inverts to full transparency, i.e. nothing).
+
+ Returns True when the flat-white fallback fired — the caller then skips the
+ colour-edge / dark-body post-passes, which would re-mark that clean silhouette.
+ """
+ q = logo.quantum_range
+ with logo.clone() as alpha:
+ alpha.alpha_channel = "extract"
+ # 1. two-tone key: max(saturation, lightness), leveled near-binary.
+ with logo.clone() as hsl:
+ hsl.alpha_channel = "off"
+ hsl.transform_colorspace("hsl")
+ with hsl.channel_images["green"] as sat:
+ key = sat.clone()
+ try:
+ with hsl.channel_images["blue"] as light:
+ key.composite(light, operator="lighten")
+ except Exception:
+ key.close()
+ raise
+ try:
+ # NB: Wand level() points are fractions of quantum range (0..1).
+ key.level(black=geo.WHITEN_KEY_BLACK, white=geo.WHITEN_KEY_WHITE)
+ # 2. flip pixels much darker (luma) than their neighborhood to black.
+ with logo.clone() as luma:
+ luma.alpha_channel = "off"
+ luma.transform_colorspace("gray")
+ with luma.clone() as detail:
+ detail.blur(
+ radius=0, sigma=max(2.0, logo.width * geo.WHITEN_DETAIL_SIGMA)
+ )
+ detail.composite(luma, operator="minus_src") # blurred - luma
+ detail.level(black=geo.WHITEN_DETAIL_LO, white=geo.WHITEN_DETAIL_HI)
+ detail.negate()
+ key.composite(detail, operator="multiply")
+ # Mostly-black result? The flat silhouette is the only readable option.
+ a_mean = alpha.mean / q
+ with key.clone() as masked:
+ masked.composite(alpha, operator="multiply")
+ k_mean = masked.mean / q
+ if (
+ flat_fallback
+ and a_mean > 0.001
+ and k_mean / a_mean < geo.WHITEN_FALLBACK_MEAN
+ ):
+ logo.colorize(color=Color("white"), alpha=Color("white"))
+ return True # fell back to the flat silhouette
+ key.transform_colorspace("srgb")
+ key.alpha_channel = "off"
+ key.composite(alpha, operator=_COPY_ALPHA)
+ logo.composite(key, left=0, top=0, operator="copy")
+ return False
+ finally:
+ key.close()
+
+
+def _flip_regions(logo: Image, mask_bytes: bytes) -> None:
+ """Invert black↔white inside the brushed regions (logo touch-up), in place.
+
+ The mask is brushed over the PROCESSED (trimmed + whitened) logo — white
+ strokes on transparency, at display resolution — and is resized to the
+ logo here. A global two-tone map fundamentally cannot decide interior
+ accents that share saturation AND luma with their surroundings (the same
+ red is fill in one place and accent in another on real logos), so the user
+ paints the few regions the keymap gets wrong. Alpha is untouched — the
+ flip only swaps fill colours, never reshapes the logo. Decode failures are
+ a no-op (the un-flipped logo renders).
+ """
+ try:
+ mask = Image(blob=mask_bytes)
+ except Exception as exc:
+ _log.warning(f"cl2k: logo flip mask is undecodable ({exc}) — regions not flipped")
+ return
+ try:
+ # Brush strokes are white-on-transparent: flatten onto black so the
+ # mask reads white=flip / black=keep, then match the logo's size.
+ mask.background_color = Color("black")
+ mask.alpha_channel = "remove"
+ mask.transform_colorspace("gray")
+ mask.resize(logo.width, logo.height)
+ with logo.clone() as flipped:
+ flipped.negate() # RGB only; alpha untouched
+ # Confine the flip: flipped's alpha := original alpha × mask.
+ with logo.clone() as alpha:
+ alpha.alpha_channel = "extract"
+ alpha.composite(mask, operator="multiply")
+ flipped.composite(alpha, operator=_COPY_ALPHA)
+ logo.composite(flipped, left=0, top=0)
+ except Exception as exc:
+ # Fail open like every other per-logo pass here, but never silently: the
+ # mask decoded, so this dropped edits the user made and saw in the preview.
+ _log.warning(f"cl2k: logo flip failed ({exc}) — regions left un-flipped")
+ finally:
+ mask.close()
+
+
+def _erase_regions(logo: Image, mask_bytes: bytes) -> None:
+ """Make brushed regions transparent (manual logo cleanup), in place.
+
+ The mask is brushed over the PROCESSED logo — white strokes on transparency,
+ at display resolution — and is resized here. Extraction and whitening can keep
+ stray bits a clean logo shouldn't have (a leftover glyph, a ® mark, edge
+ speckle); the user paints those away. White = erase; everything unpainted
+ keeps its alpha. Colours are untouched — only alpha is reduced. Decode
+ failures are a no-op (the un-erased logo renders).
+ """
+ try:
+ mask = Image(blob=mask_bytes)
+ except Exception as exc:
+ _log.warning(f"cl2k: logo erase mask is undecodable ({exc}) — nothing erased")
+ return
+ try:
+ # Brush strokes are white-on-transparent: flatten onto black so the mask
+ # reads white=erase / black=keep, match the logo's size, then negate so it
+ # becomes an alpha multiplier (erase->0, keep->full).
+ mask.background_color = Color("black")
+ mask.alpha_channel = "remove"
+ mask.transform_colorspace("gray")
+ mask.resize(logo.width, logo.height)
+ mask.negate()
+ with logo.clone() as alpha:
+ alpha.alpha_channel = "extract"
+ alpha.composite(mask, operator="multiply") # zero alpha where erased
+ logo.composite(alpha, operator=_COPY_ALPHA)
+ except Exception as exc:
+ # Fail open like every other per-logo pass here, but never silently: the
+ # mask decoded, so this dropped edits the user made and saw in the preview.
+ _log.warning(f"cl2k: logo erase failed ({exc}) — regions left un-erased")
+ finally:
+ mask.close()
+
+
+def _invert_to_clear(logo: Image) -> None:
+ """Invert logo: white → transparent, black → white, in place.
+
+ For plate-style logos (a solid light plate with dark text — e.g. sticker
+ art), the two-tone whiten correctly yields a white box with black text,
+ which is the OPPOSITE of a clearlogo. This pass makes darkness the
+ opacity: black text/keylines come out solid white, the white plate
+ vanishes, and grey anti-aliased edges feather naturally. Runs AFTER the
+ whiten + touch-up flip, so the brush still rescues mis-keyed regions.
+ """
+ with logo.clone() as blackness:
+ blackness.alpha_channel = "off"
+ blackness.transform_colorspace("gray")
+ blackness.negate()
+ # New alpha = blackness × the original alpha (transparent stays out).
+ with logo.clone() as alpha:
+ alpha.alpha_channel = "extract"
+ blackness.composite(alpha, operator="multiply")
+ logo.colorize(color=Color("white"), alpha=Color("white")) # RGB only
+ logo.composite(blackness, operator=_COPY_ALPHA)
+
+
+def _flat_white(logo: Image) -> None:
+ """Paint every opaque pixel pure white, keeping alpha — a flat silhouette.
+
+ Unlike :func:`_whiten` (the two-tone key + keyline pass), this does no
+ keying: it is the right tool for already-stylised logos the two-tone pass
+ mangles — outline wordmarks and rings, where thin strokes are almost all
+ "edge" so the keyline pass blackens them instead of leaving clean fills.
+ The result is exactly :func:`_whiten`'s mostly-black fallback, forced.
+ """
+ logo.colorize(color=Color("white"), alpha=Color("white")) # RGB only
+
+
+def _face_only(logo: Image) -> None:
+ """Keep a 3D/extruded logo's lit face as a flat white wordmark, in place.
+
+ Unsplittable art falls back to the flat silhouette — never the two-tone pass,
+ which is what this mode exists to avoid.
+ """
+ faced = flatten_3d_logo(logo.make_blob("png"))
+ if faced is None:
+ _flat_white(logo)
+ return
+ with Image(blob=faced) as face:
+ logo.composite(face, left=0, top=0, operator="copy")
+ _trim_logo(logo) # callers trimmed the OLD silhouette; the extrusion's padding is now free
+
+
+def _apply_whiten(logo: Image, *, invert: bool) -> None:
+ """Two-tone whiten + colour-edge keylines + dark-body fill, in place.
+
+ :func:`_whiten` whitens saturated/light fills and inks thin luma keylines
+ crisply. Two post-passes finish the two-tone for cases a per-pixel key can't:
+ :func:`ink_color_edges` adds a black separator where two differently-coloured
+ fills meet with no outline (else they merge to one white blob), and
+ :func:`fill_dark_bodies` blacks in a WIDE dark shape the small keyline blur
+ leaves white-cored. Both are no-ops on an already-clean logo (Dragon Ball GT).
+
+ Skipped entirely on the invert path (it makes a clearlogo differently) and
+ when the flat-white fallback fired (the post-passes would re-mark that clean
+ silhouette). The pre-whiten original is captured only when the passes can run.
+ """
+ if invert:
+ _whiten(logo, flat_fallback=False)
+ return
+ original_png = logo.make_blob("png") # colours the post-passes key against
+ if _whiten(logo, flat_fallback=True):
+ return # flat-white fallback — leave the clean silhouette untouched
+ stepped = finish_two_tone(logo.make_blob("png"), original_png)
+ with Image(blob=stepped) as img2:
+ logo.composite(img2, left=0, top=0, operator="copy")
+
+
+def _rasterize_svg_logo(svg_bytes: bytes, target_width: int = 2000) -> bytes:
+ """Rasterize an SVG clear-logo to PNG bytes at ~``target_width`` content width.
+
+ CL2K's logo pipeline is raster (Wand), and :func:`select_logo` *prefers* SVGs.
+ ImageMagick can read them in the runtime image (librsvg2-2 ships there), but
+ its delegate has no equivalent of cairosvg's ``unsafe=False``, so a hostile
+ SVG could pull external resources through it — every SVG is routed here
+ instead. Vectors are resolution-free, so ~2000px keeps the logo sharp once
+ scaled to the box.
+
+ Imported lazily so a build without cairosvg surfaces the ImportError to the
+ caller's decode-failure fallback (the typeset wordmark) instead of breaking
+ module import."""
+ import cairosvg
+
+ # unsafe=False is cairosvg's default, stated here so an upstream default flip
+ # can't silently re-enable external-entity/file loading (CWE-611).
+ return cairosvg.svg2png(
+ bytestring=svg_bytes, output_width=target_width, unsafe=False
+ )
+
+
+# BOM + whitespace, so a UTF-8-signed or indented SVG is not read as a raster.
+_SVG_LEAD = b"\xef\xbb\xbf \t\r\n\f\v"
+_SVG_SCAN = 1024
+
+
+def _is_svg(logo_bytes: bytes) -> bool:
+ """True when the bytes lead with markup — treated as SVG, never as raster."""
+ head = logo_bytes[:_SVG_SCAN].lstrip(_SVG_LEAD).lower()
+ # No raster format's magic begins with "<". Anything markup-first MUST take
+ # the sandboxed cairosvg path: a long prologue can push