From 38b50724f5070af495e721981580fcd5bbd407c8 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Tue, 30 Jun 2026 07:49:32 +0000 Subject: [PATCH] Convert: Prefer embedded preview over untrustworthy RAW render #5673 --- internal/photoprism/convert_command.go | 30 +++++- internal/photoprism/convert_command_test.go | 25 +++++ internal/photoprism/convert_image.go | 10 ++ internal/photoprism/convert_image_jpeg.go | 105 +++++++------------- internal/photoprism/convert_image_png.go | 8 +- internal/photoprism/convert_image_test.go | 72 ++++++++++++-- internal/raw/README.md | 54 ++++++++++ internal/raw/convert.go | 74 ++++++++++++++ internal/raw/convert_test.go | 64 ++++++++++++ internal/raw/errors.go | 35 +++++++ internal/raw/errors_test.go | 34 +++++++ internal/raw/preview.go | 13 +++ internal/raw/preview_test.go | 18 ++++ internal/raw/raw.go | 27 +++++ internal/thumb/testdata/corrupt-preview.jpg | Bin 0 -> 24534 bytes internal/thumb/verify.go | 15 ++- internal/thumb/verify_test.go | 7 +- 17 files changed, 505 insertions(+), 86 deletions(-) create mode 100644 internal/raw/README.md create mode 100644 internal/raw/convert.go create mode 100644 internal/raw/convert_test.go create mode 100644 internal/raw/errors.go create mode 100644 internal/raw/errors_test.go create mode 100644 internal/raw/preview.go create mode 100644 internal/raw/preview_test.go create mode 100644 internal/raw/raw.go create mode 100644 internal/thumb/testdata/corrupt-preview.jpg diff --git a/internal/photoprism/convert_command.go b/internal/photoprism/convert_command.go index e9679b2c6..186cb53c9 100644 --- a/internal/photoprism/convert_command.go +++ b/internal/photoprism/convert_command.go @@ -2,6 +2,7 @@ package photoprism import ( "os/exec" + "strings" "github.com/photoprism/photoprism/pkg/media" ) @@ -9,9 +10,10 @@ import ( // ConvertCmd represents a command to be executed for converting a MediaFile. // including any options to be used for this. type ConvertCmd struct { - Cmd *exec.Cmd - Orientation media.Orientation - VerifyImage bool + Cmd *exec.Cmd + Orientation media.Orientation + VerifyImage bool + RejectStderr []string } // String returns the conversion command as string e.g. for logging. @@ -41,6 +43,28 @@ func (c *ConvertCmd) WithImageVerification() *ConvertCmd { return c } +// WithStderrRejection rejects the command's output when its stderr contains any of the given +// substrings, even on a zero exit code, so the loop tries the next converter. +func (c *ConvertCmd) WithStderrRejection(patterns ...string) *ConvertCmd { + c.RejectStderr = append(c.RejectStderr, patterns...) + return c +} + +// StderrRejected reports whether the given stderr output matches a rejection pattern. +func (c *ConvertCmd) StderrRejected(stderr string) bool { + if stderr == "" { + return false + } + + for _, pattern := range c.RejectStderr { + if pattern != "" && strings.Contains(stderr, pattern) { + return true + } + } + + return false +} + // NewConvertCmd returns a new file converter command with default options. func NewConvertCmd(cmd *exec.Cmd) *ConvertCmd { if cmd == nil { diff --git a/internal/photoprism/convert_command_test.go b/internal/photoprism/convert_command_test.go index ca8242795..b6077269e 100644 --- a/internal/photoprism/convert_command_test.go +++ b/internal/photoprism/convert_command_test.go @@ -40,6 +40,31 @@ func TestNewConvertCmd(t *testing.T) { assert.Same(t, result, result.WithImageVerification()) assert.True(t, result.VerifyImage) }) + t.Run("WithStderrRejection", func(t *testing.T) { + result := NewConvertCmd( + exec.Command("rawtherapee-cli", "-o", "file.jpg", "-c", "file.cr3"), + ) + assert.Empty(t, result.RejectStderr) + assert.Same(t, result, result.WithStderrRejection("first error", "second error")) + assert.Equal(t, []string{"first error", "second error"}, result.RejectStderr) + }) +} + +func TestConvertCmd_StderrRejected(t *testing.T) { + cmd := NewConvertCmd(exec.Command("rawtherapee-cli", "-c", "file.cr3")).WithStderrRejection("Cannot use camera white balance") + t.Run("Match", func(t *testing.T) { + assert.True(t, cmd.StderrRejected("Processing...\nCannot use camera white balance.\n")) + }) + t.Run("NoMatch", func(t *testing.T) { + assert.False(t, cmd.StderrRejected("Warning: sidecar file requested but not found for: file.cr3")) + }) + t.Run("EmptyStderr", func(t *testing.T) { + assert.False(t, cmd.StderrRejected("")) + }) + t.Run("NoPatterns", func(t *testing.T) { + plain := NewConvertCmd(exec.Command("darktable-cli", "file.cr3", "file.jpg")) + assert.False(t, plain.StderrRejected("Cannot use camera white balance.")) + }) } func TestNewConvertCmds(t *testing.T) { diff --git a/internal/photoprism/convert_image.go b/internal/photoprism/convert_image.go index 516bb422c..2f25f753f 100644 --- a/internal/photoprism/convert_image.go +++ b/internal/photoprism/convert_image.go @@ -190,6 +190,16 @@ func (w *Convert) ToImage(f *MediaFile, force bool) (result *MediaFile, err erro continue } + // Reject output flagged untrustworthy by its stderr (e.g. a RawTherapee render of an + // unsupported sensor) so the loop falls back to the next converter. + if c.StderrRejected(stderr.String()) { + log.Debugf("convert: discarding %s from %s (untrustworthy output)", clean.Log(filepath.Base(imageName)), filepath.Base(cmd.Path)) + if removeErr := os.Remove(imageName); removeErr != nil && !os.IsNotExist(removeErr) { + log.Tracef("convert: %s (%s)", removeErr, filepath.Base(cmd.Path)) + } + continue + } + // Reject undecodable output (e.g. a truncated/bogus embedded RAW preview that passed // the MIME sniff) so the loop tries the next converter instead of indexing a file // whose thumbnails will fail. diff --git a/internal/photoprism/convert_image_jpeg.go b/internal/photoprism/convert_image_jpeg.go index 3e63e9768..3cb5f9341 100644 --- a/internal/photoprism/convert_image_jpeg.go +++ b/internal/photoprism/convert_image_jpeg.go @@ -8,6 +8,7 @@ import ( "github.com/photoprism/photoprism/internal/ffmpeg" "github.com/photoprism/photoprism/internal/ffmpeg/encode" + "github.com/photoprism/photoprism/internal/raw" ) // JpegConvertCmds returns the supported commands for converting a MediaFile to JPEG, sorted by priority. @@ -21,10 +22,11 @@ func (w *Convert) JpegConvertCmds(f *MediaFile, jpegName string, xmpName string) // Add suitable conversion commands depending on the file type, codec, runtime environment, and configuration. fileExt := f.Extension() maxSize := strconv.Itoa(w.conf.JpegSize()) + rawEnabled := w.conf.RawEnabled() // On a Mac, use the Apple Scriptable image processing system to convert images to JPEG, // see https://ss64.com/osx/sips.html. - if (f.IsRaw() || f.IsHeif()) && w.conf.SipsEnabled() && w.sipsExclude.Allow(fileExt) { + if (f.IsRaw() && rawEnabled || f.IsHeif()) && w.conf.SipsEnabled() && w.sipsExclude.Allow(fileExt) { result = append(result, NewConvertCmd( // #nosec G204 -- arguments are built from validated config and file paths. exec.Command(w.conf.SipsBin(), "-Z", maxSize, "-s", "format", "jpeg", "--out", jpegName, f.FileName())), @@ -47,82 +49,43 @@ func (w *Convert) JpegConvertCmds(f *MediaFile, jpegName string, xmpName string) ) } - // Convert RAW files to JPEG with Darktable and/or RawTherapee. - if f.IsRaw() && w.conf.RawEnabled() { - if w.conf.DarktableEnabled() && w.darktableExclude.Allow(fileExt) { - var args []string - - // Set RAW, XMP, and JPEG filenames. - if xmpName != "" { - args = []string{f.FileName(), xmpName, jpegName} - } else { - args = []string{f.FileName(), jpegName} + // Convert RAW files to JPEG: Darktable/RawTherapee render when RAW conversion is enabled, while + // ExifTool preview extraction also runs when rendering is disabled, so existing previews stay usable. + if f.IsRaw() { + if rawEnabled { + if w.conf.DarktableEnabled() && w.darktableExclude.Allow(fileExt) { + cmd, mutex := raw.DarktableCmd(raw.DarktableOptions{ + Bin: w.conf.DarktableBin(), + RawName: f.FileName(), + XmpName: xmpName, + JpegName: jpegName, + MaxSize: w.conf.JpegSize(), + Presets: w.conf.RawPresets(), + ConfigDir: conf.DarktableConfigPath(), + CacheDir: conf.DarktableCachePath(), + }) + useMutex = mutex + result = append(result, NewConvertCmd(cmd)) } - // Set RAW to JPEG conversion options. - if w.conf.RawPresets() { - useMutex = true // can run one instance only with presets enabled - args = append(args, "--width", maxSize, "--height", maxSize, "--hq", "true", "--upscale", "false") - } else { - useMutex = false // --apply-custom-presets=false disables locking - args = append(args, "--apply-custom-presets", "false", "--width", maxSize, "--height", maxSize, "--hq", "true", "--upscale", "false") + // Render the RAW with RawTherapee when Darktable is unavailable or fails. Output is + // rejected on an untrustworthy decode (raw.DecoderErrors) so the embedded preview wins. + if w.conf.RawTherapeeEnabled() && w.rawTherapeeExclude.Allow(fileExt) { + profile := filepath.Join(conf.AssetsPath(), "profiles", "raw.pp3") + + result = append(result, NewConvertCmd( + raw.TherapeeCmd(w.conf.RawTherapeeBin(), f.FileName(), jpegName, profile, int(w.conf.JpegQuality())), + ).WithStderrRejection(raw.DecoderErrors...)) } - - // Set library, config, and cache location. - args = append(args, "--core", "--library", ":memory:") - - if dir := conf.DarktableConfigPath(); dir != "" { - args = append(args, "--configdir", dir) - } - - if dir := conf.DarktableCachePath(); dir != "" { - args = append(args, "--cachedir", dir) - } - - result = append(result, NewConvertCmd( - // #nosec G204 -- arguments are built from validated config and file paths. - exec.Command(w.conf.DarktableBin(), args...)), - ) } - // Extract the largest embedded JPEG preview with ExifTool as a fallback before - // RawTherapee. The camera-rendered preview always has correct colors, unlike a - // generic demosaic of a sensor the RAW decoder cannot identify (e.g. very recent - // Canon CR3 bodies, which otherwise come out magenta). It only wins when Darktable - // is unavailable or fails, so supported cameras keep their full RAW rendering. - // JpgFromRaw is the full-resolution image and PreviewImage the smaller fallback; - // listed largest-first so the convert loop prefers the bigger one. - if w.conf.ExifToolEnabled() { - result = append(result, NewConvertCmd( - // #nosec G204 -- arguments are built from validated config and file paths. - exec.Command(w.conf.ExifToolBin(), "-q", "-q", "-b", "-JpgFromRaw", f.FileName())).WithImageVerification(), - ) - result = append(result, NewConvertCmd( - // #nosec G204 -- arguments are built from validated config and file paths. - exec.Command(w.conf.ExifToolBin(), "-q", "-q", "-b", "-PreviewImage", f.FileName())).WithImageVerification(), - ) + // Extract the embedded camera preview (largest first): the fallback after the RAW developers, + // and the only option when RAW rendering is disabled. Colors stay correct for sensors they + // cannot identify (recent Canon CR3 bodies otherwise come out magenta). Skipped if unusable. + if w.conf.ExifToolEnabled() && raw.PreviewExtAllowed(fileExt) { + result = append(result, NewConvertCmd(raw.ExifToolJpgFromRawCmd(w.conf.ExifToolBin(), f.FileName())).WithImageVerification()) + result = append(result, NewConvertCmd(raw.ExifToolPreviewImageCmd(w.conf.ExifToolBin(), f.FileName())).WithImageVerification()) } - - if w.conf.RawTherapeeEnabled() && w.rawTherapeeExclude.Allow(fileExt) { - jpegQuality := fmt.Sprintf("-j%d", w.conf.JpegQuality()) - profile := filepath.Join(conf.AssetsPath(), "profiles", "raw.pp3") - - args := []string{"-o", jpegName, "-p", profile, "-s", "-d", jpegQuality, "-js3", "-b8", "-c", f.FileName()} - - result = append(result, NewConvertCmd( - // #nosec G204 -- arguments are built from validated config and file paths. - exec.Command(w.conf.RawTherapeeBin(), args...)), - ) - } - } - - // Use ExifTool to extract JPEG thumbnails from Digital Negative (DNG) files. - if f.IsDng() && w.conf.ExifToolEnabled() { - // Example: exiftool -b -PreviewImage -w IMG_4691.DNG.jpg IMG_4691.DNG - result = append(result, NewConvertCmd( - // #nosec G204 -- arguments are built from validated config and file paths. - exec.Command(w.conf.ExifToolBin(), "-q", "-q", "-b", "-PreviewImage", f.FileName())).WithImageVerification(), - ) } // Use "djxl" to convert JPEG XL images as a fallback when libvips lacks native support. diff --git a/internal/photoprism/convert_image_png.go b/internal/photoprism/convert_image_png.go index 81213613c..c61b575be 100644 --- a/internal/photoprism/convert_image_png.go +++ b/internal/photoprism/convert_image_png.go @@ -21,9 +21,15 @@ func (w *Convert) PngConvertCmds(f *MediaFile, pngName string) (result ConvertCm fileExt := f.Extension() maxSize := strconv.Itoa(w.conf.PngSize()) + // Unlike JpegConvertCmds, this builder does not have a Darktable/RawTherapee renderer or an embedded preview + // fallback for converting RAW images to PNG — see internal/raw/README.md for more information. + if f.IsRaw() && w.conf.RawEnabled() { + log.Debugf("convert: PNG is not a supported target format for %s files", f.FileType().ToUpper()) + } + // On a Mac, use the Apple Scriptable image processing system to convert images to PNG, // see https://ss64.com/osx/sips.html. - if (f.IsRaw() || f.IsHeif()) && w.conf.SipsEnabled() && w.sipsExclude.Allow(fileExt) { + if f.IsHeif() && w.conf.SipsEnabled() && w.sipsExclude.Allow(fileExt) { result = append(result, NewConvertCmd( // #nosec G204 -- arguments are built from validated config and file paths. exec.Command(w.conf.SipsBin(), "-Z", maxSize, "-s", "format", "png", "--out", pngName, f.FileName())), diff --git a/internal/photoprism/convert_image_test.go b/internal/photoprism/convert_image_test.go index 1ace874a6..025361f1d 100644 --- a/internal/photoprism/convert_image_test.go +++ b/internal/photoprism/convert_image_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/internal/raw" "github.com/photoprism/photoprism/internal/thumb" "github.com/photoprism/photoprism/pkg/fs" ) @@ -341,9 +342,9 @@ func TestConvert_JpegConvertCmds(t *testing.T) { } // TestConvert_JpegConvertCmds_RawEmbeddedPreview verifies that RAW inputs emit -// ExifTool embedded-preview extraction commands (largest-first) ordered after -// Darktable and before RawTherapee, so an unsupported camera falls back to the -// camera-rendered JPEG instead of a wrong-color demosaic. +// ExifTool embedded-preview extraction commands (largest-first) ordered after the +// RAW developers (Darktable and RawTherapee), so an unsupported camera falls back +// to the camera-rendered JPEG instead of a wrong-color demosaic. func TestConvert_JpegConvertCmds_RawEmbeddedPreview(t *testing.T) { cnf := config.TestConfig() @@ -367,9 +368,9 @@ func TestConvert_JpegConvertCmds_RawEmbeddedPreview(t *testing.T) { assert.NotEmpty(t, cmds) - // Record the first occurrence of each command; DNG inputs additionally emit a - // trailing -PreviewImage extraction, which is not the priority position. + // Record the position of each command in the priority-ordered list. jpgFromRaw, previewImage, rawTherapee := -1, -1, -1 + var rawTherapeeCmd *ConvertCmd for i, cmd := range cmds { s := cmd.String() switch { @@ -379,6 +380,7 @@ func TestConvert_JpegConvertCmds_RawEmbeddedPreview(t *testing.T) { previewImage = i case rawTherapee < 0 && strings.Contains(s, filepath.Base(cnf.RawTherapeeBin())): rawTherapee = i + rawTherapeeCmd = cmd } } @@ -387,10 +389,56 @@ func TestConvert_JpegConvertCmds_RawEmbeddedPreview(t *testing.T) { assert.Less(t, jpgFromRaw, previewImage, "JpgFromRaw must be tried before PreviewImage") if cnf.RawTherapeeEnabled() { assert.GreaterOrEqual(t, rawTherapee, 0, "expected a RawTherapee command") - assert.Less(t, previewImage, rawTherapee, "embedded preview must be tried before RawTherapee") + assert.Less(t, rawTherapee, jpgFromRaw, "RawTherapee must be tried before the embedded preview") + assert.Contains(t, rawTherapeeCmd.RejectStderr, raw.WhiteBalanceError, "RawTherapee output must be rejected on a white-balance failure") } } +// TestConvert_JpegConvertCmds_RawDisabled verifies that with RAW conversion disabled (--disable-raw) +// PhotoPrism only extracts an existing embedded preview and never renders the RAW with Darktable or +// RawTherapee. +func TestConvert_JpegConvertCmds_RawDisabled(t *testing.T) { + cnf := config.TestConfig() + + if !cnf.ExifToolEnabled() { + t.Skip("ExifTool must be available for the RAW embedded-preview fallback") + } + + origRaw := cnf.Options().DisableRaw + cnf.Options().DisableRaw = true + t.Cleanup(func() { + cnf.Options().DisableRaw = origRaw + }) + + convert := NewConvert(cnf) + rawFile := filepath.Join(cnf.SamplesPath(), "canon_eos_6d.dng") + jpegFile := filepath.Join(cnf.SamplesPath(), "canon_eos_6d.dng.jpg") + + mediaFile, err := NewMediaFile(rawFile) + if err != nil { + t.Fatal(err) + } + + cmds, _, err := convert.JpegConvertCmds(mediaFile, jpegFile, "") + if err != nil { + t.Fatal(err) + } + + assert.NotEmpty(t, cmds) + + previewImage := false + for _, cmd := range cmds { + base := filepath.Base(cmd.Cmd.Path) + assert.NotEqual(t, "darktable-cli", base, "Darktable must not run when RAW conversion is disabled") + assert.NotEqual(t, "rawtherapee-cli", base, "RawTherapee must not run when RAW conversion is disabled") + if strings.Contains(cmd.String(), "-PreviewImage") { + previewImage = true + } + } + + assert.True(t, previewImage, "embedded preview must still be extracted when RAW conversion is disabled") +} + // TestConvert_JpegConvertCmds_HeifFallback verifies that the documented external // fallback command is emitted for HEIC and AVIF inputs when libheif tooling // (heif-dec / heif-convert) is available — see issue #5509. @@ -516,4 +564,16 @@ func TestConvert_PngConvertCmds(t *testing.T) { t.Logf("commands: %#v", cmds) }) + t.Run("Raw", func(t *testing.T) { + // RAW is converted to JPEG, not PNG: no converter command is emitted and the call reports + // the format as unsupported (see internal/raw/README.md). + mediaFile, err := NewMediaFile(filepath.Join(cnf.SamplesPath(), "canon_eos_6d.dng")) + if err != nil { + t.Fatal(err) + } + + cmds, _, err := convert.PngConvertCmds(mediaFile, filepath.Join(t.TempDir(), "canon_eos_6d.png")) + assert.Error(t, err) + assert.Empty(t, cmds) + }) } diff --git a/internal/raw/README.md b/internal/raw/README.md new file mode 100644 index 000000000..526a372a7 --- /dev/null +++ b/internal/raw/README.md @@ -0,0 +1,54 @@ +## PhotoPrism — RAW Image Conversion + +**Last Updated:** June 30, 2026 + +### Overview + +`internal/raw` provides command builders and heuristics for converting camera RAW images (including Adobe Digital Negative, `.dng`) to JPEG with Darktable, RawTherapee, and ExifTool. It mirrors the `internal/ffmpeg` pattern: the builders return `*exec.Cmd` and the caller passes the binary path plus options, while the orchestration that runs the commands and accepts their output stays in `internal/photoprism` (`Convert.JpegConvertCmds` and the `ToImage` convert loop). + +The package owns the small amount of RAW-specific knowledge that would otherwise be scattered through the converter: the command flags, the stderr patterns that mark an untrustworthy decode, and the list of formats whose embedded preview is unusable. + +#### Builders + +- `DarktableCmd(DarktableOptions) (*exec.Cmd, bool)` — also reports whether the command needs a global mutex (presets mode runs one instance at a time). +- `TherapeeCmd(bin, rawName, jpegName, profile string, quality int) *exec.Cmd` — RawTherapee render to JPEG (named without the `Raw` prefix to avoid a `raw.RawTherapee…` stutter). +- `ExifToolJpgFromRawCmd` / `ExifToolPreviewImageCmd` — extract the full-resolution / smaller embedded preview to stdout. + +### Converter Priority + +For a RAW input, `JpegConvertCmds` appends commands in this order, and the convert loop accepts the first whose output passes: + +1. **Darktable** — full RAW developer (preferred). +2. **RawTherapee** — full RAW developer (when Darktable is unavailable or fails). +3. **Embedded camera preview** via ExifTool (largest first) — the last resort. + +The embedded preview is last because a full render is higher quality, but it has correct colors when the RAW developers cannot identify the sensor (e.g. very recent Canon CR3 bodies otherwise come out magenta), so it wins whenever the developers are unavailable or untrustworthy. + +### Conversion Gating (`--disable-raw`) + +`PHOTOPRISM_DISABLE_RAW` disables both **indexing** and **conversion** of RAW, via two independent gates: + +- **Indexing/import/upload** skip RAW entirely before it reaches the converter (`index.go`, `mediafile_related.go`, `import.go`, `users_upload.go`). +- **Conversion** gates only the RAW *renderers* (Darktable, RawTherapee, and the macOS `sips` path) on `RawEnabled()`. ExifTool preview extraction runs regardless, so an already-embedded preview can still be extracted when rendering is disabled (e.g. via the `convert` CLI). + +### Error Handling + +The three tools fail differently, so they are guarded differently: + +- **Darktable** signals an unsupported sensor with a **non-zero exit code** and writes no file (its diagnostics go to stdout, e.g. `[libraw_open] detected unsupported image`). The convert loop's exit-code check skips it, so no stderr inspection is needed. +- **RawTherapee** can **exit 0 yet produce wrong colors** (e.g. a default white balance for an unidentified sensor) while printing a dcraw-derived message to stderr. Its command is therefore tagged with `ConvertCmd.WithStderrRejection(raw.DecoderErrors...)`; a match discards the output and the loop falls through to the embedded preview. `WhiteBalanceError` is the canonical case; `DecoderErrors` adds the other untrustworthy-decode messages (sourced from `dcraw.c`). +- **Embedded previews** can be header-valid yet fail the thumbnailer (a bogus Huffman table only surfaces during shrink-on-load), so they are tagged `WithImageVerification`, which runs `thumb.Verify` to force the decode before acceptance. + +Because of this, `StderrRejected` is RawTherapee-specific by design — Darktable does not need it (it fails via exit code, not via accepted-but-wrong output). + +### Preview-Unsafe Formats + +`previewUnsafeExt` lists RAW extensions whose embedded JPEG preview is known to be unusable, exposed via `PreviewExtAllowed(ext)`. Currently `.mos` (Leaf), whose preview is a bogus-Huffman JPEG that passes a MIME sniff but fails thumbnailing; skipping it forces a full RAW render. + +### PNG Output (Deferred) + +RAW is not converted to PNG today: `ToImage` writes a `.jpg` sidecar for RAW, and `PngConvertCmds` has no RAW renderer at all (it logs and skips RAW). A lossless RAW export/preview (e.g. a future UI "export" tool) is the case that would need RAW→PNG. When that lands, build the PNG path to its own spec rather than copying `JpegConvertCmds`: it should render with Darktable/RawTherapee in **PNG mode** (RawTherapee needs `-n`/`-b16`, a new builder), and it should **not** use the embedded-JPEG preview fallback, which is lossy and would defeat a lossless export. + +### Known Gaps + +- RAW→PNG is unimplemented (see *PNG Output* above); `PngConvertCmds` logs and skips RAW. RAW rendering to JPEG (Darktable, RawTherapee, `sips`) is gated on `RawEnabled()`. diff --git a/internal/raw/convert.go b/internal/raw/convert.go new file mode 100644 index 000000000..c602cf54a --- /dev/null +++ b/internal/raw/convert.go @@ -0,0 +1,74 @@ +package raw + +import ( + "fmt" + "os/exec" + "strconv" +) + +// DarktableOptions configures a Darktable RAW to JPEG conversion command. +type DarktableOptions struct { + Bin string // darktable-cli binary path + RawName string // source RAW file name + XmpName string // optional XMP sidecar file name + JpegName string // destination JPEG file name + MaxSize int // maximum edge length in pixels + Presets bool // apply custom presets (requires a global mutex) + ConfigDir string // optional darktable config directory + CacheDir string // optional darktable cache directory +} + +// DarktableCmd builds the Darktable CLI command and reports whether it requires a global mutex. +// Presets mode can run only one instance at a time, so the caller must serialize those invocations. +func DarktableCmd(o DarktableOptions) (*exec.Cmd, bool) { + var args []string + + if o.XmpName != "" { + args = []string{o.RawName, o.XmpName, o.JpegName} + } else { + args = []string{o.RawName, o.JpegName} + } + + maxSize := strconv.Itoa(o.MaxSize) + + if o.Presets { + args = append(args, "--width", maxSize, "--height", maxSize, "--hq", "true", "--upscale", "false") + } else { + // --apply-custom-presets=false disables locking. + args = append(args, "--apply-custom-presets", "false", "--width", maxSize, "--height", maxSize, "--hq", "true", "--upscale", "false") + } + + args = append(args, "--core", "--library", ":memory:") + + if o.ConfigDir != "" { + args = append(args, "--configdir", o.ConfigDir) + } + + if o.CacheDir != "" { + args = append(args, "--cachedir", o.CacheDir) + } + + // #nosec G204 -- arguments are built from validated config and file paths. + return exec.Command(o.Bin, args...), o.Presets +} + +// TherapeeCmd builds the RawTherapee CLI command that renders a RAW file to JPEG. +func TherapeeCmd(bin, rawName, jpegName, profile string, quality int) *exec.Cmd { + jpegQuality := fmt.Sprintf("-j%d", quality) + args := []string{"-o", jpegName, "-p", profile, "-s", "-d", jpegQuality, "-js3", "-b8", "-c", rawName} + + // #nosec G204 -- arguments are built from validated config and file paths. + return exec.Command(bin, args...) +} + +// ExifToolJpgFromRawCmd builds the ExifTool command that extracts the full-resolution embedded preview to stdout. +func ExifToolJpgFromRawCmd(bin, rawName string) *exec.Cmd { + // #nosec G204 -- arguments are built from validated config and file paths. + return exec.Command(bin, "-q", "-q", "-b", "-JpgFromRaw", rawName) +} + +// ExifToolPreviewImageCmd builds the ExifTool command that extracts the smaller embedded preview to stdout. +func ExifToolPreviewImageCmd(bin, rawName string) *exec.Cmd { + // #nosec G204 -- arguments are built from validated config and file paths. + return exec.Command(bin, "-q", "-q", "-b", "-PreviewImage", rawName) +} diff --git a/internal/raw/convert_test.go b/internal/raw/convert_test.go new file mode 100644 index 000000000..cb60df7d0 --- /dev/null +++ b/internal/raw/convert_test.go @@ -0,0 +1,64 @@ +package raw + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDarktableCmd(t *testing.T) { + t.Run("Presets", func(t *testing.T) { + cmd, useMutex := DarktableCmd(DarktableOptions{ + Bin: "/usr/bin/darktable-cli", + RawName: "image.cr2", + JpegName: "image.cr2.jpg", + MaxSize: 7680, + Presets: true, + }) + assert.True(t, useMutex) + s := cmd.String() + assert.Contains(t, s, "/usr/bin/darktable-cli") + assert.Contains(t, s, "image.cr2 image.cr2.jpg") + assert.Contains(t, s, "--width 7680 --height 7680") + assert.NotContains(t, s, "--apply-custom-presets") + }) + t.Run("NoPresetsWithXmpAndDirs", func(t *testing.T) { + cmd, useMutex := DarktableCmd(DarktableOptions{ + Bin: "/usr/bin/darktable-cli", + RawName: "image.cr2", + XmpName: "image.cr2.xmp", + JpegName: "image.cr2.jpg", + MaxSize: 1920, + Presets: false, + ConfigDir: "/cfg", + CacheDir: "/cache", + }) + assert.False(t, useMutex) + s := cmd.String() + assert.Contains(t, s, "image.cr2 image.cr2.xmp image.cr2.jpg") + assert.Contains(t, s, "--apply-custom-presets false") + assert.Contains(t, s, "--configdir /cfg") + assert.Contains(t, s, "--cachedir /cache") + }) +} + +func TestTherapeeCmd(t *testing.T) { + cmd := TherapeeCmd("/usr/bin/rawtherapee-cli", "image.cr2", "image.cr2.jpg", "/assets/raw.pp3", 92) + s := cmd.String() + assert.Contains(t, s, "/usr/bin/rawtherapee-cli") + assert.Contains(t, s, "-o image.cr2.jpg") + assert.Contains(t, s, "-p /assets/raw.pp3") + assert.Contains(t, s, "-j92") + assert.Contains(t, s, "-c image.cr2") +} + +func TestExifToolJpgFromRawCmd(t *testing.T) { + cmd := ExifToolJpgFromRawCmd("/usr/bin/exiftool", "image.cr2") + assert.Equal(t, []string{"/usr/bin/exiftool", "-q", "-q", "-b", "-JpgFromRaw", "image.cr2"}, cmd.Args) +} + +func TestExifToolPreviewImageCmd(t *testing.T) { + cmd := ExifToolPreviewImageCmd("/usr/bin/exiftool", "image.cr2") + assert.True(t, strings.HasSuffix(cmd.String(), "-q -q -b -PreviewImage image.cr2")) +} diff --git a/internal/raw/errors.go b/internal/raw/errors.go new file mode 100644 index 000000000..4c2c250d3 --- /dev/null +++ b/internal/raw/errors.go @@ -0,0 +1,35 @@ +package raw + +import "strings" + +// WhiteBalanceError is the RawTherapee/dcraw stderr message indicating it could not read the camera +// white balance, so its colors are untrustworthy and the embedded camera preview is preferred. +const WhiteBalanceError = "Cannot use camera white balance" + +// DecoderErrors lists RawTherapee/dcraw stderr substrings that signal an untrustworthy render — +// wrong colors or a corrupt decode — so callers can fall back to the embedded camera preview. +// Sourced from dcraw.c; verbose progress lines are deliberately excluded. +var DecoderErrors = []string{ + WhiteBalanceError, // no camera white balance coefficients: defaults applied, wrong colors + "matrix not found", // color matrix missing: generic colors, wrong rendering + "Invalid white balance", // unknown white balance preset: wrong colors + "Corrupt data near", // corrupt raw stream: unreliable pixels + "Unexpected end of file", // truncated raw: unreliable pixels + "decoder table overflow", // Huffman decoder overflow: unreliable pixels + "incorrect JPEG dimensions", // lossless-JPEG raw mismatch: unreliable pixels +} + +// Untrustworthy reports whether RAW-decoder stderr output indicates the render cannot be trusted. +func Untrustworthy(stderr string) bool { + if stderr == "" { + return false + } + + for _, pattern := range DecoderErrors { + if strings.Contains(stderr, pattern) { + return true + } + } + + return false +} diff --git a/internal/raw/errors_test.go b/internal/raw/errors_test.go new file mode 100644 index 000000000..5930cba59 --- /dev/null +++ b/internal/raw/errors_test.go @@ -0,0 +1,34 @@ +package raw + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUntrustworthy(t *testing.T) { + t.Run("WhiteBalance", func(t *testing.T) { + assert.True(t, Untrustworthy("Processing...\nCannot use camera white balance.\n")) + }) + t.Run("MatrixNotFound", func(t *testing.T) { + assert.True(t, Untrustworthy(`image.x3f: "Foo" matrix not found!`)) + }) + t.Run("CorruptData", func(t *testing.T) { + assert.True(t, Untrustworthy("Corrupt data near 0x1234")) + }) + t.Run("BenignWarning", func(t *testing.T) { + assert.False(t, Untrustworthy("Warning: sidecar file requested but not found for: image.cr2")) + }) + t.Run("VerboseProgress", func(t *testing.T) { + assert.False(t, Untrustworthy("AHD interpolation...\nConverting to sRGB colorspace...\n")) + }) + t.Run("Empty", func(t *testing.T) { + assert.False(t, Untrustworthy("")) + }) +} + +func TestDecoderErrors(t *testing.T) { + assert.Equal(t, WhiteBalanceError, DecoderErrors[0]) + assert.Contains(t, DecoderErrors, "matrix not found") + assert.Contains(t, DecoderErrors, "incorrect JPEG dimensions") +} diff --git a/internal/raw/preview.go b/internal/raw/preview.go new file mode 100644 index 000000000..956fcc875 --- /dev/null +++ b/internal/raw/preview.go @@ -0,0 +1,13 @@ +package raw + +// previewUnsafeExt lists RAW extensions whose embedded JPEG preview is unusable (e.g. fails +// thumbnailing), so preview extraction is skipped in favor of a full RAW render. +var previewUnsafeExt = map[string]bool{ + ".mos": true, // Leaf .mos files ship a bogus-Huffman embedded preview. +} + +// PreviewExtAllowed reports whether the embedded preview may be extracted for the extension, +// which must be lowercase with a leading dot (e.g. ".cr3"). +func PreviewExtAllowed(ext string) bool { + return !previewUnsafeExt[ext] +} diff --git a/internal/raw/preview_test.go b/internal/raw/preview_test.go new file mode 100644 index 000000000..fb70f2263 --- /dev/null +++ b/internal/raw/preview_test.go @@ -0,0 +1,18 @@ +package raw + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPreviewExtAllowed(t *testing.T) { + t.Run("Allowed", func(t *testing.T) { + assert.True(t, PreviewExtAllowed(".cr3")) + assert.True(t, PreviewExtAllowed(".dng")) + assert.True(t, PreviewExtAllowed(".nef")) + }) + t.Run("Unsafe", func(t *testing.T) { + assert.False(t, PreviewExtAllowed(".mos")) + }) +} diff --git a/internal/raw/raw.go b/internal/raw/raw.go new file mode 100644 index 000000000..428a2657b --- /dev/null +++ b/internal/raw/raw.go @@ -0,0 +1,27 @@ +/* +Package raw provides command builders and helpers for converting camera RAW +images to JPEG with Darktable, RawTherapee, and ExifTool, including the +heuristics for detecting an untrustworthy decode and skipping unusable previews. + +Copyright (c) 2018 - 2026 PhotoPrism UG. All rights reserved. + + This program is free software: you can redistribute it and/or modify + it under Version 3 of the GNU Affero General Public License (the "AGPL"): + + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + The AGPL is supplemented by our Trademark and Brand Guidelines, + which describe how our Brand Assets may be used: + + +Feel free to send an email to hello@photoprism.app if you have questions, +want to support our work, or just want to say hello. + +Additional information can be found in our Developer Guide: + +*/ +package raw diff --git a/internal/thumb/testdata/corrupt-preview.jpg b/internal/thumb/testdata/corrupt-preview.jpg new file mode 100644 index 0000000000000000000000000000000000000000..8bb9bf923824a84a4dfc8e154e5cbf634a9e2f12 GIT binary patch literal 24534 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%0AGa^L4y?Xq3$!D`1$V7bvw_0t@TQydp7uSs_ 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 J8Wd2^|JmBh$gBVW literal 0 HcmV?d00001 diff --git a/internal/thumb/verify.go b/internal/thumb/verify.go index 344764261..86a8d8b78 100644 --- a/internal/thumb/verify.go +++ b/internal/thumb/verify.go @@ -6,15 +6,19 @@ import ( "github.com/davidbyttow/govips/v2/vips" ) +// VerifySize is the target edge length used to force a thumbnail decode during Verify; +// it matches the standard tile size whose center-crop decode rejects corrupt previews. +const VerifySize = 500 + // Verify reports an error if the image file cannot be decoded by the active rendering library. -// Used to reject corrupt converter output (e.g. a truncated embedded RAW preview that passes a +// Used to reject corrupt converter output (e.g. a bogus-Huffman embedded RAW preview that passes a // MIME sniff but later fails libvips) so the conversion loop can try the next converter. func Verify(fileName string) error { if fileName == "" { return fmt.Errorf("verify: empty filename") } - // Use the loader the thumbnailer will use so the check matches GenerateThumbnails. + // Run the same shrink-on-load path the thumbnailer uses so the check matches GenerateThumbnails. if Library == LibVips { VipsInit() @@ -23,9 +27,12 @@ func Verify(fileName string) error { return err } - img.Close() + defer img.Close() - return nil + // libvips loads JPEGs lazily, so force the decode now (it would otherwise fail later in + // GenerateThumbnails and mark the photo IndexFailed). Mirror the center-crop tile path, + // which rejects bogus-Huffman previews a plain fit resize can skip. + return img.ThumbnailWithSize(VerifySize, VerifySize, vips.InterestingCentre, vips.SizeBoth) } _, err := Open(fileName, 1) diff --git a/internal/thumb/verify_test.go b/internal/thumb/verify_test.go index 2d0e61d0f..75f89ad0d 100644 --- a/internal/thumb/verify_test.go +++ b/internal/thumb/verify_test.go @@ -20,9 +20,14 @@ func TestVerify(t *testing.T) { }) t.Run("NotAnImage", func(t *testing.T) { name := filepath.Join(t.TempDir(), "garbage.jpg") - assert.NoError(t, os.WriteFile(name, []byte("this is not a valid jpeg payload, only plain text"), 0o644)) + assert.NoError(t, os.WriteFile(name, []byte("this is not a valid jpeg payload, only plain text"), 0o600)) assert.Error(t, Verify(name)) }) + t.Run("CorruptPreviewLoadsButFailsThumbnail", func(t *testing.T) { + // A real Leaf .mos embedded preview: a header-valid JPEG that decodes as a full image + // but fails the thumbnailer's shrink-on-load with a bogus Huffman table. + assert.Error(t, Verify("testdata/corrupt-preview.jpg")) + }) t.Run("MissingFile", func(t *testing.T) { assert.Error(t, Verify(filepath.Join(t.TempDir(), "does-not-exist.jpg"))) })