updates
This commit is contained in:
@@ -10,6 +10,9 @@ It currently supports:
|
|||||||
- Generating a wide `Thumb` image
|
- Generating a wide `Thumb` image
|
||||||
- Optionally generating a matching tall `Primary` poster
|
- Optionally generating a matching tall `Primary` poster
|
||||||
- Using Emby-known logos and backdrops already attached to the item
|
- Using Emby-known logos and backdrops already attached to the item
|
||||||
|
- Reframing any Emby or imported image in a premium artwork editor
|
||||||
|
- Exporting editor output as `Primary`, `Thumb`, or `Backdrop`
|
||||||
|
- Searching provider-backed external image sources and importing remote images into cache
|
||||||
- Applying generated artwork back to Emby
|
- Applying generated artwork back to Emby
|
||||||
- Adding optional studio badges and a red `NEW EPISODES` series tag
|
- Adding optional studio badges and a red `NEW EPISODES` series tag
|
||||||
|
|
||||||
@@ -154,6 +157,41 @@ Important:
|
|||||||
- `Apply` must remain tolerant of cache misses
|
- `Apply` must remain tolerant of cache misses
|
||||||
- Do not reintroduce a hard dependency on a pre-existing cache file only
|
- Do not reintroduce a hard dependency on a pre-existing cache file only
|
||||||
|
|
||||||
|
### 5. Artwork editor flow
|
||||||
|
|
||||||
|
Browser:
|
||||||
|
|
||||||
|
- The editor appears after an Emby item is selected
|
||||||
|
- Editor state is kept separate from the existing thumb-generator `state` object
|
||||||
|
- The editor has three panels:
|
||||||
|
- source image panel
|
||||||
|
- fixed-ratio editor canvas
|
||||||
|
- export panel
|
||||||
|
- Users can choose Emby `Primary`, selected Emby `Backdrop`, Emby `Thumb`, or an imported remote image
|
||||||
|
- Users can target `poster`, `thumb`, or `backdrop`
|
||||||
|
- Users can drag, zoom, reset, and switch `cover` / `contain`
|
||||||
|
- Landscape-to-portrait conversion supports blurred, mirrored, or solid background fill
|
||||||
|
- `POST /api/artwork/export` generates a live preview and returns cache headers
|
||||||
|
- `POST /api/artwork/apply` uploads the cached or regenerated export to the matching Emby image type
|
||||||
|
|
||||||
|
Server:
|
||||||
|
|
||||||
|
- Editor rendering is separate from `generate_thumbnail(...)`
|
||||||
|
- `render_artwork_editor_image(...)` handles fixed-frame reframing and high-quality JPEG output
|
||||||
|
- `fit_image_positioned(...)` is the shared crop/contain positioning helper
|
||||||
|
- `build_editor_fill(...)` handles blurred, mirrored, and solid background fill
|
||||||
|
- `resolve_editor_source_bytes(...)` loads either Emby source bytes or cached remote imports
|
||||||
|
- Editor exports are cached as `<cache_key>.editor.jpg`
|
||||||
|
- Remote imports are cached under `cache/imports/`
|
||||||
|
|
||||||
|
Remote image search:
|
||||||
|
|
||||||
|
- Provider lookup is intentionally abstracted behind `ImageSearchProvider`
|
||||||
|
- `GET /api/artwork/providers` exposes available providers to the UI
|
||||||
|
- `GET /api/artwork/search` calls the selected provider
|
||||||
|
- `POST /api/artwork/import` downloads, validates, size-limits, and caches a selected remote image
|
||||||
|
- The first provider is Wikimedia Commons; add future providers by implementing `ImageSearchProvider` and registering it in `IMAGE_SEARCH_PROVIDERS`
|
||||||
|
|
||||||
## Rendering Pipeline
|
## Rendering Pipeline
|
||||||
|
|
||||||
The central renderer is `generate_thumbnail(...)`.
|
The central renderer is `generate_thumbnail(...)`.
|
||||||
@@ -196,6 +234,8 @@ Current cache helpers:
|
|||||||
|
|
||||||
- Thumb cache: `<cache_key>.png`
|
- Thumb cache: `<cache_key>.png`
|
||||||
- Primary cache: `<cache_key>.primary.png`
|
- Primary cache: `<cache_key>.primary.png`
|
||||||
|
- Artwork editor export cache: `<cache_key>.editor.jpg`
|
||||||
|
- Imported remote image cache: `cache/imports/<sha256>.img`
|
||||||
|
|
||||||
The cache key is derived from rendering inputs such as:
|
The cache key is derived from rendering inputs such as:
|
||||||
|
|
||||||
@@ -279,6 +319,22 @@ When adding a new visual option:
|
|||||||
|
|
||||||
If any of those steps are skipped, behavior will drift.
|
If any of those steps are skipped, behavior will drift.
|
||||||
|
|
||||||
|
Artwork editor state is intentionally separate:
|
||||||
|
|
||||||
|
- `editorState.sourceKind`
|
||||||
|
- `editorState.sourceType`
|
||||||
|
- `editorState.sourceIndex`
|
||||||
|
- `editorState.importId`
|
||||||
|
- `editorState.cacheKey`
|
||||||
|
- transform controls from the editor sliders
|
||||||
|
|
||||||
|
Keep editor payloads aligned across:
|
||||||
|
|
||||||
|
- `/api/artwork/export`
|
||||||
|
- `/api/artwork/apply`
|
||||||
|
- `build_editor_cache_key(...)`
|
||||||
|
- `render_artwork_editor_image(...)`
|
||||||
|
|
||||||
## Laptop UX Constraints
|
## Laptop UX Constraints
|
||||||
|
|
||||||
The app has custom responsive rules for laptop-sized screens.
|
The app has custom responsive rules for laptop-sized screens.
|
||||||
@@ -378,8 +434,7 @@ python -m py_compile app.py
|
|||||||
|
|
||||||
Likely next improvements:
|
Likely next improvements:
|
||||||
|
|
||||||
- Separate primary preview in the UI
|
- Additional image search providers from Emby metadata sources
|
||||||
- Remote/provider image search from Emby metadata sources
|
|
||||||
- Better poster-specific typography/layout rules
|
- Better poster-specific typography/layout rules
|
||||||
- Better user-visible error reporting for partial apply failures
|
- Better user-visible error reporting for partial apply failures
|
||||||
- Cache cleanup strategy
|
- Cache cleanup strategy
|
||||||
|
|||||||
+10
-6
@@ -1,5 +1,9 @@
|
|||||||
FROM python:3.11-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
@@ -7,14 +11,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
fonts-liberation \
|
fonts-liberation \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt ./
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN python -m pip install --upgrade pip \
|
||||||
|
&& python -m pip install -r requirements.txt
|
||||||
# Pre-download the rembg u2net model
|
|
||||||
RUN python -c "from rembg import remove; remove(b'\x89PNG\r\n')" || true
|
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p /app/cache /app/output
|
||||||
|
|
||||||
EXPOSE 8500
|
EXPOSE 8500
|
||||||
|
|
||||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8500"]
|
CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8500"]
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import os
|
|||||||
import hashlib
|
import hashlib
|
||||||
import base64
|
import base64
|
||||||
import time
|
import time
|
||||||
|
import urllib.parse
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -22,14 +24,22 @@ EMBY_URL = os.environ.get("EMBY_URL", "http://10.0.0.2:8096")
|
|||||||
EMBY_API_KEY = os.environ.get("EMBY_API_KEY", "b9af54b630f6448289ab96422add567a")
|
EMBY_API_KEY = os.environ.get("EMBY_API_KEY", "b9af54b630f6448289ab96422add567a")
|
||||||
CACHE_DIR = Path("cache")
|
CACHE_DIR = Path("cache")
|
||||||
CACHE_DIR.mkdir(exist_ok=True)
|
CACHE_DIR.mkdir(exist_ok=True)
|
||||||
|
IMPORT_CACHE_DIR = CACHE_DIR / "imports"
|
||||||
|
IMPORT_CACHE_DIR.mkdir(exist_ok=True)
|
||||||
RENDER_VERSION = "series-banner-v10"
|
RENDER_VERSION = "series-banner-v10"
|
||||||
|
|
||||||
THUMB_WIDTH = 800
|
THUMB_WIDTH = 800
|
||||||
THUMB_HEIGHT = 450
|
THUMB_HEIGHT = 450
|
||||||
PRIMARY_WIDTH = 1000
|
PRIMARY_WIDTH = 1000
|
||||||
PRIMARY_HEIGHT = 1500
|
PRIMARY_HEIGHT = 1500
|
||||||
PRIMARY_MIN_ZOOM = 0.6
|
PRIMARY_MIN_ZOOM = 1.0
|
||||||
PRIMARY_MAX_ZOOM = 2.6
|
PRIMARY_MAX_ZOOM = 2.6
|
||||||
|
REMOTE_IMAGE_MAX_BYTES = 18 * 1024 * 1024
|
||||||
|
EDITOR_TARGETS = {
|
||||||
|
"poster": {"width": 1000, "height": 1500, "emby_type": "Primary", "label": "Poster"},
|
||||||
|
"thumb": {"width": 1920, "height": 1080, "emby_type": "Thumb", "label": "Thumb"},
|
||||||
|
"backdrop": {"width": 1920, "height": 1080, "emby_type": "Backdrop", "label": "Backdrop"},
|
||||||
|
}
|
||||||
HTTP_TIMEOUT = 30.0
|
HTTP_TIMEOUT = 30.0
|
||||||
http_client: httpx.AsyncClient | None = None
|
http_client: httpx.AsyncClient | None = None
|
||||||
AIRING_LOOKUP_CACHE_TTL = 900
|
AIRING_LOOKUP_CACHE_TTL = 900
|
||||||
@@ -77,6 +87,169 @@ def load_image_from_bytes(image_bytes: bytes, mode: str = "RGB") -> Image.Image:
|
|||||||
return img.convert(mode).copy()
|
return img.convert(mode).copy()
|
||||||
|
|
||||||
|
|
||||||
|
def encode_jpeg_bytes(img: Image.Image, *, quality: int = 95) -> bytes:
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.convert("RGB").save(buf, format="JPEG", quality=quality, optimize=True, progressive=True)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_image_bytes(image_bytes: bytes, *, max_pixels: int = 24_000_000) -> tuple[int, int, str]:
|
||||||
|
try:
|
||||||
|
with Image.open(io.BytesIO(image_bytes)) as img:
|
||||||
|
img.verify()
|
||||||
|
width, height = img.size
|
||||||
|
fmt = img.format or "image"
|
||||||
|
except (UnidentifiedImageError, OSError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="The downloaded file is not a valid image.") from exc
|
||||||
|
if width * height > max_pixels:
|
||||||
|
raise HTTPException(status_code=400, detail="Image is too large to process safely.")
|
||||||
|
return width, height, fmt
|
||||||
|
|
||||||
|
|
||||||
|
def get_editor_target(target_mode: str) -> dict:
|
||||||
|
target = EDITOR_TARGETS.get(target_mode)
|
||||||
|
if target is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid artwork target mode.")
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def get_import_cache_path(import_id: str) -> Path:
|
||||||
|
if not import_id or any(ch not in "0123456789abcdef" for ch in import_id.lower()):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid imported image id.")
|
||||||
|
path = IMPORT_CACHE_DIR / f"{import_id}.img"
|
||||||
|
if not path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Imported image was not found in cache.")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_editor_source_bytes(body: dict) -> bytes:
|
||||||
|
source_kind = body.get("source_kind", "emby")
|
||||||
|
item_id = body.get("item_id")
|
||||||
|
if source_kind == "import":
|
||||||
|
return get_import_cache_path(body.get("import_id", "")).read_bytes()
|
||||||
|
if source_kind != "emby":
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid source image kind.")
|
||||||
|
if not item_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing item id.")
|
||||||
|
image_type = body.get("source_type", "Primary")
|
||||||
|
if image_type not in {"Primary", "Thumb", "Backdrop"}:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid Emby source image type.")
|
||||||
|
index_raw = body.get("source_index", None)
|
||||||
|
index = int(index_raw) if index_raw is not None else None
|
||||||
|
return await emby_get_image(item_id, image_type, index=index)
|
||||||
|
|
||||||
|
|
||||||
|
def image_to_data_url(image_bytes: bytes, media_type: str = "image/jpeg") -> str:
|
||||||
|
return f"data:{media_type};base64,{base64.b64encode(image_bytes).decode('ascii')}"
|
||||||
|
|
||||||
|
|
||||||
|
def fit_image_positioned(
|
||||||
|
img: Image.Image,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
*,
|
||||||
|
fit_mode: str = "cover",
|
||||||
|
zoom: float = 1.0,
|
||||||
|
pan_x: float = 0.0,
|
||||||
|
pan_y: float = 0.0,
|
||||||
|
) -> tuple[Image.Image, tuple[int, int]]:
|
||||||
|
fit_mode = fit_mode if fit_mode in {"cover", "contain"} else "cover"
|
||||||
|
pan_x = max(-1.0, min(1.0, float(pan_x)))
|
||||||
|
pan_y = max(-1.0, min(1.0, float(pan_y)))
|
||||||
|
zoom = max(0.25, min(5.0, float(zoom)))
|
||||||
|
scale_fn = max if fit_mode == "cover" else min
|
||||||
|
scale = scale_fn(width / max(1, img.width), height / max(1, img.height)) * zoom
|
||||||
|
fitted_w = max(1, int(round(img.width * scale)))
|
||||||
|
fitted_h = max(1, int(round(img.height * scale)))
|
||||||
|
fitted = img.resize((fitted_w, fitted_h), Image.LANCZOS)
|
||||||
|
|
||||||
|
if fitted_w >= width:
|
||||||
|
x = -int(round((fitted_w - width) * ((pan_x + 1.0) / 2.0)))
|
||||||
|
else:
|
||||||
|
x = int(round((width - fitted_w) * ((pan_x + 1.0) / 2.0)))
|
||||||
|
if fitted_h >= height:
|
||||||
|
y = -int(round((fitted_h - height) * ((pan_y + 1.0) / 2.0)))
|
||||||
|
else:
|
||||||
|
y = int(round((height - fitted_h) * ((pan_y + 1.0) / 2.0)))
|
||||||
|
return fitted, (x, y)
|
||||||
|
|
||||||
|
|
||||||
|
def build_editor_fill(
|
||||||
|
img: Image.Image,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
*,
|
||||||
|
fill_mode: str,
|
||||||
|
fill_color: str | None,
|
||||||
|
) -> Image.Image:
|
||||||
|
if fill_mode == "solid":
|
||||||
|
try:
|
||||||
|
color = ImageColor.getrgb(fill_color or "#111318")
|
||||||
|
except ValueError:
|
||||||
|
color = (17, 19, 24)
|
||||||
|
return Image.new("RGB", (width, height), color)
|
||||||
|
|
||||||
|
base = cover_crop(img, width, height)
|
||||||
|
if fill_mode == "mirror":
|
||||||
|
base = ImageOps.mirror(base)
|
||||||
|
overlay = Image.new("RGBA", (width, height), (0, 0, 0, 54))
|
||||||
|
return Image.alpha_composite(base.convert("RGBA"), overlay).convert("RGB")
|
||||||
|
|
||||||
|
base = base.filter(ImageFilter.GaussianBlur(radius=max(18, int(min(width, height) * 0.035))))
|
||||||
|
overlay = Image.new("RGBA", (width, height), (0, 0, 0, 70))
|
||||||
|
return Image.alpha_composite(base.convert("RGBA"), overlay).convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
|
def render_artwork_editor_image(
|
||||||
|
source_bytes: bytes,
|
||||||
|
*,
|
||||||
|
target_mode: str = "poster",
|
||||||
|
fit_mode: str = "cover",
|
||||||
|
fill_mode: str = "blur",
|
||||||
|
fill_color: str | None = "#101318",
|
||||||
|
zoom: float = 1.0,
|
||||||
|
pan_x: float = 0.0,
|
||||||
|
pan_y: float = 0.0,
|
||||||
|
quality: int = 95,
|
||||||
|
) -> bytes:
|
||||||
|
target = get_editor_target(target_mode)
|
||||||
|
width = target["width"]
|
||||||
|
height = target["height"]
|
||||||
|
source = load_image_from_bytes(source_bytes, mode="RGB")
|
||||||
|
canvas = build_editor_fill(source, width, height, fill_mode=fill_mode, fill_color=fill_color)
|
||||||
|
fitted, (x, y) = fit_image_positioned(
|
||||||
|
source,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
fit_mode=fit_mode,
|
||||||
|
zoom=zoom,
|
||||||
|
pan_x=pan_x,
|
||||||
|
pan_y=pan_y,
|
||||||
|
)
|
||||||
|
canvas.paste(fitted, (x, y))
|
||||||
|
return encode_jpeg_bytes(canvas, quality=max(80, min(98, int(quality))))
|
||||||
|
|
||||||
|
|
||||||
|
def build_editor_cache_key(body: dict) -> str:
|
||||||
|
payload = {
|
||||||
|
"version": "artwork-editor-v1",
|
||||||
|
"item_id": body.get("item_id"),
|
||||||
|
"source_kind": body.get("source_kind", "emby"),
|
||||||
|
"source_type": body.get("source_type", "Primary"),
|
||||||
|
"source_index": body.get("source_index"),
|
||||||
|
"import_id": body.get("import_id"),
|
||||||
|
"target_mode": body.get("target_mode", "poster"),
|
||||||
|
"fit_mode": body.get("fit_mode", "cover"),
|
||||||
|
"fill_mode": body.get("fill_mode", "blur"),
|
||||||
|
"fill_color": body.get("fill_color", "#101318"),
|
||||||
|
"zoom": round(float(body.get("zoom", 1.0)), 4),
|
||||||
|
"pan_x": round(float(body.get("pan_x", 0.0)), 4),
|
||||||
|
"pan_y": round(float(body.get("pan_y", 0.0)), 4),
|
||||||
|
"quality": int(body.get("quality", 95)),
|
||||||
|
}
|
||||||
|
return hashlib.md5(repr(payload).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
# --- Emby API helpers ---
|
# --- Emby API helpers ---
|
||||||
|
|
||||||
def get_http_client() -> httpx.AsyncClient:
|
def get_http_client() -> httpx.AsyncClient:
|
||||||
@@ -204,6 +377,75 @@ async def emby_upload_image(item_id: str, image_bytes: bytes, image_type: str =
|
|||||||
return r.status_code
|
return r.status_code
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ArtworkSearchResult:
|
||||||
|
id: str
|
||||||
|
title: str
|
||||||
|
thumbnail_url: str
|
||||||
|
image_url: str
|
||||||
|
source_url: str
|
||||||
|
provider: str
|
||||||
|
width: int | None = None
|
||||||
|
height: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ImageSearchProvider:
|
||||||
|
key = "base"
|
||||||
|
label = "Base"
|
||||||
|
|
||||||
|
async def search(self, query: str, limit: int = 12) -> list[ArtworkSearchResult]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class WikimediaImageSearchProvider(ImageSearchProvider):
|
||||||
|
key = "wikimedia"
|
||||||
|
label = "Wikimedia Commons"
|
||||||
|
|
||||||
|
async def search(self, query: str, limit: int = 12) -> list[ArtworkSearchResult]:
|
||||||
|
client = get_http_client()
|
||||||
|
params = {
|
||||||
|
"action": "query",
|
||||||
|
"generator": "search",
|
||||||
|
"gsrnamespace": "6",
|
||||||
|
"gsrsearch": query,
|
||||||
|
"gsrlimit": str(max(1, min(24, limit))),
|
||||||
|
"prop": "imageinfo",
|
||||||
|
"iiprop": "url|size|mime",
|
||||||
|
"iiurlwidth": "420",
|
||||||
|
"format": "json",
|
||||||
|
"origin": "*",
|
||||||
|
}
|
||||||
|
r = await client.get("https://commons.wikimedia.org/w/api.php", params=params)
|
||||||
|
r.raise_for_status()
|
||||||
|
pages = (r.json().get("query") or {}).get("pages") or {}
|
||||||
|
results: list[ArtworkSearchResult] = []
|
||||||
|
for page in pages.values():
|
||||||
|
info = (page.get("imageinfo") or [{}])[0]
|
||||||
|
mime = info.get("mime", "")
|
||||||
|
if not mime.startswith("image/"):
|
||||||
|
continue
|
||||||
|
image_url = info.get("url")
|
||||||
|
if not image_url:
|
||||||
|
continue
|
||||||
|
title = (page.get("title") or "Artwork").replace("File:", "", 1)
|
||||||
|
results.append(ArtworkSearchResult(
|
||||||
|
id=hashlib.md5(image_url.encode("utf-8")).hexdigest(),
|
||||||
|
title=title,
|
||||||
|
thumbnail_url=info.get("thumburl") or image_url,
|
||||||
|
image_url=image_url,
|
||||||
|
source_url=info.get("descriptionurl") or image_url,
|
||||||
|
provider=self.key,
|
||||||
|
width=info.get("width"),
|
||||||
|
height=info.get("height"),
|
||||||
|
))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
IMAGE_SEARCH_PROVIDERS: dict[str, ImageSearchProvider] = {
|
||||||
|
WikimediaImageSearchProvider.key: WikimediaImageSearchProvider(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def parse_emby_datetime(value: str | None) -> datetime | None:
|
def parse_emby_datetime(value: str | None) -> datetime | None:
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
@@ -527,65 +769,6 @@ def cover_crop_positioned(
|
|||||||
pan_y = max(-1.0, min(1.0, float(pan_y)))
|
pan_y = max(-1.0, min(1.0, float(pan_y)))
|
||||||
zoom = max(PRIMARY_MIN_ZOOM, min(PRIMARY_MAX_ZOOM, float(zoom)))
|
zoom = max(PRIMARY_MIN_ZOOM, min(PRIMARY_MAX_ZOOM, float(zoom)))
|
||||||
|
|
||||||
if zoom < 1.0:
|
|
||||||
contain_scale = min(width / max(1, img.width), height / max(1, img.height))
|
|
||||||
cover_scale = max(width / max(1, img.width), height / max(1, img.height))
|
|
||||||
zoom_t = (zoom - PRIMARY_MIN_ZOOM) / max(0.0001, 1.0 - PRIMARY_MIN_ZOOM)
|
|
||||||
zoom_t = max(0.0, min(1.0, zoom_t))
|
|
||||||
scale = contain_scale + (cover_scale - contain_scale) * zoom_t
|
|
||||||
|
|
||||||
fitted_w = max(1, int(round(img.width * scale)))
|
|
||||||
fitted_h = max(1, int(round(img.height * scale)))
|
|
||||||
fitted = img.resize((fitted_w, fitted_h), Image.LANCZOS)
|
|
||||||
if fitted_w >= width:
|
|
||||||
paste_x = -int(round((fitted_w - width) * ((pan_x + 1.0) / 2.0)))
|
|
||||||
else:
|
|
||||||
paste_x = int(round((width - fitted_w) * ((pan_x + 1.0) / 2.0)))
|
|
||||||
if fitted_h >= height:
|
|
||||||
paste_y = -int(round((fitted_h - height) * ((pan_y + 1.0) / 2.0)))
|
|
||||||
else:
|
|
||||||
paste_y = int(round((height - fitted_h) * ((pan_y + 1.0) / 2.0)))
|
|
||||||
|
|
||||||
canvas = Image.new("RGB", (width, height))
|
|
||||||
left_gap = max(0, paste_x)
|
|
||||||
top_gap = max(0, paste_y)
|
|
||||||
right_gap = max(0, width - (paste_x + fitted_w))
|
|
||||||
bottom_gap = max(0, height - (paste_y + fitted_h))
|
|
||||||
strip = max(1, min(12, fitted_w, fitted_h))
|
|
||||||
|
|
||||||
if top_gap:
|
|
||||||
top_strip = fitted.crop((0, 0, fitted_w, strip)).resize((fitted_w, top_gap), Image.LANCZOS)
|
|
||||||
canvas.paste(top_strip, (paste_x, 0))
|
|
||||||
if bottom_gap:
|
|
||||||
bottom_strip = fitted.crop((0, fitted_h - strip, fitted_w, fitted_h)).resize((fitted_w, bottom_gap), Image.LANCZOS)
|
|
||||||
canvas.paste(bottom_strip, (paste_x, paste_y + fitted_h))
|
|
||||||
if left_gap:
|
|
||||||
left_strip = fitted.crop((0, 0, strip, fitted_h)).resize((left_gap, fitted_h), Image.LANCZOS)
|
|
||||||
canvas.paste(left_strip, (0, paste_y))
|
|
||||||
if right_gap:
|
|
||||||
right_strip = fitted.crop((fitted_w - strip, 0, fitted_w, fitted_h)).resize((right_gap, fitted_h), Image.LANCZOS)
|
|
||||||
canvas.paste(right_strip, (paste_x + fitted_w, paste_y))
|
|
||||||
if top_gap and left_gap:
|
|
||||||
canvas.paste(fitted.crop((0, 0, strip, strip)).resize((left_gap, top_gap), Image.LANCZOS), (0, 0))
|
|
||||||
if top_gap and right_gap:
|
|
||||||
canvas.paste(
|
|
||||||
fitted.crop((fitted_w - strip, 0, fitted_w, strip)).resize((right_gap, top_gap), Image.LANCZOS),
|
|
||||||
(paste_x + fitted_w, 0),
|
|
||||||
)
|
|
||||||
if bottom_gap and left_gap:
|
|
||||||
canvas.paste(
|
|
||||||
fitted.crop((0, fitted_h - strip, strip, fitted_h)).resize((left_gap, bottom_gap), Image.LANCZOS),
|
|
||||||
(0, paste_y + fitted_h),
|
|
||||||
)
|
|
||||||
if bottom_gap and right_gap:
|
|
||||||
canvas.paste(
|
|
||||||
fitted.crop((fitted_w - strip, fitted_h - strip, fitted_w, fitted_h)).resize((right_gap, bottom_gap), Image.LANCZOS),
|
|
||||||
(paste_x + fitted_w, paste_y + fitted_h),
|
|
||||||
)
|
|
||||||
|
|
||||||
canvas.paste(fitted, (paste_x, paste_y))
|
|
||||||
return canvas
|
|
||||||
|
|
||||||
target_ratio = width / max(1, height)
|
target_ratio = width / max(1, height)
|
||||||
source_ratio = img.width / max(1, img.height)
|
source_ratio = img.width / max(1, img.height)
|
||||||
|
|
||||||
@@ -814,9 +997,20 @@ def generate_thumbnail(
|
|||||||
bg_color = (15, 15, 25)
|
bg_color = (15, 15, 25)
|
||||||
bg = Image.new("RGB", (width, height), bg_color)
|
bg = Image.new("RGB", (width, height), bg_color)
|
||||||
else:
|
else:
|
||||||
# Blurred poster fallback
|
poster = load_image_from_bytes(poster_bytes, mode="RGB")
|
||||||
bg = load_image_from_bytes(poster_bytes, mode="RGB")
|
if is_tall_layout:
|
||||||
bg = cover_crop(bg, width, height)
|
bg = build_tall_backdrop_background(
|
||||||
|
poster,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
dim_factor=bg_dim,
|
||||||
|
zoom=primary_zoom,
|
||||||
|
pan_x=primary_pan_x,
|
||||||
|
pan_y=primary_pan_y,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Blurred poster fallback for landscape thumbs.
|
||||||
|
bg = cover_crop(poster, width, height)
|
||||||
bg = bg.filter(ImageFilter.GaussianBlur(radius=20))
|
bg = bg.filter(ImageFilter.GaussianBlur(radius=20))
|
||||||
bg = bg.point(lambda p: int(p * bg_dim * 0.85))
|
bg = bg.point(lambda p: int(p * bg_dim * 0.85))
|
||||||
|
|
||||||
@@ -1319,6 +1513,172 @@ async def get_cached_primary_preview(cache_key: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/artwork/providers")
|
||||||
|
async def list_artwork_search_providers():
|
||||||
|
return {
|
||||||
|
"providers": [
|
||||||
|
{"key": provider.key, "label": provider.label}
|
||||||
|
for provider in IMAGE_SEARCH_PROVIDERS.values()
|
||||||
|
],
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"key": key,
|
||||||
|
"label": value["label"],
|
||||||
|
"width": value["width"],
|
||||||
|
"height": value["height"],
|
||||||
|
"emby_type": value["emby_type"],
|
||||||
|
}
|
||||||
|
for key, value in EDITOR_TARGETS.items()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/artwork/search")
|
||||||
|
async def search_external_artwork(
|
||||||
|
q: str = Query(..., min_length=2),
|
||||||
|
provider: str = Query("wikimedia"),
|
||||||
|
limit: int = Query(12, ge=1, le=24),
|
||||||
|
):
|
||||||
|
search_provider = IMAGE_SEARCH_PROVIDERS.get(provider)
|
||||||
|
if search_provider is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Unknown image search provider.")
|
||||||
|
try:
|
||||||
|
results = await search_provider.search(q, limit=limit)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Image search failed: {exc}") from exc
|
||||||
|
return {
|
||||||
|
"provider": provider,
|
||||||
|
"items": [result.__dict__ for result in results],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/artwork/import")
|
||||||
|
async def import_remote_artwork(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
image_url = body.get("image_url", "")
|
||||||
|
if not image_url:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing image URL.")
|
||||||
|
parsed = urllib.parse.urlparse(image_url)
|
||||||
|
if parsed.scheme not in {"http", "https"}:
|
||||||
|
raise HTTPException(status_code=400, detail="Only http and https image URLs are supported.")
|
||||||
|
|
||||||
|
client = get_http_client()
|
||||||
|
try:
|
||||||
|
r = await client.get(image_url, follow_redirects=True)
|
||||||
|
r.raise_for_status()
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Could not import remote image: {exc}") from exc
|
||||||
|
|
||||||
|
content_type = r.headers.get("content-type", "").split(";")[0].lower()
|
||||||
|
if content_type and not content_type.startswith("image/"):
|
||||||
|
raise HTTPException(status_code=400, detail="Remote URL did not return an image.")
|
||||||
|
image_bytes = r.content
|
||||||
|
if len(image_bytes) > REMOTE_IMAGE_MAX_BYTES:
|
||||||
|
raise HTTPException(status_code=400, detail="Remote image is too large.")
|
||||||
|
width, height, fmt = validate_image_bytes(image_bytes)
|
||||||
|
import_id = hashlib.sha256(image_bytes).hexdigest()
|
||||||
|
import_path = IMPORT_CACHE_DIR / f"{import_id}.img"
|
||||||
|
if not import_path.exists():
|
||||||
|
import_path.write_bytes(image_bytes)
|
||||||
|
return {
|
||||||
|
"id": import_id,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"format": fmt,
|
||||||
|
"preview_url": f"/api/artwork/import/{import_id}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/artwork/import/{import_id}")
|
||||||
|
async def get_imported_artwork(import_id: str):
|
||||||
|
image_bytes = get_import_cache_path(import_id).read_bytes()
|
||||||
|
return Response(
|
||||||
|
content=image_bytes,
|
||||||
|
media_type="image/jpeg",
|
||||||
|
headers={"Cache-Control": "public, max-age=86400"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/artwork/export")
|
||||||
|
async def export_artwork(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
source_bytes = await resolve_editor_source_bytes(body)
|
||||||
|
cache_key = build_editor_cache_key(body)
|
||||||
|
cache_path = CACHE_DIR / f"{cache_key}.editor.jpg"
|
||||||
|
if not cache_path.exists():
|
||||||
|
cache_path.write_bytes(render_artwork_editor_image(
|
||||||
|
source_bytes,
|
||||||
|
target_mode=body.get("target_mode", "poster"),
|
||||||
|
fit_mode=body.get("fit_mode", "cover"),
|
||||||
|
fill_mode=body.get("fill_mode", "blur"),
|
||||||
|
fill_color=body.get("fill_color", "#101318"),
|
||||||
|
zoom=float(body.get("zoom", 1.0)),
|
||||||
|
pan_x=float(body.get("pan_x", 0.0)),
|
||||||
|
pan_y=float(body.get("pan_y", 0.0)),
|
||||||
|
quality=int(body.get("quality", 95)),
|
||||||
|
))
|
||||||
|
target = get_editor_target(body.get("target_mode", "poster"))
|
||||||
|
return Response(
|
||||||
|
content=cache_path.read_bytes(),
|
||||||
|
media_type="image/jpeg",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"X-Editor-Cache-Key": cache_key,
|
||||||
|
"X-Editor-Width": str(target["width"]),
|
||||||
|
"X-Editor-Height": str(target["height"]),
|
||||||
|
"X-Editor-Emby-Type": target["emby_type"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/artwork/export/{cache_key}")
|
||||||
|
async def get_exported_artwork(cache_key: str):
|
||||||
|
if not cache_key or any(ch not in "0123456789abcdef" for ch in cache_key.lower()):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid export cache key.")
|
||||||
|
cache_path = CACHE_DIR / f"{cache_key}.editor.jpg"
|
||||||
|
if not cache_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Export was not found in cache.")
|
||||||
|
return Response(
|
||||||
|
content=cache_path.read_bytes(),
|
||||||
|
media_type="image/jpeg",
|
||||||
|
headers={"Cache-Control": "no-store"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/artwork/apply")
|
||||||
|
async def apply_artwork_export(request: Request):
|
||||||
|
body = await request.json()
|
||||||
|
item_id = body.get("item_id")
|
||||||
|
if not item_id:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing item id.")
|
||||||
|
target = get_editor_target(body.get("target_mode", "poster"))
|
||||||
|
cache_key = body.get("cache_key") or build_editor_cache_key(body)
|
||||||
|
cache_path = CACHE_DIR / f"{cache_key}.editor.jpg"
|
||||||
|
if cache_path.exists():
|
||||||
|
image_bytes = cache_path.read_bytes()
|
||||||
|
else:
|
||||||
|
source_bytes = await resolve_editor_source_bytes(body)
|
||||||
|
image_bytes = render_artwork_editor_image(
|
||||||
|
source_bytes,
|
||||||
|
target_mode=body.get("target_mode", "poster"),
|
||||||
|
fit_mode=body.get("fit_mode", "cover"),
|
||||||
|
fill_mode=body.get("fill_mode", "blur"),
|
||||||
|
fill_color=body.get("fill_color", "#101318"),
|
||||||
|
zoom=float(body.get("zoom", 1.0)),
|
||||||
|
pan_x=float(body.get("pan_x", 0.0)),
|
||||||
|
pan_y=float(body.get("pan_y", 0.0)),
|
||||||
|
quality=int(body.get("quality", 95)),
|
||||||
|
)
|
||||||
|
cache_path.write_bytes(image_bytes)
|
||||||
|
status = await emby_upload_image(item_id, image_bytes, target["emby_type"])
|
||||||
|
return {
|
||||||
|
"status": "applied",
|
||||||
|
"emby_type": target["emby_type"],
|
||||||
|
"code": status,
|
||||||
|
"cache_key": cache_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/generate")
|
@app.post("/api/generate")
|
||||||
async def generate(request: Request):
|
async def generate(request: Request):
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
|
|||||||
+684
-7
@@ -311,6 +311,30 @@
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
.primary-preview-zoom {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.primary-preview-zoom-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid rgba(255,255,255,0.14);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(0,0,0,0.58);
|
||||||
|
color: rgba(255,255,255,0.92);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.primary-preview-zoom-btn:hover {
|
||||||
|
background: rgba(0,0,0,0.72);
|
||||||
|
}
|
||||||
.primary-preview-empty {
|
.primary-preview-empty {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -338,6 +362,195 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.artwork-editor {
|
||||||
|
margin: 0 clamp(16px, 2.4vw, 28px) clamp(14px, 2vw, 22px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
background: linear-gradient(135deg, rgba(26,32,48,0.92), rgba(13,15,18,0.94));
|
||||||
|
box-shadow: 0 18px 60px rgba(0,0,0,0.35);
|
||||||
|
display: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.artwork-editor.visible { display: block; }
|
||||||
|
.editor-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.editor-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.editor-subtitle {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.editor-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(190px, 0.8fr) minmax(260px, 1.2fr) minmax(220px, 0.9fr);
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.editor-panel {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r);
|
||||||
|
background: rgba(13,15,18,0.54);
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.editor-panel h3 {
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-2);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.editor-source-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.editor-source-btn {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 9px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.editor-source-btn.active {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||||
|
}
|
||||||
|
.editor-canvas-wrap {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
.editor-canvas {
|
||||||
|
position: relative;
|
||||||
|
width: min(100%, 330px);
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #07080a;
|
||||||
|
border: 1px solid rgba(255,255,255,0.08);
|
||||||
|
box-shadow: 0 20px 70px rgba(0,0,0,0.55);
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.editor-canvas.thumb,
|
||||||
|
.editor-canvas.backdrop { aspect-ratio: 16 / 9; width: min(100%, 470px); }
|
||||||
|
.editor-canvas.dragging { cursor: grabbing; }
|
||||||
|
.editor-canvas img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.editor-canvas-empty {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.editor-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.editor-row label {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.editor-row select,
|
||||||
|
.editor-row input[type="text"],
|
||||||
|
.editor-row input[type="color"] {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.editor-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.editor-note {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 500;
|
||||||
|
display: none;
|
||||||
|
place-items: center;
|
||||||
|
background: rgba(0,0,0,0.72);
|
||||||
|
padding: 22px;
|
||||||
|
}
|
||||||
|
.modal-backdrop.visible { display: grid; }
|
||||||
|
.modal-card {
|
||||||
|
width: min(920px, 100%);
|
||||||
|
max-height: min(760px, 92vh);
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: 0 24px 90px rgba(0,0,0,0.6);
|
||||||
|
}
|
||||||
|
.modal-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.modal-body { padding: 16px; }
|
||||||
|
.image-search-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.image-result {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface2);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.image-result img {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.image-result div {
|
||||||
|
padding: 9px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
.preview-placeholder {
|
.preview-placeholder {
|
||||||
aspect-ratio: 16/9;
|
aspect-ratio: 16/9;
|
||||||
border-radius: var(--r-lg);
|
border-radius: var(--r-lg);
|
||||||
@@ -846,6 +1059,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="primary-preview-frame" id="primaryPreviewFrame" title="Drag to position the poster crop. Use the mouse wheel to zoom.">
|
<div class="primary-preview-frame" id="primaryPreviewFrame" title="Drag to position the poster crop. Use the mouse wheel to zoom.">
|
||||||
<img id="primaryPreviewImg" src="" alt="">
|
<img id="primaryPreviewImg" src="" alt="">
|
||||||
|
<div class="primary-preview-zoom">
|
||||||
|
<button class="primary-preview-zoom-btn" id="btnPrimaryPreviewZoomOut" type="button" aria-label="Zoom out">-</button>
|
||||||
|
<button class="primary-preview-zoom-btn" id="btnPrimaryPreviewZoomIn" type="button" aria-label="Zoom in">+</button>
|
||||||
|
</div>
|
||||||
<div class="primary-preview-empty" id="primaryPreviewEmpty">Enable matching primary to edit the poster crop.</div>
|
<div class="primary-preview-empty" id="primaryPreviewEmpty">Enable matching primary to edit the poster crop.</div>
|
||||||
<div class="primary-preview-hint">Drag to position • Wheel to zoom</div>
|
<div class="primary-preview-hint">Drag to position • Wheel to zoom</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -854,6 +1071,97 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="artwork-editor" id="artworkEditor">
|
||||||
|
<div class="editor-head">
|
||||||
|
<div>
|
||||||
|
<div class="editor-title">Artwork Editor</div>
|
||||||
|
<div class="editor-subtitle" id="editorSubtitle">Select an Emby item to reframe poster, thumb, or backdrop artwork.</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<button class="btn btn-ghost" id="btnOpenImageSearch" type="button">Search web image</button>
|
||||||
|
<button class="btn btn-primary" id="btnEditorPreview" type="button">Preview export</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-grid">
|
||||||
|
<div class="editor-panel">
|
||||||
|
<h3>Source Image</h3>
|
||||||
|
<div class="editor-source-list" id="editorSourceList">
|
||||||
|
<button class="editor-source-btn active" type="button" data-source-kind="emby" data-source-type="Primary">Emby Primary poster</button>
|
||||||
|
<button class="editor-source-btn" type="button" data-source-kind="emby" data-source-type="Backdrop">Selected Emby backdrop</button>
|
||||||
|
<button class="editor-source-btn" type="button" data-source-kind="emby" data-source-type="Thumb">Current Emby thumb</button>
|
||||||
|
</div>
|
||||||
|
<p class="editor-note" id="editorImportNote" style="margin-top:12px">Remote imports will appear here after you search and import a full-size image.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-panel">
|
||||||
|
<h3>Editor Canvas</h3>
|
||||||
|
<div class="editor-canvas-wrap">
|
||||||
|
<div class="editor-canvas" id="editorCanvas" title="Drag to reposition. Use the wheel or zoom controls to scale.">
|
||||||
|
<img id="editorPreviewImg" alt="">
|
||||||
|
<div class="editor-canvas-empty" id="editorCanvasEmpty">Generate a preview to start editing.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-row">
|
||||||
|
<label>Zoom <span id="editorZoomVal">100%</span></label>
|
||||||
|
<input type="range" id="editorZoomSlider" min="25" max="500" value="100">
|
||||||
|
</div>
|
||||||
|
<div class="editor-row">
|
||||||
|
<label>Position X <span id="editorPanXVal">0</span></label>
|
||||||
|
<input type="range" id="editorPanXSlider" min="-100" max="100" value="0">
|
||||||
|
</div>
|
||||||
|
<div class="editor-row">
|
||||||
|
<label>Position Y <span id="editorPanYVal">0</span></label>
|
||||||
|
<input type="range" id="editorPanYSlider" min="-100" max="100" value="0">
|
||||||
|
</div>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<button class="btn btn-ghost" id="btnEditorZoomOut" type="button">Zoom out</button>
|
||||||
|
<button class="btn btn-ghost" id="btnEditorZoomIn" type="button">Zoom in</button>
|
||||||
|
<button class="btn btn-ghost" id="btnEditorReset" type="button">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-panel">
|
||||||
|
<h3>Export</h3>
|
||||||
|
<div class="editor-row">
|
||||||
|
<label>Target mode</label>
|
||||||
|
<select id="editorTargetSelect">
|
||||||
|
<option value="poster">Poster / Primary 1000x1500</option>
|
||||||
|
<option value="thumb">Thumb 1920x1080</option>
|
||||||
|
<option value="backdrop">Backdrop 1920x1080</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="editor-row">
|
||||||
|
<label>Image fit</label>
|
||||||
|
<select id="editorFitSelect">
|
||||||
|
<option value="cover">Cover frame</option>
|
||||||
|
<option value="contain">Contain full image</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="editor-row">
|
||||||
|
<label>Background fill</label>
|
||||||
|
<select id="editorFillSelect">
|
||||||
|
<option value="blur">Blurred fill</option>
|
||||||
|
<option value="mirror">Mirrored fill</option>
|
||||||
|
<option value="solid">Solid colour</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="editor-row">
|
||||||
|
<label>Fill colour</label>
|
||||||
|
<input type="color" id="editorFillColor" value="#101318">
|
||||||
|
</div>
|
||||||
|
<div class="editor-row">
|
||||||
|
<label>JPEG quality <span id="editorQualityVal">95</span></label>
|
||||||
|
<input type="range" id="editorQualitySlider" min="80" max="98" value="95">
|
||||||
|
</div>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<button class="btn btn-primary" id="btnEditorDownload" type="button" disabled>Download</button>
|
||||||
|
<button class="btn btn-green" id="btnEditorApply" type="button" disabled>Apply to Emby</button>
|
||||||
|
</div>
|
||||||
|
<p class="editor-note" id="editorExportMeta" style="margin-top:12px">Exports are cached and uploaded to the matching Emby image type.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Controls bar -->
|
<!-- Controls bar -->
|
||||||
<div class="controls" id="controlsBar">
|
<div class="controls" id="controlsBar">
|
||||||
<div class="ctrl-row">
|
<div class="ctrl-row">
|
||||||
@@ -993,7 +1301,7 @@
|
|||||||
<label>Poster zoom</label>
|
<label>Poster zoom</label>
|
||||||
<div class="slider-wrap">
|
<div class="slider-wrap">
|
||||||
<button class="slider-step" id="btnPrimaryZoomOut" type="button" aria-label="Zoom out">-</button>
|
<button class="slider-step" id="btnPrimaryZoomOut" type="button" aria-label="Zoom out">-</button>
|
||||||
<input type="range" id="primaryZoomSlider" min="60" max="260" value="100">
|
<input type="range" id="primaryZoomSlider" min="100" max="260" value="100">
|
||||||
<button class="slider-step" id="btnPrimaryZoomIn" type="button" aria-label="Zoom in">+</button>
|
<button class="slider-step" id="btnPrimaryZoomIn" type="button" aria-label="Zoom in">+</button>
|
||||||
<span class="slider-val" id="primaryZoomVal">100%</span>
|
<span class="slider-val" id="primaryZoomVal">100%</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1039,6 +1347,34 @@
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" id="imageSearchModal">
|
||||||
|
<div class="modal-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div>
|
||||||
|
<div class="editor-title">Search Full-Size Artwork</div>
|
||||||
|
<div class="editor-subtitle">Find a better poster, still, or backdrop and import it into the editor cache.</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-ghost" id="btnCloseImageSearch" type="button">Close</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="editor-grid" style="grid-template-columns: 1fr 180px auto; padding:0">
|
||||||
|
<div class="editor-row" style="margin:0">
|
||||||
|
<label>Search query</label>
|
||||||
|
<input type="text" id="imageSearchInput" placeholder="Movie or show title artwork">
|
||||||
|
</div>
|
||||||
|
<div class="editor-row" style="margin:0">
|
||||||
|
<label>Provider</label>
|
||||||
|
<select id="imageProviderSelect"></select>
|
||||||
|
</div>
|
||||||
|
<div class="editor-row" style="margin:0; align-self:end">
|
||||||
|
<button class="btn btn-primary" id="btnRunImageSearch" type="button">Search</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="image-search-grid" id="imageSearchResults"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -1057,6 +1393,18 @@ const state = {
|
|||||||
searchController: null,
|
searchController: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const editorState = {
|
||||||
|
sourceKind: 'emby',
|
||||||
|
sourceType: 'Primary',
|
||||||
|
sourceIndex: null,
|
||||||
|
importId: null,
|
||||||
|
cacheKey: null,
|
||||||
|
previewUrl: null,
|
||||||
|
imported: null,
|
||||||
|
drag: null,
|
||||||
|
providersLoaded: false,
|
||||||
|
};
|
||||||
|
|
||||||
const $ = s => document.querySelector(s);
|
const $ = s => document.querySelector(s);
|
||||||
const searchInput = $('#searchInput');
|
const searchInput = $('#searchInput');
|
||||||
const resultsToolbar = $('#resultsToolbar');
|
const resultsToolbar = $('#resultsToolbar');
|
||||||
@@ -1075,6 +1423,41 @@ const primaryPreviewImg = $('#primaryPreviewImg');
|
|||||||
const primaryPreviewEmpty = $('#primaryPreviewEmpty');
|
const primaryPreviewEmpty = $('#primaryPreviewEmpty');
|
||||||
const primaryPreviewMeta = $('#primaryPreviewMeta');
|
const primaryPreviewMeta = $('#primaryPreviewMeta');
|
||||||
const btnPrimaryReset = $('#btnPrimaryReset');
|
const btnPrimaryReset = $('#btnPrimaryReset');
|
||||||
|
const btnPrimaryPreviewZoomOut = $('#btnPrimaryPreviewZoomOut');
|
||||||
|
const btnPrimaryPreviewZoomIn = $('#btnPrimaryPreviewZoomIn');
|
||||||
|
const artworkEditor = $('#artworkEditor');
|
||||||
|
const editorSubtitle = $('#editorSubtitle');
|
||||||
|
const editorSourceList = $('#editorSourceList');
|
||||||
|
const editorImportNote = $('#editorImportNote');
|
||||||
|
const editorCanvas = $('#editorCanvas');
|
||||||
|
const editorPreviewImg = $('#editorPreviewImg');
|
||||||
|
const editorCanvasEmpty = $('#editorCanvasEmpty');
|
||||||
|
const editorTargetSelect = $('#editorTargetSelect');
|
||||||
|
const editorFitSelect = $('#editorFitSelect');
|
||||||
|
const editorFillSelect = $('#editorFillSelect');
|
||||||
|
const editorFillColor = $('#editorFillColor');
|
||||||
|
const editorQualitySlider = $('#editorQualitySlider');
|
||||||
|
const editorQualityVal = $('#editorQualityVal');
|
||||||
|
const editorZoomSlider = $('#editorZoomSlider');
|
||||||
|
const editorZoomVal = $('#editorZoomVal');
|
||||||
|
const editorPanXSlider = $('#editorPanXSlider');
|
||||||
|
const editorPanXVal = $('#editorPanXVal');
|
||||||
|
const editorPanYSlider = $('#editorPanYSlider');
|
||||||
|
const editorPanYVal = $('#editorPanYVal');
|
||||||
|
const btnEditorPreview = $('#btnEditorPreview');
|
||||||
|
const btnEditorDownload = $('#btnEditorDownload');
|
||||||
|
const btnEditorApply = $('#btnEditorApply');
|
||||||
|
const btnEditorReset = $('#btnEditorReset');
|
||||||
|
const btnEditorZoomOut = $('#btnEditorZoomOut');
|
||||||
|
const btnEditorZoomIn = $('#btnEditorZoomIn');
|
||||||
|
const editorExportMeta = $('#editorExportMeta');
|
||||||
|
const btnOpenImageSearch = $('#btnOpenImageSearch');
|
||||||
|
const imageSearchModal = $('#imageSearchModal');
|
||||||
|
const btnCloseImageSearch = $('#btnCloseImageSearch');
|
||||||
|
const imageSearchInput = $('#imageSearchInput');
|
||||||
|
const imageProviderSelect = $('#imageProviderSelect');
|
||||||
|
const btnRunImageSearch = $('#btnRunImageSearch');
|
||||||
|
const imageSearchResults = $('#imageSearchResults');
|
||||||
const loadingCover = $('#loadingCover');
|
const loadingCover = $('#loadingCover');
|
||||||
const bdNav = $('#bdNav');
|
const bdNav = $('#bdNav');
|
||||||
const bdCounter = $('#bdCounter');
|
const bdCounter = $('#bdCounter');
|
||||||
@@ -1258,7 +1641,7 @@ function clamp(n, min, max) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setPrimaryCropValues({ zoom, panX, panY }, { regenerate = false } = {}) {
|
function setPrimaryCropValues({ zoom, panX, panY }, { regenerate = false } = {}) {
|
||||||
if (zoom != null) primaryZoomSlider.value = String(clamp(Math.round(zoom), 60, 260));
|
if (zoom != null) primaryZoomSlider.value = String(clamp(Math.round(zoom), 100, 260));
|
||||||
if (panX != null) primaryPanXSlider.value = String(clamp(Math.round(panX), -100, 100));
|
if (panX != null) primaryPanXSlider.value = String(clamp(Math.round(panX), -100, 100));
|
||||||
if (panY != null) primaryPanYSlider.value = String(clamp(Math.round(panY), -100, 100));
|
if (panY != null) primaryPanYSlider.value = String(clamp(Math.round(panY), -100, 100));
|
||||||
updatePrimaryPreviewMeta();
|
updatePrimaryPreviewMeta();
|
||||||
@@ -1287,6 +1670,182 @@ function finishPrimaryDrag(pointerId = null) {
|
|||||||
primaryDragState = null;
|
primaryDragState = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nudgePrimaryZoom(delta) {
|
||||||
|
if (!state.item || !matchPrimaryToggle.checked) return;
|
||||||
|
setPrimaryCropValues({
|
||||||
|
zoom: Number(primaryZoomSlider.value) + delta,
|
||||||
|
}, { regenerate: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEditorLabels() {
|
||||||
|
editorZoomVal.textContent = `${editorZoomSlider.value}%`;
|
||||||
|
editorPanXVal.textContent = editorPanXSlider.value;
|
||||||
|
editorPanYVal.textContent = editorPanYSlider.value;
|
||||||
|
editorQualityVal.textContent = editorQualitySlider.value;
|
||||||
|
editorCanvas.classList.toggle('thumb', editorTargetSelect.value === 'thumb');
|
||||||
|
editorCanvas.classList.toggle('backdrop', editorTargetSelect.value === 'backdrop');
|
||||||
|
}
|
||||||
|
|
||||||
|
function editorPayload() {
|
||||||
|
const effectiveSourceIndex = editorState.sourceType === 'Backdrop'
|
||||||
|
? (currentBackdropAsset()?.index ?? state.backdropIndex)
|
||||||
|
: editorState.sourceIndex;
|
||||||
|
return {
|
||||||
|
item_id: state.item?.id,
|
||||||
|
source_kind: editorState.sourceKind,
|
||||||
|
source_type: editorState.sourceType,
|
||||||
|
source_index: effectiveSourceIndex,
|
||||||
|
import_id: editorState.importId,
|
||||||
|
target_mode: editorTargetSelect.value,
|
||||||
|
fit_mode: editorFitSelect.value,
|
||||||
|
fill_mode: editorFillSelect.value,
|
||||||
|
fill_color: editorFillColor.value,
|
||||||
|
zoom: Number(editorZoomSlider.value) / 100,
|
||||||
|
pan_x: Number(editorPanXSlider.value) / 100,
|
||||||
|
pan_y: Number(editorPanYSlider.value) / 100,
|
||||||
|
quality: Number(editorQualitySlider.value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetEditorTransform({ preview = false } = {}) {
|
||||||
|
editorZoomSlider.value = '100';
|
||||||
|
editorPanXSlider.value = '0';
|
||||||
|
editorPanYSlider.value = '0';
|
||||||
|
updateEditorLabels();
|
||||||
|
if (preview) scheduleEditorPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEditorSource(button) {
|
||||||
|
editorSourceList.querySelectorAll('.editor-source-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
button.classList.add('active');
|
||||||
|
editorState.sourceKind = button.dataset.sourceKind;
|
||||||
|
editorState.sourceType = button.dataset.sourceType || 'Primary';
|
||||||
|
editorState.sourceIndex = button.dataset.sourceIndex ? Number(button.dataset.sourceIndex) : null;
|
||||||
|
editorState.importId = button.dataset.importId || null;
|
||||||
|
editorState.cacheKey = null;
|
||||||
|
resetEditorTransform();
|
||||||
|
scheduleEditorPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncArtworkEditorForItem() {
|
||||||
|
const visible = Boolean(state.item);
|
||||||
|
artworkEditor.classList.toggle('visible', visible);
|
||||||
|
if (!visible) return;
|
||||||
|
editorSubtitle.textContent = `Editing artwork for ${state.item.name}`;
|
||||||
|
imageSearchInput.value = `${state.item.name} ${state.item.year || ''} poster backdrop`.trim();
|
||||||
|
editorSourceList.querySelectorAll('.editor-source-btn').forEach(btn => {
|
||||||
|
btn.classList.toggle('active', btn.dataset.sourceKind === editorState.sourceKind && btn.dataset.sourceType === editorState.sourceType && !btn.dataset.importId);
|
||||||
|
});
|
||||||
|
scheduleEditorPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendImportedSource(imported) {
|
||||||
|
editorState.imported = imported;
|
||||||
|
let btn = editorSourceList.querySelector('[data-source-kind="import"]');
|
||||||
|
if (!btn) {
|
||||||
|
btn = document.createElement('button');
|
||||||
|
btn.className = 'editor-source-btn';
|
||||||
|
btn.type = 'button';
|
||||||
|
btn.dataset.sourceKind = 'import';
|
||||||
|
editorSourceList.appendChild(btn);
|
||||||
|
btn.addEventListener('click', () => setEditorSource(btn));
|
||||||
|
}
|
||||||
|
btn.dataset.importId = imported.id;
|
||||||
|
btn.textContent = `Imported image ${imported.width}x${imported.height}`;
|
||||||
|
editorImportNote.textContent = `Imported ${imported.format} image cached locally for this editor session.`;
|
||||||
|
setEditorSource(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
let editorPreviewTimer;
|
||||||
|
async function generateEditorPreview() {
|
||||||
|
if (!state.item) return;
|
||||||
|
updateEditorLabels();
|
||||||
|
btnEditorPreview.disabled = true;
|
||||||
|
btnEditorApply.disabled = true;
|
||||||
|
btnEditorDownload.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/artwork/export', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(editorPayload()),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`${r.status}`);
|
||||||
|
editorState.cacheKey = r.headers.get('X-Editor-Cache-Key');
|
||||||
|
const blob = await r.blob();
|
||||||
|
if (editorState.previewUrl) URL.revokeObjectURL(editorState.previewUrl);
|
||||||
|
editorState.previewUrl = URL.createObjectURL(blob);
|
||||||
|
editorPreviewImg.src = editorState.previewUrl;
|
||||||
|
editorCanvasEmpty.style.display = 'none';
|
||||||
|
editorExportMeta.textContent = `${r.headers.get('X-Editor-Emby-Type')} export · ${r.headers.get('X-Editor-Width')}x${r.headers.get('X-Editor-Height')} · cached`;
|
||||||
|
btnEditorApply.disabled = false;
|
||||||
|
btnEditorDownload.disabled = false;
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`Editor preview failed: ${e.message}`, 'err');
|
||||||
|
} finally {
|
||||||
|
btnEditorPreview.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleEditorPreview() {
|
||||||
|
if (!state.item) return;
|
||||||
|
clearTimeout(editorPreviewTimer);
|
||||||
|
editorPreviewTimer = setTimeout(generateEditorPreview, 220);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadImageProviders() {
|
||||||
|
if (editorState.providersLoaded) return;
|
||||||
|
const r = await fetch('/api/artwork/providers');
|
||||||
|
if (!r.ok) throw new Error(`${r.status}`);
|
||||||
|
const data = await r.json();
|
||||||
|
imageProviderSelect.innerHTML = data.providers.map(provider => `<option value="${provider.key}">${esc(provider.label)}</option>`).join('');
|
||||||
|
editorState.providersLoaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runImageSearch() {
|
||||||
|
const q = imageSearchInput.value.trim();
|
||||||
|
if (q.length < 2) return;
|
||||||
|
imageSearchResults.innerHTML = '<div class="empty">Searching...</div>';
|
||||||
|
try {
|
||||||
|
const url = `/api/artwork/search?q=${encodeURIComponent(q)}&provider=${encodeURIComponent(imageProviderSelect.value)}&limit=12`;
|
||||||
|
const r = await fetch(url);
|
||||||
|
if (!r.ok) throw new Error(`${r.status}`);
|
||||||
|
const data = await r.json();
|
||||||
|
if (!data.items.length) {
|
||||||
|
imageSearchResults.innerHTML = '<div class="empty">No images found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
imageSearchResults.innerHTML = data.items.map(item => `
|
||||||
|
<button class="image-result" type="button" data-image-url="${esc(item.image_url)}">
|
||||||
|
<img src="${esc(item.thumbnail_url)}" alt="">
|
||||||
|
<div>${esc(item.title)}${item.width && item.height ? `<br>${item.width}x${item.height}` : ''}</div>
|
||||||
|
</button>
|
||||||
|
`).join('');
|
||||||
|
imageSearchResults.querySelectorAll('.image-result').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/artwork/import', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ image_url: btn.dataset.imageUrl }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`${r.status}`);
|
||||||
|
appendImportedSource(await r.json());
|
||||||
|
imageSearchModal.classList.remove('visible');
|
||||||
|
showToast('Imported image into editor', 'ok');
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`Import failed: ${e.message}`, 'err');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
imageSearchResults.innerHTML = '<div class="empty">Search failed</div>';
|
||||||
|
showToast(`Image search failed: ${e.message}`, 'err');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function doSearch(q, start = 0) {
|
async function doSearch(q, start = 0) {
|
||||||
if (state.searchController) state.searchController.abort();
|
if (state.searchController) state.searchController.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -1364,6 +1923,14 @@ async function selectItem(item) {
|
|||||||
state.generated = false;
|
state.generated = false;
|
||||||
state.cacheKey = null;
|
state.cacheKey = null;
|
||||||
state.primaryGenerated = false;
|
state.primaryGenerated = false;
|
||||||
|
editorState.sourceKind = 'emby';
|
||||||
|
editorState.sourceType = 'Primary';
|
||||||
|
editorState.sourceIndex = null;
|
||||||
|
editorState.importId = null;
|
||||||
|
editorState.cacheKey = null;
|
||||||
|
editorImportNote.textContent = 'Remote imports will appear here after you search and import a full-size image.';
|
||||||
|
editorSourceList.querySelector('[data-source-kind="import"]')?.remove();
|
||||||
|
resetEditorTransform();
|
||||||
primaryZoomSlider.value = '100';
|
primaryZoomSlider.value = '100';
|
||||||
primaryPanXSlider.value = '0';
|
primaryPanXSlider.value = '0';
|
||||||
primaryPanYSlider.value = '-16';
|
primaryPanYSlider.value = '-16';
|
||||||
@@ -1375,6 +1942,7 @@ async function selectItem(item) {
|
|||||||
|
|
||||||
placeholder.style.display = 'none';
|
placeholder.style.display = 'none';
|
||||||
controlsBar.classList.add('visible');
|
controlsBar.classList.add('visible');
|
||||||
|
syncArtworkEditorForItem();
|
||||||
btnRegenerate.disabled = true;
|
btnRegenerate.disabled = true;
|
||||||
btnApply.disabled = true;
|
btnApply.disabled = true;
|
||||||
bdCounter.style.display = 'none';
|
bdCounter.style.display = 'none';
|
||||||
@@ -1431,6 +1999,7 @@ function stepBackdrop(delta) {
|
|||||||
state.backdropIndex = nextIndex;
|
state.backdropIndex = nextIndex;
|
||||||
updateAssetPickers();
|
updateAssetPickers();
|
||||||
updateBdUI();
|
updateBdUI();
|
||||||
|
if (editorState.sourceKind === 'emby' && editorState.sourceType === 'Backdrop') scheduleEditorPreview();
|
||||||
generateThumb();
|
generateThumb();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1502,12 +2071,82 @@ matchPrimaryToggle.addEventListener('change', () => {
|
|||||||
primaryZoomSlider.addEventListener('input', schedulePrimaryPreviewRegenerate);
|
primaryZoomSlider.addEventListener('input', schedulePrimaryPreviewRegenerate);
|
||||||
primaryPanXSlider.addEventListener('input', schedulePrimaryPreviewRegenerate);
|
primaryPanXSlider.addEventListener('input', schedulePrimaryPreviewRegenerate);
|
||||||
primaryPanYSlider.addEventListener('input', schedulePrimaryPreviewRegenerate);
|
primaryPanYSlider.addEventListener('input', schedulePrimaryPreviewRegenerate);
|
||||||
|
editorSourceList.querySelectorAll('.editor-source-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => setEditorSource(btn));
|
||||||
|
});
|
||||||
|
editorTargetSelect.addEventListener('change', () => {
|
||||||
|
resetEditorTransform();
|
||||||
|
scheduleEditorPreview();
|
||||||
|
});
|
||||||
|
editorFitSelect.addEventListener('change', scheduleEditorPreview);
|
||||||
|
editorFillSelect.addEventListener('change', scheduleEditorPreview);
|
||||||
|
editorFillColor.addEventListener('input', scheduleEditorPreview);
|
||||||
|
editorQualitySlider.addEventListener('input', () => {
|
||||||
|
updateEditorLabels();
|
||||||
|
scheduleEditorPreview();
|
||||||
|
});
|
||||||
|
editorZoomSlider.addEventListener('input', scheduleEditorPreview);
|
||||||
|
editorPanXSlider.addEventListener('input', scheduleEditorPreview);
|
||||||
|
editorPanYSlider.addEventListener('input', scheduleEditorPreview);
|
||||||
|
btnEditorPreview.addEventListener('click', generateEditorPreview);
|
||||||
|
btnEditorReset.addEventListener('click', () => resetEditorTransform({ preview: true }));
|
||||||
|
btnEditorZoomOut.addEventListener('click', () => {
|
||||||
|
editorZoomSlider.value = String(clamp(Number(editorZoomSlider.value) - 10, 25, 500));
|
||||||
|
scheduleEditorPreview();
|
||||||
|
});
|
||||||
|
btnEditorZoomIn.addEventListener('click', () => {
|
||||||
|
editorZoomSlider.value = String(clamp(Number(editorZoomSlider.value) + 10, 25, 500));
|
||||||
|
scheduleEditorPreview();
|
||||||
|
});
|
||||||
|
btnEditorDownload.addEventListener('click', () => {
|
||||||
|
if (!editorState.cacheKey) return;
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = `/api/artwork/export/${editorState.cacheKey}`;
|
||||||
|
a.download = `${state.item?.name || 'artwork'}-${editorTargetSelect.value}.jpg`;
|
||||||
|
a.click();
|
||||||
|
});
|
||||||
|
btnEditorApply.addEventListener('click', async () => {
|
||||||
|
if (!state.item) return;
|
||||||
|
btnEditorApply.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/artwork/apply', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...editorPayload(), cache_key: editorState.cacheKey }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`${r.status}`);
|
||||||
|
const data = await r.json();
|
||||||
|
showToast(`Applied ${data.emby_type} artwork`, 'ok');
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`Apply failed: ${e.message}`, 'err');
|
||||||
|
} finally {
|
||||||
|
btnEditorApply.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
btnOpenImageSearch.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await loadImageProviders();
|
||||||
|
imageSearchModal.classList.add('visible');
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`Could not load image providers: ${e.message}`, 'err');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
btnCloseImageSearch.addEventListener('click', () => imageSearchModal.classList.remove('visible'));
|
||||||
|
imageSearchModal.addEventListener('click', event => {
|
||||||
|
if (event.target === imageSearchModal) imageSearchModal.classList.remove('visible');
|
||||||
|
});
|
||||||
|
btnRunImageSearch.addEventListener('click', runImageSearch);
|
||||||
|
imageSearchInput.addEventListener('keydown', event => {
|
||||||
|
if (event.key === 'Enter') runImageSearch();
|
||||||
|
});
|
||||||
btnPrimaryZoomOut.addEventListener('click', () => {
|
btnPrimaryZoomOut.addEventListener('click', () => {
|
||||||
setPrimaryCropValues({ zoom: Number(primaryZoomSlider.value) - 10 }, { regenerate: true });
|
nudgePrimaryZoom(-10);
|
||||||
});
|
});
|
||||||
btnPrimaryZoomIn.addEventListener('click', () => {
|
btnPrimaryZoomIn.addEventListener('click', () => {
|
||||||
setPrimaryCropValues({ zoom: Number(primaryZoomSlider.value) + 10 }, { regenerate: true });
|
nudgePrimaryZoom(10);
|
||||||
});
|
});
|
||||||
|
btnPrimaryPreviewZoomOut.addEventListener('click', () => nudgePrimaryZoom(-10));
|
||||||
|
btnPrimaryPreviewZoomIn.addEventListener('click', () => nudgePrimaryZoom(10));
|
||||||
btnPrimaryReset.addEventListener('click', () => {
|
btnPrimaryReset.addEventListener('click', () => {
|
||||||
setPrimaryCropValues({ zoom: 100, panX: 0, panY: -16 }, { regenerate: true });
|
setPrimaryCropValues({ zoom: 100, panX: 0, panY: -16 }, { regenerate: true });
|
||||||
});
|
});
|
||||||
@@ -1541,11 +2180,49 @@ primaryPreviewFrame.addEventListener('wheel', event => {
|
|||||||
if (!state.item || !matchPrimaryToggle.checked) return;
|
if (!state.item || !matchPrimaryToggle.checked) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const delta = event.deltaY < 0 ? 8 : -8;
|
const delta = event.deltaY < 0 ? 8 : -8;
|
||||||
setPrimaryCropValues({
|
nudgePrimaryZoom(delta);
|
||||||
zoom: Number(primaryZoomSlider.value) + delta,
|
}, { passive: false });
|
||||||
}, { regenerate: true });
|
editorCanvas.addEventListener('pointerdown', event => {
|
||||||
|
if (!state.item) return;
|
||||||
|
event.preventDefault();
|
||||||
|
editorState.drag = {
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
|
startPanX: Number(editorPanXSlider.value),
|
||||||
|
startPanY: Number(editorPanYSlider.value),
|
||||||
|
};
|
||||||
|
editorCanvas.classList.add('dragging');
|
||||||
|
editorCanvas.setPointerCapture(event.pointerId);
|
||||||
|
});
|
||||||
|
editorCanvas.addEventListener('pointermove', event => {
|
||||||
|
if (!editorState.drag || editorState.drag.pointerId !== event.pointerId) return;
|
||||||
|
const rect = editorCanvas.getBoundingClientRect();
|
||||||
|
editorPanXSlider.value = String(clamp(Math.round(editorState.drag.startPanX - ((event.clientX - editorState.drag.startX) / Math.max(1, rect.width)) * 200), -100, 100));
|
||||||
|
editorPanYSlider.value = String(clamp(Math.round(editorState.drag.startPanY - ((event.clientY - editorState.drag.startY) / Math.max(1, rect.height)) * 200), -100, 100));
|
||||||
|
scheduleEditorPreview();
|
||||||
|
});
|
||||||
|
function finishEditorDrag(pointerId = null) {
|
||||||
|
if (!editorState.drag) return;
|
||||||
|
if (pointerId != null && editorState.drag.pointerId !== pointerId) return;
|
||||||
|
try {
|
||||||
|
editorCanvas.releasePointerCapture(editorState.drag.pointerId);
|
||||||
|
} catch {}
|
||||||
|
editorCanvas.classList.remove('dragging');
|
||||||
|
editorState.drag = null;
|
||||||
|
}
|
||||||
|
editorCanvas.addEventListener('pointerup', event => finishEditorDrag(event.pointerId));
|
||||||
|
editorCanvas.addEventListener('pointercancel', event => finishEditorDrag(event.pointerId));
|
||||||
|
editorCanvas.addEventListener('lostpointercapture', () => finishEditorDrag());
|
||||||
|
editorCanvas.addEventListener('wheel', event => {
|
||||||
|
if (!state.item) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const delta = event.deltaY < 0 ? 8 : -8;
|
||||||
|
editorZoomSlider.value = String(clamp(Number(editorZoomSlider.value) + delta, 25, 500));
|
||||||
|
scheduleEditorPreview();
|
||||||
}, { passive: false });
|
}, { passive: false });
|
||||||
updatePrimaryPreviewMeta();
|
updatePrimaryPreviewMeta();
|
||||||
|
updateEditorLabels();
|
||||||
|
|
||||||
// ── Generate ──
|
// ── Generate ──
|
||||||
async function generateThumb({ silent = false } = {}) {
|
async function generateThumb({ silent = false } = {}) {
|
||||||
|
|||||||
Reference in New Issue
Block a user