Lightbox: Add support for 360° photos and videos #352 #4718 #5623

* Lightbox: Add 360° photo and video sphere viewer support

Wires Photo Sphere Viewer v5 into the lightbox for equirectangular
media. Backend detects UsePanoramaViewer and IsPhotosphere markers
alongside ProjectionType, and exposes Panorama + Projection in the
viewer / search result DTOs so the frontend can dispatch on the
projection type. The renderer (ThreeJS + PSV core + video plugin +
equirectangular video adapter) is lazy-loaded into a dedicated
webpack chunk so the base bundle is unchanged for users with no
360° media. PhotoPrism's existing lightbox controls drive the
underlying HTMLVideoElement that the PSV adapter creates for 360°
videos. Includes Vitest coverage for the sphere helper and the
lightbox dispatch, plus a TestCafe smoke spec and three new
ExifTool metadata fixtures.

* Lightbox: Fix 360° controls leak, hide zoom button, add sidebar icon

* Lightbox: Fix 360° touch panning and navigation arrows

* Lightbox: Fix fast swipe switching photos on 360° slides

Suppress PhotoSwipe swipe/drag navigation at its source via the dispatched pointer hook while a sphere slide is active, since the per-element gesture trap is outrun by a fast swipe whose pointer leaves the sphere container (touch-capable Windows). UI controls stay excluded so buttons and arrows still navigate.

* Lightbox: Route only equirectangular media to the 360° viewer

Routing was based on the loose photo_panorama flag for videos, which is also
true for cubemap and merely-wide (aspect > 1.9) videos, so non-equirectangular
media wrongly opened, distorted, in the sphere viewer. Route strictly on the
equirectangular projection for both photos and videos. Surface the video file's
projection in the viewer DTO via Photo.MediaProjection() (the primary search row
is a poster JPEG with no projection). Add an MP4 metadata-detection test.

* Lightbox: Open tagless 2:1 360° videos in the sphere viewer

Many 360° videos carry no projection tag PhotoPrism can read, so strict
equirectangular-projection routing left them in the flat player. Fall back to
equirectangular's defining 2:1 frame for panorama-flagged videos without a
projection, via is360Equirectangular() in common/sphere.js, while cubemap and
ultrawide (~2.35:1) clips stay flat.

* Tests: Make the 360° sphere video test assert instead of skipping
This commit is contained in:
Ömer Duran 2026-06-17 04:26:25 +03:00 committed by GitHub
parent bf081bd68b
commit 3ea547a564
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1354 additions and 62 deletions

244
NOTICE
View file

@ -6138,8 +6138,8 @@ License: Apache-2.0 (https://github.com/prometheus/client_model/blob/v0.6.2/LICE
--------------------------------------------------------------------------------
Package: github.com/prometheus/common
Version: v0.68.1
License: Apache-2.0 (https://github.com/prometheus/common/blob/v0.68.1/LICENSE)
Version: v0.67.5
License: Apache-2.0 (https://github.com/prometheus/common/blob/v0.67.5/LICENSE)
Apache License
Version 2.0, January 2004
@ -7217,8 +7217,8 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
Package: github.com/yalue/onnxruntime_go
Version: v1.31.0
License: MIT (https://github.com/yalue/onnxruntime_go/blob/v1.31.0/LICENSE)
Version: v1.30.1
License: MIT (https://github.com/yalue/onnxruntime_go/blob/v1.30.1/LICENSE)
Copyright (c) 2023 Nathan Otterness
@ -8851,6 +8851,214 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: go.yaml.in/yaml/v2
Version: v2.4.4
License: Apache-2.0 (https://github.com/yaml/go-yaml/blob/v2.4.4/LICENSE)
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
--------------------------------------------------------------------------------
Package: go4.org/media/heif
Version: v0.0.0-20260112195520-a5071408f32f
License: Apache-2.0 (https://github.com/go4org/go4/blob/a5071408f32f/LICENSE)
@ -9061,8 +9269,8 @@ License: Apache-2.0 (https://github.com/go4org/go4/blob/a5071408f32f/LICENSE)
--------------------------------------------------------------------------------
Package: golang.org/x/crypto
Version: v0.53.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/crypto/+/v0.53.0:LICENSE)
Version: v0.52.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/crypto/+/v0.52.0:LICENSE)
Copyright 2009 The Go Authors.
@ -9095,8 +9303,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/image
Version: v0.42.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/image/+/v0.42.0:LICENSE)
Version: v0.41.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/image/+/v0.41.0:LICENSE)
Copyright 2009 The Go Authors.
@ -9129,8 +9337,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/mod/semver
Version: v0.37.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/mod/+/v0.37.0:LICENSE)
Version: v0.36.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/mod/+/v0.36.0:LICENSE)
Copyright 2009 The Go Authors.
@ -9163,8 +9371,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/net
Version: v0.56.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/net/+/v0.56.0:LICENSE)
Version: v0.55.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)
Copyright 2009 The Go Authors.
@ -9231,8 +9439,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/sync/errgroup
Version: v0.21.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/sync/+/v0.21.0:LICENSE)
Version: v0.20.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)
Copyright 2009 The Go Authors.
@ -9265,8 +9473,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/sys
Version: v0.46.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/sys/+/v0.46.0:LICENSE)
Version: v0.45.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/sys/+/v0.45.0:LICENSE)
Copyright 2009 The Go Authors.
@ -9299,8 +9507,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
--------------------------------------------------------------------------------
Package: golang.org/x/text
Version: v0.38.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/text/+/v0.38.0:LICENSE)
Version: v0.37.0
License: BSD-3-Clause (https://cs.opensource.google/go/x/text/+/v0.37.0:LICENSE)
Copyright 2009 The Go Authors.

View file

@ -18,7 +18,7 @@ Directory Map (src)
- `src/app.js` — app bootstrap: creates Vue app, installs Vuetify + plugins, configures router, mounts to `#app`
- `src/app/routes.js` — all route definitions (guards, titles, meta)
- `src/app/session.js``$config` and `$session` singletons wired from server-provided `window.__CONFIG__` and storage
- `src/common/*` — framework-agnostic helpers: `$api` (Axios), `$notify`, `$view`, `$event` (PubSub), i18n (`gettext`), util, fullscreen, map utils, websocket
- `src/common/*` — framework-agnostic helpers: `$api` (Axios), `$notify`, `$view`, `$event` (PubSub), i18n (`gettext`), util, fullscreen, map utils, websocket, `sphere.js` (lazy-loaded 360° viewer wrapper)
- `src/component/*` — Vue components; `src/component/components.js` registers global components
- `src/page/*` — route views (Albums, Photos, Places, Settings, Admin, Discover, Help, Login, etc.)
- `src/model/*` — REST models; base `Rest` class (`model/rest.js`) wraps Axios CRUD for collections and entities

View file

@ -87,6 +87,9 @@
"minimist": "^1.2.8",
"node-storage-shim": "^2.0.1",
"passive-events-support": "^1.1.0",
"@photo-sphere-viewer/core": "^5.14.1",
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.14.1",
"@photo-sphere-viewer/video-plugin": "^5.14.1",
"photoswipe": "^5.4.4",
"playwright": "^1.61.0",
"postcss": "^8.5.15",

View file

@ -0,0 +1,110 @@
/*
Copyright (c) 2018 - 2026 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
import * as media from "common/media";
// EQUIRECTANGULAR_RATIO_TOLERANCE bounds how far a video frame may deviate from the
// 2:1 equirectangular aspect ratio and still be treated as 360° media.
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.
export function is360Equirectangular(model) {
const projection = (model?.Projection || "").toLowerCase();
if (projection === "equirectangular") {
return true;
}
if (projection) {
return false;
}
const isVideo = model?.Type === media.Video || model?.Type === media.Animated;
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;
}
// createSphereViewer mounts a Photo Sphere Viewer instance for an equirectangular photo or video.
// The renderer (and its ThreeJS dependency) is dynamic-imported on first call so the base bundle
// is unaffected when no 360° media is opened.
export async function createSphereViewer(container, src, opts = {}) {
const [coreMod, videoMod, videoAdapterMod] = await Promise.all([
import(/* webpackChunkName: "sphere-viewer" */ "@photo-sphere-viewer/core"),
opts.isVideo
? import(/* webpackChunkName: "sphere-viewer" */ "@photo-sphere-viewer/video-plugin")
: Promise.resolve(null),
opts.isVideo
? import(/* webpackChunkName: "sphere-viewer" */ "@photo-sphere-viewer/equirectangular-video-adapter")
: Promise.resolve(null),
]);
await import(/* webpackChunkName: "sphere-viewer" */ "@photo-sphere-viewer/core/index.css");
if (opts.isVideo) {
await import(/* webpackChunkName: "sphere-viewer" */ "@photo-sphere-viewer/video-plugin/index.css");
return new coreMod.Viewer({
container,
adapter: [videoAdapterMod.EquirectangularVideoAdapter, { muted: !!opts.muted, autoplay: false }],
panorama: { source: src },
plugins: [videoMod.VideoPlugin],
navbar: false,
keyboard: "always",
defaultYaw: 0,
defaultPitch: 0,
});
}
return new coreMod.Viewer({
container,
panorama: src,
navbar: false,
keyboard: "always",
defaultYaw: 0,
defaultPitch: 0,
});
}
// findSphereVideoElement returns the underlying HTMLVideoElement that PSV's
// EquirectangularVideoAdapter uses as a WebGL texture source. The adapter does
// not insert the element into the DOM, so we read it off the adapter instance
// the viewer exposes after the panorama has finished loading.
export function findSphereVideoElement(viewer) {
return viewer?.adapter?.video || null;
}
// destroySphereViewer tears down a viewer instance, releasing its WebGL context and textures.
// Safe to call on null or an already-destroyed viewer.
export function destroySphereViewer(viewer) {
if (viewer && typeof viewer.destroy === "function") {
viewer.destroy();
}
}

View file

@ -128,6 +128,7 @@ import * as media from "common/media";
import { getAppSessionStorage, getAppStorage } from "common/storage";
import * as contexts from "options/contexts";
import { $faceMarkers } from "common/face-markers";
import { createSphereViewer, destroySphereViewer, findSphereVideoElement, is360Equirectangular } from "common/sphere";
const VIDEO_EVENT_TYPES = [
"loadstart",
@ -150,6 +151,10 @@ const VIDEO_EVENT_TYPES = [
const VIDEO_REMOTE_EVENT_TYPES = ["connect", "connecting", "disconnect"];
// Max pointer travel (px) for a press on a 360° sphere to still count as a tap
// rather than a pan, used to toggle the lightbox controls on touch devices.
const SPHERE_TAP_SLOP = 10;
import PLightboxMenu from "component/lightbox/menu.vue";
import PLightboxSidebar from "component/lightbox/sidebar.vue";
import { Marker } from "model/marker";
@ -659,6 +664,27 @@ export default {
loading: false,
};
// Route equirectangular 360° media to the lazy-loaded sphere viewer before the video branch,
// since panoramic videos are also Playable and would otherwise fall through. is360Equirectangular
// routes on the equirectangular projection when present, and falls back to the 2:1 frame for
// panorama-flagged videos that carry no readable projection tag cubemap and ultrawide clips
// stay flat. See common/sphere.js for the discriminator.
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;
return {
type: "html",
html: `<div class="pswp__html"></div>`,
model: model,
isSphere: true,
isVideo: isVideo,
src: src,
msrc: img.src,
loading: true,
};
}
// Check if content is playable and return the data needed to render it in "contentLoad".
if (model?.Playable && model?.Hash) {
/*
@ -694,14 +720,157 @@ export default {
return img;
},
isContentZoomable(isContentZoomable, content) {
if (content.data?.isSphere) {
return false;
}
if (content.data?.model?.Type === media.Live) {
isContentZoomable = true;
}
return isContentZoomable;
},
// slideZoomable reports whether a slide supports the flat zoom button and
// pinch/click-to-zoom. 360° sphere slides own their zoom/pan, and videos and
// animations are never flatly zoomable; the result drives the `.is-zoomable`
// class that shows or hides the PhotoSwipe zoom button.
slideZoomable(data) {
if (data?.isSphere) {
return false;
}
return data?.model?.Type !== media.Video && data?.model?.Type !== media.Animated;
},
// trapSphereGestures stops PhotoSwipe's swipe/zoom gesture detection from running
// while the user interacts with a 360° sphere. It swallows pointer + wheel events
// on the sphere container during the bubble phase, before they reach PhotoSwipe's
// listeners on the scroll wrapper above it.
//
// Touch events are deliberately NOT trapped: PhotoSwipe binds pointer events on our
// browser baseline (it only falls back to touch when PointerEvent is unavailable),
// while Photo Sphere Viewer drives panning from touchmove/touchend listeners bound on
// `window`. A bubble-phase stopPropagation here would keep those events from reaching
// PSV and break 360° panning on touch devices. UI buttons (close, prev/next, sidebar)
// live on the PhotoSwipe wrapper outside this container and stay clickable.
//
// Trapping the pointer events also swallows PhotoSwipe's tap detection, which is what
// reveals the prev/next arrows and top bar on touch devices. To restore that, a short
// press without a pan toggles the controls here. Only touch/pen taps do so mouse
// users still get the controls via mousemove, so desktop behavior is unchanged.
trapSphereGestures(el) {
const stop = (e) => e.stopPropagation();
let tapX = 0;
let tapY = 0;
let tapping = false;
el.addEventListener(
"pointerdown",
(e) => {
e.stopPropagation();
tapping = e.pointerType !== "mouse";
tapX = e.clientX;
tapY = e.clientY;
},
{ capture: false }
);
el.addEventListener(
"pointerup",
(e) => {
e.stopPropagation();
if (tapping && Math.abs(e.clientX - tapX) < SPHERE_TAP_SLOP && Math.abs(e.clientY - tapY) < SPHERE_TAP_SLOP) {
this.toggleControls();
}
tapping = false;
},
{ capture: false }
);
el.addEventListener(
"pointercancel",
(e) => {
e.stopPropagation();
tapping = false;
},
{ capture: false }
);
["pointermove", "wheel"].forEach((type) => {
el.addEventListener(type, stop, { capture: false });
});
},
// setSphereClass toggles the `pswp--sphere` marker on the PhotoSwipe root element so
// CSS can keep the prev/next arrows reachable on touch devices for 360° slides, where
// swipe is captured for panning instead of navigation. PhotoSwipe hides the arrows on
// touch by default, leaving such slides with no way to switch photos otherwise.
setSphereClass(enabled) {
const el = this.pswp()?.element;
if (el?.classList) {
el.classList.toggle("pswp--sphere", enabled);
}
},
onContentLoad(ev) {
const { content } = ev;
if (content.data?.isSphere) {
ev.preventDefault();
try {
const sphereEl = document.createElement("div");
sphereEl.setAttribute("class", "pswp__media pswp__media--sphere");
sphereEl.style.width = "100%";
sphereEl.style.height = "100%";
sphereEl.style.touchAction = "none";
this.trapSphereGestures(sphereEl);
content.element = sphereEl;
content.state = "loading";
content.data.loading = false;
content.onLoaded();
createSphereViewer(sphereEl, content.data.src, { isVideo: content.data.isVideo, muted: this.muted })
.then((viewer) => {
content.data.sphereViewer = viewer;
// For 360° videos, expose the underlying <video> element so the
// existing PhotoPrism lightbox video controls (play/pause/seek/cast)
// operate on it. Without this the slide has no HTMLMediaElement to
// drive the PSV navbar is intentionally disabled.
if (content.data.isVideo) {
// The adapter creates the <video> element but never inserts it
// into the DOM (it's only a WebGL texture source). The shared
// setVideo() reaches for `video.parentElement.classList`, so we
// attach the element to a hidden host inside the slide before
// wiring it up. PSV keeps using the same reference for texture
// sampling either way.
const attempts = 30;
let tries = 0;
const bindWhenReady = () => {
const videoEl = findSphereVideoElement(viewer);
if (videoEl) {
if (!videoEl.parentElement) {
const host = document.createElement("div");
host.className = "pswp__sphere-video-host";
host.style.position = "absolute";
host.style.width = "1px";
host.style.height = "1px";
host.style.overflow = "hidden";
host.style.opacity = "0";
host.style.pointerEvents = "none";
host.appendChild(videoEl);
sphereEl.appendChild(host);
}
content.data.sphereVideoEl = videoEl;
this.bindSphereVideoControls(content, videoEl);
} else if (++tries < attempts) {
setTimeout(bindWhenReady, 100);
}
};
bindWhenReady();
}
})
.catch((err) => {
this.log("failed to load sphere viewer", err);
});
} catch (err) {
this.log("failed to mount sphere", err);
}
return;
}
if (content.data?.type === "html") {
// Prevent default loading behavior.
ev.preventDefault();
@ -738,6 +907,11 @@ export default {
}
},
onContentDestroy(ev) {
if (ev?.content?.data?.sphereViewer) {
destroySphereViewer(ev.content.data.sphereViewer);
ev.content.data.sphereViewer = null;
}
if (typeof ev?.content?.data?.events === "object") {
const data = ev.content.data;
@ -1208,19 +1382,17 @@ export default {
return;
}
switch (data.model?.Type) {
case media.Video:
case media.Animated:
this.isZoomable = false;
break;
default:
this.isZoomable = true;
}
this.isZoomable = this.slideZoomable(data);
this.setSphereClass(data?.isSphere === true);
let video;
// Get <video> element, if any.
if (content?.element && content?.element.firstElementChild instanceof HTMLMediaElement) {
// Get <video> element, if any. For 360° video slides the HTMLMediaElement is
// owned by Photo Sphere Viewer and cached on the slide data rather than being
// the slide element's first child.
if (content?.data?.sphereVideoEl instanceof HTMLMediaElement) {
video = content.data.sphereVideoEl;
} else if (content?.element && content?.element.firstElementChild instanceof HTMLMediaElement) {
video = content.element.firstElementChild;
} else {
video = false;
@ -1232,7 +1404,7 @@ export default {
// a slideshow is active, or it's an animation or live photo.
if (video) {
if (data.loop || this.slideshow.active || firstPicture) {
this.playVideo(content.element.firstElementChild, data.loop);
this.playVideo(video, data.loop);
}
}
@ -1247,8 +1419,11 @@ export default {
this.lightbox.on("contentDeactivate", (ev) => {
const { content } = ev;
// Stop any video currently playing on this slide.
if (content?.element && content?.element.firstElementChild instanceof HTMLMediaElement) {
// Stop any video currently playing on this slide. For 360° video slides the
// element is owned by Photo Sphere Viewer and cached on the slide data.
if (content?.data?.sphereVideoEl instanceof HTMLMediaElement) {
this.pauseVideo(content.data.sphereVideoEl);
} else if (content?.element && content?.element.firstElementChild instanceof HTMLMediaElement) {
this.pauseVideo(content.element.firstElementChild);
}
});
@ -2102,6 +2277,12 @@ export default {
return false;
},
// activeSlideIsSphere reports whether the slide currently shown in PhotoSwipe is a
// 360° sphere, used to suppress swipe-to-navigate so a horizontal drag pans the
// sphere instead of switching photos.
activeSlideIsSphere() {
return this.pswp()?.currSlide?.content?.data?.isSphere === true;
},
// Called when the lightbox receives a pointer down or up event.
// Move events are ignored for now.
onLightboxPointerEvent(ev, action) {
@ -2125,6 +2306,19 @@ export default {
return;
}
// Suppress PhotoSwipe's swipe/drag navigation while a 360° sphere slide is active so
// the drag pans the sphere instead of switching photos. The per-element gesture trap
// (trapSphereGestures) relies on bubble-phase stopPropagation and is outrun by a fast
// swipe whose pointer leaves the sphere container before reaching PhotoSwipe's
// window-level pointer listeners seen on touch-capable Windows. Marking the dispatched
// pointer event as default-prevented makes PhotoSwipe's gesture handler bail out at its
// source, independent of DOM propagation. UI controls navigate via their own click
// handlers and are excluded so buttons and the prev/next arrows stay usable.
if (!pswpControl && this.activeSlideIsSphere()) {
ev.preventDefault();
return;
}
if (target.closest(".pswp__dynamic-caption")) {
ev.preventDefault();
}
@ -2321,13 +2515,51 @@ export default {
result.data = typeof result.content.data === "object" ? result.content.data : {};
// Get <video> element, if any.
if (result.content.element && result.content.element.firstElementChild instanceof HTMLMediaElement) {
// Get <video> element, if any. For 360° video slides, the HTMLMediaElement
// is owned by Photo Sphere Viewer and cached on the slide data.
if (result.data.sphereVideoEl instanceof HTMLMediaElement) {
result.video = result.data.sphereVideoEl;
} else if (result.content.element && result.content.element.firstElementChild instanceof HTMLMediaElement) {
result.video = result.content.element.firstElementChild;
}
return result;
},
// bindSphereVideoControls wires the PhotoPrism lightbox video controls
// (play/pause/seek/duration/cast) to the HTMLMediaElement owned by Photo
// Sphere Viewer. Mirrors the event wiring that createVideoElement performs
// for the flat video path so that the existing reactive `video` state and
// the <p-lightbox__controls> template work without changes.
bindSphereVideoControls(content, videoEl) {
const data = content.data;
const ctrl = new AbortController();
data.events?.abort();
data.events = ctrl;
VIDEO_EVENT_TYPES.forEach((type) => {
videoEl.addEventListener(type, this.videoEventListener, { signal: ctrl.signal });
});
// Only push playback state into the shared reactive `video` singleton when this
// slide is the active one. PhotoSwipe preloads neighbors (preload: [1, 1]) and
// sphere binding resolves asynchronously, so a 360° video loaded next to the
// current photo would otherwise flip the controls bar on for that photo. When
// this slide is activated later, contentActivate re-resolves the cached element
// (data.sphereVideoEl) and sets the playback state then.
if (this.pswp()?.currSlide?.content !== content) {
return;
}
this.video.controls = true;
this.video.error = "";
this.video.errorCode = 0;
this.video.duration = Number.isFinite(videoEl.duration) ? videoEl.duration : 0;
this.video.time = videoEl.currentTime || 0;
this.video.seekable = videoEl.seekable && videoEl.seekable.length > 0;
this.video.playing = !videoEl.paused;
this.video.paused = videoEl.paused;
this.video.ended = videoEl.ended;
},
// Stops playback on the specified video element, if any.
pauseVideo(video) {
if (!video || !(video instanceof HTMLMediaElement)) {

View file

@ -481,6 +481,7 @@ import * as formats from "options/formats";
import { $faceMarkers } from "common/face-markers";
import * as media from "common/media";
import { is360Equirectangular } from "common/sphere";
import typeaheadCache from "common/typeahead-cache";
import { rules } from "common/form";
import { Album, MaxLength as AlbumMaxLength } from "model/album";
@ -1051,7 +1052,17 @@ export default {
}
return this.model?.Type || "";
},
// mediaIs360 reports whether the active media is equirectangular 360° content,
// reusing the lightbox slide-routing discriminator (is360Equirectangular) so the
// file icon matches what opens in the sphere viewer. Checks both the thumb model
// and the photo since either may carry the projection / dimensions.
mediaIs360() {
return is360Equirectangular(this.model) || is360Equirectangular(this.photo);
},
fileIcon() {
if (this.mediaIs360) {
return "mdi-panorama-variant-outline";
}
switch (this.mediaType) {
case media.Raw:
return "mdi-raw";

View file

@ -904,3 +904,14 @@
.p-lightbox__container > .p-lightbox__content > .pswp--touch .pswp__button--arrow .pswp__icn {
display: none;
}
/* On 360° sphere slides, swipe is captured for panning the sphere instead of switching
photos, so the navigation arrows must stay visible on touch devices where PhotoSwipe
hides them with `visibility: hidden` because a swipe normally navigates. The
`pswp--sphere` marker is set on the PhotoSwipe root only while a 360° slide is active.
Only `visibility` is overridden (not `display`) so disabled arrows at the first/last
photo stay hidden via PhotoSwipe's `:disabled { display: none }` rule. */
.pswp--touch.pswp--sphere .pswp__button--arrow {
visibility: visible;
}

View file

@ -56,6 +56,7 @@ export class Photo extends RestModel {
Private: false,
Scan: false,
Panorama: false,
Projection: "",
Portrait: false,
TakenAt: "",
TakenAtLocal: "",

View file

@ -28,6 +28,8 @@ export class Thumb extends Model {
TimeZone: "",
Favorite: false,
Playable: false,
Panorama: false,
Projection: "",
Duration: 0,
Width: 0,
Height: 0,
@ -348,6 +350,8 @@ export class Thumb extends Model {
TimeZone: photo.TimeZone,
Favorite: photo.Favorite,
Playable: photo.isPlayable(),
Panorama: !!photo.Panorama,
Projection: photo.Projection || (file ? file.Projection : "") || "",
Duration: photo.Duration,
Width: width,
Height: height,
@ -390,6 +394,8 @@ export class Thumb extends Model {
TimeZone: photo.TimeZone,
Favorite: photo.Favorite,
Playable: photo.isPlayable(),
Panorama: !!photo.Panorama,
Projection: file.Projection || photo.Projection || "",
Duration: photo.Duration,
Width: file.Width,
Height: file.Height,

View file

@ -0,0 +1,45 @@
import { Selector } from "testcafe";
import testcafeconfig from "../../testcafeconfig.json";
import Menu from "../page-model/menu";
import Photo from "../page-model/photo";
import PhotoViewer from "../page-model/photoviewer";
fixture`Test 360° sphere viewer`.page`${testcafeconfig.url}`;
const menu = new Menu();
const photo = new Photo();
const photoviewer = new PhotoViewer();
// Smoke tests for the equirectangular 360° viewer pipeline.
// Live runs require the acceptance fixture set to include
// `panorama_sphere.jpg` (and ideally `panorama_sphere.mp4`)
// with ProjectionType=equirectangular metadata.
test.meta("testID", "sphere-001").meta({ mode: "public" })("Common: Opens 360° photo in sphere viewer", async (t) => {
await menu.openPage("panoramas");
const uid = await photo.getNthPhotoUid("image", 0);
await photoviewer.openPhotoViewer("uid", uid);
await t.expect(Selector("div.pswp__media--sphere").exists).ok({ timeout: 5000 });
await t.expect(Selector("div.psv-container").exists).ok({ timeout: 10000 });
});
test.meta("testID", "sphere-002").meta({ mode: "public" })("Common: Standard photo does not mount sphere viewer", async (t) => {
await menu.openPage("browse");
const uid = await photo.getNthPhotoUid("image", 0);
await photoviewer.openPhotoViewer("uid", uid);
await t.expect(Selector("div.p-lightbox__pswp").visible).ok();
await t.expect(Selector("div.pswp__media--sphere").exists).notOk();
});
test.meta("testID", "sphere-003").meta({ mode: "public" })("Common: Opens 360° video in sphere viewer", async (t) => {
await menu.openPage("panoramas");
// The acceptance fixture set must include a 360° equirectangular video; fail clearly if it is missing.
await t.expect(Selector("div.type-video").exists).ok({ timeout: 5000 });
const uid = await photo.getNthPhotoUid("video", 0);
await photoviewer.openPhotoViewer("uid", uid);
await t.expect(Selector("div.pswp__media--sphere").exists).ok({ timeout: 5000 });
await t.expect(Selector("div.pswp__media--sphere video").exists).ok({ timeout: 10000 });
});

View file

@ -0,0 +1,105 @@
import "../fixtures";
import { expect, describe, it, vi, beforeEach } from "vitest";
const { viewerCtor, destroySpy } = vi.hoisted(() => ({
viewerCtor: vi.fn(),
destroySpy: vi.fn(),
}));
vi.mock("@photo-sphere-viewer/core", () => ({
Viewer: function Viewer(cfg) {
viewerCtor(cfg);
this.destroy = destroySpy;
},
}));
vi.mock("@photo-sphere-viewer/video-plugin", () => ({
VideoPlugin: function VideoPlugin() {},
}));
vi.mock("@photo-sphere-viewer/equirectangular-video-adapter", () => ({
EquirectangularVideoAdapter: function EquirectangularVideoAdapter() {},
}));
vi.mock("@photo-sphere-viewer/core/index.css", () => ({}));
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", () => {
expect(is360Equirectangular({ Type: "image", Projection: "equirectangular" })).toBe(true);
expect(is360Equirectangular({ Type: "video", Projection: "equirectangular" })).toBe(true);
});
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);
});
it("falls back to the 2:1 frame for a panorama video without a projection tag", () => {
expect(is360Equirectangular({ Type: "video", Panorama: true, Width: 3840, Height: 1920 })).toBe(true);
expect(is360Equirectangular({ Type: "video", Panorama: true, Width: 4096, Height: 2048 })).toBe(true);
});
it("is false for a wide non-2:1 video (ultrawide) even when panorama-flagged", () => {
expect(is360Equirectangular({ Type: "video", Panorama: true, Width: 3840, Height: 1632 })).toBe(false);
});
it("is false for a 2:1 video that is not panorama-flagged", () => {
expect(is360Equirectangular({ Type: "video", Panorama: false, Width: 3840, Height: 1920 })).toBe(false);
});
it("does not apply the 2:1 fallback to photos (projection only)", () => {
expect(is360Equirectangular({ Type: "image", Panorama: true, Width: 3840, Height: 1920 })).toBe(false);
});
it("is false for empty, null, or dimensionless input", () => {
expect(is360Equirectangular(null)).toBe(false);
expect(is360Equirectangular({})).toBe(false);
expect(is360Equirectangular({ Type: "video", Panorama: true })).toBe(false);
});
});
describe("common/sphere", () => {
beforeEach(() => {
viewerCtor.mockClear();
destroySpy.mockClear();
});
it("photo path constructs Viewer with panorama option", async () => {
const container = document.createElement("div");
const viewer = await createSphereViewer(container, "/photo.jpg");
expect(viewerCtor).toHaveBeenCalledOnce();
expect(viewerCtor.mock.calls[0][0]).toMatchObject({
container,
panorama: "/photo.jpg",
keyboard: "always",
defaultYaw: 0,
defaultPitch: 0,
});
expect(viewer).toBeDefined();
});
it("video path wires VideoPlugin and equirectangular adapter", async () => {
const container = document.createElement("div");
await createSphereViewer(container, "/video.mp4", { isVideo: true });
expect(viewerCtor).toHaveBeenCalledOnce();
const cfg = viewerCtor.mock.calls[0][0];
expect(cfg.panorama).toEqual({ source: "/video.mp4" });
expect(cfg.plugins).toHaveLength(1);
expect(cfg.adapter).toHaveLength(2);
});
it("destroySphereViewer calls viewer.destroy exactly once", async () => {
const container = document.createElement("div");
const viewer = await createSphereViewer(container, "/photo.jpg");
destroySphereViewer(viewer);
expect(destroySpy).toHaveBeenCalledOnce();
});
it("destroySphereViewer is safe on null", () => {
expect(() => destroySphereViewer(null)).not.toThrow();
expect(() => destroySphereViewer(undefined)).not.toThrow();
expect(destroySpy).not.toHaveBeenCalled();
});
it("destroySphereViewer is safe on viewer without destroy method", () => {
expect(() => destroySphereViewer({})).not.toThrow();
expect(destroySpy).not.toHaveBeenCalled();
});
});

View file

@ -1237,4 +1237,286 @@ describe("PLightbox (low-mock, jsdom-friendly)", () => {
evictSpy.mockRestore();
});
});
// getItemData dispatch: equirectangular media must route to the sphere viewer,
// while a regular photo with no projection must stay on the flat lightbox path.
describe("getItemData sphere dispatch", () => {
const baseThumbs = {
fit_720: { src: "/thumb-720.jpg", w: 720, h: 360 },
fit_1280: { src: "/thumb-1280.jpg", w: 1280, h: 640 },
fit_2048: { src: "/thumb-2048.jpg", w: 2048, h: 1024 },
fit_2560: { src: "/thumb-2560.jpg", w: 2560, h: 1280 },
fit_3840: { src: "/thumb-3840.jpg", w: 3840, h: 1920 },
fit_4096: { src: "/thumb-4096.jpg", w: 4096, h: 2048 },
fit_7680: { src: "/thumb-7680.jpg", w: 7680, h: 3840 },
};
it("returns sphere data for an equirectangular photo", () => {
const wrapper = mountLightbox();
const model = { Hash: "abc", Projection: "equirectangular", Type: "image", Thumbs: baseThumbs, Title: "Pano" };
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.type).toBe("html");
expect(slide.isSphere).toBe(true);
expect(slide.isVideo).toBe(false);
expect(slide.src).toBeTruthy();
});
it("returns sphere data with isVideo=true for an equirectangular video", () => {
const wrapper = mountLightbox();
const model = { Hash: "vid", Projection: "equirectangular", Type: "video", Thumbs: baseThumbs, Codec: "avc1", Mime: "video/mp4", Playable: true };
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.type).toBe("html");
expect(slide.isSphere).toBe(true);
expect(slide.isVideo).toBe(true);
});
it("returns flat image data for a non-equirectangular photo (regression)", () => {
const wrapper = mountLightbox();
const model = { Hash: "xyz", Projection: "", Type: "image", Thumbs: baseThumbs, Title: "Regular" };
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.type).toBeUndefined();
expect(slide.isSphere).toBeUndefined();
expect(slide.src).toBeTruthy();
});
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 };
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 an ultrawide (non-2:1) video flagged as panorama", () => {
const wrapper = mountLightbox();
const model = { Hash: "wide", Projection: "", Panorama: true, Type: "video", Width: 3840, Height: 1632, Thumbs: baseThumbs, Codec: "avc1", Mime: "video/mp4", Playable: true };
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("mounts the sphere for a 2:1 panorama video that carries no projection tag", () => {
const wrapper = mountLightbox();
const model = { Hash: "vr", Projection: "", Panorama: true, Type: "video", Width: 3840, Height: 1920, Thumbs: baseThumbs, Codec: "avc1", Mime: "video/mp4", Playable: true };
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).toBe(true);
expect(slide.isVideo).toBe(true);
});
});
// bindSphereVideoControls writes to the single shared `video` reactive state.
// PhotoSwipe preloads neighbors and sphere binding is async, so the controls
// bar must only turn on for the active slide — otherwise a preloaded 360° video
// flips the bar on while a plain photo is on screen.
describe("bindSphereVideoControls active-slide gating", () => {
const makeVideoState = () => ({
controls: false,
error: "",
errorCode: 0,
duration: 0,
time: 0,
seekable: false,
playing: false,
paused: false,
ended: false,
});
it("turns controls on when its slide is the active one", () => {
const wrapper = mountLightbox();
const videoEl = document.createElement("video");
const content = { data: {} };
const ctx = { video: makeVideoState(), videoEventListener: () => {}, pswp: () => ({ currSlide: { content } }) };
wrapper.vm.$options.methods.bindSphereVideoControls.call(ctx, content, videoEl);
expect(ctx.video.controls).toBe(true);
expect(content.data.events).toBeInstanceOf(AbortController);
});
it("leaves controls off for a preloaded (non-active) neighbor but still binds listeners", () => {
const wrapper = mountLightbox();
const videoEl = document.createElement("video");
const content = { data: {} };
const activeContent = { data: {} };
const ctx = { video: makeVideoState(), videoEventListener: () => {}, pswp: () => ({ currSlide: { content: activeContent } }) };
wrapper.vm.$options.methods.bindSphereVideoControls.call(ctx, content, videoEl);
expect(ctx.video.controls).toBe(false);
expect(content.data.events).toBeInstanceOf(AbortController);
});
it("leaves controls off when there is no PhotoSwipe instance", () => {
const wrapper = mountLightbox();
const videoEl = document.createElement("video");
const content = { data: {} };
const ctx = { video: makeVideoState(), videoEventListener: () => {}, pswp: () => null };
wrapper.vm.$options.methods.bindSphereVideoControls.call(ctx, content, videoEl);
expect(ctx.video.controls).toBe(false);
});
});
// slideZoomable drives the `.is-zoomable` class that shows/hides the zoom
// button. 360° slides must report false so the flat zoom button is hidden.
describe("slideZoomable", () => {
const slideZoomable = (data) => {
const wrapper = mountLightbox();
return wrapper.vm.$options.methods.slideZoomable.call(wrapper.vm, data);
};
it("returns false for a 360° sphere slide", () => {
expect(slideZoomable({ isSphere: true, model: { Type: "image" } })).toBe(false);
});
it("returns false for video and animation slides", () => {
expect(slideZoomable({ model: { Type: "video" } })).toBe(false);
expect(slideZoomable({ model: { Type: "animated" } })).toBe(false);
});
it("returns true for a regular image and a live photo", () => {
expect(slideZoomable({ model: { Type: "image" } })).toBe(true);
expect(slideZoomable({ model: { Type: "live" } })).toBe(true);
});
it("defaults to true when data or model is missing", () => {
expect(slideZoomable({})).toBe(true);
expect(slideZoomable(undefined)).toBe(true);
});
});
// trapSphereGestures must swallow PhotoSwipe's pointer + wheel gestures on the
// sphere container, but must NOT trap touch events — Photo Sphere Viewer pans
// from touchmove/touchend listeners bound on `window`, so trapping touch here
// would break 360° panning on touch devices. A short press without a pan must
// toggle the lightbox controls so the prev/next arrows stay reachable on mobile.
describe("trapSphereGestures", () => {
const trap = () => {
const wrapper = mountLightbox();
wrapper.vm.toggleControls = vi.fn();
const registered = [];
const el = { addEventListener: (type, handler, opts) => registered.push({ type, handler, opts }) };
wrapper.vm.$options.methods.trapSphereGestures.call(wrapper.vm, el);
const fire = (type, props = {}) => registered.filter((r) => r.type === type).forEach((r) => r.handler({ stopPropagation: () => {}, ...props }));
return { vm: wrapper.vm, registered, fire };
};
it("traps pointer and wheel events", () => {
const types = trap().registered.map((r) => r.type);
expect(types).toEqual(expect.arrayContaining(["pointerdown", "pointermove", "pointerup", "pointercancel", "wheel"]));
});
it("does NOT trap touch events so PSV panning works on mobile", () => {
const types = trap().registered.map((r) => r.type);
expect(types).not.toContain("touchstart");
expect(types).not.toContain("touchmove");
expect(types).not.toContain("touchend");
});
it("registers listeners in the bubble phase and stops propagation", () => {
const { registered } = trap();
expect(registered.every((r) => r.opts && r.opts.capture === false)).toBe(true);
const ev = { stopPropagation: vi.fn() };
registered[0].handler(ev);
expect(ev.stopPropagation).toHaveBeenCalledTimes(1);
});
it("toggles controls on a touch tap without a pan", () => {
const { vm, fire } = trap();
fire("pointerdown", { pointerType: "touch", clientX: 100, clientY: 100 });
fire("pointerup", { pointerType: "touch", clientX: 103, clientY: 102 });
expect(vm.toggleControls).toHaveBeenCalledTimes(1);
});
it("does NOT toggle controls when the touch is a pan", () => {
const { vm, fire } = trap();
fire("pointerdown", { pointerType: "touch", clientX: 100, clientY: 100 });
fire("pointerup", { pointerType: "touch", clientX: 180, clientY: 100 });
expect(vm.toggleControls).not.toHaveBeenCalled();
});
it("does NOT toggle controls for a mouse press so desktop is unchanged", () => {
const { vm, fire } = trap();
fire("pointerdown", { pointerType: "mouse", clientX: 100, clientY: 100 });
fire("pointerup", { pointerType: "mouse", clientX: 100, clientY: 100 });
expect(vm.toggleControls).not.toHaveBeenCalled();
});
it("does NOT toggle controls when a tap is canceled", () => {
const { vm, fire } = trap();
fire("pointerdown", { pointerType: "touch", clientX: 100, clientY: 100 });
fire("pointercancel", { pointerType: "touch" });
fire("pointerup", { pointerType: "touch", clientX: 100, clientY: 100 });
expect(vm.toggleControls).not.toHaveBeenCalled();
});
});
// setSphereClass marks the PhotoSwipe root so CSS can keep the prev/next arrows
// reachable on touch devices for 360° slides (PhotoSwipe hides them on touch).
describe("setSphereClass", () => {
const run = (enabled, hasEl = true) => {
const wrapper = mountLightbox();
const el = document.createElement("div");
wrapper.vm.pswp = () => (hasEl ? { element: el } : {});
wrapper.vm.$options.methods.setSphereClass.call(wrapper.vm, enabled);
return el;
};
it("adds pswp--sphere for a sphere slide", () => {
expect(run(true).classList.contains("pswp--sphere")).toBe(true);
});
it("removes pswp--sphere for a non-sphere slide", () => {
const wrapper = mountLightbox();
const el = document.createElement("div");
el.classList.add("pswp--sphere");
wrapper.vm.pswp = () => ({ element: el });
wrapper.vm.$options.methods.setSphereClass.call(wrapper.vm, false);
expect(el.classList.contains("pswp--sphere")).toBe(false);
});
it("is a no-op when the PhotoSwipe element is missing", () => {
expect(() => run(true, false)).not.toThrow();
});
});
// A 360° sphere slide owns horizontal drag for panning, so PhotoSwipe's swipe-to-
// navigate must be suppressed at its source. The per-element gesture trap can be
// outrun by a fast swipe that leaves the sphere container (seen on touch-capable
// Windows), so onLightboxPointerEvent also default-prevents the dispatched pointer
// event whenever the active slide is a sphere. UI controls stay excluded.
describe("sphere swipe navigation block", () => {
const methods = () => mountLightbox().vm.$options.methods;
const makeCtx = (isSphere) => {
const m = methods();
const ctx = { debug: false, pswp: () => ({ currSlide: { content: { data: { isSphere } } } }) };
ctx.pswpControl = (ev) => m.pswpControl.call(ctx, ev);
ctx.activeSlideIsSphere = () => m.activeSlideIsSphere.call(ctx);
ctx.onLightboxPointerEvent = (ev) => m.onLightboxPointerEvent.call(ctx, ev);
return ctx;
};
const makeEv = (target = document.createElement("div")) => ({
type: "pointerDown",
originalEvent: { target },
preventDefault: vi.fn(),
});
it("activeSlideIsSphere reflects the current slide content flag", () => {
const m = methods();
expect(m.activeSlideIsSphere.call({ pswp: () => ({ currSlide: { content: { data: { isSphere: true } } } }) })).toBe(true);
expect(m.activeSlideIsSphere.call({ pswp: () => ({ currSlide: { content: { data: {} } } }) })).toBe(false);
expect(m.activeSlideIsSphere.call({ pswp: () => null })).toBe(false);
});
it("prevents PhotoSwipe navigation for a pointer on a 360° sphere slide", () => {
const ev = makeEv();
makeCtx(true).onLightboxPointerEvent(ev);
expect(ev.preventDefault).toHaveBeenCalledTimes(1);
});
it("does NOT prevent navigation on a regular slide", () => {
const ev = makeEv();
makeCtx(false).onLightboxPointerEvent(ev);
expect(ev.preventDefault).not.toHaveBeenCalled();
});
it("does NOT block a press on a UI control so buttons and arrows still navigate", () => {
const arrow = document.createElement("button");
arrow.className = "pswp__button pswp__button--arrow";
const ev = makeEv(arrow);
makeCtx(true).onLightboxPointerEvent(ev);
expect(ev.preventDefault).not.toHaveBeenCalled();
});
});
});

View file

@ -3583,6 +3583,61 @@ describe("PLightboxSidebar component", () => {
expect(w.vm.fileTypeName).toBe("live");
expect(w.vm.fileIcon).toBe("mdi-play-circle-outline");
});
it("fileIcon is the panorama icon for an equirectangular 360° photo", () => {
const w = mountSidebar({
props: {
modelValue: { ...mockModel, Type: "image", Projection: "equirectangular" },
photo: { ...mockPhoto, Type: "image", Projection: "equirectangular" },
context: contexts.Photos,
},
});
expect(w.vm.mediaIs360).toBe(true);
expect(w.vm.fileIcon).toBe("mdi-panorama-variant-outline");
});
it("fileIcon is the panorama icon for an equirectangular 360° video", () => {
const w = mountSidebar({
props: {
modelValue: { ...mockModel, Type: "video", Projection: "equirectangular" },
photo: { ...mockPhoto, Type: "video", Projection: "equirectangular", getVideoInfo: vi.fn().mockReturnValue("") },
context: contexts.Photos,
},
});
expect(w.vm.mediaIs360).toBe(true);
expect(w.vm.fileIcon).toBe("mdi-panorama-variant-outline");
});
it("fileIcon is the panorama icon for a 2:1 panorama video without a projection tag", () => {
const w = mountSidebar({
props: {
modelValue: { ...mockModel, Type: "video", Panorama: true, Projection: "", Width: 3840, Height: 1920 },
photo: { ...mockPhoto, Type: "video", Panorama: true, Projection: "", Width: 3840, Height: 1920, getVideoInfo: vi.fn().mockReturnValue("") },
context: contexts.Photos,
},
});
expect(w.vm.mediaIs360).toBe(true);
expect(w.vm.fileIcon).toBe("mdi-panorama-variant-outline");
});
it("fileIcon stays the standard video icon for an ultrawide (non-2:1) panorama video", () => {
const w = mountSidebar({
props: {
modelValue: { ...mockModel, Type: "video", Panorama: true, Projection: "", Width: 3840, Height: 1632 },
photo: { ...mockPhoto, Type: "video", Panorama: true, Projection: "", Width: 3840, Height: 1632, getVideoInfo: vi.fn().mockReturnValue("") },
context: contexts.Photos,
},
});
expect(w.vm.mediaIs360).toBe(false);
expect(w.vm.fileIcon).toBe("mdi-video");
});
it("fileIcon stays the standard image icon for a regular photo", () => {
const w = mountSidebar({
props: {
modelValue: { ...mockModel, Type: "image" },
photo: { ...mockPhoto, Type: "image" },
context: contexts.Photos,
},
});
expect(w.vm.mediaIs360).toBe(false);
expect(w.vm.fileIcon).toBe("mdi-image-outline");
});
});
// Exhaustive matrix: against a fully-populated photo and a photo

View file

@ -4098,6 +4098,12 @@
"Lng": {
"type": "number"
},
"Panorama": {
"type": "boolean"
},
"Projection": {
"type": "string"
},
"TakenAt": {
"type": "string"
},
@ -4397,6 +4403,9 @@
"Private": {
"type": "boolean"
},
"Projection": {
"type": "string"
},
"Quality": {
"type": "integer"
},
@ -4715,9 +4724,15 @@
"Mime": {
"type": "string"
},
"Panorama": {
"type": "boolean"
},
"Playable": {
"type": "boolean"
},
"Projection": {
"type": "string"
},
"TakenAtLocal": {
"type": "string"
},

View file

@ -11,26 +11,28 @@ import (
// GeoResult represents a photo geo search result.
type GeoResult struct {
ID string `json:"-" select:"photos.id"`
PhotoUID string `json:"UID" select:"photos.photo_uid"`
PhotoType string `json:"Type,omitempty" select:"photos.photo_type"`
PhotoTitle string `json:"Title" select:"photos.photo_title"`
PhotoCaption string `json:"Caption,omitempty" select:"photos.photo_caption"`
PhotoLat float64 `json:"Lat" select:"photos.photo_lat"`
PhotoLng float64 `json:"Lng" select:"photos.photo_lng"`
TakenAt time.Time `json:"TakenAt" select:"photos.taken_at"`
TakenAtLocal time.Time `json:"TakenAtLocal" select:"photos.taken_at_local"`
TimeZone string `json:"TimeZone" select:"photos.time_zone"`
PhotoFavorite bool `json:"Favorite,omitempty" select:"photos.photo_favorite"`
PhotoDuration time.Duration `json:"Duration,omitempty" select:"photos.photo_duration"`
FileID uint `json:"-" select:"files.id AS file_id"` // File
FileWidth int `json:"Width" select:"files.file_width"`
FileHeight int `json:"Height" select:"files.file_height"`
FileHash string `json:"Hash" select:"files.file_hash"`
FileCodec string `json:"-" select:"files.file_codec"`
FileMime string `json:"-" select:"files.file_mime"`
FileVideo bool `json:"-" select:"files.file_video"`
MediaType string `json:"-" select:"files.media_type"`
ID string `json:"-" select:"photos.id"`
PhotoUID string `json:"UID" select:"photos.photo_uid"`
PhotoType string `json:"Type,omitempty" select:"photos.photo_type"`
PhotoTitle string `json:"Title" select:"photos.photo_title"`
PhotoCaption string `json:"Caption,omitempty" select:"photos.photo_caption"`
PhotoLat float64 `json:"Lat" select:"photos.photo_lat"`
PhotoLng float64 `json:"Lng" select:"photos.photo_lng"`
TakenAt time.Time `json:"TakenAt" select:"photos.taken_at"`
TakenAtLocal time.Time `json:"TakenAtLocal" select:"photos.taken_at_local"`
TimeZone string `json:"TimeZone" select:"photos.time_zone"`
PhotoFavorite bool `json:"Favorite,omitempty" select:"photos.photo_favorite"`
PhotoPanorama bool `json:"Panorama,omitempty" select:"photos.photo_panorama"`
PhotoDuration time.Duration `json:"Duration,omitempty" select:"photos.photo_duration"`
FileID uint `json:"-" select:"files.id AS file_id"` // File
FileWidth int `json:"Width" select:"files.file_width"`
FileHeight int `json:"Height" select:"files.file_height"`
FileHash string `json:"Hash" select:"files.file_hash"`
FileProjection string `json:"Projection,omitempty" select:"files.file_projection"`
FileCodec string `json:"-" select:"files.file_codec"`
FileMime string `json:"-" select:"files.file_mime"`
FileVideo bool `json:"-" select:"files.file_video"`
MediaType string `json:"-" select:"files.media_type"`
}
// Lat returns the position latitude.

View file

@ -94,7 +94,7 @@ type Photo struct {
FileMime string `json:"-" select:"files.file_mime"`
FileSize int64 `json:"-" select:"files.file_size"`
FileOrientation int `json:"-" select:"files.file_orientation"`
FileProjection string `json:"-" select:"files.file_projection"`
FileProjection string `json:"Projection,omitempty" select:"files.file_projection"`
FileAspectRatio float32 `json:"-" select:"files.file_aspect_ratio"`
FileColors string `json:"-" select:"files.file_colors"`
FileDiff int `json:"-" select:"files.file_diff"`
@ -260,6 +260,24 @@ func (m *Photo) MediaInfo() (mediaHash, mediaCodec, mediaMime string, width, hei
return m.FileHash, "", m.FileMime, m.FileWidth, m.FileHeight
}
// MediaProjection returns the projection of the photo's playable media file,
// falling back to the primary file's projection. For videos the primary search
// row is usually a poster JPEG that carries no projection metadata, so the video
// file's projection (which the indexer sets) is used instead — this keeps the
// 360° equirectangular flag accurate in the viewer DTO.
func (m *Photo) MediaProjection() string {
switch m.PhotoType {
case entity.MediaVideo, entity.MediaLive:
for _, f := range m.Files {
if f.FileVideo && f.FileProjection != "" {
return f.FileProjection
}
}
}
return m.FileProjection
}
// ShareBase returns a deterministic, human friendly file name stem for sharing
// downloads generated from the photo's timestamp and title.
func (m *Photo) ShareBase(seq int) string {

View file

@ -226,6 +226,36 @@ func TestPhoto_IsPlayable(t *testing.T) {
})
}
func TestPhoto_MediaProjection(t *testing.T) {
t.Run("VideoUsesVideoFileProjection", func(t *testing.T) {
r := Photo{
PhotoType: "video",
FileProjection: "",
Files: []entity.File{
{FileVideo: true, MediaType: media.Video.String(), FileHash: "v", FileProjection: "equirectangular"},
},
}
assert.Equal(t, "equirectangular", r.MediaProjection())
})
t.Run("VideoFallsBackToPrimaryProjection", func(t *testing.T) {
r := Photo{
PhotoType: "video",
FileProjection: "cubemap",
Files: []entity.File{
{FileVideo: true, MediaType: media.Video.String(), FileHash: "v", FileProjection: ""},
},
}
assert.Equal(t, "cubemap", r.MediaProjection())
})
t.Run("ImageUsesPrimaryProjection", func(t *testing.T) {
r := Photo{
PhotoType: "image",
FileProjection: "equirectangular",
}
assert.Equal(t, "equirectangular", r.MediaProjection())
})
}
func TestPhoto_MediaInfo(t *testing.T) {
t.Run("LiveCodecAVC", func(t *testing.T) {
r := Photo{

View file

@ -42,6 +42,8 @@ func (m *Photo) ViewerResult(contentUri, apiUri, previewToken, downloadToken str
TimeZone: m.TimeZone,
Favorite: m.PhotoFavorite,
Playable: m.IsPlayable(),
Panorama: m.PhotoPanorama,
Projection: m.MediaProjection(),
Duration: m.PhotoDuration,
Width: width,
Height: height,
@ -83,6 +85,8 @@ func (m GeoResult) ViewerResult(contentUri, apiUri, previewToken, downloadToken
TimeZone: m.TimeZone,
Favorite: m.PhotoFavorite,
Playable: m.IsPlayable(),
Panorama: m.PhotoPanorama,
Projection: m.FileProjection,
Duration: m.PhotoDuration,
Width: m.FileWidth,
Height: m.FileHeight,

View file

@ -36,13 +36,14 @@ func TestPhoto_ViewerResult(t *testing.T) {
FileHeight: 600,
Files: []entity.File{
{
FileVideo: true,
MediaType: entity.MediaVideo,
FileHash: videoHash,
FileCodec: "avc1",
FileMime: header.ContentTypeMp4AvcMain,
FileWidth: 1920,
FileHeight: 1080,
FileVideo: true,
MediaType: entity.MediaVideo,
FileHash: videoHash,
FileCodec: "avc1",
FileMime: header.ContentTypeMp4AvcMain,
FileWidth: 1920,
FileHeight: 1080,
FileProjection: "equirectangular",
},
},
}
@ -65,6 +66,7 @@ func TestPhoto_ViewerResult(t *testing.T) {
assert.Equal(t, header.ContentTypeMp4AvcMain, result.Mime)
assert.Equal(t, 1920, result.Width)
assert.Equal(t, 1080, result.Height)
assert.Equal(t, "equirectangular", result.Projection)
if assert.NotNil(t, result.Thumbs) {
assert.NotNil(t, result.Thumbs.Fit720)
}

View file

@ -18,6 +18,8 @@ type Result struct {
TimeZone string `json:"TimeZone,omitempty"`
Favorite bool `json:"Favorite"`
Playable bool `json:"Playable"`
Panorama bool `json:"Panorama,omitempty"`
Projection string `json:"Projection,omitempty"`
Duration time.Duration `json:"Duration,omitempty"`
Width int `json:"Width"`
Height int `json:"Height"`

View file

@ -279,6 +279,13 @@ func (data *Data) Exiftool(jsonData []byte, originalName string) (err error) {
data.InstanceID = rnd.SanitizeUUID(data.InstanceID)
}
// Promote GPano-style markers to equirectangular when ProjectionType is absent.
if data.Projection == "" {
if data.json["UsePanoramaViewer"] == "true" || data.json["IsPhotosphere"] == "true" {
data.Projection = projection.Equirectangular.String()
}
}
if projection.Equirectangular.Equal(data.Projection) {
data.AddKeywords(KeywordPanorama)
}

View file

@ -600,6 +600,46 @@ func TestJSON(t *testing.T) {
assert.Equal(t, 1, data.Orientation)
assert.Equal(t, projection.Equirectangular.String(), data.Projection)
})
t.Run("PanoramaUsePanoramaViewer", func(t *testing.T) {
data, err := JSON("testdata/panorama_usepanoramaviewer.json", "")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, projection.Equirectangular.String(), data.Projection)
assert.Contains(t, data.Keywords.String(), "panorama")
})
t.Run("PanoramaIsPhotosphere", func(t *testing.T) {
data, err := JSON("testdata/panorama_isphotosphere.json", "")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, projection.Equirectangular.String(), data.Projection)
assert.Contains(t, data.Keywords.String(), "panorama")
})
t.Run("PanoramaCubemap", func(t *testing.T) {
data, err := JSON("testdata/panorama_cubemap.json", "")
if err != nil {
t.Fatal(err)
}
assert.NotEqual(t, projection.Equirectangular.String(), data.Projection)
assert.NotContains(t, data.Keywords.String(), "panorama")
})
t.Run("PanoramaVideoMp4", func(t *testing.T) {
data, err := JSON("testdata/panorama360_video.json", "panorama360_video.mp4")
if err != nil {
t.Fatal(err)
}
assert.Equal(t, projection.Equirectangular.String(), data.Projection)
assert.Contains(t, data.Keywords.String(), "panorama")
})
t.Run("PNum7250006Json", func(t *testing.T) {
data, err := JSON("testdata/P7250006.json", "P7250006.MOV")

View file

@ -0,0 +1,15 @@
[{
"SourceFile": "panorama360_video.mp4",
"ExifToolVersion": 12.40,
"FileName": "panorama360_video.mp4",
"Directory": ".",
"FileSize": "12 MB",
"FileType": "MP4",
"FileTypeExtension": "mp4",
"MIMEType": "video/mp4",
"ImageWidth": 3840,
"ImageHeight": 1920,
"ProjectionType": "equirectangular",
"CompressorID": "avc1",
"Duration": "10.00 s"
}]

View file

@ -0,0 +1,16 @@
[{
"SourceFile": "panorama_cubemap.jpg",
"ExifToolVersion": 12.40,
"FileName": "panorama_cubemap.jpg",
"Directory": ".",
"FileSize": "1.0 MB",
"FileType": "JPEG",
"FileTypeExtension": "jpg",
"MIMEType": "image/jpeg",
"ImageWidth": 6000,
"ImageHeight": 4500,
"ProjectionType": "cubemap",
"EncodingProcess": "Baseline DCT, Huffman coding",
"BitsPerSample": 8,
"ColorComponents": 3
}]

View file

@ -0,0 +1,18 @@
[{
"SourceFile": "panorama_isphotosphere.jpg",
"ExifToolVersion": 12.40,
"FileName": "panorama_isphotosphere.jpg",
"Directory": ".",
"FileSize": "1.0 MB",
"FileType": "JPEG",
"FileTypeExtension": "jpg",
"MIMEType": "image/jpeg",
"ImageWidth": 5376,
"ImageHeight": 2688,
"IsPhotosphere": true,
"Make": "RICOH",
"Model": "RICOH THETA S",
"EncodingProcess": "Baseline DCT, Huffman coding",
"BitsPerSample": 8,
"ColorComponents": 3
}]

View file

@ -0,0 +1,17 @@
[{
"SourceFile": "panorama_usepanoramaviewer.jpg",
"ExifToolVersion": 12.40,
"FileName": "panorama_usepanoramaviewer.jpg",
"Directory": ".",
"FileSize": "1.0 MB",
"FileType": "JPEG",
"FileTypeExtension": "jpg",
"MIMEType": "image/jpeg",
"ImageWidth": 7200,
"ImageHeight": 3600,
"UsePanoramaViewer": true,
"CaptureSoftware": "Generic Panorama Stitcher",
"EncodingProcess": "Baseline DCT, Huffman coding",
"BitsPerSample": 8,
"ColorComponents": 3
}]

37
package-lock.json generated
View file

@ -23,6 +23,9 @@
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^9.33.0",
"@mdi/font": "^7.4.47",
"@photo-sphere-viewer/core": "^5.14.1",
"@photo-sphere-viewer/equirectangular-video-adapter": "^5.14.1",
"@photo-sphere-viewer/video-plugin": "^5.14.1",
"@testing-library/jest-dom": "^6.9.1",
"@vitejs/plugin-vue": "^6.0.7",
"@vitest/coverage-v8": "^4.1.9",
@ -5918,6 +5921,34 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/@photo-sphere-viewer/core": {
"version": "5.14.1",
"resolved": "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.14.1.tgz",
"integrity": "sha512-qrwUudrX9YZms4c2shlY/H3jUP0oh9FyGEqIDr/95ulNZgKbhQ6C/i8zDQ4j8ooFR4+z5FDORQtGvLgPyX8VCA==",
"license": "MIT",
"dependencies": {
"three": "^0.179.0"
}
},
"node_modules/@photo-sphere-viewer/equirectangular-video-adapter": {
"version": "5.14.1",
"resolved": "https://registry.npmjs.org/@photo-sphere-viewer/equirectangular-video-adapter/-/equirectangular-video-adapter-5.14.1.tgz",
"integrity": "sha512-rZ6igEy1TEfgHB8Ak/8N0rZNYQLbNEGLVmhwNxDMWESCJ9nrNx3tJHFn7k6eZYjj9zJA73xF5YdY6XWUCpZDzg==",
"license": "MIT",
"peerDependencies": {
"@photo-sphere-viewer/core": "5.14.1",
"@photo-sphere-viewer/video-plugin": "5.14.1"
}
},
"node_modules/@photo-sphere-viewer/video-plugin": {
"version": "5.14.1",
"resolved": "https://registry.npmjs.org/@photo-sphere-viewer/video-plugin/-/video-plugin-5.14.1.tgz",
"integrity": "sha512-7yItXiD+eS/+9lgtaE9+wXSIpdYVU0kBsBN4vNtChaoJZF3JB8WUXjYLbszKp1yhwsvZ6eNxDcMBUgYdK6CrQA==",
"license": "MIT",
"peerDependencies": {
"@photo-sphere-viewer/core": "5.14.1"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -16917,6 +16948,12 @@
"integrity": "sha512-D5a/bO0KdalOE3q8MlrRmSxjbKZHT3MQmXkJP+r97Vw8MMwOZKOwUSEyTtK7eSMj2y0kyAjpYMRMZmmLw1FtNQ==",
"license": "Apache-2.0"
},
"node_modules/three": {
"version": "0.179.1",
"resolved": "https://registry.npmjs.org/three/-/three-0.179.1.tgz",
"integrity": "sha512-5y/elSIQbrvKOISxpwXCR4sQqHtGiOI+MKLc3SsBdDXA2hz3Mdp3X59aUp8DyybMa34aeBwbFTpdoLJaUDEWSw==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",