mirror of
https://github.com/immich-app/immich.git
synced 2026-06-26 08:24:29 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cbaf11c3d7 |
@@ -28,7 +28,7 @@ while getopts 's:m:' flag; do
|
||||
done
|
||||
|
||||
CURRENT_SERVER=$(jq -r '.version' package.json)
|
||||
if ! NEXT_SERVER=$(pnpm --silent pump "$CURRENT_SERVER" "$SERVER_PUMP"); then
|
||||
if ! NEXT_SERVER=$(pnpm --silent pump "$SERVER_PUMP"); then
|
||||
echo "Fatal: failed to pump server version: $NEXT_SERVER" >&2
|
||||
exit 1
|
||||
fi
|
||||
@@ -45,25 +45,21 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
|
||||
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix server
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/cli
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix web
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix e2e
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/sdk
|
||||
|
||||
if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then
|
||||
echo "Pumping Server: $CURRENT_SERVER => $NEXT_SERVER"
|
||||
# copy version to open-api spec
|
||||
mise run //:open-api
|
||||
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix server
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/cli
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix web
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix e2e
|
||||
pnpm version "$NEXT_SERVER" --no-git-tag-version --no-git-checks --prefix packages/sdk
|
||||
uv version --directory machine-learning "$NEXT_SERVER"
|
||||
|
||||
# copy version to open-api spec
|
||||
mise run //:open-api
|
||||
|
||||
uv version --directory machine-learning "$NEXT_SERVER"
|
||||
|
||||
./misc/release/archive-version.js "$NEXT_SERVER"
|
||||
fi
|
||||
./misc/release/archive-version.js "$NEXT_SERVER"
|
||||
|
||||
if [ "$CURRENT_MOBILE" != "$NEXT_MOBILE" ]; then
|
||||
echo "Pumping Mobile: $CURRENT_MOBILE => $NEXT_MOBILE"
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import { pump } from './pump.js';
|
||||
import { pump, PumpInvalidError, PumpUsageError } from './pump.js';
|
||||
|
||||
const [versionRaw, type] = process.argv.slice(2);
|
||||
const { message, exitCode } = pump(versionRaw, type);
|
||||
const [type] = process.argv.slice(2);
|
||||
|
||||
console.log(message);
|
||||
process.exit(exitCode);
|
||||
try {
|
||||
const nextVersion = pump(type);
|
||||
console.log(nextVersion);
|
||||
} catch (error) {
|
||||
if (error instanceof PumpUsageError) {
|
||||
console.log(
|
||||
'Usage: ./pump-wrapper.js <minor|patch|premajor|preminor|prepatch|prerelease|release>',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (error instanceof PumpInvalidError) {
|
||||
console.log(
|
||||
`Invalid pump: ${type}. Pumping from ${error.version} to ${error.newVersion} is not allowed.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
+77
-26
@@ -1,44 +1,74 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import semver, { SemVer } from 'semver';
|
||||
|
||||
const printUsage = () => {
|
||||
return {
|
||||
message:
|
||||
'Usage: ./pump_cli.js <semver> <minor|patch|premajor|preminor|prepatch|prerelease|release>',
|
||||
exitCode: 1,
|
||||
};
|
||||
const PROJECT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
|
||||
const Files = {
|
||||
PackageJson: join(PROJECT_ROOT, 'package.json'),
|
||||
ExampleEnv: join(PROJECT_ROOT, 'docker/example.env'),
|
||||
Docs: {
|
||||
Env: join(PROJECT_ROOT, 'docs/docs/install/environment-variables.md'),
|
||||
Upgrading: join(PROJECT_ROOT, 'docs/docs/install/upgrading.md'),
|
||||
},
|
||||
};
|
||||
|
||||
const isPrerelease = (version) => version.prerelease.length > 0;
|
||||
export class PumpUsageError extends Error {}
|
||||
export class PumpInvalidError extends Error {
|
||||
constructor(options) {
|
||||
super(`Invalid pump`);
|
||||
|
||||
this.version = options.version;
|
||||
this.newVersion = options.newVersion;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SemVer} version
|
||||
* @returns {boolean}
|
||||
* @param {string} type
|
||||
*/
|
||||
const inc = (version, type) => `v${semver.inc(version, type, {}, 'rc')}`;
|
||||
export const pump = (type) => {
|
||||
const currentVersionRaw = getCurrentVersion();
|
||||
const nextVersionRaw = getNextVersion(currentVersionRaw, type);
|
||||
const nextVersion = semver.parse(normalize(nextVersionRaw));
|
||||
|
||||
/** @param {string} version */
|
||||
const normalize = (version) => {
|
||||
if (version.startsWith('v')) {
|
||||
version = version.slice(1);
|
||||
if (nextVersion && type === 'release') {
|
||||
const major = `v${nextVersion.major}`;
|
||||
|
||||
// sync major tag references in docs and example env file
|
||||
findAndReplace(
|
||||
Files.ExampleEnv,
|
||||
/^IMMICH_VERSION=v\d+$/m,
|
||||
`IMMICH_VERSION=${major}`,
|
||||
);
|
||||
findAndReplace(
|
||||
Files.Docs.Env,
|
||||
/(`IMMICH_VERSION`.*?)`v\d+`/,
|
||||
`$1\`${major}\``,
|
||||
);
|
||||
findAndReplace(Files.Docs.Upgrading, /:v\d+/, `:${major}`);
|
||||
}
|
||||
|
||||
return version;
|
||||
return nextVersionRaw;
|
||||
};
|
||||
|
||||
const getCurrentVersion = () =>
|
||||
JSON.parse(readFileSync(Files.PackageJson, 'utf8')).version;
|
||||
|
||||
/**
|
||||
* @param {string} versionRaw
|
||||
* @param {string} type
|
||||
*/
|
||||
export const pump = (versionRaw, type) => {
|
||||
export const getNextVersion = (versionRaw, type) => {
|
||||
if (!versionRaw) {
|
||||
return printUsage();
|
||||
throw new PumpUsageError();
|
||||
}
|
||||
|
||||
versionRaw = normalize(versionRaw);
|
||||
|
||||
const version = semver.parse(versionRaw);
|
||||
if (!version) {
|
||||
return printUsage();
|
||||
throw new PumpUsageError();
|
||||
}
|
||||
|
||||
let newVersionRaw;
|
||||
@@ -72,19 +102,19 @@ export const pump = (versionRaw, type) => {
|
||||
}
|
||||
|
||||
default: {
|
||||
return printUsage();
|
||||
throw new PumpUsageError();
|
||||
}
|
||||
}
|
||||
|
||||
if (!newVersionRaw) {
|
||||
return printUsage();
|
||||
throw new PumpUsageError();
|
||||
}
|
||||
|
||||
newVersionRaw = normalize(newVersionRaw);
|
||||
|
||||
const newVersion = semver.parse(newVersionRaw);
|
||||
if (!newVersion) {
|
||||
return printUsage();
|
||||
throw new PumpUsageError();
|
||||
}
|
||||
|
||||
const invalidUpgrade =
|
||||
@@ -95,11 +125,32 @@ export const pump = (versionRaw, type) => {
|
||||
version.patch !== newVersion.patch);
|
||||
|
||||
if (!valid || invalidUpgrade) {
|
||||
return {
|
||||
message: `Invalid pump: ${type}. Pumping from ${versionRaw} to ${newVersionRaw} is not allowed.`,
|
||||
exitCode: 1,
|
||||
};
|
||||
throw new PumpInvalidError({
|
||||
type,
|
||||
version: versionRaw,
|
||||
newVersion: newVersionRaw,
|
||||
});
|
||||
}
|
||||
|
||||
return { message: newVersionRaw, exitCode: 0 };
|
||||
return newVersionRaw;
|
||||
};
|
||||
|
||||
const findAndReplace = (path, pattern, replacement) =>
|
||||
writeFileSync(path, readFileSync(path, 'utf8').replace(pattern, replacement));
|
||||
|
||||
const isPrerelease = (version) => version.prerelease.length > 0;
|
||||
|
||||
/**
|
||||
* @param {SemVer} version
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const inc = (version, type) => `v${semver.inc(version, type, {}, 'rc')}`;
|
||||
|
||||
/** @param {string} version */
|
||||
const normalize = (version) => {
|
||||
if (version.startsWith('v')) {
|
||||
version = version.slice(1);
|
||||
}
|
||||
|
||||
return version;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pump } from './pump';
|
||||
import { getNextVersion, PumpInvalidError, PumpUsageError } from './pump';
|
||||
|
||||
describe(pump.name, () => {
|
||||
describe(getNextVersion.name, () => {
|
||||
describe('usage', () => {
|
||||
it.each([
|
||||
[],
|
||||
@@ -10,10 +10,7 @@ describe(pump.name, () => {
|
||||
['invalid', 'patch'],
|
||||
['2.7.5', 'major'],
|
||||
])('should not accept $0, $1 as inputs', (version, type) => {
|
||||
expect(pump(version, type)).toEqual({
|
||||
message: expect.stringContaining('Usage: '),
|
||||
exitCode: 1,
|
||||
});
|
||||
expect(() => getNextVersion(version, type)).toThrow(PumpUsageError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,10 +55,7 @@ describe(pump.name, () => {
|
||||
it.each(group.items)(
|
||||
'should allow a $0 from $1 to $2',
|
||||
(type, version, next) => {
|
||||
expect(pump(version, type)).toEqual({
|
||||
message: next,
|
||||
exitCode: 0,
|
||||
});
|
||||
expect(getNextVersion(version, type)).toEqual(next);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -77,10 +71,7 @@ describe(pump.name, () => {
|
||||
['prerelease', 'v3.0.0'],
|
||||
['release', 'v3.0.0'],
|
||||
])('should not allow a $0 on $1', (type, version) => {
|
||||
expect(pump(version, type)).toEqual({
|
||||
message: expect.stringContaining('Invalid pump'),
|
||||
exitCode: 1,
|
||||
});
|
||||
expect(() => getNextVersion(version, type)).toThrow(PumpInvalidError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ project(native_buffer LANGUAGES C)
|
||||
|
||||
add_library(native_buffer SHARED
|
||||
src/main/cpp/native_buffer.c
|
||||
src/main/cpp/native_image.c
|
||||
)
|
||||
|
||||
target_link_libraries(native_buffer jnigraphics)
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
#include <jni.h>
|
||||
#include <stdlib.h>
|
||||
#include <stdint.h>
|
||||
#include <android/bitmap.h>
|
||||
|
||||
// Cache-friendly block size for the tiled rotation (in pixels). 32x32 uint32 = 4KB, fits L1.
|
||||
#define TILE 32
|
||||
|
||||
// EXIF orientation values (androidx.exifinterface.media.ExifInterface.ORIENTATION_*).
|
||||
enum {
|
||||
ORIENTATION_FLIP_HORIZONTAL = 2,
|
||||
ORIENTATION_ROTATE_180 = 3,
|
||||
ORIENTATION_FLIP_VERTICAL = 4,
|
||||
ORIENTATION_TRANSPOSE = 5,
|
||||
ORIENTATION_ROTATE_90 = 6,
|
||||
ORIENTATION_TRANSVERSE = 7,
|
||||
ORIENTATION_ROTATE_270 = 8,
|
||||
};
|
||||
|
||||
// The orientations that swap width and height. Must stay in sync with affine_for's dim usage.
|
||||
static int swaps_dims(int o) {
|
||||
return o == ORIENTATION_ROTATE_90 || o == ORIENTATION_ROTATE_270 ||
|
||||
o == ORIENTATION_TRANSPOSE || o == ORIENTATION_TRANSVERSE;
|
||||
}
|
||||
|
||||
// A source pixel (sx, sy) maps to destination index base + sx*stepX + sy*stepY, where dw is the
|
||||
// destination width. This affine form covers all 8 EXIF orientations and matches the pixel layout
|
||||
// of Bitmap.createBitmap(src, matrixForExifOrientation(o)). int64_t so it stays correct on
|
||||
// armeabi-v7a (32-bit long) regardless of how large MAX_RAW_DECODE_PIXELS grows.
|
||||
static void affine_for(int o, int sw, int sh, int dw, int64_t *base, int64_t *stepX, int64_t *stepY) {
|
||||
switch (o) {
|
||||
case ORIENTATION_ROTATE_90: *base = sh - 1; *stepX = dw; *stepY = -1; break;
|
||||
case ORIENTATION_ROTATE_270: *base = (int64_t) (sw - 1) * dw; *stepX = -dw; *stepY = 1; break;
|
||||
case ORIENTATION_ROTATE_180: *base = (int64_t) (sh - 1) * dw + (sw - 1); *stepX = -1; *stepY = -dw; break;
|
||||
case ORIENTATION_FLIP_HORIZONTAL: *base = sw - 1; *stepX = -1; *stepY = dw; break;
|
||||
case ORIENTATION_FLIP_VERTICAL: *base = (int64_t) (sh - 1) * dw; *stepX = 1; *stepY = -dw; break;
|
||||
case ORIENTATION_TRANSPOSE: *base = 0; *stepX = dw; *stepY = 1; break;
|
||||
case ORIENTATION_TRANSVERSE: *base = (int64_t) (sw - 1) * dw + (sh - 1); *stepX = -dw; *stepY = -1; break;
|
||||
default: *base = 0; *stepX = 1; *stepY = dw; break;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy each source pixel (whole uint32, so channel order/premult is irrelevant) to its rotated
|
||||
// destination, walking TILE x TILE blocks so the scattered writes of a 90/270 transpose stay
|
||||
// cache-resident. dst is densely packed (rowBytes == dw*4, no padding), which the affine math relies on.
|
||||
static void rotate_tiled(const uint8_t *src, int srcStride, uint32_t *dst,
|
||||
int sw, int sh, int64_t base, int64_t stepX, int64_t stepY) {
|
||||
for (int ty = 0; ty < sh; ty += TILE) {
|
||||
int yEnd = ty + TILE < sh ? ty + TILE : sh;
|
||||
for (int tx = 0; tx < sw; tx += TILE) {
|
||||
int xEnd = tx + TILE < sw ? tx + TILE : sw;
|
||||
for (int sy = ty; sy < yEnd; sy++) {
|
||||
const uint32_t *srcRow = (const uint32_t *) (src + (size_t) sy * srcStride);
|
||||
int64_t idx = base + (int64_t) sy * stepY + (int64_t) tx * stepX;
|
||||
for (int sx = tx; sx < xEnd; sx++) {
|
||||
dst[idx] = srcRow[sx];
|
||||
idx += stepX;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rotates an RGBA_8888 bitmap to the given EXIF orientation into a freshly malloc'd buffer (free it
|
||||
// via NativeBuffer.free). Fills outInfo with {width, height, rowBytes} and returns the buffer
|
||||
// address, or 0 if the bitmap can't be handled (e.g. a non-8888 format) so the caller can fall back.
|
||||
JNIEXPORT jlong JNICALL
|
||||
Java_app_alextran_immich_NativeImage_rotate(
|
||||
JNIEnv *env, jclass clazz, jobject bitmap, jint orientation, jintArray outInfo) {
|
||||
AndroidBitmapInfo info;
|
||||
if (AndroidBitmap_getInfo(env, bitmap, &info) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
return 0;
|
||||
}
|
||||
if (info.format != ANDROID_BITMAP_FORMAT_RGBA_8888) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
int sw = (int) info.width;
|
||||
int sh = (int) info.height;
|
||||
int dw = swaps_dims(orientation) ? sh : sw;
|
||||
int dh = swaps_dims(orientation) ? sw : sh;
|
||||
|
||||
uint32_t *dst = (uint32_t *) malloc((size_t) dw * dh * 4);
|
||||
if (dst == NULL) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
void *srcPixels = NULL;
|
||||
if (AndroidBitmap_lockPixels(env, bitmap, &srcPixels) != ANDROID_BITMAP_RESULT_SUCCESS) {
|
||||
free(dst);
|
||||
return 0;
|
||||
}
|
||||
|
||||
int64_t base, stepX, stepY;
|
||||
affine_for(orientation, sw, sh, dw, &base, &stepX, &stepY);
|
||||
rotate_tiled((const uint8_t *) srcPixels, (int) info.stride, dst, sw, sh, base, stepX, stepY);
|
||||
|
||||
AndroidBitmap_unlockPixels(env, bitmap);
|
||||
|
||||
jint dims[3] = {dw, dh, dw * 4};
|
||||
(*env)->SetIntArrayRegion(env, outInfo, 0, 3, dims);
|
||||
// Keep ownership in C until the buffer is safely handed back: if outInfo was somehow too small,
|
||||
// SetIntArrayRegion left a pending exception and Kotlin will never receive (or free) dst.
|
||||
if ((*env)->ExceptionCheck(env)) {
|
||||
free(dst);
|
||||
return 0;
|
||||
}
|
||||
return (jlong) dst;
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import android.graphics.Bitmap
|
||||
|
||||
object NativeImage {
|
||||
init {
|
||||
// rotate() is compiled into the native_buffer shared lib (which already links jnigraphics).
|
||||
System.loadLibrary("native_buffer")
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotates an RGBA_8888 [bitmap] to the given EXIF [orientation], writing the result into a freshly
|
||||
* malloc'd native buffer. Returns the buffer address (free it with [NativeBuffer.free]) and fills
|
||||
* [outInfo] with {width, height, rowBytes}. Returns 0 when the bitmap can't be handled (e.g. a
|
||||
* non-8888 config) so the caller can fall back.
|
||||
*/
|
||||
@JvmStatic
|
||||
external fun rotate(bitmap: Bitmap, orientation: Int, outInfo: IntArray): Long
|
||||
}
|
||||
@@ -12,9 +12,7 @@ import android.provider.MediaStore.Images
|
||||
import android.provider.MediaStore.Video
|
||||
import android.util.Size
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import app.alextran.immich.NativeBuffer
|
||||
import app.alextran.immich.NativeImage
|
||||
import kotlin.math.*
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.Executors
|
||||
@@ -76,11 +74,6 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
|
||||
companion object {
|
||||
val CANCELLED = Result.success<Map<String, Long>?>(null)
|
||||
val OPTIONS = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 }
|
||||
|
||||
// "Load original" decodes a raw at full res, and the orientation pass then walks every pixel, so
|
||||
// cap the decode resolution to keep that bounded on huge DNGs. This only trims pixels on very
|
||||
// large raws - they still come out upright, just downsampled.
|
||||
const val MAX_RAW_DECODE_PIXELS = 24_000_000L
|
||||
}
|
||||
|
||||
override fun getThumbhash(thumbhash: String, callback: (Result<Map<String, Long>>) -> Unit) {
|
||||
@@ -188,133 +181,35 @@ class LocalImagesImpl(context: Context) : LocalImageApi {
|
||||
val id = assetId.toLong()
|
||||
|
||||
signal.throwIfCanceled()
|
||||
val bitmap = if (isVideo) {
|
||||
decodeVideoThumbnail(id, size, signal)
|
||||
} else {
|
||||
decodeImage(id, size, signal)
|
||||
}
|
||||
|
||||
try {
|
||||
val res = if (isVideo) {
|
||||
decodeVideoThumbnail(id, size, signal).toNativeBuffer()
|
||||
} else {
|
||||
val (bitmap, orientation) = decodeImage(id, size, signal)
|
||||
signal.throwIfCanceled()
|
||||
if (orientation == ExifInterface.ORIENTATION_NORMAL || orientation == ExifInterface.ORIENTATION_UNDEFINED) {
|
||||
bitmap.toNativeBuffer()
|
||||
} else {
|
||||
rotateToNativeBuffer(bitmap, orientation, signal)
|
||||
}
|
||||
}
|
||||
// Don't re-check cancellation here: res owns a malloc'd buffer, and bailing to CANCELLED would
|
||||
// orphan it. Deliver it; Dart frees the buffer itself if the request was cancelled meanwhile.
|
||||
signal.throwIfCanceled()
|
||||
val res = bitmap.toNativeBuffer()
|
||||
signal.throwIfCanceled()
|
||||
callback(Result.success(res))
|
||||
} catch (e: Exception) {
|
||||
callback(if (e is OperationCanceledException) CANCELLED else Result.failure(e))
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the decoded bitmap plus the EXIF orientation that still needs applying. Only Q+ raw
|
||||
// decodes come back unrotated (ImageDecoder / loadThumbnail skip EXIF for raw like DNG); every
|
||||
// other path already orients itself, so it reports ORIENTATION_NORMAL.
|
||||
private fun decodeImage(id: Long, size: Size, signal: CancellationSignal): Pair<Bitmap, Int> {
|
||||
private fun decodeImage(id: Long, size: Size, signal: CancellationSignal): Bitmap {
|
||||
signal.throwIfCanceled()
|
||||
val uri = ContentUris.withAppendedId(Images.Media.EXTERNAL_CONTENT_URI, id)
|
||||
val handleRaw = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && isRawMime(uri)
|
||||
val orientation = if (handleRaw) rawOrientation(uri) else ExifInterface.ORIENTATION_NORMAL
|
||||
|
||||
if (size.width <= 0 || size.height <= 0 || size.width > 768 || size.height > 768) {
|
||||
// A "load original" request is unsized -> a full-res decode. For raw, cap it so the later
|
||||
// orientation pass stays within a safe pixel budget.
|
||||
val bitmap = if (handleRaw && (size.width <= 0 || size.height <= 0)) {
|
||||
decodeRawCapped(uri, signal)
|
||||
} else {
|
||||
decodeSource(uri, size, signal)
|
||||
}
|
||||
return bitmap to orientation
|
||||
return decodeSource(uri, size, signal)
|
||||
}
|
||||
|
||||
val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
resolver.loadThumbnail(uri, size, signal)
|
||||
} else {
|
||||
signal.setOnCancelListener { Images.Thumbnails.cancelThumbnailRequest(resolver, id) }
|
||||
Images.Thumbnails.getThumbnail(resolver, id, Images.Thumbnails.MINI_KIND, OPTIONS)
|
||||
}
|
||||
return bitmap to orientation
|
||||
}
|
||||
|
||||
private fun isRawMime(uri: Uri): Boolean {
|
||||
val mime = resolver.getType(uri) ?: return false
|
||||
return mime.startsWith("image/x-") || mime == "image/dng"
|
||||
}
|
||||
|
||||
private fun rawOrientation(uri: Uri): Int {
|
||||
return resolver.openInputStream(uri)?.use {
|
||||
ExifInterface(it).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||
} ?: ExifInterface.ORIENTATION_NORMAL
|
||||
}
|
||||
|
||||
// Full-res raw decode for "load original", sampled down to MAX_RAW_DECODE_PIXELS (power of two).
|
||||
// Caps resolution only; the caller still rotates the result, so even huge raws end up upright.
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
private fun decodeRawCapped(uri: Uri, signal: CancellationSignal): Bitmap {
|
||||
signal.throwIfCanceled()
|
||||
return ImageDecoder.decodeBitmap(ImageDecoder.createSource(resolver, uri)) { decoder, info, _ ->
|
||||
val pixels = info.size.width.toLong() * info.size.height.toLong()
|
||||
var sample = 1
|
||||
while (pixels / (sample.toLong() * sample) > MAX_RAW_DECODE_PIXELS) {
|
||||
sample *= 2
|
||||
}
|
||||
if (sample > 1) {
|
||||
decoder.setTargetSampleSize(sample)
|
||||
}
|
||||
decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE
|
||||
decoder.setTargetColorSpace(ColorSpace.get(ColorSpace.Named.SRGB))
|
||||
}
|
||||
}
|
||||
|
||||
// ImageDecoder / loadThumbnail skip EXIF orientation for raw (e.g. DNG) on Q+, so the decoded
|
||||
// bitmap comes back unrotated. Rotate it into the output buffer in native code (one pass, no
|
||||
// intermediate rotated bitmap), falling back to Skia for any config the native path can't take.
|
||||
private fun rotateToNativeBuffer(bitmap: Bitmap, orientation: Int, signal: CancellationSignal): Map<String, Long> {
|
||||
signal.throwIfCanceled()
|
||||
// Force ARGB_8888 so both the native pass and the Skia fallback are 4 bytes/pixel: the native
|
||||
// rotate needs a lockable 8888 buffer, and toNativeBuffer() below allocates width*height*4 (an
|
||||
// F16/HDR decode would otherwise under-allocate). No-op for the common already-8888 case.
|
||||
val src = if (bitmap.config != Bitmap.Config.ARGB_8888) {
|
||||
val converted = bitmap.copy(Bitmap.Config.ARGB_8888, false)
|
||||
bitmap.recycle()
|
||||
converted ?: throw IOException("could not convert bitmap to ARGB_8888")
|
||||
} else {
|
||||
bitmap
|
||||
}
|
||||
try {
|
||||
val info = IntArray(3)
|
||||
val pointer = NativeImage.rotate(src, orientation, info)
|
||||
if (pointer != 0L) {
|
||||
return mapOf(
|
||||
"pointer" to pointer,
|
||||
"width" to info[0].toLong(),
|
||||
"height" to info[1].toLong(),
|
||||
"rowBytes" to info[2].toLong()
|
||||
)
|
||||
}
|
||||
// Native path declined (unsupported config) -> rotate via Skia, then copy out.
|
||||
val matrix = matrixForExifOrientation(orientation) ?: return src.toNativeBuffer()
|
||||
return Bitmap.createBitmap(src, 0, 0, src.width, src.height, matrix, true).toNativeBuffer()
|
||||
} finally {
|
||||
if (!src.isRecycled) src.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
// EXIF orientation (1-8) -> transform matrix, or null when no rotation/flip is needed.
|
||||
private fun matrixForExifOrientation(orientation: Int): Matrix? {
|
||||
val matrix = Matrix()
|
||||
when (orientation) {
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.postScale(-1f, 1f)
|
||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.postScale(1f, -1f)
|
||||
ExifInterface.ORIENTATION_TRANSPOSE -> matrix.apply { postRotate(270f); postScale(-1f, 1f) }
|
||||
ExifInterface.ORIENTATION_TRANSVERSE -> matrix.apply { postRotate(90f); postScale(-1f, 1f) }
|
||||
else -> return null
|
||||
}
|
||||
return matrix
|
||||
}
|
||||
|
||||
private fun decodeVideoThumbnail(id: Long, target: Size, signal: CancellationSignal): Bitmap {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "pnpm build:tsc && pnpm build:wasm",
|
||||
"build:tsc": "plugin-sdk prepareBuild && tsc --noEmit && node esbuild.js",
|
||||
"build:wasm": "extism-js dist/index.js -i dist/index.d.ts -o dist/plugin.wasm"
|
||||
"build:tsc": "mkdir -p dist && echo \"type Manifest = $(cat manifest.json); \nexport default Manifest;\" > dist/manifest.d.ts && tsc --noEmit && node esbuild.js",
|
||||
"build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
// keep in sync with plugin-sdk/host-functions.ts';
|
||||
declare module 'extism:host' {
|
||||
interface user {
|
||||
searchAlbums(ptr: PTR): I64;
|
||||
createAlbum(ptr: PTR): I64;
|
||||
addAssetsToAlbum(ptr: PTR): I64;
|
||||
addAssetsToAlbums(ptr: PTR): I64;
|
||||
}
|
||||
}
|
||||
|
||||
// keep in sync with manifest.json
|
||||
declare module 'main' {
|
||||
// filters
|
||||
export function assetFileFilter(): I32;
|
||||
export function assetMissingTimeZoneFilter(): I32;
|
||||
export function assetLocationFilter(): I32;
|
||||
export function assetTypeFilter(): I32;
|
||||
|
||||
// updates
|
||||
export function assetFavorite(): I32;
|
||||
export function assetVisibility(): I32;
|
||||
export function assetArchive(): I32;
|
||||
export function assetLock(): I32;
|
||||
export function assetTimeline(): I32;
|
||||
// export function assetTrash(): I32;
|
||||
export function assetAddToAlbums(): I32;
|
||||
}
|
||||
+144
-126
@@ -1,157 +1,175 @@
|
||||
import { getWrapper } from '@immich/plugin-sdk';
|
||||
import { AssetVisibility } from '@immich/sdk';
|
||||
import type { Manifest } from '../dist/index.d.ts';
|
||||
import type manifestType from '../dist/manifest';
|
||||
|
||||
const wrapper = getWrapper<Manifest>();
|
||||
const wrapper = getWrapper<manifestType>();
|
||||
|
||||
export const assetFileFilter = wrapper<'assetFileFilter'>(({ data, config }) => {
|
||||
const { pattern, matchType = 'contains', caseSensitive = false } = config;
|
||||
export const assetFileFilter = () => {
|
||||
return wrapper<'assetFileFilter'>(({ data, config }) => {
|
||||
const { pattern, matchType = 'contains', caseSensitive = false } = config;
|
||||
|
||||
const { asset } = data;
|
||||
const { asset } = data;
|
||||
|
||||
const fileName = asset.originalFileName || '';
|
||||
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
|
||||
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
|
||||
const fileName = asset.originalFileName || '';
|
||||
const searchName = caseSensitive ? fileName : fileName.toLowerCase();
|
||||
const searchPattern = caseSensitive ? pattern : pattern.toLowerCase();
|
||||
|
||||
switch (matchType) {
|
||||
case 'contains': {
|
||||
return { workflow: { continue: searchName.includes(searchPattern) } };
|
||||
switch (matchType) {
|
||||
case 'contains': {
|
||||
return { workflow: { continue: searchName.includes(searchPattern) } };
|
||||
}
|
||||
|
||||
case 'exact': {
|
||||
return { workflow: { continue: searchName === searchPattern } };
|
||||
}
|
||||
|
||||
case 'startsWith': {
|
||||
return { workflow: { continue: searchName.startsWith(searchPattern) } };
|
||||
}
|
||||
|
||||
case 'regex': {
|
||||
const flags = caseSensitive ? '' : 'i';
|
||||
const regex = new RegExp(searchPattern, flags);
|
||||
return { workflow: { continue: regex.test(fileName) } };
|
||||
}
|
||||
|
||||
default: {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const assetMissingTimeZoneFilter = () => {
|
||||
return wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => {
|
||||
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
|
||||
const needsTimeZone = config.inverse ? true : false;
|
||||
return { workflow: { continue: hasTimeZone === needsTimeZone } };
|
||||
});
|
||||
};
|
||||
|
||||
export const assetLocationFilter = () => {
|
||||
return wrapper<'assetLocationFilter'>(({ config, data }) => {
|
||||
if (
|
||||
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
|
||||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
|
||||
(config.region?.city && config.region.city !== data.asset.exifInfo?.city)
|
||||
) {
|
||||
return { workflow: { continue: false } };
|
||||
}
|
||||
|
||||
case 'exact': {
|
||||
return { workflow: { continue: searchName === searchPattern } };
|
||||
const configLat = Number.parseFloat(config.coordinate?.latitude ?? '');
|
||||
const configLon = Number.parseFloat(config.coordinate?.longitude ?? '');
|
||||
|
||||
if (Number.isNaN(configLat) || Number.isNaN(configLat)) {
|
||||
return { workflow: { continue: true } };
|
||||
}
|
||||
|
||||
case 'startsWith': {
|
||||
return { workflow: { continue: searchName.startsWith(searchPattern) } };
|
||||
const assetLat = data.asset.exifInfo?.latitude;
|
||||
const assetLon = data.asset.exifInfo?.longitude;
|
||||
|
||||
if (assetLat === undefined || assetLat === null || assetLon === undefined || assetLon === null) {
|
||||
return { workflow: { continue: false } };
|
||||
}
|
||||
|
||||
case 'regex': {
|
||||
const flags = caseSensitive ? '' : 'i';
|
||||
const regex = new RegExp(searchPattern, flags);
|
||||
return { workflow: { continue: regex.test(fileName) } };
|
||||
const earthDiameter = 12742;
|
||||
const deg = Math.PI / 180;
|
||||
const delta = Math.asin(
|
||||
Math.sqrt(
|
||||
Math.pow(Math.sin((assetLat * deg - configLat * deg) / 2), 2) +
|
||||
Math.cos(assetLat * deg) *
|
||||
Math.cos(configLat * deg) *
|
||||
Math.pow(Math.sin((assetLon * deg - configLon * deg) / 2), 2),
|
||||
),
|
||||
);
|
||||
|
||||
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
|
||||
});
|
||||
};
|
||||
|
||||
export const assetTypeFilter = () => {
|
||||
return wrapper<'assetTypeFilter'>(({ config, data }) => {
|
||||
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
|
||||
});
|
||||
};
|
||||
|
||||
export const assetFavorite = () => {
|
||||
return wrapper<'assetFavorite'>(({ config, data }) => {
|
||||
const target = config.inverse ? false : true;
|
||||
if (target !== data.asset.isFavorite) {
|
||||
return {
|
||||
changes: {
|
||||
asset: { isFavorite: target },
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const assetVisibility = () => {
|
||||
return wrapper<'assetVisibility'>(({ config }) => ({
|
||||
changes: { asset: { visibility: config.visibility as AssetVisibility } },
|
||||
}));
|
||||
};
|
||||
|
||||
export const assetArchive = () => {
|
||||
return wrapper<'assetArchive'>(({ config, data }) => {
|
||||
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
|
||||
return { changes: { asset: { visibility: AssetVisibility.Archive } } };
|
||||
}
|
||||
|
||||
default: {
|
||||
return {};
|
||||
if (config.inverse && data.asset.visibility === AssetVisibility.Archive) {
|
||||
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const assetMissingTimeZoneFilter = wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => {
|
||||
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
|
||||
const needsTimeZone = config.inverse ? true : false;
|
||||
return { workflow: { continue: hasTimeZone === needsTimeZone } };
|
||||
});
|
||||
return {};
|
||||
});
|
||||
};
|
||||
|
||||
export const assetLocationFilter = wrapper<'assetLocationFilter'>(({ config, data }) => {
|
||||
if (
|
||||
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
|
||||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
|
||||
(config.region?.city && config.region.city !== data.asset.exifInfo?.city)
|
||||
) {
|
||||
return { workflow: { continue: false } };
|
||||
}
|
||||
export const assetLock = () => {
|
||||
return wrapper<'assetLock'>(({ config, data }) => {
|
||||
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
|
||||
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
|
||||
}
|
||||
|
||||
const configLat = Number.parseFloat(config.coordinate?.latitude ?? '');
|
||||
const configLon = Number.parseFloat(config.coordinate?.longitude ?? '');
|
||||
if (config.inverse && data.asset.visibility === AssetVisibility.Locked) {
|
||||
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
|
||||
}
|
||||
|
||||
if (Number.isNaN(configLat) || Number.isNaN(configLat)) {
|
||||
return { workflow: { continue: true } };
|
||||
}
|
||||
|
||||
const assetLat = data.asset.exifInfo?.latitude;
|
||||
const assetLon = data.asset.exifInfo?.longitude;
|
||||
|
||||
if (assetLat === undefined || assetLat === null || assetLon === undefined || assetLon === null) {
|
||||
return { workflow: { continue: false } };
|
||||
}
|
||||
|
||||
const earthDiameter = 12742;
|
||||
const deg = Math.PI / 180;
|
||||
const delta = Math.asin(
|
||||
Math.sqrt(
|
||||
Math.pow(Math.sin((assetLat * deg - configLat * deg) / 2), 2) +
|
||||
Math.cos(assetLat * deg) *
|
||||
Math.cos(configLat * deg) *
|
||||
Math.pow(Math.sin((assetLon * deg - configLon * deg) / 2), 2),
|
||||
),
|
||||
);
|
||||
|
||||
return { workflow: { continue: earthDiameter * delta <= (config.coordinate?.radius ?? 0) } };
|
||||
});
|
||||
|
||||
export const assetTypeFilter = wrapper<'assetTypeFilter'>(({ config, data }) => {
|
||||
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
|
||||
});
|
||||
|
||||
export const assetFavorite = wrapper<'assetFavorite'>(({ config, data }) => {
|
||||
const target = config.inverse ? false : true;
|
||||
if (target !== data.asset.isFavorite) {
|
||||
return {
|
||||
changes: {
|
||||
asset: { isFavorite: target },
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const assetVisibility = wrapper<'assetVisibility'>(({ config }) => ({
|
||||
changes: { asset: { visibility: config.visibility as AssetVisibility } },
|
||||
}));
|
||||
|
||||
export const assetArchive = wrapper<'assetArchive'>(({ config, data }) => {
|
||||
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
|
||||
return { changes: { asset: { visibility: AssetVisibility.Archive } } };
|
||||
}
|
||||
|
||||
if (config.inverse && data.asset.visibility === AssetVisibility.Archive) {
|
||||
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
|
||||
}
|
||||
|
||||
return {};
|
||||
});
|
||||
|
||||
export const assetLock = wrapper<'assetLock'>(({ config, data }) => {
|
||||
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
|
||||
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
|
||||
}
|
||||
|
||||
if (config.inverse && data.asset.visibility === AssetVisibility.Locked) {
|
||||
return { changes: { asset: { visibility: AssetVisibility.Timeline } } };
|
||||
}
|
||||
|
||||
return {};
|
||||
});
|
||||
return {};
|
||||
});
|
||||
};
|
||||
|
||||
// export const assetTrash = () => {
|
||||
// // TODO use trash/untrash host functions
|
||||
// return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
|
||||
// };
|
||||
|
||||
export const assetAddToAlbums = wrapper<'assetAddToAlbums'>(({ config, data, functions }) => {
|
||||
const assetId = data.asset.id;
|
||||
export const assetAddToAlbums = () => {
|
||||
return wrapper<'assetAddToAlbums'>(({ config, data, functions }) => {
|
||||
const assetId = data.asset.id;
|
||||
|
||||
if (config.albumIds.length === 0) {
|
||||
if (!config.albumName) {
|
||||
if (config.albumIds.length === 0) {
|
||||
if (!config.albumName) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const [existing] = functions.searchAlbums({ name: config.albumName });
|
||||
if (!existing) {
|
||||
const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] });
|
||||
config.albumIds.push(created.id);
|
||||
return {};
|
||||
}
|
||||
|
||||
config.albumIds.push(existing.id);
|
||||
}
|
||||
|
||||
if (config.albumIds.length === 1) {
|
||||
functions.addAssetsToAlbum(config.albumIds[0], [assetId]);
|
||||
return {};
|
||||
}
|
||||
|
||||
const [existing] = functions.searchAlbums({ name: config.albumName });
|
||||
if (!existing) {
|
||||
const created = functions.createAlbum({ albumName: config.albumName, assetIds: [assetId] });
|
||||
config.albumIds.push(created.id);
|
||||
return {};
|
||||
}
|
||||
|
||||
config.albumIds.push(existing.id);
|
||||
}
|
||||
|
||||
if (config.albumIds.length === 1) {
|
||||
functions.addAssetsToAlbum(config.albumIds[0], [assetId]);
|
||||
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] });
|
||||
return {};
|
||||
}
|
||||
|
||||
functions.addAssetsToAlbums({ albumIds: config.albumIds, assetIds: [assetId] });
|
||||
return {};
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"skipLibCheck": true, // Skip type checking of declaration files
|
||||
"strict": true, // Enable all strict type-checking options
|
||||
"target": "es2020", // Specify ECMAScript target version
|
||||
"types": ["./dist/index.d.ts", "./node_modules/@extism/js-pdk"] // Specify a list of type definition files to be included in the compilation
|
||||
"types": ["./src/index.d.ts", "./node_modules/@extism/js-pdk"] // Specify a list of type definition files to be included in the compilation
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules" // Exclude the node_modules directory
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import esbuild from 'esbuild';
|
||||
|
||||
esbuild.build({
|
||||
entryPoints: ['src/index.ts', 'src/cli.ts'],
|
||||
entryPoints: ['src/index.ts'],
|
||||
outdir: 'dist',
|
||||
bundle: true,
|
||||
sourcemap: false,
|
||||
minify: false,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
target: ['es2020'],
|
||||
});
|
||||
|
||||
@@ -21,9 +21,6 @@
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"bin": {
|
||||
"plugin-sdk": "./plugin-sdk.mjs"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
@@ -38,8 +35,5 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@extism/js-pdk": "^1.1.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^15.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
import "./dist/cli.js";
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Command } from 'commander';
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { availableFunctions } from 'src/host-functions.js';
|
||||
|
||||
const program = new Command('plugin-sdk');
|
||||
|
||||
program
|
||||
.command('prepareBuild')
|
||||
.description('Generate .d.ts file required for extism')
|
||||
.argument(
|
||||
'[manifest]',
|
||||
"Path to the plugins's manifest file",
|
||||
'manifest.json',
|
||||
)
|
||||
.option('-o --output', 'Output file for generated types', 'dist/index.d.ts')
|
||||
.action((manifest: string, { output }) => {
|
||||
const content = readFileSync(manifest, { encoding: 'utf-8' });
|
||||
const methods = (
|
||||
JSON.parse(content) as { methods: { name: string }[] }
|
||||
).methods.map(({ name }) => name);
|
||||
mkdirSync(dirname(output), { recursive: true });
|
||||
|
||||
writeFileSync(
|
||||
output,
|
||||
`
|
||||
declare module 'extism:host' {
|
||||
interface user {
|
||||
${availableFunctions.map((functionName) => ` ${functionName}(ptr: PTR): I64;`).join('\n')}
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'main' {
|
||||
${methods.map((method) => ` export function ${method}(): I32;`).join('\n')}
|
||||
}
|
||||
|
||||
export type Manifest = ${content};
|
||||
|
||||
`,
|
||||
);
|
||||
});
|
||||
|
||||
program.parse();
|
||||
@@ -6,11 +6,14 @@ import {
|
||||
type CreateAlbumDto,
|
||||
} from '@immich/sdk';
|
||||
|
||||
// keep in sync with plugin-core/src/index.d.ts';
|
||||
declare module 'extism:host' {
|
||||
interface user extends Record<
|
||||
(typeof availableFunctions)[number],
|
||||
(ptr: PTR) => I64
|
||||
> {}
|
||||
interface user {
|
||||
searchAlbums(ptr: PTR): I64;
|
||||
createAlbum(ptr: PTR): I64;
|
||||
addAssetsToAlbum(ptr: PTR): I64;
|
||||
addAssetsToAlbums(ptr: PTR): I64;
|
||||
}
|
||||
}
|
||||
|
||||
type AlbumsToAssets = {
|
||||
@@ -31,13 +34,6 @@ type HostFunctionResult<T> =
|
||||
type QueryParams<T extends (...args: any) => any> = Parameters<T>[0];
|
||||
type AlbumSearchDto = QueryParams<typeof getAllAlbums>;
|
||||
|
||||
export const availableFunctions = [
|
||||
'searchAlbums',
|
||||
'createAlbum',
|
||||
'addAssetsToAlbum',
|
||||
'addAssetsToAlbums',
|
||||
] as const;
|
||||
|
||||
export const hostFunctions = (authToken: string) => {
|
||||
const host = Host.getFunctions();
|
||||
type HostFunctionName = keyof typeof host;
|
||||
@@ -79,5 +75,5 @@ export const hostFunctions = (authToken: string) => {
|
||||
),
|
||||
addAssetsToAlbums: ({ assetIds, albumIds }: AlbumsToAssets) =>
|
||||
call('addAssetsToAlbums', authToken, [{ albumIds, assetIds }]),
|
||||
} satisfies Record<(typeof availableFunctions)[number], unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -67,8 +67,7 @@ export const getWrapper =
|
||||
functions: ReturnType<typeof hostFunctions>;
|
||||
},
|
||||
) => WorkflowResponse<L> | undefined,
|
||||
) =>
|
||||
() => {
|
||||
) => {
|
||||
const input = Host.inputString();
|
||||
|
||||
try {
|
||||
|
||||
Generated
-4
@@ -336,10 +336,6 @@ importers:
|
||||
version: 6.0.3
|
||||
|
||||
packages/plugin-sdk:
|
||||
dependencies:
|
||||
commander:
|
||||
specifier: ^15.0.0
|
||||
version: 15.0.0
|
||||
devDependencies:
|
||||
'@extism/js-pdk':
|
||||
specifier: ^1.1.1
|
||||
|
||||
Reference in New Issue
Block a user