diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 1b7b0e8c82..8d4f8a429b 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -107,6 +107,7 @@ Class | Method | HTTP request | Description *AssetsApi* | [**getAssetMetadataByKey**](doc//AssetsApi.md#getassetmetadatabykey) | **GET** /assets/{id}/metadata/{key} | Retrieve asset metadata by key *AssetsApi* | [**getAssetOcr**](doc//AssetsApi.md#getassetocr) | **GET** /assets/{id}/ocr | Retrieve asset OCR data *AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics | Get asset statistics +*AssetsApi* | [**getAssetTile**](doc//AssetsApi.md#getassettile) | **GET** /assets/{id}/tiles/{level}/{col}/{row} | Get an image tile *AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random | Get random assets *AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | Play asset video *AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace asset diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index 5020afc4b2..43cb05a8a3 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -738,6 +738,93 @@ class AssetsApi { return null; } + /// Get an image tile + /// + /// Download a specific tile from an image at the specified level and position + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [num] col (required): + /// + /// * [String] id (required): + /// + /// * [num] level (required): + /// + /// * [num] row (required): + /// + /// * [String] key: + /// + /// * [String] slug: + Future getAssetTileWithHttpInfo(num col, String id, num level, num row, { String? key, String? slug, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/assets/{id}/tiles/{level}/{col}/{row}' + .replaceAll('{col}', col.toString()) + .replaceAll('{id}', id) + .replaceAll('{level}', level.toString()) + .replaceAll('{row}', row.toString()); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + if (slug != null) { + queryParams.addAll(_queryParams('', 'slug', slug)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Get an image tile + /// + /// Download a specific tile from an image at the specified level and position + /// + /// Parameters: + /// + /// * [num] col (required): + /// + /// * [String] id (required): + /// + /// * [num] level (required): + /// + /// * [num] row (required): + /// + /// * [String] key: + /// + /// * [String] slug: + Future getAssetTile(num col, String id, num level, num row, { String? key, String? slug, }) async { + final response = await getAssetTileWithHttpInfo(col, id, level, row, key: key, slug: slug, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'MultipartFile',) as MultipartFile; + + } + return null; + } + /// Get random assets /// /// Retrieve a specified number of random assets for the authenticated user. diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index c052e41a49..b6df615433 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -3756,6 +3756,103 @@ "x-immich-state": "Stable" } }, + "/assets/{id}/tiles/{level}/{col}/{row}": { + "get": { + "description": "Download a specific tile from an image at the specified level and position", + "operationId": "getAssetTile", + "parameters": [ + { + "name": "col", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "level", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "row", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "slug", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Get an image tile", + "tags": [ + "Assets" + ], + "x-immich-history": [ + { + "version": "v2.4.0", + "state": "Added" + }, + { + "version": "v2.4.0", + "state": "Stable" + } + ], + "x-immich-permission": "asset.view", + "x-immich-state": "Stable" + } + }, "/assets/{id}/video/playback": { "get": { "description": "Streams the video file for the specified asset. This endpoint also supports byte range requests.", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 537427ff03..bb3b07a1f7 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -2652,6 +2652,27 @@ export function viewAsset({ id, key, size, slug }: { ...opts })); } +/** + * Get an image tile + */ +export function getAssetTile({ col, id, key, level, row, slug }: { + col: number; + id: string; + key?: string; + level: number; + row: number; + slug?: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchBlob<{ + status: 200; + data: Blob; + }>(`/assets/${encodeURIComponent(id)}/tiles/${encodeURIComponent(level)}/${encodeURIComponent(col)}/${encodeURIComponent(row)}${QS.query(QS.explode({ + key, + slug + }))}`, { + ...opts + })); +} /** * Play asset video */ diff --git a/open-api/typescript-sdk/src/index.ts b/open-api/typescript-sdk/src/index.ts index 7adbca4d7e..3c23b4049d 100644 --- a/open-api/typescript-sdk/src/index.ts +++ b/open-api/typescript-sdk/src/index.ts @@ -55,6 +55,13 @@ export const getAssetThumbnailPath = (id: string) => `/assets/${id}/thumbnail`; export const getAssetPlaybackPath = (id: string) => `/assets/${id}/video/playback`; +export const getAssetTilePath = ( + id: string, + level: number, + col: number, + row: number +) => `/assets/${id}/tiles/${level}/${col}/${row}`; + export const getUserProfileImagePath = (userId: string) => `/users/${userId}/profile-image`; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 27c6c3f290..390393ab85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -728,6 +728,9 @@ importers: '@photo-sphere-viewer/core': specifier: ^5.14.0 version: 5.14.0 + '@photo-sphere-viewer/equirectangular-tiles-adapter': + specifier: ^5.14.0 + version: 5.14.0(@photo-sphere-viewer/core@5.14.0) '@photo-sphere-viewer/equirectangular-video-adapter': specifier: ^5.14.0 version: 5.14.0(@photo-sphere-viewer/core@5.14.0)(@photo-sphere-viewer/video-plugin@5.14.0(@photo-sphere-viewer/core@5.14.0)) @@ -3768,6 +3771,11 @@ packages: '@photo-sphere-viewer/core@5.14.0': resolution: {integrity: sha512-V0JeDSB1D2Q60Zqn7+0FPjq8gqbKEwuxMzNdTLydefkQugVztLvdZykO+4k5XTpweZ2QAWPH/QOI1xZbsdvR9A==} + '@photo-sphere-viewer/equirectangular-tiles-adapter@5.14.0': + resolution: {integrity: sha512-PEZreZg79tdkYbiswKppmLuwkJnMnhLVHTHJdWYqpjFt6PJ/ieh7Eg/8fIACc+DPtFrgzyCcH/6QAHPUE9nblQ==} + peerDependencies: + '@photo-sphere-viewer/core': 5.14.0 + '@photo-sphere-viewer/equirectangular-video-adapter@5.14.0': resolution: {integrity: sha512-Ez88sZ4sj3fONpZSortnN3gLXlvV/hn5U/88LsWtxI73YwhkZ06ZtXFYLXU4MBaJvqCbMGaR6j39uVXTWFo5rw==} peerDependencies: @@ -15579,6 +15587,10 @@ snapshots: dependencies: three: 0.179.1 + '@photo-sphere-viewer/equirectangular-tiles-adapter@5.14.0(@photo-sphere-viewer/core@5.14.0)': + dependencies: + '@photo-sphere-viewer/core': 5.14.0 + '@photo-sphere-viewer/equirectangular-video-adapter@5.14.0(@photo-sphere-viewer/core@5.14.0)(@photo-sphere-viewer/video-plugin@5.14.0(@photo-sphere-viewer/core@5.14.0))': dependencies: '@photo-sphere-viewer/core': 5.14.0 diff --git a/server/src/constants.ts b/server/src/constants.ts index 33f8e3b4c5..6df63ea1f4 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -55,6 +55,7 @@ export const LOGIN_URL = '/auth/login?autoLaunch=0'; export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico']; export const FACE_THUMBNAIL_SIZE = 250; +export const TILE_TARGET_SIZE = 1024; type ModelInfo = { dimSize: number }; export const CLIP_MODEL_INFO: Record = { diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index 843c2a3f3d..951126d7a6 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -7,6 +7,7 @@ import { Next, Param, ParseFilePipe, + ParseIntPipe, Post, Put, Query, @@ -167,6 +168,26 @@ export class AssetMediaController { } } + @Get(':id/tiles/:level/:col/:row') + @FileResponse() + @Authenticated({ permission: Permission.AssetView, sharedLink: true }) + @Endpoint({ + summary: 'Get an image tile', + description: 'Download a specific tile from an image at the specified level and position', + history: new HistoryBuilder().added('v2.4.0').stable('v2.4.0'), + }) + async getAssetTile( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Param('level', ParseIntPipe) level: number, + @Param('col', ParseIntPipe) col: number, + @Param('row', ParseIntPipe) row: number, + @Res() res: Response, + @Next() next: NextFunction, + ) { + await sendFile(res, next, () => this.service.getAssetTile(auth, id, level, col, row), this.logger); + } + @Get(':id/video/playback') @FileResponse() @Authenticated({ permission: Permission.AssetView, sharedLink: true }) diff --git a/server/src/cores/storage.core.ts b/server/src/cores/storage.core.ts index 96623092f1..f1a5958eec 100644 --- a/server/src/cores/storage.core.ts +++ b/server/src/cores/storage.core.ts @@ -24,7 +24,11 @@ export interface MoveRequest { }; } -export type GeneratedImageType = AssetPathType.Preview | AssetPathType.Thumbnail | AssetPathType.FullSize; +export type GeneratedImageType = + | AssetPathType.Thumbnail + | AssetPathType.Preview + | AssetPathType.FullSize + | AssetPathType.Tiles; export type GeneratedAssetType = GeneratedImageType | AssetPathType.EncodedVideo; export type ThumbnailPathEntity = { id: string; ownerId: string }; @@ -105,7 +109,7 @@ export class StorageCore { return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`); } - static getImagePath(asset: ThumbnailPathEntity, type: GeneratedImageType, format: 'jpeg' | 'webp') { + static getImagePath(asset: ThumbnailPathEntity, type: GeneratedImageType, format: 'jpeg' | 'webp' | 'dz') { return StorageCore.getNestedPath(StorageFolder.Thumbnails, asset.ownerId, `${asset.id}-${type}.${format}`); } diff --git a/server/src/enum.ts b/server/src/enum.ts index 9d0a2c0426..77b10585c9 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -45,6 +45,8 @@ export enum AssetFileType { Preview = 'preview', Thumbnail = 'thumbnail', Sidecar = 'sidecar', + /** Folder structure containing tiles of the image */ + Tiles = 'tiles', } export enum AlbumUserRole { @@ -359,6 +361,8 @@ export enum AssetPathType { FullSize = 'fullsize', Preview = 'preview', Thumbnail = 'thumbnail', + /** Folder structure containing tiles of the image */ + Tiles = 'tiles', EncodedVideo = 'encoded_video', Sidecar = 'sidecar', } diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index a8e96709ff..1aa248c895 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -152,6 +152,19 @@ export class MediaRepository { .toFile(output); } + /** + * For output file path 'output.dz', this creates an 'output.dzi' file and 'output_files' directory containing tiles + */ + async generateTiles(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise { + await this.getImageDecodingPipeline(input, options) + .toFormat(options.format) + .tile({ + depth: 'one', + size: options.size, + }) + .toFile(output); + } + private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) { let pipeline = sharp(input, { // some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 2bb8530c1c..9f7c8fef33 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -248,6 +248,33 @@ export class AssetMediaService extends BaseService { }); } + async getAssetTile(auth: AuthDto, id: string, level: number, col: number, row: number): Promise { + await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] }); + + const asset = await this.findOrFail(id); + const { tilesPath } = getAssetFiles(asset.files ?? []); + if (!tilesPath) { + // TODO: placeholder tiles. + return new ImmichFileResponse({ + fileName: `${level}_${col}_${row}.jpg`, + path: `/data/sluis_files/0/${col}_${row}.jpg`, + contentType: 'image/jpg', + cacheControl: CacheControl.None, + }); + throw new NotFoundException('Asset tiles not found'); + } + + const tileName = getFileNameWithoutExtension(asset.originalFileName) + `_${level}_${col}_${row}.jpg`; + const tilePath = tilesPath.path.replace('.dz', '_files') + `/${level}/${col}_${row}.jpg`; + + return new ImmichFileResponse({ + fileName: tileName, + path: tilePath, + contentType: 'image/jpg', + cacheControl: CacheControl.PrivateWithCache, + }); + } + async playbackVideo(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 9027e89d66..de543b33ba 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; +import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE, TILE_TARGET_SIZE } from 'src/constants'; import { StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core'; import { Exif } from 'src/database'; import { OnEvent, OnJob } from 'src/decorators'; @@ -47,6 +47,15 @@ interface UpsertFileOptions { path: string; } +interface TileInfo { + path: string; + info: { + width: number; + cols: number; + rows: number; + }; +} + @Injectable() export class MediaService extends BaseService { videoInterfaces: VideoInterfaces = { dri: [], mali: false }; @@ -149,6 +158,8 @@ export class MediaService extends BaseService { await this.storageCore.moveAssetImage(asset, AssetPathType.FullSize, image.fullsize.format); await this.storageCore.moveAssetImage(asset, AssetPathType.Preview, image.preview.format); await this.storageCore.moveAssetImage(asset, AssetPathType.Thumbnail, image.thumbnail.format); + // TODO: + // await this.storageCore.moveAssetImage(asset, AssetPathType.Tiles, image.???.format); await this.storageCore.moveAssetVideo(asset); return JobStatus.Success; @@ -171,6 +182,7 @@ export class MediaService extends BaseService { previewPath: string; thumbnailPath: string; fullsizePath?: string; + tileInfo?: TileInfo; thumbhash: Buffer; }; if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) { @@ -184,7 +196,7 @@ export class MediaService extends BaseService { return JobStatus.Skipped; } - const { previewFile, thumbnailFile, fullsizeFile } = getAssetFiles(asset.files); + const { previewFile, thumbnailFile, fullsizeFile, tilesPath } = getAssetFiles(asset.files); const toUpsert: UpsertFileOptions[] = []; if (previewFile?.path !== generated.previewPath) { toUpsert.push({ assetId: asset.id, path: generated.previewPath, type: AssetFileType.Preview }); @@ -198,6 +210,11 @@ export class MediaService extends BaseService { toUpsert.push({ assetId: asset.id, path: generated.fullsizePath, type: AssetFileType.FullSize }); } + if (generated.tileInfo?.path && tilesPath?.path !== generated.tileInfo.path) { + // TODO: save tileInfo.info (width, cols, rows) somewhere in the db. + toUpsert.push({ assetId: asset.id, path: generated.tileInfo.path, type: AssetFileType.Tiles }); + } + if (toUpsert.length > 0) { await this.assetRepository.upsertFiles(toUpsert); } @@ -222,6 +239,12 @@ export class MediaService extends BaseService { } } + if (tilesPath && tilesPath.path !== generated.tileInfo?.path) { + this.logger.debug(`Deleting old tiles for asset ${asset.id}`); + pathsToDelete.push(tilesPath.path.replace('.dz', '.dzi')); + await this.storageRepository.unlinkDir(tilesPath.path.replace('.dz', '_files'), { recursive: true }); + } + if (pathsToDelete.length > 0) { await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path))); } @@ -316,6 +339,39 @@ export class MediaService extends BaseService { ); } + // TODO: probably extract to helper method + let tileInfo: TileInfo | undefined; + if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') { + // TODO: get uncropped width from asset (FullPanoWidthPixels if present). + const originalSize = 12_988; + // Get the number of tiles at the exact target size, rounded up (to at least 1 tile). + const numTilesExact = Math.ceil(originalSize / TILE_TARGET_SIZE); + // Then round up to the nearest power of 2 (photo-sphere-viewer requirement). + const numTiles = Math.pow(2, Math.ceil(Math.log2(numTilesExact))); + const tileSize = Math.ceil(originalSize / numTiles); + + const tileOptions = { + format: image.preview.format, + size: tileSize, + quality: image.preview.quality, + ...thumbnailOptions, + }; + + tileInfo = { + path: StorageCore.getImagePath(asset, AssetPathType.Tiles, 'dz'), + info: { + width: originalSize, + cols: numTiles, + rows: numTiles / 2, + } + }; + // TODO: reverse comment state + // TODO: handle cropped panoramas here. Tile as normal but save some offset? + // promises.push(this.mediaRepository.generateTiles(data, tileOptions, tileInfo.path)); + console.log(tileOptions, tileInfo); + tileInfo = undefined; + } + const outputs = await Promise.all(promises); if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') { @@ -328,7 +384,7 @@ export class MediaService extends BaseService { await Promise.all(promises); } - return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer }; + return { previewPath, thumbnailPath, fullsizePath, tileInfo, thumbhash: outputs[0] as Buffer }; } @OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration }) diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index f3f807c829..eead81eb84 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -22,6 +22,7 @@ export const getAssetFiles = (files: AssetFile[]) => ({ previewFile: getAssetFile(files, AssetFileType.Preview), thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail), sidecarFile: getAssetFile(files, AssetFileType.Sidecar), + tilesPath: getAssetFile(files, AssetFileType.Tiles), }); export const addAssets = async ( diff --git a/server/test/repositories/media.repository.mock.ts b/server/test/repositories/media.repository.mock.ts index b6b1e82b52..33687921cf 100644 --- a/server/test/repositories/media.repository.mock.ts +++ b/server/test/repositories/media.repository.mock.ts @@ -5,6 +5,7 @@ import { Mocked, vitest } from 'vitest'; export const newMediaRepositoryMock = (): Mocked> => { return { generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()), + generateTiles: vitest.fn().mockImplementation(() => Promise.resolve()), writeExif: vitest.fn().mockImplementation(() => Promise.resolve()), copyTagGroup: vitest.fn().mockImplementation(() => Promise.resolve()), generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')), diff --git a/web/package.json b/web/package.json index 4b3e4587fc..97a0de7d97 100644 --- a/web/package.json +++ b/web/package.json @@ -32,6 +32,7 @@ "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.14.0", + "@photo-sphere-viewer/equirectangular-tiles-adapter": "^5.14.0", "@photo-sphere-viewer/equirectangular-video-adapter": "^5.14.0", "@photo-sphere-viewer/markers-plugin": "^5.14.0", "@photo-sphere-viewer/resolution-plugin": "^5.14.0", diff --git a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte index 08ba43526d..25ce3408d9 100644 --- a/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/image-panorama-viewer.svelte @@ -1,8 +1,7 @@
- {#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])} + {#await import('./photo-sphere-viewer-adapter.svelte')} - {:then [data, { default: PhotoSphereViewer }]} + {:then { default: PhotoSphereViewer }} string | null; + tileconfig?: TileConfig; originalPanorama?: string | { source: string }; adapter?: AdapterConstructor | [AdapterConstructor, unknown]; plugins?: (PluginConstructor | [PluginConstructor, unknown])[]; - navbar?: boolean; zoomToggle?: (() => void) | null; }; let { - panorama, + baseUrl, + tileUrl, + tileconfig, originalPanorama, adapter = EquirectangularAdapter, plugins = [], - navbar = false, zoomToggle = $bindable(), }: Props = $props(); @@ -116,20 +135,32 @@ return; } - viewer = new Viewer({ - adapter, - plugins: [ - MarkersPlugin, - SettingsPlugin, - [ - ResolutionPlugin, - { + if (tileconfig) { + viewer = new Viewer({ + adapter: EquirectangularTilesAdapter, + panorama: { + ...tileconfig, + baseUrl, + tileUrl, + }, + plugins: [MarkersPlugin, ...plugins], + container, + ...SHARED_VIEWER_CONFIG, + }); + } else { + viewer = new Viewer({ + adapter, + panorama: baseUrl, + plugins: [ + MarkersPlugin, + SettingsPlugin, + ResolutionPlugin.withConfig({ defaultResolution: $alwaysLoadOriginalFile && originalPanorama ? 'original' : 'default', resolutions: [ { id: 'default', label: 'Default', - panorama, + panorama: baseUrl, }, ...(originalPanorama ? [ @@ -141,39 +172,34 @@ ] : []), ], - }, + }), + ...plugins, ], - ...plugins, - ], - container, - touchmoveTwoFingers: false, - mousewheelCtrlKey: false, - navbar, - minFov: 15, - maxFov: 90, - zoomSpeed: 0.5, - fisheye: false, - }); - const resolutionPlugin = viewer.getPlugin(ResolutionPlugin); - const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => { - // zoomLevel range: [0, 100] - photoZoomState.set({ - ...$photoZoomState, - currentZoom: zoomLevel / 50, + container, + ...SHARED_VIEWER_CONFIG, }); - if (Math.round(zoomLevel) >= 75 && !hasChangedResolution) { - // Replace the preview with the original - void resolutionPlugin.setResolution('original'); - hasChangedResolution = true; + const resolutionPlugin = viewer.getPlugin(ResolutionPlugin); + const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => { + // zoomLevel range: [0, 100] + photoZoomState.set({ + ...$photoZoomState, + currentZoom: zoomLevel / 50, + }); + + if (Math.round(zoomLevel) >= 75 && !hasChangedResolution) { + // Replace the preview with the original + void resolutionPlugin.setResolution('original'); + hasChangedResolution = true; + } + }; + + if (originalPanorama && !$alwaysLoadOriginalFile) { + viewer.addEventListener(events.ZoomUpdatedEvent.type, zoomHandler, { passive: true }); } - }; - if (originalPanorama && !$alwaysLoadOriginalFile) { - viewer.addEventListener(events.ZoomUpdatedEvent.type, zoomHandler, { passive: true }); + return () => viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler); } - - return () => viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler); }); onDestroy(() => { diff --git a/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte b/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte index 1e765bc92b..b740e292ff 100644 --- a/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-panorama-viewer.svelte @@ -23,11 +23,10 @@ {:then [PhotoSphereViewer, adapter, videoPlugin]} {:catch} {$t('errors.failed_to_load_asset')} diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 100f807273..20d86c188c 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -11,6 +11,7 @@ import { getAssetOriginalPath, getAssetPlaybackPath, getAssetThumbnailPath, + getAssetTilePath, getBaseUrl, getPeopleThumbnailPath, getUserProfileImagePath, @@ -215,6 +216,13 @@ export const getAssetPlaybackUrl = (options: string | AssetUrlOptions) => { return createUrl(getAssetPlaybackPath(id), { ...authManager.params, c: cacheKey }); }; +type TileUrlOptions = { id: string, level: number, col: number, row: number, cacheKey?: string | null }; + +export const getAssetTileUrl = (options: TileUrlOptions) => { + const { id, level, col, row, cacheKey } = options; + return createUrl(getAssetTilePath(id, level, col, row), { ...authManager.params, c: cacheKey }); +} + export const getProfileImageUrl = (user: UserResponseDto) => createUrl(getUserProfileImagePath(user.id), { updatedAt: user.profileChangedAt });