feat(server): MICRO thumbnail tier for dense timeline grids

Adds a small ~128 px thumbnail tier (AssetMediaSize.Micro) so clients can
  populate dense year/month-view grids without downloading and downscaling
  the full 250 px thumbnail per tile. Additive — clients that don't request
  'micro' are unaffected, and the endpoint falls back to 'thumbnail' for
  assets generated before this PR.

  API
  - New enum value AssetMediaSize.Micro = 'micro' on /assets/{id}/thumbnail.
    Existing original/fullsize/preview/thumbnail responses unchanged.
  - Regenerated TS SDK and Dart binding.

  Service
  - MediaService.handleGenerateThumbnails now produces a third encode at
    MICRO_THUMBNAIL_SIZE = 128 px alongside preview + thumbnail in the same
    pass (same format, same colorspace, isProgressive: false).
  - AssetMediaService serves 'micro' when the asset has a Micro file; falls
    back to 'thumbnail' when no micro file exists yet.
  - File lifecycle: micro file participates in edits/moves/deletes parallel
    to preview and thumbnail.

  Tests updated to expect the additional encode + upsertFiles entry; full
  server suite (2148 tests across 87 files) is green.
pull/28696/head
Judah Starkey 2026-05-29 13:52:01 -07:00
parent c42cea5ca9
commit 44d0453ba3
8 changed files with 101 additions and 17 deletions

View File

@ -27,6 +27,7 @@ class AssetMediaSize {
static const fullsize = AssetMediaSize._(r'fullsize');
static const preview = AssetMediaSize._(r'preview');
static const thumbnail = AssetMediaSize._(r'thumbnail');
static const micro = AssetMediaSize._(r'micro');
/// List of all possible values in this [enum][AssetMediaSize].
static const values = <AssetMediaSize>[
@ -34,6 +35,7 @@ class AssetMediaSize {
fullsize,
preview,
thumbnail,
micro,
];
static AssetMediaSize? fromJson(dynamic value) => AssetMediaSizeTypeTransformer().decode(value);
@ -76,6 +78,7 @@ class AssetMediaSizeTypeTransformer {
case r'fullsize': return AssetMediaSize.fullsize;
case r'preview': return AssetMediaSize.preview;
case r'thumbnail': return AssetMediaSize.thumbnail;
case r'micro': return AssetMediaSize.micro;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');

View File

@ -16587,7 +16587,8 @@
"original",
"fullsize",
"preview",
"thumbnail"
"thumbnail",
"micro"
],
"type": "string"
},

View File

@ -7021,7 +7021,8 @@ export enum AssetMediaSize {
Original = "original",
Fullsize = "fullsize",
Preview = "preview",
Thumbnail = "thumbnail"
Thumbnail = "thumbnail",
Micro = "micro"
}
export enum SourceType {
MachineLearning = "machine-learning",

View File

@ -14,6 +14,7 @@ export enum AssetMediaSize {
FULLSIZE = 'fullsize',
PREVIEW = 'preview',
THUMBNAIL = 'thumbnail',
MICRO = 'micro',
}
const AssetMediaSizeSchema = z.enum(AssetMediaSize).describe('Asset media size').meta({ id: 'AssetMediaSize' });

View File

@ -56,6 +56,11 @@ export enum AssetFileType {
FullSize = 'fullsize',
Preview = 'preview',
Thumbnail = 'thumbnail',
/**
* A very small thumbnail for dense, zoomed-out grids where the regular
* thumbnail would be needlessly large to fetch and decode.
*/
Micro = 'micro',
Sidecar = 'sidecar',
EncodedVideo = 'encoded_video',
}

View File

@ -277,6 +277,12 @@ export class AssetMediaService extends BaseService {
return { targetSize: AssetMediaSize.PREVIEW };
}
if (dto.size === AssetMediaSize.MICRO && !path) {
// downgrade to the regular thumbnail when the micro size has not been
// generated yet (e.g. an existing library not yet re-thumbnailed).
return { targetSize: AssetMediaSize.THUMBNAIL };
}
if (!path) {
throw new NotFoundException('Asset media not found');
}

View File

@ -435,7 +435,7 @@ describe(MediaService.name, () => {
size: 1440,
});
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2);
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
{
@ -490,6 +490,14 @@ describe(MediaService.name, () => {
isProgressive: false,
isTransparent: false,
},
{
assetId: asset.id,
type: AssetFileType.Micro,
path: expect.any(String),
isEdited: false,
isProgressive: false,
isTransparent: false,
},
]);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: asset.id, thumbhash: thumbhashBuffer });
});
@ -540,6 +548,14 @@ describe(MediaService.name, () => {
isProgressive: false,
isTransparent: false,
},
{
assetId: asset.id,
type: AssetFileType.Micro,
path: expect.any(String),
isEdited: false,
isProgressive: false,
isTransparent: false,
},
]);
});
@ -589,6 +605,14 @@ describe(MediaService.name, () => {
isProgressive: false,
isTransparent: false,
},
{
assetId: asset.id,
type: AssetFileType.Micro,
path: expect.any(String),
isEdited: false,
isProgressive: false,
isTransparent: false,
},
]);
});
@ -707,7 +731,7 @@ describe(MediaService.name, () => {
size: 1440,
});
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2);
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
{
@ -757,7 +781,7 @@ describe(MediaService.name, () => {
size: 1440,
});
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2);
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
{
@ -824,6 +848,11 @@ describe(MediaService.name, () => {
isProgressive: false,
isTransparent: false,
}),
expect.objectContaining({
type: AssetFileType.Micro,
isProgressive: false,
isTransparent: false,
}),
]);
});
@ -863,6 +892,11 @@ describe(MediaService.name, () => {
isProgressive: true,
isTransparent: false,
}),
expect.objectContaining({
type: AssetFileType.Micro,
isProgressive: false,
isTransparent: false,
}),
]);
});
@ -889,6 +923,11 @@ describe(MediaService.name, () => {
isProgressive: false,
isTransparent: false,
}),
expect.objectContaining({
type: AssetFileType.Micro,
isProgressive: false,
isTransparent: false,
}),
]);
});
@ -1022,7 +1061,7 @@ describe(MediaService.name, () => {
expect.objectContaining({ processInvalidImages: true }),
);
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2);
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
expect.objectContaining({ processInvalidImages: false }),
@ -1064,7 +1103,7 @@ describe(MediaService.name, () => {
size: 1440, // capped to preview size as fullsize conversion is skipped
});
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2);
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
fullsizeBuffer,
{
@ -1101,7 +1140,7 @@ describe(MediaService.name, () => {
processInvalidImages: false,
});
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(4);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
fullsizeBuffer,
{
@ -1149,7 +1188,7 @@ describe(MediaService.name, () => {
processInvalidImages: false,
});
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(4);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
{
@ -1201,7 +1240,7 @@ describe(MediaService.name, () => {
processInvalidImages: false,
});
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(4);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
{
@ -1233,7 +1272,7 @@ describe(MediaService.name, () => {
size: 1440,
});
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(2);
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
expect(mocks.media.generateThumbnail).not.toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
@ -1266,7 +1305,7 @@ describe(MediaService.name, () => {
size: undefined,
});
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(4);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
{
@ -1309,7 +1348,7 @@ describe(MediaService.name, () => {
processInvalidImages: false,
});
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(4);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
{
@ -1342,7 +1381,7 @@ describe(MediaService.name, () => {
await sut.handleGenerateThumbnails({ id: asset.id });
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(4);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
expect.objectContaining({
@ -1464,7 +1503,7 @@ describe(MediaService.name, () => {
await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(4);
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
rawBuffer,
expect.anything(),

View File

@ -57,6 +57,11 @@ interface UpsertFileOptions {
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
/// Fixed edge (px) for the micro thumbnail used by dense, zoomed-out grids. Small
/// enough that fetching/decoding thousands of them stays cheap; it reuses the
/// thumbnail's configured format and quality, only smaller.
const MICRO_THUMBNAIL_SIZE = 128;
@Injectable()
export class MediaService extends BaseService {
videoInterfaces: VideoInterfaces = { dri: [], mali: false };
@ -330,16 +335,27 @@ export class MediaService extends BaseService {
isProgressive: !!image.thumbnail.progressive && thumbnailFormat !== ImageFormat.Webp,
isTransparent,
});
// A very small thumbnail for dense zoomed-out grids; a smaller variant of the
// thumbnail (same format/quality), so it stays tiny to fetch and decode.
const microFile = this.getImageFile(asset, {
fileType: AssetFileType.Micro,
format: thumbnailFormat,
isEdited: useEdits,
isProgressive: false,
isTransparent,
});
this.storageCore.ensureFolders(previewFile.path);
// generate final images
const baseOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] };
const thumbnailOptions = { ...image.thumbnail, ...baseOptions, format: thumbnailFormat };
const microOptions = { ...image.thumbnail, ...baseOptions, format: thumbnailFormat, size: MICRO_THUMBNAIL_SIZE };
const previewOptions = { ...image.preview, ...baseOptions, format: previewFormat };
const promises = [
this.mediaRepository.generateThumbhash(data, baseOptions),
this.mediaRepository.generateThumbnail(data, thumbnailOptions, thumbnailFile.path),
this.mediaRepository.generateThumbnail(data, previewOptions, previewFile.path),
this.mediaRepository.generateThumbnail(data, microOptions, microFile.path),
];
let fullsizeFile: UpsertFileOptions | undefined;
@ -398,7 +414,9 @@ export class MediaService extends BaseService {
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
return {
files: fullsizeFile ? [previewFile, thumbnailFile, fullsizeFile] : [previewFile, thumbnailFile],
files: fullsizeFile
? [previewFile, thumbnailFile, microFile, fullsizeFile]
: [previewFile, thumbnailFile, microFile],
thumbhash: outputs[0] as Buffer,
fullsizeDimensions,
};
@ -520,6 +538,13 @@ export class MediaService extends BaseService {
isProgressive: false,
isTransparent: false,
});
const microFile = this.getImageFile(asset, {
fileType: AssetFileType.Micro,
format: image.thumbnail.format,
isEdited: false,
isProgressive: false,
isTransparent: false,
});
this.storageCore.ensureFolders(previewFile.path);
const { videoStream, format } = asset;
@ -529,11 +554,14 @@ export class MediaService extends BaseService {
const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() });
const thumbConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() });
const microConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: MICRO_THUMBNAIL_SIZE.toString() });
const previewOptions = previewConfig.getCommand(TranscodeTarget.Video, videoStream, undefined, format ?? undefined);
const thumbnailOptions = thumbConfig.getCommand(TranscodeTarget.Video, videoStream, undefined, format ?? undefined);
const microOptions = microConfig.getCommand(TranscodeTarget.Video, videoStream, undefined, format ?? undefined);
await this.mediaRepository.transcode(asset.originalPath, previewFile.path, previewOptions);
await this.mediaRepository.transcode(asset.originalPath, thumbnailFile.path, thumbnailOptions);
await this.mediaRepository.transcode(asset.originalPath, microFile.path, microOptions);
const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path, {
colorspace: image.colorspace,
@ -541,7 +569,7 @@ export class MediaService extends BaseService {
});
return {
files: [previewFile, thumbnailFile],
files: [previewFile, thumbnailFile, microFile],
thumbhash,
fullsizeDimensions: { width: videoStream.width, height: videoStream.height },
};