diff --git a/.github/workflows/brand.yml b/.github/workflows/brand.yml new file mode 100644 index 0000000000..1b992c9ef0 --- /dev/null +++ b/.github/workflows/brand.yml @@ -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 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 0e2388a620..e645ff5608 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -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 '"$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") diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9c3a1ea32..19480e4c45 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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". +# are configured) and creates or updates the draft GitHub Release " v". # 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 diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index d6067b3f24..73a9b12324 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -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' diff --git a/.gitignore b/.gitignore index 8c2047fe6b..87a4be29c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ *~ -plan/ +# plan/ is committed; only private observation notes about the reference app stay local +/plan/observations/ /target /target-*/ /dist diff --git a/AGENTS.md b/AGENTS.md index 1e889b730d..8d73eba0c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ``, + ``, ``, ``, `` 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 `_X`, where `PREFIX` is `env_prefix` from `brand.toml` (e.g. `_LOG`, + `_GPU`, `_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**: `_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. diff --git a/CLAUDE.md b/CLAUDE.md index 7e678f38b4..39baec4812 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ``, + ``, ``, ``, `` 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 `_X`, where `PREFIX` is `env_prefix` from `brand.toml` (e.g. `_LOG`, + `_GPU`, `_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**: `_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. diff --git a/Cargo.lock b/Cargo.lock index a419eb4a2b..db69000f24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 7b84593f0f..9f6bf52342 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/LICENSE-MIT b/LICENSE similarity index 89% rename from LICENSE-MIT rename to LICENSE index d050b29c63..03125091b9 100644 --- a/LICENSE-MIT +++ b/LICENSE @@ -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 diff --git a/NOTICE b/NOTICE index b4115d0b6b..13fbf7d587 100644 --- a/NOTICE +++ b/NOTICE @@ -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. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000000..a76ab185b6 --- /dev/null +++ b/PLAN.md @@ -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. diff --git a/PLAN_phase_0.md b/PLAN_phase_0.md new file mode 100644 index 0000000000..65336df142 --- /dev/null +++ b/PLAN_phase_0.md @@ -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/, ~/Library/Application Support/, %APPDATA%\ +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 `). + - 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`. diff --git a/PLAN_phase_1.md b/PLAN_phase_1.md new file mode 100644 index 0000000000..39608dffee --- /dev/null +++ b/PLAN_phase_1.md @@ -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`). 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. diff --git a/PLAN_phase_2.md b/PLAN_phase_2.md new file mode 100644 index 0000000000..85167b2b69 --- /dev/null +++ b/PLAN_phase_2.md @@ -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//`, 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 ✅. diff --git a/PLAN_phase_3.md b/PLAN_phase_3.md new file mode 100644 index 0000000000..cd12435bfb --- /dev/null +++ b/PLAN_phase_3.md @@ -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 ✅. diff --git a/PLAN_phase_4.md b/PLAN_phase_4.md new file mode 100644 index 0000000000..e94845c5fa --- /dev/null +++ b/PLAN_phase_4.md @@ -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. diff --git a/README.md b/README.md index 0152518f48..1810a887b6 100644 --- a/README.md +++ b/README.md @@ -1,51 +1,19 @@

- - - - ArtCraft - - +

- -

LightCraft

- -

Your photos. Your pixels. Your machine.

+

Your photos. Your pixels. Your machine.

Photo library and raw development; an open-source, clean-room reimplementation of Adobe Lightroom, rebuilt in pure Rust.
Native on macOS, Windows and Linux. In the browser via WebAssembly. Drivable end to end by AI agents over MCP.

-

- Pure Rust - macOS, Windows, Linux and Web - MCP server included - License: MIT OR Apache-2.0 - Status: young and moving fast -

- -

- Join the ArtCraft community on Discord -

- -

- LightCraft on getartcraft.com · - ArtCraft · - All Crafting Apps -

- -
- -

- 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 -
- Ansel Adams, "The Tetons and the Snake River" (1942). Public domain, U.S. National Archives. Developed in LightCraft. -

- > [!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"; ``, ``, ``, `` +> and `` stand for `display_name`, `binary`, `cli_binary`, `mcp_server` and `settings_dir`, and +> `` for `env_prefix`, from that file.

Editing · @@ -59,15 +27,14 @@ Status · Quick start · Roadmap · - Downloads · - Crafting Apps + Downloads


## 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 \\ to peek at the original, press Y for side by side, or Shift+Y 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). @@ -152,7 +119,7 @@ plus an overall Amount.

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
- A radial "Sun glow" mask (Temp +40, Exposure +0.50) layered over a linear "Sky" mask. Photo from LightCraft's procedurally generated demo library. + A radial "Sun glow" mask (Temp +40, Exposure +0.50) layered over a linear "Sky" mask. Photo from the app's procedurally generated demo library.


@@ -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 + --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.** ` 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 -- "$PWD/target/release/app-cli" mcp ~/Pictures/shoot # headless + claude mcp add -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:** ` 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; ` 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/ 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/.log` in its settings folder +(Linux `$XDG_CONFIG_HOME//logs/`, by default `~/.config//logs/`; macOS +`~/Library/Application Support//logs/`; Windows `%APPDATA%\\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 `.1.log` and that one to `.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 +`_NO_PREFS` log to standard error only. By default the app's own crates log at `info` and everything else +at `warn`. `_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 `-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 `` (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 # the desktop app +nix build # → ./result/bin/{,} +nix develop # 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 = ""; +# 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.` available +environment.systemPackages = [ pkgs. ]; # home-manager: home.packages = [ pkgs. ]; ``` `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..overrideAttrs { doCheck = false; }`). **Keyboard:** G grid · D detail · E edit · C crop · M masking · Shift+P presets · \\ original · Y before/after · Z 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`, +``) 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). -
## 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. `` 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. `` in the file names is the version number, +``/`` 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--windows-x64.msi` | `lightcraft--windows-x64-portable.zip` | -| arm64 (Snapdragon and other ARM PCs) | `lightcraft--windows-arm64.msi` | `lightcraft--windows-arm64-portable.zip` | -| x86 (32-bit) | `lightcraft--windows-x86.msi` | `lightcraft--windows-x86-portable.zip` | +| x64 (64-bit Intel/AMD) | `--windows-x64.msi` | `--windows-x64-portable.zip` | +| arm64 (Snapdragon and other ARM PCs) | `--windows-arm64.msi` | `--windows-arm64-portable.zip` | +| x86 (32-bit) | `--windows-x86.msi` | `--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--macos-universal.dmg` | Signed and notarized | -| Command-line tool, universal | `lightcraft-cli--macos-universal.zip` | Signed and notarized | +| App, universal (Apple silicon + Intel) | `--macos-universal.dmg` | Signed and notarized | +| Command-line tool, universal | `--macos-universal.zip` | Signed and notarized | ### Linux | Format | x86_64 | aarch64 (ARM64) | Notes | |---|---|---|---| -| AppImage | `lightcraft--linux-x86_64.AppImage` | `lightcraft--linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) | -| Flatpak | `lightcraft--linux-x86_64.flatpak` | `lightcraft--linux-aarch64.flatpak` | Sandboxed; `flatpak install --user ` | -| Debian/Ubuntu | `lightcraft--linux-x86_64.deb` | `lightcraft--linux-aarch64.deb` | | -| Fedora/RHEL/openSUSE | `lightcraft--linux-x86_64.rpm` | `lightcraft--linux-aarch64.rpm` | | -| Tarball | `lightcraft--linux-x86_64.tar.gz` | `lightcraft--linux-aarch64.tar.gz` | Unpack anywhere | +| AppImage | `--linux-x86_64.AppImage` | `--linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) | +| Flatpak | `--linux-x86_64.flatpak` | `--linux-aarch64.flatpak` | Sandboxed; `flatpak install --user ` | +| Debian/Ubuntu | `--linux-x86_64.deb` | `--linux-aarch64.deb` | | +| Fedora/RHEL/openSUSE | `--linux-x86_64.rpm` | `--linux-aarch64.rpm` | | +| Tarball | `--linux-x86_64.tar.gz` | `--linux-aarch64.tar.gz` | Unpack anywhere | ### FreeBSD | Build | File | |---|---| -| x86_64 | `lightcraft--freebsd-x86_64.tar.gz` | +| x86_64 | `--freebsd-x86_64.tar.gz` | ### Web (WebAssembly) | Build | File | Notes | |---|---|---| -| Static site | `lightcraft-web-.zip` | Runs in a modern browser; host it on any static server | - -
- -## 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 | -|:-:|---|---|---|---| -| | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) | -| | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) | -| | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) | -| | **LightCraft** | **Photo library and raw development · you are here** | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/app) | -| | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) | -| | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) | -| | **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. - -
- -

- Join the ArtCraft community on Discord -

- -

Come make things with us

- -

- 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. -

- -

- discord.gg/artcraft · - getartcraft.com · - The Crafting Apps · - LightCraft -

+| Static site | `-web-.zip` | Runs in a modern browser; host it on any static server |
## 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. -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. +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. -

- ArtCraft
- Made by the ArtCraft team and community. -

-## Star history - -[![Star History Chart](https://api.star-history.com/svg?repos=storytold/lightcraft&type=Date&legend=top-left)](https://www.star-history.com/?repos=storytold%2Flightcraft&type=date&legend=top-left) diff --git a/ROADMAP.md b/ROADMAP.md index 32f6a89bc6..5c22c218d0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/apps/app/Cargo.toml b/apps/app/Cargo.toml index 6152c0ec96..ee5410e313 100644 --- a/apps/app/Cargo.toml +++ b/apps/app/Cargo.toml @@ -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] diff --git a/apps/app/build.rs b/apps/app/build.rs index 4947091d64..7a37ad2dde 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -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 `_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); } } diff --git a/apps/app/src/control_server.rs b/apps/app/src/control_server.rs index 64b62ca01b..50b4d1bbfa 100644 --- a/apps/app/src/control_server.rs +++ b/apps/app/src/control_server.rs @@ -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 (` 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 diff --git a/apps/app/src/logging.rs b/apps/app/src/logging.rs index ac47eb695b..ced89227b0 100644 --- a/apps/app/src/logging.rs +++ b/apps/app/src/logging.rs @@ -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: -//! `/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` +//! `/logs/.log`, next to `ui.json` (`config_dir` in `main.rs`; Linux +//! `$XDG_CONFIG_HOME//logs`, by default `~/.config//logs`). Never in the +//! library folder. Each start moves the previous log to `.1.log` (and that one to `.2`), +//! so the log of a run that crashed survives the next launch. Runs with `_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). `_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..log`). +pub fn log_file() -> String { + format!("{}.log", dac_brand::BINARY) +} +/// How many previous logs are kept (`.1.log` … `..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 `_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 `_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 +/// `_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` → … → `..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 (`_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 = 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. +/// `-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 `_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. + /// `_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); } diff --git a/apps/app/src/main.rs b/apps/app/src/main.rs index f9cfadf91a..86b6e8d234 100644 --- a/apps/app/src/main.rs +++ b/apps/app/src/main.rs @@ -1,23 +1,23 @@ -//! LightCraft desktop app. +//! The desktop app. //! -//! Usage: `lightcraft [--library DIR | --memory] [--no-demo] [--control ] [files or folders…]` +//! Usage: ` [--library DIR | --memory] [--no-demo] [--control ] [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 +//! `$_LIBRARY`, else the library last opened with Settings → Open Library…, else +//! `~/Pictures/` (`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 ` (or `LIGHTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server: +//! `--control ` (or `_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 `_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 `_NO_PREFS` (tests, scripts). fn gpu_marker_path() -> Option { - 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, 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 { } /// `/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: `_NO_PREFS` (tests, scripts) or no home folder. fn prefs_path() -> Option { - 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`, `_NO_PREFS`, no home folder). path: Option, written: Vec, 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, 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, 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: /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: /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=. Records go to stderr and to - /logs/lightcraft.log (previous runs: lightcraft.1.log, lightcraft.2.log) + /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, Vec) { @@ -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 `_NO_PREFS` (tests, scripts), so those runs don't rotate away the user's logs. fn log_dir() -> Option { - 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) -> Option { 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 `.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 /models/sam3, or LIGHTCRAFT_SAM3_DIR + // AI masks: the SAM 3 checkpoint (facebook/sam3) in /models/sam3, or _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 (_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). _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] diff --git a/apps/app/src/native_menu.rs b/apps/app/src/native_menu.rs index 999bc059cd..85b9c1d374 100644 --- a/apps/app/src/native_menu.rs +++ b/apps/app/src/native_menu.rs @@ -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); } diff --git a/apps/cli/Cargo.toml b/apps/cli/Cargo.toml index f5834b79a2..db82bbc354 100644 --- a/apps/cli/Cargo.toml +++ b/apps/cli/Cargo.toml @@ -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 } diff --git a/apps/cli/src/main.rs b/apps/cli/src/main.rs index 0cb3b6995a..4878613c37 100644 --- a/apps/cli/src/main.rs +++ b/apps/cli/src/main.rs @@ -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 -o [--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… +//! run [--demo | --library DIR | --connect [ADDR]] [--import PATH]… CMD [key=value…]… +//! mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…] +//! render -o [--set control=value]… [--settings FILE.json] [--preset ID] [--size N] [--quality Q] +//! snapshot [--library DIR | --demo] [--script FILE.jsonl] [-o OUT.png] [--size WxH] [--scale S] [FILES…] +//! merge hdr|panorama|hdr-panorama [OPTIONS] FILES… +//! synth-merge hdr|panorama -o DIR +//! commands [--json] +//! controls [--json] +//! 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 -o [OPTIONS] + {cli} render -o [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 -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 .json to DIR (default: the profiles folder LightCraft reads, - /camera-profiles, or $LIGHTCRAFT_CAMERA_PROFILES). Raws of a profiled model then + model, written as .json to DIR (default: the profiles folder {app} reads, + /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). + // (_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; _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 = 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 ` 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 = - 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 = 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 = 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 = 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(()) } } diff --git a/apps/cli/tests/cli.rs b/apps/cli/tests/cli.rs index 945b09c7e5..e5ff5f3036 100644 --- a/apps/cli/tests/cli.rs +++ b/apps/cli/tests/cli.rs @@ -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"]) diff --git a/apps/web/Cargo.toml b/apps/web/Cargo.toml index c45c52573b..c6d297be7a 100644 --- a/apps/web/Cargo.toml +++ b/apps/web/Cargo.toml @@ -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 } diff --git a/apps/web/index.html b/apps/web/index.html index 6fb74707da..a4388d0a94 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -3,30 +3,30 @@ - LightCraft + Loading… - + - -
Loading LightCraft…
-
+ +
Loading…
+
diff --git a/apps/web/src/backend.rs b/apps/web/src/backend.rs index e20f6dc9c9..2571f4e728 100644 --- a/apps/web/src/backend.rs +++ b/apps/web/src/backend.rs @@ -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); }); } diff --git a/apps/web/src/backup.rs b/apps/web/src/backup.rs index 00ec12a415..5f629ae7fa 100644 --- a/apps/web/src/backup.rs +++ b/apps/web/src/backup.rs @@ -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//\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-`)? @@ -234,7 +234,7 @@ pub fn data_offset(entry: &ZipEntry, local: &[u8]) -> Result { /// 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)); diff --git a/apps/web/src/bench.rs b/apps/web/src/bench.rs index 9f561d66c9..fd4af2563e 100644 --- a/apps/web/src/bench.rs +++ b/apps/web/src/bench.rs @@ -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; diff --git a/apps/web/src/legacy.rs b/apps/web/src/legacy.rs new file mode 100644 index 0000000000..8a30a6bbfb --- /dev/null +++ b/apps/web/src/legacy.rs @@ -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"; diff --git a/apps/web/src/lib.rs b/apps/web/src/lib.rs index 04ec89b549..12c66e877c 100644 --- a/apps/web/src/lib.rs +++ b/apps/web/src/lib.rs @@ -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; diff --git a/apps/web/src/safety.rs b/apps/web/src/safety.rs index 137b525039..4e1208855d 100644 --- a/apps/web/src/safety.rs +++ b/apps/web/src/safety.rs @@ -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 Result Result`, wires the file picker and drag-and-drop, and turns exports +//! ``, 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 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) { 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::().ok()) else { - log::error!("missing "); + log::error!("missing "); return; }; let boot = boot(opts).await; @@ -689,13 +696,13 @@ pub fn start(chinese_font: Vec) { 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)); } } }); diff --git a/apps/web/src/workers.rs b/apps/web/src/workers.rs index 452788dc55..7be646828e 100644 --- a/apps/web/src/workers.rs +++ b/apps/web/src/workers.rs @@ -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) => { diff --git a/apps/web/worker.js b/apps/web/worker.js index 420bfcc232..c26a202553 100644 --- a/apps/web/worker.js +++ b/apps/web/worker.js @@ -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"; diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 52074466dd..762370d6e0 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -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 ``, 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 ``, 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-.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 diff --git a/assets/app-icon/LICENSE.txt b/assets/app-icon/LICENSE.txt index 23da456b26..05951deab0 100644 --- a/assets/app-icon/LICENSE.txt +++ b/assets/app-icon/LICENSE.txt @@ -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). diff --git a/assets/app-icon/README.md b/assets/app-icon/README.md index 9e8e48114f..1eb213b1a8 100644 --- a/assets/app-icon/README.md +++ b/assets/app-icon/README.md @@ -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/x/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/x/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. diff --git a/assets/app-icon/app-1024.png b/assets/app-icon/app-1024.png new file mode 100644 index 0000000000..6fdacb070e Binary files /dev/null and b/assets/app-icon/app-1024.png differ diff --git a/assets/app-icon/app-macos-512.png b/assets/app-icon/app-macos-512.png new file mode 100644 index 0000000000..8603a98ad9 Binary files /dev/null and b/assets/app-icon/app-macos-512.png differ diff --git a/assets/app-icon/app.icns b/assets/app-icon/app.icns new file mode 100644 index 0000000000..c82d00a817 Binary files /dev/null and b/assets/app-icon/app.icns differ diff --git a/assets/app-icon/app.ico b/assets/app-icon/app.ico new file mode 100644 index 0000000000..f87eedd23c Binary files /dev/null and b/assets/app-icon/app.ico differ diff --git a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.lightcraft.png b/assets/app-icon/hicolor/128x128/apps/ai.storyteller.lightcraft.png deleted file mode 100644 index f9da957a14..0000000000 Binary files a/assets/app-icon/hicolor/128x128/apps/ai.storyteller.lightcraft.png and /dev/null differ diff --git a/assets/app-icon/hicolor/128x128/apps/app.png b/assets/app-icon/hicolor/128x128/apps/app.png new file mode 100644 index 0000000000..f8d4ba0e1c Binary files /dev/null and b/assets/app-icon/hicolor/128x128/apps/app.png differ diff --git a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.lightcraft.png b/assets/app-icon/hicolor/16x16/apps/ai.storyteller.lightcraft.png deleted file mode 100644 index 037a2c434d..0000000000 Binary files a/assets/app-icon/hicolor/16x16/apps/ai.storyteller.lightcraft.png and /dev/null differ diff --git a/assets/app-icon/hicolor/16x16/apps/app.png b/assets/app-icon/hicolor/16x16/apps/app.png new file mode 100644 index 0000000000..deb68f1f8a Binary files /dev/null and b/assets/app-icon/hicolor/16x16/apps/app.png differ diff --git a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.lightcraft.png b/assets/app-icon/hicolor/24x24/apps/ai.storyteller.lightcraft.png deleted file mode 100644 index 514eb83fb0..0000000000 Binary files a/assets/app-icon/hicolor/24x24/apps/ai.storyteller.lightcraft.png and /dev/null differ diff --git a/assets/app-icon/hicolor/24x24/apps/app.png b/assets/app-icon/hicolor/24x24/apps/app.png new file mode 100644 index 0000000000..d5ae52b8e5 Binary files /dev/null and b/assets/app-icon/hicolor/24x24/apps/app.png differ diff --git a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.lightcraft.png b/assets/app-icon/hicolor/256x256/apps/ai.storyteller.lightcraft.png deleted file mode 100644 index 29398ec378..0000000000 Binary files a/assets/app-icon/hicolor/256x256/apps/ai.storyteller.lightcraft.png and /dev/null differ diff --git a/assets/app-icon/hicolor/256x256/apps/app.png b/assets/app-icon/hicolor/256x256/apps/app.png new file mode 100644 index 0000000000..9277828acb Binary files /dev/null and b/assets/app-icon/hicolor/256x256/apps/app.png differ diff --git a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.lightcraft.png b/assets/app-icon/hicolor/32x32/apps/ai.storyteller.lightcraft.png deleted file mode 100644 index 087ad0a67a..0000000000 Binary files a/assets/app-icon/hicolor/32x32/apps/ai.storyteller.lightcraft.png and /dev/null differ diff --git a/assets/app-icon/hicolor/32x32/apps/app.png b/assets/app-icon/hicolor/32x32/apps/app.png new file mode 100644 index 0000000000..f435ffc8f9 Binary files /dev/null and b/assets/app-icon/hicolor/32x32/apps/app.png differ diff --git a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.lightcraft.png b/assets/app-icon/hicolor/48x48/apps/ai.storyteller.lightcraft.png deleted file mode 100644 index 0ed28ee7d9..0000000000 Binary files a/assets/app-icon/hicolor/48x48/apps/ai.storyteller.lightcraft.png and /dev/null differ diff --git a/assets/app-icon/hicolor/48x48/apps/app.png b/assets/app-icon/hicolor/48x48/apps/app.png new file mode 100644 index 0000000000..f33098c0ec Binary files /dev/null and b/assets/app-icon/hicolor/48x48/apps/app.png differ diff --git a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.lightcraft.png b/assets/app-icon/hicolor/512x512/apps/ai.storyteller.lightcraft.png deleted file mode 100644 index 5933c28025..0000000000 Binary files a/assets/app-icon/hicolor/512x512/apps/ai.storyteller.lightcraft.png and /dev/null differ diff --git a/assets/app-icon/hicolor/512x512/apps/app.png b/assets/app-icon/hicolor/512x512/apps/app.png new file mode 100644 index 0000000000..ae46ee20ee Binary files /dev/null and b/assets/app-icon/hicolor/512x512/apps/app.png differ diff --git a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png b/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png deleted file mode 100644 index 2b0c8f54c5..0000000000 Binary files a/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png and /dev/null differ diff --git a/assets/app-icon/hicolor/64x64/apps/app.png b/assets/app-icon/hicolor/64x64/apps/app.png new file mode 100644 index 0000000000..0833c9f1c4 Binary files /dev/null and b/assets/app-icon/hicolor/64x64/apps/app.png differ diff --git a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.lightcraft.svg b/assets/app-icon/hicolor/scalable/apps/ai.storyteller.lightcraft.svg deleted file mode 100644 index 9dc39ee8e0..0000000000 --- a/assets/app-icon/hicolor/scalable/apps/ai.storyteller.lightcraft.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/app-icon/hicolor/scalable/apps/app.svg b/assets/app-icon/hicolor/scalable/apps/app.svg new file mode 100644 index 0000000000..f413cfd105 --- /dev/null +++ b/assets/app-icon/hicolor/scalable/apps/app.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/app-icon/lightcraft-1024.png b/assets/app-icon/lightcraft-1024.png deleted file mode 100644 index 20b1799f68..0000000000 Binary files a/assets/app-icon/lightcraft-1024.png and /dev/null differ diff --git a/assets/app-icon/lightcraft-macos-512.png b/assets/app-icon/lightcraft-macos-512.png deleted file mode 100644 index 54f0b6172a..0000000000 Binary files a/assets/app-icon/lightcraft-macos-512.png and /dev/null differ diff --git a/assets/app-icon/lightcraft-small.svg b/assets/app-icon/lightcraft-small.svg deleted file mode 100644 index 9dc39ee8e0..0000000000 --- a/assets/app-icon/lightcraft-small.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/assets/app-icon/lightcraft.icns b/assets/app-icon/lightcraft.icns deleted file mode 100644 index 0ceba6fc56..0000000000 Binary files a/assets/app-icon/lightcraft.icns and /dev/null differ diff --git a/assets/app-icon/lightcraft.ico b/assets/app-icon/lightcraft.ico deleted file mode 100644 index 1aafd9239f..0000000000 Binary files a/assets/app-icon/lightcraft.ico and /dev/null differ diff --git a/assets/app-icon/lightcraft.svg b/assets/app-icon/lightcraft.svg deleted file mode 100644 index 0fb16be300..0000000000 --- a/assets/app-icon/lightcraft.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/brand/icon.svg b/brand/icon.svg new file mode 100644 index 0000000000..f413cfd105 --- /dev/null +++ b/brand/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/brand/logo.svg b/brand/logo.svg new file mode 100644 index 0000000000..91dd2877b6 --- /dev/null +++ b/brand/logo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/crates/brand/src/lib.rs b/crates/brand/src/lib.rs index 1e6e10d744..ad78376724 100644 --- a/crates/brand/src/lib.rs +++ b/crates/brand/src/lib.rs @@ -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 `_LOG`. pub fn env_var(name: &str) -> String { format!("{ENV_PREFIX}_{name}") } @@ -68,7 +68,7 @@ fn config_root() -> Option { } /// The folder name for `name` on this platform: as written on macOS and Windows, lower-case on other -/// Unixes (`~/.config/nonameyet`). +/// Unixes (`~/.config/`). pub fn settings_dir_name(name: &str) -> String { if cfg!(any(target_os = "macos", windows)) { name.to_string() } else { name.to_lowercase() } } diff --git a/crates/catalog/Cargo.toml b/crates/catalog/Cargo.toml index 9561a2e405..645457136f 100644 --- a/crates/catalog/Cargo.toml +++ b/crates/catalog/Cargo.toml @@ -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 } diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs index 98de0f347d..f4645c4fb7 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -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 = 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))); diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index c65417ecc1..24735be690 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -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), } diff --git a/crates/catalog/src/lock.rs b/crates/catalog/src/lock.rs index 879127c5be..a8eb0a0d56 100644 --- a/crates/catalog/src/lock.rs +++ b/crates/catalog/src/lock.rs @@ -1,7 +1,7 @@ //! One process per library: an exclusive OS lock on `/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)". + /// " 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), } diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index ea63db9d55..33644f6d3e 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -184,7 +184,7 @@ pub struct Meta { pub creator: String, pub keywords: Vec, /// 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, diff --git a/crates/catalog/src/safe_file.rs b/crates/catalog/src/safe_file.rs index df239a177d..1dba3764c9 100644 --- a/crates/catalog/src/safe_file.rs +++ b/crates/catalog/src/safe_file.rs @@ -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, diff --git a/crates/catalog/src/tests_background.rs b/crates/catalog/src/tests_background.rs index 5235c3e171..a466954a37 100644 --- a/crates/catalog/src/tests_background.rs +++ b/crates/catalog/src/tests_background.rs @@ -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); diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs index 619c661d7d..7b1b47bfdc 100644 --- a/crates/catalog/src/tests_format_version.rs +++ b/crates/catalog/src/tests_format_version.rs @@ -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"); } diff --git a/crates/catalog/src/tests_lock.rs b/crates/catalog/src/tests_lock.rs index fe88190446..585c4ac3b9 100644 --- a/crates/catalog/src/tests_lock.rs +++ b/crates/catalog/src/tests_lock.rs @@ -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); diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 0bf2d3b9bc..1b92fde083 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -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 } diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 2bc6d92f61..155f215fc6 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -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`. diff --git a/crates/codecs/src/heif.rs b/crates/codecs/src/heif.rs index 878d313e07..75a6ec0c5d 100644 --- a/crates/codecs/src/heif.rs +++ b/crates/codecs/src/heif.rs @@ -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). diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 3340b43279..3f1c03d903 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -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 diff --git a/crates/codecs/src/sniff.rs b/crates/codecs/src/sniff.rs index 5ee763c148..69a296f275 100644 --- a/crates/codecs/src/sniff.rs +++ b/crates/codecs/src/sniff.rs @@ -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, diff --git a/crates/codecs/tests/corpus.rs b/crates/codecs/tests/corpus.rs index 77cd6ed639..f8afe10dd7 100644 --- a/crates/codecs/tests/corpus.rs +++ b/crates/codecs/tests/corpus.rs @@ -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 { diff --git a/crates/color/Cargo.toml b/crates/color/Cargo.toml index fe0fd7fa93..6326695f09 100644 --- a/crates/color/Cargo.toml +++ b/crates/color/Cargo.toml @@ -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 } diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 7d137d5697..097b5dfc40 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -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 diff --git a/crates/denoise-core/Cargo.toml b/crates/denoise-core/Cargo.toml index 995a0adca0..d499250191 100644 --- a/crates/denoise-core/Cargo.toml +++ b/crates/denoise-core/Cargo.toml @@ -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 } diff --git a/crates/denoise-core/src/known.rs b/crates/denoise-core/src/known.rs index f9a5110990..76f1ff550a 100644 --- a/crates/denoise-core/src/known.rs +++ b/crates/denoise-core/src/known.rs @@ -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 { #[cfg(feature = "rawnind-model")] { diff --git a/crates/denoise-core/src/licence.rs b/crates/denoise-core/src/licence.rs index 1d89146b1b..ced3e51353 100644 --- a/crates/denoise-core/src/licence.rs +++ b/crates/denoise-core/src/licence.rs @@ -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, diff --git a/crates/denoise-core/src/run.rs b/crates/denoise-core/src/run.rs index 2210eaab84..6e6c69297e 100644 --- a/crates/denoise-core/src/run.rs +++ b/crates/denoise-core/src/run.rs @@ -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, diff --git a/crates/denoise/Cargo.toml b/crates/denoise/Cargo.toml index e63dfb8a8a..5686b286ab 100644 --- a/crates/denoise/Cargo.toml +++ b/crates/denoise/Cargo.toml @@ -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. diff --git a/crates/denoise/src/cpu.rs b/crates/denoise/src/cpu.rs index 34e5269e42..62d3dcdf23 100644 --- a/crates/denoise/src/cpu.rs +++ b/crates/denoise/src/cpu.rs @@ -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"))?; diff --git a/crates/denoise/src/cpu/tests.rs b/crates/denoise/src/cpu/tests.rs index 16a496b532..bb99508a5f 100644 --- a/crates/denoise/src/cpu/tests.rs +++ b/crates/denoise/src/cpu/tests.rs @@ -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; diff --git a/crates/denoise/src/lib.rs b/crates/denoise/src/lib.rs index 707afab409..bf4d18a7ed 100644 --- a/crates/denoise/src/lib.rs +++ b/crates/denoise/src/lib.rs @@ -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. diff --git a/crates/develop/Cargo.toml b/crates/develop/Cargo.toml index d8a053843f..01c202f3b7 100644 --- a/crates/develop/Cargo.toml +++ b/crates/develop/Cargo.toml @@ -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 } diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs index ba338d1ab5..5022669428 100644 --- a/crates/develop/src/lib.rs +++ b/crates/develop/src/lib.rs @@ -1,4 +1,4 @@ -//! The non-destructive edit model of LightCraft. +//! The non-destructive edit model of the app. //! //! [`DevelopSettings`] is the complete description of a photo's look: pure, serializable data. //! The pipeline evaluates it; the catalog stores it; presets, copy/paste, sync, versions and history diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index c44ee159e4..1e97f2bd8d 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -549,7 +549,7 @@ impl Default for Optics { /// (`WarpRectilinear`, `FixVignetteRadial`), or a raw reader's equivalent of the camera's own correction (Panasonic / /// Leica RW2 distortion, `dac_raw`'s `vendor/rw2.rs`). This is camera/file data (stored on the photo record, not in the develop /// settings); "Enable Profile Corrections" applies it, scaled by the profile distortion/vignetting amounts. -/// LightCraft never uses Adobe LCP lens profiles. +/// The app never uses Adobe LCP lens profiles. #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct EmbeddedLens { diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index b6b009ff8a..88f28eebc5 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -3,12 +3,13 @@ name = "dac-engine" version.workspace = true edition.workspace = true license.workspace = true -description = "LightCraft engine: session, command registry, history, render jobs, view-models (L4)" +description = "Engine: session, command registry, history, render jobs, view-models (L4)" [lints] workspace = true [dependencies] +dac-brand = { workspace = true } dac-geom = { workspace = true } dac-color = { workspace = true } dac-raster = { workspace = true } diff --git a/crates/engine/examples/render_bench.rs b/crates/engine/examples/render_bench.rs index 9ad011a490..19ed33e790 100644 --- a/crates/engine/examples/render_bench.rs +++ b/crates/engine/examples/render_bench.rs @@ -1,7 +1,7 @@ //! End-to-end render benchmark (min of N runs, robust to a loaded machine): //! `cargo run --release -p dac-engine --example render_bench -- corpus/raw/arw-sony-a7m3-compressed.arw` //! (`N=9` runs per scenario, `RAYON_NUM_THREADS=1` for algorithmic comparisons, -//! `LIGHTCRAFT_PROFILE=1` for per-stage timings). +//! `{ENV_PREFIX}_PROFILE=1` for per-stage timings). //! //! Without a file argument a procedural 6000×4000 source is used. Scenarios: a ~2.5 MP loupe render //! of the 2560 px preview (cold, and with a warm stage cache while a slider is dragged), a draft, @@ -9,7 +9,7 @@ //! export of each (decode + render + encode). Prints minimum wall-clock and minimum process CPU //! time: on a shared machine the CPU time shows the work done, wall-clock also the wait for cores. //! Each scenario runs on the CPU pipeline and, when a GPU adapter exists, on `dac-gpu` -//! (second column; `LIGHTCRAFT_GPU=0` for the CPU only). +//! (second column; `{ENV_PREFIX}_GPU=0` for the CPU only). use std::sync::Arc; use std::time::Instant; @@ -141,7 +141,7 @@ fn main() { }; let full = Arc::new(full); println!("source {}×{} ({:.1} MP), {n} runs each", full.width, full.height, (full.width * full.height) as f64 / 1e6); - // GPU column: `LIGHTCRAFT_GPU=0` (or no adapter) prints only the CPU column. + // GPU column: `{ENV_PREFIX}_GPU=0` (or no adapter) prints only the CPU column. let t = Instant::now(); let gpu = dac_engine::gpu::available(); match dac_engine::gpu::adapter_name().filter(|_| gpu) { diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index e72c0e35f8..86a4ac8e53 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -1154,7 +1154,7 @@ mod tests { /// download or an unfinished codec cannot make this test claim a successful RAW calibration. #[test] fn corpus_cr3_gets_a_framing_aligned_camera_look() { - let path = std::env::var_os("LIGHTCRAFT_CORPUS") + let path = dac_brand::env_os("CORPUS") .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/cr3-canon-r100-raw.cr3"); @@ -1186,7 +1186,7 @@ mod tests { /// balance is relative to the as-shot look. #[test] fn corpus_nef_gets_a_camera_look() { - let path = std::env::var_os("LIGHTCRAFT_CORPUS") + let path = dac_brand::env_os("CORPUS") .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/nef-nikon-d7500-lossless14.nef"); @@ -1203,7 +1203,7 @@ mod tests { /// while the embedded JPEG shows the whole sensor; the look is still fitted, against the matching part of it. #[test] fn corpus_rw2_with_an_in_camera_crop_gets_a_camera_look() { - let path = std::env::var_os("LIGHTCRAFT_CORPUS") + let path = dac_brand::env_os("CORPUS") .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/rw2-panasonic-fz1000m2-4x3.rw2"); @@ -1222,7 +1222,7 @@ mod tests { /// (held-out RMS 0.113): it used to open grey with the neutral fallback. #[test] fn corpus_arw_with_a_lens_corrected_preview_gets_a_camera_look() { - let path = std::env::var_os("LIGHTCRAFT_CORPUS") + let path = dac_brand::env_os("CORPUS") .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/arw-sony-a7rm2-12bit-uncompressed.arw"); @@ -1285,7 +1285,7 @@ mod tests { /// corpus files. #[test] fn corpus_raf_colour_and_white_balance() { - let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + let dir = dac_brand::env_os("CORPUS") .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw"); diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index 3557dc2ffc..f6f4e6b39c 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -2,11 +2,11 @@ //! //! A raw file without colour matrices (Sony ARW, Nikon NEF, Fujifilm RAF) gets its look fitted to its own embedded camera //! JPEG (`camera_preview`), but one photo shows too little of some colours: a lime shirt covering a -//! few dozen proxy pixels next to a hillside of foliage at the same hue. `lightcraft-cli calibrate` +//! few dozen proxy pixels next to a hillside of foliage at the same hue. the CLI's `calibrate` //! pools the colour pairs of many photos per model and fits one matrix and hue/saturation/value //! table; photos of that model then only fit their tone and chroma curves. //! -//! Profiles are JSON files (`.json`) in [`dir`]: `LIGHTCRAFT_CAMERA_PROFILES`, else +//! Profiles are JSON files (`.json`) in [`dir`]: `{ENV_PREFIX}_CAMERA_PROFILES`, else //! `/camera-profiles`; a local profile replaces the one built in ([`BUNDLED`], from //! `assets/camera-profiles/`). They are read once per process; a damaged or hostile file is //! ignored with a warning. They hold aggregate colour statistics only, never image content. @@ -22,7 +22,7 @@ const VERSION: u32 = 1; /// Largest profile file read (a 5 × 72 × 5 table is ~60 KB of JSON). const MAX_FILE: u64 = 4 << 20; -/// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`): +/// Profiles built into the app (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`): /// `(model, JSON)`. pub const BUNDLED: &[(&str, &str)] = &[ ("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")), @@ -65,12 +65,12 @@ impl CameraProfile { } } -/// LightCraft's configuration folder (settings, GPU marker, camera profiles); one helper for the app. +/// The app's configuration folder (settings, GPU marker, camera profiles); one helper for the app. pub use crate::config::config_dir; /// Where camera profiles are read from and written to. pub fn dir() -> Option { - std::env::var_os("LIGHTCRAFT_CAMERA_PROFILES").map(PathBuf::from).or_else(|| config_dir().map(|d| d.join("camera-profiles"))) + dac_brand::env_os("CAMERA_PROFILES").map(PathBuf::from).or_else(|| config_dir().map(|d| d.join("camera-profiles"))) } /// File name of `model`'s profile: letters, digits, `-` and `_` kept, anything else `_`. @@ -101,7 +101,7 @@ fn parse(json: &[u8], origin: &str) -> Result { /// The built-in profile for `model`, if any. fn bundled(model: &str) -> Option { let (_, json) = BUNDLED.iter().find(|(m, _)| *m == model)?; - parse(json.as_bytes(), &format!("built-in profile {model}")).inspect_err(|e| eprintln!("lightcraft: ignoring camera profile {e}")).ok() + parse(json.as_bytes(), &format!("built-in profile {model}")).inspect_err(|e| eprintln!("{}: ignoring camera profile {e}", dac_brand::BINARY)).ok() } /// Write `profile` to `dir` (created if needed) as `.json`; returns the path. @@ -135,11 +135,11 @@ pub fn get(model: &str) -> Option> { Some(path) if path.is_file() => match load(&path) { Ok(p) if p.model.trim() == model => Some(Arc::new(p)), Ok(p) => { - eprintln!("lightcraft: ignoring camera profile {}: it is for {:?}, not {model:?}", path.display(), p.model); + eprintln!("{}: ignoring camera profile {}: it is for {:?}, not {model:?}", dac_brand::BINARY, path.display(), p.model); None } Err(e) => { - eprintln!("lightcraft: ignoring camera profile {e}"); + eprintln!("{}: ignoring camera profile {e}", dac_brand::BINARY); None } }, diff --git a/crates/engine/src/cmd/curves.rs b/crates/engine/src/cmd/curves.rs index ba9e36dfb4..c9fdf4e47c 100644 --- a/crates/engine/src/cmd/curves.rs +++ b/crates/engine/src/cmd/curves.rs @@ -121,7 +121,7 @@ pub fn specs() -> Vec { /// File extension of exported curve presets (JSON). pub const CURVE_PRESET_EXT: &str = "lccurve"; /// `format` tag of an exported curve-preset file. -const FILE_FORMAT: &str = "lightcraft.curvePresets"; +const FILE_FORMAT: &str = crate::legacy::CURVE_PRESETS_FORMAT; /// A named set of point curves (master + red/green/blue, points in 0..1; empty = linear). /// Built-ins ship with the app; the user's are saved with the library preferences. diff --git a/crates/engine/src/cmd/denoise.rs b/crates/engine/src/cmd/denoise.rs index d90587df58..af7384ca8d 100644 --- a/crates/engine/src/cmd/denoise.rs +++ b/crates/engine/src/cmd/denoise.rs @@ -42,7 +42,7 @@ fn row(m: &DenoiserManifest, download_host: Option, installed: bool, sel "sha256": m.sha256, "tile": m.tile, "known": known::find(&m.id).is_some(), - // a pinned address LightCraft can fetch it from (the user presses Download), and which site that is + // a pinned address the app can fetch it from (the user presses Download), and which site that is "downloadHost": download_host, "installed": installed, "selected": selected, @@ -104,7 +104,7 @@ fn identify(path: &Path, c: &str, sha_known: Option<&str>) -> Result { None => sha256_file(path).map_err(|e| fail(&format!("could not read {}", path.display()), e))?, }; let file_name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - // an archive LightCraft knows (the file darktable publishes the model in) + // an archive the app knows (the file darktable publishes the model in) if let Some(k) = known::all().into_iter().find(|k| k.download.as_ref().is_some_and(|d| d.sha256 == sha256)) { return Ok(Source { manifest: k.manifest, entry: k.entry, file_name }); } @@ -122,8 +122,8 @@ fn identify(path: &Path, c: &str, sha_known: Option<&str>) -> Result { } Err(bad( c, - "LightCraft does not know this file. A denoise model is an .onnx file with a denoise-model.json beside it that says how to use it \ - (see docs/denoise.md), or an archive LightCraft can fetch itself (`denoise.models.download`)", + "The app does not know this file. A denoise model is an .onnx file with a denoise-model.json beside it that says how to use it \ + (see docs/denoise.md), or an archive the app can fetch itself (`denoise.models.download`)", )) } @@ -353,8 +353,8 @@ fn download(s: &mut Session, p: &Value) -> Result { const C: &str = "denoise.models.download"; let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; let dir = models_dir(s, C)?; - let k = known::find(id).ok_or_else(|| bad(C, "LightCraft has no download for that model: get the file from its page, then add it"))?; - let spec = k.download.clone().ok_or_else(|| bad(C, "LightCraft has no download for that model: get the file from its page, then add it"))?; + let k = known::find(id).ok_or_else(|| bad(C, "The app has no download for that model: get the file from its page, then add it"))?; + let spec = k.download.clone().ok_or_else(|| bad(C, "The app has no download for that model: get the file from its page, then add it"))?; if !cfg!(feature = "denoise") { return Err(bad(C, "this build cannot run denoise models, so there is nothing to download for it")); } @@ -633,14 +633,14 @@ pub fn specs() -> Vec { vec![ cmd!(query "denoise.models.list", "Denoise Models", [], None, "{} → {dir, productsDir, model, runtime, auto, cacheGb, threads, runOn, models: [{id, name, version, licence{name, commercial, url, notice}, provenance, source, sizeBytes, sha256, tile, known, downloadHost, installed, selected, accepted}]}", always, list), cmd!(query "denoise.models.inspect", "Inspect Denoise Model", [], None, "{path} → model terms from an adjacent manifest; never executes the file", always, inspect), - cmd!(query "denoise.models.install", "Install Denoise Model", [], None, "{path, acknowledged: true, activate?: true, background?: false} → {installed, model} or {started} — install a denoise model from a file: an .onnx with a denoise-model.json beside it, or an archive LightCraft knows (the darktable `.dtmodel`). `acknowledged` must be true: the user has been shown the model's licence and accepted it. Unless `activate` is false it becomes the model in use", always, install), - cmd!(query "denoise.models.download", "Download Denoise Model", [], None, "{id, acknowledged: true} → {started, from} — fetch a model LightCraft has a pinned address for (see `downloadHost` in the list) in the background over HTTPS with dac-fetch. `acknowledged` must be true: the user has been shown the model's terms and accepted them. It is checked against its size and SHA-256, installed and chosen by itself; `denoise.models.downloads` shows how far it is", always, download), + cmd!(query "denoise.models.install", "Install Denoise Model", [], None, "{path, acknowledged: true, activate?: true, background?: false} → {installed, model} or {started} — install a denoise model from a file: an .onnx with a denoise-model.json beside it, or an archive the app knows (the darktable `.dtmodel`). `acknowledged` must be true: the user has been shown the model's licence and accepted it. Unless `activate` is false it becomes the model in use", always, install), + cmd!(query "denoise.models.download", "Download Denoise Model", [], None, "{id, acknowledged: true} → {started, from} — fetch a model the app has a pinned address for (see `downloadHost` in the list) in the background over HTTPS with dac-fetch. `acknowledged` must be true: the user has been shown the model's terms and accepted them. It is checked against its size and SHA-256, installed and chosen by itself; `denoise.models.downloads` shows how far it is", always, download), cmd!(query "denoise.models.downloads", "Denoise Model Downloads", [], None, "{} → {running, downloads: [{id, state: running | done | installed | failed | cancelled, bytes, total, error, from}]} — also installs any download that has arrived", always, downloads), cmd!(query "denoise.models.downloadCancel", "Cancel Denoise Model Download", [], None, "{id} → {discarded} — stop a download, or delete a finished one that was not installed", always, download_cancel), cmd!(query "denoise.models.test", "Test Denoise Model", [], None, "{id} → {ok, result} — load an installed model and check it gives sensible pictures; needs the denoise runtime", always, test), cmd!(query "denoise.models.remove", "Remove Denoise Model", [], None, "{id} → {removed, model} — delete an installed model (its cached pictures stay until they are evicted)", always, remove), cmd!(query "denoise.models.select", "Choose Denoise Model", [], None, "{id: installed model | null} → {model} — null switches denoise off", always, select), - cmd!(query "denoise.settings", "Denoise Settings", [], None, "{auto?: bool, cacheGb?: 1..10000, threads?: 1..64 | null, runOn?: auto | gpu | cpu} → the settings — `auto`: make the picture of the photos being looked at that have a Denoise amount; `cacheGb`: how much the cached pictures may take; `threads`: most tiles run at once; `runOn`: where the model runs — `auto` (the default) the graphics card where it is faster than the processor (both timed on this computer when the card is set up), `gpu` the card whenever it can run the model, `cpu` the processor. Setting `runOn` also lets the card be tried again after a set-up that failed or closed LightCraft", always, settings), + cmd!(query "denoise.settings", "Denoise Settings", [], None, "{auto?: bool, cacheGb?: 1..10000, threads?: 1..64 | null, runOn?: auto | gpu | cpu} → the settings — `auto`: make the picture of the photos being looked at that have a Denoise amount; `cacheGb`: how much the cached pictures may take; `threads`: most tiles run at once; `runOn`: where the model runs — `auto` (the default) the graphics card where it is faster than the processor (both timed on this computer when the card is set up), `gpu` the card whenever it can run the model, `cpu` the processor. Setting `runOn` also lets the card be tried again after a set-up that failed or closed the app", always, settings), cmd!( "denoise.toggle", "AI Denoise", diff --git a/crates/engine/src/cmd/export.rs b/crates/engine/src/cmd/export.rs index a8b25023bc..9607a3a96e 100644 --- a/crates/engine/src/cmd/export.rs +++ b/crates/engine/src/cmd/export.rs @@ -1,5 +1,5 @@ //! Export presets: built-in ones ([`crate::export::builtin_presets`]) and the user's, saved with the -//! library. `app.export {preset}` (desktop app, MCP, `lightcraft-cli run`) starts from a preset's +//! library. `app.export {preset}` (desktop app, MCP, the CLI's `run`) starts from a preset's //! params; the call's own params override them (see [`Session::export_params`]). use serde_json::{Value, json}; diff --git a/crates/engine/src/cmd/face_models.rs b/crates/engine/src/cmd/face_models.rs index a5f0f2be66..b06fa40ceb 100644 --- a/crates/engine/src/cmd/face_models.rs +++ b/crates/engine/src/cmd/face_models.rs @@ -128,7 +128,7 @@ fn row(s: &Session, m: &ModelManifest, installed: bool, selected: bool, accepted "known": from_catalog || known::all().iter().any(|k| k.id == m.id), // from the user's own catalog file "fromCatalog": from_catalog, - // a pinned address LightCraft can fetch it from (the user presses Download), and which site that is + // a pinned address the app can fetch it from (the user presses Download), and which site that is "downloadHost": download_spec(s, &m.id).map(|d| known::host(&d.url).to_string()), "installed": installed, "selected": selected, @@ -335,14 +335,14 @@ pub(super) fn finish_downloads(s: &mut Session) { } } -/// `faces.models.download {id, acknowledged: true}`: fetch a model LightCraft knows an address for, in the background; +/// `faces.models.download {id, acknowledged: true}`: fetch a model the app knows an address for, in the background; /// when it has arrived and checked out it is installed. fn download(s: &mut Session, p: &Value) -> Result { const C: &str = "faces.models.download"; let id = str_param(p, "id").ok_or_else(|| bad(C, "missing `id`"))?; let dir = models_dir(s, C)?; refresh_catalog(s); - let spec = download_spec(s, id).ok_or_else(|| bad(C, "LightCraft has no download for that model: get the file from its page, then add it"))?; + let spec = download_spec(s, id).ok_or_else(|| bad(C, "The app has no download for that model: get the file from its page, then add it"))?; if cfg!(target_arch = "wasm32") { return Err(bad(C, "the web build downloads no models")); } @@ -500,7 +500,7 @@ pub fn specs() -> Vec { cmd!(query "faces.models.list", "Face Models", [], None, "{} → {dir, enabled, embedder, runtime, models: [{id, name, role, licence{name, commercial, url, notice}, provenance, source, sizeBytes, known, installed, selected}]}", always, list), cmd!(query "faces.models.inspect", "Inspect Face Model File", [], None, "{path} → what a .onnx file is: {kind: known | draft | unsupported, model, assumptions, reason, alreadyInstalled}; installs nothing", always, inspect), cmd!(query "faces.models.install", "Install Face Model", [], None, "{path, acknowledged: true, activate?: true} → {installed, active: {embedder, enabled}} — copy a .onnx face recognition model into the models folder. `acknowledged` must be true: the user has been shown its licence (see inspect) and accepted it. Unless `activate` is false the model becomes the one in use and recognition is switched on; an earlier model stays installed", always, install), - cmd!(query "faces.models.download", "Download Face Model", [], None, "{id, acknowledged: true} → {started, from} — fetch a recognition model LightCraft has a pinned address for (see `downloadHost` in the list), in the background over pure-Rust https (dac-fetch). `acknowledged` must be true: the user has been shown the model's terms and accepted them. It is checked against its size and SHA-256, then installed, chosen and switched on by itself; `faces.models.downloads` shows how far it is", always, download), + cmd!(query "faces.models.download", "Download Face Model", [], None, "{id, acknowledged: true} → {started, from} — fetch a recognition model the app has a pinned address for (see `downloadHost` in the list), in the background over pure-Rust https (dac-fetch). `acknowledged` must be true: the user has been shown the model's terms and accepted them. It is checked against its size and SHA-256, then installed, chosen and switched on by itself; `faces.models.downloads` shows how far it is", always, download), cmd!(query "faces.models.downloads", "Face Model Downloads", [], None, "{} → {running, downloads: [{id, state: running | done | installed | failed | cancelled, bytes, total, error, from}]} — also installs any download that has arrived; `installed` stays listed until `faces.models.downloadCancel` clears it", always, downloads), cmd!(query "faces.models.downloadCancel", "Cancel Face Model Download", [], None, "{id} → {discarded} — stop a download, or delete a finished one that was not installed", always, download_cancel), cmd!(query "faces.models.test", "Test Face Model", [], None, "{id} → {ok, result: {loadMs, embedMs, dimension, checks}} — load an installed recognition model and check it gives sensible faces; needs the recognition runtime", always, test), diff --git a/crates/engine/src/cmd/lut_profiles.rs b/crates/engine/src/cmd/lut_profiles.rs index 937a13912a..2bce66aab7 100644 --- a/crates/engine/src/cmd/lut_profiles.rs +++ b/crates/engine/src/cmd/lut_profiles.rs @@ -109,7 +109,7 @@ fn import(s: &mut Session, p: &Value) -> Result { } None => { // no library on disk: remember the original (the in-memory session ends with the app) - let f = std::env::temp_dir().join(format!("lightcraft-{}.cube", id.trim_start_matches("lut:"))); + let f = std::env::temp_dir().join(format!("app-{}.cube", id.trim_start_matches("lut:"))); std::fs::write(&f, &text).map_err(|e| bad(C, e.to_string()))?; f.to_string_lossy().to_string() } diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 5930d14af0..332e46734b 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -449,7 +449,7 @@ pub fn specs() -> Vec { "Download AI Mask Model", [], None, - "{acknowledged: true} — download the SAM 3 model (about 3.4 GB, Meta's SAM License, not LightCraft's) in the background, from the configured mirrors; only after the user agreed to it. Watch segment.model.status; segment.model.cancel stops it (it resumes later) → {started, installed, downloading}", + "{acknowledged: true} — download the SAM 3 model (about 3.4 GB, Meta's SAM License, not the app's) in the background, from the configured mirrors; only after the user agreed to it. Watch segment.model.status; segment.model.cancel stops it (it resumes later) → {started, installed, downloading}", super::always, |s, p| { let c = "segment.model.download"; diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index c7d3708ac0..7a0e93d012 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -195,7 +195,7 @@ pub fn specs() -> Vec { "versions": ph.versions.iter().map(|v| json!({"name": v.name, "created": v.created})).collect::>(), })) }), - cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool} — allow/forbid GPU rendering (CPU fallback; LIGHTCRAFT_GPU=0 forbids it for the process); returns {enabled, available, adapter, reason (why the GPU is off), lastFallback (latest render redone on the CPU, and why)}", always, |_, p| { + cmd!(query "app.gpu", "GPU Rendering", [], None, "{enabled?: bool} — allow/forbid GPU rendering (CPU fallback; `{ENV_PREFIX}_GPU=0` forbids it for the process); returns {enabled, available, adapter, reason (why the GPU is off), lastFallback (latest render redone on the CPU, and why)}", always, |_, p| { if let Some(on) = p.get("enabled").and_then(Value::as_bool) { dac_gpu::set_enabled(on); } diff --git a/crates/engine/src/config.rs b/crates/engine/src/config.rs index 7ab9b6531a..c2a95e15cf 100644 --- a/crates/engine/src/config.rs +++ b/crates/engine/src/config.rs @@ -1,4 +1,4 @@ -//! Where LightCraft keeps per-user data on this machine: the config folder (app settings, camera +//! Where the app keeps per-user data on this machine: the config folder (app settings, camera //! profiles, face and denoise models). //! //! Hosts that have a file system (the desktop app, the CLI, the MCP server) share these defaults so a model @@ -10,31 +10,19 @@ use std::path::PathBuf; use crate::Session; -/// LightCraft's per-user config folder (`…/LightCraft`), if the platform tells us where. +/// The app's per-user config folder ([`dac_brand::settings_dir`]), if the platform tells us where. pub fn config_dir() -> Option { - if cfg!(target_os = "macos") { - std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/LightCraft")) - } else if cfg!(windows) { - std::env::var_os("APPDATA").map(|a| PathBuf::from(a).join("LightCraft")) - } else { - std::env::var_os("XDG_CONFIG_HOME") - .map(PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) - .map(|c| c.join("lightcraft")) - } + dac_brand::settings_dir() } -/// Where face models are kept: `$LIGHTCRAFT_FACE_MODELS` if set, else `/models`. +/// Where face models are kept: `{ENV_PREFIX}_FACE_MODELS` if set, else `/models`. pub fn default_face_models_dir() -> Option { - std::env::var_os("LIGHTCRAFT_FACE_MODELS").filter(|v| !v.is_empty()).map(PathBuf::from).or_else(|| config_dir().map(|d| d.join("models"))) + dac_brand::env_os("FACE_MODELS").filter(|v| !v.is_empty()).map(PathBuf::from).or_else(|| config_dir().map(|d| d.join("models"))) } -/// Where opt-in denoise models are kept: `$LIGHTCRAFT_DENOISE_MODELS` if set, else `/denoise-models`. +/// Where opt-in denoise models are kept: `{ENV_PREFIX}_DENOISE_MODELS` if set, else `/denoise-models`. pub fn default_denoise_models_dir() -> Option { - std::env::var_os("LIGHTCRAFT_DENOISE_MODELS") - .filter(|v| !v.is_empty()) - .map(PathBuf::from) - .or_else(|| config_dir().map(|d| d.join("denoise-models"))) + dac_brand::env_os("DENOISE_MODELS").filter(|v| !v.is_empty()).map(PathBuf::from).or_else(|| config_dir().map(|d| d.join("denoise-models"))) } impl Session { diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 04645a82c9..545cde4cdc 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -27,7 +27,7 @@ pub fn load(s: &mut Session) { location: sc.meta.location.into(), title: sc.name.clone(), keywords: sc.meta.keywords.iter().map(|k| k.to_string()).collect(), - creator: "LightCraft Demo".into(), + creator: format!("{} Demo", dac_brand::DISPLAY_NAME), ..Default::default() }; p.rating = [0, 3, 4, 5, 2, 0, 4, 3][sc.id as usize % 8]; diff --git a/crates/engine/src/denoise.rs b/crates/engine/src/denoise.rs index d8939cf9af..56be24d98d 100644 --- a/crates/engine/src/denoise.rs +++ b/crates/engine/src/denoise.rs @@ -130,7 +130,7 @@ const GPU_MAX_FALLBACKS: usize = 3; pub(crate) const GPU_SETUP_MARKER: &str = "gpu-setup.marker"; /// Why the card is not tried when a set-up marker is found. #[cfg(feature = "denoise")] -const GPU_CRASHED: &str = "LightCraft closed while it was setting up the graphics card for AI Denoise last time"; +const GPU_CRASHED: &str = "The app closed while it was setting up the graphics card for AI Denoise last time"; /// Why the card is not used when its set-up ran out of time (the start of it). #[cfg(feature = "denoise")] const GPU_SLOW: &str = "setting up the graphics card took longer than"; @@ -196,7 +196,7 @@ impl Drop for SetUpEnded { /// Set the graphics card up with `card` on a thread of its own while `meanwhile` runs here, and give up on the card when /// it takes longer than `limit` (a driver building kernels for minutes, or stuck). The `marker` file exists while `card` -/// runs: one found before it starts was left by a LightCraft that closed during a set-up (a driver crash takes the app +/// runs: one found before it starts was left by a the app that closed during a set-up (a driver crash takes the app /// with it), so the card is not tried again until the user chooses where denoise runs (`denoise.settings {runOn}`). #[cfg(feature = "denoise")] fn set_up_card( @@ -543,7 +543,7 @@ pub(crate) fn installed_models(dir: &Path) -> Vec { out } -/// Forget that setting the graphics card up for a model closed LightCraft before (the markers [`set_up_card`] leaves): +/// Forget that setting the graphics card up for a model closed the app before (the markers [`set_up_card`] leaves): /// the user chose where denoise runs, so the card may be tried again. pub(crate) fn forget_gpu_set_ups(dir: &Path) { for i in installed_models(dir) { @@ -768,7 +768,7 @@ pub(crate) fn make_product(spec: &JobSpec, progress: Option<&Progress>) -> Resul } let ran = began.elapsed(); product::write(&spec.product, &rgb, &spec.key).map_err(|e| MakeError::Failed(format!("could not save the denoised picture: {e}")))?; - if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { + if dac_brand::env_os("PROFILE").is_some() { eprintln!( "[denoise] read + decode {:.0} ms, model (set-up, tiles, blend) {:.0} ms, write {:.0} ms", decoded.as_secs_f64() * 1e3, @@ -1597,7 +1597,7 @@ mod gpu_tests { assert!(!marker.exists()); let r = set_up_card(&marker, Duration::from_secs(10), || -> Result<(), String> { panic!("driver") }, || ()); assert!(r.is_err() && !marker.exists(), "{r:?}"); - // a marker found before the set-up starts means the last one took LightCraft down: the card is not touched + // a marker found before the set-up starts means the last one took the app down: the card is not touched std::fs::write(&marker, "left behind").unwrap(); let touched = Arc::new(AtomicBool::new(false)); let t = touched.clone(); diff --git a/crates/engine/src/devices.rs b/crates/engine/src/devices.rs index 399d03fde7..82de078f10 100644 --- a/crates/engine/src/devices.rs +++ b/crates/engine/src/devices.rs @@ -26,10 +26,10 @@ pub struct Device { /// How long a scan is served before [`devices`] starts another one. pub const MAX_AGE: Duration = Duration::from_secs(2); -/// Folders whose children are mounted volumes on this platform. `LIGHTCRAFT_DEVICE_ROOTS` +/// Folders whose children are mounted volumes on this platform. `{ENV_PREFIX}_DEVICE_ROOTS` /// (paths joined like `PATH`) replaces them (tests, unusual mounts). fn mount_parents() -> Vec { - if let Some(v) = std::env::var_os("LIGHTCRAFT_DEVICE_ROOTS") { + if let Some(v) = dac_brand::env_os("DEVICE_ROOTS") { return std::env::split_paths(&v).collect(); } let mut out = Vec::new(); @@ -93,7 +93,7 @@ impl DeviceWatch { s.refreshing = true; let me = self.clone(); // the scan runs without the state lock; `get` only ever waits for a list clone - let spawned = std::thread::Builder::new().name("lightcraft-devices".into()).spawn(move || { + let spawned = std::thread::Builder::new().name("devices".into()).spawn(move || { let list = (me.scanner)(); me.store(list); }); @@ -160,7 +160,7 @@ pub fn on_change(f: impl Fn() + Send + Sync + 'static) { fn scan() -> Vec { let mut extra = Vec::new(); - if cfg!(windows) && std::env::var_os("LIGHTCRAFT_DEVICE_ROOTS").is_none() { + if cfg!(windows) && dac_brand::env_os("DEVICE_ROOTS").is_none() { extra.extend((b'D'..=b'Z').map(|c| std::path::PathBuf::from(format!("{}:\\", c as char)))); } devices_in(&mount_parents(), extra) diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index c3c187fab9..272c394aae 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -1252,7 +1252,7 @@ pub fn export_metadata(photo: &dac_catalog::Photo, o: &ExportOptions) -> Option< usage_terms: text(&m.usage_terms), copyright_url: text(&m.copyright_url), artist: text(&m.creator), - software: Some("LightCraft".into()), + software: Some(dac_brand::DISPLAY_NAME.into()), ..Default::default() }; match o.metadata { @@ -1663,7 +1663,7 @@ mod tests { for p in img.data.iter_mut() { *p = [0, 0, 0, 255]; } - let o = ExportOptions::from_json(&serde_json::json!({"watermark": {"text": "LightCraft", "size": 0.08, "opacity": 1.0, "shadow": false}})); + let o = ExportOptions::from_json(&serde_json::json!({"watermark": {"text": "The app", "size": 0.08, "opacity": 1.0, "shadow": false}})); let wm = o.watermark.clone().unwrap(); draw_watermark(&mut img, &wm); let lit = |x0: usize, x1: usize, y0: usize, y1: usize| { @@ -1697,7 +1697,7 @@ mod tests { #[test] fn watermarks_work_without_craft_fonts() { assert_eq!(watermark_fonts(&[]).len(), 1, "Inter only"); - for wm in [vertical_japanese(), Watermark { text: "LightCraft 日本語".into(), ..Watermark::default() }] { + for wm in [vertical_japanese(), Watermark { text: "The app 日本語".into(), ..Watermark::default() }] { let mut covered = 0usize; watermark_coverage(400, 300, &wm, &[], |_, _, k, _| covered += usize::from(k > 0.0)); assert!(covered > 0, "{:?} draws something", wm.text); diff --git a/crates/engine/src/faces_index.rs b/crates/engine/src/faces_index.rs index 61a00cf6e3..1a7ebc70a5 100644 --- a/crates/engine/src/faces_index.rs +++ b/crates/engine/src/faces_index.rs @@ -220,7 +220,7 @@ pub(crate) const SCAN_TAG: &str = "yunet-2023mar"; /// Largest record file we read. const SCAN_MAX_FILE: u64 = 64 << 20; -/// The photos the detector has already searched for faces, so each is searched once, however many times LightCraft +/// The photos the detector has already searched for faces, so each is searched once, however many times the app /// starts. Kept beside the library (`face-scanned.bin`: a header naming the detector, then photo ids, appended to). #[derive(Default)] pub(crate) struct Scanned { diff --git a/crates/engine/src/faces_worker.rs b/crates/engine/src/faces_worker.rs index 8c722fdca7..1f6541958f 100644 --- a/crates/engine/src/faces_worker.rs +++ b/crates/engine/src/faces_worker.rs @@ -75,7 +75,7 @@ impl Pace { } /// How many photos are worked on at once at `pace` on a machine with `cores` threads (as the system reports them, which -/// respects container limits and affinity). `cap` is a limit the user set (`LIGHTCRAFT_FACE_THREADS`). Memory is not +/// respects container limits and affinity). `cap` is a limit the user set (`{ENV_PREFIX}_FACE_THREADS`). Memory is not /// decided here: decodes wait at the process-wide memory gate ([`crate::memory::work_gate`]), sized from the RAM. pub(crate) fn workers_for(cores: usize, pace: Pace, cap: Option) -> usize { let n = match pace { @@ -93,17 +93,17 @@ fn cores() -> usize { } fn cap_from_env() -> Option { - std::env::var("LIGHTCRAFT_FACE_THREADS").ok().and_then(|v| v.trim().parse::().ok()) + dac_brand::env("FACE_THREADS").and_then(|v| v.trim().parse::().ok()) } /// The most photos the memory budget can hold in progress at once: half of it (the rest is the window's, the caches', -/// the renders'), a worker at a time. Raising the budget (`LIGHTCRAFT_MEMORY_MB`) raises this. +/// the renders'), a worker at a time. Raising the budget (`{ENV_PREFIX}_MEMORY_MB`) raises this. fn memory_workers() -> usize { (crate::memory::budget() / 2 / WORKER_BYTES).max(1) } /// Photos to work on at once at `pace` on this machine: by the processor's threads and the pace, and never more than -/// the memory allows. A limit the user sets (`LIGHTCRAFT_FACE_THREADS`) replaces the memory one. +/// the memory allows. A limit the user sets (`{ENV_PREFIX}_FACE_THREADS`) replaces the memory one. pub(crate) fn target_workers(pace: Pace) -> usize { workers_for(cores(), pace, Some(cap_from_env().unwrap_or_else(memory_workers))) } diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index b091a0beeb..e39941cdf0 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -45,7 +45,7 @@ fn meta_of(m: &dac_meta::Metadata) -> (Meta, Option) { /// Lens corrections embedded in a raw's `OpcodeList3` (`WarpRectilinear`, `FixVignetteRadial`: a DNG's own, or the /// raw reader's equivalent of the camera's correction, e.g. Panasonic / Leica RW2 distortion), re-expressed for the -/// default-cropped, EXIF-oriented image. These are the only "profile" corrections LightCraft applies. +/// default-cropped, EXIF-oriented image. These are the only "profile" corrections the app applies. pub fn embedded_lens(raw: &dac_raw::RawInfo) -> Option { use dac_develop::{EmbeddedLens, EmbeddedVignette, EmbeddedWarp}; use dac_geom::Point; @@ -701,7 +701,7 @@ mod tests { /// lenses is far off. A file shot with the camera's correction off (DMC-GH1) carries no correction. #[test] fn corpus_rw2_distortion_matches_the_camera_jpeg() { - let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + let dir = dac_brand::env_os("CORPUS") .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw"); diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 97a830639d..d1ca045508 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -2,7 +2,7 @@ //! //! Built with `CRAFT_FONTS_DIR=`, [`CRAFT_FONTS`] holds every font in its //! manifest (today: the Japanese and Simplified-Chinese UI and document fonts); otherwise it is -//! empty and LightCraft uses only its own bundled fonts (Inter). Both the egui UI and the export +//! empty and the app uses only its own bundled fonts (Inter). Both the egui UI and the export //! watermark renderer read it from here. See craftrules `standards/fonts.md`. /// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`). diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index a3c8064b00..8a6451b4d8 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -30,10 +30,10 @@ use serde::{Deserialize, Serialize}; use crate::Session; use crate::media::ProbeInfo; -/// File extensions LightCraft imports (lower case). +/// File extensions the app imports (lower case). pub const EXTENSIONS: &[&str] = &[ "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd", - "jxl", "gif", "bmp", "heic", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG) + "jxl", "gif", "bmp", "heic", "avif", // containers the app cannot decode but imports as preview only (their embedded JPEG) "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", ]; @@ -235,7 +235,7 @@ pub fn has_import_look(p: &Photo) -> bool { #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct ImportDefaults { - /// Preset applied to raw files (`None` = the LightCraft default). + /// Preset applied to raw files (`None` = the the app default). pub raw_preset: Option, /// Use a camera's own default (below) when the photo's camera has one. pub per_camera: bool, @@ -263,7 +263,7 @@ pub struct ImportDefaults { pub struct CameraDefault { /// Make + model (`Meta::camera`). pub camera: String, - /// Preset id; `None` = the LightCraft default for this camera. + /// Preset id; `None` = the the app default for this camera. pub preset: Option, } @@ -1393,8 +1393,8 @@ mod prepared_tests { #[test] fn revalidate_add_only_converts_matching_ready_item() { let mut s = Session::new(); - let existing_path = std::env::temp_dir().join("lightcraft-revalidate-existing.jpg"); - let other_path = std::env::temp_dir().join("lightcraft-revalidate-other.jpg"); + let existing_path = std::env::temp_dir().join("app-revalidate-existing.jpg"); + let other_path = std::env::temp_dir().join("app-revalidate-other.jpg"); let existing = Photo::new(PhotoId(1), Source::File { path: existing_path.to_string_lossy().into() }, "existing.jpg", "JPG", 1, 1, "now"); s.commit("existing", Op::AddPhoto { photo: Box::new(existing) }).unwrap(); let ready = |path: &Path| ReadyFile { diff --git a/crates/engine/src/legacy.rs b/crates/engine/src/legacy.rs new file mode 100644 index 0000000000..b19394337f --- /dev/null +++ b/crates/engine/src/legacy.rs @@ -0,0 +1,12 @@ +//! Identifiers from the app's previous name that are stored in users' files. They are file-format +//! tags, not branding: changing them would stop existing files from opening, so they stay as written. +//! This is the only engine file allowed to spell the previous name. + +/// The `format` tag of a preset file (written and read). +pub const PRESET_FORMAT: &str = "lightcraft.preset"; +/// The `format` tag of an exported curve-preset file (written and read). +pub const CURVE_PRESETS_FORMAT: &str = "lightcraft.curvePresets"; +/// The previous product name as a word in preset folder names, skipped when naming a group. +pub const NAME_WORD: &str = "lightcraft"; +/// The previous desktop executable name (lock owner notes). +pub const BINARY: &str = "lightcraft"; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 9982b9af04..55ddd5b1c9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1,4 +1,4 @@ -//! The LightCraft engine façade. +//! The the app engine façade. //! //! Every user-visible action is a command with a stable id (`photo.rate`, `develop.set`, //! `album.create`, `mask.add`…) and JSON parameters. The egui UI, the CLI, the control channel and @@ -35,6 +35,7 @@ pub mod fonts; pub mod guard; pub mod import; mod import_move; +pub mod legacy; pub mod library; mod lightroom_archive; pub mod lightroom_catalog; @@ -79,9 +80,9 @@ pub enum EngineError { Catalog(#[from] dac_catalog::CatalogError), /// The command's change is applied (in memory, undoable) but its journal records could not /// be written. They stay queued and are written by the next successful save. - #[error("saved in memory but not written to disk: {0}; LightCraft will retry")] + #[error("saved in memory but not written to disk: {0}; the app will retry")] NotSaved(String), - /// Another process (the app, `lightcraft-cli`, another computer) has the library open. + /// Another process (the app, the CLI, another computer) has the library open. #[error("{0}")] LibraryInUse(String), #[error("{0}")] @@ -450,7 +451,7 @@ impl Session { rect } - /// Apply an op that is LightCraft's own bookkeeping rather than something the user did (faces found by the background + /// Apply an op that is the app's own bookkeeping rather than something the user did (faces found by the background /// scan): journaled like any op, but not an undo step, and it leaves the redo stack alone. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn apply_system(&mut self, op: Op) -> Result<()> { diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index b900de3683..8cd000352b 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -2,7 +2,7 @@ //! last view state and the preview cache. //! //! ```text -//! LightCraft Library/ +//! Library/ //! catalog.snap catalog.log (dac-catalog journal) //! presets.json view.json (user presets + favourites; last source/sort/selection) //! prefs.json (library preferences: XMP sidecars, import defaults, cache size, last export) @@ -30,16 +30,27 @@ use serde::{Deserialize, Serialize}; use crate::{EngineError, LibrarySource, Result, Selection, Session}; /// Library directory name inside the user's Pictures folder. -pub const DEFAULT_NAME: &str = "LightCraft Library"; +pub const DEFAULT_NAME: &str = dac_brand::LIBRARY_DEFAULT; -/// The default library location: `$LIGHTCRAFT_LIBRARY` if set, else `~/Pictures/LightCraft Library` -/// (`%USERPROFILE%\Pictures\LightCraft Library` on Windows). +/// The default library location: `{ENV_PREFIX}_LIBRARY` if set, else `~/Pictures/<`[`DEFAULT_NAME`]`>` +/// (`%USERPROFILE%\Pictures\…` on Windows). When that folder doesn't exist but a library under a +/// previous default name does ([`dac_brand::library_default_names`]), that one is used, so an +/// existing library keeps opening after a rename. pub fn default_dir() -> Option { - if let Some(p) = std::env::var_os("LIGHTCRAFT_LIBRARY").filter(|p| !p.is_empty()) { + if let Some(p) = dac_brand::env_os("LIBRARY").filter(|p| !p.is_empty()) { return Some(PathBuf::from(p)); } let home = if cfg!(windows) { std::env::var_os("USERPROFILE") } else { std::env::var_os("HOME") }?; - Some(PathBuf::from(home).join("Pictures").join(DEFAULT_NAME)) + Some(default_in(&PathBuf::from(home).join("Pictures"))) +} + +/// The default library folder inside `pictures`: the current name, unless only a previous one exists. +pub fn default_in(pictures: &Path) -> PathBuf { + let current = pictures.join(DEFAULT_NAME); + if current.exists() { + return current; + } + dac_brand::library_default_names().into_iter().skip(1).map(|n| pictures.join(n)).find(|p| p.is_dir()).unwrap_or(current) } pub struct Library { @@ -191,11 +202,12 @@ fn same_dir(a: &Path, b: &Path) -> bool { } } -/// This program, for the lock owner note ("LightCraft", "dac-cli"). +/// This program, for the lock owner note (the app's display name, or the executable's name). fn program_name() -> String { let exe = std::env::current_exe().ok().and_then(|p| p.file_stem().map(|s| s.to_string_lossy().to_string())); match exe.as_deref() { - Some("lightcraft") | None => "LightCraft".into(), + None => dac_brand::DISPLAY_NAME.into(), + Some(n) if n == dac_brand::BINARY || n == crate::legacy::BINARY => dac_brand::DISPLAY_NAME.into(), Some(other) => other.to_string(), } } @@ -220,7 +232,7 @@ impl SettingsLoad { log::error!("library: {name}: {e}"); self.blocked.push(name); self.warnings.push(format!( - "{name} couldn't be read ({e}). LightCraft uses the defaults for now and won't overwrite the file; reopen the library to try again." + "{name} couldn't be read ({e}). the app uses the defaults for now and won't overwrite the file; reopen the library to try again." )); return None; } @@ -237,7 +249,7 @@ impl SettingsLoad { Err(w) => { self.blocked.push(name); self.warnings.push(format!( - "{name} is damaged ({err}) and couldn't be set aside ({w}). LightCraft uses the defaults and won't overwrite the file." + "{name} is damaged ({err}) and couldn't be set aside ({w}). the app uses the defaults and won't overwrite the file." )); } } @@ -645,7 +657,7 @@ impl Session { fn unlocked_warning(lock: Option<&LibraryLock>) -> Option { lock.filter(|l| !l.held()).map(|_| { "This library could not be locked (its catalog.lock file can't be locked where it is stored, e.g. on some network \ - shares), so it is open without protection against a second program: use it in one LightCraft app or command at \ + shares), so it is open without protection against a second program: use it in one app or command at \ a time, or changes made in one of them can be lost." .to_string() }) diff --git a/crates/engine/src/lightroom_archive.rs b/crates/engine/src/lightroom_archive.rs index ec993d7fa3..34dbd1e44d 100644 --- a/crates/engine/src/lightroom_archive.rs +++ b/crates/engine/src/lightroom_archive.rs @@ -511,7 +511,7 @@ mod tests { static NEXT: AtomicU64 = AtomicU64::new(0); fn tempdir() -> PathBuf { - let path = std::env::temp_dir().join(format!("lightcraft-lr-archive-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + let path = std::env::temp_dir().join(format!("app-lr-archive-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); fs::create_dir_all(&path).unwrap(); path } diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 492d881784..efdd0eaba1 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -519,10 +519,10 @@ fn lightroom_orientation(code: &str) -> Option { ALL.into_iter().find(|&o| code.chars().eq([corner(o, 0.0, 0.0), corner(o, 1.0, 0.0)])) } -/// Lightroom's crop in LightCraft's frame. `crs` gives the crop's top-left (`CropLeft`, `CropTop`) +/// Lightroom's crop in the app's frame. `crs` gives the crop's top-left (`CropLeft`, `CropTop`) /// and bottom-right (`CropRight`, `CropBottom`) corners on the stored image (before orientation and /// straightening), normalized to its size, plus a straighten angle that turns the other way to -/// LightCraft's. LightCraft's crop is a rectangle in the oriented image rotated by `angle` about its +/// The app's. The app's crop is a rectangle in the oriented image rotated by `angle` about its /// centre, normalized to the oriented image's size, so the corners go through the orientation and /// then the rotation; `stored_aspect` (width / height of the stored image) matters once the /// rectangle turns. On a real catalog this reproduced the frame aspect of all 223 straightened crops @@ -969,7 +969,7 @@ mod tests { assert_eq!(crop["rect"]["y1"], 1.0); assert_eq!(crop["angle"], 0.0); assert!(!unknown.iter().any(|k| k.starts_with("CropLeft")), "{unknown:?}"); - // an angle alone still means a crop (Lightroom's angle turns the other way to LightCraft's) + // an angle alone still means a crop (Lightroom's angle turns the other way to the app's) let (partial, unknown) = mapped_settings("s = { CropAngle = -1.5 }", true, 1.5, Orientation::Normal, 1.5).unwrap(); assert_eq!(partial["crop"]["geometry"]["angle"], 1.5); assert!(!unknown.iter().any(|k| k.starts_with("CropAngle")), "{unknown:?}"); @@ -1021,7 +1021,7 @@ mod tests { assert!(partial.get("crop").is_none()); } - /// The `crs` corners and angle Lightroom would store for LightCraft crop `g` on a stored image + /// The `crs` corners and angle Lightroom would store for the app crop `g` on a stored image /// of aspect `w` (height 1) shown with orientation `o`. fn lightroom_crop(g: CropGeometry, o: Orientation, w: f64) -> String { let (ow, oh) = if o.swaps_axes() { (1.0, w) } else { (w, 1.0) }; @@ -1156,7 +1156,7 @@ mod tests { #[test] fn persistent_reimport_keeps_copy_and_collection_ids_and_capture_time() { - let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-reimport-{}", std::process::id())); + let dir = std::env::temp_dir().join(format!("app-lrcat-reimport-{}", std::process::id())); let mut s = crate::Session::new(); s.open_library(&dir, false).unwrap(); let mut data = sample(); @@ -1260,7 +1260,7 @@ mod tests { leaf(&mut bytes[(i + 1) * 2048..(i + 2) * 2048], 0, std::slice::from_ref(row)); } leaf(&mut bytes[..2048], 100, &schema); - let path = std::env::temp_dir().join(format!("lightcraft-native-catalog-{}.lrcat", std::process::id())); + let path = std::env::temp_dir().join(format!("app-native-catalog-{}.lrcat", std::process::id())); std::fs::write(&path, &bytes).unwrap(); let catalog = read(&path).unwrap(); assert_eq!(catalog.photos.len(), 1); diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs index 39081e6e66..b3cc1a5085 100644 --- a/crates/engine/src/lightroom_job.rs +++ b/crates/engine/src/lightroom_job.rs @@ -375,7 +375,7 @@ mod tests { #[test] fn oversized_history_archive_keeps_prepared_import_successful_with_warning() { - let archive_dir = std::env::temp_dir().join(format!("lightcraft-lr-prepared-scale-{}", std::process::id())); + let archive_dir = std::env::temp_dir().join(format!("app-lr-prepared-scale-{}", std::process::id())); let mut data = empty_data(); data.photos = (0..5_000) .map(|source_id| { @@ -429,7 +429,7 @@ mod tests { #[test] fn execute_fn_import_does_not_rewrite_source_sidecar() { - let dir = std::env::temp_dir().join(format!("lightcraft-lr-job-sidecar-{}", std::process::id())); + let dir = std::env::temp_dir().join(format!("app-lr-job-sidecar-{}", std::process::id())); let path = dir.join("source.jpg"); let sidecar = dir.join("source.xmp"); std::fs::create_dir_all(&dir).unwrap(); diff --git a/crates/engine/src/logging.rs b/crates/engine/src/logging.rs index 015cf24d83..8c5b09affa 100644 --- a/crates/engine/src/logging.rs +++ b/crates/engine/src/logging.rs @@ -1,15 +1,18 @@ //! `log` records on stderr for the native desktop app and CLI (#168). The web build installs //! eframe's `WebLogger` instead. //! -//! Warnings and errors are shown by default. `LIGHTCRAFT_LOG` or `RUST_LOG` picks another level -//! (`off`, `error`, `warn`, `info`, `debug`, `trace`); `LIGHTCRAFT_LOG` wins when both are set. -//! `RUST_LOG` also accepts per-target directives (`lightcraft=debug,wgpu=warn`): a bare level -//! applies to everything, and the most verbose `lightcraft…=level` directive sets LightCraft's. -//! Below warnings only LightCraft's own records are shown, so `info` and `debug` aren't drowned +//! Warnings and errors are shown by default. `{ENV_PREFIX}_LOG` or `RUST_LOG` picks another level +//! (`off`, `error`, `warn`, `info`, `debug`, `trace`); `{ENV_PREFIX}_LOG` wins when both are set. +//! `RUST_LOG` also accepts per-target directives (`dac=debug,wgpu=warn`): a bare level +//! applies to everything, and the most verbose `dac…=level` directive sets the app's. +//! Below warnings only the app's own records (targets starting with [`OUR_TARGETS`]) are shown, so `info` and `debug` aren't drowned //! out by the GPU and windowing libraries. use log::{Level, LevelFilter, Metadata, Record}; +/// Log targets of the workspace's own crates start with this (`dac_engine`, `dac_gpu`, …). +pub const OUR_TARGETS: &str = "dac"; + /// Records shown at a level, written to stderr as `: LEVEL target: message`. pub struct StderrLog { prefix: &'static str, @@ -24,7 +27,7 @@ impl StderrLog { impl log::Log for StderrLog { fn enabled(&self, m: &Metadata) -> bool { - m.level() <= self.level && (m.level() <= Level::Warn || m.target().starts_with("lightcraft")) + m.level() <= self.level && (m.level() <= Level::Warn || m.target().starts_with(OUR_TARGETS)) } fn log(&self, r: &Record) { if self.enabled(r.metadata()) { @@ -46,9 +49,9 @@ fn parse_level(s: &str) -> Option { } } -/// The level that `LIGHTCRAFT_LOG` / `RUST_LOG` ask for; warnings when neither names one. -pub fn level_from(lightcraft_log: Option<&str>, rust_log: Option<&str>) -> LevelFilter { - if let Some(l) = lightcraft_log.and_then(parse_level) { +/// The level that `{ENV_PREFIX}_LOG` / `RUST_LOG` ask for; warnings when neither names one. +pub fn level_from(app_log: Option<&str>, rust_log: Option<&str>) -> LevelFilter { + if let Some(l) = app_log.and_then(parse_level) { return l; } let Some(rust_log) = rust_log else { return LevelFilter::Warn }; @@ -57,7 +60,7 @@ pub fn level_from(lightcraft_log: Option<&str>, rust_log: Option<&str>) -> Level for directive in rust_log.split(',') { match directive.split_once('=') { None => bare = parse_level(directive).or(bare), - Some((target, level)) if target.trim().starts_with("lightcraft") => { + Some((target, level)) if target.trim().starts_with(OUR_TARGETS) => { if let Some(l) = parse_level(level) { ours = Some(ours.map_or(l, |o| o.max(l))); } @@ -71,7 +74,7 @@ pub fn level_from(lightcraft_log: Option<&str>, rust_log: Option<&str>) -> Level /// Install the stderr logger at the level the environment asks for. Does nothing when a logger is /// already installed. pub fn install(prefix: &'static str) { - let level = level_from(std::env::var("LIGHTCRAFT_LOG").ok().as_deref(), std::env::var("RUST_LOG").ok().as_deref()); + let level = level_from(dac_brand::env("LOG").as_deref(), std::env::var("RUST_LOG").ok().as_deref()); static LOGGER: std::sync::OnceLock = std::sync::OnceLock::new(); if log::set_logger(LOGGER.get_or_init(|| StderrLog::new(prefix, level))).is_ok() { log::set_max_level(level); @@ -91,13 +94,13 @@ mod tests { assert_eq!(level_from(None, Some("TRACE")), LevelFilter::Trace); assert_eq!(level_from(None, Some("off")), LevelFilter::Off); assert_eq!(level_from(Some("info"), None), LevelFilter::Info); - assert_eq!(level_from(Some("error"), Some("debug")), LevelFilter::Error, "LIGHTCRAFT_LOG wins"); - assert_eq!(level_from(Some("nonsense"), Some("debug")), LevelFilter::Debug, "an unknown LIGHTCRAFT_LOG is ignored"); + assert_eq!(level_from(Some("error"), Some("debug")), LevelFilter::Error, "{{ENV_PREFIX}}_LOG wins"); + assert_eq!(level_from(Some("nonsense"), Some("debug")), LevelFilter::Debug, "an unknown {{ENV_PREFIX}}_LOG is ignored"); } #[test] - fn rust_log_directives_set_lightcraft_s_level() { - assert_eq!(level_from(None, Some("lightcraft=debug,wgpu=warn")), LevelFilter::Debug); + fn rust_log_directives_set_the_app_s_level() { + assert_eq!(level_from(None, Some("dac=debug,wgpu=warn")), LevelFilter::Debug); assert_eq!(level_from(None, Some("wgpu_core=trace")), LevelFilter::Warn, "other crates' directives don't apply"); assert_eq!(level_from(None, Some("error,dac_gpu=info")), LevelFilter::Info); assert_eq!(level_from(None, Some("dac_gpu=info,dac_engine=trace")), LevelFilter::Trace); @@ -105,7 +108,7 @@ mod tests { } #[test] - fn only_lightcraft_records_below_warnings() { + fn only_our_records_below_warnings() { let log = StderrLog::new("test", LevelFilter::Debug); let meta = |level, target| Metadata::builder().level(level).target(target).build(); assert!(log.enabled(&meta(Level::Warn, "wgpu_core::device"))); diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 791783302c..b69feaf211 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -796,7 +796,7 @@ impl RenderJob { /// Render `src`: on the GPU when `gpu` is set and a GPU is available (`dac_gpu`), else on /// the CPU, reusing `stages` either way. Both produce the same image within 1–3 LSB (see -/// `docs/gpu-pipeline.md`); `LIGHTCRAFT_GPU=0` or [`dac_gpu::set_enabled`] forces the CPU. +/// `docs/gpu-pipeline.md`); `{ENV_PREFIX}_GPU=0` or [`dac_gpu::set_enabled`] forces the CPU. pub fn develop(src: &Arc, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, stages: Option<&StageCache>, gpu: bool) -> Rendered { // LUT profiles have no GPU stage: they render on the CPU if gpu diff --git a/crates/engine/src/memory.rs b/crates/engine/src/memory.rs index 49d37a6140..8cd921d979 100644 --- a/crates/engine/src/memory.rs +++ b/crates/engine/src/memory.rs @@ -4,7 +4,7 @@ //! The numbers are the caches' own bookkeeping (pixels held), not the process's resident size: the //! allocator keeps freed pages for reuse and the GPU driver maps device buffers, so `ps`/`time -l` //! report more. A binary built with a heap profiler installs [`set_heap_stats`] (e.g. -//! `lightcraft-cli` with `--features dhat-heap`) to add live/peak heap bytes. +//! the CLI with `--features dhat-heap`) to add live/peak heap bytes. use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Condvar, Mutex, OnceLock}; @@ -19,9 +19,9 @@ use serde::Serialize; /// - an eighth for the GPU renderer's pool of recycled buffers (trimmed when the app is idle). /// /// The rest is headroom for what isn't cached (renders in progress, textures, the UI). Default: -/// a quarter of the machine's RAM, at most 1.5 GiB (`LIGHTCRAFT_MEMORY_MB` overrides it). +/// a quarter of the machine's RAM, at most 1.5 GiB (`{ENV_PREFIX}_MEMORY_MB` overrides it). pub fn default_budget() -> usize { - if let Some(mb) = std::env::var("LIGHTCRAFT_MEMORY_MB").ok().and_then(|v| v.trim().parse::().ok()).filter(|m| *m >= 64) { + if let Some(mb) = dac_brand::env("MEMORY_MB").and_then(|v| v.trim().parse::().ok()).filter(|m| *m >= 64) { return mb << 20; } let cap = 3usize << 29; // 1.5 GiB diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index 9f874f289d..7c6548b711 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -191,7 +191,7 @@ impl MergeJob { self.sources.iter().map(|(_, p)| std::path::Path::new(p).file_name().and_then(|n| n.to_str()).unwrap_or(p)).collect(); metadata.caption = Some(format!("Merged from {}", names.join(", "))); } - metadata.software = Some("LightCraft Photo Merge".into()); + metadata.software = Some(format!("{} Photo Merge", dac_brand::DISPLAY_NAME)); let dng = dac_merge::write_linear_dng(&image, &color, orientation, &metadata, baseline, samples).map_err(err)?; info["width"] = json!(image.width); info["height"] = json!(image.height); diff --git a/crates/engine/src/originals.rs b/crates/engine/src/originals.rs index bdd0ed2a11..5e5db5c439 100644 --- a/crates/engine/src/originals.rs +++ b/crates/engine/src/originals.rs @@ -1,4 +1,4 @@ -//! Never write over an original: exports, renders and other files LightCraft writes for the user +//! Never write over an original: exports, renders and other files the app writes for the user //! must not land on a catalogued photo's own file (or its XMP sidecar), whatever the conflict //! setting or exact path asked for. [`OriginalGuard`] is a snapshot of the library's originals //! that answers "would writing here replace one?" by file identity (the same file under another @@ -55,7 +55,7 @@ impl OriginalGuard { for (p, what) in self.by_name.get(n).into_iter().flatten() { if same_file(p, target) { return Err(format!( - "{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)", + "{} is {what} in the library: The app never writes over an original (choose another folder or file name)", target.display() )); } @@ -69,7 +69,7 @@ impl OriginalGuard { for (p, what) in links { if same_file(p, target) { return Err(format!( - "{} is {what} in the library: LightCraft never writes over an original (choose another folder or file name)", + "{} is {what} in the library: The app never writes over an original (choose another folder or file name)", target.display() )); } diff --git a/crates/engine/src/preset_import.rs b/crates/engine/src/preset_import.rs index 07093eb1dc..ea57db3bbf 100644 --- a/crates/engine/src/preset_import.rs +++ b/crates/engine/src/preset_import.rs @@ -433,7 +433,7 @@ pub fn group_from_dir(dir: &str) -> Option { "presets", "settings", "lcpreset", - "lightcraft", + crate::legacy::NAME_WORD, "user", "develop", "files", diff --git a/crates/engine/src/presets.rs b/crates/engine/src/presets.rs index 236af7aed1..96da792d72 100644 --- a/crates/engine/src/presets.rs +++ b/crates/engine/src/presets.rs @@ -10,10 +10,10 @@ use serde_json::{Value, json}; use crate::Session; -/// File extension of LightCraft preset files. -pub const LCPRESET_EXT: &str = "lcpreset"; +/// File extension of the app's preset files (legacy extensions are read too, see [`dac_brand::preset_exts`]). +pub const LCPRESET_EXT: &str = dac_brand::PRESET_EXT; /// The `format` tag of a `.lcpreset` file. -pub const LCPRESET_FORMAT: &str = "lightcraft.preset"; +pub const LCPRESET_FORMAT: &str = crate::legacy::PRESET_FORMAT; /// A `.lcpreset` file: one or more presets with their groups. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -50,7 +50,7 @@ pub fn parse_preset_file(name: &str, bytes: &[u8]) -> Result, String let ext = Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); let text = String::from_utf8_lossy(bytes); let text = text.trim_start_matches('\u{feff}'); - if ext == "xmp" || (ext != LCPRESET_EXT && text.trim_start().starts_with('<')) { + if ext == "xmp" || (!dac_brand::preset_exts().any(|e| e == ext) && text.trim_start().starts_with('<')) { return crate::crs::preset_from_xmp(text, &stem).map(|p| vec![p]).ok_or_else(|| "no develop settings in this XMP file".into()); } let v: Value = serde_json::from_str(text).map_err(|e| format!("not a preset file: {e}"))?; @@ -88,7 +88,7 @@ pub fn parse_preset_file(name: &str, bytes: &[u8]) -> Result, String pub fn expand_preset_paths(paths: &[String]) -> Vec { let preset_file = |p: &Path| { let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack"].contains(&ext.as_str()) + dac_brand::preset_exts().any(|e| e == ext) || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack"].contains(&ext.as_str()) }; let mut out = Vec::new(); for p in paths { @@ -562,7 +562,7 @@ mod tests { let path = d.join("mine"); let r = s.execute("preset.export", &json!({"path": path.to_string_lossy()})).unwrap(); assert_eq!(r["count"], 2); - let file = d.join("mine.lcpreset"); + let file = d.join(format!("mine.{}", dac_brand::PRESET_EXT)); assert!(file.is_file()); let one = d.join("travel.lcpreset"); s.execute("preset.export", &json!({"path": one.to_string_lossy(), "group": "Travel"})).unwrap(); diff --git a/crates/engine/src/segment/mod.rs b/crates/engine/src/segment/mod.rs index 6a9cb6337b..548e4483c1 100644 --- a/crates/engine/src/segment/mod.rs +++ b/crates/engine/src/segment/mod.rs @@ -1,7 +1,7 @@ //! AI masks: Object (clicks) and Describe (text) selections computed with SAM 3 //! (`dac-segment`, cargo feature `sam`; the desktop app enables it). //! -//! **Nothing requires the model.** It is not part of LightCraft (SAM License); the user +//! **Nothing requires the model.** It is not part of the app (SAM License); the user //! downloads it when they first use an AI mask and agree to (`segment.model.download`), or //! puts the files in the model folder themselves. Without it, AI mask requests fail with a //! clear "not installed" error; renders, exports and every other feature never touch it @@ -32,7 +32,7 @@ use crate::Session; pub const INPUT_EDGE: usize = 1008; /// Download size of the model (`model.safetensors`; the tokenizer files add ~2 MB). pub const MODEL_BYTES: u64 = 3_439_938_512; -/// The model's licence (not LightCraft's): shown before downloading. +/// The model's licence (not the app's): shown before downloading. pub const LICENSE_NAME: &str = "SAM License (Meta)"; pub const LICENSE_URL: &str = "https://github.com/facebookresearch/sam3/blob/main/LICENSE"; /// How errors about a missing model start (the UI offers the download on it). @@ -225,7 +225,7 @@ impl Segmenter { pub fn mirrors(&self) -> Vec { #[cfg(feature = "sam")] { - let env = std::env::var(dac_segment::fetch::MIRRORS_ENV).ok(); + let env = dac_brand::env(dac_segment::fetch::MIRRORS_ENV); dac_segment::fetch::mirrors(env.as_deref(), self.mirrors_file.as_deref()) } #[cfg(not(feature = "sam"))] @@ -251,7 +251,7 @@ impl Segmenter { return Err(format!("{NOT_INSTALLED} yet: it is downloading ({pct} %).")); } Err(format!( - "{NOT_INSTALLED}. Download it (about {:.1} GB, {LICENSE_NAME}) when LightCraft offers it, with `segment.model.download {{\"acknowledged\": true}}`, or put model.safetensors, vocab.json and merges.txt in {}.", + "{NOT_INSTALLED}. Download it (about {:.1} GB, {LICENSE_NAME}) when the app offers it, with `segment.model.download {{\"acknowledged\": true}}`, or put model.safetensors, vocab.json and merges.txt in {}.", MODEL_BYTES as f64 / 1e9, dir.display() )) diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 44ffe0f00f..ccbb98f94d 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -5,7 +5,7 @@ //! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop //! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`). //! -//! An existing sidecar is never replaced wholesale (issue #92): LightCraft's properties +//! An existing sidecar is never replaced wholesale (issue #92): The app's properties //! ([`OWNED`], [`OWNED_IF_STATED`]) are merged into it ([`dac_meta::merge_xmp`]) and //! everything else — another editor's `crs:` develop settings, `xmpMM` history, unknown //! namespaces — is kept byte for byte. A sidecar that can't be read as XMP is first copied to @@ -136,7 +136,7 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result String { let nz = |s: &str| (!s.trim().is_empty()).then(|| s.to_string()); let meta = dac_meta::Metadata { - software: Some("LightCraft".into()), + software: Some(dac_brand::DISPLAY_NAME.into()), title: nz(&p.meta.title), caption: nz(&p.meta.caption), alt_text: nz(&p.meta.alt_text), @@ -281,8 +281,8 @@ pub fn sidecar_packet(p: &Photo, cat: &dac_catalog::Catalog) -> String { dac_meta::write_xmp_lc(&meta, &lc) } -/// The properties LightCraft writes and owns in a sidecar: replaced on every save (removed when -/// LightCraft has no value, so clearing a field clears it in the file too). All of them are read +/// The properties the app writes and owns in a sidecar: replaced on every save (removed when +/// The app has no value, so clearing a field clears it in the file too). All of them are read /// back on import and by Read Metadata from File. pub const OWNED: &[&str] = &[ "xmp:Rating", @@ -304,8 +304,8 @@ pub const OWNED: &[&str] = &[ "lc:*", ]; -/// Properties replaced only when LightCraft has a value (capture time; GPS, which LightCraft -/// doesn't read from sidecars): otherwise the sidecar's own value stays. Anything else LightCraft +/// Properties replaced only when the app has a value (capture time; GPS, which the app +/// doesn't read from sidecars): otherwise the sidecar's own value stays. Anything else the app /// writes (`xmp:CreatorTool`) goes only into new sidecars. pub const OWNED_IF_STATED: &[&str] = &["exif:DateTimeOriginal", "photoshop:DateCreated", "exif:GPSLatitude", "exif:GPSLongitude"]; diff --git a/crates/engine/src/smart.rs b/crates/engine/src/smart.rs index 018834ad65..317b7a23a6 100644 --- a/crates/engine/src/smart.rs +++ b/crates/engine/src/smart.rs @@ -89,7 +89,7 @@ pub fn check_writable(dir: &Path) -> Result<(), String> { } std::fs::create_dir(dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?; } - let probe = dir.join(format!(".lightcraft-write-test-{}", std::process::id())); + let probe = dir.join(format!(".app-write-test-{}", std::process::id())); std::fs::write(&probe, b"x").map_err(|e| format!("{} is not writable (is the drive full or read-only?): {e}", dir.display()))?; let _ = std::fs::remove_file(&probe); Ok(()) diff --git a/crates/engine/src/tests_denoise.rs b/crates/engine/src/tests_denoise.rs index 8f64f04d60..bebdd9297a 100644 --- a/crates/engine/src/tests_denoise.rs +++ b/crates/engine/src/tests_denoise.rs @@ -635,7 +635,7 @@ fn settings_are_checked_saved_and_a_paused_pump_starts_nothing() { (st.run_on, st.gpu) = (None, Some(false)); crate::denoise::write_settings(&models, &st).unwrap(); assert_eq!(x.s.execute("denoise.models.list", &json!({})).unwrap()["runOn"], "cpu"); - // choosing where it runs lets the card be tried again after a set-up that closed LightCraft + // choosing where it runs lets the card be tried again after a set-up that closed the app let onnx = crate::denoise::installed_models(&models).first().unwrap().onnx.clone(); let marker = onnx.with_file_name(crate::denoise::GPU_SETUP_MARKER); std::fs::write(&marker, "setting up").unwrap(); diff --git a/crates/engine/src/tests_face_models.rs b/crates/engine/src/tests_face_models.rs index 9d736da299..9f57bde93d 100644 --- a/crates/engine/src/tests_face_models.rs +++ b/crates/engine/src/tests_face_models.rs @@ -37,7 +37,7 @@ fn the_list_knows_the_known_models_and_is_honest_about_the_runtime() { let l = s.execute("faces.models.list", &json!({})).unwrap(); assert_eq!(l["enabled"], false); assert_eq!(l["runtime"], cfg!(not(target_arch = "wasm32"))); - assert_eq!(find(&l, "yunet-2023mar")["installed"], false, "nothing ships with LightCraft"); + assert_eq!(find(&l, "yunet-2023mar")["installed"], false, "nothing ships with the app"); assert_eq!(find(&l, "auraface-v1")["installed"], false); assert_eq!(find(&l, "auraface-v1")["licence"]["commercial"], "yes"); assert_eq!(find(&l, "sface-2021dec")["licence"]["commercial"], "unknown"); diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 8802365aed..89631f255e 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1059,7 +1059,7 @@ fn expand_stops_at_the_walk_limits() { let _ = std::fs::remove_dir_all(&d); } -/// Raw containers LightCraft cannot decode but shows by their embedded JPEG are picked up from a folder +/// Raw containers the app cannot decode but shows by their embedded JPEG are picked up from a folder /// (the extensions are in `import::EXTENSIONS`; the files differ so none is a duplicate) and import as preview only. #[test] fn folder_import_picks_up_undecodable_raw_containers_as_preview_only() { diff --git a/crates/engine/src/tests_import_move.rs b/crates/engine/src/tests_import_move.rs index 9a336a5daf..26557afe3b 100644 --- a/crates/engine/src/tests_import_move.rs +++ b/crates/engine/src/tests_import_move.rs @@ -385,7 +385,7 @@ fn move_into_the_library_is_saved_before_sources_go() { /// Timing for issue #134 (not a pass/fail test): `cargo test -p dac-engine --release /// write_policy_timing -- --ignored --nocapture`. Writes N export-sized files durably (temp + /// sync + rename) and atomically only, and copies N files verified byte for byte and against the -/// probe hash, alternating so a loaded machine affects both alike. `LIGHTCRAFT_BENCH_DIR` puts +/// probe hash, alternating so a loaded machine affects both alike. `{ENV_PREFIX}_BENCH_DIR` puts /// the files on another volume (a USB drive or a NAS is where the difference shows). #[test] #[ignore] @@ -393,7 +393,7 @@ fn write_policy_timing() { use crate::import_move::copy_verified; use dac_catalog::safe_file::{write_atomic, write_atomic_nosync}; use std::time::{Duration, Instant}; - let root = std::env::var_os("LIGHTCRAFT_BENCH_DIR").map(PathBuf::from).unwrap_or_else(std::env::temp_dir); + let root = dac_brand::env_os("BENCH_DIR").map(PathBuf::from).unwrap_or_else(std::env::temp_dir); let base = root.join(format!("lc-write-policy-{}", std::process::id())); let _ = std::fs::remove_dir_all(&base); std::fs::create_dir_all(&base).unwrap(); diff --git a/crates/engine/src/tests_persist.rs b/crates/engine/src/tests_persist.rs index c606d7b01b..73d492e251 100644 --- a/crates/engine/src/tests_persist.rs +++ b/crates/engine/src/tests_persist.rs @@ -79,7 +79,7 @@ fn failed_append_fails_the_command_and_is_retried() { assert!(matches!(e, EngineError::NotSaved(_)), "{e:?}"); let msg = e.to_string(); assert!( - msg.starts_with("saved in memory but not written to disk: ") && msg.contains("disk full") && msg.ends_with("LightCraft will retry"), + msg.starts_with("saved in memory but not written to disk: ") && msg.contains("disk full") && msg.ends_with("the app will retry"), "{msg}" ); // applied in memory, undoable, queued diff --git a/crates/engine/src/tests_segment.rs b/crates/engine/src/tests_segment.rs index 24abeaf290..bd40b0829e 100644 --- a/crates/engine/src/tests_segment.rs +++ b/crates/engine/src/tests_segment.rs @@ -68,7 +68,7 @@ fn status_and_model_commands_without_the_model() { s.segmenter.mirrors_file = Some(dir.join("none.txt")); if s.segmenter.mirrors().is_empty() { let e = s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap_err().to_string(); - assert!(e.contains("LIGHTCRAFT_SAM3_MIRRORS"), "{e}"); + assert!(e.contains(&dac_brand::env_var("SAM3_MIRRORS")), "{e}"); } assert!(!s.segmenter.download_status().running); } diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 74f2264344..52b53b07ee 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -437,7 +437,7 @@ fn all_metadata_lists_exif_and_xmp() { /// photo relinked and its edits kept; undo goes back to the original; non-raws are skipped. #[test] fn convert_raw_to_dng() { - let corpus = std::env::var_os("LIGHTCRAFT_CORPUS") + let corpus = dac_brand::env_os("CORPUS") .map(PathBuf::from) .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/nef-nikon-d5100-uncompressed.nef"); @@ -629,7 +629,7 @@ fn gps_typed_in_is_saved_to_xmp() { /// DNG export compression: lossless, zip and none all decode; none is the biggest. #[test] fn dng_export_compression_choices() { - let corpus = std::env::var_os("LIGHTCRAFT_CORPUS") + let corpus = dac_brand::env_os("CORPUS") .map(PathBuf::from) .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/nef-nikon-d5100-uncompressed.nef"); @@ -854,7 +854,7 @@ fn saving_merges_into_another_apps_sidecar() { s.execute("develop.set", &json!({"control": "effects.clarity", "value": 0})).unwrap(); s.execute("photo.readMetadataFromFile", &json!({})).unwrap(); assert_eq!(s.catalog.photo(id).unwrap().develop, saved); - // saving again keeps one LightCraft description and the other app's data + // saving again keeps one the app description and the other app's data s.execute("photo.rate", &json!({"rating": 3})).unwrap(); s.execute("photo.saveMetadataToFile", &json!({})).unwrap(); let again = std::fs::read_to_string(src.join("shot.xmp")).unwrap(); diff --git a/crates/faces/Cargo.toml b/crates/faces/Cargo.toml index e69b12ce82..b6c8928a50 100644 --- a/crates/faces/Cargo.toml +++ b/crates/faces/Cargo.toml @@ -3,7 +3,7 @@ name = "dac-faces" version.workspace = true edition.workspace = true license.workspace = true -description = "LightCraft faces (L1): bounded ONNX CPU inference, face-model manifests, validation and detection" +description = "Faces (L1): bounded ONNX CPU inference, face-model manifests, validation and detection" [lints] workspace = true @@ -12,6 +12,7 @@ workspace = true default = [] [dependencies] +dac-brand = { workspace = true } ndarray = { version = "=0.17.2", default-features = false, features = ["std"] } # Rust intrinsics only, CPU-dispatched; no BLAS, cc/assembly or nested threading. matrixmultiply = { version = "=0.3.11", default-features = false, features = ["std", "avx512"] } diff --git a/crates/faces/src/catalog.rs b/crates/faces/src/catalog.rs index 07a2062283..c3ed1d3c75 100644 --- a/crates/faces/src/catalog.rs +++ b/crates/faces/src/catalog.rs @@ -1,9 +1,9 @@ //! Your own list of models to download: `catalog.json` in the models folder. //! -//! LightCraft's built-in list only holds models whose terms let the project point at them. Anything else (a stronger +//! The app's built-in list only holds models whose terms let the project point at them. Anything else (a stronger //! recogniser whose weights are for research use, a model you trained, a mirror you trust) can be added by you: each entry //! is a model manifest (the same fields as a `face-model.json`) plus the address to fetch it from, and it then gets the same -//! Download button as a built-in one, with its licence notice shown before anything is fetched. LightCraft ships and links to +//! Download button as a built-in one, with its licence notice shown before anything is fetched. The app ships and links to //! none of them; what the file says, and whether you may use the weights, is yours to check. //! //! ```json @@ -92,10 +92,10 @@ fn entry(v: &Value) -> Result { return Err("`url` must be an https:// address of at most 500 characters, without spaces".into()); } if known::all().iter().any(|k| k.id == m.id) { - return Err("that id belongs to a model LightCraft already knows".into()); + return Err("that id belongs to a model the app already knows".into()); } if m.sha256.as_deref().is_some_and(|h| known::lookup(h).is_some()) { - return Err("LightCraft already knows a model with that file".into()); + return Err("The app already knows a model with that file".into()); } Ok(Entry { file_name: file_name_of(&raw.url), url: raw.url, manifest: raw.manifest }) } diff --git a/crates/faces/src/known.rs b/crates/faces/src/known.rs index f1ff541c7b..b475791cb2 100644 --- a/crates/faces/src/known.rs +++ b/crates/faces/src/known.rs @@ -1,14 +1,14 @@ -//! The models LightCraft recognises by their SHA-256, with what is honestly known about each. +//! The models the app recognises by their SHA-256, with what is honestly known about each. //! //! The licence and provenance texts here are what the user reads before enabling a model, so they say what -//! is *not* known too. Weights are never part of LightCraft: every model is an opt-in the user installs. +//! is *not* known too. Weights are never part of the app: every model is an opt-in the user installs. use crate::manifest::{Colour, Commercial, InputSpec, Licence, ModelManifest, OutputSpec, Resize, Role, Thresholds}; -/// Detector output decoders built into LightCraft. +/// Detector output decoders built into the app. pub const DECODERS: &[&str] = &["yunet-v2"]; -/// The id of the face detector LightCraft runs (see [`yunet`]). +/// The id of the face detector the app runs (see [`yunet`]). pub const YUNET_ID: &str = "yunet-2023mar"; pub const YUNET_SHA256: &str = "8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4"; pub const SFACE_SHA256: &str = "0ba9fbfa01b5270c96627c4ef784da859931e02f04419c829e83484087c34e79"; @@ -133,7 +133,7 @@ const SOURCES: &[(&str, &str, &str)] = &[ ("auraface-v1", "https://huggingface.co/fal/AuraFace-v1/resolve/af6d057c9b0ec4071d4c49c80e3539258798b609/glintr100.onnx", "glintr100.onnx"), ]; -/// How to download the model with this id, if LightCraft offers to. +/// How to download the model with this id, if the app offers to. pub fn download(id: &str) -> Option { let (_, url, file_name) = SOURCES.iter().find(|(i, _, _)| *i == id)?; let m = all().into_iter().find(|m| m.id == id)?; diff --git a/crates/faces/src/lib.rs b/crates/faces/src/lib.rs index b4b7066d56..b6e197450d 100644 --- a/crates/faces/src/lib.rs +++ b/crates/faces/src/lib.rs @@ -1,4 +1,4 @@ -//! Face models for LightCraft: what a model is ([`ModelManifest`]), whether to trust a description of one +//! Face models: what a model is ([`ModelManifest`]), whether to trust a description of one //! ([`manifest::validate`]), the models we know by hash ([`known`]), and a probe that reads an unknown //! `.onnx` file's input and output shapes without running it ([`onnx`]), so installing a model of your //! own can be one drop and one licence prompt. diff --git a/crates/faces/src/manifest.rs b/crates/faces/src/manifest.rs index d3d9950127..efa982aa74 100644 --- a/crates/faces/src/manifest.rs +++ b/crates/faces/src/manifest.rs @@ -25,7 +25,7 @@ pub enum Role { #[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, @@ -204,7 +204,7 @@ fn ratio(field: &'static str, v: Option, lo: f32, hi: f32) -> Result<(), Ma } } -/// Check a manifest from any source. Everything the rest of LightCraft relies on (sizes it will allocate, +/// Check a manifest from any source. Everything the rest of the app relies on (sizes it will allocate, /// numbers it will divide by, text it will show) is bounded here. pub fn validate(m: &ModelManifest) -> Result<(), ManifestError> { if !valid_id(&m.id) { diff --git a/crates/faces/src/net.rs b/crates/faces/src/net.rs index 6222314f24..cc7354ce9a 100644 --- a/crates/faces/src/net.rs +++ b/crates/faces/src/net.rs @@ -1,4 +1,4 @@ -//! A small CPU interpreter for the convolutional networks LightCraft bundles (the YuNet face detector). +//! A small CPU interpreter for the convolutional networks the app bundles (the YuNet face detector). //! //! It runs checked convolution, affine/activation, broadcast, pooling, matrix and shape operators on //! float32 tensors. Unsupported semantics fail at load time. Dense convolutions use bounded im2col and @@ -99,7 +99,7 @@ impl Net { shape::tensor(&input.shape, input.data.len(), &input.data)?; self.validate_input(&input.shape)?; let mut scratch = kernels::Scratch::default(); - let profile = std::env::var_os("LIGHTCRAFT_PROFILE").is_some(); + let profile = dac_brand::env_is_set("PROFILE"); let g = &self.graph; let Some((input_name, _)) = g.inputs.first() else { return shape_err("the model has no input") }; // when each value is last read, so big intermediates are dropped as soon as nothing needs them diff --git a/crates/faces/src/runtime.rs b/crates/faces/src/runtime.rs index bd3ee85d18..950ad1f760 100644 --- a/crates/faces/src/runtime.rs +++ b/crates/faces/src/runtime.rs @@ -1,4 +1,4 @@ -//! Installed recognition models on LightCraft's own checked, single-thread float32 CPU engine. +//! Installed recognition models on the app's own checked, single-thread float32 CPU engine. //! The detector and recognisers share graph validation and convolution kernels. use std::panic::{AssertUnwindSafe, catch_unwind}; diff --git a/crates/faces/src/suggest.rs b/crates/faces/src/suggest.rs index e24413540d..e1e9ab4ce1 100644 --- a/crates/faces/src/suggest.rs +++ b/crates/faces/src/suggest.rs @@ -94,7 +94,7 @@ pub fn suggest_with(info: &OnnxInfo, sha256: &str, size: u64, file_name: &str, e name: "Unknown".into(), commercial: Commercial::Unknown, url: None, - notice: "LightCraft does not know where this model came from or what its licence allows. Use it only if its licence allows what you are doing, and do not redistribute it.".into(), + notice: "The app does not know where this model came from or what its licence allows. Use it only if its licence allows what you are doing, and do not redistribute it.".into(), }, source: None, sha256: Some(sha256.to_string()).filter(|h| h.len() == 64), diff --git a/crates/faces/src/yunet.rs b/crates/faces/src/yunet.rs index 27a176b69d..dc95c335f0 100644 --- a/crates/faces/src/yunet.rs +++ b/crates/faces/src/yunet.rs @@ -1,4 +1,4 @@ -//! The YuNet face detector (232 KB, MIT, downloaded by the user in Settings ▸ Faces), run by LightCraft's own small +//! The YuNet face detector (232 KB, MIT, downloaded by the user in Settings ▸ Faces), run by the app's own small //! interpreter ([`crate::net`]): no extra runtime. //! //! A photo is letterboxed (aspect ratio kept, padded at the right and bottom) into the network's square diff --git a/crates/fetch/Cargo.toml b/crates/fetch/Cargo.toml index a8d79cfdc9..f9da3be1d1 100644 --- a/crates/fetch/Cargo.toml +++ b/crates/fetch/Cargo.toml @@ -3,7 +3,10 @@ name = "dac-fetch" version.workspace = true edition.workspace = true license = "Apache-2.0" -description = "LightCraft model downloads: mirrors, resume, pinned SHA-256, over HTTPS in pure Rust (L0)" +description = "Model downloads: mirrors, resume, pinned SHA-256, over HTTPS in pure Rust (L0)" + +[dependencies] +dac-brand = { workspace = true } [lints] workspace = true diff --git a/LICENSE-APACHE b/crates/fetch/LICENSE-APACHE similarity index 100% rename from LICENSE-APACHE rename to crates/fetch/LICENSE-APACHE diff --git a/crates/fetch/src/http.rs b/crates/fetch/src/http.rs index e900850acb..8c070a53ae 100644 --- a/crates/fetch/src/http.rs +++ b/crates/fetch/src/http.rs @@ -458,9 +458,10 @@ impl Response { /// Send `GET url` with extra `headers` and read the status line and headers. pub fn get(url: &Url, headers: &[(&str, String)], limits: &Limits) -> Result { let mut req = format!( - "GET {} HTTP/1.1\r\nHost: {}\r\nUser-Agent: LightCraft/{}\r\nAccept: */*\r\nAccept-Encoding: identity\r\nConnection: close\r\n", + "GET {} HTTP/1.1\r\nHost: {}\r\nUser-Agent: {}/{}\r\nAccept: */*\r\nAccept-Encoding: identity\r\nConnection: close\r\n", url.path, url.host_header(), + dac_brand::BINARY, env!("CARGO_PKG_VERSION") ); for (k, v) in headers { diff --git a/crates/fetch/src/lib.rs b/crates/fetch/src/lib.rs index 267b502335..604065feea 100644 --- a/crates/fetch/src/lib.rs +++ b/crates/fetch/src/lib.rs @@ -11,7 +11,7 @@ //! //! What to download and where from belongs to the caller: it passes the [`FileSpec`]s and the //! default mirrors (see [`mirrors`]). `dac-segment` does so for the SAM 3 weights, which -//! are never part of LightCraft (SAM License, see docs/ai-masks.md). +//! are never part of the app (SAM License, see docs/ai-masks.md). //! //! Native only: on wasm32 this crate is empty (the web build downloads no models). @@ -79,7 +79,7 @@ pub struct Options { pub stall_timeout: Duration, /// Tries per mirror and file (each resumes where the last one stopped). pub attempts: u32, - /// The environment variable holding a bearer token, sent to each configured mirror's own host (only over https, + /// The environment variable (name after the app prefix, read with `dac_brand::env`) holding a bearer token, sent to each configured mirror's own host (only over https, /// never to a host a redirect leads to), so list only mirrors that may see it. `None`: nothing is ever sent, so one model's token cannot leak to another's server. pub token_env: Option<&'static str>, } @@ -345,7 +345,7 @@ fn fetch_file(f: &FileSpec<'_>, url: &str, dir: &Path, opts: &Options, cancel: & headers.push(("Range", format!("bytes={have}-"))); } // a token for the configured host only (never sent on to a redirect's host) - if let Some(t) = bearer(opts.token_env.and_then(|name| std::env::var(name).ok()).as_deref(), &url, &origin) { + if let Some(t) = bearer(opts.token_env.and_then(dac_brand::env).as_deref(), &url, &origin) { headers.push(("Authorization", format!("Bearer {t}"))); } let r = http::get(&url, &headers, &limits).map_err(http_fail)?; diff --git a/crates/geom/Cargo.toml b/crates/geom/Cargo.toml index 9bf6201f39..2f2a5eee91 100644 --- a/crates/geom/Cargo.toml +++ b/crates/geom/Cargo.toml @@ -3,12 +3,13 @@ name = "dac-geom" version.workspace = true edition.workspace = true license.workspace = true -description = "LightCraft geom (L0)" +description = "Geom (L0)" [lints] workspace = true [dependencies] +dac-brand = { workspace = true } serde = { workspace = true } [dev-dependencies] diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 1d81279d9d..235ec7c8aa 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -1,4 +1,4 @@ -//! Geometry for LightCraft: points, rectangles, affine and projective transforms, and the crop model. +//! Geometry: points, rectangles, affine and projective transforms, and the crop model. //! //! Conventions: image coordinates are y-down. *Normalized* coordinates map the full (uncropped, //! oriented) image to `0..1` on both axes, so tools and settings are resolution independent. diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index 0c9f5ae769..34875baa0a 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -3,12 +3,13 @@ name = "dac-gpu" version.workspace = true edition.workspace = true license.workspace = true -description = "LightCraft GPU develop pipeline: wgpu compute (WGSL) twin of the CPU pipeline, which stays the reference (L3)" +description = "GPU develop pipeline: wgpu compute (WGSL) twin of the CPU pipeline, which stays the reference (L3)" [lints] workspace = true [dependencies] +dac-brand = { workspace = true } dac-denoise = { workspace = true, optional = true } dac-geom = { workspace = true } dac-color = { workspace = true } diff --git a/crates/gpu/src/backend.rs b/crates/gpu/src/backend.rs index 041f577378..59f5b5c4de 100644 --- a/crates/gpu/src/backend.rs +++ b/crates/gpu/src/backend.rs @@ -1,4 +1,4 @@ -//! Which wgpu backends LightCraft lets wgpu use — for its compute device and for the desktop +//! Which wgpu backends the app lets wgpu use — for its compute device and for the desktop //! window (eframe) — and the crash sentinel around device creation (issue #136). //! //! wgpu loads the system driver of *every* backend in the instance's set while it enumerates @@ -8,7 +8,7 @@ //! Vulkan (plus GL for the window when the build has it). //! //! Overrides, read once per process: -//! - `LIGHTCRAFT_GPU_BACKEND` = `dx12` | `vulkan` | `metal` | `gl` (comma lists allowed), `auto` +//! - `{ENV_PREFIX}_GPU_BACKEND` = `dx12` | `vulkan` | `metal` | `gl` (comma lists allowed), `auto` //! (the platform default), or `off` (no GPU compute; the window still needs one backend and //! keeps the platform default); //! - else `WGPU_BACKEND` (wgpu's own variable, same names) for both the window and compute. @@ -47,14 +47,15 @@ pub fn parse_backends(s: &str) -> Option { } } -/// The choice from `LIGHTCRAFT_GPU_BACKEND` (`lightcraft`) or else `WGPU_BACKEND` (`wgpu`): the +/// The choice from `{ENV_PREFIX}_GPU_BACKEND` (`app`) or else `WGPU_BACKEND` (`wgpu`): the /// first one that parses wins; an unknown value is skipped (with a warning). -pub fn choose(lightcraft: Option<&str>, wgpu: Option<&str>) -> BackendChoice { - for (name, v) in [("LIGHTCRAFT_GPU_BACKEND", lightcraft), ("WGPU_BACKEND", wgpu)] { +pub fn choose(app: Option<&str>, wgpu: Option<&str>) -> BackendChoice { + let app_name = dac_brand::env_var("GPU_BACKEND"); + for (name, v) in [(app_name.as_str(), app), ("WGPU_BACKEND", wgpu)] { let Some(v) = v else { continue }; match parse_backends(v) { // WGPU_BACKEND is wgpu's variable: it selects backends, it never turns the GPU off - Some(BackendChoice::Off) if name == "WGPU_BACKEND" => log::warn!("gpu: WGPU_BACKEND={v} ignored (use LIGHTCRAFT_GPU_BACKEND=off)"), + Some(BackendChoice::Off) if name == "WGPU_BACKEND" => log::warn!("gpu: WGPU_BACKEND={v} ignored (use {app_name}=off)"), Some(c) => return c, None => log::warn!("gpu: {name}={v} names no known backend; ignored"), } @@ -99,13 +100,13 @@ pub fn resolve(choice: BackendChoice, default: Backends, compiled: Backends) -> fn env_choice() -> BackendChoice { static C: std::sync::OnceLock = std::sync::OnceLock::new(); *C.get_or_init(|| { - let lc = std::env::var("LIGHTCRAFT_GPU_BACKEND").ok(); + let lc = dac_brand::env("GPU_BACKEND"); let wg = std::env::var("WGPU_BACKEND").ok(); choose(lc.as_deref(), wg.as_deref()) }) } -/// The backends of the compute device (`None`: `LIGHTCRAFT_GPU_BACKEND=off`). +/// The backends of the compute device (`None`: `{ENV_PREFIX}_GPU_BACKEND=off`). pub fn compute_backends() -> Option { resolve(env_choice(), default_compute_backends(std::env::consts::OS), wgpu::Instance::enabled_backend_features()) } @@ -125,10 +126,10 @@ pub fn window_backends() -> Backends { /// `WGPU_DX12_COMPILER` (`wgpu`, wgpu's own variable: `fxc`, `dxc`, `auto`…) names another. /// /// Issue #471: wgpu's default (`Auto`) loads whichever `dxcompiler.dll` the DLL search path finds -/// first — LightCraft ships none, so it is some other program's (an SDK's, a folder on `PATH`). A +/// first — the app ships none, so it is some other program's (an SDK's, a folder on `PATH`). A /// copy without its `dxil.dll` beside it warns that the DXIL is unsigned; wgpu takes the warning for /// a compile error, its own validation pipelines fail, the device is lost and the window never -/// opens. FXC is always there and compiles everything LightCraft's shaders need. +/// opens. FXC is always there and compiles everything the app's shaders need. pub fn dx12_compiler(env: Option<&str>) -> wgpu::Dx12Compiler { let Some(v) = env else { return wgpu::Dx12Compiler::Fxc }; v.parse().unwrap_or_else(|e| { @@ -137,7 +138,7 @@ pub fn dx12_compiler(env: Option<&str>) -> wgpu::Dx12Compiler { }) } -/// wgpu's backend options for every instance LightCraft creates — the window's and the compute +/// wgpu's backend options for every instance the app creates — the window's and the compute /// devices': wgpu's environment variables, with the DX12 compiler from [`dx12_compiler`]. pub fn backend_options() -> wgpu::BackendOptions { let mut o = wgpu::BackendOptions::from_env_or_default(); @@ -145,7 +146,7 @@ pub fn backend_options() -> wgpu::BackendOptions { o } -/// `LIGHTCRAFT_GPU_BACKEND=off`. +/// `{ENV_PREFIX}_GPU_BACKEND=off`. pub(crate) fn env_off() -> bool { env_choice() == BackendChoice::Off } @@ -213,12 +214,12 @@ mod tests { } #[test] - fn lightcraft_variable_wins_over_wgpu_backend() { + fn app_variable_wins_over_wgpu_backend() { assert_eq!(choose(None, None), BackendChoice::Auto); assert_eq!(choose(None, Some("dx12")), BackendChoice::Use(Backends::DX12)); assert_eq!(choose(Some("vulkan"), Some("dx12")), BackendChoice::Use(Backends::VULKAN)); assert_eq!(choose(Some("off"), Some("dx12")), BackendChoice::Off); - // unknown LightCraft value: WGPU_BACKEND still applies + // unknown value of our variable: WGPU_BACKEND still applies assert_eq!(choose(Some("bogus"), Some("gl")), BackendChoice::Use(Backends::GL)); // WGPU_BACKEND never turns the GPU off assert_eq!(choose(None, Some("off")), BackendChoice::Auto); diff --git a/crates/gpu/src/ctx.rs b/crates/gpu/src/ctx.rs index aeeb94e31f..3e4b30a7ce 100644 --- a/crates/gpu/src/ctx.rs +++ b/crates/gpu/src/ctx.rs @@ -302,10 +302,10 @@ impl Gpu { if let Some((binding, buffer)) = limits_override() { limits.max_storage_buffer_binding_size = limits.max_storage_buffer_binding_size.min(binding); limits.max_buffer_size = limits.max_buffer_size.min(buffer); - log::info!("gpu: LIGHTCRAFT_GPU_LIMITS: storage bindings ≤ {} MiB, buffers ≤ {} MiB", binding >> 20, buffer >> 20); + log::info!("gpu: {}: storage bindings ≤ {} MiB, buffers ≤ {} MiB", dac_brand::env_var("GPU_LIMITS"), binding >> 20, buffer >> 20); } let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { - label: Some("lightcraft"), + label: Some(dac_brand::BINARY), required_limits: limits.clone(), ..Default::default() })) @@ -575,11 +575,11 @@ impl Gpu { /// normally reset a hung GPU much sooner). const READBACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); -/// `LIGHTCRAFT_GPU_LIMITS`: lower the device limits, e.g. to reproduce a smaller adapter. +/// `{ENV_PREFIX}_GPU_LIMITS`: lower the device limits, e.g. to reproduce a smaller adapter. /// `webgpu` / `downlevel` = 128 MiB storage bindings and 256 MiB buffers (the WebGPU defaults), /// `` = n MiB storage bindings and 2n MiB buffers. Returns (binding bytes, buffer bytes). fn limits_override() -> Option<(u64, u64)> { - parse_limits(&std::env::var("LIGHTCRAFT_GPU_LIMITS").ok()?) + parse_limits(&dac_brand::env("GPU_LIMITS")?) } fn parse_limits(v: &str) -> Option<(u64, u64)> { diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 168c7e80db..9a0fa0823f 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -1,4 +1,4 @@ -//! The LightCraft develop pipeline on the GPU (wgpu compute, WGSL kernels). +//! The develop pipeline on the GPU (wgpu compute, WGSL kernels). //! //! The CPU pipeline (`dac-pipeline`) is the reference: every kernel here is a port of a CPU //! stage, both read the same resolved parameters ([`dac_pipeline::Plan`], @@ -6,11 +6,11 @@ //! same settings on both and bound the difference in 8-bit sRGB. Stages without a kernel run on the //! CPU inside the same render (per-stage hybrid); see `docs/gpu-pipeline.md`. //! -//! Which backends wgpu may load (DX12 only on Windows; `LIGHTCRAFT_GPU_BACKEND`, `WGPU_BACKEND`) and +//! Which backends wgpu may load (DX12 only on Windows; `{ENV_PREFIX}_GPU_BACKEND`, `WGPU_BACKEND`) and //! the crash sentinel around device creation: [`backend`] (issue #136). //! -//! Use [`render`]: it returns `None` when the GPU is unavailable, disabled (`LIGHTCRAFT_GPU=0`, -//! `LIGHTCRAFT_GPU_BACKEND=off` or [`set_enabled`]), the render does not fit the device, or the device reported an error, ran out +//! Use [`render`]: it returns `None` when the GPU is unavailable, disabled (`{ENV_PREFIX}_GPU=0`, +//! `{ENV_PREFIX}_GPU_BACKEND=off` or [`set_enabled`]), the render does not fit the device, or the device reported an error, ran out //! of memory or returned an incomplete image — callers then render on the CPU. [`unavailable_reason`] //! and [`last_fallback`] say why (`ui.inspect` → `perf.gpuReason` / `perf.gpuFallback`). //! The browser build has no GPU path yet (WebGPU device creation is asynchronous): everything here @@ -64,15 +64,15 @@ fn record_fallback(reason: String) { } /// Why renders do not use the GPU, or `None` when they do (or will, once the device is created). -/// E.g. "disabled by LIGHTCRAFT_GPU=0", "software adapter (llvmpipe …) skipped", "device lost …". +/// E.g. "disabled by {ENV_PREFIX}_GPU=0", "software adapter (llvmpipe …) skipped", "device lost …". /// Never blocks. pub fn unavailable_reason() -> Option { if env_disabled() { #[cfg(not(target_arch = "wasm32"))] if backend::env_off() { - return Some("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()); + return Some(format!("disabled by {}=off", dac_brand::env_var("GPU_BACKEND"))); } - return Some("disabled by LIGHTCRAFT_GPU=0".into()); + return Some(format!("disabled by {}=0", dac_brand::env_var("GPU"))); } if !ENABLED.load(Ordering::Relaxed) { return Some("disabled by the GPU rendering preference (app.gpu)".into()); @@ -136,7 +136,7 @@ pub fn reset_failures() { *LAST_FALLBACK.lock().unwrap_or_else(|e| e.into_inner()) = None; } -/// Allow or forbid GPU rendering at runtime (a preference). `LIGHTCRAFT_GPU=0` forbids it for the +/// Allow or forbid GPU rendering at runtime (a preference). `{ENV_PREFIX}_GPU=0` forbids it for the /// whole process regardless. pub fn set_enabled(on: bool) { ENABLED.store(on, Ordering::Relaxed); @@ -152,16 +152,20 @@ pub fn enabled() -> bool { #[cfg(all(feature = "denoise", not(target_arch = "wasm32")))] pub(crate) fn switched_off() -> Option { if env_disabled() { - return Some(if backend::env_off() { "disabled by LIGHTCRAFT_GPU_BACKEND=off" } else { "disabled by LIGHTCRAFT_GPU=0" }.into()); + return Some(if backend::env_off() { + format!("disabled by {}=off", dac_brand::env_var("GPU_BACKEND")) + } else { + format!("disabled by {}=0", dac_brand::env_var("GPU")) + }); } (!ENABLED.load(Ordering::Relaxed)).then(|| "disabled by the GPU rendering preference (app.gpu)".into()) } -/// `LIGHTCRAFT_GPU=0` (or `LIGHTCRAFT_GPU_BACKEND=off`): no GPU for the whole process. +/// `{ENV_PREFIX}_GPU=0` (or `{ENV_PREFIX}_GPU_BACKEND=off`): no GPU for the whole process. fn env_disabled() -> bool { static OFF: std::sync::OnceLock = std::sync::OnceLock::new(); *OFF.get_or_init(|| { - let off = std::env::var("LIGHTCRAFT_GPU").is_ok_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no")); + let off = dac_brand::env("GPU").is_some_and(|v| matches!(v.trim(), "0" | "off" | "false" | "no")); #[cfg(not(target_arch = "wasm32"))] let off = off || backend::env_off(); off @@ -178,7 +182,7 @@ pub(crate) fn device() -> Option<&'static ctx::Gpu> { return existing_device(); } GPU.get_or_init(|| { - let Some(backends) = backend::compute_backends() else { return Err("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()) }; + let Some(backends) = backend::compute_backends() else { return Err(format!("disabled by {}=off", dac_brand::env_var("GPU_BACKEND"))) }; backend::with_init_marker(backends, || { std::panic::catch_unwind(|| ctx::Gpu::new(backends)).unwrap_or_else(|_| Err("device creation panicked".into())) }) diff --git a/crates/gpu/src/nn.rs b/crates/gpu/src/nn.rs index 4f71b5641e..6075792c9e 100644 --- a/crates/gpu/src/nn.rs +++ b/crates/gpu/src/nn.rs @@ -3,8 +3,8 @@ //! //! The runner has a device of its own, so a denoise job and the interactive renders are separate contexts that the //! driver time-slices, and a failure of one never takes the other down. It respects the same switches as GPU rendering -//! (`LIGHTCRAFT_GPU`, `LIGHTCRAFT_GPU_BACKEND`, the GPU preference) and the same crash sentinel around device creation. -//! `LIGHTCRAFT_GPU_ADAPTER` picks an adapter by (part of) its name, e.g. to try another card. +//! (`{ENV_PREFIX}_GPU`, `{ENV_PREFIX}_GPU_BACKEND`, the GPU preference) and the same crash sentinel around device creation. +//! `{ENV_PREFIX}_GPU_ADAPTER` picks an adapter by (part of) its name, e.g. to try another card. //! //! Every failure is an `Err` (never a panic), and a device that errs or is lost stops being used: callers then run the //! CPU runner. The kernels (`wgsl/nn_conv.wgsl`, `wgsl/nn_pool.wgsl`) are tested against the plain-loop reference @@ -67,7 +67,7 @@ fn dev() -> Result<&'static Dev, String> { } fn create_device() -> Result { - let Some(backends) = crate::backend::compute_backends() else { return Err("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()) }; + let Some(backends) = crate::backend::compute_backends() else { return Err(format!("disabled by {}=off", dac_brand::env_var("GPU_BACKEND"))) }; crate::backend::with_init_marker(backends, || { std::panic::catch_unwind(|| make_device(backends)).unwrap_or_else(|_| Err("device creation panicked".into())) }) @@ -79,7 +79,7 @@ fn make_device(backends: wgpu::Backends) -> Result { // DX12 shaders compile with FXC (issue #471) desc.backend_options = crate::backend::backend_options(); let instance = wgpu::Instance::new(desc); - let wanted = std::env::var("LIGHTCRAFT_GPU_ADAPTER").ok().map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()); + let wanted = dac_brand::env("GPU_ADAPTER").map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()); let adapter = match &wanted { Some(w) => { let all = pollster::block_on(instance.enumerate_adapters(backends)); diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 1ee2825082..ff6e23a302 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -537,7 +537,7 @@ struct Host { pub(crate) fn profiling() -> bool { static ON: std::sync::OnceLock = 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")) } /// Render on `gpu`, reusing `stages` (if given). `None` (or a result the caller discards) when the @@ -552,7 +552,7 @@ pub fn render( fault: Option, ) -> Option { let mut t = profiling().then(std::time::Instant::now); - // Each stage is submitted on its own; under `LIGHTCRAFT_PROFILE` also waited for, so the + // Each stage is submitted on its own; under `{ENV_PREFIX}_PROFILE` also waited for, so the // timings are real. let lap = |what: &str, t: &mut Option, cx: &mut Cx<'_>| match t { Some(t) => { diff --git a/crates/heif/Cargo.toml b/crates/heif/Cargo.toml index 29417aba60..9d6b43c6d9 100644 --- a/crates/heif/Cargo.toml +++ b/crates/heif/Cargo.toml @@ -1,11 +1,12 @@ [package] name = "dac-heif" -description = "Optional HEIF/HEIC still-image decoder for LightCraft (a thin, panic-guarded wrapper around heic-rs)" +description = "Optional HEIF/HEIC still-image decoder (a thin, panic-guarded wrapper around heic-rs)" version.workspace = true edition.workspace = true license.workspace = true [dependencies] +dac-brand = { workspace = true } # Pure Rust, no unsafe. Pinned exactly: a young, single-owner crate that parses untrusted files; # moving the pin is a reviewed change (re-run the heif tests and the codecs corpus tests). # Mirrors PhotoCraft's photocraft-heif (same team, same decision): heic-rs was chosen over the diff --git a/crates/heif/README.md b/crates/heif/README.md index a69e0b5de3..6b83958497 100644 --- a/crates/heif/README.md +++ b/crates/heif/README.md @@ -1,6 +1,6 @@ # dac-heif -The optional HEIF / HEIC decoder of LightCraft: iPhone and Mac photos. A thin, panic-guarded wrapper +The optional HEIF / HEIC decoder of the app: iPhone and Mac photos. A thin, panic-guarded wrapper around [`heic-rs`](https://github.com/tbraun96/heic-rs), a pure-Rust HEVC still-picture decoder (no `unsafe`, MIT OR Apache-2.0): single pictures and grid-tiled photos, 8- and 10-bit (10-bit decodes to 16-bit), alpha auxiliary images, the container's rotation/mirror/crop, ICC, EXIF and XMP. @@ -30,4 +30,4 @@ one set of regression fixtures. opening one is a clear "HEIC/HEIF support isn't included in this build" error — the same policy as PhotoCraft. -The crate depends on no other LightCraft crate; `codecs` → `heif` is the only edge between them. +The crate depends on no other workspace crate; `codecs` → `heif` is the only edge between them. diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index 9f49fa8629..948f3921c0 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -3,7 +3,7 @@ //! A thin wrapper around `heic-rs`, a pure-Rust HEVC still-picture decoder: single pictures and //! grid-tiled photos, 8- and 10-bit (10-bit decodes to 16-bit), alpha auxiliary images, ICC, //! EXIF and XMP. Its API is plain data ([`Info`], [`Decoded`], [`Error`]) so the crate knows -//! nothing about the rest of LightCraft; `dac-codecs` adapts it behind its `heif` feature. +//! nothing about the rest of the app; `dac-codecs` adapts it behind its `heif` feature. //! A port of PhotoCraft's `photocraft-heif` (same team, same licence) so the sibling apps share //! one decision, one dependency pin and one set of regression fixtures. //! diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index b23dba4c15..51c9a6b1ba 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -3,12 +3,13 @@ name = "dac-mcp" version.workspace = true edition.workspace = true license.workspace = true -description = "LightCraft MCP server (L5): JSON-RPC 2.0 over stdio, driving a running app or a headless session" +description = "MCP server (L5): JSON-RPC 2.0 over stdio, driving a running app or a headless session" [lints] workspace = true [dependencies] +dac-brand = { workspace = true } dac-engine = { workspace = true } dac-codecs = { workspace = true } dac-raster = { workspace = true } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 4772363b76..27dd74ca4d 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -22,7 +22,7 @@ pub trait Backend { /// `(completed photos, total photos, next filename)`; false stops before the next photo. pub type ProgressHook = Box bool + Send>; -/// A running LightCraft app, reached through its loopback control port. +/// A running desktop app, reached through its loopback control port. pub struct Remote { addr: String, conn: Option<(BufReader, TcpStream)>, @@ -95,7 +95,12 @@ impl Backend for Remote { self.conn = None; self.roundtrip(&line).map_err(|e| { self.conn = None; - format!("LightCraft app at {} is not reachable: {e} (start it with `lightcraft --control PORT`)", self.addr) + format!( + "{} at {} is not reachable: {e} (start it with `{} --control PORT`)", + dac_brand::DISPLAY_NAME, + self.addr, + dac_brand::BINARY + ) })? } }; diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 25597408b7..32deef8118 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -13,7 +13,7 @@ use crate::backend::{Backend, ProgressHook}; /// File extensions recognised as photos when expanding folders. pub const PHOTO_EXTENSIONS: &[&str] = &[ "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "arw", "raf", "orf", "rw2", "rwl", "raw", "pef", "srw", "psd", "jxl", - "gif", "bmp", "avif", // containers LightCraft cannot decode but imports as preview only (their embedded JPEG) + "gif", "bmp", "avif", // containers the app cannot decode but imports as preview only (their embedded JPEG) "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", ]; @@ -148,7 +148,7 @@ impl Backend for Headless { } "app.export" => self.export(&p), m if m.starts_with("ui.") || m == "app.quit" => { - Err(format!("`{m}` needs the desktop app: start `lightcraft --control 7980` and run the MCP server with `--connect`")) + Err(format!("`{m}` needs the desktop app: start `{} --control 7980` and run the MCP server with `--connect`", dac_brand::BINARY)) } other => Err(format!("unknown method `{other}`")), } diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 5584539dfa..42a3d37b61 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -1,11 +1,11 @@ -//! LightCraft's MCP server. +//! The app's MCP server. //! //! [Model Context Protocol](https://modelcontextprotocol.io) over stdio: newline-delimited -//! JSON-RPC 2.0, hand-written (no async runtime). The server exposes LightCraft as MCP tools and +//! JSON-RPC 2.0, hand-written (no async runtime). The server exposes the app as MCP tools and //! resources and forwards everything to a [`Backend`]: //! //! - [`Remote`] talks to a running desktop app through its loopback JSON-lines control channel -//! (`lightcraft --control 7980`): one `{"id","method","params"}` line in, one +//! (` --control 7980`): one `{"id","method","params"}` line in, one //! `{"id","ok","result"|"error"}` line out (see `docs/control-protocol.md`). //! - [`Headless`] hosts an in-process [`dac_engine::Session`] and answers the same //! control-channel method names itself (rendering with the engine's pipeline and encoding with @@ -27,7 +27,7 @@ pub use headless::{Headless, PHOTO_EXTENSIONS, encode_image, expand_paths, write pub use server::{PROTOCOL_VERSION, Server}; pub use tools::{COMMAND_TOOL_PREFIX, ToolResult, call_tool, command_tool_name, helper_tools, tool_definitions}; -/// Default control-channel address of the desktop app (`lightcraft --control 7980`). +/// Default control-channel address of the desktop app (` --control 7980`). pub const DEFAULT_ADDR: &str = "127.0.0.1:7980"; #[cfg(test)] diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index ba64ef45ac..fd0e5f2aac 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -24,7 +24,7 @@ const INVALID_PARAMS: i64 = -32602; const INTERNAL_ERROR: i64 = -32603; const RESOURCE_NOT_FOUND: i64 = -32002; -const INSTRUCTIONS: &str = "LightCraft is a non-destructive photo library and raw developer (a Lightroom alternative). \ +const INSTRUCTIONS: &str = "is a non-destructive photo library and raw developer (a Lightroom alternative). \ Photos have integer ids; most editing acts on the *active* photo (tools that take `id` make that photo active first). \ Typical loop: query_photos → select_photos (or pass id) → list_controls → set_develop {values: {\"light.exposure\": 0.5}} → \ render_preview to look at the result → export. Positions (crop rects, mask points, brush strokes) are normalized image \ @@ -32,13 +32,13 @@ coordinates 0..1 with the origin at the top-left. Every action is a command: com docs; command_run {id, params} runs any of them; command_batch runs steps in order. Inspect with doc_inspect. Edits are undoable (undo / redo)."; /// Resource URIs. -pub const DOCUMENT_URI: &str = "lightcraft://document"; -pub const COMMANDS_URI: &str = "lightcraft://commands"; -pub const LIBRARY_URI: &str = "lightcraft://library"; -pub const PHOTOS_URI: &str = "lightcraft://photos"; -pub const PHOTO_URI: &str = "lightcraft://photo/active"; -pub const DEVELOP_URI: &str = "lightcraft://develop/active"; -pub const CONTROLS_URI: &str = "lightcraft://controls"; +pub const DOCUMENT_URI: &str = "dac://document"; +pub const COMMANDS_URI: &str = "dac://commands"; +pub const LIBRARY_URI: &str = "dac://library"; +pub const PHOTOS_URI: &str = "dac://photos"; +pub const PHOTO_URI: &str = "dac://photo/active"; +pub const DEVELOP_URI: &str = "dac://develop/active"; +pub const CONTROLS_URI: &str = "dac://controls"; /// An MCP server bound to one backend. pub struct Server { @@ -166,8 +166,8 @@ impl Server { Ok(json!({ "protocolVersion": version, "capabilities": {"tools": {"listChanged": false}, "resources": {"subscribe": false, "listChanged": false}}, - "serverInfo": {"name": "lightcraft", "title": "LightCraft", "version": env!("CARGO_PKG_VERSION")}, - "instructions": format!("{INSTRUCTIONS} Backend: {}.", self.backend.describe()), + "serverInfo": {"name": dac_brand::MCP_SERVER, "title": dac_brand::DISPLAY_NAME, "version": env!("CARGO_PKG_VERSION")}, + "instructions": format!("{} {INSTRUCTIONS} Backend: {}.", dac_brand::DISPLAY_NAME, self.backend.describe()), })) } "ping" => Ok(json!({})), diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 941b9b443b..210e8e6496 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -20,7 +20,7 @@ fn initialize_negotiates_version() { let mut s = server(); let r = rpc(&mut s, 1, "initialize", json!({"protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}})); assert_eq!(r["result"]["protocolVersion"], "2025-03-26"); - assert_eq!(r["result"]["serverInfo"]["name"], "lightcraft"); + assert_eq!(r["result"]["serverInfo"]["name"], dac_brand::MCP_SERVER); assert!(r["result"]["capabilities"]["tools"].is_object()); let r = rpc(&mut s, 2, "initialize", json!({"protocolVersion": "1999-01-01"})); assert_eq!(r["result"]["protocolVersion"], PROTOCOL_VERSION); @@ -121,7 +121,7 @@ fn path_writes_never_replace_an_original() { } /// Issue #181: a misspelled or unreadable `app.export` param is an error through the headless -/// backend (what `lightcraft-cli run` and the MCP `export` tool use), not a silent default. +/// backend (what the CLI `run` and the MCP `export` tool use), not a silent default. #[test] fn export_refuses_unknown_params_and_bad_values() { let mut b = Headless::demo(); @@ -235,7 +235,7 @@ fn resources() { let text = r["result"]["contents"][0]["text"].as_str().unwrap_or_else(|| panic!("{uri}: {r}")); serde_json::from_str::(text).unwrap(); } - assert_eq!(rpc(&mut s, 99, "resources/read", json!({"uri": "lightcraft://nope"}))["error"]["code"], -32002); + assert_eq!(rpc(&mut s, 99, "resources/read", json!({"uri": "dac://nope"}))["error"]["code"], -32002); } /// `select_photos` with an id that is not in the library is a tool error, and the photo that was @@ -289,11 +289,11 @@ fn core_tools_have_hints_and_reject_unknown_keys() { for key in dac_engine::export::OPTION_PARAMS.iter().chain(dac_engine::export::TARGET_PARAMS) { assert!(export["inputSchema"]["properties"].get(*key).is_some(), "missing export argument {key}"); } - let before = rpc(&mut s, 2, "resources/read", json!({"uri":"lightcraft://library"})); + let before = rpc(&mut s, 2, "resources/read", json!({"uri":"dac://library"})); let image = rpc(&mut s, 2, "tools/call", json!({"name":"render_preview","arguments":{"max_side":64}})); assert_eq!(image["result"]["isError"], false, "{image}"); assert!(image["result"]["content"].as_array().unwrap().iter().any(|c| c["type"] == "image")); - assert_eq!(before, rpc(&mut s, 2, "resources/read", json!({"uri":"lightcraft://library"}))); + assert_eq!(before, rpc(&mut s, 2, "resources/read", json!({"uri":"dac://library"}))); let batch = json!({"steps":[{"id":"catalog.stats"},{"id":"no.such"},{"id":"catalog.stats"}],"stop_on_error":false}); let r = rpc(&mut s, 3, "tools/call", json!({"name":"command_batch","arguments":batch})); assert_eq!(r["result"]["isError"], true); @@ -312,7 +312,7 @@ fn core_resources_match_tools_after_parse_error() { assert_eq!(bad["error"]["code"], -32700); assert!(bad["id"].is_null()); let list = rpc(&mut s, 1, "resources/list", json!({})); - for (uri, tool) in [("lightcraft://document", "doc_inspect"), ("lightcraft://commands", "command_list")] { + for (uri, tool) in [("dac://document", "doc_inspect"), ("dac://commands", "command_list")] { assert!(list["result"]["resources"].as_array().unwrap().iter().any(|r| r["uri"] == uri)); let resource = rpc(&mut s, 2, "resources/read", json!({"uri":uri})); let call = rpc(&mut s, 3, "tools/call", json!({"name":tool,"arguments":{}})); @@ -320,7 +320,7 @@ fn core_resources_match_tools_after_parse_error() { let b: Value = serde_json::from_str(call["result"]["content"][0]["text"].as_str().unwrap()).unwrap(); assert_eq!(a, b); } - let old = rpc(&mut s, 4, "resources/read", json!({"uri":"lightcraft://library"})); + let old = rpc(&mut s, 4, "resources/read", json!({"uri":"dac://library"})); assert!(old["result"]["contents"].is_array(), "{old}"); } @@ -361,7 +361,7 @@ fn modern_requests_receive_result_and_cache_fields() { ("tools/list", json!({})), ("resources/list", json!({})), ("resources/templates/list", json!({})), - ("resources/read", json!({"uri":"lightcraft://library"})), + ("resources/read", json!({"uri":"dac://library"})), ] { params["_meta"] = meta.clone(); let r = rpc(&mut s, 1, method, params); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index cde6930863..4e2a9b3c73 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -91,7 +91,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { tool( "command_run", "Run command", - "Run any LightCraft command by id with JSON params (see command_list for ids and parameter docs), e.g. {id: \"photo.rate\", params: {rating: 4}}.", + "Run any app command by id with JSON params (see command_list for ids and parameter docs), e.g. {id: \"photo.rate\", params: {rating: 4}}.", json!({"id": {"type": "string", "description": "Command id"}, "params": {"type": "object", "description": "Command parameters", "additionalProperties": true}}), &["id"], ), @@ -133,7 +133,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { tool( "run_command", "Run command", - "Run any LightCraft command by id with JSON params (see list_commands for ids and parameter docs), e.g. {command: \"photo.rate\", params: {rating: 4}}.", + "Run any app command by id with JSON params (see list_commands for ids and parameter docs), e.g. {command: \"photo.rate\", params: {rating: 4}}.", json!({"command": {"type": "string"}, "params": {"type": "object", "description": "Command parameters", "additionalProperties": true}}), &["command"], ), @@ -271,7 +271,7 @@ pub fn helper_tools(has_ui: bool) -> Vec { tool( "screenshot", "Screenshot", - "Capture the LightCraft window (after pending renders finish) and return it as an image.", + "Capture the app window (after pending renders finish) and return it as an image.", json!({"maxSize": {"type": "integer", "description": "Downscale so the long edge is at most this (default 1600)"}, "format": {"type": "string", "enum": ["png", "jpeg"]}, "path": {"type": "string", "description": "Also save the screenshot here"}}), &[], ), diff --git a/crates/mcp/tests/e2e.rs b/crates/mcp/tests/e2e.rs index 3a50bf43d8..6406636652 100644 --- a/crates/mcp/tests/e2e.rs +++ b/crates/mcp/tests/e2e.rs @@ -1,4 +1,4 @@ -//! M0.9 acceptance: drive LightCraft over MCP (stdio framing), set exposure and render an image. +//! M0.9 acceptance: drive the app over MCP (stdio framing), set exposure and render an image. //! Runs against the headless backend directly and through the TCP control-channel transport //! (`Remote`) to a stand-in control server. @@ -43,7 +43,7 @@ fn exposure_roundtrip(server: &mut Server) { ], ); assert_eq!(replies.len(), 3, "notifications get no reply"); - assert_eq!(replies[0]["result"]["serverInfo"]["name"], "lightcraft"); + assert_eq!(replies[0]["result"]["serverInfo"]["name"], dac_brand::MCP_SERVER); let tools: Vec<&str> = replies[1]["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect(); assert!(tools.contains(&"set_develop") && tools.contains(&"render_photo") && tools.contains(&"cmd_develop_set")); let id = replies[2]["result"]["structuredContent"]["photos"][0]["id"].as_u64().expect("a photo"); diff --git a/crates/merge/Cargo.toml b/crates/merge/Cargo.toml index 5c4c875855..ff4ef157b5 100644 --- a/crates/merge/Cargo.toml +++ b/crates/merge/Cargo.toml @@ -3,12 +3,13 @@ name = "dac-merge" version.workspace = true edition.workspace = true license.workspace = true -description = "LightCraft Photo Merge: HDR merge (align, deghost) and panorama stitching (L3)" +description = "Photo Merge: HDR merge (align, deghost) and panorama stitching (L3)" [lints] workspace = true [dependencies] +dac-brand = { workspace = true } dac-geom = { workspace = true } dac-color = { workspace = true } dac-raster = { workspace = true } diff --git a/crates/merge/src/lib.rs b/crates/merge/src/lib.rs index edfd193484..5baee7c844 100644 --- a/crates/merge/src/lib.rs +++ b/crates/merge/src/lib.rs @@ -1,4 +1,4 @@ -//! LightCraft Photo Merge: HDR merge and panorama stitching (layer L3, no UI dependencies). +//! Photo Merge: HDR merge and panorama stitching (layer L3, no UI dependencies). //! //! - [`frame`]: inputs — scene-linear frames from raw files (camera RGB) or standard images. //! - [`hdr`]: exposure brackets → one scene-linear radiance image (auto-align, exposure diff --git a/crates/merge/src/output.rs b/crates/merge/src/output.rs index 16a262953e..0b2ffe3ca8 100644 --- a/crates/merge/src/output.rs +++ b/crates/merge/src/output.rs @@ -1,5 +1,5 @@ //! Merge results as linear DNGs (`PhotometricInterpretation = LinearRaw`, three samples per -//! pixel), written with LightCraft's own TIFF/DNG writer. +//! pixel), written with the app's own TIFF/DNG writer. //! //! - Pixel values are scaled so the brightest pixel sits just below 1.0 (the white level); the //! scale is carried in `BaselineExposure`, so a default rendering looks like the reference frame. diff --git a/crates/merge/src/synth.rs b/crates/merge/src/synth.rs index b3ee789ddc..b7032b3abb 100644 --- a/crates/merge/src/synth.rs +++ b/crates/merge/src/synth.rs @@ -41,7 +41,7 @@ pub fn bracket_dngs(w: usize, h: usize, evs: &[f64]) -> Result>> { q.map(|v| (v * k).clamp(0.0, 1.0)) }); let meta = Metadata { - make: Some("LightCraft".into()), + make: Some(dac_brand::DISPLAY_NAME.into()), model: Some("Synthetic Bracket".into()), exposure_time: Some(2f64.powf(*ev) / 125.0), f_number: Some(8.0), diff --git a/crates/meta/Cargo.toml b/crates/meta/Cargo.toml index beb3618030..668bcb8345 100644 --- a/crates/meta/Cargo.toml +++ b/crates/meta/Cargo.toml @@ -3,12 +3,13 @@ name = "dac-meta" version.workspace = true edition.workspace = true license.workspace = true -description = "LightCraft metadata: EXIF, XMP, IPTC (L1)" +description = "Metadata: EXIF, XMP, IPTC (L1)" [lints] workspace = true [dependencies] +dac-brand = { workspace = true } dac-tiff = { workspace = true } dac-geom = { workspace = true } serde = { workspace = true } diff --git a/crates/meta/README.md b/crates/meta/README.md index 0322f7dd93..4c9ed32995 100644 --- a/crates/meta/README.md +++ b/crates/meta/README.md @@ -10,7 +10,7 @@ Photo metadata: EXIF, XMP (read + write), IPTC-IIM, and the containers that carr | `from_tiff(&Tiff) -> Metadata` | Same, from an already parsed stream (used by `dac-raw`). | | `embedded(&[u8]) -> Embedded` | Sniffs JPEG / PNG / WebP / TIFF and returns the Exif, XMP (+ extended XMP), ICC and IPTC blocks. `jpeg_segments`, `png_chunks`, `webp_chunks` are the per-container walkers. | | `parse_xmp(&str) -> Result` | Metadata + the opaque `lc:settings` JSON + every property (`prefix:name`, structs flattened as `a:b/c:d`). | -| `write_xmp(&Metadata, Option<&str>) -> String` | Complete `` packet; the second argument is LightCraft's full develop settings JSON. | +| `write_xmp(&Metadata, Option<&str>) -> String` | Complete `` packet; the second argument is the app's full develop settings JSON. | | `parse_iptc(&[u8]) -> Metadata` | IIM record 2: title (2:5), keywords (2:25), by-line (2:80), copyright (2:116), caption (2:120), date/time created (2:55/2:60). | | `extract(&[u8]) -> Metadata` | Everything for a whole file. Precedence: EXIF → IPTC fills gaps → XMP overrides user fields (rating, label, title, caption, artist, copyright, keywords, GPS) and fills the rest. | diff --git a/crates/meta/src/cr3.rs b/crates/meta/src/cr3.rs index 50f12a1b1c..103e2bd887 100644 --- a/crates/meta/src/cr3.rs +++ b/crates/meta/src/cr3.rs @@ -164,7 +164,7 @@ impl Cr3Track { } } -/// The parts of a CR3 file LightCraft reads. +/// The parts of a CR3 file the app reads. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Cr3<'a> { /// `CMT1`…`CMT4` (index 0…3). diff --git a/crates/meta/src/legacy.rs b/crates/meta/src/legacy.rs new file mode 100644 index 0000000000..fdcece2976 --- /dev/null +++ b/crates/meta/src/legacy.rs @@ -0,0 +1,5 @@ +//! Format identifiers inherited from the project's previous name. They are written into users' +//! XMP sidecars, so they never change: renaming them would orphan every existing develop setting. + +/// The develop-settings XMP namespace URI (prefix `lc`), as found in existing sidecars. +pub const LC_NS: &str = "http://ns.lightcraft.app/lc/1.0/"; diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 3e31fa7bf5..19b2b051f1 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -1,4 +1,4 @@ -//! Photo metadata for LightCraft: EXIF (TIFF/raw files, JPEG APP1, PNG `eXIf`, WebP `EXIF`), XMP packets +//! Photo metadata: EXIF (TIFF/raw files, JPEG APP1, PNG `eXIf`, WebP `EXIF`), XMP packets //! (read + write, standard namespaces plus our own `lc:` namespace) and basic IPTC-IIM. //! //! Entry points: @@ -17,6 +17,7 @@ mod datetime; mod exif; mod gpx; mod iptc; +mod legacy; pub mod tags; mod xmp; mod xmp_merge; @@ -51,7 +52,7 @@ pub struct Flash { } /// A named region of interest on a photo (MWG Region Guidelines, `mwg-rs:Regions`): most often a face, -/// drawn by Lightroom, digiKam, Picasa or similar tools. Read-only for now — LightCraft does not write +/// drawn by Lightroom, digiKam, Picasa or similar tools. Read-only for now — the app does not write /// regions yet (see `docs/xmp-interop.md`). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Region { @@ -75,7 +76,7 @@ pub enum RegionKind { Other(String), } -/// Everything LightCraft shows or searches about a photo. All fields are optional; unknown = `None`/empty. +/// Everything the app shows or searches about a photo. All fields are optional; unknown = `None`/empty. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Metadata { // camera diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index 80c66e8dd4..c95a8e8f3e 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -6,7 +6,7 @@ //! a packet that binds `http://purl.org/dc/elements/1.1/` to `foo:` still yields `dc:title`. //! //! Writing emits the interchange subset in standard namespaces (dc, xmp, photoshop, exif, exifEX, tiff, lr) and -//! LightCraft's full develop settings as an opaque JSON string in `lc:settings`. +//! The app's full develop settings as an opaque JSON string in `lc:settings`. use crate::{DateTime, Flash, Gps, Metadata, Orientation, Region, RegionKind, parse_number}; use dac_geom::{Point, Rect}; @@ -14,8 +14,8 @@ use quick_xml::escape::{escape, partial_escape}; use quick_xml::events::Event; use std::collections::BTreeMap; -/// LightCraft's XMP namespace URI (prefix `lc`). -pub const LC_NS: &str = "http://ns.lightcraft.app/lc/1.0/"; +/// The app's XMP namespace URI (prefix `lc`). +pub use crate::legacy::LC_NS; const NAMESPACES: &[(&str, &str)] = &[ ("x", "adobe:ns:meta/"), @@ -617,7 +617,9 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String { let mut x = String::new(); x.push_str("\n"); - x.push_str("\n"); + x.push_str("\n"); x.push_str(" \n"); x.push_str(" ".into()), serial_number: Some("SN1".into()), - software: Some("LightCraft".into()), + software: Some(dac_brand::DISPLAY_NAME.into()), lens_make: Some("L".into()), lens_model: Some("24-70mm".into()), lens_serial_number: Some("LS".into()), diff --git a/crates/meta/src/xmp_merge.rs b/crates/meta/src/xmp_merge.rs index 6cbb6bdfc8..2ce08ba791 100644 --- a/crates/meta/src/xmp_merge.rs +++ b/crates/meta/src/xmp_merge.rs @@ -335,7 +335,7 @@ mod tests { fn fresh(rating: i8, keywords: &[&str]) -> String { let m = crate::Metadata { - software: Some("LightCraft".into()), + software: Some(dac_brand::DISPLAY_NAME.into()), rating: Some(rating), keywords: keywords.iter().map(|k| k.to_string()).collect(), ..Default::default() diff --git a/crates/pipeline/Cargo.toml b/crates/pipeline/Cargo.toml index 1dd3328059..dde6716ae9 100644 --- a/crates/pipeline/Cargo.toml +++ b/crates/pipeline/Cargo.toml @@ -3,12 +3,13 @@ name = "dac-pipeline" version.workspace = true edition.workspace = true license.workspace = true -description = "LightCraft develop pipeline: scene-referred, float, resolution independent (CPU reference) (L2)" +description = "Develop pipeline: scene-referred, float, resolution independent (CPU reference) (L2)" [lints] workspace = true [dependencies] +dac-brand = { workspace = true } dac-geom = { workspace = true } dac-color = { workspace = true } dac-raster = { workspace = true } diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index dfbea32a2d..00b2ccae2f 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -1,4 +1,4 @@ -//! The LightCraft develop pipeline (CPU reference implementation). +//! The develop pipeline (CPU reference implementation). //! //! Input: a scene-referred, linear Rec.2020 source image (already EXIF-oriented) at any resolution //! (full size or a proxy), plus [`DevelopSettings`]. Output: a display-encoded sRGB image at the @@ -594,13 +594,13 @@ pub(crate) fn is_bw(s: &DevelopSettings) -> bool { s.treatment == Treatment::Bw || s.profile.id == "lc.mono" || s.profile.id.starts_with("lc.bw.") } -/// `LIGHTCRAFT_PROFILE` is set: print per-stage timings to stderr. +/// `{ENV_PREFIX}_PROFILE` is set: print per-stage timings to stderr. pub fn profiling() -> bool { static ON: std::sync::OnceLock = 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")) } -/// Run `f`, printing its duration under `LIGHTCRAFT_PROFILE`. +/// Run `f`, printing its duration under `{ENV_PREFIX}_PROFILE`. pub(crate) fn timed(what: &str, f: impl FnOnce() -> R) -> R { if !profiling() { return f(); diff --git a/crates/preview/Cargo.toml b/crates/preview/Cargo.toml index d97bb237eb..5148f3574c 100644 --- a/crates/preview/Cargo.toml +++ b/crates/preview/Cargo.toml @@ -3,12 +3,13 @@ name = "dac-preview" version.workspace = true edition.workspace = true license.workspace = true -description = "LightCraft preview cache: content hashing, memory LRU, disk thumbnail cache, prioritized job pool (L3)" +description = "Preview cache: content hashing, memory LRU, disk thumbnail cache, prioritized job pool (L3)" [lints] workspace = true [dependencies] +dac-brand = { workspace = true } dac-raster = { workspace = true } siphasher = { workspace = true } jpeg-encoder = { workspace = true } diff --git a/crates/raster/Cargo.toml b/crates/raster/Cargo.toml index 1132be0829..4484519ce2 100644 --- a/crates/raster/Cargo.toml +++ b/crates/raster/Cargo.toml @@ -3,12 +3,13 @@ name = "dac-raster" version.workspace = true edition.workspace = true license.workspace = true -description = "LightCraft raster (L0)" +description = "Raster (L0)" [lints] workspace = true [dependencies] +dac-brand = { workspace = true } serde = { workspace = true } dac-geom = { workspace = true } dac-color = { workspace = true } diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 59dc4f0f00..90baefa39d 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -1,4 +1,4 @@ -//! Image buffers and basic raster operations for LightCraft. +//! Image buffers and basic raster operations. //! //! - [`Image`]: interleaved, row-major pixels (`Rgb32f = Image<[f32; 3]>`, `Rgba8 = Image<[u8; 4]>`, //! `Plane = Image`). diff --git a/crates/raw/Cargo.toml b/crates/raw/Cargo.toml index 9d5fa73787..9478963a2b 100644 --- a/crates/raw/Cargo.toml +++ b/crates/raw/Cargo.toml @@ -3,12 +3,13 @@ name = "dac-raw" version.workspace = true edition.workspace = true license.workspace = true -description = "LightCraft RAW decoding, demosaicing and DNG colour model (L1)" +description = "RAW decoding, demosaicing and DNG colour model (L1)" [lints] workspace = true [dependencies] +dac-brand = { workspace = true } dac-tiff = { workspace = true } dac-meta = { workspace = true } dac-raster = { workspace = true } diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index ca22a91dce..9f2c0cb0dd 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -117,9 +117,9 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result> { ifd.set(t::MODEL, Value::Ascii(model.clone())); } let unique = format!("{make} {model}").trim().to_string(); - ifd.set(t::UNIQUE_CAMERA_MODEL, Value::Ascii(if unique.is_empty() { "LightCraft".into() } else { unique })); + ifd.set(t::UNIQUE_CAMERA_MODEL, Value::Ascii(if unique.is_empty() { dac_brand::DISPLAY_NAME.into() } else { unique })); ifd.set(t::ORIENTATION, Value::Short(vec![raw.orientation.to_exif()])); - ifd.set(t::SOFTWARE, Value::Ascii("LightCraft".into())); + ifd.set(t::SOFTWARE, Value::Ascii(dac_brand::DISPLAY_NAME.into())); let xmp = opts.xmp.clone().unwrap_or_else(|| dac_meta::write_xmp(&raw.metadata, None)); ifd.set(t::XMP, Value::Byte(xmp.into_bytes())); diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index b778e05bc0..ccfc6597af 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -1,4 +1,4 @@ -//! RAW decoding for LightCraft. +//! RAW decoding. //! //! - [`probe`] recognises raw containers; [`decode`] turns a file into a [`RawImage`] (sensor data + everything //! needed to render it: CFA, black/white levels, active area, default crop, orientation, DNG colour tags, diff --git a/crates/raw/src/vendor/crx.rs b/crates/raw/src/vendor/crx.rs index de74c78169..e45ffae8a3 100644 --- a/crates/raw/src/vendor/crx.rs +++ b/crates/raw/src/vendor/crx.rs @@ -1062,7 +1062,7 @@ mod tests { #[test] fn optional_cc0_samples_match_external_oracle() { - let Some(directory) = std::env::var_os("LIGHTCRAFT_CR3_CORPUS") else { return }; + let Some(directory) = dac_brand::env_os("CR3_CORPUS") else { return }; for (name, expected) in [ ("cr3-canon-r100-raw.cr3", 0xcbe5299ab9c52630u64), ("cr3-canon-m50-raw.cr3", 0x62261f0ba81cfcd2u64), diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index 9b688f6c82..8459de36d3 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -72,7 +72,7 @@ //! Lens distortion (tag `0x0119`, ExifTool name DistortionInfo; ExifTool's names for its words were the only outside //! input, their meaning below is our own measurement). The tag holds 16 little-endian 16-bit words. The camera's //! own correction, which its embedded JPEG shows, is applied as an `OpcodeList3` `WarpRectilinear` (the DNG form -//! LightCraft already applies under "Enable Profile Corrections"): +//! The app already applies under "Enable Profile Corrections"): //! //! - Word 7, low 4 bits: 1 = correction on [0 on the DMC-GH1, whose JPEG matches the uncorrected render]. //! - Word 12: the normalisation radius in pixels, the half diagonal of the active area [2870 for 4592 × 3448 diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index f1da2cc85a..2e6ea5c4e6 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -1,5 +1,5 @@ //! Corpus test over `corpus/raw/**` (git-ignored CC0 samples from raw.pixls.us, fetched with -//! `cargo xtask corpus --download`; `LIGHTCRAFT_CORPUS` overrides the corpus root). Skips cleanly when absent. +//! `cargo xtask corpus --download`; `{ENV_PREFIX}_CORPUS` overrides the corpus root). Skips cleanly when absent. //! //! Every file must be recognised, carry an embedded JPEG preview (optional for DNG, older Panasonic RAW and HEVC-preview CR3), and either decode to a valid image or report //! `Unsupported` for one of the variants we know we don't decode yet. Prints decode times @@ -10,7 +10,7 @@ use std::path::{Path, PathBuf}; use std::time::Instant; 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")) } /// Variants known not to decode yet (see the crate docs): matched against the lower-case file name. diff --git a/crates/raw/tests/cr3_corpus.rs b/crates/raw/tests/cr3_corpus.rs index 15092a38c3..df1c1b27e5 100644 --- a/crates/raw/tests/cr3_corpus.rs +++ b/crates/raw/tests/cr3_corpus.rs @@ -12,7 +12,7 @@ use dac_raw::{RawData, RawFormat, decode, probe_info}; use std::path::{Path, PathBuf}; fn corpus() -> 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 check(name: &str, hash: u64, sensor: (usize, usize), developed: (usize, usize)) { diff --git a/crates/scenes/Cargo.toml b/crates/scenes/Cargo.toml index 8a06c94c38..cd4ad8abd2 100644 --- a/crates/scenes/Cargo.toml +++ b/crates/scenes/Cargo.toml @@ -3,12 +3,13 @@ name = "dac-scenes" version.workspace = true edition.workspace = true license.workspace = true -description = "Procedurally generated photographic demo scenes (HDR, linear Rec.2020) for LightCraft demos and tests (L1)" +description = "Procedurally generated photographic demo scenes (HDR, linear Rec.2020) for demos and tests (L1)" [lints] workspace = true [dependencies] +dac-brand = { workspace = true } dac-color = { workspace = true } dac-raster = { workspace = true } serde = { workspace = true } diff --git a/crates/segment/Cargo.toml b/crates/segment/Cargo.toml index 632b4e0985..5ab8f4786b 100644 --- a/crates/segment/Cargo.toml +++ b/crates/segment/Cargo.toml @@ -3,12 +3,13 @@ name = "dac-segment" version.workspace = true edition.workspace = true license = "Apache-2.0" -description = "LightCraft segmentation: SAM 3 (Segment Anything with Concepts) inference in pure Rust (L3)" +description = "Segmentation: SAM 3 (Segment Anything with Concepts) inference in pure Rust (L3)" [lints] workspace = true [dependencies] +dac-brand = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/segment/LICENSE-APACHE b/crates/segment/LICENSE-APACHE new file mode 100644 index 0000000000..f433b1a53f --- /dev/null +++ b/crates/segment/LICENSE-APACHE @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/crates/segment/src/bin/lightcraft-sam3-worker.rs b/crates/segment/src/bin/sam3-worker.rs similarity index 78% rename from crates/segment/src/bin/lightcraft-sam3-worker.rs rename to crates/segment/src/bin/sam3-worker.rs index 38821a1038..dc778e3496 100644 --- a/crates/segment/src/bin/lightcraft-sam3-worker.rs +++ b/crates/segment/src/bin/sam3-worker.rs @@ -1,4 +1,4 @@ -//! Serves SAM 3 on this Mac's Metal GPU to one LightCraft editor over a loopback socket (an SSH +//! Serves SAM 3 on this Mac's Metal GPU to one editor over a loopback socket (an SSH //! tunnel); see docs/ai-masks.md → Remote Metal inference. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -6,7 +6,7 @@ #[cfg(not(target_arch = "wasm32"))] fn main() -> Result<(), Box> { let mut args = std::env::args().skip(1); - let dir = args.next().ok_or("usage: lightcraft-sam3-worker MODEL_DIR [127.0.0.1:8793]")?; + let dir = args.next().ok_or("usage: sam3-worker MODEL_DIR [127.0.0.1:8793]")?; let address = args.next().unwrap_or_else(|| "127.0.0.1:8793".into()).parse()?; let device = candle_core::Device::new_metal(0)?; dac_segment::remote::serve(address, std::path::Path::new(&dir), device)?; diff --git a/crates/segment/src/clip.rs b/crates/segment/src/clip.rs index b69c148a4f..47793755f2 100644 --- a/crates/segment/src/clip.rs +++ b/crates/segment/src/clip.rs @@ -1,7 +1,7 @@ //! The CLIP text encoder (24 layers, 1024 wide, causal) that turns a prompt into the token //! features SAM 3's detector attends to. //! -//! Modified work (Apache License 2.0, §4(b)): ported by the LightCraft contributors in 2026 from +//! Modified work (Apache License 2.0, §4(b)): ported by the project contributors in 2026 from //! the Python/PyTorch SAM 3 code of Hugging Face Transformers (`models/sam3/modeling_sam3.py`, CLIP text model), Copyright The HuggingFace //! Team and Meta Platforms, Inc.; translated to Rust on candle and restructured. See NOTICE. diff --git a/crates/segment/src/detector.rs b/crates/segment/src/detector.rs index 7c4eccbd33..065e910112 100644 --- a/crates/segment/src/detector.rs +++ b/crates/segment/src/detector.rs @@ -2,7 +2,7 @@ //! features by a DETR encoder, 200 object queries refined by a DETR decoder (with box //! relative-position bias and a presence token), per-query masks from a pixel decoder. //! -//! Modified work (Apache License 2.0, §4(b)): ported by the LightCraft contributors in 2026 from +//! Modified work (Apache License 2.0, §4(b)): ported by the project contributors in 2026 from //! the Python/PyTorch SAM 3 code of Hugging Face Transformers (`models/sam3/modeling_sam3.py`), Copyright The HuggingFace //! Team and Meta Platforms, Inc.; translated to Rust on candle and restructured. See NOTICE. diff --git a/crates/segment/src/fetch/mod.rs b/crates/segment/src/fetch/mod.rs index d6f7f3c178..13e4936a7e 100644 --- a/crates/segment/src/fetch/mod.rs +++ b/crates/segment/src/fetch/mod.rs @@ -2,7 +2,7 @@ //! resuming, checks, the pure-Rust HTTPS client) is the `dac-fetch` crate; this module //! says what SAM 3 needs and where it may come from. //! -//! The weights are never part of LightCraft (SAM License, see docs/ai-masks.md); where they are +//! The weights are never part of the app (SAM License, see docs/ai-masks.md); where they are //! downloaded from is configured by [`DEFAULT_MIRRORS`] and the user's own list. pub use dac_fetch::*; @@ -15,7 +15,7 @@ use std::path::Path; /// tokenizer files are small text files without a pinned hash yet: they are only parsed by the /// tokenizer (never executed) and capped in size. // TODO(maintainer): pin `vocab.json` and `merges.txt` (size + SHA-256) when the files are -// uploaded to LightCraft's CDN. +// uploaded to the app's CDN. pub const SAM3_FILES: &[FileSpec] = &[ FileSpec { name: "vocab.json", size: None, sha256: None, max: 16 << 20 }, FileSpec { name: "merges.txt", size: None, sha256: None, max: 16 << 20 }, @@ -30,20 +30,20 @@ pub const SAM3_FILES: &[FileSpec] = &[ /// Size of the official `model.safetensors` (3.44 GB). pub const SAM3_WEIGHTS_SIZE: u64 = 3_439_938_512; -/// Where LightCraft downloads the model from, in order: each is a base URL, and file `f` is at -/// `/`. **Empty for now**: LightCraft has no download location of its own yet, so the -/// in-app download needs the user's list (`LIGHTCRAFT_SAM3_MIRRORS` or the mirrors file, see +/// Where the app downloads the model from, in order: each is a base URL, and file `f` is at +/// `/`. **Empty for now**: the app has no download location of its own yet, so the +/// in-app download needs the user's list (`{ENV_PREFIX}_SAM3_MIRRORS` or the mirrors file, see /// [`mirrors`] and docs/ai-masks.md) until this is filled in. /// /// Hugging Face's `facebook/sam3` can't be a default: it is gated (each user must accept the /// SAM License there and download with their own token). -// TODO(maintainer): add LightCraft's CDN locations (https, primary first), e.g. +// TODO(maintainer): add the app's CDN locations (https, primary first), e.g. // "https:///models/sam3/" — the files there must match `SAM3_FILES`. pub const DEFAULT_MIRRORS: &[&str] = &[]; -/// Environment variable with extra mirrors (base URLs separated by commas, spaces or newlines), +/// Environment variable (name after the app prefix, read with `dac_brand::env`) with extra mirrors (base URLs separated by commas, spaces or newlines), /// tried before the defaults. -pub const MIRRORS_ENV: &str = "LIGHTCRAFT_SAM3_MIRRORS"; +pub const MIRRORS_ENV: &str = "SAM3_MIRRORS"; /// The mirrors to try for SAM 3, in order: the environment variable's, then the mirrors file's /// (one base URL per line, `#` comments), then [`DEFAULT_MIRRORS`]. @@ -56,13 +56,14 @@ pub fn options() -> Options { Options { token_env: Some(TOKEN_ENV), ..Options::default() } } -/// Environment variable with a bearer token for the SAM 3 mirror's own host (a gated Hugging Face repository). -pub const TOKEN_ENV: &str = "LIGHTCRAFT_SAM3_TOKEN"; +/// Environment variable (name after the app prefix, read with `dac_brand::env`) with a bearer token for the SAM 3 mirror's own host (a gated Hugging Face repository). +pub const TOKEN_ENV: &str = "SAM3_TOKEN"; /// What to tell the user when there is no mirror for the SAM 3 model. pub fn no_mirrors_message() -> String { format!( - "no download location is configured for the SAM 3 model in this build (set {MIRRORS_ENV}, or put the files in the model folder yourself; see docs/ai-masks.md)" + "no download location is configured for the SAM 3 model in this build (set {}, or put the files in the model folder yourself; see docs/ai-masks.md)", + dac_brand::env_var(MIRRORS_ENV) ) } diff --git a/crates/segment/src/lib.rs b/crates/segment/src/lib.rs index 6d488a919a..d650832c4b 100644 --- a/crates/segment/src/lib.rs +++ b/crates/segment/src/lib.rs @@ -9,18 +9,18 @@ //! ([`Sam3::encode`]); each click or prompt then reuses the [`Encoded`] features. //! //! On macOS the model runs on the GPU through Metal; elsewhere (for now) on the CPU. The -//! weights are not part of LightCraft: they are the `facebook/sam3` checkpoint (SAM License), +//! weights are not part of the app: they are the `facebook/sam3` checkpoint (SAM License), //! read from a directory the user downloads them to ([`Sam3::load`]). //! //! Ported from the Hugging Face `transformers` implementation (Apache-2.0); see `NOTICE`. //! This crate is therefore licensed under the Apache License 2.0 only (not the MIT option of -//! the rest of LightCraft). No UI dependencies (L3). +//! the rest of the app). No UI dependencies (L3). //! //! [`fetch`] downloads the model, only when the user asks for it (from configurable mirrors, -//! verified before use). Nothing in LightCraft requires the model: without it, AI masks report +//! verified before use). Nothing in the app requires the model: without it, AI masks report //! that it isn't installed and everything else works. //! -//! Modified work (Apache License 2.0, §4(b)): ported by the LightCraft contributors in 2026 from +//! Modified work (Apache License 2.0, §4(b)): ported by the project contributors in 2026 from //! the Python/PyTorch SAM 3 code of Hugging Face Transformers (`src/transformers/models/sam3` and `sam3_tracker`), Copyright The HuggingFace //! Team and Meta Platforms, Inc.; translated to Rust on candle and restructured. See NOTICE. #![forbid(unsafe_code)] diff --git a/crates/segment/src/mask.rs b/crates/segment/src/mask.rs index af349a2625..3d758c0468 100644 --- a/crates/segment/src/mask.rs +++ b/crates/segment/src/mask.rs @@ -1,6 +1,6 @@ //! Image preprocessing and mask post-processing (CPU, no model needed). //! -//! Modified work (Apache License 2.0, §4(b)): ported by the LightCraft contributors in 2026 from +//! Modified work (Apache License 2.0, §4(b)): ported by the project contributors in 2026 from //! the Python/PyTorch SAM 3 code of Hugging Face Transformers (`models/sam3/image_processing_sam3*.py`), Copyright The HuggingFace //! Team and Meta Platforms, Inc.; translated to Rust on candle and restructured. See NOTICE. diff --git a/crates/segment/src/neck.rs b/crates/segment/src/neck.rs index c4f716dd6f..7d7ef1bed0 100644 --- a/crates/segment/src/neck.rs +++ b/crates/segment/src/neck.rs @@ -1,7 +1,7 @@ //! The feature pyramid (`Sam3VisionNeck`): the backbone's 72 × 72 map at four scales //! (288, 144, 72 and 36 per side), each projected to 256 channels. //! -//! Modified work (Apache License 2.0, §4(b)): ported by the LightCraft contributors in 2026 from +//! Modified work (Apache License 2.0, §4(b)): ported by the project contributors in 2026 from //! the Python/PyTorch SAM 3 code of Hugging Face Transformers (`models/sam3/modeling_sam3.py`), Copyright The HuggingFace //! Team and Meta Platforms, Inc.; translated to Rust on candle and restructured. See NOTICE. diff --git a/crates/segment/src/nn.rs b/crates/segment/src/nn.rs index 540196e5d8..b5e9a7e9a1 100644 --- a/crates/segment/src/nn.rs +++ b/crates/segment/src/nn.rs @@ -1,6 +1,6 @@ //! Shared building blocks: attention, MLPs, layer norms, sine position encodings. //! -//! Modified work (Apache License 2.0, §4(b)): ported by the LightCraft contributors in 2026 from +//! Modified work (Apache License 2.0, §4(b)): ported by the project contributors in 2026 from //! the Python/PyTorch SAM 3 code of Hugging Face Transformers (`models/sam3/modeling_sam3.py`, `models/sam3_tracker`), Copyright The HuggingFace //! Team and Meta Platforms, Inc.; translated to Rust on candle and restructured. See NOTICE. diff --git a/crates/segment/src/remote.rs b/crates/segment/src/remote.rs index cdee096fe2..7263f97255 100644 --- a/crates/segment/src/remote.rs +++ b/crates/segment/src/remote.rs @@ -10,7 +10,8 @@ use serde::{Deserialize, Serialize}; const CONTROL_LIMIT: usize = 16 * 1024; const RESPONSE_LIMIT: usize = 4 * 1024 * 1024; const TIMEOUT: Duration = Duration::from_secs(9 * 60); -pub const ENV: &str = "LIGHTCRAFT_SAM3_REMOTE"; +/// Environment variable (name after the app prefix, see `dac_brand::env`) with the remote worker address. +pub const ENV: &str = "SAM3_REMOTE"; #[derive(Serialize, Deserialize)] pub enum Operation { @@ -52,7 +53,7 @@ impl Request { } pub fn configured() -> Option { - std::env::var(ENV).ok().filter(|v| !v.trim().is_empty()) + dac_brand::env(ENV).filter(|v| !v.trim().is_empty()) } pub fn read_frame(reader: &mut impl Read, limit: usize) -> io::Result> { diff --git a/crates/segment/src/tokenizer.rs b/crates/segment/src/tokenizer.rs index 0819aa48f8..73d6ce6f7b 100644 --- a/crates/segment/src/tokenizer.rs +++ b/crates/segment/src/tokenizer.rs @@ -1,7 +1,7 @@ //! The CLIP byte-pair-encoding tokenizer (lower-cased, `` word ends), padded to the //! 32-token context SAM 3's text encoder takes. //! -//! Modified work (Apache License 2.0, §4(b)): ported by the LightCraft contributors in 2026 from +//! Modified work (Apache License 2.0, §4(b)): ported by the project contributors in 2026 from //! the Python/PyTorch SAM 3 code of Hugging Face Transformers (the CLIP tokenizer used by `models/sam3`), Copyright The HuggingFace //! Team and Meta Platforms, Inc.; translated to Rust on candle and restructured. See NOTICE. diff --git a/crates/segment/src/tracker.rs b/crates/segment/src/tracker.rs index d51219c806..1412cb9bb4 100644 --- a/crates/segment/src/tracker.rs +++ b/crates/segment/src/tracker.rs @@ -1,7 +1,7 @@ //! Point prompts (SAM 3's interactive "tracker" head, SAM 2 style): a prompt encoder for //! positive/negative clicks and a two-way-transformer mask decoder over the image features. //! -//! Modified work (Apache License 2.0, §4(b)): ported by the LightCraft contributors in 2026 from +//! Modified work (Apache License 2.0, §4(b)): ported by the project contributors in 2026 from //! the Python/PyTorch SAM 3 code of Hugging Face Transformers (`models/sam3_tracker/modeling_sam3_tracker.py`), Copyright The HuggingFace //! Team and Meta Platforms, Inc.; translated to Rust on candle and restructured. See NOTICE. diff --git a/crates/segment/src/vit.rs b/crates/segment/src/vit.rs index 16b09a729b..e2d39a6d09 100644 --- a/crates/segment/src/vit.rs +++ b/crates/segment/src/vit.rs @@ -1,7 +1,7 @@ //! The SAM 3 vision backbone: a 32-layer ViT (Perception Encoder) with 2-D axial rotary //! position encoding, windowed attention (24 × 24) in most layers and global attention in four. //! -//! Modified work (Apache License 2.0, §4(b)): ported by the LightCraft contributors in 2026 from +//! Modified work (Apache License 2.0, §4(b)): ported by the project contributors in 2026 from //! the Python/PyTorch SAM 3 code of Hugging Face Transformers (`models/sam3/modeling_sam3.py`), Copyright The HuggingFace //! Team and Meta Platforms, Inc.; translated to Rust on candle and restructured. See NOTICE. diff --git a/crates/segment/tests/reference.rs b/crates/segment/tests/reference.rs index ac68382187..23b0817452 100644 --- a/crates/segment/tests/reference.rs +++ b/crates/segment/tests/reference.rs @@ -1,7 +1,7 @@ //! The port against the reference implementation (Hugging Face `transformers`, fp32). //! //! Needs the checkpoint and a reference file made by `tools/sam3_reference.py`: -//! `LIGHTCRAFT_SAM3_DIR= LIGHTCRAFT_SAM3_REF= cargo test -p dac-segment --release -- --nocapture`. +//! `{ENV_PREFIX}_SAM3_DIR= {ENV_PREFIX}_SAM3_REF= cargo test -p dac-segment --release -- --nocapture`. //! Skipped (passes) when either is missing. use std::collections::HashMap; @@ -11,8 +11,8 @@ use candle_core::{Device, IndexOp, Tensor}; use dac_segment::{Click, Sam3, tokenizer::Tokenizer}; fn setup() -> Option<(PathBuf, HashMap)> { - let dir = PathBuf::from(std::env::var_os("LIGHTCRAFT_SAM3_DIR")?); - let reference = PathBuf::from(std::env::var_os("LIGHTCRAFT_SAM3_REF")?); + let dir = PathBuf::from(dac_brand::env_os("SAM3_DIR")?); + let reference = PathBuf::from(dac_brand::env_os("SAM3_REF")?); let r = candle_core::safetensors::load(&reference, &Device::Cpu).ok()?; Some((dir, r)) } @@ -38,7 +38,7 @@ fn mask_disagreement(a: &[f32], b: &[f32]) -> f32 { #[test] fn matches_reference() { let Some((dir, r)) = setup() else { - eprintln!("skipped: set LIGHTCRAFT_SAM3_DIR and LIGHTCRAFT_SAM3_REF"); + eprintln!("skipped: set {} and {}", dac_brand::env_var("SAM3_DIR"), dac_brand::env_var("SAM3_REF")); return; }; let mut model = Sam3::load(&dir).unwrap(); diff --git a/crates/sysmem/Cargo.toml b/crates/sysmem/Cargo.toml index 31f28f7ed8..f79779b6d4 100644 --- a/crates/sysmem/Cargo.toml +++ b/crates/sysmem/Cargo.toml @@ -3,7 +3,7 @@ name = "dac-sysmem" version.workspace = true edition.workspace = true license.workspace = true -description = "LightCraft sysmem (L0): returns freed allocator memory to the OS. The workspace's only crate allowed `unsafe` (one FFI call)." +description = "Sysmem (L0): returns freed allocator memory to the OS. The workspace's only crate allowed `unsafe` (one FFI call)." # Opts out of the workspace lints only to keep `unsafe_code = "deny"` overridable for the single FFI # module below; every other crate root has `#![forbid(unsafe_code)]`. See AGENTS.md and craftrules @@ -16,3 +16,4 @@ dbg_macro = "warn" todo = "warn" [dependencies] +dac-brand = { workspace = true } diff --git a/crates/sysmem/src/lib.rs b/crates/sysmem/src/lib.rs index 2961f537b5..851d52f958 100644 --- a/crates/sysmem/src/lib.rs +++ b/crates/sysmem/src/lib.rs @@ -6,7 +6,7 @@ //! everything ever decoded. `malloc_zone_pressure_relief` (libSystem) returns those pages now. //! Elsewhere (glibc, Windows) blocks this large are unmapped when freed: nothing to do. //! -//! This is LightCraft's only crate allowed `unsafe` (one FFI call, in `macos`). Its API is safe, +//! This is the workspace's only crate allowed `unsafe` (one FFI call, in `macos`). Its API is safe, //! and on failure or on other platforms it simply releases nothing. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] diff --git a/crates/tiff/Cargo.toml b/crates/tiff/Cargo.toml index c0d80560ca..b5ef448b45 100644 --- a/crates/tiff/Cargo.toml +++ b/crates/tiff/Cargo.toml @@ -3,12 +3,13 @@ name = "dac-tiff" version.workspace = true edition.workspace = true license.workspace = true -description = "LightCraft TIFF/IFD reader and writer (L0)" +description = "TIFF/IFD reader and writer (L0)" [lints] workspace = true [dependencies] +dac-brand = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } diff --git a/crates/tiff/src/lib.rs b/crates/tiff/src/lib.rs index bafa3eb5e7..a0976854de 100644 --- a/crates/tiff/src/lib.rs +++ b/crates/tiff/src/lib.rs @@ -1,4 +1,4 @@ -//! TIFF / IFD reading and writing for LightCraft. +//! TIFF / IFD reading and writing. //! //! - [`Tiff::parse`] reads classic TIFF and BigTIFF in both byte orders, following the IFD chain, //! `SubIFDs`, and the Exif / GPS / Interoperability IFD pointers. All field types of TIFF 6.0, diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 0f7364ab47..0534fbb699 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -3,12 +3,13 @@ name = "dac-ui-egui" version.workspace = true edition.workspace = true license.workspace = true -description = "LightCraft egui frontend (Lightroom-style UI); swappable — nothing below depends on it (L5)" +description = "Egui frontend (Lightroom-style UI); swappable — nothing below depends on it (L5)" [lints] workspace = true [dependencies] +dac-brand = { workspace = true } dac-geom = { workspace = true } dac-meta = { workspace = true } dac-color = { workspace = true } @@ -30,5 +31,6 @@ dac-codecs = { workspace = true } dac-faces = { workspace = true } [build-dependencies] +dac-brand = { workspace = true } serde_json = { workspace = true } toml = "1" diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 0d0f9a3a41..2379df48e6 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -88,14 +88,15 @@ fn generate() -> Result> { )); for english in all { let en = serde_json::to_string(english)?; + let en_filled = serde_json::to_string(&brand_fill(english))?; source.push_str(&format!(" ({en} $(, $($args:tt)*)?) => {{\n match $crate::i18n::language() {{\n")); for (_, variant, messages) in &catalogs { if let Some(translated) = messages.get(english) { - let translated = serde_json::to_string(translated)?; + let translated = serde_json::to_string(&brand_fill(translated))?; source.push_str(&format!(" $crate::i18n::Locale::{variant} => format!({translated} $(, $($args)*)?),\n")); } } - source.push_str(&format!(" _ => format!({en} $(, $($args)*)?),\n }}\n }};\n")); + source.push_str(&format!(" _ => format!({en_filled} $(, $($args)*)?),\n }}\n }};\n")); } source.push_str(concat!( "}\n", @@ -107,6 +108,13 @@ fn generate() -> Result> { Ok(source) } +/// The brand placeholders (`{app}`, `{cli}`, `{env}`) are filled in here, at build time: they are not +/// `format!` arguments. The name is escaped for `format!` (`{` → `{{`). +fn brand_fill(text: &str) -> String { + let esc = |s: &str| s.replace('{', "{{").replace('}', "}}"); + text.replace("{app}", &esc(dac_brand::DISPLAY_NAME)).replace("{cli}", &esc(dac_brand::CLI_BINARY)).replace("{env}", &esc(dac_brand::ENV_PREFIX)) +} + /// The About window's credits tables. mod credits { use std::fmt::Write as _; diff --git a/crates/ui-egui/examples/grid_bench.rs b/crates/ui-egui/examples/grid_bench.rs index 8c64503466..be54649355 100644 --- a/crates/ui-egui/examples/grid_bench.rs +++ b/crates/ui-egui/examples/grid_bench.rs @@ -64,7 +64,7 @@ fn allocs() -> (u64, u64) { (ALLOCS.with(Cell::get), BYTES.with(Cell::get)) } -const ROOT: &str = "/lightcraft-grid-bench/root"; +const ROOT: &str = "/app-grid-bench/root"; fn library(n: usize, local: bool) -> Session { let mut s = Session::new(); diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 1267239495..0f615b4f8b 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -67,8 +67,8 @@ "Imported {} photo{}": "{} Foto{} importiert", "Insert at the cursor (also written {})": "An Cursorposition einfügen (auch als {} geschrieben)", "Label Set: {name}": "Farbmarkierungssatz: {name}", - "LightCraft could not open its window: {e}": "LightCraft konnte sein Fenster nicht öffnen: {e}", - "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft konnte sein Fenster nicht öffnen: {e}\n\nEinzelheiten stehen in der Protokolldatei: {path}", + "{app} could not open its window: {e}": "{app} konnte sein Fenster nicht öffnen: {e}", + "{app} could not open its window: {e}\n\nThe log file has the details: {path}": "{app} konnte sein Fenster nicht öffnen: {e}\n\nEinzelheiten stehen in der Protokolldatei: {path}", "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Lightroom-Katalog geprüft: {photos} Fotos, {collections} Sammlungen, {missing} fehlen", "Lightroom import complete: {imported} photos added": "Lightroom-Import abgeschlossen: {imported} Fotos hinzugefügt", "Lightroom import complete: {imported} photos added, {failed} failed": "Lightroom-Import abgeschlossen: {imported} Fotos hinzugefügt, {failed} fehlgeschlagen", @@ -145,8 +145,8 @@ "{name} opened for editing; it is stacked with the original": "{name} zum Bearbeiten geöffnet; mit dem Original gestapelt", "{name} — double-click to reset it": "{name} — zum Zurücksetzen doppelklicken", "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Nicht auf den Datenträger geschriebene Änderungen: {n}{0:.0}: {e}\nWenn du jetzt beendest, gehen diese Änderungen verloren.{1:.0}", - "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Ungespeicherte Änderungen: {n}{0:.0} im Arbeitsspeicher, aber nicht auf dem Datenträger: {e}\nLightCraft versucht es automatisch erneut; beim Beenden gingen diese Änderungen verloren.{1:.0}", - "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Änderungen nur im Arbeitsspeicher gespeichert: {n}{:.0}: {e} — LightCraft versucht es erneut", + "{n} change{} saved in memory but not written to disk: {e}\n{app} retries automatically; quitting now would lose {}.": "Ungespeicherte Änderungen: {n}{0:.0} im Arbeitsspeicher, aber nicht auf dem Datenträger: {e}\n{app} versucht es automatisch erneut; beim Beenden gingen diese Änderungen verloren.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e} — {app} will retry": "Änderungen nur im Arbeitsspeicher gespeichert: {n}{:.0}: {e} — {app} versucht es erneut", "{n} found · {sel} selected": "{n} gefunden · {sel} ausgewählt", "{n} photos": "Fotos: {n}", "{n} photos in {} are hidden by the filters above": "Ausgeblendete Fotos in {}: {n} (durch die obigen Filter)", @@ -181,14 +181,14 @@ "· {dups} already in the library": "· {dups} bereits in der Bibliothek", "· {n} photo{}": "· {n} Foto{}", "… and {more} more": "… und {more} weitere", - "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft kann diese RAW-Variante noch nicht dekodieren ({}). Du bearbeitest die eingebettete JPEG-Vorschau der Kamera. Sie enthält bereits deren Bildstil (z. B. Monochrom) und Weißabgleich.", + "{app} can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "{app} kann diese RAW-Variante noch nicht dekodieren ({}). Du bearbeitest die eingebettete JPEG-Vorschau der Kamera. Sie enthält bereits deren Bildstil (z. B. Monochrom) und Weißabgleich.", "Filtered: {label}": "Gefiltert: {label}", "{n} similar photo{} · View ▸ Clear Filters to see all": "Ähnliche Fotos: {n}{:.0} · Ansicht → Filter löschen zeigt alle Fotos", "Moved; {} photo(s) relinked": "Verschoben; {} Fotos neu verknüpft", "{} label (click more labels to show any of them)": "Markierung {} (weitere Markierungen auswählen, um alle passenden Fotos zu zeigen)", ", {} star{}": ", Sterne: {}{:.0}", ", {} label": ", Markierung: {}", - "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.", + "Downloads from {host}. Once it has arrived and checked out, {app} installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, {app} installs it, {after}.", "{:.1} MB": "{:.1} MB", "Running on the processor: {}.": "Running on the processor: {}.", "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.", diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index c24b59cc04..72ab14a486 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -3,7 +3,7 @@ " (inverted)": " (umgekehrt)", " · best of its burst": " · bestes Foto der Serie", " · in a burst": " · Teil einer Serie", - "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "„im Arbeitsspeicher gespeichert, aber nicht auf den Datenträger geschrieben“, die obere Leiste zeigt eine Warnung, und LightCraft versucht es erneut,", + "\"saved in memory but not written to disk\", the top bar shows a warning, and {app} keeps retrying until the": "„im Arbeitsspeicher gespeichert, aber nicht auf den Datenträger geschrieben“, die obere Leiste zeigt eine Warnung, und {app} versucht es erneut,", "#106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its": "(Issue #106). Ein DNG wird mit den RAW-Daten verglichen, temporär geschrieben, synchronisiert und zurückgelesen, bevor es seinen", "% of all commits": "% aller Commits", "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "mit `gpuReason` und `gpuFallback`, Hilfe → Systeminformationen und Einstellungen → Leistung erklären, warum die GPU", @@ -23,14 +23,14 @@ "A blank template keeps the original names.": "Eine leere Vorlage behält die Originalnamen bei.", "A change that can't be saved to disk (full or unplugged drive) is no longer silent: the command reports": "Kann eine Änderung wegen eines vollen oder getrennten Laufwerks nicht gespeichert werden, bleibt das nicht mehr unbemerkt: Der Befehl meldet", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Ein Filter blendet die Fotos dieser Ansicht aus: Ändere ihn oder lösche ihn (Ansicht → Filter löschen)", - "A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already": "Eine Bibliothek ist nur in einem Programm gleichzeitig geöffnet (Issue #99). Wenn LightCraft oder `lightcraft-cli` sie bereits geöffnet hat,", + "A library is open in one program at a time (issue #99): opening a library that {app} or `{cli}` already": "Eine Bibliothek ist nur in einem Programm gleichzeitig geöffnet (Issue #99). Wenn {app} oder `{cli}` sie bereits geöffnet hat,", "A one-time download of about": "Ein einmaliger Download von etwa", "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "Ein teilweise fehlgeschlagener Speichervorgang durch einen vollen Datenträger oder eine getrennte Netzwerkfreigabe hinterlässt keinen scheinbar beschädigten Katalog", "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "Ein schlafendes NAS, eine getrennte Netzwerkfreigabe oder ein anlaufendes USB-Laufwerk blockieren das Fenster nicht mehr (Issue #104).", "AI Mask Model Status": "Status des KI-Maskenmodells", "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "KI-Masken mit SAM 3 über Objekt und Beschreiben im Maskenbereich: Ein Objekt zum Auswählen anklicken; ⌥-Klick lässt einen Teil", "About": "Über", - "About LightCraft": "Über LightCraft", + "About {app}": "Über {app}", "Active": "Aktiv", "Activity": "Aktivität", "Actual size": "Originalgröße", @@ -94,8 +94,6 @@ "Aqua Hue": "Aquamarin – Farbton", "Aqua Luminance": "Aquamarin – Luminanz", "Aqua Saturation": "Aquamarin – Sättigung", - "ArtCraft Website": "ArtCraft-Website", - "ArtCraft — more creative apps": "ArtCraft — weitere Kreativ-Apps", "As Shot": "Wie Aufnahme", "As shot": "Wie Aufnahme", "Ascending": "Aufsteigend", @@ -434,7 +432,7 @@ "Duplicate and Invert Mask": "Maske duplizieren und umkehren", "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "Jeder / beginnt eine Ordnerebene; Platzhalter werden pro Foto ausgefüllt (eine Ebene mit fehlenden Metadaten heißt „unknown“).", "Edge": "Rand", - "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside LightCraft in pure Rust": "besitzen eine Randeinstellung und werden beim Hineinzoomen im Hintergrund genauer berechnet. Das Modell läuft in LightCraft in reinem Rust", + "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside {app} in pure Rust": "besitzen eine Randeinstellung und werden beim Hineinzoomen im Hintergrund genauer berechnet. Das Modell läuft in {app} in reinem Rust", "Edit": "Bearbeiten", "Edit (E)": "Bearbeiten (E)", "Edit Capture Time": "Aufnahmezeit bearbeiten", @@ -640,7 +638,7 @@ "Height": "Höhe", "Height (px)": "Höhe (px)", "Help": "Hilfe", - "Hide LightCraft": "LightCraft ausblenden", + "Hide {app}": "{app} ausblenden", "Hide Mask": "Maske ausblenden", "Hide Others": "Andere ausblenden", "Hides this shortcut only; the folder and its photos stay as they are": "Blendet nur diese Verknüpfung aus; der Ordner und seine Fotos bleiben unverändert", @@ -697,7 +695,7 @@ "In library": "In Bibliothek", "In memory — nothing is saved": "Im Arbeitsspeicher — nichts wird gespeichert", "In use": "In Verwendung", - "In-memory session: these settings last until LightCraft quits.": "Sitzung im Arbeitsspeicher: Diese Einstellungen gelten bis zum Beenden von LightCraft.", + "In-memory session: these settings last until {app} quits.": "Sitzung im Arbeitsspeicher: Diese Einstellungen gelten bis zum Beenden von {app}.", "Include subfolders": "Unterordner einbeziehen", "Increase Brush Feather": "Weiche Pinselkante vergrößern", "Increase Brush Size": "Pinselgröße vergrößern", @@ -723,9 +721,6 @@ "JPEG (Large)": "JPEG (groß)", "JPEG (Small)": "JPEG (klein)", "JPEG with the same name (`IMG_0001.CR3` + `IMG_0001.JPG`) no longer share one sidecar: the raw keeps `IMG_0001.xmp`,": "JPEG mit gleichem Namen (`IMG_0001.CR3` + `IMG_0001.JPG`) keine Begleitdatei mehr: RAW verwendet `IMG_0001.xmp`,", - "Join the ArtCraft Discord": "ArtCraft auf Discord beitreten", - "Join the ArtCraft Discord…": "ArtCraft auf Discord beitreten…", - "Join the ArtCraft community on Discord": "Der ArtCraft-Community auf Discord beitreten", "Keep in Local": "In „Lokal“ behalten", "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Wähle ein Laufwerk mit genügend Platz: Smart-Vorschauen benötigen etwa 1 MB pro Foto. Der Miniaturbild-Cache bleibt in der Bibliothek.", "Keyboard Shortcuts": "Tastenkürzel", @@ -763,19 +758,19 @@ "Library saved": "Bibliothek gespeichert", "Licence:": "Lizenz:", "Light": "Licht", - "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "lehnen LightCraft 0.2.0 und ältere Versionen sie ab („unsupported format … v2“).", - "LightCraft Default": "LightCraft-Standard", - "LightCraft Help": "LightCraft-Hilfe", - "LightCraft Preset": "LightCraft-Vorgabe", - "LightCraft Presets": "LightCraft-Vorgaben", - "LightCraft Website": "LightCraft-Website", - "LightCraft could not start": "LightCraft konnte nicht starten", - "LightCraft couldn't find where to keep your library.": "LightCraft konnte keinen Speicherort für deine Bibliothek finden.", - "LightCraft couldn't open the library at": "LightCraft konnte die Bibliothek nicht öffnen unter", - "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft stürzt beim Start auf Windows-PCs mit defektem Vulkan-Treiber nicht mehr ab (Issue #136, etwa bestimmte Intel-UHD-630-Treiber).", - "LightCraft on GitHub": "LightCraft auf GitHub", - "LightCraft website": "LightCraft-Website", - "LightCraft — Second Window": "LightCraft — zweites Fenster", + "{app} 0.2.0 and older refuse it (\"unsupported format … v2\").": "lehnen {app} 0.2.0 und ältere Versionen sie ab („unsupported format … v2“).", + "{app} Default": "{app}-Standard", + "{app} Help": "{app}-Hilfe", + "{app} Preset": "{app}-Vorgabe", + "{app} Presets": "{app}-Vorgaben", + "{app} Website": "{app}-Website", + "{app} could not start": "{app} konnte nicht starten", + "{app} couldn't find where to keep your library.": "{app} konnte keinen Speicherort für deine Bibliothek finden.", + "{app} couldn't open the library at": "{app} konnte die Bibliothek nicht öffnen unter", + "{app} no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "{app} stürzt beim Start auf Windows-PCs mit defektem Vulkan-Treiber nicht mehr ab (Issue #136, etwa bestimmte Intel-UHD-630-Treiber).", + "{app} Source Code": "{app} auf GitHub", + "{app} website": "{app}-Website", + "{app} — Second Window": "{app} — zweites Fenster", "Lightroom Classic Catalog": "Lightroom Classic-Katalog", "Lightroom import cancelled": "Lightroom-Import abgebrochen", "Lightroom inspection cancelled": "Lightroom-Prüfung abgebrochen", @@ -840,7 +835,7 @@ "Merged": "Zusammengefügt", "Merged PRs": "Zusammengeführte PRs", "Merging…": "Wird zusammengefügt…", - "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Es gelten die Bedingungen von Meta, nicht von LightCraft. Mit dem Download akzeptierst du sie.", + "Meta's terms, not {app}'s. Downloading it means accepting them.": "Es gelten die Bedingungen von Meta, nicht von {app}. Mit dem Download akzeptierst du sie.", "Metadata": "Metadaten", "Metadata Preset": "Metadatenvorgabe", "Metadata Presets": "Metadatenvorgaben", @@ -966,7 +961,7 @@ "OK": "OK", "Object": "Objekt", "Object Mask": "Objektmaske", - "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Objekt- und Beschreibungsmasken verwenden SAM 3, das Segmentierungsmodell von Meta. Es ist kein Bestandteil von LightCraft; alle anderen Funktionen arbeiten auch ohne dieses Modell.", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of {app}, and everything else works without it.": "Objekt- und Beschreibungsmasken verwenden SAM 3, das Segmentierungsmodell von Meta. Es ist kein Bestandteil von {app}; alle anderen Funktionen arbeiten auch ohne dieses Modell.", "October 2026": "Oktober 2026", "Off": "Aus", "Offset X": "Versatz X", @@ -1103,7 +1098,7 @@ "Quiet Green": "Ruhiges Grün", "Quit": "Beenden", "Quit Anyway": "Trotzdem beenden", - "Quit LightCraft": "LightCraft beenden", + "Quit {app}": "{app} beenden", "Quit with unsaved changes?": "Mit ungespeicherten Änderungen beenden?", "RAW decoding": "RAW-Decodierung", "Radial": "Radial", @@ -1207,7 +1202,7 @@ "Rename Mask": "Maske umbenennen", "Rename Photos": "Fotos umbenennen", "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "Beim Umbenennen von Fotos wird kein anderes Foto überschrieben, wenn sich nur die Groß-/Kleinschreibung ändert (Issue #95). Auf Laufwerken,", - "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "Beim Umbenennen meldet LightCraft Dateien, die nach einem Fehler nicht zurückverschoben werden konnten (Issue #105), etwa bei einer getrennten Netzwerkfreigabe", + "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "Beim Umbenennen meldet {app} Dateien, die nach einem Fehler nicht zurückverschoben werden konnten (Issue #105), etwa bei einer getrennten Netzwerkfreigabe", "Rename Photos…": "Fotos umbenennen…", "Rename Preset": "Vorgabe umbenennen", "Rename Preview": "Umbenennungsvorschau", @@ -1283,7 +1278,7 @@ "Save as…": "Speichern unter…", "Save preset": "Vorgabe speichern", "Saving Lightroom import index…": "Lightroom-Importindex wird gespeichert …", - "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Beim Speichern von Metadaten in einer XMP-Begleitdatei einer anderen Anwendung wird diese nicht mehr ersetzt (Issue #92): LightCraft führt seine", + "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): {app} merges its": "Beim Speichern von Metadaten in einer XMP-Begleitdatei einer anderen Anwendung wird diese nicht mehr ersetzt (Issue #92): {app} führt seine", "Scale": "Skalierung", "Scan failed": "Durchsuchen fehlgeschlagen", "Scanning": "Wird durchsucht", @@ -1421,7 +1416,7 @@ "Sort": "Sortieren", "Sort by": "Sortieren nach", "Source": "Quelle", - "Source code on GitHub": "Quellcode auf GitHub", + "Source code": "Quellcode auf GitHub", "Spherical": "Sphärisch", "Spiral": "Spirale", "Split Stack": "Stapel teilen", @@ -1488,7 +1483,7 @@ "That includes the photos in the folders inside it.": "Das schließt die Fotos in den enthaltenen Unterordnern ein.", "That key can't be used as a shortcut": "Diese Taste kann nicht als Tastenkürzel verwendet werden", "The SAM 3 model is installed: Object and Describe masks are ready.": "Das SAM-3-Modell ist installiert: Objekt- und Beschreibungsmaske sind verfügbar.", - "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Der Katalog hat eine Formatversion (Issue #102). Bibliotheken älterer LightCraft-Versionen werden beim Öffnen aktualisiert; eine Bibliothek", + "The catalog has a format version (issue #102). Opening a library from an older {app} upgrades it; a library": "Der Katalog hat eine Formatversion (Issue #102). Bibliotheken älterer {app}-Versionen werden beim Öffnen aktualisiert; eine Bibliothek", "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Der Steuerport (`--control`) schließt eine Verbindung, sobald er etwas anderes als eine JSON-Anfrage erhält", "The download didn't work:": "Der Download ist fehlgeschlagen:", "The number at the end of the original name (IMG_0042 → 0042)": "Nummer am Ende des Originalnamens (IMG_0042 → 0042)", @@ -1623,7 +1618,7 @@ "a full drive) no longer counts as built and Build Smart Previews replaces it.": "oder vollen Laufwerk gilt nicht mehr als fertig und wird beim Erstellen der Smart-Vorschauen ersetzt.", "a literal %": "ein wörtliches %", "a removed photo": "einem entfernten Foto", - "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "(Issue #101). Vor einem erneuten Versuch wird das Schreibfragment entfernt; beim nächsten Start werden alle Änderungen wiederhergestellt.", + "afterwards (issue #101): the partial write is cut off before {app} retries, so the next launch replays every": "(Issue #101). Vor einem erneuten Versuch wird das Schreibfragment entfernt; beim nächsten Start werden alle Änderungen wiederhergestellt.", "again. Change the period (or turn it off) in Settings → Performance.": "wieder. Der Zeitraum lässt sich unter Einstellungen → Leistung ändern oder deaktivieren.", "against the library's originals no longer scans the whole library when nothing is at that path.": "gegen Originale durchsucht nicht mehr die gesamte Bibliothek, wenn an diesem Pfad nichts liegt.", "all": "alle", @@ -1631,18 +1626,18 @@ "and": "und", "and checks the file before using it. Masks keep their selection, so they render and export without the model.": "Vor Verwendung werden die Dateien geprüft. Masken behalten ihre Auswahl und lassen sich ohne das Modell rendern und exportieren.", "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "und alle importierten Fotos als ZIP herunter. Datei → Bibliothek aus Sicherung wiederherstellen… stellt sie wieder her; die aktuelle Bibliothek", - "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "und blockiert das Fenster nie. Es ist optional und kein Bestandteil von LightCraft, unter Metas SAM-Lizenz mit etwa 3,4 GB.", + "and never freezes the window. It is optional and not part of {app} (Meta's SAM License, about 3.4 GB): the": "und blockiert das Fenster nie. Es ist optional und kein Bestandteil von {app}, unter Metas SAM-Lizenz mit etwa 3,4 GB.", "any": "mindestens eine", "are empty": "sind leer", "aren't empty": "sind nicht leer", "as `.corrupt-
- https://raw.githubusercontent.com/storytold/lightcraft/main/docs/images/lightcraft-demo.jpg - - - - #9fd0ff - #1f64ba - - - 760 - - - keyboard - pointing - tablet - - - lightcraft - - - - - - diff --git a/packaging/linux/flatpak-bundle.sh b/packaging/linux/flatpak-bundle.sh index 6ad39ebec7..64bd56267c 100755 --- a/packaging/linux/flatpak-bundle.sh +++ b/packaging/linux/flatpak-bundle.sh @@ -1,22 +1,23 @@ #!/usr/bin/env bash -# Repackage a LightCraft Linux tarball as a single-file Flatpak bundle: +# Repackage the app's Linux tarball as a single-file Flatpak bundle (names from brand.toml): # -# $DIST/lightcraft--linux-.flatpak +# $DIST/--linux-.flatpak # # Usage: packaging/linux/flatpak-bundle.sh [--no-test] [TARBALL] # -# TARBALL defaults to $DIST/lightcraft--linux-.tar.gz from package.sh. The bundle +# TARBALL defaults to $DIST/--linux-.tar.gz from package.sh. The bundle # is built for the host architecture (x86_64 or aarch64), which must match the tarball's. # Needs flatpak, flatpak-builder and the SVG pixbuf loader (librsvg2-common) for the host's # `appstreamcli compose`; the freedesktop runtime and SDK named in the manifest are # installed per-user from Flathub. Unless --no-test, the bundle is then installed per-user and -# `lightcraft-cli --version` is run inside the sandbox as a smoke test. -# Manifest: packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml. +# ` --version` is run inside the sandbox as a smoke test. +# Manifest template: packaging/linux/flatpak/{app_id}.bundle.yml.in. set -euo pipefail # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" HERE="$ROOT/packaging/linux" -APP_ID=ai.storyteller.lightcraft +APP_ID=$BRAND_APP_ID +APP=$BRAND_BINARY FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo TEST=1 @@ -36,21 +37,21 @@ case "$ARCH" in aarch64 | arm64) ARCH=aarch64 ;; *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; esac -BASENAME="lightcraft-$VERSION-linux-$ARCH" +BASENAME="$APP-$VERSION-linux-$ARCH" TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}" [ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; } for tool in flatpak flatpak-builder; do command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; } done -echo "==> LightCraft $VERSION Flatpak bundle for $ARCH from $(basename "$TARBALL")" +echo "==> $BRAND_DISPLAY_NAME $VERSION Flatpak bundle for $ARCH from $(basename "$TARBALL")" WORK="$CARGO_TARGET_DIR/flatpak-bundle" rm -rf "$WORK" mkdir -p "$WORK/stage" tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1 -[ -x "$WORK/stage/bin/lightcraft" ] || { echo "error: $TARBALL has no bin/lightcraft" >&2; exit 1; } -cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml" +[ -x "$WORK/stage/bin/$APP" ] || { echo "error: $TARBALL has no bin/$APP" >&2; exit 1; } +brand_render "$HERE/flatpak/{app_id}.bundle.yml.in" "$WORK/$APP_ID.yml" flatpak remote-add --user --if-not-exists flathub "$FLATHUB" # --disable-rofiles-fuse: no FUSE in containers and CI runners. @@ -65,7 +66,7 @@ echo "wrote $OUT" if [ "$TEST" = 1 ]; then flatpak install --user -y --noninteractive --reinstall "$OUT" flatpak info --user "$APP_ID" - flatpak run --command=lightcraft-cli "$APP_ID" --version + flatpak run --command="$BRAND_CLI_BINARY" "$APP_ID" --version flatpak run --command=sh "$APP_ID" -c 'ls /app/share/applications /app/share/metainfo /app/share/mime/packages /app/share/icons/hicolor/scalable/apps' fi echo "==> done" diff --git a/packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml b/packaging/linux/flatpak/{app_id}.bundle.yml.in similarity index 63% rename from packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml rename to packaging/linux/flatpak/{app_id}.bundle.yml.in index 752e1edecb..7370f15858 100644 --- a/packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml +++ b/packaging/linux/flatpak/{app_id}.bundle.yml.in @@ -1,7 +1,8 @@ # Flatpak manifest for the single-file bundle attached to every GitHub release -# (lightcraft--linux-.flatpak). Build it with packaging/linux/flatpak-bundle.sh. +# (--linux-.flatpak). Build it with packaging/linux/flatpak-bundle.sh. +# A template: the {{…}} placeholders come from brand.toml. # -# It repackages the release's own Linux tarball (lightcraft--linux-.tar.gz, +# It repackages the release's own Linux tarball (--linux-.tar.gz, # built on Ubuntu 22.04 by package.sh) instead of compiling Rust inside flatpak-builder: no # network and no vendored crate sources during the build, a few seconds instead of 20+ minutes, # and byte-for-byte the same binaries as the AppImage/deb/rpm. The script unpacks the tarball @@ -9,13 +10,13 @@ # Wayland, xkbcommon and Vulkan at runtime; the freedesktop runtime has all of them, and the # GPU driver comes from its org.freedesktop.Platform.GL extension. # -# ai.storyteller.lightcraft.yml (same directory) is the from-source manifest for Flathub. Keep +# {app_id}.yml.in (same directory) is the from-source manifest for Flathub. Keep # runtime-version and finish-args identical in both (packaging-lint checks this). -id: ai.storyteller.lightcraft +id: {{app_id}} runtime: org.freedesktop.Platform runtime-version: '25.08' sdk: org.freedesktop.Sdk -command: lightcraft +command: {{binary}} finish-args: # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland @@ -24,7 +25,7 @@ finish-args: - --share=ipc # GPU access for the wgpu renderer (Vulkan, or GL as a fallback). - --device=dri - # The library lives in ~/Pictures/LightCraft Library by default, and imports usually come from + # The library lives in ~/Pictures/{{library_default}} by default, and imports usually come from # Pictures. Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and # drag-and-drop through the document portal, so picked folders elsewhere work without broad # filesystem access. @@ -32,19 +33,19 @@ finish-args: # Deliberately not granted: home/host filesystem access, and network. The network is only for # the optional SAM 3 model download (AI Object/Describe masks, docs/ai-masks.md); without it # that download fails with an error and everything else works. Users who want it run - # `flatpak override --user --share=network ai.storyteller.lightcraft`. + # `flatpak override --user --share=network {{app_id}}`. modules: - - name: lightcraft + - name: {{binary}} buildsystem: simple build-commands: - - install -Dm755 bin/lightcraft /app/bin/lightcraft - - install -Dm755 bin/lightcraft-cli /app/bin/lightcraft-cli - - install -Dm644 share/applications/ai.storyteller.lightcraft.desktop /app/share/applications/ai.storyteller.lightcraft.desktop + - install -Dm755 bin/{{binary}} /app/bin/{{binary}} + - install -Dm755 bin/{{cli_binary}} /app/bin/{{cli_binary}} + - install -Dm644 share/applications/{{app_id}}.desktop /app/share/applications/{{app_id}}.desktop # The same MIME definitions the deb/rpm install. - - install -Dm644 share/mime/packages/ai.storyteller.lightcraft.xml /app/share/mime/packages/ai.storyteller.lightcraft.xml + - install -Dm644 share/mime/packages/{{app_id}}.xml /app/share/mime/packages/{{app_id}}.xml # Already has the release's version and date substituted by package.sh. - - install -Dm644 share/metainfo/ai.storyteller.lightcraft.metainfo.xml /app/share/metainfo/ai.storyteller.lightcraft.metainfo.xml - - mkdir -p /app/share/icons /app/share/doc && cp -R share/icons/hicolor /app/share/icons/ && cp -R share/doc/lightcraft /app/share/doc/ + - install -Dm644 share/metainfo/{{app_id}}.metainfo.xml /app/share/metainfo/{{app_id}}.metainfo.xml + - mkdir -p /app/share/icons /app/share/doc && cp -R share/icons/hicolor /app/share/icons/ && cp -R share/doc/{{binary}} /app/share/doc/ sources: - type: dir path: stage diff --git a/packaging/linux/flatpak/ai.storyteller.lightcraft.yml b/packaging/linux/flatpak/{app_id}.yml.in similarity index 51% rename from packaging/linux/flatpak/ai.storyteller.lightcraft.yml rename to packaging/linux/flatpak/{app_id}.yml.in index 079c85dfd2..7f71a3894e 100644 --- a/packaging/linux/flatpak/ai.storyteller.lightcraft.yml +++ b/packaging/linux/flatpak/{app_id}.yml.in @@ -1,24 +1,26 @@ -# Flatpak manifest for LightCraft, ready for a Flathub submission. +# Flatpak manifest for {{display_name}}, ready for a Flathub submission. A template: `cargo xtask package` +# renders it, with the desktop entry, MIME type and metainfo it installs, into target/package/linux/. # # Flatpak builds run offline, so the crate sources must be vendored first. From the repo root: # +# cargo xtask package --render-only # curl -fsSLO https://raw.githubusercontent.com/flatpak/flatpak-builder-tools/master/cargo/flatpak-cargo-generator.py -# python3 flatpak-cargo-generator.py Cargo.lock -o packaging/linux/flatpak/cargo-sources.json -# flatpak-builder --user --install --force-clean build-flatpak packaging/linux/flatpak/ai.storyteller.lightcraft.yml -# flatpak run ai.storyteller.lightcraft +# python3 flatpak-cargo-generator.py Cargo.lock -o target/package/linux/flatpak/cargo-sources.json +# flatpak-builder --user --install --force-clean build-flatpak target/package/linux/flatpak/{{app_id}}.yml +# flatpak run {{app_id}} # -# For Flathub, replace the `dir` source with a `git` source pinned to a release tag and commit. +# For Flathub, replace the workspace `dir` source with a `git` source pinned to a release tag and commit. # -# ai.storyteller.lightcraft.bundle.yml (same directory) builds the .flatpak attached to every +# {app_id}.bundle.yml.in (same directory) builds the .flatpak attached to every # release from the prebuilt binaries. Keep runtime-version and finish-args identical in both # (packaging-lint checks this). -id: ai.storyteller.lightcraft +id: {{app_id}} runtime: org.freedesktop.Platform runtime-version: '25.08' sdk: org.freedesktop.Sdk sdk-extensions: - org.freedesktop.Sdk.Extension.rust-stable -command: lightcraft +command: {{binary}} finish-args: # Display: Wayland first; X11 only when no Wayland compositor is running. - --socket=wayland @@ -27,7 +29,7 @@ finish-args: - --share=ipc # GPU access for the wgpu renderer (Vulkan, or GL as a fallback). - --device=dri - # The library lives in ~/Pictures/LightCraft Library by default, and imports usually come from + # The library lives in ~/Pictures/{{library_default}} by default, and imports usually come from # Pictures. Open/Save dialogs go through the file-chooser portal (rfd's xdg-portal backend) and # drag-and-drop through the document portal, so picked folders elsewhere work without broad # filesystem access. @@ -35,35 +37,40 @@ finish-args: # Deliberately not granted: home/host filesystem access, and network. The network is only for # the optional SAM 3 model download (AI Object/Describe masks, docs/ai-masks.md); without it # that download fails with an error and everything else works. Users who want it run - # `flatpak override --user --share=network ai.storyteller.lightcraft`. + # `flatpak override --user --share=network {{app_id}}`. build-options: append-path: /usr/lib/sdk/rust-stable/bin modules: - - name: lightcraft + - name: {{binary}} buildsystem: simple build-options: env: - CARGO_HOME: /run/build/lightcraft/cargo + CARGO_HOME: /run/build/{{binary}}/cargo CARGO_NET_OFFLINE: 'true' build-commands: - cargo --offline build --release --locked -p dac-app -p dac-cli - - install -Dm755 target/release/lightcraft /app/bin/lightcraft - - install -Dm755 target/release/lightcraft-cli /app/bin/lightcraft-cli - - install -Dm644 packaging/linux/ai.storyteller.lightcraft.desktop /app/share/applications/ai.storyteller.lightcraft.desktop - - install -Dm644 packaging/linux/ai.storyteller.lightcraft.mime.xml /app/share/mime/packages/ai.storyteller.lightcraft.xml - - mkdir -p /app/share/icons && cp -R assets/app-icon/hicolor /app/share/icons/ + - install -Dm755 target/release/app /app/bin/{{binary}} + - install -Dm755 target/release/app-cli /app/bin/{{cli_binary}} + - install -Dm644 rendered/{{app_id}}.desktop /app/share/applications/{{app_id}}.desktop + - install -Dm644 rendered/{{app_id}}.mime.xml /app/share/mime/packages/{{app_id}}.xml + - install -Dm644 rendered/{{app_id}}.metainfo.xml /app/share/metainfo/{{app_id}}.metainfo.xml - >- - version=$(awk '/^\[/{p=($0=="[workspace.package]");next} p&&$1=="version"{gsub(/[" ]/,"",$3);print $3;exit}' Cargo.toml); - install -d /app/share/metainfo; - sed -e "s/@VERSION@/$version/" -e "s/@DATE@/$(date -u +%Y-%m-%d)/" - packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in > /app/share/metainfo/ai.storyteller.lightcraft.metainfo.xml + for f in assets/app-icon/hicolor/*/apps/*; do + size=$(basename "$(dirname "$(dirname "$f")")"); + install -Dm644 "$f" "/app/share/icons/hicolor/$size/apps/{{app_id}}.${f##*.}"; + done sources: + # the workspace (this file is rendered to target/package/linux/flatpak/) - type: dir - path: ../../.. + path: ../../../.. skip: - target - dist - corpus - build-flatpak - .flatpak-builder + # the rendered desktop entry, MIME type and metainfo (version and date filled in) + - type: dir + path: .. + dest: rendered - cargo-sources.json diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml.in similarity index 69% rename from packaging/linux/nfpm.yaml rename to packaging/linux/nfpm.yaml.in index 3242910a52..a7c5c15657 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml.in @@ -1,9 +1,7 @@ -# .deb and .rpm packages (nfpm: https://nfpm.goreleaser.com). Run by package.sh, which stages -# the file tree, substitutes its path for ${STAGE} (nfpm doesn't expand env vars in `src`) and -# exports VERSION and NFPM_ARCH. -# -# nfpm package -f packaging/linux/nfpm.yaml -p deb -t dist/release/ -name: lightcraft +# .deb and .rpm packages (nfpm: https://nfpm.goreleaser.com). A template: package.sh fills the {{…}} +# placeholders from brand.toml, stages the file tree, substitutes its path for ${STAGE} (nfpm +# doesn't expand env vars in `src`) and exports VERSION, NFPM_ARCH and PACKAGE_MAINTAINER. +name: {{binary}} arch: ${NFPM_ARCH} platform: linux version: ${VERSION} @@ -12,15 +10,15 @@ version_schema: semver release: 1 section: graphics priority: optional -maintainer: ${LIGHTCRAFT_MAINTAINER} -vendor: Learning Machines LLC -homepage: https://getartcraft.com/apps/app -license: MIT OR Apache-2.0 +maintainer: ${PACKAGE_MAINTAINER} +vendor: {{vendor}} +homepage: {{homepage}} +license: MIT AND Apache-2.0 description: |- Open-source, native photo library and raw developer that works the way Lightroom users expect. Import, rate, keyword and organise photos; develop raw files non-destructively with exposure, tone, color grading, masks and - presets; GPU processing (wgpu). Includes lightcraft-cli, a headless renderer + presets; GPU processing (wgpu). Includes {{cli_binary}}, a headless renderer and command runner. # The binaries link only glibc and libgcc_s directly. Windowing (X11/Wayland, xkbcommon) and the @@ -56,11 +54,11 @@ overrides: - xdg-desktop-portal contents: - - src: ${STAGE}/usr/bin/lightcraft - dst: /usr/bin/lightcraft + - src: ${STAGE}/usr/bin/{{binary}} + dst: /usr/bin/{{binary}} file_info: { mode: 0755 } - - src: ${STAGE}/usr/bin/lightcraft-cli - dst: /usr/bin/lightcraft-cli + - src: ${STAGE}/usr/bin/{{cli_binary}} + dst: /usr/bin/{{cli_binary}} file_info: { mode: 0755 } - src: ${STAGE}/usr/share/ dst: /usr/share/ diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 361a4a315d..ed7b54bf62 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -1,11 +1,12 @@ #!/usr/bin/env bash -# Build and package LightCraft for Linux ( is x86_64 or aarch64): +# Build and package the app for Linux ( is x86_64 or aarch64; and every other name +# come from brand.toml): # -# $DIST/lightcraft--linux-.AppImage any distro with glibc >= the build host's -# $DIST/lightcraft--linux-.AppImage.zsync delta updates (needs zsyncmake) -# $DIST/lightcraft--linux-.deb Debian, Ubuntu, Mint, Pop!_OS, ... -# $DIST/lightcraft--linux-.rpm Fedora, openSUSE, RHEL, ... -# $DIST/lightcraft--linux-.tar.gz plain FHS-style tree (bin/, share/) +# $DIST/--linux-.AppImage any distro with glibc >= the build host's +# $DIST/--linux-.AppImage.zsync delta updates (needs zsyncmake) +# $DIST/--linux-.deb Debian, Ubuntu, Mint, Pop!_OS, ... +# $DIST/--linux-.rpm Fedora, openSUSE, RHEL, ... +# $DIST/--linux-.tar.gz plain FHS-style tree (bin/, share/) # # Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"] # @@ -17,7 +18,9 @@ set -euo pipefail # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" HERE="$ROOT/packaging/linux" -APP_ID=ai.storyteller.lightcraft +APP_ID=$BRAND_APP_ID +APP=$BRAND_BINARY +CLI=$BRAND_CLI_BINARY SKIP_BUILD=0 FORMATS="appimage deb rpm tar" @@ -36,10 +39,10 @@ case "$ARCH" in aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;; *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; esac -export LIGHTCRAFT_MAINTAINER="${LIGHTCRAFT_MAINTAINER:-LightCraft maintainers }" -BASENAME="lightcraft-$VERSION-linux-$ARCH" +export PACKAGE_MAINTAINER="${PACKAGE_MAINTAINER:-$BRAND_VENDOR }" +BASENAME="$APP-$VERSION-linux-$ARCH" -echo "==> LightCraft $VERSION for Linux $ARCH ($FORMATS)" +echo "==> $BRAND_DISPLAY_NAME $VERSION for Linux $ARCH ($FORMATS)" if [ "$SKIP_BUILD" = 0 ]; then (cd "$ROOT" && cargo build --release --locked -p dac-app -p dac-cli) @@ -50,18 +53,15 @@ STAGE="$WORK/root" rm -rf "$WORK" # ---- stage an FHS tree (shared by every format) ------------------------------------------------- -install -Dm755 "$BIN/lightcraft" "$STAGE/usr/bin/lightcraft" -install -Dm755 "$BIN/lightcraft-cli" "$STAGE/usr/bin/lightcraft-cli" -strip "$STAGE/usr/bin/lightcraft" "$STAGE/usr/bin/lightcraft-cli" 2>/dev/null || true -install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop" -install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml" -mkdir -p "$STAGE/usr/share/metainfo" -sed -e "s/@VERSION@/$VERSION/g" -e "s/@DATE@/$LIGHTCRAFT_BUILD_DATE/g" \ - "$HERE/$APP_ID.metainfo.xml.in" >"$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml" -mkdir -p "$STAGE/usr/share/icons" -cp -R "$ROOT/assets/app-icon/hicolor" "$STAGE/usr/share/icons/" -mkdir -p "$STAGE/usr/share/doc/lightcraft" -copy_docs "$STAGE/usr/share/doc/lightcraft" +# The cargo binaries have neutral names (app, app-cli); packages carry the brand's. +stage_binaries "$BIN" "$STAGE/usr/bin" +strip "$STAGE/usr/bin/$APP" "$STAGE/usr/bin/$CLI" 2>/dev/null || true +brand_render "$HERE/{app_id}.desktop.in" "$STAGE/usr/share/applications/$APP_ID.desktop" +brand_render "$HERE/{app_id}.mime.xml.in" "$STAGE/usr/share/mime/packages/$APP_ID.xml" +brand_render "$HERE/{app_id}.metainfo.xml.in" "$STAGE/usr/share/metainfo/$APP_ID.metainfo.xml" +install_icons "$STAGE/usr/share/icons" +mkdir -p "$STAGE/usr/share/doc/$APP" +copy_docs "$STAGE/usr/share/doc/$APP" if command -v desktop-file-validate >/dev/null; then desktop-file-validate "$STAGE/usr/share/applications/$APP_ID.desktop" @@ -86,7 +86,8 @@ if has deb || has rpm; then export VERSION export NFPM_ARCH="$DEB_ARCH" # nfpm expands env vars in fields like `version` and `arch`, but not in `contents[].src`. - sed "s|\${STAGE}|$STAGE|g" "$HERE/nfpm.yaml" >"$WORK/nfpm.yaml" + brand_render "$HERE/nfpm.yaml.in" "$WORK/nfpm.brand.yaml" + sed "s|\${STAGE}|$STAGE|g" "$WORK/nfpm.brand.yaml" >"$WORK/nfpm.yaml" for fmt in deb rpm; do if has "$fmt"; then (cd "$ROOT" && nfpm package -f "$WORK/nfpm.yaml" -p "$fmt" -t "$DIST/$BASENAME.$fmt"); fi done @@ -94,12 +95,12 @@ fi # ---- AppImage ----------------------------------------------------------------------------------- if has appimage; then - APPDIR="$WORK/LightCraft.AppDir" + APPDIR="$WORK/$APP.AppDir" cp -R "$STAGE" "$APPDIR" mv "$APPDIR/usr/share/doc" "$WORK/doc-unused" - ln -s usr/bin/lightcraft "$APPDIR/AppRun" - cp "$HERE/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" - cp "$ROOT/assets/app-icon/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" + ln -s "usr/bin/$APP" "$APPDIR/AppRun" + cp "$STAGE/usr/share/applications/$APP_ID.desktop" "$APPDIR/$APP_ID.desktop" + cp "$STAGE/usr/share/icons/hicolor/256x256/apps/$APP_ID.png" "$APPDIR/$APP_ID.png" ln -s "$APP_ID.png" "$APPDIR/.DirIcon" TOOL="${APPIMAGETOOL:-$(command -v appimagetool || true)}" @@ -120,13 +121,18 @@ if has appimage; then # Update information: AppImageUpdate, AppImageLauncher and the like read it from the file and # fetch only the blocks that changed in a newer release, through the .zsync published next to # each AppImage on GitHub Releases. `latest` is the newest published release that is not a - # pre-release. A fork's builds point at its own releases through GITHUB_REPOSITORY. - REPO="${GITHUB_REPOSITORY:-storytold/lightcraft}" - UPDATE_INFO="gh-releases-zsync|${REPO%%/*}|${REPO#*/}|latest|lightcraft-*-linux-$ARCH.AppImage.zsync" + # pre-release. Builds point at the releases of GITHUB_REPOSITORY, else of brand.toml's + # repository when that is on GitHub; with neither, the AppImage has no update information. + REPO="${GITHUB_REPOSITORY:-}" + case "$BRAND_REPOSITORY" in https://github.com/*/*) REPO="${REPO:-${BRAND_REPOSITORY#https://github.com/}}" ;; esac + UPDATE_ARGS=() + if [ -n "$REPO" ]; then + UPDATE_ARGS=(-u "gh-releases-zsync|${REPO%%/*}|${REPO#*/}|latest|$APP-*-linux-$ARCH.AppImage.zsync") + fi # Extract-and-run: works without FUSE (containers, CI). The output embeds the static runtime, # so users don't need libfuse2 either. With zsyncmake on the host (CI installs the zsync # package) appimagetool also writes the .zsync, into its working directory, hence the cd. - (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream -u "$UPDATE_INFO" "$APPDIR" "$OUT") + (cd "$DIST" && ARCH="$ARCH" APPIMAGE_EXTRACT_AND_RUN=1 "$TOOL" --no-appstream ${UPDATE_ARGS[@]+"${UPDATE_ARGS[@]}"} "$APPDIR" "$OUT") echo "wrote $OUT" if [ -s "$OUT.zsync" ]; then echo "wrote $OUT.zsync" @@ -135,6 +141,6 @@ if has appimage; then fi fi -"$STAGE/usr/bin/lightcraft-cli" --version +"$STAGE/usr/bin/$CLI" --version echo "==> done" ls -lh "$DIST" diff --git a/packaging/linux/ai.storyteller.lightcraft.desktop b/packaging/linux/{app_id}.desktop.in similarity index 71% rename from packaging/linux/ai.storyteller.lightcraft.desktop rename to packaging/linux/{app_id}.desktop.in index f827c58202..74ebf3a639 100644 --- a/packaging/linux/ai.storyteller.lightcraft.desktop +++ b/packaging/linux/{app_id}.desktop.in @@ -1,12 +1,12 @@ [Desktop Entry] Type=Application -Name=LightCraft +Name={{display_name}} GenericName=Photo Library and Raw Developer -Comment=Organise photos and develop raw files, non-destructively -Exec=lightcraft %F -Icon=ai.storyteller.lightcraft +Comment={{tagline}} +Exec={{binary}} %F +Icon={{app_id}} Terminal=false Categories=Graphics;2DGraphics;RasterGraphics;Photography; Keywords=photo;raw;camera;library;develop; -StartupWMClass=ai.storyteller.lightcraft +StartupWMClass={{app_id}} MimeType=image/jpeg;image/png;image/tiff;image/webp;image/x-adobe-dng;image/x-sony-arw;image/x-canon-cr2;image/x-canon-cr3;image/x-nikon-nef;image/x-fuji-raf;image/x-olympus-orf;image/x-panasonic-rw2;image/x-panasonic-rw;image/x-pentax-pef; diff --git a/packaging/linux/{app_id}.metainfo.xml.in b/packaging/linux/{app_id}.metainfo.xml.in new file mode 100644 index 0000000000..51a2d09437 --- /dev/null +++ b/packaging/linux/{app_id}.metainfo.xml.in @@ -0,0 +1,54 @@ + + + + {{app_id}} + CC0-1.0 + MIT AND Apache-2.0 + {{display_name}} + {{tagline}} + + {{vendor}} + + +

+ {{display_name}} is a photo library and raw developer. Organise photos and develop raw files, non-destructively. +

+
+ {{app_id}}.desktop + {{app_id}} + + {{homepage}} + {{repository}}/issues + {{repository}} + + Graphics + RasterGraphics + Photography + + + photo + raw + library + develop + + + #9fd0ff + #1f64ba + + + 760 + + + keyboard + pointing + tablet + + + {{binary}} + + + + + +
diff --git a/packaging/linux/ai.storyteller.lightcraft.mime.xml b/packaging/linux/{app_id}.mime.xml.in similarity index 100% rename from packaging/linux/ai.storyteller.lightcraft.mime.xml rename to packaging/linux/{app_id}.mime.xml.in diff --git a/packaging/macos/Info.plist.in b/packaging/macos/Info.plist.in index f571d18eda..3a935b66ad 100644 --- a/packaging/macos/Info.plist.in +++ b/packaging/macos/Info.plist.in @@ -1,19 +1,19 @@ - + CFBundleDevelopmentRegion en CFBundleDisplayName - LightCraft + {{display_name}} CFBundleName - LightCraft + {{short_name}} CFBundleExecutable - LightCraft + {{display_name}} CFBundleIdentifier - ai.storyteller.lightcraft + {{app_id}} CFBundleInfoDictionaryVersion 6.0 CFBundlePackageType @@ -21,15 +21,15 @@ CFBundleSignature ???? CFBundleShortVersionString - @SHORT_VERSION@ + {{short_version}} CFBundleVersion - @SHORT_VERSION@ - LightCraftVersion - @VERSION@ - LightCraftBuildCommit - @BUILD_SHA@ + {{short_version}} + AppFullVersion + {{version}} + AppBuildCommit + {{build_sha}} CFBundleIconFile - LightCraft + {{display_name}} LSApplicationCategoryType public.app-category.graphics-design + UTExportedTypeDeclarations UTTypeIdentifier - ai.storyteller.lightcraft.document + {{app_id}}.document UTTypeDescription - LightCraft Document + {{display_name}} Document UTTypeConformsTo public.data public.image UTTypeIconFile - LightCraft + {{display_name}} UTTypeTagSpecification public.filename-extension pcraft public.mime-type - application/x-lightcraft + application/x-{{binary}} @@ -90,7 +90,7 @@ UTTypeIdentifier - ai.storyteller.lightcraft.qoi + {{app_id}}.qoi UTTypeDescription QOI Image UTTypeConformsTo diff --git a/packaging/macos/dmg/README.md b/packaging/macos/dmg/README.md index 2b2e1a1338..2eab1a3b92 100644 --- a/packaging/macos/dmg/README.md +++ b/packaging/macos/dmg/README.md @@ -7,17 +7,17 @@ Finder scripting on CI, nothing extra installed in the signing job). | File | What | |---|---| -| `background.svg` | Source of the background: the app icon (`assets/app-icon/lightcraft.svg`, linked, not copied) cropped as a cover on the LightCraft colour field (`#f2a516`), Ink and Paper. Its text (Inter, JetBrains Mono) is outlined, so rendering it needs no fonts. | +| `background.svg` | Source of the background: the app icon (`brand/icon.svg`, linked, not copied) cropped as a cover on the brand colour field (`#f2a516`), Ink and Paper. Its text (Inter, JetBrains Mono) is outlined, so rendering it needs no fonts. | | `background.tiff` | The background at 1x (660 × 400 px, 72 dpi) and 2x (1320 × 800 px, 144 dpi) in one HiDPI TIFF (Deflate, sRGB). Goes to `.background/background.tiff`. | -| `dmg-layout.DS_Store` | Finder's view settings for the volume: window size, icon size 128, LightCraft.app at (326, 205), `Applications` at (574, 205), and the background. Goes to `.DS_Store` in the image; named so it isn't mistaken for (or ignored like) a Finder-generated `.DS_Store`. | +| `dmg-layout.DS_Store` | Finder's view settings for the volume: window size, icon size 128, `.app` at (326, 205), `Applications` at (574, 205), and the background. Goes to `.DS_Store` in the image; named so it isn't mistaken for (or ignored like) a Finder-generated `.DS_Store`. | | `generate.py` | Writes `background.tiff` and `dmg-layout.DS_Store` from the SVG and the layout above. | ## The volume name has no version -The mounted volume is called `LightCraft`, not `LightCraft `. The layout points at the background +The mounted volume is called `` (brand.toml), not ` `; after changing the name, rerun `generate.py` so `dmg-layout.DS_Store` matches. The layout points at the background through an alias that includes the volume name, and Finder resolves it by that name: with a versioned name the window keeps its size and icon positions but shows no background (tested). -The DMG file name (`lightcraft--macos-.dmg`) still carries the version. +The DMG file name (`--macos-.dmg`) still carries the version. ## Rules diff --git a/packaging/macos/dmg/background.svg b/packaging/macos/dmg/background.svg index 7dd108ea6a..0c0c59bf33 100644 --- a/packaging/macos/dmg/background.svg +++ b/packaging/macos/dmg/background.svg @@ -6,7 +6,7 @@ - + diff --git a/packaging/macos/dmg/generate.py b/packaging/macos/dmg/generate.py index 35305238b0..22b891f9ab 100644 --- a/packaging/macos/dmg/generate.py +++ b/packaging/macos/dmg/generate.py @@ -23,12 +23,16 @@ from PIL import Image, ImageCms HERE = os.path.dirname(os.path.abspath(__file__)) -# Keep in sync with package.sh (volume name) and README.md (layout). -VOLUME = "LightCraft" +# The volume and app names are brand.toml's display_name (package.sh uses the same); rerun this +# script after changing it, since dmg-layout.DS_Store records both. +import tomllib + +with open(os.path.join(HERE, "../../../brand.toml"), "rb") as _f: + VOLUME = tomllib.load(_f)["product"]["display_name"] WIDTH, HEIGHT = 660, 400 # window content, pt; the background's 1x size TITLE_BAR = 32 ICON_SIZE = 128 -ICONS = {"LightCraft.app": (326, 205), "Applications": (574, 205)} +ICONS = {f"{VOLUME}.app": (326, 205), "Applications": (574, 205)} # A fixed date for the alias and the colour profile. Finder never matches it against the image # (each build is a new volume): it finds the background by volume name and path. FIXED_DATE = datetime.datetime(2026, 10, 8, tzinfo=datetime.timezone.utc) diff --git a/packaging/macos/entitlements.plist b/packaging/macos/entitlements.plist index 8b8cd128f9..c8bddcfb37 100644 --- a/packaging/macos/entitlements.plist +++ b/packaging/macos/entitlements.plist @@ -1,6 +1,6 @@ - diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index ef966034e4..53d16168b3 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -18,8 +18,8 @@ if [ -z "${APPLE_CERTIFICATE:-}" ]; then fi TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" -KEYCHAIN="$TMP/lightcraft-signing.keychain-db" -CERT="$TMP/lightcraft-signing.p12" +KEYCHAIN="$TMP/app-signing.keychain-db" +CERT="$TMP/app-signing.p12" KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}" trap 'rm -f "$CERT"' EXIT diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 569f9b0750..38240b6707 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash # Build, sign and (optionally) notarize the macOS release artifacts: # -# $DIST/lightcraft--macos-.dmg LightCraft.app on a drag-to-Applications DMG -# $DIST/lightcraft-cli--macos-.zip the headless CLI +# $DIST/--macos-.dmg .app on a drag-to-Applications DMG +# $DIST/--macos-.zip the headless CLI +# (names from brand.toml) # # Usage: packaging/macos/package.sh [--arch universal|aarch64|x86_64] [--skip-build] # @@ -37,11 +38,12 @@ esac # Keep in sync with LSMinimumSystemVersion in Info.plist.in. export MACOSX_DEPLOYMENT_TARGET=11.0 IDENTITY="${MACOS_SIGN_IDENTITY:--}" -SHORT_VERSION="${VERSION%%-*}" WORK="$CARGO_TARGET_DIR/macos-package" -APP="$WORK/LightCraft.app" -DMG="$DIST/lightcraft-$VERSION-macos-$ARCH.dmg" -CLI_ZIP="$DIST/lightcraft-cli-$VERSION-macos-$ARCH.zip" +NAME="$BRAND_DISPLAY_NAME" +CLI="$BRAND_CLI_BINARY" +APP="$WORK/$NAME.app" +DMG="$DIST/$BRAND_BINARY-$VERSION-macos-$ARCH.dmg" +CLI_ZIP="$DIST/$CLI-$VERSION-macos-$ARCH.zip" NOTARIZE=0 if [ "$IDENTITY" = "-" ]; then @@ -52,7 +54,7 @@ else warn "macOS: APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID incomplete; signed but not notarized" fi -echo "==> LightCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE" +echo "==> $NAME $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: $NOTARIZE" # ---- build ------------------------------------------------------------------------------------- if [ "$SKIP_BUILD" = 0 ]; then @@ -63,11 +65,13 @@ fi rm -rf "$WORK" mkdir -p "$WORK/bin" -for bin in lightcraft lightcraft-cli; do +# neutral cargo names (app, app-cli) -> brand names +for pair in "app:$BRAND_BINARY" "app-cli:$CLI"; do + bin="${pair%%:*}" out="${pair#*:}" inputs=() for t in "${TARGETS[@]}"; do inputs+=("$CARGO_TARGET_DIR/$t/release/$bin"); done - lipo -create -output "$WORK/bin/$bin" "${inputs[@]}" - lipo -info "$WORK/bin/$bin" + lipo -create -output "$WORK/bin/$out" "${inputs[@]}" + lipo -info "$WORK/bin/$out" done # ---- signing helpers --------------------------------------------------------------------------- @@ -97,15 +101,14 @@ notarize() { fi } -# ---- LightCraft.app ---------------------------------------------------------------------------- +# ---- .app ---------------------------------------------------------------------------- echo "==> assembling $APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" # Executable and icon carry the display name (CFBundleExecutable / CFBundleIconFile). -cp "$WORK/bin/lightcraft" "$APP/Contents/MacOS/LightCraft" -cp "$ROOT/assets/app-icon/lightcraft.icns" "$APP/Contents/Resources/LightCraft.icns" -sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \ - -e "s/@BUILD_SHA@/${LIGHTCRAFT_BUILD_SHA:-unknown}/g" \ - "$HERE/Info.plist.in" >"$APP/Contents/Info.plist" +cp "$WORK/bin/$BRAND_BINARY" "$APP/Contents/MacOS/$NAME" +ICNS="$(ls "$ROOT"/assets/app-icon/*.icns | head -n 1)" +cp "$ICNS" "$APP/Contents/Resources/$NAME.icns" +brand_render "$HERE/Info.plist.in" "$APP/Contents/Info.plist" plutil -lint "$APP/Contents/Info.plist" printf 'APPL????' >"$APP/Contents/PkgInfo" # Licences (and the craft-fonts font licences when built with CRAFT_FONTS_DIR) inside the bundle. @@ -114,13 +117,13 @@ copy_docs "$APP/Contents/Resources/Licenses" # Sign inside-out: nested code first, then the bundle itself (no --deep on the final signature). # Today the only nested code is the main executable; frameworks/helpers would be signed here too. -sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/MacOS/LightCraft" +sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/MacOS/$NAME" sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP" codesign --verify --strict --deep --verbose=2 "$APP" if [ "$NOTARIZE" = 1 ]; then - ditto -c -k --keepParent "$APP" "$WORK/LightCraft-notarize.zip" - notarize "$WORK/LightCraft-notarize.zip" + ditto -c -k --keepParent "$APP" "$WORK/app-notarize.zip" + notarize "$WORK/app-notarize.zip" xcrun stapler staple "$APP" xcrun stapler validate "$APP" spctl --assess --type execute -vvv "$APP" @@ -130,7 +133,7 @@ fi echo "==> building $DMG" STAGE="$WORK/dmg" mkdir -p "$STAGE" -ditto "$APP" "$STAGE/LightCraft.app" +ditto "$APP" "$STAGE/$NAME.app" ln -s /Applications "$STAGE/Applications" # Finder window layout: background, icon size and positions (packaging/macos/dmg/README.md). mkdir -p "$STAGE/.background" @@ -140,7 +143,7 @@ rm -f "$DMG" "$WORK/raw.dmg" # makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`, # which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions. # The volume name has no version: .DS_Store finds the background through an alias that includes it. -hdiutil makehybrid -hfs -hfs-volume-name "LightCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE" +hdiutil makehybrid -hfs -hfs-volume-name "$NAME" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE" hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" rm -f "$WORK/raw.dmg" sign "$DMG" @@ -154,17 +157,17 @@ fi # ---- CLI --------------------------------------------------------------------------------------- echo "==> building $CLI_ZIP" -CLI_DIR="$WORK/lightcraft-cli-$VERSION-macos-$ARCH" +CLI_DIR="$WORK/$CLI-$VERSION-macos-$ARCH" mkdir -p "$CLI_DIR" -cp "$WORK/bin/lightcraft-cli" "$CLI_DIR/" +cp "$WORK/bin/$CLI" "$CLI_DIR/" copy_docs "$CLI_DIR" -sign --options runtime "$CLI_DIR/lightcraft-cli" -codesign --verify --strict --verbose=2 "$CLI_DIR/lightcraft-cli" +sign --options runtime "$CLI_DIR/$CLI" +codesign --verify --strict --verbose=2 "$CLI_DIR/$CLI" rm -f "$CLI_ZIP" ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP" # A bare Mach-O can't carry a stapled ticket; Gatekeeper looks the notarization up online. if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi -"$WORK/bin/lightcraft-cli" --version +"$WORK/bin/$CLI" --version echo "==> done" ls -lh "$DMG" "$CLI_ZIP" diff --git a/packaging/web/README.md b/packaging/web/HOSTING.md.in similarity index 91% rename from packaging/web/README.md rename to packaging/web/HOSTING.md.in index e938819345..9622fc7589 100644 --- a/packaging/web/README.md +++ b/packaging/web/HOSTING.md.in @@ -1,4 +1,4 @@ -# Hosting LightCraft for the web +# Hosting {{display_name}} for the web > **Experimental.** The browser version keeps its library (catalog and imported photos) in the > browser's own storage for the site. Browsers may clear that storage (site data cleared, @@ -6,8 +6,8 @@ > keep their original photos elsewhere and to use **File ▸ Back Up Library…**. See > `docs/web.md` in the source for details. -`lightcraft-web-.zip` (from the GitHub release, or `packaging/web/package.sh`) holds a -static site in `lightcraft-web-/`: +`{{binary}}-web-.zip` (from the GitHub release, or `packaging/web/package.sh`) holds a +static site in `{{binary}}-web-/`: | File | What it is | |---|---| @@ -15,7 +15,7 @@ static site in `lightcraft-web-/`: | `dac_web.js` | wasm-bindgen glue (generated, ES module) | | `dac_web_bg.wasm` | The app, about 13 MB (about 3 MB with brotli) | | `worker.js` | Starts the render workers (each runs the same module) | -| `lightcraft_zh_hans.otf` | Simplified Chinese UI font, included when built with `CRAFT_FONTS_DIR` | +| `app_zh_hans.otf` | Simplified Chinese UI font, included when built with `CRAFT_FONTS_DIR` | | `OFL-noto-sans-cjk-sc.txt` | The bundled Chinese font's SIL Open Font License | | `*.gz`, `*.br` | Precompressed copies of the files above (optional to serve) | | `_headers`, `.htaccess` | Sample header rules for Netlify/Cloudflare Pages and Apache | @@ -25,7 +25,7 @@ There is no server-side code. Upload the folder's contents anywhere that serves ## Any path works All URLs in `index.html` are relative, so the site works at a domain root -(`https://example.com/`), under a prefix (`https://example.com/tools/lightcraft/`) and from a +(`https://example.com/`), under a prefix (`https://example.com/tools/{{binary}}/`) and from a CDN bucket. ## Required server settings @@ -52,7 +52,7 @@ CDN bucket. nginx example: ```nginx -location /lightcraft/ { +location /{{binary}}/ { types { application/wasm wasm; text/javascript js; text/html html; } gzip_static on; # serves the .gz copies # brotli_static on; # with ngx_brotli: serves the .br copies @@ -75,8 +75,8 @@ One tab at a time can have the library open; a second tab shows a message instea ```html
Editing The Great Wave off Kanagawa with type layers and adjustment layers