mirror of
https://github.com/immich-app/immich.git
synced 2026-03-19 16:07:57 -04:00
impl tile generation
This commit is contained in:
parent
700707e399
commit
471e2ffee4
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@ -110,7 +110,6 @@ 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* | [**removeAssetEdits**](doc//AssetsApi.md#removeassetedits) | **DELETE** /assets/{id}/edits | Remove edits from an existing asset
|
||||
@ -122,6 +121,7 @@ Class | Method | HTTP request | Description
|
||||
*AssetsApi* | [**updateBulkAssetMetadata**](doc//AssetsApi.md#updatebulkassetmetadata) | **PUT** /assets/metadata | Upsert asset metadata
|
||||
*AssetsApi* | [**uploadAsset**](doc//AssetsApi.md#uploadasset) | **POST** /assets | Upload asset
|
||||
*AssetsApi* | [**viewAsset**](doc//AssetsApi.md#viewasset) | **GET** /assets/{id}/thumbnail | View asset thumbnail
|
||||
*AssetsApi* | [**viewAssetTile**](doc//AssetsApi.md#viewassettile) | **GET** /assets/{id}/tiles/{level}/{col}/{row} | View an asset tile
|
||||
*AuthenticationApi* | [**changePassword**](doc//AuthenticationApi.md#changepassword) | **POST** /auth/change-password | Change password
|
||||
*AuthenticationApi* | [**changePinCode**](doc//AuthenticationApi.md#changepincode) | **PUT** /auth/pin-code | Change pin code
|
||||
*AuthenticationApi* | [**finishOAuth**](doc//AuthenticationApi.md#finishoauth) | **POST** /oauth/callback | Finish OAuth
|
||||
|
||||
174
mobile/openapi/lib/api/assets_api.dart
generated
174
mobile/openapi/lib/api/assets_api.dart
generated
@ -929,93 +929,6 @@ 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.
|
||||
@ -1923,4 +1836,91 @@ class AssetsApi {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// View an asset tile
|
||||
///
|
||||
/// Download a specific tile from an image at the specified level - must currently be 0 - 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> viewAssetTileWithHttpInfo(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,
|
||||
);
|
||||
}
|
||||
|
||||
/// View an asset tile
|
||||
///
|
||||
/// Download a specific tile from an image at the specified level - must currently be 0 - and position
|
||||
///
|
||||
/// Parameters:
|
||||
///
|
||||
/// * [num] col (required):
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [num] level (required):
|
||||
///
|
||||
/// * [num] row (required):
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<MultipartFile?> viewAssetTile(num col, String id, num level, num row, { String? key, String? slug, }) async {
|
||||
final response = await viewAssetTileWithHttpInfo(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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -4399,8 +4399,8 @@
|
||||
},
|
||||
"/assets/{id}/tiles/{level}/{col}/{row}": {
|
||||
"get": {
|
||||
"description": "Download a specific tile from an image at the specified level and position",
|
||||
"operationId": "getAssetTile",
|
||||
"description": "Download a specific tile from an image at the specified level - must currently be 0 - and position",
|
||||
"operationId": "viewAssetTile",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "col",
|
||||
@ -4476,17 +4476,17 @@
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Get an image tile",
|
||||
"summary": "View an asset tile",
|
||||
"tags": [
|
||||
"Assets"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v2.4.0",
|
||||
"version": "v2.7.0",
|
||||
"state": "Added"
|
||||
},
|
||||
{
|
||||
"version": "v2.4.0",
|
||||
"version": "v2.7.0",
|
||||
"state": "Stable"
|
||||
}
|
||||
],
|
||||
|
||||
@ -4314,9 +4314,9 @@ export function viewAsset({ edited, id, key, size, slug }: {
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Get an image tile
|
||||
* View an asset tile
|
||||
*/
|
||||
export function getAssetTile({ col, id, key, level, row, slug }: {
|
||||
export function viewAssetTile({ col, id, key, level, row, slug }: {
|
||||
col: number;
|
||||
id: string;
|
||||
key?: string;
|
||||
|
||||
@ -190,11 +190,11 @@ export class AssetMediaController {
|
||||
@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'),
|
||||
summary: 'View an asset tile',
|
||||
description: 'Download a specific tile from an image at the specified level - must currently be 0 - and position',
|
||||
history: new HistoryBuilder().added('v2.7.0').stable('v2.7.0'),
|
||||
})
|
||||
async getAssetTile(
|
||||
async viewAssetTile(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Param('level', ParseIntPipe) level: number,
|
||||
@ -203,7 +203,10 @@ export class AssetMediaController {
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.getAssetTile(auth, id, level, col, row), this.logger);
|
||||
if (level !== 0) {
|
||||
throw new Error(`Invalid level ${level}`);
|
||||
}
|
||||
await sendFile(res, next, () => this.service.viewAssetTile(auth, id, level, col, row), this.logger);
|
||||
}
|
||||
|
||||
@Get(':id/video/playback')
|
||||
|
||||
@ -9,7 +9,6 @@ import {
|
||||
PersonPathType,
|
||||
RawExtractedFormat,
|
||||
StorageFolder,
|
||||
TilesFormat,
|
||||
} from 'src/enum';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
@ -33,16 +32,9 @@ export interface MoveRequest {
|
||||
};
|
||||
}
|
||||
|
||||
export type GeneratedImageType =
|
||||
| AssetPathType.Thumbnail
|
||||
| AssetPathType.Preview
|
||||
| AssetPathType.FullSize
|
||||
| AssetPathType.Tiles;
|
||||
export type GeneratedAssetType = GeneratedImageType | AssetPathType.EncodedVideo;
|
||||
|
||||
export type ThumbnailPathEntity = { id: string; ownerId: string };
|
||||
|
||||
export type ImagePathOptions = { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat | TilesFormat; isEdited: boolean };
|
||||
export type ImagePathOptions = { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean };
|
||||
|
||||
let instance: StorageCore | null;
|
||||
|
||||
@ -128,6 +120,10 @@ export class StorageCore {
|
||||
);
|
||||
}
|
||||
|
||||
static getTilesFolder(asset: ThumbnailPathEntity) {
|
||||
return StorageCore.getNestedPath(StorageFolder.Thumbnails, asset.ownerId, `${asset.id}_tiles`);
|
||||
}
|
||||
|
||||
static getEncodedVideoPath(asset: ThumbnailPathEntity) {
|
||||
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`);
|
||||
}
|
||||
@ -161,6 +157,16 @@ export class StorageCore {
|
||||
});
|
||||
}
|
||||
|
||||
async moveAssetTiles(asset: StorageAsset) {
|
||||
const oldDir = getAssetFile(asset.files, AssetFileType.Tiles, { isEdited: false });
|
||||
return this.moveFile({
|
||||
entityId: asset.id,
|
||||
pathType: AssetPathType.Tiles,
|
||||
oldPath: oldDir?.path || null,
|
||||
newPath: StorageCore.getTilesFolder(asset),
|
||||
})
|
||||
}
|
||||
|
||||
async moveAssetVideo(asset: StorageAsset) {
|
||||
return this.moveFile({
|
||||
entityId: asset.id,
|
||||
|
||||
@ -373,9 +373,6 @@ export enum ManualJobName {
|
||||
|
||||
export enum AssetPathType {
|
||||
Original = 'original',
|
||||
FullSize = 'fullsize',
|
||||
Preview = 'preview',
|
||||
Thumbnail = 'thumbnail',
|
||||
/** Folder structure containing tiles of the image */
|
||||
Tiles = 'tiles',
|
||||
EncodedVideo = 'encoded_video',
|
||||
@ -463,10 +460,6 @@ export enum RawExtractedFormat {
|
||||
Jxl = 'jxl',
|
||||
}
|
||||
|
||||
export enum TilesFormat {
|
||||
Dz = 'dz',
|
||||
}
|
||||
|
||||
export enum LogLevel {
|
||||
Verbose = 'verbose',
|
||||
Debug = 'debug',
|
||||
|
||||
@ -1128,6 +1128,19 @@ export class AssetRepository {
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getForTiles(id: string) {
|
||||
// TODO: we don't actually need original path and file name. Plain 'select asset_file.path from asset_file where type = tiles and assetId = id;'?
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.where('asset.id', '=', id)
|
||||
.leftJoin('asset_file', (join) =>
|
||||
join.onRef('asset.id', '=', 'asset_file.assetId').on('asset_file.type', '=', AssetFileType.Tiles),
|
||||
)
|
||||
.select(['asset.originalPath', 'asset.originalFileName', 'asset_file.path as path'])
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getForVideo(id: string) {
|
||||
return this.db
|
||||
|
||||
@ -123,6 +123,23 @@ export class MediaRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async copyTagGroup(tagGroup: string, source: string, target: string): Promise<boolean> {
|
||||
try {
|
||||
await exiftool.write(
|
||||
target,
|
||||
{},
|
||||
{
|
||||
ignoreMinorErrors: true,
|
||||
writeArgs: ['-TagsFromFile', source, `-${tagGroup}:all>${tagGroup}:all`, '-overwrite_original'],
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch (error: any) {
|
||||
this.logger.warn(`Could not copy tag data to image: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async decodeImage(input: string | Buffer, options: DecodeToBufferOptions) {
|
||||
const pipeline = await this.getImageDecodingPipeline(input, options);
|
||||
return pipeline.raw().toBuffer({ resolveWithObject: true });
|
||||
@ -166,17 +183,21 @@ export class MediaRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* For output file path 'output.dz', this creates an 'output.dzi' file and 'output_files' directory containing tiles
|
||||
* For output file path 'output.dz', this creates an 'output.dzi' file and 'output_files/0' directory containing tiles
|
||||
*/
|
||||
async generateTiles(input: string | Buffer, options: GenerateThumbnailOptions, output: string): Promise<void> {
|
||||
const pipeline = await this.getImageDecodingPipeline(input, options);
|
||||
// size is intended tile size, don't resize input image.
|
||||
const pipeline = await this.getImageDecodingPipeline(input, { ...options, size: undefined });
|
||||
await pipeline
|
||||
.toFormat(options.format)
|
||||
.toFormat(options.format) // TODO: set quality and chroma ss?
|
||||
.tile({
|
||||
depth: 'one',
|
||||
size: options.size,
|
||||
})
|
||||
.toFile(output);
|
||||
// TODO: move <uuid>_tiles_files/0 dir to <uuid>_tiles
|
||||
// TODO: delete <uuid>_tiles_files/vips-properties.xml
|
||||
// TODO: delete <uuid>_tiles.dzi
|
||||
}
|
||||
|
||||
private async getImageDecodingPipeline(input: string | Buffer, options: DecodeToBufferOptions) {
|
||||
|
||||
@ -720,6 +720,38 @@ describe(AssetMediaService.name, () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAssetTile', () => {
|
||||
it('should require asset.view permissions', async () => {
|
||||
await expect(sut.viewAssetTile(authStub.admin, 'id', 0, 0, 0)).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined);
|
||||
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
});
|
||||
|
||||
it('should throw an error if the asset tiles dir could not be found', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.asset.getForTiles.mockResolvedValue({ ...asset, path: null });
|
||||
|
||||
await expect(sut.viewAssetTile(authStub.admin, asset.id, 0, 0, 0)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('should get tile file', async () => {
|
||||
const asset = AssetFactory.from().file({ type: AssetFileType.Tiles, path: '/path/to/asset_tiles' }).build();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.asset.getForTiles.mockResolvedValue({ ...asset, path: asset.files[0].path });
|
||||
await expect(sut.viewAssetTile(authStub.admin, asset.id, 0, 0, 0)).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: `${asset.files[0].path}_files/0/0_0.jpeg`,
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
contentType: 'image/jpeg',
|
||||
}),
|
||||
);
|
||||
expect(mocks.asset.getForTiles).toHaveBeenCalledWith(asset.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('playbackVideo', () => {
|
||||
it('should require asset.view permissions', async () => {
|
||||
await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
@ -262,31 +262,22 @@ export class AssetMediaService extends BaseService {
|
||||
});
|
||||
}
|
||||
|
||||
async getAssetTile(auth: AuthDto, id: string, level: number, col: number, row: number): Promise<ImmichFileResponse> {
|
||||
async viewAssetTile(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.assetRepository.getForThumbnail(id, AssetFileType.Tiles, false);
|
||||
let tilesPath = undefined; // TODO
|
||||
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,
|
||||
});
|
||||
// TODO: get tile info { width, height } and check against col, row to return NotFound instead of 500 when tile can't be found in sendFile.
|
||||
const { path } = await this.assetRepository.getForTiles(id);
|
||||
|
||||
if (!path) {
|
||||
throw new NotFoundException('Asset tiles not found');
|
||||
}
|
||||
|
||||
tilesPath = { path: 'tmppath' };
|
||||
|
||||
const tileName = getFileNameWithoutExtension(asset.originalFileName) + `_${level}_${col}_${row}.jpg`;
|
||||
const tilePath = tilesPath.path.replace('.dz', '_files') + `/${level}/${col}_${row}.jpg`;
|
||||
// By definition of the tiles format, it's always .jpeg; should ImageFormat.Jpeg be used?
|
||||
const tilePath = `${path}_files/${level}/${col}_${row}.jpeg`;
|
||||
|
||||
return new ImmichFileResponse({
|
||||
fileName: tileName,
|
||||
path: tilePath,
|
||||
contentType: 'image/jpg',
|
||||
contentType: mimeTypes.lookup(tilePath),
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1210,6 +1210,7 @@ describe(MediaService.name, () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } });
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
|
||||
mocks.media.copyTagGroup.mockResolvedValue(true);
|
||||
|
||||
const asset = AssetFactory.from({ originalFileName: 'panorama.tif' })
|
||||
.exif({
|
||||
@ -1244,6 +1245,8 @@ describe(MediaService.name, () => {
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
|
||||
expect(mocks.media.copyTagGroup).toHaveBeenCalledExactlyOnceWith('XMP-GPano', asset.originalPath, expect.any(String));
|
||||
});
|
||||
|
||||
it('should respect encoding options when generating full-size preview', async () => {
|
||||
|
||||
@ -19,7 +19,6 @@ import {
|
||||
QueueName,
|
||||
RawExtractedFormat,
|
||||
StorageFolder,
|
||||
TilesFormat,
|
||||
TranscodeHardwareAcceleration,
|
||||
TranscodePolicy,
|
||||
TranscodeTarget,
|
||||
@ -34,6 +33,7 @@ import {
|
||||
DecodeToBufferOptions,
|
||||
GenerateThumbnailOptions,
|
||||
ImageDimensions,
|
||||
ImageOptions,
|
||||
JobItem,
|
||||
JobOf,
|
||||
VideoFormat,
|
||||
@ -56,15 +56,6 @@ interface UpsertFileOptions {
|
||||
isTransparent: boolean;
|
||||
}
|
||||
|
||||
interface TileInfo {
|
||||
path: string;
|
||||
info: {
|
||||
width: number;
|
||||
cols: number;
|
||||
rows: number;
|
||||
};
|
||||
}
|
||||
|
||||
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
|
||||
|
||||
@Injectable()
|
||||
@ -173,8 +164,7 @@ export class MediaService extends BaseService {
|
||||
await this.storageCore.moveAssetImage(asset, AssetFileType.FullSize, image.fullsize.format);
|
||||
await this.storageCore.moveAssetImage(asset, AssetFileType.Preview, image.preview.format);
|
||||
await this.storageCore.moveAssetImage(asset, AssetFileType.Thumbnail, image.thumbnail.format);
|
||||
// TODO:
|
||||
// await this.storageCore.moveAssetImage(asset, AssetFileType.Tiles, image.???.format);
|
||||
await this.storageCore.moveAssetTiles(asset);
|
||||
await this.storageCore.moveAssetVideo(asset);
|
||||
|
||||
return JobStatus.Success;
|
||||
@ -288,8 +278,8 @@ export class MediaService extends BaseService {
|
||||
const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName);
|
||||
const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null;
|
||||
const generateFullsize =
|
||||
((image.fullsize.enabled || asset.exifInfo.projectionType === 'EQUIRECTANGULAR') &&
|
||||
!mimeTypes.isWebSupportedImage(asset.originalPath)) ||
|
||||
(image.fullsize.enabled && !mimeTypes.isWebSupportedImage(asset.originalPath)) ||
|
||||
asset.exifInfo.projectionType === 'EQUIRECTANGULAR' ||
|
||||
useEdits;
|
||||
const convertFullsize = generateFullsize && (!extracted || !mimeTypes.isWebSupportedImage(` .${extracted.format}`));
|
||||
|
||||
@ -396,38 +386,47 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
|
||||
// TODO: probably extract to helper method
|
||||
let tileInfo: TileInfo | undefined;
|
||||
// TODO: handle cropped panoramas. Tile as normal but save some offset?
|
||||
let tileInfo: UpsertFileOptions | undefined;
|
||||
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') {
|
||||
// TODO: get uncropped width from asset (FullPanoWidthPixels if present).
|
||||
const originalSize = 12_988;
|
||||
// TODO: get uncropped width from asset (FullPanoWidthPixels if present). -> TODO find out why i wrote this down as a todo
|
||||
const originalSize = asset.exifInfo.exifImageWidth!;
|
||||
|
||||
// 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 = {
|
||||
...previewOptions,
|
||||
tileInfo = {
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.Tiles,
|
||||
path: StorageCore.getTilesFolder(asset),
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
};
|
||||
const tilesOptions = {
|
||||
...baseOptions,
|
||||
quality: image.preview.quality,
|
||||
format: ImageFormat.Jpeg,
|
||||
size: tileSize,
|
||||
};
|
||||
promises.push(this.mediaRepository.generateTiles(data, tilesOptions, tileInfo.path));
|
||||
|
||||
tileInfo = {
|
||||
path: StorageCore.getImagePath(asset, { fileType: AssetFileType.Tiles, format: TilesFormat.Dz, isEdited: useEdits }),
|
||||
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;
|
||||
console.log('Tile info for DB:', {
|
||||
width: originalSize,
|
||||
cols: numTiles,
|
||||
rows: numTiles / 2,
|
||||
});
|
||||
}
|
||||
|
||||
const outputs = await Promise.all(promises);
|
||||
|
||||
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') {
|
||||
await this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewFile.path);
|
||||
}
|
||||
|
||||
const decodedDimensions = { width: info.width, height: info.height };
|
||||
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
|
||||
const files = [previewFile, thumbnailFile];
|
||||
@ -435,8 +434,7 @@ export class MediaService extends BaseService {
|
||||
files.push(fullsizeFile);
|
||||
}
|
||||
if (tileInfo) {
|
||||
console.warn('TODO: should push tile info to files');
|
||||
// files.push(tileInfo);
|
||||
files.push(tileInfo);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@ -53,6 +53,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
|
||||
getForOriginal: vitest.fn(),
|
||||
getForOriginals: vitest.fn(),
|
||||
getForThumbnail: vitest.fn(),
|
||||
getForTiles: vitest.fn(),
|
||||
getForVideo: vitest.fn(),
|
||||
getForEdit: vitest.fn(),
|
||||
getForOcr: vitest.fn(),
|
||||
|
||||
@ -14,16 +14,18 @@
|
||||
|
||||
const assetId = $derived(asset.id);
|
||||
|
||||
const tileconfig = $derived(
|
||||
asset.id === '6e899018-32fe-4fd5-b6ac-b3a525b8e61f'
|
||||
? {
|
||||
width: 12_988,
|
||||
// height: 35,
|
||||
cols: 16,
|
||||
rows: 8,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
// TODO: get this via asset.tiles or whatever through the API
|
||||
const tileconfig = $derived.by(() => {
|
||||
// Get the number of tiles at the exact target size, rounded up (to at least 1 tile).
|
||||
const numTilesExact = Math.ceil(asset.exifInfo?.exifImageWidth! / 1024);
|
||||
// Then round up to the nearest power of 2 (photo-sphere-viewer requirement).
|
||||
const numTiles = Math.pow(2, Math.ceil(Math.log2(numTilesExact)));
|
||||
return {
|
||||
width: asset.exifInfo?.exifImageWidth!,
|
||||
cols: numTiles,
|
||||
rows: numTiles / 2,
|
||||
}
|
||||
});
|
||||
|
||||
const loadAssetData = async (id: string) => {
|
||||
const data = await viewAsset({ ...authManager.params, id, size: AssetMediaSize.Preview });
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user