mirror of
https://github.com/immich-app/immich.git
synced 2026-06-04 05:05:22 -04:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2ff075e9a | |||
| d8b39906f9 | |||
| b36911a16b | |||
| b074ee202e | |||
| 78bb6cf926 | |||
| c980f5fc19 | |||
| a26d9e05ba | |||
| c862163204 | |||
| 5fb8f9bf1a | |||
| b9b5dba037 | |||
| 8bfa75087c | |||
| 95280edd6c |
@@ -66,7 +66,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: gh pr edit "$PR_NUMBER" --add-label "auto-closed:template"
|
||||
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --add-label "auto-closed:template"
|
||||
|
||||
close_llm:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: gh pr edit "$PR_NUMBER" --remove-label "auto-closed:template" || true
|
||||
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --remove-label "auto-closed:template" || true
|
||||
|
||||
- name: Check for remaining auto-closed labels
|
||||
id: check_labels
|
||||
@@ -121,7 +121,7 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
REMAINING=$(gh pr view "$PR_NUMBER" --json labels \
|
||||
REMAINING=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json labels \
|
||||
--jq '[.labels[].name | select(startswith("auto-closed:"))] | length')
|
||||
echo "remaining=$REMAINING" >> "$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.6.2",
|
||||
"version": "2.6.3",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
|
||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"label": "v2.6.2",
|
||||
"url": "https://docs.v2.6.2.archive.immich.app"
|
||||
"label": "v2.6.3",
|
||||
"url": "https://docs.v2.6.3.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.5.6",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "2.6.2",
|
||||
"version": "2.6.3",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -866,6 +866,7 @@
|
||||
"crop_aspect_ratio_fixed": "Fixed",
|
||||
"crop_aspect_ratio_free": "Free",
|
||||
"crop_aspect_ratio_original": "Original",
|
||||
"crop_aspect_ratio_square": "Square",
|
||||
"curated_object_page_title": "Things",
|
||||
"current_device": "Current device",
|
||||
"current_pin_code": "Current PIN code",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-i18n",
|
||||
"version": "2.6.2",
|
||||
"version": "2.6.3",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --cache --check .",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "2.6.2"
|
||||
version = "2.6.3"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.11,<4.0"
|
||||
|
||||
Generated
+1
-1
@@ -898,7 +898,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "immich-ml"
|
||||
version = "2.6.2"
|
||||
version = "2.6.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocache" },
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3040,
|
||||
"android.injected.version.name" => "2.6.2",
|
||||
"android.injected.version.code" => 3041,
|
||||
"android.injected.version.name" => "2.6.3",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.6.2</string>
|
||||
<string>2.6.3</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
Generated
+1
-1
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 2.6.2
|
||||
- API version: 2.6.3
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
Generated
+3
-12
@@ -1004,13 +1004,10 @@ class AssetsApi {
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [bool] edited:
|
||||
/// Return edited asset if available
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<Response> playAssetVideoWithHttpInfo(String id, { bool? edited, String? key, String? slug, }) async {
|
||||
Future<Response> playAssetVideoWithHttpInfo(String id, { String? key, String? slug, }) async {
|
||||
// ignore: prefer_const_declarations
|
||||
final apiPath = r'/assets/{id}/video/playback'
|
||||
.replaceAll('{id}', id);
|
||||
@@ -1022,9 +1019,6 @@ class AssetsApi {
|
||||
final headerParams = <String, String>{};
|
||||
final formParams = <String, String>{};
|
||||
|
||||
if (edited != null) {
|
||||
queryParams.addAll(_queryParams('', 'edited', edited));
|
||||
}
|
||||
if (key != null) {
|
||||
queryParams.addAll(_queryParams('', 'key', key));
|
||||
}
|
||||
@@ -1054,14 +1048,11 @@ class AssetsApi {
|
||||
///
|
||||
/// * [String] id (required):
|
||||
///
|
||||
/// * [bool] edited:
|
||||
/// Return edited asset if available
|
||||
///
|
||||
/// * [String] key:
|
||||
///
|
||||
/// * [String] slug:
|
||||
Future<MultipartFile?> playAssetVideo(String id, { bool? edited, String? key, String? slug, }) async {
|
||||
final response = await playAssetVideoWithHttpInfo(id, edited: edited, key: key, slug: slug, );
|
||||
Future<MultipartFile?> playAssetVideo(String id, { String? key, String? slug, }) async {
|
||||
final response = await playAssetVideoWithHttpInfo(id, key: key, slug: slug, );
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
|
||||
Generated
+3
-3
@@ -29,6 +29,7 @@ class JobName {
|
||||
static const assetDetectFaces = JobName._(r'AssetDetectFaces');
|
||||
static const assetDetectDuplicatesQueueAll = JobName._(r'AssetDetectDuplicatesQueueAll');
|
||||
static const assetDetectDuplicates = JobName._(r'AssetDetectDuplicates');
|
||||
static const assetEditThumbnailGeneration = JobName._(r'AssetEditThumbnailGeneration');
|
||||
static const assetEncodeVideoQueueAll = JobName._(r'AssetEncodeVideoQueueAll');
|
||||
static const assetEncodeVideo = JobName._(r'AssetEncodeVideo');
|
||||
static const assetEmptyTrash = JobName._(r'AssetEmptyTrash');
|
||||
@@ -37,7 +38,6 @@ class JobName {
|
||||
static const assetFileMigration = JobName._(r'AssetFileMigration');
|
||||
static const assetGenerateThumbnailsQueueAll = JobName._(r'AssetGenerateThumbnailsQueueAll');
|
||||
static const assetGenerateThumbnails = JobName._(r'AssetGenerateThumbnails');
|
||||
static const assetProcessEdit = JobName._(r'AssetProcessEdit');
|
||||
static const auditLogCleanup = JobName._(r'AuditLogCleanup');
|
||||
static const auditTableCleanup = JobName._(r'AuditTableCleanup');
|
||||
static const databaseBackup = JobName._(r'DatabaseBackup');
|
||||
@@ -88,6 +88,7 @@ class JobName {
|
||||
assetDetectFaces,
|
||||
assetDetectDuplicatesQueueAll,
|
||||
assetDetectDuplicates,
|
||||
assetEditThumbnailGeneration,
|
||||
assetEncodeVideoQueueAll,
|
||||
assetEncodeVideo,
|
||||
assetEmptyTrash,
|
||||
@@ -96,7 +97,6 @@ class JobName {
|
||||
assetFileMigration,
|
||||
assetGenerateThumbnailsQueueAll,
|
||||
assetGenerateThumbnails,
|
||||
assetProcessEdit,
|
||||
auditLogCleanup,
|
||||
auditTableCleanup,
|
||||
databaseBackup,
|
||||
@@ -182,6 +182,7 @@ class JobNameTypeTransformer {
|
||||
case r'AssetDetectFaces': return JobName.assetDetectFaces;
|
||||
case r'AssetDetectDuplicatesQueueAll': return JobName.assetDetectDuplicatesQueueAll;
|
||||
case r'AssetDetectDuplicates': return JobName.assetDetectDuplicates;
|
||||
case r'AssetEditThumbnailGeneration': return JobName.assetEditThumbnailGeneration;
|
||||
case r'AssetEncodeVideoQueueAll': return JobName.assetEncodeVideoQueueAll;
|
||||
case r'AssetEncodeVideo': return JobName.assetEncodeVideo;
|
||||
case r'AssetEmptyTrash': return JobName.assetEmptyTrash;
|
||||
@@ -190,7 +191,6 @@ class JobNameTypeTransformer {
|
||||
case r'AssetFileMigration': return JobName.assetFileMigration;
|
||||
case r'AssetGenerateThumbnailsQueueAll': return JobName.assetGenerateThumbnailsQueueAll;
|
||||
case r'AssetGenerateThumbnails': return JobName.assetGenerateThumbnails;
|
||||
case r'AssetProcessEdit': return JobName.assetProcessEdit;
|
||||
case r'AuditLogCleanup': return JobName.auditLogCleanup;
|
||||
case r'AuditTableCleanup': return JobName.auditTableCleanup;
|
||||
case r'DatabaseBackup': return JobName.databaseBackup;
|
||||
|
||||
+1
-1
@@ -379,7 +379,7 @@ class MetadataSearchDto {
|
||||
///
|
||||
bool? withExif;
|
||||
|
||||
/// Include assets with people
|
||||
/// Include people data in response
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
|
||||
+1
-1
@@ -273,7 +273,7 @@ class RandomSearchDto {
|
||||
///
|
||||
bool? withExif;
|
||||
|
||||
/// Include assets with people
|
||||
/// Include people data in response
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 2.6.2+3040
|
||||
version: 2.6.3+3041
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
|
||||
@@ -4402,16 +4402,6 @@
|
||||
"description": "Streams the video file for the specified asset. This endpoint also supports byte range requests.",
|
||||
"operationId": "playAssetVideo",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "edited",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"description": "Return edited asset if available",
|
||||
"schema": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
@@ -15176,7 +15166,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "2.6.2",
|
||||
"version": "2.6.3",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
@@ -18154,6 +18144,7 @@
|
||||
"AssetDetectFaces",
|
||||
"AssetDetectDuplicatesQueueAll",
|
||||
"AssetDetectDuplicates",
|
||||
"AssetEditThumbnailGeneration",
|
||||
"AssetEncodeVideoQueueAll",
|
||||
"AssetEncodeVideo",
|
||||
"AssetEmptyTrash",
|
||||
@@ -18162,7 +18153,6 @@
|
||||
"AssetFileMigration",
|
||||
"AssetGenerateThumbnailsQueueAll",
|
||||
"AssetGenerateThumbnails",
|
||||
"AssetProcessEdit",
|
||||
"AuditLogCleanup",
|
||||
"AuditTableCleanup",
|
||||
"DatabaseBackup",
|
||||
@@ -19139,7 +19129,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"withPeople": {
|
||||
"description": "Include assets with people",
|
||||
"description": "Include people data in response",
|
||||
"type": "boolean"
|
||||
},
|
||||
"withStacked": {
|
||||
@@ -20878,7 +20868,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"withPeople": {
|
||||
"description": "Include assets with people",
|
||||
"description": "Include people data in response",
|
||||
"type": "boolean"
|
||||
},
|
||||
"withStacked": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "2.6.2",
|
||||
"version": "2.6.3",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 2.6.2
|
||||
* 2.6.3
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
@@ -1741,7 +1741,7 @@ export type MetadataSearchDto = {
|
||||
withDeleted?: boolean;
|
||||
/** Include EXIF data in response */
|
||||
withExif?: boolean;
|
||||
/** Include assets with people */
|
||||
/** Include people data in response */
|
||||
withPeople?: boolean;
|
||||
/** Include stacked assets */
|
||||
withStacked?: boolean;
|
||||
@@ -1855,7 +1855,7 @@ export type RandomSearchDto = {
|
||||
withDeleted?: boolean;
|
||||
/** Include EXIF data in response */
|
||||
withExif?: boolean;
|
||||
/** Include assets with people */
|
||||
/** Include people data in response */
|
||||
withPeople?: boolean;
|
||||
/** Include stacked assets */
|
||||
withStacked?: boolean;
|
||||
@@ -4316,8 +4316,7 @@ export function viewAsset({ edited, id, key, size, slug }: {
|
||||
/**
|
||||
* Play asset video
|
||||
*/
|
||||
export function playAssetVideo({ edited, id, key, slug }: {
|
||||
edited?: boolean;
|
||||
export function playAssetVideo({ id, key, slug }: {
|
||||
id: string;
|
||||
key?: string;
|
||||
slug?: string;
|
||||
@@ -4326,7 +4325,6 @@ export function playAssetVideo({ edited, id, key, slug }: {
|
||||
status: 200;
|
||||
data: Blob;
|
||||
}>(`/assets/${encodeURIComponent(id)}/video/playback${QS.query(QS.explode({
|
||||
edited,
|
||||
key,
|
||||
slug
|
||||
}))}`, {
|
||||
@@ -7166,6 +7164,7 @@ export enum JobName {
|
||||
AssetDetectFaces = "AssetDetectFaces",
|
||||
AssetDetectDuplicatesQueueAll = "AssetDetectDuplicatesQueueAll",
|
||||
AssetDetectDuplicates = "AssetDetectDuplicates",
|
||||
AssetEditThumbnailGeneration = "AssetEditThumbnailGeneration",
|
||||
AssetEncodeVideoQueueAll = "AssetEncodeVideoQueueAll",
|
||||
AssetEncodeVideo = "AssetEncodeVideo",
|
||||
AssetEmptyTrash = "AssetEmptyTrash",
|
||||
@@ -7174,7 +7173,6 @@ export enum JobName {
|
||||
AssetFileMigration = "AssetFileMigration",
|
||||
AssetGenerateThumbnailsQueueAll = "AssetGenerateThumbnailsQueueAll",
|
||||
AssetGenerateThumbnails = "AssetGenerateThumbnails",
|
||||
AssetProcessEdit = "AssetProcessEdit",
|
||||
AuditLogCleanup = "AuditLogCleanup",
|
||||
AuditTableCleanup = "AuditTableCleanup",
|
||||
DatabaseBackup = "DatabaseBackup",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-monorepo",
|
||||
"version": "2.6.2",
|
||||
"version": "2.6.3",
|
||||
"description": "Monorepo for Immich",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
"matchUpdateTypes": ["major"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["ghcr.io/immich-app/base-server-*"],
|
||||
"maxMajorIncrement": 0
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["ruby"],
|
||||
"groupName": "ruby",
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS builder
|
||||
FROM ghcr.io/immich-app/base-server-dev:202603251709@sha256:2bf3053c732fcb87ec90c3c614632ac44847423468ccc57fd935bff771828d9d AS builder
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp \
|
||||
@@ -71,7 +71,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
|
||||
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
||||
cd plugins && mise run build
|
||||
|
||||
FROM ghcr.io/immich-app/base-server-prod:202603031112@sha256:bb8c8645ee61977140121e56ba09db7ae656a7506f9a6af1be8461b4d81fdf03
|
||||
FROM ghcr.io/immich-app/base-server-prod:202603251709@sha256:17de30977ff87aa06758a56ad7f10d6b5c97bf9dab76e4ec4177a2a8d1b2b5f3
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production \
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# dev build
|
||||
FROM ghcr.io/immich-app/base-server-dev:202603031112@sha256:837536db5fd9e432f0f474ef9b61712fe3b3815821c3e4edf5e5b0b1f1ed30ad AS dev
|
||||
FROM ghcr.io/immich-app/base-server-dev:202603251709@sha256:2bf3053c732fcb87ec90c3c614632ac44847423468ccc57fd935bff771828d9d AS dev
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "2.6.2",
|
||||
"version": "2.6.3",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
AssetMediaOptionsDto,
|
||||
AssetMediaReplaceDto,
|
||||
AssetMediaSize,
|
||||
AssetThumbnailOptionsDto,
|
||||
CheckExistingAssetsDto,
|
||||
UploadFieldName,
|
||||
} from 'src/dtos/asset-media.dto';
|
||||
@@ -155,7 +154,7 @@ export class AssetMediaController {
|
||||
async viewAsset(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Query() dto: AssetThumbnailOptionsDto,
|
||||
@Query() dto: AssetMediaOptionsDto,
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
@@ -198,10 +197,9 @@ export class AssetMediaController {
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Res() res: Response,
|
||||
@Query() dto: AssetMediaOptionsDto,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.playbackVideo(auth, id, dto), this.logger);
|
||||
await sendFile(res, next, () => this.service.playbackVideo(auth, id), this.logger);
|
||||
}
|
||||
|
||||
@Post('exist')
|
||||
|
||||
@@ -120,8 +120,8 @@ export class StorageCore {
|
||||
);
|
||||
}
|
||||
|
||||
static getEncodedVideoPath(asset: ThumbnailPathEntity, isEdited: boolean = false) {
|
||||
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}${isEdited ? '_edited' : ''}.mp4`);
|
||||
static getEncodedVideoPath(asset: ThumbnailPathEntity) {
|
||||
return StorageCore.getNestedPath(StorageFolder.EncodedVideo, asset.ownerId, `${asset.id}.mp4`);
|
||||
}
|
||||
|
||||
static getAndroidMotionPath(asset: ThumbnailPathEntity, uuid: string) {
|
||||
|
||||
@@ -346,7 +346,8 @@ export const columns = {
|
||||
'asset.width',
|
||||
'asset.height',
|
||||
],
|
||||
assetFiles: [
|
||||
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
|
||||
assetFilesForThumbnail: [
|
||||
'asset_file.id',
|
||||
'asset_file.path',
|
||||
'asset_file.type',
|
||||
|
||||
@@ -18,13 +18,11 @@ export enum AssetMediaSize {
|
||||
}
|
||||
|
||||
export class AssetMediaOptionsDto {
|
||||
@ValidateBoolean({ optional: true, description: 'Return edited asset if available', default: false })
|
||||
edited?: boolean;
|
||||
}
|
||||
|
||||
export class AssetThumbnailOptionsDto extends AssetMediaOptionsDto {
|
||||
@ValidateEnum({ enum: AssetMediaSize, name: 'AssetMediaSize', description: 'Asset media size', optional: true })
|
||||
size?: AssetMediaSize;
|
||||
|
||||
@ValidateBoolean({ optional: true, description: 'Return edited asset if available', default: false })
|
||||
edited?: boolean;
|
||||
}
|
||||
|
||||
export enum UploadFieldName {
|
||||
|
||||
@@ -146,7 +146,7 @@ export class RandomSearchDto extends BaseSearchWithResultsDto {
|
||||
@ValidateBoolean({ optional: true, description: 'Include stacked assets' })
|
||||
withStacked?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true, description: 'Include assets with people' })
|
||||
@ValidateBoolean({ optional: true, description: 'Include people data in response' })
|
||||
withPeople?: boolean;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -588,6 +588,7 @@ export enum JobName {
|
||||
AssetDetectFaces = 'AssetDetectFaces',
|
||||
AssetDetectDuplicatesQueueAll = 'AssetDetectDuplicatesQueueAll',
|
||||
AssetDetectDuplicates = 'AssetDetectDuplicates',
|
||||
AssetEditThumbnailGeneration = 'AssetEditThumbnailGeneration',
|
||||
AssetEncodeVideoQueueAll = 'AssetEncodeVideoQueueAll',
|
||||
AssetEncodeVideo = 'AssetEncodeVideo',
|
||||
AssetEmptyTrash = 'AssetEmptyTrash',
|
||||
@@ -596,7 +597,6 @@ export enum JobName {
|
||||
AssetFileMigration = 'AssetFileMigration',
|
||||
AssetGenerateThumbnailsQueueAll = 'AssetGenerateThumbnailsQueueAll',
|
||||
AssetGenerateThumbnails = 'AssetGenerateThumbnails',
|
||||
AssetProcessEdit = 'AssetProcessEdit',
|
||||
|
||||
AuditLogCleanup = 'AuditLogCleanup',
|
||||
AuditTableCleanup = 'AuditTableCleanup',
|
||||
|
||||
@@ -30,9 +30,7 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -62,9 +60,7 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -188,9 +184,7 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -251,55 +245,6 @@ from
|
||||
where
|
||||
"asset"."id" = $4
|
||||
|
||||
-- AssetJobRepository.getForAssetEditProcessing
|
||||
select
|
||||
"asset"."id",
|
||||
"asset"."visibility",
|
||||
"asset"."originalFileName",
|
||||
"asset"."originalPath",
|
||||
"asset"."ownerId",
|
||||
"asset"."thumbhash",
|
||||
"asset"."type",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" in ($1, $2, $3, $4)
|
||||
) as agg
|
||||
) as "files",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_edit"."action",
|
||||
"asset_edit"."parameters"
|
||||
from
|
||||
"asset_edit"
|
||||
where
|
||||
"asset_edit"."assetId" = "asset"."id"
|
||||
) as agg
|
||||
) as "edits",
|
||||
to_json("asset_exif") as "exifInfo"
|
||||
from
|
||||
"asset"
|
||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||
where
|
||||
"asset"."id" = $5
|
||||
|
||||
-- AssetJobRepository.getForMetadataExtraction
|
||||
select
|
||||
"asset"."id",
|
||||
@@ -343,9 +288,7 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -371,9 +314,7 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -430,9 +371,7 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -472,9 +411,7 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -499,12 +436,11 @@ select
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" = $1
|
||||
and "asset_file"."isEdited" = $2
|
||||
) as "previewFile"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $3
|
||||
"asset"."id" = $2
|
||||
|
||||
-- AssetJobRepository.getForSyncAssets
|
||||
select
|
||||
@@ -538,9 +474,7 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -581,8 +515,7 @@ where
|
||||
|
||||
-- AssetJobRepository.streamForVideoConversion
|
||||
select
|
||||
"asset"."id",
|
||||
"asset"."isEdited"
|
||||
"asset"."id"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
@@ -613,34 +546,17 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" = $1
|
||||
) as agg
|
||||
) as "files",
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"asset_edit"."action",
|
||||
"asset_edit"."parameters"
|
||||
from
|
||||
"asset_edit"
|
||||
where
|
||||
"asset_edit"."assetId" = "asset"."id"
|
||||
) as agg
|
||||
) as "edits"
|
||||
) as "files"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $2
|
||||
"asset"."id" = $1
|
||||
and "asset"."type" = 'VIDEO'
|
||||
|
||||
-- AssetJobRepository.streamForMetadataExtraction
|
||||
@@ -682,9 +598,7 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -726,9 +640,7 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
|
||||
@@ -285,9 +285,7 @@ select
|
||||
"asset_file"."id",
|
||||
"asset_file"."path",
|
||||
"asset_file"."type",
|
||||
"asset_file"."isEdited",
|
||||
"asset_file"."isProgressive",
|
||||
"asset_file"."isTransparent"
|
||||
"asset_file"."isEdited"
|
||||
from
|
||||
"asset_file"
|
||||
where
|
||||
@@ -640,13 +638,12 @@ select
|
||||
where
|
||||
"asset_file"."assetId" = "asset"."id"
|
||||
and "asset_file"."type" = $1
|
||||
and "asset_file"."isEdited" = $2
|
||||
) as "encodedVideoPath"
|
||||
from
|
||||
"asset"
|
||||
where
|
||||
"asset"."id" = $3
|
||||
and "asset"."type" = $4
|
||||
"asset"."id" = $2
|
||||
and "asset"."type" = $3
|
||||
|
||||
-- AssetRepository.getForOcr
|
||||
select
|
||||
|
||||
@@ -254,6 +254,7 @@ where
|
||||
and "visibility" = $2
|
||||
and "deletedAt" is null
|
||||
and "state" is not null
|
||||
and "state" != $3
|
||||
|
||||
-- SearchRepository.getCities
|
||||
select distinct
|
||||
@@ -266,6 +267,7 @@ where
|
||||
and "visibility" = $2
|
||||
and "deletedAt" is null
|
||||
and "city" is not null
|
||||
and "city" != $3
|
||||
|
||||
-- SearchRepository.getCameraMakes
|
||||
select distinct
|
||||
@@ -278,6 +280,7 @@ where
|
||||
and "visibility" = $2
|
||||
and "deletedAt" is null
|
||||
and "make" is not null
|
||||
and "make" != $3
|
||||
|
||||
-- SearchRepository.getCameraModels
|
||||
select distinct
|
||||
@@ -290,6 +293,7 @@ where
|
||||
and "visibility" = $2
|
||||
and "deletedAt" is null
|
||||
and "model" is not null
|
||||
and "model" != $3
|
||||
|
||||
-- SearchRepository.getCameraLensModels
|
||||
select distinct
|
||||
@@ -302,3 +306,4 @@ where
|
||||
and "visibility" = $2
|
||||
and "deletedAt" is null
|
||||
and "lensModel" is not null
|
||||
and "lensModel" != $3
|
||||
|
||||
@@ -582,7 +582,6 @@ where
|
||||
"asset_face"."updateId" < $1
|
||||
and "asset_face"."updateId" > $2
|
||||
and "asset"."ownerId" = $3
|
||||
and "asset_face"."isVisible" = $4
|
||||
order by
|
||||
"asset_face"."updateId" asc
|
||||
|
||||
|
||||
@@ -112,26 +112,6 @@ export class AssetJobRepository {
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getForGenerateThumbnailJob(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select([
|
||||
'asset.id',
|
||||
'asset.visibility',
|
||||
'asset.originalFileName',
|
||||
'asset.originalPath',
|
||||
'asset.ownerId',
|
||||
'asset.thumbhash',
|
||||
'asset.type',
|
||||
])
|
||||
.select((eb) => withFiles(eb, [AssetFileType.Thumbnail, AssetFileType.Preview, AssetFileType.FullSize]))
|
||||
.select(withEdits)
|
||||
.$call(withExifInner)
|
||||
.where('asset.id', '=', id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getForAssetEditProcessing(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select([
|
||||
@@ -144,12 +124,13 @@ export class AssetJobRepository {
|
||||
'asset.type',
|
||||
])
|
||||
.select((eb) =>
|
||||
withFiles(eb, [
|
||||
AssetFileType.Thumbnail,
|
||||
AssetFileType.Preview,
|
||||
AssetFileType.FullSize,
|
||||
AssetFileType.EncodedVideo,
|
||||
]),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('asset_file')
|
||||
.select(columns.assetFilesForThumbnail)
|
||||
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||
.where('asset_file.type', 'in', [AssetFileType.Thumbnail, AssetFileType.Preview, AssetFileType.FullSize]),
|
||||
).as('files'),
|
||||
)
|
||||
.select(withEdits)
|
||||
.$call(withExifInner)
|
||||
@@ -327,7 +308,7 @@ export class AssetJobRepository {
|
||||
streamForVideoConversion(force?: boolean) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.id', 'asset.isEdited'])
|
||||
.select(['asset.id'])
|
||||
.where('asset.type', '=', sql.lit(AssetType.Video))
|
||||
.$if(!force, (qb) =>
|
||||
qb
|
||||
@@ -353,8 +334,7 @@ export class AssetJobRepository {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.id', 'asset.ownerId', 'asset.originalPath'])
|
||||
.select((eb) => withFiles(eb, AssetFileType.EncodedVideo))
|
||||
.select(withEdits)
|
||||
.select(withFiles)
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.type', '=', sql.lit(AssetType.Video))
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -1149,12 +1149,12 @@ export class AssetRepository {
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, true] })
|
||||
async getForVideo(id: string, isEdited: boolean) {
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async getForVideo(id: string) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select(['asset.originalPath'])
|
||||
.select((eb) => withFilePath(eb, AssetFileType.EncodedVideo, isEdited).as('encodedVideoPath'))
|
||||
.select((eb) => withFilePath(eb, AssetFileType.EncodedVideo).as('encodedVideoPath'))
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.type', '=', AssetType.Video)
|
||||
.executeTakeFirst();
|
||||
|
||||
@@ -502,10 +502,7 @@ export class SearchRepository {
|
||||
return res.map((row) => row.lensModel!);
|
||||
}
|
||||
|
||||
private getExifField<K extends 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel'>(
|
||||
field: K,
|
||||
userIds: string[],
|
||||
) {
|
||||
private getExifField(field: 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel', userIds: string[]) {
|
||||
return this.db
|
||||
.selectFrom('asset_exif')
|
||||
.select(field)
|
||||
@@ -514,6 +511,7 @@ export class SearchRepository {
|
||||
.where('ownerId', '=', anyUuid(userIds))
|
||||
.where('visibility', '=', AssetVisibility.Timeline)
|
||||
.where('deletedAt', 'is', null)
|
||||
.where(field, 'is not', null);
|
||||
.where(field, 'is not', null)
|
||||
.where(field, '!=', '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,7 +487,6 @@ class AssetFaceSync extends BaseSync {
|
||||
])
|
||||
.leftJoin('asset', 'asset.id', 'asset_face.assetId')
|
||||
.where('asset.ownerId', '=', options.userId)
|
||||
.where('asset_face.isVisible', '=', true)
|
||||
.stream();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
// Sync query for faces was incorrect on server <=2.6.2
|
||||
await sql`DELETE FROM session_sync_checkpoint WHERE type in ('AssetFaceV1', 'AssetFaceV2')`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(): Promise<void> {
|
||||
// Not implemented
|
||||
}
|
||||
@@ -695,9 +695,7 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
describe('playbackVideo', () => {
|
||||
it('should require asset.view permissions', async () => {
|
||||
await expect(sut.playbackVideo(authStub.admin, 'id', { edited: true })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(sut.playbackVideo(authStub.admin, 'id')).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']));
|
||||
@@ -708,9 +706,7 @@ describe(AssetMediaService.name, () => {
|
||||
const asset = AssetFactory.create();
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, asset.id, { edited: true })).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(sut.playbackVideo(authStub.admin, asset.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('should return the encoded video path if available', async () => {
|
||||
@@ -723,7 +719,7 @@ describe(AssetMediaService.name, () => {
|
||||
encodedVideoPath: asset.files[0].path,
|
||||
});
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, asset.id, { edited: true })).resolves.toEqual(
|
||||
await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/path/to/encoded/video.mp4',
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
@@ -740,7 +736,7 @@ describe(AssetMediaService.name, () => {
|
||||
encodedVideoPath: null,
|
||||
});
|
||||
|
||||
await expect(sut.playbackVideo(authStub.admin, asset.id, { edited: true })).resolves.toEqual(
|
||||
await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: asset.originalPath,
|
||||
cacheControl: CacheControl.PrivateWithCache,
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
AssetMediaOptionsDto,
|
||||
AssetMediaReplaceDto,
|
||||
AssetMediaSize,
|
||||
AssetThumbnailOptionsDto,
|
||||
CheckExistingAssetsDto,
|
||||
UploadFieldName,
|
||||
} from 'src/dtos/asset-media.dto';
|
||||
@@ -223,7 +222,7 @@ export class AssetMediaService extends BaseService {
|
||||
async viewThumbnail(
|
||||
auth: AuthDto,
|
||||
id: string,
|
||||
dto: AssetThumbnailOptionsDto,
|
||||
dto: AssetMediaOptionsDto,
|
||||
): Promise<ImmichFileResponse | AssetMediaRedirectResponse> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
|
||||
|
||||
@@ -267,10 +266,10 @@ export class AssetMediaService extends BaseService {
|
||||
});
|
||||
}
|
||||
|
||||
async playbackVideo(auth: AuthDto, id: string, dto: AssetMediaOptionsDto): Promise<ImmichFileResponse> {
|
||||
async playbackVideo(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetView, ids: [id] });
|
||||
|
||||
const asset = await this.assetRepository.getForVideo(id, dto.edited ?? false);
|
||||
const asset = await this.assetRepository.getForVideo(id);
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset not found or asset is not a video');
|
||||
@@ -357,6 +356,7 @@ export class AssetMediaService extends BaseService {
|
||||
await this.addToSharedLink(auth.sharedLink, duplicateId);
|
||||
}
|
||||
|
||||
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
|
||||
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
} from 'src/utils/asset.util';
|
||||
import { updateLockedColumns } from 'src/utils/database';
|
||||
import { extractTimeZone } from 'src/utils/date';
|
||||
import { scaleEdits } from 'src/utils/editor';
|
||||
import { transformOcrBoundingBox } from 'src/utils/transform';
|
||||
|
||||
@Injectable()
|
||||
@@ -566,6 +565,10 @@ export class AssetService extends BaseService {
|
||||
throw new BadRequestException('Only images can be edited');
|
||||
}
|
||||
|
||||
if (asset.livePhotoVideoId) {
|
||||
throw new BadRequestException('Editing live photos is not supported');
|
||||
}
|
||||
|
||||
if (isPanorama(asset)) {
|
||||
throw new BadRequestException('Editing panorama images is not supported');
|
||||
}
|
||||
@@ -606,28 +609,7 @@ export class AssetService extends BaseService {
|
||||
}
|
||||
|
||||
const newEdits = await this.assetEditRepository.replaceAll(id, edits);
|
||||
await this.jobRepository.queue({ name: JobName.AssetProcessEdit, data: { id } });
|
||||
|
||||
if (asset.livePhotoVideoId) {
|
||||
const liveAsset = await this.assetRepository.getForEdit(asset.livePhotoVideoId);
|
||||
if (!liveAsset) {
|
||||
throw new BadRequestException('Live photo video not found');
|
||||
}
|
||||
|
||||
const { width: liveWidth, height: liveHeight } = getDimensions(liveAsset);
|
||||
|
||||
const scaledEdits = scaleEdits(
|
||||
edits,
|
||||
{ width: liveWidth, height: liveHeight },
|
||||
{ width: assetWidth, height: assetHeight },
|
||||
);
|
||||
|
||||
await this.assetEditRepository.replaceAll(asset.livePhotoVideoId, scaledEdits);
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.AssetProcessEdit,
|
||||
data: { id: asset.livePhotoVideoId },
|
||||
});
|
||||
}
|
||||
await this.jobRepository.queue({ name: JobName.AssetEditThumbnailGeneration, data: { id } });
|
||||
|
||||
// Return the asset and its applied edits
|
||||
return {
|
||||
@@ -645,14 +627,6 @@ export class AssetService extends BaseService {
|
||||
}
|
||||
|
||||
await this.assetEditRepository.replaceAll(id, []);
|
||||
await this.jobRepository.queue({ name: JobName.AssetProcessEdit, data: { id } });
|
||||
|
||||
if (asset.livePhotoVideoId) {
|
||||
await this.assetEditRepository.replaceAll(asset.livePhotoVideoId, []);
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.AssetProcessEdit,
|
||||
data: { id: asset.livePhotoVideoId },
|
||||
});
|
||||
}
|
||||
await this.jobRepository.queue({ name: JobName.AssetEditThumbnailGeneration, data: { id } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,8 @@ export class JobService extends BaseService {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case JobName.AssetProcessEdit: {
|
||||
|
||||
case JobName.AssetEditThumbnailGeneration: {
|
||||
const asset = await this.assetRepository.getById(item.data.id);
|
||||
const edits = await this.assetEditRepository.getWithSyncInfo(item.data.id);
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ describe(MediaService.name, () => {
|
||||
expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false });
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.AssetProcessEdit,
|
||||
name: JobName.AssetEditThumbnailGeneration,
|
||||
data: { id: asset.id },
|
||||
},
|
||||
]);
|
||||
@@ -273,7 +273,7 @@ describe(MediaService.name, () => {
|
||||
data: { id: asset.id },
|
||||
},
|
||||
{
|
||||
name: JobName.AssetProcessEdit,
|
||||
name: JobName.AssetEditThumbnailGeneration,
|
||||
data: { id: asset.id },
|
||||
},
|
||||
]);
|
||||
@@ -1321,101 +1321,9 @@ describe(MediaService.name, () => {
|
||||
expect.stringContaining('fullsize.jpeg'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should generate edited video thumbnails when asset has edits', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
|
||||
.exif()
|
||||
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 0, y: 0 } })
|
||||
.build();
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
// should generate both original and edited thumbnails (2 original + 2 edited transcodes)
|
||||
expect(mocks.media.transcode).toHaveBeenCalledTimes(4);
|
||||
|
||||
// should upsert files for both original and edited
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: AssetFileType.Preview, isEdited: false }),
|
||||
expect.objectContaining({ type: AssetFileType.Thumbnail, isEdited: false }),
|
||||
expect.objectContaining({ type: AssetFileType.Preview, isEdited: true }),
|
||||
expect.objectContaining({ type: AssetFileType.Thumbnail, isEdited: true }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not generate edited video thumbnails when asset has no edits', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' }).exif().build();
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.media.generateThumbhash.mockResolvedValue(Buffer.from('thumbhash'));
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
// should only generate original thumbnails (2 transcodes for preview + thumbnail)
|
||||
expect(mocks.media.transcode).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
|
||||
expect.not.arrayContaining([expect.objectContaining({ isEdited: true })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use edited thumbhash when asset has edits', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
|
||||
.exif()
|
||||
.edit({ action: AssetEditAction.Crop })
|
||||
.build();
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
const originalThumbhash = Buffer.from('original thumbhash');
|
||||
const editedThumbhash = Buffer.from('edited thumbhash');
|
||||
mocks.media.generateThumbhash.mockResolvedValueOnce(originalThumbhash).mockResolvedValueOnce(editedThumbhash);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
// should use the edited thumbhash (second call) for the asset update
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ thumbhash: editedThumbhash }));
|
||||
});
|
||||
|
||||
it('should generate edited image thumbnails with edits applied', async () => {
|
||||
const asset = AssetFactory.from()
|
||||
.exif()
|
||||
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 100, y: 100 } })
|
||||
.build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
|
||||
await sut.handleGenerateThumbnails({ id: asset.id });
|
||||
|
||||
// should generate original (2) + edited (3 with fullsize) thumbnails
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.objectContaining({
|
||||
edits: [
|
||||
expect.objectContaining({
|
||||
action: 'crop',
|
||||
parameters: { height: 500, width: 500, x: 100, y: 100 },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.stringContaining('edited'),
|
||||
);
|
||||
|
||||
// should upsert both original and edited files
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ isEdited: false }),
|
||||
expect.objectContaining({ isEdited: true }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAssetEditProcessing', () => {
|
||||
describe('handleAssetEditThumbnailGeneration', () => {
|
||||
let rawInfo: RawImageInfo;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -1432,6 +1340,14 @@ describe(MediaService.name, () => {
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 100, height: 100, isTransparent: false });
|
||||
});
|
||||
|
||||
it('should skip videos', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video }).exif().build();
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
await expect(sut.handleAssetEditThumbnailGeneration({ id: asset.id })).resolves.toBe(JobStatus.Success);
|
||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should upsert 3 edited files for edit jobs', async () => {
|
||||
const asset = AssetFactory.from()
|
||||
.exif()
|
||||
@@ -1443,13 +1359,13 @@ describe(MediaService.name, () => {
|
||||
])
|
||||
.build();
|
||||
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
mocks.person.getFaces.mockResolvedValue([]);
|
||||
mocks.ocr.getByAssetId.mockResolvedValue([]);
|
||||
|
||||
await sut.handleAssetEditProcessing({ id: asset.id });
|
||||
await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
@@ -1465,11 +1381,11 @@ describe(MediaService.name, () => {
|
||||
.exif()
|
||||
.edit({ action: AssetEditAction.Crop, parameters: { height: 1152, width: 1512, x: 216, y: 1512 } })
|
||||
.build();
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.person.getFaces.mockResolvedValue([]);
|
||||
mocks.ocr.getByAssetId.mockResolvedValue([]);
|
||||
|
||||
await sut.handleAssetEditProcessing({ id: asset.id });
|
||||
await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
rawBuffer,
|
||||
expect.objectContaining({
|
||||
@@ -1493,9 +1409,9 @@ describe(MediaService.name, () => {
|
||||
{ type: AssetFileType.FullSize, path: 'edited3.jpg', isEdited: true },
|
||||
])
|
||||
.build();
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
|
||||
const status = await sut.handleAssetEditProcessing({ id: asset.id });
|
||||
const status = await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
|
||||
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
@@ -1511,11 +1427,11 @@ describe(MediaService.name, () => {
|
||||
|
||||
it('should generate all 3 edited files if an asset has edits', async () => {
|
||||
const asset = AssetFactory.from().exif().edit().build();
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.person.getFaces.mockResolvedValue([]);
|
||||
mocks.ocr.getByAssetId.mockResolvedValue([]);
|
||||
|
||||
await sut.handleAssetEditProcessing({ id: asset.id });
|
||||
await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
|
||||
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3);
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalledWith(
|
||||
@@ -1537,147 +1453,26 @@ describe(MediaService.name, () => {
|
||||
|
||||
it('should generate the original thumbhash if no edits exist', async () => {
|
||||
const asset = AssetFactory.from().exif().build();
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.media.generateThumbhash.mockResolvedValue(factory.buffer());
|
||||
|
||||
await sut.handleAssetEditProcessing({ id: asset.id, source: 'upload' });
|
||||
await sut.handleAssetEditThumbnailGeneration({ id: asset.id, source: 'upload' });
|
||||
|
||||
expect(mocks.media.generateThumbhash).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should apply thumbhash if job source is edit and edits exist', async () => {
|
||||
const asset = AssetFactory.from().exif().edit().build();
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
const thumbhashBuffer = factory.buffer();
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
mocks.person.getFaces.mockResolvedValue([]);
|
||||
mocks.ocr.getByAssetId.mockResolvedValue([]);
|
||||
|
||||
await sut.handleAssetEditProcessing({ id: asset.id });
|
||||
await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
|
||||
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ thumbhash: thumbhashBuffer }));
|
||||
});
|
||||
|
||||
it('should return failed if asset not found', async () => {
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(undefined as never);
|
||||
const status = await sut.handleAssetEditProcessing({ id: 'non-existent' });
|
||||
expect(status).toBe(JobStatus.Failed);
|
||||
});
|
||||
|
||||
it('should transcode edited video and generate thumbnails', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
|
||||
.exif()
|
||||
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 0, y: 0 } })
|
||||
.files([
|
||||
{ type: AssetFileType.Preview, isEdited: false },
|
||||
{ type: AssetFileType.EncodedVideo, isEdited: true },
|
||||
{ type: AssetFileType.Preview, isEdited: true },
|
||||
{ type: AssetFileType.Thumbnail, isEdited: true },
|
||||
])
|
||||
.build();
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
|
||||
await sut.handleAssetEditProcessing({ id: asset.id });
|
||||
|
||||
// should transcode the video with hw accel disabled
|
||||
expect(mocks.media.transcode).toHaveBeenCalledWith(
|
||||
'/original/video.mp4',
|
||||
expect.stringContaining('edited'),
|
||||
expect.objectContaining({
|
||||
inputOptions: expect.any(Array),
|
||||
outputOptions: expect.any(Array),
|
||||
}),
|
||||
);
|
||||
|
||||
// should generate edited thumbnails (preview + thumbnail via transcode)
|
||||
expect(mocks.media.transcode).toHaveBeenCalledTimes(3); // 1 video + 2 thumbnails
|
||||
|
||||
// should update thumbhash
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ thumbhash: thumbhashBuffer }));
|
||||
});
|
||||
|
||||
it('should clean up edited video files when asset has no edits', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
|
||||
.exif()
|
||||
.files([
|
||||
{ type: AssetFileType.EncodedVideo, path: 'edited_video.mp4', isEdited: true },
|
||||
{ type: AssetFileType.Preview, path: 'edited_preview.jpg', isEdited: true },
|
||||
{ type: AssetFileType.Thumbnail, path: 'edited_thumbnail.webp', isEdited: true },
|
||||
])
|
||||
.build();
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.media.generateThumbhash.mockResolvedValue(factory.buffer());
|
||||
|
||||
await sut.handleAssetEditProcessing({ id: asset.id });
|
||||
|
||||
// should not transcode since there are no edits
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
|
||||
// should delete old edited files
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
data: {
|
||||
files: expect.arrayContaining(['edited_video.mp4']),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip thumbnail generation for hidden video assets (live photo video portions)', async () => {
|
||||
const asset = AssetFactory.from({
|
||||
type: AssetType.Video,
|
||||
originalPath: '/original/video.mp4',
|
||||
visibility: AssetVisibility.Hidden,
|
||||
})
|
||||
.exif()
|
||||
.edit({ action: AssetEditAction.Crop })
|
||||
.files([{ type: AssetFileType.Preview, isEdited: false }])
|
||||
.build();
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
|
||||
await sut.handleAssetEditProcessing({ id: asset.id });
|
||||
|
||||
expect(mocks.media.transcode).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.media.generateThumbhash).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use original thumbhash when video has no edits but is visible', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
|
||||
.exif()
|
||||
.files([{ type: AssetFileType.Preview, path: '/thumbs/preview.jpg', isEdited: false }])
|
||||
.build();
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
const thumbhashBuffer = factory.buffer();
|
||||
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
|
||||
|
||||
await sut.handleAssetEditProcessing({ id: asset.id });
|
||||
|
||||
expect(mocks.media.generateThumbhash).toHaveBeenCalledWith('/thumbs/preview.jpg', expect.any(Object));
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: asset.id, thumbhash: thumbhashBuffer }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update dimensions from transcoded video edit', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/video.mp4' })
|
||||
.exif()
|
||||
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 800, x: 100, y: 100 } })
|
||||
.files([{ type: AssetFileType.Preview, isEdited: false }])
|
||||
.build();
|
||||
mocks.assetJob.getForAssetEditProcessing.mockResolvedValue(getForGenerateThumbnail(asset));
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
mocks.media.generateThumbhash.mockResolvedValue(factory.buffer());
|
||||
|
||||
await sut.handleAssetEditProcessing({ id: asset.id });
|
||||
|
||||
// should update asset dimensions
|
||||
expect(mocks.asset.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: asset.id, width: 800, height: 500 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleGeneratePersonThumbnail', () => {
|
||||
@@ -2179,7 +1974,7 @@ describe(MediaService.name, () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.noAudioStreams);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'foo' } } as never as SystemConfig);
|
||||
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed);
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -2433,7 +2228,7 @@ describe(MediaService.name, () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: 'invalid' as any } });
|
||||
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed);
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -2831,14 +2626,14 @@ describe(MediaService.name, () => {
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
ffmpeg: { accel: TranscodeHardwareAcceleration.Nvenc, targetVideoCodec: VideoCodec.Vp9 },
|
||||
});
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed);
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail if hwaccel option is invalid', async () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: 'invalid' as any } });
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed);
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -3125,7 +2920,7 @@ describe(MediaService.name, () => {
|
||||
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv } });
|
||||
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed);
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
|
||||
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -3535,7 +3330,7 @@ describe(MediaService.name, () => {
|
||||
sut.videoInterfaces = { dri: [], mali: true };
|
||||
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi } });
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).resolves.toBe(JobStatus.Failed);
|
||||
await expect(sut.handleVideoConversion({ id: 'video-id' })).rejects.toThrowError();
|
||||
expect(mocks.media.transcode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -3810,95 +3605,6 @@ describe(MediaService.name, () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should also transcode edited version when asset has edits', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' })
|
||||
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 0, y: 0 } })
|
||||
.build();
|
||||
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
|
||||
await sut.handleVideoConversion({ id: asset.id });
|
||||
|
||||
// should be called for both original and edited
|
||||
expect(mocks.media.probe).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.media.transcode).toHaveBeenCalledWith(
|
||||
'/original/path.ext',
|
||||
expect.stringContaining('edited'),
|
||||
expect.objectContaining({
|
||||
inputOptions: expect.any(Array),
|
||||
outputOptions: expect.any(Array),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not transcode edited version when asset has no edits', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).build();
|
||||
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
|
||||
await sut.handleVideoConversion({ id: asset.id });
|
||||
|
||||
// probe is called for both original and edit attempt, but only original is transcoded
|
||||
expect(mocks.media.transcode).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ isEdited: true })]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should disable hardware acceleration for edited video transcoding', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' })
|
||||
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 0, y: 0 } })
|
||||
.build();
|
||||
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, transcode: TranscodePolicy.All },
|
||||
});
|
||||
|
||||
await sut.handleVideoConversion({ id: asset.id });
|
||||
|
||||
// the edited transcode call should NOT have hw accel options
|
||||
const transcodeCalls = mocks.media.transcode.mock.calls;
|
||||
const editedCall = transcodeCalls.find((call) => (call[1] as string).includes('edited'));
|
||||
expect(editedCall).toBeDefined();
|
||||
// hw accel typically adds device-specific input options; for edited, should be software only
|
||||
expect(editedCall![2].inputOptions).not.toEqual(expect.arrayContaining([expect.stringContaining('qsv')]));
|
||||
});
|
||||
|
||||
it('should upsert both original and edited encoded video files', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' })
|
||||
.edit({ action: AssetEditAction.Crop, parameters: { height: 500, width: 500, x: 0, y: 0 } })
|
||||
.build();
|
||||
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
|
||||
await sut.handleVideoConversion({ id: asset.id });
|
||||
|
||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: AssetFileType.EncodedVideo, isEdited: false }),
|
||||
expect.objectContaining({ type: AssetFileType.EncodedVideo, isEdited: true }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should clean up edited encoded video when edits are removed', async () => {
|
||||
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' })
|
||||
.file({ type: AssetFileType.EncodedVideo, path: '/encoded/edited_video.mp4', isEdited: true })
|
||||
.build();
|
||||
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);
|
||||
mocks.media.probe.mockResolvedValue(probeStub.multipleVideoStreams);
|
||||
|
||||
await sut.handleVideoConversion({ id: asset.id });
|
||||
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.FileDelete,
|
||||
data: {
|
||||
files: expect.arrayContaining(['/encoded/edited_video.mp4']),
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSRGB', () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
||||
import { AssetFile } from 'src/database';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { AssetEditAction, AssetEditActionItem, CropParameters } from 'src/dtos/editing.dto';
|
||||
import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
|
||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
VideoInterfaces,
|
||||
VideoStreamInfo,
|
||||
} from 'src/types';
|
||||
import { getDimensions } from 'src/utils/asset.util';
|
||||
import { getAssetFile, getDimensions } from 'src/utils/asset.util';
|
||||
import { checkFaceVisibility, checkOcrVisibility } from 'src/utils/editor';
|
||||
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
@@ -56,13 +56,6 @@ interface UpsertFileOptions {
|
||||
}
|
||||
|
||||
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
|
||||
type VideoConversionAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForVideoConversion']>>>;
|
||||
|
||||
type ThumbnailGenerationResult = {
|
||||
files: UpsertFileOptions[];
|
||||
thumbhash: Buffer;
|
||||
fullsizeDimensions: ImageDimensions;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MediaService extends BaseService {
|
||||
@@ -91,7 +84,7 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
|
||||
if (asset.isEdited) {
|
||||
jobs.push({ name: JobName.AssetProcessEdit, data: { id: asset.id } });
|
||||
jobs.push({ name: JobName.AssetEditThumbnailGeneration, data: { id: asset.id } });
|
||||
}
|
||||
|
||||
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
@@ -175,9 +168,9 @@ export class MediaService extends BaseService {
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.AssetProcessEdit, queue: QueueName.Editor })
|
||||
async handleAssetEditProcessing({ id }: JobOf<JobName.AssetProcessEdit>): Promise<JobStatus> {
|
||||
const asset = await this.assetJobRepository.getForAssetEditProcessing(id);
|
||||
@OnJob({ name: JobName.AssetEditThumbnailGeneration, queue: QueueName.Editor })
|
||||
async handleAssetEditThumbnailGeneration({ id }: JobOf<JobName.AssetEditThumbnailGeneration>): Promise<JobStatus> {
|
||||
const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id);
|
||||
const config = await this.getConfig({ withCache: true });
|
||||
|
||||
if (!asset) {
|
||||
@@ -185,25 +178,7 @@ export class MediaService extends BaseService {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
switch (asset.type) {
|
||||
case AssetType.Image: {
|
||||
await this.handleImageEdit(asset, config);
|
||||
break;
|
||||
}
|
||||
case AssetType.Video: {
|
||||
await this.handleVideoEdit(asset, config);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
|
||||
}
|
||||
}
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
private async handleImageEdit(asset: ThumbnailAsset, config: SystemConfig) {
|
||||
const generated = await this.generateEditedImageThumbnails(asset, config);
|
||||
const generated = await this.generateEditedThumbnails(asset, config);
|
||||
await this.syncFiles(
|
||||
asset.files.filter((file) => file.isEdited),
|
||||
generated?.files ?? [],
|
||||
@@ -228,51 +203,8 @@ export class MediaService extends BaseService {
|
||||
|
||||
const fullsizeDimensions = generated?.fullsizeDimensions ?? getDimensions(asset.exifInfo!);
|
||||
await this.assetRepository.update({ id: asset.id, ...fullsizeDimensions });
|
||||
}
|
||||
|
||||
private async handleVideoEdit(asset: ThumbnailAsset, config: SystemConfig) {
|
||||
// transcode edited video
|
||||
const generatedVideo = asset.edits.length > 0 ? await this.transcodeVideo(asset, config.ffmpeg, true) : undefined;
|
||||
|
||||
await this.syncFiles(
|
||||
asset.files.filter((file) => file.isEdited && file.type === AssetFileType.EncodedVideo),
|
||||
generatedVideo ? [generatedVideo.file] : [],
|
||||
);
|
||||
|
||||
// update asset dimensions
|
||||
const newDimensions = generatedVideo?.dimensions ?? getDimensions(asset.exifInfo!);
|
||||
await this.assetRepository.update({ id: asset.id, ...newDimensions });
|
||||
|
||||
// if the asset is hidden, we dont need to update the thumbhash or thumbnails
|
||||
if (asset.visibility === AssetVisibility.Hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editedThumbnails = await this.generateEditedVideoThumbnails(asset, config);
|
||||
await this.syncFiles(
|
||||
asset.files.filter((file) => file.isEdited && file.type !== AssetFileType.EncodedVideo),
|
||||
editedThumbnails?.files ?? [],
|
||||
);
|
||||
|
||||
let thumbhash: Buffer | undefined = editedThumbnails?.thumbhash;
|
||||
if (!thumbhash) {
|
||||
const previewFile = asset.files.find((file) => file.type === AssetFileType.Preview && !file.isEdited);
|
||||
|
||||
if (!previewFile) {
|
||||
this.logger.warn(`Failed to generate thumbhash for asset ${asset.id}: missing preview file`);
|
||||
return;
|
||||
}
|
||||
|
||||
thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path, {
|
||||
colorspace: config.image.colorspace,
|
||||
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
|
||||
});
|
||||
}
|
||||
|
||||
// update asset table info
|
||||
if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) {
|
||||
await this.assetRepository.update({ id: asset.id, thumbhash });
|
||||
}
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.AssetGenerateThumbnails, queue: QueueName.ThumbnailGeneration })
|
||||
@@ -285,34 +217,31 @@ export class MediaService extends BaseService {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
let generated: ThumbnailGenerationResult;
|
||||
let generatedEdited: ThumbnailGenerationResult | undefined;
|
||||
|
||||
if (asset.visibility === AssetVisibility.Hidden) {
|
||||
this.logger.verbose(`Thumbnail generation skipped for asset ${id}: not visible`);
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
let generated: Awaited<ReturnType<MediaService['generateImageThumbnails']>>;
|
||||
if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) {
|
||||
this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`);
|
||||
generated = await this.generateVideoThumbnails(asset, config);
|
||||
generatedEdited = await this.generateEditedVideoThumbnails(asset, config);
|
||||
} else if (asset.type === AssetType.Image) {
|
||||
this.logger.verbose(`Thumbnail generation for image ${id} ${asset.originalPath}`);
|
||||
generated = await this.generateImageThumbnails(asset, config);
|
||||
generatedEdited = await this.generateEditedImageThumbnails(asset, config);
|
||||
} else {
|
||||
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
if (generatedEdited) {
|
||||
generated.files.push(...generatedEdited.files);
|
||||
const editedGenerated = await this.generateEditedThumbnails(asset, config);
|
||||
if (editedGenerated) {
|
||||
generated.files.push(...editedGenerated.files);
|
||||
}
|
||||
|
||||
await this.syncFiles(asset.files, generated.files);
|
||||
const thumbhash = editedGenerated?.thumbhash || generated.thumbhash;
|
||||
|
||||
const thumbhash = generatedEdited?.thumbhash || generated.thumbhash;
|
||||
if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) {
|
||||
await this.assetRepository.update({ id: asset.id, thumbhash });
|
||||
}
|
||||
@@ -578,21 +507,20 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
|
||||
private async generateVideoThumbnails(
|
||||
asset: ThumbnailPathEntity & { originalPath: string; edits: AssetEditActionItem[] },
|
||||
asset: ThumbnailPathEntity & { originalPath: string },
|
||||
{ ffmpeg, image }: SystemConfig,
|
||||
useEdits: boolean = false,
|
||||
) {
|
||||
const previewFile = this.getImageFile(asset, {
|
||||
fileType: AssetFileType.Preview,
|
||||
format: image.preview.format,
|
||||
isEdited: useEdits,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
});
|
||||
const thumbnailFile = this.getImageFile(asset, {
|
||||
fileType: AssetFileType.Thumbnail,
|
||||
format: image.thumbnail.format,
|
||||
isEdited: useEdits,
|
||||
isEdited: false,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
});
|
||||
@@ -605,27 +533,14 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
const mainAudioStream = this.getMainStream(audioStreams);
|
||||
|
||||
let edits: AssetEditActionItem[] | undefined;
|
||||
if (useEdits) {
|
||||
ffmpeg = { ...ffmpeg, accel: TranscodeHardwareAcceleration.Disabled };
|
||||
edits = asset.edits;
|
||||
}
|
||||
|
||||
const previewConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.preview.size.toString() });
|
||||
const thumbnailConfig = ThumbnailConfig.create({ ...ffmpeg, targetResolution: image.thumbnail.size.toString() });
|
||||
const previewOptions = previewConfig.getCommand(
|
||||
TranscodeTarget.Video,
|
||||
mainVideoStream,
|
||||
mainAudioStream,
|
||||
format,
|
||||
edits,
|
||||
);
|
||||
const previewOptions = previewConfig.getCommand(TranscodeTarget.Video, mainVideoStream, mainAudioStream, format);
|
||||
const thumbnailOptions = thumbnailConfig.getCommand(
|
||||
TranscodeTarget.Video,
|
||||
mainVideoStream,
|
||||
mainAudioStream,
|
||||
format,
|
||||
edits,
|
||||
);
|
||||
|
||||
await this.mediaRepository.transcode(asset.originalPath, previewFile.path, previewOptions);
|
||||
@@ -636,69 +551,73 @@ export class MediaService extends BaseService {
|
||||
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
|
||||
});
|
||||
|
||||
let fullsizeDimensions = { width: mainVideoStream.width, height: mainVideoStream.height };
|
||||
if (useEdits) {
|
||||
fullsizeDimensions = getOutputDimensions(asset.edits, fullsizeDimensions);
|
||||
}
|
||||
|
||||
return {
|
||||
files: [previewFile, thumbnailFile],
|
||||
thumbhash,
|
||||
fullsizeDimensions,
|
||||
fullsizeDimensions: { width: mainVideoStream.width, height: mainVideoStream.height },
|
||||
};
|
||||
}
|
||||
|
||||
private async transcodeVideo(
|
||||
asset: VideoConversionAsset,
|
||||
ffmpeg: SystemConfigFFmpegDto,
|
||||
useEdits: boolean = false,
|
||||
): Promise<{ file: UpsertFileOptions; dimensions: { width: number; height: number } } | undefined> {
|
||||
@OnJob({ name: JobName.AssetEncodeVideoQueueAll, queue: QueueName.VideoConversion })
|
||||
async handleQueueVideoConversion(job: JobOf<JobName.AssetEncodeVideoQueueAll>): Promise<JobStatus> {
|
||||
const { force } = job;
|
||||
|
||||
let queue: { name: JobName.AssetEncodeVideo; data: { id: string } }[] = [];
|
||||
for await (const asset of this.assetJobRepository.streamForVideoConversion(force)) {
|
||||
queue.push({ name: JobName.AssetEncodeVideo, data: { id: asset.id } });
|
||||
|
||||
if (queue.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await this.jobRepository.queueAll(queue);
|
||||
queue = [];
|
||||
}
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(queue);
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.AssetEncodeVideo, queue: QueueName.VideoConversion })
|
||||
async handleVideoConversion({ id }: JobOf<JobName.AssetEncodeVideo>): Promise<JobStatus> {
|
||||
const asset = await this.assetJobRepository.getForVideoConversion(id);
|
||||
if (!asset) {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
const input = asset.originalPath;
|
||||
const output = StorageCore.getEncodedVideoPath(asset, useEdits);
|
||||
const output = StorageCore.getEncodedVideoPath(asset);
|
||||
this.storageCore.ensureFolders(output);
|
||||
|
||||
const { videoStreams, audioStreams, format } = await this.mediaRepository.probe(input, {
|
||||
countFrames: this.logger.isLevelEnabled(LogLevel.Debug),
|
||||
countFrames: this.logger.isLevelEnabled(LogLevel.Debug), // makes frame count more reliable for progress logs
|
||||
});
|
||||
const videoStream = this.getMainStream(videoStreams);
|
||||
const audioStream = this.getMainStream(audioStreams);
|
||||
if (!videoStream || !format.formatName) {
|
||||
return undefined;
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
if (!videoStream.height || !videoStream.width) {
|
||||
this.logger.warn(`Skipped transcoding for asset ${asset.id}: no video streams found`);
|
||||
return undefined;
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
let target: TranscodeTarget;
|
||||
let edits: AssetEditActionItem[] | undefined;
|
||||
|
||||
if (useEdits) {
|
||||
if (asset.edits.length === 0) {
|
||||
this.logger.verbose(`Asset ${asset.id} has no edits, skipping edited version transcoding`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
ffmpeg = { ...ffmpeg, accel: TranscodeHardwareAcceleration.Disabled };
|
||||
target = TranscodeTarget.All;
|
||||
edits = asset.edits;
|
||||
} else {
|
||||
target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream);
|
||||
if (target === TranscodeTarget.None && !this.isRemuxRequired(ffmpeg, format)) {
|
||||
let { ffmpeg } = await this.getConfig({ withCache: true });
|
||||
const target = this.getTranscodeTarget(ffmpeg, videoStream, audioStream);
|
||||
if (target === TranscodeTarget.None && !this.isRemuxRequired(ffmpeg, format)) {
|
||||
const encodedVideo = getAssetFile(asset.files, AssetFileType.EncodedVideo, { isEdited: false });
|
||||
if (encodedVideo) {
|
||||
this.logger.log(`Transcoded video exists for asset ${asset.id}, but is no longer required. Deleting...`);
|
||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [encodedVideo.path] } });
|
||||
await this.assetRepository.deleteFiles([encodedVideo]);
|
||||
} else {
|
||||
this.logger.verbose(`Asset ${asset.id} does not require transcoding based on current policy, skipping`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return JobStatus.Skipped;
|
||||
}
|
||||
|
||||
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(
|
||||
target,
|
||||
videoStream,
|
||||
audioStream,
|
||||
useEdits ? undefined : format,
|
||||
edits,
|
||||
);
|
||||
|
||||
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
|
||||
if (ffmpeg.accel === TranscodeHardwareAcceleration.Disabled) {
|
||||
this.logger.log(`Transcoding video ${asset.id} without hardware acceleration`);
|
||||
} else {
|
||||
@@ -712,7 +631,7 @@ export class MediaService extends BaseService {
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Error occurred during transcoding: ${error.message}`);
|
||||
if (ffmpeg.accel === TranscodeHardwareAcceleration.Disabled) {
|
||||
throw error;
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
let partialFallbackSuccess = false;
|
||||
@@ -720,13 +639,7 @@ export class MediaService extends BaseService {
|
||||
try {
|
||||
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()}-accelerated encoding and software decoding`);
|
||||
ffmpeg = { ...ffmpeg, accelDecode: false };
|
||||
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(
|
||||
target,
|
||||
videoStream,
|
||||
audioStream,
|
||||
format,
|
||||
edits,
|
||||
);
|
||||
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
|
||||
await this.mediaRepository.transcode(input, output, command);
|
||||
partialFallbackSuccess = true;
|
||||
} catch (error: any) {
|
||||
@@ -737,87 +650,19 @@ export class MediaService extends BaseService {
|
||||
if (!partialFallbackSuccess) {
|
||||
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`);
|
||||
ffmpeg = { ...ffmpeg, accel: TranscodeHardwareAcceleration.Disabled };
|
||||
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(
|
||||
target,
|
||||
videoStream,
|
||||
audioStream,
|
||||
format,
|
||||
edits,
|
||||
);
|
||||
const command = BaseConfig.create(ffmpeg, this.videoInterfaces).getCommand(target, videoStream, audioStream);
|
||||
await this.mediaRepository.transcode(input, output, command);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Successfully encoded ${asset.id}`);
|
||||
|
||||
let finalDimensions = { width: videoStream.width, height: videoStream.height };
|
||||
if (useEdits) {
|
||||
finalDimensions = getOutputDimensions(asset.edits, finalDimensions);
|
||||
}
|
||||
|
||||
return {
|
||||
dimensions: finalDimensions,
|
||||
file: {
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.EncodedVideo,
|
||||
path: output,
|
||||
isEdited: useEdits,
|
||||
isProgressive: false,
|
||||
isTransparent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.AssetEncodeVideoQueueAll, queue: QueueName.VideoConversion })
|
||||
async handleQueueVideoConversion(job: JobOf<JobName.AssetEncodeVideoQueueAll>): Promise<JobStatus> {
|
||||
const { force } = job;
|
||||
|
||||
let jobs: JobItem[] = [];
|
||||
for await (const asset of this.assetJobRepository.streamForVideoConversion(force)) {
|
||||
if (force || !asset.isEdited) {
|
||||
jobs.push({ name: JobName.AssetEncodeVideo, data: { id: asset.id } });
|
||||
}
|
||||
|
||||
if (asset.isEdited) {
|
||||
jobs.push({ name: JobName.AssetProcessEdit, data: { id: asset.id } });
|
||||
}
|
||||
|
||||
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
jobs = [];
|
||||
}
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.AssetEncodeVideo, queue: QueueName.VideoConversion })
|
||||
async handleVideoConversion({ id }: JobOf<JobName.AssetEncodeVideo>): Promise<JobStatus> {
|
||||
const asset = await this.assetJobRepository.getForVideoConversion(id);
|
||||
if (!asset) {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
const { ffmpeg } = await this.getConfig({ withCache: true });
|
||||
|
||||
const files: UpsertFileOptions[] = [];
|
||||
try {
|
||||
const generated = await this.transcodeVideo(asset, ffmpeg);
|
||||
if (generated?.file) {
|
||||
files.push(generated.file);
|
||||
}
|
||||
|
||||
const editedGenerated = await this.transcodeVideo(asset, ffmpeg, true);
|
||||
if (editedGenerated) {
|
||||
files.push(editedGenerated.file);
|
||||
}
|
||||
} catch {
|
||||
return JobStatus.Failed;
|
||||
}
|
||||
|
||||
await this.syncFiles(asset.files, files);
|
||||
await this.assetRepository.upsertFile({
|
||||
assetId: asset.id,
|
||||
type: AssetFileType.EncodedVideo,
|
||||
path: output,
|
||||
isEdited: false,
|
||||
});
|
||||
|
||||
return JobStatus.Success;
|
||||
}
|
||||
@@ -1029,29 +874,13 @@ export class MediaService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private async generateEditedImageThumbnails(asset: ThumbnailAsset, config: SystemConfig) {
|
||||
private async generateEditedThumbnails(asset: ThumbnailAsset, config: SystemConfig) {
|
||||
if (asset.type !== AssetType.Image || (asset.files.length === 0 && asset.edits.length === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, config, true) : undefined;
|
||||
await this.updateMLVisibilities(asset);
|
||||
|
||||
return generated;
|
||||
}
|
||||
|
||||
private async generateEditedVideoThumbnails(asset: ThumbnailAsset, config: SystemConfig) {
|
||||
if (asset.type !== AssetType.Video || (asset.files.length === 0 && asset.edits.length === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const generated = asset.edits.length > 0 ? await this.generateVideoThumbnails(asset, config, true) : undefined;
|
||||
await this.updateMLVisibilities(asset);
|
||||
|
||||
return generated;
|
||||
}
|
||||
|
||||
private async updateMLVisibilities(asset: ThumbnailAsset) {
|
||||
const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop);
|
||||
const cropBox = crop
|
||||
? {
|
||||
@@ -1071,6 +900,8 @@ export class MediaService extends BaseService {
|
||||
|
||||
const ocrStatuses = checkOcrVisibility(ocrData, originalDimensions, cropBox);
|
||||
await this.ocrRepository.updateOcrVisibilities(asset.id, ocrStatuses.visible, ocrStatuses.hidden);
|
||||
|
||||
return generated;
|
||||
}
|
||||
|
||||
private warnOnTransparencyLoss(isTransparent: boolean, format: ImageFormat, assetId: string) {
|
||||
|
||||
+1
-3
@@ -130,7 +130,6 @@ export interface TranscodeCommand {
|
||||
progress: {
|
||||
frameCount: number;
|
||||
percentInterval: number;
|
||||
callback: (percent: number, frame: number) => void;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,7 +151,6 @@ export interface VideoCodecSWConfig {
|
||||
videoStream: VideoStreamInfo,
|
||||
audioStream: AudioStreamInfo,
|
||||
format?: VideoFormat,
|
||||
edits?: AssetEditActionItem[],
|
||||
): TranscodeCommand;
|
||||
}
|
||||
|
||||
@@ -391,7 +389,7 @@ export type JobItem =
|
||||
| { name: JobName.WorkflowRun; data: IWorkflowJob }
|
||||
|
||||
// Editor
|
||||
| { name: JobName.AssetProcessEdit; data: IEntityJob };
|
||||
| { name: JobName.AssetEditThumbnailGeneration; data: IEntityJob };
|
||||
|
||||
export type VectorExtension = (typeof VECTOR_EXTENSIONS)[number];
|
||||
|
||||
|
||||
@@ -116,24 +116,22 @@ export function withFaces(eb: ExpressionBuilder<DB, 'asset'>, withHidden?: boole
|
||||
).as('faces');
|
||||
}
|
||||
|
||||
export function withFiles(eb: ExpressionBuilder<DB, 'asset'>, type?: AssetFileType | AssetFileType[]) {
|
||||
export function withFiles(eb: ExpressionBuilder<DB, 'asset'>, type?: AssetFileType) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('asset_file')
|
||||
.select(columns.assetFiles)
|
||||
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||
.$if(!!type && typeof type === 'string', (qb) => qb.where('asset_file.type', '=', type!))
|
||||
.$if(!!type && Array.isArray(type), (qb) => qb.where('asset_file.type', 'in', type as AssetFileType[])),
|
||||
.$if(!!type, (qb) => qb.where('asset_file.type', '=', type!)),
|
||||
).as('files');
|
||||
}
|
||||
|
||||
export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFileType, isEdited = false) {
|
||||
export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFileType) {
|
||||
return eb
|
||||
.selectFrom('asset_file')
|
||||
.select('asset_file.path')
|
||||
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||
.where('asset_file.type', '=', type)
|
||||
.where('asset_file.isEdited', '=', isEdited);
|
||||
.where('asset_file.type', '=', type);
|
||||
}
|
||||
|
||||
export function withFacesAndPeople(
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { AssetFace } from 'src/database';
|
||||
import { AssetEditActionItem, CropParameters } from 'src/dtos/editing.dto';
|
||||
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
|
||||
import { ImageDimensions } from 'src/types';
|
||||
|
||||
@@ -32,15 +31,6 @@ const scale = (box: BoundingBox, target: ImageDimensions, source?: ImageDimensio
|
||||
};
|
||||
};
|
||||
|
||||
const scaleCrop = (crop: CropParameters, target: ImageDimensions, source: ImageDimensions) => {
|
||||
return {
|
||||
width: Math.round((crop.width / source.width) * target.width),
|
||||
height: Math.round((crop.height / source.height) * target.height),
|
||||
x: Math.round((crop.x / source.width) * target.width),
|
||||
y: Math.round((crop.y / source.height) * target.height),
|
||||
};
|
||||
};
|
||||
|
||||
export const checkFaceVisibility = (
|
||||
faces: AssetFace[],
|
||||
originalAssetDimensions: ImageDimensions,
|
||||
@@ -115,20 +105,3 @@ export const checkOcrVisibility = (
|
||||
hidden: status.filter((s) => !s.isVisible).map((s) => s.ocr),
|
||||
};
|
||||
};
|
||||
|
||||
export const scaleEdits = (
|
||||
edits: AssetEditActionItem[],
|
||||
target: ImageDimensions,
|
||||
source: ImageDimensions,
|
||||
): AssetEditActionItem[] => {
|
||||
return edits.map((edit) => {
|
||||
if (edit.action === 'crop') {
|
||||
return {
|
||||
...edit,
|
||||
parameters: scaleCrop(edit.parameters as CropParameters, target, source),
|
||||
} as AssetEditActionItem;
|
||||
}
|
||||
|
||||
return edit;
|
||||
});
|
||||
};
|
||||
|
||||
+12
-95
@@ -1,12 +1,4 @@
|
||||
import { AUDIO_ENCODER } from 'src/constants';
|
||||
import {
|
||||
AssetEditAction,
|
||||
AssetEditActionItem,
|
||||
CropParameters,
|
||||
MirrorAxis,
|
||||
MirrorParameters,
|
||||
RotateParameters,
|
||||
} from 'src/dtos/editing.dto';
|
||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||
import { CQMode, ToneMapping, TranscodeHardwareAcceleration, TranscodeTarget, VideoCodec } from 'src/enum';
|
||||
import {
|
||||
@@ -96,26 +88,15 @@ export class BaseConfig implements VideoCodecSWConfig {
|
||||
videoStream: VideoStreamInfo,
|
||||
audioStream?: AudioStreamInfo,
|
||||
format?: VideoFormat,
|
||||
edits: AssetEditActionItem[] = [],
|
||||
) {
|
||||
const inputOptions = this.getBaseInputOptions(videoStream, format);
|
||||
|
||||
if (edits.length > 0) {
|
||||
// turns out MOV files can have cropping metadata that ffmpeg automatically applies when decoding
|
||||
// this means that the video streams dimensions can just be wrong once it hits the filter pipeline
|
||||
// https://github.com/FFmpeg/FFmpeg/blob/f40fcf802472227851e0b8eeba40b9e6b3b8a3a1/libavutil/frame.h#L1021
|
||||
inputOptions.push('-apply_cropping 0');
|
||||
}
|
||||
|
||||
const options = {
|
||||
inputOptions,
|
||||
inputOptions: this.getBaseInputOptions(videoStream, format),
|
||||
outputOptions: [...this.getBaseOutputOptions(target, videoStream, audioStream), '-v verbose'],
|
||||
twoPass: this.eligibleForTwoPass(),
|
||||
progress: { frameCount: videoStream.frameCount, percentInterval: 5 },
|
||||
} as TranscodeCommand;
|
||||
|
||||
if ([TranscodeTarget.All, TranscodeTarget.Video].includes(target)) {
|
||||
const filters = this.getFilterOptions(videoStream, edits);
|
||||
const filters = this.getFilterOptions(videoStream);
|
||||
if (filters.length > 0) {
|
||||
options.outputOptions.push(`-vf ${filters.join(',')}`);
|
||||
}
|
||||
@@ -175,46 +156,10 @@ export class BaseConfig implements VideoCodecSWConfig {
|
||||
return options;
|
||||
}
|
||||
|
||||
getEditOptions(videoStream: VideoStreamInfo, edits: AssetEditActionItem[]) {
|
||||
getFilterOptions(videoStream: VideoStreamInfo) {
|
||||
const options = [];
|
||||
let currentDimensions = { width: videoStream.width, height: videoStream.height };
|
||||
|
||||
// Apply CPU edit operations before hwupload
|
||||
for (const edit of edits) {
|
||||
switch (edit.action) {
|
||||
case AssetEditAction.Crop: {
|
||||
options.push(this.getCropOperation(edit.parameters));
|
||||
currentDimensions = { width: edit.parameters.width, height: edit.parameters.height };
|
||||
break;
|
||||
}
|
||||
case AssetEditAction.Rotate: {
|
||||
const rotateFilter = this.getRotateOperation(edit.parameters);
|
||||
if (rotateFilter) {
|
||||
options.push(rotateFilter);
|
||||
if (Math.abs(edit.parameters.angle) === 90 || Math.abs(edit.parameters.angle) === 270) {
|
||||
currentDimensions = { width: currentDimensions.height, height: currentDimensions.width };
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case AssetEditAction.Mirror: {
|
||||
options.push(this.getMirrorOperation(edit.parameters));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { options, currentDimensions };
|
||||
}
|
||||
|
||||
getFilterOptions(videoStream: VideoStreamInfo, edits: AssetEditActionItem[] = []) {
|
||||
const options = [];
|
||||
const { options: editOptions, currentDimensions } = this.getEditOptions(videoStream, edits);
|
||||
options.push(...editOptions);
|
||||
|
||||
// Apply scaling based on current dimensions after edits
|
||||
if (this.shouldScale(videoStream, currentDimensions)) {
|
||||
options.push(`scale=${this.getScaling(videoStream, 2, currentDimensions)}`);
|
||||
if (this.shouldScale(videoStream)) {
|
||||
options.push(`scale=${this.getScaling(videoStream)}`);
|
||||
}
|
||||
|
||||
const tonemapOptions = this.getToneMapping(videoStream);
|
||||
@@ -293,10 +238,9 @@ export class BaseConfig implements VideoCodecSWConfig {
|
||||
return target;
|
||||
}
|
||||
|
||||
shouldScale(videoStream: VideoStreamInfo, currentDimensions?: { width: number; height: number }) {
|
||||
const dims = currentDimensions || { width: videoStream.width, height: videoStream.height };
|
||||
const oddDimensions = dims.height % 2 !== 0 || dims.width % 2 !== 0;
|
||||
const largerThanTarget = Math.min(dims.height, dims.width) > this.getTargetResolution(videoStream);
|
||||
shouldScale(videoStream: VideoStreamInfo) {
|
||||
const oddDimensions = videoStream.height % 2 !== 0 || videoStream.width % 2 !== 0;
|
||||
const largerThanTarget = Math.min(videoStream.height, videoStream.width) > this.getTargetResolution(videoStream);
|
||||
return oddDimensions || largerThanTarget;
|
||||
}
|
||||
|
||||
@@ -304,11 +248,9 @@ export class BaseConfig implements VideoCodecSWConfig {
|
||||
return videoStream.isHDR && this.config.tonemap !== ToneMapping.Disabled;
|
||||
}
|
||||
|
||||
getScaling(videoStream: VideoStreamInfo, mult = 2, currentDimensions?: { width: number; height: number }) {
|
||||
const dims = currentDimensions || { width: videoStream.width, height: videoStream.height };
|
||||
getScaling(videoStream: VideoStreamInfo, mult = 2) {
|
||||
const targetResolution = this.getTargetResolution(videoStream);
|
||||
const isVertical = dims.height > dims.width || this.isVideoRotated(videoStream);
|
||||
return isVertical ? `${targetResolution}:-${mult}` : `-${mult}:${targetResolution}`;
|
||||
return this.isVideoVertical(videoStream) ? `${targetResolution}:-${mult}` : `-${mult}:${targetResolution}`;
|
||||
}
|
||||
|
||||
getSize(videoStream: VideoStreamInfo) {
|
||||
@@ -387,31 +329,6 @@ export class BaseConfig implements VideoCodecSWConfig {
|
||||
useCQP() {
|
||||
return this.config.cqMode === CQMode.Cqp;
|
||||
}
|
||||
|
||||
// Edit operations (software filters)
|
||||
getCropOperation({ x, y, width, height }: CropParameters): string {
|
||||
return `crop=${width}:${height}:${x}:${y}`;
|
||||
}
|
||||
|
||||
getRotateOperation({ angle }: RotateParameters): string {
|
||||
switch (angle) {
|
||||
case 90: {
|
||||
return 'transpose=1'; // 90° clockwise
|
||||
}
|
||||
case 180: {
|
||||
return 'hflip,vflip'; // 180°
|
||||
}
|
||||
case 270: {
|
||||
return 'transpose=2'; // 90° counter-clockwise (270° clockwise)
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
getMirrorOperation({ axis }: MirrorParameters): string {
|
||||
return axis === MirrorAxis.Horizontal ? 'hflip' : 'vflip';
|
||||
}
|
||||
}
|
||||
|
||||
export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
|
||||
@@ -506,14 +423,14 @@ export class ThumbnailConfig extends BaseConfig {
|
||||
return ['-fps_mode vfr', '-frames:v 1', '-update 1'];
|
||||
}
|
||||
|
||||
getFilterOptions(videoStream: VideoStreamInfo, edits: AssetEditActionItem[] = []): string[] {
|
||||
getFilterOptions(videoStream: VideoStreamInfo): string[] {
|
||||
return [
|
||||
'fps=12:start_time=0:eof_action=pass:round=down',
|
||||
'thumbnail=12',
|
||||
String.raw`select=gt(scene\,0.1)-eq(prev_selected_n\,n)+isnan(prev_selected_n)+gt(n\,20)`,
|
||||
'trim=end_frame=2',
|
||||
'reverse',
|
||||
...super.getFilterOptions(videoStream, edits),
|
||||
...super.getFilterOptions(videoStream),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { SearchSuggestionType } from 'src/dtos/search.dto';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
@@ -108,4 +109,25 @@ describe(SearchService.name, () => {
|
||||
expect(response.assets.items[0].id).toBe(unstackedAsset.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSearchSuggestions', () => {
|
||||
it('should filter out empty search suggestions', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
|
||||
|
||||
const { asset: assetWithEmptyMake } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: assetWithEmptyMake.id, make: '' });
|
||||
|
||||
const auth = factory.auth({ user: { id: user.id } });
|
||||
const suggestions = await sut.getSearchSuggestions(auth, {
|
||||
type: SearchSuggestionType.CAMERA_MAKE,
|
||||
includeNull: true,
|
||||
});
|
||||
|
||||
expect(suggestions).toEqual(['Canon', null]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "2.6.2",
|
||||
"version": "2.6.3",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -120,10 +120,10 @@
|
||||
<ActionButton action={Cast} />
|
||||
<ActionButton action={Actions.Share} />
|
||||
<ActionButton action={Actions.Offline} />
|
||||
<ActionButton action={Actions.PlayMotionPhoto} />
|
||||
<ActionButton action={Actions.StopMotionPhoto} />
|
||||
<ActionButton action={Actions.ZoomIn} />
|
||||
<ActionButton action={Actions.ZoomOut} />
|
||||
<ActionButton action={Actions.PlayMotionPhoto} />
|
||||
<ActionButton action={Actions.StopMotionPhoto} />
|
||||
<ActionButton action={Actions.Copy} />
|
||||
<ActionButton action={Actions.SharedLinkDownload} />
|
||||
<ActionButton action={Actions.Info} />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
|
||||
import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock';
|
||||
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { preferences as preferencesStore, resetSavedUser, user as userStore } from '$lib/stores/user.store';
|
||||
import { renderWithTooltips } from '$tests/helpers';
|
||||
import { updateAsset } from '@immich/sdk';
|
||||
@@ -41,6 +42,7 @@ describe('AssetViewer', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
slideshowStore.slideshowState.set(SlideshowState.None);
|
||||
resetSavedUser();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -485,7 +485,7 @@
|
||||
{/if}
|
||||
|
||||
{#if $slideshowState != SlideshowState.None}
|
||||
<div class="absolute w-full flex justify-center">
|
||||
<div class="absolute inset-s-0 top-0 flex w-full justify-start">
|
||||
<SlideshowBar
|
||||
{isFullScreen}
|
||||
assetType={previewStackedAsset?.type ?? asset.type}
|
||||
@@ -580,17 +580,16 @@
|
||||
<div
|
||||
transition:fly={{ duration: 150 }}
|
||||
id="detail-panel"
|
||||
class="row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
|
||||
class={[
|
||||
'row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light',
|
||||
showDetailPanel ? 'w-90' : 'w-100',
|
||||
]}
|
||||
translate="yes"
|
||||
>
|
||||
{#if showDetailPanel}
|
||||
<div class="w-90 h-full">
|
||||
<DetailPanel {asset} currentAlbum={album} />
|
||||
</div>
|
||||
<DetailPanel {asset} currentAlbum={album} />
|
||||
{:else if assetViewerManager.isShowEditor}
|
||||
<div class="w-100 h-full">
|
||||
<EditorPanel {asset} onClose={closeEditor} />
|
||||
</div>
|
||||
<EditorPanel {asset} onClose={closeEditor} />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{ label: '2:3', value: '2:3', width: 16, height: 24 },
|
||||
{ label: '16:9', value: '16:9', width: 24, height: 14 },
|
||||
{ label: '9:16', value: '9:16', width: 14, height: 24 },
|
||||
{ label: 'Square', value: '1:1', width: 20, height: 20 },
|
||||
{ label: $t('crop_aspect_ratio_square'), value: '1:1', width: 20, height: 20 },
|
||||
];
|
||||
|
||||
let isRotated = $derived(transformManager.normalizedRotation % 180 !== 0);
|
||||
|
||||
@@ -139,7 +139,6 @@ export class AssetViewerManager extends BaseEventManager<Events> {
|
||||
|
||||
openEditor() {
|
||||
this.closeActivityPanel();
|
||||
this.isPlayingMotionPhoto = false;
|
||||
this.isShowEditor = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -127,17 +127,7 @@ export class EditManager {
|
||||
|
||||
try {
|
||||
// Setup the websocket listener before sending the edit request
|
||||
const editEvents = [waitForWebsocketEvent('AssetEditReadyV1', (event) => event.asset.id === assetId, 10_000)];
|
||||
|
||||
if (this.currentAsset.livePhotoVideoId) {
|
||||
editEvents.push(
|
||||
waitForWebsocketEvent(
|
||||
'AssetEditReadyV1',
|
||||
(event) => event.asset.id === this.currentAsset!.livePhotoVideoId,
|
||||
10_000,
|
||||
),
|
||||
);
|
||||
}
|
||||
const editCompleted = waitForWebsocketEvent('AssetEditReadyV1', (event) => event.asset.id === assetId, 10_000);
|
||||
|
||||
await (edits.length === 0
|
||||
? removeAssetEdits({ id: assetId })
|
||||
@@ -148,7 +138,7 @@ export class EditManager {
|
||||
},
|
||||
}));
|
||||
|
||||
await Promise.all(editEvents);
|
||||
await editCompleted;
|
||||
|
||||
eventManager.emit('AssetEditsApplied', assetId);
|
||||
|
||||
|
||||
@@ -242,6 +242,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
||||
!sharedLink &&
|
||||
isOwner &&
|
||||
asset.type === AssetTypeEnum.Image &&
|
||||
!asset.livePhotoVideoId &&
|
||||
asset.exifInfo?.projectionType !== ProjectionType.EQUIRECTANGULAR &&
|
||||
!asset.originalPath.toLowerCase().endsWith('.insp') &&
|
||||
!asset.originalPath.toLowerCase().endsWith('.gif') &&
|
||||
|
||||
@@ -238,8 +238,8 @@ export const getAssetMediaUrl = (options: AssetUrlOptions) => {
|
||||
};
|
||||
|
||||
export const getAssetPlaybackUrl = (options: AssetUrlOptions) => {
|
||||
const { id, cacheKey: c, edited = true } = options;
|
||||
return createUrl(getAssetPlaybackPath(id), { ...authManager.params, c, edited });
|
||||
const { id, cacheKey: c } = options;
|
||||
return createUrl(getAssetPlaybackPath(id), { ...authManager.params, c });
|
||||
};
|
||||
|
||||
export const getProfileImageUrl = (user: UserResponseDto) =>
|
||||
|
||||
Reference in New Issue
Block a user