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