Compare commits

..

15 Commits

Author SHA1 Message Date
mertalev
d846f7fc7f always use hw filters when hwa is enabled 2024-11-22 03:00:30 -05:00
mertalev
efb4394c7b formatting 2024-11-22 01:51:28 -05:00
mertalev
e2188867a6 fix format, adjust log message 2024-11-22 01:46:34 -05:00
San
5e32cc7bd5 Merge branch 'main' into main 2024-11-05 20:57:34 +08:00
renovate[bot]
1d55b5bfc0 chore(deps): update dependency @types/node to ^22.8.5 (#13923)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-05 06:48:01 -05:00
renovate[bot]
60afd7b400 chore(deps): update node (#13918)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-04 19:52:23 -05:00
renovate[bot]
3f99ef90ec fix(deps): update machine-learning (#13919)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-04 19:50:17 -05:00
Pranay Pandey
380fc06979 fix: remove duplicateIds on unique assets (#13752) 2024-11-04 10:03:03 -05:00
renovate[bot]
d34d92dca3 fix(deps): update dependency exiftool-vendored to v28.7.0 (#13790)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-04 12:49:21 +00:00
San
10f8e11db1 fix unit test 2024-11-04 15:21:54 +08:00
San
18b93ddc73 if hw decoding failed with hw dec config enabled, try sw dec+hw enc first, then full sw dec+enc 2024-11-04 15:20:15 +08:00
San
88ca1f31ad fallback to software decoding if is hdr video 2024-11-03 19:50:16 +08:00
San
c30ef4dfd6 Merge branch 'main' into main 2024-11-01 23:06:53 +08:00
San
e851a9b099 Use hw decoding, sw tone-mapping on HDR files using RKMPP w/o OpenCL 2024-11-01 15:35:46 +08:00
San
e46db37e44 Set hardware decoding options for rkmpp when hardware decoding is enabled with no OpenCL on non-HDR file 2024-11-01 12:58:59 +08:00
34 changed files with 394 additions and 1211 deletions

View File

@@ -1,4 +1,4 @@
FROM node:22.10.0-alpine3.20@sha256:fc95a044b87e95507c60c1f8c829e5d98ddf46401034932499db370c494ef0ff AS core
FROM node:22.11.0-alpine3.20@sha256:f265794478aa0b1a23d85a492c8311ed795bc527c3fe7e43453b3c872dcd71a3 AS core
WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./

10
cli/package-lock.json generated
View File

@@ -24,7 +24,7 @@
"@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.8.1",
"@types/node": "^22.8.5",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^2.0.5",
@@ -59,7 +59,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.8.1",
"@types/node": "^22.8.5",
"typescript": "^5.3.3"
}
},
@@ -1378,9 +1378,9 @@
}
},
"node_modules/@types/node": {
"version": "22.8.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz",
"integrity": "sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==",
"version": "22.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
"integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -20,7 +20,7 @@
"@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.8.1",
"@types/node": "^22.8.5",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^2.0.5",

34
e2e/package-lock.json generated
View File

@@ -15,7 +15,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2",
"@types/node": "^22.8.1",
"@types/node": "^22.8.5",
"@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4",
@@ -64,7 +64,7 @@
"@types/cli-progress": "^3.11.0",
"@types/lodash-es": "^4.17.12",
"@types/mock-fs": "^4.13.1",
"@types/node": "^22.8.1",
"@types/node": "^22.8.5",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^2.0.5",
@@ -99,7 +99,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.8.1",
"@types/node": "^22.8.5",
"typescript": "^5.3.3"
}
},
@@ -1613,9 +1613,9 @@
"dev": true
},
"node_modules/@types/node": {
"version": "22.8.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz",
"integrity": "sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==",
"version": "22.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
"integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3294,9 +3294,9 @@
}
},
"node_modules/exiftool-vendored": {
"version": "28.6.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.6.0.tgz",
"integrity": "sha512-Cx8/8ov1tKEacHhsi7FNYdisIhKq/SeQfprYSpYzwBuJwkPmCV8w7tTIvUJRQX9rvopXhBA4eBf1FPXqTZW5vA==",
"version": "28.7.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.7.0.tgz",
"integrity": "sha512-0zoq6kBS1yPjzJs+p0qZDinWEA72PTKoRk5ETYKfmeRcZAkhv83Y3HCpbb/LdgJJywfm8BcIJGezrBHvL7dVnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -3307,14 +3307,14 @@
"luxon": "^3.5.0"
},
"optionalDependencies": {
"exiftool-vendored.exe": "12.97.0",
"exiftool-vendored.pl": "12.97.0"
"exiftool-vendored.exe": "12.99.0",
"exiftool-vendored.pl": "12.99.0"
}
},
"node_modules/exiftool-vendored.exe": {
"version": "12.97.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.97.0.tgz",
"integrity": "sha512-+HxyFigEJOtwRjP7PhEslhZKuVW2V0hvmHPHtbVtNKGfAUGcfc95xNTjASQfKJvc+9ZuvzdEBPkEQmyA/ZYdIw==",
"version": "12.99.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.99.0.tgz",
"integrity": "sha512-ffpJHCzC9OYJqw4JlPNtCwRy02jwhmnSJEF/QqEjpuIWDEnlRBQP/yWRh1Nw21K1R4FB4yG5PlCgEDu09VQz/w==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -3323,9 +3323,9 @@
]
},
"node_modules/exiftool-vendored.pl": {
"version": "12.97.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.97.0.tgz",
"integrity": "sha512-mXe9JEH3csfyPWcC7+H6IpfaokDMMr4S45n7MtiobGPdeeh+kFnf1SQ9cxg4sF403P6IKVeYYPbzgKMlpro9eQ==",
"version": "12.99.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.99.0.tgz",
"integrity": "sha512-qRVEPQxtoerXF+izJ0O7jGAr5o0Uyvnyu7ao5DTKzF+V7Fv3SurE0l43oCeZPFKo/Ld4V7vEylhFCm4IHVZKWA==",
"dev": true,
"license": "MIT",
"optional": true,

View File

@@ -25,7 +25,7 @@
"@immich/sdk": "file:../open-api/typescript-sdk",
"@playwright/test": "^1.44.1",
"@types/luxon": "^3.4.2",
"@types/node": "^22.8.1",
"@types/node": "^22.8.5",
"@types/oidc-provider": "^8.5.1",
"@types/pg": "^8.11.0",
"@types/pngjs": "^6.0.4",

View File

@@ -946,13 +946,13 @@ tqdm = ["tqdm"]
[[package]]
name = "ftfy"
version = "6.3.0"
version = "6.3.1"
description = "Fixes mojibake and other problems with Unicode, after the fact"
optional = false
python-versions = ">=3.9"
files = [
{file = "ftfy-6.3.0-py3-none-any.whl", hash = "sha256:17aca296801f44142e3ff2c16f93fbf6a87609ebb3704a9a41dd5d4903396caf"},
{file = "ftfy-6.3.0.tar.gz", hash = "sha256:1c7d6418e72b25a7760feb150acf574b86924dbb2e95b32c0b3abbd1ba3d7ad6"},
{file = "ftfy-6.3.1-py3-none-any.whl", hash = "sha256:7c70eb532015cd2f9adb53f101fb6c7945988d023a085d127d1573dc49dd0083"},
{file = "ftfy-6.3.1.tar.gz", hash = "sha256:9b3c3d90f84fb267fe64d375a07b7f8912d817cf86009ae134aa03e1819506ec"},
]
[package.dependencies]
@@ -1609,13 +1609,13 @@ test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"]
[[package]]
name = "locust"
version = "2.32.0"
version = "2.32.1"
description = "Developer-friendly load testing framework"
optional = false
python-versions = ">=3.9"
files = [
{file = "locust-2.32.0-py3-none-any.whl", hash = "sha256:e004514332b8631ca91382d11d224baee4ced040c5f5c8b2233800ebcbc73c0e"},
{file = "locust-2.32.0.tar.gz", hash = "sha256:d8f7f5d9d4e801b2e7b0ee3f31109333673da744ccedf85e7da0151f2d263dd9"},
{file = "locust-2.32.1-py3-none-any.whl", hash = "sha256:3fb5548b4f2b6477fa5229ee55ac3dddbae56e86c3430bf2ba3fee358eb7e7bb"},
{file = "locust-2.32.1.tar.gz", hash = "sha256:8c3b1094dbf20860fd2f6e26b68f0c6064dc28054f4462664389d102fce1448b"},
]
[package.dependencies]
@@ -2749,13 +2749,13 @@ cli = ["click (>=5.0)"]
[[package]]
name = "python-multipart"
version = "0.0.12"
version = "0.0.17"
description = "A streaming multipart parser for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "python_multipart-0.0.12-py3-none-any.whl", hash = "sha256:43dcf96cf65888a9cd3423544dd0d75ac10f7aa0c3c28a175bbcd00c9ce1aebf"},
{file = "python_multipart-0.0.12.tar.gz", hash = "sha256:045e1f98d719c1ce085ed7f7e1ef9d8ccc8c02ba02b5566d5f7521410ced58cb"},
{file = "python_multipart-0.0.17-py3-none-any.whl", hash = "sha256:15dc4f487e0a9476cc1201261188ee0940165cffc94429b6fc565c4d3045cb5d"},
{file = "python_multipart-0.0.17.tar.gz", hash = "sha256:41330d831cae6e2f22902704ead2826ea038d0419530eadff3ea80175aec5538"},
]
[[package]]

View File

@@ -65,8 +65,6 @@ PODS:
- maplibre_gl (0.0.1):
- Flutter
- MapLibre (= 5.14.0-pre3)
- native_video_player (1.0.0):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
@@ -117,7 +115,6 @@ DEPENDENCIES:
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
- native_video_player (from `.symlinks/plugins/native_video_player/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
@@ -171,8 +168,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/isar_flutter_libs/ios"
maplibre_gl:
:path: ".symlinks/plugins/maplibre_gl/ios"
native_video_player:
:path: ".symlinks/plugins/native_video_player/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
@@ -215,7 +210,6 @@ SPEC CHECKSUMS:
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02

View File

@@ -22,8 +22,12 @@ class Asset {
durationInSeconds = remote.duration.toDuration()?.inSeconds ?? 0,
type = remote.type.toAssetType(),
fileName = remote.originalFileName,
height = remote.exifInfo?.exifImageHeight?.toInt(),
width = remote.exifInfo?.exifImageWidth?.toInt(),
height = isFlipped(remote)
? remote.exifInfo?.exifImageWidth?.toInt()
: remote.exifInfo?.exifImageHeight?.toInt(),
width = isFlipped(remote)
? remote.exifInfo?.exifImageHeight?.toInt()
: remote.exifInfo?.exifImageWidth?.toInt(),
livePhotoVideoId = remote.livePhotoVideoId,
ownerId = fastHash(remote.ownerId),
exifInfo =
@@ -188,14 +192,6 @@ class Asset {
@ignore
set byteHash(List<int> hash) => checksum = base64.encode(hash);
@ignore
int? get orientatedWidth =>
exifInfo != null && exifInfo!.isFlipped ? height : width;
@ignore
int? get orientatedHeight =>
exifInfo != null && exifInfo!.isFlipped ? width : height;
@override
bool operator ==(other) {
if (other is! Asset) return false;
@@ -515,3 +511,21 @@ extension AssetsHelper on IsarCollection<Asset> {
return where().anyOf(ids, (q, String e) => q.localIdEqualTo(e));
}
}
/// Returns `true` if this [int] is flipped 90° clockwise
bool isRotated90CW(int orientation) {
return [7, 8, -90].contains(orientation);
}
/// Returns `true` if this [int] is flipped 270° clockwise
bool isRotated270CW(int orientation) {
return [5, 6, 90].contains(orientation);
}
/// Returns `true` if this [Asset] is flipped 90° or 270° clockwise
bool isFlipped(AssetResponseDto response) {
final int orientation =
int.tryParse(response.exifInfo?.orientation ?? '0') ?? 0;
return orientation != 0 &&
(isRotated90CW(orientation) || isRotated270CW(orientation));
}

View File

@@ -23,7 +23,6 @@ class ExifInfo {
String? state;
String? country;
String? description;
String? orientation;
@ignore
bool get hasCoordinates =>
@@ -46,12 +45,6 @@ class ExifInfo {
@ignore
String get focalLength => mm != null ? mm!.toStringAsFixed(1) : "";
@ignore
bool? _isFlipped;
@ignore
bool get isFlipped => _isFlipped ??= _isOrientationFlipped(orientation);
@ignore
double? get latitude => lat;
@@ -74,8 +67,7 @@ class ExifInfo {
city = dto.city,
state = dto.state,
country = dto.country,
description = dto.description,
orientation = dto.orientation;
description = dto.description;
ExifInfo({
this.id,
@@ -95,7 +87,6 @@ class ExifInfo {
this.state,
this.country,
this.description,
this.orientation,
});
ExifInfo copyWith({
@@ -116,7 +107,6 @@ class ExifInfo {
String? state,
String? country,
String? description,
String? orientation,
}) =>
ExifInfo(
id: id ?? this.id,
@@ -136,7 +126,6 @@ class ExifInfo {
state: state ?? this.state,
country: country ?? this.country,
description: description ?? this.description,
orientation: orientation ?? this.orientation,
);
@override
@@ -158,8 +147,7 @@ class ExifInfo {
city == other.city &&
state == other.state &&
country == other.country &&
description == other.description &&
orientation == other.orientation;
description == other.description;
}
@override
@@ -181,8 +169,7 @@ class ExifInfo {
city.hashCode ^
state.hashCode ^
country.hashCode ^
description.hashCode ^
orientation.hashCode;
description.hashCode;
@override
String toString() {
@@ -205,21 +192,10 @@ class ExifInfo {
state: $state,
country: $country,
description: $description,
orientation: $orientation
}""";
}
}
bool _isOrientationFlipped(String? orientation) {
final value = orientation != null ? int.tryParse(orientation) : null;
if (value == null) {
return false;
}
final isRotated90CW = value == 5 || value == 6 || value == 90;
final isRotated270CW = value == 7 || value == 8 || value == -90;
return isRotated90CW || isRotated270CW;
}
double? _exposureTimeToSeconds(String? s) {
if (s == null) {
return null;

View File

@@ -87,18 +87,13 @@ const ExifInfoSchema = CollectionSchema(
name: r'model',
type: IsarType.string,
),
r'orientation': PropertySchema(
id: 14,
name: r'orientation',
type: IsarType.string,
),
r'state': PropertySchema(
id: 15,
id: 14,
name: r'state',
type: IsarType.string,
),
r'timeZone': PropertySchema(
id: 16,
id: 15,
name: r'timeZone',
type: IsarType.string,
)
@@ -159,12 +154,6 @@ int _exifInfoEstimateSize(
bytesCount += 3 + value.length * 3;
}
}
{
final value = object.orientation;
if (value != null) {
bytesCount += 3 + value.length * 3;
}
}
{
final value = object.state;
if (value != null) {
@@ -200,9 +189,8 @@ void _exifInfoSerialize(
writer.writeString(offsets[11], object.make);
writer.writeFloat(offsets[12], object.mm);
writer.writeString(offsets[13], object.model);
writer.writeString(offsets[14], object.orientation);
writer.writeString(offsets[15], object.state);
writer.writeString(offsets[16], object.timeZone);
writer.writeString(offsets[14], object.state);
writer.writeString(offsets[15], object.timeZone);
}
ExifInfo _exifInfoDeserialize(
@@ -227,9 +215,8 @@ ExifInfo _exifInfoDeserialize(
make: reader.readStringOrNull(offsets[11]),
mm: reader.readFloatOrNull(offsets[12]),
model: reader.readStringOrNull(offsets[13]),
orientation: reader.readStringOrNull(offsets[14]),
state: reader.readStringOrNull(offsets[15]),
timeZone: reader.readStringOrNull(offsets[16]),
state: reader.readStringOrNull(offsets[14]),
timeZone: reader.readStringOrNull(offsets[15]),
);
return object;
}
@@ -273,8 +260,6 @@ P _exifInfoDeserializeProp<P>(
return (reader.readStringOrNull(offset)) as P;
case 15:
return (reader.readStringOrNull(offset)) as P;
case 16:
return (reader.readStringOrNull(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
}
@@ -1924,155 +1909,6 @@ extension ExifInfoQueryFilter
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
property: r'orientation',
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
orientationIsNotNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNotNull(
property: r'orientation',
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationEqualTo(
String? value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'orientation',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
orientationGreaterThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
include: include,
property: r'orientation',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationLessThan(
String? value, {
bool include = false,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.lessThan(
include: include,
property: r'orientation',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationBetween(
String? lower,
String? upper, {
bool includeLower = true,
bool includeUpper = true,
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.between(
property: r'orientation',
lower: lower,
includeLower: includeLower,
upper: upper,
includeUpper: includeUpper,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationStartsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.startsWith(
property: r'orientation',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationEndsWith(
String value, {
bool caseSensitive = true,
}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.endsWith(
property: r'orientation',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationContains(
String value,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.contains(
property: r'orientation',
value: value,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationMatches(
String pattern,
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.matches(
property: r'orientation',
wildcard: pattern,
caseSensitive: caseSensitive,
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> orientationIsEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'orientation',
value: '',
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition>
orientationIsNotEmpty() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.greaterThan(
property: r'orientation',
value: '',
));
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterFilterCondition> stateIsNull() {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(const FilterCondition.isNull(
@@ -2541,18 +2377,6 @@ extension ExifInfoQuerySortBy on QueryBuilder<ExifInfo, ExifInfo, QSortBy> {
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByOrientation() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'orientation', Sort.asc);
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByOrientationDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'orientation', Sort.desc);
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> sortByState() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'state', Sort.asc);
@@ -2760,18 +2584,6 @@ extension ExifInfoQuerySortThenBy
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByOrientation() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'orientation', Sort.asc);
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByOrientationDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'orientation', Sort.desc);
});
}
QueryBuilder<ExifInfo, ExifInfo, QAfterSortBy> thenByState() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'state', Sort.asc);
@@ -2889,13 +2701,6 @@ extension ExifInfoQueryWhereDistinct
});
}
QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByOrientation(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'orientation', caseSensitive: caseSensitive);
});
}
QueryBuilder<ExifInfo, ExifInfo, QDistinct> distinctByState(
{bool caseSensitive = true}) {
return QueryBuilder.apply(this, (query) {
@@ -3004,12 +2809,6 @@ extension ExifInfoQueryProperty
});
}
QueryBuilder<ExifInfo, String?, QQueryOperations> orientationProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'orientation');
});
}
QueryBuilder<ExifInfo, String?, QQueryOperations> stateProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'state');

View File

@@ -12,7 +12,7 @@ import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/pages/common/download_panel.dart';
import 'package:immich_mobile/pages/common/native_video_loader.dart';
import 'package:immich_mobile/pages/common/video_viewer.page.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
@@ -56,14 +56,18 @@ class GalleryViewerPage extends HookConsumerWidget {
final settings = ref.watch(appSettingsServiceProvider);
final loadAsset = renderList.loadAsset;
final totalAssets = useState(renderList.totalAssets);
final shouldLoopVideo =
useState(settings.getSetting<bool>(AppSettingsEnum.loopVideo));
final shouldLoopVideo = useState(AppSettingsEnum.loopVideo.defaultValue);
final isZoomed = useState(false);
final isPlayingVideo = useState(false);
final localPosition = useRef<Offset?>(null);
final currentIndex = useValueNotifier(initialIndex);
final localPosition = useState<Offset?>(null);
final currentIndex = useState(initialIndex);
final currentAsset = loadAsset(currentIndex.value);
// Update is playing motion video
ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) {
isPlayingVideo.value = state == VideoPlaybackState.playing;
});
final stackIndex = useState(-1);
final stack = showStack && currentAsset.stackCount > 0
? ref.watch(assetStackStateProvider(currentAsset))
@@ -77,26 +81,28 @@ class GalleryViewerPage extends HookConsumerWidget {
: stackElements.elementAt(stackIndex.value);
final isMotionPhoto = asset.livePhotoVideoId != null;
// Update is playing motion video
if (isMotionPhoto) {
ref.listen(videoPlaybackValueProvider.select((v) => v.state), (_, state) {
isPlayingVideo.value = state == VideoPlaybackState.playing;
});
}
// Listen provider to prevent autoDispose when navigating to other routes from within the gallery page
ref.listen(currentAssetProvider, (_, __) {});
useEffect(
() {
// Delay state update to after the execution of build method
ref.read(currentAssetProvider.notifier).set(asset);
// Future.microtask(
// () => ref.read(currentAssetProvider.notifier).set(asset),
// );
Future.microtask(
() => ref.read(currentAssetProvider.notifier).set(asset),
);
return null;
},
[asset],
);
useEffect(
() {
shouldLoopVideo.value =
settings.getSetting<bool>(AppSettingsEnum.loopVideo);
return null;
},
[],
);
Future<void> precacheNextImage(int index) async {
void onError(Object exception, StackTrace? stackTrace) {
// swallow error silently
@@ -105,7 +111,6 @@ class GalleryViewerPage extends HookConsumerWidget {
try {
if (index < totalAssets.value && index >= 0) {
log.info('Precaching next image at index $index');
final asset = loadAsset(index);
await precacheImage(
ImmichImage.imageProvider(asset: asset),
@@ -185,9 +190,7 @@ class GalleryViewerPage extends HookConsumerWidget {
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
if (isMotionPhoto) {
isPlayingVideo.value = false;
}
isPlayingVideo.value = false;
return null;
},
[],
@@ -273,8 +276,6 @@ class GalleryViewerPage extends HookConsumerWidget {
isZoomed.value = state != PhotoViewScaleState.initial;
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
},
// wantKeepAlive: true,
// gaplessPlayback: true,
loadingBuilder: (context, event, index) => ClipRect(
child: Stack(
fit: StackFit.expand,
@@ -302,19 +303,13 @@ class GalleryViewerPage extends HookConsumerWidget {
itemCount: totalAssets.value,
scrollDirection: Axis.horizontal,
onPageChanged: (value) async {
log.info('Page changed to $value');
final next = currentIndex.value < value ? value + 1 : value - 1;
ref.read(hapticFeedbackProvider.notifier).selectionClick();
log.info('Setting current index to $value');
currentIndex.value = value;
if (stackIndex.value != -1) {
stackIndex.value = -1;
}
if (isMotionPhoto) {
isPlayingVideo.value = false;
}
stackIndex.value = -1;
isPlayingVideo.value = false;
// Wait for page change animation to finish
await Future.delayed(const Duration(milliseconds: 400));
@@ -328,23 +323,17 @@ class GalleryViewerPage extends HookConsumerWidget {
final ImageProvider provider =
ImmichImage.imageProvider(asset: a);
ref.read(videoPlaybackValueProvider.notifier).reset();
if (a.isImage && !isPlayingVideo.value) {
ref.read(showControlsProvider.notifier).show = false;
return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __) {
log.info('Drag start');
localPosition.value = details.localPosition;
},
onDragUpdate: (_, details, __) {
log.info('Drag update');
handleSwipeUpDown(details);
},
onDragStart: (_, details, __) =>
localPosition.value = details.localPosition,
onDragUpdate: (_, details, __) =>
handleSwipeUpDown(details),
onTapDown: (_, __, ___) {
ref.read(showControlsProvider.notifier).toggle();
},
onLongPressStart: (_, __, ___) {
if (isMotionPhoto) {
if (asset.livePhotoVideoId != null) {
isPlayingVideo.value = true;
}
},
@@ -364,26 +353,24 @@ class GalleryViewerPage extends HookConsumerWidget {
),
);
} else {
log.info('Loading asset ${a.id} (index $index) as video');
return PhotoViewGalleryPageOptions.customChild(
onDragStart: (_, details, __) =>
localPosition.value = details.localPosition,
onDragUpdate: (_, details, __) =>
handleSwipeUpDown(details),
// heroAttributes: PhotoViewHeroAttributes(
// tag: isFromDto
// ? '${currentAsset.remoteId}-$heroOffset'
// : currentAsset.id + heroOffset,
// ),
heroAttributes: PhotoViewHeroAttributes(
tag: isFromDto
? '${currentAsset.remoteId}-$heroOffset'
: currentAsset.id + heroOffset,
),
filterQuality: FilterQuality.high,
initialScale: 1.0,
maxScale: 1.0,
minScale: 1.0,
basePosition: Alignment.center,
child: NativeVideoLoader(
key: ValueKey(a.id),
child: VideoViewerPage(
key: ValueKey(a),
asset: a,
isMotionVideo: isMotionPhoto,
isMotionVideo: a.livePhotoVideoId != null,
loopVideo: shouldLoopVideo.value,
placeholder: Image(
image: provider,

View File

@@ -1,207 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/asset.service.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:photo_manager/photo_manager.dart';
class NativeVideoLoader extends HookConsumerWidget {
final Asset asset;
final bool isMotionVideo;
final Widget placeholder;
final bool showControls;
final Duration hideControlsTimer;
final bool loopVideo;
const NativeVideoLoader({
super.key,
required this.asset,
required this.placeholder,
this.isMotionVideo = false,
this.showControls = true,
this.hideControlsTimer = const Duration(seconds: 5),
this.loopVideo = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final log = Logger('NativeVideoLoader');
log.info('Building NativeVideoLoader');
// fast path for aspect ratio
// final initAspectRatio = useMemoized(
// () {
// if (asset.exifInfo == null) {
// return null;
// }
// final width = asset.orientatedWidth?.toDouble();
// final height = asset.orientatedHeight?.toDouble();
// return width != null && height != null && width > 0 && height > 0
// ? width / height
// : null;
// },
// );
// final localEntity = useMemoized(
// () => asset.isLocal ? AssetEntity.fromId(asset.localId!) : null,
// );
Future<double> calculateAspectRatio(AssetEntity? localEntity) async {
log.info('Calculating aspect ratio');
late final double? orientatedWidth;
late final double? orientatedHeight;
if (asset.exifInfo != null) {
orientatedWidth = asset.orientatedWidth?.toDouble();
orientatedHeight = asset.orientatedHeight?.toDouble();
} else if (localEntity != null) {
orientatedWidth = localEntity.orientatedWidth.toDouble();
orientatedHeight = localEntity.orientatedHeight.toDouble();
} else {
final entity = await ref.read(assetServiceProvider).loadExif(asset);
orientatedWidth = entity.orientatedWidth?.toDouble();
orientatedHeight = entity.orientatedHeight?.toDouble();
}
log.info('Calculated aspect ratio');
if (orientatedWidth != null &&
orientatedHeight != null &&
orientatedWidth > 0 &&
orientatedHeight > 0) {
return orientatedWidth / orientatedHeight;
}
return 1.0;
}
// final aspectRatioFuture = useMemoized(() => calculateAspectRatio());
Future<VideoSource> createLocalSource(AssetEntity? localEntity) async {
log.info('Loading video from local storage');
if (localEntity == null) {
throw Exception('No entity found for the video');
}
final file = await localEntity.file;
if (file == null) {
throw Exception('No file found for the video');
}
final source = await VideoSource.init(
path: file.path,
type: VideoSourceType.file,
);
log.info('Loaded video from local storage');
return source;
}
Future<VideoSource> createRemoteSource() async {
log.info('Loading video from server');
// Use a network URL for the video player controller
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final String videoUrl = asset.livePhotoVideoId != null
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/video/playback'
: '$serverEndpoint/assets/${asset.remoteId}/video/playback';
final source = await VideoSource.init(
path: videoUrl,
type: VideoSourceType.network,
headers: ApiService.getRequestHeaders(),
);
log.info('Loaded video from server');
return source;
}
Future<VideoSource> createSource(AssetEntity? localEntity) {
if (localEntity != null && asset.livePhotoVideoId == null) {
return createLocalSource(localEntity);
}
return createRemoteSource();
}
// final createSourceFuture = useMemoized(() => createSource());
final combinedFuture = useMemoized(
() => Future.delayed(Duration(milliseconds: 1), () async {
if (!context.mounted) {
return null;
}
final entity =
asset.isLocal ? await AssetEntity.fromId(asset.localId!) : null;
return (createSource(entity), calculateAspectRatio(entity)).wait;
}),
);
final doCleanup = useState(false);
ref.listen(videoPlaybackValueProvider.select((value) => value.state),
(_, value) {
if (value == VideoPlaybackState.initializing) {
log.info('Cleaning up video');
doCleanup.value = true;
}
});
// useEffect(() {
// Future.microtask(() {
// if (!context.mounted) {
// return Future.value(null);
// }
// return (createSourceFuture, aspectRatioFuture).wait;
// });
// return () {
// }
// }, [asset.id]);
final size = MediaQuery.sizeOf(context);
return SizedBox(
height: size.height,
width: size.width,
child: GestureDetector(
behavior: HitTestBehavior.deferToChild,
child: PopScope(
onPopInvokedWithResult: (didPop, _) =>
ref.read(videoPlaybackValueProvider.notifier).reset(),
child: SizedBox(
height: size.height,
width: size.width,
child: doCleanup.value
? placeholder
: FutureBuilder(
key: ValueKey(asset.id),
future: combinedFuture,
// initialData: initAspectRatio,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return placeholder;
}
return NativeVideoViewerPage(
videoSource: snapshot.data!.$1,
aspectRatio: snapshot.data!.$2,
duration: asset.duration,
isMotionVideo: isMotionVideo,
hideControlsTimer: hideControlsTimer,
loopVideo: loopVideo,
);
},
),
),
),
),
);
}
}

View File

@@ -1,317 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/utils/hooks/interval_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
class NativeVideoViewerPage extends HookConsumerWidget {
final VideoSource videoSource;
final double aspectRatio;
final Duration duration;
final bool isMotionVideo;
final bool showControls;
final Duration hideControlsTimer;
final bool loopVideo;
const NativeVideoViewerPage({
super.key,
required this.videoSource,
required this.aspectRatio,
required this.duration,
this.isMotionVideo = false,
this.showControls = true,
this.hideControlsTimer = const Duration(seconds: 5),
this.loopVideo = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useRef<NativeVideoPlayerController?>(null);
final lastVideoPosition = useRef(-1);
final isBuffering = useRef(false);
final log = Logger('NativeVideoViewerPage');
log.info('Building NativeVideoViewerPage');
void checkIfBuffering([Timer? timer]) {
if (!context.mounted) {
return timer?.cancel();
}
log.info('Checking if buffering');
final videoPlayback = ref.read(videoPlaybackValueProvider);
if ((isBuffering.value ||
videoPlayback.state == VideoPlaybackState.initializing) &&
videoPlayback.state != VideoPlaybackState.buffering) {
log.info('Marking video as buffering');
ref.read(videoPlaybackValueProvider.notifier).value =
videoPlayback.copyWith(state: VideoPlaybackState.buffering);
}
}
// timer to mark videos as buffering if the position does not change
useInterval(const Duration(seconds: 5), checkIfBuffering);
// When the volume changes, set the volume
ref.listen(videoPlayerControlsProvider.select((value) => value.mute),
(_, mute) {
final playerController = controller.value;
if (playerController == null) {
log.info('No controller to seek to');
return;
}
final playbackInfo = playerController.playbackInfo;
if (playbackInfo == null) {
log.info('No playback info to update');
return;
}
try {
if (mute && playbackInfo.volume != 0.0) {
log.info('Muting video');
playerController.setVolume(0.0);
} else if (!mute && playbackInfo.volume != 0.7) {
log.info('Unmuting video');
playerController.setVolume(0.7);
}
} catch (error) {
log.severe('Error setting volume: $error');
}
});
// When the position changes, seek to the position
ref.listen(videoPlayerControlsProvider.select((value) => value.position),
(_, position) {
final playerController = controller.value;
if (playerController == null) {
log.info('No controller to seek to');
return;
}
final playbackInfo = playerController.playbackInfo;
if (playbackInfo == null) {
log.info('No playback info to update');
return;
}
// Find the position to seek to
final int seek = (duration * (position / 100.0)).inSeconds;
if (seek != playbackInfo.position) {
log.info('Seeking to position: $seek from ${playbackInfo.position}');
try {
playerController.seekTo(seek);
} catch (error) {
log.severe('Error seeking to position $position: $error');
}
}
ref.read(videoPlaybackValueProvider.notifier).position =
Duration(seconds: seek);
});
// // When the custom video controls pause or play
ref.listen(videoPlayerControlsProvider.select((value) => value.pause),
(_, pause) {
try {
if (pause) {
log.info('Pausing video');
controller.value?.pause();
WakelockPlus.disable();
} else {
log.info('Playing video');
controller.value?.play();
WakelockPlus.enable();
}
} catch (error) {
log.severe('Error pausing or playing video: $error');
}
});
void onPlaybackReady() {
try {
log.info('onPlaybackReady: Playing video');
controller.value?.play();
controller.value?.setVolume(0.9);
WakelockPlus.enable();
} catch (error) {
log.severe('Error playing video: $error');
}
}
void onPlaybackStatusChanged() {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
log.info('No controller to update');
return;
}
final videoPlayback =
VideoPlaybackValue.fromNativeController(controller.value!);
ref.read(videoPlaybackValueProvider.notifier).value = videoPlayback;
if (videoPlayback.state == VideoPlaybackState.playing) {
// Sync with the controls playing
WakelockPlus.enable();
log.info('Video is playing; enabled wakelock');
} else {
// Sync with the controls pause
WakelockPlus.disable();
log.info('Video is not playing; disabled wakelock');
}
}
void onPlaybackPositionChanged() {
final videoController = controller.value;
if (videoController == null || !context.mounted) {
log.info('No controller to update');
return;
}
final playbackInfo = videoController.playbackInfo;
if (playbackInfo == null) {
log.info('No playback info to update');
return;
}
ref.read(videoPlaybackValueProvider.notifier).position =
Duration(seconds: playbackInfo.position);
// Check if the video is buffering
if (playbackInfo.status == PlaybackStatus.playing) {
log.info('Updating playing video position');
isBuffering.value = lastVideoPosition.value == playbackInfo.position;
lastVideoPosition.value = playbackInfo.position;
} else {
log.info('Updating non-playing video position');
isBuffering.value = false;
lastVideoPosition.value = -1;
}
}
void onPlaybackEnded() {
log.info('onPlaybackEnded: Video ended');
if (loopVideo) {
log.info('onPlaybackEnded: Looping video');
try {
controller.value?.play();
} catch (error) {
log.severe('Error looping video: $error');
}
} else {
WakelockPlus.disable();
}
}
void initController(NativeVideoPlayerController nc) {
if (controller.value != null) {
log.info('initController: Controller already initialized');
return;
}
log.info('initController: adding onPlaybackPositionChanged listener');
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
log.info('initController: adding onPlaybackStatusChanged listener');
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
log.info('initController: adding onPlaybackReady listener');
nc.onPlaybackReady.addListener(onPlaybackReady);
log.info('initController: adding onPlaybackEnded listener');
nc.onPlaybackEnded.addListener(onPlaybackEnded);
log.info('initController: loading video source');
nc.loadVideoSource(videoSource);
log.info('initController: setting controller');
controller.value = nc;
Timer(const Duration(milliseconds: 200), checkIfBuffering);
}
useEffect(
() {
log.info('useEffect: resetting video player controls');
ref.read(videoPlayerControlsProvider.notifier).reset();
if (isMotionVideo) {
// ignore: prefer-extracting-callbacks
log.info('useEffect: disabling showing video player controls');
ref.read(showControlsProvider.notifier).show = false;
}
return () {
final playerController = controller.value;
if (playerController == null) {
log.info('No controller to dispose');
return;
}
try {
log.info('Stopping video');
playerController.stop();
log.info('Removing onPlaybackPositionChanged listener');
playerController.onPlaybackPositionChanged
.removeListener(onPlaybackPositionChanged);
log.info('Removing onPlaybackStatusChanged listener');
playerController.onPlaybackStatusChanged
.removeListener(onPlaybackStatusChanged);
log.info('Removing onPlaybackReady listener');
playerController.onPlaybackReady.removeListener(onPlaybackReady);
log.info('Removing onPlaybackEnded listener');
playerController.onPlaybackEnded.removeListener(onPlaybackEnded);
} catch (error) {
log.severe('Error during useEffect cleanup: $error');
}
log.info('Disposing controller');
controller.value = null;
log.info('Disabling Wakelock');
WakelockPlus.disable();
};
},
[videoSource],
);
return Stack(
children: [
Center(
child: AspectRatio(
aspectRatio: aspectRatio,
child: NativeVideoPlayerView(
onViewReady: initController,
),
),
),
if (showControls)
Center(
child: CustomVideoPlayerControls(
hideTimerDuration: hideControlsTimer,
),
),
// Visibility(
// visible: controller.value == null,
// child: const Positioned.fill(
// child: Center(
// child: DelayedLoadingIndicator(
// fadeInDuration: Duration(milliseconds: 500),
// ),
// ),
// ),
// ),
],
);
}
}

View File

@@ -124,7 +124,8 @@ class VideoViewerPage extends HookConsumerWidget {
return PopScope(
onPopInvokedWithResult: (didPop, _) {
ref.read(videoPlaybackValueProvider.notifier).reset();
ref.read(videoPlaybackValueProvider.notifier).value =
VideoPlaybackValue.uninitialized();
},
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 400),

View File

@@ -1,7 +1,7 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
class VideoPlaybackControls {
const VideoPlaybackControls({
VideoPlaybackControls({
required this.position,
required this.mute,
required this.pause,
@@ -17,14 +17,15 @@ final videoPlayerControlsProvider =
return VideoPlayerControls(ref);
});
const videoPlayerControlsDefault = VideoPlaybackControls(
position: 0,
pause: false,
mute: false,
);
class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
VideoPlayerControls(this.ref) : super(videoPlayerControlsDefault);
VideoPlayerControls(this.ref)
: super(
VideoPlaybackControls(
position: 0,
pause: false,
mute: false,
),
);
final Ref ref;
@@ -35,17 +36,17 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
void reset() {
state = videoPlayerControlsDefault;
state = VideoPlaybackControls(
position: 0,
pause: false,
mute: false,
);
}
double get position => state.position;
bool get mute => state.mute;
set position(double value) {
if (state.position == value) {
return;
}
state = VideoPlaybackControls(
position: value,
mute: state.mute,
@@ -54,10 +55,6 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
set mute(bool value) {
if (state.mute == value) {
return;
}
state = VideoPlaybackControls(
position: state.position,
mute: value,
@@ -74,10 +71,6 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
void pause() {
if (state.pause) {
return;
}
state = VideoPlaybackControls(
position: state.position,
mute: state.mute,
@@ -86,10 +79,6 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
void play() {
if (!state.pause) {
return;
}
state = VideoPlaybackControls(
position: state.position,
mute: state.mute,
@@ -106,6 +95,12 @@ class VideoPlayerControls extends StateNotifier<VideoPlaybackControls> {
}
void restart() {
state = VideoPlaybackControls(
position: 0,
mute: state.mute,
pause: true,
);
state = VideoPlaybackControls(
position: 0,
mute: state.mute,

View File

@@ -1,5 +1,4 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:native_video_player/native_video_player.dart';
import 'package:video_player/video_player.dart';
enum VideoPlaybackState {
@@ -23,44 +22,13 @@ class VideoPlaybackValue {
/// The volume of the video
final double volume;
const VideoPlaybackValue({
VideoPlaybackValue({
required this.position,
required this.duration,
required this.state,
required this.volume,
});
factory VideoPlaybackValue.fromNativeController(
NativeVideoPlayerController controller,
) {
final playbackInfo = controller.playbackInfo;
final videoInfo = controller.videoInfo;
if (playbackInfo == null || videoInfo == null) {
return videoPlaybackValueDefault;
}
late final VideoPlaybackState status;
switch (playbackInfo.status) {
case PlaybackStatus.playing:
status = VideoPlaybackState.playing;
break;
case PlaybackStatus.paused:
status = VideoPlaybackState.paused;
break;
case PlaybackStatus.stopped:
status = VideoPlaybackState.completed;
break;
}
return VideoPlaybackValue(
position: Duration(seconds: playbackInfo.position),
duration: Duration(seconds: videoInfo.duration),
state: status,
volume: playbackInfo.volume,
);
}
factory VideoPlaybackValue.fromController(VideoPlayerController? controller) {
final video = controller?.value;
late VideoPlaybackState s;
@@ -84,35 +52,26 @@ class VideoPlaybackValue {
);
}
VideoPlaybackValue copyWith({
Duration? position,
Duration? duration,
VideoPlaybackState? state,
double? volume,
}) {
factory VideoPlaybackValue.uninitialized() {
return VideoPlaybackValue(
position: position ?? this.position,
duration: duration ?? this.duration,
state: state ?? this.state,
volume: volume ?? this.volume,
position: Duration.zero,
duration: Duration.zero,
state: VideoPlaybackState.initializing,
volume: 0.0,
);
}
}
const VideoPlaybackValue videoPlaybackValueDefault = VideoPlaybackValue(
position: Duration.zero,
duration: Duration.zero,
state: VideoPlaybackState.initializing,
volume: 0.0,
);
final videoPlaybackValueProvider =
StateNotifierProvider<VideoPlaybackValueState, VideoPlaybackValue>((ref) {
return VideoPlaybackValueState(ref);
});
class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
VideoPlaybackValueState(this.ref) : super(videoPlaybackValueDefault);
VideoPlaybackValueState(this.ref)
: super(
VideoPlaybackValue.uninitialized(),
);
final Ref ref;
@@ -123,7 +82,6 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
}
set position(Duration value) {
if (state.position == value) return;
state = VideoPlaybackValue(
position: value,
duration: state.duration,
@@ -131,8 +89,4 @@ class VideoPlaybackValueState extends StateNotifier<VideoPlaybackValue> {
volume: state.volume,
);
}
void reset() {
state = videoPlaybackValueDefault;
}
}

View File

@@ -1,18 +0,0 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter_hooks/flutter_hooks.dart';
// https://github.com/rrousselGit/flutter_hooks/issues/233#issuecomment-840416638
void useInterval(Duration delay, VoidCallback callback) {
final savedCallback = useRef(callback);
savedCallback.value = callback;
useEffect(
() {
final timer = Timer.periodic(delay, (_) => savedCallback.value());
return timer.cancel;
},
[delay],
);
}

View File

@@ -4,7 +4,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/utils/db.dart';
import 'package:isar/isar.dart';
const int targetVersion = 7;
const int targetVersion = 6;
Future<void> migrateDatabaseIfNeeded(Isar db) async {
final int version = Store.get(StoreKey.version, 1);

View File

@@ -1,11 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
class CustomVideoPlayerControls extends HookConsumerWidget {
final Duration hideTimerDuration;
@@ -28,9 +29,10 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
}
},
);
final showBuffering = useState(false);
final VideoPlaybackState state =
ref.watch(videoPlaybackValueProvider.select((value) => value.state));
final showBuffering = state == VideoPlaybackState.buffering;
ref.watch(videoPlaybackValueProvider).state;
/// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() {
@@ -50,9 +52,16 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
showControlsAndStartHideTimer();
});
ref.listen(videoPlaybackValueProvider.select((value) => value.state),
(_, state) {
// Show buffering
showBuffering.value = state == VideoPlaybackState.buffering;
});
/// Toggles between playing and pausing depending on the state of the video
void togglePlay() {
showControlsAndStartHideTimer();
final state = ref.read(videoPlaybackValueProvider).state;
if (state == VideoPlaybackState.playing) {
ref.read(videoPlayerControlsProvider.notifier).pause();
} else if (state == VideoPlaybackState.completed) {
@@ -69,7 +78,7 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
absorbing: !ref.watch(showControlsProvider),
child: Stack(
children: [
if (showBuffering)
if (showBuffering.value)
const Center(
child: DelayedLoadingIndicator(
fadeInDuration: Duration(milliseconds: 400),
@@ -77,8 +86,12 @@ class CustomVideoPlayerControls extends HookConsumerWidget {
)
else
GestureDetector(
onTap: () =>
ref.read(showControlsProvider.notifier).show = false,
onTap: () {
if (state != VideoPlaybackState.playing) {
togglePlay();
}
ref.read(showControlsProvider.notifier).show = false;
},
child: CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,

View File

@@ -15,10 +15,9 @@ class FileInfo extends StatelessWidget {
Widget build(BuildContext context) {
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
String resolution =
asset.orientatedHeight != null && asset.orientatedWidth != null
? "${asset.orientatedHeight} x ${asset.orientatedWidth} "
: "";
String resolution = asset.width != null && asset.height != null
? "${asset.height} x ${asset.width} "
: "";
String fileSize = asset.exifInfo?.fileSize != null
? formatBytes(asset.exifInfo!.fileSize!)
: "";

View File

@@ -2,9 +2,9 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/pages/common/native_video_loader.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/pages/common/video_viewer.page.dart';
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
@@ -68,9 +68,10 @@ class MemoryCard extends StatelessWidget {
} else {
return Hero(
tag: 'memory-${asset.id}',
child: NativeVideoLoader(
key: ValueKey(asset.id),
child: VideoViewerPage(
key: ValueKey(asset),
asset: asset,
showDownloadingIndicator: false,
placeholder: SizedBox.expand(
child: ImmichImage(
asset,

View File

@@ -1024,15 +1024,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.4"
native_video_player:
dependency: "direct main"
description:
path: "."
ref: "feat/headers"
resolved-ref: "568c76e1552791f06dcf44b45d3373cad12913ed"
url: "https://github.com/immich-app/native_video_player"
source: git
version: "1.3.1"
nested:
dependency: transitive
description:

View File

@@ -57,10 +57,6 @@ dependencies:
async: ^2.11.0
dynamic_color: ^1.7.0 #package to apply system theme
background_downloader: ^8.5.5
native_video_player:
git:
url: https://github.com/immich-app/native_video_player
ref: feat/headers
#image editing packages
crop_image: ^1.0.13

View File

@@ -12,7 +12,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.8.1",
"@types/node": "^22.8.5",
"typescript": "^5.3.3"
}
},
@@ -22,9 +22,9 @@
"integrity": "sha512-8tKiYffhwTGHSHYGnZ3oneLGCjX0po/XAXQ5Ng9fqKkvIdl/xz8+Vh8i+6xjzZqvZ2pLVpUcuSfnvNI/x67L0g=="
},
"node_modules/@types/node": {
"version": "22.8.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz",
"integrity": "sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==",
"version": "22.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
"integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -19,7 +19,7 @@
"@oazapfts/runtime": "^1.0.2"
},
"devDependencies": {
"@types/node": "^22.8.1",
"@types/node": "^22.8.5",
"typescript": "^5.3.3"
},
"repository": {

View File

@@ -25,7 +25,7 @@ COPY --from=dev /usr/src/app/node_modules/@img ./node_modules/@img
COPY --from=dev /usr/src/app/node_modules/exiftool-vendored.pl ./node_modules/exiftool-vendored.pl
# web build
FROM node:22.10.0-alpine3.20@sha256:fc95a044b87e95507c60c1f8c829e5d98ddf46401034932499db370c494ef0ff AS web
FROM node:22.11.0-alpine3.20@sha256:f265794478aa0b1a23d85a492c8311ed795bc527c3fe7e43453b3c872dcd71a3 AS web
WORKDIR /usr/src/open-api/typescript-sdk
COPY open-api/typescript-sdk/package*.json open-api/typescript-sdk/tsconfig*.json ./

View File

@@ -83,7 +83,7 @@
"@types/lodash": "^4.14.197",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7",
"@types/node": "^22.8.1",
"@types/node": "^22.8.5",
"@types/nodemailer": "^6.4.14",
"@types/picomatch": "^3.0.0",
"@types/pngjs": "^6.0.5",
@@ -5110,9 +5110,9 @@
}
},
"node_modules/@types/node": {
"version": "22.8.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz",
"integrity": "sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==",
"version": "22.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
"integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
"dependencies": {
"undici-types": "~6.19.8"
}
@@ -8236,9 +8236,9 @@
}
},
"node_modules/exiftool-vendored": {
"version": "28.6.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.6.0.tgz",
"integrity": "sha512-Cx8/8ov1tKEacHhsi7FNYdisIhKq/SeQfprYSpYzwBuJwkPmCV8w7tTIvUJRQX9rvopXhBA4eBf1FPXqTZW5vA==",
"version": "28.7.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.7.0.tgz",
"integrity": "sha512-0zoq6kBS1yPjzJs+p0qZDinWEA72PTKoRk5ETYKfmeRcZAkhv83Y3HCpbb/LdgJJywfm8BcIJGezrBHvL7dVnQ==",
"dependencies": {
"@photostructure/tz-lookup": "^11.0.0",
"@types/luxon": "^3.4.2",
@@ -8247,23 +8247,23 @@
"luxon": "^3.5.0"
},
"optionalDependencies": {
"exiftool-vendored.exe": "12.97.0",
"exiftool-vendored.pl": "12.97.0"
"exiftool-vendored.exe": "12.99.0",
"exiftool-vendored.pl": "12.99.0"
}
},
"node_modules/exiftool-vendored.exe": {
"version": "12.97.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.97.0.tgz",
"integrity": "sha512-+HxyFigEJOtwRjP7PhEslhZKuVW2V0hvmHPHtbVtNKGfAUGcfc95xNTjASQfKJvc+9ZuvzdEBPkEQmyA/ZYdIw==",
"version": "12.99.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.99.0.tgz",
"integrity": "sha512-ffpJHCzC9OYJqw4JlPNtCwRy02jwhmnSJEF/QqEjpuIWDEnlRBQP/yWRh1Nw21K1R4FB4yG5PlCgEDu09VQz/w==",
"optional": true,
"os": [
"win32"
]
},
"node_modules/exiftool-vendored.pl": {
"version": "12.97.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.97.0.tgz",
"integrity": "sha512-mXe9JEH3csfyPWcC7+H6IpfaokDMMr4S45n7MtiobGPdeeh+kFnf1SQ9cxg4sF403P6IKVeYYPbzgKMlpro9eQ==",
"version": "12.99.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.99.0.tgz",
"integrity": "sha512-qRVEPQxtoerXF+izJ0O7jGAr5o0Uyvnyu7ao5DTKzF+V7Fv3SurE0l43oCeZPFKo/Ld4V7vEylhFCm4IHVZKWA==",
"optional": true,
"os": [
"!win32"
@@ -18258,9 +18258,9 @@
}
},
"@types/node": {
"version": "22.8.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.8.5.tgz",
"integrity": "sha512-5iYk6AMPtsMbkZqCO1UGF9W5L38twq11S2pYWkybGHH2ogPUvXWNlQqJBzuEZWKj/WRH+QTeiv6ySWqJtvIEgA==",
"version": "22.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
"integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
"requires": {
"undici-types": "~6.19.8"
}
@@ -20579,29 +20579,29 @@
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="
},
"exiftool-vendored": {
"version": "28.6.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.6.0.tgz",
"integrity": "sha512-Cx8/8ov1tKEacHhsi7FNYdisIhKq/SeQfprYSpYzwBuJwkPmCV8w7tTIvUJRQX9rvopXhBA4eBf1FPXqTZW5vA==",
"version": "28.7.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored/-/exiftool-vendored-28.7.0.tgz",
"integrity": "sha512-0zoq6kBS1yPjzJs+p0qZDinWEA72PTKoRk5ETYKfmeRcZAkhv83Y3HCpbb/LdgJJywfm8BcIJGezrBHvL7dVnQ==",
"requires": {
"@photostructure/tz-lookup": "^11.0.0",
"@types/luxon": "^3.4.2",
"batch-cluster": "^13.0.0",
"exiftool-vendored.exe": "12.97.0",
"exiftool-vendored.pl": "12.97.0",
"exiftool-vendored.exe": "12.99.0",
"exiftool-vendored.pl": "12.99.0",
"he": "^1.2.0",
"luxon": "^3.5.0"
}
},
"exiftool-vendored.exe": {
"version": "12.97.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.97.0.tgz",
"integrity": "sha512-+HxyFigEJOtwRjP7PhEslhZKuVW2V0hvmHPHtbVtNKGfAUGcfc95xNTjASQfKJvc+9ZuvzdEBPkEQmyA/ZYdIw==",
"version": "12.99.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.exe/-/exiftool-vendored.exe-12.99.0.tgz",
"integrity": "sha512-ffpJHCzC9OYJqw4JlPNtCwRy02jwhmnSJEF/QqEjpuIWDEnlRBQP/yWRh1Nw21K1R4FB4yG5PlCgEDu09VQz/w==",
"optional": true
},
"exiftool-vendored.pl": {
"version": "12.97.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.97.0.tgz",
"integrity": "sha512-mXe9JEH3csfyPWcC7+H6IpfaokDMMr4S45n7MtiobGPdeeh+kFnf1SQ9cxg4sF403P6IKVeYYPbzgKMlpro9eQ==",
"version": "12.99.0",
"resolved": "https://registry.npmjs.org/exiftool-vendored.pl/-/exiftool-vendored.pl-12.99.0.tgz",
"integrity": "sha512-qRVEPQxtoerXF+izJ0O7jGAr5o0Uyvnyu7ao5DTKzF+V7Fv3SurE0l43oCeZPFKo/Ld4V7vEylhFCm4IHVZKWA==",
"optional": true
},
"express": {

View File

@@ -108,7 +108,7 @@
"@types/lodash": "^4.14.197",
"@types/mock-fs": "^4.13.1",
"@types/multer": "^1.4.7",
"@types/node": "^22.8.1",
"@types/node": "^22.8.5",
"@types/nodemailer": "^6.4.14",
"@types/picomatch": "^3.0.0",
"@types/pngjs": "^6.0.5",

View File

@@ -31,11 +31,23 @@ describe(SearchService.name, () => {
describe('getDuplicates', () => {
it('should get duplicates', async () => {
assetMock.getDuplicates.mockResolvedValue([assetStub.hasDupe]);
assetMock.getDuplicates.mockResolvedValue([assetStub.hasDupe, assetStub.hasDupe]);
await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([
{ duplicateId: assetStub.hasDupe.duplicateId, assets: [expect.objectContaining({ id: assetStub.hasDupe.id })] },
{
duplicateId: assetStub.hasDupe.duplicateId,
assets: [
expect.objectContaining({ id: assetStub.hasDupe.id }),
expect.objectContaining({ id: assetStub.hasDupe.id }),
],
},
]);
});
it('should update assets with duplicateId', async () => {
assetMock.getDuplicates.mockResolvedValue([assetStub.hasDupe]);
await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([]);
expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.hasDupe.id], { duplicateId: null });
});
});
describe('handleQueueSearchDuplicates', () => {

View File

@@ -16,8 +16,24 @@ import { usePagination } from 'src/utils/pagination';
export class DuplicateService extends BaseService {
async getDuplicates(auth: AuthDto): Promise<DuplicateResponseDto[]> {
const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] });
return mapDuplicateResponse(res.map((a) => mapAsset(a, { auth, withStack: true })));
const uniqueAssetIds: string[] = [];
const duplicates = mapDuplicateResponse(res.map((a) => mapAsset(a, { auth, withStack: true }))).filter(
(duplicate) => {
if (duplicate.assets.length === 1) {
uniqueAssetIds.push(duplicate.assets[0].id);
return false;
}
return true;
},
);
if (uniqueAssetIds.length > 0) {
try {
await this.assetRepository.updateAll(uniqueAssetIds, { duplicateId: null });
} catch (error: any) {
this.logger.error(`Failed to remove duplicateId from assets: ${error.message}`);
}
}
return duplicates;
}
@OnJob({ name: JobName.QUEUE_DUPLICATE_DETECTION, queue: QueueName.DUPLICATE_DETECTION })

View File

@@ -2133,7 +2133,7 @@ describe(MediaService.name, () => {
'-map 0:1',
'-g 256',
'-v verbose',
'-vf scale_rkrga=-2:720:format=nv12:afbc=1',
'-vf scale_rkrga=-2:720:format=nv12:afbc=1:async_depth=4',
'-level 51',
'-rc_mode CQP',
'-qp_init 23',
@@ -2204,7 +2204,7 @@ describe(MediaService.name, () => {
inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']),
outputOptions: expect.arrayContaining([
expect.stringContaining(
'scale_rkrga=-2:720:format=p010:afbc=1,hwmap=derive_device=opencl:mode=read,tonemap_opencl=format=nv12:r=pc:p=bt709:t=bt709:m=bt709:tonemap=hable:desat=0:tonemap_mode=lum:peak=100,hwmap=derive_device=rkmpp:mode=write:reverse=1,format=drm_prime',
'scale_rkrga=-2:720:format=p010:afbc=1:async_depth=4,hwmap=derive_device=opencl:mode=read,tonemap_opencl=format=nv12:r=pc:p=bt709:t=bt709:m=bt709:tonemap=hable:desat=0:tonemap_mode=lum:peak=100,hwmap=derive_device=rkmpp:mode=write:reverse=1,format=drm_prime',
),
]),
twoPass: false,
@@ -2212,6 +2212,28 @@ describe(MediaService.name, () => {
);
});
it('should set hardware decoding options for rkmpp when hardware decoding is enabled with no OpenCL on non-HDR file', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
storageMock.stat.mockResolvedValue({ isFile: () => false, isCharacterDevice: () => false } as Stats);
mediaMock.probe.mockResolvedValue(probeStub.noAudioStreams);
systemMock.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHWAccel.RKMPP, accelDecode: true, crf: 30, maxBitrate: '0' },
});
assetMock.getByIds.mockResolvedValue([assetStub.video]);
await sut.handleVideoConversion({ id: assetStub.video.id });
expect(mediaMock.transcode).toHaveBeenCalledWith(
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
expect.objectContaining({
inputOptions: expect.arrayContaining(['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']),
outputOptions: expect.arrayContaining([
expect.stringContaining('scale_rkrga=-2:720:format=nv12:afbc=1:async_depth=4'),
]),
twoPass: false,
}),
);
});
it('should use software decoding and tone-mapping if hardware decoding is disabled', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
storageMock.stat.mockResolvedValue({ isFile: () => true, isCharacterDevice: () => true } as Stats);
@@ -2236,7 +2258,7 @@ describe(MediaService.name, () => {
);
});
it('should use software decoding and tone-mapping if opencl is not available', async () => {
it('should use software tone-mapping if opencl is not available', async () => {
storageMock.readdir.mockResolvedValue(['renderD128']);
storageMock.stat.mockResolvedValue({ isFile: () => false, isCharacterDevice: () => false } as Stats);
mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR);
@@ -2249,7 +2271,7 @@ describe(MediaService.name, () => {
'/original/path.ext',
'upload/encoded-video/user-id/as/se/asset-id.mp4',
expect.objectContaining({
inputOptions: [],
inputOptions: expect.any(Array),
outputOptions: expect.arrayContaining([
expect.stringContaining(
'tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=yuv420p',

View File

@@ -329,9 +329,11 @@ export class MediaService extends BaseService {
}
if (ffmpeg.accel === TranscodeHWAccel.DISABLED) {
this.logger.log(`Encoding video ${asset.id} without hardware acceleration`);
this.logger.log(`Transcoding video ${asset.id} without hardware acceleration`);
} else {
this.logger.log(`Encoding video ${asset.id} with ${ffmpeg.accel.toUpperCase()} acceleration`);
this.logger.log(
`Transcoding video ${asset.id} with ${ffmpeg.accel.toUpperCase()}-accelerated encoding and${ffmpeg.accelDecode ? '' : ' software'} decoding`,
);
}
try {
@@ -341,10 +343,26 @@ export class MediaService extends BaseService {
if (ffmpeg.accel === TranscodeHWAccel.DISABLED) {
return JobStatus.FAILED;
}
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`);
const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED });
command = config.getCommand(target, mainVideoStream, mainAudioStream);
await this.mediaRepository.transcode(input, output, command);
let partialFallbackSuccess = false;
if (ffmpeg.accelDecode) {
try {
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()}-accelerated encoding and software decoding`);
const config = BaseConfig.create({ ...ffmpeg, accelDecode: false });
command = config.getCommand(target, mainVideoStream, mainAudioStream);
await this.mediaRepository.transcode(input, output, command);
partialFallbackSuccess = true;
} catch (error: any) {
this.logger.error(`Error occurred during transcoding: ${error.message}`);
}
}
if (!partialFallbackSuccess) {
this.logger.error(`Retrying with ${ffmpeg.accel.toUpperCase()} acceleration disabled`);
const config = BaseConfig.create({ ...ffmpeg, accel: TranscodeHWAccel.DISABLED });
command = config.getCommand(target, mainVideoStream, mainAudioStream);
await this.mediaRepository.transcode(input, output, command);
}
}
this.logger.log(`Successfully encoded ${asset.id}`);
@@ -502,7 +520,7 @@ export class MediaService extends BaseService {
const maliDeviceStat = await this.storageRepository.stat('/dev/mali0');
this.maliOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice();
} catch {
this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU decoding');
this.logger.debug('OpenCL not available for transcoding, so RKMPP acceleration will use CPU tonemapping');
this.maliOpenCL = false;
}
}

View File

@@ -58,10 +58,9 @@ export class BaseConfig implements VideoCodecSWConfig {
break;
}
case TranscodeHWAccel.RKMPP: {
handler =
config.accelDecode && hasMaliOpenCL
? new RkmppHwDecodeConfig(config, devices)
: new RkmppSwDecodeConfig(config, devices);
handler = config.accelDecode
? new RkmppHwDecodeConfig(config, devices, hasMaliOpenCL)
: new RkmppSwDecodeConfig(config, devices);
break;
}
default: {
@@ -327,6 +326,32 @@ export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
this.devices = this.validateDevices(devices);
}
getScalingFilter(videoStream: VideoStreamInfo, format: string) {
return `scale_${this.config.accel}=${this.getScaling(videoStream)}:format=${format}`;
}
getHwDecodeFilterOptions(videoStream: VideoStreamInfo) {
const options = [];
const tonemapOptions = this.getToneMapping(videoStream);
if (this.shouldScale(videoStream) || tonemapOptions.length === 0) {
const format = tonemapOptions.length === 0 ? 'nv12' : 'p010';
options.push(this.getScalingFilter(videoStream, format));
} else if (tonemapOptions.length > 0) {
options.push('format=p010');
}
options.push(...tonemapOptions);
if (options.length === 0 && !videoStream.pixelFormat.endsWith('420p')) {
options.push('format=nv12');
}
return options;
}
getFilterOptions(videoStream: VideoStreamInfo) {
return this.config.accelDecode
? this.getHwDecodeFilterOptions(videoStream)
: [`hwupload_${this.config.accel}`, ...this.getHwDecodeFilterOptions(videoStream)];
}
getSupportedCodecs() {
return [VideoCodec.H264, VideoCodec.HEVC];
}
@@ -370,6 +395,20 @@ export class BaseHWConfig extends BaseConfig implements VideoCodecHWConfig {
return `/dev/dri/${deviceName}`;
}
getInputThreadOptions() {
if (this.config.accelDecode) {
return [`-threads 1`];
}
return [];
}
getOutputThreadOptions() {
if (this.config.accelDecode) {
return [];
}
return super.getOutputThreadOptions();
}
}
export class ThumbnailConfig extends BaseConfig {
@@ -504,15 +543,11 @@ export class AV1Config extends BaseConfig {
}
}
export class NvencSwDecodeConfig extends BaseHWConfig {
export class NvencConfig extends BaseHWConfig {
getSupportedCodecs() {
return [VideoCodec.H264, VideoCodec.HEVC, VideoCodec.AV1];
}
getBaseInputOptions() {
return ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'];
}
getBaseOutputOptions(target: TranscodeTarget, videoStream: VideoStreamInfo, audioStream?: AudioStreamInfo) {
const options = [
// below settings recommended from https://docs.nvidia.com/video-technologies/video-codec-sdk/12.0/ffmpeg-with-nvidia-gpu/index.html#command-line-for-latency-tolerant-high-quality-transcoding
@@ -531,16 +566,6 @@ export class NvencSwDecodeConfig extends BaseHWConfig {
return options;
}
getFilterOptions(videoStream: VideoStreamInfo) {
const options = this.getToneMapping(videoStream);
options.push('hwupload_cuda');
if (this.shouldScale(videoStream)) {
options.push(`scale_cuda=${this.getScaling(videoStream)}:format=nv12`);
}
return options;
}
getPresetOptions() {
let presetIndex = this.getPresetIndex();
if (presetIndex < 0) {
@@ -570,10 +595,6 @@ export class NvencSwDecodeConfig extends BaseHWConfig {
}
}
getThreadOptions() {
return [];
}
getRefs() {
const bframes = this.getBFrames();
if (bframes > 0 && bframes < 3 && this.config.refs < 3) {
@@ -581,27 +602,14 @@ export class NvencSwDecodeConfig extends BaseHWConfig {
}
return this.config.refs;
}
}
export class NvencHwDecodeConfig extends NvencSwDecodeConfig {
getBaseInputOptions() {
if (!this.config.accelDecode) {
return ['-init_hw_device cuda=cuda:0', '-filter_hw_device cuda'];
}
return ['-hwaccel cuda', '-hwaccel_output_format cuda', '-noautorotate', ...this.getInputThreadOptions()];
}
getFilterOptions(videoStream: VideoStreamInfo) {
const options = [];
if (this.shouldScale(videoStream)) {
options.push(`scale_cuda=${this.getScaling(videoStream)}`);
}
options.push(...this.getToneMapping(videoStream));
if (options.length > 0) {
options[options.length - 1] += ':format=nv12';
} else if (!videoStream.pixelFormat.endsWith('420p')) {
options.push('format=nv12');
}
return options;
}
getToneMapping(videoStream: VideoStreamInfo) {
if (!this.shouldToneMap(videoStream)) {
return [];
@@ -617,28 +625,36 @@ export class NvencHwDecodeConfig extends NvencSwDecodeConfig {
'tonemap_mode=lum',
`transfer=${transfer}`,
'peak=100',
'format=nv12',
];
return [`tonemap_cuda=${tonemapOptions.join(':')}`];
}
getInputThreadOptions() {
return [`-threads 1`];
}
getOutputThreadOptions() {
return [];
}
}
export class QsvSwDecodeConfig extends BaseHWConfig {
export class QsvConfig extends BaseHWConfig {
getBaseInputOptions() {
if (this.devices.length === 0) {
throw new Error('No QSV device found');
}
let qsvString = '';
const hwDevice = this.getPreferredHardwareDevice();
if (this.config.accelDecode) {
const options = [
'-hwaccel qsv',
'-hwaccel_output_format qsv',
'-async_depth 4',
'-noautorotate',
...this.getInputThreadOptions(),
];
if (hwDevice) {
options.push(`-qsv_device ${hwDevice}`);
}
return options;
}
let qsvString = '';
if (hwDevice) {
qsvString = `,child_device=${hwDevice}`;
}
@@ -655,15 +671,6 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
return options;
}
getFilterOptions(videoStream: VideoStreamInfo) {
const options = this.getToneMapping(videoStream);
options.push('hwupload=extra_hw_frames=64');
if (this.shouldScale(videoStream)) {
options.push(`scale_qsv=${this.getScaling(videoStream)}:mode=hq:format=nv12`);
}
return options;
}
getPresetOptions() {
let presetIndex = this.getPresetIndex();
if (presetIndex < 0) {
@@ -709,44 +716,9 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
getScaling(videoStream: VideoStreamInfo): string {
return super.getScaling(videoStream, 1);
}
}
export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
getBaseInputOptions() {
if (this.devices.length === 0) {
throw new Error('No QSV device found');
}
const options = [
'-hwaccel qsv',
'-hwaccel_output_format qsv',
'-async_depth 4',
'-noautorotate',
...this.getInputThreadOptions(),
];
const hwDevice = this.getPreferredHardwareDevice();
if (hwDevice) {
options.push(`-qsv_device ${hwDevice}`);
}
return options;
}
getFilterOptions(videoStream: VideoStreamInfo) {
const options = [];
const tonemapOptions = this.getToneMapping(videoStream);
if (this.shouldScale(videoStream) || tonemapOptions.length === 0) {
let scaling = `scale_qsv=${this.getScaling(videoStream)}:async_depth=4:mode=hq`;
if (tonemapOptions.length === 0) {
scaling += ':format=nv12';
}
options.push(scaling);
}
options.push(...tonemapOptions);
if (options.length === 0 && !videoStream.pixelFormat.endsWith('420p')) {
options.push('format=nv12');
}
return options;
getScalingFilter(videoStream: VideoStreamInfo, format: string) {
return `scale_qsv=${this.getScaling(videoStream)}:async_depth=4:mode=hq:format=${format}`;
}
getToneMapping(videoStream: VideoStreamInfo): string[] {
@@ -773,19 +745,27 @@ export class QsvHwDecodeConfig extends QsvSwDecodeConfig {
'hwmap=derive_device=qsv:reverse=1,format=qsv',
];
}
getInputThreadOptions() {
return [`-threads 1`];
}
}
export class VaapiSwDecodeConfig extends BaseHWConfig {
export class VaapiConfig extends BaseHWConfig {
getBaseInputOptions() {
if (this.devices.length === 0) {
throw new Error('No VAAPI device found');
}
let hwDevice = this.getPreferredHardwareDevice();
if (this.config.accelDecode) {
const options = [
'-hwaccel vaapi',
'-hwaccel_output_format vaapi',
'-noautorotate',
...this.getInputThreadOptions(),
];
if (hwDevice) {
options.push(`-hwaccel_device ${hwDevice}`);
}
}
if (!hwDevice) {
hwDevice = `/dev/dri/${this.devices[0]}`;
}
@@ -793,16 +773,6 @@ export class VaapiSwDecodeConfig extends BaseHWConfig {
return [`-init_hw_device vaapi=accel:${hwDevice}`, '-filter_hw_device accel'];
}
getFilterOptions(videoStream: VideoStreamInfo) {
const options = this.getToneMapping(videoStream);
options.push('hwupload=extra_hw_frames=64');
if (this.shouldScale(videoStream)) {
options.push(`scale_vaapi=${this.getScaling(videoStream)}:mode=hq:out_range=pc:format=nv12`);
}
return options;
}
getPresetOptions() {
let presetIndex = this.getPresetIndex();
if (presetIndex < 0) {
@@ -844,43 +814,9 @@ export class VaapiSwDecodeConfig extends BaseHWConfig {
useCQP() {
return this.config.cqMode !== CQMode.ICQ || this.config.targetVideoCodec === VideoCodec.VP9;
}
}
export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig {
getBaseInputOptions() {
if (this.devices.length === 0) {
throw new Error('No VAAPI device found');
}
const options = [
'-hwaccel vaapi',
'-hwaccel_output_format vaapi',
'-noautorotate',
...this.getInputThreadOptions(),
];
const hwDevice = this.getPreferredHardwareDevice();
if (hwDevice) {
options.push(`-hwaccel_device ${hwDevice}`);
}
return options;
}
getFilterOptions(videoStream: VideoStreamInfo) {
const options = [];
const tonemapOptions = this.getToneMapping(videoStream);
if (this.shouldScale(videoStream) || tonemapOptions.length === 0) {
let scaling = `scale_vaapi=${this.getScaling(videoStream)}:mode=hq:out_range=pc`;
if (tonemapOptions.length === 0) {
scaling += ':format=nv12';
}
options.push(scaling);
}
options.push(...tonemapOptions);
if (options.length === 0 && !videoStream.pixelFormat.endsWith('420p')) {
options.push('format=nv12');
}
return options;
getScalingFilter(videoStream: VideoStreamInfo, format: string) {
return `scale_vaapi=${this.getScaling(videoStream)}:mode=hq:out_range=pc:format=${format}`;
}
getToneMapping(videoStream: VideoStreamInfo): string[] {
@@ -907,29 +843,33 @@ export class VaapiHwDecodeConfig extends VaapiSwDecodeConfig {
'hwmap=derive_device=vaapi:reverse=1,format=vaapi',
];
}
getInputThreadOptions() {
return [`-threads 1`];
}
}
export class RkmppSwDecodeConfig extends BaseHWConfig {
export class RkmppConfig extends BaseHWConfig {
protected hasMaliOpenCL: boolean;
constructor(
protected config: SystemConfigFFmpegDto,
devices: string[] = [],
hasMaliOpenCL = false,
) {
super(config, devices);
this.hasMaliOpenCL = hasMaliOpenCL;
}
eligibleForTwoPass(): boolean {
return false;
}
getBaseInputOptions(): string[] {
getBaseInputOptions() {
if (this.devices.length === 0) {
throw new Error('No RKMPP device found');
}
return [];
if (this.config.accelDecode) {
return ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga', '-noautorotate'];
}
return ['-init_hw_device rkmpp=hw', '-filter_hw_device hw'];
}
getPresetOptions() {
@@ -962,32 +902,29 @@ export class RkmppSwDecodeConfig extends BaseHWConfig {
return [VideoCodec.H264, VideoCodec.HEVC];
}
getVideoCodec(): string {
return `${this.config.targetVideoCodec}_rkmpp`;
}
}
export class RkmppHwDecodeConfig extends RkmppSwDecodeConfig {
getBaseInputOptions() {
if (this.devices.length === 0) {
throw new Error('No RKMPP device found');
}
return ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga', '-noautorotate'];
}
getFilterOptions(videoStream: VideoStreamInfo) {
if (this.shouldToneMap(videoStream)) {
const { primaries, transfer, matrix } = this.getColors();
if (this.hasMaliOpenCL) {
return [
// use RKMPP for scaling, OpenCL for tone mapping
`scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1:async_depth=4`,
'hwmap=derive_device=opencl:mode=read',
`tonemap_opencl=format=nv12:r=pc:p=${primaries}:t=${transfer}:m=${matrix}:tonemap=${this.config.tonemap}:desat=0:tonemap_mode=lum:peak=100`,
'hwmap=derive_device=rkmpp:mode=write:reverse=1',
'format=drm_prime',
];
}
return [
`scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1`,
'hwmap=derive_device=opencl:mode=read',
`tonemap_opencl=format=nv12:r=pc:p=${primaries}:t=${transfer}:m=${matrix}:tonemap=${this.config.tonemap}:desat=0:tonemap_mode=lum:peak=100`,
'hwmap=derive_device=rkmpp:mode=write:reverse=1',
'format=drm_prime',
// use RKMPP for scaling, CPU for tone mapping (only works on RK3588, which supports 10-bit output)
`scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1:async_depth=4`,
'hwdownload',
'format=p010',
`tonemapx=tonemap=${this.config.tonemap}:desat=0:p=${primaries}:t=${transfer}:m=${matrix}:r=pc:peak=100:format=yuv420p`,
'hwupload',
];
} else if (this.shouldScale(videoStream)) {
return [`scale_rkrga=${this.getScaling(videoStream)}:format=nv12:afbc=1`];
return [`scale_rkrga=${this.getScaling(videoStream)}:format=nv12:afbc=1:async_depth=4`];
}
return [];
}

View File

@@ -1,4 +1,4 @@
FROM node:22.10.0-alpine3.20@sha256:fc95a044b87e95507c60c1f8c829e5d98ddf46401034932499db370c494ef0ff
FROM node:22.11.0-alpine3.20@sha256:f265794478aa0b1a23d85a492c8311ed795bc527c3fe7e43453b3c872dcd71a3
RUN apk add --no-cache tini
USER node