├── CNAME ├── assets ├── const.gohtml ├── gallery.css ├── favicon.ico ├── favicon-192.png ├── favicon-512.png ├── batch.js ├── manifest.json ├── 404.html ├── browser.html ├── batch.css ├── error.gohtml ├── raw-editor.css ├── dngconv.html ├── index.html ├── about.html ├── gallery.gohtml ├── photo.css ├── batch.gohtml ├── main.css ├── photo.gohtml ├── main.js ├── gallery.js └── raw-editor.gohtml ├── favicon.ico ├── screens ├── edit.png ├── browse.png └── welcome.png ├── .github ├── FUNDING.yml └── dependabot.yml ├── pkg ├── dngconv │ ├── doc.pdf │ ├── dngconv_darwin.go │ ├── dngconv_windows.go │ ├── dngconv_wine.go │ └── dngconv.go ├── dng │ ├── dng.go │ ├── lightsrc.go │ ├── dcp.go │ ├── dng_test.go │ ├── profile.go │ └── temp.go ├── dcraw │ ├── embed │ │ ├── dcraw.wasm │ │ └── init.go │ ├── fs_test.go │ ├── fs.go │ └── dcraw.go ├── README.md ├── osutil │ ├── cmd_other.go │ ├── pkg.go │ ├── proc_other.go │ ├── file_unix.go │ ├── init_windows.go │ ├── proc.go │ ├── file_darwin.go │ ├── cmd.go │ ├── proc_windows.go │ ├── cmd_windows.go │ ├── file_windows.go │ └── file.go ├── chrome │ ├── set.go │ ├── chrome_darwin.go │ ├── origin.go │ ├── chrome_unix.go │ ├── chrome_windows.go │ └── chrome.go ├── craw │ ├── init_darwin.go │ ├── init_windows.go │ ├── init_wine.go │ ├── index_test.go │ ├── profiles_test.go │ ├── init.go │ ├── profiles.go │ ├── fuji.go │ └── index.go ├── xmp │ ├── xmp.go │ └── extract.go ├── optls │ └── optls.go └── wine │ └── wine.go ├── internal ├── tools.go ├── util │ └── util.go └── config │ └── config.go ├── assets.go ├── assets_dev.go ├── .gitignore ├── http_thumb.go ├── http_test.go ├── LICENSE ├── dng.go ├── go.mod ├── http_upload.go ├── http_gallery.go ├── meta.go ├── make_wine.sh ├── README.md ├── http_dialog.go ├── make.cmd ├── batch.go ├── make.sh ├── jpeg.go ├── main.go ├── http_photo.go ├── go.sum ├── profiles.go ├── http_batch.go ├── workspace.go ├── http.go └── edit.go /CNAME: -------------------------------------------------------------------------------- 1 | www.rethinkraw.com -------------------------------------------------------------------------------- /assets/const.gohtml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/gallery.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | height: 100vh; 4 | } -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ncruces/RethinkRAW/HEAD/favicon.ico -------------------------------------------------------------------------------- /screens/edit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ncruces/RethinkRAW/HEAD/screens/edit.png -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://www.paypal.com/donate?hosted_button_id=RAFHHYAE7TG5A 2 | -------------------------------------------------------------------------------- /assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ncruces/RethinkRAW/HEAD/assets/favicon.ico -------------------------------------------------------------------------------- /pkg/dngconv/doc.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ncruces/RethinkRAW/HEAD/pkg/dngconv/doc.pdf -------------------------------------------------------------------------------- /screens/browse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ncruces/RethinkRAW/HEAD/screens/browse.png -------------------------------------------------------------------------------- /screens/welcome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ncruces/RethinkRAW/HEAD/screens/welcome.png -------------------------------------------------------------------------------- /assets/favicon-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ncruces/RethinkRAW/HEAD/assets/favicon-192.png -------------------------------------------------------------------------------- /assets/favicon-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ncruces/RethinkRAW/HEAD/assets/favicon-512.png -------------------------------------------------------------------------------- /pkg/dng/dng.go: -------------------------------------------------------------------------------- 1 | // Package dng provides support for reading and writing DNG files. 2 | package dng 3 | -------------------------------------------------------------------------------- /pkg/dcraw/embed/dcraw.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ncruces/RethinkRAW/HEAD/pkg/dcraw/embed/dcraw.wasm -------------------------------------------------------------------------------- /pkg/README.md: -------------------------------------------------------------------------------- 1 | [![PkgGoDev](https://pkg.go.dev/badge/image)](https://pkg.go.dev/github.com/ncruces/rethinkraw/pkg) 2 | -------------------------------------------------------------------------------- /pkg/osutil/cmd_other.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package osutil 4 | 5 | func createConsole() error { 6 | return nil 7 | } 8 | -------------------------------------------------------------------------------- /pkg/osutil/pkg.go: -------------------------------------------------------------------------------- 1 | // Package osutil provides additional platform-independent access to operating system functionality. 2 | package osutil 3 | -------------------------------------------------------------------------------- /internal/tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | 3 | package tools 4 | 5 | import ( 6 | _ "github.com/josephspurrier/goversioninfo" 7 | _ "github.com/ncruces/go-fetch" 8 | _ "github.com/ncruces/go-fs/memfsgen" 9 | ) 10 | -------------------------------------------------------------------------------- /assets.go: -------------------------------------------------------------------------------- 1 | //go:build memfs 2 | 3 | package main 4 | 5 | import "html/template" 6 | 7 | var assetHandler = assets 8 | 9 | func assetTemplates() *template.Template { 10 | return template.Must(template.ParseFS(assets, "*.gohtml")) 11 | } 12 | -------------------------------------------------------------------------------- /pkg/osutil/proc_other.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package osutil 4 | 5 | import ( 6 | "os" 7 | "syscall" 8 | ) 9 | 10 | func setPriority(proc os.Process, prio PriorityClass) error { 11 | return syscall.Setpriority(syscall.PRIO_PROCESS, proc.Pid, int(prio)) 12 | } 13 | -------------------------------------------------------------------------------- /pkg/chrome/set.go: -------------------------------------------------------------------------------- 1 | package chrome 2 | 3 | type set[T comparable] map[T]struct{} 4 | 5 | func (s set[T]) Add(k T) { 6 | s[k] = struct{}{} 7 | } 8 | 9 | func (s set[T]) Del(k T) { 10 | delete(s, k) 11 | } 12 | 13 | func (s set[T]) Has(k T) bool { 14 | _, ok := s[k] 15 | return ok 16 | } 17 | -------------------------------------------------------------------------------- /pkg/dcraw/fs_test.go: -------------------------------------------------------------------------------- 1 | package dcraw 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | "testing/fstest" 7 | ) 8 | 9 | func Test_readerFS(t *testing.T) { 10 | fs := readerFS{strings.NewReader("contents")} 11 | if err := fstest.TestFS(fs, readerFSname); err != nil { 12 | t.Fatal(err) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pkg/craw/init_darwin.go: -------------------------------------------------------------------------------- 1 | package craw 2 | 3 | import "os" 4 | 5 | func initPaths() { 6 | const path = "/Library/Application Support/Adobe/CameraRaw" 7 | if GlobalSettings == "" { 8 | GlobalSettings = path 9 | } 10 | if p, ok := os.LookupEnv("HOME"); UserSettings == "" && ok { 11 | UserSettings = p + path 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /pkg/craw/init_windows.go: -------------------------------------------------------------------------------- 1 | package craw 2 | 3 | import "os" 4 | 5 | func initPaths() { 6 | const path = `\Adobe\CameraRaw` 7 | if p, ok := os.LookupEnv("PROGRAMDATA"); GlobalSettings == "" && ok { 8 | GlobalSettings = p + path 9 | } 10 | if p, ok := os.LookupEnv("APPDATA"); UserSettings == "" && ok { 11 | UserSettings = p + path 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /pkg/osutil/file_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows && !darwin 2 | 3 | package osutil 4 | 5 | import ( 6 | "os" 7 | "os/exec" 8 | ) 9 | 10 | func isHidden(os.DirEntry) bool { 11 | return false 12 | } 13 | 14 | func open(file string) error { 15 | return exec.Command("xdg-open", file).Run() 16 | } 17 | 18 | func getANSIPath(path string) (string, error) { 19 | return path, nil 20 | } 21 | -------------------------------------------------------------------------------- /assets/batch.js: -------------------------------------------------------------------------------- 1 | void function () { 2 | 3 | window.popup = (elem, evt) => { 4 | if (evt.altKey || evt.ctrlKey || evt.metaKey || evt.shiftKey || evt.button !== 0) { 5 | return false; 6 | } 7 | 8 | let minimalUI = !window.matchMedia('(display-mode: browser)').matches; 9 | if (minimalUI) { 10 | return !window.open(elem.href, void 0, 'location=no,scrollbars=yes'); 11 | } 12 | return !window.open(elem.href); 13 | }; 14 | 15 | }(); -------------------------------------------------------------------------------- /pkg/osutil/init_windows.go: -------------------------------------------------------------------------------- 1 | package osutil 2 | 3 | import "golang.org/x/sys/windows" 4 | 5 | var ( 6 | kernel32 = windows.NewLazySystemDLL("kernel32.dll") 7 | user32 = windows.NewLazySystemDLL("user32.dll") 8 | 9 | wideCharToMultiByte = kernel32.NewProc("WideCharToMultiByte") 10 | getConsoleWindow = kernel32.NewProc("GetConsoleWindow") 11 | attachConsole = kernel32.NewProc("AttachConsole") 12 | setForegroundWindow = user32.NewProc("SetForegroundWindow") 13 | ) 14 | -------------------------------------------------------------------------------- /pkg/craw/init_wine.go: -------------------------------------------------------------------------------- 1 | //go:build !windows && !darwin 2 | 3 | package craw 4 | 5 | import "github.com/ncruces/rethinkraw/pkg/wine" 6 | 7 | func initPaths() { 8 | const path = `\Adobe\CameraRaw` 9 | if p, err := wine.Getenv("PROGRAMDATA"); GlobalSettings == "" && err == nil { 10 | GlobalSettings, _ = wine.FromWindows(p + path) 11 | } 12 | if p, err := wine.Getenv("APPDATA"); UserSettings == "" && err == nil { 13 | UserSettings, _ = wine.FromWindows(p + path) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /assets/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "RethinkRAW", 3 | "short_name": "RethinkRAW", 4 | "icons": [ 5 | { 6 | "src": "/favicon-192.png", 7 | "type": "image/png", 8 | "sizes": "192x192" 9 | }, 10 | { 11 | "src": "/favicon-512.png", 12 | "type": "image/png", 13 | "sizes": "512x512" 14 | } 15 | ], 16 | "start_url": "/", 17 | "scope": "/", 18 | "display": "standalone", 19 | "theme_color": "#663399", 20 | "background_color": "#ffffff" 21 | } -------------------------------------------------------------------------------- /pkg/osutil/proc.go: -------------------------------------------------------------------------------- 1 | package osutil 2 | 3 | import "os" 4 | 5 | type PriorityClass int 6 | 7 | const ( 8 | Realtime PriorityClass = -20 9 | High PriorityClass = -16 10 | AboveNormal PriorityClass = -8 11 | Normal PriorityClass = 0 12 | BelowNormal PriorityClass = 8 13 | Idle PriorityClass = 16 14 | ) 15 | 16 | // SetPriority sets the scheduling priority of proc. 17 | func SetPriority(proc os.Process, prio PriorityClass) error { 18 | return setPriority(proc, prio) 19 | } 20 | -------------------------------------------------------------------------------- /pkg/chrome/chrome_darwin.go: -------------------------------------------------------------------------------- 1 | package chrome 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | func findChrome() { 9 | versions := []string{"Google Chrome", "Chromium", "Microsoft Edge"} 10 | 11 | for _, v := range versions { 12 | c := filepath.Join("/Applications", v+".app", "Contents/MacOS", v) 13 | if _, err := os.Stat(c); err == nil { 14 | chrome = c 15 | return 16 | } 17 | } 18 | } 19 | 20 | func signal(p *os.Process, sig os.Signal) error { 21 | return p.Signal(sig) 22 | } 23 | -------------------------------------------------------------------------------- /pkg/osutil/file_darwin.go: -------------------------------------------------------------------------------- 1 | package osutil 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "syscall" 7 | ) 8 | 9 | func isHidden(de os.DirEntry) bool { 10 | i, err := de.Info() 11 | if err != nil { 12 | return false 13 | } 14 | s, ok := i.Sys().(*syscall.Stat_t) 15 | return ok && s.Flags&0x8000 != 0 // UF_HIDDEN 16 | } 17 | 18 | func open(file string) error { 19 | return exec.Command("open", file).Run() 20 | } 21 | 22 | func getANSIPath(path string) (string, error) { 23 | return path, nil 24 | } 25 | -------------------------------------------------------------------------------- /pkg/chrome/origin.go: -------------------------------------------------------------------------------- 1 | package chrome 2 | 3 | import ( 4 | "net/url" 5 | "strings" 6 | ) 7 | 8 | func origin(u string) string { 9 | url, err := url.Parse(u) 10 | if err != nil { 11 | return "null" 12 | } 13 | switch url.Scheme { 14 | case "blob": 15 | return origin(url.Opaque) 16 | case "http", "ws": 17 | return url.Scheme + "://" + strings.TrimSuffix(url.Host, ":80") 18 | case "https", "wss": 19 | return url.Scheme + "://" + strings.TrimSuffix(url.Host, ":443") 20 | default: 21 | return "null" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /assets_dev.go: -------------------------------------------------------------------------------- 1 | //go:build !memfs 2 | 3 | package main 4 | 5 | import ( 6 | "html/template" 7 | "net/http" 8 | "os" 9 | ) 10 | 11 | //go:generate -command memfsgen go run github.com/ncruces/go-fs/memfsgen 12 | //go:generate memfsgen -minify -mimetype gohtml:text/html -tag memfs -pkg main assets assets_gen.go 13 | 14 | var assets = os.DirFS("assets") 15 | var assetHandler = http.FileServer(http.Dir("assets")) 16 | 17 | func assetTemplates() *template.Template { 18 | return template.Must(template.ParseGlob("assets/*.gohtml")) 19 | } 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /pkg/chrome/chrome_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows && !darwin 2 | 3 | package chrome 4 | 5 | import ( 6 | "os" 7 | "os/exec" 8 | ) 9 | 10 | func findChrome() { 11 | versions := []string{ 12 | "google-chrome-stable", 13 | "google-chrome", 14 | "chromium-browser", 15 | "chromium", 16 | "microsoft-edge-stable", 17 | "microsoft-edge", 18 | } 19 | 20 | for _, v := range versions { 21 | if c, err := exec.LookPath(v); err == nil { 22 | chrome = c 23 | return 24 | } 25 | } 26 | } 27 | 28 | func signal(p *os.Process, sig os.Signal) error { 29 | return p.Signal(sig) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/craw/index_test.go: -------------------------------------------------------------------------------- 1 | package craw 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | ) 7 | 8 | func TestLoadIndex(t *testing.T) { 9 | once.Do(initPaths) 10 | 11 | tests := []string{ 12 | "CameraProfiles/Index.dat", 13 | "LensProfiles/Index.dat", 14 | } 15 | 16 | for _, tt := range tests { 17 | t.Run(tt, func(t *testing.T) { 18 | path := filepath.Join(GlobalSettings, filepath.FromSlash(tt)) 19 | idx, err := LoadIndex(path) 20 | if err != nil { 21 | t.Error(err) 22 | } else { 23 | t.Logf("Read %d records.", len(idx)) 24 | } 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pkg/dngconv/dngconv_darwin.go: -------------------------------------------------------------------------------- 1 | package dngconv 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/exec" 7 | ) 8 | 9 | func findConverter() { 10 | const converter = "/Applications/Adobe DNG Converter.app/Contents/MacOS/Adobe DNG Converter" 11 | _, err := os.Stat(converter) 12 | if err != nil { 13 | return 14 | } 15 | Path = converter 16 | } 17 | 18 | func runConverter(ctx context.Context, args ...string) error { 19 | _, err := exec.CommandContext(ctx, Path, args...).Output() 20 | return err 21 | } 22 | 23 | func dngPath(path string) (string, error) { 24 | return path, nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/osutil/cmd.go: -------------------------------------------------------------------------------- 1 | package osutil 2 | 3 | import ( 4 | "os" 5 | "runtime" 6 | "strings" 7 | ) 8 | 9 | // CreateConsole ensures a Windows process has an attached console. 10 | // If needed, it creates an hidden console and attaches to it. 11 | func CreateConsole() error { 12 | return createConsole() 13 | } 14 | 15 | // CleanupArgs cleans up [os.Args]. 16 | // On macOS, it removes the Process Serial Number arg. 17 | func CleanupArgs() { 18 | if runtime.GOOS == "darwin" { 19 | for i, a := range os.Args { 20 | if strings.HasPrefix(a, "-psn_") { 21 | os.Args = append(os.Args[:i], os.Args[i+1:]...) 22 | break 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Visual Studio Code 2 | .vscode/* 3 | 4 | ## Windows 5 | .DS_Store 6 | 7 | ## macOS 8 | Thumbs.db 9 | [Dd]esktop.ini 10 | 11 | ## Go 12 | 13 | # Binaries for programs and plugins 14 | *.com 15 | *.exe 16 | *.com~ 17 | *.exe~ 18 | *.syso 19 | *.dll 20 | *.so 21 | *.dylib 22 | *.dmg 23 | *.tbz 24 | 25 | # Test binary, built with `go test -c` 26 | *.test 27 | 28 | # Output of the go coverage tool, specifically when used with LiteIDE 29 | *.out 30 | 31 | ## Project 32 | /stuff 33 | /*_gen.go 34 | /assets/normalize.css 35 | /assets/dialog-polyfill.* 36 | /assets/fa-solid-900.woff2 37 | /assets/fontawesome.css 38 | /RethinkRAW/ 39 | /RethinkRAW.app/ 40 | -------------------------------------------------------------------------------- /pkg/craw/profiles_test.go: -------------------------------------------------------------------------------- 1 | package craw 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ncruces/rethinkraw/pkg/dngconv" 7 | ) 8 | 9 | func TestGetCameraProfiles(t *testing.T) { 10 | profiles, err := GetCameraProfileNames("SONY", "ILCE-7") 11 | if err != nil { 12 | t.Error(err) 13 | } else if len(profiles) != 9 { 14 | t.Errorf("Expected 9 profiles got %d", len(profiles)) 15 | } 16 | 17 | if dngconv.IsInstalled() { 18 | EmbedProfiles = dngconv.Path 19 | profiles, err = GetCameraProfileNames("FUJIFILM", "FinePix X100") 20 | if err != nil { 21 | t.Error(err) 22 | } else if len(profiles) != 8 { 23 | t.Errorf("Expected 8 profiles got %d", len(profiles)) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /http_thumb.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "net/http" 4 | 5 | func thumbHandler(w http.ResponseWriter, r *http.Request) httpResult { 6 | if r := sendAllowed(w, r, "GET", "HEAD"); r.Done() { 7 | return r 8 | } 9 | prefix := getPathPrefix(r) 10 | path := fromURLPath(r.URL.Path, prefix) 11 | 12 | w.Header().Set("Cache-Control", "max-age=10") 13 | if r := sendCached(w, r, path); r.Done() { 14 | return r 15 | } 16 | 17 | w.Header().Set("Content-Type", "image/jpeg") 18 | if r.Method == "HEAD" { 19 | return httpResult{} 20 | } 21 | 22 | if out, err := previewJPEG(r.Context(), path); err != nil { 23 | return httpResult{Error: err} 24 | } else { 25 | w.Write(out) 26 | return httpResult{} 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pkg/dcraw/embed/init.go: -------------------------------------------------------------------------------- 1 | // Package embed provides support embeding dcraw into your application. 2 | // 3 | // You can obtain this build of dcraw from: 4 | // https://github.com/ncruces/dcraw/releases/tag/v9.28.6-wasm 5 | // 6 | // Before importing this package, inspect the dcraw license and 7 | // consider the implications. 8 | package embed 9 | 10 | import ( 11 | _ "embed" 12 | 13 | "github.com/ncruces/rethinkraw/pkg/dcraw" 14 | ) 15 | 16 | //go:generate -command go-fetch go run github.com/ncruces/go-fetch 17 | //go:generate go-fetch -unpack "https://github.com/ncruces/dcraw/releases/download/v9.28.8-wasm/dcraw.wasm.gz" dcraw.wasm 18 | 19 | //go:embed dcraw.wasm 20 | var binary []byte 21 | 22 | func init() { 23 | dcraw.Binary = binary 24 | } 25 | -------------------------------------------------------------------------------- /http_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func Test_getAppDomain(t *testing.T) { 6 | tests := []struct { 7 | ip string 8 | want string 9 | }{ 10 | {"", ""}, 11 | {"localhost", ""}, 12 | 13 | {"1.1.1.1", "1-1-1-1.app.rethinkraw.com"}, 14 | {"127.0.0.1", "127-0-0-1.app.rethinkraw.com"}, 15 | {"192.168.1.1", "192-168-1-1.app.rethinkraw.com"}, 16 | 17 | {"::", "0--0.app.rethinkraw.com"}, 18 | {"::1", "0--1.app.rethinkraw.com"}, 19 | {"2001:1::1", "2001-1--1.app.rethinkraw.com"}, 20 | {"::ffff:0:0", "0-0-0-0.app.rethinkraw.com"}, 21 | } 22 | for _, tt := range tests { 23 | if got := getAppDomain(tt.ip); got != tt.want { 24 | t.Errorf("getAppDomain(%q) = %q, want %q", tt.ip, got, tt.want) 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /pkg/dngconv/dngconv_windows.go: -------------------------------------------------------------------------------- 1 | package dngconv 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "os/exec" 7 | 8 | "github.com/ncruces/rethinkraw/pkg/osutil" 9 | ) 10 | 11 | func findConverter() { 12 | const converter = `\Adobe\Adobe DNG Converter\Adobe DNG Converter.exe` 13 | paths := []string{ 14 | os.Getenv("PROGRAMW6432"), 15 | os.Getenv("PROGRAMFILES"), 16 | os.Getenv("PROGRAMFILES(X86)"), 17 | } 18 | for _, path := range paths { 19 | if path != "" { 20 | c := path + converter 21 | _, err := os.Stat(c) 22 | if err == nil { 23 | Path = c 24 | return 25 | } 26 | } 27 | } 28 | } 29 | 30 | func runConverter(ctx context.Context, args ...string) error { 31 | _, err := exec.CommandContext(ctx, Path, args...).Output() 32 | return err 33 | } 34 | 35 | func dngPath(path string) (string, error) { 36 | return osutil.GetANSIPath(path) 37 | } 38 | -------------------------------------------------------------------------------- /pkg/osutil/proc_windows.go: -------------------------------------------------------------------------------- 1 | package osutil 2 | 3 | import ( 4 | "os" 5 | 6 | "golang.org/x/sys/windows" 7 | ) 8 | 9 | func setPriority(proc os.Process, prio PriorityClass) error { 10 | const da = windows.PROCESS_SET_INFORMATION 11 | h, err := windows.OpenProcess(da, false, uint32(proc.Pid)) 12 | if err != nil { 13 | return err 14 | } 15 | defer windows.CloseHandle(h) 16 | var class uint32 17 | switch { 18 | case prio <= -20: 19 | class = windows.REALTIME_PRIORITY_CLASS 20 | case prio < -12: 21 | class = windows.HIGH_PRIORITY_CLASS 22 | case prio < -4: 23 | class = windows.ABOVE_NORMAL_PRIORITY_CLASS 24 | case prio < 4: 25 | class = windows.NORMAL_PRIORITY_CLASS 26 | case prio < 12: 27 | class = windows.BELOW_NORMAL_PRIORITY_CLASS 28 | default: 29 | class = windows.IDLE_PRIORITY_CLASS 30 | } 31 | return windows.SetPriorityClass(h, class) 32 | } 33 | -------------------------------------------------------------------------------- /assets/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | RethinkRAW 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |

Not Found

17 |

There was an error processing your request.

18 |

Here are a few links you may find useful:

19 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /assets/browser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | RethinkRAW 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |

Upgrade your Browser

15 |

It appears you are using a browser version which is not supported by RethinkRAW.

16 |

17 | To get the most out of RethinkRAW we recomend you install 18 | Google Chrome or 19 | Microsoft Edge. 20 |

21 |

You should restart RethinkRAW after upgrading your browser.

22 | 23 | 24 | -------------------------------------------------------------------------------- /assets/batch.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | height: 100vh; 4 | } 5 | 6 | dialog#progress-dialog { 7 | width: 10rem; 8 | } 9 | 10 | dialog#progress-dialog progress { 11 | margin-top: 0.2rem; 12 | max-width: 100%; 13 | } 14 | 15 | dialog#export-dialog { 16 | width: 20rem; 17 | } 18 | 19 | form#export-form div { 20 | margin-top: 0.4rem; 21 | font-size: small; 22 | display: grid; 23 | grid-gap: 0.4rem; 24 | grid-auto-rows: 1fr; 25 | grid-template-columns: repeat(8, 1fr); 26 | } 27 | 28 | form#export-form>:first-child { 29 | margin-top: 0; 30 | } 31 | 32 | form#export-form input[type=number] { 33 | width: 4.5rem; 34 | } 35 | 36 | form#export-form input[type=checkbox] { 37 | height: 100%; 38 | } 39 | 40 | form#export-form span, 41 | form#export-form label { 42 | padding: 2px 0; 43 | white-space: nowrap; 44 | } 45 | 46 | form#export-form span { 47 | padding-left: 2px; 48 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019-2022 Nuno Cruces 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 11 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 12 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 13 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 14 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 15 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 16 | SOFTWARE. 17 | -------------------------------------------------------------------------------- /dng.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "strconv" 7 | 8 | "github.com/ncruces/rethinkraw/pkg/dngconv" 9 | "golang.org/x/sync/semaphore" 10 | ) 11 | 12 | var semDNGConverter = semaphore.NewWeighted(3) 13 | 14 | func runDNGConverter(ctx context.Context, input, output string, side int, exp *exportSettings) error { 15 | args := []string{} 16 | if exp != nil && exp.DNG { 17 | if exp.Preview != "" { 18 | args = append(args, "-"+exp.Preview) 19 | } 20 | if exp.Lossy { 21 | args = append(args, "-lossy") 22 | } 23 | if exp.Embed { 24 | args = append(args, "-e") 25 | } 26 | } else { 27 | if side > 0 { 28 | args = append(args, "-lossy", "-side", strconv.Itoa(side)) 29 | } 30 | args = append(args, "-p2") 31 | } 32 | 33 | if err := semDNGConverter.Acquire(ctx, 1); err != nil { 34 | return err 35 | } 36 | defer semDNGConverter.Release(1) 37 | 38 | log.Print("dng converter...") 39 | return dngconv.Convert(ctx, input, output, args...) 40 | } 41 | -------------------------------------------------------------------------------- /pkg/xmp/xmp.go: -------------------------------------------------------------------------------- 1 | // Package xmp provides support for dealing with XMP files and packets. 2 | package xmp 3 | 4 | import ( 5 | "encoding/xml" 6 | "io" 7 | "strings" 8 | ) 9 | 10 | // IsSidecarForExt checks if a reader is the sidecar for a given file extension. 11 | func IsSidecarForExt(r io.Reader, ext string) bool { 12 | test := func(name xml.Name) bool { 13 | return name.Local == "SidecarForExtension" && 14 | (name.Space == "http://ns.adobe.com/photoshop/1.0/" || name.Space == "photoshop") 15 | } 16 | 17 | dec := xml.NewDecoder(r) 18 | for { 19 | t, err := dec.Token() 20 | if err != nil { 21 | return err == io.EOF // assume yes 22 | } 23 | 24 | if s, ok := t.(xml.StartElement); ok { 25 | if test(s.Name) { 26 | t, _ := dec.Token() 27 | v, ok := t.(xml.CharData) 28 | return ok && strings.EqualFold(ext, "."+string(v)) 29 | } 30 | for _, a := range s.Attr { 31 | if test(a.Name) { 32 | return strings.EqualFold(ext, "."+a.Value) 33 | } 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /pkg/craw/init.go: -------------------------------------------------------------------------------- 1 | // Package craw provides support for loading Camera Raw settings. 2 | package craw 3 | 4 | import ( 5 | "path/filepath" 6 | "runtime" 7 | "strings" 8 | "sync" 9 | ) 10 | 11 | // Paths used to find Camera Raw settings. 12 | var ( 13 | GlobalSettings string // The global Camera Raw settings directory. 14 | UserSettings string // The user's Camera Raw settings directory. 15 | EmbedProfiles string // The file where to look for embed profiles. 16 | ) 17 | 18 | const ( 19 | globalPrefixWin = "C:/ProgramData/Adobe/CameraRaw/" 20 | globalPrefixMac = "/Library/Application Support/Adobe/CameraRaw/" 21 | ) 22 | 23 | var once sync.Once 24 | 25 | func fixPath(path string) string { 26 | if strings.HasPrefix(path, globalPrefixWin) { 27 | path = filepath.Join(GlobalSettings, path[len(globalPrefixWin):]) 28 | } 29 | if runtime.GOOS != "darwin" && strings.HasPrefix(path, globalPrefixMac) { 30 | path = filepath.Join(GlobalSettings, path[len(globalPrefixMac):]) 31 | } 32 | return filepath.FromSlash(path) 33 | } 34 | -------------------------------------------------------------------------------- /pkg/chrome/chrome_windows.go: -------------------------------------------------------------------------------- 1 | package chrome 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "path/filepath" 7 | "strconv" 8 | "syscall" 9 | ) 10 | 11 | func findChrome() { 12 | versions := []string{`Google\Chrome`, `Chromium`, `Microsoft\Edge`} 13 | suffixes := []string{`Application\chrome.exe`, `Application\msedge.exe`} 14 | prefixes := []string{ 15 | os.Getenv("LOCALAPPDATA"), 16 | os.Getenv("PROGRAMW6432"), 17 | os.Getenv("PROGRAMFILES"), 18 | os.Getenv("PROGRAMFILES(X86)"), 19 | } 20 | 21 | for _, v := range versions { 22 | for _, s := range suffixes { 23 | for _, p := range prefixes { 24 | if p != "" { 25 | c := filepath.Join(p, v, s) 26 | if _, err := os.Stat(c); err == nil { 27 | chrome = c 28 | return 29 | } 30 | } 31 | } 32 | } 33 | } 34 | } 35 | 36 | func signal(p *os.Process, sig os.Signal) error { 37 | if sig == syscall.SIGINT || sig == syscall.SIGHUP || sig == syscall.SIGTERM { 38 | return exec.Command("taskkill", "/pid", strconv.Itoa(p.Pid)).Run() 39 | } 40 | return p.Signal(sig) 41 | } 42 | -------------------------------------------------------------------------------- /assets/error.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | RethinkRAW 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |

{{.Status}}

17 |

There was an error processing your request.

18 |

Here are a few links you may find useful:

19 | 23 | {{- if .Message}} 24 | More information about this error: 25 | {{- .Message}} 26 | 27 | {{- end}} 28 | 29 | 30 | -------------------------------------------------------------------------------- /assets/raw-editor.css: -------------------------------------------------------------------------------- 1 | form#settings { 2 | position: sticky; 3 | float: right; 4 | z-index: 1; 5 | top: 0; 6 | width: 15rem; 7 | height: 100vh; 8 | padding: 0 8px; 9 | font-size: small; 10 | overflow-y: scroll; 11 | scrollbar-gutter: stable; 12 | } 13 | 14 | @supports (overflow: overlay) and (not (scrollbar-gutter: stable)) { 15 | form#settings { 16 | padding-right: 1rem; 17 | overflow: overlay; 18 | } 19 | } 20 | 21 | form#settings fieldset { 22 | margin-top: 0.2rem; 23 | margin-bottom: 0.2rem; 24 | } 25 | 26 | form#settings select { 27 | width: 100%; 28 | margin-bottom: 0.3rem; 29 | } 30 | 31 | form#settings select:last-child { 32 | margin-bottom: initial; 33 | } 34 | 35 | form#settings output { 36 | float: right; 37 | } 38 | 39 | form#settings input[type=range] { 40 | display: block; 41 | margin-top: 0; 42 | width: 100%; 43 | } 44 | 45 | form#settings fieldset[disabled] { 46 | color: #ccc; 47 | } 48 | 49 | form#settings div[class*=disabled] { 50 | color: #ccc; 51 | } 52 | 53 | form#settings div[class*=disabled] output { 54 | visibility: hidden; 55 | } -------------------------------------------------------------------------------- /assets/dngconv.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | RethinkRAW 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |

Install Adobe DNG Converter

17 |

RethinkRAW requires Adobe DNG Converter to process RAW photos.

18 |

Please download and install the free Adobe DNG Converter.

19 |

20 | Adobe DNG Converter is available for both Windows and macOS. 21 | In other OSes, you must install it using Wine. 22 |

23 |

You should restart RethinkRAW after installing a new version of Adobe DNG Converter.

24 | 25 | 26 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/ncruces/rethinkraw 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/gorilla/schema v1.4.1 9 | github.com/gorilla/websocket v1.5.3 10 | github.com/josephspurrier/goversioninfo v1.5.0 11 | github.com/ncruces/go-exiftool v0.4.2 12 | github.com/ncruces/go-fetch v0.0.0-20201125022143-c61f8921eb46 13 | github.com/ncruces/go-fs v0.2.4 14 | github.com/ncruces/go-image v0.1.0 15 | github.com/ncruces/jason v0.4.0 16 | github.com/ncruces/zenity v0.10.14 17 | github.com/tetratelabs/wazero v1.10.1 18 | golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 19 | golang.org/x/sync v0.19.0 20 | golang.org/x/sys v0.39.0 21 | gonum.org/v1/gonum v0.16.0 22 | ) 23 | 24 | require ( 25 | github.com/akavel/rsrc v0.10.2 // indirect 26 | github.com/davecgh/go-spew v1.1.1 // indirect 27 | github.com/dchest/jsmin v1.0.0 // indirect 28 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 29 | github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94 // indirect 30 | github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 // indirect 31 | github.com/tdewolff/minify/v2 v2.21.3 // indirect 32 | github.com/tdewolff/parse/v2 v2.7.20 // indirect 33 | golang.org/x/image v0.25.0 // indirect 34 | golang.org/x/net v0.38.0 // indirect 35 | ) 36 | -------------------------------------------------------------------------------- /pkg/xmp/extract.go: -------------------------------------------------------------------------------- 1 | package xmp 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | ) 8 | 9 | const xpacket_begin = "` 12 | const xpacket_id = "W5M0MpCehiHzreSzNTczkc9d" 13 | 14 | func splitPacket(data []byte, atEOF bool) (advance int, token []byte, err error) { 15 | if begin := bytes.Index(data, []byte(xpacket_begin)); begin >= 0 { 16 | if end := bytes.Index(data[begin+len(xpacket_begin):], []byte(xpacket_end)); end >= 0 { 17 | if last := begin + len(xpacket_begin) + end + len(xpacket_end) + len(xpacket_sufix); last < len(data) { 18 | if bytes.Contains(data[begin:begin+50], []byte(xpacket_id)) { 19 | return last, data[begin:last], nil 20 | } 21 | } 22 | } 23 | advance = begin 24 | } else { 25 | advance = len(data) - len(xpacket_begin) + 1 26 | } 27 | 28 | if atEOF { 29 | return 0, nil, io.EOF 30 | } 31 | if advance < 0 { 32 | return 0, nil, nil 33 | } 34 | return advance, nil, nil 35 | } 36 | 37 | // ExtractXMP extracts a XMP packet from a reader. 38 | func ExtractXMP(r io.Reader) ([]byte, error) { 39 | scan := bufio.NewScanner(r) 40 | scan.Split(splitPacket) 41 | if scan.Scan() { 42 | return scan.Bytes(), nil 43 | } 44 | return nil, scan.Err() 45 | } 46 | -------------------------------------------------------------------------------- /pkg/dngconv/dngconv_wine.go: -------------------------------------------------------------------------------- 1 | //go:build !windows && !darwin 2 | 3 | package dngconv 4 | 5 | import ( 6 | "context" 7 | "os" 8 | 9 | "github.com/ncruces/rethinkraw/pkg/wine" 10 | ) 11 | 12 | func findConverter() { 13 | const converter = `\Adobe\Adobe DNG Converter\Adobe DNG Converter.exe` 14 | paths := []string{ 15 | "PROGRAMW6432", 16 | "PROGRAMFILES", 17 | "PROGRAMFILES(X86)", 18 | } 19 | for _, path := range paths { 20 | env, err := wine.Getenv(path) 21 | if err != nil { 22 | continue 23 | } 24 | 25 | unix, err := wine.FromWindows(env + converter) 26 | if err != nil { 27 | continue 28 | } 29 | 30 | _, err = os.Stat(unix) 31 | if err == nil { 32 | Path = unix 33 | break 34 | } 35 | } 36 | } 37 | 38 | func runConverter(ctx context.Context, args ...string) error { 39 | _, err := wine.CommandContext(ctx, Path, args...).Output() 40 | return err 41 | } 42 | 43 | var dngPathCache = map[string]string{} 44 | 45 | func dngPath(path string) (string, error) { 46 | p, ok := dngPathCache[path] 47 | if ok { 48 | return p, nil 49 | } 50 | 51 | p, err := wine.ToWindows(path) 52 | if err != nil { 53 | return "", err 54 | } 55 | 56 | if len(dngPathCache) > 100 { 57 | for k := range dngPathCache { 58 | delete(dngPathCache, k) 59 | break 60 | } 61 | } 62 | dngPathCache[path] = p 63 | return p, nil 64 | } 65 | -------------------------------------------------------------------------------- /internal/util/util.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/md5" 5 | "crypto/rand" 6 | "encoding/base64" 7 | "unsafe" 8 | ) 9 | 10 | func Btoi(b bool) int { 11 | if b { 12 | return 1 13 | } 14 | return 0 15 | } 16 | 17 | func HashedID(data string) string { 18 | buf := md5.Sum([]byte(data)) 19 | return base64.RawURLEncoding.EncodeToString(buf[:15]) 20 | } 21 | 22 | func RandomID() string { 23 | var buf [15]byte 24 | if _, err := rand.Read(buf[:]); err != nil { 25 | panic(err) 26 | } 27 | return base64.RawURLEncoding.EncodeToString(buf[:]) 28 | } 29 | 30 | func PercentEncode(s string) string { 31 | const upperhex = "0123456789ABCDEF" 32 | unreserved := func(c byte) bool { 33 | switch { 34 | case c >= 'a': 35 | return c <= 'z' || c == '~' 36 | case c >= 'A': 37 | return c <= 'Z' || c == '_' 38 | case c >= '0': 39 | return c <= '9' 40 | default: 41 | return c == '-' || c == '.' 42 | } 43 | } 44 | 45 | hex := 0 46 | for _, c := range []byte(s) { 47 | if !unreserved(c) { 48 | hex++ 49 | } 50 | } 51 | if hex == 0 { 52 | return s 53 | } 54 | 55 | i := 0 56 | buf := make([]byte, len(s)+2*hex) 57 | for _, c := range []byte(s) { 58 | if unreserved(c) { 59 | buf[i] = c 60 | i++ 61 | } else { 62 | buf[i+0] = '%' 63 | buf[i+1] = upperhex[c>>4] 64 | buf[i+2] = upperhex[c&15] 65 | i += 3 66 | } 67 | } 68 | return *(*string)(unsafe.Pointer(&buf)) 69 | } 70 | -------------------------------------------------------------------------------- /pkg/dngconv/dngconv.go: -------------------------------------------------------------------------------- 1 | // Package dngconv provides support to locate and run Adobe DNG Converter. 2 | // 3 | // Adobe DNG Converter is available for Windows and macOS. 4 | // In other OSes, you must install it using Wine: 5 | // https://www.winehq.org/ 6 | // 7 | // Documentation for command line arguments of Adobe DNG Converter 8 | // is available at: 9 | // https://github.com/ncruces/RethinkRAW/blob/master/pkg/dngconv/doc.pdf 10 | package dngconv 11 | 12 | import ( 13 | "context" 14 | "fmt" 15 | "os" 16 | "path/filepath" 17 | "sync" 18 | ) 19 | 20 | var Path string 21 | 22 | var once sync.Once 23 | 24 | // IsInstalled checks if Adobe DNG Converter is installed. 25 | // If true, [Path] will be set to the converter's executable path. 26 | func IsInstalled() bool { 27 | once.Do(findConverter) 28 | return Path != "" 29 | } 30 | 31 | // Convert converts an input RAW file into an output DNG using Adobe DNG Converter. 32 | func Convert(ctx context.Context, input, output string, args ...string) error { 33 | once.Do(findConverter) 34 | 35 | input, err := dngPath(input) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | dir, err := dngPath(filepath.Dir(output)) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | err = os.RemoveAll(output) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | args = append(args, "-d", dir, "-o", filepath.Base(output), input) 51 | err = runConverter(ctx, args...) 52 | if err != nil { 53 | return fmt.Errorf("dng converter: %w", err) 54 | } 55 | return nil 56 | } 57 | -------------------------------------------------------------------------------- /pkg/osutil/cmd_windows.go: -------------------------------------------------------------------------------- 1 | package osutil 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | "syscall" 7 | ) 8 | 9 | func createConsole() error { 10 | if hwnd, _, _ := getConsoleWindow.Call(); hwnd != 0 { 11 | return nil 12 | } 13 | 14 | cmd := exec.Command("cmd", "/c", "pause") 15 | in, err := cmd.StdinPipe() 16 | if err != nil { 17 | return err 18 | } 19 | out, err := cmd.StdoutPipe() 20 | if err != nil { 21 | return err 22 | } 23 | 24 | cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} 25 | if err := cmd.Start(); err != nil { 26 | return err 27 | } 28 | 29 | var buf [32]byte 30 | _, err = out.Read(buf[:]) 31 | if err == nil { 32 | r, _, lerr := attachConsole.Call(uintptr(cmd.Process.Pid)) 33 | if r == 0 { 34 | err = lerr 35 | } 36 | } 37 | if cerr := in.Close(); err == nil { 38 | err = cerr 39 | } 40 | if werr := cmd.Wait(); err == nil { 41 | err = werr 42 | } 43 | if err != nil { 44 | return err 45 | } 46 | 47 | if os.Stdin.Fd() == 0 { 48 | h, _ := syscall.GetStdHandle(syscall.STD_INPUT_HANDLE) 49 | os.Stdin = os.NewFile(uintptr(h), "/dev/stdin") 50 | syscall.CloseOnExec(h) 51 | } 52 | if os.Stdout.Fd() == 0 { 53 | h, _ := syscall.GetStdHandle(syscall.STD_OUTPUT_HANDLE) 54 | os.Stdin = os.NewFile(uintptr(h), "/dev/stdout") 55 | syscall.CloseOnExec(h) 56 | } 57 | if os.Stderr.Fd() == 0 { 58 | h, _ := syscall.GetStdHandle(syscall.STD_ERROR_HANDLE) 59 | os.Stderr = os.NewFile(uintptr(h), "/dev/stderr") 60 | syscall.CloseOnExec(h) 61 | } 62 | 63 | if hwnd, _, _ := getConsoleWindow.Call(); hwnd != 0 { 64 | setForegroundWindow.Call(hwnd) 65 | } 66 | return nil 67 | } 68 | -------------------------------------------------------------------------------- /pkg/dng/lightsrc.go: -------------------------------------------------------------------------------- 1 | package dng 2 | 3 | // LightSource represents a kind of light source. 4 | type LightSource uint16 5 | 6 | // Values for the LightSource EXIF tag. 7 | const ( 8 | LSUnknown LightSource = iota 9 | LSDaylight 10 | LSFluorescent 11 | LSTungsten 12 | LSFlash 13 | _ 14 | _ 15 | _ 16 | _ 17 | LSFineWeather 18 | LSCloudyWeather 19 | LSShade 20 | LSDaylightFluorescent // D 5700 - 7100K 21 | LSDayWhiteFluorescent // N 4600 - 5500K 22 | LSCoolWhiteFluorescent // W 3800 - 4500K 23 | LSWhiteFluorescent // WW 3250 - 3800K 24 | LSWarmWhiteFluorescent // L 2600 - 3250K 25 | LSStandardLightA 26 | LSStandardLightB 27 | LSStandardLightC 28 | LSD55 29 | LSD65 30 | LSD75 31 | LSD50 32 | LSISOStudioTungsten 33 | 34 | LSOther LightSource = 255 35 | ) 36 | 37 | // Port of dng_camera_profile::IlluminantToTemperature 38 | 39 | // Temperature gets the color temperature of the illuminant. 40 | func (ls LightSource) Temperature() float64 { 41 | switch ls { 42 | 43 | case LSStandardLightA, LSTungsten: 44 | return 2850.0 45 | 46 | case LSISOStudioTungsten: 47 | return 3200.0 48 | 49 | case LSD50: 50 | return 5000.0 51 | 52 | case LSD55, LSDaylight, LSFineWeather, LSFlash, LSStandardLightB: 53 | return 5500.0 54 | 55 | case LSD65, LSStandardLightC, LSCloudyWeather: 56 | return 6500.0 57 | 58 | case LSD75, LSShade: 59 | return 7500.0 60 | 61 | case LSDaylightFluorescent: 62 | return (5700.0 + 7100.0) * 0.5 63 | 64 | case LSDayWhiteFluorescent: 65 | return (4600.0 + 5500.0) * 0.5 66 | 67 | case LSCoolWhiteFluorescent, LSFluorescent: 68 | return (3800.0 + 4500.0) * 0.5 69 | 70 | case LSWhiteFluorescent: 71 | return (3250.0 + 3800.0) * 0.5 72 | 73 | case LSWarmWhiteFluorescent: 74 | return (2600.0 + 3250.0) * 0.5 75 | 76 | default: 77 | return 0.0 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "mime" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | "strings" 9 | 10 | "github.com/ncruces/go-exiftool" 11 | "github.com/ncruces/rethinkraw/pkg/dcraw" 12 | ) 13 | 14 | var ( 15 | ServerMode bool 16 | BaseDir, DataDir, TempDir string 17 | ) 18 | 19 | func init() { 20 | mime.AddExtensionType(".dng", "image/x-adobe-dng") 21 | } 22 | 23 | func SetupPaths() (err error) { 24 | if exe, err := os.Executable(); err != nil { 25 | return err 26 | } else if exe, err := filepath.EvalSymlinks(exe); err != nil { 27 | return err 28 | } else { 29 | BaseDir = filepath.Dir(exe) 30 | } 31 | 32 | DataDir = filepath.Join(BaseDir, "data") 33 | TempDir = filepath.Join(os.TempDir(), "RethinkRAW") 34 | 35 | name := filepath.Base(os.Args[0]) 36 | if runtime.GOOS == "windows" { 37 | ServerMode = name == "RethinkRAW.com" 38 | dcraw.Path = BaseDir + `\utils\dcraw.wasm` 39 | exiftool.Exec = BaseDir + `\utils\exiftool\exiftool.exe` 40 | exiftool.Arg1 = strings.TrimSuffix(exiftool.Exec, ".exe") 41 | exiftool.Config = BaseDir + `\utils\exiftool_config.pl` 42 | } else { 43 | ServerMode = name == "rethinkraw-server" 44 | dcraw.Path = BaseDir + "/utils/dcraw.wasm" 45 | exiftool.Exec = BaseDir + "/utils/exiftool/exiftool" 46 | exiftool.Config = BaseDir + "/utils/exiftool_config.pl" 47 | } 48 | 49 | if testDataDir() == nil { 50 | return nil 51 | } 52 | if data, err := os.UserConfigDir(); err != nil { 53 | return err 54 | } else { 55 | DataDir = filepath.Join(data, "RethinkRAW") 56 | } 57 | return testDataDir() 58 | } 59 | 60 | func testDataDir() error { 61 | if err := os.MkdirAll(DataDir, 0700); err != nil { 62 | return err 63 | } 64 | if f, err := os.Create(filepath.Join(DataDir, "lastrun")); err != nil { 65 | return err 66 | } else { 67 | return f.Close() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /http_upload.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "mime/multipart" 6 | "net/http" 7 | "os" 8 | "path/filepath" 9 | ) 10 | 11 | func uploadHandler(w http.ResponseWriter, r *http.Request) httpResult { 12 | if isLocalhost(r) { 13 | return httpResult{Status: http.StatusForbidden} 14 | } 15 | if err := r.ParseMultipartForm(100 << 20); err != nil { 16 | return httpResult{Status: http.StatusBadRequest, Error: err} 17 | } 18 | 19 | multipartFile := func(key string) *multipart.FileHeader { 20 | if r.MultipartForm == nil { 21 | return nil 22 | } 23 | vs := r.MultipartForm.File[key] 24 | if len(vs) == 0 { 25 | return nil 26 | } 27 | return vs[0] 28 | } 29 | multipartValue := func(key string) string { 30 | if r.MultipartForm == nil { 31 | return "" 32 | } 33 | vs := r.MultipartForm.Value[key] 34 | if len(vs) == 0 { 35 | return "" 36 | } 37 | return vs[0] 38 | } 39 | 40 | prefix := getPathPrefix(r) 41 | root := fromURLPath(multipartValue("root"), prefix) 42 | path := filepath.Join(root, filepath.FromSlash(multipartValue("path"))) 43 | file, err := multipartFile("file").Open() 44 | if err != nil { 45 | return httpResult{Error: err} 46 | } 47 | defer file.Close() 48 | 49 | if fi, err := os.Stat(root); err != nil { 50 | return httpResult{Error: err} 51 | } else if !fi.IsDir() { 52 | return httpResult{Status: http.StatusForbidden} 53 | } 54 | 55 | if err := os.MkdirAll(filepath.Dir(path), 0777); err != nil { 56 | return httpResult{Error: err} 57 | } 58 | 59 | dest, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0666) 60 | if err != nil { 61 | return httpResult{Error: err} 62 | } 63 | defer dest.Close() 64 | 65 | _, err = io.Copy(dest, file) 66 | if err != nil { 67 | return httpResult{Error: err} 68 | } 69 | 70 | err = dest.Close() 71 | if err != nil { 72 | return httpResult{Error: err} 73 | } 74 | return httpResult{Status: http.StatusOK} 75 | } 76 | -------------------------------------------------------------------------------- /pkg/dng/dcp.go: -------------------------------------------------------------------------------- 1 | package dng 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "errors" 7 | "os" 8 | ) 9 | 10 | // GetDCPProfileName extracts the profile name from a DCP file. 11 | func GetDCPProfileName(path string) (string, error) { 12 | data, err := os.ReadFile(path) 13 | if err != nil { 14 | return "", err 15 | } 16 | if len(data) < 8 { 17 | return "", errors.New("dcp: could not read dcp header") 18 | } 19 | 20 | var endian binary.ByteOrder 21 | switch string(data[0:4]) { 22 | case "IIRC": 23 | endian = binary.LittleEndian 24 | case "MMCR": 25 | endian = binary.BigEndian 26 | default: 27 | return "", errors.New("dcp: could not read dcp header") 28 | } 29 | 30 | offset := endian.Uint32(data[4:]) 31 | if len(data) < int(offset)+2 { 32 | return "", errors.New("dcp: invalid offset") 33 | } 34 | 35 | count := endian.Uint16(data[offset:]) 36 | entries := data[offset+2:] 37 | if len(data) < 12*int(count) { 38 | return "", errors.New("dcp: invalid directory size") 39 | } else { 40 | entries = entries[:12*count] 41 | } 42 | 43 | for i := 0; i < len(entries); i += 12 { 44 | tag := endian.Uint16(entries[i:]) 45 | if tag == 0xc6f8 { // ProfileName 46 | typ := endian.Uint16(entries[i+2:]) // BYTE or ASCII 47 | cnt := endian.Uint32(entries[i+4:]) 48 | off := endian.Uint32(entries[i+8:]) 49 | 50 | if (typ == 1 || typ == 2) && cnt > 1 { 51 | var val []byte 52 | if cnt <= 4 { 53 | val = entries[i+8:][:cnt] 54 | } else if len(data) >= int(off+cnt) { 55 | val = data[off:][:cnt] 56 | } else { 57 | return "", errors.New("dcp: invalid offset") 58 | } 59 | if bytes.IndexByte(val, 0) != int(cnt-1) { // NUL terminator 60 | return "", errors.New("dcp: invalid profile name") 61 | } 62 | return string(val[:cnt-1]), nil 63 | } 64 | return "", errors.New("dcp: invalid profile name") 65 | } 66 | } 67 | 68 | return "", errors.New("dcp: no profile name") 69 | } 70 | -------------------------------------------------------------------------------- /assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | RethinkRAW 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 42 | 43 | 44 | 45 |
46 |

What do you want to do?

47 | 48 | 49 |
50 | 51 |
52 | About RethinkRAW 53 | 54 | 55 | -------------------------------------------------------------------------------- /http_gallery.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/fs" 5 | "net/http" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/ncruces/jason" 11 | "github.com/ncruces/rethinkraw/pkg/osutil" 12 | ) 13 | 14 | func galleryHandler(w http.ResponseWriter, r *http.Request) httpResult { 15 | if r := sendAllowed(w, r, "GET", "HEAD"); r.Done() { 16 | return r 17 | } 18 | prefix := getPathPrefix(r) 19 | path := fromURLPath(r.URL.Path, prefix) 20 | 21 | w.Header().Set("Cache-Control", "max-age=10") 22 | if r := sendCached(w, r, path); r.Done() { 23 | return r 24 | } 25 | 26 | if files, err := os.ReadDir(path); err != nil { 27 | return httpResult{Error: err} 28 | } else { 29 | data := struct { 30 | Upload bool 31 | Title, Path string 32 | Dirs, Photos []struct{ Name, Path string } 33 | JSON jason.Object 34 | }{ 35 | !isLocalhost(r), 36 | toUsrPath(path, prefix), 37 | toURLPath(path, prefix), 38 | nil, nil, jason.Object{}, 39 | } 40 | if data.Upload { 41 | data.JSON["uploadPath"] = data.Path 42 | data.JSON["uploadExts"] = extensions 43 | } 44 | 45 | for _, entry := range files { 46 | name := entry.Name() 47 | path := filepath.Join(path, name) 48 | item := struct{ Name, Path string }{name, toURLPath(path, prefix)} 49 | 50 | if osutil.HiddenFile(entry) { 51 | continue 52 | } 53 | if entry.Type().IsRegular() { 54 | if _, ok := extensions[strings.ToUpper(filepath.Ext(name))]; ok { 55 | data.Photos = append(data.Photos, item) 56 | } 57 | continue 58 | } 59 | if entry.Type()&os.ModeSymlink != 0 { 60 | fi, err := os.Stat(path) 61 | if err != nil { 62 | continue 63 | } 64 | entry = fs.FileInfoToDirEntry(fi) 65 | } 66 | if entry.IsDir() { 67 | data.Dirs = append(data.Dirs, item) 68 | } 69 | } 70 | 71 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 72 | return httpResult{ 73 | Error: templates.ExecuteTemplate(w, "gallery.gohtml", data), 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /pkg/dng/dng_test.go: -------------------------------------------------------------------------------- 1 | package dng_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ncruces/rethinkraw/pkg/dng" 7 | ) 8 | 9 | func TestGetTemperatureFromXY(t *testing.T) { 10 | cases := [][2]int{ 11 | {5500, 10}, 12 | {6500, 10}, 13 | {7500, 10}, 14 | {2850, 0}, 15 | {3800, 20}, 16 | {5500, 0}, 17 | } 18 | 19 | for _, p := range cases { 20 | x, y := dng.GetXYFromTemperature(p[0], p[1]) 21 | p0, p1 := dng.GetTemperatureFromXY(x, y) 22 | if p0 != p[0] || p1 != p[1] { 23 | t.Error(p, p0, p1) 24 | } 25 | } 26 | } 27 | 28 | func TestCameraProfile_GetTemperature(t *testing.T) { 29 | // For a RG/GB camera. 30 | { 31 | cam := dng.CameraProfile{ 32 | CalibrationIlluminant1: dng.LSStandardLightA, 33 | CalibrationIlluminant2: dng.LSD65, 34 | ColorMatrix1: []float64{0.9210, -0.4777, +0.0345, -0.4492, 1.3117, 0.1471, -0.0345, 0.0879, 0.6708}, 35 | ColorMatrix2: []float64{0.7657, -0.2847, -0.0607, -0.4083, 1.1966, 0.2389, -0.0684, 0.1418, 0.5844}, 36 | CameraCalibration1: []float64{0.9434, 0, 0, 0, 1, 0, 0, 0, 0.94}, 37 | CameraCalibration2: []float64{0.9434, 0, 0, 0, 1, 0, 0, 0, 0.94}, 38 | } 39 | 40 | temp, tint, err := cam.GetTemperature([]float64{0.346414, 1, 0.636816}) 41 | if temp != 6383 || tint != 1 || err != nil { 42 | t.Error(temp, tint, err) 43 | } 44 | } 45 | 46 | // For a 4 color RGB+E camera (F828). 47 | // Multipliers calculated with dcraw. 48 | { 49 | cam := dng.CameraProfile{ 50 | CalibrationIlluminant1: dng.LSStandardLightA, 51 | CalibrationIlluminant2: dng.LSD65, 52 | ColorMatrix1: []float64{0.8771, -0.3148, -0.0125, -0.5926, 1.2567, 0.3815, -0.0871, 0.1575, 0.6633, -0.4678, 0.8486, 0.4548}, 53 | ColorMatrix2: []float64{0.7925, -0.1910, -0.0776, -0.8227, 1.5459, 0.2998, -0.1517, 0.2198, 0.6817, -0.7241, 1.1401, 0.3481}, 54 | } 55 | 56 | temp, tint, err := cam.GetTemperature([]float64{1 / 1.080806, 1, 1 / 3.700866, 1 / 1.623588}) 57 | if temp != 2681 || tint != 28 || err != nil { 58 | t.Error(temp, tint, err) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /assets/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | RethinkRAW 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |

About RethinkRAW

17 |

18 | Copyright © 2019-2022, Nuno Cruces 19 |

20 |

21 | RethinkRAW is provided “as is,” without warranty of any kind, express or implied. 22 |

23 |

Credits

24 |
25 |
RethinkRAW is made possible by the following software:
26 | 32 |
33 |
34 |
RethinkRAW also requires:
35 | 43 |
44 |

Back

45 |

Go back to our welcome screen.

46 | 47 | 48 | -------------------------------------------------------------------------------- /pkg/optls/optls.go: -------------------------------------------------------------------------------- 1 | // Package optls lets you accept TLS and unencrypted connections on the same port. 2 | package optls 3 | 4 | import ( 5 | "crypto/tls" 6 | "io" 7 | "net" 8 | "sync/atomic" 9 | "time" 10 | ) 11 | 12 | // Listen creates a listener accepting connections on the given network address using [net.Listen]. 13 | func Listen(network, address string, config *tls.Config) (net.Listener, error) { 14 | inner, err := net.Listen(network, address) 15 | if err != nil { 16 | return nil, err 17 | } 18 | return NewListener(inner, config), nil 19 | } 20 | 21 | // NewListener creates a listener which accepts connections from an inner [net.Listener]. 22 | // If config is valid, and the client sends a ClientHello message, 23 | // the connection is wrapped with a [tls.Server]. 24 | func NewListener(inner net.Listener, config *tls.Config) net.Listener { 25 | if config == nil || len(config.Certificates) == 0 && 26 | config.GetCertificate == nil && config.GetConfigForClient == nil { 27 | return inner 28 | } 29 | return &listener{ 30 | Listener: inner, 31 | config: config, 32 | } 33 | } 34 | 35 | type listener struct { 36 | net.Listener 37 | config *tls.Config 38 | } 39 | 40 | func (l *listener) Accept() (net.Conn, error) { 41 | inner, err := l.Listener.Accept() 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | deadline := time.Now().Add(time.Second) 47 | inner.SetReadDeadline(deadline) 48 | defer inner.SetReadDeadline(time.Time{}) 49 | 50 | conn := conn{Conn: inner} 51 | conn.n, conn.err = inner.Read(conn.p[:]) 52 | if conn.n == 1 && conn.p[0] == 0x16 { 53 | return tls.Server(&conn, l.config), nil 54 | } 55 | return &conn, nil 56 | } 57 | 58 | type conn struct { 59 | net.Conn 60 | p [1]byte 61 | n int 62 | err error 63 | done atomic.Bool 64 | } 65 | 66 | func (c *conn) Read(b []byte) (int, error) { 67 | if len(b) > 0 && !c.done.Swap(true) { 68 | b[0] = c.p[0] 69 | return c.n, c.err 70 | } 71 | return c.Conn.Read(b) 72 | } 73 | 74 | func (c *conn) ReadFrom(r io.Reader) (int64, error) { 75 | return io.Copy(c.Conn, r) 76 | } 77 | 78 | func (c *conn) Close() error { 79 | defer c.done.Store(true) 80 | return c.Conn.Close() 81 | } 82 | -------------------------------------------------------------------------------- /pkg/osutil/file_windows.go: -------------------------------------------------------------------------------- 1 | package osutil 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | "strings" 9 | "syscall" 10 | "unicode/utf16" 11 | "unsafe" 12 | 13 | "golang.org/x/sys/windows" 14 | ) 15 | 16 | func isHidden(de os.DirEntry) bool { 17 | i, err := de.Info() 18 | if err != nil { 19 | return false 20 | } 21 | s, ok := i.Sys().(*syscall.Win32FileAttributeData) 22 | return ok && s.FileAttributes&(syscall.FILE_ATTRIBUTE_HIDDEN|syscall.FILE_ATTRIBUTE_SYSTEM) != 0 23 | } 24 | 25 | func open(file string) error { 26 | return exec.Command("rundll32", "url.dll,FileProtocolHandler", file).Run() 27 | } 28 | 29 | func isANSIString(s string) bool { 30 | ascii := true 31 | for _, c := range []byte(s) { 32 | if c < ' ' || c > '~' { 33 | ascii = false 34 | break 35 | } 36 | } 37 | if ascii { 38 | return true 39 | } 40 | 41 | var used int32 42 | long := utf16.Encode([]rune(s)) 43 | n, _, _ := wideCharToMultiByte.Call(0 /*CP_ACP*/, 0x400, /*WC_NO_BEST_FIT_CHARS*/ 44 | uintptr(unsafe.Pointer(&long[0])), uintptr(len(long)), 0, 0, 0, 45 | uintptr(unsafe.Pointer(&used))) 46 | 47 | return n > 0 && used == 0 48 | } 49 | 50 | func getANSIPath(path string) (string, error) { 51 | path = filepath.Clean(path) 52 | 53 | if len(path) < 260 && isANSIString(path) { 54 | return path, nil 55 | } 56 | 57 | vol := len(filepath.VolumeName(path)) 58 | for i := len(path); i >= vol; i-- { 59 | if i == len(path) || os.IsPathSeparator(path[i]) { 60 | file := path[:i] 61 | _, err := os.Stat(file) 62 | if err == nil { 63 | if filepath.IsAbs(file) { 64 | file = `\\?\` + file 65 | } 66 | if long, err := syscall.UTF16FromString(file); err == nil { 67 | short := [264]uint16{} 68 | n, _ := windows.GetShortPathName(&long[0], &short[0], 264) 69 | if 0 < n && n < 264 { 70 | file = syscall.UTF16ToString(short[:n]) 71 | path = strings.TrimPrefix(file, `\\?\`) + path[i:] 72 | if len(path) < 260 && isANSIString(path) { 73 | return path, nil 74 | } 75 | } 76 | } 77 | break 78 | } 79 | } 80 | } 81 | 82 | return path, errors.New("could not convert to ANSI path: " + path) 83 | } 84 | -------------------------------------------------------------------------------- /pkg/craw/profiles.go: -------------------------------------------------------------------------------- 1 | package craw 2 | 3 | import ( 4 | "errors" 5 | "io/fs" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/ncruces/rethinkraw/pkg/dng" 10 | ) 11 | 12 | // GetCameraProfiles gets all the profiles that apply to a given camera. 13 | // Returns the DCP file paths for the profiles. 14 | // It looks for profiles under the GlobalSettings and UserSettings directories. 15 | func GetCameraProfiles(make, model string) ([]string, error) { 16 | once.Do(initPaths) 17 | 18 | glb, err := LoadIndex(filepath.Join(GlobalSettings, filepath.FromSlash("CameraProfiles/Index.dat"))) 19 | if err != nil { 20 | return nil, err 21 | } 22 | usr, err := LoadIndex(filepath.Join(UserSettings, filepath.FromSlash("CameraProfiles/Index.dat"))) 23 | if err != nil && !errors.Is(err, fs.ErrNotExist) { 24 | return nil, err 25 | } 26 | 27 | make = strings.ToUpper(make) 28 | model = strings.ToUpper(model) 29 | makeModel := make + " " + model 30 | 31 | var profiles []string 32 | for _, rec := range append(glb, usr...) { 33 | test := strings.ToUpper(rec.Prop["model_restriction"]) 34 | var matches bool 35 | if test == "" || test == model || test == makeModel { 36 | matches = true 37 | } else if testMake, testModel, ok := strings.Cut(test, " "); ok { 38 | matches = testModel == model && strings.Contains(make, testMake) 39 | } 40 | if matches { 41 | profiles = append(profiles, rec.Path) 42 | } 43 | } 44 | 45 | return profiles, nil 46 | } 47 | 48 | // GetCameraProfileNames gets the names of all profiles that apply to a given camera. 49 | // It looks for profiles under the GlobalSettings and UserSettings directories, 50 | // and in the EmbedProfiles file, if set. 51 | func GetCameraProfileNames(make, model string) ([]string, error) { 52 | once.Do(initPaths) 53 | 54 | profiles, err := GetCameraProfiles(make, model) 55 | if err != nil { 56 | return nil, err 57 | } 58 | 59 | var names []string 60 | for _, profile := range profiles { 61 | name, err := dng.GetDCPProfileName(profile) 62 | if err != nil { 63 | return nil, err 64 | } 65 | names = append(names, name) 66 | } 67 | 68 | if make == "FUJIFILM" { 69 | embed, err := fujiCameraProfiles(model) 70 | if err != nil { 71 | return nil, err 72 | } 73 | names = append(names, embed...) 74 | } 75 | 76 | return names, nil 77 | } 78 | -------------------------------------------------------------------------------- /meta.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "log" 7 | "path/filepath" 8 | 9 | "github.com/ncruces/go-exiftool" 10 | ) 11 | 12 | var exifserver *exiftool.Server 13 | 14 | func setupExifTool() (s *exiftool.Server, err error) { 15 | exifserver, err = exiftool.NewServer("-ignoreMinorErrors", "-quiet", "-quiet") 16 | return exifserver, err 17 | } 18 | 19 | func getMetaHTML(path string) ([]byte, error) { 20 | log.Print("exiftool (get meta)...") 21 | return exifserver.Command("-htmlFormat", "-groupHeadings", "-long", "-fixBase", path) 22 | } 23 | 24 | func fixMetaDNG(orig, dest, name string) error { 25 | opts := []string{"-tagsFromFile", orig, "-fixBase", 26 | "-MakerNotes", "-OriginalRawFileName-=" + filepath.Base(orig)} 27 | if name != "" { 28 | opts = append(opts, "-OriginalRawFileName="+filepath.Base(name)) 29 | } 30 | opts = append(opts, "-overwrite_original", dest) 31 | 32 | log.Print("exiftool (fix dng)...") 33 | _, err := exifserver.Command(opts...) 34 | return err 35 | } 36 | 37 | func fixMetaJPEG(orig, dest string) error { 38 | opts := []string{"-tagsFromFile", orig, "-fixBase", 39 | "-CommonIFD0", "-ExifIFD:all", "-GPS:all", // https://exiftool.org/forum/index.php?topic=8378.msg43043#msg43043 40 | "-IPTC:all", "-XMP-dc:all", "-XMP-dc:Format=", 41 | "-fast", "-overwrite_original", dest} 42 | 43 | log.Print("exiftool (fix jpeg)...") 44 | _, err := exifserver.Command(opts...) 45 | return err 46 | } 47 | 48 | func dngHasEdits(path string) bool { 49 | log.Print("exiftool (has edits?)...") 50 | out, err := exifserver.Command("-XMP-photoshop:all", path) 51 | return err == nil && len(out) > 0 52 | } 53 | 54 | func cameraMatchingWhiteBalance(path string) string { 55 | log.Print("exiftool (get camera matching white balance)...") 56 | out, err := exifserver.Command("-duplicates", "-short3", "-fast", "-ExifIFD:WhiteBalance", "-MakerNotes:WhiteBalance", path) 57 | if err != nil { 58 | return "" 59 | } 60 | 61 | for scan := bufio.NewScanner(bytes.NewReader(out)); scan.Scan(); { 62 | switch wb := scan.Text(); wb { 63 | case "Auto", "Daylight", "Cloudy", "Shade", "Tungsten", "Fluorescent", "Flash": 64 | return wb 65 | case "Sunny": 66 | return "Daylight" 67 | case "Overcast": 68 | return "Cloudy" 69 | case "Incandescent": 70 | return "Tungsten" 71 | } 72 | } 73 | return "As Shot" 74 | } 75 | -------------------------------------------------------------------------------- /make_wine.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eo pipefail 3 | 4 | cd -P -- "$(dirname -- "$0")" 5 | 6 | tgt="RethinkRAW" 7 | 8 | mkdir -p "$tgt/utils" 9 | cp "build/exiftool_config.pl" "$tgt/utils" 10 | ln -sf rethinkraw "$tgt/rethinkraw-server" 11 | 12 | if [ ! -f "$tgt/utils/exiftool/exiftool" ]; then 13 | echo Download ExifTool... 14 | url="https://github.com/ncruces/go-exiftool/releases/download/dist/exiftool_unix.tgz" 15 | curl -sL "$url" | tar xz -C "$tgt/utils" 16 | fi 17 | 18 | if [ ! -f "$tgt/utils/dcraw.wasm" ]; then 19 | echo Download dcraw... 20 | url="https://github.com/ncruces/dcraw/releases/download/v9.28.8-wasm/dcraw.wasm.gz" 21 | curl -sL "$url" | gzip -dc > "$tgt/utils/dcraw.wasm" 22 | cp "$tgt/utils/dcraw.wasm" "pkg/dcraw/embed/dcraw.wasm" 23 | fi 24 | 25 | if [ ! -f "assets/normalize.css" ]; then 26 | echo Download normalize.css... 27 | curl -sL "https://unpkg.com/@csstools/normalize.css" > assets/normalize.css 28 | fi 29 | 30 | if [ ! -f "assets/dialog-polyfill.js" ]; then 31 | echo Download dialog-polyfill... 32 | curl -sL "https://unpkg.com/dialog-polyfill@0.5/dist/dialog-polyfill.js" > assets/dialog-polyfill.js 33 | curl -sL "https://unpkg.com/dialog-polyfill@0.5/dist/dialog-polyfill.css" > assets/dialog-polyfill.css 34 | fi 35 | 36 | if [ ! -f "assets/fontawesome.css" ]; then 37 | echo Download Font Awesome... 38 | curl -sL "https://unpkg.com/@fortawesome/fontawesome-free@5.x/css/fontawesome.css" > assets/fontawesome.css 39 | curl -sL "https://unpkg.com/@fortawesome/fontawesome-free@5.x/webfonts/fa-solid-900.woff2" > assets/fa-solid-900.woff2 40 | fi 41 | 42 | if [[ "$1" == test ]]; then 43 | echo Run tests... 44 | go test ./... 45 | elif [[ "$1" == run ]]; then 46 | echo Run app... 47 | go build -race -o "$tgt/rethinkraw" && shift && exec "$tgt/rethinkraw" "$@" 48 | elif [[ "$1" == serve ]]; then 49 | echo Run server... 50 | go build -race -o "$tgt/rethinkraw" && shift && exec "$tgt/rethinkraw-server" "$@" 51 | elif [[ "$1" == install ]]; then 52 | echo Build installer... 53 | rm -rf "$tgt/data" 54 | export COPYFILE_DISABLE=1 55 | cp "assets/favicon-192.png" "$tgt/icon.png" 56 | tar cfj RethinkRAW.tbz "$tgt" 57 | else 58 | echo Build release... 59 | export CGO_ENABLED=0 60 | go clean 61 | GOOS= GOARCH= go generate 62 | go build -tags memfs -ldflags "-s -w" -trimpath -o "$tgt/rethinkraw" 63 | fi -------------------------------------------------------------------------------- /assets/gallery.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | RethinkRAW: {{.Title}} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {{- template "const.gohtml" .JSON}} 20 | 21 | 22 | 23 |

39 |
40 | 47 |
48 | 49 | 50 | Lorem ipsum
51 | 52 |
53 | 54 | 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RethinkRAW [R](https://rethinkraw.com) 2 | 3 | RethinkRAW is an unpretentious, free RAW photo editor. 4 | 5 | ## Install 6 | 7 | On Windows using [Scoop](https://scoop.sh/) 🍨: 8 | 9 | scoop install https://ncruces.github.io/scoop/RethinkRAW.json 10 | 11 | On macOS using [Homebrew](https://brew.sh/) 🍺: 12 | 13 | brew install ncruces/tap/rethinkraw 14 | 15 | Or download the [latest release](https://github.com/ncruces/RethinkRAW/releases/latest). 16 | 17 | ## Build 18 | 19 | Download and unpack the [latest source code](https://github.com/ncruces/RethinkRAW/releases/latest) 20 | and run: 21 | - [`./make.cmd`](https://github.com/ncruces/RethinkRAW/blob/master/make.cmd) (on Windows) 22 | - [`./make.sh`](https://github.com/ncruces/RethinkRAW/blob/master/make.sh) (on macOS) or 23 | - [`./make_wine.sh`](https://github.com/ncruces/RethinkRAW/blob/master/make_wine.sh) (elsewhere) 24 | 25 | ## Features 26 | 27 | RethinkRAW works like a simplified, standalone version of Camera Raw. 28 | You can edit your photos without first importing them into a catalog, 29 | and it doesn't require Photoshop. 30 | Yet, it integrates nicely into an Adobe workflow. 31 | 32 | You get all the basic, familiar knobs, 33 | and your edits are loaded from, and saved to, 34 | Adobe compatible XMP sidecars and DNGs. 35 | This means you can later move on to Adobe tools, 36 | without losing any of your edits. 37 | 38 | To achieve this, RethinkRAW leverages the free 39 | [Adobe DNG Converter](https://helpx.adobe.com/photoshop/using/adobe-dng-converter.html). 40 | 41 | ## Server mode 42 | 43 | RethinkRAW can act like a server that you can access remotely. 44 | 45 | On Windows run: 46 | 47 | [PATH_TO]\RethinkRAW.com --password [SECRET] [DIRECTORY] 48 | 49 | On macOS run: 50 | 51 | /Applications/RethinkRAW.app/Contents/Resources/rethinkraw-server --password [SECRET] [DIRECTORY] 52 | 53 | Elsewhere run: 54 | 55 | [PATH_TO]/rethinkraw-server --password [SECRET] [DIRECTORY] 56 | 57 | You can edit photos in `DIRECTORY` by visiting: 58 | - http://local.app.rethinkraw.com:39639 (on the same computer) or 59 | - http://127.0.0.1:39639 (replacing ***127.0.0.1*** by your IP address) 60 | 61 | ## Screenshots 62 | 63 | ![Welcome screen](screens/welcome.png) 64 | 65 | #### Browsing photos 66 | 67 | ![Browsing photos](screens/browse.png) 68 | 69 | #### Editing a photo 70 | 71 | ![Editing a photo](screens/edit.png) 72 | -------------------------------------------------------------------------------- /assets/photo.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | height: 100vh; 4 | overflow: hidden; 5 | } 6 | 7 | #box1 { 8 | height: 100%; 9 | padding: 8px; 10 | } 11 | 12 | #box2 { 13 | height: calc(100% - 62px); 14 | position: relative; 15 | margin-top: 8px; 16 | overflow: hidden; 17 | } 18 | 19 | #spinner { 20 | position: absolute; 21 | top: 50%; 22 | left: 50%; 23 | font-size: 1.5rem; 24 | color: whitesmoke; 25 | pointer-events: none; 26 | } 27 | 28 | img#photo { 29 | width: 100%; 30 | height: 100%; 31 | object-fit: contain; 32 | transition: transform 0.1s ease; 33 | } 34 | 35 | img#photo:after { 36 | position: absolute; 37 | display: block; 38 | top: 0; 39 | left: 0; 40 | right: 0; 41 | bottom: 0; 42 | padding-top: 32px; 43 | text-align: center; 44 | background-color: #fff; 45 | content: 'Preview failed to load.'; 46 | } 47 | 48 | img#print { 49 | display: none; 50 | } 51 | 52 | @media print { 53 | img#print { 54 | display: block; 55 | visibility: visible; 56 | image-orientation: none; 57 | object-fit: contain; 58 | position: fixed; 59 | top: 0; 60 | left: 0; 61 | right: 0; 62 | bottom: 0; 63 | width: 100%; 64 | height: 100%; 65 | } 66 | } 67 | 68 | dialog#meta-dialog { 69 | overflow-y: auto; 70 | font-size: small; 71 | max-width: 50rem; 72 | max-height: 30rem; 73 | } 74 | 75 | dialog#meta-dialog td { 76 | min-width: 25ch; 77 | } 78 | 79 | dialog#progress-dialog { 80 | width: 10rem; 81 | } 82 | 83 | dialog#progress-dialog progress { 84 | margin-top: 0.2rem; 85 | max-width: 100%; 86 | } 87 | 88 | dialog#export-dialog { 89 | width: 20rem; 90 | } 91 | 92 | form#export-form div { 93 | margin-top: 0.4rem; 94 | font-size: small; 95 | display: grid; 96 | grid-gap: 0.4rem; 97 | grid-auto-rows: 1fr; 98 | grid-template-columns: repeat(8, 1fr); 99 | } 100 | 101 | form#export-form>:first-child { 102 | margin-top: 0; 103 | } 104 | 105 | form#export-form input[type=number] { 106 | width: 4.5rem; 107 | } 108 | 109 | form#export-form input[type=checkbox] { 110 | height: 100%; 111 | } 112 | 113 | form#export-form span, 114 | form#export-form label { 115 | padding: 2px 0; 116 | white-space: nowrap; 117 | } 118 | 119 | form#export-form span { 120 | padding-left: 2px; 121 | } -------------------------------------------------------------------------------- /pkg/dcraw/fs.go: -------------------------------------------------------------------------------- 1 | package dcraw 2 | 3 | import ( 4 | "io" 5 | "io/fs" 6 | "time" 7 | ) 8 | 9 | // These implement an [fs.FS] with a single root directory, 10 | // and a single file in that directory, named [readerFSname], 11 | // that reads from the [io.ReadSeeker]. 12 | 13 | type readerFS struct{ r io.ReadSeeker } 14 | type readerDir struct{ r io.ReadSeeker } 15 | type readerFile struct{ io.ReadSeeker } 16 | 17 | const readerFSname = "input" 18 | 19 | func (f readerFS) Open(name string) (fs.File, error) { 20 | if name == "." { 21 | return &readerDir{f.r}, nil 22 | } 23 | if name == readerFSname { 24 | _, err := f.r.Seek(0, io.SeekStart) 25 | if err != nil { 26 | return nil, err 27 | } 28 | return readerFile{f.r}, nil 29 | } 30 | if fs.ValidPath(name) { 31 | return nil, fs.ErrNotExist 32 | } 33 | return nil, fs.ErrInvalid 34 | } 35 | 36 | func (f readerFile) Close() error { return nil } 37 | 38 | func (f readerFile) Stat() (fs.FileInfo, error) { return f, nil } 39 | 40 | func (f readerFile) Size() int64 { 41 | current, err := f.Seek(0, io.SeekCurrent) 42 | if err != nil { 43 | return 0 44 | } 45 | end, _ := f.Seek(0, io.SeekEnd) 46 | f.Seek(current, io.SeekStart) 47 | return end 48 | } 49 | 50 | func (f readerFile) IsDir() bool { return false } 51 | 52 | func (f readerFile) ModTime() time.Time { return time.Time{} } 53 | 54 | func (f readerFile) Mode() fs.FileMode { return 0400 } 55 | 56 | func (f readerFile) Name() string { return readerFSname } 57 | 58 | func (f readerFile) Sys() any { return nil } 59 | 60 | func (f readerFile) Info() (fs.FileInfo, error) { return f, nil } 61 | 62 | func (f readerFile) Type() fs.FileMode { return f.Mode().Type() } 63 | 64 | func (d *readerDir) Close() error { 65 | d.r = nil 66 | return nil 67 | } 68 | 69 | func (d *readerDir) ReadDir(n int) (entries []fs.DirEntry, err error) { 70 | switch { 71 | case d.r != nil: 72 | entries = []fs.DirEntry{readerFile{d.r}} 73 | d.r = nil 74 | case n > 0: 75 | err = io.EOF 76 | } 77 | return 78 | } 79 | 80 | func (d *readerDir) Read([]byte) (int, error) { return 0, nil } 81 | 82 | func (d *readerDir) Stat() (fs.FileInfo, error) { return d, nil } 83 | 84 | func (d *readerDir) IsDir() bool { return true } 85 | 86 | func (d *readerDir) ModTime() time.Time { return time.Time{} } 87 | 88 | func (d *readerDir) Mode() fs.FileMode { return fs.ModeDir | 0700 } 89 | 90 | func (d *readerDir) Name() string { return "." } 91 | 92 | func (d *readerDir) Size() int64 { return 0 } 93 | 94 | func (d *readerDir) Sys() any { return nil } 95 | -------------------------------------------------------------------------------- /http_dialog.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "io/fs" 6 | "net/http" 7 | "net/url" 8 | "os" 9 | 10 | "github.com/ncruces/zenity" 11 | ) 12 | 13 | var extensions = map[string]struct{}{} 14 | var filters zenity.FileFilter 15 | 16 | func init() { 17 | pattern := []string{ 18 | "public.camera-raw-image", 19 | "*.CRW", "*.NEF", "*.RAF", "*.ORF", "*.MRW", "*.DCR", "*.MOS", "*.RAW", "*.PEF", "*.SRF", 20 | "*.DNG", "*.X3F", "*.CR2", "*.ERF", "*.SR2", "*.KDC", "*.MFW", "*.MEF", "*.ARW", "*.NRW", 21 | "*.RW2", "*.RWL", "*.IIQ", "*.3FR", "*.FFF", "*.SRW", "*.GPR", "*.DXO", "*.ARQ", "*.CR3", 22 | } 23 | 24 | filters = zenity.FileFilter{Name: "RAW photos", Patterns: pattern, CaseFold: true} 25 | for _, ext := range pattern[1:] { 26 | extensions[ext[1:]] = struct{}{} 27 | } 28 | } 29 | 30 | func dialogHandler(_ http.ResponseWriter, r *http.Request) httpResult { 31 | if !isLocalhost(r) { 32 | return httpResult{Status: http.StatusForbidden} 33 | } 34 | if err := r.ParseForm(); err != nil { 35 | return httpResult{Status: http.StatusBadRequest, Error: err} 36 | } 37 | 38 | var err error 39 | var path string 40 | var paths []string 41 | 42 | _, photo := r.Form["photo"] 43 | _, batch := r.Form["batch"] 44 | _, gallery := r.Form["gallery"] 45 | 46 | switch { 47 | case batch: 48 | paths, err = zenity.SelectFileMultiple(zenity.Context(r.Context()), filters) 49 | case photo: 50 | path, err = zenity.SelectFile(zenity.Context(r.Context()), filters) 51 | case gallery: 52 | path, err = zenity.SelectFile(zenity.Context(r.Context()), zenity.Directory()) 53 | default: 54 | return httpResult{Status: http.StatusNotFound} 55 | } 56 | 57 | if path != "" { 58 | if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { 59 | return httpResult{Status: http.StatusUnprocessableEntity, Message: err.Error()} 60 | } else if err != nil { 61 | return httpResult{Error: err} 62 | } 63 | } else if len(paths) != 0 { 64 | path = toBatchPath(paths...) 65 | } else if errors.Is(err, zenity.ErrCanceled) { 66 | return httpResult{Status: http.StatusResetContent} 67 | } else if err == nil { 68 | return httpResult{Status: http.StatusInternalServerError} 69 | } else { 70 | return httpResult{Error: err} 71 | } 72 | 73 | var url url.URL 74 | switch { 75 | case batch: 76 | url.Path = "/batch/" + path 77 | case photo: 78 | url.Path = "/photo/" + toURLPath(path, "") 79 | case gallery: 80 | url.Path = "/gallery/" + toURLPath(path, "") 81 | } 82 | return httpResult{ 83 | Status: http.StatusSeeOther, 84 | Location: url.String(), 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /make.cmd: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | SETLOCAL EnableDelayedExpansion 3 | 4 | CD /D "%~dp0" 5 | 6 | SET "tgt=RethinkRAW" 7 | 8 | IF NOT EXIST %tgt%\utils MKDIR %tgt%\utils 9 | COPY build\exiftool_config.pl %tgt%\utils >NUL 10 | 11 | IF NOT EXIST %tgt%\utils\exiftool\exiftool.exe ( 12 | ECHO Download ExifTool... 13 | SET "url=https://github.com/ncruces/go-exiftool/releases/download/dist/exiftool_windows.zip" 14 | go run github.com/ncruces/go-fetch -unpack !url! %tgt%\utils 15 | ) 16 | 17 | IF NOT EXIST %tgt%\utils\dcraw.wasm ( 18 | ECHO Download dcraw... 19 | SET "url=https://github.com/ncruces/dcraw/releases/download/v9.28.8-wasm/dcraw.wasm.gz" 20 | go run github.com/ncruces/go-fetch -unpack !url! %tgt%\utils 21 | COPY /Y %tgt%\utils\dcraw.wasm pkg\dcraw\embed\dcraw.wasm 22 | ) 23 | 24 | IF NOT EXIST assets\normalize.css ( 25 | ECHO Download normalize.css... 26 | go run github.com/ncruces/go-fetch "https://unpkg.com/@csstools/normalize.css" assets\normalize.css 27 | ) 28 | 29 | IF NOT EXIST assets\dialog-polyfill.js ( 30 | ECHO Download dialog-polyfill... 31 | go run github.com/ncruces/go-fetch "https://unpkg.com/dialog-polyfill@0.5/dist/dialog-polyfill.js" assets\dialog-polyfill.js 32 | go run github.com/ncruces/go-fetch "https://unpkg.com/dialog-polyfill@0.5/dist/dialog-polyfill.css" assets\dialog-polyfill.css 33 | ) 34 | 35 | IF NOT EXIST assets\fontawesome.css ( 36 | ECHO Download Font Awesome... 37 | go run github.com/ncruces/go-fetch "https://unpkg.com/@fortawesome/fontawesome-free@5.x/css/fontawesome.css" assets\fontawesome.css 38 | go run github.com/ncruces/go-fetch "https://unpkg.com/@fortawesome/fontawesome-free@5.x/webfonts/fa-solid-900.woff2" assets\fa-solid-900.woff2 39 | ) 40 | 41 | IF [%1]==[test] ( 42 | ECHO Run tests... 43 | go test .\... 44 | ) ELSE IF [%1]==[run] ( 45 | ECHO Run app... 46 | go build -race -o %tgt%\RethinkRAW.exe && %tgt%\RethinkRAW.exe 47 | ) ELSE IF [%1]==[run] ( 48 | ECHO Run server... 49 | go build -race -o %tgt%\RethinkRAW.com && %tgt%\RethinkRAW.com -pass= . 50 | ) ELSE IF [%1]==[install] ( 51 | ECHO Build installer... 52 | IF EXIST %tgt%\data RMDIR /S /Q %tgt%\data 53 | IF EXIST %tgt%\debug.log DEL /Q %tgt%\debug.log 54 | 7z a -mx9 -myx9 -sfx7z.sfx RethinkRAW.exe %tgt% 55 | ) ELSE ( 56 | ECHO Release build... 57 | go run github.com/josephspurrier/goversioninfo/cmd/goversioninfo -64 build/versioninfo.json 58 | SET CGO_ENABLED=0 59 | SET GOOS=windows 60 | go clean 61 | go generate ^ 62 | && go build -tags memfs -ldflags "-s -w" -trimpath -o %tgt%\RethinkRAW.com ^ 63 | && go build -tags memfs -ldflags "-s -w -H windowsgui" -trimpath -o %tgt%\RethinkRAW.exe 64 | ) -------------------------------------------------------------------------------- /pkg/craw/fuji.go: -------------------------------------------------------------------------------- 1 | package craw 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | func fujiCameraProfiles(model string) ([]string, error) { 12 | if EmbedProfiles == "" { 13 | return nil, nil 14 | } 15 | 16 | f, err := os.Open(EmbedProfiles) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | prefix := strings.ReplaceAll(model, " ", "_") + "_Camera_" 22 | 23 | scan := bufio.NewScanner(f) 24 | scan.Split(func(data []byte, atEOF bool) (advance int, token []byte, err error) { 25 | if begin := bytes.Index(data, []byte(prefix)); begin >= 0 { 26 | if end := bytes.IndexByte(data[begin+len(prefix):], 0); end >= 0 { 27 | last := begin + len(prefix) + end 28 | return last + 1, data[begin+len(prefix) : last], nil 29 | } 30 | advance = begin 31 | } else { 32 | advance = len(data) - len(prefix) + 1 33 | } 34 | 35 | if atEOF { 36 | return 0, nil, io.EOF 37 | } 38 | if advance < 0 { 39 | return 0, nil, nil 40 | } 41 | return advance, nil, nil 42 | }) 43 | 44 | var profiles []string 45 | for scan.Scan() { 46 | var name string 47 | id := strings.ToUpper(scan.Text()) 48 | 49 | switch strings.TrimSuffix(id, "_V2") { 50 | default: 51 | continue 52 | case "PROVIA_STANDARD": 53 | name = "Camera PROVIA/Standard" 54 | case "VELVIA_VIVID": 55 | name = "Camera Velvia/Vivid" 56 | case "ASTIA_SOFT": 57 | name = "Camera ASTIA/Soft" 58 | case "PRO_NEG_HI": 59 | name = "Camera Pro Neg Hi" 60 | case "PRO_NEG_STD": 61 | name = "Camera Pro Neg Std" 62 | case "MONOCHROME": 63 | name = "Camera Monochrome" 64 | case "MONOCHROME_YE_FILTER": 65 | name = "Camera Monochrome+Ye Filter" 66 | case "MONOCHROME_R_FILTER": 67 | name = "Camera Monochrome+R Filter" 68 | case "MONOCHROME_G_FILTER": 69 | name = "Camera Monochrome+G Filter" 70 | case "ACROS": 71 | name = "Camera ACROS" 72 | case "ACROS_YE_FILTER": 73 | name = "Camera ACROS+Ye Filter" 74 | case "ACROS_R_FILTER": 75 | name = "Camera ACROS+R Filter" 76 | case "ACROS_G_FILTER": 77 | name = "Camera ACROS+G Filter" 78 | case "CLASSIC_CHROME": 79 | name = "Camera CLASSIC CHROME" 80 | case "ETERNA_CINEMA": 81 | name = "Camera ETERNA/Cinema" 82 | } 83 | if strings.HasSuffix(id, "_V2") { 84 | name += " v2" 85 | } 86 | 87 | if !contains(profiles, name) { 88 | profiles = append(profiles, name) 89 | } 90 | } 91 | 92 | return profiles, scan.Err() 93 | } 94 | 95 | func contains(a []string, s string) bool { 96 | for _, v := range a { 97 | if s == v { 98 | return true 99 | } 100 | } 101 | return false 102 | } 103 | -------------------------------------------------------------------------------- /assets/batch.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | RethinkRAW: Batch processing {{len .Photos}} photos 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {{- template "raw-editor.gohtml" "hidden"}} 25 | 26 | 40 | 49 | 50 | 51 | Lorem ipsum
52 | 53 |
54 | 55 | 56 | -------------------------------------------------------------------------------- /batch.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "compress/flate" 5 | "context" 6 | "encoding/base64" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | 12 | "github.com/ncruces/rethinkraw/pkg/osutil" 13 | "golang.org/x/sync/errgroup" 14 | ) 15 | 16 | func toBatchPath(paths ...string) string { 17 | var buf strings.Builder 18 | b64 := base64.NewEncoder(base64.RawURLEncoding, &buf) 19 | flt, err := flate.NewWriter(b64, flate.BestCompression) 20 | if err != nil { 21 | panic(err) 22 | } 23 | for _, path := range paths { 24 | flt.Write([]byte(path)) 25 | flt.Write([]byte{0}) 26 | } 27 | flt.Close() 28 | b64.Close() 29 | return buf.String() 30 | } 31 | 32 | func fromBatchPath(path string) []string { 33 | b64 := base64.NewDecoder(base64.RawURLEncoding, strings.NewReader(strings.TrimPrefix(path, "/"))) 34 | flt := flate.NewReader(b64) 35 | var buf strings.Builder 36 | _, err := io.Copy(&buf, flt) 37 | if err != nil { 38 | return nil 39 | } 40 | str := buf.String() 41 | return strings.Split(strings.TrimSuffix(str, "\x00"), "\x00") 42 | } 43 | 44 | type batchPhoto struct { 45 | Path string 46 | Name string 47 | } 48 | 49 | func findPhotos(batch []string) ([]batchPhoto, error) { 50 | var photos []batchPhoto 51 | for _, path := range batch { 52 | var prefix string 53 | if len(batch) > 1 { 54 | prefix, _ = filepath.Split(path) 55 | } else { 56 | prefix = path + string(filepath.Separator) 57 | } 58 | err := filepath.WalkDir(path, func(path string, entry os.DirEntry, err error) error { 59 | if err != nil { 60 | return err 61 | } 62 | if osutil.HiddenFile(entry) { 63 | if entry.IsDir() { 64 | return filepath.SkipDir 65 | } 66 | return nil 67 | } 68 | if entry.Type().IsRegular() { 69 | if _, ok := extensions[strings.ToUpper(filepath.Ext(path))]; ok { 70 | var name string 71 | if strings.HasPrefix(path, prefix) { 72 | name = path[len(prefix):] 73 | } else { 74 | _, name = filepath.Split(path) 75 | } 76 | photos = append(photos, batchPhoto{path, name}) 77 | } 78 | } 79 | return nil 80 | }) 81 | if err != nil { 82 | return nil, err 83 | } 84 | } 85 | return photos, nil 86 | } 87 | 88 | func batchProcess(ctx context.Context, photos []batchPhoto, proc func(ctx context.Context, photo batchPhoto) error) <-chan error { 89 | const parallelism = 6 90 | output := make(chan error, parallelism) 91 | 92 | go func() { 93 | group, ctx := errgroup.WithContext(ctx) 94 | group.SetLimit(parallelism) 95 | for _, photo := range photos { 96 | photo := photo 97 | group.Go(func() error { 98 | output <- proc(ctx, photo) 99 | return nil 100 | }) 101 | } 102 | group.Wait() 103 | close(output) 104 | }() 105 | 106 | return output 107 | } 108 | -------------------------------------------------------------------------------- /pkg/craw/index.go: -------------------------------------------------------------------------------- 1 | package craw 2 | 3 | import ( 4 | "encoding/binary" 5 | "errors" 6 | "io" 7 | "os" 8 | ) 9 | 10 | // IndexedRecord holds properties for an indexed file. 11 | type IndexedRecord struct { 12 | Path string // The path to the indexed file. 13 | Prop map[string]string // The name-value property pairs. 14 | } 15 | 16 | // LoadIndex loads an Index.dat file. 17 | // Index.dat files index the profiles, presets and other settings 18 | // in a Camera Raw settings directory for faster access. 19 | func LoadIndex(path string) ([]IndexedRecord, error) { 20 | f, err := os.Open(path) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | // Index.dat files are a collection of records. 26 | // 27 | // There's an 8 byte file header: 28 | // - a 4 byte number, presumably the index's version 29 | // - a 4 byte number, the number of records in the index 30 | // 31 | // Then, follow N records: 32 | // - a string, the file path to which the record refers 33 | // - a 8 byte record header, contents unknown 34 | // - a 4 byte number, the number of properties for the record, and 35 | // - 2*N strings, N name-value property pairs 36 | // 37 | // Numbers are stored in little endian. 38 | // Strings are stored with a 4 byte length, followed by N bytes of content, 39 | // and a null terminator byte. 40 | 41 | var buf [12]byte 42 | 43 | // Read the 8 byte file header 44 | if _, err := io.ReadFull(f, buf[:8]); err == io.EOF { 45 | return nil, nil 46 | } else if err != nil { 47 | return nil, err 48 | } 49 | 50 | count := int(binary.LittleEndian.Uint32(buf[4:])) 51 | index := make([]IndexedRecord, count) 52 | 53 | for i := 0; i < count; i++ { 54 | index[i].Path, err = readString(f) 55 | if err != nil { 56 | return index, err 57 | } 58 | 59 | index[i].Path = fixPath(index[i].Path) 60 | 61 | // Read the 12 byte record header 62 | if _, err := io.ReadFull(f, buf[:12]); err != nil { 63 | return index, err 64 | } 65 | 66 | count := int(binary.LittleEndian.Uint32(buf[8:])) 67 | index[i].Prop = make(map[string]string, count) 68 | 69 | for j := 0; j < count; j++ { 70 | key, err := readString(f) 71 | if err != nil { 72 | return index, err 73 | } 74 | 75 | val, err := readString(f) 76 | if err != nil { 77 | return index, err 78 | } 79 | 80 | index[i].Prop[key] = val 81 | } 82 | } 83 | 84 | return index, nil 85 | } 86 | 87 | func readString(r io.Reader) (string, error) { 88 | var buf [4]byte 89 | 90 | // Read the string length 91 | if _, err := io.ReadFull(r, buf[:]); err != nil { 92 | return "", err 93 | } 94 | 95 | len := int(binary.LittleEndian.Uint32(buf[:])) 96 | 97 | // Read the string with null terminator 98 | var str = make([]byte, len+1) 99 | if _, err := io.ReadFull(r, str); err != nil { 100 | return "", err 101 | } 102 | if str[len] != 0 { 103 | return "", errors.New("string not null terminated") 104 | } 105 | 106 | return string(str[:len]), nil 107 | } 108 | -------------------------------------------------------------------------------- /assets/main.css: -------------------------------------------------------------------------------- 1 | @import 'normalize.css'; 2 | @import 'fontawesome.css'; 3 | 4 | body { 5 | font-family: -apple-system, 'Segoe UI', system-ui, 'Roboto', 'Helvetica Neue', sans-serif; 6 | user-select: none; 7 | } 8 | 9 | @media print { 10 | html, body { 11 | height: 100%; 12 | margin: 0; 13 | padding: 0; 14 | overflow: hidden; 15 | } 16 | @page { 17 | size: landscape; 18 | margin: 0; 19 | padding: 0; 20 | } 21 | * { 22 | visibility: hidden; 23 | margin: 0; 24 | padding: 0; 25 | } 26 | } 27 | 28 | [hidden] { 29 | display: none !important; 30 | } 31 | 32 | #menu { 33 | display: flex; 34 | flex-flow: row wrap; 35 | max-width: 960px; 36 | padding-bottom: 0.25em; 37 | border-bottom: 1px solid black; 38 | font-weight: bold; 39 | } 40 | 41 | #menu>* { 42 | margin: 0.1rem; 43 | } 44 | 45 | #menu * { 46 | vertical-align: middle; 47 | } 48 | 49 | #menu button { 50 | font-weight: bold; 51 | font-size: medium; 52 | cursor: pointer; 53 | } 54 | 55 | #menu .toolbar { 56 | width: 100%; 57 | overflow: hidden; 58 | white-space: nowrap; 59 | } 60 | 61 | #menu .toolbar>button { 62 | font-size: 0; 63 | padding: 0; 64 | width: 30px; 65 | height: 30px; 66 | } 67 | 68 | #menu .toolbar>button.pushed { 69 | border: 1px inset; 70 | } 71 | 72 | #menu .toolbar>button>i { 73 | width: 1rem; 74 | height: 1rem; 75 | font-size: 1rem; 76 | } 77 | 78 | #menu .toolbar>button:not(.pushed)>i.pushed { 79 | display: none; 80 | } 81 | 82 | #menu .toolbar>button.pushed>i:not(.pushed) { 83 | display: none; 84 | } 85 | 86 | #menu .toolbar>span { 87 | margin-left: 1ch; 88 | } 89 | 90 | @media (display-mode: browser) { 91 | #menu .minimal-ui { 92 | display: none; 93 | } 94 | } 95 | 96 | #menu-sticker { 97 | position: sticky; 98 | top: 0; 99 | background: white; 100 | margin-bottom: -2px; 101 | padding: 8px 0 2px 8px; 102 | } 103 | 104 | #drop-target { 105 | height: 100%; 106 | } 107 | 108 | #gallery { 109 | display: flex; 110 | flex-flow: row wrap; 111 | padding: 8px 0 8px 8px; 112 | } 113 | 114 | #gallery span { 115 | text-align: center; 116 | width: 100%; 117 | max-width: 960px; 118 | padding-top: 32px; 119 | } 120 | 121 | #gallery a { 122 | height: 192px; 123 | margin: 0.1rem; 124 | } 125 | 126 | #gallery a>img { 127 | height: 100%; 128 | min-width: 108px; 129 | max-width: 384px; 130 | object-fit: cover; 131 | background-color: whitesmoke; 132 | } 133 | 134 | @font-face { 135 | font-family: 'Font Awesome 5 Free'; 136 | font-style: normal; 137 | font-weight: 900; 138 | font-display: block; 139 | src: url("fa-solid-900.woff2"); 140 | } 141 | 142 | .fas { 143 | font-family: 'Font Awesome 5 Free'; 144 | font-weight: 900; 145 | } 146 | 147 | .fa-rotate-cw { 148 | transform: scaleX(-1) rotate(-45deg); 149 | } 150 | 151 | .fa-rotate-ccw { 152 | transform: rotate(-45deg); 153 | } 154 | 155 | .fa-rotate-cw::before { 156 | content: '\f2ea'; 157 | } 158 | 159 | .fa-rotate-ccw::before { 160 | content: '\f2ea'; 161 | } -------------------------------------------------------------------------------- /make.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eo pipefail 3 | 4 | cd -P -- "$(dirname -- "$0")" 5 | 6 | app="RethinkRAW.app/Contents/Resources" 7 | tgt="RethinkRAW.app/Contents/Resources/RethinkRAW.app/Contents" 8 | 9 | osacompile -l JavaScript -o RethinkRAW.app build/droplet.js 10 | mkdir -p "$tgt/Resources" 11 | mkdir -p "$tgt/MacOS/utils" 12 | cp "build/app.plist" "$tgt/Info.plist" 13 | cp "build/icon.icns" "$tgt/Resources/" 14 | cp "build/icon.icns" "$app/droplet.icns" 15 | cp "build/exiftool_config.pl" "$tgt/MacOS/utils" 16 | plutil -replace CFBundleVersion -string "0.10.7" RethinkRAW.app/Contents/Info.plist 17 | plutil -replace CFBundleDocumentTypes -xml "$(cat build/doctypes.plist)" RethinkRAW.app/Contents/Info.plist 18 | ln -sf "RethinkRAW.app/Contents/MacOS/rethinkraw" "$app/rethinkraw-server" 19 | 20 | if [ ! -f "$tgt/MacOS/utils/exiftool/exiftool" ]; then 21 | echo Download ExifTool... 22 | url="https://github.com/ncruces/go-exiftool/releases/download/dist/exiftool_unix.tgz" 23 | curl -sL "$url" | tar xz -C "$tgt/MacOS/utils" 24 | fi 25 | 26 | if [ ! -f "$tgt/MacOS/utils/dcraw.wasm" ]; then 27 | echo Download dcraw... 28 | url="https://github.com/ncruces/dcraw/releases/download/v9.28.9-wasm/dcraw.wasm.gz" 29 | curl -sL "$url" | gzip -dc > "$tgt/MacOS/utils/dcraw.wasm" 30 | cp "$tgt/MacOS/utils/dcraw.wasm" "pkg/dcraw/embed/dcraw.wasm" 31 | fi 32 | 33 | if [ ! -f "assets/normalize.css" ]; then 34 | echo Download normalize.css... 35 | curl -sL "https://unpkg.com/@csstools/normalize.css" > assets/normalize.css 36 | fi 37 | 38 | if [ ! -f "assets/dialog-polyfill.js" ]; then 39 | echo Download dialog-polyfill... 40 | curl -sL "https://unpkg.com/dialog-polyfill@0.5/dist/dialog-polyfill.js" > assets/dialog-polyfill.js 41 | curl -sL "https://unpkg.com/dialog-polyfill@0.5/dist/dialog-polyfill.css" > assets/dialog-polyfill.css 42 | fi 43 | 44 | if [ ! -f "assets/fontawesome.css" ]; then 45 | echo Download Font Awesome... 46 | curl -sL "https://unpkg.com/@fortawesome/fontawesome-free@5.x/css/fontawesome.css" > assets/fontawesome.css 47 | curl -sL "https://unpkg.com/@fortawesome/fontawesome-free@5.x/webfonts/fa-solid-900.woff2" > assets/fa-solid-900.woff2 48 | fi 49 | 50 | if [[ "$1" == test ]]; then 51 | echo Run tests... 52 | go test ./... 53 | elif [[ "$1" == run ]]; then 54 | echo Run app... 55 | go build -race -o "$tgt/MacOS/rethinkraw" && shift && exec "$tgt/MacOS/rethinkraw" "$@" 56 | elif [[ "$1" == serve ]]; then 57 | echo Run server... 58 | go build -race -o "$tgt/MacOS/rethinkraw" && shift && exec "$app/rethinkraw-server" "$@" 59 | elif [[ "$1" == install ]]; then 60 | echo Build installer... 61 | rm -rf "$tgt/MacOS/data" 62 | tmp="$(mktemp -d)" 63 | ln -s /Applications "$tmp" 64 | cp -r RethinkRAW.app "$tmp" 65 | hdiutil create -volname RethinkRAW -srcfolder "$tmp" -format UDBZ -ov RethinkRAW.dmg 66 | else 67 | echo Build release... 68 | tmp="$(mktemp -d)" 69 | export CGO_ENABLED=0 70 | export GOOS=darwin 71 | go clean 72 | GOOS= GOARCH= go generate 73 | GOARCH=amd64 go build -tags memfs -ldflags "-s -w" -trimpath -o "$tmp/rethinkraw_x64" 74 | GOARCH=arm64 go build -tags memfs -ldflags "-s -w" -trimpath -o "$tmp/rethinkraw_arm" 75 | lipo -create "$tmp/rethinkraw_x64" "$tmp/rethinkraw_arm" -output "$tgt/MacOS/rethinkraw" 76 | fi -------------------------------------------------------------------------------- /pkg/wine/wine.go: -------------------------------------------------------------------------------- 1 | // Package wine provides support to run Windows programs under Wine. 2 | package wine 3 | 4 | import ( 5 | "bytes" 6 | "context" 7 | "errors" 8 | "fmt" 9 | "os" 10 | "os/exec" 11 | "sync" 12 | ) 13 | 14 | var server struct { 15 | wg sync.WaitGroup 16 | cmd *exec.Cmd 17 | } 18 | 19 | // IsInstalled checks if Wine is installed. 20 | func IsInstalled() bool { 21 | _, err := exec.LookPath("wine") 22 | return err == nil 23 | } 24 | 25 | // Startup starts a persistent Wine server, 26 | // which improves the performance and reliability of subsequent usages of Wine. 27 | func Startup() error { 28 | cmd := exec.Command("wineserver", "--persistent", "--foreground", "--debug=0") 29 | if err := cmd.Start(); err != nil { 30 | return err 31 | } 32 | 33 | server.cmd = cmd 34 | server.wg.Add(1) 35 | go func() { 36 | // This ensures commands that capture stdout succeed. 37 | exec.Command("wine", "cmd", "/c", "ver").Run() 38 | server.wg.Done() 39 | }() 40 | 41 | return nil 42 | } 43 | 44 | // Shutdown shuts down a persistent Wine server started by this package, 45 | // and waits for it to complete. 46 | func Shutdown() (ex error) { 47 | if server.cmd != nil { 48 | server.cmd.Process.Signal(os.Interrupt) 49 | err := server.cmd.Wait() 50 | server.cmd = nil 51 | 52 | var eerr *exec.ExitError 53 | if errors.As(err, &eerr) && eerr.ExitCode() == 2 { 54 | return nil 55 | } 56 | return err 57 | } 58 | return nil 59 | } 60 | 61 | // Getenv retrieves the value of the Windows (Wine) environment variable named by the key. 62 | func Getenv(key string) (string, error) { 63 | for _, b := range []byte(key) { 64 | if b == '(' || b == ')' || b == '_' || 65 | '0' <= b && b <= '9' || 66 | 'a' <= b && b <= 'z' || 67 | 'A' <= b && b <= 'Z' { 68 | continue 69 | } 70 | return "", fmt.Errorf("wine: invalid character %q in variable name", b) 71 | } 72 | 73 | server.wg.Wait() 74 | out, err := exec.Command("wine", "cmd", "/c", "if defined", key, "echo", "%"+key+"%").Output() 75 | if err != nil { 76 | return "", err 77 | } 78 | return string(bytes.TrimSuffix(out, []byte("\r\n"))), nil 79 | } 80 | 81 | // FromWindows translates a Windows (Wine) to a Unix path. 82 | func FromWindows(path string) (string, error) { 83 | server.wg.Wait() 84 | out, err := exec.Command("winepath", "--unix", "-0", path).Output() 85 | if err != nil { 86 | return "", err 87 | } 88 | return string(bytes.TrimSuffix(out, []byte{0})), nil 89 | } 90 | 91 | // ToWindows translates a Unix to a Windows (Wine) path. 92 | func ToWindows(path string) (string, error) { 93 | server.wg.Wait() 94 | out, err := exec.Command("winepath", "--windows", "-0", path).Output() 95 | if err != nil { 96 | return "", err 97 | } 98 | return string(bytes.TrimSuffix(out, []byte{0})), nil 99 | } 100 | 101 | // Command returns the [exec.Cmd] struct to execute a Windows program using Wine. 102 | func Command(name string, args ...string) *exec.Cmd { 103 | server.wg.Wait() 104 | args = append([]string{name}, args...) 105 | return exec.Command("wine", args...) 106 | } 107 | 108 | // CommandContext is like [Command] but includes a context. 109 | func CommandContext(ctx context.Context, name string, args ...string) *exec.Cmd { 110 | server.wg.Wait() 111 | args = append([]string{name}, args...) 112 | return exec.CommandContext(ctx, "wine", args...) 113 | } 114 | -------------------------------------------------------------------------------- /assets/photo.gohtml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | RethinkRAW: {{.Title}} 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {{- template "raw-editor.gohtml"}} 23 | 24 |
25 | 41 |
42 | {{.Name}} 43 | 44 | 45 |
46 |
47 | 48 | 49 | 50 | Lorem ipsum
51 | 52 |
53 | 54 | 55 | -------------------------------------------------------------------------------- /pkg/osutil/file.go: -------------------------------------------------------------------------------- 1 | package osutil 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "io/fs" 7 | "os" 8 | "regexp" 9 | "runtime" 10 | "strconv" 11 | "strings" 12 | "syscall" 13 | ) 14 | 15 | var newFilenameRE = regexp.MustCompile(`\A(.*?)(?: \((\d{1,4})\))?(\.\w*)?\z`) 16 | 17 | // NewFile creates a new named file. 18 | // If the file already exists, a numeric suffix is appended or incremented. 19 | func NewFile(name string) (*os.File, error) { 20 | for { 21 | f, err := os.OpenFile(name, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0666) 22 | if errors.Is(err, fs.ErrExist) { 23 | m := newFilenameRE.FindStringSubmatch(name) 24 | if m != nil { 25 | var i = 0 26 | if m[2] != "" { 27 | i, _ = strconv.Atoi(m[2]) 28 | } 29 | name = m[1] + " (" + strconv.Itoa(i+1) + ")" + m[3] 30 | continue 31 | } 32 | } 33 | return f, err 34 | } 35 | } 36 | 37 | // Copy copies src to dst. 38 | func Copy(src, dst string) (err error) { 39 | in, err := os.Open(src) 40 | if err != nil { 41 | return err 42 | } 43 | defer in.Close() 44 | 45 | out, err := os.Create(dst) 46 | if err != nil { 47 | return err 48 | } 49 | defer func() { 50 | cerr := out.Close() 51 | if err == nil { 52 | err = cerr 53 | } 54 | }() 55 | 56 | _, err = io.Copy(out, in) 57 | return err 58 | } 59 | 60 | // Move moves src to dst. 61 | // Tries [os.Rename]. Failing that, does a Copy followed by a [os.Remove]. 62 | func Move(src, dst string) error { 63 | err := os.Rename(src, dst) 64 | if isNotSameDevice(err) { 65 | if err := Copy(src, dst); err != nil { 66 | return err 67 | } 68 | if err := os.Remove(src); errors.Is(err, fs.ErrNotExist) { 69 | return nil 70 | } else { 71 | return err 72 | } 73 | } 74 | return err 75 | } 76 | 77 | // Lnky copies src to dst. 78 | // Tries [os.Link] to create a hardlink. Failing that, does a Copy. 79 | func Lnky(src, dst string) error { 80 | sfi, err := os.Stat(src) 81 | if err != nil { 82 | return err 83 | } 84 | 85 | dfi, _ := os.Stat(dst) 86 | if os.SameFile(sfi, dfi) { 87 | return nil 88 | } 89 | 90 | if os.Link(src, dst) == nil { 91 | return nil 92 | } 93 | return Copy(src, dst) 94 | } 95 | 96 | func isNotSameDevice(err error) bool { 97 | var lerr *os.LinkError 98 | if errors.As(err, &lerr) { 99 | if runtime.GOOS == "windows" { 100 | return lerr.Err == syscall.Errno(0x11) // ERROR_NOT_SAME_DEVICE 101 | } else { 102 | return lerr.Err == syscall.Errno(0x12) // EXDEV 103 | } 104 | } 105 | return false 106 | } 107 | 108 | // HiddenFile reports whether de is hidden. 109 | // Files starting with a period are reported as hidden on all systems, even Windows. 110 | // Other than that, plaform rules apply. 111 | func HiddenFile(de os.DirEntry) bool { 112 | if strings.HasPrefix(de.Name(), ".") { 113 | return true 114 | } 115 | return isHidden(de) 116 | } 117 | 118 | // ShellOpen opens a file (or a directory, or URL), 119 | // just as if you had double-clicked the file's icon. 120 | func ShellOpen(file string) error { 121 | return open(file) 122 | } 123 | 124 | // GetANSIPath converts path so that it is valid for use with Windows ANSI APIs. 125 | // Outside of Windows, path is returned unchanged. 126 | // 127 | // On Windows, if path length exceeds MAX_PATH, or if it contains characters 128 | // that cannot be represented in the system's ANSI code page, 129 | // GetShortPathName is used to try to construct an equivalent, valid path. 130 | // 131 | // Note: path is assumed to be UTF-8 encoded, and is returned UTF-8 encoded. 132 | // GetANSIPath can be used to obtain an equivalent path that you can offer 133 | // as a command line argument to an external program that uses ANSI APIs, 134 | // not to encode path so that you can access ANSI APIs directly. 135 | func GetANSIPath(path string) (string, error) { 136 | return getANSIPath(path) 137 | } 138 | -------------------------------------------------------------------------------- /assets/main.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | void function () { 4 | 5 | if (typeof String.prototype.replaceAll !== 'function') { 6 | location.replace('/browser.html'); 7 | } 8 | 9 | document.documentElement.addEventListener('keydown', function (evt) { 10 | if (navigator.platform.startsWith('Mac') && evt.metaKey && !(evt.altKey || evt.ctrlKey) || 11 | !navigator.platform.startsWith('Mac') && evt.ctrlKey && !(evt.altKey || evt.metaKey)) { 12 | var minimalUI = !window.matchMedia('(display-mode: browser)').matches; 13 | 14 | switch (evt.key) { 15 | case 'n': 16 | case 't': 17 | if (minimalUI) { 18 | evt.preventDefault(); 19 | if (evt.repeat) return; 20 | window.open('/', void 0, 'location=no,scrollbars=yes'); 21 | } 22 | break; 23 | 24 | case 'o': 25 | if (minimalUI) { 26 | evt.preventDefault(); 27 | if (evt.repeat) return; 28 | location.href = evt.shiftKey ? '/dialog?gallery' : '/dialog?photo'; 29 | } 30 | break; 31 | 32 | case 's': 33 | evt.preventDefault(); 34 | if (evt.repeat) return; 35 | if (evt.shiftKey && window.saveFileAs) { 36 | window.saveFileAs(); 37 | } else if (window.saveFile) { 38 | window.saveFile(); 39 | } 40 | break; 41 | 42 | case 'p': 43 | evt.preventDefault(); 44 | if (evt.repeat) return; 45 | if (window.printFile) { 46 | window.printFile(); 47 | } 48 | break; 49 | } 50 | } 51 | }); 52 | 53 | document.documentElement.addEventListener('click', function (evt) { 54 | if (evt.altKey || evt.ctrlKey || evt.metaKey || evt.shiftKey || evt.button !== 0) evt.preventDefault(); 55 | }); 56 | 57 | document.documentElement.addEventListener('contextmenu', function (evt) { 58 | evt.preventDefault(); 59 | }); 60 | 61 | // Newline-delimited JSON. 62 | JSON.parseLast = function (ndjson) { 63 | var end = ndjson.lastIndexOf('\n'); 64 | if (end < 0) return void 0; 65 | var start = ndjson.lastIndexOf('\n', end - 1); 66 | return JSON.parse(ndjson.substring(start, end)); 67 | }; 68 | 69 | JSON.parseLines = function (ndjson) { 70 | return ndjson.trimEnd().split('\n').map(JSON.parse); 71 | }; 72 | 73 | // Register dialogs with polyfill, add type=cancel buttons. 74 | var dialogs = document.querySelectorAll('dialog'); 75 | for (var i = 0; i < dialogs.length; ++i) { 76 | (function (dialog) { 77 | dialogPolyfill.registerDialog(dialog); 78 | dialog.addEventListener('cancel', function () { return dialog.returnValue = '' }); 79 | var buttons = dialog.querySelectorAll('form button[type=cancel]'); 80 | for (var i = 0; i < buttons.length; ++i) { 81 | (function (button) { 82 | button.type = 'button'; 83 | button.addEventListener('click', function () { 84 | dialog.dispatchEvent(new Event('cancel')); 85 | dialog.close(); 86 | }); 87 | })(buttons[i]); 88 | } 89 | })(dialogs[i]); 90 | } 91 | 92 | }(); 93 | 94 | function back() { 95 | if (document.referrer) { 96 | history.back(); 97 | window.close(); 98 | } else { 99 | location.replace('/'); 100 | } 101 | } 102 | 103 | function sleep(ms) { 104 | return new Promise(function (resolve) { 105 | return setTimeout(resolve, ms); 106 | }); 107 | } 108 | 109 | function alertError(src, err) { 110 | console.log(err); 111 | var name = err && err.name || 'Error'; 112 | var message = err && err.message; 113 | if (message) { 114 | var end = /\w$/.test(message) ? '.' : ''; 115 | var sep = message.length > 25 ? '\n' : ' '; 116 | alert(name + '\n' + src + ' with:' + sep + message + end); 117 | } else { 118 | alert(name + '\n' + src + '.'); 119 | } 120 | } -------------------------------------------------------------------------------- /assets/gallery.js: -------------------------------------------------------------------------------- 1 | void function () { 2 | 3 | if (!template.uploadPath) return; 4 | 5 | let form = document.createElement('input'); 6 | form.type = 'file'; 7 | form.hidden = true; 8 | form.multiple = true; 9 | form.accept = Object.keys(template.uploadExts).join(','); 10 | form.addEventListener('change', async () => { 11 | await uploadFiles(form.files); 12 | location.reload(); 13 | }); 14 | 15 | window.upload = () => form.click(); 16 | 17 | let drop = document.getElementById('drop-target'); 18 | drop.addEventListener('dragover', evt => evt.preventDefault()); 19 | drop.addEventListener('drop', async evt => { 20 | evt.preventDefault(); 21 | 22 | // Recursively find files. 23 | let files = []; 24 | let directories = []; 25 | for (let i of evt.dataTransfer.items) { 26 | let entry = i.webkitGetAsEntry() 27 | if (entry.isFile) { 28 | files.push(entry); 29 | } 30 | if (entry.isDirectory) { 31 | directories.push(entry); 32 | } 33 | } 34 | for (let d of directories) { 35 | files.push(...await walkdir(d)); 36 | } 37 | 38 | // Filter files by wanted extensions. 39 | files = files.filter(f => ext(f.name).toUpperCase() in template.uploadExts); 40 | await uploadFiles(files); 41 | location.reload(); 42 | }); 43 | 44 | async function walkdir(directory) { 45 | function readEntries(reader) { 46 | return new Promise((resolve, reject) => reader.readEntries(resolve, reject)); 47 | } 48 | 49 | async function readAll(reader) { 50 | let files = []; 51 | let entries; 52 | do { 53 | entries = await readEntries(reader); 54 | for (let entry of entries) { 55 | if (entry.isFile) { 56 | files.push(entry); 57 | } 58 | if (entry.isDirectory) { 59 | files.push(...await walkdir(entry)) 60 | } 61 | } 62 | } while (entries.length > 0); 63 | return files; 64 | } 65 | 66 | return await readAll(directory.createReader()) 67 | } 68 | 69 | async function uploadFiles(files) { 70 | let dialog = document.getElementById('progress-dialog'); 71 | let progress = dialog.querySelector('progress'); 72 | progress.removeAttribute('value'); 73 | progress.max = files.length; 74 | dialog.firstChild.textContent = 'Uploading…'; 75 | dialog.showModal(); 76 | 77 | try { 78 | let i = 0; 79 | for (let file of files) { 80 | await uploadFile(file); 81 | progress.value = ++i; 82 | } 83 | } catch (err) { 84 | alertError('Upload failed', err); 85 | } finally { 86 | dialog.close(); 87 | } 88 | } 89 | 90 | function uploadFile(entry) { 91 | return new Promise((resolve, reject) => { 92 | function request(file) { 93 | let data = new FormData(); 94 | data.set('root', template.uploadPath) 95 | data.set('path', entry.fullPath || file.name) 96 | data.set('file', file); 97 | 98 | let xhr = new XMLHttpRequest(); 99 | xhr.open('POST', '/upload'); 100 | xhr.onload = () => { 101 | if (xhr.status < 400) { 102 | resolve(xhr.response); 103 | } else { 104 | reject({ 105 | status: xhr.status, 106 | name: xhr.statusText, 107 | message: xhr.response, 108 | }); 109 | } 110 | }; 111 | xhr.onerror = () => reject({ 112 | status: xhr.status, 113 | name: xhr.statusText, 114 | }); 115 | xhr.setRequestHeader('Accept', 'application/json'); 116 | xhr.responseType = 'json'; 117 | xhr.send(data); 118 | }; 119 | 120 | if (entry.fullPath) { 121 | entry.file(request, reject); 122 | } else { 123 | request(entry); 124 | } 125 | }); 126 | } 127 | 128 | function ext(name) { 129 | let slash = name.lastIndexOf('/'); 130 | let dot = name.lastIndexOf('.'); 131 | if (dot >= 0 && dot > slash) { 132 | return name.substring(dot); 133 | } 134 | return ''; 135 | } 136 | 137 | }(); -------------------------------------------------------------------------------- /jpeg.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/binary" 7 | "errors" 8 | "image/jpeg" 9 | "log" 10 | "os" 11 | 12 | "github.com/ncruces/go-image/resize" 13 | "github.com/ncruces/go-image/rotateflip" 14 | "github.com/ncruces/rethinkraw/pkg/dcraw" 15 | ) 16 | 17 | func previewJPEG(ctx context.Context, path string) ([]byte, error) { 18 | log.Print("dcraw (get thumb)...") 19 | f, err := os.Open(path) 20 | if err != nil { 21 | return nil, err 22 | } 23 | defer f.Close() 24 | return dcraw.GetThumbJPEG(ctx, f) 25 | } 26 | 27 | func exportJPEG(ctx context.Context, path string) ([]byte, error) { 28 | log.Print("dcraw (get thumb)...") 29 | f, err := os.Open(path) 30 | if err != nil { 31 | return nil, err 32 | } 33 | defer f.Close() 34 | data, err := dcraw.GetThumb(ctx, f) 35 | if err != nil { 36 | return nil, err 37 | } 38 | if !bytes.HasPrefix(data, []byte("\xff\xd8")) { 39 | return nil, errors.New("not a JPEG file") 40 | } 41 | return data, nil 42 | } 43 | 44 | func resampleJPEG(data []byte, settings exportSettings) ([]byte, error) { 45 | img, err := jpeg.Decode(bytes.NewReader(data)) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | exf := rotateflip.Orientation(exifOrientation(data)) 51 | img = rotateflip.Image(img, exf.Op()) 52 | fit := settings.FitImage(img.Bounds().Size()) 53 | img = resize.Thumbnail(uint(fit.X), uint(fit.Y), img, resize.Lanczos2) 54 | 55 | buf := bytes.Buffer{} 56 | // https://fotoforensics.com/tutorial.php?tt=estq 57 | opt := jpeg.Options{Quality: [13]int{30, 34, 47, 62, 69, 76, 79, 82, 86, 90, 93, 97, 99}[settings.Quality]} 58 | if err := jpeg.Encode(&buf, img, &opt); err != nil { 59 | return nil, err 60 | } 61 | 62 | return append(jfifHeader(settings), buf.Bytes()[2:]...), nil 63 | } 64 | 65 | func exifOrientation(data []byte) int { 66 | if !bytes.HasPrefix(data, []byte("\xff\xd8")) { 67 | return -1 68 | } 69 | 70 | data = data[2:] 71 | for len(data) >= 2 { 72 | var marker = binary.BigEndian.Uint16(data) 73 | 74 | switch { 75 | case marker == 0xffff: // padding 76 | data = data[1:] 77 | 78 | case marker == 0xffe1: // APP1 79 | if len(data) > 4 { 80 | size := int(binary.BigEndian.Uint16(data[2:])) + 2 81 | if 4 <= size && size <= len(data) { 82 | data = data[4:size] 83 | if !bytes.HasPrefix(data, []byte("Exif\x00\x00")) { 84 | return 0 85 | } 86 | 87 | data = data[6:] 88 | if len(data) < 8 { 89 | return -2 90 | } 91 | 92 | var endian binary.ByteOrder 93 | switch string(data[0:4]) { 94 | case "II*\x00": 95 | endian = binary.LittleEndian 96 | case "MM\x00*": 97 | endian = binary.BigEndian 98 | default: 99 | return -2 100 | } 101 | 102 | offset := endian.Uint32(data[4:]) 103 | if len(data) < int(offset)+2 { 104 | return -2 105 | } 106 | 107 | count := endian.Uint16(data[offset:]) 108 | entries := data[offset+2:] 109 | if len(data) < 12*int(count) { 110 | return -2 111 | } else { 112 | entries = entries[:12*count] 113 | } 114 | 115 | for i := 0; i < len(entries); i += 12 { 116 | tag := endian.Uint16(entries[i:]) 117 | if tag == 0x0112 { // Orientation 118 | typ := endian.Uint16(entries[i+2:]) // SHORT 119 | cnt := endian.Uint32(entries[i+4:]) // 1 120 | val := endian.Uint32(entries[i+8:]) 121 | if typ == 3 && cnt == 1 && val <= 9 { 122 | return int(val) 123 | } 124 | return -2 125 | } 126 | } 127 | return 0 128 | } 129 | } 130 | return -2 131 | 132 | case marker >= 0xffe0: // APPn, JPGn, COM 133 | if len(data) > 4 { 134 | size := int(binary.BigEndian.Uint16(data[2:])) + 2 135 | if 4 <= size && size <= len(data) { 136 | data = data[size:] 137 | continue 138 | } 139 | } 140 | return -2 141 | 142 | case marker == 0xff00: // invalid 143 | return -2 144 | 145 | default: 146 | return 0 147 | } 148 | } 149 | return -2 150 | } 151 | 152 | func jfifHeader(settings exportSettings) []byte { 153 | if settings.DimUnit == "px" { 154 | return []byte{'\xff', '\xd8'} 155 | } 156 | 157 | data := [20]byte{'\xff', '\xd8', '\xff', '\xe0', 0, 16, 'J', 'F', 'I', 'F', 0, 1, 2} 158 | binary.BigEndian.PutUint16(data[14:], uint16(settings.Density)) 159 | binary.BigEndian.PutUint16(data[16:], uint16(settings.Density)) 160 | if settings.DenUnit == "ppi" { 161 | data[13] = 1 162 | } else { 163 | data[13] = 2 164 | } 165 | return data[:] 166 | } 167 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "crypto/tls" 6 | "flag" 7 | "fmt" 8 | "log" 9 | "net/url" 10 | "os" 11 | "os/signal" 12 | "path/filepath" 13 | "runtime" 14 | "strconv" 15 | "syscall" 16 | 17 | "github.com/ncruces/rethinkraw/internal/config" 18 | "github.com/ncruces/rethinkraw/pkg/chrome" 19 | "github.com/ncruces/rethinkraw/pkg/dngconv" 20 | "github.com/ncruces/rethinkraw/pkg/optls" 21 | "github.com/ncruces/rethinkraw/pkg/osutil" 22 | "github.com/ncruces/rethinkraw/pkg/wine" 23 | "github.com/ncruces/zenity" 24 | ) 25 | 26 | var shutdown = make(chan os.Signal, 1) 27 | 28 | var ( 29 | serverHost string 30 | serverPort string 31 | serverAuth string 32 | serverPrefix string 33 | serverConfig tls.Config 34 | ) 35 | 36 | func init() { 37 | signal.Notify(shutdown, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM) 38 | osutil.CreateConsole() 39 | osutil.CleanupArgs() 40 | log.SetOutput(os.Stderr) 41 | } 42 | 43 | func main() { 44 | err := run() 45 | if err != nil { 46 | log.Fatal(err) 47 | } 48 | } 49 | 50 | func run() error { 51 | if err := config.SetupPaths(); err != nil { 52 | return err 53 | } 54 | 55 | port := flag.Int("port", 39639, "the port on which the server listens for connections") 56 | pass := flag.String("password", "$PASSWORD", "the password used to authenticate to the server (required)") 57 | cert := flag.String("certfile", "", "the PEM encoded certificate `file`") 58 | key := flag.String("keyfile", "", "the PEM encoded private key `file`") 59 | flag.Usage = func() { 60 | w := flag.CommandLine.Output() 61 | fmt.Fprintf(w, "usage: %s [OPTION]... DIRECTORY\n", filepath.Base(os.Args[0])) 62 | flag.PrintDefaults() 63 | } 64 | const unspecified = "\x00" 65 | *pass = unspecified 66 | flag.Parse() 67 | 68 | if runtime.GOOS != "windows" && runtime.GOOS != "darwin" { 69 | wine.Startup() 70 | } 71 | 72 | serverPort = ":" + strconv.Itoa(*port) 73 | var url url.URL 74 | 75 | if config.ServerMode { 76 | if flag.NArg() != 1 { 77 | flag.Usage() 78 | os.Exit(2) 79 | } 80 | if fi, err := os.Stat(flag.Arg(0)); err != nil { 81 | return err 82 | } else if abs, err := filepath.Abs(flag.Arg(0)); err != nil { 83 | return err 84 | } else if fi.IsDir() { 85 | serverPrefix = abs 86 | } else { 87 | flag.Usage() 88 | os.Exit(2) 89 | } 90 | if serverAuth = *pass; serverAuth == unspecified { 91 | if env := os.Getenv("PASSWORD"); env != "" { 92 | serverAuth = env 93 | } else { 94 | flag.Usage() 95 | os.Exit(2) 96 | } 97 | } 98 | if *cert != "" { 99 | var err error 100 | serverConfig.Certificates = make([]tls.Certificate, 1) 101 | serverConfig.Certificates[0], err = tls.LoadX509KeyPair(*cert, *key) 102 | if err != nil { 103 | return err 104 | } 105 | } 106 | serverConfig.NextProtos = []string{"h2"} 107 | } else { 108 | serverHost = "localhost" 109 | url.Scheme = "http" 110 | url.Host = serverHost + serverPort 111 | 112 | if flag.NArg() > 0 { 113 | if fi, err := os.Stat(flag.Arg(0)); err != nil { 114 | return err 115 | } else if abs, err := filepath.Abs(flag.Arg(0)); err != nil { 116 | return err 117 | } else if flag.NArg() > 1 { 118 | url.Path = "/batch/" + toBatchPath(flag.Args()...) 119 | } else if fi.IsDir() { 120 | url.Path = "/gallery/" + toURLPath(abs, "") 121 | } else { 122 | url.Path = "/photo/" + toURLPath(abs, "") 123 | } 124 | } 125 | } 126 | 127 | if !dngconv.IsInstalled() { 128 | if config.ServerMode { 129 | log.Fatal("Please download and install Adobe DNG Converter.") 130 | } 131 | url.Path = "/dngconv.html" 132 | } 133 | 134 | if ln, err := optls.Listen("tcp", serverHost+serverPort, &serverConfig); err == nil { 135 | http := setupHTTP() 136 | exif, err := setupExifTool() 137 | if err != nil { 138 | return err 139 | } 140 | defer func() { 141 | http.Shutdown(context.Background()) 142 | exif.Shutdown() 143 | wine.Shutdown() 144 | os.RemoveAll(config.TempDir) 145 | }() 146 | go http.Serve(ln) 147 | } else if config.ServerMode { 148 | return err 149 | } 150 | 151 | if config.ServerMode { 152 | log.Print("listening on http://local.app.rethinkraw.com" + serverPort) 153 | <-shutdown 154 | return nil 155 | } 156 | 157 | if !zenity.IsAvailable() { 158 | log.Fatal("Please install zenity.") 159 | } 160 | if !chrome.IsInstalled() { 161 | return zenity.Error( 162 | "Please download and install either Google Chrome or Microsoft Edge.", 163 | zenity.Title("Google Chrome not found")) 164 | } 165 | 166 | data := filepath.Join(config.DataDir, "chrome") 167 | cache := filepath.Join(config.TempDir, "chrome") 168 | cmd := chrome.Command(url.String(), data, cache) 169 | 170 | if err := cmd.Start(); err != nil { 171 | return err 172 | } 173 | go func() { 174 | for s := range shutdown { 175 | cmd.Signal(s) 176 | } 177 | }() 178 | return cmd.Wait() 179 | } 180 | -------------------------------------------------------------------------------- /pkg/dng/profile.go: -------------------------------------------------------------------------------- 1 | package dng 2 | 3 | import ( 4 | "math" 5 | 6 | "gonum.org/v1/gonum/mat" 7 | ) 8 | 9 | // CameraProfile encapsulates DNG camera color profile and calibration data. 10 | // 11 | // This data can be extracted from the same named DNG tags. 12 | type CameraProfile struct { 13 | CalibrationIlluminant1, CalibrationIlluminant2 LightSource // Light sources for up to two calibrations. 14 | ColorMatrix1, ColorMatrix2 []float64 // Color matrices for up to two calibrations. 15 | CameraCalibration1, CameraCalibration2 []float64 // Individual camera calibrations. 16 | AnalogBalance []float64 // Amount by which each channel has already been scaled. 17 | 18 | temperature1, temperature2 float64 19 | colorMatrix1, colorMatrix2 *mat.Dense 20 | } 21 | 22 | // Init initializes the profile. 23 | // 24 | // Init is called implicitly when necessary, but 25 | // changes to profile fields made after Init is called 26 | // are ignored until Init is called explicitly again. 27 | func (p *CameraProfile) Init() error { 28 | return mat.Maybe(p.init) 29 | } 30 | 31 | // GetTemperature computes a correlated color temperature and offset (tint) 32 | // from camera color space coordinates of a perfectly neutral color. 33 | // 34 | // This can be used to convert an AsShotNeutral DNG tag to a temperature and tint. 35 | func (p *CameraProfile) GetTemperature(neutral []float64) (temperature, tint int, err error) { 36 | err = mat.Maybe(func() { 37 | xy := p.neutralToXY(mat.NewVecDense(len(neutral), neutral)) 38 | temperature, tint = GetTemperatureFromXY(xy.x, xy.y) 39 | }) 40 | return 41 | } 42 | 43 | // Port of dng_color_spec::dng_color_spec. 44 | func (p *CameraProfile) init() { 45 | channels := len(p.ColorMatrix1) / 3 46 | 47 | p.temperature1 = p.CalibrationIlluminant1.Temperature() 48 | p.temperature2 = p.CalibrationIlluminant2.Temperature() 49 | 50 | var analog *mat.DiagDense 51 | if p.AnalogBalance != nil { 52 | analog = mat.NewDiagDense(channels, p.AnalogBalance) 53 | } 54 | 55 | p.colorMatrix1 = mat.NewDense(channels, 3, p.ColorMatrix1) 56 | if p.CameraCalibration1 != nil { 57 | p.colorMatrix1.Mul(mat.NewDense(channels, channels, p.CameraCalibration1), p.colorMatrix1) 58 | } 59 | if analog != nil { 60 | p.colorMatrix1.Mul(analog, p.colorMatrix1) 61 | } 62 | 63 | if p.CameraCalibration2 == nil || 64 | p.temperature1 == p.temperature2 || 65 | p.temperature1 <= 0.0 || p.temperature2 <= 0.0 { 66 | p.temperature1 = 5000.0 67 | p.temperature2 = 5000.0 68 | p.colorMatrix2 = p.colorMatrix1 69 | } else { 70 | p.colorMatrix2 = mat.NewDense(channels, 3, p.ColorMatrix2) 71 | if p.CameraCalibration2 != nil { 72 | p.colorMatrix2.Mul(mat.NewDense(channels, channels, p.CameraCalibration2), p.colorMatrix2) 73 | } 74 | if analog != nil { 75 | p.colorMatrix2.Mul(analog, p.colorMatrix2) 76 | } 77 | 78 | if p.temperature1 > p.temperature2 { 79 | p.temperature1, p.temperature2 = p.temperature2, p.temperature1 80 | p.colorMatrix1, p.colorMatrix2 = p.colorMatrix2, p.colorMatrix1 81 | } 82 | } 83 | } 84 | 85 | // Port of dng_color_spec::NeutralToXY. 86 | func (p *CameraProfile) neutralToXY(neutral mat.Vector) xy64 { 87 | const maxPasses = 30 88 | 89 | if neutral.Len() == 1 { 90 | return _D50 91 | } 92 | 93 | last := _D50 94 | for pass := 0; pass < maxPasses; pass++ { 95 | xyzToCamera := p.findXYZtoCamera(last) 96 | 97 | var vec mat.VecDense 98 | vec.SolveVec(xyzToCamera, neutral) 99 | next := newXYZ64(&vec).xy() 100 | 101 | if math.Abs(next.x-last.x)+math.Abs(next.y-last.y) < 0.0000001 { 102 | return next 103 | } 104 | 105 | // If we reach the limit without converging, we are most likely 106 | // in a two value oscillation. So take the average of the last 107 | // two estimates and give up. 108 | if pass == maxPasses-1 { 109 | next.x = (last.x + next.x) * 0.5 110 | next.y = (last.y + next.y) * 0.5 111 | } 112 | last = next 113 | } 114 | return last 115 | } 116 | 117 | // Port of dng_color_spec::FindXYZtoCamera. 118 | func (p *CameraProfile) findXYZtoCamera(white xy64) mat.Matrix { 119 | if p.colorMatrix1 == nil { 120 | p.init() 121 | } 122 | 123 | // Convert to temperature/offset space. 124 | temperature, _ := white.temperature() 125 | 126 | // Find fraction to weight the first calibration. 127 | var g float64 128 | if temperature <= p.temperature1 { 129 | g = 1.0 130 | } else if temperature >= p.temperature2 { 131 | g = 0.0 132 | } else { 133 | g = (1.0/temperature - 1.0/p.temperature2) / 134 | (1.0/p.temperature1 - 1.0/p.temperature2) 135 | } 136 | 137 | // Interpolate the color matrix. 138 | var colorMatrix mat.Dense 139 | 140 | if g >= 1.0 { 141 | return p.colorMatrix1 142 | } else if g <= 0.0 { 143 | return p.colorMatrix2 144 | } else { 145 | var c1, c2 mat.Dense 146 | c1.Scale(g, p.colorMatrix1) 147 | c2.Scale((1.0 - g), p.colorMatrix2) 148 | colorMatrix.Add(&c1, &c2) 149 | } 150 | 151 | // Return the interpolated color matrix. 152 | return &colorMatrix 153 | } 154 | -------------------------------------------------------------------------------- /http_photo.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/gorilla/schema" 11 | "github.com/ncruces/jason" 12 | "github.com/ncruces/rethinkraw/internal/util" 13 | "github.com/ncruces/zenity" 14 | ) 15 | 16 | func photoHandler(w http.ResponseWriter, r *http.Request) httpResult { 17 | if err := r.ParseForm(); err != nil { 18 | return httpResult{Status: http.StatusBadRequest, Error: err} 19 | } 20 | prefix := getPathPrefix(r) 21 | path := fromURLPath(r.URL.Path, prefix) 22 | 23 | _, meta := r.Form["meta"] 24 | _, save := r.Form["save"] 25 | _, export := r.Form["export"] 26 | _, preview := r.Form["preview"] 27 | _, settings := r.Form["settings"] 28 | _, whiteBalance := r.Form["wb"] 29 | 30 | switch { 31 | case meta: 32 | w.Header().Set("Cache-Control", "max-age=10") 33 | if r := sendCached(w, r, path); r.Done() { 34 | return r 35 | } 36 | 37 | if out, err := getMetaHTML(path); err != nil { 38 | return httpResult{Error: err} 39 | } else { 40 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 41 | w.Write(out) 42 | return httpResult{} 43 | } 44 | 45 | case save: 46 | var xmp xmpSettings 47 | dec := schema.NewDecoder() 48 | dec.IgnoreUnknownKeys(true) 49 | if err := dec.Decode(&xmp, r.Form); err != nil { 50 | return httpResult{Error: err} 51 | } 52 | xmp.Filename = filepath.Base(path) 53 | 54 | if err := saveEdit(r.Context(), path, xmp); err != nil { 55 | return httpResult{Error: err} 56 | } else { 57 | return httpResult{Status: http.StatusNoContent} 58 | } 59 | 60 | case export: 61 | var xmp xmpSettings 62 | var exp exportSettings 63 | dec := schema.NewDecoder() 64 | dec.IgnoreUnknownKeys(true) 65 | if err := dec.Decode(&xmp, r.Form); err != nil { 66 | return httpResult{Error: err} 67 | } 68 | if err := dec.Decode(&exp, r.Form); err != nil { 69 | return httpResult{Error: err} 70 | } 71 | xmp.Filename = filepath.Base(path) 72 | 73 | exppath := exportPath(path, exp) 74 | if isLocalhost(r) { 75 | if res, err := zenity.SelectFileSave(zenity.Context(r.Context()), zenity.Filename(exppath), zenity.ConfirmOverwrite()); res != "" { 76 | exppath = res 77 | } else if errors.Is(err, zenity.ErrCanceled) { 78 | return httpResult{Status: http.StatusNoContent} 79 | } else if err == nil { 80 | return httpResult{Status: http.StatusInternalServerError} 81 | } else { 82 | return httpResult{Error: err} 83 | } 84 | } 85 | 86 | if out, err := exportEdit(r.Context(), path, xmp, exp); err != nil { 87 | return httpResult{Error: err} 88 | } else if isLocalhost(r) { 89 | if err := os.WriteFile(exppath, out, 0666); err != nil { 90 | return httpResult{Error: err} 91 | } else { 92 | return httpResult{Status: http.StatusNoContent} 93 | } 94 | } else { 95 | name := filepath.Base(exppath) 96 | w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+util.PercentEncode(name)) 97 | if exp.DNG { 98 | w.Header().Set("Content-Type", "image/x-adobe-dng") 99 | } else { 100 | w.Header().Set("Content-Type", "image/jpeg") 101 | } 102 | w.Write(out) 103 | return httpResult{} 104 | } 105 | 106 | case preview: 107 | var xmp xmpSettings 108 | var size struct{ Preview int } 109 | dec := schema.NewDecoder() 110 | dec.IgnoreUnknownKeys(true) 111 | if err := dec.Decode(&xmp, r.Form); err != nil { 112 | return httpResult{Error: err} 113 | } 114 | if err := dec.Decode(&size, r.Form); err != nil { 115 | return httpResult{Error: err} 116 | } 117 | if out, err := previewEdit(r.Context(), path, size.Preview, xmp); err != nil { 118 | return httpResult{Error: err} 119 | } else { 120 | w.Header().Set("Content-Type", "image/jpeg") 121 | w.Write(out) 122 | return httpResult{} 123 | } 124 | 125 | case settings: 126 | if xmp, err := loadEdit(path); err != nil { 127 | return httpResult{Error: err} 128 | } else { 129 | w.Header().Set("Content-Type", "application/json") 130 | enc := json.NewEncoder(w) 131 | if err := enc.Encode(xmp); err != nil { 132 | return httpResult{Error: err} 133 | } 134 | } 135 | return httpResult{} 136 | 137 | case whiteBalance: 138 | var coords struct{ WB []float64 } 139 | dec := schema.NewDecoder() 140 | dec.IgnoreUnknownKeys(true) 141 | if err := dec.Decode(&coords, r.Form); err != nil { 142 | return httpResult{Error: err} 143 | } 144 | if wb, err := loadWhiteBalance(r.Context(), path, coords.WB); err != nil { 145 | return httpResult{Error: err} 146 | } else { 147 | w.Header().Set("Content-Type", "application/json") 148 | enc := json.NewEncoder(w) 149 | if err := enc.Encode(wb); err != nil { 150 | return httpResult{Error: err} 151 | } 152 | } 153 | return httpResult{} 154 | 155 | default: 156 | if _, err := os.Stat(path); err != nil { 157 | return httpResult{Error: err} 158 | } 159 | 160 | w.Header().Set("Cache-Control", "max-age=300") 161 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 162 | return httpResult{ 163 | Error: templates.ExecuteTemplate(w, "photo.gohtml", jason.Object{ 164 | "Title": toUsrPath(path, prefix), 165 | "Path": toURLPath(path, prefix), 166 | "Name": filepath.Base(path), 167 | }), 168 | } 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/akavel/rsrc v0.10.2 h1:Zxm8V5eI1hW4gGaYsJQUhxpjkENuG91ki8B4zCrvEsw= 2 | github.com/akavel/rsrc v0.10.2/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/dchest/jsmin v1.0.0 h1:Y2hWXmGZiRxtl+VcTksyucgTlYxnhPzTozCwx9gy9zI= 6 | github.com/dchest/jsmin v1.0.0/go.mod h1:AVBIund7Mr7lKXT70hKT2YgL3XEXUaUk5iw9DZ8b0Uc= 7 | github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= 8 | github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 9 | github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= 10 | github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= 11 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 12 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 13 | github.com/josephspurrier/goversioninfo v1.5.0 h1:9TJtORoyf4YMoWSOo/cXFN9A/lB3PniJ91OxIH6e7Zg= 14 | github.com/josephspurrier/goversioninfo v1.5.0/go.mod h1:6MoTvFZ6GKJkzcdLnU5T/RGYUbHQbKpYeNP0AgQLd2o= 15 | github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94 h1:+AIlO01SKT9sfWU5CLWi0cfHc7dQwgGz3FhFRzXLoMg= 16 | github.com/krolaw/zipstream v0.0.0-20180621105154-0a2661891f94/go.mod h1:TcE3PIIkVWbP/HjhRAafgCjRKvDOi086iqp9VkNX/ng= 17 | github.com/ncruces/go-exiftool v0.4.2 h1:GgZIZ9/xQ2cMC06Ivzr1bytEiPBstiberP5yGCkBGQU= 18 | github.com/ncruces/go-exiftool v0.4.2/go.mod h1:2ViZnklkWjv8Ev/30JG+FcZdwqZms9LKkQjYrBN9QaE= 19 | github.com/ncruces/go-fetch v0.0.0-20201125022143-c61f8921eb46 h1:Nc+PzFbiFag5VeLUag7mR9KrKYJCt0yCWATZ75NMzSg= 20 | github.com/ncruces/go-fetch v0.0.0-20201125022143-c61f8921eb46/go.mod h1:Dmb2pVxvl1ue+0EUXlWdRcJX37qTvnrkwydN4FLJUfY= 21 | github.com/ncruces/go-fs v0.2.4 h1:Af7M2HGRgIsQQBRXKeLBX6panqyWmhY4poc3dod+1CM= 22 | github.com/ncruces/go-fs v0.2.4/go.mod h1:FpRs15UV5b2JOf7OUUrVFbMdTLHoXOGX4irjr1DKob0= 23 | github.com/ncruces/go-image v0.1.0 h1:PCbPeiqA2Pbc7m3jWBjhJodwkGew8HEB7fC8SVM+8EA= 24 | github.com/ncruces/go-image v0.1.0/go.mod h1:DUnNl2l0T6tEuK266gUGy3Xq8C+A/71XuoWsAHh8bzg= 25 | github.com/ncruces/jason v0.4.0 h1:0Gy0/YHmy+P2tBNFo31mgzowJuhe35S7jxcEilXIIBI= 26 | github.com/ncruces/jason v0.4.0/go.mod h1:gaKw0MQbOK/IR2sJ6zBSCOLn+uPOv0iLH4VxOtdkGtU= 27 | github.com/ncruces/zenity v0.10.14 h1:OBFl7qfXcvsdo1NUEGxTlZvAakgWMqz9nG38TuiaGLI= 28 | github.com/ncruces/zenity v0.10.14/go.mod h1:ZBW7uVe/Di3IcRYH0Br8X59pi+O6EPnNIOU66YHpOO4= 29 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 30 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 31 | github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844 h1:GranzK4hv1/pqTIhMTXt2X8MmMOuH3hMeUR0o9SP5yc= 32 | github.com/randall77/makefat v0.0.0-20210315173500-7ddd0e42c844/go.mod h1:T1TLSfyWVBRXVGzWd0o9BI4kfoO9InEgfQe4NV3mLz8= 33 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= 34 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 35 | github.com/tdewolff/minify/v2 v2.21.3 h1:KmhKNGrN/dGcvb2WDdB5yA49bo37s+hcD8RiF+lioV8= 36 | github.com/tdewolff/minify/v2 v2.21.3/go.mod h1:iGxHaGiONAnsYuo8CRyf8iPUcqRJVB/RhtEcTpqS7xw= 37 | github.com/tdewolff/parse/v2 v2.7.20 h1:Y33JmRLjyGhX5JRvYh+CO6Sk6pGMw3iO5eKGhUhx8JE= 38 | github.com/tdewolff/parse/v2 v2.7.20/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA= 39 | github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= 40 | github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo= 41 | github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= 42 | github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8= 43 | github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU= 44 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 45 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 46 | golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= 47 | golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= 48 | golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= 49 | golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= 50 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 51 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 52 | golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 53 | golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 54 | golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 55 | golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 56 | gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= 57 | gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= 58 | gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA= 59 | gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 60 | -------------------------------------------------------------------------------- /profiles.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | "sync" 7 | 8 | "github.com/ncruces/rethinkraw/pkg/craw" 9 | "github.com/ncruces/rethinkraw/pkg/dngconv" 10 | "golang.org/x/exp/slices" 11 | ) 12 | 13 | var defaultProfiles = []string{ 14 | "Adobe Color", "Adobe Monochrome", "Adobe Landscape", "Adobe Neutral", 15 | "Adobe Portrait", "Adobe Vivid", "Adobe Standard", "Adobe Standard B&W", 16 | } 17 | 18 | var profileSettings = map[string][]string{ 19 | "Adobe Standard": { 20 | "-XMP-crs:ConvertToGrayscale=", 21 | "-XMP-crs:CameraProfile=", 22 | "-XMP-crs:Look*=", 23 | }, 24 | "Adobe Standard B&W": { 25 | "-XMP-crs:ConvertToGrayscale=True", 26 | "-XMP-crs:CameraProfile=", 27 | "-XMP-crs:Look*=", 28 | }, 29 | "Adobe Color": { 30 | "-XMP-crs:ConvertToGrayscale=", 31 | "-XMP-crs:CameraProfile=", 32 | "-XMP-crs:Look*=", 33 | "-XMP-crs:LookName=Adobe Color", 34 | "-XMP-crs:LookUUID=B952C231111CD8E0ECCF14B86BAA7077", 35 | "-XMP-crs:LookParametersToneCurvePV2012=0, 0; 22, 16; 40, 35; 127, 127; 224, 230; 240, 246; 255, 255", 36 | "-XMP-crs:LookParametersLookTable=E1095149FDB39D7A057BAB208837E2E1", 37 | }, 38 | "Adobe Monochrome": { 39 | "-XMP-crs:ConvertToGrayscale=True", 40 | "-XMP-crs:CameraProfile=", 41 | "-XMP-crs:Look*=", 42 | "-XMP-crs:LookName=Adobe Monochrome", 43 | "-XMP-crs:LookUUID=0CFE8F8AB5F63B2A73CE0B0077D20817", 44 | "-XMP-crs:LookParametersConvertToGrayscale=True", 45 | "-XMP-crs:LookParametersClarity2012=+8", 46 | "-XMP-crs:LookParametersToneCurvePV2012=0, 0; 64, 56; 128, 128; 192, 197; 255, 255", 47 | "-XMP-crs:LookParametersLookTable=73ED6C18DDE909DD7EA2D771F5AC282D", 48 | }, 49 | "Adobe Landscape": { 50 | "-XMP-crs:ConvertToGrayscale=", 51 | "-XMP-crs:CameraProfile=", 52 | "-XMP-crs:Look*=", 53 | "-XMP-crs:LookName=Adobe Landscape", 54 | "-XMP-crs:LookUUID=6F9C877E84273F4E8271E6B91BEB36A1", 55 | "-XMP-crs:LookParametersHighlights2012=-12", 56 | "-XMP-crs:LookParametersShadows2012=+12", 57 | "-XMP-crs:LookParametersClarity2012=+10", 58 | "-XMP-crs:LookParametersToneCurvePV2012=0, 0; 64, 60; 128, 128; 192, 196; 255, 255", 59 | "-XMP-crs:LookParametersLookTable=0B3BFB5CFB7DBF7FF175E98F24D316B0", 60 | }, 61 | "Adobe Neutral": { 62 | "-XMP-crs:ConvertToGrayscale=", 63 | "-XMP-crs:CameraProfile=", 64 | "-XMP-crs:Look*=", 65 | "-XMP-crs:LookName=Adobe Neutral", 66 | "-XMP-crs:LookUUID=1E8E067A11CD44394A3C36A327BB34D1", 67 | "-XMP-crs:LookParametersToneCurvePV2012=0, 0; 16, 24; 64, 72; 128, 128; 192, 176; 244, 234; 255, 255", 68 | "-XMP-crs:LookParametersLookTable=7740BB918B2F6D93D7B95A4DBB78DB23", 69 | }, 70 | "Adobe Portrait": { 71 | "-XMP-crs:ConvertToGrayscale=", 72 | "-XMP-crs:CameraProfile=", 73 | "-XMP-crs:Look*=", 74 | "-XMP-crs:LookName=Adobe Portrait", 75 | "-XMP-crs:LookUUID=D6496412E06A83789C499DF9540AA616", 76 | "-XMP-crs:LookParametersToneCurvePV2012=0, 0; 66, 64; 190, 192; 255, 255", 77 | "-XMP-crs:LookParametersLookTable=E5A76DBB8B3F132A04C01AF45DC2EF1B", 78 | }, 79 | "Adobe Vivid": { 80 | "-XMP-crs:ConvertToGrayscale=", 81 | "-XMP-crs:CameraProfile=", 82 | "-XMP-crs:Look*=", 83 | "-XMP-crs:LookName=Adobe Vivid", 84 | "-XMP-crs:LookUUID=EA1DE074F188405965EF399C72C221D9", 85 | "-XMP-crs:LookParametersClarity2012=+10", 86 | "-XMP-crs:LookParametersToneCurvePV2012=0, 0; 32, 22; 64, 56; 128, 128; 224, 232; 240, 246; 255, 255", 87 | "-XMP-crs:LookParametersLookTable=2FE663AB0D3CE5DA7B9F657BBCD66DFE", 88 | }, 89 | } 90 | 91 | type makeModel struct{ make, model string } 92 | 93 | var cameraProfilesMtx sync.Mutex 94 | var cameraProfiles = map[makeModel]struct { 95 | adobe string 96 | other []string 97 | }{} 98 | 99 | func loadProfiles(make, model string, process float32, grayscale bool, profile, look string) (string, []string) { 100 | adobe, other := func() (string, []string) { 101 | cameraProfilesMtx.Lock() 102 | defer cameraProfilesMtx.Unlock() 103 | 104 | res, ok := cameraProfiles[makeModel{make, model}] 105 | if ok { 106 | return res.adobe, res.other 107 | } 108 | 109 | craw.EmbedProfiles = dngconv.Path 110 | profiles, _ := craw.GetCameraProfileNames(make, model) 111 | 112 | upgraded := map[string]string{} 113 | for _, name := range profiles { 114 | key := name 115 | switch { 116 | case strings.HasSuffix(key, " v2"): 117 | key = name[:len(key)-len(" v2")] 118 | case strings.HasSuffix(key, " v4"): 119 | key = name[:len(key)-len(" v4")] 120 | } 121 | if upgraded[key] < name { 122 | upgraded[key] = name 123 | } 124 | } 125 | 126 | res.adobe = "Adobe Standard" 127 | for key, name := range upgraded { 128 | if key == "Adobe Standard" { 129 | res.adobe = name 130 | } else { 131 | res.other = append(res.other, name) 132 | } 133 | } 134 | 135 | sort.Strings(res.other) 136 | 137 | cameraProfiles[makeModel{make, model}] = res 138 | return res.adobe, res.other 139 | }() 140 | 141 | if process != 0 || profile != "" || look != "" { 142 | switch { 143 | case look == "" && (profile == "" || profile == adobe): 144 | profile = "Adobe Standard" 145 | case slices.Contains(defaultProfiles, look) && (profile == "" || profile == adobe): 146 | profile = look 147 | case slices.Contains(other, profile) && look == "" && !grayscale: 148 | // 149 | default: 150 | profile = "Custom" 151 | } 152 | if profile == "Adobe Standard" && grayscale { 153 | profile = "Adobe Standard B&W" 154 | } 155 | } 156 | 157 | return profile, other 158 | } 159 | -------------------------------------------------------------------------------- /http_batch.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "net/http" 8 | "os" 9 | "path/filepath" 10 | 11 | "github.com/gorilla/schema" 12 | "github.com/ncruces/rethinkraw/pkg/osutil" 13 | "github.com/ncruces/zenity" 14 | ) 15 | 16 | type multiStatus struct { 17 | Code int `json:"code"` 18 | Text string `json:"text"` 19 | Body any `json:"response,omitempty"` 20 | Done int `json:"done,omitempty"` 21 | Total int `json:"total,omitempty"` 22 | } 23 | 24 | func batchHandler(w http.ResponseWriter, r *http.Request) httpResult { 25 | if err := r.ParseForm(); err != nil { 26 | return httpResult{Status: http.StatusBadRequest, Error: err} 27 | } 28 | prefix := getPathPrefix(r) 29 | batch := fromBatchPath(r.URL.Path) 30 | if len(batch) == 0 { 31 | path := fromURLPath(r.URL.Path, prefix) 32 | if fi, _ := os.Stat(path); fi != nil && fi.IsDir() { 33 | return httpResult{Location: "/batch/" + toBatchPath(path)} 34 | } 35 | return httpResult{Status: http.StatusGone} 36 | } 37 | photos, err := findPhotos(batch) 38 | if err != nil { 39 | return httpResult{Error: err} 40 | } 41 | 42 | _, save := r.Form["save"] 43 | _, export := r.Form["export"] 44 | _, settings := r.Form["settings"] 45 | 46 | switch { 47 | case save: 48 | var xmp xmpSettings 49 | dec := schema.NewDecoder() 50 | dec.IgnoreUnknownKeys(true) 51 | if err := dec.Decode(&xmp, r.Form); err != nil { 52 | return httpResult{Error: err} 53 | } 54 | xmp.Orientation = 0 55 | 56 | results := batchProcess(r.Context(), photos, func(ctx context.Context, photo batchPhoto) error { 57 | xmp := xmp 58 | xmp.Filename = filepath.Base(photo.Path) 59 | return saveEdit(ctx, photo.Path, xmp) 60 | }) 61 | 62 | w.Header().Set("Content-Type", "application/x-ndjson") 63 | w.WriteHeader(http.StatusMultiStatus) 64 | batchResultWriter(w, results, len(photos)) 65 | return httpResult{} 66 | 67 | case export: 68 | var xmp xmpSettings 69 | var exp exportSettings 70 | dec := schema.NewDecoder() 71 | dec.IgnoreUnknownKeys(true) 72 | if err := dec.Decode(&xmp, r.Form); err != nil { 73 | return httpResult{Error: err} 74 | } 75 | if err := dec.Decode(&exp, r.Form); err != nil { 76 | return httpResult{Error: err} 77 | } 78 | xmp.Orientation = 0 79 | 80 | var exppath string 81 | if len(photos) > 0 { 82 | exppath = filepath.Dir(photos[0].Path) 83 | if res, err := zenity.SelectFile(zenity.Context(r.Context()), zenity.Directory(), zenity.Filename(exppath)); res != "" { 84 | exppath = res 85 | } else if errors.Is(err, zenity.ErrCanceled) { 86 | return httpResult{Status: http.StatusNoContent} 87 | } else if err == nil { 88 | return httpResult{Status: http.StatusInternalServerError} 89 | } else { 90 | return httpResult{Error: err} 91 | } 92 | } 93 | 94 | results := batchProcess(r.Context(), photos, func(ctx context.Context, photo batchPhoto) error { 95 | err := batchProcessPhoto(ctx, photo, exppath, xmp, exp) 96 | if err == nil && exp.Both { 97 | err = batchProcessPhoto(ctx, photo, exppath, xmp, exportSettings{}) 98 | } 99 | return err 100 | }) 101 | 102 | w.Header().Set("Content-Type", "application/x-ndjson") 103 | w.WriteHeader(http.StatusMultiStatus) 104 | batchResultWriter(w, results, len(photos)) 105 | return httpResult{} 106 | 107 | case settings: 108 | if len(photos) == 0 { 109 | return httpResult{Status: http.StatusNoContent} 110 | } 111 | if xmp, err := loadEdit(photos[0].Path); err != nil { 112 | return httpResult{Error: err} 113 | } else { 114 | w.Header().Set("Content-Type", "application/json") 115 | enc := json.NewEncoder(w) 116 | if err := enc.Encode(xmp); err != nil { 117 | return httpResult{Error: err} 118 | } 119 | } 120 | return httpResult{} 121 | 122 | default: 123 | w.Header().Set("Cache-Control", "max-age=10") 124 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 125 | 126 | data := struct { 127 | Export bool 128 | Photos []struct{ Name, Path string } 129 | }{ 130 | isLocalhost(r), nil, 131 | } 132 | 133 | for _, photo := range photos { 134 | item := struct{ Name, Path string }{photo.Name, toURLPath(photo.Path, prefix)} 135 | data.Photos = append(data.Photos, item) 136 | } 137 | 138 | return httpResult{ 139 | Error: templates.ExecuteTemplate(w, "batch.gohtml", data), 140 | } 141 | } 142 | } 143 | 144 | func batchProcessPhoto(ctx context.Context, photo batchPhoto, exppath string, xmp xmpSettings, exp exportSettings) error { 145 | xmp.Filename = filepath.Base(photo.Path) 146 | out, err := exportEdit(ctx, photo.Path, xmp, exp) 147 | if err != nil { 148 | return err 149 | } 150 | 151 | exppath = filepath.Join(exppath, exportPath(photo.Name, exp)) 152 | if err := os.MkdirAll(filepath.Dir(exppath), 0777); err != nil { 153 | return err 154 | } 155 | f, err := osutil.NewFile(exppath) 156 | if err != nil { 157 | return err 158 | } 159 | defer f.Close() 160 | 161 | _, err = f.Write(out) 162 | if err != nil { 163 | return err 164 | } 165 | return f.Close() 166 | } 167 | 168 | func batchResultWriter(w http.ResponseWriter, results <-chan error, total int) { 169 | i := 0 170 | enc := json.NewEncoder(w) 171 | flush, _ := w.(http.Flusher) 172 | for err := range results { 173 | i += 1 174 | var status multiStatus 175 | if err != nil { 176 | status.Code, status.Body = errorStatus(err) 177 | } else { 178 | status.Code = http.StatusOK 179 | } 180 | status.Done, status.Total = i, total 181 | status.Text = http.StatusText(status.Code) 182 | enc.Encode(status) 183 | 184 | if flush != nil { 185 | flush.Flush() 186 | } 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /pkg/chrome/chrome.go: -------------------------------------------------------------------------------- 1 | // Package chrome provides support to locate and run Google Chrome (or Microsoft Edge). 2 | package chrome 3 | 4 | import ( 5 | "bufio" 6 | "bytes" 7 | "errors" 8 | "io/fs" 9 | "log" 10 | "math/rand" 11 | "os" 12 | "os/exec" 13 | "path/filepath" 14 | "strconv" 15 | "sync" 16 | "sync/atomic" 17 | 18 | "github.com/gorilla/websocket" 19 | "github.com/ncruces/jason" 20 | ) 21 | 22 | var once sync.Once 23 | var chrome string 24 | 25 | // IsInstalled checks if Chrome is installed. 26 | func IsInstalled() bool { 27 | once.Do(findChrome) 28 | return chrome != "" 29 | } 30 | 31 | // Cmd represents a Chrome instance being prepared or run. 32 | type Cmd struct { 33 | cmd *exec.Cmd 34 | ws *websocket.Conn 35 | url string 36 | msg atomic.Uint32 37 | } 38 | 39 | // Command returns the Cmd struct to execute a Chrome app loaded from url, 40 | // and with the given user data and disk cache directories. 41 | func Command(url string, dataDir, cacheDir string, arg ...string) *Cmd { 42 | once.Do(findChrome) 43 | if chrome == "" { 44 | return nil 45 | } 46 | 47 | prefs := filepath.Join(dataDir, "Default", "Preferences") 48 | if _, err := os.Stat(prefs); errors.Is(err, fs.ErrNotExist) { 49 | if err := os.MkdirAll(filepath.Dir(prefs), 0700); err == nil { 50 | os.WriteFile(prefs, []byte(`{ 51 | "profile": {"cookie_controls_mode": 1}, 52 | "search": {"suggest_enabled": false}, 53 | "signin": {"allowed_on_next_startup": false}, 54 | "enable_do_not_track": true 55 | }`), 0600) 56 | } 57 | } 58 | 59 | // https://peter.sh/experiments/chromium-command-line-switches/ 60 | // https://github.com/GoogleChrome/chrome-launcher/blob/master/docs/chrome-flags-for-tools.md 61 | // https://source.chromium.org/chromium/chromium/src/+/master:chrome/test/chromedriver/chrome_launcher.cc 62 | arg = append([]string{ 63 | "--app=" + url, 64 | "--user-data-dir=" + dataDir, 65 | "--disk-cache-dir=" + cacheDir, 66 | "--class=" + strconv.FormatUint(rand.Uint64(), 16), 67 | "--incognito", "--inprivate", "--bwsi", "--remote-debugging-port=0", 68 | "--no-first-run", "--no-default-browser-check", "--no-service-autorun", "--no-pings", 69 | "--disable-sync", "--disable-breakpad", "--disable-extensions", "--disable-default-apps", 70 | "--disable-component-extensions-with-background-pages", "--disable-background-networking", 71 | "--disable-domain-reliability", "--disable-client-side-phishing-detection", "--disable-component-update", 72 | }, arg...) 73 | 74 | cmd := exec.Command(chrome, arg...) 75 | return &Cmd{cmd: cmd, url: origin(url)} 76 | } 77 | 78 | // Run starts Chrome and waits for it to complete. 79 | func (c *Cmd) Run() error { 80 | if err := c.Start(); err != nil { 81 | return err 82 | } 83 | return c.Wait() 84 | } 85 | 86 | // Start starts Chrome but does not wait for it to complete. 87 | func (c *Cmd) Start() error { 88 | pipe, err := c.cmd.StderrPipe() 89 | if err != nil { 90 | return err 91 | } 92 | defer pipe.Close() 93 | 94 | err = c.cmd.Start() 95 | if err != nil { 96 | return err 97 | } 98 | 99 | scan := bufio.NewScanner(pipe) 100 | for scan.Scan() { 101 | const prefix = "DevTools listening on " 102 | line := scan.Bytes() 103 | if bytes.HasPrefix(line, []byte(prefix)) { 104 | url := line[len(prefix):] 105 | c.ws, _, err = websocket.DefaultDialer.Dial(string(url), nil) 106 | if err != nil { 107 | return err 108 | } 109 | go c.receiveloop() 110 | return nil 111 | } 112 | } 113 | return scan.Err() 114 | } 115 | 116 | // Wait for Chrome to exit. 117 | func (c *Cmd) Wait() error { 118 | return c.cmd.Wait() 119 | } 120 | 121 | // Signal sends a signal to Chrome. 122 | func (c *Cmd) Signal(sig os.Signal) error { 123 | return signal(c.cmd.Process, sig) 124 | } 125 | 126 | // Close closes Chrome. 127 | func (c *Cmd) Close() error { 128 | return c.send("Browser.close", "", nil) 129 | } 130 | 131 | func (c *Cmd) receiveloop() { 132 | var started bool 133 | targets := set[string]{} 134 | c.send("Target.setDiscoverTargets", "", jason.Object{"discover": true}) 135 | for { 136 | var msg cdpMessage 137 | err := c.ws.ReadJSON(&msg) 138 | if err != nil { 139 | if websocket.IsUnexpectedCloseError(err, 140 | websocket.CloseGoingAway, 141 | websocket.CloseNormalClosure, 142 | websocket.CloseAbnormalClosure) { 143 | log.Println("chrome:", err) 144 | } 145 | break 146 | } 147 | switch msg.Method { 148 | case "Target.targetDestroyed", "Target.targetCrashed": 149 | targets.Del(jason.ToA[string](msg.Params["targetId"])) 150 | case "Target.targetCreated", "Target.targetInfoChanged": 151 | info := jason.ToA[cdpTargetInfo](msg.Params["targetInfo"]) 152 | if origin(info.URL) == c.url { 153 | targets.Add(info.TargetID) 154 | } else { 155 | targets.Del(info.TargetID) 156 | } 157 | if info.Type == "page" { 158 | started = true 159 | } 160 | } 161 | if started && len(targets) == 0 { 162 | c.Close() 163 | } 164 | } 165 | } 166 | 167 | func (c *Cmd) send(method, session string, params any) error { 168 | return c.ws.WriteJSON(jason.Object{ 169 | "id": c.msg.Add(1), 170 | "method": method, 171 | "params": params, 172 | "sessionId": session, 173 | }) 174 | } 175 | 176 | type cdpMessage struct { 177 | ID uint32 `json:"id,omitempty"` 178 | Method string `json:"method,omitempty"` 179 | Result jason.RawValue `json:"result,omitempty"` 180 | Params jason.RawObject `json:"params,omitempty"` 181 | } 182 | 183 | type cdpTargetInfo struct { 184 | TargetID string `json:"targetId"` 185 | Type string `json:"type"` 186 | Title string `json:"title"` 187 | URL string `json:"url"` 188 | Attached bool `json:"attached"` 189 | OpenerId string `json:"openerId,omitempty"` 190 | } 191 | -------------------------------------------------------------------------------- /workspace.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "sync" 7 | "time" 8 | 9 | "github.com/ncruces/rethinkraw/internal/config" 10 | "github.com/ncruces/rethinkraw/internal/util" 11 | "github.com/ncruces/rethinkraw/pkg/osutil" 12 | ) 13 | 14 | // RethinkRAW edits happen in a workspace. 15 | // 16 | // Adobe DNG Converter loads RAW files and editing metadata from disk, 17 | // and saves DNG files with their embed previews to disk as well. 18 | // 19 | // A workspace is a temporary directory created for each opened RAW file 20 | // where all edits and conversions take place. 21 | // 22 | // This temporary directory is located on: "$TMPDIR/RethingRAW/[HASH]/" 23 | // The directory is created when the file is first opened, 24 | // and deleted when the workspace is finally closed. 25 | // 26 | // It can contain several files: 27 | // . orig.EXT - a read-only copy of the original RAW file 28 | // . orig.xmp - a sidecar for orig.EXT 29 | // . temp.dng - a DNG used as the target for all conversions 30 | // . edit.dng - a DNG conversion of the original RAW file used for editing previews 31 | // 32 | // Editing settings are loaded from orig.xmp or orig.EXT (in that order). 33 | // The DNG in edit.dng is downscaled to at most 2560 on the widest side. 34 | // When generating a preview, use edit.dng unless the preview requires full resolution. 35 | // If edit.dng is missing, use orig.EXT, ask for a 2560 preview, and save that to edit.dng. 36 | 37 | type workspace struct { 38 | hash string // a hash of the original RAW file path 39 | ext string // the extension of the original RAW file 40 | base string // base directory for the workspace 41 | hasPixels bool // have we extracted pixel data? 42 | hasEdit bool // any recent edits? 43 | } 44 | 45 | func openWorkspace(path string) (wk workspace, err error) { 46 | wk.hash = util.HashedID(filepath.Clean(path)) 47 | wk.ext = filepath.Ext(path) 48 | wk.base = filepath.Join(config.TempDir, wk.hash) + string(filepath.Separator) 49 | 50 | workspaces.open(wk.hash) 51 | defer func() { 52 | if err != nil { 53 | if workspaces.delete(wk.hash) { 54 | os.RemoveAll(wk.base) 55 | } 56 | wk = workspace{} 57 | } 58 | }() 59 | 60 | // create directory 61 | err = os.MkdirAll(wk.base, 0700) 62 | if err != nil { 63 | return wk, err 64 | } 65 | 66 | // have we edited this file recently (10 min)? 67 | fi, err := os.Stat(wk.base + "edit.dng") 68 | if err == nil && time.Since(fi.ModTime()) < 10*time.Minute { 69 | pi, _ := os.Stat(wk.base + "edit.ppm") 70 | wk.hasPixels = pi != nil && !pi.ModTime().Before(fi.ModTime()) 71 | wk.hasEdit = true 72 | return wk, err 73 | } 74 | 75 | // was this just copied (1 min)? 76 | fi, err = os.Stat(wk.base + "orig" + wk.ext) 77 | if err == nil && time.Since(fi.ModTime()) < time.Minute { 78 | return wk, err 79 | } 80 | 81 | // otherwise, copy the original RAW file to orig.EXT 82 | err = osutil.Lnky(path, wk.base+"orig"+wk.ext) 83 | if err != nil { 84 | return wk, err 85 | } 86 | 87 | // and load a sidecar for it 88 | err = loadSidecar(path, wk.base+"orig.xmp") 89 | return wk, err 90 | } 91 | 92 | func (wk *workspace) close() { 93 | if lru := workspaces.close(wk.hash); lru != "" { 94 | os.RemoveAll(filepath.Join(config.TempDir, lru)) 95 | } 96 | } 97 | 98 | // A read-only copy of the original RAW file (full resolution). 99 | func (wk *workspace) orig() string { 100 | return wk.base + "orig" + wk.ext 101 | } 102 | 103 | // A DNG used as the target for all conversions. 104 | func (wk *workspace) temp() string { 105 | return wk.base + "temp.dng" 106 | } 107 | 108 | // A JPG used as the target for export. 109 | func (wk *workspace) jpeg() string { 110 | return wk.base + "temp.jpg" 111 | } 112 | 113 | // A DNG conversion of the original RAW file used for editing previews (downscaled to 2560). 114 | func (wk *workspace) edit() string { 115 | return wk.base + "edit.dng" 116 | } 117 | 118 | // A RAW pixel map for edit.dng. 119 | func (wk *workspace) pixels() string { 120 | return wk.base + "edit.ppm" 121 | } 122 | 123 | // A sidecar for orig.EXT. 124 | func (wk *workspace) origXMP() string { 125 | return wk.base + "orig.xmp" 126 | } 127 | 128 | // HTTP is stateless. There is no notion of a file being opened for editing. 129 | // 130 | // A global manager keeps track of which files are currently being edited, 131 | // and how many tasks are pending for each file. 132 | // 133 | // Once a file has no pending tasks, the workspace is eligible for deletion. 134 | // As an optimization, the 3 LRU workspaces are cached. 135 | 136 | var workspaces = workspaceLocker{locks: make(map[string]*workspaceLock)} 137 | 138 | const workspaceMaxLRU = 3 139 | 140 | type workspaceLocker struct { 141 | sync.Mutex 142 | lru []string 143 | locks map[string]*workspaceLock 144 | } 145 | 146 | type workspaceLock struct { 147 | sync.Mutex 148 | n int // 149 | } 150 | 151 | // Open and lock a workspace. 152 | func (wl *workspaceLocker) open(hash string) { 153 | wl.Lock() 154 | 155 | // create a workspace lock 156 | lk, ok := wl.locks[hash] 157 | if !ok { 158 | lk = &workspaceLock{} 159 | wl.locks[hash] = lk 160 | } 161 | lk.n++ // one more pending task 162 | 163 | for i, h := range wl.lru { 164 | if h == hash { 165 | // remove workspace from LRU 166 | wl.lru = append(wl.lru[:i], wl.lru[i+1:]...) 167 | } 168 | } 169 | 170 | wl.Unlock() 171 | lk.Lock() 172 | } 173 | 174 | // Close and unlock a workspace, but cache it. 175 | // Return a workspace to evict. 176 | func (wl *workspaceLocker) close(hash string) (lru string) { 177 | wl.Lock() 178 | 179 | lk := wl.locks[hash] 180 | lk.n-- // one less pending task 181 | 182 | // are we the last task? 183 | if lk.n <= 0 { 184 | // evict a workspace from LRU 185 | if len(wl.lru) >= workspaceMaxLRU { 186 | lru, wl.lru = wl.lru[0], wl.lru[1:] 187 | } 188 | // add ourselves to LRU 189 | wl.lru = append(wl.lru, hash) 190 | // delete our lock 191 | delete(wl.locks, hash) 192 | } 193 | 194 | lk.Unlock() 195 | wl.Unlock() 196 | return lru // return the evicted workspace 197 | } 198 | 199 | // Close and unlock a workspace, but don't cache it. 200 | // Return if safe to delete. 201 | func (wl *workspaceLocker) delete(hash string) (ok bool) { 202 | wl.Lock() 203 | 204 | lk := wl.locks[hash] 205 | lk.n-- // one less pending task 206 | 207 | // are we the last task? 208 | if lk.n <= 0 { 209 | // delete our lock 210 | delete(wl.locks, hash) 211 | ok = true 212 | } 213 | 214 | lk.Unlock() 215 | wl.Unlock() 216 | return ok // were we the last task? 217 | } 218 | -------------------------------------------------------------------------------- /pkg/dng/temp.go: -------------------------------------------------------------------------------- 1 | package dng 2 | 3 | import ( 4 | "math" 5 | 6 | "gonum.org/v1/gonum/mat" 7 | ) 8 | 9 | // GetTemperatureFromXY computes a correlated color temperature and offset (tint) 10 | // from x-y chromaticity coordinates. 11 | // 12 | // This can be used to convert an AsShotWhiteXY DNG tag to a temperature and tint. 13 | func GetTemperatureFromXY(x, y float64) (temperature, tint int) { 14 | tmp, tnt := xy64{x, y}.temperature() 15 | tmp = math.RoundToEven(tmp) 16 | tnt = math.RoundToEven(tnt) 17 | return int(tmp), int(tnt) 18 | } 19 | 20 | // GetXYFromTemperature computes the x-y chromaticity coordinates 21 | // of a correlated color temperature and offset (tint). 22 | // 23 | // This can be used to convert a temperature and tint to an AsShotWhiteXY DNG tag. 24 | func GetXYFromTemperature(temperature, tint int) (x, y float64) { 25 | xy := getXY(float64(temperature), float64(tint)) 26 | return xy.x, xy.y 27 | } 28 | 29 | var _D50 = xy64{0.34567, 0.35850} 30 | 31 | type xy64 struct{ x, y float64 } 32 | type xyz64 struct{ x, y, z float64 } 33 | 34 | func newXYZ64(v mat.Vector) xyz64 { 35 | if v.Len() != 3 { 36 | panic(mat.ErrShape) 37 | } 38 | return xyz64{v.AtVec(0), v.AtVec(1), v.AtVec(2)} 39 | } 40 | 41 | // Port of XYZtoXY. 42 | func (v xyz64) xy() xy64 { 43 | total := v.x + v.y + v.z 44 | if total <= 0.0 { 45 | return _D50 46 | } 47 | return xy64{v.x / total, v.y / total} 48 | } 49 | 50 | // Scale factor between distances in uv space to a more user friendly "tint" 51 | // parameter. 52 | const tintScale = -3000.0 53 | 54 | // Table from Wyszecki & Stiles, "Color Science", second edition, page 228. 55 | var tempTable = [31]struct { 56 | r, u, v, t float64 57 | }{ 58 | {0, 0.18006, 0.26352, -0.24341}, 59 | {10, 0.18066, 0.26589, -0.25479}, 60 | {20, 0.18133, 0.26846, -0.26876}, 61 | {30, 0.18208, 0.27119, -0.28539}, 62 | {40, 0.18293, 0.27407, -0.30470}, 63 | {50, 0.18388, 0.27709, -0.32675}, 64 | {60, 0.18494, 0.28021, -0.35156}, 65 | {70, 0.18611, 0.28342, -0.37915}, 66 | {80, 0.18740, 0.28668, -0.40955}, 67 | {90, 0.18880, 0.28997, -0.44278}, 68 | {100, 0.19032, 0.29326, -0.47888}, 69 | {125, 0.19462, 0.30141, -0.58204}, 70 | {150, 0.19962, 0.30921, -0.70471}, 71 | {175, 0.20525, 0.31647, -0.84901}, 72 | {200, 0.21142, 0.32312, -1.0182}, 73 | {225, 0.21807, 0.32909, -1.2168}, 74 | {250, 0.22511, 0.33439, -1.4512}, 75 | {275, 0.23247, 0.33904, -1.7298}, 76 | {300, 0.24010, 0.34308, -2.0637}, 77 | {325, 0.24792, 0.34655, -2.4681}, /* Note: 0.24792 is a corrected value for the error found in W&S as 0.24702 */ 78 | {350, 0.25591, 0.34951, -2.9641}, 79 | {375, 0.26400, 0.35200, -3.5814}, 80 | {400, 0.27218, 0.35407, -4.3633}, 81 | {425, 0.28039, 0.35577, -5.3762}, 82 | {450, 0.28863, 0.35714, -6.7262}, 83 | {475, 0.29685, 0.35823, -8.5955}, 84 | {500, 0.30505, 0.35907, -11.324}, 85 | {525, 0.31320, 0.35968, -15.628}, 86 | {550, 0.32129, 0.36011, -23.325}, 87 | {575, 0.32931, 0.36038, -40.770}, 88 | {600, 0.33724, 0.36051, -116.45}, 89 | } 90 | 91 | // Port of dng_temperature::Set_xy_coord. 92 | func (xy xy64) temperature() (temperature, tint float64) { 93 | // Convert to uv space. 94 | u := 2.0 * xy.x / (1.5 - xy.x + 6.0*xy.y) 95 | v := 3.0 * xy.y / (1.5 - xy.x + 6.0*xy.y) 96 | 97 | // Search for line pair coordinate in between. 98 | last_dt := 0.0 99 | last_du := 0.0 100 | last_dv := 0.0 101 | 102 | for index := 1; index < len(tempTable); index++ { 103 | // Convert slope to delta-u and delta-v, with length 1. 104 | du := 1.0 105 | dv := tempTable[index].t 106 | { 107 | len := math.Hypot(du, dv) 108 | du /= len 109 | dv /= len 110 | } 111 | 112 | // Find delta from black body point to test coordinate. 113 | uu := u - tempTable[index].u 114 | vv := v - tempTable[index].v 115 | 116 | // Find distance above or below line. 117 | dt := -uu*dv + vv*du 118 | 119 | // If below line, we have found line pair. 120 | if dt <= 0.0 || index == len(tempTable)-1 { 121 | // Find fractional weight of two lines. 122 | if dt > 0.0 { 123 | dt = 0.0 124 | } 125 | dt = -dt 126 | 127 | f := 0.0 128 | if index != 1 { 129 | f = dt / (last_dt + dt) 130 | } 131 | 132 | // Interpolate the temperature. 133 | temperature = 1.0e6 / (tempTable[index-1].r*f + tempTable[index].r*(1.0-f)) 134 | 135 | // Find delta from black body point to test coordinate. 136 | uu = u - (tempTable[index-1].u*f + tempTable[index].u*(1.0-f)) 137 | vv = v - (tempTable[index-1].v*f + tempTable[index].v*(1.0-f)) 138 | 139 | // Interpolate vectors along slope. 140 | du = du*(1.0-f) + last_du*f 141 | dv = dv*(1.0-f) + last_dv*f 142 | { 143 | len := math.Hypot(du, dv) 144 | du /= len 145 | dv /= len 146 | } 147 | 148 | // Find distance along slope. 149 | tint = (uu*du + vv*dv) * tintScale 150 | break 151 | } 152 | 153 | // Try next line pair. 154 | last_dt = dt 155 | last_du = du 156 | last_dv = dv 157 | } 158 | 159 | return temperature, tint 160 | } 161 | 162 | // Port of dng_temperature::Get_xy_coord. 163 | func getXY(temperature, tint float64) xy64 { 164 | var result xy64 165 | 166 | // Find inverse temperature to use as index. 167 | r := 1.0e6 / temperature 168 | 169 | // Convert tint to offset in uv space. 170 | offset := tint * (1.0 / tintScale) 171 | 172 | // Search for line pair containing coordinate. 173 | for index := 1; index < len(tempTable); index++ { 174 | if r < tempTable[index].r || index == len(tempTable)-1 { 175 | // Find relative weight of first line. 176 | f := (tempTable[index].r - r) / (tempTable[index].r - tempTable[index-1].r) 177 | 178 | // Interpolate the black body coordinates. 179 | u := tempTable[index-1].u*f + tempTable[index].u*(1.0-f) 180 | v := tempTable[index-1].v*f + tempTable[index].v*(1.0-f) 181 | 182 | // Find vectors along slope for each line. 183 | 184 | uu1 := 1.0 185 | vv1 := tempTable[index-1].t 186 | { 187 | len := math.Hypot(uu1, vv1) 188 | uu1 /= len 189 | vv1 /= len 190 | } 191 | 192 | uu2 := 1.0 193 | vv2 := tempTable[index].t 194 | { 195 | len := math.Hypot(uu2, vv2) 196 | uu2 /= len 197 | vv2 /= len 198 | } 199 | 200 | // Find vector from black body point. 201 | uu3 := uu1*f + uu2*(1.0-f) 202 | vv3 := vv1*f + vv2*(1.0-f) 203 | { 204 | len := math.Hypot(uu3, vv3) 205 | uu3 /= len 206 | vv3 /= len 207 | } 208 | 209 | // Adjust coordinate along this vector. 210 | u += uu3 * offset 211 | v += vv3 * offset 212 | 213 | // Convert to xy coordinates. 214 | result.x = 1.5 * u / (u - 4.0*v + 2.0) 215 | result.y = v / (u - 4.0*v + 2.0) 216 | break 217 | } 218 | } 219 | 220 | return result 221 | } 222 | -------------------------------------------------------------------------------- /pkg/dcraw/dcraw.go: -------------------------------------------------------------------------------- 1 | // Package dcraw provides support running dcraw. 2 | // 3 | // To use this package you need to point it to a WASM/WASI build of dcraw. 4 | // My builds of dcraw are available from: 5 | // https://github.com/ncruces/dcraw 6 | // 7 | // A build of dcraw build can be provided by your application, 8 | // loaded from a file path or embed into your application. 9 | // 10 | // To embed a build of dcraw into your application, import package embed: 11 | // 12 | // import _ github.com/ncruces/rethinkraw/pkg/dcraw/embed 13 | package dcraw 14 | 15 | import ( 16 | "bytes" 17 | "context" 18 | "encoding/binary" 19 | "errors" 20 | "fmt" 21 | "image" 22 | "image/jpeg" 23 | "io" 24 | "io/fs" 25 | "os" 26 | "regexp" 27 | "strconv" 28 | "sync" 29 | 30 | "github.com/ncruces/go-image/rotateflip" 31 | "github.com/tetratelabs/wazero" 32 | "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" 33 | "golang.org/x/sync/semaphore" 34 | ) 35 | 36 | // Configure Dcraw. 37 | var ( 38 | Binary []byte // Binary to execute. 39 | Path string // Path to load the binary from. 40 | ) 41 | 42 | var ( 43 | once sync.Once 44 | wasm wazero.Runtime 45 | module wazero.CompiledModule 46 | sem *semaphore.Weighted 47 | orienRegex *regexp.Regexp 48 | thumbRegex *regexp.Regexp 49 | ) 50 | 51 | func compile() { 52 | ctx := context.Background() 53 | 54 | wasm = wazero.NewRuntime(ctx) 55 | wasi_snapshot_preview1.MustInstantiate(ctx, wasm) 56 | 57 | if Binary == nil && Path != "" { 58 | if bin, err := os.ReadFile(Path); err != nil { 59 | panic(err) 60 | } else { 61 | Binary = bin 62 | } 63 | } 64 | 65 | if m, err := wasm.CompileModule(ctx, Binary); err != nil { 66 | panic(err) 67 | } else { 68 | module = m 69 | } 70 | 71 | sem = semaphore.NewWeighted(6) 72 | orienRegex = regexp.MustCompile(`Orientation: +(\d)`) 73 | thumbRegex = regexp.MustCompile(`Thumb size: +(\d+) x (\d+)`) 74 | } 75 | 76 | func run(ctx context.Context, root fs.FS, args ...string) ([]byte, error) { 77 | once.Do(compile) 78 | 79 | err := sem.Acquire(ctx, 1) 80 | if err != nil { 81 | return nil, err 82 | } 83 | defer sem.Release(1) 84 | 85 | var buf bytes.Buffer 86 | cfg := wazero.NewModuleConfig(). 87 | WithArgs(args...).WithStdout(&buf).WithFS(root) 88 | module, err := wasm.InstantiateModule(ctx, module, cfg) 89 | if err != nil { 90 | return nil, err 91 | } 92 | err = module.Close(ctx) 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | return buf.Bytes(), nil 98 | } 99 | 100 | // GetThumb extracts a thumbnail from a RAW file. 101 | // 102 | // The thumbnail will either be a JPEG, or a PNM file in 8-bit P5/P6 format. 103 | // For more about PNM, see https://en.wikipedia.org/wiki/Netpbm 104 | func GetThumb(ctx context.Context, r io.ReadSeeker) ([]byte, error) { 105 | out, err := run(ctx, readerFS{r}, "dcraw", "-e", "-e", "-c", readerFSname) 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | const eoi = "\xff\xd9" 111 | const tag = "OFfSeTLeNgtH" 112 | const size = 2 + 2*64/8 + len(tag) 113 | if off := len(out) - size; off >= 0 && 114 | bytes.HasSuffix(out, []byte(tag)) && 115 | bytes.HasPrefix(out[off:], []byte(eoi)) { 116 | offset := int64(binary.LittleEndian.Uint64(out[off+2:])) 117 | length := int64(binary.LittleEndian.Uint64(out[off+2+64/8:])) 118 | _, err := r.Seek(offset, io.SeekStart) 119 | if err != nil { 120 | return nil, err 121 | } 122 | out = append(out[:off], make([]byte, int(length))...) 123 | _, err = io.ReadFull(r, out[off:]) 124 | if err != nil { 125 | return nil, err 126 | } 127 | } 128 | return out, nil 129 | } 130 | 131 | // GetThumbSize returns the size of the thumbnail [GetThumb] would extract. 132 | // The size is the bigger of width/height, in pixels. 133 | func GetThumbSize(ctx context.Context, r io.ReadSeeker) (int, error) { 134 | out, err := run(ctx, readerFS{r}, "dcraw", "-i", "-v", readerFSname) 135 | if err != nil { 136 | return 0, err 137 | } 138 | 139 | var max int 140 | if match := thumbRegex.FindSubmatch(out); match != nil { 141 | width, _ := strconv.Atoi(string(match[1])) 142 | height, _ := strconv.Atoi(string(match[2])) 143 | if width > height { 144 | max = width 145 | } else { 146 | max = height 147 | } 148 | } 149 | return max, nil 150 | } 151 | 152 | // GetThumbJPEG extracts a JPEG thumbnail from a RAW file. 153 | // 154 | // This is the same as calling [GetThumb], but converts PNM thumbnails to JPEG. 155 | func GetThumbJPEG(ctx context.Context, r io.ReadSeeker) ([]byte, error) { 156 | data, err := GetThumb(ctx, r) 157 | if err != nil { 158 | return nil, err 159 | } 160 | 161 | if bytes.HasPrefix(data, []byte("\xff\xd8")) { 162 | return data, nil 163 | } 164 | 165 | orientation := make(chan int) 166 | go func() { 167 | defer close(orientation) 168 | orientation <- GetOrientation(ctx, r) 169 | }() 170 | 171 | img, err := pnmDecodeThumb(data) 172 | if err != nil { 173 | return nil, err 174 | } 175 | 176 | exf := rotateflip.Orientation(<-orientation) 177 | img = rotateflip.Image(img, exf.Op()) 178 | 179 | buf := bytes.Buffer{} 180 | if err := jpeg.Encode(&buf, img, nil); err != nil { 181 | return nil, err 182 | } 183 | return buf.Bytes(), nil 184 | } 185 | 186 | // GetOrientation returns the EXIF orientation of the RAW file, or 0 if unknown. 187 | func GetOrientation(ctx context.Context, r io.ReadSeeker) int { 188 | out, err := run(ctx, readerFS{r}, "dcraw", "-i", "-v", readerFSname) 189 | if err != nil { 190 | return 0 191 | } 192 | 193 | if match := orienRegex.FindSubmatch(out); match != nil { 194 | return int(match[1][0] - '0') 195 | } 196 | return 0 197 | } 198 | 199 | // GetRAWPixels develops an half-resolution, demosaiced, not white balanced 200 | // image from the RAW file. 201 | func GetRAWPixels(ctx context.Context, r io.ReadSeeker) ([]byte, error) { 202 | return run(ctx, readerFS{r}, "dcraw", 203 | "-r", "1", "1", "1", "1", 204 | "-o", "0", 205 | "-h", 206 | "-4", 207 | "-t", "0", 208 | "-c", 209 | readerFSname) 210 | } 211 | 212 | func pnmDecodeThumb(data []byte) (image.Image, error) { 213 | var format, width, height int 214 | n, _ := fmt.Fscanf(bytes.NewReader(data), "P%d\n%d %d\n255\n", &format, &width, &height) 215 | if n == 3 { 216 | for i := 0; i < 3; i++ { 217 | data = data[bytes.IndexByte(data, '\n')+1:] 218 | } 219 | 220 | rect := image.Rect(0, 0, width, height) 221 | switch { 222 | case format == 5 && len(data) == width*height: 223 | img := image.NewGray(rect) 224 | copy(img.Pix, data) 225 | return img, nil 226 | 227 | case format == 6 && len(data) == 3*width*height: 228 | img := image.NewRGBA(rect) 229 | var i, j int 230 | for k := 0; k < width*height; k++ { 231 | img.Pix[i+0] = data[j+0] 232 | img.Pix[i+1] = data[j+1] 233 | img.Pix[i+2] = data[j+2] 234 | img.Pix[i+3] = 255 235 | i += 4 236 | j += 3 237 | } 238 | return img, nil 239 | } 240 | } 241 | return nil, errors.New("unsupported thumbnail") 242 | } 243 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "encoding/json" 7 | "errors" 8 | "html/template" 9 | "io/fs" 10 | "log" 11 | "net" 12 | "net/http" 13 | "os" 14 | "os/exec" 15 | "path/filepath" 16 | "strings" 17 | "time" 18 | 19 | "github.com/ncruces/jason" 20 | "github.com/ncruces/rethinkraw/internal/config" 21 | ) 22 | 23 | var templates *template.Template 24 | 25 | func setupHTTP() *http.Server { 26 | mux := http.NewServeMux() 27 | mux.Handle("/gallery/", http.StripPrefix("/gallery", httpHandler(galleryHandler))) 28 | mux.Handle("/photo/", http.StripPrefix("/photo", httpHandler(photoHandler))) 29 | mux.Handle("/batch/", http.StripPrefix("/batch", httpHandler(batchHandler))) 30 | mux.Handle("/thumb/", http.StripPrefix("/thumb", httpHandler(thumbHandler))) 31 | mux.Handle("/dialog", httpHandler(dialogHandler)) 32 | mux.Handle("/upload", httpHandler(uploadHandler)) 33 | mux.Handle("/", assetHandler) 34 | 35 | handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 36 | if !isLocalhost(r) { 37 | if !config.ServerMode || !matchHostServerName(r) { 38 | http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden) 39 | return 40 | } 41 | if url := canUseTLS(r); url != "" { 42 | http.Redirect(w, r, url, http.StatusTemporaryRedirect) 43 | return 44 | } 45 | if _, pwd, _ := r.BasicAuth(); pwd != serverAuth { 46 | w.Header().Set("WWW-Authenticate", `Basic charset="UTF-8"`) 47 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 48 | return 49 | } 50 | if r.URL.Path == "/" { 51 | http.Redirect(w, r, "/gallery", http.StatusTemporaryRedirect) 52 | return 53 | } 54 | } 55 | mux.ServeHTTP(w, r) 56 | }) 57 | 58 | templates = assetTemplates() 59 | 60 | server := &http.Server{ 61 | ReadHeaderTimeout: time.Second, 62 | IdleTimeout: time.Minute, 63 | Handler: handler, 64 | } 65 | return server 66 | } 67 | 68 | // httpResult helps httpHandler short circuit a result 69 | type httpResult struct { 70 | Status int 71 | Message string 72 | Location string 73 | Error error 74 | } 75 | 76 | func (r *httpResult) Done() bool { return r.Status != 0 || r.Location != "" || r.Error != nil } 77 | 78 | // httpHandler is an [http.Handler] that returns an httpResult 79 | type httpHandler func(w http.ResponseWriter, r *http.Request) httpResult 80 | 81 | func (h httpHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 82 | switch res := h(w, r); { 83 | 84 | case res.Location != "": 85 | if res.Status == 0 { 86 | res.Status = http.StatusTemporaryRedirect 87 | } 88 | http.Redirect(w, r, res.Location, res.Status) 89 | 90 | case res.Status >= 400: 91 | sendError(w, r, res.Status, res.Message) 92 | 93 | case res.Status != 0: 94 | w.WriteHeader(res.Status) 95 | 96 | case res.Error != nil: 97 | status, message := errorStatus(res.Error) 98 | sendError(w, r, status, message) 99 | log.Print(message) 100 | } 101 | } 102 | 103 | func errorStatus(err error) (status int, message string) { 104 | switch { 105 | case errors.Is(err, fs.ErrNotExist): 106 | status = http.StatusNotFound 107 | case errors.Is(err, fs.ErrPermission): 108 | status = http.StatusForbidden 109 | default: 110 | status = http.StatusInternalServerError 111 | } 112 | 113 | var buf strings.Builder 114 | buf.WriteString(strings.TrimSpace(err.Error())) 115 | 116 | var eerr *exec.ExitError 117 | if errors.As(err, &eerr) { 118 | if msg := bytes.TrimSpace(eerr.Stderr); len(msg) > 0 { 119 | buf.WriteByte('\n') 120 | buf.Write(msg) 121 | } 122 | } 123 | 124 | return status, buf.String() 125 | } 126 | 127 | func sendError(w http.ResponseWriter, r *http.Request, status int, message string) { 128 | h := w.Header() 129 | for n := range h { 130 | delete(h, n) 131 | } 132 | h.Set("X-Content-Type-Options", "nosniff") 133 | if strings.HasPrefix(r.Header.Get("Accept"), "text/html") { 134 | h.Set("Content-Type", "text/html; charset=utf-8") 135 | w.WriteHeader(status) 136 | templates.ExecuteTemplate(w, "error.gohtml", jason.Object{ 137 | "Status": http.StatusText(status), 138 | "Message": message, 139 | }) 140 | } else { 141 | h.Set("Content-Type", "application/json") 142 | w.WriteHeader(status) 143 | json.NewEncoder(w).Encode(message) 144 | } 145 | } 146 | 147 | func sendCached(w http.ResponseWriter, r *http.Request, path string) httpResult { 148 | if fi, err := os.Stat(path); err != nil { 149 | return httpResult{Error: err} 150 | } else { 151 | headers := w.Header() 152 | headers.Set("Last-Modified", fi.ModTime().UTC().Format(http.TimeFormat)) 153 | if ims := r.Header.Get("If-Modified-Since"); ims != "" { 154 | if t, err := http.ParseTime(ims); err == nil { 155 | if fi.ModTime().Before(t.Add(time.Second)) { 156 | for k := range headers { 157 | switch k { 158 | case "Cache-Control", "Last-Modified": 159 | // keep 160 | default: 161 | delete(headers, k) 162 | } 163 | } 164 | return httpResult{Status: http.StatusNotModified} 165 | } 166 | } 167 | } 168 | } 169 | return httpResult{} 170 | } 171 | 172 | func sendAllowed(w http.ResponseWriter, r *http.Request, allowed ...string) httpResult { 173 | for _, method := range allowed { 174 | if method == r.Method { 175 | return httpResult{} 176 | } 177 | } 178 | 179 | w.Header().Set("Allow", strings.Join(allowed, ", ")) 180 | return httpResult{Status: http.StatusMethodNotAllowed} 181 | } 182 | 183 | func isLocalhost(r *http.Request) bool { 184 | return r.TLS == nil && strings.TrimSuffix(r.Host, serverPort) == "localhost" 185 | } 186 | 187 | func getPathPrefix(r *http.Request) string { 188 | if !isLocalhost(r) { 189 | return serverPrefix 190 | } 191 | return "" 192 | } 193 | 194 | func toUsrPath(path, prefix string) string { 195 | path = filepath.Clean(path) 196 | if prefix != "" { 197 | if len(path) == len(prefix) || !strings.HasPrefix(path, prefix) { 198 | return "/" 199 | } 200 | path = path[len(prefix):] 201 | return filepath.ToSlash(path) 202 | } 203 | return path 204 | } 205 | 206 | func toURLPath(path, prefix string) string { 207 | path = filepath.Clean(path) 208 | if !strings.HasPrefix(path, prefix) { 209 | return "" 210 | } 211 | path = path[len(prefix):] 212 | if filepath.Separator == '\\' && strings.HasPrefix(path, `\\`) { 213 | return `\\` + filepath.ToSlash(path[2:]) 214 | } 215 | return strings.TrimPrefix(filepath.ToSlash(path), "/") 216 | } 217 | 218 | func fromURLPath(path, prefix string) string { 219 | if filepath.Separator != '/' { 220 | path = filepath.FromSlash(strings.TrimPrefix(path, "/")) 221 | } 222 | return filepath.Join(prefix, path) 223 | } 224 | 225 | func matchHostServerName(r *http.Request) bool { 226 | if r.TLS == nil { 227 | return true 228 | } 229 | host, _, err := net.SplitHostPort(r.Host) 230 | if err != nil { 231 | host = r.Host 232 | } 233 | return r.TLS.ServerName == host 234 | } 235 | 236 | func canUseTLS(r *http.Request) (url string) { 237 | if r.TLS != nil { 238 | return "" 239 | } 240 | 241 | host := r.Host 242 | name, port, err := net.SplitHostPort(host) 243 | if err != nil { 244 | return "" 245 | } 246 | if app := getAppDomain(name); app != "" { 247 | host = net.JoinHostPort(app, port) 248 | name = app 249 | } 250 | 251 | chi := tls.ClientHelloInfo{ServerName: name, SupportedVersions: []uint16{ 252 | tls.VersionTLS13, 253 | tls.VersionTLS12, 254 | }} 255 | 256 | config := &serverConfig 257 | if config.GetConfigForClient != nil { 258 | cfg, err := config.GetConfigForClient(&chi) 259 | if err != nil { 260 | return "" 261 | } 262 | if cfg != nil { 263 | config = cfg 264 | } 265 | } 266 | 267 | if config.GetCertificate != nil { 268 | cert, err := config.GetCertificate(&chi) 269 | if err != nil { 270 | return "" 271 | } 272 | if cert != nil { 273 | u := r.URL 274 | u.Host = host 275 | u.Scheme = "https" 276 | return u.String() 277 | } 278 | } 279 | 280 | for _, cert := range config.Certificates { 281 | if chi.SupportsCertificate(&cert) == nil { 282 | u := r.URL 283 | u.Host = host 284 | u.Scheme = "https" 285 | return u.String() 286 | } 287 | } 288 | return "" 289 | } 290 | 291 | func getAppDomain(name string) string { 292 | if ip := net.ParseIP(name); ip != nil { 293 | name = ip.String() 294 | if ip4 := ip.To4(); len(ip4) == net.IPv4len { 295 | name = strings.ReplaceAll(name, ".", "-") 296 | return name + ".app.rethinkraw.com" 297 | 298 | } else if len(ip) == net.IPv6len { 299 | if strings.HasPrefix(name, "::") { 300 | name = "0" + name 301 | } 302 | if strings.HasSuffix(name, "::") { 303 | name = name + "0" 304 | } 305 | name = strings.ReplaceAll(name, ":", "-") 306 | return name + ".app.rethinkraw.com" 307 | } 308 | } 309 | return "" 310 | } 311 | -------------------------------------------------------------------------------- /edit.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "image" 8 | "io/fs" 9 | "math" 10 | "os" 11 | "path/filepath" 12 | "strings" 13 | 14 | "github.com/ncruces/rethinkraw/pkg/osutil" 15 | "github.com/ncruces/rethinkraw/pkg/xmp" 16 | ) 17 | 18 | func loadEdit(path string) (xmp xmpSettings, err error) { 19 | wk, err := openWorkspace(path) 20 | if err != nil { 21 | return xmp, err 22 | } 23 | defer wk.close() 24 | 25 | return loadXMP(wk.origXMP()) 26 | } 27 | 28 | func saveEdit(ctx context.Context, path string, xmp xmpSettings) error { 29 | wk, err := openWorkspace(path) 30 | if err != nil { 31 | return err 32 | } 33 | defer wk.close() 34 | 35 | if xmp.WhiteBalance == "Camera Matching…" { 36 | xmp.WhiteBalance = cameraMatchingWhiteBalance(wk.orig()) 37 | } 38 | 39 | err = editXMP(wk.origXMP(), xmp) 40 | if err != nil { 41 | return err 42 | } 43 | 44 | dest, err := destSidecar(path) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | if path == dest { 50 | exp := exportSettings{ 51 | DNG: true, 52 | Embed: true, 53 | Preview: dngPreview(ctx, wk.orig()), 54 | } 55 | 56 | err = runDNGConverter(ctx, wk.orig(), wk.temp(), 0, &exp) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | err = osutil.Move(wk.temp(), dest+".bak") 62 | } else { 63 | err = osutil.Copy(wk.origXMP(), dest+".bak") 64 | } 65 | 66 | if err != nil { 67 | return err 68 | } 69 | return os.Rename(dest+".bak", dest) 70 | } 71 | 72 | func previewEdit(ctx context.Context, path string, size int, xmp xmpSettings) ([]byte, error) { 73 | wk, err := openWorkspace(path) 74 | if err != nil { 75 | return nil, err 76 | } 77 | defer wk.close() 78 | 79 | if xmp.WhiteBalance == "Camera Matching…" { 80 | xmp.WhiteBalance = cameraMatchingWhiteBalance(wk.orig()) 81 | } 82 | 83 | if size == 0 { 84 | // use the original RAW file for a full resolution preview 85 | 86 | err = editXMP(wk.origXMP(), xmp) 87 | if err != nil { 88 | return nil, err 89 | } 90 | 91 | err = runDNGConverter(ctx, wk.orig(), wk.temp(), 0, &exportSettings{}) 92 | if err != nil { 93 | return nil, err 94 | } 95 | 96 | return previewJPEG(ctx, wk.temp()) 97 | } else if wk.hasEdit { 98 | // use edit.dng (downscaled to at most 2560 on the widest side) 99 | 100 | err = editXMP(wk.edit(), xmp) 101 | if err != nil { 102 | return nil, err 103 | } 104 | 105 | err = runDNGConverter(ctx, wk.edit(), wk.temp(), size, nil) 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | return previewJPEG(ctx, wk.temp()) 111 | } else { 112 | // create edit.dng (downscaled to 2560 on the widest side) 113 | 114 | err = editXMP(wk.origXMP(), xmp) 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | err = runDNGConverter(ctx, wk.orig(), wk.edit(), 2560, nil) 120 | if err != nil { 121 | return nil, err 122 | } 123 | 124 | return previewJPEG(ctx, wk.edit()) 125 | } 126 | } 127 | 128 | func exportEdit(ctx context.Context, path string, xmp xmpSettings, exp exportSettings) ([]byte, error) { 129 | wk, err := openWorkspace(path) 130 | if err != nil { 131 | return nil, err 132 | } 133 | defer wk.close() 134 | 135 | if xmp.WhiteBalance == "Camera Matching…" { 136 | xmp.WhiteBalance = cameraMatchingWhiteBalance(wk.orig()) 137 | } 138 | 139 | err = editXMP(wk.origXMP(), xmp) 140 | if err != nil { 141 | return nil, err 142 | } 143 | 144 | err = runDNGConverter(ctx, wk.orig(), wk.temp(), 0, &exp) 145 | if err != nil { 146 | return nil, err 147 | } 148 | 149 | if exp.DNG { 150 | err = fixMetaDNG(wk.orig(), wk.temp(), path) 151 | if err != nil { 152 | return nil, err 153 | } 154 | return os.ReadFile(wk.temp()) 155 | } else { 156 | data, err := exportJPEG(ctx, wk.temp()) 157 | if err != nil { 158 | return nil, err 159 | } 160 | if exp.Resample { 161 | return resampleJPEG(data, exp) 162 | } 163 | 164 | err = os.WriteFile(wk.jpeg(), data, 0600) 165 | if err != nil { 166 | return nil, err 167 | } 168 | err = fixMetaJPEG(wk.orig(), wk.jpeg()) 169 | if err != nil { 170 | return nil, err 171 | } 172 | return os.ReadFile(wk.jpeg()) 173 | } 174 | } 175 | 176 | func exportPath(path string, exp exportSettings) string { 177 | var ext string 178 | if exp.DNG { 179 | ext = ".dng" 180 | } else { 181 | ext = ".jpg" 182 | } 183 | return strings.TrimSuffix(path, filepath.Ext(path)) + ext 184 | } 185 | 186 | func loadWhiteBalance(ctx context.Context, path string, coords []float64) (wb xmpWhiteBalance, err error) { 187 | wk, err := openWorkspace(path) 188 | if err != nil { 189 | return wb, err 190 | } 191 | defer wk.close() 192 | 193 | if !wk.hasEdit { 194 | // create edit.dng (downscaled to at most 2560 on the widest side) 195 | 196 | err = runDNGConverter(ctx, wk.orig(), wk.edit(), 2560, nil) 197 | if err != nil { 198 | return wb, err 199 | } 200 | } 201 | 202 | if !wk.hasPixels && len(coords) == 2 { 203 | err = getRawPixels(ctx, wk.edit(), wk.pixels()) 204 | if err != nil { 205 | return wb, err 206 | } 207 | } 208 | 209 | return computeWhiteBalance(wk.edit(), wk.pixels(), coords) 210 | } 211 | 212 | type exportSettings struct { 213 | DNG bool 214 | Preview string 215 | Lossy bool 216 | Embed bool 217 | Both bool 218 | 219 | Resample bool 220 | Quality int 221 | Fit string 222 | Long float64 223 | Short float64 224 | Width float64 225 | Height float64 226 | DimUnit string 227 | Density int 228 | DenUnit string 229 | MPixels float64 230 | } 231 | 232 | func (ex *exportSettings) FitImage(size image.Point) (fit image.Point) { 233 | if ex.Fit == "mpix" { 234 | mul := math.Sqrt(1e6 * ex.MPixels / float64(size.X*size.Y)) 235 | if size.X > size.Y { 236 | fit.X = math.MaxInt 237 | fit.Y = int(mul * float64(size.Y)) 238 | } else { 239 | fit.X = int(mul * float64(size.X)) 240 | fit.Y = math.MaxInt 241 | } 242 | } else { 243 | mul := 1.0 244 | 245 | if ex.DimUnit != "px" { 246 | density := float64(ex.Density) 247 | if ex.DimUnit == "in" { 248 | if ex.DenUnit == "ppi" { 249 | mul = density 250 | } else { 251 | mul = density * 2.54 252 | } 253 | } else { 254 | if ex.DenUnit == "ppi" { 255 | mul = density / 2.54 256 | } else { 257 | mul = density 258 | } 259 | } 260 | } 261 | 262 | round := func(x float64) int { 263 | if x == 0.0 { 264 | return math.MaxInt 265 | } 266 | i := int(x + 0.5) 267 | if i < 16 { 268 | return 16 269 | } else { 270 | return i 271 | } 272 | } 273 | 274 | if ex.Fit == "dims" { 275 | long, short := ex.Long, ex.Short 276 | if 0 < long && long < short { 277 | long, short = short, long 278 | } 279 | 280 | if size.X > size.Y { 281 | fit.X = round(mul * long) 282 | fit.Y = round(mul * short) 283 | } else { 284 | fit.X = round(mul * short) 285 | fit.Y = round(mul * long) 286 | } 287 | } else { 288 | fit.X = round(mul * ex.Width) 289 | fit.Y = round(mul * ex.Height) 290 | } 291 | } 292 | return fit 293 | } 294 | 295 | func loadSidecar(src, dst string) error { 296 | var data []byte 297 | err := os.ErrNotExist 298 | ext := filepath.Ext(src) 299 | 300 | if ext != "" { 301 | // if NAME.xmp is there for NAME.EXT, use it 302 | name := strings.TrimSuffix(src, ext) + ".xmp" 303 | data, err = os.ReadFile(name) 304 | if err == nil && !xmp.IsSidecarForExt(bytes.NewReader(data), ext) { 305 | err = os.ErrNotExist 306 | } 307 | } 308 | if errors.Is(err, fs.ErrNotExist) { 309 | // if NAME.EXT.xmp is there for NAME.EXT, use it 310 | data, err = os.ReadFile(src + ".xmp") 311 | if err == nil && !xmp.IsSidecarForExt(bytes.NewReader(data), ext) { 312 | err = os.ErrNotExist 313 | } 314 | } 315 | if err == nil { 316 | // copy xmp file 317 | err = os.WriteFile(dst, data, 0600) 318 | } 319 | if err == nil || errors.Is(err, fs.ErrNotExist) { 320 | // extract embed XMP data 321 | return extractXMP(src, dst) 322 | } 323 | return err 324 | } 325 | 326 | func destSidecar(src string) (string, error) { 327 | ext := filepath.Ext(src) 328 | 329 | if ext != "" { 330 | // if NAME.xmp is there for NAME.EXT, use it 331 | name := strings.TrimSuffix(src, ext) + ".xmp" 332 | data, err := os.ReadFile(name) 333 | if err == nil && xmp.IsSidecarForExt(bytes.NewReader(data), ext) { 334 | return name, nil 335 | } 336 | if !errors.Is(err, fs.ErrNotExist) { 337 | return "", err 338 | } 339 | } 340 | 341 | // if NAME.EXT.xmp exists, use it 342 | if _, err := os.Stat(src + ".xmp"); err == nil { 343 | return src + ".xmp", nil 344 | } else if !errors.Is(err, fs.ErrNotExist) { 345 | return "", err 346 | } 347 | 348 | // if NAME.DNG was edited, use it 349 | if strings.EqualFold(ext, ".dng") && dngHasEdits(src) { 350 | return src, nil 351 | } 352 | 353 | // fallback to NAME.xmp 354 | return strings.TrimSuffix(src, ext) + ".xmp", nil 355 | } 356 | -------------------------------------------------------------------------------- /assets/raw-editor.gohtml: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |
6 | Profile 7 | 21 |
22 | 23 |
24 | White Balance 25 | 38 |
39 | 40 | 41 | 43 | 44 | 45 | 46 | 48 |
49 |
50 | 51 |
52 | Tone 53 | 59 |
60 | 61 | 62 | 64 | 65 | 66 | 67 | 69 | 70 | 71 | 72 | 74 | 75 | 76 | 77 | 79 | 80 | 81 | 82 | 84 | 85 | 86 | 87 | 89 |
90 |
91 | 92 |
93 | Presence 94 | 95 | 96 | 97 | 99 | 100 | 101 | 102 | 104 | 105 | 106 | 107 | 109 | 110 |
111 | 112 | 113 | 115 | 116 | 117 | 118 | 120 |
121 |
122 | 123 |
124 | Curve 125 | 132 |
133 | 134 |
135 | Detail 136 | 137 | 138 | 139 | 141 | 142 | 143 | 144 | 146 | 147 | 148 | 149 | 151 |
152 | 153 |
154 | Lens Corrections 155 |
156 | 157 |
158 |
159 | 160 | 161 |
162 |
163 | 164 | 171 |
172 | 173 |
174 | 175 | 176 | 177 | 178 | 193 | 194 | 195 | 200 | 201 | 202 | 203 | 204 | 205 | 210 | 211 | 212 | 216 | 217 | 218 | megapixels 219 |
220 | 221 |
222 | 223 | 228 | 229 | 230 | 231 | 232 | 233 | 234 |
235 | 236 |
237 | 238 | 239 |
240 |
241 |
--------------------------------------------------------------------------------