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 past this + # scan, and ImageMagick's own XML delegates must never see the bytes. + return head.startswith(b"<") + + +def _read_logo_image(logo_bytes: bytes) -> Image: + """Decode logo bytes, rasterizing SVG sources at high density. + + SVG logos are rasterized to PNG via cairosvg at ~2000px content width (see + :func:`_rasterize_svg_logo`), never through ImageMagick's own delegate. + Raster formats pass through untouched. + """ + if not _is_svg(logo_bytes): + return Image(blob=logo_bytes) + return Image(blob=_rasterize_svg_logo(logo_bytes, target_width=2000)) + + +def _trim_logo(logo: Image) -> None: + """Crop to visible content (alpha > geo.LOGO_TRIM_ALPHA), in place. + + The ONE logo trim — process_logo, logo_is_usable and _place_logo must agree. + """ + # trim() reports the box in canvas coords, so a source carrying a page offset + # would push `left` past the image and make crop raise. + logo.reset_coords() + with logo.clone() as probe: + probe.alpha_channel = "extract" # alpha -> greyscale, so trim sees it + probe.threshold(geo.LOGO_TRIM_ALPHA / 255.0) + # All sub-threshold: IM trims a uniform image to 1x1, not to nothing — + # bail out here (matches the Pillow PSD path's None bbox). + if probe.mean_channel()[0] <= 0: + return + probe.background_color = Color("black") + probe.trim(color=Color("black")) + left, top = probe.page_x, probe.page_y + width, height = probe.width, probe.height + if width > 0 and height > 0: + logo.crop(left=left, top=top, width=width, height=height) + + +def process_logo( + logo_bytes: bytes, + *, + whiten: bool = True, + flat_white: bool = False, + logo_3d: bool = False, + flip_mask_bytes: Optional[bytes] = None, + erase_mask_bytes: Optional[bytes] = None, + invert: bool = False, +) -> Tuple[bytes, int, int]: + """Trim transparent padding and (optionally) whiten a clear logo. + + Returns ``(png_bytes, width, height)`` for the *trimmed* result — the exact + bytes and dimensions :func:`_place_logo` would size and place. The frontend + uses this for the live logo overlay: drawing these bytes at the box derived + from ``width``/``height`` + the logo geometry matches the rendered placement + pixel-for-pixel, so the size/position sliders preview instantly without a + server render per drag. ``flip_mask_bytes`` applies the user's black↔white + touch-up regions (see :func:`_flip_regions`); ``invert`` turns plate-style + logos into clearlogos (see :func:`_invert_to_clear`). + + Recolour modes rank ``logo_3d`` > ``flat_white`` > ``whiten``. + """ + with _read_logo_image(logo_bytes) as logo: + _trim_logo(logo) + if logo_3d: + _face_only(logo) + elif flat_white: + _flat_white(logo) + elif whiten: + _apply_whiten(logo, invert=invert) + if flip_mask_bytes: + _flip_regions(logo, flip_mask_bytes) + # Both white-silhouette modes invert to full transparency, i.e. nothing. + if invert and not flat_white and not logo_3d: + _invert_to_clear(logo) + if erase_mask_bytes: + _erase_regions(logo, erase_mask_bytes) + logo.format = "png" + return logo.make_blob(), logo.width, logo.height + + +def logo_is_usable(logo_bytes: bytes, min_width: int = geo.LOGO_MIN_WIDTH) -> bool: + """True if the clear logo is sharp enough to place at the CL2K logo box. + + Measures the logo's *trimmed* content width (transparent padding removed, the + same trim :func:`_place_logo` does) and rejects anything narrower than + ``min_width`` — those would have to be upscaled heavily to the ~600px box and + render fuzzy. Per the CL2K rule, a too-small logo should yield to drawn title + text instead. Returns True on any decode error (fail open — don't drop a logo + we simply couldn't measure).""" + try: + with _read_logo_image(logo_bytes) as logo: + _trim_logo(logo) + return logo.width >= min_width + except Exception: + return True + + +def _place_logo( + base: Image, + logo_bytes: bytes, + baseline: int, + max_width: Optional[int], + whiten: bool, + logo_scale: float = 1.0, + logo_y_offset: int = 0, + flip_mask_bytes: Optional[bytes] = None, + erase_mask_bytes: Optional[bytes] = None, + invert: bool = False, + flat_white: bool = False, + logo_3d: bool = False, +) -> None: + """Whiten, size and bottom-align the clear logo onto ``base``. + + The guide-fit box targets ``max_width`` (the 700px recommended guide by + default) with height clamped so the logo top never rises above + ``LOGO_ZONE_TOP``. ``logo_scale`` then multiplies that whole box (1.0 = + strict guides), clamped only to the canvas. The width guides (600/700/800) + are guidelines, not limits — hand-made references run ~846-881px wide, and + boxy/sticker designs break the y=1100 top guide rather than shrink to an + unreadable stamp — so the slider can take ANY logo past the guide box. + + ``logo_y_offset`` shifts the placement (px; positive = down) without changing + the size. At offset 0 the logo bottom sits exactly on the template's + "Main Logo Bottom" guide (y=1352; collections use 1319) — finished creator + PSDs in refs/ all bottom-align there pixel-exact (Deuce Bigalow measured + y≈1349 too), so the offset is an escape hatch for odd logo artwork, not a + routine adjustment. + """ + logo_scale = max( + geo.LOGO_SCALE_MIN, min(float(logo_scale or 1.0), geo.LOGO_SCALE_MAX) + ) + logo_y_offset = max( + geo.LOGO_Y_OFFSET_MIN, min(int(logo_y_offset or 0), geo.LOGO_Y_OFFSET_MAX) + ) + with _read_logo_image(logo_bytes) as logo: + _trim_logo(logo) # drop padding -> width == visible content + # Mode ranking mirrors process_logo — the overlay must match the render. + if logo_3d: + _face_only(logo) + elif flat_white: + _flat_white(logo) + elif whiten: + _apply_whiten(logo, invert=invert) + if flip_mask_bytes: + # Same trimmed/whitened space the touch-up brush was drawn over + # (process_logo's output) — applied before the resize below. + _flip_regions(logo, flip_mask_bytes) + if invert and not flat_white and not logo_3d: + _invert_to_clear(logo) + if erase_mask_bytes: + _erase_regions(logo, erase_mask_bytes) + if max_width is None: + # Auto: size from the logo's own shape (see geometry.auto_logo_size). + target_w, target_h = geo.auto_logo_size( + logo.width, logo.height, baseline + ) + else: + target_w = min(max_width, geo.LOGO_WIDTH_MAX) # the guide box width + target_h = int(round(logo.height * target_w / logo.width)) + max_h = baseline - geo.LOGO_ZONE_TOP + if target_h > max_h: + target_h = max_h + target_w = int(round(logo.width * target_h / logo.height)) + # Scale the guide-fit box as a whole; keep it on the canvas (aspect kept). + target_w = int(round(target_w * logo_scale)) + target_h = int(round(target_h * logo_scale)) + if target_w > base.width: + target_h = int(round(target_h * base.width / target_w)) + target_w = base.width + if target_h > base.height: + target_w = int(round(target_w * base.height / target_h)) + target_h = base.height + target_w, target_h = max(1, target_w), max(1, target_h) + logo.resize(target_w, target_h, filter="lanczos") + # Offset moves placement only; keep the logo fully on the canvas. + top = baseline - target_h + logo_y_offset + top = max(0, min(top, base.height - target_h)) + base.composite( + logo, + left=geo.CENTER_X - target_w // 2, + top=top, + ) + + +def _draw_text( + base: Image, + text: str, + center_y: int, + font_path: Optional[str], + font_size: int, + kerning: float = 0.0, +) -> None: + """Draw centred white text with its vertical centre at ``center_y``.""" + with Drawing() as draw: + if font_path: + draw.font = font_path + draw.font_size = font_size + draw.fill_color = Color("white") + draw.text_alignment = "center" + if kerning: + draw.text_kerning = kerning + # Wand anchors text on the baseline; nudge down ~0.35em to centre it. + draw.text(geo.CENTER_X, int(center_y + font_size * 0.35), text) + draw(base) + + +def _encode_jpeg(base: Image) -> bytes: + """Encode a Wand image to JPEG at the CL2K quality with NO chroma subsampling + (4:4:4), matching hand-made CL2K posters (which use ~q99 / full colour). The + default libjpeg 4:2:0 subsampling softens coloured edges, so we force 4:4:4.""" + base.format = "jpeg" + base.compression_quality = geo.OUTPUT_QUALITY + base.options["jpeg:sampling-factor"] = geo.JPEG_SAMPLING_FACTOR + # Embed a standard sRGB profile so colour-managed viewers render the (sRGB) + # pixels correctly instead of stretching an untagged file into their gamut. + base.profiles["icc"] = color.srgb_icc_bytes() + if geo.JPEG_PROGRESSIVE: + # Write a progressive (SOF2) JPEG to match the hand-made reference + # convention. Quality is unaffected — only the scan order changes. + # NOTE: Wand's `interlace_scheme` property sets image->interlace, but the + # JPEG writer reads image_info->interlace; only MagickSetInterlaceScheme + # sets that, so the property alone produces a baseline file. 6 = + # JPEGInterlace in MagickCore's InterlaceType enum. + from wand.api import library + + library.MagickSetInterlaceScheme(base.wand, 6) + return base.make_blob() + + +def _draw_border(base: Image) -> None: + """Composite the template's BORDER LAYER — inner glow, then the white stroke. + + Reproduces the PSD effects-only layer in Photoshop's own order: the black + Inner Glow ramps inward from the canvas edge first, then the 25px inside + Stroke is painted over its innermost band. + + Bounds are given as right/bottom, NOT width/height: ImageMagick's rectangle + primitive is inclusive of both corners, so ``width=bw`` paints bw+1 px. The + top and left bands start at 0 so that extra pixel landed inside the canvas, + while the bottom and right bands started at CANVAS-bw so theirs was clipped + away — which is what made every poster 27px on the top/left and 26px on the + bottom/right, and left a 1px white line behind when a downstream border strip + cropped a symmetric 26. + + The glow is a canvas-sized field, so it is only composited when ``base`` is + the CL2K canvas; the as-is paths can hand this an arbitrary size, and those + still get a correctly-sized stroke on all four edges. + """ + bw = geo.BORDER_WIDTH + w, h = base.width, base.height + if (w, h) == (geo.CANVAS_W, geo.CANVAS_H) and geo.INNER_GLOW_PNG.exists(): + with Image(filename=str(geo.INNER_GLOW_PNG)) as glow: + base.composite(glow, left=0, top=0) + with Drawing() as draw: + draw.fill_color = Color(geo.BORDER_COLOR) + draw.stroke_width = 0 + draw.rectangle(left=0, top=0, right=w - 1, bottom=bw - 1) + draw.rectangle(left=0, top=h - bw, right=w - 1, bottom=h - 1) + draw.rectangle(left=0, top=0, right=bw - 1, bottom=h - 1) + draw.rectangle(left=w - bw, top=0, right=w - 1, bottom=h - 1) + draw(base) + + +# ----- public ---------------------------------------------------------------- +def _balance_lines(words: List[str], n: int, measure) -> List[str]: + """Split ``words`` into ``n`` contiguous lines that minimise the widest line. + + ``measure(text)`` returns the rendered width of a string in the chosen font. + Brute-forces the n-1 cut points (titles are short, so the count is tiny) and + keeps the most balanced break — so a wrapped wordmark reads as even lines, not + one long line + one orphan word. + """ + if n <= 1 or len(words) <= 1: + return [" ".join(words)] + if n >= len(words): + return list(words) + best: Optional[List[str]] = None + best_max: Optional[float] = None + for cuts in itertools.combinations(range(1, len(words)), n - 1): + groups, prev = [], 0 + for c in (*cuts, len(words)): + groups.append(" ".join(words[prev:c])) + prev = c + widest = max(measure(g) for g in groups) + if best_max is None or widest < best_max: + best_max, best = widest, groups + return best or [" ".join(words)] + + +def generate_text_logo( + title: str, + font_path: Optional[str] = None, + font_px: int = 200, + color: str = "white", + stroke_width: int = 0, + stroke_color: str = "black", +) -> bytes: + """Render ``title`` as an ALL-CAPS transparent wordmark (text-logo fallback). + + Used only when no real clear logo is found (TMDB -> fanart -> here). The title + is balance-wrapped onto 1–3 lines so the block roughly matches the CL2K logo + box aspect (~3:1, the 600×200 guide) instead of a single tiny strip — a long + title fills the box on two/three lines like a hand-made wordmark. The result is + fed through the normal logo path (width-normalised to the 600px box), keeping + every poster logo-shaped. ``stroke_width`` (px at the internal render size; 0 = + none) adds a thin outline for legibility over busy art. + """ + text = " ".join((title or "").upper().split()) + if not text: + return b"" + font = font_path or geo.resolve_font(bold=True) + words = text.split() + # The box the wordmark is normalised into: width 600, height (baseline-zone_top). + target_aspect = geo.LOGO_WIDTH_STD / max( + 1, geo.MAIN_LOGO_BOTTOM - geo.LOGO_ZONE_TOP + ) + + # Pick the line count whose block aspect (widest line : total height) is closest + # to the box. Aspect is scale-independent, so measure at a fixed reference size. + with Image(width=8000, height=2000, background=Color("transparent")) as probe: + with Drawing() as md: + if font: + md.font = font + md.font_size = 100 + + def measure(s: str) -> float: + return md.get_font_metrics(probe, s, False).text_width or 1.0 + + best_lines = [text] + best_score = None + for n in range(1, min(3, len(words)) + 1): + lines = _balance_lines(words, n, measure) + block_w = max(measure(s) for s in lines) + block_h = 100 * 1.15 * len(lines) # line height incl. ~15% spacing + score = abs(block_w / block_h - target_aspect) + if best_score is None or score < best_score: + best_score, best_lines = score, lines + + # Render the chosen layout, centred, ALL-CAPS, with the optional stroke. + line_h = int(round(font_px * 1.15)) + n = len(best_lines) + with Image( + width=8000, height=line_h * n + 400, background=Color("transparent") + ) as img: + with Drawing() as draw: + if font: + draw.font = font + draw.font_size = font_px + draw.fill_color = Color(color) + draw.text_alignment = "center" + if stroke_width > 0: + draw.stroke_color = Color(stroke_color) + draw.stroke_width = stroke_width + draw.stroke_antialias = True + cx = 4000 + y = 200 + int(font_px * 0.8) + for s in best_lines: + draw.text(cx, y, s) + y += line_h + draw(img) + # reset_coords drops the 8000px canvas offset trim would otherwise bake + # into the PNG — _trim_logo reads page coords and would crop out of bounds. + img.trim(reset_coords=True) + img.format = "png" + return img.make_blob() + + +def render_framed_art( + *, + backdrop_bytes: bytes, + width: int, + height: int, + focus_x: float = 0.5, + fit_mode: str = "cover", + v_pos: float = 0.0, + zoom: float = 1.0, +) -> bytes: + """Render plain framed artwork at ``width``×``height`` — no gradient/logo/label. + + ``fit_mode`` ``"cover"`` fills the canvas (cropping the overflowing edges); + ``"fit"`` contains the whole image on black (letterbox). ``zoom`` (0.5–3.0) + scales from that baseline — raise it in ``fit`` to punch in from contain toward + a full crop, or in ``cover`` to crop tighter. ``focus_x`` (0..1) pans the window + horizontally and ``v_pos`` (-1..1, 0 = centred) vertically, where the image + overflows the canvas; plain black letterbox where it doesn't. There is no + gradient here to hide an extended band, so ``v_pos`` is source-bounded both + ways. Encoded at CL2K quality. + """ + zoom = max(geo.ZOOM_MIN, min(float(zoom or 1.0), geo.ZOOM_MAX)) + with Image(blob=backdrop_bytes) as img: + base = ( + min(width / img.width, height / img.height) + if fit_mode == "fit" + else max(width / img.width, height / img.height) + ) + scale = base * zoom + nw = max(1, int(round(img.width * scale))) + nh = max(1, int(round(img.height * scale))) + img.resize(nw, nh, filter="lanczos") + # Place the focal point at the canvas centre; clamp so an axis the image + # covers shows no needless black, and centre an axis it doesn't (letterbox). + ox = int(round(width / 2 - focus_x * nw)) + ox = max(min(ox, 0), width - nw) if nw >= width else (width - nw) // 2 + oy = -_v_pos_top(nh, height, v_pos) if nh >= height else (height - nh) // 2 + with Image(width=width, height=height, background=Color("black")) as canvas: + canvas.composite(img, left=ox, top=oy) + return _encode_jpeg(canvas) + + +def render_square_art( + *, + backdrop_bytes: bytes, + size: int = 1000, + focus_x: float = 0.5, + fit_mode: str = "cover", + v_pos: float = 0.0, + zoom: float = 1.0, +) -> bytes: + """Render square (1:1) art from a backdrop/poster — just the framed artwork.""" + return render_framed_art( + backdrop_bytes=backdrop_bytes, + width=size, + height=size, + focus_x=focus_x, + fit_mode=fit_mode, + v_pos=v_pos, + zoom=zoom, + ) + + +def _framed_inset_base( + backdrop_bytes: bytes, + *, + focus_x: float, + fit_mode: str, + crop: Optional[Tuple[float, float, float, float]], + v_pos: float, + zoom: float, +) -> Image: + """Frame the backdrop FULL-BLEED and return a full CANVAS image. + + The template's stroke is Style=Inside on a full-canvas layer, so it paints + OVER the outer 25px of artwork rather than displacing it. Every finished + creator poster in refs/ agrees: their POSTER group is (0, 0, 1000, 1500) — + one is even (0, 0, 1000, 1502) — under a BORDER LAYER of (-2, 0, 1000, 1500). + + This used to inset the art to a 948x1448 inner rect on the theory that the + border would otherwise clip it. It does clip it, and that is the intent: the + art is meant to run under the frame at full scale, not be shrunk 5% to fit + inside it. Insetting also broke the framing UI's contract, since CropFramer + offers a 2:3 crop box while the inner rect is not 2:3. + + render_cl2k and frame_backdrop both go through here, so they stay + pixel-identical (the PSD POSTER-layer parity the exporter relies on). + """ + base = Image( + width=geo.CANVAS_W, height=geo.CANVAS_H, background=Color(geo.BORDER_COLOR) + ) + with Image(blob=backdrop_bytes) as art: + if fit_mode == "fit": + _fit_resize(art, geo.CANVAS_W, geo.CANVAS_H, crop, v_pos, zoom) + else: + _cover_resize(art, geo.CANVAS_W, geo.CANVAS_H, focus_x, v_pos, zoom) + base.composite(art, left=0, top=0) + return base + + +def frame_backdrop( + *, + backdrop_bytes: bytes, + 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, +) -> bytes: + """Frame a backdrop to the 2:3 canvas exactly as :func:`render_cl2k` would + and return PNG bytes. + + The PSD exporter uses this for its POSTER layer so the exported document + matches the rendered poster pixel-for-pixel — the fit/cover/v_pos framings + (edge-extend fills, seam blending) live only in this module and must not be + re-implemented elsewhere. + """ + with _framed_inset_base( + backdrop_bytes, + focus_x=focus_x, + fit_mode=fit_mode, + crop=crop, + v_pos=v_pos, + zoom=zoom, + ) as base: + base.format = "png" + return base.make_blob() + + +def render_cl2k( + *, + backdrop_bytes: bytes, + kind: str, + logo_bytes: Optional[bytes] = None, + title: str = "", + season_text: 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, # paint the logo a flat pure-white silhouette + logo_3d: bool = False, # extruded art -> flat-white lit face + invert: bool = False, # plate logo -> clearlogo (white->transparent, black->white) + font_path: Optional[str] = None, + 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 = "", + place_logo: bool = True, + text_logo_stroke: int = 0, +) -> bytes: + """Render a CL2K poster and return JPEG bytes. + + ``kind`` is one of ``movie`` / ``show`` / ``collection`` / ``season``. A + clear logo is preferred; when none is supplied (or usable) the ``title`` is + drawn as all-caps text in the logo area (MM2K fallback). + + ``band_label`` draws an explicit banner in the bottom label band (e.g. + ``COMPLETE LIMITED SERIES`` or ``SPECIALS``), overriding the automatic + COLLECTION / season label. Long strings use the tighter PSD tracking. + + ``fit_mode`` controls how the backdrop fills the 2:3 canvas: + + - ``"cover"`` (default): scale up and crop to fill; ``focus_x`` (0..1) and + ``v_pos`` (-1..1) choose which part is kept (0.5/0 = centre). Best when the + subject already fills a roughly 2:3 region. + - ``"fit"``: scale the backdrop *down* to the canvas width and top-anchor it + on black, keeping the full width so subjects spread across a wide backdrop + all stay in frame (the artist technique). ``crop`` (``x, y, w, h`` in 0..1) + optionally isolates the subject region first; the black bottom band is the + gradient/logo zone. ``v_pos`` applies here too, but on :func:`_fit_resize`'s + 0..1 top-anchored scale (0 = top), not cover's -1..1. + """ + kind = kind.lower() + baseline = geo.logo_baseline(kind) + label_font = font_path or geo.resolve_font(bold=False) + title_font = font_path or geo.resolve_font(bold=True) + + with _framed_inset_base( + backdrop_bytes, + focus_x=focus_x, + fit_mode=fit_mode, + crop=crop, + v_pos=v_pos, + zoom=zoom, + ) as base: + with Image(filename=str(geo.GRADIENT_PNG)) as grad: + base.composite(grad, left=0, top=0) + + # ``place_logo=False`` renders the logo-less base (backdrop + gradient + + # label + border) the frontend overlays a live logo on top of, so the + # size/position sliders move the logo instantly without re-rendering. The + # logo is baked in only on a real generate (or when a text-wordmark + # fallback is needed, which the overlay can't reproduce client-side). + if place_logo: + # strip(): a whitespace-only title typesets to b"", which Wand cannot + # decode — the crash this guard (and the hoist below) exists to avoid. + has_title = bool((title or "").strip()) + wordmark_used = False + if not logo_bytes and has_title: + # No clear logo found (TMDB -> fanart exhausted): the typeset + # wordmark goes through the same logo path so the poster stays + # logo-shaped. It is already white-on-transparent — inverting it + # would erase it, so invert is real-logo only. + logo_bytes = generate_text_logo( + title, title_font, stroke_width=text_logo_stroke + ) + invert, wordmark_used = False, True + placed = False + if logo_bytes: + try: + _place_logo( + base, + logo_bytes, + baseline, + logo_max_width, + whiten, + logo_scale, + logo_y_offset, + flip_mask_bytes=logo_flip_bytes, + erase_mask_bytes=logo_erase_bytes, + invert=invert, + flat_white=flat_white, + logo_3d=logo_3d, + ) + placed = True + except Exception as exc: + # A clear logo we can't decode/place (corrupt bytes, an SVG with + # no rasterizer) must never fail the whole render. + _log.warning(f"cl2k: could not place the clear logo ({exc})") + # Hoisted OUT of the handler above: typesetting the fallback can itself + # yield unplaceable bytes, which in there would 500 the whole render. + fallback = ( + generate_text_logo(title, title_font, stroke_width=text_logo_stroke) + if (not placed and not wordmark_used and has_title) + else b"" + ) + if fallback: + # Per-logo brush strokes (flip/erase) belong to the dropped logo, so + # they don't carry over to the wordmark. + _place_logo( + base, + fallback, + baseline, + logo_max_width, + whiten, + logo_scale, + logo_y_offset, + invert=False, + flat_white=flat_white, + logo_3d=logo_3d, + ) + + # Every branch derives its tracking from the label itself, exactly as the + # PSD exporter does. Pinning collection/season to a flat LABEL_TRACKING + # would agree today but diverge the moment a season string reaches the + # long-banner length — 800 here, 600 in the .psd, for the same poster. + def _label(txt: str, center_y: int) -> None: + _draw_text( + base, + txt, + center_y, + label_font, + geo.LABEL_FONT_PX, + kerning=geo.tracking_to_kerning(geo.label_tracking(txt)), + ) + + if band_label: + # Explicit banner (e.g. COMPLETE LIMITED SERIES), which the template + # tracks tighter than every other label so it fits the width. + _label(band_label.upper(), geo.SEASON_TEXT_Y) + elif kind == "collection": + _label("COLLECTION", geo.COLLECTION_LABEL_Y) + elif kind == "season" and season_text: + _label(season_text.upper(), geo.SEASON_TEXT_Y) + + _draw_border(base) + + return _encode_jpeg(base) + + +def overlay_label( + image_bytes: bytes, + text: str, + center_y: Optional[int] = None, + font_path: Optional[str] = None, + add_border: bool = False, +) -> bytes: + """Draw a CL2K-style label (white, centred, tracked Arial) onto an existing + image and return JPEG bytes. + + Used to re-text a finished poster — e.g. swap a season year — without running + the full CL2K render (no logo/gradient added). ``center_y`` defaults to + the locked CL2K season-label y; pass another value to match a custom poster's + band. Pairs with AI text-removal: erase the old label, then draw the new one + here so the new text is always crisp and in the CL2K font. + + ``add_border`` paints :func:`apply_border`'s frame onto the SAME decoded image + so the save path encodes once instead of round-tripping JPEG between steps. + """ + if center_y is None: + center_y = geo.SEASON_TEXT_Y + font = font_path or geo.resolve_font(bold=False) + txt = (text or "").upper() + tracking = geo.label_tracking(txt) + with Image(blob=image_bytes) as base: + _draw_text( + base, + txt, + int(center_y), + font, + geo.LABEL_FONT_PX, + kerning=geo.tracking_to_kerning(tracking), + ) + if add_border: + _draw_border(base) + return _encode_jpeg(base) + + +def apply_border(image_bytes: bytes) -> bytes: + """Composite the default 26px white CL2K frame onto a finished poster. + + Used by the save-as-is paths (re-text / finished-poster upload / Drive .psd) so + a borderless poster still satisfies the DAPS white-border rule, mirroring the + frame ``render_cl2k`` bakes in. The frame is painted inset over the canvas + edges, so re-applying it to an already-26px-white-bordered poster is a no-op. + Returns JPEG bytes. + """ + with Image(blob=image_bytes) as base: + _draw_border(base) + return _encode_jpeg(base) + + +def overlay_logo( + image_bytes: bytes, + logo_bytes: bytes, + *, + kind: str = "movie", + logo_max_width: Optional[int] = None, + logo_scale: float = 1.0, + logo_y_offset: int = 0, + whiten: bool = True, + flat_white: bool = False, + logo_3d: bool = False, # extruded art -> flat-white lit face + invert: bool = False, + add_border: bool = False, +) -> bytes: + """Composite a clear logo onto a finished poster at the locked CL2K baseline. + + Trims, whitens and width-normalises the logo to the CL2K guides exactly like + a fresh render (via the shared :func:`_place_logo`), then bottom-aligns it on + the kind's baseline. Used to add a TMDB / fanart / custom logo onto an + already-finished uploaded poster (the save-as-is flow), where no full render + happens. The poster should already be the locked 1000×1500 canvas. Returns + JPEG bytes. + + ``add_border`` paints :func:`apply_border`'s frame onto the SAME decoded image + so the save path encodes once instead of round-tripping JPEG between steps. + """ + baseline = geo.logo_baseline((kind or "movie").lower()) + with Image(blob=image_bytes) as base: + _place_logo( + base, + logo_bytes, + baseline, + logo_max_width, + whiten, + logo_scale, + logo_y_offset, + invert=invert, + flat_white=flat_white, + logo_3d=logo_3d, + ) + if add_border: + _draw_border(base) + return _encode_jpeg(base) + + +# ----- CLI harness (P1 visual check) ----------------------------------------- +def main() -> None: + import argparse + + ap = argparse.ArgumentParser(description="Render a CL2K poster (visual check).") + ap.add_argument("--backdrop", required=True, help="source backdrop image") + ap.add_argument("--logo", help="clear logo (PNG with alpha)") + ap.add_argument( + "--kind", default="movie", choices=["movie", "show", "collection", "season"] + ) + ap.add_argument("--title", default="", help="title for the text fallback") + ap.add_argument("--season-text", default="", help="e.g. 'Season 1'") + ap.add_argument( + "--width", + type=int, + default=geo.LOGO_WIDTH_RECOMMENDED, + help="logo width (600 std / 700 recommended / 800 max)", + ) + ap.add_argument( + "--no-whiten", action="store_true", help="keep the logo's original colours" + ) + ap.add_argument("--font", help="font file for text") + ap.add_argument("--out", required=True, help="output .jpg path") + args = ap.parse_args() + + with open(args.backdrop, "rb") as fh: + backdrop = fh.read() + logo = None + if args.logo: + with open(args.logo, "rb") as fh: + logo = fh.read() + + blob = render_cl2k( + backdrop_bytes=backdrop, + kind=args.kind, + logo_bytes=logo, + title=args.title, + season_text=args.season_text, + logo_max_width=args.width, + whiten=not args.no_whiten, + font_path=args.font, + ) + with open(args.out, "wb") as fh: + fh.write(blob) + print(f"wrote {args.out} ({len(blob)} bytes)") + + +if __name__ == "__main__": + main() diff --git a/backend/util/cl2k/text_detect.py b/backend/util/cl2k/text_detect.py new file mode 100644 index 00000000..0fbaf2c5 --- /dev/null +++ b/backend/util/cl2k/text_detect.py @@ -0,0 +1,130 @@ +"""PP-OCRv4 DBNet text detector (ONNX) — a colour/polarity-agnostic text +localiser for the CL2K "tighten to letters" anchor. + +The colour-key alone has to GUESS which colour is the title (it assumes the +most-saturated thing in the brush is the text), which inverts on a light title +over a saturated plate. A DBNet detector localises text by appearance, not +colour, so it pins the title regardless of polarity — the tighten anchors then +read the true ink colours from the detected strokes. + +Fail-soft by design: every entry point returns ``None`` if onnxruntime or the +vendored model is unavailable, or on any inference error, so ``tighten_text_mask`` +degrades to the colour-key rather than breaking. The model +(``models/ppocr_v4_det.onnx``, ~4.7 MB) is a ':full'-image vendored asset; +``onnxruntime`` is in requirements-cl2k.txt. +""" + +from __future__ import annotations + +import logging +import os +import threading +from typing import Optional + +import numpy as np +from PIL import Image + +from backend.util.cl2k.limits import open_bounded + +_log = logging.getLogger(__name__) + +_MODEL_PATH = os.path.join(os.path.dirname(__file__), "models", "ppocr_v4_det.onnx") +# DBNet preprocessing (PP-OCR): ImageNet mean/std, /255, NCHW. +_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) +_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32) +# Long-side pyramid, max-merged. DBNet misses display type that is LARGE relative +# to its input (the glyphs outgrow the receptive field), and a single 960 pass +# skipped whole title lines on real posters; the smaller passes bring huge +# lettering back into range. Only-downscale, so tiny images dedupe to one pass. +_SCALES = (960, 640, 448, 320) +# Working long side the map is built (and returned) at. Every pass downsamples to +# 960 or less anyway, so 2x that costs no accuracy and bounds the float buffers. +_MAX_WORK_SIDE = 2048 + + +_SESSION = None +_SESSION_LOCK = threading.Lock() + + +def _session(): + """Cached ORT session, or None when onnxruntime/model is unavailable. + A failed init is transient — never cached, so it retries next call.""" + global _SESSION + if _SESSION is not None: + return _SESSION + if not os.path.exists(_MODEL_PATH): + return None + try: + import onnxruntime as ort # optional dep — absent => colour-key fallback + except Exception: + return None + # Build inside the lock (double-checked) so a request burst spawns ONE session, + # not several racing InferenceSessions (OOM / provider-init failure). + with _SESSION_LOCK: + if _SESSION is not None: # another caller built it while we waited + return _SESSION + try: + opts = ort.SessionOptions() + opts.intra_op_num_threads = 2 # gentle on the shared worker + _SESSION = ort.InferenceSession( + _MODEL_PATH, sess_options=opts, providers=["CPUExecutionProvider"] + ) + except Exception as exc: + _log.warning( + "cl2k text detector unavailable this call (will retry): %s", exc + ) + return None + return _SESSION + + +def available() -> bool: + """Whether the detector can run (onnxruntime importable + model present).""" + return _session() is not None + + +def detect_text_probmap(image_bytes: bytes) -> Optional[np.ndarray]: + """DBNet text-probability heatmap for ``image_bytes``. + + Returns an ``HxW`` float array in ``[0, 1]`` (high on text, low elsewhere), + or ``None`` when the detector is unavailable or errors. High values are + text-line cores (DBNet predicts a shrunk kernel), so a threshold gives a + polarity-agnostic text-line mask, not per-glyph. Runs the ``_SCALES`` pyramid + and max-merges, so both huge display titles and small credit lines register + in one map. + + The map is at the ``_MAX_WORK_SIDE`` working size, NOT the source's: a big + source would otherwise cost a full-resolution float buffer per pyramid level. + Callers scale it to their own array (see ``logo_extract._sized_probmap``). + """ + sess = _session() + if sess is None: + return None + try: + img = open_bounded(image_bytes, "RGB") + if max(img.size) > _MAX_WORK_SIDE: + img.thumbnail((_MAX_WORK_SIDE, _MAX_WORK_SIDE), Image.LANCZOS) + w, h = img.size + name = sess.get_inputs()[0].name + merged = None + seen = set() + for limit in _SCALES: + scale = min(limit / max(w, h), 1.0) # only downscale + nw = max(32, int(round(w * scale)) // 32 * 32) # DBNet needs /32 dims + nh = max(32, int(round(h * scale)) // 32 * 32) + if (nw, nh) in seen: # small image — several limits collapse to one + continue + seen.add((nw, nh)) + x = ( + np.asarray(img.resize((nw, nh), Image.BILINEAR), dtype=np.float32) + / 255.0 + ) + x = ((x - _MEAN) / _STD).transpose(2, 0, 1)[None] + prob = sess.run(None, {name: x})[0][0, 0] # HxW in [0, 1] + up = Image.fromarray( + (np.clip(prob, 0.0, 1.0) * 255).astype(np.uint8) + ).resize((w, h), Image.BILINEAR) + arr = np.asarray(up, dtype=np.float32) / 255.0 + merged = arr if merged is None else np.maximum(merged, arr) + return merged + except Exception: + return None diff --git a/backend/util/cl2k/text_removal.py b/backend/util/cl2k/text_removal.py new file mode 100644 index 00000000..16a28141 --- /dev/null +++ b/backend/util/cl2k/text_removal.py @@ -0,0 +1,379 @@ +"""AI text removal for the CL2K maker — a provider-agnostic seam. + +Default is ``none`` (the textless-art strategy needs no AI). When a provider is +configured AND a user-brushed mask is supplied, the masked regions are erased by +the chosen backend: + +- ``lama_sidecar`` — POST image+mask to a self-hosted lama-sidecar/IOPaint + server. FREE, private, the recommended default. ``ai_endpoint`` = the inpaint + URL; ``client_key`` is sent as X-API-Key when set (for a sidecar running with + LAMA_API_KEY) — its own field, so it never collides with the openai token. + (LaMa is what we benchmarked; excellent over texture, weaker over faces.) +- ``openai`` — OpenAI ``images.edit`` (gpt-image-1). PAID; better over faces, can + hallucinate. ``api_key`` (+ optional ``ai_model``). + +Mask convention here is **white (255) = remove**, black = keep (what the brush UI +and LaMa/IOPaint use). Each backend takes the original image + mask and returns +cleaned image bytes. Pass-through (input returned unchanged) when the provider is +none/disabled or no mask is supplied. Exceptions propagate to the caller. +""" + +from __future__ import annotations + +import base64 +import io +import time +from typing import Optional + +_TIMEOUT_DEFAULT = 120 + + +def is_enabled(config) -> bool: + """True only when a real AI provider is configured.""" + return bool(config and getattr(config, "ai_provider", "none") not in ("", "none")) + + +def unavailable_reason(config) -> Optional[str]: + """Why an explicit AI erase can't run, or None if it can. + + The /retext endpoint calls this so a user-triggered "Send to AI" fails + loudly when the provider is misconfigured, instead of silently returning the + image unchanged. (The generate path stays lenient and just skips — it must + not fail a whole render over a missing key.) + """ + provider = getattr(config, "ai_provider", "none") if config else "none" + if provider in ("", "none"): + return "AI provider is 'none' — set one in Module Settings → CL2K Maker." + if provider == "openai" and not getattr(config, "api_key", ""): + return ( + "No API key set for the 'openai' provider — " + "add one in Module Settings → CL2K Maker." + ) + if provider == "lama_sidecar" and not getattr(config, "ai_endpoint", ""): + return ( + "No AI Endpoint set for the LaMa sidecar — " + "add your container URL in Module Settings → CL2K Maker." + ) + if provider not in ("lama_sidecar", "openai"): + # e.g. a leftover 'huggingface' config from before that provider was + # dropped (its payload never matched a real HF API). + return ( + f"Unknown AI provider '{provider}' — " + "choose one in Module Settings → CL2K Maker." + ) + return None + + +def remove_text( + image_bytes: bytes, + *, + config=None, + mask_bytes: Optional[bytes] = None, + prompt: Optional[str] = None, + logger=None, +) -> bytes: + """Erase the masked regions via the configured provider; else pass-through. + + ``prompt`` overrides the module-settings ``ai_prompt`` for this one call + (used by the poster-editor so a per-edit prompt can be supplied while still + defaulting to the configured prompt). ``logger`` (optional) receives + start/elapsed/status lines so a slow or failing AI call is visible in the + logs instead of timing out silently. + """ + if not is_enabled(config): + return image_bytes + provider = getattr(config, "ai_provider", "none") + # The brush canvas is sized to the *displayed* image (same aspect ratio, + # fewer pixels), so the mask arrives at display resolution. LaMa / HF expect + # mask dimensions == image dimensions and don't reliably resize server-side + # — normalize once here for every provider (OpenAI re-resizes internally; + # _composite_masked likewise — both harmless after this). + if mask_bytes: + mask_bytes = _mask_to_image_dims(image_bytes, mask_bytes) + # LaMa / HF are blind — they only fill what is masked, so without a mask + # there is nothing to do. OpenAI is a vision model and can remove text from + # the prompt alone, so a mask is optional there. + if provider == "lama_sidecar": + if not mask_bytes: + return image_bytes + result = _lama_sidecar(image_bytes, mask_bytes, config) + elif provider == "openai": + result = _openai(image_bytes, mask_bytes, config, prompt=prompt, logger=logger) + else: + return image_bytes + + # Generative providers (OpenAI, and Firefly via handoff) re-render the WHOLE + # canvas, which alters faces. When a mask is supplied, keep their fill ONLY + # inside the masked region and restore the original pixels everywhere else, + # so the artwork outside the text is preserved exactly. NEVER do this for + # the LaMa sidecar: it dilates the mask server-side to erase the logo's + # anti-aliased fringe/glow, so re-compositing with our tight brush mask + # would paint that fringe right back (and it already guarantees only masked + # pixels change). + if mask_bytes and result is not image_bytes and provider != "lama_sidecar": + result = _composite_masked(image_bytes, result, mask_bytes) + return result + + +def _mask_to_image_dims(image_bytes: bytes, mask_bytes: bytes) -> bytes: + """Resize the brushed mask to the image's pixel dimensions (PNG bytes). + + Pass-through when the sizes already match or either decode fails (the + provider call then behaves exactly as before this normalization existed). + """ + from PIL import Image + + from backend.util.cl2k.limits import ImageTooLargeError, open_bounded + + try: + with Image.open(io.BytesIO(image_bytes)) as im: + size = im.size # header only — no pixel decode + mask = open_bounded(mask_bytes, "L") + if mask.size == size: + return mask_bytes + buf = io.BytesIO() + mask.resize(size).save(buf, "PNG") + return buf.getvalue() + except ImageTooLargeError: + raise # never pass an unbounded mask through to the provider decode + except Exception: + return mask_bytes + + +def _composite_masked( + original_bytes: bytes, result_bytes: bytes, mask_bytes: bytes +) -> bytes: + """Keep ``result`` only where the mask is white; original pixels elsewhere.""" + from PIL import Image, ImageFilter + + from backend.util.cl2k.limits import open_bounded + + orig = open_bounded(original_bytes, "RGB") + res = open_bounded(result_bytes, "RGB").resize(orig.size, Image.Resampling.LANCZOS) + mask = ( + open_bounded(mask_bytes, "L") + .resize(orig.size) + .filter(ImageFilter.GaussianBlur(4)) # feather for a seamless blend + ) + out = Image.composite(res, orig, mask) + buf = io.BytesIO() + out.save(buf, "PNG") + return buf.getvalue() + + +def _timeout(config) -> int: + return int(getattr(config, "ai_timeout", _TIMEOUT_DEFAULT) or _TIMEOUT_DEFAULT) + + +def _lama_url(endpoint: str) -> str: + """Resolve the sidecar inpaint URL (see :func:`_lama_route`).""" + return _lama_route(endpoint, "/api/v1/inpaint") + + +def _lama_route(endpoint: str, path: str) -> str: + """Resolve a sidecar route URL from whatever the user typed. + + Users set ``ai_endpoint`` to their container's address — ``host:8080`` or + ``http://host:8080`` — and we fill in the sidecar ``path`` so they don't have + to know it. A scheme is added when missing; an endpoint that already carries + a path (custom sidecar behind a proxy) is left as-is. Every route (inpaint, + upscale, detect) resolves the same way, so a custom path is honoured + consistently instead of only for inpaint. + """ + from urllib.parse import urlparse, urlunparse + + raw = (endpoint or "").strip().rstrip("/") + if not raw: + return raw + parsed = urlparse(raw if "://" in raw else f"http://{raw}") + if parsed.path in ("", "/"): + parsed = parsed._replace(path=path) + return urlunparse(parsed) + + +def _lama_headers(config) -> dict: + """X-API-Key for a sidecar running with LAMA_API_KEY; empty when keyless + (the sidecar ignores the header unless it has a key configured).""" + # Pre-split configs kept this in api_key; Cl2kMakerConfig migrates it across + # on load, so there is nothing to guess at here. + key = getattr(config, "client_key", "") or "" + return {"X-API-Key": key} if key else {} + + +def _lama_sidecar(image_bytes: bytes, mask_bytes: bytes, config) -> bytes: + """IOPaint/LaMa server: {image, mask} (base64, white=remove) -> cleaned image.""" + import requests + + url = _lama_url(getattr(config, "ai_endpoint", "")) + if not url: + return image_bytes + payload = { + "image": base64.b64encode(image_bytes).decode(), + "mask": base64.b64encode(mask_bytes).decode(), + } + # Per-request dilation override (>=0); -1 leaves the sidecar's own default. + # Older sidecars ignore unknown JSON fields, so this is backwards-compatible. + # 0 is a real value (dilation OFF) — only None falls back, never `or`. + raw_dilate = getattr(config, "ai_mask_dilate", -1) + dilate = -1 if raw_dilate is None else int(raw_dilate) + if dilate >= 0: + payload["dilate"] = dilate + resp = requests.post( + url, + json=payload, + headers=_lama_headers(config), + timeout=_timeout(config), + # The key must never follow a redirect off the configured sidecar. + allow_redirects=False, + ) + resp.raise_for_status() + return resp.content + + +def upscale_image( + image_bytes: bytes, config, scale: int = 0, logger=None +) -> Optional[bytes]: + """2x/4x super-resolution via the sidecar's /api/v1/upscale (logos only). + + Best-effort: returns the upscaled PNG bytes, or None on ANY failure — + provider not lama_sidecar, endpoint unset, an older sidecar without the + endpoint (404), timeout, or a server error — so callers can fall back to + exactly the behaviour they had before this endpoint existed. + ``scale`` 0 picks automatically: 4x for very small art, else 2x. + """ + import requests + + if getattr(config, "ai_provider", "none") != "lama_sidecar": + return None + endpoint = getattr(config, "ai_endpoint", "") + if not endpoint: + return None + url = _lama_route(endpoint, "/api/v1/upscale") + if scale not in (2, 4): + try: + from PIL import Image + + with Image.open(io.BytesIO(image_bytes)) as im: + scale = 4 if im.width < 200 else 2 + except Exception: + scale = 2 + try: + resp = requests.post( + url, + json={"image": base64.b64encode(image_bytes).decode(), "scale": scale}, + headers=_lama_headers(config), + timeout=_timeout(config), + allow_redirects=False, + ) + if resp.status_code != 200: + if logger: + logger.info( + f"CL2K AI: logo upscale unavailable ({resp.status_code}) — " + "using the original" + ) + return None + return resp.content + except Exception as exc: + if logger: + logger.info(f"CL2K AI: logo upscale failed ({exc}) — using the original") + return None + + +def _openai( + image_bytes: bytes, + mask_bytes: Optional[bytes], + config, + prompt: Optional[str] = None, + logger=None, +) -> bytes: + """OpenAI images.edit (gpt-image-1). + + Mask-optional: with no mask, the prompt alone drives removal (the model finds + the text — but it regenerates the whole image, so fidelity isn't pixel-exact). + With a mask, only that region is edited; OpenAI marks the edit area with + TRANSPARENCY, so we invert our white=remove mask to alpha-0-where-remove. + + ``prompt`` (per-call) overrides ``config.ai_prompt`` when provided. ``logger`` + (optional) logs the model, image size, elapsed time and HTTP status so a slow + or failing edit is diagnosable (gpt-image-1 edits routinely take 30–120s). + """ + import requests + from PIL import Image + + key = getattr(config, "api_key", "") + if not key: + if logger: + logger.warning("CL2K AI (openai): no api_key set — skipping text removal") + return image_bytes + model = getattr(config, "ai_model", "") or "gpt-image-1" + prompt = ( + (prompt or "").strip() + or getattr(config, "ai_prompt", "") + or ("Remove all text from this image and reconstruct the background.") + ) + + from backend.util.cl2k.limits import open_bounded + + src = open_bounded(image_bytes, "RGB") + img_buf = io.BytesIO() + src.save(img_buf, "PNG") # gpt-image-1 edits expect PNG input + files = {"image": ("image.png", img_buf.getvalue(), "image/png")} + data = {"model": model, "prompt": prompt, "size": "auto"} + + if mask_bytes: + m = open_bounded(mask_bytes, "L").resize(src.size) + rgba = Image.new("RGBA", src.size, (0, 0, 0, 255)) + rgba.putalpha(Image.eval(m, lambda px: 255 - px)) # white(remove) -> alpha 0 + mask_buf = io.BytesIO() + rgba.save(mask_buf, "PNG") + files["mask"] = ("mask.png", mask_buf.getvalue(), "image/png") + + timeout = _timeout(config) + if not mask_bytes and logger: + logger.warning( + "CL2K AI (openai): no mask supplied — the WHOLE poster is regenerated " + "(faces altered, fidelity not pixel-exact, resolution capped at the " + "model's native size). Brush a mask to preserve the artwork outside " + "the text." + ) + if logger: + logger.info( + f"CL2K AI (openai): images.edit start — model={model}, " + f"image={src.width}x{src.height}, mask={'yes' if mask_bytes else 'no'}, " + f"timeout={timeout}s" + ) + started = time.time() + try: + resp = requests.post( + "https://api.openai.com/v1/images/edits", + headers={"Authorization": f"Bearer {key}"}, + files=files, + data=data, + timeout=timeout, + ) + except requests.RequestException as exc: + elapsed = time.time() - started + if logger: + logger.error( + f"CL2K AI (openai): images.edit failed after {elapsed:.1f}s " + f"(network/timeout): {exc}" + ) + raise + elapsed = time.time() - started + if logger: + logger.info( + f"CL2K AI (openai): images.edit responded {resp.status_code} " + f"in {elapsed:.1f}s" + ) + if not resp.ok: + # Surface the API's own error message (e.g. quota/content-policy) so it + # lands in the logs rather than a bare status code. + body = (resp.text or "")[:300] + if logger: + logger.error( + f"CL2K AI (openai): images.edit returned {resp.status_code}: {body}" + ) + resp.raise_for_status() + return base64.b64decode(resp.json()["data"][0]["b64_json"]) + + diff --git a/backend/util/cl2k/tmdb_art.py b/backend/util/cl2k/tmdb_art.py new file mode 100644 index 00000000..2d9c1526 --- /dev/null +++ b/backend/util/cl2k/tmdb_art.py @@ -0,0 +1,157 @@ +# backend/util/cl2k/tmdb_art.py +"""TMDB art-picker helpers for the CL2K maker. + +Free functions over a TMDBClient instance (first argument), kept here so +backend/util/tmdb.py stays identical to main — the maker is the only +consumer. They deliberately reuse the client's private plumbing +(``_memo``/``_memo_lock`` cache, ``_request_with_retry``, +``_normalize_langs``, ``_fetch_images``); if those internals change on +main, this module is the one place to follow suit. +""" + +from typing import Any, Dict, List, Union + + +def _image_mt(media_type: str) -> str: + """Map a media kind to the TMDB path segment.""" + mt = (media_type or "").lower() + if mt == "movie": + return "movie" + if mt == "collection": + return "collection" + return "tv" + + +def list_images( + tmdb, tmdb_id: int, media_type: str, languages: Union[str, List[str]] = "en" +) -> Any: + """Return the full TMDB images payload (every logo + backdrop) for a + media item — for the CL2K art picker. + + Unlike TMDBClient.get_images (which auto-picks one of each), this exposes + all candidates so callers can choose by resolution. Returns None on a + transient failure, or ``{"logos": [], "backdrops": []}`` for a bad id. + """ + if not tmdb.enabled or not tmdb_id: + return None + mt = _image_mt(media_type) + key = ("list_images", str(tmdb_id), mt) + with tmdb._memo_lock: + if key in tmdb._memo: + return tmdb._memo[key] + result = tmdb._fetch_images(tmdb_id, mt, languages) + # Don't memoize a transient None (a 404 returns an empty dict) — one blip would + # strip this title's art for the whole process. Cache authoritative answers only. + if result is not None: + with tmdb._memo_lock: + tmdb._memo[key] = result + return result + + +def list_season_images( + tmdb, + tmdb_id: int, + season_number: int, + languages: Union[str, List[str]] = "en", +) -> Any: + """Return the TMDB season-level posters for a show season — for the CL2K + art picker (a season has its own portrait 2:3 key-art, distinct from the + show backdrops). GET /3/tv/{id}/season/{n}/images. + + Returns ``{"posters": [...]}`` (textless art included via + ``include_image_language``), or None on a transient failure / disabled. + """ + if not tmdb.enabled or not tmdb_id or season_number is None: + return None + key = ("list_season_images", str(tmdb_id), str(season_number)) + with tmdb._memo_lock: + if key in tmdb._memo: + return tmdb._memo[key] + url = f"{tmdb.BASE}/tv/{tmdb_id}/season/{season_number}/images" + langs = tmdb._normalize_langs(languages) + params = { + "api_key": tmdb.cfg.apikey, + "include_image_language": ",".join([*langs, "null"]), + } + resp = tmdb._request_with_retry( + url, params, what=f"tv/{tmdb_id}/season/{season_number}/images" + ) + if resp is None: + return None + if resp.status_code == 404: + result: Any = {"posters": []} + elif not resp.ok: + tmdb.logger.warning( + f"TMDB returned {resp.status_code} for tv/{tmdb_id}/season/{season_number}/images" + ) + return None + else: + try: + result = resp.json() + except ValueError: + return None + with tmdb._memo_lock: + tmdb._memo[key] = result + return result + + +def search_titles(tmdb, query: str, media_type: str) -> List[Dict[str, Any]]: + """Search TMDB by title for the maker's entry point. + + ``media_type`` is movie / show / collection. Returns the raw results + list (id, title/name, year, overview, ...), or [] on failure / disabled. + """ + if not tmdb.enabled or not query: + return [] + mt = _image_mt(media_type) + url = f"{tmdb.BASE}/search/{mt}" + params = {"api_key": tmdb.cfg.apikey, "query": query} + resp = tmdb._request_with_retry(url, params, what=f"search/{mt}") + if resp is None or not resp.ok: + return [] + try: + return resp.json().get("results", []) + except ValueError: + return [] + + +def external_ids(tmdb, tmdb_id: int, media_type: str) -> Dict[str, Any]: + """Return ``{tvdb_id, imdb_id}`` from TMDB for a movie/show. + + Used by the CL2K maker to auto-populate ids when a title is picked from + search, so generated filenames match TVDB/IMDB-keyed libraries without + manual entry. Collections have no external_ids endpoint (returns empty). + Missing ids come back as None; ``{}``-ish on any failure / disabled. + """ + if not tmdb.enabled or not tmdb_id: + return {"tvdb_id": None, "imdb_id": None} + mt = _image_mt(media_type) + if mt == "collection": + return {"tvdb_id": None, "imdb_id": None} + key = ("external_ids", str(tmdb_id), mt) + with tmdb._memo_lock: + if key in tmdb._memo: + return tmdb._memo[key] + url = f"{tmdb.BASE}/{mt}/{tmdb_id}/external_ids" + params = {"api_key": tmdb.cfg.apikey} + resp = tmdb._request_with_retry(url, params, what=f"{mt}/{tmdb_id}/external_ids") + out: Dict[str, Any] = {"tvdb_id": None, "imdb_id": None} + # Only a 404 is authoritative "no external ids" (safe to cache); a transient + # None/5xx/429/bad-json stays uncached, or one blip blanks the ids permanently. + if resp is None or (not resp.ok and resp.status_code != 404): + return out + if resp.status_code == 404: + with tmdb._memo_lock: + tmdb._memo[key] = out + return out + try: + data = resp.json() + except ValueError: + return out + tvdb = data.get("tvdb_id") + imdb = data.get("imdb_id") + out["tvdb_id"] = tvdb if isinstance(tvdb, int) else None + out["imdb_id"] = imdb or None + with tmdb._memo_lock: + tmdb._memo[key] = out + return out diff --git a/backend/util/database/cl2k_generated.py b/backend/util/database/cl2k_generated.py new file mode 100644 index 00000000..a1677000 --- /dev/null +++ b/backend/util/database/cl2k_generated.py @@ -0,0 +1,138 @@ +import datetime +from typing import Any, Dict, List, Optional + +from .db_base import DatabaseBase + + +class Cl2kGenerated(DatabaseBase): + """Provenance of CL2K posters generated by the cl2k_maker module.""" + + def record(self, rec: Dict[str, Any]) -> None: + """Upsert a generated-poster record (keyed on the output file path).""" + # Never persist a Plex admin token at rest; download() re-mints it on read. + from backend.util.cl2k.image_fetch import strip_plex_token + + now = datetime.datetime.now(datetime.timezone.utc).isoformat() + self.execute_query( + """ + INSERT INTO cl2k_generated + (kind, tmdb_id, tvdb_id, imdb_id, season_number, title, year, + file, backdrop_path, logo_source, uploaded, generated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(file) DO UPDATE SET + kind=excluded.kind, tmdb_id=excluded.tmdb_id, + tvdb_id=excluded.tvdb_id, imdb_id=excluded.imdb_id, + season_number=excluded.season_number, title=excluded.title, + year=excluded.year, backdrop_path=excluded.backdrop_path, + logo_source=excluded.logo_source, uploaded=excluded.uploaded, + generated_at=excluded.generated_at + """, + ( + rec.get("kind"), + rec.get("tmdb_id"), + rec.get("tvdb_id"), + rec.get("imdb_id"), + rec.get("season_number"), + rec.get("title"), + rec.get("year"), + rec.get("file"), + strip_plex_token(rec.get("backdrop_path")), + rec.get("logo_source"), + int(rec.get("uploaded", 0)), + now, + ), + ) + + def exists_for( + self, + kind: str, + tmdb_id: Optional[int], + season_number: Optional[int] = None, + ) -> bool: + """True if a poster for this tmdb_id (+season) was already generated.""" + if not tmdb_id: + return False + if season_number is not None: + row = self.execute_query( + "SELECT 1 FROM cl2k_generated " + "WHERE kind=? AND tmdb_id=? AND season_number=? LIMIT 1", + (kind, tmdb_id, season_number), + fetch_one=True, + ) + else: + row = self.execute_query( + "SELECT 1 FROM cl2k_generated WHERE kind=? AND tmdb_id=? LIMIT 1", + (kind, tmdb_id), + fetch_one=True, + ) + return row is not None + + def get_backdrop_for(self, tmdb_id: Optional[int]) -> Optional[str]: + """Most-recent backdrop_path generated for a tmdb_id (any kind). + + Lets a new season reuse the show's existing background (DAPS: reuse the + same backdrop across seasons, only change the season number). + """ + if not tmdb_id: + return None + row = self.execute_query( + "SELECT backdrop_path FROM cl2k_generated " + "WHERE tmdb_id=? AND backdrop_path IS NOT NULL " + "ORDER BY generated_at DESC LIMIT 1", + (tmdb_id,), + fetch_one=True, + ) + return row["backdrop_path"] if row else None + + def list_recent(self, limit: int = 200) -> List[Dict[str, Any]]: + return ( + self.execute_query( + "SELECT * FROM cl2k_generated ORDER BY generated_at DESC LIMIT ?", + (limit,), + fetch_all=True, + ) + or [] + ) + + def mark_uploaded(self, file: str) -> None: + self.execute_query( + "UPDATE cl2k_generated SET uploaded=1 WHERE file=?", (file,) + ) + + +def cl2k_generated_table(): + """TableDefinition for the cl2k_generated table. + + Registered through backend/extensions/cl2k/manifest.py (tables), since + the CL2K maker is part of the :full image and core schema.py must not + reference it. Imported lazily there — TableDefinition comes from + schema.py, which calls extension tables mid-init. + """ + from .schema import ColumnDefinition, TableDefinition + + # CL2K maker — provenance of generated posters (review / re-run / revert) + return TableDefinition( + name="cl2k_generated", + columns=[ + ColumnDefinition("id", "INTEGER", primary_key=True, nullable=False), + ColumnDefinition("kind", "TEXT"), # movie/show/collection/season + ColumnDefinition("tmdb_id", "INTEGER"), + ColumnDefinition("tvdb_id", "INTEGER"), + ColumnDefinition("imdb_id", "TEXT"), + ColumnDefinition("season_number", "INTEGER"), + ColumnDefinition("title", "TEXT"), + ColumnDefinition("year", "INTEGER"), + ColumnDefinition("file", "TEXT", nullable=False, unique=True), + ColumnDefinition("backdrop_path", "TEXT"), # TMDB path used + ColumnDefinition("logo_source", "TEXT"), # tmdb | fanart | text + ColumnDefinition("uploaded", "INTEGER", default=0), + ColumnDefinition("generated_at", "TEXT"), + ], + ) + + +def cl2k_generated_for(db) -> "Cl2kGenerated": + """The Cl2kGenerated interface on a ChubDB — extensions can't add + properties to ChubDB, so call sites use this instead of a + ``db.cl2k_generated`` property.""" + return db.extension_interface("cl2k_generated", Cl2kGenerated) diff --git a/backend/util/database/poster_heal_review.py b/backend/util/database/poster_heal_review.py new file mode 100644 index 00000000..482e14c9 --- /dev/null +++ b/backend/util/database/poster_heal_review.py @@ -0,0 +1,185 @@ +# backend/util/database/poster_heal_review.py +"""poster_heal_review table — proposed id/title fixes awaiting manual review. + +One row per poster file (the local CL2K source copy). The scheduled +poster_self_heal run upserts proposals here; the review UI lists them and the +apply endpoint rewrites the file on disk + the user's Drive, then marks the row +applied. Registered as an extension table via +backend/extensions/poster_self_heal/manifest.py ``tables()`` and accessed through +``db.extension_interface`` (extensions can't add ChubDB properties). +""" + +import datetime +from typing import Any, Dict, List, Optional + +from .db_base import DatabaseBase + +# Statuses a row moves through. "proposed"/"pending"/"failed" are open (shown for +# review); "applied"/"dismissed" are terminal and sticky across re-scans. +# +# "failed" is deliberately OPEN and deliberately NOT sticky: an auto-apply that +# raised must be visible (the run tells the user it was left for review), but a +# later run that succeeds or re-proposes cleanly has to be able to overwrite it — +# a sticky "failed" would latch a transient rclone/token error forever. +OPEN_STATUSES = ("proposed", "pending", "failed") + + +def _now() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat() + + +class PosterHealReview(DatabaseBase): + """Read/write interface for the poster_heal_review table.""" + + def upsert(self, rec: Dict[str, Any]) -> None: + """Insert or refresh a proposal, keyed on poster_file. A row already + resolved (applied/dismissed) keeps that status so it isn't re-surfaced.""" + self.execute_query( + """ + INSERT INTO poster_heal_review + (poster_file, drive_folder_id, asset_type, drift_type, + current_filename, proposed_filename, tmdb_id_old, tmdb_id_new, + title_old, title_new, confidence, reason, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(poster_file) DO UPDATE SET + drive_folder_id=excluded.drive_folder_id, + asset_type=excluded.asset_type, + drift_type=excluded.drift_type, + current_filename=excluded.current_filename, + proposed_filename=excluded.proposed_filename, + tmdb_id_old=excluded.tmdb_id_old, + tmdb_id_new=excluded.tmdb_id_new, + title_old=excluded.title_old, + title_new=excluded.title_new, + confidence=excluded.confidence, + reason=excluded.reason, + status=CASE + WHEN poster_heal_review.status IN ('applied', 'dismissed') + THEN poster_heal_review.status + ELSE excluded.status + END + """, + ( + rec.get("poster_file"), + rec.get("drive_folder_id"), + rec.get("asset_type"), + rec.get("drift_type"), + rec.get("current_filename"), + rec.get("proposed_filename"), + rec.get("tmdb_id_old"), + rec.get("tmdb_id_new"), + rec.get("title_old"), + rec.get("title_new"), + rec.get("confidence"), + rec.get("reason"), + rec.get("status", "proposed"), + _now(), + ), + ) + + def list_open(self, limit: int = 500) -> List[Dict[str, Any]]: + # Placeholders built from OPEN_STATUSES so this and open_count can never + # disagree with the constant (or with each other) as statuses are added. + marks = ", ".join("?" for _ in OPEN_STATUSES) + return ( + self.execute_query( + f"SELECT * FROM poster_heal_review WHERE status IN ({marks}) " + "ORDER BY confidence DESC, created_at DESC LIMIT ?", + (*OPEN_STATUSES, limit), + fetch_all=True, + ) + or [] + ) + + def open_count(self) -> int: + marks = ", ".join("?" for _ in OPEN_STATUSES) + row = self.execute_query( + f"SELECT COUNT(*) AS n FROM poster_heal_review WHERE status IN ({marks})", + tuple(OPEN_STATUSES), + fetch_one=True, + ) + return int(row["n"]) if row else 0 + + def get(self, review_id: int) -> Optional[Dict[str, Any]]: + return self.execute_query( + "SELECT * FROM poster_heal_review WHERE id = ?", + (review_id,), + fetch_one=True, + ) + + def set_status(self, review_id: int, status: str) -> None: + self.execute_query( + "UPDATE poster_heal_review SET status = ? WHERE id = ?", + (status, review_id), + ) + + def dismissed_files(self) -> set: + """``poster_file`` values the user dismissed. Auto-apply must skip + these — the CASE only keeps them out of the queue, not off the disk.""" + rows = ( + self.execute_query( + "SELECT poster_file FROM poster_heal_review WHERE status = 'dismissed'", + fetch_all=True, + ) + or [] + ) + return {r["poster_file"] for r in rows if r.get("poster_file")} + + def is_dismissed(self, poster_file: str) -> bool: + """Live check — call immediately before a rename; the run-start snapshot + goes stale as soon as the user dismisses something mid-run.""" + row = self.execute_query( + "SELECT 1 AS hit FROM poster_heal_review " + "WHERE poster_file = ? AND status = 'dismissed'", + (poster_file,), + fetch_one=True, + ) + return bool(row) + + def mark_failed(self, poster_file: str, reason: str) -> None: + """Force a row open as ``failed`` after an auto-apply raised. Overrides + a terminal ``applied``; ``dismissed`` stays terminal.""" + self.execute_query( + "UPDATE poster_heal_review SET status = 'failed', reason = ? " + "WHERE poster_file = ? AND status <> 'dismissed'", + (reason, poster_file), + ) + + def delete(self, review_id: int) -> None: + self.execute_query("DELETE FROM poster_heal_review WHERE id = ?", (review_id,)) + + def clear(self) -> None: + self.execute_query("DELETE FROM poster_heal_review") + + +def poster_heal_review_table(): + """TableDefinition for poster_heal_review. Imported lazily by the manifest's + tables() hook (TableDefinition lives in schema.py, imported here not at + module level to avoid circular imports during config init).""" + from .schema import ColumnDefinition, TableDefinition + + return TableDefinition( + name="poster_heal_review", + columns=[ + ColumnDefinition("id", "INTEGER", primary_key=True, nullable=False), + ColumnDefinition("poster_file", "TEXT", nullable=False, unique=True), + ColumnDefinition("drive_folder_id", "TEXT"), + ColumnDefinition("asset_type", "TEXT"), + ColumnDefinition("drift_type", "TEXT"), # id | title | backfill + ColumnDefinition("current_filename", "TEXT"), + ColumnDefinition("proposed_filename", "TEXT"), + ColumnDefinition("tmdb_id_old", "INTEGER"), + ColumnDefinition("tmdb_id_new", "INTEGER"), + ColumnDefinition("title_old", "TEXT"), + ColumnDefinition("title_new", "TEXT"), + ColumnDefinition("confidence", "REAL"), + ColumnDefinition("reason", "TEXT"), + ColumnDefinition("status", "TEXT", default="proposed"), + ColumnDefinition("created_at", "TEXT"), + ], + ) + + +def poster_heal_review_for(db) -> "PosterHealReview": + """Accessor — extensions query via db.extension_interface, not ChubDB props.""" + return db.extension_interface("poster_heal_review", PosterHealReview) diff --git a/backend/util/poster_self_heal/__init__.py b/backend/util/poster_self_heal/__init__.py new file mode 100644 index 00000000..ff456648 --- /dev/null +++ b/backend/util/poster_self_heal/__init__.py @@ -0,0 +1,2 @@ +# backend/util/poster_self_heal/__init__.py +"""Support code for the poster_self_heal extension (config + resolver).""" diff --git a/backend/util/poster_self_heal/apply.py b/backend/util/poster_self_heal/apply.py new file mode 100644 index 00000000..41358738 --- /dev/null +++ b/backend/util/poster_self_heal/apply.py @@ -0,0 +1,62 @@ +# backend/util/poster_self_heal/apply.py +"""Apply a poster_heal_review proposal — rename the poster on the user's Google +Drive and on the local source copy. Shared by the review API (manual apply) and +the module (auto-apply).""" + +import os +from typing import Any, Dict + +from backend.util.cl2k.gdrive_upload import list_files, move_file + + +def apply_proposal(row: Dict[str, Any], sync_cfg: Any, logger) -> str: + """Rename per a proposal: the user's Drive first (a failure there is a clean + no-op), then the local source copy (best-effort — the Drive is canonical, and + a missing/failed local file reconciles on the next poster_renamerr scan). + + ``row`` needs current_filename, proposed_filename, poster_file, drive_folder_id + (both a resolver proposal dict and a poster_heal_review row carry these). + Returns a human note about the local step; raises on a Drive rename failure. + """ + current = row.get("current_filename") or "" + proposed = row.get("proposed_filename") or "" + old_path = row.get("poster_file") or "" + folder_id = row.get("drive_folder_id") + + # Nothing to rename. + if not proposed or proposed == current: + return "" + + if folder_id: + # moveto/os.replace overwrite the destination permanently (Drive trash is + # off) — refuse if a different file already holds the target name. + # strict=True: a listing FAILURE must not read as "the name is free", or a + # transient Drive error would let the rename destroy the colliding poster. + if proposed in list_files(folder_id, sync_cfg, logger, strict=True): + raise RuntimeError( + f"refusing to apply: '{proposed}' already exists in Drive folder " + f"{folder_id}; renaming '{current}' onto it would overwrite a " + "different poster." + ) + move_file(current, proposed, folder_id, sync_cfg, logger) + + # Live-Drive-only poster: bare filename, no local copy to touch. + if not old_path or not os.path.isabs(old_path): + return "" + + note = "" + new_path = os.path.join(os.path.dirname(old_path), proposed) + try: + if not os.path.exists(old_path): + note = " (local copy was missing; it will refresh on the next scan)" + logger.warning(f"local file missing, skipped local rename: {old_path}") + elif os.path.exists(new_path) and not os.path.samefile(old_path, new_path): + note = f" (local '{proposed}' already exists; left both to avoid clobber)" + logger.warning(f"local rename target exists, skipped: {new_path}") + else: + os.replace(old_path, new_path) + logger.info(f"renamed local {current} -> {proposed}") + except OSError as exc: + note = f" (local rename failed: {exc}; it will refresh on the next scan)" + logger.warning(f"local rename failed for {old_path}: {exc}") + return note diff --git a/backend/util/poster_self_heal/cache_reconcile.py b/backend/util/poster_self_heal/cache_reconcile.py new file mode 100644 index 00000000..0791547d --- /dev/null +++ b/backend/util/poster_self_heal/cache_reconcile.py @@ -0,0 +1,33 @@ +# backend/util/poster_self_heal/cache_reconcile.py +"""Drop the poster_cache row for a file the healer just renamed. + +Call after EVERY successful apply (scheduled and manual) — a surviving row +re-proposes the rename and collides with the file we created. +""" + +from typing import Any + + +def drop_stale_row(db: Any, file_path: str, logger: Any = None) -> int: + """Delete the poster_cache row for the EXACT pre-rename path. + + Exact match, never a prefix: 'X.jpg' must not also take 'X.jpg.bak'. Returns + the row count; best-effort, so a cache problem can't fail a rename that + already succeeded on Drive. + """ + if not file_path: + return 0 + try: + return ( + db.poster.execute_query( + "DELETE FROM poster_cache WHERE file = ?", (file_path,) + ) + or 0 + ) + except Exception as exc: # the rename already landed; never fail the run + if logger: + logger.warning( + f"poster_self_heal: could not drop the stale cache row for " + f"{file_path}: {exc}" + ) + return 0 diff --git a/backend/util/poster_self_heal/config.py b/backend/util/poster_self_heal/config.py new file mode 100644 index 00000000..2c325678 --- /dev/null +++ b/backend/util/poster_self_heal/config.py @@ -0,0 +1,35 @@ +# backend/util/poster_self_heal/config.py +"""Pydantic config model for the poster_self_heal extension. + +Grafted onto ChubConfig by backend/extensions/poster_self_heal/manifest.py +(config_fields), so ``load_config().poster_self_heal`` is typed like the core +module sections. Lives here (not backend/util/config.py) because poster_self_heal +is part of the :full image. + +Deliberately has NO source/drive fields: the healer operates on the CL2K maker's +output, reading ``cl2k_maker.local_folders`` + ``cl2k_maker.gdrive_uploads`` (and +``cl2k_maker.style``) from the loaded config at run time. +""" + +from pydantic import BaseModel + + +class PosterSelfHealConfig(BaseModel): + log_level: str = "info" + + # The healer rebuilds each poster's canonical DAPS filename from its live + # library row (current tmdb/tvdb/imdb ids) + TMDB's canonical title/year, and + # proposes the rename when it differs — so id, title, and year drift are all + # healed together (a filename is canonical or it isn't; partial heals don't + # make sense). Per-id TMDB lookups are cached by the TMDB client's own + # cache_expiration, so a scheduled run re-checks every poster cheaply. + # + # The one genuinely-optional behaviour: whether to also ADD ids to a poster + # that has none (backfill), vs only correcting posters that already carry one. + backfill_ids: bool = True + + # When True, confident proposals are applied automatically during the run + # (renamed on Drive + locally) instead of waiting for manual review. Ambiguous + # matches (multiple library items share a title) ALWAYS go to the review queue + # regardless — they have no single safe rename to auto-apply. + auto_apply: bool = False diff --git a/backend/util/poster_self_heal/notify.py b/backend/util/poster_self_heal/notify.py new file mode 100644 index 00000000..b2a1ffd2 --- /dev/null +++ b/backend/util/poster_self_heal/notify.py @@ -0,0 +1,37 @@ +# backend/util/poster_self_heal/notify.py +"""Discord formatter for poster_self_heal run summaries. + +Registered via the extension manifest's notification_formatters() hook and merged +into notification_formatting.format_for_discord's registry under the +"poster_self_heal" module key. +""" + +from typing import Any, Dict, List + + +def format_poster_self_heal(output: Any) -> List[Dict[str, Any]]: + """Return Discord embed fields summarising a healer run.""" + o = output if isinstance(output, dict) else {} + fields: List[Dict[str, Any]] = [ + {"name": "Posters scanned", "value": str(o.get("scanned", 0)), "inline": True}, + ] + if o.get("applied"): + fields.append( + {"name": "Auto-applied", "value": str(o["applied"]), "inline": True} + ) + if o.get("proposed"): + fields.append( + {"name": "Proposed (review)", "value": str(o["proposed"]), "inline": True} + ) + if o.get("pending"): + fields.append( + {"name": "Needs a pick", "value": str(o["pending"]), "inline": True} + ) + if o.get("failed"): + fields.append( + {"name": "Auto-apply failed", "value": str(o["failed"]), "inline": True} + ) + fields.append( + {"name": "Open for review", "value": str(o.get("open", 0)), "inline": True} + ) + return fields diff --git a/backend/util/poster_self_heal/resolver.py b/backend/util/poster_self_heal/resolver.py new file mode 100644 index 00000000..92a3ad46 --- /dev/null +++ b/backend/util/poster_self_heal/resolver.py @@ -0,0 +1,357 @@ +# backend/util/poster_self_heal/resolver.py +"""Decide whether a CL2K poster's embedded ids / title / year have drifted. + +CL2K posters are always made for the user's library items, so the live +``media_cache`` row is the source of the current id set (tmdb/tvdb/imdb) — it +mirrors *arr/Plex, which sync from TMDB/TVDB. We match each poster to its library +row (id-first across all three ids, then title+year), rebuild the canonical DAPS +filename from that row's ids plus TMDB's canonical title/year (``get_details``), +and propose the rename when it differs from the current filename. One pass heals +id, title, and year drift together — a filename is canonical or it isn't. + +``resolve_poster`` returns a proposal dict (shaped for poster_heal_review.upsert) +or None for a no-op (already canonical, no library match, or a transient TMDB +failure that must be retried rather than acted on). +""" + +import os +from typing import Any, Dict, List, Optional, Tuple + +from backend.util.cl2k.naming import build_poster_filename +from backend.util.constants import asset_type_regex, season_number_regex +from backend.util.helper import extract_ids, extract_year +from backend.util.normalization import normalize_titles, parse_asset_filename + +YEAR_TOLERANCE = 1 + +# poster_cache image_type -> the asset-tag suffix build_poster_filename appends. +_ASSET_SUFFIX = { + "poster": "", + "logo": " - logo", + "background": " - background", + "squareart": " - squareart", +} + + +def _media_type(asset_type: Optional[str]) -> str: + return "movie" if asset_type == "movie" else "tv" + + +def _norm(value: Optional[str]) -> str: + return normalize_titles(value) if value else "" + + +def _as_int(value: Any) -> Optional[int]: + try: + return int(value) if value not in (None, "", "None", 0, "0") else None + except (TypeError, ValueError): + return None + + +def _season_int(value: Any) -> Optional[int]: + """Like _as_int but KEEPS 0 — season 0 is the Specials season, a real value + (unlike an id, where 0 means 'absent').""" + if value in (None, "", "None"): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _season_collapsed(name: str) -> str: + """Collapse a filename's season tag to a padding-insensitive, year-safe key: + 'Season 1' / 'Season 01' / 'Specials' (= Season 0) / 'Season 2026' all reduce + to their integer value. Lets the resolver tell a real rename from a + season-format-only difference — CHUB/Plex parse the season to an int and match + identically, so reformatting it alone isn't worth a Drive rename.""" + return season_number_regex.sub( + lambda m: f" - Season {int(m.group(1)) if m.group(1) is not None else 0}", + name, + ) + + +def _imdb(value: Any) -> str: + """Normalize an IMDb id to a comparable 'tt…' string, or '' when absent.""" + s = str(value).strip().lower() if value not in (None, "", "None") else "" + return s if s.startswith("tt") else "" + + +_IMAGE_EXTS = (".jpg", ".jpeg", ".png", ".webp") + + +def poster_from_filename(filename: str) -> Optional[Dict[str, Any]]: + """Parse a bare Drive filename into the poster dict ``resolve_poster`` + consumes — the live-Drive counterpart of a poster_cache row, for CL2K posters + saved straight to Drive (never recorded in poster_cache). Mirrors + poster_renamerr's own filename parsing so a Drive poster heals identically to + a local one. ``file`` is the bare name (no directory) so apply renames only + on Drive. ``asset_type`` is left for resolve_poster to refine from the matched + library row (a season tag ⇒ show). Returns None for a non-image file.""" + _, ext = os.path.splitext(filename) + if ext.lower() not in _IMAGE_EXTS: + return None + m = asset_type_regex.search(filename) + if m: + image_type = m.group(1).lower() + base = asset_type_regex.sub("", filename).strip(" -_") + title = parse_asset_filename(base + ext) + normalized_title = normalize_titles(base) + else: + image_type = "poster" + title = parse_asset_filename(filename) + normalized_title = normalize_titles(filename) + tmdb_id, tvdb_id, imdb_id = extract_ids(filename) + match = season_number_regex.search(filename) + season_number = ( + int(match.group(1)) if match and match.group(1) else (0 if match else None) + ) + return { + "title": title, + "normalized_title": normalized_title, + "year": extract_year(filename) or extract_year(title), + "tmdb_id": tmdb_id, + "tvdb_id": tvdb_id, + "imdb_id": imdb_id, + "season_number": season_number, + "image_type": image_type, + "asset_type": "show" if season_number is not None else None, + "file": filename, + } + + +def index_media(media_rows: List[Dict[str, Any]]) -> Dict[str, Any]: + """Build id + title indexes over matched media_cache rows. First write wins + per id (media_cache is deduped per identity already).""" + by_tmdb: Dict[Tuple[str, int], Dict[str, Any]] = {} + by_tvdb: Dict[int, Dict[str, Any]] = {} + by_imdb: Dict[str, Dict[str, Any]] = {} + by_title: Dict[str, List[Dict[str, Any]]] = {} + for m in media_rows: + if not m.get("matched"): + continue + t = _as_int(m.get("tmdb_id")) + # TMDB movie and TV ids are separate namespaces that collide numerically. + tk = (_media_type(m.get("asset_type")), t) if t else None + if tk and tk not in by_tmdb: + by_tmdb[tk] = m + v = _as_int(m.get("tvdb_id")) + if v and v not in by_tvdb: + by_tvdb[v] = m + i = _imdb(m.get("imdb_id")) + if i and i not in by_imdb: + by_imdb[i] = m + nt = m.get("normalized_title") or _norm(m.get("title")) + if nt: + by_title.setdefault(nt, []).append(m) + return {"tmdb": by_tmdb, "tvdb": by_tvdb, "imdb": by_imdb, "title": by_title} + + +def _find_media( + poster: Dict[str, Any], idx: Dict[str, Any] +) -> Tuple[Optional[Dict[str, Any]], str, bool]: + """Match a poster to one live media row. + + Returns (media_row, match_kind, ambiguous). ``match_kind`` is "id" (a high- + confidence id hit on any of tmdb/tvdb/imdb), "title" (a unique title+year + hit), or "". ``ambiguous`` is True when title+year matched >1 row. + """ + # A season marker means show; otherwise an absent asset_type stays unknown — + # defaulting it to "movie" mis-filed typeless Drive posters (parse leaves + # the type for this function to refine). + at = poster.get("asset_type") or ( + "show" if poster.get("season_number") is not None else None + ) + t = _as_int(poster.get("tmdb_id")) + if t: + if at: + hits = [idx["tmdb"].get((_media_type(at), t))] + else: + # TMDB numbers movies and shows separately, so try both spaces. + hits = [idx["tmdb"].get(("movie", t)), idx["tmdb"].get(("tv", t))] + hits = [h for h in hits if h] + if len(hits) == 1: + return hits[0], "id", False + if len(hits) > 1: + return None, "", True + v = _as_int(poster.get("tvdb_id")) + if v and v in idx["tvdb"]: + return idx["tvdb"][v], "id", False + i = _imdb(poster.get("imdb_id")) + if i and i in idx["imdb"]: + return idx["imdb"][i], "id", False + + nt = poster.get("normalized_title") or _norm(poster.get("title")) + candidates = idx["title"].get(nt, []) if nt else [] + # Same media type only — a movie poster must not match a show of the same + # title/year. Unknown type filters nothing: the unique-match rule below + # turns a movie/show title collision into "ambiguous", never a wrong match. + if at: + pt = _media_type(at) + candidates = [m for m in candidates if _media_type(m.get("asset_type")) == pt] + poster_year = _as_int(poster.get("year")) + if poster_year is not None: + yeared = [ + m + for m in candidates + if _as_int(m.get("year")) is not None + and abs(_as_int(m.get("year")) - poster_year) <= YEAR_TOLERANCE + ] + if yeared: + candidates = yeared + if len(candidates) == 1: + return candidates[0], "title", False + if len(candidates) > 1: + return None, "", True + return None, "", False + + +def _canonical_title_year( + tmdb_id: Optional[int], + media_type: str, + tmdb_client, + fallback_title: str, + fallback_year: Optional[int], +) -> Tuple[Optional[str], Optional[int], bool]: + """(title, year, ok). ok=False means a transient TMDB failure → retry later. + + Uses TMDB get_details for the canonical title/year (the user's chosen source + of truth), falling back to the library values when TMDB has nothing useful. + """ + if not tmdb_id: + return fallback_title, fallback_year, True + details = tmdb_client.get_details(tmdb_id, media_type) + if details is None: + return None, None, False # transient — don't act + title = fallback_title + year = fallback_year + if details.get("verified"): + if details.get("title"): + title = details["title"] + if _as_int(details.get("year")): + year = _as_int(details.get("year")) + return title, year, True + + +def resolve_poster( + poster: Dict[str, Any], + media_index: Dict[str, Any], + drive_folder_id: Optional[str], + tmdb_client, + cfg, +) -> Optional[Dict[str, Any]]: + """Return a poster_heal_review proposal dict, or None for a no-op / retry.""" + asset_type = poster.get("asset_type") or "movie" + cur_name = os.path.basename(poster.get("file", "")) + _, ext = os.path.splitext(poster.get("file", "")) + title_old = poster.get("title") or "" + old_tmdb = _as_int(poster.get("tmdb_id")) + old_tvdb = _as_int(poster.get("tvdb_id")) + old_imdb = _imdb(poster.get("imdb_id")) + old_year = _as_int(poster.get("year")) + has_any_id = bool(old_tmdb or old_tvdb or old_imdb) + + if not has_any_id and not cfg.backfill_ids: + return None # id-less poster, backfill disabled + + media, match_kind, ambiguous = _find_media(poster, media_index) + + if not media: + if ambiguous: + # Multiple library items share this title — can't safely pick one. + return { + "poster_file": poster.get("file"), + "drive_folder_id": drive_folder_id, + "asset_type": asset_type, + "drift_type": "ambiguous", + "current_filename": cur_name, + "proposed_filename": cur_name, + "tmdb_id_old": old_tmdb, + "tmdb_id_new": None, + "title_old": title_old, + "title_new": title_old, + "confidence": 0.5, + "reason": "matches multiple library items by title — pick the right one", + "status": "pending", + } + return None # no library match (or non-library poster) — leave it alone + + # The matched library row is authoritative for the media type — a live-Drive + # poster may carry none (asset_type=None). Drives the TMDB endpoint and the + # canonical filename's kind. + resolved_type = media.get("asset_type") or asset_type + media_type = _media_type(resolved_type) + + # Canonical id set comes from the live library row; title/year from TMDB. + new_tmdb = _as_int(media.get("tmdb_id")) or old_tmdb + new_tvdb = _as_int(media.get("tvdb_id")) or old_tvdb + new_imdb = _imdb(media.get("imdb_id")) or old_imdb + title_new, year_new, ok = _canonical_title_year( + new_tmdb, + media_type, + tmdb_client, + media.get("title") or title_old, + _as_int(media.get("year")) or old_year, + ) + if not ok: + return None # transient TMDB failure — retry next run + + # A season/specials poster heals the SHOW's identity (it matches the show's + # media row) while keeping its own season tag: pass kind="season" so + # build_poster_filename re-emits ` - Season NN` / ` - Specials` (0 = Specials, + # which is why _season_int keeps 0). Otherwise the tag would be dropped. + season = _season_int(poster.get("season_number")) + new_name = build_poster_filename( + kind="season" if season is not None else resolved_type, + title=title_new or title_old, + year=year_new, + tmdb_id=new_tmdb, + tvdb_id=new_tvdb, + imdb_id=new_imdb or None, + season_number=season, + ext=ext or ".jpg", + asset_suffix=_ASSET_SUFFIX.get(poster.get("image_type") or "poster", ""), + ) + if new_name == cur_name: + return None # already canonical + + # Label what changed (the current -> proposed filename shows the full detail). + changed: List[str] = [] + if old_tmdb != new_tmdb: + changed.append("backfill" if not old_tmdb else "tmdb") + if new_tvdb and old_tvdb != new_tvdb: + changed.append("tvdb") + if new_imdb and old_imdb != new_imdb: + changed.append("imdb") + if _norm(title_old) != _norm(title_new): + changed.append("title") + if year_new and old_year != year_new: + changed.append("year") + + # When no id/title/year actually changed, a leftover filename difference that + # is ONLY the season tag's formatting (Season 1 ≡ Season 01, Specials ≡ + # Season 0, year seasons like Season 2026) is not real drift — the matcher + # keys on the parsed integer season — so skip the pointless rename. + if not changed and _season_collapsed(cur_name) == _season_collapsed(new_name): + return None + drift_type = "+".join(changed) or "rename" + + return { + "poster_file": poster.get("file"), + "drive_folder_id": drive_folder_id, + "asset_type": resolved_type, + "drift_type": drift_type, + "current_filename": cur_name, + "proposed_filename": new_name, + "tmdb_id_old": old_tmdb, + "tmdb_id_new": new_tmdb, + "title_old": title_old, + "title_new": title_new, + "confidence": 0.95 if match_kind == "id" else 0.9, + "reason": ( + "matched the live library item by id" + if match_kind == "id" + else "matched the live library item by title + year" + ), + "status": "proposed", + } diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index fe4927a7..3401e21a 100755 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -83,9 +83,19 @@ RUN pip3 install --no-cache-dir cairosvg && \ COPY --from=frontend /app/frontend/dist ./frontend/dist # ------------------------- -# Stage 2: Final runtime image +# Stage 1b: builder + the :full image's Python deps # ------------------------- -FROM python:3.14-slim@sha256:ce40764625a4ff50df3548277632e7f96c4e77fe75fa848aae9885476e7df5a4 +# psd-tools (.psd export), Wand (ImageMagick bindings) and the cairosvg pin +# for SVG logo rendering — requirements-cl2k.txt is the one place they're +# pinned. Only the `full` runtime copies this stage's site-packages. +FROM builder AS builder-full +COPY requirements-cl2k.txt . +RUN pip3 install --no-cache-dir -r requirements-cl2k.txt + +# ------------------------- +# Stage 2: shared runtime base — everything except site-packages +# ------------------------- +FROM python:3.14-slim@sha256:ce40764625a4ff50df3548277632e7f96c4e77fe75fa848aae9885476e7df5a4 AS runtime-base WORKDIR /app @@ -96,10 +106,6 @@ RUN apt-get update && \ apt-get install -y --no-install-recommends curl git tzdata jdupes && \ rm -rf /var/lib/apt/lists/* -# Copy only Python runtime and site-packages from builder -COPY --from=builder /usr/local/lib/python3.14/site-packages /usr/local/lib/python3.14/site-packages -COPY --from=builder /usr/local/bin /usr/local/bin - # Copy rclone binary (installed to /usr/bin by rclone install script) COPY --from=builder /usr/bin/rclone /usr/bin/rclone @@ -160,3 +166,56 @@ ENV DOCKER_ENV=true ENV PYTHONUNBUFFERED=1 ENTRYPOINT ["bash", "scripts/start.sh"] + +# ------------------------- +# Stage 3: :full — the lean runtime plus the CL2K toolchain +# ------------------------- +FROM runtime-base AS full + +# imagemagick (Wand's libMagickWand), librsvg2-2 (so IM can read SVG clear +# logos), fonts-dejavu-core (guaranteed fallback font), and cabextract to +# unpack the vendored Arial below. No `contrib` component or +# ttf-mscorefonts-installer: that package downloads MS fonts from SourceForge +# at build time, and a flaky mirror failed the whole image build. +RUN set -eux; \ + apt-get update; \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + imagemagick librsvg2-2 fonts-dejavu-core \ + fontconfig cabextract; \ + rm -rf /var/lib/apt/lists/* + +# Real Microsoft Arial (regular + bold) for CL2K text rendering, extracted at +# build from the vendored, unmodified arial32.exe (deploy/docker/fonts/) — so +# the build never touches the network for fonts. Renamed to the msttcorefonts +# paths geometry.resolve_font() probes (Arial.ttf / Arial_Bold.ttf). +# Redistributing the original .exe is permitted by its EULA (shipped alongside +# as LICENSE-mscorefonts.txt); the extracted .ttf stays inside the image and is +# never committed to the repo. +COPY deploy/docker/fonts/arial32.exe /tmp/arial32.exe +RUN set -eux; \ + mkdir -p /usr/share/fonts/truetype/msttcorefonts /tmp/arialout; \ + cabextract -L -d /tmp/arialout /tmp/arial32.exe; \ + cp /tmp/arialout/arial.ttf /usr/share/fonts/truetype/msttcorefonts/Arial.ttf; \ + cp /tmp/arialout/arialbd.ttf /usr/share/fonts/truetype/msttcorefonts/Arial_Bold.ttf; \ + fc-cache -f; \ + rm -rf /tmp/arial32.exe /tmp/arialout + +# Python deps from the full builder — the only place cl2k wheels exist. +COPY --from=builder-full /usr/local/lib/python3.14/site-packages /usr/local/lib/python3.14/site-packages +COPY --from=builder-full /usr/local/bin /usr/local/bin + +# Turns every extension's functional hooks on (backend/extensions/__init__.py). +ENV CHUB_IMAGE_FLAVOR=full + +# ------------------------- +# Stage 4: :latest — lean runtime. LAST so a bare `docker build` builds it. +# ------------------------- +FROM runtime-base AS runtime + +# Python deps from the lean builder: no cl2k wheels anywhere in this image. +COPY --from=builder /usr/local/lib/python3.14/site-packages /usr/local/lib/python3.14/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin + +# Extensions' functional hooks off; config/tables stay registered so a config +# written under :full keeps loading and round-tripping here. +ENV CHUB_IMAGE_FLAVOR=lean diff --git a/deploy/docker/compose.yaml b/deploy/docker/compose.yaml index da7caf60..32a09d18 100755 --- a/deploy/docker/compose.yaml +++ b/deploy/docker/compose.yaml @@ -1,5 +1,7 @@ services: chub: + # :latest is the minimal image; use :full for the CL2K poster maker + + # poster self-heal (see README "Image tags"). image: ghcr.io/chodeus/chub:latest container_name: chub restart: unless-stopped diff --git a/deploy/docker/fonts/LICENSE-mscorefonts.txt b/deploy/docker/fonts/LICENSE-mscorefonts.txt new file mode 100644 index 00000000..455c6660 --- /dev/null +++ b/deploy/docker/fonts/LICENSE-mscorefonts.txt @@ -0,0 +1,48 @@ +Microsoft TrueType core fonts for the Web — End User License Agreement +Source: https://corefonts.sourceforge.net/eula.htm +Bundled here unmodified to accompany arial32.exe per the redistribution +terms below (original-format redistribution is permitted). +======================================================================== + +TrueType core fonts for the Web end user license agreement + +END-USER LICENSE AGREEMENT FOR +MICROSOFT SOFTWARE + +IMPORTANT-READ CAREFULLY: This Microsoft End-User License Agreement ("EULA") is a legal agreement between you (either an individual or a single entity) and Microsoft Corporation for the Microsoft software accompanying this EULA, which includes computer software and may include associated media, printed materials, and "on-line" or electronic documentation ("SOFTWARE PRODUCT" or "SOFTWARE"). By exercising your rights to make and use copies of the SOFTWARE PRODUCT, you agree to be bound by the terms of this EULA. If you do not agree to the terms of this EULA, you may not use the SOFTWARE PRODUCT. + +SOFTWARE PRODUCT LICENSE + +The SOFTWARE PRODUCT is protected by copyright laws and international copyright treaties, as well as other intellectual property laws and treaties. The SOFTWARE PRODUCT is licensed, not sold. + +1. GRANT OF LICENSE. This EULA grants you the following rights: + + - Installation and Use. You may install and use an unlimited number of copies of the SOFTWARE PRODUCT. + + - Reproduction and Distribution. You may reproduce and distribute an unlimited number of copies of the SOFTWARE PRODUCT; provided that each copy shall be a true and complete copy, including all copyright and trademark notices, and shall be accompanied by a copy of this EULA. Copies of the SOFTWARE PRODUCT may not be distributed for profit either on a standalone basis or included as part of your own product. + +2. DESCRIPTION OF OTHER RIGHTS AND LIMITATIONS. + + - Limitations on Reverse Engineering, Decompilation, and Disassembly. You may not reverse engineer, decompile, or disassemble the SOFTWARE PRODUCT, except and only to the extent that such activity is expressly permitted by applicable law notwithstanding this limitation. + + - Restrictions on Alteration. You may not rename, edit or create any derivative works from the SOFTWARE PRODUCT, other than subsetting when embedding them in documents. + + - Software Transfer. You may permanently transfer all of your rights under this EULA, provided the recipient agrees to the terms of this EULA. + + - Termination. Without prejudice to any other rights, Microsoft may terminate this EULA if you fail to comply with the terms and conditions of this EULA. In such event, you must destroy all copies of the SOFTWARE PRODUCT and all of its component parts. + +3. COPYRIGHT. All title and copyrights in and to the SOFTWARE PRODUCT (including but not limited to any images, text, and "applets" incorporated into the SOFTWARE PRODUCT), the accompanying printed materials, and any copies of the SOFTWARE PRODUCT are owned by Microsoft or its suppliers. The SOFTWARE PRODUCT is protected by copyright laws and international treaty provisions. Therefore, you must treat the SOFTWARE PRODUCT like any other copyrighted material. + +4. U.S. GOVERNMENT RESTRICTED RIGHTS. The SOFTWARE PRODUCT and documentation are provided with RESTRICTED RIGHTS. Use, duplication, or disclosure by the Government is subject to restrictions as set forth in subparagraph (c)(1)(ii) of the Rights in Technical Data and Computer Software clause at DFARS 252.227-7013 or subparagraphs (c)(1) and (2) of the Commercial Computer Software - Restricted Rights at 48 CFR 52.227-19, as applicable. Manufacturer is Microsoft Corporation/One Microsoft Way/Redmond, WA 98052-6399. + +LIMITED WARRANTY +NO WARRANTIES. Microsoft expressly disclaims any warranty for the SOFTWARE PRODUCT. The SOFTWARE PRODUCT and any related documentation is provided "as is" without warranty of any kind, either express or implied, including, without limitation, the implied warranties or merchantability, fitness for a particular purpose, or noninfringement. The entire risk arising out of use or performance of the SOFTWARE PRODUCT remains with you. +NO LIABILITY FOR CONSEQUENTIAL DAMAGES. In no event shall Microsoft or its suppliers be liable for any damages whatsoever (including, without limitation, damages for loss of business profits, business interruption, loss of business information, or any other pecuniary loss) arising out of the use of or inability to use this Microsoft product, even if Microsoft has been advised of the possibility of such damages. Because some states/jurisdictions do not allow the exclusion or limitation of liability for consequential or incidental damages, the above limitation may not apply to you. + +MISCELLANEOUS + +If you acquired this product in the United States, this EULA is governed by the laws of the State of Washington. + +If this product was acquired outside the United States, then local laws may apply. + +Should you have any questions concerning this EULA, or if you desire to contact Microsoft for any reason, please contact the Microsoft subsidiary serving your country, or write: Microsoft Sales Information Center/One Microsoft Way/Redmond, WA 98052-6399. diff --git a/deploy/docker/fonts/README.md b/deploy/docker/fonts/README.md new file mode 100644 index 00000000..c48ae75d --- /dev/null +++ b/deploy/docker/fonts/README.md @@ -0,0 +1,24 @@ +# Vendored fonts + +## arial32.exe + +Microsoft's original, unmodified "Arial" self-extracting installer from the +SourceForge `corefonts` project (the same file Debian's +`ttf-mscorefonts-installer` downloads). + +- Source: https://downloads.sourceforge.net/corefonts/arial32.exe +- md5: `9637df0e91703179f0723ec095a36cb5` +- Size: 554208 bytes + +Vendored so the Docker image build never fetches fonts over the network at +build time (the SourceForge mirrors are flaky and have broken CI). The +`deploy/docker/Dockerfile` runtime stage `cabextract`s it and installs Arial + +Arial Bold for CL2K text rendering (see `backend/util/cl2k/geometry.py`). + +## Licensing + +Redistribution of the **original, unmodified** `.exe` is permitted by the +Microsoft "TrueType core fonts for the Web" EULA, provided each copy is +complete and accompanied by that agreement — see `LICENSE-mscorefonts.txt`. +The **extracted `.ttf`** files are NOT redistributable: they are unpacked only +inside the built image and must never be committed to this repo. diff --git a/deploy/docker/fonts/arial32.exe b/deploy/docker/fonts/arial32.exe new file mode 100644 index 00000000..caaa6b6b Binary files /dev/null and b/deploy/docker/fonts/arial32.exe differ diff --git a/docs/RELEASE_CHECKLIST.md b/docs/RELEASE_CHECKLIST.md index cde0aa35..78387863 100644 --- a/docs/RELEASE_CHECKLIST.md +++ b/docs/RELEASE_CHECKLIST.md @@ -58,7 +58,7 @@ Working directory: current CHUB clone (what is currently `chodeus/daps` experime ## First CI run - [ ] Trigger the release workflow manually (Actions → Release → Run workflow) OR push a tag to trigger it -- [ ] Confirm **`ghcr.io/chodeus/chub:latest`** builds and publishes successfully +- [ ] Confirm **`ghcr.io/chodeus/chub:latest`** and **`:full`** (plus `vX.Y.Z` / `vX.Y.Z-full`) build and publish successfully - [ ] Pull the image locally and run it to confirm end-to-end: `docker run --rm -p 8000:8000 ghcr.io/chodeus/chub:latest` ## Assets & presentation diff --git a/frontend/src/css/tailwind.css b/frontend/src/css/tailwind.css index e3363490..adc8bbe8 100644 --- a/frontend/src/css/tailwind.css +++ b/frontend/src/css/tailwind.css @@ -83,7 +83,7 @@ --color-default: var(--border); /* legacy alias (.border-default) */ --color-input-error: var(--error); /* legacy alias (.border-input-error) */ - /* legacy aliases for develop-only/missed classes the migration dropped: + /* legacy aliases for ':full'-image/missed classes the migration dropped: .bg-error-bg, .bg-warning-bg, .border-error-border, .text-sidebar-secondary, .bg-primary-hover (mirrors old colors.css) */ --color-error-bg: color-mix(in srgb, var(--error) 10%, transparent); diff --git a/frontend/src/extensions/UnavailableNotice.jsx b/frontend/src/extensions/UnavailableNotice.jsx new file mode 100644 index 00000000..fa4791be --- /dev/null +++ b/frontend/src/extensions/UnavailableNotice.jsx @@ -0,0 +1,26 @@ +import React from 'react'; + +/** Page shown when an extension route is opened on an image that doesn't carry it. */ +export function makeUnavailableNotice(pageName) { + const Notice = () => ( +
+ +

+ {pageName} ships in the :full image +

+

+ This install runs the minimal ghcr.io/chodeus/chub:latest image. Switch + the container to the :full tag to enable it — your config and data + carry over unchanged. +

+
+ ); + Notice.displayName = `ExtensionUnavailable(${pageName})`; + return Notice; +} diff --git a/frontend/src/extensions/cl2k/AiConnectionTest.jsx b/frontend/src/extensions/cl2k/AiConnectionTest.jsx new file mode 100644 index 00000000..9d0ac873 --- /dev/null +++ b/frontend/src/extensions/cl2k/AiConnectionTest.jsx @@ -0,0 +1,69 @@ +// "Test connection" for the CL2K AI provider — the custom settings field type +// `cl2k_ai_test` (registered in manifest.jsx). +// +// Sends NO credentials: GET /api/config serves api_key/client_key redacted, so +// the browser never holds the real ones and the endpoint reads them server-side. +// Consequence worth surfacing to the user: it tests SAVED settings, not unsaved +// edits. +import React, { useCallback, useState } from 'react'; +import { apiCore } from '../../utils/api/core'; +import { useToast } from '../../contexts/ToastContext.jsx'; + +export const Cl2kAiTestField = ({ rootConfig }) => { + const toast = useToast(); + const [busy, setBusy] = useState(false); + const [result, setResult] = useState(null); // { ok, message } + const provider = rootConfig?.cl2k_maker?.ai_provider || 'none'; + + const run = useCallback(async () => { + setBusy(true); + setResult(null); + try { + const res = await apiCore.post('/cl2k-maker/test-ai', {}); + const message = res?.message || 'Connection works'; + setResult({ ok: true, message }); + toast.success(message); + } catch (e) { + const message = e?.message || 'Connection test failed'; + setResult({ ok: false, message }); + toast.error(message); + } finally { + setBusy(false); + } + }, [toast]); + + return ( +
+

+ Round-trips the provider's authenticated route using your saved settings — save + first if you have just changed the key. +

+
+ +
+ {result && ( +

+ {result.message} +

+ )} +
+ ); +}; diff --git a/frontend/src/extensions/cl2k/SaveLocationsFields.jsx b/frontend/src/extensions/cl2k/SaveLocationsFields.jsx new file mode 100644 index 00000000..48c0235f --- /dev/null +++ b/frontend/src/extensions/cl2k/SaveLocationsFields.jsx @@ -0,0 +1,657 @@ +// CL2K maker — Save Locations settings UI (design_handoff_cl2k_save_locations, +// option 2a: routed cards + coverage strip). +// +// Three custom field types registered by manifest.jsx via FieldRegistry.register +// (a public extension slot — no shared-file edits): the Local Folders and Google +// Drives cards edit their own config key (local_folders / gdrive_uploads); the +// Coverage card is read-only. ModuleSettingsPage's field memo only re-renders on +// a field's OWN value, so the two list fields publish into a tiny module-scope +// store and the coverage field subscribes to it (its rootConfig prop goes stale +// as siblings edit). +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react'; +import { Modal } from '../../components/ui'; +import { FieldRegistry } from '../../components/fields/FieldRegistry.jsx'; +import { apiCore } from '../../utils/api/core'; +import { useToast } from '../../contexts/ToastContext.jsx'; + +export const CL2K_ART_TYPES = [ + { value: 'poster', label: 'Poster' }, + { value: 'logo', label: 'Logo' }, + { value: 'background', label: 'Background' }, + { value: 'squareart', label: 'Square Art' }, +]; + +const COVERAGE_LABELS = { + poster: 'Poster', + logo: 'Logo', + background: 'Background', + squareart: 'Square', +}; + +// Resolved once at module scope (manifests are eagerly imported at app init, +// after the registry's own module graph) — resolving inside render trips +// react-hooks/static-components. +const DirPickerField = FieldRegistry.getField('dir_picker'); + +// ─── Live-coverage store ───────────────────────────────────────────────────── + +const coverageStore = { + state: { local_folders: null, gdrive_uploads: null }, + listeners: new Set(), +}; + +const publishSaveLocations = (key, value) => { + if (coverageStore.state[key] === value) return; + coverageStore.state = { ...coverageStore.state, [key]: value }; + coverageStore.listeners.forEach(fn => fn()); +}; + +const subscribeCoverage = fn => { + coverageStore.listeners.add(fn); + return () => coverageStore.listeners.delete(fn); +}; + +const coverageSnapshot = () => coverageStore.state; + +// ─── Shared bits ───────────────────────────────────────────────────────────── + +const Icon = ({ name, className = '', style }) => ( + +); + +const AddButton = ({ label, onClick, disabled }) => ( + +); + +// Card header: description left (the title itself is the section card's h2), +// "+ Add …" action right. Hidden while the empty state shows — it repeats the +// same button. +const CardIntro = ({ description, action }) => ( +
+

+ {description} +

+ {action} +
+); + +// The routing UI: one toggling pill per artwork type. +const TypeChips = ({ microLabel, types, onToggle, disabled }) => ( +
+ + {microLabel} + + {CL2K_ART_TYPES.map(t => { + const selected = (types || []).includes(t.value); + return ( + + ); + })} +
+); + +const EmptyState = ({ icon, heading, body, actionLabel, onAdd, disabled }) => ( +
+ +

{heading}

+

{body}

+
+ +
+
+); + +const NameRow = ({ + icon, + iconClass, + entry, + onRename, + onDelete, + disabled, + nameRef, + deleteLabel, +}) => ( +
+ + onRename(e.target.value)} + className="flex-1 min-w-0 bg-transparent border-none p-0 text-sm font-semibold text-fg placeholder:text-fg-dim focus:outline-none" + aria-label="Location name" + /> + +
+); + +// Entry-list plumbing shared by both cards: append-in-edit-state (focus the new +// row's name, scroll it into view), per-row patch, immediate delete (existing +// CHUB pages don't confirm). +const useEntryList = (value, onChange, blankEntry) => { + const entries = useMemo(() => (Array.isArray(value) ? value : []), [value]); + const [focusIndex, setFocusIndex] = useState(null); + + const add = useCallback(() => { + setFocusIndex(entries.length); + onChange([...entries, { ...blankEntry }]); + }, [entries, onChange, blankEntry]); + + const patch = useCallback( + (index, changes) => { + onChange(entries.map((e, i) => (i === index ? { ...e, ...changes } : e))); + }, + [entries, onChange] + ); + + const remove = useCallback( + index => { + onChange(entries.filter((_, i) => i !== index)); + }, + [entries, onChange] + ); + + const toggleType = useCallback( + (index, type) => { + const current = entries[index]?.types || []; + patch(index, { + types: current.includes(type) + ? current.filter(t => t !== type) + : [...current, type], + }); + }, + [entries, patch] + ); + + return { entries, add, patch, remove, toggleType, focusIndex, setFocusIndex }; +}; + +const useAutoFocus = (isTarget, clear) => { + const ref = useRef(null); + useEffect(() => { + if (isTarget && ref.current) { + ref.current.focus(); + ref.current.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + clear(); + } + }, [isTarget, clear]); + return ref; +}; + +// ─── Local Folders card ────────────────────────────────────────────────────── + +const FolderEntry = ({ + entry, + disabled, + onPatch, + onDelete, + onToggleType, + onBrowse, + autoFocus, + clearFocus, +}) => { + const nameRef = useAutoFocus(autoFocus, clearFocus); + return ( +
+ onPatch({ name })} + onDelete={onDelete} + disabled={disabled} + nameRef={nameRef} + deleteLabel="Delete folder" + /> +
+ onPatch({ path: e.target.value })} + className="flex-1 min-w-0 h-[38px] px-3 bg-bg border border-border rounded-lg font-mono text-[12.5px] text-fg-muted placeholder:text-fg-dim focus:ring-primary focus:outline-none transition-colors" + aria-label="Folder path" + /> + +
+ +
+ ); +}; + +export const Cl2kLocalFoldersField = ({ value, onChange, disabled = false }) => { + const { entries, add, patch, remove, toggleType, focusIndex, setFocusIndex } = useEntryList( + value, + onChange, + { name: '', path: '', types: [] } + ); + const [browseIndex, setBrowseIndex] = useState(null); + + useEffect(() => { + publishSaveLocations('local_folders', Array.isArray(value) ? value : []); + }, [value]); + + const clearFocus = useCallback(() => setFocusIndex(null), [setFocusIndex]); + + return ( +
+ 0 && ( + + ) + } + /> + {entries.length === 0 ? ( + + ) : ( +
+ {entries.map((entry, i) => ( + patch(i, changes)} + onDelete={() => remove(i)} + onToggleType={type => toggleType(i, type)} + onBrowse={() => setBrowseIndex(i)} + autoFocus={focusIndex === i} + clearFocus={clearFocus} + /> + ))} +
+ )} + + setBrowseIndex(null)} size="large"> + Select Directory + + {DirPickerField && browseIndex !== null && ( + { + patch(browseIndex, { path: newPath }); + setBrowseIndex(null); + }} + /> + )} + + + + + +
+ ); +}; + +// ─── Google Drives card ────────────────────────────────────────────────────── + +const TestUploadButton = ({ folderId, disabled }) => { + const toast = useToast(); + const [busy, setBusy] = useState(false); + const canRun = !disabled && !busy && !!(folderId || '').trim(); + + const run = useCallback(async () => { + setBusy(true); + try { + const res = await apiCore.post('/cl2k-maker/test-drive', { + gdrive_folder_id: folderId, + }); + toast.success(res?.message || 'Upload works'); + } catch (e) { + toast.error(e?.message || 'Upload test failed'); + } finally { + setBusy(false); + } + }, [folderId, toast]); + + return ( + + ); +}; + +// Splits ONE parent Drive into the community artwork layout. Opt-in per row — +// a Drive that should stay flat simply never uses it, and nothing on Drive is +// moved or deleted either way. +const SplitSubfoldersButton = ({ folderId, disabled, onSplit }) => { + const toast = useToast(); + const [busy, setBusy] = useState(false); + const canRun = !disabled && !busy && !!(folderId || '').trim(); + + const run = useCallback(async () => { + setBusy(true); + try { + const res = await apiCore.post('/cl2k-maker/gdrive/type-subfolders', { + gdrive_folder_id: folderId, + }); + const subfolders = res?.data?.subfolders || []; + // Refuse to rewrite the row on a partial answer: a missing type or a + // blank folder_id would save a destination that silently uploads + // nothing (_drive_targets skips a blank id). + const usable = + CL2K_ART_TYPES.filter(t => t.value !== 'poster').every(t => + subfolders.some(s => s.image_type === t.value && (s.folder_id || '').trim()) + ) && subfolders.every(s => (s.folder_id || '').trim()); + if (!usable) throw new Error('Drive returned an incomplete set of subfolders'); + onSplit(subfolders); + toast.success(res?.message || 'Split into type subfolders'); + } catch (e) { + toast.error(e?.message || 'Could not create the type subfolders'); + } finally { + setBusy(false); + } + }, [folderId, toast, onSplit]); + + return ( + + ); +}; + +const DriveEntry = ({ + entry, + disabled, + onPatch, + onDelete, + onToggleType, + onSplit, + autoFocus, + clearFocus, +}) => { + const nameRef = useAutoFocus(autoFocus, clearFocus); + return ( +
+ onPatch({ name })} + onDelete={onDelete} + disabled={disabled} + nameRef={nameRef} + deleteLabel="Delete Drive upload" + /> +
+ onPatch({ folder_id: e.target.value })} + className="flex-1 min-w-0 h-[38px] px-3 bg-bg border border-border rounded-lg font-mono text-[12.5px] text-fg-muted placeholder:text-fg-dim truncate focus:ring-primary focus:outline-none transition-colors" + aria-label="Drive folder ID" + /> + + +
+ +
+ ); +}; + +export const Cl2kGdriveUploadsField = ({ value, onChange, disabled = false }) => { + const { entries, add, patch, remove, toggleType, focusIndex, setFocusIndex } = useEntryList( + value, + onChange, + { name: '', folder_id: '', types: [] } + ); + + useEffect(() => { + publishSaveLocations('gdrive_uploads', Array.isArray(value) ? value : []); + }, [value]); + + const clearFocus = useCallback(() => setFocusIndex(null), [setFocusIndex]); + + // The subfolders arrive from an async call, so the row must be re-found in + // the LATEST entries by its stable folder_id — a captured index could point + // at a different row (or stale array) after an add/remove mid-flight. + const entriesRef = useRef(entries); + useEffect(() => { + entriesRef.current = entries; + }, [entries]); + + // Replace the split row with one routed row per type. The parent row's own + // claimed types are dropped — they now live on the children — and any type + // it didn't claim is left unclaimed rather than silently switched on. + const splitRow = useCallback( + (folderId, subfolders) => { + const current = entriesRef.current; + const index = current.findIndex(e => e.folder_id === folderId); + if (index === -1) return; // the row was removed while we fetched + const parent = current[index]; + const claimed = parent.types || []; + const children = subfolders + // Only types the parent actually claimed. An unclaimed row splits + // into nothing and is left untouched by the empty-children return. + .filter(s => claimed.includes(s.image_type)) + .map(s => ({ + name: `${parent.name || 'Drive'} ${s.name}`.trim(), + folder_id: s.folder_id, + types: [s.image_type], + })); + if (children.length === 0) return; + onChange(current.flatMap((e, i) => (i === index ? children : [e]))); + }, + [onChange] + ); + + return ( +
+ 0 && ( + + ) + } + /> + {entries.length === 0 ? ( + + ) : ( +
+ {entries.map((entry, i) => ( + patch(i, changes)} + onDelete={() => remove(i)} + onToggleType={type => toggleType(i, type)} + onSplit={subfolders => splitRow(entry.folder_id, subfolders)} + autoFocus={focusIndex === i} + clearFocus={clearFocus} + /> + ))} +
+ )} +
+ +

+ Uploads use your Sync GDrive OAuth token — set one under Sync GDrive. A service + account can't own files in a personal Drive, so it has no usable upload + path. +

+
+
+ ); +}; + +// ─── Coverage strip ────────────────────────────────────────────────────────── + +export const Cl2kCoverageField = ({ rootConfig }) => { + const lists = useSyncExternalStore(subscribeCoverage, coverageSnapshot); + // Before the list fields' first publish (initial mount), fall back to the + // loaded config. + const localFolders = lists.local_folders ?? rootConfig?.cl2k_maker?.local_folders ?? []; + const gdriveUploads = lists.gdrive_uploads ?? rootConfig?.cl2k_maker?.gdrive_uploads ?? []; + + return ( +
+

+ Types nobody claims aren't auto-saved — still downloadable from the maker page. +

+
+ {CL2K_ART_TYPES.map(t => { + const count = + localFolders.filter(f => (f?.types || []).includes(t.value)).length + + gdriveUploads.filter(d => (d?.types || []).includes(t.value)).length; + const covered = count > 0; + return ( +
+
+ {COVERAGE_LABELS[t.value]} +
+ {covered ? ( +
+ {count}{' '} + {count === 1 ? 'location' : 'locations'} +
+ ) : ( +
+ + Download only +
+ )} +
+ ); + })} +
+ {/* Page footer note (spec: below the location sections, all options). A + field can't render outside its section card, so it closes this card. */} +
+ +

+ Anything not routed above isn't auto-saved — every generation stays + downloadable from the maker page. +

+
+
+ ); +}; diff --git a/frontend/src/extensions/cl2k/manifest.jsx b/frontend/src/extensions/cl2k/manifest.jsx new file mode 100644 index 00000000..67585068 --- /dev/null +++ b/frontend/src/extensions/cl2k/manifest.jsx @@ -0,0 +1,74 @@ +// CL2K poster maker — self-registration manifest (':full'-image extension). +// Discovered by src/extensions/index.js; see that file for the contract. +import React from 'react'; +import { FieldRegistry } from '../../components/fields/FieldRegistry.jsx'; +import { CL2K_MAKER_SCHEMA, CL2K_MAKER_MODULE_ENTRY } from './settings_schema.js'; +import { Cl2kAiTestField } from './AiConnectionTest.jsx'; +import { + Cl2kCoverageField, + Cl2kGdriveUploadsField, + Cl2kLocalFoldersField, +} from './SaveLocationsFields.jsx'; + +const Cl2kMakerPage = React.lazy(() => import('../../pages/poster/Cl2kMakerPage.jsx')); + +// Custom field types for the Save Locations redesign. Registered at module +// scope — manifests are eagerly imported at app init (src/extensions/index.js), +// so the types exist before ModuleSettingsPage first resolves them. +FieldRegistry.register('cl2k_local_folders', Cl2kLocalFoldersField); +FieldRegistry.register('cl2k_gdrive_uploads', Cl2kGdriveUploadsField); +FieldRegistry.register('cl2k_coverage', Cl2kCoverageField); +FieldRegistry.register('cl2k_ai_test', Cl2kAiTestField); + +export default { + routes: [ + { + path: 'poster/cl2k-maker', + pageName: 'CL2K Poster Maker', + pageDescription: + 'Build DAPS-named CL2K posters from TMDB/fanart art, .psd sources, or uploads', + Component: Cl2kMakerPage, + }, + ], + navChildren: [ + { + parentId: 'poster', + before: 'unmatched-assets', + item: { + id: 'cl2k-maker', + label: 'CL2K Poster Maker', + path: '/poster/cl2k-maker', + }, + }, + ], + settingsSchema: [{ after: 'border_replacerr', entry: CL2K_MAKER_SCHEMA }], + settingsModules: [{ after: 'border_replacerr', entry: CL2K_MAKER_MODULE_ENTRY }], + configModules: [{ after: 'border_replacerr', key: 'cl2k_maker' }], + capabilities: { + // Extra Unmatched Assets row action: jump into the maker with the row's + // ids prefilled. Shown whenever the row has ANY of tmdb/tvdb/imdb — a + // TVDB-only Sonarr show (no TMDB cross-link) still gets the link; the + // maker resolves a tmdb_id on entry when one exists. The optional + // `asset` arg (from the Additional-artwork view) maps the missing + // artwork type to the maker tab so it opens ready to build it. + 'unmatchedAssets.rowAction': (item, asset) => { + if (!(item.tmdb_id || item.tvdb_id || item.imdb_id)) return null; + const ASSET_TAB = { background: 'background', logo: 'logo', squareart: 'square' }; + const tab = ASSET_TAB[asset]; + return { + to: `/poster/cl2k-maker?${new URLSearchParams({ + ...(item.tmdb_id ? { tmdb_id: item.tmdb_id } : {}), + type: item._type, + title: item.title || '', + ...(item.year ? { year: item.year } : {}), + ...(item.tvdb_id ? { tvdb_id: item.tvdb_id } : {}), + ...(item.imdb_id ? { imdb_id: item.imdb_id } : {}), + ...(tab ? { asset: tab } : {}), + }).toString()}`, + title: tab ? 'Build this artwork in CL2K' : 'Make a CL2K poster', + ariaLabel: tab ? 'Build this artwork in CL2K' : 'Make a CL2K poster', + icon: 'wallpaper', + }; + }, + }, +}; diff --git a/frontend/src/extensions/cl2k/settings_schema.js b/frontend/src/extensions/cl2k/settings_schema.js new file mode 100644 index 00000000..32239de6 --- /dev/null +++ b/frontend/src/extensions/cl2k/settings_schema.js @@ -0,0 +1,248 @@ +// CL2K maker — Module Settings schema fragment. +// Spliced into SETTINGS_SCHEMA / SETTINGS_MODULES by manifest.jsx +// (anchored after border_replacerr, its position before the extension split). + +export const CL2K_MAKER_SCHEMA = { + key: 'cl2k_maker', + label: 'CL2K Maker', + // Config-only: posters are generated on-demand from the CL2K Poster Maker + // page, so there is no batch run. `runnable: false` hides the Run button + + // Dry-run (ModuleSettingsPage) and drops it from the Schedule and Dashboard. + runnable: false, + fields: [ + { + key: 'log_level', + label: 'Log Level', + type: 'dropdown', + options: ['debug', 'info'], + required: true, + description: + '"debug" prints per-poster art/logo resolution; "info" is the normal cron-friendly level.', + }, + // ─── Generation ──────────────────────────────────────────── + { + key: 'skip_existing', + label: 'Skip Existing', + type: 'check_box', + section: 'Generation', + description: + 'Skip items that already have a generated CL2K poster. The maker page’s force option overrides this per generation.', + }, + { + key: 'style', + label: 'Style Tag', + type: 'text', + section: 'Generation', + placeholder: 'CL2K', + description: 'poster_cache style tag recorded for generated posters.', + }, + { + key: 'priority', + label: 'Priority', + type: 'number', + section: 'Generation', + placeholder: '0', + description: 'poster_cache priority for generated posters (higher wins on match).', + }, + // ─── Save locations (routed cards + coverage, option 2a) ── + // Custom field types registered by manifest.jsx from + // SaveLocationsFields.jsx; each card renders its own description, add + // button, entry list and empty state. Nothing here is required — zero + // locations is valid (unrouted art stays downloadable from the maker + // page). + { + key: 'local_folders', + label: 'Local Folders', + type: 'cl2k_local_folders', + section: 'Local Folders', + required: false, + }, + { + key: 'gdrive_uploads', + label: 'Google Drives', + type: 'cl2k_gdrive_uploads', + section: 'Google Drives', + required: false, + }, + { + // Read-only, live-computed strip — carries no config value of its + // own (never calls onChange, so the key never lands in formData). + key: 'save_coverage', + label: 'Coverage', + type: 'cl2k_coverage', + section: 'Coverage', + required: false, + }, + // ─── Logo & text ─────────────────────────────────────────── + { + key: 'whiten_logo', + label: 'Whiten Logo', + type: 'check_box', + section: 'Logo & Text', + description: 'Recolor the clear logo to solid white (the CL2K look).', + }, + { + key: 'text_logo_fallback', + label: 'Text Logo Fallback', + type: 'check_box', + section: 'Logo & Text', + description: + 'When no clear logo is found on TMDB or fanart.tv, synthesize an ALL-CAPS typeset wordmark from the title. Long titles are balance-wrapped onto two/three lines to fill the logo box.', + }, + { + key: 'text_logo_stroke', + label: 'Text Logo Outline (px)', + type: 'number', + section: 'Logo & Text', + placeholder: '0', + description: + 'Outline width for the synthesized text wordmark; 0 = none (clean white, the CL2K default). A small value (~4) adds legibility over busy artwork.', + }, + { + key: 'language', + label: 'Language', + type: 'text', + section: 'Logo & Text', + placeholder: 'en', + description: 'ISO-639-1 language preferred for logo selection.', + }, + // ─── AI text removal ─────────────────────────────────────── + { + key: 'ai_provider', + label: 'AI Provider', + type: 'dropdown', + options: ['none', 'lama_sidecar', 'openai'], + section: 'AI Text Removal', + required: true, + description: + 'Inpainter used when "Remove text" is enabled with a brushed mask. "none" disables it; "lama_sidecar" is free/local; "openai" is paid.', + }, + { + // Only the LaMa sidecar uses an endpoint URL; OpenAI's endpoint is + // built in, so this is hidden for openai/none. + key: 'ai_endpoint', + label: 'AI Endpoint', + type: 'text', + section: 'AI Text Removal', + conditional: { + field: 'ai_provider', + condition: 'in', + value: ['lama_sidecar'], + }, + placeholder: 'http://:8418', + description: + 'Just your lama-sidecar container’s address — http://:, where is the sidecar container’s mapped port (not CHUB’s). CHUB adds the /api/v1/... paths for you.', + }, + { + key: 'api_key', + label: 'AI API Key', + type: 'password', + section: 'AI Text Removal', + conditional: { + field: 'ai_provider', + condition: 'in', + value: ['openai'], + }, + description: 'OpenAI token.', + }, + { + // Its own field, not api_key: switching providers used to overwrite + // whichever token was already there. + key: 'client_key', + label: 'Sidecar API Key', + type: 'password', + section: 'AI Text Removal', + conditional: { + field: 'ai_provider', + condition: 'in', + value: ['lama_sidecar'], + }, + description: + 'Optional: set this only if the sidecar runs with LAMA_API_KEY. CHUB sends it as X-API-Key.', + }, + { + key: 'ai_model', + label: 'AI Model', + type: 'text', + section: 'AI Text Removal', + conditional: { + field: 'ai_provider', + condition: 'in', + value: ['openai'], + }, + description: 'OpenAI model id (default gpt-image-1).', + }, + { + // Read-only probe (AiConnectionTest.jsx) — no config key of its own. + key: 'ai_connection_test', + label: 'Connection', + type: 'cl2k_ai_test', + section: 'AI Text Removal', + conditional: { + field: 'ai_provider', + condition: 'in', + value: ['lama_sidecar', 'openai'], + }, + }, + { + key: 'ai_prompt', + label: 'AI Prompt', + type: 'textarea', + section: 'AI Text Removal', + conditional: { + field: 'ai_provider', + condition: 'in', + value: ['openai'], + }, + description: 'Prompt sent to OpenAI when removing text.', + }, + { + key: 'ai_mask_dilate', + label: 'Mask Dilation (px)', + type: 'number', + section: 'AI Text Removal', + conditional: { + field: 'ai_provider', + condition: 'in', + value: ['lama_sidecar'], + }, + placeholder: '-1', + description: + 'How far the sidecar grows your brush strokes before erasing, so a logo’s anti-aliased fringe/glow goes too. -1 uses the sidecar’s default (5). Raise to 7–8 for glowing/beveled logos if a faint outline survives; lower to 2–3 if you brush generously.', + }, + { + key: 'ai_logo_upscale', + label: 'Upscale Small Logos', + type: 'check_box', + section: 'AI Text Removal', + conditional: { + field: 'ai_provider', + condition: 'in', + value: ['lama_sidecar'], + }, + description: + 'When an auto-sourced clear logo is too small for the poster’s logo box, upscale it 2–4x on the sidecar (Real-ESRGAN) instead of falling back to a plain text wordmark. Any sidecar failure quietly falls back to the old behaviour.', + }, + { + key: 'ai_timeout', + label: 'AI Timeout (s)', + type: 'number', + section: 'AI Text Removal', + conditional: { + field: 'ai_provider', + condition: 'not_equals', + value: 'none', + }, + placeholder: '300', + description: + 'Seconds to wait for the AI provider before giving up. The LaMa sidecar’s quality passes can take a few minutes on CPU for a large erase, so don’t set this too low.', + }, + ], +}; + +export const CL2K_MAKER_MODULE_ENTRY = { + name: 'CL2K Maker', + key: 'cl2k_maker', + description: + 'Generate DAPS-named CL2K posters from TMDB/fanart art, .psd sources, or uploads. Build posters on the CL2K Poster Maker page.', +}; diff --git a/frontend/src/extensions/gating.test.jsx b/frontend/src/extensions/gating.test.jsx new file mode 100644 index 00000000..171f323b --- /dev/null +++ b/frontend/src/extensions/gating.test.jsx @@ -0,0 +1,26 @@ +// The registry resolves availability once at module load; in this test env the +// /api/version fetch fails, so every extension must read as unavailable. +import { describe, expect, it } from 'vitest'; + +import { extensionCapability, extensionRoutes, withExtensionNavChildren } from './index.js'; + +describe('extension gating (no backend => fail lean)', () => { + it('reports no capabilities when nothing is enabled', () => { + expect(extensionCapability('unmatchedRowAction')).toBeNull(); + }); + + it('keeps every extension route mounted, swapped to the unavailable notice', () => { + const routes = extensionRoutes(); + // Both bundled extensions contribute at least one route each. + expect(routes.length).toBeGreaterThanOrEqual(2); + for (const route of routes) { + expect(route.Component.displayName ?? '').toMatch(/^ExtensionUnavailable\(/); + } + }); + + it('splices no nav children while unavailable', () => { + const sections = [{ items: [{ id: 'posters', children: [{ id: 'core-item' }] }] }]; + const out = withExtensionNavChildren(sections); + expect(out[0].items[0].children).toHaveLength(1); + }); +}); diff --git a/frontend/src/extensions/index.js b/frontend/src/extensions/index.js index b80a8d3a..7c11598e 100644 --- a/frontend/src/extensions/index.js +++ b/frontend/src/extensions/index.js @@ -22,10 +22,35 @@ // With no extension folders present (main) every aggregate below is empty // and the consumers render exactly as if this module did not exist. +import { makeUnavailableNotice } from './UnavailableNotice.jsx'; + const manifestModules = import.meta.glob('./*/manifest.jsx', { eager: true }); -const MANIFESTS = Object.values(manifestModules) - .map(mod => mod.default) - .filter(Boolean); +// './cl2k/manifest.jsx' -> 'cl2k'; the folder name IS the extension name the +// backend reports, so no per-manifest registration is needed. +const ALL = Object.entries(manifestModules) + .map(([path, mod]) => ({ name: path.split('/')[1], manifest: mod.default })) + .filter(entry => Boolean(entry.manifest)); + +async function fetchEnabledExtensions() { + const res = await fetch('/api/version', { credentials: 'same-origin' }); + if (!res.ok) return new Set(); + const body = await res.json(); + const names = body?.data?.extensions; + return new Set(Array.isArray(names) ? names : []); +} + +// Resolved once at module load (top-level await), before any consumer reads the +// registry — several consume it at import time, so reactivity can't gate them. +// Any failure or a 3s stall reads as "no extensions": fail lean, never broken. +const ENABLED = + ALL.length === 0 + ? new Set() + : await Promise.race([ + fetchEnabledExtensions().catch(() => new Set()), + new Promise(resolve => setTimeout(() => resolve(new Set()), 3000)), + ]); + +const MANIFESTS = ALL.filter(entry => ENABLED.has(entry.name)).map(entry => entry.manifest); /** * Splice anchored additions into a copy of `list`. @@ -50,7 +75,15 @@ export function spliceByAnchor(list, additions, keyOf) { } export function extensionRoutes() { - return MANIFESTS.flatMap(m => m.routes ?? []); + // Unavailable extensions keep their routes mounted so a deep link explains + // itself (":full image required") instead of falling into the 404 page. + return ALL.flatMap(({ name, manifest }) => + (manifest.routes ?? []).map(route => + ENABLED.has(name) + ? route + : { ...route, Component: makeUnavailableNotice(route.pageName) } + ) + ); } /** NAV_SECTIONS with every extension's navChildren spliced in. */ diff --git a/frontend/src/extensions/poster_self_heal/CoverageField.jsx b/frontend/src/extensions/poster_self_heal/CoverageField.jsx new file mode 100644 index 00000000..5ccd31b3 --- /dev/null +++ b/frontend/src/extensions/poster_self_heal/CoverageField.jsx @@ -0,0 +1,146 @@ +// Read-only Module Settings strip: which save locations the healer assesses for +// id/title/year drift. Carries no config value — it never calls onChange, so its +// key never lands in formData. Renders INSIDE ModuleSettingsPage's own section +// card, so it must not draw a second outer card. +import React, { useEffect, useState } from 'react'; +import { posterSelfHealAPI } from '../../utils/api/posterSelfHeal.js'; + +const TYPE_LABEL = { + poster: 'Poster', + logo: 'Logo', + background: 'Background', + squareart: 'Square Art', +}; + +const Icon = ({ name, className = '' }) => ( + +); + +const Pills = ({ types, muted }) => + types.length === 0 ? ( + nothing routed + ) : ( + + {types.map(t => ( + + {TYPE_LABEL[t] || t} + + ))} + + ); + +const Row = ({ icon, iconClass, name, value, types, muted }) => ( +
+ + {name || 'Unnamed'} + {value} + + + +
+); + +export const PosterSelfHealCoverageField = () => { + const [cov, setCov] = useState(null); + const [error, setError] = useState(''); + + useEffect(() => { + const controller = new AbortController(); + let active = true; + (async () => { + try { + const res = await posterSelfHealAPI.coverage({ signal: controller.signal }); + if (active) setCov(res?.data || null); + } catch (err) { + if (active && err?.name !== 'AbortError') + setError(err?.message || 'Could not read the healer’s coverage'); + } + })(); + return () => { + active = false; + controller.abort(); + }; + }, []); + + if (error) { + return

{error}

; + } + if (!cov) { + return

Reading save locations…

; + } + + const folders = cov.folders || []; + const drives = cov.drives || []; + const unrouted = cov.unrouted_types || []; + + if (!cov.available) { + return ( +

+ The CL2K Maker extension isn’t available, so nothing is being assessed. +

+ ); + } + + return ( +
+

+ Every run assesses these for id, title and year drift. They come straight from CL2K + Maker’s save locations — change them there and the next run follows. +

+ {folders.length === 0 && drives.length === 0 ? ( +

+ No save locations are configured, so the healer has nothing to assess. +

+ ) : ( +
+ {folders.map(f => ( + + ))} + {drives.map(d => ( + + ))} +
+ )} +

+ Folder tags show what CL2K saves there; Drive tags show which types are renamed in + that Drive. A folder claiming no types is still assessed. +

+ {unrouted.length > 0 && ( +

+ + + No Drive receives {unrouted.map(t => TYPE_LABEL[t] || t).join(', ')} art — + those heal locally only, so anyone syncing your Drive keeps the old names. + +

+ )} +
+ ); +}; + +export default PosterSelfHealCoverageField; diff --git a/frontend/src/extensions/poster_self_heal/CoverageField.test.jsx b/frontend/src/extensions/poster_self_heal/CoverageField.test.jsx new file mode 100644 index 00000000..52401fb5 --- /dev/null +++ b/frontend/src/extensions/poster_self_heal/CoverageField.test.jsx @@ -0,0 +1,115 @@ +/** + * The Poster Healer "Assessed locations" settings strip. + * + * The registry assertion matters: scripts/check-field-types.js only scans the + * three CORE schema files, so an extension field type that doesn't resolve fails + * silently at runtime instead of failing CI. + */ +import { render, screen, waitFor } from '@testing-library/react'; + +const mockAPI = { coverage: vi.fn() }; +vi.mock('../../utils/api/posterSelfHeal.js', () => ({ posterSelfHealAPI: mockAPI })); +vi.mock('react-router', () => ({ + Link: ({ children, ...rest }) => {children}, +})); + +const { PosterSelfHealCoverageField } = await import('./CoverageField.jsx'); +const { FieldRegistry } = await import('../../components/fields/FieldRegistry.jsx'); +const { POSTER_SELF_HEAL_SCHEMA } = await import('./settings_schema.js'); +await import('./manifest.jsx'); // registers the field type as a side effect + +const ok = data => ({ data }); + +describe('field type registration', () => { + it('registers the exact type the schema asks for', () => { + const field = POSTER_SELF_HEAL_SCHEMA.fields.find(f => f.key === 'assessed_locations'); + expect(field).toBeTruthy(); + // Identity, not truthiness: getField() returns an UnknownFieldType + // placeholder for an unregistered type, so a truthy check can never fail + // and a typo'd `type:` would sail through CI into a blank settings row. + expect(FieldRegistry.getField(field.type)).toBe(PosterSelfHealCoverageField); + }); +}); + +describe('PosterSelfHealCoverageField', () => { + it('lists the folders and Drives being assessed', async () => { + mockAPI.coverage.mockResolvedValue( + ok({ + available: true, + folders: [{ name: 'Logos', path: '/art/logos', types: ['logo'] }], + drives: [{ name: 'Logos', folder_id: 'LOGOS_ID', heals_types: ['logo'] }], + unrouted_types: [], + }) + ); + render(); + await waitFor(() => expect(screen.getByText('/art/logos')).toBeInTheDocument()); + expect(screen.getByText('LOGOS_ID')).toBeInTheDocument(); + }); + + it('warns when a type reaches no Drive', async () => { + mockAPI.coverage.mockResolvedValue( + ok({ + available: true, + folders: [{ name: 'Local', path: '/art', types: ['squareart'] }], + drives: [], + unrouted_types: ['squareart'], + }) + ); + render(); + await waitFor(() => expect(screen.getByText(/No Drive receives/)).toBeInTheDocument()); + expect(screen.getByText(/keeps the old names/)).toBeInTheDocument(); + }); + + it('shows a folder that claims no types — it is still assessed', async () => { + mockAPI.coverage.mockResolvedValue( + ok({ + available: true, + folders: [{ name: 'Inert', path: '/art/orphans', types: [] }], + drives: [], + unrouted_types: [], + }) + ); + render(); + await waitFor(() => expect(screen.getByText('/art/orphans')).toBeInTheDocument()); + expect(screen.getByText('nothing routed')).toBeInTheDocument(); + }); + + it('says so when nothing is configured', async () => { + mockAPI.coverage.mockResolvedValue( + ok({ available: true, folders: [], drives: [], unrouted_types: [] }) + ); + render(); + await waitFor(() => expect(screen.getByText(/nothing to assess/)).toBeInTheDocument()); + }); + + it('reports a failure instead of rendering an empty strip', async () => { + mockAPI.coverage.mockRejectedValue(new Error('boom')); + render(); + await waitFor(() => expect(screen.getByText('boom')).toBeInTheDocument()); + }); + + it('stays silent when the request is aborted on unmount', async () => { + const abort = new Error('aborted'); + abort.name = 'AbortError'; + mockAPI.coverage.mockRejectedValue(abort); + render(); + // An abort is not a failure to show the user — the loading line stays. + await waitFor(() => expect(screen.getByText(/Reading save locations/)).toBeInTheDocument()); + }); + + it('passes an AbortSignal so unmount can cancel', async () => { + mockAPI.coverage.mockResolvedValue( + ok({ available: true, folders: [], drives: [], unrouted_types: [] }) + ); + const { unmount } = render(); + await waitFor(() => expect(mockAPI.coverage).toHaveBeenCalled()); + // Last call, not first: correct today because restoreMocks clears state + // between tests, but this stays right if that config ever changes. + const calls = mockAPI.coverage.mock.calls; + const passed = calls[calls.length - 1][0]; + expect(passed.signal).toBeInstanceOf(AbortSignal); + expect(passed.signal.aborted).toBe(false); + unmount(); + expect(passed.signal.aborted).toBe(true); + }); +}); diff --git a/frontend/src/extensions/poster_self_heal/manifest.jsx b/frontend/src/extensions/poster_self_heal/manifest.jsx new file mode 100644 index 00000000..ca50dab2 --- /dev/null +++ b/frontend/src/extensions/poster_self_heal/manifest.jsx @@ -0,0 +1,45 @@ +// Poster Self-Heal — self-registration manifest (':full'-image extension). +// Discovered by src/extensions/index.js; see that file for the contract. +import React from 'react'; +import { FieldRegistry } from '../../components/fields/FieldRegistry.jsx'; +import { POSTER_SELF_HEAL_SCHEMA, POSTER_SELF_HEAL_MODULE_ENTRY } from './settings_schema.js'; +import { PosterSelfHealCoverageField } from './CoverageField.jsx'; + +// Registered at module scope — manifests are eagerly imported at app init, so +// the type exists before ModuleSettingsPage first resolves it. +FieldRegistry.register('poster_self_heal_coverage', PosterSelfHealCoverageField); + +const PosterHealReviewPage = React.lazy( + () => import('../../pages/poster/PosterHealReviewPage.jsx') +); + +export default { + routes: [ + { + path: 'poster/heal-review', + pageName: 'Poster Healer Review', + pageDescription: + 'Review and apply proposed id / title / year fixes for your CL2K posters', + Component: PosterHealReviewPage, + }, + ], + // Without this the page is reachable ONLY from a badge on the CL2K Maker page + // that renders when the open count is non-zero — so a run that reports work + // left for review can point at a page with no way to reach it. + navChildren: [ + { + parentId: 'poster', + before: 'unmatched-assets', + item: { + id: 'poster-heal-review', + label: 'Poster Healer Review', + path: '/poster/heal-review', + }, + }, + ], + // Anchored after a core module (always present) rather than cl2k_maker, so it + // doesn't depend on another extension's splice having run first. + settingsSchema: [{ after: 'border_replacerr', entry: POSTER_SELF_HEAL_SCHEMA }], + settingsModules: [{ after: 'border_replacerr', entry: POSTER_SELF_HEAL_MODULE_ENTRY }], + configModules: [{ after: 'border_replacerr', key: 'poster_self_heal' }], +}; diff --git a/frontend/src/extensions/poster_self_heal/settings_schema.js b/frontend/src/extensions/poster_self_heal/settings_schema.js new file mode 100644 index 00000000..a6c28809 --- /dev/null +++ b/frontend/src/extensions/poster_self_heal/settings_schema.js @@ -0,0 +1,60 @@ +// poster_self_heal — Module Settings schema fragment. +// Spliced into SETTINGS_SCHEMA / SETTINGS_MODULES by manifest.jsx. +// Runnable (no runnable:false): it runs on a schedule to detect drift. + +export const POSTER_SELF_HEAL_SCHEMA = { + key: 'poster_self_heal', + label: 'Poster Healer', + // The healer can't resolve canonical ids/titles/years without TMDB — warn on + // the config page when no key is set (ModuleSettingsPage reads `requires`). + requires: [ + { + field: 'tmdb.apikey', + message: + 'The Poster Healer needs a TMDB API key to resolve canonical ids, titles, and years — without one, runs will do nothing.', + linkTo: '/settings/general', + linkText: 'Set it in General settings', + }, + ], + fields: [ + { + // Read-only, live-computed — carries no config value of its own + // (never calls onChange, so the key never lands in formData). + key: 'assessed_locations', + label: 'Assessed locations', + type: 'poster_self_heal_coverage', + section: 'Assessed locations', + required: false, + }, + { + key: 'log_level', + label: 'Log Level', + type: 'dropdown', + options: ['debug', 'info'], + required: true, + description: + '"debug" logs every poster’s resolution; "info" is the normal cron-friendly level.', + }, + { + key: 'backfill_ids', + label: 'Backfill missing IDs', + type: 'check_box', + description: + 'Also ADD resolved {tmdb-…}/{tvdb-…}/{imdb-…} ids to a poster that has none — not just correct posters that already carry an id. The source drive (CL2K output dir + Google Drive folder) comes from the CL2K Maker settings.', + }, + { + key: 'auto_apply', + label: 'Auto-apply confident fixes', + type: 'check_box', + description: + 'Apply confident fixes automatically during the run (rename on Google Drive + locally) instead of waiting for manual review. Ambiguous matches — where a title matches more than one library item — always go to the review page regardless.', + }, + ], +}; + +export const POSTER_SELF_HEAL_MODULE_ENTRY = { + name: 'Poster Healer', + key: 'poster_self_heal', + description: + 'Keep your CL2K poster drive current — re-resolve each generated poster against TMDB and propose fixing stale ids, titles, and years, applied after manual review.', +}; diff --git a/frontend/src/pages/poster/Cl2kMakerPage.jsx b/frontend/src/pages/poster/Cl2kMakerPage.jsx new file mode 100644 index 00000000..7e2e9a80 --- /dev/null +++ b/frontend/src/pages/poster/Cl2kMakerPage.jsx @@ -0,0 +1,7096 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Link, useSearchParams } from 'react-router'; + +import { cl2kMakerAPI } from '../../utils/api/cl2k_maker.js'; +import { configAPI } from '../../utils/api/config.js'; +import { postersAPI } from '../../utils/api/posters.js'; +import { posterSelfHealAPI } from '../../utils/api/posterSelfHeal.js'; +import { streamTokenParam, ensureStreamToken } from '../../utils/api/streamAuth.js'; +import { useStreamToken } from '../../hooks/useStreamToken.js'; +import { useToast } from '../../contexts/ToastContext.jsx'; +import { Button, LoadingButton, PageHeader, Toggle } from '../../components/ui/index.js'; +import SegmentedControl from '../../components/ui/SegmentedControl.jsx'; +import Spinner from '../../components/ui/Spinner.jsx'; +import { StyleStamp } from '../../components/ui/StyleStamp.jsx'; + +// Small self-contained badge: shows "N posters need review" linking to the +// Poster Healer review page, only when the healer has open proposals. Lives here +// so the CL2K page surfaces healer work without threading state through the page. +const HealReviewLink = () => { + const [count, setCount] = useState(0); + useEffect(() => { + posterSelfHealAPI + .count() + .then(r => setCount(r?.data?.count || 0)) + .catch(() => {}); + }, []); + if (!count) return null; + return ( + + {count} poster{count === 1 ? '' : 's'} need review → + + ); +}; + +/** + * CL2K Poster Maker. + * + * A 4-stage flow that turns a TMDB/TVDB/IMDB title into a DAPS-named CL2K + * poster: + * 1. Pick title — TMDB search, ID/URL paste, or a deep link from Unmatched + * Assets (?tmdb_id=&type=&title=). + * 2. Source — where the artwork comes from: TMDB, fanart.tv, a manually + * cleaned backdrop, a finished poster used as-is, or a Drive .psd. + * 3. Build + preview — art/logo picker, optional AI text-removal driven by a + * brushed mask, and a live preview. + * 4. Output — generate (write + cache), export a layered .psd, download the + * backdrop for an external clean-up handoff, or browse history. + * + * Non-visual knobs (logo width, whiten, language, AI provider, save locations) + * live in Module Settings → CL2K Maker; this page reads them and links back to + * edit. Saves route by artwork type: every configured local folder / Drive that + * claims the type gets a copy; nothing routed = downloadable only. + */ + +const KIND_OPTIONS = [ + { value: 'movie', label: 'Movie' }, + { value: 'show', label: 'Show' }, + { value: 'collection', label: 'Collection' }, +]; + +// CL2K bottom-banner labels (from the template). Empty = no banner / auto label. +// "Specials" is NOT here — it's a season (0), made via the Season box, which both +// files as `- Specials` and renders the SPECIALS label. +const BAND_LABEL_OPTIONS = [ + { value: '', label: 'None' }, + { value: 'COMPLETE LIMITED SERIES', label: 'Complete Limited Series' }, +]; + +// Wordmark logos first. TMDB's "logos" array is polluted with portrait +// character art (full-body Goku/Vegeta/etc. renders) that whiten into line-art +// garbage and render tiny in the landscape CL2K logo zone. Real title wordmarks +// are wide (aspect ≳ 1.4); push tall/portrait images to the end. Stable within +// each group; unknown-dimension logos (fanart/Plex clearlogos — already proper +// wordmarks) rank with the wordmarks. Hides nothing — just orders. Shared by the +// Builder source pickers and the merged asset-tab lists. +const sortWordmarkFirst = logos => + logos + .map((l, i) => [l, i]) + .sort(([a, ai], [b, bi]) => { + const score = x => (x.width && x.height && x.width / x.height < 1.4 ? 1 : 0); + return score(a) - score(b) || ai - bi; + }) + .map(([l]) => l); + +// Top-level tabs = WHAT you're building. Sources (TMDB/fanart/Plex/Upload) are +// chosen per-picker inside each page (see SourceSelector). +const BUILD_TABS = [ + { key: 'poster', label: 'Poster', icon: 'image', ar: '2:3' }, + { key: 'background', label: 'Background', icon: 'wallpaper', ar: '16:9' }, + { key: 'square', label: 'Square art', icon: 'crop_square', ar: '1:1' }, + { key: 'logo', label: 'Logo', icon: 'sell', ar: 'logo' }, +]; +// Retired tab keys (source-as-tab, the Finished-poster tab, and the removed +// Edit-poster / upload-backdrop tabs) → the unified 'poster' build tab, so a +// saved session migrates instead of landing on a tab that no longer exists. +// Editing a finished poster — G-Drive pick or manual upload — all lives in the +// Poster tab now, so there's no separate Edit-poster tab. +const TAB_MIGRATE = { + tmdb: 'poster', + fanart: 'poster', + plex: 'poster', + 'upload-poster': 'poster', + 'upload-backdrop': 'poster', + edit: 'poster', +}; + +// Per-picker artwork sources (Option A: a segmented row in each picker header). +// 'upload' swaps the grid for that picker's custom-upload control. +const ART_SOURCES = [ + { key: 'tmdb', label: 'TMDB', icon: 'movie' }, + { key: 'fanart', label: 'fanart', icon: 'palette' }, + { key: 'plex', label: 'Plex', icon: 'live_tv' }, + { key: 'upload', label: 'Upload', icon: 'upload' }, +]; + +// The Poster tab's backdrop picker and the Logo Asset extract-from-poster picker +// add a 'gdrive' source (browse the GDrive sync cache) on top of ART_SOURCES — +// both grab a FINISHED poster, which is meaningless for the logo / background / +// square pickers, so those stay on ART_SOURCES. +const BACKDROP_SOURCES = [...ART_SOURCES, { key: 'gdrive', label: 'GDrive', icon: 'cloud_sync' }]; + +// Why an AI erase can't run, or null when it can. Mirrors the backend's +// unavailable_reason() case for case — keep them in step. api_key reads back as +// '********' when set (redacted) and '' when unset. +export const aiUnavailableReason = config => { + const provider = config?.ai_provider || 'none'; + if (provider === 'none') + return 'AI provider is “none” — enable one in Module Settings or this has no effect.'; + if (provider === 'openai') + return config?.api_key + ? null + : 'No API key set for this provider — add one in Module Settings.'; + if (provider === 'lama_sidecar') + return config?.ai_endpoint + ? null + : 'No AI Endpoint set — add your LaMa container URL in Module Settings.'; + return `Unknown AI provider “${provider}” — choose one in Module Settings.`; +}; + +// Detection is sidecar-only: /detect-text rejects every other provider, so the +// button has to be withheld rather than offered and failed. +export const lamaDetectReady = config => + config?.ai_provider === 'lama_sidecar' && !aiUnavailableReason(config); + +// The logo picker gets 'gdrive' too, browsing the sync cache for `- logo` assets +// (image_type=logo) rather than finished posters. A gdrive_list folder that sits +// outside every renamer source_dir — an "Extras"/assets drive — is indexed +// search_only=1, so it is pickable here without becoming a poster-match +// candidate. Nothing hits Drive at browse time: sync_gdrive rclones the folder +// to disk and poster_cache indexes it, so this reads local files. +const LOGO_SOURCES = [...ART_SOURCES, { key: 'gdrive', label: 'GDrive', icon: 'cloud_sync' }]; + +// Stable identity for an uploaded image ({ b64, name }) in change-detection +// signatures. A b64-prefix slice can collide: the first ~24 bytes of a JPEG are +// a format header that different files from the same exporter share. +// Identity of an uploaded asset, for keying masks/previews to the image they were +// made for. Name+length alone collides (same filename is normal), so sample the +// encoded head and tail too — for real image data those carry the header fields +// and the trailing checksum. +const customSig = c => + c + ? `${c.name}:${c.b64?.length ?? 0}:${(c.b64 ?? '').slice(0, 32)}:${(c.b64 ?? '').slice(-32)}` + : null; + +/** Live-mount flag for the background season polls. */ +// Set (not just cleared) on mount: a StrictMode double-mount runs the cleanup +// first, which would otherwise leave the flag stuck false for the real mount. +const useMountedRef = () => { + const ref = useRef(true); + useEffect(() => { + ref.current = true; + return () => { + ref.current = false; + }; + }, []); + return ref; +}; + +/** Poll a season batch to its terminal status; null = the caller unmounted. */ +// The batch outlives a request timeout, so it is polled rather than awaited. +// Both callers share this so the mounted re-check AFTER each request — the one +// that stops a post-unmount progress update — can't drift between them. +const pollSeasonsBatch = async (jobId, mountedRef, onProgress) => { + // Transient poll errors are tolerated, but a run of them (job evicted / + // server gone) throws, so the button never sticks in a spinner forever. + let fails = 0; + while (true) { + await new Promise(r => setTimeout(r, 1500)); + if (!mountedRef.current) return null; + let d; + try { + d = (await cl2kMakerAPI.seasonsStatus(jobId))?.data; + } catch { + if (++fails >= 10) throw new Error('Lost contact with the season job'); + continue; + } + if (!mountedRef.current) return null; // unmounted while that request ran + if (!d) { + if (++fails >= 10) throw new Error('Lost contact with the season job'); + continue; + } + fails = 0; + onProgress(`${d.done}/${d.total}`); + if (d.status === 'done' || d.status === 'error') return d; + } +}; + +/** Terminal toast for a season batch, including the partial-failure case. */ +// `outcome` / `failed` are additive backend fields — an older payload omits them +// and falls through to the plain success line. +const seasonsBatchToast = (toast, d) => { + if (d?.status === 'error') { + toast.error(d?.error || 'Season generation failed'); + return; + } + const failed = d?.failed ?? 0; + if (d?.outcome === 'partial' && failed > 0) { + toast.warning( + `Seasons: ${d?.generated ?? 0}/${d?.total ?? 0} generated — ${failed} failed` + ); + return; + } + toast.success(`Seasons: ${d?.generated ?? 0}/${d?.total ?? 0} generated`); +}; + +const SourceSelector = ({ value, onChange, sources = ART_SOURCES }) => ( +
+ {sources.map(s => ( + + ))} +
+); + +// Map a deep-link / paste media type onto the kind strings the maker uses. +const normalizeKind = t => { + const v = (t || '').toLowerCase(); + if (v === 'movie') return 'movie'; + if (v === 'collection') return 'collection'; + if (v === 'tv' || v === 'series' || v === 'show') return 'show'; + return 'movie'; +}; + +// ─── ID / URL paste parsing ────────────────────────────────────────────── +// Accepts a bare id, an explicit `tvdb:` tag, or a TMDB / TVDB / IMDB url. +// Returns {source, id} where source is 'tmdb' (resolve not needed) or +// 'tvdb_id' / 'imdb_id' (needs resolve), or {error} for a recognised-but- +// unusable input (a slug-only thetvdb.com URL carries no numeric id). +const parsePastedId = raw => { + const s = (raw || '').trim(); + if (!s) return null; + const imdb = s.match(/(tt\d{6,})/i); + if (imdb) return { source: 'imdb_id', id: imdb[1] }; + const tmdbUrl = s.match(/themoviedb\.org\/(movie|tv|collection)\/(\d+)/i); + if (tmdbUrl) return { source: 'tmdb', id: tmdbUrl[2], type: normalizeKind(tmdbUrl[1]) }; + // Explicit tvdb tag: `tvdb:413715`, `tvdb-413715`, `tvdb 413715`, `tvdb_id=413715`. + const tvdbTag = s.match(/^tvdb(?:_id)?[\s:=-]+(\d+)$/i); + if (tvdbTag) return { source: 'tvdb_id', id: tvdbTag[1] }; + if (/thetvdb\.com/i.test(s)) { + // Numeric TVDB url forms only: ?id=/&seriesid=, or /series|/movies/. + // Modern slug urls (/series/) carry no number — flag those so the + // caller can tell the user to paste the numeric Series ID instead. + const tvdbUrl = s.match(/(?:[?&](?:id|seriesid)=|\/(?:series|movies)\/)(\d+)/i); + if (tvdbUrl) return { source: 'tvdb_id', id: tvdbUrl[1] }; + return { error: 'tvdb_slug' }; + } + if (/^\d+$/.test(s)) return { source: 'tmdb', id: s }; + return null; +}; + +// ─── In-progress state persistence ─────────────────────────────────────────── +// The page component unmounts on navigation, dropping all React state — so the +// poster you were building vanishes when you leave and come back. Persist the +// selected title + builder selections in sessionStorage (survives route changes, +// clears on tab close) so returning restores the work. +const SS_ITEM = 'cl2k:item'; +const SS_SELKEY = 'cl2k:selKey'; +const SS_BUILDER = 'cl2k:builder'; + +const ssRead = (key, fallback) => { + try { + const raw = sessionStorage.getItem(key); + return raw == null ? fallback : JSON.parse(raw); + } catch { + return fallback; + } +}; +const ssWrite = (key, value) => { + try { + sessionStorage.setItem(key, JSON.stringify(value)); + } catch { + /* sessionStorage unavailable (private mode / quota) — skip */ + } +}; +const ssRemove = key => { + try { + sessionStorage.removeItem(key); + } catch { + /* noop */ + } +}; + +// Fetch TMDB external ids for a picked title and merge tvdb_id/imdb_id in where +// they're not already set, so filenames/matching are right without manual entry. +// Collections have no external ids; a lookup failure leaves the item untouched. +const withExternalIds = async base => { + // Any id will do — the endpoint resolves tvdb/imdb to a tmdb id itself. + if (base?.kind === 'collection') return base; + if (!base?.tmdb_id && !base?.tvdb_id && !base?.imdb_id) return base; + if (base.tvdb_id && base.imdb_id) return base; + try { + const resp = await cl2kMakerAPI.externalIds(base.tmdb_id, base.kind, { + tvdbId: base.tvdb_id, + imdbId: base.imdb_id, + }); + const ext = resp?.data || {}; + return { + ...base, + tvdb_id: base.tvdb_id ?? (ext.tvdb_id || null), + imdb_id: base.imdb_id ?? (ext.imdb_id || null), + }; + } catch { + return base; + } +}; + +// ─── Logo placement geometry (mirrors renderer._place_logo) ────────────────── +// Same maths the backend uses to size + bottom-align the clear logo, so a CSS +// overlay drawn with /logo-processed bytes lands exactly where a render would. +// All px are on the locked 1000×1500 CL2K canvas. +const CL2K_CANVAS_W = 1000; +const CL2K_CANVAS_H = 1500; +// Bottom baselines verified against the PSDs in refs/ (template + finished +// posters all bottom-align their LOGO layer at exactly these guides). +const CL2K_LOGO_BASELINE_MAIN = 1352; // geo.MAIN_LOGO_BOTTOM ("Main Logo Bottom") +const CL2K_LOGO_BASELINE_COLLECTION = 1319; // geo.COLLECTION_LOGO_BOTTOM +const CL2K_LOGO_WIDTH_MAX = 800; // geo.LOGO_WIDTH_MAX (guide line, not a clamp) +const CL2K_LOGO_ZONE_TOP = 1100; // geo.LOGO_ZONE_TOP ("Main Logo Height") +const CL2K_BORDER_WIDTH = 25; // geo.BORDER_WIDTH (PSD stroke, Style=Inside) +// Slider/clamp ranges — mirror of geometry.py's interactive control ranges (the +// backend pydantic Field/Form ge/le validate against the same numbers). Keep in +// sync with backend/util/cl2k/geometry.py. +const CONTROL_RANGES = { + logoScale: { min: 0.25, max: 3 }, // geo.LOGO_SCALE_MIN/MAX + logoYOffset: { min: -600, max: 200 }, // geo.LOGO_Y_OFFSET_MIN/MAX + zoom: { min: 0.5, max: 3 }, // geo.ZOOM_MIN/MAX + vPos: { min: -1, max: 1 }, // geo.V_POS_MIN/MAX — 0 = centred +}; + +const clampVPos = v => + Math.max(CONTROL_RANGES.vPos.min, Math.min(Number(v) || 0, CONTROL_RANGES.vPos.max)); + +// v_pos (-1..1, 0 = centred) <-> the 0..1 source fraction the crop-box overlays +// draw with. `hF` is the kept box's height as a fraction of the scaled source, so +// travel is the real slack — (1 - hF) / 2 — not half the whole source. +// The two directions are NOT symmetric in cover mode: _cover_resize pans down +// through the source AND up to black_allow past its bottom edge, edge-extended +// into the gradient. `band` mirrors that; paths that crop through _v_pos_top +// (render_framed_art, and _cover_resize's own zoom-out branch) are symmetric and +// leave it 0. A 16:9 backdrop at zoom 1 is the case that makes this matter: it +// cover-fills to exactly the canvas, so hF is 1 and the ONLY travel it has is the +// band. +const COVER_EXTEND_BAND = 0.3; // renderer._cover_resize: black_allow = 0.30 * CANVAS_H +const vPosClampHF = hF => Math.min(1, Math.max(0, hF || 0)); +const vPosTravelUp = hF => (1 - vPosClampHF(hF)) / 2; +const vPosTravelDown = (hF, band = 0) => vPosTravelUp(hF) + band * vPosClampHF(hF); +const vPosToFrac = (vPos, hF, band = 0) => { + const v = clampVPos(vPos); + return 0.5 + v * (v < 0 ? vPosTravelUp(hF) : vPosTravelDown(hF, band)); +}; +// Inverse, for turning a drag on the crop box back into v_pos. A drag moves the +// BOX, so it may only address travel that is ON the image: clamped to the box's +// reachable centre range, which leaves the extend `band` to the slider. null = +// no source travel at all (a 16:9 backdrop at zoom 1 cover-fills exactly, so the +// band is its ONLY travel) — the caller must then keep the user's v_pos, NOT +// drive it from a horizontal drag. +const VPOS_TRAVEL_EPS = 1e-4; // sub-pixel travel is no travel (a near-2:3 source) +const fracToVPos = (frac, hF, band = 0) => { + // Unmeasured box (ratio not loaded): hF is undefined, which clamps to 0 and + // would read as FULL travel — an early drag would then invent a v_pos. + if (!Number.isFinite(hF)) return null; + const up = vPosTravelUp(hF); + if (up <= VPOS_TRAVEL_EPS) return null; + const d = Math.max(-up, Math.min(frac - 0.5, up)); + if (d === 0) return 0; + return clampVPos(d / (d < 0 ? up : vPosTravelDown(hF, band))); +}; + +// Kept region of the source (fractions) under the 2:3 cover fill — mirror of +// _cover_resize's scale + crop. Shared by the crop overlay and the slider bounds +// so they can't disagree about how much travel exists. +const coverKeep = (ratio, zoom) => { + const target = 2 / 3; + const z = Math.max(CONTROL_RANGES.zoom.min, Math.min(zoom || 1, CONTROL_RANGES.zoom.max)); + const wide = 1 / ratio > target; + const rawW = wide ? (target * ratio) / z : 1 / z; + const rawH = wide ? 1 / z : 1 / target / ratio / z; + // Either raw fraction over 1 means the art no longer fills the canvas — + // _cover_resize's zoom-out branch, which crops through the symmetric + // _v_pos_top and so has no extend band. + return { + wF: Math.min(1, rawW), + hF: Math.min(1, rawH), + band: rawW <= 1 && rawH <= 1 ? COVER_EXTEND_BAND : 0, + }; +}; + +// Same for the asset frames (render_framed_art): fit scales the source INTO the +// frame, cover fills it, and neither can hide an extend band. +const framedKeep = (ratio, zoom, aspect, fitMode) => { + const z = Math.max(CONTROL_RANGES.zoom.min, Math.min(zoom || 1, CONTROL_RANGES.zoom.max)); + const base = fitMode === 'fit' ? Math.min(1, aspect / ratio) : Math.max(1, aspect / ratio); + const s = base * z; + return { wF: Math.min(1, 1 / s), hF: Math.min(1, aspect / (ratio * s)), band: 0 }; +}; + +// Slider bounds for a kept region: a direction with no travel collapses to 0, so +// the control can't offer a range the renderer will ignore. `dead` = no travel at +// all, which is a disabled slider rather than a 0..0 one. +const vPosBounds = (hF, band = 0) => { + const min = vPosTravelUp(hF) > VPOS_TRAVEL_EPS ? CONTROL_RANGES.vPos.min : 0; + const max = vPosTravelDown(hF, band) > VPOS_TRAVEL_EPS ? CONTROL_RANGES.vPos.max : 0; + return { min, max, dead: min === 0 && max === 0 }; +}; +const clampToBounds = (v, { min, max }) => + Math.max(min, Math.min(Number.isFinite(Number(v)) ? Number(v) : 0, max)); +// A collapsed direction is state, not a broken control — say which and why. +// `oneSided`: the poster's fit/extend renderers anchor from the TOP over 0..1, so +// negative is meaningless there by design rather than for want of source. +const vPosTip = ({ min, dead }, { oneSided = false, frame = 'crop' } = {}) => { + if (dead) + return `No vertical travel at this zoom — the art no longer fills the ${frame}, so the renderer centres it. Raise Zoom to pan.`; + if (oneSided) return `Positions the fitted photo from the top of the ${frame}.`; + if (min === 0) + return `Up needs source above the ${frame}: at this zoom the kept region already fills its height. Raise Zoom past 1x for negative travel.`; + // No down-only case to handle: down travel is up + the band, so losing it + // means losing both, which `dead` already caught. + return 'Slides the framing at the same size. Positive continues past the photo’s bottom edge into the gradient.'; +}; + +const cl2kLogoBaseline = kind => + (kind || '').toLowerCase() === 'collection' + ? CL2K_LOGO_BASELINE_COLLECTION + : CL2K_LOGO_BASELINE_MAIN; + +// natW/natH = trimmed logo dims (from /logo-processed); boxW/boxH = the box the +// backend computed for them (geo.auto_logo_size — what _place_logo uses, so the +// overlay matches the render). maxWidth = flat-guide fallback, over-sizes wide +// logos ~15%. baseline = the kind's bottom guide (geo.logo_baseline mirror). +// Returns the overlay box as percentages of the 2:3 preview, or null. +const logoBoxPct = ({ natW, natH, boxW, boxH, maxWidth, scale = 1, yOffset = 0, baseline }) => { + if (!natW || !natH) return null; + const base = baseline || CL2K_LOGO_BASELINE_MAIN; + const s = Math.max( + CONTROL_RANGES.logoScale.min, + Math.min(scale || 1, CONTROL_RANGES.logoScale.max) + ); + const off = Math.max( + CONTROL_RANGES.logoYOffset.min, + Math.min(Math.round(yOffset || 0), CONTROL_RANGES.logoYOffset.max) + ); + let targetW; + let targetH; + if (boxW > 0 && boxH > 0) { + targetW = boxW; + targetH = boxH; + } else { + targetW = Math.min(maxWidth || 700, CL2K_LOGO_WIDTH_MAX); + targetH = Math.round((natH * targetW) / natW); + const maxH = base - CL2K_LOGO_ZONE_TOP; + if (targetH > maxH) { + targetH = maxH; + targetW = Math.round((natW * targetH) / natH); + } + } + // Scale the guide-fit box as a whole; keep it on the canvas (aspect kept) — + // mirrors _place_logo so the overlay still lands pixel-exact. The width + // guides are guidelines only, not a clamp. + targetW = Math.round(targetW * s); + targetH = Math.round(targetH * s); + if (targetW > CL2K_CANVAS_W) { + targetH = Math.round((targetH * CL2K_CANVAS_W) / targetW); + targetW = CL2K_CANVAS_W; + } + if (targetH > CL2K_CANVAS_H) { + targetW = Math.round((targetW * CL2K_CANVAS_H) / targetH); + targetH = CL2K_CANVAS_H; + } + let top = base - targetH + off; + top = Math.max(0, Math.min(top, CL2K_CANVAS_H - targetH)); + const left = CL2K_CANVAS_W / 2 - targetW / 2; + return { + left: (left / CL2K_CANVAS_W) * 100, + top: (top / CL2K_CANVAS_H) * 100, + width: (targetW / CL2K_CANVAS_W) * 100, + height: (targetH / CL2K_CANVAS_H) * 100, + }; +}; + +// Live logo drawn over the logo-less preview base. Moves instantly with the +// size/position sliders — no server render per drag. `logo` = { dataUrl, width, +// height, boxW, boxH, maxWidth } from /logo-processed; `kind` picks the bottom +// baseline (collection logos sit on the higher 1319 guide). +const LogoOverlay = ({ logo, scale, yOffset, kind }) => { + if (!logo?.dataUrl) return null; + const box = logoBoxPct({ + natW: logo.width, + natH: logo.height, + boxW: logo.boxW, + boxH: logo.boxH, + maxWidth: logo.maxWidth, + scale, + yOffset, + baseline: cl2kLogoBaseline(kind), + }); + if (!box) return null; + return ( + + ); +}; + +// Dim + spinner over a stale preview while its replacement renders server-side. +// Without this the old image just sits there until the new one pops in, which +// reads as "the slider did nothing". Inline styles — must not depend on utility +// classes existing. +const PreviewRefreshing = ({ active }) => { + if (!active) return null; + return ( + + ); +}; + +const Cl2kMakerPage = () => { + // Keep every proxied Plex-art /fetch URL fresh as the stream token rotates. + useStreamToken(); + const toast = useToast(); + const [searchParams] = useSearchParams(); + + const [config, setConfig] = useState(null); + // Drive-upload status (enabled + has a usable OAuth token) for the banner warning. + const [uploadStatus, setUploadStatus] = useState(null); + + // Selected item: { tmdb_id, kind, title, year, tvdb_id, imdb_id } | null. + // Seeded from an Unmatched-Assets deep link + // (?tmdb_id=&type=&title=&year=&tvdb_id=&imdb_id=) when present; otherwise + // restored from sessionStorage so an in-progress poster survives navigation. + const [item, setItem] = useState(() => { + const tmdbId = searchParams.get('tmdb_id'); + const tvdbId = searchParams.get('tvdb_id'); + const imdbId = searchParams.get('imdb_id'); + // A deep link carries any of tmdb/tvdb/imdb. A TVDB/IMDB-only link — e.g. + // an unmatched Sonarr show TMDB has no cross-link for — seeds with a null + // tmdb_id; the resolve-on-entry effect below fills it when a match exists. + if (tmdbId || tvdbId || imdbId) { + // A fresh deep link is a new title — drop any stale builder snapshot. + ssRemove(SS_BUILDER); + return { + tmdb_id: tmdbId ? Number(tmdbId) : null, + kind: normalizeKind(searchParams.get('type')), + title: searchParams.get('title') || '', + year: searchParams.get('year') ? Number(searchParams.get('year')) : null, + tvdb_id: tvdbId ? Number(tvdbId) : null, + imdb_id: imdbId || null, + }; + } + return ssRead(SS_ITEM, null); + }); + + // Bumped only when a NEW title is picked, so editing the ids in-place doesn't + // remount the Builder (which would wipe panel state). Restored too, so the + // Builder remounts with the same key and re-reads its saved selections. + const [selectionKey, setSelectionKey] = useState(() => ssRead(SS_SELKEY, 0)); + const pickItem = useCallback(it => { + ssRemove(SS_BUILDER); // a new title starts the builder fresh + setItem(it); + setSelectionKey(k => k + 1); + }, []); + + // Persist the selected title + selection key across navigation. + useEffect(() => { + ssWrite(SS_ITEM, item); + }, [item]); + useEffect(() => { + ssWrite(SS_SELKEY, selectionKey); + }, [selectionKey]); + + const resetItem = useCallback(() => { + ssRemove(SS_ITEM); + ssRemove(SS_BUILDER); + setItem(null); + }, []); + + // Resolve a blank title from whatever id the item carries (TMDB → TVDB → IMDB) + // so an id-only entry (paste / Edit IDs / deep link) shows the real name in the + // header instead of "TMDB #…". Runs once per id-set and never overrides a title + // the user has typed; a miss is harmless (the backend still backfills the + // filename at save time). + const titleProbe = useRef(null); + // A newly picked title resets the probe so re-entering the same id resolves again. + useEffect(() => { + titleProbe.current = null; + }, [selectionKey]); + useEffect(() => { + if (!item || item.kind === 'collection' || (item.title || '').trim()) return undefined; + const sig = `${item.tmdb_id || ''}|${item.tvdb_id || ''}|${item.imdb_id || ''}`; + if (sig === '||' || titleProbe.current === sig) return undefined; + titleProbe.current = sig; + let cancelled = false; + (async () => { + try { + const resp = await cl2kMakerAPI.details(item.tmdb_id, item.kind, { + tvdbId: item.tvdb_id, + imdbId: item.imdb_id, + }); + const title = resp?.data?.title; + if (!cancelled && title) { + setItem(prev => + prev && !(prev.title || '').trim() + ? { ...prev, title, year: prev.year ?? resp.data.year ?? null } + : prev + ); + } + } catch { + /* leave blank — the backend still backfills the filename on save */ + } + })(); + return () => { + cancelled = true; + }; + }, [item]); + + // A TVDB/IMDB-only entry (deep link, or a paste TMDB has no cross-link for) + // carries no tmdb_id, so the TMDB art picker comes back empty. Resolve one in + // the background and patch it in when TMDB has the link — unlocking the full + // TMDB/fanart picker. A miss is harmless (Plex art + Edit IDs still work). + // Runs once per id-set and never clobbers an existing tmdb_id. + const idResolveProbe = useRef(null); + useEffect(() => { + idResolveProbe.current = null; + }, [selectionKey]); + useEffect(() => { + if (!item || item.tmdb_id || item.kind === 'collection') return undefined; + const ext = item.tvdb_id + ? { id: item.tvdb_id, source: 'tvdb_id' } + : item.imdb_id + ? { id: item.imdb_id, source: 'imdb_id' } + : null; + if (!ext) return undefined; + const sig = `${ext.source}:${ext.id}`; + if (idResolveProbe.current === sig) return undefined; + idResolveProbe.current = sig; + let cancelled = false; + (async () => { + try { + const resp = await cl2kMakerAPI.resolve(String(ext.id), ext.source, item.kind); + const tmdbId = resp?.data?.tmdb_id; + if (!cancelled && tmdbId) { + setItem(prev => + prev && !prev.tmdb_id ? { ...prev, tmdb_id: Number(tmdbId) } : prev + ); + } + } catch { + /* leave tmdb_id null — Plex/fanart by tvdb + Edit IDs still work */ + } + })(); + return () => { + cancelled = true; + }; + }, [item]); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const [cfgResp, statusResp] = await Promise.allSettled([ + configAPI.fetchConfig({ useCache: false }), + cl2kMakerAPI.uploadStatus(), + ]); + if (cancelled) return; + setConfig( + cfgResp.status === 'fulfilled' ? cfgResp.value?.data?.cl2k_maker || {} : {} + ); + if (statusResp.status === 'fulfilled') { + setUploadStatus(statusResp.value?.data || null); + } + } catch { + if (!cancelled) setConfig({}); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + return ( +
+ {/* Brand-styled range sliders (mock spec) — scoped to this page so + it stays ':full'-image and doesn't touch shared CSS. */} + + + DEVELOP + + } + /> + + + + {!item ? ( + + ) : ( + setItem(prev => (prev ? { ...prev, ...patch } : prev))} + toast={toast} + /> + )} +
+ ); +}; + +// ─── Config banner ─────────────────────────────────────────────────────── + +const ConfigBanner = ({ config, uploadStatus }) => { + if (config === null) return null; + // Entries that actually route something (a claimed type + a real target). + const folderCount = (config.local_folders || []).filter( + f => (f?.path || '').trim() && (f?.types || []).length + ).length; + const driveCount = (config.gdrive_uploads || []).filter( + d => (d?.folder_id || '').trim() && (d?.types || []).length + ).length; + const noLocations = folderCount === 0 && driveCount === 0; + // Drive uploads are configured but there's no usable Sync GDrive OAuth + // token, so every upload will fail (a service account can't own files in a + // personal Drive). + const uploadNoToken = uploadStatus?.gdrive_configured && uploadStatus?.token_ok === false; + return ( + <> + {/* Config strip — the non-visual knobs live in Module Settings; the + save locations are shown read-only since there's no inline-save + backend, with a link back to edit them. */} +
+ + Save locations + + {noLocations + ? 'none — download only' + : `${folderCount} folder${folderCount === 1 ? '' : 's'} · ${driveCount} Drive${driveCount === 1 ? '' : 's'}`} + + + + Whiten logo{' '} + + {config.whiten_logo ? 'yes' : 'no'} + + + + AI provider{' '} + {config.ai_provider || 'none'} + + + + Edit in Module Settings → + +
+ + {noLocations && ( +
+ + info + + + No save locations configured — generated art isn't auto-saved but stays + downloadable here.{' '} + + Add folders or Drives in Module Settings. + + +
+ )} + {uploadNoToken && ( +
+ + warning + + + Google Drive uploads configured but no OAuth token — uploads will fail, + local saves still work.{' '} + + Connect Drive → + + +
+ )} + + ); +}; + +// ─── Save destinations (shared across every save flow) ────────────────────── + +// Hook owning the two independent save-medium toggles. Each medium defaults ON +// only when the module config actually routes something through it (an entry +// with a claimed type; Drive additionally needs a usable OAuth token). The +// defaults are applied once `uploadStatus` arrives so they never clobber a +// user toggle. Both off (or nothing routed) is valid — the art stays +// downloadable from this page. +const useSaveTargets = uploadStatus => { + const [saveLocal, setSaveLocal] = useState(true); + const [uploadGdrive, setUploadGdrive] = useState(false); + const initRef = useRef(false); + useEffect(() => { + if (!uploadStatus || initRef.current) return; + initRef.current = true; + setSaveLocal(!!uploadStatus.local_configured); + setUploadGdrive(!!uploadStatus.gdrive_configured && uploadStatus.token_ok !== false); + }, [uploadStatus]); + const noTarget = !saveLocal && !uploadGdrive; + return { + saveLocal, + setSaveLocal, + uploadGdrive, + setUploadGdrive, + uploadStatus, + noTarget, + // Request fields the backend reads (independent of how the UI is wired). + fields: { save_local: saveLocal, upload_gdrive: uploadGdrive }, + }; +}; + +// The two tick boxes — which save MEDIUMS this generation uses; the module +// config routes each artwork type to its claiming folders/Drives within them. +// A medium with nothing routed is disabled (with a hint); both off is valid +// (download only). +const SaveTargets = ({ targets }) => { + const { saveLocal, setSaveLocal, uploadGdrive, setUploadGdrive, uploadStatus, noTarget } = + targets; + const localConfigured = !!uploadStatus?.local_configured; + const gdriveConfigured = !!uploadStatus?.gdrive_configured; + const tokenOk = uploadStatus?.token_ok !== false; + return ( +
+ Save to +
+ + +
+ {!localConfigured && !gdriveConfigured && ( +

+ No save locations routed — every generation stays downloadable here. Add folders + or Drives under{' '} + + Module Settings + + . +

+ )} + {!gdriveConfigured && localConfigured && ( +

+ Add a Drive upload under{' '} + + Module Settings + {' '} + to enable Drive upload. +

+ )} + {uploadGdrive && gdriveConfigured && !tokenOk && ( +

+ No usable Sync GDrive OAuth token — the Drive upload will fail (the poster still + saves locally if that box is ticked). +

+ )} + {noTarget && ( +

+ Nothing selected — this generation won't be auto-saved, just downloadable. +

+ )} +
+ ); +}; + +// ─── Stage 1: title picker ───────────────────────────────────────────────── + +const TitlePicker = ({ onPick, toast }) => { + const [kind, setKind] = useState('movie'); + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [searching, setSearching] = useState(false); + const [paste, setPaste] = useState(''); + const [resolving, setResolving] = useState(false); + const [picking, setPicking] = useState(false); + + const runSearch = useCallback( + async e => { + e?.preventDefault(); + if (!query.trim()) return; + setSearching(true); + try { + const resp = await cl2kMakerAPI.search(query.trim(), kind); + setResults(resp?.data?.results || []); + } catch (err) { + toast.error(err.message || 'Search failed'); + } finally { + setSearching(false); + } + }, + [query, kind, toast] + ); + + const pickResult = useCallback( + async r => { + const title = r.title || r.name || ''; + const dateStr = r.release_date || r.first_air_date || ''; + const base = { + tmdb_id: r.id, + kind, + title, + year: dateStr ? Number(dateStr.slice(0, 4)) : null, + tvdb_id: null, + imdb_id: null, + }; + setPicking(true); + try { + // Auto-populate tvdb_id/imdb_id from TMDB so filenames match the + // library; the manual "Edit IDs" editor stays as the fallback. + onPick(await withExternalIds(base)); + } finally { + setPicking(false); + } + }, + [kind, onPick] + ); + + const runPaste = useCallback(async () => { + const parsed = parsePastedId(paste); + if (parsed?.error === 'tvdb_slug') { + toast.error( + "TheTVDB page URLs use a name slug, not a number. Open the page and paste the numeric 'Series ID' shown there, type tvdb:, or search by title above." + ); + return; + } + if (!parsed) { + toast.error('Could not parse an ID or URL'); + return; + } + setResolving(true); + try { + const pasteKind = parsed.type || kind; + let tmdbId = parsed.source === 'tmdb' ? Number(parsed.id) : null; + if (!tmdbId) { + const resp = await cl2kMakerAPI.resolve(parsed.id, parsed.source, pasteKind); + tmdbId = resp?.data?.tmdb_id; + } + const tvdbId = parsed.source === 'tvdb_id' ? Number(parsed.id) : null; + const imdbId = parsed.source === 'imdb_id' ? parsed.id : null; + // No TMDB entry is cross-linked to this external id (common for smaller + // TVDB-keyed shows)? Open the builder anyway with the id set — Plex / + // fanart art and the Edit IDs panel still work — instead of dead-ending. + if (!tmdbId) { + toast.info( + 'No TMDB entry is linked to that id — opened with the ID set. Search by title above, or add a title in Edit IDs.' + ); + } + const base = { + tmdb_id: tmdbId ? Number(tmdbId) : null, + kind: pasteKind, + title: '', + year: null, + tvdb_id: tvdbId, + imdb_id: imdbId, + }; + // Fill in whichever of tvdb/imdb the paste didn't already supply; the + // blank title is resolved by the title-backfill effect once the item is + // set (covers paste, Edit IDs and deep links uniformly). + onPick(await withExternalIds(base)); + } catch (err) { + toast.error(err.message || 'Resolve failed'); + } finally { + setResolving(false); + } + }, [paste, kind, onPick, toast]); + + const inputCls = + 'flex-1 min-w-0 h-[42px] px-3.5 rounded-lg bg-surface-inset border border-border text-fg text-sm outline-none focus:border-primary transition-colors placeholder:text-fg-dim'; + + return ( +
+

Pick a title

+ +
+ +
+ +
+ setQuery(e.target.value)} + placeholder="Search TMDB by title…" + className={inputCls} + /> + +
+

+ Try: dune, severance, shogun, oppenheimer… +

+ + {results.length > 0 && ( +
+ {results.map(r => { + const title = r.title || r.name || '(untitled)'; + const date = r.release_date || r.first_air_date || ''; + return ( + + ); + })} +
+ )} + +
+

+ …or paste a TMDB / TVDB / IMDB ID or URL +

+
+ setPaste(e.target.value)} + placeholder="e.g. 603, tt0133093, tvdb:413715, or a TMDB/TVDB URL" + className={`${inputCls} h-10 font-mono text-[13px]`} + /> + +
+
+
+ ); +}; + +// ─── Stage 2–4: builder ──────────────────────────────────────────────────── + +// ─── ID editor (attach/clear tmdb/tvdb/imdb so filenames match the library) ── + +const IdEditor = ({ item, onItemChange }) => { + const numOrNull = v => (String(v).trim() === '' ? null : Number(v)); + const strOrNull = v => (String(v).trim() === '' ? null : String(v).trim()); + const inputCls = 'flex-1 bg-surface border border-border rounded px-2 py-1 text-sm text-fg'; + const row = (lbl, el) => ( + + ); + return ( +
+

+ Set the ids that match your library — only the ids you fill get written to the + filename. For a TVDB-only title (no TMDB entry), clear TMDB and add TVDB/IMDB. +

+ {row( + 'Title', + onItemChange({ title: e.target.value })} + className={inputCls} + /> + )} + {row( + 'Year', + onItemChange({ year: numOrNull(e.target.value) })} + className={inputCls} + /> + )} + {row( + 'TMDB', + onItemChange({ tmdb_id: numOrNull(e.target.value) })} + className={inputCls} + /> + )} + {row( + 'TVDB', + onItemChange({ tvdb_id: numOrNull(e.target.value) })} + className={inputCls} + /> + )} + {row( + 'IMDB', + onItemChange({ imdb_id: strOrNull(e.target.value) })} + className={inputCls} + /> + )} +
+ ); +}; + +const Builder = ({ item, config, uploadStatus, onReset, onItemChange, toast }) => { + // Restore the builder's selections from the session snapshot (written by the + // effect below, cleared when a new title is picked) so they survive + // navigation. Read once on mount. + const saved = useMemo(() => ssRead(SS_BUILDER, {}), []); + const [searchParams] = useSearchParams(); + + // Migrate retired tab keys to the unified 'poster' build tab (see TAB_MIGRATE) + // so a saved session never lands on a tab that no longer exists. A deep link + // from Unmatched → Additional artwork carries ?asset= so the maker opens + // on the right asset tab (background / square / logo) ready to build it. + const [tab, setTab] = useState(() => { + const assetParam = searchParams.get('asset'); + if (assetParam && BUILD_TABS.some(t => t.key === assetParam)) return assetParam; + const t = saved.tab ?? 'poster'; + return TAB_MIGRATE[t] || t; + }); + const [editIds, setEditIds] = useState(false); + + // Save destinations (output dir / Drive) — shared by every save flow below. + const saveTargets = useSaveTargets(uploadStatus); + + // Art (shared across the TMDB / fanart / Plex tabs) + const [tmdbArt, setTmdbArt] = useState(null); + const [fanartArt, setFanartArt] = useState(null); + const [plexArt, setPlexArt] = useState(null); + // TMDB season-level posters (portrait 2:3) — only for season posters. + const [seasonArt, setSeasonArt] = useState(null); + const [loadingArt, setLoadingArt] = useState(true); + const [backdrop, setBackdrop] = useState(saved.backdrop ?? null); // file_path | absolute url + const [logo, setLogo] = useState(saved.logo ?? null); + // Per-picker sources on the Poster page (persisted so a restored fanart/plex + // selection comes back with its own grid showing, not an unhighlighted TMDB). + const [backdropSource, setBackdropSource] = useState(saved.backdropSource ?? 'tmdb'); + const [logoSource, setLogoSource] = useState(saved.logoSource ?? 'tmdb'); + // Custom uploaded logo (one-off, not persisted): { b64, name, url }. When set + // it overrides the chosen TMDB/fanart `logo` path. + const [customLogo, setCustomLogo] = useState(null); + const setCustomLogoExclusive = useCallback(c => { + setCustomLogo(c); + if (c) setLogo(null); // custom logo replaces a chosen TMDB/fanart logo + }, []); + // Custom uploaded backdrop (the 'Upload' backdrop source): { b64, name, url }. + // Mutually exclusive with a picker-chosen `backdrop` path. + const [customBackdrop, setCustomBackdrop] = useState(null); + + // Crop framing. "cover" scales up + crops to fill (focal point below); "fit" + // scales the backdrop down to the canvas width and black-pads the bottom so + // subjects spread across a wide backdrop all stay in frame (the artist + // technique). `crop` (0..1 {x,y,w,h}) isolates the subject region for fit mode. + const [fitMode, setFitMode] = useState(saved.fitMode ?? 'cover'); + const [crop, setCrop] = useState(saved.crop ?? null); + // Vertical position (-1..1, 0 = centred) — the ONE vertical control. In + // fit/extend it positions the fitted photo; in Fill it pans the framing at the + // same size (down flows real artwork into the gradient, up is source-bounded). + const [vPos, setVPos] = useState(saved.vPos ?? 0); + // Zoom (>=1) for fit/extend: enlarge the subject above the full-width fit so a + // wide backdrop isn't shrunk to a tiny strip. + const [zoom, setZoom] = useState(saved.zoom ?? 1); + const [focusX, setFocusX] = useState(saved.focusX ?? 0.5); + // Measured by CropFramer's ; owned here because the Vertical + // position slider's real range depends on it. null until measured. + const [backdropRatio, setBackdropRatio] = useState(null); + + // How much travel v_pos really has at this ratio/zoom. Fill is the only + // two-sided mode; fit/extend anchor from the top over 0..1. + const vPosBoundsFor = useCallback((mode, ratio, z) => { + if (mode !== 'cover') return { min: 0, max: 1, dead: false }; + if (!ratio) return { min: CONTROL_RANGES.vPos.min, max: 1, dead: false }; + const { hF, band } = coverKeep(ratio, z); + return vPosBounds(hF, band); + }, []); + const vPosLimits = useMemo( + () => vPosBoundsFor(fitMode, backdropRatio, zoom), + [vPosBoundsFor, fitMode, backdropRatio, zoom] + ); + // Raising a range input's `min` above its current `value` moves the thumb + // WITHOUT firing onChange, so the shown position would lie about the state. + // Every setter that can shrink the range re-clamps v_pos itself. + const setZoomClamped = useCallback( + z => { + setZoom(z); + setVPos(v => clampToBounds(v, vPosBoundsFor(fitMode, backdropRatio, z))); + }, + [vPosBoundsFor, fitMode, backdropRatio] + ); + const onBackdropRatio = useCallback( + r => { + setBackdropRatio(r); + setVPos(v => clampToBounds(v, vPosBoundsFor(fitMode, r, zoom))); + }, + [vPosBoundsFor, fitMode, zoom] + ); + + // Framing is tuned for ONE image: a crop box, zoom, focal point and vertical + // pan chosen for backdrop A are meaningless on backdrop B, and silently + // carrying them over produced posters framed by the previous picture. Picking + // a new TITLE already resets everything (Builder remounts on selectionKey), + // so this covers switching the backdrop WITHIN a title. Done in the setters + // rather than an effect — the repo's react-hooks/set-state-in-effect rule + // forbids resetting state from a useEffect. + const resetFraming = useCallback(() => { + setCrop(null); + setVPos(0); + setZoom(1); + setFocusX(0.5); + setBackdropRatio(null); // re-measured by the new image's onLoad + }, []); + // fitMode is deliberately NOT reset — it's a per-user way of working + // (Fill vs Fit), not a property of the chosen image. + // v_pos is only two-sided in Fill: the fit/extend renderers anchor the photo + // from the TOP over 0..1, so negative has no meaning there. Clamp on the way + // in rather than let the backend silently floor it. + const setFitModeClamped = useCallback( + mode => { + setFitMode(mode); + setVPos(v => clampToBounds(v, vPosBoundsFor(mode, backdropRatio, zoom))); + }, + [vPosBoundsFor, backdropRatio, zoom] + ); + const setBackdropExclusive = useCallback( + p => { + setBackdrop(p); + if (p) setCustomBackdrop(null); + resetFraming(); + }, + [resetFraming] + ); + const setCustomBackdropExclusive = useCallback( + c => { + setCustomBackdrop(c); + if (c) setBackdrop(null); + resetFraming(); + }, + [resetFraming] + ); + + // Logo size override (1 = the strict CL2K guide box; >1 enlarges the whole + // guide-fit box past the width guides, capped only by the canvas). + const [logoScale, setLogoScale] = useState(saved.logoScale ?? 1); + // Logo vertical offset (px from the locked baseline; positive = down). + const [logoYOffset, setLogoYOffset] = useState(saved.logoYOffset ?? 0); + // Per-render whiten override; null = the module config (whiten_logo). + const [whitenLogo, setWhitenLogo] = useState(saved.whitenLogo ?? null); + const effectiveWhiten = whitenLogo === null ? (config?.whiten_logo ?? true) : whitenLogo; + // Flat white: paint the logo a pure-white silhouette (no two-tone keylines) — + // for already-stylised/outline logos the CL2K-white pass mangles. Wins over whiten. + const [flatWhite, setFlatWhite] = useState(saved.flatWhite ?? false); + // 3D logo: keep extruded art's lit face, drop the extrusion. Wins over flat. + const [logo3d, setLogo3d] = useState(saved.logo3d ?? false); + // Invert logo: white -> transparent, black -> white (plate/sticker art). + const [invertLogo, setInvertLogo] = useState(saved.invertLogo ?? false); + + // Season variant (shows only) + const [seasonNumber, setSeasonNumber] = useState(saved.seasonNumber ?? ''); + const [bulkSeasons, setBulkSeasons] = useState(saved.bulkSeasons ?? ''); + // Optional bottom banner (e.g. COMPLETE LIMITED SERIES). Overrides the auto + // COLLECTION / season label — including on a season poster, so a limited + // series can show COMPLETE LIMITED SERIES in place of SEASON N (the file is + // still saved as `- Season NN`). Drop a stale saved value no longer in the + // options (e.g. the retired "SPECIALS", now made via Season 0) so it can't + // silently re-apply. + const [bandLabel, setBandLabel] = useState(() => + BAND_LABEL_OPTIONS.some(o => o.value === saved.bandLabel) ? saved.bandLabel : '' + ); + + // AI text-removal + const [removeText, setRemoveText] = useState(saved.removeText ?? false); + const [maskB64, setMaskB64] = useState(null); + const [brushSize, setBrushSize] = useState(18); + + // Persist the builder selections (not the ephemeral mask/preview) so the + // in-progress poster is restored on return. + useEffect(() => { + ssWrite(SS_BUILDER, { + tab, + backdrop, + logo, + backdropSource, + logoSource, + fitMode, + crop, + vPos, + zoom, + focusX, + logoScale, + logoYOffset, + whitenLogo, + flatWhite, + logo3d, + invertLogo, + seasonNumber, + bulkSeasons, + bandLabel, + removeText, + }); + }, [ + tab, + backdrop, + logo, + backdropSource, + logoSource, + fitMode, + crop, + vPos, + zoom, + focusX, + logoScale, + logoYOffset, + whitenLogo, + flatWhite, + logo3d, + invertLogo, + seasonNumber, + bulkSeasons, + bandLabel, + removeText, + ]); + + // Preview + const [previewUrl, setPreviewUrl] = useState(null); + const [previewing, setPreviewing] = useState(false); + const [busy, setBusy] = useState(false); + // Live "n/total" readout while a background season batch runs (see runBulkSeasons). + const [bulkProgress, setBulkProgress] = useState(''); + // Cleared on unmount so the season poll loop below stops (no setState leak). + const bulkMountedRef = useMountedRef(); + + // A real (chosen/custom) logo is drawn as a live overlay on the logo-less base + // so the size/position sliders move it without a server render. No logo = a + // text wordmark, which is baked into the base instead (can't overlay it). + // + // Two processed variants: `processedBase` (no touch-up) is the STABLE image + // the B/W touch-up brush draws over — it must not change as strokes land, or + // the accumulated mask would be lost; `processedLogo` (touch-up applied) is + // what the live overlay shows and the render bakes in. + const hasLogo = !!(logo || customLogo); + // Strokes are keyed to the (logo, whiten, invert) they were drawn over: a + // stale key makes the mask a derived no-op instead of needing a reset-in-effect. + const flipKey = `${customSig(customLogo) || logo}|${effectiveWhiten}|${flatWhite}|${logo3d}|${invertLogo}`; + const [logoFlip, setLogoFlip] = useState(null); // { key, b64 } + const logoFlipB64 = logoFlip && logoFlip.key === flipKey ? logoFlip.b64 : null; + const setLogoFlipB64 = useCallback( + b64 => setLogoFlip(b64 ? { key: flipKey, b64 } : null), + [flipKey] + ); + // Eraser strokes are keyed to the LOGO only (erasing is geometric — it survives + // colour-mode switches, unlike the colour-dependent B/W flip). + const eraseKey = customSig(customLogo) || logo; + const [logoErase, setLogoErase] = useState(null); // { key, b64 } + const logoEraseB64 = logoErase && logoErase.key === eraseKey ? logoErase.b64 : null; + const setLogoEraseB64 = useCallback( + b64 => setLogoErase(b64 ? { key: eraseKey, b64 } : null), + [eraseKey] + ); + const [processedBase, setProcessedBase] = useState(null); + const [processedEdited, setProcessedEdited] = useState(null); // { forKey, data } — flip+erase + const logoReq = useCallback( + extra => + cl2kMakerAPI + .logoProcessed({ + ...(customLogo?.b64 ? { logo_b64: customLogo.b64 } : { logo_path: logo }), + whiten: effectiveWhiten, + flat_white: flatWhite, + logo_3d: logo3d, + invert: invertLogo, + kind: item.kind, + ...extra, + }) + .then(resp => { + const d = resp?.data; + return d?.b64 + ? { + dataUrl: `data:image/png;base64,${d.b64}`, + width: d.width, + height: d.height, + boxW: d.box_w, + boxH: d.box_h, + maxWidth: d.max_width, + } + : null; + }), + [customLogo, logo, effectiveWhiten, flatWhite, logo3d, invertLogo, item.kind] + ); + useEffect(() => { + if (!hasLogo) return undefined; // no fetch; `overlayLogo` below hides it + let cancelled = false; + logoReq({}) + .then(d => { + if (!cancelled) setProcessedBase(d); + }) + .catch(() => { + if (!cancelled) setProcessedBase(null); + }); + return () => { + cancelled = true; + }; + }, [hasLogo, logoReq]); + const editKey = `${flipKey}|${logoFlipB64 || ''}|${logoEraseB64 || ''}`; + const hasEdit = !!(logoFlipB64 || logoEraseB64); + useEffect(() => { + if (!hasEdit) return undefined; // overlay derives to the base below + let cancelled = false; + logoReq({ flip_b64: logoFlipB64, erase_b64: logoEraseB64 }) + .then(d => { + if (!cancelled) setProcessedEdited({ forKey: editKey, data: d }); + }) + .catch(() => { + if (!cancelled) setProcessedEdited(null); + }); + return () => { + cancelled = true; + }; + }, [editKey, hasEdit, logoFlipB64, logoEraseB64, logoReq]); + // Only show the overlay while a logo is selected (the fetched bytes may lag a + // deselect by a tick). Derived, so no reset-setState in the effects above; the + // edited variant is used only while it matches the current masks. + const processedLogo = + hasEdit && processedEdited?.forKey === editKey ? processedEdited.data : processedBase; + const overlayLogo = hasLogo ? processedLogo : null; + + const isSeasonPoster = item.kind === 'show' && String(seasonNumber).trim() !== ''; + const effectiveKind = isSeasonPoster ? 'season' : item.kind; + + // Picking a banner (e.g. COMPLETE LIMITED SERIES) on a show with no season + // number defaults it to Season 1: that banner belongs on the limited series' + // season poster, so this saves it to the season slot rather than the main + // show poster. An explicit season number is never overridden, and clearing + // the season afterwards keeps the banner — a main-poster banner stays + // possible. + const onBandLabel = useCallback( + value => { + setBandLabel(value); + if (value && item.kind === 'show' && String(seasonNumber).trim() === '') { + setSeasonNumber('1'); + } + }, + [item.kind, seasonNumber] + ); + + // Load TMDB + fanart art. Builder is keyed by item, so selection/art state + // starts fresh on each item — no synchronous resets needed here. + useEffect(() => { + let cancelled = false; + (async () => { + try { + const [tm, fa, px] = await Promise.allSettled([ + cl2kMakerAPI.images(item.tmdb_id, item.kind, { + tvdbId: item.tvdb_id, + imdbId: item.imdb_id, + }), + cl2kMakerAPI.fanartImages({ + tmdbId: item.tmdb_id, + type: item.kind, + tvdbId: item.tvdb_id, + imdbId: item.imdb_id, + }), + // Read-only: resolves via the synced plex cache, returns empty + // (with a reason) when Plex isn't configured or the item isn't + // in a library. Fetched eagerly so it's also in the merged + // asset-tab lists, not just the Plex source tab. + cl2kMakerAPI.plexImages({ + tmdbId: item.tmdb_id, + type: item.kind, + tvdbId: item.tvdb_id, + imdbId: item.imdb_id, + }), + ]); + if (cancelled) return; + if (tm.status === 'fulfilled') setTmdbArt(tm.value?.data || null); + if (fa.status === 'fulfilled') setFanartArt(fa.value?.data || null); + if (px.status === 'fulfilled') setPlexArt(px.value?.data || null); + } finally { + if (!cancelled) setLoadingArt(false); + } + })(); + return () => { + cancelled = true; + }; + // Only the identity fields drive the fetch — editing other item fields + // must not re-storm the art APIs (plexImages is uncached). + }, [item.tmdb_id, item.tvdb_id, item.imdb_id, item.kind]); + + // Season posters: TMDB has portrait 2:3 season-level key-art, a better source + // than fitting a show backdrop. Fetch it when building a season poster. + useEffect(() => { + if (!isSeasonPoster || !item.tmdb_id) return undefined; + let cancelled = false; + (async () => { + try { + const resp = await cl2kMakerAPI.seasonImages(item.tmdb_id, Number(seasonNumber), { + tvdbId: item.tvdb_id, + imdbId: item.imdb_id, + }); + if (!cancelled) setSeasonArt(resp?.data || null); + } catch { + if (!cancelled) setSeasonArt(null); + } + })(); + return () => { + cancelled = true; + }; + }, [item.tmdb_id, isSeasonPoster, seasonNumber]); + + // Revoke the preview object URL when it changes / unmounts. + useEffect(() => { + return () => { + if (previewUrl) URL.revokeObjectURL(previewUrl); + }; + }, [previewUrl]); + + const baseRequest = useMemo( + () => ({ + kind: effectiveKind, + title: item.title, + tmdb_id: item.tmdb_id, + year: item.year, + tvdb_id: item.tvdb_id, + imdb_id: item.imdb_id, + season_number: isSeasonPoster ? Number(seasonNumber) : null, + backdrop_path: customBackdrop ? null : backdrop, + backdrop_b64: customBackdrop?.b64 || null, + logo_path: logo, + logo_b64: customLogo?.b64 || null, + logo_scale: logoScale, + logo_y_offset: logoYOffset, + whiten: whitenLogo, + flat_white: flatWhite, + logo_3d: logo3d, + invert: invertLogo, + logo_flip_b64: logoFlipB64, + logo_erase_b64: logoEraseB64, + // AI text-removal is an explicit step now — the "Send to AI" button + // erases the masked text and bakes the cleaned art into the backdrop. + // Render/generate must NEVER run AI themselves, or a checked box with + // no mask triggers a destructive maskless whole-poster regeneration. + remove_text: false, + mask_b64: null, + fit_mode: fitMode, + focus_x: focusX, + crop_x: (fitMode === 'fit' || fitMode === 'extend') && crop ? crop.x : null, + crop_y: (fitMode === 'fit' || fitMode === 'extend') && crop ? crop.y : null, + crop_w: (fitMode === 'fit' || fitMode === 'extend') && crop ? crop.w : null, + crop_h: (fitMode === 'fit' || fitMode === 'extend') && crop ? crop.h : null, + // v_pos applies to every mode now (Fill pans up; fit/extend position). + v_pos: vPos, + zoom: zoom, + // Banner overrides the auto COLLECTION / SEASON label — e.g. a season + // poster drawing COMPLETE LIMITED SERIES in place of SEASON N. + band_label: bandLabel, + // Save destinations (ignored by /preview, honoured by /generate). + save_local: saveTargets.saveLocal, + upload_gdrive: saveTargets.uploadGdrive, + }), + [ + effectiveKind, + item, + isSeasonPoster, + seasonNumber, + backdrop, + customBackdrop, + logo, + customLogo, + logoScale, + logoYOffset, + whitenLogo, + flatWhite, + logo3d, + invertLogo, + logoFlipB64, + logoEraseB64, + fitMode, + crop, + vPos, + zoom, + focusX, + bandLabel, + saveTargets.saveLocal, + saveTargets.uploadGdrive, + ] + ); + + const setPreview = useCallback(blob => { + const url = URL.createObjectURL(blob); + setPreviewUrl(prev => { + if (prev) URL.revokeObjectURL(prev); + return url; + }); + }, []); + + // Read the latest request inside the debounced effect without making its + // identity a trigger (the effect fires off baseSig, below). The debounce + // means the ref is always current by the time the timeout reads it. + const baseRequestRef = useRef(baseRequest); + useEffect(() => { + baseRequestRef.current = baseRequest; + }, [baseRequest]); + + // Signature of the fields that change the logo-less base. Logo size/position + // (and title) are excluded when a real logo is chosen — the overlay handles + // those live — and included only for the baked text-wordmark fallback. + const baseSig = useMemo( + () => + JSON.stringify({ + tab, + k: effectiveKind, + id: item.tmdb_id, + sn: isSeasonPoster ? seasonNumber : null, + bd: backdrop, + cbd: customSig(customBackdrop), + fm: fitMode, + c: (fitMode === 'fit' || fitMode === 'extend') && crop ? crop : null, + vp: vPos, + zm: zoom, + fx: focusX, + bl: bandLabel, + pl: !hasLogo, + ti: hasLogo ? null : item.title, + ls: hasLogo ? null : logoScale, + ly: hasLogo ? null : logoYOffset, + }), + [ + tab, + effectiveKind, + item.tmdb_id, + item.title, + isSeasonPoster, + seasonNumber, + backdrop, + customBackdrop, + fitMode, + crop, + vPos, + zoom, + focusX, + bandLabel, + hasLogo, + logoScale, + logoYOffset, + ] + ); + + // Auto-render the cheap logo-less base shortly after a base-affecting change + // settles — no more manual "Render preview" click for framing/backdrop/label. + // AI text-removal is skipped here (slow); the Refresh button runs the full + // render with AI when the user wants to see it. + useEffect(() => { + if (tab !== 'poster') return undefined; + // No art chosen yet (picker path or custom upload) — nothing to render. + if (!backdrop && !customBackdrop) return undefined; + let cancelled = false; + const aborter = new AbortController(); + const handle = setTimeout(async () => { + setPreviewing(true); + try { + const blob = await cl2kMakerAPI.preview( + { + ...baseRequestRef.current, + place_logo: !hasLogo, + remove_text: false, + mask_b64: null, + }, + { signal: aborter.signal } + ); + if (!cancelled) setPreview(blob); + } catch { + /* auto-render stays quiet; the Refresh button surfaces errors */ + } finally { + if (!cancelled) setPreviewing(false); + } + }, 300); + return () => { + cancelled = true; + aborter.abort(); + clearTimeout(handle); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [baseSig]); + + // Manual full refresh from current inputs (no AI). Bake the logo only for the + // text-wordmark fallback — a chosen logo stays a live overlay for the sliders. + // Abort + sequence guard, matching the auto-render effect above: a second click + // supersedes the first, so a slow stale render can't overwrite a newer preview + // (or clear the spinner the newer one owns). + const previewSeq = useRef(0); + const previewAbort = useRef(null); + // Bump the sequence BEFORE aborting: the aborted call still reaches its + // `finally`, which must then see a newer seq and skip its setState. + useEffect( + () => () => { + previewSeq.current += 1; + previewAbort.current?.abort(); + }, + [] + ); + const runPreview = useCallback(async () => { + previewAbort.current?.abort(); + const aborter = new AbortController(); + previewAbort.current = aborter; + const seq = ++previewSeq.current; + setPreviewing(true); + try { + const blob = await cl2kMakerAPI.preview( + { ...baseRequest, place_logo: !hasLogo }, + { signal: aborter.signal } + ); + if (seq === previewSeq.current) setPreview(blob); + } catch (err) { + if (!aborter.signal.aborted) toast.error(err.message || 'Preview failed'); + } finally { + if (seq === previewSeq.current) setPreviewing(false); + } + }, [baseRequest, hasLogo, setPreview, toast]); + + const runGenerate = useCallback(async () => { + setBusy(true); + try { + // Building a poster here is a deliberate, one-off action, so always + // overwrite — "already generated" (a stale provenance row, even after + // the file was deleted) shouldn't block a manual Generate. The skip is + // only meant for the unattended scheduled batch run. + const resp = await cl2kMakerAPI.generate({ ...baseRequest, force: true }); + savedToast(toast, resp?.data, 'Generated'); + } catch (err) { + toast.error(err.message || 'Generate failed'); + } finally { + setBusy(false); + } + }, [baseRequest, toast]); + + const runPsdExport = useCallback(async () => { + setBusy(true); + try { + const blob = await cl2kMakerAPI.psdExport(baseRequest); + downloadBlob(blob, `${slugify(item.title) || item.tmdb_id}.psd`); + } catch (err) { + toast.error(err.message || 'PSD export failed'); + } finally { + setBusy(false); + } + }, [baseRequest, item, toast]); + + const runBulkSeasons = useCallback(async () => { + const nums = parseSeasonList(bulkSeasons); + if (!nums.length) { + toast.error('Enter season numbers, e.g. 1,2,3'); + return; + } + if (!backdrop && !customBackdrop) { + toast.error('Pick a backdrop first — seasons reuse the poster you built.'); + return; + } + setBusy(true); + setBulkProgress(`0/${nums.length}`); + try { + // The seasons reuse the SAME backdrop + logo the user built in the + // preview (mirrors baseRequest) instead of a server-side auto-pick. + const resp = await cl2kMakerAPI.generateSeasons({ + tmdb_id: item.tmdb_id, + title: item.title, + seasons: nums, + year: item.year, + tvdb_id: item.tvdb_id, + imdb_id: item.imdb_id, + backdrop_path: customBackdrop ? null : backdrop, + backdrop_b64: customBackdrop?.b64 || null, + logo_path: logo, + logo_b64: customLogo?.b64 || null, + whiten: whitenLogo, + flat_white: flatWhite, + logo_3d: logo3d, + invert: invertLogo, + // Every season reuses the SAME logo, so it must carry the same + // touch-up/erase edits the preview was built with. + logo_flip_b64: logoFlipB64, + logo_erase_b64: logoEraseB64, + fit_mode: fitMode, + focus_x: focusX, + crop_x: (fitMode === 'fit' || fitMode === 'extend') && crop ? crop.x : null, + crop_y: (fitMode === 'fit' || fitMode === 'extend') && crop ? crop.y : null, + crop_w: (fitMode === 'fit' || fitMode === 'extend') && crop ? crop.w : null, + crop_h: (fitMode === 'fit' || fitMode === 'extend') && crop ? crop.h : null, + v_pos: vPos, + zoom: zoom, + logo_scale: logoScale, + logo_y_offset: logoYOffset, + save_local: saveTargets.saveLocal, + upload_gdrive: saveTargets.uploadGdrive, + // Deliberate manual action — overwrite existing season posters too. + force: true, + }); + const jobId = resp?.data?.job_id; + if (!jobId) throw new Error(resp?.message || 'Could not start season batch'); + + const d = await pollSeasonsBatch(jobId, bulkMountedRef, setBulkProgress); + if (d) seasonsBatchToast(toast, d); // null = unmounted, outcome unknown + } catch (err) { + if (bulkMountedRef.current) toast.error(err.message || 'Season generation failed'); + } finally { + if (bulkMountedRef.current) { + setBusy(false); + setBulkProgress(''); + } + } + }, [ + bulkMountedRef, + bulkSeasons, + item, + backdrop, + customBackdrop, + logo, + customLogo, + whitenLogo, + flatWhite, + logo3d, + invertLogo, + logoFlipB64, + logoEraseB64, + saveTargets, + fitMode, + focusX, + crop, + vPos, + zoom, + logoScale, + logoYOffset, + toast, + ]); + + // Per-source art map for the per-picker SourceSelector (Poster page picks + // backdrop + logo sources independently from this). + const artBySource = useMemo( + () => ({ tmdb: tmdbArt, fanart: fanartArt, plex: plexArt }), + [tmdbArt, fanartArt, plexArt] + ); + const isRenderTab = tab === 'poster'; + + return ( + <> + {/* Selected title bar */} +
+
+
+ + {item.title || `TMDB #${item.tmdb_id}`} + + {item.year ? ( + {item.year} + ) : null} + + {item.kind} + + {item.tmdb_id ? ( + + TMDB {item.tmdb_id} + + ) : null} + {item.tvdb_id ? ( + + TVDB {item.tvdb_id} + + ) : null} + {item.imdb_id ? ( + + {item.imdb_id} + + ) : null} +
+
+ + +
+
+ {editIds && } +
+ + {/* Stage 2: build-type tabs (what you're making) + a More ▾ menu for + the occasional finished-poster / edit workflows. Sources are picked + per-picker inside each page. */} +
+
+ {BUILD_TABS.map(t => { + const on = tab === t.key; + return ( + + ); + })} +
+
+ + {/* Stage 3 + 4 panels */} + {isRenderTab && ( + { + setFocusX(fx); + setVPos(vp); + }} + item={item} + config={config} + seasonNumber={seasonNumber} + setSeasonNumber={setSeasonNumber} + bandLabel={bandLabel} + setBandLabel={onBandLabel} + isSeasonPoster={isSeasonPoster} + bulkSeasons={bulkSeasons} + setBulkSeasons={setBulkSeasons} + onBulkSeasons={runBulkSeasons} + bulkProgress={bulkProgress} + removeText={removeText} + setRemoveText={setRemoveText} + brushSize={brushSize} + setBrushSize={setBrushSize} + maskB64={maskB64} + onMaskChange={setMaskB64} + previewUrl={previewUrl} + previewing={previewing} + onPreview={runPreview} + onGenerate={runGenerate} + onPsdExport={runPsdExport} + busy={busy} + saveTargets={saveTargets} + effectiveKind={effectiveKind} + toast={toast} + /> + )} + + {tab === 'square' && ( + + )} + {tab === 'background' && ( + + )} + {tab === 'logo' && ( + + )} + + {/* Each tab now renders its own "Recently generated" accordion in its + right column, so no Builder-level history section is needed. */} + + ); +}; + +// ─── Render panel (TMDB / fanart) ────────────────────────────────────────── + +// Collapsible accordion for the studio's right-column secondary panels (AI text +// removal, touch-up, extraction, bulk seasons, history). Pure UI open/closed +// state — content (and all its handlers) is passed as children. `dot` shows a +// small accent indicator on the header when a flag is active. +// Optionally controlled: pass `open` + `onToggle` to drive the open state from +// the parent (e.g. so a success handler can collapse it); otherwise it manages +// its own UI-only state. +const StudioAccordion = ({ + title, + count, + dot = false, + defaultOpen = false, + open: openProp, + onToggle, + children, +}) => { + const [openState, setOpenState] = useState(defaultOpen); + const controlled = openProp !== undefined; + const open = controlled ? openProp : openState; + const toggle = () => (controlled ? onToggle?.() : setOpenState(o => !o)); + return ( +
+
+ + {open &&
{children}
} +
+ ); +}; + +// A labelled section header for the always-visible studio control groups. +const StudioGroupLabel = ({ children }) => ( + {children} +); + +const RenderPanel = ({ + artBySource, + seasonArt, + loadingArt, + backdrop, + setBackdrop, + customBackdrop, + setCustomBackdrop, + backdropSource, + setBackdropSource, + logoSource, + setLogoSource, + logo, + setLogo, + customLogo, + setCustomLogo, + logoScale, + setLogoScale, + logoYOffset, + setLogoYOffset, + whitenLogo, + setWhitenLogo, + flatWhite, + setFlatWhite, + logo3d, + setLogo3d, + invertLogo, + setInvertLogo, + logoTouchUpUrl, + onLogoFlip, + onLogoErase, + logoFlipB64, + logoEraseB64, + processedLogo, + fitMode, + setFitMode, + crop, + setCrop, + vPos, + setVPos, + zoom, + setZoom, + vPosLimits, + backdropRatio, + onBackdropRatio, + focusX, + onFocusChange, + item, + config, + seasonNumber, + setSeasonNumber, + bandLabel, + setBandLabel, + isSeasonPoster, + bulkSeasons, + setBulkSeasons, + onBulkSeasons, + bulkProgress, + removeText, + setRemoveText, + brushSize, + setBrushSize, + maskB64, + onMaskChange, + previewUrl, + previewing, + onPreview, + onGenerate, + onPsdExport, + busy, + saveTargets, + effectiveKind, + toast, +}) => { + // Independent per-picker sources (Option A): the backdrop and the logo each + // choose their own source, so e.g. a Plex backdrop + a fanart.tv logo works. + // State lives in the Builder (persisted with the session); switching a picker + // OFF Upload clears its custom image — one consistent rule everywhere, no + // invisible "the upload is still what renders" state. + // 'upload' AND 'gdrive' both seed customBackdrop, so only clear it when + // switching to a picker-grid source (tmdb/fanart/plex). + const onBdSource = s => { + setBackdropSource(s); + if (s !== 'upload' && s !== 'gdrive') setCustomBackdrop(null); + }; + const onLogoSource = s => { + setLogoSource(s); + // 'gdrive' seeds customLogo the same way 'upload' does (the picked asset + // is imported as bytes), so neither may clear it on switch-in. + if (s !== 'upload' && s !== 'gdrive') setCustomLogo(null); + }; + + // Output mode: 'cl2k' = the full CL2K render (gradient + logo + framing + + // season + border). 'asis' = file a poster built outside CHUB unchanged (just + // the optional border, no logo/gradient/reframe) — the old "Finished poster" + // tab, folded in so the Upload/GDrive sources feed it. + const [outputMode, setOutputMode] = useState('cl2k'); // 'cl2k' | 'asis' + const isAsis = outputMode === 'asis'; + const bdArt = artBySource[backdropSource] || null; + const lgArt = artBySource[logoSource] || null; + const backdrops = bdArt?.backdrops || []; + const posters = bdArt?.posters || []; + // Wordmark-first so real title logos beat TMDB's character-art junk. + const logos = useMemo(() => sortWordmarkFirst(lgArt?.logos || []), [lgArt]); + const seasonPosters = seasonArt?.posters || []; + const backdropUrl = customBackdrop?.url || (backdrop ? urlForPath(backdrop) : null); + // A render input exists — either a picker path or a custom upload. Gates the + // preview/Generate actions (PSD stays path-only: the backend PSD route can't + // take uploaded bytes and would silently swap in an auto-picked backdrop). + const hasBackdrop = !!(backdrop || customBackdrop); + // Template guide lines over the rendered preview (the PSD's cyan guides). + const [showGuides, setShowGuides] = useState(true); + + const onBackdropFile = async e => { + const f = e.target.files?.[0]; + e.target.value = ''; + if (!f) return; + try { + const url = await readFileAsDataURL(f); + setCustomBackdrop({ b64: url.split(',').pop(), url, name: f.name }); + } catch (err) { + toast.error(err.message || 'Could not read that image'); + } + }; + + // Synced assets that match the current title — auto-populated like the + // TMDB/fanart/Plex grids (no manual search), fetched lazily the first time a + // GDrive source is shown and again whenever the title changes. Picking imports + // the bytes, so the render path downstream is identical to an upload. + const gdriveQuery = (item.title ?? '').trim(); + const backdropGdrive = useGdriveBrowse({ + kind: 'poster', + active: backdropSource === 'gdrive', + query: gdriveQuery, + onImport: setCustomBackdrop, + }); + // image_type='logo' is one of the browse endpoint's allowed values, so the + // logo picker needs no new backend route. + const logoGdrive = useGdriveBrowse({ + kind: 'logo', + active: logoSource === 'gdrive', + query: gdriveQuery, + onImport: setCustomLogo, + }); + + const bdSel = ( + + ); + const plexBackdropEmpty = + backdropSource === 'plex' && bdArt?.reason && !backdrops.length && !posters.length; + const bdLabel = isAsis ? 'Poster image' : 'Backdrop'; + + // ── File-as-is (finished poster) output ──────────────────────────────────── + // File-as-is: take a finished poster (Upload or GDrive grab) and file it with + // minimal changes — optionally erase the old title (the Send to AI button bakes + // the cleaned art into the backdrop) and draw a new title — via /retext. At save + // apply_ai=false, so drawing the label costs no AI. No logo, gradient, or + // reframe; just the new label + optional CL2K border. + const [asisBorder, setAsisBorder] = useState(true); + const [asisLabel, setAsisLabel] = useState(''); // new title drawn on the poster + const [asisTextY, setAsisTextY] = useState(0.96); // vertical position (CL2K band = 96%) + const [asisPreview, setAsisPreview] = useState(null); // { b64, sig } + const [asisPreviewing, setAsisPreviewing] = useState(false); + const [asisSaving, setAsisSaving] = useState(false); + // File-as-is "Generate seasons" batch — local busy + "n/total" readout (the + // full-CL2K batch's busy/bulkProgress are parent props for a different flow). + const [asisBulkBusy, setAsisBulkBusy] = useState(false); + const [asisBulkProgress, setAsisBulkProgress] = useState(''); + // Stops the batch poll loop below once this panel is gone (its twin in Builder). + const asisMountedRef = useMountedRef(); + + // Source poster as a data URL, cached on the image's identity — the debounced + // auto-preview re-runs per keystroke and re-encoding a poster is the whole cost. + const asisEncodedRef = useRef(null); + // `signal` is optional — effect callers pass their cleanup aborter's; the + // user-invoked handlers rely on the mounted ref instead. + const asisDataUrlFromSource = useCallback( + async signal => { + if (!backdrop && !customBackdrop) return null; + const key = customBackdrop ? customSig(customBackdrop) : backdrop; + if (asisEncodedRef.current?.key === key) return asisEncodedRef.current.dataUrl; + // Mint the stream token before building the proxy URL so the client fetch + // of local Plex art carries it (rather than racing render / BLANK_IMAGE). + await ensureStreamToken(); + const url = customBackdrop?.url || urlForPath(backdrop); + if (!url) return null; + const resp = await fetch(url, { signal }); + // fetch resolves on 4xx/5xx, so without this the error body base64s into + // /retext as the poster (same guard the sync-cache imports already make). + if (!resp.ok) throw new Error(`Could not load the poster image (${resp.status})`); + const dataUrl = await readFileAsDataURL(await resp.blob()); + asisEncodedRef.current = { key, dataUrl }; + return dataUrl; + }, + [backdrop, customBackdrop] + ); + + // Send to AI — erase the masked text from the backdrop via the configured + // inpainter, then adopt the cleaned image as the (custom) backdrop so the + // preview re-renders with the text gone. The brush mask is dropped (the text + // is erased, and swapping the image clears the canvas), so a later Generate + // does NOT re-run AI. This is the only paid step in the Full CL2K flow. + const aiProvider = config?.ai_provider || 'none'; + const [aiErasing, setAiErasing] = useState(false); + // Prompt defaults to the module-settings ai_prompt, editable per-erase. + // Derived (not seeded via an effect) so it tracks config until the user types + // — null means "untouched, show the default"; '' means they cleared it. + const [aiPromptEdit, setAiPrompt] = useState(null); + const aiPrompt = aiPromptEdit ?? (config?.ai_prompt || ''); + const runBackdropErase = useCallback(async () => { + if (!backdropUrl || !maskB64 || aiProvider === 'none') return; + setAiErasing(true); + try { + // Custom uploads are sent as bytes (we already hold the b64); a remote + // CDN backdrop is sent as a path for the backend to fetch — the browser + // can't fetch image.tmdb.org directly (no CORS header). + const source = customBackdrop?.b64 + ? { image_b64: customBackdrop.b64 } + : { image_path: backdrop }; + const resp = await cl2kMakerAPI.retext({ + ...source, + mask_b64: maskB64, + apply_ai: true, + prompt: aiPrompt || config?.ai_prompt || '', + label_text: '', + border: false, + preview: true, + keep_size: true, + kind: isSeasonPoster ? 'season' : effectiveKind, + season_number: isSeasonPoster ? Number(seasonNumber) : null, + title: item.title, + tmdb_id: item.tmdb_id, + year: item.year, + tvdb_id: item.tvdb_id, + imdb_id: item.imdb_id, + }); + const erased = resp?.data?.preview_b64; + if (erased) { + setCustomBackdrop({ + b64: erased, + url: `data:image/jpeg;base64,${erased}`, + name: customBackdrop?.name || 'erased.jpg', + }); + onMaskChange(null); + toast.success('Text erased — check the preview, then Generate & save.'); + } else { + toast.error('AI returned no image'); + } + } catch (err) { + toast.error(err.message || 'AI erase failed'); + } finally { + setAiErasing(false); + } + }, [ + backdropUrl, + backdrop, + maskB64, + aiProvider, + aiPrompt, + config, + isSeasonPoster, + seasonNumber, + effectiveKind, + item, + customBackdrop, + setCustomBackdrop, + onMaskChange, + toast, + ]); + + // Detect text — OCR the current backdrop into a prefill mask for the brush + // canvas. Source bytes go the same way /retext gets them: the upload's b64 + // when we hold it, else the stored art path for the backend to fetch — the + // browser can't fetch image.tmdb.org directly (no CORS header). + // Returns the white-on-black mask PNG (b64) for BrushMask to composite in, or + // null after toasting — it never runs the removal itself. + const runDetectText = useCallback(async () => { + if (!backdropUrl) return null; + try { + const source = customBackdrop?.b64 + ? { image_b64: customBackdrop.b64 } + : { image_path: backdrop }; + const resp = await cl2kMakerAPI.detectText({ ...source, min_score: 0.5 }); + if (!resp?.data?.regions?.length) { + toast.info('No text found'); + return null; + } + return resp?.data?.mask || null; + } catch (err) { + toast.error(err.message || 'Text detection failed'); + return null; + } + }, [backdropUrl, backdrop, customBackdrop, toast]); + + // Tighten to letters — colour-key the CURRENT brushed block down to just the + // title glyph strokes, so the AI erase fills thin gaps (sharp) instead of one + // big block (blurry). `maskDataUrl` is BrushMask's live canvas; the backend + // returns the tightened white-on-black mask for it to repaint, or a "kept" + // flag when no solid-coloured title could be isolated. + const runTightenText = useCallback( + async maskDataUrl => { + if (!backdropUrl || !maskDataUrl) return null; + try { + const source = customBackdrop?.b64 + ? { image_b64: customBackdrop.b64 } + : { image_path: backdrop }; + const resp = await cl2kMakerAPI.tightenMask({ ...source, mask_b64: maskDataUrl }); + if (!resp?.data?.tightened) { + toast.info(resp?.data?.reason || 'Couldn’t isolate letters — kept your mask'); + return null; + } + // Tighten REPLACES the brushed block, so signal it: the user + // should eyeball the new mask before erasing (multi-coloured, + // white and outlined titles all key; tone-on-tone badges keep + // the block). + toast.success('Tightened to the letters — check the mask before erasing'); + return resp?.data?.mask || null; + } catch (err) { + toast.error(err.message || 'Tighten failed'); + return null; + } + }, + [backdropUrl, backdrop, customBackdrop, toast] + ); + + // Identity passed to /retext (filename + Plex match). Season info comes from + // the Poster tab's own season controls, so there's no separate field here. + const asisIds = useMemo( + () => ({ + kind: isSeasonPoster ? 'season' : effectiveKind, + season_number: isSeasonPoster ? Number(seasonNumber) : null, + title: item.title, + tmdb_id: item.tmdb_id, + year: item.year, + tvdb_id: item.tvdb_id, + imdb_id: item.imdb_id, + }), + [isSeasonPoster, seasonNumber, effectiveKind, item] + ); + + // The EXPLICIT label override sent to /retext — a banner (e.g. COMPLETE LIMITED + // SERIES) wins, else the free-text "New title" (only for non-season posters). + // The SEASON N / Specials band is NOT spelled out here: the backend derives it + // from season_number (season_band_text is the single source of truth), so an + // empty label on a season poster tells it to draw the season band. + const asisExplicitLabel = useMemo( + () => bandLabel || (isSeasonPoster ? '' : asisLabel), + [bandLabel, isSeasonPoster, asisLabel] + ); + + // Signature of the inputs that affect the as-is render. season_number is in here + // because the backend derives the band from it, so changing the season must drop + // the stale preview. A rendered preview is shown only while the sig still matches. + const asisSig = useMemo( + () => + JSON.stringify([ + backdropUrl, + asisExplicitLabel, + isSeasonPoster ? Number(seasonNumber) : null, + asisTextY, + asisBorder, + ]), + [backdropUrl, asisExplicitLabel, isSeasonPoster, seasonNumber, asisTextY, asisBorder] + ); + + // apply_ai=false: draws the (optional) new label + border on whatever the + // backdrop currently is (the Send to AI button has already baked any erase in), + // so this never spends AI credits. + const runAsisPreview = useCallback(async () => { + try { + // Inside the try: the source read fetches + encodes, so it can reject. + const image_b64 = await asisDataUrlFromSource(); + // Re-checked after every await — the panel unmounts on a build-tab + // switch, and a settled read must not start work on the dead one. + if (!asisMountedRef.current || !image_b64) return; + setAsisPreviewing(true); + const resp = await cl2kMakerAPI.retext({ + image_b64, + mask_b64: null, + apply_ai: false, + label_text: asisExplicitLabel, + text_y: asisTextY, + border: asisBorder, + preview: true, + ...asisIds, + }); + if (asisMountedRef.current) + setAsisPreview({ b64: resp?.data?.preview_b64 || null, sig: asisSig }); + } catch (err) { + if (asisMountedRef.current) toast.error(err.message || 'Preview failed'); + } finally { + if (asisMountedRef.current) setAsisPreviewing(false); + } + }, [ + asisMountedRef, + asisDataUrlFromSource, + asisExplicitLabel, + asisTextY, + asisBorder, + asisIds, + asisSig, + toast, + ]); + + const runAsisSave = useCallback(async () => { + try { + const image_b64 = await asisDataUrlFromSource(); + if (!asisMountedRef.current || !image_b64) return; + setAsisSaving(true); + const resp = await cl2kMakerAPI.retext({ + image_b64, + mask_b64: null, + apply_ai: false, + label_text: asisExplicitLabel, + text_y: asisTextY, + border: asisBorder, + preview: false, + ...asisIds, + ...saveTargets.fields, + }); + // The save completed — feedback fires even if the tab switched. + savedToast(toast, resp?.data); + } catch (err) { + toast.error(err.message || 'Save failed'); + } finally { + if (asisMountedRef.current) setAsisSaving(false); + } + }, [ + asisMountedRef, + asisDataUrlFromSource, + asisExplicitLabel, + asisTextY, + asisBorder, + asisIds, + saveTargets.fields, + toast, + ]); + + // File-as-is "Generate seasons": re-file the one source poster once per season, + // each with its own SEASON-N band (the backend derives the label). Runs in the + // background and polls progress, mirroring the full-CL2K season batch. + const runAsisBulkSeasons = useCallback(async () => { + const nums = parseSeasonList(bulkSeasons); + if (!nums.length) { + toast.error('Enter season numbers, e.g. 1,2,3'); + return; + } + try { + const image_b64 = await asisDataUrlFromSource(); + if (!asisMountedRef.current) return; + if (!image_b64) throw new Error('Upload or grab a poster first.'); + setAsisBulkBusy(true); + setAsisBulkProgress(`0/${nums.length}`); + const resp = await cl2kMakerAPI.retextSeasons({ + image_b64, + seasons: nums, + title: item.title, + tmdb_id: item.tmdb_id, + year: item.year, + tvdb_id: item.tvdb_id, + imdb_id: item.imdb_id, + text_y: asisTextY, + border: asisBorder, + ...saveTargets.fields, + }); + // The batch is already running server-side; unmounting just stops us + // watching it, exactly as the poll loop's own mounted check does. + if (!asisMountedRef.current) return; + const jobId = resp?.data?.job_id; + if (!jobId) throw new Error(resp?.message || 'Could not start season batch'); + + const d = await pollSeasonsBatch(jobId, asisMountedRef, setAsisBulkProgress); + if (d) seasonsBatchToast(toast, d); // null = unmounted, outcome unknown + } catch (err) { + if (asisMountedRef.current) toast.error(err.message || 'Season generation failed'); + } finally { + if (asisMountedRef.current) { + setAsisBulkBusy(false); + setAsisBulkProgress(''); + } + } + }, [ + asisMountedRef, + bulkSeasons, + asisDataUrlFromSource, + item, + asisTextY, + asisBorder, + saveTargets.fields, + toast, + ]); + + const asisFresh = !!(asisPreview?.b64 && asisPreview.sig === asisSig); + const asisShownSrc = asisFresh ? `data:image/jpeg;base64,${asisPreview.b64}` : backdropUrl; + + // Latest as-is render inputs, read inside the debounced effect without making + // its identity a trigger (the effect fires off asisSig). Mirrors baseRequestRef + // for the full render. + const asisPreviewInputsRef = useRef(null); + useEffect(() => { + asisPreviewInputsRef.current = { + fromSource: asisDataUrlFromSource, + label_text: asisExplicitLabel, + text_y: asisTextY, + border: asisBorder, + ids: asisIds, + sig: asisSig, + }; + }, [asisDataUrlFromSource, asisExplicitLabel, asisTextY, asisBorder, asisIds, asisSig]); + + // Auto-render the as-is preview shortly after a label/position/border change + // settles, so typing a New title (or setting a season/banner) shows on the + // preview without a manual click — matching the full render's auto-preview. + // apply_ai stays false (the Send to AI erase is a separate, paid step), so + // this never spends AI credits. + useEffect(() => { + if (!isAsis || !hasBackdrop) return undefined; + let cancelled = false; + // Abort as well as flag: `cancelled` only stops the setState, it leaves the + // source fetch + retext running (same idiom as the base auto-render above). + const aborter = new AbortController(); + const handle = setTimeout(async () => { + const p = asisPreviewInputsRef.current; + try { + const image_b64 = await p.fromSource(aborter.signal); + if (cancelled || !image_b64) return; + setAsisPreviewing(true); + const resp = await cl2kMakerAPI.retext( + { + image_b64, + mask_b64: null, + apply_ai: false, + label_text: p.label_text, + text_y: p.text_y, + border: p.border, + preview: true, + ...p.ids, + }, + { signal: aborter.signal } + ); + if (!cancelled) + setAsisPreview({ b64: resp?.data?.preview_b64 || null, sig: p.sig }); + } catch { + /* auto-render stays quiet; the Preview button surfaces errors */ + } finally { + if (!cancelled) setAsisPreviewing(false); + } + }, 300); + return () => { + cancelled = true; + aborter.abort(); + clearTimeout(handle); + }; + }, [asisSig, isAsis, hasBackdrop]); + + const fileNameHint = `${item.title || `TMDB ${item.tmdb_id}`}${ + item.year ? ` (${item.year})` : '' + }.jpg`; + return ( +
+ {/* Output mode — full CL2K render vs. file the image as-is. A second + segmented pill tucked under the asset tabs, plus a filename hint. */} +
+
+ + +
+ + → {fileNameHint} + +
+ + {isAsis ? ( + /* FILE-AS-IS single-column panel (mock cl2k-12). */ +
+
+ Preview +
+ {hasBackdrop && asisShownSrc ? ( + Finished poster preview + ) : ( + + Upload a finished poster to start. + + )} + +
+ + Preview + +
+
+
+

+ Re-file an existing poster +

+

+ Upload a finished poster and save it under DAPS naming — no CL2K + render. +

+
+ setCustomBackdrop(null)} + /> + {item.kind === 'show' && ( + + )} + {item.kind === 'show' && ( +
+
+ All seasons + setBulkSeasons(e.target.value)} + placeholder="Generate all: 1,2,3" + className="flex-1 bg-surface border border-border rounded px-2 py-1 text-sm text-fg" + /> + + Generate seasons + + {asisBulkBusy && asisBulkProgress && ( + + {asisBulkProgress} + + )} +
+

+ Files this poster once per season with its own SEASON N band + (Season 0 = Specials). Runs in the background — the count + updates as each season is saved. +

+
+ )} + {item.kind !== 'collection' && ( + + )} + + +

+ Drawn in the CL2K font at 96% — the locked CL2K band position (the + season/specials line). Set a Season number (draws SEASON N / SPECIALS); + a Banner overrides it (e.g. COMPLETE LIMITED SERIES). The New title box + is the fallback when neither is set. Brush over the old text and{' '} + Send to AI first to remove it. +

+
+ + Add CL2K white border + + +
+

+ The DAPS default 26px white frame (per the CL2K PSD). Uncheck only if + this poster already has the required border. +

+ + + Save as {fileNameHint} + +

+ .psd export is unavailable for as-is files — the original is copied, + renamed, and optionally bordered. +

+
+
+ ) : ( + /* FULL CL2K STUDIO — 3-column grid (mock cl2k-02). */ +
+ {/* LEFT: source pickers */} +
+ {!isAsis && seasonPosters.length > 0 && ( + + )} + {/* Source. As-is = a plain manual upload (no source selector). + Otherwise source-selectable: 'Upload' swaps the grid for a + custom-image dropzone; 'GDrive' for the sync-cache picker; + official posters from the same source appear below. */} + {isAsis ? ( + setCustomBackdrop(null)} + /> + ) : backdropSource === 'upload' ? ( + setCustomBackdrop(null)} + /> + ) : backdropSource === 'gdrive' ? ( + setCustomBackdrop(null)} + /> + ) : ( + <> + + {posters.length > 0 && ( + + )} + + )} + + {/* Logo source picker (grid + source tabs only — controls live + in the right column). */} + +
+ + {/* CENTER: large persistent preview + actions (never moves). The + crop framer lives in the right-column Framing group. */} +
+ {/* Width-driven on phones — a viewport-height floor there + just letterboxes the poster and pushes the controls + off the bottom. Height leads from md up. */} +
+ {hasBackdrop && previewUrl ? ( + <> + CL2K preview + {processedLogo && ( + + )} + + {showGuides && } + + + ) : ( + + {!hasBackdrop + ? 'Select a backdrop to start.' + : previewing + ? 'Rendering preview…' + : 'Preview unavailable — tap Refresh.'} + + )} +
+ + {/* Primary actions */} +
+ + Generate & save + + + Preview + + + .psd + +
+ + {backdropUrl && ( + + )} + + {/* Save targets + Generate handoff hint */} +
+ +

+ Handoff: download the backdrop, clean it in Firefly/Photoshop, then + bring it back via the backdrop{' '} + Upload source. +

+
+
+ + {/* RIGHT: framing + logo controls + accordions */} +
+ {/* FRAMING — always visible (Zoom / Vertical position + + guides). The sliders only call setZoom/setVPos/onFocusChange + — all RenderPanel props — so no CropFramer drag math moves. */} +
+ Framing + {backdropUrl && ( + + )} +
+
+ Zoom + + {(zoom ?? 1).toFixed(2)}× + +
+ setZoom(Number(e.target.value))} + className="w-full" + /> +
+
+
+ + Vertical position + + + {vPosLimits.dead ? '—' : Math.round((vPos ?? 0) * 100)} + +
+ setVPos(Number(e.target.value))} + className="w-full disabled:opacity-40" + /> +
+
+ CL2K guides + +
+
+ + {/* LOGO controls — always visible (colour mode + sliders + + invert). Touch-up brush lives in its own accordion below. */} +
+
+ Logo + +
+ + {/* SEASON · BANNER — season-number always visible; bulk in an + accordion below. Banner select for non-collections. */} + {(item.kind === 'show' || item.kind !== 'collection') && ( + <> +
+
+ Season · Banner + {item.kind === 'show' && ( + + )} + {item.kind !== 'collection' && ( +
+ +

+ Optional bottom banner in the CL2K label band (e.g. + a limited series). On a season poster it replaces + the SEASON N text — the file is still saved as the + season poster. +

+
+ )} +
+ + )} + + {/* ACCORDION: AI text removal */} + + + + + {/* ACCORDION: Logo touch-up (B/W brush over the processed logo) */} + {logoTouchUpUrl && whitenLogo && ( + + + + )} + + {/* ACCORDION: Eraser (brush parts of the logo to transparent) */} + {logoTouchUpUrl && ( + +

+ Brush over anything the extraction kept that shouldn't be + there (a stray glyph, a ® mark, edge speckle) to make it + transparent. Use the square brush for straight edges. Everything + you don't paint is left exactly as shown. +

+ +
+ )} + + {/* ACCORDION: Bulk seasons (shows only) */} + {item.kind === 'show' && ( + + + + )} + + {/* ACCORDION: Recently generated (moved from Builder level) */} + + + +
+
+ )} +
+ ); +}; + +// Presentational split for the studio right column: 'season' = the single +// Season-number input (always visible under SEASON·BANNER); 'bulk' = the +// "1,2,3 → Generate seasons" row + help (a collapsed accordion); undefined = +// the whole card. No handler logic differs by variant — props still flow. +const SeasonControls = ({ + seasonNumber, + setSeasonNumber, + bulkSeasons, + setBulkSeasons, + onBulkSeasons, + bulkProgress, + busy, + variant, +}) => { + const showSeason = variant !== 'bulk'; + const showBulk = variant !== 'season'; + const cardCls = variant + ? 'flex flex-col gap-3' + : 'bg-surface border border-border rounded-lg p-3 flex flex-col gap-3'; + return ( +
+ {!variant &&

Season variant

} + {showSeason && ( + + )} + {showBulk && ( + <> +
+ setBulkSeasons(e.target.value)} + placeholder="Generate all: 1,2,3" + className="flex-1 bg-surface border border-border rounded px-2 py-1 text-sm text-fg" + /> + + Generate seasons + + {busy && bulkProgress && ( + + {bulkProgress} + + )} +
+

+ Each season reuses the backdrop & logo from the poster you built above; + only the season number changes. Runs in the background — the count updates + as each season is saved. +

+ + )} +
+ ); +}; + +// ─── AI text-removal panel + brush canvas ────────────────────────────────── + +const AiPanel = ({ + config, + removeText, + setRemoveText, + brushSize, + setBrushSize, + backdropUrl, + mask, + onMaskChange, + hasMask, + aiBusy, + onSendToAi, + aiPrompt, + setAiPrompt, + onDetectText, + onTightenText, +}) => { + // UI-only: the large pop-out mask editor (mock cl2k-09). Reuses the same + // BrushMask + onMaskChange pipeline — no new mask handlers. Both the inline + // and pop-out canvases seed from the current `mask`, and the inline one is + // keyed on modalOpen so it remounts (and re-seeds from the updated mask) when + // the pop-out closes — strokes carry both ways. + const [modalOpen, setModalOpen] = useState(false); + const provider = config?.ai_provider || 'none'; + // Gates the Send to AI button so it doesn't silently no-op. + const aiBlock = aiUnavailableReason(config); + // Withheld unless the sidecar can serve it; Tighten stays on — it is local. + const detectText = lamaDetectReady(config) ? onDetectText : null; + return ( + <> +
+ +

+ Provider: {provider}. OpenAI re-imagines + the whole image — brush a mask over just the text to keep faces/art intact. Set + the provider/key in{' '} + + Module Settings + + . +

+ {removeText && aiBlock &&
{aiBlock}
} + {removeText && ( + <> + + {backdropUrl ? ( + + ) : ( +
+ Select a backdrop to brush a mask over. +
+ )} + {backdropUrl && ( + + )} + {backdropUrl && ( + <> + {provider !== 'lama_sidecar' && ( +