Update gallery_app.py
This commit is contained in:
@@ -24,7 +24,7 @@ class AppState:
|
|||||||
self.page = 0
|
self.page = 0
|
||||||
self.page_size = 24
|
self.page_size = 24
|
||||||
self.grid_cols = 4
|
self.grid_cols = 4
|
||||||
self.preview_quality = 50 # Default compression level
|
self.preview_quality = 50
|
||||||
|
|
||||||
# Tagging State
|
# Tagging State
|
||||||
self.active_cat = "Default"
|
self.active_cat = "Default"
|
||||||
@@ -38,7 +38,7 @@ class AppState:
|
|||||||
self.all_images = []
|
self.all_images = []
|
||||||
self.staged_data = {}
|
self.staged_data = {}
|
||||||
self.green_dots = set()
|
self.green_dots = set()
|
||||||
self.index_map = {} # {number: path_to_image}
|
self.index_map = {}
|
||||||
|
|
||||||
def load_active_profile(self):
|
def load_active_profile(self):
|
||||||
p_data = self.profiles.get(self.profile_name, {})
|
p_data = self.profiles.get(self.profile_name, {})
|
||||||
@@ -56,15 +56,12 @@ class AppState:
|
|||||||
state = AppState()
|
state = AppState()
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# 2. IMAGE SERVING API (Dynamic Quality)
|
# 2. IMAGE SERVING API
|
||||||
# ==========================================
|
# ==========================================
|
||||||
|
|
||||||
@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):
|
||||||
"""
|
"""Serves WebP thumbnail with dynamic quality."""
|
||||||
Serves WebP thumbnail.
|
|
||||||
'q' parameter allows dynamic quality adjustment from the UI.
|
|
||||||
"""
|
|
||||||
if not os.path.exists(path): return Response(status_code=404)
|
if not os.path.exists(path): 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)
|
return Response(content=img_bytes, media_type="image/webp") if img_bytes else Response(status_code=500)
|
||||||
@@ -72,7 +69,6 @@ async def get_thumbnail(path: str, size: int = 400, q: int = 50):
|
|||||||
@app.get('/full_res')
|
@app.get('/full_res')
|
||||||
async def get_full_res(path: str):
|
async def get_full_res(path: str):
|
||||||
if not os.path.exists(path): return Response(status_code=404)
|
if not os.path.exists(path): return Response(status_code=404)
|
||||||
# Full res always uses high quality (90)
|
|
||||||
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")
|
return Response(content=img_bytes, media_type="image/webp")
|
||||||
|
|
||||||
@@ -83,10 +79,8 @@ async def get_full_res(path: str):
|
|||||||
def load_images():
|
def load_images():
|
||||||
if os.path.exists(state.source_dir):
|
if os.path.exists(state.source_dir):
|
||||||
state.all_images = SorterEngine.get_images(state.source_dir, recursive=True)
|
state.all_images = SorterEngine.get_images(state.source_dir, recursive=True)
|
||||||
# Safety check for page bounds
|
|
||||||
total_pages = math.ceil(len(state.all_images) / state.page_size)
|
total_pages = math.ceil(len(state.all_images) / state.page_size)
|
||||||
if state.page >= total_pages: state.page = 0
|
if state.page >= total_pages: state.page = 0
|
||||||
|
|
||||||
refresh_staged_info()
|
refresh_staged_info()
|
||||||
refresh_ui()
|
refresh_ui()
|
||||||
else:
|
else:
|
||||||
@@ -104,16 +98,14 @@ def refresh_staged_info():
|
|||||||
|
|
||||||
# 2. Update Sidebar Index Map
|
# 2. Update Sidebar Index Map
|
||||||
state.index_map.clear()
|
state.index_map.clear()
|
||||||
|
# Staging
|
||||||
# Check Staging
|
|
||||||
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:
|
||||||
try:
|
try:
|
||||||
num = int(info['name'].rsplit('_', 1)[1].split('.')[0])
|
num = int(info['name'].rsplit('_', 1)[1].split('.')[0])
|
||||||
state.index_map[num] = orig_path
|
state.index_map[num] = orig_path
|
||||||
except: pass
|
except: pass
|
||||||
|
# Disk
|
||||||
# Check Disk
|
|
||||||
cat_path = os.path.join(state.output_dir, state.active_cat)
|
cat_path = os.path.join(state.output_dir, state.active_cat)
|
||||||
if os.path.exists(cat_path):
|
if os.path.exists(cat_path):
|
||||||
for f in os.listdir(cat_path):
|
for f in os.listdir(cat_path):
|
||||||
@@ -136,7 +128,6 @@ def action_tag(img_path, manual_idx=None):
|
|||||||
ext = os.path.splitext(img_path)[1]
|
ext = os.path.splitext(img_path)[1]
|
||||||
name = f"{state.active_cat}_{idx:03d}{ext}"
|
name = f"{state.active_cat}_{idx:03d}{ext}"
|
||||||
|
|
||||||
# Conflict Check
|
|
||||||
final_path = os.path.join(state.output_dir, state.active_cat, name)
|
final_path = os.path.join(state.output_dir, state.active_cat, name)
|
||||||
staged_names = {v['name'] for v in state.staged_data.values() if v['cat'] == state.active_cat}
|
staged_names = {v['name'] for v in state.staged_data.values() if v['cat'] == state.active_cat}
|
||||||
|
|
||||||
@@ -145,17 +136,13 @@ def action_tag(img_path, manual_idx=None):
|
|||||||
name = f"{state.active_cat}_{idx:03d}_{len(staged_names)+1}{ext}"
|
name = f"{state.active_cat}_{idx:03d}_{len(staged_names)+1}{ext}"
|
||||||
|
|
||||||
SorterEngine.stage_image(img_path, state.active_cat, name)
|
SorterEngine.stage_image(img_path, state.active_cat, name)
|
||||||
|
|
||||||
if manual_idx is None or manual_idx == state.next_index:
|
if manual_idx is None or manual_idx == state.next_index:
|
||||||
state.next_index = idx + 1
|
state.next_index = idx + 1
|
||||||
|
refresh_staged_info(); refresh_ui()
|
||||||
refresh_staged_info()
|
|
||||||
refresh_ui()
|
|
||||||
|
|
||||||
def action_untag(img_path):
|
def action_untag(img_path):
|
||||||
SorterEngine.clear_staged_item(img_path)
|
SorterEngine.clear_staged_item(img_path)
|
||||||
refresh_staged_info()
|
refresh_staged_info(); refresh_ui()
|
||||||
refresh_ui()
|
|
||||||
|
|
||||||
def action_delete(img_path):
|
def action_delete(img_path):
|
||||||
SorterEngine.delete_to_trash(img_path)
|
SorterEngine.delete_to_trash(img_path)
|
||||||
@@ -197,15 +184,13 @@ def render_sidebar():
|
|||||||
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'
|
color = 'green' if is_used else 'grey-9'
|
||||||
|
|
||||||
def click_grid(num=i, used=is_used):
|
def click_grid(num=i, used=is_used):
|
||||||
state.next_index = num
|
state.next_index = num
|
||||||
if used: open_zoom_dialog(state.index_map[num], f"{state.active_cat} #{num}")
|
if used: open_zoom_dialog(state.index_map[num], f"{state.active_cat} #{num}")
|
||||||
render_sidebar()
|
render_sidebar()
|
||||||
|
|
||||||
ui.button(str(i), on_click=click_grid).props(f'color={color} size=sm flat').classes('w-full border border-gray-800')
|
ui.button(str(i), on_click=click_grid).props(f'color={color} size=sm flat').classes('w-full border border-gray-800')
|
||||||
|
|
||||||
# Category Selector
|
# Category Select
|
||||||
categories = SorterEngine.get_categories() or ["Default"]
|
categories = SorterEngine.get_categories() or ["Default"]
|
||||||
if state.active_cat not in categories: state.active_cat = categories[0]
|
if state.active_cat not in categories: state.active_cat = categories[0]
|
||||||
|
|
||||||
@@ -236,7 +221,6 @@ def render_sidebar():
|
|||||||
def render_gallery():
|
def render_gallery():
|
||||||
grid_container.clear()
|
grid_container.clear()
|
||||||
batch = get_current_batch()
|
batch = get_current_batch()
|
||||||
# Dynamic thumbnail sizing
|
|
||||||
thumb_size = int(1800 / state.grid_cols)
|
thumb_size = int(1800 / state.grid_cols)
|
||||||
|
|
||||||
with grid_container:
|
with grid_container:
|
||||||
@@ -244,7 +228,6 @@ def render_gallery():
|
|||||||
for img_path in batch:
|
for img_path in batch:
|
||||||
is_staged = img_path in state.staged_data
|
is_staged = img_path in state.staged_data
|
||||||
|
|
||||||
# Card Container
|
|
||||||
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'):
|
||||||
# Header
|
# Header
|
||||||
with ui.row().classes('w-full justify-between no-wrap mb-1'):
|
with ui.row().classes('w-full justify-between no-wrap mb-1'):
|
||||||
@@ -254,21 +237,20 @@ def render_gallery():
|
|||||||
ui.button(icon='delete', on_click=lambda p=img_path: action_delete(p)).props('flat size=sm dense color=red')
|
ui.button(icon='delete', on_click=lambda p=img_path: action_delete(p)).props('flat size=sm dense color=red')
|
||||||
|
|
||||||
# --- FIXED IMAGE RENDERING ---
|
# --- FIXED IMAGE RENDERING ---
|
||||||
# Changed 'object-cover' to 'object-contain'
|
# 1. Increased height to h-64 (256px) for better visibility
|
||||||
# Added 'bg-black' so the empty space around non-square images looks clean
|
# 2. Changed object-cover to object-contain (NO CROPPING)
|
||||||
|
# 3. Added bg-black to fill empty space nicely
|
||||||
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-48 object-contain bg-black rounded') \
|
.classes('w-full h-64 object-contain bg-black rounded') \
|
||||||
.props('no-spinner')
|
.props('no-spinner')
|
||||||
|
|
||||||
# Actions
|
# Actions
|
||||||
if is_staged:
|
if is_staged:
|
||||||
info = state.staged_data[img_path]
|
info = state.staged_data[img_path]
|
||||||
try:
|
try: num = info['name'].rsplit('_', 1)[1].split('.')[0]
|
||||||
num = info['name'].rsplit('_', 1)[1].split('.')[0]
|
except: num = "?"
|
||||||
label = f"Untag (#{num})"
|
|
||||||
except: label = "Untag"
|
|
||||||
ui.label(f"🏷️ {info['cat']}").classes('text-center text-green-400 text-xs py-1 w-full')
|
ui.label(f"🏷️ {info['cat']}").classes('text-center text-green-400 text-xs py-1 w-full')
|
||||||
ui.button(label, on_click=lambda p=img_path: action_untag(p)).props('flat color=grey-5 dense').classes('w-full')
|
ui.button(f"Untag (#{num})", on_click=lambda p=img_path: action_untag(p)).props('flat color=grey-5 dense').classes('w-full')
|
||||||
else:
|
else:
|
||||||
with ui.row().classes('w-full no-wrap mt-2 gap-1'):
|
with ui.row().classes('w-full no-wrap mt-2 gap-1'):
|
||||||
local_idx = ui.number(value=state.next_index, precision=0).props('dense dark outlined').classes('w-1/3')
|
local_idx = ui.number(value=state.next_index, precision=0).props('dense dark outlined').classes('w-1/3')
|
||||||
@@ -283,10 +265,7 @@ def render_pagination():
|
|||||||
|
|
||||||
with pagination_container:
|
with pagination_container:
|
||||||
# Slider
|
# Slider
|
||||||
def on_slide(e):
|
ui.slider(min=0, max=total_pages-1, value=state.page, on_change=lambda e: set_page(int(e.value))).classes('w-1/2 mb-2').props('color=green')
|
||||||
state.page = int(e.value)
|
|
||||||
refresh_ui()
|
|
||||||
ui.slider(min=0, max=total_pages-1, value=state.page, on_change=on_slide).classes('w-1/2 mb-2').props('color=green')
|
|
||||||
|
|
||||||
# Buttons
|
# Buttons
|
||||||
with ui.row().classes('items-center gap-2'):
|
with ui.row().classes('items-center gap-2'):
|
||||||
@@ -302,13 +281,10 @@ def render_pagination():
|
|||||||
ui.button('▶', on_click=lambda: set_page(state.page + 1)).props('flat color=white').bind_visibility_from(state, 'page', backward=lambda x: x < total_pages - 1)
|
ui.button('▶', on_click=lambda: set_page(state.page + 1)).props('flat color=white').bind_visibility_from(state, 'page', backward=lambda x: x < total_pages - 1)
|
||||||
|
|
||||||
def set_page(p):
|
def set_page(p):
|
||||||
state.page = p
|
state.page = p; refresh_ui()
|
||||||
refresh_ui()
|
|
||||||
|
|
||||||
def refresh_ui():
|
def refresh_ui():
|
||||||
render_sidebar()
|
render_sidebar(); render_pagination(); render_gallery()
|
||||||
render_pagination()
|
|
||||||
render_gallery()
|
|
||||||
|
|
||||||
def handle_key(e):
|
def handle_key(e):
|
||||||
if not e.action.keydown: return
|
if not e.action.keydown: return
|
||||||
@@ -328,9 +304,7 @@ with ui.header().classes('items-center bg-slate-900 text-white border-b border-g
|
|||||||
# Profile Select
|
# Profile Select
|
||||||
profile_names = list(state.profiles.keys())
|
profile_names = list(state.profiles.keys())
|
||||||
def change_profile(e):
|
def change_profile(e):
|
||||||
state.profile_name = e.value
|
state.profile_name = e.value; state.load_active_profile(); load_images()
|
||||||
state.load_active_profile()
|
|
||||||
load_images()
|
|
||||||
ui.select(profile_names, value=state.profile_name, on_change=change_profile).props('dark dense options-dense borderless').classes('w-32')
|
ui.select(profile_names, value=state.profile_name, on_change=change_profile).props('dark dense options-dense borderless').classes('w-32')
|
||||||
|
|
||||||
# Paths
|
# Paths
|
||||||
@@ -341,18 +315,14 @@ with ui.header().classes('items-center bg-slate-900 text-white border-b border-g
|
|||||||
ui.button(icon='save', on_click=state.save_current_profile).props('flat round color=white')
|
ui.button(icon='save', on_click=state.save_current_profile).props('flat round color=white')
|
||||||
ui.button('LOAD', on_click=load_images).props('color=green flat').classes('font-bold border border-green-700')
|
ui.button('LOAD', on_click=load_images).props('color=green flat').classes('font-bold border border-green-700')
|
||||||
|
|
||||||
# VIEW SETTINGS MENU (New)
|
# View Menu
|
||||||
with ui.button(icon='tune', color='white').props('flat round'):
|
with ui.button(icon='tune', color='white').props('flat round'):
|
||||||
with ui.menu().classes('bg-gray-800 text-white p-4'):
|
with ui.menu().classes('bg-gray-800 text-white p-4'):
|
||||||
ui.label('VIEW SETTINGS').classes('text-xs font-bold mb-2')
|
ui.label('VIEW SETTINGS').classes('text-xs font-bold mb-2')
|
||||||
|
|
||||||
ui.label('Grid Columns:')
|
ui.label('Grid Columns:')
|
||||||
def set_cols(v): state.grid_cols=v; refresh_ui()
|
ui.slider(min=2, max=8, step=1, value=state.grid_cols, on_change=lambda e: (setattr(state, 'grid_cols', e.value), refresh_ui())).props('color=green')
|
||||||
ui.slider(min=2, max=8, step=1, value=state.grid_cols, on_change=lambda e: set_cols(e.value)).props('color=green')
|
|
||||||
|
|
||||||
ui.label('Preview Quality:')
|
ui.label('Preview Quality:')
|
||||||
def set_qual(v): state.preview_quality=v; refresh_ui()
|
ui.slider(min=10, max=100, step=10, value=state.preview_quality, on_change=lambda e: (setattr(state, 'preview_quality', e.value), refresh_ui())).props('color=green label-always')
|
||||||
ui.slider(min=10, max=100, step=10, value=state.preview_quality, on_change=lambda e: set_qual(e.value)).props('color=green label-always')
|
|
||||||
|
|
||||||
ui.switch('Dark', value=True, on_change=lambda e: ui.dark_mode().set_value(e.value)).props('color=green')
|
ui.switch('Dark', value=True, on_change=lambda e: ui.dark_mode().set_value(e.value)).props('color=green')
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user