Compare commits

...

3 Commits

Author SHA1 Message Date
Mees Frensel
0cc420007f Merge branch 'feat/panorama-tiling' into feat/panorama-tiles 2025-12-19 13:01:23 +01:00
Alex
125de91c71 fix: merged video in On This Device played with incorrect dimension (#24656)
* fix: merged video in On This Device played with incorrect dimension

* chore: pr feedback
2025-12-18 20:59:58 -06:00
Mees Frensel
4348d10ea2 wip: panorama tiling 2025-12-18 14:31:24 +01:00
22 changed files with 530 additions and 90 deletions

View File

@@ -6,6 +6,8 @@ import 'package:immich_mobile/infrastructure/repositories/local_asset.repository
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/utils/exif.converter.dart';
typedef _AssetVideoDimension = ({double? width, double? height, bool isFlipped});
class AssetService {
final RemoteAssetRepository _remoteAssetRepository;
final DriftLocalAssetRepository _localAssetRepository;
@@ -58,44 +60,48 @@ class AssetService {
}
Future<double> getAspectRatio(BaseAsset asset) async {
bool isFlipped;
double? width;
double? height;
final dimension = asset is LocalAsset
? await _getLocalAssetDimensions(asset)
: await _getRemoteAssetDimensions(asset as RemoteAsset);
if (asset.hasRemote) {
final exif = await getExif(asset);
isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else if (asset is LocalAsset) {
isFlipped = CurrentPlatform.isAndroid && (asset.orientation == 90 || asset.orientation == 270);
width = asset.width?.toDouble();
height = asset.height?.toDouble();
} else {
isFlipped = false;
if (dimension.width == null || dimension.height == null || dimension.height == 0) {
return 1.0;
}
return dimension.isFlipped ? dimension.height! / dimension.width! : dimension.width! / dimension.height!;
}
Future<_AssetVideoDimension> _getLocalAssetDimensions(LocalAsset asset) async {
double? width = asset.width?.toDouble();
double? height = asset.height?.toDouble();
int orientation = asset.orientation;
if (width == null || height == null) {
if (asset.hasRemote) {
final id = asset is LocalAsset ? asset.remoteId! : (asset as RemoteAsset).id;
final remoteAsset = await _remoteAssetRepository.get(id);
width = remoteAsset?.width?.toDouble();
height = remoteAsset?.height?.toDouble();
} else {
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).localId!;
final localAsset = await _localAssetRepository.get(id);
width = localAsset?.width?.toDouble();
height = localAsset?.height?.toDouble();
}
final fetched = await _localAssetRepository.get(asset.id);
width = fetched?.width?.toDouble();
height = fetched?.height?.toDouble();
orientation = fetched?.orientation ?? 0;
}
final orientedWidth = isFlipped ? height : width;
final orientedHeight = isFlipped ? width : height;
if (orientedWidth != null && orientedHeight != null && orientedHeight > 0) {
return orientedWidth / orientedHeight;
// On Android, local assets need orientation correction for 90°/270° rotations
// On iOS, the Photos framework pre-corrects dimensions
final isFlipped = CurrentPlatform.isAndroid && (orientation == 90 || orientation == 270);
return (width: width, height: height, isFlipped: isFlipped);
}
Future<_AssetVideoDimension> _getRemoteAssetDimensions(RemoteAsset asset) async {
double? width = asset.width?.toDouble();
double? height = asset.height?.toDouble();
if (width == null || height == null) {
final fetched = await _remoteAssetRepository.get(asset.id);
width = fetched?.width?.toDouble();
height = fetched?.height?.toDouble();
}
return 1.0;
final exif = await getExif(asset);
final isFlipped = ExifDtoConverter.isOrientationFlipped(exif?.orientation);
return (width: width, height: height, isFlipped: isFlipped);
}
Future<List<(String, String)>> getPlaces(String userId) {

View File

@@ -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

View File

@@ -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<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
///
/// Retrieve a specified number of random assets for the authenticated user.

View File

@@ -87,6 +87,25 @@ void main() {
verify(() => mockLocalAssetRepository.get('local-1')).called(1);
});
test('uses fetched asset orientation when dimensions are missing on Android', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
// Original asset has default orientation 0, but dimensions are missing
final localAsset = TestUtils.createLocalAsset(id: 'local-1', width: null, height: null, orientation: 0);
// Fetched asset has 90° orientation and proper dimensions
final fetchedAsset = TestUtils.createLocalAsset(id: 'local-1', width: 1920, height: 1080, orientation: 90);
when(() => mockLocalAssetRepository.get('local-1')).thenAnswer((_) async => fetchedAsset);
final result = await sut.getAspectRatio(localAsset);
// Should flip dimensions since fetched asset has 90° orientation
expect(result, 1080 / 1920);
verify(() => mockLocalAssetRepository.get('local-1')).called(1);
});
test('returns 1.0 when dimensions are still unavailable after fetching', () async {
final remoteAsset = TestUtils.createRemoteAsset(id: 'remote-1', width: null, height: null);
@@ -112,7 +131,9 @@ void main() {
expect(result, 1.0);
});
test('handles local asset with remoteId and uses exif from remote', () async {
test('handles local asset with remoteId using local orientation not remote exif', () async {
// When a LocalAsset has a remoteId (merged), we should use local orientation
// because the width/height come from the local asset (pre-corrected on iOS)
final localAsset = TestUtils.createLocalAsset(
id: 'local-1',
remoteId: 'remote-1',
@@ -121,9 +142,24 @@ void main() {
orientation: 0,
);
final exif = const ExifInfo(orientation: '6');
final result = await sut.getAspectRatio(localAsset);
when(() => mockRemoteAssetRepository.getExif('remote-1')).thenAnswer((_) async => exif);
expect(result, 1920 / 1080);
// Should not call remote exif for LocalAsset
verifyNever(() => mockRemoteAssetRepository.getExif(any()));
});
test('handles local asset with remoteId and 90 degree rotation on Android', () async {
debugDefaultTargetPlatformOverride = TargetPlatform.android;
addTearDown(() => debugDefaultTargetPlatformOverride = null);
final localAsset = TestUtils.createLocalAsset(
id: 'local-1',
remoteId: 'remote-1',
width: 1920,
height: 1080,
orientation: 90,
);
final result = await sut.getAspectRatio(localAsset);

View File

@@ -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.",

View File

@@ -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
*/

View File

@@ -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`;

12
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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<string, ModelInfo> = {

View File

@@ -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 })

View File

@@ -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}`);
}

View File

@@ -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',
}

View File

@@ -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<void> {
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

View File

@@ -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> {
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });

View File

@@ -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 })

View File

@@ -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 (

View File

@@ -5,6 +5,7 @@ import { Mocked, vitest } from 'vitest';
export const newMediaRepositoryMock = (): Mocked<RepositoryInterface<MediaRepository>> => {
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('')),

View File

@@ -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",

View File

@@ -1,8 +1,7 @@
<script lang="ts">
import { authManager } from '$lib/managers/auth-manager.svelte';
import { getAssetOriginalUrl, getAssetThumbnailUrl } from '$lib/utils';
import { getAssetOriginalUrl, getAssetThumbnailUrl, getAssetTileUrl } from '$lib/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 { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
@@ -14,19 +13,31 @@
let { asset, zoomToggle = $bindable() }: Props = $props();
const loadAssetData = async (id: string) => {
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
return URL.createObjectURL(data);
};
const tileconfig =
asset.id === '6e899018-32fe-4fd5-b6ac-b3a525b8e61f'
? {
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>
<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 />
{:then [data, { default: PhotoSphereViewer }]}
{:then { default: PhotoSphereViewer }}
<PhotoSphereViewer
bind:zoomToggle
panorama={data}
{baseUrl}
{tileUrl}
{tileconfig}
originalPanorama={isWebCompatibleImage(asset)
? getAssetOriginalUrl(asset.id)
: getAssetThumbnailUrl({ id: asset.id, size: AssetMediaSize.Fullsize, cacheKey: asset.thumbhash })}

View File

@@ -11,6 +11,7 @@
type PluginConstructor,
} from '@photo-sphere-viewer/core';
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 '@photo-sphere-viewer/markers-plugin/index.css';
import { ResolutionPlugin } from '@photo-sphere-viewer/resolution-plugin';
@@ -26,21 +27,39 @@
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 = {
panorama: string | { source: string };
baseUrl: string | { source: string };
tileUrl?: (col: number, row: number, level: number) => 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>(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>(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(() => {

View File

@@ -23,11 +23,10 @@
<LoadingSpinner />
{:then [PhotoSphereViewer, adapter, videoPlugin]}
<PhotoSphereViewer
panorama={{ source: getAssetPlaybackUrl(assetId) }}
baseUrl={{ source: getAssetPlaybackUrl(assetId) }}
originalPanorama={{ source: getAssetOriginalUrl(assetId) }}
plugins={[videoPlugin]}
{adapter}
navbar
/>
{:catch}
{$t('errors.failed_to_load_asset')}

View File

@@ -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 });