mirror of
https://github.com/immich-app/immich.git
synced 2025-12-19 17:23:21 +03:00
Compare commits
3 Commits
workflow-u
...
feat/panor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cc420007f | ||
|
|
125de91c71 | ||
|
|
4348d10ea2 |
@@ -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) {
|
||||
|
||||
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* | [**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
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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
12
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] });
|
||||
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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('')),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user