mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
* 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:
parent
bf081bd68b
commit
3ea547a564
27 changed files with 1354 additions and 62 deletions
244
NOTICE
244
NOTICE
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
110
frontend/src/common/sphere.js
Normal file
110
frontend/src/common/sphere.js
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export class Photo extends RestModel {
|
|||
Private: false,
|
||||
Scan: false,
|
||||
Panorama: false,
|
||||
Projection: "",
|
||||
Portrait: false,
|
||||
TakenAt: "",
|
||||
TakenAtLocal: "",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
45
frontend/tests/acceptance/acceptance-public/sphere.js
Normal file
45
frontend/tests/acceptance/acceptance-public/sphere.js
Normal 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 });
|
||||
});
|
||||
105
frontend/tests/vitest/common/sphere.test.js
Normal file
105
frontend/tests/vitest/common/sphere.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
15
internal/meta/testdata/panorama360_video.json
vendored
Normal file
15
internal/meta/testdata/panorama360_video.json
vendored
Normal 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"
|
||||
}]
|
||||
16
internal/meta/testdata/panorama_cubemap.json
vendored
Normal file
16
internal/meta/testdata/panorama_cubemap.json
vendored
Normal 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
|
||||
}]
|
||||
18
internal/meta/testdata/panorama_isphotosphere.json
vendored
Normal file
18
internal/meta/testdata/panorama_isphotosphere.json
vendored
Normal 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
|
||||
}]
|
||||
17
internal/meta/testdata/panorama_usepanoramaviewer.json
vendored
Normal file
17
internal/meta/testdata/panorama_usepanoramaviewer.json
vendored
Normal 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
37
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue