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
|
||||
|
||||
# ==========================================
|
||||
# STATE MANAGEMENT
|
||||
# STATE MANAGEMENT (OPTIMIZED)
|
||||
# ==========================================
|
||||
class AppState:
|
||||
"""Centralized application state with lazy loading."""
|
||||
"""Centralized application state with caching and dirty flags."""
|
||||
|
||||
def __init__(self):
|
||||
# Profile Data
|
||||
@@ -37,9 +37,23 @@ class AppState:
|
||||
|
||||
# Data Caches
|
||||
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.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)
|
||||
self.sidebar_container = None
|
||||
@@ -80,30 +94,122 @@ class AppState:
|
||||
start = self.page * 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()
|
||||
|
||||
# ==========================================
|
||||
# IMAGE SERVING API
|
||||
# IMAGE SERVING API (OPTIMIZED WITH CACHING)
|
||||
# ==========================================
|
||||
|
||||
@app.get('/thumbnail')
|
||||
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):
|
||||
return Response(status_code=404)
|
||||
|
||||
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')
|
||||
async def get_full_res(path: str):
|
||||
"""Serve full resolution image."""
|
||||
if not os.path.exists(path):
|
||||
return Response(status_code=404)
|
||||
|
||||
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():
|
||||
@@ -113,43 +219,52 @@ def load_images():
|
||||
return
|
||||
|
||||
state.all_images = SorterEngine.get_images(state.source_dir, recursive=True)
|
||||
state.rebuild_path_index() # Build reverse index
|
||||
|
||||
# Reset page if out of bounds
|
||||
if state.page >= state.total_pages:
|
||||
state.page = 0
|
||||
|
||||
state.mark_staged_dirty() # Force refresh of staged data
|
||||
refresh_staged_info()
|
||||
state.mark_all_dirty()
|
||||
refresh_ui()
|
||||
|
||||
def refresh_staged_info():
|
||||
"""Update staged data and index maps."""
|
||||
state.staged_data = SorterEngine.get_staged_data()
|
||||
|
||||
# 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)
|
||||
"""Update staged data and index maps (optimized) - includes persistent tags."""
|
||||
# Green dots using optimized O(staged) lookup
|
||||
state.green_dots = state.compute_green_dots()
|
||||
|
||||
# Build index map for active category
|
||||
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():
|
||||
if info['cat'] == state.active_cat:
|
||||
idx = _extract_index(info['name'])
|
||||
if idx is not None:
|
||||
state.index_map[idx] = orig_path
|
||||
staged_indexes.add(idx)
|
||||
|
||||
# Add committed images from disk
|
||||
cat_path = os.path.join(state.output_dir, state.active_cat)
|
||||
if os.path.exists(cat_path):
|
||||
for filename in os.listdir(cat_path):
|
||||
if filename.startswith(state.active_cat):
|
||||
idx = _extract_index(filename)
|
||||
if idx is not None and idx not in state.index_map:
|
||||
state.index_map[idx] = os.path.join(cat_path, filename)
|
||||
# 2. Add committed images from disk (cached scan)
|
||||
disk_map = state.get_disk_index_map(state.active_cat)
|
||||
for idx, path in disk_map.items():
|
||||
if idx not in state.index_map:
|
||||
state.index_map[idx] = path
|
||||
state.committed_indexes.add(idx)
|
||||
|
||||
# 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]:
|
||||
"""Extract numeric index from filename (e.g., 'Cat_042.jpg' -> 42)."""
|
||||
@@ -159,7 +274,7 @@ def _extract_index(filename: str) -> Optional[int]:
|
||||
return None
|
||||
|
||||
# ==========================================
|
||||
# ACTIONS
|
||||
# ACTIONS (OPTIMIZED)
|
||||
# ==========================================
|
||||
|
||||
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)
|
||||
|
||||
# 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:
|
||||
state.next_index = idx + 1
|
||||
|
||||
state.mark_staged_dirty()
|
||||
refresh_staged_info()
|
||||
state.mark_sidebar_dirty()
|
||||
state.mark_gallery_dirty()
|
||||
refresh_ui()
|
||||
|
||||
def action_untag(img_path: str):
|
||||
"""Remove staging from an image."""
|
||||
SorterEngine.clear_staged_item(img_path)
|
||||
state.mark_staged_dirty()
|
||||
refresh_staged_info()
|
||||
state.mark_sidebar_dirty()
|
||||
state.mark_gallery_dirty()
|
||||
refresh_ui()
|
||||
|
||||
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)
|
||||
ui.notify(f"Page processed ({state.batch_mode})", type='positive')
|
||||
|
||||
state.invalidate_disk_cache() # Disk content changed
|
||||
state.mark_staged_dirty()
|
||||
load_images()
|
||||
|
||||
async def action_apply_global():
|
||||
@@ -217,11 +341,13 @@ async def action_apply_global():
|
||||
state.batch_mode,
|
||||
state.source_dir
|
||||
)
|
||||
state.invalidate_disk_cache() # Disk content changed
|
||||
state.mark_staged_dirty()
|
||||
load_images()
|
||||
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):
|
||||
@@ -232,9 +358,9 @@ def open_zoom_dialog(path: str, title: Optional[str] = None, show_untag: bool =
|
||||
|
||||
with ui.row().classes('gap-2'):
|
||||
# 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():
|
||||
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
|
||||
dialog.close()
|
||||
set_page(target_page)
|
||||
@@ -262,21 +388,37 @@ def open_zoom_dialog(path: str, title: Optional[str] = None, show_untag: bool =
|
||||
|
||||
def render_sidebar():
|
||||
"""Render category management sidebar."""
|
||||
if not state._dirty_sidebar:
|
||||
return
|
||||
|
||||
state.sidebar_container.clear()
|
||||
|
||||
with state.sidebar_container:
|
||||
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'):
|
||||
for i in range(1, 26):
|
||||
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 handler():
|
||||
if num in state.index_map:
|
||||
# Number is used - open preview
|
||||
img_path = state.index_map[num]
|
||||
is_staged = img_path in state.staged_data
|
||||
open_zoom_dialog(
|
||||
@@ -286,8 +428,8 @@ def render_sidebar():
|
||||
show_jump=True
|
||||
)
|
||||
else:
|
||||
# Number is free - set as next index
|
||||
state.next_index = num
|
||||
state._dirty_sidebar = True
|
||||
render_sidebar()
|
||||
return handler
|
||||
|
||||
@@ -300,7 +442,9 @@ def render_sidebar():
|
||||
|
||||
def on_category_change(e):
|
||||
state.active_cat = e.value
|
||||
state.mark_staged_dirty()
|
||||
refresh_staged_info()
|
||||
state._dirty_sidebar = True
|
||||
render_sidebar()
|
||||
|
||||
ui.select(
|
||||
@@ -319,7 +463,9 @@ def render_sidebar():
|
||||
if new_cat_input.value:
|
||||
SorterEngine.add_category(new_cat_input.value)
|
||||
state.active_cat = new_cat_input.value
|
||||
state.mark_staged_dirty()
|
||||
refresh_staged_info()
|
||||
state._dirty_sidebar = True
|
||||
render_sidebar()
|
||||
|
||||
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'):
|
||||
def delete_category():
|
||||
SorterEngine.delete_category(state.active_cat)
|
||||
state.mark_staged_dirty()
|
||||
refresh_staged_info()
|
||||
state._dirty_sidebar = True
|
||||
render_sidebar()
|
||||
|
||||
ui.button('DELETE CATEGORY', color='red', on_click=delete_category).classes('w-full')
|
||||
@@ -343,23 +491,33 @@ def render_sidebar():
|
||||
|
||||
def reset_index():
|
||||
state.next_index = (max(state.index_map.keys()) + 1) if state.index_map else 1
|
||||
state._dirty_sidebar = True
|
||||
render_sidebar()
|
||||
|
||||
ui.button('🔄', on_click=reset_index).props('flat color=white')
|
||||
|
||||
state._dirty_sidebar = False
|
||||
|
||||
def render_gallery():
|
||||
"""Render image gallery grid."""
|
||||
if not state._dirty_gallery:
|
||||
return
|
||||
|
||||
state.grid_container.clear()
|
||||
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 ui.grid(columns=state.grid_cols).classes('w-full gap-3'):
|
||||
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):
|
||||
"""Render individual image card."""
|
||||
is_staged = img_path in state.staged_data
|
||||
def render_image_card(img_path: str, is_staged: bool):
|
||||
"""Render individual image card with lazy loading."""
|
||||
thumb_size = 800
|
||||
|
||||
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)
|
||||
).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}") \
|
||||
.classes('w-full h-64 bg-black rounded') \
|
||||
.props('fit=contain no-spinner')
|
||||
.props('fit=contain loading=lazy') # Native lazy loading
|
||||
|
||||
# Tagging UI
|
||||
if is_staged:
|
||||
@@ -402,9 +560,13 @@ def render_image_card(img_path: str):
|
||||
|
||||
def render_pagination():
|
||||
"""Render pagination controls."""
|
||||
if not state._dirty_pagination:
|
||||
return
|
||||
|
||||
state.pagination_container.clear()
|
||||
|
||||
if state.total_pages <= 1:
|
||||
state._dirty_pagination = False
|
||||
return
|
||||
|
||||
with state.pagination_container:
|
||||
@@ -437,17 +599,42 @@ def render_pagination():
|
||||
# Next button
|
||||
if state.page < state.total_pages - 1:
|
||||
ui.button('▶', on_click=lambda: set_page(state.page + 1)).props('flat color=white')
|
||||
|
||||
state._dirty_pagination = False
|
||||
|
||||
def set_page(p: int):
|
||||
"""Navigate to specific page."""
|
||||
state.page = max(0, min(p, state.total_pages - 1))
|
||||
state._dirty_pagination = True
|
||||
state._dirty_gallery = True
|
||||
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():
|
||||
"""Refresh all UI components."""
|
||||
render_sidebar()
|
||||
render_pagination()
|
||||
render_gallery()
|
||||
"""Refresh dirty UI components only."""
|
||||
if state._dirty_sidebar:
|
||||
render_sidebar()
|
||||
if state._dirty_pagination:
|
||||
render_pagination()
|
||||
if state._dirty_gallery:
|
||||
render_gallery()
|
||||
|
||||
def handle_keyboard(e):
|
||||
"""Handle keyboard navigation."""
|
||||
@@ -501,14 +688,14 @@ def build_header():
|
||||
ui.slider(
|
||||
min=2, max=8, step=1,
|
||||
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')
|
||||
|
||||
ui.label('Preview Quality:')
|
||||
ui.slider(
|
||||
min=10, max=100, step=10,
|
||||
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')
|
||||
|
||||
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.dark_mode().enable()
|
||||
|
||||
# Initial load with all dirty flags set
|
||||
state.mark_all_dirty()
|
||||
load_images()
|
||||
|
||||
ui.run(title="NiceSorter", host="0.0.0.0", port=8080, reload=False)
|
||||
Reference in New Issue
Block a user