diff --git a/cista/preview.py b/cista/preview.py index 08f2180..8b3d346 100644 --- a/cista/preview.py +++ b/cista/preview.py @@ -20,7 +20,8 @@ from sanic import Blueprint, empty, raw, redirect from sanic.exceptions import NotFound from sanic.log import logger -from cista import auth, config, onlyoffice, sharefs +from cista import auth, config, onlyoffice, sharefs, watching +from cista.fileio import fuid from cista.preview_worker import ( DOC_PREVIEW_SUFFIXES, OFFICE_PREVIEW_SUFFIXES, @@ -656,6 +657,12 @@ async def preview(req, path): # Preview generation failed, redirect to the file itself return redirect(f"/files/{path}", status=303) + # Store aspect ratio if the worker returned dimensions + if preview_resp and preview_resp.width and preview_resp.height: + ar = round(preview_resp.height / preview_resp.width, 2) + fuid_str = fuid(stat) + watching.notify_ar(fuid_str, ar) + # Build headers and cache the full response preview_mime = ( preview_resp.mime diff --git a/cista/preview_worker.py b/cista/preview_worker.py index 408e2d6..909e664 100644 --- a/cista/preview_worker.py +++ b/cista/preview_worker.py @@ -95,6 +95,8 @@ class PreviewResponse(msgspec.Struct, omit_defaults=True): timings: list[float] | None = None error: str | None = None stderr: str | None = None + width: int | None = None + height: int | None = None _enc = msgspec.json.Encoder() @@ -173,6 +175,7 @@ def _get_image_dimensions(path: Path) -> tuple[int, int] | None: """ try: img = pyvips.Image.new_from_file(str(path)) + img = img.autorot() except pyvips.error.Error: return None else: @@ -228,6 +231,8 @@ def process_image_pyvips(path, *, maxsize, quality): # HEIC/HEIF: ffmpeg handles tile assembly and HDR correctly; # skip pyvips entirely. if suffix in (".heic", ".heif"): + heic_dims = _get_image_dimensions(path) + width, height = heic_dims or (None, None) ret = _image_via_ffmpeg(path, maxsize, quality) t_end = perf_counter() return ret, PreviewResponse( @@ -235,13 +240,17 @@ def process_image_pyvips(path, *, maxsize, quality): mime="image/avif", backend="ffmpeg", timings=[round((t_end - t_start) * 1000, 1)], + width=width, + height=height, ) # Other image formats: pyvips first, ffmpeg fallback. load_opts = {"access": "sequential"} + orig_w = orig_h = None try: img = pyvips.Image.new_from_file(str(path), **load_opts) img = img.autorot() + orig_w, orig_h = img.width, img.height scale = min(maxsize / img.width, maxsize / img.height, 1.0) if scale < 1.0: img = img.resize(scale) @@ -253,6 +262,7 @@ def process_image_pyvips(path, *, maxsize, quality): ) backend = "pyvips" except pyvips.error.Error: + orig_w, orig_h = None, None ret = _image_via_ffmpeg(path, maxsize, quality) backend = "ffmpeg" t_end = perf_counter() @@ -262,6 +272,8 @@ def process_image_pyvips(path, *, maxsize, quality): mime="image/avif", backend=backend, timings=[round((t_end - t_start) * 1000, 1)], + width=orig_w, + height=orig_h, ) @@ -270,6 +282,7 @@ def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom): t_start = perf_counter() img = pyvips.Image.new_from_buffer(data, "") img = img.autorot() + orig_w, orig_h = img.width, img.height scale = min(maxsize / img.width, maxsize / img.height, 1.0) if scale < 1.0: img = img.resize(scale) @@ -286,6 +299,8 @@ def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom): mime="image/avif", backend="pyvips", timings=[round((t_end - t_start) * 1000, 1)], + width=orig_w, + height=orig_h, ) @@ -315,6 +330,8 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0): round((t_load_end - t_load_start) * 1000, 1), round((t_save_end - t_save_start) * 1000, 1), ], + width=round(w), + height=round(h), ) @@ -347,6 +364,11 @@ def process_video(path, *, maxsize, quality): raise RuntimeError("No frames found in video") # Resize frame to thumbnail size + # Capture display dimensions before resize (accounting for rotation) + disp_w = frame.width + disp_h = frame.height + if frame.rotation in (90, 270): + disp_w, disp_h = disp_h, disp_w if frame.width > maxsize or frame.height > maxsize: scale_factor = min(maxsize / frame.width, maxsize / frame.height) new_width = int(frame.width * scale_factor) @@ -442,6 +464,8 @@ def process_video(path, *, maxsize, quality): round((t_load_end - t_load_start) * 1000, 1), round((t_save_end - t_save_start) * 1000, 1), ], + width=disp_w, + height=disp_h, ) del imgdata, istream, ostream, icc, occ, frame gc.collect() diff --git a/cista/protocol.py b/cista/protocol.py index 974dc5c..93a38bb 100644 --- a/cista/protocol.py +++ b/cista/protocol.py @@ -12,7 +12,7 @@ class ErrorMsg(msgspec.Struct): ## Directory listings -class FileEntry(msgspec.Struct, array_like=True, frozen=True): +class FileEntry(msgspec.Struct, array_like=True, frozen=True, omit_defaults=True): level: int name: str key: str @@ -20,6 +20,7 @@ class FileEntry(msgspec.Struct, array_like=True, frozen=True): size: int allocated: int isfile: int + ar: float | None = None def __str__(self): return self.key or "FileEntry()" diff --git a/cista/watching.py b/cista/watching.py index 6693598..75fba65 100644 --- a/cista/watching.py +++ b/cista/watching.py @@ -154,6 +154,17 @@ stop_event = threading.Event() # Thread-safe queue for signaling path updates from websockets _update_queue: queue.Queue[PurePosixPath] = queue.Queue() +# Thread-safe queue for AR updates from the preview worker +_ar_queue: queue.Queue[tuple[str, float]] = queue.Queue() + +# AR map: fuid -> aspect ratio (height/width). Written only by the watcher thread. +_ar_map: dict[str, float] = {} + + +def notify_ar(fuid_key: str, ar: float) -> None: + """Called from preview handler to update the AR for a file.""" + _ar_queue.put_nowait((fuid_key, ar)) + def notify_change(*paths: PurePosixPath | str): """Signal that paths have changed. Called from control/upload websockets.""" @@ -186,14 +197,16 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry] except Exception: logger.exception(f"get_allocated_size failed for {path}") allocated = st.st_size if isfile else 0 + key = fuid(st) entry = FileEntry( level=len(rel.parts), name=rel.name, - key=fuid(st), + key=key, mtime=int(st.st_mtime), size=st.st_size if isfile else 0, allocated=allocated, isfile=isfile, + ar=_ar_map.get(key) if isfile else None, ) if isfile: return [entry] @@ -775,6 +788,43 @@ def watcher(loop): broadcast(format_root(fresh), loop) state.root = fresh + # Drain AR updates from preview worker (immediate, no debounce) + ar_new_root: list[FileEntry] | None = None + try: + while True: + fuid_key, ar = _ar_queue.get_nowait() + _ar_map[fuid_key] = ar + # Patch the matching entry in the current root + root_to_patch = ( + ar_new_root if ar_new_root is not None else path_index.root + ) + for i, entry in enumerate(root_to_patch): + if entry.key == fuid_key and entry.isfile and entry.ar != ar: + if ar_new_root is None: + ar_new_root = root_to_patch[:] + ar_new_root[i] = FileEntry( + level=entry.level, + name=entry.name, + key=entry.key, + mtime=entry.mtime, + size=entry.size, + allocated=entry.allocated, + isfile=entry.isfile, + ar=ar, + ) + break + except queue.Empty: + pass + if ar_new_root is not None: + try: + update_msg = format_update(state.root, ar_new_root) + with state.lock: + broadcast(update_msg, loop) + state.root = ar_new_root + path_index = PathIndex(ar_new_root) + except Exception: + logger.exception("AR update broadcast failed") + # Collect events from websocket signals (non-blocking) try: while True: diff --git a/frontend/src/components/Gallery.vue b/frontend/src/components/Gallery.vue index 0ec7604..99a12e1 100644 --- a/frontend/src/components/Gallery.vue +++ b/frontend/src/components/Gallery.vue @@ -3,7 +3,13 @@ @@ -21,6 +27,7 @@ import { onUnmounted, ref, shallowRef, + watch, watchEffect } from 'vue' import { useRouter } from 'vue-router' @@ -71,11 +78,108 @@ const rename = async (doc: Doc, newName: string) => { } const gallery = ref() const columnCount = ref(1) +const columnWidthPx = ref(240) +const emPx = ref(16) +const aspectByKey = ref>({}) + +const optimalRowHeightPx = (ratios: number[]) => { + const w = Math.max(1, columnWidthPx.value) + const minH = Math.max(1, Math.round(7 * emPx.value)) + const maxH = Math.max(minH, Math.round(25 * emPx.value)) + const usable = ratios.filter(ar => Number.isFinite(ar) && ar > 0) + if (usable.length === 0) return Math.round(15 * emPx.value) + + let bestH = Math.round(15 * emPx.value) + let bestScore = -1 + for (let h = minH; h <= maxH; h++) { + let score = 0 + for (const ar of usable) { + let shownW = w + let shownH = w * ar + if (shownH > h) { + shownH = h + shownW = h / ar + } + // Fill efficiency in the row cell (0..1) + score += (shownW * shownH) / (w * h) + } + if (score > bestScore) { + bestScore = score + bestH = h + } + } + return bestH +} + +const setAspect = (key: string, ar: number) => { + if (!Number.isFinite(ar) || ar <= 0) return + if (aspectByKey.value[key] === ar) return + aspectByKey.value = { + ...aspectByKey.value, + [key]: ar + } +} + +const rowHeightsByKey = computed>(() => { + const docs = props.documents + const cols = Math.max(1, columnCount.value) + const byKey = aspectByKey.value + const out: Record = {} + + const assignRows = (group: Doc[]) => { + for (let start = 0; start < group.length; start += cols) { + const row = group.slice(start, start + cols) + const ratios = row + .filter(doc => doc.previewable) + .map(doc => byKey[doc.key]) + .filter((ar): ar is number => ar != null) + const height = `${optimalRowHeightPx(ratios)}px` + for (const doc of row) out[doc.key] = height + } + } + + let group: Doc[] = [] + for (let i = 0; i < docs.length; i++) { + if (i > 0 && docs[i]!.loc !== docs[i - 1]!.loc) { + assignRows(group) + group = [] + } + group.push(docs[i]!) + } + assignRows(group) + + return out +}) + +// Seed collected ratios from server-provided ar values on docs +const seedFromDocs = () => { + for (const doc of props.documents) + if (doc.previewable && doc.ar != null) setAspect(doc.key, doc.ar) +} + +const onImgLoad = (e: Event) => { + const img = e.target as HTMLImageElement + if (img.tagName !== 'IMG' || img.naturalWidth === 0) return + const anchor = img.closest('a[id^="file-"]') as HTMLAnchorElement | null + if (!anchor) return + const key = anchor.id.slice('file-'.length) + if (!key) return + setAspect(key, img.naturalHeight / img.naturalWidth) +} const updateColumns = () => { if (!gallery.value) return - columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split( - ' ' - ).length + const style = getComputedStyle(gallery.value) + const templates = style.gridTemplateColumns + .split(' ') + .filter(part => !!part && part !== 'none') + columnCount.value = Math.max(1, templates.length) + const first = templates[0] + if (first && first.endsWith('px')) { + const parsed = Number.parseFloat(first) + if (Number.isFinite(parsed) && parsed > 0) columnWidthPx.value = parsed + } + const parsedEm = Number.parseFloat(style.fontSize) + if (Number.isFinite(parsedEm) && parsedEm > 0) emPx.value = parsedEm } const columns = computed(() => columnCount.value) defineExpose({ @@ -230,14 +334,20 @@ onMounted(() => { active.focus() } updateColumns() + seedFromDocs() if (gallery.value) { resizeObserver = new ResizeObserver(updateColumns) resizeObserver.observe(gallery.value) + gallery.value.addEventListener('load', onImgLoad, { capture: true }) } }) onUnmounted(() => { resizeObserver?.disconnect() + gallery.value?.removeEventListener('load', onImgLoad, { capture: true }) }) + +// Re-seed aspect ratios whenever docs update (e.g., ar patch from server) +watch(() => props.documents, seedFromDocs) const mkdir = async (doc: Doc, name: string) => { doc.name = name doc.key = crypto.randomUUID() diff --git a/frontend/src/components/GalleryFigure.vue b/frontend/src/components/GalleryFigure.vue index 1238487..b81b500 100644 --- a/frontend/src/components/GalleryFigure.vue +++ b/frontend/src/components/GalleryFigure.vue @@ -82,8 +82,8 @@ const onclick = (ev: Event) => { margin-left: 0.3em; } figure { - height: 15em; - max-height: 15em; + height: var(--gallery-figure-height, 15em); + max-height: var(--gallery-figure-height, 15em); position: relative; border-radius: .5em; overflow: hidden; @@ -93,12 +93,13 @@ figure { align-items: center; justify-content: center; overflow: hidden; + transition: height 0.4s ease, max-height 0.4s ease; } figure > article { flex: 0 0 auto; } figure :deep(.video-container) { - height: 15em; + height: var(--gallery-figure-height, 15em); } .titlespacer { flex-shrink: 100000; diff --git a/frontend/src/repositories/Document.ts b/frontend/src/repositories/Document.ts index 8aa8e56..6c474f0 100644 --- a/frontend/src/repositories/Document.ts +++ b/frontend/src/repositories/Document.ts @@ -13,6 +13,7 @@ export type DocProps = { dir: boolean ghost?: boolean expires?: number // Unix timestamp for ghost expiry + ar?: number // Aspect ratio (height/width) from server, if known } export class Doc { @@ -26,6 +27,7 @@ export class Doc { public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry) /** @internal Use the name getter/setter instead */ public _name: string = '' + public ar?: number // Aspect ratio (height/width), provided by server after first preview render constructor(props: Partial = {}) { const { name, ...rest } = props @@ -130,7 +132,8 @@ export type FileEntry = [ number, // mtime number, // size number, // allocated (actual disk usage) - number // isfile + number, // isfile + number? // ar: aspect ratio (height/width), present if known ] export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array] diff --git a/frontend/src/repositories/WS.ts b/frontend/src/repositories/WS.ts index b0695bd..6326a79 100644 --- a/frontend/src/repositories/WS.ts +++ b/frontend/src/repositories/WS.ts @@ -164,6 +164,11 @@ const handleWatchMessage = (event: MessageEvent) => { case !!msg.update: handleUpdateMessage(msg) break + case !!msg.ar: { + const store = useMainStore() + store.updateAr(msg.ar as Record) + break + } case !!msg.space: const store = useMainStore() store.space = msg.space diff --git a/frontend/src/stores/main.ts b/frontend/src/stores/main.ts index 86c3372..10848b4 100644 --- a/frontend/src/stores/main.ts +++ b/frontend/src/stores/main.ts @@ -5,7 +5,7 @@ import { collator } from '@/utils' import { type SortOrder, sorted } from '@/utils/docsort' import SearchWorker from '@/workers/searchWorker?worker' import { type StateTree, defineStore } from 'pinia' -import { documentRef, getDocuments, setDocuments } from './documentStore' +import { documentRef, getDocuments, setDocuments, triggerUpdate } from './documentStore' // Singleton search worker instance let searchWorker: Worker | null = null @@ -124,7 +124,7 @@ export const useMainStore = defineStore('main', { updateRoot(root: FileEntry[]) { const docs = [] let loc = [] as string[] - for (const [level, name, key, mtime, size, allocated, isfile] of root) { + for (const [level, name, key, mtime, size, allocated, isfile, ar] of root) { loc = loc.slice(0, level - 1) docs.push( new Doc({ @@ -134,7 +134,8 @@ export const useMainStore = defineStore('main', { size, allocated, mtime, - dir: !isfile + dir: !isfile, + ar }) ) loc.push(name) @@ -157,6 +158,22 @@ export const useMainStore = defineStore('main', { // Sync documents to search worker this.syncSearchWorker() }, + /** Patch aspect ratios on existing docs from a server ar update message */ + updateAr(arMap: Record) { + const docs = getDocuments() + let changed = false + for (const doc of docs) { + const ar = arMap[doc.key] + if (ar != null && doc.ar !== ar) { + doc.ar = ar + changed = true + } + } + if (changed) { + triggerUpdate() + this.docVersion++ + } + }, /** Add a ghost file/folder for optimistic UI updates */ addGhost(doc: Doc) { doc.ghost = true