Update gallery_app.py
This commit is contained in:
284
gallery_app.py
284
gallery_app.py
@@ -7,10 +7,10 @@ from fastapi import Response
|
|||||||
from engine import SorterEngine
|
from engine import SorterEngine
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# STATE MANAGEMENT
|
# STATE MANAGEMENT (OPTIMIZED)
|
||||||
# ==========================================
|
# ==========================================
|
||||||
class AppState:
|
class AppState:
|
||||||
"""Centralized application state with lazy loading."""
|
"""Centralized application state with caching and dirty flags."""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# Profile Data
|
# Profile Data
|
||||||
@@ -37,9 +37,23 @@ class AppState:
|
|||||||
|
|
||||||
# Data Caches
|
# Data Caches
|
||||||
self.all_images: List[str] = []
|
self.all_images: List[str] = []
|
||||||
self.staged_data: Dict = {}
|
self._path_to_idx: Dict[str, int] = {} # Reverse index for O(1) lookups
|
||||||
self.green_dots: Set[int] = set()
|
self.green_dots: Set[int] = set()
|
||||||
self.index_map: Dict[int, str] = {}
|
self.index_map: Dict[int, str] = {}
|
||||||
|
self.committed_indexes: Set[int] = set() # NEW: Track which indexes are from persistent tags
|
||||||
|
|
||||||
|
# Staged data cache with dirty flag
|
||||||
|
self._staged_data: Dict = {}
|
||||||
|
self._staged_dirty = True
|
||||||
|
|
||||||
|
# Disk index cache
|
||||||
|
self._disk_index_cache: Dict[str, Dict[int, str]] = {}
|
||||||
|
self._disk_cache_valid = False
|
||||||
|
|
||||||
|
# UI dirty flags for granular updates
|
||||||
|
self._dirty_sidebar = True
|
||||||
|
self._dirty_pagination = True
|
||||||
|
self._dirty_gallery = True
|
||||||
|
|
||||||
# UI Containers (populated later)
|
# UI Containers (populated later)
|
||||||
self.sidebar_container = None
|
self.sidebar_container = None
|
||||||
@@ -80,30 +94,122 @@ class AppState:
|
|||||||
start = self.page * self.page_size
|
start = self.page * self.page_size
|
||||||
return self.all_images[start : start + self.page_size]
|
return self.all_images[start : start + self.page_size]
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# CACHED PROPERTY: STAGED DATA
|
||||||
|
# ==========================================
|
||||||
|
@property
|
||||||
|
def staged_data(self) -> Dict:
|
||||||
|
"""Cached staged data - only fetches from DB when dirty."""
|
||||||
|
if self._staged_dirty:
|
||||||
|
self._staged_data = SorterEngine.get_staged_data()
|
||||||
|
self._staged_dirty = False
|
||||||
|
return self._staged_data
|
||||||
|
|
||||||
|
def mark_staged_dirty(self):
|
||||||
|
"""Mark staged data for refresh on next access."""
|
||||||
|
self._staged_dirty = True
|
||||||
|
|
||||||
|
def invalidate_disk_cache(self):
|
||||||
|
"""Invalidate disk index cache (call after commits)."""
|
||||||
|
self._disk_cache_valid = False
|
||||||
|
self._disk_index_cache.clear()
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# OPTIMIZED INDEX BUILDING
|
||||||
|
# ==========================================
|
||||||
|
def rebuild_path_index(self):
|
||||||
|
"""Build reverse path->index mapping for O(1) lookups."""
|
||||||
|
self._path_to_idx = {path: idx for idx, path in enumerate(self.all_images)}
|
||||||
|
|
||||||
|
def compute_green_dots(self) -> Set[int]:
|
||||||
|
"""O(staged) instead of O(all_images)."""
|
||||||
|
dots = set()
|
||||||
|
for path in self.staged_data.keys():
|
||||||
|
if path in self._path_to_idx:
|
||||||
|
dots.add(self._path_to_idx[path] // self.page_size)
|
||||||
|
return dots
|
||||||
|
|
||||||
|
def get_disk_index_map(self, category: str) -> Dict[int, str]:
|
||||||
|
"""Cached disk scan for category indexes."""
|
||||||
|
if not self._disk_cache_valid or category not in self._disk_index_cache:
|
||||||
|
self._rebuild_disk_cache_for_category(category)
|
||||||
|
return self._disk_index_cache.get(category, {})
|
||||||
|
|
||||||
|
def _rebuild_disk_cache_for_category(self, category: str):
|
||||||
|
"""Scan disk for existing files in category folder."""
|
||||||
|
cat_path = os.path.join(self.output_dir, category)
|
||||||
|
index_map = {}
|
||||||
|
if os.path.exists(cat_path):
|
||||||
|
try:
|
||||||
|
with os.scandir(cat_path) as entries:
|
||||||
|
for entry in entries:
|
||||||
|
if entry.is_file() and entry.name.startswith(category):
|
||||||
|
idx = _extract_index(entry.name)
|
||||||
|
if idx is not None:
|
||||||
|
index_map[idx] = entry.path
|
||||||
|
except PermissionError:
|
||||||
|
pass
|
||||||
|
self._disk_index_cache[category] = index_map
|
||||||
|
self._disk_cache_valid = True
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# DIRTY FLAG HELPERS
|
||||||
|
# ==========================================
|
||||||
|
def mark_all_dirty(self):
|
||||||
|
self._dirty_sidebar = True
|
||||||
|
self._dirty_pagination = True
|
||||||
|
self._dirty_gallery = True
|
||||||
|
|
||||||
|
def mark_gallery_dirty(self):
|
||||||
|
self._dirty_gallery = True
|
||||||
|
|
||||||
|
def mark_sidebar_dirty(self):
|
||||||
|
self._dirty_sidebar = True
|
||||||
|
|
||||||
|
|
||||||
state = AppState()
|
state = AppState()
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# IMAGE SERVING API
|
# IMAGE SERVING API (OPTIMIZED WITH CACHING)
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
@app.get('/thumbnail')
|
@app.get('/thumbnail')
|
||||||
async def get_thumbnail(path: str, size: int = 400, q: int = 50):
|
async def get_thumbnail(path: str, size: int = 400, q: int = 50):
|
||||||
"""Serve WebP thumbnail with dynamic quality."""
|
"""Serve WebP thumbnail with caching headers."""
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return Response(status_code=404)
|
return Response(status_code=404)
|
||||||
|
|
||||||
img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, q, size)
|
img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, q, size)
|
||||||
return Response(content=img_bytes, media_type="image/webp") if img_bytes else Response(status_code=500)
|
|
||||||
|
if img_bytes:
|
||||||
|
return Response(
|
||||||
|
content=img_bytes,
|
||||||
|
media_type="image/webp",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "public, max-age=3600", # 1 hour browser cache
|
||||||
|
"ETag": f'"{hash(path + str(os.path.getmtime(path)))}"'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return Response(status_code=500)
|
||||||
|
|
||||||
@app.get('/full_res')
|
@app.get('/full_res')
|
||||||
async def get_full_res(path: str):
|
async def get_full_res(path: str):
|
||||||
"""Serve full resolution image."""
|
"""Serve full resolution image."""
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
return Response(status_code=404)
|
return Response(status_code=404)
|
||||||
|
|
||||||
img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 90, None)
|
img_bytes = await run.cpu_bound(SorterEngine.compress_for_web, path, 90, None)
|
||||||
return Response(content=img_bytes, media_type="image/webp") if img_bytes else Response(status_code=500)
|
|
||||||
|
if img_bytes:
|
||||||
|
return Response(
|
||||||
|
content=img_bytes,
|
||||||
|
media_type="image/webp",
|
||||||
|
headers={"Cache-Control": "public, max-age=7200"} # 2 hour cache for full res
|
||||||
|
)
|
||||||
|
return Response(status_code=500)
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# CORE LOGIC
|
# CORE LOGIC (OPTIMIZED)
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
def load_images():
|
def load_images():
|
||||||
@@ -113,43 +219,52 @@ def load_images():
|
|||||||
return
|
return
|
||||||
|
|
||||||
state.all_images = SorterEngine.get_images(state.source_dir, recursive=True)
|
state.all_images = SorterEngine.get_images(state.source_dir, recursive=True)
|
||||||
|
state.rebuild_path_index() # Build reverse index
|
||||||
|
|
||||||
# Reset page if out of bounds
|
# Reset page if out of bounds
|
||||||
if state.page >= state.total_pages:
|
if state.page >= state.total_pages:
|
||||||
state.page = 0
|
state.page = 0
|
||||||
|
|
||||||
|
state.mark_staged_dirty() # Force refresh of staged data
|
||||||
refresh_staged_info()
|
refresh_staged_info()
|
||||||
|
state.mark_all_dirty()
|
||||||
refresh_ui()
|
refresh_ui()
|
||||||
|
|
||||||
def refresh_staged_info():
|
def refresh_staged_info():
|
||||||
"""Update staged data and index maps."""
|
"""Update staged data and index maps (optimized) - includes persistent tags."""
|
||||||
state.staged_data = SorterEngine.get_staged_data()
|
# Green dots using optimized O(staged) lookup
|
||||||
|
state.green_dots = state.compute_green_dots()
|
||||||
# Update green dots (pages with staged images)
|
|
||||||
state.green_dots.clear()
|
|
||||||
staged_keys = set(state.staged_data.keys())
|
|
||||||
for idx, img_path in enumerate(state.all_images):
|
|
||||||
if img_path in staged_keys:
|
|
||||||
state.green_dots.add(idx // state.page_size)
|
|
||||||
|
|
||||||
# Build index map for active category
|
# Build index map for active category
|
||||||
state.index_map.clear()
|
state.index_map.clear()
|
||||||
|
state.committed_indexes.clear() # Track which are committed vs staged
|
||||||
|
|
||||||
# Add staged images
|
# 1. Add staged images for current category (pending commits) - these are "yellow"
|
||||||
|
staged_indexes = set()
|
||||||
for orig_path, info in state.staged_data.items():
|
for orig_path, info in state.staged_data.items():
|
||||||
if info['cat'] == state.active_cat:
|
if info['cat'] == state.active_cat:
|
||||||
idx = _extract_index(info['name'])
|
idx = _extract_index(info['name'])
|
||||||
if idx is not None:
|
if idx is not None:
|
||||||
state.index_map[idx] = orig_path
|
state.index_map[idx] = orig_path
|
||||||
|
staged_indexes.add(idx)
|
||||||
|
|
||||||
# Add committed images from disk
|
# 2. Add committed images from disk (cached scan)
|
||||||
cat_path = os.path.join(state.output_dir, state.active_cat)
|
disk_map = state.get_disk_index_map(state.active_cat)
|
||||||
if os.path.exists(cat_path):
|
for idx, path in disk_map.items():
|
||||||
for filename in os.listdir(cat_path):
|
if idx not in state.index_map:
|
||||||
if filename.startswith(state.active_cat):
|
state.index_map[idx] = path
|
||||||
idx = _extract_index(filename)
|
state.committed_indexes.add(idx)
|
||||||
if idx is not None and idx not in state.index_map:
|
|
||||||
state.index_map[idx] = os.path.join(cat_path, filename)
|
# 3. Load persistent tags for this output folder (NEW)
|
||||||
|
# This shows which indexes are "taken" even if files moved elsewhere
|
||||||
|
persistent = SorterEngine.get_persistent_tags_by_category(state.output_dir, state.active_cat)
|
||||||
|
for idx, filename in persistent.items():
|
||||||
|
if idx not in state.index_map:
|
||||||
|
# Check if file still exists in output
|
||||||
|
full_path = os.path.join(state.output_dir, state.active_cat, filename)
|
||||||
|
if os.path.exists(full_path):
|
||||||
|
state.index_map[idx] = full_path
|
||||||
|
state.committed_indexes.add(idx)
|
||||||
|
|
||||||
def _extract_index(filename: str) -> Optional[int]:
|
def _extract_index(filename: str) -> Optional[int]:
|
||||||
"""Extract numeric index from filename (e.g., 'Cat_042.jpg' -> 42)."""
|
"""Extract numeric index from filename (e.g., 'Cat_042.jpg' -> 42)."""
|
||||||
@@ -159,7 +274,7 @@ def _extract_index(filename: str) -> Optional[int]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# ACTIONS
|
# ACTIONS (OPTIMIZED)
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
def action_tag(img_path: str, manual_idx: Optional[int] = None):
|
def action_tag(img_path: str, manual_idx: Optional[int] = None):
|
||||||
@@ -178,17 +293,23 @@ def action_tag(img_path: str, manual_idx: Optional[int] = None):
|
|||||||
|
|
||||||
SorterEngine.stage_image(img_path, state.active_cat, name)
|
SorterEngine.stage_image(img_path, state.active_cat, name)
|
||||||
|
|
||||||
# Only auto-increment if we used the default next_index (not manual)
|
# Only auto-increment if we used the default next_index
|
||||||
if manual_idx is None:
|
if manual_idx is None:
|
||||||
state.next_index = idx + 1
|
state.next_index = idx + 1
|
||||||
|
|
||||||
|
state.mark_staged_dirty()
|
||||||
refresh_staged_info()
|
refresh_staged_info()
|
||||||
|
state.mark_sidebar_dirty()
|
||||||
|
state.mark_gallery_dirty()
|
||||||
refresh_ui()
|
refresh_ui()
|
||||||
|
|
||||||
def action_untag(img_path: str):
|
def action_untag(img_path: str):
|
||||||
"""Remove staging from an image."""
|
"""Remove staging from an image."""
|
||||||
SorterEngine.clear_staged_item(img_path)
|
SorterEngine.clear_staged_item(img_path)
|
||||||
|
state.mark_staged_dirty()
|
||||||
refresh_staged_info()
|
refresh_staged_info()
|
||||||
|
state.mark_sidebar_dirty()
|
||||||
|
state.mark_gallery_dirty()
|
||||||
refresh_ui()
|
refresh_ui()
|
||||||
|
|
||||||
def action_delete(img_path: str):
|
def action_delete(img_path: str):
|
||||||
@@ -205,6 +326,9 @@ def action_apply_page():
|
|||||||
|
|
||||||
SorterEngine.commit_batch(batch, state.output_dir, state.cleanup_mode, state.batch_mode)
|
SorterEngine.commit_batch(batch, state.output_dir, state.cleanup_mode, state.batch_mode)
|
||||||
ui.notify(f"Page processed ({state.batch_mode})", type='positive')
|
ui.notify(f"Page processed ({state.batch_mode})", type='positive')
|
||||||
|
|
||||||
|
state.invalidate_disk_cache() # Disk content changed
|
||||||
|
state.mark_staged_dirty()
|
||||||
load_images()
|
load_images()
|
||||||
|
|
||||||
async def action_apply_global():
|
async def action_apply_global():
|
||||||
@@ -217,11 +341,13 @@ async def action_apply_global():
|
|||||||
state.batch_mode,
|
state.batch_mode,
|
||||||
state.source_dir
|
state.source_dir
|
||||||
)
|
)
|
||||||
|
state.invalidate_disk_cache() # Disk content changed
|
||||||
|
state.mark_staged_dirty()
|
||||||
load_images()
|
load_images()
|
||||||
ui.notify("Global apply complete!", type='positive')
|
ui.notify("Global apply complete!", type='positive')
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# UI COMPONENTS
|
# UI COMPONENTS (OPTIMIZED)
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
def open_zoom_dialog(path: str, title: Optional[str] = None, show_untag: bool = False, show_jump: bool = False):
|
def open_zoom_dialog(path: str, title: Optional[str] = None, show_untag: bool = False, show_jump: bool = False):
|
||||||
@@ -232,9 +358,9 @@ def open_zoom_dialog(path: str, title: Optional[str] = None, show_untag: bool =
|
|||||||
|
|
||||||
with ui.row().classes('gap-2'):
|
with ui.row().classes('gap-2'):
|
||||||
# Jump to page button
|
# Jump to page button
|
||||||
if show_jump and path in state.all_images:
|
if show_jump and path in state._path_to_idx: # O(1) lookup
|
||||||
def jump_to_image():
|
def jump_to_image():
|
||||||
img_idx = state.all_images.index(path)
|
img_idx = state._path_to_idx[path] # O(1) instead of list.index()
|
||||||
target_page = img_idx // state.page_size
|
target_page = img_idx // state.page_size
|
||||||
dialog.close()
|
dialog.close()
|
||||||
set_page(target_page)
|
set_page(target_page)
|
||||||
@@ -262,21 +388,37 @@ def open_zoom_dialog(path: str, title: Optional[str] = None, show_untag: bool =
|
|||||||
|
|
||||||
def render_sidebar():
|
def render_sidebar():
|
||||||
"""Render category management sidebar."""
|
"""Render category management sidebar."""
|
||||||
|
if not state._dirty_sidebar:
|
||||||
|
return
|
||||||
|
|
||||||
state.sidebar_container.clear()
|
state.sidebar_container.clear()
|
||||||
|
|
||||||
with state.sidebar_container:
|
with state.sidebar_container:
|
||||||
ui.label("🏷️ Category Manager").classes('text-xl font-bold mb-2 text-white')
|
ui.label("🏷️ Category Manager").classes('text-xl font-bold mb-2 text-white')
|
||||||
|
|
||||||
# Number grid (1-25)
|
# Legend for number grid colors
|
||||||
|
with ui.row().classes('gap-4 mb-2 text-xs'):
|
||||||
|
ui.label("🟢 Committed").classes('text-green-400')
|
||||||
|
ui.label("🟡 Staged").classes('text-yellow-400')
|
||||||
|
ui.label("⚫ Free").classes('text-gray-500')
|
||||||
|
|
||||||
|
# Number grid (1-25) with color coding
|
||||||
with ui.grid(columns=5).classes('gap-1 mb-4 w-full'):
|
with ui.grid(columns=5).classes('gap-1 mb-4 w-full'):
|
||||||
for i in range(1, 26):
|
for i in range(1, 26):
|
||||||
is_used = i in state.index_map
|
is_used = i in state.index_map
|
||||||
color = 'green' if is_used else 'grey-9'
|
is_committed = i in state.committed_indexes
|
||||||
|
|
||||||
|
# Color logic: green=committed, yellow=staged, grey=free
|
||||||
|
if is_committed:
|
||||||
|
color = 'green'
|
||||||
|
elif is_used:
|
||||||
|
color = 'yellow'
|
||||||
|
else:
|
||||||
|
color = 'grey-9'
|
||||||
|
|
||||||
def make_click_handler(num: int):
|
def make_click_handler(num: int):
|
||||||
def handler():
|
def handler():
|
||||||
if num in state.index_map:
|
if num in state.index_map:
|
||||||
# Number is used - open preview
|
|
||||||
img_path = state.index_map[num]
|
img_path = state.index_map[num]
|
||||||
is_staged = img_path in state.staged_data
|
is_staged = img_path in state.staged_data
|
||||||
open_zoom_dialog(
|
open_zoom_dialog(
|
||||||
@@ -286,8 +428,8 @@ def render_sidebar():
|
|||||||
show_jump=True
|
show_jump=True
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Number is free - set as next index
|
|
||||||
state.next_index = num
|
state.next_index = num
|
||||||
|
state._dirty_sidebar = True
|
||||||
render_sidebar()
|
render_sidebar()
|
||||||
return handler
|
return handler
|
||||||
|
|
||||||
@@ -300,7 +442,9 @@ def render_sidebar():
|
|||||||
|
|
||||||
def on_category_change(e):
|
def on_category_change(e):
|
||||||
state.active_cat = e.value
|
state.active_cat = e.value
|
||||||
|
state.mark_staged_dirty()
|
||||||
refresh_staged_info()
|
refresh_staged_info()
|
||||||
|
state._dirty_sidebar = True
|
||||||
render_sidebar()
|
render_sidebar()
|
||||||
|
|
||||||
ui.select(
|
ui.select(
|
||||||
@@ -319,7 +463,9 @@ def render_sidebar():
|
|||||||
if new_cat_input.value:
|
if new_cat_input.value:
|
||||||
SorterEngine.add_category(new_cat_input.value)
|
SorterEngine.add_category(new_cat_input.value)
|
||||||
state.active_cat = new_cat_input.value
|
state.active_cat = new_cat_input.value
|
||||||
|
state.mark_staged_dirty()
|
||||||
refresh_staged_info()
|
refresh_staged_info()
|
||||||
|
state._dirty_sidebar = True
|
||||||
render_sidebar()
|
render_sidebar()
|
||||||
|
|
||||||
ui.button(icon='add', on_click=add_category).props('flat color=green')
|
ui.button(icon='add', on_click=add_category).props('flat color=green')
|
||||||
@@ -328,7 +474,9 @@ def render_sidebar():
|
|||||||
with ui.expansion('Danger Zone', icon='warning').classes('w-full text-red-400 mt-2'):
|
with ui.expansion('Danger Zone', icon='warning').classes('w-full text-red-400 mt-2'):
|
||||||
def delete_category():
|
def delete_category():
|
||||||
SorterEngine.delete_category(state.active_cat)
|
SorterEngine.delete_category(state.active_cat)
|
||||||
|
state.mark_staged_dirty()
|
||||||
refresh_staged_info()
|
refresh_staged_info()
|
||||||
|
state._dirty_sidebar = True
|
||||||
render_sidebar()
|
render_sidebar()
|
||||||
|
|
||||||
ui.button('DELETE CATEGORY', color='red', on_click=delete_category).classes('w-full')
|
ui.button('DELETE CATEGORY', color='red', on_click=delete_category).classes('w-full')
|
||||||
@@ -343,23 +491,33 @@ def render_sidebar():
|
|||||||
|
|
||||||
def reset_index():
|
def reset_index():
|
||||||
state.next_index = (max(state.index_map.keys()) + 1) if state.index_map else 1
|
state.next_index = (max(state.index_map.keys()) + 1) if state.index_map else 1
|
||||||
|
state._dirty_sidebar = True
|
||||||
render_sidebar()
|
render_sidebar()
|
||||||
|
|
||||||
ui.button('🔄', on_click=reset_index).props('flat color=white')
|
ui.button('🔄', on_click=reset_index).props('flat color=white')
|
||||||
|
|
||||||
|
state._dirty_sidebar = False
|
||||||
|
|
||||||
def render_gallery():
|
def render_gallery():
|
||||||
"""Render image gallery grid."""
|
"""Render image gallery grid."""
|
||||||
|
if not state._dirty_gallery:
|
||||||
|
return
|
||||||
|
|
||||||
state.grid_container.clear()
|
state.grid_container.clear()
|
||||||
batch = state.get_current_batch()
|
batch = state.get_current_batch()
|
||||||
|
|
||||||
|
# Pre-fetch staged data keys for O(1) lookup in loop
|
||||||
|
staged_keys = set(state.staged_data.keys())
|
||||||
|
|
||||||
with state.grid_container:
|
with state.grid_container:
|
||||||
with ui.grid(columns=state.grid_cols).classes('w-full gap-3'):
|
with ui.grid(columns=state.grid_cols).classes('w-full gap-3'):
|
||||||
for img_path in batch:
|
for img_path in batch:
|
||||||
render_image_card(img_path)
|
render_image_card(img_path, img_path in staged_keys)
|
||||||
|
|
||||||
|
state._dirty_gallery = False
|
||||||
|
|
||||||
def render_image_card(img_path: str):
|
def render_image_card(img_path: str, is_staged: bool):
|
||||||
"""Render individual image card."""
|
"""Render individual image card with lazy loading."""
|
||||||
is_staged = img_path in state.staged_data
|
|
||||||
thumb_size = 800
|
thumb_size = 800
|
||||||
|
|
||||||
with ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow'):
|
with ui.card().classes('p-2 bg-gray-900 border border-gray-700 no-shadow'):
|
||||||
@@ -376,10 +534,10 @@ def render_image_card(img_path: str):
|
|||||||
on_click=lambda p=img_path: action_delete(p)
|
on_click=lambda p=img_path: action_delete(p)
|
||||||
).props('flat size=sm dense color=red')
|
).props('flat size=sm dense color=red')
|
||||||
|
|
||||||
# Thumbnail
|
# Thumbnail with lazy loading
|
||||||
ui.image(f"/thumbnail?path={img_path}&size={thumb_size}&q={state.preview_quality}") \
|
ui.image(f"/thumbnail?path={img_path}&size={thumb_size}&q={state.preview_quality}") \
|
||||||
.classes('w-full h-64 bg-black rounded') \
|
.classes('w-full h-64 bg-black rounded') \
|
||||||
.props('fit=contain no-spinner')
|
.props('fit=contain loading=lazy') # Native lazy loading
|
||||||
|
|
||||||
# Tagging UI
|
# Tagging UI
|
||||||
if is_staged:
|
if is_staged:
|
||||||
@@ -402,9 +560,13 @@ def render_image_card(img_path: str):
|
|||||||
|
|
||||||
def render_pagination():
|
def render_pagination():
|
||||||
"""Render pagination controls."""
|
"""Render pagination controls."""
|
||||||
|
if not state._dirty_pagination:
|
||||||
|
return
|
||||||
|
|
||||||
state.pagination_container.clear()
|
state.pagination_container.clear()
|
||||||
|
|
||||||
if state.total_pages <= 1:
|
if state.total_pages <= 1:
|
||||||
|
state._dirty_pagination = False
|
||||||
return
|
return
|
||||||
|
|
||||||
with state.pagination_container:
|
with state.pagination_container:
|
||||||
@@ -437,17 +599,42 @@ def render_pagination():
|
|||||||
# Next button
|
# Next button
|
||||||
if state.page < state.total_pages - 1:
|
if state.page < state.total_pages - 1:
|
||||||
ui.button('▶', on_click=lambda: set_page(state.page + 1)).props('flat color=white')
|
ui.button('▶', on_click=lambda: set_page(state.page + 1)).props('flat color=white')
|
||||||
|
|
||||||
|
state._dirty_pagination = False
|
||||||
|
|
||||||
def set_page(p: int):
|
def set_page(p: int):
|
||||||
"""Navigate to specific page."""
|
"""Navigate to specific page."""
|
||||||
state.page = max(0, min(p, state.total_pages - 1))
|
state.page = max(0, min(p, state.total_pages - 1))
|
||||||
|
state._dirty_pagination = True
|
||||||
|
state._dirty_gallery = True
|
||||||
refresh_ui()
|
refresh_ui()
|
||||||
|
|
||||||
|
# Preload next page in background
|
||||||
|
asyncio.create_task(preload_adjacent_pages())
|
||||||
|
|
||||||
|
async def preload_adjacent_pages():
|
||||||
|
"""Preload thumbnails for adjacent pages in background."""
|
||||||
|
pages_to_preload = []
|
||||||
|
|
||||||
|
if state.page < state.total_pages - 1:
|
||||||
|
next_start = (state.page + 1) * state.page_size
|
||||||
|
pages_to_preload.extend(state.all_images[next_start:next_start + state.page_size])
|
||||||
|
|
||||||
|
if pages_to_preload:
|
||||||
|
await run.cpu_bound(
|
||||||
|
SorterEngine.load_batch_parallel,
|
||||||
|
pages_to_preload,
|
||||||
|
state.preview_quality
|
||||||
|
)
|
||||||
|
|
||||||
def refresh_ui():
|
def refresh_ui():
|
||||||
"""Refresh all UI components."""
|
"""Refresh dirty UI components only."""
|
||||||
render_sidebar()
|
if state._dirty_sidebar:
|
||||||
render_pagination()
|
render_sidebar()
|
||||||
render_gallery()
|
if state._dirty_pagination:
|
||||||
|
render_pagination()
|
||||||
|
if state._dirty_gallery:
|
||||||
|
render_gallery()
|
||||||
|
|
||||||
def handle_keyboard(e):
|
def handle_keyboard(e):
|
||||||
"""Handle keyboard navigation."""
|
"""Handle keyboard navigation."""
|
||||||
@@ -501,14 +688,14 @@ def build_header():
|
|||||||
ui.slider(
|
ui.slider(
|
||||||
min=2, max=8, step=1,
|
min=2, max=8, step=1,
|
||||||
value=state.grid_cols,
|
value=state.grid_cols,
|
||||||
on_change=lambda e: (setattr(state, 'grid_cols', e.value), refresh_ui())
|
on_change=lambda e: (setattr(state, 'grid_cols', e.value), state.mark_gallery_dirty(), refresh_ui())
|
||||||
).props('color=green')
|
).props('color=green')
|
||||||
|
|
||||||
ui.label('Preview Quality:')
|
ui.label('Preview Quality:')
|
||||||
ui.slider(
|
ui.slider(
|
||||||
min=10, max=100, step=10,
|
min=10, max=100, step=10,
|
||||||
value=state.preview_quality,
|
value=state.preview_quality,
|
||||||
on_change=lambda e: (setattr(state, 'preview_quality', e.value), refresh_ui())
|
on_change=lambda e: (setattr(state, 'preview_quality', e.value), state.mark_gallery_dirty(), refresh_ui())
|
||||||
).props('color=green label-always')
|
).props('color=green label-always')
|
||||||
|
|
||||||
ui.switch('Dark', value=True, on_change=lambda e: ui.dark_mode().set_value(e.value)) \
|
ui.switch('Dark', value=True, on_change=lambda e: ui.dark_mode().set_value(e.value)) \
|
||||||
@@ -563,6 +750,9 @@ build_main_content()
|
|||||||
|
|
||||||
ui.keyboard(on_key=handle_keyboard)
|
ui.keyboard(on_key=handle_keyboard)
|
||||||
ui.dark_mode().enable()
|
ui.dark_mode().enable()
|
||||||
|
|
||||||
|
# Initial load with all dirty flags set
|
||||||
|
state.mark_all_dirty()
|
||||||
load_images()
|
load_images()
|
||||||
|
|
||||||
ui.run(title="NiceSorter", host="0.0.0.0", port=8080, reload=False)
|
ui.run(title="NiceSorter", host="0.0.0.0", port=8080, reload=False)
|
||||||
Reference in New Issue
Block a user