* Use chrono for UTC/calendar conversion instead of hand-rolled copies Replaces the hand-rolled Howard Hinnant civil/days-from-civil copies and the leap-year/days-in-month table with chrono as a workspace dependency: - apps/lightcraft/src/logging.rs: timestamp (log line time) - crates/catalog/src/dates.rs: civil + weekday - crates/catalog/src/stacks.rs: iso_seconds (date -> seconds) - crates/engine/src/cmd/face_recognize.rs: capture_secs (native only) - crates/meta/src/datetime.rs: DateTime::unix_seconds and the valid() day check Output formats and semantics are unchanged (pre-epoch clamp, day rollover that normalize_iso relies on, leap-second tolerance, EXIF/XMP parsing). chrono uses only std, so wasm stays safe. AGENTS.md/CLAUDE.md now say time goes through chrono. Follow-up to storytold/cadcraft#1785 * Never panic on a clock outside chrono's range; keep 5-digit years unprefixed chrono's From<SystemTime> unwraps, so a clock past year 262143 panicked inside the logger. timestamp now uses the fallible DateTime::from_timestamp and reads such a clock as the epoch, keeping the old pre-epoch clamp. timestamp and catalog's civil write years past 9999 with {:04} like the old {y:04} did, because %Y would add a '+'. Tests cover the out-of-range and 5-digit-year cases. * Don't build an unrepresentable SystemTime in the far-future test UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows' SystemTime and panicked before timestamp() ran, failing the Windows test job. Build it with checked_add and only assert where it exists. * time: preserve old out-of-range date conversion (no epoch fallback) `DateTime::from_timestamp(...).unwrap_or_default()` silently reset any instant past chrono's year range to 1970-01-01, changing output the old hand-rolled Howard-Hinnant code converted exactly (e.g. 10_000_000_000_000 s was 318857-05-20 17:46:40, not the epoch). Restore the old conversion domain with a cycle-based helper that routes the calendar through chrono (the calendar repeats every 400 years = 146_097 days, so month/day/time come from chrono and only the year is carried in i128, keeping every i64 exact). - apps/lightcraft/src/logging.rs: `timestamp` via `utc_parts`; keep the pre-1970 epoch clamp; u64 secs past i64::MAX saturate, not epoch. - crates/catalog/src/dates.rs: `civil` cycle-based (the callers stay inside 0000-9999, but a far clock no longer resets to the epoch). Pinned old outputs in tests: logging u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z (was epoch), 10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z civil 10_000_000_000_000 -> 318857-05-20T17:46:40, -10_000_000_000_000 -> -314918-08-13T06:13:20, i64 extremes * Don't build a year-318857 SystemTime unchecked in the logging test Windows' SystemTime only reaches about year 30828, so UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before timestamp() ran and failed the Windows test job. Build it with checked_add and assert where the platform can hold it. The year-10000 case fits everywhere and stays unconditional. * Name the 400-year range extension as the one exception to the time rule The rule said never to hand-roll calendar math, but the range fix (shifting by whole 400-year Gregorian cycles so dates past chrono's year range keep their old output) is cycle arithmetic, so the rule and the code disagreed. The rule now names that exception: the library still computes month, day and time, and only whole cycles are carried in the year. * Say the library computes month and day; time-of-day is plain arithmetic Narrows the range-extension wording: the date library computes the calendar fields, and splitting seconds into hours, minutes and seconds is ordinary arithmetic, which is what the code does.
17 KiB
LightCraft — 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.
Start every session here
- Read
plan/STATUS.md(current milestone, next unchecked task, blockers). - Read the task in
plan/execution-plan.md§3, the relevant section ofplan/architecture.md, and the README/docs of the crate you touch. Behaviour/visual reference:plan/lightroom/(incl.10-observed-ui.md+ screenshots). - Know where we stand: read
ROADMAP.md(stage, headline numbers, every dimension) and, for method and evidence,docs/target-app-parity.md. The checklist counts features that exist; the real gaps are quality and coverage: camera colour calibration, CR3 / compressed raws, per-model verification, render fidelity against Lightroom, AI models, HDR / video / Classic modules (raw detail:docs/raw-parity.md). A ✅ row is not proof of parity — if you find a ✅ feature that is wrong or incomplete, downgrade it to 🟡 with a note. - Pick work from
docs/gaps.md(ranked by user impact;Bmarks beta blockers). The row-by-row tracker isdocs/parity-checklist.md(one row per feature, menu item and shortcut). When you land a feature, update its row(s) and the gap entry in the same commit, and the numbers inROADMAP.md/docs/target-app-parity.mdwhen a listed gap closes (bump each doc's Last updated line and revision history, per craftrulesstandards/progress-docs.md);cargo xtask parity(part ofci) checks that everycmd:/ctl:id and path the tracker cites still exists, andcargo xtask parity --writerefreshes its summary. - 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).
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).
Resuming after a crash / another session: check git status on main, git worktree list, and each worktree's
git log main..HEAD + git status for unmerged commits or uncommitted work before starting anything new. Commit small
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
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
(storytold/craftrules).
- Non-test code never panics: no
unwrap(),expect(),panic!,unreachable!,todo!,unimplemented!, and nounsafe. Return the crate's error type throughResultand?; useok_or(..)?,let … else,if let, or a fallback (unwrap_or…) only where it can't silently corrupt a document. An unfinished feature returns an "unsupported" error or is disabled. Sole exception: a provably infallible literal, as#[allow(clippy::expect_used)].expect("why it can't fail").
unsafelives only incrates/sysmem(one FFI call,malloc_zone_pressure_relief, that returns freed allocator pages to macOS after raw decodes). Every other production crate root has#![forbid(unsafe_code)]. A new unsafe need goes in an isolated, well-tested helper crate like it:// SAFETY:on every block, a safe API, a safe fallback where possible, and a line here naming it.- Input-derived numbers are hostile:
get()instead of[i]/[a..b]for offsets from files, users, agents or arithmetic on them; checked/saturating math for lengths, offsets and counts; no division by zero, NaN/inf or negative casts tousize; cap allocations sized by input; slice strings only at char boundaries. - Bound recursion (depth limits or seen-sets: IFD chains, nested metadata, collections). Don't cascade:
lock().unwrap_or_else(PoisonError::into_inner)or an error; worker-thread joins areResults. - Last-resort guard: a panic hook plus
catch_unwindaround command dispatch and import/export turns an escaped panic into an error dialog and keeps the document. It's a safety net, not a licence; keeppanic = "unwind"on native. - Prove it: every crash fix lands with a small synthetic regression test that panicked before the fix.
- Enforced by clippy: root
clippy.tomlallows unwrap/expect/panic/indexing in tests only, and every production crate root (lib.rs, each binary'smain.rs) carries#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]. Not[workspace.lints]: those would also hit integration tests, examples and benches. New crates start with the attribute.
Non-negotiables
- Clean-room. Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, profiles (DCP), lens profiles (LCP), camera matrices, fonts. Observation of the installed Lightroom is read-only (it syncs the user's personal library: never import/edit/rate/delete there). Never copy GPL/LGPL/AGPL code (darktable, RawTherapee, ART, LibRaw, rawspeed, rawloader, rawler, lensfun, dcraw-derived GPL code…).
- Pure Rust in the product. No C/C++ dependencies.
- Layering (
plan/architecture.md§3, enforced bycargo xtask layers): nothing below L5 depends on egui/eframe/winit/rfd. - Everything is a command (
crates/engine): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id. Every slider is adevelopcontrol spec. - Resolution independence: settings use normalized image coordinates and relative radii; previews and exports must match.
- Time goes through
chrono(workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic). - 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.
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 Adobe-published icon sets, sample photos, colour profiles or LUTs — even when OFL/MIT. The UI font is Inter (OFL); Japanese and Chinese fonts come from craft-fonts (below). One exception (maintainer decision, 2026-10-07): Noto Sans/Serif CJK (Google-branded, OFL, co-developed with Adobe as Source Han) is allowed via craft-fonts for Chinese 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 indocs/brand/LICENSE-brand.txt. Use them only unmodified and never redraw, recolour or derive from them. - 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, 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 uses it as the optional build inputCRAFT_FONTS_DIR:git clone https://github.com/storytold/craft-fonts ../craft-fontsthenCRAFT_FONTS_DIR=../craft-fonts cargo run -p lightcraft(or any cargo/xtask command).crates/engine/build.rsembeds the manifest's fonts aslightcraft_engine::CRAFT_FONTS(wasm32: BIZ UDPGothic Regular only);cargo xtask webships 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,CRAFT_FONTSis empty: everything builds, tests and runs, but Chinese and Japanese text lack glyphs. Releases always build with it (release.yml,CRAFT_FONTS_REQUIRED=1) and ship the fonts' OFL licences. Tests that need these fonts skip without it; the FreeBSD CI job runs them with it. Rules: craftrulesstandards/fonts.md. - Icons drawn in code (e.g.
crates/ui-egui/src/icons.rs) are original work and are recorded inassets/ATTRIBUTION.mdas such; do not trace them from Adobe icons. - Demo/test images: generated procedurally by
lightcraft-scenes, or CC0 downloads kept in the gitignoredcorpus/with their source recorded. Screenshots of Adobe apps live only in the gitignoredplan/and are never committed or published. - Enforced:
cargo xtask assets(inci) fails when an image/icon/font/sound/video/raw/ICC/XMP file is not matched by a path pattern in the first column ofassets/ATTRIBUTION.md, when a referenced licence file is missing, when a file name suggests Adobe material, or when an Adobe profile/template format (.dcp,.lcp,.lrtemplate, …) appears. Never weaken this check to make a commit pass — fix the attribution or remove the asset. - When in doubt about an asset's licence: don't use it.
Running and looking at the app
cargo run --release -p lightcraft -- --control 7980opens the desktop app with the JSON-lines control server (seedocs/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 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 withplan/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, one per line; seedocs/control-protocol.md→ Headless rendering). In a running app useui.screenshot {"path": …, "headless": true}; windowed screenshots fall back to headless after 2 s. - MCP:
lightcraft-cli mcp(headless,--demofor the procedural library) orlightcraft-cli mcp --connect(drives the running app). Seedocs/mcp.md. Quick non-UI checks:lightcraft-cli render in.jpg -o out.jpg --set light.exposure=1. - Export goes through
lightcraft_engine::export(one encoder for app, CLI, MCP and web); UI-only commands live incrates/ui-egui/src/menus.rs. - Shell gotcha:
mv/cpare aliased interactive here — use/bin/mv -f//bin/cp -f. - Parallel agents: separate git worktrees and
CARGO_TARGET_DIR=target/agent-<name>; each agent uses its own control port (pick one in 18000–19999, never the default 7980) and its own scratch subfolder (<scratch>/<agent-name>/) — neverrm -rfshared paths; delete your target dir when done (disk is shared); keep everyCargo.tomlvalid at all times (thecrates/*glob means one broken manifest breaks everyone). - Test corpora:
cargo xtask corpus --downloadintocorpus/(gitignored, CC0 only). Never commit media. - Shared real-file test corpora (Photoshop-authored PSDs, etc.) live in
storytold/photocraft-corpus, explained in craftrulesstandards/test-corpora.md. Never commit large binary fixtures to this repo; fetch them pinned by commit and sha256-verified, as PhotoCraft does withcargo xtask corpus.
Testing & performance (do this often)
- Unit/property tests next to the code; end-to-end tests drive real binaries (
crates/mcp/tests/e2e.rs,apps/lightcraft-cli/tests/). New features need at least one test that would fail without them. - Run the app after every user-visible change: launch with
--control 7980, drive it with a JSON-lines script (docs/showcase/run.py file.jsonl), takeui.screenshot, and look at it. Checkui.inspect→perf. - Benchmarks:
cargo xtask bench(24 MP raw from corpus; CPU and GPU columns) appends totarget/bench/history.jsonland flags CPU-time regressions > 20 % vs the previous run (--strictto fail). Run it before and after perf work. - Measure, don't guess:
LIGHTCRAFT_PROFILE=1prints per-stage pipeline timings to stderr; time CLI renders with/usr/bin/time. Record numbers inplan/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 (
lightcraft-scenes) or CC0 downloads in the gitignoredcorpus/. - A test that can't run here (no GPU, corpus file, craft-fonts face, model) calls
lightcraft_testkit::skip!(Gpu, "why")before it returns, not a bareeprintln!.cargo xtask cithen lists the skips and the machine's GPU adapters and data, and writestarget/ci/report.jsonandtarget/ci/summary.md(Markdown for a PR).
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); testkit = test helpers
(dev-dependency only). 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; CI only
evaluates it (.github/workflows/nix.yml), so the build may lag behind the workspace; see README → Quick start.
Contributor credits (About window)
- About ▸ Contributors/Models are compiled into the binary from
contributors/contributors.json(commit stats; generated, never hand-edit) andcontributors/people.toml(names people chose for themselves). Seedocs/contributors.md. - Agents working for a contributor: when you prepare a PR, check whether your human's GitHub
username has a
[people.<username>]entry incontributors/people.toml. If not, ask them once whether they want to be credited by more than their username: a real name, a display name, and/or their public GitHub profile name (sync_github_name = true). If yes, add only their own entry (copy the template at the top of the file, or runpython3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name) and include it in their PR, committed as them. If no, change nothing: they are credited as@usernameanyway. - Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit
contributors.json. - Maintainers refresh the stats with
python3 ../../craftrules/scripts/contributors.py .(it also re-verifies who wrote eachpeople.tomlentry;--checkonly verifies).