├── 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 | [](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 |
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 |
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 |
41 | {{- range .Photos}}
42 |

43 | {{- else}}
44 |
No RAW photos here.
45 | {{- end}}
46 |
47 |
48 |
49 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # RethinkRAW [
](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 | 
64 |
65 | #### Browsing photos
66 |
67 | 
68 |
69 | #### Editing a photo
70 |
71 | 
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 |
41 | {{- range .Photos}}
42 |
43 |
44 |
45 | {{- else}}
46 |
No RAW photos here.
47 | {{- end}}
48 |
49 |
50 |
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 |

43 |
44 |
![]()
45 |
46 |
47 |
48 |
49 |
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 |
159 |
160 |
--------------------------------------------------------------------------------