mirror of
https://github.com/immich-app/immich.git
synced 2025-12-21 01:11:16 +03:00
Merge branch 'feat/panorama-tiling' into feat/panorama-tiles
This commit is contained in:
1
mobile/openapi/README.md
generated
1
mobile/openapi/README.md
generated
@@ -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* | [**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* | [**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* | [**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* | [**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* | [**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
|
*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace asset
|
||||||
|
|||||||
87
mobile/openapi/lib/api/assets_api.dart
generated
87
mobile/openapi/lib/api/assets_api.dart
generated
@@ -738,6 +738,93 @@ class AssetsApi {
|
|||||||
return null;
|
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<Response> 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 = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
if (key != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'key', key));
|
||||||
|
}
|
||||||
|
if (slug != null) {
|
||||||
|
queryParams.addAll(_queryParams('', 'slug', slug));
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
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<MultipartFile?> 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
|
/// Get random assets
|
||||||
///
|
///
|
||||||
/// Retrieve a specified number of random assets for the authenticated user.
|
/// Retrieve a specified number of random assets for the authenticated user.
|
||||||
|
|||||||
@@ -3756,6 +3756,103 @@
|
|||||||
"x-immich-state": "Stable"
|
"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": {
|
"/assets/{id}/video/playback": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Streams the video file for the specified asset. This endpoint also supports byte range requests.",
|
"description": "Streams the video file for the specified asset. This endpoint also supports byte range requests.",
|
||||||
|
|||||||
@@ -2652,6 +2652,27 @@ export function viewAsset({ id, key, size, slug }: {
|
|||||||
...opts
|
...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
|
* Play asset video
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ export const getAssetThumbnailPath = (id: string) => `/assets/${id}/thumbnail`;
|
|||||||
export const getAssetPlaybackPath = (id: string) =>
|
export const getAssetPlaybackPath = (id: string) =>
|
||||||
`/assets/${id}/video/playback`;
|
`/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) =>
|
export const getUserProfileImagePath = (userId: string) =>
|
||||||
`/users/${userId}/profile-image`;
|
`/users/${userId}/profile-image`;
|
||||||
|
|
||||||
|
|||||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@@ -728,6 +728,9 @@ importers:
|
|||||||
'@photo-sphere-viewer/core':
|
'@photo-sphere-viewer/core':
|
||||||
specifier: ^5.14.0
|
specifier: ^5.14.0
|
||||||
version: 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':
|
'@photo-sphere-viewer/equirectangular-video-adapter':
|
||||||
specifier: ^5.14.0
|
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))
|
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':
|
'@photo-sphere-viewer/core@5.14.0':
|
||||||
resolution: {integrity: sha512-V0JeDSB1D2Q60Zqn7+0FPjq8gqbKEwuxMzNdTLydefkQugVztLvdZykO+4k5XTpweZ2QAWPH/QOI1xZbsdvR9A==}
|
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':
|
'@photo-sphere-viewer/equirectangular-video-adapter@5.14.0':
|
||||||
resolution: {integrity: sha512-Ez88sZ4sj3fONpZSortnN3gLXlvV/hn5U/88LsWtxI73YwhkZ06ZtXFYLXU4MBaJvqCbMGaR6j39uVXTWFo5rw==}
|
resolution: {integrity: sha512-Ez88sZ4sj3fONpZSortnN3gLXlvV/hn5U/88LsWtxI73YwhkZ06ZtXFYLXU4MBaJvqCbMGaR6j39uVXTWFo5rw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -15579,6 +15587,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
three: 0.179.1
|
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))':
|
'@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:
|
dependencies:
|
||||||
'@photo-sphere-viewer/core': 5.14.0
|
'@photo-sphere-viewer/core': 5.14.0
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export const LOGIN_URL = '/auth/login?autoLaunch=0';
|
|||||||
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico'];
|
export const excludePaths = ['/.well-known/immich', '/custom.css', '/favicon.ico'];
|
||||||
|
|
||||||
export const FACE_THUMBNAIL_SIZE = 250;
|
export const FACE_THUMBNAIL_SIZE = 250;
|
||||||
|
export const TILE_TARGET_SIZE = 1024;
|
||||||
|
|
||||||
type ModelInfo = { dimSize: number };
|
type ModelInfo = { dimSize: number };
|
||||||
export const CLIP_MODEL_INFO: Record<string, ModelInfo> = {
|
export const CLIP_MODEL_INFO: Record<string, ModelInfo> = {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Next,
|
Next,
|
||||||
Param,
|
Param,
|
||||||
ParseFilePipe,
|
ParseFilePipe,
|
||||||
|
ParseIntPipe,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Query,
|
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')
|
@Get(':id/video/playback')
|
||||||
@FileResponse()
|
@FileResponse()
|
||||||
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
|
@Authenticated({ permission: Permission.AssetView, sharedLink: true })
|
||||||
|
|||||||
@@ -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 GeneratedAssetType = GeneratedImageType | AssetPathType.EncodedVideo;
|
||||||
|
|
||||||
export type ThumbnailPathEntity = { id: string; ownerId: string };
|
export type ThumbnailPathEntity = { id: string; ownerId: string };
|
||||||
@@ -105,7 +109,7 @@ export class StorageCore {
|
|||||||
return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`);
|
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}`);
|
return StorageCore.getNestedPath(StorageFolder.Thumbnails, asset.ownerId, `${asset.id}-${type}.${format}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export enum AssetFileType {
|
|||||||
Preview = 'preview',
|
Preview = 'preview',
|
||||||
Thumbnail = 'thumbnail',
|
Thumbnail = 'thumbnail',
|
||||||
Sidecar = 'sidecar',
|
Sidecar = 'sidecar',
|
||||||
|
/** Folder structure containing tiles of the image */
|
||||||
|
Tiles = 'tiles',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AlbumUserRole {
|
export enum AlbumUserRole {
|
||||||
@@ -359,6 +361,8 @@ export enum AssetPathType {
|
|||||||
FullSize = 'fullsize',
|
FullSize = 'fullsize',
|
||||||
Preview = 'preview',
|
Preview = 'preview',
|
||||||
Thumbnail = 'thumbnail',
|
Thumbnail = 'thumbnail',
|
||||||
|
/** Folder structure containing tiles of the image */
|
||||||
|
Tiles = 'tiles',
|
||||||
EncodedVideo = 'encoded_video',
|
EncodedVideo = 'encoded_video',
|
||||||
Sidecar = 'sidecar',
|
Sidecar = 'sidecar',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,19 @@ export class MediaRepository {
|
|||||||
.toFile(output);
|
.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<void> {
|
||||||
|
await this.getImageDecodingPipeline(input, options)
|
||||||
|
.toFormat(options.format)
|
||||||
|
.tile({
|
||||||
|
depth: 'one',
|
||||||
|
size: options.size,
|
||||||
|
})
|
||||||
|
.toFile(output);
|
||||||
|
}
|
||||||
|
|
||||||
private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
|
private getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
|
||||||
let pipeline = sharp(input, {
|
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
|
// some invalid images can still be processed by sharp, but we want to fail on them by default to avoid crashes
|
||||||
|
|||||||
@@ -248,6 +248,33 @@ export class AssetMediaService extends BaseService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAssetTile(auth: AuthDto, id: string, level: number, col: number, row: number): Promise<ImmichFileResponse> {
|
||||||
|
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<ImmichFileResponse> {
|
async playbackVideo(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
||||||
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
|
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
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 { StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
||||||
import { Exif } from 'src/database';
|
import { Exif } from 'src/database';
|
||||||
import { OnEvent, OnJob } from 'src/decorators';
|
import { OnEvent, OnJob } from 'src/decorators';
|
||||||
@@ -47,6 +47,15 @@ interface UpsertFileOptions {
|
|||||||
path: string;
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TileInfo {
|
||||||
|
path: string;
|
||||||
|
info: {
|
||||||
|
width: number;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MediaService extends BaseService {
|
export class MediaService extends BaseService {
|
||||||
videoInterfaces: VideoInterfaces = { dri: [], mali: false };
|
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.FullSize, image.fullsize.format);
|
||||||
await this.storageCore.moveAssetImage(asset, AssetPathType.Preview, image.preview.format);
|
await this.storageCore.moveAssetImage(asset, AssetPathType.Preview, image.preview.format);
|
||||||
await this.storageCore.moveAssetImage(asset, AssetPathType.Thumbnail, image.thumbnail.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);
|
await this.storageCore.moveAssetVideo(asset);
|
||||||
|
|
||||||
return JobStatus.Success;
|
return JobStatus.Success;
|
||||||
@@ -171,6 +182,7 @@ export class MediaService extends BaseService {
|
|||||||
previewPath: string;
|
previewPath: string;
|
||||||
thumbnailPath: string;
|
thumbnailPath: string;
|
||||||
fullsizePath?: string;
|
fullsizePath?: string;
|
||||||
|
tileInfo?: TileInfo;
|
||||||
thumbhash: Buffer;
|
thumbhash: Buffer;
|
||||||
};
|
};
|
||||||
if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) {
|
if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) {
|
||||||
@@ -184,7 +196,7 @@ export class MediaService extends BaseService {
|
|||||||
return JobStatus.Skipped;
|
return JobStatus.Skipped;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { previewFile, thumbnailFile, fullsizeFile } = getAssetFiles(asset.files);
|
const { previewFile, thumbnailFile, fullsizeFile, tilesPath } = getAssetFiles(asset.files);
|
||||||
const toUpsert: UpsertFileOptions[] = [];
|
const toUpsert: UpsertFileOptions[] = [];
|
||||||
if (previewFile?.path !== generated.previewPath) {
|
if (previewFile?.path !== generated.previewPath) {
|
||||||
toUpsert.push({ assetId: asset.id, path: generated.previewPath, type: AssetFileType.Preview });
|
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 });
|
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) {
|
if (toUpsert.length > 0) {
|
||||||
await this.assetRepository.upsertFiles(toUpsert);
|
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) {
|
if (pathsToDelete.length > 0) {
|
||||||
await Promise.all(pathsToDelete.map((path) => this.storageRepository.unlink(path)));
|
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);
|
const outputs = await Promise.all(promises);
|
||||||
|
|
||||||
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') {
|
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') {
|
||||||
@@ -328,7 +384,7 @@ export class MediaService extends BaseService {
|
|||||||
await Promise.all(promises);
|
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 })
|
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const getAssetFiles = (files: AssetFile[]) => ({
|
|||||||
previewFile: getAssetFile(files, AssetFileType.Preview),
|
previewFile: getAssetFile(files, AssetFileType.Preview),
|
||||||
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail),
|
thumbnailFile: getAssetFile(files, AssetFileType.Thumbnail),
|
||||||
sidecarFile: getAssetFile(files, AssetFileType.Sidecar),
|
sidecarFile: getAssetFile(files, AssetFileType.Sidecar),
|
||||||
|
tilesPath: getAssetFile(files, AssetFileType.Tiles),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const addAssets = async (
|
export const addAssets = async (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Mocked, vitest } from 'vitest';
|
|||||||
export const newMediaRepositoryMock = (): Mocked<RepositoryInterface<MediaRepository>> => {
|
export const newMediaRepositoryMock = (): Mocked<RepositoryInterface<MediaRepository>> => {
|
||||||
return {
|
return {
|
||||||
generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()),
|
generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||||
|
generateTiles: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||||
writeExif: vitest.fn().mockImplementation(() => Promise.resolve()),
|
writeExif: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||||
copyTagGroup: vitest.fn().mockImplementation(() => Promise.resolve()),
|
copyTagGroup: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||||
generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')),
|
generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')),
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@photo-sphere-viewer/core": "^5.14.0",
|
"@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/equirectangular-video-adapter": "^5.14.0",
|
||||||
"@photo-sphere-viewer/markers-plugin": "^5.14.0",
|
"@photo-sphere-viewer/markers-plugin": "^5.14.0",
|
||||||
"@photo-sphere-viewer/resolution-plugin": "^5.14.0",
|
"@photo-sphere-viewer/resolution-plugin": "^5.14.0",
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { getAssetOriginalUrl, getAssetThumbnailUrl, getAssetTileUrl } from '$lib/utils';
|
||||||
import { getAssetOriginalUrl, getAssetThumbnailUrl } from '$lib/utils';
|
|
||||||
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
import { isWebCompatibleImage } from '$lib/utils/asset-utils';
|
||||||
import { AssetMediaSize, viewAsset, type AssetResponseDto } from '@immich/sdk';
|
import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
|
||||||
import { LoadingSpinner } from '@immich/ui';
|
import { LoadingSpinner } from '@immich/ui';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
import { fade } from 'svelte/transition';
|
import { fade } from 'svelte/transition';
|
||||||
@@ -14,19 +13,31 @@
|
|||||||
|
|
||||||
let { asset, zoomToggle = $bindable() }: Props = $props();
|
let { asset, zoomToggle = $bindable() }: Props = $props();
|
||||||
|
|
||||||
const loadAssetData = async (id: string) => {
|
const tileconfig =
|
||||||
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
|
asset.id === '6e899018-32fe-4fd5-b6ac-b3a525b8e61f'
|
||||||
return URL.createObjectURL(data);
|
? {
|
||||||
};
|
width: 12_988,
|
||||||
|
// height: 35,
|
||||||
|
cols: 16,
|
||||||
|
rows: 8,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const baseUrl = getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Preview, cacheKey: asset.thumbhash });
|
||||||
|
// TODO: determine whether to return null based on 1. if asset has tiles, 2. if tile is inside 'cropped' bounds.
|
||||||
|
const tileUrl = (col: number, row: number, level: number) =>
|
||||||
|
tileconfig ? getAssetTileUrl({ id: asset.id, level, col, row, cacheKey: asset.thumbhash }) : null;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
|
<div transition:fade={{ duration: 150 }} class="flex h-full select-none place-content-center place-items-center">
|
||||||
{#await Promise.all([loadAssetData(asset.id), import('./photo-sphere-viewer-adapter.svelte')])}
|
{#await import('./photo-sphere-viewer-adapter.svelte')}
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
{:then [data, { default: PhotoSphereViewer }]}
|
{:then { default: PhotoSphereViewer }}
|
||||||
<PhotoSphereViewer
|
<PhotoSphereViewer
|
||||||
bind:zoomToggle
|
bind:zoomToggle
|
||||||
panorama={data}
|
{baseUrl}
|
||||||
|
{tileUrl}
|
||||||
|
{tileconfig}
|
||||||
originalPanorama={isWebCompatibleImage(asset)
|
originalPanorama={isWebCompatibleImage(asset)
|
||||||
? getAssetOriginalUrl(asset.id)
|
? getAssetOriginalUrl(asset.id)
|
||||||
: getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Fullsize, cacheKey: asset.thumbhash })}
|
: getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Fullsize, cacheKey: asset.thumbhash })}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
type PluginConstructor,
|
type PluginConstructor,
|
||||||
} from '@photo-sphere-viewer/core';
|
} from '@photo-sphere-viewer/core';
|
||||||
import '@photo-sphere-viewer/core/index.css';
|
import '@photo-sphere-viewer/core/index.css';
|
||||||
|
import { EquirectangularTilesAdapter } from '@photo-sphere-viewer/equirectangular-tiles-adapter';
|
||||||
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
|
import { MarkersPlugin } from '@photo-sphere-viewer/markers-plugin';
|
||||||
import '@photo-sphere-viewer/markers-plugin/index.css';
|
import '@photo-sphere-viewer/markers-plugin/index.css';
|
||||||
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
|
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
|
||||||
@@ -26,21 +27,39 @@
|
|||||||
strokeLinejoin: 'round',
|
strokeLinejoin: 'round',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const SHARED_VIEWER_CONFIG = {
|
||||||
|
touchmoveTwoFingers: false,
|
||||||
|
mousewheelCtrlKey: false,
|
||||||
|
navbar: false,
|
||||||
|
minFov: 15,
|
||||||
|
maxFov: 90,
|
||||||
|
zoomSpeed: 0.5,
|
||||||
|
fisheye: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
type TileConfig = {
|
||||||
|
width: number;
|
||||||
|
cols: number;
|
||||||
|
rows: number;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
panorama: string | { source: string };
|
baseUrl: string | { source: string };
|
||||||
|
tileUrl?: (col: number, row: number, level: number) => string | null;
|
||||||
|
tileconfig?: TileConfig;
|
||||||
originalPanorama?: string | { source: string };
|
originalPanorama?: string | { source: string };
|
||||||
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
|
adapter?: AdapterConstructor | [AdapterConstructor, unknown];
|
||||||
plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
|
plugins?: (PluginConstructor | [PluginConstructor, unknown])[];
|
||||||
navbar?: boolean;
|
|
||||||
zoomToggle?: (() => void) | null;
|
zoomToggle?: (() => void) | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
panorama,
|
baseUrl,
|
||||||
|
tileUrl,
|
||||||
|
tileconfig,
|
||||||
originalPanorama,
|
originalPanorama,
|
||||||
adapter = EquirectangularAdapter,
|
adapter = EquirectangularAdapter,
|
||||||
plugins = [],
|
plugins = [],
|
||||||
navbar = false,
|
|
||||||
zoomToggle = $bindable(),
|
zoomToggle = $bindable(),
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
@@ -116,20 +135,32 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
viewer = new Viewer({
|
if (tileconfig) {
|
||||||
adapter,
|
viewer = new Viewer({
|
||||||
plugins: [
|
adapter: EquirectangularTilesAdapter,
|
||||||
MarkersPlugin,
|
panorama: {
|
||||||
SettingsPlugin,
|
...tileconfig,
|
||||||
[
|
baseUrl,
|
||||||
ResolutionPlugin,
|
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',
|
defaultResolution: $alwaysLoadOriginalFile && originalPanorama ? 'original' : 'default',
|
||||||
resolutions: [
|
resolutions: [
|
||||||
{
|
{
|
||||||
id: 'default',
|
id: 'default',
|
||||||
label: 'Default',
|
label: 'Default',
|
||||||
panorama,
|
panorama: baseUrl,
|
||||||
},
|
},
|
||||||
...(originalPanorama
|
...(originalPanorama
|
||||||
? [
|
? [
|
||||||
@@ -141,39 +172,34 @@
|
|||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
],
|
],
|
||||||
},
|
}),
|
||||||
|
...plugins,
|
||||||
],
|
],
|
||||||
...plugins,
|
container,
|
||||||
],
|
...SHARED_VIEWER_CONFIG,
|
||||||
container,
|
|
||||||
touchmoveTwoFingers: false,
|
|
||||||
mousewheelCtrlKey: false,
|
|
||||||
navbar,
|
|
||||||
minFov: 15,
|
|
||||||
maxFov: 90,
|
|
||||||
zoomSpeed: 0.5,
|
|
||||||
fisheye: false,
|
|
||||||
});
|
|
||||||
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
|
|
||||||
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
|
||||||
// zoomLevel range: [0, 100]
|
|
||||||
photoZoomState.set({
|
|
||||||
...$photoZoomState,
|
|
||||||
currentZoom: zoomLevel / 50,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (Math.round(zoomLevel) >= 75 && !hasChangedResolution) {
|
const resolutionPlugin = viewer.getPlugin<ResolutionPlugin>(ResolutionPlugin);
|
||||||
// Replace the preview with the original
|
const zoomHandler = ({ zoomLevel }: events.ZoomUpdatedEvent) => {
|
||||||
void resolutionPlugin.setResolution('original');
|
// zoomLevel range: [0, 100]
|
||||||
hasChangedResolution = true;
|
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) {
|
return () => viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
|
||||||
viewer.addEventListener(events.ZoomUpdatedEvent.type, zoomHandler, { passive: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => viewer.removeEventListener(events.ZoomUpdatedEvent.type, zoomHandler);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
|
|||||||
@@ -23,11 +23,10 @@
|
|||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
{:then [PhotoSphereViewer, adapter, videoPlugin]}
|
{:then [PhotoSphereViewer, adapter, videoPlugin]}
|
||||||
<PhotoSphereViewer
|
<PhotoSphereViewer
|
||||||
panorama={{ source: getAssetPlaybackUrl(assetId) }}
|
baseUrl={{ source: getAssetPlaybackUrl(assetId) }}
|
||||||
originalPanorama={{ source: getAssetOriginalUrl(assetId) }}
|
originalPanorama={{ source: getAssetOriginalUrl(assetId) }}
|
||||||
plugins={[videoPlugin]}
|
plugins={[videoPlugin]}
|
||||||
{adapter}
|
{adapter}
|
||||||
navbar
|
|
||||||
/>
|
/>
|
||||||
{:catch}
|
{:catch}
|
||||||
{$t('errors.failed_to_load_asset')}
|
{$t('errors.failed_to_load_asset')}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getAssetOriginalPath,
|
getAssetOriginalPath,
|
||||||
getAssetPlaybackPath,
|
getAssetPlaybackPath,
|
||||||
getAssetThumbnailPath,
|
getAssetThumbnailPath,
|
||||||
|
getAssetTilePath,
|
||||||
getBaseUrl,
|
getBaseUrl,
|
||||||
getPeopleThumbnailPath,
|
getPeopleThumbnailPath,
|
||||||
getUserProfileImagePath,
|
getUserProfileImagePath,
|
||||||
@@ -215,6 +216,13 @@ export const getAssetPlaybackUrl = (options: string | AssetUrlOptions) => {
|
|||||||
return createUrl(getAssetPlaybackPath(id), { ...authManager.params, c: cacheKey });
|
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) =>
|
export const getProfileImageUrl = (user: UserResponseDto) =>
|
||||||
createUrl(getUserProfileImagePath(user.id), { updatedAt: user.profileChangedAt });
|
createUrl(getUserProfileImagePath(user.id), { updatedAt: user.profileChangedAt });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user