This commit is contained in:
2026-04-15 21:25:03 +12:00
parent 914e7454d0
commit 1805551dae
4 changed files with 1176 additions and 80 deletions
+57 -2
View File
@@ -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
View File
@@ -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"]
+423 -63
View File
@@ -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
View File
@@ -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 } = {}) {