Phase 0: configurable brand, MIT licence, plan/ and Immich tracker
- brand.toml is the only place the product name lives; every crate and app
reads it through dac-brand (env vars with legacy fallback, settings folder
with copy-only migration, default library, preset extensions, i18n {app})
- cargo xtask brand check|test, run|install|package, docs, rename-crates,
deny and immich; packaging files are templates rendered from brand.toml
- icons regenerated from brand/icon.svg; upstream branding removed
- MIT licence (segment and fetch stay Apache-2.0); cargo deny licence policy
- clean-room rules in AGENTS.md/CLAUDE.md; docs/upstream-merge.md
- plan/ rebuilt and committed; parity tracker reopens Classic rows, adds
PLAN.md section 2 rows and the IMM Immich section; plan/immich.md and a
pinned Immich test server
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@@ -0,0 +1,62 @@
|
||||
# The product name lives only in brand.toml (PLAN_phase_0.md §0.2.4), and the licence policy is
|
||||
# enforced by cargo-deny (§0.4.6):
|
||||
# - check: `cargo xtask brand check` (no product name outside brand.toml and the allowlist),
|
||||
# `cargo xtask docs --check` (rendered docs up to date);
|
||||
# - deny: `cargo deny check licenses` with deny.toml;
|
||||
# - throw-away brand: `cargo xtask brand test` builds the CLI with xtask/test-brand.toml
|
||||
# ("Zzyzx Test") and checks its help, headless UI snapshot, MCP serverInfo and config/log paths.
|
||||
name: Brand
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: brand-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: brand-check
|
||||
- name: brand check
|
||||
run: cargo xtask brand check
|
||||
- name: docs up to date
|
||||
run: cargo xtask docs --check
|
||||
|
||||
deny:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-deny
|
||||
- name: cargo deny check licenses
|
||||
run: cargo deny check licenses
|
||||
|
||||
test-brand:
|
||||
name: throw-away brand
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: brand-test
|
||||
workspaces: ". -> target/brand-test"
|
||||
- name: cargo xtask brand test
|
||||
run: cargo xtask brand test
|
||||
@@ -5,9 +5,9 @@ name: Packaging lint
|
||||
on:
|
||||
push:
|
||||
branches: [main, release]
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/app-icon/**"]
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/app-icon/**", "brand.toml"]
|
||||
pull_request:
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/app-icon/**"]
|
||||
paths: [".github/workflows/**", "packaging/**", "assets/app-icon/**", "brand.toml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -44,22 +44,33 @@ jobs:
|
||||
if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 }
|
||||
Write-Output "ok $($f.Name)"
|
||||
}
|
||||
- name: Render the packaging templates (brand.toml)
|
||||
run: |
|
||||
. packaging/env.sh
|
||||
OUT="$RUNNER_TEMP/pkg"
|
||||
for t in packaging/linux/*.in packaging/linux/flatpak/*.in packaging/macos/*.in packaging/windows/*.in; do
|
||||
rel="${t#packaging/}"; rel="${rel%.in}"; rel="${rel//\{app_id\}/$BRAND_APP_ID}"
|
||||
brand_render "$t" "$OUT/$rel"
|
||||
done
|
||||
echo "OUT=$OUT" >>"$GITHUB_ENV"
|
||||
echo "APP_ID=$BRAND_APP_ID" >>"$GITHUB_ENV"
|
||||
echo "APP_BINARY=$BRAND_BINARY" >>"$GITHUB_ENV"
|
||||
- name: XML, desktop entry, AppStream, Flatpak manifest
|
||||
run: |
|
||||
xmllint --noout packaging/windows/lightcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml*
|
||||
xmllint --noout "$OUT"/windows/app.wxs "$OUT"/macos/*.plist "$OUT"/linux/*.xml packaging/macos/entitlements.plist
|
||||
# Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon.
|
||||
if grep -oE '<Icon Id="[^"]*"' packaging/windows/lightcraft.wxs | grep -vE '\.(ico|exe)"$'; then
|
||||
if grep -oE '<Icon Id="[^"]*"' "$OUT/windows/app.wxs" | grep -vE '\.(ico|exe)"$'; then
|
||||
echo "::error::Icon Id must end in .ico or .exe"; exit 1
|
||||
fi
|
||||
desktop-file-validate packaging/linux/ai.storyteller.lightcraft.desktop
|
||||
sed -e 's/@VERSION@/0.0.0/' -e 's/@DATE@/2026-01-01/' packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in >"$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
|
||||
appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.lightcraft.metainfo.xml"
|
||||
desktop-file-validate "$OUT/linux/$APP_ID.desktop"
|
||||
appstreamcli validate --no-net --explain "$OUT/linux/$APP_ID.metainfo.xml"
|
||||
python3 - <<'EOF'
|
||||
import yaml
|
||||
src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.lightcraft.yml"))
|
||||
bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml"))
|
||||
import os, yaml
|
||||
out, app_id, binary = os.environ["OUT"], os.environ["APP_ID"], os.environ["APP_BINARY"]
|
||||
src = yaml.safe_load(open(f"{out}/linux/flatpak/{app_id}.yml"))
|
||||
bundle = yaml.safe_load(open(f"{out}/linux/flatpak/{app_id}.bundle.yml"))
|
||||
for m in (src, bundle):
|
||||
assert m["id"] == "ai.storyteller.lightcraft" and m["command"] == "lightcraft", m["id"]
|
||||
assert m["id"] == app_id and m["command"] == binary, m["id"]
|
||||
for key in ("runtime", "runtime-version", "sdk", "finish-args"):
|
||||
assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}"
|
||||
print("Flatpak manifests ok")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Release pipeline: every push to `release` builds platform artifacts (signed where credentials
|
||||
# are configured) and creates or updates the draft GitHub Release "LightCraft v<version>".
|
||||
# are configured) and creates or updates the draft GitHub Release "<display_name> v<version>".
|
||||
# See docs/releasing.md.
|
||||
#
|
||||
# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment
|
||||
@@ -31,7 +31,7 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
RUST_BACKTRACE: 1
|
||||
LIGHTCRAFT_BUILD_SHA: ${{ github.sha }}
|
||||
BUILD_SHA: ${{ github.sha }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
|
||||
jobs:
|
||||
@@ -43,6 +43,11 @@ jobs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
date: ${{ steps.v.outputs.date }}
|
||||
prerelease: ${{ steps.v.outputs.prerelease }}
|
||||
# brand.toml (the product name lives only there)
|
||||
display_name: ${{ steps.v.outputs.display_name }}
|
||||
binary: ${{ steps.v.outputs.binary }}
|
||||
cli_binary: ${{ steps.v.outputs.cli_binary }}
|
||||
app_id: ${{ steps.v.outputs.app_id }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- id: v
|
||||
@@ -60,8 +65,12 @@ jobs:
|
||||
echo "version=$v"
|
||||
echo "date=$(date -u +%Y-%m-%d)"
|
||||
echo "prerelease=$pre"
|
||||
echo "display_name=$BRAND_DISPLAY_NAME"
|
||||
echo "binary=$BRAND_BINARY"
|
||||
echo "cli_binary=$BRAND_CLI_BINARY"
|
||||
echo "app_id=$BRAND_APP_ID"
|
||||
} >>"$GITHUB_OUTPUT"
|
||||
echo "### LightCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
echo "### $BRAND_DISPLAY_NAME v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY"
|
||||
|
||||
macos:
|
||||
name: macOS universal
|
||||
@@ -69,8 +78,8 @@ jobs:
|
||||
runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend)
|
||||
environment: release
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
PACKAGE_VERSION: ${{ needs.version.outputs.version }}
|
||||
BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
@@ -87,7 +96,7 @@ jobs:
|
||||
key: release-macos-universal
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
|
||||
run: cargo xtask version set "$PACKAGE_VERSION"
|
||||
- name: Import signing certificate
|
||||
env:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
@@ -127,8 +136,8 @@ jobs:
|
||||
# .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware.
|
||||
- { arch: arm64, target: aarch64-pc-windows-msvc }
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
PACKAGE_VERSION: ${{ needs.version.outputs.version }}
|
||||
BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
@@ -146,7 +155,7 @@ jobs:
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
shell: bash
|
||||
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
|
||||
run: cargo xtask version set "$PACKAGE_VERSION"
|
||||
- name: Install WiX
|
||||
shell: pwsh
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
@@ -183,8 +192,8 @@ jobs:
|
||||
- { arch: x86_64, runner: ubuntu-22.04, deb: amd64 }
|
||||
- { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 }
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
PACKAGE_VERSION: ${{ needs.version.outputs.version }}
|
||||
BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -209,7 +218,7 @@ jobs:
|
||||
key: release-linux-${{ matrix.arch }}
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
|
||||
run: cargo xtask version set "$PACKAGE_VERSION"
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
@@ -218,9 +227,11 @@ jobs:
|
||||
- name: Check the packages
|
||||
run: |
|
||||
dpkg-deb --info dist/release/*.deb
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/lightcraft$|metainfo|applications'
|
||||
ldd target/release/lightcraft
|
||||
python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.lightcraft.yml')); assert m['id'] == 'ai.storyteller.lightcraft'"
|
||||
. packaging/env.sh
|
||||
dpkg-deb --contents dist/release/*.deb | grep -E "usr/bin/$BRAND_BINARY\$|metainfo|applications"
|
||||
ldd target/release/app
|
||||
cargo xtask package --render-only
|
||||
python3 -c "import yaml, os; a = os.environ['BRAND_APP_ID']; m = yaml.safe_load(open(f'target/package/linux/flatpak/{a}.yml')); assert m['id'] == a"
|
||||
for appimage in dist/release/*.AppImage; do
|
||||
chmod +x "$appimage"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version
|
||||
@@ -247,8 +258,8 @@ jobs:
|
||||
- { arch: x86_64, runner: ubuntu-24.04 }
|
||||
- { arch: aarch64, runner: ubuntu-24.04-arm }
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
PACKAGE_VERSION: ${{ needs.version.outputs.version }}
|
||||
BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
@@ -264,7 +275,7 @@ jobs:
|
||||
# Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs.
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
- name: Build the Flatpak bundle
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/lightcraft-$LIGHTCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
run: packaging/linux/flatpak-bundle.sh "dist/linux/${{ needs.version.outputs.binary }}-$PACKAGE_VERSION-linux-${{ matrix.arch }}.tar.gz"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-flatpak-${{ matrix.arch }}
|
||||
@@ -279,8 +290,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 180
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
PACKAGE_VERSION: ${{ needs.version.outputs.version }}
|
||||
BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input; required for releases): see craftrules standards/fonts.md.
|
||||
@@ -298,7 +309,7 @@ jobs:
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Passed into the VM by name; never templated into the script below.
|
||||
envs: "LIGHTCRAFT_VERSION LIGHTCRAFT_BUILD_DATE LIGHTCRAFT_BUILD_SHA INPUT_VERSION"
|
||||
envs: "PACKAGE_VERSION BUILD_DATE BUILD_SHA INPUT_VERSION"
|
||||
# The FreeBSD CI packages, plus bash for the packaging script.
|
||||
prepare: |
|
||||
pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
@@ -309,13 +320,14 @@ jobs:
|
||||
git config --global --add safe.directory "$PWD"
|
||||
# Build outside the checkout so only dist/ is copied back out of the VM (copying the
|
||||
# target dir back fails on large builds). 4 jobs: more runs the VM out of memory.
|
||||
export CARGO_TARGET_DIR=/var/tmp/lightcraft-target CARGO_BUILD_JOBS=4
|
||||
if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$LIGHTCRAFT_VERSION"; fi
|
||||
export CARGO_TARGET_DIR=/var/tmp/app-target CARGO_BUILD_JOBS=4
|
||||
if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$PACKAGE_VERSION"; fi
|
||||
# craft-fonts was checked out next to the sources and synced into the VM with them.
|
||||
export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1
|
||||
bash packaging/freebsd/package.sh
|
||||
tar -tzf dist/release/lightcraft-*-freebsd-x86_64.tar.gz \
|
||||
| grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$|/share/doc/lightcraft/LICENSE-MIT$'
|
||||
. packaging/env.sh
|
||||
tar -tzf "dist/release/$BRAND_BINARY-"*-freebsd-x86_64.tar.gz \
|
||||
| grep -E "/bin/$BRAND_BINARY\$|/bin/$BRAND_CLI_BINARY\$|/share/applications/$BRAND_APP_ID\.desktop\$|/share/doc/$BRAND_BINARY/LICENSE\$"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-freebsd-x86_64
|
||||
@@ -327,8 +339,8 @@ jobs:
|
||||
needs: version
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }}
|
||||
LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
PACKAGE_VERSION: ${{ needs.version.outputs.version }}
|
||||
BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
WASM_BINDGEN_VERSION: 0.2.129
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -353,7 +365,7 @@ jobs:
|
||||
wasm-bindgen --version
|
||||
- name: Apply version override
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$LIGHTCRAFT_VERSION"
|
||||
run: cargo xtask version set "$PACKAGE_VERSION"
|
||||
- name: Build and zip
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
@@ -391,7 +403,7 @@ jobs:
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
tag="v$VERSION"
|
||||
title="LightCraft v$VERSION"
|
||||
title="${{ needs.version.outputs.display_name }} v$VERSION"
|
||||
pre=()
|
||||
if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi
|
||||
if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
run: ./packaging/windows/package.ps1 -Arch arm64
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: lightcraft-windows-arm64
|
||||
name: app-windows-arm64
|
||||
path: dist/arm64/*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
@@ -64,19 +64,24 @@ jobs:
|
||||
runs-on: windows-11-arm
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
# for brand.toml and packaging/windows/brand.ps1 (the product name lives only there)
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: lightcraft-windows-arm64
|
||||
name: app-windows-arm64
|
||||
path: dist
|
||||
- name: Install the MSI, check it, run it, uninstall it
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. ./packaging/windows/brand.ps1
|
||||
$appExe = "$($Brand.binary).exe"
|
||||
$cliExe = "$($Brand.cli_binary).exe"
|
||||
$msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" }
|
||||
$dir = Join-Path $env:ProgramFiles 'LightCraft'
|
||||
foreach ($exe in 'lightcraft.exe', 'lightcraft-cli.exe') {
|
||||
$dir = Join-Path $env:ProgramFiles $Brand.display_name
|
||||
foreach ($exe in $appExe, $cliExe) {
|
||||
$path = Join-Path $dir $exe
|
||||
if (-not (Test-Path $path)) { throw "$exe not installed in $dir" }
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
@@ -85,11 +90,11 @@ jobs:
|
||||
Write-Output "ok $path (ARM64)"
|
||||
}
|
||||
# The installed CLI runs natively on ARM64.
|
||||
$cli = Join-Path $dir 'lightcraft-cli.exe'
|
||||
$cli = Join-Path $dir $cliExe
|
||||
$out = & $cli --version 2>&1 | Out-String
|
||||
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "lightcraft-cli --version exited $LASTEXITCODE" }
|
||||
Write-Output "ok lightcraft-cli --version: $($out.Trim())"
|
||||
if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "$cliExe --version exited $LASTEXITCODE" }
|
||||
Write-Output "ok $cliExe --version: $($out.Trim())"
|
||||
$p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" }
|
||||
if (Test-Path (Join-Path $dir 'lightcraft.exe')) { throw 'uninstall left lightcraft.exe behind' }
|
||||
if (Test-Path (Join-Path $dir $appExe)) { throw "uninstall left $appExe behind" }
|
||||
Write-Output 'ok uninstalled'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
*~
|
||||
plan/
|
||||
# plan/ is committed; only private observation notes about the reference app stay local
|
||||
/plan/observations/
|
||||
/target
|
||||
/target-*/
|
||||
/dist
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
# LightCraft — instructions for agents
|
||||
# Instructions for agents
|
||||
|
||||
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../pdfcraft` (Acrobat), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
|
||||
The app is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. It is a fork of an upstream project (see `NOTICE`; merge procedure in `docs/upstream-merge.md`).
|
||||
|
||||
## The name lives in `brand.toml` only
|
||||
- The product name, binary names, app id, settings folder, env-var prefix, MCP server id, URL scheme and file
|
||||
extensions are defined once, in [`brand.toml`](brand.toml), and reach code through the `dac-brand` crate. Never write
|
||||
the product name (or a legacy upstream name) in code, UI strings, docs or tests: say "the app" in prose, use `{app}`
|
||||
(display name), `{cli}` and `{env}` in i18n catalogs, and `dac_brand::…` constants in code.
|
||||
- `cargo xtask brand check` (part of `cargo xtask ci`) fails if the name appears anywhere else. Allowed exceptions:
|
||||
`brand.toml`, `[legacy]` handling, `NOTICE`, `LICENSE`, `assets/ATTRIBUTION.md`, `docs/upstream-merge.md`.
|
||||
- Docs that genuinely need the name are written as `X.md.in` templates with `{{app}}`, `{{binary}}`, `{{cli_binary}}`,
|
||||
`{{env_prefix}}`, `{{app_id}}`, `{{settings_dir}}`, rendered by `cargo xtask docs`; otherwise docs write `<app>`,
|
||||
`<binary>`, `<cli>`, `<PREFIX>`, `<settings_dir>` for the `brand.toml` values.
|
||||
- Crates are `dac-*`; the packages with binaries are `dac-app` (desktop), `dac-cli` and `dac-web`. Inside `target/` the
|
||||
binaries are always `app` and `app-cli`; `cargo xtask run`, `cargo xtask package` and `cargo xtask install` give them
|
||||
the brand names.
|
||||
- Environment variables are `<PREFIX>_X`, where `PREFIX` is `env_prefix` from `brand.toml` (e.g. `<PREFIX>_LOG`,
|
||||
`<PREFIX>_GPU`, `<PREFIX>_PROFILE`); the legacy upstream prefix in `[legacy]` is still read.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
|
||||
@@ -13,7 +29,7 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
|
||||
4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
|
||||
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
`plan/` is gitignored (local-only).
|
||||
`plan/observations/` (Adobe-observation notes) is gitignored (local-only).
|
||||
|
||||
**Merging agent branches:** merge one branch, run `cargo xtask ci`, fix, commit — then the next. Two individually green
|
||||
branches can still break each other (e.g. a new struct field vs. a new constructor).
|
||||
@@ -23,7 +39,7 @@ branches can still break each other (e.g. a new struct field vs. a new construct
|
||||
and often so a crash loses minutes, not hours.
|
||||
|
||||
## Never crash (outranks feature work)
|
||||
People trust LightCraft with their photo libraries and edits; a crash loses their work. A malformed raw/JPEG/XMP, a bad
|
||||
People trust the app with their photo libraries and edits; a crash loses their work. A malformed raw/JPEG/XMP, a bad
|
||||
command, control or MCP argument, a corrupt catalog or settings file, or a full disk must produce an error the user (or
|
||||
agent) can act on, never a panic. Don't ship a feature by adding a panic path, and fix a crash before building on top
|
||||
of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
@@ -59,6 +75,19 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
|
||||
- **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment.
|
||||
|
||||
## Clean-room sources (in addition to the non-negotiables)
|
||||
- darktable and Ansel may be used **as running applications, manuals and prose design notes only**.
|
||||
- Their source trees in `../darktable` and `../ansel` are **off-limits to agents**: never open, grep, read or copy
|
||||
them. The same goes for GPL data tables wherever they appear: `noiseprofiles.json`, `wb_presets.json`,
|
||||
`colormatrices.c`, basecurves.
|
||||
- Camera matrices are **never harvested from Adobe-converted DNGs**: check the DNG's `Software` tag (Adobe DNG
|
||||
Converter, Lightroom, Camera Raw) and reject those. Camera-native DNGs (written by the camera itself) are fine.
|
||||
- Every PR that adds an algorithm has a **"Sources:"** line naming what it was built from: a paper, a prose spec, or
|
||||
"own design".
|
||||
- No GPL/LGPL/AGPL/MPL dependency may enter the build; `cargo deny` enforces the licence allow-list.
|
||||
- Licence: the workspace is MIT (`LICENSE`, `license = "MIT"`); `crates/segment` and `crates/fetch` stay Apache-2.0
|
||||
only, with their own `LICENSE-APACHE`. Keep the upstream notices in `LICENSE` and `NOTICE`.
|
||||
|
||||
## Assets: icons, images, fonts (ABSOLUTE RULE — never violate)
|
||||
- **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not.
|
||||
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
|
||||
@@ -68,12 +97,13 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
text, because nearly every OFL Chinese face derives from it. Use it unmodified under its OFL; this does not open the
|
||||
door to Source Han under Adobe's name or to any other Adobe asset.
|
||||
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
|
||||
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
|
||||
- The placeholder brand art is `brand/icon.svg` and `brand/logo.svg` (original work, MIT); every generated icon is
|
||||
derived from `icon_svg` in `brand.toml`.
|
||||
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
|
||||
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo.** Don't commit
|
||||
font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft
|
||||
font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. The app
|
||||
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
|
||||
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p dac-app` (or any cargo/xtask command). `crates/engine/build.rs`
|
||||
then `CRAFT_FONTS_DIR=../craft-fonts cargo xtask run` (or any cargo/xtask command). `crates/engine/build.rs`
|
||||
embeds the manifest's fonts as `dac_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web`
|
||||
ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI
|
||||
(`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset,
|
||||
@@ -90,17 +120,17 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
- When in doubt about an asset's licence: don't use it.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p dac-app -- --control 7980` opens the desktop app with the JSON-lines control server (see `docs/control-protocol.md`).
|
||||
- `cargo xtask run -- --control 7980` (or `cargo run --release -p dac-app -- --control 7980`) opens the desktop app with the JSON-lines control server (see `docs/control-protocol.md`).
|
||||
- **Never send OS-level synthetic input** (osascript/System Events keystrokes or clicks, `cliclick`, accessibility
|
||||
automation): it goes to whatever window is frontmost — the user's terminal or other apps. Drive LightCraft only
|
||||
automation): it goes to whatever window is frontmost — the user's terminal or other apps. Drive the app only
|
||||
through its control channel (`ui.key`, `ui.pointer`, `ui.clickWidget`, `ui.menu.invoke`) or headless snapshots.
|
||||
- For UI work, **look at the result**: drive via the control channel and take `ui.screenshot`, compare with `plan/lightroom/screenshots/`.
|
||||
- **Unattended (display asleep/locked, CI, nightly runs): prefer headless snapshots** — no window needed:
|
||||
`lightcraft-cli snapshot --demo --script tour.jsonl -o out.png --size 1600x1000` (control-protocol requests,
|
||||
`cargo run --release -p dac-cli -- snapshot --demo --script tour.jsonl -o out.png --size 1600x1000` (control-protocol requests,
|
||||
one per line; see `docs/control-protocol.md` → Headless rendering). In a running app use
|
||||
`ui.screenshot {"path": …, "headless": true}`; windowed screenshots fall back to headless after 2 s.
|
||||
- MCP: `lightcraft-cli mcp` (headless, `--demo` for the procedural library) or `lightcraft-cli mcp --connect` (drives
|
||||
the running app). See `docs/mcp.md`. Quick non-UI checks: `lightcraft-cli render in.jpg -o out.jpg --set light.exposure=1`.
|
||||
- MCP: `cargo run -p dac-cli -- mcp` (headless, `--demo` for the procedural library) or `… mcp --connect` (drives
|
||||
the running app). See `docs/mcp.md`. Quick non-UI checks: `cargo run -p dac-cli -- render in.jpg -o out.jpg --set light.exposure=1`.
|
||||
- Export goes through `dac_engine::export` (one encoder for app, CLI, MCP and web); UI-only commands live in
|
||||
`crates/ui-egui/src/menus.rs`.
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
@@ -115,15 +145,15 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
(`docs/showcase/run.py file.jsonl`), take `ui.screenshot`, and look at it. Check `ui.inspect` → `perf`.
|
||||
- **Benchmarks:** `cargo xtask bench` (24 MP raw from corpus; CPU and GPU columns) appends to `target/bench/history.jsonl`
|
||||
and flags CPU-time regressions > 20 % vs the previous run (`--strict` to fail). Run it before and after perf work.
|
||||
- **Measure, don't guess**: `LIGHTCRAFT_PROFILE=1` prints per-stage pipeline timings to stderr; time CLI renders
|
||||
- **Measure, don't guess**: `<PREFIX>_PROFILE=1` (`PREFIX` = `env_prefix` from `brand.toml`) prints per-stage pipeline timings to stderr; time CLI renders
|
||||
with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe
|
||||
≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG.
|
||||
- Never commit media: test images are procedural (`dac-scenes`) or CC0 downloads in the gitignored `corpus/`.
|
||||
|
||||
## Map of the code
|
||||
`geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine`
|
||||
→ `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` +
|
||||
→ `ui-egui`, `mcp` (L5) → apps `dac-app` (desktop, binary `app`), `dac-cli` (render/commands/MCP, binary `app-cli`), `dac-web`; `brand` (`dac-brand`) carries `brand.toml` into code. `scenes` generates demo
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`, `brand check`, `docs`, `run`, `package`, `install`). `flake.nix` +
|
||||
`nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the
|
||||
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not
|
||||
in CI: it may lag behind the workspace; see README → Quick start.
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
# LightCraft — instructions for agents
|
||||
# Instructions for agents
|
||||
|
||||
LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. Sibling of `../pdfcraft` (Acrobat), `../photocraft` (Photoshop), `../vectorcraft` (Illustrator) and `../filmcraft` (Premiere), with the same conventions.
|
||||
The app is a clean-room, open-source, pure-Rust photo library + non-destructive raw developer targeting Adobe Lightroom parity (and beyond). Native on macOS, Windows, Linux; web via WASM. It is a fork of an upstream project (see `NOTICE`; merge procedure in `docs/upstream-merge.md`).
|
||||
|
||||
## The name lives in `brand.toml` only
|
||||
- The product name, binary names, app id, settings folder, env-var prefix, MCP server id, URL scheme and file
|
||||
extensions are defined once, in [`brand.toml`](brand.toml), and reach code through the `dac-brand` crate. Never write
|
||||
the product name (or a legacy upstream name) in code, UI strings, docs or tests: say "the app" in prose, use `{app}`
|
||||
(display name), `{cli}` and `{env}` in i18n catalogs, and `dac_brand::…` constants in code.
|
||||
- `cargo xtask brand check` (part of `cargo xtask ci`) fails if the name appears anywhere else. Allowed exceptions:
|
||||
`brand.toml`, `[legacy]` handling, `NOTICE`, `LICENSE`, `assets/ATTRIBUTION.md`, `docs/upstream-merge.md`.
|
||||
- Docs that genuinely need the name are written as `X.md.in` templates with `{{app}}`, `{{binary}}`, `{{cli_binary}}`,
|
||||
`{{env_prefix}}`, `{{app_id}}`, `{{settings_dir}}`, rendered by `cargo xtask docs`; otherwise docs write `<app>`,
|
||||
`<binary>`, `<cli>`, `<PREFIX>`, `<settings_dir>` for the `brand.toml` values.
|
||||
- Crates are `dac-*`; the packages with binaries are `dac-app` (desktop), `dac-cli` and `dac-web`. Inside `target/` the
|
||||
binaries are always `app` and `app-cli`; `cargo xtask run`, `cargo xtask package` and `cargo xtask install` give them
|
||||
the brand names.
|
||||
- Environment variables are `<PREFIX>_X`, where `PREFIX` is `env_prefix` from `brand.toml` (e.g. `<PREFIX>_LOG`,
|
||||
`<PREFIX>_GPU`, `<PREFIX>_PROFILE`); the legacy upstream prefix in `[legacy]` is still read.
|
||||
|
||||
## Start every session here
|
||||
1. Read `plan/STATUS.md` (current milestone, next unchecked task, blockers).
|
||||
@@ -13,7 +29,7 @@ LightCraft is a clean-room, open-source, pure-Rust photo library + non-destructi
|
||||
4. **Pick work** from [`docs/parity.md`](docs/parity.md) → *Top gaps* (ordered by user impact; the tracker has one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap list in the same commit, and the *Where we stand* / *Where we're going* sections of `ROADMAP.md` when a listed gap closes; `cargo xtask parity` (part of `ci`) checks that every `cmd:`/`ctl:` id and path the tracker cites still exists, and `cargo xtask parity --write` refreshes its summary.
|
||||
5. Follow the autonomous operation protocol (`plan/execution-plan.md` §7). Don't stop to ask unless §7 lists the decision as the user's.
|
||||
|
||||
`plan/` is gitignored (local-only).
|
||||
`plan/observations/` (Adobe-observation notes) is gitignored (local-only).
|
||||
|
||||
**Merging agent branches:** merge one branch, run `cargo xtask ci`, fix, commit — then the next. Two individually green
|
||||
branches can still break each other (e.g. a new struct field vs. a new constructor).
|
||||
@@ -23,7 +39,7 @@ branches can still break each other (e.g. a new struct field vs. a new construct
|
||||
and often so a crash loses minutes, not hours.
|
||||
|
||||
## Never crash (outranks feature work)
|
||||
People trust LightCraft with their photo libraries and edits; a crash loses their work. A malformed raw/JPEG/XMP, a bad
|
||||
People trust the app with their photo libraries and edits; a crash loses their work. A malformed raw/JPEG/XMP, a bad
|
||||
command, control or MCP argument, a corrupt catalog or settings file, or a full disk must produce an error the user (or
|
||||
agent) can act on, never a panic. Don't ship a feature by adding a panic path, and fix a crash before building on top
|
||||
of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
@@ -59,6 +75,19 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
|
||||
- **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment.
|
||||
|
||||
## Clean-room sources (in addition to the non-negotiables)
|
||||
- darktable and Ansel may be used **as running applications, manuals and prose design notes only**.
|
||||
- Their source trees in `../darktable` and `../ansel` are **off-limits to agents**: never open, grep, read or copy
|
||||
them. The same goes for GPL data tables wherever they appear: `noiseprofiles.json`, `wb_presets.json`,
|
||||
`colormatrices.c`, basecurves.
|
||||
- Camera matrices are **never harvested from Adobe-converted DNGs**: check the DNG's `Software` tag (Adobe DNG
|
||||
Converter, Lightroom, Camera Raw) and reject those. Camera-native DNGs (written by the camera itself) are fine.
|
||||
- Every PR that adds an algorithm has a **"Sources:"** line naming what it was built from: a paper, a prose spec, or
|
||||
"own design".
|
||||
- No GPL/LGPL/AGPL/MPL dependency may enter the build; `cargo deny` enforces the licence allow-list.
|
||||
- Licence: the workspace is MIT (`LICENSE`, `license = "MIT"`); `crates/segment` and `crates/fetch` stay Apache-2.0
|
||||
only, with their own `LICENSE-APACHE`. Keep the upstream notices in `LICENSE` and `NOTICE`.
|
||||
|
||||
## Assets: icons, images, fonts (ABSOLUTE RULE — never violate)
|
||||
- **Never use any iconography, image, artwork, font, sound or other asset from Adobe products** (no Lightroom/Creative Cloud icons, no screenshots, no presets/profiles/LUTs, no UI bitmaps — not even as a temporary placeholder or "reference copy"). Observing Adobe's UI to imitate *layout and behaviour* is allowed; copying or tracing its assets is not.
|
||||
- **This includes Adobe's open-licensed assets**: no Source Sans/Serif/Code or Source Han fonts, no Adobe Fonts, no
|
||||
@@ -68,12 +97,13 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
text, because nearly every OFL Chinese face derives from it. Use it unmodified under its OFL; this does not open the
|
||||
door to Source Han under Adobe's name or to any other Adobe asset.
|
||||
- Every asset in the repository must be one of: **our own original work** (e.g. icons drawn in code as vectors, procedurally generated demo photos), **public domain / CC0**, **Creative Commons** (CC-BY / CC-BY-SA with attribution honoured), **OFL** (fonts), or **permissive open-source** (MIT/Apache-2.0/BSD/ISC) — or contributed by a person who created the asset and licenses it openly.
|
||||
- **Exception: `docs/brand/`.** The ArtCraft name, wordmark and logos there are ArtCraft Team trademarks, not open source and not covered by LightCraft's MIT OR Apache-2.0 licence (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`); their terms are in `docs/brand/LICENSE-brand.txt`. Use them only unmodified and never redraw, recolour or derive from them.
|
||||
- The placeholder brand art is `brand/icon.svg` and `brand/logo.svg` (original work, MIT); every generated icon is
|
||||
derived from `icon_svg` in `brand.toml`.
|
||||
- **Every asset must have an entry in `assets/ATTRIBUTION.md`** (path, title, author/creator, source URL or "original work", licence, date added, modifications) and its licence text when required (e.g. `assets/fonts/OFL-*.txt`). Add the entry in the same commit as the asset. Assets without an attribution entry must not be committed.
|
||||
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo.** Don't commit
|
||||
font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. LightCraft
|
||||
font files here (Inter, already in `assets/fonts/`, is the one exception); add new fonts to craft-fonts. The app
|
||||
uses it as the optional build input `CRAFT_FONTS_DIR`: `git clone https://github.com/storytold/craft-fonts ../craft-fonts`
|
||||
then `CRAFT_FONTS_DIR=../craft-fonts cargo run -p dac-app` (or any cargo/xtask command). `crates/engine/build.rs`
|
||||
then `CRAFT_FONTS_DIR=../craft-fonts cargo xtask run` (or any cargo/xtask command). `crates/engine/build.rs`
|
||||
embeds the manifest's fonts as `dac_engine::CRAFT_FONTS` (wasm32: BIZ UDPGothic Regular only); `cargo xtask web`
|
||||
ships Noto Sans CJK SC Regular as a separate asset loaded by the UI before startup. The UI
|
||||
(`theme::font_definitions`) and the export watermark renderer use CJK faces (picked by script) as fallbacks after Inter. Unset,
|
||||
@@ -90,17 +120,17 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
- When in doubt about an asset's licence: don't use it.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p dac-app -- --control 7980` opens the desktop app with the JSON-lines control server (see `docs/control-protocol.md`).
|
||||
- `cargo xtask run -- --control 7980` (or `cargo run --release -p dac-app -- --control 7980`) opens the desktop app with the JSON-lines control server (see `docs/control-protocol.md`).
|
||||
- **Never send OS-level synthetic input** (osascript/System Events keystrokes or clicks, `cliclick`, accessibility
|
||||
automation): it goes to whatever window is frontmost — the user's terminal or other apps. Drive LightCraft only
|
||||
automation): it goes to whatever window is frontmost — the user's terminal or other apps. Drive the app only
|
||||
through its control channel (`ui.key`, `ui.pointer`, `ui.clickWidget`, `ui.menu.invoke`) or headless snapshots.
|
||||
- For UI work, **look at the result**: drive via the control channel and take `ui.screenshot`, compare with `plan/lightroom/screenshots/`.
|
||||
- **Unattended (display asleep/locked, CI, nightly runs): prefer headless snapshots** — no window needed:
|
||||
`lightcraft-cli snapshot --demo --script tour.jsonl -o out.png --size 1600x1000` (control-protocol requests,
|
||||
`cargo run --release -p dac-cli -- snapshot --demo --script tour.jsonl -o out.png --size 1600x1000` (control-protocol requests,
|
||||
one per line; see `docs/control-protocol.md` → Headless rendering). In a running app use
|
||||
`ui.screenshot {"path": …, "headless": true}`; windowed screenshots fall back to headless after 2 s.
|
||||
- MCP: `lightcraft-cli mcp` (headless, `--demo` for the procedural library) or `lightcraft-cli mcp --connect` (drives
|
||||
the running app). See `docs/mcp.md`. Quick non-UI checks: `lightcraft-cli render in.jpg -o out.jpg --set light.exposure=1`.
|
||||
- MCP: `cargo run -p dac-cli -- mcp` (headless, `--demo` for the procedural library) or `… mcp --connect` (drives
|
||||
the running app). See `docs/mcp.md`. Quick non-UI checks: `cargo run -p dac-cli -- render in.jpg -o out.jpg --set light.exposure=1`.
|
||||
- Export goes through `dac_engine::export` (one encoder for app, CLI, MCP and web); UI-only commands live in
|
||||
`crates/ui-egui/src/menus.rs`.
|
||||
- Shell gotcha: `mv`/`cp` are aliased interactive here — use `/bin/mv -f` / `/bin/cp -f`.
|
||||
@@ -115,15 +145,15 @@ of it. Full standard: `../craftrules/standards/never-crash.md`
|
||||
(`docs/showcase/run.py file.jsonl`), take `ui.screenshot`, and look at it. Check `ui.inspect` → `perf`.
|
||||
- **Benchmarks:** `cargo xtask bench` (24 MP raw from corpus; CPU and GPU columns) appends to `target/bench/history.jsonl`
|
||||
and flags CPU-time regressions > 20 % vs the previous run (`--strict` to fail). Run it before and after perf work.
|
||||
- **Measure, don't guess**: `LIGHTCRAFT_PROFILE=1` prints per-stage pipeline timings to stderr; time CLI renders
|
||||
- **Measure, don't guess**: `<PREFIX>_PROFILE=1` (`PREFIX` = `env_prefix` from `brand.toml`) prints per-stage pipeline timings to stderr; time CLI renders
|
||||
with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe
|
||||
≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG.
|
||||
- Never commit media: test images are procedural (`dac-scenes`) or CC0 downloads in the gitignored `corpus/`.
|
||||
|
||||
## Map of the code
|
||||
`geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine`
|
||||
→ `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` +
|
||||
→ `ui-egui`, `mcp` (L5) → apps `dac-app` (desktop, binary `app`), `dac-cli` (render/commands/MCP, binary `app-cli`), `dac-web`; `brand` (`dac-brand`) carries `brand.toml` into code. `scenes` generates demo
|
||||
photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`, `brand check`, `docs`, `run`, `package`, `install`). `flake.nix` +
|
||||
`nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the
|
||||
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not
|
||||
in CI: it may lag behind the workspace; see README → Quick start.
|
||||
|
||||
@@ -1179,6 +1179,7 @@ dependencies = [
|
||||
name = "dac-app"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
"dac-codecs",
|
||||
"dac-engine",
|
||||
"dac-raster",
|
||||
@@ -1202,6 +1203,7 @@ name = "dac-catalog"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"dac-brand",
|
||||
"dac-develop",
|
||||
"dac-meta",
|
||||
"log",
|
||||
@@ -1216,6 +1218,7 @@ dependencies = [
|
||||
name = "dac-cli"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
"dac-codecs",
|
||||
"dac-engine",
|
||||
"dac-mcp",
|
||||
@@ -1224,6 +1227,7 @@ dependencies = [
|
||||
"dac-sysmem",
|
||||
"dac-ui-egui",
|
||||
"dhat",
|
||||
"log",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -1232,6 +1236,7 @@ name = "dac-codecs"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dac-brand",
|
||||
"dac-color",
|
||||
"dac-geom",
|
||||
"dac-heif",
|
||||
@@ -1257,6 +1262,7 @@ dependencies = [
|
||||
name = "dac-color"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
"dac-geom",
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -1266,6 +1272,7 @@ dependencies = [
|
||||
name = "dac-denoise"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
"dac-denoise-core",
|
||||
"dac-raster",
|
||||
"faer-core",
|
||||
@@ -1283,6 +1290,7 @@ dependencies = [
|
||||
name = "dac-denoise-core"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
"dac-raster",
|
||||
"miniz_oxide 0.8.9",
|
||||
"proptest",
|
||||
@@ -1298,6 +1306,7 @@ dependencies = [
|
||||
name = "dac-develop"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
"dac-color",
|
||||
"dac-geom",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -1311,6 +1320,7 @@ name = "dac-engine"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"ab_glyph",
|
||||
"dac-brand",
|
||||
"dac-catalog",
|
||||
"dac-codecs",
|
||||
"dac-color",
|
||||
@@ -1346,6 +1356,7 @@ dependencies = [
|
||||
name = "dac-faces"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
"matrixmultiply",
|
||||
"ndarray",
|
||||
"proptest",
|
||||
@@ -1360,6 +1371,7 @@ dependencies = [
|
||||
name = "dac-fetch"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
"log",
|
||||
"rustls",
|
||||
"rustls-rustcrypto",
|
||||
@@ -1371,6 +1383,7 @@ dependencies = [
|
||||
name = "dac-geom"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
"proptest",
|
||||
"serde",
|
||||
]
|
||||
@@ -1380,6 +1393,7 @@ name = "dac-gpu"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"dac-brand",
|
||||
"dac-color",
|
||||
"dac-denoise",
|
||||
"dac-develop",
|
||||
@@ -1396,6 +1410,7 @@ dependencies = [
|
||||
name = "dac-heif"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
"heic-rs",
|
||||
]
|
||||
|
||||
@@ -1403,6 +1418,7 @@ dependencies = [
|
||||
name = "dac-mcp"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
"dac-codecs",
|
||||
"dac-engine",
|
||||
"dac-raster",
|
||||
@@ -1414,6 +1430,7 @@ dependencies = [
|
||||
name = "dac-merge"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
"dac-codecs",
|
||||
"dac-color",
|
||||
"dac-geom",
|
||||
@@ -1430,6 +1447,7 @@ dependencies = [
|
||||
name = "dac-meta"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
"dac-geom",
|
||||
"dac-tiff",
|
||||
"proptest",
|
||||
@@ -1442,6 +1460,7 @@ dependencies = [
|
||||
name = "dac-pipeline"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
"dac-color",
|
||||
"dac-develop",
|
||||
"dac-geom",
|
||||
@@ -1459,6 +1478,7 @@ dependencies = [
|
||||
name = "dac-preview"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
"dac-raster",
|
||||
"jpeg-encoder",
|
||||
"log",
|
||||
@@ -1471,6 +1491,7 @@ dependencies = [
|
||||
name = "dac-raster"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
"dac-color",
|
||||
"dac-geom",
|
||||
"libc",
|
||||
@@ -1483,6 +1504,7 @@ dependencies = [
|
||||
name = "dac-raw"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
"dac-color",
|
||||
"dac-geom",
|
||||
"dac-meta",
|
||||
@@ -1505,6 +1527,7 @@ dependencies = [
|
||||
name = "dac-scenes"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
"dac-color",
|
||||
"dac-raster",
|
||||
"image",
|
||||
@@ -1518,6 +1541,7 @@ version = "0.4.0"
|
||||
dependencies = [
|
||||
"candle-core",
|
||||
"candle-nn",
|
||||
"dac-brand",
|
||||
"dac-fetch",
|
||||
"log",
|
||||
"serde",
|
||||
@@ -1528,11 +1552,15 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "dac-sysmem"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dac-tiff"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
"proptest",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1542,6 +1570,7 @@ dependencies = [
|
||||
name = "dac-ui-egui"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"dac-brand",
|
||||
"dac-catalog",
|
||||
"dac-codecs",
|
||||
"dac-color",
|
||||
@@ -1567,6 +1596,7 @@ name = "dac-web"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"dac-brand",
|
||||
"dac-codecs",
|
||||
"dac-engine",
|
||||
"dac-preview",
|
||||
|
||||
@@ -5,9 +5,9 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
[workspace.package]
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
license = "MIT"
|
||||
rust-version = "1.90"
|
||||
repository = "https://github.com/storytold/lightcraft"
|
||||
repository = "https://example.invalid/repo"
|
||||
|
||||
[workspace.dependencies]
|
||||
# internal
|
||||
@@ -49,7 +49,7 @@ bytemuck = { version = "1", features = ["derive"] }
|
||||
crc32fast = "1"
|
||||
siphasher = "1"
|
||||
libc = "0.2" # dev-only: process CPU time in benches
|
||||
dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap); never in default builds
|
||||
dhat = "0.3" # opt-in heap profiling (dac-cli --features dhat-heap); never in default builds
|
||||
|
||||
# formats
|
||||
quick-xml = "0.38"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors
|
||||
Copyright (c) the LightCraft contributors
|
||||
Copyright (c) the PhotoCraft contributors
|
||||
Copyright (c) the project contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -1,11 +1,18 @@
|
||||
LightCraft
|
||||
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors.
|
||||
Licensed under the MIT License or the Apache License, Version 2.0, at your option
|
||||
(LICENSE-MIT, LICENSE-APACHE).
|
||||
This software is licensed under the MIT License (LICENSE), except crates/segment and
|
||||
crates/fetch, which are licensed under the Apache License 2.0 only (see LICENSE-APACHE in each
|
||||
of those crate folders).
|
||||
|
||||
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
|
||||
not open source and are not covered by the license above; their terms are in
|
||||
docs/brand/LICENSE-brand.txt.
|
||||
Upstream attribution
|
||||
|
||||
This project is a fork of LightCraft (https://github.com/storytold/lightcraft),
|
||||
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors, licensed under the MIT
|
||||
License or the Apache License, Version 2.0. It also includes code from PhotoCraft,
|
||||
Copyright (c) the PhotoCraft contributors. Code inherited from these projects is used here
|
||||
under the MIT License; the upstream copyright notices are kept in LICENSE and in this file.
|
||||
|
||||
The ArtCraft name, wordmark and logos are trademarks of the ArtCraft Team and are not part of
|
||||
this project. The upstream LightCraft app icon in assets/app-icon/ is covered by its own
|
||||
licence file (assets/app-icon/LICENSE.txt).
|
||||
|
||||
Third-party material
|
||||
|
||||
@@ -20,14 +27,15 @@ crates/segment (dac-segment) ports the SAM 3 model code of Hugging Face Transfor
|
||||
Copyright The HuggingFace Team and Meta Platforms, Inc., licensed under the Apache License 2.0.
|
||||
The port was modified by the LightCraft contributors (2026): translated from Python/PyTorch to
|
||||
Rust on candle and restructured; each ported file says so. Because of this, crates/segment is
|
||||
licensed under the Apache License 2.0 only (LICENSE-APACHE), not under the MIT option.
|
||||
licensed under the Apache License 2.0 only (crates/segment/LICENSE-APACHE), not under MIT.
|
||||
crates/fetch is likewise licensed under the Apache License 2.0 only (crates/fetch/LICENSE-APACHE).
|
||||
|
||||
The SAM 3 weights are not part of LightCraft and are never distributed with it. A user downloads
|
||||
The SAM 3 weights are not part of this software and are never distributed with it. A user downloads
|
||||
the facebook/sam3 checkpoint only when they choose to (in the app, or by hand), under Meta's SAM
|
||||
License (https://github.com/facebookresearch/sam3/blob/main/LICENSE), a separate, non-OSI
|
||||
licence. See docs/ai-masks.md.
|
||||
|
||||
AI denoise weights are not part of LightCraft and are never distributed with it.
|
||||
AI denoise weights are not part of this software and are never distributed with it.
|
||||
The default build offers user-supplied models only. The optional rawnind-model
|
||||
feature offers a separately downloaded RawNIND UtNet2 Bayer checkpoint under
|
||||
GPL-3.0 (https://www.gnu.org/licenses/gpl-3.0.html), after the user accepts its terms.
|
||||
|
||||
@@ -0,0 +1,746 @@
|
||||
# Lightroom Classic clone in pure Rust: feature list, inputs and plan to completion
|
||||
|
||||
*Written 2026-10-10 from a survey of the five folders next to this one: `darktable`, `ansel`, `lightcraft`,
|
||||
`photocraft` and this empty `nonameyet`.*
|
||||
|
||||
**Detailed phase plans:** [Phase 0](PLAN_phase_0.md) · [Phase 1](PLAN_phase_1.md) · [Phase 2](PLAN_phase_2.md) ·
|
||||
[Phase 3](PLAN_phase_3.md) · [Phase 4](PLAN_phase_4.md). Phases 5–6 are described in §6 below.
|
||||
|
||||
**Product name:** not chosen yet, and never hard-coded. It lives in one file, `brand.toml`, and can be changed at any
|
||||
time; see [Phase 0 §0.2](PLAN_phase_0.md#02-the-name-lives-in-one-config-file-brandtoml). "NoNameYet" below is a
|
||||
placeholder.
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
1. **Fork LightCraft; don't start from zero.**
|
||||
- LightCraft is about 174k lines of pure Rust and compiles cleanly (`cargo check --workspace` passes).
|
||||
- It already covers about 80% of Lightroom features by row count (P0 core features 98%). Its own honest estimate
|
||||
is 60–70% of a real day-to-day replacement, and about 45% for a *Lightroom Classic* power user.
|
||||
- Rebuilding that from scratch would cost hundreds of agent-hours and gain nothing.
|
||||
- "From scratch in Rust" still holds: every line of the product stays Rust we wrote. No C/C++ is linked and no
|
||||
foreign code is pasted in.
|
||||
2. **LightCraft is built on the *cloud* Lightroom model.** It has a single window with view modes and a right-hand
|
||||
panel. Classic is a **module** application (Library | Develop | Map | Book | Slideshow | Print | Web) with a
|
||||
**catalog** and Classic panels and keys. The fork's first real job is the Classic shell and a catalog that scales.
|
||||
3. **What remains is mostly quality, data and output modules, not buttons.** In order of pain:
|
||||
- camera colour calibration
|
||||
- lens-profile database
|
||||
- CR3 and compressed ORF/SRW raws
|
||||
- full-resolution zoom
|
||||
- the output modules: Map, Print, Book, Slideshow, Web, publish services and tethering
|
||||
- AI masks and denoise
|
||||
- HDR and video
|
||||
4. **PhotoCraft donates finished parts.** These are a real Remove tool (PatchMatch content-aware fill), a full ICC
|
||||
colour-management engine (printer soft proofing), text shaping (Print, Book, identity plate), pressure-sensitive
|
||||
brushes, a layered PSD writer ("Edit in…" round trips), a WASM plugin sandbox and action recording.
|
||||
5. **Licence: MIT (decided).** darktable and Ansel are GPL-3.0+, so they serve as **behaviour and design references
|
||||
only**: run the apps, read their manuals and prose design notes, never their source code or GPL data tables
|
||||
(section 4). Camera colour, noise and white-balance data are measured by us (Phase 2).
|
||||
|
||||
---
|
||||
|
||||
## 1. What is in this folder
|
||||
|
||||
| Project | Language / licence | Size | What it is | Role in this plan |
|
||||
|---|---|---|---|---|
|
||||
| **lightcraft** | Rust, MIT OR Apache-2.0 | ~174k LoC, 1,117 commits, active (last commit 2026-10-09) | Clean-room Lightroom (cloud-style) clone: egui UI, wgpu compute pipeline, command engine, MCP, WASM build | **Fork base** |
|
||||
| **photocraft** | Rust, MIT OR Apache-2.0 | ~365k LoC, ~1,049 commits | Clean-room Photoshop clone, same conventions as LightCraft (no shared code; crates were copied and have diverged) | **Donor of crates** |
|
||||
| **darktable** | C/C++, GPL-3.0+ | 96 IOP modules | The most complete open-source raw developer: lighttable, darkroom, map, print, slideshow, tethering, Lua, ONNX AI, MCP | **Behaviour and feature reference** (app and manual only, no source: §4) |
|
||||
| **ansel** | C, GPL-3.0+ | darktable fork from May 2022 | darktable re-engineered: incremental pipeline cache, simplified UI, LensSerious lens DB, dependency-free ML raw denoise | **Design reference** (prose notes in `ansel/doc/`, no source) |
|
||||
|
||||
---
|
||||
|
||||
## 2. Complete Lightroom Classic feature list
|
||||
|
||||
Status column `LC`: LightCraft today. ✅ works · 🟡 partial · ⬜ missing · ? not verified.
|
||||
"Src" is where the missing piece can come from: **PC** = PhotoCraft, **DT** = darktable, **AN** = Ansel (both as running app, manual or prose notes only, never source: §4), **new** = write it.
|
||||
|
||||
### 2.1 Application shell and cross-cutting features
|
||||
|
||||
| Feature | LC | Src / notes |
|
||||
|---|---|---|
|
||||
| Module picker (Library, Develop, Map, Book, Slideshow, Print, Web), with configurable hidden modules | ⬜ | new: LightCraft has no module system |
|
||||
| Identity plate (text or graphic, custom fonts) and activity centre/progress | 🟡 | PC `text` for identity plate; LightCraft has an Activity panel |
|
||||
| Panel system: left/right/top/bottom panels, auto hide/show, solo mode, Tab/⇧Tab/F5–F8, T toolbar | ⬜ | new (KEYC-PANELS) |
|
||||
| Screen modes (normal, full screen, full screen with menu bar), lights out (dim/off) | 🟡 | new (KEYC-VIEWS) |
|
||||
| Secondary display window: Grid, Loupe (normal/live/locked), Compare, Survey, Slideshow | 🟡 | second window exists; keys and locked/live modes missing |
|
||||
| Filmstrip with source indicator, filters, thumbnail badges | ✅ | |
|
||||
| Classic keyboard map (all module keys, Ctrl+Alt+1..7 module switches) | 🟡 | new: LightCraft deliberately remaps some keys; add a "Classic keymap" layer |
|
||||
| Keyboard shortcut editor | 🟡 | settings exist; editor is a M16 item |
|
||||
| Preferences: General, Presets, External Editing, File Handling, Interface, Performance, Display, Network | 🟡 | partial in LC |
|
||||
| Catalog settings: backup schedule, preview size and quality, auto-discard of 1:1 previews, XMP auto-write, address lookup, face detection | 🟡 | new pieces |
|
||||
| Undo/redo across modules, history | ✅ | engine op log |
|
||||
| Plugin manager and SDK (Lua in Lightroom Classic) | ⬜ | PC `plugins` (WASM sandbox); see §6.5 |
|
||||
| Help, keyboard cheat-sheet overlay per module (⌘/), What's new | 🟡 | |
|
||||
| Localisation, accessibility | 🟡 | 8 languages in LC |
|
||||
|
||||
### 2.2 Catalog
|
||||
|
||||
| Feature | LC | Src / notes |
|
||||
|---|---|---|
|
||||
| Single-file catalog of hundreds of thousands of photos, crash safe | 🟡 | LC op-log plus snapshot works to about 85k photos; the whole catalog sits in RAM. Needs an on-disk indexed store for over 500k (§6.2) |
|
||||
| Multiple catalogs: open, create, open recent, choose at startup | ? | |
|
||||
| Backup on exit with integrity test and optimise | ⬜ | new |
|
||||
| Export as Catalog (subset, with or without negatives and previews) / Import from another catalog (merge, conflict policy) | ⬜ | new; LC already has a `.lrcat` read-only importer |
|
||||
| Upgrade/migrate from a Lightroom Classic `.lrcat` (ratings, keywords, collections, develop settings, history) | 🟡 | LC has it (pure-Rust SQLite reader); history replay and Adobe-profile mapping are incomplete. mapping comes from published `crs:` descriptions and our own measurements (Phase 4.5) |
|
||||
| Previews: minimal, embedded and sidecar, standard, 1:1; Smart Previews (lossy DNG) for offline editing | 🟡 | smart previews exist in LC |
|
||||
| Missing file and folder detection, relink, "Find missing photos" | 🟡 | relink exists for the catalog import |
|
||||
| Metadata ↔ XMP sync, conflict badges ("metadata changed on disk"), read/save metadata from/to file | 🟡 | XMP read/write exists; conflict UI missing |
|
||||
|
||||
### 2.3 Import
|
||||
|
||||
| Feature | LC | Src / notes |
|
||||
|---|---|---|
|
||||
| Import dialog: source panel (devices, folders), Copy as DNG / Copy / Move / Add | ✅ | |
|
||||
| Grid with checkboxes, "new photos" / destination-folder views, suspected duplicates greyed out | ✅ | |
|
||||
| File handling: build previews, smart previews, don't import duplicates, second copy (backup) to, add to collection | 🟡 | verify "make a second copy" |
|
||||
| File renaming templates (template editor with tokens) | ✅ | |
|
||||
| Apply during import: develop preset, metadata preset, keywords | ✅ | |
|
||||
| Destination: into subfolder, organise by date (formats) | ✅ | |
|
||||
| Import presets | ? | |
|
||||
| Auto Import (watched folder) | ✅ | |
|
||||
| Tethered capture: camera controls bar, shot naming, session, develop preset on capture, live view | ⬜ | new pure-Rust PTP/USB (`nusb`); AN's "studio capture" folder watch is a cheap first step |
|
||||
| Formats: all raws (CR3, compressed ORF/SRW and the long tail), DNG, JPEG, TIFF, PSD, PNG, HEIC/HEIF, AVIF, JXL, video files | 🟡 | see §2.6 Raw |
|
||||
|
||||
### 2.4 Library module
|
||||
|
||||
| Feature | LC | Src / notes |
|
||||
|---|---|---|
|
||||
| Views: Grid (compact/expanded cells, cell styles, badges), Loupe (info overlays 1/2), Compare (select/candidate, linked zoom), Survey, People | 🟡 | People view lacks cluster separators |
|
||||
| Navigator panel with zoom levels (Fit/Fill/1:1/custom) | ? | |
|
||||
| Catalog panel: All Photographs, Quick Collection, Previous Import, Missing photos | 🟡 | Quick Collection ⬜ |
|
||||
| Folders panel: volumes with free space, hierarchy, show parent, add/move/rename/remove folders, synchronise folder, update location, show in Finder | 🟡 | rename/move pending |
|
||||
| Collections: collection, smart collection (full rule editor, nested AND/OR), collection set, target collection (B key), sync with mobile (out of scope) | 🟡 | smart albums ✅; target collection and sets need checking |
|
||||
| Publish Services panel | ⬜ | §2.12 |
|
||||
| Library Filter bar: Text / Attribute / Metadata (columns: date, camera, lens, keyword, label, etc.) / None, filter presets, lock | ✅ | KEYC filter-bar keys partial |
|
||||
| Flags (P/X/U, ` to toggle), auto-advance, star ratings, colour labels and label sets, `[`/`]` rating up/down | 🟡 | `[`/`]` and flag cycling missing |
|
||||
| Stacking: group, unstack, collapse/expand, auto-stack by capture time, move to top of stack | ✅ | |
|
||||
| Virtual copies, set copy as master | ✅ | |
|
||||
| Quick Develop panel (relative ± buttons, presets, crop ratio, treatment) | ? | check; Classic-specific |
|
||||
| Keywording panel: keyword tags, suggestions, keyword sets (Ctrl+Alt+0–9) | 🟡 | keyword sets ⬜ |
|
||||
| Keyword List: hierarchy, synonyms, export flags, import/export keyword lists, filter by keyword | 🟡 | |
|
||||
| Painter tool (spray keywords, labels, flags, ratings, metadata, settings, rotation, target collection) | ⬜ | |
|
||||
| Metadata panel: default, EXIF, IPTC, IPTC Extension, Location, Large Caption, custom; metadata presets; edit many; copy/paste metadata | 🟡 | metadata copy/paste ⬜ |
|
||||
| Comments panel (from shared collections) | ⬜ | low value without cloud; can drop |
|
||||
| Edit capture time (shift, set, use file date), batch | ? | |
|
||||
| Convert to DNG, Find previous process version, Update DNG previews | 🟡 | DNG export exists |
|
||||
| Face detection and People view (named, unnamed, cluster, confirm) | 🟡 | LC faces MVP |
|
||||
| Show in Explorer/Finder, Edit In external editor, Open as Layers / Smart Object (Photoshop round trip) | 🟡 | external editor ✅; PC `psd` writer for layered round trip |
|
||||
| Slideshow (impromptu, ⌘↵) | ✅ | basic |
|
||||
|
||||
### 2.5 Develop module
|
||||
|
||||
| Feature | LC | Src / notes |
|
||||
|---|---|---|
|
||||
| Left panel: Navigator, Presets (groups, favourites, amount), Snapshots, History (named steps, clear), Collections | ✅ | snapshots and versions exist |
|
||||
| Toolbar: Before/After (left-right, top-bottom, split), loupe, grid overlays, soft proofing toggle | ✅ | |
|
||||
| Histogram with clipping indicators (J), RGB readout, interactive drag on histogram | 🟡 | KEY-HISTOGRAM partial |
|
||||
| Tool strip: Crop and Straighten (aspect lock, angle, auto, constrain), Remove/Spot Removal (content-aware, heal, clone; visualize spots), Red Eye/Pet Eye, Masking | 🟡 | content-aware is heal from an auto source: port PC `algo/inpaint.rs` (PatchMatch) and `content_aware.rs` |
|
||||
| Basic: treatment (colour/B&W), profile browser, WB (as shot, auto, presets, eyedropper, temp/tint), exposure, contrast, highlights, shadows, whites, blacks, texture, clarity, dehaze, vibrance, saturation, Auto | ✅ | tone tuned by eye; fidelity suite is §6.3 |
|
||||
| Tone Curve: parametric (regions, split points), point curve (RGB, R, G, B), targeted adjustment, refine saturation | ✅ | curves apply after display encoding: check against LR behaviour |
|
||||
| HSL/Color: hue/saturation/luminance per 8 bands, targeted adjustment; B&W mix | ✅ | |
|
||||
| Point Color (sample, range, ranges visualisation) | ✅ | |
|
||||
| Color Grading (3 wheels plus global, blending, balance) / legacy split toning | ✅ | |
|
||||
| Detail: sharpening (amount, radius, detail, masking with Alt preview), NR (luminance, detail, contrast; colour, detail, smoothness) | ✅ | profiled NR with our own measured noise profiles (Phase 2.6); DT's profiled denoise is the behaviour benchmark |
|
||||
| Enhance: AI Denoise, Raw Details, Super Resolution (outputs a DNG) | 🟡 | Bayer AI denoise only; design from AN's prose note on `rawdenoiseai` (deterministic U-Net, no runtime) |
|
||||
| Lens Corrections: profile (auto lens lookup, custom), remove CA, defringe (purple/green with eyedroppers), manual distortion and vignetting | 🟡 | **No lens-profile DB**: §6.3 |
|
||||
| Transform: Upright (Auto, Level, Vertical, Full, Guided), manual transforms | ✅ | |
|
||||
| Effects: post-crop vignette (styles), grain | ✅ | |
|
||||
| Calibration: process version, shadow tint, RGB primaries hue/saturation | ✅ | process versions are LC's own |
|
||||
| Profiles: Adobe-style looks, camera-matching profiles (Camera Standard, Portrait…), creative profiles with amount, LUT profiles, legacy | 🟡 | own looks ✅; **camera-matching** and **measured camera colour** ⬜ |
|
||||
| Masking: brush (auto mask, flow, density, pressure), linear, radial, luminance range, colour range, depth range, Select Subject, Select Sky, Background, Objects, People (face, eyes, lips, hair, body, clothes), Landscape (water, vegetation, mountains…), add/subtract/intersect, invert, mask overlay modes, update AI masks, adaptive presets | 🟡 | AI masks are heuristics; SAM 3 path exists without hosted weights; pressure brushes from PC `paint`/`tablet` |
|
||||
| Lens Blur (AI depth, bokeh shapes, focal range) | ⬜ | needs a depth model |
|
||||
| Generative Remove | 🚫 | Adobe cloud; local PatchMatch instead |
|
||||
| Copy/paste/sync settings (selective), Auto Sync, Previous, Reset | ✅ | |
|
||||
| Presets: create from groups, import (`.xmp`, `.lrtemplate`), amount slider, adaptive presets | ✅ | adaptive ⬜ |
|
||||
| Reference View (side by side with a reference photo) | ✅ | |
|
||||
| Soft proofing: profile, rendering intent (perceptual/relative), Simulate Paper & Ink, destination gamut warning, create proof copy | 🟡 | **use PC `cms`** (ICC v2/v4, LUT profiles, BPC, gamut check) |
|
||||
| HDR editing (extended range, HDR display, visualise, SDR preview, HDR export) | ⬜ | §6.6 |
|
||||
| Photo Merge: HDR (deghost, auto align, auto settings), Panorama (projections, boundary warp, fill edges), HDR Panorama | ✅ | |
|
||||
| Develop view options (info overlay, show messages) | ⬜ | small |
|
||||
| Zoom: true 1:1 and higher from the original pixels | 🟡 | 2,560 px preview cap; region renderer exists for loupe only (§6.2) |
|
||||
|
||||
### 2.6 Raw and camera support
|
||||
|
||||
| Feature | LC | Src / notes |
|
||||
|---|---|---|
|
||||
| DNG all variants incl. 1.7 JXL, opcodes, semantic masks | ✅ | |
|
||||
| CR2, NEF, ARW, RAF incl. X-Trans compressed, RW2/RWL, PEF, uncompressed ORF/SRW | ✅ | |
|
||||
| **CR3** (all CRX variants, sRAW/mRAW), **compressed ORF**, **compressed SRW**, NEF lossy-after-split | 🟡/⬜ | LC: only M50/R100/R8 verified. Most common missing format |
|
||||
| Long tail: 3FR, IIQ, MOS, ERF, KDC, DCR, MRW, X3F, NRW, etc. | ⬜ | |
|
||||
| Per-camera colour matrices, WB presets, noise profiles, black/white levels, crop masks | 🟡 | **biggest quality gap**; measured by us (Phase 2.1, rules in §4) |
|
||||
| Lens-correction profiles for camera/lens pairs | 🟡 | DNG and RW2 embedded only |
|
||||
|
||||
### 2.7 Map module
|
||||
|
||||
| Feature | LC | Src / notes |
|
||||
|---|---|---|
|
||||
| Map view with styles (road, satellite, terrain, light/dark), zoom, search location | ⬜ | new: slippy-map tile renderer in egui, configurable tile server plus disk cache |
|
||||
| Pins and clusters for geotagged photos, hover previews | ⬜ | |
|
||||
| Drag photos onto the map to geotag | ⬜ | |
|
||||
| Tracklog import (GPX), auto-tag with time offset | 🟡 | GPX geotagging ✅; map display ⬜ |
|
||||
| Saved locations (radius, private flag to strip GPS on export) | ⬜ | |
|
||||
| Location filter bar (visible on map, tagged, untagged) | ⬜ | |
|
||||
| Reverse geocoding (city, state, country fill) | 🚫 in LC | opt-in online service, or offline GeoNames (CC-BY) |
|
||||
|
||||
### 2.8 Book module
|
||||
|
||||
| Feature | LC | Src / notes |
|
||||
|---|---|---|
|
||||
| Book settings: type (Blurb/PDF/JPEG), size, cover, paper, logo page | ⬜ | Blurb upload is proprietary; ship PDF and JPEG |
|
||||
| Auto Layout with presets, page templates (1–4+ photos, text), custom pages | ⬜ | new |
|
||||
| Page and spread editing, page numbers, guides (bleed, safe text, filler text) | ⬜ | |
|
||||
| Cell padding, photo zoom/pan in cell, photo text and page text, text styles (font, size, tracking, baseline, leading, kerning, alignment) | ⬜ | **PC `text`** (parley shaping, CJK) |
|
||||
| Background (graphic, colour, photo), multi-page and spread views | ⬜ | |
|
||||
| Export book to PDF (embedded fonts, ICC, multi-page) and JPEG | ⬜ | needs a real PDF writer (PC's is single page and has no font embedding) |
|
||||
| Saved books (special collection type) | ⬜ | |
|
||||
|
||||
### 2.9 Slideshow module
|
||||
|
||||
| Feature | LC | Src / notes |
|
||||
|---|---|---|
|
||||
| Templates (built-in, user), template browser preview | ⬜ | |
|
||||
| Options: zoom to fill, stroke border, cast shadow | ⬜ | |
|
||||
| Layout guides and margins, overlays (identity plate, rating stars, text overlays with tokens, watermark) | ⬜ | PC `text` |
|
||||
| Backdrop: colour wash, image, background colour | ⬜ | |
|
||||
| Titles: intro/ending screens | ⬜ | |
|
||||
| Music: multiple tracks, sync slides to music, audio balance | ⬜ | pure-Rust decode (`symphonia`) + output (§6.5 notes) |
|
||||
| Playback: duration, fades, random order, repeat, pan and zoom, quality | 🟡 | impromptu basic only |
|
||||
| Export to PDF, JPEG sequence, video (MP4/H.264) | ⬜ | video encode: pure-Rust AV1 (`rav1e`) feasible; H.264 is a risk (§6.6) |
|
||||
| Saved slideshows | ⬜ | |
|
||||
|
||||
### 2.10 Print module
|
||||
|
||||
| Feature | LC | Src / notes |
|
||||
|---|---|---|
|
||||
| Layout styles: Single Image/Contact Sheet, Picture Package, Custom Package | ⬜ | new |
|
||||
| Image settings: zoom to fill, rotate to fit, repeat one photo per page, stroke border | ⬜ | |
|
||||
| Layout: margins, page grid, cell spacing and size, keep square; rulers, guides, page bleed, dimensions | ⬜ | |
|
||||
| Page: background colour, identity plate, watermark, page options (numbers, info, crop marks), photo info, text | ⬜ | PC `text`; PC `print_cmds.rs` has crop/registration marks |
|
||||
| Print job: to printer or JPEG file, draft mode, print resolution, sharpening (low/standard/high, matte/glossy), 16-bit output, colour management (printer ICC, rendering intent, print adjustment brightness/contrast) | ⬜ | **PC `cms`**; OS printing through IPP/CUPS (pure-Rust `ipp`) on Linux and macOS; Windows needs Win32 print API in an isolated `unsafe` crate |
|
||||
| Page setup / printer settings, Print One | ⬜ | |
|
||||
| Templates and saved prints | ⬜ | |
|
||||
|
||||
### 2.11 Web module
|
||||
|
||||
| Feature | LC | Src / notes |
|
||||
|---|---|---|
|
||||
| Layout styles (HTML grid, track/square/etc. galleries) with templates | 🚫 in LC | new: static HTML/CSS/JS templates we write; DT's web-gallery export is a behaviour reference |
|
||||
| Site info, colour palette, appearance, image info, output settings (size, quality, watermark, metadata) | ⬜ | reuse export pipeline |
|
||||
| Upload settings (FTP/SFTP) and Export to folder | ⬜ | pure-Rust SFTP (`russh`); plain FTP optional |
|
||||
| Saved web galleries | ⬜ | |
|
||||
|
||||
### 2.12 Export, publish and sharing
|
||||
|
||||
| Feature | LC | Src / notes |
|
||||
|---|---|---|
|
||||
| Export dialog: location, naming, video, file settings (JPEG/PSD/TIFF/PNG/DNG/AVIF/JXL/HEIF/Original), colour space, bit depth, resize, output sharpening, metadata policy, watermark, post-processing action | ✅ | JXL encode and HDR ⬜; PSD export via PC `psd` |
|
||||
| Export presets, export with previous, background export queue | ✅ | |
|
||||
| Watermark editor (text/graphic, shadows, anchors) | ✅ | text shaping upgrade with PC `text` |
|
||||
| Publish services: Hard Drive, Flickr, Adobe Stock, Facebook, etc.; publish collections, "modified photos to re-publish", comments | ⬜ | new: framework plus Hard Drive first; third-party APIs as plugins |
|
||||
| Email photos | ⬜ | low priority (mailto with attachments or SMTP) |
|
||||
| Post-processing: open in other app, export actions | ? | |
|
||||
|
||||
### 2.13 Video
|
||||
|
||||
| Feature | LC | Src / notes |
|
||||
|---|---|---|
|
||||
| Import and catalog video | ✅ | |
|
||||
| Playback, trim, poster frame, capture frame as JPEG | ⬜ | **riskiest pure-Rust item** (§6.6) |
|
||||
| Quick Develop/presets on video, export video | ⬜ | |
|
||||
|
||||
### 2.14 Immich integration (beyond Lightroom)
|
||||
|
||||
[Immich](https://immich.app) is a self-hosted photo server (AGPL-3.0). We talk to it only over its HTTP API, with a
|
||||
client we write ourselves. Tracker section **IMM**.
|
||||
|
||||
| Feature | Id | Phase |
|
||||
|---|---|---|
|
||||
| Connect to one or more Immich servers (URL + API key in the OS keychain), version check, self-signed TLS trust | IMM-CONNECT | 1 |
|
||||
| Link catalog photos to Immich assets by SHA-1 checksum; "in Immich" badge, filter, smart-collection rule, Open in Immich | IMM-LINK | 1 |
|
||||
| Immich as an Import source (timeline, albums, people, favourites); copy originals or add linked | IMM-IMPORT | 1 |
|
||||
| Shared-originals mode: Immich external library over the same folders, path mapping, no duplicate uploads | IMM-EXTLIB | 1 |
|
||||
| Immich shared links from Web/Slideshow/Publish (expiry, password, download) | IMM-SHARELINK | 3 |
|
||||
| Immich publish service: collections → albums; renders, originals, or both stacked; re-publish; trash-only removal | IMM-PUBLISH | 4 |
|
||||
| Two-way metadata sync: rating, favourite ↔ pick, caption ↔ description, keywords ↔ tags, collections ↔ albums, GPS/time; conflict view | IMM-SYNC | 4 |
|
||||
| Import Immich people and face regions; push names back | IMM-PEOPLE | 4 |
|
||||
| Immich smart (CLIP) and metadata search from the Library filter bar | IMM-SEARCH | 4 |
|
||||
| All Immich actions as commands (CLI, control channel, MCP) | IMM-MCP | 4 |
|
||||
|
||||
### 2.15 Automation (beyond Lightroom)
|
||||
|
||||
| Feature | LC | Notes |
|
||||
|---|---|---|
|
||||
| Every action is a command; CLI; JSON control channel; MCP server; headless UI snapshots | ✅ | Strong differentiator; keep it. DT's `src/mcp` tool list is a good checklist for MCP coverage |
|
||||
| Recordable actions / batch scripts | ⬜ | PC `engine/src/actions_cmds.rs` |
|
||||
|
||||
---
|
||||
|
||||
## 3. What each existing project gives us
|
||||
|
||||
### 3.1 LightCraft (fork base): keep almost everything
|
||||
|
||||
- **Workspace and quality bar.** It has layered crates enforced by `cargo xtask layers` (L0 geom/color/raster through
|
||||
L5 UI). Production code may not panic, and `unsafe` is confined to one crate. CI runs fmt, clippy, tests, layers
|
||||
and wasm, plus a parity tracker with an automatic checker.
|
||||
- **Develop pipeline** (`crates/pipeline`, `crates/develop`, `crates/gpu`):
|
||||
- scene-referred float pipeline in linear Rec.2020, with OkLab/OkLCh colour tools;
|
||||
- per-stage cache;
|
||||
- a wgpu compute twin that matches the CPU within 1/255.
|
||||
- **Raw** (`crates/raw`, 18.5k lines): the most complete pure-Rust clean-room decoder set around. It covers DNG 1.7,
|
||||
CR2, partial CR3, NEF, ARW, RAF X-Trans, every RW2 format and PEF, plus AHD/PPG/X-Trans demosaic and a DNG writer.
|
||||
- **Library and catalog:**
|
||||
- crash-safe op-log catalog;
|
||||
- albums, smart albums, stacks, virtual copies;
|
||||
- filter and search, XMP interop;
|
||||
- `.lrcat` importer, GPX geotagging, faces MVP.
|
||||
- **Edit tools:** every global slider, masking, heal/clone, red eye, Upright, HDR/panorama merge, presets, versions
|
||||
and sync.
|
||||
- **Export:** JPEG, PNG, TIFF, WebP, AVIF and DNG, with naming templates, watermarks and background jobs.
|
||||
- **Automation:** the command engine, CLI, control channel and MCP server.
|
||||
- **Platforms:** desktop builds plus a web build.
|
||||
- **Must change or remove:**
|
||||
- The ArtCraft brand (`docs/brand/` is not open licensed).
|
||||
- Names (`lightcraft-*` crates).
|
||||
- The cloud-style UI shell.
|
||||
- The in-RAM catalog at very large scale.
|
||||
- Rebuild the missing local `plan/` reference folder that the tracker ids point at.
|
||||
- The 45k-line `engine` crate needs splitting before Print, Book and the other new modules go into it.
|
||||
|
||||
### 3.2 PhotoCraft (donor): port these crates with copy-and-rename, as LightCraft already did with `heif`
|
||||
|
||||
| PhotoCraft piece | Gives the clone | Where it lands |
|
||||
|---|---|---|
|
||||
| `algo/src/inpaint.rs`, `content_aware.rs` (PatchMatch, Wexler EM, Poisson blend) | Real content-aware Remove, auto heal source | Develop → Remove |
|
||||
| `algo/src/segment/` (GrabCut, maxflow, SLIC, saliency, sky) | Classical fallback and edge refinement for AI masks | Masking |
|
||||
| `cms` (5k lines, own ICC v2/v4 engine, LUT/CMYK, BPC, gamut check, profile writing) | Printer soft proofing, Print module colour management, gamut warning | Develop soft proof, Print |
|
||||
| `text` (parley shaping, CJK/vertical, craft-fonts) | Print/Book/Slideshow text, identity plate, watermarks | Output modules |
|
||||
| `paint`, `tablet`, `ui-egui/src/stylus.rs` | Pressure/tilt-aware masking brush (macOS and X11; Windows still to write) | Masking brush |
|
||||
| `psd` + `io/src/psd_export.rs` | Layered PSD/PSB export, "Edit In → Open as Layers", PSD import | Export / external edit |
|
||||
| `plugins` (wasmi sandbox) | Plugin SDK for publish services, export filters, metadata providers | §6.5 |
|
||||
| `engine/src/actions_cmds.rs` | Recordable actions / batch automation | Automation |
|
||||
| `codecs` (EXR, deep EXR) | EXR import/export (HDR) | Export |
|
||||
| `vector` | Book/Print layout shapes, mask paths | Output modules |
|
||||
| `engine/src/print_cmds.rs` (minimal PDF writer, crop marks) | Starting point only; replace with a multi-page PDF writer with font embedding | Print/Book |
|
||||
|
||||
Not worth taking: PhotoCraft's `raw` (weaker than LightCraft's), its `gpu` (a compositor, not a develop pipeline) and
|
||||
its `codecs` JXL (it has none).
|
||||
|
||||
### 3.3 darktable and Ansel (behaviour and design reference only)
|
||||
|
||||
The project is MIT (§4), so darktable and Ansel are used **as running applications, user manuals and prose design
|
||||
notes**. Their source code is never opened. File paths that appear below only show *where a feature lives*, so it can
|
||||
be tried in the running app. They are not reading material.
|
||||
|
||||
**Features and design to emulate:**
|
||||
|
||||
- The **output views** exist and work, so they are a functional reference for Map, Print, Slideshow, tethering,
|
||||
web gallery, photo book and publishing. Try them in the app: the map, print, slideshow and tethering views; the
|
||||
print settings, map locations and geotagging panels; and the gallery, LaTeX book and Piwigo export storages.
|
||||
- **Colour-science algorithms** that are published in papers and documented in the darktable and Ansel docs:
|
||||
- colour calibration / CAT with chart fitting (`channelmixerrgb`);
|
||||
- filmic, sigmoid and AgX tone mapping;
|
||||
- diffuse-or-sharpen (PDE-based detail);
|
||||
- tone equalizer;
|
||||
- profiled denoise (Poisson-Gaussian noise model);
|
||||
- highlight reconstruction (Ansel's harmonic version, `ansel/doc/highlights-reconstruction.md`).
|
||||
- **Ansel's engineering notes** (`ansel/doc/*.md`):
|
||||
- incremental pipeline cache that recomputes only downstream stages (5–40× faster);
|
||||
- lazy-loaded SQLite lens DB;
|
||||
- "studio capture" folder-watch tethering;
|
||||
- a deterministic, dependency-free ML raw denoiser (`rawdenoiseai`).
|
||||
- LightCraft already does most of the pipeline-cache part.
|
||||
- **darktable-chart**, as described in the darktable manual: chart shots, thin-plate-spline fitting, ΔE report.
|
||||
This is the method for making our own camera profiles; we implement it from the published description.
|
||||
- **darktable's MCP feature list**, as documented for users: a checklist for agent coverage.
|
||||
|
||||
**Data that is not used.** It is listed here so nobody imports it by accident; only the last row is allowed:
|
||||
|
||||
| Data | Path | Licence | Gap it fills |
|
||||
|---|---|---|---|
|
||||
| ~100 chart-measured camera matrices | `darktable/src/common/colormatrices.c` | GPL-3+ | Camera colour calibration |
|
||||
| Noise profiles, 437 cameras (Poisson-Gaussian per ISO) | `darktable/data/noiseprofiles.json` | GPL-3+ | Profiled denoise, AI denoise sigma maps |
|
||||
| WB presets, 485 models | `darktable/data/wb_presets.json` | GPL-3+ | WB presets per camera |
|
||||
| Per-camera base curves (535 styles) | `darktable/data/styles/`, `src/iop/basecurve.c` | GPL-3+ | "Camera Standard"-like default looks |
|
||||
| Colour checker reference values, illuminants, CIE observer | `src/common/colorchecker.h`, `illuminants.h`, `src/external/cie_colorimetric_tables.c` | GPL-3+ (CIE data itself is public) | Our own chart calibration |
|
||||
| rawspeed `cameras.xml` (matrices, crops, black/white levels, CFA) | submodule **not checked out**; `git submodule update --init` | LGPL-2.1 | Camera coverage and colour |
|
||||
| LibRaw (CR3 and many long-tail decoders) | submodule **not checked out** | LGPL-2.1 / CDDL | CR3, compressed ORF and other decoders |
|
||||
| lensfun lens DB (~1,500 lenses) | not bundled; separate download | **CC-BY-SA 3.0** (data) | **Allowed**: shipped as a separate, attributed CC-BY-SA data file (Phase 2.5); lensfun's LGPL *code* is not read |
|
||||
|
||||
---
|
||||
|
||||
## 4. Licence: MIT (decided 2026-10-10)
|
||||
|
||||
The fork is licensed **MIT**. LightCraft and PhotoCraft are "MIT OR Apache-2.0", and the fork takes the MIT option of
|
||||
that dual licence.
|
||||
|
||||
### 4.1 What MIT means in practice
|
||||
|
||||
- **Upstream copyright stays.** `LICENSE` names the LightCraft and PhotoCraft contributors (for inherited code) and
|
||||
"the project contributors" (for new code). No product name appears in it; the name is in `brand.toml`.
|
||||
- **Two exceptions stay Apache-2.0 only, and that's allowed.**
|
||||
- LightCraft's `crates/segment` ports Hugging Face SAM 3 code that is Apache-2.0, so it can't be relicensed.
|
||||
- `crates/fetch` was split out of it.
|
||||
- Both keep `license = "Apache-2.0"` and their `NOTICE` entries. The app as a whole is MIT with two
|
||||
Apache-2.0-licensed components, which is compatible.
|
||||
- The planned `net` crate (Phase 1.7) is **written fresh under MIT** rather than grown from `fetch`.
|
||||
- **Merging continues both ways.**
|
||||
- Upstream LightCraft changes can be merged at any time, taking the MIT option.
|
||||
- Our changes could go back upstream if wanted.
|
||||
- **Commercial use and app stores are unrestricted.**
|
||||
- **Dependencies:** `cargo deny` allows only MIT, Apache-2.0, BSD-2/3, ISC, Zlib, Unicode-3.0, CC0-1.0 and MIT-0.
|
||||
GPL, LGPL, AGPL, MPL and SSPL are denied in the dependency tree.
|
||||
- **Data files** may be CC-BY or CC-BY-SA if they ship as separate, attributed data files, never compiled into code.
|
||||
This covers the lensfun DB and GeoNames.
|
||||
|
||||
### 4.2 Clean-room rules (inherited from LightCraft, unchanged)
|
||||
|
||||
- **Never read or copy GPL, LGPL or AGPL source:** darktable, Ansel, RawTherapee, ART, LibRaw, rawspeed, lensfun's
|
||||
code, dcraw-derived code, Immich. Agents must not open their `.c`, `.cc`, `.h` or `.rs` files.
|
||||
- **Allowed sources:**
|
||||
- running darktable and Ansel as *applications* and watching their behaviour;
|
||||
- their user manuals and prose design notes (for example `ansel/doc/*.md`), skipping any code excerpts in them;
|
||||
- published papers and format descriptions written in prose.
|
||||
- LightCraft has decided the same for raw formats (2026-10-05).
|
||||
- **No GPL data tables** such as darktable's `noiseprofiles.json`, `wb_presets.json`, `colormatrices.c` or
|
||||
basecurves. The equivalents are measured ourselves (Phase 2.1).
|
||||
- **Adobe:**
|
||||
- no assets, DCP, LCP, presets, profiles or matrices;
|
||||
- never look inside Adobe application bundles;
|
||||
- camera matrices are never harvested from Adobe-converted DNGs. Matrices in camera-native DNGs (written by the
|
||||
camera maker's own software, checked via the `Software` tag) are manufacturer data and may be used.
|
||||
|
||||
### 4.3 Cost of this choice
|
||||
|
||||
Camera colour, noise profiles, white-balance presets and long-tail raw decoders must be **measured or
|
||||
reverse-engineered by us**. That makes Phase 2 larger (110–180 h instead of 80–160 h) and turns it partly into a
|
||||
**data-collection project**: chart shots, flat-field and ISO series, and CC0 raw samples. It needs a community
|
||||
contribution pipeline (Phase 2.2). The upside is a clean, permissively licensed camera and lens database that we own.
|
||||
|
||||
---
|
||||
|
||||
## 5. Target architecture (deltas from LightCraft)
|
||||
|
||||
```
|
||||
L0 geom color raster tiff sysmem fetch heif (LightCraft)
|
||||
L1 raw codecs meta develop denoise faces scenes (LightCraft) + cms (from PhotoCraft)
|
||||
camdb ← NEW: camera matrices, WB presets, noise profiles, crops, levels (data crate)
|
||||
lensdb ← NEW: lens profiles (lensfun data + our own), lookup by EXIF
|
||||
text ← from PhotoCraft (parley) pdf ← NEW: multi-page PDF writer, font embedding, ICC
|
||||
L2 pipeline (LightCraft) + region/tiled full-res renders
|
||||
inpaint ← from PhotoCraft algo (PatchMatch, content-aware, GrabCut refinement)
|
||||
L3 gpu catalog preview merge segment (LightCraft) catalog → indexed on-disk store
|
||||
layout ← NEW: page/cell/template model shared by Print, Book, Slideshow, Web, Contact sheet
|
||||
geo ← NEW: tile fetch/cache, projections, clustering, GPX, saved locations, geocoding
|
||||
tether ← NEW: PTP over USB (nusb) / PTP-IP; folder-watch fallback
|
||||
media ← NEW: audio (slideshow), video decode/encode (highest risk)
|
||||
L4 engine → split: engine-core (commands, session, jobs), engine-library, engine-develop,
|
||||
engine-export, engine-output (print/book/slideshow/web), engine-publish
|
||||
plugins ← from PhotoCraft (wasmi), publish/export SDK
|
||||
L5 ui-egui → Classic shell: Module trait {Library, Develop, Map, Book, Slideshow, Print, Web},
|
||||
panel framework (left/right/top/filmstrip, solo, auto-hide), Classic keymap layer
|
||||
mcp
|
||||
net ← promoted from fetch's pure-Rust HTTPS client: JSON, multipart upload, proxies, custom CAs
|
||||
credentials← OS keychain / encrypted-file secrets immich ← NEW: Immich client, link, publish, sync
|
||||
brand ← NEW (L0): every product name/id, generated from brand.toml
|
||||
Apps: internal binaries `app`, `app-cli` (and optional web build), renamed from brand.toml at packaging time
|
||||
```
|
||||
|
||||
**Upstream strategy.** LightCraft is moving fast: 1,117 commits, many in the last week. Treat the fork as a
|
||||
**downstream** that merges `lightcraft/main` regularly.
|
||||
|
||||
- Keep LightCraft's crates as unchanged as you can.
|
||||
- Put Classic-only work in **new crates and new UI modules**, so merges stay cheap.
|
||||
- Rename crates only once, mechanically, at fork time.
|
||||
|
||||
---
|
||||
|
||||
## 6. Plan to completion
|
||||
|
||||
Estimates are in **agent-hours**, calibrated on LightCraft's own log (4–6 parallel agents built M0–M13 in about 25
|
||||
active hours, roughly 105–145 agent-hours in total). Treat them as relative sizes: the heavy items depend on data and
|
||||
licences, not on typing speed. Each phase ends with an **exit gate** checked by tests or measurement, not by
|
||||
ticking boxes.
|
||||
|
||||
### Phase 0: Fork, brand config and foundations (≈ 12–22 h) → [PLAN_phase_0.md](PLAN_phase_0.md)
|
||||
|
||||
1. Fork `lightcraft` into `nonameyet/` with full git history (`git clone` + new remote).
|
||||
2. **Product name in one config file (`brand.toml`)**, read by a `brand` crate. No source file contains the name; a CI
|
||||
check enforces it, and settings/libraries migrate automatically on rename. Remove the ArtCraft brand
|
||||
(`docs/brand/`, README hero, logos). Give crates and binaries neutral internal names.
|
||||
3. **Licence (MIT, §4):** `LICENSE` (MIT), `NOTICE`, `cargo deny` policy; keep the clean-room rules in
|
||||
`AGENTS.md`/`CLAUDE.md`; `segment` and `fetch` stay Apache-2.0.
|
||||
4. Rebuild `plan/`: a Lightroom Classic reference (feature catalog, menus, Classic shortcuts) **written in our own
|
||||
words**, the Classic module map from §2, and `STATUS.md`. Extend `docs/parity.md`:
|
||||
- reopen the 🚫 Classic rows (KEYC-MODULES, Web module, reverse geocoding);
|
||||
- fix the garbled "Top gaps" list.
|
||||
5. Bring in shared infrastructure: `craft-fonts` (CJK) and the photo corpus (`cargo xtask corpus`). Add an
|
||||
`upstream` remote and a merge routine (merge, `cargo xtask ci`, fix, commit).
|
||||
6. Add Immich tracker rows (IMM-*), the `plan/immich.md` research note, and a pinned Immich test server
|
||||
(`cargo xtask immich up`).
|
||||
|
||||
**Gate:** `cargo xtask ci` green; renaming = editing `brand.toml` only; the app launches; parity tracker counts Classic rows.
|
||||
|
||||
### Phase 1: The Classic shell, a catalog that scales, Immich link (≈ 50–85 h) → [PLAN_phase_1.md](PLAN_phase_1.md)
|
||||
|
||||
1. **Module system** in `ui-egui`:
|
||||
- a `Module` trait with its own panels, toolbar and keymap;
|
||||
- module picker, identity plate, panel framework (auto hide/show, solo mode, Tab/⇧Tab/F5–F8/T), screen modes,
|
||||
lights out;
|
||||
- a secondary window with Grid/Loupe(live/locked)/Compare/Survey.
|
||||
- Re-home LightCraft's existing views into **Library** and **Develop**.
|
||||
2. **Classic keymap layer** (KEYC-*), with LightCraft's layout as an option.
|
||||
3. **Library panels Classic-style:**
|
||||
- Navigator; Catalog panel (Quick Collection, Previous Import);
|
||||
- Folders (rename/move/sync, volumes);
|
||||
- Collections with sets and target collection; Quick Develop;
|
||||
- Keywording (keyword sets, suggestions); Keyword List (synonyms, export flags, import/export);
|
||||
- Metadata panel views plus presets and copy/paste;
|
||||
- Painter tool.
|
||||
4. **Catalog v4:**
|
||||
- benchmark the current op-log at 250k and 1M photos;
|
||||
- move to an on-disk, indexed store (pure Rust, e.g. `redb`, with secondary indices) behind the existing
|
||||
`catalog` API;
|
||||
- lazy photo records;
|
||||
- backup-on-exit with integrity check and optimise;
|
||||
- multiple catalogs; Export as Catalog / Import from Catalog with conflict rules;
|
||||
- metadata-conflict badges against XMP.
|
||||
5. **Split `engine`** (45k lines) into the sub-crates of §5 before the output modules land.
|
||||
6. **Full-resolution zoom:**
|
||||
- generalise `ui-egui/src/region.rs` to tile/region rendering at 1:1 and above, for loupe, compare, soft proof and
|
||||
overlays;
|
||||
- 1:1 preview cache with auto-discard.
|
||||
|
||||
7. **Network groundwork and Immich link:**
|
||||
- `net` and `credentials` crates; `remote_identity` catalog table; SHA-1 checksums at import;
|
||||
- IMM-CONNECT, IMM-LINK, IMM-IMPORT and IMM-EXTLIB.
|
||||
|
||||
**Gate:**
|
||||
- A 500k-photo synthetic catalog opens in under 2 s and filters in under 100 ms.
|
||||
- Every Library and Develop panel and every Classic shortcut row in the tracker is ✅.
|
||||
- 1:1 zoom matches export pixels.
|
||||
|
||||
### Phase 2: Image quality and camera coverage (≈ 110–180 h, partly data collection) → [PLAN_phase_2.md](PLAN_phase_2.md)
|
||||
|
||||
1. **`camdb` crate:** per-camera matrices (dual illuminant), WB presets, noise profiles, black/white levels and crops.
|
||||
- sources: data the file carries itself, our own chart calibration, noise profiles measured from ISO series and
|
||||
the corpus, WB presets read from maker notes, and LightCraft's JPEG fitting as the fallback;
|
||||
- our own chart calibration tool, built from the published description of the darktable-chart method
|
||||
(ColorChecker / IT8, thin-plate-spline LUT), for "measured" profiles;
|
||||
- a community pipeline for CC0 chart and ISO-series submissions.
|
||||
2. **Camera-matching profiles** (Standard/Portrait/Landscape/Neutral/Vivid per maker): fitted to camera JPEGs with
|
||||
LightCraft's existing fitter, stored as our own LUT profiles.
|
||||
3. **Raw decoders:**
|
||||
- remaining CR3 CRX variants and sRAW/mRAW;
|
||||
- compressed ORF and SRW;
|
||||
- NEF lossy-after-split;
|
||||
- then the long tail (3FR, IIQ, MOS, ERF, KDC, DCR, MRW, NRW, X3F if feasible).
|
||||
- clean-room: black-box analysis and prose format descriptions, as LightCraft does now.
|
||||
- Corpus: one CC0 sample per model from raw.pixls.us, with a decode and colour sanity test per model.
|
||||
4. **`lensdb` crate:**
|
||||
- lensfun DB (CC-BY-SA, attributed), converted at build time to a compact lazy-loaded table (Ansel's lesson:
|
||||
don't parse 1,500 XML entries at startup);
|
||||
- auto lookup from EXIF lens id, distortion/TCA/vignetting models, custom profile import;
|
||||
- later, our own calibration tool.
|
||||
5. **Remove tool:** port PhotoCraft's PatchMatch inpaint and content-aware fill; spot pin editing; Detect (dust spots).
|
||||
6. **Denoise:**
|
||||
- profiled classical NR using noise profiles (Poisson-Gaussian variance stabilisation);
|
||||
- AI denoise across Bayer, X-Trans and linear raws, following the deterministic no-runtime design in Ansel's prose
|
||||
notes, and needing
|
||||
permissively licensed or our own trained weights;
|
||||
- Raw Details and Super Resolution (DNG output).
|
||||
7. **Render-fidelity suite:**
|
||||
- reference renders made locally from CC0 raws (never committed);
|
||||
- ΔE2000 and SSIM per slider sweep;
|
||||
- tune tone/highlights/clarity/texture/NR/sharpening against the numbers.
|
||||
- Also settle tone-curve placement (LightCraft applies curves after display encoding).
|
||||
8. **Soft proofing:** switch to PhotoCraft `cms`:
|
||||
- printer ICC profiles, perceptual/relative intent, BPC;
|
||||
- Simulate Paper & Ink;
|
||||
- destination gamut warning, create proof copy.
|
||||
|
||||
**Gate:**
|
||||
- ≥ 95% of models in the corpus decode from sensor data.
|
||||
- Median ΔE2000 ≤ 2 against camera JPEGs for supported bodies.
|
||||
- Lens profile found for ≥ 80% of corpus lens EXIFs.
|
||||
- Fidelity suite runs in CI on a small set.
|
||||
|
||||
### Phase 3: Output modules (≈ 72–124 h; can run in parallel with Phase 2 after Phase 1) → [PLAN_phase_3.md](PLAN_phase_3.md)
|
||||
|
||||
Shared first: **`layout`** (pages, cells, guides, templates, text blocks), **`text`** (from PhotoCraft) and a real
|
||||
**`pdf`** writer (multi-page, font subsetting and embedding, ICC output intents, JPEG/Flate images).
|
||||
|
||||
1. **Print:**
|
||||
- Single Image/Contact Sheet, Picture Package, Custom Package;
|
||||
- guides, rulers, crop marks, page/photo info, identity plate, watermark;
|
||||
- print sharpening and 16-bit output;
|
||||
- colour management with printer ICC and intent;
|
||||
- print to JPEG/PDF;
|
||||
- OS printing: IPP/CUPS in pure Rust on Linux and macOS; Win32 spooler on Windows in an isolated `unsafe` crate;
|
||||
- templates and saved prints.
|
||||
2. **Map:**
|
||||
- egui slippy map with a configurable tile server, disk cache and attribution;
|
||||
- pins and clusters, drag-to-geotag;
|
||||
- GPX tracklog display plus auto-tag;
|
||||
- saved locations with privacy (strip GPS on export);
|
||||
- location filter;
|
||||
- opt-in reverse geocoding (online service or offline GeoNames).
|
||||
3. **Slideshow:**
|
||||
- templates, overlays, backdrop, titles, pan and zoom, fades;
|
||||
- music with sync-to-music (pure-Rust decode with `symphonia`; audio output through `cpal`, which links system
|
||||
ALSA on Linux, so accept it or add a pure-Rust PipeWire/PulseAudio backend);
|
||||
- export to PDF and JPEG;
|
||||
- video export (AV1 via `rav1e` in WebM/MP4 first, H.264 depends on §6.6);
|
||||
- saved slideshows.
|
||||
4. **Book:**
|
||||
- settings (size, cover, paper);
|
||||
- Auto Layout with presets, page templates, custom pages;
|
||||
- cells, page and photo text with full type controls;
|
||||
- backgrounds, guides, spread view;
|
||||
- export to PDF and JPEG (Blurb is proprietary, so out of scope);
|
||||
- saved books.
|
||||
5. **Web:**
|
||||
- gallery templates we write ourselves (HTML/CSS/JS, responsive, no external CDN);
|
||||
- site info, palette, image info, output settings;
|
||||
- export to folder plus SFTP upload (`russh`);
|
||||
- saved galleries;
|
||||
- "Share via Immich": album plus shared link (IMM-SHARELINK).
|
||||
|
||||
**Gate:** each module's tracker rows are ✅; golden-image tests of the layout engine (PDF raster compare); a print
|
||||
round trip through a known ICC profile measures within ΔE tolerance.
|
||||
|
||||
### Phase 4: Workflow power features and full Immich integration (≈ 60–105 h) → [PLAN_phase_4.md](PLAN_phase_4.md)
|
||||
|
||||
1. **Tethered capture:**
|
||||
- start with "studio capture" (watched folder plus auto-apply preset), which is cheap and works with any vendor
|
||||
app;
|
||||
- then native PTP over USB (`nusb`) and PTP-IP for Canon/Nikon/Sony: shoot, settings bar, live view, naming,
|
||||
sessions.
|
||||
2. **Publish services framework:**
|
||||
- publish collections, the modified-for-republish state, remote id tracking;
|
||||
- built-in **Hard Drive** publisher;
|
||||
- Immich built in (item 8); WebDAV/Piwigo/PhotoPrism/Nextcloud as plugins;
|
||||
- optional Flickr and other OAuth services.
|
||||
3. **Plugin SDK:**
|
||||
- PhotoCraft's wasmi sandbox extended with a host API (catalog read/query, export hooks, metadata providers,
|
||||
publish service interface, menu commands);
|
||||
- capability-based permissions (network, filesystem roots);
|
||||
- pure Rust, unlike Lightroom's Lua.
|
||||
4. **Edit In:**
|
||||
- external editor presets;
|
||||
- "Open as Layers" / "Open as Smart Object"-style round trip to PhotoCraft, using PhotoCraft's layered PSD writer;
|
||||
- re-import and stack the result.
|
||||
5. **Actions:** record and replay of command sequences, built on PhotoCraft's `actions_cmds`; batch on a selection.
|
||||
6. **People:** face clusters with separators, write face regions to XMP (MWG regions), small-face detection on
|
||||
large images.
|
||||
7. **`.lrcat` migration v2:** history replay, snapshot import, profile mapping table, and a fidelity report per
|
||||
photo.
|
||||
|
||||
8. **Immich, two-way:**
|
||||
- publish service (albums; renders, originals, or both stacked);
|
||||
- metadata sync with conflict handling;
|
||||
- people and face import;
|
||||
- smart search;
|
||||
- MCP commands.
|
||||
|
||||
**Gate:** a scripted end-to-end session through MCP: tethered shoot → import preset → cull → develop → publish to
|
||||
folder, SFTP and Immich → rating changed in Immich syncs back → print to PDF. It runs headless in CI (with a simulated camera).
|
||||
|
||||
### Phase 5: AI, HDR and video (≈ 100–200 h, highest uncertainty)
|
||||
|
||||
1. **AI model strategy (an owner decision).** Use permissively licensed weights, or train our own; pure-Rust
|
||||
inference on candle or LightCraft's own interpreter; host the downloads.
|
||||
- Subject, Sky, Background, People parts, Landscape parts, depth (for Depth Range and Lens Blur).
|
||||
- Update AI masks after edits; adaptive presets.
|
||||
- Semantic search with CLIP-like embeddings on device.
|
||||
- Assisted culling: eyes closed, focus, near duplicates.
|
||||
- darktable's `src/ai` shows a working model set (SAM 2.1, NIND denoise, RealPLKSR), but check each model's
|
||||
licence on its own.
|
||||
2. **HDR:**
|
||||
- HDR editing range (extended highlights in the scene-referred pipeline, which LightCraft already has);
|
||||
- HDR display via wgpu with an HDR swapchain (platform-dependent);
|
||||
- visualise high dynamic range, SDR preview settings;
|
||||
- export as JPEG with gain map (ISO 21496-1), AVIF/JXL HDR, and EXR (PhotoCraft codecs).
|
||||
3. **Video:**
|
||||
- playback, trim, poster frame, frame capture, presets on video, export.
|
||||
- **Pure-Rust decoding is the risk.** AV1 via `rav1d` (Rust) is fine, but there is no mature pure-Rust H.264 or
|
||||
HEVC decoder, and HEVC also has patent issues.
|
||||
- Options:
|
||||
- (a) write a pure-Rust H.264 baseline/main decoder: large;
|
||||
- (b) allow OS decoders (VideoToolbox, Media Foundation, VA-API) in an isolated FFI crate, as LightCraft
|
||||
already does for one macOS call;
|
||||
- (c) share FilmCraft's crates if and when they exist.
|
||||
- Recommendation: (b). It breaks "pure Rust" only at a thin, optional platform edge.
|
||||
|
||||
**Gate:** AI masks reach IoU ≥ 0.85 on an annotated CC0 test set; an HDR export round-trips gain maps; MP4 from the
|
||||
main camera brands plays and trims.
|
||||
|
||||
### Phase 6: 1.0 hardening (≈ 30–50 h)
|
||||
|
||||
- Performance budgets:
|
||||
- slider ≤ 16 ms at preview size on 24 MP;
|
||||
- 1:1 tile ≤ 100 ms;
|
||||
- import 1,000 raws with standard previews in under 2 min;
|
||||
- catalog at 1M photos.
|
||||
- Robustness: fuzzing raw/XMP/catalog/plugin inputs (the never-crash rule), corrupt-catalog recovery, and a disk-full
|
||||
test.
|
||||
- Accessibility (AccessKit through egui), localisation, a Classic-style help overlay per module.
|
||||
- Packaging: AppImage/Flatpak, .deb/.rpm, dmg (notarised), msi. Decide whether to keep or drop the web build (it
|
||||
costs CI time and constrains crates).
|
||||
- Documentation: user manual, plugin SDK docs, migration guide from Lightroom Classic.
|
||||
|
||||
**Gate:** tracker ≥ 98% of in-scope rows; fidelity and performance suites green; a beta with real Lightroom Classic
|
||||
users migrating real catalogs.
|
||||
|
||||
### Totals
|
||||
|
||||
| Phase | Agent-hours |
|
||||
|---|---:|
|
||||
| 0 Fork, brand config and foundations | 12–22 |
|
||||
| 1 Classic shell, catalog, Immich link | 50–85 |
|
||||
| 2 Quality and camera coverage | 110–180 |
|
||||
| 3 Output modules | 72–124 |
|
||||
| 4 Workflow power features, Immich two-way | 60–105 |
|
||||
| 5 AI, HDR, video | 100–200 |
|
||||
| 6 Hardening | 30–50 |
|
||||
| **Total** | **≈ 434–766** |
|
||||
|
||||
Order and parallelism:
|
||||
- Phases 0 → 1 must come first; they are the architectural changes everything else sits on.
|
||||
- Phases 2, 3 and 4 then run in parallel on separate crates.
|
||||
- Phase 5 depends mostly on the model decision and can start research early.
|
||||
- With 4–6 parallel agents at about 70% efficiency, that is roughly **110–190 hours of wall-clock work**, plus the
|
||||
non-coding time for data (chart shots, corpus), model licensing and testing with real users.
|
||||
- Without Phase 5 (AI, HDR, video), a complete Classic-module application with Lightroom-grade colour is about
|
||||
**335–565 agent-hours**.
|
||||
|
||||
---
|
||||
|
||||
## 7. Risks
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|---|---|---|
|
||||
| Clean-room data collection is slow (MIT, no GPL tables) | Some cameras stay "estimated" for a long time | Community CC0 chart/ISO-series submissions; automatic noise estimation from corpus raws; maker-note WB presets; JPEG fitting fallback |
|
||||
| Contamination: an agent reads GPL source | Licence breach | Clean-room rule in AGENTS.md; GPL trees are never referenced by path in tasks; every algorithm PR names its sources |
|
||||
| LightCraft keeps moving; merges get painful | Lost upstream fixes | New work in new crates; merge weekly; keep shared crate names unchanged after the one-time rename |
|
||||
| Catalog redesign breaks existing libraries | Data loss | Keep the catalog API; migration with backup; crash/fuzz tests from LightCraft |
|
||||
| AI weights not licensable | AI masks stay heuristic | Classical fallbacks (PhotoCraft GrabCut/maxflow); train our own small models |
|
||||
| Video decoding in pure Rust | No video | Isolated OS-decoder FFI crate (§Phase 5) |
|
||||
| Look fidelity vs Adobe never matches | Users notice a different look | Measured fidelity suite; camera-matching profiles; accept "different but good" where needed |
|
||||
| Printing on Windows needs FFI | Print module incomplete on Windows | Isolated `unsafe` crate like `sysmem`; PDF export always works |
|
||||
| Immich API changes between releases | Sync breaks after a server upgrade | Pin and test the minimum and latest releases nightly; version check on connect; feature-detect optional endpoints |
|
||||
| Name hard-coded again by upstream merges or new code | Rename gets expensive | `cargo xtask brand check` in CI plus a throw-away-brand build |
|
||||
| Adobe IP | Legal | Keep LightCraft's rules: no Adobe assets, DCP, LCP, presets or bundle inspection; feature names in our own words |
|
||||
|
||||
---
|
||||
|
||||
## 8. Immediate next steps
|
||||
|
||||
1. ~~Licence~~: **MIT** (decided 2026-10-10). The name can wait: `brand.toml` makes it a one-line change at any time.
|
||||
2. Phase 0 ([PLAN_phase_0.md](PLAN_phase_0.md)): fork, brand config, debrand, rebuild `plan/`, update the tracker
|
||||
(including Immich).
|
||||
3. Spike in Phase 1 (both inform everything after):
|
||||
- the `Module` trait and panel framework in the UI;
|
||||
- the catalog benchmark at 500k photos.
|
||||
@@ -0,0 +1,262 @@
|
||||
# Phase 0: Fork, brand configuration and foundations
|
||||
|
||||
Part of [PLAN.md](PLAN.md). **Estimate:** 12–22 agent-hours. **Licence:** MIT (decided 2026-10-10, PLAN.md §4).
|
||||
**Unblocks:** every later phase.
|
||||
|
||||
**Goal:** the LightCraft code base, running under a product name that lives in **one config file** and can be changed
|
||||
at any time. It also gets a parity tracker that covers Lightroom Classic and Immich, and an upstream-merge routine.
|
||||
|
||||
---
|
||||
|
||||
## 0.1 Fork
|
||||
|
||||
1. Clone `../lightcraft` into this folder with full history:
|
||||
- `git clone ../lightcraft .`
|
||||
- rename `origin` to `upstream`
|
||||
- add the new origin.
|
||||
2. Tag the fork point `fork-base` so `git log fork-base..upstream/main` always shows what upstream did since.
|
||||
3. Write `docs/upstream-merge.md`. The routine:
|
||||
1. `git fetch upstream`
|
||||
2. merge into a `merge/upstream-YYYYMMDD` branch
|
||||
3. run `cargo xtask brand check` and `cargo xtask ci`
|
||||
4. fix, commit, merge.
|
||||
- Weekly at first.
|
||||
- Upstream renames are resolved by the brand tooling in 0.2, never by hand-editing names.
|
||||
|
||||
**Done when:** `cargo xtask ci` is green on the untouched fork.
|
||||
|
||||
---
|
||||
|
||||
## 0.2 The name lives in one config file (`brand.toml`)
|
||||
|
||||
**Requirement:** the product name (and everything derived from it) is defined **once** in `brand.toml` at the
|
||||
workspace root. Changing that file and rebuilding renames the product everywhere:
|
||||
|
||||
- window title, menus and About;
|
||||
- CLI help;
|
||||
- logs and settings folders;
|
||||
- installers;
|
||||
- MCP server name;
|
||||
- docs that are generated.
|
||||
|
||||
**No source file may contain the product name.**
|
||||
|
||||
### 0.2.1 The config file
|
||||
|
||||
`brand.toml` (workspace root, the only place a name is written):
|
||||
|
||||
```toml
|
||||
# Changing anything in [product] is safe at any time: rebuild and repackage.
|
||||
[product]
|
||||
display_name = "NoNameYet" # window title, menus, About, dialogs, docs
|
||||
short_name = "NoNameYet" # macOS menu bar, taskbar, notifications
|
||||
tagline = "Photo library and raw development"
|
||||
vendor = "NoNameYet contributors"
|
||||
homepage = "https://example.invalid"
|
||||
repository = "https://example.invalid/repo"
|
||||
binary = "nonameyet" # desktop executable name in packages
|
||||
cli_binary = "nonameyet-cli"
|
||||
env_prefix = "NONAMEYET" # NONAMEYET_LOG, NONAMEYET_GPU, …
|
||||
mcp_server = "nonameyet" # MCP server id shown to agents
|
||||
icon_svg = "brand/icon.svg" # source for every generated icon
|
||||
logo_svg = "brand/logo.svg" # identity plate default, About box
|
||||
|
||||
# Identifiers stored in users' files and OS registrations. Changing these is allowed, but the old
|
||||
# values MUST be appended to [legacy] in the same commit, so existing installs keep working.
|
||||
[identity]
|
||||
app_id = "org.example.NoNameYet" # reverse-DNS: bundle id, Flatpak id, MIME/desktop ids
|
||||
settings_dir = "NoNameYet" # ~/.config/<x>, ~/Library/Application Support/<x>, %APPDATA%\<x>
|
||||
library_default = "NoNameYet Library" # default catalog folder name under Pictures
|
||||
catalog_ext = "nnycat"
|
||||
preset_ext = "nnypreset"
|
||||
url_scheme = "nonameyet" # nonameyet:// deep links, OAuth redirects
|
||||
|
||||
# Never derived from the product name: these stay valid across renames forever.
|
||||
[stable]
|
||||
xmp_namespace_uri = "https://ns.example.invalid/develop/1.0/"
|
||||
xmp_namespace_prefix = "nnyd"
|
||||
catalog_magic = "PHOTO-CATALOG" # file-format magic; neutral on purpose
|
||||
|
||||
# Previous identities, read on startup for migration (settings, catalogs, presets, env vars).
|
||||
[legacy]
|
||||
settings_dirs = ["LightCraft"]
|
||||
env_prefixes = ["LIGHTCRAFT"]
|
||||
preset_exts = ["lcpreset"]
|
||||
catalog_exts = []
|
||||
app_ids = []
|
||||
```
|
||||
|
||||
Three groups, because names fall into three kinds:
|
||||
|
||||
- **[product]** is the user-visible brand, freely changeable.
|
||||
- **[identity]** is OS and file identity. It is changeable, but it needs migration, which the app does automatically
|
||||
from **[legacy]**.
|
||||
- **[stable]** holds format identifiers that must never follow the name. A rename must never orphan users' XMP sidecars.
|
||||
|
||||
### 0.2.2 How code gets the name
|
||||
|
||||
- **New crate `crates/brand` (L0, no dependencies).**
|
||||
- Its `build.rs` reads `../../brand.toml` (or `$BRAND_FILE`, for test builds) and generates `pub const`s:
|
||||
`DISPLAY_NAME`, `BINARY`, `APP_ID`, `ENV_PREFIX` and the rest.
|
||||
- It also provides helpers:
|
||||
- `brand::env("LOG")` reads `NONAMEYET_LOG`, falling back to every legacy prefix;
|
||||
- `brand::settings_dir()`;
|
||||
- `brand::legacy_settings_dirs()`.
|
||||
- `cargo:rerun-if-changed=brand.toml` means editing the file triggers a rebuild.
|
||||
- **Code changes:**
|
||||
- Replace every hard-coded name in `crates/` and `apps/` (292 × "LightCraft" in 115 files, 25 `LIGHTCRAFT_*`
|
||||
environment variables, preset and library names) with `brand::` constants.
|
||||
- Replace user-visible format strings like `"{} Library"` with `brand::LIBRARY_DEFAULT`.
|
||||
- **Localisation:**
|
||||
- Catalogs use a `{app}` placeholder instead of the name;
|
||||
- the i18n loader substitutes `brand::DISPLAY_NAME` at load time.
|
||||
- Update every `docs/localization-*.md` table.
|
||||
- **Native macOS menu bar:** take the application menu title from `brand::SHORT_NAME`.
|
||||
- **Packaging becomes templates rendered by `cargo xtask package` from `brand.toml`:**
|
||||
- `packaging/linux/{app_id}.desktop.in`, `{app_id}.metainfo.xml.in`, `{app_id}.mime.xml.in`;
|
||||
- Flatpak manifest, `nfpm.yaml.in`;
|
||||
- `macos/Info.plist.in`;
|
||||
- `windows/app.wxs.in`.
|
||||
- The current files hard-code `ai.storyteller.lightcraft.*` and `lightcraft.wxs`.
|
||||
- **Icons:** `packaging/icons.sh` renders every icon size from `brand.icon_svg`.
|
||||
- **Binaries:**
|
||||
- Cargo `[[bin]] name`s cannot read a config file, so the binaries keep **neutral internal names** (`app`, `app-cli`).
|
||||
- `cargo xtask run`, `cargo xtask package` and `cargo xtask install` copy and rename them to `brand.binary` /
|
||||
`brand.cli_binary`.
|
||||
- Shell completions and man pages are generated with the branded name.
|
||||
- **Crate names:**
|
||||
- Packages are named by function with a neutral internal prefix (`dac-raw`, `dac-catalog`, …; the prefix is an
|
||||
internal codename never shown to users and can be changed with `cargo xtask rename-crates <prefix>`).
|
||||
- Renaming the crates is a separate one-time mechanical commit, done right after the fork, so upstream merges map
|
||||
cleanly. See `docs/upstream-merge.md`: the xtask also rewrites `lightcraft_*`/`lightcraft-*` paths in incoming
|
||||
upstream changes.
|
||||
|
||||
### 0.2.3 Migration on rename
|
||||
|
||||
On first start under a new `[identity]`, the app checks every `[legacy]` entry in order:
|
||||
|
||||
- if a legacy settings folder exists and the new one does not, it **copies** the folder (never moves it) and writes a
|
||||
`migrated-from` marker;
|
||||
- it opens the legacy default library if no new one exists;
|
||||
- it reads legacy preset extensions;
|
||||
- it honours legacy env var prefixes, with a one-time deprecation log line.
|
||||
|
||||
LightCraft itself is listed in `[legacy]`, so existing LightCraft users can switch with their settings and libraries.
|
||||
|
||||
### 0.2.4 Enforcement
|
||||
|
||||
- **`cargo xtask brand check`** (part of `cargo xtask ci`) fails if any of these appears outside `brand.toml`,
|
||||
`[legacy]` handling, `ATTRIBUTION.md`, `docs/upstream-merge.md` or `CHANGELOG` history:
|
||||
- the current `display_name`, `binary` or `env_prefix`;
|
||||
- any legacy name (`LightCraft`, `lightcraft`, `LIGHTCRAFT_`, `ArtCraft`, `storyteller`).
|
||||
- **A CI job builds with a throw-away brand** (`BRAND_FILE=xtask/test-brand.toml`, display name "Zzyzx Test") and
|
||||
checks two things:
|
||||
- The headless UI snapshots (LightCraft already captures every widget through the control channel) contain
|
||||
"Zzyzx Test" and no other product name.
|
||||
- Settings, logs and the default library go to Zzyzx paths.
|
||||
- **Docs:** README and docs are written with the name in a `{{app}}` template where it matters. `cargo xtask docs`
|
||||
renders them; prose elsewhere says "the app".
|
||||
|
||||
**Done when:**
|
||||
- Editing `display_name` in `brand.toml` and running `cargo xtask run` shows the new name in the title, menus, About,
|
||||
logs path, CLI `--help`, MCP `serverInfo` and installer metadata, with **zero** source edits.
|
||||
- The brand-check job is green.
|
||||
|
||||
---
|
||||
|
||||
## 0.3 Remove upstream branding
|
||||
|
||||
- Delete `docs/brand/` (ArtCraft trademarks, not open licensed), the README hero and badges, Discord links and the
|
||||
"Crafting Apps" sections.
|
||||
- Remove the `contributors/` and `assets/ATTRIBUTION.md` entries that only concern ArtCraft branding.
|
||||
- Add our own placeholder `brand/icon.svg` and `brand/logo.svg` (original work) with attribution entries.
|
||||
- Keep the upstream copyright notices in `NOTICE`/`LICENSE-*`; MIT and Apache require it.
|
||||
|
||||
---
|
||||
|
||||
## 0.4 Apply the licence: MIT (PLAN.md §4)
|
||||
|
||||
1. **`LICENSE`:**
|
||||
- MIT text, with the copyright lines "the LightCraft contributors" and "the PhotoCraft contributors" for inherited
|
||||
code, and "the project contributors" for new code;
|
||||
- no product name.
|
||||
2. **Remove `LICENSE-APACHE`** from the root and set `license = "MIT"` in `[workspace.package]`.
|
||||
3. **Apache-2.0-only crates keep their licence:**
|
||||
- `crates/segment` (ported Hugging Face SAM 3 code) and `crates/fetch` keep `license = "Apache-2.0"`, a local
|
||||
copy of `LICENSE-APACHE` in each crate folder, and their `NOTICE` entries;
|
||||
- Phase 1.7 writes the new `net` crate fresh under MIT instead of extending `fetch`.
|
||||
4. **`NOTICE`:** keep the upstream attributions, the Hugging Face notice and third-party data attributions.
|
||||
5. **Clean-room rules:** keep LightCraft's `AGENTS.md`/`CLAUDE.md` non-negotiables word for word and add:
|
||||
- darktable and Ansel may be used **as running applications, manuals and prose design notes only**;
|
||||
- their source trees in `../darktable` and `../ansel` are off-limits to agents, as are GPL data tables
|
||||
(`noiseprofiles.json`, `wb_presets.json`, `colormatrices.c`, basecurves);
|
||||
- camera matrices are never harvested from Adobe-converted DNGs (check the `Software` tag); camera-native DNGs are
|
||||
fine;
|
||||
- a "Sources" line in every PR that adds an algorithm (paper, prose spec, or "own design").
|
||||
6. **`cargo deny`** in CI, so a GPL dependency can't arrive silently:
|
||||
- licences allowed: MIT, MIT-0, Apache-2.0 (incl. LLVM exception), BSD-2/3-Clause, ISC, Zlib, Unicode-3.0, CC0-1.0;
|
||||
- everything else, including GPL, LGPL, AGPL and MPL, is denied.
|
||||
7. **`assets/ATTRIBUTION.md` policy (unchanged):** CC-BY and CC-BY-SA are allowed only as separate data or asset
|
||||
files, with attribution (for example the lensfun DB and GeoNames).
|
||||
|
||||
---
|
||||
|
||||
## 0.5 Rebuild `plan/` and extend the tracker
|
||||
|
||||
LightCraft's tracker ids point to a local `plan/` folder that wasn't published.
|
||||
|
||||
1. Write `plan/` from scratch, **in our own words**:
|
||||
- `lightroom-classic/` (feature catalog, menus, Classic shortcuts, per-module behaviour notes);
|
||||
- `architecture.md` (PLAN.md §5);
|
||||
- `STATUS.md`;
|
||||
- `execution-plan.md` (these phase files).
|
||||
2. Decide whether to keep `plan/` gitignored. Recommendation: **commit it**. The ignore rule only existed to keep
|
||||
Adobe-observation notes local, so keep that one subfolder (`plan/observations/`) ignored.
|
||||
3. Extend `docs/parity.md`:
|
||||
- reopen the 🚫 Classic rows (KEYC-MODULES, the seven LRC-WEB rows, LRC-MAP-REVGEO);
|
||||
- add rows for every PLAN.md §2 feature that has no row yet;
|
||||
- fix the duplicated "Top gaps" list.
|
||||
4. Add a new tracker section **"IMM. Immich integration"** with the rows below. They are worked through in Phases 1,
|
||||
3 and 4. `cargo xtask parity` counts them like any other row.
|
||||
|
||||
| Id | Feature | Tier | Phase |
|
||||
|---|---|---|---|
|
||||
| IMM-CONNECT | Connect to one or more Immich servers (URL + API key), version check, secure key storage | P1 | 1 |
|
||||
| IMM-LINK | Link catalog photos to Immich assets by checksum; "in Immich" badge and filter | P1 | 1 |
|
||||
| IMM-IMPORT | Immich as an import source (browse albums/timeline, download originals or add as linked) | P1 | 1 |
|
||||
| IMM-EXTLIB | Shared-originals mode: Immich external library and catalog over the same folders, no duplicate uploads | P1 | 1 |
|
||||
| IMM-SHARELINK | Create Immich shared links for published albums (from Web/Slideshow/Publish) | P2 | 3 |
|
||||
| IMM-PUBLISH | Immich publish service: collections → albums, renders and/or originals, re-publish, stacks | P1 | 4 |
|
||||
| IMM-SYNC | Two-way metadata sync (rating, favourite, title/description, tags ↔ keywords, albums ↔ collections, GPS, time, archive) | P1 | 4 |
|
||||
| IMM-PEOPLE | Import Immich people and face regions into People; push names back | P2 | 4 |
|
||||
| IMM-SEARCH | Immich smart (CLIP) and metadata search from the Library filter bar | P2 | 4 |
|
||||
| IMM-MCP | Immich commands available via CLI, control channel and MCP | P2 | 4 |
|
||||
|
||||
5. Write `plan/immich.md`, the research note:
|
||||
- Immich concepts (assets, checksums, albums, tags, people, stacks, external libraries, shared links, API keys
|
||||
and permissions);
|
||||
- the API endpoints we need, with the Immich release they were checked against;
|
||||
- the minimum supported Immich version;
|
||||
- how Immich's ratings and favourites behave.
|
||||
- **Licence note:** the Immich server is AGPL-3.0. We only talk to it over HTTP and write our own client from its
|
||||
public API documentation. We don't vendor its code or its generated SDK.
|
||||
6. Add `xtask/immich/compose.yml`, a pinned Immich release for local and CI integration tests, plus
|
||||
`cargo xtask immich up|down|seed`. The seed step loads a CC0 fixture set and creates an API key.
|
||||
|
||||
---
|
||||
|
||||
## 0.6 Shared infrastructure
|
||||
|
||||
- `craft-fonts` as the `CRAFT_FONTS_DIR` build input (CJK glyphs), and the raw corpus via `cargo xtask corpus`.
|
||||
- Read and keep the never-crash standard (LightCraft's `AGENTS.md` already includes its substance; `craftrules` is
|
||||
not needed at build time).
|
||||
|
||||
---
|
||||
|
||||
## Exit gate
|
||||
|
||||
- [ ] `cargo xtask ci` green under the configurable brand; brand-check job green with the throw-away brand.
|
||||
- [ ] Renaming the product = editing `brand.toml` only (demonstrated in a PR that renames twice and back).
|
||||
- [ ] Legacy LightCraft settings and library are picked up on first start.
|
||||
- [ ] `plan/` exists; `docs/parity.md` contains Classic rows and IMM rows; `cargo xtask parity` prints the new totals.
|
||||
- [ ] `cargo xtask immich up` starts a test server; licence policy enforced by `cargo deny`.
|
||||
@@ -0,0 +1,251 @@
|
||||
# Phase 1: Classic shell, scalable catalog and Immich link
|
||||
|
||||
Part of [PLAN.md](PLAN.md). **Estimate:** 50–85 agent-hours. **Depends on:** Phase 0.
|
||||
**Unblocks:** Phases 2, 3 and 4. Those can run in parallel after this phase.
|
||||
|
||||
**Goal:** the app *is* Lightroom Classic in structure:
|
||||
- modules, Classic panels and keys;
|
||||
- a catalog that holds 500k+ photos;
|
||||
- true 1:1 zoom;
|
||||
- network groundwork, so the catalog can be linked to an Immich server.
|
||||
|
||||
The product name is never written in code; use `brand::` constants (Phase 0.2).
|
||||
|
||||
---
|
||||
|
||||
## 1.1 Split the engine crate first (≈ 6–10 h)
|
||||
|
||||
`crates/engine` is 45k lines and mixes import, export, catalog import, a SQLite reader, faces, denoise, segmentation,
|
||||
presets and devices. Split it before new features land in it:
|
||||
|
||||
| New crate | Takes |
|
||||
|---|---|
|
||||
| `engine-core` | command registry (`CommandSpec`), session, jobs, journal, undo |
|
||||
| `engine-library` | import, devices, folders, collections, keywords, metadata, XMP, `.lrcat`/Luminar import |
|
||||
| `engine-develop` | develop commands, presets, profiles, versions, sync, merge |
|
||||
| `engine-export` | export, watermarks, naming templates, DNG writing |
|
||||
| `engine` | thin facade that re-exports the above, so the UI, CLI, MCP and upstream merges keep compiling |
|
||||
|
||||
- Update `xtask/src/layers.rs` with the new crates.
|
||||
- **Done when:** behaviour is identical (all tests pass), and no file moves change command ids.
|
||||
|
||||
---
|
||||
|
||||
## 1.2 Module system and panel framework (≈ 12–20 h)
|
||||
|
||||
Today `ui-egui` has a `ViewMode` and a `RightPanel` enum (the cloud Lightroom layout).
|
||||
|
||||
- **A `Module` trait:**
|
||||
```rust
|
||||
trait Module {
|
||||
fn id(&self) -> ModuleId; // Library, Develop, Map, Book, Slideshow, Print, Web
|
||||
fn left_panels(&self) -> &[PanelId];
|
||||
fn right_panels(&self) -> &[PanelId];
|
||||
fn toolbar(&mut self, ui: &mut Ui, cx: &mut Cx);
|
||||
fn center(&mut self, ui: &mut Ui, cx: &mut Cx);
|
||||
fn keymap(&self) -> &Keymap; // module-local keys layered over global keys
|
||||
}
|
||||
```
|
||||
- **Top bar:**
|
||||
- identity plate on the left (text or image from `brand.logo_svg` by default, user-customisable);
|
||||
- module picker on the right, with hide/show modules from its context menu;
|
||||
- an activity/progress area.
|
||||
- **Panel framework:**
|
||||
- left, right, top and bottom (filmstrip) panel groups;
|
||||
- collapsible panel headers;
|
||||
- **solo mode** (opening one panel closes the others);
|
||||
- auto hide and show per edge, with a manual override;
|
||||
- Tab (side panels), ⇧Tab (all), F5–F8 (each edge), T (toolbar);
|
||||
- panel order and visibility saved per module.
|
||||
- **Screen modes:** normal, full screen with menu bar, full screen; **lights out** (dim/off, L).
|
||||
- **Secondary window:**
|
||||
- Grid, Loupe (Normal / Live / Locked), Compare, Survey, Slideshow;
|
||||
- its own filmstrip and filter;
|
||||
- the KEYC-SECONDWINDOW keys.
|
||||
- **Re-home existing views:**
|
||||
- Library: grid, loupe, compare, survey, people.
|
||||
- Develop: detail view, all editing panels, reference view.
|
||||
- Map, Book, Slideshow, Print and Web get placeholder modules ("coming in Phase 3"), so the picker is complete now.
|
||||
- Every module, panel and screen mode is a command (`module.switch`, `panel.toggle`, …), reachable from MCP, the
|
||||
control channel and the menus.
|
||||
|
||||
**Done when:** switching modules keeps the selection and filmstrip; the panel state persists across restarts;
|
||||
headless UI snapshots exist for each module.
|
||||
|
||||
---
|
||||
|
||||
## 1.3 Classic keymap layer (≈ 3–5 h)
|
||||
|
||||
- Add a keymap set called **"Classic"**, the default. LightCraft's current keys become a selectable alternative set.
|
||||
- Module keys:
|
||||
- G grid, E loupe, C compare, N survey, D develop;
|
||||
- Ctrl+Alt+1…7 switch module;
|
||||
- ⌘⌥↑ returns to the previous module.
|
||||
- Rating and flag keys:
|
||||
- `[` and `]` rating down and up;
|
||||
- P/X/U flags and the backtick flag toggle;
|
||||
- ⇧+key to set and advance (or Caps Lock auto-advance).
|
||||
- Library and Develop keys:
|
||||
- B target collection;
|
||||
- Ctrl+Alt+0–9 keyword sets;
|
||||
- ⇧⌘C and ⇧⌘V copy/paste metadata;
|
||||
- R crop, Q remove, ⇧T masking, K brush, M linear, ⇧M radial.
|
||||
- Help: F1 opens help; ⌘/ shows the shortcut overlay for the current module.
|
||||
- Shortcut editor in Preferences (it reads the command registry, detects conflicts, and imports/exports a keymap
|
||||
file).
|
||||
- Tracker rows: all KEYC-* rows to ✅.
|
||||
|
||||
---
|
||||
|
||||
## 1.4 Library module: Classic panels (≈ 10–16 h)
|
||||
|
||||
| Panel / feature | Work |
|
||||
|---|---|
|
||||
| Navigator | Fit/Fill/1:1/custom zoom presets, click-to-pan, synced with the loupe |
|
||||
| Catalog panel | All Photographs, **Quick Collection** (B, ⌘B show, ⌥⌘B clear, ⌘⇧B save as collection), Previous Import, Missing Photographs, Added by Previous Export |
|
||||
| Folders | Volume headers with free space; add/move/rename/remove folders on disk (journaled, undoable); synchronise folder (new, missing, metadata changes); update location; show parent; show in file manager |
|
||||
| Collections | Collection sets (nesting), **target collection**, smart collection rule editor parity (nested any/all groups, every metadata field), collection export/import |
|
||||
| Quick Develop | Relative ± buttons for WB, exposure, contrast, highlights, shadows, whites, blacks, clarity, vibrance; presets; crop ratio; treatment |
|
||||
| Keywording | Keyword tags as text, suggestions (co-occurrence), **keyword sets** (built-in and user), keyword shortcuts |
|
||||
| Keyword List | Hierarchy, synonyms, include/exclude on export, person keywords, import/export of keyword text files, counts and filters |
|
||||
| Metadata | Panel presets (Default, EXIF, IPTC, IPTC Extension, Location, Large Caption, Minimal); **metadata presets**; edit many; copy/paste metadata; edit capture time (shift, set, use file date) |
|
||||
| Painter tool | Spray keywords, labels, flags, ratings, metadata preset, develop settings, rotation, target collection |
|
||||
| Grid cells | Compact/expanded cell styles, badges (keywords, collections, edited, virtual copy, metadata conflict, **Immich linked**), index numbers |
|
||||
|
||||
---
|
||||
|
||||
## 1.5 Catalog v4: scale, backup, multiple catalogs (≈ 12–20 h)
|
||||
|
||||
Today the catalog is an op log plus a full JSON snapshot, held entirely in RAM (BTreeMaps of `Arc<Photo>`). It works up
|
||||
to about 85k photos.
|
||||
|
||||
1. **Benchmark first.** `cargo xtask bench-catalog --photos 250000,1000000` measures open time, peak RSS, filter
|
||||
latency, snapshot time and import throughput on the current design. Record the numbers in `docs/catalog.md`.
|
||||
2. **New storage behind the same `catalog` API:**
|
||||
- a pure-Rust embedded store (candidate `redb`, ACID with MVCC; decide from the benchmark);
|
||||
- tables: photos, folders, collections, keywords, develop settings/history, previews index, **remote identities**
|
||||
(1.7);
|
||||
- secondary indexes for the filter bar's columns;
|
||||
- photo records loaded lazily; the grid pages through ids;
|
||||
- keep the op log as the undo journal and change feed (useful for sync in Phase 4).
|
||||
3. **Migration:** v3 (LightCraft JSON) → v4 runs automatically, keeps a backup, and is tested on the 85k fixture.
|
||||
4. **Catalog file:** one catalog = one folder with a `*.{brand.catalog_ext}` entry point.
|
||||
- File → New/Open/Open Recent Catalog;
|
||||
- a catalog chooser at startup (Alt held, or a preference).
|
||||
5. **Backup:** schedule (on exit: daily/weekly/monthly/never), backup folder, integrity test, optimise (compaction plus
|
||||
index rebuild).
|
||||
6. **Export as Catalog / Import from Another Catalog:** subset with or without originals and previews; conflict rules
|
||||
(new, changed: replace settings / metadata / keep), with a preview of changes.
|
||||
7. **Metadata vs XMP:** a "changed on disk" / "changed in catalog" badge; Read Metadata from File / Save to File;
|
||||
conflict dialog.
|
||||
|
||||
**Done when:** 500k photos open in under 2 s, filter in under 100 ms, use under 2 GB RSS; 1M photos still usable;
|
||||
the crash and fuzz tests are carried over.
|
||||
|
||||
---
|
||||
|
||||
## 1.6 True 1:1 zoom and previews (≈ 6–10 h)
|
||||
|
||||
- Generalise `ui-egui/src/region.rs` (it renders the on-screen window at zoom scale, but only for the loupe and
|
||||
Before/After) to a **tiled region renderer**:
|
||||
- used by loupe, compare, survey, reference view, soft proof and overlays;
|
||||
- zoom levels Fit, Fill, 1:4, 1:3, 1:2, 1:1, 2:1, 3:1, 4:1, 8:1, 11:1;
|
||||
- tiles cached per settings hash;
|
||||
- the GPU path where available.
|
||||
- Preview store: standard previews at a size set per catalog, 1:1 previews built on demand or at import, auto-discard
|
||||
of 1:1 previews (day/week/month/never), and Library → Previews → Build/Discard commands.
|
||||
|
||||
**Done when:** 1:1 pixels equal the full-resolution export pixels (test on corpus raws); panning at 1:1 on 45 MP
|
||||
stays above 30 fps.
|
||||
|
||||
---
|
||||
|
||||
## 1.7 Network groundwork (≈ 4–6 h)
|
||||
|
||||
Immich in 1.8 and Phase 4, publish services, maps and model downloads all need this.
|
||||
|
||||
- New L0 **`net` crate**, written fresh under **MIT**. `crates/fetch` is Apache-2.0 only (PLAN.md §4.1), so it is not
|
||||
extended. It uses the same approach as `fetch`: rustls with the RustCrypto provider, pure Rust. It provides:
|
||||
- methods GET/POST/PUT/PATCH/DELETE;
|
||||
- JSON bodies (serde);
|
||||
- **streaming multipart upload** with progress;
|
||||
- streaming download (keeping the existing resume and SHA-256 logic);
|
||||
- connect and stall timeouts, cancellation, redirects, gzip;
|
||||
- optional HTTP proxy;
|
||||
- `User-Agent` built from `brand::`;
|
||||
- custom CA / self-signed certificates per connection (common on home Immich servers), with trust-on-first-use plus
|
||||
fingerprint confirmation.
|
||||
- `fetch` stays as it is for model downloads, or later becomes a thin Apache-2.0 wrapper over `net`.
|
||||
- **`credentials` module:** secrets stored in the OS keychain where reachable in pure Rust:
|
||||
- Linux Secret Service over D-Bus via `zbus`;
|
||||
- macOS and Windows behind an isolated platform crate.
|
||||
- Fallback: a file encrypted with a key derived from a user passphrase.
|
||||
- Never write secrets into the catalog, settings, logs, MCP output or crash reports; add a test that greps the logs.
|
||||
- **Connections settings pane:** Preferences → Connections lists accounts for Immich now, and publish services, map
|
||||
tiles and geocoding later.
|
||||
- **Catalog `remote_identity` table:** (photo_id, service, account_id, remote_id, remote_checksum, remote_updated_at,
|
||||
last_synced_at, sync_state). Indexed both ways. This is where Immich links live.
|
||||
- **Checksums at import:**
|
||||
- compute **SHA-1** of each original, which is what Immich stores for its assets;
|
||||
- compute it with the existing content hash in the same read pass;
|
||||
- a background job back-fills SHA-1 for existing catalogs.
|
||||
|
||||
---
|
||||
|
||||
## 1.8 Immich: connect, link, import (≈ 8–12 h)
|
||||
|
||||
New crate **`immich`** (L3, depends on `net`, `catalog`): a typed client for the endpoints listed in `plan/immich.md`,
|
||||
written by us from the public API docs. A version check refuses servers older than the minimum version, with a clear
|
||||
message.
|
||||
|
||||
1. **IMM-CONNECT:**
|
||||
- Preferences → Connections → Add Immich server: URL, API key (with a link to the server's API-key page), Test
|
||||
button;
|
||||
- shows server version, user and the key's permissions;
|
||||
- more than one server/account allowed;
|
||||
- commands `immich.connect`, `immich.disconnect`, `immich.status`.
|
||||
2. **IMM-LINK:**
|
||||
- a background job lists the server's assets (paged, incremental by `updatedAt`);
|
||||
- matches them to catalog photos by **SHA-1 checksum**; the fallback is original filename + capture time + file
|
||||
size, marked "probable" so the user can confirm;
|
||||
- writes `remote_identity` rows;
|
||||
- results:
|
||||
- grid badge "in Immich";
|
||||
- Library filter "Immich: linked / not linked / probable";
|
||||
- Metadata panel field with an "Open in Immich" link to the asset's web URL;
|
||||
- a smart-collection criterion.
|
||||
3. **IMM-IMPORT:**
|
||||
- the Import dialog gets an **Immich** source: browse the timeline, albums, people and favourites as thumbnails
|
||||
loaded from the server;
|
||||
- choose **Copy** (download originals into a dated destination folder, then import normally) or **Link only**
|
||||
(catalog entry with remote preview; the original is downloaded on first Develop);
|
||||
- Immich rating, favourite, description, tags, albums and GPS are taken into catalog metadata on import (mapping as
|
||||
in Phase 4 IMM-SYNC; one-way at this stage);
|
||||
- duplicates are skipped by checksum.
|
||||
4. **IMM-EXTLIB** (shared originals, the recommended setup for self-hosters):
|
||||
- when Immich indexes the same folders as an *external library*, the link uses the checksum, never an upload;
|
||||
- a setup helper shows which catalog folders are covered by which Immich external library (path mapping table for
|
||||
container paths such as `/mnt/photos` ↔ `/home/me/Photos`);
|
||||
- writes XMP sidecars so Immich picks up ratings, descriptions and keywords on its next scan. Phase 4 adds direct
|
||||
API sync.
|
||||
5. **Tests:**
|
||||
- unit tests against recorded HTTP fixtures;
|
||||
- integration tests against `cargo xtask immich up`, run as a nightly CI job rather than on every PR, because it
|
||||
needs Docker;
|
||||
- error paths: offline server, bad key, TLS mismatch and HTTP 5xx. None may crash; each must give a clear error and
|
||||
a retry option.
|
||||
|
||||
---
|
||||
|
||||
## Exit gate
|
||||
|
||||
- [ ] Engine split; layering check green; command ids unchanged.
|
||||
- [ ] All seven modules in the picker (Library and Develop complete, others placeholders); panel framework, screen
|
||||
modes and secondary window done; Classic keymap default; shortcut editor.
|
||||
- [ ] Library Classic panels ✅ in tracker (Navigator, Catalog, Folders, Collections, Quick Develop, Keywording,
|
||||
Keyword List, Metadata, Painter).
|
||||
- [ ] Catalog v4: 500k under 2 s open, under 100 ms filter; backup and optimise; multiple catalogs; export/import
|
||||
catalog.
|
||||
- [ ] 1:1 zoom pixel-equal to export.
|
||||
- [ ] `net` and `credentials` in place; IMM-CONNECT, IMM-LINK, IMM-IMPORT and IMM-EXTLIB ✅ against the pinned Immich
|
||||
test server.
|
||||
@@ -0,0 +1,231 @@
|
||||
# Phase 2: Image quality and camera coverage
|
||||
|
||||
Part of [PLAN.md](PLAN.md). **Estimate:** 110–180 agent-hours, partly data collection. The project is MIT, so
|
||||
all camera data is measured or reverse-engineered by us (PLAN.md §4). **Depends on:** Phase 1 (engine split and the 1:1 region renderer).
|
||||
**Runs in parallel with:** Phases 3 and 4.
|
||||
|
||||
**Goal:** a photographer opening their own raws sees **correct colour**, **correct lens geometry** and
|
||||
**Lightroom-grade detail**, on nearly every camera they own. This is the gap that decides whether people switch.
|
||||
|
||||
Clean-room rules hold throughout (PLAN.md §4.2):
|
||||
- No Adobe matrices, DCP, LCP, presets or profiles.
|
||||
- No darktable, Ansel, rawspeed, LibRaw or lensfun source or GPL data tables.
|
||||
- Each data source is recorded in `docs/sources.md` with its licence and how it was obtained.
|
||||
|
||||
---
|
||||
|
||||
## 2.1 `camdb` crate: per-camera data (≈ 15–30 h)
|
||||
|
||||
**Contents per camera model** (keyed by make, model and the EXIF unique model id):
|
||||
- colour matrices for two illuminants;
|
||||
- forward matrices when known;
|
||||
- white-balance presets;
|
||||
- noise profile (Poisson-Gaussian a/b per ISO per channel);
|
||||
- black and white levels;
|
||||
- default crop and masked areas;
|
||||
- CFA layout;
|
||||
- aliases (marketing names, regional variants).
|
||||
|
||||
**Format:** a compact binary table generated at build time from human-editable TOML in `data/cameras/`, lazily
|
||||
loaded. One file per maker.
|
||||
|
||||
**Sources, in priority order:**
|
||||
1. **Data the file carries itself:**
|
||||
- DNG ColorMatrix/ForwardMatrix at runtime;
|
||||
- Olympus and Pentax maker-note matrices;
|
||||
- **camera-native DNGs** (Leica, Pentax, Ricoh, Hasselblad, phones; checked via the `Software` tag, never from
|
||||
Adobe-converted DNGs) can seed `camdb` entries.
|
||||
2. **Our own chart calibration** (2.2). This is the main source of "measured" entries.
|
||||
3. **Measured by our tools from raw files:**
|
||||
- **WB presets** read from each maker's own maker notes (many bodies store multipliers for every preset), via
|
||||
`xtask camdb-harvest` over the CC0 corpus;
|
||||
- **black and white levels, crops, CFA layout** from the files and our decoders;
|
||||
- **noise profiles**:
|
||||
- `app-cli noise-profile` fits a Poisson-Gaussian model (a, b per channel) from an ISO series of a defocused
|
||||
flat target;
|
||||
- the same model can also be estimated automatically from flat regions of ordinary corpus raws, as a fallback.
|
||||
4. **LightCraft's per-file embedded-JPEG fit**, which already exists. It is the fallback when nothing above has the
|
||||
model, and is marked "estimated" in the Info panel.
|
||||
|
||||
Entries record provenance (`source = "chart" | "native-dng" | "maker-note" | "estimated"`) and the contributor.
|
||||
|
||||
**Pipeline use:**
|
||||
- the input transform chooses the matrix by illuminant (interpolating by CCT between the two);
|
||||
- WB presets fill the white-balance menu;
|
||||
- noise profiles feed 2.6.
|
||||
|
||||
**Done when:** every corpus camera resolves to a `camdb` entry or an explicit "estimated"; no camera falls back to
|
||||
the neutral matrix silently.
|
||||
|
||||
---
|
||||
|
||||
## 2.2 Own camera calibration tool (≈ 8–12 h)
|
||||
|
||||
- `app-cli calibrate` takes a chart shot (ColorChecker 24 in its 2000 and 2014 versions, ColorChecker SG, IT8.7/2),
|
||||
either auto-detected or with corners picked by the user.
|
||||
- It fits a 3×3 matrix (least squares on ΔE2000) and an optional 2.5D hue/sat LUT (thin-plate spline, the method
|
||||
darktable manual describes; implemented from that description, not from its code).
|
||||
- Output is a TOML `camdb` entry or a user camera profile.
|
||||
- Reference chart values come from published CIE/manufacturer data. CIE observer and illuminant tables are public
|
||||
data.
|
||||
- A UI wizard in Develop → Profile → Calibrate Camera… gives users their own profiles.
|
||||
- **Community data pipeline.** This is on the critical path, because there are no GPL tables to fall back on:
|
||||
- a documented kit: which chart, lighting, ISO series, flat field and lens grid;
|
||||
- `app-cli contribute` packages the shots plus EXIF and a CC0 licence statement;
|
||||
- an upload target (Git LFS repo or object store);
|
||||
- a CI job re-fits submitted data and opens a PR with the new `camdb`/`lensdb` entries.
|
||||
- **Priority:** the 50 most-used bodies, from public usage statistics, get a chart shot even if the maintainers have
|
||||
to buy or rent time on them.
|
||||
|
||||
---
|
||||
|
||||
## 2.3 Camera-matching profiles (≈ 6–10 h)
|
||||
|
||||
- For each supported maker, generate "Camera Standard / Portrait / Landscape / Neutral / Vivid"-style **looks** by
|
||||
fitting our pipeline to the camera's own JPEGs: tone curve plus a 3D LUT in our profile format.
|
||||
- Use LightCraft's existing fitter (`docs/camera-preview-colour.md`) on many shots per body, not per file.
|
||||
- Store them as our own profiles in `data/profiles/<maker>/`, and list them in the profile browser under "Camera
|
||||
Matching".
|
||||
|
||||
---
|
||||
|
||||
## 2.4 Raw decoders: coverage (≈ 25–60 h)
|
||||
|
||||
| Item | Today | Work |
|
||||
|---|---|---|
|
||||
| Canon CR3: all CRX variants, sRAW/mRAW, dual-pixel | lossless and C-RAW verified on M50/R100/R8 only | every CRX variant and subsampled RAW; verify on ≥ 20 bodies |
|
||||
| Olympus/OM compressed ORF | preview only | decoder |
|
||||
| Samsung compressed SRW | preview only | decoder |
|
||||
| Nikon lossy-after-split NEF | preview only | decoder; also NEFs labelled compressed but stored uncompressed |
|
||||
| Long tail | none | 3FR/FFF (Hasselblad), IIQ (Phase One), MOS (Leaf), ERF (Epson), KDC/DCR (Kodak), MRW (Minolta), NRW, SRF/SR2, X3F if feasible |
|
||||
| HEIC/HEIF, AVIF import | HEIC behind a feature flag | default-on HEIC decode (pure-Rust HEVC, shared `heif` crate), AVIF decode |
|
||||
|
||||
- **Method: clean-room only.** Use LightCraft's black-box analysis (as for compressed NEF and RW2) and prose format
|
||||
descriptions. Never LibRaw, rawspeed or dcraw source. Each decoder documents its analysis in the module docs, as
|
||||
`vendor/rw2.rs` does.
|
||||
- **Risk:** LightCraft rates this "high, 40–80 h". The budget covers the common formats first; the long tail falls back
|
||||
to embedded previews until done.
|
||||
- **Corpus:**
|
||||
- one CC0 sample per model from raw.pixls.us in `cargo xtask corpus`;
|
||||
- `crates/raw/tests/corpus.rs` checks per model: sensor decode (not preview), plausible levels, colour sanity
|
||||
against the camera JPEG (median ΔE threshold);
|
||||
- a coverage report in `docs/cameras.md` is generated by xtask.
|
||||
|
||||
---
|
||||
|
||||
## 2.5 `lensdb` crate: lens corrections (≈ 10–18 h)
|
||||
|
||||
- **lensfun database** (CC-BY-SA 3.0, ~1,500 lenses), used as **data only**. lensfun's LGPL library code is never read;
|
||||
the correction models are implemented from their published mathematical descriptions.
|
||||
- Shipped as a **separate data file**, not compiled into the binary, so the MIT code stays separate from the
|
||||
CC-BY-SA data. It is converted at packaging time into a compact, lazily loaded table (Ansel's notes measured about
|
||||
100 ms just to parse lensfun's XML).
|
||||
- It can also be downloaded or updated at runtime from upstream.
|
||||
- Attribution and the CC-BY-SA terms go in `ATTRIBUTION.md`; edits we make to that data stay CC-BY-SA in
|
||||
`data/lenses/` and are offered back upstream.
|
||||
- Our **own** measured profiles (below) are CC0 and kept in a separate file.
|
||||
- **Lookup:** EXIF lens id, lens model string, focal length and aperture, with crop-factor handling and mount
|
||||
filtering. Users can pick manually from make/model menus (the Lens Corrections → Profile UI).
|
||||
- **Models:**
|
||||
- distortion: poly3, poly5, PTLens;
|
||||
- TCA: linear, poly3;
|
||||
- vignetting: pa model.
|
||||
- Applied in the existing geometry stage with a single resample.
|
||||
- Amount sliders for distortion and vignetting.
|
||||
- **Our own profiles:** `app-cli lens-calibrate` with distortion from straight-line or grid shots, vignetting from flat
|
||||
fields and TCA from edges, writing `data/lenses/` entries. Users can save custom profiles.
|
||||
- **Done when:** a profile is found for ≥ 80% of lens EXIFs in the corpus; straight lines stay straight within 0.5 px
|
||||
on test charts.
|
||||
|
||||
---
|
||||
|
||||
## 2.6 Detail: noise reduction, sharpening, Enhance (≈ 12–25 h)
|
||||
|
||||
- **Profiled classical NR:**
|
||||
- use the `camdb` noise profiles (a variance-stabilising transform, then wavelet/non-local-means denoise in the
|
||||
stabilised domain);
|
||||
- keep the Luminance/Detail/Contrast and Color/Detail/Smoothness controls as they are;
|
||||
- run colour NR at half resolution on both CPU and GPU (an open LightCraft M5 item).
|
||||
- **AI Denoise:**
|
||||
- LightCraft has Bayer-only inference with **opt-in GPL weights** that the user downloads separately; they are never
|
||||
bundled with the MIT app. The goal is our own CC0/MIT weights.
|
||||
- Follow the **design described in Ansel's prose note** `doc/rawdenoiseai.md` (not its code): a small U-Net
|
||||
conditioned on a per-pixel sigma map from the noise profile, deterministic, no external runtime.
|
||||
- Extend it to X-Trans and linear DNG.
|
||||
- Output, as Lightroom does: a new DNG stacked with the original, plus Amount.
|
||||
- Weights: train our own on CC0 / own pairs (preferred), or choose a permissively licensed model. That is an owner
|
||||
decision shared with Phase 5.
|
||||
- **Raw Details:** better demosaic with reduced false colour and artifacts; offer the demosaic choice (AHD, PPG, and
|
||||
later RCD/AMaZE-class) as an "Enhance" output.
|
||||
- **Super Resolution (2×):** classical edge-directed upscaling first; an ML model later (Phase 5).
|
||||
- Sharpening keeps its masking preview (Alt-drag) at true 1:1, which Phase 1.6 makes possible.
|
||||
|
||||
---
|
||||
|
||||
## 2.7 Remove tool: content-aware (≈ 6–10 h)
|
||||
|
||||
- Port PhotoCraft:
|
||||
- `algo/src/inpaint.rs`: multi-scale Wexler EM with PatchMatch NNF and `best_offset`;
|
||||
- `algo/src/content_aware.rs`: restricted sampling area, rotation/scale/mirror adaptation, Poisson colour
|
||||
adaptation.
|
||||
- Put them in a new L2 **`inpaint`** crate and copy-rename them as LightCraft did with `heif`.
|
||||
- Make it resolution independent:
|
||||
- store strokes in normalised coordinates;
|
||||
- synthesise at preview scale for interaction and at full scale for export;
|
||||
- cache results per stroke hash.
|
||||
- **Spot pin editing:** drag the source, change the size, switch Remove/Heal/Clone, feather and opacity.
|
||||
- **Detect (dust spots):**
|
||||
- a "Visualize Spots" threshold already exists;
|
||||
- add automatic spot detection as a sensor-dust finder: same position across photos from one body, low-contrast
|
||||
blobs;
|
||||
- add sync of spots across a selection.
|
||||
|
||||
---
|
||||
|
||||
## 2.8 Render fidelity suite (≈ 8–14 h)
|
||||
|
||||
- **References:** renders made by the maintainer's own Lightroom Classic copy, from CC0 raws, with settings sweeps.
|
||||
- They are stored **locally only** (never committed) in `plan/observations/fidelity/`.
|
||||
- The reference pixels themselves are never shipped.
|
||||
- **Metrics:** ΔE2000 (mean, p95), SSIM, histogram distance, and highlight-clipping location per sweep step.
|
||||
- **Tuning targets, in order:**
|
||||
1. default rendering;
|
||||
2. exposure, highlights, shadows, whites, blacks;
|
||||
3. texture, clarity, dehaze;
|
||||
4. NR and sharpening;
|
||||
5. vignette and grain.
|
||||
- **Decide tone-curve placement.** LightCraft applies curves after display encoding; compare against reference
|
||||
behaviour and move the stage if needed, behind a process-version bump so old edits render unchanged.
|
||||
- **CI:** a small public subset compared against *our own* golden renders, which guards against regressions.
|
||||
|
||||
---
|
||||
|
||||
## 2.9 Soft proofing with real ICC (≈ 6–10 h)
|
||||
|
||||
- Port PhotoCraft's **`cms`**: pure-Rust ICC v2/v4 parse and write, LUT profiles including CMYK↔Lab, rendering
|
||||
intents, black point compensation, gamut check.
|
||||
- Replace or complement `moxcms` for output transforms.
|
||||
- Keep one CMS path, so the preview equals the export.
|
||||
- **Soft proof:**
|
||||
- choose any installed or added printer/paper ICC;
|
||||
- Perceptual or Relative intent;
|
||||
- **Simulate Paper & Ink** (paper white plus black point);
|
||||
- monitor and destination **gamut warnings**;
|
||||
- "Create Proof Copy" (a virtual copy).
|
||||
- Before/after shows proof against the original.
|
||||
- **Display profile:** read the OS monitor profile (ICC from the platform, via an isolated crate if FFI is needed) and
|
||||
render through it.
|
||||
- **Done when:** proofing through a known profile matches a reference transform within ΔE 1. Print (Phase 3) uses the
|
||||
same code.
|
||||
|
||||
---
|
||||
|
||||
## Exit gate
|
||||
|
||||
- [ ] ≥ 95% of corpus camera models decode from sensor data; coverage report generated.
|
||||
- [ ] Median ΔE2000 ≤ 2 against camera JPEGs (camera-matching profile) for supported bodies; no silent neutral-matrix
|
||||
fallback.
|
||||
- [ ] Lens profile found for ≥ 80% of corpus lens EXIFs.
|
||||
- [ ] Content-aware Remove ✅, spot editing ✅, dust detection ✅.
|
||||
- [ ] Fidelity suite runs; default rendering mean ΔE2000 ≤ 3 against the local references.
|
||||
- [ ] Soft proofing with printer ICC and Simulate Paper & Ink ✅.
|
||||
@@ -0,0 +1,189 @@
|
||||
# Phase 3: Output modules (Print, Map, Slideshow, Book, Web)
|
||||
|
||||
Part of [PLAN.md](PLAN.md). **Estimate:** 72–124 agent-hours. **Depends on:** Phase 1 (module system).
|
||||
Print also needs Phase 2.9 (`cms`); until then it uses the existing output-space transform.
|
||||
**Runs in parallel with:** Phases 2 and 4.
|
||||
|
||||
**Goal:** every Lightroom Classic output module works end to end, sharing one layout and text engine and one PDF
|
||||
writer.
|
||||
|
||||
Functional references: the darktable application's map, print, slideshow and tethering views, its web-gallery and
|
||||
photo-book exports, and its user manual. Use them as **running applications and documentation only**; their source
|
||||
is never read (MIT clean-room, PLAN.md §4.2).
|
||||
|
||||
---
|
||||
|
||||
## 3.1 Shared foundations (≈ 15–25 h)
|
||||
|
||||
| Crate | Layer | Contents |
|
||||
|---|---|---|
|
||||
| `text` | L1 | **Port of PhotoCraft `text`**: font database, parley shaping (complex scripts), skrifa, CJK/vertical, craft-fonts loading. Replaces LightCraft's ab_glyph watermark renderer, and the export watermarks use it too |
|
||||
| `pdf` | L1 | **New** PDF 1.7 writer: multi-page, TrueType/OpenType **subsetting and embedding**, images (JPEG passthrough, Flate 8/16-bit), ICC-based colour spaces and **OutputIntent**, page boxes (media/bleed/trim), vector paths for guides and crop marks, metadata. PhotoCraft's single-page `print_cmds.rs` writer is a starting point for crop and registration marks only |
|
||||
| `layout` | L3 | Page model shared by Print, Book, Slideshow and Web: page size, margins, bleed; **cells** (photo cell with fit/fill, zoom, pan and rotate; text cell; graphic cell); grids and packages; guides; snapping; **templates** (serde, user-saveable, built-ins we design); token text (`{Title}`, `{Caption}`, `{Filename}`, `{Date}`, `{Exposure}`, …, shared with the export naming templates) |
|
||||
| `ui` widgets | L5 | Page canvas with zoom, rulers, guides, cell handles and drag-and-drop from the filmstrip; template browser with live previews; text style panel |
|
||||
|
||||
- A **"saved creation"** catalog object type (Saved Print / Book / Slideshow / Web Gallery) is stored as a special
|
||||
collection with the layout document attached, so it appears in the Collections panel exactly as in Classic.
|
||||
- All of it is reachable as commands (`print.*`, `book.*`, …) for MCP and CLI. `app-cli print --template …
|
||||
--collection …` renders headless.
|
||||
|
||||
---
|
||||
|
||||
## 3.2 Print module (≈ 14–22 h)
|
||||
|
||||
- **Layout styles:**
|
||||
- Single Image / Contact Sheet (grid: rows, columns, spacing, cell size, keep square);
|
||||
- Picture Package (fixed cell sizes, auto layout, new page);
|
||||
- Custom Package (free cells, anchor, lock to photo aspect ratio).
|
||||
- **Image settings:** zoom to fill, rotate to fit, repeat one photo per page, stroke border, inner stroke.
|
||||
- **Guides:** rulers, page bleed, margins and gutters, image cells, dimensions.
|
||||
- **Page:**
|
||||
- page background colour;
|
||||
- identity plate (with rotation, opacity, render on every image);
|
||||
- watermark (shared with export);
|
||||
- page options (page numbers, page info, crop marks);
|
||||
- photo info tokens;
|
||||
- font size.
|
||||
- **Print job:**
|
||||
- to printer, JPEG file or **PDF file**;
|
||||
- draft mode (uses previews);
|
||||
- print resolution;
|
||||
- print sharpening (Low/Standard/High × Matte/Glossy);
|
||||
- 16-bit output;
|
||||
- colour management: printer-managed or a chosen ICC profile with Perceptual/Relative intent and BPC (`cms`), plus
|
||||
a Print Adjustment brightness/contrast compensation.
|
||||
- **OS printing:**
|
||||
- Linux and macOS: **IPP to CUPS** in pure Rust. Send the rendered PDF or raster; query printer media sizes and
|
||||
margins for the page-setup dialog.
|
||||
- Windows: the Win32 print spooler in an isolated `platform-print` crate (the only `unsafe` here, documented in
|
||||
AGENTS.md as `sysmem` is). PDF/JPEG output always works.
|
||||
- Page Setup, Print, Print One; templates (built-ins and user); saved prints.
|
||||
|
||||
**Done when:**
|
||||
- A 2×2 contact sheet, a picture package and a custom layout print to PDF with embedded fonts and the correct ICC
|
||||
OutputIntent.
|
||||
- A CUPS test printer (`cups-pdf` in CI) receives the job.
|
||||
- Measured colour through a profile is within ΔE 1 of the soft proof.
|
||||
|
||||
---
|
||||
|
||||
## 3.3 Map module (≈ 12–20 h)
|
||||
|
||||
- **New crate `geo`** (L3):
|
||||
- Web-Mercator tiles: fetch through `net`, disk cache with size limit and expiry;
|
||||
- **configurable tile servers**: OpenStreetMap default, respecting its tile usage policy (User-Agent from `brand::`,
|
||||
attribution shown, no bulk prefetch), plus user-added XYZ servers, satellite and terrain;
|
||||
- clustering (grid-based, zoom-dependent);
|
||||
- GPX/KML/GeoJSON track parsing (GPX already exists);
|
||||
- saved locations (centre, radius, private flag).
|
||||
- **Map view (egui):**
|
||||
- smooth zoom/pan, style picker, search box (geocoding);
|
||||
- pins and clusters with hover previews;
|
||||
- multi-select;
|
||||
- **drag photos from the filmstrip onto the map to geotag**, and drag pins to move them.
|
||||
- **Tracklogs:**
|
||||
- load, show and choose tracks;
|
||||
- time-zone offset;
|
||||
- **Auto-tag selected photos**; set the offset by matching one photo to a known position.
|
||||
- **Saved Locations panel:** create from the map, radius circle, **private** flag (GPS stripped on export of photos
|
||||
inside it), photo counts.
|
||||
- **Location filter bar:** visible on map, tagged, untagged, by saved location.
|
||||
- **Reverse geocoding** (fills Sublocation/City/State/Country/ISO code):
|
||||
- opt-in, with a choice of provider: an online Nominatim-compatible endpoint with rate limits, or an **offline
|
||||
GeoNames** cities dataset (CC-BY 4.0), downloaded on request and attributed;
|
||||
- never sends anything without consent.
|
||||
- If Immich is connected, an option shows Immich-only assets (linked but not in the catalog) as ghost pins. Off by
|
||||
default.
|
||||
|
||||
---
|
||||
|
||||
## 3.4 Slideshow module (≈ 10–16 h)
|
||||
|
||||
- **Options:** zoom to fill, stroke border, cast shadow (opacity, offset, radius, angle).
|
||||
- **Layout:** guides, margins (linked or independent), aspect preview (screen, 16:9, 4:3).
|
||||
- **Overlays:** identity plate, rating stars, watermark, text overlays with tokens (positioned and anchored to the
|
||||
photo), shadow on text.
|
||||
- **Backdrop:** colour wash with angle, background image with opacity, background colour.
|
||||
- **Titles:** intro and ending screens with colour and identity plate.
|
||||
- **Playback:**
|
||||
- manual or automatic; slide and fade durations;
|
||||
- colour fade; random order; repeat;
|
||||
- **pan and zoom** (Ken Burns, amount);
|
||||
- quality (draft or full).
|
||||
- **Music:**
|
||||
- several tracks;
|
||||
- **fit slide durations to the music**;
|
||||
- volume and balance.
|
||||
- Decode with `symphonia` (pure Rust: MP3, AAC, FLAC, Vorbis, WAV). Output with `cpal`; on Linux this links the
|
||||
system ALSA library, so accept that and document it, or add a pure-Rust PipeWire/Pulse backend later.
|
||||
- **Export:**
|
||||
- PDF slideshow;
|
||||
- JPEG sequence;
|
||||
- **video**: AV1 in MP4/WebM with pure-Rust `rav1e` encode and Opus/FLAC audio.
|
||||
- H.264 MP4 depends on the Phase 5 video decision, where an OS encoder is an option.
|
||||
- Templates and saved slideshows; preview in the content area; full-screen play on the main or secondary display.
|
||||
|
||||
---
|
||||
|
||||
## 3.5 Book module (≈ 12–20 h)
|
||||
|
||||
- **Book settings:**
|
||||
- book types **PDF** and **JPEG** (Blurb ordering is a proprietary service and out of scope);
|
||||
- sizes (small square, standard landscape/portrait, large landscape, letter, A4, custom);
|
||||
- cover type (hardcover, softcover, none);
|
||||
- paper type as a note;
|
||||
- JPEG quality, colour profile, file resolution, sharpening.
|
||||
- **Auto Layout:** presets (one photo per page, left blank, with text, fill), "Auto Layout" and "Clear Layout".
|
||||
- **Page:** page templates by photo count (1–4+, with or without text); favourite templates; add page or blank; page
|
||||
numbers (position, display, apply to all).
|
||||
- **Guides:** page bleed, text safe area, photo cells, filler text.
|
||||
- **Cells:** padding (linked or per side), zoom and pan inside the cell, swap photos by dragging.
|
||||
- **Text:**
|
||||
- photo text (tokens or custom, offset);
|
||||
- page text;
|
||||
- **Type panel**: font, style, size, opacity, colour, tracking, baseline, leading, kerning, columns, gutter,
|
||||
alignment;
|
||||
- text style presets.
|
||||
- Uses the `text` crate.
|
||||
- **Background:** apply to all pages, graphic or photo with opacity, colour.
|
||||
- **Views:** multi-page, spread, single page, zoomed page.
|
||||
- **Export:** a PDF with embedded fonts and ICC (one file, cover plus pages, spreads as single pages) or a JPEG per
|
||||
page.
|
||||
- Saved books.
|
||||
|
||||
---
|
||||
|
||||
## 3.6 Web module (≈ 8–14 h)
|
||||
|
||||
- **Gallery templates** we write ourselves: grid, square, track and a single-image viewer. They are static
|
||||
HTML/CSS/minimal JS with no external CDN, responsive, with keyboard navigation and accessible alt text from the
|
||||
captions.
|
||||
- **Panels:**
|
||||
- Site Info: title, collection title and description, contact, web or mail link;
|
||||
- Colour Palette;
|
||||
- Appearance: grid size, cell numbers, photo borders, image page size;
|
||||
- Image Info: title and caption tokens;
|
||||
- Output Settings: large image size, quality, metadata (all / copyright only), watermark, sharpening.
|
||||
- **Preview:** live in-app preview of the rendered HTML.
|
||||
- **Output:** export to a folder, or **upload** via **SFTP** (pure-Rust `russh`/`russh-sftp`); plain FTP is optional
|
||||
and off by default. Upload server presets with credentials in the keychain (Phase 1.7).
|
||||
- **IMM-SHARELINK:** as an alternative output, "Share via Immich":
|
||||
- upload the gallery's photos to an Immich album (Phase 4 IMM-PUBLISH);
|
||||
- create an **Immich shared link** with expiry, password, allow-download and show-metadata options;
|
||||
- copy the URL.
|
||||
- The same action is offered in Slideshow (share the selection) and in the Publish Services context menu.
|
||||
- Saved web galleries.
|
||||
|
||||
---
|
||||
|
||||
## Exit gate
|
||||
|
||||
- [ ] `text`, `pdf` and `layout` crates done, with golden PDF tests (rasterised compare) and font-embedding checks.
|
||||
- [ ] Print: all three layout styles, colour-managed PDF/JPEG/printer output; CUPS CI job green.
|
||||
- [ ] Map: tiles, pins/clusters, drag-to-geotag, tracklogs, saved locations with privacy, location filter, opt-in
|
||||
reverse geocoding.
|
||||
- [ ] Slideshow: templates, overlays, music sync, PDF/JPEG/AV1 video export.
|
||||
- [ ] Book: auto layout, templates, text, PDF/JPEG export.
|
||||
- [ ] Web: templates, live preview, folder and SFTP output; Immich shared link (IMM-SHARELINK ✅).
|
||||
- [ ] Saved creations appear in Collections; every module scriptable via MCP.
|
||||
- [ ] All LRC-PRINT/MAP/SS/BOOK/WEB tracker rows ✅.
|
||||
@@ -0,0 +1,217 @@
|
||||
# Phase 4: Workflow power features and full Immich integration
|
||||
|
||||
Part of [PLAN.md](PLAN.md). **Estimate:** 60–105 agent-hours (including about 20–35 h of Immich work).
|
||||
**Depends on:** Phase 1 (catalog v4, `net`, `credentials`, the `immich` crate and links).
|
||||
**Runs in parallel with:** Phases 2 and 3.
|
||||
|
||||
**Goal:**
|
||||
- the workflow features power users rely on: tethering, publish services, plugins, round trips, actions, people and
|
||||
catalog migration;
|
||||
- **two-way integration with Immich**, so the desktop catalog and a self-hosted Immich server stay one library.
|
||||
|
||||
---
|
||||
|
||||
## 4.1 Tethered capture (≈ 10–16 h)
|
||||
|
||||
1. **Studio capture first** (the design described in Ansel's prose note `doc/studio-capture.md`): watch a folder written by any vendor tool or the camera's own app;
|
||||
auto-import with:
|
||||
- a develop preset;
|
||||
- a metadata preset and keywords;
|
||||
- a session name and naming template;
|
||||
- a target collection.
|
||||
The newest shot shows in the loupe automatically.
|
||||
2. **Native tethering**, new crate `tether`:
|
||||
- PTP over USB with `nusb` (pure Rust), plus PTP/IP over Wi-Fi;
|
||||
- vendor extensions for Canon, Nikon, Sony and Fujifilm, in that order;
|
||||
- capture, download-on-capture, delete-from-card option.
|
||||
3. **Tether bar** (as in Classic): camera name, shutter, aperture, ISO and WB readouts and settings; capture button;
|
||||
develop settings "same as previous"; **live view** where the camera supports it.
|
||||
4. Platform notes:
|
||||
- Linux needs a udev rule (shipped in packages).
|
||||
- macOS: release the camera from `ptpcamerad`.
|
||||
- Windows: WinUSB driver notes.
|
||||
- Unsupported cameras fall back to studio capture with a clear message.
|
||||
|
||||
**Done when:** studio capture works with any camera; native capture is verified on at least one Canon, Nikon and Sony
|
||||
body; CI uses a simulated PTP device.
|
||||
|
||||
---
|
||||
|
||||
## 4.2 Publish services framework (≈ 8–12 h)
|
||||
|
||||
- **Model:**
|
||||
- a *publish service* (type plus account plus export settings) contains **published collections** and sets;
|
||||
- each photo has a state: *new to publish*, *modified to re-publish*, *published*, *deleted to remove*;
|
||||
- remote ids are stored in `remote_identity` (Phase 1.7);
|
||||
- the modified state comes from the develop/metadata change feed (the catalog op log).
|
||||
- **UI:**
|
||||
- Publish Services panel in Library;
|
||||
- the Publish button;
|
||||
- the grid split into New / Modified / Published sections;
|
||||
- "Mark as Up-to-date";
|
||||
- remote comments and likes where the service supports them.
|
||||
- This closes LRC-LIB-COMMENTS for services that have them.
|
||||
- **Built-in services:**
|
||||
- **Hard Drive** (export to a folder kept in sync);
|
||||
- **Immich** (4.6);
|
||||
- **SFTP** (reuses Phase 3.6).
|
||||
- Other services (Flickr, SmugMug, Piwigo, Nextcloud, PhotoPrism, WebDAV) come as **plugins** (4.3).
|
||||
|
||||
---
|
||||
|
||||
## 4.3 Plugin SDK (≈ 10–16 h)
|
||||
|
||||
- **Sandbox:** port PhotoCraft's `plugins` crate (`wasmi`, no host imports by default, fuel and memory limits).
|
||||
- **Host API, versioned and capability-gated:**
|
||||
- catalog query (read);
|
||||
- metadata read/write in a plugin-owned namespace, plus standard fields with permission;
|
||||
- **export post-process hooks**;
|
||||
- **publish service provider interface** (the same trait the built-in services implement);
|
||||
- metadata/keyword providers;
|
||||
- menu commands that register as engine commands;
|
||||
- simple dialogs from a declarative UI description.
|
||||
- **Permissions:**
|
||||
- network per host;
|
||||
- filesystem roots;
|
||||
- granted on install and revocable in the Plugin Manager.
|
||||
- **Plugin Manager:** install, enable, disable, update, view logs.
|
||||
- **SDK:** Rust crate plus template, docs and two example plugins (a WebDAV publisher and a metadata reverse-lookup).
|
||||
- Lightroom's Lua plugins are not supported, and no promise is made to support them.
|
||||
|
||||
---
|
||||
|
||||
## 4.4 Edit In and the PhotoCraft round trip (≈ 5–8 h)
|
||||
|
||||
- External editor presets (app, file format, colour space, bit depth, resolution, compression, stack with original,
|
||||
naming).
|
||||
- **PhotoCraft integration:**
|
||||
- "Edit in PhotoCraft" sends TIFF or **layered PSD** using PhotoCraft's `psd` writer (ported). "Open as Layers"
|
||||
builds a layered PSD from the selection.
|
||||
- When the file is saved, it is re-imported and stacked with the original; the edit-in round trip keeps metadata.
|
||||
- If PhotoCraft runs with `--control`, the two apps can talk directly over the control channel: open, and notify on
|
||||
save.
|
||||
- PSD/PSB **import** uses the PhotoCraft `psd` reader (flattened composite plus layer metadata).
|
||||
|
||||
---
|
||||
|
||||
## 4.5 Actions, people, migration (≈ 10–18 h)
|
||||
|
||||
- **Actions:**
|
||||
- record a sequence of engine commands (built on PhotoCraft's `actions_cmds` design and our journal);
|
||||
- parameterise it, save it, assign a shortcut, run it on a selection;
|
||||
- also exposed through MCP and the CLI (`app-cli run-action`).
|
||||
- **People:**
|
||||
- face clusters with separators and confidence;
|
||||
- merge and split;
|
||||
- small-face detection on large images (tiled detection beyond 640 px);
|
||||
- **write face regions to XMP** (MWG Regions) and read them back;
|
||||
- person keywords.
|
||||
- **`.lrcat` migration v2:**
|
||||
- replay develop history and snapshots;
|
||||
- a profile mapping table (Adobe profile names → our camera-matching/creative profiles from Phase 2.3);
|
||||
- masks (brush, linear, radial, range) and spot/heal;
|
||||
- a per-photo **fidelity report** listing what could not be mapped;
|
||||
- publish-service collections imported as plain collections.
|
||||
- Mapping is derived from the published XMP `crs:` field descriptions and from our own measurements: apply known
|
||||
values in Lightroom, export, compare (the local fidelity references of Phase 2.8). darktable's importer is
|
||||
**not** read (MIT clean-room).
|
||||
|
||||
---
|
||||
|
||||
## 4.6 Immich: full two-way integration (≈ 20–35 h)
|
||||
|
||||
Builds on Phase 1.8: connection, link by checksum, import, shared originals. Every Immich action is a command
|
||||
(`immich.*`), so it works from the UI, CLI, control channel and MCP (**IMM-MCP**).
|
||||
|
||||
### 4.6.1 IMM-PUBLISH: Immich as a publish service
|
||||
|
||||
- An **"Immich" publish service** per connected account. Published collections map 1:1 to **Immich albums** (created
|
||||
if missing, renamed if the collection is renamed). Collection sets map to album name prefixes.
|
||||
- **What is sent**, per service:
|
||||
1. **Rendered** JPEG/AVIF/JXL using the export settings, so Immich shows the edited look; or
|
||||
2. **Original** (plus XMP sidecar); or
|
||||
3. **Both, stacked in Immich**, with the render on top and the original underneath, using Immich's stack feature.
|
||||
- **Upload:**
|
||||
- streaming multipart upload with progress and resume-on-failure (via `net`);
|
||||
- a stable device asset id (catalog photo id plus virtual-copy id);
|
||||
- **checksum pre-check before upload**, so Immich never stores a duplicate; already-present assets are just linked
|
||||
and added to the album.
|
||||
- **Re-publish:** edits mark photos *modified*; re-publishing replaces the rendered asset in place (keeping its id,
|
||||
albums and favourites where the API allows), otherwise uploads a new asset, moves album membership and trashes the
|
||||
old one.
|
||||
- **Removal:** removing from a published collection removes the photo from the album. Deleting the Immich asset is a
|
||||
separate, explicit option (default **off**); assets go to the Immich trash, never a hard delete.
|
||||
- **Shared-originals mode** (IMM-EXTLIB): for originals already in an Immich external library, publish only changes
|
||||
album membership and metadata; nothing is uploaded except renders, if chosen.
|
||||
|
||||
### 4.6.2 IMM-SYNC: two-way metadata sync
|
||||
|
||||
- **Per-field opt-in mapping**, with defaults:
|
||||
|
||||
| Catalog | Immich | Default |
|
||||
|---|---|---|
|
||||
| Star rating 0–5 | asset rating | two-way |
|
||||
| Pick flag | favourite | two-way (configurable: favourite ↔ pick, or favourite ↔ 5★, or off) |
|
||||
| Reject flag | archived | off (configurable) |
|
||||
| Title / Caption | description | two-way (caption ↔ description; title optional) |
|
||||
| Keywords (hierarchical) | tags (hierarchical, `A/B/C`) | two-way; export-excluded keywords never sent |
|
||||
| Collections | albums | two-way for linked collections only |
|
||||
| GPS, capture date/time | asset location and date | two-way, with a conflict prompt |
|
||||
| People names / face regions | people / faces | see IMM-PEOPLE |
|
||||
| Deleted photo | trashed asset | **never automatic**; a queue for the user to confirm |
|
||||
|
||||
- **Change detection:**
|
||||
- local changes come from the catalog op log since `last_synced_at`;
|
||||
- remote changes come from incremental queries (`updatedAfter` or the server's sync stream, depending on the pinned
|
||||
Immich API version in `plan/immich.md`).
|
||||
- **Conflicts** (both sides changed since the last sync):
|
||||
- a per-field policy: catalog wins, Immich wins, or newest wins (the default);
|
||||
- a Sync Conflicts view that shows both values for anything unresolved.
|
||||
- **Scheduling:** manual, on publish, or a background interval. It runs offline-safe: queued and retried with
|
||||
backoff, and it never blocks the UI.
|
||||
- Activity log entries for every sync run; a dry-run mode shows what would change.
|
||||
|
||||
### 4.6.3 IMM-PEOPLE
|
||||
|
||||
- Import Immich **people** (names, birthdates, hidden flag) and **face regions** (bounding boxes) for linked assets
|
||||
into the People view. They are marked "from Immich" and can be confirmed or corrected.
|
||||
- Push names assigned in the app back to Immich people (opt-in); merges map to Immich person merges.
|
||||
- This gives good face recognition **before Phase 5**, by reusing Immich's own machine learning.
|
||||
|
||||
### 4.6.4 IMM-SEARCH
|
||||
|
||||
- The Library filter bar and the Text filter get an **"Immich smart search"** mode: a natural-language query (Immich's
|
||||
CLIP search) returns asset ids, which are mapped to linked catalog photos. The results show as a temporary
|
||||
collection that can be saved as a collection.
|
||||
- Also: metadata search (OCR text, objects, places) when the server supports it.
|
||||
- This stands in for local AI search (FILT-SEARCH-AI) until Phase 5, and can coexist with it later.
|
||||
|
||||
### 4.6.5 Hardening and tests
|
||||
|
||||
- **Version policy:** support the pinned minimum Immich release and the latest release. A nightly CI job runs
|
||||
integration tests against both through `cargo xtask immich up --version …`.
|
||||
- **Scale:** sync of 100k assets completes, resumes after interruption and stays under 5% CPU when idle.
|
||||
- **Failure:**
|
||||
- server down, key revoked, permission missing, TLS change: each gives a clear error, nothing is lost, and work is
|
||||
queued;
|
||||
- never crash, as the never-crash standard requires; fuzz the JSON decoding of server responses.
|
||||
- **Privacy:** a "private" saved location (Phase 3.3) strips GPS from renders sent to Immich too. Uploads respect the
|
||||
export metadata policy.
|
||||
|
||||
---
|
||||
|
||||
## Exit gate
|
||||
|
||||
- [ ] Studio capture and native tethering (Canon/Nikon/Sony verified) ✅.
|
||||
- [ ] Publish framework with Hard Drive, SFTP and Immich services; plugin SDK with two example plugins.
|
||||
- [ ] Edit-in round trip with PhotoCraft (layered PSD) ✅; actions record and replay ✅.
|
||||
- [ ] People clusters and XMP face regions ✅; `.lrcat` v2 migration with fidelity report.
|
||||
- [ ] IMM-PUBLISH, IMM-SYNC, IMM-PEOPLE, IMM-SEARCH and IMM-MCP ✅ against the minimum and latest Immich releases.
|
||||
- [ ] Scripted end-to-end session through MCP, headless in CI:
|
||||
1. simulated tethered shoot;
|
||||
2. import preset;
|
||||
3. cull;
|
||||
4. develop;
|
||||
5. publish to Hard Drive, SFTP and Immich (album plus stacked original);
|
||||
6. edit the rating in Immich and see it sync back;
|
||||
7. print a contact sheet to PDF.
|
||||
@@ -1,51 +1,19 @@
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
|
||||
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
|
||||
</picture>
|
||||
</a>
|
||||
<img alt="" src="brand/logo.svg" width="120">
|
||||
</p>
|
||||
|
||||
|
||||
<h1 align="center">LightCraft</h1>
|
||||
|
||||
<h3 align="center">Your photos. Your pixels. Your machine.</h3>
|
||||
<h1 align="center">Your photos. Your pixels. Your machine.</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Photo library and raw development; an open-source, clean-room reimplementation of Adobe Lightroom, rebuilt in pure Rust.</b><br>
|
||||
Native on macOS, Windows and Linux. In the browser via WebAssembly. Drivable end to end by AI agents over MCP.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Pure Rust" src="https://img.shields.io/badge/pure-Rust-f2a516?style=flat-square&logo=rust&logoColor=white">
|
||||
<img alt="macOS, Windows, Linux and Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20Web-8a5800?style=flat-square">
|
||||
<img alt="MCP server included" src="https://img.shields.io/badge/MCP-ready-8a5800?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-8a5800?style=flat-square">
|
||||
<a href="ROADMAP.md"><img alt="Status: young and moving fast" src="https://img.shields.io/badge/status-young%20%26%20moving%20fast-f2a516?style=flat-square"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/apps/app"><b>LightCraft on getartcraft.com</b></a> ·
|
||||
<a href="https://getartcraft.com/">ArtCraft</a> ·
|
||||
<a href="https://getartcraft.com/apps">All Crafting Apps</a>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/hero-tetons.jpg" alt="LightCraft's Edit view with Ansel Adams' The Tetons and the Snake River in the loupe, the Light and Effects panels open on the right, and the four showcase photos in the filmstrip" width="100%">
|
||||
<br>
|
||||
<sub><i>Ansel Adams, "The Tetons and the Snake River" (1942). Public domain, U.S. National Archives. Developed in LightCraft.</i></sub>
|
||||
</p>
|
||||
|
||||
> [!NOTE]
|
||||
> **ArtCraft is a community of artists from all walks of life.** Digital, generative, music,
|
||||
> games — if you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).**
|
||||
> The product name, binary names, settings folder and environment-variable prefix are set in one place,
|
||||
> [`brand.toml`](brand.toml). This README calls the product "the app"; `<app>`, `<binary>`, `<cli>`, `<mcp_server>`
|
||||
> and `<settings_dir>` stand for `display_name`, `binary`, `cli_binary`, `mcp_server` and `settings_dir`, and
|
||||
> `<PREFIX>` for `env_prefix`, from that file.
|
||||
|
||||
<p align="center">
|
||||
<a href="#edit-like-you-mean-it">Editing</a> ·
|
||||
@@ -59,15 +27,14 @@
|
||||
<a href="#feature-status">Status</a> ·
|
||||
<a href="#quick-start">Quick start</a> ·
|
||||
<a href="ROADMAP.md">Roadmap</a> ·
|
||||
<a href="#downloads">Downloads</a> ·
|
||||
<a href="#the-crafting-apps">Crafting Apps</a>
|
||||
<a href="#downloads">Downloads</a>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
## Edit like you mean it
|
||||
|
||||
LightCraft is a complete darkroom in a single native app. Every adjustment is **non-destructive**, so your originals
|
||||
The app is a complete darkroom in a single native app. Every adjustment is **non-destructive**, so your originals
|
||||
are never touched. Every slider renders through a **scene-referred, wide-gamut, 32-bit float pipeline**: highlights
|
||||
roll off like film, shadows open up without halos, and colour stays clean from capture to export.
|
||||
|
||||
@@ -125,7 +92,7 @@ Split-tone shadows, midtones and highlights independently with drag-anywhere col
|
||||
## Before & after
|
||||
|
||||
Hold <kbd>\\</kbd> to peek at the original, press <kbd>Y</kbd> for side by side, or <kbd>Shift</kbd>+<kbd>Y</kbd> for a
|
||||
split view. Every image below is a real screenshot of LightCraft, captured automatically by an agent through the
|
||||
split view. Every image below is a real screenshot of the app, captured automatically by an agent through the
|
||||
[control channel](#built-for-agents).
|
||||
|
||||
<table>
|
||||
@@ -152,7 +119,7 @@ plus an overall Amount.
|
||||
<p align="center">
|
||||
<img src="docs/images/masking.jpg" alt="Masking panel with a linear Sky mask and a radial Sun glow mask; the radial gradient is drawn as a red overlay around the sun on a lake scene" width="100%">
|
||||
<br>
|
||||
<sub>A radial "Sun glow" mask (Temp +40, Exposure +0.50) layered over a linear "Sky" mask. <i>Photo from LightCraft's procedurally generated demo library.</i></sub>
|
||||
<sub>A radial "Sun glow" mask (Temp +40, Exposure +0.50) layered over a linear "Sky" mask. <i>Photo from the app's procedurally generated demo library.</i></sub>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
@@ -218,12 +185,12 @@ so they stay smooth whether you have forty photos or forty thousand.
|
||||
|
||||
## Built for agents
|
||||
|
||||
Every menu item, slider, brush stroke, crop handle and keystroke in LightCraft is a **command** with a stable id and
|
||||
Every menu item, slider, brush stroke, crop handle and keystroke in the app is a **command** with a stable id and
|
||||
JSON parameters. The UI, the keyboard, the CLI, a JSON-lines control channel and an **MCP server** all dispatch
|
||||
through the same entry point. An agent can cull a shoot, develop it, mask a sky and export it, and *see* the result.
|
||||
|
||||
```sh
|
||||
lightcraft --control 7980 ~/Pictures/trip
|
||||
<binary> --control 7980 ~/Pictures/trip
|
||||
```
|
||||
```jsonc
|
||||
{"method": "engine.execute", "params": {"command": "photo.flag", "params": {"flag": "pick"}}}
|
||||
@@ -236,18 +203,18 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
|
||||
- **Command registry.** `engine.commands` lists the available commands; `develop.controls` lists every slider's
|
||||
range, default and current value.
|
||||
- **MCP server.** `lightcraft-cli mcp` exposes the command registry to Claude (or any MCP client), alongside
|
||||
- **MCP server.** `<cli> mcp` exposes the command registry to Claude (or any MCP client), alongside
|
||||
helpers for import, query, develop, mask, render (returned as an image) and export. It runs headless, or attached
|
||||
to the running app with screenshots, clicks and gestures. See [docs/mcp.md](docs/mcp.md).
|
||||
|
||||
```sh
|
||||
cargo build --release -p dac-cli
|
||||
claude mcp add lightcraft -- "$PWD/target/release/lightcraft-cli" mcp ~/Pictures/shoot # headless
|
||||
claude mcp add lightcraft-app -- "$PWD/target/release/lightcraft-cli" mcp --connect 127.0.0.1:7980 # live app
|
||||
claude mcp add <mcp_server> -- "$PWD/target/release/app-cli" mcp ~/Pictures/shoot # headless
|
||||
claude mcp add <mcp_server>-app -- "$PWD/target/release/app-cli" mcp --connect 127.0.0.1:7980 # live app
|
||||
```
|
||||
- **Scriptable CLI:** `lightcraft-cli run --import in.dng develop.set control=light.exposure value=0.7 app.export
|
||||
- **Scriptable CLI:** `<cli> run --import in.dng develop.set control=light.exposure value=0.7 app.export
|
||||
path=out.jpg longEdge=2048` runs any chain of commands (headless, on a saved library, or against the running app)
|
||||
and prints one JSON result per command; `lightcraft-cli render in.dng -o out.jpg --set light.exposure=0.7 --preset …`.
|
||||
and prints one JSON result per command; `<cli> render in.dng -o out.jpg --set light.exposure=0.7 --preset …`.
|
||||
- **Undo for everything**, including agent actions: a slider drag (or a scripted burst of updates) is one undo step.
|
||||
- **Every widget is addressable** (`ui.widgets`) and clickable by name, so agents operate the real UI, not a
|
||||
side door.
|
||||
@@ -282,7 +249,7 @@ lightcraft --control 7980 ~/Pictures/trip
|
||||
|
||||
## Feature status
|
||||
|
||||
LightCraft is young and moving fast. **Where we honestly stand** (details in the [roadmap](ROADMAP.md#where-we-stand)):
|
||||
The app is young and moving fast. **Where we honestly stand** (details in the [roadmap](ROADMAP.md#where-we-stand)):
|
||||
|
||||
- **By feature count we're at ~79%** of Lightroom (core features 98%), tracked row by row in
|
||||
[docs/parity.md](docs/parity.md).
|
||||
@@ -326,16 +293,22 @@ LightCraft is young and moving fast. **Where we honestly stand** (details in the
|
||||
## Quick start
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/lightcraft && cd lightcraft
|
||||
cargo run --release -p dac-app # opens your library (~/Pictures/LightCraft Library; a new one starts with demo photos)
|
||||
cargo run --release -p dac-app -- ~/Pictures/trip # import your photos (folders are scanned, duplicates skipped)
|
||||
cargo run --release -p dac-app -- --memory # a throwaway in-memory demo session (writes nothing)
|
||||
cargo run --release -p dac-app -- --control 7980 # with the automation channel
|
||||
git clone https://example.invalid/repo && cd repo
|
||||
cargo xtask brand check # the product name lives only in brand.toml
|
||||
cargo xtask run # build and open your library (~/Pictures/<app> Library; a new one starts with demo photos)
|
||||
cargo xtask run -- ~/Pictures/trip # import your photos (folders are scanned, duplicates skipped)
|
||||
cargo xtask run -- --memory # a throwaway in-memory demo session (writes nothing)
|
||||
cargo xtask run -- --control 7980 # with the automation channel
|
||||
cargo xtask web --serve # the same app in the browser: http://127.0.0.1:8080/
|
||||
cargo run --release -p dac-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5
|
||||
cargo xtask package # installers and archives under the brand binary names
|
||||
cargo xtask install # install the app and CLI for the current user
|
||||
cargo xtask ci # fmt, clippy, tests, layering, wasm checks
|
||||
```
|
||||
|
||||
Inside `target/` the binaries are always called `app` and `app-cli`; `cargo xtask run`, `package` and `install`
|
||||
give them the brand names from `brand.toml`. `cargo run -p dac-app` / `-p dac-cli` also work for development.
|
||||
|
||||
CI defaults to line-table debug information and one build job per 1.5 GB of RAM
|
||||
available when it starts (at most one per CPU; 4 if memory can't be read), so an
|
||||
8 GB machine with 6 GB free builds with 4, and a busy machine with little free
|
||||
@@ -351,53 +324,53 @@ git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p dac-app
|
||||
```
|
||||
|
||||
Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
|
||||
Without it the app builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this
|
||||
repo; see [craftrules `standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
|
||||
|
||||
**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Deutsch) or **Settings → General →
|
||||
Language**; the choice applies immediately and persists. See [docs/localization.md](docs/localization.md).
|
||||
|
||||
**Logs:** the desktop app writes its log to standard error and to `logs/lightcraft.log` in its settings folder
|
||||
(Linux `$XDG_CONFIG_HOME/lightcraft/logs/`, by default `~/.config/lightcraft/logs/`; macOS
|
||||
`~/Library/Application Support/LightCraft/logs/`; Windows `%APPDATA%\LightCraft\logs\`), never in the library. A
|
||||
**Logs:** the desktop app writes its log to standard error and to `logs/<binary>.log` in its settings folder
|
||||
(Linux `$XDG_CONFIG_HOME/<settings_dir>/logs/`, by default `~/.config/<settings_dir>/logs/`; macOS
|
||||
`~/Library/Application Support/<settings_dir>/logs/`; Windows `%APPDATA%\<settings_dir>\logs\`), never in the library. A
|
||||
launch from a desktop menu or the Dock has no terminal, so attach this file to a bug report (**Help ▸ Open Log
|
||||
Folder** shows it in the file manager). Each start moves the
|
||||
previous log to `lightcraft.1.log` and that one to `lightcraft.2.log`, so the log of a run that crashed survives the
|
||||
previous log to `<binary>.1.log` and that one to `<binary>.2.log`, so the log of a run that crashed survives the
|
||||
next start; the file stops growing at 16 MiB, `--version` and `--help` write none, and runs with
|
||||
`LIGHTCRAFT_NO_PREFS` log to standard error only. By default LightCraft's own crates log at `info` and everything else
|
||||
at `warn`. `LIGHTCRAFT_LOG=info` or `debug` works as before (that level for LightCraft's own crates, warnings and
|
||||
`<PREFIX>_NO_PREFS` log to standard error only. By default the app's own crates log at `info` and everything else
|
||||
at `warn`. `<PREFIX>_LOG=info` or `debug` works as before (that level for the app's own crates, warnings and
|
||||
errors from the rest; any other value: warnings and errors only) and wins over `RUST_LOG`, which otherwise replaces
|
||||
the default with env_logger-style directives such as `RUST_LOG=debug` or `RUST_LOG=warn,dac_pipeline=trace` (a
|
||||
directive ending in `*` covers every target starting with it, as in `lightcraft*=debug`). Panics are recorded there
|
||||
too, and still in `lightcraft-panics.log` in the temp folder. The logger is `apps/app/src/logging.rs`.
|
||||
directive ending in `*` covers every target starting with it, as in `dac*=debug`). Panics are recorded there
|
||||
too, and still in `<binary>-panics.log` in the temp folder. The logger is `apps/app/src/logging.rs`.
|
||||
|
||||
The web build needs the `wasm32-unknown-unknown` target and the matching `wasm-bindgen` CLI
|
||||
(`cargo xtask web` prints the exact install command); see [docs/web.md](docs/web.md).
|
||||
|
||||
**Nix** builds the desktop app and `lightcraft-cli` (the Nix build always includes the craft-fonts input, so Japanese
|
||||
**Nix** builds the desktop app and `<cli>` (the Nix build always includes the craft-fonts input, so Japanese
|
||||
text has glyphs):
|
||||
|
||||
```sh
|
||||
nix run github:storytold/lightcraft # the desktop app
|
||||
nix build github:storytold/lightcraft # → ./result/bin/{lightcraft,lightcraft-cli}
|
||||
nix develop github:storytold/lightcraft # rust toolchain + native deps + fonts
|
||||
nix run <flake-url> # the desktop app
|
||||
nix build <flake-url> # → ./result/bin/{<binary>,<cli>}
|
||||
nix develop <flake-url> # rust toolchain + native deps + fonts
|
||||
```
|
||||
|
||||
In a flake configuration (NixOS, home-manager, nix-darwin):
|
||||
|
||||
```nix
|
||||
# flake.nix
|
||||
inputs.lightcraft.url = "github:storytold/lightcraft";
|
||||
# optional: build against your own nixpkgs instead of the one LightCraft pins
|
||||
# inputs.lightcraft.inputs.nixpkgs.follows = "nixpkgs";
|
||||
inputs.app.url = "<flake-url>";
|
||||
# optional: build against your own nixpkgs instead of the one the app pins
|
||||
# inputs.app.inputs.nixpkgs.follows = "nixpkgs";
|
||||
|
||||
# then, in a NixOS or home-manager module (where `inputs` is in scope):
|
||||
nixpkgs.overlays = [ inputs.lightcraft.overlays.default ]; # makes `pkgs.lightcraft` available
|
||||
environment.systemPackages = [ pkgs.lightcraft ]; # home-manager: home.packages = [ pkgs.lightcraft ];
|
||||
nixpkgs.overlays = [ inputs.app.overlays.default ]; # makes `pkgs.<binary>` available
|
||||
environment.systemPackages = [ pkgs.<binary> ]; # home-manager: home.packages = [ pkgs.<binary> ];
|
||||
```
|
||||
|
||||
`nix build` installs the same desktop file, hicolor icons and AppStream metadata as the .deb/.rpm, and runs
|
||||
`cargo test --workspace` as its check phase (skip it with `pkgs.lightcraft.overrideAttrs { doCheck = false; }`).
|
||||
`cargo test --workspace` as its check phase (skip it with `pkgs.<binary>.overrideAttrs { doCheck = false; }`).
|
||||
|
||||
**Keyboard:** <kbd>G</kbd> grid · <kbd>D</kbd> detail · <kbd>E</kbd> edit · <kbd>C</kbd> crop · <kbd>M</kbd> masking ·
|
||||
<kbd>Shift</kbd>+<kbd>P</kbd> presets · <kbd>\\</kbd> original · <kbd>Y</kbd> before/after · <kbd>Z</kbd> zoom ·
|
||||
@@ -407,8 +380,8 @@ environment.systemPackages = [ pkgs.lightcraft ]; # home-manager: home
|
||||
|
||||
An engine-first Cargo workspace of small, tested crates with enforced layering (`cargo xtask layers`): `geom`,
|
||||
`color`, `raster`, `tiff` → `raw`, `codecs`, `meta`, `develop` → `pipeline` → `catalog` → `engine` → `ui-egui`. The
|
||||
egui frontend is one swappable crate; nothing below it knows a UI exists. `mcp` and the apps (`lightcraft`,
|
||||
`lightcraft-cli`) sit on top of `engine`.
|
||||
egui frontend is one swappable crate; nothing below it knows a UI exists. `mcp` and the apps (`app`,
|
||||
`<cli>`) sit on top of `engine`.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -426,23 +399,21 @@ Humans and agents follow the same rules, so read [AGENTS.md](AGENTS.md) first. T
|
||||
`unsafe`, checked indexing on anything derived from input, and a regression test with every crash fix. Details in
|
||||
[AGENTS.md](AGENTS.md#never-crash-outranks-feature-work).
|
||||
|
||||
Questions, ideas or a bug you'd like to talk through first? Bring them to [Discord](https://discord.gg/artcraft).
|
||||
|
||||
<br>
|
||||
|
||||
## Downloads
|
||||
|
||||
**New to LightCraft?** Download it from the [LightCraft page on getartcraft.com](https://getartcraft.com/apps/app). That's the easiest way to install it.
|
||||
|
||||
**Want a specific build or format?** On GitHub, the [latest release](https://github.com/storytold/lightcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/lightcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
Release builds are listed on the project's releases page. `<ver>` in the file names is the version number,
|
||||
`<binary>`/`<cli>` are the brand binary names, and `SHA256SUMS.txt` lists a checksum for every file. Build them yourself
|
||||
with `cargo xtask package`.
|
||||
|
||||
### Windows
|
||||
|
||||
| Build | Installer | Portable |
|
||||
|---|---|---|
|
||||
| x64 (64-bit Intel/AMD) | `lightcraft-<ver>-windows-x64.msi` | `lightcraft-<ver>-windows-x64-portable.zip` |
|
||||
| arm64 (Snapdragon and other ARM PCs) | `lightcraft-<ver>-windows-arm64.msi` | `lightcraft-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `lightcraft-<ver>-windows-x86.msi` | `lightcraft-<ver>-windows-x86-portable.zip` |
|
||||
| x64 (64-bit Intel/AMD) | `<binary>-<ver>-windows-x64.msi` | `<binary>-<ver>-windows-x64-portable.zip` |
|
||||
| arm64 (Snapdragon and other ARM PCs) | `<binary>-<ver>-windows-arm64.msi` | `<binary>-<ver>-windows-arm64-portable.zip` |
|
||||
| x86 (32-bit) | `<binary>-<ver>-windows-x86.msi` | `<binary>-<ver>-windows-x86-portable.zip` |
|
||||
|
||||
Installers and executables are code-signed.
|
||||
|
||||
@@ -450,102 +421,51 @@ Installers and executables are code-signed.
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| App, universal (Apple silicon + Intel) | `lightcraft-<ver>-macos-universal.dmg` | Signed and notarized |
|
||||
| Command-line tool, universal | `lightcraft-cli-<ver>-macos-universal.zip` | Signed and notarized |
|
||||
| App, universal (Apple silicon + Intel) | `<binary>-<ver>-macos-universal.dmg` | Signed and notarized |
|
||||
| Command-line tool, universal | `<cli>-<ver>-macos-universal.zip` | Signed and notarized |
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | aarch64 (ARM64) | Notes |
|
||||
|---|---|---|---|
|
||||
| AppImage | `lightcraft-<ver>-linux-x86_64.AppImage` | `lightcraft-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
|
||||
| Flatpak | `lightcraft-<ver>-linux-x86_64.flatpak` | `lightcraft-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
|
||||
| Debian/Ubuntu | `lightcraft-<ver>-linux-x86_64.deb` | `lightcraft-<ver>-linux-aarch64.deb` | |
|
||||
| Fedora/RHEL/openSUSE | `lightcraft-<ver>-linux-x86_64.rpm` | `lightcraft-<ver>-linux-aarch64.rpm` | |
|
||||
| Tarball | `lightcraft-<ver>-linux-x86_64.tar.gz` | `lightcraft-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
|
||||
| AppImage | `<binary>-<ver>-linux-x86_64.AppImage` | `<binary>-<ver>-linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) |
|
||||
| Flatpak | `<binary>-<ver>-linux-x86_64.flatpak` | `<binary>-<ver>-linux-aarch64.flatpak` | Sandboxed; `flatpak install --user <file>` |
|
||||
| Debian/Ubuntu | `<binary>-<ver>-linux-x86_64.deb` | `<binary>-<ver>-linux-aarch64.deb` | |
|
||||
| Fedora/RHEL/openSUSE | `<binary>-<ver>-linux-x86_64.rpm` | `<binary>-<ver>-linux-aarch64.rpm` | |
|
||||
| Tarball | `<binary>-<ver>-linux-x86_64.tar.gz` | `<binary>-<ver>-linux-aarch64.tar.gz` | Unpack anywhere |
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
|---|---|
|
||||
| x86_64 | `lightcraft-<ver>-freebsd-x86_64.tar.gz` |
|
||||
| x86_64 | `<binary>-<ver>-freebsd-x86_64.tar.gz` |
|
||||
|
||||
### Web (WebAssembly)
|
||||
|
||||
| Build | File | Notes |
|
||||
|---|---|---|
|
||||
| Static site | `lightcraft-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
|
||||
|
||||
<br>
|
||||
|
||||
## The Crafting Apps
|
||||
|
||||
LightCraft is one of the **Crafting Apps**: free, open-source creative tools from the
|
||||
[ArtCraft](https://getartcraft.com/) team, each written from scratch in Rust and each able to
|
||||
stand on its own.
|
||||
|
||||
| | App | What it's for | Code | Learn more |
|
||||
|:-:|---|---|---|---|
|
||||
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | **Photo library and raw development · you are here** | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/app) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
|
||||
</p>
|
||||
|
||||
<h3 align="center">Come make things with us</h3>
|
||||
|
||||
<p align="center">
|
||||
Our Discord is where artists of every kind hang out: people who paint, shoot, draw, cut film,
|
||||
set type, and people still figuring out what they like to make. Share what you're working on,
|
||||
ask for help, tell us what's broken, or tell us what you wish these tools could do.
|
||||
Whatever your medium and however long you've been at it, you're welcome here.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
|
||||
<a href="https://getartcraft.com/">getartcraft.com</a> ·
|
||||
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
|
||||
<a href="https://getartcraft.com/apps/app">LightCraft</a>
|
||||
</p>
|
||||
| Static site | `<binary>-web-<ver>.zip` | Runs in a modern browser; host it on any static server |
|
||||
|
||||
<br>
|
||||
|
||||
## License and credits
|
||||
|
||||
LightCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
|
||||
Copyright (c) 2026 ArtCraft Team and the LightCraft contributors. Required notices are in [NOTICE](NOTICE).
|
||||
The app is licensed under the [MIT License](LICENSE), except `crates/segment` and `crates/fetch`, which are
|
||||
Apache-2.0 only. It is a fork of an upstream open-source project; the upstream copyright notices and required
|
||||
third-party notices are in [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
|
||||
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
|
||||
with its author, source and license in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md).
|
||||
|
||||
Showcase photographs are public-domain works, used via Wikimedia Commons: Ansel Adams, *The Tetons and the Snake River*
|
||||
(1942, U.S. National Archives); Dorothea Lange, *Migrant Mother* (1936, Library of Congress); Bill Anders / NASA,
|
||||
*Earthrise* (1968); NASA, *The Blue Marble* (1972). The demo library is procedurally generated by LightCraft. UI font:
|
||||
*Earthrise* (1968); NASA, *The Blue Marble* (1972). The demo library is procedurally generated by the app. UI font:
|
||||
Inter (SIL OFL 1.1). Builds made with [craft-fonts](https://github.com/storytold/craft-fonts) (all official releases)
|
||||
also embed its Chinese and Japanese fonts (Noto Sans CJK SC, BIZ UDPGothic, BIZ UDMincho, Shippori Mincho; SIL OFL 1.1), listed in its
|
||||
[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md). All icons are original.
|
||||
|
||||
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
|
||||
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
|
||||
part of this repository and LightCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. LightCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. The app is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
</p>
|
||||
|
||||
## Star history
|
||||
|
||||
[](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# LightCraft roadmap
|
||||
# Roadmap
|
||||
|
||||
Milestones toward full Adobe Lightroom parity (cloud Lightroom first, then every Lightroom Classic module), with wall-clock
|
||||
estimates for continuous (24/7) agent-driven development with 4–6 parallel agents. Estimates are calibrated on the sibling
|
||||
@@ -197,13 +197,13 @@ own chart-based calibration (M11.4). Adobe matrices are never used.
|
||||
|
||||
File → Import Lightroom Catalog… reads `.lrcat` plus committed WAL pages directly through a pure-Rust
|
||||
SQLite reader. Originals stay in place; ratings, flags, labels, XMP metadata, hierarchical keywords,
|
||||
collections/sets, virtual copies and supported develop settings migrate into LightCraft. Existing edits
|
||||
collections/sets, virtual copies and supported develop settings migrate into the app. Existing edits
|
||||
are preserved by default, reimport identities are persistent, and source settings/history/snapshots are
|
||||
archived before catalog mutation. Missing originals remain available for relinking.
|
||||
|
||||
Rendering is approximate: unsupported Adobe profiles/AI/process settings are reported and archived;
|
||||
history/snapshots remain source data, and smart collections import current membership. The Lightroom
|
||||
database is read-only; LightCraft owns subsequent edits. See `docs/lightroom-catalog-import.md`.
|
||||
database is read-only; the app owns subsequent edits. See `docs/lightroom-catalog-import.md`.
|
||||
|
||||
## Log
|
||||
- 2026-09-30: roadmap created; M0 in progress; research docs (Lightroom reference, Rust imaging ecosystem) complete.
|
||||
|
||||
@@ -22,6 +22,7 @@ rawnind-model = ["denoise", "dac-engine/rawnind-model"]
|
||||
heif = ["dac-codecs/heif"]
|
||||
|
||||
[dependencies]
|
||||
dac-brand = { workspace = true }
|
||||
dac-engine = { workspace = true, features = ["sam"] }
|
||||
dac-sysmem = { workspace = true }
|
||||
dac-ui-egui = { workspace = true }
|
||||
@@ -33,7 +34,8 @@ serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for lightcraft.exe; build.rs only uses it when targeting Windows.
|
||||
# Icon + VERSIONINFO for the desktop executable; build.rs only uses it when targeting Windows.
|
||||
dac-brand = { workspace = true }
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
//! Windows only: embed the app icon and version info (VERSIONINFO) into `lightcraft.exe`, so
|
||||
//! Windows only: embed the app icon and version info (VERSIONINFO) into the desktop executable, so
|
||||
//! Explorer, the taskbar, the Start menu and Alt-Tab show the lynx.
|
||||
//!
|
||||
//! On every other target this does nothing. A missing resource compiler is a warning, so a
|
||||
//! cross-compile from macOS or Linux still links, unless `LIGHTCRAFT_REQUIRE_WINRES=1` turns it
|
||||
//! cross-compile from macOS or Linux still links, unless `<PREFIX>_REQUIRE_WINRES=1` turns it
|
||||
//! into an error (for release builds).
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-changed=../../assets/app-icon/lightcraft.ico");
|
||||
println!("cargo:rerun-if-env-changed=LIGHTCRAFT_REQUIRE_WINRES");
|
||||
println!("cargo:rerun-if-changed=../../assets/app-icon/app.ico");
|
||||
let require = dac_brand::env_var("REQUIRE_WINRES");
|
||||
println!("cargo:rerun-if-env-changed={require}");
|
||||
for prefix in dac_brand::LEGACY_ENV_PREFIXES {
|
||||
println!("cargo:rerun-if-env-changed={prefix}_REQUIRE_WINRES");
|
||||
}
|
||||
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") {
|
||||
return;
|
||||
}
|
||||
let mut res = winresource::WindowsResource::new();
|
||||
res.set_icon("../../assets/app-icon/lightcraft.ico")
|
||||
.set("ProductName", "LightCraft")
|
||||
.set("FileDescription", "LightCraft photo library and raw developer")
|
||||
.set("LegalCopyright", "Copyright (c) the LightCraft authors. MIT OR Apache-2.0.")
|
||||
.set("OriginalFilename", "lightcraft.exe")
|
||||
.set("InternalName", "lightcraft");
|
||||
res.set_icon("../../assets/app-icon/app.ico")
|
||||
.set("ProductName", dac_brand::DISPLAY_NAME)
|
||||
.set("FileDescription", &format!("{}: {}", dac_brand::DISPLAY_NAME, dac_brand::TAGLINE))
|
||||
.set("CompanyName", dac_brand::VENDOR)
|
||||
.set("LegalCopyright", &format!("Copyright (c) {}. MIT OR Apache-2.0.", dac_brand::VENDOR))
|
||||
.set("OriginalFilename", &format!("{}.exe", dac_brand::BINARY))
|
||||
.set("InternalName", dac_brand::BINARY);
|
||||
if let Err(e) = res.compile() {
|
||||
if std::env::var_os("LIGHTCRAFT_REQUIRE_WINRES").is_some() {
|
||||
if dac_brand::env_is_set("REQUIRE_WINRES") {
|
||||
panic!("embedding Windows resources failed: {e}");
|
||||
}
|
||||
println!("cargo:warning=lightcraft.exe built without icon/version resources: {e}");
|
||||
println!("cargo:warning={}.exe built without icon/version resources: {e}", dac_brand::BINARY);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! This is the transport the MCP server (`lightcraft-cli mcp --connect`) wraps.
|
||||
//! This is the transport the MCP server (`<cli> mcp --connect`) wraps.
|
||||
//!
|
||||
//! The server only ever reads requests: a line that is not a JSON object with a string `method`
|
||||
//! (an HTTP request line from a browser's cross-origin `fetch`, a stray `nc`, binary junk) gets
|
||||
|
||||
@@ -2,25 +2,25 @@
|
||||
//!
|
||||
//! It replaces the stderr-only `StderrLog` that `main.rs` used to install. A launch from a
|
||||
//! desktop menu or the Dock has no terminal, so the file is what a bug report can attach:
|
||||
//! `<settings folder>/logs/lightcraft.log`, next to `ui.json` (`config_dir` in `main.rs`; Linux
|
||||
//! `$XDG_CONFIG_HOME/lightcraft/logs`, by default `~/.config/lightcraft/logs`). Never in the
|
||||
//! library folder. Each start moves the previous log to `lightcraft.1.log` (and that one to `.2`),
|
||||
//! so the log of a run that crashed survives the next launch. Runs with `LIGHTCRAFT_NO_PREFS`
|
||||
//! `<settings folder>/logs/<binary>.log`, next to `ui.json` (`config_dir` in `main.rs`; Linux
|
||||
//! `$XDG_CONFIG_HOME/<settings dir>/logs`, by default `~/.config/<settings dir>/logs`). Never in the
|
||||
//! library folder. Each start moves the previous log to `<binary>.1.log` (and that one to `.2`),
|
||||
//! so the log of a run that crashed survives the next launch. Runs with `<PREFIX>_NO_PREFS`
|
||||
//! (tests, scripts) log to standard error only, so they don't rotate away the user's own logs.
|
||||
//!
|
||||
//! Levels ([`filter_spec`]): `info` for LightCraft's own crates, `warn` for everything else (wgpu
|
||||
//! and naga are chatty). `LIGHTCRAFT_LOG` keeps the meaning it had with the old logger: `info` or
|
||||
//! `debug` lowers LightCraft's own crates to that level, any other value means warnings and errors
|
||||
//! Levels ([`filter_spec`]): `info` for the app's own crates, `warn` for everything else (wgpu
|
||||
//! and naga are chatty). `<PREFIX>_LOG` keeps the meaning it had with the old logger: `info` or
|
||||
//! `debug` lowers the app's own crates to that level, any other value means warnings and errors
|
||||
//! only. Without it, `RUST_LOG` replaces the default with env_logger-style directives: `debug`,
|
||||
//! `warn,dac_pipeline=trace`, `wgpu_core=info`. A directive ending in `*` matches every
|
||||
//! target that starts with it (`lightcraft*=debug`).
|
||||
//! target that starts with it (`dac*=debug`).
|
||||
//!
|
||||
//! Records logged before the settings folder is known are kept (up to [`MAX_PENDING`]) and
|
||||
//! written once the file is attached. Writing never panics: a file that can't be created or
|
||||
//! written leaves standard error as the only sink. Panics that escape everything are added to the
|
||||
//! file by [`AppLogger::record_panic`] (the default hook already printed them on standard error).
|
||||
//!
|
||||
//! This is the desktop app's logger only; `lightcraft-cli` doesn't share a process with it.
|
||||
//! This is the desktop app's logger only; the command-line tool doesn't share a process with it.
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
@@ -31,27 +31,29 @@ use std::time::SystemTime;
|
||||
use log::LevelFilter;
|
||||
|
||||
/// The current log file's name inside the log folder.
|
||||
pub const LOG_FILE: &str = "lightcraft.log";
|
||||
/// How many previous logs are kept (`lightcraft.1.log` … `lightcraft.<KEEP>.log`).
|
||||
pub fn log_file() -> String {
|
||||
format!("{}.log", dac_brand::BINARY)
|
||||
}
|
||||
/// How many previous logs are kept (`<binary>.1.log` … `<binary>.<KEEP>.log`).
|
||||
pub const KEEP: usize = 2;
|
||||
/// The log file stops growing past this size (a runaway warning can't fill the disk).
|
||||
pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
/// Records kept in memory until the log file is attached.
|
||||
pub const MAX_PENDING: usize = 512;
|
||||
/// The built-in filter when neither `LIGHTCRAFT_LOG` nor `RUST_LOG` says otherwise.
|
||||
pub const DEFAULT_FILTER: &str = "warn,lightcraft*=info";
|
||||
/// The built-in filter when neither `<PREFIX>_LOG` nor `RUST_LOG` says otherwise.
|
||||
pub const DEFAULT_FILTER: &str = "warn,dac*=info,app=info";
|
||||
|
||||
/// The filter directives for this run from `LIGHTCRAFT_LOG` and `RUST_LOG` (blank = unset).
|
||||
/// The filter directives for this run from `<PREFIX>_LOG` and `RUST_LOG` (blank = unset).
|
||||
///
|
||||
/// `LIGHTCRAFT_LOG` wins when set and means what it did for the old stderr logger: `info` or
|
||||
/// `debug` for LightCraft's own crates (warnings and errors from everything else), any other value
|
||||
/// `<PREFIX>_LOG` wins when set and means what it did for the old stderr logger: `info` or
|
||||
/// `debug` for the app's own crates (warnings and errors from everything else), any other value
|
||||
/// warnings and errors only. Else `RUST_LOG` as env_logger directives, else [`DEFAULT_FILTER`].
|
||||
pub fn filter_spec(lightcraft_log: Option<&str>, rust_log: Option<&str>) -> String {
|
||||
pub fn filter_spec(app_log: Option<&str>, rust_log: Option<&str>) -> String {
|
||||
let set = |v: Option<&str>| v.map(str::trim).filter(|v| !v.is_empty()).map(str::to_owned);
|
||||
if let Some(level) = set(lightcraft_log) {
|
||||
if let Some(level) = set(app_log) {
|
||||
return match level.as_str() {
|
||||
"debug" => "warn,lightcraft*=debug".to_owned(),
|
||||
"info" => "warn,lightcraft*=info".to_owned(),
|
||||
"debug" => "warn,dac*=debug,app=debug".to_owned(),
|
||||
"info" => "warn,dac*=info,app=info".to_owned(),
|
||||
_ => "warn".to_owned(),
|
||||
};
|
||||
}
|
||||
@@ -137,8 +139,12 @@ fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
fn numbered_name(n: usize) -> String {
|
||||
format!("{}.{n}.log", dac_brand::BINARY)
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("lightcraft.{n}.log")) }
|
||||
if n == 0 { dir.join(log_file()) } else { dir.join(numbered_name(n)) }
|
||||
}
|
||||
|
||||
/// Shift the previous logs up one (`.log` → `.1.log` → … → `.<KEEP>.log`, the oldest dropped)
|
||||
@@ -266,7 +272,7 @@ impl AppLogger {
|
||||
}
|
||||
}
|
||||
|
||||
/// No log file for this run (`LIGHTCRAFT_NO_PREFS`): log to standard error only.
|
||||
/// No log file for this run (`<PREFIX>_NO_PREFS`): log to standard error only.
|
||||
pub fn no_file(&self) {
|
||||
self.sink.lock().unwrap_or_else(PoisonError::into_inner).no_file();
|
||||
}
|
||||
@@ -275,7 +281,7 @@ impl AppLogger {
|
||||
/// error), whatever the filter. Skipped when the sink is busy: the panic may have happened on
|
||||
/// this very thread while it held the lock, and waiting would hang the app.
|
||||
pub fn record_panic(&self, thread: &str, report: &str) {
|
||||
let line = format_line(×tamp(SystemTime::now()), log::Level::Error, thread, "lightcraft", report);
|
||||
let line = format_line(×tamp(SystemTime::now()), log::Level::Error, thread, "app", report);
|
||||
let mut sink = match self.sink.try_lock() {
|
||||
Ok(sink) => sink,
|
||||
Err(TryLockError::Poisoned(p)) => p.into_inner(),
|
||||
@@ -312,8 +318,7 @@ impl log::Log for AppLogger {
|
||||
/// first. Call [`AppLogger::attach_dir`] (or [`AppLogger::no_file`]) once the arguments are parsed.
|
||||
pub fn install() -> Option<&'static AppLogger> {
|
||||
static LOGGER: OnceLock<AppLogger> = OnceLock::new();
|
||||
let var = |name| std::env::var(name).ok();
|
||||
let filter = Filter::parse(&filter_spec(var("LIGHTCRAFT_LOG").as_deref(), var("RUST_LOG").as_deref()));
|
||||
let filter = Filter::parse(&filter_spec(dac_brand::env("LOG").as_deref(), std::env::var("RUST_LOG").ok().as_deref()));
|
||||
let max = filter.max();
|
||||
let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true));
|
||||
log::set_logger(logger).ok()?;
|
||||
@@ -322,7 +327,7 @@ pub fn install() -> Option<&'static AppLogger> {
|
||||
}
|
||||
|
||||
/// Chain a panic hook after the current one (the engine guard's: standard error plus
|
||||
/// `lightcraft-panics.log` in the temp folder) that also puts the panic in the log file.
|
||||
/// `<binary>-panics.log` in the temp folder) that also puts the panic in the log file.
|
||||
pub fn record_panics(logger: &'static AppLogger) {
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
@@ -342,7 +347,7 @@ mod tests {
|
||||
|
||||
fn temp_dir(tag: &str) -> PathBuf {
|
||||
static N: AtomicUsize = AtomicUsize::new(0);
|
||||
let d = std::env::temp_dir().join(format!("lightcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let d = std::env::temp_dir().join(format!("app-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
d
|
||||
}
|
||||
@@ -357,21 +362,21 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The logger this replaced (`StderrLog`): warnings and errors from every target, `info` and
|
||||
/// `debug` only from LightCraft's own crates, and only when `LIGHTCRAFT_LOG` asked for them.
|
||||
fn old_stderr_log_enabled(lightcraft_log: &str, level: log::Level, target: &str) -> bool {
|
||||
let max = match lightcraft_log {
|
||||
/// `debug` only from the app's own crates, and only when `<PREFIX>_LOG` asked for them.
|
||||
fn old_stderr_log_enabled(app_log: &str, level: log::Level, target: &str) -> bool {
|
||||
let max = match app_log {
|
||||
"debug" => LevelFilter::Debug,
|
||||
"info" => LevelFilter::Info,
|
||||
_ => LevelFilter::Warn,
|
||||
};
|
||||
level <= max && (level <= log::Level::Warn || target.starts_with("lightcraft"))
|
||||
level <= max && (level <= log::Level::Warn || (target.starts_with("dac") || target == "app" || target.starts_with("app::")))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_filter_shows_lightcraft_info_and_other_crates_warnings() {
|
||||
fn the_default_filter_shows_own_info_and_other_crates_warnings() {
|
||||
let f = Filter::parse(DEFAULT_FILTER);
|
||||
assert_eq!(f.level_for("lightcraft"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("lightcraft::control_server"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("app"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("app::control_server"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("dac_engine::guard"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("dac_ui_egui::render"), LevelFilter::Info);
|
||||
assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn);
|
||||
@@ -382,16 +387,16 @@ mod tests {
|
||||
assert_eq!(filter_spec(Some(""), Some(" ")), DEFAULT_FILTER);
|
||||
}
|
||||
|
||||
/// `LIGHTCRAFT_LOG` keeps working exactly as it did with the old stderr-only logger.
|
||||
/// `<PREFIX>_LOG` keeps working exactly as it did with the old stderr-only logger.
|
||||
#[test]
|
||||
fn lightcraft_log_keeps_its_meaning() {
|
||||
let targets = ["dac-app", "lightcraft::control_server", "dac_engine::segment", "dac_gpu", "wgpu_core::device", "naga", "eframe"];
|
||||
fn app_log_keeps_its_meaning() {
|
||||
let targets = ["dac-app", "app::control_server", "dac_engine::segment", "dac_gpu", "wgpu_core::device", "naga", "eframe"];
|
||||
let levels = [log::Level::Error, log::Level::Warn, log::Level::Info, log::Level::Debug, log::Level::Trace];
|
||||
for value in ["info", "debug", "warn", "error", "verbose", "INFO"] {
|
||||
let f = Filter::parse(&filter_spec(Some(value), None));
|
||||
for target in targets {
|
||||
for level in levels {
|
||||
assert_eq!(level <= f.level_for(target), old_stderr_log_enabled(value, level, target), "LIGHTCRAFT_LOG={value} {level} {target}");
|
||||
assert_eq!(level <= f.level_for(target), old_stderr_log_enabled(value, level, target), "LOG={value} {level} {target}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -400,7 +405,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_log_replaces_the_default_and_lightcraft_log_wins_over_it() {
|
||||
fn rust_log_replaces_the_default_and_app_log_wins_over_it() {
|
||||
assert_eq!(filter_spec(None, Some("warn,wgpu_core=info")), "warn,wgpu_core=info");
|
||||
let f = Filter::parse(&filter_spec(None, Some("debug")));
|
||||
assert_eq!(f.level_for("eframe"), LevelFilter::Debug);
|
||||
@@ -421,14 +426,14 @@ mod tests {
|
||||
// A module name is matched at `::` boundaries, not as a bare prefix.
|
||||
assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info);
|
||||
// A trailing `*` is a prefix.
|
||||
assert_eq!(Filter::parse("lightcraft*=debug").level_for("dac_raw::cr2"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("lightcraft*=debug").level_for("eframe"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("dac*=debug").level_for("dac_raw::cr2"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("dac*=debug").level_for("eframe"), LevelFilter::Error);
|
||||
// A bare target name sets that target to the most verbose level, as env_logger does.
|
||||
assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace);
|
||||
assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error);
|
||||
// Levels are case-insensitive and surrounding blanks are ignored.
|
||||
assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("lightcraft"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse(" WARN , lightcraft = Debug ").level_for("eframe"), LevelFilter::Warn);
|
||||
assert_eq!(Filter::parse(" WARN , app = Debug ").level_for("app"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse(" WARN , app = Debug ").level_for("eframe"), LevelFilter::Warn);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -441,30 +446,30 @@ mod tests {
|
||||
"=",
|
||||
"==",
|
||||
"=debug",
|
||||
"lightcraft=",
|
||||
"app=",
|
||||
"nonsense=loud",
|
||||
"🦀=info",
|
||||
"*",
|
||||
"*=",
|
||||
"=*",
|
||||
"a=b=c",
|
||||
"warn,,lightcraft=DEBUG",
|
||||
"warn,,app=DEBUG",
|
||||
"\0",
|
||||
"é::ü=trace",
|
||||
&long,
|
||||
&many,
|
||||
] {
|
||||
let f = Filter::parse(spec);
|
||||
for target in ["", "lightcraft", "🦀", "a::b", "*"] {
|
||||
for target in ["", "app", "🦀", "a::b", "*"] {
|
||||
let _ = f.level_for(target);
|
||||
}
|
||||
let _ = f.max();
|
||||
let _ = filter_spec(Some(spec), Some(spec));
|
||||
}
|
||||
assert_eq!(Filter::parse("warn,,lightcraft=DEBUG").level_for("lightcraft"), LevelFilter::Debug);
|
||||
assert_eq!(Filter::parse("warn,,app=DEBUG").level_for("app"), LevelFilter::Debug);
|
||||
// An unknown level is skipped: that target keeps the default.
|
||||
assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("lightcraft=").level_for("lightcraft"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("app=").level_for("app"), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error);
|
||||
assert_eq!(Filter::parse("🦀=info").level_for("🦀::claw"), LevelFilter::Info);
|
||||
}
|
||||
@@ -487,8 +492,8 @@ mod tests {
|
||||
#[test]
|
||||
fn a_line_carries_time_level_thread_target_and_message() {
|
||||
assert_eq!(
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "lightcraft", "library /photos: 12 photos"),
|
||||
"2026-10-08T07:59:17.728Z INFO [main] lightcraft: library /photos: 12 photos\n"
|
||||
format_line("2026-10-08T07:59:17.728Z", log::Level::Info, "main", "app", "library /photos: 12 photos"),
|
||||
"2026-10-08T07:59:17.728Z INFO [main] app: library /photos: 12 photos\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
@@ -499,14 +504,14 @@ mod tests {
|
||||
let dir = temp_dir("rotate");
|
||||
for run in 1..=4 {
|
||||
let (path, _file) = rotate(&dir).expect("rotate");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(path, dir.join(log_file()));
|
||||
assert_eq!(read(&path), "");
|
||||
std::fs::write(&path, format!("run {run}")).expect("write");
|
||||
}
|
||||
assert_eq!(read(&dir.join(LOG_FILE)), "run 4");
|
||||
assert_eq!(read(&dir.join("lightcraft.1.log")), "run 3");
|
||||
assert_eq!(read(&dir.join("lightcraft.2.log")), "run 2");
|
||||
assert!(!dir.join("lightcraft.3.log").exists(), "only {KEEP} old logs are kept");
|
||||
assert_eq!(read(&dir.join(log_file())), "run 4");
|
||||
assert_eq!(read(&dir.join(numbered_name(1))), "run 3");
|
||||
assert_eq!(read(&dir.join(numbered_name(2))), "run 2");
|
||||
assert!(!dir.join(numbered_name(3)).exists(), "only {KEEP} old logs are kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -592,24 +597,24 @@ mod tests {
|
||||
use log::Log;
|
||||
let dir = temp_dir("logger");
|
||||
let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false);
|
||||
record(&logger, log::Level::Info, "lightcraft", "before the settings directory");
|
||||
record(&logger, log::Level::Info, "app", "before the settings directory");
|
||||
record(&logger, log::Level::Info, "wgpu_core::device", "too chatty");
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
assert_eq!(path, dir.join(LOG_FILE));
|
||||
assert_eq!(path, dir.join(log_file()));
|
||||
record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning");
|
||||
record(&logger, log::Level::Error, "dac_engine::guard", "`develop.reset` failed unexpectedly: boom");
|
||||
record(&logger, log::Level::Debug, "lightcraft", "below info");
|
||||
record(&logger, log::Level::Debug, "app", "below info");
|
||||
let text = read(&path);
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
assert_eq!(lines.len(), 3, "{text}");
|
||||
assert!(lines[0].contains(" INFO [") && lines[0].ends_with("lightcraft: before the settings directory"), "{text}");
|
||||
assert!(lines[0].contains(" INFO [") && lines[0].ends_with("app: before the settings directory"), "{text}");
|
||||
assert!(lines[1].ends_with("wgpu_hal::vulkan: a real warning"), "{text}");
|
||||
assert!(lines[2].contains(" ERROR [") && lines[2].ends_with("dac_engine::guard: `develop.reset` failed unexpectedly: boom"), "{text}");
|
||||
assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("dac_ui_egui").build()));
|
||||
assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build()));
|
||||
// Attaching again rotates: the first log becomes `.1`.
|
||||
logger.attach_dir(&dir).expect("attach again");
|
||||
assert_eq!(read(&dir.join("lightcraft.1.log")), text);
|
||||
assert_eq!(read(&dir.join(numbered_name(1))), text);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -623,7 +628,7 @@ mod tests {
|
||||
let l = std::sync::Arc::clone(&logger);
|
||||
std::thread::spawn(move || {
|
||||
for i in 0..100 {
|
||||
record(&l, log::Level::Info, "lightcraft", &format!("thread {t} record {i}"));
|
||||
record(&l, log::Level::Info, "app", &format!("thread {t} record {i}"));
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -633,7 +638,7 @@ mod tests {
|
||||
}
|
||||
let text = read(&path);
|
||||
assert_eq!(text.lines().count(), 800);
|
||||
assert!(text.lines().all(|l| l.contains(" lightcraft: thread ")), "{text}");
|
||||
assert!(text.lines().all(|l| l.contains(" app: thread ")), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -643,7 +648,7 @@ mod tests {
|
||||
std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp");
|
||||
std::fs::write(&dir, "not a directory").expect("block");
|
||||
let logger = AppLogger::new(Filter::parse("info"), false);
|
||||
record(&logger, log::Level::Info, "lightcraft", "early");
|
||||
record(&logger, log::Level::Info, "app", "early");
|
||||
assert!(logger.attach_dir(&dir).is_err());
|
||||
record(&logger, log::Level::Error, "x", "still fine");
|
||||
// Nothing waits in memory for a file that won't come.
|
||||
@@ -663,8 +668,8 @@ mod tests {
|
||||
.join();
|
||||
assert!(logger.sink.is_poisoned());
|
||||
let path = logger.attach_dir(&dir).expect("attach");
|
||||
record(&logger, log::Level::Warn, "lightcraft", "after the poison");
|
||||
assert!(read(&path).contains("lightcraft: after the poison"));
|
||||
record(&logger, log::Level::Warn, "app", "after the poison");
|
||||
assert!(read(&path).contains("app: after the poison"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -681,7 +686,7 @@ mod tests {
|
||||
logger.record_panic("main", "while the sink is held");
|
||||
}
|
||||
let text = read(&path);
|
||||
assert!(text.contains(" ERROR [render] lightcraft: panicked at crates/x/src/lib.rs:3:5:\nboom\n"), "{text}");
|
||||
assert!(text.contains(" ERROR [render] app: panicked at crates/x/src/lib.rs:3:5:\nboom\n"), "{text}");
|
||||
assert!(!text.contains("while the sink is held"), "{text}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
//! LightCraft desktop app.
|
||||
//! The desktop app.
|
||||
//!
|
||||
//! Usage: `lightcraft [--library DIR | --memory] [--no-demo] [--control <port>] [files or folders…]`
|
||||
//! Usage: `<binary> [--library DIR | --memory] [--no-demo] [--control <port>] [files or folders…]`
|
||||
//!
|
||||
//! The library (catalog, presets, thumbnail cache) lives in `--library DIR`, else
|
||||
//! `$LIGHTCRAFT_LIBRARY`, else the library last opened with Settings → Open Library…, else
|
||||
//! `~/Pictures/LightCraft Library`; a new library starts with the
|
||||
//! `$<PREFIX>_LIBRARY`, else the library last opened with Settings → Open Library…, else
|
||||
//! `~/Pictures/<library default>` (`dac_brand::LIBRARY_DEFAULT`); a new library starts with the
|
||||
//! procedural demo photos unless `--no-demo` or files are given. Files and folders on the command
|
||||
//! line are imported (duplicates are skipped). `--memory` runs an in-memory session that writes
|
||||
//! nothing (demo photos unless files are given; used by the README showcase scripts). A library
|
||||
//! that can't be opened is never replaced silently: the window says why and asks what to do
|
||||
//! (`dac_ui_egui::panels::library_problem`).
|
||||
//!
|
||||
//! `--control <port>` (or `LIGHTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `--control <port>` (or `<PREFIX>_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"ui.inspect","params":{}}` → `{"id":1,"ok":true,"result":…}`.
|
||||
//! See `dac_ui_egui::control` for the methods.
|
||||
//!
|
||||
//! On Windows, release builds are GUI-subsystem programs: launching the app opens no console
|
||||
//! window (issue #7). Their `--help` / `--version` output and diagnostics then have no console to
|
||||
//! go to; use `lightcraft-cli` (a console program) from a terminal, or a debug build.
|
||||
//! go to; use the command-line tool (a console program) from a terminal, or a debug build.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
@@ -35,15 +35,15 @@ use dac_ui_egui::{DacApp, Services, UiState};
|
||||
use dialog_filter::FileDialogExt as _;
|
||||
|
||||
/// Reverse-DNS app id: Wayland app id, `.desktop` file and hicolor icon name.
|
||||
const APP_ID: &str = "ai.storyteller.lightcraft";
|
||||
const APP_ID: &str = dac_brand::APP_ID;
|
||||
|
||||
/// The window, taskbar and Dock icon (assets/app-icon/, regenerated by packaging/icons.sh). macOS
|
||||
/// gets the version with Apple's transparent margin so it sits right among the other Dock icons.
|
||||
fn app_icon() -> egui::IconData {
|
||||
#[cfg(target_os = "macos")]
|
||||
let png: &[u8] = include_bytes!("../../../assets/app-icon/lightcraft-macos-512.png");
|
||||
let png: &[u8] = include_bytes!("../../../assets/app-icon/app-macos-512.png");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.lightcraft.png");
|
||||
let png: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/app.png");
|
||||
eframe::icon_data::from_png_bytes(png).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -81,8 +81,8 @@ impl eframe::App for App {
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's renderer (egui-wgpu): eframe's defaults, with LightCraft's backend choice — DX12
|
||||
/// alone on Windows unless `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136:
|
||||
/// The window's renderer (egui-wgpu): eframe's defaults, with the app's backend choice — DX12
|
||||
/// alone on Windows unless `<PREFIX>_GPU_BACKEND` / `WGPU_BACKEND` say otherwise (issue #136:
|
||||
/// with Vulkan in the set, wgpu loads the Vulkan driver even when it then picks DX12) — and DX12
|
||||
/// shaders compiled with FXC, never a stray `dxcompiler.dll` (issue #471).
|
||||
fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
|
||||
@@ -95,9 +95,9 @@ fn window_wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
|
||||
}
|
||||
|
||||
/// Written while the GPU compute device is created, removed once that returned
|
||||
/// (`dac_gpu::backend::set_init_marker`). Not with `LIGHTCRAFT_NO_PREFS` (tests, scripts).
|
||||
/// (`dac_gpu::backend::set_init_marker`). Not with `<PREFIX>_NO_PREFS` (tests, scripts).
|
||||
fn gpu_marker_path() -> Option<std::path::PathBuf> {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
if dac_brand::env_is_set("NO_PREFS") {
|
||||
return None;
|
||||
}
|
||||
config_dir().map(|d| d.join("gpu-init.marker"))
|
||||
@@ -126,10 +126,11 @@ fn gpu_crash_check_at(marker: Option<std::path::PathBuf>, in_memory: bool) -> Op
|
||||
}
|
||||
|
||||
fn gpu_crash_notice(what: &str) -> String {
|
||||
let (app, var) = (dac_brand::DISPLAY_NAME, dac_brand::env_var("GPU_BACKEND"));
|
||||
format!(
|
||||
"LightCraft closed unexpectedly while starting the GPU last time ({what}), so GPU rendering is now off and photos render on the CPU. \
|
||||
To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; to try another graphics backend, start LightCraft with \
|
||||
LIGHTCRAFT_GPU_BACKEND=dx12, vulkan or off (see docs/gpu-pipeline.md → Troubleshooting)."
|
||||
"{app} closed unexpectedly while starting the GPU last time ({what}), so GPU rendering is now off and photos render on the CPU. \
|
||||
To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; to try another graphics backend, start {app} with \
|
||||
{var}=dx12, vulkan or off (see docs/gpu-pipeline.md → Troubleshooting)."
|
||||
)
|
||||
}
|
||||
|
||||
@@ -138,9 +139,9 @@ fn config_dir() -> Option<std::path::PathBuf> {
|
||||
}
|
||||
|
||||
/// `<config>/ui.json`, the saved UI state and app settings — `None` when nothing is read or
|
||||
/// written: `LIGHTCRAFT_NO_PREFS` (tests, scripts) or no home folder.
|
||||
/// written: `<PREFIX>_NO_PREFS` (tests, scripts) or no home folder.
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
if dac_brand::env_is_set("NO_PREFS") {
|
||||
return None;
|
||||
}
|
||||
config_dir().map(|d| d.join("ui.json"))
|
||||
@@ -222,7 +223,7 @@ fn write_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
/// library it opened is saved then).
|
||||
#[derive(Default)]
|
||||
struct PrefsWriter {
|
||||
/// Where to write; `None` writes nothing (`--memory`, `LIGHTCRAFT_NO_PREFS`, no home folder).
|
||||
/// Where to write; `None` writes nothing (`--memory`, `<PREFIX>_NO_PREFS`, no home folder).
|
||||
path: Option<std::path::PathBuf>,
|
||||
written: Vec<u8>,
|
||||
library: String,
|
||||
@@ -270,7 +271,7 @@ impl PrefsWriter {
|
||||
Err(e) => {
|
||||
if !self.failing {
|
||||
log::warn!("{e}");
|
||||
app.notices.push(format!("{e}. LightCraft keeps trying."));
|
||||
app.notices.push(format!("{e}. {} keeps trying.", dac_brand::DISPLAY_NAME));
|
||||
}
|
||||
self.failing = true;
|
||||
// don't retry every frame
|
||||
@@ -467,7 +468,7 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services
|
||||
save_preset_file: Some(Box::new(|name: &str| {
|
||||
rfd::FileDialog::new()
|
||||
.set_title(dac_ui_egui::i18n::tr("Export Presets"))
|
||||
.add_filter_nocase(dac_ui_egui::i18n::tr("LightCraft Preset"), &["lcpreset"])
|
||||
.add_filter_nocase(dac_ui_egui::i18n::tr("{app} Preset"), &[dac_brand::PRESET_EXT])
|
||||
.set_file_name(name)
|
||||
.save_file()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
@@ -520,7 +521,7 @@ fn open_library_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_d
|
||||
}
|
||||
let unopened = || Session::new().with_fs().with_default_denoise_models().with_system_clock();
|
||||
let Some(dir) = dir else {
|
||||
log::warn!("no library location (set --library or LIGHTCRAFT_LIBRARY)");
|
||||
log::warn!("no library location (set --library or {})", dac_brand::env_var("LIBRARY"));
|
||||
return (unopened(), Some(LibraryProblem::new("", "There is no home folder to keep the library in. Choose a folder for it.")));
|
||||
};
|
||||
let t0 = std::time::Instant::now();
|
||||
@@ -544,30 +545,38 @@ fn open_library_session(in_memory: bool, dir: Option<std::path::PathBuf>, seed_d
|
||||
}
|
||||
}
|
||||
|
||||
/// `--help` text; `{bin}`, `{app}`, `{P}` and `{lib}` are filled by [`help`].
|
||||
const HELP: &str = "\
|
||||
lightcraft — photo library + raw developer
|
||||
{bin} — photo library + raw developer
|
||||
|
||||
USAGE: lightcraft [OPTIONS] [FILES/FOLDERS…] (files and folders are imported)
|
||||
USAGE: {bin} [OPTIONS] [FILES/FOLDERS…] (files and folders are imported)
|
||||
|
||||
OPTIONS:
|
||||
--library DIR open (or create) this library (default: ~/Pictures/LightCraft Library; env LIGHTCRAFT_LIBRARY)
|
||||
--library DIR open (or create) this library (default: ~/Pictures/{lib}; env {P}_LIBRARY)
|
||||
--no-demo don't seed a new library with the procedural demo photos
|
||||
--memory throwaway in-memory session (alias --demo); nothing is saved
|
||||
--control PORT serve the JSON-lines control channel on 127.0.0.1:PORT (env LIGHTCRAFT_CONTROL_PORT;
|
||||
--control PORT serve the JSON-lines control channel on 127.0.0.1:PORT (env {P}_CONTROL_PORT;
|
||||
see docs/control-protocol.md)
|
||||
--version, --help
|
||||
|
||||
ENVIRONMENT:
|
||||
LIGHTCRAFT_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS,
|
||||
Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. LIGHTCRAFT_GPU=0: CPU rendering.
|
||||
LIGHTCRAFT_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: <settings folder>/models/sam3;
|
||||
optional: LightCraft offers to download it when first needed)
|
||||
LIGHTCRAFT_SAM3_MIRRORS=URL,… where to download the SAM 3 model from (base URLs, tried in order)
|
||||
LIGHTCRAFT_LOG=info|debug more log detail from LightCraft itself (default: info from LightCraft, warnings
|
||||
{P}_GPU_BACKEND=dx12|vulkan|metal|auto|off graphics backend (default: DX12 on Windows, Metal on macOS,
|
||||
Vulkan on Linux; off = render on the CPU); else WGPU_BACKEND. {P}_GPU=0: CPU rendering.
|
||||
{P}_SAM3_DIR=DIR the SAM 3 model for Object / Describe masks (default: <settings folder>/models/sam3;
|
||||
optional: {app} offers to download it when first needed)
|
||||
{P}_SAM3_MIRRORS=URL,… where to download the SAM 3 model from (base URLs, tried in order)
|
||||
{P}_LOG=info|debug more log detail from {app} itself (default: info from {app}, warnings
|
||||
from other crates); else RUST_LOG=<env_logger directives>. Records go to stderr and to
|
||||
<settings folder>/logs/lightcraft.log (previous runs: lightcraft.1.log, lightcraft.2.log)
|
||||
<settings folder>/logs/{bin}.log (previous runs: {bin}.1.log, {bin}.2.log)
|
||||
";
|
||||
|
||||
fn help() -> String {
|
||||
HELP.replace("{bin}", dac_brand::BINARY)
|
||||
.replace("{lib}", dac_brand::LIBRARY_DEFAULT)
|
||||
.replace("{P}", dac_brand::ENV_PREFIX)
|
||||
.replace("{app}", dac_brand::DISPLAY_NAME)
|
||||
}
|
||||
|
||||
/// The launch arguments' files and folders to import (absolute), and a notice for each one that
|
||||
/// is not imported (see [`dac_engine::import::launch_path`]; issue #374).
|
||||
fn launch_imports(args: &[String]) -> (Vec<String>, Vec<String>) {
|
||||
@@ -594,9 +603,9 @@ fn launch_imports_in(args: &[String], cwd: Option<&std::path::Path>, home: Optio
|
||||
}
|
||||
|
||||
/// Where the log files live: `logs` in the settings folder, next to `ui.json` (see `logging`).
|
||||
/// None with `LIGHTCRAFT_NO_PREFS` (tests, scripts), so those runs don't rotate away the user's logs.
|
||||
/// None with `<PREFIX>_NO_PREFS` (tests, scripts), so those runs don't rotate away the user's logs.
|
||||
fn log_dir() -> Option<std::path::PathBuf> {
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
if dac_brand::env_is_set("NO_PREFS") {
|
||||
return None;
|
||||
}
|
||||
config_dir().map(|d| d.join("logs"))
|
||||
@@ -616,12 +625,12 @@ fn control_port_from(what: &str, value: Option<String>) -> Option<u16> {
|
||||
fn main() -> eframe::Result {
|
||||
// First, so every start-up record is kept for the log file (`logging`).
|
||||
let logger = logging::install();
|
||||
dac_engine::guard::install_hook(std::env::temp_dir().join("lightcraft-panics.log"));
|
||||
dac_engine::guard::install_hook(std::env::temp_dir().join(format!("{}-panics.log", dac_brand::BINARY)));
|
||||
if let Some(logger) = logger {
|
||||
logging::record_panics(logger);
|
||||
}
|
||||
alloc_release::install();
|
||||
let mut control_port = control_port_from("LIGHTCRAFT_CONTROL_PORT", std::env::var("LIGHTCRAFT_CONTROL_PORT").ok().filter(|v| !v.is_empty()));
|
||||
let mut control_port = control_port_from(&dac_brand::env_var("CONTROL_PORT"), dac_brand::env("CONTROL_PORT").filter(|v| !v.is_empty()));
|
||||
let mut files = Vec::new();
|
||||
let mut seed_demo = true;
|
||||
let mut in_memory = false;
|
||||
@@ -634,15 +643,15 @@ fn main() -> eframe::Result {
|
||||
"--no-demo" => seed_demo = false,
|
||||
"--memory" | "--demo" => in_memory = true,
|
||||
"--version" => {
|
||||
println!("lightcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
println!("{} {}", dac_brand::BINARY, env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
"-h" | "--help" => {
|
||||
print!("{HELP}");
|
||||
print!("{}", help());
|
||||
return Ok(());
|
||||
}
|
||||
o if o.starts_with("--") => {
|
||||
eprintln!("lightcraft: unknown option `{o}`\n\n{HELP}");
|
||||
eprintln!("{}: unknown option `{o}`\n\n{}", dac_brand::BINARY, help());
|
||||
std::process::exit(2);
|
||||
}
|
||||
_ => files.push(a),
|
||||
@@ -656,7 +665,7 @@ fn main() -> eframe::Result {
|
||||
match log_dir().filter(|_| !in_memory) {
|
||||
Some(dir) => match logger.attach_dir(&dir) {
|
||||
Ok(path) => {
|
||||
log::info!("LightCraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display());
|
||||
log::info!("{} {}, log file {}", dac_brand::DISPLAY_NAME, env!("CARGO_PKG_VERSION"), path.display());
|
||||
log_file = Some(path);
|
||||
}
|
||||
// The file sink has given up by now, so this goes to standard error only.
|
||||
@@ -665,6 +674,14 @@ fn main() -> eframe::Result {
|
||||
None => logger.no_file(),
|
||||
}
|
||||
}
|
||||
// first start under this name: copy the settings folder of a previous name (never moved)
|
||||
if !in_memory && !dac_brand::env_is_set("NO_PREFS") {
|
||||
match dac_brand::migrate_legacy_settings() {
|
||||
Ok(Some(from)) => log::info!("settings copied from {}", from.display()),
|
||||
Ok(None) => {}
|
||||
Err(e) => log::warn!("could not copy the previous settings folder: {e}"),
|
||||
}
|
||||
}
|
||||
// what the arguments name, resolved now: an empty or relative argument never becomes a drive
|
||||
// root, and a whole drive or home folder is not walked (issue #374)
|
||||
let (files, launch_notices) = launch_imports(&files);
|
||||
@@ -675,11 +692,7 @@ fn main() -> eframe::Result {
|
||||
}
|
||||
// --library, else the library last opened from Settings, else the default location
|
||||
let library_dir = library_dir.or_else(|| {
|
||||
prefs
|
||||
.as_ref()
|
||||
.map(|u| u.settings.library_path.clone())
|
||||
.filter(|p| !p.is_empty() && std::env::var_os("LIGHTCRAFT_LIBRARY").is_none())
|
||||
.map(Into::into)
|
||||
prefs.as_ref().map(|u| u.settings.library_path.clone()).filter(|p| !p.is_empty() && !dac_brand::env_is_set("LIBRARY")).map(Into::into)
|
||||
});
|
||||
let library_dir = library_dir.or_else(dac_engine::library::default_dir);
|
||||
// GPU compute: off if the preference says so, or if the last launch died creating the device
|
||||
@@ -689,7 +702,7 @@ fn main() -> eframe::Result {
|
||||
dac_engine::gpu::set_enabled(gpu_on);
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("LightCraft")
|
||||
.with_title(dac_brand::DISPLAY_NAME)
|
||||
.with_inner_size([1600.0, 1000.0])
|
||||
.with_min_inner_size([900.0, 560.0])
|
||||
.with_drag_and_drop(true)
|
||||
@@ -697,7 +710,7 @@ fn main() -> eframe::Result {
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false)
|
||||
.with_icon(app_icon())
|
||||
// Wayland matches the window to packaging/linux/ai.storyteller.lightcraft.desktop by this id
|
||||
// Wayland matches the window to the packaged `<APP_ID>.desktop` file by this id
|
||||
.with_app_id(APP_ID),
|
||||
wgpu_options: window_wgpu_options(),
|
||||
..Default::default()
|
||||
@@ -705,15 +718,15 @@ fn main() -> eframe::Result {
|
||||
// the app's copy: `log_file` is still named in the message of a start that fails
|
||||
let app_log_file = log_file.clone();
|
||||
let started = eframe::run_native(
|
||||
"LightCraft",
|
||||
dac_brand::DISPLAY_NAME,
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty());
|
||||
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or LIGHTCRAFT_SAM3_DIR
|
||||
// AI masks: the SAM 3 checkpoint (facebook/sam3) in <config>/models/sam3, or <PREFIX>_SAM3_DIR
|
||||
// (never required: without it, AI masks offer to download it; see docs/ai-masks.md)
|
||||
session.segmenter.dir =
|
||||
std::env::var_os("LIGHTCRAFT_SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3")));
|
||||
// the user's own download locations, one base URL per line (LIGHTCRAFT_SAM3_MIRRORS too)
|
||||
dac_brand::env_os("SAM3_DIR").map(std::path::PathBuf::from).or_else(|| config_dir().map(|d| d.join("models").join("sam3")));
|
||||
// the user's own download locations, one base URL per line (<PREFIX>_SAM3_MIRRORS too)
|
||||
session.segmenter.mirrors_file = config_dir().map(|d| d.join("models").join("sam3-mirrors.txt"));
|
||||
let mut app = DacApp::new(session, services(cc.egui_ctx.clone(), app_log_file.as_deref()));
|
||||
if let Some(ui) = prefs {
|
||||
@@ -754,9 +767,9 @@ fn main() -> eframe::Result {
|
||||
app.ui.view = dac_ui_egui::state::ViewMode::PhotoGrid;
|
||||
}
|
||||
// native menu bar generated from the command registry (macOS; elsewhere the menus are
|
||||
// drawn in the window's top bar). LIGHTCRAFT_NO_NATIVE_MENU=1 keeps the in-window menus.
|
||||
// drawn in the window's top bar). <PREFIX>_NO_NATIVE_MENU=1 keeps the in-window menus.
|
||||
#[cfg(target_os = "macos")]
|
||||
let menu = (std::env::var_os("LIGHTCRAFT_NO_NATIVE_MENU").is_none()).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
|
||||
let menu = (!dac_brand::env_is_set("NO_NATIVE_MENU")).then(|| native_menu::NativeMenu::install(&mut app, &cc.egui_ctx));
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
writer,
|
||||
@@ -775,8 +788,8 @@ fn main() -> eframe::Result {
|
||||
/// message box naming the log file, instead of exiting without a trace — the release builds have
|
||||
/// no console on Windows, and an app started from Finder shows no standard error (issue #260).
|
||||
fn startup_failed(error: &str, log_file: Option<&std::path::Path>) {
|
||||
log::error!("LightCraft could not open its window: {error}");
|
||||
if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() {
|
||||
log::error!("{} could not open its window: {error}", dac_brand::DISPLAY_NAME);
|
||||
if dac_brand::env_is_set("NO_PREFS") {
|
||||
return; // tests and scripts: no dialog to click away
|
||||
}
|
||||
let (title, text) = dac_ui_egui::i18n::startup_failed_message(error, log_file.map(|p| p.display().to_string()).as_deref());
|
||||
@@ -929,7 +942,7 @@ mod tests {
|
||||
fn window_backends_follow_the_platform_default() {
|
||||
let eframe::egui_wgpu::WgpuSetup::CreateNew(n) = window_wgpu_options().wgpu_setup else { panic!("expected CreateNew") };
|
||||
let b = n.instance_descriptor.backends;
|
||||
if std::env::var_os("LIGHTCRAFT_GPU_BACKEND").is_none() && std::env::var_os("WGPU_BACKEND").is_none() {
|
||||
if !dac_brand::env_is_set("GPU_BACKEND") && std::env::var_os("WGPU_BACKEND").is_none() {
|
||||
if cfg!(windows) {
|
||||
assert_eq!(b, eframe::wgpu::Backends::DX12);
|
||||
} else if cfg!(target_os = "macos") {
|
||||
@@ -984,7 +997,7 @@ mod tests {
|
||||
assert_eq!(control_port_from("--control", Some("nope".into())), None);
|
||||
assert_eq!(control_port_from("--control", Some(String::new())), None);
|
||||
assert_eq!(control_port_from("--control", Some("70000".into())), None);
|
||||
assert_eq!(control_port_from("LIGHTCRAFT_CONTROL_PORT", None), None);
|
||||
assert_eq!(control_port_from("CONTROL_PORT", None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -227,10 +227,10 @@ impl NativeMenu {
|
||||
self.items.clear();
|
||||
|
||||
// the application menu
|
||||
let app_menu = Submenu::new("LightCraft", true);
|
||||
let about = MenuItem::with_id("app.about", dac_ui_egui::i18n::tr("About LightCraft"), true, None);
|
||||
let app_menu = Submenu::new(dac_brand::SHORT_NAME, true);
|
||||
let about = MenuItem::with_id("app.about", dac_brand::fill(&dac_ui_egui::i18n::tr("About {app}")), true, None);
|
||||
let settings = MenuItem::with_id(SETTINGS, dac_ui_egui::i18n::tr("Settings…"), true, accelerator(SETTINGS_KEY));
|
||||
let quit = MenuItem::with_id(QUIT, dac_ui_egui::i18n::tr("Quit LightCraft"), true, accelerator("Cmd+Q"));
|
||||
let quit = MenuItem::with_id(QUIT, dac_brand::fill(&dac_ui_egui::i18n::tr("Quit {app}")), true, accelerator("Cmd+Q"));
|
||||
let _ = app_menu.append_items(&[
|
||||
&about,
|
||||
&PredefinedMenuItem::separator(),
|
||||
@@ -238,7 +238,7 @@ impl NativeMenu {
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::services(Some(dac_ui_egui::i18n::tr("Services"))),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&PredefinedMenuItem::hide(Some(dac_ui_egui::i18n::tr("Hide LightCraft"))),
|
||||
&PredefinedMenuItem::hide(Some(dac_brand::fill(&dac_ui_egui::i18n::tr("Hide {app}")))),
|
||||
&PredefinedMenuItem::hide_others(Some(dac_ui_egui::i18n::tr("Hide Others"))),
|
||||
&PredefinedMenuItem::show_all(Some(dac_ui_egui::i18n::tr("Show All"))),
|
||||
&PredefinedMenuItem::separator(),
|
||||
@@ -457,7 +457,7 @@ mod tests {
|
||||
set_language(Locale::ZhHant);
|
||||
assert_eq!(label_text("File"), "檔案");
|
||||
assert_eq!(label_text("Settings…"), "設定…");
|
||||
assert_eq!(label_text("Quit LightCraft"), "結束 LightCraft");
|
||||
assert_eq!(dac_brand::fill(&label_text("Quit {app}")), dac_brand::fill("結束 {app}"));
|
||||
set_language(Locale::En);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "LightCraft command-line tool: headless render, command listing and the MCP server"
|
||||
description = "Command-line tool: headless render, command listing and the MCP server"
|
||||
|
||||
[[bin]]
|
||||
name = "app-cli"
|
||||
@@ -14,9 +14,11 @@ path = "src/main.rs"
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
dac-brand = { workspace = true }
|
||||
dac-engine = { workspace = true }
|
||||
dac-sysmem = { workspace = true }
|
||||
dac-mcp = { workspace = true }
|
||||
log = { workspace = true }
|
||||
dac-ui-egui = { workspace = true }
|
||||
dac-codecs = { workspace = true }
|
||||
dac-raster = { workspace = true }
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
//! `lightcraft-cli`: headless LightCraft.
|
||||
//! The command-line tool (`dac_brand::CLI_BINARY`): the app, headless.
|
||||
//!
|
||||
//! ```text
|
||||
//! lightcraft-cli run [--demo | --library DIR | --connect [ADDR]] [--import PATH]… CMD [key=value…]…
|
||||
//! lightcraft-cli mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…]
|
||||
//! lightcraft-cli render <in> -o <out> [--set control=value]… [--settings FILE.json] [--preset ID] [--size N] [--quality Q]
|
||||
//! lightcraft-cli snapshot [--library DIR | --demo] [--script FILE.jsonl] [-o OUT.png] [--size WxH] [--scale S] [FILES…]
|
||||
//! lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES…
|
||||
//! lightcraft-cli synth-merge hdr|panorama -o DIR
|
||||
//! lightcraft-cli commands [--json]
|
||||
//! lightcraft-cli controls [--json]
|
||||
//! lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
|
||||
//! <cli> run [--demo | --library DIR | --connect [ADDR]] [--import PATH]… CMD [key=value…]…
|
||||
//! <cli> mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…]
|
||||
//! <cli> render <in> -o <out> [--set control=value]… [--settings FILE.json] [--preset ID] [--size N] [--quality Q]
|
||||
//! <cli> snapshot [--library DIR | --demo] [--script FILE.jsonl] [-o OUT.png] [--size WxH] [--scale S] [FILES…]
|
||||
//! <cli> merge hdr|panorama|hdr-panorama [OPTIONS] FILES…
|
||||
//! <cli> synth-merge hdr|panorama -o DIR
|
||||
//! <cli> commands [--json]
|
||||
//! <cli> controls [--json]
|
||||
//! <cli> calibrate [--max N] [--out DIR] FOLDERS/FILES…
|
||||
//! ```
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
@@ -24,33 +24,34 @@ use dac_engine::Session;
|
||||
use dac_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server, expand_paths};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// `--help` text; `{cli}`, `{bin}`, `{app}` and `{P}` are filled by [`usage`].
|
||||
const USAGE: &str = "\
|
||||
lightcraft-cli — headless LightCraft (photo library + raw developer)
|
||||
{cli} — headless {app} (photo library + raw developer)
|
||||
|
||||
USAGE:
|
||||
lightcraft-cli run [OPTIONS] COMMAND [key=value | '{json}']… [COMMAND …]…
|
||||
{cli} run [OPTIONS] COMMAND [key=value | '{json}']… [COMMAND …]…
|
||||
Run one or more commands (ids from `commands`, or control-protocol methods such as
|
||||
engine.commands / ui.inspect / ui.screenshot) and print one JSON line per command:
|
||||
{\"command\", \"ok\", \"result\" | \"error\", \"ms\"}. A word without `=` starts the next
|
||||
command; values are JSON when they parse (1, true, [1,2], {…}), else strings. Exit status
|
||||
is non-zero when a command fails. Example:
|
||||
lightcraft-cli run --import ~/Photos/a.jpg develop.set control=light.exposure value=0.7 \\
|
||||
{cli} run --import ~/Photos/a.jpg develop.set control=light.exposure value=0.7 \\
|
||||
develop.auto app.export path=/tmp/a.jpg longEdge=1600
|
||||
Options:
|
||||
--demo headless, the procedural demo library
|
||||
--library DIR headless, open (or create) a LightCraft library; edits are saved
|
||||
--library DIR headless, open (or create) a {app} library; edits are saved
|
||||
--import PATH headless, import a file or folder first (repeatable)
|
||||
--connect [ADDR] drive the running app (`lightcraft --control 7980`; default 127.0.0.1:7980)
|
||||
--connect [ADDR] drive the running app (`{bin} --control 7980`; default 127.0.0.1:7980)
|
||||
--script FILE|- also run JSON lines {\"command\": id, \"params\": {…}} (or {\"method\": …})
|
||||
--keep-going continue after a failed command
|
||||
lightcraft-cli mcp [OPTIONS] [FILES/FOLDERS…]
|
||||
{cli} mcp [OPTIONS] [FILES/FOLDERS…]
|
||||
MCP server (JSON-RPC 2.0 over stdio). Headless by default: an in-process session with the
|
||||
given files imported. Options:
|
||||
--connect [ADDR] drive a running app instead (`lightcraft --control 7980`; default 127.0.0.1:7980)
|
||||
--connect [ADDR] drive a running app instead (`{bin} --control 7980`; default 127.0.0.1:7980)
|
||||
--demo headless: start with the procedurally generated demo library
|
||||
--library DIR headless: open (or create) a persistent LightCraft library; edits are saved
|
||||
--library DIR headless: open (or create) a persistent {app} library; edits are saved
|
||||
--compact list only the helper tools (every command stays reachable via run_command)
|
||||
lightcraft-cli render <IN> -o <OUT> [OPTIONS]
|
||||
{cli} render <IN> -o <OUT> [OPTIONS]
|
||||
Develop one file and export it (.jpg, .png, .tif, .webp, .avif or .dng by extension) with the
|
||||
same encoder as the app's Export dialog. Options:
|
||||
--set CONTROL=VALUE set a develop slider, repeatable (e.g. --set light.exposure=0.5)
|
||||
@@ -62,11 +63,11 @@ USAGE:
|
||||
string), e.g. --opt colorSpace=displayP3 --opt bitDepth=16
|
||||
--opt percent=50 --opt shortEdge=1080 --opt ppi=300
|
||||
--opt format=original (copy + XMP sidecar) --opt metadata=none
|
||||
lightcraft-cli snapshot [OPTIONS] [FILES/FOLDERS…]
|
||||
{cli} snapshot [OPTIONS] [FILES/FOLDERS…]
|
||||
Run the full app UI headlessly (no window, no GPU: CPU-rasterized egui) and write PNGs.
|
||||
Options:
|
||||
--demo the procedural demo library (default unless --library or FILES)
|
||||
--library DIR open (or create) a LightCraft library
|
||||
--library DIR open (or create) a {app} library
|
||||
--script FILE JSON-lines control-protocol requests (docs/control-protocol.md), one
|
||||
per line: {\"method\": \"ui.set\", \"params\": {\"view\": \"detail\"}}.
|
||||
Replies go to stdout. `ui.screenshot` without a path writes -o (then
|
||||
@@ -76,26 +77,35 @@ USAGE:
|
||||
-o, --output OUT PNG path (a final screenshot is written here if the script took none)
|
||||
--size WxH window size in points (default 1600x1000)
|
||||
--scale S pixels per point (default 1)
|
||||
lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES…
|
||||
{cli} merge hdr|panorama|hdr-panorama [OPTIONS] FILES…
|
||||
Photo Merge: writes <first>-HDR.dng / -Pano.dng / -HDR-Pano.dng next to the first file and
|
||||
prints the result as JSON. Options:
|
||||
--deghost none|low|medium|high --no-align --bracket N (HDR panorama)
|
||||
--projection auto|spherical|cylindrical|perspective --boundary-warp 0..100
|
||||
--auto-crop --fill-edges --no-auto-settings
|
||||
--preview OUT.png only render a ≤ 1024 px preview (nothing written next to the files)
|
||||
lightcraft-cli synth-merge hdr|panorama -o DIR
|
||||
{cli} synth-merge hdr|panorama -o DIR
|
||||
Write synthetic merge inputs (procedural scene; bracketed DNGs or overlapping PNG views).
|
||||
lightcraft-cli commands [--json] list every command id with its parameters
|
||||
lightcraft-cli controls [--json] list every develop control id with its range
|
||||
lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS/FILES…
|
||||
{cli} commands [--json] list every command id with its parameters
|
||||
{cli} controls [--json] list every develop control id with its range
|
||||
{cli} calibrate [--max N] [--out DIR] FOLDERS/FILES…
|
||||
Fit a colour profile per camera model from raw files and their embedded camera JPEGs
|
||||
(Sony ARW, Nikon NEF, Fujifilm RAF): up to N files spread over the folders (default 300; 0 = all), pooled per
|
||||
model, written as <model>.json to DIR (default: the profiles folder LightCraft reads,
|
||||
<config>/camera-profiles, or $LIGHTCRAFT_CAMERA_PROFILES). Raws of a profiled model then
|
||||
model, written as <model>.json to DIR (default: the profiles folder {app} reads,
|
||||
<config>/camera-profiles, or ${P}_CAMERA_PROFILES). Raws of a profiled model then
|
||||
take their colour from the profile and only their tone from their own JPEG.
|
||||
lightcraft-cli --version | --help
|
||||
{cli} --version | --help
|
||||
";
|
||||
|
||||
fn usage() -> String {
|
||||
USAGE.replace("{cli}", CLI).replace("{bin}", dac_brand::BINARY).replace("{P}", dac_brand::ENV_PREFIX).replace("{app}", APP)
|
||||
}
|
||||
|
||||
/// The command name shown in messages and `--help`.
|
||||
const CLI: &str = dac_brand::CLI_BINARY;
|
||||
const APP: &str = dac_brand::DISPLAY_NAME;
|
||||
const BIN: &str = dac_brand::BINARY;
|
||||
|
||||
#[cfg(feature = "dhat-heap")]
|
||||
#[global_allocator]
|
||||
static ALLOC: dhat::Alloc = dhat::Alloc;
|
||||
@@ -113,7 +123,7 @@ fn library_warnings(session: &mut Session, who: &str) {
|
||||
fn library_error(dir: &str, e: dac_engine::EngineError) -> String {
|
||||
match e {
|
||||
dac_engine::EngineError::LibraryInUse(why) => format!(
|
||||
"{dir}: {why}\nTo work with the library while the app has it open, start the app with `--control PORT` and use `lightcraft-cli mcp --connect 127.0.0.1:PORT`."
|
||||
"{dir}: {why}\nTo work with the library while the app has it open, start the app with `--control PORT` and use `{CLI} mcp --connect 127.0.0.1:PORT`."
|
||||
),
|
||||
e => format!("{dir}: {e}"),
|
||||
}
|
||||
@@ -121,10 +131,10 @@ fn library_error(dir: &str, e: dac_engine::EngineError) -> String {
|
||||
|
||||
fn main() -> ExitCode {
|
||||
// `--features dhat-heap`: count allocations; the profile is written when `_heap` drops
|
||||
// (LIGHTCRAFT_DHAT_FILE, default dhat-heap.json).
|
||||
// (<PREFIX>_DHAT_FILE, default dhat-heap.json).
|
||||
#[cfg(feature = "dhat-heap")]
|
||||
let _heap = {
|
||||
let file = std::env::var("LIGHTCRAFT_DHAT_FILE").unwrap_or_else(|_| "dhat-heap.json".into());
|
||||
let file = dac_brand::env("DHAT_FILE").unwrap_or_else(|| "dhat-heap.json".into());
|
||||
dac_engine::memory::set_heap_stats(|| {
|
||||
let s = dhat::HeapStats::get();
|
||||
dac_engine::memory::HeapUsage { current: s.curr_bytes as u64, peak: s.max_bytes as u64 }
|
||||
@@ -132,9 +142,17 @@ fn main() -> ExitCode {
|
||||
dhat::Profiler::builder().file_name(file).build()
|
||||
};
|
||||
alloc_release::install();
|
||||
// Warnings (a GPU render redone on the CPU, an unknown backend name) on stderr; LIGHTCRAFT_LOG
|
||||
// Warnings (a GPU render redone on the CPU, an unknown backend name) on stderr; <PREFIX>_LOG
|
||||
// or RUST_LOG picks another level (#168).
|
||||
dac_engine::logging::install("dac-cli");
|
||||
// first start under this name: copy the settings folder of a previous name (never moved)
|
||||
if !dac_brand::env_is_set("NO_PREFS") {
|
||||
match dac_brand::migrate_legacy_settings() {
|
||||
Ok(Some(from)) => log::info!("settings copied from {}", from.display()),
|
||||
Ok(None) => {}
|
||||
Err(e) => log::warn!("could not copy the previous settings folder: {e}"),
|
||||
}
|
||||
}
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let r = match args.first().map(String::as_str) {
|
||||
Some("run") => run(&args[1..]),
|
||||
@@ -147,19 +165,19 @@ fn main() -> ExitCode {
|
||||
Some("controls") => controls(&args[1..]),
|
||||
Some("calibrate") => calibrate(&args[1..]),
|
||||
Some("--version" | "-V" | "version") => {
|
||||
println!("lightcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
println!("{CLI} {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
Some("--help" | "-h" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
print!("{}", usage());
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(format!("unknown subcommand `{other}`\n\n{USAGE}")),
|
||||
Some(other) => Err(format!("unknown subcommand `{other}`\n\n{}", usage())),
|
||||
};
|
||||
match r {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft-cli: {e}");
|
||||
eprintln!("{CLI}: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
@@ -310,11 +328,11 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
match Remote::connect(&addr) {
|
||||
Ok(r) => {
|
||||
eprintln!("lightcraft-cli mcp: connected to LightCraft at {addr}");
|
||||
eprintln!("{CLI} mcp: connected to {APP} at {addr}");
|
||||
Box::new(r)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("lightcraft-cli mcp: LightCraft is not reachable at {addr} yet ({e}); will retry on each call");
|
||||
eprintln!("{CLI} mcp: {APP} is not reachable at {addr} yet ({e}); will retry on each call");
|
||||
Box::new(Remote::lazy(&addr))
|
||||
}
|
||||
}
|
||||
@@ -324,8 +342,8 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
Some(dir) => {
|
||||
let mut h = Headless::default();
|
||||
let r = h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
|
||||
eprintln!("lightcraft-cli mcp: opened library {dir} ({r:?})");
|
||||
library_warnings(&mut h.session, "lightcraft-cli mcp");
|
||||
eprintln!("{CLI} mcp: opened library {dir} ({r:?})");
|
||||
library_warnings(&mut h.session, &format!("{CLI} mcp"));
|
||||
h
|
||||
}
|
||||
None if demo => Headless::demo(),
|
||||
@@ -334,12 +352,12 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
if !files.is_empty() {
|
||||
let paths = expand_paths(&files);
|
||||
let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
|
||||
eprintln!("lightcraft-cli mcp: imported {} photo(s)", r["imported"].as_array().map_or(0, Vec::len));
|
||||
eprintln!("{CLI} mcp: imported {} photo(s)", r["imported"].as_array().map_or(0, Vec::len));
|
||||
}
|
||||
Box::new(h)
|
||||
}
|
||||
};
|
||||
eprintln!("lightcraft-cli mcp: serving MCP on stdio ({})", backend.describe());
|
||||
eprintln!("{CLI} mcp: serving MCP on stdio ({})", backend.describe());
|
||||
let mut server = Server::new(backend).with_command_tools(!compact);
|
||||
let stdin = std::io::stdin();
|
||||
server.serve(BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string())
|
||||
@@ -453,7 +471,7 @@ fn synth_merge(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// One step of `lightcraft-cli run`: a command id (or control-protocol method) and its params.
|
||||
/// One step of `<cli> run`: a command id (or control-protocol method) and its params.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct Step {
|
||||
id: String,
|
||||
@@ -543,42 +561,42 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
if steps.is_empty() {
|
||||
return Err("run: no command given (see `lightcraft-cli commands`)".into());
|
||||
return Err(format!("run: no command given (see `{CLI} commands`)"));
|
||||
}
|
||||
let mut backend: Box<dyn Backend> =
|
||||
match connect {
|
||||
Some(addr) => {
|
||||
if demo || library.is_some() || !imports.is_empty() {
|
||||
return Err("--demo, --library and --import apply to headless mode only".into());
|
||||
}
|
||||
Box::new(Remote::connect(&addr).map_err(|e| {
|
||||
format!("LightCraft is not reachable at {addr} ({e}); start it with `lightcraft --control {}`", connect_port(&addr))
|
||||
})?)
|
||||
let mut backend: Box<dyn Backend> = match connect {
|
||||
Some(addr) => {
|
||||
if demo || library.is_some() || !imports.is_empty() {
|
||||
return Err("--demo, --library and --import apply to headless mode only".into());
|
||||
}
|
||||
None => {
|
||||
let mut h = match &library {
|
||||
Some(dir) => {
|
||||
let mut h = Headless::default();
|
||||
h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
|
||||
library_warnings(&mut h.session, "dac-cli");
|
||||
h
|
||||
}
|
||||
None if demo => Headless::demo(),
|
||||
None => Headless::default(),
|
||||
};
|
||||
if !imports.is_empty() {
|
||||
let paths = expand_paths(&imports);
|
||||
let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
|
||||
// what follows acts on the imported photos (already-known files are reported as duplicates)
|
||||
let mut ids: Vec<Value> = r["imported"].as_array().cloned().unwrap_or_default();
|
||||
ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned()));
|
||||
if let Some(first) = ids.first().cloned() {
|
||||
h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Box::new(
|
||||
Remote::connect(&addr)
|
||||
.map_err(|e| format!("{APP} is not reachable at {addr} ({e}); start it with `{BIN} --control {}`", connect_port(&addr)))?,
|
||||
)
|
||||
}
|
||||
None => {
|
||||
let mut h = match &library {
|
||||
Some(dir) => {
|
||||
let mut h = Headless::default();
|
||||
h.session.open_library(dir, demo).map_err(|e| library_error(dir, e))?;
|
||||
library_warnings(&mut h.session, "dac-cli");
|
||||
h
|
||||
}
|
||||
None if demo => Headless::demo(),
|
||||
None => Headless::default(),
|
||||
};
|
||||
if !imports.is_empty() {
|
||||
let paths = expand_paths(&imports);
|
||||
let r = h.session.execute("library.import", &json!({"paths": paths})).map_err(|e| e.to_string())?;
|
||||
// what follows acts on the imported photos (already-known files are reported as duplicates)
|
||||
let mut ids: Vec<Value> = r["imported"].as_array().cloned().unwrap_or_default();
|
||||
ids.extend(r["duplicates"].as_array().into_iter().flatten().filter_map(|d| d.get("existing").filter(|v| v.is_u64()).cloned()));
|
||||
if let Some(first) = ids.first().cloned() {
|
||||
h.session.execute("library.select", &json!({"ids": ids, "active": first})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
Box::new(h)
|
||||
}
|
||||
};
|
||||
Box::new(h)
|
||||
}
|
||||
};
|
||||
let mut out = std::io::stdout().lock();
|
||||
let mut failed = 0;
|
||||
for s in &steps {
|
||||
@@ -690,9 +708,9 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
dac_engine::export::write_file(&output, &e.bytes)?;
|
||||
for (sc, bytes) in &sidecars {
|
||||
dac_engine::export::write_file(sc, bytes)?;
|
||||
eprintln!("lightcraft-cli: wrote {sc}");
|
||||
eprintln!("{CLI}: wrote {sc}");
|
||||
}
|
||||
eprintln!("lightcraft-cli: wrote {output} ({}×{})", e.width, e.height);
|
||||
eprintln!("{CLI}: wrote {output} ({}×{})", e.width, e.height);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -743,7 +761,7 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
let ti = Instant::now();
|
||||
let r = session.execute("library.import", &json!({"paths": expand_paths(&files)})).map_err(|e| e.to_string())?;
|
||||
let n = r["imported"].as_array().map_or(0, Vec::len);
|
||||
eprintln!("lightcraft-cli snapshot: imported {n} files in {:.0} ms", ti.elapsed().as_secs_f64() * 1e3);
|
||||
eprintln!("{CLI} snapshot: imported {n} files in {:.0} ms", ti.elapsed().as_secs_f64() * 1e3);
|
||||
}
|
||||
let services = dac_ui_egui::Services {
|
||||
write_shared: Some(std::sync::Arc::new(dac_engine::export::write_file)),
|
||||
@@ -819,11 +837,7 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
writeln!(out, "{reply}").map_err(|e| e.to_string())?;
|
||||
if method == "ui.screenshot" {
|
||||
eprintln!(
|
||||
"lightcraft-cli snapshot: {} ({:.0} ms)",
|
||||
reply["result"]["path"].as_str().unwrap_or("?"),
|
||||
ts.elapsed().as_secs_f64() * 1000.0
|
||||
);
|
||||
eprintln!("{CLI} snapshot: {} ({:.0} ms)", reply["result"]["path"].as_str().unwrap_or("?"), ts.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
if h.quit_requested() {
|
||||
break;
|
||||
@@ -835,18 +849,18 @@ fn snapshot(args: &[String]) -> Result<(), String> {
|
||||
if r["ok"] != true {
|
||||
return Err(format!("screenshot failed: {}", r["error"]));
|
||||
}
|
||||
eprintln!("lightcraft-cli snapshot: wrote {path} ({}×{})", r["result"]["width"], r["result"]["height"]);
|
||||
eprintln!("{CLI} snapshot: wrote {path} ({}×{})", r["result"]["width"], r["result"]["height"]);
|
||||
}
|
||||
// a background export started by the script finishes before we exit (its files would be cut off)
|
||||
if h.app.export.is_some() {
|
||||
eprintln!("lightcraft-cli snapshot: waiting for the background export");
|
||||
eprintln!("{CLI} snapshot: waiting for the background export");
|
||||
let te = std::time::Instant::now();
|
||||
while h.app.export.is_some() && te.elapsed() < std::time::Duration::from_secs(3600) {
|
||||
h.step();
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
eprintln!("lightcraft-cli snapshot: done in {:.2} s ({} frames)", t0.elapsed().as_secs_f64(), h.frames());
|
||||
eprintln!("{CLI} snapshot: done in {:.2} s ({} frames)", t0.elapsed().as_secs_f64(), h.frames());
|
||||
if failed > 0 { Err(format!("{failed} scripted request(s) failed")) } else { Ok(()) }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! End-to-end tests of the `lightcraft-cli` binary: `mcp` over real stdio pipes, `render`, `commands`.
|
||||
//! End-to-end tests of the command-line binary: `mcp` over real stdio pipes, `render`, `commands`.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
@@ -8,7 +8,7 @@ use serde_json::{Value, json};
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_app-cli");
|
||||
|
||||
fn tmp(name: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("lightcraft-cli-test-{}", std::process::id()));
|
||||
let d = std::env::temp_dir().join(format!("app-cli-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d.join(name)
|
||||
}
|
||||
@@ -267,8 +267,8 @@ fn snapshot_defaults_to_cpu_without_gpu_environment_overrides() {
|
||||
.unwrap();
|
||||
let image = tmp("default-cpu.png");
|
||||
let output = Command::new(BIN)
|
||||
.env_remove("LIGHTCRAFT_GPU")
|
||||
.env_remove("LIGHTCRAFT_GPU_BACKEND")
|
||||
.env_remove(dac_brand::env_var("GPU"))
|
||||
.env_remove(dac_brand::env_var("GPU_BACKEND"))
|
||||
.env_remove("WGPU_BACKEND")
|
||||
.args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", image.to_str().unwrap(), "--size", "480x320"])
|
||||
.output()
|
||||
@@ -300,12 +300,13 @@ fn snapshot_starts_without_a_gpu() {
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
for (var, value, reason) in [("LIGHTCRAFT_GPU", "0", "LIGHTCRAFT_GPU=0"), ("LIGHTCRAFT_GPU_BACKEND", "off", "LIGHTCRAFT_GPU_BACKEND=off")] {
|
||||
for (var, value) in [(dac_brand::env_var("GPU"), "0"), (dac_brand::env_var("GPU_BACKEND"), "off")] {
|
||||
let reason = format!("{var}={value}");
|
||||
let out = tmp(&format!("nogpu-{var}.png"));
|
||||
let o = Command::new(BIN)
|
||||
.env_remove("LIGHTCRAFT_GPU")
|
||||
.env_remove("LIGHTCRAFT_GPU_BACKEND")
|
||||
.env(var, value)
|
||||
.env_remove(dac_brand::env_var("GPU"))
|
||||
.env_remove(dac_brand::env_var("GPU_BACKEND"))
|
||||
.env(&var, value)
|
||||
.args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "480x320"])
|
||||
.output()
|
||||
.unwrap();
|
||||
@@ -315,7 +316,7 @@ fn snapshot_starts_without_a_gpu() {
|
||||
let gpu = &replies[1]["result"];
|
||||
assert_eq!(gpu["available"], false, "{var}: {gpu}");
|
||||
assert_eq!(gpu["adapter"], Value::Null, "{var}: no device was created: {gpu}");
|
||||
assert!(gpu["reason"].as_str().is_some_and(|r| r.contains(reason)), "{var}: {gpu}");
|
||||
assert!(gpu["reason"].as_str().is_some_and(|r| r.contains(&reason)), "{var}: {gpu}");
|
||||
let d = dac_codecs::decode(&std::fs::read(&out).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((d.width, d.height), (480, 320));
|
||||
}
|
||||
@@ -387,7 +388,7 @@ fn devices_are_listed_and_imported_from() {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(base.join("CARD/DCIM/100TEST")).unwrap();
|
||||
gradient_png(&base.join("CARD/DCIM/100TEST/IMG_0001.png"));
|
||||
let o = Command::new(BIN).env("LIGHTCRAFT_DEVICE_ROOTS", &base).args(["run", "--demo", "library.devices"]).output().unwrap();
|
||||
let o = Command::new(BIN).env(dac_brand::env_var("DEVICE_ROOTS"), &base).args(["run", "--demo", "library.devices"]).output().unwrap();
|
||||
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
|
||||
let line: Value = serde_json::from_slice(o.stdout.split(|b| *b == b'\n').next().unwrap()).unwrap();
|
||||
let dev = &line["result"][0];
|
||||
@@ -418,7 +419,7 @@ fn a_library_open_in_another_process_is_refused() {
|
||||
}
|
||||
let (ok, _, stderr) = run_cli(&["--library", lib_s, "library.info"], None);
|
||||
assert!(!ok);
|
||||
assert!(stderr.contains("already open in lightcraft-cli") && stderr.contains(&format!("process {}", holder.id())), "{stderr}");
|
||||
assert!(stderr.contains("already open in ") && stderr.contains(&format!("process {}", holder.id())), "{stderr}");
|
||||
assert!(stderr.contains("mcp --connect"), "{stderr}");
|
||||
|
||||
drop(holder.stdin.take()); // EOF: the server exits and lets go of the library
|
||||
@@ -449,10 +450,10 @@ fn warnings_are_logged_on_stderr() {
|
||||
let run = |level: &str| {
|
||||
let o = Command::new(BIN)
|
||||
.args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap()])
|
||||
.env("LIGHTCRAFT_GPU_BACKEND", "bogus")
|
||||
.env(dac_brand::env_var("GPU_BACKEND"), "bogus")
|
||||
.env("RUST_LOG", level)
|
||||
.env_remove("LIGHTCRAFT_LOG")
|
||||
.env_remove("LIGHTCRAFT_GPU")
|
||||
.env_remove(dac_brand::env_var("LOG"))
|
||||
.env_remove(dac_brand::env_var("GPU"))
|
||||
.output()
|
||||
.unwrap();
|
||||
let stderr = String::from_utf8_lossy(&o.stderr).into_owned();
|
||||
@@ -460,7 +461,7 @@ fn warnings_are_logged_on_stderr() {
|
||||
stderr
|
||||
};
|
||||
let warn = run("warn");
|
||||
assert!(warn.contains("LIGHTCRAFT_GPU_BACKEND=bogus names no known backend"), "{warn}");
|
||||
assert!(warn.contains(&format!("{}=bogus names no known backend", dac_brand::env_var("GPU_BACKEND"))), "{warn}");
|
||||
let off = run("off");
|
||||
assert!(!off.contains("names no known backend"), "{off}");
|
||||
}
|
||||
@@ -481,8 +482,12 @@ fn snapshot_rejects_invalid_dimensions_before_creating_an_image() {
|
||||
#[test]
|
||||
fn snapshot_preserves_fractional_scale_rounding() {
|
||||
let image = tmp("fractional-scale-rounding.png");
|
||||
let output =
|
||||
Command::new(BIN).env("LIGHTCRAFT_GPU", "0").args(["snapshot", "--size", "345x200", "--scale", "0.9", "-o"]).arg(&image).output().unwrap();
|
||||
let output = Command::new(BIN)
|
||||
.env(dac_brand::env_var("GPU"), "0")
|
||||
.args(["snapshot", "--size", "345x200", "--scale", "0.9", "-o"])
|
||||
.arg(&image)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let decoded = dac_codecs::decode(&std::fs::read(image).unwrap(), Default::default()).unwrap();
|
||||
assert_eq!((decoded.width, decoded.height), (311, 180));
|
||||
@@ -498,7 +503,7 @@ fn snapshot_leaves_desktop_gpu_preferences_untouched() {
|
||||
session.open_library(&library, true).unwrap();
|
||||
drop(session);
|
||||
let config = dir.join("config");
|
||||
let app_config = config.join("lightcraft");
|
||||
let app_config = config.join(dac_brand::settings_dir_name(dac_brand::SETTINGS_DIR));
|
||||
std::fs::create_dir_all(&app_config).unwrap();
|
||||
let ui_path = app_config.join("ui.json");
|
||||
let mut ui = dac_ui_egui::UiState::default();
|
||||
@@ -514,9 +519,12 @@ fn snapshot_leaves_desktop_gpu_preferences_untouched() {
|
||||
for gpu_environment in [None, Some("1")] {
|
||||
let image = dir.join(format!("{}.png", gpu_environment.unwrap_or("default")));
|
||||
let mut cmd = Command::new(BIN);
|
||||
cmd.env("XDG_CONFIG_HOME", &config).env_remove("LIGHTCRAFT_GPU").env_remove("LIGHTCRAFT_GPU_BACKEND").env_remove("WGPU_BACKEND");
|
||||
cmd.env("XDG_CONFIG_HOME", &config)
|
||||
.env_remove(dac_brand::env_var("GPU"))
|
||||
.env_remove(dac_brand::env_var("GPU_BACKEND"))
|
||||
.env_remove("WGPU_BACKEND");
|
||||
if let Some(value) = gpu_environment {
|
||||
cmd.env("LIGHTCRAFT_GPU", value);
|
||||
cmd.env(dac_brand::env_var("GPU"), value);
|
||||
}
|
||||
let output = cmd
|
||||
.args(["snapshot", "--library"])
|
||||
|
||||
@@ -4,7 +4,7 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "LightCraft in the browser: the egui UI compiled to WASM (build with `cargo xtask web`)"
|
||||
description = "The app in the browser: the egui UI compiled to WASM (build with `cargo xtask web`)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -13,6 +13,7 @@ workspace = true
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
dac-brand = { workspace = true }
|
||||
dac-engine = { workspace = true }
|
||||
dac-ui-egui = { workspace = true }
|
||||
dac-codecs = { workspace = true }
|
||||
|
||||
@@ -3,30 +3,30 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>LightCraft</title>
|
||||
<title>Loading…</title>
|
||||
<link rel="icon" href="data:,">
|
||||
<meta name="description" content="LightCraft: open-source photo library and raw developer, running in your browser (WASM).">
|
||||
<meta name="description" content="Open-source photo library and raw developer, running in your browser (WASM).">
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #1c1c1c; }
|
||||
canvas#lightcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#lightcraft_loading {
|
||||
canvas#app_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
#app_loading {
|
||||
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
color: #b8b8b8; font: 15px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif; pointer-events: none;
|
||||
}
|
||||
#lightcraft_status { position: absolute; left: 0; bottom: 0; width: 1px; height: 1px; overflow: hidden; }
|
||||
#app_status { position: absolute; left: 0; bottom: 0; width: 1px; height: 1px; overflow: hidden; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="lightcraft_canvas"></canvas>
|
||||
<div id="lightcraft_loading">Loading LightCraft…</div>
|
||||
<div id="lightcraft_status" aria-live="polite"></div>
|
||||
<canvas id="app_canvas"></canvas>
|
||||
<div id="app_loading">Loading…</div>
|
||||
<div id="app_status" aria-live="polite"></div>
|
||||
<script type="module">
|
||||
import init, { start, command } from "./dac_web.js";
|
||||
(async () => {
|
||||
// The Chinese font is a separate file: embedding it would exceed the host's WASM size limit.
|
||||
// Font-free development builds can still run, with the same missing-glyph warning as before.
|
||||
const chineseFont = fetch("./lightcraft_zh_hans.otf")
|
||||
const chineseFont = fetch("./app_zh_hans.otf")
|
||||
.then(async response => response.ok ? new Uint8Array(await response.arrayBuffer()) : new Uint8Array())
|
||||
.catch(() => new Uint8Array());
|
||||
// Compile once; the render workers (worker.js) instantiate the same compiled module.
|
||||
@@ -34,15 +34,15 @@
|
||||
WebAssembly.compileStreaming(fetch("./dac_web_bg.wasm")),
|
||||
chineseFont,
|
||||
]);
|
||||
globalThis.lightcraftModule = module;
|
||||
globalThis.appModule = module;
|
||||
await init({ module_or_path: module });
|
||||
// automation: `await lightcraft.command("library.info", "{}")` → JSON text
|
||||
globalThis.lightcraft = { command };
|
||||
// automation: `await app.command("library.info", "{}")` → JSON text
|
||||
globalThis.app = { command };
|
||||
start(font);
|
||||
})().catch((e) => {
|
||||
console.error(e);
|
||||
const el = document.getElementById("lightcraft_loading");
|
||||
if (el) el.textContent = "LightCraft failed to load: " + e;
|
||||
const el = document.getElementById("app_loading");
|
||||
if (el) el.textContent = "Failed to load: " + e;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -9,7 +9,7 @@ use wasm_bindgen::{JsCast, JsValue};
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
use web_sys::{FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetDirectoryOptions, FileSystemGetFileOptions, IdbDatabase};
|
||||
|
||||
const IDB_NAME: &str = "lightcraft";
|
||||
const IDB_NAME: &str = crate::legacy::IDB_NAME;
|
||||
const IDB_STORE: &str = "files";
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -339,7 +339,7 @@ pub fn request_persistence(on_result: impl FnOnce(bool) + 'static) {
|
||||
Ok(p) => JsFuture::from(p).await.ok().is_some_and(|v| v.is_truthy()),
|
||||
Err(_) => false,
|
||||
};
|
||||
log::info!("lightcraft: persistent storage {}", if granted { "granted" } else { "not granted" });
|
||||
log::info!("app: persistent storage {}", if granted { "granted" } else { "not granted" });
|
||||
on_result(granted);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -34,11 +34,11 @@ const UTF8: u16 = 0x0800;
|
||||
/// 1980-01-01 00:00 in MS-DOS format (date, time).
|
||||
const DOS_DATE: u16 = (1 << 5) | 1;
|
||||
|
||||
/// The readme inside every backup.
|
||||
pub const README: &str = "LightCraft library backup (browser version)\n\n\
|
||||
/// The readme inside every backup (`{app}` is filled with [`dac_brand::fill`]).
|
||||
pub const README: &str = "{app} library backup (browser version)\n\n\
|
||||
library/ the catalog (catalog.snap + catalog.log), presets, view state and preferences\n\
|
||||
originals/ every imported photo, as originals/<content hash>/<file name>\n\n\
|
||||
Restore it in LightCraft for the web with File > Restore Library from Backup...\n\
|
||||
Restore it in {app} for the web with File > Restore Library from Backup...\n\
|
||||
The photos in originals/ are the untouched files you imported; edits live in the catalog.\n";
|
||||
|
||||
/// Is `name` a safe library folder name (`library` or `library-<letters, digits, ->`)?
|
||||
@@ -234,7 +234,7 @@ pub fn data_offset(entry: &ZipEntry, local: &[u8]) -> Result<u64, String> {
|
||||
/// Check an entry's bytes.
|
||||
pub fn verify(entry: &ZipEntry, data: &[u8]) -> Result<(), String> {
|
||||
if entry.method != 0 {
|
||||
return Err(format!("{}: compressed entries are not supported (not a LightCraft backup?)", entry.name));
|
||||
return Err(format!("{}: compressed entries are not supported (not a {} backup?)", entry.name, dac_brand::DISPLAY_NAME));
|
||||
}
|
||||
if data.len() as u64 != entry.size || crc32fast::hash(data) != entry.crc {
|
||||
return Err(format!("{}: damaged (checksum mismatch)", entry.name));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Scripted in-app measurement (`?bench`): open the first photo in Detail, then drive the Exposure
|
||||
//! control exactly like a slider drag (begin interaction → N `develop.set` → end) and time each
|
||||
//! step from the command to the moment the new loupe texture is ready. Results go to the log
|
||||
//! (the browser console on the web) as one `lightcraft-bench {…json…}` line.
|
||||
//! (the browser console on the web) as one `app-bench {…json…}` line.
|
||||
|
||||
use dac_ui_egui::DacApp;
|
||||
use dac_ui_egui::render::Slot;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Names the browser build stored under its previous product name. They name data already in
|
||||
//! users' browsers, so they never follow the brand: changing them would orphan saved libraries.
|
||||
|
||||
/// The IndexedDB database holding the library when OPFS is unavailable.
|
||||
pub const IDB_NAME: &str = "lightcraft";
|
||||
/// The Web Locks name that keeps a library to one tab; shared with tabs still running an older build.
|
||||
pub const TAB_LOCK: &str = "lightcraft-library";
|
||||
@@ -1,4 +1,4 @@
|
||||
//! LightCraft in the browser: the same egui UI as the desktop app, compiled to WASM.
|
||||
//! The app in the browser: the same egui UI as the desktop app, compiled to WASM.
|
||||
//!
|
||||
//! Build with `cargo xtask web` (→ `target/web/`), then serve that folder over HTTP (see
|
||||
//! `docs/web.md`). Differences from the desktop host:
|
||||
@@ -25,6 +25,8 @@ pub mod wire;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod backend;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod legacy;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod safety;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
@@ -49,11 +49,11 @@ pub fn confirm(text: &str) -> bool {
|
||||
/// Cover the page with a message (the app can't continue, or must not start).
|
||||
pub fn show_blocking(text: &str) {
|
||||
let Some(doc) = document() else { return };
|
||||
let el = match doc.get_element_by_id("lightcraft_blocking") {
|
||||
let el = match doc.get_element_by_id("app_blocking") {
|
||||
Some(el) => el,
|
||||
None => {
|
||||
let Ok(el) = doc.create_element("div") else { return };
|
||||
el.set_id("lightcraft_blocking");
|
||||
el.set_id("app_blocking");
|
||||
let _ = el.set_attribute(
|
||||
"style",
|
||||
"position:fixed;inset:0;z-index:10;display:flex;align-items:center;justify-content:center;padding:32px;\
|
||||
@@ -66,7 +66,7 @@ pub fn show_blocking(text: &str) {
|
||||
}
|
||||
};
|
||||
el.set_text_content(Some(text));
|
||||
if let Some(loading) = doc.get_element_by_id("lightcraft_loading") {
|
||||
if let Some(loading) = doc.get_element_by_id("app_loading") {
|
||||
loading.remove();
|
||||
}
|
||||
}
|
||||
@@ -81,16 +81,17 @@ pub fn install_panic_hook() {
|
||||
_ => "unknown error".into(),
|
||||
};
|
||||
let at = info.location().map(|l| format!(" at {}:{}", l.file(), l.line())).unwrap_or_default();
|
||||
log::error!("LightCraft panicked{at}: {msg}");
|
||||
log::error!("{} panicked{at}: {msg}", dac_brand::DISPLAY_NAME);
|
||||
show_blocking(&format!(
|
||||
"LightCraft stopped after an internal error:\n{msg}\n\nThe library saved in this browser is kept. Reload the page to continue.\n\
|
||||
If it happens again right away, a photo may be the cause: reload with ?workers=0 or report the error."
|
||||
"{app} stopped after an internal error:\n{msg}\n\nThe library saved in this browser is kept. Reload the page to continue.\n\
|
||||
If it happens again right away, a photo may be the cause: reload with ?workers=0 or report the error.",
|
||||
app = dac_brand::DISPLAY_NAME
|
||||
));
|
||||
}));
|
||||
}
|
||||
|
||||
/// The name of the lock that keeps a library to one tab.
|
||||
const TAB_LOCK: &str = "lightcraft-library";
|
||||
const TAB_LOCK: &str = crate::legacy::TAB_LOCK;
|
||||
|
||||
/// Hold the library lock for as long as this page lives (Web Locks API). `Some(true)`: ours;
|
||||
/// `Some(false)`: another tab of this site has it; `None`: the browser can't tell (no Web Locks, or
|
||||
@@ -163,7 +164,7 @@ pub async fn backup(files: Files, backend: Backend, names: HashMap<String, Strin
|
||||
bytes += (header.len() + data.len()) as f64;
|
||||
Ok(())
|
||||
};
|
||||
add(&mut zip, "README.txt", README.as_bytes())?;
|
||||
add(&mut zip, "README.txt", dac_brand::fill(README).as_bytes())?;
|
||||
for name in LIBRARY_FILES {
|
||||
if let Some(d) = files.get(name) {
|
||||
add(&mut zip, &format!("library/{name}"), &d)?;
|
||||
@@ -190,7 +191,7 @@ pub async fn backup(files: Files, backend: Backend, names: HashMap<String, Strin
|
||||
opts.set_type("application/zip");
|
||||
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(|e| format!("{e:?}"))?;
|
||||
let day: String = String::from(js_sys::Date::new_0().to_iso_string()).chars().take(10).collect();
|
||||
download_blob(&format!("lightcraft-library-backup-{day}.zip"), &blob)?;
|
||||
download_blob(&format!("{}-library-backup-{day}.zip", dac_brand::BINARY), &blob)?;
|
||||
Ok((originals, bytes + tail.len() as f64))
|
||||
}
|
||||
|
||||
@@ -210,7 +211,7 @@ pub async fn restore(file: web_sys::File, backend: Backend) -> Result<String, St
|
||||
let cd = read_range(&file, cd_offset, cd_offset + cd_size).await?;
|
||||
let entries = parse_central(&cd)?;
|
||||
if !entries.iter().any(|e| e.name == "library/catalog.snap" || e.name == "library/catalog.log") {
|
||||
return Err("this isn't a LightCraft library backup (no library/catalog files)".into());
|
||||
return Err(format!("this isn't a {} library backup (no library/catalog files)", dac_brand::DISPLAY_NAME));
|
||||
}
|
||||
let dir = restored_dir_name(js_sys::Date::now());
|
||||
let mut written = 0usize;
|
||||
@@ -231,16 +232,16 @@ pub async fn restore(file: web_sys::File, backend: Backend) -> Result<String, St
|
||||
written += 1;
|
||||
}
|
||||
backend.write(ACTIVE_LIBRARY, dir.as_bytes()).await?;
|
||||
log::info!("lightcraft: restored {written} files into {dir}");
|
||||
log::info!("app: restored {written} files into {dir}");
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// File ▸ Restore Library from Backup…: confirm, pick the zip, restore, reload.
|
||||
pub fn pick_and_restore(backend: Backend, before_reload: impl FnOnce() + 'static) {
|
||||
let ok = confirm(
|
||||
"Restore a LightCraft library backup?\n\nThe backup becomes the library in this browser and the page reloads. \
|
||||
let ok = confirm(&dac_brand::fill(
|
||||
"Restore a {app} library backup?\n\nThe backup becomes the library in this browser and the page reloads. \
|
||||
The current library is not deleted: it stays in browser storage.",
|
||||
);
|
||||
));
|
||||
if !ok {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn download_names() {
|
||||
assert_eq!(download_name("dir/x-lightcraft.png"), "x-lightcraft.png");
|
||||
assert_eq!(download_name("dir/x-edit.png"), "x-edit.png");
|
||||
assert_eq!(download_name("y.png"), "y.png");
|
||||
assert_eq!(mime_for("a.JPG"), "image/jpeg");
|
||||
assert_eq!(mime_for("a"), "application/octet-stream");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Browser host: opens the library in browser storage, starts the render workers and eframe on
|
||||
//! `<canvas id="lightcraft_canvas">`, wires the file picker and drag-and-drop, and turns exports
|
||||
//! `<canvas id="app_canvas">`, wires the file picker and drag-and-drop, and turns exports
|
||||
//! into downloads.
|
||||
|
||||
use std::cell::Cell;
|
||||
@@ -45,7 +45,7 @@ fn perf_now() -> f64 {
|
||||
}
|
||||
|
||||
fn set_status(text: &str) {
|
||||
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_status")) {
|
||||
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("app_status")) {
|
||||
el.set_text_content(Some(text));
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,7 @@ async fn flush(files: Files, backend: Backend, dir: String, flushing: Rc<Cell<bo
|
||||
let ops = files.take_dirty();
|
||||
if ops.is_empty() {
|
||||
if files.error().is_some() {
|
||||
log::info!("lightcraft: saving works again");
|
||||
log::info!("app: saving works again");
|
||||
}
|
||||
files.set_error(None);
|
||||
break;
|
||||
@@ -291,17 +291,17 @@ async fn boot(opts: Options) -> Boot {
|
||||
if let Some(b) = &backend {
|
||||
if opts.reset {
|
||||
// everything this site keeps in the browser, photos included: never without asking
|
||||
let ok = safety::confirm(
|
||||
"?reset deletes the LightCraft library stored in this browser, including every imported photo. \
|
||||
let ok = safety::confirm(&dac_brand::fill(
|
||||
"?reset deletes the {app} library stored in this browser, including every imported photo. \
|
||||
This can't be undone. Delete it?",
|
||||
);
|
||||
));
|
||||
if ok {
|
||||
match b.clear().await {
|
||||
Ok(()) => log::info!("lightcraft: storage cleared (?reset)"),
|
||||
Ok(()) => log::info!("app: storage cleared (?reset)"),
|
||||
Err(e) => log::error!("clearing storage: {e}"),
|
||||
}
|
||||
} else {
|
||||
log::info!("lightcraft: ?reset declined; storage kept");
|
||||
log::info!("app: ?reset declined; storage kept");
|
||||
}
|
||||
}
|
||||
// a restored backup lives in its own folder (see `backup`)
|
||||
@@ -323,10 +323,10 @@ async fn boot(opts: Options) -> Boot {
|
||||
}
|
||||
crate::backend::request_persistence(|granted| {
|
||||
if !granted {
|
||||
notice(
|
||||
"This browser may delete LightCraft's library when it runs short of space (persistent storage wasn't granted). \
|
||||
notice(&dac_brand::fill(
|
||||
"This browser may delete {app}'s library when it runs short of space (persistent storage wasn't granted). \
|
||||
Keep your original photos elsewhere and use File ▸ Back Up Library… now and then.",
|
||||
);
|
||||
));
|
||||
}
|
||||
});
|
||||
// thumbnails stored without an index entry (index saved late, or lost): delete them
|
||||
@@ -350,7 +350,7 @@ async fn boot(opts: Options) -> Boot {
|
||||
}
|
||||
}
|
||||
log::info!(
|
||||
"lightcraft: storage {} opened in {:.0} ms ({} thumbnails indexed, {:.1} MB)",
|
||||
"app: storage {} opened in {:.0} ms ({} thumbnails indexed, {:.1} MB)",
|
||||
backend.as_ref().map_or("memory", |b| b.kind()),
|
||||
perf_now() - t0,
|
||||
index.len(),
|
||||
@@ -393,7 +393,7 @@ impl WebApp {
|
||||
let mut problem = None;
|
||||
match session.open_library_in(stores, true).cloned() {
|
||||
Ok(r) => log::info!(
|
||||
"lightcraft: library {} in {:.0} ms ({} photos; snapshot seq {}, {} ops replayed)",
|
||||
"app: library {} in {:.0} ms ({} photos; snapshot seq {}, {} ops replayed)",
|
||||
if r.created { "created" } else { "loaded" },
|
||||
perf_now() - t,
|
||||
session.catalog.len(),
|
||||
@@ -415,10 +415,10 @@ impl WebApp {
|
||||
}
|
||||
}
|
||||
if backend.is_some() {
|
||||
notice(
|
||||
"LightCraft in the browser is experimental. Your library is kept in this browser's storage: keep your original photos \
|
||||
notice(&dac_brand::fill(
|
||||
"{app} in the browser is experimental. Your library is kept in this browser's storage: keep your original photos \
|
||||
elsewhere and back up with File ▸ Back Up Library….",
|
||||
);
|
||||
));
|
||||
}
|
||||
// previews are large (≤ 2560 px, f32): keep few in a 32-bit address space
|
||||
session.media.preview_capacity = 3;
|
||||
@@ -450,7 +450,7 @@ impl WebApp {
|
||||
if let Some(w) = &workers {
|
||||
app.renderer.set_offload(Box::new(w.clone()));
|
||||
}
|
||||
log::info!("lightcraft: {n} render workers");
|
||||
log::info!("app: {n} render workers");
|
||||
CTX.with(|c| *c.borrow_mut() = Some(cc.egui_ctx.clone()));
|
||||
let origin = dac_ui_egui::now_ms() - perf_now();
|
||||
WebApp {
|
||||
@@ -632,9 +632,9 @@ impl eframe::App for WebApp {
|
||||
if let Some(b) = self.bench.as_mut()
|
||||
&& let Some(report) = b.step(&mut self.app)
|
||||
{
|
||||
log::info!("lightcraft-bench {report}");
|
||||
log::info!("app-bench {report}");
|
||||
if let Some((alive, ready, remote, inline)) = self.workers.as_ref().map(Workers::stats) {
|
||||
log::info!("lightcraft-workers {{\"alive\":{alive},\"ready\":{ready},\"remote_jobs\":{remote},\"inline_jobs\":{inline}}}");
|
||||
log::info!("app-workers {{\"alive\":{alive},\"ready\":{ready},\"remote_jobs\":{remote},\"inline_jobs\":{inline}}}");
|
||||
}
|
||||
set_status(&format!("bench: {report}"));
|
||||
self.bench = None;
|
||||
@@ -652,7 +652,7 @@ impl eframe::App for WebApp {
|
||||
self.app.ui(ui);
|
||||
if !self.first_frame_logged && !self.app.widgets.is_empty() {
|
||||
self.first_frame_logged = true;
|
||||
log::info!("lightcraft first UI frame at {:.0} ms after navigation start", perf_now());
|
||||
log::info!("app first UI frame at {:.0} ms after navigation start", perf_now());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -662,26 +662,33 @@ impl eframe::App for WebApp {
|
||||
pub fn start(chinese_font: Vec<u8>) {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
safety::install_panic_hook();
|
||||
log::info!("lightcraft: wasm instantiated at {:.0} ms", perf_now());
|
||||
log::info!("app: wasm instantiated at {:.0} ms", perf_now());
|
||||
// index.html is static: the page title and loading text come from the brand
|
||||
if let Some(doc) = window().and_then(|w| w.document()) {
|
||||
doc.set_title(dac_brand::DISPLAY_NAME);
|
||||
if let Some(el) = doc.get_element_by_id("app_loading") {
|
||||
el.set_text_content(Some(&format!("Loading {}…", dac_brand::DISPLAY_NAME)));
|
||||
}
|
||||
}
|
||||
if chinese_font.is_empty() {
|
||||
log::warn!("lightcraft: no Simplified Chinese font supplied; Chinese-only glyphs will show as boxes");
|
||||
log::warn!("app: no Simplified Chinese font supplied; Chinese-only glyphs will show as boxes");
|
||||
}
|
||||
let opts = Options::from_url();
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
// one tab per library: two would each keep their own copy and overwrite each other's saves
|
||||
if opts.store != "memory" && safety::acquire_tab_lock().await == Some(false) {
|
||||
safety::show_blocking(
|
||||
"LightCraft is already open in another tab or window of this browser.\n\n\
|
||||
safety::show_blocking(&dac_brand::fill(
|
||||
"{app} is already open in another tab or window of this browser.\n\n\
|
||||
Switch to that tab, or close it and reload this page. (Two tabs would overwrite each other's changes.)",
|
||||
);
|
||||
));
|
||||
return;
|
||||
}
|
||||
let Some(canvas) = window()
|
||||
.and_then(|w| w.document())
|
||||
.and_then(|d| d.get_element_by_id("lightcraft_canvas"))
|
||||
.and_then(|d| d.get_element_by_id("app_canvas"))
|
||||
.and_then(|e| e.dyn_into::<web_sys::HtmlCanvasElement>().ok())
|
||||
else {
|
||||
log::error!("missing <canvas id=\"lightcraft_canvas\">");
|
||||
log::error!("missing <canvas id=\"app_canvas\">");
|
||||
return;
|
||||
};
|
||||
let boot = boot(opts).await;
|
||||
@@ -689,13 +696,13 @@ pub fn start(chinese_font: Vec<u8>) {
|
||||
let r = runner.start(canvas, eframe::WebOptions::default(), Box::new(move |cc| Ok(Box::new(WebApp::new(cc, boot, chinese_font))))).await;
|
||||
match r {
|
||||
Ok(()) => {
|
||||
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("lightcraft_loading")) {
|
||||
if let Some(el) = window().and_then(|w| w.document()).and_then(|d| d.get_element_by_id("app_loading")) {
|
||||
el.remove();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("LightCraft failed to start: {e:?}");
|
||||
set_status(&format!("LightCraft failed to start: {e:?}"));
|
||||
log::error!("{} failed to start: {e:?}", dac_brand::DISPLAY_NAME);
|
||||
set_status(&format!("{} failed to start: {e:?}", dac_brand::DISPLAY_NAME));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -240,15 +240,15 @@ impl Workers {
|
||||
inline_done: 0,
|
||||
inline_this_frame: false,
|
||||
})));
|
||||
let module = get(&js_sys::global(), "lightcraftModule");
|
||||
let module = get(&js_sys::global(), "appModule");
|
||||
if module.is_undefined() {
|
||||
log::warn!("render workers: no compiled module on the page (window.lightcraftModule); rendering on the main thread");
|
||||
log::warn!("render workers: no compiled module on the page (window.appModule); rendering on the main thread");
|
||||
return w;
|
||||
}
|
||||
for i in 0..n {
|
||||
let opts = web_sys::WorkerOptions::new();
|
||||
opts.set_type(web_sys::WorkerType::Module);
|
||||
opts.set_name(&format!("lightcraft-render-{i}"));
|
||||
opts.set_name(&format!("{}-render-{i}", dac_brand::BINARY));
|
||||
let worker = match web_sys::Worker::new_with_options("./worker.js", &opts) {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// LightCraft render worker: a second instance of the app's wasm module that renders jobs posted
|
||||
// The render worker: a second instance of the app's wasm module that renders jobs posted
|
||||
// by the page (see apps/web/src/workers.rs). The first message carries the compiled
|
||||
// WebAssembly.Module and the storage backend kind; the module then takes over `onmessage`.
|
||||
import init, { worker_main } from "./dac_web.js";
|
||||
|
||||
@@ -9,9 +9,10 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| Path | Asset | Creator | Source | Licence | Added | Modifications |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `assets/fonts/Inter-*.ttf` (Regular, Medium, SemiBold) | Inter | Rasmus Andersson | https://github.com/rsms/inter | SIL Open Font License 1.1 (`assets/fonts/OFL-Inter.txt`) | 2026-09-30 | none |
|
||||
| `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` |
|
||||
| `brand/icon.svg`, `brand/logo.svg` | Placeholder app icon and logo (six-blade lens aperture, no text) | the project contributors | original work for this repository | MIT (`LICENSE`) | 2026-10-10 | none |
|
||||
| `assets/app-icon/` (PNG/ICNS/ICO/SVG files) | app icon renders | project contributors | original work, generated from `brand/icon.svg` by `packaging/icons.sh` | MIT (`assets/app-icon/LICENSE.txt`) | 2026-10-10 | rendered to PNG/ICNS/ICO |
|
||||
| `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none |
|
||||
| `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
|
||||
| `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | macOS DMG window background | @XusBadia | original work for this repository; embeds the app icon (`brand/icon.svg`, referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
|
||||
| `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none |
|
||||
| `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
@@ -20,7 +21,6 @@ covers a whole directory), or if a licence file referenced in the Licence column
|
||||
| `docs/images/*-earthrise.jpg`, `docs/images/grid-pd.jpg` | "Earthrise" (AS08-14-2383) | NASA / Bill Anders, Apollo 8 (1968) | https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots |
|
||||
| `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a |
|
||||
| `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none |
|
||||
| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-<family>.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and ships Noto Sans CJK SC Regular as a separate font file |
|
||||
|
||||
Notes
|
||||
|
||||
@@ -1,13 +1,2 @@
|
||||
LightCraft app icon (lightcraft.svg and every file derived from it in this directory:
|
||||
lightcraft-small.svg, lightcraft-1024.png, lightcraft-macos-512.png, lightcraft.icns,
|
||||
lightcraft.ico, hicolor/).
|
||||
|
||||
Original artwork made for this project by the LightCraft project owner in ArtCraft (no
|
||||
third-party material). Licensed like the rest of LightCraft, under either of
|
||||
|
||||
* Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0)
|
||||
* MIT license (https://opensource.org/licenses/MIT)
|
||||
|
||||
at your option.
|
||||
|
||||
Regenerate the derived files with packaging/icons.sh.
|
||||
The app icon (brand/icon.svg and every file derived from it in this directory) is original work by the
|
||||
project contributors, licensed under the MIT licence (see LICENSE at the repository root).
|
||||
|
||||
@@ -1,49 +1,20 @@
|
||||
# LightCraft app icon
|
||||
# App icon
|
||||
|
||||
A **lynx**, frontal head-and-shoulders portrait with an alert gaze, in the style of an engraving: the
|
||||
Crafting Apps "owl template" (full-bleed colour field, tight portrait, the figure in paper with ink line
|
||||
work and a crisp ink contour, no roundel or border).
|
||||
|
||||
## Palette (exactly three colours)
|
||||
|
||||
| Colour | Hex | Used for |
|
||||
|---|---|---|
|
||||
| Ink | `#0b0b0c` | line work and the contour |
|
||||
| Paper | `#efe9dc` | the figure (the lynx) |
|
||||
| LightCraft amber | `#f2a516` | the full-bleed field (the app colour) |
|
||||
|
||||
## Geometry
|
||||
|
||||
`viewBox="0 0 512 512"`, a rounded-square tile with `rx=112`, clipped; the colour fills the whole tile.
|
||||
macOS files (`.icns`, `lightcraft-macos-512.png`) add Apple's transparent margin (an 824/1024 body);
|
||||
Windows and Linux files use the full-bleed tile so the lynx reads at 16-48 px.
|
||||
|
||||
## Provenance
|
||||
|
||||
The owner's original drawing, made in ArtCraft (2880 px, keyed to the three colours), vectorised with
|
||||
craftrules `assets/logo-options/_tools/vectorize_tile.py`. The source drawing stays in craftrules at
|
||||
`assets/app-icons/lightcraft/source.png`; `lightcraft.svg` here is the canonical artwork. Licence:
|
||||
`LICENSE.txt` (MIT OR Apache-2.0, like the repo).
|
||||
|
||||
## Files
|
||||
Every file here is generated from `brand.toml`'s `icon_svg` (`brand/icon.svg`) by `packaging/icons.sh`.
|
||||
Do not edit them by hand: change the SVG (or point `icon_svg` at another one) and rerun the script.
|
||||
|
||||
| File | What |
|
||||
|---|---|
|
||||
| `lightcraft.svg` | canonical vector, traced at 2048 px (about 770 KB) |
|
||||
| `lightcraft-small.svg` | lighter vector, traced at 1024 px (about 360 KB) |
|
||||
| `lightcraft-1024.png` | 1024 px render (store listings, docs) |
|
||||
| `lightcraft-macos-512.png` | runtime window/Dock icon on macOS (embedded by `apps/app/src/main.rs`) |
|
||||
| `lightcraft.icns` | macOS bundle icon (`CFBundleIconFile`) |
|
||||
| `lightcraft.ico` | Windows icon, 16-256 px, embedded in `lightcraft.exe` by `apps/app/build.rs` |
|
||||
| `hicolor/<n>x<n>/apps/ai.storyteller.lightcraft.png` | Linux icon theme, 16-512 px; the 256 px one is also the runtime window icon on Windows and Linux |
|
||||
| `hicolor/scalable/apps/ai.storyteller.lightcraft.svg` | Linux scalable icon (the small SVG) |
|
||||
| `app-1024.png` | 1024 px render (store listings, docs) |
|
||||
| `app-macos-512.png` | runtime window/Dock icon on macOS (embedded by `apps/app/src/main.rs`); Apple's grid, with a transparent margin |
|
||||
| `app.icns` | macOS bundle icon (`CFBundleIconFile`) |
|
||||
| `app.ico` | Windows icon, 16-256 px, embedded in the executable by `apps/app/build.rs` |
|
||||
| `hicolor/<n>x<n>/apps/app.png` | Linux icon theme, 16-512 px; the 256 px one is also the runtime window icon on Windows and Linux |
|
||||
| `hicolor/scalable/apps/app.svg` | Linux scalable icon (a copy of the source SVG) |
|
||||
|
||||
The app id is `ai.storyteller.lightcraft`: the Wayland app id, the `.desktop` file
|
||||
(`packaging/linux/ai.storyteller.lightcraft.desktop`, `Icon=ai.storyteller.lightcraft`) and the hicolor icon
|
||||
name. To install on Linux, copy `hicolor/` into `/usr/share/icons/hicolor/` (or `~/.local/share/icons/hicolor/`)
|
||||
and the `.desktop` file into `applications/`.
|
||||
Packaging installs the hicolor icons under the brand's app id (`packaging/env.sh` `install_icons`).
|
||||
|
||||
## Regenerate
|
||||
|
||||
Edit `lightcraft.svg` (or `lightcraft-small.svg`), then run `packaging/icons.sh` (needs `resvg`; `iconutil`
|
||||
on macOS for the `.icns`; the `.ico` is packed by `cargo xtask ico`). Every output is committed.
|
||||
`packaging/icons.sh` needs `resvg` (or `rsvg-convert`); the `.icns` needs `iconutil` (macOS) or Python Pillow,
|
||||
and the `.ico` is packed by `cargo xtask ico`. Every output is committed.
|
||||
|
||||
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 663 B |
|
After Width: | Height: | Size: 698 B |
|
Before Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 352 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<!-- Placeholder mark: six-blade lens aperture. Original work, MIT licence (see assets/ATTRIBUTION.md). -->
|
||||
<rect width="256" height="256" rx="56" fill="#0d1b24"/>
|
||||
<circle cx="128.0" cy="128.0" r="102.40" fill="#123447"/>
|
||||
<path d="M128.00 89.60L228.50 147.62A102.40 102.40 0 0 0 128.00 25.60Z" fill="#2b6f8f"/><path d="M161.26 108.80L161.26 224.85A102.40 102.40 0 0 0 216.68 76.80Z" fill="#348aa8"/><path d="M161.26 147.20L60.75 205.22A102.40 102.40 0 0 0 216.68 179.20Z" fill="#3fa3bf"/><path d="M128.00 166.40L27.50 108.38A102.40 102.40 0 0 0 128.00 230.40Z" fill="#2b6f8f"/><path d="M94.74 147.20L94.74 31.15A102.40 102.40 0 0 0 39.32 179.20Z" fill="#348aa8"/><path d="M94.74 108.80L195.25 50.78A102.40 102.40 0 0 0 39.32 76.80Z" fill="#3fa3bf"/>
|
||||
<circle cx="128.0" cy="128.0" r="102.40" fill="none" stroke="#e8eef2" stroke-width="7.68"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 947 B |
|
Before Width: | Height: | Size: 336 KiB |
|
Before Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 352 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 750 KiB |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
|
||||
<!-- Placeholder mark: six-blade lens aperture. Original work, MIT licence (see assets/ATTRIBUTION.md). -->
|
||||
<rect width="256" height="256" rx="56" fill="#0d1b24"/>
|
||||
<circle cx="128.0" cy="128.0" r="102.40" fill="#123447"/>
|
||||
<path d="M128.00 89.60L228.50 147.62A102.40 102.40 0 0 0 128.00 25.60Z" fill="#2b6f8f"/><path d="M161.26 108.80L161.26 224.85A102.40 102.40 0 0 0 216.68 76.80Z" fill="#348aa8"/><path d="M161.26 147.20L60.75 205.22A102.40 102.40 0 0 0 216.68 179.20Z" fill="#3fa3bf"/><path d="M128.00 166.40L27.50 108.38A102.40 102.40 0 0 0 128.00 230.40Z" fill="#2b6f8f"/><path d="M94.74 147.20L94.74 31.15A102.40 102.40 0 0 0 39.32 179.20Z" fill="#348aa8"/><path d="M94.74 108.80L195.25 50.78A102.40 102.40 0 0 0 39.32 76.80Z" fill="#3fa3bf"/>
|
||||
<circle cx="128.0" cy="128.0" r="102.40" fill="none" stroke="#e8eef2" stroke-width="7.68"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 947 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<!-- Placeholder mark: six-blade lens aperture. Original work, MIT licence (see assets/ATTRIBUTION.md). -->
|
||||
|
||||
<circle cx="256.0" cy="256.0" r="204.80" fill="#123447"/>
|
||||
<path d="M256.00 179.20L457.00 295.25A204.80 204.80 0 0 0 256.00 51.20Z" fill="#2b6f8f"/><path d="M322.51 217.60L322.51 449.70A204.80 204.80 0 0 0 433.36 153.60Z" fill="#348aa8"/><path d="M322.51 294.40L121.51 410.45A204.80 204.80 0 0 0 433.36 358.40Z" fill="#3fa3bf"/><path d="M256.00 332.80L55.00 216.75A204.80 204.80 0 0 0 256.00 460.80Z" fill="#2b6f8f"/><path d="M189.49 294.40L189.49 62.30A204.80 204.80 0 0 0 78.64 358.40Z" fill="#348aa8"/><path d="M189.49 217.60L390.49 101.55A204.80 204.80 0 0 0 78.64 153.60Z" fill="#3fa3bf"/>
|
||||
<circle cx="256.0" cy="256.0" r="204.80" fill="none" stroke="#1d2b34" stroke-width="15.36"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 901 B |
@@ -13,7 +13,7 @@ use std::sync::Mutex;
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/brand.rs"));
|
||||
|
||||
/// `{prefix}_{name}`, e.g. `env_var("LOG")` is `NONAMEYET_LOG`.
|
||||
/// `{prefix}_{name}`, e.g. `env_var("LOG")` is `<ENV_PREFIX>_LOG`.
|
||||
pub fn env_var(name: &str) -> String {
|
||||
format!("{ENV_PREFIX}_{name}")
|
||||
}
|
||||
@@ -68,7 +68,7 @@ fn config_root() -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
/// The folder name for `name` on this platform: as written on macOS and Windows, lower-case on other
|
||||
/// Unixes (`~/.config/nonameyet`).
|
||||
/// Unixes (`~/.config/<settings_dir>`).
|
||||
pub fn settings_dir_name(name: &str) -> String {
|
||||
if cfg!(any(target_os = "macos", windows)) { name.to_string() } else { name.to_lowercase() }
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ name = "dac-catalog"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "LightCraft library: photos, albums, ratings/flags, search/filter/sort, op-log persistence (L3)"
|
||||
description = "Library: photos, albums, ratings/flags, search/filter/sort, op-log persistence (L3)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
dac-brand = { workspace = true }
|
||||
dac-develop = { workspace = true }
|
||||
dac-meta = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -111,7 +111,7 @@ pub struct LoadReport {
|
||||
}
|
||||
|
||||
/// Where persistence time goes (reported by `library.info` → `persistence`; printed to stderr
|
||||
/// per write under `LIGHTCRAFT_PROFILE`). Times are wall-clock milliseconds on the calling
|
||||
/// per write under `{ENV_PREFIX}_PROFILE`). Times are wall-clock milliseconds on the calling
|
||||
/// thread, i.e. how long the caller (the UI thread, for the app) was blocked.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -159,10 +159,10 @@ pub struct SnapshotTiming {
|
||||
pub records: u64,
|
||||
}
|
||||
|
||||
/// `LIGHTCRAFT_PROFILE` is set: print persistence timings to stderr.
|
||||
/// `{ENV_PREFIX}_PROFILE` is set: print persistence timings to stderr.
|
||||
fn profiling() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| std::env::var_os("LIGHTCRAFT_PROFILE").is_some())
|
||||
*ON.get_or_init(|| dac_brand::env_is_set("PROFILE"))
|
||||
}
|
||||
|
||||
fn ms_since(t: web_time::Instant) -> f64 {
|
||||
@@ -226,7 +226,7 @@ pub fn decode_record(line: &str) -> Option<(u64, Op)> {
|
||||
enum Line {
|
||||
Record(u64, Op),
|
||||
/// The CRC matches — the line is exactly what was written — but the op doesn't parse: it
|
||||
/// was written by a newer LightCraft (an op or field this version doesn't know), not damaged.
|
||||
/// was written by a newer version of the app (an op or field this version doesn't know), not damaged.
|
||||
Newer(u64),
|
||||
/// Malformed or a CRC mismatch: torn or damaged.
|
||||
Bad,
|
||||
@@ -250,7 +250,7 @@ fn decode_line(line: &str) -> Line {
|
||||
}
|
||||
}
|
||||
|
||||
/// The error for a library written by a newer LightCraft.
|
||||
/// The error for a library written by a newer version of the app.
|
||||
fn newer(what: String) -> CatalogError {
|
||||
CatalogError::Newer(format!("{what}; this version reads catalog format v{VERSION} and older"))
|
||||
}
|
||||
@@ -317,7 +317,7 @@ impl Journal {
|
||||
// the header first: a newer snapshot may not parse as this version's catalog
|
||||
let h: SnapHeader = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?;
|
||||
if h.format != FORMAT {
|
||||
return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: not a LightCraft catalog (format {:?})", h.format)));
|
||||
return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: not a {} catalog (format {:?})", dac_brand::DISPLAY_NAME, h.format)));
|
||||
}
|
||||
if h.version > VERSION {
|
||||
return Err(newer(format!("{SNAPSHOT} is catalog format v{}", h.version)));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The LightCraft library (catalog).
|
||||
//! The library (catalog).
|
||||
//!
|
||||
//! State changes only through [`Op`]s. [`Catalog::apply`] returns the inverse op, which gives:
|
||||
//! - **persistence**: ops are appended to a log (JSON lines) and replayed on load after the last
|
||||
@@ -56,9 +56,9 @@ pub enum CatalogError {
|
||||
Corrupt(String),
|
||||
#[error("catalog storage: {0}")]
|
||||
Io(String),
|
||||
/// The library was written by a newer LightCraft (a newer catalog format, or a change this
|
||||
/// The library was written by a newer version of the app (a newer catalog format, or a change this
|
||||
/// version doesn't know). Nothing was read into the session and nothing was modified.
|
||||
#[error("this library was written by a newer version of LightCraft ({0}); update LightCraft to open it. The library was left unchanged.")]
|
||||
#[error("this library was written by a newer version of the app ({0}); update the app to open it. The library was left unchanged.")]
|
||||
Newer(String),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! One process per library: an exclusive OS lock on `<library>/catalog.lock`, held for as long as
|
||||
//! the library is open (issue #99).
|
||||
//!
|
||||
//! Two processes on the same library (the app and `lightcraft-cli --library`, two app instances,
|
||||
//! Two processes on the same library (the app and the CLI with `--library`, two app instances,
|
||||
//! two computers on a shared folder) would each keep their own catalog and `seq` counter, and
|
||||
//! whichever wrote the last snapshot would silently drop the other's edits. So opening a library
|
||||
//! takes the lock first, and a second opener gets [`LockError::InUse`] instead.
|
||||
@@ -34,7 +34,7 @@ pub struct LockOwner {
|
||||
pub pid: u32,
|
||||
/// Computer name, if known.
|
||||
pub host: String,
|
||||
/// The program (`LightCraft`, `lightcraft-cli`…) and its version.
|
||||
/// The program (the display name or the CLI binary name) and its version.
|
||||
pub program: String,
|
||||
pub version: String,
|
||||
/// When it opened the library (seconds since 1970).
|
||||
@@ -53,9 +53,9 @@ impl LockOwner {
|
||||
}
|
||||
}
|
||||
|
||||
/// "LightCraft 0.3.0 (process 123 on studio-mac)".
|
||||
/// "<app> 0.3.0 (process 123 on studio-mac)".
|
||||
pub fn describe(&self) -> String {
|
||||
let program = if self.program.is_empty() { "another LightCraft" } else { self.program.as_str() };
|
||||
let program = if self.program.is_empty() { "another program" } else { self.program.as_str() };
|
||||
let version = if self.version.is_empty() { String::new() } else { format!(" {}", self.version) };
|
||||
let host = if self.host.is_empty() {
|
||||
String::new()
|
||||
@@ -77,7 +77,7 @@ fn host_name() -> String {
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum LockError {
|
||||
/// Another process has the library open.
|
||||
#[error("This library is already open in {}. Close it there first.", .0.as_ref().map(LockOwner::describe).unwrap_or_else(|| "another LightCraft program".into()))]
|
||||
#[error("This library is already open in {}. Close it there first.", .0.as_ref().map(LockOwner::describe).unwrap_or_else(|| "another program".into()))]
|
||||
InUse(Option<LockOwner>),
|
||||
}
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ pub struct Meta {
|
||||
pub creator: String,
|
||||
pub keywords: Vec<String>,
|
||||
/// Face/pet/focus regions read from XMP (MWG-RS), on the upright (EXIF-oriented) photo.
|
||||
/// Removing or resizing one edits the catalog only; LightCraft never writes regions to XMP.
|
||||
/// Removing or resizing one edits the catalog only; the app never writes regions to XMP.
|
||||
/// Left out of the catalog JSON when empty (most photos), so older catalogs read unchanged.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub regions: Vec<dac_meta::Region>,
|
||||
|
||||
@@ -115,7 +115,7 @@ fn create_temp(path: &Path) -> io::Result<(PathBuf, File)> {
|
||||
}
|
||||
let dir = parent_of(path);
|
||||
for _ in 0..1000 {
|
||||
let tmp = dir.join(format!(".lightcraft-{}-{}.tmp", std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
let tmp = dir.join(format!(".{}-{}-{}.tmp", dac_brand::BINARY, std::process::id(), N.fetch_add(1, Ordering::Relaxed)));
|
||||
match OpenOptions::new().write(true).create_new(true).open(&tmp) {
|
||||
Ok(f) => return Ok((tmp, f)),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
|
||||
|
||||
@@ -268,7 +268,7 @@ fn fs_store_background_snapshot() {
|
||||
edit(&mut j, &mut live, k);
|
||||
}
|
||||
// a previous crash mid-snapshot left a partial temp file
|
||||
std::fs::write(dir.join(format!("{SNAPSHOT}.tmp")), b"{\"format\":\"lightcraft-catal").unwrap();
|
||||
std::fs::write(dir.join(format!("{SNAPSHOT}.tmp")), b"{\"format\":\"dac-catal").unwrap();
|
||||
let (mut j2, c2, r) = Journal::open(Box::new(FsStore::open(&dir).unwrap())).unwrap();
|
||||
assert_eq!((c2.to_snapshot(), r.snapshot_seq), (live.to_snapshot(), 0));
|
||||
drop(j);
|
||||
|
||||
@@ -89,7 +89,7 @@ fn assert_refused_untouched(m: &MemStore) {
|
||||
let e = Journal::open(Box::new(m.clone())).err().expect("refused");
|
||||
assert!(matches!(e, CatalogError::Newer(_)), "{e:?}");
|
||||
let msg = e.to_string();
|
||||
assert!(msg.contains("newer version of LightCraft") && msg.contains("left unchanged"), "{msg}");
|
||||
assert!(msg.contains("newer version of the app") && msg.contains("left unchanged"), "{msg}");
|
||||
assert_eq!(*m.files.lock().unwrap(), before, "nothing modified, nothing renamed");
|
||||
}
|
||||
|
||||
|
||||
@@ -15,21 +15,33 @@ fn temp_dir(tag: &str) -> PathBuf {
|
||||
#[test]
|
||||
fn second_opener_is_refused_until_the_first_lets_go() {
|
||||
let dir = temp_dir("twice");
|
||||
let first = LibraryLock::acquire(&dir, "LightCraft").unwrap();
|
||||
let first = LibraryLock::acquire(&dir, dac_brand::DISPLAY_NAME).unwrap();
|
||||
assert!(first.held());
|
||||
let owner: LockOwner = serde_json::from_slice(&std::fs::read(dir.join(OWNER)).unwrap()).unwrap();
|
||||
assert_eq!((owner.pid, owner.program.as_str()), (std::process::id(), "LightCraft"));
|
||||
assert_eq!((owner.pid, owner.program.as_str()), (std::process::id(), dac_brand::DISPLAY_NAME));
|
||||
|
||||
let e = LibraryLock::acquire(&dir, "dac-cli").unwrap_err();
|
||||
let LockError::InUse(Some(who)) = &e else { panic!("{e:?}") };
|
||||
assert_eq!(who.pid, std::process::id());
|
||||
let msg = e.to_string();
|
||||
assert!(msg.contains("already open in LightCraft") && msg.contains(&format!("process {}", std::process::id())), "{msg}");
|
||||
assert!(
|
||||
msg.contains(&format!("already open in {}", dac_brand::DISPLAY_NAME)) && msg.contains(&format!("process {}", std::process::id())),
|
||||
"{msg}"
|
||||
);
|
||||
|
||||
drop(first);
|
||||
assert!(!dir.join(OWNER).exists(), "the owner note goes with the lock");
|
||||
assert!(dir.join(LOCK).exists(), "the lock file itself may stay: only a held lock refuses");
|
||||
let again = LibraryLock::acquire(&dir, "dac-cli").unwrap();
|
||||
// another test spawning a process can briefly share this lock's file descriptor (between fork
|
||||
// and exec), so the release may take a moment to be seen
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
|
||||
let again = loop {
|
||||
match LibraryLock::acquire(&dir, "dac-cli") {
|
||||
Ok(l) => break l,
|
||||
Err(_) if std::time::Instant::now() < deadline => std::thread::sleep(std::time::Duration::from_millis(10)),
|
||||
Err(e) => panic!("{e:?}"),
|
||||
}
|
||||
};
|
||||
assert!(again.held());
|
||||
drop(again);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
@@ -40,8 +52,8 @@ fn second_opener_is_refused_until_the_first_lets_go() {
|
||||
fn leftover_lock_files_are_not_a_lock() {
|
||||
let dir = temp_dir("stale");
|
||||
std::fs::write(dir.join(LOCK), b"").unwrap();
|
||||
std::fs::write(dir.join(OWNER), br#"{"pid":999999,"host":"elsewhere","program":"LightCraft","version":"0.2.0","since":1}"#).unwrap();
|
||||
let l = LibraryLock::acquire(&dir, "LightCraft").unwrap();
|
||||
std::fs::write(dir.join(OWNER), br#"{"pid":999999,"host":"elsewhere","program":"Other","version":"0.2.0","since":1}"#).unwrap();
|
||||
let l = LibraryLock::acquire(&dir, dac_brand::DISPLAY_NAME).unwrap();
|
||||
assert!(l.held());
|
||||
let owner: LockOwner = serde_json::from_slice(&std::fs::read(dir.join(OWNER)).unwrap()).unwrap();
|
||||
assert_eq!(owner.pid, std::process::id(), "the note now names this process");
|
||||
@@ -67,7 +79,7 @@ fn lock_held_by_another_process_is_released_when_it_dies() {
|
||||
assert!(std::time::Instant::now() < deadline, "child never locked");
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
}
|
||||
let e = LibraryLock::acquire(&dir, "LightCraft").unwrap_err();
|
||||
let e = LibraryLock::acquire(&dir, dac_brand::DISPLAY_NAME).unwrap_err();
|
||||
let LockError::InUse(Some(who)) = &e else { panic!("{e:?}") };
|
||||
assert_eq!(who.pid, child.id());
|
||||
assert!(e.to_string().contains("on this computer") || who.host.is_empty(), "{e}");
|
||||
@@ -75,7 +87,7 @@ fn lock_held_by_another_process_is_released_when_it_dies() {
|
||||
child.kill().unwrap(); // SIGKILL: no clean-up runs, like a crash
|
||||
child.wait().unwrap();
|
||||
assert!(dir.join(OWNER).exists(), "the crashed holder's note is left behind");
|
||||
let l = LibraryLock::acquire(&dir, "LightCraft").expect("the OS released the dead process's lock");
|
||||
let l = LibraryLock::acquire(&dir, dac_brand::DISPLAY_NAME).expect("the OS released the dead process's lock");
|
||||
assert!(l.held());
|
||||
drop(l);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
@@ -3,12 +3,13 @@ name = "dac-codecs"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "LightCraft standard image codecs: sniffing, decode to linear light, ICC, encoders (L1)"
|
||||
description = "Standard image codecs: sniffing, decode to linear light, ICC, encoders (L1)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
dac-brand = { workspace = true }
|
||||
dac-geom = { workspace = true }
|
||||
dac-color = { workspace = true }
|
||||
dac-raster = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# dac-codecs (L1)
|
||||
|
||||
Standard image formats for LightCraft: format sniffing, decoding to **linear light** with colour
|
||||
Standard image formats for the app: format sniffing, decoding to **linear light** with colour
|
||||
management, fast thumbnails, and encoders with metadata embedding. Pure Rust, no C dependencies,
|
||||
builds for `wasm32-unknown-unknown`.
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const F: Format = Format::Heif;
|
||||
|
||||
/// The reason given when this build has no HEIF decoder.
|
||||
#[cfg(not(feature = "heif"))]
|
||||
pub(crate) const NOT_IN_BUILD: &str = "HEIC/HEIF support isn't included in this build of LightCraft";
|
||||
pub(crate) const NOT_IN_BUILD: &str = "HEIC/HEIF support isn't included in this build";
|
||||
|
||||
/// The reason a HEIF file that no build can open is refused (this crate decodes HEVC stills
|
||||
/// only; the wrapper names the exact construct in its own errors).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Standard image codecs for LightCraft (layer L1).
|
||||
//! Standard image codecs (layer L1).
|
||||
//!
|
||||
//! - [`sniff`] detects the container format (and flags camera raws for `dac-raw`).
|
||||
//! - [`decode`] decodes JPEG, PNG, TIFF, WebP, GIF, BMP, PSD (merged composite) and JPEG XL into
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::exif::Tiff;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Image container formats LightCraft recognises.
|
||||
/// Image container formats the app recognises.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum Format {
|
||||
Jpeg,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! Corpus tests over `corpus/images/**`, `corpus/pngsuite/**` and `corpus/raw/**` (git-ignored; fetched
|
||||
//! with `cargo xtask corpus --download`). Each test skips cleanly when its directory is absent.
|
||||
//! `LIGHTCRAFT_CORPUS` overrides the corpus root.
|
||||
//! `{ENV_PREFIX}_CORPUS` overrides the corpus root.
|
||||
|
||||
use dac_codecs::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn corpus_root() -> PathBuf {
|
||||
std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
dac_brand::env_os("CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus"))
|
||||
}
|
||||
|
||||
fn files(dir: &Path) -> Vec<PathBuf> {
|
||||
|
||||
@@ -3,12 +3,13 @@ name = "dac-color"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "LightCraft color (L0)"
|
||||
description = "Color (L0)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
dac-brand = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
dac-geom = { workspace = true }
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Colour science for LightCraft.
|
||||
//! Colour science.
|
||||
//!
|
||||
//! - [`Mat3`] and RGB colour spaces defined by primaries + white point ([`RgbSpace`]).
|
||||
//! - Chromatic adaptation (Bradford), correlated colour temperature ↔ chromaticity, and the
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "dac-denoise-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "LightCraft denoise model metadata, Bayer tiling and cache formats (L1; no inference backend)"
|
||||
description = "Denoise model metadata, Bayer tiling and cache formats (L1; no inference backend)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -13,6 +13,7 @@ default = []
|
||||
rawnind-model = []
|
||||
|
||||
[dependencies]
|
||||
dac-brand = { workspace = true }
|
||||
dac-raster = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The denoise models LightCraft knows about: what each one is, who may use it, and where to fetch it.
|
||||
//! The denoise models the app knows about: what each one is, who may use it, and where to fetch it.
|
||||
//!
|
||||
//! None is part of LightCraft. A model with an address here can be downloaded when the user asks and has accepted
|
||||
//! None is part of the app. A model with an address here can be downloaded when the user asks and has accepted
|
||||
//! its terms; anything else is brought by the user as an `.onnx` file with a `denoise-model.json` beside it.
|
||||
|
||||
#[cfg(any(feature = "rawnind-model", test))]
|
||||
@@ -31,7 +31,7 @@ fn rawnind_bayer() -> Known {
|
||||
name: "GPL-3.0".into(),
|
||||
commercial: Commercial::Yes,
|
||||
url: Some("https://www.gnu.org/licenses/gpl-3.0.html".into()),
|
||||
notice: "The weights are published under the GNU General Public License v3. LightCraft does not include them: they \
|
||||
notice: "The weights are published under the GNU General Public License v3. The app does not include them: they \
|
||||
are downloaded to your computer at your request and used by it only. If you pass the model file on, the \
|
||||
GPL applies to that copy. Commercial use is subject to the GPL terms, including applicable source, licence and notice obligations on redistribution."
|
||||
.into(),
|
||||
@@ -57,7 +57,7 @@ fn rawnind_bayer() -> Known {
|
||||
Known { manifest, download: Some(download), entry: Some("model_bayer.onnx") }
|
||||
}
|
||||
|
||||
/// Every model LightCraft knows.
|
||||
/// Every model the app knows.
|
||||
pub fn all() -> Vec<Known> {
|
||||
#[cfg(feature = "rawnind-model")]
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Commercial {
|
||||
Yes,
|
||||
/// Research or personal use only. Never bundled or redistributed by LightCraft.
|
||||
/// Research or personal use only. Never bundled or redistributed by the app.
|
||||
No,
|
||||
/// The licence or the training data is unclear: the user is told so before installing.
|
||||
Unknown,
|
||||
|
||||
@@ -123,7 +123,7 @@ pub fn denoise_bayer(
|
||||
let mut wsum = allocate(nx * ny, 0f32)?;
|
||||
let group = p.parallel.clamp(1, 4);
|
||||
let mut done = 0;
|
||||
// where the time goes, printed under `LIGHTCRAFT_PROFILE`
|
||||
// where the time goes, printed under `{ENV_PREFIX}_PROFILE`
|
||||
let (mut t_wait, mut t_total) = (std::time::Duration::ZERO, std::time::Duration::ZERO);
|
||||
let began = web_time::Instant::now();
|
||||
|
||||
@@ -201,7 +201,7 @@ pub fn denoise_bayer(
|
||||
}
|
||||
}
|
||||
});
|
||||
if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() {
|
||||
if dac_brand::env_is_set("PROFILE") {
|
||||
eprintln!(
|
||||
"[denoise] {total} tiles, {} at once: tiles {:.0} ms (blending, {:.0} ms of it, overlaps the model), normalise {:.0} ms",
|
||||
group,
|
||||
|
||||
@@ -3,7 +3,7 @@ name = "dac-denoise"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "LightCraft AI denoise (L1): denoise-model manifests, Bayer packing, tiling and blending, the cache file, and pure-Rust CPU inference"
|
||||
description = "AI denoise (L1): denoise-model manifests, Bayer packing, tiling and blending, the cache file, and pure-Rust CPU inference"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -15,6 +15,7 @@ runtime = ["dep:faer"]
|
||||
rawnind-model = ["dac-denoise-core/rawnind-model"]
|
||||
|
||||
[dependencies]
|
||||
dac-brand = { workspace = true }
|
||||
dac-denoise-core = { workspace = true }
|
||||
# Safe matrix views over the pure-Rust GEMM backend; no BLAS, cc/build scripts
|
||||
# or inner thread pool. Its dependencies include architecture-specific assembly.
|
||||
|
||||
@@ -183,7 +183,7 @@ impl NetRunner {
|
||||
let first = self.plan.tensors.first().ok_or_else(|| bad("no input"))?;
|
||||
let want = count(first.channels, first.side)?;
|
||||
w.buffers.first_mut().and_then(|b| b.get_mut(..want)).ok_or_else(|| bad("missing input buffer"))?.copy_from_slice(input);
|
||||
let profile = std::env::var_os("LIGHTCRAFT_PROFILE").is_some();
|
||||
let profile = dac_brand::env_is_set("PROFILE");
|
||||
for (i, op) in self.plan.net.ops().iter().enumerate() {
|
||||
let started = web_time::Instant::now();
|
||||
let out = *self.plan.tensors.get(i + 1).ok_or_else(|| bad("missing output tensor"))?;
|
||||
|
||||
@@ -141,7 +141,7 @@ fn reused_im2col_clears_all_edge_padding_between_images() {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
#[ignore = "needs LC_DENOISE_MODEL; one tile with LIGHTCRAFT_PROFILE=1"]
|
||||
#[ignore = "needs LC_DENOISE_MODEL; one tile with {ENV_PREFIX}_PROFILE=1"]
|
||||
fn profile_one_real_tile() {
|
||||
let model = std::env::var_os("LC_DENOISE_MODEL").expect("set LC_DENOISE_MODEL");
|
||||
let m = crate::known::find("rawnind-bayer").unwrap().manifest;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! AI denoise for LightCraft, as a step between the sensor and the demosaic that the rest of the develop
|
||||
//! AI denoise, as a step between the sensor and the demosaic that the rest of the develop
|
||||
//! pipeline never sees: the raw file stays untouched, the denoised picture is cached data that can always be
|
||||
//! made again, and the amount is an ordinary develop setting.
|
||||
//!
|
||||
//! - [`manifest`]: what a denoise model takes and gives, and who may use it (untrusted JSON, validated).
|
||||
//! - [`known`] and [`archive`]: the models LightCraft can fetch, and taking the model file out of the `.zip` it comes in.
|
||||
//! - [`known`] and [`archive`]: the models the app can fetch, and taking the model file out of the `.zip` it comes in.
|
||||
//! - [`bayer`]: Bayer layouts and the packing of a mosaic into the four planes a model takes.
|
||||
//! - [`tiles`] and [`run`]: cutting a picture into overlapping tiles for a fixed-size model, bringing each tile's
|
||||
//! output scale back to the input's, keeping clipped highlights as they were, and blending the tiles together.
|
||||
|
||||
@@ -3,12 +3,13 @@ name = "dac-develop"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "LightCraft develop settings: the non-destructive edit model, control specs, presets (L1)"
|
||||
description = "Develop settings: the non-destructive edit model, control specs, presets (L1)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
dac-brand = { workspace = true }
|
||||
dac-geom = { workspace = true }
|
||||
dac-color = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||