impl tile generation

This commit is contained in:
Mees Frensel 2026-03-03 15:11:48 +01:00
parent 700707e399
commit 471e2ffee4
15 changed files with 243 additions and 180 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 () => {

View File

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

View File

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

View File

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