mirror of
https://github.com/immich-app/immich.git
synced 2026-07-07 04:57:05 -07:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f53477e474 | |||
| b2b5841472 | |||
| c0f9c50abe | |||
| 7d27eeceb6 | |||
| bca556b6c5 | |||
| ead25d62b5 | |||
| 8734066187 | |||
| d2580e9d53 | |||
| 07043c4af1 | |||
| fec11ab156 | |||
| 3919887c2a | |||
| bb8e242fbe |
@@ -5,3 +5,4 @@
|
||||
/machine-learning/ @mertalev
|
||||
/e2e/ @danieldietzler
|
||||
/mobile/ @shenlong-tanwen @santoshakil
|
||||
/native/ @santoshakil @mertalev
|
||||
|
||||
@@ -28,15 +28,11 @@ docker image prune
|
||||
|
||||
## Versioning Policy
|
||||
|
||||
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`.
|
||||
We intend for breaking changes, including those to the API or deployment, to be limited to major version releases.
|
||||
You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`. These metatags do not follow release candidates.
|
||||
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`. We intend for breaking changes to be limited to major version releases.
|
||||
You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`.
|
||||
|
||||
The mobile app is typically compatible with the current and prior major version. However, the server is only compatible with the matching major version.
|
||||
Thus, we recommend upgrading all mobile clients before upgrading the server to ensure compatibility.
|
||||
|
||||
We do not backport patches to earlier versions. We encourage all users to run the most recent stable release of Immich.
|
||||
Downgrading to an earlier version, even within the same minor version, is not supported.
|
||||
Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich.
|
||||
Switching back to an earlier version, even within the same minor release tag, is not supported.
|
||||
|
||||
[semver]: https://semver.org/
|
||||
|
||||
|
||||
@@ -431,10 +431,6 @@
|
||||
"transcoding_realtime_description": "Allows transcoding to be performed in real-time as the video is being streamed. Enables quality switching, but may cause higher playback latency and stuttering depending on server capabilities.",
|
||||
"transcoding_realtime_enabled": "Enable real-time transcoding",
|
||||
"transcoding_realtime_enabled_description": "If disabled, the server will refuse to start new real-time transcoding sessions.",
|
||||
"transcoding_realtime_resolutions": "Resolutions",
|
||||
"transcoding_realtime_resolutions_description": "The resolutions offered for real-time transcoding. Higher resolutions may cause playback issues if the server cannot transcode them quickly enough.",
|
||||
"transcoding_realtime_video_codecs": "Video codecs",
|
||||
"transcoding_realtime_video_codecs_description": "The video codecs offered for real-time transcoding. Clients will choose the best option they support during playback. AV1 is more efficient than HEVC, which is more efficient than H.264. When using hardware acceleration, only select the codecs the accelerator can encode. When using software transcoding, note that H.264 is faster than AV1, which is faster than HEVC.",
|
||||
"transcoding_reference_frames": "Reference frames",
|
||||
"transcoding_reference_frames_description": "The number of frames to reference when compressing a given frame. Higher values improve compression efficiency, but slow down encoding. 0 sets this value automatically.",
|
||||
"transcoding_required_description": "Only videos not in an accepted format",
|
||||
|
||||
@@ -107,67 +107,3 @@ Java_app_alextran_immich_NativeImage_rotate(
|
||||
}
|
||||
return (jlong) dst;
|
||||
}
|
||||
|
||||
// Convert an RGBA_1010102 buffer to densely-packed RGBA_8888, matching Skia's
|
||||
// Bitmap.copy(ARGB_8888) byte-for-byte so it's a drop-in for the intermediate 8888 bitmap.
|
||||
// Each source pixel is a native (little-endian on every Android ABI) u32 with R in bits 0-9,
|
||||
// G in 10-19, B in 20-29, A in 30-31 (standard RGB10_A2). Each 10-bit channel maps to 8-bit via
|
||||
// round(v*255/1023). The 2-bit alpha maps to a*85. Both are kept as plain arithmetic as the whole
|
||||
// loop auto-vectorizes to NEON and measures faster than a LUT on-device. Output is R,G,B,A bytes
|
||||
// per pixel, i.e. Android ARGB_8888 memory == Dart PixelFormat.rgba8888.
|
||||
static void convert_1010102(const uint8_t *src, int srcStride, uint32_t *dst, int w, int h) {
|
||||
for (int y = 0; y < h; y++) {
|
||||
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) y * srcStride);
|
||||
uint32_t *dstRow = dst + (size_t) y * w;
|
||||
for (int x = 0; x < w; x++) {
|
||||
uint32_t px = srcRow[x];
|
||||
uint32_t r = ((px & 0x3FF) * 16336u + 32768u) >> 16;
|
||||
uint32_t g = (((px >> 10) & 0x3FF) * 16336u + 32768u) >> 16;
|
||||
uint32_t b = (((px >> 20) & 0x3FF) * 16336u + 32768u) >> 16;
|
||||
uint32_t a = ((px >> 30) & 0x3) * 85u;
|
||||
dstRow[x] = r | (g << 8) | (b << 16) | (a << 24);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Converts an RGBA_1010102 bitmap (what a 10-bit HEIC/AVIF decodes to on API 33+) into a freshly
|
||||
// malloc'd RGBA_8888 buffer. Fills outInfo with {width, height, rowBytes} and returns the buffer
|
||||
// address, or 0 (so the caller falls back to a Skia copy) if the bitmap isn't 1010102 or can't be
|
||||
// locked. Same ownership contract as rotate: free the returned buffer via NativeBuffer.free.
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_NativeImage_convert1010102(
|
||||
JNIEnv *env, jclass clazz, jobject bitmap, jintArray outInfo) {
|
||||
AndroidBitmapInfo info;
|
||||
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
return 0;
|
||||
}
|
||||
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_1010102) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int w = (int) info.width;
|
||||
int h = (int) info.height;
|
||||
|
||||
uint32_t *dst = (uint32_t *) malloc((size_t) w * h * 4);
|
||||
if (dst == NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void *srcPixels = NULL;
|
||||
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
free(dst);
|
||||
return 0;
|
||||
}
|
||||
|
||||
convert_1010102((const uint8_t *) srcPixels, (int) info.stride, dst, w, h);
|
||||
|
||||
AndroidBitmap_unlockPixels(env, bitmap);
|
||||
|
||||
jint dims[3] = {w, h, w * 4};
|
||||
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
|
||||
if ((*env)->ExceptionCheck(env)) {
|
||||
free(dst);
|
||||
return 0;
|
||||
}
|
||||
return (jlong) dst;
|
||||
}
|
||||
|
||||
@@ -16,14 +16,4 @@ object NativeImage {
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun rotate(bitmap: Bitmap, orientation: Int, outInfo: IntArray): Long
|
||||
|
||||
/**
|
||||
* Converts an RGBA_1010102 [bitmap] (what a 10-bit HEIC/AVIF decodes to on API 33+) to RGBA_8888,
|
||||
* writing the result into a freshly malloc'd native buffer in one pass, with no intermediate
|
||||
* ARGB_8888 bitmap. Returns the buffer address (free it with [NativeBuffer.free]) and fills
|
||||
* [outInfo] with {width, height, rowBytes}. Returns 0 when the bitmap isn't RGBA_1010102 so the
|
||||
* caller can fall back to a Skia copy.
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun convert1010102(bitmap: Bitmap, outInfo: IntArray): Long
|
||||
}
|
||||
|
||||
@@ -46,47 +46,22 @@ inline fun ImageDecoder.Source.decodeBitmap(target: Size = Size(0, 0)): Bitmap {
|
||||
}
|
||||
|
||||
fun Bitmap.toNativeBuffer(): Map<String, Long> {
|
||||
// Dart reads the buffer as rgba8888, but 10-bit sources decode to RGBA_1010102, which garbles
|
||||
// colors when copied verbatim. Convert those straight into the output buffer in native code -
|
||||
// one pass, no intermediate ARGB_8888 bitmap.
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && config == Bitmap.Config.RGBA_1010102) {
|
||||
val info = IntArray(3)
|
||||
val pointer = NativeImage.convert1010102(this, info)
|
||||
if (pointer != 0L) {
|
||||
recycle()
|
||||
return mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to info[0].toLong(),
|
||||
"height" to info[1].toLong(),
|
||||
"rowBytes" to info[2].toLong()
|
||||
)
|
||||
}
|
||||
// native convert declined (OOM/lock) -> fall through to the Skia copy path below.
|
||||
}
|
||||
// Other non-8888 configs (e.g. HDR F16) still need Skia's convert; 8-bit is copied as-is.
|
||||
val bitmap = if (config != Bitmap.Config.ARGB_8888) {
|
||||
val converted = copy(Bitmap.Config.ARGB_8888, false)
|
||||
recycle()
|
||||
converted ?: throw IOException("could not convert bitmap to ARGB_8888")
|
||||
} else {
|
||||
this
|
||||
}
|
||||
val size = bitmap.width * bitmap.height * 4
|
||||
val size = width * height * 4
|
||||
val pointer = NativeBuffer.allocate(size)
|
||||
try {
|
||||
val buffer = NativeBuffer.wrap(pointer, size)
|
||||
bitmap.copyPixelsToBuffer(buffer)
|
||||
copyPixelsToBuffer(buffer)
|
||||
return mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to bitmap.width.toLong(),
|
||||
"height" to bitmap.height.toLong(),
|
||||
"rowBytes" to (bitmap.width * 4).toLong()
|
||||
"width" to width.toLong(),
|
||||
"height" to height.toLong(),
|
||||
"rowBytes" to (width * 4).toLong()
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
} catch (e: Exception) {
|
||||
NativeBuffer.free(pointer)
|
||||
throw e
|
||||
} finally {
|
||||
bitmap.recycle()
|
||||
recycle()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package app.alextran.immich.images
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.ImageDecoder
|
||||
import android.os.Build
|
||||
import android.os.CancellationSignal
|
||||
import android.os.OperationCanceledException
|
||||
import app.alextran.immich.INITIAL_BUFFER_SIZE
|
||||
@@ -24,7 +22,6 @@ import java.io.File
|
||||
import java.io.IOException
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
private const val MAX_PREALLOC_BYTES = 128 * 1024 * 1024
|
||||
|
||||
@@ -39,16 +36,12 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
|
||||
companion object {
|
||||
val CANCELLED = Result.success<Map<String, Long>?>(null)
|
||||
|
||||
// Shared, process-lifetime pool: RemoteImagesImpl is re-created per FlutterEngine, so a
|
||||
// per-instance pool would leak threads across engine restarts.
|
||||
private val decodeExecutor = Executors.newFixedThreadPool(2)
|
||||
}
|
||||
|
||||
override fun requestImage(
|
||||
url: String,
|
||||
requestId: Long,
|
||||
preferEncoded: Boolean,
|
||||
@Suppress("UNUSED_PARAMETER") preferEncoded: Boolean, // always returns encoded; setting has no effect on Android
|
||||
callback: (Result<Map<String, Long>?>) -> Unit
|
||||
) {
|
||||
val signal = CancellationSignal()
|
||||
@@ -58,51 +51,12 @@ class RemoteImagesImpl(context: Context) : RemoteImageApi {
|
||||
url,
|
||||
signal,
|
||||
onSuccess = { buffer ->
|
||||
requestMap.remove(requestId)
|
||||
if (signal.isCanceled) {
|
||||
requestMap.remove(requestId)
|
||||
buffer.free()
|
||||
NativeBuffer.free(buffer.pointer)
|
||||
return@fetch callback(CANCELLED)
|
||||
}
|
||||
|
||||
// Decode natively when the caller wants pixels: Flutter's fallback decoder copies
|
||||
// 10-bit bitmaps (RGBA_1010102) as if they were rgba8888, garbling colors. Decode on a
|
||||
// dedicated pool - the fetch callback threads are shared with video streaming. On any
|
||||
// decode failure (including OOM on huge originals), hand Flutter the encoded bytes as
|
||||
// before.
|
||||
if (!preferEncoded && Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
decodeExecutor.execute {
|
||||
val res = if (signal.isCanceled) null else try {
|
||||
val source = ImageDecoder.createSource(NativeBuffer.wrap(buffer.pointer, buffer.offset))
|
||||
source.decodeBitmap().toNativeBuffer()
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
requestMap.remove(requestId)
|
||||
when {
|
||||
// Deliver even if the request was cancelled meanwhile: re-checking here would orphan
|
||||
// res's malloc, and Dart frees the buffer itself when it sees the cancel.
|
||||
res != null -> {
|
||||
buffer.free()
|
||||
callback(Result.success(res))
|
||||
}
|
||||
signal.isCanceled -> {
|
||||
buffer.free()
|
||||
callback(CANCELLED)
|
||||
}
|
||||
else -> callback(
|
||||
Result.success(
|
||||
mapOf(
|
||||
"pointer" to buffer.pointer,
|
||||
"length" to buffer.offset.toLong()
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return@fetch
|
||||
}
|
||||
|
||||
requestMap.remove(requestId)
|
||||
callback(
|
||||
Result.success(
|
||||
mapOf(
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// Plumbing check: proves immich_native_core is usable from the real immich app on
|
||||
// a real device — the build hook compiled the Rust for this target, the code asset
|
||||
// bundled into the app, and the @Native symbols resolve at runtime. The two
|
||||
// payloads run against their device-verified ground truth: the EXIF rotate ported
|
||||
// from native_image.c (#29337) and the 10-bit convert matching Skia's Bitmap.copy
|
||||
// (#29631). Calls the generated bindings directly — dart is the test harness here;
|
||||
// the production callers are the platform decode pipelines.
|
||||
// Self-contained: does NOT boot the immich app or need a server.
|
||||
//
|
||||
// Run: flutter test integration_test/native_core_test.dart -d <device>
|
||||
import 'dart:ffi';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_native_core/immich_native_core.dart';
|
||||
import 'package:integration_test/integration_test.dart';
|
||||
|
||||
Uint8List _px1010102(int r, int g, int b, int a) {
|
||||
final px = (r & 0x3FF) | ((g & 0x3FF) << 10) | ((b & 0x3FF) << 20) | ((a & 0x3) << 30);
|
||||
return Uint8List(4)..buffer.asByteData().setUint32(0, px, Endian.little);
|
||||
}
|
||||
|
||||
Uint8List? _withBuffers(
|
||||
Uint8List src,
|
||||
int dstLen,
|
||||
bool Function(Pointer<Uint8> src, int dstLen, Pointer<Uint8> dst) call,
|
||||
) {
|
||||
final srcPtr = malloc<Uint8>(src.length);
|
||||
final dstPtr = malloc<Uint8>(dstLen);
|
||||
try {
|
||||
srcPtr.asTypedList(src.length).setAll(0, src);
|
||||
if (!call(srcPtr, dstLen, dstPtr)) {
|
||||
return null;
|
||||
}
|
||||
return Uint8List.fromList(dstPtr.asTypedList(dstLen));
|
||||
} finally {
|
||||
malloc.free(srcPtr);
|
||||
malloc.free(dstPtr);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test('native core loads: version roundtrips through the C string contract', () {
|
||||
final ptr = immich_core_version();
|
||||
expect(ptr, isNot(equals(nullptr)));
|
||||
final version = ptr.cast<Utf8>().toDartString();
|
||||
immich_core_free_string(ptr);
|
||||
expect(version, isNotEmpty);
|
||||
});
|
||||
|
||||
test('exif rotate (the #29337 algorithm) rotates 180 on device', () {
|
||||
// 2x1: red, green -> 180 -> green, red
|
||||
final src = Uint8List.fromList([255, 0, 0, 255, 0, 255, 0, 255]);
|
||||
final out = _withBuffers(src, 8, (s, len, d) =>
|
||||
immich_core_rotate_rgba8888(s, src.length, 8, 2, 1, 3, d, len));
|
||||
expect(out, [0, 255, 0, 255, 255, 0, 0, 255]);
|
||||
});
|
||||
|
||||
test('10-bit convert (the #29631 algorithm) matches Skia ground truth on device', () {
|
||||
// 179->45 and 111->28 pin round(v*255/1023) over >>2 (44/27) — the exact
|
||||
// discriminating values probed on this hardware against Bitmap.copy.
|
||||
final src = Uint8List.fromList([
|
||||
..._px1010102(1023, 0, 0, 3),
|
||||
..._px1010102(179, 111, 0, 3),
|
||||
]);
|
||||
final out = _withBuffers(src, 8, (s, len, d) =>
|
||||
immich_core_rgba1010102_to_rgba8888(s, src.length, 8, 2, 1, d, len));
|
||||
expect(out, isNotNull);
|
||||
expect(out!.sublist(0, 4), [255, 0, 0, 255]);
|
||||
expect(out.sublist(4, 8), [45, 28, 0, 255]);
|
||||
});
|
||||
}
|
||||
@@ -34,6 +34,15 @@ platform :ios do
|
||||
)
|
||||
end
|
||||
|
||||
# Xcode script phases don't inherit MISE_TRUSTED_CONFIG_PATHS, and the mise shim
|
||||
# for rustup refuses untrusted configs, which kills the native assets build hook.
|
||||
# `mise trust` persists trust to disk so it survives into the xcode environment.
|
||||
def trust_mise_configs
|
||||
return unless system("command -v mise > /dev/null 2>&1")
|
||||
sh("mise trust ../../../mise.toml")
|
||||
sh("mise trust ../../mise.toml")
|
||||
end
|
||||
|
||||
# Helper method to assemble xcargs with optional CUSTOM_GROUP_ID override
|
||||
def build_xcargs(group_id: nil)
|
||||
args = "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual"
|
||||
@@ -102,6 +111,8 @@ end
|
||||
)
|
||||
app_identifier = base_bundle_id
|
||||
|
||||
trust_mise_configs
|
||||
|
||||
# Set version number if provided
|
||||
if version_number
|
||||
increment_version_number(version_number: version_number)
|
||||
@@ -258,6 +269,8 @@ end
|
||||
|
||||
api_key = get_api_key
|
||||
|
||||
trust_mise_configs
|
||||
|
||||
# Download and install provisioning profiles from App Store Connect
|
||||
# Certificate is imported by GHA workflow into build.keychain
|
||||
sigh(api_key: api_key, app_identifier: DEV_BUNDLE_ID, force: true)
|
||||
@@ -284,6 +297,7 @@ end
|
||||
configuration: "Release",
|
||||
export_method: "app-store",
|
||||
skip_package_ipa: true,
|
||||
xcodebuild_formatter: "", # raw xcodebuild output so script-phase errors show in CI logs
|
||||
xcargs: build_xcargs(group_id: DEV_GROUP_ID),
|
||||
export_options: {
|
||||
provisioningProfiles: {
|
||||
|
||||
@@ -12,7 +12,7 @@ class RemoteImageRequest extends ImageRequest {
|
||||
}
|
||||
|
||||
final info = await remoteImageApi.requestImage(uri, requestId: requestId, preferEncoded: false);
|
||||
// Android falls back to encoded data if native decoding fails, so check for both shapes of the response.
|
||||
// Android always returns encoded data, so we need to check for both shapes of the response.
|
||||
final frame = switch (info) {
|
||||
{'pointer': int pointer, 'length': int length} => await _fromEncodedPlatformImage(pointer, length),
|
||||
{'pointer': int pointer, 'width': int width, 'height': int height, 'rowBytes': int rowBytes} =>
|
||||
|
||||
@@ -184,3 +184,10 @@ url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c55847602
|
||||
[tools.java."platforms.windows-x64"]
|
||||
checksum = "sha256:b6c17e747ae78cdd6de4d7532b3164b277daee97c007d3eaa2b39cca99882664"
|
||||
url = "https://download.java.net/java/GA/jdk21.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-21.0.2_windows-x64_bin.zip"
|
||||
|
||||
[[tools.rust]]
|
||||
version = "1.92.0"
|
||||
backend = "core:rust"
|
||||
|
||||
[tools.rust.options]
|
||||
targets = "aarch64-apple-ios,aarch64-apple-ios-sim,aarch64-linux-android,armv7-linux-androideabi,x86_64-apple-ios,x86_64-linux-android"
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
[tools]
|
||||
"aqua:flutter/flutter" = "3.44.1"
|
||||
java = "21.0.2"
|
||||
# immich_native_core builds from source via a dart build hook that drives rustup.
|
||||
# mise bootstraps rustup + this toolchain; keep in sync with the crate's rust-toolchain.toml.
|
||||
# targets are preinstalled here because mise exports RUSTUP_TOOLCHAIN, which makes
|
||||
# rustup ignore the crate's rust-toolchain.toml (and its target list) at build time.
|
||||
rust = { version = "1.92.0", targets = "armv7-linux-androideabi,aarch64-linux-android,x86_64-linux-android,aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios" }
|
||||
|
||||
[tools."github:CQLabs/homebrew-dcm"]
|
||||
version = "1.37.0"
|
||||
|
||||
Generated
-1
@@ -452,7 +452,6 @@ Class | Method | HTTP request | Description
|
||||
- [FacialRecognitionConfig](doc//FacialRecognitionConfig.md)
|
||||
- [FoldersResponse](doc//FoldersResponse.md)
|
||||
- [FoldersUpdate](doc//FoldersUpdate.md)
|
||||
- [HlsVideoResolution](doc//HlsVideoResolution.md)
|
||||
- [ImageFormat](doc//ImageFormat.md)
|
||||
- [IntegrityReport](doc//IntegrityReport.md)
|
||||
- [IntegrityReportResponseDto](doc//IntegrityReportResponseDto.md)
|
||||
|
||||
Generated
-1
@@ -173,7 +173,6 @@ part 'model/face_dto.dart';
|
||||
part 'model/facial_recognition_config.dart';
|
||||
part 'model/folders_response.dart';
|
||||
part 'model/folders_update.dart';
|
||||
part 'model/hls_video_resolution.dart';
|
||||
part 'model/image_format.dart';
|
||||
part 'model/integrity_report.dart';
|
||||
part 'model/integrity_report_response_dto.dart';
|
||||
|
||||
Generated
-2
@@ -391,8 +391,6 @@ class ApiClient {
|
||||
return FoldersResponse.fromJson(value);
|
||||
case 'FoldersUpdate':
|
||||
return FoldersUpdate.fromJson(value);
|
||||
case 'HlsVideoResolution':
|
||||
return HlsVideoResolutionTypeTransformer().decode(value);
|
||||
case 'ImageFormat':
|
||||
return ImageFormatTypeTransformer().decode(value);
|
||||
case 'IntegrityReport':
|
||||
|
||||
Generated
-3
@@ -106,9 +106,6 @@ String parameterToString(dynamic value) {
|
||||
if (value is Colorspace) {
|
||||
return ColorspaceTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is HlsVideoResolution) {
|
||||
return HlsVideoResolutionTypeTransformer().encode(value).toString();
|
||||
}
|
||||
if (value is ImageFormat) {
|
||||
return ImageFormatTypeTransformer().encode(value).toString();
|
||||
}
|
||||
|
||||
-94
@@ -1,94 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.18
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
/// HLS video resolution
|
||||
class HlsVideoResolution {
|
||||
/// Instantiate a new enum with the provided [value].
|
||||
const HlsVideoResolution._(this.value);
|
||||
|
||||
/// The underlying value of this enum member.
|
||||
final int value;
|
||||
|
||||
@override
|
||||
String toString() => value.toString();
|
||||
|
||||
int toJson() => value;
|
||||
|
||||
static const number480 = HlsVideoResolution._(480);
|
||||
static const number720 = HlsVideoResolution._(720);
|
||||
static const number1080 = HlsVideoResolution._(1080);
|
||||
static const number1440 = HlsVideoResolution._(1440);
|
||||
static const number2160 = HlsVideoResolution._(2160);
|
||||
|
||||
/// List of all possible values in this [enum][HlsVideoResolution].
|
||||
static const values = <HlsVideoResolution>[
|
||||
number480,
|
||||
number720,
|
||||
number1080,
|
||||
number1440,
|
||||
number2160,
|
||||
];
|
||||
|
||||
static HlsVideoResolution? fromJson(dynamic value) => HlsVideoResolutionTypeTransformer().decode(value);
|
||||
|
||||
static List<HlsVideoResolution> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <HlsVideoResolution>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = HlsVideoResolution.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
}
|
||||
|
||||
/// Transformation class that can [encode] an instance of [HlsVideoResolution] to int,
|
||||
/// and [decode] dynamic data back to [HlsVideoResolution].
|
||||
class HlsVideoResolutionTypeTransformer {
|
||||
factory HlsVideoResolutionTypeTransformer() => _instance ??= const HlsVideoResolutionTypeTransformer._();
|
||||
|
||||
const HlsVideoResolutionTypeTransformer._();
|
||||
|
||||
int encode(HlsVideoResolution data) => data.value;
|
||||
|
||||
/// Decodes a [dynamic value][data] to a HlsVideoResolution.
|
||||
///
|
||||
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
||||
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
||||
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
||||
///
|
||||
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
||||
/// and users are still using an old app with the old code.
|
||||
HlsVideoResolution? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case 480: return HlsVideoResolution.number480;
|
||||
case 720: return HlsVideoResolution.number720;
|
||||
case 1080: return HlsVideoResolution.number1080;
|
||||
case 1440: return HlsVideoResolution.number1440;
|
||||
case 2160: return HlsVideoResolution.number2160;
|
||||
default:
|
||||
if (!allowNull) {
|
||||
throw ArgumentError('Unknown enum value to decode: $data');
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Singleton [HlsVideoResolutionTypeTransformer] instance.
|
||||
static HlsVideoResolutionTypeTransformer? _instance;
|
||||
}
|
||||
|
||||
@@ -14,40 +14,26 @@ class SystemConfigFFmpegRealtimeDto {
|
||||
/// Returns a new [SystemConfigFFmpegRealtimeDto] instance.
|
||||
SystemConfigFFmpegRealtimeDto({
|
||||
required this.enabled,
|
||||
this.resolutions = const [],
|
||||
this.videoCodecs = const [],
|
||||
});
|
||||
|
||||
/// Enable real-time HLS transcoding (alpha)
|
||||
bool enabled;
|
||||
|
||||
/// Resolutions to use for real-time HLS transcoding
|
||||
List<HlsVideoResolution> resolutions;
|
||||
|
||||
/// Video codecs to use for real-time HLS transcoding
|
||||
List<VideoCodec> videoCodecs;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigFFmpegRealtimeDto &&
|
||||
other.enabled == enabled &&
|
||||
_deepEquality.equals(other.resolutions, resolutions) &&
|
||||
_deepEquality.equals(other.videoCodecs, videoCodecs);
|
||||
other.enabled == enabled;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(enabled.hashCode) +
|
||||
(resolutions.hashCode) +
|
||||
(videoCodecs.hashCode);
|
||||
(enabled.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigFFmpegRealtimeDto[enabled=$enabled, resolutions=$resolutions, videoCodecs=$videoCodecs]';
|
||||
String toString() => 'SystemConfigFFmpegRealtimeDto[enabled=$enabled]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'enabled'] = this.enabled;
|
||||
json[r'resolutions'] = this.resolutions;
|
||||
json[r'videoCodecs'] = this.videoCodecs;
|
||||
return json;
|
||||
}
|
||||
|
||||
@@ -61,8 +47,6 @@ class SystemConfigFFmpegRealtimeDto {
|
||||
|
||||
return SystemConfigFFmpegRealtimeDto(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
resolutions: HlsVideoResolution.listFromJson(json[r'resolutions']),
|
||||
videoCodecs: VideoCodec.listFromJson(json[r'videoCodecs']),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
@@ -111,8 +95,6 @@ class SystemConfigFFmpegRealtimeDto {
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'enabled',
|
||||
'resolutions',
|
||||
'videoCodecs',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -912,6 +912,13 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.2"
|
||||
immich_native_core:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "../native/immich_native_core"
|
||||
relative: true
|
||||
source: path
|
||||
version: "0.1.0"
|
||||
immich_ui:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1124,6 +1131,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.19.1"
|
||||
native_toolchain_rust:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_rust
|
||||
sha256: faa57d2258a3b0fd2a634054f54e4496c9fcbd971977e7d2b7e6916d56892857
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4+0"
|
||||
native_video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1755,6 +1770,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.4"
|
||||
toml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: toml
|
||||
sha256: "35a35f782228656a2af31e8c73d1353cc4ef3d683fd68af1111b44631879c05e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.18.0"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -39,6 +39,8 @@ dependencies:
|
||||
hooks_riverpod: ^2.6.1
|
||||
http: ^1.6.0
|
||||
image_picker: ^1.2.1
|
||||
immich_native_core:
|
||||
path: ../native/immich_native_core
|
||||
immich_ui:
|
||||
path: './packages/ui'
|
||||
intl: ^0.20.2
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/target
|
||||
smoke/*.node
|
||||
# generated + committed (regen via `mise run codegen`):
|
||||
# crates/immich_core_dart/include/immich_core.h (cbindgen)
|
||||
# immich_native_core/lib/immich_native_core_bindings_generated.dart (ffigen)
|
||||
Generated
+543
@@ -0,0 +1,543 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "cbindgen"
|
||||
version = "0.29.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"indexmap",
|
||||
"log",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"syn",
|
||||
"tempfile",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
|
||||
dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctor"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "immich_core"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "immich_core_ffi"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cbindgen",
|
||||
"immich_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "immich_core_napi"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"immich_core",
|
||||
"napi",
|
||||
"napi-build",
|
||||
"napi-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "napi"
|
||||
version = "3.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b41bda2ac390efb5e8d22025d925ccc3f3807d8c1bea6d19b36127247c4b8f83"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"ctor",
|
||||
"futures",
|
||||
"napi-build",
|
||||
"napi-sys",
|
||||
"nohash-hasher",
|
||||
"rustc-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-build"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c9c366d2c8c60b86fa632df75f745509b52f9128f91a6bad4c796e44abb505e1"
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive"
|
||||
version = "3.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61d66f70256ad5aef58659966064471d0ad90e2897bc36a5a5e0389c85aabc1e"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"ctor",
|
||||
"napi-derive-backend",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-derive-backend"
|
||||
version = "5.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81b4b08f15eed7a2a20c3f4c6314013fc3ac890a3afa9892b594485299ebdb2d"
|
||||
dependencies = [
|
||||
"convert_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"semver",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "napi-sys"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f5bcdf71abd3a50d00b49c1c2c75251cb3c913777d6139cd37dabc093a5e400"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nohash-hasher"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.118"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.12+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 0.7.15",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
dependencies = [
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "0.7.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1"
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
@@ -0,0 +1,43 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"crates/immich_core",
|
||||
"crates/immich_core_ffi",
|
||||
"crates/immich_core_napi",
|
||||
]
|
||||
|
||||
# shared logic lives in immich_core (no binding deps). each binding crate is a
|
||||
# thin wrapper that picks its own crate-type: immich_core_ffi -> cdylib/staticlib,
|
||||
# the C ABI consumed by dart (ffigen), swift (C interop) and kotlin (JNI shim);
|
||||
# immich_core_napi -> cdylib (.node) for the node server.
|
||||
# capabilities (image, ...) are cargo features on immich_core so both
|
||||
# bindings opt into the same set. crate-type can't be feature-gated, which is why
|
||||
# the bindings are separate crates rather than one crate with feature flags.
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
# single source of truth for all external dep versions. inner crates reference
|
||||
# these with `{ workspace = true }` and never hardcode a version.
|
||||
# default-features = false MUST live here (workspace level) — cargo ignores it if
|
||||
# set only on the inner crate. inner crates then add the minimal features they need.
|
||||
[workspace.dependencies]
|
||||
napi = { version = "3", default-features = false }
|
||||
napi-derive = "3"
|
||||
napi-build = "2"
|
||||
cbindgen = { version = "0.29", default-features = false }
|
||||
|
||||
# CI-enforced (not review-hoped): the boundary crate also #![deny]s unwrap/expect.
|
||||
[workspace.lints.clippy]
|
||||
undocumented_unsafe_blocks = "deny"
|
||||
|
||||
# NB: no `panic = "abort"` — the FFI boundary relies on catch_unwind, which is a
|
||||
# no-op under abort. default unwind is what lets a boundary panic become a null
|
||||
# return instead of taking down the host (Flutter app / node server).
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
@@ -0,0 +1,70 @@
|
||||
# immich_native_core (PoC)
|
||||
|
||||
Shared Rust core for the pixel work the platforms can't do without native code,
|
||||
built from source into the app via Flutter native assets.
|
||||
|
||||
The two capabilities are ports of native code we already ship or have in review,
|
||||
so the same logic exists once, tested, instead of per-platform C:
|
||||
- **EXIF-orientation rotate** — from `mobile/android/app/src/main/cpp/native_image.c`
|
||||
(#29337, merged; fixes #24796, sideways RAW photos). Byte-for-byte the same affine
|
||||
+ tiled copy, plus bounds checks the raw C can't have.
|
||||
- **RGBA_1010102 → RGBA8888 convert** — the 10-bit HEIC/AVIF color fix (#29631,
|
||||
fixes #24906). Same `round(v*255/1023)` LUT + packing as the C, proven on-device
|
||||
against Skia's `Bitmap.copy(ARGB_8888)`.
|
||||
|
||||
Both fill a caller-owned output buffer (no allocation at the boundary) because the
|
||||
production callers hold JNI-locked bitmaps. Nothing in the app calls the core yet;
|
||||
wiring `native_image.c`'s JNI shims to it is the follow-up.
|
||||
|
||||
## Layout
|
||||
```
|
||||
crates/
|
||||
immich_core pure logic, no binding deps. capabilities = cargo features (image).
|
||||
immich_core_ffi the hand-written C ABI + cbindgen header — consumed by dart (ffigen),
|
||||
swift (native C interop) and kotlin (JNI shim)
|
||||
immich_core_napi cdylib (.node) via napi-rs (server, unwired)
|
||||
immich_native_core/ the Flutter package mobile depends on. build hook + ffigen @Native bindings.
|
||||
smoke/ host dart + node roundtrip scripts (no device)
|
||||
```
|
||||
Bindings are separate crates (Cargo can't gate `crate-type` by feature).
|
||||
|
||||
## How the native lib is built (Flutter native assets — no prebuilt, no CI)
|
||||
`immich_native_core/hook/build.dart` (`native_toolchain_rust`) compiles
|
||||
`crates/immich_core_ffi` **from source on every app build** via rustup and bundles
|
||||
it as a Flutter *code asset*. The Dart side uses ffigen `@Native` externals bound to
|
||||
that asset — no `DynamicLibrary`, no prebuilt artifacts, no fetch/publish/separate-repo.
|
||||
|
||||
Native assets is on by default on Flutter stable (3.38+), so a stock `flutter build`
|
||||
runs the hook. Each builder needs **rustup** (the hook auto-installs the pinned
|
||||
toolchain + targets from `crates/immich_core_ffi/rust-toolchain.toml`).
|
||||
|
||||
## Dev commands (mise)
|
||||
```
|
||||
mise run build cargo build --workspace
|
||||
mise run test cargo test --workspace (host Rust tests, incl. FFI-boundary)
|
||||
mise run lint clippy -D warnings (fmt: mise run fmt)
|
||||
mise run codegen regen cbindgen header + ffigen @Native bindings — commit the result
|
||||
mise run test:flutter HOST FFI roundtrip through the real build hook (no device)
|
||||
mise run smoke Rust tests + host dart:ffi + host napi roundtrips
|
||||
```
|
||||
|
||||
## Add a capability (end to end)
|
||||
1. add the logic to `crates/immich_core` (behind a cargo feature if it pulls a dep).
|
||||
2. expose a C entry in `crates/immich_core_ffi/src/lib.rs` — `#[no_mangle] pub extern "C"`,
|
||||
wrap the body in `guard(...)`/`catch_unwind` (panic at the boundary → sentinel, never
|
||||
unwind into the host), validate pointers, and either fill a caller-owned buffer or
|
||||
return Rust-owned memory freed via `immich_core_free_string`.
|
||||
3. `mise run codegen` — regenerates the committed cbindgen header + ffigen `@Native` bindings.
|
||||
4. `mise run test:flutter` (host) + a case in `immich_native_core/test/` and in
|
||||
`mobile/integration_test/native_core_test.dart` (device). The dart surface is the
|
||||
generated bindings; add a hand-written dart wrapper only when a dart feature consumes it.
|
||||
5. platform callers: kotlin via the existing JNI shim pattern (`NativeImage.kt`), swift
|
||||
reads the same header natively.
|
||||
|
||||
## Consume from immich/mobile
|
||||
`immich_native_core: { path: ../native/immich_native_core }` in `mobile/pubspec.yaml`,
|
||||
then `dart pub get`. No app-level Gradle/Podfile edits — the hook builds + bundles the
|
||||
lib. Builders need rustup. See the package README for the iOS App-Extension caveat.
|
||||
|
||||
`/native/` is codeowned by @santoshakil + @mertalev. License: reuses the immich
|
||||
repo-root AGPL-3.0 (no separate license file).
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "immich_core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = ["image"]
|
||||
image = [] # pure pixel math, no deps
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,265 @@
|
||||
//! EXIF-orientation rotation of RGBA8888 pixel buffers, ported from the Android
|
||||
//! native_image.c (immich PR #29337). Lives here so the perf-critical pixel math
|
||||
//! exists once, tested, callable from any platform's decode pipeline (Android RAW
|
||||
//! today; the algorithm is platform-agnostic). The platform side keeps the bitmap
|
||||
//! lock + output allocation and calls this to fill the destination buffer.
|
||||
|
||||
// EXIF orientation values (androidx ExifInterface.ORIENTATION_*).
|
||||
const FLIP_HORIZONTAL: i32 = 2;
|
||||
const ROTATE_180: i32 = 3;
|
||||
const FLIP_VERTICAL: i32 = 4;
|
||||
const TRANSPOSE: i32 = 5;
|
||||
const ROTATE_90: i32 = 6;
|
||||
const TRANSVERSE: i32 = 7;
|
||||
const ROTATE_270: i32 = 8;
|
||||
|
||||
// 32x32 u32 tile = 4KB, L1-resident so a 90/270 transpose's scattered writes stay hot.
|
||||
const TILE: usize = 32;
|
||||
|
||||
/// Whether the orientation swaps width and height (the 90/270 + transpose family).
|
||||
pub fn swaps_dims(orientation: i32) -> bool {
|
||||
matches!(orientation, ROTATE_90 | ROTATE_270 | TRANSPOSE | TRANSVERSE)
|
||||
}
|
||||
|
||||
// (base, step_x, step_y): src pixel (sx,sy) maps to dst pixel index
|
||||
// base + sx*step_x + sy*step_y for a destination of width `dw`. Mirrors
|
||||
// native_image.c affine_for byte-for-byte. i64 so the math stays correct on 32-bit.
|
||||
fn affine_for(o: i32, sw: i64, sh: i64, dw: i64) -> (i64, i64, i64) {
|
||||
match o {
|
||||
ROTATE_90 => (sh - 1, dw, -1),
|
||||
ROTATE_270 => ((sw - 1) * dw, -dw, 1),
|
||||
ROTATE_180 => ((sh - 1) * dw + (sw - 1), -1, -dw),
|
||||
FLIP_HORIZONTAL => (sw - 1, -1, dw),
|
||||
FLIP_VERTICAL => ((sh - 1) * dw, 1, -dw),
|
||||
TRANSPOSE => (0, dw, 1),
|
||||
TRANSVERSE => ((sw - 1) * dw + (sh - 1), -dw, -1),
|
||||
_ => (0, 1, dw),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rotate `src` (RGBA8888, `sh` rows of `src_stride` bytes, `sw` pixels per row) into
|
||||
/// `dst` (densely packed, `dw*dh*4` bytes) for the given EXIF orientation, where
|
||||
/// (dw,dh) swap for the 90/270/transpose family. Returns `false` without touching
|
||||
/// out-of-range memory if the sizes are inconsistent, so the caller can fall back.
|
||||
/// Indexing is bounds-checked: a bad input fails safe (panic caught at the FFI
|
||||
/// boundary / false here), never an out-of-bounds write like the raw C.
|
||||
pub fn rotate_rgba8888(
|
||||
src: &[u8],
|
||||
src_stride: usize,
|
||||
sw: usize,
|
||||
sh: usize,
|
||||
orientation: i32,
|
||||
dst: &mut [u8],
|
||||
) -> bool {
|
||||
if sw == 0 || sh == 0 || src_stride < sw * 4 {
|
||||
return false;
|
||||
}
|
||||
let dw = if swaps_dims(orientation) { sh } else { sw };
|
||||
let dh = if swaps_dims(orientation) { sw } else { sh };
|
||||
if src.len() < src_stride * sh || dst.len() < dw * dh * 4 {
|
||||
return false;
|
||||
}
|
||||
let (base, step_x, step_y) = affine_for(orientation, sw as i64, sh as i64, dw as i64);
|
||||
for ty in (0..sh).step_by(TILE) {
|
||||
let y_end = (ty + TILE).min(sh);
|
||||
for tx in (0..sw).step_by(TILE) {
|
||||
let x_end = (tx + TILE).min(sw);
|
||||
for sy in ty..y_end {
|
||||
let row = sy * src_stride;
|
||||
let mut idx = base + sy as i64 * step_y + tx as i64 * step_x;
|
||||
for sx in tx..x_end {
|
||||
let s = row + sx * 4;
|
||||
let d = idx as usize * 4;
|
||||
dst[d..d + 4].copy_from_slice(&src[s..s + 4]);
|
||||
idx += step_x;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// 10-bit -> 8-bit, matching Skia's Bitmap.copy(ARGB_8888): round(v * 255 / 1023).
|
||||
// Compile-time LUT so it's one lookup per channel, not a mul+div per pixel. The
|
||||
// integer form equals round-half-up for all 1024 inputs and v*255/1023 never lands
|
||||
// on x.5, so it's exact for every value (verified on-device against Skia).
|
||||
const SCALE10: [u8; 1024] = {
|
||||
let mut lut = [0u8; 1024];
|
||||
let mut v = 0usize;
|
||||
while v < 1024 {
|
||||
lut[v] = ((v as u32 * 255 + 511) / 1023) as u8;
|
||||
v += 1;
|
||||
}
|
||||
lut
|
||||
};
|
||||
|
||||
// 2-bit alpha -> 8-bit (a * 85). Photos decode opaque (a == 3 -> 255).
|
||||
const ALPHA2: [u8; 4] = [0, 85, 170, 255];
|
||||
|
||||
/// Convert an Android RGBA_1010102 buffer (what a 10-bit HEIC/AVIF decodes to on
|
||||
/// API 33+) to RGBA8888, byte-for-byte with Skia's `Bitmap.copy(ARGB_8888)`. Each
|
||||
/// src pixel is a little-endian u32 with R in bits 0-9, G in 10-19, B in 20-29,
|
||||
/// A in 30-31 (standard RGB10_A2 packing). `src` is `h` rows of `src_stride` bytes,
|
||||
/// `dst` the caller's densely packed `w*h*4`. Returns false on inconsistent sizes
|
||||
/// so the caller can fall back — same contract as [`rotate_rgba8888`], no alloc.
|
||||
pub fn rgba1010102_to_rgba8888(
|
||||
src: &[u8],
|
||||
src_stride: usize,
|
||||
w: usize,
|
||||
h: usize,
|
||||
dst: &mut [u8],
|
||||
) -> bool {
|
||||
if w == 0 || h == 0 || src_stride < w * 4 {
|
||||
return false;
|
||||
}
|
||||
if src.len() < src_stride * h || dst.len() < w * h * 4 {
|
||||
return false;
|
||||
}
|
||||
for y in 0..h {
|
||||
let s_row = y * src_stride;
|
||||
let d_row = y * w * 4;
|
||||
for x in 0..w {
|
||||
let s = s_row + x * 4;
|
||||
// explicit little-endian — dodges an unaligned *const u32 read on non-x86
|
||||
let px = u32::from_le_bytes([src[s], src[s + 1], src[s + 2], src[s + 3]]);
|
||||
let d = d_row + x * 4;
|
||||
dst[d] = SCALE10[(px & 0x3FF) as usize];
|
||||
dst[d + 1] = SCALE10[((px >> 10) & 0x3FF) as usize];
|
||||
dst[d + 2] = SCALE10[((px >> 20) & 0x3FF) as usize];
|
||||
dst[d + 3] = ALPHA2[((px >> 30) & 0x3) as usize];
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Independent textbook EXIF transform: src(sx,sy) -> dst(dx,dy). Verifies the
|
||||
// affine port against orientation *semantics*, not against itself.
|
||||
fn ref_dst_xy(o: i32, sx: usize, sy: usize, sw: usize, sh: usize) -> (usize, usize) {
|
||||
match o {
|
||||
FLIP_HORIZONTAL => (sw - 1 - sx, sy),
|
||||
ROTATE_180 => (sw - 1 - sx, sh - 1 - sy),
|
||||
FLIP_VERTICAL => (sx, sh - 1 - sy),
|
||||
TRANSPOSE => (sy, sx),
|
||||
ROTATE_90 => (sh - 1 - sy, sx),
|
||||
TRANSVERSE => (sh - 1 - sy, sw - 1 - sx),
|
||||
ROTATE_270 => (sy, sw - 1 - sx),
|
||||
_ => (sx, sy),
|
||||
}
|
||||
}
|
||||
|
||||
fn pixel(i: usize) -> [u8; 4] {
|
||||
[
|
||||
(i & 0xff) as u8,
|
||||
((i >> 8) & 0xff) as u8,
|
||||
((i >> 16) & 0xff) as u8,
|
||||
0xff,
|
||||
]
|
||||
}
|
||||
|
||||
fn check(o: i32, sw: usize, sh: usize) {
|
||||
let mut src = vec![0u8; sw * sh * 4];
|
||||
for sy in 0..sh {
|
||||
for sx in 0..sw {
|
||||
let i = sy * sw + sx;
|
||||
src[i * 4..i * 4 + 4].copy_from_slice(&pixel(i));
|
||||
}
|
||||
}
|
||||
let (dw, dh) = if swaps_dims(o) { (sh, sw) } else { (sw, sh) };
|
||||
let mut dst = vec![0u8; dw * dh * 4];
|
||||
assert!(rotate_rgba8888(&src, sw * 4, sw, sh, o, &mut dst));
|
||||
for sy in 0..sh {
|
||||
for sx in 0..sw {
|
||||
let (dx, dy) = ref_dst_xy(o, sx, sy, sw, sh);
|
||||
let di = dy * dw + dx;
|
||||
let si = sy * sw + sx;
|
||||
assert_eq!(&dst[di * 4..di * 4 + 4], &pixel(si), "o={o} src({sx},{sy})");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_orientations_match_exif_reference() {
|
||||
for o in [1, 2, 3, 4, 5, 6, 7, 8] {
|
||||
check(o, 4, 3);
|
||||
check(o, 1, 5);
|
||||
check(o, 5, 1);
|
||||
check(o, 40, 33); // spans multiple tiles
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_for_normal_orientation() {
|
||||
let src: Vec<u8> = (0..24u8).collect(); // 2x3 RGBA
|
||||
let mut dst = vec![0u8; 24];
|
||||
assert!(rotate_rgba8888(&src, 8, 2, 3, 1, &mut dst));
|
||||
assert_eq!(src, dst);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_src_stride_padding() {
|
||||
let (sw, sh, stride) = (2usize, 2usize, 12usize); // 4 bytes row padding
|
||||
let mut src = vec![0u8; stride * sh];
|
||||
for sy in 0..sh {
|
||||
for sx in 0..sw {
|
||||
let i = sy * sw + sx;
|
||||
src[sy * stride + sx * 4..sy * stride + sx * 4 + 4].copy_from_slice(&pixel(i));
|
||||
}
|
||||
}
|
||||
let mut dst = vec![0u8; sw * sh * 4];
|
||||
assert!(rotate_rgba8888(&src, stride, sw, sh, ROTATE_180, &mut dst));
|
||||
for i in 0..4 {
|
||||
assert_eq!(&dst[i * 4..i * 4 + 4], &pixel(3 - i)); // 180: i -> N-1-i
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_sizes() {
|
||||
let src = vec![0u8; 16];
|
||||
let mut small = vec![0u8; 4];
|
||||
assert!(!rotate_rgba8888(&src, 8, 2, 2, ROTATE_90, &mut small)); // dst too small
|
||||
assert!(!rotate_rgba8888(&src, 4, 2, 2, 1, &mut small)); // stride < sw*4
|
||||
}
|
||||
|
||||
// On-device (Pixel 9a) vs Skia's Bitmap.copy(ARGB_8888). 179/111 rule out `>> 2`.
|
||||
#[test]
|
||||
fn scale10_matches_skia() {
|
||||
for (v, want) in [
|
||||
(0, 0),
|
||||
(111, 28),
|
||||
(179, 45),
|
||||
(230, 57),
|
||||
(304, 76),
|
||||
(1023, 255),
|
||||
] {
|
||||
assert_eq!(SCALE10[v], want, "SCALE10[{v}]");
|
||||
}
|
||||
assert_eq!(ALPHA2, [0, 85, 170, 255]);
|
||||
}
|
||||
|
||||
// px = little-endian u32; R low 10 bits, A top 2.
|
||||
#[test]
|
||||
fn convert_packing() {
|
||||
let red = 0x0000_03FFu32.to_le_bytes(); // R=1023, rest 0
|
||||
let alpha = 0xC000_0000u32.to_le_bytes(); // A=3, rest 0
|
||||
let mut src = Vec::new();
|
||||
src.extend_from_slice(&red);
|
||||
src.extend_from_slice(&alpha);
|
||||
let mut dst = vec![0u8; 8];
|
||||
assert!(rgba1010102_to_rgba8888(&src, 8, 2, 1, &mut dst));
|
||||
assert_eq!(&dst[0..4], &[255, 0, 0, 0]); // opaque-less red
|
||||
assert_eq!(&dst[4..8], &[0, 0, 0, 255]); // opaque black
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn convert_rejects_bad_sizes() {
|
||||
let src = vec![0u8; 16];
|
||||
let mut small = vec![0u8; 4];
|
||||
assert!(!rgba1010102_to_rgba8888(&src, 0, 0, 0, &mut small)); // zero dims
|
||||
assert!(!rgba1010102_to_rgba8888(&src, 4, 2, 2, &mut small)); // stride < w*4
|
||||
assert!(!rgba1010102_to_rgba8888(&src, 8, 2, 2, &mut small)); // dst too small
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
//! immich_native_core — shared Rust core for the immich server (napi) and mobile (dart:ffi).
|
||||
//!
|
||||
//! Pure logic only: no binding or platform deps live here. Each binding crate
|
||||
//! (`immich_core_ffi`, `immich_core_napi`) is a thin wrapper. Capabilities are
|
||||
//! cargo features (`image`, ...) so every binding opts into the same set.
|
||||
|
||||
#[cfg(feature = "image")]
|
||||
pub mod image;
|
||||
|
||||
/// Version of the native core. Smoke-test entrypoint exercised by every binding.
|
||||
pub fn core_version() -> &'static str {
|
||||
env!("CARGO_PKG_VERSION")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_is_present() {
|
||||
assert!(!core_version().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "immich_core_ffi"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
# native_toolchain_rust requires cdylib (the bundled lib) + staticlib (iOS). It
|
||||
# derives the artifact name from [package].name, so no [lib] name override here.
|
||||
[lib]
|
||||
crate-type = ["cdylib", "staticlib"]
|
||||
|
||||
# features pinned explicitly so the cbindgen header + ffigen bindings always
|
||||
# match the exported symbols regardless of default-feature drift.
|
||||
[dependencies]
|
||||
immich_core = { path = "../immich_core", default-features = false, features = ["image"] }
|
||||
|
||||
[build-dependencies]
|
||||
cbindgen = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,19 @@
|
||||
use std::path::Path;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=src/lib.rs");
|
||||
println!("cargo:rerun-if-changed=cbindgen.toml");
|
||||
|
||||
let crate_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
|
||||
let out = Path::new(&crate_dir).join("include").join("immich_core.h");
|
||||
std::fs::create_dir_all(out.parent().unwrap()).ok();
|
||||
|
||||
// Hard-fail, not a warning: the CI drift gate diffs this header, so a silent
|
||||
// codegen failure would let a stale header sail through green.
|
||||
match cbindgen::generate(&crate_dir) {
|
||||
Ok(bindings) => {
|
||||
bindings.write_to_file(&out);
|
||||
}
|
||||
Err(e) => panic!("cbindgen failed: {e}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
language = "C"
|
||||
pragma_once = true
|
||||
autogen_warning = "// Generated by cbindgen — do not edit."
|
||||
@@ -0,0 +1,59 @@
|
||||
#pragma once
|
||||
|
||||
// Generated by cbindgen — do not edit.
|
||||
|
||||
#include <stdarg.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
/**
|
||||
* Native core version as a NUL-terminated UTF-8 string.
|
||||
* Free the result with [`immich_core_free_string`].
|
||||
*/
|
||||
char *immich_core_version(void);
|
||||
|
||||
/**
|
||||
* Rotate an RGBA8888 image to the given EXIF `orientation`. `src` is `sh` rows of
|
||||
* `src_stride` bytes; `dst` is the caller's densely-packed `dw*dh*4` output (dims
|
||||
* swap for 90/270/transpose). Returns false (a safe no-op) on null pointers or
|
||||
* inconsistent sizes so the caller can fall back. The platform side owns the
|
||||
* bitmap lock + the dst allocation; this only fills dst.
|
||||
*
|
||||
* # Safety
|
||||
* `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`.
|
||||
*/
|
||||
bool immich_core_rotate_rgba8888(const uint8_t *src,
|
||||
uintptr_t src_len,
|
||||
uintptr_t src_stride,
|
||||
uint32_t width,
|
||||
uint32_t height,
|
||||
int32_t orientation,
|
||||
uint8_t *dst,
|
||||
uintptr_t dst_len);
|
||||
|
||||
/**
|
||||
* Convert an RGBA_1010102 image (`src`, `sh` rows of `src_stride` bytes) to
|
||||
* RGBA8888 in the caller's densely-packed `w*h*4` `dst`, matching Skia's
|
||||
* `Bitmap.copy(ARGB_8888)`. Returns false (a safe no-op) on null pointers or
|
||||
* inconsistent sizes so the caller can fall back. The platform side owns the
|
||||
* bitmap lock + the dst allocation; this only fills dst.
|
||||
*
|
||||
* # Safety
|
||||
* `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`.
|
||||
*/
|
||||
bool immich_core_rgba1010102_to_rgba8888(const uint8_t *src,
|
||||
uintptr_t src_len,
|
||||
uintptr_t src_stride,
|
||||
uint32_t width,
|
||||
uint32_t height,
|
||||
uint8_t *dst,
|
||||
uintptr_t dst_len);
|
||||
|
||||
/**
|
||||
* Release a string returned by this library.
|
||||
*
|
||||
* # Safety
|
||||
* `ptr` must be a pointer previously returned by this library, or null.
|
||||
*/
|
||||
void immich_core_free_string(char *ptr);
|
||||
@@ -0,0 +1,28 @@
|
||||
# The build hook (native_toolchain_rust) drives cargo via rustup and auto-installs
|
||||
# this toolchain + targets. Pin a version (never bare stable/beta) for reproducible
|
||||
# builds. Keep the channel in sync with mise.toml's rust pin.
|
||||
#
|
||||
# Full default target set from native_toolchain_rust: the hook validates the HOST
|
||||
# triple too (any dart/flutter command builds a host code asset), so linux + windows
|
||||
# must stay in for CI runners and contributor machines, not just the app targets.
|
||||
[toolchain]
|
||||
channel = "1.92.0"
|
||||
targets = [
|
||||
# Android
|
||||
"armv7-linux-androideabi",
|
||||
"aarch64-linux-android",
|
||||
"x86_64-linux-android",
|
||||
# iOS (device + simulator)
|
||||
"aarch64-apple-ios",
|
||||
"aarch64-apple-ios-sim",
|
||||
"x86_64-apple-ios",
|
||||
# Windows
|
||||
"aarch64-pc-windows-msvc",
|
||||
"x86_64-pc-windows-msvc",
|
||||
# Linux
|
||||
"aarch64-unknown-linux-gnu",
|
||||
"x86_64-unknown-linux-gnu",
|
||||
# macOS
|
||||
"aarch64-apple-darwin",
|
||||
"x86_64-apple-darwin",
|
||||
]
|
||||
@@ -0,0 +1,153 @@
|
||||
//! dart:ffi binding for immich_core (mobile).
|
||||
//!
|
||||
//! Returns heap-allocated C strings the caller must release with
|
||||
//! `immich_core_free_string`. cbindgen emits `include/immich_core.h` at build time.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use std::ffi::{c_char, CString};
|
||||
use std::ptr;
|
||||
|
||||
/// Native core version as a NUL-terminated UTF-8 string.
|
||||
/// Free the result with [`immich_core_free_string`].
|
||||
#[no_mangle]
|
||||
pub extern "C" fn immich_core_version() -> *mut c_char {
|
||||
guard(ptr::null_mut(), || {
|
||||
into_c_string(immich_core::core_version().to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// Rotate an RGBA8888 image to the given EXIF `orientation`. `src` is `sh` rows of
|
||||
/// `src_stride` bytes; `dst` is the caller's densely-packed `dw*dh*4` output (dims
|
||||
/// swap for 90/270/transpose). Returns false (a safe no-op) on null pointers or
|
||||
/// inconsistent sizes so the caller can fall back. The platform side owns the
|
||||
/// bitmap lock + the dst allocation; this only fills dst.
|
||||
///
|
||||
/// # Safety
|
||||
/// `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn immich_core_rotate_rgba8888(
|
||||
src: *const u8,
|
||||
src_len: usize,
|
||||
src_stride: usize,
|
||||
width: u32,
|
||||
height: u32,
|
||||
orientation: i32,
|
||||
dst: *mut u8,
|
||||
dst_len: usize,
|
||||
) -> bool {
|
||||
if src.is_null() || dst.is_null() {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: caller guarantees `src` is valid for reads of `src_len` bytes (see # Safety).
|
||||
let src_slice = unsafe { std::slice::from_raw_parts(src, src_len) };
|
||||
// SAFETY: caller guarantees `dst` is valid for writes of `dst_len` bytes (see # Safety).
|
||||
let dst_slice = unsafe { std::slice::from_raw_parts_mut(dst, dst_len) };
|
||||
// AssertUnwindSafe: the closure writes through `&mut dst_slice`, which isn't
|
||||
// UnwindSafe, but a panic mid-rotate only leaves dst partially written — not a
|
||||
// broken invariant — and we return false so the caller discards the buffer.
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
immich_core::image::rotate_rgba8888(
|
||||
src_slice,
|
||||
src_stride,
|
||||
width as usize,
|
||||
height as usize,
|
||||
orientation,
|
||||
dst_slice,
|
||||
)
|
||||
}))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Convert an RGBA_1010102 image (`src`, `sh` rows of `src_stride` bytes) to
|
||||
/// RGBA8888 in the caller's densely-packed `w*h*4` `dst`, matching Skia's
|
||||
/// `Bitmap.copy(ARGB_8888)`. Returns false (a safe no-op) on null pointers or
|
||||
/// inconsistent sizes so the caller can fall back. The platform side owns the
|
||||
/// bitmap lock + the dst allocation; this only fills dst.
|
||||
///
|
||||
/// # Safety
|
||||
/// `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn immich_core_rgba1010102_to_rgba8888(
|
||||
src: *const u8,
|
||||
src_len: usize,
|
||||
src_stride: usize,
|
||||
width: u32,
|
||||
height: u32,
|
||||
dst: *mut u8,
|
||||
dst_len: usize,
|
||||
) -> bool {
|
||||
if src.is_null() || dst.is_null() {
|
||||
return false;
|
||||
}
|
||||
// SAFETY: caller guarantees `src` is valid for reads of `src_len` bytes (see # Safety).
|
||||
let src_slice = unsafe { std::slice::from_raw_parts(src, src_len) };
|
||||
// SAFETY: caller guarantees `dst` is valid for writes of `dst_len` bytes (see # Safety).
|
||||
let dst_slice = unsafe { std::slice::from_raw_parts_mut(dst, dst_len) };
|
||||
// AssertUnwindSafe: a panic mid-convert only leaves dst partially written — not a
|
||||
// broken invariant — and we return false so the caller discards the buffer.
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
immich_core::image::rgba1010102_to_rgba8888(
|
||||
src_slice,
|
||||
src_stride,
|
||||
width as usize,
|
||||
height as usize,
|
||||
dst_slice,
|
||||
)
|
||||
}))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Release a string returned by this library.
|
||||
///
|
||||
/// # Safety
|
||||
/// `ptr` must be a pointer previously returned by this library, or null.
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn immich_core_free_string(ptr: *mut c_char) {
|
||||
if ptr.is_null() {
|
||||
return;
|
||||
}
|
||||
guard((), || {
|
||||
// SAFETY: `ptr` came from this library's `CString::into_raw` (see # Safety).
|
||||
let s = unsafe { CString::from_raw(ptr) };
|
||||
drop(s);
|
||||
});
|
||||
}
|
||||
|
||||
/// Run `f` at the FFI boundary, turning a panic into `sentinel` rather than
|
||||
/// unwinding across `extern "C"` into the host. Guards panics only — a bad `len`
|
||||
/// or a double/foreign free is caller-contract UB that stays the caller's
|
||||
/// `# Safety` obligation, not something this can catch.
|
||||
fn guard<T>(sentinel: T, f: impl FnOnce() -> T + std::panic::UnwindSafe) -> T {
|
||||
std::panic::catch_unwind(f).unwrap_or(sentinel)
|
||||
}
|
||||
|
||||
fn into_c_string(s: String) -> *mut c_char {
|
||||
match CString::new(s) {
|
||||
Ok(c) => c.into_raw(),
|
||||
Err(_) => ptr::null_mut(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
use std::ffi::CStr;
|
||||
|
||||
#[test]
|
||||
fn version_roundtrips_and_frees() {
|
||||
let p = immich_core_version();
|
||||
assert!(!p.is_null());
|
||||
// SAFETY: `p` is a non-null NUL-terminated string from this library.
|
||||
let s = unsafe { CStr::from_ptr(p) }.to_str().unwrap();
|
||||
assert!(!s.is_empty());
|
||||
// SAFETY: `p` was returned by this library and is freed exactly once.
|
||||
unsafe { immich_core_free_string(p) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn free_null_is_noop() {
|
||||
// SAFETY: free_string explicitly accepts null.
|
||||
unsafe { immich_core_free_string(ptr::null_mut()) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "immich_core_napi"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
immich_core = { path = "../immich_core", default-features = false }
|
||||
napi = { workspace = true, features = ["napi4", "dyn-symbols"] }
|
||||
napi-derive = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = { workspace = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! napi-rs binding for immich_core (node server).
|
||||
//!
|
||||
//! Built as a cdylib loaded as a `.node` addon — the same shape as the server's
|
||||
//! existing native deps (sharp, bcrypt).
|
||||
|
||||
use napi_derive::napi;
|
||||
|
||||
/// Native core version. JS: `core.coreVersion()`.
|
||||
#[napi]
|
||||
pub fn core_version() -> String {
|
||||
immich_core::core_version().to_owned()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock.
|
||||
/pubspec.lock
|
||||
**/doc/api/
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
/build/
|
||||
/coverage/
|
||||
@@ -0,0 +1,43 @@
|
||||
# immich_native_core (Flutter package)
|
||||
|
||||
dart:ffi bindings to the `immich_native_core` Rust core. The native code is **built
|
||||
from source on every app build** via a Dart build hook (Flutter native assets) — no
|
||||
prebuilt binaries, no `DynamicLibrary`, no platform plugin glue.
|
||||
|
||||
## Use it from immich/mobile
|
||||
|
||||
```yaml
|
||||
# mobile/pubspec.yaml
|
||||
dependencies:
|
||||
immich_native_core:
|
||||
path: ../native/immich_native_core
|
||||
```
|
||||
|
||||
`dart pub get`. The dart surface is exactly the ffigen output of the C header —
|
||||
the current capabilities (EXIF rotate, 10-bit convert) are called by the platform
|
||||
decode pipelines, not by dart, so there are no hand-written dart wrappers. Add one
|
||||
only when a dart feature actually consumes a function.
|
||||
|
||||
No app-level Gradle/Podfile edits. `hook/build.dart` compiles the Rust crate and
|
||||
Flutter bundles it as a code asset; the `@Native` bindings resolve against it.
|
||||
**Requirement:** every machine that builds the app needs [rustup](https://rustup.rs)
|
||||
— the hook auto-installs the pinned toolchain + targets from the crate's
|
||||
`rust-toolchain.toml`.
|
||||
|
||||
## Layout
|
||||
|
||||
- `hook/build.dart` — builds `../crates/immich_core_ffi` via `native_toolchain_rust`.
|
||||
- `lib/immich_native_core.dart` — barrel; re-exports the generated bindings.
|
||||
- `lib/src/ffi/bindings.g.dart` — ffigen `@Native` output (committed; do not edit).
|
||||
- `ffigen.yaml` — ffi-native mode; asset-id must match the hook's `assetName`.
|
||||
- `test/` — host FFI roundtrip (`flutter test`); device runs via `mobile/integration_test`.
|
||||
|
||||
## ⚠ iOS App Extensions
|
||||
|
||||
Code assets are bundled into the app's **Runner** target. immich ships a Share
|
||||
Extension and a Widget Extension — if the core is ever called from one of those,
|
||||
verify the symbols resolve there (same family as the embed-into-Runner-only gotcha).
|
||||
Not an issue while only the main app calls it.
|
||||
|
||||
The Rust workspace, the codegen/build/test commands, and the "add a function" loop
|
||||
live in [`../README.md`](../README.md).
|
||||
@@ -0,0 +1,4 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
@@ -0,0 +1,20 @@
|
||||
# Regenerate: `mise run codegen` (cbindgen header -> ffigen @Native bindings).
|
||||
# ffi-native mode emits top-level @Native externals + a library @DefaultAsset
|
||||
# pointing at the code asset hook/build.dart produces — no DynamicLibrary loader.
|
||||
# asset-id MUST equal the generated file's package URI (and the hook's assetName).
|
||||
name: ImmichNativeCoreBindings
|
||||
ffi-native:
|
||||
asset-id: 'package:immich_native_core/src/ffi/bindings.g.dart'
|
||||
description: 'FFI bindings to immich_native_core — generated, do not edit.'
|
||||
output: 'lib/src/ffi/bindings.g.dart'
|
||||
headers:
|
||||
entry-points:
|
||||
- '../crates/immich_core_ffi/include/immich_core.h'
|
||||
include-directives:
|
||||
- '**/immich_core.h'
|
||||
functions:
|
||||
include:
|
||||
- 'immich_core_.*'
|
||||
comments:
|
||||
style: any
|
||||
length: full
|
||||
@@ -0,0 +1,61 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:code_assets/code_assets.dart';
|
||||
import 'package:hooks/hooks.dart';
|
||||
import 'package:native_toolchain_rust/native_toolchain_rust.dart';
|
||||
|
||||
// Builds crates/immich_core_ffi from source on every app build and bundles it as
|
||||
// a code asset. assetName must match the ffigen output (its package URI is the
|
||||
// @Native DefaultAsset id). The crate is a sibling, so point cratePath at it.
|
||||
void main(List<String> args) async {
|
||||
await build(args, (input, output) async {
|
||||
await _ensureRustTarget(input);
|
||||
await RustBuilder(
|
||||
assetName: 'src/ffi/bindings.g.dart',
|
||||
cratePath: '../crates/immich_core_ffi',
|
||||
).run(input: input, output: output);
|
||||
});
|
||||
}
|
||||
|
||||
// rustup only auto-installs the rust-toolchain.toml targets when that file drives
|
||||
// toolchain selection; with RUSTUP_TOOLCHAIN exported (mise does on CI) the file is
|
||||
// bypassed and cross targets never install. Add the build target explicitly — a
|
||||
// fast no-op when it's already present.
|
||||
Future<void> _ensureRustTarget(BuildInput input) async {
|
||||
final code = input.config.code;
|
||||
final triple = switch (code.targetOS) {
|
||||
OS.android => switch (code.targetArchitecture) {
|
||||
Architecture.arm => 'armv7-linux-androideabi',
|
||||
Architecture.arm64 => 'aarch64-linux-android',
|
||||
Architecture.x64 => 'x86_64-linux-android',
|
||||
_ => null,
|
||||
},
|
||||
OS.iOS => switch ((code.targetArchitecture, code.iOS.targetSdk)) {
|
||||
(Architecture.arm64, IOSSdk.iPhoneSimulator) => 'aarch64-apple-ios-sim',
|
||||
(Architecture.arm64, _) => 'aarch64-apple-ios',
|
||||
(Architecture.x64, _) => 'x86_64-apple-ios',
|
||||
_ => null,
|
||||
},
|
||||
OS.macOS => switch (code.targetArchitecture) {
|
||||
Architecture.arm64 => 'aarch64-apple-darwin',
|
||||
Architecture.x64 => 'x86_64-apple-darwin',
|
||||
_ => null,
|
||||
},
|
||||
OS.linux => switch (code.targetArchitecture) {
|
||||
Architecture.arm64 => 'aarch64-unknown-linux-gnu',
|
||||
Architecture.x64 => 'x86_64-unknown-linux-gnu',
|
||||
_ => null,
|
||||
},
|
||||
_ => null,
|
||||
};
|
||||
if (triple == null) return;
|
||||
final crate = input.packageRoot
|
||||
.resolve('../crates/immich_core_ffi/')
|
||||
.toFilePath();
|
||||
// best effort — if rustup itself is broken the build below reports it properly
|
||||
await Process.run('rustup', [
|
||||
'target',
|
||||
'add',
|
||||
triple,
|
||||
], workingDirectory: crate);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/// dart:ffi bindings to the immich_native_core Rust core, built from source and
|
||||
/// bundled via the Dart build hook. The dart surface is exactly the ffigen output
|
||||
/// of the C header — the production callers of the current capabilities are the
|
||||
/// platform decode pipelines (Kotlin/JNI, Swift), so there are no hand-written
|
||||
/// dart wrappers; add one only when a dart feature actually consumes a function.
|
||||
library;
|
||||
|
||||
export 'src/ffi/bindings.g.dart';
|
||||
@@ -0,0 +1,80 @@
|
||||
// AUTO GENERATED FILE, DO NOT EDIT.
|
||||
//
|
||||
// Generated by `package:ffigen`.
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
@ffi.DefaultAsset('package:immich_native_core/src/ffi/bindings.g.dart')
|
||||
library;
|
||||
|
||||
import 'dart:ffi' as ffi;
|
||||
|
||||
/// Native core version as a NUL-terminated UTF-8 string.
|
||||
/// Free the result with [`immich_core_free_string`].
|
||||
@ffi.Native<ffi.Pointer<ffi.Char> Function()>()
|
||||
external ffi.Pointer<ffi.Char> immich_core_version();
|
||||
|
||||
/// Rotate an RGBA8888 image to the given EXIF `orientation`. `src` is `sh` rows of
|
||||
/// `src_stride` bytes; `dst` is the caller's densely-packed `dw*dh*4` output (dims
|
||||
/// swap for 90/270/transpose). Returns false (a safe no-op) on null pointers or
|
||||
/// inconsistent sizes so the caller can fall back. The platform side owns the
|
||||
/// bitmap lock + the dst allocation; this only fills dst.
|
||||
///
|
||||
/// # Safety
|
||||
/// `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`.
|
||||
@ffi.Native<
|
||||
ffi.Bool Function(
|
||||
ffi.Pointer<ffi.Uint8>,
|
||||
ffi.UintPtr,
|
||||
ffi.UintPtr,
|
||||
ffi.Uint32,
|
||||
ffi.Uint32,
|
||||
ffi.Int32,
|
||||
ffi.Pointer<ffi.Uint8>,
|
||||
ffi.UintPtr,
|
||||
)
|
||||
>()
|
||||
external bool immich_core_rotate_rgba8888(
|
||||
ffi.Pointer<ffi.Uint8> src,
|
||||
int src_len,
|
||||
int src_stride,
|
||||
int width,
|
||||
int height,
|
||||
int orientation,
|
||||
ffi.Pointer<ffi.Uint8> dst,
|
||||
int dst_len,
|
||||
);
|
||||
|
||||
/// Convert an RGBA_1010102 image (`src`, `sh` rows of `src_stride` bytes) to
|
||||
/// RGBA8888 in the caller's densely-packed `w*h*4` `dst`, matching Skia's
|
||||
/// `Bitmap.copy(ARGB_8888)`. Returns false (a safe no-op) on null pointers or
|
||||
/// inconsistent sizes so the caller can fall back. The platform side owns the
|
||||
/// bitmap lock + the dst allocation; this only fills dst.
|
||||
///
|
||||
/// # Safety
|
||||
/// `src` must be valid for reads of `src_len` bytes and `dst` for writes of `dst_len`.
|
||||
@ffi.Native<
|
||||
ffi.Bool Function(
|
||||
ffi.Pointer<ffi.Uint8>,
|
||||
ffi.UintPtr,
|
||||
ffi.UintPtr,
|
||||
ffi.Uint32,
|
||||
ffi.Uint32,
|
||||
ffi.Pointer<ffi.Uint8>,
|
||||
ffi.UintPtr,
|
||||
)
|
||||
>()
|
||||
external bool immich_core_rgba1010102_to_rgba8888(
|
||||
ffi.Pointer<ffi.Uint8> src,
|
||||
int src_len,
|
||||
int src_stride,
|
||||
int width,
|
||||
int height,
|
||||
ffi.Pointer<ffi.Uint8> dst,
|
||||
int dst_len,
|
||||
);
|
||||
|
||||
/// Release a string returned by this library.
|
||||
///
|
||||
/// # Safety
|
||||
/// `ptr` must be a pointer previously returned by this library, or null.
|
||||
@ffi.Native<ffi.Void Function(ffi.Pointer<ffi.Char>)>()
|
||||
external void immich_core_free_string(ffi.Pointer<ffi.Char> ptr);
|
||||
@@ -0,0 +1,26 @@
|
||||
name: immich_native_core
|
||||
description: "dart:ffi bindings to the immich_native_core Rust core, built from source via Dart build hooks."
|
||||
version: 0.1.0
|
||||
homepage: https://github.com/immich-app/immich
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: '>=3.11.0 <4.0.0'
|
||||
flutter: '>=3.3.0'
|
||||
|
||||
# Not a platform plugin: the native lib is built + bundled by hook/build.dart as a
|
||||
# code asset (Flutter native assets), so there is no ffiPlugin / android / ios dir.
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
# build-hook deps — run at build time to compile the Rust crate (need rustup).
|
||||
code_assets: ^1.2.1
|
||||
hooks: ^2.0.2
|
||||
native_toolchain_rust: ^1.0.4
|
||||
|
||||
dev_dependencies:
|
||||
ffi: ^2.2.0 # tests only — the lib surface is the generated bindings (dart:ffi)
|
||||
ffigen: 20.1.1 # pinned exact — a caret bump can re-emit bindings
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
@@ -0,0 +1,106 @@
|
||||
// Host FFI roundtrip — `flutter test` builds the hook for the host platform and
|
||||
// resolves the @Native symbols, no device needed. Calls the generated bindings
|
||||
// directly (the package's actual surface); device runs: mobile/integration_test.
|
||||
import 'dart:ffi';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_native_core/immich_native_core.dart';
|
||||
|
||||
Uint8List _px1010102(int r, int g, int b, int a) {
|
||||
final px =
|
||||
(r & 0x3FF) |
|
||||
((g & 0x3FF) << 10) |
|
||||
((b & 0x3FF) << 20) |
|
||||
((a & 0x3) << 30);
|
||||
return Uint8List(4)..buffer.asByteData().setUint32(0, px, Endian.little);
|
||||
}
|
||||
|
||||
typedef _ImageFn =
|
||||
bool Function(Pointer<Uint8> src, int dstLen, Pointer<Uint8> dst);
|
||||
|
||||
// malloc src+dst, run the native call, return the dst bytes (or null if it declined).
|
||||
Uint8List? _withBuffers(Uint8List src, int dstLen, _ImageFn call) {
|
||||
final srcPtr = malloc<Uint8>(src.length);
|
||||
final dstPtr = malloc<Uint8>(dstLen);
|
||||
try {
|
||||
srcPtr.asTypedList(src.length).setAll(0, src);
|
||||
if (!call(srcPtr, dstLen, dstPtr)) {
|
||||
return null;
|
||||
}
|
||||
return Uint8List.fromList(dstPtr.asTypedList(dstLen));
|
||||
} finally {
|
||||
malloc.free(srcPtr);
|
||||
malloc.free(dstPtr);
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('core loads: version roundtrips through the C string contract', () {
|
||||
final ptr = immich_core_version();
|
||||
expect(ptr, isNot(equals(nullptr)));
|
||||
final version = ptr.cast<Utf8>().toDartString();
|
||||
immich_core_free_string(ptr);
|
||||
expect(version, isNotEmpty);
|
||||
});
|
||||
|
||||
test('exif rotate: 180 reverses pixels, 90 swaps dims', () {
|
||||
// 2x1 image: red, green (RGBA).
|
||||
final src = Uint8List.fromList([255, 0, 0, 255, 0, 255, 0, 255]);
|
||||
final r180 = _withBuffers(
|
||||
src,
|
||||
8,
|
||||
(s, len, d) =>
|
||||
immich_core_rotate_rgba8888(s, src.length, 8, 2, 1, 3, d, len),
|
||||
);
|
||||
expect(r180, [0, 255, 0, 255, 255, 0, 0, 255]); // green, red
|
||||
|
||||
final r90 = _withBuffers(
|
||||
src,
|
||||
8,
|
||||
(s, len, d) =>
|
||||
immich_core_rotate_rgba8888(s, src.length, 8, 2, 1, 6, d, len),
|
||||
);
|
||||
expect(r90, isNotNull); // 90 -> 1x2, same byte count
|
||||
});
|
||||
|
||||
test('rotate declines bad sizes instead of writing', () {
|
||||
final src = Uint8List(16);
|
||||
final tooSmall = _withBuffers(
|
||||
src,
|
||||
4,
|
||||
(s, len, d) =>
|
||||
immich_core_rotate_rgba8888(s, src.length, 8, 2, 2, 6, d, len),
|
||||
);
|
||||
expect(tooSmall, isNull);
|
||||
});
|
||||
|
||||
test('10-bit convert matches the on-device Skia ground truth', () {
|
||||
// 179->45 and 111->28 pin round(v*255/1023); a plain >>2 would give 44/27.
|
||||
final src = Uint8List.fromList([
|
||||
..._px1010102(1023, 0, 0, 3),
|
||||
..._px1010102(179, 111, 0, 3),
|
||||
]);
|
||||
final out = _withBuffers(
|
||||
src,
|
||||
8,
|
||||
(s, len, d) =>
|
||||
immich_core_rgba1010102_to_rgba8888(s, src.length, 8, 2, 1, d, len),
|
||||
);
|
||||
expect(out, isNotNull);
|
||||
expect(out!.sublist(0, 4), [255, 0, 0, 255]);
|
||||
expect(out.sublist(4, 8), [45, 28, 0, 255]);
|
||||
});
|
||||
|
||||
test('convert declines bad sizes instead of writing', () {
|
||||
final src = Uint8List(16);
|
||||
final badStride = _withBuffers(
|
||||
src,
|
||||
16,
|
||||
(s, len, d) =>
|
||||
immich_core_rgba1010102_to_rgba8888(s, src.length, 4, 2, 2, d, len),
|
||||
);
|
||||
expect(badStride, isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
[tools]
|
||||
rust = "1.92.0" # keep in sync with rust-toolchain.toml (the build hook uses rustup)
|
||||
|
||||
[tasks.build]
|
||||
description = "Build all native core crates (host)"
|
||||
run = "cargo build --workspace"
|
||||
|
||||
[tasks.test]
|
||||
description = "Run native core Rust tests"
|
||||
run = "cargo test --workspace"
|
||||
|
||||
[tasks.fmt]
|
||||
description = "Format all crates"
|
||||
run = "cargo fmt --all"
|
||||
|
||||
[tasks.lint]
|
||||
description = "Clippy (warnings = errors)"
|
||||
run = "cargo clippy --workspace --all-targets -- -D warnings"
|
||||
|
||||
# Regen the committed cbindgen header + ffigen @Native bindings.
|
||||
[tasks."codegen:ffigen"]
|
||||
alias = "codegen"
|
||||
description = "Generate the C header (cbindgen) + Dart @Native bindings (ffigen)"
|
||||
sources = [
|
||||
"crates/immich_core_ffi/src/lib.rs",
|
||||
"crates/immich_core_ffi/cbindgen.toml",
|
||||
"immich_native_core/ffigen.yaml",
|
||||
]
|
||||
outputs = [
|
||||
"crates/immich_core_ffi/include/immich_core.h",
|
||||
"immich_native_core/lib/src/ffi/bindings.g.dart",
|
||||
]
|
||||
run = [
|
||||
"cargo build -p immich_core_ffi",
|
||||
"cd immich_native_core && dart run ffigen --config ffigen.yaml && dart format lib/src/ffi/bindings.g.dart",
|
||||
]
|
||||
|
||||
# Host FFI roundtrip through the real build hook — no device. Builds the Rust crate
|
||||
# via rustup + resolves the @Native code asset.
|
||||
[tasks."test:flutter"]
|
||||
description = "Host FFI roundtrip via the build hook (flutter test)"
|
||||
dir = "immich_native_core"
|
||||
run = "flutter test"
|
||||
|
||||
[tasks."build:dart"]
|
||||
description = "Build the dart:ffi cdylib directly (host, raw cargo)"
|
||||
run = "cargo build -p immich_core_ffi"
|
||||
|
||||
[tasks."build:napi"]
|
||||
description = "Build the node addon + stage a .node for require() (server, unwired)"
|
||||
run = [
|
||||
"cargo build -p immich_core_napi --release",
|
||||
"cp target/release/libimmich_core_napi.dylib smoke/immich_core_napi.node 2>/dev/null || cp target/release/libimmich_core_napi.so smoke/immich_core_napi.node",
|
||||
]
|
||||
|
||||
[tasks."smoke:dart"]
|
||||
description = "Host dart:ffi ABI roundtrip (raw DynamicLibrary on the cdylib)"
|
||||
depends = ["build:dart"]
|
||||
run = "dart run smoke/dart_smoke.dart target/debug/libimmich_core_ffi.dylib"
|
||||
|
||||
[tasks."smoke:node"]
|
||||
description = "Host napi roundtrip"
|
||||
depends = ["build:napi"]
|
||||
run = "node smoke/node_smoke.mjs"
|
||||
|
||||
[tasks.smoke]
|
||||
description = "Rust tests + host dart:ffi + host napi roundtrips"
|
||||
depends = ["test", "smoke:dart", "smoke:node"]
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cross-build the napi addon for Linux server (x86_64 + aarch64) via zigbuild
|
||||
# (no Docker) and stage as .node under dist/server/<target>/.
|
||||
# In CI you'd build these natively per-arch instead; this is local convenience.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
CRATE=immich_core_napi
|
||||
|
||||
for t in x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu; do
|
||||
rustup target add "$t" >/dev/null 2>&1 || true
|
||||
cargo zigbuild -p "$CRATE" --target "$t" --release
|
||||
mkdir -p "dist/server/$t"
|
||||
cp "target/$t/release/lib${CRATE}.so" "dist/server/$t/immich_core_napi.node"
|
||||
done
|
||||
|
||||
echo "linux -> dist/server/*/immich_core_napi.node"
|
||||
@@ -0,0 +1,31 @@
|
||||
// Mobile-side roundtrip: open the dart:ffi cdylib and call into the shared core.
|
||||
// Standalone script (no package:ffi dep) — reads the returned C string by hand.
|
||||
//
|
||||
// dart run smoke/dart_smoke.dart target/debug/libimmich_core_ffi.dylib
|
||||
|
||||
import 'dart:ffi';
|
||||
|
||||
typedef _VersionNative = Pointer<Uint8> Function();
|
||||
typedef _FreeNative = Void Function(Pointer<Uint8>);
|
||||
typedef _FreeDart = void Function(Pointer<Uint8>);
|
||||
|
||||
String _readCString(Pointer<Uint8> p) {
|
||||
final bytes = <int>[];
|
||||
for (var i = 0; p[i] != 0; i++) {
|
||||
bytes.add(p[i]);
|
||||
}
|
||||
return String.fromCharCodes(bytes);
|
||||
}
|
||||
|
||||
void main(List<String> args) {
|
||||
final libPath = args.isNotEmpty ? args.first : 'target/debug/libimmich_core_ffi.dylib';
|
||||
final lib = DynamicLibrary.open(libPath);
|
||||
|
||||
final version = lib.lookupFunction<_VersionNative, _VersionNative>('immich_core_version');
|
||||
final free = lib.lookupFunction<_FreeNative, _FreeDart>('immich_core_free_string');
|
||||
|
||||
final ptr = version();
|
||||
print('DART core_version = ${_readCString(ptr)}');
|
||||
free(ptr);
|
||||
print('DART roundtrip OK');
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// Server-side roundtrip: load the napi addon and call into the shared core.
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const core = require('./immich_core_napi.node');
|
||||
|
||||
const version = core.coreVersion();
|
||||
console.log(`NAPI core_version = ${version}`);
|
||||
if (!version) {
|
||||
console.error('NAPI empty version');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('NAPI roundtrip OK');
|
||||
@@ -19120,17 +19120,6 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"HlsVideoResolution": {
|
||||
"description": "HLS video resolution",
|
||||
"enum": [
|
||||
480,
|
||||
720,
|
||||
1080,
|
||||
1440,
|
||||
2160
|
||||
],
|
||||
"type": "integer"
|
||||
},
|
||||
"ImageFormat": {
|
||||
"description": "Image format",
|
||||
"enum": [
|
||||
@@ -25765,26 +25754,10 @@
|
||||
"enabled": {
|
||||
"description": "Enable real-time HLS transcoding (alpha)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"resolutions": {
|
||||
"description": "Resolutions to use for real-time HLS transcoding",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/HlsVideoResolution"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"videoCodecs": {
|
||||
"description": "Video codecs to use for real-time HLS transcoding",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/VideoCodec"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled",
|
||||
"resolutions",
|
||||
"videoCodecs"
|
||||
"enabled"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
|
||||
@@ -2302,10 +2302,6 @@ export type SystemConfigBackupsDto = {
|
||||
export type SystemConfigFFmpegRealtimeDto = {
|
||||
/** Enable real-time HLS transcoding (alpha) */
|
||||
enabled: boolean;
|
||||
/** Resolutions to use for real-time HLS transcoding */
|
||||
resolutions: HlsVideoResolution[];
|
||||
/** Video codecs to use for real-time HLS transcoding */
|
||||
videoCodecs: VideoCodec[];
|
||||
};
|
||||
export type SystemConfigFFmpegDto = {
|
||||
accel: TranscodeHWAccel;
|
||||
@@ -7633,13 +7629,6 @@ export enum CQMode {
|
||||
Cqp = "cqp",
|
||||
Icq = "icq"
|
||||
}
|
||||
export enum HlsVideoResolution {
|
||||
$480 = 480,
|
||||
$720 = 720,
|
||||
$1080 = 1080,
|
||||
$1440 = 1440,
|
||||
$2160 = 2160
|
||||
}
|
||||
export enum ToneMapping {
|
||||
Hable = "hable",
|
||||
Mobius = "mobius",
|
||||
|
||||
Generated
+148
-9
@@ -34,7 +34,7 @@ importers:
|
||||
dependencies:
|
||||
'@docusaurus/core':
|
||||
specifier: ~3.10.0
|
||||
version: 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)
|
||||
version: 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.7))(esbuild@0.28.1)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)
|
||||
'@docusaurus/preset-classic':
|
||||
specifier: ~3.10.0
|
||||
version: 3.10.1(@algolia/client-search@5.54.0)(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.7))(@types/react@19.2.17)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(search-insights@2.17.3)(typescript@6.0.3)
|
||||
@@ -58,7 +58,7 @@ importers:
|
||||
version: 10.5.0(postcss@8.5.15)
|
||||
docusaurus-lunr-search:
|
||||
specifier: ^3.3.2
|
||||
version: 3.6.0(@docusaurus/core@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
version: 3.6.0(@docusaurus/core@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.7))(esbuild@0.28.1)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
lunr:
|
||||
specifier: ^2.3.9
|
||||
version: 2.3.9
|
||||
@@ -786,8 +786,8 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../packages/sdk
|
||||
'@immich/ui':
|
||||
specifier: ^0.83.0
|
||||
version: 0.83.0(@sveltejs/kit@2.65.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.61.0))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))
|
||||
specifier: ^0.81.1
|
||||
version: 0.81.1(@sveltejs/kit@2.65.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.61.0))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))
|
||||
'@mapbox/mapbox-gl-rtl-text':
|
||||
specifier: 0.4.0
|
||||
version: 0.4.0
|
||||
@@ -3250,8 +3250,8 @@ packages:
|
||||
resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==}
|
||||
hasBin: true
|
||||
|
||||
'@immich/ui@0.83.0':
|
||||
resolution: {integrity: sha512-Xh3R3yhn8/Qyq8lGWIiVmturA+dHXtmW/h++AGb2UAwS/58qu7ssocO/HWIamaSpkgjPGotlWpPIpUJq1zh9FQ==}
|
||||
'@immich/ui@0.81.1':
|
||||
resolution: {integrity: sha512-7g173hArs7OS5CfHUG+ZJVQp1iCvFZzBmm+f2uH1WChlFdcwty5DLilYrDBszWuPIvu8wTX2AXTneS/KYBCUxw==}
|
||||
peerDependencies:
|
||||
'@sveltejs/kit': ^2.13.0
|
||||
svelte: ^5.0.0
|
||||
@@ -14402,6 +14402,49 @@ snapshots:
|
||||
- uglify-js
|
||||
- webpack-cli
|
||||
|
||||
'@docusaurus/bundler@3.10.1(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@docusaurus/babel': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@docusaurus/cssnano-preset': 3.10.1
|
||||
'@docusaurus/logger': 3.10.1
|
||||
'@docusaurus/types': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@docusaurus/utils': 3.10.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
babel-loader: 9.2.1(@babel/core@7.29.7)(webpack@5.107.2(postcss@8.5.15))
|
||||
clean-css: 5.3.3
|
||||
copy-webpack-plugin: 11.0.0(webpack@5.107.2(postcss@8.5.15))
|
||||
css-loader: 6.11.0(webpack@5.107.2(postcss@8.5.15))
|
||||
css-minimizer-webpack-plugin: 5.0.1(clean-css@5.3.3)(esbuild@0.28.1)(webpack@5.107.2(postcss@8.5.15))
|
||||
cssnano: 6.1.2(postcss@8.5.15)
|
||||
file-loader: 6.2.0(webpack@5.107.2(postcss@8.5.15))
|
||||
html-minifier-terser: 7.2.0
|
||||
mini-css-extract-plugin: 2.10.2(webpack@5.107.2(postcss@8.5.15))
|
||||
null-loader: 4.0.1(webpack@5.107.2(postcss@8.5.15))
|
||||
postcss: 8.5.15
|
||||
postcss-loader: 7.3.4(postcss@8.5.15)(typescript@6.0.3)(webpack@5.107.2(postcss@8.5.15))
|
||||
postcss-preset-env: 10.6.1(postcss@8.5.15)
|
||||
terser-webpack-plugin: 5.6.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(postcss@8.5.15)(webpack@5.107.2(postcss@8.5.15))
|
||||
tslib: 2.8.1
|
||||
url-loader: 4.1.1(file-loader@6.2.0(webpack@5.107.2(postcss@8.5.15)))(webpack@5.107.2(postcss@8.5.15))
|
||||
webpack: 5.107.2(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)
|
||||
webpackbar: 7.0.0(webpack@5.107.2(postcss@8.5.15))
|
||||
transitivePeerDependencies:
|
||||
- '@minify-html/node'
|
||||
- '@parcel/css'
|
||||
- '@rspack/core'
|
||||
- '@swc/core'
|
||||
- '@swc/css'
|
||||
- '@swc/html'
|
||||
- csso
|
||||
- esbuild
|
||||
- lightningcss
|
||||
- react
|
||||
- react-dom
|
||||
- supports-color
|
||||
- typescript
|
||||
- uglify-js
|
||||
- webpack-cli
|
||||
|
||||
'@docusaurus/bundler@3.10.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
@@ -14445,6 +14488,75 @@ snapshots:
|
||||
- uglify-js
|
||||
- webpack-cli
|
||||
|
||||
'@docusaurus/core@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.7))(esbuild@0.28.1)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@docusaurus/babel': 3.10.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@docusaurus/bundler': 3.10.1(esbuild@0.28.1)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)
|
||||
'@docusaurus/logger': 3.10.1
|
||||
'@docusaurus/mdx-loader': 3.10.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@docusaurus/utils': 3.10.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@docusaurus/utils-common': 3.10.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@docusaurus/utils-validation': 3.10.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@mdx-js/react': 3.1.1(@types/react@19.2.17)(react@19.2.7)
|
||||
boxen: 6.2.1
|
||||
chalk: 4.1.2
|
||||
chokidar: 3.6.0
|
||||
cli-table3: 0.6.5
|
||||
combine-promises: 1.2.0
|
||||
commander: 5.1.0
|
||||
core-js: 3.49.0
|
||||
detect-port: 1.6.1
|
||||
escape-html: 1.0.3
|
||||
eta: 2.2.0
|
||||
eval: 0.1.8
|
||||
execa: 5.1.1
|
||||
fs-extra: 11.3.5
|
||||
html-tags: 3.3.1
|
||||
html-webpack-plugin: 5.6.7(webpack@5.107.2(postcss@8.5.15))
|
||||
leven: 3.1.0
|
||||
lodash: 4.18.1
|
||||
open: 8.4.2
|
||||
p-map: 4.0.0
|
||||
prompts: 2.4.2
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
react-helmet-async: '@slorber/react-helmet-async@1.3.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7)'
|
||||
react-loadable: '@docusaurus/react-loadable@6.0.0(react@19.2.7)'
|
||||
react-loadable-ssr-addon-v5-slorber: 1.0.3(@docusaurus/react-loadable@6.0.0(react@19.2.7))(webpack@5.107.2(postcss@8.5.15))
|
||||
react-router: 5.3.4(react@19.2.7)
|
||||
react-router-config: 5.1.1(react-router@5.3.4(react@19.2.7))(react@19.2.7)
|
||||
react-router-dom: 5.3.4(react@19.2.7)
|
||||
semver: 7.8.4
|
||||
serve-handler: 6.1.7
|
||||
tinypool: 1.1.1
|
||||
tslib: 2.8.1
|
||||
update-notifier: 6.0.2
|
||||
webpack: 5.107.2(postcss@8.5.15)
|
||||
webpack-bundle-analyzer: 4.10.2
|
||||
webpack-dev-server: 5.2.5(tslib@2.8.1)(webpack@5.107.2(postcss@8.5.15))
|
||||
webpack-merge: 6.0.1
|
||||
transitivePeerDependencies:
|
||||
- '@minify-html/node'
|
||||
- '@parcel/css'
|
||||
- '@rspack/core'
|
||||
- '@swc/core'
|
||||
- '@swc/css'
|
||||
- '@swc/html'
|
||||
- bufferutil
|
||||
- clean-css
|
||||
- cssnano
|
||||
- csso
|
||||
- debug
|
||||
- esbuild
|
||||
- html-minifier-terser
|
||||
- lightningcss
|
||||
- postcss
|
||||
- supports-color
|
||||
- typescript
|
||||
- uglify-js
|
||||
- utf-8-validate
|
||||
- webpack-cli
|
||||
|
||||
'@docusaurus/core@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)':
|
||||
dependencies:
|
||||
'@docusaurus/babel': 3.10.1(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -15999,7 +16111,7 @@ snapshots:
|
||||
pg-connection-string: 2.13.0
|
||||
postgres: 3.4.9
|
||||
|
||||
'@immich/ui@0.83.0(@sveltejs/kit@2.65.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.61.0))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))':
|
||||
'@immich/ui@0.81.1(@sveltejs/kit@2.65.1(@opentelemetry/api@1.9.1)(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.61.0))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))':
|
||||
dependencies:
|
||||
'@internationalized/date': 3.12.2
|
||||
'@mdi/js': 7.4.47
|
||||
@@ -19865,6 +19977,19 @@ snapshots:
|
||||
optionalDependencies:
|
||||
webpack: 5.107.2(postcss@8.5.15)
|
||||
|
||||
css-minimizer-webpack-plugin@5.0.1(clean-css@5.3.3)(esbuild@0.28.1)(webpack@5.107.2(postcss@8.5.15)):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
cssnano: 6.1.2(postcss@8.5.15)
|
||||
jest-worker: 29.7.0
|
||||
postcss: 8.5.15
|
||||
schema-utils: 4.3.3
|
||||
serialize-javascript: 6.0.2
|
||||
webpack: 5.107.2(postcss@8.5.15)
|
||||
optionalDependencies:
|
||||
clean-css: 5.3.3
|
||||
esbuild: 0.28.1
|
||||
|
||||
css-minimizer-webpack-plugin@5.0.1(clean-css@5.3.3)(webpack@5.107.2(postcss@8.5.15)):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
@@ -20342,9 +20467,9 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
docusaurus-lunr-search@3.6.0(@docusaurus/core@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
docusaurus-lunr-search@3.6.0(@docusaurus/core@3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.7))(esbuild@0.28.1)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
dependencies:
|
||||
'@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.7))(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)
|
||||
'@docusaurus/core': 3.10.1(@mdx-js/react@3.1.1(@types/react@19.2.17)(react@19.2.7))(esbuild@0.28.1)(postcss@8.5.15)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@6.0.3)
|
||||
autocomplete.js: 0.37.1
|
||||
clsx: 2.1.1
|
||||
gauge: 3.0.2
|
||||
@@ -25943,6 +26068,20 @@ snapshots:
|
||||
esbuild: 0.28.1
|
||||
lightningcss: 1.32.0
|
||||
|
||||
terser-webpack-plugin@5.6.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(esbuild@0.28.1)(html-minifier-terser@7.2.0)(postcss@8.5.15)(webpack@5.107.2(postcss@8.5.15)):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
jest-worker: 27.5.1
|
||||
schema-utils: 4.3.3
|
||||
terser: 5.48.0
|
||||
webpack: 5.107.2(postcss@8.5.15)
|
||||
optionalDependencies:
|
||||
clean-css: 5.3.3
|
||||
cssnano: 6.1.2(postcss@8.5.15)
|
||||
esbuild: 0.28.1
|
||||
html-minifier-terser: 7.2.0
|
||||
postcss: 8.5.15
|
||||
|
||||
terser-webpack-plugin@5.6.1(clean-css@5.3.3)(cssnano@6.1.2(postcss@8.5.15))(html-minifier-terser@7.2.0)(postcss@8.5.15)(webpack@5.107.2(postcss@8.5.15)):
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
AudioCodec,
|
||||
Colorspace,
|
||||
CQMode,
|
||||
HlsVideoResolution,
|
||||
ImageFormat,
|
||||
LogLevel,
|
||||
OAuthTokenEndpointAuthMethod,
|
||||
@@ -49,8 +48,6 @@ export type SystemConfig = {
|
||||
tonemap: ToneMapping;
|
||||
realtime: {
|
||||
enabled: boolean;
|
||||
videoCodecs: VideoCodec[];
|
||||
resolutions: HlsVideoResolution[];
|
||||
};
|
||||
};
|
||||
integrityChecks: {
|
||||
@@ -250,8 +247,6 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
accelDecode: true,
|
||||
realtime: {
|
||||
enabled: false,
|
||||
videoCodecs: [VideoCodec.H264, VideoCodec.Hevc],
|
||||
resolutions: [HlsVideoResolution.p480, HlsVideoResolution.p720, HlsVideoResolution.p1080],
|
||||
},
|
||||
},
|
||||
integrityChecks: {
|
||||
|
||||
+9
-60
@@ -235,65 +235,14 @@ export const HLS_PLAYLIST_CONTENT_TYPE = 'application/vnd.apple.mpegurl';
|
||||
export const HLS_SEGMENT_DURATION = 2;
|
||||
export const HLS_SEGMENT_FILENAME_REGEX = /^seg_(\d+)\.m4s$/;
|
||||
export const HLS_VARIANTS = [
|
||||
{ resolution: 480, codec: VideoCodec.Av1, bitrate: 1_000_000 },
|
||||
{ resolution: 480, codec: VideoCodec.Hevc, bitrate: 1_200_000 },
|
||||
{ resolution: 480, codec: VideoCodec.H264, bitrate: 2_500_000 },
|
||||
{ resolution: 720, codec: VideoCodec.Av1, bitrate: 2_000_000 },
|
||||
{ resolution: 720, codec: VideoCodec.Hevc, bitrate: 2_500_000 },
|
||||
{ resolution: 720, codec: VideoCodec.H264, bitrate: 5_000_000 },
|
||||
{ resolution: 1080, codec: VideoCodec.Av1, bitrate: 4_000_000 },
|
||||
{ resolution: 1080, codec: VideoCodec.Hevc, bitrate: 4_500_000 },
|
||||
{ resolution: 1080, codec: VideoCodec.H264, bitrate: 8_000_000 },
|
||||
{ resolution: 1440, codec: VideoCodec.Av1, bitrate: 7_000_000 },
|
||||
{ resolution: 1440, codec: VideoCodec.Hevc, bitrate: 8_000_000 },
|
||||
{ resolution: 1440, codec: VideoCodec.H264, bitrate: 14_000_000 },
|
||||
{ resolution: 2160, codec: VideoCodec.Av1, bitrate: 12_000_000 },
|
||||
{ resolution: 2160, codec: VideoCodec.Hevc, bitrate: 14_000_000 },
|
||||
{ resolution: 2160, codec: VideoCodec.H264, bitrate: 25_000_000 },
|
||||
{ resolution: 480, codec: VideoCodec.Av1, bitrate: 1_000_000, codecString: 'av01.0.04M.08' },
|
||||
{ resolution: 480, codec: VideoCodec.Hevc, bitrate: 1_200_000, codecString: 'hvc1.1.6.L90.B0' },
|
||||
{ resolution: 480, codec: VideoCodec.H264, bitrate: 2_500_000, codecString: 'avc1.64001e' },
|
||||
{ resolution: 720, codec: VideoCodec.Av1, bitrate: 2_000_000, codecString: 'av01.0.08M.08' },
|
||||
{ resolution: 720, codec: VideoCodec.Hevc, bitrate: 2_500_000, codecString: 'hvc1.1.6.L93.B0' },
|
||||
{ resolution: 720, codec: VideoCodec.H264, bitrate: 5_000_000, codecString: 'avc1.64001f' },
|
||||
{ resolution: 1080, codec: VideoCodec.Av1, bitrate: 4_000_000, codecString: 'av01.0.09M.08' },
|
||||
{ resolution: 1080, codec: VideoCodec.Hevc, bitrate: 4_500_000, codecString: 'hvc1.1.6.L120.B0' },
|
||||
{ resolution: 1080, codec: VideoCodec.H264, bitrate: 8_000_000, codecString: 'avc1.640028' },
|
||||
];
|
||||
export const HLS_VERSION = 7;
|
||||
|
||||
export type CodecLevel = { maxFrame: number; maxRate: number; token: string };
|
||||
|
||||
// H.264 High profile: token is the hex level_idc.
|
||||
export const H264_LEVELS: CodecLevel[] = [
|
||||
{ maxFrame: 1620, maxRate: 40_500, token: '1e' }, // 3.0
|
||||
{ maxFrame: 3600, maxRate: 108_000, token: '1f' }, // 3.1
|
||||
{ maxFrame: 5120, maxRate: 216_000, token: '20' }, // 3.2
|
||||
{ maxFrame: 8192, maxRate: 245_760, token: '28' }, // 4.0
|
||||
{ maxFrame: 8704, maxRate: 522_240, token: '2a' }, // 4.2
|
||||
{ maxFrame: 22_080, maxRate: 589_824, token: '32' }, // 5.0
|
||||
{ maxFrame: 36_864, maxRate: 983_040, token: '33' }, // 5.1
|
||||
{ maxFrame: 36_864, maxRate: 2_073_600, token: '34' }, // 5.2
|
||||
{ maxFrame: 139_264, maxRate: 4_177_920, token: '3c' }, // 6.0
|
||||
{ maxFrame: 139_264, maxRate: 8_355_840, token: '3d' }, // 6.1
|
||||
{ maxFrame: 139_264, maxRate: 16_711_680, token: '3e' }, // 6.2
|
||||
];
|
||||
|
||||
// HEVC Main profile, Main tier: token is `L` + level_idc (level × 30).
|
||||
export const HEVC_LEVELS: CodecLevel[] = [
|
||||
{ maxFrame: 552_960, maxRate: 16_588_800, token: 'L90' }, // 3.0
|
||||
{ maxFrame: 983_040, maxRate: 33_177_600, token: 'L93' }, // 3.1
|
||||
{ maxFrame: 2_228_224, maxRate: 66_846_720, token: 'L120' }, // 4.0
|
||||
{ maxFrame: 2_228_224, maxRate: 133_693_440, token: 'L123' }, // 4.1
|
||||
{ maxFrame: 8_912_896, maxRate: 267_386_880, token: 'L150' }, // 5.0
|
||||
{ maxFrame: 8_912_896, maxRate: 534_773_760, token: 'L153' }, // 5.1
|
||||
{ maxFrame: 8_912_896, maxRate: 1_069_547_520, token: 'L156' }, // 5.2
|
||||
{ maxFrame: 35_651_584, maxRate: 1_069_547_520, token: 'L180' }, // 6.0
|
||||
{ maxFrame: 35_651_584, maxRate: 2_139_095_040, token: 'L183' }, // 6.1
|
||||
{ maxFrame: 35_651_584, maxRate: 4_278_190_080, token: 'L186' }, // 6.2
|
||||
];
|
||||
|
||||
// AV1 Main profile (0), Main tier (M): token is the two-digit seq_level_idx + `M`.
|
||||
export const AV1_LEVELS: CodecLevel[] = [
|
||||
{ maxFrame: 665_856, maxRate: 19_975_168, token: '04M' }, // 3.0
|
||||
{ maxFrame: 1_065_024, maxRate: 31_950_336, token: '05M' }, // 3.1
|
||||
{ maxFrame: 2_359_296, maxRate: 70_778_880, token: '08M' }, // 4.0
|
||||
{ maxFrame: 2_359_296, maxRate: 141_557_760, token: '09M' }, // 4.1
|
||||
{ maxFrame: 8_912_896, maxRate: 267_386_880, token: '12M' }, // 5.0
|
||||
{ maxFrame: 8_912_896, maxRate: 534_773_760, token: '13M' }, // 5.1
|
||||
{ maxFrame: 8_912_896, maxRate: 1_069_547_520, token: '14M' }, // 5.2
|
||||
{ maxFrame: 35_651_584, maxRate: 1_069_547_520, token: '16M' }, // 6.0
|
||||
{ maxFrame: 35_651_584, maxRate: 2_139_095_040, token: '17M' }, // 6.1
|
||||
{ maxFrame: 35_651_584, maxRate: 4_278_190_080, token: '18M' }, // 6.2
|
||||
];
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
AudioCodecSchema,
|
||||
ColorspaceSchema,
|
||||
CQModeSchema,
|
||||
HlsVideoResolutionSchema,
|
||||
ImageFormatSchema,
|
||||
LogLevelSchema,
|
||||
OAuthTokenEndpointAuthMethodSchema,
|
||||
@@ -116,8 +115,6 @@ const SystemConfigFFmpegSchema = z
|
||||
realtime: z
|
||||
.object({
|
||||
enabled: configBool.describe('Enable real-time HLS transcoding (alpha)'),
|
||||
videoCodecs: z.array(VideoCodecSchema).describe('Video codecs to use for real-time HLS transcoding'),
|
||||
resolutions: z.array(HlsVideoResolutionSchema).describe('Resolutions to use for real-time HLS transcoding'),
|
||||
})
|
||||
.meta({ id: 'SystemConfigFFmpegRealtimeDto' }),
|
||||
})
|
||||
|
||||
@@ -529,19 +529,6 @@ export enum CQMode {
|
||||
|
||||
export const CQModeSchema = z.enum(CQMode).describe('CQ mode').meta({ id: 'CQMode' });
|
||||
|
||||
export enum HlsVideoResolution {
|
||||
p480 = 480,
|
||||
p720 = 720,
|
||||
p1080 = 1080,
|
||||
p1440 = 1440,
|
||||
p2160 = 2160,
|
||||
}
|
||||
|
||||
export const HlsVideoResolutionSchema = z
|
||||
.enum(HlsVideoResolution)
|
||||
.describe('HLS video resolution')
|
||||
.meta({ id: 'HlsVideoResolution', type: 'integer' });
|
||||
|
||||
export enum Colorspace {
|
||||
Srgb = 'srgb',
|
||||
P3 = 'p3',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { HlsVideoResolution, VideoCodec } from 'src/enum';
|
||||
import { TranscodeHardwareAcceleration } from 'src/enum';
|
||||
import { HlsService } from 'src/services/hls.service';
|
||||
import { eiffelTower, train, waterfall } from 'test/fixtures/media.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
@@ -96,79 +96,67 @@ seg_10.m4s
|
||||
|
||||
const sessionId = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
const eiffelExpectedMasterAv1 = `#EXTM3U
|
||||
const eiffelExpectedMasterDisabled = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1350000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/0/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/1/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/2/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2700000,RESOLUTION=720x1280,CODECS="av01.0.05M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=720x1280,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/3/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/4/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/5/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=5400000,RESOLUTION=1080x1920,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1080x1920,CODECS="av01.0.09M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/6/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/7/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/8/playlist.m3u8
|
||||
`;
|
||||
|
||||
const eiffelExpectedMasterNoAv1 = `#EXTM3U
|
||||
const eiffelExpectedMasterRkmpp = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/1/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/2/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/4/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/5/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/7/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910
|
||||
${sessionId}/8/playlist.m3u8
|
||||
`;
|
||||
|
||||
const waterfallExpectedMasterAv1 = `#EXTM3U
|
||||
const waterfallExpectedMasterDisabled = `#EXTM3U
|
||||
#EXT-X-VERSION:7
|
||||
#EXT-X-INDEPENDENT-SEGMENTS
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1350000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/0/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/1/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/2/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2700000,RESOLUTION=720x1280,CODECS="av01.0.05M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=720x1280,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/3/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/4/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/5/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=5400000,RESOLUTION=1080x1920,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1080x1920,CODECS="av01.0.09M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/6/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/7/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/8/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=9450000,RESOLUTION=1440x2560,CODECS="av01.0.12M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/9/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1440x2560,CODECS="hvc1.1.6.L150.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/10/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=18900000,RESOLUTION=1440x2560,CODECS="avc1.640032,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/11/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=16200000,RESOLUTION=2160x3840,CODECS="av01.0.12M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/12/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=18900000,RESOLUTION=2160x3840,CODECS="hvc1.1.6.L150.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/13/playlist.m3u8
|
||||
#EXT-X-STREAM-INF:BANDWIDTH=33750000,RESOLUTION=2160x3840,CODECS="avc1.640033,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830
|
||||
${sessionId}/14/playlist.m3u8
|
||||
`;
|
||||
|
||||
describe(HlsService.name, () => {
|
||||
@@ -183,24 +171,9 @@ describe(HlsService.name, () => {
|
||||
const auth = factory.auth();
|
||||
const assetId = 'asset-1';
|
||||
|
||||
const allCodecs = [VideoCodec.Av1, VideoCodec.Hevc, VideoCodec.H264];
|
||||
const allResolutions = [
|
||||
HlsVideoResolution.p480,
|
||||
HlsVideoResolution.p720,
|
||||
HlsVideoResolution.p1080,
|
||||
HlsVideoResolution.p1440,
|
||||
HlsVideoResolution.p2160,
|
||||
];
|
||||
|
||||
const setup = (
|
||||
asset: typeof eiffelTower | typeof waterfall,
|
||||
videoCodecs?: VideoCodec[],
|
||||
resolutions?: HlsVideoResolution[],
|
||||
) => {
|
||||
const setup = (asset: typeof eiffelTower | typeof waterfall, accel: TranscodeHardwareAcceleration) => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
|
||||
mocks.systemMetadata.get.mockResolvedValue({
|
||||
ffmpeg: { realtime: { enabled: true, videoCodecs, resolutions } },
|
||||
});
|
||||
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { realtime: { enabled: true }, accel } });
|
||||
mocks.videoStream.getForMainPlaylist.mockResolvedValue(asset);
|
||||
mocks.crypto.randomUUID.mockReturnValue(sessionId);
|
||||
mocks.websocket.serverSend.mockImplementation((event, ...rest) => {
|
||||
@@ -211,19 +184,19 @@ describe(HlsService.name, () => {
|
||||
});
|
||||
};
|
||||
|
||||
it('offers AV1, HEVC, and H.264 when AV1 is configured and the accelerator supports it', async () => {
|
||||
setup(eiffelTower, allCodecs);
|
||||
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterAv1);
|
||||
it('returns main playlist for eiffel-tower (1080p portrait, no acceleration)', async () => {
|
||||
setup(eiffelTower, TranscodeHardwareAcceleration.Disabled);
|
||||
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterDisabled);
|
||||
});
|
||||
|
||||
it('omits AV1 when it is not in the configured codecs', async () => {
|
||||
setup(eiffelTower);
|
||||
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterNoAv1);
|
||||
it('returns main playlist for eiffel-tower with RKMPP (no AV1 variants)', async () => {
|
||||
setup(eiffelTower, TranscodeHardwareAcceleration.Rkmpp);
|
||||
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterRkmpp);
|
||||
});
|
||||
|
||||
it('offers every resolution up to the source and derives 4K codec levels (waterfall, 4K, 29.83fps)', async () => {
|
||||
setup(waterfall, allCodecs, allResolutions);
|
||||
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(waterfallExpectedMasterAv1);
|
||||
it('returns main playlist for waterfall (4K landscape) with no acceleration', async () => {
|
||||
setup(waterfall, TranscodeHardwareAcceleration.Disabled);
|
||||
await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(waterfallExpectedMasterDisabled);
|
||||
});
|
||||
|
||||
it('throws BadRequestException when realtime transcoding is disabled', async () => {
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { constants } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { HLS_SEGMENT_DURATION, HLS_SEGMENT_FILENAME_REGEX, HLS_VARIANTS, HLS_VERSION } from 'src/constants';
|
||||
import {
|
||||
HLS_SEGMENT_DURATION,
|
||||
HLS_SEGMENT_FILENAME_REGEX,
|
||||
HLS_VARIANTS,
|
||||
HLS_VERSION,
|
||||
SUPPORTED_HWA_CODECS,
|
||||
} from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnEvent } from 'src/decorators';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
@@ -12,7 +18,7 @@ import { BaseService } from 'src/services/base.service';
|
||||
import { VideoPacketInfo, VideoStreamInfo } from 'src/types';
|
||||
import { PendingEvents } from 'src/utils/event';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { getCodecString, getOutputSize } from 'src/utils/media';
|
||||
import { getOutputSize } from 'src/utils/media';
|
||||
|
||||
type AssetWithStreamInfo = { videoStream: VideoStreamInfo & { timeBase: number }; packets: VideoPacketInfo };
|
||||
type Segmentation = { fps: number; framesPerSegment: number; segmentCount: number; segmentDuration: number };
|
||||
@@ -125,21 +131,18 @@ export class HlsService extends BaseService {
|
||||
}
|
||||
|
||||
private generateMainPlaylist(sessionId: string, ffmpeg: SystemConfigFFmpegDto, asset: AssetWithStreamInfo) {
|
||||
const fps = (asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration;
|
||||
const roundedFps = fps.toFixed(3);
|
||||
const fps = ((asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration).toFixed(3);
|
||||
const sourceResolution = Math.min(asset.videoStream.height, asset.videoStream.width);
|
||||
const targetResolution = Math.max(sourceResolution, HLS_VARIANTS[0].resolution);
|
||||
const lines = ['#EXTM3U', `#EXT-X-VERSION:${HLS_VERSION}`, '#EXT-X-INDEPENDENT-SEGMENTS'];
|
||||
const { videoCodecs, resolutions } = ffmpeg.realtime;
|
||||
for (let i = 0; i < HLS_VARIANTS.length; i++) {
|
||||
const { resolution, bitrate, codec } = HLS_VARIANTS[i];
|
||||
if (resolution > targetResolution || !videoCodecs.includes(codec) || !resolutions.includes(resolution)) {
|
||||
const { resolution, bitrate, codec, codecString } = HLS_VARIANTS[i];
|
||||
if (resolution > targetResolution || !SUPPORTED_HWA_CODECS[ffmpeg.accel].includes(codec)) {
|
||||
continue;
|
||||
}
|
||||
const { width, height } = getOutputSize(asset.videoStream, resolution);
|
||||
const codecString = getCodecString(codec, width, height, fps);
|
||||
lines.push(
|
||||
`#EXT-X-STREAM-INF:BANDWIDTH=${Math.round(bitrate * 1.35)},RESOLUTION=${width}x${height},CODECS="${codecString},mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=${roundedFps}`,
|
||||
`#EXT-X-STREAM-INF:BANDWIDTH=${bitrate},RESOLUTION=${width}x${height},CODECS="${codecString},mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=${fps}`,
|
||||
`${sessionId}/${i}/playlist.m3u8`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
AudioCodec,
|
||||
Colorspace,
|
||||
CQMode,
|
||||
HlsVideoResolution,
|
||||
ImageFormat,
|
||||
LogLevel,
|
||||
OAuthTokenEndpointAuthMethod,
|
||||
@@ -77,8 +76,6 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
tonemap: ToneMapping.Hable,
|
||||
realtime: {
|
||||
enabled: false,
|
||||
videoCodecs: [VideoCodec.H264, VideoCodec.Hevc],
|
||||
resolutions: [HlsVideoResolution.p480, HlsVideoResolution.p720, HlsVideoResolution.p1080],
|
||||
},
|
||||
},
|
||||
integrityChecks: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AUDIO_ENCODER, AV1_LEVELS, CodecLevel, H264_LEVELS, HEVC_LEVELS, SUPPORTED_HWA_CODECS } from 'src/constants';
|
||||
import { AUDIO_ENCODER, SUPPORTED_HWA_CODECS } from 'src/constants';
|
||||
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
|
||||
import {
|
||||
ColorMatrix,
|
||||
@@ -36,29 +36,6 @@ export const getOutputSize = (videoStream: VideoStreamInfo, targetRes: number) =
|
||||
return isVideoVertical(videoStream) ? { width: targetRes, height: larger } : { width: larger, height: targetRes };
|
||||
};
|
||||
|
||||
const pickLevel = (levels: CodecLevel[], frame: number, rate: number) =>
|
||||
levels.find((level) => frame <= level.maxFrame && rate <= level.maxRate) ?? levels.at(-1)!;
|
||||
|
||||
export const getCodecString = (codec: VideoCodec, width: number, height: number, fps: number): string => {
|
||||
switch (codec) {
|
||||
case VideoCodec.H264: {
|
||||
const macroblocks = Math.ceil(width / 16) * Math.ceil(height / 16);
|
||||
return `avc1.6400${pickLevel(H264_LEVELS, macroblocks, macroblocks * fps).token}`;
|
||||
}
|
||||
case VideoCodec.Hevc: {
|
||||
const samples = width * height;
|
||||
return `hvc1.1.6.${pickLevel(HEVC_LEVELS, samples, samples * fps).token}.B0`;
|
||||
}
|
||||
case VideoCodec.Av1: {
|
||||
const samples = width * height;
|
||||
return `av01.0.${pickLevel(AV1_LEVELS, samples, samples * fps).token}.08`;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Codec '${codec}' does not support HLS codec strings`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export class BaseConfig implements VideoCodecSWConfig {
|
||||
readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast'];
|
||||
protected constructor(
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@
|
||||
"@formatjs/icu-messageformat-parser": "^3.0.0",
|
||||
"@immich/justified-layout-wasm": "^0.4.3",
|
||||
"@immich/sdk": "workspace:*",
|
||||
"@immich/ui": "^0.83.0",
|
||||
"@immich/ui": "^0.81.1",
|
||||
"@mapbox/mapbox-gl-rtl-text": "0.4.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
import {
|
||||
AudioCodec,
|
||||
CQMode,
|
||||
HlsVideoResolution,
|
||||
ToneMapping,
|
||||
TranscodeHWAccel,
|
||||
TranscodePolicy,
|
||||
@@ -402,45 +401,9 @@
|
||||
title={$t('admin.transcoding_realtime_enabled')}
|
||||
subtitle={$t('admin.transcoding_realtime_enabled_description')}
|
||||
bind:checked={configToEdit.ffmpeg.realtime.enabled}
|
||||
isEdited={configToEdit.ffmpeg.realtime.enabled !== config.ffmpeg.realtime.enabled}
|
||||
isEdited={configToEdit.ffmpeg.realtime.enabled !== configToEdit.ffmpeg.realtime.enabled}
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
<SettingCheckboxes
|
||||
label={$t('admin.transcoding_realtime_video_codecs')}
|
||||
desc={$t('admin.transcoding_realtime_video_codecs_description')}
|
||||
disabled={disabled || !configToEdit.ffmpeg.realtime.enabled}
|
||||
bind:value={configToEdit.ffmpeg.realtime.videoCodecs}
|
||||
name="realtimeVideoCodecs"
|
||||
options={[
|
||||
{ value: VideoCodec.H264, text: 'H.264' },
|
||||
{ value: VideoCodec.Hevc, text: 'HEVC' },
|
||||
{ value: VideoCodec.Av1, text: 'AV1' },
|
||||
]}
|
||||
isEdited={!isEqual(
|
||||
sortBy(configToEdit.ffmpeg.realtime.videoCodecs),
|
||||
sortBy(config.ffmpeg.realtime.videoCodecs),
|
||||
)}
|
||||
/>
|
||||
|
||||
<SettingCheckboxes
|
||||
label={$t('admin.transcoding_realtime_resolutions')}
|
||||
desc={$t('admin.transcoding_realtime_resolutions_description')}
|
||||
disabled={disabled || !configToEdit.ffmpeg.realtime.enabled}
|
||||
bind:value={configToEdit.ffmpeg.realtime.resolutions}
|
||||
name="realtimeResolutions"
|
||||
options={[
|
||||
{ value: HlsVideoResolution.$480, text: '480p' },
|
||||
{ value: HlsVideoResolution.$720, text: '720p' },
|
||||
{ value: HlsVideoResolution.$1080, text: '1080p' },
|
||||
{ value: HlsVideoResolution.$1440, text: '1440p' },
|
||||
{ value: HlsVideoResolution.$2160, text: '2160p' },
|
||||
]}
|
||||
isEdited={!isEqual(
|
||||
sortBy(configToEdit.ffmpeg.realtime.resolutions),
|
||||
sortBy(config.ffmpeg.realtime.resolutions),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script lang="ts" generics="T extends string | number">
|
||||
<script lang="ts" generics="T extends string">
|
||||
import { Checkbox, Label } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
|
||||
Reference in New Issue
Block a user