Closed nidbCN closed 6 days ago
I decided to do something with the source code and to find the reason or provide some clues.
In the log I find the output from libjxl said 'JXL_FAILURE: Invalid transform ID', it seems happend during handleGeneratePreview
and logs before the function throw exceptions, so I go the source code to find which function called the libjxl to decode.
After view the source code and log message I noticed that after upload, immich use mediaRepository.generateThumbnail
and this function call sharp.toFile
, but the library sharp DOSE NOT SUPPORT JXL files. But I still can't find which function called libjxl and what arguments were passed.
Then I add a console.log
to source code of sharp and re-generate preview, I found JXL_FAILURE: Invalid transform ID
happen after sharp, I'll check the source code of library sharp.
It seems the problem is not from immich
UPDATE: use libjxl 0.9 and rebuild docker images solve the problem. I built a image for personally useage: dockerhub
I can't verify if the problem cause by sharp and not by immich. It require me build my own libvips and I don't know what's in immich's version.
For temporary use and experiment, I write a patch and try use ffmpeg to convert jxl instead of sharp. I share it here maybe it will be helpful for someone. But it not a good solution and I'll continuing find why JXLDecode failed.
**NOTICE: THIS SOLUTION IS ONLY FOR EXPERIMENT AND NOT USE THIS IN PRODUCTION ENVIRONMENT**
./patch/patch.js
:
"use strict";
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
var MediaRepository_1;
Object.defineProperty(exports, "__esModule", { value: true });
exports.MediaRepository = void 0;
// start patch
const process = require("child_process");
// end patch
const common_1 = require("@nestjs/common");
const exiftool_vendored_1 = require("exiftool-vendored");
const fluent_ffmpeg_1 = __importDefault(require("fluent-ffmpeg"));
const promises_1 = __importDefault(require("node:fs/promises"));
const node_util_1 = require("node:util");
const sharp_1 = __importDefault(require("sharp"));
const config_1 = require("../config");
const logger_interface_1 = require("../interfaces/logger.interface");
const instrumentation_1 = require("../utils/instrumentation");
const misc_1 = require("../utils/misc");
const probe = (0, node_util_1.promisify)(fluent_ffmpeg_1.default.ffprobe);
sharp_1.default.concurrency(0);
sharp_1.default.cache({ files: 0 });
let MediaRepository = MediaRepository_1 = class MediaRepository {
logger;
constructor(logger) {
this.logger = logger;
this.logger.setContext(MediaRepository_1.name);
}
async extract(input, output) {
try {
await exiftool_vendored_1.exiftool.extractJpgFromRaw(input, output);
}
catch (error) {
this.logger.debug('Could not extract JPEG from image, trying preview', error.message);
try {
await exiftool_vendored_1.exiftool.extractPreview(input, output);
}
catch (error) {
this.logger.debug('Could not extract preview from image', error.message);
return false;
}
}
return true;
}
async generateThumbnail(input, output, options) {
// start patch
if (input.toLowerCase().endsWith(".jxl") && options.format == "webp") {
console.warn("Running patch script for jxl.");
const { stdout, stderr } = await process.exec(`/usr/src/app/jxl_ffmpeg -hide_banner -i "/usr/src/app/${input}" -vf scale="-1:${options.size}" -quality ${options.quality} "/usr/src/app/${output}"`);
if (!stderr) {
console.info("Patch run success, jxl has convert to webp.");
} else {
console.error(stderr)
}
//./jxl_ffmpeg -i upload/library/a085767f-89b2-4f0a-94b0-72c819103657/2024-09-04/ffmpeg.jxl -vf scale="-1:1440" -quality 80 upload/thumbs/a085767f-89b2-4f0a-94b0-72c819103657/fa/63/fa639e52-a8c1-468e-aa63-089cf9c7c733-preview.webp
} else {
// end patch
const pipeline = (0, sharp_1.default)(input, { failOn: options.processInvalidImages ? 'none' : 'error', limitInputPixels: false })
.pipelineColorspace(options.colorspace === config_1.Colorspace.SRGB ? 'srgb' : 'rgb16')
.rotate();
if (options.crop) {
pipeline.extract(options.crop);
}
await pipeline
.resize(options.size, options.size, { fit: 'outside', withoutEnlargement: true })
.withIccProfile(options.colorspace)
.toFormat(options.format, {
quality: options.quality,
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
})
.toFile(output);
}
// start patch
}
// end patch
async probe(input) {
const results = await probe(input);
return {
format: {
formatName: results.format.format_name,
formatLongName: results.format.format_long_name,
duration: results.format.duration || 0,
bitrate: results.format.bit_rate ?? 0,
},
videoStreams: results.streams
.filter((stream) => stream.codec_type === 'video')
.filter((stream) => !stream.disposition?.attached_pic)
.map((stream) => ({
index: stream.index,
height: stream.height || 0,
width: stream.width || 0,
codecName: stream.codec_name === 'h265' ? 'hevc' : stream.codec_name,
codecType: stream.codec_type,
frameCount: Number.parseInt(stream.nb_frames ?? '0'),
rotation: Number.parseInt(`${stream.rotation ?? 0}`),
isHDR: stream.color_transfer === 'smpte2084' || stream.color_transfer === 'arib-std-b67',
bitrate: Number.parseInt(stream.bit_rate ?? '0'),
})),
audioStreams: results.streams
.filter((stream) => stream.codec_type === 'audio')
.map((stream) => ({
index: stream.index,
codecType: stream.codec_type,
codecName: stream.codec_name,
frameCount: Number.parseInt(stream.nb_frames ?? '0'),
})),
};
}
transcode(input, output, options) {
if (!options.twoPass) {
return new Promise((resolve, reject) => {
this.configureFfmpegCall(input, output, options)
.on('error', reject)
.on('end', () => resolve())
.run();
});
}
if (typeof output !== 'string') {
throw new TypeError('Two-pass transcoding does not support writing to a stream');
}
return new Promise((resolve, reject) => {
this.configureFfmpegCall(input, '/dev/null', options)
.addOptions('-pass', '1')
.addOptions('-passlogfile', output)
.addOptions('-f null')
.on('error', reject)
.on('end', () => {
this.configureFfmpegCall(input, output, options)
.addOptions('-pass', '2')
.addOptions('-passlogfile', output)
.on('error', reject)
.on('end', () => (0, misc_1.handlePromiseError)(promises_1.default.unlink(`${output}-0.log`), this.logger))
.on('end', () => (0, misc_1.handlePromiseError)(promises_1.default.rm(`${output}-0.log.mbtree`, { force: true }), this.logger))
.on('end', () => resolve())
.run();
})
.run();
});
}
async generateThumbhash(imagePath) {
console.log(imagePath);
const maxSize = 100;
const { data, info } = await (0, sharp_1.default)(imagePath)
.resize(maxSize, maxSize, { fit: 'inside', withoutEnlargement: true })
.raw()
.ensureAlpha()
.toBuffer({ resolveWithObject: true });
const thumbhash = await import('thumbhash');
return Buffer.from(thumbhash.rgbaToThumbHash(info.width, info.height, data));
}
async getImageDimensions(input) {
const { width = 0, height = 0 } = await (0, sharp_1.default)(input).metadata();
return { width, height };
}
configureFfmpegCall(input, output, options) {
return (0, fluent_ffmpeg_1.default)(input, { niceness: 10 })
.inputOptions(options.inputOptions)
.outputOptions(options.outputOptions)
.output(output)
.on('error', (error, stdout, stderr) => this.logger.error(stderr || error));
}
};
exports.MediaRepository = MediaRepository;
exports.MediaRepository = MediaRepository = MediaRepository_1 = __decorate([
(0, instrumentation_1.Instrumentation)(),
(0, common_1.Injectable)(),
__param(0, (0, common_1.Inject)(logger_interface_1.ILoggerRepository)),
__metadata("design:paramtypes", [Object])
], MediaRepository);
docker-compose.yml
# ...
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
extends:
file: hwaccel.transcoding.yml
service: quicksync # set to one of [nvenc, quicksync, rkmpp, vaapi, vaapi-wsl] for accelerated transcoding
volumes:
# Do not edit the next line. If you want to change the media storage location on your system, edit the value of UPLOAD_LOCATION in the .env file
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
- ./patch/ffmpeg:/usr/src/app/jxl_ffmpeg
- ./patch/patch.js:/usr/src/app/dist/repositories/media.repository.js
env_file:
- .env
# ...
and put a ffmpeg bin that support libjxl and libwebp to path folder.
The version of libjxl in the container is probably too old (v0.7.0) for these JXL files.
The version of libjxl in the container is probably too old (v0.7.0) for these JXL files.
I use the libjxl offical tools djxl(v0.7.0) and it can correct decode the jxl files to png.
If there's another function that cause the decode failed, what should I do to upgrade the libjxl? Thanks a lot.
Hmm, interesting that it worked with djxl 0.7.0. I think that makes it more likely that it's a libvips issue. It's also possible that libvips is only tested against a newer version of libjxl. You can make an issue about it and provide a sample image to see what they suggest.
Other than that, if you'd like to try building immich and libvips with a newer version of libjxl:
unstable
section and change libjxl0.7
to libjxl0.9
docker build . -t base-dev --target=dev
and docker build . -t base-prod
base-dev
and base-prod
make dev-update
or make prod
in the root immich folder to bring the server upOther than that, if you'd like to try building immich and libvips with a newer version of libjxl:
- Clone the immich and base-images repos and follow the setup instructions (except that you don't need to start the server yet)
- In the base image Dockerfile, move libjxl-dev and libjxl0.7 to the
unstable
section and changelibjxl0.7
tolibjxl0.9
- Build the image with
docker build . -t base-dev --target=dev
anddocker build . -t base-prod
- In the server Dockerfile, change the dev and prod images to
base-dev
andbase-prod
.- Run either
make dev-update
ormake prod
to bring the server up- Try uploading a JXL image to this immich instance
Ok I'll try that. I personally has try built a libvips of new version but I can't confirm which the libvips that immich use. Now according your reply it seems I just need to change the new libjxl.
Thanks for your help.
Other than that, if you'd like to try building immich and libvips with a newer version of libjxl:
- Clone the immich and base-images repos and follow the setup instructions (except that you don't need to start the server yet)
- In the base image Dockerfile, move libjxl-dev and libjxl0.7 to the
unstable
section and changelibjxl0.7
tolibjxl0.9
- Build the image with
docker build . -t base-dev --target=dev
anddocker build . -t base-prod
- In the server Dockerfile, change the dev and prod images to extend from
base-dev
andbase-prod
- Run either
make dev-update
ormake prod
in the root immich folder to bring the server up- Try uploading a JXL image to this immich instance
After upgrade libjxl to 0.9 and re-build, immich can successfully generate preview for jxl images export by Adobe ACR.
The images in this table all can be process correctly.
exif | qulity | color deepth |
---|---|---|
all | lossless | 16bit |
copyright only | 80 | 16bit |
copyright only | 80 | 8bit |
One more question. I'd like to know why immich offical use libjxl 0.7 and not upgrade to 0.9, is there any stability issue?
Thanks for your help a lot.
Glad to hear it's working! There's no particular stability reason for using 0.7.0 - it's just Debian stable installs. We can change this in the base image.
Glad to hear it's working! There's no particular stability reason for using 0.7.0 - it's just Debian stable installs. We can change this in the base image.
Should I create a pull request in base image repo or just wait immich's next version?
Feel free to make a PR for it! Except that it should use testing
instead of unstable
(latest commit in main adds testing
).
Feel free to make a PR for it! Except that it should use
testing
instead ofunstable
(latest commit in main addstesting
).
copy that, thanks for your help again
The bug
The immich server can't process Adobe ACR generated JXL picture. Here are the arguments that I export it:
The export arguments are very normal, I set a sRGB color range, 8bit deepth, high quelity output and keep another things default. I turn on verbose logs and put it in this issue. I also try a JXL picture with lossless output and the resule was same as the loss one. The picture that I uploaded can be normally display on my PC with ImageGlass and can be process in ffmpeg with libjxl. And the archive I attached to this issue pic.zip is the picture that failed to generate preview.
The OS that Immich Server is running on
Ubuntu 24.04 with docker compose
Version of Immich Server
v1.111.0
Version of Immich Mobile App
-
Platform with the issue
Your docker-compose.yml content
Your .env content
Reproduction steps
Relevant log output
Additional information
No response