Thumbs: Enhance embedding of ICC profiles based on InteropIndex #5178

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-11-23 12:56:58 +01:00
parent ab2ba2e72a
commit 19f083c719
56 changed files with 570 additions and 102 deletions

View File

@@ -1,6 +1,6 @@
# PhotoPrism® Repository Guidelines # PhotoPrism® Repository Guidelines
**Last Updated:** November 21, 2025 **Last Updated:** November 23, 2025
## Purpose ## Purpose
@@ -212,6 +212,7 @@ Note: Across our public documentation, official images, and in production, the c
## Code Style & Lint ## Code Style & Lint
- Go: run `make fmt-go swag-fmt` to reformat the backend code + Swagger annotations (see `Makefile` for additional targets) - Go: run `make fmt-go swag-fmt` to reformat the backend code + Swagger annotations (see `Makefile` for additional targets)
- Run `make lint-go` (golangci-lint) after Go changes; prefer `golangci-lint run ./internal/<pkg>/...` for focused edits.
- Doc comments for packages and exported identifiers must be complete sentences that begin with the name of the thing being described and end with a period. - Doc comments for packages and exported identifiers must be complete sentences that begin with the name of the thing being described and end with a period.
- All newly added functions, including unexported helpers, must have a concise doc comment that explains their behavior. - All newly added functions, including unexported helpers, must have a concise doc comment that explains their behavior.
- For short examples inside comments, indent code rather than using backticks; godoc treats indented blocks as preformatted. - For short examples inside comments, indent code rather than using backticks; godoc treats indented blocks as preformatted.

View File

@@ -1,54 +1,122 @@
================================================================================
================================================================================
Files: compatibleWithAdobeRGB1998.icc Third-Party ICC Profiles for PhotoPrism
Source: Debian icc-profiles-free
https://salsa.debian.org/debian/icc-profiles-free/-/tree/a7a3c11b8a6d3bc2937447183b87dc89de9d2388/icc-profiles-openicc/default_profiles/base --------------------------------------------------------------------------------
Copyright: Kai-Uwe Behrmann <www.behrmann.name>
Marti Maria <www.littlecms.com> The following 3rd-party ICC profiles may be used by or distributed with
Photogamut <www.photogamut.org> PhotoPrism. Any information relevant to third-party vendors listed below are
Graeme Gill <www.argyllcms.com> collected using common, reasonable means.
ColorSolutions <www.basICColor.com>
License: Zlib Date generated: 2025-11-23
--------------------------------------------------------------------------------
Files: a98.icc (compatibleWithAdobeRGB1998.icc)
Source: OpenICC "compatibleWithAdobeRGB1998.icc" via Debian icc-profiles-free
URL: https://salsa.debian.org/debian/icc-profiles-free/-/blob/a7a3c11b8a6d3bc2937447183b87dc89de9d2388/icc-profiles-openicc/default_profiles/base/compatibleWithAdobeRGB1998.icc
License: zlib/libpng
Checksum (md5): 826a1e13374e3dc34f9872f31ec028c8
The zlib/libpng License
Copyright (c) Graeme Gill <graeme@argyllcms.com>
This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages
arising from the use of this software.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
License: Zlib
The zlib/libpng License
.
This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages
arising from the use of this software.
.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
.
1. The origin of this software must not be misrepresented; you must not 1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software claim that you wrote the original software. If you use this software
in a product, an acknowledgment in the product documentation would be in a product, an acknowledgment in the product documentation would be
appreciated but is not required. appreciated but is not required.
.
2. Altered source versions must be plainly marked as such, and must not be 2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software. misrepresented as being the original software.
.
3. This notice may not be removed or altered from any source 3. This notice may not be removed or altered from any source
distribution. distribution.
.
NO WARRANTY The provided ICC Profiles in the package are called DATA in the following
. statement:
BECAUSE THE DATA IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE DATA, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN NO WARRANTY
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE DATA "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED BECAUSE THE DATA IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF FOR THE DATA, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
TO THE QUALITY AND PERFORMANCE OF THE DATA IS WITH YOU. SHOULD THE PROVIDE THE DATA "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
DATA PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
REPAIR OR CORRECTION. MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
. TO THE QUALITY AND PERFORMANCE OF THE DATA IS WITH YOU. SHOULD THE
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING DATA PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REPAIR OR CORRECTION.
REDISTRIBUTE THE DATA AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
OUT OF THE USE OR INABILITY TO USE THE DATA (INCLUDING BUT NOT LIMITED WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY REDISTRIBUTE THE DATA AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
YOU OR THIRD PARTIES OR A FAILURE OF THE DATA TO OPERATE WITH ANY OTHER INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE OUT OF THE USE OR INABILITY TO USE THE DATA (INCLUDING BUT NOT LIMITED
POSSIBILITY OF SUCH DAMAGES. TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE DATA TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
--------------------------------------------------------------------------------
Files: adobecompat-v2.icc, adobecompat-v4.icc, applecompat-v2.icc, applecompat-v4.icc, cgats001compat-v2-micro.icc, colormatchcompat-v2.icc, colormatchcompat-v4.icc, dci-p3-v4.icc, displayp3-v2-magic.icc, displayp3-v2-micro.icc, displayp3-v4.icc, displayp3compat-v2-magic.icc, displayp3compat-v2-micro.icc, displayp3compat-v4.icc, prophoto-v2-magic.icc, prophoto-v2-micro.icc, prophoto-v4.icc, rec2020-g24-v4.icc, rec2020-v2-magic.icc, rec2020-v2-micro.icc, rec2020-v4.icc, rec2020compat-v2-magic.icc, rec2020compat-v2-micro.icc, rec2020compat-v4.icc, rec601ntsc-v2-magic.icc, rec601ntsc-v2-micro.icc, rec601ntsc-v4.icc, rec601pal-v2-magic.icc, rec601pal-v2-micro.icc, rec601pal-v4.icc, rec709-v2-magic.icc, rec709-v2-micro.icc, rec709-v4.icc, scrgb-v2.icc, sgrey-v2-magic.icc, sgrey-v2-micro.icc, sgrey-v2-nano.icc, sgrey-v4.icc, srgb-v2-magic.icc, srgb-v2-micro.icc, srgb-v2-nano.icc, srgb-v4.icc, widegamutcompat-v2.icc, widegamutcompat-v4.icc
Source: Compact-ICC-Profiles via Debian icc-profiles-free
URL: https://salsa.debian.org/debian/icc-profiles-free/-/tree/a7a3c11b8a6d3bc2937447183b87dc89de9d2388/Compact-ICC-Profiles/profiles
License: CC0-1.0 (Public Domain Dedication)
Checksums (md5):
adobecompat-v2.icc 08220aa4b4e4259ec3c446a35197d89b
adobecompat-v4.icc fbf912760a8d14e496ff389c29c3132d
applecompat-v2.icc 21453c734d9364abceacc7ab837019ec
applecompat-v4.icc fc399558e27a0d53748820cff2a98a2b
cgats001compat-v2-micro.icc 56a85233ee08fa7527875be36cf426d6
colormatchcompat-v2.icc 9f2a755b4b3069f46f4eaef11e24926d
colormatchcompat-v4.icc 9e119efb0abaa31e955f8a30eeabc58c
dci-p3-v4.icc bdedf9e7ad0b93ed8f85f8c3ebdc4223
displayp3-v2-magic.icc 6748fcfd56d38770a02c023bbd6d0529
displayp3-v2-micro.icc 2615293123ddc4366af3da39455c3d7a
displayp3-v4.icc 32dc35d6a113b86cbc31bd1281e3baed
displayp3compat-v2-magic.icc 05fb82a702e27438ecec47a2f120cdcd
displayp3compat-v2-micro.icc 2ef6c295ac5d05760c1a4cf1668951a3
displayp3compat-v4.icc c34c90451326b183916f05b3ae41d920
prophoto-v2-magic.icc 15a31d407cf35662fbe4513f6204bfdf
prophoto-v2-micro.icc 445c1a3f3f1a20aab68e76838d3ed334
prophoto-v4.icc 8f4523255234753cd2d3111d8f09b184
rec2020-g24-v4.icc 3c7afbfff612d10a10775c167d36b51e
rec2020-v2-magic.icc 3b5846fb69faa53ebdfd29061f17b0e3
rec2020-v2-micro.icc 13d1e96875c35f7d5ebaf0f6a3d16357
rec2020-v4.icc 297c644758a979abe62349ca1ff416d5
rec2020compat-v2-magic.icc 9aa85534e81c275bc0627badf157580a
rec2020compat-v2-micro.icc fa39fc95daece7dcaff18e7265082368
rec2020compat-v4.icc 66839229639bb55bc443b490fe302364
rec601ntsc-v2-magic.icc 53ee12707ac87a8e3b3452af4a325e29
rec601ntsc-v2-micro.icc af05afec2146917a67445ac6cb5ca61d
rec601ntsc-v4.icc cc57dd6fa3d6f08e43e0f70b5376c00a
rec601pal-v2-magic.icc f706fd528cedcd1c88dac50073112f94
rec601pal-v2-micro.icc d193e01949eb5347b0d16d2b3ccdabcf
rec601pal-v4.icc dd7cd61d6ee14a521c9fb5afa2803e8e
rec709-v2-magic.icc 47f09046656a2f0d66117a9c1b15e137
rec709-v2-micro.icc f91edc9f3ff1390c842bd9e8759688b0
rec709-v4.icc 0339e2a70940aefd9237311889d065e6
scrgb-v2.icc a841263101bdf48fb9b81486f5451f2d
sgrey-v2-magic.icc ef6221686b517e4665480639202dacd5
sgrey-v2-micro.icc ca08451dba57ca1e910330cda37515ad
sgrey-v2-nano.icc 57d72e3f6437d65c618ebcdb1f6fa1bd
sgrey-v4.icc b93b1e31e75243ea08fbdab9e82f7cbe
srgb-v2-magic.icc 5967f401f9a54913a283942710cef93c
srgb-v2-micro.icc e8de3a5b44d70610306b0a20225701d1
srgb-v2-nano.icc e060a57b7a057f7f0bdb859b68db60e9
srgb-v4.icc 3c6a277ddee033ad090ba22b8323dcaf
widegamutcompat-v2.icc 63c0165987ee94c8c7f2c68749e0418d
widegamutcompat-v4.icc 4ccb168ae2f7daa51d3cef7df6fa6f16
--------------------------------------------------------------------------------

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

65
internal/thumb/README.md Normal file
View File

@@ -0,0 +1,65 @@
## PhotoPrism — Thumbnails Package
**Last Updated:** November 23, 2025
### Overview
`internal/thumb` builds thumbnails with libvips, handling resize/crop options, color management, metadata stripping, and format export (JPEG/PNG). It is used by PhotoPrisms workers and CLI to generate cached thumbs consistently.
### Context & Constraints
- Uses libvips via govips; initialization is centralized in `VipsInit`.
- Works on files or in-memory buffers; writes outputs with `fs.ModeFile`.
- ICC handling: if a JPEG lacks an embedded profile but sets EXIF `InteroperabilityIndex` (`R03`/Adobe RGB, `R98`/sRGB, `THM`/thumbnail), we embed an Adobe-compatible profile; otherwise we leave color untouched.
- Metadata is removed from outputs to keep thumbs small.
### Goals
- Produce consistent thumbnails for all configured sizes and resample modes.
- Preserve color fidelity when cameras signal color space through EXIF interop tags.
- Keep error paths non-fatal: invalid sizes, missing files, or absent profiles should return errors (not panics).
### Non-Goals
- Serving or caching thumbnails (handled elsewhere).
- Full ICC workflow management; only minimal embedding for interop-index cases.
### Package Layout (Code Map)
- `vips.go` — main `Vips` entry: load, resize/crop, strip metadata, export.
- `vips_icc.go` — EXIF InteroperabilityIndex handling and ICC embedding.
- `icc.go` — lists bundled ICC filenames (`IccProfiles`) and `GetIccProfile` helper.
- `resample.go`, `sizes.go` — resample options and predefined sizes.
- `thumb.go` and helpers — naming, caching, file info.
- Tests live alongside sources (`*_test.go`, fixtures under `testdata/`).
### ICC & Interop Handling
- EXIF `InteroperabilityIndex` codes we honor (per EXIF TagNames and regex.info):
- `R03` → Adobe RGB (1998) compatible (`a98.icc`, etc.)
- `R98` → sRGB (assumed default; no embed)
- `THM` → Thumbnail (treated as sRGB; no embed)
- If an ICC profile already exists, we skip embedding.
- Test Files:
- `testdata/interop_index.jpg` — R03 interop tag, no ICC (expects Adobe profile embed).
- `testdata/interop_index_srgb_icc.jpg` — R03 tag with embedded ICC (must remain unchanged).
- `testdata/interop_index_r98.jpg` — R98 interop tag, no ICC (should stay sRGB without embedding).
- `testdata/interop_index_thm.jpg` — THM interop tag, no ICC (thumbnail; should remain unchanged).
- References:
- [EXIF TagNames (InteroperabilityIndex)](https://unpkg.com/exiftool-vendored.pl@10.50.0/bin/html/TagNames/EXIF.html)
- [Digital-Image Color Spaces: Recommendations and Links](https://regex.info/blog/photo-tech/color-spaces-page7)
### Tests
- Fast scoped: `go test ./internal/thumb -run 'Icc|Vips' -count=1`
- Full: `go test ./internal/thumb -count=1`
### Lint & Formatting
- Format: `make fmt-go`
- Lint: `make lint-go` or `golangci-lint run ./internal/thumb/...`
### Notes
- When adding ICC files, place them in `assets/profiles/icc/` and append to `IccProfiles`.
- Comments for exported identifiers must start with the identifier name (Go style).

View File

@@ -1,21 +1,189 @@
package thumb package thumb
import ( import (
"errors"
"fmt"
"os" "os"
"path" "path/filepath"
"strings"
) )
/* // Standard ICC profiles located in "assets/profiles/icc".
Possible TODO: move this into a shared pkg/ so non-thumb const (
consumers can also use it. However, it looks fiddly to hook that // IccAdobeRGBCompat is compatible with Adobe RGB (1998).
up to `assets`, so I'm punting on that for now. IccAdobeRGBCompat = "a98.icc"
*/
func MustGetAdobeRGB1998Path() string { // IccAdobeRGBCompatV2 is A98C (Adobe RGB 1998 compatible, ICC v2).
p := path.Join(IccProfilesPath, "adobe_rgb_compat.icc") IccAdobeRGBCompatV2 = "adobecompat-v2.icc"
_, err := os.Stat(p) // IccAdobeRGBCompatV4 is A98C (Adobe RGB 1998 compatible, ICC v4).
if err != nil { IccAdobeRGBCompatV4 = "adobecompat-v4.icc"
panic(err)
} // IccAppleCompatV2 is APLC (Apple Color Matching compatible, ICC v2).
return p IccAppleCompatV2 = "applecompat-v2.icc"
// IccAppleCompatV4 is APLC (Apple Color Matching compatible, ICC v4).
IccAppleCompatV4 = "applecompat-v4.icc"
// IccCgats001CompatV2Micro is uCMY (CGATS.001 compatible CMY, compact).
IccCgats001CompatV2Micro = "cgats001compat-v2-micro.icc"
// IccColorMatchCompatV2 is ACMC (ColorMatch RGB compatible, ICC v2).
IccColorMatchCompatV2 = "colormatchcompat-v2.icc"
// IccColorMatchCompatV4 is ACMC (ColorMatch RGB compatible, ICC v4).
IccColorMatchCompatV4 = "colormatchcompat-v4.icc"
// IccDciP3V4 is TP3 (DCIP3).
IccDciP3V4 = "dci-p3-v4.icc"
// IccDisplayP3V2Magic is sP3 (Display P3, ICC v2 magic).
IccDisplayP3V2Magic = "displayp3-v2-magic.icc"
// IccDisplayP3V2Micro is uP3 (Display P3, micro).
IccDisplayP3V2Micro = "displayp3-v2-micro.icc"
// IccDisplayP3V4 is sP3 (Display P3, ICC v4).
IccDisplayP3V4 = "displayp3-v4.icc"
// IccDisplayP3CompatV2Magic is sP3C (Display P3 compatible, ICC v2 magic).
IccDisplayP3CompatV2Magic = "displayp3compat-v2-magic.icc"
// IccDisplayP3CompatV2Micro is uP3C (Display P3 compatible, micro).
IccDisplayP3CompatV2Micro = "displayp3compat-v2-micro.icc"
// IccDisplayP3CompatV4 is sP3C (Display P3 compatible, ICC v4).
IccDisplayP3CompatV4 = "displayp3compat-v4.icc"
// IccProPhotoV2Magic is uROM (ProPhoto RGB compact).
IccProPhotoV2Magic = "prophoto-v2-magic.icc"
// IccProPhotoV2Micro is uROM (ProPhoto RGB micro).
IccProPhotoV2Micro = "prophoto-v2-micro.icc"
// IccProPhotoV4 is ROMM (ProPhoto/ROMM RGB, ICC v4).
IccProPhotoV4 = "prophoto-v4.icc"
// IccRec2020Gamma24V4 is 2024 (Rec.2020 gamma 2.4, ICC v4).
IccRec2020Gamma24V4 = "rec2020-g24-v4.icc"
// IccRec2020V2Magic is 2020 (Rec.2020, ICC v2 magic).
IccRec2020V2Magic = "rec2020-v2-magic.icc"
// IccRec2020V2Micro is u202 (Rec.2020 micro).
IccRec2020V2Micro = "rec2020-v2-micro.icc"
// IccRec2020V4 is 2020 (Rec.2020, ICC v4).
IccRec2020V4 = "rec2020-v4.icc"
// IccRec2020CompatV2Magic is 202C (Rec.2020 compatible, ICC v2 magic).
IccRec2020CompatV2Magic = "rec2020compat-v2-magic.icc"
// IccRec2020CompatV2Micro is u20C (Rec.2020 compatible, micro).
IccRec2020CompatV2Micro = "rec2020compat-v2-micro.icc"
// IccRec2020CompatV4 is 202C (Rec.2020 compatible, ICC v4).
IccRec2020CompatV4 = "rec2020compat-v4.icc"
// IccRec601NtscV2Magic is R601 (Rec.601 NTSC, ICC v2 magic).
IccRec601NtscV2Magic = "rec601ntsc-v2-magic.icc"
// IccRec601NtscV2Micro is u601 (Rec.601 NTSC, micro).
IccRec601NtscV2Micro = "rec601ntsc-v2-micro.icc"
// IccRec601NtscV4 is R601 (Rec.601 NTSC, ICC v4).
IccRec601NtscV4 = "rec601ntsc-v4.icc"
// IccRec601PalV2Magic is 601P (Rec.601 PAL, ICC v2 magic).
IccRec601PalV2Magic = "rec601pal-v2-magic.icc"
// IccRec601PalV2Micro is u60P (Rec.601 PAL, micro).
IccRec601PalV2Micro = "rec601pal-v2-micro.icc"
// IccRec601PalV4 is 601P (Rec.601 PAL, ICC v4).
IccRec601PalV4 = "rec601pal-v4.icc"
// IccRec709V2Magic is R709 (Rec.709, ICC v2 magic).
IccRec709V2Magic = "rec709-v2-magic.icc"
// IccRec709V2Micro is u709 (Rec.709, micro).
IccRec709V2Micro = "rec709-v2-micro.icc"
// IccRec709V4 is R709 (Rec.709, ICC v4).
IccRec709V4 = "rec709-v4.icc"
// IccScRgbV2 is cRGB (scRGB, ICC v2).
IccScRgbV2 = "scrgb-v2.icc"
// IccSGreyV2Magic is sGry (Display P3 compatible gray, ICC v2 magic).
IccSGreyV2Magic = "sgrey-v2-magic.icc"
// IccSGreyV2Micro is uGry (Display P3 compatible gray, micro).
IccSGreyV2Micro = "sgrey-v2-micro.icc"
// IccSGreyV2Nano is nGry (Display P3 compatible gray, nano).
IccSGreyV2Nano = "sgrey-v2-nano.icc"
// IccSGreyV4 is sGry (Display P3 compatible gray, ICC v4).
IccSGreyV4 = "sgrey-v4.icc"
// IccSRgbV2Magic is sRGB (standard sRGB, ICC v2 magic).
IccSRgbV2Magic = "srgb-v2-magic.icc"
// IccSRgbV2Micro is uRGB (sRGB micro).
IccSRgbV2Micro = "srgb-v2-micro.icc"
// IccSRgbV2Nano is nRGB (sRGB nano).
IccSRgbV2Nano = "srgb-v2-nano.icc"
// IccSRgbV4 is sRGB (standard sRGB, ICC v4).
IccSRgbV4 = "srgb-v4.icc"
// IccWideGamutCompatV2 is AWGC (Adobe Wide Gamut compatible, ICC v2).
IccWideGamutCompatV2 = "widegamutcompat-v2.icc"
// IccWideGamutCompatV4 is AWGC (Adobe Wide Gamut compatible, ICC v4).
IccWideGamutCompatV4 = "widegamutcompat-v4.icc"
)
// IccProfiles lists all bundled ICC profile filenames in one place so tests and
// callers can iterate or validate the full set shipped in assets/profiles/icc.
var IccProfiles = []string{
IccAdobeRGBCompat,
IccAdobeRGBCompatV2,
IccAdobeRGBCompatV4,
IccAppleCompatV2,
IccAppleCompatV4,
IccCgats001CompatV2Micro,
IccColorMatchCompatV2,
IccColorMatchCompatV4,
IccDciP3V4,
IccDisplayP3V2Magic,
IccDisplayP3V2Micro,
IccDisplayP3V4,
IccDisplayP3CompatV2Magic,
IccDisplayP3CompatV2Micro,
IccDisplayP3CompatV4,
IccProPhotoV2Magic,
IccProPhotoV2Micro,
IccProPhotoV4,
IccRec2020Gamma24V4,
IccRec2020V2Magic,
IccRec2020V2Micro,
IccRec2020V4,
IccRec2020CompatV2Magic,
IccRec2020CompatV2Micro,
IccRec2020CompatV4,
IccRec601NtscV2Magic,
IccRec601NtscV2Micro,
IccRec601NtscV4,
IccRec601PalV2Magic,
IccRec601PalV2Micro,
IccRec601PalV4,
IccRec709V2Magic,
IccRec709V2Micro,
IccRec709V4,
IccScRgbV2,
IccSGreyV2Magic,
IccSGreyV2Micro,
IccSGreyV2Nano,
IccSGreyV4,
IccSRgbV2Magic,
IccSRgbV2Micro,
IccSRgbV2Nano,
IccSRgbV4,
IccWideGamutCompatV2,
IccWideGamutCompatV4,
}
// GetIccProfile returns the absolute path to the first requested ICC profile
// that is present in assets/profiles/icc. It validates existence so callers
// can embed profiles without risking a panic or missing file error.
func GetIccProfile(profiles ...string) (string, error) {
if len(profiles) == 0 {
return "", errors.New("no icc profiles specified")
}
// Find first ICC profile file that exists.
for _, p := range profiles {
filePath := filepath.Join(IccProfilesPath, p)
if info, err := os.Stat(filePath); err == nil && !info.IsDir() {
return filePath, nil
}
}
return "", fmt.Errorf("no matching icc profiles found (%s)", strings.Join(profiles, ", "))
} }

View File

@@ -0,0 +1,55 @@
package thumb
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestGetIccProfile(t *testing.T) {
t.Run("Exists", func(t *testing.T) {
profilePath, err := GetIccProfile(IccAdobeRGBCompat)
require.NoError(t, err)
assert.True(t, fs.FileExists(profilePath))
})
t.Run("Missing", func(t *testing.T) {
originalPath := IccProfilesPath
missingDir := t.TempDir()
IccProfilesPath = missingDir
t.Cleanup(func() {
IccProfilesPath = originalPath
})
profilePath, err := GetIccProfile(IccAdobeRGBCompat)
assert.Error(t, err)
assert.Empty(t, profilePath)
})
}
func TestIccProfiles(t *testing.T) {
for _, profile := range IccProfiles {
t.Run(profile, func(t *testing.T) {
path, err := GetIccProfile(profile)
require.NoError(t, err)
require.True(t, fs.FileExists(path))
//nolint:gosec // test-only: path is constrained to known profile files in assets
data, err := os.ReadFile(path)
require.NoError(t, err)
if len(data) < 40 {
t.Fatalf("profile %s too small to contain ICC header", profile)
}
assert.Equal(t, []byte{'a', 'c', 's', 'p'}, data[36:40], "profile %s must contain ICC signature", profile)
})
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

View File

@@ -75,6 +75,8 @@ func Vips(imageName string, imageBuffer []byte, hash, thumbPath string, width, h
case ResampleFillCenter, ResampleResize: case ResampleFillCenter, ResampleResize:
crop = vips.InterestingCentre crop = vips.InterestingCentre
size = vips.SizeBoth size = vips.SizeBoth
default:
// Use defaults.
} }
if err = vipsSetIccProfileForInteropIndex(img, clean.Log(filepath.Base(imageName))); err != nil { if err = vipsSetIccProfileForInteropIndex(img, clean.Log(filepath.Base(imageName))); err != nil {
@@ -161,47 +163,3 @@ func VipsJpegExportParams(width, height int) *vips.JpegExportParams {
return params return params
} }
func vipsSetIccProfileForInteropIndex(img *vips.ImageRef, logName string) error {
// Many cameras will define a JPEG's colour space by setting the InteroperabilityIndex
// tag instead of embedding an inline ICC profile.
// We detect this and embed explicit icc profiles for thumbs of such images, for the benefit of Vips
// and web browsers, none of which pay any attention to the InteropIndex tag.
iiFull := img.GetString("exif-ifd4-InteroperabilityIndex")
if iiFull == "" {
return nil
}
// according to my reading, I think [:4] should be e.g. "R98\x00".
// However, vips always returns [:4] = "R98 ", e.g. space instead of null.
// I'm pulling [:3] instead to paper over this - the exif spec says "4 bytes
// incl null terminator" so I think this is safe.
ii := iiFull[:3]
log.Tracef("interopindex: %s read exif and got interopindex %s, %s", logName, ii, iiFull)
if img.HasICCProfile() {
log.Debugf("interopindex: %s has both an interop index tag and an embedded ICC profile. ignoring.", logName)
return nil
}
fallbackProfile := ""
switch ii {
case "R03":
// adobe rgb
fallbackProfile = MustGetAdobeRGB1998Path()
case "R98":
// srgb
// we could logically embed an srgb profile in the image here, but
// there's no value in doing so; everything assumes srgb anyway.
case "THM":
// a thumbnail file. I can't find a ref on what colour space
// this is, so I'm assuming without evidence that they are also srgb.
default:
log.Debugf("interopindex: %s has unknown interop index %s", logName, ii)
}
if fallbackProfile == "" {
return nil
}
return img.TransformICCProfileWithFallback(fallbackProfile, fallbackProfile) // icc profile gets embedded here
}

View File

@@ -0,0 +1,78 @@
package thumb
import (
"fmt"
"github.com/davidbyttow/govips/v2/vips"
)
// InteroperabilityIndex EXIF codes used by some cameras to hint at the color space.
// Sources: EXIF TagNames (R03=Adobe RGB, R98=sRGB, THM=thumbnail)
// https://unpkg.com/exiftool-vendored.pl@10.50.0/bin/html/TagNames/EXIF.html
// Additional context: https://regex.info/blog/photo-tech/color-spaces-page7
const (
// InteropIndexAdobeRGB is the EXIF code for Adobe RGB (1998) ("R03").
InteropIndexAdobeRGB = "R03"
// InteropIndexSRGB is the EXIF code for sRGB ("R98").
InteropIndexSRGB = "R98"
// InteropIndexThumb marks a thumbnail image; treated as sRGB ("THM").
InteropIndexThumb = "THM"
)
// vipsSetIccProfileForInteropIndex embeds an ICC profile when a JPEG declares
// its color space via the EXIF InteroperabilityIndex tag (e.g., "R03"/Adobe RGB)
// but lacks an embedded profile. If an ICC profile is already present, it
// leaves the image untouched.
func vipsSetIccProfileForInteropIndex(img *vips.ImageRef, logName string) (err error) {
// Some cameras signal color space via EXIF InteroperabilityIndex instead of
// embedding an ICC profile. Browsers and libvips ignore this tag, so we
// inject a matching ICC profile to produce correct thumbnails.
iiFull := img.GetString("exif-ifd4-InteroperabilityIndex")
if iiFull == "" {
return nil
}
// EXIF InteroperabilityIndex is 4 bytes including null; libvips returns
// a string with a trailing space. Using the first three bytes covers the
// meaningful code (e.g., "R03", "R98").
if len(iiFull) < 3 {
log.Debugf("interopindex: %s has unexpected interop index %q", logName, iiFull)
return nil
}
ii := iiFull[:3]
log.Tracef("interopindex: %s read exif and got interopindex %s, %s", logName, ii, iiFull)
if img.HasICCProfile() {
log.Debugf("interopindex: %s already has an embedded ICC profile; skipping fallback.", logName)
return nil
}
profilePath := ""
switch ii {
case InteropIndexAdobeRGB:
// Use Adobe RGB 1998 compatible profile.
profilePath, err = GetIccProfile(IccAdobeRGBCompat, IccAdobeRGBCompatV2, IccAdobeRGBCompatV4)
if err != nil {
return fmt.Errorf("interopindex %s: %w", ii, err)
}
case InteropIndexSRGB:
// sRGB: browsers and libvips assume sRGB by default, so no embed needed.
case InteropIndexThumb:
// Thumbnail file; specification unclear—treat as sRGB and do nothing.
default:
log.Debugf("interopindex: %s has unknown interop index %s", logName, ii)
}
if profilePath == "" {
return nil
}
// Embed ICC profile. govips expects both an input (fallback) and output
// profile; using the same path injects the chosen profile when none is
// embedded and keeps colors consistent otherwise.
return img.TransformICCProfileWithFallback(profilePath, profilePath)
}

View File

@@ -0,0 +1,75 @@
package thumb
import (
"testing"
"github.com/davidbyttow/govips/v2/vips"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestVipsSetIccProfileForInteropIndex(t *testing.T) {
t.Run("PreservesExistingProfile", func(t *testing.T) {
VipsInit()
img, err := vips.LoadImageFromFile("testdata/interop_index_srgb_icc.jpg", VipsImportParams())
require.NoError(t, err)
iiFull := img.GetString("exif-ifd4-InteroperabilityIndex")
require.NotEmpty(t, iiFull)
require.True(t, img.HasICCProfile())
originalProfile := img.GetICCProfile()
require.NotEmpty(t, originalProfile)
err = vipsSetIccProfileForInteropIndex(img, "interop_index_srgb_icc.jpg")
assert.NoError(t, err)
assert.True(t, img.HasICCProfile())
assert.Equal(t, originalProfile, img.GetICCProfile())
})
t.Run("EmbedsAdobeProfileWhenMissing", func(t *testing.T) {
VipsInit()
img, err := vips.LoadImageFromFile("testdata/interop_index.jpg", VipsImportParams())
require.NoError(t, err)
require.False(t, img.HasICCProfile(), "fixture should have no embedded ICC profile")
err = vipsSetIccProfileForInteropIndex(img, "interop_index.jpg")
assert.NoError(t, err)
assert.True(t, img.HasICCProfile(), "Adobe ICC profile should be embedded based on InteropIndex R03")
assert.NotEmpty(t, img.GetICCProfile())
})
t.Run("NoInteropIndexNoop", func(t *testing.T) {
VipsInit()
img, err := vips.LoadImageFromFile("testdata/example.jpg", VipsImportParams())
require.NoError(t, err)
hasICCBefore := img.HasICCProfile()
err = vipsSetIccProfileForInteropIndex(img, "example.jpg")
assert.NoError(t, err)
assert.Equal(t, hasICCBefore, img.HasICCProfile())
})
t.Run("InteropIndexSRGB_NoEmbed", func(t *testing.T) {
VipsInit()
img, err := vips.LoadImageFromFile("testdata/interop_index_r98.jpg", VipsImportParams())
require.NoError(t, err)
require.False(t, img.HasICCProfile(), "fixture should have no embedded ICC profile")
err = vipsSetIccProfileForInteropIndex(img, "interop_index_r98.jpg")
assert.NoError(t, err)
assert.False(t, img.HasICCProfile(), "sRGB interop index should remain without embedded ICC")
})
t.Run("InteropIndexThumb_NoEmbed", func(t *testing.T) {
VipsInit()
img, err := vips.LoadImageFromFile("testdata/interop_index_thm.jpg", VipsImportParams())
require.NoError(t, err)
require.False(t, img.HasICCProfile(), "fixture should have no embedded ICC profile")
err = vipsSetIccProfileForInteropIndex(img, "interop_index_thm.jpg")
assert.NoError(t, err)
assert.False(t, img.HasICCProfile(), "THM interop index should remain without embedded ICC")
})
}