251 files
This commit is contained in:
@@ -36,8 +36,10 @@ from .const import (
|
||||
)
|
||||
from . import image_processing as ip
|
||||
from . import playlist
|
||||
from . import video
|
||||
from .coordinator import AlbumCoordinator, MediaItem
|
||||
from .store import SlideshowStore
|
||||
from .viewers import async_get_tracker
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -59,6 +61,13 @@ _SKIP_SEARCH_LIMIT = 30
|
||||
|
||||
_MAX_RENDER_ATTEMPTS = 10
|
||||
|
||||
# How long a render waits for an item's metadata (faces for the crop,
|
||||
# caption fields) to be fetched on demand before rendering without it.
|
||||
# Upcoming slides render ahead of time, so this rarely delays a slide.
|
||||
_ENRICH_WAIT_SECONDS = 8.0
|
||||
|
||||
_MB = 1024 * 1024
|
||||
|
||||
# Fixed floor (in items) for the pairing cooldown exclusion zone, applied on
|
||||
# each side of the current index in addition to the percentage-based radius.
|
||||
# Keeps tiny albums from pairing consecutive/near-consecutive shots even when
|
||||
@@ -161,9 +170,34 @@ def _caption_frame(item: MediaItem | None) -> dict[str, Any]:
|
||||
"longitude": getattr(item, "longitude", None),
|
||||
"description": getattr(item, "description", None),
|
||||
**_camera_metadata_attributes(item),
|
||||
**_file_attributes(item),
|
||||
}
|
||||
|
||||
|
||||
def _file_attributes(item: MediaItem | None) -> dict[str, Any]:
|
||||
return {
|
||||
"filename": getattr(item, "filename", None),
|
||||
"path": getattr(item, "source_path", None),
|
||||
}
|
||||
|
||||
|
||||
# Describe the stream a video slide is served as (see
|
||||
# ``AlbumCoordinator.async_video_info``); ``None`` for photos.
|
||||
VIDEO_INFO_FIELDS = (
|
||||
"video_version", "video_bitrate_mbps", "video_size_bytes", "video_content_type",
|
||||
)
|
||||
|
||||
|
||||
def _is_video(item: Any) -> bool:
|
||||
"""A video slide (played alone). Live Photos are photos with motion."""
|
||||
return bool(getattr(item, "video_url", None)) and not getattr(item, "live_photo", False)
|
||||
|
||||
|
||||
def _has_motion(item: Any) -> bool:
|
||||
"""A video, or a Live Photo whose motion clip can play over its still."""
|
||||
return bool(getattr(item, "video_url", None))
|
||||
|
||||
|
||||
def _item_faces(item: MediaItem | None) -> tuple[ip.FaceBox, ...] | None:
|
||||
"""Return the validated face boxes stored on a media item.
|
||||
|
||||
@@ -213,6 +247,10 @@ class _DownloadCache:
|
||||
def put(self, url: str, data: bytes) -> None:
|
||||
if len(data) > self._max_bytes:
|
||||
# Item exceeds the entire cache budget; skip caching but don't raise.
|
||||
_LOGGER.debug(
|
||||
"Image cache: not caching %.1f MB image, larger than the %.1f MB budget",
|
||||
len(data) / _MB, self._max_bytes / _MB,
|
||||
)
|
||||
return
|
||||
if url in self._cache:
|
||||
self._total_bytes -= len(self._cache[url])
|
||||
@@ -222,13 +260,28 @@ class _DownloadCache:
|
||||
self._evict()
|
||||
|
||||
def resize(self, max_bytes: int) -> None:
|
||||
self._max_bytes = max(max_bytes, 1)
|
||||
max_bytes = max(max_bytes, 1)
|
||||
if max_bytes != self._max_bytes:
|
||||
_LOGGER.debug(
|
||||
"Image cache: budget %.1f MB -> %.1f MB (%.1f MB in use)",
|
||||
self._max_bytes / _MB, max_bytes / _MB, self._total_bytes / _MB,
|
||||
)
|
||||
self._max_bytes = max_bytes
|
||||
self._evict()
|
||||
|
||||
def _evict(self) -> None:
|
||||
count = freed = 0
|
||||
while self._total_bytes > self._max_bytes and self._cache:
|
||||
_, data = self._cache.popitem(last=False)
|
||||
self._total_bytes -= len(data)
|
||||
count += 1
|
||||
freed += len(data)
|
||||
if count:
|
||||
_LOGGER.debug(
|
||||
"Image cache: evicted %d image(s) (%.1f MB), now %.1f/%.1f MB, %d cached",
|
||||
count, freed / _MB, self._total_bytes / _MB, self._max_bytes / _MB,
|
||||
len(self._cache),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
|
||||
@@ -243,6 +296,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_e
|
||||
|
||||
class AlbumSlideshowCamera(Camera):
|
||||
_attr_should_poll = False
|
||||
# The signed stream URL and the server's file paths stay out of history.
|
||||
_unrecorded_attributes = frozenset({"video_url", "current_path"})
|
||||
|
||||
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator, store: SlideshowStore) -> None:
|
||||
super().__init__()
|
||||
@@ -272,6 +327,10 @@ class AlbumSlideshowCamera(Camera):
|
||||
# ``horizontal`` (side-by-side, left/right) or ``vertical`` (stacked,
|
||||
# top/bottom) for a paired frame; None for single frames.
|
||||
self._last_pair_orientation: str | None = None
|
||||
# ``{"photo_id", "duration_ms"}`` when the current slide is a video
|
||||
# (always unpaired); ``_video_url`` is its signed proxy URL.
|
||||
self._last_video: dict | None = None
|
||||
self._video_url: str | None = None
|
||||
# Cached effective playlist (after date filter + ordering). Invalidated
|
||||
# by any store change or coordinator update.
|
||||
self._effective_cache: tuple[int, list[MediaItem]] | None = None
|
||||
@@ -315,6 +374,8 @@ class AlbumSlideshowCamera(Camera):
|
||||
self._consecutive_failures: int = 0
|
||||
self._render_task: asyncio.Task | None = None
|
||||
self._render_settings = self._current_render_settings()
|
||||
self._viewers = async_get_tracker(hass)
|
||||
self._unsub_viewers = None
|
||||
|
||||
def _on_coordinator_update() -> None:
|
||||
previous_items = self._effective_cache[1] if self._effective_cache else None
|
||||
@@ -326,7 +387,14 @@ class AlbumSlideshowCamera(Camera):
|
||||
|
||||
coordinator.async_add_listener(_on_coordinator_update)
|
||||
|
||||
self._pause_when_unwatched = bool(store.pause_when_unwatched)
|
||||
|
||||
def _on_store_change() -> None:
|
||||
enabled = bool(self.store.pause_when_unwatched)
|
||||
if enabled and not self._pause_when_unwatched and self._unsub_viewers is not None:
|
||||
# Cards stay quiet while the setting is off; let them check in.
|
||||
self._viewers.async_restart_grace(self.entry.entry_id)
|
||||
self._pause_when_unwatched = enabled
|
||||
self._download_cache.resize(self.store.image_cache_mb * 1024 * 1024)
|
||||
self._slide_deadline = None
|
||||
settings = self._current_render_settings()
|
||||
@@ -355,12 +423,18 @@ class AlbumSlideshowCamera(Camera):
|
||||
# offset based on entry_id keeps the pattern stable across
|
||||
# restarts. Up to ~3 s spread across albums.
|
||||
startup_delay = (hash(self.entry.entry_id) % 3000) / 1000.0
|
||||
self._unsub_viewers = self._viewers.async_track(
|
||||
self.entry.entry_id, self._on_viewers_change
|
||||
)
|
||||
self._render_task = self.hass.async_create_background_task(
|
||||
self._render_loop(initial_delay=startup_delay),
|
||||
name="album_slideshow_render_loop",
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
if self._unsub_viewers is not None:
|
||||
self._unsub_viewers()
|
||||
self._unsub_viewers = None
|
||||
preload_task = self._preload_task
|
||||
self._cancel_preload()
|
||||
if self._render_task is not None:
|
||||
@@ -396,6 +470,12 @@ class AlbumSlideshowCamera(Camera):
|
||||
cur = items[self._index] if items and 0 <= self._index < len(items) else None
|
||||
captured_at = _ts_to_iso(getattr(cur, "captured_at", None))
|
||||
caption_frames = self._caption_frames(cur)
|
||||
# Paths go out separately as ``current_path``, which isn't recorded.
|
||||
paths = [frame.get("path") for frame in caption_frames]
|
||||
caption_frames = [
|
||||
{key: value for key, value in frame.items() if key != "path"}
|
||||
for frame in caption_frames
|
||||
]
|
||||
captured_at_pair = (
|
||||
[frame["captured_at"] for frame in caption_frames]
|
||||
if self._last_pair_frames else None
|
||||
@@ -425,6 +505,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
"media_count_total": len(data.get("items", []) or []),
|
||||
"current_index": self._index,
|
||||
"current_filename": getattr(cur, "filename", None),
|
||||
"current_path": paths if self._last_pair_frames else getattr(cur, "source_path", None),
|
||||
"current_url": getattr(cur, "url", None),
|
||||
"current_is_portrait": self._last_is_portrait,
|
||||
"captured_at": captured_at_pair if captured_at_pair else captured_at,
|
||||
@@ -445,6 +526,17 @@ class AlbumSlideshowCamera(Camera):
|
||||
# ``pair_orientation`` tells the card how the two halves are laid
|
||||
# out: ``horizontal`` (left/right) or ``vertical`` (top/bottom).
|
||||
"caption_frames": caption_frames,
|
||||
# Video slides: the camera image is the poster frame; the card
|
||||
# plays ``video_url`` over it for ``video_hold_seconds``.
|
||||
"media_kind": (
|
||||
("live_photo" if self._last_video.get("live") else "video")
|
||||
if self._last_video else "image"
|
||||
),
|
||||
"video_url": self._video_url if self._last_video else None,
|
||||
"video_hold_seconds": (
|
||||
self._slide_hold_seconds() if self._last_video else None
|
||||
),
|
||||
**self._video_info_attributes(),
|
||||
"pair_orientation": self._last_pair_orientation,
|
||||
"slide_interval": int(self.store.slide_interval),
|
||||
"fill_mode": self.store.fill_mode,
|
||||
@@ -455,6 +547,8 @@ class AlbumSlideshowCamera(Camera):
|
||||
"shuffle_age_bias": int(self.store.shuffle_age_bias),
|
||||
"missing_date_mode": self.store.missing_date_mode,
|
||||
"paused": bool(self.store.paused),
|
||||
"pause_when_unwatched": bool(self.store.pause_when_unwatched),
|
||||
"paused_unwatched": self._paused_unwatched,
|
||||
"refresh_hours": int(self.store.refresh_hours),
|
||||
"aspect_ratio": self.store.aspect_ratio,
|
||||
"pair_divider_px": int(self.store.pair_divider_px),
|
||||
@@ -489,7 +583,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
the slide was rendered still shows.
|
||||
"""
|
||||
if not self._last_pair_frames:
|
||||
return [_caption_frame(cur)]
|
||||
return [{**_caption_frame(cur), **self._video_info_attributes()}]
|
||||
photo_ids = self._current_frame.meta.get("photo_ids", []) if self._current_frame else []
|
||||
if len(photo_ids) != len(self._last_pair_frames):
|
||||
return self._last_pair_frames
|
||||
@@ -762,6 +856,8 @@ class AlbumSlideshowCamera(Camera):
|
||||
self._last_is_portrait = None
|
||||
self._last_pair_frames = None
|
||||
self._last_pair_orientation = None
|
||||
self._last_video = None
|
||||
self._video_url = None
|
||||
|
||||
def blank_frame():
|
||||
with Image.new("RGB", (16, 9), "black") as image:
|
||||
@@ -795,12 +891,17 @@ class AlbumSlideshowCamera(Camera):
|
||||
# that stub out homeassistant.
|
||||
from homeassistant.components.camera import async_get_still_stream
|
||||
|
||||
return await async_get_still_stream(
|
||||
request,
|
||||
self.async_camera_image,
|
||||
self.content_type,
|
||||
self.frame_interval,
|
||||
)
|
||||
# Someone is watching the stream, so "Pause when not viewed" holds off.
|
||||
close = self._viewers.async_add_stream(self.entry.entry_id)
|
||||
try:
|
||||
return await async_get_still_stream(
|
||||
request,
|
||||
self.async_camera_image,
|
||||
self.content_type,
|
||||
self.frame_interval,
|
||||
)
|
||||
finally:
|
||||
close()
|
||||
|
||||
# Older HA cores may dispatch via the alt name; alias for compatibility.
|
||||
async def async_handle_async_mjpeg_stream(self, request):
|
||||
@@ -874,9 +975,20 @@ class AlbumSlideshowCamera(Camera):
|
||||
try:
|
||||
composed, meta = await renderer._compose_for_index(items)
|
||||
meta = dict(meta or {})
|
||||
meta.setdefault("photo_ids", [getattr(items[renderer._index], "photo_id", None)])
|
||||
shown = items[renderer._index]
|
||||
meta.setdefault("photo_ids", [getattr(shown, "photo_id", None)])
|
||||
# Faces used for the crop, so later face data can be detected.
|
||||
meta.setdefault("faces", (_item_faces(items[renderer._index]),))
|
||||
meta.setdefault("faces", (_item_faces(shown),))
|
||||
# A paired Live Photo shows only its still.
|
||||
if _has_motion(shown) and len(meta["photo_ids"]) == 1 and shown.photo_id:
|
||||
if _is_video(shown):
|
||||
meta["video"] = {
|
||||
"photo_id": shown.photo_id,
|
||||
"duration_ms": shown.duration_ms,
|
||||
**await self._video_info(shown),
|
||||
}
|
||||
else:
|
||||
meta["video"] = {"photo_id": shown.photo_id, "live": True}
|
||||
cursor = self._capture_cursor(renderer)
|
||||
if composed is None:
|
||||
raise RuntimeError("Image composition returned no frame")
|
||||
@@ -902,6 +1014,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
|
||||
def _apply_frame(self, frame: _RenderedFrame) -> None:
|
||||
"""Make a rendered frame current and publish it to Home Assistant."""
|
||||
previous_frame = getattr(self, "_current_frame", None)
|
||||
self._current_frame = frame
|
||||
self._framebuffer = frame.data
|
||||
self.store.last_frame = frame.data
|
||||
@@ -915,19 +1028,41 @@ class AlbumSlideshowCamera(Camera):
|
||||
self._last_is_portrait = meta.get("is_portrait")
|
||||
self._last_pair_frames = meta.get("pair_frames")
|
||||
self._last_pair_orientation = meta.get("pair_orientation")
|
||||
previous_video = getattr(self, "_last_video", None)
|
||||
self._last_video = meta.get("video")
|
||||
if not self._last_video:
|
||||
self._video_url = None
|
||||
elif not (
|
||||
previous_video
|
||||
and previous_video.get("photo_id") == self._last_video["photo_id"]
|
||||
and getattr(self, "_video_url", None)
|
||||
):
|
||||
# Re-rendering the same clip (metadata refreshes do this) keeps its
|
||||
# URL, so the card lets it play on instead of restarting it.
|
||||
self._video_url = video.sign_video_path(
|
||||
self.hass, video.video_path(self.entry.entry_id, self._last_video["photo_id"])
|
||||
)
|
||||
self._frame_id += 1
|
||||
self._frame_serials[self._frame_id] = frame.serial
|
||||
while len(self._frame_serials) > _FRAME_ID_MEMORY:
|
||||
del self._frame_serials[next(iter(self._frame_serials))]
|
||||
if previous_frame is None or previous_frame.meta.get("photo_ids") != meta.get("photo_ids"):
|
||||
# A different slide is up: restart the clock with its own hold
|
||||
# (a video's length), whichever path put it here. Re-renders and
|
||||
# exclusion rebuilds can swap the slide without going through the
|
||||
# timer; a re-render of the same slide keeps the running clock.
|
||||
self._slide_deadline = None
|
||||
self._interrupt_event.set()
|
||||
|
||||
_LOGGER.debug(
|
||||
"Album Slideshow %s: displayed buffered frame_id=%d index=%d "
|
||||
"previous=%d next=%d",
|
||||
"previous=%d next=%d hold=%.1fs",
|
||||
self.entry.title,
|
||||
self._frame_id,
|
||||
self._index,
|
||||
len(self._previous_frames),
|
||||
len(self._next_frames),
|
||||
self._slide_hold_seconds(),
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -1203,7 +1338,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
if self._timeline_dirty:
|
||||
return True
|
||||
try:
|
||||
if self.store.paused:
|
||||
if self._held:
|
||||
await self._interrupt_event.wait()
|
||||
else:
|
||||
await asyncio.wait_for(
|
||||
@@ -1214,6 +1349,33 @@ class AlbumSlideshowCamera(Camera):
|
||||
except asyncio.TimeoutError:
|
||||
return False
|
||||
|
||||
@property
|
||||
def _paused_unwatched(self) -> bool:
|
||||
"""Held by "Pause when not viewed": no card or stream is showing us."""
|
||||
return bool(self.store.pause_when_unwatched) and self._viewers.is_unwatched(
|
||||
self.entry.entry_id
|
||||
)
|
||||
|
||||
@property
|
||||
def _held(self) -> bool:
|
||||
"""Whether the slide timer is stopped (manual navigation still works)."""
|
||||
return bool(self.store.paused) or self._paused_unwatched
|
||||
|
||||
def _on_viewers_change(self) -> None:
|
||||
if not self.store.pause_when_unwatched:
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"Album Slideshow %s: %s",
|
||||
self.entry.title,
|
||||
"paused, no one is viewing it" if self._paused_unwatched
|
||||
else "resumed, a viewer is back",
|
||||
)
|
||||
# Wake the render loop; a returning viewer gets a full interval on
|
||||
# the slide they find.
|
||||
self._slide_deadline = None
|
||||
self._interrupt_event.set()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _render_loop(self, initial_delay: float = 0.0) -> None:
|
||||
"""Display buffered frames on command/timer and refill them in the background."""
|
||||
loop = asyncio.get_running_loop()
|
||||
@@ -1248,7 +1410,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
while True:
|
||||
try:
|
||||
if self._slide_deadline is None:
|
||||
self._slide_deadline = loop.time() + float(int(self.store.slide_interval))
|
||||
self._slide_deadline = loop.time() + self._slide_hold_seconds()
|
||||
if self._timeline_dirty:
|
||||
async with self._navigation_lock:
|
||||
if self._timeline_dirty:
|
||||
@@ -1258,7 +1420,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
interrupted = await self._wait_or_interrupt(
|
||||
max(0.0, self._slide_deadline - loop.time())
|
||||
)
|
||||
if not interrupted and not self.store.paused:
|
||||
if not interrupted and not self._held:
|
||||
async with self._navigation_lock:
|
||||
if (
|
||||
not self._timeline_dirty
|
||||
@@ -1266,7 +1428,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
and loop.time() >= self._slide_deadline
|
||||
):
|
||||
await self._show_next_frame()
|
||||
self._slide_deadline = loop.time() + float(int(self.store.slide_interval))
|
||||
self._slide_deadline = loop.time() + self._slide_hold_seconds()
|
||||
self._consecutive_failures = 0
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
@@ -1279,6 +1441,68 @@ class AlbumSlideshowCamera(Camera):
|
||||
err,
|
||||
)
|
||||
|
||||
async def _ensure_enriched(self, item: MediaItem) -> None:
|
||||
"""Fetch an item's metadata before rendering it, if still missing.
|
||||
|
||||
Faces steer the crop and caption fields are baked into paired slides,
|
||||
so a slide rendered before the background pass reaches its photo
|
||||
would otherwise be missing both. Bounded so a slow server only
|
||||
delays the slide briefly; the fetch carries on in the background.
|
||||
"""
|
||||
enrich = getattr(getattr(self, "coordinator", None), "async_enrich_item", None)
|
||||
if enrich is None:
|
||||
return
|
||||
loop = asyncio.get_running_loop()
|
||||
started = loop.time()
|
||||
try:
|
||||
fetched = await asyncio.wait_for(asyncio.shield(enrich(item)), _ENRICH_WAIT_SECONDS)
|
||||
if fetched:
|
||||
_LOGGER.debug(
|
||||
"Album Slideshow: fetched metadata for %s in %d ms before display",
|
||||
getattr(item, "filename", None), (loop.time() - started) * 1000,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - render without metadata
|
||||
_LOGGER.debug(
|
||||
"Album Slideshow: metadata for %s not ready: %r",
|
||||
getattr(item, "filename", None), err,
|
||||
)
|
||||
|
||||
async def _video_info(self, item: MediaItem) -> dict[str, Any]:
|
||||
"""Which stream a video is served as; empty when it can't be told."""
|
||||
fetch = getattr(self.coordinator, "async_video_info", None)
|
||||
if fetch is None:
|
||||
return {}
|
||||
try:
|
||||
info = await fetch(item)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as err: # noqa: BLE001 - a caption must not cost the slide
|
||||
_LOGGER.debug("Album Slideshow: could not describe video %s: %s", item.filename, err)
|
||||
return {}
|
||||
return {field: info.get(field) for field in VIDEO_INFO_FIELDS if field in info}
|
||||
|
||||
def _video_info_attributes(self) -> dict[str, Any]:
|
||||
video_meta = getattr(self, "_last_video", None) or {}
|
||||
duration_ms = video_meta.get("duration_ms")
|
||||
return {
|
||||
"video_duration": duration_ms / 1000 if duration_ms else None,
|
||||
**{field: video_meta.get(field) for field in VIDEO_INFO_FIELDS},
|
||||
}
|
||||
|
||||
def _slide_hold_seconds(self) -> float:
|
||||
"""Seconds the current slide stays up: the interval, or the clip's length."""
|
||||
interval = int(self.store.slide_interval)
|
||||
if not self._last_video or self._last_video.get("live"):
|
||||
# Photos, including Live Photos, keep the slide interval.
|
||||
return float(interval)
|
||||
return video.video_hold_seconds(
|
||||
self._last_video.get("duration_ms"),
|
||||
int(getattr(self.store, "video_max_seconds", interval)),
|
||||
interval,
|
||||
)
|
||||
|
||||
@property
|
||||
def _compose_semaphore(self) -> asyncio.Semaphore:
|
||||
"""Return the domain-wide compose semaphore, creating it on demand.
|
||||
@@ -1372,6 +1596,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
):
|
||||
return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)
|
||||
|
||||
await self._ensure_enriched(cur)
|
||||
cur_bytes = await self._fetch_bytes(cur.url)
|
||||
if not cur_bytes:
|
||||
raise RuntimeError(f"Failed to fetch image: {cur.url}")
|
||||
@@ -1388,7 +1613,12 @@ class AlbumSlideshowCamera(Camera):
|
||||
img = None
|
||||
return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)
|
||||
|
||||
if orientation_mismatch and portrait_mode == ORIENTATION_MISMATCH_PAIR:
|
||||
# A video plays alone, so it is never split into a pair.
|
||||
if (
|
||||
orientation_mismatch
|
||||
and portrait_mode == ORIENTATION_MISMATCH_PAIR
|
||||
and not _is_video(cur)
|
||||
):
|
||||
pair = await self._find_next_mismatch_image(
|
||||
items, is_portrait_canvas, width, height, limit=_PAIR_SEARCH_LIMIT
|
||||
)
|
||||
@@ -1485,6 +1715,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
continue
|
||||
if self._index != start:
|
||||
self._do_advance(count, items)
|
||||
await self._ensure_enriched(cur)
|
||||
composed = await self._async_image_job(
|
||||
ip.render_image, img, fill_mode, width, height, self._crop_hints(cur)
|
||||
)
|
||||
@@ -1505,6 +1736,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
height: int,
|
||||
fill_mode: str,
|
||||
) -> tuple[Image.Image | None, dict | None]:
|
||||
await self._ensure_enriched(item)
|
||||
b = await self._fetch_bytes(item.url)
|
||||
if not b:
|
||||
return None, None
|
||||
@@ -1592,7 +1824,7 @@ class AlbumSlideshowCamera(Camera):
|
||||
it = items[idx]
|
||||
tries += 1
|
||||
|
||||
if it.url in self._recent_urls:
|
||||
if it.url in self._recent_urls or _is_video(it):
|
||||
continue
|
||||
|
||||
meta_portrait = ip.is_portrait_item_by_metadata(it)
|
||||
@@ -1610,6 +1842,8 @@ class AlbumSlideshowCamera(Camera):
|
||||
continue
|
||||
|
||||
if ip.is_portrait_item(it, img) != is_portrait_canvas:
|
||||
# Only the chosen partner needs faces and caption fields.
|
||||
await self._ensure_enriched(it)
|
||||
return img, it
|
||||
ip.safe_close(img)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user