Lightbox: Add 16K thumbnails and refine 360° viewer routing #5669 #5623

This commit is contained in:
Michael Mayer 2026-06-17 04:12:41 +00:00
parent 4ab00fa32d
commit d52554fc50
5 changed files with 35 additions and 13 deletions

View file

@ -31,15 +31,21 @@ const EQUIRECTANGULAR_RATIO_TOLERANCE = 0.15;
// is360Equirectangular reports whether a slide/thumb model is equirectangular 360°
// content that should open in the sphere viewer. An explicit "equirectangular"
// projection is authoritative and any other non-empty projection (cubemap, …) is
// never 360°. Many 360° videos carry no projection tag PhotoPrism can read, so a
// panorama-flagged video with no projection falls back to equirectangular's defining
// 2:1 frame — this keeps genuine spherical videos opening while cubemap (4:3, 6:1)
// and ultrawide (~2.35:1) clips stay in the flat player.
// projection is authoritative, but a full sphere is ~2:1: when the frame size is known
// and clearly not 2:1 it stays in the flat viewer, since a partial/cylindrical panorama
// tagged equirectangular (or promoted from GPano markers) would otherwise render as a
// vertically distorted sphere. Any other non-empty projection (cubemap, …) is never 360°.
// Many 360° videos carry no projection tag PhotoPrism can read, so a panorama-flagged
// video with no projection falls back to the same 2:1 frame test — this keeps genuine
// spherical videos opening while cubemap (4:3, 6:1) and ultrawide (~2.35:1) clips stay flat.
export function is360Equirectangular(model) {
const projection = (model?.Projection || "").toLowerCase();
const w = Number(model?.Width);
const h = Number(model?.Height);
const known = w > 0 && h > 0;
const is2to1 = known && Math.abs(w / h - 2) <= EQUIRECTANGULAR_RATIO_TOLERANCE;
if (projection === "equirectangular") {
return true;
return !known || is2to1;
}
if (projection) {
return false;
@ -48,9 +54,7 @@ export function is360Equirectangular(model) {
if (!isVideo || model?.Panorama !== true) {
return false;
}
const w = Number(model?.Width);
const h = Number(model?.Height);
return w > 0 && h > 0 && Math.abs(w / h - 2) <= EQUIRECTANGULAR_RATIO_TOLERANCE;
return is2to1;
}
// createSphereViewer mounts a Photo Sphere Viewer instance for an equirectangular photo or video.

View file

@ -672,7 +672,9 @@ export default {
const isEquirect = is360Equirectangular(model);
if (isEquirect && model?.Hash) {
const isVideo = model?.Type === media.Video || model?.Type === media.Animated;
const src = isVideo ? this.$util.videoUrl(model.Hash, model?.Codec, model?.Mime) : this.$util.thumb(model.Thumbs, 8192, 4096).src;
// Request the largest available size so 360° photos stay crisp when zoomed; $util.thumb
// clamps to the biggest size the server advertises (fit_15360 when the on-demand limit allows).
const src = isVideo ? this.$util.videoUrl(model.Hash, model?.Codec, model?.Mime) : this.$util.thumb(model.Thumbs, 15360, 8640).src;
return {
type: "html",
html: `<div class="pswp__html"></div>`,

View file

@ -234,7 +234,7 @@
<v-list-subheader class="pa-0">
{{ $gettextInterpolate($gettext("Static Size Limit: %{n}px"), { n: parseInt(settings.ThumbSize) }) }}
</v-list-subheader>
<v-slider v-model="settings.ThumbSize" :min="720" :max="7680" :step="4" :disabled="isDemo" hide-details class="ma-0" @end="onChange"></v-slider>
<v-slider v-model="settings.ThumbSize" :min="720" :max="15360" :step="4" :disabled="isDemo" hide-details class="ma-0" @end="onChange"></v-slider>
</v-col>
<v-col cols="12" sm="6" lg="4" class="py-2">
@ -248,7 +248,7 @@
<v-slider
v-model="settings.ThumbSizeUncached"
:min="720"
:max="7680"
:max="15360"
:step="4"
:disabled="isDemo"
hide-details

View file

@ -27,10 +27,18 @@ vi.mock("@photo-sphere-viewer/video-plugin/index.css", () => ({}));
import { createSphereViewer, destroySphereViewer, is360Equirectangular } from "common/sphere";
describe("common/sphere is360Equirectangular", () => {
it("is true for an explicit equirectangular projection", () => {
it("is true for an explicit equirectangular projection without known dimensions", () => {
expect(is360Equirectangular({ Type: "image", Projection: "equirectangular" })).toBe(true);
expect(is360Equirectangular({ Type: "video", Projection: "equirectangular" })).toBe(true);
});
it("is true for an explicit equirectangular projection with ~2:1 dimensions", () => {
expect(is360Equirectangular({ Type: "image", Projection: "equirectangular", Width: 6656, Height: 3328 })).toBe(true);
expect(is360Equirectangular({ Type: "image", Projection: "equirectangular", Width: 15520, Height: 7760 })).toBe(true);
});
it("is false for an equirectangular-tagged frame that is clearly not 2:1 (partial/cylindrical panorama)", () => {
expect(is360Equirectangular({ Type: "image", Projection: "equirectangular", Width: 8192, Height: 1024 })).toBe(false);
expect(is360Equirectangular({ Type: "video", Projection: "equirectangular", Width: 8192, Height: 1024 })).toBe(false);
});
it("is false for any other non-empty projection", () => {
expect(is360Equirectangular({ Type: "video", Projection: "cubemap", Panorama: true, Width: 3840, Height: 1920 })).toBe(false);
expect(is360Equirectangular({ Type: "image", Projection: "cubestrip" })).toBe(false);

View file

@ -1284,6 +1284,14 @@ describe("PLightbox (low-mock, jsdom-friendly)", () => {
expect(slide.isSphere).toBeUndefined();
expect(slide.src).toBeTruthy();
});
it("does NOT mount the sphere for an equirectangular-tagged photo that is not 2:1 (partial panorama)", () => {
const wrapper = mountLightbox();
const model = { Hash: "strip", Projection: "equirectangular", Type: "image", Width: 8192, Height: 1024, Thumbs: baseThumbs, Title: "Wide" };
const ctx = { ...wrapper.vm, models: [model], $util, getSlidePixels: () => ({ width: 2048, height: 1024 }) };
const slide = wrapper.vm.$options.methods.getItemData.call(ctx, null, 0);
expect(slide.isSphere).toBeUndefined();
});
it("does NOT mount the sphere for a cubemap video even when Panorama is flagged", () => {
const wrapper = mountLightbox();
const model = { Hash: "cube", Projection: "cubemap", Panorama: true, Type: "video", Thumbs: baseThumbs, Codec: "avc1", Mime: "video/mp4", Playable: true };