diff --git a/README.md b/README.md index 1321a5db..447a0ac7 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,29 @@ +The LUTzy icon: an RGB colour cube on a dark tile + # LUTzy A macOS app that applies `.cube` LUTs to RAW and other images. It can also build a LUT from a RAW + JPEG pair. SwiftUI and Core Image, no third-party packages. macOS 26 to run, [Xcode 27 to compile](#build). +A 3D LUT is a table with a colour at every lattice point. The photo's colours are looked up in it, interpolated between the nearest points, and replaced. That is the whole trick, and it is why a LUT can be a look but never a sharpen or a blur. + +Two colour cubes. On the left, a regular lattice of coloured dots. On the right, the same dots after a LUT: shadows lifted and cooled, highlights warmed. + ## Features - **RAW** via `CIRAWFilter`, not the embedded preview. DNG, CR2, CR3, NEF, ARW, ORF, RAF, RW2, PEF, SRW, X3F, RAW. JPEG, PNG, TIFF, BMP, HEIC. -- **Import** — drop a file, a folder, several files, or pictures straight from Photos. ⌘O file · ⌘⇧I Photos (max 50) · ⌘⌥I source folder. -- **LUTs** — `.cube` 3D (`LUT_3D_SIZE`, `DOMAIN_MIN` / `DOMAIN_MAX`) through `CIColorCubeWithColorSpace` on Metal. Sidebar scans recursively, groups by subfolder, searchable. Intensity 0–100%. ⌘⇧L picks the folder. +- **Import** — drop a file, a folder, several files, pictures straight from Photos, or a bitmap from any app. ⌘O file · ⌘⇧I Photos (max 50) · ⌘⌥I source folder. +- **LUTs** — `.cube` 3D (`LUT_3D_SIZE`, `DOMAIN_MIN` / `DOMAIN_MAX`) through `CIColorCubeWithColorSpace` on Metal. Sidebar scans recursively, groups by subfolder, searchable. Intensity 0–100%, in the Adjust inspector. ⌘⇧L picks the folder. - **Preview** — V side-by-side / single. Hold Space in single view for the original. ↑ ↓ walk the library. - **Inspector** ⌘I - **Info** — histogram of what's on screen (graded, or original while Space is down) plus EXIF / TIFF / GPS - **Develop** — the `CIRAWFilter` knobs this file's decoder actually supports - **Adjust** — nine sliders after develop, before the LUT: exposure, brightness, contrast, saturation, highlights, shadows, temperature, tint, vibrance - **Filmstrip** when a source folder is open. ← → or [ ] step through; the current look stays on. ⌘R rescans. +- **Window** — Liquid Glass toolbar, customizable (View ▸ Customize Toolbar…). The file and LUT names sit in the title. ⌘, sets launch defaults: side-by-side, source browser, export format. - **Export** — 16-bit TIFF, JPEG (quality 0.95), or PNG, always full resolution, named `{photo}_{LUT}.ext` (spaces in the LUT name become underscores). ⌘⇧E Export All writes the whole look — develop, adjustments, LUT, intensity — and counts failures instead of aborting. ## Derive LUT from JPG @@ -25,11 +32,7 @@ SwiftUI and Core Image, no third-party packages. macOS 26 to run, [Xcode 27 to c The JPEG is treated as a look (the manufacturer's color science, or whatever picture profile was on). LUTzy writes the difference against a neutral RAW develop. Same frame required — aspect within 1%. Pixel size can differ. -``` -RAW ──► CIRAWFilter (neutral) ─┐ - ├─► align ─► smooth samples ─► 33³ cube ─┬─► .cube -JPEG ─► decode ─► edge mask ────┘ └─► report -``` +RAW is developed neutrally and the JPEG decoded; the pair is aligned, edges masked, sampled, and filled into a 33-cubed cube that is written as a .cube file and a report. The result previews on the current image and stays in memory until **Save to LUT Folder…**. @@ -81,7 +84,7 @@ swift test > [!IMPORTANT] > `swift run` and Run from Xcode both produce a SwiftPM executable, not a sandboxed `.app`. LUT folder and source folder do not persist across launches. -There is no `.xcodeproj`. `Package.swift` excludes `Assets.xcassets` and `LUTzy.entitlements`; the appiconset is empty; there is no `Info.plist` or bundle identifier. The entitlements file is real (sandbox, user-selected files, app-scoped bookmarks) and unused. An Xcode app target would apply it. This repo doesn't have one. +There is no `.xcodeproj`. `Package.swift` excludes `Assets.xcassets` and `LUTzy.entitlements`, and a SwiftPM executable has no `Info.plist` or bundle identifier, so `swift run` uses neither the icon in the appiconset nor the entitlements file. The release script below applies the icon and an `Info.plist`. The entitlements (sandbox, user-selected files, app-scoped bookmarks) are real and still unused; wiring them up needs an Xcode app target this repo doesn't have. - **Run** — macOS 26 - **Compile** — Xcode 27 / macOS 27 SDK @@ -103,6 +106,18 @@ Swift 6 language mode on every target. No `@unchecked Sendable`, `nonisolated(un Pipeline notes: [docs/PHASE2_SPEC.md](docs/PHASE2_SPEC.md). Standing review: [docs/CODE_REVIEW.md](docs/CODE_REVIEW.md). +## Release + +```bash +DEVELOPER_ID_APP="Developer ID Application: Your Name (TEAMID)" \ +NOTARY_PROFILE="your-notarytool-profile" \ +scripts/release-dmg.sh 0.1.1 +``` + +Does what an Xcode archive would: universal release build, a hand-written `LUTzy.app` with an `Info.plist` and the icon, signed with Developer ID and the hardened runtime (no sandbox yet), notarized and stapled, then packed into a DMG that is notarized and stapled again. Output lands in `build/release/`. Needs `create-dmg` (`brew install create-dmg`) and a notarytool keychain profile. + +The icon is drawn by `scripts/render-icon.swift`: the RGB cube a `.cube` file indexes, seen from its green corner, rendered into every slot of the appiconset. Run it again rather than editing the PNGs. + ## License [MIT](LICENSE) diff --git a/docs/images/derive.svg b/docs/images/derive.svg new file mode 100644 index 00000000..fee08c87 --- /dev/null +++ b/docs/images/derive.svg @@ -0,0 +1,29 @@ + +RAW +JPEG + + +develop, neutral + + +decode + + + +align + + +mask edges + + +sample + + +33³ cube + + +.cube + + +report + \ No newline at end of file diff --git a/docs/images/lut-cube.svg b/docs/images/lut-cube.svg new file mode 100644 index 00000000..cb939c64 --- /dev/null +++ b/docs/images/lut-cube.svg @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +.cube +every colour a photo can hold +where the LUT sends each one + \ No newline at end of file diff --git a/scripts/render-readme-graphics.py b/scripts/render-readme-graphics.py new file mode 100755 index 00000000..e67fc402 --- /dev/null +++ b/scripts/render-readme-graphics.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# Draws docs/images/lut-cube.svg and docs/images/derive.svg for the README. Run from the package +# root; standard library only. Regenerate rather than hand-editing the SVGs. +import math, colorsys +INK="#8E8E93"; INK2="#6E6E73" +SANS="-apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, Arial, sans-serif" +MONO="ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" + +AZ,EL=math.radians(38),math.radians(24) +def proj3(x,y,z,cx,cy,e): + x,y,z=x-.5,y-.5,z-.5 + ca,sa=math.cos(AZ),math.sin(AZ); x,z = x*ca+z*sa, -x*sa+z*ca + ce,se=math.cos(EL),math.sin(EL); y,z = y*ce-z*se, y*se+z*ce + return cx+x*e, cy-y*e, z +def proj(x,y,z,cx,cy,e): + px,py,_=proj3(x,y,z,cx,cy,e); return px,py + +def look(r,g,b): + # a film-ish look: S-curve, teal shadows, warm highlights, slight desat + def s(v): return v*v*(3-2*v) + r,g,b = s(r),s(g),s(b) + lum = 0.2126*r+0.7152*g+0.0722*b + shadow = (1-lum)**2; hi = lum**2 + r += 0.18*hi - 0.12*shadow; g += 0.06*hi + 0.02*shadow; b += -0.10*hi + 0.22*shadow + h,l,sat = colorsys.rgb_to_hls(*[min(1,max(0,v)) for v in (r,g,b)]) + r,g,b = colorsys.hls_to_rgb(h,l,sat*0.85) + return tuple(min(1,max(0,v)) for v in (r,g,b)) + +def cube(cx,cy,e,n,f,out): + V={'000':(0,0,0),'100':(1,0,0),'001':(0,0,1),'101':(1,0,1),'010':(0,1,0),'110':(1,1,0),'011':(0,1,1),'111':(1,1,1)} + C={k:proj3(*v,cx,cy,e) for k,v in V.items()} + far=min(C,key=lambda k:C[k][2]) + edges=[(a,b) for a in V for b in V if a') + for a,b in edges: + if far in (a,b): line(a,b,True) + dots=[] + for i in range(n): + for j in range(n): + for k in range(n): + r,g,b=f(i/(n-1),j/(n-1),k/(n-1)) + px,py,depth=proj3(r,g,b,cx,cy,e) + dots.append((depth,px,py,r,g,b)) + dots.sort() + for depth,px,py,r,g,b in dots: + col='#%02x%02x%02x'%tuple(int(round(v*255)) for v in (r,g,b)) + rad=4.6+1.6*(depth+0.87)/1.74 + out.append(f'') + for a,b in edges: + if far not in (a,b): line(a,b,False) + +def lut_cube_svg(): + W,H=720,318; out=[] + out.append(f'') + e=150 + cube(190,150,e,4,lambda x,y,z:(x,y,z),out) + cube(530,150,e,4,look,out) + # arrow + out.append(f'') + out.append(f'') + out.append(f'.cube') + out.append(f'every colour a photo can hold') + out.append(f'where the LUT sends each one') + out.append('') + return '\n'.join(out) + +def derive_svg(): + W,H=720,150; out=[] + out.append(f'') + def t(x,y,s,mono=False,anchor='start',fill=INK2): + ff=f' font-family="{MONO}" font-size="12"' if mono else '' + out.append(f'{s}') + def p(d): + out.append(f'') + def arrow(x,y): # pointing right, tip at x + p(f'M{x-7} {y-5} L{x} {y} L{x-7} {y+5}') + # inputs + t(0,44,'RAW',True); t(0,116,'JPEG',True) + p('M44 40 H72'); arrow(72,40); t(80,44,'develop, neutral') + p('M44 112 H72'); arrow(72,112); t(80,116,'decode') + # merge + p('M192 40 H228 V76 H244'); p('M128 112 H228 V76'); arrow(244,76) + t(252,80,'align'); p('M292 76 H316'); arrow(316,76) + t(324,80,'mask edges'); p('M404 76 H428'); arrow(428,76) + t(436,80,'sample'); p('M490 76 H514'); arrow(514,76) + t(522,80,'33³ cube') + # outputs + p('M590 76 H616 V40 H640'); arrow(640,40); t(648,44,'.cube',True) + p('M616 76 V112 H640'); arrow(640,112); t(648,116,'report') + out.append('') + return '\n'.join(out) + +open('docs/images/lut-cube.svg','w').write(lut_cube_svg()) +open('docs/images/derive.svg','w').write(derive_svg()) +print("written")