Skip to content

Commit 38b5072

Browse files
committed
Convert: Prefer embedded preview over untrustworthy RAW render photoprism#5673
1 parent 9f3d958 commit 38b5072

17 files changed

Lines changed: 504 additions & 85 deletions

internal/photoprism/convert_command.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@ package photoprism
22

33
import (
44
"os/exec"
5+
"strings"
56

67
"github.com/photoprism/photoprism/pkg/media"
78
)
89

910
// ConvertCmd represents a command to be executed for converting a MediaFile.
1011
// including any options to be used for this.
1112
type ConvertCmd struct {
12-
Cmd *exec.Cmd
13-
Orientation media.Orientation
14-
VerifyImage bool
13+
Cmd *exec.Cmd
14+
Orientation media.Orientation
15+
VerifyImage bool
16+
RejectStderr []string
1517
}
1618

1719
// String returns the conversion command as string e.g. for logging.
@@ -41,6 +43,28 @@ func (c *ConvertCmd) WithImageVerification() *ConvertCmd {
4143
return c
4244
}
4345

46+
// WithStderrRejection rejects the command's output when its stderr contains any of the given
47+
// substrings, even on a zero exit code, so the loop tries the next converter.
48+
func (c *ConvertCmd) WithStderrRejection(patterns ...string) *ConvertCmd {
49+
c.RejectStderr = append(c.RejectStderr, patterns...)
50+
return c
51+
}
52+
53+
// StderrRejected reports whether the given stderr output matches a rejection pattern.
54+
func (c *ConvertCmd) StderrRejected(stderr string) bool {
55+
if stderr == "" {
56+
return false
57+
}
58+
59+
for _, pattern := range c.RejectStderr {
60+
if pattern != "" && strings.Contains(stderr, pattern) {
61+
return true
62+
}
63+
}
64+
65+
return false
66+
}
67+
4468
// NewConvertCmd returns a new file converter command with default options.
4569
func NewConvertCmd(cmd *exec.Cmd) *ConvertCmd {
4670
if cmd == nil {

internal/photoprism/convert_command_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,31 @@ func TestNewConvertCmd(t *testing.T) {
4040
assert.Same(t, result, result.WithImageVerification())
4141
assert.True(t, result.VerifyImage)
4242
})
43+
t.Run("WithStderrRejection", func(t *testing.T) {
44+
result := NewConvertCmd(
45+
exec.Command("rawtherapee-cli", "-o", "file.jpg", "-c", "file.cr3"),
46+
)
47+
assert.Empty(t, result.RejectStderr)
48+
assert.Same(t, result, result.WithStderrRejection("first error", "second error"))
49+
assert.Equal(t, []string{"first error", "second error"}, result.RejectStderr)
50+
})
51+
}
52+
53+
func TestConvertCmd_StderrRejected(t *testing.T) {
54+
cmd := NewConvertCmd(exec.Command("rawtherapee-cli", "-c", "file.cr3")).WithStderrRejection("Cannot use camera white balance")
55+
t.Run("Match", func(t *testing.T) {
56+
assert.True(t, cmd.StderrRejected("Processing...\nCannot use camera white balance.\n"))
57+
})
58+
t.Run("NoMatch", func(t *testing.T) {
59+
assert.False(t, cmd.StderrRejected("Warning: sidecar file requested but not found for: file.cr3"))
60+
})
61+
t.Run("EmptyStderr", func(t *testing.T) {
62+
assert.False(t, cmd.StderrRejected(""))
63+
})
64+
t.Run("NoPatterns", func(t *testing.T) {
65+
plain := NewConvertCmd(exec.Command("darktable-cli", "file.cr3", "file.jpg"))
66+
assert.False(t, plain.StderrRejected("Cannot use camera white balance."))
67+
})
4368
}
4469

4570
func TestNewConvertCmds(t *testing.T) {

internal/photoprism/convert_image.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,16 @@ func (w *Convert) ToImage(f *MediaFile, force bool) (result *MediaFile, err erro
190190
continue
191191
}
192192

193+
// Reject output flagged untrustworthy by its stderr (e.g. a RawTherapee render of an
194+
// unsupported sensor) so the loop falls back to the next converter.
195+
if c.StderrRejected(stderr.String()) {
196+
log.Debugf("convert: discarding %s from %s (untrustworthy output)", clean.Log(filepath.Base(imageName)), filepath.Base(cmd.Path))
197+
if removeErr := os.Remove(imageName); removeErr != nil && !os.IsNotExist(removeErr) {
198+
log.Tracef("convert: %s (%s)", removeErr, filepath.Base(cmd.Path))
199+
}
200+
continue
201+
}
202+
193203
// Reject undecodable output (e.g. a truncated/bogus embedded RAW preview that passed
194204
// the MIME sniff) so the loop tries the next converter instead of indexing a file
195205
// whose thumbnails will fail.

internal/photoprism/convert_image_jpeg.go

Lines changed: 33 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88

99
"github.com/photoprism/photoprism/internal/ffmpeg"
1010
"github.com/photoprism/photoprism/internal/ffmpeg/encode"
11+
"github.com/photoprism/photoprism/internal/raw"
1112
)
1213

1314
// 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)
2122
// Add suitable conversion commands depending on the file type, codec, runtime environment, and configuration.
2223
fileExt := f.Extension()
2324
maxSize := strconv.Itoa(w.conf.JpegSize())
25+
rawEnabled := w.conf.RawEnabled()
2426

2527
// On a Mac, use the Apple Scriptable image processing system to convert images to JPEG,
2628
// see https://ss64.com/osx/sips.html.
27-
if (f.IsRaw() || f.IsHeif()) && w.conf.SipsEnabled() && w.sipsExclude.Allow(fileExt) {
29+
if (f.IsRaw() && rawEnabled || f.IsHeif()) && w.conf.SipsEnabled() && w.sipsExclude.Allow(fileExt) {
2830
result = append(result, NewConvertCmd(
2931
// #nosec G204 -- arguments are built from validated config and file paths.
3032
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)
4749
)
4850
}
4951

50-
// Convert RAW files to JPEG with Darktable and/or RawTherapee.
51-
if f.IsRaw() && w.conf.RawEnabled() {
52-
if w.conf.DarktableEnabled() && w.darktableExclude.Allow(fileExt) {
53-
var args []string
54-
55-
// Set RAW, XMP, and JPEG filenames.
56-
if xmpName != "" {
57-
args = []string{f.FileName(), xmpName, jpegName}
58-
} else {
59-
args = []string{f.FileName(), jpegName}
60-
}
61-
62-
// Set RAW to JPEG conversion options.
63-
if w.conf.RawPresets() {
64-
useMutex = true // can run one instance only with presets enabled
65-
args = append(args, "--width", maxSize, "--height", maxSize, "--hq", "true", "--upscale", "false")
66-
} else {
67-
useMutex = false // --apply-custom-presets=false disables locking
68-
args = append(args, "--apply-custom-presets", "false", "--width", maxSize, "--height", maxSize, "--hq", "true", "--upscale", "false")
52+
// Convert RAW files to JPEG: Darktable/RawTherapee render when RAW conversion is enabled, while
53+
// ExifTool preview extraction also runs when rendering is disabled, so existing previews stay usable.
54+
if f.IsRaw() {
55+
if rawEnabled {
56+
if w.conf.DarktableEnabled() && w.darktableExclude.Allow(fileExt) {
57+
cmd, mutex := raw.DarktableCmd(raw.DarktableOptions{
58+
Bin: w.conf.DarktableBin(),
59+
RawName: f.FileName(),
60+
XmpName: xmpName,
61+
JpegName: jpegName,
62+
MaxSize: w.conf.JpegSize(),
63+
Presets: w.conf.RawPresets(),
64+
ConfigDir: conf.DarktableConfigPath(),
65+
CacheDir: conf.DarktableCachePath(),
66+
})
67+
useMutex = mutex
68+
result = append(result, NewConvertCmd(cmd))
6969
}
7070

71-
// Set library, config, and cache location.
72-
args = append(args, "--core", "--library", ":memory:")
71+
// Render the RAW with RawTherapee when Darktable is unavailable or fails. Output is
72+
// rejected on an untrustworthy decode (raw.DecoderErrors) so the embedded preview wins.
73+
if w.conf.RawTherapeeEnabled() && w.rawTherapeeExclude.Allow(fileExt) {
74+
profile := filepath.Join(conf.AssetsPath(), "profiles", "raw.pp3")
7375

74-
if dir := conf.DarktableConfigPath(); dir != "" {
75-
args = append(args, "--configdir", dir)
76+
result = append(result, NewConvertCmd(
77+
raw.TherapeeCmd(w.conf.RawTherapeeBin(), f.FileName(), jpegName, profile, int(w.conf.JpegQuality())),
78+
).WithStderrRejection(raw.DecoderErrors...))
7679
}
77-
78-
if dir := conf.DarktableCachePath(); dir != "" {
79-
args = append(args, "--cachedir", dir)
80-
}
81-
82-
result = append(result, NewConvertCmd(
83-
// #nosec G204 -- arguments are built from validated config and file paths.
84-
exec.Command(w.conf.DarktableBin(), args...)),
85-
)
8680
}
8781

88-
// Extract the largest embedded JPEG preview with ExifTool as a fallback before
89-
// RawTherapee. The camera-rendered preview always has correct colors, unlike a
90-
// generic demosaic of a sensor the RAW decoder cannot identify (e.g. very recent
91-
// Canon CR3 bodies, which otherwise come out magenta). It only wins when Darktable
92-
// is unavailable or fails, so supported cameras keep their full RAW rendering.
93-
// JpgFromRaw is the full-resolution image and PreviewImage the smaller fallback;
94-
// listed largest-first so the convert loop prefers the bigger one.
95-
if w.conf.ExifToolEnabled() {
96-
result = append(result, NewConvertCmd(
97-
// #nosec G204 -- arguments are built from validated config and file paths.
98-
exec.Command(w.conf.ExifToolBin(), "-q", "-q", "-b", "-JpgFromRaw", f.FileName())).WithImageVerification(),
99-
)
100-
result = append(result, NewConvertCmd(
101-
// #nosec G204 -- arguments are built from validated config and file paths.
102-
exec.Command(w.conf.ExifToolBin(), "-q", "-q", "-b", "-PreviewImage", f.FileName())).WithImageVerification(),
103-
)
82+
// Extract the embedded camera preview (largest first): the fallback after the RAW developers,
83+
// and the only option when RAW rendering is disabled. Colors stay correct for sensors they
84+
// cannot identify (recent Canon CR3 bodies otherwise come out magenta). Skipped if unusable.
85+
if w.conf.ExifToolEnabled() && raw.PreviewExtAllowed(fileExt) {
86+
result = append(result, NewConvertCmd(raw.ExifToolJpgFromRawCmd(w.conf.ExifToolBin(), f.FileName())).WithImageVerification())
87+
result = append(result, NewConvertCmd(raw.ExifToolPreviewImageCmd(w.conf.ExifToolBin(), f.FileName())).WithImageVerification())
10488
}
105-
106-
if w.conf.RawTherapeeEnabled() && w.rawTherapeeExclude.Allow(fileExt) {
107-
jpegQuality := fmt.Sprintf("-j%d", w.conf.JpegQuality())
108-
profile := filepath.Join(conf.AssetsPath(), "profiles", "raw.pp3")
109-
110-
args := []string{"-o", jpegName, "-p", profile, "-s", "-d", jpegQuality, "-js3", "-b8", "-c", f.FileName()}
111-
112-
result = append(result, NewConvertCmd(
113-
// #nosec G204 -- arguments are built from validated config and file paths.
114-
exec.Command(w.conf.RawTherapeeBin(), args...)),
115-
)
116-
}
117-
}
118-
119-
// Use ExifTool to extract JPEG thumbnails from Digital Negative (DNG) files.
120-
if f.IsDng() && w.conf.ExifToolEnabled() {
121-
// Example: exiftool -b -PreviewImage -w IMG_4691.DNG.jpg IMG_4691.DNG
122-
result = append(result, NewConvertCmd(
123-
// #nosec G204 -- arguments are built from validated config and file paths.
124-
exec.Command(w.conf.ExifToolBin(), "-q", "-q", "-b", "-PreviewImage", f.FileName())).WithImageVerification(),
125-
)
12689
}
12790

12891
// Use "djxl" to convert JPEG XL images as a fallback when libvips lacks native support.

internal/photoprism/convert_image_png.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,15 @@ func (w *Convert) PngConvertCmds(f *MediaFile, pngName string) (result ConvertCm
2121
fileExt := f.Extension()
2222
maxSize := strconv.Itoa(w.conf.PngSize())
2323

24+
// Unlike JpegConvertCmds, this builder does not have a Darktable/RawTherapee renderer or an embedded preview
25+
// fallback for converting RAW images to PNG — see internal/raw/README.md for more information.
26+
if f.IsRaw() && w.conf.RawEnabled() {
27+
log.Debugf("convert: PNG is not a supported target format for %s files", f.FileType().ToUpper())
28+
}
29+
2430
// On a Mac, use the Apple Scriptable image processing system to convert images to PNG,
2531
// see https://ss64.com/osx/sips.html.
26-
if (f.IsRaw() || f.IsHeif()) && w.conf.SipsEnabled() && w.sipsExclude.Allow(fileExt) {
32+
if f.IsHeif() && w.conf.SipsEnabled() && w.sipsExclude.Allow(fileExt) {
2733
result = append(result, NewConvertCmd(
2834
// #nosec G204 -- arguments are built from validated config and file paths.
2935
exec.Command(w.conf.SipsBin(), "-Z", maxSize, "-s", "format", "png", "--out", pngName, f.FileName())),

internal/photoprism/convert_image_test.go

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/stretchr/testify/assert"
1010

1111
"github.com/photoprism/photoprism/internal/config"
12+
"github.com/photoprism/photoprism/internal/raw"
1213
"github.com/photoprism/photoprism/internal/thumb"
1314
"github.com/photoprism/photoprism/pkg/fs"
1415
)
@@ -341,9 +342,9 @@ func TestConvert_JpegConvertCmds(t *testing.T) {
341342
}
342343

343344
// TestConvert_JpegConvertCmds_RawEmbeddedPreview verifies that RAW inputs emit
344-
// ExifTool embedded-preview extraction commands (largest-first) ordered after
345-
// Darktable and before RawTherapee, so an unsupported camera falls back to the
346-
// camera-rendered JPEG instead of a wrong-color demosaic.
345+
// ExifTool embedded-preview extraction commands (largest-first) ordered after the
346+
// RAW developers (Darktable and RawTherapee), so an unsupported camera falls back
347+
// to the camera-rendered JPEG instead of a wrong-color demosaic.
347348
func TestConvert_JpegConvertCmds_RawEmbeddedPreview(t *testing.T) {
348349
cnf := config.TestConfig()
349350

@@ -367,9 +368,9 @@ func TestConvert_JpegConvertCmds_RawEmbeddedPreview(t *testing.T) {
367368

368369
assert.NotEmpty(t, cmds)
369370

370-
// Record the first occurrence of each command; DNG inputs additionally emit a
371-
// trailing -PreviewImage extraction, which is not the priority position.
371+
// Record the position of each command in the priority-ordered list.
372372
jpgFromRaw, previewImage, rawTherapee := -1, -1, -1
373+
var rawTherapeeCmd *ConvertCmd
373374
for i, cmd := range cmds {
374375
s := cmd.String()
375376
switch {
@@ -379,6 +380,7 @@ func TestConvert_JpegConvertCmds_RawEmbeddedPreview(t *testing.T) {
379380
previewImage = i
380381
case rawTherapee < 0 && strings.Contains(s, filepath.Base(cnf.RawTherapeeBin())):
381382
rawTherapee = i
383+
rawTherapeeCmd = cmd
382384
}
383385
}
384386

@@ -387,10 +389,56 @@ func TestConvert_JpegConvertCmds_RawEmbeddedPreview(t *testing.T) {
387389
assert.Less(t, jpgFromRaw, previewImage, "JpgFromRaw must be tried before PreviewImage")
388390
if cnf.RawTherapeeEnabled() {
389391
assert.GreaterOrEqual(t, rawTherapee, 0, "expected a RawTherapee command")
390-
assert.Less(t, previewImage, rawTherapee, "embedded preview must be tried before RawTherapee")
392+
assert.Less(t, rawTherapee, jpgFromRaw, "RawTherapee must be tried before the embedded preview")
393+
assert.Contains(t, rawTherapeeCmd.RejectStderr, raw.WhiteBalanceError, "RawTherapee output must be rejected on a white-balance failure")
391394
}
392395
}
393396

397+
// TestConvert_JpegConvertCmds_RawDisabled verifies that with RAW conversion disabled (--disable-raw)
398+
// PhotoPrism only extracts an existing embedded preview and never renders the RAW with Darktable or
399+
// RawTherapee.
400+
func TestConvert_JpegConvertCmds_RawDisabled(t *testing.T) {
401+
cnf := config.TestConfig()
402+
403+
if !cnf.ExifToolEnabled() {
404+
t.Skip("ExifTool must be available for the RAW embedded-preview fallback")
405+
}
406+
407+
origRaw := cnf.Options().DisableRaw
408+
cnf.Options().DisableRaw = true
409+
t.Cleanup(func() {
410+
cnf.Options().DisableRaw = origRaw
411+
})
412+
413+
convert := NewConvert(cnf)
414+
rawFile := filepath.Join(cnf.SamplesPath(), "canon_eos_6d.dng")
415+
jpegFile := filepath.Join(cnf.SamplesPath(), "canon_eos_6d.dng.jpg")
416+
417+
mediaFile, err := NewMediaFile(rawFile)
418+
if err != nil {
419+
t.Fatal(err)
420+
}
421+
422+
cmds, _, err := convert.JpegConvertCmds(mediaFile, jpegFile, "")
423+
if err != nil {
424+
t.Fatal(err)
425+
}
426+
427+
assert.NotEmpty(t, cmds)
428+
429+
previewImage := false
430+
for _, cmd := range cmds {
431+
base := filepath.Base(cmd.Cmd.Path)
432+
assert.NotEqual(t, "darktable-cli", base, "Darktable must not run when RAW conversion is disabled")
433+
assert.NotEqual(t, "rawtherapee-cli", base, "RawTherapee must not run when RAW conversion is disabled")
434+
if strings.Contains(cmd.String(), "-PreviewImage") {
435+
previewImage = true
436+
}
437+
}
438+
439+
assert.True(t, previewImage, "embedded preview must still be extracted when RAW conversion is disabled")
440+
}
441+
394442
// TestConvert_JpegConvertCmds_HeifFallback verifies that the documented external
395443
// fallback command is emitted for HEIC and AVIF inputs when libheif tooling
396444
// (heif-dec / heif-convert) is available — see issue #5509.
@@ -516,4 +564,16 @@ func TestConvert_PngConvertCmds(t *testing.T) {
516564

517565
t.Logf("commands: %#v", cmds)
518566
})
567+
t.Run("Raw", func(t *testing.T) {
568+
// RAW is converted to JPEG, not PNG: no converter command is emitted and the call reports
569+
// the format as unsupported (see internal/raw/README.md).
570+
mediaFile, err := NewMediaFile(filepath.Join(cnf.SamplesPath(), "canon_eos_6d.dng"))
571+
if err != nil {
572+
t.Fatal(err)
573+
}
574+
575+
cmds, _, err := convert.PngConvertCmds(mediaFile, filepath.Join(t.TempDir(), "canon_eos_6d.png"))
576+
assert.Error(t, err)
577+
assert.Empty(t, cmds)
578+
})
519579
}

0 commit comments

Comments
 (0)